From a9c7303743b5787e42f2a1e4960f3b1f46b43c0e Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 3 Nov 2021 08:45:48 +0100 Subject: [PATCH 001/509] Contrary to EWS docs, actual min value is 5. Fixes #1019 --- exchangelib/properties.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exchangelib/properties.py b/exchangelib/properties.py index bae116e2..7134f0cf 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -717,7 +717,7 @@ class FreeBusyViewOptions(EWSElement): time_window = EWSElementField(value_cls=TimeWindow, is_required=True) # Interval value is in minutes - merged_free_busy_interval = IntegerField(field_uri='MergedFreeBusyIntervalInMinutes', min=6, max=1440, default=30, + merged_free_busy_interval = IntegerField(field_uri='MergedFreeBusyIntervalInMinutes', min=5, max=1440, default=30, is_required=True) requested_view = ChoiceField(field_uri='RequestedView', choices={Choice(c) for c in REQUESTED_VIEWS}, is_required=True) # Choice('None') is also valid, but only for responses From e5b162acc836d558467d85b2835b8f79edd9a3af Mon Sep 17 00:00:00 2001 From: Tom Milligan Date: Wed, 3 Nov 2021 07:56:30 +0000 Subject: [PATCH 002/509] Support datetime.timezone to EWSTimeZone conversion (#1017) --- exchangelib/ewsdatetime.py | 6 ++++++ tests/test_ewsdatetime.py | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/exchangelib/ewsdatetime.py b/exchangelib/ewsdatetime.py index 283a519b..d9fdd30e 100644 --- a/exchangelib/ewsdatetime.py +++ b/exchangelib/ewsdatetime.py @@ -250,6 +250,11 @@ def from_ms_id(cls, ms_id): def from_pytz(cls, tz): return cls(tz.zone) + @classmethod + def from_datetime(cls, tz): + """Convert from a standard library `datetime.timezone` instance.""" + return cls(tz.tzname(None)) + @classmethod def from_dateutil(cls, tz): # Objects returned by dateutil.tz.tzlocal() and dateutil.tz.gettz() are not supported. They @@ -272,6 +277,7 @@ def from_timezone(cls, tz): return { cls.__module__.split('.')[0]: lambda z: z, 'backports': cls.from_zoneinfo, + 'datetime': cls.from_datetime, 'dateutil': cls.from_dateutil, 'pytz': cls.from_pytz, 'zoneinfo': cls.from_zoneinfo, diff --git a/tests/test_ewsdatetime.py b/tests/test_ewsdatetime.py index affbf00c..f19f3e76 100644 --- a/tests/test_ewsdatetime.py +++ b/tests/test_ewsdatetime.py @@ -102,6 +102,10 @@ def test_from_timezone(self): EWSTimeZone('UTC'), EWSTimeZone.from_timezone(dateutil.tz.UTC) ) + self.assertEqual( + EWSTimeZone('UTC'), + EWSTimeZone.from_timezone(datetime.timezone.utc) + ) def test_localize(self): # Test some corner cases around DST From 936c134c2724b090c63f18f1b3fbee6fae7a2d8b Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 3 Nov 2021 09:01:43 +0100 Subject: [PATCH 003/509] tzlocal apparently can return all sorts of timezone implementations. Just unwrap the object and try again. --- exchangelib/ewsdatetime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exchangelib/ewsdatetime.py b/exchangelib/ewsdatetime.py index d9fdd30e..5e378cfc 100644 --- a/exchangelib/ewsdatetime.py +++ b/exchangelib/ewsdatetime.py @@ -281,7 +281,7 @@ def from_timezone(cls, tz): 'dateutil': cls.from_dateutil, 'pytz': cls.from_pytz, 'zoneinfo': cls.from_zoneinfo, - 'pytz_deprecation_shim': lambda z: cls.from_zoneinfo(z.unwrap_shim()) + 'pytz_deprecation_shim': lambda z: cls.from_timezone(z.unwrap_shim()) }[tz_module](tz) except KeyError: raise TypeError('Unsupported tzinfo type: %r' % tz) From cf9cf70d2f3692518f5b10720bab18b3d1c8c2af Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 8 Nov 2021 09:15:21 +0100 Subject: [PATCH 004/509] 3.10 is out. Bump tested versions Remove 3.6 which is EoL in 2 months. --- .github/workflows/python-package.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index d958d529..c3c44c46 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -29,10 +29,10 @@ jobs: needs: pre_job strategy: matrix: - python-version: [3.6, 3.7, 3.8, 3.9, 3.10-dev] + python-version: [3.7, 3.8, 3.9, 3.10] include: # Allow failure on Python dev - e.g. Cython install regularly fails - - python-version: 3.10-dev + - python-version: 3.11-dev allowed_failure: true max-parallel: 1 @@ -92,7 +92,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: '3.9' + python-version: '3.10' - name: Unencrypt secret file env: From 9e729316703e9de6253057c0ed21d10b96de06df Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 14 Nov 2021 09:27:04 +0100 Subject: [PATCH 005/509] Quote version numbers Otherwise, 3.10 becomes 3.1 --- .github/workflows/python-package.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index c3c44c46..f03617a8 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -29,10 +29,10 @@ jobs: needs: pre_job strategy: matrix: - python-version: [3.7, 3.8, 3.9, 3.10] + python-version: ['3.7', '3.8', '3.9', '3.10'] include: # Allow failure on Python dev - e.g. Cython install regularly fails - - python-version: 3.11-dev + - python-version: "3.11-dev" allowed_failure: true max-parallel: 1 From f5ddc2494203b19996acb7d185275821229ee288 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 16 Nov 2021 21:56:55 +0100 Subject: [PATCH 006/509] Fix log message --- exchangelib/folders/collections.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exchangelib/folders/collections.py b/exchangelib/folders/collections.py index 0511b74a..d0e5c81b 100644 --- a/exchangelib/folders/collections.py +++ b/exchangelib/folders/collections.py @@ -186,8 +186,8 @@ def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order query_string = None log.debug( 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', - self.folders, self.account, + self.folders, shape, depth, additional_fields, From 554628f3aa2c00317b439745d6ecfa4a1fc9f2d5 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 16 Nov 2021 21:59:08 +0100 Subject: [PATCH 007/509] Make logic around stopping paging based on returned paging values easier to reason about. Allow offset value without an item count. Refs #1022 --- exchangelib/services/common.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index e7d54452..978fe8aa 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -713,16 +713,26 @@ def _paged_call(self, payload_func, max_items, expected_message_count, **kwargs) @staticmethod def _get_paging_values(elem): """Read paging information from the paging container element.""" - is_last_page = elem.get('IncludesLastItemInRange').lower() in ('true', '0') - offset = elem.get('IndexedPagingOffset') - if offset is None and not is_last_page: - log.debug("Not last page in range, but Exchange didn't send a page offset. Assuming first page") - offset = '1' - next_offset = None if is_last_page else int(offset) + # + offset_attr = elem.get('IndexedPagingOffset') + next_offset = None if offset_attr is None else int(offset_attr) item_count = int(elem.get('TotalItemsInView')) - if not item_count and next_offset is not None: - raise ValueError("Expected empty 'next_offset' when 'item_count' is 0") - log.debug('Got page with next offset %s (last_page %s)', next_offset, is_last_page) + is_last_page = elem.get('IncludesLastItemInRange').lower() in ('true', '0') + log.debug('Got page with offset %s, item_count %s, last_page %s', next_offset, item_count, is_last_page) + # Clean up contradictory paging values + if next_offset is None and not is_last_page: + log.debug("Not last page in range, but server didn't send a page offset. Assuming first page") + next_offset = 1 + if next_offset is not None and is_last_page: + if next_offset != item_count: + log.debug("Last page in range, but we still got an offset. Assuming paging has completed") + next_offset = None + if not item_count and not is_last_page: + log.debug("Not last page in range, but also no items left. Assuming paging has completed") + next_offset = None + if item_count and next_offset == 0: + log.debug("Non-zero offset, but also no items left. Assuming paging has completed") + next_offset = None return item_count, next_offset def _get_page(self, message): From a8e349db775a70c705312f1f70a86df74bbc314b Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 16 Nov 2021 22:20:58 +0100 Subject: [PATCH 008/509] Optimize _paged_call() to not re-request folders where paging is done. This would cause weird paging values in the response. Refs #1022 --- exchangelib/folders/collections.py | 2 +- exchangelib/services/common.py | 24 +++++++++++++++++------- exchangelib/services/find_folder.py | 3 +-- exchangelib/services/find_item.py | 3 +-- exchangelib/services/find_people.py | 9 ++++++--- 5 files changed, 26 insertions(+), 15 deletions(-) diff --git a/exchangelib/folders/collections.py b/exchangelib/folders/collections.py index d0e5c81b..195fb483 100644 --- a/exchangelib/folders/collections.py +++ b/exchangelib/folders/collections.py @@ -259,7 +259,7 @@ def find_people(self, q, shape=ID_ONLY, depth=None, additional_fields=None, orde restriction = Restriction(q, folders=[folder], applies_to=Restriction.ITEMS) query_string = None yield from FindPeople(account=self.account, chunk_size=page_size).call( - folder=[folder], + folder=folder, additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 978fe8aa..46addc5a 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -664,20 +664,27 @@ def _get_next_offset(paging_infos): log.warning('Inconsistent next_offset values: %r. Using lowest value', next_offsets) return min(next_offsets) - def _paged_call(self, payload_func, max_items, expected_message_count, **kwargs): + def _paged_call(self, payload_func, max_items, folders, **kwargs): """Call a service that supports paging requests. Return a generator over all response items. Keeps track of all paging-related counters. """ - paging_infos = [dict(item_count=0, next_offset=None) for _ in range(expected_message_count)] + paging_infos = {f: dict(item_count=0, next_offset=None) for f in folders} common_next_offset = kwargs['offset'] total_item_count = 0 while True: + if not paging_infos: + # Paging is done for all folders + break log.debug('Getting page at offset %s (max_items %s)', common_next_offset, max_items) kwargs['offset'] = common_next_offset - pages = self._get_pages(payload_func, kwargs, expected_message_count) - for (page, next_offset), paging_info in zip(pages, paging_infos): + kwargs['folders'] = paging_infos.keys() # Only request the paging of the remaining folders. + pages = self._get_pages(payload_func, kwargs, len(paging_infos)) + for (page, next_offset), (f, paging_info) in zip(pages, list(paging_infos.items())): paging_info['next_offset'] = next_offset if isinstance(page, Exception): + # Assume this folder no longer works. Don't attempt to page it again. + log.debug('Exception occurred for folder %s. Removing.', f) + del paging_infos[f] yield page continue if page is not None: @@ -690,8 +697,11 @@ def _paged_call(self, payload_func, max_items, expected_message_count, **kwargs) log.debug("'max_items' count reached (inner)") break if not paging_info['next_offset']: - # Paging is done for this message + # Paging is done for this folder. Don't attempt to page it again. + log.debug('Paging has completed for folder %s. Removing.', f) + del paging_infos[f] continue + log.debug('Folder %s still has items', f, paging_info) # Check sanity of paging offsets, but don't fail. When we are iterating huge collections that take a # long time to complete, the collection may change while we are iterating. This can affect the # 'next_offset' value and make it inconsistent with the number of already collected items. @@ -705,9 +715,9 @@ def _paged_call(self, payload_func, max_items, expected_message_count, **kwargs) if max_items and total_item_count >= max_items: log.debug("'max_items' count reached (outer)") break - common_next_offset = self._get_next_offset(paging_infos) + common_next_offset = self._get_next_offset(paging_infos.values()) if common_next_offset is None: - # Paging is done for all messages + # Paging is done for all folders break @staticmethod diff --git a/exchangelib/services/find_folder.py b/exchangelib/services/find_folder.py index c3757ac7..ea44897f 100644 --- a/exchangelib/services/find_folder.py +++ b/exchangelib/services/find_folder.py @@ -37,9 +37,8 @@ def call(self, folders, additional_fields, restriction, shape, depth, max_items, return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - expected_message_count=len(folders), + folders=folders, **dict( - folders=folders, additional_fields=additional_fields, restriction=restriction, shape=shape, diff --git a/exchangelib/services/find_item.py b/exchangelib/services/find_item.py index 89f0351f..871dad24 100644 --- a/exchangelib/services/find_item.py +++ b/exchangelib/services/find_item.py @@ -40,9 +40,8 @@ def call(self, folders, additional_fields, restriction, order_fields, shape, que return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - expected_message_count=len(folders), + folders=folders, **dict( - folders=folders, additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, diff --git a/exchangelib/services/find_people.py b/exchangelib/services/find_people.py index 978ead1a..84e73fce 100644 --- a/exchangelib/services/find_people.py +++ b/exchangelib/services/find_people.py @@ -42,9 +42,8 @@ def call(self, folder, additional_fields, restriction, order_fields, shape, quer return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - expected_message_count=1, # We can only query one folder, so there will only be one element in response + folders=[folder], # We can only query one folder, so there will only be one element in response **dict( - folder=folder, additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, @@ -67,8 +66,12 @@ def _elems_to_objs(self, elems): continue yield Persona.from_xml(elem, account=self.account) - def get_payload(self, folder, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, + def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, offset=0): + folders = list(folders) + if len(folders) != 1: + raise ValueError('%r can only query one folder' % self.SERVICE_NAME) + folder = folders[0] findpeople = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth)) personashape = create_shape_element( tag='m:PersonaShape', shape=shape, additional_fields=additional_fields, version=self.account.version From b24ab896f3d24b7a16d7689224af677abb17bf5c Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 16 Nov 2021 23:23:56 +0100 Subject: [PATCH 009/509] Update dev version to install dev packages on --- .github/workflows/python-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index f03617a8..4e4d5958 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -58,7 +58,7 @@ jobs: - name: Install cutting-edge Cython-based packages on Python dev versions continue-on-error: ${{ matrix.allowed_failure || false }} - if: matrix.python-version == '3.10-dev' + if: matrix.python-version == '3.11-dev' run: | sudo apt-get install libxml2-dev libxslt1-dev python -m pip install hg+https://foss.heptapod.net/pypy/cffi From ec5dbb756be0a0e61eb53691e623ea2de10a4c9e Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 16 Nov 2021 23:24:39 +0100 Subject: [PATCH 010/509] Accept attribute fields 'id' and 'changekey' in .only() on folder querysets. --- exchangelib/folders/queryset.py | 1 + 1 file changed, 1 insertion(+) diff --git a/exchangelib/folders/queryset.py b/exchangelib/folders/queryset.py index f6ffe1cb..b7f5a0fa 100644 --- a/exchangelib/folders/queryset.py +++ b/exchangelib/folders/queryset.py @@ -43,6 +43,7 @@ def only(self, *args): from .base import Folder # Subfolders will always be of class Folder all_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=None) + all_fields.update(Folder.attribute_fields()) only_fields = [] for arg in args: for field_path in all_fields: From fa53c5977f6f8c9b3db8677921c2e8e01529e633 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 16 Nov 2021 23:24:52 +0100 Subject: [PATCH 011/509] Fix log statement --- exchangelib/services/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 46addc5a..c28bd960 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -701,7 +701,7 @@ def _paged_call(self, payload_func, max_items, folders, **kwargs): log.debug('Paging has completed for folder %s. Removing.', f) del paging_infos[f] continue - log.debug('Folder %s still has items', f, paging_info) + log.debug('Folder %s still has items', f) # Check sanity of paging offsets, but don't fail. When we are iterating huge collections that take a # long time to complete, the collection may change while we are iterating. This can affect the # 'next_offset' value and make it inconsistent with the number of already collected items. From 83edeebcaca4bf2d45e691f91cfaf17d54f3ccee Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 16 Nov 2021 23:26:07 +0100 Subject: [PATCH 012/509] Remove leftover comment --- exchangelib/services/common.py | 1 - 1 file changed, 1 deletion(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index c28bd960..e2ac0d4f 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -723,7 +723,6 @@ def _paged_call(self, payload_func, max_items, folders, **kwargs): @staticmethod def _get_paging_values(elem): """Read paging information from the paging container element.""" - # offset_attr = elem.get('IndexedPagingOffset') next_offset = None if offset_attr is None else int(offset_attr) item_count = int(elem.get('TotalItemsInView')) From fc67d2a103d42dfcbe38e4f5db79204054e2daf7 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Fri, 19 Nov 2021 08:02:58 +0100 Subject: [PATCH 013/509] Improve docs on notifications and subscriotions Particularly, expand the docs on handling POST requests for push notifications. Triggered by https://stackoverflow.com/questions/70011703/parsing-xml-returned-by-sendnotificationin-exchangelib --- docs/index.md | 70 ++++++++++++++++++++++++----------- tests/test_items/test_sync.py | 2 +- 2 files changed, 50 insertions(+), 22 deletions(-) diff --git a/docs/index.md b/docs/index.md index d95bc0e2..24bb926f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1656,11 +1656,9 @@ A description of how to subscribe to notifications and receive notifications using EWS is available at [https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/notification-subscriptions-mailbox-events-and-ews-in-exchange](https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/notification-subscriptions-mailbox-events-and-ews-in-exchange): +The following shows how to synchronize folders and items: ```python from exchangelib import Account -from exchangelib.properties import CopiedEvent, CreatedEvent, DeletedEvent, \ - ModifiedEvent, MovedEvent, NewMailEvent, StatusEvent, FreeBusyChangedEvent -form exchangelib.services import SendNotification a = Account(...) @@ -1670,7 +1668,8 @@ for change_type, item in a.inbox.sync_hierarchy(): # SyncFolderHierarchy.CHANGE_TYPES pass # The next time you call a.inbox.sync_hierarchy(), you will only get folder -# changes since the last .sync_hierarchy() call. +# changes since the last .sync_hierarchy() call. The sync status is stored in +# a.inbox.folder_sync_state. # Synchronize your local items within a.inbox for change_type, item in a.inbox.sync_items(): @@ -1678,34 +1677,57 @@ for change_type, item in a.inbox.sync_items(): # SyncFolderItems.CHANGE_TYPES pass # The next time you call a.inbox.sync_items(), you will only get item changes -# since the last .sync_items() call. +# since the last .sync_items() call. The sync status is stored in +# a.inbox.item_sync_state. +``` -# Create a pull subscription that can be used to pull events from the server +Here's how to create a pull subscription that can be used to pull events from the server: +```python subscription_id, watermark = a.inbox.subscribe_to_pull() +``` -# Create a push subscription. The server will regularly send an HTTP POST -# request to the callback URL to deliver changes or a status message. +Here's how to create a push subscription. The server will regularly send an HTTP POST +request to the callback URL to deliver changes or a status message. There is also support +for parsing the POST data that the Exchange server sends to the callback URL. +```python subscription_id, watermark = a.inbox.subscribe_to_push( callback_url='https://my_app.example.com/callback_url' ) -# When the server sends a push notification, the POST data contains a -# 'SendNotification' XML document. You can use exchangelib in the callback URL -# implementation to parse this data: -ws = SendNotification(protocol=a.protocol) -for notification in ws.parse(response.data): - # ws.parse() returns Notification objects - pass +``` + +When the server sends a push notification, the POST data contains a +'SendNotification' XML document. You can use exchangelib in the callback URL +implementation to parse this data. Here's a short example of a Flask app that +handles these documents: +```python +from exchangelib.services import SendNotification +from flask import Flask, request -# Create a streaming subscription that can be used to stream events from the -# server. +app = Flask(__name__) + +@app.route('/callback_url', methods=['POST']) +def upload_file(): + ws = SendNotification(protocol=None) + for notification in ws.parse(request.data): + # ws.parse() returns Notification objects + pass +``` + +Here's how to create a streaming subscription that can be used to stream events from the +server. +```python subscription_id = a.inbox.subscribe_to_streaming() +``` -# Cancel the subscription. Does not apply to push subscriptions that cancel -# automatically after a certain amount of failed attempts. +Cancel the subscription when you're done synchronizing. This is not supported for push +subscriptions. They cancel automatically after a certain amount of failed attempts. +```python a.inbox.unsubscribe(subscription_id) +``` -# You can also use one of the three context managers that handle unsubscription -# automatically: +When creating subscriptions, you can also use one of the three context managers +that handle unsubscription automatically: +```python with a.inbox.pull_subscription() as (subscription_id, watermark): pass @@ -1723,6 +1745,9 @@ contain events in the `events` attribute and a new watermark in the `watermark` attribute. ```python +from exchangelib.properties import CopiedEvent, CreatedEvent, DeletedEvent, \ + ModifiedEvent + for notification in a.inbox.get_events(subscription_id, watermark): for event in notification.events: if isinstance(event, (CreatedEvent, ModifiedEvent)): @@ -1747,6 +1772,9 @@ The default configuration is to only have 1 connection. See the documentation on `Configuration.max_connections` on how to increase the connection count. ```python +from exchangelib.properties import MovedEvent, NewMailEvent, StatusEvent, \ + FreeBusyChangedEvent + for notification in a.inbox.get_streaming_events( subscription_id, connection_timeout=1 ): diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index 6d29b549..a99d99b0 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -251,7 +251,7 @@ def test_push_message_parsing(self): ''' - ws = SendNotification(protocol=self.account.protocol) + ws = SendNotification(protocol=None) self.assertListEqual( list(ws.parse(xml)), [Notification(subscription_id='XXXXX=', previous_watermark='AAAAA=', more_events=False, From 264704d24079d19396e65862059230e5382c1ad0 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Fri, 19 Nov 2021 22:39:01 +0100 Subject: [PATCH 014/509] Reformat CHANGELOG --- CHANGELOG.md | 852 +++++++++++++++++++++++---------------------------- 1 file changed, 382 insertions(+), 470 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29e9a776..99c538ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,18 @@ HEAD 4.6.0 ----- + - Support microsecond precision in `EWSDateTime.ewsformat()` - Remove usage of the `multiprocessing` module to allow running in AWS Lambda - Support `tzlocal>=4` - 4.5.2 ----- -- Make `FileAttachment.fp` a proper `BytesIO` implementation -- Add missing `CalendarItem.recurrence_id` field -- Add `SingleFolderQuerySet.resolve()` to aid accessing a folder shared by a different account: + +- Make `FileAttachment.fp` a proper `BytesIO` implementation +- Add missing `CalendarItem.recurrence_id` field +- Add `SingleFolderQuerySet.resolve()` to aid accessing a folder shared by a different account: + ```python from exchangelib import Account from exchangelib.folders import Calendar, SingleFolderQuerySet @@ -28,221 +30,216 @@ shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFold mailbox=Mailbox(email_address="other_user@example.com") )).resolve() ``` -- Minor bugfixes +- Minor bugfixes 4.5.1 ----- -- Support updating items in `Account.upload()`. Previously, only insert was supported. -- Fixed types for `Contact.manager_mailbox` and `Contact.direct_reports`. -- Support getting `text_body` field on item attachments. +- Support updating items in `Account.upload()`. Previously, only insert was supported. +- Fixed types for `Contact.manager_mailbox` and `Contact.direct_reports`. +- Support getting `text_body` field on item attachments. 4.5.0 ----- -- Fixed bug when updating indexed fields on `Contact` items. -- Fixed bug preventing parsing of `CalendarPermission` items in the `permission_set` field. -- Add support for parsing push notification POST requests sent from the Exchange server - to the callback URL. +- Fixed bug when updating indexed fields on `Contact` items. +- Fixed bug preventing parsing of `CalendarPermission` items in the `permission_set` field. +- Add support for parsing push notification POST requests sent from the Exchange server to the callback URL. 4.4.0 ----- -- Add `Folder.move()` to move folders to a different parent folder. +- Add `Folder.move()` to move folders to a different parent folder. 4.3.0 ----- -- Add context managers `Folder.pull_subscription()`, `Folder.push_subscription()` and - `Folder.streaming_subscription()` that handle unsubscriptions automatically. +- Add context managers `Folder.pull_subscription()`, `Folder.push_subscription()` and + `Folder.streaming_subscription()` that handle unsubscriptions automatically. 4.2.0 ----- -- Move `util._may_retry_on_error` and and `util._raise_response_errors` to - `RetryPolicy.may_retry_on_error` and `RetryPolicy.raise_response_errors`, respectively. This allows - for easier customization of the retry logic. +- Move `util._may_retry_on_error` and and `util._raise_response_errors` to + `RetryPolicy.may_retry_on_error` and `RetryPolicy.raise_response_errors`, respectively. This allows for easier + customization of the retry logic. 4.1.0 ----- -- Add support for synchronization, subscriptions and notifications. Both pull, push and streaming - notifications are supported. See https://ecederstrand.github.io/exchangelib/#synchronization-subscriptions-and-notifications +- Add support for synchronization, subscriptions and notifications. Both pull, push and streaming notifications are + supported. See https://ecederstrand.github.io/exchangelib/#synchronization-subscriptions-and-notifications 4.0.0 ----- -- Add a new `max_connections` option for the `Configuration` class, to increase the session pool size - on a per-server, per-credentials basis. Useful when exchangelib is used with threads, where one may - wish to increase the number of concurrent connections to the server. -- Add `Message.mark_as_junk()` and complementary `QuerySet.mark_as_junk()` methods to mark or un-mark - messages as junk email, and optionally move them to the junk folder. -- Add support for Master Category Lists, also known as User Configurations. These are custom values - that can be assigned to folders. Available via `Folder.get_user_configuration()`. -- `Persona` objects as returned by `QuerySet.people()` now support almost all documented fields. -- Improved `QuerySet.people()` to call the `GetPersona` service if at least one field is requested that - is not supported by the `FindPeople` service. -- Removed the internal caching in `QuerySet`. It's not necessary in most use cases for exchangelib, - and the memory overhead and complexity is not worth the extra effort. This means that `.iterator()` - is now a no-op and marked as deprecated. ATTENTION: If you previously relied on caching of results - in `QuerySet`, you need to do you own caching now. -- Allow plain `date`, `datetime` and `zoneinfo.ZoneInfo` objects as values for fields and methods. This - lowers the barrier for using the library. We still use `EWSDate`, `EWSDateTime` and `EWSTimeZone` for - all values returned from the server, but these classes are subclasses of `date`, `datetime` and - `zoneinfo.ZoneInfo` objects and instances will behave just like instance of their parent class. +- Add a new `max_connections` option for the `Configuration` class, to increase the session pool size on a per-server, + per-credentials basis. Useful when exchangelib is used with threads, where one may wish to increase the number of + concurrent connections to the server. +- Add `Message.mark_as_junk()` and complementary `QuerySet.mark_as_junk()` methods to mark or un-mark messages as junk + email, and optionally move them to the junk folder. +- Add support for Master Category Lists, also known as User Configurations. These are custom values that can be assigned + to folders. Available via `Folder.get_user_configuration()`. +- `Persona` objects as returned by `QuerySet.people()` now support almost all documented fields. +- Improved `QuerySet.people()` to call the `GetPersona` service if at least one field is requested that is not supported + by the `FindPeople` service. +- Removed the internal caching in `QuerySet`. It's not necessary in most use cases for exchangelib, and the memory + overhead and complexity is not worth the extra effort. This means that `.iterator()` + is now a no-op and marked as deprecated. ATTENTION: If you previously relied on caching of results in `QuerySet`, you + need to do you own caching now. +- Allow plain `date`, `datetime` and `zoneinfo.ZoneInfo` objects as values for fields and methods. This lowers the + barrier for using the library. We still use `EWSDate`, `EWSDateTime` and `EWSTimeZone` for all values returned from + the server, but these classes are subclasses of `date`, `datetime` and + `zoneinfo.ZoneInfo` objects and instances will behave just like instance of their parent class. 3.3.2 ----- -- Change Kerberos dependency from `requests_kerberos` to `requests_gssapi` -- Let `EWSDateTime.from_datetime()` accept `datetime.datetime` objects with `tzinfo` objects that - are `dateutil`, `zoneinfo` and `pytz` instances, in addition to `EWSTimeZone`. +- Change Kerberos dependency from `requests_kerberos` to `requests_gssapi` +- Let `EWSDateTime.from_datetime()` accept `datetime.datetime` objects with `tzinfo` objects that are `dateutil` + , `zoneinfo` and `pytz` instances, in addition to `EWSTimeZone`. 3.3.1 ----- -- Allow overriding `dns.resolver.Resolver` class attributes via `Autodiscovery.DNS_RESOLVER_ATTRS`. +- Allow overriding `dns.resolver.Resolver` class attributes via `Autodiscovery.DNS_RESOLVER_ATTRS`. 3.3.0 ----- -- Switch `EWSTimeZone` to be implemented on top of the new `zoneinfo` module in Python 3.9 instead - of `pytz`. `backports.zoneinfo` is used for earlier versions of Python. This means that the - `ÈWSTimeZone` methods `timezone()`, `normalize()` and `localize()` methods are now deprecated. -- Add `EWSTimeZone.from_dateutil()` to support converting `dateutil.tz` timezones to `EWSTimeZone`. -- Dropped support for Python 3.5 which is EOL per September 2020. -- Added support for `CalendaItem.appointment_state`, `CalendaItem.conflicting_meetings` and - `CalendarItem.adjacent_meetings` fields. -- Added support for the `Message.reminder_message_data` field. -- Added support for `Contact.manager_mailbox`, `Contact.direct_reports` and `Contact.complete_name` fields. -- Added support for `Item.response_objects` field. -- Changed `Task.due_date` and `Tas.start_date` fields from datetime to date fields, since the time - was being truncated anyway by the server. -- Added support for `Task.recurrence` field. -- Added read-only support for `Contact.user_smime_certificate` and `Contact.ms_exchange_certificate`. - This means that all fields on all item types are now supported. +- Switch `EWSTimeZone` to be implemented on top of the new `zoneinfo` module in Python 3.9 instead of `pytz` + . `backports.zoneinfo` is used for earlier versions of Python. This means that the + `ÈWSTimeZone` methods `timezone()`, `normalize()` and `localize()` methods are now deprecated. +- Add `EWSTimeZone.from_dateutil()` to support converting `dateutil.tz` timezones to `EWSTimeZone`. +- Dropped support for Python 3.5 which is EOL per September 2020. +- Added support for `CalendaItem.appointment_state`, `CalendaItem.conflicting_meetings` and + `CalendarItem.adjacent_meetings` fields. +- Added support for the `Message.reminder_message_data` field. +- Added support for `Contact.manager_mailbox`, `Contact.direct_reports` and `Contact.complete_name` fields. +- Added support for `Item.response_objects` field. +- Changed `Task.due_date` and `Tas.start_date` fields from datetime to date fields, since the time was being truncated + anyway by the server. +- Added support for `Task.recurrence` field. +- Added read-only support for `Contact.user_smime_certificate` and `Contact.ms_exchange_certificate`. This means that + all fields on all item types are now supported. 3.2.1 ----- -- Fix bug leading to an exception in `CalendarItem.cancel()`. -- Improve stability of `.order_by()` in edge cases where sorting must be done client-side. -- Allow increasing the session pool-size dynamically. -- Change semantics of `.filter(foo__in=[])` to return an empty result. This was previously undefined - behavior. Now we adopt the behaviour of Django in this case. This is still undefined behavior for - list-type fields. -- Moved documentation to GitHub Pages and auto-documentation generated by `pdoc3`. +- Fix bug leading to an exception in `CalendarItem.cancel()`. +- Improve stability of `.order_by()` in edge cases where sorting must be done client-side. +- Allow increasing the session pool-size dynamically. +- Change semantics of `.filter(foo__in=[])` to return an empty result. This was previously undefined behavior. Now we + adopt the behaviour of Django in this case. This is still undefined behavior for list-type fields. +- Moved documentation to GitHub Pages and auto-documentation generated by `pdoc3`. 3.2.0 ----- -- Remove use of `ThreadPool` objects. Threads were used to implement async HTTP requests, but - were creating massive memory leaks. Async requests should be reimplemented using a real async - HTTP request package, so this is just an emergency fix. This also lowers the default - `Protocol.SESSION_POOLSIZE` to 1 because no internal code is running multi-threaded anymore. -- All-day calendar items (created as `CalendarItem(is_all_day=True, ...)`) now accept `EWSDate` - instances for the `start` and `end` values. Similarly, all-day calendar items fetched from - the server now return `start` and `end` values as `EWSDate` instances. In this case, start - and end values are inclusive; a one-day event starts and ends on the same `EWSDate` value. -- Add support for `RecurringMasterItemId` and `OccurrenceItemId` elements that allow to request - the master recurrence from a `CalendarItem` occurrence, and to request a specific occurrence - from a `CalendarItem` master recurrence. `CalendarItem.master_recurrence()` and - `CalendarItem.occurrence(some_occurrence_index)` methods were added to aid this traversal. - `some_occurrence_index` in the last method specifies which item in the list of occurrences to - target; `CalendarItem.occurrence(3)` gets the third occurrence in the recurrence. -- Change `Contact.birthday` and `Contact.wedding_anniversary` from `EWSDateTime` to `EWSDate` - fields. EWS still expects and sends datetime values but has started to reset the time part to - 11:59. Dates are a better match for these two fields anyway. -- Remove support for `len(some_queryset)`. It had the nasty side-effect of forcing - `list(some_queryset)` to run the query twice, once for pre-allocating the list via the result - of `len(some_queryset)`, and then once more to fetch the results. All occurrences of - `len(some_queryset)` can be replaced with `some_queryset.count()`. Unfortunately, there is - no way to keep backwards-compatibility for this feature. -- Added `Account.identity`, an attribute to contain extra information for impersonation. Setting - `Account.identity.upn` or `Account.identity.sid` removes the need for an AD lookup on every request. - `upn` will often be the same as `primary_smtp_address`, but it is not guaranteed. If you have - access to your organization's AD servers, you can look up these values once and add them to your - `Account` object to improve performance of the following requests. -- Added support for CBA authentication +- Remove use of `ThreadPool` objects. Threads were used to implement async HTTP requests, but were creating massive + memory leaks. Async requests should be reimplemented using a real async HTTP request package, so this is just an + emergency fix. This also lowers the default + `Protocol.SESSION_POOLSIZE` to 1 because no internal code is running multi-threaded anymore. +- All-day calendar items (created as `CalendarItem(is_all_day=True, ...)`) now accept `EWSDate` + instances for the `start` and `end` values. Similarly, all-day calendar items fetched from the server now + return `start` and `end` values as `EWSDate` instances. In this case, start and end values are inclusive; a one-day + event starts and ends on the same `EWSDate` value. +- Add support for `RecurringMasterItemId` and `OccurrenceItemId` elements that allow to request the master recurrence + from a `CalendarItem` occurrence, and to request a specific occurrence from a `CalendarItem` master + recurrence. `CalendarItem.master_recurrence()` and + `CalendarItem.occurrence(some_occurrence_index)` methods were added to aid this traversal. + `some_occurrence_index` in the last method specifies which item in the list of occurrences to + target; `CalendarItem.occurrence(3)` gets the third occurrence in the recurrence. +- Change `Contact.birthday` and `Contact.wedding_anniversary` from `EWSDateTime` to `EWSDate` + fields. EWS still expects and sends datetime values but has started to reset the time part to 11:59. Dates are a + better match for these two fields anyway. +- Remove support for `len(some_queryset)`. It had the nasty side-effect of forcing + `list(some_queryset)` to run the query twice, once for pre-allocating the list via the result of `len(some_queryset)`, + and then once more to fetch the results. All occurrences of + `len(some_queryset)` can be replaced with `some_queryset.count()`. Unfortunately, there is no way to keep + backwards-compatibility for this feature. +- Added `Account.identity`, an attribute to contain extra information for impersonation. Setting + `Account.identity.upn` or `Account.identity.sid` removes the need for an AD lookup on every request. + `upn` will often be the same as `primary_smtp_address`, but it is not guaranteed. If you have access to your + organization's AD servers, you can look up these values once and add them to your + `Account` object to improve performance of the following requests. +- Added support for CBA authentication 3.1.1 ----- -- The `max_wait` argument to `FaultTolerance` changed semantics. Previously, it triggered when - the delay until the next attempt would exceed this value. It now triggers after the given - timespan since the *first* request attempt. -- Fixed a bug when pagination is combined with `max_items` (#710) -- Other minor bug fixes +- The `max_wait` argument to `FaultTolerance` changed semantics. Previously, it triggered when the delay until the next + attempt would exceed this value. It now triggers after the given timespan since the *first* request attempt. +- Fixed a bug when pagination is combined with `max_items` (#710) +- Other minor bug fixes 3.1.0 ----- -- Removed the legacy autodiscover implementation. -- Added `QuerySet.depth()` to configure item traversal of querysets. Default is `Shallow` except - for the `CommonViews` folder where default is `Associated`. -- Updating credentials on `Account.protocol` after getting an `UnauthorizedError` now works. +- Removed the legacy autodiscover implementation. +- Added `QuerySet.depth()` to configure item traversal of querysets. Default is `Shallow` except for the `CommonViews` + folder where default is `Associated`. +- Updating credentials on `Account.protocol` after getting an `UnauthorizedError` now works. 3.0.0 ----- -- The new Autodiscover implementation added in 2.2.0 is now default. To switch back to the old - implementation, set the environment variable `EXCHANGELIB_AUTODISCOVER_VERSION=legacy`. -- Removed support for Python 2 +- The new Autodiscover implementation added in 2.2.0 is now default. To switch back to the old implementation, set the + environment variable `EXCHANGELIB_AUTODISCOVER_VERSION=legacy`. +- Removed support for Python 2 2.2.0 ----- -- Added support for specifying a separate retry policy for the autodiscover service endpoint - selection. Set via the `exchangelib.autodiscover.legacy.INITIAL_RETRY_POLICY` module variable - for the the old autodiscover implementation, and via the - `exchangelib.autodiscover.Autodiscovery.INITIAL_RETRY_POLICY` class variable for the new one. -- Support the authorization code OAuth 2.0 grant type (see issue #698) -- Removed the `RootOfHierarchy.permission_set` field. It was causing too many failures in the wild. -- The full autodiscover response containing all contents of the reponse is now available as `Account.ad_response`. -- Added a new Autodiscover implementation that is closer to the specification and easier to debug. To switch - to the new implementation, set the environment variable `EXCHANGELIB_AUTODISCOVER_VERSION=new`. The old - one is still the default if the variable is not set, or set to `EXCHANGELIB_AUTODISCOVER_VERSION=legacy`. -- The `Item.mime_content` field was switched back from a string type to a `bytes` type. It turns out trying - to decode the data was an error (see issue #709). +- Added support for specifying a separate retry policy for the autodiscover service endpoint selection. Set via + the `exchangelib.autodiscover.legacy.INITIAL_RETRY_POLICY` module variable for the the old autodiscover + implementation, and via the + `exchangelib.autodiscover.Autodiscovery.INITIAL_RETRY_POLICY` class variable for the new one. +- Support the authorization code OAuth 2.0 grant type (see issue #698) +- Removed the `RootOfHierarchy.permission_set` field. It was causing too many failures in the wild. +- The full autodiscover response containing all contents of the reponse is now available as `Account.ad_response`. +- Added a new Autodiscover implementation that is closer to the specification and easier to debug. To switch to the new + implementation, set the environment variable `EXCHANGELIB_AUTODISCOVER_VERSION=new`. The old one is still the default + if the variable is not set, or set to `EXCHANGELIB_AUTODISCOVER_VERSION=legacy`. +- The `Item.mime_content` field was switched back from a string type to a `bytes` type. It turns out trying to decode + the data was an error (see issue #709). 2.1.1 ----- -- Bugfix release. +- Bugfix release. 2.1.0 ----- -- Added support for OAuth 2.0 authentication -- Fixed a bug in `RelativeMonthlyPattern` and `RelativeYearlyPattern` where the `weekdays` field was thought to - be a list, but is in fact a single value. Renamed the field to `weekday` to reflect the change. -- Added support for archiving items to the archive mailbox, if the account has one. -- Added support for getting delegate information on an Account, as `Account.delegates`. -- Added support for the `ConvertId` service. Available as `Protocol.convert_ids()`. +- Added support for OAuth 2.0 authentication +- Fixed a bug in `RelativeMonthlyPattern` and `RelativeYearlyPattern` where the `weekdays` field was thought to be a + list, but is in fact a single value. Renamed the field to `weekday` to reflect the change. +- Added support for archiving items to the archive mailbox, if the account has one. +- Added support for getting delegate information on an Account, as `Account.delegates`. +- Added support for the `ConvertId` service. Available as `Protocol.convert_ids()`. 2.0.1 ----- -- Fixed a bug where version 2.x could not open autodiscover cache files generated by - version 1.x packages. +- Fixed a bug where version 2.x could not open autodiscover cache files generated by version 1.x packages. 2.0.0 ----- -- `Item.mime_content` is now a text field instead of a binary field. Encoding and - decoding is done automatically. -- The `Item.item_id`, `Folder.folder_id` and `Occurrence.item_id` fields that were renamed - to just `id` in 1.12.0, have now been removed. -- The `Persona.persona_id` field was replaced with `Persona.id` and `Persona.changekey`, to - align with the `Item` and `Folder` classes. -- In addition to bulk deleting via a QuerySet (`qs.delete()`), it is now possible to also - bulk send, move and copy items in a QuerySet (via `qs.send()`, `qs.move()` and `qs.copy()`, - respectively). -- SSPI support was added but dependencies are not installed by default since it only works - in Win32 environments. Install as `pip install exchangelib[sspi]` to get SSPI support. - Install with `pip install exchangelib[complete]` to get both Kerberos and SSPI auth. -- The custom `extern_id` field is no longer registered by default. If you require this field, - register it manually as part of your setup code on the item types you need: + +- `Item.mime_content` is now a text field instead of a binary field. Encoding and decoding is done automatically. +- The `Item.item_id`, `Folder.folder_id` and `Occurrence.item_id` fields that were renamed to just `id` in 1.12.0, have + now been removed. +- The `Persona.persona_id` field was replaced with `Persona.id` and `Persona.changekey`, to align with the `Item` + and `Folder` classes. +- In addition to bulk deleting via a QuerySet (`qs.delete()`), it is now possible to also bulk send, move and copy items + in a QuerySet (via `qs.send()`, `qs.move()` and `qs.copy()`, respectively). +- SSPI support was added but dependencies are not installed by default since it only works in Win32 environments. + Install as `pip install exchangelib[sspi]` to get SSPI support. Install with `pip install exchangelib[complete]` to + get both Kerberos and SSPI auth. +- The custom `extern_id` field is no longer registered by default. If you require this field, register it manually as + part of your setup code on the item types you need: ```python from exchangelib import CalendarItem, Message, Contact, Task @@ -253,137 +250,122 @@ shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFold Contact.register('extern_id', ExternId) Task.register('extern_id', ExternId) ``` -- The `ServiceAccount` class has been removed. If you want fault tolerance, set it in a - `Configuration` object: +- The `ServiceAccount` class has been removed. If you want fault tolerance, set it in a + `Configuration` object: ```python from exchangelib import Configuration, Credentials, FaultTolerance c = Credentials('foo', 'bar') config = Configuration(credentials=c, retry_policy=FaultTolerance()) ``` -- It is now possible to use Kerberos and SSPI auth without providing a dummy - `Credentials('', '')` object. -- The `has_ssl` argument of `Configuration` was removed. If you want to connect to a - plain HTTP endpoint, pass the full URL in the `service_endpoint` argument. -- We no longer look in `types.xsd` for a hint of which API version the server is running. Instead, - we query the service directly, starting with the latest version first. - +- It is now possible to use Kerberos and SSPI auth without providing a dummy + `Credentials('', '')` object. +- The `has_ssl` argument of `Configuration` was removed. If you want to connect to a plain HTTP endpoint, pass the full + URL in the `service_endpoint` argument. +- We no longer look in `types.xsd` for a hint of which API version the server is running. Instead, we query the service + directly, starting with the latest version first. 1.12.5 ------ -- Bugfix release. +- Bugfix release. 1.12.4 ------ -- Fix bug that left out parts of the folder hierarchy when traversing `account.root`. -- Fix bug that did not properly find all attachments if an item has a mix of item - and file attachments. +- Fix bug that left out parts of the folder hierarchy when traversing `account.root`. +- Fix bug that did not properly find all attachments if an item has a mix of item and file attachments. 1.12.3 ------ -- Add support for reading and writing `PermissionSet` field on folders. -- Add support for Exchange 2019 build IDs. +- Add support for reading and writing `PermissionSet` field on folders. +- Add support for Exchange 2019 build IDs. 1.12.2 ------ -- Add `Protocol.expand_dl()` to get members of a distribution list. +- Add `Protocol.expand_dl()` to get members of a distribution list. 1.12.1 ------ -- Lower the session pool size automatically in response to ErrorServerBusy and - ErrorTooManyObjectsOpened errors from the server. -- Unusual slicing and indexing (e.g. `inbox.all()[9000]` and `inbox.all()[9000:9001]`) - is now efficient. -- Downloading large attachments is now more memory-efficient. We can now stream the file - content without ever storing the full file content in memory, using the new - `Attachment.fp` context manager. + +- Lower the session pool size automatically in response to ErrorServerBusy and ErrorTooManyObjectsOpened errors from the + server. +- Unusual slicing and indexing (e.g. `inbox.all()[9000]` and `inbox.all()[9000:9001]`) + is now efficient. +- Downloading large attachments is now more memory-efficient. We can now stream the file content without ever storing + the full file content in memory, using the new + `Attachment.fp` context manager. 1.12.0 ------ -- Add a MAINFEST.in to ensure the LICENSE file gets included + CHANGELOG.md - and README.md to sdist tarball -- Renamed `Item.item_id`, `Folder.folder_id` and `Occurrence.item_id` to just - `Item.id`, `Folder.id` and `Occurrence.id`, respectively. This removes - redundancy in the naming and provides consistency. For all classes that - have an ID, the ID can now be accessed using the `id` attribute. Backwards - compatibility and deprecation warnings were added. -- Support folder traversal without creating a full cache of the folder - hierarchy first, using the `some_folder // 'sub_folder' // 'leaf'` - (double-slash) syntax. -- Fix a bug in traversal of public and archive folders. These folder - hierarchies are now fully supported. -- Fix a bug where the timezone of a calendar item changed when the item was - fetched and then saved. -- Kerberos support is now optional and Kerberos dependencies are not - installed by default. Install as `pip install exchangelib[kerberos]` to get - Kerberos support. +- Add a MAINFEST.in to ensure the LICENSE file gets included + CHANGELOG.md and README.md to sdist tarball +- Renamed `Item.item_id`, `Folder.folder_id` and `Occurrence.item_id` to just + `Item.id`, `Folder.id` and `Occurrence.id`, respectively. This removes redundancy in the naming and provides + consistency. For all classes that have an ID, the ID can now be accessed using the `id` attribute. Backwards + compatibility and deprecation warnings were added. +- Support folder traversal without creating a full cache of the folder hierarchy first, using + the `some_folder // 'sub_folder' // 'leaf'` + (double-slash) syntax. +- Fix a bug in traversal of public and archive folders. These folder hierarchies are now fully supported. +- Fix a bug where the timezone of a calendar item changed when the item was fetched and then saved. +- Kerberos support is now optional and Kerberos dependencies are not installed by default. Install + as `pip install exchangelib[kerberos]` to get Kerberos support. 1.11.4 ------ -- Improve back off handling when receiving `ErrorServerBusy` error messages - from the server -- Fixed bug where `Account.root` and its children would point to the root - folder of the connecting account instead of the target account when - connecting to other accounts. +- Improve back off handling when receiving `ErrorServerBusy` error messages from the server +- Fixed bug where `Account.root` and its children would point to the root folder of the connecting account instead of + the target account when connecting to other accounts. 1.11.3 ------ -- Add experimental Kerberos support. This adds the `pykerberos` package, - which needs the following system packages to be installed on Ubuntu/Debian - systems: `apt-get install build-essential libssl-dev libffi-dev python-dev libkrb5-dev`. +- Add experimental Kerberos support. This adds the `pykerberos` package, which needs the following system packages to be + installed on Ubuntu/Debian systems: `apt-get install build-essential libssl-dev libffi-dev python-dev libkrb5-dev`. 1.11.2 ------ -- Bugfix release +- Bugfix release 1.11.1 ------ -- Bugfix release +- Bugfix release 1.11.0 ------ -- Added `cancel` to `CalendarItem` and `CancelCalendarItem` class to - allow cancelling meetings that were set up -- Added `accept`, `decline` and `tentatively_accept` to `CalendarItem` - as wrapper methods -- Added `accept`, `decline` and `tentatively_accept` to - `MeetingRequest` to respond to incoming invitations -- Added `BaseMeetingItem` (inheriting from `Item`) being used as base - for MeetingCancellation, MeetingMessage, MeetingRequest and - MeetingResponse -- Added `AssociatedCalendarItemId` (property), - `AssociatedCalendarItemIdField` and `ReferenceItemIdField` -- Added `PostReplyItem` -- Removed `Folder.get_folder_by_name()` which has been deprecated - since version `1.10.2`. -- Added `Item.copy(to_folder=some_folder)` method which copies an item - to the given folder and returns the ID of the new item. -- We now respect the back off value of an `ErrorServerBusy` - server error. -- Added support for fetching free/busy availability information ofr a - list of accounts. -- Added `Message.reply()`, `Message.reply_all()`, and - `Message.forward()` methods. -- The full search API now works on single folders *and* collections of - folders, e.g. `some_folder.glob('foo*').filter()`, - `some_folder.children.filter()` and `some_folder.walk().filter()`. -- Deprecated `EWSService.CHUNKSIZE` in favor of a per-request - chunk\_size available on `Account.bulk_foo()` methods. -- Support searching the GAL and other contact folders using - `some_contact_folder.people()`. -- Deprecated the `page_size` argument for `QuerySet.iterator()` because it - was inconsistent with other API methods. You can still set the page size - of a queryset like this: +- Added `cancel` to `CalendarItem` and `CancelCalendarItem` class to allow cancelling meetings that were set up +- Added `accept`, `decline` and `tentatively_accept` to `CalendarItem` + as wrapper methods +- Added `accept`, `decline` and `tentatively_accept` to + `MeetingRequest` to respond to incoming invitations +- Added `BaseMeetingItem` (inheriting from `Item`) being used as base for MeetingCancellation, MeetingMessage, + MeetingRequest and MeetingResponse +- Added `AssociatedCalendarItemId` (property), + `AssociatedCalendarItemIdField` and `ReferenceItemIdField` +- Added `PostReplyItem` +- Removed `Folder.get_folder_by_name()` which has been deprecated since version `1.10.2`. +- Added `Item.copy(to_folder=some_folder)` method which copies an item to the given folder and returns the ID of the new + item. +- We now respect the back off value of an `ErrorServerBusy` + server error. +- Added support for fetching free/busy availability information ofr a list of accounts. +- Added `Message.reply()`, `Message.reply_all()`, and + `Message.forward()` methods. +- The full search API now works on single folders *and* collections of folders, e.g. `some_folder.glob('foo*').filter()` + , + `some_folder.children.filter()` and `some_folder.walk().filter()`. +- Deprecated `EWSService.CHUNKSIZE` in favor of a per-request chunk\_size available on `Account.bulk_foo()` methods. +- Support searching the GAL and other contact folders using + `some_contact_folder.people()`. +- Deprecated the `page_size` argument for `QuerySet.iterator()` because it was inconsistent with other API methods. You + can still set the page size of a queryset like this: ```python qs = a.inbox.filter(...).iterator() @@ -395,54 +377,49 @@ shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFold 1.10.7 ------ -- Added support for registering extended properties on folders. -- Added support for creating, updating, deleting and emptying folders. +- Added support for registering extended properties on folders. +- Added support for creating, updating, deleting and emptying folders. 1.10.6 ------ -- Added support for getting and setting `Account.oof_settings` using - the new `OofSettings` class. -- Added snake\_case named shortcuts to all distinguished folders on - the `Account` model. E.g. `Account.search_folders`. +- Added support for getting and setting `Account.oof_settings` using the new `OofSettings` class. +- Added snake\_case named shortcuts to all distinguished folders on the `Account` model. E.g. `Account.search_folders`. 1.10.5 ------ -- Bugfix release +- Bugfix release 1.10.4 ------ -- Added support for most item fields. The remaining ones are mentioned - in issue \#203. +- Added support for most item fields. The remaining ones are mentioned in issue \#203. 1.10.3 ------ -- Added an `exchangelib.util.PrettyXmlHandler` log handler which will - pretty-print and highlight XML requests and responses. +- Added an `exchangelib.util.PrettyXmlHandler` log handler which will pretty-print and highlight XML requests and + responses. 1.10.2 ------ -- Greatly improved folder navigation. See the 'Folders' section in the - README -- Added deprecation warnings for `Account.folders` and - `Folder.get_folder_by_name()` +- Greatly improved folder navigation. See the 'Folders' section in the README +- Added deprecation warnings for `Account.folders` and + `Folder.get_folder_by_name()` 1.10.1 ------ -- Bugfix release +- Bugfix release 1.10.0 ------ -- Removed the `verify_ssl` argument to `Account`, `discover` and - `Configuration`. If you need to disable TLS verification, register a - custom `HTTPAdapter` class. A sample adapter class is provided for - convenience: +- Removed the `verify_ssl` argument to `Account`, `discover` and + `Configuration`. If you need to disable TLS verification, register a custom `HTTPAdapter` class. A sample adapter + class is provided for convenience: ```python from exchangelib.protocol import BaseProtocol, NoVerifyHTTPAdapter @@ -452,141 +429,111 @@ shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFold 1.9.6 ----- -- Support new Office365 build numbers +- Support new Office365 build numbers 1.9.5 ----- -- Added support for the `effective_rights`field on items and folders. -- Added support for custom `requests` transport adapters, to allow - proxy support, custom TLS validation etc. -- Default value for the `affected_task_occurrences` argument to - `Item.move_to_trash()`, `Item.soft_delete()` and `Item.delete()` was - changed to `'AllOccurrences'` as a less surprising default when - working with simple tasks. -- Added `Task.complete()` helper method to mark tasks as complete. +- Added support for the `effective_rights`field on items and folders. +- Added support for custom `requests` transport adapters, to allow proxy support, custom TLS validation etc. +- Default value for the `affected_task_occurrences` argument to + `Item.move_to_trash()`, `Item.soft_delete()` and `Item.delete()` was changed to `'AllOccurrences'` as a less + surprising default when working with simple tasks. +- Added `Task.complete()` helper method to mark tasks as complete. 1.9.4 ----- -- Added minimal support for the `PostItem` item type -- Added support for the `DistributionList` item type -- Added support for receiving naive datetimes from the server. They - will be localized using the new `default_timezone` attribute on - `Account` -- Added experimental support for recurring calendar items. See - examples in issue \#37. +- Added minimal support for the `PostItem` item type +- Added support for the `DistributionList` item type +- Added support for receiving naive datetimes from the server. They will be localized using the new `default_timezone` + attribute on + `Account` +- Added experimental support for recurring calendar items. See examples in issue \#37. 1.9.3 ----- -- Improved support for `filter()`, `.only()`, `.order_by()` etc. on - indexed properties. It is now possible to specify labels and - subfields, e.g. - `.filter(phone_numbers=PhoneNumber(label='CarPhone', phone_number='123'))` - `.filter(phone_numbers__CarPhone='123')`, - `.filter(physical_addresses__Home__street='Elm St. 123')`, - .only('physical\_addresses\_\_Home\_\_street')\` etc. -- Improved performance of `.order_by()` when sorting on - multiple fields. -- Implemented QueryString search. You can now filter using an EWS - QueryString, e.g. `filter('subject:XXX')` +- Improved support for `filter()`, `.only()`, `.order_by()` etc. on indexed properties. It is now possible to specify + labels and subfields, e.g. + `.filter(phone_numbers=PhoneNumber(label='CarPhone', phone_number='123'))` + `.filter(phone_numbers__CarPhone='123')`, + `.filter(physical_addresses__Home__street='Elm St. 123')`, .only('physical\_addresses\_\_Home\_\_street')\` etc. +- Improved performance of `.order_by()` when sorting on multiple fields. +- Implemented QueryString search. You can now filter using an EWS QueryString, e.g. `filter('subject:XXX')` 1.9.2 ----- -- Added `EWSTimeZone.localzone()` to get the local timezone -- Support `some_folder.get(item_id=..., changekey=...)` as a shortcut - to get a single item when you know the ID and changekey. -- Support attachments on Exchange 2007 +- Added `EWSTimeZone.localzone()` to get the local timezone +- Support `some_folder.get(item_id=..., changekey=...)` as a shortcut to get a single item when you know the ID and + changekey. +- Support attachments on Exchange 2007 1.9.1 ----- -- Fixed XML generation for Exchange 2010 and other picky server - versions -- Fixed timezone localization for `EWSTimeZone` created from a static - timezone +- Fixed XML generation for Exchange 2010 and other picky server versions +- Fixed timezone localization for `EWSTimeZone` created from a static timezone 1.9.0 ----- -- Expand support for `ExtendedProperty` to include all - possible attributes. This required renaming the `property_id` - attribute to `property_set_id`. -- When using the `Credentials` class, `UnauthorizedError` is now - raised if the credentials are wrong. -- Add a new `version` attribute to `Configuration`, to force the - server version if version guessing does not work. Accepts a - `exchangelib.version.Version` object. -- Rework bulk operations `Account.bulk_foo()` and `Account.fetch()` to - return some exceptions unraised, if it is deemed the exception does - not apply to all items. This means that e.g. `fetch()` can return a - mix of `` `Item `` and `ErrorItemNotFound` instances, if only some - of the requested `ItemId` were valid. Other exceptions will be - raised immediately, e.g. `ErrorNonExistentMailbox` because the - exception applies to all items. It is the responsibility of the - caller to check the type of the returned values. -- The `Folder` class has new attributes `total_count`, `unread_count` - and `child_folder_count`, and a `refresh()` method to update - these values. -- The argument to `Account.upload()` was renamed from `upload_data` to - just `data` -- Support for using a string search expression for `Folder.filter()` - was removed. It was a cool idea but using QuerySet chaining and `Q` - objects is even cooler and provides the same functionality, - and more. -- Add support for `reminder_due_by` and - `reminder_minutes_before_start` fields on `Item` objects. Submitted - by `@vikipha`. -- Added a new `ServiceAccount` class which is like `Credentials` but - does what `is_service_account` did before. If you need - fault-tolerane and used `Credentials(..., is_service_account=True)` - before, use `ServiceAccount` now. This also disables fault-tolerance - for the `Credentials` class, which is in line with what most - users expected. -- Added an optional `update_fields` attribute to `save()` to specify - only some fields to be updated. -- Code in in `folders.py` has been split into multiple files, and some - classes will have new import locaions. The most commonly used - classes have a shortcut in \_\_init\_\_.py -- Added support for the `exists` lookup in filters, e.g. - `my_folder.filter(categories__exists=True|False)` to filter on the - existence of that field on items in the folder. -- When filtering, `foo__in=value` now requires the value to be a list, - and `foo__contains` requires the value to be a list if the field - itself is a list, e.g. `categories__contains=['a', 'b']`. -- Added support for fields and enum entries that are only supported in - some EWS versions -- Added a new field `Item.text_body` which is a read-only version of - HTML body content, where HTML tags are stripped by the server. Only - supported from Exchange 2013 and up. -- Added a new choice `WorkingElsewhere` to the - `CalendarItem.legacy_free_busy_status` enum. Only supported from - Exchange 2013 and up. +- Expand support for `ExtendedProperty` to include all possible attributes. This required renaming the `property_id` + attribute to `property_set_id`. +- When using the `Credentials` class, `UnauthorizedError` is now raised if the credentials are wrong. +- Add a new `version` attribute to `Configuration`, to force the server version if version guessing does not work. + Accepts a + `exchangelib.version.Version` object. +- Rework bulk operations `Account.bulk_foo()` and `Account.fetch()` to return some exceptions unraised, if it is deemed + the exception does not apply to all items. This means that e.g. `fetch()` can return a mix of `` `Item `` + and `ErrorItemNotFound` instances, if only some of the requested `ItemId` were valid. Other exceptions will be raised + immediately, e.g. `ErrorNonExistentMailbox` because the exception applies to all items. It is the responsibility of + the caller to check the type of the returned values. +- The `Folder` class has new attributes `total_count`, `unread_count` + and `child_folder_count`, and a `refresh()` method to update these values. +- The argument to `Account.upload()` was renamed from `upload_data` to just `data` +- Support for using a string search expression for `Folder.filter()` + was removed. It was a cool idea but using QuerySet chaining and `Q` + objects is even cooler and provides the same functionality, and more. +- Add support for `reminder_due_by` and + `reminder_minutes_before_start` fields on `Item` objects. Submitted by `@vikipha`. +- Added a new `ServiceAccount` class which is like `Credentials` but does what `is_service_account` did before. If you + need fault-tolerane and used `Credentials(..., is_service_account=True)` + before, use `ServiceAccount` now. This also disables fault-tolerance for the `Credentials` class, which is in line + with what most users expected. +- Added an optional `update_fields` attribute to `save()` to specify only some fields to be updated. +- Code in in `folders.py` has been split into multiple files, and some classes will have new import locaions. The most + commonly used classes have a shortcut in \_\_init\_\_.py +- Added support for the `exists` lookup in filters, e.g. + `my_folder.filter(categories__exists=True|False)` to filter on the existence of that field on items in the folder. +- When filtering, `foo__in=value` now requires the value to be a list, and `foo__contains` requires the value to be a + list if the field itself is a list, e.g. `categories__contains=['a', 'b']`. +- Added support for fields and enum entries that are only supported in some EWS versions +- Added a new field `Item.text_body` which is a read-only version of HTML body content, where HTML tags are stripped by + the server. Only supported from Exchange 2013 and up. +- Added a new choice `WorkingElsewhere` to the + `CalendarItem.legacy_free_busy_status` enum. Only supported from Exchange 2013 and up. 1.8.1 ----- -- Fix completely botched `Message.from` field renaming in 1.8.0 -- Improve performance of QuerySet slicing and indexing. For example, - `account.inbox.all()[10]` and `account.inbox.all()[:10]` now only - fetch 10 items from the server even though `account.inbox.all()` - could contain thousands of messages. +- Fix completely botched `Message.from` field renaming in 1.8.0 +- Improve performance of QuerySet slicing and indexing. For example, + `account.inbox.all()[10]` and `account.inbox.all()[:10]` now only fetch 10 items from the server even + though `account.inbox.all()` + could contain thousands of messages. 1.8.0 ----- -- Renamed `Message.from` field to `Message.author`. `from` is a Python - keyword so `from` could only be accessed as - `Getattr(my_essage, 'from')` which is just stupid. -- Make `EWSTimeZone` Windows timezone name translation more robust -- Add read-only `Message.message_id` which holds the Internet Message - Id -- Memory and speed improvements when sorting querysets using - `order_by()` on a single field. -- Allow setting `Mailbox` and `Attendee`-type attributes as plain - strings, e.g.: +- Renamed `Message.from` field to `Message.author`. `from` is a Python keyword so `from` could only be accessed as + `Getattr(my_essage, 'from')` which is just stupid. +- Make `EWSTimeZone` Windows timezone name translation more robust +- Add read-only `Message.message_id` which holds the Internet Message Id +- Memory and speed improvements when sorting querysets using + `order_by()` on a single field. +- Allow setting `Mailbox` and `Attendee`-type attributes as plain strings, e.g.: ```python calendar_item.organizer = 'anne@example.com' @@ -598,28 +545,25 @@ shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFold 1.7.6 ----- -- Bugfix release +- Bugfix release 1.7.5 ----- -- `Account.fetch()` and `Folder.fetch()` are now generators. They will - do nothing before being evaluated. -- Added optional `page_size` attribute to `QuerySet.iterator()` to - specify the number of items to return per HTTP request for large - query results. Default `page_size` is 100. -- Many minor changes to make queries less greedy and return earlier +- `Account.fetch()` and `Folder.fetch()` are now generators. They will do nothing before being evaluated. +- Added optional `page_size` attribute to `QuerySet.iterator()` to specify the number of items to return per HTTP + request for large query results. Default `page_size` is 100. +- Many minor changes to make queries less greedy and return earlier 1.7.4 ----- -- Add Python2 support +- Add Python2 support 1.7.3 ----- -- Implement attachments support. It's now possible to create, delete - and get attachments connected to any item type: +- Implement attachments support. It's now possible to create, delete and get attachments connected to any item type: ```python from exchangelib.folders import FileAttachment, ItemAttachment @@ -650,40 +594,29 @@ shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFold item.detach(my_file) ``` - Be aware that adding and deleting attachments from items that are - already created in Exchange (items that have an `item_id`) will - update the `changekey` of the item. + Be aware that adding and deleting attachments from items that are already created in Exchange (items that have + an `item_id`) will update the `changekey` of the item. -- Implement `Item.headers` which contains custom Internet - message headers. Primarily useful for `Message` objects. Read-only - for now. +- Implement `Item.headers` which contains custom Internet message headers. Primarily useful for `Message` objects. + Read-only for now. 1.7.2 ----- -- Implement the `Contact.physical_addresses` attribute. This is a list - of `exchangelib.folders.PhysicalAddress` items. -- Implement the `CalendarItem.is_all_day` boolean to create - all-day appointments. -- Implement `my_folder.export()` and `my_folder.upload()`. Thanks to - @SamCB! -- Fixed `Account.folders` for non-distinguished folders -- Added `Folder.get_folder_by_name()` to make it easier to get - sub-folders by name. -- Implement `CalendarView` searches as - `my_calendar.view(start=..., end=...)`. A view differs from a normal - `filter()` in that a view expands recurring items and returns - recurring item occurrences that are valid in the time span of - the view. -- Persistent storage location for autodiscover cache is now platform - independent -- Implemented custom extended properties. To add support for your own - custom property, subclass `exchangelib.folders.ExtendedProperty` and - call `register()` on the item class you want to use the extended - property with. When you have registered your extended property, you - can use it exactly like you would use any other attribute on this - item type. If you change your mind, you can remove the extended - property again with `deregister()`: +- Implement the `Contact.physical_addresses` attribute. This is a list of `exchangelib.folders.PhysicalAddress` items. +- Implement the `CalendarItem.is_all_day` boolean to create all-day appointments. +- Implement `my_folder.export()` and `my_folder.upload()`. Thanks to @SamCB! +- Fixed `Account.folders` for non-distinguished folders +- Added `Folder.get_folder_by_name()` to make it easier to get sub-folders by name. +- Implement `CalendarView` searches as + `my_calendar.view(start=..., end=...)`. A view differs from a normal + `filter()` in that a view expands recurring items and returns recurring item occurrences that are valid in the time + span of the view. +- Persistent storage location for autodiscover cache is now platform independent +- Implemented custom extended properties. To add support for your own custom property, + subclass `exchangelib.folders.ExtendedProperty` and call `register()` on the item class you want to use the extended + property with. When you have registered your extended property, you can use it exactly like you would use any other + attribute on this item type. If you change your mind, you can remove the extended property again with `deregister()`: ```python class LunchMenu(ExtendedProperty): @@ -697,11 +630,9 @@ shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFold CalendarItem.deregister('lunch_menu') ``` -- Fixed a bug on folder items where an existing HTML body would be - converted to text when calling `save()`. When creating or updating - an item body, you can use the two new helper classes - `exchangelib.Body` and `exchangelib.HTMLBody` to specify if your - body should be saved as HTML or text. E.g.: +- Fixed a bug on folder items where an existing HTML body would be converted to text when calling `save()`. When + creating or updating an item body, you can use the two new helper classes + `exchangelib.Body` and `exchangelib.HTMLBody` to specify if your body should be saved as HTML or text. E.g.: ```python item = CalendarItem(...) @@ -717,24 +648,19 @@ shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFold 1.7.1 ----- -- Fix bug where fetching items from a folder that can contain multiple - item types (e.g. the Deleted Items folder) would only return one - item type. -- Added `Item.move(to_folder=...)` that moves an item to another - folder, and `Item.refresh()` that updates the Item with data - from EWS. -- Support reverse sort on individual fields in `order_by()`, e.g. - `my_folder.all().order_by('subject', '-start')` -- `Account.bulk_create()` was added to create items that don't need a - folder, e.g. `Message.send()` -- `Account.fetch()` was added to fetch items without knowing the - containing folder. -- Implemented `SendItem` service to send existing messages. -- `Folder.bulk_delete()` was moved to `Account.bulk_delete()` -- `Folder.bulk_update()` was moved to `Account.bulk_update()` and - changed to expect a list of `(Item, fieldnames)` tuples where Item - is e.g. a `Message` instance and `fieldnames` is a list of - attributes names that need updating. E.g.: +- Fix bug where fetching items from a folder that can contain multiple item types (e.g. the Deleted Items folder) would + only return one item type. +- Added `Item.move(to_folder=...)` that moves an item to another folder, and `Item.refresh()` that updates the Item with + data from EWS. +- Support reverse sort on individual fields in `order_by()`, e.g. + `my_folder.all().order_by('subject', '-start')` +- `Account.bulk_create()` was added to create items that don't need a folder, e.g. `Message.send()` +- `Account.fetch()` was added to fetch items without knowing the containing folder. +- Implemented `SendItem` service to send existing messages. +- `Folder.bulk_delete()` was moved to `Account.bulk_delete()` +- `Folder.bulk_update()` was moved to `Account.bulk_update()` and changed to expect a list of `(Item, fieldnames)` + tuples where Item is e.g. a `Message` instance and `fieldnames` is a list of attributes names that need updating. + E.g.: ```python items = [] @@ -753,53 +679,44 @@ shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFold 1.7.0 ----- -- Added the `is_service_account` flag to `Credentials`. - `is_service_account=False` disables the fault-tolerant error - handling policy and enables immediate failures. -- `Configuration` now expects a single `credentials` attribute instead - of separate `username` and `password` attributes. -- Added support for distinguished folders `Account.trash`, - `Account.drafts`, `Account.outbox`, `Account.sent` and - `Account.junk`. -- Renamed `Folder.find_items()` to `Folder.filter()` -- Renamed `Folder.add_items()` to `Folder.bulk_create()` -- Renamed `Folder.update_items()` to `Folder.bulk_update()` -- Renamed `Folder.delete_items()` to `Folder.bulk_delete()` -- Renamed `Folder.get_items()` to `Folder.fetch()` -- Made various policies for message saving, meeting invitation - sending, conflict resolution, task occurrences and deletion - available on `bulk_create()`, `bulk_update()` and `bulk_delete()`. -- Added convenience methods `Item.save()`, `Item.delete()`, - `Item.soft_delete()`, `Item.move_to_trash()`, and methods - `Message.send()` and `Message.send_and_save()` that are specific to - `Message` objects. These methods make it easier to create, update - and delete single items. -- Removed `fetch(.., with_extra=True)` in favor of the more - fine-grained `fetch(.., only_fields=[...])` -- Added a `QuerySet` class that supports QuerySet-returning methods - `filter()`, `exclude()`, `only()`, `order_by()`, - `reverse()``values()` and `values_list()` that all allow - for chaining. `QuerySet` also has methods `iterator()`, `get()`, - `count()`, `exists()` and `delete()`. All these methods behave like - their counterparts in Django. +- Added the `is_service_account` flag to `Credentials`. + `is_service_account=False` disables the fault-tolerant error handling policy and enables immediate failures. +- `Configuration` now expects a single `credentials` attribute instead of separate `username` and `password` attributes. +- Added support for distinguished folders `Account.trash`, + `Account.drafts`, `Account.outbox`, `Account.sent` and + `Account.junk`. +- Renamed `Folder.find_items()` to `Folder.filter()` +- Renamed `Folder.add_items()` to `Folder.bulk_create()` +- Renamed `Folder.update_items()` to `Folder.bulk_update()` +- Renamed `Folder.delete_items()` to `Folder.bulk_delete()` +- Renamed `Folder.get_items()` to `Folder.fetch()` +- Made various policies for message saving, meeting invitation sending, conflict resolution, task occurrences and + deletion available on `bulk_create()`, `bulk_update()` and `bulk_delete()`. +- Added convenience methods `Item.save()`, `Item.delete()`, + `Item.soft_delete()`, `Item.move_to_trash()`, and methods + `Message.send()` and `Message.send_and_save()` that are specific to + `Message` objects. These methods make it easier to create, update and delete single items. +- Removed `fetch(.., with_extra=True)` in favor of the more fine-grained `fetch(.., only_fields=[...])` +- Added a `QuerySet` class that supports QuerySet-returning methods + `filter()`, `exclude()`, `only()`, `order_by()`, + `reverse()``values()` and `values_list()` that all allow for chaining. `QuerySet` also has methods `iterator()` + , `get()`, + `count()`, `exists()` and `delete()`. All these methods behave like their counterparts in Django. 1.6.2 ----- -- Use of `my_folder.with_extra_fields = True` to get the extra fields - in `Item.EXTRA_ITEM_FIELDS` is deprecated (it was a kludge anyway). - Instead, use `my_folder.get_items(ids, with_extra=[True, False])`. - The default was also changed to `True`, to avoid head-scratching - with newcomers. +- Use of `my_folder.with_extra_fields = True` to get the extra fields in `Item.EXTRA_ITEM_FIELDS` is deprecated (it was + a kludge anyway). Instead, use `my_folder.get_items(ids, with_extra=[True, False])`. The default was also changed + to `True`, to avoid head-scratching with newcomers. 1.6.1 ----- -- Simplify `Q` objects and `Restriction.from_source()` by using Item - attribute names in expressions and kwargs instead of EWS - FieldURI values. Change `Folder.find_items()` to accept either a - search expression, or a list of `Q` objects just like Django - `filter()` does. E.g.: +- Simplify `Q` objects and `Restriction.from_source()` by using Item attribute names in expressions and kwargs instead + of EWS FieldURI values. Change `Folder.find_items()` to accept either a search expression, or a list of `Q` objects + just like Django + `filter()` does. E.g.: ```python ids = account.calendar.find_items( @@ -814,17 +731,16 @@ shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFold 1.6.0 ----- -- Complete rewrite of `Folder.find_items()`. The old `start`, `end`, - `subject` and `categories` args are deprecated in favor of a Django - QuerySet filter() syntax. The supported lookup types are `__gt`, - `__lt`, `__gte`, `__lte`, `__range`, `__in`, `__exact`, `__iexact`, - `__contains`, `__icontains`, `__contains`, `__icontains`, - `__startswith`, `__istartswith`, plus an additional `__not` which - translates to `!=`. Additionally, *all* fields on the item are now - supported in `Folder.find_items()`. +- Complete rewrite of `Folder.find_items()`. The old `start`, `end`, + `subject` and `categories` args are deprecated in favor of a Django QuerySet filter() syntax. The supported lookup + types are `__gt`, + `__lt`, `__gte`, `__lte`, `__range`, `__in`, `__exact`, `__iexact`, + `__contains`, `__icontains`, `__contains`, `__icontains`, + `__startswith`, `__istartswith`, plus an additional `__not` which translates to `!=`. Additionally, *all* fields on + the item are now supported in `Folder.find_items()`. - **WARNING**: This change is backwards-incompatible! Old uses of - `Folder.find_items()` like this: + **WARNING**: This change is backwards-incompatible! Old uses of + `Folder.find_items()` like this: ```python ids = account.calendar.find_items( @@ -834,7 +750,7 @@ shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFold ) ``` - must be rewritten like this: + must be rewritten like this: ```python ids = account.calendar.find_items( @@ -844,35 +760,31 @@ shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFold ) ``` - failing to do so will most likely result in empty or wrong results. + failing to do so will most likely result in empty or wrong results. -- Added a `exchangelib.restrictions.Q` class much like Django Q - objects that can be used to create even more complex filtering. Q - objects must be passed directly to `exchangelib.services.FindItem`. +- Added a `exchangelib.restrictions.Q` class much like Django Q objects that can be used to create even more complex + filtering. Q objects must be passed directly to `exchangelib.services.FindItem`. 1.3.6 ----- -- Don't require sequence arguments to `Folder.*_items()` methods to - support `len()` (e.g. generators and `map` instances are - now supported) -- Allow empty sequences as argument to `Folder.*_items()` methods +- Don't require sequence arguments to `Folder.*_items()` methods to support `len()` (e.g. generators and `map` instances + are now supported) +- Allow empty sequences as argument to `Folder.*_items()` methods 1.3.4 ----- -- Add support for `required_attendees`, `optional_attendees` and - `resources` attribute on `folders.CalendarItem`. These are - implemented with a new `folders.Attendee` class. +- Add support for `required_attendees`, `optional_attendees` and + `resources` attribute on `folders.CalendarItem`. These are implemented with a new `folders.Attendee` class. 1.3.3 ----- -- Add support for `organizer` attribute on `CalendarItem`. Implemented - with a new `folders.Mailbox` class. +- Add support for `organizer` attribute on `CalendarItem`. Implemented with a new `folders.Mailbox` class. 1.2 --- -- Initial import +- Initial import From 0757658f27fc5358b64c419c744507e52e063120 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Fri, 19 Nov 2021 22:40:39 +0100 Subject: [PATCH 015/509] Bump version --- CHANGELOG.md | 5 + docs/exchangelib/ewsdatetime.html | 35 ++++++- docs/exchangelib/folders/collections.html | 12 +-- docs/exchangelib/folders/index.html | 10 +- docs/exchangelib/folders/queryset.html | 3 + docs/exchangelib/index.html | 37 ++++++-- docs/exchangelib/properties.html | 4 +- docs/exchangelib/services/common.html | 102 ++++++++++++++------- docs/exchangelib/services/find_folder.html | 9 +- docs/exchangelib/services/find_item.html | 9 +- docs/exchangelib/services/find_people.html | 29 ++++-- docs/exchangelib/services/index.html | 32 ++++--- exchangelib/__init__.py | 2 +- 13 files changed, 197 insertions(+), 92 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99c538ba..b40a824b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ Change Log HEAD ---- +4.6.1 +----- + +- Support `tzlocal>=4.1` +- Bug fixes for paging in multi-folder requests. 4.6.0 ----- diff --git a/docs/exchangelib/ewsdatetime.html b/docs/exchangelib/ewsdatetime.html index 0115c703..03ea9c67 100644 --- a/docs/exchangelib/ewsdatetime.html +++ b/docs/exchangelib/ewsdatetime.html @@ -278,6 +278,11 @@

Module exchangelib.ewsdatetime

def from_pytz(cls, tz): return cls(tz.zone) + @classmethod + def from_datetime(cls, tz): + """Convert from a standard library `datetime.timezone` instance.""" + return cls(tz.tzname(None)) + @classmethod def from_dateutil(cls, tz): # Objects returned by dateutil.tz.tzlocal() and dateutil.tz.gettz() are not supported. They @@ -300,10 +305,11 @@

Module exchangelib.ewsdatetime

return { cls.__module__.split('.')[0]: lambda z: z, 'backports': cls.from_zoneinfo, + 'datetime': cls.from_datetime, 'dateutil': cls.from_dateutil, 'pytz': cls.from_pytz, 'zoneinfo': cls.from_zoneinfo, - 'pytz_deprecation_shim': lambda z: cls.from_zoneinfo(z.unwrap_shim()) + 'pytz_deprecation_shim': lambda z: cls.from_timezone(z.unwrap_shim()) }[tz_module](tz) except KeyError: raise TypeError('Unsupported tzinfo type: %r' % tz) @@ -919,6 +925,11 @@

Methods

def from_pytz(cls, tz): return cls(tz.zone) + @classmethod + def from_datetime(cls, tz): + """Convert from a standard library `datetime.timezone` instance.""" + return cls(tz.tzname(None)) + @classmethod def from_dateutil(cls, tz): # Objects returned by dateutil.tz.tzlocal() and dateutil.tz.gettz() are not supported. They @@ -941,10 +952,11 @@

Methods

return { cls.__module__.split('.')[0]: lambda z: z, 'backports': cls.from_zoneinfo, + 'datetime': cls.from_datetime, 'dateutil': cls.from_dateutil, 'pytz': cls.from_pytz, 'zoneinfo': cls.from_zoneinfo, - 'pytz_deprecation_shim': lambda z: cls.from_zoneinfo(z.unwrap_shim()) + 'pytz_deprecation_shim': lambda z: cls.from_timezone(z.unwrap_shim()) }[tz_module](tz) except KeyError: raise TypeError('Unsupported tzinfo type: %r' % tz) @@ -1009,6 +1021,21 @@

Class variables

Static methods

+
+def from_datetime(tz) +
+
+

Convert from a standard library datetime.timezone instance.

+
+ +Expand source code + +
@classmethod
+def from_datetime(cls, tz):
+    """Convert from a standard library `datetime.timezone` instance."""
+    return cls(tz.tzname(None))
+
+
def from_dateutil(tz)
@@ -1083,10 +1110,11 @@

Static methods

return { cls.__module__.split('.')[0]: lambda z: z, 'backports': cls.from_zoneinfo, + 'datetime': cls.from_datetime, 'dateutil': cls.from_dateutil, 'pytz': cls.from_pytz, 'zoneinfo': cls.from_zoneinfo, - 'pytz_deprecation_shim': lambda z: cls.from_zoneinfo(z.unwrap_shim()) + 'pytz_deprecation_shim': lambda z: cls.from_timezone(z.unwrap_shim()) }[tz_module](tz) except KeyError: raise TypeError('Unsupported tzinfo type: %r' % tz) @@ -1252,6 +1280,7 @@

  • IANA_TO_MS_MAP
  • MS_TO_IANA_MAP
  • +
  • from_datetime
  • from_dateutil
  • from_ms_id
  • from_pytz
  • diff --git a/docs/exchangelib/folders/collections.html b/docs/exchangelib/folders/collections.html index b7fec265..a51e66ae 100644 --- a/docs/exchangelib/folders/collections.html +++ b/docs/exchangelib/folders/collections.html @@ -214,8 +214,8 @@

    Module exchangelib.folders.collections

    query_string = None log.debug( 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', - self.folders, self.account, + self.folders, shape, depth, additional_fields, @@ -287,7 +287,7 @@

    Module exchangelib.folders.collections

    restriction = Restriction(q, folders=[folder], applies_to=Restriction.ITEMS) query_string = None yield from FindPeople(account=self.account, chunk_size=page_size).call( - folder=[folder], + folder=folder, additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, @@ -698,8 +698,8 @@

    Classes

    query_string = None log.debug( 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', - self.folders, self.account, + self.folders, shape, depth, additional_fields, @@ -771,7 +771,7 @@

    Classes

    restriction = Restriction(q, folders=[folder], applies_to=Restriction.ITEMS) query_string = None yield from FindPeople(account=self.account, chunk_size=page_size).call( - folder=[folder], + folder=folder, additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, @@ -1271,8 +1271,8 @@

    Examples

    query_string = None log.debug( 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', - self.folders, self.account, + self.folders, shape, depth, additional_fields, @@ -1356,7 +1356,7 @@

    Examples

    restriction = Restriction(q, folders=[folder], applies_to=Restriction.ITEMS) query_string = None yield from FindPeople(account=self.account, chunk_size=page_size).call( - folder=[folder], + folder=folder, additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, diff --git a/docs/exchangelib/folders/index.html b/docs/exchangelib/folders/index.html index 9703061f..db127e2a 100644 --- a/docs/exchangelib/folders/index.html +++ b/docs/exchangelib/folders/index.html @@ -4663,8 +4663,8 @@

    Inherited members

    query_string = None log.debug( 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', - self.folders, self.account, + self.folders, shape, depth, additional_fields, @@ -4736,7 +4736,7 @@

    Inherited members

    restriction = Restriction(q, folders=[folder], applies_to=Restriction.ITEMS) query_string = None yield from FindPeople(account=self.account, chunk_size=page_size).call( - folder=[folder], + folder=folder, additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, @@ -5236,8 +5236,8 @@

    Examples

    query_string = None log.debug( 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', - self.folders, self.account, + self.folders, shape, depth, additional_fields, @@ -5321,7 +5321,7 @@

    Examples

    restriction = Restriction(q, folders=[folder], applies_to=Restriction.ITEMS) query_string = None yield from FindPeople(account=self.account, chunk_size=page_size).call( - folder=[folder], + folder=folder, additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, @@ -5734,6 +5734,7 @@

    Inherited members

    from .base import Folder # Subfolders will always be of class Folder all_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=None) + all_fields.update(Folder.attribute_fields()) only_fields = [] for arg in args: for field_path in all_fields: @@ -5943,6 +5944,7 @@

    Methods

    from .base import Folder # Subfolders will always be of class Folder all_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=None) + all_fields.update(Folder.attribute_fields()) only_fields = [] for arg in args: for field_path in all_fields: diff --git a/docs/exchangelib/folders/queryset.html b/docs/exchangelib/folders/queryset.html index bf266603..00217b69 100644 --- a/docs/exchangelib/folders/queryset.html +++ b/docs/exchangelib/folders/queryset.html @@ -71,6 +71,7 @@

    Module exchangelib.folders.queryset

    from .base import Folder # Subfolders will always be of class Folder all_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=None) + all_fields.update(Folder.attribute_fields()) only_fields = [] for arg in args: for field_path in all_fields: @@ -246,6 +247,7 @@

    Classes

    from .base import Folder # Subfolders will always be of class Folder all_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=None) + all_fields.update(Folder.attribute_fields()) only_fields = [] for arg in args: for field_path in all_fields: @@ -455,6 +457,7 @@

    Methods

    from .base import Folder # Subfolders will always be of class Folder all_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=None) + all_fields.update(Folder.attribute_fields()) only_fields = [] for arg in args: for field_path in all_fields: diff --git a/docs/exchangelib/index.html b/docs/exchangelib/index.html index 78758a9f..e7ce07eb 100644 --- a/docs/exchangelib/index.html +++ b/docs/exchangelib/index.html @@ -46,7 +46,7 @@

    Package exchangelib

    from .transport import BASIC, DIGEST, NTLM, GSSAPI, SSPI, OAUTH2, CBA from .version import Build, Version -__version__ = '4.6.0' +__version__ = '4.6.1' __all__ = [ '__version__', @@ -5576,6 +5576,11 @@

    Methods

    def from_pytz(cls, tz): return cls(tz.zone) + @classmethod + def from_datetime(cls, tz): + """Convert from a standard library `datetime.timezone` instance.""" + return cls(tz.tzname(None)) + @classmethod def from_dateutil(cls, tz): # Objects returned by dateutil.tz.tzlocal() and dateutil.tz.gettz() are not supported. They @@ -5598,10 +5603,11 @@

    Methods

    return { cls.__module__.split('.')[0]: lambda z: z, 'backports': cls.from_zoneinfo, + 'datetime': cls.from_datetime, 'dateutil': cls.from_dateutil, 'pytz': cls.from_pytz, 'zoneinfo': cls.from_zoneinfo, - 'pytz_deprecation_shim': lambda z: cls.from_zoneinfo(z.unwrap_shim()) + 'pytz_deprecation_shim': lambda z: cls.from_timezone(z.unwrap_shim()) }[tz_module](tz) except KeyError: raise TypeError('Unsupported tzinfo type: %r' % tz) @@ -5666,6 +5672,21 @@

    Class variables

    Static methods

    +
    +def from_datetime(tz) +
    +
    +

    Convert from a standard library datetime.timezone instance.

    +
    + +Expand source code + +
    @classmethod
    +def from_datetime(cls, tz):
    +    """Convert from a standard library `datetime.timezone` instance."""
    +    return cls(tz.tzname(None))
    +
    +
    def from_dateutil(tz)
    @@ -5740,10 +5761,11 @@

    Static methods

    return { cls.__module__.split('.')[0]: lambda z: z, 'backports': cls.from_zoneinfo, + 'datetime': cls.from_datetime, 'dateutil': cls.from_dateutil, 'pytz': cls.from_pytz, 'zoneinfo': cls.from_zoneinfo, - 'pytz_deprecation_shim': lambda z: cls.from_zoneinfo(z.unwrap_shim()) + 'pytz_deprecation_shim': lambda z: cls.from_timezone(z.unwrap_shim()) }[tz_module](tz) except KeyError: raise TypeError('Unsupported tzinfo type: %r' % tz)
    @@ -7481,8 +7503,8 @@

    Inherited members

    query_string = None log.debug( 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', - self.folders, self.account, + self.folders, shape, depth, additional_fields, @@ -7554,7 +7576,7 @@

    Inherited members

    restriction = Restriction(q, folders=[folder], applies_to=Restriction.ITEMS) query_string = None yield from FindPeople(account=self.account, chunk_size=page_size).call( - folder=[folder], + folder=folder, additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, @@ -8054,8 +8076,8 @@

    Examples

    query_string = None log.debug( 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', - self.folders, self.account, + self.folders, shape, depth, additional_fields, @@ -8139,7 +8161,7 @@

    Examples

    restriction = Restriction(q, folders=[folder], applies_to=Restriction.ITEMS) query_string = None yield from FindPeople(account=self.account, chunk_size=page_size).call( - folder=[folder], + folder=folder, additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, @@ -12765,6 +12787,7 @@

    EWS
    • IANA_TO_MS_MAP
    • MS_TO_IANA_MAP
    • +
    • from_datetime
    • from_dateutil
    • from_ms_id
    • from_pytz
    • diff --git a/docs/exchangelib/properties.html b/docs/exchangelib/properties.html index 7bced661..2fe0294f 100644 --- a/docs/exchangelib/properties.html +++ b/docs/exchangelib/properties.html @@ -745,7 +745,7 @@

      Module exchangelib.properties

      time_window = EWSElementField(value_cls=TimeWindow, is_required=True) # Interval value is in minutes - merged_free_busy_interval = IntegerField(field_uri='MergedFreeBusyIntervalInMinutes', min=6, max=1440, default=30, + merged_free_busy_interval = IntegerField(field_uri='MergedFreeBusyIntervalInMinutes', min=5, max=1440, default=30, is_required=True) requested_view = ChoiceField(field_uri='RequestedView', choices={Choice(c) for c in REQUESTED_VIEWS}, is_required=True) # Choice('None') is also valid, but only for responses @@ -5258,7 +5258,7 @@

      Inherited members

      time_window = EWSElementField(value_cls=TimeWindow, is_required=True) # Interval value is in minutes - merged_free_busy_interval = IntegerField(field_uri='MergedFreeBusyIntervalInMinutes', min=6, max=1440, default=30, + merged_free_busy_interval = IntegerField(field_uri='MergedFreeBusyIntervalInMinutes', min=5, max=1440, default=30, is_required=True) requested_view = ChoiceField(field_uri='RequestedView', choices={Choice(c) for c in REQUESTED_VIEWS}, is_required=True) # Choice('None') is also valid, but only for responses
      diff --git a/docs/exchangelib/services/common.html b/docs/exchangelib/services/common.html index 45ec4c75..53a47f9b 100644 --- a/docs/exchangelib/services/common.html +++ b/docs/exchangelib/services/common.html @@ -692,20 +692,27 @@

      Module exchangelib.services.common

      log.warning('Inconsistent next_offset values: %r. Using lowest value', next_offsets) return min(next_offsets) - def _paged_call(self, payload_func, max_items, expected_message_count, **kwargs): + def _paged_call(self, payload_func, max_items, folders, **kwargs): """Call a service that supports paging requests. Return a generator over all response items. Keeps track of all paging-related counters. """ - paging_infos = [dict(item_count=0, next_offset=None) for _ in range(expected_message_count)] + paging_infos = {f: dict(item_count=0, next_offset=None) for f in folders} common_next_offset = kwargs['offset'] total_item_count = 0 while True: + if not paging_infos: + # Paging is done for all folders + break log.debug('Getting page at offset %s (max_items %s)', common_next_offset, max_items) kwargs['offset'] = common_next_offset - pages = self._get_pages(payload_func, kwargs, expected_message_count) - for (page, next_offset), paging_info in zip(pages, paging_infos): + kwargs['folders'] = paging_infos.keys() # Only request the paging of the remaining folders. + pages = self._get_pages(payload_func, kwargs, len(paging_infos)) + for (page, next_offset), (f, paging_info) in zip(pages, list(paging_infos.items())): paging_info['next_offset'] = next_offset if isinstance(page, Exception): + # Assume this folder no longer works. Don't attempt to page it again. + log.debug('Exception occurred for folder %s. Removing.', f) + del paging_infos[f] yield page continue if page is not None: @@ -718,8 +725,11 @@

      Module exchangelib.services.common

      log.debug("'max_items' count reached (inner)") break if not paging_info['next_offset']: - # Paging is done for this message + # Paging is done for this folder. Don't attempt to page it again. + log.debug('Paging has completed for folder %s. Removing.', f) + del paging_infos[f] continue + log.debug('Folder %s still has items', f) # Check sanity of paging offsets, but don't fail. When we are iterating huge collections that take a # long time to complete, the collection may change while we are iterating. This can affect the # 'next_offset' value and make it inconsistent with the number of already collected items. @@ -733,24 +743,33 @@

      Module exchangelib.services.common

      if max_items and total_item_count >= max_items: log.debug("'max_items' count reached (outer)") break - common_next_offset = self._get_next_offset(paging_infos) + common_next_offset = self._get_next_offset(paging_infos.values()) if common_next_offset is None: - # Paging is done for all messages + # Paging is done for all folders break @staticmethod def _get_paging_values(elem): """Read paging information from the paging container element.""" - is_last_page = elem.get('IncludesLastItemInRange').lower() in ('true', '0') - offset = elem.get('IndexedPagingOffset') - if offset is None and not is_last_page: - log.debug("Not last page in range, but Exchange didn't send a page offset. Assuming first page") - offset = '1' - next_offset = None if is_last_page else int(offset) + offset_attr = elem.get('IndexedPagingOffset') + next_offset = None if offset_attr is None else int(offset_attr) item_count = int(elem.get('TotalItemsInView')) - if not item_count and next_offset is not None: - raise ValueError("Expected empty 'next_offset' when 'item_count' is 0") - log.debug('Got page with next offset %s (last_page %s)', next_offset, is_last_page) + is_last_page = elem.get('IncludesLastItemInRange').lower() in ('true', '0') + log.debug('Got page with offset %s, item_count %s, last_page %s', next_offset, item_count, is_last_page) + # Clean up contradictory paging values + if next_offset is None and not is_last_page: + log.debug("Not last page in range, but server didn't send a page offset. Assuming first page") + next_offset = 1 + if next_offset is not None and is_last_page: + if next_offset != item_count: + log.debug("Last page in range, but we still got an offset. Assuming paging has completed") + next_offset = None + if not item_count and not is_last_page: + log.debug("Not last page in range, but also no items left. Assuming paging has completed") + next_offset = None + if item_count and next_offset == 0: + log.debug("Non-zero offset, but also no items left. Assuming paging has completed") + next_offset = None return item_count, next_offset def _get_page(self, message): @@ -1762,20 +1781,27 @@

      Inherited members

      log.warning('Inconsistent next_offset values: %r. Using lowest value', next_offsets) return min(next_offsets) - def _paged_call(self, payload_func, max_items, expected_message_count, **kwargs): + def _paged_call(self, payload_func, max_items, folders, **kwargs): """Call a service that supports paging requests. Return a generator over all response items. Keeps track of all paging-related counters. """ - paging_infos = [dict(item_count=0, next_offset=None) for _ in range(expected_message_count)] + paging_infos = {f: dict(item_count=0, next_offset=None) for f in folders} common_next_offset = kwargs['offset'] total_item_count = 0 while True: + if not paging_infos: + # Paging is done for all folders + break log.debug('Getting page at offset %s (max_items %s)', common_next_offset, max_items) kwargs['offset'] = common_next_offset - pages = self._get_pages(payload_func, kwargs, expected_message_count) - for (page, next_offset), paging_info in zip(pages, paging_infos): + kwargs['folders'] = paging_infos.keys() # Only request the paging of the remaining folders. + pages = self._get_pages(payload_func, kwargs, len(paging_infos)) + for (page, next_offset), (f, paging_info) in zip(pages, list(paging_infos.items())): paging_info['next_offset'] = next_offset if isinstance(page, Exception): + # Assume this folder no longer works. Don't attempt to page it again. + log.debug('Exception occurred for folder %s. Removing.', f) + del paging_infos[f] yield page continue if page is not None: @@ -1788,8 +1814,11 @@

      Inherited members

      log.debug("'max_items' count reached (inner)") break if not paging_info['next_offset']: - # Paging is done for this message + # Paging is done for this folder. Don't attempt to page it again. + log.debug('Paging has completed for folder %s. Removing.', f) + del paging_infos[f] continue + log.debug('Folder %s still has items', f) # Check sanity of paging offsets, but don't fail. When we are iterating huge collections that take a # long time to complete, the collection may change while we are iterating. This can affect the # 'next_offset' value and make it inconsistent with the number of already collected items. @@ -1803,24 +1832,33 @@

      Inherited members

      if max_items and total_item_count >= max_items: log.debug("'max_items' count reached (outer)") break - common_next_offset = self._get_next_offset(paging_infos) + common_next_offset = self._get_next_offset(paging_infos.values()) if common_next_offset is None: - # Paging is done for all messages + # Paging is done for all folders break @staticmethod def _get_paging_values(elem): """Read paging information from the paging container element.""" - is_last_page = elem.get('IncludesLastItemInRange').lower() in ('true', '0') - offset = elem.get('IndexedPagingOffset') - if offset is None and not is_last_page: - log.debug("Not last page in range, but Exchange didn't send a page offset. Assuming first page") - offset = '1' - next_offset = None if is_last_page else int(offset) + offset_attr = elem.get('IndexedPagingOffset') + next_offset = None if offset_attr is None else int(offset_attr) item_count = int(elem.get('TotalItemsInView')) - if not item_count and next_offset is not None: - raise ValueError("Expected empty 'next_offset' when 'item_count' is 0") - log.debug('Got page with next offset %s (last_page %s)', next_offset, is_last_page) + is_last_page = elem.get('IncludesLastItemInRange').lower() in ('true', '0') + log.debug('Got page with offset %s, item_count %s, last_page %s', next_offset, item_count, is_last_page) + # Clean up contradictory paging values + if next_offset is None and not is_last_page: + log.debug("Not last page in range, but server didn't send a page offset. Assuming first page") + next_offset = 1 + if next_offset is not None and is_last_page: + if next_offset != item_count: + log.debug("Last page in range, but we still got an offset. Assuming paging has completed") + next_offset = None + if not item_count and not is_last_page: + log.debug("Not last page in range, but also no items left. Assuming paging has completed") + next_offset = None + if item_count and next_offset == 0: + log.debug("Non-zero offset, but also no items left. Assuming paging has completed") + next_offset = None return item_count, next_offset def _get_page(self, message): diff --git a/docs/exchangelib/services/find_folder.html b/docs/exchangelib/services/find_folder.html index a8459a1a..720aea51 100644 --- a/docs/exchangelib/services/find_folder.html +++ b/docs/exchangelib/services/find_folder.html @@ -65,9 +65,8 @@

      Module exchangelib.services.find_folder

      return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - expected_message_count=len(folders), + folders=folders, **dict( - folders=folders, additional_fields=additional_fields, restriction=restriction, shape=shape, @@ -163,9 +162,8 @@

      Classes

      return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - expected_message_count=len(folders), + folders=folders, **dict( - folders=folders, additional_fields=additional_fields, restriction=restriction, shape=shape, @@ -272,9 +270,8 @@

      Methods

      return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - expected_message_count=len(folders), + folders=folders, **dict( - folders=folders, additional_fields=additional_fields, restriction=restriction, shape=shape, diff --git a/docs/exchangelib/services/find_item.html b/docs/exchangelib/services/find_item.html index 9b24365d..d2f23e50 100644 --- a/docs/exchangelib/services/find_item.html +++ b/docs/exchangelib/services/find_item.html @@ -68,9 +68,8 @@

      Module exchangelib.services.find_item

      return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - expected_message_count=len(folders), + folders=folders, **dict( - folders=folders, additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, @@ -187,9 +186,8 @@

      Classes

      return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - expected_message_count=len(folders), + folders=folders, **dict( - folders=folders, additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, @@ -318,9 +316,8 @@

      Methods

      return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - expected_message_count=len(folders), + folders=folders, **dict( - folders=folders, additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, diff --git a/docs/exchangelib/services/find_people.html b/docs/exchangelib/services/find_people.html index 4e7ecd4a..8452aafe 100644 --- a/docs/exchangelib/services/find_people.html +++ b/docs/exchangelib/services/find_people.html @@ -70,9 +70,8 @@

      Module exchangelib.services.find_people

      return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - expected_message_count=1, # We can only query one folder, so there will only be one element in response + folders=[folder], # We can only query one folder, so there will only be one element in response **dict( - folder=folder, additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, @@ -95,8 +94,12 @@

      Module exchangelib.services.find_people

      continue yield Persona.from_xml(elem, account=self.account) - def get_payload(self, folder, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, + def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, offset=0): + folders = list(folders) + if len(folders) != 1: + raise ValueError('%r can only query one folder' % self.SERVICE_NAME) + folder = folders[0] findpeople = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth)) personashape = create_shape_element( tag='m:PersonaShape', shape=shape, additional_fields=additional_fields, version=self.account.version @@ -193,9 +196,8 @@

      Classes

      return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - expected_message_count=1, # We can only query one folder, so there will only be one element in response + folders=[folder], # We can only query one folder, so there will only be one element in response **dict( - folder=folder, additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, @@ -218,8 +220,12 @@

      Classes

      continue yield Persona.from_xml(elem, account=self.account) - def get_payload(self, folder, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, + def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, offset=0): + folders = list(folders) + if len(folders) != 1: + raise ValueError('%r can only query one folder' % self.SERVICE_NAME) + folder = folders[0] findpeople = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth)) personashape = create_shape_element( tag='m:PersonaShape', shape=shape, additional_fields=additional_fields, version=self.account.version @@ -327,9 +333,8 @@

      Methods

      return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - expected_message_count=1, # We can only query one folder, so there will only be one element in response + folders=[folder], # We can only query one folder, so there will only be one element in response **dict( - folder=folder, additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, @@ -343,7 +348,7 @@

      Methods

      -def get_payload(self, folder, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, offset=0) +def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, offset=0)
      @@ -351,8 +356,12 @@

      Methods

      Expand source code -
      def get_payload(self, folder, additional_fields, restriction, order_fields, query_string, shape, depth, page_size,
      +
      def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size,
                       offset=0):
      +    folders = list(folders)
      +    if len(folders) != 1:
      +        raise ValueError('%r can only query one folder' % self.SERVICE_NAME)
      +    folder = folders[0]
           findpeople = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth))
           personashape = create_shape_element(
               tag='m:PersonaShape', shape=shape, additional_fields=additional_fields, version=self.account.version
      diff --git a/docs/exchangelib/services/index.html b/docs/exchangelib/services/index.html
      index 9c266390..cd15d9e8 100644
      --- a/docs/exchangelib/services/index.html
      +++ b/docs/exchangelib/services/index.html
      @@ -1979,9 +1979,8 @@ 

      Inherited members

      return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - expected_message_count=len(folders), + folders=folders, **dict( - folders=folders, additional_fields=additional_fields, restriction=restriction, shape=shape, @@ -2088,9 +2087,8 @@

      Methods

      return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - expected_message_count=len(folders), + folders=folders, **dict( - folders=folders, additional_fields=additional_fields, restriction=restriction, shape=shape, @@ -2196,9 +2194,8 @@

      Inherited members

      return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - expected_message_count=len(folders), + folders=folders, **dict( - folders=folders, additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, @@ -2327,9 +2324,8 @@

      Methods

      return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - expected_message_count=len(folders), + folders=folders, **dict( - folders=folders, additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, @@ -2446,9 +2442,8 @@

      Inherited members

      return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - expected_message_count=1, # We can only query one folder, so there will only be one element in response + folders=[folder], # We can only query one folder, so there will only be one element in response **dict( - folder=folder, additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, @@ -2471,8 +2466,12 @@

      Inherited members

      continue yield Persona.from_xml(elem, account=self.account) - def get_payload(self, folder, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, + def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, offset=0): + folders = list(folders) + if len(folders) != 1: + raise ValueError('%r can only query one folder' % self.SERVICE_NAME) + folder = folders[0] findpeople = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth)) personashape = create_shape_element( tag='m:PersonaShape', shape=shape, additional_fields=additional_fields, version=self.account.version @@ -2580,9 +2579,8 @@

      Methods

      return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - expected_message_count=1, # We can only query one folder, so there will only be one element in response + folders=[folder], # We can only query one folder, so there will only be one element in response **dict( - folder=folder, additional_fields=additional_fields, restriction=restriction, order_fields=order_fields, @@ -2596,7 +2594,7 @@

      Methods

      -def get_payload(self, folder, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, offset=0) +def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, offset=0)
      @@ -2604,8 +2602,12 @@

      Methods

      Expand source code -
      def get_payload(self, folder, additional_fields, restriction, order_fields, query_string, shape, depth, page_size,
      +
      def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size,
                       offset=0):
      +    folders = list(folders)
      +    if len(folders) != 1:
      +        raise ValueError('%r can only query one folder' % self.SERVICE_NAME)
      +    folder = folders[0]
           findpeople = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth))
           personashape = create_shape_element(
               tag='m:PersonaShape', shape=shape, additional_fields=additional_fields, version=self.account.version
      diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py
      index c56c0a3e..d8d1bf83 100644
      --- a/exchangelib/__init__.py
      +++ b/exchangelib/__init__.py
      @@ -18,7 +18,7 @@
       from .transport import BASIC, DIGEST, NTLM, GSSAPI, SSPI, OAUTH2, CBA
       from .version import Build, Version
       
      -__version__ = '4.6.0'
      +__version__ = '4.6.1'
       
       __all__ = [
           '__version__',
      
      From 1c951c57fcbfc1339d71dbae33f3c90fc0d6ce94 Mon Sep 17 00:00:00 2001
      From: Erik Cederstrand 
      Date: Tue, 7 Dec 2021 13:05:58 +0100
      Subject: [PATCH 016/509] Code and comment nits
      
      ---
       exchangelib/restriction.py        |  2 +-
       tests/test_extended_properties.py | 12 ++++++------
       tests/test_items/test_queryset.py |  2 +-
       3 files changed, 8 insertions(+), 8 deletions(-)
      
      diff --git a/exchangelib/restriction.py b/exchangelib/restriction.py
      index 00e30f3a..1b599969 100644
      --- a/exchangelib/restriction.py
      +++ b/exchangelib/restriction.py
      @@ -129,7 +129,7 @@ def _get_children_from_kwarg(self, key, value, is_single_kwarg=False):
                   # 'Contains' element which only supports matching on string elements, not arrays.
                   #
                   # Exact matching of categories (i.e. match ['a', 'b'] but not ['a', 'b', 'c']) could be implemented by
      -            # post-processing items by fetch the categories field unconditionally and removing the items that don't
      +            # post-processing items by fetching the categories field unconditionally and removing the items that don't
                   # have an exact match.
                   if lookup == self.LOOKUP_IN:
                       # EWS doesn't have an '__in' operator. Allow '__in' lookups on list and non-list field types,
      diff --git a/tests/test_extended_properties.py b/tests/test_extended_properties.py
      index 7d444c42..e0d35648 100644
      --- a/tests/test_extended_properties.py
      +++ b/tests/test_extended_properties.py
      @@ -37,7 +37,7 @@ class TestProp(ExtendedProperty):
                   # Test item creation, refresh, and update
                   item = self.get_test_item(folder=self.test_folder)
                   prop_val = item.dead_beef
      -            self.assertTrue(isinstance(prop_val, int))
      +            self.assertIsInstance(prop_val, int)
                   item.save()
                   item.refresh()
                   self.assertEqual(prop_val, item.dead_beef)
      @@ -69,7 +69,7 @@ class TestArayProp(ExtendedProperty):
                   # Test item creation, refresh, and update
                   item = self.get_test_item(folder=self.test_folder)
                   prop_val = item.dead_beef_array
      -            self.assertTrue(isinstance(prop_val, list))
      +            self.assertIsInstance(prop_val, list)
                   item.save()
                   item.refresh()
                   self.assertEqual(prop_val, item.dead_beef_array)
      @@ -88,7 +88,7 @@ def test_extended_property_with_tag(self):
                   # Test item creation, refresh, and update
                   item = self.get_test_item(folder=self.test_folder)
                   prop_val = item.my_flag
      -            self.assertTrue(isinstance(prop_val, int))
      +            self.assertIsInstance(prop_val, int)
                   item.save()
                   item.refresh()
                   self.assertEqual(prop_val, item.my_flag)
      @@ -115,7 +115,7 @@ def test_extended_property_with_string_tag(self):
                   # Test item creation, refresh, and update
                   item = self.get_test_item(folder=self.test_folder)
                   prop_val = item.my_flag
      -            self.assertTrue(isinstance(prop_val, int))
      +            self.assertIsInstance(prop_val, int)
                   item.save()
                   item.refresh()
                   self.assertEqual(prop_val, item.my_flag)
      @@ -143,7 +143,7 @@ class MyMeeting(ExtendedProperty):
                   # Test item creation, refresh, and update
                   item = self.get_test_item(folder=self.test_folder)
                   prop_val = item.my_meeting
      -            self.assertTrue(isinstance(prop_val, bytes))
      +            self.assertIsInstance(prop_val, bytes)
                   item.save()
                   item = self.get_item_by_id((item.id, item.changekey))
                   self.assertEqual(prop_val, item.my_meeting, (prop_val, item.my_meeting))
      @@ -168,7 +168,7 @@ class MyMeetingArray(ExtendedProperty):
                   # Test item creation, refresh, and update
                   item = self.get_test_item(folder=self.test_folder)
                   prop_val = item.my_meeting_array
      -            self.assertTrue(isinstance(prop_val, list))
      +            self.assertIsInstance(prop_val, list)
                   item.save()
                   item = self.get_item_by_id((item.id, item.changekey))
                   self.assertEqual(prop_val, item.my_meeting_array)
      diff --git a/tests/test_items/test_queryset.py b/tests/test_items/test_queryset.py
      index 64198295..28a0b7ff 100644
      --- a/tests/test_items/test_queryset.py
      +++ b/tests/test_items/test_queryset.py
      @@ -169,7 +169,7 @@ def test_querysets(self):
               # len() and count()
               self.assertEqual(qs.count(), 4)
               # Indexing and slicing
      -        self.assertTrue(isinstance(qs[0], self.ITEM_CLASS))
      +        self.assertIsInstance(qs[0], self.ITEM_CLASS)
               self.assertEqual(len(list(qs[1:3])), 2)
               self.assertEqual(qs.count(), 4)
               with self.assertRaises(IndexError):
      
      From 3e22bc376eac9494ca448de3ec666dde5ed7688f Mon Sep 17 00:00:00 2001
      From: Erik Cederstrand 
      Date: Tue, 7 Dec 2021 13:06:59 +0100
      Subject: [PATCH 017/509] Fix filtering on array type extended props
      
      ---
       CHANGELOG.md                      |  2 ++
       exchangelib/fields.py             |  4 ++++
       exchangelib/items/base.py         |  7 +++++--
       tests/test_extended_properties.py | 26 ++++++++++++++++++++++++++
       tests/test_field.py               |  4 ++--
       5 files changed, 39 insertions(+), 4 deletions(-)
      
      diff --git a/CHANGELOG.md b/CHANGELOG.md
      index b40a824b..4e9e7915 100644
      --- a/CHANGELOG.md
      +++ b/CHANGELOG.md
      @@ -3,6 +3,8 @@ Change Log
       
       HEAD
       ----
      +- Fix filtering on array-type extended properties.
      +
       
       4.6.1
       -----
      diff --git a/exchangelib/fields.py b/exchangelib/fields.py
      index c0da7a1b..913084ba 100644
      --- a/exchangelib/fields.py
      +++ b/exchangelib/fields.py
      @@ -1365,6 +1365,10 @@ def __hash__(self):
               return hash(self.name)
       
       
      +class ExtendedPropertyListField(ExtendedPropertyField):
      +    is_list = True
      +
      +
       class ItemField(FieldURIField):
           @property
           def value_cls(self):
      diff --git a/exchangelib/items/base.py b/exchangelib/items/base.py
      index 0d2bcdee..6dbf2f92 100644
      --- a/exchangelib/items/base.py
      +++ b/exchangelib/items/base.py
      @@ -2,7 +2,7 @@
       
       from ..extended_properties import ExtendedProperty
       from ..fields import BooleanField, ExtendedPropertyField, BodyField, MailboxField, MailboxListField, EWSElementField, \
      -    CharField, IdElementField, AttachmentField
      +    CharField, IdElementField, AttachmentField, ExtendedPropertyListField
       from ..properties import InvalidField, IdChangeKeyMixIn, EWSElement, ReferenceItemId, ItemId, EWSMeta
       from ..services import CreateItem
       from ..util import require_account
      @@ -94,7 +94,10 @@ def register(cls, attr_name, attr_cls):
               #   https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/item
               #
               # Find the correct index for the new extended property, and insert.
      -        field = ExtendedPropertyField(attr_name, value_cls=attr_cls)
      +        if attr_cls.is_array_type():
      +            field = ExtendedPropertyListField(attr_name, value_cls=attr_cls)
      +        else:
      +            field = ExtendedPropertyField(attr_name, value_cls=attr_cls)
               cls.add_field(field, insert_after=cls.INSERT_AFTER_FIELD)
       
           @classmethod
      diff --git a/tests/test_extended_properties.py b/tests/test_extended_properties.py
      index e0d35648..81fe4fea 100644
      --- a/tests/test_extended_properties.py
      +++ b/tests/test_extended_properties.py
      @@ -293,3 +293,29 @@ class ExternalSharingFolderId(ExtendedProperty):
               finally:
                   self.ITEM_CLASS.deregister("sharing_url")
                   self.ITEM_CLASS.deregister("sharing_folder_id")
      +
      +    def test_via_queryset(self):
      +        class TestProp(ExtendedProperty):
      +            property_set_id = 'deadbeaf-cafe-cafe-cafe-deadbeefcafe'
      +            property_name = 'Test Property'
      +            property_type = 'Integer'
      +
      +        class TestArayProp(ExtendedProperty):
      +            property_set_id = 'deadcafe-beef-beef-beef-deadcafebeef'
      +            property_name = 'Test Array Property'
      +            property_type = 'IntegerArray'
      +
      +        attr_name = 'dead_beef'
      +        array_attr_name = 'dead_beef_array'
      +        self.ITEM_CLASS.register(attr_name=attr_name, attr_cls=TestProp)
      +        self.ITEM_CLASS.register(attr_name=array_attr_name, attr_cls=TestArayProp)
      +        try:
      +            item = self.get_test_item(folder=self.test_folder).save()
      +            self.assertEqual(self.test_folder.filter(**{attr_name: getattr(item, attr_name)}).count(), 1)
      +            self.assertEqual(
      +                self.test_folder.filter(**{'%s__contains' % array_attr_name: getattr(item, array_attr_name)}).count(),
      +                1
      +            )
      +        finally:
      +            self.ITEM_CLASS.deregister(attr_name=attr_name)
      +            self.ITEM_CLASS.deregister(attr_name=array_attr_name)
      diff --git a/tests/test_field.py b/tests/test_field.py
      index 48e131e7..eaca5c6e 100644
      --- a/tests/test_field.py
      +++ b/tests/test_field.py
      @@ -9,7 +9,7 @@
       from exchangelib.extended_properties import ExternId
       from exchangelib.fields import BooleanField, IntegerField, DecimalField, TextField, ChoiceField, DateTimeField, \
           Base64Field, TimeZoneField, ExtendedPropertyField, CharListField, Choice, DateField, EnumField, EnumListField, \
      -    CharField, InvalidFieldForVersion, InvalidChoiceForVersion
      +    CharField, InvalidFieldForVersion, InvalidChoiceForVersion, ExtendedPropertyListField
       from exchangelib.indexed_properties import SingleFieldIndexedElement
       from exchangelib.version import Version, EXCHANGE_2007, EXCHANGE_2010, EXCHANGE_2013
       from exchangelib.util import to_xml, TNS
      @@ -74,7 +74,7 @@ def test_value_validation(self):
               class ExternIdArray(ExternId):
                   property_type = 'StringArray'
       
      -        field = ExtendedPropertyField('foo', value_cls=ExternIdArray, is_required=True)
      +        field = ExtendedPropertyListField('foo', value_cls=ExternIdArray, is_required=True)
               with self.assertRaises(ValueError)as e:
                   field.clean(None)  # Value is required
               self.assertEqual(str(e.exception), "'foo' is a required field")
      
      From 57fbaa5b4dec645a4522f6106745f723c55b1161 Mon Sep 17 00:00:00 2001
      From: Erik Cederstrand 
      Date: Tue, 7 Dec 2021 14:45:58 +0100
      Subject: [PATCH 018/509] GetStreamingEvents should use a streaming request.
       Test this. Fixes #1025
      
      ---
       exchangelib/services/common.py               | 6 +++++-
       exchangelib/services/get_attachment.py       | 2 +-
       exchangelib/services/get_streaming_events.py | 2 +-
       tests/test_items/test_sync.py                | 9 ++++++---
       4 files changed, 13 insertions(+), 6 deletions(-)
      
      diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py
      index e2ac0d4f..a5235cd2 100644
      --- a/exchangelib/services/common.py
      +++ b/exchangelib/services/common.py
      @@ -214,6 +214,8 @@ def _chunked_get_elements(self, payload_func, items, **kwargs):
                   yield from self._get_elements(payload=payload_func(chunk, **kwargs))
       
           def stop_streaming(self):
      +        if not self.streaming:
      +            raise RuntimeError('Attempt to stop a non-streaming service')
               if self._streaming_response:
                   self._streaming_response.close()  # Release memory
                   self._streaming_response = None
      @@ -263,7 +265,9 @@ def _get_elements(self, payload):
           def _get_response(self, payload, api_version):
               """Send the actual HTTP request and get the response."""
               session = self.protocol.get_session()
      -        self._streaming_session, self._streaming_response = None, None
      +        if self.streaming:
      +            # Make sure to clean up lingering resources
      +            self.stop_streaming()
               r, session = post_ratelimited(
                   protocol=self.protocol,
                   session=session,
      diff --git a/exchangelib/services/get_attachment.py b/exchangelib/services/get_attachment.py
      index c6cf8d4b..7bf2b3ef 100644
      --- a/exchangelib/services/get_attachment.py
      +++ b/exchangelib/services/get_attachment.py
      @@ -102,5 +102,5 @@ def stream_file_content(self, attachment_id):
                   # The returned content did not contain any EWS exceptions. Give up and re-raise the original exception.
                   raise enf
               finally:
      -            self.streaming = False
                   self.stop_streaming()
      +            self.streaming = False
      diff --git a/exchangelib/services/get_streaming_events.py b/exchangelib/services/get_streaming_events.py
      index 4bc9bd27..3fa0dcb9 100644
      --- a/exchangelib/services/get_streaming_events.py
      +++ b/exchangelib/services/get_streaming_events.py
      @@ -15,7 +15,6 @@ class GetStreamingEvents(EWSAccountService):
       
           SERVICE_NAME = 'GetStreamingEvents'
           element_container_name = '{%s}Notifications' % MNS
      -    streaming = True
           prefer_affinity = True
       
           # Connection status values
      @@ -27,6 +26,7 @@ def __init__(self, *args, **kwargs):
               self.connection_status = None
               self.error_subscription_ids = []
               super().__init__(*args, **kwargs)
      +        self.streaming = True
       
           def call(self, subscription_ids, connection_timeout):
               if connection_timeout < 1:
      diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py
      index a99d99b0..fa2461df 100644
      --- a/tests/test_items/test_sync.py
      +++ b/tests/test_items/test_sync.py
      @@ -172,17 +172,21 @@ def test_streaming_notifications(self):
               with test_folder.streaming_subscription() as subscription_id:
                   # Test that we see a create event
                   i1 = self.get_test_item(folder=test_folder).save()
      -            # 1 minute connection timeout
      +            t1 = time.perf_counter()
      +            # Let's only wait for one notification so this test doesn't take forever. 'connection_timeout' is only
      +            # meant as a fallback.
                   notifications = list(test_folder.get_streaming_events(
                       subscription_id, connection_timeout=1, max_notifications_returned=1
                   ))
      +            t2 = time.perf_counter()
      +            # Make sure we returned after 'max_notifications' instead of waiting for 'connection_timeout'
      +            self.assertLess(t2 - t1, 60)
                   created_event, _ = self._filter_events(notifications, CreatedEvent, i1.id)
                   self.assertEqual(created_event.item_id.id, i1.id)
       
                   # Test that we see an update event
                   i1.subject = get_random_string(8)
                   i1.save(update_fields=['subject'])
      -            # 1 minute connection timeout
                   notifications = list(test_folder.get_streaming_events(
                       subscription_id, connection_timeout=1, max_notifications_returned=1
                   ))
      @@ -192,7 +196,6 @@ def test_streaming_notifications(self):
                   # Test that we see a delete event
                   i1_id = i1.id
                   i1.delete()
      -            # 1 minute connection timeout
                   notifications = list(test_folder.get_streaming_events(
                       subscription_id, connection_timeout=1, max_notifications_returned=1
                   ))
      
      From d71a89841941870b1ac68185fd5fbb5923e04a6e Mon Sep 17 00:00:00 2001
      From: Erik Cederstrand 
      Date: Tue, 7 Dec 2021 15:21:38 +0100
      Subject: [PATCH 019/509] Use Autodiscovery directly instead of the helper
       function
      
      ---
       exchangelib/account.py | 6 +++---
       1 file changed, 3 insertions(+), 3 deletions(-)
      
      diff --git a/exchangelib/account.py b/exchangelib/account.py
      index 796306d8..6106d88e 100644
      --- a/exchangelib/account.py
      +++ b/exchangelib/account.py
      @@ -3,7 +3,7 @@
       
       from cached_property import threaded_cached_property
       
      -from .autodiscover import discover
      +from .autodiscover import Autodiscovery
       from .configuration import Configuration
       from .credentials import DELEGATE, IMPERSONATION, ACCESS_TYPES
       from .errors import UnknownTimeZone
      @@ -115,9 +115,9 @@ def __init__(self, primary_smtp_address, fullname=None, access_type=None, autodi
                           credentials = config.credentials
                   else:
                       retry_policy, auth_type = None, None
      -            self.ad_response, self.protocol = discover(
      +            self.ad_response, self.protocol = Autodiscovery(
                       email=primary_smtp_address, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy
      -            )
      +            ).discover()
                   primary_smtp_address = self.ad_response.autodiscover_smtp_address
               else:
                   if not config:
      
      From 382863c0497e93fb8399953e5067eded5f3de558 Mon Sep 17 00:00:00 2001
      From: Erik Cederstrand 
      Date: Tue, 7 Dec 2021 17:54:06 +0100
      Subject: [PATCH 020/509] Let get_streaming_events() accept multiple
       subscription IDs without changing the API
      
      ---
       exchangelib/folders/base.py | 10 ++++++----
       1 file changed, 6 insertions(+), 4 deletions(-)
      
      diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py
      index d9d77204..f67140e8 100644
      --- a/exchangelib/folders/base.py
      +++ b/exchangelib/folders/base.py
      @@ -18,7 +18,7 @@
           CreateUserConfiguration, UpdateUserConfiguration, DeleteUserConfiguration, SubscribeToPush, SubscribeToPull, \
           Unsubscribe, GetEvents, GetStreamingEvents, MoveFolder
       from ..services.get_user_configuration import ALL
      -from ..util import TNS, require_id
      +from ..util import TNS, require_id, is_iterable
       from ..version import Version, EXCHANGE_2007_SP1, EXCHANGE_2010
       
       log = logging.getLogger(__name__)
      @@ -655,11 +655,11 @@ def get_events(self, subscription_id, watermark):
                   if not notification.more_events:
                       break
       
      -    def get_streaming_events(self, subscription_id, connection_timeout=1, max_notifications_returned=None):
      +    def get_streaming_events(self, subscription_id_or_ids, connection_timeout=1, max_notifications_returned=None):
               """Get events since the subscription was created, in streaming mode. This method will block as many minutes
               as specified by 'connection_timeout'.
       
      -        :param subscription_id: A subscription ID as acquired by .subscribe_to_streaming()
      +        :param subscription_id_or_ids: A subscription ID, or list of IDs, as acquired by .subscribe_to_streaming()
               :param connection_timeout: Timeout of the connection, in minutes. The connection is closed after this timeout
               is reached.
               :param max_notifications_returned: If specified, will exit after receiving this number of notifications
      @@ -671,8 +671,10 @@ def get_streaming_events(self, subscription_id, connection_timeout=1, max_notifi
               # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed
               request_timeout = connection_timeout*60 + 60
               svc = GetStreamingEvents(account=self.account, timeout=request_timeout)
      +        subscription_ids = subscription_id_or_ids if is_iterable(subscription_id_or_ids, generators_allowed=True) \
      +            else [subscription_id_or_ids]
               for i, notification in enumerate(
      -                svc.call(subscription_ids=[subscription_id], connection_timeout=connection_timeout),
      +                svc.call(subscription_ids=subscription_ids, connection_timeout=connection_timeout),
                       start=1
               ):
                   yield notification
      
      From 8890271fc4725f138c95cd89d5270f6fefc2ec72 Mon Sep 17 00:00:00 2001
      From: Erik Cederstrand 
      Date: Tue, 7 Dec 2021 17:55:41 +0100
      Subject: [PATCH 021/509] Raise errors in GetStreamingEvents and remove
       error_subscription_ids hackyness
      
      ---
       CHANGELOG.md                                 |  1 +
       exchangelib/folders/base.py                  |  4 +---
       exchangelib/services/get_streaming_events.py | 22 +++++++++++++-------
       tests/test_items/test_sync.py                | 18 ++++++++++++++++
       4 files changed, 35 insertions(+), 10 deletions(-)
      
      diff --git a/CHANGELOG.md b/CHANGELOG.md
      index 4e9e7915..ae4965e1 100644
      --- a/CHANGELOG.md
      +++ b/CHANGELOG.md
      @@ -4,6 +4,7 @@ Change Log
       HEAD
       ----
       - Fix filtering on array-type extended properties.
      +- Exceptions in `GetStreamingEvents` responses are now raised.
       
       
       4.6.1
      diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py
      index f67140e8..e4ce3804 100644
      --- a/exchangelib/folders/base.py
      +++ b/exchangelib/folders/base.py
      @@ -6,7 +6,7 @@
       from .collections import FolderCollection, SyncCompleted
       from .queryset import SingleFolderQuerySet, SHALLOW as SHALLOW_FOLDERS, DEEP as DEEP_FOLDERS
       from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorCannotEmptyFolder, ErrorCannotDeleteObject, \
      -    ErrorDeleteDistinguishedFolder, ErrorInvalidSubscription, ErrorNoPublicFolderReplicaAvailable, ErrorItemNotFound
      +    ErrorDeleteDistinguishedFolder, ErrorNoPublicFolderReplicaAvailable, ErrorItemNotFound
       from ..fields import IntegerField, CharField, FieldPath, EffectiveRightsField, PermissionSetField, EWSElementField, \
           Field, IdElementField, InvalidField
       from ..items import CalendarItem, RegisterMixIn, ITEM_CLASSES, DELETE_TYPE_CHOICES, HARD_DELETE, \
      @@ -681,8 +681,6 @@ def get_streaming_events(self, subscription_id_or_ids, connection_timeout=1, max
                   if max_notifications_returned and i >= max_notifications_returned:
                       svc.stop_streaming()
                       break
      -        if svc.error_subscription_ids:
      -            raise ErrorInvalidSubscription('Invalid subscription IDs: %s' % svc.error_subscription_ids)
       
           def __floordiv__(self, other):
               """Support the some_folder // 'child_folder' // 'child_of_child_folder' navigation syntax.
      diff --git a/exchangelib/services/get_streaming_events.py b/exchangelib/services/get_streaming_events.py
      index 3fa0dcb9..dcf0e255 100644
      --- a/exchangelib/services/get_streaming_events.py
      +++ b/exchangelib/services/get_streaming_events.py
      @@ -1,6 +1,7 @@
       import logging
       
       from .common import EWSAccountService, add_xml_child
      +from ..errors import EWSError
       from ..properties import Notification
       from ..util import create_element, get_xml_attr, get_xml_attrs, MNS, DocumentYielder, DummyResponse
       
      @@ -24,7 +25,6 @@ class GetStreamingEvents(EWSAccountService):
           def __init__(self, *args, **kwargs):
               # These values are set each time call() is consumed
               self.connection_status = None
      -        self.error_subscription_ids = []
               super().__init__(*args, **kwargs)
               self.streaming = True
       
      @@ -69,15 +69,23 @@ def _get_soap_messages(self, body, **parse_opts):
       
           def _get_element_container(self, message, name=None):
               error_ids_elem = message.find('{%s}ErrorSubscriptionIds' % MNS)
      -        if error_ids_elem is not None:
      -            self.error_subscription_ids = get_xml_attrs(error_ids_elem, '{%s}ErrorSubscriptionId' % MNS)
      -            log.debug('These subscription IDs are invalid: %s', self.error_subscription_ids)
      +        error_ids = [] if error_ids_elem is None else get_xml_attrs(error_ids_elem, '{%s}SubscriptionId' % MNS)
               self.connection_status = get_xml_attr(message, '{%s}ConnectionStatus' % MNS)  # Either 'OK' or 'Closed'
               log.debug('Connection status is: %s', self.connection_status)
      -        # Upstream expects to find a 'name' tag but our response does not always have it. Return an empty element.
      +        # Upstream normally expects to find a 'name' tag but our response does not always have it. We still want to
      +        # call upstream, to have exceptions raised. Return an empty list if there is no 'name' tag and no errors.
               if message.find(name) is None:
      -            return []
      -        return super()._get_element_container(message=message, name=name)
      +            name = None
      +        try:
      +            res = super()._get_element_container(message=message, name=name)
      +        except EWSError as e:
      +            # When the request contains a combination of good and failing subscription IDs, notifications for the good
      +            # subscriptions seem to never be returned even though the XML spec allows it. This means there's no point in
      +            # trying to collect any notifications here and delivering a combination of errors and return values.
      +            if error_ids:
      +                e.value += ' (subscription IDs: %s)' % ', '.join(repr(i) for i in error_ids)
      +            raise e
      +        return [] if name is None else res
       
           def get_payload(self, subscription_ids, connection_timeout):
               getstreamingevents = create_element('m:%s' % self.SERVICE_NAME)
      diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py
      index fa2461df..ba661ccc 100644
      --- a/tests/test_items/test_sync.py
      +++ b/tests/test_items/test_sync.py
      @@ -224,6 +224,24 @@ def test_streaming_with_other_calls(self):
                   self.account.protocol.decrease_poolsize()
                   self.account.protocol._session_pool_maxsize -= 1
       
      +    def test_streaming_invalid_subscription(self):
      +        # Test that we can get the failing subscription IDs from the response message
      +        test_folder = self.account.drafts
      +
      +        # Test a single bad notification
      +        with self.assertRaises(ErrorInvalidSubscription) as e:
      +            list(test_folder.get_streaming_events('AAA-', connection_timeout=1, max_notifications_returned=1))
      +        self.assertEqual(e.exception.value, "Subscription is invalid. (subscription IDs: 'AAA-')")
      +
      +        # Test a combination of a good and a bad notification
      +        with self.assertRaises(ErrorInvalidSubscription) as e:
      +            with test_folder.streaming_subscription() as subscription_id:
      +                self.get_test_item(folder=test_folder).save()
      +                list(test_folder.get_streaming_events(
      +                    ('AAA-', subscription_id), connection_timeout=1, max_notifications_returned=1
      +                ))
      +        self.assertEqual(e.exception.value, "Subscription is invalid. (subscription IDs: 'AAA-')")
      +
           def test_push_message_parsing(self):
               xml = b'''\
       
      
      From 4fa32181cd23c9c902d684dcdad9c9569242afa1 Mon Sep 17 00:00:00 2001
      From: Erik Cederstrand 
      Date: Tue, 7 Dec 2021 19:27:34 +0100
      Subject: [PATCH 022/509] Don't use list when all we need is a tuple
      
      ---
       exchangelib/fields.py              |  2 +-
       exchangelib/folders/collections.py |  2 +-
       exchangelib/items/calendar_item.py |  2 +-
       exchangelib/restriction.py         | 20 ++++++++++----------
       exchangelib/services/common.py     |  6 ++++--
       exchangelib/util.py                |  2 +-
       tests/test_items/test_basics.py    |  4 ++--
       7 files changed, 20 insertions(+), 18 deletions(-)
      
      diff --git a/exchangelib/fields.py b/exchangelib/fields.py
      index 913084ba..ee2b4031 100644
      --- a/exchangelib/fields.py
      +++ b/exchangelib/fields.py
      @@ -893,7 +893,7 @@ def clean(self, value, version=None):
               ))
       
           def supported_choices(self, version):
      -        return [c.value for c in self.choices if c.supports_version(version)]
      +        return tuple(c.value for c in self.choices if c.supports_version(version))
       
       
       FREE_BUSY_CHOICES = [Choice('Free'), Choice('Tentative'), Choice('Busy'), Choice('OOF'), Choice('NoData'),
      diff --git a/exchangelib/folders/collections.py b/exchangelib/folders/collections.py
      index 195fb483..57eaf713 100644
      --- a/exchangelib/folders/collections.py
      +++ b/exchangelib/folders/collections.py
      @@ -41,7 +41,7 @@ def __init__(self, account, folders):
           @threaded_cached_property
           def folders(self):
               # Resolve the list of folders, in case it's a generator
      -        return list(self._folders)
      +        return tuple(self._folders)
       
           def __len__(self):
               return len(self.folders)
      diff --git a/exchangelib/items/calendar_item.py b/exchangelib/items/calendar_item.py
      index b633aa46..8a24eade 100644
      --- a/exchangelib/items/calendar_item.py
      +++ b/exchangelib/items/calendar_item.py
      @@ -150,7 +150,7 @@ def recurring_master(self):
       
           @classmethod
           def timezone_fields(cls):
      -        return [f for f in cls.FIELDS if isinstance(f, TimeZoneField)]
      +        return tuple(f for f in cls.FIELDS if isinstance(f, TimeZoneField))
       
           def clean_timezone_fields(self, version):
               # Sets proper values on the timezone fields if they are not already set
      diff --git a/exchangelib/restriction.py b/exchangelib/restriction.py
      index 1b599969..18a5ff5b 100644
      --- a/exchangelib/restriction.py
      +++ b/exchangelib/restriction.py
      @@ -103,17 +103,17 @@ def _get_children_from_kwarg(self, key, value, is_single_kwarg=False):
                   if lookup == self.LOOKUP_EXISTS:
                       # value=True will fall through to further processing
                       if not value:
      -                    return [~self.__class__(**{key: True})]
      +                    return (~self.__class__(**{key: True}),)
       
                   if lookup == self.LOOKUP_RANGE:
                       # EWS doesn't have a 'range' operator. Emulate 'foo__range=(1, 2)' as 'foo__gte=1 and foo__lte=2'
                       # (both values inclusive).
                       if len(value) != 2:
                           raise ValueError("Value of lookup '%s' must have exactly 2 elements" % key)
      -                return [
      +                return (
                           self.__class__(**{'%s__gte' % field_path: value[0]}),
                           self.__class__(**{'%s__lte' % field_path: value[1]}),
      -                ]
      +                )
       
                   # Filtering on list types is a bit quirky. The only lookup type I have found to work is:
                   #
      @@ -136,12 +136,12 @@ def _get_children_from_kwarg(self, key, value, is_single_kwarg=False):
                       # specifying a list value. We'll emulate it as a set of OR'ed exact matches.
                       if not is_iterable(value, generators_allowed=True):
                           raise ValueError("Value for lookup %r must be a list" % key)
      -                children = [self.__class__(**{field_path: v}) for v in value]
      +                children = tuple(self.__class__(**{field_path: v}) for v in value)
                       if not children:
                           # This is an '__in' operator with an empty list as the value. We interpret it to mean "is foo
                           # contained in the empty set?" which is always false. Mark this Q object as such.
      -                    return [self.__class__(conn_type=self.NEVER)]
      -                return [self.__class__(*children, conn_type=self.OR)]
      +                    return (self.__class__(conn_type=self.NEVER),)
      +                return (self.__class__(*children, conn_type=self.OR),)
       
                   if lookup == self.LOOKUP_CONTAINS and is_iterable(value, generators_allowed=True):
                       # A '__contains' lookup with an list as the value ony makes sense for list fields, since exact match
      @@ -149,8 +149,8 @@ def _get_children_from_kwarg(self, key, value, is_single_kwarg=False):
                       #
                       # An empty list as value is allowed. We interpret it to mean "are all values in the empty set contained
                       # in foo?" which is always true.
      -                children = [self.__class__(**{field_path: v}) for v in value]
      -                return [self.__class__(*children, conn_type=self.AND)]
      +                children = tuple(self.__class__(**{field_path: v}) for v in value)
      +                return (self.__class__(*children, conn_type=self.AND),)
       
                   try:
                       op = self._lookup_to_op(lookup)
      @@ -160,13 +160,13 @@ def _get_children_from_kwarg(self, key, value, is_single_kwarg=False):
                   field_path, op = key, self.EQ
       
               if not is_single_kwarg:
      -            return [self.__class__(**{key: value})]
      +            return (self.__class__(**{key: value}),)
       
               # This is a single-kwarg Q object with a lookup that requires a single value. Make this a leaf
               self.field_path = field_path
               self.op = op
               self.value = value
      -        return []
      +        return ()
       
           def reduce(self):
               """Simplify this object, if possible."""
      diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py
      index a5235cd2..a751436c 100644
      --- a/exchangelib/services/common.py
      +++ b/exchangelib/services/common.py
      @@ -294,7 +294,7 @@ def _get_response(self, payload, api_version):
           @property
           def _api_versions_to_try(self):
               # Put the hint first in the list, and then all other versions except the hint, from newest to oldest
      -        return [self._version_hint.api_version] + [v for v in API_VERSIONS if v != self._version_hint.api_version]
      +        return (self._version_hint.api_version,) + tuple(v for v in API_VERSIONS if v != self._version_hint.api_version)
       
           def _get_response_xml(self, payload, **parse_opts):
               """Send the payload to the server and return relevant elements from the result. Several things happen here:
      @@ -346,7 +346,9 @@ def _get_response_xml(self, payload, **parse_opts):
                           # In streaming mode, we may not have accessed the raw stream yet. Caller must handle this.
                           r.close()  # Release memory
       
      -        raise self.NO_VALID_SERVER_VERSIONS('Tried versions %s but all were invalid' % self._api_versions_to_try)
      +        raise self.NO_VALID_SERVER_VERSIONS(
      +            'Tried versions %s but all were invalid' % ', '.join(self._api_versions_to_try)
      +        )
       
           def _handle_backoff(self, e):
               """Take a request from the server to back off and checks the retry policy for what to do. Re-raise the
      diff --git a/exchangelib/util.py b/exchangelib/util.py
      index 772313c8..8e52c6c6 100644
      --- a/exchangelib/util.py
      +++ b/exchangelib/util.py
      @@ -167,7 +167,7 @@ def get_xml_attr(tree, name):
       
       
       def get_xml_attrs(tree, name):
      -    return [elem.text for elem in tree.findall(name) if elem.text is not None]
      +    return list(elem.text for elem in tree.findall(name) if elem.text is not None)
       
       
       def value_to_xml_text(value):
      diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py
      index dbf1796c..d5d0e77b 100644
      --- a/tests/test_items/test_basics.py
      +++ b/tests/test_items/test_basics.py
      @@ -114,8 +114,8 @@ def get_random_insert_kwargs(self):
               return insert_kwargs
       
           def get_item_fields(self):
      -        return [self.ITEM_CLASS.get_field_by_fieldname('id'), self.ITEM_CLASS.get_field_by_fieldname('changekey')] \
      -               + [f for f in self.ITEM_CLASS.FIELDS if f.name != '_id']
      +        return (self.ITEM_CLASS.get_field_by_fieldname('id'), self.ITEM_CLASS.get_field_by_fieldname('changekey')) \
      +               + tuple(f for f in self.ITEM_CLASS.FIELDS if f.name != '_id')
       
           def get_random_update_kwargs(self, item, insert_kwargs):
               update_kwargs = {}
      
      From 38b9e2d8210c5a901c36dd5838ad10358e4b128a Mon Sep 17 00:00:00 2001
      From: Erik Cederstrand 
      Date: Tue, 7 Dec 2021 19:44:43 +0100
      Subject: [PATCH 023/509] Improve accinity cookie suuport. Refs #999
      
      ---
       CHANGELOG.md                        |  1 +
       exchangelib/account.py              |  3 +++
       exchangelib/services/common.py      | 37 ++++++++++++++++++++---------
       exchangelib/services/subscribe.py   |  2 ++
       exchangelib/services/unsubscribe.py |  1 +
       tests/test_items/test_sync.py       |  6 +++++
       6 files changed, 39 insertions(+), 11 deletions(-)
      
      diff --git a/CHANGELOG.md b/CHANGELOG.md
      index ae4965e1..64580891 100644
      --- a/CHANGELOG.md
      +++ b/CHANGELOG.md
      @@ -5,6 +5,7 @@ HEAD
       ----
       - Fix filtering on array-type extended properties.
       - Exceptions in `GetStreamingEvents` responses are now raised.
      +- Support affinity cookies for pull and streaming subscriptions.
       
       
       4.6.1
      diff --git a/exchangelib/account.py b/exchangelib/account.py
      index 6106d88e..47953333 100644
      --- a/exchangelib/account.py
      +++ b/exchangelib/account.py
      @@ -128,6 +128,9 @@ def __init__(self, primary_smtp_address, fullname=None, access_type=None, autodi
               # Other ways of identifying the account can be added later
               self.identity = Identity(primary_smtp_address=primary_smtp_address)
       
      +        # For maintaining affinity in e.g. subscriptions
      +        self.affinity_cookie = None
      +
               # We may need to override the default server version on a per-account basis because Microsoft may report one
               # server version up-front but delegate account requests to an older backend server.
               self.version = self.protocol.version
      diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py
      index a751436c..d8cbf525 100644
      --- a/exchangelib/services/common.py
      +++ b/exchangelib/services/common.py
      @@ -88,8 +88,6 @@ class EWSService(metaclass=abc.ABCMeta):
           supported_from = None
           # Marks services that support paging of requested items
           supports_paging = False
      -    # Marks services that need affinity to the backend server
      -    prefer_affinity = False
       
           def __init__(self, protocol, chunk_size=None, timeout=None):
               self.chunk_size = chunk_size or CHUNK_SIZE  # The number of items to send in a single request
      @@ -171,13 +169,7 @@ def _version_hint(self, value):
               self.protocol.config.version = value
       
           def _extra_headers(self, session):
      -        headers = {}
      -        if self.prefer_affinity:
      -            headers['X-PreferServerAffinity'] = 'True'
      -        for cookie in session.cookies:
      -            if cookie.name == 'X-BackEndCookie':
      -                headers['X-BackEndOverrideCookie'] = cookie.value
      -        return headers
      +        return {}
       
           @property
           def _account_to_impersonate(self):
      @@ -262,6 +254,9 @@ def _get_elements(self, payload):
                       if self.streaming:
                           self.stop_streaming()
       
      +    def _handle_response_cookies(self, session):
      +        pass
      +
           def _get_response(self, payload, api_version):
               """Send the actual HTTP request and get the response."""
               session = self.protocol.get_session()
      @@ -283,6 +278,7 @@ def _get_response(self, payload, api_version):
                   stream=self.streaming,
                   timeout=self.timeout or self.protocol.TIMEOUT,
               )
      +        self._handle_response_cookies(session)
               if self.streaming:
                   # We con only release the session when we have fully consumed the response. Save session and response
                   # objects for later.
      @@ -765,6 +761,8 @@ class EWSAccountService(EWSService, metaclass=abc.ABCMeta):
           """Base class for services that act on items concerning a single Mailbox on the server."""
       
           NO_VALID_SERVER_VERSIONS = ErrorInvalidSchemaVersionForMailboxVersion
      +    # Marks services that need affinity to the backend server
      +    prefer_affinity = False
       
           def __init__(self, *args, **kwargs):
               self.account = kwargs.pop('account')
      @@ -779,11 +777,28 @@ def _version_hint(self):
           def _version_hint(self, value):
               self.account.version = value
       
      -    def _extra_headers(self, *args, **kwargs):
      -        headers = super()._extra_headers(*args, **kwargs)
      +    def _handle_response_cookies(self, session):
      +        super()._handle_response_cookies(session=session)
      +
      +        # See self._extra_headers() for documentation on affinity
      +        if self.prefer_affinity:
      +            for cookie in session.cookies:
      +                if cookie.name == 'X-BackEndOverrideCookie':
      +                    self.account.affinity_cookie = cookie.value
      +                    break
      +
      +    def _extra_headers(self, session):
      +        headers = super()._extra_headers(session=session)
               # See
               # https://blogs.msdn.microsoft.com/webdav_101/2015/05/11/best-practices-ews-authentication-and-access-issues/
               headers['X-AnchorMailbox'] = self.account.primary_smtp_address
      +
      +        # See
      +        # https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/how-to-maintain-affinity-between-group-of-subscriptions-and-mailbox-server
      +        if self.prefer_affinity:
      +            headers['X-PreferServerAffinity'] = 'True'
      +            if self.account.affinity_cookie:
      +                headers['X-BackEndOverrideCookie'] = self.account.affinity_cookie
               return headers
       
           @property
      diff --git a/exchangelib/services/subscribe.py b/exchangelib/services/subscribe.py
      index 3dc2beff..7c4571c2 100644
      --- a/exchangelib/services/subscribe.py
      +++ b/exchangelib/services/subscribe.py
      @@ -56,6 +56,7 @@ def _partial_payload(self, folders, event_types):
       
       class SubscribeToPull(Subscribe):
           subscription_request_elem_tag = 'm:PullSubscriptionRequest'
      +    prefer_affinity = True
       
           def call(self, folders, event_types, watermark, timeout):
               yield from self._partial_call(
      @@ -95,6 +96,7 @@ def get_payload(self, folders, event_types, watermark, status_frequency, url):
       
       class SubscribeToStreaming(Subscribe):
           subscription_request_elem_tag = 'm:StreamingSubscriptionRequest'
      +    prefer_affinity = True
       
           def call(self, folders, event_types):
               yield from self._partial_call(payload_func=self.get_payload, folders=folders, event_types=event_types)
      diff --git a/exchangelib/services/unsubscribe.py b/exchangelib/services/unsubscribe.py
      index d46cb4e3..784694aa 100644
      --- a/exchangelib/services/unsubscribe.py
      +++ b/exchangelib/services/unsubscribe.py
      @@ -10,6 +10,7 @@ class Unsubscribe(EWSAccountService):
       
           SERVICE_NAME = 'Unsubscribe'
           returns_elements = False
      +    prefer_affinity = True
       
           def call(self, subscription_id):
               return self._get_elements(payload=self.get_payload(subscription_id=subscription_id))
      diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py
      index ba661ccc..56025471 100644
      --- a/tests/test_items/test_sync.py
      +++ b/tests/test_items/test_sync.py
      @@ -16,12 +16,15 @@ class SyncTest(BaseItemTest):
           ITEM_CLASS = Message
       
           def test_pull_subscribe(self):
      +        self.account.affinity_cookie = None
               with self.account.inbox.pull_subscription() as (subscription_id, watermark):
                   self.assertIsNotNone(subscription_id)
                   self.assertIsNotNone(watermark)
               # Context manager already unsubscribed us
               with self.assertRaises(ErrorSubscriptionNotFound):
                   self.account.inbox.unsubscribe(subscription_id)
      +        # Test affinity cookie
      +        self.assertIsNotNone(self.account.affinity_cookie)
       
           def test_push_subscribe(self):
               with self.account.inbox.push_subscription(
      @@ -33,11 +36,14 @@ def test_push_subscribe(self):
                   self.account.inbox.unsubscribe(subscription_id)
       
           def test_streaming_subscribe(self):
      +        self.account.affinity_cookie = None
               with self.account.inbox.streaming_subscription() as subscription_id:
                   self.assertIsNotNone(subscription_id)
               # Context manager already unsubscribed us
               with self.assertRaises(ErrorSubscriptionNotFound):
                   self.account.inbox.unsubscribe(subscription_id)
      +        # Test affinity cookie
      +        self.assertIsNotNone(self.account.affinity_cookie)
       
           def test_sync_folder_hierarchy(self):
               test_folder = self.get_test_folder().save()
      
      From 2794d71da32188d749af2b841de3308421164991 Mon Sep 17 00:00:00 2001
      From: Erik Cederstrand 
      Date: Tue, 7 Dec 2021 19:54:25 +0100
      Subject: [PATCH 024/509] Bump version
      
      ---
       CHANGELOG.md                                  |   5 +
       docs/exchangelib/account.html                 |  16 ++-
       docs/exchangelib/fields.html                  |  46 ++++++-
       docs/exchangelib/folders/base.html            |  40 +++---
       docs/exchangelib/folders/collections.html     |   4 +-
       docs/exchangelib/folders/index.html           |  28 ++---
       docs/exchangelib/index.html                   |  37 +++---
       docs/exchangelib/items/base.html              |  17 ++-
       docs/exchangelib/items/calendar_item.html     |   6 +-
       docs/exchangelib/items/index.html             |  14 ++-
       docs/exchangelib/restriction.html             |  44 +++----
       docs/exchangelib/services/common.html         | 115 +++++++++++++-----
       docs/exchangelib/services/get_attachment.html |  12 +-
       .../services/get_streaming_events.html        |  52 ++++----
       docs/exchangelib/services/index.html          |  54 +++++---
       docs/exchangelib/services/subscribe.html      |  14 +++
       docs/exchangelib/services/unsubscribe.html    |   7 ++
       docs/exchangelib/util.html                    |   4 +-
       exchangelib/__init__.py                       |   2 +-
       19 files changed, 343 insertions(+), 174 deletions(-)
      
      diff --git a/CHANGELOG.md b/CHANGELOG.md
      index 64580891..85a5e8d5 100644
      --- a/CHANGELOG.md
      +++ b/CHANGELOG.md
      @@ -3,6 +3,11 @@ Change Log
       
       HEAD
       ----
      +
      +
      +4.6.2
      +-----
      +
       - Fix filtering on array-type extended properties.
       - Exceptions in `GetStreamingEvents` responses are now raised.
       - Support affinity cookies for pull and streaming subscriptions.
      diff --git a/docs/exchangelib/account.html b/docs/exchangelib/account.html
      index 0484475e..46108c30 100644
      --- a/docs/exchangelib/account.html
      +++ b/docs/exchangelib/account.html
      @@ -31,7 +31,7 @@ 

      Module exchangelib.account

      from cached_property import threaded_cached_property -from .autodiscover import discover +from .autodiscover import Autodiscovery from .configuration import Configuration from .credentials import DELEGATE, IMPERSONATION, ACCESS_TYPES from .errors import UnknownTimeZone @@ -143,9 +143,9 @@

      Module exchangelib.account

      credentials = config.credentials else: retry_policy, auth_type = None, None - self.ad_response, self.protocol = discover( + self.ad_response, self.protocol = Autodiscovery( email=primary_smtp_address, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy - ) + ).discover() primary_smtp_address = self.ad_response.autodiscover_smtp_address else: if not config: @@ -156,6 +156,9 @@

      Module exchangelib.account

      # Other ways of identifying the account can be added later self.identity = Identity(primary_smtp_address=primary_smtp_address) + # For maintaining affinity in e.g. subscriptions + self.affinity_cookie = None + # We may need to override the default server version on a per-account basis because Microsoft may report one # server version up-front but delegate account requests to an older backend server. self.version = self.protocol.version @@ -752,9 +755,9 @@

      Classes

      credentials = config.credentials else: retry_policy, auth_type = None, None - self.ad_response, self.protocol = discover( + self.ad_response, self.protocol = Autodiscovery( email=primary_smtp_address, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy - ) + ).discover() primary_smtp_address = self.ad_response.autodiscover_smtp_address else: if not config: @@ -765,6 +768,9 @@

      Classes

      # Other ways of identifying the account can be added later self.identity = Identity(primary_smtp_address=primary_smtp_address) + # For maintaining affinity in e.g. subscriptions + self.affinity_cookie = None + # We may need to override the default server version on a per-account basis because Microsoft may report one # server version up-front but delegate account requests to an older backend server. self.version = self.protocol.version diff --git a/docs/exchangelib/fields.html b/docs/exchangelib/fields.html index 415328a5..1bb7ae2b 100644 --- a/docs/exchangelib/fields.html +++ b/docs/exchangelib/fields.html @@ -921,7 +921,7 @@

      Module exchangelib.fields

      )) def supported_choices(self, version): - return [c.value for c in self.choices if c.supports_version(version)] + return tuple(c.value for c in self.choices if c.supports_version(version)) FREE_BUSY_CHOICES = [Choice('Free'), Choice('Tentative'), Choice('Busy'), Choice('OOF'), Choice('NoData'), @@ -1393,6 +1393,10 @@

      Module exchangelib.fields

      return hash(self.name) +class ExtendedPropertyListField(ExtendedPropertyField): + is_list = True + + class ItemField(FieldURIField): @property def value_cls(self): @@ -2704,7 +2708,7 @@

      Methods

      )) def supported_choices(self, version): - return [c.value for c in self.choices if c.supports_version(version)]
      + return tuple(c.value for c in self.choices if c.supports_version(version))

      Ancestors

        @@ -2760,7 +2764,7 @@

        Methods

        Expand source code
        def supported_choices(self, version):
        -    return [c.value for c in self.choices if c.supports_version(version)]
        + return tuple(c.value for c in self.choices if c.supports_version(version))

    @@ -4097,6 +4101,10 @@

    Ancestors

    +

    Subclasses

    +

    Methods

    @@ -4183,6 +4191,32 @@

    Methods

    +
    +class ExtendedPropertyListField +(*args, **kwargs) +
    +
    +

    Holds information related to an item field.

    +
    + +Expand source code + +
    class ExtendedPropertyListField(ExtendedPropertyField):
    +    is_list = True
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var is_list
    +
    +
    +
    +
    +
    class Field (name=None, is_required=False, is_required_after_save=False, is_read_only=False, is_read_only_after_send=False, is_searchable=True, is_attribute=False, default=None, supported_from=None, deprecated_from=None) @@ -7326,6 +7360,12 @@

    ExtendedPropertyListField

    + + +
  • Field

    • clean
    • diff --git a/docs/exchangelib/folders/base.html b/docs/exchangelib/folders/base.html index 3a747d5a..ce911729 100644 --- a/docs/exchangelib/folders/base.html +++ b/docs/exchangelib/folders/base.html @@ -34,7 +34,7 @@

      Module exchangelib.folders.base

      from .collections import FolderCollection, SyncCompleted from .queryset import SingleFolderQuerySet, SHALLOW as SHALLOW_FOLDERS, DEEP as DEEP_FOLDERS from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorCannotEmptyFolder, ErrorCannotDeleteObject, \ - ErrorDeleteDistinguishedFolder, ErrorInvalidSubscription, ErrorNoPublicFolderReplicaAvailable, ErrorItemNotFound + ErrorDeleteDistinguishedFolder, ErrorNoPublicFolderReplicaAvailable, ErrorItemNotFound from ..fields import IntegerField, CharField, FieldPath, EffectiveRightsField, PermissionSetField, EWSElementField, \ Field, IdElementField, InvalidField from ..items import CalendarItem, RegisterMixIn, ITEM_CLASSES, DELETE_TYPE_CHOICES, HARD_DELETE, \ @@ -46,7 +46,7 @@

      Module exchangelib.folders.base

      CreateUserConfiguration, UpdateUserConfiguration, DeleteUserConfiguration, SubscribeToPush, SubscribeToPull, \ Unsubscribe, GetEvents, GetStreamingEvents, MoveFolder from ..services.get_user_configuration import ALL -from ..util import TNS, require_id +from ..util import TNS, require_id, is_iterable from ..version import Version, EXCHANGE_2007_SP1, EXCHANGE_2010 log = logging.getLogger(__name__) @@ -683,11 +683,11 @@

      Module exchangelib.folders.base

      if not notification.more_events: break - def get_streaming_events(self, subscription_id, connection_timeout=1, max_notifications_returned=None): + def get_streaming_events(self, subscription_id_or_ids, connection_timeout=1, max_notifications_returned=None): """Get events since the subscription was created, in streaming mode. This method will block as many minutes as specified by 'connection_timeout'. - :param subscription_id: A subscription ID as acquired by .subscribe_to_streaming() + :param subscription_id_or_ids: A subscription ID, or list of IDs, as acquired by .subscribe_to_streaming() :param connection_timeout: Timeout of the connection, in minutes. The connection is closed after this timeout is reached. :param max_notifications_returned: If specified, will exit after receiving this number of notifications @@ -699,16 +699,16 @@

      Module exchangelib.folders.base

      # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed request_timeout = connection_timeout*60 + 60 svc = GetStreamingEvents(account=self.account, timeout=request_timeout) + subscription_ids = subscription_id_or_ids if is_iterable(subscription_id_or_ids, generators_allowed=True) \ + else [subscription_id_or_ids] for i, notification in enumerate( - svc.call(subscription_ids=[subscription_id], connection_timeout=connection_timeout), + svc.call(subscription_ids=subscription_ids, connection_timeout=connection_timeout), start=1 ): yield notification if max_notifications_returned and i >= max_notifications_returned: svc.stop_streaming() break - if svc.error_subscription_ids: - raise ErrorInvalidSubscription('Invalid subscription IDs: %s' % svc.error_subscription_ids) def __floordiv__(self, other): """Support the some_folder // 'child_folder' // 'child_of_child_folder' navigation syntax. @@ -1568,11 +1568,11 @@

      Classes

      if not notification.more_events: break - def get_streaming_events(self, subscription_id, connection_timeout=1, max_notifications_returned=None): + def get_streaming_events(self, subscription_id_or_ids, connection_timeout=1, max_notifications_returned=None): """Get events since the subscription was created, in streaming mode. This method will block as many minutes as specified by 'connection_timeout'. - :param subscription_id: A subscription ID as acquired by .subscribe_to_streaming() + :param subscription_id_or_ids: A subscription ID, or list of IDs, as acquired by .subscribe_to_streaming() :param connection_timeout: Timeout of the connection, in minutes. The connection is closed after this timeout is reached. :param max_notifications_returned: If specified, will exit after receiving this number of notifications @@ -1584,16 +1584,16 @@

      Classes

      # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed request_timeout = connection_timeout*60 + 60 svc = GetStreamingEvents(account=self.account, timeout=request_timeout) + subscription_ids = subscription_id_or_ids if is_iterable(subscription_id_or_ids, generators_allowed=True) \ + else [subscription_id_or_ids] for i, notification in enumerate( - svc.call(subscription_ids=[subscription_id], connection_timeout=connection_timeout), + svc.call(subscription_ids=subscription_ids, connection_timeout=connection_timeout), start=1 ): yield notification if max_notifications_returned and i >= max_notifications_returned: svc.stop_streaming() break - if svc.error_subscription_ids: - raise ErrorInvalidSubscription('Invalid subscription IDs: %s' % svc.error_subscription_ids) def __floordiv__(self, other): """Support the some_folder // 'child_folder' // 'child_of_child_folder' navigation syntax. @@ -2192,12 +2192,12 @@

      Methods

      -def get_streaming_events(self, subscription_id, connection_timeout=1, max_notifications_returned=None) +def get_streaming_events(self, subscription_id_or_ids, connection_timeout=1, max_notifications_returned=None)

      Get events since the subscription was created, in streaming mode. This method will block as many minutes as specified by 'connection_timeout'.

      -

      :param subscription_id: A subscription ID as acquired by .subscribe_to_streaming() +

      :param subscription_id_or_ids: A subscription ID, or list of IDs, as acquired by .subscribe_to_streaming() :param connection_timeout: Timeout of the connection, in minutes. The connection is closed after this timeout is reached. :param max_notifications_returned: If specified, will exit after receiving this number of notifications @@ -2208,11 +2208,11 @@

      Methods

      Expand source code -
      def get_streaming_events(self, subscription_id, connection_timeout=1, max_notifications_returned=None):
      +
      def get_streaming_events(self, subscription_id_or_ids, connection_timeout=1, max_notifications_returned=None):
           """Get events since the subscription was created, in streaming mode. This method will block as many minutes
           as specified by 'connection_timeout'.
       
      -    :param subscription_id: A subscription ID as acquired by .subscribe_to_streaming()
      +    :param subscription_id_or_ids: A subscription ID, or list of IDs, as acquired by .subscribe_to_streaming()
           :param connection_timeout: Timeout of the connection, in minutes. The connection is closed after this timeout
           is reached.
           :param max_notifications_returned: If specified, will exit after receiving this number of notifications
      @@ -2224,16 +2224,16 @@ 

      Methods

      # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed request_timeout = connection_timeout*60 + 60 svc = GetStreamingEvents(account=self.account, timeout=request_timeout) + subscription_ids = subscription_id_or_ids if is_iterable(subscription_id_or_ids, generators_allowed=True) \ + else [subscription_id_or_ids] for i, notification in enumerate( - svc.call(subscription_ids=[subscription_id], connection_timeout=connection_timeout), + svc.call(subscription_ids=subscription_ids, connection_timeout=connection_timeout), start=1 ): yield notification if max_notifications_returned and i >= max_notifications_returned: svc.stop_streaming() - break - if svc.error_subscription_ids: - raise ErrorInvalidSubscription('Invalid subscription IDs: %s' % svc.error_subscription_ids)
      + break
      diff --git a/docs/exchangelib/folders/collections.html b/docs/exchangelib/folders/collections.html index a51e66ae..eae00689 100644 --- a/docs/exchangelib/folders/collections.html +++ b/docs/exchangelib/folders/collections.html @@ -69,7 +69,7 @@

      Module exchangelib.folders.collections

      @threaded_cached_property def folders(self): # Resolve the list of folders, in case it's a generator - return list(self._folders) + return tuple(self._folders) def __len__(self): return len(self.folders) @@ -553,7 +553,7 @@

      Classes

      @threaded_cached_property def folders(self): # Resolve the list of folders, in case it's a generator - return list(self._folders) + return tuple(self._folders) def __len__(self): return len(self.folders) diff --git a/docs/exchangelib/folders/index.html b/docs/exchangelib/folders/index.html index db127e2a..b59eb683 100644 --- a/docs/exchangelib/folders/index.html +++ b/docs/exchangelib/folders/index.html @@ -1540,11 +1540,11 @@

      Inherited members

      if not notification.more_events: break - def get_streaming_events(self, subscription_id, connection_timeout=1, max_notifications_returned=None): + def get_streaming_events(self, subscription_id_or_ids, connection_timeout=1, max_notifications_returned=None): """Get events since the subscription was created, in streaming mode. This method will block as many minutes as specified by 'connection_timeout'. - :param subscription_id: A subscription ID as acquired by .subscribe_to_streaming() + :param subscription_id_or_ids: A subscription ID, or list of IDs, as acquired by .subscribe_to_streaming() :param connection_timeout: Timeout of the connection, in minutes. The connection is closed after this timeout is reached. :param max_notifications_returned: If specified, will exit after receiving this number of notifications @@ -1556,16 +1556,16 @@

      Inherited members

      # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed request_timeout = connection_timeout*60 + 60 svc = GetStreamingEvents(account=self.account, timeout=request_timeout) + subscription_ids = subscription_id_or_ids if is_iterable(subscription_id_or_ids, generators_allowed=True) \ + else [subscription_id_or_ids] for i, notification in enumerate( - svc.call(subscription_ids=[subscription_id], connection_timeout=connection_timeout), + svc.call(subscription_ids=subscription_ids, connection_timeout=connection_timeout), start=1 ): yield notification if max_notifications_returned and i >= max_notifications_returned: svc.stop_streaming() break - if svc.error_subscription_ids: - raise ErrorInvalidSubscription('Invalid subscription IDs: %s' % svc.error_subscription_ids) def __floordiv__(self, other): """Support the some_folder // 'child_folder' // 'child_of_child_folder' navigation syntax. @@ -2164,12 +2164,12 @@

      Methods

      -def get_streaming_events(self, subscription_id, connection_timeout=1, max_notifications_returned=None) +def get_streaming_events(self, subscription_id_or_ids, connection_timeout=1, max_notifications_returned=None)

      Get events since the subscription was created, in streaming mode. This method will block as many minutes as specified by 'connection_timeout'.

      -

      :param subscription_id: A subscription ID as acquired by .subscribe_to_streaming() +

      :param subscription_id_or_ids: A subscription ID, or list of IDs, as acquired by .subscribe_to_streaming() :param connection_timeout: Timeout of the connection, in minutes. The connection is closed after this timeout is reached. :param max_notifications_returned: If specified, will exit after receiving this number of notifications @@ -2180,11 +2180,11 @@

      Methods

      Expand source code -
      def get_streaming_events(self, subscription_id, connection_timeout=1, max_notifications_returned=None):
      +
      def get_streaming_events(self, subscription_id_or_ids, connection_timeout=1, max_notifications_returned=None):
           """Get events since the subscription was created, in streaming mode. This method will block as many minutes
           as specified by 'connection_timeout'.
       
      -    :param subscription_id: A subscription ID as acquired by .subscribe_to_streaming()
      +    :param subscription_id_or_ids: A subscription ID, or list of IDs, as acquired by .subscribe_to_streaming()
           :param connection_timeout: Timeout of the connection, in minutes. The connection is closed after this timeout
           is reached.
           :param max_notifications_returned: If specified, will exit after receiving this number of notifications
      @@ -2196,16 +2196,16 @@ 

      Methods

      # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed request_timeout = connection_timeout*60 + 60 svc = GetStreamingEvents(account=self.account, timeout=request_timeout) + subscription_ids = subscription_id_or_ids if is_iterable(subscription_id_or_ids, generators_allowed=True) \ + else [subscription_id_or_ids] for i, notification in enumerate( - svc.call(subscription_ids=[subscription_id], connection_timeout=connection_timeout), + svc.call(subscription_ids=subscription_ids, connection_timeout=connection_timeout), start=1 ): yield notification if max_notifications_returned and i >= max_notifications_returned: svc.stop_streaming() - break - if svc.error_subscription_ids: - raise ErrorInvalidSubscription('Invalid subscription IDs: %s' % svc.error_subscription_ids)
      + break
      @@ -4518,7 +4518,7 @@

      Inherited members

      @threaded_cached_property def folders(self): # Resolve the list of folders, in case it's a generator - return list(self._folders) + return tuple(self._folders) def __len__(self): return len(self.folders) diff --git a/docs/exchangelib/index.html b/docs/exchangelib/index.html index e7ce07eb..2644e86d 100644 --- a/docs/exchangelib/index.html +++ b/docs/exchangelib/index.html @@ -46,7 +46,7 @@

      Package exchangelib

      from .transport import BASIC, DIGEST, NTLM, GSSAPI, SSPI, OAUTH2, CBA from .version import Build, Version -__version__ = '4.6.1' +__version__ = '4.6.2' __all__ = [ '__version__', @@ -354,9 +354,9 @@

      Inherited members

      credentials = config.credentials else: retry_policy, auth_type = None, None - self.ad_response, self.protocol = discover( + self.ad_response, self.protocol = Autodiscovery( email=primary_smtp_address, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy - ) + ).discover() primary_smtp_address = self.ad_response.autodiscover_smtp_address else: if not config: @@ -367,6 +367,9 @@

      Inherited members

      # Other ways of identifying the account can be added later self.identity = Identity(primary_smtp_address=primary_smtp_address) + # For maintaining affinity in e.g. subscriptions + self.affinity_cookie = None + # We may need to override the default server version on a per-account basis because Microsoft may report one # server version up-front but delegate account requests to an older backend server. self.version = self.protocol.version @@ -3736,7 +3739,7 @@

      Methods

      @classmethod def timezone_fields(cls): - return [f for f in cls.FIELDS if isinstance(f, TimeZoneField)] + return tuple(f for f in cls.FIELDS if isinstance(f, TimeZoneField)) def clean_timezone_fields(self, version): # Sets proper values on the timezone fields if they are not already set @@ -3912,7 +3915,7 @@

      Static methods

      @classmethod
       def timezone_fields(cls):
      -    return [f for f in cls.FIELDS if isinstance(f, TimeZoneField)]
      + return tuple(f for f in cls.FIELDS if isinstance(f, TimeZoneField))
      @@ -7358,7 +7361,7 @@

      Inherited members

      @threaded_cached_property def folders(self): # Resolve the list of folders, in case it's a generator - return list(self._folders) + return tuple(self._folders) def __len__(self): return len(self.folders) @@ -10200,17 +10203,17 @@

      Inherited members

      if lookup == self.LOOKUP_EXISTS: # value=True will fall through to further processing if not value: - return [~self.__class__(**{key: True})] + return (~self.__class__(**{key: True}),) if lookup == self.LOOKUP_RANGE: # EWS doesn't have a 'range' operator. Emulate 'foo__range=(1, 2)' as 'foo__gte=1 and foo__lte=2' # (both values inclusive). if len(value) != 2: raise ValueError("Value of lookup '%s' must have exactly 2 elements" % key) - return [ + return ( self.__class__(**{'%s__gte' % field_path: value[0]}), self.__class__(**{'%s__lte' % field_path: value[1]}), - ] + ) # Filtering on list types is a bit quirky. The only lookup type I have found to work is: # @@ -10226,19 +10229,19 @@

      Inherited members

      # 'Contains' element which only supports matching on string elements, not arrays. # # Exact matching of categories (i.e. match ['a', 'b'] but not ['a', 'b', 'c']) could be implemented by - # post-processing items by fetch the categories field unconditionally and removing the items that don't + # post-processing items by fetching the categories field unconditionally and removing the items that don't # have an exact match. if lookup == self.LOOKUP_IN: # EWS doesn't have an '__in' operator. Allow '__in' lookups on list and non-list field types, # specifying a list value. We'll emulate it as a set of OR'ed exact matches. if not is_iterable(value, generators_allowed=True): raise ValueError("Value for lookup %r must be a list" % key) - children = [self.__class__(**{field_path: v}) for v in value] + children = tuple(self.__class__(**{field_path: v}) for v in value) if not children: # This is an '__in' operator with an empty list as the value. We interpret it to mean "is foo # contained in the empty set?" which is always false. Mark this Q object as such. - return [self.__class__(conn_type=self.NEVER)] - return [self.__class__(*children, conn_type=self.OR)] + return (self.__class__(conn_type=self.NEVER),) + return (self.__class__(*children, conn_type=self.OR),) if lookup == self.LOOKUP_CONTAINS and is_iterable(value, generators_allowed=True): # A '__contains' lookup with an list as the value ony makes sense for list fields, since exact match @@ -10246,8 +10249,8 @@

      Inherited members

      # # An empty list as value is allowed. We interpret it to mean "are all values in the empty set contained # in foo?" which is always true. - children = [self.__class__(**{field_path: v}) for v in value] - return [self.__class__(*children, conn_type=self.AND)] + children = tuple(self.__class__(**{field_path: v}) for v in value) + return (self.__class__(*children, conn_type=self.AND),) try: op = self._lookup_to_op(lookup) @@ -10257,13 +10260,13 @@

      Inherited members

      field_path, op = key, self.EQ if not is_single_kwarg: - return [self.__class__(**{key: value})] + return (self.__class__(**{key: value}),) # This is a single-kwarg Q object with a lookup that requires a single value. Make this a leaf self.field_path = field_path self.op = op self.value = value - return [] + return () def reduce(self): """Simplify this object, if possible.""" diff --git a/docs/exchangelib/items/base.html b/docs/exchangelib/items/base.html index 30bb629c..c05cda80 100644 --- a/docs/exchangelib/items/base.html +++ b/docs/exchangelib/items/base.html @@ -30,7 +30,7 @@

      Module exchangelib.items.base

      from ..extended_properties import ExtendedProperty from ..fields import BooleanField, ExtendedPropertyField, BodyField, MailboxField, MailboxListField, EWSElementField, \ - CharField, IdElementField, AttachmentField + CharField, IdElementField, AttachmentField, ExtendedPropertyListField from ..properties import InvalidField, IdChangeKeyMixIn, EWSElement, ReferenceItemId, ItemId, EWSMeta from ..services import CreateItem from ..util import require_account @@ -122,7 +122,10 @@

      Module exchangelib.items.base

      # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/item # # Find the correct index for the new extended property, and insert. - field = ExtendedPropertyField(attr_name, value_cls=attr_cls) + if attr_cls.is_array_type(): + field = ExtendedPropertyListField(attr_name, value_cls=attr_cls) + else: + field = ExtendedPropertyField(attr_name, value_cls=attr_cls) cls.add_field(field, insert_after=cls.INSERT_AFTER_FIELD) @classmethod @@ -679,7 +682,10 @@

      Inherited members

      # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/item # # Find the correct index for the new extended property, and insert. - field = ExtendedPropertyField(attr_name, value_cls=attr_cls) + if attr_cls.is_array_type(): + field = ExtendedPropertyListField(attr_name, value_cls=attr_cls) + else: + field = ExtendedPropertyField(attr_name, value_cls=attr_cls) cls.add_field(field, insert_after=cls.INSERT_AFTER_FIELD) @classmethod @@ -779,7 +785,10 @@

      Static methods

      # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/item # # Find the correct index for the new extended property, and insert. - field = ExtendedPropertyField(attr_name, value_cls=attr_cls) + if attr_cls.is_array_type(): + field = ExtendedPropertyListField(attr_name, value_cls=attr_cls) + else: + field = ExtendedPropertyField(attr_name, value_cls=attr_cls) cls.add_field(field, insert_after=cls.INSERT_AFTER_FIELD)
      diff --git a/docs/exchangelib/items/calendar_item.html b/docs/exchangelib/items/calendar_item.html index c020d2d0..eb6e9df4 100644 --- a/docs/exchangelib/items/calendar_item.html +++ b/docs/exchangelib/items/calendar_item.html @@ -178,7 +178,7 @@

      Module exchangelib.items.calendar_item

      @classmethod def timezone_fields(cls): - return [f for f in cls.FIELDS if isinstance(f, TimeZoneField)] + return tuple(f for f in cls.FIELDS if isinstance(f, TimeZoneField)) def clean_timezone_fields(self, version): # Sets proper values on the timezone fields if they are not already set @@ -1065,7 +1065,7 @@

      Inherited members

      @classmethod def timezone_fields(cls): - return [f for f in cls.FIELDS if isinstance(f, TimeZoneField)] + return tuple(f for f in cls.FIELDS if isinstance(f, TimeZoneField)) def clean_timezone_fields(self, version): # Sets proper values on the timezone fields if they are not already set @@ -1241,7 +1241,7 @@

      Static methods

      @classmethod
       def timezone_fields(cls):
      -    return [f for f in cls.FIELDS if isinstance(f, TimeZoneField)]
      + return tuple(f for f in cls.FIELDS if isinstance(f, TimeZoneField))
      diff --git a/docs/exchangelib/items/index.html b/docs/exchangelib/items/index.html index 72252230..5bfc22d9 100644 --- a/docs/exchangelib/items/index.html +++ b/docs/exchangelib/items/index.html @@ -467,7 +467,7 @@

      Inherited members

      @classmethod def timezone_fields(cls): - return [f for f in cls.FIELDS if isinstance(f, TimeZoneField)] + return tuple(f for f in cls.FIELDS if isinstance(f, TimeZoneField)) def clean_timezone_fields(self, version): # Sets proper values on the timezone fields if they are not already set @@ -643,7 +643,7 @@

      Static methods

      @classmethod
       def timezone_fields(cls):
      -    return [f for f in cls.FIELDS if isinstance(f, TimeZoneField)]
      + return tuple(f for f in cls.FIELDS if isinstance(f, TimeZoneField))
      @@ -4050,7 +4050,10 @@

      Inherited members

      # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/item # # Find the correct index for the new extended property, and insert. - field = ExtendedPropertyField(attr_name, value_cls=attr_cls) + if attr_cls.is_array_type(): + field = ExtendedPropertyListField(attr_name, value_cls=attr_cls) + else: + field = ExtendedPropertyField(attr_name, value_cls=attr_cls) cls.add_field(field, insert_after=cls.INSERT_AFTER_FIELD) @classmethod @@ -4150,7 +4153,10 @@

      Static methods

      # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/item # # Find the correct index for the new extended property, and insert. - field = ExtendedPropertyField(attr_name, value_cls=attr_cls) + if attr_cls.is_array_type(): + field = ExtendedPropertyListField(attr_name, value_cls=attr_cls) + else: + field = ExtendedPropertyField(attr_name, value_cls=attr_cls) cls.add_field(field, insert_after=cls.INSERT_AFTER_FIELD) diff --git a/docs/exchangelib/restriction.html b/docs/exchangelib/restriction.html index cf22d647..ed06a805 100644 --- a/docs/exchangelib/restriction.html +++ b/docs/exchangelib/restriction.html @@ -131,17 +131,17 @@

      Module exchangelib.restriction

      if lookup == self.LOOKUP_EXISTS: # value=True will fall through to further processing if not value: - return [~self.__class__(**{key: True})] + return (~self.__class__(**{key: True}),) if lookup == self.LOOKUP_RANGE: # EWS doesn't have a 'range' operator. Emulate 'foo__range=(1, 2)' as 'foo__gte=1 and foo__lte=2' # (both values inclusive). if len(value) != 2: raise ValueError("Value of lookup '%s' must have exactly 2 elements" % key) - return [ + return ( self.__class__(**{'%s__gte' % field_path: value[0]}), self.__class__(**{'%s__lte' % field_path: value[1]}), - ] + ) # Filtering on list types is a bit quirky. The only lookup type I have found to work is: # @@ -157,19 +157,19 @@

      Module exchangelib.restriction

      # 'Contains' element which only supports matching on string elements, not arrays. # # Exact matching of categories (i.e. match ['a', 'b'] but not ['a', 'b', 'c']) could be implemented by - # post-processing items by fetch the categories field unconditionally and removing the items that don't + # post-processing items by fetching the categories field unconditionally and removing the items that don't # have an exact match. if lookup == self.LOOKUP_IN: # EWS doesn't have an '__in' operator. Allow '__in' lookups on list and non-list field types, # specifying a list value. We'll emulate it as a set of OR'ed exact matches. if not is_iterable(value, generators_allowed=True): raise ValueError("Value for lookup %r must be a list" % key) - children = [self.__class__(**{field_path: v}) for v in value] + children = tuple(self.__class__(**{field_path: v}) for v in value) if not children: # This is an '__in' operator with an empty list as the value. We interpret it to mean "is foo # contained in the empty set?" which is always false. Mark this Q object as such. - return [self.__class__(conn_type=self.NEVER)] - return [self.__class__(*children, conn_type=self.OR)] + return (self.__class__(conn_type=self.NEVER),) + return (self.__class__(*children, conn_type=self.OR),) if lookup == self.LOOKUP_CONTAINS and is_iterable(value, generators_allowed=True): # A '__contains' lookup with an list as the value ony makes sense for list fields, since exact match @@ -177,8 +177,8 @@

      Module exchangelib.restriction

      # # An empty list as value is allowed. We interpret it to mean "are all values in the empty set contained # in foo?" which is always true. - children = [self.__class__(**{field_path: v}) for v in value] - return [self.__class__(*children, conn_type=self.AND)] + children = tuple(self.__class__(**{field_path: v}) for v in value) + return (self.__class__(*children, conn_type=self.AND),) try: op = self._lookup_to_op(lookup) @@ -188,13 +188,13 @@

      Module exchangelib.restriction

      field_path, op = key, self.EQ if not is_single_kwarg: - return [self.__class__(**{key: value})] + return (self.__class__(**{key: value}),) # This is a single-kwarg Q object with a lookup that requires a single value. Make this a leaf self.field_path = field_path self.op = op self.value = value - return [] + return () def reduce(self): """Simplify this object, if possible.""" @@ -704,17 +704,17 @@

      Classes

      if lookup == self.LOOKUP_EXISTS: # value=True will fall through to further processing if not value: - return [~self.__class__(**{key: True})] + return (~self.__class__(**{key: True}),) if lookup == self.LOOKUP_RANGE: # EWS doesn't have a 'range' operator. Emulate 'foo__range=(1, 2)' as 'foo__gte=1 and foo__lte=2' # (both values inclusive). if len(value) != 2: raise ValueError("Value of lookup '%s' must have exactly 2 elements" % key) - return [ + return ( self.__class__(**{'%s__gte' % field_path: value[0]}), self.__class__(**{'%s__lte' % field_path: value[1]}), - ] + ) # Filtering on list types is a bit quirky. The only lookup type I have found to work is: # @@ -730,19 +730,19 @@

      Classes

      # 'Contains' element which only supports matching on string elements, not arrays. # # Exact matching of categories (i.e. match ['a', 'b'] but not ['a', 'b', 'c']) could be implemented by - # post-processing items by fetch the categories field unconditionally and removing the items that don't + # post-processing items by fetching the categories field unconditionally and removing the items that don't # have an exact match. if lookup == self.LOOKUP_IN: # EWS doesn't have an '__in' operator. Allow '__in' lookups on list and non-list field types, # specifying a list value. We'll emulate it as a set of OR'ed exact matches. if not is_iterable(value, generators_allowed=True): raise ValueError("Value for lookup %r must be a list" % key) - children = [self.__class__(**{field_path: v}) for v in value] + children = tuple(self.__class__(**{field_path: v}) for v in value) if not children: # This is an '__in' operator with an empty list as the value. We interpret it to mean "is foo # contained in the empty set?" which is always false. Mark this Q object as such. - return [self.__class__(conn_type=self.NEVER)] - return [self.__class__(*children, conn_type=self.OR)] + return (self.__class__(conn_type=self.NEVER),) + return (self.__class__(*children, conn_type=self.OR),) if lookup == self.LOOKUP_CONTAINS and is_iterable(value, generators_allowed=True): # A '__contains' lookup with an list as the value ony makes sense for list fields, since exact match @@ -750,8 +750,8 @@

      Classes

      # # An empty list as value is allowed. We interpret it to mean "are all values in the empty set contained # in foo?" which is always true. - children = [self.__class__(**{field_path: v}) for v in value] - return [self.__class__(*children, conn_type=self.AND)] + children = tuple(self.__class__(**{field_path: v}) for v in value) + return (self.__class__(*children, conn_type=self.AND),) try: op = self._lookup_to_op(lookup) @@ -761,13 +761,13 @@

      Classes

      field_path, op = key, self.EQ if not is_single_kwarg: - return [self.__class__(**{key: value})] + return (self.__class__(**{key: value}),) # This is a single-kwarg Q object with a lookup that requires a single value. Make this a leaf self.field_path = field_path self.op = op self.value = value - return [] + return () def reduce(self): """Simplify this object, if possible.""" diff --git a/docs/exchangelib/services/common.html b/docs/exchangelib/services/common.html index 53a47f9b..659ca5cf 100644 --- a/docs/exchangelib/services/common.html +++ b/docs/exchangelib/services/common.html @@ -116,8 +116,6 @@

      Module exchangelib.services.common

      supported_from = None # Marks services that support paging of requested items supports_paging = False - # Marks services that need affinity to the backend server - prefer_affinity = False def __init__(self, protocol, chunk_size=None, timeout=None): self.chunk_size = chunk_size or CHUNK_SIZE # The number of items to send in a single request @@ -199,13 +197,7 @@

      Module exchangelib.services.common

      self.protocol.config.version = value def _extra_headers(self, session): - headers = {} - if self.prefer_affinity: - headers['X-PreferServerAffinity'] = 'True' - for cookie in session.cookies: - if cookie.name == 'X-BackEndCookie': - headers['X-BackEndOverrideCookie'] = cookie.value - return headers + return {} @property def _account_to_impersonate(self): @@ -242,6 +234,8 @@

      Module exchangelib.services.common

      yield from self._get_elements(payload=payload_func(chunk, **kwargs)) def stop_streaming(self): + if not self.streaming: + raise RuntimeError('Attempt to stop a non-streaming service') if self._streaming_response: self._streaming_response.close() # Release memory self._streaming_response = None @@ -288,10 +282,15 @@

      Module exchangelib.services.common

      if self.streaming: self.stop_streaming() + def _handle_response_cookies(self, session): + pass + def _get_response(self, payload, api_version): """Send the actual HTTP request and get the response.""" session = self.protocol.get_session() - self._streaming_session, self._streaming_response = None, None + if self.streaming: + # Make sure to clean up lingering resources + self.stop_streaming() r, session = post_ratelimited( protocol=self.protocol, session=session, @@ -307,6 +306,7 @@

      Module exchangelib.services.common

      stream=self.streaming, timeout=self.timeout or self.protocol.TIMEOUT, ) + self._handle_response_cookies(session) if self.streaming: # We con only release the session when we have fully consumed the response. Save session and response # objects for later. @@ -318,7 +318,7 @@

      Module exchangelib.services.common

      @property def _api_versions_to_try(self): # Put the hint first in the list, and then all other versions except the hint, from newest to oldest - return [self._version_hint.api_version] + [v for v in API_VERSIONS if v != self._version_hint.api_version] + return (self._version_hint.api_version,) + tuple(v for v in API_VERSIONS if v != self._version_hint.api_version) def _get_response_xml(self, payload, **parse_opts): """Send the payload to the server and return relevant elements from the result. Several things happen here: @@ -370,7 +370,9 @@

      Module exchangelib.services.common

      # In streaming mode, we may not have accessed the raw stream yet. Caller must handle this. r.close() # Release memory - raise self.NO_VALID_SERVER_VERSIONS('Tried versions %s but all were invalid' % self._api_versions_to_try) + raise self.NO_VALID_SERVER_VERSIONS( + 'Tried versions %s but all were invalid' % ', '.join(self._api_versions_to_try) + ) def _handle_backoff(self, e): """Take a request from the server to back off and checks the retry policy for what to do. Re-raise the @@ -787,6 +789,8 @@

      Module exchangelib.services.common

      """Base class for services that act on items concerning a single Mailbox on the server.""" NO_VALID_SERVER_VERSIONS = ErrorInvalidSchemaVersionForMailboxVersion + # Marks services that need affinity to the backend server + prefer_affinity = False def __init__(self, *args, **kwargs): self.account = kwargs.pop('account') @@ -801,11 +805,28 @@

      Module exchangelib.services.common

      def _version_hint(self, value): self.account.version = value - def _extra_headers(self, *args, **kwargs): - headers = super()._extra_headers(*args, **kwargs) + def _handle_response_cookies(self, session): + super()._handle_response_cookies(session=session) + + # See self._extra_headers() for documentation on affinity + if self.prefer_affinity: + for cookie in session.cookies: + if cookie.name == 'X-BackEndOverrideCookie': + self.account.affinity_cookie = cookie.value + break + + def _extra_headers(self, session): + headers = super()._extra_headers(session=session) # See # https://blogs.msdn.microsoft.com/webdav_101/2015/05/11/best-practices-ews-authentication-and-access-issues/ headers['X-AnchorMailbox'] = self.account.primary_smtp_address + + # See + # https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/how-to-maintain-affinity-between-group-of-subscriptions-and-mailbox-server + if self.prefer_affinity: + headers['X-PreferServerAffinity'] = 'True' + if self.account.affinity_cookie: + headers['X-BackEndOverrideCookie'] = self.account.affinity_cookie return headers @property @@ -1087,6 +1108,8 @@

      Classes

      """Base class for services that act on items concerning a single Mailbox on the server.""" NO_VALID_SERVER_VERSIONS = ErrorInvalidSchemaVersionForMailboxVersion + # Marks services that need affinity to the backend server + prefer_affinity = False def __init__(self, *args, **kwargs): self.account = kwargs.pop('account') @@ -1101,11 +1124,28 @@

      Classes

      def _version_hint(self, value): self.account.version = value - def _extra_headers(self, *args, **kwargs): - headers = super()._extra_headers(*args, **kwargs) + def _handle_response_cookies(self, session): + super()._handle_response_cookies(session=session) + + # See self._extra_headers() for documentation on affinity + if self.prefer_affinity: + for cookie in session.cookies: + if cookie.name == 'X-BackEndOverrideCookie': + self.account.affinity_cookie = cookie.value + break + + def _extra_headers(self, session): + headers = super()._extra_headers(session=session) # See # https://blogs.msdn.microsoft.com/webdav_101/2015/05/11/best-practices-ews-authentication-and-access-issues/ headers['X-AnchorMailbox'] = self.account.primary_smtp_address + + # See + # https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/how-to-maintain-affinity-between-group-of-subscriptions-and-mailbox-server + if self.prefer_affinity: + headers['X-PreferServerAffinity'] = 'True' + if self.account.affinity_cookie: + headers['X-BackEndOverrideCookie'] = self.account.affinity_cookie return headers @property @@ -1160,6 +1200,13 @@

      Subclasses

    • UpdateUserConfiguration
    • UploadItems
    +

    Class variables

    +
    +
    var prefer_affinity
    +
    +
    +
    +

    Inherited members

    • EWSService: @@ -1205,8 +1252,6 @@

      Inherited members

      supported_from = None # Marks services that support paging of requested items supports_paging = False - # Marks services that need affinity to the backend server - prefer_affinity = False def __init__(self, protocol, chunk_size=None, timeout=None): self.chunk_size = chunk_size or CHUNK_SIZE # The number of items to send in a single request @@ -1288,13 +1333,7 @@

      Inherited members

      self.protocol.config.version = value def _extra_headers(self, session): - headers = {} - if self.prefer_affinity: - headers['X-PreferServerAffinity'] = 'True' - for cookie in session.cookies: - if cookie.name == 'X-BackEndCookie': - headers['X-BackEndOverrideCookie'] = cookie.value - return headers + return {} @property def _account_to_impersonate(self): @@ -1331,6 +1370,8 @@

      Inherited members

      yield from self._get_elements(payload=payload_func(chunk, **kwargs)) def stop_streaming(self): + if not self.streaming: + raise RuntimeError('Attempt to stop a non-streaming service') if self._streaming_response: self._streaming_response.close() # Release memory self._streaming_response = None @@ -1377,10 +1418,15 @@

      Inherited members

      if self.streaming: self.stop_streaming() + def _handle_response_cookies(self, session): + pass + def _get_response(self, payload, api_version): """Send the actual HTTP request and get the response.""" session = self.protocol.get_session() - self._streaming_session, self._streaming_response = None, None + if self.streaming: + # Make sure to clean up lingering resources + self.stop_streaming() r, session = post_ratelimited( protocol=self.protocol, session=session, @@ -1396,6 +1442,7 @@

      Inherited members

      stream=self.streaming, timeout=self.timeout or self.protocol.TIMEOUT, ) + self._handle_response_cookies(session) if self.streaming: # We con only release the session when we have fully consumed the response. Save session and response # objects for later. @@ -1407,7 +1454,7 @@

      Inherited members

      @property def _api_versions_to_try(self): # Put the hint first in the list, and then all other versions except the hint, from newest to oldest - return [self._version_hint.api_version] + [v for v in API_VERSIONS if v != self._version_hint.api_version] + return (self._version_hint.api_version,) + tuple(v for v in API_VERSIONS if v != self._version_hint.api_version) def _get_response_xml(self, payload, **parse_opts): """Send the payload to the server and return relevant elements from the result. Several things happen here: @@ -1459,7 +1506,9 @@

      Inherited members

      # In streaming mode, we may not have accessed the raw stream yet. Caller must handle this. r.close() # Release memory - raise self.NO_VALID_SERVER_VERSIONS('Tried versions %s but all were invalid' % self._api_versions_to_try) + raise self.NO_VALID_SERVER_VERSIONS( + 'Tried versions %s but all were invalid' % ', '.join(self._api_versions_to_try) + ) def _handle_backoff(self, e): """Take a request from the server to back off and checks the retry policy for what to do. Re-raise the @@ -1915,10 +1964,6 @@

      Class variables

      -
      var prefer_affinity
      -
      -
      -
      var returns_elements
      @@ -1998,6 +2043,8 @@

      Methods

      Expand source code
      def stop_streaming(self):
      +    if not self.streaming:
      +        raise RuntimeError('Attempt to stop a non-streaming service')
           if self._streaming_response:
               self._streaming_response.close()  # Release memory
               self._streaming_response = None
      @@ -2036,6 +2083,9 @@ 

      Index

      + self.stop_streaming() + self.streaming = False
      @@ -247,8 +247,8 @@

      Classes

      # The returned content did not contain any EWS exceptions. Give up and re-raise the original exception. raise enf finally: - self.streaming = False - self.stop_streaming() + self.stop_streaming() + self.streaming = False

      Ancestors

        @@ -350,8 +350,8 @@

        Methods

        # The returned content did not contain any EWS exceptions. Give up and re-raise the original exception. raise enf finally: - self.streaming = False - self.stop_streaming() + self.stop_streaming() + self.streaming = False
      diff --git a/docs/exchangelib/services/get_streaming_events.html b/docs/exchangelib/services/get_streaming_events.html index b7ada27f..bcdab97b 100644 --- a/docs/exchangelib/services/get_streaming_events.html +++ b/docs/exchangelib/services/get_streaming_events.html @@ -29,6 +29,7 @@

      Module exchangelib.services.get_streaming_events<
      import logging
       
       from .common import EWSAccountService, add_xml_child
      +from ..errors import EWSError
       from ..properties import Notification
       from ..util import create_element, get_xml_attr, get_xml_attrs, MNS, DocumentYielder, DummyResponse
       
      @@ -43,7 +44,6 @@ 

      Module exchangelib.services.get_streaming_events< SERVICE_NAME = 'GetStreamingEvents' element_container_name = '{%s}Notifications' % MNS - streaming = True prefer_affinity = True # Connection status values @@ -53,8 +53,8 @@

      Module exchangelib.services.get_streaming_events< def __init__(self, *args, **kwargs): # These values are set each time call() is consumed self.connection_status = None - self.error_subscription_ids = [] super().__init__(*args, **kwargs) + self.streaming = True def call(self, subscription_ids, connection_timeout): if connection_timeout < 1: @@ -97,15 +97,23 @@

      Module exchangelib.services.get_streaming_events< def _get_element_container(self, message, name=None): error_ids_elem = message.find('{%s}ErrorSubscriptionIds' % MNS) - if error_ids_elem is not None: - self.error_subscription_ids = get_xml_attrs(error_ids_elem, '{%s}ErrorSubscriptionId' % MNS) - log.debug('These subscription IDs are invalid: %s', self.error_subscription_ids) + error_ids = [] if error_ids_elem is None else get_xml_attrs(error_ids_elem, '{%s}SubscriptionId' % MNS) self.connection_status = get_xml_attr(message, '{%s}ConnectionStatus' % MNS) # Either 'OK' or 'Closed' log.debug('Connection status is: %s', self.connection_status) - # Upstream expects to find a 'name' tag but our response does not always have it. Return an empty element. + # Upstream normally expects to find a 'name' tag but our response does not always have it. We still want to + # call upstream, to have exceptions raised. Return an empty list if there is no 'name' tag and no errors. if message.find(name) is None: - return [] - return super()._get_element_container(message=message, name=name) + name = None + try: + res = super()._get_element_container(message=message, name=name) + except EWSError as e: + # When the request contains a combination of good and failing subscription IDs, notifications for the good + # subscriptions seem to never be returned even though the XML spec allows it. This means there's no point in + # trying to collect any notifications here and delivering a combination of errors and return values. + if error_ids: + e.value += ' (subscription IDs: %s)' % ', '.join(repr(i) for i in error_ids) + raise e + return [] if name is None else res def get_payload(self, subscription_ids, connection_timeout): getstreamingevents = create_element('m:%s' % self.SERVICE_NAME) @@ -147,7 +155,6 @@

      Classes

      SERVICE_NAME = 'GetStreamingEvents' element_container_name = '{%s}Notifications' % MNS - streaming = True prefer_affinity = True # Connection status values @@ -157,8 +164,8 @@

      Classes

      def __init__(self, *args, **kwargs): # These values are set each time call() is consumed self.connection_status = None - self.error_subscription_ids = [] super().__init__(*args, **kwargs) + self.streaming = True def call(self, subscription_ids, connection_timeout): if connection_timeout < 1: @@ -201,15 +208,23 @@

      Classes

      def _get_element_container(self, message, name=None): error_ids_elem = message.find('{%s}ErrorSubscriptionIds' % MNS) - if error_ids_elem is not None: - self.error_subscription_ids = get_xml_attrs(error_ids_elem, '{%s}ErrorSubscriptionId' % MNS) - log.debug('These subscription IDs are invalid: %s', self.error_subscription_ids) + error_ids = [] if error_ids_elem is None else get_xml_attrs(error_ids_elem, '{%s}SubscriptionId' % MNS) self.connection_status = get_xml_attr(message, '{%s}ConnectionStatus' % MNS) # Either 'OK' or 'Closed' log.debug('Connection status is: %s', self.connection_status) - # Upstream expects to find a 'name' tag but our response does not always have it. Return an empty element. + # Upstream normally expects to find a 'name' tag but our response does not always have it. We still want to + # call upstream, to have exceptions raised. Return an empty list if there is no 'name' tag and no errors. if message.find(name) is None: - return [] - return super()._get_element_container(message=message, name=name) + name = None + try: + res = super()._get_element_container(message=message, name=name) + except EWSError as e: + # When the request contains a combination of good and failing subscription IDs, notifications for the good + # subscriptions seem to never be returned even though the XML spec allows it. This means there's no point in + # trying to collect any notifications here and delivering a combination of errors and return values. + if error_ids: + e.value += ' (subscription IDs: %s)' % ', '.join(repr(i) for i in error_ids) + raise e + return [] if name is None else res def get_payload(self, subscription_ids, connection_timeout): getstreamingevents = create_element('m:%s' % self.SERVICE_NAME) @@ -250,10 +265,6 @@

      Class variables

      -
      var streaming
      -
      -
      -

      Methods

      @@ -335,7 +346,6 @@

      element_container_name

    • get_payload
    • prefer_affinity
    • -
    • streaming
  • diff --git a/docs/exchangelib/services/index.html b/docs/exchangelib/services/index.html index cd15d9e8..662c2a94 100644 --- a/docs/exchangelib/services/index.html +++ b/docs/exchangelib/services/index.html @@ -2757,8 +2757,8 @@

    Inherited members

    # The returned content did not contain any EWS exceptions. Give up and re-raise the original exception. raise enf finally: - self.streaming = False - self.stop_streaming()
    + self.stop_streaming() + self.streaming = False

    Ancestors

      @@ -2860,8 +2860,8 @@

      Methods

      # The returned content did not contain any EWS exceptions. Give up and re-raise the original exception. raise enf finally: - self.streaming = False - self.stop_streaming() + self.stop_streaming() + self.streaming = False @@ -4118,7 +4118,6 @@

      Inherited members

      SERVICE_NAME = 'GetStreamingEvents' element_container_name = '{%s}Notifications' % MNS - streaming = True prefer_affinity = True # Connection status values @@ -4128,8 +4127,8 @@

      Inherited members

      def __init__(self, *args, **kwargs): # These values are set each time call() is consumed self.connection_status = None - self.error_subscription_ids = [] super().__init__(*args, **kwargs) + self.streaming = True def call(self, subscription_ids, connection_timeout): if connection_timeout < 1: @@ -4172,15 +4171,23 @@

      Inherited members

      def _get_element_container(self, message, name=None): error_ids_elem = message.find('{%s}ErrorSubscriptionIds' % MNS) - if error_ids_elem is not None: - self.error_subscription_ids = get_xml_attrs(error_ids_elem, '{%s}ErrorSubscriptionId' % MNS) - log.debug('These subscription IDs are invalid: %s', self.error_subscription_ids) + error_ids = [] if error_ids_elem is None else get_xml_attrs(error_ids_elem, '{%s}SubscriptionId' % MNS) self.connection_status = get_xml_attr(message, '{%s}ConnectionStatus' % MNS) # Either 'OK' or 'Closed' log.debug('Connection status is: %s', self.connection_status) - # Upstream expects to find a 'name' tag but our response does not always have it. Return an empty element. + # Upstream normally expects to find a 'name' tag but our response does not always have it. We still want to + # call upstream, to have exceptions raised. Return an empty list if there is no 'name' tag and no errors. if message.find(name) is None: - return [] - return super()._get_element_container(message=message, name=name) + name = None + try: + res = super()._get_element_container(message=message, name=name) + except EWSError as e: + # When the request contains a combination of good and failing subscription IDs, notifications for the good + # subscriptions seem to never be returned even though the XML spec allows it. This means there's no point in + # trying to collect any notifications here and delivering a combination of errors and return values. + if error_ids: + e.value += ' (subscription IDs: %s)' % ', '.join(repr(i) for i in error_ids) + raise e + return [] if name is None else res def get_payload(self, subscription_ids, connection_timeout): getstreamingevents = create_element('m:%s' % self.SERVICE_NAME) @@ -4221,10 +4228,6 @@

      Class variables

      -
      var streaming
      -
      -
      -

      Methods

      @@ -5429,6 +5432,7 @@

      Inherited members

      class SubscribeToPull(Subscribe):
           subscription_request_elem_tag = 'm:PullSubscriptionRequest'
      +    prefer_affinity = True
       
           def call(self, folders, event_types, watermark, timeout):
               yield from self._partial_call(
      @@ -5453,6 +5457,10 @@ 

      Ancestors

    Class variables

    +
    var prefer_affinity
    +
    +
    +
    var subscription_request_elem_tag
    @@ -5613,6 +5621,7 @@

    Inherited members

    class SubscribeToStreaming(Subscribe):
         subscription_request_elem_tag = 'm:StreamingSubscriptionRequest'
    +    prefer_affinity = True
     
         def call(self, folders, event_types):
             yield from self._partial_call(payload_func=self.get_payload, folders=folders, event_types=event_types)
    @@ -5642,6 +5651,10 @@ 

    Ancestors

    Class variables

    +
    var prefer_affinity
    +
    +
    +
    var subscription_request_elem_tag
    @@ -6024,6 +6037,7 @@

    Inherited members

    SERVICE_NAME = 'Unsubscribe' returns_elements = False + prefer_affinity = True def call(self, subscription_id): return self._get_elements(payload=self.get_payload(subscription_id=subscription_id)) @@ -6044,6 +6058,10 @@

    Class variables

    +
    var prefer_affinity
    +
    +
    +
    var returns_elements
    @@ -7152,7 +7170,6 @@

    element_container_name
  • get_payload
  • prefer_affinity
  • -
  • streaming
  • @@ -7248,6 +7265,7 @@

  • call
  • get_payload
  • +
  • prefer_affinity
  • subscription_request_elem_tag
  • @@ -7264,6 +7282,7 @@

  • call
  • get_payload
  • +
  • prefer_affinity
  • subscription_request_elem_tag
  • @@ -7296,6 +7315,7 @@

    SERVICE_NAME
  • call
  • get_payload
  • +
  • prefer_affinity
  • returns_elements
  • diff --git a/docs/exchangelib/services/subscribe.html b/docs/exchangelib/services/subscribe.html index 64b9ca3a..1acf1fc5 100644 --- a/docs/exchangelib/services/subscribe.html +++ b/docs/exchangelib/services/subscribe.html @@ -87,6 +87,7 @@

    Module exchangelib.services.subscribe

    class SubscribeToPull(Subscribe): subscription_request_elem_tag = 'm:PullSubscriptionRequest' + prefer_affinity = True def call(self, folders, event_types, watermark, timeout): yield from self._partial_call( @@ -126,6 +127,7 @@

    Module exchangelib.services.subscribe

    class SubscribeToStreaming(Subscribe): subscription_request_elem_tag = 'm:StreamingSubscriptionRequest' + prefer_affinity = True def call(self, folders, event_types): yield from self._partial_call(payload_func=self.get_payload, folders=folders, event_types=event_types) @@ -263,6 +265,7 @@

    Inherited members

    class SubscribeToPull(Subscribe):
         subscription_request_elem_tag = 'm:PullSubscriptionRequest'
    +    prefer_affinity = True
     
         def call(self, folders, event_types, watermark, timeout):
             yield from self._partial_call(
    @@ -287,6 +290,10 @@ 

    Ancestors

    Class variables

    +
    var prefer_affinity
    +
    +
    +
    var subscription_request_elem_tag
    @@ -447,6 +454,7 @@

    Inherited members

    class SubscribeToStreaming(Subscribe):
         subscription_request_elem_tag = 'm:StreamingSubscriptionRequest'
    +    prefer_affinity = True
     
         def call(self, folders, event_types):
             yield from self._partial_call(payload_func=self.get_payload, folders=folders, event_types=event_types)
    @@ -476,6 +484,10 @@ 

    Ancestors

    Class variables

    +
    var prefer_affinity
    +
    +
    +
    var subscription_request_elem_tag
    @@ -554,6 +566,7 @@

  • call
  • get_payload
  • +
  • prefer_affinity
  • subscription_request_elem_tag
  • @@ -570,6 +583,7 @@

  • call
  • get_payload
  • +
  • prefer_affinity
  • subscription_request_elem_tag
  • diff --git a/docs/exchangelib/services/unsubscribe.html b/docs/exchangelib/services/unsubscribe.html index 5b95cdba..3b656c5e 100644 --- a/docs/exchangelib/services/unsubscribe.html +++ b/docs/exchangelib/services/unsubscribe.html @@ -38,6 +38,7 @@

    Module exchangelib.services.unsubscribe

    SERVICE_NAME = 'Unsubscribe' returns_elements = False + prefer_affinity = True def call(self, subscription_id): return self._get_elements(payload=self.get_payload(subscription_id=subscription_id)) @@ -76,6 +77,7 @@

    Classes

    SERVICE_NAME = 'Unsubscribe' returns_elements = False + prefer_affinity = True def call(self, subscription_id): return self._get_elements(payload=self.get_payload(subscription_id=subscription_id)) @@ -96,6 +98,10 @@

    Class variables

    +
    var prefer_affinity
    +
    +
    +
    var returns_elements
    @@ -166,6 +172,7 @@

    SERVICE_NAME
  • call
  • get_payload
  • +
  • prefer_affinity
  • returns_elements
  • diff --git a/docs/exchangelib/util.html b/docs/exchangelib/util.html index 5cfbda7d..7b6edb4a 100644 --- a/docs/exchangelib/util.html +++ b/docs/exchangelib/util.html @@ -195,7 +195,7 @@

    Module exchangelib.util

    def get_xml_attrs(tree, name): - return [elem.text for elem in tree.findall(name) if elem.text is not None] + return list(elem.text for elem in tree.findall(name) if elem.text is not None) def value_to_xml_text(value): @@ -1120,7 +1120,7 @@

    Functions

    Expand source code
    def get_xml_attrs(tree, name):
    -    return [elem.text for elem in tree.findall(name) if elem.text is not None]
    + return list(elem.text for elem in tree.findall(name) if elem.text is not None)

    diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index d8d1bf83..5ebe9cd9 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -18,7 +18,7 @@ from .transport import BASIC, DIGEST, NTLM, GSSAPI, SSPI, OAUTH2, CBA from .version import Build, Version -__version__ = '4.6.1' +__version__ = '4.6.2' __all__ = [ '__version__', From 8577b8f7a534de5bd19ad7e28d6cf4be082c8750 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 7 Dec 2021 22:45:11 +0100 Subject: [PATCH 025/509] All supported Python versions have stable dict ordering --- exchangelib/fields.py | 3 +-- exchangelib/restriction.py | 6 +----- exchangelib/services/create_item.py | 7 +------ exchangelib/services/delete_item.py | 24 +++++++++++------------- exchangelib/services/empty_folder.py | 10 ++++------ exchangelib/services/find_folder.py | 12 +++++------- exchangelib/services/find_item.py | 12 +++++------- exchangelib/services/find_people.py | 12 +++++------- exchangelib/services/update_item.py | 26 ++++++++++++-------------- exchangelib/util.py | 14 ++++++-------- 10 files changed, 51 insertions(+), 75 deletions(-) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index ee2b4031..655fe050 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -1,7 +1,6 @@ import abc import datetime import logging -from collections import OrderedDict from decimal import Decimal, InvalidOperation from importlib import import_module @@ -733,7 +732,7 @@ def from_xml(self, elem, account): return self.default def to_xml(self, value, version): - attrs = OrderedDict([('Id', value.ms_id)]) + attrs = dict(Id=value.ms_id) if value.ms_name: attrs['Name'] = value.ms_name return create_element(self.request_tag(), attrs=attrs) diff --git a/exchangelib/restriction.py b/exchangelib/restriction.py index 18a5ff5b..6035d3ae 100644 --- a/exchangelib/restriction.py +++ b/exchangelib/restriction.py @@ -1,5 +1,4 @@ import logging -from collections import OrderedDict from copy import copy from .fields import InvalidField, FieldPath, DateTimeBackedDateField @@ -295,10 +294,7 @@ def _op_to_xml(cls, op): compare_mode = 'Exact' return create_element( 't:Contains', - attrs=OrderedDict([ - ('ContainmentMode', match_mode), - ('ContainmentComparison', compare_mode), - ]) + attrs=dict(ContainmentMode=match_mode, ContainmentComparison=compare_mode) ) def is_leaf(self): diff --git a/exchangelib/services/create_item.py b/exchangelib/services/create_item.py index 1ef7c18d..f6b1b733 100644 --- a/exchangelib/services/create_item.py +++ b/exchangelib/services/create_item.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from .common import EWSAccountService from ..util import create_element, set_xml_value, MNS @@ -82,10 +80,7 @@ def get_payload(self, items, folder, message_disposition, send_meeting_invitatio """ createitem = create_element( 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('MessageDisposition', message_disposition), - ('SendMeetingInvitations', send_meeting_invitations), - ]) + attrs=dict(MessageDisposition=message_disposition, SendMeetingInvitations=send_meeting_invitations) ) if folder: saveditemfolderid = create_element('m:SavedItemFolderId') diff --git a/exchangelib/services/delete_item.py b/exchangelib/services/delete_item.py index 4e94cca8..b2f071e9 100644 --- a/exchangelib/services/delete_item.py +++ b/exchangelib/services/delete_item.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from .common import EWSAccountService, create_item_ids_element from ..util import create_element from ..version import EXCHANGE_2013_SP1 @@ -46,21 +44,21 @@ def get_payload(self, items, delete_type, send_meeting_cancellations, affected_t if self.account.version.build >= EXCHANGE_2013_SP1: deleteitem = create_element( 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('DeleteType', delete_type), - ('SendMeetingCancellations', send_meeting_cancellations), - ('AffectedTaskOccurrences', affected_task_occurrences), - ('SuppressReadReceipts', 'true' if suppress_read_receipts else 'false'), - ]) + attrs=dict( + DeleteType=delete_type, + SendMeetingCancellations=send_meeting_cancellations, + AffectedTaskOccurrences=affected_task_occurrences, + SuppressReadReceipts='true' if suppress_read_receipts else 'false', + ) ) else: deleteitem = create_element( 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('DeleteType', delete_type), - ('SendMeetingCancellations', send_meeting_cancellations), - ('AffectedTaskOccurrences', affected_task_occurrences), - ]) + attrs=dict( + DeleteType=delete_type, + SendMeetingCancellations=send_meeting_cancellations, + AffectedTaskOccurrences=affected_task_occurrences, + ) ) item_ids = create_item_ids_element(items=items, version=self.account.version) diff --git a/exchangelib/services/empty_folder.py b/exchangelib/services/empty_folder.py index c304d706..1a2bc87a 100644 --- a/exchangelib/services/empty_folder.py +++ b/exchangelib/services/empty_folder.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from .common import EWSAccountService, create_folder_ids_element from ..util import create_element @@ -18,10 +16,10 @@ def call(self, folders, delete_type, delete_sub_folders): def get_payload(self, folders, delete_type, delete_sub_folders): emptyfolder = create_element( 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('DeleteType', delete_type), - ('DeleteSubFolders', 'true' if delete_sub_folders else 'false'), - ]) + attrs=dict( + DeleteType=delete_type, + DeleteSubFolders='true' if delete_sub_folders else 'false', + ) ) folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version) emptyfolder.append(folder_ids) diff --git a/exchangelib/services/find_folder.py b/exchangelib/services/find_folder.py index ea44897f..70f33d8f 100644 --- a/exchangelib/services/find_folder.py +++ b/exchangelib/services/find_folder.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from .common import EWSAccountService, create_shape_element from ..util import create_element, set_xml_value, TNS, MNS from ..version import EXCHANGE_2010 @@ -65,11 +63,11 @@ def get_payload(self, folders, additional_fields, restriction, shape, depth, pag if self.account.version.build >= EXCHANGE_2010: indexedpageviewitem = create_element( 'm:IndexedPageFolderView', - attrs=OrderedDict([ - ('MaxEntriesReturned', str(page_size)), - ('Offset', str(offset)), - ('BasePoint', 'Beginning'), - ]) + attrs=dict( + MaxEntriesReturned=str(page_size), + Offset=str(offset), + BasePoint='Beginning', + ) ) findfolder.append(indexedpageviewitem) else: diff --git a/exchangelib/services/find_item.py b/exchangelib/services/find_item.py index 871dad24..f5868a6b 100644 --- a/exchangelib/services/find_item.py +++ b/exchangelib/services/find_item.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from .common import EWSAccountService, create_shape_element from ..util import create_element, set_xml_value, TNS, MNS @@ -76,11 +74,11 @@ def get_payload(self, folders, additional_fields, restriction, order_fields, que if calendar_view is None: view_type = create_element( 'm:IndexedPageItemView', - attrs=OrderedDict([ - ('MaxEntriesReturned', str(page_size)), - ('Offset', str(offset)), - ('BasePoint', 'Beginning'), - ]) + attrs=dict( + MaxEntriesReturned=str(page_size), + Offset=str(offset), + BasePoint='Beginning', + ) ) else: view_type = calendar_view.to_xml(version=self.account.version) diff --git a/exchangelib/services/find_people.py b/exchangelib/services/find_people.py index 84e73fce..8a8d4ddb 100644 --- a/exchangelib/services/find_people.py +++ b/exchangelib/services/find_people.py @@ -1,6 +1,4 @@ import logging -from collections import OrderedDict - from .common import EWSAccountService, create_shape_element from ..util import create_element, set_xml_value, MNS from ..version import EXCHANGE_2013 @@ -79,11 +77,11 @@ def get_payload(self, folders, additional_fields, restriction, order_fields, que findpeople.append(personashape) view_type = create_element( 'm:IndexedPageItemView', - attrs=OrderedDict([ - ('MaxEntriesReturned', str(page_size)), - ('Offset', str(offset)), - ('BasePoint', 'Beginning'), - ]) + attrs=dict( + MaxEntriesReturned=str(page_size), + Offset=str(offset), + BasePoint='Beginning', + ) ) findpeople.append(view_type) if restriction: diff --git a/exchangelib/services/update_item.py b/exchangelib/services/update_item.py index 3e44a40f..794a4b5d 100644 --- a/exchangelib/services/update_item.py +++ b/exchangelib/services/update_item.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from .common import EWSAccountService, to_item_id from ..ewsdatetime import EWSDate from ..fields import FieldPath, IndexedField @@ -68,7 +66,7 @@ def _set_item_elem(self, item_model, field_path, value): def _sorted_fields(item_model, fieldnames): # Take a list of fieldnames and return the (unique) fields in the order they are mentioned in item_class.FIELDS. # Checks that all fieldnames are valid. - unique_fieldnames = list(OrderedDict.fromkeys(fieldnames)) # Make field names unique ,but keep ordering + unique_fieldnames = list(dict.fromkeys(fieldnames)) # Make field names unique, but keep ordering # Loop over FIELDS and not supported_fields(). Upstream should make sure not to update a non-supported field. for f in item_model.FIELDS: if f.name in unique_fieldnames: @@ -157,21 +155,21 @@ def get_payload(self, items, conflict_resolution, message_disposition, send_meet if self.account.version.build >= EXCHANGE_2013_SP1: updateitem = create_element( 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('ConflictResolution', conflict_resolution), - ('MessageDisposition', message_disposition), - ('SendMeetingInvitationsOrCancellations', send_meeting_invitations_or_cancellations), - ('SuppressReadReceipts', 'true' if suppress_read_receipts else 'false'), - ]) + attrs=dict( + ConflictResolution=conflict_resolution, + MessageDisposition=message_disposition, + SendMeetingInvitationsOrCancellations=send_meeting_invitations_or_cancellations, + SuppressReadReceipts='true' if suppress_read_receipts else 'false', + ) ) else: updateitem = create_element( 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('ConflictResolution', conflict_resolution), - ('MessageDisposition', message_disposition), - ('SendMeetingInvitationsOrCancellations', send_meeting_invitations_or_cancellations), - ]) + attrs=dict( + ConflictResolution=conflict_resolution, + MessageDisposition=message_disposition, + SendMeetingInvitationsOrCancellations=send_meeting_invitations_or_cancellations, + ) ) itemchanges = create_element('m:ItemChanges') version = self.account.version diff --git a/exchangelib/util.py b/exchangelib/util.py index 8e52c6c6..db788f58 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -8,7 +8,6 @@ import xml.sax.handler # nosec from base64 import b64decode, b64encode from codecs import BOM_UTF8 -from collections import OrderedDict from decimal import Decimal from functools import wraps from threading import get_ident @@ -74,11 +73,11 @@ def __init__(self, msg, data): AUTODISCOVER_REQUEST_NS = 'http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006' AUTODISCOVER_RESPONSE_NS = 'http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a' -ns_translation = OrderedDict([ - ('s', SOAPNS), - ('m', MNS), - ('t', TNS), -]) +ns_translation = { + 's': SOAPNS, + 'm': MNS, + 't': TNS, +} for item in ns_translation.items(): lxml.etree.register_namespace(*item) @@ -269,14 +268,13 @@ def safe_xml_value(value, replacement='?'): def create_element(name, attrs=None, nsmap=None): - # Python versions prior to 3.6 do not preserve dict or kwarg ordering, so we cannot pull in attrs as **kwargs if we - # also want stable XML attribute output. Instead, let callers supply us with an OrderedDict instance. if ':' in name: ns, name = name.split(':') name = '{%s}%s' % (ns_translation[ns], name) elem = _forgiving_parser.makeelement(name, nsmap=nsmap) if attrs: # Try hard to keep attribute order, to ensure deterministic output. This simplifies testing. + # Dicts in Python 3.6+ have stable ordering. for k, v in attrs.items(): elem.set(k, v) return elem From 9fcc16dd4b8f23f98083876b64a3775efc928cdf Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 7 Dec 2021 22:45:52 +0100 Subject: [PATCH 026/509] Require Python 3.7. 3.6 is EOL on 2021-12-23. --- exchangelib/__init__.py | 8 -------- setup.py | 3 +-- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index 5ebe9cd9..83a62d91 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -1,5 +1,3 @@ -import sys - from .account import Account, Identity from .attachments import FileAttachment, ItemAttachment from .autodiscover import discover @@ -44,12 +42,6 @@ import requests.utils BaseProtocol.USERAGENT = "%s/%s (%s)" % (__name__, __version__, requests.utils.default_user_agent()) -# Support fromisoformat() in Python < 3.7 -if sys.version_info[:2] < (3, 7): - from backports.datetime_fromisoformat import MonkeyPatch - MonkeyPatch.patch_fromisoformat() - - def close_connections(): from .autodiscover import close_connections as close_autodiscover_connections from .protocol import close_connections as close_protocol_connections diff --git a/setup.py b/setup.py index 05722ca9..49b46e61 100755 --- a/setup.py +++ b/setup.py @@ -40,7 +40,6 @@ def read(file_name): license='BSD-2-Clause', keywords='ews exchange autodiscover microsoft outlook exchange-web-services o365 office365', install_requires=[ - 'backports-datetime-fromisoformat;python_version<"3.7"', 'backports.zoneinfo;python_version<"3.9"', 'cached_property', 'defusedxml>=0.6.0', @@ -62,7 +61,7 @@ def read(file_name): }, packages=find_packages(exclude=('tests', 'tests.*')), tests_require=['flake8', 'psutil', 'python-dateutil', 'pytz', 'PyYAML', 'requests_mock'], - python_requires=">=3.6", + python_requires=">=3.7", test_suite='tests', zip_safe=False, url='https://github.com/ecederstrand/exchangelib', From 77f7895176e8fd13e0f9bb7bea9f87fff614624a Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 7 Dec 2021 23:16:55 +0100 Subject: [PATCH 027/509] Always use attrs arg for create_element() - we don't do caching anymore. Handle common transformations there, too. --- exchangelib/fields.py | 3 +-- exchangelib/properties.py | 11 ++++++----- exchangelib/restriction.py | 4 +--- exchangelib/services/delete_item.py | 2 +- exchangelib/services/empty_folder.py | 5 +---- exchangelib/services/find_folder.py | 6 +----- exchangelib/services/find_item.py | 6 +----- exchangelib/services/find_people.py | 6 +----- exchangelib/services/get_delegate.py | 5 +---- exchangelib/services/get_server_time_zones.py | 2 +- exchangelib/services/mark_as_junk.py | 5 +---- exchangelib/services/resolve_names.py | 10 ++++------ exchangelib/services/send_item.py | 5 +---- exchangelib/services/update_item.py | 2 +- exchangelib/services/upload_items.py | 5 +++-- exchangelib/util.py | 4 ++++ 16 files changed, 29 insertions(+), 52 deletions(-) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index 655fe050..44dc69b2 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -934,12 +934,11 @@ def from_xml(self, elem, account): def to_xml(self, value, version): from .properties import Body, HTMLBody - field_elem = create_element(self.request_tag()) body_type = { Body: Body.body_type, HTMLBody: HTMLBody.body_type, }[type(value)] - field_elem.set('BodyType', body_type) + field_elem = create_element(self.request_tag(), attrs=dict(BodyType=body_type)) return set_xml_value(field_elem, value, version=version) diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 7134f0cf..9e084e64 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -283,17 +283,18 @@ def to_xml(self, version): # WARNING: The order of addition of XML elements is VERY important. Exchange expects XML elements in a # specific, non-documented order and will fail with meaningless errors if the order is wrong. - # Call create_element() without args, to not fill up the cache with unique attribute values. - elem = create_element(self.request_tag()) - - # Add attributes + # Collect attributes + attrs = dict() for f in self.attribute_fields(): if f.is_read_only: continue value = getattr(self, f.name) if value is None or (f.is_list and not value): continue - elem.set(f.field_uri, value_to_xml_text(getattr(self, f.name))) + attrs[f.field_uri] = value_to_xml_text(getattr(self, f.name)) + + # Create element with attributes + elem = create_element(self.request_tag(), attrs=attrs) # Add elements and values for f in self.supported_fields(version=version): diff --git a/exchangelib/restriction.py b/exchangelib/restriction.py index 6035d3ae..94a016c1 100644 --- a/exchangelib/restriction.py +++ b/exchangelib/restriction.py @@ -444,10 +444,8 @@ def xml_elem(self, folders, version, applies_to): # We need to convert to datetime clean_value = field_path.field.date_to_datetime(clean_value) elem.append(field_path.to_xml()) - constant = create_element('t:Constant') if self.op != self.EXISTS: - # Use .set() to not fill up the create_element() cache with unique values - constant.set('Value', value_to_xml_text(clean_value)) + constant = create_element('t:Constant', attrs=dict(Value=value_to_xml_text(clean_value))) if self.op in self.CONTAINS_OPS: elem.append(constant) else: diff --git a/exchangelib/services/delete_item.py b/exchangelib/services/delete_item.py index b2f071e9..e1981306 100644 --- a/exchangelib/services/delete_item.py +++ b/exchangelib/services/delete_item.py @@ -48,7 +48,7 @@ def get_payload(self, items, delete_type, send_meeting_cancellations, affected_t DeleteType=delete_type, SendMeetingCancellations=send_meeting_cancellations, AffectedTaskOccurrences=affected_task_occurrences, - SuppressReadReceipts='true' if suppress_read_receipts else 'false', + SuppressReadReceipts=suppress_read_receipts, ) ) else: diff --git a/exchangelib/services/empty_folder.py b/exchangelib/services/empty_folder.py index 1a2bc87a..a1a457c3 100644 --- a/exchangelib/services/empty_folder.py +++ b/exchangelib/services/empty_folder.py @@ -16,10 +16,7 @@ def call(self, folders, delete_type, delete_sub_folders): def get_payload(self, folders, delete_type, delete_sub_folders): emptyfolder = create_element( 'm:%s' % self.SERVICE_NAME, - attrs=dict( - DeleteType=delete_type, - DeleteSubFolders='true' if delete_sub_folders else 'false', - ) + attrs=dict(DeleteType=delete_type, DeleteSubFolders=delete_sub_folders) ) folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version) emptyfolder.append(folder_ids) diff --git a/exchangelib/services/find_folder.py b/exchangelib/services/find_folder.py index 70f33d8f..d8937de2 100644 --- a/exchangelib/services/find_folder.py +++ b/exchangelib/services/find_folder.py @@ -63,11 +63,7 @@ def get_payload(self, folders, additional_fields, restriction, shape, depth, pag if self.account.version.build >= EXCHANGE_2010: indexedpageviewitem = create_element( 'm:IndexedPageFolderView', - attrs=dict( - MaxEntriesReturned=str(page_size), - Offset=str(offset), - BasePoint='Beginning', - ) + attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') ) findfolder.append(indexedpageviewitem) else: diff --git a/exchangelib/services/find_item.py b/exchangelib/services/find_item.py index f5868a6b..da20d085 100644 --- a/exchangelib/services/find_item.py +++ b/exchangelib/services/find_item.py @@ -74,11 +74,7 @@ def get_payload(self, folders, additional_fields, restriction, order_fields, que if calendar_view is None: view_type = create_element( 'm:IndexedPageItemView', - attrs=dict( - MaxEntriesReturned=str(page_size), - Offset=str(offset), - BasePoint='Beginning', - ) + attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') ) else: view_type = calendar_view.to_xml(version=self.account.version) diff --git a/exchangelib/services/find_people.py b/exchangelib/services/find_people.py index 8a8d4ddb..22b86451 100644 --- a/exchangelib/services/find_people.py +++ b/exchangelib/services/find_people.py @@ -77,11 +77,7 @@ def get_payload(self, folders, additional_fields, restriction, order_fields, que findpeople.append(personashape) view_type = create_element( 'm:IndexedPageItemView', - attrs=dict( - MaxEntriesReturned=str(page_size), - Offset=str(offset), - BasePoint='Beginning', - ) + attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') ) findpeople.append(view_type) if restriction: diff --git a/exchangelib/services/get_delegate.py b/exchangelib/services/get_delegate.py index e2c923ff..29512fcb 100644 --- a/exchangelib/services/get_delegate.py +++ b/exchangelib/services/get_delegate.py @@ -26,10 +26,7 @@ def _elems_to_objs(self, elems): yield DelegateUser.from_xml(elem=elem, account=self.account) def get_payload(self, user_ids, mailbox, include_permissions): - payload = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=dict(IncludePermissions='true' if include_permissions else 'false'), - ) + payload = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(IncludePermissions=include_permissions)) set_xml_value(payload, mailbox, version=self.protocol.version) if user_ids != [None]: set_xml_value(payload, user_ids, version=self.protocol.version) diff --git a/exchangelib/services/get_server_time_zones.py b/exchangelib/services/get_server_time_zones.py index eab3f913..47979900 100644 --- a/exchangelib/services/get_server_time_zones.py +++ b/exchangelib/services/get_server_time_zones.py @@ -26,7 +26,7 @@ def call(self, timezones=None, return_full_timezone_data=False): def get_payload(self, timezones, return_full_timezone_data): payload = create_element( 'm:%s' % self.SERVICE_NAME, - attrs=dict(ReturnFullTimeZoneData='true' if return_full_timezone_data else 'false'), + attrs=dict(ReturnFullTimeZoneData=return_full_timezone_data), ) if timezones is not None: is_empty, timezones = peek(timezones) diff --git a/exchangelib/services/mark_as_junk.py b/exchangelib/services/mark_as_junk.py index bd3f417e..7effda50 100644 --- a/exchangelib/services/mark_as_junk.py +++ b/exchangelib/services/mark_as_junk.py @@ -26,10 +26,7 @@ def _get_elements_in_container(cls, container): def get_payload(self, items, is_junk, move_item): # Takes a list of items and returns either success or raises an error message - mark_as_junk = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=dict(IsJunk='true' if is_junk else 'false', MoveItem='true' if move_item else 'false') - ) + mark_as_junk = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(IsJunk=is_junk, MoveItem=move_item)) item_ids = create_item_ids_element(items=items, version=self.account.version) mark_as_junk.append(item_ids) return mark_as_junk diff --git a/exchangelib/services/resolve_names.py b/exchangelib/services/resolve_names.py index 4381f041..08fa178b 100644 --- a/exchangelib/services/resolve_names.py +++ b/exchangelib/services/resolve_names.py @@ -65,17 +65,15 @@ def _elems_to_objs(self, elems): def get_payload(self, unresolved_entries, parent_folders, return_full_contact_data, search_scope, contact_data_shape): - payload = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=dict(ReturnFullContactData='true' if return_full_contact_data else 'false'), - ) + attrs = dict(ReturnFullContactData=return_full_contact_data) if search_scope: - payload.set('SearchScope', search_scope) + attrs['SearchScope'] = search_scope if contact_data_shape: if self.protocol.version.build < EXCHANGE_2010_SP2: raise NotImplementedError( "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later") - payload.set('ContactDataShape', contact_data_shape) + attrs['ContactDataShape'] = contact_data_shape + payload = create_element('m:%s' % self.SERVICE_NAME, attrs=attrs) if parent_folders: parentfolderids = create_element('m:ParentFolderIds') set_xml_value(parentfolderids, parent_folders, version=self.protocol.version) diff --git a/exchangelib/services/send_item.py b/exchangelib/services/send_item.py index 0d5c8117..92ca8e36 100644 --- a/exchangelib/services/send_item.py +++ b/exchangelib/services/send_item.py @@ -15,10 +15,7 @@ def call(self, items, saved_item_folder): return self._chunked_get_elements(self.get_payload, items=items, saved_item_folder=saved_item_folder) def get_payload(self, items, saved_item_folder): - senditem = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=dict(SaveItemToFolder='true' if saved_item_folder else 'false'), - ) + senditem = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(SaveItemToFolder=bool(saved_item_folder))) item_ids = create_item_ids_element(items=items, version=self.account.version) senditem.append(item_ids) if saved_item_folder: diff --git a/exchangelib/services/update_item.py b/exchangelib/services/update_item.py index 794a4b5d..8e50ab4f 100644 --- a/exchangelib/services/update_item.py +++ b/exchangelib/services/update_item.py @@ -159,7 +159,7 @@ def get_payload(self, items, conflict_resolution, message_disposition, send_meet ConflictResolution=conflict_resolution, MessageDisposition=message_disposition, SendMeetingInvitationsOrCancellations=send_meeting_invitations_or_cancellations, - SuppressReadReceipts='true' if suppress_read_receipts else 'false', + SuppressReadReceipts=suppress_read_receipts, ) ) else: diff --git a/exchangelib/services/upload_items.py b/exchangelib/services/upload_items.py index 36fb89ec..1025ca13 100644 --- a/exchangelib/services/upload_items.py +++ b/exchangelib/services/upload_items.py @@ -29,9 +29,10 @@ def get_payload(self, items): uploaditems.append(itemselement) for parent_folder, (item_id, is_associated, data_str) in items: # TODO: The full spec also allows the "UpdateOrCreate" create action. - item = create_element('t:Item', attrs=dict(CreateAction='Update' if item_id else 'CreateNew')) + attrs = dict(CreateAction='Update' if item_id else 'CreateNew') if is_associated is not None: - item.set('IsAssociated', 'true' if is_associated else 'false') + attrs['IsAssociated'] = is_associated + item = create_element('t:Item', attrs=attrs) parentfolderid = ParentFolderId(parent_folder.id, parent_folder.changekey) set_xml_value(item, parentfolderid, version=self.account.version) if item_id: diff --git a/exchangelib/util.py b/exchangelib/util.py index db788f58..3bfcfe2a 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -276,6 +276,10 @@ def create_element(name, attrs=None, nsmap=None): # Try hard to keep attribute order, to ensure deterministic output. This simplifies testing. # Dicts in Python 3.6+ have stable ordering. for k, v in attrs.items(): + if isinstance(v, bool): + v = 'true' if v else 'false' + elif isinstance(v, int): + v = str(v) elem.set(k, v) return elem From 551010023b3ec6d2ca54c57df0a8e4ed8231b38b Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 8 Dec 2021 03:04:02 +0100 Subject: [PATCH 028/509] Convert all string formatting to f-strings --- exchangelib/__init__.py | 3 +- exchangelib/account.py | 15 +- exchangelib/attachments.py | 19 +-- exchangelib/autodiscover/cache.py | 4 +- exchangelib/autodiscover/discovery.py | 26 ++-- exchangelib/autodiscover/properties.py | 14 +- exchangelib/autodiscover/protocol.py | 9 +- exchangelib/configuration.py | 14 +- exchangelib/errors.py | 14 +- exchangelib/ewsdatetime.py | 16 +- exchangelib/extended_properties.py | 24 +-- exchangelib/fields.py | 142 +++++++++--------- exchangelib/folders/base.py | 54 +++---- exchangelib/folders/collections.py | 30 ++-- exchangelib/folders/queryset.py | 10 +- exchangelib/folders/roots.py | 14 +- exchangelib/indexed_properties.py | 2 +- exchangelib/items/base.py | 16 +- exchangelib/items/calendar_item.py | 4 +- exchangelib/items/item.py | 4 +- exchangelib/properties.py | 82 +++++----- exchangelib/protocol.py | 41 +++-- exchangelib/queryset.py | 41 ++--- exchangelib/recurrence.py | 41 +++-- exchangelib/restriction.py | 58 ++++--- exchangelib/services/archive_item.py | 4 +- exchangelib/services/common.py | 92 ++++++------ exchangelib/services/convert_id.py | 6 +- exchangelib/services/create_attachment.py | 4 +- exchangelib/services/create_folder.py | 4 +- exchangelib/services/create_item.py | 19 +-- .../services/create_user_configuration.py | 2 +- exchangelib/services/delete_attachment.py | 2 +- exchangelib/services/delete_folder.py | 2 +- exchangelib/services/delete_item.py | 20 +-- .../services/delete_user_configuration.py | 2 +- exchangelib/services/empty_folder.py | 2 +- exchangelib/services/expand_dl.py | 4 +- exchangelib/services/export_items.py | 4 +- exchangelib/services/find_folder.py | 8 +- exchangelib/services/find_item.py | 6 +- exchangelib/services/find_people.py | 12 +- exchangelib/services/get_attachment.py | 6 +- exchangelib/services/get_delegate.py | 4 +- exchangelib/services/get_events.py | 2 +- exchangelib/services/get_folder.py | 4 +- exchangelib/services/get_item.py | 4 +- exchangelib/services/get_mail_tips.py | 4 +- exchangelib/services/get_persona.py | 6 +- exchangelib/services/get_room_lists.py | 4 +- exchangelib/services/get_rooms.py | 4 +- .../services/get_searchable_mailboxes.py | 6 +- exchangelib/services/get_server_time_zones.py | 44 +++--- exchangelib/services/get_streaming_events.py | 14 +- exchangelib/services/get_user_availability.py | 10 +- .../services/get_user_configuration.py | 4 +- exchangelib/services/get_user_oof_settings.py | 6 +- exchangelib/services/mark_as_junk.py | 2 +- exchangelib/services/move_folder.py | 6 +- exchangelib/services/move_item.py | 6 +- exchangelib/services/resolve_names.py | 8 +- exchangelib/services/send_item.py | 4 +- exchangelib/services/send_notification.py | 2 +- exchangelib/services/set_user_oof_settings.py | 8 +- exchangelib/services/subscribe.py | 12 +- exchangelib/services/sync_folder_hierarchy.py | 14 +- exchangelib/services/sync_folder_items.py | 10 +- exchangelib/services/unsubscribe.py | 2 +- exchangelib/services/update_folder.py | 8 +- exchangelib/services/update_item.py | 34 ++--- .../services/update_user_configuration.py | 2 +- exchangelib/services/upload_items.py | 4 +- exchangelib/settings.py | 6 +- exchangelib/transport.py | 4 +- exchangelib/util.py | 42 +++--- exchangelib/version.py | 18 +-- exchangelib/winzone.py | 2 +- scripts/notifier.py | 12 +- scripts/optimize.py | 12 +- tests/common.py | 12 +- tests/test_account.py | 2 +- tests/test_autodiscover.py | 44 +++--- tests/test_configuration.py | 2 +- tests/test_ewsdatetime.py | 2 +- tests/test_extended_properties.py | 2 +- tests/test_field.py | 23 +-- tests/test_folder.py | 8 +- tests/test_items/test_basics.py | 14 +- tests/test_items/test_contacts.py | 12 +- tests/test_items/test_generic.py | 14 +- tests/test_items/test_helpers.py | 4 +- tests/test_items/test_messages.py | 10 +- tests/test_items/test_queryset.py | 6 +- tests/test_items/test_sync.py | 4 +- tests/test_properties.py | 16 +- tests/test_protocol.py | 2 +- tests/test_transport.py | 6 +- 97 files changed, 691 insertions(+), 722 deletions(-) diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index 83a62d91..49d2ae0a 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -40,7 +40,8 @@ # Set a default user agent, e.g. "exchangelib/3.1.1 (python-requests/2.22.0)" import requests.utils -BaseProtocol.USERAGENT = "%s/%s (%s)" % (__name__, __version__, requests.utils.default_user_agent()) +BaseProtocol.USERAGENT = f"{__name__}/{__version__} ({requests.utils.default_user_agent()})" + def close_connections(): from .autodiscover import close_connections as close_autodiscover_connections diff --git a/exchangelib/account.py b/exchangelib/account.py index 47953333..c3938b72 100644 --- a/exchangelib/account.py +++ b/exchangelib/account.py @@ -79,12 +79,12 @@ def __init__(self, primary_smtp_address, fullname=None, access_type=None, autodi :return: """ if '@' not in primary_smtp_address: - raise ValueError("primary_smtp_address %r is not an email address" % primary_smtp_address) + raise ValueError(f"primary_smtp_address {primary_smtp_address!r} is not an email address") self.fullname = fullname # Assume delegate access if individual credentials are provided. Else, assume service user with impersonation self.access_type = access_type or (DELEGATE if credentials else IMPERSONATION) if self.access_type not in ACCESS_TYPES: - raise ValueError("'access_type' %r must be one of %s" % (self.access_type, ACCESS_TYPES)) + raise ValueError(f"'access_type' {self.access_type!r} must be one of {ACCESS_TYPES}") try: self.locale = locale or getlocale()[0] or None # get_locale() might not be able to determine the locale except ValueError as e: @@ -92,12 +92,12 @@ def __init__(self, primary_smtp_address, fullname=None, access_type=None, autodi log.warning('Failed to get locale (%s)', e) self.locale = None if not isinstance(self.locale, (type(None), str)): - raise ValueError("Expected 'locale' to be a string, got %r" % self.locale) + raise ValueError(f"Expected 'locale' to be a string, got {self.locale!r}") if default_timezone: try: self.default_timezone = EWSTimeZone.from_timezone(default_timezone) except TypeError: - raise ValueError("Expected 'default_timezone' to be an EWSTimeZone, got %r" % default_timezone) + raise ValueError(f"Expected 'default_timezone' to be an EWSTimeZone, got {default_timezone!r}") else: try: self.default_timezone = EWSTimeZone.localzone() @@ -107,7 +107,7 @@ def __init__(self, primary_smtp_address, fullname=None, access_type=None, autodi log.warning('%s. Fallback to UTC', e.args[0]) self.default_timezone = UTC if not isinstance(config, (Configuration, type(None))): - raise ValueError("Expected 'config' to be a Configuration, got %r" % config) + raise ValueError(f"Expected 'config' to be a Configuration, got {config}") if autodiscover: if config: retry_policy, auth_type = config.retry_policy, config.auth_type @@ -631,7 +631,6 @@ def delegates(self): return delegates def __str__(self): - txt = '%s' % self.primary_smtp_address if self.fullname: - txt += ' (%s)' % self.fullname - return txt + return f'{self.primary_smtp_address} ({self.fullname})' + return self.primary_smtp_address diff --git a/exchangelib/attachments.py b/exchangelib/attachments.py index 00591c31..44d62070 100644 --- a/exchangelib/attachments.py +++ b/exchangelib/attachments.py @@ -48,7 +48,7 @@ def __init__(self, **kwargs): def clean(self, version=None): from .items import Item if self.parent_item is not None and not isinstance(self.parent_item, Item): - raise ValueError("'parent_item' value %r must be an Item instance" % self.parent_item) + raise ValueError(f"'parent_item' value {self.parent_item!r} must be an Item instance") if self.content_type is None and self.name is not None: self.content_type = mimetypes.guess_type(self.name)[0] or 'application/octet-stream' super().clean(version=version) @@ -58,7 +58,7 @@ def attach(self): if self.attachment_id: raise ValueError('This attachment has already been created') if not self.parent_item or not self.parent_item.account: - raise ValueError('Parent item %s must have an account' % self.parent_item) + raise ValueError(f'Parent item {self.parent_item} must have an account') item = CreateAttachment(account=self.parent_item.account).get(parent_item=self.parent_item, items=[self]) attachment_id = item.attachment_id if attachment_id.root_id != self.parent_item.id: @@ -76,7 +76,7 @@ def detach(self): if not self.attachment_id: raise ValueError('This attachment has not been created') if not self.parent_item or not self.parent_item.account: - raise ValueError('Parent item %s must have an account' % self.parent_item) + raise ValueError(f'Parent item {self.parent_item} must have an account') root_item_id = DeleteAttachment(account=self.parent_item.account).get(items=[self.attachment_id]) if root_item_id.id != self.parent_item.id: raise ValueError("'root_item_id.id' mismatch") @@ -93,9 +93,10 @@ def __hash__(self): return hash(tuple(getattr(self, f) for f in self._slots_keys if f != 'parent_item')) def __repr__(self): - return self.__class__.__name__ + '(%s)' % ', '.join( - '%s=%r' % (f.name, getattr(self, f.name)) for f in self.FIELDS if f.name not in ('_item', '_content') + args_str = ', '.join( + f'{f.name}={getattr(self, f.name)!r}' for f in self.FIELDS if f.name not in ('_item', '_content') ) + return f'{self.__class__.__name__}({args_str})' class FileAttachment(Attachment): @@ -124,7 +125,7 @@ def _init_fp(self): # Create a file-like object for the attachment content. We try hard to reduce memory consumption so we never # store the full attachment content in-memory. if not self.parent_item or not self.parent_item.account: - raise ValueError('%s must have an account' % self.__class__.__name__) + raise ValueError(f'{self.__class__.__name__} must have an account') self._fp = FileAttachmentIO(attachment=self) @property @@ -145,7 +146,7 @@ def content(self): def content(self, value): """Replace the attachment content.""" if not isinstance(value, bytes): - raise ValueError("'value' %r must be a bytes object" % value) + raise ValueError(f"'value' {value!r} must be a bytes object") self._content = value @classmethod @@ -192,7 +193,7 @@ def item(self): return self._item # We have an ID to the data but still haven't called GetAttachment to get the actual data. Do that now. if not self.parent_item or not self.parent_item.account: - raise ValueError('%s must have an account' % self.__class__.__name__) + raise ValueError(f'{self.__class__.__name__} must have an account') additional_fields = { FieldPath(field=f) for f in BaseFolder.allowed_item_fields(version=self.parent_item.account.version) } @@ -207,7 +208,7 @@ def item(self): def item(self, value): from .items import Item if not isinstance(value, Item): - raise ValueError("'value' %r must be an Item object" % value) + raise ValueError(f"'value' {value!r} must be an Item object") self._item = value @classmethod diff --git a/exchangelib/autodiscover/cache.py b/exchangelib/autodiscover/cache.py index 74fc0e28..e104ef8a 100644 --- a/exchangelib/autodiscover/cache.py +++ b/exchangelib/autodiscover/cache.py @@ -26,9 +26,7 @@ def shelve_filename(): except KeyError: # getuser() fails on some systems. Provide a sane default. See issue #448 user = 'exchangelib' - return 'exchangelib.{version}.cache.{user}.py{major}{minor}'.format( - version=version, user=user, major=major, minor=minor - ) + return f'exchangelib.{version}.cache.{user}.py{major}{minor}' AUTODISCOVER_PERSISTENT_STORAGE = os.path.join(tempfile.gettempdir(), shelve_filename()) diff --git a/exchangelib/autodiscover/discovery.py b/exchangelib/autodiscover/discovery.py index 76f29ccb..f40af5b1 100644 --- a/exchangelib/autodiscover/discovery.py +++ b/exchangelib/autodiscover/discovery.py @@ -194,14 +194,14 @@ def _quick(self, protocol): try: r = self._get_authenticated_response(protocol=protocol) except TransportError as e: - raise AutoDiscoverFailed('Response error: %s' % e) + raise AutoDiscoverFailed(f'Response error: {e}') if r.status_code == 200: try: ad = Autodiscover.from_bytes(bytes_content=r.content) return self._step_5(ad=ad) except ValueError as e: - raise AutoDiscoverFailed('Invalid response: %s' % e) - raise AutoDiscoverFailed('Invalid response code: %s' % r.status_code) + raise AutoDiscoverFailed(f'Invalid response: {e}') + raise AutoDiscoverFailed(f'Invalid response code: {r.status_code}') def _redirect_url_is_valid(self, url): """Three separate responses can be “Redirect responses”: @@ -252,7 +252,7 @@ def _get_unauthenticated_response(self, url, method='post'): if not self._is_valid_hostname(hostname): # 'requests' is really bad at reporting that a hostname cannot be resolved. Let's check this separately. # Don't retry on DNS errors. They will most likely be persistent. - raise TransportError('%r has no DNS entry' % hostname) + raise TransportError(f'{hostname!r} has no DNS entry') kwargs = dict( url=url, headers=DEFAULT_HEADERS.copy(), allow_redirects=False, timeout=AutodiscoverProtocol.TIMEOUT @@ -337,7 +337,7 @@ def _attempt_response(self, url): # This type of credentials *must* use the OAuth auth type auth_type = OAUTH2 elif self.credentials is None and auth_type in CREDENTIALS_REQUIRED: - raise ValueError('Auth type %r was detected but no credentials were provided' % auth_type) + raise ValueError(f'Auth type {auth_type!r} was detected but no credentials were provided') ad_protocol = AutodiscoverProtocol( config=Configuration( service_endpoint=url, @@ -394,7 +394,7 @@ def _get_srv_records(self, hostname): log.debug('Attempting to get SRV records for %s', hostname) records = [] try: - answers = self.resolver.resolve('%s.' % hostname, 'SRV') + answers = self.resolver.resolve(f'{hostname}.', 'SRV') except (dns.resolver.NoNameservers, dns.resolver.NoAnswer, dns.resolver.NXDOMAIN) as e: log.debug('DNS lookup failure: %s', e) return records @@ -419,7 +419,7 @@ def _step_1(self, hostname): :param hostname: :return: """ - url = 'https://%s/Autodiscover/Autodiscover.xml' % hostname + url = f'https://{hostname}/Autodiscover/Autodiscover.xml' log.info('Step 1: Trying autodiscover on %r with email %r', url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: @@ -435,7 +435,7 @@ def _step_2(self, hostname): :param hostname: :return: """ - url = 'https://autodiscover.%s/Autodiscover/Autodiscover.xml' % hostname + url = f'https://autodiscover.{hostname}/Autodiscover/Autodiscover.xml' log.info('Step 2: Trying autodiscover on %r with email %r', url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: @@ -458,7 +458,7 @@ def _step_3(self, hostname): :param hostname: :return: """ - url = 'http://autodiscover.%s/Autodiscover/Autodiscover.xml' % hostname + url = f'http://autodiscover.{hostname}/Autodiscover/Autodiscover.xml' log.info('Step 3: Trying autodiscover on %r with email %r', url, self.email) try: _, r = self._get_unauthenticated_response(url=url, method='get') @@ -491,7 +491,7 @@ def _step_4(self, hostname): :param hostname: :return: """ - dns_hostname = '_autodiscover._tcp.%s' % hostname + dns_hostname = f'_autodiscover._tcp.{hostname}' log.info('Step 4: Trying autodiscover on %r with email %r', dns_hostname, self.email) srv_records = self._get_srv_records(dns_hostname) try: @@ -500,7 +500,7 @@ def _step_4(self, hostname): srv_host = None if not srv_host: return self._step_6() - redirect_url = 'https://%s/Autodiscover/Autodiscover.xml' % srv_host + redirect_url = f'https://{srv_host}/Autodiscover/Autodiscover.xml' if self._redirect_url_is_valid(url=redirect_url): is_valid_response, ad = self._attempt_response(url=redirect_url) if is_valid_response: @@ -551,8 +551,8 @@ def _step_6(self): future requests. """ raise AutoDiscoverFailed( - 'All steps in the autodiscover protocol failed for email %r. If you think this is an error, consider doing ' - 'an official test at https://testconnectivity.microsoft.com' % self.email) + f'All steps in the autodiscover protocol failed for email {self.email}. If you think this is an error, ' + f'consider doing an official test at https://testconnectivity.microsoft.com') def _select_srv_host(srv_records): diff --git a/exchangelib/autodiscover/properties.py b/exchangelib/autodiscover/properties.py index 5162343a..9838595d 100644 --- a/exchangelib/autodiscover/properties.py +++ b/exchangelib/autodiscover/properties.py @@ -192,7 +192,7 @@ class Account(AutodiscoverBase): @classmethod def from_xml(cls, elem, account): kwargs = {} - public_folder_information = elem.find('{%s}PublicFolderInformation' % cls.NAMESPACE) + public_folder_information = elem.find(f'{{{cls.NAMESPACE}}}PublicFolderInformation') for f in cls.FIELDS: if f.name == 'public_folder_smtp_address': if public_folder_information is None: @@ -258,7 +258,7 @@ def ews_url(self): if Protocol.EXCH in protocols: return protocols[Protocol.EXCH].ews_url raise ValueError( - 'No EWS URL found in any of the available protocols: %s' % [str(p) for p in self.account.protocols] + f'No EWS URL found in any of the available protocols: {[str(p) for p in self.account.protocols]}' ) @@ -295,13 +295,13 @@ def from_bytes(cls, bytes_content): :return: """ if not is_xml(bytes_content) and not is_xml(bytes_content, expected_prefix=b' ('physical_addresses', 'Home', 'street') """ if not isinstance(field_path, str): - raise ValueError("Field path %r must be a string" % field_path) + raise ValueError(f"Field path {field_path!r} must be a string") search_parts = field_path.split('__') field = search_parts[0] try: @@ -105,22 +105,19 @@ def resolve_field_path(field_path, folder, strict=True): if isinstance(field, IndexedField): if strict and not label: raise ValueError( - "IndexedField path '%s' must specify label, e.g. '%s__%s'" - % (field_path, fieldname, field.value_cls.get_field_by_fieldname('label').default) + f"IndexedField path {field_path!r} must specify label, e.g. " + f"'{fieldname}__{field.value_cls.get_field_by_fieldname('label').default}'" ) valid_labels = field.value_cls.get_field_by_fieldname('label').supported_choices( version=folder.account.version ) if label and label not in valid_labels: - raise ValueError( - "Label '%s' on IndexedField path '%s' must be one of %s" - % (label, field_path, ', '.join(valid_labels)) - ) + raise ValueError(f"Label {label} on IndexedField path {field_path!r} must be one of {valid_labels}") if issubclass(field.value_cls, MultiFieldIndexedElement): if strict and not subfieldname: raise ValueError( - "IndexedField path '%s' must specify subfield, e.g. '%s__%s__%s'" - % (field_path, fieldname, label, field.value_cls.FIELDS[1].name) + f"IndexedField path {field_path!r} must specify subfield, e.g. " + f"'{fieldname}__{label}__{field.value_cls.FIELDS[1].name}'" ) if subfieldname: @@ -131,24 +128,19 @@ def resolve_field_path(field_path, folder, strict=True): version=folder.account.version )) raise ValueError( - "Subfield '%s' on IndexedField path '%s' must be one of %s" - % (subfieldname, field_path, fnames) + f"Subfield {subfieldname!r} on IndexedField path {field_path!r} must be one of {fnames}" ) else: if not issubclass(field.value_cls, SingleFieldIndexedElement): - raise ValueError("'field.value_cls' %r must be an SingleFieldIndexedElement instance" % field.value_cls) + raise ValueError(f"'field.value_cls' {field.value_cls!r} must be an SingleFieldIndexedElement instance") if subfieldname: raise ValueError( - "IndexedField path '%s' must not specify subfield, e.g. just '%s__%s'" - % (field_path, fieldname, label) + f"IndexedField path {field_path!r} must not specify subfield, e.g. just {fieldname}__{label}'" ) subfield = field.value_cls.value_field(version=folder.account.version) else: if label or subfieldname: - raise ValueError( - "Field path '%s' must not specify label or subfield, e.g. just '%s'" - % (field_path, fieldname) - ) + raise ValueError(f"Field path {field_path!r} must not specify label or subfield, e.g. just {fieldname!r}") return field, label, subfield @@ -161,11 +153,11 @@ class FieldPath: def __init__(self, field, label=None, subfield=None): # 'label' and 'subfield' are only used for IndexedField fields if not isinstance(field, (FieldURIField, ExtendedPropertyField)): - raise ValueError("'field' %r must be an FieldURIField, of ExtendedPropertyField instance" % field) + raise ValueError(f"'field' {field!r} must be an FieldURIField, of ExtendedPropertyField instance") if label and not isinstance(label, str): - raise ValueError("'label' %r must be a %s instance" % (label, str)) + raise ValueError(f"'label' {label!r} must be a {str} instance") if subfield and not isinstance(subfield, SubField): - raise ValueError("'subfield' %r must be a SubField instance" % subfield) + raise ValueError(f"'subfield' {subfield!r} must be a SubField instance") self.field = field self.label = label self.subfield = subfield @@ -197,7 +189,7 @@ def get_sort_value(self, item): def to_xml(self): if isinstance(self.field, IndexedField): if not self.label or not self.subfield: - raise ValueError("Field path for indexed field '%s' is missing label and/or subfield" % self.field.name) + raise ValueError(f"Field path for indexed field {self.field.name!r} is missing label and/or subfield") return self.subfield.field_uri_xml(field_uri=self.field.field_uri, label=self.label) return self.field.field_uri_xml() @@ -219,8 +211,8 @@ def path(self): if self.label: from .indexed_properties import SingleFieldIndexedElement if issubclass(self.field.value_cls, SingleFieldIndexedElement) or not self.subfield: - return '%s__%s' % (self.field.name, self.label) - return '%s__%s__%s' % (self.field.name, self.label, self.subfield.name) + return f'{self.field.name}__{self.label}' + return f'{self.field.name}__{self.label}__{self.subfield.name}' return self.field.name def __eq__(self, other): @@ -241,9 +233,9 @@ class FieldOrder: def __init__(self, field_path, reverse=False): if not isinstance(field_path, FieldPath): - raise ValueError("'field_path' %r must be a FieldPath instance" % field_path) + raise ValueError(f"'field_path' {field_path!r} must be a FieldPath instance") if not isinstance(reverse, bool): - raise ValueError("'reverse' %r must be a boolean" % reverse) + raise ValueError(f"'reverse' {reverse!r} must be a boolean") self.field_path = field_path self.reverse = reverse @@ -293,33 +285,34 @@ def __init__(self, name=None, is_required=False, is_required_after_save=False, i # The Exchange build when this field was introduced. When talking with versions prior to this version, # we will ignore this field. if supported_from is not None and not isinstance(supported_from, Build): - raise ValueError("'supported_from' %r must be a Build instance" % supported_from) + raise ValueError(f"'supported_from' {supported_from!r} must be a Build instance") self.supported_from = supported_from # The Exchange build when this field was deprecated. When talking with versions at or later than this version, # we will ignore this field. if deprecated_from is not None and not isinstance(deprecated_from, Build): - raise ValueError("'deprecated_from' %r must be a Build instance" % deprecated_from) + raise ValueError(f"'deprecated_from' {deprecated_from!r} must be a Build instance") self.deprecated_from = deprecated_from def clean(self, value, version=None): if version and not self.supports_version(version): - raise InvalidFieldForVersion("Field '%s' does not support EWS builds prior to %s (server has %s)" % ( - self.name, self.supported_from, version)) + raise InvalidFieldForVersion( + f"Field {self.name!r} does not support EWS builds prior to {self.supported_from} (server has {version})" + ) if value is None: if self.is_required and self.default is None: - raise ValueError("'%s' is a required field with no default" % self.name) + raise ValueError(f"{self.name!r} is a required field with no default") return self.default if self.is_list: if not is_iterable(value): - raise ValueError("Field '%s' value %r must be a list" % (self.name, value)) + raise ValueError(f"Field {self.name!r} value {value!r} must be a list") for v in value: if not isinstance(v, self.value_cls): - raise TypeError("Field '%s' value %r must be of type %s" % (self.name, v, self.value_cls)) + raise TypeError(f"Field {self.name!r} value {v!r} must be of type {self.value_cls}") if hasattr(v, 'clean'): v.clean(version=version) else: if not isinstance(value, self.value_cls): - raise TypeError("Field '%s' value %r must be of type %s" % (self.name, value, self.value_cls)) + raise TypeError(f"Field {self.name!r} value {value!r} must be of type {self.value_cls}") if hasattr(value, 'clean'): value.clean(version=version) return value @@ -335,7 +328,7 @@ def to_xml(self, value, version): def supports_version(self, version): # 'version' is a Version instance, for convenience by callers if not isinstance(version, Version): - raise ValueError("'version' %r must be a Version instance" % version) + raise ValueError(f"'version' {version!r} must be a Version instance") if self.supported_from and version.build < self.supported_from: return False if self.deprecated_from and version.build >= self.deprecated_from: @@ -350,8 +343,9 @@ def __hash__(self): pass def __repr__(self): - return self.__class__.__name__ + '(%s)' % ', '.join('%s=%r' % (f, getattr(self, f)) for f in ( + args_str = ', '.join(f'{f}={getattr(self, f)!r}' for f in ( 'name', 'value_cls', 'is_list', 'is_complex', 'default')) + return f'{self.__class__.__name__}({args_str})' class FieldURIField(Field): @@ -402,12 +396,12 @@ def field_uri_xml(self): def request_tag(self): if not self.field_uri_postfix: raise ValueError("'field_uri_postfix' value is missing") - return 't:%s' % self.field_uri_postfix + return f't:{self.field_uri_postfix}' def response_tag(self): if not self.field_uri_postfix: raise ValueError("'field_uri_postfix' value is missing") - return '{%s}%s' % (self.namespace, self.field_uri_postfix) + return f'{{{self.namespace}}}{self.field_uri_postfix}' def __hash__(self): return hash(self.field_uri) @@ -436,9 +430,9 @@ def __init__(self, *args, **kwargs): def _clean_single_value(self, v): if self.min is not None and v < self.min: raise ValueError( - "Value %r on field '%s' must be greater than %s" % (v, self.name, self.min)) + f"Value {v!r} on field {self.name!r} must be greater than {self.min}") if self.max is not None and v > self.max: - raise ValueError("Value %r on field '%s' must be less than %s" % (v, self.name, self.max)) + raise ValueError(f"Value {v!r} on field {self.name!r} must be less than {self.max}") def clean(self, value, version=None): value = super().clean(value, version=version) @@ -477,18 +471,16 @@ def clean(self, value, version=None): for i, v in enumerate(value): if isinstance(v, str): if v not in self.enum: - raise ValueError( - "List value '%s' on field '%s' must be one of %s" % (v, self.name, self.enum)) + raise ValueError(f"List value {v!r} on field {self.name!r} must be one of {self.enum}") value[i] = self.enum.index(v) + 1 if not value: - raise ValueError("Value '%s' on field '%s' must not be empty" % (value, self.name)) + raise ValueError(f"Value {value!r} on field {self.name!r} must not be empty") if len(value) > len(set(value)): - raise ValueError("List entries '%s' on field '%s' must be unique" % (value, self.name)) + raise ValueError(f"List entries {value!r} on field {self.name!r} must be unique") else: if isinstance(value, str): if value not in self.enum: - raise ValueError( - "Value '%s' on field '%s' must be one of %s" % (value, self.name, self.enum)) + raise ValueError(f"Value {value!r} on field {self.name!r} must be one of {self.enum}") value = self.enum.index(value) + 1 return super().clean(value, version=version) @@ -648,7 +640,7 @@ class DateTimeField(FieldURIField): def clean(self, value, version=None): if isinstance(value, datetime.datetime): if not value.tzinfo: - raise ValueError("Value '%s' on field '%s' must be timezone aware" % (value, self.name)) + raise ValueError(f"Value {value!r} on field {self.name!r} must be timezone aware") if type(value) is datetime.datetime: value = self.value_cls.from_datetime(value) return super().clean(value, version=version) @@ -753,7 +745,7 @@ class TextListField(TextField): def from_xml(self, elem, account): iter_elem = elem.find(self.response_tag()) if iter_elem is not None: - return get_xml_attrs(iter_elem, '{%s}String' % TNS) + return get_xml_attrs(iter_elem, f'{{{TNS}}}String') return self.default @@ -766,14 +758,14 @@ def from_xml(self, elem, account): reply = elem.find(self.response_tag()) if reply is None: return None - message = reply.find('{%s}%s' % (TNS, self.INNER_ELEMENT_NAME)) + message = reply.find(f'{{{TNS}}}{self.INNER_ELEMENT_NAME}') if message is None: return None return message.text def to_xml(self, value, version): field_elem = create_element(self.request_tag()) - message = create_element('t:%s' % self.INNER_ELEMENT_NAME) + message = create_element(f't:{self.INNER_ELEMENT_NAME}') message.text = value return set_xml_value(field_elem, message, version=version) @@ -796,10 +788,10 @@ def clean(self, value, version=None): if self.is_list: for v in value: if len(v) > self.max_length: - raise ValueError("'%s' value '%s' exceeds length %s" % (self.name, v, self.max_length)) + raise ValueError(f"{self.name!r} value {v!r} exceeds length {self.max_length}") else: if len(value) > self.max_length: - raise ValueError("'%s' value '%s' exceeds length %s" % (self.name, value, self.max_length)) + raise ValueError(f"{self.name!r} value {value!r} exceeds length {self.max_length}") return value @@ -826,7 +818,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def list_elem_tag(self): - return '{%s}%s' % (self.namespace, self.list_elem_name) + return f'{{{self.namespace}}}{self.list_elem_name}' def from_xml(self, elem, account): iter_elem = elem.find(self.response_tag()) @@ -859,7 +851,7 @@ def __init__(self, value, supported_from=None): def supports_version(self, version): # 'version' is a Version instance, for convenience by callers if not isinstance(version, Version): - raise ValueError("'version' %r must be a Version instance" % version) + raise ValueError(f"'version' {version!r} must be a Version instance") if not self.supported_from: return True return version.build >= self.supported_from @@ -882,14 +874,14 @@ def clean(self, value, version=None): if value in valid_choices_for_version: return value if value in valid_choices: - raise InvalidChoiceForVersion("Choice '%s' only supports EWS builds from %s to %s (server has %s)" % ( - self.name, self.supported_from or '*', self.deprecated_from or '*', version)) + raise InvalidChoiceForVersion( + f"Choice {self.name!r} only supports EWS builds from {self.supported_from or '*'} to " + f"{self.deprecated_from or '*'} (server has {version})" + ) else: if value in valid_choices: return value - raise ValueError("Invalid choice '%s' for field '%s'. Valid choices are: %s" % ( - value, self.name, ', '.join(valid_choices) - )) + raise ValueError(f"Invalid choice {value!r} for field {self.name!r}. Valid choices are: {valid_choices}") def supported_choices(self, version): return tuple(c.value for c in self.choices if c.supports_version(version)) @@ -1072,7 +1064,7 @@ def from_xml(self, elem, account): nested_elem = sub_elem.find(self.value_cls.response_tag()) if nested_elem is None: raise ValueError( - 'Expected XML element %r missing on field %r' % (self.value_cls.response_tag(), self.name) + f'Expected XML element {self.value_cls.response_tag()!r} missing on field {self.name!r}' ) return self.value_cls.from_xml(elem=nested_elem, account=account) return self.value_cls.from_xml(elem=sub_elem, account=account) @@ -1194,7 +1186,7 @@ def field_uri_xml(field_uri, label): def clean(self, value, version=None): value = super().clean(value, version=version) if self.is_required and not value: - raise ValueError('Value for subfield %r must be non-empty' % self.name) + raise ValueError(f'Value for subfield {self.name!r} must be non-empty') return value def __hash__(self): @@ -1234,13 +1226,13 @@ def to_xml(self, value, version): def field_uri_xml(self, field_uri, label): from .properties import IndexedFieldURI - return IndexedFieldURI(field_uri='%s:%s' % (field_uri, self.field_uri), field_index=label).to_xml(version=None) + return IndexedFieldURI(field_uri=f'{field_uri}:{self.field_uri}', field_index=label).to_xml(version=None) def request_tag(self): - return 't:%s' % self.field_uri + return f't:{self.field_uri}' def response_tag(self): - return '{%s}%s' % (self.namespace, self.field_uri) + return f'{{{self.namespace}}}{self.field_uri}' class IndexedField(EWSElementField): @@ -1252,14 +1244,14 @@ def __init__(self, *args, **kwargs): from .indexed_properties import IndexedElement value_cls = kwargs['value_cls'] if not issubclass(value_cls, IndexedElement): - raise ValueError("'value_cls' %r must be a subclass of IndexedElement" % value_cls) + raise ValueError(f"'value_cls' {value_cls!r} must be a subclass of IndexedElement") super().__init__(*args, **kwargs) def to_xml(self, value, version): - return set_xml_value(create_element('t:%s' % self.PARENT_ELEMENT_NAME), value, version) + return set_xml_value(create_element(f't:{self.PARENT_ELEMENT_NAME}'), value, version) def response_tag(self): - return '{%s}%s' % (self.namespace, self.PARENT_ELEMENT_NAME) + return f'{{{self.namespace}}}{self.PARENT_ELEMENT_NAME}' def __hash__(self): return hash(self.field_uri) @@ -1279,7 +1271,7 @@ def clean(self, value, version=None): if value is not None: default_labels = self.value_cls.LABEL_CHOICES if len(value) > len(default_labels): - raise ValueError('This field can handle at most %s values (value: %r)' % (len(default_labels), value)) + raise ValueError(f'This field can handle at most {len(default_labels)} values (value: {value})') tmp = [] for s, default_label in zip(value, default_labels): if not isinstance(s, str): @@ -1320,7 +1312,7 @@ def __init__(self, *args, **kwargs): def clean(self, value, version=None): if value is None: if self.is_required: - raise ValueError("'%s' is a required field" % self.name) + raise ValueError(f"{self.name!r} is a required field") return self.default if not isinstance(value, self.value_cls): # Allow keeping ExtendedProperty field values as their simple Python type, but run clean() anyway @@ -1388,7 +1380,7 @@ def to_xml(self, value, version): class UnknownEntriesField(CharListField): def list_elem_tag(self): - return '{%s}UnknownEntry' % self.namespace + return f'{{{self.namespace}}}UnknownEntry' class PermissionSetField(EWSElementField): @@ -1483,9 +1475,9 @@ def get_type(cls, value): first = next(iter(value)) except StopIteration: first = None - value_type = '%sArray' % cls.TYPES_MAP_REVERSED[type(first)] + value_type = f'{cls.TYPES_MAP_REVERSED[type(first)]}Array' if value_type not in cls.TYPES_MAP: - raise ValueError('%r is not a supported type' % value) + raise ValueError(f'{value!r} is not a supported type') return value_type return cls.TYPES_MAP_REVERSED[type(value)] @@ -1496,7 +1488,7 @@ def is_array_type(cls, value_type): def clean(self, value, version=None): if value is None: if self.is_required and self.default is None: - raise ValueError("'%s' is a required field with no default" % self.name) + raise ValueError(f"{self.name!r} is a required field with no default") return self.default return value @@ -1504,8 +1496,8 @@ def from_xml(self, elem, account): field_elem = elem.find(self.response_tag()) if field_elem is None: return self.default - value_type_str = get_xml_attr(field_elem, '{%s}Type' % TNS) - value = get_xml_attr(field_elem, '{%s}Value' % TNS) + value_type_str = get_xml_attr(field_elem, f'{{{TNS}}}Type') + value = get_xml_attr(field_elem, f'{{{TNS}}}Value') if value_type_str == 'Byte': try: # The value is an unsigned integer in the range 0 -> 255. Convert it to a single byte diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index e4ce3804..d7271810 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -110,7 +110,7 @@ def parts(self): @property def absolute(self): - return ''.join('/%s' % p.name for p in self.parts) + return ''.join(f'/{p.name}' for p in self.parts) def _walk(self): for c in self.children: @@ -164,29 +164,29 @@ def tree(self): ├── exchangelib issues └── Mom """ - tree = '%s\n' % self.name + tree = f'{self.name}\n' children = list(self.children) for i, c in enumerate(sorted(children, key=attrgetter('name')), start=1): nodes = c.tree().split('\n') for j, node in enumerate(nodes, start=1): if i != len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += '├── %s\n' % node + tree += f'├── {node}\n' elif i != len(children) and j > 1: # Not the last child, and not name of child - tree += '│ %s\n' % node + tree += f'│ {node}\n' elif i == len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += '└── %s\n' % node + tree += f'└── {node}\n' else: # Last child, and not name of child - tree += ' %s\n' % node + tree += f' {node}\n' return tree.strip() @classmethod def supports_version(cls, version): # 'version' is a Version instance, for convenience by callers if not isinstance(version, Version): - raise ValueError("'version' %r must be a Version instance" % version) + raise ValueError(f"'version' {version!r} must be a Version instance") if not cls.supported_from: return True return version.build >= cls.supported_from @@ -223,7 +223,7 @@ def item_model_from_tag(cls, tag): try: return cls.ITEM_MODEL_MAP[tag] except KeyError: - raise ValueError('Item type %s was unexpected in a %s folder' % (tag, cls.__name__)) + raise ValueError(f'Item type {tag} was unexpected in a {cls.__name__} folder') @classmethod def allowed_item_fields(cls, version): @@ -247,7 +247,7 @@ def validate_item_field(self, field, version): except InvalidField: continue else: - raise InvalidField("%r is not a valid field on %s" % (field, self.supported_item_models)) + raise InvalidField(f"{field!r} is not a valid field on { self.supported_item_models}") def normalize_fields(self, fields): # Takes a list of fieldnames, Field or FieldPath objects pointing to item fields. Turns them into FieldPath @@ -263,7 +263,7 @@ def normalize_fields(self, fields): field_path = FieldPath(field=field_path) fields[i] = field_path if not isinstance(field_path, FieldPath): - raise ValueError("Field %r must be a string or FieldPath instance" % field_path) + raise ValueError(f"Field {field_path!r} must be a string or FieldPath instance") if field_path.field.name == 'start': has_start = True elif field_path.field.name == 'end': @@ -289,7 +289,7 @@ def get_item_field_by_fieldname(cls, fieldname): return item_model.get_field_by_fieldname(fieldname) except InvalidField: pass - raise InvalidField("%r is not a valid field name on %s" % (fieldname, cls.supported_item_models)) + raise InvalidField(f"{fieldname!r} is not a valid field name on {cls.supported_item_models}") def get(self, *args, **kwargs): return FolderCollection(account=self.account, folders=[self]).get(*args, **kwargs) @@ -358,14 +358,14 @@ def move(self, to_folder): def delete(self, delete_type=HARD_DELETE): if delete_type not in DELETE_TYPE_CHOICES: - raise ValueError("'delete_type' %s must be one of %s" % (delete_type, DELETE_TYPE_CHOICES)) + raise ValueError(f"'delete_type' {delete_type!r} must be one of {DELETE_TYPE_CHOICES}") DeleteFolder(account=self.account).get(folders=[self], delete_type=delete_type) self.root.remove_folder(self) # Remove the updated folder from the cache self._id = None def empty(self, delete_type=HARD_DELETE, delete_sub_folders=False): if delete_type not in DELETE_TYPE_CHOICES: - raise ValueError("'delete_type' %s must be one of %s" % (delete_type, DELETE_TYPE_CHOICES)) + raise ValueError(f"'delete_type' {delete_type!r} must be one of {DELETE_TYPE_CHOICES}") EmptyFolder(account=self.account).get( folders=[self], delete_type=delete_type, delete_sub_folders=delete_sub_folders ) @@ -378,9 +378,9 @@ def wipe(self, page_size=None, _seen=None, _level=0): # distinguished folders from being deleted. Use with caution! _seen = _seen or set() if self.id in _seen: - raise RecursionError('We already tried to wipe %s' % self) + raise RecursionError(f'We already tried to wipe {self}') if _level > 16: - raise RecursionError('Max recursion level reached: %s' % _level) + raise RecursionError(f'Max recursion level reached: {_level}') _seen.add(self.id) log.warning('Wiping %s', self) has_distinguished_subfolders = any(f.is_distinguished for f in self.children) @@ -458,14 +458,14 @@ def resolve(cls, account, folder): # Resolve a single folder folders = list(FolderCollection(account=account, folders=[folder]).resolve()) if not folders: - raise ErrorFolderNotFound('Could not find folder %r' % folder) + raise ErrorFolderNotFound(f'Could not find folder {folder!r}') if len(folders) != 1: - raise ValueError('Expected result length 1, but got %s' % folders) + raise ValueError(f'Expected result length 1, but got {folders}') f = folders[0] if isinstance(f, Exception): raise f if f.__class__ != cls: - raise ValueError("Expected folder %r to be a %s instance" % (f, cls)) + raise ValueError(f"Expected folder {f!r} to be a {cls} instance") return f @require_id @@ -525,7 +525,7 @@ def subscribe_to_pull(self, event_types=SubscribeToPull.EVENT_TYPES, watermark=N event_types=event_types, watermark=watermark, timeout=timeout, )) if len(s_ids) != 1: - raise ValueError('Expected result length 1, but got %s' % s_ids) + raise ValueError(f'Expected result length 1, but got {s_ids}') s_id = s_ids[0] if isinstance(s_id, Exception): raise s_id @@ -546,7 +546,7 @@ def subscribe_to_push(self, callback_url, event_types=SubscribeToPush.EVENT_TYPE event_types=event_types, watermark=watermark, status_frequency=status_frequency, callback_url=callback_url, )) if len(s_ids) != 1: - raise ValueError('Expected result length 1, but got %s' % s_ids) + raise ValueError(f'Expected result length 1, but got {s_ids}') s_id = s_ids[0] if isinstance(s_id, Exception): raise s_id @@ -563,7 +563,7 @@ def subscribe_to_streaming(self, event_types=SubscribeToPush.EVENT_TYPES): event_types=event_types, )) if len(s_ids) != 1: - raise ValueError('Expected result length 1, but got %s' % s_ids) + raise ValueError(f'Expected result length 1, but got {s_ids}') s_id = s_ids[0] if isinstance(s_id, Exception): raise s_id @@ -702,7 +702,7 @@ def __floordiv__(self, other): try: return SingleFolderQuerySet(account=self.account, folder=self).depth(SHALLOW_FOLDERS).get(name=other) except DoesNotExist: - raise ErrorFolderNotFound("No subfolder with name '%s'" % other) + raise ErrorFolderNotFound(f"No subfolder with name {other!r}") def __truediv__(self, other): """Support the some_folder / 'child_folder' / 'child_of_child_folder' navigation syntax.""" @@ -715,7 +715,7 @@ def __truediv__(self, other): for c in self.children: if c.name == other: return c - raise ErrorFolderNotFound("No subfolder with name '%s'" % other) + raise ErrorFolderNotFound(f"No subfolder with name {other!r}") def __repr__(self): return self.__class__.__name__ + \ @@ -723,7 +723,7 @@ def __repr__(self): self.folder_class, self.id, self.changekey)) def __str__(self): - return '%s (%s)' % (self.__class__.__name__, self.name) + return f'{self.__class__.__name__} ({self.name})' class Folder(BaseFolder): @@ -788,7 +788,7 @@ def get_distinguished(cls, root): folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound('Could not find distinguished folder %r' % cls.DISTINGUISHED_FOLDER_ID) + raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}') @property def parent(self): @@ -805,7 +805,7 @@ def parent(self, value): self.parent_folder_id = None else: if not isinstance(value, BaseFolder): - raise ValueError("'value' %r must be a Folder instance" % value) + raise ValueError(f"'value' {value!r} must be a Folder instance") self.root = value.root self.parent_folder_id = ParentFolderId(id=value.id, changekey=value.changekey) @@ -813,7 +813,7 @@ def clean(self, version=None): from .roots import RootOfHierarchy super().clean(version=version) if self.root and not isinstance(self.root, RootOfHierarchy): - raise ValueError("'root' %r must be a RootOfHierarchy instance" % self.root) + raise ValueError(f"'root' {self.root!r} must be a RootOfHierarchy instance") @classmethod def from_xml_with_root(cls, elem, root): diff --git a/exchangelib/folders/collections.py b/exchangelib/folders/collections.py index 57eaf713..85cdfe0a 100644 --- a/exchangelib/folders/collections.py +++ b/exchangelib/folders/collections.py @@ -134,7 +134,7 @@ def validate_item_field(self, field, version): except InvalidField: continue else: - raise InvalidField("%r is not a valid field on %s" % (field, self.supported_item_models)) + raise InvalidField(f"{field!r} is not a valid field on {self.supported_item_models}") def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order_fields=None, calendar_view=None, page_size=None, max_items=None, offset=0): @@ -161,18 +161,18 @@ def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order log.debug('Query will never return results') return if shape not in SHAPE_CHOICES: - raise ValueError("'shape' %s must be one of %s" % (shape, SHAPE_CHOICES)) + raise ValueError(f"'shape' {shape!r} must be one of {SHAPE_CHOICES}") if depth is None: depth = self._get_default_item_traversal_depth() if depth not in ITEM_TRAVERSAL_CHOICES: - raise ValueError("'depth' %s must be one of %s" % (depth, ITEM_TRAVERSAL_CHOICES)) + raise ValueError(f"'depth' {depth!r} must be one of {ITEM_TRAVERSAL_CHOICES}") if additional_fields: for f in additional_fields: self.validate_item_field(field=f, version=self.account.version) if f.field.is_complex: - raise ValueError("find_items() does not support field '%s'. Use fetch() instead" % f.field.name) + raise ValueError(f"find_items() does not support field {f.field.name!r}. Use fetch() instead") if calendar_view is not None and not isinstance(calendar_view, CalendarView): - raise ValueError("'calendar_view' %s must be a CalendarView instance" % calendar_view) + raise ValueError(f"'calendar_view' {calendar_view!r} must be a CalendarView instance") # Build up any restrictions if q.is_empty(): @@ -237,16 +237,16 @@ def find_people(self, q, shape=ID_ONLY, depth=None, additional_fields=None, orde log.debug('Query will never return results') return if shape not in SHAPE_CHOICES: - raise ValueError("'shape' %s must be one of %s" % (shape, SHAPE_CHOICES)) + raise ValueError(f"'shape' {shape!r} must be one of {SHAPE_CHOICES}") if depth is None: depth = self._get_default_item_traversal_depth() if depth not in ITEM_TRAVERSAL_CHOICES: - raise ValueError("'depth' %s must be one of %s" % (depth, ITEM_TRAVERSAL_CHOICES)) + raise ValueError(f"'depth' {depth!r} must be one of {ITEM_TRAVERSAL_CHOICES}") if additional_fields: for f in additional_fields: Persona.validate_field(field=f, version=self.account.version) if f.field.is_complex: - raise ValueError("find_people() does not support field '%s'" % f.field.name) + raise ValueError(f"find_people() does not support field {f.field.name!r}") # Build up any restrictions if q.is_empty(): @@ -286,11 +286,11 @@ def _get_target_cls(self): for f in self.folders: if isinstance(f, RootOfHierarchy): if has_non_roots: - raise ValueError('Cannot call GetFolder on a mix of folder types: {}'.format(self.folders)) + raise ValueError(f'Cannot call GetFolder on a mix of folder types: {self.folders}') has_roots = True else: if has_roots: - raise ValueError('Cannot call GetFolder on a mix of folder types: {}'.format(self.folders)) + raise ValueError(f'Cannot call GetFolder on a mix of folder types: {self.folders}') has_non_roots = True return RootOfHierarchy if has_roots else Folder @@ -299,8 +299,8 @@ def _get_default_traversal_depth(self, traversal_attr): if len(unique_depths) == 1: return unique_depths.pop() raise ValueError( - 'Folders in this collection do not have a common %s value. You need to define an explicit traversal depth' - 'with QuerySet.depth() (values: %s)' % (traversal_attr, unique_depths) + f'Folders in this collection do not have a common {traversal_attr} value. You need to define an explicit ' + f'traversal depth with QuerySet.depth() (values: {unique_depths})' ) def _get_default_item_traversal_depth(self): @@ -345,18 +345,18 @@ def find_folders(self, q=None, shape=ID_ONLY, depth=None, additional_fields=None else: restriction = Restriction(q, folders=self.folders, applies_to=Restriction.FOLDERS) if shape not in SHAPE_CHOICES: - raise ValueError("'shape' %s must be one of %s" % (shape, SHAPE_CHOICES)) + raise ValueError(f"'shape' {shape!r} must be one of {SHAPE_CHOICES}") if depth is None: depth = self._get_default_folder_traversal_depth() if depth not in FOLDER_TRAVERSAL_CHOICES: - raise ValueError("'depth' %s must be one of %s" % (depth, FOLDER_TRAVERSAL_CHOICES)) + raise ValueError(f"'depth' {depth!r} must be one of {FOLDER_TRAVERSAL_CHOICES}") if additional_fields is None: # Default to all non-complex properties. Subfolders will always be of class Folder additional_fields = self.get_folder_fields(target_cls=Folder, is_complex=False) else: for f in additional_fields: if f.field.is_complex: - raise ValueError("find_folders() does not support field '%s'. Use get_folders()." % f.field.name) + raise ValueError(f"find_folders() does not support field {f.field.name!r}. Use get_folders().") # Add required fields additional_fields.update( diff --git a/exchangelib/folders/queryset.py b/exchangelib/folders/queryset.py index b7f5a0fa..51cc9053 100644 --- a/exchangelib/folders/queryset.py +++ b/exchangelib/folders/queryset.py @@ -21,7 +21,7 @@ class FolderQuerySet: def __init__(self, folder_collection): from .collections import FolderCollection if not isinstance(folder_collection, FolderCollection): - raise ValueError("'folder_collection' %r must be a FolderCollection instance" % folder_collection) + raise ValueError(f"'folder_collection' {folder_collection!r} must be a FolderCollection instance") self.folder_collection = folder_collection self.q = Q() # Default to no restrictions self.only_fields = None @@ -51,7 +51,7 @@ def only(self, *args): only_fields.append(field_path) break else: - raise InvalidField("Unknown field %r on folders %s" % (arg, self.folder_collection.folders)) + raise InvalidField(f"Unknown field {arg!r} on folders {self.folder_collection.folders}") new_qs = self._copy_self() new_qs.only_fields = only_fields return new_qs @@ -81,7 +81,7 @@ def get(self, *args, **kwargs): if not folders: raise DoesNotExist('Could not find a child folder matching the query') if len(folders) != 1: - raise MultipleObjectsReturned('Expected result length 1, but got %s' % folders) + raise MultipleObjectsReturned(f'Expected result length 1, but got {folders}') f = folders[0] if isinstance(f, Exception): raise f @@ -142,7 +142,7 @@ def _query(self): continue # Add the extra field values to the folders we fetched with find_folders() if f.__class__ != complex_f.__class__: - raise ValueError('Type mismatch: %s vs %s' % (f, complex_f)) + raise ValueError(f'Type mismatch: {f} vs {complex_f}') for complex_field in complex_fields: field_name = complex_field.field.name setattr(f, field_name, getattr(complex_f, field_name)) @@ -165,7 +165,7 @@ def resolve(self): if not folders: raise DoesNotExist('Could not find a folder matching the query') if len(folders) != 1: - raise MultipleObjectsReturned('Expected result length 1, but got %s' % folders) + raise MultipleObjectsReturned(f'Expected result length 1, but got {folders}') f = folders[0] if isinstance(f, Exception): raise f diff --git a/exchangelib/folders/roots.py b/exchangelib/folders/roots.py index d4a470bf..6a7c0d29 100644 --- a/exchangelib/folders/roots.py +++ b/exchangelib/folders/roots.py @@ -104,21 +104,21 @@ def get_distinguished(cls, account): :param account: """ if not cls.DISTINGUISHED_FOLDER_ID: - raise ValueError('Class %s must have a DISTINGUISHED_FOLDER_ID value' % cls) + raise ValueError(f'Class {cls} must have a DISTINGUISHED_FOLDER_ID value') try: return cls.resolve( account=account, folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound('Could not find distinguished folder %s' % cls.DISTINGUISHED_FOLDER_ID) + raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}') def get_default_folder(self, folder_cls): """Return the distinguished folder instance of type folder_cls belonging to this account. If no distinguished folder was found, try as best we can to return the default folder of type 'folder_cls' """ if not folder_cls.DISTINGUISHED_FOLDER_ID: - raise ValueError("'folder_cls' %s must have a DISTINGUISHED_FOLDER_ID value" % folder_cls) + raise ValueError(f"'folder_cls' {folder_cls} must have a DISTINGUISHED_FOLDER_ID value") # Use cached distinguished folder instance, but only if cache has already been prepped. This is an optimization # for accessing e.g. 'account.contacts' without fetching all folders of the account. if self._subfolders is not None: @@ -139,7 +139,7 @@ def get_default_folder(self, folder_cls): except MISSING_FOLDER_ERRORS: # The Exchange server does not return a distinguished folder of this type pass - raise ErrorFolderNotFound('No usable default %s folders' % folder_cls) + raise ErrorFolderNotFound(f'No usable default {folder_cls} folders') @property def _folders_map(self): @@ -263,15 +263,13 @@ def _get_candidate(self, folder_cls, folder_coll): candidates = [f for f in same_type if f.name.lower() in folder_cls.localized_names(self.account.locale)] if candidates: if len(candidates) > 1: - raise ValueError( - 'Multiple possible default %s folders: %s' % (folder_cls, [f.name for f in candidates]) - ) + raise ValueError(f'Multiple possible default {folder_cls} folders: {[f.name for f in candidates]}') if candidates[0].is_distinguished: log.debug('Found cached distinguished %s folder', folder_cls) else: log.debug('Found cached %s folder with localized name', folder_cls) return candidates[0] - raise ErrorFolderNotFound('No usable default %s folders' % folder_cls) + raise ErrorFolderNotFound(f'No usable default {folder_cls} folders') class PublicFoldersRoot(RootOfHierarchy): diff --git a/exchangelib/indexed_properties.py b/exchangelib/indexed_properties.py index e94d211a..e98c7211 100644 --- a/exchangelib/indexed_properties.py +++ b/exchangelib/indexed_properties.py @@ -19,7 +19,7 @@ class SingleFieldIndexedElement(IndexedElement, metaclass=EWSMeta): def value_field(cls, version=None): fields = cls.supported_fields(version=version) if len(fields) != 1: - raise ValueError('This class must have only one field (found %s)' % (fields,)) + raise ValueError(f'This class must have only one field (found {fields})') return fields[0] diff --git a/exchangelib/items/base.py b/exchangelib/items/base.py index 6dbf2f92..959e9c5e 100644 --- a/exchangelib/items/base.py +++ b/exchangelib/items/base.py @@ -79,15 +79,15 @@ def register(cls, attr_name, attr_cls): :return: """ if not cls.INSERT_AFTER_FIELD: - raise ValueError('Class %s is missing INSERT_AFTER_FIELD value' % cls) + raise ValueError(f'Class {cls} is missing INSERT_AFTER_FIELD value') try: cls.get_field_by_fieldname(attr_name) except InvalidField: pass else: - raise ValueError("'%s' is already registered" % attr_name) + raise ValueError(f"{attr_name!r} is already registered") if not issubclass(attr_cls, ExtendedProperty): - raise ValueError("%r must be a subclass of ExtendedProperty" % attr_cls) + raise ValueError(f"{attr_cls!r} must be a subclass of ExtendedProperty") # Check if class attributes are properly defined attr_cls.validate_cls() # ExtendedProperty is not a real field, but a placeholder in the fields list. See @@ -110,9 +110,9 @@ def deregister(cls, attr_name): try: field = cls.get_field_by_fieldname(attr_name) except InvalidField: - raise ValueError("'%s' is not registered" % attr_name) + raise ValueError(f"{attr_name!r} is not registered") if not isinstance(field, ExtendedPropertyField): - raise ValueError("'%s' is not registered as an ExtendedProperty" % attr_name) + raise ValueError(f"{attr_name} is not registered as an ExtendedProperty") cls.remove_field(field) @@ -136,11 +136,11 @@ def __init__(self, **kwargs): from ..account import Account self.account = kwargs.pop('account', None) if self.account is not None and not isinstance(self.account, Account): - raise ValueError("'account' %r must be an Account instance" % self.account) + raise ValueError(f"'account' {self.account!r} must be an Account instance") self.folder = kwargs.pop('folder', None) if self.folder is not None: if not isinstance(self.folder, BaseFolder): - raise ValueError("'folder' %r must be a Folder instance" % self.folder) + raise ValueError(f"'folder' {self.folder!r} must be a Folder instance") if self.folder.account is not None: if self.account is not None: # Make sure the account from kwargs matches the folder account @@ -179,7 +179,7 @@ def __init__(self, **kwargs): from ..account import Account self.account = kwargs.pop('account', None) if self.account is not None and not isinstance(self.account, Account): - raise ValueError("'account' %r must be an Account instance" % self.account) + raise ValueError(f"'account' {self.account!r} must be an Account instance") super().__init__(**kwargs) @require_account diff --git a/exchangelib/items/calendar_item.py b/exchangelib/items/calendar_item.py index 8a24eade..f2347315 100644 --- a/exchangelib/items/calendar_item.py +++ b/exchangelib/items/calendar_item.py @@ -181,7 +181,7 @@ def clean_timezone_fields(self, version): def clean(self, version=None): super().clean(version=version) if self.start and self.end and self.end < self.start: - raise ValueError("'end' must be greater than 'start' (%s -> %s)" % (self.start, self.end)) + raise ValueError(f"'end' must be greater than 'start' ({self.start} -> {self.end})") if version: self.clean_timezone_fields(version=version) @@ -216,7 +216,7 @@ def from_xml(cls, elem, account): # the inverse of what we do in .to_xml(). Convert to the local timezone before getting the date. if field_name == 'end': val -= datetime.timedelta(days=1) - tz = getattr(item, '_%s_timezone' % field_name) + tz = getattr(item, f'_{field_name}_timezone') setattr(item, field_name, val.astimezone(tz).date()) return item diff --git a/exchangelib/items/item.py b/exchangelib/items/item.py index 73d6cdcd..d3e737be 100644 --- a/exchangelib/items/item.py +++ b/exchangelib/items/item.py @@ -80,7 +80,7 @@ def __init__(self, **kwargs): for a in self.attachments: if a.parent_item: if a.parent_item is not self: - raise ValueError("'parent_item' of attachment %s must point to this item" % a) + raise ValueError(f"'parent_item' of attachment {a} must point to this item") else: a.parent_item = self self.attach(self.attachments) @@ -170,7 +170,7 @@ def _update_fieldnames(self): @require_account def _update(self, update_fieldnames, message_disposition, conflict_resolution, send_meeting_invitations): if not self.changekey: - raise ValueError('%s must have changekey' % self.__class__.__name__) + raise ValueError(f'{self.__class__.__name__} must have changekey') if not update_fieldnames: # The fields to update was not specified explicitly. Update all fields where update is possible update_fieldnames = self._update_fieldnames() diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 9e084e64..020818f6 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -29,7 +29,7 @@ def __init__(self, *fields): for f in fields: # Check for duplicate field names if f.name in self._dict: - raise ValueError('Field %r is a duplicate' % f) + raise ValueError(f'Field {f!r} is a duplicate') self._dict[f.name] = f def __getitem__(self, idx_or_slice): @@ -63,11 +63,11 @@ def index_by_name(self, field_name): for i, f in enumerate(self): if f.name == field_name: return i - raise ValueError('Unknown field name %r' % field_name) + raise ValueError(f'Unknown field name {field_name!r}') def insert(self, index, field): if field.name in self._dict: - raise ValueError('Field %r is a duplicate' % field) + raise ValueError(f'Field {field!r} is a duplicate') super().insert(index, field) self._dict[field.name] = field @@ -145,7 +145,7 @@ class GlobalObjectId(ExtendedProperty): # https://stackoverflow.com/questions/33757805 def __new__(cls, uid): - payload = binascii.hexlify(bytearray('vCal-Uid\x01\x00\x00\x00{}\x00'.format(uid).encode('ascii'))) + payload = binascii.hexlify(bytearray(f'vCal-Uid\x01\x00\x00\x00{uid}\x00'.encode('ascii'))) length = binascii.hexlify(bytearray(struct.pack('= end: - raise ValueError("'start' must be less than 'end' (%s -> %s)" % (start, end)) + raise ValueError(f"'start' must be less than 'end' ({start} -> {end})") if not isinstance(merged_free_busy_interval, int): - raise ValueError("'merged_free_busy_interval' value %r must be an 'int'" % merged_free_busy_interval) + raise ValueError(f"'merged_free_busy_interval' value {merged_free_busy_interval!r} must be an 'int'") if requested_view not in FreeBusyViewOptions.REQUESTED_VIEWS: raise ValueError( - "'requested_view' value %r must be one of %s" % (requested_view, FreeBusyViewOptions.REQUESTED_VIEWS)) + f"'requested_view' value {requested_view!r} must be one of {FreeBusyViewOptions.REQUESTED_VIEWS}" + ) _, _, periods, transitions, transitions_groups = list(self.get_timezones( timezones=[start.tzinfo], return_full_timezone_data=True @@ -616,12 +615,12 @@ def __str__(self): else: fullname, api_version, build = '[unknown]', '[unknown]', '[unknown]' - return '''\ -EWS url: %s -Product name: %s -EWS API version: %s -Build number: %s -EWS auth: %s''' % (self.service_endpoint, fullname, api_version, build, self.auth_type) + return f'''\ +EWS url: {self.service_endpoint} +Product name: {fullname} +EWS API version: {api_version} +Build number: {build} +EWS auth: {self.auth_type}''' class NoVerifyHTTPAdapter(requests.adapters.HTTPAdapter): @@ -686,14 +685,14 @@ def raise_response_errors(self, response): raise UnauthorizedError('The referenced account is currently locked out') if response.status_code == 401 and self.fail_fast: # This is a login failure - raise UnauthorizedError('Invalid credentials for %s' % response.url) + raise UnauthorizedError(f'Invalid credentials for {response.url}') if 'TimeoutException' in response.headers: # A header set by us on CONNECTION_ERRORS raise response.headers['TimeoutException'] # This could be anything. Let higher layers handle this raise MalformedResponseError( - 'Unknown failure in response. Code: %s headers: %s content: %s' - % (response.status_code, response.headers, response.text) + f'Unknown failure in response. Code: {response.status_code} headers: {response.headers} ' + f'content: {response.text}' ) diff --git a/exchangelib/queryset.py b/exchangelib/queryset.py index 3dd69c66..edd462ef 100644 --- a/exchangelib/queryset.py +++ b/exchangelib/queryset.py @@ -63,10 +63,10 @@ class QuerySet(SearchableMixIn): def __init__(self, folder_collection, request_type=ITEM): from .folders import FolderCollection if not isinstance(folder_collection, FolderCollection): - raise ValueError("folder_collection value '%s' must be a FolderCollection instance" % folder_collection) + raise ValueError(f"folder_collection value {folder_collection!r} must be a FolderCollection instance") self.folder_collection = folder_collection # A FolderCollection instance if request_type not in self.REQUEST_TYPES: - raise ValueError("'request_type' %r must be one of %s" % (request_type, self.REQUEST_TYPES)) + raise ValueError(f"'request_type' {request_type} must be one of {self.REQUEST_TYPES}") self.request_type = request_type self.q = Q() # Default to no restrictions self.only_fields = None @@ -89,13 +89,13 @@ def _copy_self(self): # new_qs = qs.exclude(bar='baz') # This should work, and should fetch from the server # if not isinstance(self.q, Q): - raise ValueError("self.q value '%s' must be None or a Q instance" % self.q) + raise ValueError(f"self.q value {self.q!r} must be None or a Q instance") if not isinstance(self.only_fields, (type(None), tuple)): - raise ValueError("self.only_fields value '%s' must be None or a tuple" % self.only_fields) + raise ValueError(f"self.only_fields value {self.only_fields!r} must be None or a tuple") if not isinstance(self.order_fields, (type(None), tuple)): - raise ValueError("self.order_fields value '%s' must be None or a tuple" % self.order_fields) + raise ValueError(f"self.order_fields value {self.order_fields!r} must be None or a tuple") if self.return_format not in self.RETURN_TYPES: - raise ValueError("self.return_value '%s' must be one of %s" % (self.return_format, self.RETURN_TYPES)) + raise ValueError(f"self.return_value {self.return_format!r} must be one of {self.RETURN_TYPES}") # Only mutable objects need to be deepcopied. Folder should be the same object new_qs = self.__class__(self.folder_collection, request_type=self.request_type) new_qs.q = deepcopy(self.q) @@ -118,7 +118,7 @@ def _get_field_path(self, field_path): return FieldPath.from_string(field_path=field_path, folder=folder) except InvalidField: pass - raise InvalidField("Unknown field path %r on folders %s" % (field_path, self.folder_collection.folders)) + raise InvalidField(f"Unknown field path {field_path!r} on folders {self.folder_collection.folders}") def _get_field_order(self, field_path): from .items import Persona @@ -132,7 +132,7 @@ def _get_field_order(self, field_path): return FieldOrder.from_string(field_path=field_path, folder=folder) except InvalidField: pass - raise InvalidField("Unknown field path %r on folders %s" % (field_path, self.folder_collection.folders)) + raise InvalidField(f"Unknown field path {field_path!r} on folders {self.folder_collection.folders}") @property def _id_field(self): @@ -144,7 +144,7 @@ def _changekey_field(self): def _additional_fields(self): if not isinstance(self.only_fields, tuple): - raise ValueError("'only_fields' value %r must be a tuple" % self.only_fields) + raise ValueError(f"'only_fields' value {self.only_fields!r} must be a tuple") # Remove ItemId and ChangeKey. We get them unconditionally additional_fields = {f for f in self.only_fields if not f.field.is_attribute} if self.request_type != self.ITEM: @@ -252,10 +252,10 @@ def _query(self): except TypeError as e: if 'unorderable types' not in e.args[0]: raise - raise ValueError(( - "Cannot sort on field '%s'. The field has no default value defined, and there are either items " - "with None values for this field, or the query contains exception instances (original error: %s).") - % (f.field_path, e)) + raise ValueError( + f"Cannot sort on field {f.field_path!r}. The field has no default value defined, and there are " + f"either items with None values for this field, or the query contains exception instances " + f"(original error: {e}).") if not extra_order_fields: return items @@ -439,7 +439,7 @@ def only(self, *args): try: only_fields = tuple(self._get_field_path(arg) for arg in args) except ValueError as e: - raise ValueError("%s in only()" % e.args[0]) + raise ValueError(f"{e.args[0]} in only()") new_qs = self._copy_self() new_qs.only_fields = only_fields return new_qs @@ -453,7 +453,7 @@ def order_by(self, *args): try: order_fields = tuple(self._get_field_order(arg) for arg in args) except ValueError as e: - raise ValueError("%s in order_by()" % e.args[0]) + raise ValueError(f"{e.args[0]} in order_by()") new_qs = self._copy_self() new_qs.order_fields = order_fields return new_qs @@ -471,7 +471,7 @@ def values(self, *args): try: only_fields = tuple(self._get_field_path(arg) for arg in args) except ValueError as e: - raise ValueError("%s in values()" % e.args[0]) + raise ValueError(f"{e.args[0]} in values()") new_qs = self._copy_self() new_qs.only_fields = only_fields new_qs.return_format = self.VALUES @@ -483,13 +483,13 @@ def values_list(self, *args, **kwargs): """ flat = kwargs.pop('flat', False) if kwargs: - raise AttributeError('Unknown kwargs: %s' % kwargs) + raise AttributeError(f'Unknown kwargs: {kwargs}') if flat and len(args) != 1: raise ValueError('flat=True requires exactly one field name') try: only_fields = tuple(self._get_field_path(arg) for arg in args) except ValueError as e: - raise ValueError("%s in values_list()" % e.args[0]) + raise ValueError(f"{e.args[0]} in values_list()") new_qs = self._copy_self() new_qs.only_fields = only_fields new_qs.return_format = self.FLAT if flat else self.VALUES_LIST @@ -652,8 +652,9 @@ def mark_as_junk(self, page_size=1000, **mark_as_junk_kwargs): ) def __str__(self): - fmt_args = [('q', str(self.q)), ('folders', '[%s]' % ', '.join(str(f) for f in self.folder_collection.folders))] - return self.__class__.__name__ + '(%s)' % ', '.join('%s=%s' % (k, v) for k, v in fmt_args) + fmt_args = [('q', str(self.q)), ('folders', f"[{', '.join(str(f) for f in self.folder_collection.folders)}]")] + args_str = ', '.join(f'{k}={v}' for k, v in fmt_args) + return f'{self.__class__.__name__}({args_str})' def _get_value_or_default(field, item): diff --git a/exchangelib/recurrence.py b/exchangelib/recurrence.py index 2308b27b..4a5a4f17 100644 --- a/exchangelib/recurrence.py +++ b/exchangelib/recurrence.py @@ -41,7 +41,7 @@ class AbsoluteYearlyPattern(Pattern): month = EnumField(field_uri='Month', enum=MONTHS, is_required=True) def __str__(self): - return 'Occurs on day %s of %s' % (self.day_of_month, _month_to_str(self.month)) + return f'Occurs on day {self.day_of_month} of {_month_to_str(self.month)}' class RelativeYearlyPattern(Pattern): @@ -62,11 +62,8 @@ class RelativeYearlyPattern(Pattern): month = EnumField(field_uri='Month', enum=MONTHS, is_required=True) def __str__(self): - return 'Occurs on weekday %s in the %s week of %s' % ( - _weekday_to_str(self.weekday), - _week_number_to_str(self.week_number), - _month_to_str(self.month) - ) + return f'Occurs on weekday {_weekday_to_str(self.weekday)} in the {_week_number_to_str(self.week_number)} ' \ + f'week of {_month_to_str(self.month)}' class AbsoluteMonthlyPattern(Pattern): @@ -83,7 +80,7 @@ class AbsoluteMonthlyPattern(Pattern): day_of_month = IntegerField(field_uri='DayOfMonth', min=1, max=31, is_required=True) def __str__(self): - return 'Occurs on day %s of every %s month(s)' % (self.day_of_month, self.interval) + return f'Occurs on day {self.day_of_month} of every {self.interval} month(s)' class RelativeMonthlyPattern(Pattern): @@ -104,11 +101,8 @@ class RelativeMonthlyPattern(Pattern): week_number = EnumField(field_uri='DayOfWeekIndex', enum=WEEK_NUMBERS, is_required=True) def __str__(self): - return 'Occurs on weekday %s in the %s week of every %s month(s)' % ( - _weekday_to_str(self.weekday), - _week_number_to_str(self.week_number), - self.interval - ) + return f'Occurs on weekday {_weekday_to_str(self.weekday)} in the {_week_number_to_str(self.week_number)} ' \ + f'week of every {self.interval} month(s)' class WeeklyPattern(Pattern): @@ -130,9 +124,8 @@ def __str__(self): weekdays = [_weekday_to_str(self.weekdays)] else: weekdays = [_weekday_to_str(i) for i in self.weekdays] - return 'Occurs on weekdays %s of every %s week(s) where the first day of the week is %s' % ( - ', '.join(weekdays), self.interval, _weekday_to_str(self.first_day_of_week) - ) + return f'Occurs on weekdays {", ".join(weekdays)} of every {self.interval} week(s) where the first day of ' \ + f'the week is {_weekday_to_str(self.first_day_of_week)}' class DailyPattern(Pattern): @@ -144,7 +137,7 @@ class DailyPattern(Pattern): interval = IntegerField(field_uri='Interval', min=1, max=999, is_required=True) def __str__(self): - return 'Occurs every %s day(s)' % self.interval + return f'Occurs every {self.interval} day(s)' class YearlyRegeneration(Regeneration): @@ -156,7 +149,7 @@ class YearlyRegeneration(Regeneration): interval = IntegerField(field_uri='Interval', min=1, is_required=True) def __str__(self): - return 'Regenerates every %s year(s)' % self.interval + return f'Regenerates every {self.interval} year(s)' class MonthlyRegeneration(Regeneration): @@ -168,7 +161,7 @@ class MonthlyRegeneration(Regeneration): interval = IntegerField(field_uri='Interval', min=1, is_required=True) def __str__(self): - return 'Regenerates every %s month(s)' % self.interval + return f'Regenerates every {self.interval} month(s)' class WeeklyRegeneration(Regeneration): @@ -180,7 +173,7 @@ class WeeklyRegeneration(Regeneration): interval = IntegerField(field_uri='Interval', min=1, is_required=True) def __str__(self): - return 'Regenerates every %s week(s)' % self.interval + return f'Regenerates every {self.interval} week(s)' class DailyRegeneration(Regeneration): @@ -192,7 +185,7 @@ class DailyRegeneration(Regeneration): interval = IntegerField(field_uri='Interval', min=1, is_required=True) def __str__(self): - return 'Regenerates every %s day(s)' % self.interval + return f'Regenerates every {self.interval} day(s)' class Boundary(EWSElement, metaclass=EWSMeta): @@ -208,7 +201,7 @@ class NoEndPattern(Boundary): start = DateOrDateTimeField(field_uri='StartDate', is_required=True) def __str__(self): - return 'Starts on %s' % self.start + return f'Starts on {self.start}' class EndDatePattern(Boundary): @@ -222,7 +215,7 @@ class EndDatePattern(Boundary): end = DateOrDateTimeField(field_uri='EndDate', is_required=True) def __str__(self): - return 'Starts on %s, ends on %s' % (self.start, self.end) + return f'Starts on {self.start}, ends on {self.end}' class NumberedPattern(Boundary): @@ -236,7 +229,7 @@ class NumberedPattern(Boundary): number = IntegerField(field_uri='NumberOfOccurrences', min=1, max=999, is_required=True) def __str__(self): - return 'Starts on %s and occurs %s times' % (self.start, self.number) + return f'Starts on {self.start} and occurs {self.number} times' class Occurrence(IdChangeKeyMixIn): @@ -336,7 +329,7 @@ def from_xml(cls, elem, account): return cls(pattern=pattern, boundary=boundary) def __str__(self): - return 'Pattern: %s, Boundary: %s' % (self.pattern, self.boundary) + return f'Pattern: {self.pattern}, Boundary: {self.boundary}' class TaskRecurrence(Recurrence): diff --git a/exchangelib/restriction.py b/exchangelib/restriction.py index 94a016c1..709d0ed2 100644 --- a/exchangelib/restriction.py +++ b/exchangelib/restriction.py @@ -75,7 +75,7 @@ def __init__(self, *args, **kwargs): # Parse args which must now be Q objects for q in args: if not isinstance(q, self.__class__): - raise ValueError("Non-keyword arg %r must be a Q instance" % q) + raise ValueError(f"Non-keyword arg {q!r} must be a Q instance") self.children.extend(args) # Parse keyword args and extract the filter @@ -108,10 +108,10 @@ def _get_children_from_kwarg(self, key, value, is_single_kwarg=False): # EWS doesn't have a 'range' operator. Emulate 'foo__range=(1, 2)' as 'foo__gte=1 and foo__lte=2' # (both values inclusive). if len(value) != 2: - raise ValueError("Value of lookup '%s' must have exactly 2 elements" % key) + raise ValueError(f"Value of lookup {key!r} must have exactly 2 elements") return ( - self.__class__(**{'%s__gte' % field_path: value[0]}), - self.__class__(**{'%s__lte' % field_path: value[1]}), + self.__class__(**{f'{field_path}__gte': value[0]}), + self.__class__(**{f'{field_path}__lte': value[1]}), ) # Filtering on list types is a bit quirky. The only lookup type I have found to work is: @@ -134,7 +134,7 @@ def _get_children_from_kwarg(self, key, value, is_single_kwarg=False): # EWS doesn't have an '__in' operator. Allow '__in' lookups on list and non-list field types, # specifying a list value. We'll emulate it as a set of OR'ed exact matches. if not is_iterable(value, generators_allowed=True): - raise ValueError("Value for lookup %r must be a list" % key) + raise ValueError(f"Value for lookup {key!r} must be a list") children = tuple(self.__class__(**{field_path: v}) for v in value) if not children: # This is an '__in' operator with an empty list as the value. We interpret it to mean "is foo @@ -154,7 +154,7 @@ def _get_children_from_kwarg(self, key, value, is_single_kwarg=False): try: op = self._lookup_to_op(lookup) except KeyError: - raise ValueError("Lookup '%s' is not supported (called as '%s=%r')" % (lookup, key, value)) + raise ValueError(f"Lookup {lookup!r} is not supported (called as '{key}={value!r}')") else: field_path, op = key, self.EQ @@ -257,7 +257,7 @@ def _op_to_xml(cls, op): return create_element(xml_tag_map[op]) valid_ops = cls.EXACT, cls.IEXACT, cls.CONTAINS, cls.ICONTAINS, cls.STARTSWITH, cls.ISTARTSWITH if op not in valid_ops: - raise ValueError("'op' %s must be one of %s" % (op, valid_ops)) + raise ValueError(f"'op' {op!r} must be one of {valid_ops}") # For description of Contains attribute values, see # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/contains @@ -287,7 +287,7 @@ def _op_to_xml(cls, op): elif op in (cls.STARTSWITH, cls.ISTARTSWITH): match_mode = 'Prefixed' else: - raise ValueError('Unsupported op: %s' % op) + raise ValueError(f'Unsupported op: {op}') if op in (cls.IEXACT, cls.ICONTAINS, cls.ISTARTSWITH): compare_mode = 'IgnoreCase' else: @@ -316,18 +316,18 @@ def expr(self): if self.query_string: return self.query_string if self.is_leaf(): - expr = '%s %s %r' % (self.field_path, self.op, self.value) + expr = f'{self.field_path} {self.op} {self.value!r}' else: # Sort children by field name so we get stable output (for easier testing). Children should never be empty. - expr = (' %s ' % (self.AND if self.conn_type == self.NOT else self.conn_type)).join( - (c.expr() if c.is_leaf() or c.conn_type == self.NOT else '(%s)' % c.expr()) + expr = (f' {self.AND if self.conn_type == self.NOT else self.conn_type} ').join( + (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f'({c.expr()})') for c in sorted(self.children, key=lambda i: i.field_path or '') ) if self.conn_type == self.NOT: # Add the NOT operator. Put children in parens if there is more than one child. if self.is_leaf() or len(self.children) == 1: - return self.conn_type + ' %s' % expr - return self.conn_type + ' (%s)' % expr + return self.conn_type + f' {expr}' + return self.conn_type + f' ({expr})' return expr def to_xml(self, folders, version, applies_to): @@ -358,25 +358,23 @@ def _check_integrity(self): raise ValueError('Query strings cannot be combined with other settings') return if self.conn_type not in self.CONN_TYPES: - raise ValueError("'conn_type' %s must be one of %s" % (self.conn_type, self.CONN_TYPES)) + raise ValueError(f"'conn_type' {self.conn_type!r} must be one of {self.CONN_TYPES}") if not self.is_leaf(): for q in self.children: if q.query_string and len(self.children) > 1: - raise ValueError( - 'A query string cannot be combined with other restrictions' - ) + raise ValueError('A query string cannot be combined with other restrictions') return if not self.field_path: raise ValueError("'field_path' must be set") if self.op not in self.OP_TYPES: - raise ValueError("'op' %s must be one of %s" % (self.op, self.OP_TYPES)) + raise ValueError(f"'op' {self.op} must be one of {self.OP_TYPES}") if self.op == self.EXISTS and self.value is not True: raise ValueError("'value' must be True when operator is EXISTS") if self.value is None: - raise ValueError('Value for filter on field path "%s" cannot be None' % self.field_path) + raise ValueError(f'Value for filter on field path {self.field_path!r} cannot be None') if is_iterable(self.value, generators_allowed=True): raise ValueError( - 'Value %r for filter on field path "%s" must be a single value' % (self.value, self.field_path) + f'Value {self.value!r} for filter on field path {self.field_path!r} must be a single value' ) def _validate_field_path(self, field_path, folder, applies_to, version): @@ -387,11 +385,11 @@ def _validate_field_path(self, field_path, folder, applies_to, version): else: folder.validate_item_field(field=field_path.field, version=version) if not field_path.field.is_searchable: - raise ValueError("EWS does not support filtering on field '%s'" % field_path.field.name) + raise ValueError(f"EWS does not support filtering on field {field_path.field.name!r}") if field_path.subfield and not field_path.subfield.is_searchable: - raise ValueError("EWS does not support filtering on subfield '%s'" % field_path.subfield.name) + raise ValueError(f"EWS does not support filtering on subfield {field_path.subfield.name!r}") if issubclass(field_path.field.value_cls, MultiFieldIndexedElement) and not field_path.subfield: - raise ValueError("Field path '%s' must contain a subfield" % self.field_path) + raise ValueError(f"Field path {self.field_path!r} must contain a subfield") def _get_field_path(self, folders, applies_to, version): # Convert the string field path to a real FieldPath object. The path is validated using the given folders. @@ -408,7 +406,7 @@ def _get_field_path(self, folders, applies_to, version): self._validate_field_path(field_path=field_path, folder=folder, applies_to=applies_to, version=version) break else: - raise InvalidField("Unknown field path %r on folders %s" % (self.field_path, folders)) + raise InvalidField(f"Unknown field path {self.field_path!r} on folders {folders}") return field_path def _get_clean_value(self, field_path, version): @@ -516,10 +514,10 @@ def __str__(self): def __repr__(self): if self.is_leaf(): if self.query_string: - return self.__class__.__name__ + '(%r)' % self.query_string + return self.__class__.__name__ + f'({self.query_string!r})' if self.is_never(): - return self.__class__.__name__ + '(conn_type=%r)' % (self.conn_type) - return self.__class__.__name__ + '(%s %s %r)' % (self.field_path, self.op, self.value) + return self.__class__.__name__ + f'(conn_type={self.conn_type!r})' + return self.__class__.__name__ + f'({self.field_path} {self.op} {self.value!r})' sorted_children = tuple(sorted(self.children, key=lambda i: i.field_path or '')) if self.conn_type == self.NOT or len(self.children) > 1: return self.__class__.__name__ + repr((self.conn_type,) + sorted_children) @@ -536,15 +534,15 @@ class Restriction: def __init__(self, q, folders, applies_to): if not isinstance(q, Q): - raise ValueError("'q' value %r must be a Q instance" % q) + raise ValueError(f"'q' value {q} must be a Q instance") if q.is_empty(): raise ValueError("Q object must not be empty") from .folders import BaseFolder for folder in folders: if not isinstance(folder, BaseFolder): - raise ValueError("'folder' value %r must be a Folder instance" % folder) + raise ValueError(f"'folder' value {folder!r} must be a Folder instance") if applies_to not in self.RESTRICTION_TYPES: - raise ValueError("'applies_to' must be one of %s" % (self.RESTRICTION_TYPES,)) + raise ValueError(f"'applies_to' {applies_to!r} must be one of {self.RESTRICTION_TYPES}") self.q = q self.folders = folders self.applies_to = applies_to diff --git a/exchangelib/services/archive_item.py b/exchangelib/services/archive_item.py index ba8d73f0..6dc1bd95 100644 --- a/exchangelib/services/archive_item.py +++ b/exchangelib/services/archive_item.py @@ -7,7 +7,7 @@ class ArchiveItem(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/archiveitem-operation""" SERVICE_NAME = 'ArchiveItem' - element_container_name = '{%s}Items' % MNS + element_container_name = f'{{{MNS}}}Items' supported_from = EXCHANGE_2013 def call(self, items, to_folder): @@ -29,7 +29,7 @@ def _elems_to_objs(self, elems): yield Item.id_from_xml(elem) def get_payload(self, items, to_folder): - archiveitem = create_element('m:%s' % self.SERVICE_NAME) + archiveitem = create_element(f'm:{self.SERVICE_NAME}') folder_id = create_folder_ids_element(tag='m:ArchiveSourceFolderId', folders=[to_folder], version=self.account.version) item_ids = create_item_ids_element(items=items, version=self.account.version) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index d8cbf525..795541cf 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -92,12 +92,12 @@ class EWSService(metaclass=abc.ABCMeta): def __init__(self, protocol, chunk_size=None, timeout=None): self.chunk_size = chunk_size or CHUNK_SIZE # The number of items to send in a single request if not isinstance(self.chunk_size, int): - raise ValueError("'chunk_size' %r must be an integer" % chunk_size) + raise ValueError(f"'chunk_size' {chunk_size!r} must be an integer") if self.chunk_size < 1: raise ValueError("'chunk_size' must be a positive number") if self.supported_from and protocol.version.build < self.supported_from: raise NotImplementedError( - '%r is only supported on %r and later' % (self.SERVICE_NAME, self.supported_from.fullname()) + f'{self.SERVICE_NAME!r} is only supported on {self.supported_from.fullname()!r} and later' ) self.protocol = protocol # Allow a service to override the default protocol timeout. Useful for streaming services @@ -143,10 +143,10 @@ def get(self, expect_result=True, **kwargs): return None if expect_result is False: if res: - raise ValueError('Expected result length 0, but got %r' % res) + raise ValueError(f'Expected result length 0, but got {res}') return None if len(res) != 1: - raise ValueError('Expected result length 1, but got %r' % res) + raise ValueError(f'Expected result length 1, but got {res}') return res[0] def parse(self, xml): @@ -244,7 +244,7 @@ def _get_elements(self, payload): raise e # Re-raise as an ErrorServerBusy with a default delay of 5 minutes - raise ErrorServerBusy('Reraised from %s(%s)' % (e.__class__.__name__, e)) + raise ErrorServerBusy(f'Reraised from {e.__class__.__name__}({e})') except Exception: # This may run from a thread pool, which obfuscates the stack trace. Print trace immediately. account = self.account if isinstance(self, EWSAccountService) else None @@ -342,9 +342,7 @@ def _get_response_xml(self, payload, **parse_opts): # In streaming mode, we may not have accessed the raw stream yet. Caller must handle this. r.close() # Release memory - raise self.NO_VALID_SERVER_VERSIONS( - 'Tried versions %s but all were invalid' % ', '.join(self._api_versions_to_try) - ) + raise self.NO_VALID_SERVER_VERSIONS(f'Tried versions {self._api_versions_to_try} but all were invalid') def _handle_backoff(self, e): """Take a request from the server to back off and checks the retry policy for what to do. Re-raise the @@ -384,17 +382,17 @@ def _update_api_version(self, api_version, header, **parse_opts): @classmethod def _response_tag(cls): """Return the name of the element containing the service response.""" - return '{%s}%sResponse' % (MNS, cls.SERVICE_NAME) + return f'{{{MNS}}}{cls.SERVICE_NAME}Response' @staticmethod def _response_messages_tag(): """Return the name of the element containing service response messages.""" - return '{%s}ResponseMessages' % MNS + return f'{{{MNS}}}ResponseMessages' @classmethod def _response_message_tag(cls): """Return the name of the element of a single response message.""" - return '{%s}%sResponseMessage' % (MNS, cls.SERVICE_NAME) + return f'{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage' @classmethod def _get_soap_parts(cls, response, **parse_opts): @@ -402,12 +400,12 @@ def _get_soap_parts(cls, response, **parse_opts): try: root = to_xml(response.iter_content()) except ParseError as e: - raise SOAPError('Bad SOAP response: %s' % e) - header = root.find('{%s}Header' % SOAPNS) + raise SOAPError(f'Bad SOAP response: {e}') + header = root.find(f'{{{SOAPNS}}}Header') if header is None: # This is normal when the response contains SOAP-level errors log.debug('No header in XML response') - body = root.find('{%s}Body' % SOAPNS) + body = root.find(f'{{{SOAPNS}}}Body') if body is None: raise MalformedResponseError('No Body element in SOAP response') return header, body @@ -416,11 +414,9 @@ def _get_soap_messages(self, body, **parse_opts): """Return the elements in the response containing the response messages. Raises any SOAP exceptions.""" response = body.find(self._response_tag()) if response is None: - fault = body.find('{%s}Fault' % SOAPNS) + fault = body.find(f'{{{SOAPNS}}}Fault') if fault is None: - raise SOAPError( - 'Unknown SOAP response (expected %s or Fault): %s' % (self._response_tag(), xml_to_str(body)) - ) + raise SOAPError(f'Unknown SOAP response (expected {self._response_tag()} or Fault): {xml_to_str(body)}') self._raise_soap_errors(fault=fault) # Will throw SOAPError or custom EWS error response_messages = response.find(self._response_messages_tag()) if response_messages is None: @@ -439,34 +435,33 @@ def _raise_soap_errors(cls, fault): detail = fault.find('detail') if detail is not None: code, msg = None, '' - if detail.find('{%s}ResponseCode' % ENS) is not None: - code = get_xml_attr(detail, '{%s}ResponseCode' % ENS).strip() - if detail.find('{%s}Message' % ENS) is not None: - msg = get_xml_attr(detail, '{%s}Message' % ENS).strip() - msg_xml = detail.find('{%s}MessageXml' % TNS) # Crazy. Here, it's in the TNS namespace + if detail.find(f'{{{ENS}}}ResponseCode') is not None: + code = get_xml_attr(detail, f'{{{ENS}}}ResponseCode').strip() + if detail.find(f'{{{ENS}}}Message') is not None: + msg = get_xml_attr(detail, f'{{{ENS}}}Message').strip() + msg_xml = detail.find(f'{{{TNS}}}MessageXml') # Crazy. Here, it's in the TNS namespace if code == 'ErrorServerBusy': back_off = None try: - value = msg_xml.find('{%s}Value' % TNS) + value = msg_xml.find(f'{{{TNS}}}Value') if value.get('Name') == 'BackOffMilliseconds': back_off = int(value.text) / 1000.0 # Convert to seconds except (TypeError, AttributeError): pass raise ErrorServerBusy(msg, back_off=back_off) if code == 'ErrorSchemaValidation' and msg_xml is not None: - violation = get_xml_attr(msg_xml, '{%s}Violation' % TNS) + violation = get_xml_attr(msg_xml, f'{{{TNS}}}Violation') if violation is not None: - msg = '%s %s' % (msg, violation) + msg = f'{msg} {violation}' try: raise vars(errors)[code](msg) except KeyError: - detail = '%s: code: %s msg: %s (%s)' % (cls.SERVICE_NAME, code, msg, xml_to_str(detail)) + detail = f'{cls.SERVICE_NAME}: code: {code} msg: {msg} ({xml_to_str(detail)})' try: raise vars(errors)[faultcode](faultstring) except KeyError: pass - raise SOAPError('SOAP error code: %s string: %s actor: %s detail: %s' % ( - faultcode, faultstring, faultactor, detail)) + raise SOAPError(f'SOAP error code: {faultcode} string: {faultstring} actor: {faultactor} detail: {detail}') def _get_element_container(self, message, name=None): """Return the XML element in a response element that contains the elements we want the service to return. For @@ -496,19 +491,19 @@ def _get_element_container(self, message, name=None): response_class = message.get('ResponseClass') # ResponseCode, MessageText: See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/responsecode - response_code = get_xml_attr(message, '{%s}ResponseCode' % MNS) + response_code = get_xml_attr(message, f'{{{MNS}}}ResponseCode') if response_class == 'Success' and response_code == 'NoError': if not name: return message container = message.find(name) if container is None: - raise MalformedResponseError('No %s elements in ResponseMessage (%s)' % (name, xml_to_str(message))) + raise MalformedResponseError(f'No {name} elements in ResponseMessage ({xml_to_str(message)})') return container if response_code == 'NoError': return True # Raise any non-acceptable errors in the container, or return the container or the acceptable exception instance - msg_text = get_xml_attr(message, '{%s}MessageText' % MNS) - msg_xml = message.find('{%s}MessageXml' % MNS) + msg_text = get_xml_attr(message, f'{{{MNS}}}MessageText') + msg_xml = message.find(f'{{{MNS}}}MessageXml') if response_class == 'Warning': try: raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml) @@ -518,7 +513,7 @@ def _get_element_container(self, message, name=None): log.warning(str(e)) container = message.find(name) if container is None: - raise MalformedResponseError('No %s elements in ResponseMessage (%s)' % (name, xml_to_str(message))) + raise MalformedResponseError(f'No {name} elements in ResponseMessage ({xml_to_str(message)})') return container # rspclass == 'Error', or 'Success' and not 'NoError' try: @@ -530,30 +525,29 @@ def _get_element_container(self, message, name=None): def _get_exception(code, text, msg_xml): """Parse error messages contained in EWS responses and raise as exceptions defined in this package.""" if not code: - return TransportError('Empty ResponseCode in ResponseMessage (MessageText: %s, MessageXml: %s)' % ( - text, msg_xml)) + return TransportError(f'Empty ResponseCode in ResponseMessage (MessageText: {text}, MessageXml: {msg_xml})') if msg_xml is not None: # If this is an ErrorInvalidPropertyRequest error, the xml may contain a specific FieldURI for elem_cls in (FieldURI, IndexedFieldURI, ExtendedFieldURI, ExceptionFieldURI): elem = msg_xml.find(elem_cls.response_tag()) if elem is not None: field_uri = elem_cls.from_xml(elem, account=None) - text += ' (field: %s)' % field_uri + text += f' (field: {field_uri})' break # If this is an ErrorInvalidValueForProperty error, the xml may contain the name and value of the property if code == 'ErrorInvalidValueForProperty': msg_parts = {} - for elem in msg_xml.findall('{%s}Value' % TNS): + for elem in msg_xml.findall(f'{{{TNS}}}Value'): key, val = elem.get('Name'), elem.text if key: msg_parts[key] = val if msg_parts: - text += ' (%s)' % ', '.join('%s: %s' % (k, v) for k, v in msg_parts.items()) + text += f" ({', '.join(f'{k}: {v}' for k, v in msg_parts.items())})" # If this is an ErrorInternalServerError error, the xml may contain a more specific error code inner_code, inner_text = None, None - for value_elem in msg_xml.findall('{%s}Value' % TNS): + for value_elem in msg_xml.findall(f'{{{TNS}}}Value'): name = value_elem.get('Name') if name == 'InnerErrorResponseCode': inner_code = value_elem.text @@ -562,17 +556,18 @@ def _get_exception(code, text, msg_xml): if inner_code: try: # Raise the error as the inner error code - return vars(errors)[inner_code]('%s (raised from: %s(%r))' % (inner_text, code, text)) + return vars(errors)[inner_code](f'{inner_text} (raised from: {code}({text!r}))') except KeyError: # Inner code is unknown to us. Just append to the original text - text += ' (inner error: %s(%r))' % (inner_code, inner_text) + text += f' (inner error: {inner_code}({inner_text!r}))' try: # Raise the error corresponding to the ResponseCode return vars(errors)[code](text) except KeyError: # Should not happen - return TransportError('Unknown ResponseCode in ResponseMessage: %s (MessageText: %s, MessageXml: %s)' % ( - code, text, msg_xml)) + return TransportError( + f'Unknown ResponseCode in ResponseMessage: {code} (MessageText: {text}, MessageXml: {msg_xml})' + ) def _get_elements_in_response(self, response): """Take a list of 'SomeServiceResponseMessage' elements and return the elements in each response message that @@ -628,8 +623,9 @@ def _get_elements_in_container(cls, container): def _get_elems_from_page(self, elem, max_items, total_item_count): container = elem.find(self.element_container_name) if container is None: - raise MalformedResponseError('No %s elements in ResponseMessage (%s)' % ( - self.element_container_name, xml_to_str(elem))) + raise MalformedResponseError( + f'No {self.element_container_name} elements in ResponseMessage ({xml_to_str(elem)})' + ) for e in self._get_elements_in_container(container=container): if max_items and total_item_count >= max_items: # No need to continue. Break out of elements loop @@ -645,7 +641,7 @@ def _get_pages(self, payload_func, kwargs, expected_message_count): page_elems = list(self._get_elements(payload=payload)) if len(page_elems) != expected_message_count: raise MalformedResponseError( - "Expected %s items in 'response', got %s" % (expected_message_count, len(page_elems)) + f"Expected {expected_message_count} items in 'response', got {len(page_elems)}" ) return page_elems @@ -894,7 +890,7 @@ def parse_folder_elem(elem, folder, account): folder_cls = cls break if not folder_cls: - raise ValueError('Unknown distinguished folder ID: %s' % folder.id) + raise ValueError(f'Unknown distinguished folder ID: {folder.id}') f = folder_cls.from_xml_with_root(elem=elem, root=account.root) else: # 'folder' is a generic FolderId instance. We don't know the root so assume account.root. diff --git a/exchangelib/services/convert_id.py b/exchangelib/services/convert_id.py index eeaf313b..6fed74f2 100644 --- a/exchangelib/services/convert_id.py +++ b/exchangelib/services/convert_id.py @@ -16,7 +16,7 @@ class ConvertId(EWSService): def call(self, items, destination_format): if destination_format not in ID_FORMATS: - raise ValueError("'destination_format' %r must be one of %s" % (destination_format, ID_FORMATS)) + raise ValueError(f"'destination_format' {destination_format!r} must be one of {ID_FORMATS}") return self._elems_to_objs( self._chunked_get_elements(self.get_payload, items=items, destination_format=destination_format) ) @@ -33,11 +33,11 @@ def _elems_to_objs(self, elems): def get_payload(self, items, destination_format): supported_item_classes = AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId - convertid = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(DestinationFormat=destination_format)) + convertid = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DestinationFormat=destination_format)) item_ids = create_element('m:SourceIds') for item in items: if not isinstance(item, supported_item_classes): - raise ValueError("'item' value %r must be an instance of %r" % (item, supported_item_classes)) + raise ValueError(f"'item' value {item!r} must be an instance of {supported_item_classes}") set_xml_value(item_ids, item, version=self.protocol.version) if not len(item_ids): raise ValueError('"items" must not be empty') diff --git a/exchangelib/services/create_attachment.py b/exchangelib/services/create_attachment.py index d103d10a..26433d46 100644 --- a/exchangelib/services/create_attachment.py +++ b/exchangelib/services/create_attachment.py @@ -9,7 +9,7 @@ class CreateAttachment(EWSAccountService): """ SERVICE_NAME = 'CreateAttachment' - element_container_name = '{%s}Attachments' % MNS + element_container_name = f'{{{MNS}}}Attachments' def call(self, parent_item, items): return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, parent_item=parent_item)) @@ -25,7 +25,7 @@ def _elems_to_objs(self, elems): def get_payload(self, items, parent_item): from ..items import BaseItem - payload = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') version = self.account.version if isinstance(parent_item, BaseItem): # to_item_id() would convert this to a normal ItemId, but the service wants a ParentItemId diff --git a/exchangelib/services/create_folder.py b/exchangelib/services/create_folder.py index e236b4c9..afaa1533 100644 --- a/exchangelib/services/create_folder.py +++ b/exchangelib/services/create_folder.py @@ -6,7 +6,7 @@ class CreateFolder(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createfolder-operation""" SERVICE_NAME = 'CreateFolder' - element_container_name = '{%s}Folders' % MNS + element_container_name = f'{{{MNS}}}Folders' def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -28,7 +28,7 @@ def _elems_to_objs(self, elems): yield parse_folder_elem(elem=elem, folder=folder, account=self.account) def get_payload(self, folders, parent_folder): - create_folder = create_element('m:%s' % self.SERVICE_NAME) + create_folder = create_element(f'm:{self.SERVICE_NAME}') parentfolderid = create_element('m:ParentFolderId') set_xml_value(parentfolderid, parent_folder, version=self.account.version) set_xml_value(create_folder, parentfolderid, version=self.account.version) diff --git a/exchangelib/services/create_item.py b/exchangelib/services/create_item.py index f6b1b733..f9163086 100644 --- a/exchangelib/services/create_item.py +++ b/exchangelib/services/create_item.py @@ -10,23 +10,24 @@ class CreateItem(EWSAccountService): """ SERVICE_NAME = 'CreateItem' - element_container_name = '{%s}Items' % MNS + element_container_name = f'{{{MNS}}}Items' def call(self, items, folder, message_disposition, send_meeting_invitations): from ..folders import BaseFolder, FolderId from ..items import SAVE_ONLY, SEND_AND_SAVE_COPY, SEND_ONLY, \ SEND_MEETING_INVITATIONS_CHOICES, MESSAGE_DISPOSITION_CHOICES if message_disposition not in MESSAGE_DISPOSITION_CHOICES: - raise ValueError("'message_disposition' %s must be one of %s" % ( - message_disposition, MESSAGE_DISPOSITION_CHOICES - )) + raise ValueError( + f"'message_disposition' {message_disposition!r} must be one of {MESSAGE_DISPOSITION_CHOICES}" + ) if send_meeting_invitations not in SEND_MEETING_INVITATIONS_CHOICES: - raise ValueError("'send_meeting_invitations' %s must be one of %s" % ( - send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES - )) + raise ValueError( + f"'send_meeting_invitations' {send_meeting_invitations!r} must be one of " + f"{SEND_MEETING_INVITATIONS_CHOICES}" + ) if folder is not None: if not isinstance(folder, (BaseFolder, FolderId)): - raise ValueError("'folder' %r must be a Folder or FolderId instance" % folder) + raise ValueError(f"'folder' {folder!r} must be a Folder or FolderId instance") if folder.account != self.account: raise ValueError('"Folder must belong to this account') if message_disposition == SAVE_ONLY and folder is None: @@ -79,7 +80,7 @@ def get_payload(self, items, folder, message_disposition, send_meeting_invitatio :param send_meeting_invitations: """ createitem = create_element( - 'm:%s' % self.SERVICE_NAME, + f'm:{self.SERVICE_NAME}', attrs=dict(MessageDisposition=message_disposition, SendMeetingInvitations=send_meeting_invitations) ) if folder: diff --git a/exchangelib/services/create_user_configuration.py b/exchangelib/services/create_user_configuration.py index fd1de65a..488929fd 100644 --- a/exchangelib/services/create_user_configuration.py +++ b/exchangelib/services/create_user_configuration.py @@ -14,6 +14,6 @@ def call(self, user_configuration): return self._get_elements(payload=self.get_payload(user_configuration=user_configuration)) def get_payload(self, user_configuration): - createuserconfiguration = create_element('m:%s' % self.SERVICE_NAME) + createuserconfiguration = create_element(f'm:{self.SERVICE_NAME}') set_xml_value(createuserconfiguration, user_configuration, version=self.protocol.version) return createuserconfiguration diff --git a/exchangelib/services/delete_attachment.py b/exchangelib/services/delete_attachment.py index 4fe0fe90..bb5a0653 100644 --- a/exchangelib/services/delete_attachment.py +++ b/exchangelib/services/delete_attachment.py @@ -25,7 +25,7 @@ def _get_elements_in_container(cls, container): return container.findall(RootItemId.response_tag()) def get_payload(self, items): - payload = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') attachment_ids = create_attachment_ids_element(items=items, version=self.account.version) payload.append(attachment_ids) return payload diff --git a/exchangelib/services/delete_folder.py b/exchangelib/services/delete_folder.py index 08313742..dc106fef 100644 --- a/exchangelib/services/delete_folder.py +++ b/exchangelib/services/delete_folder.py @@ -12,7 +12,7 @@ def call(self, folders, delete_type): return self._chunked_get_elements(self.get_payload, items=folders, delete_type=delete_type) def get_payload(self, folders, delete_type): - deletefolder = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(DeleteType=delete_type)) + deletefolder = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DeleteType=delete_type)) folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version) deletefolder.append(folder_ids) return deletefolder diff --git a/exchangelib/services/delete_item.py b/exchangelib/services/delete_item.py index e1981306..fababcf4 100644 --- a/exchangelib/services/delete_item.py +++ b/exchangelib/services/delete_item.py @@ -16,19 +16,15 @@ class DeleteItem(EWSAccountService): def call(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): from ..items import DELETE_TYPE_CHOICES, SEND_MEETING_CANCELLATIONS_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES if delete_type not in DELETE_TYPE_CHOICES: - raise ValueError("'delete_type' %s must be one of %s" % ( - delete_type, DELETE_TYPE_CHOICES - )) + raise ValueError(f"'delete_type' {delete_type} must be one of {DELETE_TYPE_CHOICES}") if send_meeting_cancellations not in SEND_MEETING_CANCELLATIONS_CHOICES: - raise ValueError("'send_meeting_cancellations' %s must be one of %s" % ( - send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES - )) + raise ValueError(f"'send_meeting_cancellations' {send_meeting_cancellations} must be one of " + f"{SEND_MEETING_CANCELLATIONS_CHOICES}") if affected_task_occurrences not in AFFECTED_TASK_OCCURRENCES_CHOICES: - raise ValueError("'affected_task_occurrences' %s must be one of %s" % ( - affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES - )) + raise ValueError(f"'affected_task_occurrences' {affected_task_occurrences} must be one of " + f"{AFFECTED_TASK_OCCURRENCES_CHOICES}") if suppress_read_receipts not in (True, False): - raise ValueError("'suppress_read_receipts' %s must be True or False" % suppress_read_receipts) + raise ValueError(f"'suppress_read_receipts' {suppress_read_receipts} must be True or False") return self._chunked_get_elements( self.get_payload, items=items, @@ -43,7 +39,7 @@ def get_payload(self, items, delete_type, send_meeting_cancellations, affected_t # Takes a list of (id, changekey) tuples or Item objects and returns the XML for a DeleteItem request. if self.account.version.build >= EXCHANGE_2013_SP1: deleteitem = create_element( - 'm:%s' % self.SERVICE_NAME, + f'm:{self.SERVICE_NAME}', attrs=dict( DeleteType=delete_type, SendMeetingCancellations=send_meeting_cancellations, @@ -53,7 +49,7 @@ def get_payload(self, items, delete_type, send_meeting_cancellations, affected_t ) else: deleteitem = create_element( - 'm:%s' % self.SERVICE_NAME, + f'm:{self.SERVICE_NAME}', attrs=dict( DeleteType=delete_type, SendMeetingCancellations=send_meeting_cancellations, diff --git a/exchangelib/services/delete_user_configuration.py b/exchangelib/services/delete_user_configuration.py index 7b3b9abe..2f6fbda6 100644 --- a/exchangelib/services/delete_user_configuration.py +++ b/exchangelib/services/delete_user_configuration.py @@ -14,6 +14,6 @@ def call(self, user_configuration_name): return self._get_elements(payload=self.get_payload(user_configuration_name=user_configuration_name)) def get_payload(self, user_configuration_name): - deleteuserconfiguration = create_element('m:%s' % self.SERVICE_NAME) + deleteuserconfiguration = create_element(f'm:{self.SERVICE_NAME}') set_xml_value(deleteuserconfiguration, user_configuration_name, version=self.account.version) return deleteuserconfiguration diff --git a/exchangelib/services/empty_folder.py b/exchangelib/services/empty_folder.py index a1a457c3..be666f35 100644 --- a/exchangelib/services/empty_folder.py +++ b/exchangelib/services/empty_folder.py @@ -15,7 +15,7 @@ def call(self, folders, delete_type, delete_sub_folders): def get_payload(self, folders, delete_type, delete_sub_folders): emptyfolder = create_element( - 'm:%s' % self.SERVICE_NAME, + f'm:{self.SERVICE_NAME}', attrs=dict(DeleteType=delete_type, DeleteSubFolders=delete_sub_folders) ) folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version) diff --git a/exchangelib/services/expand_dl.py b/exchangelib/services/expand_dl.py index 20236326..68d27f18 100644 --- a/exchangelib/services/expand_dl.py +++ b/exchangelib/services/expand_dl.py @@ -8,7 +8,7 @@ class ExpandDL(EWSService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/expanddl-operation""" SERVICE_NAME = 'ExpandDL' - element_container_name = '{%s}DLExpansion' % MNS + element_container_name = f'{{{MNS}}}DLExpansion' WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults def call(self, distribution_list): @@ -22,6 +22,6 @@ def _elems_to_objs(self, elems): yield Mailbox.from_xml(elem, account=None) def get_payload(self, distribution_list): - payload = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') set_xml_value(payload, distribution_list, version=self.protocol.version) return payload diff --git a/exchangelib/services/export_items.py b/exchangelib/services/export_items.py index 8d8e6683..11bc292d 100644 --- a/exchangelib/services/export_items.py +++ b/exchangelib/services/export_items.py @@ -8,7 +8,7 @@ class ExportItems(EWSAccountService): ERRORS_TO_CATCH_IN_RESPONSE = ResponseMessageError SERVICE_NAME = 'ExportItems' - element_container_name = '{%s}Data' % MNS + element_container_name = f'{{{MNS}}}Data' def call(self, items): return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items)) @@ -21,7 +21,7 @@ def _elems_to_objs(self, elems): yield elem.text # All we want is the 64bit string in the 'Data' tag def get_payload(self, items): - exportitems = create_element('m:%s' % self.SERVICE_NAME) + exportitems = create_element(f'm:{self.SERVICE_NAME}') item_ids = create_item_ids_element(items=items, version=self.account.version) exportitems.append(item_ids) return exportitems diff --git a/exchangelib/services/find_folder.py b/exchangelib/services/find_folder.py index d8937de2..e2ce62cc 100644 --- a/exchangelib/services/find_folder.py +++ b/exchangelib/services/find_folder.py @@ -7,8 +7,8 @@ class FindFolder(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/findfolder-operation""" SERVICE_NAME = 'FindFolder' - element_container_name = '{%s}Folders' % TNS - paging_container_name = '{%s}RootFolder' % MNS + element_container_name = f'{{{TNS}}}Folders' + paging_container_name = f'{{{MNS}}}RootFolder' supports_paging = True def __init__(self, *args, **kwargs): @@ -30,7 +30,7 @@ def call(self, folders, additional_fields, restriction, shape, depth, max_items, """ roots = {f.root for f in folders} if len(roots) != 1: - raise ValueError('FindFolder must be called with folders in the same root hierarchy (%r)' % roots) + raise ValueError(f'FindFolder must be called with folders in the same root hierarchy ({roots})') self.root = roots.pop() return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, @@ -55,7 +55,7 @@ def _elems_to_objs(self, elems): yield Folder.from_xml_with_root(elem=elem, root=self.root) def get_payload(self, folders, additional_fields, restriction, shape, depth, page_size, offset=0): - findfolder = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth)) + findfolder = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) foldershape = create_shape_element( tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version ) diff --git a/exchangelib/services/find_item.py b/exchangelib/services/find_item.py index da20d085..9ce1f39a 100644 --- a/exchangelib/services/find_item.py +++ b/exchangelib/services/find_item.py @@ -6,8 +6,8 @@ class FindItem(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/finditem-operation""" SERVICE_NAME = 'FindItem' - element_container_name = '{%s}Items' % TNS - paging_container_name = '{%s}RootFolder' % MNS + element_container_name = f'{{{TNS}}}Items' + paging_container_name = f'{{{MNS}}}RootFolder' supports_paging = True def __init__(self, *args, **kwargs): @@ -66,7 +66,7 @@ def _elems_to_objs(self, elems): def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, calendar_view, page_size, offset=0): - finditem = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth)) + finditem = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) itemshape = create_shape_element( tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version ) diff --git a/exchangelib/services/find_people.py b/exchangelib/services/find_people.py index 22b86451..7ce5fdd2 100644 --- a/exchangelib/services/find_people.py +++ b/exchangelib/services/find_people.py @@ -10,7 +10,7 @@ class FindPeople(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/findpeople-operation""" SERVICE_NAME = 'FindPeople' - element_container_name = '{%s}People' % MNS + element_container_name = f'{{{MNS}}}People' supported_from = EXCHANGE_2013 supports_paging = True @@ -68,9 +68,9 @@ def get_payload(self, folders, additional_fields, restriction, order_fields, que offset=0): folders = list(folders) if len(folders) != 1: - raise ValueError('%r can only query one folder' % self.SERVICE_NAME) + raise ValueError(f'{self.SERVICE_NAME} can only query one folder') folder = folders[0] - findpeople = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth)) + findpeople = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) personashape = create_shape_element( tag='m:PersonaShape', shape=shape, additional_fields=additional_fields, version=self.account.version ) @@ -100,9 +100,9 @@ def get_payload(self, folders, additional_fields, restriction, order_fields, que @staticmethod def _get_paging_values(elem): """Find paging values. The paging element from FindPeople is different from other paging containers.""" - item_count = int(elem.find('{%s}TotalNumberOfPeopleInView' % MNS).text) - first_matching = int(elem.find('{%s}FirstMatchingRowIndex' % MNS).text) - first_loaded = int(elem.find('{%s}FirstLoadedRowIndex' % MNS).text) + item_count = int(elem.find(f'{{{MNS}}}TotalNumberOfPeopleInView').text) + first_matching = int(elem.find(f'{{{MNS}}}FirstMatchingRowIndex').text) + first_loaded = int(elem.find(f'{{{MNS}}}FirstLoadedRowIndex').text) log.debug('Got page with total items %s, first matching %s, first loaded %s ', item_count, first_matching, first_loaded) next_offset = None # GetPersona does not support fetching more pages diff --git a/exchangelib/services/get_attachment.py b/exchangelib/services/get_attachment.py index 7bf2b3ef..f35d7c2d 100644 --- a/exchangelib/services/get_attachment.py +++ b/exchangelib/services/get_attachment.py @@ -12,11 +12,11 @@ class GetAttachment(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getattachment-operation""" SERVICE_NAME = 'GetAttachment' - element_container_name = '{%s}Attachments' % MNS + element_container_name = f'{{{MNS}}}Attachments' def call(self, items, include_mime_content, body_type, filter_html_content, additional_fields): if body_type and body_type not in BODY_TYPE_CHOICES: - raise ValueError("'body_type' %s must be one of %s" % (body_type, BODY_TYPE_CHOICES)) + raise ValueError(f"'body_type' {body_type!r} must be one of {BODY_TYPE_CHOICES}") return self._elems_to_objs(self._chunked_get_elements( self.get_payload, items=items, include_mime_content=include_mime_content, body_type=body_type, filter_html_content=filter_html_content, additional_fields=additional_fields, @@ -32,7 +32,7 @@ def _elems_to_objs(self, elems): yield cls_map[elem.tag].from_xml(elem=elem, account=self.account) def get_payload(self, items, include_mime_content, body_type, filter_html_content, additional_fields): - payload = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') shape_elem = create_element('m:AttachmentShape') if include_mime_content: add_xml_child(shape_elem, 't:IncludeMimeContent', 'true') diff --git a/exchangelib/services/get_delegate.py b/exchangelib/services/get_delegate.py index 29512fcb..39513981 100644 --- a/exchangelib/services/get_delegate.py +++ b/exchangelib/services/get_delegate.py @@ -26,7 +26,7 @@ def _elems_to_objs(self, elems): yield DelegateUser.from_xml(elem=elem, account=self.account) def get_payload(self, user_ids, mailbox, include_permissions): - payload = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(IncludePermissions=include_permissions)) + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IncludePermissions=include_permissions)) set_xml_value(payload, mailbox, version=self.protocol.version) if user_ids != [None]: set_xml_value(payload, user_ids, version=self.protocol.version) @@ -38,4 +38,4 @@ def _get_elements_in_container(cls, container): @classmethod def _response_message_tag(cls): - return '{%s}DelegateUserResponseMessageType' % MNS + return f'{{{MNS}}}DelegateUserResponseMessageType' diff --git a/exchangelib/services/get_events.py b/exchangelib/services/get_events.py index d88dc1f1..1bd60e2c 100644 --- a/exchangelib/services/get_events.py +++ b/exchangelib/services/get_events.py @@ -32,7 +32,7 @@ def _get_elements_in_container(cls, container): return container.findall(Notification.response_tag()) def get_payload(self, subscription_id, watermark): - getstreamingevents = create_element('m:%s' % self.SERVICE_NAME) + getstreamingevents = create_element(f'm:{self.SERVICE_NAME}') add_xml_child(getstreamingevents, 'm:SubscriptionId', subscription_id) add_xml_child(getstreamingevents, 'm:Watermark', watermark) return getstreamingevents diff --git a/exchangelib/services/get_folder.py b/exchangelib/services/get_folder.py index 3ddf8d56..f1511b6b 100644 --- a/exchangelib/services/get_folder.py +++ b/exchangelib/services/get_folder.py @@ -8,7 +8,7 @@ class GetFolder(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getfolder-operation""" SERVICE_NAME = 'GetFolder' - element_container_name = '{%s}Folders' % MNS + element_container_name = f'{{{MNS}}}Folders' ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + ( ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorInvalidOperation, ) @@ -44,7 +44,7 @@ def _elems_to_objs(self, elems): yield parse_folder_elem(elem=elem, folder=folder, account=self.account) def get_payload(self, folders, additional_fields, shape): - getfolder = create_element('m:%s' % self.SERVICE_NAME) + getfolder = create_element(f'm:{self.SERVICE_NAME}') foldershape = create_shape_element( tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version ) diff --git a/exchangelib/services/get_item.py b/exchangelib/services/get_item.py index 006e6a07..a118a2a9 100644 --- a/exchangelib/services/get_item.py +++ b/exchangelib/services/get_item.py @@ -6,7 +6,7 @@ class GetItem(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getitem-operation""" SERVICE_NAME = 'GetItem' - element_container_name = '{%s}Items' % MNS + element_container_name = f'{{{MNS}}}Items' def call(self, items, additional_fields, shape): """Return all items in an account that correspond to a list of ID's, in stable order. @@ -30,7 +30,7 @@ def _elems_to_objs(self, elems): yield BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) def get_payload(self, items, additional_fields, shape): - getitem = create_element('m:%s' % self.SERVICE_NAME) + getitem = create_element(f'm:{self.SERVICE_NAME}') itemshape = create_shape_element( tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version ) diff --git a/exchangelib/services/get_mail_tips.py b/exchangelib/services/get_mail_tips.py index c5649556..eea44159 100644 --- a/exchangelib/services/get_mail_tips.py +++ b/exchangelib/services/get_mail_tips.py @@ -24,7 +24,7 @@ def _elems_to_objs(self, elems): yield MailTips.from_xml(elem=elem, account=None) def get_payload(self, recipients, sending_as, mail_tips_requested): - payload = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') set_xml_value(payload, sending_as, version=self.protocol.version) recipients_elem = create_element('m:Recipients') @@ -44,4 +44,4 @@ def _get_elements_in_response(self, response): @classmethod def _response_message_tag(cls): - return '{%s}MailTipsResponseMessageType' % MNS + return f'{{{MNS}}}MailTipsResponseMessageType' diff --git a/exchangelib/services/get_persona.py b/exchangelib/services/get_persona.py index bbd0b194..f6bd0b61 100644 --- a/exchangelib/services/get_persona.py +++ b/exchangelib/services/get_persona.py @@ -23,15 +23,15 @@ def _elems_to_objs(self, elems): def get_payload(self, persona): version = self.protocol.version - payload = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') set_xml_value(payload, to_item_id(persona, PersonaId, version=version), version=version) return payload @classmethod def _get_elements_in_container(cls, container): from ..items import Persona - return container.findall('{%s}%s' % (MNS, Persona.ELEMENT_NAME)) + return container.findall(f'{{{MNS}}}{Persona.ELEMENT_NAME}') @classmethod def _response_tag(cls): - return '{%s}%sResponseMessage' % (MNS, cls.SERVICE_NAME) + return f'{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage' diff --git a/exchangelib/services/get_room_lists.py b/exchangelib/services/get_room_lists.py index 7cea2aef..e6ba847a 100644 --- a/exchangelib/services/get_room_lists.py +++ b/exchangelib/services/get_room_lists.py @@ -8,7 +8,7 @@ class GetRoomLists(EWSService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getroomlists-operation""" SERVICE_NAME = 'GetRoomLists' - element_container_name = '{%s}RoomLists' % MNS + element_container_name = f'{{{MNS}}}RoomLists' supported_from = EXCHANGE_2010 def call(self): @@ -22,4 +22,4 @@ def _elems_to_objs(self, elems): yield RoomList.from_xml(elem=elem, account=None) def get_payload(self): - return create_element('m:%s' % self.SERVICE_NAME) + return create_element(f'm:{self.SERVICE_NAME}') diff --git a/exchangelib/services/get_rooms.py b/exchangelib/services/get_rooms.py index 766dc95e..b43f6a83 100644 --- a/exchangelib/services/get_rooms.py +++ b/exchangelib/services/get_rooms.py @@ -8,7 +8,7 @@ class GetRooms(EWSService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getrooms-operation""" SERVICE_NAME = 'GetRooms' - element_container_name = '{%s}Rooms' % MNS + element_container_name = f'{{{MNS}}}Rooms' supported_from = EXCHANGE_2010 def call(self, roomlist): @@ -22,6 +22,6 @@ def _elems_to_objs(self, elems): yield Room.from_xml(elem=elem, account=None) def get_payload(self, roomlist): - getrooms = create_element('m:%s' % self.SERVICE_NAME) + getrooms = create_element(f'm:{self.SERVICE_NAME}') set_xml_value(getrooms, roomlist, version=self.protocol.version) return getrooms diff --git a/exchangelib/services/get_searchable_mailboxes.py b/exchangelib/services/get_searchable_mailboxes.py index 1989fb4a..47149fec 100644 --- a/exchangelib/services/get_searchable_mailboxes.py +++ b/exchangelib/services/get_searchable_mailboxes.py @@ -11,8 +11,8 @@ class GetSearchableMailboxes(EWSService): """ SERVICE_NAME = 'GetSearchableMailboxes' - element_container_name = '{%s}SearchableMailboxes' % MNS - failed_mailboxes_container_name = '{%s}FailedMailboxes' % MNS + element_container_name = f'{{{MNS}}}SearchableMailboxes' + failed_mailboxes_container_name = f'{{{MNS}}}FailedMailboxes' supported_from = EXCHANGE_2013 def call(self, search_filter, expand_group_membership): @@ -30,7 +30,7 @@ def _elems_to_objs(self, elems): yield cls_map[elem.tag].from_xml(elem=elem, account=None) def get_payload(self, search_filter, expand_group_membership): - payload = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') if search_filter: add_xml_child(payload, 'm:SearchFilter', search_filter) if expand_group_membership is not None: diff --git a/exchangelib/services/get_server_time_zones.py b/exchangelib/services/get_server_time_zones.py index 47979900..cd680052 100644 --- a/exchangelib/services/get_server_time_zones.py +++ b/exchangelib/services/get_server_time_zones.py @@ -14,7 +14,7 @@ class GetServerTimeZones(EWSService): """ SERVICE_NAME = 'GetServerTimeZones' - element_container_name = '{%s}TimeZoneDefinitions' % MNS + element_container_name = f'{{{MNS}}}TimeZoneDefinitions' supported_from = EXCHANGE_2010 def call(self, timezones=None, return_full_timezone_data=False): @@ -25,7 +25,7 @@ def call(self, timezones=None, return_full_timezone_data=False): def get_payload(self, timezones, return_full_timezone_data): payload = create_element( - 'm:%s' % self.SERVICE_NAME, + f'm:{self.SERVICE_NAME}', attrs=dict(ReturnFullTimeZoneData=return_full_timezone_data), ) if timezones is not None: @@ -53,8 +53,8 @@ def _elems_to_objs(self, elems): @staticmethod def _get_periods(timezonedef): tz_periods = {} - periods = timezonedef.find('{%s}Periods' % TNS) - for period in periods.findall('{%s}Period' % TNS): + periods = timezonedef.find(f'{{{TNS}}}Periods') + for period in periods.findall(f'{{{TNS}}}Period'): # Convert e.g. "trule:Microsoft/Registry/W. Europe Standard Time/2006-Daylight" to (2006, 'Daylight') p_year, p_type = period.get('Id').rsplit('/', 1)[1].split('-') tz_periods[(int(p_year), p_type)] = dict( @@ -66,29 +66,29 @@ def _get_periods(timezonedef): @staticmethod def _get_transitions_groups(timezonedef): tz_transitions_groups = {} - transitiongroups = timezonedef.find('{%s}TransitionsGroups' % TNS) + transitiongroups = timezonedef.find(f'{{{TNS}}}TransitionsGroups') if transitiongroups is not None: - for transitiongroup in transitiongroups.findall('{%s}TransitionsGroup' % TNS): + for transitiongroup in transitiongroups.findall(f'{{{TNS}}}TransitionsGroup'): tg_id = int(transitiongroup.get('Id')) tz_transitions_groups[tg_id] = [] - for transition in transitiongroup.findall('{%s}Transition' % TNS): + for transition in transitiongroup.findall(f'{{{TNS}}}Transition'): # Apply same conversion to To as for period IDs - to_year, to_type = transition.find('{%s}To' % TNS).text.rsplit('/', 1)[1].split('-') + to_year, to_type = transition.find(f'{{{TNS}}}To').text.rsplit('/', 1)[1].split('-') tz_transitions_groups[tg_id].append(dict( to=(int(to_year), to_type), )) - for transition in transitiongroup.findall('{%s}RecurringDayTransition' % TNS): + for transition in transitiongroup.findall(f'{{{TNS}}}RecurringDayTransition'): # Apply same conversion to To as for period IDs - to_year, to_type = transition.find('{%s}To' % TNS).text.rsplit('/', 1)[1].split('-') - occurrence = xml_text_to_value(transition.find('{%s}Occurrence' % TNS).text, int) + to_year, to_type = transition.find(f'{{{TNS}}}To').text.rsplit('/', 1)[1].split('-') + occurrence = xml_text_to_value(transition.find(f'{{{TNS}}}Occurrence').text, int) if occurrence == -1: # See TimeZoneTransition.from_xml() occurrence = 5 tz_transitions_groups[tg_id].append(dict( to=(int(to_year), to_type), - offset=xml_text_to_value(transition.find('{%s}TimeOffset' % TNS).text, datetime.timedelta), - iso_month=xml_text_to_value(transition.find('{%s}Month' % TNS).text, int), - iso_weekday=WEEKDAY_NAMES.index(transition.find('{%s}DayOfWeek' % TNS).text) + 1, + offset=xml_text_to_value(transition.find(f'{{{TNS}}}TimeOffset').text, datetime.timedelta), + iso_month=xml_text_to_value(transition.find(f'{{{TNS}}}Month').text, int), + iso_weekday=WEEKDAY_NAMES.index(transition.find(f'{{{TNS}}}DayOfWeek').text) + 1, occurrence=occurrence, )) return tz_transitions_groups @@ -96,21 +96,21 @@ def _get_transitions_groups(timezonedef): @staticmethod def _get_transitions(timezonedef): tz_transitions = {} - transitions = timezonedef.find('{%s}Transitions' % TNS) + transitions = timezonedef.find(f'{{{TNS}}}Transitions') if transitions is not None: - for transition in transitions.findall('{%s}Transition' % TNS): - to = transition.find('{%s}To' % TNS) + for transition in transitions.findall(f'{{{TNS}}}Transition'): + to = transition.find(f'{{{TNS}}}To') if to.get('Kind') != 'Group': - raise ValueError('Unexpected "Kind" XML attr: %s' % to.get('Kind')) + raise ValueError(f"Unexpected 'Kind' XML attr: {to.get('Kind')}") tg_id = xml_text_to_value(to.text, int) tz_transitions[tg_id] = None - for transition in transitions.findall('{%s}AbsoluteDateTransition' % TNS): - to = transition.find('{%s}To' % TNS) + for transition in transitions.findall(f'{{{TNS}}}AbsoluteDateTransition'): + to = transition.find(f'{{{TNS}}}To') if to.get('Kind') != 'Group': - raise ValueError('Unexpected "Kind" XML attr: %s' % to.get('Kind')) + raise ValueError(f"Unexpected 'Kind' XML attr: {to.get('Kind')}") tg_id = xml_text_to_value(to.text, int) try: - t_date = xml_text_to_value(transition.find('{%s}DateTime' % TNS).text, EWSDateTime).date() + t_date = xml_text_to_value(transition.find(f'{{{TNS}}}DateTime').text, EWSDateTime).date() except NaiveDateTimeNotAllowed as e: # We encountered a naive datetime. Don't worry. we just need the date t_date = e.local_dt.date() diff --git a/exchangelib/services/get_streaming_events.py b/exchangelib/services/get_streaming_events.py index dcf0e255..f404cf12 100644 --- a/exchangelib/services/get_streaming_events.py +++ b/exchangelib/services/get_streaming_events.py @@ -6,7 +6,7 @@ from ..util import create_element, get_xml_attr, get_xml_attrs, MNS, DocumentYielder, DummyResponse log = logging.getLogger(__name__) -xml_log = logging.getLogger('%s.xml' % __name__) +xml_log = logging.getLogger(f'{__name__}.xml') class GetStreamingEvents(EWSAccountService): @@ -15,7 +15,7 @@ class GetStreamingEvents(EWSAccountService): """ SERVICE_NAME = 'GetStreamingEvents' - element_container_name = '{%s}Notifications' % MNS + element_container_name = f'{{{MNS}}}Notifications' prefer_affinity = True # Connection status values @@ -68,9 +68,9 @@ def _get_soap_messages(self, body, **parse_opts): break def _get_element_container(self, message, name=None): - error_ids_elem = message.find('{%s}ErrorSubscriptionIds' % MNS) - error_ids = [] if error_ids_elem is None else get_xml_attrs(error_ids_elem, '{%s}SubscriptionId' % MNS) - self.connection_status = get_xml_attr(message, '{%s}ConnectionStatus' % MNS) # Either 'OK' or 'Closed' + error_ids_elem = message.find(f'{{{MNS}}}ErrorSubscriptionIds') + error_ids = [] if error_ids_elem is None else get_xml_attrs(error_ids_elem, f'{{{MNS}}}SubscriptionId') + self.connection_status = get_xml_attr(message, f'{{{MNS}}}ConnectionStatus') # Either 'OK' or 'Closed' log.debug('Connection status is: %s', self.connection_status) # Upstream normally expects to find a 'name' tag but our response does not always have it. We still want to # call upstream, to have exceptions raised. Return an empty list if there is no 'name' tag and no errors. @@ -83,12 +83,12 @@ def _get_element_container(self, message, name=None): # subscriptions seem to never be returned even though the XML spec allows it. This means there's no point in # trying to collect any notifications here and delivering a combination of errors and return values. if error_ids: - e.value += ' (subscription IDs: %s)' % ', '.join(repr(i) for i in error_ids) + e.value += f' (subscription IDs: {error_ids})' raise e return [] if name is None else res def get_payload(self, subscription_ids, connection_timeout): - getstreamingevents = create_element('m:%s' % self.SERVICE_NAME) + getstreamingevents = create_element(f'm:{self.SERVICE_NAME}') subscriptions_elem = create_element('m:SubscriptionIds') for subscription_id in subscription_ids: add_xml_child(subscriptions_elem, 't:SubscriptionId', subscription_id) diff --git a/exchangelib/services/get_user_availability.py b/exchangelib/services/get_user_availability.py index 2cfa87bf..fce084cf 100644 --- a/exchangelib/services/get_user_availability.py +++ b/exchangelib/services/get_user_availability.py @@ -28,7 +28,7 @@ def _elems_to_objs(self, elems): yield FreeBusyView.from_xml(elem=elem, account=None) def get_payload(self, timezone, mailbox_data, free_busy_view_options): - payload = create_element('m:%sRequest' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}Request') set_xml_value(payload, timezone, version=self.protocol.version) mailbox_data_array = create_element('m:MailboxDataArray') set_xml_value(mailbox_data_array, mailbox_data, version=self.protocol.version) @@ -38,18 +38,18 @@ def get_payload(self, timezone, mailbox_data, free_busy_view_options): @staticmethod def _response_messages_tag(): - return '{%s}FreeBusyResponseArray' % MNS + return f'{{{MNS}}}FreeBusyResponseArray' @classmethod def _response_message_tag(cls): - return '{%s}FreeBusyResponse' % MNS + return f'{{{MNS}}}FreeBusyResponse' def _get_elements_in_response(self, response): for msg in response: # Just check the response code and raise errors - self._get_element_container(message=msg.find('{%s}ResponseMessage' % MNS)) + self._get_element_container(message=msg.find(f'{{{MNS}}}ResponseMessage')) yield from self._get_elements_in_container(container=msg) @classmethod def _get_elements_in_container(cls, container): - return [container.find('{%s}FreeBusyView' % MNS)] + return [container.find(f'{{{MNS}}}FreeBusyView')] diff --git a/exchangelib/services/get_user_configuration.py b/exchangelib/services/get_user_configuration.py index 3a21846a..9d3f0357 100644 --- a/exchangelib/services/get_user_configuration.py +++ b/exchangelib/services/get_user_configuration.py @@ -19,7 +19,7 @@ class GetUserConfiguration(EWSAccountService): def call(self, user_configuration_name, properties): if properties not in PROPERTIES_CHOICES: - raise ValueError("'properties' %r must be one of %s" % (properties, PROPERTIES_CHOICES)) + raise ValueError(f"'properties' {properties!r} must be one of {PROPERTIES_CHOICES}") return self._elems_to_objs(self._get_elements(payload=self.get_payload( user_configuration_name=user_configuration_name, properties=properties ))) @@ -36,7 +36,7 @@ def _get_elements_in_container(cls, container): return container.findall(UserConfiguration.response_tag()) def get_payload(self, user_configuration_name, properties): - getuserconfiguration = create_element('m:%s' % self.SERVICE_NAME) + getuserconfiguration = create_element(f'm:{self.SERVICE_NAME}') set_xml_value(getuserconfiguration, user_configuration_name, version=self.account.version) user_configuration_properties = create_element('m:UserConfigurationProperties') set_xml_value(user_configuration_properties, properties, version=self.account.version) diff --git a/exchangelib/services/get_user_oof_settings.py b/exchangelib/services/get_user_oof_settings.py index 032dd1b5..342977b0 100644 --- a/exchangelib/services/get_user_oof_settings.py +++ b/exchangelib/services/get_user_oof_settings.py @@ -10,7 +10,7 @@ class GetUserOofSettings(EWSAccountService): """ SERVICE_NAME = 'GetUserOofSettings' - element_container_name = '{%s}OofSettings' % TNS + element_container_name = f'{{{TNS}}}OofSettings' def call(self, mailbox): return self._elems_to_objs(self._get_elements(payload=self.get_payload(mailbox=mailbox))) @@ -23,7 +23,7 @@ def _elems_to_objs(self, elems): yield OofSettings.from_xml(elem=elem, account=self.account) def get_payload(self, mailbox): - payload = create_element('m:%sRequest' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}Request') return set_xml_value(payload, AvailabilityMailbox.from_mailbox(mailbox), version=self.account.version) @classmethod @@ -38,4 +38,4 @@ def _get_element_container(self, message, name=None): @classmethod def _response_message_tag(cls): - return '{%s}ResponseMessage' % MNS + return f'{{{MNS}}}ResponseMessage' diff --git a/exchangelib/services/mark_as_junk.py b/exchangelib/services/mark_as_junk.py index 7effda50..94e79f4d 100644 --- a/exchangelib/services/mark_as_junk.py +++ b/exchangelib/services/mark_as_junk.py @@ -26,7 +26,7 @@ def _get_elements_in_container(cls, container): def get_payload(self, items, is_junk, move_item): # Takes a list of items and returns either success or raises an error message - mark_as_junk = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(IsJunk=is_junk, MoveItem=move_item)) + mark_as_junk = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IsJunk=is_junk, MoveItem=move_item)) item_ids = create_item_ids_element(items=items, version=self.account.version) mark_as_junk.append(item_ids) return mark_as_junk diff --git a/exchangelib/services/move_folder.py b/exchangelib/services/move_folder.py index e45ac777..b759ad04 100644 --- a/exchangelib/services/move_folder.py +++ b/exchangelib/services/move_folder.py @@ -6,12 +6,12 @@ class MoveFolder(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/movefolder-operation""" SERVICE_NAME = "MoveFolder" - element_container_name = '{%s}Folders' % MNS + element_container_name = f'{{{MNS}}}Folders' def call(self, folders, to_folder): from ..folders import BaseFolder, FolderId if not isinstance(to_folder, (BaseFolder, FolderId)): - raise ValueError("'to_folder' %r must be a Folder or FolderId instance" % to_folder) + raise ValueError(f"'to_folder' {to_folder!r} must be a Folder or FolderId instance") return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=folders, to_folder=to_folder)) def _elems_to_objs(self, elems): @@ -24,7 +24,7 @@ def _elems_to_objs(self, elems): def get_payload(self, folders, to_folder): # Takes a list of folders and returns their new folder IDs - movefolder = create_element('m:%s' % self.SERVICE_NAME) + movefolder = create_element(f'm:{self.SERVICE_NAME}') tofolderid = create_element('m:ToFolderId') set_xml_value(tofolderid, to_folder, version=self.account.version) movefolder.append(tofolderid) diff --git a/exchangelib/services/move_item.py b/exchangelib/services/move_item.py index f4e6c768..eeadc8f5 100644 --- a/exchangelib/services/move_item.py +++ b/exchangelib/services/move_item.py @@ -6,12 +6,12 @@ class MoveItem(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/moveitem-operation""" SERVICE_NAME = 'MoveItem' - element_container_name = '{%s}Items' % MNS + element_container_name = f'{{{MNS}}}Items' def call(self, items, to_folder): from ..folders import BaseFolder, FolderId if not isinstance(to_folder, (BaseFolder, FolderId)): - raise ValueError("'to_folder' %r must be a Folder or FolderId instance" % to_folder) + raise ValueError(f"'to_folder' {to_folder!r} must be a Folder or FolderId instance") return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, to_folder=to_folder)) def _elems_to_objs(self, elems): @@ -24,7 +24,7 @@ def _elems_to_objs(self, elems): def get_payload(self, items, to_folder): # Takes a list of items and returns their new item IDs - moveitem = create_element('m:%s' % self.SERVICE_NAME) + moveitem = create_element(f'm:{self.SERVICE_NAME}') tofolderid = create_element('m:ToFolderId') set_xml_value(tofolderid, to_folder, version=self.account.version) moveitem.append(tofolderid) diff --git a/exchangelib/services/resolve_names.py b/exchangelib/services/resolve_names.py index 08fa178b..af8887cd 100644 --- a/exchangelib/services/resolve_names.py +++ b/exchangelib/services/resolve_names.py @@ -13,7 +13,7 @@ class ResolveNames(EWSService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/resolvenames-operation""" SERVICE_NAME = 'ResolveNames' - element_container_name = '{%s}ResolutionSet' % MNS + element_container_name = f'{{{MNS}}}ResolutionSet' ERRORS_TO_CATCH_IN_RESPONSE = ErrorNameResolutionNoResults WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults # Note: paging information is returned as attrs on the 'ResolutionSet' element, but this service does not @@ -34,9 +34,9 @@ def call(self, unresolved_entries, parent_folders=None, return_full_contact_data self.chunk_size, self.SERVICE_NAME ) if search_scope and search_scope not in SEARCH_SCOPE_CHOICES: - raise ValueError("'search_scope' %s must be one if %s" % (search_scope, SEARCH_SCOPE_CHOICES)) + raise ValueError(f"'search_scope' {search_scope} must be one if {SEARCH_SCOPE_CHOICES}") if contact_data_shape and contact_data_shape not in SHAPE_CHOICES: - raise ValueError("'shape' %s must be one if %s" % (contact_data_shape, SHAPE_CHOICES)) + raise ValueError(f"'shape' {contact_data_shape} must be one if {SHAPE_CHOICES}") self.return_full_contact_data = return_full_contact_data return self._elems_to_objs(self._chunked_get_elements( self.get_payload, @@ -73,7 +73,7 @@ def get_payload(self, unresolved_entries, parent_folders, return_full_contact_da raise NotImplementedError( "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later") attrs['ContactDataShape'] = contact_data_shape - payload = create_element('m:%s' % self.SERVICE_NAME, attrs=attrs) + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) if parent_folders: parentfolderids = create_element('m:ParentFolderIds') set_xml_value(parentfolderids, parent_folders, version=self.protocol.version) diff --git a/exchangelib/services/send_item.py b/exchangelib/services/send_item.py index 92ca8e36..067c0179 100644 --- a/exchangelib/services/send_item.py +++ b/exchangelib/services/send_item.py @@ -11,11 +11,11 @@ class SendItem(EWSAccountService): def call(self, items, saved_item_folder): from ..folders import BaseFolder, FolderId if saved_item_folder and not isinstance(saved_item_folder, (BaseFolder, FolderId)): - raise ValueError("'saved_item_folder' %r must be a Folder or FolderId instance" % saved_item_folder) + raise ValueError(f"'saved_item_folder' {saved_item_folder!r} must be a Folder or FolderId instance") return self._chunked_get_elements(self.get_payload, items=items, saved_item_folder=saved_item_folder) def get_payload(self, items, saved_item_folder): - senditem = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(SaveItemToFolder=bool(saved_item_folder))) + senditem = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(SaveItemToFolder=bool(saved_item_folder))) item_ids = create_item_ids_element(items=items, version=self.account.version) senditem.append(item_ids) if saved_item_folder: diff --git a/exchangelib/services/send_notification.py b/exchangelib/services/send_notification.py index da0e93d0..c83461f4 100644 --- a/exchangelib/services/send_notification.py +++ b/exchangelib/services/send_notification.py @@ -24,7 +24,7 @@ def _elems_to_objs(self, elems): @classmethod def _response_tag(cls): """Return the name of the element containing the service response.""" - return '{%s}%s' % (MNS, cls.SERVICE_NAME) + return f'{{{MNS}}}{cls.SERVICE_NAME}' @classmethod def _get_elements_in_container(cls, container): diff --git a/exchangelib/services/set_user_oof_settings.py b/exchangelib/services/set_user_oof_settings.py index cbdfb93a..f392cd86 100644 --- a/exchangelib/services/set_user_oof_settings.py +++ b/exchangelib/services/set_user_oof_settings.py @@ -14,13 +14,13 @@ class SetUserOofSettings(EWSAccountService): def call(self, oof_settings, mailbox): if not isinstance(oof_settings, OofSettings): - raise ValueError("'oof_settings' %r must be an OofSettings instance" % oof_settings) + raise ValueError(f"'oof_settings' {oof_settings!r} must be an OofSettings instance") if not isinstance(mailbox, Mailbox): - raise ValueError("'mailbox' %r must be an Mailbox instance" % mailbox) + raise ValueError(f"'mailbox' {mailbox!r} must be an Mailbox instance") return self._get_elements(payload=self.get_payload(oof_settings=oof_settings, mailbox=mailbox)) def get_payload(self, oof_settings, mailbox): - payload = create_element('m:%sRequest' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}Request') set_xml_value(payload, AvailabilityMailbox.from_mailbox(mailbox), version=self.account.version) set_xml_value(payload, oof_settings, version=self.account.version) return payload @@ -31,4 +31,4 @@ def _get_element_container(self, message, name=None): @classmethod def _response_message_tag(cls): - return '{%s}ResponseMessage' % MNS + return f'{{{MNS}}}ResponseMessage' diff --git a/exchangelib/services/subscribe.py b/exchangelib/services/subscribe.py index 7c4571c2..d231bfc7 100644 --- a/exchangelib/services/subscribe.py +++ b/exchangelib/services/subscribe.py @@ -24,7 +24,7 @@ class Subscribe(EWSAccountService, metaclass=abc.ABCMeta): def _partial_call(self, payload_func, folders, event_types, **kwargs): if set(event_types) - set(self.EVENT_TYPES): - raise ValueError("'event_types' values must consist of values in %s" % str(self.EVENT_TYPES)) + raise ValueError(f"'event_types' values must consist of values in {self.EVENT_TYPES}") return self._elems_to_objs(self._get_elements( payload=payload_func(folders=folders, event_types=event_types, **kwargs) )) @@ -39,7 +39,7 @@ def _elems_to_objs(self, elems): @classmethod def _get_elements_in_container(cls, container): - return [(container.find('{%s}SubscriptionId' % MNS), container.find('{%s}Watermark' % MNS))] + return [(container.find(f'{{{MNS}}}SubscriptionId'), container.find(f'{{{MNS}}}Watermark'))] def _partial_payload(self, folders, event_types): request_elem = create_element(self.subscription_request_elem_tag) @@ -65,7 +65,7 @@ def call(self, folders, event_types, watermark, timeout): ) def get_payload(self, folders, event_types, watermark, timeout): - subscribe = create_element('m:%s' % self.SERVICE_NAME) + subscribe = create_element(f'm:{self.SERVICE_NAME}') request_elem = self._partial_payload(folders=folders, event_types=event_types) if watermark: add_xml_child(request_elem, 'm:Watermark', watermark) @@ -84,7 +84,7 @@ def call(self, folders, event_types, watermark, status_frequency, url): ) def get_payload(self, folders, event_types, watermark, status_frequency, url): - subscribe = create_element('m:%s' % self.SERVICE_NAME) + subscribe = create_element(f'm:{self.SERVICE_NAME}') request_elem = self._partial_payload(folders=folders, event_types=event_types) if watermark: add_xml_child(request_elem, 'm:Watermark', watermark) @@ -110,10 +110,10 @@ def _elems_to_objs(self, elems): @classmethod def _get_elements_in_container(cls, container): - return [container.find('{%s}SubscriptionId' % MNS)] + return [container.find(f'{{{MNS}}}SubscriptionId')] def get_payload(self, folders, event_types): - subscribe = create_element('m:%s' % self.SERVICE_NAME) + subscribe = create_element(f'm:{self.SERVICE_NAME}') request_elem = self._partial_payload(folders=folders, event_types=event_types) subscribe.append(request_elem) return subscribe diff --git a/exchangelib/services/sync_folder_hierarchy.py b/exchangelib/services/sync_folder_hierarchy.py index 9e478eb7..3672e778 100644 --- a/exchangelib/services/sync_folder_hierarchy.py +++ b/exchangelib/services/sync_folder_hierarchy.py @@ -11,7 +11,7 @@ class SyncFolder(EWSAccountService, metaclass=abc.ABCMeta): """Base class for SyncFolderHierarchy and SyncFolderItems.""" - element_container_name = '{%s}Changes' % MNS + element_container_name = f'{{{MNS}}}Changes' # Change types CREATE = 'create' UPDATE = 'update' @@ -28,13 +28,13 @@ def __init__(self, *args, **kwargs): def _change_types_map(self): return { - '{%s}Create' % TNS: self.CREATE, - '{%s}Update' % TNS: self.UPDATE, - '{%s}Delete' % TNS: self.DELETE, + f'{{{TNS}}}Create': self.CREATE, + f'{{{TNS}}}Update': self.UPDATE, + f'{{{TNS}}}Delete': self.DELETE, } def _get_element_container(self, message, name=None): - self.sync_state = message.find('{%s}SyncState' % MNS).text + self.sync_state = message.find(f'{{{MNS}}}SyncState').text log.debug('Sync state is: %s', self.sync_state) self.includes_last_item_in_range = xml_text_to_value( message.find(self.last_in_range_name).text, bool @@ -43,7 +43,7 @@ def _get_element_container(self, message, name=None): return super()._get_element_container(message=message, name=name) def _partial_get_payload(self, folder, shape, additional_fields, sync_state): - svc_elem = create_element('m:%s' % self.SERVICE_NAME) + svc_elem = create_element(f'm:{self.SERVICE_NAME}') foldershape = create_shape_element( tag=self.shape_tag, shape=shape, additional_fields=additional_fields, version=self.account.version ) @@ -62,7 +62,7 @@ class SyncFolderHierarchy(SyncFolder): SERVICE_NAME = 'SyncFolderHierarchy' shape_tag = 'm:FolderShape' - last_in_range_name = '{%s}IncludesLastFolderInRange' % MNS + last_in_range_name = f'{{{MNS}}}IncludesLastFolderInRange' def call(self, folder, shape, additional_fields, sync_state): self.sync_state = sync_state diff --git a/exchangelib/services/sync_folder_items.py b/exchangelib/services/sync_folder_items.py index ae64c9aa..ae9bf609 100644 --- a/exchangelib/services/sync_folder_items.py +++ b/exchangelib/services/sync_folder_items.py @@ -18,11 +18,11 @@ class SyncFolderItems(SyncFolder): READ_FLAG_CHANGE = 'read_flag_change' CHANGE_TYPES = SyncFolder.CHANGE_TYPES + (READ_FLAG_CHANGE,) shape_tag = 'm:ItemShape' - last_in_range_name = '{%s}IncludesLastItemInRange' % MNS + last_in_range_name = f'{{{MNS}}}IncludesLastItemInRange' def _change_types_map(self): res = super()._change_types_map() - res['{%s}ReadFlagChange' % TNS] = self.READ_FLAG_CHANGE + res[f'{{{TNS}}}ReadFlagChange'] = self.READ_FLAG_CHANGE return res def call(self, folder, shape, additional_fields, sync_state, ignore, max_changes_returned, sync_scope): @@ -30,9 +30,9 @@ def call(self, folder, shape, additional_fields, sync_state, ignore, max_changes if max_changes_returned is None: max_changes_returned = self.chunk_size if max_changes_returned <= 0: - raise ValueError("'max_changes_returned' %s must be a positive integer" % max_changes_returned) + raise ValueError(f"'max_changes_returned' {max_changes_returned} must be a positive integer") if sync_scope is not None and sync_scope not in self.SYNC_SCOPES: - raise ValueError("'sync_scope' %s must be one of %r" % (sync_scope, self.SYNC_SCOPES)) + raise ValueError(f"'sync_scope' {sync_scope!r} must be one of {self.SYNC_SCOPES}") return self._elems_to_objs(self._get_elements(payload=self.get_payload( folder=folder, shape=shape, @@ -54,7 +54,7 @@ def _elems_to_objs(self, elems): if change_type == self.READ_FLAG_CHANGE: item = ( ItemId.from_xml(elem=elem.find(ItemId.response_tag()), account=self.account), - xml_text_to_value(elem.find('{%s}IsRead' % TNS).text, bool) + xml_text_to_value(elem.find(f'{{{TNS}}}IsRead').text, bool) ) elif change_type == self.DELETE: item = ItemId.from_xml(elem=elem.find(ItemId.response_tag()), account=self.account) diff --git a/exchangelib/services/unsubscribe.py b/exchangelib/services/unsubscribe.py index 784694aa..fff44ad4 100644 --- a/exchangelib/services/unsubscribe.py +++ b/exchangelib/services/unsubscribe.py @@ -16,6 +16,6 @@ def call(self, subscription_id): return self._get_elements(payload=self.get_payload(subscription_id=subscription_id)) def get_payload(self, subscription_id): - unsubscribe = create_element('m:%s' % self.SERVICE_NAME) + unsubscribe = create_element(f'm:{self.SERVICE_NAME}') add_xml_child(unsubscribe, 'm:SubscriptionId', subscription_id) return unsubscribe diff --git a/exchangelib/services/update_folder.py b/exchangelib/services/update_folder.py index 7b0417d8..692b5a16 100644 --- a/exchangelib/services/update_folder.py +++ b/exchangelib/services/update_folder.py @@ -7,7 +7,7 @@ class UpdateFolder(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updatefolder-operation""" SERVICE_NAME = 'UpdateFolder' - element_container_name = '{%s}Folders' % MNS + element_container_name = f'{{{MNS}}}Folders' def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -54,13 +54,13 @@ def _get_folder_update_elems(self, folder, fieldnames): for fieldname in self._sort_fieldnames(folder_model=folder_model, fieldnames=fieldnames_set): field = folder_model.get_field_by_fieldname(fieldname) if field.is_read_only: - raise ValueError('%s is a read-only field' % field.name) + raise ValueError(f'{field.name!r} is a read-only field') value = field.clean(getattr(folder, field.name), version=self.account.version) # Make sure the value is OK if value is None or (field.is_list and not value): # A value of None or [] means we want to remove this field from the item if field.is_required or field.is_required_after_save: - raise ValueError('%s is a required field and may not be deleted' % field.name) + raise ValueError(f'{field.name!r} is a required field and may not be deleted') for field_path in FieldPath(field=field).expand(version=self.account.version): yield self._delete_folder_elem(field_path=field_path) continue @@ -69,7 +69,7 @@ def _get_folder_update_elems(self, folder, fieldnames): def get_payload(self, folders): from ..folders import BaseFolder, FolderId - updatefolder = create_element('m:%s' % self.SERVICE_NAME) + updatefolder = create_element(f'm:{self.SERVICE_NAME}') folderchanges = create_element('m:FolderChanges') version = self.account.version for folder, fieldnames in folders: diff --git a/exchangelib/services/update_item.py b/exchangelib/services/update_item.py index 8e50ab4f..5d8f4ee5 100644 --- a/exchangelib/services/update_item.py +++ b/exchangelib/services/update_item.py @@ -10,26 +10,27 @@ class UpdateItem(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem-operation""" SERVICE_NAME = 'UpdateItem' - element_container_name = '{%s}Items' % MNS + element_container_name = f'{{{MNS}}}Items' def call(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, suppress_read_receipts): from ..items import CONFLICT_RESOLUTION_CHOICES, MESSAGE_DISPOSITION_CHOICES, \ SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, SEND_ONLY if conflict_resolution not in CONFLICT_RESOLUTION_CHOICES: - raise ValueError("'conflict_resolution' %s must be one of %s" % ( - conflict_resolution, CONFLICT_RESOLUTION_CHOICES - )) + raise ValueError( + f"'conflict_resolution' {conflict_resolution!r} must be one of {CONFLICT_RESOLUTION_CHOICES}" + ) if message_disposition not in MESSAGE_DISPOSITION_CHOICES: - raise ValueError("'message_disposition' %s must be one of %s" % ( - message_disposition, MESSAGE_DISPOSITION_CHOICES - )) + raise ValueError( + f"'message_disposition' {message_disposition!r} must be one of {MESSAGE_DISPOSITION_CHOICES}" + ) if send_meeting_invitations_or_cancellations not in SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES: - raise ValueError("'send_meeting_invitations_or_cancellations' %s must be one of %s" % ( - send_meeting_invitations_or_cancellations, SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES - )) + raise ValueError( + f"'send_meeting_invitations_or_cancellations' {send_meeting_invitations_or_cancellations!r} must be " + f"one of {SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES}" + ) if suppress_read_receipts not in (True, False): - raise ValueError("'suppress_read_receipts' %s must be True or False" % suppress_read_receipts) + raise ValueError(f"'suppress_read_receipts' {suppress_read_receipts!r} must be True or False") if message_disposition == SEND_ONLY: raise ValueError('Cannot send-only existing objects. Use SendItem service instead') return self._elems_to_objs(self._chunked_get_elements( @@ -73,8 +74,7 @@ def _sorted_fields(item_model, fieldnames): unique_fieldnames.remove(f.name) yield f if unique_fieldnames: - raise ValueError("Field name(s) %s are not valid for a '%s' item" % ( - ', '.join("'%s'" % f for f in unique_fieldnames), item_model.__name__)) + raise ValueError(f"Field name(s) {unique_fieldnames} are not valid for a {item_model.__name__!r} item") def _get_item_update_elems(self, item, fieldnames): from ..items import CalendarItem @@ -91,7 +91,7 @@ def _get_item_update_elems(self, item, fieldnames): for field in self._sorted_fields(item_model=item.__class__, fieldnames=fieldnames_copy): if field.is_read_only: - raise ValueError('%s is a read-only field' % field.name) + raise ValueError(f'{field.name} is a read-only field') value = self._get_item_value(item, field) if value is None or (field.is_list and not value): # A value of None or [] means we want to remove this field from the item @@ -114,7 +114,7 @@ def _get_item_value(self, item, field): def _get_delete_item_elems(self, field): if field.is_required or field.is_required_after_save: - raise ValueError('%s is a required field and may not be deleted' % field.name) + raise ValueError(f'{field.name!r} is a required field and may not be deleted') for field_path in FieldPath(field=field).expand(version=self.account.version): yield self._delete_item_elem(field_path=field_path) @@ -154,7 +154,7 @@ def get_payload(self, items, conflict_resolution, message_disposition, send_meet # an UpdateItem request. if self.account.version.build >= EXCHANGE_2013_SP1: updateitem = create_element( - 'm:%s' % self.SERVICE_NAME, + f'm:{self.SERVICE_NAME}', attrs=dict( ConflictResolution=conflict_resolution, MessageDisposition=message_disposition, @@ -164,7 +164,7 @@ def get_payload(self, items, conflict_resolution, message_disposition, send_meet ) else: updateitem = create_element( - 'm:%s' % self.SERVICE_NAME, + f'm:{self.SERVICE_NAME}', attrs=dict( ConflictResolution=conflict_resolution, MessageDisposition=message_disposition, diff --git a/exchangelib/services/update_user_configuration.py b/exchangelib/services/update_user_configuration.py index 26fd45db..df9d2231 100644 --- a/exchangelib/services/update_user_configuration.py +++ b/exchangelib/services/update_user_configuration.py @@ -14,6 +14,6 @@ def call(self, user_configuration): return self._get_elements(payload=self.get_payload(user_configuration=user_configuration)) def get_payload(self, user_configuration): - updateuserconfiguration = create_element('m:%s' % self.SERVICE_NAME) + updateuserconfiguration = create_element(f'm:{self.SERVICE_NAME}') set_xml_value(updateuserconfiguration, user_configuration, version=self.account.version) return updateuserconfiguration diff --git a/exchangelib/services/upload_items.py b/exchangelib/services/upload_items.py index 1025ca13..812b7c59 100644 --- a/exchangelib/services/upload_items.py +++ b/exchangelib/services/upload_items.py @@ -8,7 +8,7 @@ class UploadItems(EWSAccountService): """ SERVICE_NAME = 'UploadItems' - element_container_name = '{%s}ItemId' % MNS + element_container_name = f'{{{MNS}}}ItemId' def call(self, items): # _pool_requests expects 'items', not 'data' @@ -24,7 +24,7 @@ def get_payload(self, items): :param items: """ - uploaditems = create_element('m:%s' % self.SERVICE_NAME) + uploaditems = create_element(f'm:{self.SERVICE_NAME}') itemselement = create_element('m:Items') uploaditems.append(itemselement) for parent_folder, (item_id, is_associated, data_str) in items: diff --git a/exchangelib/settings.py b/exchangelib/settings.py index bb8e058f..1b89ce27 100644 --- a/exchangelib/settings.py +++ b/exchangelib/settings.py @@ -29,13 +29,13 @@ def clean(self, version=None): super().clean(version=version) if self.state == self.SCHEDULED: if not self.start or not self.end: - raise ValueError("'start' and 'end' must be set when state is '%s'" % self.SCHEDULED) + raise ValueError(f"'start' and 'end' must be set when state is {self.SCHEDULED!r}") if self.start >= self.end: raise ValueError("'start' must be before 'end'") if self.end < datetime.datetime.now(tz=UTC): raise ValueError("'end' must be in the future") if self.state != self.DISABLED and (not self.internal_reply or not self.external_reply): - raise ValueError("'internal_reply' and 'external_reply' must be set when state is not '%s'" % self.DISABLED) + raise ValueError(f"'internal_reply' and 'external_reply' must be set when state is not {self.DISABLED!r}") @classmethod def from_xml(cls, elem, account): @@ -49,7 +49,7 @@ def from_xml(cls, elem, account): def to_xml(self, version): self.clean(version=version) - elem = create_element('t:%s' % self.REQUEST_ELEMENT_NAME) + elem = create_element(f't:{self.REQUEST_ELEMENT_NAME}') for attr in ('state', 'external_audience'): value = getattr(self, attr) if value is None: diff --git a/exchangelib/transport.py b/exchangelib/transport.py index 3867e50a..04b64cfe 100644 --- a/exchangelib/transport.py +++ b/exchangelib/transport.py @@ -46,7 +46,7 @@ pass DEFAULT_ENCODING = 'utf-8' -DEFAULT_HEADERS = {'Content-Type': 'text/xml; charset=%s' % DEFAULT_ENCODING, 'Accept-Encoding': 'gzip, deflate'} +DEFAULT_HEADERS = {'Content-Type': f'text/xml; charset={DEFAULT_ENCODING}', 'Accept-Encoding': 'gzip, deflate'} def wrap(content, api_version, account_to_impersonate=None, timezone=None): @@ -84,7 +84,7 @@ def wrap(content, api_version, account_to_impersonate=None, timezone=None): ): val = getattr(account_to_impersonate, attr) if val: - add_xml_child(connectingsid, 't:%s' % tag, val) + add_xml_child(connectingsid, f't:{tag}', val) break exchangeimpersonation.append(connectingsid) header.append(exchangeimpersonation) diff --git a/exchangelib/util.py b/exchangelib/util.py index 3bfcfe2a..afeeff31 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -26,14 +26,14 @@ from .errors import TransportError, RateLimitError, RedirectError, RelativeRedirect, MalformedResponseError log = logging.getLogger(__name__) -xml_log = logging.getLogger('%s.xml' % __name__) +xml_log = logging.getLogger(f'{__name__}.xml') def require_account(f): @wraps(f) def wrapper(self, *args, **kwargs): if not self.account: - raise ValueError('%s must have an account' % self.__class__.__name__) + raise ValueError(f'{self.__class__.__name__} must have an account') return f(self, *args, **kwargs) return wrapper @@ -42,9 +42,9 @@ def require_id(f): @wraps(f) def wrapper(self, *args, **kwargs): if not self.account: - raise ValueError('%s must have an account' % self.__class__.__name__) + raise ValueError(f'{self.__class__.__name__} must have an account') if not self.id: - raise ValueError('%s must have an ID' % self.__class__.__name__) + raise ValueError(f'{self.__class__.__name__} must have an ID') return f(self, *args, **kwargs) return wrapper @@ -202,7 +202,7 @@ def value_to_xml_text(value): return value.id if isinstance(value, AssociatedCalendarItemId): return value.id - raise TypeError('Unsupported type: %s (%s)' % (type(value), value)) + raise TypeError(f'Unsupported type: {type(value)} ({value})') def xml_text_to_value(value, value_type): @@ -244,22 +244,22 @@ def set_xml_value(elem, value, version): elem.append(v.to_xml()) elif isinstance(v, EWSElement): if not isinstance(version, Version): - raise ValueError("'version' %r must be a Version instance" % version) + raise ValueError(f"'version' {version!r} must be a Version instance") elem.append(v.to_xml(version=version)) elif isinstance(v, _element_class): elem.append(v) elif isinstance(v, str): add_xml_child(elem, 't:String', v) else: - raise ValueError('Unsupported type %s for list element %s on elem %s' % (type(v), v, elem)) + raise ValueError(f'Unsupported type {type(v)} for list element {v} on elem {elem}') elif isinstance(value, (FieldPath, FieldOrder)): elem.append(value.to_xml()) elif isinstance(value, EWSElement): if not isinstance(version, Version): - raise ValueError("'version' %r must be a Version instance" % version) + raise ValueError(f"'version' {version!r} must be a Version instance") elem.append(value.to_xml(version=version)) else: - raise ValueError('Unsupported type %s for value %s on elem %s' % (type(value), value, elem)) + raise ValueError(f'Unsupported type {type(value)} for value {value} on elem {elem}') return elem @@ -270,7 +270,7 @@ def safe_xml_value(value, replacement='?'): def create_element(name, attrs=None, nsmap=None): if ':' in name: ns, name = name.split(':') - name = '{%s}%s' % (ns_translation[ns], name) + name = f'{{{ns_translation[ns]}}}{name}' elem = _forgiving_parser.makeelement(name, nsmap=nsmap) if attrs: # Try hard to keep attribute order, to ensure deterministic output. This simplifies testing. @@ -453,8 +453,8 @@ class DocumentYielder: def __init__(self, content_iterator, document_tag='Envelope'): self._iterator = content_iterator - self._start_token = b'<%s' % document_tag.encode('utf-8') - self._end_token = b'/%s>' % document_tag.encode('utf-8') + self._start_token = f'<{document_tag}'.encode('utf-8') + self._end_token = f'/{document_tag}>'.encode('utf-8') def get_tag(self, stop_byte): tag_buffer = [b'<'] @@ -488,7 +488,7 @@ def __iter__(self): buffer.append(tag) if tag.endswith(self._end_token): # End of document. Yield a valid document and reset the buffer - yield b"\n%s" % b''.join(buffer) + yield b"\n" + b''.join(buffer) doc_started = False buffer = [] elif doc_started: @@ -520,19 +520,19 @@ def to_xml(bytes_content): raise ParseError(str(e), '', e.lineno, e.offset) else: offending_excerpt = offending_line[max(0, e.offset - 20):e.offset + 20] - msg = '%s\nOffending text: [...]%s[...]' % (str(e), offending_excerpt) + msg = f'{e}\nOffending text: [...]{offending_excerpt}[...]' raise ParseError(msg, '', e.lineno, e.offset) except TypeError: try: stream.seek(0) except (IndexError, io.UnsupportedOperation): pass - raise ParseError('This is not XML: %r' % stream.read(), '', -1, 0) + raise ParseError(f'This is not XML: {stream.read()!r}', '', -1, 0) if res.getroot() is None: try: stream.seek(0) - msg = 'No root element found: %r' % stream.read() + msg = f'No root element found: {stream.read()!r}' except (IndexError, io.UnsupportedOperation): msg = 'No root element found' raise ParseError(msg, '', -1, 0) @@ -598,7 +598,7 @@ def emit(self, record): record.args[key] = self.highlight_xml(self.prettify_xml(value)) except Exception as e: # Something bad happened, but we don't want to crash the program just because logging failed - print('XML highlighting failed: %s' % e) + print(f'XML highlighting failed: {e}') return super().emit(record) def is_tty(self): @@ -659,7 +659,7 @@ def get_domain(email): try: return email.split('@')[1].lower() except (IndexError, AttributeError): - raise ValueError("'%s' is not a valid email" % email) + raise ValueError(f"{email!r} is not a valid email") def split_url(url): @@ -688,10 +688,10 @@ def get_redirect_url(response, allow_relative=True, require_relative=False): if not redirect_path.startswith('/'): # The path is not top-level. Add response path redirect_path = (response_path or '/') + redirect_path - redirect_url = '%s://%s%s' % ('https' if redirect_has_ssl else 'http', redirect_server, redirect_path) + redirect_url = f"{'https' if redirect_has_ssl else 'http'}://{redirect_server}{redirect_path}" if redirect_url == request_url: # And some are mean enough to redirect to the same location - raise TransportError('Redirect to same location: %s' % redirect_url) + raise TransportError(f'Redirect to same location: {redirect_url}') if not allow_relative and (request_has_ssl == response_has_ssl and request_server == redirect_server): raise RelativeRedirect(redirect_url) if require_relative and (request_has_ssl != response_has_ssl or request_server != redirect_server): @@ -911,7 +911,7 @@ def _redirect_or_fail(response, redirects, allow_redirects): log.debug("'allow_redirects' only supports relative redirects (%s -> %s)", response.url, e.value) raise RedirectError(url=e.value) if not allow_redirects: - raise TransportError('Redirect not allowed but we were redirected (%s -> %s)' % (response.url, redirect_url)) + raise TransportError(f'Redirect not allowed but we were redirected ({response.url} -> {redirect_url})') log.debug('HTTP redirected to %s', redirect_url) redirects += 1 if redirects > MAX_REDIRECTS: diff --git a/exchangelib/version.py b/exchangelib/version.py index 8dadec36..2593b7e3 100644 --- a/exchangelib/version.py +++ b/exchangelib/version.py @@ -78,7 +78,7 @@ def __init__(self, major_version, minor_version, major_build=0, minor_build=0): self.major_build = major_build self.minor_build = minor_build if major_version < 8: - raise ValueError("Exchange major versions below 8 don't support EWS (%s)" % self) + raise ValueError(f"Exchange major versions below 8 don't support EWS ({self})") @classmethod def from_xml(cls, elem): @@ -110,7 +110,7 @@ def from_hex_string(cls, s): :param s: """ - bin_s = '{:032b}'.format(int(s, 16)) # Convert string to 32-bit binary string + bin_s = f'{int(s, 16):032b}' # Convert string to 32-bit binary string major_version = int(bin_s[4:10], 2) minor_version = int(bin_s[10:16], 2) build_number = int(bin_s[17:32], 2) @@ -122,7 +122,7 @@ def api_version(self): try: return self.API_VERSION_MAP[self.major_version][self.minor_version] except KeyError: - raise ValueError('API version for build %s is unknown' % self) + raise ValueError(f'API version for build {self} is unknown') def fullname(self): return VERSIONS[self.api_version()][1] @@ -162,7 +162,7 @@ def __ge__(self, other): return self.__cmp__(other) >= 0 def __str__(self): - return '%s.%s.%s.%s' % (self.major_version, self.minor_version, self.major_build, self.minor_build) + return f'{self.major_version}.{self.minor_version}.{self.major_build}.{self.minor_build}' def __repr__(self): return self.__class__.__name__ \ @@ -230,7 +230,7 @@ def guess(cls, protocol, api_version_hint=None): except ResponseMessageError as e: # We may have survived long enough to get a new version if not protocol.config.version.build: - raise TransportError('No valid version headers found in response (%r)' % e) + raise TransportError(f'No valid version headers found in response ({e!r})') if not protocol.config.version.build: raise TransportError('No valid version headers found in response') return protocol.version @@ -242,13 +242,13 @@ def _is_invalid_version_string(version): @classmethod def from_soap_header(cls, requested_api_version, header): - info = header.find('{%s}ServerVersionInfo' % TNS) + info = header.find(f'{{{TNS}}}ServerVersionInfo') if info is None: - raise TransportError('No ServerVersionInfo in header: %r' % xml_to_str(header)) + raise TransportError(f'No ServerVersionInfo in header: {xml_to_str(header)!r}') try: build = Build.from_xml(elem=info) except ValueError: - raise TransportError('Bad ServerVersionInfo in response: %r' % xml_to_str(header)) + raise TransportError(f'Bad ServerVersionInfo in response: {xml_to_str(header)!r}') # Not all Exchange servers send the Version element api_version_from_server = info.get('Version') or build.api_version() if api_version_from_server != requested_api_version: @@ -277,4 +277,4 @@ def __repr__(self): return self.__class__.__name__ + repr((self.build, self.api_version)) def __str__(self): - return 'Build=%s, API=%s, Fullname=%s' % (self.build, self.api_version, self.fullname) + return f'Build={self.build}, API={self.api_version}, Fullname={self.fullname}' diff --git a/exchangelib/winzone.py b/exchangelib/winzone.py index ccea299f..94097a86 100644 --- a/exchangelib/winzone.py +++ b/exchangelib/winzone.py @@ -21,7 +21,7 @@ def generate_map(timeout=10): """ r = requests.get(CLDR_WINZONE_URL, timeout=timeout) if r.status_code != 200: - raise ValueError('Unexpected response: %s' % r) + raise ValueError(f'Unexpected response: {r}') tz_map = {} timezones_elem = to_xml(r.content).find('windowsZones').find('mapTimezones') type_version = timezones_elem.get('typeVersion') diff --git a/scripts/notifier.py b/scripts/notifier.py index 3357371a..8e6a10bc 100644 --- a/scripts/notifier.py +++ b/scripts/notifier.py @@ -73,18 +73,14 @@ if msg.start < now: continue minutes_to_appointment = int((msg.start - now).total_seconds() / 60) - subj = 'You have a meeting in %s minutes' % minutes_to_appointment - body = '%s-%s: %s\n%s' % ( - msg.start.astimezone(tz).strftime('%H:%M'), - msg.end.astimezone(tz).strftime('%H:%M'), - msg.subject[:150], - msg.location - ) + subj = f'You have a meeting in {minutes_to_appointment} minutes' + body = f"{msg.start.astimezone(tz).strftime('%H:%M')}-{msg.end.astimezone(tz).strftime('%H:%M')}: " \ + f"{msg.subject[:150]}\n{msg.location}" zenity(**{'info': None, 'no-markup': None, 'title': subj, 'text': body}) for msg in a.inbox.filter(datetime_received__gt=emails_since, is_read=False)\ .only('datetime_received', 'subject', 'text_body')\ .order_by('datetime_received')[:10]: - subj = 'New mail: %s' % msg.subject + subj = f'New mail: {msg.subject}' clean_body = '\n'.join(line for line in msg.text_body.split('\n') if line) notify(subj, clean_body[:200]) diff --git a/scripts/optimize.py b/scripts/optimize.py index c785df30..b60e7cb7 100755 --- a/scripts/optimize.py +++ b/scripts/optimize.py @@ -37,7 +37,7 @@ credentials=Credentials(settings['username'], settings['password']), retry_policy=FaultTolerance(), ) -print('Exchange server: %s' % config.service_endpoint) +print(f'Exchange server: {config.service_endpoint}') account = Account(config=config, primary_smtp_address=settings['account'], access_type=DELEGATE) @@ -52,14 +52,14 @@ def generate_items(count): tpl_item = CalendarItem( start=start, end=end, - body='This is a performance optimization test of server %s intended to find the optimal batch size and ' - 'concurrent connection pool size of this server.' % account.protocol.server, + body=f'This is a performance optimization test of server {account.protocol.server} intended to find the ' + f'optimal batch size and concurrent connection pool size of this server.', location="It's safe to delete this", categories=categories, ) for j in range(count): item = copy.copy(tpl_item) - item.subject = 'Performance optimization test %s by exchangelib' % j, + item.subject = f'Performance optimization test {j} by exchangelib', yield item @@ -75,8 +75,8 @@ def test(items, chunk_size): rate1 = len(ids) / delta1 delta2 = t3 - t2 rate2 = len(ids) / delta2 - print(('Time to process %s items (batchsize %s, poolsize %s): %s / %s (%s / %s per sec)' % ( - len(ids), chunk_size, account.protocol.poolsize, delta1, delta2, rate1, rate2))) + print(f'Time to process {len(ids)} items (batchsize {chunk_size}, poolsize {account.protocol.poolsize}): ' + f'{delta1} / {delta2} ({rate1} / {rate2} per sec)') # Generate items diff --git a/tests/common.py b/tests/common.py index 2af0d222..3f3861e7 100644 --- a/tests/common.py +++ b/tests/common.py @@ -61,7 +61,7 @@ def setUp(self): def tearDown(self): t2 = time.monotonic() - self.t1 if t2 > self.SLOW_TEST_DURATION: - print("{:07.3f} : {}".format(t2, self.id())) + print(f"{t2:07.3f} : {self.id()}") class EWSTest(TimedTestCase, metaclass=abc.ABCMeta): @@ -76,9 +76,9 @@ def setUpClass(cls): with open(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'settings.yml')) as f: settings = safe_load(f) except FileNotFoundError: - print('Skipping %s - no settings.yml file found' % cls.__name__) + print(f'Skipping {cls.__name__} - no settings.yml file found') print('Copy settings.yml.sample to settings.yml and enter values for your test server') - raise unittest.SkipTest('Skipping %s - no settings.yml file found' % cls.__name__) + raise unittest.SkipTest(f'Skipping {cls.__name__} - no settings.yml file found') cls.settings = settings cls.verify_ssl = settings.get('verify_ssl', True) @@ -127,7 +127,7 @@ def random_val(self, field): # In the test_extended_distinguished_property test, EWS rull return 4 NULL bytes after char 16 if we # send a longer bytes sequence. return get_random_string(16).encode() - raise ValueError('Unsupported field %s' % field) + raise ValueError(f'Unsupported field {field}') if isinstance(field, URIField): return get_random_url() if isinstance(field, EmailAddressField): @@ -258,7 +258,7 @@ def random_val(self, field): ) ] ) - raise ValueError('Unknown field %s' % field) + raise ValueError(f'Unknown field {field}') def get_random_bool(): @@ -272,7 +272,7 @@ def get_random_int(min_val=0, max_val=2147483647): def get_random_decimal(min_val=0, max_val=100): precision = 2 val = get_random_int(min_val, max_val * 10**precision) / 10.0**precision - return Decimal('{:.2f}'.format(val)) + return Decimal(f'{val:.2f}') def get_random_choice(choices): diff --git a/tests/test_account.py b/tests/test_account.py index 8a925b92..01f40ec1 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -208,7 +208,7 @@ def may_retry_on_error(self, response, wait): def raise_response_errors(self, response): if response.status_code == 401: - raise UnauthorizedError('Invalid credentials for %s' % response.url) + raise UnauthorizedError(f'Invalid credentials for {response.url}') return super().raise_response_errors(response) try: diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 69cbab65..b855a9a5 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -32,26 +32,26 @@ def setUp(self): # Some mocking helpers self.domain = get_domain(self.account.primary_smtp_address) - self.dummy_ad_endpoint = 'https://%s/Autodiscover/Autodiscover.xml' % self.domain + self.dummy_ad_endpoint = f'https://{self.domain}/Autodiscover/Autodiscover.xml' self.dummy_ews_endpoint = 'https://expr.example.com/EWS/Exchange.asmx' - self.dummy_ad_response = b'''\ + self.dummy_ad_response = f'''\ - %s + {self.account.primary_smtp_address} email settings EXPR - %s + {self.dummy_ews_endpoint} -''' % (self.account.primary_smtp_address.encode(), self.dummy_ews_endpoint.encode()) - self.dummy_ews_response = b'''\ +'''.encode() + self.dummy_ews_response = '''\ @@ -72,7 +72,7 @@ def setUp(self): -''' +'''.encode() @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here def test_magic(self, m): @@ -105,9 +105,9 @@ def test_autodiscover_empty_cache(self): def test_autodiscover_failure(self): # A live test that errors can be raised. Here, we try to aútodiscover a non-existing email address if not self.settings.get('autodiscover_server'): - self.skipTest("Skipping %s - no 'autodiscover_server' entry in settings.yml" % self.__class__.__name__) + self.skipTest(f"Skipping {self.__class__.__name__} - no 'autodiscover_server' entry in settings.yml") # Autodiscovery may take a long time. Prime the cache with the autodiscover server from the config file - ad_endpoint = 'https://%s/Autodiscover/Autodiscover.xml' % self.settings['autodiscover_server'] + ad_endpoint = f"https://{self.settings['autodiscover_server']}/Autodiscover/Autodiscover.xml" cache_key = (self.domain, self.account.protocol.credentials) autodiscover_cache[cache_key] = AutodiscoverProtocol(config=Configuration( service_endpoint=ad_endpoint, @@ -311,57 +311,57 @@ def test_autodiscover_redirect(self, m): # Make sure we discover an address redirect to the same domain. We have to mock the same URL with two different # responses. We do that with a response list. - redirect_addr_content = b'''\ + redirect_addr_content = f'''\ redirectAddr - redirect_me@%s + redirect_me@{self.domain} -''' % self.domain.encode() - settings_content = b'''\ +'''.encode() + settings_content = f'''\ - redirected@%s + redirected@{self.domain} email settings EXPR - https://redirected.%s/EWS/Exchange.asmx + https://redirected.{self.domain}/EWS/Exchange.asmx -''' % (self.domain.encode(), self.domain.encode()) +'''.encode() # Also mock the EWS URL. We try to guess its auth method as part of autodiscovery - m.post('https://redirected.%s/EWS/Exchange.asmx' % self.domain, status_code=200) + m.post(f'https://redirected.{self.domain}/EWS/Exchange.asmx', status_code=200) m.post(self.dummy_ad_endpoint, [ dict(status_code=200, content=redirect_addr_content), dict(status_code=200, content=settings_content), ]) ad_response, _ = discovery.discover() - self.assertEqual(ad_response.autodiscover_smtp_address, 'redirected@%s' % self.domain) - self.assertEqual(ad_response.ews_url, 'https://redirected.%s/EWS/Exchange.asmx' % self.domain) + self.assertEqual(ad_response.autodiscover_smtp_address, f'redirected@{self.domain}') + self.assertEqual(ad_response.ews_url, f'https://redirected.{self.domain}/EWS/Exchange.asmx') # Test that we catch circular redirects on the same domain with a primed cache. Just mock the endpoint to # return the same redirect response on every request. self.assertEqual(len(autodiscover_cache), 1) - m.post(self.dummy_ad_endpoint, status_code=200, content=b'''\ + m.post(self.dummy_ad_endpoint, status_code=200, content=f'''\ redirectAddr - foo@%s + foo@{self.domain} -''' % self.domain.encode()) +'''.encode()) self.assertEqual(len(autodiscover_cache), 1) with self.assertRaises(AutoDiscoverCircularRedirect): discovery.discover() diff --git a/tests/test_configuration.py b/tests/test_configuration.py index d95d7feb..6350594d 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -25,7 +25,7 @@ def test_init(self): Configuration(auth_type='foo') self.assertEqual( e.exception.args[0], - "'auth_type' 'foo' must be one of %s" % ', '.join("'%s'" % k for k in sorted(AUTH_TYPE_MAP)) + f"'auth_type' 'foo' must be one of {sorted(AUTH_TYPE_MAP)}" ) with self.assertRaises(ValueError) as e: Configuration(version='foo') diff --git a/tests/test_ewsdatetime.py b/tests/test_ewsdatetime.py index f19f3e76..27442ced 100644 --- a/tests/test_ewsdatetime.py +++ b/tests/test_ewsdatetime.py @@ -72,7 +72,7 @@ def test_ewstimezone(self): with self.assertRaises(UnknownTimeZone) as e: del EWSTimeZone.IANA_TO_MS_MAP['Africa/Tripoli'] EWSTimeZone('Africa/Tripoli') - self.assertEqual(e.exception.args[0], 'No Windows timezone name found for timezone "Africa/Tripoli"') + self.assertEqual(e.exception.args[0], "No Windows timezone name found for timezone 'Africa/Tripoli'") # Test __eq__ with non-EWSTimeZone compare self.assertFalse(EWSTimeZone('GMT') == zoneinfo.ZoneInfo('UTC')) diff --git a/tests/test_extended_properties.py b/tests/test_extended_properties.py index 81fe4fea..b8db63d3 100644 --- a/tests/test_extended_properties.py +++ b/tests/test_extended_properties.py @@ -313,7 +313,7 @@ class TestArayProp(ExtendedProperty): item = self.get_test_item(folder=self.test_folder).save() self.assertEqual(self.test_folder.filter(**{attr_name: getattr(item, attr_name)}).count(), 1) self.assertEqual( - self.test_folder.filter(**{'%s__contains' % array_attr_name: getattr(item, array_attr_name)}).count(), + self.test_folder.filter(**{f'{array_attr_name}__contains': getattr(item, array_attr_name)}).count(), 1 ) finally: diff --git a/tests/test_field.py b/tests/test_field.py index eaca5c6e..1c6cdd30 100644 --- a/tests/test_field.py +++ b/tests/test_field.py @@ -53,12 +53,15 @@ def test_value_validation(self): field = DateTimeField('foo', field_uri='bar') with self.assertRaises(ValueError) as e: field.clean(datetime.datetime(2017, 1, 1)) # Datetime values must be timezone aware - self.assertEqual(str(e.exception), "Value '2017-01-01 00:00:00' on field 'foo' must be timezone aware") + self.assertEqual( + str(e.exception), + "Value datetime.datetime(2017, 1, 1, 0, 0) on field 'foo' must be timezone aware" + ) field = ChoiceField('foo', field_uri='bar', choices=[Choice('foo'), Choice('bar')]) with self.assertRaises(ValueError) as e: field.clean('XXX') # Value must be a valid choice - self.assertEqual(str(e.exception), "Invalid choice 'XXX' for field 'foo'. Valid choices are: foo, bar") + self.assertEqual(str(e.exception), "Invalid choice 'XXX' for field 'foo'. Valid choices are: ['foo', 'bar']") # A few tests on extended properties that override base methods field = ExtendedPropertyField('foo', value_cls=ExternId, is_required=True) @@ -119,13 +122,13 @@ class ExternIdArray(ExternId): field = EnumListField('foo', field_uri='bar', enum=['a', 'b', 'c']) with self.assertRaises(ValueError)as e: field.clean([]) - self.assertEqual(str(e.exception), "Value '[]' on field 'foo' must not be empty") + self.assertEqual(str(e.exception), "Value [] on field 'foo' must not be empty") with self.assertRaises(ValueError) as e: field.clean([0]) self.assertEqual(str(e.exception), "Value 0 on field 'foo' must be greater than 1") with self.assertRaises(ValueError) as e: field.clean([1, 1]) # Values must be unique - self.assertEqual(str(e.exception), "List entries '[1, 1]' on field 'foo' must be unique") + self.assertEqual(str(e.exception), "List entries [1, 1] on field 'foo' must be unique") with self.assertRaises(ValueError) as e: field.clean(['d']) self.assertEqual(str(e.exception), "List value 'd' on field 'foo' must be one of ['a', 'b', 'c']") @@ -141,7 +144,7 @@ def test_garbage_input(self): THIS_IS_GARBAGE ''' - elem = to_xml(payload).find('{%s}Item' % TNS) + elem = to_xml(payload).find(f'{{{TNS}}}Item') for field_cls in (Base64Field, BooleanField, IntegerField, DateField, DateTimeField, DecimalField): field = field_cls('foo', field_uri='item:Foo', is_required=True, default='DUMMY') self.assertEqual(field.from_xml(elem=elem, account=account), None) @@ -154,7 +157,7 @@ def test_garbage_input(self): ''' - elem = to_xml(payload).find('{%s}Item' % TNS) + elem = to_xml(payload).find(f'{{{TNS}}}Item') field = TimeZoneField('foo', field_uri='item:Foo', default='DUMMY') self.assertEqual(field.from_xml(elem=elem, account=account), None) @@ -193,7 +196,7 @@ def test_naive_datetime(self): 2017-06-21T18:40:02Z ''' - elem = to_xml(payload).find('{%s}Item' % TNS) + elem = to_xml(payload).find(f'{{{TNS}}}Item') self.assertEqual( field.from_xml(elem=elem, account=account), datetime.datetime(2017, 6, 21, 18, 40, 2, tzinfo=utc) ) @@ -206,7 +209,7 @@ def test_naive_datetime(self): 2017-06-21T18:40:02 ''' - elem = to_xml(payload).find('{%s}Item' % TNS) + elem = to_xml(payload).find(f'{{{TNS}}}Item') self.assertEqual( field.from_xml(elem=elem, account=account), datetime.datetime(2017, 6, 21, 18, 40, 2, tzinfo=tz) ) @@ -219,7 +222,7 @@ def test_naive_datetime(self): THIS_IS_GARBAGE ''' - elem = to_xml(payload).find('{%s}Item' % TNS) + elem = to_xml(payload).find(f'{{{TNS}}}Item') self.assertEqual(field.from_xml(elem=elem, account=account), None) # Element not found returns default value @@ -229,7 +232,7 @@ def test_naive_datetime(self): ''' - elem = to_xml(payload).find('{%s}Item' % TNS) + elem = to_xml(payload).find(f'{{{TNS}}}Item') self.assertEqual(field.from_xml(elem=elem, account=account), default_value) def test_single_field_indexed_element(self): diff --git a/tests/test_folder.py b/tests/test_folder.py index 2be3fe37..9be59903 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -198,7 +198,7 @@ def test_counts(self): # Create some items items = [] for i in range(3): - subject = 'Test Subject %s' % i + subject = f'Test Subject {i}' item = Message(account=self.account, folder=f, is_read=False, subject=subject, categories=self.categories) item.save() items.append(item) @@ -319,8 +319,8 @@ def test_glob(self): self.assertEqual(len(list(self.account.contacts.glob('gal*'))), 1) # Test case-insensitivity self.assertGreaterEqual(len(list(self.account.contacts.glob('/'))), 5) self.assertGreaterEqual(len(list(self.account.contacts.glob('../*'))), 5) - self.assertEqual(len(list(self.account.root.glob('**/%s' % self.account.contacts.name))), 1) - self.assertEqual(len(list(self.account.root.glob('Top of*/%s' % self.account.contacts.name))), 1) + self.assertEqual(len(list(self.account.root.glob(f'**/{self.account.contacts.name}'))), 1) + self.assertEqual(len(list(self.account.root.glob(f'Top of*/{self.account.contacts.name}'))), 1) def test_collection_filtering(self): self.assertGreaterEqual(self.account.root.tois.children.all().count(), 0) @@ -575,7 +575,7 @@ def test_user_configuration(self): get_random_datetime(tz=self.account.default_timezone): get_random_string(8), get_random_str_tuple(4, 4): get_random_datetime(tz=self.account.default_timezone), } - xml_data = b'%s' % get_random_string(16).encode('utf-8') + xml_data = f'{get_random_string(16)}'.encode('utf-8') binary_data = get_random_bytes(100) f.create_user_configuration(name=name, dictionary=dictionary, xml_data=xml_data, binary_data=binary_data) diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index d5d0e77b..f9db3abd 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -240,7 +240,7 @@ def test_queryset_nonsearchable_fields(self): continue try: filter_val = f.clean(self.random_val(f)) - filter_kwargs = {'%s__in' % f.name: filter_val} if f.is_list else {f.name: filter_val} + filter_kwargs = {f'{f.name}__in': filter_val} if f.is_list else {f.name: filter_val} # We raise ValueError when searching on an is_searchable=False field with self.assertRaises(ValueError): @@ -299,7 +299,7 @@ def _run_filter_tests(self, qs, f, filter_kwargs, val): break time.sleep(2) matches = qs.filter(**kw).count() - if f.is_list and not val and list(kw)[0].endswith('__%s' % Q.LOOKUP_IN): + if f.is_list and not val and list(kw)[0].endswith(f'__{Q.LOOKUP_IN}'): # __in with an empty list returns an empty result self.assertEqual(matches, 0, (f.name, val, kw)) else: @@ -320,12 +320,12 @@ def test_filter_on_simple_fields(self): for f in fields: val = getattr(item, f.name) # Filter with =, __in and __contains. We could have more filters here, but these should always match. - filter_kwargs = [{f.name: val}, {'%s__in' % f.name: [val]}] + filter_kwargs = [{f.name: val}, {f'{f.name}__in': [val]}] if isinstance(f, TextField) and not isinstance(f, ChoiceField): # Choice fields cannot be filtered using __contains. Sort of makes sense. random_start = get_random_int(min_val=0, max_val=len(val)//2) random_end = get_random_int(min_val=len(val)//2+1, max_val=len(val)) - filter_kwargs.append({'%s__contains' % f.name: val[random_start:random_end]}) + filter_kwargs.append({f'{f.name}__contains': val[random_start:random_end]}) self._run_filter_tests(common_qs, f, filter_kwargs, val) def test_filter_on_list_fields(self): @@ -347,7 +347,7 @@ def test_filter_on_list_fields(self): for f in fields: val = getattr(item, f.name) # Filter multi-value fields with =, __in and __contains - filter_kwargs = [{'%s__in' % f.name: val}, {'%s__contains' % f.name: val}] + filter_kwargs = [{f'{f.name}__in': val}, {f'{f.name}__contains': val}] self._run_filter_tests(common_qs, f, filter_kwargs, val) def test_filter_on_single_field_index_fields(self): @@ -374,7 +374,7 @@ def test_filter_on_single_field_index_fields(self): continue filter_kwargs.extend([ {f.name: v}, {path: subval}, - {'%s__in' % path: [subval]}, {'%s__contains' % path: [subval]} + {f'{path}__in': [subval]}, {f'{path}__contains': [subval]} ]) self._run_filter_tests(common_qs, f, filter_kwargs, val) @@ -403,7 +403,7 @@ def test_filter_on_multi_field_index_fields(self): if subval is None: continue filter_kwargs.extend([ - {path: subval}, {'%s__in' % path: [subval]}, {'%s__contains' % path: [subval]} + {path: subval}, {f'{path}__in': [subval]}, {f'{path}__contains': [subval]} ]) self._run_filter_tests(common_qs, f, filter_kwargs, val) diff --git a/tests/test_items/test_contacts.py b/tests/test_items/test_contacts.py index 4e77130f..1cc91029 100644 --- a/tests/test_items/test_contacts.py +++ b/tests/test_items/test_contacts.py @@ -27,17 +27,17 @@ def test_order_by_on_indexed_field(self): label = self.random_val(EmailAddress.get_field_by_fieldname('label')) for i in range(4): item = self.get_test_item() - item.email_addresses = [EmailAddress(email='%s@foo.com' % i, label=label)] + item.email_addresses = [EmailAddress(email=f'{i}@foo.com', label=label)] test_items.append(item) self.test_folder.bulk_create(items=test_items) qs = self.test_folder.filter(categories__contains=self.categories) self.assertEqual( - [i[0].email for i in qs.order_by('email_addresses__%s' % label) + [i[0].email for i in qs.order_by(f'email_addresses__{label}') .values_list('email_addresses', flat=True)], ['0@foo.com', '1@foo.com', '2@foo.com', '3@foo.com'] ) self.assertEqual( - [i[0].email for i in qs.order_by('-email_addresses__%s' % label) + [i[0].email for i in qs.order_by(f'-email_addresses__{label}') .values_list('email_addresses', flat=True)], ['3@foo.com', '2@foo.com', '1@foo.com', '0@foo.com'] ) @@ -47,17 +47,17 @@ def test_order_by_on_indexed_field(self): label = self.random_val(PhysicalAddress.get_field_by_fieldname('label')) for i in range(4): item = self.get_test_item() - item.physical_addresses = [PhysicalAddress(street='Elm St %s' % i, label=label)] + item.physical_addresses = [PhysicalAddress(street=f'Elm St {i}', label=label)] test_items.append(item) self.test_folder.bulk_create(items=test_items) qs = self.test_folder.filter(categories__contains=self.categories) self.assertEqual( - [i[0].street for i in qs.order_by('physical_addresses__%s__street' % label) + [i[0].street for i in qs.order_by(f'physical_addresses__{label}__street') .values_list('physical_addresses', flat=True)], ['Elm St 0', 'Elm St 1', 'Elm St 2', 'Elm St 3'] ) self.assertEqual( - [i[0].street for i in qs.order_by('-physical_addresses__%s__street' % label) + [i[0].street for i in qs.order_by(f'-physical_addresses__{label}__street') .values_list('physical_addresses', flat=True)], ['Elm St 3', 'Elm St 2', 'Elm St 1', 'Elm St 0'] ) diff --git a/tests/test_items/test_generic.py b/tests/test_items/test_generic.py index 8206ffaa..18875818 100644 --- a/tests/test_items/test_generic.py +++ b/tests/test_items/test_generic.py @@ -170,7 +170,7 @@ def test_order_by(self): test_items = [] for i in range(4): item = self.get_test_item() - item.subject = 'Subj %s' % i + item.subject = f'Subj {i}' test_items.append(item) self.test_folder.bulk_create(items=test_items) qs = QuerySet( @@ -192,7 +192,7 @@ def test_order_by(self): test_items = [] for i in range(4): item = self.get_test_item() - item.extern_id = 'ID %s' % i + item.extern_id = f'ID {i}' test_items.append(item) self.test_folder.bulk_create(items=test_items) qs = QuerySet( @@ -217,8 +217,8 @@ def test_order_by(self): for i in range(2): for j in range(2): item = self.get_test_item() - item.subject = 'Subj %s' % i - item.extern_id = 'ID %s' % j + item.subject = f'Subj {i}' + item.extern_id = f'ID {j}' test_items.append(item) self.test_folder.bulk_create(items=test_items) qs = QuerySet( @@ -261,7 +261,7 @@ def test_order_by_with_empty_values(self): for i in range(4): item = self.get_test_item() if i % 2 == 0: - item.subject = 'Subj %s' % i + item.subject = f'Subj {i}' else: item.subject = None test_items.append(item) @@ -285,7 +285,7 @@ def test_order_by_on_list_field(self): item = self.get_test_item() item.subject = self.categories[0] # Make sure we have something unique to filter on if i % 2 == 0: - item.categories = ['Cat %s' % i] + item.categories = [f'Cat {i}'] else: item.categories = [] test_items.append(item) @@ -637,7 +637,7 @@ def test_filter_with_querystring(self): # Also, some servers are misconfigured and don't support querystrings at all. Don't fail on that. try: self.assertIn( - self.test_folder.filter('Subject:%s' % item.subject).count(), + self.test_folder.filter(f'Subject:{item.subject}').count(), (0, 1) ) except ErrorInternalServerError as e: diff --git a/tests/test_items/test_helpers.py b/tests/test_items/test_helpers.py index 12b56b04..a4987553 100644 --- a/tests/test_items/test_helpers.py +++ b/tests/test_items/test_helpers.py @@ -27,13 +27,13 @@ def test_save_with_update_fields(self): item.save(update_fields=['xxx']) self.assertEqual( e.exception.args[0], - "Field name(s) 'xxx' are not valid for a '%s' item" % self.ITEM_CLASS.__name__ + f"Field name(s) ['xxx'] are not valid for a {self.ITEM_CLASS.__name__!r} item" ) with self.assertRaises(ValueError) as e: item.save(update_fields='subject') self.assertEqual( e.exception.args[0], - "Field name(s) 's', 'u', 'b', 'j', 'e', 'c', 't' are not valid for a '%s' item" % self.ITEM_CLASS.__name__ + f"Field name(s) ['s', 'u', 'b', 'j', 'e', 'c', 't'] are not valid for a {self.ITEM_CLASS.__name__!r} item" ) def test_soft_delete(self): diff --git a/tests/test_items/test_messages.py b/tests/test_items/test_messages.py index b18590d5..4194d641 100644 --- a/tests/test_items/test_messages.py +++ b/tests/test_items/test_messages.py @@ -22,7 +22,7 @@ def get_incoming_message(self, subject): while True: t2 = time.monotonic() if t2 - t1 > self.INCOMING_MESSAGE_TIMEOUT: - self.skipTest('Too bad. Gave up in %s waiting for the incoming message to show up' % self.id()) + self.skipTest(f'Too bad. Gave up in {self.id()} waiting for the incoming message to show up') try: return self.account.inbox.get(subject=subject) except DoesNotExist: @@ -92,7 +92,7 @@ def test_reply(self): item.folder = None item.send() # get_test_item() sets the to_recipients to the test account sent_item = self.get_incoming_message(item.subject) - new_subject = ('Re: %s' % sent_item.subject)[:255] + new_subject = (f'Re: {sent_item.subject}')[:255] sent_item.reply(subject=new_subject, body='Hello reply', to_recipients=[item.author]) self.assertEqual(self.account.sent.filter(subject=new_subject).count(), 1) @@ -102,7 +102,7 @@ def test_reply_all(self): item.folder = None item.send() sent_item = self.get_incoming_message(item.subject) - new_subject = ('Re: %s' % sent_item.subject)[:255] + new_subject = (f'Re: {sent_item.subject}')[:255] sent_item.reply_all(subject=new_subject, body='Hello reply') self.assertEqual(self.account.sent.filter(subject=new_subject).count(), 1) @@ -112,7 +112,7 @@ def test_forward(self): item.folder = None item.send() sent_item = self.get_incoming_message(item.subject) - new_subject = ('Re: %s' % sent_item.subject)[:255] + new_subject = (f'Re: {sent_item.subject}')[:255] sent_item.forward(subject=new_subject, body='Hello reply', to_recipients=[item.author]) self.assertEqual(self.account.sent.filter(subject=new_subject).count(), 1) @@ -122,7 +122,7 @@ def test_create_forward(self): item.folder = None item.send() sent_item = self.get_incoming_message(item.subject) - new_subject = ('Re: %s' % sent_item.subject)[:255] + new_subject = (f'Re: {sent_item.subject}')[:255] sent_item.create_forward(subject=new_subject, body='Hello reply', to_recipients=[item.author]).send() self.assertEqual(self.account.sent.filter(subject=new_subject).count(), 1) diff --git a/tests/test_items/test_queryset.py b/tests/test_items/test_queryset.py index 28a0b7ff..2f077aa7 100644 --- a/tests/test_items/test_queryset.py +++ b/tests/test_items/test_queryset.py @@ -17,7 +17,7 @@ def test_querysets(self): test_items = [] for i in range(4): item = self.get_test_item() - item.subject = 'Item %s' % i + item.subject = f'Item {i}' item.save() test_items.append(item) qs = QuerySet( @@ -201,7 +201,7 @@ def test_cached_queryset_corner_cases(self): test_items = [] for i in range(4): item = self.get_test_item() - item.subject = 'Item %s' % i + item.subject = f'Item {i}' item.save() test_items.append(item) qs = QuerySet( @@ -274,7 +274,7 @@ def test_slicing(self): items = [] for i in range(4): item = self.get_test_item() - item.subject = 'Subj %s' % i + item.subject = f'Subj {i}' del item.attachments[:] items.append(item) self.test_folder.bulk_create(items=items) diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index 56025471..ffde7f89 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -237,7 +237,7 @@ def test_streaming_invalid_subscription(self): # Test a single bad notification with self.assertRaises(ErrorInvalidSubscription) as e: list(test_folder.get_streaming_events('AAA-', connection_timeout=1, max_notifications_returned=1)) - self.assertEqual(e.exception.value, "Subscription is invalid. (subscription IDs: 'AAA-')") + self.assertEqual(e.exception.value, "Subscription is invalid. (subscription IDs: ['AAA-'])") # Test a combination of a good and a bad notification with self.assertRaises(ErrorInvalidSubscription) as e: @@ -246,7 +246,7 @@ def test_streaming_invalid_subscription(self): list(test_folder.get_streaming_events( ('AAA-', subscription_id), connection_timeout=1, max_notifications_returned=1 )) - self.assertEqual(e.exception.value, "Subscription is invalid. (subscription IDs: 'AAA-')") + self.assertEqual(e.exception.value, "Subscription is invalid. (subscription IDs: ['AAA-'])") def test_push_message_parsing(self): xml = b'''\ diff --git a/tests/test_properties.py b/tests/test_properties.py index 7b89051c..5533da02 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -26,25 +26,25 @@ def test_ews_element_sanity(self): with self.subTest(cls=cls): # Make sure that we have an ELEMENT_NAME on all models if cls != BulkCreateResult and not (cls.__doc__ and cls.__doc__.startswith('Base class ')): - self.assertIsNotNone(cls.ELEMENT_NAME, '%s must have an ELEMENT_NAME' % cls) + self.assertIsNotNone(cls.ELEMENT_NAME, f'{cls} must have an ELEMENT_NAME') # Assert that all FIELDS names are unique on the model. Also assert that the class defines # __slots__, that all fields are mentioned in __slots__ and that __slots__ is unique. field_names = set() all_slots = tuple(chain(*(getattr(c, '__slots__', ()) for c in cls.__mro__))) self.assertEqual(len(all_slots), len(set(all_slots)), - 'Model %s: __slots__ contains duplicates: %s' % (cls, sorted(all_slots))) + f'Model {cls}: __slots__ contains duplicates: {sorted(all_slots)}') for f in cls.FIELDS: with self.subTest(f=f): self.assertNotIn(f.name, field_names, - 'Field name %r is not unique on model %r' % (f.name, cls.__name__)) + f'Field name {f.name!r} is not unique on model {cls.__name__!r}') self.assertIn(f.name, all_slots, - 'Field name %s is not in __slots__ on model %s' % (f.name, cls.__name__)) + f'Field name {f.name!r} is not in __slots__ on model {cls.__name__}') field_names.add(f.name) # Finally, test that all models have a link to MSDN documentation if issubclass(cls, Folder): # We have a long list of folders subclasses. Don't require a docstring for each continue - self.assertIsNotNone(cls.__doc__, '%s is missing a docstring' % cls) + self.assertIsNotNone(cls.__doc__, f'{cls} is missing a docstring') if cls in (DLMailbox, BulkCreateResult, ExternId, Flag): # Some classes are allowed to not have a link continue @@ -57,7 +57,7 @@ def test_ews_element_sanity(self): # collapse multiline docstrings docstring = ' '.join(doc.strip() for doc in cls.__doc__.split('\n')) self.assertIn('MSDN: https://docs.microsoft.com', docstring, - '%s is missing an MSDN link in the docstring' % cls) + f'{cls} is missing an MSDN link in the docstring') def test_uid(self): # Test translation of calendar UIDs. See #453 @@ -82,9 +82,9 @@ def test_internet_message_headers(self): foo@example.com ''' - headers_elem = to_xml(payload).find('{%s}InternetMessageHeaders' % TNS) + headers_elem = to_xml(payload).find(f'{{{TNS}}}InternetMessageHeaders') headers = {} - for elem in headers_elem.findall('{%s}InternetMessageHeader' % TNS): + for elem in headers_elem.findall(f'{{{TNS}}}InternetMessageHeader'): header = MessageHeader.from_xml(elem=elem, account=None) headers[header.name] = header.value self.assertDictEqual( diff --git a/tests/test_protocol.py b/tests/test_protocol.py index b13f0f56..3c493bb9 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -526,7 +526,7 @@ def test_sessionpool(self): self.account.calendar.filter(start__lt=end, end__gt=start, categories__contains=self.categories).delete() items = [] for i in range(75): - subject = 'Test Subject %s' % i + subject = f'Test Subject {i}' item = CalendarItem( start=start, end=end, diff --git a/tests/test_transport.py b/tests/test_transport.py index 70714fa8..0293c42f 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -116,7 +116,7 @@ def test_wrap(self): ('sid', 'SID'), ('smtp_address', 'SmtpAddress'), ): - val = '%s@example.com' % attr + val = f'{attr}@example.com' account = MockAccount( access_type=DELEGATE, identity=Identity(**{attr: val}), default_timezone=MockTZ('XXX') ) @@ -128,7 +128,7 @@ def test_wrap(self): ) self.assertEqual( PrettyXmlHandler.prettify_xml(wrapped), - ''' + f''' -'''.format(tag=tag, val=val).encode()) +'''.encode()) From 513c3867690bfdf0de15858e6641eab151baebf1 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 8 Dec 2021 11:57:33 +0100 Subject: [PATCH 029/509] Addres linter warnings --- exchangelib/errors.py | 4 ++-- exchangelib/folders/roots.py | 2 +- exchangelib/properties.py | 2 +- exchangelib/util.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/exchangelib/errors.py b/exchangelib/errors.py index e9d0908f..18b3bbd9 100644 --- a/exchangelib/errors.py +++ b/exchangelib/errors.py @@ -43,8 +43,8 @@ def __init__(self, value, url, status_code, total_wait): self.total_wait = total_wait def __str__(self): - f'{self.value} (gave up after {self.total_wait:.3f} seconds. ' \ - f'URL {self.url} returned status code {self.status_code})' + return f'{self.value} (gave up after {self.total_wait:.3f} seconds. ' \ + f'URL {self.url} returned status code {self.status_code})' class SOAPError(TransportError): diff --git a/exchangelib/folders/roots.py b/exchangelib/folders/roots.py index 6a7c0d29..52b36408 100644 --- a/exchangelib/folders/roots.py +++ b/exchangelib/folders/roots.py @@ -66,7 +66,7 @@ def deregister(cls, *args, **kwargs): def get_folder(self, folder): if not folder.id: raise ValueError("'folder' must have an ID") - return self._folders_map.get(folder.id, None) + return self._folders_map.get(folder.id) def add_folder(self, folder): if not folder.id: diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 020818f6..cabab68f 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -285,7 +285,7 @@ def to_xml(self, version): # specific, non-documented order and will fail with meaningless errors if the order is wrong. # Collect attributes - attrs = dict() + attrs = {} for f in self.attribute_fields(): if f.is_read_only: continue diff --git a/exchangelib/util.py b/exchangelib/util.py index afeeff31..1d84806d 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -671,7 +671,7 @@ def split_url(url): def get_redirect_url(response, allow_relative=True, require_relative=False): # allow_relative=False throws RelativeRedirect error if scheme and hostname are equal to the request # require_relative=True throws RelativeRedirect error if scheme and hostname are not equal to the request - redirect_url = response.headers.get('location', None) + redirect_url = response.headers.get('location') if not redirect_url: raise TransportError('HTTP redirect but no location header') # At least some servers are kind enough to supply a new location. It may be relative From 01abde7f68fd00cc9d6104df038ea05f2f917d90 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 9 Dec 2021 11:49:55 +0100 Subject: [PATCH 030/509] Reduce code duplication --- exchangelib/folders/collections.py | 75 +++++++++++++----------------- 1 file changed, 33 insertions(+), 42 deletions(-) diff --git a/exchangelib/folders/collections.py b/exchangelib/folders/collections.py index 85cdfe0a..5de3405a 100644 --- a/exchangelib/folders/collections.py +++ b/exchangelib/folders/collections.py @@ -136,6 +136,31 @@ def validate_item_field(self, field, version): else: raise InvalidField(f"{field!r} is not a valid field on {self.supported_item_models}") + def _rinse_args(self, q, shape, depth, additional_fields, field_validator): + if shape not in SHAPE_CHOICES: + raise ValueError(f"'shape' {shape!r} must be one of {SHAPE_CHOICES}") + if depth is None: + depth = self._get_default_item_traversal_depth() + if depth not in ITEM_TRAVERSAL_CHOICES: + raise ValueError(f"'depth' {depth!r} must be one of {ITEM_TRAVERSAL_CHOICES}") + if additional_fields: + for f in additional_fields: + field_validator(field=f, version=self.account.version) + if f.field.is_complex: + raise ValueError(f"Field {f.field.name!r} not supported for this service") + + # Build up any restrictions + if q.is_empty(): + restriction = None + query_string = None + elif q.query_string: + restriction = None + query_string = Restriction(q, folders=self.folders, applies_to=Restriction.ITEMS) + else: + restriction = Restriction(q, folders=self.folders, applies_to=Restriction.ITEMS) + query_string = None + return depth, restriction, query_string + def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order_fields=None, calendar_view=None, page_size=None, max_items=None, offset=0): """Private method to call the FindItem service. @@ -160,30 +185,13 @@ def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order if q.is_never(): log.debug('Query will never return results') return - if shape not in SHAPE_CHOICES: - raise ValueError(f"'shape' {shape!r} must be one of {SHAPE_CHOICES}") - if depth is None: - depth = self._get_default_item_traversal_depth() - if depth not in ITEM_TRAVERSAL_CHOICES: - raise ValueError(f"'depth' {depth!r} must be one of {ITEM_TRAVERSAL_CHOICES}") - if additional_fields: - for f in additional_fields: - self.validate_item_field(field=f, version=self.account.version) - if f.field.is_complex: - raise ValueError(f"find_items() does not support field {f.field.name!r}. Use fetch() instead") + depth, restriction, query_string = self._rinse_args( + q=q, shape=shape, depth=depth, additional_fields=additional_fields, + field_validator=self.validate_item_field + ) if calendar_view is not None and not isinstance(calendar_view, CalendarView): raise ValueError(f"'calendar_view' {calendar_view!r} must be a CalendarView instance") - # Build up any restrictions - if q.is_empty(): - restriction = None - query_string = None - elif q.query_string: - restriction = None - query_string = Restriction(q, folders=self.folders, applies_to=Restriction.ITEMS) - else: - restriction = Restriction(q, folders=self.folders, applies_to=Restriction.ITEMS) - query_string = None log.debug( 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', self.account, @@ -236,28 +244,11 @@ def find_people(self, q, shape=ID_ONLY, depth=None, additional_fields=None, orde if q.is_never(): log.debug('Query will never return results') return - if shape not in SHAPE_CHOICES: - raise ValueError(f"'shape' {shape!r} must be one of {SHAPE_CHOICES}") - if depth is None: - depth = self._get_default_item_traversal_depth() - if depth not in ITEM_TRAVERSAL_CHOICES: - raise ValueError(f"'depth' {depth!r} must be one of {ITEM_TRAVERSAL_CHOICES}") - if additional_fields: - for f in additional_fields: - Persona.validate_field(field=f, version=self.account.version) - if f.field.is_complex: - raise ValueError(f"find_people() does not support field {f.field.name!r}") + depth, restriction, query_string = self._rinse_args( + q=q, shape=shape, depth=depth, additional_fields=additional_fields, + field_validator=Persona.validate_field + ) - # Build up any restrictions - if q.is_empty(): - restriction = None - query_string = None - elif q.query_string: - restriction = None - query_string = Restriction(q, folders=[folder], applies_to=Restriction.ITEMS) - else: - restriction = Restriction(q, folders=[folder], applies_to=Restriction.ITEMS) - query_string = None yield from FindPeople(account=self.account, chunk_size=page_size).call( folder=folder, additional_fields=additional_fields, From 520528b9f15b80254d6a3f062cde5df831bb1db7 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 9 Dec 2021 15:16:09 +0100 Subject: [PATCH 031/509] Fix spelling errors and other nits --- exchangelib/account.py | 6 +++--- exchangelib/autodiscover/discovery.py | 2 +- exchangelib/credentials.py | 2 +- exchangelib/fields.py | 4 ++-- exchangelib/folders/collections.py | 4 ++-- exchangelib/items/__init__.py | 4 ++-- exchangelib/items/base.py | 4 ++-- exchangelib/items/contact.py | 2 +- exchangelib/items/item.py | 8 ++++---- exchangelib/properties.py | 6 +++--- exchangelib/queryset.py | 4 ++-- exchangelib/restriction.py | 4 ++-- exchangelib/services/common.py | 3 ++- exchangelib/services/sync_folder_hierarchy.py | 16 ++++++++++++---- exchangelib/winzone.py | 3 +-- 15 files changed, 40 insertions(+), 32 deletions(-) diff --git a/exchangelib/account.py b/exchangelib/account.py index c3938b72..21d2a445 100644 --- a/exchangelib/account.py +++ b/exchangelib/account.py @@ -16,7 +16,7 @@ Notes, Outbox, PeopleConnect, PublicFoldersRoot, QuickContacts, RecipientCache, RecoverableItemsDeletions, \ RecoverableItemsPurges, RecoverableItemsRoot, RecoverableItemsVersions, Root, SearchFolders, SentItems, \ ServerFailures, SyncIssues, Tasks, ToDoSearch, VoiceMail -from .items import HARD_DELETE, AUTO_RESOLVE, SEND_TO_NONE, SAVE_ONLY, ALL_OCCURRENCIES, ID_ONLY +from .items import HARD_DELETE, AUTO_RESOLVE, SEND_TO_NONE, SAVE_ONLY, ALL_OCCURRENCES, ID_ONLY from .properties import Mailbox, SendingAs from .protocol import Protocol from .queryset import QuerySet @@ -451,7 +451,7 @@ def bulk_update(self, items, conflict_resolution=AUTO_RESOLVE, message_dispositi ))) def bulk_delete(self, ids, delete_type=HARD_DELETE, send_meeting_cancellations=SEND_TO_NONE, - affected_task_occurrences=ALL_OCCURRENCIES, suppress_read_receipts=True, chunk_size=None): + affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True, chunk_size=None): """Bulk delete items. :param ids: an iterable of either (id, changekey) tuples or Item objects. @@ -460,7 +460,7 @@ def bulk_delete(self, ids, delete_type=HARD_DELETE, send_meeting_cancellations=S :param send_meeting_cancellations: only applicable to CalendarItem. Possible values are specified in SEND_MEETING_CANCELLATIONS_CHOICES. (Default value = SEND_TO_NONE) :param affected_task_occurrences: only applicable for recurring Task items. Possible values are specified in - AFFECTED_TASK_OCCURRENCES_CHOICES. (Default value = ALL_OCCURRENCIES) + AFFECTED_TASK_OCCURRENCES_CHOICES. (Default value = ALL_OCCURRENCES) :param suppress_read_receipts: only supported from Exchange 2013. True or False. (Default value = True) :param chunk_size: The number of items to send to the server in a single request (Default value = None) diff --git a/exchangelib/autodiscover/discovery.py b/exchangelib/autodiscover/discovery.py index f40af5b1..a2ff82cb 100644 --- a/exchangelib/autodiscover/discovery.py +++ b/exchangelib/autodiscover/discovery.py @@ -534,7 +534,7 @@ def _step_5(self, ad): if ad_response.redirect_url: log.debug('Got a redirect URL: %s', ad_response.redirect_url) # We are diverging a bit from the protocol here. We will never get an HTTP 302 since earlier steps already - # followed the redirects where possible. Instead, we handle retirect responses here. + # followed the redirects where possible. Instead, we handle redirect responses here. if self._redirect_url_is_valid(url=ad_response.redirect_url): is_valid_response, ad = self._attempt_response(url=ad_response.redirect_url) if is_valid_response: diff --git a/exchangelib/credentials.py b/exchangelib/credentials.py index 777e46ef..8cc60835 100644 --- a/exchangelib/credentials.py +++ b/exchangelib/credentials.py @@ -2,7 +2,7 @@ Implements an Exchange user object and access types. Exchange provides two different ways of granting access for a login to a specific account. Impersonation is used mainly for service accounts that connect via EWS. Delegate is used for ad-hoc access e.g. granted manually by the user. -See http://blogs.msdn.com/b/exchangedev/archive/2009/06/15/exchange-impersonation-vs-delegate-access.aspx +See https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/impersonation-and-ews-in-exchange """ import abc import logging diff --git a/exchangelib/fields.py b/exchangelib/fields.py index 2ed08d7e..8669d393 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -62,7 +62,7 @@ class InvalidField(ValueError): class InvalidFieldForVersion(ValueError): - """Used when a field is not supported on the given Exchnage version.""" + """Used when a field is not supported on the given Exchange version.""" class InvalidChoiceForVersion(ValueError): @@ -829,7 +829,7 @@ def from_xml(self, elem, account): class URIField(TextField): """Helper to mark strings that must conform to xsd:anyURI. - If we want an URI validator, see http://stackoverflow.com/questions/14466585/is-this-regex-correct-for-xsdanyuri + If we want a URI validator, see https://stackoverflow.com/questions/14466585/is-this-regex-correct-for-xsdanyuri """ diff --git a/exchangelib/folders/collections.py b/exchangelib/folders/collections.py index 5de3405a..bc547a98 100644 --- a/exchangelib/folders/collections.py +++ b/exchangelib/folders/collections.py @@ -166,7 +166,7 @@ def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order """Private method to call the FindItem service. :param q: a Q instance containing any restrictions - :param shape: controls whether to return (id, chanegkey) tuples or Item objects. If additional_fields is + :param shape: controls whether to return (id, changekey) tuples or Item objects. If additional_fields is non-null, we always return Item objects. (Default value = ID_ONLY) :param depth: controls the whether to return soft-deleted items or not. (Default value = None) :param additional_fields: the extra properties we want on the return objects. Default is no properties. Be aware @@ -227,7 +227,7 @@ def find_people(self, q, shape=ID_ONLY, depth=None, additional_fields=None, orde """Private method to call the FindPeople service. :param q: a Q instance containing any restrictions - :param shape: controls whether to return (id, chanegkey) tuples or Persona objects. If additional_fields is + :param shape: controls whether to return (id, changekey) tuples or Persona objects. If additional_fields is non-null, we always return Persona objects. (Default value = ID_ONLY) :param depth: controls the whether to return soft-deleted items or not. (Default value = None) :param additional_fields: the extra properties we want on the return objects. Default is no properties. diff --git a/exchangelib/items/__init__.py b/exchangelib/items/__init__.py index 9ccded9f..d1244748 100644 --- a/exchangelib/items/__init__.py +++ b/exchangelib/items/__init__.py @@ -1,7 +1,7 @@ from .base import RegisterMixIn, BulkCreateResult, MESSAGE_DISPOSITION_CHOICES, SAVE_ONLY, SEND_ONLY, \ ID_ONLY, DEFAULT, ALL_PROPERTIES, SEND_MEETING_INVITATIONS_CHOICES, SEND_TO_NONE, SEND_ONLY_TO_ALL, \ SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, SEND_ONLY_TO_CHANGED, SEND_TO_CHANGED_AND_SAVE_COPY, \ - SEND_MEETING_CANCELLATIONS_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES, ALL_OCCURRENCIES, \ + SEND_MEETING_CANCELLATIONS_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES, ALL_OCCURRENCES, \ SPECIFIED_OCCURRENCE_ONLY, CONFLICT_RESOLUTION_CHOICES, NEVER_OVERWRITE, AUTO_RESOLVE, ALWAYS_OVERWRITE, \ DELETE_TYPE_CHOICES, HARD_DELETE, SOFT_DELETE, MOVE_TO_DELETED_ITEMS, SEND_TO_ALL_AND_SAVE_COPY, \ SEND_AND_SAVE_COPY, SHAPE_CHOICES @@ -37,7 +37,7 @@ 'Contact', 'Persona', 'DistributionList', 'SEND_MEETING_INVITATIONS_CHOICES', 'SEND_TO_NONE', 'SEND_ONLY_TO_ALL', 'SEND_TO_ALL_AND_SAVE_COPY', 'SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES', 'SEND_ONLY_TO_CHANGED', 'SEND_TO_CHANGED_AND_SAVE_COPY', - 'SEND_MEETING_CANCELLATIONS_CHOICES', 'AFFECTED_TASK_OCCURRENCES_CHOICES', 'ALL_OCCURRENCIES', + 'SEND_MEETING_CANCELLATIONS_CHOICES', 'AFFECTED_TASK_OCCURRENCES_CHOICES', 'ALL_OCCURRENCES', 'SPECIFIED_OCCURRENCE_ONLY', 'CONFLICT_RESOLUTION_CHOICES', 'NEVER_OVERWRITE', 'AUTO_RESOLVE', 'ALWAYS_OVERWRITE', 'DELETE_TYPE_CHOICES', 'HARD_DELETE', 'SOFT_DELETE', 'MOVE_TO_DELETED_ITEMS', 'BaseItem', 'Item', 'BulkCreateResult', diff --git a/exchangelib/items/base.py b/exchangelib/items/base.py index 959e9c5e..02243b19 100644 --- a/exchangelib/items/base.py +++ b/exchangelib/items/base.py @@ -43,9 +43,9 @@ # AffectedTaskOccurrences values. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteitem -ALL_OCCURRENCIES = 'AllOccurrences' +ALL_OCCURRENCES = 'AllOccurrences' SPECIFIED_OCCURRENCE_ONLY = 'SpecifiedOccurrenceOnly' -AFFECTED_TASK_OCCURRENCES_CHOICES = (ALL_OCCURRENCIES, SPECIFIED_OCCURRENCE_ONLY) +AFFECTED_TASK_OCCURRENCES_CHOICES = (ALL_OCCURRENCES, SPECIFIED_OCCURRENCE_ONLY) # ConflictResolution values. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem diff --git a/exchangelib/items/contact.py b/exchangelib/items/contact.py index 4f02f3ed..b0d7c038 100644 --- a/exchangelib/items/contact.py +++ b/exchangelib/items/contact.py @@ -157,7 +157,7 @@ class Persona(IdChangeKeyMixIn): callback_phones = PhoneNumberAttributedValueField(field_uri='persona:CallbackPhones') car_phones = PhoneNumberAttributedValueField(field_uri='persona:CarPhones') home_faxes = PhoneNumberAttributedValueField(field_uri='persona:HomeFaxes') - orgnaization_main_phones = PhoneNumberAttributedValueField(field_uri='persona:OrganizationMainPhones') + organization_main_phones = PhoneNumberAttributedValueField(field_uri='persona:OrganizationMainPhones') other_faxes = PhoneNumberAttributedValueField(field_uri='persona:OtherFaxes') other_telephones = PhoneNumberAttributedValueField(field_uri='persona:OtherTelephones') other_phones2 = PhoneNumberAttributedValueField(field_uri='persona:OtherPhones2') diff --git a/exchangelib/items/item.py b/exchangelib/items/item.py index d3e737be..bca3eaf0 100644 --- a/exchangelib/items/item.py +++ b/exchangelib/items/item.py @@ -1,7 +1,7 @@ import logging from .base import BaseItem, SAVE_ONLY, SEND_AND_SAVE_COPY, ID_ONLY, SEND_TO_NONE, \ - AUTO_RESOLVE, SOFT_DELETE, HARD_DELETE, ALL_OCCURRENCIES, MOVE_TO_DELETED_ITEMS + AUTO_RESOLVE, SOFT_DELETE, HARD_DELETE, ALL_OCCURRENCES, MOVE_TO_DELETED_ITEMS from ..fields import BooleanField, IntegerField, TextField, CharListField, ChoiceField, URIField, BodyField, \ DateTimeField, MessageHeaderField, AttachmentField, Choice, EWSElementField, EffectiveRightsField, CultureField, \ CharField, MimeContentField, FieldPath @@ -225,7 +225,7 @@ def move(self, to_folder): self._id = self.ID_ELEMENT_CLS(*res) self.folder = to_folder - def move_to_trash(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES, + def move_to_trash(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True): # Delete and move to the trash folder. self._delete(delete_type=MOVE_TO_DELETED_ITEMS, send_meeting_cancellations=send_meeting_cancellations, @@ -233,7 +233,7 @@ def move_to_trash(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_o self._id = None self.folder = self.account.trash - def soft_delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES, + def soft_delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True): # Delete and move to the dumpster, if it is enabled. self._delete(delete_type=SOFT_DELETE, send_meeting_cancellations=send_meeting_cancellations, @@ -241,7 +241,7 @@ def soft_delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occ self._id = None self.folder = self.account.recoverable_items_deletions - def delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES, + def delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True): # Remove the item permanently. No copies are stored anywhere. self._delete(delete_type=HARD_DELETE, send_meeting_cancellations=send_meeting_cancellations, diff --git a/exchangelib/properties.py b/exchangelib/properties.py index cabab68f..2467c0fd 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -1440,9 +1440,9 @@ class IdChangeKeyMixIn(EWSElement, metaclass=EWSMeta): ID_ELEMENT_CLS = None def __init__(self, **kwargs): - _id = self.ID_ELEMENT_CLS(kwargs.pop('id', None), kwargs.pop('changekey', None)) - if _id.id or _id.changekey: - kwargs['_id'] = _id + _id, _changekey = kwargs.pop('id', None), kwargs.pop('changekey', None) + if _id or _changekey: + kwargs['_id'] = self.ID_ELEMENT_CLS(_id, _changekey) super().__init__(**kwargs) @classmethod diff --git a/exchangelib/queryset.py b/exchangelib/queryset.py index edd462ef..aa996989 100644 --- a/exchangelib/queryset.py +++ b/exchangelib/queryset.py @@ -44,8 +44,8 @@ def people(self): class QuerySet(SearchableMixIn): - """A Django QuerySet-like class for querying items. Defers queries until the QuerySet is consumed. Supports chaining to - build up complex queries. + """A Django QuerySet-like class for querying items. Defers queries until the QuerySet is consumed. Supports + chaining to build up complex queries. Django QuerySet documentation: https://docs.djangoproject.com/en/dev/ref/models/querysets/ """ diff --git a/exchangelib/restriction.py b/exchangelib/restriction.py index 709d0ed2..4ad556b7 100644 --- a/exchangelib/restriction.py +++ b/exchangelib/restriction.py @@ -9,7 +9,7 @@ class Q: - """A class with an API similar to Django Q objects. Used to implemnt advanced filtering logic.""" + """A class with an API similar to Django Q objects. Used to implement advanced filtering logic.""" # Connection types AND = 'AND' @@ -319,7 +319,7 @@ def expr(self): expr = f'{self.field_path} {self.op} {self.value!r}' else: # Sort children by field name so we get stable output (for easier testing). Children should never be empty. - expr = (f' {self.AND if self.conn_type == self.NOT else self.conn_type} ').join( + expr = f' {self.AND if self.conn_type == self.NOT else self.conn_type} '.join( (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f'({c.expr()})') for c in sorted(self.children, key=lambda i: i.field_path or '') ) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 795541cf..c9e50165 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -157,7 +157,8 @@ def parse(self, xml): def _elems_to_objs(self, elems): """Takes a generator of XML elements and exceptions. Returns the equivalent Python objects (or exceptions).""" - raise NotImplementedError() + if self.returns_elements: + raise NotImplementedError() @property def _version_hint(self): diff --git a/exchangelib/services/sync_folder_hierarchy.py b/exchangelib/services/sync_folder_hierarchy.py index 3672e778..c78a0e64 100644 --- a/exchangelib/services/sync_folder_hierarchy.py +++ b/exchangelib/services/sync_folder_hierarchy.py @@ -64,15 +64,23 @@ class SyncFolderHierarchy(SyncFolder): shape_tag = 'm:FolderShape' last_in_range_name = f'{{{MNS}}}IncludesLastFolderInRange' + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.folder = None # A hack to communicate parsing args to _elems_to_objs() + def call(self, folder, shape, additional_fields, sync_state): self.sync_state = sync_state - change_types = self._change_types_map() - for elem in self._get_elements(payload=self.get_payload( + self.folder = folder + return self._elems_to_objs(self._get_elements(payload=self.get_payload( folder=folder, shape=shape, additional_fields=additional_fields, sync_state=sync_state, - )): + ))) + + def _elems_to_objs(self, elems): + change_types = self._change_types_map() + for elem in elems: if isinstance(elem, Exception): yield elem continue @@ -83,7 +91,7 @@ def call(self, folder, shape, additional_fields, sync_state): # We can't find() the element because we don't know which tag to look for. The change element can # contain multiple folder types, each with their own tag. folder_elem = elem[0] - folder = parse_folder_elem(elem=folder_elem, folder=folder, account=self.account) + folder = parse_folder_elem(elem=folder_elem, folder=self.folder, account=self.account) yield change_type, folder def get_payload(self, folder, shape, additional_fields, sync_state): diff --git a/exchangelib/winzone.py b/exchangelib/winzone.py index 94097a86..01f833d9 100644 --- a/exchangelib/winzone.py +++ b/exchangelib/winzone.py @@ -1,5 +1,4 @@ -"""A dict to translate from IANA location name to Windows timezone name. Translations taken from -http://unicode.org/repos/cldr/trunk/common/supplemental/windowsZones.xml +"""A dict to translate from IANA location name to Windows timezone name. Translations taken from CLDR_WINZONE_URL """ import re From 02a02684f0917f2f73845ca6b3a59c40e787bd48 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 9 Dec 2021 15:49:25 +0100 Subject: [PATCH 032/509] Improve code reuse between UpdateFolder and UpdateItem --- exchangelib/services/create_attachment.py | 2 +- exchangelib/services/update_folder.py | 185 +++++++++++++++------- exchangelib/services/update_item.py | 154 ++++-------------- tests/test_items/test_helpers.py | 4 +- 4 files changed, 164 insertions(+), 181 deletions(-) diff --git a/exchangelib/services/create_attachment.py b/exchangelib/services/create_attachment.py index 26433d46..b7550830 100644 --- a/exchangelib/services/create_attachment.py +++ b/exchangelib/services/create_attachment.py @@ -33,7 +33,7 @@ def get_payload(self, items, parent_item): set_xml_value(payload, to_item_id(parent_item, ParentItemId, version=version), version=version) attachments = create_element('m:Attachments') for item in items: - set_xml_value(attachments, item, version=self.account.version) + set_xml_value(attachments, item, version=version) if not len(attachments): raise ValueError('"items" must not be empty') payload.append(attachments) diff --git a/exchangelib/services/update_folder.py b/exchangelib/services/update_folder.py index 692b5a16..8a54b682 100644 --- a/exchangelib/services/update_folder.py +++ b/exchangelib/services/update_folder.py @@ -1,12 +1,129 @@ from .common import EWSAccountService, parse_folder_elem, to_item_id -from ..fields import FieldPath +from ..fields import FieldPath, IndexedField +from ..properties import FolderId from ..util import create_element, set_xml_value, MNS -class UpdateFolder(EWSAccountService): +class BaseUpdateService(EWSAccountService): + """Base class for UpdateFolder and UpdateItem""" + SET_FIELD_ELEMENT_NAME = None + DELETE_FIELD_ELEMENT_NAME = None + CHANGE_ELEMENT_NAME = None + CHANGES_ELEMENT_NAME = None + + @staticmethod + def _sorted_fields(target_model, fieldnames): + # Take a list of fieldnames and return the fields in the order they are mentioned in target_model.FIELDS. + # Checks that all fieldnames are valid. + fieldnames_copy = list(fieldnames) + # Loop over FIELDS and not supported_fields(). Upstream should make sure not to update a non-supported field. + for f in target_model.FIELDS: + if f.name in fieldnames_copy: + fieldnames_copy.remove(f.name) + yield f + if fieldnames_copy: + raise ValueError(f"Field name(s) {fieldnames_copy} are not valid {target_model.__name__!r} fields") + + def _get_value(self, target, field): + return field.clean(getattr(target, field.name), version=self.account.version) # Make sure the value is OK + + def _set_field_elems(self, target_model, field, value): + if isinstance(field, IndexedField): + # Generate either set or delete elements for all combinations of labels and subfields + supported_labels = field.value_cls.get_field_by_fieldname('label')\ + .supported_choices(version=self.account.version) + seen_labels = set() + subfields = field.value_cls.supported_fields(version=self.account.version) + for v in value: + seen_labels.add(v.label) + for subfield in subfields: + field_path = FieldPath(field=field, label=v.label, subfield=subfield) + subfield_value = getattr(v, subfield.name) + if not subfield_value: + # Generate delete elements for blank subfield values + yield self._delete_field_elem(field_path=field_path) + else: + # Generate set elements for non-null subfield values + yield self._set_field_elem( + target_model=target_model, + field_path=field_path, + value=field.value_cls(**{'label': v.label, subfield.name: subfield_value}), + ) + # Generate delete elements for all subfields of all labels not mentioned in the list of values + for label in (label for label in supported_labels if label not in seen_labels): + for subfield in subfields: + yield self._delete_field_elem(field_path=FieldPath(field=field, label=label, subfield=subfield)) + else: + yield self._set_field_elem(target_model=target_model, field_path=FieldPath(field=field), value=value) + + def _set_field_elem(self, target_model, field_path, value): + setfield = create_element(self.SET_FIELD_ELEMENT_NAME) + set_xml_value(setfield, field_path, version=self.account.version) + folder = create_element(target_model.request_tag()) + field_elem = field_path.field.to_xml(value, version=self.account.version) + set_xml_value(folder, field_elem, version=self.account.version) + setfield.append(folder) + return setfield + + def _delete_field_elems(self, field): + for field_path in FieldPath(field=field).expand(version=self.account.version): + yield self._delete_field_elem(field_path=field_path) + + def _delete_field_elem(self, field_path): + deletefolderfield = create_element(self.DELETE_FIELD_ELEMENT_NAME) + return set_xml_value(deletefolderfield, field_path, version=self.account.version) + + def _update_elems(self, target, fieldnames): + target_model = target.__class__ + + for field in self._sorted_fields(target_model=target_model, fieldnames=fieldnames): + if field.is_read_only: + raise ValueError(f'{field.name!r} is a read-only field') + value = self._get_value(target, field) + + if value is None or (field.is_list and not value): + # A value of None or [] means we want to remove this field from the item + if field.is_required or field.is_required_after_save: + raise ValueError(f'{field.name!r} is a required field and may not be deleted') + yield from self._delete_field_elems(field) + continue + + yield from self._set_field_elems(target_model=target_model, field=field, value=value) + + def _change_elem(self, target, target_elem, fieldnames): + if not fieldnames: + raise ValueError('"fieldnames" must not be empty') + change = create_element(self.CHANGE_ELEMENT_NAME) + set_xml_value(change, target_elem, version=self.account.version) + updates = create_element('t:Updates') + for elem in self._update_elems(target=target, fieldnames=fieldnames): + updates.append(elem) + change.append(updates) + return change + + def _target_elem(self, target): + raise NotImplementedError() + + def _changes_elem(self, target_changes): + changes = create_element(self.CHANGES_ELEMENT_NAME) + for target, fieldnames in target_changes: + target_elem = self._target_elem(target) + changes.append(self._change_elem( + target=target, target_elem=target_elem, fieldnames=fieldnames + )) + if not len(changes): + raise ValueError('List of changes must not be empty') + return changes + + +class UpdateFolder(BaseUpdateService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updatefolder-operation""" SERVICE_NAME = 'UpdateFolder' + SET_FIELD_ELEMENT_NAME = 't:SetFolderField' + DELETE_FIELD_ELEMENT_NAME = 't:DeleteFolderField' + CHANGE_ELEMENT_NAME = 't:FolderChange' + CHANGES_ELEMENT_NAME = 'm:FolderChanges' element_container_name = f'{{{MNS}}}Folders' def __init__(self, *args, **kwargs): @@ -26,63 +143,15 @@ def _elems_to_objs(self, elems): continue yield parse_folder_elem(elem=elem, folder=folder, account=self.account) - @staticmethod - def _sort_fieldnames(folder_model, fieldnames): - # Take a list of fieldnames and return the fields in the order they are mentioned in folder_model.FIELDS. - # Loop over FIELDS and not supported_fields(). Upstream should make sure not to update a non-supported field. - for f in folder_model.FIELDS: - if f.name in fieldnames: - yield f.name - - def _set_folder_elem(self, folder_model, field_path, value): - setfolderfield = create_element('t:SetFolderField') - set_xml_value(setfolderfield, field_path, version=self.account.version) - folder = create_element(folder_model.request_tag()) - field_elem = field_path.field.to_xml(value, version=self.account.version) - set_xml_value(folder, field_elem, version=self.account.version) - setfolderfield.append(folder) - return setfolderfield - - def _delete_folder_elem(self, field_path): - deletefolderfield = create_element('t:DeleteFolderField') - return set_xml_value(deletefolderfield, field_path, version=self.account.version) - - def _get_folder_update_elems(self, folder, fieldnames): - folder_model = folder.__class__ - fieldnames_set = set(fieldnames) - - for fieldname in self._sort_fieldnames(folder_model=folder_model, fieldnames=fieldnames_set): - field = folder_model.get_field_by_fieldname(fieldname) - if field.is_read_only: - raise ValueError(f'{field.name!r} is a read-only field') - value = field.clean(getattr(folder, field.name), version=self.account.version) # Make sure the value is OK - - if value is None or (field.is_list and not value): - # A value of None or [] means we want to remove this field from the item - if field.is_required or field.is_required_after_save: - raise ValueError(f'{field.name!r} is a required field and may not be deleted') - for field_path in FieldPath(field=field).expand(version=self.account.version): - yield self._delete_folder_elem(field_path=field_path) - continue - - yield self._set_folder_elem(folder_model=folder_model, field_path=FieldPath(field=field), value=value) + def _target_elem(self, target): + from ..folders import BaseFolder + if isinstance(target, (BaseFolder, FolderId)): + return target.to_xml(version=self.account.version) + return to_item_id(target, FolderId, version=self.account.version) def get_payload(self, folders): - from ..folders import BaseFolder, FolderId + # Takes a list of (Folder, fieldnames) tuples where 'Folder' is a instance of a subclass of Folder and + # 'fieldnames' are the attribute names that were updated. updatefolder = create_element(f'm:{self.SERVICE_NAME}') - folderchanges = create_element('m:FolderChanges') - version = self.account.version - for folder, fieldnames in folders: - folderchange = create_element('t:FolderChange') - if not isinstance(folder, (BaseFolder, FolderId)): - folder = to_item_id(folder, FolderId, version=version) - set_xml_value(folderchange, folder, version=version) - updates = create_element('t:Updates') - for elem in self._get_folder_update_elems(folder=folder, fieldnames=fieldnames): - updates.append(elem) - folderchange.append(updates) - folderchanges.append(folderchange) - if not len(folderchanges): - raise ValueError('"folders" must not be empty') - updatefolder.append(folderchanges) + updatefolder.append(self._changes_elem(target_changes=folders)) return updatefolder diff --git a/exchangelib/services/update_item.py b/exchangelib/services/update_item.py index 5d8f4ee5..461b1ff8 100644 --- a/exchangelib/services/update_item.py +++ b/exchangelib/services/update_item.py @@ -1,15 +1,19 @@ -from .common import EWSAccountService, to_item_id +from .common import to_item_id from ..ewsdatetime import EWSDate -from ..fields import FieldPath, IndexedField from ..properties import ItemId -from ..util import create_element, set_xml_value, MNS +from ..util import create_element, MNS from ..version import EXCHANGE_2013_SP1 +from .update_folder import BaseUpdateService -class UpdateItem(EWSAccountService): +class UpdateItem(BaseUpdateService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem-operation""" SERVICE_NAME = 'UpdateItem' + SET_FIELD_ELEMENT_NAME = 't:SetItemField' + DELETE_FIELD_ELEMENT_NAME = 't:DeleteItemField' + CHANGE_ELEMENT_NAME = 't:ItemChange' + CHANGES_ELEMENT_NAME = 'm:ItemChanges' element_container_name = f'{{{MNS}}}Items' def call(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, @@ -50,142 +54,52 @@ def _elems_to_objs(self, elems): continue yield Item.id_from_xml(elem) - def _delete_item_elem(self, field_path): - deleteitemfield = create_element('t:DeleteItemField') - return set_xml_value(deleteitemfield, field_path, version=self.account.version) - - def _set_item_elem(self, item_model, field_path, value): - setitemfield = create_element('t:SetItemField') - set_xml_value(setitemfield, field_path, version=self.account.version) - item_elem = create_element(item_model.request_tag()) - field_elem = field_path.field.to_xml(value, version=self.account.version) - set_xml_value(item_elem, field_elem, version=self.account.version) - setitemfield.append(item_elem) - return setitemfield - - @staticmethod - def _sorted_fields(item_model, fieldnames): - # Take a list of fieldnames and return the (unique) fields in the order they are mentioned in item_class.FIELDS. - # Checks that all fieldnames are valid. - unique_fieldnames = list(dict.fromkeys(fieldnames)) # Make field names unique, but keep ordering - # Loop over FIELDS and not supported_fields(). Upstream should make sure not to update a non-supported field. - for f in item_model.FIELDS: - if f.name in unique_fieldnames: - unique_fieldnames.remove(f.name) - yield f - if unique_fieldnames: - raise ValueError(f"Field name(s) {unique_fieldnames} are not valid for a {item_model.__name__!r} item") - - def _get_item_update_elems(self, item, fieldnames): + def _update_elems(self, target, fieldnames): from ..items import CalendarItem fieldnames_copy = list(fieldnames) - if item.__class__ == CalendarItem: + if target.__class__ == CalendarItem: # For CalendarItem items where we update 'start' or 'end', we want to update internal timezone fields - item.clean_timezone_fields(version=self.account.version) # Possibly also sets timezone values + target.clean_timezone_fields(version=self.account.version) # Possibly also sets timezone values for field_name in ('start', 'end'): if field_name in fieldnames_copy: - tz_field_name = item.tz_field_for_field_name(field_name).name + tz_field_name = target.tz_field_for_field_name(field_name).name if tz_field_name not in fieldnames_copy: fieldnames_copy.append(tz_field_name) - for field in self._sorted_fields(item_model=item.__class__, fieldnames=fieldnames_copy): - if field.is_read_only: - raise ValueError(f'{field.name} is a read-only field') - value = self._get_item_value(item, field) - if value is None or (field.is_list and not value): - # A value of None or [] means we want to remove this field from the item - yield from self._get_delete_item_elems(field=field) - else: - yield from self._get_set_item_elems(item_model=item.__class__, field=field, value=value) + yield from super()._update_elems(target=target, fieldnames=fieldnames_copy) - def _get_item_value(self, item, field): + def _get_value(self, target, field): from ..items import CalendarItem - value = field.clean(getattr(item, field.name), version=self.account.version) # Make sure the value is OK - if item.__class__ == CalendarItem: + value = super()._get_value(target, field) + + if target.__class__ == CalendarItem: # For CalendarItem items where we update 'start' or 'end', we want to send values in the local timezone if field.name in ('start', 'end'): if type(value) is EWSDate: # EWS always expects a datetime - return item.date_to_datetime(field_name=field.name) - tz_field_name = item.tz_field_for_field_name(field.name).name - return value.astimezone(getattr(item, tz_field_name)) - return value + return target.date_to_datetime(field_name=field.name) + tz_field_name = target.tz_field_for_field_name(field.name).name + return value.astimezone(getattr(target, tz_field_name)) - def _get_delete_item_elems(self, field): - if field.is_required or field.is_required_after_save: - raise ValueError(f'{field.name!r} is a required field and may not be deleted') - for field_path in FieldPath(field=field).expand(version=self.account.version): - yield self._delete_item_elem(field_path=field_path) + return value - def _get_set_item_elems(self, item_model, field, value): - if isinstance(field, IndexedField): - # Generate either set or delete elements for all combinations of labels and subfields - supported_labels = field.value_cls.get_field_by_fieldname('label')\ - .supported_choices(version=self.account.version) - seen_labels = set() - subfields = field.value_cls.supported_fields(version=self.account.version) - for v in value: - seen_labels.add(v.label) - for subfield in subfields: - field_path = FieldPath(field=field, label=v.label, subfield=subfield) - subfield_value = getattr(v, subfield.name) - if not subfield_value: - # Generate delete elements for blank subfield values - yield self._delete_item_elem(field_path=field_path) - else: - # Generate set elements for non-null subfield values - yield self._set_item_elem( - item_model=item_model, - field_path=field_path, - value=field.value_cls(**{'label': v.label, subfield.name: subfield_value}), - ) - # Generate delete elements for all subfields of all labels not mentioned in the list of values - for label in (label for label in supported_labels if label not in seen_labels): - for subfield in subfields: - yield self._delete_item_elem(field_path=FieldPath(field=field, label=label, subfield=subfield)) - else: - yield self._set_item_elem(item_model=item_model, field_path=FieldPath(field=field), value=value) + def _target_elem(self, target): + if not target.account: + target.account = self.account + return to_item_id(target, ItemId, version=self.account.version) def get_payload(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, suppress_read_receipts): # Takes a list of (Item, fieldnames) tuples where 'Item' is a instance of a subclass of Item and 'fieldnames' - # are the attribute names that were updated. Returns the XML for an UpdateItem call. - # an UpdateItem request. + # are the attribute names that were updated. + attrs = dict( + ConflictResolution=conflict_resolution, + MessageDisposition=message_disposition, + SendMeetingInvitationsOrCancellations=send_meeting_invitations_or_cancellations, + ) if self.account.version.build >= EXCHANGE_2013_SP1: - updateitem = create_element( - f'm:{self.SERVICE_NAME}', - attrs=dict( - ConflictResolution=conflict_resolution, - MessageDisposition=message_disposition, - SendMeetingInvitationsOrCancellations=send_meeting_invitations_or_cancellations, - SuppressReadReceipts=suppress_read_receipts, - ) - ) - else: - updateitem = create_element( - f'm:{self.SERVICE_NAME}', - attrs=dict( - ConflictResolution=conflict_resolution, - MessageDisposition=message_disposition, - SendMeetingInvitationsOrCancellations=send_meeting_invitations_or_cancellations, - ) - ) - itemchanges = create_element('m:ItemChanges') - version = self.account.version - for item, fieldnames in items: - if not item.account: - item.account = self.account - if not fieldnames: - raise ValueError('"fieldnames" must not be empty') - itemchange = create_element('t:ItemChange') - set_xml_value(itemchange, to_item_id(item, ItemId, version=version), version=version) - updates = create_element('t:Updates') - for elem in self._get_item_update_elems(item=item, fieldnames=fieldnames): - updates.append(elem) - itemchange.append(updates) - itemchanges.append(itemchange) - if not len(itemchanges): - raise ValueError('"items" must not be empty') - updateitem.append(itemchanges) + attrs['SuppressReadReceipts'] = suppress_read_receipts + updateitem = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + updateitem.append(self._changes_elem(target_changes=items)) return updateitem diff --git a/tests/test_items/test_helpers.py b/tests/test_items/test_helpers.py index a4987553..3100e0f6 100644 --- a/tests/test_items/test_helpers.py +++ b/tests/test_items/test_helpers.py @@ -27,13 +27,13 @@ def test_save_with_update_fields(self): item.save(update_fields=['xxx']) self.assertEqual( e.exception.args[0], - f"Field name(s) ['xxx'] are not valid for a {self.ITEM_CLASS.__name__!r} item" + f"Field name(s) ['xxx'] are not valid {self.ITEM_CLASS.__name__!r} fields" ) with self.assertRaises(ValueError) as e: item.save(update_fields='subject') self.assertEqual( e.exception.args[0], - f"Field name(s) ['s', 'u', 'b', 'j', 'e', 'c', 't'] are not valid for a {self.ITEM_CLASS.__name__!r} item" + f"Field name(s) ['s', 'u', 'b', 'j', 'e', 'c', 't'] are not valid {self.ITEM_CLASS.__name__!r} fields" ) def test_soft_delete(self): From 37a4d279f29ef8a128ddae135dfed1aabc24e243 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 9 Dec 2021 15:49:38 +0100 Subject: [PATCH 033/509] Fix unstable test --- tests/test_items/test_sync.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index ffde7f89..321b1366 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -23,8 +23,7 @@ def test_pull_subscribe(self): # Context manager already unsubscribed us with self.assertRaises(ErrorSubscriptionNotFound): self.account.inbox.unsubscribe(subscription_id) - # Test affinity cookie - self.assertIsNotNone(self.account.affinity_cookie) + # Affinity cookie is not always sent by the server for pull subscriptions def test_push_subscribe(self): with self.account.inbox.push_subscription( From 34b32a49c0a7f4fb823406a7afbcc343e72a3630 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 9 Dec 2021 15:57:12 +0100 Subject: [PATCH 034/509] Import from the original module --- exchangelib/services/common.py | 6 +++--- exchangelib/services/create_item.py | 3 ++- exchangelib/services/move_folder.py | 4 ++-- exchangelib/services/move_item.py | 3 ++- exchangelib/services/send_item.py | 3 ++- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index c9e50165..e830ae81 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -19,7 +19,8 @@ ErrorCannotEmptyFolder, ErrorDeleteDistinguishedFolder, ErrorInvalidSubscription, ErrorInvalidWatermark, \ ErrorInvalidSyncStateData, ErrorNameResolutionNoResults, ErrorNameResolutionMultipleResults, \ ErrorConnectionFailedTransientError -from ..properties import FieldURI, IndexedFieldURI, ExtendedFieldURI, ExceptionFieldURI, ItemId +from ..properties import FieldURI, IndexedFieldURI, ExtendedFieldURI, ExceptionFieldURI, ItemId, FolderId, \ + DistinguishedFolderId from ..transport import wrap from ..util import chunkify, create_element, add_xml_child, get_xml_attr, to_xml, post_ratelimited, \ xml_to_str, set_xml_value, SOAPNS, TNS, MNS, ENS, ParseError, DummyResponse @@ -844,7 +845,6 @@ def create_shape_element(tag, shape, additional_fields, version): def create_folder_ids_element(tag, folders, version): - from ..folders import FolderId folder_ids = create_element(tag) for folder in folders: if not isinstance(folder, FolderId): @@ -876,7 +876,7 @@ def create_attachment_ids_element(items, version): def parse_folder_elem(elem, folder, account): - from ..folders import BaseFolder, Folder, DistinguishedFolderId, RootOfHierarchy + from ..folders import BaseFolder, Folder, RootOfHierarchy if isinstance(elem, Exception): return elem if isinstance(folder, RootOfHierarchy): diff --git a/exchangelib/services/create_item.py b/exchangelib/services/create_item.py index f9163086..831516e5 100644 --- a/exchangelib/services/create_item.py +++ b/exchangelib/services/create_item.py @@ -1,4 +1,5 @@ from .common import EWSAccountService +from ..properties import FolderId from ..util import create_element, set_xml_value, MNS @@ -13,7 +14,7 @@ class CreateItem(EWSAccountService): element_container_name = f'{{{MNS}}}Items' def call(self, items, folder, message_disposition, send_meeting_invitations): - from ..folders import BaseFolder, FolderId + from ..folders import BaseFolder from ..items import SAVE_ONLY, SEND_AND_SAVE_COPY, SEND_ONLY, \ SEND_MEETING_INVITATIONS_CHOICES, MESSAGE_DISPOSITION_CHOICES if message_disposition not in MESSAGE_DISPOSITION_CHOICES: diff --git a/exchangelib/services/move_folder.py b/exchangelib/services/move_folder.py index b759ad04..06830e77 100644 --- a/exchangelib/services/move_folder.py +++ b/exchangelib/services/move_folder.py @@ -1,4 +1,5 @@ from .common import EWSAccountService, create_folder_ids_element +from ..properties import FolderId from ..util import create_element, set_xml_value, MNS @@ -9,13 +10,12 @@ class MoveFolder(EWSAccountService): element_container_name = f'{{{MNS}}}Folders' def call(self, folders, to_folder): - from ..folders import BaseFolder, FolderId + from ..folders import BaseFolder if not isinstance(to_folder, (BaseFolder, FolderId)): raise ValueError(f"'to_folder' {to_folder!r} must be a Folder or FolderId instance") return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=folders, to_folder=to_folder)) def _elems_to_objs(self, elems): - from ..folders import FolderId for elem in elems: if isinstance(elem, (Exception, type(None))): yield elem diff --git a/exchangelib/services/move_item.py b/exchangelib/services/move_item.py index eeadc8f5..ead6fc75 100644 --- a/exchangelib/services/move_item.py +++ b/exchangelib/services/move_item.py @@ -1,4 +1,5 @@ from .common import EWSAccountService, create_item_ids_element +from ..properties import FolderId from ..util import create_element, set_xml_value, MNS @@ -9,7 +10,7 @@ class MoveItem(EWSAccountService): element_container_name = f'{{{MNS}}}Items' def call(self, items, to_folder): - from ..folders import BaseFolder, FolderId + from ..folders import BaseFolder if not isinstance(to_folder, (BaseFolder, FolderId)): raise ValueError(f"'to_folder' {to_folder!r} must be a Folder or FolderId instance") return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, to_folder=to_folder)) diff --git a/exchangelib/services/send_item.py b/exchangelib/services/send_item.py index 067c0179..bc494bd0 100644 --- a/exchangelib/services/send_item.py +++ b/exchangelib/services/send_item.py @@ -1,4 +1,5 @@ from .common import EWSAccountService, create_item_ids_element +from ..properties import FolderId from ..util import create_element, set_xml_value @@ -9,7 +10,7 @@ class SendItem(EWSAccountService): returns_elements = False def call(self, items, saved_item_folder): - from ..folders import BaseFolder, FolderId + from ..folders import BaseFolder if saved_item_folder and not isinstance(saved_item_folder, (BaseFolder, FolderId)): raise ValueError(f"'saved_item_folder' {saved_item_folder!r} must be a Folder or FolderId instance") return self._chunked_get_elements(self.get_payload, items=items, saved_item_folder=saved_item_folder) From 802366a1cd0495db05ca9e95ae2438c22616e781 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sat, 11 Dec 2021 23:48:19 +0100 Subject: [PATCH 035/509] Fix some spelling suggestions and clean up code a bit --- exchangelib/account.py | 2 +- exchangelib/fields.py | 34 +++++++++---------- exchangelib/folders/base.py | 6 ++-- exchangelib/protocol.py | 2 +- exchangelib/services/archive_item.py | 13 ++++--- exchangelib/services/common.py | 12 +++---- exchangelib/services/convert_id.py | 6 ++-- exchangelib/services/create_folder.py | 11 +++--- exchangelib/services/create_item.py | 10 +++--- .../services/create_user_configuration.py | 6 ++-- exchangelib/services/delete_attachment.py | 11 +++--- exchangelib/services/delete_folder.py | 7 ++-- exchangelib/services/delete_item.py | 31 +++++------------ .../services/delete_user_configuration.py | 6 ++-- exchangelib/services/empty_folder.py | 7 ++-- exchangelib/services/expand_dl.py | 4 +-- exchangelib/services/export_items.py | 7 ++-- exchangelib/services/find_folder.py | 19 +++++------ exchangelib/services/find_item.py | 19 +++++------ exchangelib/services/find_people.py | 22 ++++++------ exchangelib/services/get_attachment.py | 3 +- exchangelib/services/get_events.py | 8 ++--- exchangelib/services/get_folder.py | 12 +++---- exchangelib/services/get_item.py | 12 +++---- exchangelib/services/get_persona.py | 9 ++--- exchangelib/services/get_rooms.py | 10 +++--- exchangelib/services/get_server_time_zones.py | 22 ++++++------ exchangelib/services/get_streaming_events.py | 8 ++--- exchangelib/services/get_user_availability.py | 3 +- .../services/get_user_configuration.py | 12 +++---- exchangelib/services/get_user_oof_settings.py | 7 ++-- exchangelib/services/mark_as_junk.py | 7 ++-- exchangelib/services/move_folder.py | 11 +++--- exchangelib/services/move_item.py | 11 +++--- exchangelib/services/resolve_names.py | 3 +- exchangelib/services/send_item.py | 13 ++++--- exchangelib/services/set_user_oof_settings.py | 3 +- exchangelib/services/subscribe.py | 19 +++++------ exchangelib/services/sync_folder_hierarchy.py | 14 ++++---- exchangelib/services/sync_folder_items.py | 13 +++---- exchangelib/services/unsubscribe.py | 6 ++-- exchangelib/services/update_folder.py | 18 +++++----- exchangelib/services/update_item.py | 6 ++-- .../services/update_user_configuration.py | 4 +-- exchangelib/services/upload_items.py | 19 ++++++----- exchangelib/transport.py | 22 ++++++------ tests/test_protocol.py | 2 +- 47 files changed, 232 insertions(+), 280 deletions(-) diff --git a/exchangelib/account.py b/exchangelib/account.py index 21d2a445..f522cfbf 100644 --- a/exchangelib/account.py +++ b/exchangelib/account.py @@ -467,7 +467,7 @@ def bulk_delete(self, ids, delete_type=HARD_DELETE, send_meeting_cancellations=S :return: a list of either True or exception instances, in the same order as the input """ log.debug( - 'Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurences: %s)', + 'Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurrences: %s)', self, delete_type, send_meeting_cancellations, diff --git a/exchangelib/fields.py b/exchangelib/fields.py index 8669d393..b6df0598 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -99,7 +99,7 @@ def resolve_field_path(field_path, folder, strict=True): label and SubField object. """ from .indexed_properties import SingleFieldIndexedElement, MultiFieldIndexedElement - fieldname, label, subfieldname = split_field_path(field_path) + fieldname, label, subfield_name = split_field_path(field_path) field = folder.get_item_field_by_fieldname(fieldname) subfield = None if isinstance(field, IndexedField): @@ -114,32 +114,32 @@ def resolve_field_path(field_path, folder, strict=True): if label and label not in valid_labels: raise ValueError(f"Label {label} on IndexedField path {field_path!r} must be one of {valid_labels}") if issubclass(field.value_cls, MultiFieldIndexedElement): - if strict and not subfieldname: + if strict and not subfield_name: raise ValueError( f"IndexedField path {field_path!r} must specify subfield, e.g. " f"'{fieldname}__{label}__{field.value_cls.FIELDS[1].name}'" ) - if subfieldname: + if subfield_name: try: - subfield = field.value_cls.get_field_by_fieldname(subfieldname) + subfield = field.value_cls.get_field_by_fieldname(subfield_name) except ValueError: - fnames = ', '.join(f.name for f in field.value_cls.supported_fields( + field_names = ', '.join(f.name for f in field.value_cls.supported_fields( version=folder.account.version )) raise ValueError( - f"Subfield {subfieldname!r} on IndexedField path {field_path!r} must be one of {fnames}" + f"Subfield {subfield_name!r} on IndexedField path {field_path!r} must be one of {field_names}" ) else: if not issubclass(field.value_cls, SingleFieldIndexedElement): raise ValueError(f"'field.value_cls' {field.value_cls!r} must be an SingleFieldIndexedElement instance") - if subfieldname: + if subfield_name: raise ValueError( f"IndexedField path {field_path!r} must not specify subfield, e.g. just {fieldname}__{label}'" ) subfield = field.value_cls.value_field(version=folder.account.version) else: - if label or subfieldname: + if label or subfield_name: raise ValueError(f"Field path {field_path!r} must not specify label or subfield, e.g. just {fieldname!r}") return field, label, subfield @@ -171,11 +171,11 @@ def get_value(self, item): # For indexed properties, get either the full property set, the property with matching label, or a particular # subfield. if self.label: - for subitem in getattr(item, self.field.name): - if subitem.label == self.label: + for sub_item in getattr(item, self.field.name): + if sub_item.label == self.label: if self.subfield: - return getattr(subitem, self.subfield.name) - return subitem + return getattr(sub_item, self.subfield.name) + return sub_item return None # No item with this label return getattr(item, self.field.name) @@ -349,7 +349,7 @@ def __repr__(self): class FieldURIField(Field): - """A field that has a FieldURI value in EWS. This means it's value is contained in an XML element or arrtibute. It + """A field that has a FieldURI value in EWS. This means it's value is contained in an XML element or attribute. It may additionally be a label for searching, filtering and limiting fields. In that case, the FieldURI format will be 'itemtype:FieldName' """ @@ -1345,11 +1345,9 @@ def to_xml(self, value, version): extended_property = create_element(self.value_cls.request_tag()) set_xml_value(extended_property, self.field_uri_xml(), version=version) if isinstance(value, self.value_cls): - set_xml_value(extended_property, value, version=version) - else: - # Allow keeping ExtendedProperty field values as their simple Python type - set_xml_value(extended_property, self.value_cls(value), version=version) - return extended_property + return set_xml_value(extended_property, value, version=version) + # Allow keeping ExtendedProperty field values as their simple Python type + return set_xml_value(extended_property, self.value_cls(value), version=version) def __hash__(self): return hash(self.name) diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index d7271810..f74a0163 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -596,12 +596,12 @@ def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes """Return all item changes to a folder, as a generator. If sync_state is specified, get all item changes after this sync state. After fully consuming the generator, self.item_sync_state will hold the new sync state. - :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderitems service. + :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderItems service. :param only_fields: A list of string or FieldPath items specifying the fields to fetch. Default to all fields :param ignore: A list of Item IDs to ignore in the sync :param max_changes_returned: The max number of change :param sync_scope: Specify whether to return just items, or items and folder associated information. Possible - values are specified in SyncFolderitems.SYNC_SCOPES + values are specified in SyncFolderItems.SYNC_SCOPES :return: A generator of (change_type, item) tuples """ if not sync_state: @@ -623,7 +623,7 @@ def sync_hierarchy(self, sync_state=None, only_fields=None): changes after this sync state. After fully consuming the generator, self.folder_sync_state will hold the new sync state. - :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderitems service. + :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderItems service. :param only_fields: A list of string or FieldPath items specifying the fields to fetch. Default to all fields :return: """ diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index e9a22f17..6d63b916 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -538,7 +538,7 @@ def get_roomlists(self): return GetRoomLists(protocol=self).call() def get_rooms(self, roomlist): - return GetRooms(protocol=self).call(roomlist=RoomList(email_address=roomlist)) + return GetRooms(protocol=self).call(room_list=RoomList(email_address=roomlist)) def resolve_names(self, names, return_full_contact_data=False, search_scope=None, shape=None): """Resolve accounts on the server using partial account data, e.g. an email address or initials. diff --git a/exchangelib/services/archive_item.py b/exchangelib/services/archive_item.py index 6dc1bd95..d8c1a126 100644 --- a/exchangelib/services/archive_item.py +++ b/exchangelib/services/archive_item.py @@ -29,10 +29,9 @@ def _elems_to_objs(self, elems): yield Item.id_from_xml(elem) def get_payload(self, items, to_folder): - archiveitem = create_element(f'm:{self.SERVICE_NAME}') - folder_id = create_folder_ids_element(tag='m:ArchiveSourceFolderId', folders=[to_folder], - version=self.account.version) - item_ids = create_item_ids_element(items=items, version=self.account.version) - archiveitem.append(folder_id) - archiveitem.append(item_ids) - return archiveitem + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append( + create_folder_ids_element(tag='m:ArchiveSourceFolderId', folders=[to_folder], version=self.account.version) + ) + payload.append(create_item_ids_element(items=items, version=self.account.version)) + return payload diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index e830ae81..6c40c36c 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -72,7 +72,7 @@ class EWSService(metaclass=abc.ABCMeta): SERVICE_NAME = None # The name of the SOAP service element_container_name = None # The name of the XML element wrapping the collection of returned items paging_container_name = None # The name of the element that contains paging information and the paged results - returns_elements = True # If False, the service does not return response elements, just the RsponseCode status + returns_elements = True # If False, the service does not return response elements, just the ResponseCode status # Return exception instance instead of raising exceptions for the following errors when contained in an element ERRORS_TO_CATCH_IN_RESPONSE = ( EWSWarning, ErrorCannotDeleteObject, ErrorInvalidChangeKey, ErrorItemNotFound, ErrorItemSave, @@ -431,9 +431,9 @@ def _get_soap_messages(self, body, **parse_opts): def _raise_soap_errors(cls, fault): """Parse error messages contained in SOAP headers and raise as exceptions defined in this package.""" # Fault: See http://www.w3.org/TR/2000/NOTE-SOAP-20000508/#_Toc478383507 - faultcode = get_xml_attr(fault, 'faultcode') - faultstring = get_xml_attr(fault, 'faultstring') - faultactor = get_xml_attr(fault, 'faultactor') + fault_code = get_xml_attr(fault, 'faultcode') + fault_string = get_xml_attr(fault, 'faultstring') + fault_actor = get_xml_attr(fault, 'faultactor') detail = fault.find('detail') if detail is not None: code, msg = None, '' @@ -460,10 +460,10 @@ def _raise_soap_errors(cls, fault): except KeyError: detail = f'{cls.SERVICE_NAME}: code: {code} msg: {msg} ({xml_to_str(detail)})' try: - raise vars(errors)[faultcode](faultstring) + raise vars(errors)[fault_code](fault_string) except KeyError: pass - raise SOAPError(f'SOAP error code: {faultcode} string: {faultstring} actor: {faultactor} detail: {detail}') + raise SOAPError(f'SOAP error code: {fault_code} string: {fault_string} actor: {fault_actor} detail: {detail}') def _get_element_container(self, message, name=None): """Return the XML element in a response element that contains the elements we want the service to return. For diff --git a/exchangelib/services/convert_id.py b/exchangelib/services/convert_id.py index 6fed74f2..6c7d8d6e 100644 --- a/exchangelib/services/convert_id.py +++ b/exchangelib/services/convert_id.py @@ -33,7 +33,7 @@ def _elems_to_objs(self, elems): def get_payload(self, items, destination_format): supported_item_classes = AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId - convertid = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DestinationFormat=destination_format)) + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DestinationFormat=destination_format)) item_ids = create_element('m:SourceIds') for item in items: if not isinstance(item, supported_item_classes): @@ -41,8 +41,8 @@ def get_payload(self, items, destination_format): set_xml_value(item_ids, item, version=self.protocol.version) if not len(item_ids): raise ValueError('"items" must not be empty') - convertid.append(item_ids) - return convertid + payload.append(item_ids) + return payload @classmethod def _get_elements_in_container(cls, container): diff --git a/exchangelib/services/create_folder.py b/exchangelib/services/create_folder.py index afaa1533..422391bf 100644 --- a/exchangelib/services/create_folder.py +++ b/exchangelib/services/create_folder.py @@ -28,10 +28,7 @@ def _elems_to_objs(self, elems): yield parse_folder_elem(elem=elem, folder=folder, account=self.account) def get_payload(self, folders, parent_folder): - create_folder = create_element(f'm:{self.SERVICE_NAME}') - parentfolderid = create_element('m:ParentFolderId') - set_xml_value(parentfolderid, parent_folder, version=self.account.version) - set_xml_value(create_folder, parentfolderid, version=self.account.version) - folder_ids = create_folder_ids_element(tag='m:Folders', folders=folders, version=self.account.version) - create_folder.append(folder_ids) - return create_folder + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(set_xml_value(create_element('m:ParentFolderId'), parent_folder, version=self.account.version)) + payload.append(create_folder_ids_element(tag='m:Folders', folders=folders, version=self.account.version)) + return payload diff --git a/exchangelib/services/create_item.py b/exchangelib/services/create_item.py index 831516e5..a850ee60 100644 --- a/exchangelib/services/create_item.py +++ b/exchangelib/services/create_item.py @@ -80,14 +80,12 @@ def get_payload(self, items, folder, message_disposition, send_meeting_invitatio :param message_disposition: :param send_meeting_invitations: """ - createitem = create_element( + payload = create_element( f'm:{self.SERVICE_NAME}', attrs=dict(MessageDisposition=message_disposition, SendMeetingInvitations=send_meeting_invitations) ) if folder: - saveditemfolderid = create_element('m:SavedItemFolderId') - set_xml_value(saveditemfolderid, folder, version=self.account.version) - createitem.append(saveditemfolderid) + payload.append(set_xml_value(create_element('m:SavedItemFolderId'), folder, version=self.account.version)) item_elems = create_element('m:Items') for item in items: if not item.account: @@ -95,5 +93,5 @@ def get_payload(self, items, folder, message_disposition, send_meeting_invitatio set_xml_value(item_elems, item, version=self.account.version) if not len(item_elems): raise ValueError('"items" must not be empty') - createitem.append(item_elems) - return createitem + payload.append(item_elems) + return payload diff --git a/exchangelib/services/create_user_configuration.py b/exchangelib/services/create_user_configuration.py index 488929fd..7d6b7b3b 100644 --- a/exchangelib/services/create_user_configuration.py +++ b/exchangelib/services/create_user_configuration.py @@ -14,6 +14,6 @@ def call(self, user_configuration): return self._get_elements(payload=self.get_payload(user_configuration=user_configuration)) def get_payload(self, user_configuration): - createuserconfiguration = create_element(f'm:{self.SERVICE_NAME}') - set_xml_value(createuserconfiguration, user_configuration, version=self.protocol.version) - return createuserconfiguration + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), user_configuration, version=self.protocol.version + ) diff --git a/exchangelib/services/delete_attachment.py b/exchangelib/services/delete_attachment.py index bb5a0653..aa302a1e 100644 --- a/exchangelib/services/delete_attachment.py +++ b/exchangelib/services/delete_attachment.py @@ -1,6 +1,6 @@ from .common import EWSAccountService, create_attachment_ids_element from ..properties import RootItemId -from ..util import create_element +from ..util import create_element, set_xml_value class DeleteAttachment(EWSAccountService): @@ -25,7 +25,8 @@ def _get_elements_in_container(cls, container): return container.findall(RootItemId.response_tag()) def get_payload(self, items): - payload = create_element(f'm:{self.SERVICE_NAME}') - attachment_ids = create_attachment_ids_element(items=items, version=self.account.version) - payload.append(attachment_ids) - return payload + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), + create_attachment_ids_element(items=items, version=self.account.version), + version=self.account.version + ) diff --git a/exchangelib/services/delete_folder.py b/exchangelib/services/delete_folder.py index dc106fef..17287bc4 100644 --- a/exchangelib/services/delete_folder.py +++ b/exchangelib/services/delete_folder.py @@ -12,7 +12,6 @@ def call(self, folders, delete_type): return self._chunked_get_elements(self.get_payload, items=folders, delete_type=delete_type) def get_payload(self, folders, delete_type): - deletefolder = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DeleteType=delete_type)) - folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version) - deletefolder.append(folder_ids) - return deletefolder + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DeleteType=delete_type)) + payload.append(create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version)) + return payload diff --git a/exchangelib/services/delete_item.py b/exchangelib/services/delete_item.py index fababcf4..dcb42b56 100644 --- a/exchangelib/services/delete_item.py +++ b/exchangelib/services/delete_item.py @@ -37,26 +37,13 @@ def call(self, items, delete_type, send_meeting_cancellations, affected_task_occ def get_payload(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): # Takes a list of (id, changekey) tuples or Item objects and returns the XML for a DeleteItem request. + attrs = dict( + DeleteType=delete_type, + SendMeetingCancellations=send_meeting_cancellations, + AffectedTaskOccurrences=affected_task_occurrences, + ) if self.account.version.build >= EXCHANGE_2013_SP1: - deleteitem = create_element( - f'm:{self.SERVICE_NAME}', - attrs=dict( - DeleteType=delete_type, - SendMeetingCancellations=send_meeting_cancellations, - AffectedTaskOccurrences=affected_task_occurrences, - SuppressReadReceipts=suppress_read_receipts, - ) - ) - else: - deleteitem = create_element( - f'm:{self.SERVICE_NAME}', - attrs=dict( - DeleteType=delete_type, - SendMeetingCancellations=send_meeting_cancellations, - AffectedTaskOccurrences=affected_task_occurrences, - ) - ) - - item_ids = create_item_ids_element(items=items, version=self.account.version) - deleteitem.append(item_ids) - return deleteitem + attrs['SuppressReadReceipts'] = suppress_read_receipts + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + payload.append(create_item_ids_element(items=items, version=self.account.version)) + return payload diff --git a/exchangelib/services/delete_user_configuration.py b/exchangelib/services/delete_user_configuration.py index 2f6fbda6..ca1cf039 100644 --- a/exchangelib/services/delete_user_configuration.py +++ b/exchangelib/services/delete_user_configuration.py @@ -14,6 +14,6 @@ def call(self, user_configuration_name): return self._get_elements(payload=self.get_payload(user_configuration_name=user_configuration_name)) def get_payload(self, user_configuration_name): - deleteuserconfiguration = create_element(f'm:{self.SERVICE_NAME}') - set_xml_value(deleteuserconfiguration, user_configuration_name, version=self.account.version) - return deleteuserconfiguration + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), user_configuration_name, version=self.account.version + ) diff --git a/exchangelib/services/empty_folder.py b/exchangelib/services/empty_folder.py index be666f35..4eb44ff2 100644 --- a/exchangelib/services/empty_folder.py +++ b/exchangelib/services/empty_folder.py @@ -14,10 +14,9 @@ def call(self, folders, delete_type, delete_sub_folders): ) def get_payload(self, folders, delete_type, delete_sub_folders): - emptyfolder = create_element( + payload = create_element( f'm:{self.SERVICE_NAME}', attrs=dict(DeleteType=delete_type, DeleteSubFolders=delete_sub_folders) ) - folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version) - emptyfolder.append(folder_ids) - return emptyfolder + payload.append(create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version)) + return payload diff --git a/exchangelib/services/expand_dl.py b/exchangelib/services/expand_dl.py index 68d27f18..33f9b01b 100644 --- a/exchangelib/services/expand_dl.py +++ b/exchangelib/services/expand_dl.py @@ -22,6 +22,4 @@ def _elems_to_objs(self, elems): yield Mailbox.from_xml(elem, account=None) def get_payload(self, distribution_list): - payload = create_element(f'm:{self.SERVICE_NAME}') - set_xml_value(payload, distribution_list, version=self.protocol.version) - return payload + return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), distribution_list, version=self.protocol.version) diff --git a/exchangelib/services/export_items.py b/exchangelib/services/export_items.py index 11bc292d..2ba1494a 100644 --- a/exchangelib/services/export_items.py +++ b/exchangelib/services/export_items.py @@ -21,10 +21,9 @@ def _elems_to_objs(self, elems): yield elem.text # All we want is the 64bit string in the 'Data' tag def get_payload(self, items): - exportitems = create_element(f'm:{self.SERVICE_NAME}') - item_ids = create_item_ids_element(items=items, version=self.account.version) - exportitems.append(item_ids) - return exportitems + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(create_item_ids_element(items=items, version=self.account.version)) + return payload # We need to override this since ExportItemsResponseMessage is formatted a # little bit differently. . diff --git a/exchangelib/services/find_folder.py b/exchangelib/services/find_folder.py index e2ce62cc..7d7fffa5 100644 --- a/exchangelib/services/find_folder.py +++ b/exchangelib/services/find_folder.py @@ -55,23 +55,20 @@ def _elems_to_objs(self, elems): yield Folder.from_xml_with_root(elem=elem, root=self.root) def get_payload(self, folders, additional_fields, restriction, shape, depth, page_size, offset=0): - findfolder = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) - foldershape = create_shape_element( + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) + payload.append(create_shape_element( tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version - ) - findfolder.append(foldershape) + )) if self.account.version.build >= EXCHANGE_2010: - indexedpageviewitem = create_element( + indexed_page_folder_view = create_element( 'm:IndexedPageFolderView', attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') ) - findfolder.append(indexedpageviewitem) + payload.append(indexed_page_folder_view) else: if offset != 0: raise ValueError('Offsets are only supported from Exchange 2010') if restriction: - findfolder.append(restriction.to_xml(version=self.account.version)) - parentfolderids = create_element('m:ParentFolderIds') - set_xml_value(parentfolderids, folders, version=self.account.version) - findfolder.append(parentfolderids) - return findfolder + payload.append(restriction.to_xml(version=self.account.version)) + payload.append(set_xml_value(create_element('m:ParentFolderIds'), folders, version=self.account.version)) + return payload diff --git a/exchangelib/services/find_item.py b/exchangelib/services/find_item.py index 9ce1f39a..870f53c7 100644 --- a/exchangelib/services/find_item.py +++ b/exchangelib/services/find_item.py @@ -66,11 +66,10 @@ def _elems_to_objs(self, elems): def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, calendar_view, page_size, offset=0): - finditem = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) - itemshape = create_shape_element( + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) + payload.append(create_shape_element( tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version - ) - finditem.append(itemshape) + )) if calendar_view is None: view_type = create_element( 'm:IndexedPageItemView', @@ -78,20 +77,20 @@ def get_payload(self, folders, additional_fields, restriction, order_fields, que ) else: view_type = calendar_view.to_xml(version=self.account.version) - finditem.append(view_type) + payload.append(view_type) if restriction: - finditem.append(restriction.to_xml(version=self.account.version)) + payload.append(restriction.to_xml(version=self.account.version)) if order_fields: - finditem.append(set_xml_value( + payload.append(set_xml_value( create_element('m:SortOrder'), order_fields, version=self.account.version )) - finditem.append(set_xml_value( + payload.append(set_xml_value( create_element('m:ParentFolderIds'), folders, version=self.account.version )) if query_string: - finditem.append(query_string.to_xml(version=self.account.version)) - return finditem + payload.append(query_string.to_xml(version=self.account.version)) + return payload diff --git a/exchangelib/services/find_people.py b/exchangelib/services/find_people.py index 7ce5fdd2..24e260f0 100644 --- a/exchangelib/services/find_people.py +++ b/exchangelib/services/find_people.py @@ -70,32 +70,30 @@ def get_payload(self, folders, additional_fields, restriction, order_fields, que if len(folders) != 1: raise ValueError(f'{self.SERVICE_NAME} can only query one folder') folder = folders[0] - findpeople = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) - personashape = create_shape_element( + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) + payload.append(create_shape_element( tag='m:PersonaShape', shape=shape, additional_fields=additional_fields, version=self.account.version - ) - findpeople.append(personashape) - view_type = create_element( + )) + payload.append(create_element( 'm:IndexedPageItemView', attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') - ) - findpeople.append(view_type) + )) if restriction: - findpeople.append(restriction.to_xml(version=self.account.version)) + payload.append(restriction.to_xml(version=self.account.version)) if order_fields: - findpeople.append(set_xml_value( + payload.append(set_xml_value( create_element('m:SortOrder'), order_fields, version=self.account.version )) - findpeople.append(set_xml_value( + payload.append(set_xml_value( create_element('m:ParentFolderId'), folder, version=self.account.version )) if query_string: - findpeople.append(query_string.to_xml(version=self.account.version)) - return findpeople + payload.append(query_string.to_xml(version=self.account.version)) + return payload @staticmethod def _get_paging_values(elem): diff --git a/exchangelib/services/get_attachment.py b/exchangelib/services/get_attachment.py index f35d7c2d..e61e4f45 100644 --- a/exchangelib/services/get_attachment.py +++ b/exchangelib/services/get_attachment.py @@ -50,8 +50,7 @@ def get_payload(self, items, include_mime_content, body_type, filter_html_conten shape_elem.append(additional_properties) if len(shape_elem): payload.append(shape_elem) - attachment_ids = create_attachment_ids_element(items=items, version=self.account.version) - payload.append(attachment_ids) + payload.append(create_attachment_ids_element(items=items, version=self.account.version)) return payload def _update_api_version(self, api_version, header, **parse_opts): diff --git a/exchangelib/services/get_events.py b/exchangelib/services/get_events.py index 1bd60e2c..248bc2a0 100644 --- a/exchangelib/services/get_events.py +++ b/exchangelib/services/get_events.py @@ -32,7 +32,7 @@ def _get_elements_in_container(cls, container): return container.findall(Notification.response_tag()) def get_payload(self, subscription_id, watermark): - getstreamingevents = create_element(f'm:{self.SERVICE_NAME}') - add_xml_child(getstreamingevents, 'm:SubscriptionId', subscription_id) - add_xml_child(getstreamingevents, 'm:Watermark', watermark) - return getstreamingevents + payload = create_element(f'm:{self.SERVICE_NAME}') + add_xml_child(payload, 'm:SubscriptionId', subscription_id) + add_xml_child(payload, 'm:Watermark', watermark) + return payload diff --git a/exchangelib/services/get_folder.py b/exchangelib/services/get_folder.py index f1511b6b..ca146941 100644 --- a/exchangelib/services/get_folder.py +++ b/exchangelib/services/get_folder.py @@ -44,11 +44,9 @@ def _elems_to_objs(self, elems): yield parse_folder_elem(elem=elem, folder=folder, account=self.account) def get_payload(self, folders, additional_fields, shape): - getfolder = create_element(f'm:{self.SERVICE_NAME}') - foldershape = create_shape_element( + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(create_shape_element( tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version - ) - getfolder.append(foldershape) - folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version) - getfolder.append(folder_ids) - return getfolder + )) + payload.append(create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version)) + return payload diff --git a/exchangelib/services/get_item.py b/exchangelib/services/get_item.py index a118a2a9..0a828c9d 100644 --- a/exchangelib/services/get_item.py +++ b/exchangelib/services/get_item.py @@ -30,11 +30,9 @@ def _elems_to_objs(self, elems): yield BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) def get_payload(self, items, additional_fields, shape): - getitem = create_element(f'm:{self.SERVICE_NAME}') - itemshape = create_shape_element( + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(create_shape_element( tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version - ) - getitem.append(itemshape) - item_ids = create_item_ids_element(items=items, version=self.account.version) - getitem.append(item_ids) - return getitem + )) + payload.append(create_item_ids_element(items=items, version=self.account.version)) + return payload diff --git a/exchangelib/services/get_persona.py b/exchangelib/services/get_persona.py index f6bd0b61..6fc7774f 100644 --- a/exchangelib/services/get_persona.py +++ b/exchangelib/services/get_persona.py @@ -22,10 +22,11 @@ def _elems_to_objs(self, elems): return Persona.from_xml(elem=elem, account=None) def get_payload(self, persona): - version = self.protocol.version - payload = create_element(f'm:{self.SERVICE_NAME}') - set_xml_value(payload, to_item_id(persona, PersonaId, version=version), version=version) - return payload + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), + to_item_id(persona, PersonaId, version=self.protocol.version), + version=self.protocol.version + ) @classmethod def _get_elements_in_container(cls, container): diff --git a/exchangelib/services/get_rooms.py b/exchangelib/services/get_rooms.py index b43f6a83..4315a174 100644 --- a/exchangelib/services/get_rooms.py +++ b/exchangelib/services/get_rooms.py @@ -11,8 +11,8 @@ class GetRooms(EWSService): element_container_name = f'{{{MNS}}}Rooms' supported_from = EXCHANGE_2010 - def call(self, roomlist): - return self._elems_to_objs(self._get_elements(payload=self.get_payload(roomlist=roomlist))) + def call(self, room_list): + return self._elems_to_objs(self._get_elements(payload=self.get_payload(room_list=room_list))) def _elems_to_objs(self, elems): for elem in elems: @@ -21,7 +21,5 @@ def _elems_to_objs(self, elems): continue yield Room.from_xml(elem=elem, account=None) - def get_payload(self, roomlist): - getrooms = create_element(f'm:{self.SERVICE_NAME}') - set_xml_value(getrooms, roomlist, version=self.protocol.version) - return getrooms + def get_payload(self, room_list): + return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), room_list, version=self.protocol.version) diff --git a/exchangelib/services/get_server_time_zones.py b/exchangelib/services/get_server_time_zones.py index cd680052..218f5e86 100644 --- a/exchangelib/services/get_server_time_zones.py +++ b/exchangelib/services/get_server_time_zones.py @@ -51,9 +51,9 @@ def _elems_to_objs(self, elems): yield tz_id, tz_name, tz_periods, tz_transitions, tz_transitions_groups @staticmethod - def _get_periods(timezonedef): + def _get_periods(timezone_def): tz_periods = {} - periods = timezonedef.find(f'{{{TNS}}}Periods') + periods = timezone_def.find(f'{{{TNS}}}Periods') for period in periods.findall(f'{{{TNS}}}Period'): # Convert e.g. "trule:Microsoft/Registry/W. Europe Standard Time/2006-Daylight" to (2006, 'Daylight') p_year, p_type = period.get('Id').rsplit('/', 1)[1].split('-') @@ -64,20 +64,20 @@ def _get_periods(timezonedef): return tz_periods @staticmethod - def _get_transitions_groups(timezonedef): + def _get_transitions_groups(timezone_def): tz_transitions_groups = {} - transitiongroups = timezonedef.find(f'{{{TNS}}}TransitionsGroups') - if transitiongroups is not None: - for transitiongroup in transitiongroups.findall(f'{{{TNS}}}TransitionsGroup'): - tg_id = int(transitiongroup.get('Id')) + transition_groups = timezone_def.find(f'{{{TNS}}}TransitionsGroups') + if transition_groups is not None: + for transition_group in transition_groups.findall(f'{{{TNS}}}TransitionsGroup'): + tg_id = int(transition_group.get('Id')) tz_transitions_groups[tg_id] = [] - for transition in transitiongroup.findall(f'{{{TNS}}}Transition'): + for transition in transition_group.findall(f'{{{TNS}}}Transition'): # Apply same conversion to To as for period IDs to_year, to_type = transition.find(f'{{{TNS}}}To').text.rsplit('/', 1)[1].split('-') tz_transitions_groups[tg_id].append(dict( to=(int(to_year), to_type), )) - for transition in transitiongroup.findall(f'{{{TNS}}}RecurringDayTransition'): + for transition in transition_group.findall(f'{{{TNS}}}RecurringDayTransition'): # Apply same conversion to To as for period IDs to_year, to_type = transition.find(f'{{{TNS}}}To').text.rsplit('/', 1)[1].split('-') occurrence = xml_text_to_value(transition.find(f'{{{TNS}}}Occurrence').text, int) @@ -94,9 +94,9 @@ def _get_transitions_groups(timezonedef): return tz_transitions_groups @staticmethod - def _get_transitions(timezonedef): + def _get_transitions(timezone_def): tz_transitions = {} - transitions = timezonedef.find(f'{{{TNS}}}Transitions') + transitions = timezone_def.find(f'{{{TNS}}}Transitions') if transitions is not None: for transition in transitions.findall(f'{{{TNS}}}Transition'): to = transition.find(f'{{{TNS}}}To') diff --git a/exchangelib/services/get_streaming_events.py b/exchangelib/services/get_streaming_events.py index f404cf12..d75299b5 100644 --- a/exchangelib/services/get_streaming_events.py +++ b/exchangelib/services/get_streaming_events.py @@ -88,13 +88,13 @@ def _get_element_container(self, message, name=None): return [] if name is None else res def get_payload(self, subscription_ids, connection_timeout): - getstreamingevents = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f'm:{self.SERVICE_NAME}') subscriptions_elem = create_element('m:SubscriptionIds') for subscription_id in subscription_ids: add_xml_child(subscriptions_elem, 't:SubscriptionId', subscription_id) if not len(subscriptions_elem): raise ValueError('"subscription_ids" must not be empty') - getstreamingevents.append(subscriptions_elem) - add_xml_child(getstreamingevents, 'm:ConnectionTimeout', connection_timeout) - return getstreamingevents + payload.append(subscriptions_elem) + add_xml_child(payload, 'm:ConnectionTimeout', connection_timeout) + return payload diff --git a/exchangelib/services/get_user_availability.py b/exchangelib/services/get_user_availability.py index fce084cf..000e0720 100644 --- a/exchangelib/services/get_user_availability.py +++ b/exchangelib/services/get_user_availability.py @@ -33,8 +33,7 @@ def get_payload(self, timezone, mailbox_data, free_busy_view_options): mailbox_data_array = create_element('m:MailboxDataArray') set_xml_value(mailbox_data_array, mailbox_data, version=self.protocol.version) payload.append(mailbox_data_array) - set_xml_value(payload, free_busy_view_options, version=self.protocol.version) - return payload + return set_xml_value(payload, free_busy_view_options, version=self.protocol.version) @staticmethod def _response_messages_tag(): diff --git a/exchangelib/services/get_user_configuration.py b/exchangelib/services/get_user_configuration.py index 9d3f0357..8a684eac 100644 --- a/exchangelib/services/get_user_configuration.py +++ b/exchangelib/services/get_user_configuration.py @@ -36,9 +36,9 @@ def _get_elements_in_container(cls, container): return container.findall(UserConfiguration.response_tag()) def get_payload(self, user_configuration_name, properties): - getuserconfiguration = create_element(f'm:{self.SERVICE_NAME}') - set_xml_value(getuserconfiguration, user_configuration_name, version=self.account.version) - user_configuration_properties = create_element('m:UserConfigurationProperties') - set_xml_value(user_configuration_properties, properties, version=self.account.version) - getuserconfiguration.append(user_configuration_properties) - return getuserconfiguration + payload = create_element(f'm:{self.SERVICE_NAME}') + set_xml_value(payload, user_configuration_name, version=self.account.version) + payload.append( + set_xml_value(create_element('m:UserConfigurationProperties'), properties, version=self.account.version) + ) + return payload diff --git a/exchangelib/services/get_user_oof_settings.py b/exchangelib/services/get_user_oof_settings.py index 342977b0..eb8eb91d 100644 --- a/exchangelib/services/get_user_oof_settings.py +++ b/exchangelib/services/get_user_oof_settings.py @@ -23,8 +23,11 @@ def _elems_to_objs(self, elems): yield OofSettings.from_xml(elem=elem, account=self.account) def get_payload(self, mailbox): - payload = create_element(f'm:{self.SERVICE_NAME}Request') - return set_xml_value(payload, AvailabilityMailbox.from_mailbox(mailbox), version=self.account.version) + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}Request'), + AvailabilityMailbox.from_mailbox(mailbox), + version=self.account.version + ) @classmethod def _get_elements_in_container(cls, container): diff --git a/exchangelib/services/mark_as_junk.py b/exchangelib/services/mark_as_junk.py index 94e79f4d..78801e2b 100644 --- a/exchangelib/services/mark_as_junk.py +++ b/exchangelib/services/mark_as_junk.py @@ -26,7 +26,6 @@ def _get_elements_in_container(cls, container): def get_payload(self, items, is_junk, move_item): # Takes a list of items and returns either success or raises an error message - mark_as_junk = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IsJunk=is_junk, MoveItem=move_item)) - item_ids = create_item_ids_element(items=items, version=self.account.version) - mark_as_junk.append(item_ids) - return mark_as_junk + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IsJunk=is_junk, MoveItem=move_item)) + payload.append(create_item_ids_element(items=items, version=self.account.version)) + return payload diff --git a/exchangelib/services/move_folder.py b/exchangelib/services/move_folder.py index 06830e77..e1983fe2 100644 --- a/exchangelib/services/move_folder.py +++ b/exchangelib/services/move_folder.py @@ -24,10 +24,7 @@ def _elems_to_objs(self, elems): def get_payload(self, folders, to_folder): # Takes a list of folders and returns their new folder IDs - movefolder = create_element(f'm:{self.SERVICE_NAME}') - tofolderid = create_element('m:ToFolderId') - set_xml_value(tofolderid, to_folder, version=self.account.version) - movefolder.append(tofolderid) - folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version) - movefolder.append(folder_ids) - return movefolder + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(set_xml_value(create_element('m:ToFolderId'), to_folder, version=self.account.version)) + payload.append(create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version)) + return payload diff --git a/exchangelib/services/move_item.py b/exchangelib/services/move_item.py index ead6fc75..8a2ba564 100644 --- a/exchangelib/services/move_item.py +++ b/exchangelib/services/move_item.py @@ -25,10 +25,7 @@ def _elems_to_objs(self, elems): def get_payload(self, items, to_folder): # Takes a list of items and returns their new item IDs - moveitem = create_element(f'm:{self.SERVICE_NAME}') - tofolderid = create_element('m:ToFolderId') - set_xml_value(tofolderid, to_folder, version=self.account.version) - moveitem.append(tofolderid) - item_ids = create_item_ids_element(items=items, version=self.account.version) - moveitem.append(item_ids) - return moveitem + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(set_xml_value(create_element('m:ToFolderId'), to_folder, version=self.account.version)) + payload.append(create_item_ids_element(items=items, version=self.account.version)) + return payload diff --git a/exchangelib/services/resolve_names.py b/exchangelib/services/resolve_names.py index af8887cd..97c62bea 100644 --- a/exchangelib/services/resolve_names.py +++ b/exchangelib/services/resolve_names.py @@ -75,8 +75,7 @@ def get_payload(self, unresolved_entries, parent_folders, return_full_contact_da attrs['ContactDataShape'] = contact_data_shape payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) if parent_folders: - parentfolderids = create_element('m:ParentFolderIds') - set_xml_value(parentfolderids, parent_folders, version=self.protocol.version) + set_xml_value(create_element('m:ParentFolderIds'), parent_folders, version=self.protocol.version) for entry in unresolved_entries: add_xml_child(payload, 'm:UnresolvedEntry', entry) if not len(payload): diff --git a/exchangelib/services/send_item.py b/exchangelib/services/send_item.py index bc494bd0..2d336866 100644 --- a/exchangelib/services/send_item.py +++ b/exchangelib/services/send_item.py @@ -16,11 +16,10 @@ def call(self, items, saved_item_folder): return self._chunked_get_elements(self.get_payload, items=items, saved_item_folder=saved_item_folder) def get_payload(self, items, saved_item_folder): - senditem = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(SaveItemToFolder=bool(saved_item_folder))) - item_ids = create_item_ids_element(items=items, version=self.account.version) - senditem.append(item_ids) + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(SaveItemToFolder=bool(saved_item_folder))) + payload.append(create_item_ids_element(items=items, version=self.account.version)) if saved_item_folder: - saveditemfolderid = create_element('m:SavedItemFolderId') - set_xml_value(saveditemfolderid, saved_item_folder, version=self.account.version) - senditem.append(saveditemfolderid) - return senditem + payload.append( + set_xml_value(create_element('m:SavedItemFolderId'), saved_item_folder, version=self.account.version) + ) + return payload diff --git a/exchangelib/services/set_user_oof_settings.py b/exchangelib/services/set_user_oof_settings.py index f392cd86..c4d325a5 100644 --- a/exchangelib/services/set_user_oof_settings.py +++ b/exchangelib/services/set_user_oof_settings.py @@ -22,8 +22,7 @@ def call(self, oof_settings, mailbox): def get_payload(self, oof_settings, mailbox): payload = create_element(f'm:{self.SERVICE_NAME}Request') set_xml_value(payload, AvailabilityMailbox.from_mailbox(mailbox), version=self.account.version) - set_xml_value(payload, oof_settings, version=self.account.version) - return payload + return set_xml_value(payload, oof_settings, version=self.account.version) def _get_element_container(self, message, name=None): message = message.find(self._response_message_tag()) diff --git a/exchangelib/services/subscribe.py b/exchangelib/services/subscribe.py index d231bfc7..97cd720a 100644 --- a/exchangelib/services/subscribe.py +++ b/exchangelib/services/subscribe.py @@ -65,13 +65,13 @@ def call(self, folders, event_types, watermark, timeout): ) def get_payload(self, folders, event_types, watermark, timeout): - subscribe = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f'm:{self.SERVICE_NAME}') request_elem = self._partial_payload(folders=folders, event_types=event_types) if watermark: add_xml_child(request_elem, 'm:Watermark', watermark) add_xml_child(request_elem, 't:Timeout', timeout) # In minutes - subscribe.append(request_elem) - return subscribe + payload.append(request_elem) + return payload class SubscribeToPush(Subscribe): @@ -84,14 +84,14 @@ def call(self, folders, event_types, watermark, status_frequency, url): ) def get_payload(self, folders, event_types, watermark, status_frequency, url): - subscribe = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f'm:{self.SERVICE_NAME}') request_elem = self._partial_payload(folders=folders, event_types=event_types) if watermark: add_xml_child(request_elem, 'm:Watermark', watermark) add_xml_child(request_elem, 't:StatusFrequency', status_frequency) # In minutes add_xml_child(request_elem, 't:URL', url) - subscribe.append(request_elem) - return subscribe + payload.append(request_elem) + return payload class SubscribeToStreaming(Subscribe): @@ -113,7 +113,6 @@ def _get_elements_in_container(cls, container): return [container.find(f'{{{MNS}}}SubscriptionId')] def get_payload(self, folders, event_types): - subscribe = create_element(f'm:{self.SERVICE_NAME}') - request_elem = self._partial_payload(folders=folders, event_types=event_types) - subscribe.append(request_elem) - return subscribe + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(self._partial_payload(folders=folders, event_types=event_types)) + return payload diff --git a/exchangelib/services/sync_folder_hierarchy.py b/exchangelib/services/sync_folder_hierarchy.py index c78a0e64..106951c0 100644 --- a/exchangelib/services/sync_folder_hierarchy.py +++ b/exchangelib/services/sync_folder_hierarchy.py @@ -43,16 +43,14 @@ def _get_element_container(self, message, name=None): return super()._get_element_container(message=message, name=name) def _partial_get_payload(self, folder, shape, additional_fields, sync_state): - svc_elem = create_element(f'm:{self.SERVICE_NAME}') - foldershape = create_shape_element( + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(create_shape_element( tag=self.shape_tag, shape=shape, additional_fields=additional_fields, version=self.account.version - ) - svc_elem.append(foldershape) - folder_id = create_folder_ids_element(tag='m:SyncFolderId', folders=[folder], version=self.account.version) - svc_elem.append(folder_id) + )) + payload.append(create_folder_ids_element(tag='m:SyncFolderId', folders=[folder], version=self.account.version)) if sync_state: - add_xml_child(svc_elem, 'm:SyncState', sync_state) - return svc_elem + add_xml_child(payload, 'm:SyncState', sync_state) + return payload class SyncFolderHierarchy(SyncFolder): diff --git a/exchangelib/services/sync_folder_items.py b/exchangelib/services/sync_folder_items.py index ae9bf609..2c0bc52e 100644 --- a/exchangelib/services/sync_folder_items.py +++ b/exchangelib/services/sync_folder_items.py @@ -66,14 +66,15 @@ def _elems_to_objs(self, elems): yield change_type, item def get_payload(self, folder, shape, additional_fields, sync_state, ignore, max_changes_returned, sync_scope): - syncfolderitems = self._partial_get_payload( + sync_folder_items = self._partial_get_payload( folder=folder, shape=shape, additional_fields=additional_fields, sync_state=sync_state ) is_empty, ignore = (True, None) if ignore is None else peek(ignore) if not is_empty: - item_ids = create_item_ids_element(items=ignore, version=self.account.version, tag='m:Ignore') - syncfolderitems.append(item_ids) - add_xml_child(syncfolderitems, 'm:MaxChangesReturned', max_changes_returned) + sync_folder_items.append( + create_item_ids_element(items=ignore, version=self.account.version, tag='m:Ignore') + ) + add_xml_child(sync_folder_items, 'm:MaxChangesReturned', max_changes_returned) if sync_scope: - add_xml_child(syncfolderitems, 'm:SyncScope', sync_scope) - return syncfolderitems + add_xml_child(sync_folder_items, 'm:SyncScope', sync_scope) + return sync_folder_items diff --git a/exchangelib/services/unsubscribe.py b/exchangelib/services/unsubscribe.py index fff44ad4..4bb03566 100644 --- a/exchangelib/services/unsubscribe.py +++ b/exchangelib/services/unsubscribe.py @@ -16,6 +16,6 @@ def call(self, subscription_id): return self._get_elements(payload=self.get_payload(subscription_id=subscription_id)) def get_payload(self, subscription_id): - unsubscribe = create_element(f'm:{self.SERVICE_NAME}') - add_xml_child(unsubscribe, 'm:SubscriptionId', subscription_id) - return unsubscribe + payload = create_element(f'm:{self.SERVICE_NAME}') + add_xml_child(payload, 'm:SubscriptionId', subscription_id) + return payload diff --git a/exchangelib/services/update_folder.py b/exchangelib/services/update_folder.py index 8a54b682..0e709d02 100644 --- a/exchangelib/services/update_folder.py +++ b/exchangelib/services/update_folder.py @@ -57,21 +57,21 @@ def _set_field_elems(self, target_model, field, value): yield self._set_field_elem(target_model=target_model, field_path=FieldPath(field=field), value=value) def _set_field_elem(self, target_model, field_path, value): - setfield = create_element(self.SET_FIELD_ELEMENT_NAME) - set_xml_value(setfield, field_path, version=self.account.version) + set_field = create_element(self.SET_FIELD_ELEMENT_NAME) + set_xml_value(set_field, field_path, version=self.account.version) folder = create_element(target_model.request_tag()) field_elem = field_path.field.to_xml(value, version=self.account.version) set_xml_value(folder, field_elem, version=self.account.version) - setfield.append(folder) - return setfield + set_field.append(folder) + return set_field def _delete_field_elems(self, field): for field_path in FieldPath(field=field).expand(version=self.account.version): yield self._delete_field_elem(field_path=field_path) def _delete_field_elem(self, field_path): - deletefolderfield = create_element(self.DELETE_FIELD_ELEMENT_NAME) - return set_xml_value(deletefolderfield, field_path, version=self.account.version) + delete_folder_field = create_element(self.DELETE_FIELD_ELEMENT_NAME) + return set_xml_value(delete_folder_field, field_path, version=self.account.version) def _update_elems(self, target, fieldnames): target_model = target.__class__ @@ -152,6 +152,6 @@ def _target_elem(self, target): def get_payload(self, folders): # Takes a list of (Folder, fieldnames) tuples where 'Folder' is a instance of a subclass of Folder and # 'fieldnames' are the attribute names that were updated. - updatefolder = create_element(f'm:{self.SERVICE_NAME}') - updatefolder.append(self._changes_elem(target_changes=folders)) - return updatefolder + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(self._changes_elem(target_changes=folders)) + return payload diff --git a/exchangelib/services/update_item.py b/exchangelib/services/update_item.py index 461b1ff8..8e4ebf44 100644 --- a/exchangelib/services/update_item.py +++ b/exchangelib/services/update_item.py @@ -100,6 +100,6 @@ def get_payload(self, items, conflict_resolution, message_disposition, send_meet ) if self.account.version.build >= EXCHANGE_2013_SP1: attrs['SuppressReadReceipts'] = suppress_read_receipts - updateitem = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) - updateitem.append(self._changes_elem(target_changes=items)) - return updateitem + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + payload.append(self._changes_elem(target_changes=items)) + return payload diff --git a/exchangelib/services/update_user_configuration.py b/exchangelib/services/update_user_configuration.py index df9d2231..2c7753d1 100644 --- a/exchangelib/services/update_user_configuration.py +++ b/exchangelib/services/update_user_configuration.py @@ -14,6 +14,4 @@ def call(self, user_configuration): return self._get_elements(payload=self.get_payload(user_configuration=user_configuration)) def get_payload(self, user_configuration): - updateuserconfiguration = create_element(f'm:{self.SERVICE_NAME}') - set_xml_value(updateuserconfiguration, user_configuration, version=self.account.version) - return updateuserconfiguration + return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), user_configuration, version=self.account.version) diff --git a/exchangelib/services/upload_items.py b/exchangelib/services/upload_items.py index 812b7c59..09810edb 100644 --- a/exchangelib/services/upload_items.py +++ b/exchangelib/services/upload_items.py @@ -24,23 +24,24 @@ def get_payload(self, items): :param items: """ - uploaditems = create_element(f'm:{self.SERVICE_NAME}') - itemselement = create_element('m:Items') - uploaditems.append(itemselement) + payload = create_element(f'm:{self.SERVICE_NAME}') + items_elem = create_element('m:Items') + payload.append(items_elem) for parent_folder, (item_id, is_associated, data_str) in items: # TODO: The full spec also allows the "UpdateOrCreate" create action. attrs = dict(CreateAction='Update' if item_id else 'CreateNew') if is_associated is not None: attrs['IsAssociated'] = is_associated item = create_element('t:Item', attrs=attrs) - parentfolderid = ParentFolderId(parent_folder.id, parent_folder.changekey) - set_xml_value(item, parentfolderid, version=self.account.version) + parent_folder_id = ParentFolderId(parent_folder.id, parent_folder.changekey) + set_xml_value(item, parent_folder_id, version=self.account.version) if item_id: - itemid = to_item_id(item_id, ItemId, version=self.account.version) - set_xml_value(item, itemid, version=self.account.version) + set_xml_value( + item, to_item_id(item_id, ItemId, version=self.account.version), version=self.account.version + ) add_xml_child(item, 't:Data', data_str) - itemselement.append(item) - return uploaditems + items_elem.append(item) + return payload def _elems_to_objs(self, elems): for elem in elems: diff --git a/exchangelib/transport.py b/exchangelib/transport.py index 04b64cfe..78c1bbbc 100644 --- a/exchangelib/transport.py +++ b/exchangelib/transport.py @@ -69,11 +69,11 @@ def wrap(content, api_version, account_to_impersonate=None, timezone=None): """ envelope = create_element('s:Envelope', nsmap=ns_translation) header = create_element('s:Header') - requestserverversion = create_element('t:RequestServerVersion', attrs=dict(Version=api_version)) - header.append(requestserverversion) + request_server_version = create_element('t:RequestServerVersion', attrs=dict(Version=api_version)) + header.append(request_server_version) if account_to_impersonate: - exchangeimpersonation = create_element('t:ExchangeImpersonation') - connectingsid = create_element('t:ConnectingSID') + exchange_impersonation = create_element('t:ExchangeImpersonation') + connecting_sid = create_element('t:ConnectingSID') # We have multiple options for uniquely identifying the user. Here's a prioritized list in accordance with # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/connectingsid for attr, tag in ( @@ -84,15 +84,15 @@ def wrap(content, api_version, account_to_impersonate=None, timezone=None): ): val = getattr(account_to_impersonate, attr) if val: - add_xml_child(connectingsid, f't:{tag}', val) + add_xml_child(connecting_sid, f't:{tag}', val) break - exchangeimpersonation.append(connectingsid) - header.append(exchangeimpersonation) + exchange_impersonation.append(connecting_sid) + header.append(exchange_impersonation) if timezone: - timezonecontext = create_element('t:TimeZoneContext') - timezonedefinition = create_element('t:TimeZoneDefinition', attrs=dict(Id=timezone.ms_id)) - timezonecontext.append(timezonedefinition) - header.append(timezonecontext) + timezone_context = create_element('t:TimeZoneContext') + timezone_definition = create_element('t:TimeZoneDefinition', attrs=dict(Id=timezone.ms_id)) + timezone_context.append(timezone_definition) + header.append(timezone_context) envelope.append(header) body = create_element('s:Body') body.append(content) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 3c493bb9..6dac1898 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -252,7 +252,7 @@ def test_get_rooms(self): roomlist = RoomList(email_address='my.roomlist@example.com') ws = GetRooms(self.account.protocol) with self.assertRaises(ErrorNameResolutionNoResults): - list(ws.call(roomlist=roomlist)) + list(ws.call(room_list=roomlist)) # Test shortcut with self.assertRaises(ErrorNameResolutionNoResults): list(self.account.protocol.get_rooms('my.roomlist@example.com')) From a726e8551d10b9b69afa0cc37c60a7ec203e847d Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 12 Dec 2021 16:32:22 +0100 Subject: [PATCH 036/509] Document API changes --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85a5e8d5..a240bf85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ Change Log HEAD ---- +- Fixed some spelling mistakes: + - `ALL_OCCURRENCIES` to `ALL_OCCURRENCES` in `exchangelib.items.base` + - `Persona.orgnaization_main_phones` to `organization_main_phones` 4.6.2 From 581b187582bf81b0fd5f10c38a2a86789271a4ee Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 12 Dec 2021 16:35:34 +0100 Subject: [PATCH 037/509] Remove deprecated methods --- CHANGELOG.md | 2 ++ exchangelib/ewsdatetime.py | 26 -------------------------- exchangelib/queryset.py | 5 ----- 3 files changed, 2 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a240bf85..00fa166c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ HEAD - Fixed some spelling mistakes: - `ALL_OCCURRENCIES` to `ALL_OCCURRENCES` in `exchangelib.items.base` - `Persona.orgnaization_main_phones` to `organization_main_phones` +- Removed deprecated methods `EWSTimeZone.localize()`, `EWSTimeZone.normalize()`, + `EWSTimeZone.timezone()` and `QuerySet.iterator()`. 4.6.2 diff --git a/exchangelib/ewsdatetime.py b/exchangelib/ewsdatetime.py index 960ada59..b0f89922 100644 --- a/exchangelib/ewsdatetime.py +++ b/exchangelib/ewsdatetime.py @@ -296,32 +296,6 @@ def localzone(cls): # Handle both old and new versions of tzlocal that may return pytz or zoneinfo objects, respectively return cls.from_timezone(tz) - @classmethod - def timezone(cls, location): - warnings.warn('replace EWSTimeZone.timezone() with just EWSTimeZone()', DeprecationWarning, stacklevel=2) - return cls(location) - - def normalize(self, dt, is_dst=False): - warnings.warn('normalization is now handled gracefully', DeprecationWarning, stacklevel=2) - return dt - - def localize(self, dt, is_dst=False): - warnings.warn('replace tz.localize() with dt.replace(tzinfo=tz)', DeprecationWarning, stacklevel=2) - if dt.tzinfo is not None: - raise ValueError(f'{dt} must be timezone-unaware') - dt = dt.replace(tzinfo=self) - if is_dst is not None: - # DST dates are assumed to always be after non-DST dates - dt_before = dt.replace(fold=0) - dt_after = dt.replace(fold=1) - dst_before = dt_before.dst() - dst_after = dt_after.dst() - if dst_before > dst_after: - dt = dt_before if is_dst else dt_after - elif dst_before < dst_after: - dt = dt_after if is_dst else dt_before - return dt - def fromutc(self, dt): t = super().fromutc(dt) if isinstance(t, EWSDateTime): diff --git a/exchangelib/queryset.py b/exchangelib/queryset.py index aa996989..c4142771 100644 --- a/exchangelib/queryset.py +++ b/exchangelib/queryset.py @@ -504,11 +504,6 @@ def depth(self, depth): new_qs._depth = depth return new_qs - def iterator(self): - # Return an iterator over the results - warnings.warn('QuerySet no longer caches results. .iterator() is a no-op.', DeprecationWarning, stacklevel=2) - return self.__iter__() - ########################### # # Methods that end chaining From 5598230aff5e99d0075c13506f5b50293c8e0182 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 12 Dec 2021 22:38:22 +0100 Subject: [PATCH 038/509] Fix tests after last commit --- exchangelib/ewsdatetime.py | 1 - exchangelib/queryset.py | 1 - tests/test_ewsdatetime.py | 32 ------------------------------- tests/test_items/test_queryset.py | 14 -------------- 4 files changed, 48 deletions(-) diff --git a/exchangelib/ewsdatetime.py b/exchangelib/ewsdatetime.py index b0f89922..89f29150 100644 --- a/exchangelib/ewsdatetime.py +++ b/exchangelib/ewsdatetime.py @@ -1,6 +1,5 @@ import datetime import logging -import warnings try: import zoneinfo diff --git a/exchangelib/queryset.py b/exchangelib/queryset.py index c4142771..65bf7633 100644 --- a/exchangelib/queryset.py +++ b/exchangelib/queryset.py @@ -1,6 +1,5 @@ import abc import logging -import warnings from copy import deepcopy from itertools import islice diff --git a/tests/test_ewsdatetime.py b/tests/test_ewsdatetime.py index 27442ced..89b47f69 100644 --- a/tests/test_ewsdatetime.py +++ b/tests/test_ewsdatetime.py @@ -3,7 +3,6 @@ import dateutil.tz import pytz import requests_mock -import warnings try: import zoneinfo except ImportError: @@ -107,37 +106,6 @@ def test_from_timezone(self): EWSTimeZone.from_timezone(datetime.timezone.utc) ) - def test_localize(self): - # Test some corner cases around DST - tz = EWSTimeZone('Europe/Copenhagen') - with warnings.catch_warnings(): - # localize() is deprecated but we still want to test it. Silence the DeprecationWarning - warnings.simplefilter("ignore") - self.assertEqual( - str(tz.localize(EWSDateTime(2023, 10, 29, 2, 36, 0), is_dst=False)), - '2023-10-29 02:36:00+01:00' - ) - self.assertEqual( - str(tz.localize(EWSDateTime(2023, 10, 29, 2, 36, 0), is_dst=None)), - '2023-10-29 02:36:00+02:00' - ) - self.assertEqual( - str(tz.localize(EWSDateTime(2023, 10, 29, 2, 36, 0), is_dst=True)), - '2023-10-29 02:36:00+02:00' - ) - self.assertEqual( - str(tz.localize(EWSDateTime(2023, 3, 26, 2, 36, 0), is_dst=False)), - '2023-03-26 02:36:00+01:00' - ) - self.assertEqual( - str(tz.localize(EWSDateTime(2023, 3, 26, 2, 36, 0), is_dst=None)), - '2023-03-26 02:36:00+01:00' - ) - self.assertEqual( - str(tz.localize(EWSDateTime(2023, 3, 26, 2, 36, 0), is_dst=True)), - '2023-03-26 02:36:00+02:00' - ) - def test_ewsdatetime(self): # Test a static timezone tz = EWSTimeZone('Etc/GMT-5') diff --git a/tests/test_items/test_queryset.py b/tests/test_items/test_queryset.py index 2f077aa7..aa718168 100644 --- a/tests/test_items/test_queryset.py +++ b/tests/test_items/test_queryset.py @@ -1,5 +1,4 @@ import time -import warnings from exchangelib.folders import Inbox, FolderCollection from exchangelib.items import Message, SHALLOW, ASSOCIATED @@ -148,19 +147,6 @@ def test_querysets(self): [i.categories[0] for i in qs.only('categories').order_by('subject')], [test_cat, test_cat, test_cat, test_cat] ) - # Test iterator - with warnings.catch_warnings(): - # iterator() is deprecated but we still want to test it. Silence the DeprecationWarning - warnings.simplefilter("ignore") - self.assertEqual( - {(i.subject, i.categories[0]) for i in qs.iterator()}, - {('Item 0', test_cat), ('Item 1', test_cat), ('Item 2', test_cat), ('Item 3', test_cat)} - ) - # Test that iterator() preserves the result format - self.assertEqual( - {(i[0], i[1][0]) for i in qs.values_list('subject', 'categories').iterator()}, - {('Item 0', test_cat), ('Item 1', test_cat), ('Item 2', test_cat), ('Item 3', test_cat)} - ) self.assertEqual(qs.get(subject='Item 3').subject, 'Item 3') with self.assertRaises(DoesNotExist): qs.get(subject='Item XXX') From 2d8700684aca835a0157aa3d61f1877468c6511d Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 13 Dec 2021 12:53:01 +0100 Subject: [PATCH 039/509] Add docs on filtering on indexed properties. Properly test __contains on indexed properties. Refs #1026 --- docs/index.md | 9 +++++++++ tests/common.py | 6 ++++++ tests/test_items/test_basics.py | 10 ++++------ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/index.md b/docs/index.md index 24bb926f..dcb0db33 100644 --- a/docs/index.md +++ b/docs/index.md @@ -930,6 +930,7 @@ ordered_items = a.inbox.all().order_by('subject') reverse_ordered_items = a.inbox.all().order_by('-subject') # Indexed properties can be ordered on their individual components sorted_by_home_street = a.contacts.all().order_by( + 'phone_numbers__CarPhone', 'physical_addresses__Home__street' ) # Beware that sorting is done client-side here @@ -1031,6 +1032,14 @@ qs.filter(categories__exists=True) # Returns items that have no categories set, i.e. the field does not exist on # the item on the server. qs.filter(categories__exists=False) + +# When filtering on indexed properties, you need to specify the full path to the +# value you want to filter on. +a.contacts.filter(phone_numbers__CarPhone='123456') +a.contacts.filter(phone_numbers__CarPhone__contains='123') +a.contacts.filter(physical_addresses__Home__street='Elm Street') +a.contacts.filter(physical_addresses__Home__street__contains='Elm') + ``` WARNING: Filtering on the 'body' field is not fully supported by EWS. There diff --git a/tests/common.py b/tests/common.py index 3f3861e7..cc087521 100644 --- a/tests/common.py +++ b/tests/common.py @@ -293,6 +293,12 @@ def get_random_string(length, spaces=True, special=True): return res +def get_random_substring(val): + random_start = get_random_int(min_val=0, max_val=len(val) // 2) + random_end = get_random_int(min_val=len(val) // 2 + 1, max_val=len(val)) + return val[random_start:random_end] + + def get_random_byte(): return get_random_bytes(1) diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index f9db3abd..e7652aae 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -19,7 +19,7 @@ from exchangelib.util import value_to_xml_text from ..common import EWSTest, get_random_string, get_random_datetime_range, get_random_date, \ - get_random_decimal, get_random_choice, get_random_int, get_random_datetime + get_random_decimal, get_random_choice, get_random_substring, get_random_datetime class BaseItemTest(EWSTest, metaclass=abc.ABCMeta): @@ -323,9 +323,7 @@ def test_filter_on_simple_fields(self): filter_kwargs = [{f.name: val}, {f'{f.name}__in': [val]}] if isinstance(f, TextField) and not isinstance(f, ChoiceField): # Choice fields cannot be filtered using __contains. Sort of makes sense. - random_start = get_random_int(min_val=0, max_val=len(val)//2) - random_end = get_random_int(min_val=len(val)//2+1, max_val=len(val)) - filter_kwargs.append({f'{f.name}__contains': val[random_start:random_end]}) + filter_kwargs.append({f'{f.name}__contains': get_random_substring(val)}) self._run_filter_tests(common_qs, f, filter_kwargs, val) def test_filter_on_list_fields(self): @@ -374,7 +372,7 @@ def test_filter_on_single_field_index_fields(self): continue filter_kwargs.extend([ {f.name: v}, {path: subval}, - {f'{path}__in': [subval]}, {f'{path}__contains': [subval]} + {f'{path}__in': [subval]}, {f'{path}__contains': get_random_substring(subval)} ]) self._run_filter_tests(common_qs, f, filter_kwargs, val) @@ -403,7 +401,7 @@ def test_filter_on_multi_field_index_fields(self): if subval is None: continue filter_kwargs.extend([ - {path: subval}, {f'{path}__in': [subval]}, {f'{path}__contains': [subval]} + {path: subval}, {f'{path}__in': [subval]}, {f'{path}__contains': get_random_substring(subval)} ]) self._run_filter_tests(common_qs, f, filter_kwargs, val) From 51bd20c8a436fca68d89507942dd647566257ad8 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 13 Dec 2021 15:14:20 +0100 Subject: [PATCH 040/509] services are higher-level than folders and items. Change imports to reflect that. --- exchangelib/attachments.py | 5 +++- exchangelib/folders/base.py | 36 +++++++++++++++++------ exchangelib/folders/collections.py | 30 +++++++++++++------ exchangelib/items/base.py | 3 +- exchangelib/items/calendar_item.py | 2 +- exchangelib/items/item.py | 8 ++++- exchangelib/items/message.py | 3 +- exchangelib/queryset.py | 2 +- exchangelib/services/__init__.py | 2 +- exchangelib/services/archive_item.py | 2 +- exchangelib/services/common.py | 7 ++--- exchangelib/services/create_attachment.py | 4 +-- exchangelib/services/create_item.py | 7 ++--- exchangelib/services/delete_item.py | 2 +- exchangelib/services/find_folder.py | 2 +- exchangelib/services/find_item.py | 4 +-- exchangelib/services/find_people.py | 2 +- exchangelib/services/get_attachment.py | 3 +- exchangelib/services/get_item.py | 2 +- exchangelib/services/get_persona.py | 3 +- exchangelib/services/move_folder.py | 2 +- exchangelib/services/move_item.py | 4 +-- exchangelib/services/resolve_names.py | 3 +- exchangelib/services/send_item.py | 2 +- exchangelib/services/sync_folder_items.py | 2 +- exchangelib/services/update_folder.py | 2 +- exchangelib/services/update_item.py | 7 ++--- 27 files changed, 92 insertions(+), 59 deletions(-) diff --git a/exchangelib/attachments.py b/exchangelib/attachments.py index 44d62070..442e2954 100644 --- a/exchangelib/attachments.py +++ b/exchangelib/attachments.py @@ -5,7 +5,6 @@ from .fields import BooleanField, TextField, IntegerField, URIField, DateTimeField, EWSElementField, Base64Field, \ ItemField, IdField, FieldPath from .properties import EWSElement, EWSMeta -from .services import GetAttachment, CreateAttachment, DeleteAttachment log = logging.getLogger(__name__) @@ -54,6 +53,7 @@ def clean(self, version=None): super().clean(version=version) def attach(self): + from .services import CreateAttachment # Adds this attachment to an item and updates the changekey of the parent item if self.attachment_id: raise ValueError('This attachment has already been created') @@ -72,6 +72,7 @@ def attach(self): self.attachment_id = attachment_id def detach(self): + from .services import DeleteAttachment # Deletes an attachment remotely and updates the changekey of the parent item if not self.attachment_id: raise ValueError('This attachment has not been created') @@ -187,6 +188,7 @@ def __init__(self, **kwargs): @property def item(self): from .folders import BaseFolder + from .services import GetAttachment if self.attachment_id is None: return self._item if self._item is not None: @@ -245,6 +247,7 @@ def readinto(self, b): return len(output) def __enter__(self): + from .services import GetAttachment self._stream = GetAttachment(account=self._attachment.parent_item.account).stream_file_content( attachment_id=self._attachment.attachment_id ) diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index f74a0163..37cdb812 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -14,10 +14,6 @@ from ..properties import Mailbox, FolderId, ParentFolderId, DistinguishedFolderId, UserConfiguration, \ UserConfigurationName, UserConfigurationNameMNS, EWSMeta from ..queryset import SearchableMixIn, DoesNotExist -from ..services import CreateFolder, UpdateFolder, DeleteFolder, EmptyFolder, GetUserConfiguration, \ - CreateUserConfiguration, UpdateUserConfiguration, DeleteUserConfiguration, SubscribeToPush, SubscribeToPull, \ - Unsubscribe, GetEvents, GetStreamingEvents, MoveFolder -from ..services.get_user_configuration import ALL from ..util import TNS, require_id, is_iterable from ..version import Version, EXCHANGE_2007_SP1, EXCHANGE_2010 @@ -314,6 +310,7 @@ def bulk_create(self, items, *args, **kwargs): return self.account.bulk_create(folder=self, items=items, *args, **kwargs) def save(self, update_fields=None): + from ..services import CreateFolder, UpdateFolder if self.id is None: # New folder if update_fields: @@ -347,6 +344,7 @@ def save(self, update_fields=None): return self def move(self, to_folder): + from ..services import MoveFolder res = MoveFolder(account=self.account).get(folders=[self], to_folder=to_folder) folder_id, changekey = res.id, res.changekey if self.id != folder_id: @@ -357,6 +355,7 @@ def move(self, to_folder): self.root.update_folder(self) # Update the folder in the cache def delete(self, delete_type=HARD_DELETE): + from ..services import DeleteFolder if delete_type not in DELETE_TYPE_CHOICES: raise ValueError(f"'delete_type' {delete_type!r} must be one of {DELETE_TYPE_CHOICES}") DeleteFolder(account=self.account).get(folders=[self], delete_type=delete_type) @@ -364,6 +363,7 @@ def delete(self, delete_type=HARD_DELETE): self._id = None def empty(self, delete_type=HARD_DELETE, delete_sub_folders=False): + from ..services import EmptyFolder if delete_type not in DELETE_TYPE_CHOICES: raise ValueError(f"'delete_type' {delete_type!r} must be one of {DELETE_TYPE_CHOICES}") EmptyFolder(account=self.account).get( @@ -479,7 +479,11 @@ def refresh(self): return self @require_id - def get_user_configuration(self, name, properties=ALL): + def get_user_configuration(self, name, properties=None): + from ..services import GetUserConfiguration + from ..services.get_user_configuration import ALL + if properties is None: + properties = ALL return GetUserConfiguration(account=self.account).get( user_configuration_name=UserConfigurationNameMNS(name=name, folder=self), properties=properties, @@ -487,6 +491,7 @@ def get_user_configuration(self, name, properties=ALL): @require_id def create_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None): + from ..services import CreateUserConfiguration user_configuration = UserConfiguration( user_configuration_name=UserConfigurationName(name=name, folder=self), dictionary=dictionary, @@ -497,6 +502,7 @@ def create_user_configuration(self, name, dictionary=None, xml_data=None, binary @require_id def update_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None): + from ..services import UpdateUserConfiguration user_configuration = UserConfiguration( user_configuration_name=UserConfigurationName(name=name, folder=self), dictionary=dictionary, @@ -507,12 +513,13 @@ def update_user_configuration(self, name, dictionary=None, xml_data=None, binary @require_id def delete_user_configuration(self, name): + from ..services import DeleteUserConfiguration return DeleteUserConfiguration(account=self.account).get( user_configuration_name=UserConfigurationNameMNS(name=name, folder=self) ) @require_id - def subscribe_to_pull(self, event_types=SubscribeToPull.EVENT_TYPES, watermark=None, timeout=60): + def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): """Create a pull subscription. :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPull.EVENT_TYPES @@ -521,6 +528,9 @@ def subscribe_to_pull(self, event_types=SubscribeToPull.EVENT_TYPES, watermark=N GetEvents request for this subscription. :return: The subscription ID and a watermark """ + from ..services import SubscribeToPull + if event_types is None: + event_types = SubscribeToPull.EVENT_TYPES s_ids = list(FolderCollection(account=self.account, folders=[self]).subscribe_to_pull( event_types=event_types, watermark=watermark, timeout=timeout, )) @@ -532,8 +542,7 @@ def subscribe_to_pull(self, event_types=SubscribeToPull.EVENT_TYPES, watermark=N return s_id @require_id - def subscribe_to_push(self, callback_url, event_types=SubscribeToPush.EVENT_TYPES, watermark=None, - status_frequency=1): + def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1): """Create a push subscription. :param callback_url: A client-defined URL that the server will call @@ -542,6 +551,9 @@ def subscribe_to_push(self, callback_url, event_types=SubscribeToPush.EVENT_TYPE :param status_frequency: The frequency, in minutes, that the callback URL will be called with. :return: The subscription ID and a watermark """ + from ..services import SubscribeToPush + if event_types is None: + event_types = SubscribeToPush.EVENT_TYPES s_ids = list(FolderCollection(account=self.account, folders=[self]).subscribe_to_push( event_types=event_types, watermark=watermark, status_frequency=status_frequency, callback_url=callback_url, )) @@ -553,12 +565,15 @@ def subscribe_to_push(self, callback_url, event_types=SubscribeToPush.EVENT_TYPE return s_id @require_id - def subscribe_to_streaming(self, event_types=SubscribeToPush.EVENT_TYPES): + def subscribe_to_streaming(self, event_types=None): """Create a streaming subscription. :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES :return: The subscription ID """ + from ..services import SubscribeToStreaming + if event_types is None: + event_types = SubscribeToStreaming.EVENT_TYPES s_ids = list(FolderCollection(account=self.account, folders=[self]).subscribe_to_streaming( event_types=event_types, )) @@ -590,6 +605,7 @@ def unsubscribe(self, subscription_id): This method doesn't need the current folder instance, but it makes sense to keep the method along the other sync methods. """ + from ..services import Unsubscribe return Unsubscribe(account=self.account).get(subscription_id=subscription_id) def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None): @@ -648,6 +664,7 @@ def get_events(self, subscription_id, watermark): This method doesn't need the current folder instance, but it makes sense to keep the method along the other sync methods. """ + from ..services import GetEvents svc = GetEvents(account=self.account) while True: notification = svc.get(subscription_id=subscription_id, watermark=watermark) @@ -668,6 +685,7 @@ def get_streaming_events(self, subscription_id_or_ids, connection_timeout=1, max This method doesn't need the current folder instance, but it makes sense to keep the method along the other sync methods. """ + from ..services import GetStreamingEvents # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed request_timeout = connection_timeout*60 + 60 svc = GetStreamingEvents(account=self.account, timeout=request_timeout) diff --git a/exchangelib/folders/collections.py b/exchangelib/folders/collections.py index bc547a98..8c172da4 100644 --- a/exchangelib/folders/collections.py +++ b/exchangelib/folders/collections.py @@ -8,8 +8,6 @@ from ..properties import CalendarView from ..queryset import QuerySet, SearchableMixIn, Q from ..restriction import Restriction -from ..services import FindFolder, GetFolder, FindItem, FindPeople, SyncFolderItems, SyncFolderHierarchy, \ - SubscribeToPull, SubscribeToPush, SubscribeToStreaming from ..util import require_account log = logging.getLogger(__name__) @@ -179,6 +177,7 @@ def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order :return: a generator for the returned item IDs or items """ + from ..services import FindItem if not self.folders: log.debug('Folder list is empty') return @@ -201,7 +200,7 @@ def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order additional_fields, restriction.q if restriction else None, ) - yield from FindItem(account=self.account, chunk_size=page_size).call( + yield from FindItem(account=self.account, page_size=page_size).call( folders=self.folders, additional_fields=additional_fields, restriction=restriction, @@ -238,6 +237,7 @@ def find_people(self, q, shape=ID_ONLY, depth=None, additional_fields=None, orde :return: a generator for the returned personas """ + from ..services import FindPeople folder = self._get_single_folder() if not folder: return @@ -249,7 +249,7 @@ def find_people(self, q, shape=ID_ONLY, depth=None, additional_fields=None, orde field_validator=Persona.validate_field ) - yield from FindPeople(account=self.account, chunk_size=page_size).call( + yield from FindPeople(account=self.account, page_size=page_size).call( folder=folder, additional_fields=additional_fields, restriction=restriction, @@ -321,6 +321,7 @@ def resolve(self): @require_account def find_folders(self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None, offset=0): + from ..services import FindFolder # 'depth' controls whether to return direct children or recurse into sub-folders from .base import BaseFolder, Folder if q is None: @@ -354,7 +355,7 @@ def find_folders(self, q=None, shape=ID_ONLY, depth=None, additional_fields=None (FieldPath(field=BaseFolder.get_field_by_fieldname(f)) for f in self.REQUIRED_FOLDER_FIELDS) ) - yield from FindFolder(account=self.account, chunk_size=page_size).call( + yield from FindFolder(account=self.account, page_size=page_size).call( folders=self.folders, additional_fields=additional_fields, restriction=restriction, @@ -365,6 +366,7 @@ def find_folders(self, q=None, shape=ID_ONLY, depth=None, additional_fields=None ) def get_folders(self, additional_fields=None): + from ..services import GetFolder # Expand folders with their full set of properties from .base import BaseFolder if not self.folders: @@ -385,31 +387,40 @@ def get_folders(self, additional_fields=None): shape=ID_ONLY, ) - def subscribe_to_pull(self, event_types=SubscribeToPull.EVENT_TYPES, watermark=None, timeout=60): + def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): + from ..services import SubscribeToPull if not self.folders: log.debug('Folder list is empty') return + if not event_types: + event_types = SubscribeToPull.EVENT_TYPES yield from SubscribeToPull(account=self.account).call( folders=self.folders, event_types=event_types, watermark=watermark, timeout=timeout, ) - def subscribe_to_push(self, callback_url, event_types=SubscribeToPush.EVENT_TYPES, watermark=None, - status_frequency=1): + def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1): + from ..services import SubscribeToPush if not self.folders: log.debug('Folder list is empty') return + if not event_types: + event_types = SubscribeToPush.EVENT_TYPES yield from SubscribeToPush(account=self.account).call( folders=self.folders, event_types=event_types, watermark=watermark, status_frequency=status_frequency, url=callback_url, ) - def subscribe_to_streaming(self, event_types=SubscribeToPush.EVENT_TYPES): + def subscribe_to_streaming(self, event_types=None): + from ..services import SubscribeToStreaming if not self.folders: log.debug('Folder list is empty') return + if not event_types: + event_types = SubscribeToStreaming.EVENT_TYPES yield from SubscribeToStreaming(account=self.account).call(folders=self.folders, event_types=event_types) def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None): + from ..services import SyncFolderItems folder = self._get_single_folder() if not folder: return @@ -442,6 +453,7 @@ def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes raise SyncCompleted(sync_state=svc.sync_state) def sync_hierarchy(self, sync_state=None, only_fields=None): + from ..services import SyncFolderHierarchy folder = self._get_single_folder() if not folder: return diff --git a/exchangelib/items/base.py b/exchangelib/items/base.py index 02243b19..02e222f3 100644 --- a/exchangelib/items/base.py +++ b/exchangelib/items/base.py @@ -4,7 +4,6 @@ from ..fields import BooleanField, ExtendedPropertyField, BodyField, MailboxField, MailboxListField, EWSElementField, \ CharField, IdElementField, AttachmentField, ExtendedPropertyListField from ..properties import InvalidField, IdChangeKeyMixIn, EWSElement, ReferenceItemId, ItemId, EWSMeta -from ..services import CreateItem from ..util import require_account from ..version import EXCHANGE_2007_SP1 @@ -184,6 +183,7 @@ def __init__(self, **kwargs): @require_account def send(self, save_copy=True, copy_to_folder=None): + from ..services import CreateItem if copy_to_folder and not save_copy: raise AttributeError("'save_copy' must be True when 'copy_to_folder' is set") message_disposition = SEND_AND_SAVE_COPY if save_copy else SEND_ONLY @@ -201,6 +201,7 @@ def save(self, folder): :param folder: :return: """ + from ..services import CreateItem return CreateItem(account=self.account).get( items=[self], folder=folder, diff --git a/exchangelib/items/calendar_item.py b/exchangelib/items/calendar_item.py index f2347315..5d54fa78 100644 --- a/exchangelib/items/calendar_item.py +++ b/exchangelib/items/calendar_item.py @@ -11,7 +11,6 @@ AssociatedCalendarItemIdField, DateOrDateTimeField, EWSElementListField, AppointmentStateField from ..properties import Attendee, ReferenceItemId, OccurrenceItemId, RecurringMasterItemId, EWSMeta from ..recurrence import FirstOccurrence, LastOccurrence, Occurrence, DeletedOccurrence -from ..services import CreateItem from ..util import set_xml_value, require_account from ..version import EXCHANGE_2010, EXCHANGE_2013 @@ -364,6 +363,7 @@ class BaseMeetingReplyItem(BaseItem, metaclass=EWSMeta): def send(self, message_disposition=SEND_AND_SAVE_COPY): # Some responses contain multiple response IDs, e.g. MeetingRequest.accept(). Return either the single ID or # the list of IDs. + from ..services import CreateItem res = list(CreateItem(account=self.account).call( items=[self], folder=self.folder, diff --git a/exchangelib/items/item.py b/exchangelib/items/item.py index bca3eaf0..a15850b7 100644 --- a/exchangelib/items/item.py +++ b/exchangelib/items/item.py @@ -7,7 +7,6 @@ CharField, MimeContentField, FieldPath from ..properties import ConversationId, ParentFolderId, ReferenceItemId, OccurrenceItemId, RecurringMasterItemId, \ ResponseObjects, Fields -from ..services import GetItem, CreateItem, UpdateItem, DeleteItem, MoveItem, CopyItem, ArchiveItem from ..util import is_iterable, require_account, require_id from ..version import EXCHANGE_2010, EXCHANGE_2013 @@ -132,6 +131,7 @@ def save(self, update_fields=None, conflict_resolution=AUTO_RESOLVE, send_meetin def _create(self, message_disposition, send_meeting_invitations): # Return a BulkCreateResult because we want to return the ID of both the main item *and* attachments. In send # and send-and-save-copy mode, the server does not return an ID, so we just return True. + from ..services import CreateItem return CreateItem(account=self.account).get( items=[self], folder=self.folder, @@ -169,6 +169,7 @@ def _update_fieldnames(self): @require_account def _update(self, update_fieldnames, message_disposition, conflict_resolution, send_meeting_invitations): + from ..services import UpdateItem if not self.changekey: raise ValueError(f'{self.__class__.__name__} must have changekey') if not update_fieldnames: @@ -187,6 +188,7 @@ def _update(self, update_fieldnames, message_disposition, conflict_resolution, s def refresh(self): # Updates the item based on fresh data from EWS from ..folders import Folder + from ..services import GetItem additional_fields = { FieldPath(field=f) for f in Folder(root=self.account.root).allowed_item_fields(version=self.account.version) } @@ -204,6 +206,7 @@ def refresh(self): @require_id def copy(self, to_folder): + from ..services import CopyItem # If 'to_folder' is a public folder or a folder in a different mailbox then None is returned return CopyItem(account=self.account).get( items=[self], @@ -213,6 +216,7 @@ def copy(self, to_folder): @require_id def move(self, to_folder): + from ..services import MoveItem res = MoveItem(account=self.account).get( items=[self], to_folder=to_folder, @@ -250,6 +254,7 @@ def delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurren @require_id def _delete(self, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): + from ..services import DeleteItem DeleteItem(account=self.account).get( items=[self], delete_type=delete_type, @@ -260,6 +265,7 @@ def _delete(self, delete_type, send_meeting_cancellations, affected_task_occurre @require_id def archive(self, to_folder): + from ..services import ArchiveItem return ArchiveItem(account=self.account).get(items=[self], to_folder=to_folder, expect_result=True) def attach(self, attachments): diff --git a/exchangelib/items/message.py b/exchangelib/items/message.py index 44828ccb..51ae2ab0 100644 --- a/exchangelib/items/message.py +++ b/exchangelib/items/message.py @@ -4,7 +4,6 @@ from .item import Item from ..fields import BooleanField, Base64Field, TextField, MailboxField, MailboxListField, CharField, EWSElementField from ..properties import ReferenceItemId, ReminderMessageData -from ..services import SendItem, MarkAsJunk from ..util import require_account, require_id from ..version import EXCHANGE_2013, EXCHANGE_2013_SP1 @@ -46,6 +45,7 @@ class Message(Item): @require_account def send(self, save_copy=True, copy_to_folder=None, conflict_resolution=AUTO_RESOLVE, send_meeting_invitations=SEND_TO_NONE): + from ..services import SendItem # Only sends a message. The message can either be an existing draft stored in EWS or a new message that does # not yet exist in EWS. if copy_to_folder and not save_copy: @@ -153,6 +153,7 @@ def mark_as_junk(self, is_junk=True, move_item=True): :param move_item: If true, the item will be moved to the junk folder. :return: """ + from ..services import MarkAsJunk res = MarkAsJunk(account=self.account).get( items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item ) diff --git a/exchangelib/queryset.py b/exchangelib/queryset.py index 65bf7633..f06fed90 100644 --- a/exchangelib/queryset.py +++ b/exchangelib/queryset.py @@ -8,7 +8,6 @@ from .items import CalendarItem, ID_ONLY from .properties import InvalidField from .restriction import Q -from .services import CHUNK_SIZE from .version import EXCHANGE_2010 log = logging.getLogger(__name__) @@ -306,6 +305,7 @@ def _getitem_idx(self, idx): raise IndexError() def _getitem_slice(self, s): + from .services import CHUNK_SIZE if ((s.start or 0) < 0) or ((s.stop or 0) < 0) or ((s.step or 0) < 0): # islice() does not support negative start, stop and step. Make sure cache is full by iterating the full # query result, and then slice on the cache. diff --git a/exchangelib/services/__init__.py b/exchangelib/services/__init__.py index 448bfac8..a505b1e6 100644 --- a/exchangelib/services/__init__.py +++ b/exchangelib/services/__init__.py @@ -7,8 +7,8 @@ https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/ews-operations-in-exchange """ -from .archive_item import ArchiveItem from .common import CHUNK_SIZE +from .archive_item import ArchiveItem from .convert_id import ConvertId from .copy_item import CopyItem from .create_attachment import CreateAttachment diff --git a/exchangelib/services/archive_item.py b/exchangelib/services/archive_item.py index d8c1a126..184f88dc 100644 --- a/exchangelib/services/archive_item.py +++ b/exchangelib/services/archive_item.py @@ -1,4 +1,5 @@ from .common import EWSAccountService, create_folder_ids_element, create_item_ids_element +from ..items import Item from ..util import create_element, MNS from ..version import EXCHANGE_2013 @@ -21,7 +22,6 @@ def call(self, items, to_folder): return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, to_folder=to_folder)) def _elems_to_objs(self, elems): - from ..items import Item for elem in elems: if isinstance(elem, Exception): yield elem diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 6c40c36c..7f408b67 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -4,6 +4,7 @@ from itertools import chain from .. import errors +from ..attachments import AttachmentId from ..credentials import IMPERSONATION, OAuth2Credentials from ..errors import EWSWarning, TransportError, SOAPError, ErrorTimeoutExpired, ErrorBatchProcessingStopped, \ ErrorQuotaExceeded, ErrorCannotDeleteObject, ErrorCreateItemAccessDenied, ErrorFolderNotFound, \ @@ -19,6 +20,8 @@ ErrorCannotEmptyFolder, ErrorDeleteDistinguishedFolder, ErrorInvalidSubscription, ErrorInvalidWatermark, \ ErrorInvalidSyncStateData, ErrorNameResolutionNoResults, ErrorNameResolutionMultipleResults, \ ErrorConnectionFailedTransientError +from ..folders import BaseFolder, Folder, RootOfHierarchy +from ..items import BaseItem from ..properties import FieldURI, IndexedFieldURI, ExtendedFieldURI, ExceptionFieldURI, ItemId, FolderId, \ DistinguishedFolderId from ..transport import wrap @@ -816,8 +819,6 @@ def to_item_id(item, item_cls, version): if isinstance(item, item_cls): # Allow any subclass of item_cls, e.g. OccurrenceItemId when ItemId is passed return item - from ..folders import BaseFolder - from ..items import BaseItem if isinstance(item, (BaseFolder, BaseItem)): return item.to_id_xml(version=version) if isinstance(item, (tuple, list)): @@ -865,7 +866,6 @@ def create_item_ids_element(items, version, tag='m:ItemIds'): def create_attachment_ids_element(items, version): - from ..attachments import AttachmentId attachment_ids = create_element('m:AttachmentIds') for item in items: attachment_id = item if isinstance(item, AttachmentId) else AttachmentId(id=item) @@ -876,7 +876,6 @@ def create_attachment_ids_element(items, version): def parse_folder_elem(elem, folder, account): - from ..folders import BaseFolder, Folder, RootOfHierarchy if isinstance(elem, Exception): return elem if isinstance(folder, RootOfHierarchy): diff --git a/exchangelib/services/create_attachment.py b/exchangelib/services/create_attachment.py index b7550830..9a85284c 100644 --- a/exchangelib/services/create_attachment.py +++ b/exchangelib/services/create_attachment.py @@ -1,4 +1,6 @@ from .common import EWSAccountService, to_item_id +from ..attachments import FileAttachment, ItemAttachment +from ..items import BaseItem from ..properties import ParentItemId from ..util import create_element, set_xml_value, MNS @@ -15,7 +17,6 @@ def call(self, parent_item, items): return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, parent_item=parent_item)) def _elems_to_objs(self, elems): - from ..attachments import FileAttachment, ItemAttachment cls_map = {cls.response_tag(): cls for cls in (FileAttachment, ItemAttachment)} for elem in elems: if isinstance(elem, Exception): @@ -24,7 +25,6 @@ def _elems_to_objs(self, elems): yield cls_map[elem.tag].from_xml(elem=elem, account=self.account) def get_payload(self, items, parent_item): - from ..items import BaseItem payload = create_element(f'm:{self.SERVICE_NAME}') version = self.account.version if isinstance(parent_item, BaseItem): diff --git a/exchangelib/services/create_item.py b/exchangelib/services/create_item.py index a850ee60..6c4f736e 100644 --- a/exchangelib/services/create_item.py +++ b/exchangelib/services/create_item.py @@ -1,4 +1,7 @@ from .common import EWSAccountService +from ..folders import BaseFolder +from ..items import SAVE_ONLY, SEND_AND_SAVE_COPY, SEND_ONLY, \ + SEND_MEETING_INVITATIONS_CHOICES, MESSAGE_DISPOSITION_CHOICES, BulkCreateResult from ..properties import FolderId from ..util import create_element, set_xml_value, MNS @@ -14,9 +17,6 @@ class CreateItem(EWSAccountService): element_container_name = f'{{{MNS}}}Items' def call(self, items, folder, message_disposition, send_meeting_invitations): - from ..folders import BaseFolder - from ..items import SAVE_ONLY, SEND_AND_SAVE_COPY, SEND_ONLY, \ - SEND_MEETING_INVITATIONS_CHOICES, MESSAGE_DISPOSITION_CHOICES if message_disposition not in MESSAGE_DISPOSITION_CHOICES: raise ValueError( f"'message_disposition' {message_disposition!r} must be one of {MESSAGE_DISPOSITION_CHOICES}" @@ -46,7 +46,6 @@ def call(self, items, folder, message_disposition, send_meeting_invitations): )) def _elems_to_objs(self, elems): - from ..items import BulkCreateResult for elem in elems: if isinstance(elem, (Exception, type(None))): yield elem diff --git a/exchangelib/services/delete_item.py b/exchangelib/services/delete_item.py index dcb42b56..0b2a296e 100644 --- a/exchangelib/services/delete_item.py +++ b/exchangelib/services/delete_item.py @@ -1,4 +1,5 @@ from .common import EWSAccountService, create_item_ids_element +from ..items import DELETE_TYPE_CHOICES, SEND_MEETING_CANCELLATIONS_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES from ..util import create_element from ..version import EXCHANGE_2013_SP1 @@ -14,7 +15,6 @@ class DeleteItem(EWSAccountService): returns_elements = False def call(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): - from ..items import DELETE_TYPE_CHOICES, SEND_MEETING_CANCELLATIONS_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES if delete_type not in DELETE_TYPE_CHOICES: raise ValueError(f"'delete_type' {delete_type} must be one of {DELETE_TYPE_CHOICES}") if send_meeting_cancellations not in SEND_MEETING_CANCELLATIONS_CHOICES: diff --git a/exchangelib/services/find_folder.py b/exchangelib/services/find_folder.py index 7d7fffa5..ac2d6996 100644 --- a/exchangelib/services/find_folder.py +++ b/exchangelib/services/find_folder.py @@ -1,4 +1,5 @@ from .common import EWSAccountService, create_shape_element +from ..folders import Folder from ..util import create_element, set_xml_value, TNS, MNS from ..version import EXCHANGE_2010 @@ -47,7 +48,6 @@ def call(self, folders, additional_fields, restriction, shape, depth, max_items, )) def _elems_to_objs(self, elems): - from ..folders import Folder for elem in elems: if isinstance(elem, Exception): yield elem diff --git a/exchangelib/services/find_item.py b/exchangelib/services/find_item.py index 870f53c7..5ffd68e3 100644 --- a/exchangelib/services/find_item.py +++ b/exchangelib/services/find_item.py @@ -1,4 +1,6 @@ from .common import EWSAccountService, create_shape_element +from ..folders.base import BaseFolder +from ..items import Item, ID_ONLY from ..util import create_element, set_xml_value, TNS, MNS @@ -53,8 +55,6 @@ def call(self, folders, additional_fields, restriction, order_fields, shape, que )) def _elems_to_objs(self, elems): - from ..folders.base import BaseFolder - from ..items import Item, ID_ONLY for elem in elems: if isinstance(elem, Exception): yield elem diff --git a/exchangelib/services/find_people.py b/exchangelib/services/find_people.py index 24e260f0..c8ee9f3c 100644 --- a/exchangelib/services/find_people.py +++ b/exchangelib/services/find_people.py @@ -1,5 +1,6 @@ import logging from .common import EWSAccountService, create_shape_element +from ..items import Persona, ID_ONLY from ..util import create_element, set_xml_value, MNS from ..version import EXCHANGE_2013 @@ -54,7 +55,6 @@ def call(self, folder, additional_fields, restriction, order_fields, shape, quer )) def _elems_to_objs(self, elems): - from ..items import Persona, ID_ONLY for elem in elems: if isinstance(elem, Exception): yield elem diff --git a/exchangelib/services/get_attachment.py b/exchangelib/services/get_attachment.py index e61e4f45..ef5ed724 100644 --- a/exchangelib/services/get_attachment.py +++ b/exchangelib/services/get_attachment.py @@ -1,6 +1,7 @@ from itertools import chain from .common import EWSAccountService, create_attachment_ids_element +from ..attachments import FileAttachment, ItemAttachment from ..util import create_element, add_xml_child, set_xml_value, DummyResponse, StreamingBase64Parser,\ StreamingContentHandler, ElementNotFound, MNS @@ -23,7 +24,6 @@ def call(self, items, include_mime_content, body_type, filter_html_content, addi )) def _elems_to_objs(self, elems): - from ..attachments import FileAttachment, ItemAttachment cls_map = {cls.response_tag(): cls for cls in (FileAttachment, ItemAttachment)} for elem in elems: if isinstance(elem, Exception): @@ -70,7 +70,6 @@ def _get_soap_messages(self, body, **parse_opts): if not parse_opts.get('stream_file_content', False): return super()._get_soap_messages(body, **parse_opts) - from ..attachments import FileAttachment # 'body' is actually the raw response passed on by '_get_soap_parts' r = body parser = StreamingBase64Parser() diff --git a/exchangelib/services/get_item.py b/exchangelib/services/get_item.py index 0a828c9d..7ab57876 100644 --- a/exchangelib/services/get_item.py +++ b/exchangelib/services/get_item.py @@ -1,4 +1,5 @@ from .common import EWSAccountService, create_item_ids_element, create_shape_element +from ..folders.base import BaseFolder from ..util import create_element, MNS @@ -22,7 +23,6 @@ def call(self, items, additional_fields, shape): )) def _elems_to_objs(self, elems): - from ..folders.base import BaseFolder for elem in elems: if isinstance(elem, Exception): yield elem diff --git a/exchangelib/services/get_persona.py b/exchangelib/services/get_persona.py index 6fc7774f..7079e723 100644 --- a/exchangelib/services/get_persona.py +++ b/exchangelib/services/get_persona.py @@ -1,4 +1,5 @@ from .common import EWSAccountService, to_item_id +from ..items import Persona from ..properties import PersonaId from ..util import create_element, set_xml_value, MNS @@ -12,7 +13,6 @@ def call(self, persona): return self._elems_to_objs(self._get_elements(payload=self.get_payload(persona=persona))) def _elems_to_objs(self, elems): - from ..items import Persona elements = list(elems) if len(elements) != 1: raise ValueError('Expected exactly one element in response') @@ -30,7 +30,6 @@ def get_payload(self, persona): @classmethod def _get_elements_in_container(cls, container): - from ..items import Persona return container.findall(f'{{{MNS}}}{Persona.ELEMENT_NAME}') @classmethod diff --git a/exchangelib/services/move_folder.py b/exchangelib/services/move_folder.py index e1983fe2..49f11acf 100644 --- a/exchangelib/services/move_folder.py +++ b/exchangelib/services/move_folder.py @@ -1,4 +1,5 @@ from .common import EWSAccountService, create_folder_ids_element +from ..folders import BaseFolder from ..properties import FolderId from ..util import create_element, set_xml_value, MNS @@ -10,7 +11,6 @@ class MoveFolder(EWSAccountService): element_container_name = f'{{{MNS}}}Folders' def call(self, folders, to_folder): - from ..folders import BaseFolder if not isinstance(to_folder, (BaseFolder, FolderId)): raise ValueError(f"'to_folder' {to_folder!r} must be a Folder or FolderId instance") return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=folders, to_folder=to_folder)) diff --git a/exchangelib/services/move_item.py b/exchangelib/services/move_item.py index 8a2ba564..498509c6 100644 --- a/exchangelib/services/move_item.py +++ b/exchangelib/services/move_item.py @@ -1,4 +1,6 @@ from .common import EWSAccountService, create_item_ids_element +from ..folders import BaseFolder +from ..items import Item from ..properties import FolderId from ..util import create_element, set_xml_value, MNS @@ -10,13 +12,11 @@ class MoveItem(EWSAccountService): element_container_name = f'{{{MNS}}}Items' def call(self, items, to_folder): - from ..folders import BaseFolder if not isinstance(to_folder, (BaseFolder, FolderId)): raise ValueError(f"'to_folder' {to_folder!r} must be a Folder or FolderId instance") return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, to_folder=to_folder)) def _elems_to_objs(self, elems): - from ..items import Item for elem in elems: if isinstance(elem, (Exception, type(None))): yield elem diff --git a/exchangelib/services/resolve_names.py b/exchangelib/services/resolve_names.py index 97c62bea..20b0bf4f 100644 --- a/exchangelib/services/resolve_names.py +++ b/exchangelib/services/resolve_names.py @@ -2,6 +2,7 @@ from .common import EWSService from ..errors import ErrorNameResolutionNoResults, ErrorNameResolutionMultipleResults +from ..items import SHAPE_CHOICES, SEARCH_SCOPE_CHOICES,Contact from ..properties import Mailbox from ..util import create_element, set_xml_value, add_xml_child, MNS from ..version import EXCHANGE_2010_SP2 @@ -27,7 +28,6 @@ def __init__(self, *args, **kwargs): def call(self, unresolved_entries, parent_folders=None, return_full_contact_data=False, search_scope=None, contact_data_shape=None): - from ..items import SHAPE_CHOICES, SEARCH_SCOPE_CHOICES if self.chunk_size > 100: log.warning( 'Chunk size %s is dangerously high. %s supports returning at most 100 candidates for a lookup', @@ -48,7 +48,6 @@ def call(self, unresolved_entries, parent_folders=None, return_full_contact_data )) def _elems_to_objs(self, elems): - from ..items import Contact for elem in elems: if isinstance(elem, Exception): yield elem diff --git a/exchangelib/services/send_item.py b/exchangelib/services/send_item.py index 2d336866..e86b538b 100644 --- a/exchangelib/services/send_item.py +++ b/exchangelib/services/send_item.py @@ -1,4 +1,5 @@ from .common import EWSAccountService, create_item_ids_element +from ..folders import BaseFolder from ..properties import FolderId from ..util import create_element, set_xml_value @@ -10,7 +11,6 @@ class SendItem(EWSAccountService): returns_elements = False def call(self, items, saved_item_folder): - from ..folders import BaseFolder if saved_item_folder and not isinstance(saved_item_folder, (BaseFolder, FolderId)): raise ValueError(f"'saved_item_folder' {saved_item_folder!r} must be a Folder or FolderId instance") return self._chunked_get_elements(self.get_payload, items=items, saved_item_folder=saved_item_folder) diff --git a/exchangelib/services/sync_folder_items.py b/exchangelib/services/sync_folder_items.py index 2c0bc52e..193a4723 100644 --- a/exchangelib/services/sync_folder_items.py +++ b/exchangelib/services/sync_folder_items.py @@ -1,5 +1,6 @@ from .common import add_xml_child, create_item_ids_element from .sync_folder_hierarchy import SyncFolder +from ..folders import BaseFolder from ..properties import ItemId from ..util import xml_text_to_value, peek, TNS, MNS @@ -44,7 +45,6 @@ def call(self, folder, shape, additional_fields, sync_state, ignore, max_changes ))) def _elems_to_objs(self, elems): - from ..folders.base import BaseFolder change_types = self._change_types_map() for elem in elems: if isinstance(elem, Exception): diff --git a/exchangelib/services/update_folder.py b/exchangelib/services/update_folder.py index 0e709d02..b3162c67 100644 --- a/exchangelib/services/update_folder.py +++ b/exchangelib/services/update_folder.py @@ -1,5 +1,6 @@ from .common import EWSAccountService, parse_folder_elem, to_item_id from ..fields import FieldPath, IndexedField +from ..folders import BaseFolder from ..properties import FolderId from ..util import create_element, set_xml_value, MNS @@ -144,7 +145,6 @@ def _elems_to_objs(self, elems): yield parse_folder_elem(elem=elem, folder=folder, account=self.account) def _target_elem(self, target): - from ..folders import BaseFolder if isinstance(target, (BaseFolder, FolderId)): return target.to_xml(version=self.account.version) return to_item_id(target, FolderId, version=self.account.version) diff --git a/exchangelib/services/update_item.py b/exchangelib/services/update_item.py index 8e4ebf44..ef6132ea 100644 --- a/exchangelib/services/update_item.py +++ b/exchangelib/services/update_item.py @@ -1,5 +1,7 @@ from .common import to_item_id from ..ewsdatetime import EWSDate +from ..items import CONFLICT_RESOLUTION_CHOICES, MESSAGE_DISPOSITION_CHOICES, \ + SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, SEND_ONLY, Item, CalendarItem from ..properties import ItemId from ..util import create_element, MNS from ..version import EXCHANGE_2013_SP1 @@ -18,8 +20,6 @@ class UpdateItem(BaseUpdateService): def call(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, suppress_read_receipts): - from ..items import CONFLICT_RESOLUTION_CHOICES, MESSAGE_DISPOSITION_CHOICES, \ - SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, SEND_ONLY if conflict_resolution not in CONFLICT_RESOLUTION_CHOICES: raise ValueError( f"'conflict_resolution' {conflict_resolution!r} must be one of {CONFLICT_RESOLUTION_CHOICES}" @@ -47,7 +47,6 @@ def call(self, items, conflict_resolution, message_disposition, send_meeting_inv )) def _elems_to_objs(self, elems): - from ..items import Item for elem in elems: if isinstance(elem, (Exception, type(None))): yield elem @@ -55,7 +54,6 @@ def _elems_to_objs(self, elems): yield Item.id_from_xml(elem) def _update_elems(self, target, fieldnames): - from ..items import CalendarItem fieldnames_copy = list(fieldnames) if target.__class__ == CalendarItem: @@ -70,7 +68,6 @@ def _update_elems(self, target, fieldnames): yield from super()._update_elems(target=target, fieldnames=fieldnames_copy) def _get_value(self, target, field): - from ..items import CalendarItem value = super()._get_value(target, field) if target.__class__ == CalendarItem: From ac9b759d5d239a8ad3e92032ad4c7f62f9956fb8 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 13 Dec 2021 15:51:32 +0100 Subject: [PATCH 041/509] Properly distinquish between page size and chunk size --- CHANGELOG.md | 7 +- docs/index.md | 33 +-- exchangelib/folders/base.py | 11 +- exchangelib/queryset.py | 72 ++++--- exchangelib/services/__init__.py | 3 +- exchangelib/services/common.py | 199 +++++++++--------- exchangelib/services/find_folder.py | 6 +- exchangelib/services/find_item.py | 6 +- exchangelib/services/find_people.py | 6 +- exchangelib/services/resolve_names.py | 6 +- exchangelib/services/sync_folder_hierarchy.py | 4 +- exchangelib/services/sync_folder_items.py | 2 +- tests/common.py | 2 +- 13 files changed, 200 insertions(+), 157 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00fa166c..97ae7802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,14 @@ HEAD ---- - Fixed some spelling mistakes: - `ALL_OCCURRENCIES` to `ALL_OCCURRENCES` in `exchangelib.items.base` - - `Persona.orgnaization_main_phones` to `organization_main_phones` + - `Persona.orgnaization_main_phones` to `Persona.organization_main_phones` - Removed deprecated methods `EWSTimeZone.localize()`, `EWSTimeZone.normalize()`, `EWSTimeZone.timezone()` and `QuerySet.iterator()`. +- Disambiguated `chunk_size` and `page_size` in querysets and services. Add a + new `QuerySet.chunk_size` attribute and let it replace the task that + `QuerySet.page_size` previously had. Chunk size is the number of items we send + in e.g. a `GetItem` call, while `page_size` is the number of items we request + per page in services like `FindItem` that support paging. 4.6.2 diff --git a/docs/index.md b/docs/index.md index dcb0db33..de85ba7a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -869,13 +869,6 @@ a.inbox.filter(subject__startswith='Invoice').mark_as_junk( ) ``` -You can change the default page size of bulk operations if you have a slow -or busy server. - -```python -a.inbox.filter(subject__startswith='Invoice').delete(page_size=25) -``` - ## Searching Searching is modeled after the Django QuerySet API, and a large part of @@ -1121,16 +1114,25 @@ FolderCollection(account=a, folders=[a.inbox, a.calendar]).filter(subject='foo') ## Paging -Paging EWS services, e.g. `FindItem` and `FindFolder`, have a default page size of 100. You can -change this value globally if you want: +Paging EWS services, e.g. `FindItem` and `FindFolder`, have a default page size of 100. This is the +number of items fetched per page when paging is requested. You can change this value globally: + +```python +import exchangelib.services +exchangelib.services.PAGE_SIZE = 25 +``` + +Other EWS services like `GetItem` and `GetFolder`, have a default chunk size of 100. This value is +used when we request a large number of items and need to split up the requested items into multiple +requests. You can change this value globally: ```python import exchangelib.services exchangelib.services.CHUNK_SIZE = 25 ``` -If you are working with very small or very large items, this may not be a reasonable -value. For example, if you want to retrieve and save emails with large attachments, +If you are working with very small or very large items, these may not be a reasonable +values. For example, if you want to retrieve and save emails with large attachments, you can change this value on a per-queryset basis: ```python @@ -1138,12 +1140,19 @@ from exchangelib import Account a = Account(...) qs = a.inbox.all().only('mime_content') -qs.page_size = 5 +qs.page_size = 200 # Number of IDs for FindItem to get per page +qs.chunk_size = 5 # Number of full items for GetItem to request per call for msg in qs: with open('%s.eml' % msg.item_id, 'w') as f: f.write(msg.mime_content) ``` +You can also change the default page and chunk size of bulk operations via QuerySets: + +```python +a.inbox.filter(subject__startswith='Invoice').delete(page_size=1000, chunk_size=100) +``` + Finally, the bulk methods defined on the `Account` class have an optional `chunk_size` argument that you can use to set a non-default page size when fetching, creating, updating or deleting items. diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 37cdb812..855b8ae2 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -373,7 +373,7 @@ def empty(self, delete_type=HARD_DELETE, delete_sub_folders=False): # We don't know exactly what was deleted, so invalidate the entire folder cache to be safe self.root.clear_cache() - def wipe(self, page_size=None, _seen=None, _level=0): + def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0): # Recursively deletes all items in this folder, and all subfolders and their content. Attempts to protect # distinguished folders from being deleted. Use with caution! _seen = _seen or set() @@ -396,13 +396,18 @@ def wipe(self, page_size=None, _seen=None, _level=0): self.empty(delete_sub_folders=False) except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): log.warning('Not allowed to empty %s. Trying to delete items instead', self) + kwargs = {} + if page_size is not None: + kwargs['page_size'] = page_size + if chunk_size is not None: + kwargs['chunk_size'] = chunk_size try: - self.all().delete(**dict(page_size=page_size) if page_size else {}) + self.all().delete(**kwargs) except (ErrorAccessDenied, ErrorCannotDeleteObject, ErrorItemNotFound): log.warning('Not allowed to delete items in %s', self) _level += 1 for f in self.children: - f.wipe(page_size=page_size, _seen=_seen, _level=_level) + f.wipe(page_size=page_size, chunk_size=chunk_size, _seen=_seen, _level=_level) # Remove non-distinguished children that are empty and have no subfolders if f.is_deletable and not f.children: log.warning('Deleting folder %s', f) diff --git a/exchangelib/queryset.py b/exchangelib/queryset.py index f06fed90..9b685979 100644 --- a/exchangelib/queryset.py +++ b/exchangelib/queryset.py @@ -72,6 +72,7 @@ def __init__(self, folder_collection, request_type=ITEM): self.return_format = self.NONE self.calendar_view = None self.page_size = None + self.chunk_size = None self.max_items = None self.offset = 0 self._depth = None @@ -102,6 +103,7 @@ def _copy_self(self): new_qs.return_format = self.return_format new_qs.calendar_view = self.calendar_view new_qs.page_size = self.page_size + new_qs.chunk_size = self.chunk_size new_qs.max_items = self.max_items new_qs.offset = self.offset new_qs._depth = self._depth @@ -228,7 +230,7 @@ def _query(self): items = self.folder_collection.account.fetch( ids=self.folder_collection.find_items(self.q, **find_kwargs), only_fields=additional_fields, - chunk_size=self.page_size, + chunk_size=self.chunk_size, ) else: if not additional_fields: @@ -305,7 +307,7 @@ def _getitem_idx(self, idx): raise IndexError() def _getitem_slice(self, s): - from .services import CHUNK_SIZE + from .services import PAGE_SIZE if ((s.start or 0) < 0) or ((s.stop or 0) < 0) or ((s.step or 0) < 0): # islice() does not support negative start, stop and step. Make sure cache is full by iterating the full # query result, and then slice on the cache. @@ -319,7 +321,7 @@ def _getitem_slice(self, s): new_qs.offset = s.start elif s.stop is not None: new_qs.max_items = s.stop - if new_qs.page_size is None and new_qs.max_items is not None and new_qs.max_items < CHUNK_SIZE: + if new_qs.page_size is None and new_qs.max_items is not None and new_qs.max_items < PAGE_SIZE: new_qs.page_size = new_qs.max_items return islice(new_qs.__iter__(), None, None, s.step) @@ -528,16 +530,17 @@ def get(self, *args, **kwargs): return items[0] def count(self, page_size=1000): - """Get the query count, with as little effort as possible 'page_size' is the number of items to - fetch from the server per request. We're only fetching the IDs, so keep it high + """Get the query count, with as little effort as possible - :param page_size: (Default value = 1000) + :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. + (Default value = 1000) """ new_qs = self._copy_self() new_qs.only_fields = () new_qs.order_fields = None new_qs.return_format = self.NONE new_qs.page_size = page_size + # 'chunk_size' not needed since we never need to call GetItem return len(list(new_qs.__iter__())) def exists(self): @@ -553,42 +556,45 @@ def _id_only_copy_self(self): new_qs.return_format = self.NONE return new_qs - def delete(self, page_size=1000, **delete_kwargs): - """Delete the items matching the query, with as little effort as possible. 'page_size' is the number of items - to fetch and delete per request. We're only fetching the IDs, so keep it high. + def delete(self, page_size=1000, chunk_size=100, **delete_kwargs): + """Delete the items matching the query, with as little effort as possible - :param page_size: (Default value = 1000) + :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. + (Default value = 1000) + :param chunk_size: The number of items to delete per request. (Default value = 100) :param delete_kwargs: """ ids = self._id_only_copy_self() ids.page_size = page_size return self.folder_collection.account.bulk_delete( ids=ids, - chunk_size=page_size, + chunk_size=chunk_size, **delete_kwargs ) - def send(self, page_size=1000, **send_kwargs): - """Send the items matching the query, with as little effort as possible. 'page_size' is the number of items - to fetch and send per request. We're only fetching the IDs, so keep it high. + def send(self, page_size=1000, chunk_size=100, **send_kwargs): + """Send the items matching the query, with as little effort as possible - :param page_size: (Default value = 1000) + :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. + (Default value = 1000) + :param chunk_size: The number of items to send per request. (Default value = 100) :param send_kwargs: """ ids = self._id_only_copy_self() ids.page_size = page_size return self.folder_collection.account.bulk_send( ids=ids, - chunk_size=page_size, + chunk_size=chunk_size, **send_kwargs ) - def copy(self, to_folder, page_size=1000, **copy_kwargs): - """Copy the items matching the query, with as little effort as possible. 'page_size' is the number of items - to fetch and copy per request. We're only fetching the IDs, so keep it high. + def copy(self, to_folder, page_size=1000, chunk_size=100, **copy_kwargs): + """Copy the items matching the query, with as little effort as possible :param to_folder: - :param page_size: (Default value = 1000) + :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. + (Default value = 1000) + :param chunk_size: The number of items to copy per request. (Default value = 100) :param copy_kwargs: """ ids = self._id_only_copy_self() @@ -596,52 +602,58 @@ def copy(self, to_folder, page_size=1000, **copy_kwargs): return self.folder_collection.account.bulk_copy( ids=ids, to_folder=to_folder, - chunk_size=page_size, + chunk_size=chunk_size, **copy_kwargs ) - def move(self, to_folder, page_size=1000): + def move(self, to_folder, page_size=1000, chunk_size=100): """Move the items matching the query, with as little effort as possible. 'page_size' is the number of items to fetch and move per request. We're only fetching the IDs, so keep it high. :param to_folder: - :param page_size: (Default value = 1000) + :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. + (Default value = 1000) + :param chunk_size: The number of items to move per request. (Default value = 100) """ ids = self._id_only_copy_self() ids.page_size = page_size return self.folder_collection.account.bulk_move( ids=ids, to_folder=to_folder, - chunk_size=page_size, + chunk_size=chunk_size, ) - def archive(self, to_folder, page_size=1000): + def archive(self, to_folder, page_size=1000, chunk_size=100): """Archive the items matching the query, with as little effort as possible. 'page_size' is the number of items to fetch and move per request. We're only fetching the IDs, so keep it high. :param to_folder: - :param page_size: (Default value = 1000) + :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. + (Default value = 1000) + :param chunk_size: The number of items to archive per request. (Default value = 100) """ ids = self._id_only_copy_self() ids.page_size = page_size return self.folder_collection.account.bulk_archive( ids=ids, to_folder=to_folder, - chunk_size=page_size, + chunk_size=chunk_size, ) - def mark_as_junk(self, page_size=1000, **mark_as_junk_kwargs): + def mark_as_junk(self, page_size=1000, chunk_size=1000, **mark_as_junk_kwargs): """Mark the items matching the query as junk, with as little effort as possible. 'page_size' is the number of items to fetch and mark per request. We're only fetching the IDs, so keep it high. - :param page_size: (Default value = 1000) + :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. + (Default value = 1000) + :param chunk_size: The number of items to mark as junk per request. (Default value = 100) :param mark_as_junk_kwargs: """ ids = self._id_only_copy_self() ids.page_size = page_size return self.folder_collection.account.bulk_mark_as_junk( ids=ids, - chunk_size=page_size, + chunk_size=chunk_size, **mark_as_junk_kwargs ) diff --git a/exchangelib/services/__init__.py b/exchangelib/services/__init__.py index a505b1e6..f256c58e 100644 --- a/exchangelib/services/__init__.py +++ b/exchangelib/services/__init__.py @@ -7,7 +7,7 @@ https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/ews-operations-in-exchange """ -from .common import CHUNK_SIZE +from .common import CHUNK_SIZE, PAGE_SIZE from .archive_item import ArchiveItem from .convert_id import ConvertId from .copy_item import CopyItem @@ -58,6 +58,7 @@ __all__ = [ 'CHUNK_SIZE', + 'PAGE_SIZE', 'ArchiveItem', 'ConvertId', 'CopyItem', diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 7f408b67..3992e92c 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -31,7 +31,8 @@ log = logging.getLogger(__name__) -CHUNK_SIZE = 100 # A default chunk size for all services +PAGE_SIZE = 100 # A default page size for all paging services. This is the number of items we request per page +CHUNK_SIZE = 100 # A default chunk size for all services. This is the number of items we send in a single request KNOWN_EXCEPTIONS = ( ErrorAccessDenied, @@ -94,9 +95,9 @@ class EWSService(metaclass=abc.ABCMeta): supports_paging = False def __init__(self, protocol, chunk_size=None, timeout=None): - self.chunk_size = chunk_size or CHUNK_SIZE # The number of items to send in a single request + self.chunk_size = chunk_size or CHUNK_SIZE if not isinstance(self.chunk_size, int): - raise ValueError(f"'chunk_size' {chunk_size!r} must be an integer") + raise ValueError(f"'chunk_size' {self.chunk_size!r} must be an integer") if self.chunk_size < 1: raise ValueError("'chunk_size' must be a positive number") if self.supported_from and protocol.version.build < self.supported_from: @@ -625,47 +626,70 @@ def _get_elements_in_container(cls, container): return list(container) return [True] - def _get_elems_from_page(self, elem, max_items, total_item_count): - container = elem.find(self.element_container_name) - if container is None: - raise MalformedResponseError( - f'No {self.element_container_name} elements in ResponseMessage ({xml_to_str(elem)})' - ) - for e in self._get_elements_in_container(container=container): - if max_items and total_item_count >= max_items: - # No need to continue. Break out of elements loop - log.debug("'max_items' count reached (elements)") - break - yield e - def _get_pages(self, payload_func, kwargs, expected_message_count): - """Request a page, or a list of pages if multiple collections are pages in a single request. Return each - page. - """ - payload = payload_func(**kwargs) - page_elems = list(self._get_elements(payload=payload)) - if len(page_elems) != expected_message_count: - raise MalformedResponseError( - f"Expected {expected_message_count} items in 'response', got {len(page_elems)}" - ) - return page_elems +class EWSAccountService(EWSService, metaclass=abc.ABCMeta): + """Base class for services that act on items concerning a single Mailbox on the server.""" - @staticmethod - def _get_next_offset(paging_infos): - next_offsets = {p['next_offset'] for p in paging_infos if p['next_offset'] is not None} - if not next_offsets: - # Paging is done for all messages - return None - # We cannot guarantee that all messages that have a next_offset also have the *same* next_offset. This is - # because the collections that we are iterating may change while iterating. We'll do our best but we cannot - # guarantee 100% consistency when large collections are simultaneously being changed on the server. - # - # It's not possible to supply a per-folder offset when iterating multiple folders, so we'll just have to - # choose something that is most likely to work. Select the lowest of all the values to at least make sure - # we don't miss any items, although we may then get duplicates ¯\_(ツ)_/¯ - if len(next_offsets) > 1: - log.warning('Inconsistent next_offset values: %r. Using lowest value', next_offsets) - return min(next_offsets) + NO_VALID_SERVER_VERSIONS = ErrorInvalidSchemaVersionForMailboxVersion + # Marks services that need affinity to the backend server + prefer_affinity = False + + def __init__(self, *args, **kwargs): + self.account = kwargs.pop('account') + kwargs['protocol'] = self.account.protocol + super().__init__(*args, **kwargs) + + @property + def _version_hint(self): + return self.account.version + + @_version_hint.setter + def _version_hint(self, value): + self.account.version = value + + def _handle_response_cookies(self, session): + super()._handle_response_cookies(session=session) + + # See self._extra_headers() for documentation on affinity + if self.prefer_affinity: + for cookie in session.cookies: + if cookie.name == 'X-BackEndOverrideCookie': + self.account.affinity_cookie = cookie.value + break + + def _extra_headers(self, session): + headers = super()._extra_headers(session=session) + # See + # https://blogs.msdn.microsoft.com/webdav_101/2015/05/11/best-practices-ews-authentication-and-access-issues/ + headers['X-AnchorMailbox'] = self.account.primary_smtp_address + + # See + # https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/how-to-maintain-affinity-between-group-of-subscriptions-and-mailbox-server + if self.prefer_affinity: + headers['X-PreferServerAffinity'] = 'True' + if self.account.affinity_cookie: + headers['X-BackEndOverrideCookie'] = self.account.affinity_cookie + return headers + + @property + def _account_to_impersonate(self): + if self.account.access_type == IMPERSONATION: + return self.account.identity + return None + + @property + def _timezone(self): + return self.account.default_timezone + + +class EWSPagingService(EWSAccountService): + def __init__(self, *args, **kwargs): + self.page_size = kwargs.pop('page_size', None) or PAGE_SIZE + if not isinstance(self.page_size, int): + raise ValueError(f"'page_size' {self.page_size!r} must be an integer") + if self.page_size < 1: + raise ValueError("'page_size' must be a positive number") + super().__init__(*args, **kwargs) def _paged_call(self, payload_func, max_items, folders, **kwargs): """Call a service that supports paging requests. Return a generator over all response items. Keeps track of @@ -757,60 +781,47 @@ def _get_page(self, message): paging_elem = None return paging_elem, next_offset + def _get_elems_from_page(self, elem, max_items, total_item_count): + container = elem.find(self.element_container_name) + if container is None: + raise MalformedResponseError( + f'No {self.element_container_name} elements in ResponseMessage ({xml_to_str(elem)})' + ) + for e in self._get_elements_in_container(container=container): + if max_items and total_item_count >= max_items: + # No need to continue. Break out of elements loop + log.debug("'max_items' count reached (elements)") + break + yield e -class EWSAccountService(EWSService, metaclass=abc.ABCMeta): - """Base class for services that act on items concerning a single Mailbox on the server.""" - - NO_VALID_SERVER_VERSIONS = ErrorInvalidSchemaVersionForMailboxVersion - # Marks services that need affinity to the backend server - prefer_affinity = False - - def __init__(self, *args, **kwargs): - self.account = kwargs.pop('account') - kwargs['protocol'] = self.account.protocol - super().__init__(*args, **kwargs) - - @property - def _version_hint(self): - return self.account.version - - @_version_hint.setter - def _version_hint(self, value): - self.account.version = value - - def _handle_response_cookies(self, session): - super()._handle_response_cookies(session=session) - - # See self._extra_headers() for documentation on affinity - if self.prefer_affinity: - for cookie in session.cookies: - if cookie.name == 'X-BackEndOverrideCookie': - self.account.affinity_cookie = cookie.value - break - - def _extra_headers(self, session): - headers = super()._extra_headers(session=session) - # See - # https://blogs.msdn.microsoft.com/webdav_101/2015/05/11/best-practices-ews-authentication-and-access-issues/ - headers['X-AnchorMailbox'] = self.account.primary_smtp_address - - # See - # https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/how-to-maintain-affinity-between-group-of-subscriptions-and-mailbox-server - if self.prefer_affinity: - headers['X-PreferServerAffinity'] = 'True' - if self.account.affinity_cookie: - headers['X-BackEndOverrideCookie'] = self.account.affinity_cookie - return headers - - @property - def _account_to_impersonate(self): - if self.account.access_type == IMPERSONATION: - return self.account.identity - return None + def _get_pages(self, payload_func, kwargs, expected_message_count): + """Request a page, or a list of pages if multiple collections are pages in a single request. Return each + page. + """ + payload = payload_func(**kwargs) + page_elems = list(self._get_elements(payload=payload)) + if len(page_elems) != expected_message_count: + raise MalformedResponseError( + f"Expected {expected_message_count} items in 'response', got {len(page_elems)}" + ) + return page_elems - @property - def _timezone(self): - return self.account.default_timezone + @staticmethod + def _get_next_offset(paging_infos): + next_offsets = {p['next_offset'] for p in paging_infos if p['next_offset'] is not None} + if not next_offsets: + # Paging is done for all messages + return None + # We cannot guarantee that all messages that have a next_offset also have the *same* next_offset. This is + # because the collections that we are iterating may change while iterating. We'll do our best but we cannot + # guarantee 100% consistency when large collections are simultaneously being changed on the server. + # + # It's not possible to supply a per-folder offset when iterating multiple folders, so we'll just have to + # choose something that is most likely to work. Select the lowest of all the values to at least make sure + # we don't miss any items, although we may then get duplicates ¯\_(ツ)_/¯ + if len(next_offsets) > 1: + log.warning('Inconsistent next_offset values: %r. Using lowest value', next_offsets) + return min(next_offsets) def to_item_id(item, item_cls, version): diff --git a/exchangelib/services/find_folder.py b/exchangelib/services/find_folder.py index ac2d6996..b30e6fd6 100644 --- a/exchangelib/services/find_folder.py +++ b/exchangelib/services/find_folder.py @@ -1,10 +1,10 @@ -from .common import EWSAccountService, create_shape_element +from .common import EWSPagingService, create_shape_element from ..folders import Folder from ..util import create_element, set_xml_value, TNS, MNS from ..version import EXCHANGE_2010 -class FindFolder(EWSAccountService): +class FindFolder(EWSPagingService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/findfolder-operation""" SERVICE_NAME = 'FindFolder' @@ -42,7 +42,7 @@ def call(self, folders, additional_fields, restriction, shape, depth, max_items, restriction=restriction, shape=shape, depth=depth, - page_size=self.chunk_size, + page_size=self.page_size, offset=offset, ) )) diff --git a/exchangelib/services/find_item.py b/exchangelib/services/find_item.py index 5ffd68e3..1560606c 100644 --- a/exchangelib/services/find_item.py +++ b/exchangelib/services/find_item.py @@ -1,10 +1,10 @@ -from .common import EWSAccountService, create_shape_element +from .common import EWSPagingService, create_shape_element from ..folders.base import BaseFolder from ..items import Item, ID_ONLY from ..util import create_element, set_xml_value, TNS, MNS -class FindItem(EWSAccountService): +class FindItem(EWSPagingService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/finditem-operation""" SERVICE_NAME = 'FindItem' @@ -49,7 +49,7 @@ def call(self, folders, additional_fields, restriction, order_fields, shape, que shape=shape, depth=depth, calendar_view=calendar_view, - page_size=self.chunk_size, + page_size=self.page_size, offset=offset, ) )) diff --git a/exchangelib/services/find_people.py b/exchangelib/services/find_people.py index c8ee9f3c..af01ef9b 100644 --- a/exchangelib/services/find_people.py +++ b/exchangelib/services/find_people.py @@ -1,5 +1,5 @@ import logging -from .common import EWSAccountService, create_shape_element +from .common import EWSPagingService, create_shape_element from ..items import Persona, ID_ONLY from ..util import create_element, set_xml_value, MNS from ..version import EXCHANGE_2013 @@ -7,7 +7,7 @@ log = logging.getLogger(__name__) -class FindPeople(EWSAccountService): +class FindPeople(EWSPagingService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/findpeople-operation""" SERVICE_NAME = 'FindPeople' @@ -49,7 +49,7 @@ def call(self, folder, additional_fields, restriction, order_fields, shape, quer query_string=query_string, shape=shape, depth=depth, - page_size=self.chunk_size, + page_size=self.page_size, offset=offset, ) )) diff --git a/exchangelib/services/resolve_names.py b/exchangelib/services/resolve_names.py index 20b0bf4f..bfd58ccf 100644 --- a/exchangelib/services/resolve_names.py +++ b/exchangelib/services/resolve_names.py @@ -2,7 +2,7 @@ from .common import EWSService from ..errors import ErrorNameResolutionNoResults, ErrorNameResolutionMultipleResults -from ..items import SHAPE_CHOICES, SEARCH_SCOPE_CHOICES,Contact +from ..items import SHAPE_CHOICES, SEARCH_SCOPE_CHOICES, Contact from ..properties import Mailbox from ..util import create_element, set_xml_value, add_xml_child, MNS from ..version import EXCHANGE_2010_SP2 @@ -29,8 +29,8 @@ def __init__(self, *args, **kwargs): def call(self, unresolved_entries, parent_folders=None, return_full_contact_data=False, search_scope=None, contact_data_shape=None): if self.chunk_size > 100: - log.warning( - 'Chunk size %s is dangerously high. %s supports returning at most 100 candidates for a lookup', + raise AttributeError( + 'Chunk size %s is too high. %s supports returning at most 100 candidates for a lookup', self.chunk_size, self.SERVICE_NAME ) if search_scope and search_scope not in SEARCH_SCOPE_CHOICES: diff --git a/exchangelib/services/sync_folder_hierarchy.py b/exchangelib/services/sync_folder_hierarchy.py index 106951c0..d097ad26 100644 --- a/exchangelib/services/sync_folder_hierarchy.py +++ b/exchangelib/services/sync_folder_hierarchy.py @@ -1,14 +1,14 @@ import abc import logging -from .common import EWSAccountService, add_xml_child, create_folder_ids_element, create_shape_element, parse_folder_elem +from .common import EWSPagingService, add_xml_child, create_folder_ids_element, create_shape_element, parse_folder_elem from ..properties import FolderId from ..util import create_element, xml_text_to_value, MNS, TNS log = logging.getLogger(__name__) -class SyncFolder(EWSAccountService, metaclass=abc.ABCMeta): +class SyncFolder(EWSPagingService, metaclass=abc.ABCMeta): """Base class for SyncFolderHierarchy and SyncFolderItems.""" element_container_name = f'{{{MNS}}}Changes' diff --git a/exchangelib/services/sync_folder_items.py b/exchangelib/services/sync_folder_items.py index 193a4723..29579932 100644 --- a/exchangelib/services/sync_folder_items.py +++ b/exchangelib/services/sync_folder_items.py @@ -29,7 +29,7 @@ def _change_types_map(self): def call(self, folder, shape, additional_fields, sync_state, ignore, max_changes_returned, sync_scope): self.sync_state = sync_state if max_changes_returned is None: - max_changes_returned = self.chunk_size + max_changes_returned = self.page_size if max_changes_returned <= 0: raise ValueError(f"'max_changes_returned' {max_changes_returned} must be a positive integer") if sync_scope is not None and sync_scope not in self.SYNC_SCOPES: diff --git a/tests/common.py b/tests/common.py index cc087521..9814acf9 100644 --- a/tests/common.py +++ b/tests/common.py @@ -104,7 +104,7 @@ def setUp(self): def wipe_test_account(self): # Deletes up all deletable items in the test account. Not run in a normal test run - self.account.root.wipe(page_size=100) + self.account.root.wipe() def bulk_delete(self, ids): # Clean up items and check return values From a4efcfbf5f0d4e0793cb418ee49fd86e90e95d35 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 13 Dec 2021 17:31:07 +0100 Subject: [PATCH 042/509] Introduce _elem_to_obj() to reduce code duplication --- exchangelib/services/archive_item.py | 8 +--- exchangelib/services/common.py | 12 +++++- exchangelib/services/convert_id.py | 14 +++---- exchangelib/services/create_attachment.py | 10 ++--- exchangelib/services/create_item.py | 15 +++---- exchangelib/services/delete_attachment.py | 8 +--- exchangelib/services/expand_dl.py | 8 +--- exchangelib/services/export_items.py | 8 +--- exchangelib/services/find_folder.py | 8 +--- exchangelib/services/find_item.py | 13 ++---- exchangelib/services/find_people.py | 13 ++---- exchangelib/services/get_attachment.py | 10 ++--- exchangelib/services/get_delegate.py | 8 +--- exchangelib/services/get_events.py | 8 +--- exchangelib/services/get_item.py | 8 +--- exchangelib/services/get_mail_tips.py | 8 +--- exchangelib/services/get_room_lists.py | 8 +--- exchangelib/services/get_rooms.py | 8 +--- .../services/get_searchable_mailboxes.py | 10 ++--- exchangelib/services/get_server_time_zones.py | 18 ++++---- exchangelib/services/get_streaming_events.py | 8 +--- exchangelib/services/get_user_availability.py | 8 +--- .../services/get_user_configuration.py | 8 +--- exchangelib/services/get_user_oof_settings.py | 8 +--- exchangelib/services/mark_as_junk.py | 10 ++--- exchangelib/services/move_folder.py | 10 ++--- exchangelib/services/move_item.py | 10 ++--- exchangelib/services/resolve_names.py | 23 ++++------ exchangelib/services/send_notification.py | 8 +--- exchangelib/services/subscribe.py | 18 +++----- exchangelib/services/sync_folder_hierarchy.py | 37 +++++++--------- exchangelib/services/sync_folder_items.py | 42 ++++++++----------- exchangelib/services/update_item.py | 10 ++--- exchangelib/services/upload_items.py | 8 +--- 34 files changed, 141 insertions(+), 270 deletions(-) diff --git a/exchangelib/services/archive_item.py b/exchangelib/services/archive_item.py index 184f88dc..d228c990 100644 --- a/exchangelib/services/archive_item.py +++ b/exchangelib/services/archive_item.py @@ -21,12 +21,8 @@ def call(self, items, to_folder): """ return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, to_folder=to_folder)) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Item.id_from_xml(elem) + def _elem_to_obj(self, elem): + return Item.id_from_xml(elem) def get_payload(self, items, to_folder): payload = create_element(f'm:{self.SERVICE_NAME}') diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 3992e92c..cd301cc0 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -162,8 +162,16 @@ def parse(self, xml): def _elems_to_objs(self, elems): """Takes a generator of XML elements and exceptions. Returns the equivalent Python objects (or exceptions).""" - if self.returns_elements: - raise NotImplementedError() + if not self.returns_elements: + raise ValueError("Incorrect call to method when 'returns_elements' is False") + for elem in elems: + if isinstance(elem, Exception): + yield elem + continue + yield self._elem_to_obj(elem) + + def _elem_to_obj(self, elem): + raise NotImplementedError() @property def _version_hint(self): diff --git a/exchangelib/services/convert_id.py b/exchangelib/services/convert_id.py index 6c7d8d6e..6fd47557 100644 --- a/exchangelib/services/convert_id.py +++ b/exchangelib/services/convert_id.py @@ -13,6 +13,9 @@ class ConvertId(EWSService): SERVICE_NAME = 'ConvertId' supported_from = EXCHANGE_2007_SP1 + cls_map = {cls.response_tag(): cls for cls in ( + AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId + )} def call(self, items, destination_format): if destination_format not in ID_FORMATS: @@ -21,15 +24,8 @@ def call(self, items, destination_format): self._chunked_get_elements(self.get_payload, items=items, destination_format=destination_format) ) - def _elems_to_objs(self, elems): - cls_map = {cls.response_tag(): cls for cls in ( - AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId - )} - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield cls_map[elem.tag].from_xml(elem, account=None) + def _elem_to_obj(self, elem): + return self.cls_map[elem.tag].from_xml(elem, account=None) def get_payload(self, items, destination_format): supported_item_classes = AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId diff --git a/exchangelib/services/create_attachment.py b/exchangelib/services/create_attachment.py index 9a85284c..f4c83247 100644 --- a/exchangelib/services/create_attachment.py +++ b/exchangelib/services/create_attachment.py @@ -12,17 +12,13 @@ class CreateAttachment(EWSAccountService): SERVICE_NAME = 'CreateAttachment' element_container_name = f'{{{MNS}}}Attachments' + cls_map = {cls.response_tag(): cls for cls in (FileAttachment, ItemAttachment)} def call(self, parent_item, items): return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, parent_item=parent_item)) - def _elems_to_objs(self, elems): - cls_map = {cls.response_tag(): cls for cls in (FileAttachment, ItemAttachment)} - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield cls_map[elem.tag].from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return self.cls_map[elem.tag].from_xml(elem=elem, account=self.account) def get_payload(self, items, parent_item): payload = create_element(f'm:{self.SERVICE_NAME}') diff --git a/exchangelib/services/create_item.py b/exchangelib/services/create_item.py index 6c4f736e..fa283dda 100644 --- a/exchangelib/services/create_item.py +++ b/exchangelib/services/create_item.py @@ -45,15 +45,12 @@ def call(self, items, folder, message_disposition, send_meeting_invitations): send_meeting_invitations=send_meeting_invitations, )) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, (Exception, type(None))): - yield elem - continue - if isinstance(elem, bool): - yield elem - continue - yield BulkCreateResult.from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + if elem is None: + return elem + if isinstance(elem, bool): + return elem + return BulkCreateResult.from_xml(elem=elem, account=self.account) @classmethod def _get_elements_in_container(cls, container): diff --git a/exchangelib/services/delete_attachment.py b/exchangelib/services/delete_attachment.py index aa302a1e..cc661b77 100644 --- a/exchangelib/services/delete_attachment.py +++ b/exchangelib/services/delete_attachment.py @@ -13,12 +13,8 @@ class DeleteAttachment(EWSAccountService): def call(self, items): return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items)) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield RootItemId.from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return RootItemId.from_xml(elem=elem, account=self.account) @classmethod def _get_elements_in_container(cls, container): diff --git a/exchangelib/services/expand_dl.py b/exchangelib/services/expand_dl.py index 33f9b01b..ec21b5a8 100644 --- a/exchangelib/services/expand_dl.py +++ b/exchangelib/services/expand_dl.py @@ -14,12 +14,8 @@ class ExpandDL(EWSService): def call(self, distribution_list): return self._elems_to_objs(self._get_elements(payload=self.get_payload(distribution_list=distribution_list))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Mailbox.from_xml(elem, account=None) + def _elem_to_obj(self, elem): + return Mailbox.from_xml(elem, account=None) def get_payload(self, distribution_list): return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), distribution_list, version=self.protocol.version) diff --git a/exchangelib/services/export_items.py b/exchangelib/services/export_items.py index 2ba1494a..6361b4fd 100644 --- a/exchangelib/services/export_items.py +++ b/exchangelib/services/export_items.py @@ -13,12 +13,8 @@ class ExportItems(EWSAccountService): def call(self, items): return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items)) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield elem.text # All we want is the 64bit string in the 'Data' tag + def _elem_to_obj(self, elem): + return elem.text # All we want is the 64bit string in the 'Data' tag def get_payload(self, items): payload = create_element(f'm:{self.SERVICE_NAME}') diff --git a/exchangelib/services/find_folder.py b/exchangelib/services/find_folder.py index b30e6fd6..61cc1d44 100644 --- a/exchangelib/services/find_folder.py +++ b/exchangelib/services/find_folder.py @@ -47,12 +47,8 @@ def call(self, folders, additional_fields, restriction, shape, depth, max_items, ) )) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Folder.from_xml_with_root(elem=elem, root=self.root) + def _elem_to_obj(self, elem): + return Folder.from_xml_with_root(elem=elem, root=self.root) def get_payload(self, folders, additional_fields, restriction, shape, depth, page_size, offset=0): payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) diff --git a/exchangelib/services/find_item.py b/exchangelib/services/find_item.py index 1560606c..0137507e 100644 --- a/exchangelib/services/find_item.py +++ b/exchangelib/services/find_item.py @@ -54,15 +54,10 @@ def call(self, folders, additional_fields, restriction, order_fields, shape, que ) )) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - if self.shape == ID_ONLY and self.additional_fields is None: - yield Item.id_from_xml(elem) - continue - yield BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + if self.shape == ID_ONLY and self.additional_fields is None: + return Item.id_from_xml(elem) + return BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, calendar_view, page_size, offset=0): diff --git a/exchangelib/services/find_people.py b/exchangelib/services/find_people.py index af01ef9b..c84b7a4c 100644 --- a/exchangelib/services/find_people.py +++ b/exchangelib/services/find_people.py @@ -54,15 +54,10 @@ def call(self, folder, additional_fields, restriction, order_fields, shape, quer ) )) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - if self.shape == ID_ONLY and self.additional_fields is None: - yield Persona.id_from_xml(elem) - continue - yield Persona.from_xml(elem, account=self.account) + def _elem_to_obj(self, elem): + if self.shape == ID_ONLY and self.additional_fields is None: + return Persona.id_from_xml(elem) + return Persona.from_xml(elem, account=self.account) def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, offset=0): diff --git a/exchangelib/services/get_attachment.py b/exchangelib/services/get_attachment.py index ef5ed724..0980d974 100644 --- a/exchangelib/services/get_attachment.py +++ b/exchangelib/services/get_attachment.py @@ -14,6 +14,7 @@ class GetAttachment(EWSAccountService): SERVICE_NAME = 'GetAttachment' element_container_name = f'{{{MNS}}}Attachments' + cls_map = {cls.response_tag(): cls for cls in (FileAttachment, ItemAttachment)} def call(self, items, include_mime_content, body_type, filter_html_content, additional_fields): if body_type and body_type not in BODY_TYPE_CHOICES: @@ -23,13 +24,8 @@ def call(self, items, include_mime_content, body_type, filter_html_content, addi body_type=body_type, filter_html_content=filter_html_content, additional_fields=additional_fields, )) - def _elems_to_objs(self, elems): - cls_map = {cls.response_tag(): cls for cls in (FileAttachment, ItemAttachment)} - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield cls_map[elem.tag].from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return self.cls_map[elem.tag].from_xml(elem=elem, account=self.account) def get_payload(self, items, include_mime_content, body_type, filter_html_content, additional_fields): payload = create_element(f'm:{self.SERVICE_NAME}') diff --git a/exchangelib/services/get_delegate.py b/exchangelib/services/get_delegate.py index 39513981..8d8136a0 100644 --- a/exchangelib/services/get_delegate.py +++ b/exchangelib/services/get_delegate.py @@ -18,12 +18,8 @@ def call(self, user_ids, include_permissions): include_permissions=include_permissions, )) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield DelegateUser.from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return DelegateUser.from_xml(elem=elem, account=self.account) def get_payload(self, user_ids, mailbox, include_permissions): payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IncludePermissions=include_permissions)) diff --git a/exchangelib/services/get_events.py b/exchangelib/services/get_events.py index 248bc2a0..aa2429b4 100644 --- a/exchangelib/services/get_events.py +++ b/exchangelib/services/get_events.py @@ -20,12 +20,8 @@ def call(self, subscription_id, watermark): subscription_id=subscription_id, watermark=watermark, ))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Notification.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return Notification.from_xml(elem=elem, account=None) @classmethod def _get_elements_in_container(cls, container): diff --git a/exchangelib/services/get_item.py b/exchangelib/services/get_item.py index 7ab57876..b266fa9d 100644 --- a/exchangelib/services/get_item.py +++ b/exchangelib/services/get_item.py @@ -22,12 +22,8 @@ def call(self, items, additional_fields, shape): self.get_payload, items=items, additional_fields=additional_fields, shape=shape, )) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) def get_payload(self, items, additional_fields, shape): payload = create_element(f'm:{self.SERVICE_NAME}') diff --git a/exchangelib/services/get_mail_tips.py b/exchangelib/services/get_mail_tips.py index eea44159..70ed1e73 100644 --- a/exchangelib/services/get_mail_tips.py +++ b/exchangelib/services/get_mail_tips.py @@ -16,12 +16,8 @@ def call(self, sending_as, recipients, mail_tips_requested): mail_tips_requested=mail_tips_requested, )) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield MailTips.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return MailTips.from_xml(elem=elem, account=None) def get_payload(self, recipients, sending_as, mail_tips_requested): payload = create_element(f'm:{self.SERVICE_NAME}') diff --git a/exchangelib/services/get_room_lists.py b/exchangelib/services/get_room_lists.py index e6ba847a..01260ab5 100644 --- a/exchangelib/services/get_room_lists.py +++ b/exchangelib/services/get_room_lists.py @@ -14,12 +14,8 @@ class GetRoomLists(EWSService): def call(self): return self._elems_to_objs(self._get_elements(payload=self.get_payload())) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield RoomList.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return RoomList.from_xml(elem=elem, account=None) def get_payload(self): return create_element(f'm:{self.SERVICE_NAME}') diff --git a/exchangelib/services/get_rooms.py b/exchangelib/services/get_rooms.py index 4315a174..cdfbbe1e 100644 --- a/exchangelib/services/get_rooms.py +++ b/exchangelib/services/get_rooms.py @@ -14,12 +14,8 @@ class GetRooms(EWSService): def call(self, room_list): return self._elems_to_objs(self._get_elements(payload=self.get_payload(room_list=room_list))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Room.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return Room.from_xml(elem=elem, account=None) def get_payload(self, room_list): return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), room_list, version=self.protocol.version) diff --git a/exchangelib/services/get_searchable_mailboxes.py b/exchangelib/services/get_searchable_mailboxes.py index 47149fec..55f6c232 100644 --- a/exchangelib/services/get_searchable_mailboxes.py +++ b/exchangelib/services/get_searchable_mailboxes.py @@ -14,6 +14,7 @@ class GetSearchableMailboxes(EWSService): element_container_name = f'{{{MNS}}}SearchableMailboxes' failed_mailboxes_container_name = f'{{{MNS}}}FailedMailboxes' supported_from = EXCHANGE_2013 + cls_map = {cls.response_tag(): cls for cls in (SearchableMailbox, FailedMailbox)} def call(self, search_filter, expand_group_membership): return self._elems_to_objs(self._get_elements(payload=self.get_payload( @@ -21,13 +22,8 @@ def call(self, search_filter, expand_group_membership): expand_group_membership=expand_group_membership, ))) - def _elems_to_objs(self, elems): - cls_map = {cls.response_tag(): cls for cls in (SearchableMailbox, FailedMailbox)} - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield cls_map[elem.tag].from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return self.cls_map[elem.tag].from_xml(elem=elem, account=None) def get_payload(self, search_filter, expand_group_membership): payload = create_element(f'm:{self.SERVICE_NAME}') diff --git a/exchangelib/services/get_server_time_zones.py b/exchangelib/services/get_server_time_zones.py index 218f5e86..a529f0f1 100644 --- a/exchangelib/services/get_server_time_zones.py +++ b/exchangelib/services/get_server_time_zones.py @@ -38,17 +38,13 @@ def get_payload(self, timezones, return_full_timezone_data): payload.append(tz_ids) return payload - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - tz_id = elem.get('Id') - tz_name = elem.get('Name') - tz_periods = self._get_periods(elem) - tz_transitions_groups = self._get_transitions_groups(elem) - tz_transitions = self._get_transitions(elem) - yield tz_id, tz_name, tz_periods, tz_transitions, tz_transitions_groups + def _elem_to_obj(self, elem): + tz_id = elem.get('Id') + tz_name = elem.get('Name') + tz_periods = self._get_periods(elem) + tz_transitions_groups = self._get_transitions_groups(elem) + tz_transitions = self._get_transitions(elem) + return tz_id, tz_name, tz_periods, tz_transitions, tz_transitions_groups @staticmethod def _get_periods(timezone_def): diff --git a/exchangelib/services/get_streaming_events.py b/exchangelib/services/get_streaming_events.py index d75299b5..6f30622d 100644 --- a/exchangelib/services/get_streaming_events.py +++ b/exchangelib/services/get_streaming_events.py @@ -35,12 +35,8 @@ def call(self, subscription_ids, connection_timeout): subscription_ids=subscription_ids, connection_timeout=connection_timeout, ))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Notification.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return Notification.from_xml(elem=elem, account=None) @classmethod def _get_soap_parts(cls, response, **parse_opts): diff --git a/exchangelib/services/get_user_availability.py b/exchangelib/services/get_user_availability.py index 000e0720..95ed32e7 100644 --- a/exchangelib/services/get_user_availability.py +++ b/exchangelib/services/get_user_availability.py @@ -20,12 +20,8 @@ def call(self, timezone, mailbox_data, free_busy_view_options): free_busy_view_options=free_busy_view_options ))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield FreeBusyView.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return FreeBusyView.from_xml(elem=elem, account=None) def get_payload(self, timezone, mailbox_data, free_busy_view_options): payload = create_element(f'm:{self.SERVICE_NAME}Request') diff --git a/exchangelib/services/get_user_configuration.py b/exchangelib/services/get_user_configuration.py index 8a684eac..14ac74f9 100644 --- a/exchangelib/services/get_user_configuration.py +++ b/exchangelib/services/get_user_configuration.py @@ -24,12 +24,8 @@ def call(self, user_configuration_name, properties): user_configuration_name=user_configuration_name, properties=properties ))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield UserConfiguration.from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return UserConfiguration.from_xml(elem=elem, account=self.account) @classmethod def _get_elements_in_container(cls, container): diff --git a/exchangelib/services/get_user_oof_settings.py b/exchangelib/services/get_user_oof_settings.py index eb8eb91d..08571dcf 100644 --- a/exchangelib/services/get_user_oof_settings.py +++ b/exchangelib/services/get_user_oof_settings.py @@ -15,12 +15,8 @@ class GetUserOofSettings(EWSAccountService): def call(self, mailbox): return self._elems_to_objs(self._get_elements(payload=self.get_payload(mailbox=mailbox))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield OofSettings.from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return OofSettings.from_xml(elem=elem, account=self.account) def get_payload(self, mailbox): return set_xml_value( diff --git a/exchangelib/services/mark_as_junk.py b/exchangelib/services/mark_as_junk.py index 78801e2b..17a593c1 100644 --- a/exchangelib/services/mark_as_junk.py +++ b/exchangelib/services/mark_as_junk.py @@ -13,12 +13,10 @@ def call(self, items, is_junk, move_item): self._chunked_get_elements(self.get_payload, items=items, is_junk=is_junk, move_item=move_item) ) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, (Exception, type(None))): - yield elem - continue - yield MovedItemId.id_from_xml(elem) + def _elem_to_obj(self, elem): + if elem is None: + return elem + return MovedItemId.id_from_xml(elem) @classmethod def _get_elements_in_container(cls, container): diff --git a/exchangelib/services/move_folder.py b/exchangelib/services/move_folder.py index 49f11acf..1bff9c4b 100644 --- a/exchangelib/services/move_folder.py +++ b/exchangelib/services/move_folder.py @@ -15,12 +15,10 @@ def call(self, folders, to_folder): raise ValueError(f"'to_folder' {to_folder!r} must be a Folder or FolderId instance") return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=folders, to_folder=to_folder)) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, (Exception, type(None))): - yield elem - continue - yield FolderId.from_xml(elem=elem.find(FolderId.response_tag()), account=self.account) + def _elem_to_obj(self, elem): + if elem is None: + return elem + return FolderId.from_xml(elem=elem.find(FolderId.response_tag()), account=self.account) def get_payload(self, folders, to_folder): # Takes a list of folders and returns their new folder IDs diff --git a/exchangelib/services/move_item.py b/exchangelib/services/move_item.py index 498509c6..126ae820 100644 --- a/exchangelib/services/move_item.py +++ b/exchangelib/services/move_item.py @@ -16,12 +16,10 @@ def call(self, items, to_folder): raise ValueError(f"'to_folder' {to_folder!r} must be a Folder or FolderId instance") return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, to_folder=to_folder)) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, (Exception, type(None))): - yield elem - continue - yield Item.id_from_xml(elem) + def _elem_to_obj(self, elem): + if elem is None: + return elem + return Item.id_from_xml(elem) def get_payload(self, items, to_folder): # Takes a list of items and returns their new item IDs diff --git a/exchangelib/services/resolve_names.py b/exchangelib/services/resolve_names.py index bfd58ccf..56a053d3 100644 --- a/exchangelib/services/resolve_names.py +++ b/exchangelib/services/resolve_names.py @@ -47,20 +47,15 @@ def call(self, unresolved_entries, parent_folders=None, return_full_contact_data contact_data_shape=contact_data_shape, )) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - if self.return_full_contact_data: - mailbox_elem = elem.find(Mailbox.response_tag()) - contact_elem = elem.find(Contact.response_tag()) - yield ( - None if mailbox_elem is None else Mailbox.from_xml(elem=mailbox_elem, account=None), - None if contact_elem is None else Contact.from_xml(elem=contact_elem, account=None), - ) - else: - yield Mailbox.from_xml(elem=elem.find(Mailbox.response_tag()), account=None) + def _elem_to_obj(self, elem): + if self.return_full_contact_data: + mailbox_elem = elem.find(Mailbox.response_tag()) + contact_elem = elem.find(Contact.response_tag()) + return ( + None if mailbox_elem is None else Mailbox.from_xml(elem=mailbox_elem, account=None), + None if contact_elem is None else Contact.from_xml(elem=contact_elem, account=None), + ) + return Mailbox.from_xml(elem=elem.find(Mailbox.response_tag()), account=None) def get_payload(self, unresolved_entries, parent_folders, return_full_contact_data, search_scope, contact_data_shape): diff --git a/exchangelib/services/send_notification.py b/exchangelib/services/send_notification.py index c83461f4..0fbe8ac1 100644 --- a/exchangelib/services/send_notification.py +++ b/exchangelib/services/send_notification.py @@ -14,12 +14,8 @@ class SendNotification(EWSService): def call(self): raise NotImplementedError() - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Notification.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return Notification.from_xml(elem=elem, account=None) @classmethod def _response_tag(cls): diff --git a/exchangelib/services/subscribe.py b/exchangelib/services/subscribe.py index 97cd720a..ad887870 100644 --- a/exchangelib/services/subscribe.py +++ b/exchangelib/services/subscribe.py @@ -29,13 +29,9 @@ def _partial_call(self, payload_func, folders, event_types, **kwargs): payload=payload_func(folders=folders, event_types=event_types, **kwargs) )) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - subscription_elem, watermark_elem = elem - yield subscription_elem.text, watermark_elem.text + def _elem_to_obj(self, elem): + subscription_elem, watermark_elem = elem + return subscription_elem.text, watermark_elem.text @classmethod def _get_elements_in_container(cls, container): @@ -101,12 +97,8 @@ class SubscribeToStreaming(Subscribe): def call(self, folders, event_types): yield from self._partial_call(payload_func=self.get_payload, folders=folders, event_types=event_types) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield elem.text + def _elem_to_obj(self, elem): + return elem.text @classmethod def _get_elements_in_container(cls, container): diff --git a/exchangelib/services/sync_folder_hierarchy.py b/exchangelib/services/sync_folder_hierarchy.py index d097ad26..6d566d99 100644 --- a/exchangelib/services/sync_folder_hierarchy.py +++ b/exchangelib/services/sync_folder_hierarchy.py @@ -19,6 +19,11 @@ class SyncFolder(EWSPagingService, metaclass=abc.ABCMeta): CHANGE_TYPES = (CREATE, UPDATE, DELETE) shape_tag = None last_in_range_name = None + change_types_map = { + f'{{{TNS}}}Create': CREATE, + f'{{{TNS}}}Update': UPDATE, + f'{{{TNS}}}Delete': DELETE, + } def __init__(self, *args, **kwargs): # These values are reset and set each time call() is consumed @@ -26,13 +31,6 @@ def __init__(self, *args, **kwargs): self.includes_last_item_in_range = None super().__init__(*args, **kwargs) - def _change_types_map(self): - return { - f'{{{TNS}}}Create': self.CREATE, - f'{{{TNS}}}Update': self.UPDATE, - f'{{{TNS}}}Delete': self.DELETE, - } - def _get_element_container(self, message, name=None): self.sync_state = message.find(f'{{{MNS}}}SyncState').text log.debug('Sync state is: %s', self.sync_state) @@ -76,21 +74,16 @@ def call(self, folder, shape, additional_fields, sync_state): sync_state=sync_state, ))) - def _elems_to_objs(self, elems): - change_types = self._change_types_map() - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - change_type = change_types[elem.tag] - if change_type == self.DELETE: - folder = FolderId.from_xml(elem=elem.find(FolderId.response_tag()), account=self.account) - else: - # We can't find() the element because we don't know which tag to look for. The change element can - # contain multiple folder types, each with their own tag. - folder_elem = elem[0] - folder = parse_folder_elem(elem=folder_elem, folder=self.folder, account=self.account) - yield change_type, folder + def _elem_to_obj(self, elem): + change_type = self.change_types_map[elem.tag] + if change_type == self.DELETE: + folder = FolderId.from_xml(elem=elem.find(FolderId.response_tag()), account=self.account) + else: + # We can't find() the element because we don't know which tag to look for. The change element can + # contain multiple folder types, each with their own tag. + folder_elem = elem[0] + folder = parse_folder_elem(elem=folder_elem, folder=self.folder, account=self.account) + return change_type, folder def get_payload(self, folder, shape, additional_fields, sync_state): return self._partial_get_payload( diff --git a/exchangelib/services/sync_folder_items.py b/exchangelib/services/sync_folder_items.py index 29579932..7740690e 100644 --- a/exchangelib/services/sync_folder_items.py +++ b/exchangelib/services/sync_folder_items.py @@ -20,11 +20,8 @@ class SyncFolderItems(SyncFolder): CHANGE_TYPES = SyncFolder.CHANGE_TYPES + (READ_FLAG_CHANGE,) shape_tag = 'm:ItemShape' last_in_range_name = f'{{{MNS}}}IncludesLastItemInRange' - - def _change_types_map(self): - res = super()._change_types_map() - res[f'{{{TNS}}}ReadFlagChange'] = self.READ_FLAG_CHANGE - return res + change_types_map = SyncFolder.change_types_map + change_types_map[f'{{{TNS}}}ReadFlagChange'] = READ_FLAG_CHANGE def call(self, folder, shape, additional_fields, sync_state, ignore, max_changes_returned, sync_scope): self.sync_state = sync_state @@ -44,26 +41,21 @@ def call(self, folder, shape, additional_fields, sync_state, ignore, max_changes sync_scope=sync_scope, ))) - def _elems_to_objs(self, elems): - change_types = self._change_types_map() - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - change_type = change_types[elem.tag] - if change_type == self.READ_FLAG_CHANGE: - item = ( - ItemId.from_xml(elem=elem.find(ItemId.response_tag()), account=self.account), - xml_text_to_value(elem.find(f'{{{TNS}}}IsRead').text, bool) - ) - elif change_type == self.DELETE: - item = ItemId.from_xml(elem=elem.find(ItemId.response_tag()), account=self.account) - else: - # We can't find() the element because we don't know which tag to look for. The change element can - # contain multiple item types, each with their own tag. - item_elem = elem[0] - item = BaseFolder.item_model_from_tag(item_elem.tag).from_xml(elem=item_elem, account=self.account) - yield change_type, item + def _elem_to_obj(self, elem): + change_type = self.change_types_map[elem.tag] + if change_type == self.READ_FLAG_CHANGE: + item = ( + ItemId.from_xml(elem=elem.find(ItemId.response_tag()), account=self.account), + xml_text_to_value(elem.find(f'{{{TNS}}}IsRead').text, bool) + ) + elif change_type == self.DELETE: + item = ItemId.from_xml(elem=elem.find(ItemId.response_tag()), account=self.account) + else: + # We can't find() the element because we don't know which tag to look for. The change element can + # contain multiple item types, each with their own tag. + item_elem = elem[0] + item = BaseFolder.item_model_from_tag(item_elem.tag).from_xml(elem=item_elem, account=self.account) + return change_type, item def get_payload(self, folder, shape, additional_fields, sync_state, ignore, max_changes_returned, sync_scope): sync_folder_items = self._partial_get_payload( diff --git a/exchangelib/services/update_item.py b/exchangelib/services/update_item.py index ef6132ea..4dfdaf32 100644 --- a/exchangelib/services/update_item.py +++ b/exchangelib/services/update_item.py @@ -46,12 +46,10 @@ def call(self, items, conflict_resolution, message_disposition, send_meeting_inv suppress_read_receipts=suppress_read_receipts, )) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, (Exception, type(None))): - yield elem - continue - yield Item.id_from_xml(elem) + def _elem_to_obj(self, elem): + if elem is None: + return elem + return Item.id_from_xml(elem) def _update_elems(self, target, fieldnames): fieldnames_copy = list(fieldnames) diff --git a/exchangelib/services/upload_items.py b/exchangelib/services/upload_items.py index 09810edb..36eb7f49 100644 --- a/exchangelib/services/upload_items.py +++ b/exchangelib/services/upload_items.py @@ -43,12 +43,8 @@ def get_payload(self, items): items_elem.append(item) return payload - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield elem.get(ItemId.ID_ATTR), elem.get(ItemId.CHANGEKEY_ATTR) + def _elem_to_obj(self, elem): + return elem.get(ItemId.ID_ATTR), elem.get(ItemId.CHANGEKEY_ATTR) @classmethod def _get_elements_in_container(cls, container): From 149d25e56c5d55e325615a579146f68fea4d310e Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 13 Dec 2021 23:25:00 +0100 Subject: [PATCH 043/509] Install wheel for easier package installs --- .github/workflows/python-package.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 4e4d5958..ea9bb563 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -54,7 +54,7 @@ jobs: - name: Upgrade pip run: | - python -m pip install --upgrade pip + python -m pip install --upgrade pip wheel - name: Install cutting-edge Cython-based packages on Python dev versions continue-on-error: ${{ matrix.allowed_failure || false }} @@ -104,7 +104,7 @@ jobs: - name: Upgrade pip run: | - python -m pip install --upgrade pip + python -m pip install --upgrade pip wheel - name: Install dependencies run: | From f65ba677099ce938145530806517f6dead3a917e Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 13 Dec 2021 23:25:45 +0100 Subject: [PATCH 044/509] Fix GetDelegate with user_ids argument --- exchangelib/services/common.py | 4 +++- exchangelib/services/get_delegate.py | 9 +++++++-- tests/test_account.py | 18 +++++++++--------- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index cd301cc0..2deaccd2 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -19,7 +19,7 @@ SessionPoolMinSizeReached, ErrorIncorrectSchemaVersion, ErrorInvalidRequest, ErrorCorruptData, \ ErrorCannotEmptyFolder, ErrorDeleteDistinguishedFolder, ErrorInvalidSubscription, ErrorInvalidWatermark, \ ErrorInvalidSyncStateData, ErrorNameResolutionNoResults, ErrorNameResolutionMultipleResults, \ - ErrorConnectionFailedTransientError + ErrorConnectionFailedTransientError, ErrorDelegateNoUser, ErrorNotDelegate from ..folders import BaseFolder, Folder, RootOfHierarchy from ..items import BaseItem from ..properties import FieldURI, IndexedFieldURI, ExtendedFieldURI, ExceptionFieldURI, ItemId, FolderId, \ @@ -43,6 +43,7 @@ ErrorConnectionFailed, ErrorConnectionFailedTransientError, ErrorCreateItemAccessDenied, + ErrorDelegateNoUser, ErrorDeleteDistinguishedFolder, ErrorExceededConnectionCount, ErrorFolderNotFound, @@ -63,6 +64,7 @@ ErrorNonExistentMailbox, ErrorNoPublicFolderReplicaAvailable, ErrorNoRespondingCASInDestinationSite, + ErrorNotDelegate, ErrorQuotaExceeded, ErrorTimeoutExpired, RateLimitError, diff --git a/exchangelib/services/get_delegate.py b/exchangelib/services/get_delegate.py index 8d8136a0..3465dc9b 100644 --- a/exchangelib/services/get_delegate.py +++ b/exchangelib/services/get_delegate.py @@ -1,5 +1,5 @@ from .common import EWSAccountService -from ..properties import DLMailbox, DelegateUser # The service expects a Mailbox element in the MNS namespace +from ..properties import DLMailbox, DelegateUser, UserId # The service expects a Mailbox element in the MNS namespace from ..util import create_element, set_xml_value, MNS from ..version import EXCHANGE_2007_SP1 @@ -25,7 +25,12 @@ def get_payload(self, user_ids, mailbox, include_permissions): payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IncludePermissions=include_permissions)) set_xml_value(payload, mailbox, version=self.protocol.version) if user_ids != [None]: - set_xml_value(payload, user_ids, version=self.protocol.version) + user_ids_elem = create_element('m:UserIds') + for user_id in user_ids: + if isinstance(user_id, str): + user_id = UserId(primary_smtp_address=user_id) + set_xml_value(user_ids_elem, user_id, version=self.protocol.version) + set_xml_value(payload, user_ids_elem, version=self.protocol.version) return payload @classmethod diff --git a/tests/test_account.py b/tests/test_account.py index 01f40ec1..362c8bb3 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -5,7 +5,8 @@ from exchangelib.attachments import FileAttachment from exchangelib.configuration import Configuration from exchangelib.credentials import Credentials, DELEGATE -from exchangelib.errors import ErrorAccessDenied, ErrorFolderNotFound, UnauthorizedError +from exchangelib.errors import ErrorAccessDenied, ErrorFolderNotFound, UnauthorizedError, ErrorNotDelegate, \ + ErrorDelegateNoUser from exchangelib.folders import Calendar from exchangelib.items import Message from exchangelib.properties import DelegateUser, UserId, DelegatePermissions @@ -116,14 +117,13 @@ def test_mail_tips(self): def test_delegate(self): # The test server does not have any delegate info. Test that account.delegates works, and mock to test parsing # of a non-empty response. - self.assertGreaterEqual( - len(self.account.delegates), - 0 - ) - self.assertGreaterEqual( - len(list(GetDelegate(account=self.account).call(user_ids=['foo@example.com'], include_permissions=True))), - 0 - ) + self.assertGreaterEqual(len(self.account.delegates), 0) + with self.assertRaises(ErrorDelegateNoUser): + list(GetDelegate(account=self.account).call(user_ids=['foo@example.com'], include_permissions=True)) + with self.assertRaises(ErrorNotDelegate): + list(GetDelegate(account=self.account).call( + user_ids=[self.account.primary_smtp_address], include_permissions=True + )) xml = b'''\ From 23b16b05f46917477ea455b6bfa0d861840db9f3 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 13 Dec 2021 23:26:37 +0100 Subject: [PATCH 045/509] Let the field handle wrapping values in t:String elements --- exchangelib/fields.py | 53 ++++++++++++++++++++++++++----------------- exchangelib/util.py | 2 -- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index b6df0598..e7900755 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -742,12 +742,28 @@ class TextListField(TextField): is_list = True + def __init__(self, *args, **kwargs): + self.list_elem_name = kwargs.pop('list_elem_name', 'String') + super().__init__(*args, **kwargs) + + def list_elem_request_tag(self): + return f't:{self.list_elem_name}' + + def list_elem_response_tag(self): + return f'{{{self.namespace}}}{self.list_elem_name}' + def from_xml(self, elem, account): iter_elem = elem.find(self.response_tag()) if iter_elem is not None: - return get_xml_attrs(iter_elem, f'{{{TNS}}}String') + return get_xml_attrs(iter_elem, self.list_elem_response_tag()) return self.default + def to_xml(self, value, version): + field_elem = create_element(self.request_tag()) + for v in value: + field_elem.append(set_xml_value(create_element(self.list_elem_request_tag()), v, version=version)) + return field_elem + class MessageField(TextField): """A field that handles the Message element.""" @@ -785,13 +801,8 @@ def __init__(self, *args, **kwargs): def clean(self, value, version=None): value = super().clean(value, version=version) if value is not None: - if self.is_list: - for v in value: - if len(v) > self.max_length: - raise ValueError(f"{self.name!r} value {v!r} exceeds length {self.max_length}") - else: - if len(value) > self.max_length: - raise ValueError(f"{self.name!r} value {value!r} exceeds length {self.max_length}") + if len(value) > self.max_length: + raise ValueError(f"{self.name!r} value {value!r} exceeds length {self.max_length}") return value @@ -808,23 +819,23 @@ def __init__(self, *args, **kwargs): self.is_attribute = True -class CharListField(CharField): - """Like CharField, but for lists of strings.""" - - is_list = True +class CharListField(TextListField): + """Like TextListField, but for string values with a limited length.""" def __init__(self, *args, **kwargs): - self.list_elem_name = kwargs.pop('list_elem_name', 'String') + self.max_length = kwargs.pop('max_length', 255) + if not 1 <= self.max_length <= 255: + # A field supporting messages longer than 255 chars should be TextField + raise ValueError("'max_length' must be in the range 1-255") super().__init__(*args, **kwargs) - def list_elem_tag(self): - return f'{{{self.namespace}}}{self.list_elem_name}' - - def from_xml(self, elem, account): - iter_elem = elem.find(self.response_tag()) - if iter_elem is not None: - return get_xml_attrs(iter_elem, self.list_elem_tag()) - return self.default + def clean(self, value, version=None): + value = super().clean(value, version=version) + if value is not None: + for v in value: + if len(v) > self.max_length: + raise ValueError(f"{self.name!r} value {v!r} exceeds length {self.max_length}") + return value class URIField(TextField): diff --git a/exchangelib/util.py b/exchangelib/util.py index 1d84806d..0c2a941f 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -248,8 +248,6 @@ def set_xml_value(elem, value, version): elem.append(v.to_xml(version=version)) elif isinstance(v, _element_class): elem.append(v) - elif isinstance(v, str): - add_xml_child(elem, 't:String', v) else: raise ValueError(f'Unsupported type {type(v)} for list element {v} on elem {elem}') elif isinstance(value, (FieldPath, FieldOrder)): From ba11d57e8b784fea58fc8d15df23864329635691 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 13 Dec 2021 23:27:04 +0100 Subject: [PATCH 046/509] Simplify --- exchangelib/services/upload_items.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/exchangelib/services/upload_items.py b/exchangelib/services/upload_items.py index 36eb7f49..780d7d0d 100644 --- a/exchangelib/services/upload_items.py +++ b/exchangelib/services/upload_items.py @@ -33,8 +33,7 @@ def get_payload(self, items): if is_associated is not None: attrs['IsAssociated'] = is_associated item = create_element('t:Item', attrs=attrs) - parent_folder_id = ParentFolderId(parent_folder.id, parent_folder.changekey) - set_xml_value(item, parent_folder_id, version=self.account.version) + set_xml_value(item, ParentFolderId(parent_folder.id, parent_folder.changekey), version=self.account.version) if item_id: set_xml_value( item, to_item_id(item_id, ItemId, version=self.account.version), version=self.account.version From e23355d6da696ae38595058c6c21ce2948070e41 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 13 Dec 2021 23:32:46 +0100 Subject: [PATCH 047/509] Make set_xml_value() fully recursive --- exchangelib/util.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/exchangelib/util.py b/exchangelib/util.py index 0c2a941f..cfb64941 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -238,24 +238,15 @@ def set_xml_value(elem, value, version): elem.text = value_to_xml_text(value) elif isinstance(value, _element_class): elem.append(value) - elif is_iterable(value, generators_allowed=True): - for v in value: - if isinstance(v, (FieldPath, FieldOrder)): - elem.append(v.to_xml()) - elif isinstance(v, EWSElement): - if not isinstance(version, Version): - raise ValueError(f"'version' {version!r} must be a Version instance") - elem.append(v.to_xml(version=version)) - elif isinstance(v, _element_class): - elem.append(v) - else: - raise ValueError(f'Unsupported type {type(v)} for list element {v} on elem {elem}') elif isinstance(value, (FieldPath, FieldOrder)): elem.append(value.to_xml()) elif isinstance(value, EWSElement): if not isinstance(version, Version): raise ValueError(f"'version' {version!r} must be a Version instance") elem.append(value.to_xml(version=version)) + elif is_iterable(value, generators_allowed=True): + for v in value: + set_xml_value(elem, v, version) else: raise ValueError(f'Unsupported type {type(value)} for value {value} on elem {elem}') return elem From 138fc48fec405e40644522f174fb162d955c04e0 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 14 Dec 2021 09:17:33 +0100 Subject: [PATCH 048/509] Make it explicit when we want an item ID and when we want the full item --- exchangelib/fields.py | 2 +- exchangelib/folders/base.py | 12 +---------- exchangelib/properties.py | 8 +++---- exchangelib/services/archive_item.py | 2 +- exchangelib/services/common.py | 21 ++++++++++--------- exchangelib/services/create_attachment.py | 2 +- exchangelib/services/create_folder.py | 8 ++++--- exchangelib/services/create_item.py | 6 ++++-- exchangelib/services/delete_folder.py | 2 +- exchangelib/services/empty_folder.py | 2 +- exchangelib/services/find_folder.py | 8 ++++--- exchangelib/services/find_item.py | 8 +++---- exchangelib/services/find_people.py | 10 ++++----- exchangelib/services/get_folder.py | 2 +- exchangelib/services/get_persona.py | 2 +- exchangelib/services/move_folder.py | 6 +++--- exchangelib/services/move_item.py | 6 +++--- exchangelib/services/resolve_names.py | 8 ++++--- exchangelib/services/send_item.py | 10 ++++----- exchangelib/services/subscribe.py | 2 +- exchangelib/services/sync_folder_hierarchy.py | 2 +- exchangelib/services/update_folder.py | 14 ++++--------- exchangelib/services/update_item.py | 2 +- exchangelib/services/upload_items.py | 4 +--- exchangelib/util.py | 4 ++-- 25 files changed, 70 insertions(+), 83 deletions(-) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index e7900755..29b3f65f 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -1259,7 +1259,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def to_xml(self, value, version): - return set_xml_value(create_element(f't:{self.PARENT_ELEMENT_NAME}'), value, version) + return set_xml_value(create_element(f't:{self.PARENT_ELEMENT_NAME}'), value, version=version) def response_tag(self): return f'{{{self.namespace}}}{self.PARENT_ELEMENT_NAME}' diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 855b8ae2..46194864 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -434,7 +434,7 @@ def _kwargs_from_elem(cls, elem, account): kwargs['name'] = cls.DISTINGUISHED_FOLDER_ID return kwargs - def to_folder_id(self): + def to_id(self): if self.is_distinguished: # Don't add the changekey here. When modifying folder content, we usually don't care if others have changed # the folder content since we fetched the changekey. @@ -448,16 +448,6 @@ def to_folder_id(self): return FolderId(id=self.id, changekey=self.changekey) raise ValueError('Must be a distinguished folder or have an ID') - def to_xml(self, version): - try: - return self.to_folder_id().to_xml(version=version) - except ValueError: - return super().to_xml(version=version) - - def to_id_xml(self, version): - # Folder(name='Foo') is a perfectly valid ID to e.g. create a folder - return self.to_xml(version=version) - @classmethod def resolve(cls, account, folder): # Resolve a single folder diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 2467c0fd..8a86a4f0 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -304,7 +304,7 @@ def to_xml(self, version): value = getattr(self, f.name) if value is None or (f.is_list and not value): continue - set_xml_value(elem, f.to_xml(value, version=version), version) + set_xml_value(elem, f.to_xml(value, version=version)) return elem @classmethod @@ -1483,8 +1483,8 @@ def id_from_xml(cls, elem): return None, None return id_elem.get(cls.ID_ELEMENT_CLS.ID_ATTR), id_elem.get(cls.ID_ELEMENT_CLS.CHANGEKEY_ATTR) - def to_id_xml(self, version): - return self._id.to_xml(version=version) + def to_id(self): + return self._id def __eq__(self, other): if isinstance(other, tuple): @@ -1519,7 +1519,7 @@ class UserConfigurationName(EWSElement): def clean(self, version=None): from .folders import BaseFolder if isinstance(self.folder, BaseFolder): - self.folder = self.folder.to_folder_id() + self.folder = self.folder.to_id() super().clean(version=version) @classmethod diff --git a/exchangelib/services/archive_item.py b/exchangelib/services/archive_item.py index d228c990..7f175a8b 100644 --- a/exchangelib/services/archive_item.py +++ b/exchangelib/services/archive_item.py @@ -27,7 +27,7 @@ def _elem_to_obj(self, elem): def get_payload(self, items, to_folder): payload = create_element(f'm:{self.SERVICE_NAME}') payload.append( - create_folder_ids_element(tag='m:ArchiveSourceFolderId', folders=[to_folder], version=self.account.version) + create_folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ArchiveSourceFolderId') ) payload.append(create_item_ids_element(items=items, version=self.account.version)) return payload diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 2deaccd2..a4577bfc 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -23,7 +23,7 @@ from ..folders import BaseFolder, Folder, RootOfHierarchy from ..items import BaseItem from ..properties import FieldURI, IndexedFieldURI, ExtendedFieldURI, ExceptionFieldURI, ItemId, FolderId, \ - DistinguishedFolderId + DistinguishedFolderId, BaseItemId from ..transport import wrap from ..util import chunkify, create_element, add_xml_child, get_xml_attr, to_xml, post_ratelimited, \ xml_to_str, set_xml_value, SOAPNS, TNS, MNS, ENS, ParseError, DummyResponse @@ -834,14 +834,17 @@ def _get_next_offset(paging_infos): return min(next_offsets) -def to_item_id(item, item_cls, version): +def to_item_id(item, item_cls): # Coerce a tuple, dict or object to an 'item_cls' instance. Used to create [Parent][Item|Folder]Id instances from a # variety of input. - if isinstance(item, item_cls): - # Allow any subclass of item_cls, e.g. OccurrenceItemId when ItemId is passed + if isinstance(item, BaseItemId): + # Allow any BaseItemId subclass to pass unaltered return item if isinstance(item, (BaseFolder, BaseItem)): - return item.to_id_xml(version=version) + try: + return item.to_id() + except ValueError: + return item if isinstance(item, (tuple, list)): return item_cls(*item) if isinstance(item, dict): @@ -866,12 +869,10 @@ def create_shape_element(tag, shape, additional_fields, version): return shape_elem -def create_folder_ids_element(tag, folders, version): +def create_folder_ids_element(folders, version, tag='m:FolderIds'): folder_ids = create_element(tag) for folder in folders: - if not isinstance(folder, FolderId): - folder = to_item_id(folder, FolderId, version=version) - set_xml_value(folder_ids, folder, version=version) + set_xml_value(folder_ids, to_item_id(folder, FolderId), version=version) if not len(folder_ids): raise ValueError('"folders" must not be empty') return folder_ids @@ -880,7 +881,7 @@ def create_folder_ids_element(tag, folders, version): def create_item_ids_element(items, version, tag='m:ItemIds'): item_ids = create_element(tag) for item in items: - set_xml_value(item_ids, to_item_id(item, ItemId, version=version), version=version) + set_xml_value(item_ids, to_item_id(item, ItemId), version=version) if not len(item_ids): raise ValueError('"items" must not be empty') return item_ids diff --git a/exchangelib/services/create_attachment.py b/exchangelib/services/create_attachment.py index f4c83247..efe7a1e2 100644 --- a/exchangelib/services/create_attachment.py +++ b/exchangelib/services/create_attachment.py @@ -26,7 +26,7 @@ def get_payload(self, items, parent_item): if isinstance(parent_item, BaseItem): # to_item_id() would convert this to a normal ItemId, but the service wants a ParentItemId parent_item = ParentItemId(parent_item.id, parent_item.changekey) - set_xml_value(payload, to_item_id(parent_item, ParentItemId, version=version), version=version) + set_xml_value(payload, to_item_id(parent_item, ParentItemId), version=self.account.version) attachments = create_element('m:Attachments') for item in items: set_xml_value(attachments, item, version=version) diff --git a/exchangelib/services/create_folder.py b/exchangelib/services/create_folder.py index 422391bf..1dcd1003 100644 --- a/exchangelib/services/create_folder.py +++ b/exchangelib/services/create_folder.py @@ -1,5 +1,5 @@ from .common import EWSAccountService, parse_folder_elem, create_folder_ids_element -from ..util import create_element, set_xml_value, MNS +from ..util import create_element, MNS class CreateFolder(EWSAccountService): @@ -29,6 +29,8 @@ def _elems_to_objs(self, elems): def get_payload(self, folders, parent_folder): payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(set_xml_value(create_element('m:ParentFolderId'), parent_folder, version=self.account.version)) - payload.append(create_folder_ids_element(tag='m:Folders', folders=folders, version=self.account.version)) + payload.append( + create_folder_ids_element(folders=[parent_folder], version=self.account.version, tag='m:ParentFolderId') + ) + payload.append(create_folder_ids_element(folders=folders, version=self.account.version, tag='m:Folders')) return payload diff --git a/exchangelib/services/create_item.py b/exchangelib/services/create_item.py index fa283dda..02e060f2 100644 --- a/exchangelib/services/create_item.py +++ b/exchangelib/services/create_item.py @@ -1,4 +1,4 @@ -from .common import EWSAccountService +from .common import EWSAccountService, create_folder_ids_element from ..folders import BaseFolder from ..items import SAVE_ONLY, SEND_AND_SAVE_COPY, SEND_ONLY, \ SEND_MEETING_INVITATIONS_CHOICES, MESSAGE_DISPOSITION_CHOICES, BulkCreateResult @@ -81,7 +81,9 @@ def get_payload(self, items, folder, message_disposition, send_meeting_invitatio attrs=dict(MessageDisposition=message_disposition, SendMeetingInvitations=send_meeting_invitations) ) if folder: - payload.append(set_xml_value(create_element('m:SavedItemFolderId'), folder, version=self.account.version)) + payload.append(create_folder_ids_element( + folders=[folder], version=self.account.version, tag='m:SavedItemFolderId' + )) item_elems = create_element('m:Items') for item in items: if not item.account: diff --git a/exchangelib/services/delete_folder.py b/exchangelib/services/delete_folder.py index 17287bc4..d3dfffea 100644 --- a/exchangelib/services/delete_folder.py +++ b/exchangelib/services/delete_folder.py @@ -13,5 +13,5 @@ def call(self, folders, delete_type): def get_payload(self, folders, delete_type): payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DeleteType=delete_type)) - payload.append(create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version)) + payload.append(create_folder_ids_element(folders=folders, version=self.account.version)) return payload diff --git a/exchangelib/services/empty_folder.py b/exchangelib/services/empty_folder.py index 4eb44ff2..26862e03 100644 --- a/exchangelib/services/empty_folder.py +++ b/exchangelib/services/empty_folder.py @@ -18,5 +18,5 @@ def get_payload(self, folders, delete_type, delete_sub_folders): f'm:{self.SERVICE_NAME}', attrs=dict(DeleteType=delete_type, DeleteSubFolders=delete_sub_folders) ) - payload.append(create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version)) + payload.append(create_folder_ids_element(folders=folders, version=self.account.version)) return payload diff --git a/exchangelib/services/find_folder.py b/exchangelib/services/find_folder.py index 61cc1d44..84c24b0a 100644 --- a/exchangelib/services/find_folder.py +++ b/exchangelib/services/find_folder.py @@ -1,6 +1,6 @@ -from .common import EWSPagingService, create_shape_element +from .common import EWSPagingService, create_shape_element, create_folder_ids_element from ..folders import Folder -from ..util import create_element, set_xml_value, TNS, MNS +from ..util import create_element, TNS, MNS from ..version import EXCHANGE_2010 @@ -66,5 +66,7 @@ def get_payload(self, folders, additional_fields, restriction, shape, depth, pag raise ValueError('Offsets are only supported from Exchange 2010') if restriction: payload.append(restriction.to_xml(version=self.account.version)) - payload.append(set_xml_value(create_element('m:ParentFolderIds'), folders, version=self.account.version)) + payload.append(create_folder_ids_element( + folders=folders, version=self.protocol.version, tag='m:ParentFolderIds', + )) return payload diff --git a/exchangelib/services/find_item.py b/exchangelib/services/find_item.py index 0137507e..d46215e4 100644 --- a/exchangelib/services/find_item.py +++ b/exchangelib/services/find_item.py @@ -1,4 +1,4 @@ -from .common import EWSPagingService, create_shape_element +from .common import EWSPagingService, create_shape_element, create_folder_ids_element from ..folders.base import BaseFolder from ..items import Item, ID_ONLY from ..util import create_element, set_xml_value, TNS, MNS @@ -81,10 +81,8 @@ def get_payload(self, folders, additional_fields, restriction, order_fields, que order_fields, version=self.account.version )) - payload.append(set_xml_value( - create_element('m:ParentFolderIds'), - folders, - version=self.account.version + payload.append(create_folder_ids_element( + folders=folders, version=self.protocol.version, tag='m:ParentFolderIds', )) if query_string: payload.append(query_string.to_xml(version=self.account.version)) diff --git a/exchangelib/services/find_people.py b/exchangelib/services/find_people.py index c84b7a4c..432b58b2 100644 --- a/exchangelib/services/find_people.py +++ b/exchangelib/services/find_people.py @@ -1,5 +1,5 @@ import logging -from .common import EWSPagingService, create_shape_element +from .common import EWSPagingService, create_shape_element, create_folder_ids_element from ..items import Persona, ID_ONLY from ..util import create_element, set_xml_value, MNS from ..version import EXCHANGE_2013 @@ -81,11 +81,9 @@ def get_payload(self, folders, additional_fields, restriction, order_fields, que order_fields, version=self.account.version )) - payload.append(set_xml_value( - create_element('m:ParentFolderId'), - folder, - version=self.account.version - )) + payload.append( + create_folder_ids_element(folders=[folder], version=self.account.version, tag='m:ParentFolderId') + ) if query_string: payload.append(query_string.to_xml(version=self.account.version)) return payload diff --git a/exchangelib/services/get_folder.py b/exchangelib/services/get_folder.py index ca146941..e27b2c0b 100644 --- a/exchangelib/services/get_folder.py +++ b/exchangelib/services/get_folder.py @@ -48,5 +48,5 @@ def get_payload(self, folders, additional_fields, shape): payload.append(create_shape_element( tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version )) - payload.append(create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version)) + payload.append(create_folder_ids_element(folders=folders, version=self.account.version)) return payload diff --git a/exchangelib/services/get_persona.py b/exchangelib/services/get_persona.py index 7079e723..208e946d 100644 --- a/exchangelib/services/get_persona.py +++ b/exchangelib/services/get_persona.py @@ -24,7 +24,7 @@ def _elems_to_objs(self, elems): def get_payload(self, persona): return set_xml_value( create_element(f'm:{self.SERVICE_NAME}'), - to_item_id(persona, PersonaId, version=self.protocol.version), + to_item_id(persona, PersonaId), version=self.protocol.version ) diff --git a/exchangelib/services/move_folder.py b/exchangelib/services/move_folder.py index 1bff9c4b..e1487ace 100644 --- a/exchangelib/services/move_folder.py +++ b/exchangelib/services/move_folder.py @@ -1,7 +1,7 @@ from .common import EWSAccountService, create_folder_ids_element from ..folders import BaseFolder from ..properties import FolderId -from ..util import create_element, set_xml_value, MNS +from ..util import create_element, MNS class MoveFolder(EWSAccountService): @@ -23,6 +23,6 @@ def _elem_to_obj(self, elem): def get_payload(self, folders, to_folder): # Takes a list of folders and returns their new folder IDs payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(set_xml_value(create_element('m:ToFolderId'), to_folder, version=self.account.version)) - payload.append(create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version)) + payload.append(create_folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) + payload.append(create_folder_ids_element(folders=folders, version=self.account.version)) return payload diff --git a/exchangelib/services/move_item.py b/exchangelib/services/move_item.py index 126ae820..c3843a41 100644 --- a/exchangelib/services/move_item.py +++ b/exchangelib/services/move_item.py @@ -1,8 +1,8 @@ -from .common import EWSAccountService, create_item_ids_element +from .common import EWSAccountService, create_item_ids_element, create_folder_ids_element from ..folders import BaseFolder from ..items import Item from ..properties import FolderId -from ..util import create_element, set_xml_value, MNS +from ..util import create_element, MNS class MoveItem(EWSAccountService): @@ -24,6 +24,6 @@ def _elem_to_obj(self, elem): def get_payload(self, items, to_folder): # Takes a list of items and returns their new item IDs payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(set_xml_value(create_element('m:ToFolderId'), to_folder, version=self.account.version)) + payload.append(create_folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) payload.append(create_item_ids_element(items=items, version=self.account.version)) return payload diff --git a/exchangelib/services/resolve_names.py b/exchangelib/services/resolve_names.py index 56a053d3..ef6783fd 100644 --- a/exchangelib/services/resolve_names.py +++ b/exchangelib/services/resolve_names.py @@ -1,10 +1,10 @@ import logging -from .common import EWSService +from .common import EWSService, create_folder_ids_element from ..errors import ErrorNameResolutionNoResults, ErrorNameResolutionMultipleResults from ..items import SHAPE_CHOICES, SEARCH_SCOPE_CHOICES, Contact from ..properties import Mailbox -from ..util import create_element, set_xml_value, add_xml_child, MNS +from ..util import create_element, add_xml_child, MNS from ..version import EXCHANGE_2010_SP2 log = logging.getLogger(__name__) @@ -69,7 +69,9 @@ def get_payload(self, unresolved_entries, parent_folders, return_full_contact_da attrs['ContactDataShape'] = contact_data_shape payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) if parent_folders: - set_xml_value(create_element('m:ParentFolderIds'), parent_folders, version=self.protocol.version) + payload.append(create_folder_ids_element( + folders=parent_folders, version=self.protocol.version, tag='m:ParentFolderIds', + )) for entry in unresolved_entries: add_xml_child(payload, 'm:UnresolvedEntry', entry) if not len(payload): diff --git a/exchangelib/services/send_item.py b/exchangelib/services/send_item.py index e86b538b..070cf60a 100644 --- a/exchangelib/services/send_item.py +++ b/exchangelib/services/send_item.py @@ -1,7 +1,7 @@ -from .common import EWSAccountService, create_item_ids_element +from .common import EWSAccountService, create_item_ids_element, create_folder_ids_element from ..folders import BaseFolder from ..properties import FolderId -from ..util import create_element, set_xml_value +from ..util import create_element class SendItem(EWSAccountService): @@ -19,7 +19,7 @@ def get_payload(self, items, saved_item_folder): payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(SaveItemToFolder=bool(saved_item_folder))) payload.append(create_item_ids_element(items=items, version=self.account.version)) if saved_item_folder: - payload.append( - set_xml_value(create_element('m:SavedItemFolderId'), saved_item_folder, version=self.account.version) - ) + payload.append(create_folder_ids_element( + folders=[saved_item_folder], version=self.account.version, tag='m:SavedItemFolderId' + )) return payload diff --git a/exchangelib/services/subscribe.py b/exchangelib/services/subscribe.py index ad887870..3600b741 100644 --- a/exchangelib/services/subscribe.py +++ b/exchangelib/services/subscribe.py @@ -39,7 +39,7 @@ def _get_elements_in_container(cls, container): def _partial_payload(self, folders, event_types): request_elem = create_element(self.subscription_request_elem_tag) - folder_ids = create_folder_ids_element(tag='t:FolderIds', folders=folders, version=self.account.version) + folder_ids = create_folder_ids_element(folders=folders, version=self.account.version, tag='t:FolderIds') request_elem.append(folder_ids) event_types_elem = create_element('t:EventTypes') for event_type in event_types: diff --git a/exchangelib/services/sync_folder_hierarchy.py b/exchangelib/services/sync_folder_hierarchy.py index 6d566d99..1550f84a 100644 --- a/exchangelib/services/sync_folder_hierarchy.py +++ b/exchangelib/services/sync_folder_hierarchy.py @@ -45,7 +45,7 @@ def _partial_get_payload(self, folder, shape, additional_fields, sync_state): payload.append(create_shape_element( tag=self.shape_tag, shape=shape, additional_fields=additional_fields, version=self.account.version )) - payload.append(create_folder_ids_element(tag='m:SyncFolderId', folders=[folder], version=self.account.version)) + payload.append(create_folder_ids_element(folders=[folder], version=self.account.version, tag='m:SyncFolderId')) if sync_state: add_xml_child(payload, 'm:SyncState', sync_state) return payload diff --git a/exchangelib/services/update_folder.py b/exchangelib/services/update_folder.py index b3162c67..d93392cd 100644 --- a/exchangelib/services/update_folder.py +++ b/exchangelib/services/update_folder.py @@ -1,6 +1,5 @@ from .common import EWSAccountService, parse_folder_elem, to_item_id from ..fields import FieldPath, IndexedField -from ..folders import BaseFolder from ..properties import FolderId from ..util import create_element, set_xml_value, MNS @@ -91,11 +90,11 @@ def _update_elems(self, target, fieldnames): yield from self._set_field_elems(target_model=target_model, field=field, value=value) - def _change_elem(self, target, target_elem, fieldnames): + def _change_elem(self, target, fieldnames): if not fieldnames: raise ValueError('"fieldnames" must not be empty') change = create_element(self.CHANGE_ELEMENT_NAME) - set_xml_value(change, target_elem, version=self.account.version) + set_xml_value(change, self._target_elem(target), version=self.account.version) updates = create_element('t:Updates') for elem in self._update_elems(target=target, fieldnames=fieldnames): updates.append(elem) @@ -108,10 +107,7 @@ def _target_elem(self, target): def _changes_elem(self, target_changes): changes = create_element(self.CHANGES_ELEMENT_NAME) for target, fieldnames in target_changes: - target_elem = self._target_elem(target) - changes.append(self._change_elem( - target=target, target_elem=target_elem, fieldnames=fieldnames - )) + changes.append(self._change_elem(target=target, fieldnames=fieldnames)) if not len(changes): raise ValueError('List of changes must not be empty') return changes @@ -145,9 +141,7 @@ def _elems_to_objs(self, elems): yield parse_folder_elem(elem=elem, folder=folder, account=self.account) def _target_elem(self, target): - if isinstance(target, (BaseFolder, FolderId)): - return target.to_xml(version=self.account.version) - return to_item_id(target, FolderId, version=self.account.version) + return to_item_id(target, FolderId) def get_payload(self, folders): # Takes a list of (Folder, fieldnames) tuples where 'Folder' is a instance of a subclass of Folder and diff --git a/exchangelib/services/update_item.py b/exchangelib/services/update_item.py index 4dfdaf32..ce92bbe8 100644 --- a/exchangelib/services/update_item.py +++ b/exchangelib/services/update_item.py @@ -82,7 +82,7 @@ def _get_value(self, target, field): def _target_elem(self, target): if not target.account: target.account = self.account - return to_item_id(target, ItemId, version=self.account.version) + return to_item_id(target, ItemId) def get_payload(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, suppress_read_receipts): diff --git a/exchangelib/services/upload_items.py b/exchangelib/services/upload_items.py index 780d7d0d..7618b810 100644 --- a/exchangelib/services/upload_items.py +++ b/exchangelib/services/upload_items.py @@ -35,9 +35,7 @@ def get_payload(self, items): item = create_element('t:Item', attrs=attrs) set_xml_value(item, ParentFolderId(parent_folder.id, parent_folder.changekey), version=self.account.version) if item_id: - set_xml_value( - item, to_item_id(item_id, ItemId, version=self.account.version), version=self.account.version - ) + set_xml_value(item, to_item_id(item_id, ItemId), version=self.account.version) add_xml_child(item, 't:Data', data_str) items_elem.append(item) return payload diff --git a/exchangelib/util.py b/exchangelib/util.py index cfb64941..1b3c8469 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -229,7 +229,7 @@ def xml_text_to_value(value, value_type): }[value_type](value) -def set_xml_value(elem, value, version): +def set_xml_value(elem, value, version=None): from .ewsdatetime import EWSDateTime, EWSDate from .fields import FieldPath, FieldOrder from .properties import EWSElement @@ -276,7 +276,7 @@ def create_element(name, attrs=None, nsmap=None): def add_xml_child(tree, name, value): # We're calling add_xml_child many places where we don't have the version handy. Don't pass EWSElement or list of # EWSElement to this function! - tree.append(set_xml_value(elem=create_element(name), value=value, version=None)) + tree.append(set_xml_value(elem=create_element(name), value=value)) class StreamingContentHandler(xml.sax.handler.ContentHandler): From 08a2b1f09be3c526bd9f459ec0aa3a5bd3b5f102 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 14 Dec 2021 09:23:53 +0100 Subject: [PATCH 049/509] Shorten method names --- exchangelib/services/archive_item.py | 6 +++--- exchangelib/services/common.py | 10 +++++----- exchangelib/services/create_folder.py | 6 +++--- exchangelib/services/create_item.py | 4 ++-- exchangelib/services/delete_attachment.py | 4 ++-- exchangelib/services/delete_folder.py | 4 ++-- exchangelib/services/delete_item.py | 4 ++-- exchangelib/services/empty_folder.py | 4 ++-- exchangelib/services/export_items.py | 4 ++-- exchangelib/services/find_folder.py | 8 +++----- exchangelib/services/find_item.py | 8 +++----- exchangelib/services/find_people.py | 8 +++----- exchangelib/services/get_attachment.py | 4 ++-- exchangelib/services/get_folder.py | 7 +++---- exchangelib/services/get_item.py | 6 +++--- exchangelib/services/mark_as_junk.py | 4 ++-- exchangelib/services/move_folder.py | 6 +++--- exchangelib/services/move_item.py | 6 +++--- exchangelib/services/resolve_names.py | 6 +++--- exchangelib/services/send_item.py | 6 +++--- exchangelib/services/subscribe.py | 4 ++-- exchangelib/services/sync_folder_hierarchy.py | 6 +++--- exchangelib/services/sync_folder_items.py | 6 ++---- exchangelib/services/update_folder.py | 2 ++ exchangelib/services/update_item.py | 2 -- 25 files changed, 63 insertions(+), 72 deletions(-) diff --git a/exchangelib/services/archive_item.py b/exchangelib/services/archive_item.py index 7f175a8b..a7f917dc 100644 --- a/exchangelib/services/archive_item.py +++ b/exchangelib/services/archive_item.py @@ -1,4 +1,4 @@ -from .common import EWSAccountService, create_folder_ids_element, create_item_ids_element +from .common import EWSAccountService, folder_ids_element, item_ids_element from ..items import Item from ..util import create_element, MNS from ..version import EXCHANGE_2013 @@ -27,7 +27,7 @@ def _elem_to_obj(self, elem): def get_payload(self, items, to_folder): payload = create_element(f'm:{self.SERVICE_NAME}') payload.append( - create_folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ArchiveSourceFolderId') + folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ArchiveSourceFolderId') ) - payload.append(create_item_ids_element(items=items, version=self.account.version)) + payload.append(item_ids_element(items=items, version=self.account.version)) return payload diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index a4577bfc..57f2dec3 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -852,7 +852,7 @@ def to_item_id(item, item_cls): return item_cls(item.id, item.changekey) -def create_shape_element(tag, shape, additional_fields, version): +def shape_element(tag, shape, additional_fields, version): shape_elem = create_element(tag) add_xml_child(shape_elem, 't:BaseShape', shape) if additional_fields: @@ -869,7 +869,7 @@ def create_shape_element(tag, shape, additional_fields, version): return shape_elem -def create_folder_ids_element(folders, version, tag='m:FolderIds'): +def folder_ids_element(folders, version, tag='m:FolderIds'): folder_ids = create_element(tag) for folder in folders: set_xml_value(folder_ids, to_item_id(folder, FolderId), version=version) @@ -878,7 +878,7 @@ def create_folder_ids_element(folders, version, tag='m:FolderIds'): return folder_ids -def create_item_ids_element(items, version, tag='m:ItemIds'): +def item_ids_element(items, version, tag='m:ItemIds'): item_ids = create_element(tag) for item in items: set_xml_value(item_ids, to_item_id(item, ItemId), version=version) @@ -887,8 +887,8 @@ def create_item_ids_element(items, version, tag='m:ItemIds'): return item_ids -def create_attachment_ids_element(items, version): - attachment_ids = create_element('m:AttachmentIds') +def attachment_ids_element(items, version, tag='m:AttachmentIds'): + attachment_ids = create_element(tag) for item in items: attachment_id = item if isinstance(item, AttachmentId) else AttachmentId(id=item) set_xml_value(attachment_ids, attachment_id, version=version) diff --git a/exchangelib/services/create_folder.py b/exchangelib/services/create_folder.py index 1dcd1003..0fdadfb3 100644 --- a/exchangelib/services/create_folder.py +++ b/exchangelib/services/create_folder.py @@ -1,4 +1,4 @@ -from .common import EWSAccountService, parse_folder_elem, create_folder_ids_element +from .common import EWSAccountService, parse_folder_elem, folder_ids_element from ..util import create_element, MNS @@ -30,7 +30,7 @@ def _elems_to_objs(self, elems): def get_payload(self, folders, parent_folder): payload = create_element(f'm:{self.SERVICE_NAME}') payload.append( - create_folder_ids_element(folders=[parent_folder], version=self.account.version, tag='m:ParentFolderId') + folder_ids_element(folders=[parent_folder], version=self.account.version, tag='m:ParentFolderId') ) - payload.append(create_folder_ids_element(folders=folders, version=self.account.version, tag='m:Folders')) + payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:Folders')) return payload diff --git a/exchangelib/services/create_item.py b/exchangelib/services/create_item.py index 02e060f2..a91f42fd 100644 --- a/exchangelib/services/create_item.py +++ b/exchangelib/services/create_item.py @@ -1,4 +1,4 @@ -from .common import EWSAccountService, create_folder_ids_element +from .common import EWSAccountService, folder_ids_element from ..folders import BaseFolder from ..items import SAVE_ONLY, SEND_AND_SAVE_COPY, SEND_ONLY, \ SEND_MEETING_INVITATIONS_CHOICES, MESSAGE_DISPOSITION_CHOICES, BulkCreateResult @@ -81,7 +81,7 @@ def get_payload(self, items, folder, message_disposition, send_meeting_invitatio attrs=dict(MessageDisposition=message_disposition, SendMeetingInvitations=send_meeting_invitations) ) if folder: - payload.append(create_folder_ids_element( + payload.append(folder_ids_element( folders=[folder], version=self.account.version, tag='m:SavedItemFolderId' )) item_elems = create_element('m:Items') diff --git a/exchangelib/services/delete_attachment.py b/exchangelib/services/delete_attachment.py index cc661b77..cb71d041 100644 --- a/exchangelib/services/delete_attachment.py +++ b/exchangelib/services/delete_attachment.py @@ -1,4 +1,4 @@ -from .common import EWSAccountService, create_attachment_ids_element +from .common import EWSAccountService, attachment_ids_element from ..properties import RootItemId from ..util import create_element, set_xml_value @@ -23,6 +23,6 @@ def _get_elements_in_container(cls, container): def get_payload(self, items): return set_xml_value( create_element(f'm:{self.SERVICE_NAME}'), - create_attachment_ids_element(items=items, version=self.account.version), + attachment_ids_element(items=items, version=self.account.version), version=self.account.version ) diff --git a/exchangelib/services/delete_folder.py b/exchangelib/services/delete_folder.py index d3dfffea..96b0a7c7 100644 --- a/exchangelib/services/delete_folder.py +++ b/exchangelib/services/delete_folder.py @@ -1,4 +1,4 @@ -from .common import EWSAccountService, create_folder_ids_element +from .common import EWSAccountService, folder_ids_element from ..util import create_element @@ -13,5 +13,5 @@ def call(self, folders, delete_type): def get_payload(self, folders, delete_type): payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DeleteType=delete_type)) - payload.append(create_folder_ids_element(folders=folders, version=self.account.version)) + payload.append(folder_ids_element(folders=folders, version=self.account.version)) return payload diff --git a/exchangelib/services/delete_item.py b/exchangelib/services/delete_item.py index 0b2a296e..8e05e0fc 100644 --- a/exchangelib/services/delete_item.py +++ b/exchangelib/services/delete_item.py @@ -1,4 +1,4 @@ -from .common import EWSAccountService, create_item_ids_element +from .common import EWSAccountService, item_ids_element from ..items import DELETE_TYPE_CHOICES, SEND_MEETING_CANCELLATIONS_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES from ..util import create_element from ..version import EXCHANGE_2013_SP1 @@ -45,5 +45,5 @@ def get_payload(self, items, delete_type, send_meeting_cancellations, affected_t if self.account.version.build >= EXCHANGE_2013_SP1: attrs['SuppressReadReceipts'] = suppress_read_receipts payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) - payload.append(create_item_ids_element(items=items, version=self.account.version)) + payload.append(item_ids_element(items=items, version=self.account.version)) return payload diff --git a/exchangelib/services/empty_folder.py b/exchangelib/services/empty_folder.py index 26862e03..a6726f18 100644 --- a/exchangelib/services/empty_folder.py +++ b/exchangelib/services/empty_folder.py @@ -1,4 +1,4 @@ -from .common import EWSAccountService, create_folder_ids_element +from .common import EWSAccountService, folder_ids_element from ..util import create_element @@ -18,5 +18,5 @@ def get_payload(self, folders, delete_type, delete_sub_folders): f'm:{self.SERVICE_NAME}', attrs=dict(DeleteType=delete_type, DeleteSubFolders=delete_sub_folders) ) - payload.append(create_folder_ids_element(folders=folders, version=self.account.version)) + payload.append(folder_ids_element(folders=folders, version=self.account.version)) return payload diff --git a/exchangelib/services/export_items.py b/exchangelib/services/export_items.py index 6361b4fd..9663275e 100644 --- a/exchangelib/services/export_items.py +++ b/exchangelib/services/export_items.py @@ -1,4 +1,4 @@ -from .common import EWSAccountService, create_item_ids_element +from .common import EWSAccountService, item_ids_element from ..errors import ResponseMessageError from ..util import create_element, MNS @@ -18,7 +18,7 @@ def _elem_to_obj(self, elem): def get_payload(self, items): payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(create_item_ids_element(items=items, version=self.account.version)) + payload.append(item_ids_element(items=items, version=self.account.version)) return payload # We need to override this since ExportItemsResponseMessage is formatted a diff --git a/exchangelib/services/find_folder.py b/exchangelib/services/find_folder.py index 84c24b0a..873b9375 100644 --- a/exchangelib/services/find_folder.py +++ b/exchangelib/services/find_folder.py @@ -1,4 +1,4 @@ -from .common import EWSPagingService, create_shape_element, create_folder_ids_element +from .common import EWSPagingService, shape_element, folder_ids_element from ..folders import Folder from ..util import create_element, TNS, MNS from ..version import EXCHANGE_2010 @@ -52,7 +52,7 @@ def _elem_to_obj(self, elem): def get_payload(self, folders, additional_fields, restriction, shape, depth, page_size, offset=0): payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) - payload.append(create_shape_element( + payload.append(shape_element( tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version )) if self.account.version.build >= EXCHANGE_2010: @@ -66,7 +66,5 @@ def get_payload(self, folders, additional_fields, restriction, shape, depth, pag raise ValueError('Offsets are only supported from Exchange 2010') if restriction: payload.append(restriction.to_xml(version=self.account.version)) - payload.append(create_folder_ids_element( - folders=folders, version=self.protocol.version, tag='m:ParentFolderIds', - )) + payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds')) return payload diff --git a/exchangelib/services/find_item.py b/exchangelib/services/find_item.py index d46215e4..77d54436 100644 --- a/exchangelib/services/find_item.py +++ b/exchangelib/services/find_item.py @@ -1,4 +1,4 @@ -from .common import EWSPagingService, create_shape_element, create_folder_ids_element +from .common import EWSPagingService, shape_element, folder_ids_element from ..folders.base import BaseFolder from ..items import Item, ID_ONLY from ..util import create_element, set_xml_value, TNS, MNS @@ -62,7 +62,7 @@ def _elem_to_obj(self, elem): def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, calendar_view, page_size, offset=0): payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) - payload.append(create_shape_element( + payload.append(shape_element( tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version )) if calendar_view is None: @@ -81,9 +81,7 @@ def get_payload(self, folders, additional_fields, restriction, order_fields, que order_fields, version=self.account.version )) - payload.append(create_folder_ids_element( - folders=folders, version=self.protocol.version, tag='m:ParentFolderIds', - )) + payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds')) if query_string: payload.append(query_string.to_xml(version=self.account.version)) return payload diff --git a/exchangelib/services/find_people.py b/exchangelib/services/find_people.py index 432b58b2..cf38a0ac 100644 --- a/exchangelib/services/find_people.py +++ b/exchangelib/services/find_people.py @@ -1,5 +1,5 @@ import logging -from .common import EWSPagingService, create_shape_element, create_folder_ids_element +from .common import EWSPagingService, shape_element, folder_ids_element from ..items import Persona, ID_ONLY from ..util import create_element, set_xml_value, MNS from ..version import EXCHANGE_2013 @@ -66,7 +66,7 @@ def get_payload(self, folders, additional_fields, restriction, order_fields, que raise ValueError(f'{self.SERVICE_NAME} can only query one folder') folder = folders[0] payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) - payload.append(create_shape_element( + payload.append(shape_element( tag='m:PersonaShape', shape=shape, additional_fields=additional_fields, version=self.account.version )) payload.append(create_element( @@ -81,9 +81,7 @@ def get_payload(self, folders, additional_fields, restriction, order_fields, que order_fields, version=self.account.version )) - payload.append( - create_folder_ids_element(folders=[folder], version=self.account.version, tag='m:ParentFolderId') - ) + payload.append(folder_ids_element(folders=[folder], version=self.account.version, tag='m:ParentFolderId')) if query_string: payload.append(query_string.to_xml(version=self.account.version)) return payload diff --git a/exchangelib/services/get_attachment.py b/exchangelib/services/get_attachment.py index 0980d974..8730fbab 100644 --- a/exchangelib/services/get_attachment.py +++ b/exchangelib/services/get_attachment.py @@ -1,6 +1,6 @@ from itertools import chain -from .common import EWSAccountService, create_attachment_ids_element +from .common import EWSAccountService, attachment_ids_element from ..attachments import FileAttachment, ItemAttachment from ..util import create_element, add_xml_child, set_xml_value, DummyResponse, StreamingBase64Parser,\ StreamingContentHandler, ElementNotFound, MNS @@ -46,7 +46,7 @@ def get_payload(self, items, include_mime_content, body_type, filter_html_conten shape_elem.append(additional_properties) if len(shape_elem): payload.append(shape_elem) - payload.append(create_attachment_ids_element(items=items, version=self.account.version)) + payload.append(attachment_ids_element(items=items, version=self.account.version)) return payload def _update_api_version(self, api_version, header, **parse_opts): diff --git a/exchangelib/services/get_folder.py b/exchangelib/services/get_folder.py index e27b2c0b..a151e3e1 100644 --- a/exchangelib/services/get_folder.py +++ b/exchangelib/services/get_folder.py @@ -1,5 +1,4 @@ -from .common import EWSAccountService, parse_folder_elem, create_folder_ids_element, \ - create_shape_element +from .common import EWSAccountService, parse_folder_elem, folder_ids_element, shape_element from ..errors import ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorInvalidOperation from ..util import create_element, MNS @@ -45,8 +44,8 @@ def _elems_to_objs(self, elems): def get_payload(self, folders, additional_fields, shape): payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(create_shape_element( + payload.append(shape_element( tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version )) - payload.append(create_folder_ids_element(folders=folders, version=self.account.version)) + payload.append(folder_ids_element(folders=folders, version=self.account.version)) return payload diff --git a/exchangelib/services/get_item.py b/exchangelib/services/get_item.py index b266fa9d..eb7b06b3 100644 --- a/exchangelib/services/get_item.py +++ b/exchangelib/services/get_item.py @@ -1,4 +1,4 @@ -from .common import EWSAccountService, create_item_ids_element, create_shape_element +from .common import EWSAccountService, item_ids_element, shape_element from ..folders.base import BaseFolder from ..util import create_element, MNS @@ -27,8 +27,8 @@ def _elem_to_obj(self, elem): def get_payload(self, items, additional_fields, shape): payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(create_shape_element( + payload.append(shape_element( tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version )) - payload.append(create_item_ids_element(items=items, version=self.account.version)) + payload.append(item_ids_element(items=items, version=self.account.version)) return payload diff --git a/exchangelib/services/mark_as_junk.py b/exchangelib/services/mark_as_junk.py index 17a593c1..3998ed66 100644 --- a/exchangelib/services/mark_as_junk.py +++ b/exchangelib/services/mark_as_junk.py @@ -1,4 +1,4 @@ -from .common import EWSAccountService, create_item_ids_element +from .common import EWSAccountService, item_ids_element from ..properties import MovedItemId from ..util import create_element @@ -25,5 +25,5 @@ def _get_elements_in_container(cls, container): def get_payload(self, items, is_junk, move_item): # Takes a list of items and returns either success or raises an error message payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IsJunk=is_junk, MoveItem=move_item)) - payload.append(create_item_ids_element(items=items, version=self.account.version)) + payload.append(item_ids_element(items=items, version=self.account.version)) return payload diff --git a/exchangelib/services/move_folder.py b/exchangelib/services/move_folder.py index e1487ace..f649202a 100644 --- a/exchangelib/services/move_folder.py +++ b/exchangelib/services/move_folder.py @@ -1,4 +1,4 @@ -from .common import EWSAccountService, create_folder_ids_element +from .common import EWSAccountService, folder_ids_element from ..folders import BaseFolder from ..properties import FolderId from ..util import create_element, MNS @@ -23,6 +23,6 @@ def _elem_to_obj(self, elem): def get_payload(self, folders, to_folder): # Takes a list of folders and returns their new folder IDs payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(create_folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) - payload.append(create_folder_ids_element(folders=folders, version=self.account.version)) + payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) + payload.append(folder_ids_element(folders=folders, version=self.account.version)) return payload diff --git a/exchangelib/services/move_item.py b/exchangelib/services/move_item.py index c3843a41..72c0fda2 100644 --- a/exchangelib/services/move_item.py +++ b/exchangelib/services/move_item.py @@ -1,4 +1,4 @@ -from .common import EWSAccountService, create_item_ids_element, create_folder_ids_element +from .common import EWSAccountService, item_ids_element, folder_ids_element from ..folders import BaseFolder from ..items import Item from ..properties import FolderId @@ -24,6 +24,6 @@ def _elem_to_obj(self, elem): def get_payload(self, items, to_folder): # Takes a list of items and returns their new item IDs payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(create_folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) - payload.append(create_item_ids_element(items=items, version=self.account.version)) + payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) + payload.append(item_ids_element(items=items, version=self.account.version)) return payload diff --git a/exchangelib/services/resolve_names.py b/exchangelib/services/resolve_names.py index ef6783fd..9774f3c0 100644 --- a/exchangelib/services/resolve_names.py +++ b/exchangelib/services/resolve_names.py @@ -1,6 +1,6 @@ import logging -from .common import EWSService, create_folder_ids_element +from .common import EWSService, folder_ids_element from ..errors import ErrorNameResolutionNoResults, ErrorNameResolutionMultipleResults from ..items import SHAPE_CHOICES, SEARCH_SCOPE_CHOICES, Contact from ..properties import Mailbox @@ -69,8 +69,8 @@ def get_payload(self, unresolved_entries, parent_folders, return_full_contact_da attrs['ContactDataShape'] = contact_data_shape payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) if parent_folders: - payload.append(create_folder_ids_element( - folders=parent_folders, version=self.protocol.version, tag='m:ParentFolderIds', + payload.append(folder_ids_element( + folders=parent_folders, version=self.protocol.version, tag='m:ParentFolderIds' )) for entry in unresolved_entries: add_xml_child(payload, 'm:UnresolvedEntry', entry) diff --git a/exchangelib/services/send_item.py b/exchangelib/services/send_item.py index 070cf60a..b7bb8646 100644 --- a/exchangelib/services/send_item.py +++ b/exchangelib/services/send_item.py @@ -1,4 +1,4 @@ -from .common import EWSAccountService, create_item_ids_element, create_folder_ids_element +from .common import EWSAccountService, item_ids_element, folder_ids_element from ..folders import BaseFolder from ..properties import FolderId from ..util import create_element @@ -17,9 +17,9 @@ def call(self, items, saved_item_folder): def get_payload(self, items, saved_item_folder): payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(SaveItemToFolder=bool(saved_item_folder))) - payload.append(create_item_ids_element(items=items, version=self.account.version)) + payload.append(item_ids_element(items=items, version=self.account.version)) if saved_item_folder: - payload.append(create_folder_ids_element( + payload.append(folder_ids_element( folders=[saved_item_folder], version=self.account.version, tag='m:SavedItemFolderId' )) return payload diff --git a/exchangelib/services/subscribe.py b/exchangelib/services/subscribe.py index 3600b741..9a2e86a9 100644 --- a/exchangelib/services/subscribe.py +++ b/exchangelib/services/subscribe.py @@ -3,7 +3,7 @@ """ import abc -from .common import EWSAccountService, create_folder_ids_element, add_xml_child +from .common import EWSAccountService, folder_ids_element, add_xml_child from ..util import create_element, MNS @@ -39,7 +39,7 @@ def _get_elements_in_container(cls, container): def _partial_payload(self, folders, event_types): request_elem = create_element(self.subscription_request_elem_tag) - folder_ids = create_folder_ids_element(folders=folders, version=self.account.version, tag='t:FolderIds') + folder_ids = folder_ids_element(folders=folders, version=self.account.version, tag='t:FolderIds') request_elem.append(folder_ids) event_types_elem = create_element('t:EventTypes') for event_type in event_types: diff --git a/exchangelib/services/sync_folder_hierarchy.py b/exchangelib/services/sync_folder_hierarchy.py index 1550f84a..f01ee421 100644 --- a/exchangelib/services/sync_folder_hierarchy.py +++ b/exchangelib/services/sync_folder_hierarchy.py @@ -1,7 +1,7 @@ import abc import logging -from .common import EWSPagingService, add_xml_child, create_folder_ids_element, create_shape_element, parse_folder_elem +from .common import EWSPagingService, add_xml_child, folder_ids_element, shape_element, parse_folder_elem from ..properties import FolderId from ..util import create_element, xml_text_to_value, MNS, TNS @@ -42,10 +42,10 @@ def _get_element_container(self, message, name=None): def _partial_get_payload(self, folder, shape, additional_fields, sync_state): payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(create_shape_element( + payload.append(shape_element( tag=self.shape_tag, shape=shape, additional_fields=additional_fields, version=self.account.version )) - payload.append(create_folder_ids_element(folders=[folder], version=self.account.version, tag='m:SyncFolderId')) + payload.append(folder_ids_element(folders=[folder], version=self.account.version, tag='m:SyncFolderId')) if sync_state: add_xml_child(payload, 'm:SyncState', sync_state) return payload diff --git a/exchangelib/services/sync_folder_items.py b/exchangelib/services/sync_folder_items.py index 7740690e..9b35c268 100644 --- a/exchangelib/services/sync_folder_items.py +++ b/exchangelib/services/sync_folder_items.py @@ -1,4 +1,4 @@ -from .common import add_xml_child, create_item_ids_element +from .common import add_xml_child, item_ids_element from .sync_folder_hierarchy import SyncFolder from ..folders import BaseFolder from ..properties import ItemId @@ -63,9 +63,7 @@ def get_payload(self, folder, shape, additional_fields, sync_state, ignore, max_ ) is_empty, ignore = (True, None) if ignore is None else peek(ignore) if not is_empty: - sync_folder_items.append( - create_item_ids_element(items=ignore, version=self.account.version, tag='m:Ignore') - ) + sync_folder_items.append(item_ids_element(items=ignore, version=self.account.version, tag='m:Ignore')) add_xml_child(sync_folder_items, 'm:MaxChangesReturned', max_changes_returned) if sync_scope: add_xml_child(sync_folder_items, 'm:SyncScope', sync_scope) diff --git a/exchangelib/services/update_folder.py b/exchangelib/services/update_folder.py index d93392cd..9e0d764b 100644 --- a/exchangelib/services/update_folder.py +++ b/exchangelib/services/update_folder.py @@ -107,6 +107,8 @@ def _target_elem(self, target): def _changes_elem(self, target_changes): changes = create_element(self.CHANGES_ELEMENT_NAME) for target, fieldnames in target_changes: + if not target.account: + target.account = self.account changes.append(self._change_elem(target=target, fieldnames=fieldnames)) if not len(changes): raise ValueError('List of changes must not be empty') diff --git a/exchangelib/services/update_item.py b/exchangelib/services/update_item.py index ce92bbe8..eb78120e 100644 --- a/exchangelib/services/update_item.py +++ b/exchangelib/services/update_item.py @@ -80,8 +80,6 @@ def _get_value(self, target, field): return value def _target_elem(self, target): - if not target.account: - target.account = self.account return to_item_id(target, ItemId) def get_payload(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, From df7551a42ca161053f89dcddcf89b1592e4edb47 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 14 Dec 2021 10:21:42 +0100 Subject: [PATCH 050/509] Clean up set_xml_value() usage a bit --- exchangelib/items/calendar_item.py | 2 +- exchangelib/services/get_server_time_zones.py | 2 +- exchangelib/settings.py | 8 ++++---- exchangelib/util.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/exchangelib/items/calendar_item.py b/exchangelib/items/calendar_item.py index 5d54fa78..37f389d8 100644 --- a/exchangelib/items/calendar_item.py +++ b/exchangelib/items/calendar_item.py @@ -260,7 +260,7 @@ def to_xml(self, version): # We already generated an XML element for this field, but it contains a plain date at this point, which # is invalid. Replace the value. field = self.get_field_by_fieldname(field_name) - set_xml_value(elem=elem.find(field.response_tag()), value=value, version=version) + set_xml_value(elem.find(field.response_tag()), value) return elem diff --git a/exchangelib/services/get_server_time_zones.py b/exchangelib/services/get_server_time_zones.py index a529f0f1..1e9a314a 100644 --- a/exchangelib/services/get_server_time_zones.py +++ b/exchangelib/services/get_server_time_zones.py @@ -33,7 +33,7 @@ def get_payload(self, timezones, return_full_timezone_data): if not is_empty: tz_ids = create_element('m:Ids') for timezone in timezones: - tz_id = set_xml_value(create_element('t:Id'), timezone.ms_id, version=self.protocol.version) + tz_id = set_xml_value(create_element('t:Id'), timezone.ms_id) tz_ids.append(tz_id) payload.append(tz_ids) return payload diff --git a/exchangelib/settings.py b/exchangelib/settings.py index 1b89ce27..dcd1832f 100644 --- a/exchangelib/settings.py +++ b/exchangelib/settings.py @@ -55,22 +55,22 @@ def to_xml(self, version): if value is None: continue f = self.get_field_by_fieldname(attr) - set_xml_value(elem, f.to_xml(value, version=version), version=version) + set_xml_value(elem, f.to_xml(value, version=version)) if self.start or self.end: duration = create_element('t:Duration') if self.start: f = self.get_field_by_fieldname('start') - set_xml_value(duration, f.to_xml(self.start, version=version), version) + set_xml_value(duration, f.to_xml(self.start, version=version)) if self.end: f = self.get_field_by_fieldname('end') - set_xml_value(duration, f.to_xml(self.end, version=version), version) + set_xml_value(duration, f.to_xml(self.end, version=version)) elem.append(duration) for attr in ('internal_reply', 'external_reply'): value = getattr(self, attr) if value is None: value = '' # The value can be empty, but the XML element must always be present f = self.get_field_by_fieldname(attr) - set_xml_value(elem, f.to_xml(value, version=version), version) + set_xml_value(elem, f.to_xml(value, version=version)) return elem def __hash__(self): diff --git a/exchangelib/util.py b/exchangelib/util.py index 1b3c8469..259e8817 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -246,7 +246,7 @@ def set_xml_value(elem, value, version=None): elem.append(value.to_xml(version=version)) elif is_iterable(value, generators_allowed=True): for v in value: - set_xml_value(elem, v, version) + set_xml_value(elem, v, version=version) else: raise ValueError(f'Unsupported type {type(value)} for value {value} on elem {elem}') return elem From 136ebe4171451e94bb902050d01b6165f08b15d0 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 14 Dec 2021 10:57:00 +0100 Subject: [PATCH 051/509] Only raise NotImplementedError() when method must be implemented --- exchangelib/services/common.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 57f2dec3..2f90eee5 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -164,8 +164,6 @@ def parse(self, xml): def _elems_to_objs(self, elems): """Takes a generator of XML elements and exceptions. Returns the equivalent Python objects (or exceptions).""" - if not self.returns_elements: - raise ValueError("Incorrect call to method when 'returns_elements' is False") for elem in elems: if isinstance(elem, Exception): yield elem @@ -173,6 +171,8 @@ def _elems_to_objs(self, elems): yield self._elem_to_obj(elem) def _elem_to_obj(self, elem): + if not self.returns_elements: + raise RuntimeError("Incorrect call to method when 'returns_elements' is False") raise NotImplementedError() @property From e722eef9d3e6d87b180a01b2f4e50c9193a68596 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 20 Dec 2021 14:08:27 +0100 Subject: [PATCH 052/509] Clean up docs a bit --- docs/index.md | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/docs/index.md b/docs/index.md index de85ba7a..6999eb91 100644 --- a/docs/index.md +++ b/docs/index.md @@ -77,7 +77,7 @@ To install the very latest code, install directly from GitHub instead: pip install git+https://github.com/ecederstrand/exchangelib.git ``` -`exchangelib` uses the `lxml` package, and `pykerberos` to support Kerberos authentication. +This package uses the `lxml` package, and `pykerberos` to support Kerberos authentication. To be able to install these, you may need to install some additional operating system packages. On Ubuntu: @@ -231,8 +231,8 @@ account = Account(primary_smtp_address='john@example.com', config=config, autodiscover=False, access_type=DELEGATE) ``` -exchangelib will attempt to guess the server version and authentication -method. If you have a really bizarre or locked-down installation and the +We will attempt to guess the server version and authentication method +automatically. If you have a really bizarre or locked-down installation and the guessing fails, or you want to avoid the extra network traffic, you can set the auth method and version explicitly instead: @@ -246,7 +246,7 @@ config = Configuration( ) ``` -By default, 'exchangelib' will only create 1 connection to the server. If you +By default, only one single connection to the server is created. If you are using threads to send multiple requests concurrently, you may want to increase this limit. The Exchange server may have rate-limiting policies in place for the connecting credentials, so make sure to agree with your Exchange @@ -341,7 +341,7 @@ credentials = OAuth2AuthorizationCodeCredentials( config = Configuration(credentials=credentials, auth_type=OAUTH2) ``` -Applications using the authorization code flow that let exchangelib refresh +Applications using the authorization code flow that refreshes access tokens for them probably want to store the refreshed tokens so users don't have to re-authorize. Subclass OAuth2AuthorizationCodeCredentials and override on_token_auto_refreshed(): @@ -356,8 +356,8 @@ class MyCredentials(OAuth2AuthorizationCodeCredentials): For applications that use the authorization code flow and rely on an external provider to refresh access tokens (and thus are unable to provide a client ID -and secret to exchangelib), subclass OAuth2AuthorizationCodeCredentials and -override refresh(). +and secret to this package), subclass `OAuth2AuthorizationCodeCredentials` and +override `refresh()`. ```python class MyCredentials(OAuth2AuthorizationCodeCredentials): @@ -432,7 +432,7 @@ class RootCAAdapter(requests.adapters.HTTPAdapter): }[urlparse(url).hostname] super().cert_verify(conn=conn, url=url, verify=cert_file, cert=cert) -# Tell exchangelib to use this adapter class instead of the default +# Use this adapter class instead of the default BaseProtocol.HTTP_ADAPTER_CLS = RootCAAdapter ``` @@ -450,31 +450,30 @@ class ProxyAdapter(requests.adapters.HTTPAdapter): } return super().send(*args, **kwargs) -# Tell exchangelib to use this adapter class instead of the default +# Use this adapter class instead of the default BaseProtocol.HTTP_ADAPTER_CLS = ProxyAdapter ``` -`exchangelib` provides a sample adapter which ignores TLS validation -errors. Use at own risk. +A sample adapter is provided which ignores TLS validation errors. Use at own risk. ```python from exchangelib.protocol import BaseProtocol, NoVerifyHTTPAdapter -# Tell exchangelib to use this adapter class instead of the default +# Use this adapter class instead of the default BaseProtocol.HTTP_ADAPTER_CLS = NoVerifyHTTPAdapter ``` ### User-Agent You can supply a custom 'User-Agent' for your application. -By default, `exchangelib` will use: `exchangelib/ (python-requests/)` +The default is `exchangelib/ (python-requests/)` Here's an example using different User-Agent: ```python from exchangelib.protocol import BaseProtocol -# Tell exchangelib to use this user-agent instead of the default +# Use this user-agent instead of the default BaseProtocol.USERAGENT = "Auto-Reply/0.1.0" ``` @@ -536,7 +535,7 @@ root │ └── todos └── archive ├── Last Job - ├── exchangelib issues + ├── GitLab issues └── Mom ''' ``` @@ -1304,7 +1303,7 @@ can access them. Create a subclass of `ExtendedProperty` and define a set of matching setup values: ```python -from exchangelib import Account, ExtendedProperty, CalendarItem, Folder, Message +from exchangelib import Account, ExtendedProperty, CalendarItem a = Account(...) @@ -1361,6 +1360,8 @@ RootOfHierarchy folder classes. Here's an example of getting the size (in bytes) of a folder: ```python +from exchangelib import ExtendedProperty, Folder + class FolderSize(ExtendedProperty): property_tag = 0x0e08 property_type = 'Integer' @@ -1386,6 +1387,8 @@ https://docs.microsoft.com/en-us/dotnet/api/microsoft.exchange.webservices.data. In conclusion, the definition for the due date becomes: ```python +from exchangelib import ExtendedProperty, Message + class FlagDue(ExtendedProperty): property_set_id = '00062003-0000-0000-C000-000000000046' property_id = 0x8105 @@ -1714,7 +1717,7 @@ subscription_id, watermark = a.inbox.subscribe_to_push( ``` When the server sends a push notification, the POST data contains a -'SendNotification' XML document. You can use exchangelib in the callback URL +`SendNotification` XML document. You can use this package in the callback URL implementation to parse this data. Here's a short example of a Flask app that handles these documents: ```python From e9bc26677eb342e426decf7a5a1ca317de9c150e Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 20 Dec 2021 14:09:52 +0100 Subject: [PATCH 053/509] Allow creating SOAP documents without a header --- exchangelib/transport.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/exchangelib/transport.py b/exchangelib/transport.py index 78c1bbbc..e33a2cf2 100644 --- a/exchangelib/transport.py +++ b/exchangelib/transport.py @@ -49,7 +49,7 @@ DEFAULT_HEADERS = {'Content-Type': f'text/xml; charset={DEFAULT_ENCODING}', 'Accept-Encoding': 'gzip, deflate'} -def wrap(content, api_version, account_to_impersonate=None, timezone=None): +def wrap(content, api_version=None, account_to_impersonate=None, timezone=None): """Generate the necessary boilerplate XML for a raw SOAP request. The XML is specific to the server version. ExchangeImpersonation allows to act as the user we want to impersonate. @@ -69,8 +69,9 @@ def wrap(content, api_version, account_to_impersonate=None, timezone=None): """ envelope = create_element('s:Envelope', nsmap=ns_translation) header = create_element('s:Header') - request_server_version = create_element('t:RequestServerVersion', attrs=dict(Version=api_version)) - header.append(request_server_version) + if api_version: + request_server_version = create_element('t:RequestServerVersion', attrs=dict(Version=api_version)) + header.append(request_server_version) if account_to_impersonate: exchange_impersonation = create_element('t:ExchangeImpersonation') connecting_sid = create_element('t:ConnectingSID') @@ -93,7 +94,8 @@ def wrap(content, api_version, account_to_impersonate=None, timezone=None): timezone_definition = create_element('t:TimeZoneDefinition', attrs=dict(Id=timezone.ms_id)) timezone_context.append(timezone_definition) header.append(timezone_context) - envelope.append(header) + if len(header): + envelope.append(header) body = create_element('s:Body') body.append(content) envelope.append(body) From 8f7385d4628b06d53c1c5396eea4a9614cd5e86f Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 20 Dec 2021 14:14:13 +0100 Subject: [PATCH 054/509] Support generating responses to push notifications from the Exchange server --- CHANGELOG.md | 2 ++ docs/index.md | 13 ++++++-- exchangelib/services/send_notification.py | 26 +++++++++++++--- tests/test_items/test_sync.py | 37 +++++++++++++++++++++++ 4 files changed, 70 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ae7802..372dc464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ HEAD `QuerySet.page_size` previously had. Chunk size is the number of items we send in e.g. a `GetItem` call, while `page_size` is the number of items we request per page in services like `FindItem` that support paging. +- Support creating a proper response when getting a notification request + on the callback URL of a push subscription. 4.6.2 diff --git a/docs/index.md b/docs/index.md index 6999eb91..58e168be 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1709,7 +1709,8 @@ subscription_id, watermark = a.inbox.subscribe_to_pull() Here's how to create a push subscription. The server will regularly send an HTTP POST request to the callback URL to deliver changes or a status message. There is also support -for parsing the POST data that the Exchange server sends to the callback URL. +for parsing the POST data that the Exchange server sends to the callback URL, and for +creating proper responses to these URLs. ```python subscription_id, watermark = a.inbox.subscribe_to_push( callback_url='https://my_app.example.com/callback_url' @@ -1727,11 +1728,16 @@ from flask import Flask, request app = Flask(__name__) @app.route('/callback_url', methods=['POST']) -def upload_file(): +def notify_me(): ws = SendNotification(protocol=None) for notification in ws.parse(request.data): # ws.parse() returns Notification objects pass + data = ws.ok_payload() + # Or, if you want to end the subscription: + data = ws.unsubscribe_payload() + + return data, 201, {'Content-Type': 'text/xml; charset=utf-8'} ``` Here's how to create a streaming subscription that can be used to stream events from the @@ -1741,7 +1747,8 @@ subscription_id = a.inbox.subscribe_to_streaming() ``` Cancel the subscription when you're done synchronizing. This is not supported for push -subscriptions. They cancel automatically after a certain amount of failed attempts. +subscriptions. They cancel automatically after a certain amount of failed attempts, or +you can let your callback URL send an unsubscribe response as described above. ```python a.inbox.unsubscribe(subscription_id) ``` diff --git a/exchangelib/services/send_notification.py b/exchangelib/services/send_notification.py index 0fbe8ac1..db0711fa 100644 --- a/exchangelib/services/send_notification.py +++ b/exchangelib/services/send_notification.py @@ -1,18 +1,27 @@ -from .common import EWSService +from .common import EWSService, add_xml_child from ..properties import Notification -from ..util import MNS +from ..transport import wrap +from ..util import create_element, MNS class SendNotification(EWSService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/sendnotification - This is not an actual EWS service you can call. We only use it to parse the XML body of push notifications. + This service is implemented backwards compared to other services. We use it to parse the XML body of push + notifications we receive on the callback URL defined in a push subscription, and to create responses to these + push notifications. """ SERVICE_NAME = 'SendNotification' + OK = 'OK' + UNSUBSCRIBE = 'Unsubscribe' + STATUS_CHOICES = (OK, UNSUBSCRIBE) - def call(self): - raise NotImplementedError() + def ok_payload(self): + return wrap(content=self.get_payload(status=self.OK)) + + def unsubscribe_payload(self): + return wrap(content=self.get_payload(status=self.UNSUBSCRIBE)) def _elem_to_obj(self, elem): return Notification.from_xml(elem=elem, account=None) @@ -25,3 +34,10 @@ def _response_tag(cls): @classmethod def _get_elements_in_container(cls, container): return container.findall(Notification.response_tag()) + + def get_payload(self, status): + if status not in self.STATUS_CHOICES: + raise ValueError(f"'status' {status!r} must be one of {self.STATUS_CHOICES}") + payload = create_element(f'm:{self.SERVICE_NAME}Result') + add_xml_child(payload, 'm:SubscriptionStatus', status) + return payload diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index 321b1366..4769547e 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -5,6 +5,7 @@ from exchangelib.items import Message from exchangelib.properties import StatusEvent, CreatedEvent, ModifiedEvent, DeletedEvent, Notification from exchangelib.services import SendNotification +from exchangelib.util import PrettyXmlHandler from .test_basics import BaseItemTest from ..common import get_random_string @@ -283,3 +284,39 @@ def test_push_message_parsing(self): [Notification(subscription_id='XXXXX=', previous_watermark='AAAAA=', more_events=False, events=[StatusEvent(watermark='BBBBB=')])] ) + + def test_push_message_responses(self): + # Test SendNotification + ws = SendNotification(protocol=None) + self.assertEqual( + PrettyXmlHandler.prettify_xml(ws.ok_response()), + b'''\ + + + + + OK + + + +''' + ) + self.assertEqual( + PrettyXmlHandler.prettify_xml(ws.unsubscribe_response()), + b'''\ + + + + + Unsubscribe + + + +''' + ) From e188ded4565d02e389817241f25eb8ad51d60ebf Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 20 Dec 2021 14:27:18 +0100 Subject: [PATCH 055/509] Fix method names --- tests/test_items/test_sync.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index 4769547e..993a2230 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -289,7 +289,7 @@ def test_push_message_responses(self): # Test SendNotification ws = SendNotification(protocol=None) self.assertEqual( - PrettyXmlHandler.prettify_xml(ws.ok_response()), + PrettyXmlHandler.prettify_xml(ws.ok_payload()), b'''\ Date: Mon, 20 Dec 2021 14:53:22 +0100 Subject: [PATCH 056/509] Retry on all complex fields. Retry with increasing delay. Mark indexed and extended fields as complex --- exchangelib/fields.py | 5 +++++ tests/test_items/test_basics.py | 8 ++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index 29b3f65f..3c4f26ee 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -1270,6 +1270,7 @@ def __hash__(self): class EmailAddressesField(IndexedField): is_list = True + is_complex = True PARENT_ELEMENT_NAME = 'EmailAddresses' @@ -1295,6 +1296,7 @@ def clean(self, value, version=None): class PhoneNumberField(IndexedField): is_list = True + is_complex = True PARENT_ELEMENT_NAME = 'PhoneNumbers' @@ -1306,6 +1308,7 @@ def __init__(self, *args, **kwargs): class PhysicalAddressField(IndexedField): is_list = True + is_complex = True PARENT_ELEMENT_NAME = 'PhysicalAddresses' @@ -1316,6 +1319,8 @@ def __init__(self, *args, **kwargs): class ExtendedPropertyField(Field): + is_complex = True + def __init__(self, *args, **kwargs): self.value_cls = kwargs.pop('value_cls') super().__init__(*args, **kwargs) diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index e7652aae..8e625113 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -287,17 +287,17 @@ def _run_filter_tests(self, qs, f, filter_kwargs, val): for kw in filter_kwargs: with self.subTest(f=f, kw=kw): matches = qs.filter(**kw).count() - if isinstance(f, TextField) and f.is_complex: - # Complex text fields sometimes fail a search using generated data. In production, + if f.is_complex: + # Complex fields sometimes fail a search using generated data. In production, # they almost always work anyway. Give it one more try after 10 seconds; it seems EWS does # some sort of indexing that needs to catch up. if not matches and isinstance(f, BodyField): # The body field is particularly nasty in this area. Give up continue - for _ in range(5): + for i in range(1, 6): if matches: break - time.sleep(2) + time.sleep(i*2) matches = qs.filter(**kw).count() if f.is_list and not val and list(kw)[0].endswith(f'__{Q.LOOKUP_IN}'): # __in with an empty list returns an empty result From 229d94e132f54372450bad364e43d136bb116287 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 26 Dec 2021 14:02:55 +0100 Subject: [PATCH 057/509] Fix docs on using create_forward(). Fixes #1028 --- docs/index.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/index.md b/docs/index.md index 58e168be..cea233ca 100644 --- a/docs/index.md +++ b/docs/index.md @@ -760,11 +760,12 @@ You can also edit a draft of a reply or forward ```python from exchangelib import FileAttachment -forward_draft = m.create_forward( +save_result = m.create_forward( subject='Fwd: Daily motivation', body='Hey, look at this!', - to_recipients=['carl@example.com', 'denice@example.com'] -).save(a.drafts) # gives you back the item + to_recipients=['erik@cederstrand.dk'] +).save(a.drafts) # gives you back a BulkCreateResult containing the ID and changekey +forward_draft = a.drafts.get(id=save_result.id, changekey=save_result.changekey) forward_draft.reply_to = ['erik@example.com'] forward_draft.attach(FileAttachment( name='my_file.txt', content='hello world'.encode('utf-8')) From 57c34cefb4dff6cf2acb4598cf24fa6154661be9 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 26 Dec 2021 19:05:51 +0100 Subject: [PATCH 058/509] Better error reporting --- tests/test_items/test_basics.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index 8e625113..6fc8eb9c 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -286,6 +286,7 @@ def _reduce_fields_for_filter(self, item, fields): def _run_filter_tests(self, qs, f, filter_kwargs, val): for kw in filter_kwargs: with self.subTest(f=f, kw=kw): + retries = 0 matches = qs.filter(**kw).count() if f.is_complex: # Complex fields sometimes fail a search using generated data. In production, @@ -294,16 +295,15 @@ def _run_filter_tests(self, qs, f, filter_kwargs, val): if not matches and isinstance(f, BodyField): # The body field is particularly nasty in this area. Give up continue - for i in range(1, 6): + for _ in range(5): if matches: break - time.sleep(i*2) + retries += 1 + time.sleep(retries*2) matches = qs.filter(**kw).count() - if f.is_list and not val and list(kw)[0].endswith(f'__{Q.LOOKUP_IN}'): - # __in with an empty list returns an empty result - self.assertEqual(matches, 0, (f.name, val, kw)) - else: - self.assertEqual(matches, 1, (f.name, val, kw)) + # __in with an empty list returns an empty result + expected = 0 if f.is_list and not val and list(kw)[0].endswith(f'__in') else 1 + self.assertEqual(matches, expected, (f.name, val, kw, retries)) def test_filter_on_simple_fields(self): # Test that we can filter on all simple fields From 2543c2041f2c6b448e1ed38c42b8d9f70e5fdeb3 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 26 Dec 2021 19:49:08 +0100 Subject: [PATCH 059/509] Clean up some todos, remove remnants of thread protocol, fix unpickle inheritance --- exchangelib/protocol.py | 13 ++++--------- exchangelib/services/common.py | 2 +- tests/test_items/test_basics.py | 3 +-- tests/test_items/test_sync.py | 6 +++--- tests/test_protocol.py | 4 ++-- 5 files changed, 11 insertions(+), 17 deletions(-) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 6d63b916..146bd6c9 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -39,7 +39,7 @@ class BaseProtocol: # The maximum number of sessions (== TCP connections, see below) we will open to this service endpoint. Keep this # low unless you have an agreement with the Exchange admin on the receiving end to hammer the server and # rate-limiting policies have been disabled for the connecting user. Changing this setting only makes sense if - # you are using a thread pool to run multiple concurrent workers in this process. + # you are using threads to run multiple concurrent workers in this process. SESSION_POOLSIZE = 1 # We want only 1 TCP connection per Session object. We may have lots of different credentials hitting the server and # each credential needs its own session (NTLM auth will only send credentials once and then secure the connection, @@ -593,19 +593,14 @@ def convert_ids(self, ids, destination_format): return ConvertId(protocol=self).call(items=ids, destination_format=destination_format) def __getstate__(self): - # The lock and thread pool cannot be pickled + # The lock cannot be pickled state = super().__getstate__() del state['_version_lock'] - try: - del state['thread_pool'] - except KeyError: - # thread_pool is a cached property and may not exist - pass return state def __setstate__(self, state): - # Restore the lock. The thread pool is a cached property and will be recreated automatically. - self.__dict__.update(state) + # Restore the lock + super().__setstate__(state) self._version_lock = Lock() def __str__(self): diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 2f90eee5..afff25e8 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -262,7 +262,7 @@ def _get_elements(self, payload): # Re-raise as an ErrorServerBusy with a default delay of 5 minutes raise ErrorServerBusy(f'Reraised from {e.__class__.__name__}({e})') except Exception: - # This may run from a thread pool, which obfuscates the stack trace. Print trace immediately. + # This may run in a thread, which obfuscates the stack trace. Print trace immediately. account = self.account if isinstance(self, EWSAccountService) else None log.warning('Account %s: Exception in _get_elements: %s', account, traceback.format_exc(20)) raise diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index 6fc8eb9c..fb9db792 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -299,7 +299,7 @@ def _run_filter_tests(self, qs, f, filter_kwargs, val): if matches: break retries += 1 - time.sleep(retries*2) + time.sleep(retries*retries) # Exponential sleep matches = qs.filter(**kw).count() # __in with an empty list returns an empty result expected = 0 if f.is_list and not val and list(kw)[0].endswith(f'__in') else 1 @@ -378,7 +378,6 @@ def test_filter_on_single_field_index_fields(self): def test_filter_on_multi_field_index_fields(self): # Test that we can filter on all index fields - # TODO: Test filtering on subfields of IndexedField item = self.get_test_item() fields = [] for f in self._reduce_fields_for_filter(item, self.get_item_fields()): diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index 993a2230..0c50cb9a 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -150,7 +150,7 @@ def test_pull_notifications(self): # Test that we see a create event i1 = self.get_test_item(folder=test_folder).save() - time.sleep(5) # TODO: For some reason, events do not trigger instantly + time.sleep(5) # For some reason, events do not trigger instantly notifications = list(test_folder.get_events(subscription_id, watermark)) created_event, watermark = self._filter_events(notifications, CreatedEvent, i1.id) self.assertEqual(created_event.item_id.id, i1.id) @@ -158,7 +158,7 @@ def test_pull_notifications(self): # Test that we see an update event i1.subject = get_random_string(8) i1.save(update_fields=['subject']) - time.sleep(5) # TODO: For some reason, events do not trigger instantly + time.sleep(5) # For some reason, events do not trigger instantly notifications = list(test_folder.get_events(subscription_id, watermark)) modified_event, watermark = self._filter_events(notifications, ModifiedEvent, i1.id) self.assertEqual(modified_event.item_id.id, i1.id) @@ -166,7 +166,7 @@ def test_pull_notifications(self): # Test that we see a delete event i1_id = i1.id i1.delete() - time.sleep(5) # TODO: For some reason, events do not trigger instantly + time.sleep(5) # For some reason, events do not trigger instantly notifications = list(test_folder.get_events(subscription_id, watermark)) deleted_event, watermark = self._filter_events(notifications, DeletedEvent, i1_id) self.assertEqual(deleted_event.item_id.id, i1_id) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 6dac1898..fd872582 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -19,7 +19,7 @@ TransportError, SessionPoolMaxSizeReached, TimezoneDefinitionInvalidForYear from exchangelib.properties import TimeZone, RoomList, FreeBusyView, AlternateId, ID_FORMATS, EWS_ID, \ SearchableMailbox, FailedMailbox, Mailbox, DLMailbox -from exchangelib.protocol import Protocol, BaseProtocol, NoVerifyHTTPAdapter, FailFast +from exchangelib.protocol import Protocol, BaseProtocol, NoVerifyHTTPAdapter, FailFast, close_connections from exchangelib.services import GetServerTimeZones, GetRoomLists, GetRooms, ResolveNames, GetSearchableMailboxes from exchangelib.settings import OofSettings from exchangelib.transport import NOAUTH, NTLM @@ -87,7 +87,7 @@ def test_protocol_instance_caching(self, m): p = Protocol(config=config) self.assertNotEqual(base_p, p) - Protocol.clear_cache() + close_connections() # Also clears cache def test_close(self): # Don't use example.com here - it does not resolve or answer on all ISPs From d192f3aba12f2522ad048e0456b628453c4f404f Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 29 Dec 2021 11:18:34 +0100 Subject: [PATCH 060/509] Hide GetPersona quirkyness inside GetPersona.call() so the service behaves like all other services --- exchangelib/account.py | 5 +---- exchangelib/services/get_persona.py | 16 ++++++---------- exchangelib/services/mark_as_junk.py | 2 -- tests/test_items/test_contacts.py | 8 +++----- 4 files changed, 10 insertions(+), 21 deletions(-) diff --git a/exchangelib/account.py b/exchangelib/account.py index f522cfbf..bdde95ee 100644 --- a/exchangelib/account.py +++ b/exchangelib/account.py @@ -605,10 +605,7 @@ def fetch_personas(self, ids): # We accept generators, so it's not always convenient for caller to know up-front if 'ids' is empty. Allow # empty 'ids' and return early. return - # GetPersona only accepts one persona ID per request. Crazy. - svc = GetPersona(account=self) - for i in ids: - yield svc.call(persona=i) + yield from GetPersona(account=self).call(personas=ids) @property def mail_tips(self): diff --git a/exchangelib/services/get_persona.py b/exchangelib/services/get_persona.py index 208e946d..deaa4419 100644 --- a/exchangelib/services/get_persona.py +++ b/exchangelib/services/get_persona.py @@ -9,16 +9,12 @@ class GetPersona(EWSAccountService): SERVICE_NAME = 'GetPersona' - def call(self, persona): - return self._elems_to_objs(self._get_elements(payload=self.get_payload(persona=persona))) - - def _elems_to_objs(self, elems): - elements = list(elems) - if len(elements) != 1: - raise ValueError('Expected exactly one element in response') - elem = elements[0] - if isinstance(elem, Exception): - raise elem + def call(self, personas): + # GetPersona only accepts one persona ID per request. Crazy. + for persona in personas: + yield from self._elems_to_objs(self._get_elements(payload=self.get_payload(persona=persona))) + + def _elem_to_obj(self, elem): return Persona.from_xml(elem=elem, account=None) def get_payload(self, persona): diff --git a/exchangelib/services/mark_as_junk.py b/exchangelib/services/mark_as_junk.py index 3998ed66..a1f0101e 100644 --- a/exchangelib/services/mark_as_junk.py +++ b/exchangelib/services/mark_as_junk.py @@ -14,8 +14,6 @@ def call(self, items, is_junk, move_item): ) def _elem_to_obj(self, elem): - if elem is None: - return elem return MovedItemId.id_from_xml(elem) @classmethod diff --git a/tests/test_items/test_contacts.py b/tests/test_items/test_contacts.py index 1cc91029..5ba11178 100644 --- a/tests/test_items/test_contacts.py +++ b/tests/test_items/test_contacts.py @@ -187,7 +187,7 @@ def test_get_persona(self): ''' ws = GetPersona(account=self.account) - persona = ws.parse(xml) + persona = list(ws.parse(xml))[0] self.assertEqual(persona.id, 'AAQkADEzAQAKtOtR=') self.assertEqual(persona.persona_type, 'Person') self.assertEqual( @@ -221,7 +221,5 @@ def test_get_persona(self): def test_get_persona_failure(self): # The test server may not have any personas. Just test that the service response with something we can parse persona = Persona(id='AAA=', changekey='xxx') - try: - GetPersona(account=self.account).call(persona=persona) - except ErrorInvalidIdMalformed: - pass + with self.assertRaises(ErrorInvalidIdMalformed): + GetPersona(account=self.account).get(personas=[persona]) From 3f9274e92630f25b952d6e3870e662ec757cd30d Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 29 Dec 2021 11:22:01 +0100 Subject: [PATCH 061/509] A subscription for multiple folders still only returns a single subscription ID. Refs #1031 --- CHANGELOG.md | 2 ++ exchangelib/folders/base.py | 30 +++++------------------------- exchangelib/folders/collections.py | 6 +++--- 3 files changed, 10 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 372dc464..893e3926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ HEAD per page in services like `FindItem` that support paging. - Support creating a proper response when getting a notification request on the callback URL of a push subscription. +- `FolderCollection.subscribe_to_[pull|push|streaming]` now return a single + subscription instead of a 1-element generator. 4.6.2 diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 46194864..9f194495 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -526,15 +526,9 @@ def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): from ..services import SubscribeToPull if event_types is None: event_types = SubscribeToPull.EVENT_TYPES - s_ids = list(FolderCollection(account=self.account, folders=[self]).subscribe_to_pull( + return FolderCollection(account=self.account, folders=[self]).subscribe_to_pull( event_types=event_types, watermark=watermark, timeout=timeout, - )) - if len(s_ids) != 1: - raise ValueError(f'Expected result length 1, but got {s_ids}') - s_id = s_ids[0] - if isinstance(s_id, Exception): - raise s_id - return s_id + ) @require_id def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1): @@ -549,15 +543,9 @@ def subscribe_to_push(self, callback_url, event_types=None, watermark=None, stat from ..services import SubscribeToPush if event_types is None: event_types = SubscribeToPush.EVENT_TYPES - s_ids = list(FolderCollection(account=self.account, folders=[self]).subscribe_to_push( + return FolderCollection(account=self.account, folders=[self]).subscribe_to_push( event_types=event_types, watermark=watermark, status_frequency=status_frequency, callback_url=callback_url, - )) - if len(s_ids) != 1: - raise ValueError(f'Expected result length 1, but got {s_ids}') - s_id = s_ids[0] - if isinstance(s_id, Exception): - raise s_id - return s_id + ) @require_id def subscribe_to_streaming(self, event_types=None): @@ -569,15 +557,7 @@ def subscribe_to_streaming(self, event_types=None): from ..services import SubscribeToStreaming if event_types is None: event_types = SubscribeToStreaming.EVENT_TYPES - s_ids = list(FolderCollection(account=self.account, folders=[self]).subscribe_to_streaming( - event_types=event_types, - )) - if len(s_ids) != 1: - raise ValueError(f'Expected result length 1, but got {s_ids}') - s_id = s_ids[0] - if isinstance(s_id, Exception): - raise s_id - return s_id + return FolderCollection(account=self.account, folders=[self]).subscribe_to_streaming(event_types=event_types) @require_id def pull_subscription(self, **kwargs): diff --git a/exchangelib/folders/collections.py b/exchangelib/folders/collections.py index 8c172da4..bcac09a5 100644 --- a/exchangelib/folders/collections.py +++ b/exchangelib/folders/collections.py @@ -394,7 +394,7 @@ def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): return if not event_types: event_types = SubscribeToPull.EVENT_TYPES - yield from SubscribeToPull(account=self.account).call( + return SubscribeToPull(account=self.account).get( folders=self.folders, event_types=event_types, watermark=watermark, timeout=timeout, ) @@ -405,7 +405,7 @@ def subscribe_to_push(self, callback_url, event_types=None, watermark=None, stat return if not event_types: event_types = SubscribeToPush.EVENT_TYPES - yield from SubscribeToPush(account=self.account).call( + return SubscribeToPush(account=self.account).get( folders=self.folders, event_types=event_types, watermark=watermark, status_frequency=status_frequency, url=callback_url, ) @@ -417,7 +417,7 @@ def subscribe_to_streaming(self, event_types=None): return if not event_types: event_types = SubscribeToStreaming.EVENT_TYPES - yield from SubscribeToStreaming(account=self.account).call(folders=self.folders, event_types=event_types) + return SubscribeToStreaming(account=self.account).get(folders=self.folders, event_types=event_types) def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None): from ..services import SyncFolderItems From 7988fa69abac3917ea74d6234bc1ec6c79422413 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 29 Dec 2021 11:37:36 +0100 Subject: [PATCH 062/509] Add subscription context managers to folder collections. Refs #1031 --- CHANGELOG.md | 4 ++- exchangelib/folders/base.py | 38 +------------------- exchangelib/folders/collections.py | 58 ++++++++++++++++++++++++++++++ tests/test_items/test_sync.py | 20 +++++++++++ 4 files changed, 82 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 893e3926..fad24598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,10 @@ HEAD per page in services like `FindItem` that support paging. - Support creating a proper response when getting a notification request on the callback URL of a push subscription. -- `FolderCollection.subscribe_to_[pull|push|streaming]` now return a single +- `FolderCollection.subscribe_to_[pull|push|streaming]()` now return a single subscription instead of a 1-element generator. +- `FolderCollection` now has the same `[pull|push|streaming]_subscription()` + context managers as folders. 4.6.2 diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 9f194495..443f62f0 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -3,7 +3,7 @@ from fnmatch import fnmatch from operator import attrgetter -from .collections import FolderCollection, SyncCompleted +from .collections import FolderCollection, SyncCompleted, PullSubscription, PushSubscription, StreamingSubscription from .queryset import SingleFolderQuerySet, SHALLOW as SHALLOW_FOLDERS, DEEP as DEEP_FOLDERS from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorCannotEmptyFolder, ErrorCannotDeleteObject, \ ErrorDeleteDistinguishedFolder, ErrorNoPublicFolderReplicaAvailable, ErrorItemNotFound @@ -847,39 +847,3 @@ def from_xml_with_root(cls, elem, root): if folder_cls == Folder: log.debug('Fallback to class Folder (folder_class %s, name %s)', folder.folder_class, folder.name) return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS}) - - -class BaseSubscription(metaclass=abc.ABCMeta): - def __init__(self, folder, **subscription_kwargs): - self.folder = folder - self.subscription_kwargs = subscription_kwargs - self.subscription_id = None - - def __enter__(self): - pass - - def __exit__(self, *args, **kwargs): - self.folder.unsubscribe(subscription_id=self.subscription_id) - self.subscription_id = None - - -class PullSubscription(BaseSubscription): - def __enter__(self): - self.subscription_id, watermark = self.folder.subscribe_to_pull(**self.subscription_kwargs) - return self.subscription_id, watermark - - -class PushSubscription(BaseSubscription): - def __enter__(self): - self.subscription_id, watermark = self.folder.subscribe_to_push(**self.subscription_kwargs) - return self.subscription_id, watermark - - def __exit__(self, *args, **kwargs): - # Cannot unsubscribe to push subscriptions - pass - - -class StreamingSubscription(BaseSubscription): - def __enter__(self): - self.subscription_id = self.folder.subscribe_to_streaming(**self.subscription_kwargs) - return self.subscription_id diff --git a/exchangelib/folders/collections.py b/exchangelib/folders/collections.py index bcac09a5..842e3860 100644 --- a/exchangelib/folders/collections.py +++ b/exchangelib/folders/collections.py @@ -1,3 +1,4 @@ +import abc import logging from cached_property import threaded_cached_property @@ -419,6 +420,27 @@ def subscribe_to_streaming(self, event_types=None): event_types = SubscribeToStreaming.EVENT_TYPES return SubscribeToStreaming(account=self.account).get(folders=self.folders, event_types=event_types) + def pull_subscription(self, **kwargs): + return PullSubscription(folder=self, **kwargs) + + def push_subscription(self, **kwargs): + return PushSubscription(folder=self, **kwargs) + + def streaming_subscription(self, **kwargs): + return StreamingSubscription(folder=self, **kwargs) + + def unsubscribe(self, subscription_id): + """Unsubscribe. Only applies to pull and streaming notifications. + + :param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|streaming]() + :return: True + + This method doesn't need the current collection instance, but it makes sense to keep the method along the other + sync methods. + """ + from ..services import Unsubscribe + return Unsubscribe(account=self.account).get(subscription_id=subscription_id) + def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None): from ..services import SyncFolderItems folder = self._get_single_folder() @@ -486,3 +508,39 @@ def sync_hierarchy(self, sync_state=None, only_fields=None): if svc.includes_last_item_in_range: # Try again if there are more items break raise SyncCompleted(sync_state=svc.sync_state) + + +class BaseSubscription(metaclass=abc.ABCMeta): + def __init__(self, folder, **subscription_kwargs): + self.folder = folder + self.subscription_kwargs = subscription_kwargs + self.subscription_id = None + + def __enter__(self): + pass + + def __exit__(self, *args, **kwargs): + self.folder.unsubscribe(subscription_id=self.subscription_id) + self.subscription_id = None + + +class PullSubscription(BaseSubscription): + def __enter__(self): + self.subscription_id, watermark = self.folder.subscribe_to_pull(**self.subscription_kwargs) + return self.subscription_id, watermark + + +class PushSubscription(BaseSubscription): + def __enter__(self): + self.subscription_id, watermark = self.folder.subscribe_to_push(**self.subscription_kwargs) + return self.subscription_id, watermark + + def __exit__(self, *args, **kwargs): + # Cannot unsubscribe to push subscriptions + pass + + +class StreamingSubscription(BaseSubscription): + def __enter__(self): + self.subscription_id = self.folder.subscribe_to_streaming(**self.subscription_kwargs) + return self.subscription_id diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index 0c50cb9a..4daca77f 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -24,6 +24,12 @@ def test_pull_subscribe(self): # Context manager already unsubscribed us with self.assertRaises(ErrorSubscriptionNotFound): self.account.inbox.unsubscribe(subscription_id) + # Test via folder collection + with self.account.root.tois.children.pull_subscription() as (subscription_id, watermark): + self.assertIsNotNone(subscription_id) + self.assertIsNotNone(watermark) + with self.assertRaises(ErrorSubscriptionNotFound): + self.account.root.tois.children.unsubscribe(subscription_id) # Affinity cookie is not always sent by the server for pull subscriptions def test_push_subscribe(self): @@ -34,6 +40,14 @@ def test_push_subscribe(self): self.assertIsNotNone(watermark) with self.assertRaises(ErrorInvalidSubscription): self.account.inbox.unsubscribe(subscription_id) + # Test via folder collection + with self.account.root.tois.children.push_subscription( + callback_url='https://example.com/foo' + ) as (subscription_id, watermark): + self.assertIsNotNone(subscription_id) + self.assertIsNotNone(watermark) + with self.assertRaises(ErrorInvalidSubscription): + self.account.root.tois.children.unsubscribe(subscription_id) def test_streaming_subscribe(self): self.account.affinity_cookie = None @@ -42,6 +56,12 @@ def test_streaming_subscribe(self): # Context manager already unsubscribed us with self.assertRaises(ErrorSubscriptionNotFound): self.account.inbox.unsubscribe(subscription_id) + # Test via folder collection + with self.account.root.tois.children.streaming_subscription() as subscription_id: + self.assertIsNotNone(subscription_id) + with self.assertRaises(ErrorSubscriptionNotFound): + self.account.root.tois.children.unsubscribe(subscription_id) + # Test affinity cookie self.assertIsNotNone(self.account.affinity_cookie) From 63e2eea0e91a3cda4a9f3437f3ddc4f38b484ab9 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sat, 1 Jan 2022 15:14:08 +0100 Subject: [PATCH 063/509] Update URL --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b20102f1..f08b5608 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ exporting and uploading calendar, mailbox, task, contact and distribution list i [![image](https://img.shields.io/pypi/v/exchangelib.svg)](https://pypi.org/project/exchangelib/) [![image](https://img.shields.io/pypi/pyversions/exchangelib.svg)](https://pypi.org/project/exchangelib/) -[![image](https://api.codacy.com/project/badge/Grade/5f805ad901054a889f4b99a82d6c1cb7)](https://www.codacy.com/app/ecederstrand/exchangelib) +[![image](https://api.codacy.com/project/badge/Grade/5f805ad901054a889f4b99a82d6c1cb7)](https://app.codacy.com/gh/ecederstrand/exchangelib) [![image](https://api.travis-ci.com/ecederstrand/exchangelib.png)](http://travis-ci.com/ecederstrand/exchangelib) [![image](https://coveralls.io/repos/github/ecederstrand/exchangelib/badge.svg?branch=master)](https://coveralls.io/github/ecederstrand/exchangelib?branch=master) [![xscode](https://img.shields.io/badge/Available%20on-xs%3Acode-blue)](https://xscode.com/ecederstrand/exchangelib) From e2e224a69a62ce7160538abe255f239d938b4212 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 2 Jan 2022 16:11:57 +0100 Subject: [PATCH 064/509] Improve code coverage --- exchangelib/services/common.py | 3 ++- exchangelib/services/convert_id.py | 2 -- exchangelib/services/create_attachment.py | 2 -- exchangelib/services/create_item.py | 4 ---- exchangelib/services/get_mail_tips.py | 2 -- exchangelib/services/move_folder.py | 2 -- exchangelib/services/move_item.py | 2 -- exchangelib/services/resolve_names.py | 2 -- exchangelib/services/update_folder.py | 2 -- exchangelib/services/update_item.py | 2 -- exchangelib/settings.py | 2 -- tests/test_account.py | 11 +++++++++-- tests/test_folder.py | 11 ++++++++++- tests/test_items/test_generic.py | 6 ++++++ tests/test_items/test_sync.py | 3 +++ tests/test_protocol.py | 10 +++++++++- 16 files changed, 39 insertions(+), 27 deletions(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index afff25e8..f5ce1ebd 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -165,7 +165,8 @@ def parse(self, xml): def _elems_to_objs(self, elems): """Takes a generator of XML elements and exceptions. Returns the equivalent Python objects (or exceptions).""" for elem in elems: - if isinstance(elem, Exception): + # Allow None here. Some services don't return an ID if the target folder is outside the mailbox. + if isinstance(elem, (Exception, type(None))): yield elem continue yield self._elem_to_obj(elem) diff --git a/exchangelib/services/convert_id.py b/exchangelib/services/convert_id.py index 6fd47557..69d63ffe 100644 --- a/exchangelib/services/convert_id.py +++ b/exchangelib/services/convert_id.py @@ -35,8 +35,6 @@ def get_payload(self, items, destination_format): if not isinstance(item, supported_item_classes): raise ValueError(f"'item' value {item!r} must be an instance of {supported_item_classes}") set_xml_value(item_ids, item, version=self.protocol.version) - if not len(item_ids): - raise ValueError('"items" must not be empty') payload.append(item_ids) return payload diff --git a/exchangelib/services/create_attachment.py b/exchangelib/services/create_attachment.py index efe7a1e2..3878795e 100644 --- a/exchangelib/services/create_attachment.py +++ b/exchangelib/services/create_attachment.py @@ -30,7 +30,5 @@ def get_payload(self, items, parent_item): attachments = create_element('m:Attachments') for item in items: set_xml_value(attachments, item, version=version) - if not len(attachments): - raise ValueError('"items" must not be empty') payload.append(attachments) return payload diff --git a/exchangelib/services/create_item.py b/exchangelib/services/create_item.py index a91f42fd..b89fc9e9 100644 --- a/exchangelib/services/create_item.py +++ b/exchangelib/services/create_item.py @@ -46,8 +46,6 @@ def call(self, items, folder, message_disposition, send_meeting_invitations): )) def _elem_to_obj(self, elem): - if elem is None: - return elem if isinstance(elem, bool): return elem return BulkCreateResult.from_xml(elem=elem, account=self.account) @@ -89,7 +87,5 @@ def get_payload(self, items, folder, message_disposition, send_meeting_invitatio if not item.account: item.account = self.account set_xml_value(item_elems, item, version=self.account.version) - if not len(item_elems): - raise ValueError('"items" must not be empty') payload.append(item_elems) return payload diff --git a/exchangelib/services/get_mail_tips.py b/exchangelib/services/get_mail_tips.py index 70ed1e73..69f4dd5c 100644 --- a/exchangelib/services/get_mail_tips.py +++ b/exchangelib/services/get_mail_tips.py @@ -26,8 +26,6 @@ def get_payload(self, recipients, sending_as, mail_tips_requested): recipients_elem = create_element('m:Recipients') for recipient in recipients: set_xml_value(recipients_elem, recipient, version=self.protocol.version) - if not len(recipients_elem): - raise ValueError('"recipients" must not be empty') payload.append(recipients_elem) if mail_tips_requested: diff --git a/exchangelib/services/move_folder.py b/exchangelib/services/move_folder.py index f649202a..9dbf5898 100644 --- a/exchangelib/services/move_folder.py +++ b/exchangelib/services/move_folder.py @@ -16,8 +16,6 @@ def call(self, folders, to_folder): return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=folders, to_folder=to_folder)) def _elem_to_obj(self, elem): - if elem is None: - return elem return FolderId.from_xml(elem=elem.find(FolderId.response_tag()), account=self.account) def get_payload(self, folders, to_folder): diff --git a/exchangelib/services/move_item.py b/exchangelib/services/move_item.py index 72c0fda2..dbb0cc6d 100644 --- a/exchangelib/services/move_item.py +++ b/exchangelib/services/move_item.py @@ -17,8 +17,6 @@ def call(self, items, to_folder): return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, to_folder=to_folder)) def _elem_to_obj(self, elem): - if elem is None: - return elem return Item.id_from_xml(elem) def get_payload(self, items, to_folder): diff --git a/exchangelib/services/resolve_names.py b/exchangelib/services/resolve_names.py index 9774f3c0..19f4d2b5 100644 --- a/exchangelib/services/resolve_names.py +++ b/exchangelib/services/resolve_names.py @@ -74,6 +74,4 @@ def get_payload(self, unresolved_entries, parent_folders, return_full_contact_da )) for entry in unresolved_entries: add_xml_child(payload, 'm:UnresolvedEntry', entry) - if not len(payload): - raise ValueError('"unresolved_entries" must not be empty') return payload diff --git a/exchangelib/services/update_folder.py b/exchangelib/services/update_folder.py index 9e0d764b..95bd8c90 100644 --- a/exchangelib/services/update_folder.py +++ b/exchangelib/services/update_folder.py @@ -110,8 +110,6 @@ def _changes_elem(self, target_changes): if not target.account: target.account = self.account changes.append(self._change_elem(target=target, fieldnames=fieldnames)) - if not len(changes): - raise ValueError('List of changes must not be empty') return changes diff --git a/exchangelib/services/update_item.py b/exchangelib/services/update_item.py index eb78120e..1bea493d 100644 --- a/exchangelib/services/update_item.py +++ b/exchangelib/services/update_item.py @@ -47,8 +47,6 @@ def call(self, items, conflict_resolution, message_disposition, send_meeting_inv )) def _elem_to_obj(self, elem): - if elem is None: - return elem return Item.id_from_xml(elem) def _update_elems(self, target, fieldnames): diff --git a/exchangelib/settings.py b/exchangelib/settings.py index dcd1832f..a495c202 100644 --- a/exchangelib/settings.py +++ b/exchangelib/settings.py @@ -52,8 +52,6 @@ def to_xml(self, version): elem = create_element(f't:{self.REQUEST_ELEMENT_NAME}') for attr in ('state', 'external_audience'): value = getattr(self, attr) - if value is None: - continue f = self.get_field_by_fieldname(attr) set_xml_value(elem, f.to_xml(value, version=version)) if self.start or self.end: diff --git a/tests/test_account.py b/tests/test_account.py index 362c8bb3..c3754496 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -9,9 +9,9 @@ ErrorDelegateNoUser from exchangelib.folders import Calendar from exchangelib.items import Message -from exchangelib.properties import DelegateUser, UserId, DelegatePermissions +from exchangelib.properties import DelegateUser, UserId, DelegatePermissions, SendingAs from exchangelib.protocol import Protocol, FaultTolerance -from exchangelib.services import GetDelegate +from exchangelib.services import GetDelegate, GetMailTips from exchangelib.version import Version, EXCHANGE_2007_SP1 from .common import EWSTest @@ -113,6 +113,13 @@ def test_pickle(self): def test_mail_tips(self): # Test that mail tips work self.assertEqual(self.account.mail_tips.recipient_address, self.account.primary_smtp_address) + # recipients must not be empty + list(GetMailTips(protocol=self.account.protocol).call( + sending_as=SendingAs(email_address=self.account.primary_smtp_address), + recipients=[], + mail_tips_requested='All', + )) + #self.assertEqual(e.exception.args[0], '"recipients" must not be empty') def test_delegate(self): # The test server does not have any delegate info. Test that account.delegates works, and mock to test parsing diff --git a/tests/test_folder.py b/tests/test_folder.py index 9be59903..4e439565 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -8,7 +8,7 @@ SyncIssues, MyContacts, ToDoSearch, FolderCollection, DistinguishedFolderId, Files, \ DefaultFoldersChangeHistory, PassThroughSearchResults, SmsAndChatsSync, GraphAnalytics, Signal, \ PdpProfileV2Secured, VoiceMail, FolderQuerySet, SingleFolderQuerySet, SHALLOW, RootOfHierarchy, Companies, \ - OrganizationalContacts, PeopleCentricConversationBuddies, PublicFoldersRoot + OrganizationalContacts, PeopleCentricConversationBuddies, PublicFoldersRoot, NON_DELETABLE_FOLDERS from exchangelib.properties import Mailbox, InvalidField, EffectiveRights, PermissionSet, CalendarPermission, UserId from exchangelib.queryset import Q from exchangelib.services import GetFolder @@ -449,6 +449,9 @@ def test_move(self): f2 = Folder(parent=self.account.inbox, name=get_random_string(16)).save() f1_id, f1_changekey, f1_parent = f1.id, f1.changekey, f1.parent + with self.assertRaises(ValueError) as e: + f1.move(to_folder='XXX') # Must be folder instance + self.assertEqual(e.exception.args[0], "'to_folder' 'XXX' must be a Folder or FolderId instance") f1.move(f2) self.assertEqual(f1.id, f1_id) self.assertNotEqual(f1.changekey, f1_changekey) @@ -471,6 +474,12 @@ def test_generic_folder(self): f.save() f.delete() + def test_non_deletable_folders(self): + for f in self.account.root.walk(): + if f.__class__ not in NON_DELETABLE_FOLDERS: + continue + self.assertEqual(f.is_deletable, False) + def test_folder_query_set(self): # Create a folder hierarchy and test a folder queryset # diff --git a/tests/test_items/test_generic.py b/tests/test_items/test_generic.py index 18875818..ff4b212e 100644 --- a/tests/test_items/test_generic.py +++ b/tests/test_items/test_generic.py @@ -90,6 +90,9 @@ def test_invalid_direct_args(self): item.move(to_folder=self.test_folder) # Must be an existing item item = self.get_test_item() item.save() + with self.assertRaises(ValueError) as e: + item.move(to_folder='XXX') # Must be folder instance + self.assertEqual(e.exception.args[0], "'to_folder' 'XXX' must be a Folder or FolderId instance") item_id, changekey = item.id, item.changekey item.delete() item.id, item.changekey = item_id, changekey @@ -129,6 +132,9 @@ def test_invalid_kwargs_on_send(self): item.send() # Must have account on send item = self.get_test_item() item.save() + with self.assertRaises(ValueError) as e: + item.send(copy_to_folder='XXX', save_copy=True) # Invalid folder + self.assertEqual(e.exception.args[0], "'saved_item_folder' 'XXX' must be a Folder or FolderId instance") item_id, changekey = item.id, item.changekey item.delete() item.id, item.changekey = item_id, changekey diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index 4daca77f..aff75757 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -308,6 +308,9 @@ def test_push_message_parsing(self): def test_push_message_responses(self): # Test SendNotification ws = SendNotification(protocol=None) + with self.assertRaises(ValueError): + # Invalid status + ws.get_payload(status='XXX') self.assertEqual( PrettyXmlHandler.prettify_xml(ws.ok_payload()), b'''\ diff --git a/tests/test_protocol.py b/tests/test_protocol.py index fd872582..34df6d05 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -20,7 +20,8 @@ from exchangelib.properties import TimeZone, RoomList, FreeBusyView, AlternateId, ID_FORMATS, EWS_ID, \ SearchableMailbox, FailedMailbox, Mailbox, DLMailbox from exchangelib.protocol import Protocol, BaseProtocol, NoVerifyHTTPAdapter, FailFast, close_connections -from exchangelib.services import GetServerTimeZones, GetRoomLists, GetRooms, ResolveNames, GetSearchableMailboxes +from exchangelib.services import GetServerTimeZones, GetRoomLists, GetRooms, ResolveNames, GetSearchableMailboxes, \ + SetUserOofSettings from exchangelib.settings import OofSettings from exchangelib.transport import NOAUTH, NTLM from exchangelib.version import Build, Version @@ -475,6 +476,13 @@ def test_oof_settings(self): start=start, end=end, ) + with self.assertRaises(ValueError): + self.account.oof_settings = 'XXX' + with self.assertRaises(ValueError): + SetUserOofSettings(account=self.account).get( + oof_settings=oof, + mailbox='XXX', + ) self.account.oof_settings = oof # TODO: For some reason, disabling OOF does not always work. Don't assert because we want a stable test suite if self.account.oof_settings != oof: From ab1d3db3cb00923b5dd04e8a1653cdd7c302163f Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 2 Jan 2022 22:22:41 +0100 Subject: [PATCH 065/509] Improve code coverage --- .../services/get_user_configuration.py | 2 +- exchangelib/winzone.py | 2 - tests/test_attachments.py | 33 ++++++++++++ tests/test_folder.py | 14 ++++++ tests/test_protocol.py | 50 ++++++++++++++++++- 5 files changed, 96 insertions(+), 5 deletions(-) diff --git a/exchangelib/services/get_user_configuration.py b/exchangelib/services/get_user_configuration.py index 14ac74f9..ba091b27 100644 --- a/exchangelib/services/get_user_configuration.py +++ b/exchangelib/services/get_user_configuration.py @@ -7,7 +7,7 @@ XML_DATA = 'XmlData' BINARY_DATA = 'BinaryData' ALL = 'All' -PROPERTIES_CHOICES = {ID, DICTIONARY, XML_DATA, BINARY_DATA, ALL} +PROPERTIES_CHOICES = (ID, DICTIONARY, XML_DATA, BINARY_DATA, ALL) class GetUserConfiguration(EWSAccountService): diff --git a/exchangelib/winzone.py b/exchangelib/winzone.py index 01f833d9..7d6055d5 100644 --- a/exchangelib/winzone.py +++ b/exchangelib/winzone.py @@ -30,8 +30,6 @@ def generate_map(timeout=10): if e.get('territory') == DEFAULT_TERRITORY or location not in tz_map: # Prefer default territory. This is so MS_TIMEZONE_TO_IANA_MAP maps from MS timezone ID back to the # "preferred" region/location timezone name. - if not location: - raise ValueError('Expected location') tz_map[location] = e.get('other'), e.get('territory') return type_version, other_version, tz_map diff --git a/tests/test_attachments.py b/tests/test_attachments.py index 7329db69..02d9f25e 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -1,7 +1,10 @@ from exchangelib.attachments import FileAttachment, ItemAttachment, AttachmentId from exchangelib.errors import ErrorItemNotFound, ErrorInvalidIdMalformed +from exchangelib.fields import FieldPath from exchangelib.folders import Inbox from exchangelib.items import Item, Message +from exchangelib.properties import HTMLBody +from exchangelib.services import GetAttachment from exchangelib.util import chunkify from .test_items.test_basics import BaseItemTest @@ -106,6 +109,36 @@ def test_item_attachments(self): self.assertEqual(fresh_attachments[0].item.subject, attached_item1.subject) self.assertEqual(fresh_attachments[0].item.body, attached_item1.body) + def test_raw_service_call(self): + item = self.get_test_item(folder=self.test_folder) + attached_item1 = self.get_test_item(folder=self.test_folder) + attached_item1.body = HTMLBody('Hello HTML') + att1 = ItemAttachment(name='attachment1', item=attached_item1) + item.attach(att1) + item.save() + with self.assertRaises(ValueError): + # Bad body_type + GetAttachment(account=att1.parent_item.account).get( + items=[att1.attachment_id], include_mime_content=True, body_type='XXX', filter_html_content=None, + additional_fields=[], + ) + # Test body_type + attachment = GetAttachment(account=att1.parent_item.account).get( + items=[att1.attachment_id], include_mime_content=True, body_type='Text', filter_html_content=None, + additional_fields=[FieldPath(field=self.ITEM_CLASS.get_field_by_fieldname('body'))], + ) + self.assertEqual(attachment.item.body, 'Hello HTML\r\n') + # Test filter_html_content. I wonder what unsafe HTML is. + attachment = GetAttachment(account=att1.parent_item.account).get( + items=[att1.attachment_id], include_mime_content=False, body_type='HTML', filter_html_content=True, + additional_fields=[FieldPath(field=self.ITEM_CLASS.get_field_by_fieldname('body'))], + ) + self.assertEqual( + attachment.item.body, + '\r\n\r\n\r\n' + '\r\n\r\nHello HTML\r\n\r\n\r\n' + ) + def test_file_attachments(self): item = self.get_test_item(folder=self.test_folder) diff --git a/tests/test_folder.py b/tests/test_folder.py index 4e439565..7720340c 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -570,6 +570,14 @@ def test_user_configuration(self): # and must not start with "IPM.Configuration" name = get_random_string(16, spaces=False, special=False) + # Bad property + with self.assertRaises(ValueError) as e: + f.get_user_configuration(name=name, properties='XXX') + self.assertEqual( + e.exception.args[0], + "'properties' 'XXX' must be one of ('Id', 'Dictionary', 'XmlData', 'BinaryData', 'All')" + ) + # Should not exist yet with self.assertRaises(ErrorItemNotFound): f.get_user_configuration(name=name) @@ -613,6 +621,12 @@ def test_user_configuration(self): self.assertEqual(config.xml_data, b'baz') self.assertEqual(config.binary_data, b'YYY') + # Fetch again but only one property type + config = f.get_user_configuration(name=name, properties='XmlData') + self.assertEqual(config.dictionary, None) + self.assertEqual(config.xml_data, b'baz') + self.assertEqual(config.binary_data, None) + # Delete the config f.delete_user_configuration(name=name) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 34df6d05..940a439c 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -18,10 +18,10 @@ from exchangelib.errors import SessionPoolMinSizeReached, ErrorNameResolutionNoResults, ErrorAccessDenied, \ TransportError, SessionPoolMaxSizeReached, TimezoneDefinitionInvalidForYear from exchangelib.properties import TimeZone, RoomList, FreeBusyView, AlternateId, ID_FORMATS, EWS_ID, \ - SearchableMailbox, FailedMailbox, Mailbox, DLMailbox + SearchableMailbox, FailedMailbox, Mailbox, DLMailbox, ItemId from exchangelib.protocol import Protocol, BaseProtocol, NoVerifyHTTPAdapter, FailFast, close_connections from exchangelib.services import GetServerTimeZones, GetRoomLists, GetRooms, ResolveNames, GetSearchableMailboxes, \ - SetUserOofSettings + SetUserOofSettings, ExpandDL from exchangelib.settings import OofSettings from exchangelib.transport import NOAUTH, NTLM from exchangelib.version import Build, Version @@ -429,6 +429,42 @@ def test_expanddl(self): self.account.protocol.expand_dl( DLMailbox(email_address='non_existent_distro@example.com', mailbox_type='PublicDL') ) + xml = b'''\ + + + + + + + NoError + + + Foo Smith + foo@example.com + SMTP + Mailbox + + + Bar Smith + bar@example.com + SMTP + Mailbox + + + + + + +''' + self.assertListEqual( + list(ExpandDL(protocol=self.account.protocol).parse(xml)), + [ + Mailbox(name='Foo Smith', email_address='foo@example.com'), + Mailbox(name='Bar Smith', email_address='bar@example.com'), + ] + ) def test_oof_settings(self): # First, ensure a common starting point @@ -526,6 +562,16 @@ def test_convert_id(self): destination_format=fmt)) self.assertEqual(len(res), 1) self.assertEqual(res[0].format, fmt) + # Test bad format + with self.assertRaises(ValueError) as e: + self.account.protocol.convert_ids( + [AlternateId(id=i, format=EWS_ID, mailbox=self.account.primary_smtp_address)], + destination_format='XXX') + self.assertEqual(e.exception.args[0], f"'destination_format' 'XXX' must be one of {ID_FORMATS}") + # Test bad item type + with self.assertRaises(ValueError) as e: + list(self.account.protocol.convert_ids([ItemId(id=1)], destination_format='EwsId')) + self.assertIn('must be an instance of', e.exception.args[0]) def test_sessionpool(self): # First, empty the calendar From ea6e5b2a390dd4608848dc887743d4766c356e54 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 2 Jan 2022 23:59:41 +0100 Subject: [PATCH 066/509] Improve code coverage --- exchangelib/configuration.py | 2 +- exchangelib/folders/collections.py | 6 +- exchangelib/services/create_folder.py | 4 + exchangelib/services/delete_item.py | 2 - exchangelib/services/subscribe.py | 2 +- exchangelib/services/sync_folder_items.py | 4 +- exchangelib/services/update_item.py | 2 - tests/test_configuration.py | 3 + tests/test_folder.py | 8 +- tests/test_items/test_contacts.py | 39 +++++++- tests/test_items/test_generic.py | 110 ++++++++++++++++++++++ tests/test_items/test_sync.py | 28 +++++- 12 files changed, 192 insertions(+), 18 deletions(-) diff --git a/exchangelib/configuration.py b/exchangelib/configuration.py index f5b12c87..ff5487ff 100644 --- a/exchangelib/configuration.py +++ b/exchangelib/configuration.py @@ -57,7 +57,7 @@ def __init__(self, credentials=None, server=None, service_endpoint=None, auth_ty if not isinstance(retry_policy, RetryPolicy): raise ValueError(f"'retry_policy' {retry_policy!r} must be a RetryPolicy instance") if not isinstance(max_connections, (int, type(None))): - raise ValueError("'max_connections' must be an integer") + raise ValueError(f"'max_connections' {max_connections!r} must be an integer") self._credentials = credentials if server: self.service_endpoint = f'https://{server}/EWS/Exchange.asmx' diff --git a/exchangelib/folders/collections.py b/exchangelib/folders/collections.py index 842e3860..c0764140 100644 --- a/exchangelib/folders/collections.py +++ b/exchangelib/folders/collections.py @@ -393,7 +393,7 @@ def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): if not self.folders: log.debug('Folder list is empty') return - if not event_types: + if event_types is None: event_types = SubscribeToPull.EVENT_TYPES return SubscribeToPull(account=self.account).get( folders=self.folders, event_types=event_types, watermark=watermark, timeout=timeout, @@ -404,7 +404,7 @@ def subscribe_to_push(self, callback_url, event_types=None, watermark=None, stat if not self.folders: log.debug('Folder list is empty') return - if not event_types: + if event_types is None: event_types = SubscribeToPush.EVENT_TYPES return SubscribeToPush(account=self.account).get( folders=self.folders, event_types=event_types, watermark=watermark, status_frequency=status_frequency, @@ -416,7 +416,7 @@ def subscribe_to_streaming(self, event_types=None): if not self.folders: log.debug('Folder list is empty') return - if not event_types: + if event_types is None: event_types = SubscribeToStreaming.EVENT_TYPES return SubscribeToStreaming(account=self.account).get(folders=self.folders, event_types=event_types) diff --git a/exchangelib/services/create_folder.py b/exchangelib/services/create_folder.py index 0fdadfb3..1a17e2e1 100644 --- a/exchangelib/services/create_folder.py +++ b/exchangelib/services/create_folder.py @@ -1,4 +1,5 @@ from .common import EWSAccountService, parse_folder_elem, folder_ids_element +from ..errors import ErrorFolderExists from ..util import create_element, MNS @@ -7,6 +8,9 @@ class CreateFolder(EWSAccountService): SERVICE_NAME = 'CreateFolder' element_container_name = f'{{{MNS}}}Folders' + ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + ( + ErrorFolderExists, + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/exchangelib/services/delete_item.py b/exchangelib/services/delete_item.py index 8e05e0fc..d46626fc 100644 --- a/exchangelib/services/delete_item.py +++ b/exchangelib/services/delete_item.py @@ -23,8 +23,6 @@ def call(self, items, delete_type, send_meeting_cancellations, affected_task_occ if affected_task_occurrences not in AFFECTED_TASK_OCCURRENCES_CHOICES: raise ValueError(f"'affected_task_occurrences' {affected_task_occurrences} must be one of " f"{AFFECTED_TASK_OCCURRENCES_CHOICES}") - if suppress_read_receipts not in (True, False): - raise ValueError(f"'suppress_read_receipts' {suppress_read_receipts} must be True or False") return self._chunked_get_elements( self.get_payload, items=items, diff --git a/exchangelib/services/subscribe.py b/exchangelib/services/subscribe.py index 9a2e86a9..ad43fd20 100644 --- a/exchangelib/services/subscribe.py +++ b/exchangelib/services/subscribe.py @@ -45,7 +45,7 @@ def _partial_payload(self, folders, event_types): for event_type in event_types: add_xml_child(event_types_elem, 't:EventType', event_type) if not len(event_types_elem): - raise ValueError('"event_types" must not be empty') + raise ValueError("'event_types' must not be empty") request_elem.append(event_types_elem) return request_elem diff --git a/exchangelib/services/sync_folder_items.py b/exchangelib/services/sync_folder_items.py index 9b35c268..6595d19d 100644 --- a/exchangelib/services/sync_folder_items.py +++ b/exchangelib/services/sync_folder_items.py @@ -11,10 +11,10 @@ class SyncFolderItems(SyncFolder): """ SERVICE_NAME = 'SyncFolderItems' - SYNC_SCOPES = { + SYNC_SCOPES = ( 'NormalItems', 'NormalAndAssociatedItems', - } + ) # Extra change type READ_FLAG_CHANGE = 'read_flag_change' CHANGE_TYPES = SyncFolder.CHANGE_TYPES + (READ_FLAG_CHANGE,) diff --git a/exchangelib/services/update_item.py b/exchangelib/services/update_item.py index 1bea493d..78b75ba5 100644 --- a/exchangelib/services/update_item.py +++ b/exchangelib/services/update_item.py @@ -33,8 +33,6 @@ def call(self, items, conflict_resolution, message_disposition, send_meeting_inv f"'send_meeting_invitations_or_cancellations' {send_meeting_invitations_or_cancellations!r} must be " f"one of {SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES}" ) - if suppress_read_receipts not in (True, False): - raise ValueError(f"'suppress_read_receipts' {suppress_read_receipts!r} must be True or False") if message_disposition == SEND_ONLY: raise ValueError('Cannot send-only existing objects. Use SendItem service instead') return self._elems_to_objs(self._chunked_get_elements( diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 6350594d..b38d717d 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -33,6 +33,9 @@ def test_init(self): with self.assertRaises(ValueError) as e: Configuration(retry_policy='foo') self.assertEqual(e.exception.args[0], "'retry_policy' 'foo' must be a RetryPolicy instance") + with self.assertRaises(ValueError) as e: + Configuration(max_connections='foo') + self.assertEqual(e.exception.args[0], "'max_connections' 'foo' must be an integer") def test_magic(self): config = Configuration( diff --git a/tests/test_folder.py b/tests/test_folder.py index 7720340c..7da2ee86 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -1,5 +1,5 @@ from exchangelib.errors import ErrorDeleteDistinguishedFolder, ErrorObjectTypeChanged, DoesNotExist, \ - MultipleObjectsReturned, ErrorItemSave, ErrorItemNotFound + MultipleObjectsReturned, ErrorItemSave, ErrorItemNotFound, ErrorFolderExists from exchangelib.extended_properties import ExtendedProperty from exchangelib.items import Message from exchangelib.folders import Calendar, DeletedItems, Drafts, Inbox, Outbox, SentItems, JunkEmail, Messages, Tasks, \ @@ -402,8 +402,10 @@ class FolderSize(ExtendedProperty): self.account.root.deregister(FolderSize) def test_create_update_empty_delete(self): - f = Messages(parent=self.account.inbox, name=get_random_string(16)) - f.save() + name = get_random_string(16) + f = Messages(parent=self.account.inbox, name=name).save() + with self.assertRaises(ErrorFolderExists): + Messages(parent=self.account.inbox, name=name).save() self.assertIsNotNone(f.id) self.assertIsNotNone(f.changekey) diff --git a/tests/test_items/test_contacts.py b/tests/test_items/test_contacts.py index 5ba11178..c358cc94 100644 --- a/tests/test_items/test_contacts.py +++ b/tests/test_items/test_contacts.py @@ -9,8 +9,8 @@ from exchangelib.indexed_properties import EmailAddress, PhysicalAddress, PhoneNumber from exchangelib.items import Contact, DistributionList, Persona from exchangelib.properties import Mailbox, Member, Attribution, SourceId, FolderId, StringAttributedValue, \ - PhoneNumberAttributedValue, PersonaPhoneNumberTypeValue -from exchangelib.services import GetPersona + PhoneNumberAttributedValue, PersonaPhoneNumberTypeValue, EmailAddress as EmailAddressProp +from exchangelib.services import GetPersona, FindPeople from ..common import get_random_string, get_random_email from .test_basics import CommonItemTest @@ -126,6 +126,41 @@ def test_find_people(self): )), 0 ) + # Test with a querystring filter + self.assertGreaterEqual(len(list(self.test_folder.people().filter('DisplayName:john'))), 0) + xml = b'''\ + + + + + NoError + + + + Foo B. Smith + + Foo Smith + foo@example.com + SMTP + + 2147483647 + + + 1 + + +''' + self.assertListEqual( + list(FindPeople(account=self.account).parse(xml)), + [Persona( + id='AAAA=', + display_name='Foo B. Smith', + email_address=EmailAddressProp(name='Foo Smith', email_address='foo@example.com'), + relevance_score='2147483647', + )] + ) def test_get_persona(self): xml = b'''\ diff --git a/tests/test_items/test_generic.py b/tests/test_items/test_generic.py index ff4b212e..9515da62 100644 --- a/tests/test_items/test_generic.py +++ b/tests/test_items/test_generic.py @@ -12,6 +12,7 @@ from exchangelib.items import CalendarItem, Message from exchangelib.queryset import QuerySet from exchangelib.restriction import Restriction, Q +from exchangelib.services import CreateItem, UpdateItem, DeleteItem from exchangelib.version import Build, EXCHANGE_2007, EXCHANGE_2013 from ..common import get_random_string, mock_version @@ -144,6 +145,115 @@ def test_invalid_kwargs_on_send(self): with self.assertRaises(AttributeError): item.send(copy_to_folder=self.account.trash, save_copy=False) # Inconsistent args + def test_invalid_createitem_args(self): + with self.assertRaises(ValueError) as e: + CreateItem(account=self.account).call( + items=[], + folder=None, + message_disposition='XXX', + send_meeting_invitations='SendToNone', + ) + self.assertEqual( + e.exception.args[0], + "'message_disposition' 'XXX' must be one of ('SaveOnly', 'SendOnly', 'SendAndSaveCopy')" + ) + with self.assertRaises(ValueError) as e: + CreateItem(account=self.account).call( + items=[], + folder=None, + message_disposition='SaveOnly', + send_meeting_invitations='XXX', + ) + self.assertEqual( + e.exception.args[0], + "'send_meeting_invitations' 'XXX' must be one of ('SendToNone', 'SendOnlyToAll', 'SendToAllAndSaveCopy')" + ) + with self.assertRaises(ValueError) as e: + CreateItem(account=self.account).call( + items=[], + folder='XXX', + message_disposition='SaveOnly', + send_meeting_invitations='SendToNone', + ) + self.assertEqual(e.exception.args[0], "'folder' 'XXX' must be a Folder or FolderId instance") + + def test_invalid_deleteitem_args(self): + with self.assertRaises(ValueError) as e: + DeleteItem(account=self.account).call( + items=[], + delete_type='XXX', + send_meeting_cancellations='SendToNone', + affected_task_occurrences='AllOccurrences', + suppress_read_receipts=True, + ) + self.assertEqual( + e.exception.args[0], + "'delete_type' XXX must be one of ('HardDelete', 'SoftDelete', 'MoveToDeletedItems')" + ) + with self.assertRaises(ValueError) as e: + DeleteItem(account=self.account).call( + items=[], + delete_type='HardDelete', + send_meeting_cancellations='XXX', + affected_task_occurrences='AllOccurrences', + suppress_read_receipts=True, + ) + self.assertEqual( + e.exception.args[0], + "'send_meeting_cancellations' XXX must be one of ('SendToNone', 'SendOnlyToAll', 'SendToAllAndSaveCopy')" + ) + with self.assertRaises(ValueError) as e: + DeleteItem(account=self.account).call( + items=[], + delete_type='HardDelete', + send_meeting_cancellations='SendToNone', + affected_task_occurrences='XXX', + suppress_read_receipts=True, + ) + self.assertEqual( + e.exception.args[0], + "'affected_task_occurrences' XXX must be one of ('AllOccurrences', 'SpecifiedOccurrenceOnly')" + ) + + def test_invalid_updateitem_args(self): + with self.assertRaises(ValueError) as e: + UpdateItem(account=self.account).call( + items=[], + conflict_resolution='XXX', + message_disposition='SaveOnly', + send_meeting_invitations_or_cancellations='SendToNone', + suppress_read_receipts=True, + ) + self.assertEqual( + e.exception.args[0], + "'conflict_resolution' 'XXX' must be one of ('NeverOverwrite', 'AutoResolve', 'AlwaysOverwrite')" + ) + with self.assertRaises(ValueError) as e: + UpdateItem(account=self.account).call( + items=[], + conflict_resolution='NeverOverwrite', + message_disposition='XXX', + send_meeting_invitations_or_cancellations='SendToNone', + suppress_read_receipts=True, + ) + self.assertEqual( + e.exception.args[0], + "'message_disposition' 'XXX' must be one of ('SaveOnly', 'SendOnly', 'SendAndSaveCopy')" + ) + with self.assertRaises(ValueError) as e: + UpdateItem(account=self.account).call( + items=[], + conflict_resolution='NeverOverwrite', + message_disposition='SaveOnly', + send_meeting_invitations_or_cancellations='XXX', + suppress_read_receipts=True, + ) + self.assertEqual( + e.exception.args[0], + "'send_meeting_invitations_or_cancellations' 'XXX' must be one of " + "('SendToNone', 'SendOnlyToAll', 'SendOnlyToChanged', 'SendToAllAndSaveCopy', 'SendToChangedAndSaveCopy')" + ) + def test_unsupported_fields(self): # Create a field that is not supported by any current versions. Test that we fail when using this field class UnsupportedProp(ExtendedProperty): diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index aff75757..8ec3d61a 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -3,8 +3,8 @@ from exchangelib.errors import ErrorInvalidSubscription, ErrorSubscriptionNotFound from exchangelib.folders import Inbox from exchangelib.items import Message -from exchangelib.properties import StatusEvent, CreatedEvent, ModifiedEvent, DeletedEvent, Notification -from exchangelib.services import SendNotification +from exchangelib.properties import StatusEvent, CreatedEvent, ModifiedEvent, DeletedEvent, Notification, ItemId +from exchangelib.services import SendNotification, SubscribeToPull from exchangelib.util import PrettyXmlHandler from .test_basics import BaseItemTest @@ -16,6 +16,17 @@ class SyncTest(BaseItemTest): FOLDER_CLASS = Inbox ITEM_CLASS = Message + def test_subscribe_invalid_kwargs(self): + with self.assertRaises(ValueError) as e: + self.account.inbox.subscribe_to_pull(event_types=['XXX']) + self.assertEqual( + e.exception.args[0], + f"'event_types' values must consist of values in {SubscribeToPull.EVENT_TYPES}" + ) + with self.assertRaises(ValueError) as e: + self.account.inbox.subscribe_to_pull(event_types=[]) + self.assertEqual(e.exception.args[0], "'event_types' must not be empty") + def test_pull_subscribe(self): self.account.affinity_cookie = None with self.account.inbox.pull_subscription() as (subscription_id, watermark): @@ -102,10 +113,23 @@ def test_sync_folder_hierarchy(self): def test_sync_folder_items(self): test_folder = self.get_test_folder().save() + with self.assertRaises(ValueError) as e: + list(test_folder.sync_items(max_changes_returned=-1)) + self.assertEqual(e.exception.args[0], "'max_changes_returned' -1 must be a positive integer") + with self.assertRaises(ValueError) as e: + list(test_folder.sync_items(sync_scope='XXX')) + self.assertEqual( + e.exception.args[0], + "'sync_scope' 'XXX' must be one of ('NormalItems', 'NormalAndAssociatedItems')" + ) + # Test that item_sync_state is set after calling sync_hierarchy self.assertIsNone(test_folder.item_sync_state) list(test_folder.sync_items()) self.assertIsNotNone(test_folder.item_sync_state) + # Test non-default values + list(test_folder.sync_items(sync_scope='NormalItems')) + list(test_folder.sync_items(ignore=[ItemId(id='AAA=')])) # Test that we see a create event i1 = self.get_test_item(folder=test_folder).save() From 6cf4be1553122849c727537ea937a9060936c391 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 3 Jan 2022 00:21:01 +0100 Subject: [PATCH 067/509] Improve code coverage --- exchangelib/protocol.py | 7 ++++--- exchangelib/services/resolve_names.py | 10 ++++----- tests/test_protocol.py | 29 ++++++++++++++++++++++++--- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 146bd6c9..6d4404c0 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -540,10 +540,11 @@ def get_roomlists(self): def get_rooms(self, roomlist): return GetRooms(protocol=self).call(room_list=RoomList(email_address=roomlist)) - def resolve_names(self, names, return_full_contact_data=False, search_scope=None, shape=None): + def resolve_names(self, names, parent_folders=None, return_full_contact_data=False, search_scope=None, shape=None): """Resolve accounts on the server using partial account data, e.g. an email address or initials. :param names: A list of identifiers to query + :param parent_folders: A list of contact folders to search in :param return_full_contact_data: If True, returns full contact data (Default value = False) :param search_scope: The scope to perform the search. Must be one of SEARCH_SCOPE_CHOICES (Default value = None) :param shape: (Default value = None) @@ -551,8 +552,8 @@ def resolve_names(self, names, return_full_contact_data=False, search_scope=None :return: A list of Mailbox items or, if return_full_contact_data is True, tuples of (Mailbox, Contact) items """ return list(ResolveNames(protocol=self).call( - unresolved_entries=names, return_full_contact_data=return_full_contact_data, search_scope=search_scope, - contact_data_shape=shape, + unresolved_entries=names, parent_folders=parent_folders, return_full_contact_data=return_full_contact_data, + search_scope=search_scope, contact_data_shape=shape, )) def expand_dl(self, distribution_list): diff --git a/exchangelib/services/resolve_names.py b/exchangelib/services/resolve_names.py index 19f4d2b5..85fd186f 100644 --- a/exchangelib/services/resolve_names.py +++ b/exchangelib/services/resolve_names.py @@ -29,14 +29,14 @@ def __init__(self, *args, **kwargs): def call(self, unresolved_entries, parent_folders=None, return_full_contact_data=False, search_scope=None, contact_data_shape=None): if self.chunk_size > 100: - raise AttributeError( - 'Chunk size %s is too high. %s supports returning at most 100 candidates for a lookup', - self.chunk_size, self.SERVICE_NAME + raise ValueError( + f'Chunk size {self.chunk_size} is too high. {self.SERVICE_NAME} supports returning at most 100 ' + f'candidates for a lookup', ) if search_scope and search_scope not in SEARCH_SCOPE_CHOICES: - raise ValueError(f"'search_scope' {search_scope} must be one if {SEARCH_SCOPE_CHOICES}") + raise ValueError(f"'search_scope' {search_scope} must be one of {SEARCH_SCOPE_CHOICES}") if contact_data_shape and contact_data_shape not in SHAPE_CHOICES: - raise ValueError(f"'shape' {contact_data_shape} must be one if {SHAPE_CHOICES}") + raise ValueError(f"'shape' {contact_data_shape} must be one of {SHAPE_CHOICES}") self.return_full_contact_data = return_full_contact_data return self._elems_to_objs(self._chunked_get_elements( self.get_payload, diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 940a439c..18b3975d 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -14,7 +14,7 @@ from exchangelib.credentials import Credentials from exchangelib.configuration import Configuration -from exchangelib.items import CalendarItem +from exchangelib.items import CalendarItem, SEARCH_SCOPE_CHOICES from exchangelib.errors import SessionPoolMinSizeReached, ErrorNameResolutionNoResults, ErrorAccessDenied, \ TransportError, SessionPoolMaxSizeReached, TimezoneDefinitionInvalidForYear from exchangelib.properties import TimeZone, RoomList, FreeBusyView, AlternateId, ID_FORMATS, EWS_ID, \ @@ -296,14 +296,37 @@ def test_get_rooms_parsing(self): ) def test_resolvenames(self): - with self.assertRaises(ValueError): + with self.assertRaises(ValueError) as e: self.account.protocol.resolve_names(names=[], search_scope='XXX') - with self.assertRaises(ValueError): + self.assertEqual( + e.exception.args[0], + f"'search_scope' XXX must be one of {SEARCH_SCOPE_CHOICES}" + ) + with self.assertRaises(ValueError) as e: self.account.protocol.resolve_names(names=[], shape='XXX') + self.assertEqual(e.exception.args[0], "'shape' XXX must be one of ('IdOnly', 'Default', 'AllProperties')") + with self.assertRaises(ValueError) as e: + ResolveNames(protocol=self.account.protocol, chunk_size=500).call(unresolved_entries=None) + self.assertEqual( + e.exception.args[0], + "Chunk size 500 is too high. ResolveNames supports returning at most 100 candidates for a lookup" + ) self.assertGreaterEqual( self.account.protocol.resolve_names(names=['xxx@example.com']), [] ) + self.assertGreaterEqual( + self.account.protocol.resolve_names(names=['xxx@example.com'], search_scope='ActiveDirectoryContacts'), + [] + ) + self.assertGreaterEqual( + self.account.protocol.resolve_names(names=['xxx@example.com'], shape='AllProperties'), + [] + ) + self.assertGreaterEqual( + self.account.protocol.resolve_names(names=['xxx@example.com'], parent_folders=[self.account.contacts]), + [] + ) self.assertEqual( self.account.protocol.resolve_names(names=[self.account.primary_smtp_address]), [Mailbox(email_address=self.account.primary_smtp_address)] From 8543c7a67b794ff0191b1db3764ce5550f7d323b Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 3 Jan 2022 00:27:44 +0100 Subject: [PATCH 068/509] Improve code coverage --- exchangelib/services/common.py | 6 +++--- exchangelib/services/get_streaming_events.py | 2 +- exchangelib/services/update_folder.py | 2 +- tests/test_account.py | 1 - tests/test_items/test_sync.py | 10 ++++++++++ 5 files changed, 15 insertions(+), 6 deletions(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index f5ce1ebd..3df8bb48 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -875,7 +875,7 @@ def folder_ids_element(folders, version, tag='m:FolderIds'): for folder in folders: set_xml_value(folder_ids, to_item_id(folder, FolderId), version=version) if not len(folder_ids): - raise ValueError('"folders" must not be empty') + raise ValueError("'folders' must not be empty") return folder_ids @@ -884,7 +884,7 @@ def item_ids_element(items, version, tag='m:ItemIds'): for item in items: set_xml_value(item_ids, to_item_id(item, ItemId), version=version) if not len(item_ids): - raise ValueError('"items" must not be empty') + raise ValueError("'items' must not be empty") return item_ids @@ -894,7 +894,7 @@ def attachment_ids_element(items, version, tag='m:AttachmentIds'): attachment_id = item if isinstance(item, AttachmentId) else AttachmentId(id=item) set_xml_value(attachment_ids, attachment_id, version=version) if not len(attachment_ids): - raise ValueError('"items" must not be empty') + raise ValueError("'items' must not be empty") return attachment_ids diff --git a/exchangelib/services/get_streaming_events.py b/exchangelib/services/get_streaming_events.py index 6f30622d..be529988 100644 --- a/exchangelib/services/get_streaming_events.py +++ b/exchangelib/services/get_streaming_events.py @@ -89,7 +89,7 @@ def get_payload(self, subscription_ids, connection_timeout): for subscription_id in subscription_ids: add_xml_child(subscriptions_elem, 't:SubscriptionId', subscription_id) if not len(subscriptions_elem): - raise ValueError('"subscription_ids" must not be empty') + raise ValueError("'subscription_ids' must not be empty") payload.append(subscriptions_elem) add_xml_child(payload, 'm:ConnectionTimeout', connection_timeout) diff --git a/exchangelib/services/update_folder.py b/exchangelib/services/update_folder.py index 95bd8c90..6ec12101 100644 --- a/exchangelib/services/update_folder.py +++ b/exchangelib/services/update_folder.py @@ -92,7 +92,7 @@ def _update_elems(self, target, fieldnames): def _change_elem(self, target, fieldnames): if not fieldnames: - raise ValueError('"fieldnames" must not be empty') + raise ValueError("'fieldnames' must not be empty") change = create_element(self.CHANGE_ELEMENT_NAME) set_xml_value(change, self._target_elem(target), version=self.account.version) updates = create_element('t:Updates') diff --git a/tests/test_account.py b/tests/test_account.py index c3754496..92df79e3 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -119,7 +119,6 @@ def test_mail_tips(self): recipients=[], mail_tips_requested='All', )) - #self.assertEqual(e.exception.args[0], '"recipients" must not be empty') def test_delegate(self): # The test server does not have any delegate info. Test that account.delegates works, and mock to test parsing diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index 8ec3d61a..de3b770f 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -278,6 +278,16 @@ def test_streaming_invalid_subscription(self): # Test that we can get the failing subscription IDs from the response message test_folder = self.account.drafts + # Test with empty list of subscription + with self.assertRaises(ValueError) as e: + list(test_folder.get_streaming_events([], connection_timeout=1, max_notifications_returned=1)) + self.assertEqual(e.exception.args[0], "'subscription_ids' must not be empty") + + # Test with bad connection_timeout + with self.assertRaises(ValueError) as e: + list(test_folder.get_streaming_events('AAA-', connection_timeout=-1, max_notifications_returned=1)) + self.assertEqual(e.exception.args[0], "'connection_timeout' must be a positive integer") + # Test a single bad notification with self.assertRaises(ErrorInvalidSubscription) as e: list(test_folder.get_streaming_events('AAA-', connection_timeout=1, max_notifications_returned=1)) From 4fc9ff5af681723b82f630bc8f2d6626d49f98c8 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 3 Jan 2022 00:44:53 +0100 Subject: [PATCH 069/509] Remove unreachable code --- exchangelib/services/common.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 3df8bb48..70b2a942 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -874,8 +874,6 @@ def folder_ids_element(folders, version, tag='m:FolderIds'): folder_ids = create_element(tag) for folder in folders: set_xml_value(folder_ids, to_item_id(folder, FolderId), version=version) - if not len(folder_ids): - raise ValueError("'folders' must not be empty") return folder_ids @@ -883,8 +881,6 @@ def item_ids_element(items, version, tag='m:ItemIds'): item_ids = create_element(tag) for item in items: set_xml_value(item_ids, to_item_id(item, ItemId), version=version) - if not len(item_ids): - raise ValueError("'items' must not be empty") return item_ids @@ -893,26 +889,21 @@ def attachment_ids_element(items, version, tag='m:AttachmentIds'): for item in items: attachment_id = item if isinstance(item, AttachmentId) else AttachmentId(id=item) set_xml_value(attachment_ids, attachment_id, version=version) - if not len(attachment_ids): - raise ValueError("'items' must not be empty") return attachment_ids def parse_folder_elem(elem, folder, account): - if isinstance(elem, Exception): - return elem if isinstance(folder, RootOfHierarchy): f = folder.from_xml(elem=elem, account=folder.account) elif isinstance(folder, Folder): f = folder.from_xml_with_root(elem=elem, root=folder.root) elif isinstance(folder, DistinguishedFolderId): # We don't know the root, so assume account.root. - folder_cls = None for cls in account.root.WELLKNOWN_FOLDERS: if cls.DISTINGUISHED_FOLDER_ID == folder.id: folder_cls = cls break - if not folder_cls: + else: raise ValueError(f'Unknown distinguished folder ID: {folder.id}') f = folder_cls.from_xml_with_root(elem=elem, root=account.root) else: From 33e05eb05e8ebdfa6584d7300de3cfff3539bc25 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 3 Jan 2022 23:53:33 +0100 Subject: [PATCH 070/509] Improve code coverage --- exchangelib/errors.py | 14 +------- exchangelib/folders/collections.py | 1 + exchangelib/protocol.py | 2 +- exchangelib/queryset.py | 8 ----- exchangelib/services/find_people.py | 11 +++--- .../services/get_searchable_mailboxes.py | 11 ++---- exchangelib/services/update_folder.py | 7 ++-- exchangelib/version.py | 18 +++++----- tests/test_items/test_sync.py | 12 +++++++ tests/test_protocol.py | 12 ++++--- tests/test_util.py | 8 +++++ tests/test_version.py | 35 +++++++++++++++++++ 12 files changed, 87 insertions(+), 52 deletions(-) diff --git a/exchangelib/errors.py b/exchangelib/errors.py index 18b3bbd9..3fb42b5c 100644 --- a/exchangelib/errors.py +++ b/exchangelib/errors.py @@ -87,22 +87,10 @@ class AutoDiscoverCircularRedirect(AutoDiscoverError): pass -class AutoDiscoverRedirect(AutoDiscoverError): - def __init__(self, redirect_email): - self.redirect_email = redirect_email - super().__init__(str(self)) - - def __str__(self): - return f'AutoDiscover redirects to {self.redirect_email}' - - class NaiveDateTimeNotAllowed(ValueError): def __init__(self, local_dt): super().__init__() - from .ewsdatetime import EWSDateTime - if not isinstance(local_dt, EWSDateTime): - raise ValueError(f"'local_dt' value {local_dt!r} must be an EWSDateTime") - self.local_dt = local_dt + self.local_dt = local_dt # An EWSDateTime instance class UnknownTimeZone(EWSError): diff --git a/exchangelib/folders/collections.py b/exchangelib/folders/collections.py index c0764140..b4fec6f5 100644 --- a/exchangelib/folders/collections.py +++ b/exchangelib/folders/collections.py @@ -516,6 +516,7 @@ def __init__(self, folder, **subscription_kwargs): self.subscription_kwargs = subscription_kwargs self.subscription_id = None + @abc.abstractmethod def __enter__(self): pass diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 6d4404c0..b7ab2a63 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -573,7 +573,7 @@ def get_searchable_mailboxes(self, search_filter=None, expand_group_membership=F This method is only available to users who have been assigned the Discovery Management RBAC role. See https://docs.microsoft.com/en-us/exchange/permissions-exo/permissions-exo - :param search_filter: Is set, must be a single email alias (Default value = None) + :param search_filter: If set, must be a single email alias (Default value = None) :param expand_group_membership: If True, returned distribution lists are expanded (Default value = False) :return: a list of SearchableMailbox, FailedMailbox or Exception instances diff --git a/exchangelib/queryset.py b/exchangelib/queryset.py index 9b685979..cacfb3d8 100644 --- a/exchangelib/queryset.py +++ b/exchangelib/queryset.py @@ -87,14 +87,6 @@ def _copy_self(self): # items = list(qs) # new_qs = qs.exclude(bar='baz') # This should work, and should fetch from the server # - if not isinstance(self.q, Q): - raise ValueError(f"self.q value {self.q!r} must be None or a Q instance") - if not isinstance(self.only_fields, (type(None), tuple)): - raise ValueError(f"self.only_fields value {self.only_fields!r} must be None or a tuple") - if not isinstance(self.order_fields, (type(None), tuple)): - raise ValueError(f"self.order_fields value {self.order_fields!r} must be None or a tuple") - if self.return_format not in self.RETURN_TYPES: - raise ValueError(f"self.return_value {self.return_format!r} must be one of {self.RETURN_TYPES}") # Only mutable objects need to be deepcopied. Folder should be the same object new_qs = self.__class__(self.folder_collection, request_type=self.request_type) new_qs.q = deepcopy(self.q) diff --git a/exchangelib/services/find_people.py b/exchangelib/services/find_people.py index cf38a0ac..a753f02a 100644 --- a/exchangelib/services/find_people.py +++ b/exchangelib/services/find_people.py @@ -22,7 +22,7 @@ def __init__(self, *args, **kwargs): self.shape = None def call(self, folder, additional_fields, restriction, order_fields, shape, query_string, depth, max_items, offset): - """Find items in an account. + """Find items in an account. This service can only be called on a single folder. :param folder: the Folder object to query :param additional_fields: the extra fields that should be returned with the item, as FieldPath objects @@ -41,7 +41,7 @@ def call(self, folder, additional_fields, restriction, order_fields, shape, quer return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - folders=[folder], # We can only query one folder, so there will only be one element in response + folders=[folder], # We just need the list to satisfy self._paged_call() **dict( additional_fields=additional_fields, restriction=restriction, @@ -61,10 +61,7 @@ def _elem_to_obj(self, elem): def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, offset=0): - folders = list(folders) - if len(folders) != 1: - raise ValueError(f'{self.SERVICE_NAME} can only query one folder') - folder = folders[0] + # We actually only support a single folder, but self._paged_call() sends us a list payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) payload.append(shape_element( tag='m:PersonaShape', shape=shape, additional_fields=additional_fields, version=self.account.version @@ -81,7 +78,7 @@ def get_payload(self, folders, additional_fields, restriction, order_fields, que order_fields, version=self.account.version )) - payload.append(folder_ids_element(folders=[folder], version=self.account.version, tag='m:ParentFolderId')) + payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:ParentFolderId')) if query_string: payload.append(query_string.to_xml(version=self.account.version)) return payload diff --git a/exchangelib/services/get_searchable_mailboxes.py b/exchangelib/services/get_searchable_mailboxes.py index 55f6c232..5b24a511 100644 --- a/exchangelib/services/get_searchable_mailboxes.py +++ b/exchangelib/services/get_searchable_mailboxes.py @@ -37,13 +37,8 @@ def _get_elements_in_response(self, response): for msg in response: for container_name in (self.element_container_name, self.failed_mailboxes_container_name): try: - container_or_exc = self._get_element_container(message=msg, name=container_name) + container = self._get_element_container(message=msg, name=container_name) except MalformedResponseError: - # Responses may contain no failed mailboxes. _get_element_container() does not accept this. - if container_name == self.failed_mailboxes_container_name: - continue - raise - if isinstance(container_or_exc, (bool, Exception)): - yield container_or_exc + # Responses may contain no mailboxes of either kind. _get_element_container() does not accept this. continue - yield from self._get_elements_in_container(container=container_or_exc) + yield from self._get_elements_in_container(container=container) diff --git a/exchangelib/services/update_folder.py b/exchangelib/services/update_folder.py index 6ec12101..dcaf82c1 100644 --- a/exchangelib/services/update_folder.py +++ b/exchangelib/services/update_folder.py @@ -1,10 +1,12 @@ +import abc + from .common import EWSAccountService, parse_folder_elem, to_item_id from ..fields import FieldPath, IndexedField from ..properties import FolderId from ..util import create_element, set_xml_value, MNS -class BaseUpdateService(EWSAccountService): +class BaseUpdateService(EWSAccountService, metaclass=abc.ABCMeta): """Base class for UpdateFolder and UpdateItem""" SET_FIELD_ELEMENT_NAME = None DELETE_FIELD_ELEMENT_NAME = None @@ -101,8 +103,9 @@ def _change_elem(self, target, fieldnames): change.append(updates) return change + @abc.abstractmethod def _target_elem(self, target): - raise NotImplementedError() + pass def _changes_elem(self, target_changes): changes = create_element(self.CHANGES_ELEMENT_NAME) diff --git a/exchangelib/version.py b/exchangelib/version.py index 2593b7e3..c6dad149 100644 --- a/exchangelib/version.py +++ b/exchangelib/version.py @@ -66,13 +66,13 @@ class Build: def __init__(self, major_version, minor_version, major_build=0, minor_build=0): if not isinstance(major_version, int): - raise ValueError("'major_version' must be an integer") + raise ValueError(f"'major_version' {major_version!r} must be an integer") if not isinstance(minor_version, int): - raise ValueError("'minor_version' must be an integer") + raise ValueError(f"'minor_version' {minor_version!r} must be an integer") if not isinstance(major_build, int): - raise ValueError("'major_build' must be an integer") + raise ValueError(f"'major_build' {major_build!r} must be an integer") if not isinstance(minor_build, int): - raise ValueError("'minor_build' must be an integer") + raise ValueError(f"'minor_build' {minor_build!r} must be an integer") self.major_version = major_version self.minor_version = minor_version self.major_build = major_build @@ -188,15 +188,17 @@ class Version: __slots__ = 'build', 'api_version' def __init__(self, build, api_version=None): - if not isinstance(build, (Build, type(None))): - raise ValueError("'build' must be a Build instance") - self.build = build if api_version is None: + if not isinstance(build, Build): + raise ValueError(f"'build' {build!r} must be a Build instance") self.api_version = build.api_version() else: + if not isinstance(build, (Build, type(None))): + raise ValueError(f"'build' {build!r} must be a Build instance") if not isinstance(api_version, str): - raise ValueError("'api_version' must be a string") + raise ValueError(f"'api_version' {api_version!r} must be a string") self.api_version = api_version + self.build = build @property def fullname(self): diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index de3b770f..18c69b05 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -32,6 +32,10 @@ def test_pull_subscribe(self): with self.account.inbox.pull_subscription() as (subscription_id, watermark): self.assertIsNotNone(subscription_id) self.assertIsNotNone(watermark) + # Test with watermark + with self.account.inbox.pull_subscription(watermark=watermark) as (subscription_id, watermark): + self.assertIsNotNone(subscription_id) + self.assertIsNotNone(watermark) # Context manager already unsubscribed us with self.assertRaises(ErrorSubscriptionNotFound): self.account.inbox.unsubscribe(subscription_id) @@ -49,6 +53,14 @@ def test_push_subscribe(self): ) as (subscription_id, watermark): self.assertIsNotNone(subscription_id) self.assertIsNotNone(watermark) + # Test with watermark + with self.account.inbox.push_subscription( + callback_url='https://example.com/foo', + watermark=watermark, + ) as (subscription_id, watermark): + self.assertIsNotNone(subscription_id) + self.assertIsNotNone(watermark) + # Cannot unsubscribe. Must be done as response to callback URL request with self.assertRaises(ErrorInvalidSubscription): self.account.inbox.unsubscribe(subscription_id) # Test via folder collection diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 18b3975d..0df8538b 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -397,7 +397,9 @@ def test_resolvenames_parsing(self): def test_get_searchable_mailboxes(self): # Insufficient privileges for the test account, so let's just test the exception with self.assertRaises(ErrorAccessDenied): - self.account.protocol.get_searchable_mailboxes('non_existent_distro@example.com') + self.account.protocol.get_searchable_mailboxes(search_filter='non_existent_distro@example.com') + with self.assertRaises(ErrorAccessDenied): + self.account.protocol.get_searchable_mailboxes(expand_group_membership=True) xml = b'''\ @@ -409,7 +411,7 @@ def test_get_searchable_mailboxes(self): 33a408fe-2574-4e3b-49f5-5e1e000a3035 - LOLgroup@contoso.com + LOLgroup@example.com false LOLgroup @@ -417,7 +419,7 @@ def test_get_searchable_mailboxes(self): /o=First/ou=Exchange(FYLT)/cn=Recipients/cn=81213b958a0b5295b13b3f02b812bf1bc-LOLgroup - FAILgroup@contoso.com + FAILgroup@example.com 123 Catastrophic Failure true @@ -430,7 +432,7 @@ def test_get_searchable_mailboxes(self): self.assertListEqual(list(ws.parse(xml)), [ SearchableMailbox( guid='33a408fe-2574-4e3b-49f5-5e1e000a3035', - primary_smtp_address='LOLgroup@contoso.com', + primary_smtp_address='LOLgroup@example.com', is_external=False, external_email=None, display_name='LOLgroup', @@ -438,7 +440,7 @@ def test_get_searchable_mailboxes(self): reference_id='/o=First/ou=Exchange(FYLT)/cn=Recipients/cn=81213b958a0b5295b13b3f02b812bf1bc-LOLgroup', ), FailedMailbox( - mailbox='FAILgroup@contoso.com', + mailbox='FAILgroup@example.com', error_code=123, error_message='Catastrophic Failure', is_archive=True, diff --git a/tests/test_util.py b/tests/test_util.py index 77ca77a5..18521144 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -235,6 +235,10 @@ def test_post_ratelimited(self): r, session = post_ratelimited(protocol=protocol, session=session, url='http://', headers=None, data='') self.assertEqual(rle.exception.status_code, 503) self.assertEqual(rle.exception.url, url) + self.assertRegex( + str(rle.exception), + r'Max timeout reached \(gave up after .* seconds. URL https://example.com returned status code 503\)' + ) self.assertTrue(1 <= rle.exception.total_wait < 2) # One RETRY_WAIT plus some overhead # Test something larger than the default wait, so we retry at least once @@ -244,6 +248,10 @@ def test_post_ratelimited(self): r, session = post_ratelimited(protocol=protocol, session=session, url='http://', headers=None, data='') self.assertEqual(rle.exception.status_code, 503) self.assertEqual(rle.exception.url, url) + self.assertRegex( + str(rle.exception), + r'Max timeout reached \(gave up after .* seconds. URL https://example.com returned status code 503\)' + ) # We double the wait for each retry, so this is RETRY_WAIT + 2*RETRY_WAIT plus some overhead self.assertTrue(3 <= rle.exception.total_wait < 4, rle.exception.total_wait) finally: diff --git a/tests/test_version.py b/tests/test_version.py index d1e73198..91ec5e38 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -8,6 +8,41 @@ class VersionTest(TimedTestCase): + def test_invalid_version_args(self): + with self.assertRaises(ValueError) as e: + Version(build='XXX') + self.assertEqual(e.exception.args[0], "'build' 'XXX' must be a Build instance") + with self.assertRaises(ValueError) as e: + Version(build='XXX', api_version='XXX') + self.assertEqual(e.exception.args[0], "'build' 'XXX' must be a Build instance") + with self.assertRaises(ValueError) as e: + Version(build=Build(15, 1, 2, 3), api_version=999) + self.assertEqual(e.exception.args[0], "'api_version' 999 must be a string") + + def test_invalid_build_args(self): + with self.assertRaises(ValueError) as e: + Build('XXX', 2, 3, 4) + self.assertEqual(e.exception.args[0], "'major_version' 'XXX' must be an integer") + with self.assertRaises(ValueError) as e: + Build(1, 'XXX', 3, 4) + self.assertEqual(e.exception.args[0], "'minor_version' 'XXX' must be an integer") + with self.assertRaises(ValueError) as e: + Build(1, 2, 'XXX', 4) + self.assertEqual(e.exception.args[0], "'major_build' 'XXX' must be an integer") + with self.assertRaises(ValueError) as e: + Build(1, 2, 3, 'XXX') + self.assertEqual(e.exception.args[0], "'minor_build' 'XXX' must be an integer") + + def test_comparison(self): + self.assertEqual(Version(Build(15, 1, 2, 3)), Version(Build(15, 1, 2, 3))) + self.assertNotEqual(Version(Build(15, 1, 2, 3)), Version(Build(15, 1))) + self.assertNotEqual(Version(Build(15, 1, 2, 3), api_version='XXX'), Version(None, api_version='XXX')) + self.assertNotEqual(Version(None, api_version='XXX'), Version(Build(15, 1, 2), api_version='XXX')) + self.assertEqual(Version(Build(15, 1, 2, 3), 'XXX'), Version(Build(15, 1, 2, 3), 'XXX')) + self.assertNotEqual(Version(Build(15, 1, 2, 3), 'XXX'), Version(Build(15, 1, 2, 3), 'YYY')) + self.assertNotEqual(Version(Build(15, 1, 2, 3), 'XXX'), Version(Build(99, 88), 'XXX')) + self.assertNotEqual(Version(Build(15, 1, 2, 3), 'XXX'), Version(Build(99, 88), 'YYY')) + def test_default_api_version(self): # Test that a version gets a reasonable api_version value if we don't set one explicitly version = Version(build=Build(15, 1, 2, 3)) From a2167ac55891963c6db6382bdface531cd0307d2 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 4 Jan 2022 10:43:54 +0100 Subject: [PATCH 071/509] Improve code coverage --- exchangelib/autodiscover/discovery.py | 9 +-------- exchangelib/configuration.py | 4 +++- exchangelib/indexed_properties.py | 4 ++-- tests/test_configuration.py | 1 + tests/test_field.py | 9 +++++++-- tests/test_protocol.py | 9 ++++++++- 6 files changed, 22 insertions(+), 14 deletions(-) diff --git a/exchangelib/autodiscover/discovery.py b/exchangelib/autodiscover/discovery.py index a2ff82cb..d6928cfd 100644 --- a/exchangelib/autodiscover/discovery.py +++ b/exchangelib/autodiscover/discovery.py @@ -9,11 +9,9 @@ from .properties import Autodiscover from .protocol import AutodiscoverProtocol from ..configuration import Configuration -from ..credentials import OAuth2Credentials from ..errors import AutoDiscoverFailed, AutoDiscoverCircularRedirect, TransportError, RedirectError, UnauthorizedError from ..protocol import Protocol, FailFast -from ..transport import get_auth_method_from_response, DEFAULT_HEADERS, NOAUTH, OAUTH2, GSSAPI, AUTH_TYPE_MAP, \ - CREDENTIALS_REQUIRED +from ..transport import get_auth_method_from_response, DEFAULT_HEADERS, NOAUTH, GSSAPI, AUTH_TYPE_MAP from ..util import post_ratelimited, get_domain, get_redirect_url, _back_off_if_needed, \ DummyResponse, CONNECTION_ERRORS, TLS_ERRORS from ..version import Version @@ -333,11 +331,6 @@ def _attempt_response(self, url): log.debug('Attempting to get a valid response from %s', url) try: auth_type, r = self._get_unauthenticated_response(url=url) - if isinstance(self.credentials, OAuth2Credentials): - # This type of credentials *must* use the OAuth auth type - auth_type = OAUTH2 - elif self.credentials is None and auth_type in CREDENTIALS_REQUIRED: - raise ValueError(f'Auth type {auth_type!r} was detected but no credentials were provided') ad_protocol = AutodiscoverProtocol( config=Configuration( service_endpoint=url, diff --git a/exchangelib/configuration.py b/exchangelib/configuration.py index ff5487ff..883da80d 100644 --- a/exchangelib/configuration.py +++ b/exchangelib/configuration.py @@ -4,7 +4,7 @@ from .credentials import BaseCredentials, OAuth2Credentials from .protocol import RetryPolicy, FailFast -from .transport import AUTH_TYPE_MAP, OAUTH2 +from .transport import AUTH_TYPE_MAP, OAUTH2, CREDENTIALS_REQUIRED from .util import split_url from .version import Version @@ -46,6 +46,8 @@ def __init__(self, credentials=None, server=None, service_endpoint=None, auth_ty if isinstance(credentials, OAuth2Credentials) and auth_type is None: # This type of credentials *must* use the OAuth auth type auth_type = OAUTH2 + elif credentials is None and auth_type in CREDENTIALS_REQUIRED: + raise ValueError(f'Auth type {auth_type!r} was detected but no credentials were provided') if server and service_endpoint: raise AttributeError("Only one of 'server' or 'service_endpoint' must be provided") if auth_type is not None and auth_type not in AUTH_TYPE_MAP: diff --git a/exchangelib/indexed_properties.py b/exchangelib/indexed_properties.py index e98c7211..9c128d6a 100644 --- a/exchangelib/indexed_properties.py +++ b/exchangelib/indexed_properties.py @@ -16,10 +16,10 @@ class SingleFieldIndexedElement(IndexedElement, metaclass=EWSMeta): """Base class for all classes that implement an indexed element with a single field.""" @classmethod - def value_field(cls, version=None): + def value_field(cls, version): fields = cls.supported_fields(version=version) if len(fields) != 1: - raise ValueError(f'This class must have only one field (found {fields})') + raise ValueError(f'Class {cls} must have only one value field (found {tuple(f.name for f in fields)})') return fields[0] diff --git a/tests/test_configuration.py b/tests/test_configuration.py index b38d717d..be8f4369 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -36,6 +36,7 @@ def test_init(self): with self.assertRaises(ValueError) as e: Configuration(max_connections='foo') self.assertEqual(e.exception.args[0], "'max_connections' 'foo' must be an integer") + self.assertEqual(Configuration().server, None) # Test that property works when service_endpoint is None def test_magic(self): config = Configuration( diff --git a/tests/test_field.py b/tests/test_field.py index 1c6cdd30..854f3b00 100644 --- a/tests/test_field.py +++ b/tests/test_field.py @@ -241,5 +241,10 @@ class TestField(SingleFieldIndexedElement): a = CharField() b = CharField() - with self.assertRaises(ValueError): - TestField.value_field() + with self.assertRaises(ValueError) as e: + TestField.value_field(version=Version(EXCHANGE_2013)) + self.assertEqual( + e.exception.args[0], + "Class .TestField'> " + "must have only one value field (found ('a', 'b'))" + ) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 0df8538b..588c14ed 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -24,7 +24,7 @@ SetUserOofSettings, ExpandDL from exchangelib.settings import OofSettings from exchangelib.transport import NOAUTH, NTLM -from exchangelib.version import Build, Version +from exchangelib.version import Build, Version, EXCHANGE_2010_SP1 from exchangelib.winzone import CLDR_TO_MS_TIMEZONE_MAP from .common import EWSTest, get_random_datetime_range, get_random_string, RANDOM_DATE_MIN, RANDOM_DATE_MAX @@ -311,6 +311,13 @@ def test_resolvenames(self): e.exception.args[0], "Chunk size 500 is too high. ResolveNames supports returning at most 100 candidates for a lookup" ) + with self.assertRaises(NotImplementedError) as e: + self.account.protocol.version.build = EXCHANGE_2010_SP1 + self.account.protocol.resolve_names(names=['xxx@example.com'], shape='IdOnly') + self.assertEqual( + e.exception.args[0], + f"'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later" + ) self.assertGreaterEqual( self.account.protocol.resolve_names(names=['xxx@example.com']), [] From e9a44103919cb77ea8435ca72a889b837bc6b865 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 4 Jan 2022 15:30:15 +0100 Subject: [PATCH 072/509] Improve coverage of patterns --- exchangelib/fields.py | 11 +++++- exchangelib/recurrence.py | 54 ++++++++++---------------- tests/test_items/test_calendaritems.py | 31 ++++++++++++++- tests/test_recurrence.py | 30 ++++++++++---- 4 files changed, 81 insertions(+), 45 deletions(-) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index 3c4f26ee..02358c74 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -486,8 +486,6 @@ def clean(self, value, version=None): def as_string(self, value): # Converts an integer in the enum to its equivalent string - if isinstance(value, str): - return value if self.is_list: return [self.enum[v - 1] for v in sorted(value)] return self.enum[value - 1] @@ -517,6 +515,15 @@ class EnumListField(EnumField): is_list = True +class WeekdaysField(EnumListField): + """Like EnumListField, allow a single value instead of a 1-element list.""" + + def clean(self, value, version=None): + if isinstance(value, (int, str)): + value = [value] + return super().clean(value, version) + + class EnumAsIntField(EnumField): """Like EnumField, but communicates values with EWS in integers.""" diff --git a/exchangelib/recurrence.py b/exchangelib/recurrence.py index 4a5a4f17..23e45d5a 100644 --- a/exchangelib/recurrence.py +++ b/exchangelib/recurrence.py @@ -1,7 +1,7 @@ import logging -from .fields import IntegerField, EnumField, EnumListField, DateOrDateTimeField, DateTimeField, EWSElementField, \ - IdElementField, MONTHS, WEEK_NUMBERS, WEEKDAYS +from .fields import IntegerField, EnumField, WeekdaysField, DateOrDateTimeField, DateTimeField, EWSElementField, \ + IdElementField, MONTHS, WEEK_NUMBERS, WEEKDAYS, WEEKDAY_NAMES from .properties import EWSElement, IdChangeKeyMixIn, ItemId, EWSMeta log = logging.getLogger(__name__) @@ -51,9 +51,9 @@ class RelativeYearlyPattern(Pattern): ELEMENT_NAME = 'RelativeYearlyRecurrence' - # The weekday of the occurrence, as a valid ISO 8601 weekday number in range 1 -> 7 (1 being Monday). - # Alternatively, the weekday can be one of the DAY (or 8), WEEK_DAY (or 9) or WEEKEND_DAY (or 10) consts which - # is interpreted as the first day, weekday, or weekend day in the month, respectively. + # Valid ISO 8601 weekday, as a number in range 1 -> 7 (1 being Monday). The value can also be one of the DAY + # (or 8), WEEK_DAY (or 9) or WEEKEND_DAY (or 10) consts which is interpreted as the first day, weekday, or weekend + # day of the year. Despite the field name in EWS, this is not a list. weekday = EnumField(field_uri='DaysOfWeek', enum=WEEKDAYS, is_required=True) # Week number of the month, in range 1 -> 5. If 5 is specified, this assumes the last week of the month for # months that have only 4 weeks @@ -92,9 +92,9 @@ class RelativeMonthlyPattern(Pattern): # Interval, in months, in range 1 -> 99 interval = IntegerField(field_uri='Interval', min=1, max=99, is_required=True) - # The weekday of the occurrence, as a valid ISO 8601 weekday number in range 1 -> 7 (1 being Monday). - # Alternatively, the weekday can be one of the DAY (or 8), WEEK_DAY (or 9) or WEEKEND_DAY (or 10) consts which - # is interpreted as the first day, weekday, or weekend day in the month, respectively. + # Valid ISO 8601 weekday, as a number in range 1 -> 7 (1 being Monday). The value can also be one of the DAY + # (or 8), WEEK_DAY (or 9) or WEEKEND_DAY (or 10) consts which is interpreted as the first day, weekday, or weekend + # day of the month. Despite the field name in EWS, this is not a list. weekday = EnumField(field_uri='DaysOfWeek', enum=WEEKDAYS, is_required=True) # Week number of the month, in range 1 -> 5. If 5 is specified, this assumes the last week of the month for # months that have only 4 weeks. @@ -113,17 +113,12 @@ class WeeklyPattern(Pattern): # Interval, in weeks, in range 1 -> 99 interval = IntegerField(field_uri='Interval', min=1, max=99, is_required=True) # List of valid ISO 8601 weekdays, as list of numbers in range 1 -> 7 (1 being Monday) - weekdays = EnumListField(field_uri='DaysOfWeek', enum=WEEKDAYS, is_required=True) + weekdays = WeekdaysField(field_uri='DaysOfWeek', enum=WEEKDAY_NAMES, is_required=True) # The first day of the week. Defaults to Monday - first_day_of_week = EnumField(field_uri='FirstDayOfWeek', enum=WEEKDAYS, default=1, is_required=True) + first_day_of_week = EnumField(field_uri='FirstDayOfWeek', enum=WEEKDAY_NAMES, default=1, is_required=True) def __str__(self): - if isinstance(self.weekdays, str): - weekdays = [self.weekdays] - elif isinstance(self.weekdays, int): - weekdays = [_weekday_to_str(self.weekdays)] - else: - weekdays = [_weekday_to_str(i) for i in self.weekdays] + weekdays = [_weekday_to_str(i) for i in self.get_field_by_fieldname('weekdays').clean(self.weekdays)] return f'Occurs on weekdays {", ".join(weekdays)} of every {self.interval} week(s) where the first day of ' \ f'the week is {_weekday_to_str(self.first_day_of_week)}' @@ -285,7 +280,8 @@ class Recurrence(EWSElement): """ ELEMENT_NAME = 'Recurrence' - PATTERN_CLASSES = PATTERN_CLASSES + PATTERN_CLASS_MAP = {cls.response_tag(): cls for cls in PATTERN_CLASSES} + BOUNDARY_CLASS_MAP = {cls.response_tag(): cls for cls in BOUNDARY_CLASSES} pattern = EWSElementField(value_cls=Pattern) boundary = EWSElementField(value_cls=Boundary) @@ -310,22 +306,12 @@ def __init__(self, **kwargs): @classmethod def from_xml(cls, elem, account): - for pattern_cls in cls.PATTERN_CLASSES: - pattern_elem = elem.find(pattern_cls.response_tag()) - if pattern_elem is None: - continue - pattern = pattern_cls.from_xml(elem=pattern_elem, account=account) - break - else: - pattern = None - for boundary_cls in BOUNDARY_CLASSES: - boundary_elem = elem.find(boundary_cls.response_tag()) - if boundary_elem is None: - continue - boundary = boundary_cls.from_xml(elem=boundary_elem, account=account) - break - else: - boundary = None + pattern, boundary = None, None + for child_elem in elem: + if child_elem.tag in cls.PATTERN_CLASS_MAP: + pattern = cls.PATTERN_CLASS_MAP[child_elem.tag].from_xml(elem=child_elem, account=account) + elif child_elem.tag in cls.BOUNDARY_CLASS_MAP: + boundary = cls.BOUNDARY_CLASS_MAP[child_elem.tag].from_xml(elem=child_elem, account=account) return cls(pattern=pattern, boundary=boundary) def __str__(self): @@ -337,4 +323,4 @@ class TaskRecurrence(Recurrence): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recurrence-taskrecurrencetype """ - PATTERN_CLASSES = PATTERN_CLASSES + REGENERATION_CLASSES + PATTERN_CLASS_MAP = {cls.response_tag(): cls for cls in PATTERN_CLASSES + REGENERATION_CLASSES} diff --git a/tests/test_items/test_calendaritems.py b/tests/test_items/test_calendaritems.py index 431d7c1f..cc2cbfdd 100644 --- a/tests/test_items/test_calendaritems.py +++ b/tests/test_items/test_calendaritems.py @@ -2,11 +2,13 @@ from exchangelib.errors import ErrorInvalidOperation, ErrorItemNotFound from exchangelib.ewsdatetime import UTC +from exchangelib.fields import NOVEMBER, WEEKEND_DAY, WEEK_DAY, THIRD, MONDAY, WEDNESDAY from exchangelib.folders import Calendar from exchangelib.items import CalendarItem, BulkCreateResult from exchangelib.items.calendar_item import SINGLE, OCCURRENCE, EXCEPTION, RECURRING_MASTER -from exchangelib.recurrence import Recurrence, DailyPattern, Occurrence, FirstOccurrence, LastOccurrence, \ - DeletedOccurrence +from exchangelib.recurrence import Recurrence, Occurrence, FirstOccurrence, LastOccurrence, DeletedOccurrence, \ +AbsoluteYearlyPattern, RelativeYearlyPattern, AbsoluteMonthlyPattern, RelativeMonthlyPattern, WeeklyPattern, \ + DailyPattern from ..common import get_random_string, get_random_datetime_range, get_random_date from .test_basics import CommonItemTest @@ -228,6 +230,31 @@ def test_client_side_ordering_on_mixed_all_day_and_normal(self): list(self.test_folder.view(start=start - datetime.timedelta(days=1), end=end).order_by('start')) list(self.test_folder.view(start=start - datetime.timedelta(days=1), end=end).order_by('-start')) + def test_all_recurring_pattern_types(self): + start = datetime.datetime(2016, 1, 1, 8, tzinfo=self.account.default_timezone) + end = datetime.datetime(2016, 1, 1, 10, tzinfo=self.account.default_timezone) + + for pattern in ( + AbsoluteYearlyPattern(day_of_month=13, month=NOVEMBER), + RelativeYearlyPattern(weekday=1, week_number=THIRD, month=11), + RelativeYearlyPattern(weekday=WEEKEND_DAY, week_number=3, month=11), + AbsoluteMonthlyPattern(interval=3, day_of_month=13), + RelativeMonthlyPattern(interval=3, weekday=2, week_number=3), + RelativeMonthlyPattern(interval=3, weekday=WEEK_DAY, week_number=3), + WeeklyPattern(interval=3, weekdays=[MONDAY, WEDNESDAY], first_day_of_week=1), + DailyPattern(interval=1), + ): + master_item = self.ITEM_CLASS( + folder=self.test_folder, + start=start, + end=end, + recurrence=Recurrence(pattern=pattern, start=start.date(), number=4), + categories=self.categories, + ).save() + master_item.refresh() + self.assertEqual(pattern, master_item.recurrence.pattern) + master_item.delete() + def test_recurring_item(self): # Create a recurring calendar item. Test that occurrence fields are correct on the master item diff --git a/tests/test_recurrence.py b/tests/test_recurrence.py index 4042e8c3..91a5fcd4 100644 --- a/tests/test_recurrence.py +++ b/tests/test_recurrence.py @@ -1,8 +1,9 @@ import datetime -from exchangelib.fields import MONDAY, FEBRUARY, AUGUST, SECOND, LAST, WEEKEND_DAY +from exchangelib.fields import MONDAY, FEBRUARY, AUGUST, SECOND, LAST, SUNDAY, WEEKEND_DAY from exchangelib.recurrence import Recurrence, AbsoluteYearlyPattern, RelativeYearlyPattern, AbsoluteMonthlyPattern, \ - RelativeMonthlyPattern, WeeklyPattern, DailyPattern, NoEndPattern, EndDatePattern, NumberedPattern + RelativeMonthlyPattern, WeeklyPattern, DailyPattern, NoEndPattern, EndDatePattern, NumberedPattern, \ + YearlyRegeneration, MonthlyRegeneration, WeeklyRegeneration, DailyRegeneration from .common import TimedTestCase @@ -11,17 +12,32 @@ class RecurrenceTest(TimedTestCase): def test_magic(self): pattern = AbsoluteYearlyPattern(month=FEBRUARY, day_of_month=28) self.assertEqual(str(pattern), 'Occurs on day 28 of February') - pattern = RelativeYearlyPattern(month=AUGUST, week_number=SECOND, weekday=MONDAY) - self.assertEqual(str(pattern), 'Occurs on weekday Monday in the Second week of August') + pattern = RelativeYearlyPattern(month=AUGUST, week_number=SECOND, weekday=WEEKEND_DAY) + self.assertEqual(str(pattern), 'Occurs on weekday WeekendDay in the Second week of August') pattern = AbsoluteMonthlyPattern(interval=3, day_of_month=31) self.assertEqual(str(pattern), 'Occurs on day 31 of every 3 month(s)') pattern = RelativeMonthlyPattern(interval=2, week_number=LAST, weekday=5) self.assertEqual(str(pattern), 'Occurs on weekday Friday in the Last week of every 2 month(s)') - pattern = WeeklyPattern(interval=4, weekdays=WEEKEND_DAY, first_day_of_week=7) - self.assertEqual(str(pattern), - 'Occurs on weekdays WeekendDay of every 4 week(s) where the first day of the week is Sunday') + pattern = WeeklyPattern(interval=4, weekdays=[1, 7], first_day_of_week=7) + self.assertEqual( + str(pattern), + 'Occurs on weekdays Monday, Sunday of every 4 week(s) where the first day of the week is Sunday' + ) + pattern = WeeklyPattern(interval=4, weekdays=[MONDAY, SUNDAY], first_day_of_week=7) + self.assertEqual( + str(pattern), + 'Occurs on weekdays Monday, Sunday of every 4 week(s) where the first day of the week is Sunday' + ) pattern = DailyPattern(interval=6) self.assertEqual(str(pattern), 'Occurs every 6 day(s)') + pattern = YearlyRegeneration(interval=6) + self.assertEqual(str(pattern), 'Regenerates every 6 year(s)') + pattern = MonthlyRegeneration(interval=6) + self.assertEqual(str(pattern), 'Regenerates every 6 month(s)') + pattern = WeeklyRegeneration(interval=6) + self.assertEqual(str(pattern), 'Regenerates every 6 week(s)') + pattern = DailyRegeneration(interval=6) + self.assertEqual(str(pattern), 'Regenerates every 6 day(s)') def test_validation(self): p = DailyPattern(interval=3) From 6f4e16fdc1bead1647e9e09774892605919b0390 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 5 Jan 2022 00:03:30 +0100 Subject: [PATCH 073/509] Mark more classes as metaclasses. Minor cleanup in Field --- exchangelib/fields.py | 14 +++++++------- exchangelib/folders/known_folders.py | 5 +++-- exchangelib/items/calendar_item.py | 4 ++-- exchangelib/properties.py | 2 +- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index 02358c74..bcfc157b 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -268,7 +268,7 @@ class Field(metaclass=abc.ABCMeta): def __init__(self, name=None, is_required=False, is_required_after_save=False, is_read_only=False, is_read_only_after_send=False, is_searchable=True, is_attribute=False, default=None, supported_from=None, deprecated_from=None): - self.name = name + self.name = name # Usually set by the EWSMeta metaclass self.default = default # Default value if none is given self.is_required = is_required # Some fields cannot be deleted on update. Default to True if 'is_required' is set @@ -390,17 +390,17 @@ def to_xml(self, value, version): def field_uri_xml(self): from .properties import FieldURI if not self.field_uri: - raise ValueError("'field_uri' value is missing") + raise ValueError(f"'field_uri' value is missing on field '{self.name}'") return FieldURI(field_uri=self.field_uri).to_xml(version=None) def request_tag(self): if not self.field_uri_postfix: - raise ValueError("'field_uri_postfix' value is missing") + raise ValueError(f"'field_uri_postfix' value is missing on field '{self.name}'") return f't:{self.field_uri_postfix}' def response_tag(self): if not self.field_uri_postfix: - raise ValueError("'field_uri_postfix' value is missing") + raise ValueError(f"'field_uri_postfix' value is missing on field '{self.name}'") return f'{{{self.namespace}}}{self.field_uri_postfix}' def __hash__(self): @@ -1061,8 +1061,8 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) -class BaseEmailField(EWSElementField): - """A base class for EWSElement classes that have an 'email_address' field that we want to provide helpers for.""" +class BaseEmailField(EWSElementField, metaclass=abc.ABCMeta): + """Base class for EWSElement classes that have an 'email_address' field that we want to provide helpers for.""" is_complex = True # FindItem only returns the name, not the email address @@ -1253,7 +1253,7 @@ def response_tag(self): return f'{{{self.namespace}}}{self.field_uri}' -class IndexedField(EWSElementField): +class IndexedField(EWSElementField, metaclass=abc.ABCMeta): """A base class for all indexed fields.""" PARENT_ELEMENT_NAME = None diff --git a/exchangelib/folders/known_folders.py b/exchangelib/folders/known_folders.py index 5d8285cb..f383727e 100644 --- a/exchangelib/folders/known_folders.py +++ b/exchangelib/folders/known_folders.py @@ -2,6 +2,7 @@ from .collections import FolderCollection from ..items import CalendarItem, Contact, Message, Task, DistributionList, MeetingRequest, MeetingResponse, \ MeetingCancellation, ITEM_CLASSES, ASSOCIATED +from ..properties import EWSMeta from ..version import EXCHANGE_2010_SP1, EXCHANGE_2013, EXCHANGE_2013_SP1 @@ -167,8 +168,8 @@ class Contacts(Folder): } -class WellknownFolder(Folder): - """A base class to use until we have a more specific folder implementation for this folder.""" +class WellknownFolder(Folder, metaclass=EWSMeta): + """Base class to use until we have a more specific folder implementation for this folder.""" supported_item_models = ITEM_CLASSES diff --git a/exchangelib/items/calendar_item.py b/exchangelib/items/calendar_item.py index 37f389d8..458f55b2 100644 --- a/exchangelib/items/calendar_item.py +++ b/exchangelib/items/calendar_item.py @@ -264,8 +264,8 @@ def to_xml(self, version): return elem -class BaseMeetingItem(Item): - """A base class for meeting requests that share the same fields (Message, Request, Response, Cancellation) +class BaseMeetingItem(Item, metaclass=EWSMeta): + """Base class for meeting requests that share the same fields (Message, Request, Response, Cancellation) MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/responsecode Certain types are created as a side effect of doing something else. Meeting messages, for example, are created diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 8a86a4f0..0775bfb4 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -429,7 +429,7 @@ class MessageHeader(EWSElement): value = SubField() -class BaseItemId(EWSElement): +class BaseItemId(EWSElement, metaclass=EWSMeta): """Base class for ItemId elements.""" ID_ATTR = None From fe5ba6ec25a197aeb2c1e027629e44e2d469f1df Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 5 Jan 2022 00:07:16 +0100 Subject: [PATCH 074/509] Rewrite GetServerTimeZones to return TimeZoneDefinition objects using standard tools --- exchangelib/fields.py | 21 ++ exchangelib/properties.py | 276 +++++++++++++----- exchangelib/protocol.py | 11 +- exchangelib/services/get_server_time_zones.py | 82 +----- tests/test_protocol.py | 14 +- 5 files changed, 229 insertions(+), 175 deletions(-) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index bcfc157b..aeff031b 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -639,6 +639,12 @@ def from_xml(self, elem, account): return self.default +class TimeDeltaField(FieldURIField): + """A field that handles timedelta values.""" + + value_cls = datetime.timedelta + + class DateTimeField(FieldURIField): """A field that handles datetime values.""" @@ -998,6 +1004,21 @@ class EWSElementListField(EWSElementField): is_complex = True +class TransitionListField(EWSElementListField): + def __init__(self, *args, **kwargs): + from .properties import BaseTransition + kwargs['value_cls'] = BaseTransition + super().__init__(*args, **kwargs) + + def from_xml(self, elem, account): + iter_elem = elem.find(self.response_tag()) if self.field_uri else elem + if iter_elem is not None: + return [ + self.value_cls.transition_model_from_tag(e.tag).from_xml(elem=e, account=account) for e in iter_elem + ] + return self.default + + class AssociatedCalendarItemIdField(EWSElementField): is_complex = True diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 0775bfb4..de8f3a51 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -13,7 +13,7 @@ EWSElementListField, EnumListField, FreeBusyStatusField, UnknownEntriesField, MessageField, RecipientAddressField, \ RoutingTypeField, WEEKDAY_NAMES, FieldPath, Field, AssociatedCalendarItemIdField, ReferenceItemIdField, \ Base64Field, TypeValueField, DictionaryField, IdElementField, CharListField, GenericEventListField, \ - InvalidField, InvalidFieldForVersion + DateTimeBackedDateField, TimeDeltaField, TransitionListField, InvalidField, InvalidFieldForVersion from .util import get_xml_attr, create_element, set_xml_value, value_to_xml_text, MNS, TNS from .version import Version, EXCHANGE_2013, Build @@ -799,12 +799,15 @@ def to_server_timezone(self, timezones, for_year): :return: A Microsoft timezone ID, as a string """ candidates = set() - for tz_id, tz_name, tz_periods, tz_transitions, tz_transitions_groups in timezones: - candidate = self.from_server_timezone(tz_periods, tz_transitions, tz_transitions_groups, for_year) + for tz_definition in timezones: + candidate = self.from_server_timezone( + tz_definition=tz_definition, + for_year=for_year, + ) if candidate == self: - log.debug('Found exact candidate: %s (%s)', tz_id, tz_name) + log.debug('Found exact candidate: %s (%s)', tz_definition.id, tz_definition.name) # We prefer this timezone over anything else. Return immediately. - return tz_id + return tz_definition.id # Reduce list based on base bias and standard / daylight bias values if candidate.bias != self.bias: continue @@ -824,8 +827,8 @@ def to_server_timezone(self, timezones, for_year): continue if candidate.daylight_time.bias != self.daylight_time.bias: continue - log.debug('Found candidate with matching biases: %s (%s)', tz_id, tz_name) - candidates.add(tz_id) + log.debug('Found candidate with matching biases: %s (%s)', tz_definition.id, tz_definition.name) + candidates.add(tz_definition.id) if not candidates: raise ValueError('No server timezones match this timezone definition') if len(candidates) == 1: @@ -835,81 +838,10 @@ def to_server_timezone(self, timezones, for_year): return candidates.pop() @classmethod - def from_server_timezone(cls, periods, transitions, transitionsgroups, for_year): + def from_server_timezone(cls, tz_definition, for_year): # Creates a TimeZone object from the result of a GetServerTimeZones call with full timezone data - - # Get the default bias - bias = cls._get_bias(periods=periods, for_year=for_year) - - # Get a relevant transition ID - valid_tg_id = cls._get_valid_transition_id(transitions=transitions, for_year=for_year) - transitiongroup = transitionsgroups[valid_tg_id] - if not 0 <= len(transitiongroup) <= 2: - raise ValueError(f'Expected 0-2 transitions in transitionsgroup {transitiongroup}') - - standard_time, daylight_time = cls._get_std_and_dst(transitiongroup=transitiongroup, periods=periods, bias=bias) - return cls(bias=bias, standard_time=standard_time, daylight_time=daylight_time) - - @staticmethod - def _get_bias(periods, for_year): - # Set a default bias - valid_period = None - for (year, period_type), period in sorted(periods.items()): - if year > for_year: - break - if period_type != 'Standard': - continue - valid_period = period - if valid_period is None: - raise TimezoneDefinitionInvalidForYear(f'Year {for_year} not included in periods {periods}') - return int(valid_period['bias'].total_seconds()) // 60 # Convert to minutes - - @staticmethod - def _get_valid_transition_id(transitions, for_year): - # Look through the transitions, and pick the relevant one according to the 'for_year' value - valid_tg_id = None - for tg_id, from_date in sorted(transitions.items()): - if from_date and from_date.year > for_year: - break - valid_tg_id = tg_id - if valid_tg_id is None: - raise ValueError(f'No valid transition for year {for_year}: {transitions}') - return valid_tg_id - - @staticmethod - def _get_std_and_dst(transitiongroup, periods, bias): - # Return 'standard_time' and 'daylight_time' objects. We do unnecessary work here, but it keeps code simple. - standard_time, daylight_time = None, None - for transition in transitiongroup: - period = periods[transition['to']] - if len(transition) == 1: - # This is a simple transition representing a timezone with no DST. Some servers don't accept TimeZone - # elements without a STD and DST element (see issue #488). Return StandardTime and DaylightTime objects - # with dummy values and 0 bias - this satisfies the broken servers and hopefully doesn't break the - # well-behaving servers. - standard_time = StandardTime(bias=0, time=datetime.time(0), occurrence=1, iso_month=1, weekday=1) - daylight_time = DaylightTime(bias=0, time=datetime.time(0), occurrence=5, iso_month=12, weekday=7) - continue - # 'offset' is the time of day to transition, as timedelta since midnight. Must be a reasonable value - if not datetime.timedelta(0) <= transition['offset'] < datetime.timedelta(days=1): - raise ValueError(f"'offset' value {transition['offset']} must be be between 0 and 24 hours") - transition_kwargs = dict( - time=(datetime.datetime(2000, 1, 1) + transition['offset']).time(), - occurrence=transition['occurrence'], - iso_month=transition['iso_month'], - weekday=transition['iso_weekday'], - ) - if period['name'] == 'Standard': - transition_kwargs['bias'] = 0 - standard_time = StandardTime(**transition_kwargs) - continue - if period['name'] == 'Daylight': - dst_bias = int(period['bias'].total_seconds()) // 60 # Convert to minutes - transition_kwargs['bias'] = dst_bias - bias - daylight_time = DaylightTime(**transition_kwargs) - continue - raise ValueError(f'Unknown transition: {transition}') - return standard_time, daylight_time + std_time, daylight_time, period = tz_definition.get_std_and_dst(for_year=for_year) + return cls(bias=period.bias_in_minutes, standard_time=std_time, daylight_time=daylight_time) class CalendarView(EWSElement): @@ -1778,3 +1710,185 @@ class Notification(EWSElement): previous_watermark = CharField(field_uri='PreviousWatermark') more_events = BooleanField(field_uri='MoreEvents') events = GenericEventListField('') + + +class BaseTransition(EWSElement, metaclass=EWSMeta): + """Base class for all other transition classes""" + + to = CharField(field_uri='To') + kind = CharField(field_uri='Kind', is_attribute=True) # An attribute on the 'To' element + + @staticmethod + def transition_model_from_tag(tag): + return {cls.response_tag(): cls for cls in ( + Transition, AbsoluteDateTransition, RecurringDateTransition, RecurringDayTransition + )}[tag] + + @classmethod + def from_xml(cls, elem, account): + kind = elem.find(cls.get_field_by_fieldname('to').response_tag()).get('Kind') + res = super().from_xml(elem=elem, account=account) + res.kind = kind + return res + + +class Transition(BaseTransition): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/transition""" + + ELEMENT_NAME = 'Transition' + + +class AbsoluteDateTransition(BaseTransition): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/absolutedatetransition""" + + ELEMENT_NAME = 'AbsoluteDateTransition' + + date = DateTimeBackedDateField(field_uri='DateTime') + + +class RecurringDayTransition(BaseTransition): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recurringdaytransition""" + + ELEMENT_NAME = 'RecurringDayTransition' + + offset = TimeDeltaField(field_uri='TimeOffset') + month = IntegerField(field_uri='Month') + # Valid ISO 8601 weekday, as a number in range 1 -> 7 (1 being Monday) + day_of_week = EnumField(field_uri='DayOfWeek', enum=WEEKDAY_NAMES) + occurrence = IntegerField(field_uri='Occurrence') + + @classmethod + def from_xml(cls, elem, account): + res = super().from_xml(elem, account) + # See TimeZoneTransition.from_xml() + if res.occurrence == -1: + res.occurrence = 5 + return res + + +class RecurringDateTransition(BaseTransition): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recurringdatetransition""" + + ELEMENT_NAME = 'RecurringDateTransition' + + offset = TimeDeltaField(field_uri='TimeOffset') + month = IntegerField(field_uri='Month') + day = IntegerField(field_uri='Day') # Day of month + + +class Period(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/period""" + + ELEMENT_NAME = 'Period' + + id = CharField(field_uri='Id', is_attribute=True) + name = CharField(field_uri='Name', is_attribute=True) + bias = TimeDeltaField(field_uri='Bias', is_attribute=True) + + def _split_id(self): + to_year, to_type = self.id.rsplit('/', 1)[1].split('-') + return int(to_year), to_type + + @property + def year(self): + return self._split_id()[0] + + @property + def type(self): + return self._split_id()[1] + + @property + def bias_in_minutes(self): + return int(self.bias.total_seconds()) // 60 # Convert to minutes + + +class TransitionsGroup(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/transitionsgroup""" + + ELEMENT_NAME = 'TransitionsGroup' + + id = CharField(field_uri='Id', is_attribute=True) + transitions = TransitionListField(value_cls=BaseTransition) + + +class TimeZoneDefinition(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezonedefinition""" + + ELEMENT_NAME = 'TimeZoneDefinition' + + id = CharField(field_uri='Id', is_attribute=True) + name = CharField(field_uri='Name', is_attribute=True) + + periods = EWSElementListField(field_uri='Periods', value_cls=Period) + transitions_groups = EWSElementListField(field_uri='TransitionsGroups', value_cls=TransitionsGroup) + transitions = TransitionListField(field_uri='Transitions', value_cls=BaseTransition) + + @classmethod + def from_xml(cls, elem, account): + return super().from_xml(elem, account) + + def _get_standard_period(self, for_year): + # Look through periods and pick a relevant period according to the 'for_year' value + valid_period = None + for period in sorted(self.periods, key=lambda p: (p.year, p.type)): + if period.year > for_year: + break + if period.type != 'Standard': + continue + valid_period = period + if valid_period is None: + raise TimezoneDefinitionInvalidForYear(f'Year {for_year} not included in periods {self.periods}') + return valid_period + + def _get_transitions_group(self, for_year): + # Look through the transitions, and pick the relevant transition group according to the 'for_year' value + transitions_group = None + transitions_groups_map = {tg.id: tg for tg in self.transitions_groups} + for transition in sorted(self.transitions, key=lambda t: t.to): + if transition.kind != 'Group': + continue + if isinstance(transition, AbsoluteDateTransition) and transition.date.year > for_year: + break + transitions_group = transitions_groups_map[transition.to] + if transitions_group is None: + raise ValueError(f'No valid transition group for year {for_year}: {self.transitions}') + return transitions_group + + def get_std_and_dst(self, for_year): + # Return 'standard_time' and 'daylight_time' objects. We do unnecessary work here, but it keeps code simple. + transitions_group = self._get_transitions_group(for_year) + if not 0 <= len(transitions_group.transitions) <= 2: + raise ValueError(f'Expected 0-2 transitions in transitions group {transitions_group}') + + standard_period = self._get_standard_period(for_year) + periods_map = {p.id: p for p in self.periods} + standard_time, daylight_time = None, None + if len(transitions_group.transitions) == 1: + # This is a simple transition group representing a timezone with no DST. Some servers don't accept + # TimeZone elements without a STD and DST element (see issue #488). Return StandardTime and DaylightTime + # objects with dummy values and 0 bias - this satisfies the broken servers and hopefully doesn't break + # the well-behaving servers. + standard_time = StandardTime(bias=0, time=datetime.time(0), occurrence=1, iso_month=1, weekday=1) + daylight_time = DaylightTime(bias=0, time=datetime.time(0), occurrence=5, iso_month=12, weekday=7) + return standard_time, daylight_time, standard_period + for transition in transitions_group.transitions: + # 'offset' is the time of day to transition, as timedelta since midnight. Must be a reasonable value + if not datetime.timedelta(0) <= transition.offset < datetime.timedelta(days=1): + raise ValueError(f"'offset' value {transition['offset']} must be be between 0 and 24 hours") + transition_kwargs = dict( + time=(datetime.datetime(2000, 1, 1) + transition.offset).time(), + occurrence=transition.occurrence, + iso_month=transition.month, + weekday=transition.day_of_week, + ) + period = periods_map[transition.to] + if period.name == 'Standard': + transition_kwargs['bias'] = 0 + standard_time = StandardTime(**transition_kwargs) + continue + if period.name == 'Daylight': + transition_kwargs['bias'] = period.bias_in_minutes - standard_period.bias_in_minutes + daylight_time = DaylightTime(**transition_kwargs) + continue + raise ValueError(f'Unknown transition: {transition}') + return standard_time, daylight_time, standard_period diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index b7ab2a63..82a00680 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -475,7 +475,7 @@ def get_timezones(self, timezones=None, return_full_timezone_data=False): (Default value = None) :param return_full_timezone_data: If true, also returns periods and transitions (Default value = False) - :return: A list of (tz_id, name, periods, transitions) tuples + :return: A generator of TimeZoneDefinition objects """ return GetServerTimeZones(protocol=self).call( timezones=timezones, return_full_timezone_data=return_full_timezone_data @@ -511,17 +511,12 @@ def get_free_busy_info(self, accounts, start, end, merged_free_busy_interval=30, raise ValueError( f"'requested_view' value {requested_view!r} must be one of {FreeBusyViewOptions.REQUESTED_VIEWS}" ) - _, _, periods, transitions, transitions_groups = list(self.get_timezones( + tz_definition = list(self.get_timezones( timezones=[start.tzinfo], return_full_timezone_data=True ))[0] return GetUserAvailability(self).call( - timezone=TimeZone.from_server_timezone( - periods=periods, - transitions=transitions, - transitionsgroups=transitions_groups, - for_year=start.year - ), + timezone=TimeZone.from_server_timezone(tz_definition=tz_definition, for_year=start.year), mailbox_data=[MailboxData( email=account.primary_smtp_address if isinstance(account, Account) else account, attendee_type=attendee_type, diff --git a/exchangelib/services/get_server_time_zones.py b/exchangelib/services/get_server_time_zones.py index 1e9a314a..fadda221 100644 --- a/exchangelib/services/get_server_time_zones.py +++ b/exchangelib/services/get_server_time_zones.py @@ -1,10 +1,6 @@ -import datetime - from .common import EWSService -from ..errors import NaiveDateTimeNotAllowed -from ..ewsdatetime import EWSDateTime -from ..fields import WEEKDAY_NAMES -from ..util import create_element, set_xml_value, xml_text_to_value, peek, TNS, MNS +from ..properties import TimeZoneDefinition +from ..util import create_element, set_xml_value, peek, MNS from ..version import EXCHANGE_2010 @@ -39,76 +35,4 @@ def get_payload(self, timezones, return_full_timezone_data): return payload def _elem_to_obj(self, elem): - tz_id = elem.get('Id') - tz_name = elem.get('Name') - tz_periods = self._get_periods(elem) - tz_transitions_groups = self._get_transitions_groups(elem) - tz_transitions = self._get_transitions(elem) - return tz_id, tz_name, tz_periods, tz_transitions, tz_transitions_groups - - @staticmethod - def _get_periods(timezone_def): - tz_periods = {} - periods = timezone_def.find(f'{{{TNS}}}Periods') - for period in periods.findall(f'{{{TNS}}}Period'): - # Convert e.g. "trule:Microsoft/Registry/W. Europe Standard Time/2006-Daylight" to (2006, 'Daylight') - p_year, p_type = period.get('Id').rsplit('/', 1)[1].split('-') - tz_periods[(int(p_year), p_type)] = dict( - name=period.get('Name'), - bias=xml_text_to_value(period.get('Bias'), datetime.timedelta) - ) - return tz_periods - - @staticmethod - def _get_transitions_groups(timezone_def): - tz_transitions_groups = {} - transition_groups = timezone_def.find(f'{{{TNS}}}TransitionsGroups') - if transition_groups is not None: - for transition_group in transition_groups.findall(f'{{{TNS}}}TransitionsGroup'): - tg_id = int(transition_group.get('Id')) - tz_transitions_groups[tg_id] = [] - for transition in transition_group.findall(f'{{{TNS}}}Transition'): - # Apply same conversion to To as for period IDs - to_year, to_type = transition.find(f'{{{TNS}}}To').text.rsplit('/', 1)[1].split('-') - tz_transitions_groups[tg_id].append(dict( - to=(int(to_year), to_type), - )) - for transition in transition_group.findall(f'{{{TNS}}}RecurringDayTransition'): - # Apply same conversion to To as for period IDs - to_year, to_type = transition.find(f'{{{TNS}}}To').text.rsplit('/', 1)[1].split('-') - occurrence = xml_text_to_value(transition.find(f'{{{TNS}}}Occurrence').text, int) - if occurrence == -1: - # See TimeZoneTransition.from_xml() - occurrence = 5 - tz_transitions_groups[tg_id].append(dict( - to=(int(to_year), to_type), - offset=xml_text_to_value(transition.find(f'{{{TNS}}}TimeOffset').text, datetime.timedelta), - iso_month=xml_text_to_value(transition.find(f'{{{TNS}}}Month').text, int), - iso_weekday=WEEKDAY_NAMES.index(transition.find(f'{{{TNS}}}DayOfWeek').text) + 1, - occurrence=occurrence, - )) - return tz_transitions_groups - - @staticmethod - def _get_transitions(timezone_def): - tz_transitions = {} - transitions = timezone_def.find(f'{{{TNS}}}Transitions') - if transitions is not None: - for transition in transitions.findall(f'{{{TNS}}}Transition'): - to = transition.find(f'{{{TNS}}}To') - if to.get('Kind') != 'Group': - raise ValueError(f"Unexpected 'Kind' XML attr: {to.get('Kind')}") - tg_id = xml_text_to_value(to.text, int) - tz_transitions[tg_id] = None - for transition in transitions.findall(f'{{{TNS}}}AbsoluteDateTransition'): - to = transition.find(f'{{{TNS}}}To') - if to.get('Kind') != 'Group': - raise ValueError(f"Unexpected 'Kind' XML attr: {to.get('Kind')}") - tg_id = xml_text_to_value(to.text, int) - try: - t_date = xml_text_to_value(transition.find(f'{{{TNS}}}DateTime').text, EWSDateTime).date() - except NaiveDateTimeNotAllowed as e: - # We encountered a naive datetime. Don't worry. we just need the date - t_date = e.local_dt.date() - tz_transitions[tg_id] = t_date - return tz_transitions + return TimeZoneDefinition.from_xml(elem=elem, account=None) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 588c14ed..0c796be9 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -20,7 +20,7 @@ from exchangelib.properties import TimeZone, RoomList, FreeBusyView, AlternateId, ID_FORMATS, EWS_ID, \ SearchableMailbox, FailedMailbox, Mailbox, DLMailbox, ItemId from exchangelib.protocol import Protocol, BaseProtocol, NoVerifyHTTPAdapter, FailFast, close_connections -from exchangelib.services import GetServerTimeZones, GetRoomLists, GetRooms, ResolveNames, GetSearchableMailboxes, \ +from exchangelib.services import GetRoomLists, GetRooms, ResolveNames, GetSearchableMailboxes, \ SetUserOofSettings, ExpandDL from exchangelib.settings import OofSettings from exchangelib.transport import NOAUTH, NTLM @@ -159,18 +159,18 @@ def test_decrease_poolsize(self): self.assertEqual(protocol._session_pool.qsize(), 1) def test_get_timezones(self): - ws = GetServerTimeZones(self.account.protocol) - data = ws.call() - self.assertAlmostEqual(len(list(data)), 130, delta=30, msg=data) # Test shortcut + data = list(self.account.protocol.get_timezones()) self.assertAlmostEqual(len(list(self.account.protocol.get_timezones())), 130, delta=30, msg=data) # Test translation to TimeZone objects - for _, _, periods, transitions, transitionsgroups in self.account.protocol.get_timezones( + for tz_definition in self.account.protocol.get_timezones( return_full_timezone_data=True): try: - TimeZone.from_server_timezone( - periods=periods, transitions=transitions, transitionsgroups=transitionsgroups, for_year=2018, + tz = TimeZone.from_server_timezone( + tz_definition=tz_definition, + for_year=2018, ) + self.assertEqual(tz.bias, tz_definition.get_std_and_dst(for_year=2018)[2].bias_in_minutes) except TimezoneDefinitionInvalidForYear: pass From 10403c83da86c78ff2a30d4bc481244bcac17d2b Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 5 Jan 2022 00:20:46 +0100 Subject: [PATCH 075/509] Use standard validation instead --- exchangelib/fields.py | 13 +++++++++++++ exchangelib/properties.py | 5 ++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index aeff031b..7fe96be0 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -644,6 +644,19 @@ class TimeDeltaField(FieldURIField): value_cls = datetime.timedelta + def __init__(self, *args, **kwargs): + self.min = kwargs.pop('min', datetime.timedelta(0)) + self.max = kwargs.pop('max', datetime.timedelta(days=1)) + super().__init__(*args, **kwargs) + + def clean(self, value, version=None): + if self.min is not None and value < self.min: + raise ValueError( + f"Value {value!r} on field {self.name!r} must be greater than {self.min}") + if self.max is not None and value > self.max: + raise ValueError(f"Value {value!r} on field {self.name!r} must be less than {self.max}") + return super().clean(value, version=version) + class DateTimeField(FieldURIField): """A field that handles datetime values.""" diff --git a/exchangelib/properties.py b/exchangelib/properties.py index de8f3a51..fd8787ec 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -1872,9 +1872,8 @@ def get_std_and_dst(self, for_year): daylight_time = DaylightTime(bias=0, time=datetime.time(0), occurrence=5, iso_month=12, weekday=7) return standard_time, daylight_time, standard_period for transition in transitions_group.transitions: - # 'offset' is the time of day to transition, as timedelta since midnight. Must be a reasonable value - if not datetime.timedelta(0) <= transition.offset < datetime.timedelta(days=1): - raise ValueError(f"'offset' value {transition['offset']} must be be between 0 and 24 hours") + # 'offset' is the time of day to transition, as timedelta since midnight. Check that it's a reasonable value + transition.clean(version=None) transition_kwargs = dict( time=(datetime.datetime(2000, 1, 1) + transition.offset).time(), occurrence=transition.occurrence, From 697cdd7fcbe70237c8e31691e1e295f426ab625d Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 5 Jan 2022 00:26:41 +0100 Subject: [PATCH 076/509] Coverage of boundary __str__ --- exchangelib/recurrence.py | 2 +- tests/test_recurrence.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/exchangelib/recurrence.py b/exchangelib/recurrence.py index 23e45d5a..e6192a46 100644 --- a/exchangelib/recurrence.py +++ b/exchangelib/recurrence.py @@ -224,7 +224,7 @@ class NumberedPattern(Boundary): number = IntegerField(field_uri='NumberOfOccurrences', min=1, max=999, is_required=True) def __str__(self): - return f'Starts on {self.start} and occurs {self.number} times' + return f'Starts on {self.start} and occurs {self.number} time(s)' class Occurrence(IdChangeKeyMixIn): diff --git a/tests/test_recurrence.py b/tests/test_recurrence.py index 91a5fcd4..56f5540b 100644 --- a/tests/test_recurrence.py +++ b/tests/test_recurrence.py @@ -39,6 +39,15 @@ def test_magic(self): pattern = DailyRegeneration(interval=6) self.assertEqual(str(pattern), 'Regenerates every 6 day(s)') + d_start = datetime.date(2017, 9, 1) + d_end = datetime.date(2017, 9, 7) + boundary = NoEndPattern(start=d_start) + self.assertEqual(str(boundary), 'Starts on 2017-09-01') + boundary = EndDatePattern(start=d_start, end=d_end) + self.assertEqual(str(boundary), 'Starts on 2017-09-01, ends on 2017-09-07') + boundary = NumberedPattern(start=d_start, number=1) + self.assertEqual(str(boundary), 'Starts on 2017-09-01 and occurs 1 time(s)') + def test_validation(self): p = DailyPattern(interval=3) d_start = datetime.date(2017, 9, 1) From 4333605476098aee32d1377cba88ff154d349fd4 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 5 Jan 2022 18:21:22 +0100 Subject: [PATCH 077/509] Test SOAP error handling for a service that uses DocumentYielder --- exchangelib/services/common.py | 2 +- exchangelib/services/get_streaming_events.py | 2 +- exchangelib/util.py | 4 ++-- tests/test_items/test_sync.py | 14 ++++++++++++-- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 70b2a942..7a547276 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -158,7 +158,7 @@ def get(self, expect_result=True, **kwargs): def parse(self, xml): """Used mostly for testing, when we want to parse static XML data.""" - resp = DummyResponse(url=None, headers=None, request_headers=None, content=xml) + resp = DummyResponse(url=None, headers=None, request_headers=None, content=xml, streaming=self.streaming) _, body = self._get_soap_parts(response=resp) return self._elems_to_objs(self._get_elements_in_response(response=self._get_soap_messages(body=body))) diff --git a/exchangelib/services/get_streaming_events.py b/exchangelib/services/get_streaming_events.py index be529988..2c110864 100644 --- a/exchangelib/services/get_streaming_events.py +++ b/exchangelib/services/get_streaming_events.py @@ -49,7 +49,7 @@ def _get_soap_messages(self, body, **parse_opts): # XML response. r = body for i, doc in enumerate(DocumentYielder(r.iter_content()), start=1): - xml_log.debug('''Response XML (docs received: %(i)s): %(xml_response)s''', dict(i=i, xml_response=doc)) + xml_log.debug('''Response XML (docs counter: %(i)s): %(xml_response)s''', dict(i=i, xml_response=doc)) response = DummyResponse(url=None, headers=None, request_headers=None, content=doc) try: _, body = super()._get_soap_parts(response=response, **parse_opts) diff --git a/exchangelib/util.py b/exchangelib/util.py index 259e8817..b9eec736 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -628,11 +628,11 @@ def __init__(self, headers): class DummyResponse: """A class to fake a requests Response object for functions that expect this.""" - def __init__(self, url, headers, request_headers, content=b'', status_code=503, history=None): + def __init__(self, url, headers, request_headers, content=b'', status_code=503, streaming=False, history=None): self.status_code = status_code self.url = url self.headers = headers - self.content = content + self.content = iter((bytes([b]) for b in content)) if streaming else content self.text = content.decode('utf-8', errors='ignore') self.request = DummyRequest(headers=request_headers) self.history = history diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index 18c69b05..0e34b6af 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -1,10 +1,10 @@ import time -from exchangelib.errors import ErrorInvalidSubscription, ErrorSubscriptionNotFound +from exchangelib.errors import ErrorInvalidSubscription, ErrorSubscriptionNotFound, MalformedResponseError from exchangelib.folders import Inbox from exchangelib.items import Message from exchangelib.properties import StatusEvent, CreatedEvent, ModifiedEvent, DeletedEvent, Notification, ItemId -from exchangelib.services import SendNotification, SubscribeToPull +from exchangelib.services import SendNotification, SubscribeToPull, GetStreamingEvents from exchangelib.util import PrettyXmlHandler from .test_basics import BaseItemTest @@ -389,3 +389,13 @@ def test_push_message_responses(self): ''' ) + + def test_get_streaming_events_exceptions(self): + # Test special error handling in this service. It's almost impossible to trigger a ParseError through the + # DocumentYielder, so we test with a SOAP message without a body element. + xml = b'''\ + + +''' + with self.assertRaises(MalformedResponseError): + list(GetStreamingEvents(account=self.account).parse(xml)) From 298e8fc17a03c349e820c95f57409c5daf31bc32 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 5 Jan 2022 19:32:25 +0100 Subject: [PATCH 078/509] Improve robustness of DocumentYielder and add tests --- exchangelib/util.py | 28 ++++++++++++++++++++-------- tests/test_util.py | 41 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/exchangelib/util.py b/exchangelib/util.py index b9eec736..b101c7b7 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -442,10 +442,12 @@ class DocumentYielder: def __init__(self, content_iterator, document_tag='Envelope'): self._iterator = content_iterator - self._start_token = f'<{document_tag}'.encode('utf-8') - self._end_token = f'/{document_tag}>'.encode('utf-8') + self._document_tag = document_tag.encode() - def get_tag(self, stop_byte): + def _get_tag(self): + """Iterate over the bytes until we have a full tag in the buffer. If there's a '>' in an attr value, then we'll + exit on that, but it's OK becaus wejust need the plain tag name later. + """ tag_buffer = [b'<'] while True: try: @@ -453,10 +455,20 @@ def get_tag(self, stop_byte): except StopIteration: break tag_buffer.append(c) - if c == stop_byte: + if c == b'>': break return b''.join(tag_buffer) + @staticmethod + def _normalize_tag(tag): + """Returns the plain tag name given a range of tag formats: + * + * + * + * + """ + return tag.strip(b'<>/').split(b' ')[0].split(b':')[-1] + def __iter__(self): """Consumes the content iterator, looking for start and end tags. Returns each document when we have fully collected it. @@ -467,15 +479,15 @@ def __iter__(self): while True: c = next(self._iterator) if not doc_started and c == b'<': - tag = self.get_tag(stop_byte=b' ') - if tag.startswith(self._start_token): + tag = self._get_tag() + if self._normalize_tag(tag) == self._document_tag: # Start of document. Collect bytes from this point buffer.append(tag) doc_started = True elif doc_started and c == b'<': - tag = self.get_tag(stop_byte=b'>') + tag = self._get_tag() buffer.append(tag) - if tag.endswith(self._end_token): + if self._normalize_tag(tag) == self._document_tag: # End of document. Yield a valid document and reset the buffer yield b"\n" + b''.join(buffer) doc_started = False diff --git a/tests/test_util.py b/tests/test_util.py index 18521144..5d3716f9 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -10,7 +10,7 @@ from exchangelib.protocol import FailFast, FaultTolerance import exchangelib.util from exchangelib.util import chunkify, peek, get_redirect_url, get_domain, PrettyXmlHandler, to_xml, BOM_UTF8, \ - ParseError, post_ratelimited, safe_b64decode, CONNECTION_ERRORS + ParseError, post_ratelimited, safe_b64decode, CONNECTION_ERRORS, DocumentYielder from .common import EWSTest, mock_post, mock_session_exception @@ -275,3 +275,42 @@ def test_safe_b64decode(self): self.assertEqual(safe_b64decode(b'SGVsbG8gd29ybGQ='), b'Hello world') # Test incorrectly padded binary data self.assertEqual(safe_b64decode(b'SGVsbG8gd29ybGQ'), b'Hello world') + + def test_document_yielder(self): + self.assertListEqual( + list(DocumentYielder(_bytes_to_iter(b'a'), 'b')), + [b"\na"] + ) + self.assertListEqual( + list(DocumentYielder(_bytes_to_iter(b'acb'), 'b')), + [ + b"\na", + b"\nc", + b"\nb", + ] + ) + self.assertListEqual( + list(DocumentYielder(_bytes_to_iter(b''), 'XXX')), + [b"\n"] + ) + self.assertListEqual( + list(DocumentYielder(_bytes_to_iter(b''), 'XXX')), + [b"\n"] + ) + self.assertListEqual( + list(DocumentYielder(_bytes_to_iter(b""), 'XXX')), + [b"\n"] + ) + self.assertListEqual( + list(DocumentYielder(_bytes_to_iter(b""), 'XXX')), + [b"\n"] + ) + # Test 'dangerous' chars in attr values + self.assertListEqual( + list(DocumentYielder(_bytes_to_iter(b""), 'XXX')), + [b"\n"] + ) + + +def _bytes_to_iter(content): + return iter((bytes([b]) for b in content)) From c360d2121de76b8e972fb0b2d8ae46654144150c Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 5 Jan 2022 19:33:31 +0100 Subject: [PATCH 079/509] Improve formatting of XML --- tests/test_items/test_sync.py | 52 +++++++++++++++++------------------ 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index 0e34b6af..a82d9f56 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -317,33 +317,33 @@ def test_streaming_invalid_subscription(self): def test_push_message_parsing(self): xml = b'''\ - - + + - - - + + + - - - NoError - - XXXXX= - AAAAA= - false - - BBBBB= - - - - - - - ''' + + + NoError + + XXXXX= + AAAAA= + false + + BBBBB= + + + + + + +''' ws = SendNotification(protocol=None) self.assertListEqual( list(ws.parse(xml)), @@ -395,7 +395,7 @@ def test_get_streaming_events_exceptions(self): # DocumentYielder, so we test with a SOAP message without a body element. xml = b'''\ - -''' + +''' with self.assertRaises(MalformedResponseError): list(GetStreamingEvents(account=self.account).parse(xml)) From e1ea8934a2769415b7db2d26a7a59be79594420e Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 5 Jan 2022 19:33:51 +0100 Subject: [PATCH 080/509] Add coverage of ConnectionStatus handling --- tests/test_items/test_sync.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index a82d9f56..61088cbb 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -390,7 +390,30 @@ def test_push_message_responses(self): ''' ) - def test_get_streaming_events_exceptions(self): + def test_get_streaming_events_connection_closed(self): + # Test that we respect connection status + xml = b'''\ + + + + + + + NoError + Closed + + + + +''' + ws = GetStreamingEvents(account=self.account) + self.assertEqual(ws.connection_status, None) + list(ws.parse(xml)) + self.assertEqual(ws.connection_status, ws.CLOSED) + + def test_get_streaming_events_bad_response(self): # Test special error handling in this service. It's almost impossible to trigger a ParseError through the # DocumentYielder, so we test with a SOAP message without a body element. xml = b'''\ From f9d5abe9818e5dc5f74ef546c2c53c1306fcbfcd Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 5 Jan 2022 19:40:45 +0100 Subject: [PATCH 081/509] Better concept for default auth types --- exchangelib/configuration.py | 14 ++++++++++---- tests/test_configuration.py | 3 +++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/exchangelib/configuration.py b/exchangelib/configuration.py index 883da80d..ffe79160 100644 --- a/exchangelib/configuration.py +++ b/exchangelib/configuration.py @@ -2,7 +2,7 @@ from cached_property import threaded_cached_property -from .credentials import BaseCredentials, OAuth2Credentials +from .credentials import BaseCredentials, OAuth2Credentials, OAuth2AuthorizationCodeCredentials from .protocol import RetryPolicy, FailFast from .transport import AUTH_TYPE_MAP, OAUTH2, CREDENTIALS_REQUIRED from .util import split_url @@ -10,6 +10,12 @@ log = logging.getLogger(__name__) +DEFAULT_AUTH_TYPE = { + # This type of credentials *must* use the OAuth auth type + OAuth2Credentials: OAUTH2, + OAuth2AuthorizationCodeCredentials: OAUTH2, +} + class Configuration: """Contains information needed to create an authenticated connection to an EWS endpoint. @@ -43,9 +49,9 @@ def __init__(self, credentials=None, server=None, service_endpoint=None, auth_ty retry_policy=None, max_connections=None): if not isinstance(credentials, (BaseCredentials, type(None))): raise ValueError(f"'credentials' {credentials!r} must be a Credentials instance") - if isinstance(credentials, OAuth2Credentials) and auth_type is None: - # This type of credentials *must* use the OAuth auth type - auth_type = OAUTH2 + if auth_type is None: + # Set a default auth type for the credentials where this makes sense + auth_type = DEFAULT_AUTH_TYPE.get(type(credentials)) elif credentials is None and auth_type in CREDENTIALS_REQUIRED: raise ValueError(f'Auth type {auth_type!r} was detected but no credentials were provided') if server and service_endpoint: diff --git a/tests/test_configuration.py b/tests/test_configuration.py index be8f4369..da7cb954 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -18,6 +18,9 @@ def test_init(self): with self.assertRaises(ValueError) as e: Configuration(credentials='foo') self.assertEqual(e.exception.args[0], "'credentials' 'foo' must be a Credentials instance") + with self.assertRaises(ValueError) as e: + Configuration(credentials=None, auth_type=NTLM) + self.assertEqual(e.exception.args[0], "Auth type 'NTLM' was detected but no credentials were provided") with self.assertRaises(AttributeError) as e: Configuration(server='foo', service_endpoint='bar') self.assertEqual(e.exception.args[0], "Only one of 'server' or 'service_endpoint' must be provided") From 37b198bbb63f48b7ffcd8ccd59c96929b162c94f Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 5 Jan 2022 20:38:30 +0100 Subject: [PATCH 082/509] Test version compat check --- exchangelib/services/find_folder.py | 2 +- tests/test_folder.py | 11 +++++++++++ tests/test_protocol.py | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/exchangelib/services/find_folder.py b/exchangelib/services/find_folder.py index 873b9375..d9659d22 100644 --- a/exchangelib/services/find_folder.py +++ b/exchangelib/services/find_folder.py @@ -63,7 +63,7 @@ def get_payload(self, folders, additional_fields, restriction, shape, depth, pag payload.append(indexed_page_folder_view) else: if offset != 0: - raise ValueError('Offsets are only supported from Exchange 2010') + raise NotImplementedError("'offset' is only supported for Exchange 2010 servers and later") if restriction: payload.append(restriction.to_xml(version=self.account.version)) payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds')) diff --git a/tests/test_folder.py b/tests/test_folder.py index 7da2ee86..0dfbf448 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -12,6 +12,7 @@ from exchangelib.properties import Mailbox, InvalidField, EffectiveRights, PermissionSet, CalendarPermission, UserId from exchangelib.queryset import Q from exchangelib.services import GetFolder +from exchangelib.version import EXCHANGE_2007 from .common import EWSTest, get_random_string, get_random_int, get_random_bool, get_random_datetime, get_random_bytes,\ get_random_byte @@ -77,6 +78,16 @@ def test_find_folders(self): folders = list(FolderCollection(account=self.account, folders=[self.account.root]).find_folders()) self.assertGreater(len(folders), 40, sorted(f.name for f in folders)) + def test_find_folders_compat(self): + with self.assertRaises(NotImplementedError) as e: + coll = FolderCollection(account=self.account, folders=[self.account.root]) + self.account.protocol.version.build = EXCHANGE_2007 # Need to set it after the last auto-config of version + list(coll.find_folders(offset=1)) + self.assertEqual( + e.exception.args[0], + "'offset' is only supported for Exchange 2010 servers and later" + ) + def test_find_folders_with_restriction(self): # Exact match folders = list(FolderCollection(account=self.account, folders=[self.account.root]) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 0c796be9..42f6790e 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -316,7 +316,7 @@ def test_resolvenames(self): self.account.protocol.resolve_names(names=['xxx@example.com'], shape='IdOnly') self.assertEqual( e.exception.args[0], - f"'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later" + "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later" ) self.assertGreaterEqual( self.account.protocol.resolve_names(names=['xxx@example.com']), From cad80944b31b716bb1a53f9120a24bee16cd3db5 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 5 Jan 2022 20:50:24 +0100 Subject: [PATCH 083/509] Test __del__ methods --- tests/test_autodiscover.py | 11 ++++++++++- tests/test_protocol.py | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index b855a9a5..f03ea716 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -1,6 +1,7 @@ from collections import namedtuple import glob from types import MethodType +from unittest.mock import Mock import dns import requests_mock @@ -9,7 +10,7 @@ from exchangelib.credentials import Credentials, DELEGATE import exchangelib.autodiscover.discovery from exchangelib.autodiscover import close_connections, clear_cache, autodiscover_cache, AutodiscoverProtocol, \ - Autodiscovery + Autodiscovery, AutodiscoverCache from exchangelib.autodiscover.properties import Autodiscover from exchangelib.configuration import Configuration from exchangelib.errors import ErrorNonExistentMailbox, AutoDiscoverCircularRedirect, AutoDiscoverFailed @@ -593,3 +594,11 @@ def test_parse_response(self): ''' with self.assertRaises(ValueError): Autodiscover.from_bytes(xml).response.ews_url + + def test_del_on_error(self): + # Test that __del__ can handle exceptions on close() + cache = AutodiscoverCache() + cache.close = Mock(side_effect=Exception('XXX')) + with self.assertRaises(Exception): + cache.close() + del cache diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 42f6790e..dde47c09 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -3,6 +3,7 @@ import pickle import socket import tempfile +from unittest.mock import Mock import warnings try: import zoneinfo @@ -694,3 +695,16 @@ def test_disable_ssl_verification(self): os.environ.pop('REQUESTS_CA_BUNDLE', None) # May already have been deleted BaseProtocol.HTTP_ADAPTER_CLS = default_adapter_cls self.account.protocol.credentials = self.account.protocol.credentials + + def test_del_on_error(self): + # Test that __del__ can handle exceptions on close() + protocol = Protocol(config=Configuration( + service_endpoint='http://httpbin.org', + credentials=Credentials(get_random_string(8), get_random_string(8)), + auth_type=NOAUTH, version=Version(Build(15, 1)), retry_policy=FailFast(), + max_connections=3 + )) + protocol.close = Mock(side_effect=Exception('XXX')) + with self.assertRaises(Exception): + protocol.close() + del protocol From 30d8ed8357dc0ee4b5dc6dc8b30af449d06a43ab Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 5 Jan 2022 20:52:42 +0100 Subject: [PATCH 084/509] Test helper --- tests/test_protocol.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index dde47c09..ceb77d4d 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -13,6 +13,7 @@ import psutil import requests_mock +from exchangelib import close_connections from exchangelib.credentials import Credentials from exchangelib.configuration import Configuration from exchangelib.items import CalendarItem, SEARCH_SCOPE_CHOICES @@ -33,6 +34,10 @@ class ProtocolTest(EWSTest): + def test_close_connections_helper(self): + # Just test that it doesn't break + close_connections() + def test_pickle(self): # Test that we can pickle, repr and str Protocols o = Protocol(config=Configuration( From 20a78c6be82dce900aec2c0e0b92e9e3e3083df3 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 5 Jan 2022 22:06:36 +0100 Subject: [PATCH 085/509] Fix global state change --- tests/test_autodiscover.py | 12 ++++++++---- tests/test_protocol.py | 14 +++++++++----- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index f03ea716..516eb669 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -598,7 +598,11 @@ def test_parse_response(self): def test_del_on_error(self): # Test that __del__ can handle exceptions on close() cache = AutodiscoverCache() - cache.close = Mock(side_effect=Exception('XXX')) - with self.assertRaises(Exception): - cache.close() - del cache + tmp = AutodiscoverCache.close + try: + AutodiscoverCache.close = Mock(side_effect=Exception('XXX')) + with self.assertRaises(Exception): + cache.close() + del cache + finally: + AutodiscoverCache.close = tmp diff --git a/tests/test_protocol.py b/tests/test_protocol.py index ceb77d4d..72e8991a 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -704,12 +704,16 @@ def test_disable_ssl_verification(self): def test_del_on_error(self): # Test that __del__ can handle exceptions on close() protocol = Protocol(config=Configuration( - service_endpoint='http://httpbin.org', + service_endpoint='http://foo.example.org', credentials=Credentials(get_random_string(8), get_random_string(8)), auth_type=NOAUTH, version=Version(Build(15, 1)), retry_policy=FailFast(), max_connections=3 )) - protocol.close = Mock(side_effect=Exception('XXX')) - with self.assertRaises(Exception): - protocol.close() - del protocol + tmp = Protocol.close + try: + Protocol.close = Mock(side_effect=Exception('XXX')) + with self.assertRaises(Exception): + protocol.close() + del protocol + finally: + Protocol.close = tmp From 4c33a399956ff54b0321708dc6700cb24bb4948a Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 5 Jan 2022 22:07:10 +0100 Subject: [PATCH 086/509] Test Version.guess() --- exchangelib/version.py | 2 +- tests/test_protocol.py | 59 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/exchangelib/version.py b/exchangelib/version.py index c6dad149..8c7162b5 100644 --- a/exchangelib/version.py +++ b/exchangelib/version.py @@ -235,7 +235,7 @@ def guess(cls, protocol, api_version_hint=None): raise TransportError(f'No valid version headers found in response ({e!r})') if not protocol.config.version.build: raise TransportError('No valid version headers found in response') - return protocol.version + return protocol.config.version @staticmethod def _is_invalid_version_string(version): diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 72e8991a..3d958a51 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -717,3 +717,62 @@ def test_del_on_error(self): del protocol finally: Protocol.close = tmp + + @requests_mock.mock() + def test_version_guess(self, m): + protocol = Protocol(config=Configuration( + service_endpoint='https://example.com/EWS/Exchange.asmx', + credentials=Credentials(get_random_string(8), get_random_string(8)), + auth_type=NTLM, version=Version(Build(15, 1)), retry_policy=FailFast() + )) + # Test that we can get the version even on error responses + m.post('https://example.com/EWS/Exchange.asmx', status_code=200, content=b'''\ + + + + + + + + + + Multiple results were found. + ErrorNameResolutionMultipleResults + 0 + + + + +''') + Version.guess(protocol) + self.assertEqual(protocol.version.build, Build(15, 1, 2345, 6789)) + + # Test exception when there are no version headers + m.post('https://example.com/EWS/Exchange.asmx', status_code=200, content=b'''\ + + + + + + + + + . + ErrorNameResolutionMultipleResults + 0 + + + + +''') + with self.assertRaises(TransportError) as e: + Version.guess(protocol) + self.assertEqual( + e.exception.args[0], + "No valid version headers found in response (ErrorNameResolutionMultipleResults('.'))" + ) From 2d7b48d39f4cc903fed6a958ff65e14a518634d6 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 5 Jan 2022 22:10:39 +0100 Subject: [PATCH 087/509] Test hashing of Build --- tests/test_build.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_build.py b/tests/test_build.py index 20f793be..5317f96a 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -8,6 +8,7 @@ def test_magic(self): with self.assertRaises(ValueError): Build(7, 0) self.assertEqual(str(Build(9, 8, 7, 6)), '9.8.7.6') + hash(Build(9, 8, 7, 6)) def test_compare(self): self.assertEqual(Build(15, 0, 1, 2), Build(15, 0, 1, 2)) From 8d763b238727909f05eb72d96f4dbb8cda9c75b6 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 5 Jan 2022 22:20:08 +0100 Subject: [PATCH 088/509] Test shelve_filename failure --- tests/test_autodiscover.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 516eb669..c7749316 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -1,7 +1,8 @@ from collections import namedtuple import glob +import sys from types import MethodType -from unittest.mock import Mock +from unittest.mock import Mock, patch import dns import requests_mock @@ -11,6 +12,7 @@ import exchangelib.autodiscover.discovery from exchangelib.autodiscover import close_connections, clear_cache, autodiscover_cache, AutodiscoverProtocol, \ Autodiscovery, AutodiscoverCache +from exchangelib.autodiscover.cache import shelve_filename from exchangelib.autodiscover.properties import Autodiscover from exchangelib.configuration import Configuration from exchangelib.errors import ErrorNonExistentMailbox, AutoDiscoverCircularRedirect, AutoDiscoverFailed @@ -606,3 +608,13 @@ def test_del_on_error(self): del cache finally: AutodiscoverCache.close = tmp + + def test_shelve_filename(self): + major, minor = sys.version_info[:2] + self.assertEqual(shelve_filename(), f'exchangelib.2.cache.erik.py{major}{minor}') + + @patch('getpass.getuser', side_effect=KeyError()) + def test_shelve_filename_getuser_failure(self, m): + # Test that shelve_filename can handle a failing getuser() + major, minor = sys.version_info[:2] + self.assertEqual(shelve_filename(), f'exchangelib.2.cache.exchangelib.py{major}{minor}') From 0087ecf0cdcd6cb317cad0ed219a5c95356d4fb2 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 5 Jan 2022 22:32:15 +0100 Subject: [PATCH 089/509] Make test independent of actual user --- tests/test_autodiscover.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index c7749316..a6904518 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -1,4 +1,5 @@ from collections import namedtuple +import getpass import glob import sys from types import MethodType @@ -611,7 +612,7 @@ def test_del_on_error(self): def test_shelve_filename(self): major, minor = sys.version_info[:2] - self.assertEqual(shelve_filename(), f'exchangelib.2.cache.erik.py{major}{minor}') + self.assertEqual(shelve_filename(), f'exchangelib.2.cache.{getpass.getuser()}.py{major}{minor}') @patch('getpass.getuser', side_effect=KeyError()) def test_shelve_filename_getuser_failure(self, m): From f021bc28e2f22b4a16319134ae866e9e4875fffa Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 5 Jan 2022 23:22:20 +0100 Subject: [PATCH 090/509] Remove unreachable code paths, and clean up --- exchangelib/__init__.py | 1 + exchangelib/folders/queryset.py | 16 ++++------------ exchangelib/items/message.py | 4 +--- tests/test_protocol.py | 2 +- 4 files changed, 7 insertions(+), 16 deletions(-) diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index 49d2ae0a..2f60dc98 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -36,6 +36,7 @@ 'Q', 'BASIC', 'DIGEST', 'NTLM', 'GSSAPI', 'SSPI', 'OAUTH2', 'CBA', 'Build', 'Version', + 'close_connections', ] # Set a default user agent, e.g. "exchangelib/3.1.1 (python-requests/2.22.0)" diff --git a/exchangelib/folders/queryset.py b/exchangelib/folders/queryset.py index 51cc9053..209ee949 100644 --- a/exchangelib/folders/queryset.py +++ b/exchangelib/folders/queryset.py @@ -123,6 +123,9 @@ def _query(self): # Fetch all properties for the found folders resolveable_folders = [] for f in folders: + if isinstance(f, Exception): + yield f + continue if not f.get_folder_allowed: log.debug('GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder', f) yield f @@ -134,9 +137,6 @@ def _query(self): account=self.folder_collection.account, folders=resolveable_folders ).get_folders(additional_fields=complex_fields) for f, complex_f in zip(resolveable_folders, complex_folders): - if isinstance(f, Exception): - yield f - continue if isinstance(complex_f, Exception): yield complex_f continue @@ -161,12 +161,4 @@ def _copy_cls(self): return self.__class__(account=self.folder_collection.account, folder=self.folder_collection.folders[0]) def resolve(self): - folders = list(self.folder_collection.resolve()) - if not folders: - raise DoesNotExist('Could not find a folder matching the query') - if len(folders) != 1: - raise MultipleObjectsReturned(f'Expected result length 1, but got {folders}') - f = folders[0] - if isinstance(f, Exception): - raise f - return f + return list(self.folder_collection.resolve())[0] diff --git a/exchangelib/items/message.py b/exchangelib/items/message.py index 51ae2ab0..a2a5b537 100644 --- a/exchangelib/items/message.py +++ b/exchangelib/items/message.py @@ -95,12 +95,10 @@ def send_and_save(self, update_fields=None, conflict_resolution=AUTO_RESOLVE, self.send(save_copy=False, conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations) else: - res = self._create( + self._create( message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations ) - if res is not True: - raise ValueError('Unexpected response in send-only mode') @require_id def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 3d958a51..a211633b 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -94,7 +94,7 @@ def test_protocol_instance_caching(self, m): p = Protocol(config=config) self.assertNotEqual(base_p, p) - close_connections() # Also clears cache + Protocol.clear_cache() def test_close(self): # Don't use example.com here - it does not resolve or answer on all ISPs From d892b894f02cc706ea6e3570a8a584e6021b9df0 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 6 Jan 2022 00:27:26 +0100 Subject: [PATCH 091/509] Test corner cases of folders and item updates --- exchangelib/properties.py | 2 ++ tests/test_folder.py | 26 ++++++++++++++++++++++++++ tests/test_items/test_calendaritems.py | 19 +++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/exchangelib/properties.py b/exchangelib/properties.py index fd8787ec..6ae2974f 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -1416,6 +1416,8 @@ def id_from_xml(cls, elem): return id_elem.get(cls.ID_ELEMENT_CLS.ID_ATTR), id_elem.get(cls.ID_ELEMENT_CLS.CHANGEKEY_ATTR) def to_id(self): + if self._id is None: + raise ValueError('Must have an ID') return self._id def __eq__(self, other): diff --git a/tests/test_folder.py b/tests/test_folder.py index 0dfbf448..7efb64b1 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -78,6 +78,12 @@ def test_find_folders(self): folders = list(FolderCollection(account=self.account, folders=[self.account.root]).find_folders()) self.assertGreater(len(folders), 40, sorted(f.name for f in folders)) + def test_find_folders_multiple_roots(self): + coll = FolderCollection(account=self.account, folders=[self.account.root, self.account.public_folders_root]) + with self.assertRaises(ValueError) as e: + list(coll.find_folders(depth='Shallow')) + self.assertIn('FindFolder must be called with folders in the same root hierarchy', e.exception.args[0]) + def test_find_folders_compat(self): with self.assertRaises(NotImplementedError) as e: coll = FolderCollection(account=self.account, folders=[self.account.root]) @@ -298,6 +304,13 @@ def test_parent(self): self.account.calendar.parent.parent.name, 'root' ) + # Setters + parent = self.account.calendar.parent + with self.assertRaises(ValueError) as e: + self.account.calendar.parent = 'XXX' + self.assertEqual(e.exception.args[0], "'value' 'XXX' must be a Folder instance") + self.account.calendar.parent = None + self.account.calendar.parent = parent def test_children(self): self.assertIn( @@ -332,6 +345,9 @@ def test_glob(self): self.assertGreaterEqual(len(list(self.account.contacts.glob('../*'))), 5) self.assertEqual(len(list(self.account.root.glob(f'**/{self.account.contacts.name}'))), 1) self.assertEqual(len(list(self.account.root.glob(f'Top of*/{self.account.contacts.name}'))), 1) + with self.assertRaises(ValueError) as e: + list(self.account.root.glob('../*')) + self.assertEqual(e.exception.args[0], 'Already at top') def test_collection_filtering(self): self.assertGreaterEqual(self.account.root.tois.children.all().count(), 0) @@ -358,6 +374,10 @@ def test_div_navigation(self): (self.account.root / '.').id, self.account.root.id ) + with self.assertRaises(ValueError) as e: + _ = self.account.root / '..' + self.assertEqual(e.exception.args[0], 'Already at top') + def test_double_div_navigation(self): self.account.root.clear_cache() # Clear the cache @@ -445,11 +465,17 @@ def test_create_update_empty_delete(self): f.wipe() self.assertEqual(len(list(f.children)), 0) + item_id, changekey = f.id, f.changekey f.delete() with self.assertRaises(ValueError): # No longer has an ID f.refresh() + with self.assertRaises(ErrorItemNotFound): + f.id, f.changekey = item_id, changekey + # Invalid ID + f.save() + # Delete all subfolders of inbox for c in self.account.inbox.children: c.delete() diff --git a/tests/test_items/test_calendaritems.py b/tests/test_items/test_calendaritems.py index cc2cbfdd..b6937226 100644 --- a/tests/test_items/test_calendaritems.py +++ b/tests/test_items/test_calendaritems.py @@ -482,3 +482,22 @@ def test_get_master_recurrence(self): master_item.delete() # Item is gone from the server, so this should fail with self.assertRaises(ErrorItemNotFound): third_occurrence.delete() # Item is gone from the server, so this should fail + + def test_invalid_updateitem_items(self): + # Test here because CalendarItem is the only item that has a requiref field with no default + item = self.get_test_item().save() + with self.assertRaises(ValueError) as e: + self.account.bulk_update([(item, [])]) + self.assertEqual(e.exception.args[0], "'fieldnames' must not be empty") + + start = item.start + item.start = None + with self.assertRaises(ValueError) as e: + self.account.bulk_update([(item, ['start'])]) + self.assertEqual(e.exception.args[0], "'start' is a required field with no default") + + item.start = start + item.is_meeting = None + with self.assertRaises(ValueError) as e: + self.account.bulk_update([(item, ['is_meeting'])]) + self.assertEqual(e.exception.args[0], "'is_meeting' is a read-only field") From e9bf1026a066fd2b5e44b9890eee6cfd43ec3c33 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 6 Jan 2022 00:34:43 +0100 Subject: [PATCH 092/509] Test invalid folder name with // syntax --- tests/test_folder.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_folder.py b/tests/test_folder.py index 7efb64b1..7396df35 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -1,5 +1,5 @@ from exchangelib.errors import ErrorDeleteDistinguishedFolder, ErrorObjectTypeChanged, DoesNotExist, \ - MultipleObjectsReturned, ErrorItemSave, ErrorItemNotFound, ErrorFolderExists + MultipleObjectsReturned, ErrorItemSave, ErrorItemNotFound, ErrorFolderExists, ErrorFolderNotFound from exchangelib.extended_properties import ExtendedProperty from exchangelib.items import Message from exchangelib.folders import Calendar, DeletedItems, Drafts, Inbox, Outbox, SentItems, JunkEmail, Messages, Tasks, \ @@ -378,7 +378,6 @@ def test_div_navigation(self): _ = self.account.root / '..' self.assertEqual(e.exception.args[0], 'Already at top') - def test_double_div_navigation(self): self.account.root.clear_cache() # Clear the cache @@ -400,6 +399,12 @@ def test_double_div_navigation(self): (self.account.root // '.').id, self.account.root.id ) + + # Test invalid subfolder + with self.assertRaises(ErrorFolderNotFound): + _ = self.account.root // 'XXX' + + # Check that thi didn't trigger caching self.assertIsNone(self.account.root._subfolders) def test_extended_properties(self): From 25c2e4094f45f6e3bc666a2f49ef7d84b9772b17 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 6 Jan 2022 00:34:57 +0100 Subject: [PATCH 093/509] flake8 --- tests/test_folder.py | 2 +- tests/test_items/test_basics.py | 2 +- tests/test_items/test_calendaritems.py | 2 +- tests/test_protocol.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_folder.py b/tests/test_folder.py index 7396df35..8d886ae9 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -404,7 +404,7 @@ def test_double_div_navigation(self): with self.assertRaises(ErrorFolderNotFound): _ = self.account.root // 'XXX' - # Check that thi didn't trigger caching + # Check that this didn't trigger caching self.assertIsNone(self.account.root._subfolders) def test_extended_properties(self): diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index fb9db792..9796a652 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -302,7 +302,7 @@ def _run_filter_tests(self, qs, f, filter_kwargs, val): time.sleep(retries*retries) # Exponential sleep matches = qs.filter(**kw).count() # __in with an empty list returns an empty result - expected = 0 if f.is_list and not val and list(kw)[0].endswith(f'__in') else 1 + expected = 0 if f.is_list and not val and list(kw)[0].endswith('__in') else 1 self.assertEqual(matches, expected, (f.name, val, kw, retries)) def test_filter_on_simple_fields(self): diff --git a/tests/test_items/test_calendaritems.py b/tests/test_items/test_calendaritems.py index b6937226..27f929a7 100644 --- a/tests/test_items/test_calendaritems.py +++ b/tests/test_items/test_calendaritems.py @@ -7,7 +7,7 @@ from exchangelib.items import CalendarItem, BulkCreateResult from exchangelib.items.calendar_item import SINGLE, OCCURRENCE, EXCEPTION, RECURRING_MASTER from exchangelib.recurrence import Recurrence, Occurrence, FirstOccurrence, LastOccurrence, DeletedOccurrence, \ -AbsoluteYearlyPattern, RelativeYearlyPattern, AbsoluteMonthlyPattern, RelativeMonthlyPattern, WeeklyPattern, \ + AbsoluteYearlyPattern, RelativeYearlyPattern, AbsoluteMonthlyPattern, RelativeMonthlyPattern, WeeklyPattern, \ DailyPattern from ..common import get_random_string, get_random_datetime_range, get_random_date diff --git a/tests/test_protocol.py b/tests/test_protocol.py index a211633b..2a6a64f6 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -21,7 +21,7 @@ TransportError, SessionPoolMaxSizeReached, TimezoneDefinitionInvalidForYear from exchangelib.properties import TimeZone, RoomList, FreeBusyView, AlternateId, ID_FORMATS, EWS_ID, \ SearchableMailbox, FailedMailbox, Mailbox, DLMailbox, ItemId -from exchangelib.protocol import Protocol, BaseProtocol, NoVerifyHTTPAdapter, FailFast, close_connections +from exchangelib.protocol import Protocol, BaseProtocol, NoVerifyHTTPAdapter, FailFast from exchangelib.services import GetRoomLists, GetRooms, ResolveNames, GetSearchableMailboxes, \ SetUserOofSettings, ExpandDL from exchangelib.settings import OofSettings From 436e973f4f4148a75e5bb0ebce54fd2c65d2083a Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 6 Jan 2022 00:36:45 +0100 Subject: [PATCH 094/509] Test invalid folder name with / syntax --- tests/test_folder.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_folder.py b/tests/test_folder.py index 8d886ae9..6680e8f1 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -378,6 +378,11 @@ def test_div_navigation(self): _ = self.account.root / '..' self.assertEqual(e.exception.args[0], 'Already at top') + # Test invalid subfolder + with self.assertRaises(ErrorFolderNotFound): + _ = self.account.root / 'XXX' + + def test_double_div_navigation(self): self.account.root.clear_cache() # Clear the cache From bc6e438a4780bc3265dcd0b5fd1fc33a1017df1f Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 6 Jan 2022 01:22:46 +0100 Subject: [PATCH 095/509] Push all enum value checking into the service. Sort enum values in error message --- exchangelib/fields.py | 2 +- exchangelib/folders/base.py | 7 +---- exchangelib/folders/collections.py | 17 +++--------- exchangelib/properties.py | 5 ++++ exchangelib/protocol.py | 15 ----------- exchangelib/services/delete_folder.py | 3 +++ exchangelib/services/empty_folder.py | 3 +++ exchangelib/services/find_folder.py | 6 +++++ exchangelib/services/find_item.py | 6 ++++- exchangelib/services/find_people.py | 6 ++++- tests/test_field.py | 2 +- tests/test_folder.py | 1 - tests/test_protocol.py | 39 ++++++++++++++++++++------- 13 files changed, 63 insertions(+), 49 deletions(-) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index 7fe96be0..1cfc0f35 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -918,7 +918,7 @@ def clean(self, value, version=None): else: if value in valid_choices: return value - raise ValueError(f"Invalid choice {value!r} for field {self.name!r}. Valid choices are: {valid_choices}") + raise ValueError(f"Invalid choice {value!r} for field {self.name!r}. Valid choices are {sorted(valid_choices)}") def supported_choices(self, version): return tuple(c.value for c in self.choices if c.supports_version(version)) diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 443f62f0..ae1b9e5d 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -9,8 +9,7 @@ ErrorDeleteDistinguishedFolder, ErrorNoPublicFolderReplicaAvailable, ErrorItemNotFound from ..fields import IntegerField, CharField, FieldPath, EffectiveRightsField, PermissionSetField, EWSElementField, \ Field, IdElementField, InvalidField -from ..items import CalendarItem, RegisterMixIn, ITEM_CLASSES, DELETE_TYPE_CHOICES, HARD_DELETE, \ - SHALLOW as SHALLOW_ITEMS +from ..items import CalendarItem, RegisterMixIn, ITEM_CLASSES, HARD_DELETE, SHALLOW as SHALLOW_ITEMS from ..properties import Mailbox, FolderId, ParentFolderId, DistinguishedFolderId, UserConfiguration, \ UserConfigurationName, UserConfigurationNameMNS, EWSMeta from ..queryset import SearchableMixIn, DoesNotExist @@ -356,16 +355,12 @@ def move(self, to_folder): def delete(self, delete_type=HARD_DELETE): from ..services import DeleteFolder - if delete_type not in DELETE_TYPE_CHOICES: - raise ValueError(f"'delete_type' {delete_type!r} must be one of {DELETE_TYPE_CHOICES}") DeleteFolder(account=self.account).get(folders=[self], delete_type=delete_type) self.root.remove_folder(self) # Remove the updated folder from the cache self._id = None def empty(self, delete_type=HARD_DELETE, delete_sub_folders=False): from ..services import EmptyFolder - if delete_type not in DELETE_TYPE_CHOICES: - raise ValueError(f"'delete_type' {delete_type!r} must be one of {DELETE_TYPE_CHOICES}") EmptyFolder(account=self.account).get( folders=[self], delete_type=delete_type, delete_sub_folders=delete_sub_folders ) diff --git a/exchangelib/folders/collections.py b/exchangelib/folders/collections.py index b4fec6f5..7d92d5a6 100644 --- a/exchangelib/folders/collections.py +++ b/exchangelib/folders/collections.py @@ -3,9 +3,8 @@ from cached_property import threaded_cached_property -from .queryset import FOLDER_TRAVERSAL_CHOICES from ..fields import FieldPath, InvalidField -from ..items import Persona, ITEM_TRAVERSAL_CHOICES, SHAPE_CHOICES, ID_ONLY +from ..items import Persona, ID_ONLY from ..properties import CalendarView from ..queryset import QuerySet, SearchableMixIn, Q from ..restriction import Restriction @@ -135,13 +134,9 @@ def validate_item_field(self, field, version): else: raise InvalidField(f"{field!r} is not a valid field on {self.supported_item_models}") - def _rinse_args(self, q, shape, depth, additional_fields, field_validator): - if shape not in SHAPE_CHOICES: - raise ValueError(f"'shape' {shape!r} must be one of {SHAPE_CHOICES}") + def _rinse_args(self, q, depth, additional_fields, field_validator): if depth is None: depth = self._get_default_item_traversal_depth() - if depth not in ITEM_TRAVERSAL_CHOICES: - raise ValueError(f"'depth' {depth!r} must be one of {ITEM_TRAVERSAL_CHOICES}") if additional_fields: for f in additional_fields: field_validator(field=f, version=self.account.version) @@ -186,7 +181,7 @@ def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order log.debug('Query will never return results') return depth, restriction, query_string = self._rinse_args( - q=q, shape=shape, depth=depth, additional_fields=additional_fields, + q=q, depth=depth, additional_fields=additional_fields, field_validator=self.validate_item_field ) if calendar_view is not None and not isinstance(calendar_view, CalendarView): @@ -246,7 +241,7 @@ def find_people(self, q, shape=ID_ONLY, depth=None, additional_fields=None, orde log.debug('Query will never return results') return depth, restriction, query_string = self._rinse_args( - q=q, shape=shape, depth=depth, additional_fields=additional_fields, + q=q, depth=depth, additional_fields=additional_fields, field_validator=Persona.validate_field ) @@ -337,12 +332,8 @@ def find_folders(self, q=None, shape=ID_ONLY, depth=None, additional_fields=None restriction = None else: restriction = Restriction(q, folders=self.folders, applies_to=Restriction.FOLDERS) - if shape not in SHAPE_CHOICES: - raise ValueError(f"'shape' {shape!r} must be one of {SHAPE_CHOICES}") if depth is None: depth = self._get_default_folder_traversal_depth() - if depth not in FOLDER_TRAVERSAL_CHOICES: - raise ValueError(f"'depth' {depth!r} must be one of {FOLDER_TRAVERSAL_CHOICES}") if additional_fields is None: # Default to all non-complex properties. Subfolders will always be of class Folder additional_fields = self.get_folder_fields(target_cls=Folder, is_complex=False) diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 6ae2974f..7d5f6c64 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -711,6 +711,11 @@ class TimeWindow(EWSElement): start = DateTimeField(field_uri='StartTime', is_required=True) end = DateTimeField(field_uri='EndTime', is_required=True) + def clean(self, version=None): + if self.start >= self.end: + raise ValueError(f"'start' must be less than 'end' ({self.start} -> {self.end})") + super().clean(version=version) + class FreeBusyViewOptions(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/freebusyviewoptions""" diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 82a00680..30355497 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -496,21 +496,6 @@ def get_free_busy_info(self, accounts, start, end, merged_free_busy_interval=30, :return: A generator of FreeBusyView objects """ from .account import Account - for account, attendee_type, exclude_conflicts in accounts: - if not isinstance(account, (Account, str)): - raise ValueError(f"'accounts' item {account!r} must be an 'Account' or 'str' instance") - if attendee_type not in MailboxData.ATTENDEE_TYPES: - raise ValueError(f"'accounts' item {attendee_type!r} must be one of {MailboxData.ATTENDEE_TYPES}") - if not isinstance(exclude_conflicts, bool): - raise ValueError(f"'accounts' item {exclude_conflicts!r} must be a 'bool' instance") - if start >= end: - raise ValueError(f"'start' must be less than 'end' ({start} -> {end})") - if not isinstance(merged_free_busy_interval, int): - raise ValueError(f"'merged_free_busy_interval' value {merged_free_busy_interval!r} must be an 'int'") - if requested_view not in FreeBusyViewOptions.REQUESTED_VIEWS: - raise ValueError( - f"'requested_view' value {requested_view!r} must be one of {FreeBusyViewOptions.REQUESTED_VIEWS}" - ) tz_definition = list(self.get_timezones( timezones=[start.tzinfo], return_full_timezone_data=True diff --git a/exchangelib/services/delete_folder.py b/exchangelib/services/delete_folder.py index 96b0a7c7..713ad0b1 100644 --- a/exchangelib/services/delete_folder.py +++ b/exchangelib/services/delete_folder.py @@ -1,4 +1,5 @@ from .common import EWSAccountService, folder_ids_element +from ..items import DELETE_TYPE_CHOICES from ..util import create_element @@ -9,6 +10,8 @@ class DeleteFolder(EWSAccountService): returns_elements = False def call(self, folders, delete_type): + if delete_type not in DELETE_TYPE_CHOICES: + raise ValueError(f"'delete_type' {delete_type!r} must be one of {DELETE_TYPE_CHOICES}") return self._chunked_get_elements(self.get_payload, items=folders, delete_type=delete_type) def get_payload(self, folders, delete_type): diff --git a/exchangelib/services/empty_folder.py b/exchangelib/services/empty_folder.py index a6726f18..a1a2a4e2 100644 --- a/exchangelib/services/empty_folder.py +++ b/exchangelib/services/empty_folder.py @@ -1,4 +1,5 @@ from .common import EWSAccountService, folder_ids_element +from ..items import DELETE_TYPE_CHOICES from ..util import create_element @@ -9,6 +10,8 @@ class EmptyFolder(EWSAccountService): returns_elements = False def call(self, folders, delete_type, delete_sub_folders): + if delete_type not in DELETE_TYPE_CHOICES: + raise ValueError(f"'delete_type' {delete_type!r} must be one of {DELETE_TYPE_CHOICES}") return self._chunked_get_elements( self.get_payload, items=folders, delete_type=delete_type, delete_sub_folders=delete_sub_folders ) diff --git a/exchangelib/services/find_folder.py b/exchangelib/services/find_folder.py index d9659d22..5333eb40 100644 --- a/exchangelib/services/find_folder.py +++ b/exchangelib/services/find_folder.py @@ -1,5 +1,7 @@ from .common import EWSPagingService, shape_element, folder_ids_element from ..folders import Folder +from ..folders.queryset import FOLDER_TRAVERSAL_CHOICES +from ..items import SHAPE_CHOICES from ..util import create_element, TNS, MNS from ..version import EXCHANGE_2010 @@ -33,6 +35,10 @@ def call(self, folders, additional_fields, restriction, shape, depth, max_items, if len(roots) != 1: raise ValueError(f'FindFolder must be called with folders in the same root hierarchy ({roots})') self.root = roots.pop() + if shape not in SHAPE_CHOICES: + raise ValueError(f"'shape' {shape!r} must be one of {SHAPE_CHOICES}") + if depth not in FOLDER_TRAVERSAL_CHOICES: + raise ValueError(f"'depth' {depth!r} must be one of {FOLDER_TRAVERSAL_CHOICES}") return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, diff --git a/exchangelib/services/find_item.py b/exchangelib/services/find_item.py index 77d54436..22fe1f2e 100644 --- a/exchangelib/services/find_item.py +++ b/exchangelib/services/find_item.py @@ -1,6 +1,6 @@ from .common import EWSPagingService, shape_element, folder_ids_element from ..folders.base import BaseFolder -from ..items import Item, ID_ONLY +from ..items import Item, ID_ONLY, SHAPE_CHOICES, ITEM_TRAVERSAL_CHOICES from ..util import create_element, set_xml_value, TNS, MNS @@ -35,6 +35,10 @@ def call(self, folders, additional_fields, restriction, order_fields, shape, que :return: XML elements for the matching items """ + if shape not in SHAPE_CHOICES: + raise ValueError(f"'shape' {shape!r} must be one of {SHAPE_CHOICES}") + if depth not in ITEM_TRAVERSAL_CHOICES: + raise ValueError(f"'depth' {depth!r} must be one of {ITEM_TRAVERSAL_CHOICES}") self.additional_fields = additional_fields self.shape = shape return self._elems_to_objs(self._paged_call( diff --git a/exchangelib/services/find_people.py b/exchangelib/services/find_people.py index a753f02a..bf64a74a 100644 --- a/exchangelib/services/find_people.py +++ b/exchangelib/services/find_people.py @@ -1,6 +1,6 @@ import logging from .common import EWSPagingService, shape_element, folder_ids_element -from ..items import Persona, ID_ONLY +from ..items import Persona, ID_ONLY, SHAPE_CHOICES, ITEM_TRAVERSAL_CHOICES from ..util import create_element, set_xml_value, MNS from ..version import EXCHANGE_2013 @@ -36,6 +36,10 @@ def call(self, folder, additional_fields, restriction, order_fields, shape, quer :return: XML elements for the matching items """ + if shape not in SHAPE_CHOICES: + raise ValueError(f"'shape' {shape!r} must be one of {SHAPE_CHOICES}") + if depth not in ITEM_TRAVERSAL_CHOICES: + raise ValueError(f"'depth' {depth!r} must be one of {ITEM_TRAVERSAL_CHOICES}") self.additional_fields = additional_fields self.shape = shape return self._elems_to_objs(self._paged_call( diff --git a/tests/test_field.py b/tests/test_field.py index 854f3b00..744c32e4 100644 --- a/tests/test_field.py +++ b/tests/test_field.py @@ -61,7 +61,7 @@ def test_value_validation(self): field = ChoiceField('foo', field_uri='bar', choices=[Choice('foo'), Choice('bar')]) with self.assertRaises(ValueError) as e: field.clean('XXX') # Value must be a valid choice - self.assertEqual(str(e.exception), "Invalid choice 'XXX' for field 'foo'. Valid choices are: ['foo', 'bar']") + self.assertEqual(str(e.exception), "Invalid choice 'XXX' for field 'foo'. Valid choices are ['bar', 'foo']") # A few tests on extended properties that override base methods field = ExtendedPropertyField('foo', value_cls=ExternId, is_required=True) diff --git a/tests/test_folder.py b/tests/test_folder.py index 6680e8f1..9ad1d1e7 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -382,7 +382,6 @@ def test_div_navigation(self): with self.assertRaises(ErrorFolderNotFound): _ = self.account.root / 'XXX' - def test_double_div_navigation(self): self.account.root.clear_cache() # Clear the cache diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 2a6a64f6..8a9f522c 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -20,7 +20,7 @@ from exchangelib.errors import SessionPoolMinSizeReached, ErrorNameResolutionNoResults, ErrorAccessDenied, \ TransportError, SessionPoolMaxSizeReached, TimezoneDefinitionInvalidForYear from exchangelib.properties import TimeZone, RoomList, FreeBusyView, AlternateId, ID_FORMATS, EWS_ID, \ - SearchableMailbox, FailedMailbox, Mailbox, DLMailbox, ItemId + SearchableMailbox, FailedMailbox, Mailbox, DLMailbox, ItemId, MailboxData, FreeBusyViewOptions from exchangelib.protocol import Protocol, BaseProtocol, NoVerifyHTTPAdapter, FailFast from exchangelib.services import GetRoomLists, GetRooms, ResolveNames, GetSearchableMailboxes, \ SetUserOofSettings, ExpandDL @@ -187,19 +187,38 @@ def test_get_free_busy_info(self): end = datetime.datetime.now(tz=tz) + datetime.timedelta(hours=6) accounts = [(self.account, 'Organizer', False)] - with self.assertRaises(ValueError): - self.account.protocol.get_free_busy_info(accounts=[(123, 'XXX', 'XXX')], start=0, end=0) - with self.assertRaises(ValueError): - self.account.protocol.get_free_busy_info(accounts=[(self.account, 'XXX', 'XXX')], start=0, end=0) - with self.assertRaises(ValueError): - self.account.protocol.get_free_busy_info(accounts=[(self.account, 'Organizer', 'XXX')], start=0, end=0) - with self.assertRaises(ValueError): + with self.assertRaises(TypeError) as e: + self.account.protocol.get_free_busy_info(accounts=[(123, 'XXX', 'XXX')], start=start, end=end) + self.assertEqual( + e.exception.args[0], + "Field 'email' value 123 must be of type " + ) + with self.assertRaises(ValueError) as e: + self.account.protocol.get_free_busy_info(accounts=[(self.account, 'XXX', 'XXX')], start=start, end=end) + self.assertEqual( + e.exception.args[0], + f"Invalid choice 'XXX' for field 'attendee_type'. Valid choices are {sorted(MailboxData.ATTENDEE_TYPES)}" + ) + with self.assertRaises(TypeError) as e: + self.account.protocol.get_free_busy_info(accounts=[(self.account, 'Organizer', 'X')], start=start, end=end) + self.assertEqual(e.exception.args[0], "Field 'exclude_conflicts' value 'X' must be of type ") + with self.assertRaises(ValueError) as e: self.account.protocol.get_free_busy_info(accounts=accounts, start=end, end=start) - with self.assertRaises(ValueError): + self.assertIn("'start' must be less than 'end'", e.exception.args[0]) + with self.assertRaises(TypeError) as e: self.account.protocol.get_free_busy_info(accounts=accounts, start=start, end=end, merged_free_busy_interval='XXX') - with self.assertRaises(ValueError): + self.assertEqual( + e.exception.args[0], + "Field 'merged_free_busy_interval' value 'XXX' must be of type " + ) + with self.assertRaises(ValueError) as e: self.account.protocol.get_free_busy_info(accounts=accounts, start=start, end=end, requested_view='XXX') + self.assertEqual( + e.exception.args[0], + f"Invalid choice 'XXX' for field 'requested_view'. Valid choices are " + f"{sorted(FreeBusyViewOptions.REQUESTED_VIEWS)}" + ) for view_info in self.account.protocol.get_free_busy_info(accounts=accounts, start=start, end=end): self.assertIsInstance(view_info, FreeBusyView) From 042beee4d715c61390bb3015292e1b1b6e0709a5 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 6 Jan 2022 13:05:18 +0100 Subject: [PATCH 096/509] Extract common enum and type check patterns into custom exception classes --- exchangelib/account.py | 5 +- exchangelib/attachments.py | 7 +- exchangelib/configuration.py | 11 +- exchangelib/credentials.py | 8 +- exchangelib/errors.py | 22 ++++ exchangelib/ewsdatetime.py | 8 +- exchangelib/extended_properties.py | 18 ++- exchangelib/fields.py | 38 +++--- exchangelib/folders/base.py | 10 +- exchangelib/folders/collections.py | 3 +- exchangelib/folders/queryset.py | 3 +- exchangelib/items/base.py | 11 +- exchangelib/properties.py | 8 +- exchangelib/protocol.py | 7 +- exchangelib/queryset.py | 8 +- exchangelib/restriction.py | 17 +-- exchangelib/services/common.py | 11 +- exchangelib/services/convert_id.py | 5 +- exchangelib/services/create_item.py | 12 +- exchangelib/services/delete_folder.py | 3 +- exchangelib/services/delete_item.py | 13 +- exchangelib/services/empty_folder.py | 3 +- exchangelib/services/find_folder.py | 11 +- exchangelib/services/find_item.py | 5 +- exchangelib/services/find_people.py | 5 +- exchangelib/services/get_attachment.py | 3 +- exchangelib/services/get_streaming_events.py | 6 +- .../services/get_user_configuration.py | 3 +- exchangelib/services/move_folder.py | 3 +- exchangelib/services/move_item.py | 3 +- exchangelib/services/resolve_names.py | 6 +- exchangelib/services/send_item.py | 3 +- exchangelib/services/send_notification.py | 3 +- exchangelib/services/set_user_oof_settings.py | 5 +- exchangelib/services/sync_folder_items.py | 5 +- exchangelib/services/update_item.py | 15 +-- exchangelib/util.py | 5 +- exchangelib/version.py | 16 +-- tests/test_account.py | 3 +- tests/test_configuration.py | 21 ++-- tests/test_ewsdatetime.py | 6 +- tests/test_extended_properties.py | 2 +- tests/test_field.py | 12 +- tests/test_folder.py | 75 +++++++++-- tests/test_items/test_generic.py | 116 +++++++++++++++--- tests/test_items/test_queryset.py | 2 +- tests/test_items/test_sync.py | 4 +- tests/test_protocol.py | 26 ++-- tests/test_restriction.py | 2 +- tests/test_version.py | 28 ++--- 50 files changed, 409 insertions(+), 216 deletions(-) diff --git a/exchangelib/account.py b/exchangelib/account.py index bdde95ee..9f6354ea 100644 --- a/exchangelib/account.py +++ b/exchangelib/account.py @@ -6,7 +6,7 @@ from .autodiscover import Autodiscovery from .configuration import Configuration from .credentials import DELEGATE, IMPERSONATION, ACCESS_TYPES -from .errors import UnknownTimeZone +from .errors import UnknownTimeZone, InvalidEnumValue from .ewsdatetime import EWSTimeZone, UTC from .fields import FieldPath from .folders import Folder, AdminAuditLogs, ArchiveDeletedItems, ArchiveInbox, ArchiveMsgFolderRoot, \ @@ -84,7 +84,7 @@ def __init__(self, primary_smtp_address, fullname=None, access_type=None, autodi # Assume delegate access if individual credentials are provided. Else, assume service user with impersonation self.access_type = access_type or (DELEGATE if credentials else IMPERSONATION) if self.access_type not in ACCESS_TYPES: - raise ValueError(f"'access_type' {self.access_type!r} must be one of {ACCESS_TYPES}") + raise InvalidEnumValue('access_type', self.access_type, ACCESS_TYPES) try: self.locale = locale or getlocale()[0] or None # get_locale() might not be able to determine the locale except ValueError as e: @@ -610,7 +610,6 @@ def fetch_personas(self, ids): @property def mail_tips(self): """See self.oof_settings about caching considerations.""" - # mail_tips_requested must be one of properties.MAIL_TIPS_TYPES return GetMailTips(protocol=self.protocol).get( sending_as=SendingAs(email_address=self.primary_smtp_address), recipients=[Mailbox(email_address=self.primary_smtp_address)], diff --git a/exchangelib/attachments.py b/exchangelib/attachments.py index 442e2954..6455a080 100644 --- a/exchangelib/attachments.py +++ b/exchangelib/attachments.py @@ -2,6 +2,7 @@ import logging import mimetypes +from .errors import InvalidTypeError from .fields import BooleanField, TextField, IntegerField, URIField, DateTimeField, EWSElementField, Base64Field, \ ItemField, IdField, FieldPath from .properties import EWSElement, EWSMeta @@ -47,7 +48,7 @@ def __init__(self, **kwargs): def clean(self, version=None): from .items import Item if self.parent_item is not None and not isinstance(self.parent_item, Item): - raise ValueError(f"'parent_item' value {self.parent_item!r} must be an Item instance") + raise InvalidTypeError('parent_item', self.parent_item, Item) if self.content_type is None and self.name is not None: self.content_type = mimetypes.guess_type(self.name)[0] or 'application/octet-stream' super().clean(version=version) @@ -147,7 +148,7 @@ def content(self): def content(self, value): """Replace the attachment content.""" if not isinstance(value, bytes): - raise ValueError(f"'value' {value!r} must be a bytes object") + raise InvalidTypeError('value', value, bytes) self._content = value @classmethod @@ -210,7 +211,7 @@ def item(self): def item(self, value): from .items import Item if not isinstance(value, Item): - raise ValueError(f"'value' {value!r} must be an Item object") + raise InvalidTypeError('value', value, Item) self._item = value @classmethod diff --git a/exchangelib/configuration.py b/exchangelib/configuration.py index ffe79160..1122e148 100644 --- a/exchangelib/configuration.py +++ b/exchangelib/configuration.py @@ -2,6 +2,7 @@ from cached_property import threaded_cached_property +from .errors import InvalidEnumValue, InvalidTypeError from .credentials import BaseCredentials, OAuth2Credentials, OAuth2AuthorizationCodeCredentials from .protocol import RetryPolicy, FailFast from .transport import AUTH_TYPE_MAP, OAUTH2, CREDENTIALS_REQUIRED @@ -48,7 +49,7 @@ class Configuration: def __init__(self, credentials=None, server=None, service_endpoint=None, auth_type=None, version=None, retry_policy=None, max_connections=None): if not isinstance(credentials, (BaseCredentials, type(None))): - raise ValueError(f"'credentials' {credentials!r} must be a Credentials instance") + raise InvalidTypeError('credentials', credentials, BaseCredentials) if auth_type is None: # Set a default auth type for the credentials where this makes sense auth_type = DEFAULT_AUTH_TYPE.get(type(credentials)) @@ -57,15 +58,15 @@ def __init__(self, credentials=None, server=None, service_endpoint=None, auth_ty if server and service_endpoint: raise AttributeError("Only one of 'server' or 'service_endpoint' must be provided") if auth_type is not None and auth_type not in AUTH_TYPE_MAP: - raise ValueError(f"'auth_type' {auth_type!r} must be one of {sorted(AUTH_TYPE_MAP)}") + raise InvalidEnumValue('auth_type', auth_type, AUTH_TYPE_MAP) if not retry_policy: retry_policy = FailFast() if not isinstance(version, (Version, type(None))): - raise ValueError(f"'version' {version!r} must be a Version instance") + raise InvalidTypeError('version', version, Version) if not isinstance(retry_policy, RetryPolicy): - raise ValueError(f"'retry_policy' {retry_policy!r} must be a RetryPolicy instance") + raise InvalidTypeError('retry_policy', retry_policy, RetryPolicy) if not isinstance(max_connections, (int, type(None))): - raise ValueError(f"'max_connections' {max_connections!r} must be an integer") + raise InvalidTypeError('max_connections', max_connections, int) self._credentials = credentials if server: self.service_endpoint = f'https://{server}/EWS/Exchange.asmx' diff --git a/exchangelib/credentials.py b/exchangelib/credentials.py index 8cc60835..e324c080 100644 --- a/exchangelib/credentials.py +++ b/exchangelib/credentials.py @@ -8,6 +8,10 @@ import logging from threading import RLock +from oauthlib.oauth2 import OAuth2Token + +from .errors import InvalidTypeError + log = logging.getLogger(__name__) IMPERSONATION = 'impersonation' @@ -140,7 +144,7 @@ def on_token_auto_refreshed(self, access_token): """ # Ensure we don't update the object in the middle of a new session being created, which could cause a race. if not isinstance(access_token, dict): - raise ValueError("'access_token' must be an OAuth2Token") + raise InvalidTypeError('access_token', access_token, OAuth2Token) with self.lock: log.debug('%s auth token for %s', 'Refreshing' if self.access_token else 'Setting', self.client_id) self.access_token = access_token @@ -200,7 +204,7 @@ def __init__(self, authorization_code=None, access_token=None, **kwargs): super().__init__(**kwargs) self.authorization_code = authorization_code if access_token is not None and not isinstance(access_token, dict): - raise ValueError("'access_token' must be an OAuth2Token") + raise InvalidTypeError('access_token', access_token, OAuth2Token) self.access_token = access_token def __repr__(self): diff --git a/exchangelib/errors.py b/exchangelib/errors.py index 3fb42b5c..b8e88435 100644 --- a/exchangelib/errors.py +++ b/exchangelib/errors.py @@ -11,6 +11,28 @@ class DoesNotExist(Exception): pass +class InvalidEnumValue(ValueError): + def __init__(self, field_name, value, choices): + self.field_name = field_name + self.value = value + self.choices = choices + super().__init__(str(self)) + + def __str__(self): + return f'{self.field_name!r} {self.value!r} must be one of {sorted(self.choices)}' + + +class InvalidTypeError(TypeError): + def __init__(self, field_name, value, valid_type): + self.field_name = field_name + self.value = value + self.valid_type = valid_type + super().__init__(str(self)) + + def __str__(self): + return f'{self.field_name!r} {self.value!r} must be of type {self.valid_type}' + + class EWSError(Exception): """Global error type within this module.""" diff --git a/exchangelib/ewsdatetime.py b/exchangelib/ewsdatetime.py index 89f29150..db4768f7 100644 --- a/exchangelib/ewsdatetime.py +++ b/exchangelib/ewsdatetime.py @@ -7,7 +7,7 @@ from backports import zoneinfo import tzlocal -from .errors import NaiveDateTimeNotAllowed, UnknownTimeZone +from .errors import NaiveDateTimeNotAllowed, UnknownTimeZone, InvalidTypeError from .winzone import IANA_TO_MS_TIMEZONE_MAP, MS_TIMEZONE_TO_IANA_MAP log = logging.getLogger(__name__) @@ -52,7 +52,7 @@ def fromordinal(cls, n): @classmethod def from_date(cls, d): if type(d) is not datetime.date: - raise ValueError(f"{d!r} must be a date instance") + raise InvalidTypeError('d', d, datetime.date) return cls(d.year, d.month, d.day) @classmethod @@ -89,7 +89,7 @@ def __new__(cls, *args, **kwargs): # Don't allow pytz or dateutil timezones here. They are not safe to use as direct input for datetime() tzinfo = EWSTimeZone.from_timezone(tzinfo) if not isinstance(tzinfo, (EWSTimeZone, type(None))): - raise ValueError(f'tzinfo {tzinfo!r} must be an EWSTimeZone instance') + raise InvalidTypeError('tzinfo', tzinfo, EWSTimeZone) if len(args) == 8: args = args[:7] + (tzinfo,) else: @@ -112,7 +112,7 @@ def ewsformat(self): @classmethod def from_datetime(cls, d): if type(d) is not datetime.datetime: - raise ValueError(f"{d!r} must be a datetime instance") + raise InvalidTypeError('d', d, datetime.datetime) if d.tzinfo is None: tz = None elif isinstance(d.tzinfo, EWSTimeZone): diff --git a/exchangelib/extended_properties.py b/exchangelib/extended_properties.py index e7419197..5e325364 100644 --- a/exchangelib/extended_properties.py +++ b/exchangelib/extended_properties.py @@ -1,6 +1,7 @@ import logging from decimal import Decimal +from .errors import InvalidEnumValue from .ewsdatetime import EWSDateTime from .properties import EWSElement, ExtendedFieldURI from .util import create_element, add_xml_child, get_xml_attrs, get_xml_attr, set_xml_value, value_to_xml_text, \ @@ -119,9 +120,8 @@ def _validate_distinguished_property_set_id(cls): "When 'distinguished_property_set_id' is set, 'property_id' or 'property_name' must also be set" ) if cls.distinguished_property_set_id not in cls.DISTINGUISHED_SETS: - raise ValueError( - f"'distinguished_property_set_id' {cls.distinguished_property_set_id} must be one of " - f"{sorted(cls.DISTINGUISHED_SETS)}" + raise InvalidEnumValue( + 'distinguished_property_set_id', cls.distinguished_property_set_id, cls.DISTINGUISHED_SETS ) @classmethod @@ -171,24 +171,20 @@ def _validate_property_id(cls): @classmethod def _validate_property_type(cls): if cls.property_type not in cls.PROPERTY_TYPES: - raise ValueError(f"'property_type' {cls.property_type!r} must be one of {sorted(cls.PROPERTY_TYPES)}") + raise InvalidEnumValue('property_type', cls.property_type, cls.PROPERTY_TYPES) def clean(self, version=None): self.validate_cls() python_type = self.python_type() if self.is_array_type(): if not is_iterable(self.value): - raise ValueError(f"{self.__class__.__name__!r} value {self.value!r} must be a list") + raise TypeError(f"Field {self.__class__.__name__!r} value {self.value!r} must be of type {list}") for v in self.value: if not isinstance(v, python_type): - raise TypeError( - f"{self.__class__.__name__!r} value element {v!r} must be an instance of {python_type}" - ) + raise TypeError(f"Field {self.__class__.__name__!r} list value {v!r} must be of type {python_type}") else: if not isinstance(self.value, python_type): - raise TypeError( - f"{self.__class__.__name__!r} value {self.value!r} must be an instance of {python_type}" - ) + raise TypeError(f"Field {self.__class__.__name__!r} value {self.value!r} must be of type {python_type}") @classmethod def _normalize_obj(cls, obj): diff --git a/exchangelib/fields.py b/exchangelib/fields.py index 1cfc0f35..552e57c6 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -4,6 +4,7 @@ from decimal import Decimal, InvalidOperation from importlib import import_module +from .errors import InvalidTypeError from .ewsdatetime import EWSDateTime, EWSDate, EWSTimeZone, NaiveDateTimeNotAllowed, UnknownTimeZone, UTC from .util import create_element, get_xml_attr, get_xml_attrs, set_xml_value, value_to_xml_text, is_iterable, \ xml_text_to_value, TNS @@ -80,7 +81,7 @@ def split_field_path(field_path): 'physical_addresses__Home__street' -> ('physical_addresses', 'Home', 'street') """ if not isinstance(field_path, str): - raise ValueError(f"Field path {field_path!r} must be a string") + raise InvalidTypeError('field_path', field_path, str) search_parts = field_path.split('__') field = search_parts[0] try: @@ -112,7 +113,9 @@ def resolve_field_path(field_path, folder, strict=True): version=folder.account.version ) if label and label not in valid_labels: - raise ValueError(f"Label {label} on IndexedField path {field_path!r} must be one of {valid_labels}") + raise ValueError( + f"Label {label!r} on IndexedField path {field_path!r} must be one of {sorted(valid_labels)}" + ) if issubclass(field.value_cls, MultiFieldIndexedElement): if strict and not subfield_name: raise ValueError( @@ -128,11 +131,12 @@ def resolve_field_path(field_path, folder, strict=True): version=folder.account.version )) raise ValueError( - f"Subfield {subfield_name!r} on IndexedField path {field_path!r} must be one of {field_names}" + f"Subfield {subfield_name!r} on IndexedField path {field_path!r} " + f"must be one of {sorted(field_names)}" ) else: if not issubclass(field.value_cls, SingleFieldIndexedElement): - raise ValueError(f"'field.value_cls' {field.value_cls!r} must be an SingleFieldIndexedElement instance") + raise InvalidTypeError('field.value_cls', field.value_cls, SingleFieldIndexedElement) if subfield_name: raise ValueError( f"IndexedField path {field_path!r} must not specify subfield, e.g. just {fieldname}__{label}'" @@ -153,11 +157,11 @@ class FieldPath: def __init__(self, field, label=None, subfield=None): # 'label' and 'subfield' are only used for IndexedField fields if not isinstance(field, (FieldURIField, ExtendedPropertyField)): - raise ValueError(f"'field' {field!r} must be an FieldURIField, of ExtendedPropertyField instance") + raise InvalidTypeError('field', field, (FieldURIField, ExtendedPropertyField)) if label and not isinstance(label, str): - raise ValueError(f"'label' {label!r} must be a {str} instance") + raise InvalidTypeError('label', label, str) if subfield and not isinstance(subfield, SubField): - raise ValueError(f"'subfield' {subfield!r} must be a SubField instance") + raise InvalidTypeError('subfield', subfield, SubField) self.field = field self.label = label self.subfield = subfield @@ -233,9 +237,9 @@ class FieldOrder: def __init__(self, field_path, reverse=False): if not isinstance(field_path, FieldPath): - raise ValueError(f"'field_path' {field_path!r} must be a FieldPath instance") + raise InvalidTypeError('field_path', field_path, FieldPath) if not isinstance(reverse, bool): - raise ValueError(f"'reverse' {reverse!r} must be a boolean") + raise InvalidTypeError('reverse', reverse, bool) self.field_path = field_path self.reverse = reverse @@ -285,12 +289,12 @@ def __init__(self, name=None, is_required=False, is_required_after_save=False, i # The Exchange build when this field was introduced. When talking with versions prior to this version, # we will ignore this field. if supported_from is not None and not isinstance(supported_from, Build): - raise ValueError(f"'supported_from' {supported_from!r} must be a Build instance") + raise InvalidTypeError('supported_from', supported_from, Build) self.supported_from = supported_from # The Exchange build when this field was deprecated. When talking with versions at or later than this version, # we will ignore this field. if deprecated_from is not None and not isinstance(deprecated_from, Build): - raise ValueError(f"'deprecated_from' {deprecated_from!r} must be a Build instance") + raise InvalidTypeError('deprecated_from', deprecated_from, Build) self.deprecated_from = deprecated_from def clean(self, value, version=None): @@ -304,7 +308,7 @@ def clean(self, value, version=None): return self.default if self.is_list: if not is_iterable(value): - raise ValueError(f"Field {self.name!r} value {value!r} must be a list") + raise TypeError(f"Field {self.name!r} value {value!r} must be of type {list}") for v in value: if not isinstance(v, self.value_cls): raise TypeError(f"Field {self.name!r} value {v!r} must be of type {self.value_cls}") @@ -328,7 +332,7 @@ def to_xml(self, value, version): def supports_version(self, version): # 'version' is a Version instance, for convenience by callers if not isinstance(version, Version): - raise ValueError(f"'version' {version!r} must be a Version instance") + raise InvalidTypeError('version', version, Version) if self.supported_from and version.build < self.supported_from: return False if self.deprecated_from and version.build >= self.deprecated_from: @@ -471,7 +475,7 @@ def clean(self, value, version=None): for i, v in enumerate(value): if isinstance(v, str): if v not in self.enum: - raise ValueError(f"List value {v!r} on field {self.name!r} must be one of {self.enum}") + raise ValueError(f"List value {v!r} on field {self.name!r} must be one of {sorted(self.enum)}") value[i] = self.enum.index(v) + 1 if not value: raise ValueError(f"Value {value!r} on field {self.name!r} must not be empty") @@ -480,7 +484,7 @@ def clean(self, value, version=None): else: if isinstance(value, str): if value not in self.enum: - raise ValueError(f"Value {value!r} on field {self.name!r} must be one of {self.enum}") + raise ValueError(f"Value {value!r} on field {self.name!r} must be one of {sorted(self.enum)}") value = self.enum.index(value) + 1 return super().clean(value, version=version) @@ -888,7 +892,7 @@ def __init__(self, value, supported_from=None): def supports_version(self, version): # 'version' is a Version instance, for convenience by callers if not isinstance(version, Version): - raise ValueError(f"'version' {version!r} must be a Version instance") + raise InvalidTypeError('version', version, Version) if not self.supported_from: return True return version.build >= self.supported_from @@ -1296,7 +1300,7 @@ def __init__(self, *args, **kwargs): from .indexed_properties import IndexedElement value_cls = kwargs['value_cls'] if not issubclass(value_cls, IndexedElement): - raise ValueError(f"'value_cls' {value_cls!r} must be a subclass of IndexedElement") + raise TypeError(f"'value_cls' {value_cls!r} must be a subclass of type {IndexedElement}") super().__init__(*args, **kwargs) def to_xml(self, value, version): diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index ae1b9e5d..e6f4ca92 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -6,7 +6,7 @@ from .collections import FolderCollection, SyncCompleted, PullSubscription, PushSubscription, StreamingSubscription from .queryset import SingleFolderQuerySet, SHALLOW as SHALLOW_FOLDERS, DEEP as DEEP_FOLDERS from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorCannotEmptyFolder, ErrorCannotDeleteObject, \ - ErrorDeleteDistinguishedFolder, ErrorNoPublicFolderReplicaAvailable, ErrorItemNotFound + ErrorDeleteDistinguishedFolder, ErrorNoPublicFolderReplicaAvailable, ErrorItemNotFound, InvalidTypeError from ..fields import IntegerField, CharField, FieldPath, EffectiveRightsField, PermissionSetField, EWSElementField, \ Field, IdElementField, InvalidField from ..items import CalendarItem, RegisterMixIn, ITEM_CLASSES, HARD_DELETE, SHALLOW as SHALLOW_ITEMS @@ -181,7 +181,7 @@ def tree(self): def supports_version(cls, version): # 'version' is a Version instance, for convenience by callers if not isinstance(version, Version): - raise ValueError(f"'version' {version!r} must be a Version instance") + raise InvalidTypeError('version', version, Version) if not cls.supported_from: return True return version.build >= cls.supported_from @@ -258,7 +258,7 @@ def normalize_fields(self, fields): field_path = FieldPath(field=field_path) fields[i] = field_path if not isinstance(field_path, FieldPath): - raise ValueError(f"Field {field_path!r} must be a string or FieldPath instance") + raise InvalidTypeError('field_path', field_path, FieldPath) if field_path.field.name == 'start': has_start = True elif field_path.field.name == 'end': @@ -793,7 +793,7 @@ def parent(self, value): self.parent_folder_id = None else: if not isinstance(value, BaseFolder): - raise ValueError(f"'value' {value!r} must be a Folder instance") + raise InvalidTypeError('value', value, BaseFolder) self.root = value.root self.parent_folder_id = ParentFolderId(id=value.id, changekey=value.changekey) @@ -801,7 +801,7 @@ def clean(self, version=None): from .roots import RootOfHierarchy super().clean(version=version) if self.root and not isinstance(self.root, RootOfHierarchy): - raise ValueError(f"'root' {self.root!r} must be a RootOfHierarchy instance") + raise InvalidTypeError('root', self.root, RootOfHierarchy) @classmethod def from_xml_with_root(cls, elem, root): diff --git a/exchangelib/folders/collections.py b/exchangelib/folders/collections.py index 7d92d5a6..cfff827a 100644 --- a/exchangelib/folders/collections.py +++ b/exchangelib/folders/collections.py @@ -3,6 +3,7 @@ from cached_property import threaded_cached_property +from ..errors import InvalidTypeError from ..fields import FieldPath, InvalidField from ..items import Persona, ID_ONLY from ..properties import CalendarView @@ -185,7 +186,7 @@ def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order field_validator=self.validate_item_field ) if calendar_view is not None and not isinstance(calendar_view, CalendarView): - raise ValueError(f"'calendar_view' {calendar_view!r} must be a CalendarView instance") + raise InvalidTypeError('calendar_view', calendar_view, CalendarView) log.debug( 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', diff --git a/exchangelib/folders/queryset.py b/exchangelib/folders/queryset.py index 209ee949..76b45476 100644 --- a/exchangelib/folders/queryset.py +++ b/exchangelib/folders/queryset.py @@ -1,6 +1,7 @@ import logging from copy import deepcopy +from ..errors import InvalidTypeError from ..properties import InvalidField, FolderId from ..queryset import DoesNotExist, MultipleObjectsReturned from ..restriction import Q @@ -21,7 +22,7 @@ class FolderQuerySet: def __init__(self, folder_collection): from .collections import FolderCollection if not isinstance(folder_collection, FolderCollection): - raise ValueError(f"'folder_collection' {folder_collection!r} must be a FolderCollection instance") + raise InvalidTypeError('folder_collection', folder_collection, FolderCollection) self.folder_collection = folder_collection self.q = Q() # Default to no restrictions self.only_fields = None diff --git a/exchangelib/items/base.py b/exchangelib/items/base.py index 02e222f3..316baaad 100644 --- a/exchangelib/items/base.py +++ b/exchangelib/items/base.py @@ -1,5 +1,6 @@ import logging +from ..errors import InvalidTypeError from ..extended_properties import ExtendedProperty from ..fields import BooleanField, ExtendedPropertyField, BodyField, MailboxField, MailboxListField, EWSElementField, \ CharField, IdElementField, AttachmentField, ExtendedPropertyListField @@ -84,9 +85,9 @@ def register(cls, attr_name, attr_cls): except InvalidField: pass else: - raise ValueError(f"{attr_name!r} is already registered") + raise ValueError(f"'attr_name' {attr_name!r} is already registered") if not issubclass(attr_cls, ExtendedProperty): - raise ValueError(f"{attr_cls!r} must be a subclass of ExtendedProperty") + raise TypeError(f"'attr_cls' {attr_cls!r} must be a subclass of type {ExtendedProperty}") # Check if class attributes are properly defined attr_cls.validate_cls() # ExtendedProperty is not a real field, but a placeholder in the fields list. See @@ -135,11 +136,11 @@ def __init__(self, **kwargs): from ..account import Account self.account = kwargs.pop('account', None) if self.account is not None and not isinstance(self.account, Account): - raise ValueError(f"'account' {self.account!r} must be an Account instance") + raise InvalidTypeError('account', self.account, Account) self.folder = kwargs.pop('folder', None) if self.folder is not None: if not isinstance(self.folder, BaseFolder): - raise ValueError(f"'folder' {self.folder!r} must be a Folder instance") + raise InvalidTypeError('folder', self.folder, BaseFolder) if self.folder.account is not None: if self.account is not None: # Make sure the account from kwargs matches the folder account @@ -178,7 +179,7 @@ def __init__(self, **kwargs): from ..account import Account self.account = kwargs.pop('account', None) if self.account is not None and not isinstance(self.account, Account): - raise ValueError(f"'account' {self.account!r} must be an Account instance") + raise InvalidTypeError('account', self.account, Account) super().__init__(**kwargs) @require_account diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 7d5f6c64..b6b75942 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -7,7 +7,7 @@ from inspect import getmro from threading import Lock -from .errors import TimezoneDefinitionInvalidForYear +from .errors import TimezoneDefinitionInvalidForYear, InvalidTypeError from .fields import SubField, TextField, EmailAddressField, ChoiceField, DateTimeField, EWSElementField, MailboxField, \ Choice, BooleanField, IdField, ExtendedPropertyField, IntegerField, TimeField, EnumField, CharField, EmailField, \ EWSElementListField, EnumListField, FreeBusyStatusField, UnknownEntriesField, MessageField, RecipientAddressField, \ @@ -350,14 +350,14 @@ def validate_field(cls, field, version): :param version: """ if not isinstance(version, Version): - raise ValueError(f"'version' {version!r} must be a Version instance") + raise InvalidTypeError('version', version, Version) # Allow both Field and FieldPath instances and string field paths as input if isinstance(field, str): field = cls.get_field_by_fieldname(fieldname=field) elif isinstance(field, FieldPath): field = field.field if not isinstance(field, Field): - raise ValueError(f"Field {field!r} must be a string, Field or FieldPath instance") + raise InvalidTypeError('field', field, Field) cls.get_field_by_fieldname(fieldname=field.name) # Will raise if field name is invalid if not field.supports_version(version): # The field exists but is not valid for this version @@ -665,7 +665,7 @@ def __hash__(self): @classmethod def from_mailbox(cls, mailbox): if not isinstance(mailbox, Mailbox): - raise ValueError(f"'mailbox' {mailbox!r} must be a Mailbox instance") + raise InvalidTypeError('mailbox', mailbox, Mailbox) return cls(name=mailbox.name, email_address=mailbox.email_address, routing_type=mailbox.routing_type) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 30355497..537909b4 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -19,7 +19,7 @@ from .credentials import OAuth2AuthorizationCodeCredentials, OAuth2Credentials from .errors import TransportError, SessionPoolMinSizeReached, SessionPoolMaxSizeReached, RateLimitError, CASError, \ - ErrorInvalidSchemaVersionForMailboxVersion, UnauthorizedError, MalformedResponseError + ErrorInvalidSchemaVersionForMailboxVersion, UnauthorizedError, MalformedResponseError, InvalidTypeError from .properties import FreeBusyViewOptions, MailboxData, TimeWindow, TimeZone, RoomList, DLMailbox from .services import GetServerTimeZones, GetRoomLists, GetRooms, ResolveNames, GetUserAvailability, \ GetSearchableMailboxes, ExpandDL, ConvertId @@ -61,7 +61,7 @@ class BaseProtocol: def __init__(self, config): from .configuration import Configuration if not isinstance(config, Configuration): - raise ValueError(f"'config' {config!r} must be a Configuration instance") + raise InvalidTypeError('config', config, Configuration) if not config.service_endpoint: raise AttributeError("'config.service_endpoint' must be set") self.config = config @@ -520,7 +520,8 @@ def get_roomlists(self): def get_rooms(self, roomlist): return GetRooms(protocol=self).call(room_list=RoomList(email_address=roomlist)) - def resolve_names(self, names, parent_folders=None, return_full_contact_data=False, search_scope=None, shape=None): + def resolve_names(self, names, parent_folders=None, return_full_contact_data=False, search_scope=None, + shape=None): """Resolve accounts on the server using partial account data, e.g. an email address or initials. :param names: A list of identifiers to query diff --git a/exchangelib/queryset.py b/exchangelib/queryset.py index cacfb3d8..3af399b6 100644 --- a/exchangelib/queryset.py +++ b/exchangelib/queryset.py @@ -3,7 +3,7 @@ from copy import deepcopy from itertools import islice -from .errors import MultipleObjectsReturned, DoesNotExist +from .errors import MultipleObjectsReturned, DoesNotExist, InvalidEnumValue, InvalidTypeError from .fields import FieldPath, FieldOrder from .items import CalendarItem, ID_ONLY from .properties import InvalidField @@ -61,10 +61,10 @@ class QuerySet(SearchableMixIn): def __init__(self, folder_collection, request_type=ITEM): from .folders import FolderCollection if not isinstance(folder_collection, FolderCollection): - raise ValueError(f"folder_collection value {folder_collection!r} must be a FolderCollection instance") + raise InvalidTypeError('folder_collection', folder_collection, FolderCollection) self.folder_collection = folder_collection # A FolderCollection instance if request_type not in self.REQUEST_TYPES: - raise ValueError(f"'request_type' {request_type} must be one of {self.REQUEST_TYPES}") + raise InvalidEnumValue('request_type', request_type, self.REQUEST_TYPES) self.request_type = request_type self.q = Q() # Default to no restrictions self.only_fields = None @@ -136,7 +136,7 @@ def _changekey_field(self): def _additional_fields(self): if not isinstance(self.only_fields, tuple): - raise ValueError(f"'only_fields' value {self.only_fields!r} must be a tuple") + raise InvalidTypeError('only_fields', self.only_fields, tuple) # Remove ItemId and ChangeKey. We get them unconditionally additional_fields = {f for f in self.only_fields if not f.field.is_attribute} if self.request_type != self.ITEM: diff --git a/exchangelib/restriction.py b/exchangelib/restriction.py index 4ad556b7..4660bcc2 100644 --- a/exchangelib/restriction.py +++ b/exchangelib/restriction.py @@ -1,6 +1,7 @@ import logging from copy import copy +from .errors import InvalidEnumValue, InvalidTypeError from .fields import InvalidField, FieldPath, DateTimeBackedDateField from .util import create_element, xml_to_str, value_to_xml_text, is_iterable from .version import EXCHANGE_2010 @@ -75,7 +76,7 @@ def __init__(self, *args, **kwargs): # Parse args which must now be Q objects for q in args: if not isinstance(q, self.__class__): - raise ValueError(f"Non-keyword arg {q!r} must be a Q instance") + raise TypeError(f"Non-keyword arg {q!r} must be of type {Q}") self.children.extend(args) # Parse keyword args and extract the filter @@ -134,7 +135,7 @@ def _get_children_from_kwarg(self, key, value, is_single_kwarg=False): # EWS doesn't have an '__in' operator. Allow '__in' lookups on list and non-list field types, # specifying a list value. We'll emulate it as a set of OR'ed exact matches. if not is_iterable(value, generators_allowed=True): - raise ValueError(f"Value for lookup {key!r} must be a list") + raise TypeError(f"Value for lookup {key!r} must be of type {list}") children = tuple(self.__class__(**{field_path: v}) for v in value) if not children: # This is an '__in' operator with an empty list as the value. We interpret it to mean "is foo @@ -257,7 +258,7 @@ def _op_to_xml(cls, op): return create_element(xml_tag_map[op]) valid_ops = cls.EXACT, cls.IEXACT, cls.CONTAINS, cls.ICONTAINS, cls.STARTSWITH, cls.ISTARTSWITH if op not in valid_ops: - raise ValueError(f"'op' {op!r} must be one of {valid_ops}") + raise InvalidEnumValue('op', op, valid_ops) # For description of Contains attribute values, see # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/contains @@ -358,7 +359,7 @@ def _check_integrity(self): raise ValueError('Query strings cannot be combined with other settings') return if self.conn_type not in self.CONN_TYPES: - raise ValueError(f"'conn_type' {self.conn_type!r} must be one of {self.CONN_TYPES}") + raise InvalidEnumValue('conn_type', self.conn_type, self.CONN_TYPES) if not self.is_leaf(): for q in self.children: if q.query_string and len(self.children) > 1: @@ -367,7 +368,7 @@ def _check_integrity(self): if not self.field_path: raise ValueError("'field_path' must be set") if self.op not in self.OP_TYPES: - raise ValueError(f"'op' {self.op} must be one of {self.OP_TYPES}") + raise InvalidEnumValue('op', self.op, self.OP_TYPES) if self.op == self.EXISTS and self.value is not True: raise ValueError("'value' must be True when operator is EXISTS") if self.value is None: @@ -534,15 +535,15 @@ class Restriction: def __init__(self, q, folders, applies_to): if not isinstance(q, Q): - raise ValueError(f"'q' value {q} must be a Q instance") + raise InvalidTypeError('q', q, Q) if q.is_empty(): raise ValueError("Q object must not be empty") from .folders import BaseFolder for folder in folders: if not isinstance(folder, BaseFolder): - raise ValueError(f"'folder' value {folder!r} must be a Folder instance") + raise InvalidTypeError('folder', folder, BaseFolder) if applies_to not in self.RESTRICTION_TYPES: - raise ValueError(f"'applies_to' {applies_to!r} must be one of {self.RESTRICTION_TYPES}") + raise InvalidEnumValue('applies_to', applies_to, self.RESTRICTION_TYPES) self.q = q self.folders = folders self.applies_to = applies_to diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 7a547276..e5a5002b 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -19,7 +19,7 @@ SessionPoolMinSizeReached, ErrorIncorrectSchemaVersion, ErrorInvalidRequest, ErrorCorruptData, \ ErrorCannotEmptyFolder, ErrorDeleteDistinguishedFolder, ErrorInvalidSubscription, ErrorInvalidWatermark, \ ErrorInvalidSyncStateData, ErrorNameResolutionNoResults, ErrorNameResolutionMultipleResults, \ - ErrorConnectionFailedTransientError, ErrorDelegateNoUser, ErrorNotDelegate + ErrorConnectionFailedTransientError, ErrorDelegateNoUser, ErrorNotDelegate, InvalidTypeError from ..folders import BaseFolder, Folder, RootOfHierarchy from ..items import BaseItem from ..properties import FieldURI, IndexedFieldURI, ExtendedFieldURI, ExceptionFieldURI, ItemId, FolderId, \ @@ -99,12 +99,13 @@ class EWSService(metaclass=abc.ABCMeta): def __init__(self, protocol, chunk_size=None, timeout=None): self.chunk_size = chunk_size or CHUNK_SIZE if not isinstance(self.chunk_size, int): - raise ValueError(f"'chunk_size' {self.chunk_size!r} must be an integer") + raise InvalidTypeError('chunk_size', chunk_size, int) if self.chunk_size < 1: - raise ValueError("'chunk_size' must be a positive number") + raise ValueError(f"'chunk_size' {self.chunk_size} must be a positive number") if self.supported_from and protocol.version.build < self.supported_from: raise NotImplementedError( - f'{self.SERVICE_NAME!r} is only supported on {self.supported_from.fullname()!r} and later' + f'{self.SERVICE_NAME!r} is only supported on {self.supported_from.fullname()!r} and later. ' + f'Your current version is {protocol.version.build.fullname()!r}.' ) self.protocol = protocol # Allow a service to override the default protocol timeout. Useful for streaming services @@ -697,7 +698,7 @@ class EWSPagingService(EWSAccountService): def __init__(self, *args, **kwargs): self.page_size = kwargs.pop('page_size', None) or PAGE_SIZE if not isinstance(self.page_size, int): - raise ValueError(f"'page_size' {self.page_size!r} must be an integer") + raise InvalidTypeError('page_size', self.page_size, int) if self.page_size < 1: raise ValueError("'page_size' must be a positive number") super().__init__(*args, **kwargs) diff --git a/exchangelib/services/convert_id.py b/exchangelib/services/convert_id.py index 69d63ffe..8ab37f51 100644 --- a/exchangelib/services/convert_id.py +++ b/exchangelib/services/convert_id.py @@ -1,4 +1,5 @@ from .common import EWSService +from ..errors import InvalidEnumValue, InvalidTypeError from ..properties import AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId, ID_FORMATS from ..util import create_element, set_xml_value from ..version import EXCHANGE_2007_SP1 @@ -19,7 +20,7 @@ class ConvertId(EWSService): def call(self, items, destination_format): if destination_format not in ID_FORMATS: - raise ValueError(f"'destination_format' {destination_format!r} must be one of {ID_FORMATS}") + raise InvalidEnumValue('destination_format', destination_format, ID_FORMATS) return self._elems_to_objs( self._chunked_get_elements(self.get_payload, items=items, destination_format=destination_format) ) @@ -33,7 +34,7 @@ def get_payload(self, items, destination_format): item_ids = create_element('m:SourceIds') for item in items: if not isinstance(item, supported_item_classes): - raise ValueError(f"'item' value {item!r} must be an instance of {supported_item_classes}") + raise InvalidTypeError('item', item, supported_item_classes) set_xml_value(item_ids, item, version=self.protocol.version) payload.append(item_ids) return payload diff --git a/exchangelib/services/create_item.py b/exchangelib/services/create_item.py index b89fc9e9..4cb33e4d 100644 --- a/exchangelib/services/create_item.py +++ b/exchangelib/services/create_item.py @@ -1,4 +1,5 @@ from .common import EWSAccountService, folder_ids_element +from ..errors import InvalidEnumValue, InvalidTypeError from ..folders import BaseFolder from ..items import SAVE_ONLY, SEND_AND_SAVE_COPY, SEND_ONLY, \ SEND_MEETING_INVITATIONS_CHOICES, MESSAGE_DISPOSITION_CHOICES, BulkCreateResult @@ -18,17 +19,14 @@ class CreateItem(EWSAccountService): def call(self, items, folder, message_disposition, send_meeting_invitations): if message_disposition not in MESSAGE_DISPOSITION_CHOICES: - raise ValueError( - f"'message_disposition' {message_disposition!r} must be one of {MESSAGE_DISPOSITION_CHOICES}" - ) + raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES) if send_meeting_invitations not in SEND_MEETING_INVITATIONS_CHOICES: - raise ValueError( - f"'send_meeting_invitations' {send_meeting_invitations!r} must be one of " - f"{SEND_MEETING_INVITATIONS_CHOICES}" + raise InvalidEnumValue( + 'send_meeting_invitations', send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES ) if folder is not None: if not isinstance(folder, (BaseFolder, FolderId)): - raise ValueError(f"'folder' {folder!r} must be a Folder or FolderId instance") + raise InvalidTypeError('folder', folder, (BaseFolder, FolderId)) if folder.account != self.account: raise ValueError('"Folder must belong to this account') if message_disposition == SAVE_ONLY and folder is None: diff --git a/exchangelib/services/delete_folder.py b/exchangelib/services/delete_folder.py index 713ad0b1..a063d08c 100644 --- a/exchangelib/services/delete_folder.py +++ b/exchangelib/services/delete_folder.py @@ -1,4 +1,5 @@ from .common import EWSAccountService, folder_ids_element +from ..errors import InvalidEnumValue from ..items import DELETE_TYPE_CHOICES from ..util import create_element @@ -11,7 +12,7 @@ class DeleteFolder(EWSAccountService): def call(self, folders, delete_type): if delete_type not in DELETE_TYPE_CHOICES: - raise ValueError(f"'delete_type' {delete_type!r} must be one of {DELETE_TYPE_CHOICES}") + raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES) return self._chunked_get_elements(self.get_payload, items=folders, delete_type=delete_type) def get_payload(self, folders, delete_type): diff --git a/exchangelib/services/delete_item.py b/exchangelib/services/delete_item.py index d46626fc..1aac043b 100644 --- a/exchangelib/services/delete_item.py +++ b/exchangelib/services/delete_item.py @@ -1,4 +1,5 @@ from .common import EWSAccountService, item_ids_element +from ..errors import InvalidEnumValue from ..items import DELETE_TYPE_CHOICES, SEND_MEETING_CANCELLATIONS_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES from ..util import create_element from ..version import EXCHANGE_2013_SP1 @@ -16,13 +17,15 @@ class DeleteItem(EWSAccountService): def call(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): if delete_type not in DELETE_TYPE_CHOICES: - raise ValueError(f"'delete_type' {delete_type} must be one of {DELETE_TYPE_CHOICES}") + raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES) if send_meeting_cancellations not in SEND_MEETING_CANCELLATIONS_CHOICES: - raise ValueError(f"'send_meeting_cancellations' {send_meeting_cancellations} must be one of " - f"{SEND_MEETING_CANCELLATIONS_CHOICES}") + raise InvalidEnumValue( + 'send_meeting_cancellations', send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES + ) if affected_task_occurrences not in AFFECTED_TASK_OCCURRENCES_CHOICES: - raise ValueError(f"'affected_task_occurrences' {affected_task_occurrences} must be one of " - f"{AFFECTED_TASK_OCCURRENCES_CHOICES}") + raise InvalidEnumValue( + 'affected_task_occurrences', affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES + ) return self._chunked_get_elements( self.get_payload, items=items, diff --git a/exchangelib/services/empty_folder.py b/exchangelib/services/empty_folder.py index a1a2a4e2..209c832e 100644 --- a/exchangelib/services/empty_folder.py +++ b/exchangelib/services/empty_folder.py @@ -1,4 +1,5 @@ from .common import EWSAccountService, folder_ids_element +from ..errors import InvalidEnumValue from ..items import DELETE_TYPE_CHOICES from ..util import create_element @@ -11,7 +12,7 @@ class EmptyFolder(EWSAccountService): def call(self, folders, delete_type, delete_sub_folders): if delete_type not in DELETE_TYPE_CHOICES: - raise ValueError(f"'delete_type' {delete_type!r} must be one of {DELETE_TYPE_CHOICES}") + raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES) return self._chunked_get_elements( self.get_payload, items=folders, delete_type=delete_type, delete_sub_folders=delete_sub_folders ) diff --git a/exchangelib/services/find_folder.py b/exchangelib/services/find_folder.py index 5333eb40..6579e9d7 100644 --- a/exchangelib/services/find_folder.py +++ b/exchangelib/services/find_folder.py @@ -1,4 +1,5 @@ from .common import EWSPagingService, shape_element, folder_ids_element +from ..errors import InvalidEnumValue from ..folders import Folder from ..folders.queryset import FOLDER_TRAVERSAL_CHOICES from ..items import SHAPE_CHOICES @@ -31,14 +32,14 @@ def call(self, folders, additional_fields, restriction, shape, depth, max_items, :return: XML elements for the matching folders """ + if shape not in SHAPE_CHOICES: + raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + if depth not in FOLDER_TRAVERSAL_CHOICES: + raise InvalidEnumValue('depth', depth, FOLDER_TRAVERSAL_CHOICES) roots = {f.root for f in folders} if len(roots) != 1: - raise ValueError(f'FindFolder must be called with folders in the same root hierarchy ({roots})') + raise ValueError(f"All folders in 'roots' must have the same root hierarchy ({roots})") self.root = roots.pop() - if shape not in SHAPE_CHOICES: - raise ValueError(f"'shape' {shape!r} must be one of {SHAPE_CHOICES}") - if depth not in FOLDER_TRAVERSAL_CHOICES: - raise ValueError(f"'depth' {depth!r} must be one of {FOLDER_TRAVERSAL_CHOICES}") return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, diff --git a/exchangelib/services/find_item.py b/exchangelib/services/find_item.py index 22fe1f2e..6ace4756 100644 --- a/exchangelib/services/find_item.py +++ b/exchangelib/services/find_item.py @@ -1,4 +1,5 @@ from .common import EWSPagingService, shape_element, folder_ids_element +from ..errors import InvalidEnumValue from ..folders.base import BaseFolder from ..items import Item, ID_ONLY, SHAPE_CHOICES, ITEM_TRAVERSAL_CHOICES from ..util import create_element, set_xml_value, TNS, MNS @@ -36,9 +37,9 @@ def call(self, folders, additional_fields, restriction, order_fields, shape, que :return: XML elements for the matching items """ if shape not in SHAPE_CHOICES: - raise ValueError(f"'shape' {shape!r} must be one of {SHAPE_CHOICES}") + raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) if depth not in ITEM_TRAVERSAL_CHOICES: - raise ValueError(f"'depth' {depth!r} must be one of {ITEM_TRAVERSAL_CHOICES}") + raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape return self._elems_to_objs(self._paged_call( diff --git a/exchangelib/services/find_people.py b/exchangelib/services/find_people.py index bf64a74a..1eeea5e3 100644 --- a/exchangelib/services/find_people.py +++ b/exchangelib/services/find_people.py @@ -1,5 +1,6 @@ import logging from .common import EWSPagingService, shape_element, folder_ids_element +from ..errors import InvalidEnumValue from ..items import Persona, ID_ONLY, SHAPE_CHOICES, ITEM_TRAVERSAL_CHOICES from ..util import create_element, set_xml_value, MNS from ..version import EXCHANGE_2013 @@ -37,9 +38,9 @@ def call(self, folder, additional_fields, restriction, order_fields, shape, quer :return: XML elements for the matching items """ if shape not in SHAPE_CHOICES: - raise ValueError(f"'shape' {shape!r} must be one of {SHAPE_CHOICES}") + raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) if depth not in ITEM_TRAVERSAL_CHOICES: - raise ValueError(f"'depth' {depth!r} must be one of {ITEM_TRAVERSAL_CHOICES}") + raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape return self._elems_to_objs(self._paged_call( diff --git a/exchangelib/services/get_attachment.py b/exchangelib/services/get_attachment.py index 8730fbab..cd5b2e6e 100644 --- a/exchangelib/services/get_attachment.py +++ b/exchangelib/services/get_attachment.py @@ -2,6 +2,7 @@ from .common import EWSAccountService, attachment_ids_element from ..attachments import FileAttachment, ItemAttachment +from ..errors import InvalidEnumValue from ..util import create_element, add_xml_child, set_xml_value, DummyResponse, StreamingBase64Parser,\ StreamingContentHandler, ElementNotFound, MNS @@ -18,7 +19,7 @@ class GetAttachment(EWSAccountService): def call(self, items, include_mime_content, body_type, filter_html_content, additional_fields): if body_type and body_type not in BODY_TYPE_CHOICES: - raise ValueError(f"'body_type' {body_type!r} must be one of {BODY_TYPE_CHOICES}") + raise InvalidEnumValue('body_type', body_type, BODY_TYPE_CHOICES) return self._elems_to_objs(self._chunked_get_elements( self.get_payload, items=items, include_mime_content=include_mime_content, body_type=body_type, filter_html_content=filter_html_content, additional_fields=additional_fields, diff --git a/exchangelib/services/get_streaming_events.py b/exchangelib/services/get_streaming_events.py index 2c110864..e5439935 100644 --- a/exchangelib/services/get_streaming_events.py +++ b/exchangelib/services/get_streaming_events.py @@ -1,7 +1,7 @@ import logging from .common import EWSAccountService, add_xml_child -from ..errors import EWSError +from ..errors import EWSError, InvalidTypeError from ..properties import Notification from ..util import create_element, get_xml_attr, get_xml_attrs, MNS, DocumentYielder, DummyResponse @@ -29,8 +29,10 @@ def __init__(self, *args, **kwargs): self.streaming = True def call(self, subscription_ids, connection_timeout): + if not isinstance(connection_timeout, int): + raise InvalidTypeError('connection_timeout', connection_timeout, int) if connection_timeout < 1: - raise ValueError("'connection_timeout' must be a positive integer") + raise ValueError(f"'connection_timeout' {connection_timeout} must be a positive integer") return self._elems_to_objs(self._get_elements(payload=self.get_payload( subscription_ids=subscription_ids, connection_timeout=connection_timeout, ))) diff --git a/exchangelib/services/get_user_configuration.py b/exchangelib/services/get_user_configuration.py index ba091b27..7ddba345 100644 --- a/exchangelib/services/get_user_configuration.py +++ b/exchangelib/services/get_user_configuration.py @@ -1,4 +1,5 @@ from .common import EWSAccountService +from ..errors import InvalidEnumValue from ..properties import UserConfiguration from ..util import create_element, set_xml_value @@ -19,7 +20,7 @@ class GetUserConfiguration(EWSAccountService): def call(self, user_configuration_name, properties): if properties not in PROPERTIES_CHOICES: - raise ValueError(f"'properties' {properties!r} must be one of {PROPERTIES_CHOICES}") + raise InvalidEnumValue('properties', properties, PROPERTIES_CHOICES) return self._elems_to_objs(self._get_elements(payload=self.get_payload( user_configuration_name=user_configuration_name, properties=properties ))) diff --git a/exchangelib/services/move_folder.py b/exchangelib/services/move_folder.py index 9dbf5898..ca0de96c 100644 --- a/exchangelib/services/move_folder.py +++ b/exchangelib/services/move_folder.py @@ -1,4 +1,5 @@ from .common import EWSAccountService, folder_ids_element +from ..errors import InvalidTypeError from ..folders import BaseFolder from ..properties import FolderId from ..util import create_element, MNS @@ -12,7 +13,7 @@ class MoveFolder(EWSAccountService): def call(self, folders, to_folder): if not isinstance(to_folder, (BaseFolder, FolderId)): - raise ValueError(f"'to_folder' {to_folder!r} must be a Folder or FolderId instance") + raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId)) return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=folders, to_folder=to_folder)) def _elem_to_obj(self, elem): diff --git a/exchangelib/services/move_item.py b/exchangelib/services/move_item.py index dbb0cc6d..57054869 100644 --- a/exchangelib/services/move_item.py +++ b/exchangelib/services/move_item.py @@ -1,4 +1,5 @@ from .common import EWSAccountService, item_ids_element, folder_ids_element +from ..errors import InvalidTypeError from ..folders import BaseFolder from ..items import Item from ..properties import FolderId @@ -13,7 +14,7 @@ class MoveItem(EWSAccountService): def call(self, items, to_folder): if not isinstance(to_folder, (BaseFolder, FolderId)): - raise ValueError(f"'to_folder' {to_folder!r} must be a Folder or FolderId instance") + raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId)) return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, to_folder=to_folder)) def _elem_to_obj(self, elem): diff --git a/exchangelib/services/resolve_names.py b/exchangelib/services/resolve_names.py index 85fd186f..92453597 100644 --- a/exchangelib/services/resolve_names.py +++ b/exchangelib/services/resolve_names.py @@ -1,7 +1,7 @@ import logging from .common import EWSService, folder_ids_element -from ..errors import ErrorNameResolutionNoResults, ErrorNameResolutionMultipleResults +from ..errors import ErrorNameResolutionNoResults, ErrorNameResolutionMultipleResults, InvalidEnumValue from ..items import SHAPE_CHOICES, SEARCH_SCOPE_CHOICES, Contact from ..properties import Mailbox from ..util import create_element, add_xml_child, MNS @@ -34,9 +34,9 @@ def call(self, unresolved_entries, parent_folders=None, return_full_contact_data f'candidates for a lookup', ) if search_scope and search_scope not in SEARCH_SCOPE_CHOICES: - raise ValueError(f"'search_scope' {search_scope} must be one of {SEARCH_SCOPE_CHOICES}") + raise InvalidEnumValue('search_scope', search_scope, SEARCH_SCOPE_CHOICES) if contact_data_shape and contact_data_shape not in SHAPE_CHOICES: - raise ValueError(f"'shape' {contact_data_shape} must be one of {SHAPE_CHOICES}") + raise InvalidEnumValue('contact_data_shape', contact_data_shape, SHAPE_CHOICES) self.return_full_contact_data = return_full_contact_data return self._elems_to_objs(self._chunked_get_elements( self.get_payload, diff --git a/exchangelib/services/send_item.py b/exchangelib/services/send_item.py index b7bb8646..e85af7d3 100644 --- a/exchangelib/services/send_item.py +++ b/exchangelib/services/send_item.py @@ -1,4 +1,5 @@ from .common import EWSAccountService, item_ids_element, folder_ids_element +from ..errors import InvalidTypeError from ..folders import BaseFolder from ..properties import FolderId from ..util import create_element @@ -12,7 +13,7 @@ class SendItem(EWSAccountService): def call(self, items, saved_item_folder): if saved_item_folder and not isinstance(saved_item_folder, (BaseFolder, FolderId)): - raise ValueError(f"'saved_item_folder' {saved_item_folder!r} must be a Folder or FolderId instance") + raise InvalidTypeError('saved_item_folder', saved_item_folder, (BaseFolder, FolderId)) return self._chunked_get_elements(self.get_payload, items=items, saved_item_folder=saved_item_folder) def get_payload(self, items, saved_item_folder): diff --git a/exchangelib/services/send_notification.py b/exchangelib/services/send_notification.py index db0711fa..537194b4 100644 --- a/exchangelib/services/send_notification.py +++ b/exchangelib/services/send_notification.py @@ -1,4 +1,5 @@ from .common import EWSService, add_xml_child +from ..errors import InvalidEnumValue from ..properties import Notification from ..transport import wrap from ..util import create_element, MNS @@ -37,7 +38,7 @@ def _get_elements_in_container(cls, container): def get_payload(self, status): if status not in self.STATUS_CHOICES: - raise ValueError(f"'status' {status!r} must be one of {self.STATUS_CHOICES}") + raise InvalidEnumValue('status', status, self.STATUS_CHOICES) payload = create_element(f'm:{self.SERVICE_NAME}Result') add_xml_child(payload, 'm:SubscriptionStatus', status) return payload diff --git a/exchangelib/services/set_user_oof_settings.py b/exchangelib/services/set_user_oof_settings.py index c4d325a5..79bfcfee 100644 --- a/exchangelib/services/set_user_oof_settings.py +++ b/exchangelib/services/set_user_oof_settings.py @@ -1,4 +1,5 @@ from .common import EWSAccountService +from ..errors import InvalidTypeError from ..properties import AvailabilityMailbox, Mailbox from ..settings import OofSettings from ..util import create_element, set_xml_value, MNS @@ -14,9 +15,9 @@ class SetUserOofSettings(EWSAccountService): def call(self, oof_settings, mailbox): if not isinstance(oof_settings, OofSettings): - raise ValueError(f"'oof_settings' {oof_settings!r} must be an OofSettings instance") + raise InvalidTypeError('oof_settings', oof_settings, OofSettings) if not isinstance(mailbox, Mailbox): - raise ValueError(f"'mailbox' {mailbox!r} must be an Mailbox instance") + raise InvalidTypeError('mailbox', mailbox, Mailbox) return self._get_elements(payload=self.get_payload(oof_settings=oof_settings, mailbox=mailbox)) def get_payload(self, oof_settings, mailbox): diff --git a/exchangelib/services/sync_folder_items.py b/exchangelib/services/sync_folder_items.py index 6595d19d..caa38f0f 100644 --- a/exchangelib/services/sync_folder_items.py +++ b/exchangelib/services/sync_folder_items.py @@ -1,5 +1,6 @@ from .common import add_xml_child, item_ids_element from .sync_folder_hierarchy import SyncFolder +from ..errors import InvalidEnumValue, InvalidTypeError from ..folders import BaseFolder from ..properties import ItemId from ..util import xml_text_to_value, peek, TNS, MNS @@ -27,10 +28,12 @@ def call(self, folder, shape, additional_fields, sync_state, ignore, max_changes self.sync_state = sync_state if max_changes_returned is None: max_changes_returned = self.page_size + if not isinstance(max_changes_returned, int): + raise InvalidTypeError('max_changes_returned', max_changes_returned, int) if max_changes_returned <= 0: raise ValueError(f"'max_changes_returned' {max_changes_returned} must be a positive integer") if sync_scope is not None and sync_scope not in self.SYNC_SCOPES: - raise ValueError(f"'sync_scope' {sync_scope!r} must be one of {self.SYNC_SCOPES}") + raise InvalidEnumValue('sync_scope', sync_scope, self.SYNC_SCOPES) return self._elems_to_objs(self._get_elements(payload=self.get_payload( folder=folder, shape=shape, diff --git a/exchangelib/services/update_item.py b/exchangelib/services/update_item.py index 78b75ba5..877bb5bd 100644 --- a/exchangelib/services/update_item.py +++ b/exchangelib/services/update_item.py @@ -1,4 +1,5 @@ from .common import to_item_id +from ..errors import InvalidEnumValue from ..ewsdatetime import EWSDate from ..items import CONFLICT_RESOLUTION_CHOICES, MESSAGE_DISPOSITION_CHOICES, \ SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, SEND_ONLY, Item, CalendarItem @@ -21,17 +22,13 @@ class UpdateItem(BaseUpdateService): def call(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, suppress_read_receipts): if conflict_resolution not in CONFLICT_RESOLUTION_CHOICES: - raise ValueError( - f"'conflict_resolution' {conflict_resolution!r} must be one of {CONFLICT_RESOLUTION_CHOICES}" - ) + raise InvalidEnumValue('conflict_resolution', conflict_resolution, CONFLICT_RESOLUTION_CHOICES) if message_disposition not in MESSAGE_DISPOSITION_CHOICES: - raise ValueError( - f"'message_disposition' {message_disposition!r} must be one of {MESSAGE_DISPOSITION_CHOICES}" - ) + raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES) if send_meeting_invitations_or_cancellations not in SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES: - raise ValueError( - f"'send_meeting_invitations_or_cancellations' {send_meeting_invitations_or_cancellations!r} must be " - f"one of {SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES}" + raise InvalidEnumValue( + 'send_meeting_invitations_or_cancellations', send_meeting_invitations_or_cancellations, + SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES ) if message_disposition == SEND_ONLY: raise ValueError('Cannot send-only existing objects. Use SendItem service instead') diff --git a/exchangelib/util.py b/exchangelib/util.py index b101c7b7..9b5ce202 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -23,7 +23,8 @@ from pygments.formatters.terminal import TerminalFormatter from pygments.lexers.html import XmlLexer -from .errors import TransportError, RateLimitError, RedirectError, RelativeRedirect, MalformedResponseError +from .errors import TransportError, RateLimitError, RedirectError, RelativeRedirect, MalformedResponseError, \ + InvalidTypeError log = logging.getLogger(__name__) xml_log = logging.getLogger(f'{__name__}.xml') @@ -242,7 +243,7 @@ def set_xml_value(elem, value, version=None): elem.append(value.to_xml()) elif isinstance(value, EWSElement): if not isinstance(version, Version): - raise ValueError(f"'version' {version!r} must be a Version instance") + raise InvalidTypeError('version', version, Version) elem.append(value.to_xml(version=version)) elif is_iterable(value, generators_allowed=True): for v in value: diff --git a/exchangelib/version.py b/exchangelib/version.py index 8c7162b5..dda9cf04 100644 --- a/exchangelib/version.py +++ b/exchangelib/version.py @@ -1,7 +1,7 @@ import logging import re -from .errors import TransportError, ResponseMessageError +from .errors import TransportError, ResponseMessageError, InvalidTypeError from .util import xml_to_str, TNS log = logging.getLogger(__name__) @@ -66,13 +66,13 @@ class Build: def __init__(self, major_version, minor_version, major_build=0, minor_build=0): if not isinstance(major_version, int): - raise ValueError(f"'major_version' {major_version!r} must be an integer") + raise InvalidTypeError('major_version', major_version, int) if not isinstance(minor_version, int): - raise ValueError(f"'minor_version' {minor_version!r} must be an integer") + raise InvalidTypeError('minor_version', minor_version, int) if not isinstance(major_build, int): - raise ValueError(f"'major_build' {major_build!r} must be an integer") + raise InvalidTypeError('major_build', major_build, int) if not isinstance(minor_build, int): - raise ValueError(f"'minor_build' {minor_build!r} must be an integer") + raise InvalidTypeError('minor_build', minor_build, int) self.major_version = major_version self.minor_version = minor_version self.major_build = major_build @@ -190,13 +190,13 @@ class Version: def __init__(self, build, api_version=None): if api_version is None: if not isinstance(build, Build): - raise ValueError(f"'build' {build!r} must be a Build instance") + raise InvalidTypeError('build', build, Build) self.api_version = build.api_version() else: if not isinstance(build, (Build, type(None))): - raise ValueError(f"'build' {build!r} must be a Build instance") + raise InvalidTypeError('build', build, Build) if not isinstance(api_version, str): - raise ValueError(f"'api_version' {api_version!r} must be a string") + raise InvalidTypeError('api_version', api_version, str) self.api_version = api_version self.build = build diff --git a/tests/test_account.py b/tests/test_account.py index 92df79e3..0c1cbe77 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -35,9 +35,8 @@ def test_validation(self): Account(primary_smtp_address='blah@example.com', autodiscover=False) self.assertEqual(str(e.exception), 'non-autodiscover requires a config') with self.assertRaises(ValueError) as e: - # access type must be one of ACCESS_TYPES Account(primary_smtp_address='blah@example.com', access_type=123) - self.assertEqual(str(e.exception), "'access_type' 123 must be one of ('impersonation', 'delegate')") + self.assertEqual(str(e.exception), "'access_type' 123 must be one of ['delegate', 'impersonation']") with self.assertRaises(ValueError) as e: # locale must be a string Account(primary_smtp_address='blah@example.com', locale=123) diff --git a/tests/test_configuration.py b/tests/test_configuration.py index da7cb954..aadee840 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -15,9 +15,12 @@ class ConfigurationTest(TimedTestCase): def test_init(self): - with self.assertRaises(ValueError) as e: + with self.assertRaises(TypeError) as e: Configuration(credentials='foo') - self.assertEqual(e.exception.args[0], "'credentials' 'foo' must be a Credentials instance") + self.assertEqual( + e.exception.args[0], + "'credentials' 'foo' must be of type " + ) with self.assertRaises(ValueError) as e: Configuration(credentials=None, auth_type=NTLM) self.assertEqual(e.exception.args[0], "Auth type 'NTLM' was detected but no credentials were provided") @@ -30,15 +33,17 @@ def test_init(self): e.exception.args[0], f"'auth_type' 'foo' must be one of {sorted(AUTH_TYPE_MAP)}" ) - with self.assertRaises(ValueError) as e: + with self.assertRaises(TypeError) as e: Configuration(version='foo') - self.assertEqual(e.exception.args[0], "'version' 'foo' must be a Version instance") - with self.assertRaises(ValueError) as e: + self.assertEqual(e.exception.args[0], "'version' 'foo' must be of type ") + with self.assertRaises(TypeError) as e: Configuration(retry_policy='foo') - self.assertEqual(e.exception.args[0], "'retry_policy' 'foo' must be a RetryPolicy instance") - with self.assertRaises(ValueError) as e: + self.assertEqual( + e.exception.args[0], "'retry_policy' 'foo' must be of type " + ) + with self.assertRaises(TypeError) as e: Configuration(max_connections='foo') - self.assertEqual(e.exception.args[0], "'max_connections' 'foo' must be an integer") + self.assertEqual(e.exception.args[0], "'max_connections' 'foo' must be of type ") self.assertEqual(Configuration().server, None) # Test that property works when service_endpoint is None def test_magic(self): diff --git a/tests/test_ewsdatetime.py b/tests/test_ewsdatetime.py index 89b47f69..fd528257 100644 --- a/tests/test_ewsdatetime.py +++ b/tests/test_ewsdatetime.py @@ -189,9 +189,9 @@ def test_ewsdatetime(self): with self.assertRaises(ValueError): dt.ewsformat() # Test wrong tzinfo type - with self.assertRaises(ValueError): + with self.assertRaises(TypeError): EWSDateTime(2000, 1, 2, 3, 4, 5, tzinfo=pytz.timezone('Europe/Copenhagen')) - with self.assertRaises(ValueError): + with self.assertRaises(TypeError): EWSDateTime.from_datetime(EWSDateTime(2000, 1, 2, 3, 4, 5)) def test_generate(self): @@ -231,5 +231,5 @@ def test_ewsdate(self): self.assertIsInstance(dt, EWSDate) self.assertEqual(dt, EWSDate(2000, 1, 1)) - with self.assertRaises(ValueError): + with self.assertRaises(TypeError): EWSDate.from_date(EWSDate(2000, 1, 2)) diff --git a/tests/test_extended_properties.py b/tests/test_extended_properties.py index b8db63d3..7bbe4c3c 100644 --- a/tests/test_extended_properties.py +++ b/tests/test_extended_properties.py @@ -50,7 +50,7 @@ class TestProp(ExtendedProperty): # Test deregister with self.assertRaises(ValueError): self.ITEM_CLASS.register(attr_name=attr_name, attr_cls=TestProp) # Already registered - with self.assertRaises(ValueError): + with self.assertRaises(TypeError): self.ITEM_CLASS.register(attr_name='XXX', attr_cls=Mailbox) # Not an extended property finally: self.ITEM_CLASS.deregister(attr_name=attr_name) diff --git a/tests/test_field.py b/tests/test_field.py index 744c32e4..3e7b428c 100644 --- a/tests/test_field.py +++ b/tests/test_field.py @@ -28,9 +28,9 @@ def test_value_validation(self): self.assertEqual(field.clean(None), 'XXX') field = CharListField('foo', field_uri='bar') - with self.assertRaises(ValueError) as e: + with self.assertRaises(TypeError) as e: field.clean('XXX') # Must be a list type - self.assertEqual(str(e.exception), "Field 'foo' value 'XXX' must be a list") + self.assertEqual(str(e.exception), "Field 'foo' value 'XXX' must be of type ") field = CharListField('foo', field_uri='bar') with self.assertRaises(TypeError) as e: @@ -70,7 +70,7 @@ def test_value_validation(self): self.assertEqual(str(e.exception), "'foo' is a required field") with self.assertRaises(TypeError) as e: field.clean(123) # Correct type is required - self.assertEqual(str(e.exception), "'ExternId' value 123 must be an instance of ") + self.assertEqual(str(e.exception), "Field 'ExternId' value 123 must be of type ") self.assertEqual(field.clean('XXX'), 'XXX') # We can clean a simple value and keep it as a simple value self.assertEqual(field.clean(ExternId('XXX')), ExternId('XXX')) # We can clean an ExternId instance as well @@ -81,12 +81,12 @@ class ExternIdArray(ExternId): with self.assertRaises(ValueError)as e: field.clean(None) # Value is required self.assertEqual(str(e.exception), "'foo' is a required field") - with self.assertRaises(ValueError)as e: + with self.assertRaises(TypeError)as e: field.clean(123) # Must be an iterable - self.assertEqual(str(e.exception), "'ExternIdArray' value 123 must be a list") + self.assertEqual(str(e.exception), "Field 'ExternIdArray' value 123 must be of type ") with self.assertRaises(TypeError) as e: field.clean([123]) # Correct type is required - self.assertEqual(str(e.exception), "'ExternIdArray' value element 123 must be an instance of ") + self.assertEqual(str(e.exception), "Field 'ExternIdArray' list value 123 must be of type ") # Test min/max on IntegerField field = IntegerField('foo', field_uri='bar', min=5, max=10) diff --git a/tests/test_folder.py b/tests/test_folder.py index 9ad1d1e7..19a5e121 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -11,7 +11,7 @@ OrganizationalContacts, PeopleCentricConversationBuddies, PublicFoldersRoot, NON_DELETABLE_FOLDERS from exchangelib.properties import Mailbox, InvalidField, EffectiveRights, PermissionSet, CalendarPermission, UserId from exchangelib.queryset import Q -from exchangelib.services import GetFolder +from exchangelib.services import GetFolder, DeleteFolder, FindFolder from exchangelib.version import EXCHANGE_2007 from .common import EWSTest, get_random_string, get_random_int, get_random_bool, get_random_datetime, get_random_bytes,\ @@ -74,6 +74,47 @@ def test_public_folders_root(self): 0, ) + def test_invalid_deletefolder_args(self): + with self.assertRaises(ValueError) as e: + DeleteFolder(account=self.account).call( + folders=[], + delete_type='XXX', + ) + self.assertEqual( + e.exception.args[0], + "'delete_type' 'XXX' must be one of ['HardDelete', 'MoveToDeletedItems', 'SoftDelete']" + ) + + def test_invalid_findfolder_args(self): + with self.assertRaises(ValueError) as e: + FindFolder(account=self.account).call( + folders=['XXX'], + additional_fields=None, + restriction=None, + shape='XXX', + depth='Shallow', + max_items=None, + offset=None, + ) + self.assertEqual( + e.exception.args[0], + "'shape' 'XXX' must be one of ['AllProperties', 'Default', 'IdOnly']" + ) + with self.assertRaises(ValueError) as e: + FindFolder(account=self.account).call( + folders=['XXX'], + additional_fields=None, + restriction=None, + shape='IdOnly', + depth='XXX', + max_items=None, + offset=None, + ) + self.assertEqual( + e.exception.args[0], + "'depth' 'XXX' must be one of ['Deep', 'Shallow', 'SoftDeleted']" + ) + def test_find_folders(self): folders = list(FolderCollection(account=self.account, folders=[self.account.root]).find_folders()) self.assertGreater(len(folders), 40, sorted(f.name for f in folders)) @@ -82,13 +123,17 @@ def test_find_folders_multiple_roots(self): coll = FolderCollection(account=self.account, folders=[self.account.root, self.account.public_folders_root]) with self.assertRaises(ValueError) as e: list(coll.find_folders(depth='Shallow')) - self.assertIn('FindFolder must be called with folders in the same root hierarchy', e.exception.args[0]) + self.assertIn("All folders in 'roots' must have the same root hierarchy", e.exception.args[0]) def test_find_folders_compat(self): - with self.assertRaises(NotImplementedError) as e: - coll = FolderCollection(account=self.account, folders=[self.account.root]) - self.account.protocol.version.build = EXCHANGE_2007 # Need to set it after the last auto-config of version - list(coll.find_folders(offset=1)) + coll = FolderCollection(account=self.account, folders=[self.account.root]) + tmp = self.account.protocol.version.build + self.account.protocol.version.build = EXCHANGE_2007 # Need to set it after the last auto-config of version + try: + with self.assertRaises(NotImplementedError) as e: + list(coll.find_folders(offset=1)) + finally: + self.account.protocol.version.build = tmp self.assertEqual( e.exception.args[0], "'offset' is only supported for Exchange 2010 servers and later" @@ -306,9 +351,11 @@ def test_parent(self): ) # Setters parent = self.account.calendar.parent - with self.assertRaises(ValueError) as e: + with self.assertRaises(TypeError) as e: self.account.calendar.parent = 'XXX' - self.assertEqual(e.exception.args[0], "'value' 'XXX' must be a Folder instance") + self.assertEqual( + e.exception.args[0], "'value' 'XXX' must be of type " + ) self.account.calendar.parent = None self.account.calendar.parent = parent @@ -497,9 +544,13 @@ def test_move(self): f2 = Folder(parent=self.account.inbox, name=get_random_string(16)).save() f1_id, f1_changekey, f1_parent = f1.id, f1.changekey, f1.parent - with self.assertRaises(ValueError) as e: + with self.assertRaises(TypeError) as e: f1.move(to_folder='XXX') # Must be folder instance - self.assertEqual(e.exception.args[0], "'to_folder' 'XXX' must be a Folder or FolderId instance") + self.assertEqual( + e.exception.args[0], + "'to_folder' 'XXX' must be of type (, " + ")" + ) f1.move(f2) self.assertEqual(f1.id, f1_id) self.assertNotEqual(f1.changekey, f1_changekey) @@ -602,7 +653,7 @@ def test_folder_query_set(self): f0.delete() def test_folder_query_set_failures(self): - with self.assertRaises(ValueError): + with self.assertRaises(TypeError): FolderQuerySet('XXX') fld_qs = SingleFolderQuerySet(account=self.account, folder=self.account.inbox) with self.assertRaises(InvalidField): @@ -623,7 +674,7 @@ def test_user_configuration(self): f.get_user_configuration(name=name, properties='XXX') self.assertEqual( e.exception.args[0], - "'properties' 'XXX' must be one of ('Id', 'Dictionary', 'XmlData', 'BinaryData', 'All')" + "'properties' 'XXX' must be one of ['All', 'BinaryData', 'Dictionary', 'Id', 'XmlData']" ) # Should not exist yet diff --git a/tests/test_items/test_generic.py b/tests/test_items/test_generic.py index 9515da62..8e16d2ab 100644 --- a/tests/test_items/test_generic.py +++ b/tests/test_items/test_generic.py @@ -12,7 +12,7 @@ from exchangelib.items import CalendarItem, Message from exchangelib.queryset import QuerySet from exchangelib.restriction import Restriction, Q -from exchangelib.services import CreateItem, UpdateItem, DeleteItem +from exchangelib.services import CreateItem, UpdateItem, DeleteItem, FindItem, FindPeople from exchangelib.version import Build, EXCHANGE_2007, EXCHANGE_2013 from ..common import get_random_string, mock_version @@ -91,9 +91,13 @@ def test_invalid_direct_args(self): item.move(to_folder=self.test_folder) # Must be an existing item item = self.get_test_item() item.save() - with self.assertRaises(ValueError) as e: + with self.assertRaises(TypeError) as e: item.move(to_folder='XXX') # Must be folder instance - self.assertEqual(e.exception.args[0], "'to_folder' 'XXX' must be a Folder or FolderId instance") + self.assertEqual( + e.exception.args[0], + "'to_folder' 'XXX' must be of type (, " + ")" + ) item_id, changekey = item.id, item.changekey item.delete() item.id, item.changekey = item_id, changekey @@ -133,9 +137,13 @@ def test_invalid_kwargs_on_send(self): item.send() # Must have account on send item = self.get_test_item() item.save() - with self.assertRaises(ValueError) as e: + with self.assertRaises(TypeError) as e: item.send(copy_to_folder='XXX', save_copy=True) # Invalid folder - self.assertEqual(e.exception.args[0], "'saved_item_folder' 'XXX' must be a Folder or FolderId instance") + self.assertEqual( + e.exception.args[0], + "'saved_item_folder' 'XXX' must be of type (, " + ")" + ) item_id, changekey = item.id, item.changekey item.delete() item.id, item.changekey = item_id, changekey @@ -155,7 +163,7 @@ def test_invalid_createitem_args(self): ) self.assertEqual( e.exception.args[0], - "'message_disposition' 'XXX' must be one of ('SaveOnly', 'SendOnly', 'SendAndSaveCopy')" + "'message_disposition' 'XXX' must be one of ['SaveOnly', 'SendAndSaveCopy', 'SendOnly']" ) with self.assertRaises(ValueError) as e: CreateItem(account=self.account).call( @@ -166,16 +174,20 @@ def test_invalid_createitem_args(self): ) self.assertEqual( e.exception.args[0], - "'send_meeting_invitations' 'XXX' must be one of ('SendToNone', 'SendOnlyToAll', 'SendToAllAndSaveCopy')" + "'send_meeting_invitations' 'XXX' must be one of ['SendOnlyToAll', 'SendToAllAndSaveCopy', 'SendToNone']" ) - with self.assertRaises(ValueError) as e: + with self.assertRaises(TypeError) as e: CreateItem(account=self.account).call( items=[], folder='XXX', message_disposition='SaveOnly', send_meeting_invitations='SendToNone', ) - self.assertEqual(e.exception.args[0], "'folder' 'XXX' must be a Folder or FolderId instance") + self.assertEqual( + e.exception.args[0], + "'folder' 'XXX' must be of type (, " + ")" + ) def test_invalid_deleteitem_args(self): with self.assertRaises(ValueError) as e: @@ -188,7 +200,7 @@ def test_invalid_deleteitem_args(self): ) self.assertEqual( e.exception.args[0], - "'delete_type' XXX must be one of ('HardDelete', 'SoftDelete', 'MoveToDeletedItems')" + "'delete_type' 'XXX' must be one of ['HardDelete', 'MoveToDeletedItems', 'SoftDelete']" ) with self.assertRaises(ValueError) as e: DeleteItem(account=self.account).call( @@ -200,7 +212,7 @@ def test_invalid_deleteitem_args(self): ) self.assertEqual( e.exception.args[0], - "'send_meeting_cancellations' XXX must be one of ('SendToNone', 'SendOnlyToAll', 'SendToAllAndSaveCopy')" + "'send_meeting_cancellations' 'XXX' must be one of ['SendOnlyToAll', 'SendToAllAndSaveCopy', 'SendToNone']" ) with self.assertRaises(ValueError) as e: DeleteItem(account=self.account).call( @@ -212,7 +224,7 @@ def test_invalid_deleteitem_args(self): ) self.assertEqual( e.exception.args[0], - "'affected_task_occurrences' XXX must be one of ('AllOccurrences', 'SpecifiedOccurrenceOnly')" + "'affected_task_occurrences' 'XXX' must be one of ['AllOccurrences', 'SpecifiedOccurrenceOnly']" ) def test_invalid_updateitem_args(self): @@ -226,7 +238,7 @@ def test_invalid_updateitem_args(self): ) self.assertEqual( e.exception.args[0], - "'conflict_resolution' 'XXX' must be one of ('NeverOverwrite', 'AutoResolve', 'AlwaysOverwrite')" + "'conflict_resolution' 'XXX' must be one of ['AlwaysOverwrite', 'AutoResolve', 'NeverOverwrite']" ) with self.assertRaises(ValueError) as e: UpdateItem(account=self.account).call( @@ -238,7 +250,7 @@ def test_invalid_updateitem_args(self): ) self.assertEqual( e.exception.args[0], - "'message_disposition' 'XXX' must be one of ('SaveOnly', 'SendOnly', 'SendAndSaveCopy')" + "'message_disposition' 'XXX' must be one of ['SaveOnly', 'SendAndSaveCopy', 'SendOnly']" ) with self.assertRaises(ValueError) as e: UpdateItem(account=self.account).call( @@ -251,7 +263,77 @@ def test_invalid_updateitem_args(self): self.assertEqual( e.exception.args[0], "'send_meeting_invitations_or_cancellations' 'XXX' must be one of " - "('SendToNone', 'SendOnlyToAll', 'SendOnlyToChanged', 'SendToAllAndSaveCopy', 'SendToChangedAndSaveCopy')" + "['SendOnlyToAll', 'SendOnlyToChanged', 'SendToAllAndSaveCopy', 'SendToChangedAndSaveCopy', 'SendToNone']" + ) + + def test_invalid_finditem_args(self): + with self.assertRaises(ValueError) as e: + FindItem(account=self.account).call( + folders=None, + additional_fields=None, + restriction=None, + order_fields=None, + shape='XXX', + query_string=None, + depth='Shallow', + calendar_view=None, + max_items=None, + offset=None, + ) + self.assertEqual( + e.exception.args[0], + "'shape' 'XXX' must be one of ['AllProperties', 'Default', 'IdOnly']" + ) + with self.assertRaises(ValueError) as e: + FindItem(account=self.account).call( + folders=None, + additional_fields=None, + restriction=None, + order_fields=None, + shape='IdOnly', + query_string=None, + depth='XXX', + calendar_view=None, + max_items=None, + offset=None, + ) + self.assertEqual( + e.exception.args[0], + "'depth' 'XXX' must be one of ['Associated', 'Shallow', 'SoftDeleted']" + ) + + def test_invalid_findpeople_args(self): + with self.assertRaises(ValueError) as e: + FindPeople(account=self.account).call( + folder=None, + additional_fields=None, + restriction=None, + order_fields=None, + shape='XXX', + query_string=None, + depth='Shallow', + max_items=None, + offset=None, + ) + self.assertEqual( + e.exception.args[0], + "'shape' 'XXX' must be one of ['AllProperties', 'Default', 'IdOnly']" + ) + with self.assertRaises(ValueError) as e: + FindPeople(account=self.account).call( + folder=None, + additional_fields=None, + restriction=None, + order_fields=None, + shape='IdOnly', + query_string=None, + depth='XXX', + max_items=None, + offset=None, + ) + self.assertEqual( + e.exception.args[0], + "'depth' 'XXX' must be one of ['Associated', 'Shallow', 'SoftDeleted']" ) def test_unsupported_fields(self): @@ -478,7 +560,7 @@ def test_finditems(self): common_qs.filter(categories__contains=item.categories).count(), # Exact match 1 ) - with self.assertRaises(ValueError): + with self.assertRaises(TypeError): common_qs.filter(categories__in='ci6xahH1').count() # Plain string is not supported self.assertEqual( common_qs.filter(categories__in=['ci6xahH1']).count(), # Same, but as list @@ -738,7 +820,7 @@ def test_filter_with_querystring(self): applies_to=Restriction.ITEMS) # We don't allow QueryString in combination with other restrictions - with self.assertRaises(ValueError): + with self.assertRaises(TypeError): self.test_folder.filter('Subject:XXX', foo='bar') with self.assertRaises(ValueError): self.test_folder.filter('Subject:XXX').filter(foo='bar') diff --git a/tests/test_items/test_queryset.py b/tests/test_items/test_queryset.py index aa718168..1da5d034 100644 --- a/tests/test_items/test_queryset.py +++ b/tests/test_items/test_queryset.py @@ -59,7 +59,7 @@ def test_querysets(self): [(i.subject, i.categories[0]) for i in qs.order_by('subject').reverse()], [('Item 3', test_cat), ('Item 2', test_cat), ('Item 1', test_cat), ('Item 0', test_cat)] ) - with self.assertRaises(ValueError): + with self.assertRaises(TypeError): list(qs.values([])) self.assertEqual( list(qs.order_by('subject').values('subject')), diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index 61088cbb..bdafb1fa 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -132,7 +132,7 @@ def test_sync_folder_items(self): list(test_folder.sync_items(sync_scope='XXX')) self.assertEqual( e.exception.args[0], - "'sync_scope' 'XXX' must be one of ('NormalItems', 'NormalAndAssociatedItems')" + "'sync_scope' 'XXX' must be one of ['NormalAndAssociatedItems', 'NormalItems']" ) # Test that item_sync_state is set after calling sync_hierarchy @@ -298,7 +298,7 @@ def test_streaming_invalid_subscription(self): # Test with bad connection_timeout with self.assertRaises(ValueError) as e: list(test_folder.get_streaming_events('AAA-', connection_timeout=-1, max_notifications_returned=1)) - self.assertEqual(e.exception.args[0], "'connection_timeout' must be a positive integer") + self.assertEqual(e.exception.args[0], "'connection_timeout' -1 must be a positive integer") # Test a single bad notification with self.assertRaises(ErrorInvalidSubscription) as e: diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 8a9f522c..2a3a9bf6 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -325,20 +325,26 @@ def test_resolvenames(self): self.account.protocol.resolve_names(names=[], search_scope='XXX') self.assertEqual( e.exception.args[0], - f"'search_scope' XXX must be one of {SEARCH_SCOPE_CHOICES}" + f"'search_scope' 'XXX' must be one of {sorted(SEARCH_SCOPE_CHOICES)}" ) with self.assertRaises(ValueError) as e: self.account.protocol.resolve_names(names=[], shape='XXX') - self.assertEqual(e.exception.args[0], "'shape' XXX must be one of ('IdOnly', 'Default', 'AllProperties')") + self.assertEqual( + e.exception.args[0], "'contact_data_shape' 'XXX' must be one of ['AllProperties', 'Default', 'IdOnly']" + ) with self.assertRaises(ValueError) as e: ResolveNames(protocol=self.account.protocol, chunk_size=500).call(unresolved_entries=None) self.assertEqual( e.exception.args[0], "Chunk size 500 is too high. ResolveNames supports returning at most 100 candidates for a lookup" ) - with self.assertRaises(NotImplementedError) as e: - self.account.protocol.version.build = EXCHANGE_2010_SP1 - self.account.protocol.resolve_names(names=['xxx@example.com'], shape='IdOnly') + tmp = self.account.protocol.version.build + self.account.protocol.version.build = EXCHANGE_2010_SP1 + try: + with self.assertRaises(NotImplementedError) as e: + self.account.protocol.resolve_names(names=['xxx@example.com'], shape='IdOnly') + finally: + self.account.protocol.version.build = tmp self.assertEqual( e.exception.args[0], "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later" @@ -569,9 +575,9 @@ def test_oof_settings(self): start=start, end=end, ) - with self.assertRaises(ValueError): + with self.assertRaises(TypeError): self.account.oof_settings = 'XXX' - with self.assertRaises(ValueError): + with self.assertRaises(TypeError): SetUserOofSettings(account=self.account).get( oof_settings=oof, mailbox='XXX', @@ -624,11 +630,11 @@ def test_convert_id(self): self.account.protocol.convert_ids( [AlternateId(id=i, format=EWS_ID, mailbox=self.account.primary_smtp_address)], destination_format='XXX') - self.assertEqual(e.exception.args[0], f"'destination_format' 'XXX' must be one of {ID_FORMATS}") + self.assertEqual(e.exception.args[0], f"'destination_format' 'XXX' must be one of {sorted(ID_FORMATS)}") # Test bad item type - with self.assertRaises(ValueError) as e: + with self.assertRaises(TypeError) as e: list(self.account.protocol.convert_ids([ItemId(id=1)], destination_format='EwsId')) - self.assertIn('must be an instance of', e.exception.args[0]) + self.assertIn('must be of type', e.exception.args[0]) def test_sessionpool(self): # First, empty the calendar diff --git a/tests/test_restriction.py b/tests/test_restriction.py index 7b631d8f..d8e13a77 100644 --- a/tests/test_restriction.py +++ b/tests/test_restriction.py @@ -171,5 +171,5 @@ def test_q_querystring(self): with self.assertRaises(ValueError): Q('this is a QS') & Q(foo='bar') - with self.assertRaises(ValueError): + with self.assertRaises(TypeError): Q(5) diff --git a/tests/test_version.py b/tests/test_version.py index 91ec5e38..980e2c80 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -9,29 +9,29 @@ class VersionTest(TimedTestCase): def test_invalid_version_args(self): - with self.assertRaises(ValueError) as e: + with self.assertRaises(TypeError) as e: Version(build='XXX') - self.assertEqual(e.exception.args[0], "'build' 'XXX' must be a Build instance") - with self.assertRaises(ValueError) as e: + self.assertEqual(e.exception.args[0], "'build' 'XXX' must be of type ") + with self.assertRaises(TypeError) as e: Version(build='XXX', api_version='XXX') - self.assertEqual(e.exception.args[0], "'build' 'XXX' must be a Build instance") - with self.assertRaises(ValueError) as e: + self.assertEqual(e.exception.args[0], "'build' 'XXX' must be of type ") + with self.assertRaises(TypeError) as e: Version(build=Build(15, 1, 2, 3), api_version=999) - self.assertEqual(e.exception.args[0], "'api_version' 999 must be a string") + self.assertEqual(e.exception.args[0], "'api_version' 999 must be of type ") def test_invalid_build_args(self): - with self.assertRaises(ValueError) as e: + with self.assertRaises(TypeError) as e: Build('XXX', 2, 3, 4) - self.assertEqual(e.exception.args[0], "'major_version' 'XXX' must be an integer") - with self.assertRaises(ValueError) as e: + self.assertEqual(e.exception.args[0], "'major_version' 'XXX' must be of type ") + with self.assertRaises(TypeError) as e: Build(1, 'XXX', 3, 4) - self.assertEqual(e.exception.args[0], "'minor_version' 'XXX' must be an integer") - with self.assertRaises(ValueError) as e: + self.assertEqual(e.exception.args[0], "'minor_version' 'XXX' must be of type ") + with self.assertRaises(TypeError) as e: Build(1, 2, 'XXX', 4) - self.assertEqual(e.exception.args[0], "'major_build' 'XXX' must be an integer") - with self.assertRaises(ValueError) as e: + self.assertEqual(e.exception.args[0], "'major_build' 'XXX' must be of type ") + with self.assertRaises(TypeError) as e: Build(1, 2, 3, 'XXX') - self.assertEqual(e.exception.args[0], "'minor_build' 'XXX' must be an integer") + self.assertEqual(e.exception.args[0], "'minor_build' 'XXX' must be of type ") def test_comparison(self): self.assertEqual(Version(Build(15, 1, 2, 3)), Version(Build(15, 1, 2, 3))) From 62b7c1fad581eee93fc9372219947bdad788535d Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 6 Jan 2022 13:55:33 +0100 Subject: [PATCH 097/509] Simplify, as suggested by DeepSource --- exchangelib/account.py | 5 +---- exchangelib/autodiscover/discovery.py | 5 +---- exchangelib/credentials.py | 7 +------ 3 files changed, 3 insertions(+), 14 deletions(-) diff --git a/exchangelib/account.py b/exchangelib/account.py index 9f6354ea..db7c851c 100644 --- a/exchangelib/account.py +++ b/exchangelib/account.py @@ -45,10 +45,7 @@ def __init__(self, primary_smtp_address=None, smtp_address=None, upn=None, sid=N self.sid = sid def __eq__(self, other): - for k in self.__dict__: - if getattr(self, k) != getattr(other, k): - return False - return True + return all(getattr(self, k) == getattr(other, k) for k in self.__dict__) def __hash__(self): return hash(repr(self)) diff --git a/exchangelib/autodiscover/discovery.py b/exchangelib/autodiscover/discovery.py index d6928cfd..1104ca28 100644 --- a/exchangelib/autodiscover/discovery.py +++ b/exchangelib/autodiscover/discovery.py @@ -35,10 +35,7 @@ def __init__(self, priority, weight, port, srv): self.srv = srv def __eq__(self, other): - for k in self.__dict__: - if getattr(self, k) != getattr(other, k): - return False - return True + return all(getattr(self, k) == getattr(other, k) for k in self.__dict__) class Autodiscovery: diff --git a/exchangelib/credentials.py b/exchangelib/credentials.py index e324c080..1e5bb73e 100644 --- a/exchangelib/credentials.py +++ b/exchangelib/credentials.py @@ -47,12 +47,7 @@ def _get_hash_values(self): return (getattr(self, k) for k in self.__dict__ if k != '_lock') def __eq__(self, other): - for k in self.__dict__: - if k == '_lock': - continue - if getattr(self, k) != getattr(other, k): - return False - return True + return all(getattr(self, k) == getattr(other, k) for k in self.__dict__ if k != '_lock') def __hash__(self): return hash(tuple(self._get_hash_values())) From ab2e247a8afb1c735907598237fc0b7d6423a13b Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 6 Jan 2022 13:56:39 +0100 Subject: [PATCH 098/509] Improve code coverage --- tests/test_folder.py | 14 +++++++++++++- tests/test_items/test_calendaritems.py | 11 ++++++++++- tests/test_items/test_sync.py | 6 ++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/tests/test_folder.py b/tests/test_folder.py index 19a5e121..1ededf95 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -11,7 +11,7 @@ OrganizationalContacts, PeopleCentricConversationBuddies, PublicFoldersRoot, NON_DELETABLE_FOLDERS from exchangelib.properties import Mailbox, InvalidField, EffectiveRights, PermissionSet, CalendarPermission, UserId from exchangelib.queryset import Q -from exchangelib.services import GetFolder, DeleteFolder, FindFolder +from exchangelib.services import GetFolder, DeleteFolder, FindFolder, EmptyFolder from exchangelib.version import EXCHANGE_2007 from .common import EWSTest, get_random_string, get_random_int, get_random_bool, get_random_datetime, get_random_bytes,\ @@ -85,6 +85,18 @@ def test_invalid_deletefolder_args(self): "'delete_type' 'XXX' must be one of ['HardDelete', 'MoveToDeletedItems', 'SoftDelete']" ) + def test_invalid_emptyfolder_args(self): + with self.assertRaises(ValueError) as e: + EmptyFolder(account=self.account).call( + folders=[], + delete_type='XXX', + delete_sub_folders=False, + ) + self.assertEqual( + e.exception.args[0], + "'delete_type' 'XXX' must be one of ['HardDelete', 'MoveToDeletedItems', 'SoftDelete']" + ) + def test_invalid_findfolder_args(self): with self.assertRaises(ValueError) as e: FindFolder(account=self.account).call( diff --git a/tests/test_items/test_calendaritems.py b/tests/test_items/test_calendaritems.py index 27f929a7..be64ec9e 100644 --- a/tests/test_items/test_calendaritems.py +++ b/tests/test_items/test_calendaritems.py @@ -490,13 +490,22 @@ def test_invalid_updateitem_items(self): self.account.bulk_update([(item, [])]) self.assertEqual(e.exception.args[0], "'fieldnames' must not be empty") + # Test a field that has is_required=True start = item.start item.start = None with self.assertRaises(ValueError) as e: self.account.bulk_update([(item, ['start'])]) self.assertEqual(e.exception.args[0], "'start' is a required field with no default") - item.start = start + + # Test a field that has is_required_after_safe=True + uid = item.uid + item.uid = None + with self.assertRaises(ValueError) as e: + self.account.bulk_update([(item, ['uid'])]) + self.assertEqual(e.exception.args[0], "'uid' is a required field and may not be deleted") + item.uid = uid + item.is_meeting = None with self.assertRaises(ValueError) as e: self.account.bulk_update([(item, ['is_meeting'])]) diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index bdafb1fa..869c07a0 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -125,6 +125,9 @@ def test_sync_folder_hierarchy(self): def test_sync_folder_items(self): test_folder = self.get_test_folder().save() + with self.assertRaises(TypeError) as e: + list(test_folder.sync_items(max_changes_returned='XXX')) + self.assertEqual(e.exception.args[0], "'max_changes_returned' 'XXX' must be of type ") with self.assertRaises(ValueError) as e: list(test_folder.sync_items(max_changes_returned=-1)) self.assertEqual(e.exception.args[0], "'max_changes_returned' -1 must be a positive integer") @@ -296,6 +299,9 @@ def test_streaming_invalid_subscription(self): self.assertEqual(e.exception.args[0], "'subscription_ids' must not be empty") # Test with bad connection_timeout + with self.assertRaises(TypeError) as e: + list(test_folder.get_streaming_events('AAA-', connection_timeout=-1, max_notifications_returned='XXX')) + self.assertEqual(e.exception.args[0], "'connection_timeout' 'XXX' must be of type ") with self.assertRaises(ValueError) as e: list(test_folder.get_streaming_events('AAA-', connection_timeout=-1, max_notifications_returned=1)) self.assertEqual(e.exception.args[0], "'connection_timeout' -1 must be a positive integer") From 1bbae0e527dc82a45bf3b5946b438d69de96c20f Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 6 Jan 2022 14:09:40 +0100 Subject: [PATCH 099/509] Push timeout calculation to service call --- exchangelib/folders/base.py | 4 +--- exchangelib/services/get_streaming_events.py | 2 ++ tests/test_items/test_sync.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index e6f4ca92..b6f3648a 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -656,9 +656,7 @@ def get_streaming_events(self, subscription_id_or_ids, connection_timeout=1, max sync methods. """ from ..services import GetStreamingEvents - # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed - request_timeout = connection_timeout*60 + 60 - svc = GetStreamingEvents(account=self.account, timeout=request_timeout) + svc = GetStreamingEvents(account=self.account) subscription_ids = subscription_id_or_ids if is_iterable(subscription_id_or_ids, generators_allowed=True) \ else [subscription_id_or_ids] for i, notification in enumerate( diff --git a/exchangelib/services/get_streaming_events.py b/exchangelib/services/get_streaming_events.py index e5439935..a1f9ba15 100644 --- a/exchangelib/services/get_streaming_events.py +++ b/exchangelib/services/get_streaming_events.py @@ -33,6 +33,8 @@ def call(self, subscription_ids, connection_timeout): raise InvalidTypeError('connection_timeout', connection_timeout, int) if connection_timeout < 1: raise ValueError(f"'connection_timeout' {connection_timeout} must be a positive integer") + # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed + self.timeout = connection_timeout * 60 + 60 return self._elems_to_objs(self._get_elements(payload=self.get_payload( subscription_ids=subscription_ids, connection_timeout=connection_timeout, ))) diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index 869c07a0..01ec1f0b 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -300,7 +300,7 @@ def test_streaming_invalid_subscription(self): # Test with bad connection_timeout with self.assertRaises(TypeError) as e: - list(test_folder.get_streaming_events('AAA-', connection_timeout=-1, max_notifications_returned='XXX')) + list(test_folder.get_streaming_events('AAA-', connection_timeout='XXX', max_notifications_returned=1)) self.assertEqual(e.exception.args[0], "'connection_timeout' 'XXX' must be of type ") with self.assertRaises(ValueError) as e: list(test_folder.get_streaming_events('AAA-', connection_timeout=-1, max_notifications_returned=1)) From 940234b74bf89a7ffaf10d2d03b5d46d37362c28 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 9 Jan 2022 17:30:28 +0100 Subject: [PATCH 100/509] Improve test coverage --- exchangelib/attachments.py | 11 +------- tests/test_attachments.py | 47 +++++++++++++++++++++++-------- tests/test_extended_properties.py | 22 +++++++++++---- tests/test_items/test_generic.py | 13 ++++++++- tests/test_items/test_messages.py | 17 ++++++++++- 5 files changed, 81 insertions(+), 29 deletions(-) diff --git a/exchangelib/attachments.py b/exchangelib/attachments.py index 6455a080..00f6206c 100644 --- a/exchangelib/attachments.py +++ b/exchangelib/attachments.py @@ -62,10 +62,6 @@ def attach(self): raise ValueError(f'Parent item {self.parent_item} must have an account') item = CreateAttachment(account=self.parent_item.account).get(parent_item=self.parent_item, items=[self]) attachment_id = item.attachment_id - if attachment_id.root_id != self.parent_item.id: - raise ValueError("'root_id' vs. 'id' mismatch") - if attachment_id.root_changekey == self.parent_item.changekey: - raise ValueError('root_id changekey match') self.parent_item.changekey = attachment_id.root_changekey # EWS does not like receiving root_id and root_changekey on subsequent requests attachment_id.root_id = None @@ -79,12 +75,7 @@ def detach(self): raise ValueError('This attachment has not been created') if not self.parent_item or not self.parent_item.account: raise ValueError(f'Parent item {self.parent_item} must have an account') - root_item_id = DeleteAttachment(account=self.parent_item.account).get(items=[self.attachment_id]) - if root_item_id.id != self.parent_item.id: - raise ValueError("'root_item_id.id' mismatch") - if root_item_id.changekey == self.parent_item.changekey: - raise ValueError("'root_item_id.changekey' match") - self.parent_item.changekey = root_item_id.changekey + DeleteAttachment(account=self.parent_item.account).get(items=[self.attachment_id]) self.parent_item = None self.attachment_id = None diff --git a/tests/test_attachments.py b/tests/test_attachments.py index 02d9f25e..d189ae3f 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -16,26 +16,43 @@ class AttachmentsTest(BaseItemTest): FOLDER_CLASS = Inbox ITEM_CLASS = Message + def test_magic(self): + for item in (FileAttachment(name='XXX'), ItemAttachment(name='XXX')): + self.assertIn('name=', str(item)) + self.assertIn(item.__class__.__name__, repr(item)) + def test_attachment_failure(self): att1 = FileAttachment(name='my_file_1.txt', content='Hello from unicode æøå'.encode('utf-8')) att1.attachment_id = 'XXX' - with self.assertRaises(ValueError): - att1.attach() # Cannot have an attachment ID + with self.assertRaises(ValueError) as e: + att1.attach() + self.assertEqual(e.exception.args[0], "This attachment has already been created") att1.attachment_id = None - with self.assertRaises(ValueError): - att1.attach() # Must have a parent item + with self.assertRaises(ValueError) as e: + att1.attach() + self.assertEqual(e.exception.args[0], "Parent item None must have an account") att1.parent_item = Item() - with self.assertRaises(ValueError): - att1.attach() # Parent item must have an account + with self.assertRaises(ValueError) as e: + att1.attach() + self.assertEqual(e.exception.args[0], "Parent item Item(attachments=[]) must have an account") att1.parent_item = None - with self.assertRaises(ValueError): - att1.detach() # Must have an attachment ID + with self.assertRaises(ValueError) as e: + att1.detach() + self.assertEqual(e.exception.args[0], "This attachment has not been created") att1.attachment_id = 'XXX' - with self.assertRaises(ValueError): - att1.detach() # Must have a parent item + with self.assertRaises(ValueError) as e: + att1.detach() + self.assertEqual(e.exception.args[0], "Parent item None must have an account") att1.parent_item = Item() - with self.assertRaises(ValueError): - att1.detach() # Parent item must have an account + with self.assertRaises(ValueError) as e: + att1.detach() + self.assertEqual(e.exception.args[0], "Parent item Item(attachments=[]) must have an account") + att1.parent_item = 'XXX' + with self.assertRaises(TypeError) as e: + att1.clean() + self.assertEqual( + e.exception.args[0], "'parent_item' 'XXX' must be of type " + ) att1.parent_item = None att1.attachment_id = None @@ -44,6 +61,9 @@ def test_file_attachment_properties(self): att1 = FileAttachment(name='my_file_1.txt', content=binary_file_content) self.assertIn("name='my_file_1.txt'", str(att1)) att1.content = binary_file_content # Test property setter + with self.assertRaises(TypeError) as e: + att1.content = 'XXX' + self.assertEqual(e.exception.args[0], "'value' 'XXX' must be of type ") self.assertEqual(att1.content, binary_file_content) # Test property getter att1.attachment_id = 'xxx' self.assertEqual(att1.content, binary_file_content) # Test property getter when attachment_id is set @@ -56,6 +76,9 @@ def test_item_attachment_properties(self): att1 = ItemAttachment(name='attachment1', item=attached_item1) self.assertIn("name='attachment1'", str(att1)) att1.item = attached_item1 # Test property setter + with self.assertRaises(TypeError) as e: + att1.item = 'XXX' + self.assertEqual(e.exception.args[0], "'value' 'XXX' must be of type ") self.assertEqual(att1.item, attached_item1) # Test property getter self.assertEqual(att1.item, attached_item1) # Test property getter att1.attachment_id = 'xxx' diff --git a/tests/test_extended_properties.py b/tests/test_extended_properties.py index 7bbe4c3c..ae490c59 100644 --- a/tests/test_extended_properties.py +++ b/tests/test_extended_properties.py @@ -1,5 +1,5 @@ from exchangelib.extended_properties import ExtendedProperty, Flag -from exchangelib.items import Message, CalendarItem +from exchangelib.items import Message, CalendarItem, BaseItem from exchangelib.folders import Inbox from exchangelib.properties import Mailbox @@ -48,10 +48,22 @@ class TestProp(ExtendedProperty): self.assertEqual(new_prop_val, item.dead_beef) # Test deregister - with self.assertRaises(ValueError): - self.ITEM_CLASS.register(attr_name=attr_name, attr_cls=TestProp) # Already registered - with self.assertRaises(TypeError): - self.ITEM_CLASS.register(attr_name='XXX', attr_cls=Mailbox) # Not an extended property + with self.assertRaises(ValueError) as e: + self.ITEM_CLASS.register(attr_name=attr_name, attr_cls=TestProp) + self.assertEqual(e.exception.args[0], "'attr_name' 'dead_beef' is already registered") + with self.assertRaises(TypeError) as e: + self.ITEM_CLASS.register(attr_name='XXX', attr_cls=Mailbox) + self.assertEqual( + e.exception.args[0], + "'attr_cls' must be a subclass of type " + "" + ) + with self.assertRaises(ValueError) as e: + BaseItem.register(attr_name=attr_name, attr_cls=Mailbox) + self.assertEqual( + e.exception.args[0], + "Class is missing INSERT_AFTER_FIELD value" + ) finally: self.ITEM_CLASS.deregister(attr_name=attr_name) self.assertNotIn(attr_name, {f.name for f in self.ITEM_CLASS.supported_fields(self.account.version)}) diff --git a/tests/test_items/test_generic.py b/tests/test_items/test_generic.py index 8e16d2ab..87112c51 100644 --- a/tests/test_items/test_generic.py +++ b/tests/test_items/test_generic.py @@ -8,7 +8,7 @@ from exchangelib.errors import ErrorItemNotFound, ErrorInternalServerError from exchangelib.extended_properties import ExtendedProperty, ExternId from exchangelib.fields import ExtendedPropertyField, CharField -from exchangelib.folders import Inbox, FolderCollection +from exchangelib.folders import Inbox, FolderCollection, Root from exchangelib.items import CalendarItem, Message from exchangelib.queryset import QuerySet from exchangelib.restriction import Restriction, Q @@ -39,6 +39,17 @@ def test_validation(self): setattr(item, f.name, 'a') def test_invalid_direct_args(self): + with self.assertRaises(TypeError) as e: + self.ITEM_CLASS(account='XXX') + self.assertEqual(e.exception.args[0], "'account' 'XXX' must be of type ") + with self.assertRaises(TypeError) as e: + self.ITEM_CLASS(folder='XXX') + self.assertEqual( + e.exception.args[0], "'folder' 'XXX' must be of type " + ) + with self.assertRaises(ValueError) as e: + self.ITEM_CLASS(account=self.account, folder=self.FOLDER_CLASS(root=Root(account='XXX'))) + self.assertEqual(e.exception.args[0], "'account' does not match 'folder.account'") item = self.get_test_item() item.account = None with self.assertRaises(ValueError): diff --git a/tests/test_items/test_messages.py b/tests/test_items/test_messages.py index 4194d641..42c631ae 100644 --- a/tests/test_items/test_messages.py +++ b/tests/test_items/test_messages.py @@ -96,6 +96,17 @@ def test_reply(self): sent_item.reply(subject=new_subject, body='Hello reply', to_recipients=[item.author]) self.assertEqual(self.account.sent.filter(subject=new_subject).count(), 1) + def test_create_reply(self): + # Test that we can save a reply without sending it + item = self.get_test_item(folder=None) + item.folder = None + item.send() + sent_item = self.get_incoming_message(item.subject) + new_subject = (f'Re: {sent_item.subject}')[:255] + sent_item.create_reply(subject=new_subject, body='Hello reply', to_recipients=[item.author])\ + .save(self.account.drafts) + self.assertEqual(self.account.drafts.filter(subject=new_subject).count(), 1) + def test_reply_all(self): # Test that we can reply-all a Message item. EWS only allows items that have been sent to receive a reply item = self.get_test_item(folder=None) @@ -123,7 +134,11 @@ def test_create_forward(self): item.send() sent_item = self.get_incoming_message(item.subject) new_subject = (f'Re: {sent_item.subject}')[:255] - sent_item.create_forward(subject=new_subject, body='Hello reply', to_recipients=[item.author]).send() + forward_item = sent_item.create_forward(subject=new_subject, body='Hello reply', to_recipients=[item.author]) + with self.assertRaises(AttributeError) as e: + forward_item.send(save_copy=False, copy_to_folder=self.account.sent) + self.assertEqual(e.exception.args[0], "'save_copy' must be True when 'copy_to_folder' is set") + forward_item.send() self.assertEqual(self.account.sent.filter(subject=new_subject).count(), 1) def test_mark_as_junk(self): From 022a253c80be30cb2352431275d58ab7fe44eb53 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 9 Jan 2022 17:45:14 +0100 Subject: [PATCH 101/509] Improve test coverage of folder querysets --- tests/test_folder.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/test_folder.py b/tests/test_folder.py index 1ededf95..13e69b42 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -665,13 +665,21 @@ def test_folder_query_set(self): f0.delete() def test_folder_query_set_failures(self): - with self.assertRaises(TypeError): + with self.assertRaises(TypeError) as e: FolderQuerySet('XXX') + self.assertEqual( + e.exception.args[0], + "'folder_collection' 'XXX' must be of type " + ) + # Test FolderQuerySet._copy_cls() + self.assertEqual(list(FolderQuerySet(FolderCollection(account=self.account, folders=[])).only('name')), []) fld_qs = SingleFolderQuerySet(account=self.account, folder=self.account.inbox) - with self.assertRaises(InvalidField): + with self.assertRaises(InvalidField) as e: fld_qs.only('XXX') - with self.assertRaises(InvalidField): + self.assertIn("Unknown field 'XXX' on folders", e.exception.args[0]) + with self.assertRaises(InvalidField) as e: list(fld_qs.filter(XXX='XXX')) + self.assertIn("Unknown field path 'XXX' on folders", e.exception.args[0]) def test_user_configuration(self): """Test that we can do CRUD operations on user configuration data.""" From 611b103cbfe0f55eabf7b3baeb35b2d10631f3e2 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 9 Jan 2022 18:01:10 +0100 Subject: [PATCH 102/509] Improve test coverage of message corner cases --- tests/test_items/test_messages.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_items/test_messages.py b/tests/test_items/test_messages.py index 42c631ae..f52b66b5 100644 --- a/tests/test_items/test_messages.py +++ b/tests/test_items/test_messages.py @@ -2,9 +2,11 @@ from email.mime.text import MIMEText import time +from exchangelib.attachments import FileAttachment from exchangelib.folders import Inbox from exchangelib.items import Message from exchangelib.queryset import DoesNotExist +from exchangelib.version import EXCHANGE_2010_SP2 from ..common import get_random_string from .test_basics import CommonItemTest @@ -37,6 +39,15 @@ def test_send(self): self.assertIsNone(item.changekey) self.assertEqual(self.test_folder.filter(categories__contains=item.categories).count(), 0) + def test_send_pre_2013(self): + # Test < Exchange 2013 fallback for attachments and send-only mode + item = self.get_test_item() + item.attach(FileAttachment(name='file_attachment', content=b'file_attachment')) + self.account.version.build = EXCHANGE_2010_SP2 + item.send(save_copy=False) + self.assertIsNone(item.id) + self.assertIsNone(item.changekey) + def test_send_and_save(self): # Test that we can send_and_save Message items item = self.get_test_item() @@ -103,6 +114,10 @@ def test_create_reply(self): item.send() sent_item = self.get_incoming_message(item.subject) new_subject = (f'Re: {sent_item.subject}')[:255] + with self.assertRaises(ValueError) as e: + sent_item.author = None + sent_item.create_reply(subject=new_subject, body='Hello reply').save(self.account.drafts) + self.assertEqual(e.exception.args[0], "'to_recipients' must be set when message has no 'author'") sent_item.create_reply(subject=new_subject, body='Hello reply', to_recipients=[item.author])\ .save(self.account.drafts) self.assertEqual(self.account.drafts.filter(subject=new_subject).count(), 1) From b265d61f1749c0fdb9e244b2f5a82758fe2820d3 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 9 Jan 2022 18:50:54 +0100 Subject: [PATCH 103/509] Improve test coverage of Account --- exchangelib/account.py | 13 ++++---- tests/test_account.py | 53 +++++++++++++++++++++++++++----- tests/test_credentials.py | 1 + tests/test_items/test_generic.py | 5 +++ 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/exchangelib/account.py b/exchangelib/account.py index db7c851c..5b9e0e89 100644 --- a/exchangelib/account.py +++ b/exchangelib/account.py @@ -1,4 +1,4 @@ -from locale import getlocale +import locale as stdlib_locale from logging import getLogger from cached_property import threaded_cached_property @@ -6,7 +6,7 @@ from .autodiscover import Autodiscovery from .configuration import Configuration from .credentials import DELEGATE, IMPERSONATION, ACCESS_TYPES -from .errors import UnknownTimeZone, InvalidEnumValue +from .errors import UnknownTimeZone, InvalidEnumValue, InvalidTypeError from .ewsdatetime import EWSTimeZone, UTC from .fields import FieldPath from .folders import Folder, AdminAuditLogs, ArchiveDeletedItems, ArchiveInbox, ArchiveMsgFolderRoot, \ @@ -83,18 +83,19 @@ def __init__(self, primary_smtp_address, fullname=None, access_type=None, autodi if self.access_type not in ACCESS_TYPES: raise InvalidEnumValue('access_type', self.access_type, ACCESS_TYPES) try: - self.locale = locale or getlocale()[0] or None # get_locale() might not be able to determine the locale + # get_locale() might not be able to determine the locale + self.locale = locale or stdlib_locale.getlocale()[0] or None except ValueError as e: # getlocale() may throw ValueError if it fails to parse the system locale log.warning('Failed to get locale (%s)', e) self.locale = None if not isinstance(self.locale, (type(None), str)): - raise ValueError(f"Expected 'locale' to be a string, got {self.locale!r}") + raise InvalidTypeError('locale', self.locale, str) if default_timezone: try: self.default_timezone = EWSTimeZone.from_timezone(default_timezone) except TypeError: - raise ValueError(f"Expected 'default_timezone' to be an EWSTimeZone, got {default_timezone!r}") + raise InvalidTypeError('default_timezone', default_timezone, EWSTimeZone) else: try: self.default_timezone = EWSTimeZone.localzone() @@ -104,7 +105,7 @@ def __init__(self, primary_smtp_address, fullname=None, access_type=None, autodi log.warning('%s. Fallback to UTC', e.args[0]) self.default_timezone = UTC if not isinstance(config, (Configuration, type(None))): - raise ValueError(f"Expected 'config' to be a Configuration, got {config}") + raise InvalidTypeError('config', config, Configuration) if autodiscover: if config: retry_policy, auth_type = config.retry_policy, config.auth_type diff --git a/tests/test_account.py b/tests/test_account.py index 0c1cbe77..9392cb65 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -1,12 +1,15 @@ from collections import namedtuple import pickle +from unittest.mock import patch + from exchangelib.account import Account from exchangelib.attachments import FileAttachment from exchangelib.configuration import Configuration from exchangelib.credentials import Credentials, DELEGATE from exchangelib.errors import ErrorAccessDenied, ErrorFolderNotFound, UnauthorizedError, ErrorNotDelegate, \ - ErrorDelegateNoUser + ErrorDelegateNoUser, UnknownTimeZone +from exchangelib.ewsdatetime import UTC from exchangelib.folders import Calendar from exchangelib.items import Message from exchangelib.properties import DelegateUser, UserId, DelegatePermissions, SendingAs @@ -37,18 +40,54 @@ def test_validation(self): with self.assertRaises(ValueError) as e: Account(primary_smtp_address='blah@example.com', access_type=123) self.assertEqual(str(e.exception), "'access_type' 123 must be one of ['delegate', 'impersonation']") - with self.assertRaises(ValueError) as e: + with self.assertRaises(TypeError) as e: # locale must be a string Account(primary_smtp_address='blah@example.com', locale=123) - self.assertEqual(str(e.exception), "Expected 'locale' to be a string, got 123") - with self.assertRaises(ValueError) as e: + self.assertEqual(str(e.exception), "'locale' 123 must be of type ") + with self.assertRaises(TypeError) as e: # default timezone must be an EWSTimeZone Account(primary_smtp_address='blah@example.com', default_timezone=123) - self.assertEqual(str(e.exception), "Expected 'default_timezone' to be an EWSTimeZone, got 123") - with self.assertRaises(ValueError) as e: + self.assertEqual( + str(e.exception), "'default_timezone' 123 must be of type " + ) + with self.assertRaises(TypeError) as e: # config must be a Configuration Account(primary_smtp_address='blah@example.com', config=123) - self.assertEqual(str(e.exception), "Expected 'config' to be a Configuration, got 123") + self.assertEqual( + str(e.exception), "'config' 123 must be of type " + ) + + @patch('locale.getlocale', side_effect=ValueError()) + def test_getlocale_failure(self, m): + a = Account( + primary_smtp_address=self.account.primary_smtp_address, + access_type=DELEGATE, + config=Configuration( + service_endpoint=self.account.protocol.service_endpoint, + credentials=Credentials(self.account.protocol.credentials.username, 'WRONG_PASSWORD'), + version=self.account.version, + auth_type=self.account.protocol.auth_type, + retry_policy=self.retry_policy, + ), + autodiscover=False, + ) + self.assertEqual(a.locale, None) + + @patch('tzlocal.get_localzone', side_effect=UnknownTimeZone('')) + def test_tzlocal_failure(self, m): + a = Account( + primary_smtp_address=self.account.primary_smtp_address, + access_type=DELEGATE, + config=Configuration( + service_endpoint=self.account.protocol.service_endpoint, + credentials=Credentials(self.account.protocol.credentials.username, 'WRONG_PASSWORD'), + version=self.account.version, + auth_type=self.account.protocol.auth_type, + retry_policy=self.retry_policy, + ), + autodiscover=False, + ) + self.assertEqual(a.default_timezone, UTC) def test_get_default_folder(self): # Test a normal folder lookup with GetFolder diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 356ea4da..bd8a1f11 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -26,6 +26,7 @@ def test_type(self): def test_pickle(self): # Test that we can pickle, hash, repr, str and compare various credentials types for o in ( + Identity('XXX', 'YYY', 'ZZZ', 'WWW'), Credentials('XXX', 'YYY'), OAuth2Credentials('XXX', 'YYY', 'ZZZZ'), OAuth2Credentials('XXX', 'YYY', 'ZZZZ', identity=Identity('AAA')), diff --git a/tests/test_items/test_generic.py b/tests/test_items/test_generic.py index 87112c51..f0953f60 100644 --- a/tests/test_items/test_generic.py +++ b/tests/test_items/test_generic.py @@ -1130,6 +1130,11 @@ def test_item_attachments(self): self.assertEqual(old_val, new_val, (f.name, old_val, new_val)) # Test detach + with self.assertRaises(ValueError) as e: + attachment2.parent_item = 'XXX' + item.detach(attachment2) + self.assertEqual(e.exception.args[0], "Attachment does not belong to this item") + attachment2.parent_item = item item.detach(attachment2) self.assertTrue(attachment2.attachment_id is None) self.assertTrue(attachment2.parent_item is None) From 16b03657bc23da23016ce3f10101cfc49c891ada Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jan 2022 19:33:56 +0100 Subject: [PATCH 104/509] Test autodiscover protocol auth_type() and make it easier to use this info in the future. --- exchangelib/autodiscover/discovery.py | 15 ++------------- exchangelib/autodiscover/properties.py | 26 +++++++++++++++++++------- tests/test_autodiscover.py | 11 ++++++----- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/exchangelib/autodiscover/discovery.py b/exchangelib/autodiscover/discovery.py index 1104ca28..c020c6b1 100644 --- a/exchangelib/autodiscover/discovery.py +++ b/exchangelib/autodiscover/discovery.py @@ -14,7 +14,6 @@ from ..transport import get_auth_method_from_response, DEFAULT_HEADERS, NOAUTH, GSSAPI, AUTH_TYPE_MAP from ..util import post_ratelimited, get_domain, get_redirect_url, _back_off_if_needed, \ DummyResponse, CONNECTION_ERRORS, TLS_ERRORS -from ..version import Version log = logging.getLogger(__name__) @@ -151,29 +150,19 @@ def resolver(self): return resolver def _build_response(self, ad_response): - ews_url = ad_response.ews_url + ews_url = ad_response.protocol.ews_url if not ews_url: raise AutoDiscoverFailed("Response is missing an 'ews_url' value") if not ad_response.autodiscover_smtp_address: # Autodiscover does not always return an email address. In that case, the requesting email should be used ad_response.user.autodiscover_smtp_address = self.email - # Get the server version. Not all protocol entries have a server version so we cheat a bit and also look at the - # other ones that point to the same endpoint. - for protocol in ad_response.account.protocols: - if not protocol.ews_url or not protocol.server_version: - continue - if protocol.ews_url.lower() == ews_url.lower(): - version = Version(build=protocol.server_version) - break - else: - version = None # We may not want to use the auth_package hints in the AD response. It could be incorrect and we can just guess. protocol = Protocol( config=Configuration( service_endpoint=ews_url, credentials=self.credentials, - version=version, + version=ad_response.version, auth_type=self.auth_type, retry_policy=self.retry_policy, ) diff --git a/exchangelib/autodiscover/properties.py b/exchangelib/autodiscover/properties.py index 9838595d..ee677aee 100644 --- a/exchangelib/autodiscover/properties.py +++ b/exchangelib/autodiscover/properties.py @@ -5,6 +5,7 @@ from ..transport import DEFAULT_ENCODING, NOAUTH, NTLM, BASIC, GSSAPI, SSPI, CBA from ..util import create_element, add_xml_child, to_xml, is_xml, xml_to_str, AUTODISCOVER_REQUEST_NS, \ AUTODISCOVER_BASE_NS, AUTODISCOVER_RESPONSE_NS as RNS, ParseError +from ..version import Version class AutodiscoverBase(EWSElement): @@ -153,7 +154,7 @@ def auth_type(self): 'negotiate': SSPI, # Unsure about this one 'nego2': GSSAPI, 'anonymous': NOAUTH, # Seen in some docs even though it's not mentioned in MSDN - }.get(self.auth_package.lower(), NTLM) # Default to NTLM + }.get(self.auth_package.lower(), None) class Error(EWSElement): @@ -241,22 +242,33 @@ def autodiscover_smtp_address(self): return None @property - def ews_url(self): - """Return the EWS URL contained in the response. + def version(self): + # Get the server version. Not all protocol entries have a server version so we cheat a bit and also look at the + # other ones that point to the same endpoint. + ews_url = self.protocol.ews_url + for protocol in self.account.protocols: + if not protocol.ews_url or not protocol.server_version: + continue + if protocol.ews_url.lower() == ews_url.lower(): + return Version(build=protocol.server_version) + return None + + @property + def protocol(self): + """Return the protocol containing an EWS URL. A response may contain a number of possible protocol types. EXPR is meant for EWS. See https://techcommunity.microsoft.com/t5/blogs/blogarticleprintpage/blog-id/Exchange/article-id/16 We allow fallback to EXCH if EXPR is not available, to support installations where EXPR is not available. - Additionally, some responses may contain and EXPR with no EWS URL. In that case, return the URL from EXCH, if - available. + Additionally, some responses may contain an EXPR with no EWS URL. In that case, return EXCH, if available. """ protocols = {p.type: p for p in self.account.protocols if p.ews_url} if Protocol.EXPR in protocols: - return protocols[Protocol.EXPR].ews_url + return protocols[Protocol.EXPR] if Protocol.EXCH in protocols: - return protocols[Protocol.EXCH].ews_url + return protocols[Protocol.EXCH] raise ValueError( f'No EWS URL found in any of the available protocols: {[str(p) for p in self.account.protocols]}' ) diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index a6904518..72cd5a8d 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -103,6 +103,7 @@ def test_autodiscover_empty_cache(self): retry_policy=self.retry_policy, ) self.assertEqual(ad_response.autodiscover_smtp_address, self.account.primary_smtp_address) + self.assertEqual(ad_response.protocol.auth_type, self.account.protocol.auth_type) self.assertEqual(protocol.service_endpoint.lower(), self.account.protocol.service_endpoint.lower()) self.assertEqual(protocol.version.build, self.account.protocol.version.build) @@ -351,7 +352,7 @@ def test_autodiscover_redirect(self, m): ]) ad_response, _ = discovery.discover() self.assertEqual(ad_response.autodiscover_smtp_address, f'redirected@{self.domain}') - self.assertEqual(ad_response.ews_url, f'https://redirected.{self.domain}/EWS/Exchange.asmx') + self.assertEqual(ad_response.protocol.ews_url, f'https://redirected.{self.domain}/EWS/Exchange.asmx') # Test that we catch circular redirects on the same domain with a primed cache. Just mock the endpoint to # return the same redirect response on every request. @@ -409,7 +410,7 @@ def test_autodiscover_redirect(self, m): m.post('https://httpbin.org/EWS/Exchange.asmx', status_code=200) ad_response, _ = discovery.discover() self.assertEqual(ad_response.autodiscover_smtp_address, 'john@redirected.httpbin.org') - self.assertEqual(ad_response.ews_url, 'https://httpbin.org/EWS/Exchange.asmx') + self.assertEqual(ad_response.protocol.ews_url, 'https://httpbin.org/EWS/Exchange.asmx') def test_get_srv_records(self): from exchangelib.autodiscover.discovery import SrvRecord @@ -550,7 +551,7 @@ def test_parse_response(self): ''' self.assertEqual( - Autodiscover.from_bytes(xml).response.ews_url, + Autodiscover.from_bytes(xml).response.protocol.ews_url, 'https://expr.example.com/EWS/Exchange.asmx' ) @@ -573,7 +574,7 @@ def test_parse_response(self): ''' self.assertEqual( - Autodiscover.from_bytes(xml).response.ews_url, + Autodiscover.from_bytes(xml).response.protocol.ews_url, 'https://exch.example.com/EWS/Exchange.asmx' ) @@ -596,7 +597,7 @@ def test_parse_response(self): ''' with self.assertRaises(ValueError): - Autodiscover.from_bytes(xml).response.ews_url + Autodiscover.from_bytes(xml).response.protocol.ews_url def test_del_on_error(self): # Test that __del__ can handle exceptions on close() From 552b472a3721aab5a32b9b8ec262bed1a42ce150 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jan 2022 19:34:14 +0100 Subject: [PATCH 105/509] Test fromordinal() --- tests/test_ewsdatetime.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_ewsdatetime.py b/tests/test_ewsdatetime.py index fd528257..9f84d5ed 100644 --- a/tests/test_ewsdatetime.py +++ b/tests/test_ewsdatetime.py @@ -233,3 +233,5 @@ def test_ewsdate(self): with self.assertRaises(TypeError): EWSDate.from_date(EWSDate(2000, 1, 2)) + + self.assertEqual(EWSDate.fromordinal(730120), EWSDate(2000, 1, 1)) From 41cd64a6434b6d5c31f02fc4a57d74cf7e6692f9 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jan 2022 19:41:01 +0100 Subject: [PATCH 106/509] Test edge cases of Response properties --- tests/test_autodiscover.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 72cd5a8d..5b634120 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -14,7 +14,7 @@ from exchangelib.autodiscover import close_connections, clear_cache, autodiscover_cache, AutodiscoverProtocol, \ Autodiscovery, AutodiscoverCache from exchangelib.autodiscover.cache import shelve_filename -from exchangelib.autodiscover.properties import Autodiscover +from exchangelib.autodiscover.properties import Autodiscover, Response, Account as ADAccount from exchangelib.configuration import Configuration from exchangelib.errors import ErrorNonExistentMailbox, AutoDiscoverCircularRedirect, AutoDiscoverFailed from exchangelib.protocol import FaultTolerance, FailFast @@ -95,6 +95,15 @@ def test_magic(self, m): str(protocol) repr(protocol) + def test_response_properties(self): + # Test edge cases of Response properties + self.assertEqual(Response().redirect_address, None) + self.assertEqual(Response(account=ADAccount(action=ADAccount.REDIRECT_URL)).redirect_address, None) + self.assertEqual(Response().redirect_url, None) + self.assertEqual(Response(account=ADAccount(action=ADAccount.SETTINGS)).redirect_url, None) + self.assertEqual(Response().autodiscover_smtp_address, None) + self.assertEqual(Response(account=ADAccount(action=ADAccount.REDIRECT_ADDR)).autodiscover_smtp_address, None) + def test_autodiscover_empty_cache(self): # A live test of the entire process with an empty cache ad_response, protocol = exchangelib.autodiscover.discovery.discover( From 6d1604aa927e7868a279f698e308ec65ca5b0ca9 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jan 2022 20:11:37 +0100 Subject: [PATCH 107/509] Fix speling, and simplify logic --- exchangelib/folders/roots.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/exchangelib/folders/roots.py b/exchangelib/folders/roots.py index 52b36408..70f55ade 100644 --- a/exchangelib/folders/roots.py +++ b/exchangelib/folders/roots.py @@ -231,45 +231,46 @@ def get_default_folder(self, folder_cls): # Try to pick a suitable default folder. we do this by: # 1. Searching the full folder list for a folder with the distinguished folder name # 2. Searching TOIS for a direct child folder of the same type that is marked as distinguished - # 3. Searching TOIS for a direct child folder of the same type that is has a localized name + # 3. Searching TOIS for a direct child folder of the same type that has a localized name # 4. Searching root for a direct child folder of the same type that is marked as distinguished - # 5. Searching root for a direct child folder of the same type that is has a localized name + # 5. Searching root for a direct child folder of the same type that has a localized name log.debug('Searching default %s folder in full folder list', folder_cls) for f in self._folders_map.values(): - # Require exact class to not match e.g. RecipientCache instead of Contacts + # Require exact type, to avoid matching with subclasses (e.g. RecipientCache and Contacts) if f.__class__ == folder_cls and f.has_distinguished_name: log.debug('Found cached %s folder with default distinguished name', folder_cls) return f - # Try direct children of TOIS first, unless we're trying to get the TOIS folder. TOIS might not exist. + # Try direct children of TOIS first, unless we're trying to get the TOIS folder if folder_cls != MsgFolderRoot: try: return self._get_candidate(folder_cls=folder_cls, folder_coll=self.tois.children) except MISSING_FOLDER_ERRORS: - # No candidates, or TOIS does not exist, or we don't have access + # No candidates, or TOIS does not exist, or we don't have access to TOIS pass - # No candidates in TOIS. Try direct children of root. + # Finally, try direct children of root return self._get_candidate(folder_cls=folder_cls, folder_coll=self.children) def _get_candidate(self, folder_cls, folder_coll): - # Get a single the folder of the same type in folder_coll + # Look for a single useful folder of type folder_cls in folder_coll same_type = [f for f in folder_coll if f.__class__ == folder_cls] are_distinguished = [f for f in same_type if f.is_distinguished] if are_distinguished: candidates = are_distinguished else: candidates = [f for f in same_type if f.name.lower() in folder_cls.localized_names(self.account.locale)] - if candidates: - if len(candidates) > 1: - raise ValueError(f'Multiple possible default {folder_cls} folders: {[f.name for f in candidates]}') - if candidates[0].is_distinguished: - log.debug('Found cached distinguished %s folder', folder_cls) - else: - log.debug('Found cached %s folder with localized name', folder_cls) - return candidates[0] - raise ErrorFolderNotFound(f'No usable default {folder_cls} folders') + if not candidates: + raise ErrorFolderNotFound(f'No usable default {folder_cls} folders') + if len(candidates) > 1: + raise ValueError(f'Multiple possible default {folder_cls} folders: {[f.name for f in candidates]}') + candidate = candidates[0] + if candidate.is_distinguished: + log.debug('Found distinguished %s folder', folder_cls) + else: + log.debug('Found %s folder with localized name %s', folder_cls, candidate.name) + return candidate class PublicFoldersRoot(RootOfHierarchy): From 76df40ab0b04f28b7f75f09becf366840197ddb4 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jan 2022 20:16:25 +0100 Subject: [PATCH 108/509] Add coverage of BaseReplyItem.__init__ --- tests/test_items/test_messages.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_items/test_messages.py b/tests/test_items/test_messages.py index f52b66b5..b9aef669 100644 --- a/tests/test_items/test_messages.py +++ b/tests/test_items/test_messages.py @@ -4,7 +4,7 @@ from exchangelib.attachments import FileAttachment from exchangelib.folders import Inbox -from exchangelib.items import Message +from exchangelib.items import Message, ReplyToItem from exchangelib.queryset import DoesNotExist from exchangelib.version import EXCHANGE_2010_SP2 @@ -123,6 +123,9 @@ def test_create_reply(self): self.assertEqual(self.account.drafts.filter(subject=new_subject).count(), 1) def test_reply_all(self): + with self.assertRaises(TypeError) as e: + ReplyToItem(account='XXX') + self.assertEqual(e.exception.args[0], "'account' 'XXX' must be of type ") # Test that we can reply-all a Message item. EWS only allows items that have been sent to receive a reply item = self.get_test_item(folder=None) item.folder = None From 470019785ce7a9cd61491a99789d0e3a8e5967da Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jan 2022 20:41:34 +0100 Subject: [PATCH 109/509] There are no meaningful errors to catch in GetDelegate --- exchangelib/account.py | 7 +------ exchangelib/services/get_delegate.py | 1 + tests/test_account.py | 4 +++- tests/test_items/test_contacts.py | 7 +++++++ 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/exchangelib/account.py b/exchangelib/account.py index 5b9e0e89..191eaacb 100644 --- a/exchangelib/account.py +++ b/exchangelib/account.py @@ -617,12 +617,7 @@ def mail_tips(self): @property def delegates(self): """Return a list of DelegateUser objects representing the delegates that are set on this account.""" - delegates = [] - for d in GetDelegate(account=self).call(user_ids=None, include_permissions=True): - if isinstance(d, Exception): - raise d - delegates.append(d) - return delegates + return list(GetDelegate(account=self).call(user_ids=None, include_permissions=True)) def __str__(self): if self.fullname: diff --git a/exchangelib/services/get_delegate.py b/exchangelib/services/get_delegate.py index 3465dc9b..279c28ee 100644 --- a/exchangelib/services/get_delegate.py +++ b/exchangelib/services/get_delegate.py @@ -8,6 +8,7 @@ class GetDelegate(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getdelegate-operation""" SERVICE_NAME = 'GetDelegate' + ERRORS_TO_CATCH_IN_RESPONSE = () supported_from = EXCHANGE_2007_SP1 def call(self, user_ids, include_permissions): diff --git a/tests/test_account.py b/tests/test_account.py index 9392cb65..beff9196 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -8,7 +8,7 @@ from exchangelib.configuration import Configuration from exchangelib.credentials import Credentials, DELEGATE from exchangelib.errors import ErrorAccessDenied, ErrorFolderNotFound, UnauthorizedError, ErrorNotDelegate, \ - ErrorDelegateNoUser, UnknownTimeZone + ErrorDelegateNoUser, UnknownTimeZone, ErrorInvalidUserSid from exchangelib.ewsdatetime import UTC from exchangelib.folders import Calendar from exchangelib.items import Message @@ -162,6 +162,8 @@ def test_delegate(self): # The test server does not have any delegate info. Test that account.delegates works, and mock to test parsing # of a non-empty response. self.assertGreaterEqual(len(self.account.delegates), 0) + with self.assertRaises(ErrorInvalidUserSid): + list(GetDelegate(account=self.account).call(user_ids=[UserId(sid='XXX')], include_permissions=True)) with self.assertRaises(ErrorDelegateNoUser): list(GetDelegate(account=self.account).call(user_ids=['foo@example.com'], include_permissions=True)) with self.assertRaises(ErrorNotDelegate): diff --git a/tests/test_items/test_contacts.py b/tests/test_items/test_contacts.py index c358cc94..bf169ad2 100644 --- a/tests/test_items/test_contacts.py +++ b/tests/test_items/test_contacts.py @@ -117,6 +117,13 @@ def test_distribution_lists(self): dl.delete() + def test_fetch_personas(self): + # Test QuerySet input + self.assertGreaterEqual( + len(list(self.account.fetch_personas(self.test_folder.people().filter(display_name='john')))), + 0 + ) + def test_find_people(self): # The test server may not have any contacts. Just test that the FindPeople and GetPersona services work. self.assertGreaterEqual(len(list(self.test_folder.people())), 0) From 0a2115e04801387b8080e17ac080d27d725a5b2e Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jan 2022 20:49:08 +0100 Subject: [PATCH 110/509] Test corner case of create_reply() --- tests/test_items/test_messages.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_items/test_messages.py b/tests/test_items/test_messages.py index b9aef669..4efed71a 100644 --- a/tests/test_items/test_messages.py +++ b/tests/test_items/test_messages.py @@ -115,12 +115,20 @@ def test_create_reply(self): sent_item = self.get_incoming_message(item.subject) new_subject = (f'Re: {sent_item.subject}')[:255] with self.assertRaises(ValueError) as e: + tmp = sent_item.author sent_item.author = None - sent_item.create_reply(subject=new_subject, body='Hello reply').save(self.account.drafts) + try: + sent_item.create_reply(subject=new_subject, body='Hello reply').save(self.account.drafts) + finally: + sent_item.author = tmp self.assertEqual(e.exception.args[0], "'to_recipients' must be set when message has no 'author'") sent_item.create_reply(subject=new_subject, body='Hello reply', to_recipients=[item.author])\ .save(self.account.drafts) self.assertEqual(self.account.drafts.filter(subject=new_subject).count(), 1) + # Test with no to_recipients + sent_item.create_reply(subject=new_subject, body='Hello reply')\ + .save(self.account.drafts) + self.assertEqual(self.account.drafts.filter(subject=new_subject).count(), 2) def test_reply_all(self): with self.assertRaises(TypeError) as e: From cf743d3b61942c33b39262fbcaf8c719b4960c10 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jan 2022 20:54:08 +0100 Subject: [PATCH 111/509] Test send with no copy --- tests/test_items/test_messages.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_items/test_messages.py b/tests/test_items/test_messages.py index 4efed71a..2fc9ecc1 100644 --- a/tests/test_items/test_messages.py +++ b/tests/test_items/test_messages.py @@ -48,6 +48,13 @@ def test_send_pre_2013(self): self.assertIsNone(item.id) self.assertIsNone(item.changekey) + def test_send_no_copy(self): + # Test < Exchange 2013 fallback for attachments and send-only mode + item = self.get_test_item() + item.send(save_copy=False) + self.assertIsNone(item.id) + self.assertIsNone(item.changekey) + def test_send_and_save(self): # Test that we can send_and_save Message items item = self.get_test_item() From 6205a5356661ede10fdad8856ac804d3038a82df Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jan 2022 20:58:26 +0100 Subject: [PATCH 112/509] Test corner case of protocol auth_type property --- tests/test_autodiscover.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 5b634120..803e347e 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -18,7 +18,7 @@ from exchangelib.configuration import Configuration from exchangelib.errors import ErrorNonExistentMailbox, AutoDiscoverCircularRedirect, AutoDiscoverFailed from exchangelib.protocol import FaultTolerance, FailFast -from exchangelib.transport import NTLM +from exchangelib.transport import NTLM, NOAUTH from exchangelib.util import get_domain from .common import EWSTest, get_random_string @@ -113,6 +113,8 @@ def test_autodiscover_empty_cache(self): ) self.assertEqual(ad_response.autodiscover_smtp_address, self.account.primary_smtp_address) self.assertEqual(ad_response.protocol.auth_type, self.account.protocol.auth_type) + ad_response.protocol.auth_required = False + self.assertEqual(ad_response.protocol.auth_type, NOAUTH) self.assertEqual(protocol.service_endpoint.lower(), self.account.protocol.service_endpoint.lower()) self.assertEqual(protocol.version.build, self.account.protocol.version.build) From fc5cb3c8095b2b6aee73d2e24609394313c4a538 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jan 2022 21:09:47 +0100 Subject: [PATCH 113/509] Add coverage of raise_errors() --- exchangelib/autodiscover/properties.py | 2 +- tests/test_autodiscover.py | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/exchangelib/autodiscover/properties.py b/exchangelib/autodiscover/properties.py index ee677aee..fd4324dc 100644 --- a/exchangelib/autodiscover/properties.py +++ b/exchangelib/autodiscover/properties.py @@ -325,7 +325,7 @@ def raise_errors(self): raise ErrorNonExistentMailbox('The SMTP address has no mailbox associated with it') raise AutoDiscoverFailed(f'Unknown error {errorcode}: {message}') except AttributeError: - raise AutoDiscoverFailed(f'Unknown autodiscover error response: {self}') + raise AutoDiscoverFailed(f'Unknown autodiscover error response: {self.error_response}') @staticmethod def payload(email): diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 803e347e..4018a7df 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -14,7 +14,7 @@ from exchangelib.autodiscover import close_connections, clear_cache, autodiscover_cache, AutodiscoverProtocol, \ Autodiscovery, AutodiscoverCache from exchangelib.autodiscover.cache import shelve_filename -from exchangelib.autodiscover.properties import Autodiscover, Response, Account as ADAccount +from exchangelib.autodiscover.properties import Autodiscover, Response, Account as ADAccount, ErrorResponse, Error from exchangelib.configuration import Configuration from exchangelib.errors import ErrorNonExistentMailbox, AutoDiscoverCircularRedirect, AutoDiscoverFailed from exchangelib.protocol import FaultTolerance, FailFast @@ -610,6 +610,21 @@ def test_parse_response(self): with self.assertRaises(ValueError): Autodiscover.from_bytes(xml).response.protocol.ews_url + def test_raise_errors(self): + with self.assertRaises(AutoDiscoverFailed) as e: + Autodiscover().raise_errors() + self.assertEqual(e.exception.args[0], 'Unknown autodiscover error response: None') + with self.assertRaises(AutoDiscoverFailed) as e: + Autodiscover( + error_response=ErrorResponse(error=Error(code='YYY', message='XXX')) + ).raise_errors() + self.assertEqual(e.exception.args[0], 'Unknown error YYY: XXX') + with self.assertRaises(ErrorNonExistentMailbox) as e: + Autodiscover( + error_response=ErrorResponse(error=Error(message='The e-mail address cannot be found.')) + ).raise_errors() + self.assertEqual(e.exception.args[0], 'The SMTP address has no mailbox associated with it') + def test_del_on_error(self): # Test that __del__ can handle exceptions on close() cache = AutodiscoverCache() From 87ddcf75fbae28ff3bb3dfbf352fff8fdeb6647a Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jan 2022 21:26:12 +0100 Subject: [PATCH 114/509] No need to re-raise ParseError as ValuError. Fix logic in from_bytes() and test error messages --- exchangelib/autodiscover/discovery.py | 12 +++++++----- exchangelib/autodiscover/properties.py | 11 ++++------- tests/test_autodiscover.py | 11 ++++++++--- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/exchangelib/autodiscover/discovery.py b/exchangelib/autodiscover/discovery.py index c020c6b1..a7ceadaf 100644 --- a/exchangelib/autodiscover/discovery.py +++ b/exchangelib/autodiscover/discovery.py @@ -13,7 +13,7 @@ from ..protocol import Protocol, FailFast from ..transport import get_auth_method_from_response, DEFAULT_HEADERS, NOAUTH, GSSAPI, AUTH_TYPE_MAP from ..util import post_ratelimited, get_domain, get_redirect_url, _back_off_if_needed, \ - DummyResponse, CONNECTION_ERRORS, TLS_ERRORS + DummyResponse, ParseError, CONNECTION_ERRORS, TLS_ERRORS log = logging.getLogger(__name__) @@ -182,9 +182,10 @@ def _quick(self, protocol): if r.status_code == 200: try: ad = Autodiscover.from_bytes(bytes_content=r.content) - return self._step_5(ad=ad) - except ValueError as e: + except ParseError as e: raise AutoDiscoverFailed(f'Invalid response: {e}') + else: + return self._step_5(ad=ad) raise AutoDiscoverFailed(f'Invalid response code: {r.status_code}') def _redirect_url_is_valid(self, url): @@ -339,14 +340,15 @@ def _attempt_response(self, url): if r.status_code == 200: try: ad = Autodiscover.from_bytes(bytes_content=r.content) + except ParseError as e: + log.debug('Invalid response: %s', e) + else: # We got a valid response. Unless this is a URL redirect response, we cache the result if ad.response is None or not ad.response.redirect_url: cache_key = self._cache_key log.debug('Adding cache entry for key %s: %s', cache_key, ad_protocol.service_endpoint) autodiscover_cache[cache_key] = ad_protocol return True, ad - except ValueError as e: - log.debug('Invalid response: %s', e) return False, None def _is_valid_hostname(self, hostname): diff --git a/exchangelib/autodiscover/properties.py b/exchangelib/autodiscover/properties.py index fd4324dc..55865e71 100644 --- a/exchangelib/autodiscover/properties.py +++ b/exchangelib/autodiscover/properties.py @@ -306,14 +306,11 @@ def from_bytes(cls, bytes_content): :param bytes_content: :return: """ - if not is_xml(bytes_content) and not is_xml(bytes_content, expected_prefix=b'', -1, 0) + root = to_xml(bytes_content).getroot() # May raise ParseError if root.tag != cls.response_tag(): - raise ValueError(f'Unknown root element in XML: {bytes_content}') + raise ParseError(f'Unknown root element in XML: {bytes_content}', '', -1, 0) return cls.from_xml(elem=root, account=None) def raise_errors(self): diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 4018a7df..bc4666fe 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -19,7 +19,7 @@ from exchangelib.errors import ErrorNonExistentMailbox, AutoDiscoverCircularRedirect, AutoDiscoverFailed from exchangelib.protocol import FaultTolerance, FailFast from exchangelib.transport import NTLM, NOAUTH -from exchangelib.util import get_domain +from exchangelib.util import get_domain, ParseError from .common import EWSTest, get_random_string @@ -500,12 +500,17 @@ def test_select_srv_host(self): def test_parse_response(self): # Test parsing of various XML responses - with self.assertRaises(ValueError): + with self.assertRaises(ParseError) as e: Autodiscover.from_bytes(b'XXX') # Invalid response + self.assertEqual(e.exception.args[0], "Response is not XML: b'XXX'") xml = b'''bar''' - with self.assertRaises(ValueError): + with self.assertRaises(ParseError) as e: Autodiscover.from_bytes(xml) # Invalid XML response + self.assertEqual( + e.exception.args[0], + 'Unknown root element in XML: b\'bar\'' + ) # Redirect to different email address xml = b'''\ From 540cbc9c1eef8b0ecfb288f7535e63cf9a842321 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jan 2022 21:33:51 +0100 Subject: [PATCH 115/509] Test attachments as args to Item --- tests/test_attachments.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_attachments.py b/tests/test_attachments.py index d189ae3f..bb2bd629 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -53,6 +53,9 @@ def test_attachment_failure(self): self.assertEqual( e.exception.args[0], "'parent_item' 'XXX' must be of type " ) + with self.assertRaises(ValueError) as e: + Message(attachments=[att1]) + self.assertIn('must point to this item', e.exception.args[0]) att1.parent_item = None att1.attachment_id = None From 585ea2520a01eea0a1771c12007b1bb8d038ebab Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jan 2022 21:38:51 +0100 Subject: [PATCH 116/509] Fix test --- tests/test_items/test_messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_items/test_messages.py b/tests/test_items/test_messages.py index 2fc9ecc1..5f17736b 100644 --- a/tests/test_items/test_messages.py +++ b/tests/test_items/test_messages.py @@ -49,8 +49,8 @@ def test_send_pre_2013(self): self.assertIsNone(item.changekey) def test_send_no_copy(self): - # Test < Exchange 2013 fallback for attachments and send-only mode item = self.get_test_item() + item.folder = None item.send(save_copy=False) self.assertIsNone(item.id) self.assertIsNone(item.changekey) From 4a09b8b1b73f5f388131cffe07ad4143812f4d4d Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jan 2022 21:49:52 +0100 Subject: [PATCH 117/509] Add docstrings to abstract methods --- exchangelib/fields.py | 6 +++--- exchangelib/folders/base.py | 6 +++--- exchangelib/folders/collections.py | 2 +- exchangelib/protocol.py | 13 ++++++------- exchangelib/queryset.py | 12 ++++++------ exchangelib/services/update_folder.py | 2 +- 6 files changed, 20 insertions(+), 21 deletions(-) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index 552e57c6..cd6571a6 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -323,11 +323,11 @@ def clean(self, value, version=None): @abc.abstractmethod def from_xml(self, elem, account): - pass + """Read a value from the given element""" @abc.abstractmethod def to_xml(self, value, version): - pass + """Convert this field to an XML element""" def supports_version(self, version): # 'version' is a Version instance, for convenience by callers @@ -344,7 +344,7 @@ def __eq__(self, other): @abc.abstractmethod def __hash__(self): - pass + """Field instances must be hashable""" def __repr__(self): args_str = ', '.join(f'{f}={getattr(self, f)!r}' for f in ( diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index b6f3648a..68848208 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -66,17 +66,17 @@ def __init__(self, **kwargs): @property @abc.abstractmethod def account(self): - pass + """Return the account this folder belongs to""" @property @abc.abstractmethod def root(self): - pass + """Return the root folder this folder belongs to""" @property @abc.abstractmethod def parent(self): - pass + """Return the parent folder of this folder""" @property def is_deletable(self): diff --git a/exchangelib/folders/collections.py b/exchangelib/folders/collections.py index cfff827a..aee3b57d 100644 --- a/exchangelib/folders/collections.py +++ b/exchangelib/folders/collections.py @@ -510,7 +510,7 @@ def __init__(self, folder, **subscription_kwargs): @abc.abstractmethod def __enter__(self): - pass + """Create the subscription""" def __exit__(self, *args, **kwargs): self.folder.unsubscribe(subscription_id=self.subscription_id) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 537909b4..13b53d0c 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -625,27 +625,26 @@ class RetryPolicy(metaclass=abc.ABCMeta): @property @abc.abstractmethod def fail_fast(self): - # Used to choose the error handling policy. When True, a fault-tolerant policy is used. False, a fail-fast - # policy is used. - pass + """Used to choose the error handling policy. When True, a fault-tolerant policy is used. False, a fail-fast + policy is used.""" @property @abc.abstractmethod def back_off_until(self): - pass + """Return a datetime to back off until""" @back_off_until.setter @abc.abstractmethod def back_off_until(self, value): - pass + """Setter for back off values""" @abc.abstractmethod def back_off(self, seconds): - pass + """Set a new back off until value""" @abc.abstractmethod def may_retry_on_error(self, response, wait): - pass + """Return whether retries should still be attempted""" def raise_response_errors(self, response): cas_error = response.headers.get('X-CasErrorCode') diff --git a/exchangelib/queryset.py b/exchangelib/queryset.py index 3af399b6..024b3eb0 100644 --- a/exchangelib/queryset.py +++ b/exchangelib/queryset.py @@ -18,27 +18,27 @@ class SearchableMixIn: @abc.abstractmethod def get(self, *args, **kwargs): - pass + """Return a single object""" @abc.abstractmethod def all(self): - pass + """Return all objects, unfiltered""" @abc.abstractmethod def none(self): - pass + """Return an empty result""" @abc.abstractmethod def filter(self, *args, **kwargs): - pass + """Apply filters to a query""" @abc.abstractmethod def exclude(self, *args, **kwargs): - pass + """Apply filters to a query""" @abc.abstractmethod def people(self): - pass + """Search for personas""" class QuerySet(SearchableMixIn): diff --git a/exchangelib/services/update_folder.py b/exchangelib/services/update_folder.py index dcaf82c1..9ab2bdfa 100644 --- a/exchangelib/services/update_folder.py +++ b/exchangelib/services/update_folder.py @@ -105,7 +105,7 @@ def _change_elem(self, target, fieldnames): @abc.abstractmethod def _target_elem(self, target): - pass + """Convert the object to update to an XML element""" def _changes_elem(self, target_changes): changes = create_element(self.CHANGES_ELEMENT_NAME) From cf05fb82dfec2a5a727cdf4a5ccfe1301872e0d2 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jan 2022 21:58:24 +0100 Subject: [PATCH 118/509] Test is_xml() --- tests/test_util.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_util.py b/tests/test_util.py index 5d3716f9..7bcf857b 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -10,7 +10,7 @@ from exchangelib.protocol import FailFast, FaultTolerance import exchangelib.util from exchangelib.util import chunkify, peek, get_redirect_url, get_domain, PrettyXmlHandler, to_xml, BOM_UTF8, \ - ParseError, post_ratelimited, safe_b64decode, CONNECTION_ERRORS, DocumentYielder + ParseError, post_ratelimited, safe_b64decode, CONNECTION_ERRORS, DocumentYielder, is_xml from .common import EWSTest, mock_post, mock_session_exception @@ -114,6 +114,11 @@ def test_to_xml(self): # Not all lxml versions throw an error here, so we can't use assertRaises self.assertIn('Offending text: [...]Baz'), True) + self.assertEqual(is_xml(BOM_UTF8+b''), True) + self.assertEqual(is_xml(b'XXX'), False) + def test_get_domain(self): self.assertEqual(get_domain('foo@example.com'), 'example.com') with self.assertRaises(ValueError): From df8decacb119f5ec634c7f5afe0b43fd4cd22f82 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jan 2022 22:12:53 +0100 Subject: [PATCH 119/509] Mock failing lxml parser --- exchangelib/util.py | 4 ++-- tests/test_util.py | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/exchangelib/util.py b/exchangelib/util.py index 9b5ce202..d62fac9f 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -153,7 +153,7 @@ def xml_to_str(tree, encoding=None, xml_declaration=False): :return: """ if xml_declaration and not encoding: - raise ValueError("'xml_declaration' is not supported when 'encoding' is None") + raise AttributeError("'xml_declaration' is not supported when 'encoding' is None") if encoding: return lxml.etree.tostring(tree, encoding=encoding, xml_declaration=True) return lxml.etree.tostring(tree, encoding=str, xml_declaration=False) @@ -522,7 +522,7 @@ def to_xml(bytes_content): raise ParseError(str(e), '', e.lineno, e.offset) else: offending_excerpt = offending_line[max(0, e.offset - 20):e.offset + 20] - msg = f'{e}\nOffending text: [...]{offending_excerpt}[...]' + msg = f'{e}\nOffending text: [...]{offending_excerpt.decode("utf-8", errors="ignore")}[...]' raise ParseError(msg, '', e.lineno, e.offset) except TypeError: try: diff --git a/tests/test_util.py b/tests/test_util.py index 7bcf857b..a8dcb3b9 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,6 +1,7 @@ import io from itertools import chain import logging +from unittest.mock import patch import requests import requests_mock @@ -10,7 +11,7 @@ from exchangelib.protocol import FailFast, FaultTolerance import exchangelib.util from exchangelib.util import chunkify, peek, get_redirect_url, get_domain, PrettyXmlHandler, to_xml, BOM_UTF8, \ - ParseError, post_ratelimited, safe_b64decode, CONNECTION_ERRORS, DocumentYielder, is_xml + ParseError, post_ratelimited, safe_b64decode, CONNECTION_ERRORS, DocumentYielder, is_xml, xml_to_str from .common import EWSTest, mock_post, mock_session_exception @@ -108,17 +109,23 @@ def test_to_xml(self): to_xml(BOM_UTF8+b'&broken') with self.assertRaises(ParseError): to_xml(b'foo') - try: + + @patch('lxml.etree.parse', side_effect=ParseError('', '', 1, 0)) + def test_to_xml_failure(self, m): + # Not all lxml versions throw ParseError on the same XML, so we have to mock + with self.assertRaises(ParseError) as e: to_xml(b'Baz') - except ParseError as e: - # Not all lxml versions throw an error here, so we can't use assertRaises - self.assertIn('Offending text: [...]BazBaz'), True) self.assertEqual(is_xml(BOM_UTF8+b''), True) self.assertEqual(is_xml(b'XXX'), False) + def test_xml_to_str(self): + with self.assertRaises(AttributeError): + xml_to_str('XXX', encoding=None, xml_declaration=True) + def test_get_domain(self): self.assertEqual(get_domain('foo@example.com'), 'example.com') with self.assertRaises(ValueError): From ff1661254b39527282bf46eeb60ba900014745f2 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jan 2022 22:16:53 +0100 Subject: [PATCH 120/509] Mock other lxml parser exceptions --- tests/test_util.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_util.py b/tests/test_util.py index a8dcb3b9..97595972 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -117,6 +117,20 @@ def test_to_xml_failure(self, m): to_xml(b'Baz') self.assertIn('Offending text: [...]BazBaz') + self.assertIn('XXX', e.exception.args[0]) + + @patch('lxml.etree.parse', side_effect=TypeError('')) + def test_to_xml_failure_3(self, m): + # Not all lxml versions throw ParseError on the same XML, so we have to mock + with self.assertRaises(ParseError) as e: + to_xml(b'Baz') + self.assertEqual(e.exception.args[0], "This is not XML: b'Baz'") + def test_is_xml(self): self.assertEqual(is_xml(b''), True) self.assertEqual(is_xml(BOM_UTF8+b''), True) From bb97b616768277c3f19986911db051934a5823c1 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jan 2022 22:31:45 +0100 Subject: [PATCH 121/509] Tst anonymizing handler --- exchangelib/util.py | 5 +++-- tests/test_util.py | 17 ++++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/exchangelib/util.py b/exchangelib/util.py index d62fac9f..dbf6ef58 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -620,14 +620,15 @@ def __init__(self, forbidden_strings, *args, **kwargs): super().__init__(*args, **kwargs) def parse_bytes(self, xml_bytes): - root = lxml.etree.parse(io.BytesIO(xml_bytes), parser=_forgiving_parser) # nosec + root = to_xml(xml_bytes) for elem in root.iter(): # Anonymize element attribute values known to contain private data for attr in set(elem.keys()) & self.PRIVATE_TAGS: elem.set(attr, 'DEADBEEF=') # Anonymize anything requested by the caller for s in self.forbidden_strings: - elem.text.replace(s, '[REMOVED]') + if elem.text is not None: + elem.text = elem.text.replace(s, '[REMOVED]') return root diff --git a/tests/test_util.py b/tests/test_util.py index 97595972..a3ea0a16 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -11,7 +11,8 @@ from exchangelib.protocol import FailFast, FaultTolerance import exchangelib.util from exchangelib.util import chunkify, peek, get_redirect_url, get_domain, PrettyXmlHandler, to_xml, BOM_UTF8, \ - ParseError, post_ratelimited, safe_b64decode, CONNECTION_ERRORS, DocumentYielder, is_xml, xml_to_str + ParseError, post_ratelimited, safe_b64decode, CONNECTION_ERRORS, DocumentYielder, is_xml, xml_to_str, \ + AnonymizingXmlHandler from .common import EWSTest, mock_post, mock_session_exception @@ -140,6 +141,20 @@ def test_xml_to_str(self): with self.assertRaises(AttributeError): xml_to_str('XXX', encoding=None, xml_declaration=True) + def test_anonymizing_handler(self): + h = AnonymizingXmlHandler(forbidden_strings=('XXX', 'yyy')) + self.assertEqual(xml_to_str(h.parse_bytes(b'''\ + + + XXX + Hello yyy world +''')), '''\ + + + [REMOVED] + Hello [REMOVED] world +''') + def test_get_domain(self): self.assertEqual(get_domain('foo@example.com'), 'example.com') with self.assertRaises(ValueError): From 055837833002d02f04657a25701ce4bd614a7232 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jan 2022 22:31:58 +0100 Subject: [PATCH 122/509] Re-use existing safe parser --- exchangelib/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exchangelib/util.py b/exchangelib/util.py index dbf6ef58..a828696b 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -563,7 +563,7 @@ class PrettyXmlHandler(logging.StreamHandler): @staticmethod def parse_bytes(xml_bytes): - return lxml.etree.parse(io.BytesIO(xml_bytes), parser=_forgiving_parser) # nosec + return to_xml(xml_bytes) @classmethod def prettify_xml(cls, xml_bytes): From a00b4389d232df1d6faf898e48da5880cd472665 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jan 2022 22:56:14 +0100 Subject: [PATCH 123/509] Fix only_fields on sync_hierarchy() --- exchangelib/folders/collections.py | 11 +++++++---- tests/test_items/test_sync.py | 3 +++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/exchangelib/folders/collections.py b/exchangelib/folders/collections.py index aee3b57d..5b1eb5e1 100644 --- a/exchangelib/folders/collections.py +++ b/exchangelib/folders/collections.py @@ -475,10 +475,13 @@ def sync_hierarchy(self, sync_state=None, only_fields=None): # We didn't restrict list of field paths. Get all fields from the server, including extended properties. additional_fields = {FieldPath(field=f) for f in folder.supported_fields(version=self.account.version)} else: - for f in only_fields: - folder.validate_field(field=f, version=self.account.version) - # Remove ItemId and ChangeKey. We get them unconditionally - additional_fields = {f for f in folder.normalize_fields(fields=only_fields) if not f.field.is_attribute} + additional_fields = set() + for field_name in only_fields: + folder.validate_field(field=field_name, version=self.account.version) + f = folder.get_field_by_fieldname(fieldname=field_name) + if not f.is_attribute: + # Remove ItemId and ChangeKey. We get them unconditionally + additional_fields.add(FieldPath(field=f)) # Add required fields additional_fields.update( diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index 01ec1f0b..abc06ac0 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -95,6 +95,8 @@ def test_sync_folder_hierarchy(self): self.assertIsNone(test_folder.folder_sync_state) list(test_folder.sync_hierarchy()) self.assertIsNotNone(test_folder.folder_sync_state) + # Test non-default values + list(test_folder.sync_hierarchy(only_fields=['name'])) # Test that we see a create event f1 = self.FOLDER_CLASS(parent=test_folder, name=get_random_string(8)).save() @@ -143,6 +145,7 @@ def test_sync_folder_items(self): list(test_folder.sync_items()) self.assertIsNotNone(test_folder.item_sync_state) # Test non-default values + list(test_folder.sync_items(only_fields=['subject'])) list(test_folder.sync_items(sync_scope='NormalItems')) list(test_folder.sync_items(ignore=[ItemId(id='AAA=')])) From 6dfcb2380e07b6a66fc515204a81f8852aa54b21 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jan 2022 23:05:28 +0100 Subject: [PATCH 124/509] Better coverage of OAuth credentials --- tests/test_credentials.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_credentials.py b/tests/test_credentials.py index bd8a1f11..a0dcd489 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -44,3 +44,23 @@ def test_pickle(self): self.assertEqual(hash(o), hash(unpickled_o)) self.assertEqual(repr(o), repr(unpickled_o)) self.assertEqual(str(o), str(unpickled_o)) + + def test_oauth_validation(self): + with self.assertRaises(TypeError) as e: + OAuth2AuthorizationCodeCredentials(client_id='WWW', client_secret='XXX', access_token='XXX') + self.assertEqual( + e.exception.args[0], + "'access_token' 'XXX' must be of type " + ) + + c = OAuth2Credentials('XXX', 'YYY', 'ZZZZ') + c.refresh('XXX') # No-op + + with self.assertRaises(TypeError) as e: + c.on_token_auto_refreshed('XXX') + self.assertEqual( + e.exception.args[0], + "'access_token' 'XXX' must be of type " + ) + c.on_token_auto_refreshed(dict(access_token='XXX')) + self.assertIsInstance(c.sig(), int) From ac55fd2d316fd076409d67bf19844ed780c37b15 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jan 2022 23:43:13 +0100 Subject: [PATCH 125/509] Improve coverage of get_service_authtype() --- exchangelib/transport.py | 5 ++--- tests/test_protocol.py | 24 +++++++++++++++++++++--- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/exchangelib/transport.py b/exchangelib/transport.py index e33a2cf2..bcac5ad2 100644 --- a/exchangelib/transport.py +++ b/exchangelib/transport.py @@ -7,7 +7,7 @@ from .errors import UnauthorizedError, TransportError from .util import create_element, add_xml_child, xml_to_str, ns_translation, _back_off_if_needed, \ - _retry_after, DummyResponse, CONNECTION_ERRORS + _retry_after, DummyResponse, CONNECTION_ERRORS, RETRY_WAIT log = logging.getLogger(__name__) @@ -128,7 +128,6 @@ def get_service_authtype(service_endpoint, retry_policy, api_versions, name): # respond when given a valid request. Try all known versions. Gross. from .protocol import BaseProtocol retry = 0 - wait = 10 # seconds t_start = time.monotonic() headers = DEFAULT_HEADERS.copy() for api_version in api_versions: @@ -148,7 +147,7 @@ def get_service_authtype(service_endpoint, retry_policy, api_versions, name): total_wait = time.monotonic() - t_start r = DummyResponse(url=service_endpoint, headers={}, request_headers=headers) if retry_policy.may_retry_on_error(response=r, wait=total_wait): - wait = _retry_after(r, wait) + wait = _retry_after(r, RETRY_WAIT) log.info("Connection error on URL %s (retry %s, error: %s). Cool down %s secs", service_endpoint, retry, e, wait) retry_policy.back_off(wait) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 2a3a9bf6..ad4111e2 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -3,7 +3,7 @@ import pickle import socket import tempfile -from unittest.mock import Mock +from unittest.mock import Mock, patch import warnings try: import zoneinfo @@ -18,10 +18,10 @@ from exchangelib.configuration import Configuration from exchangelib.items import CalendarItem, SEARCH_SCOPE_CHOICES from exchangelib.errors import SessionPoolMinSizeReached, ErrorNameResolutionNoResults, ErrorAccessDenied, \ - TransportError, SessionPoolMaxSizeReached, TimezoneDefinitionInvalidForYear + TransportError, SessionPoolMaxSizeReached, TimezoneDefinitionInvalidForYear, RateLimitError from exchangelib.properties import TimeZone, RoomList, FreeBusyView, AlternateId, ID_FORMATS, EWS_ID, \ SearchableMailbox, FailedMailbox, Mailbox, DLMailbox, ItemId, MailboxData, FreeBusyViewOptions -from exchangelib.protocol import Protocol, BaseProtocol, NoVerifyHTTPAdapter, FailFast +from exchangelib.protocol import Protocol, BaseProtocol, NoVerifyHTTPAdapter, FailFast, FaultTolerance from exchangelib.services import GetRoomLists, GetRooms, ResolveNames, GetSearchableMailboxes, \ SetUserOofSettings, ExpandDL from exchangelib.settings import OofSettings @@ -801,3 +801,21 @@ def test_version_guess(self, m): e.exception.args[0], "No valid version headers found in response (ErrorNameResolutionMultipleResults('.'))" ) + + @patch('requests.sessions.Session.post', side_effect=ConnectionResetError('XXX')) + def test_get_service_authtype(self, m): + with self.assertRaises(TransportError) as e: + Protocol(config=Configuration( + service_endpoint='https://example.com/EWS/Exchange.asmx', + credentials=Credentials(get_random_string(8), get_random_string(8)), + auth_type=None, version=Version(Build(15, 1)), retry_policy=FailFast() + )) + self.assertEqual(e.exception.args[0], 'XXX') + + with self.assertRaises(RateLimitError) as e: + Protocol(config=Configuration( + service_endpoint='https://example.com/EWS/Exchange.asmx', + credentials=Credentials(get_random_string(8), get_random_string(8)), + auth_type=None, version=Version(Build(15, 1)), retry_policy=FaultTolerance(max_wait=0.5) + )) + self.assertEqual(e.exception.args[0], 'Max timeout reached') From 524427eff661048a5a3cf732a75a3f25e1672bae Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 00:37:20 +0100 Subject: [PATCH 126/509] Improve test coverage of properties --- exchangelib/properties.py | 4 +--- tests/test_account.py | 39 ++++++++++++++++++++++++++++++- tests/test_extended_properties.py | 4 ++++ tests/test_properties.py | 27 ++++++++++++++++++++- 4 files changed, 69 insertions(+), 5 deletions(-) diff --git a/exchangelib/properties.py b/exchangelib/properties.py index b6b75942..30a487ab 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -52,9 +52,7 @@ def __iadd__(self, other): return self def __contains__(self, item): - if isinstance(item, str): - return item in self._dict - return super().__contains__(item) + return item in self._dict def copy(self): return self.__class__(*self) diff --git a/tests/test_account.py b/tests/test_account.py index beff9196..2c1f6240 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -12,7 +12,8 @@ from exchangelib.ewsdatetime import UTC from exchangelib.folders import Calendar from exchangelib.items import Message -from exchangelib.properties import DelegateUser, UserId, DelegatePermissions, SendingAs +from exchangelib.properties import DelegateUser, UserId, DelegatePermissions, SendingAs, MailTips, RecipientAddress, \ + OutOfOffice from exchangelib.protocol import Protocol, FaultTolerance from exchangelib.services import GetDelegate, GetMailTips from exchangelib.version import Version, EXCHANGE_2007_SP1 @@ -157,6 +158,42 @@ def test_mail_tips(self): recipients=[], mail_tips_requested='All', )) + xml = b'''\ + + + + + NoError + + + NoError + + + user2@contoso.com + SMTP + + + + + + + Hello World Mailtips + + + + + +''' + self.assertEqual( + list(GetMailTips(protocol=None).parse(xml)), + [MailTips( + recipient_address=RecipientAddress(email_address='user2@contoso.com'), + out_of_office=OutOfOffice(), + custom_mail_tip='Hello World Mailtips', + )] + ) def test_delegate(self): # The test server does not have any delegate info. Test that account.delegates works, and mock to test parsing diff --git a/tests/test_extended_properties.py b/tests/test_extended_properties.py index ae490c59..3c079d9d 100644 --- a/tests/test_extended_properties.py +++ b/tests/test_extended_properties.py @@ -28,7 +28,11 @@ class TestProp(ExtendedProperty): with self.assertRaises(ValueError): self.ITEM_CLASS.deregister('subject') # Not an extended property + # Test that we can clean an item before and after registry + item = self.ITEM_CLASS() + item.clean(version=self.account.version) self.ITEM_CLASS.register(attr_name=attr_name, attr_cls=TestProp) + item.clean(version=self.account.version) try: # After register self.assertEqual(TestProp.python_type(), int) diff --git a/tests/test_properties.py b/tests/test_properties.py index 5533da02..c23a4f07 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -6,7 +6,7 @@ from exchangelib.folders import Folder, RootOfHierarchy from exchangelib.indexed_properties import PhysicalAddress from exchangelib.items import Item, BulkCreateResult -from exchangelib.properties import EWSElement, MessageHeader +from exchangelib.properties import EWSElement, MessageHeader, Fields from exchangelib.extended_properties import ExternId, Flag from exchangelib.util import to_xml, TNS from exchangelib.version import Version, EXCHANGE_2010, EXCHANGE_2013 @@ -67,6 +67,14 @@ def test_uid(self): b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x001\x00\x00\x00vCal-Uid\x01\x00\x00\x00' b'261cbc18-1f65-5a0a-bd11-23b1e224cc2f\x00' ) + self.assertEqual( + UID.to_global_object_id( + '040000008200E00074C5B7101A82E00800000000FF7FEDCAA34CD701000000000000000010000000DA513DCB6FE1904891890D' + 'BA92380E52' + ), + b'\x04\x00\x00\x00\x82\x00\xe0\x00t\xc5\xb7\x10\x1a\x82\xe0\x08\x00\x00\x00\x00\xff\x7f\xed\xca\xa3L\xd7' + b'\x01\x00\x00\x00\x00\x00\x00\x00\x00\x10\x00\x00\x00\xdaQ=\xcbo\xe1\x90H\x91\x89\r\xba\x928\x0eR' + ) def test_internet_message_headers(self): # Message headers are read-only, and an integration test is difficult because we can't reliably AND quickly @@ -184,3 +192,20 @@ def test_invalid_attribute(self): self.assertEqual( e.exception.args[0], "'invalid_attr' is not a valid attribute. See ItemId.FIELDS for valid field names" ) + + def test_fields(self): + with self.assertRaises(ValueError) as e: + Fields( + TextField(name='xxx'), + TextField(name='xxx'), + ) + self.assertIn('is a duplicate', e.exception.args[0]) + fields = Fields(TextField(name='xxx'), TextField(name='yyy')) + self.assertEqual(fields, fields.copy()) + self.assertFalse(123 in fields) + with self.assertRaises(ValueError) as e: + fields.index_by_name('zzz') + self.assertEqual(e.exception.args[0], "Unknown field name 'zzz'") + with self.assertRaises(ValueError) as e: + fields.insert(0, TextField(name='xxx')) + self.assertIn('is a duplicate', e.exception.args[0]) From 50dcb425f3a1d5a9706844eb20c8a772243f9a95 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 00:56:37 +0100 Subject: [PATCH 127/509] Improve test coverage of FolderCollection and Credentials --- exchangelib/folders/collections.py | 6 ------ tests/test_credentials.py | 3 +++ tests/test_items/test_sync.py | 7 ++++++- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/exchangelib/folders/collections.py b/exchangelib/folders/collections.py index 5b1eb5e1..0fdb7e27 100644 --- a/exchangelib/folders/collections.py +++ b/exchangelib/folders/collections.py @@ -236,8 +236,6 @@ def find_people(self, q, shape=ID_ONLY, depth=None, additional_fields=None, orde """ from ..services import FindPeople folder = self._get_single_folder() - if not folder: - return if q.is_never(): log.debug('Query will never return results') return @@ -436,8 +434,6 @@ def unsubscribe(self, subscription_id): def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None): from ..services import SyncFolderItems folder = self._get_single_folder() - if not folder: - return if only_fields is None: # We didn't restrict list of field paths. Get all fields from the server, including extended properties. additional_fields = {FieldPath(field=f) for f in folder.allowed_item_fields(version=self.account.version)} @@ -469,8 +465,6 @@ def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes def sync_hierarchy(self, sync_state=None, only_fields=None): from ..services import SyncFolderHierarchy folder = self._get_single_folder() - if not folder: - return if only_fields is None: # We didn't restrict list of field paths. Get all fields from the server, including extended properties. additional_fields = {FieldPath(field=f) for f in folder.supported_fields(version=self.account.version)} diff --git a/tests/test_credentials.py b/tests/test_credentials.py index a0dcd489..b8158699 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -45,6 +45,9 @@ def test_pickle(self): self.assertEqual(repr(o), repr(unpickled_o)) self.assertEqual(str(o), str(unpickled_o)) + def test_plain(self): + Credentials('XXX', 'YYY').refresh('XXX') # No-op + def test_oauth_validation(self): with self.assertRaises(TypeError) as e: OAuth2AuthorizationCodeCredentials(client_id='WWW', client_secret='XXX', access_token='XXX') diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index abc06ac0..4dc860d6 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -1,7 +1,7 @@ import time from exchangelib.errors import ErrorInvalidSubscription, ErrorSubscriptionNotFound, MalformedResponseError -from exchangelib.folders import Inbox +from exchangelib.folders import Inbox, FolderCollection from exchangelib.items import Message from exchangelib.properties import StatusEvent, CreatedEvent, ModifiedEvent, DeletedEvent, Notification, ItemId from exchangelib.services import SendNotification, SubscribeToPull, GetStreamingEvents @@ -72,6 +72,11 @@ def test_push_subscribe(self): with self.assertRaises(ErrorInvalidSubscription): self.account.root.tois.children.unsubscribe(subscription_id) + def test_empty_folder_collection(self): + self.assertEqual(FolderCollection(account=None, folders=[]).subscribe_to_pull(), None) + self.assertEqual(FolderCollection(account=None, folders=[]).subscribe_to_push('http://example.com'), None) + self.assertEqual(FolderCollection(account=None, folders=[]).subscribe_to_streaming(), None) + def test_streaming_subscribe(self): self.account.affinity_cookie = None with self.account.inbox.streaming_subscription() as subscription_id: From af68092ac7d1372c1dd20fee000105e9ef365192 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 01:33:50 +0100 Subject: [PATCH 128/509] Revert version change after test completion --- tests/test_folder.py | 6 +++--- tests/test_items/test_messages.py | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_folder.py b/tests/test_folder.py index 13e69b42..a9758764 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -139,13 +139,13 @@ def test_find_folders_multiple_roots(self): def test_find_folders_compat(self): coll = FolderCollection(account=self.account, folders=[self.account.root]) - tmp = self.account.protocol.version.build - self.account.protocol.version.build = EXCHANGE_2007 # Need to set it after the last auto-config of version + tmp = self.account.version.build + self.account.version.build = EXCHANGE_2007 # Need to set it after the last auto-config of version try: with self.assertRaises(NotImplementedError) as e: list(coll.find_folders(offset=1)) finally: - self.account.protocol.version.build = tmp + self.account.version.build = tmp self.assertEqual( e.exception.args[0], "'offset' is only supported for Exchange 2010 servers and later" diff --git a/tests/test_items/test_messages.py b/tests/test_items/test_messages.py index 5f17736b..895849be 100644 --- a/tests/test_items/test_messages.py +++ b/tests/test_items/test_messages.py @@ -43,8 +43,12 @@ def test_send_pre_2013(self): # Test < Exchange 2013 fallback for attachments and send-only mode item = self.get_test_item() item.attach(FileAttachment(name='file_attachment', content=b'file_attachment')) + tmp = self.account.version.build self.account.version.build = EXCHANGE_2010_SP2 - item.send(save_copy=False) + try: + item.send(save_copy=False) + finally: + self.account.version.build = tmp self.assertIsNone(item.id) self.assertIsNone(item.changekey) From 5589b0a89f980dcfc5582f98a0de293b73a586d9 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 02:32:15 +0100 Subject: [PATCH 129/509] Remove instance checking for internal methods. Improve coverage of folders --- exchangelib/fields.py | 28 ++++++++++++---------------- exchangelib/folders/base.py | 23 +++-------------------- exchangelib/folders/collections.py | 3 ++- exchangelib/properties.py | 6 +----- tests/test_folder.py | 30 ++++++++++++++++++++++++++++++ 5 files changed, 48 insertions(+), 42 deletions(-) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index cd6571a6..0417dcb0 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -8,7 +8,7 @@ from .ewsdatetime import EWSDateTime, EWSDate, EWSTimeZone, NaiveDateTimeNotAllowed, UnknownTimeZone, UTC from .util import create_element, get_xml_attr, get_xml_attrs, set_xml_value, value_to_xml_text, is_iterable, \ xml_text_to_value, TNS -from .version import Build, Version, EXCHANGE_2013 +from .version import Build, EXCHANGE_2013 log = logging.getLogger(__name__) @@ -155,13 +155,13 @@ class FieldPath: """ def __init__(self, field, label=None, subfield=None): + """ + + :param field: A FieldURIField or ExtendedPropertyField instance + :param label: a str + :param subfield: A SubField instance + """ # 'label' and 'subfield' are only used for IndexedField fields - if not isinstance(field, (FieldURIField, ExtendedPropertyField)): - raise InvalidTypeError('field', field, (FieldURIField, ExtendedPropertyField)) - if label and not isinstance(label, str): - raise InvalidTypeError('label', label, str) - if subfield and not isinstance(subfield, SubField): - raise InvalidTypeError('subfield', subfield, SubField) self.field = field self.label = label self.subfield = subfield @@ -234,12 +234,12 @@ def __hash__(self): class FieldOrder: """Holds values needed to call server-side sorting on a single field path.""" - def __init__(self, field_path, reverse=False): - if not isinstance(field_path, FieldPath): - raise InvalidTypeError('field_path', field_path, FieldPath) - if not isinstance(reverse, bool): - raise InvalidTypeError('reverse', reverse, bool) + """ + + :param field_path: A FieldPath instance + :param reverse: A bool + """ self.field_path = field_path self.reverse = reverse @@ -331,8 +331,6 @@ def to_xml(self, value, version): def supports_version(self, version): # 'version' is a Version instance, for convenience by callers - if not isinstance(version, Version): - raise InvalidTypeError('version', version, Version) if self.supported_from and version.build < self.supported_from: return False if self.deprecated_from and version.build >= self.deprecated_from: @@ -891,8 +889,6 @@ def __init__(self, value, supported_from=None): def supports_version(self, version): # 'version' is a Version instance, for convenience by callers - if not isinstance(version, Version): - raise InvalidTypeError('version', version, Version) if not self.supported_from: return True return version.build >= self.supported_from diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 68848208..28c1d67b 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -14,7 +14,7 @@ UserConfigurationName, UserConfigurationNameMNS, EWSMeta from ..queryset import SearchableMixIn, DoesNotExist from ..util import TNS, require_id, is_iterable -from ..version import Version, EXCHANGE_2007_SP1, EXCHANGE_2010 +from ..version import EXCHANGE_2007_SP1, EXCHANGE_2010 log = logging.getLogger(__name__) @@ -180,8 +180,6 @@ def tree(self): @classmethod def supports_version(cls, version): # 'version' is a Version instance, for convenience by callers - if not isinstance(version, Version): - raise InvalidTypeError('version', version, Version) if not cls.supported_from: return True return version.build >= cls.supported_from @@ -225,24 +223,11 @@ def allowed_item_fields(cls, version): # Return non-ID fields of all item classes allowed in this folder type fields = set() for item_model in cls.supported_item_models: - fields.update( - set(item_model.supported_fields(version=version)) - ) + fields.update(set(item_model.supported_fields(version=version))) return fields def validate_item_field(self, field, version): - # Takes a fieldname, Field or FieldPath object pointing to an item field, and checks that it is valid - # for the item types supported by this folder. - - # For each field, check if the field is valid for any of the item models supported by this folder - for item_model in self.supported_item_models: - try: - item_model.validate_field(field=field, version=version) - break - except InvalidField: - continue - else: - raise InvalidField(f"{field!r} is not a valid field on { self.supported_item_models}") + FolderCollection(account=self.account, folders=[self]).validate_item_field(field=field, version=version) def normalize_fields(self, fields): # Takes a list of fieldnames, Field or FieldPath objects pointing to item fields. Turns them into FieldPath @@ -257,8 +242,6 @@ def normalize_fields(self, fields): elif isinstance(field_path, Field): field_path = FieldPath(field=field_path) fields[i] = field_path - if not isinstance(field_path, FieldPath): - raise InvalidTypeError('field_path', field_path, FieldPath) if field_path.field.name == 'start': has_start = True elif field_path.field.name == 'end': diff --git a/exchangelib/folders/collections.py b/exchangelib/folders/collections.py index 0fdb7e27..e6bf44e3 100644 --- a/exchangelib/folders/collections.py +++ b/exchangelib/folders/collections.py @@ -125,7 +125,8 @@ def supported_item_models(self): return tuple(item_model for folder in self.folders for item_model in folder.supported_item_models) def validate_item_field(self, field, version): - # For each field, check if the field is valid for any of the item models supported by this folder + # Takes a fieldname, Field or FieldPath object pointing to an item field, and checks that it is valid + # for the item types supported by this folder collection. for item_model in self.supported_item_models: try: item_model.validate_field(field=field, version=version) diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 30a487ab..76862ce5 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -15,7 +15,7 @@ Base64Field, TypeValueField, DictionaryField, IdElementField, CharListField, GenericEventListField, \ DateTimeBackedDateField, TimeDeltaField, TransitionListField, InvalidField, InvalidFieldForVersion from .util import get_xml_attr, create_element, set_xml_value, value_to_xml_text, MNS, TNS -from .version import Version, EXCHANGE_2013, Build +from .version import EXCHANGE_2013, Build log = logging.getLogger(__name__) @@ -347,15 +347,11 @@ def validate_field(cls, field, version): :param field: :param version: """ - if not isinstance(version, Version): - raise InvalidTypeError('version', version, Version) # Allow both Field and FieldPath instances and string field paths as input if isinstance(field, str): field = cls.get_field_by_fieldname(fieldname=field) elif isinstance(field, FieldPath): field = field.field - if not isinstance(field, Field): - raise InvalidTypeError('field', field, Field) cls.get_field_by_fieldname(fieldname=field.name) # Will raise if field name is invalid if not field.supports_version(version): # The field exists but is not valid for this version diff --git a/tests/test_folder.py b/tests/test_folder.py index a9758764..eada1ff5 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -67,6 +67,27 @@ def test_folder_failure(self): with self.assertRaises(ValueError): self.account.root.get_default_folder(Folder) + with self.assertRaises(ValueError) as e: + Folder(root=self.account.public_folders_root, parent=self.account.inbox) + self.assertEqual(e.exception.args[0], "'parent.root' must match 'root'") + with self.assertRaises(ValueError) as e: + Folder(parent=self.account.inbox, parent_folder_id='XXX') + self.assertEqual(e.exception.args[0], "'parent_folder_id' must match 'parent' ID") + with self.assertRaises(TypeError) as e: + Folder(root='XXX').clean() + self.assertEqual( + e.exception.args[0], "'root' 'XXX' must be of type " + ) + with self.assertRaises(ValueError) as e: + Folder().save(update_fields=['name']) + self.assertEqual(e.exception.args[0], "'update_fields' is only valid for updates") + with self.assertRaises(ValueError) as e: + Messages().validate_item_field('XXX', version=self.account.version) + self.assertIn("'XXX' is not a valid field on", e.exception.args[0]) + with self.assertRaises(ValueError) as e: + Folder.item_model_from_tag('XXX') + self.assertEqual(e.exception.args[0], 'Item type XXX was unexpected in a Folder folder') + def test_public_folders_root(self): # Test account does not have a public folders root. Make a dummy query just to hit .get_children() self.assertGreaterEqual( @@ -371,6 +392,9 @@ def test_parent(self): self.account.calendar.parent = None self.account.calendar.parent = parent + # Test self-referencing folder + self.assertIsNone(Folder(id=self.account.inbox.id, parent=self.account.inbox).parent) + def test_children(self): self.assertIn( 'Top of Information Store', @@ -585,6 +609,12 @@ def test_generic_folder(self): f.save() f.delete() + self.assertEqual(Folder().has_distinguished_name, None) + self.assertEqual(Inbox(name='XXX').has_distinguished_name, False) + self.assertEqual(Inbox(name='Inbox').has_distinguished_name, True) + self.assertEqual(Inbox(is_distinguished=False).is_deletable, True) + self.assertEqual(Inbox(is_distinguished=True).is_deletable, False) + def test_non_deletable_folders(self): for f in self.account.root.walk(): if f.__class__ not in NON_DELETABLE_FOLDERS: From dd7c413086efa1c6a1d020a65a919b31f529d553 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 02:43:54 +0100 Subject: [PATCH 130/509] Basic coverage of meeting requests --- tests/test_items/test_calendaritems.py | 30 ++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/tests/test_items/test_calendaritems.py b/tests/test_items/test_calendaritems.py index be64ec9e..2243ec3b 100644 --- a/tests/test_items/test_calendaritems.py +++ b/tests/test_items/test_calendaritems.py @@ -1,11 +1,11 @@ import datetime -from exchangelib.errors import ErrorInvalidOperation, ErrorItemNotFound +from exchangelib.errors import ErrorInvalidOperation, ErrorItemNotFound, ErrorMissingInformationReferenceItemId from exchangelib.ewsdatetime import UTC from exchangelib.fields import NOVEMBER, WEEKEND_DAY, WEEK_DAY, THIRD, MONDAY, WEDNESDAY from exchangelib.folders import Calendar from exchangelib.items import CalendarItem, BulkCreateResult -from exchangelib.items.calendar_item import SINGLE, OCCURRENCE, EXCEPTION, RECURRING_MASTER +from exchangelib.items.calendar_item import MeetingRequest, AcceptItem, SINGLE, OCCURRENCE, EXCEPTION, RECURRING_MASTER from exchangelib.recurrence import Recurrence, Occurrence, FirstOccurrence, LastOccurrence, DeletedOccurrence, \ AbsoluteYearlyPattern, RelativeYearlyPattern, AbsoluteMonthlyPattern, RelativeMonthlyPattern, WeeklyPattern, \ DailyPattern @@ -510,3 +510,29 @@ def test_invalid_updateitem_items(self): with self.assertRaises(ValueError) as e: self.account.bulk_update([(item, ['is_meeting'])]) self.assertEqual(e.exception.args[0], "'is_meeting' is a read-only field") + + def test_meeting_request(self): + # The test server only has one account so we cannot test meeting invitations + with self.assertRaises(ValueError) as e: + MeetingRequest(account=self.account).accept() + self.assertEqual(e.exception.args[0], "'id' is a required field with no default") + with self.assertRaises(ValueError) as e: + MeetingRequest(account=self.account).decline() + self.assertEqual(e.exception.args[0], "'id' is a required field with no default") + with self.assertRaises(ValueError) as e: + MeetingRequest(account=self.account).tentatively_accept() + self.assertEqual(e.exception.args[0], "'id' is a required field with no default") + + with self.assertRaises(ErrorMissingInformationReferenceItemId) as e: + AcceptItem(account=self.account).send() + + def test_clean(self): + start = get_random_date() + start_dt, end_dt = get_random_datetime_range( + start_date=start, + end_date=start + datetime.timedelta(days=365), + tz=self.account.default_timezone + ) + with self.assertRaises(ValueError) as e: + CalendarItem(start=end_dt, end=start_dt).clean(version=self.account.version) + self.assertIn("'end' must be greater than 'start'", e.exception.args[0]) From bf984e638dffbc3c07495bd73e957b76f7fc821e Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 14:45:13 +0100 Subject: [PATCH 131/509] Test corner cases of get_service_authtype() --- exchangelib/util.py | 1 + tests/test_protocol.py | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/exchangelib/util.py b/exchangelib/util.py index a828696b..7e6d9cbc 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -649,6 +649,7 @@ def __init__(self, url, headers, request_headers, content=b'', status_code=503, self.content = iter((bytes([b]) for b in content)) if streaming else content self.text = content.decode('utf-8', errors='ignore') self.request = DummyRequest(headers=request_headers) + self.reason = '' self.history = history def iter_content(self): diff --git a/tests/test_protocol.py b/tests/test_protocol.py index ad4111e2..ecb44320 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -26,6 +26,7 @@ SetUserOofSettings, ExpandDL from exchangelib.settings import OofSettings from exchangelib.transport import NOAUTH, NTLM +from exchangelib.util import DummyResponse from exchangelib.version import Build, Version, EXCHANGE_2010_SP1 from exchangelib.winzone import CLDR_TO_MS_TIMEZONE_MAP @@ -819,3 +820,23 @@ def test_get_service_authtype(self, m): auth_type=None, version=Version(Build(15, 1)), retry_policy=FaultTolerance(max_wait=0.5) )) self.assertEqual(e.exception.args[0], 'Max timeout reached') + + @patch('requests.sessions.Session.post', return_value=DummyResponse(url='https://example.com/EWS/Exchange.asmx', headers={}, request_headers={}, status_code=401)) + def test_get_service_authtype_401(self, m): + with self.assertRaises(TransportError) as e: + Protocol(config=Configuration( + service_endpoint='https://example.com/EWS/Exchange.asmx', + credentials=Credentials(get_random_string(8), get_random_string(8)), + auth_type=None, version=Version(Build(15, 1)), retry_policy=FailFast() + )) + self.assertEqual(e.exception.args[0], 'Failed to get auth type from service') + + @patch('requests.sessions.Session.post', return_value=DummyResponse(url='https://example.com/EWS/Exchange.asmx', headers={}, request_headers={}, status_code=501)) + def test_get_service_authtype_501(self, m): + with self.assertRaises(TransportError) as e: + Protocol(config=Configuration( + service_endpoint='https://example.com/EWS/Exchange.asmx', + credentials=Credentials(get_random_string(8), get_random_string(8)), + auth_type=None, version=Version(Build(15, 1)), retry_policy=FailFast() + )) + self.assertEqual(e.exception.args[0], 'Failed to get auth type from service') From 555218ab597ba68e0242e9f42f63daa8548e0edd Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 14:49:15 +0100 Subject: [PATCH 132/509] Use get() - it does the same --- exchangelib/items/calendar_item.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/exchangelib/items/calendar_item.py b/exchangelib/items/calendar_item.py index 458f55b2..e0c34bd3 100644 --- a/exchangelib/items/calendar_item.py +++ b/exchangelib/items/calendar_item.py @@ -364,18 +364,12 @@ def send(self, message_disposition=SEND_AND_SAVE_COPY): # Some responses contain multiple response IDs, e.g. MeetingRequest.accept(). Return either the single ID or # the list of IDs. from ..services import CreateItem - res = list(CreateItem(account=self.account).call( + return CreateItem(account=self.account).get( items=[self], folder=self.folder, message_disposition=message_disposition, send_meeting_invitations=SEND_TO_NONE, - )) - for r in res: - if isinstance(r, Exception): - raise r - if len(res) == 1: - return res[0] - return res + ) class AcceptItem(BaseMeetingReplyItem): From 3e17ceadf7f67d1e4816519c97cd0e2bf1cf6344 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 14:59:38 +0100 Subject: [PATCH 133/509] Test timezone field cleaning on Exchange 2007 --- tests/test_items/test_calendaritems.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_items/test_calendaritems.py b/tests/test_items/test_calendaritems.py index 2243ec3b..44cdfd48 100644 --- a/tests/test_items/test_calendaritems.py +++ b/tests/test_items/test_calendaritems.py @@ -9,6 +9,7 @@ from exchangelib.recurrence import Recurrence, Occurrence, FirstOccurrence, LastOccurrence, DeletedOccurrence, \ AbsoluteYearlyPattern, RelativeYearlyPattern, AbsoluteMonthlyPattern, RelativeMonthlyPattern, WeeklyPattern, \ DailyPattern +from exchangelib.version import Version, EXCHANGE_2007 from ..common import get_random_string, get_random_datetime_range, get_random_date from .test_basics import CommonItemTest @@ -536,3 +537,9 @@ def test_clean(self): with self.assertRaises(ValueError) as e: CalendarItem(start=end_dt, end=start_dt).clean(version=self.account.version) self.assertIn("'end' must be greater than 'start'", e.exception.args[0]) + + item = CalendarItem(start=start_dt, end=end_dt) + item.clean(version=Version(EXCHANGE_2007)) + self.assertEqual(item._meeting_timezone, start_dt.tzinfo) + self.assertEqual(item._start_timezone, None) + self.assertEqual(item._end_timezone, None) From 80dd183b840794cbfc3c7cd70501eca9eb07d6d3 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 15:05:29 +0100 Subject: [PATCH 134/509] Test timezone field names on Exchange 2007 --- tests/test_items/test_calendaritems.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_items/test_calendaritems.py b/tests/test_items/test_calendaritems.py index 44cdfd48..513c3d32 100644 --- a/tests/test_items/test_calendaritems.py +++ b/tests/test_items/test_calendaritems.py @@ -543,3 +543,26 @@ def test_clean(self): self.assertEqual(item._meeting_timezone, start_dt.tzinfo) self.assertEqual(item._start_timezone, None) self.assertEqual(item._end_timezone, None) + + def test_tz_field_for_field_name(self): + self.assertEqual( + CalendarItem(account=self.account).tz_field_for_field_name('start').name, + '_start_timezone', + ) + self.assertEqual( + CalendarItem(account=self.account).tz_field_for_field_name('end').name, + '_end_timezone', + ) + tmp = self.account.version.build + self.account.version.build = EXCHANGE_2007 + try: + self.assertEqual( + CalendarItem(account=self.account).tz_field_for_field_name('start').name, + '_meeting_timezone', + ) + self.assertEqual( + CalendarItem(account=self.account).tz_field_for_field_name('end').name, + '_meeting_timezone', + ) + finally: + self.account.version.build = tmp From e128ffebc6dc85be39d88a7429b03adc6f5c4417 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 15:10:20 +0100 Subject: [PATCH 135/509] Remove type checks for internal class --- exchangelib/restriction.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/exchangelib/restriction.py b/exchangelib/restriction.py index 4660bcc2..4ced563d 100644 --- a/exchangelib/restriction.py +++ b/exchangelib/restriction.py @@ -534,16 +534,13 @@ class Restriction: RESTRICTION_TYPES = (FOLDERS, ITEMS) def __init__(self, q, folders, applies_to): - if not isinstance(q, Q): - raise InvalidTypeError('q', q, Q) + """ + :param q: A Q instance + :param folders: A list of BaseFolder instances + :param applies_to: A member of the RESTRICTION_TYPES eum + """ if q.is_empty(): raise ValueError("Q object must not be empty") - from .folders import BaseFolder - for folder in folders: - if not isinstance(folder, BaseFolder): - raise InvalidTypeError('folder', folder, BaseFolder) - if applies_to not in self.RESTRICTION_TYPES: - raise InvalidEnumValue('applies_to', applies_to, self.RESTRICTION_TYPES) self.q = q self.folders = folders self.applies_to = applies_to From d816551d54c6c5c0febe4532c79630c105bab13b Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 15:17:03 +0100 Subject: [PATCH 136/509] Add coverage of _rinse_item() --- tests/test_items/test_calendaritems.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_items/test_calendaritems.py b/tests/test_items/test_calendaritems.py index 513c3d32..0edd486f 100644 --- a/tests/test_items/test_calendaritems.py +++ b/tests/test_items/test_calendaritems.py @@ -231,6 +231,10 @@ def test_client_side_ordering_on_mixed_all_day_and_normal(self): list(self.test_folder.view(start=start - datetime.timedelta(days=1), end=end).order_by('start')) list(self.test_folder.view(start=start - datetime.timedelta(days=1), end=end).order_by('-start')) + # Test that client-side ordering on non-selected fields works + list(self.test_folder.view(start=start - datetime.timedelta(days=1), end=end).only('end').order_by('start')) + list(self.test_folder.view(start=start - datetime.timedelta(days=1), end=end).only('end').order_by('-start')) + def test_all_recurring_pattern_types(self): start = datetime.datetime(2016, 1, 1, 8, tzinfo=self.account.default_timezone) end = datetime.datetime(2016, 1, 1, 10, tzinfo=self.account.default_timezone) From 355f66e68fac0628c2290f38321cbc08864faaee Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 15:27:36 +0100 Subject: [PATCH 137/509] Test wiping when empty() is not allowed --- tests/test_folder.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/test_folder.py b/tests/test_folder.py index eada1ff5..6870616a 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -1,5 +1,8 @@ +from unittest.mock import Mock + from exchangelib.errors import ErrorDeleteDistinguishedFolder, ErrorObjectTypeChanged, DoesNotExist, \ - MultipleObjectsReturned, ErrorItemSave, ErrorItemNotFound, ErrorFolderExists, ErrorFolderNotFound + MultipleObjectsReturned, ErrorItemSave, ErrorItemNotFound, ErrorFolderExists, ErrorFolderNotFound, \ + ErrorCannotEmptyFolder from exchangelib.extended_properties import ExtendedProperty from exchangelib.items import Message from exchangelib.folders import Calendar, DeletedItems, Drafts, Inbox, Outbox, SentItems, JunkEmail, Messages, Tasks, \ @@ -575,6 +578,20 @@ def test_create_update_empty_delete(self): with self.assertRaises(ErrorDeleteDistinguishedFolder): self.account.inbox.delete() + def test_wipe_without_empty(self): + name = get_random_string(16) + f = Messages(parent=self.account.inbox, name=name).save() + Messages(parent=f, name=get_random_string(16)).save() + self.assertEqual(len(list(f.children)), 1) + tmp = f.empty + try: + f.empty = Mock(side_effect=ErrorCannotEmptyFolder('XXX')) + f.wipe() + finally: + f.empty = tmp + + self.assertEqual(len(list(f.children)), 0) + def test_move(self): f1 = Folder(parent=self.account.inbox, name=get_random_string(16)).save() f2 = Folder(parent=self.account.inbox, name=get_random_string(16)).save() From 0371b64874f3d69d67d968cc8a7498db06339174 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 16:02:26 +0100 Subject: [PATCH 138/509] Make all DummyResponse args optional --- exchangelib/autodiscover/discovery.py | 7 +++---- exchangelib/services/common.py | 2 +- exchangelib/services/get_attachment.py | 2 +- exchangelib/services/get_streaming_events.py | 2 +- exchangelib/transport.py | 2 +- exchangelib/util.py | 11 ++++++----- tests/test_protocol.py | 4 ++-- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/exchangelib/autodiscover/discovery.py b/exchangelib/autodiscover/discovery.py index a7ceadaf..4f377c7f 100644 --- a/exchangelib/autodiscover/discovery.py +++ b/exchangelib/autodiscover/discovery.py @@ -258,7 +258,7 @@ def _get_unauthenticated_response(self, url, method='post'): # Don't retry on TLS errors. They will most likely be persistent. raise TransportError(str(e)) except CONNECTION_ERRORS as e: - r = DummyResponse(url=url, headers={}, request_headers=kwargs['headers']) + r = DummyResponse(url=url, request_headers=kwargs['headers']) total_wait = time.monotonic() - t_start if self.INITIAL_RETRY_POLICY.may_retry_on_error(response=r, wait=total_wait): log.debug("Connection error on URL %s (retry %s, error: %s). Cool down", url, retry, e) @@ -304,8 +304,7 @@ def _get_authenticated_response(self, protocol): # isn't necessarily the right endpoint to use. raise TransportError(str(e)) except RedirectError as e: - r = DummyResponse(url=protocol.service_endpoint, headers={'location': e.url}, request_headers=None, - status_code=302) + r = DummyResponse(url=protocol.service_endpoint, headers={'location': e.url}, status_code=302) return r def _attempt_response(self, url): @@ -444,7 +443,7 @@ def _step_3(self, hostname): try: _, r = self._get_unauthenticated_response(url=url, method='get') except TransportError: - r = DummyResponse(url=url, headers={}, request_headers={}) + r = DummyResponse(url=url) if r.status_code in (301, 302) and 'location' in r.headers: redirect_url = get_redirect_url(r) if self._redirect_url_is_valid(url=redirect_url): diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index e5a5002b..2d499401 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -159,7 +159,7 @@ def get(self, expect_result=True, **kwargs): def parse(self, xml): """Used mostly for testing, when we want to parse static XML data.""" - resp = DummyResponse(url=None, headers=None, request_headers=None, content=xml, streaming=self.streaming) + resp = DummyResponse(content=xml, streaming=self.streaming) _, body = self._get_soap_parts(response=resp) return self._elems_to_objs(self._get_elements_in_response(response=self._get_soap_messages(body=body))) diff --git a/exchangelib/services/get_attachment.py b/exchangelib/services/get_attachment.py index cd5b2e6e..084828c9 100644 --- a/exchangelib/services/get_attachment.py +++ b/exchangelib/services/get_attachment.py @@ -88,7 +88,7 @@ def stream_file_content(self, attachment_id): # When the returned XML does not contain a Content element, ElementNotFound is thrown by parser.parse(). # Let the non-streaming SOAP parser parse the response and hook into the normal exception handling. # Wrap in DummyResponse because _get_soap_parts() expects an iter_content() method. - response = DummyResponse(url=None, headers=None, request_headers=None, content=enf.data) + response = DummyResponse(content=enf.data) _, body = super()._get_soap_parts(response=response) res = super()._get_soap_messages(body=body) for e in self._get_elements_in_response(response=res): diff --git a/exchangelib/services/get_streaming_events.py b/exchangelib/services/get_streaming_events.py index a1f9ba15..877b7865 100644 --- a/exchangelib/services/get_streaming_events.py +++ b/exchangelib/services/get_streaming_events.py @@ -54,7 +54,7 @@ def _get_soap_messages(self, body, **parse_opts): r = body for i, doc in enumerate(DocumentYielder(r.iter_content()), start=1): xml_log.debug('''Response XML (docs counter: %(i)s): %(xml_response)s''', dict(i=i, xml_response=doc)) - response = DummyResponse(url=None, headers=None, request_headers=None, content=doc) + response = DummyResponse(content=doc) try: _, body = super()._get_soap_parts(response=response, **parse_opts) except Exception: diff --git a/exchangelib/transport.py b/exchangelib/transport.py index bcac5ad2..b682c332 100644 --- a/exchangelib/transport.py +++ b/exchangelib/transport.py @@ -145,7 +145,7 @@ def get_service_authtype(service_endpoint, retry_policy, api_versions, name): except CONNECTION_ERRORS as e: # Don't retry on TLS errors. They will most likely be persistent. total_wait = time.monotonic() - t_start - r = DummyResponse(url=service_endpoint, headers={}, request_headers=headers) + r = DummyResponse(url=service_endpoint, request_headers=headers) if retry_policy.may_retry_on_error(response=r, wait=total_wait): wait = _retry_after(r, RETRY_WAIT) log.info("Connection error on URL %s (retry %s, error: %s). Cool down %s secs", diff --git a/exchangelib/util.py b/exchangelib/util.py index 7e6d9cbc..5c2ad2c1 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -635,17 +635,18 @@ def parse_bytes(self, xml_bytes): class DummyRequest: """A class to fake a requests Request object for functions that expect this.""" - def __init__(self, headers): - self.headers = headers + def __init__(self, headers=None): + self.headers = headers or {} class DummyResponse: """A class to fake a requests Response object for functions that expect this.""" - def __init__(self, url, headers, request_headers, content=b'', status_code=503, streaming=False, history=None): + def __init__(self, url=None, headers=None, request_headers=None, content=b'', status_code=503, streaming=False, + history=None): self.status_code = status_code self.url = url - self.headers = headers + self.headers = headers or {} self.content = iter((bytes([b]) for b in content)) if streaming else content self.text = content.decode('utf-8', errors='ignore') self.request = DummyRequest(headers=request_headers) @@ -811,7 +812,7 @@ def post_ratelimited(protocol, session, url, headers, data, allow_redirects=Fals thread_id, retry, timeout, url, wait) d_start = time.monotonic() # Always create a dummy response for logging purposes, in case we fail in the following - r = DummyResponse(url=url, headers={}, request_headers=headers) + r = DummyResponse(url=url, request_headers=headers) try: r = session.post(url=url, headers=headers, data=data, allow_redirects=False, timeout=timeout, stream=stream) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index ecb44320..f3aa6d9b 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -821,7 +821,7 @@ def test_get_service_authtype(self, m): )) self.assertEqual(e.exception.args[0], 'Max timeout reached') - @patch('requests.sessions.Session.post', return_value=DummyResponse(url='https://example.com/EWS/Exchange.asmx', headers={}, request_headers={}, status_code=401)) + @patch('requests.sessions.Session.post', return_value=DummyResponse(status_code=401)) def test_get_service_authtype_401(self, m): with self.assertRaises(TransportError) as e: Protocol(config=Configuration( @@ -831,7 +831,7 @@ def test_get_service_authtype_401(self, m): )) self.assertEqual(e.exception.args[0], 'Failed to get auth type from service') - @patch('requests.sessions.Session.post', return_value=DummyResponse(url='https://example.com/EWS/Exchange.asmx', headers={}, request_headers={}, status_code=501)) + @patch('requests.sessions.Session.post', return_value=DummyResponse(status_code=501)) def test_get_service_authtype_501(self, m): with self.assertRaises(TransportError) as e: Protocol(config=Configuration( From e46deb06e05a748da0c12c7902a693f1ff8d46f6 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 16:17:02 +0100 Subject: [PATCH 139/509] Move the mocked attr into the try: --- exchangelib/restriction.py | 2 +- tests/test_folder.py | 2 +- tests/test_items/test_calendaritems.py | 4 ++-- tests/test_items/test_generic.py | 6 ++++++ tests/test_items/test_messages.py | 4 ++-- tests/test_protocol.py | 2 +- tests/test_services.py | 10 +++++++++- 7 files changed, 22 insertions(+), 8 deletions(-) diff --git a/exchangelib/restriction.py b/exchangelib/restriction.py index 4ced563d..2df3caff 100644 --- a/exchangelib/restriction.py +++ b/exchangelib/restriction.py @@ -1,7 +1,7 @@ import logging from copy import copy -from .errors import InvalidEnumValue, InvalidTypeError +from .errors import InvalidEnumValue from .fields import InvalidField, FieldPath, DateTimeBackedDateField from .util import create_element, xml_to_str, value_to_xml_text, is_iterable from .version import EXCHANGE_2010 diff --git a/tests/test_folder.py b/tests/test_folder.py index 6870616a..72d5356b 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -164,8 +164,8 @@ def test_find_folders_multiple_roots(self): def test_find_folders_compat(self): coll = FolderCollection(account=self.account, folders=[self.account.root]) tmp = self.account.version.build - self.account.version.build = EXCHANGE_2007 # Need to set it after the last auto-config of version try: + self.account.version.build = EXCHANGE_2007 # Need to set it after the last auto-config of version with self.assertRaises(NotImplementedError) as e: list(coll.find_folders(offset=1)) finally: diff --git a/tests/test_items/test_calendaritems.py b/tests/test_items/test_calendaritems.py index 0edd486f..c4a8258d 100644 --- a/tests/test_items/test_calendaritems.py +++ b/tests/test_items/test_calendaritems.py @@ -529,7 +529,7 @@ def test_meeting_request(self): self.assertEqual(e.exception.args[0], "'id' is a required field with no default") with self.assertRaises(ErrorMissingInformationReferenceItemId) as e: - AcceptItem(account=self.account).send() + AcceptItem(account=self.account).send() def test_clean(self): start = get_random_date() @@ -558,8 +558,8 @@ def test_tz_field_for_field_name(self): '_end_timezone', ) tmp = self.account.version.build - self.account.version.build = EXCHANGE_2007 try: + self.account.version.build = EXCHANGE_2007 self.assertEqual( CalendarItem(account=self.account).tz_field_for_field_name('start').name, '_meeting_timezone', diff --git a/tests/test_items/test_generic.py b/tests/test_items/test_generic.py index f0953f60..90e12bf0 100644 --- a/tests/test_items/test_generic.py +++ b/tests/test_items/test_generic.py @@ -278,6 +278,12 @@ def test_invalid_updateitem_args(self): ) def test_invalid_finditem_args(self): + with self.assertRaises(TypeError) as e: + FindItem(account=self.account, page_size='XXX') + self.assertEqual(e.exception.args[0], "'page_size' 'XXX' must be of type ") + with self.assertRaises(ValueError) as e: + FindItem(account=self.account, page_size=-1) + self.assertEqual(e.exception.args[0], "'page_size' must be a positive number") with self.assertRaises(ValueError) as e: FindItem(account=self.account).call( folders=None, diff --git a/tests/test_items/test_messages.py b/tests/test_items/test_messages.py index 895849be..18af0861 100644 --- a/tests/test_items/test_messages.py +++ b/tests/test_items/test_messages.py @@ -44,8 +44,8 @@ def test_send_pre_2013(self): item = self.get_test_item() item.attach(FileAttachment(name='file_attachment', content=b'file_attachment')) tmp = self.account.version.build - self.account.version.build = EXCHANGE_2010_SP2 try: + self.account.version.build = EXCHANGE_2010_SP2 item.send(save_copy=False) finally: self.account.version.build = tmp @@ -127,8 +127,8 @@ def test_create_reply(self): new_subject = (f'Re: {sent_item.subject}')[:255] with self.assertRaises(ValueError) as e: tmp = sent_item.author - sent_item.author = None try: + sent_item.author = None sent_item.create_reply(subject=new_subject, body='Hello reply').save(self.account.drafts) finally: sent_item.author = tmp diff --git a/tests/test_protocol.py b/tests/test_protocol.py index f3aa6d9b..2546e2bf 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -340,8 +340,8 @@ def test_resolvenames(self): "Chunk size 500 is too high. ResolveNames supports returning at most 100 candidates for a lookup" ) tmp = self.account.protocol.version.build - self.account.protocol.version.build = EXCHANGE_2010_SP1 try: + self.account.protocol.version.build = EXCHANGE_2010_SP1 with self.assertRaises(NotImplementedError) as e: self.account.protocol.resolve_names(names=['xxx@example.com'], shape='IdOnly') finally: diff --git a/tests/test_services.py b/tests/test_services.py index a419608f..9deda698 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1,4 +1,5 @@ import requests_mock +from unittest.mock import Mock from exchangelib.errors import ErrorServerBusy, ErrorNonExistentMailbox, TransportError, MalformedResponseError, \ ErrorInvalidServerVersion, ErrorTooManyObjectsOpened, SOAPError @@ -190,6 +191,13 @@ def test_get_elements(self): with self.assertRaises(ErrorInvalidServerVersion): list(svc._get_elements(create_element('XXX'))) + def test_handle_backoff(self): + # Test that we can handle backoff messages + svc = ResolveNames(self.account.protocol) + try: + svc. + list(svc._get_elements(create_element('XXX'))) + @requests_mock.mock() def test_invalid_soap_response(self, m): m.post(self.account.protocol.service_endpoint, text='XXX') @@ -200,8 +208,8 @@ def test_version_renegotiate(self): # Test that we can recover from a wrong API version. This is needed in version guessing and when the # autodiscover response returns a wrong server version for the account old_version = self.account.version.api_version - self.account.version.api_version = 'Exchange2016' # Newer EWS versions require a valid value try: + self.account.version.api_version = 'Exchange2016' # Newer EWS versions require a valid value list(self.account.inbox.filter(subject=get_random_string(16))) self.assertEqual(old_version, self.account.version.api_version) finally: From 7d7cc2c2da40a3e5ae2a86f7f61b1b54d8589ebe Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 16:17:46 +0100 Subject: [PATCH 140/509] Test _handle_backoff() --- tests/test_services.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/test_services.py b/tests/test_services.py index 9deda698..57a2507f 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -4,7 +4,7 @@ from exchangelib.errors import ErrorServerBusy, ErrorNonExistentMailbox, TransportError, MalformedResponseError, \ ErrorInvalidServerVersion, ErrorTooManyObjectsOpened, SOAPError from exchangelib.folders import FolderCollection -from exchangelib.protocol import FaultTolerance +from exchangelib.protocol import FaultTolerance, FailFast from exchangelib.services import GetServerTimeZones, GetRoomLists, GetRooms, ResolveNames, FindFolder from exchangelib.util import create_element from exchangelib.version import EXCHANGE_2007, EXCHANGE_2010 @@ -194,9 +194,18 @@ def test_get_elements(self): def test_handle_backoff(self): # Test that we can handle backoff messages svc = ResolveNames(self.account.protocol) + tmp = svc._response_generator + orig_policy = self.account.protocol.config.retry_policy try: - svc. - list(svc._get_elements(create_element('XXX'))) + # We need to fail fast so we don't end up in an infinite loop + self.account.protocol.config.retry_policy = FailFast() + svc._response_generator = Mock(side_effect=ErrorServerBusy('XXX', back_off=1)) + with self.assertRaises(ErrorServerBusy) as e: + list(svc._get_elements(create_element('XXX'))) + self.assertEqual(e.exception.args[0], 'XXX') + finally: + svc._response_generator = tmp + self.account.protocol.config.retry_policy = orig_policy @requests_mock.mock() def test_invalid_soap_response(self, m): From fdb844ac60c381645f4b96e415cfa15fdc96f97e Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 16:24:05 +0100 Subject: [PATCH 141/509] Test coverage of exceeded connection counts --- tests/test_services.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/test_services.py b/tests/test_services.py index 57a2507f..acc10c78 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -2,7 +2,7 @@ from unittest.mock import Mock from exchangelib.errors import ErrorServerBusy, ErrorNonExistentMailbox, TransportError, MalformedResponseError, \ - ErrorInvalidServerVersion, ErrorTooManyObjectsOpened, SOAPError + ErrorInvalidServerVersion, ErrorTooManyObjectsOpened, SOAPError, ErrorExceededConnectionCount from exchangelib.folders import FolderCollection from exchangelib.protocol import FaultTolerance, FailFast from exchangelib.services import GetServerTimeZones, GetRoomLists, GetRooms, ResolveNames, FindFolder @@ -207,6 +207,20 @@ def test_handle_backoff(self): svc._response_generator = tmp self.account.protocol.config.retry_policy = orig_policy + def test_exceeded_connection_count(self): + # Test server repeatedly returning ErrorExceededConnectionCount + svc = ResolveNames(self.account.protocol) + tmp = svc._get_soap_messages + orig_policy = self.account.protocol.config.retry_policy + try: + # We need to fail fast so we don't end up in an infinite loop + svc._get_soap_messages = Mock(side_effect=ErrorExceededConnectionCount('XXX')) + with self.assertRaises(ErrorExceededConnectionCount) as e: + list(svc.call(unresolved_entries=['XXX'])) + self.assertEqual(e.exception.args[0], 'XXX') + finally: + svc._get_soap_messages = tmp + @requests_mock.mock() def test_invalid_soap_response(self, m): m.post(self.account.protocol.service_endpoint, text='XXX') From fd994238088d7321ebb2df926a39abac670b2bda Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 16:49:24 +0100 Subject: [PATCH 142/509] Test oauth and noauth session creation --- tests/test_protocol.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 2546e2bf..4cbc7df7 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -10,11 +10,12 @@ except ImportError: from backports import zoneinfo +from oauthlib.oauth2 import InvalidClientIdError, InvalidGrantError import psutil import requests_mock from exchangelib import close_connections -from exchangelib.credentials import Credentials +from exchangelib.credentials import Credentials, OAuth2Credentials, OAuth2AuthorizationCodeCredentials from exchangelib.configuration import Configuration from exchangelib.items import CalendarItem, SEARCH_SCOPE_CHOICES from exchangelib.errors import SessionPoolMinSizeReached, ErrorNameResolutionNoResults, ErrorAccessDenied, \ @@ -25,7 +26,7 @@ from exchangelib.services import GetRoomLists, GetRooms, ResolveNames, GetSearchableMailboxes, \ SetUserOofSettings, ExpandDL from exchangelib.settings import OofSettings -from exchangelib.transport import NOAUTH, NTLM +from exchangelib.transport import NOAUTH, NTLM, OAUTH2 from exchangelib.util import DummyResponse from exchangelib.version import Build, Version, EXCHANGE_2010_SP1 from exchangelib.winzone import CLDR_TO_MS_TIMEZONE_MAP @@ -840,3 +841,29 @@ def test_get_service_authtype_501(self, m): auth_type=None, version=Version(Build(15, 1)), retry_policy=FailFast() )) self.assertEqual(e.exception.args[0], 'Failed to get auth type from service') + + def test_noauth_session(self): + self.assertEqual( + Protocol(config=Configuration( + service_endpoint='https://example.com/Foo.asmx', credentials=None, + auth_type=NOAUTH, version=Version(Build(15, 1)), retry_policy=FailFast() + )).create_session().auth, + None + ) + + def test_oauth2_session(self): + # Only test failure cases until we have working OAuth2 credentials + with self.assertRaises(InvalidClientIdError): + Protocol(config=Configuration( + service_endpoint='https://example.com/Foo.asmx', + credentials=OAuth2Credentials('XXX', 'YYY', 'ZZZZ'), + auth_type=OAUTH2, version=Version(Build(15, 1)), retry_policy=FailFast() + )).create_session() + with self.assertRaises(InvalidGrantError): + Protocol(config=Configuration( + service_endpoint='https://example.com/Foo.asmx', + credentials=OAuth2AuthorizationCodeCredentials( + client_id='WWW', client_secret='XXX', authorization_code='YYY' + ), + auth_type=OAUTH2, version=Version(Build(15, 1)), retry_policy=FailFast() + )).create_session() From 9d666a872fbb94d1cb5ba9108b78c23b9a31dfaf Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 17:13:22 +0100 Subject: [PATCH 143/509] Add coverage of _get_candidates() --- tests/test_folder.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_folder.py b/tests/test_folder.py index 72d5356b..5bc5b764 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -908,3 +908,27 @@ def test_permissionset_effectiverights_parsing(self): ], unknown_entries=None ), ) + + def test_get_candidate(self): + # _get_candidate is a private method, but it's really difficult to recreate a situation where it's used. + f1 = Inbox(name='XXX', is_distinguished=True) + f2 = Inbox(name=Inbox.LOCALIZED_NAMES[self.account.locale][0]) + with self.assertRaises(ErrorFolderNotFound) as e: + self.account.root._get_candidate(folder_cls=Inbox, folder_coll=[]) + self.assertEqual( + e.exception.args[0], "No usable default folders" + ) + self.assertEqual( + self.account.root._get_candidate(folder_cls=Inbox, folder_coll=[f1]), + f1 + ) + self.assertEqual( + self.account.root._get_candidate(folder_cls=Inbox, folder_coll=[f2]), + f2 + ) + with self.assertRaises(ValueError) as e: + self.account.root._get_candidate(folder_cls=Inbox, folder_coll=[f1, f1]) + self.assertEqual( + e.exception.args[0], + "Multiple possible default folders: ['XXX', 'XXX']" + ) From 71a12fe54ab25cefa7f24d3325c95a166d8568a5 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 17:40:15 +0100 Subject: [PATCH 144/509] Fix some linter recommendations --- exchangelib/attachments.py | 1 + exchangelib/autodiscover/properties.py | 2 +- exchangelib/fields.py | 5 ++--- exchangelib/folders/collections.py | 6 +++--- exchangelib/protocol.py | 1 - exchangelib/util.py | 2 +- 6 files changed, 8 insertions(+), 9 deletions(-) diff --git a/exchangelib/attachments.py b/exchangelib/attachments.py index 00f6206c..7828b648 100644 --- a/exchangelib/attachments.py +++ b/exchangelib/attachments.py @@ -218,6 +218,7 @@ class FileAttachmentIO(io.RawIOBase): def __init__(self, attachment): self._attachment = attachment + self._stream = None self._overflow = None def readable(self): diff --git a/exchangelib/autodiscover/properties.py b/exchangelib/autodiscover/properties.py index 55865e71..4fad1bfd 100644 --- a/exchangelib/autodiscover/properties.py +++ b/exchangelib/autodiscover/properties.py @@ -154,7 +154,7 @@ def auth_type(self): 'negotiate': SSPI, # Unsure about this one 'nego2': GSSAPI, 'anonymous': NOAUTH, # Seen in some docs even though it's not mentioned in MSDN - }.get(self.auth_package.lower(), None) + }.get(self.auth_package.lower()) class Error(EWSElement): diff --git a/exchangelib/fields.py b/exchangelib/fields.py index 0417dcb0..b379ec9e 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -828,9 +828,8 @@ def __init__(self, *args, **kwargs): def clean(self, value, version=None): value = super().clean(value, version=version) - if value is not None: - if len(value) > self.max_length: - raise ValueError(f"{self.name!r} value {value!r} exceeds length {self.max_length}") + if value is not None and len(value) > self.max_length: + raise ValueError(f"{self.name!r} value {value!r} exceeds length {self.max_length}") return value diff --git a/exchangelib/folders/collections.py b/exchangelib/folders/collections.py index e6bf44e3..2f4bd9f8 100644 --- a/exchangelib/folders/collections.py +++ b/exchangelib/folders/collections.py @@ -383,7 +383,7 @@ def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): from ..services import SubscribeToPull if not self.folders: log.debug('Folder list is empty') - return + return None if event_types is None: event_types = SubscribeToPull.EVENT_TYPES return SubscribeToPull(account=self.account).get( @@ -394,7 +394,7 @@ def subscribe_to_push(self, callback_url, event_types=None, watermark=None, stat from ..services import SubscribeToPush if not self.folders: log.debug('Folder list is empty') - return + return None if event_types is None: event_types = SubscribeToPush.EVENT_TYPES return SubscribeToPush(account=self.account).get( @@ -406,7 +406,7 @@ def subscribe_to_streaming(self, event_types=None): from ..services import SubscribeToStreaming if not self.folders: log.debug('Folder list is empty') - return + return None if event_types is None: event_types = SubscribeToStreaming.EVENT_TYPES return SubscribeToStreaming(account=self.account).get(folders=self.folders, event_types=event_types) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 13b53d0c..1e1b8cb8 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -13,7 +13,6 @@ import requests.adapters import requests.sessions -import requests.utils from oauthlib.oauth2 import BackendApplicationClient, WebApplicationClient from requests_oauthlib import OAuth2Session diff --git a/exchangelib/util.py b/exchangelib/util.py index 5c2ad2c1..6b6de253 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -167,7 +167,7 @@ def get_xml_attr(tree, name): def get_xml_attrs(tree, name): - return list(elem.text for elem in tree.findall(name) if elem.text is not None) + return [elem.text for elem in tree.findall(name) if elem.text is not None] def value_to_xml_text(value): From 7a2c89f52ce267be9ce53f377527b84bc2b4b2a6 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 21:35:29 +0100 Subject: [PATCH 145/509] Test Protocol init. Move validation to __call__ where it's needed first --- exchangelib/protocol.py | 11 +++++------ tests/test_protocol.py | 11 ++++++++++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 13b53d0c..080bbe80 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -59,11 +59,6 @@ class BaseProtocol: USERAGENT = None def __init__(self, config): - from .configuration import Configuration - if not isinstance(config, Configuration): - raise InvalidTypeError('config', config, Configuration) - if not config.service_endpoint: - raise AttributeError("'config.service_endpoint' must be set") self.config = config self._session_pool_size = 0 self._session_pool_maxsize = config.max_connections or self.SESSION_POOLSIZE @@ -370,8 +365,12 @@ def __call__(cls, *args, **kwargs): # # We ignore auth_type from kwargs in the cache key. We trust caller to supply the correct auth_type - otherwise # __init__ will guess the correct auth type. - config = kwargs['config'] + from .configuration import Configuration + if not isinstance(config, Configuration): + raise InvalidTypeError('config', config, Configuration) + if not config.service_endpoint: + raise AttributeError("'config.service_endpoint' must be set") _protocol_cache_key = cls._cache_key(config) try: diff --git a/tests/test_protocol.py b/tests/test_protocol.py index ad4111e2..00ee4ce9 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -33,11 +33,20 @@ class ProtocolTest(EWSTest): - def test_close_connections_helper(self): # Just test that it doesn't break close_connections() + def test_init(self): + with self.assertRaises(TypeError) as e: + Protocol(config='XXX') + self.assertEqual( + e.exception.args[0], "'config' 'XXX' must be of type " + ) + with self.assertRaises(AttributeError) as e: + Protocol(config=Configuration()) + self.assertEqual(e.exception.args[0], "'config.service_endpoint' must be set") + def test_pickle(self): # Test that we can pickle, repr and str Protocols o = Protocol(config=Configuration( From 9fd02a5096f6c527d9df6a712bc19e0e82bf8824 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 21:36:11 +0100 Subject: [PATCH 146/509] Mock the instance. Makes cleanup moot --- tests/test_autodiscover.py | 12 ++++-------- tests/test_protocol.py | 12 ++++-------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index bc4666fe..ddfe21a2 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -633,14 +633,10 @@ def test_raise_errors(self): def test_del_on_error(self): # Test that __del__ can handle exceptions on close() cache = AutodiscoverCache() - tmp = AutodiscoverCache.close - try: - AutodiscoverCache.close = Mock(side_effect=Exception('XXX')) - with self.assertRaises(Exception): - cache.close() - del cache - finally: - AutodiscoverCache.close = tmp + cache.close = Mock(side_effect=Exception('XXX')) + with self.assertRaises(Exception): + cache.close() + del cache def test_shelve_filename(self): major, minor = sys.version_info[:2] diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 00ee4ce9..9b61ad5e 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -743,14 +743,10 @@ def test_del_on_error(self): auth_type=NOAUTH, version=Version(Build(15, 1)), retry_policy=FailFast(), max_connections=3 )) - tmp = Protocol.close - try: - Protocol.close = Mock(side_effect=Exception('XXX')) - with self.assertRaises(Exception): - protocol.close() - del protocol - finally: - Protocol.close = tmp + protocol.close = Mock(side_effect=Exception('XXX')) + with self.assertRaises(Exception): + protocol.close() + del protocol @requests_mock.mock() def test_version_guess(self, m): From dd8b608269acfdb34987bc0cbba97d821bba9903 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 22:16:13 +0100 Subject: [PATCH 147/509] Improve test coverage of Protocol --- exchangelib/protocol.py | 12 ++----- tests/test_protocol.py | 79 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 73 insertions(+), 18 deletions(-) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 11d5ce84..e462be19 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -8,7 +8,7 @@ import datetime import logging import os -from queue import LifoQueue, Empty, Full +from queue import LifoQueue, Empty from threading import Lock import requests.adapters @@ -201,13 +201,10 @@ def get_session(self): def release_session(self, session): # This should never fail, as we don't have more sessions than the queue contains log.debug('Server %s: Releasing session %s', self.server, session.session_id) - if self.MAX_SESSION_USAGE_COUNT and session.usage_count > self.MAX_SESSION_USAGE_COUNT: + if self.MAX_SESSION_USAGE_COUNT and session.usage_count >= self.MAX_SESSION_USAGE_COUNT: log.debug('Server %s: session %s usage exceeded limit. Discarding', self.server, session.session_id) session = self.renew_session(session) - try: - self._session_pool.put(session, block=False) - except Full: - log.debug('Server %s: Session pool was already full %s', self.server, session.session_id) + self._session_pool.put(session, block=False) @staticmethod def close_session(session): @@ -275,9 +272,6 @@ def create_session(self): return session def create_oauth2_session(self): - if self.auth_type != OAUTH2: - raise ValueError(f'Auth type must be {OAUTH2!r} for credentials type {self.credentials.__class__.__name__}') - has_token = False scope = ['https://outlook.office365.com/.default'] session_params = {} diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 8acb7b84..871c7cb7 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -35,6 +35,26 @@ class ProtocolTest(EWSTest): + def test_magic(self): + o = Protocol(config=Configuration( + service_endpoint='https://example.com/Foo.asmx', + credentials=Credentials(get_random_string(8), get_random_string(8)), + auth_type=NTLM, version=Version(Build(15, 1)), retry_policy=FailFast() + )) + self.assertEqual(str(o), '''\ +EWS url: https://example.com/Foo.asmx +Product name: Microsoft Exchange Server 2016 +EWS API version: Exchange2016 +Build number: 15.1.0.0 +EWS auth: NTLM''') + o.config.version = None + self.assertEqual(str(o), '''\ +EWS url: https://example.com/Foo.asmx +Product name: [unknown] +EWS API version: [unknown] +Build number: [unknown] +EWS auth: NTLM''') + def test_close_connections_helper(self): # Just test that it doesn't break close_connections() @@ -124,7 +144,7 @@ def conn_count(): auth_type=NOAUTH, version=Version(Build(15, 1)), retry_policy=FailFast(), max_connections=3 )) - # Merely getting a session should not create conections + # Merely getting a session should not create connections session = protocol.get_session() self.assertEqual(conn_count(), 0) # Open one URL - we have 1 connection @@ -175,6 +195,30 @@ def test_decrease_poolsize(self): protocol.decrease_poolsize() self.assertEqual(protocol._session_pool.qsize(), 1) + def test_max_usage_count(self): + protocol = Protocol(config=Configuration( + service_endpoint='https://example.com/Foo.asmx', + credentials=Credentials(get_random_string(8), get_random_string(8)), + auth_type=NTLM, version=Version(Build(15, 1)), retry_policy=FailFast(), + max_connections=1 + )) + session = protocol.get_session() + protocol.release_session(session) + protocol.release_session(session) + for _ in range(2): + session = protocol.get_session() + protocol.release_session(session) + self.assertEqual(session.usage_count, 2) + tmp = Protocol.MAX_SESSION_USAGE_COUNT + try: + Protocol.MAX_SESSION_USAGE_COUNT = 1 + for _ in range(2): + session = protocol.get_session() + protocol.release_session(session) + self.assertEqual(session.usage_count, 1) + finally: + Protocol.MAX_SESSION_USAGE_COUNT = tmp + def test_get_timezones(self): # Test shortcut data = list(self.account.protocol.get_timezones()) @@ -847,6 +891,21 @@ def test_get_service_authtype_501(self, m): )) self.assertEqual(e.exception.args[0], 'Failed to get auth type from service') + def test_create_session_failure(self): + protocol = Protocol(config=Configuration( + service_endpoint='https://example.com/Foo.asmx', credentials=None, + auth_type=NOAUTH, version=Version(Build(15, 1)), retry_policy=FailFast() + )) + with self.assertRaises(ValueError) as e: + protocol.config.auth_type = None + protocol.create_session() + self.assertEqual(e.exception.args[0], 'Cannot create session without knowing the auth type') + with self.assertRaises(ValueError) as e: + protocol.config.auth_type = NTLM + protocol.credentials = None + protocol.create_session() + self.assertEqual(e.exception.args[0], "Auth type 'NTLM' requires credentials") + def test_noauth_session(self): self.assertEqual( Protocol(config=Configuration( @@ -864,11 +923,13 @@ def test_oauth2_session(self): credentials=OAuth2Credentials('XXX', 'YYY', 'ZZZZ'), auth_type=OAUTH2, version=Version(Build(15, 1)), retry_policy=FailFast() )).create_session() - with self.assertRaises(InvalidGrantError): - Protocol(config=Configuration( - service_endpoint='https://example.com/Foo.asmx', - credentials=OAuth2AuthorizationCodeCredentials( - client_id='WWW', client_secret='XXX', authorization_code='YYY' - ), - auth_type=OAUTH2, version=Version(Build(15, 1)), retry_policy=FailFast() - )).create_session() + + protocol = Protocol(config=Configuration( + service_endpoint='https://example.com/Foo.asmx', + credentials=OAuth2AuthorizationCodeCredentials( + client_id='WWW', client_secret='XXX', authorization_code='YYY', access_token={'access_token': 'ZZZ'} + ), + auth_type=OAUTH2, version=Version(Build(15, 1)), retry_policy=FailFast() + )) + session = protocol.create_session() + protocol.refresh_credentials(session) From 2f17e01b57ea0147c014eb23547bcc8923f0f547 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 22:55:11 +0100 Subject: [PATCH 148/509] Remove unused code path --- exchangelib/services/common.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 2d499401..8174b314 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -849,8 +849,6 @@ def to_item_id(item, item_cls): return item if isinstance(item, (tuple, list)): return item_cls(*item) - if isinstance(item, dict): - return item_cls(**item) return item_cls(item.id, item.changekey) From 312a1e2f234e78b6d4d786efd5a1d4c053d2d4ae Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 22:55:35 +0100 Subject: [PATCH 149/509] Test extra exception info parsing --- tests/test_services.py | 70 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/tests/test_services.py b/tests/test_services.py index acc10c78..c2a176b1 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -2,10 +2,11 @@ from unittest.mock import Mock from exchangelib.errors import ErrorServerBusy, ErrorNonExistentMailbox, TransportError, MalformedResponseError, \ - ErrorInvalidServerVersion, ErrorTooManyObjectsOpened, SOAPError, ErrorExceededConnectionCount + ErrorInvalidServerVersion, ErrorTooManyObjectsOpened, SOAPError, ErrorExceededConnectionCount, \ + ErrorInternalServerError, ErrorInvalidValueForProperty from exchangelib.folders import FolderCollection from exchangelib.protocol import FaultTolerance, FailFast -from exchangelib.services import GetServerTimeZones, GetRoomLists, GetRooms, ResolveNames, FindFolder +from exchangelib.services import GetServerTimeZones, GetRoomLists, GetRooms, ResolveNames, FindFolder, DeleteItem from exchangelib.util import create_element from exchangelib.version import EXCHANGE_2007, EXCHANGE_2010 @@ -24,6 +25,71 @@ def test_invalid_server_version(self): with self.assertRaises(NotImplementedError): list(GetRooms(protocol=account.protocol).call('XXX')) + def test_inner_error_parsing(self): + # Test that we can parse an exception response via SOAP body + xml = b'''\ + + + + + + + An internal server error occurred. The operation failed. + ErrorInternalServerError + 0 + + Cannot delete message because the destination folder is out of quota. + ErrorQuotaExceededOnDelete + 0 + + + + + +''' + ws = DeleteItem(account=self.account) + with self.assertRaises(ErrorInternalServerError) as e: + list(ws.parse(xml)) + self.assertEqual( + e.exception.args[0], + "An internal server error occurred. The operation failed. (inner error: " + "ErrorQuotaExceededOnDelete('Cannot delete message because the destination folder is out of quota.'))" + ) + + def test_invalid_value_extras(self): + # Test that we can parse an exception response via SOAP body + xml = b'''\ + + + + + + + The specified value is invalid for property. + ErrorInvalidValueForProperty + 0 + + XXX + YYY + + + + + +''' + ws = DeleteItem(account=self.account) + with self.assertRaises(ErrorInvalidValueForProperty) as e: + list(ws.parse(xml)) + self.assertEqual(e.exception.args[0], "The specified value is invalid for property. (Foo: XXX, Bar: YYY)") + def test_error_server_busy(self): # Test that we can parse an exception response via SOAP body xml = b'''\ From 2cc138cc5e6b071ebcffe612b5203611e06f1bf4 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 23:02:38 +0100 Subject: [PATCH 150/509] Also test chunk_size --- exchangelib/services/common.py | 2 +- tests/test_items/test_generic.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 8174b314..cdc7cd0d 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -700,7 +700,7 @@ def __init__(self, *args, **kwargs): if not isinstance(self.page_size, int): raise InvalidTypeError('page_size', self.page_size, int) if self.page_size < 1: - raise ValueError("'page_size' must be a positive number") + raise ValueError(f"'page_size' {self.page_size} must be a positive number") super().__init__(*args, **kwargs) def _paged_call(self, payload_func, max_items, folders, **kwargs): diff --git a/tests/test_items/test_generic.py b/tests/test_items/test_generic.py index 90e12bf0..3f7236e9 100644 --- a/tests/test_items/test_generic.py +++ b/tests/test_items/test_generic.py @@ -283,7 +283,13 @@ def test_invalid_finditem_args(self): self.assertEqual(e.exception.args[0], "'page_size' 'XXX' must be of type ") with self.assertRaises(ValueError) as e: FindItem(account=self.account, page_size=-1) - self.assertEqual(e.exception.args[0], "'page_size' must be a positive number") + self.assertEqual(e.exception.args[0], "'page_size' -1 must be a positive number") + with self.assertRaises(TypeError) as e: + FindItem(account=self.account, chunk_size='XXX') + self.assertEqual(e.exception.args[0], "'chunk_size' 'XXX' must be of type ") + with self.assertRaises(ValueError) as e: + FindItem(account=self.account, chunk_size=-1) + self.assertEqual(e.exception.args[0], "'chunk_size' -1 must be a positive number") with self.assertRaises(ValueError) as e: FindItem(account=self.account).call( folders=None, From 7e4c850534d62fd6b1c7ca638c9a703acbe22556 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 23:19:12 +0100 Subject: [PATCH 151/509] Parse and test for extra error info found in issue #370 --- exchangelib/services/common.py | 6 +++++- tests/test_services.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index cdc7cd0d..a1bce1d7 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -468,9 +468,13 @@ def _raise_soap_errors(cls, fault): pass raise ErrorServerBusy(msg, back_off=back_off) if code == 'ErrorSchemaValidation' and msg_xml is not None: + line_number = get_xml_attr(msg_xml, f'{{{TNS}}}LineNumber') + line_position = get_xml_attr(msg_xml, f'{{{TNS}}}LinePosition') violation = get_xml_attr(msg_xml, f'{{{TNS}}}Violation') - if violation is not None: + if violation: msg = f'{msg} {violation}' + if line_number or line_position: + msg = f'{msg} (line: {line_number} position: {line_position})' try: raise vars(errors)[code](msg) except KeyError: diff --git a/tests/test_services.py b/tests/test_services.py index c2a176b1..8b11c2bd 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -3,7 +3,7 @@ from exchangelib.errors import ErrorServerBusy, ErrorNonExistentMailbox, TransportError, MalformedResponseError, \ ErrorInvalidServerVersion, ErrorTooManyObjectsOpened, SOAPError, ErrorExceededConnectionCount, \ - ErrorInternalServerError, ErrorInvalidValueForProperty + ErrorInternalServerError, ErrorInvalidValueForProperty, ErrorSchemaValidation from exchangelib.folders import FolderCollection from exchangelib.protocol import FaultTolerance, FailFast from exchangelib.services import GetServerTimeZones, GetRoomLists, GetRooms, ResolveNames, FindFolder, DeleteItem @@ -119,6 +119,33 @@ def test_error_server_busy(self): ws.parse(xml) self.assertEqual(e.exception.back_off, 297.749) # Test that we correctly parse the BackOffMilliseconds value + def test_error_schema_validation(self): + # Test that we can parse extra info with ErrorSchemaValidation + xml = b'''\ + + + + + a:ErrorSchemaValidation + XXX + + ErrorSchemaValidation + YYY + + 123 + 456 + ZZZ + + + + +''' + version = mock_version(build=EXCHANGE_2010) + ws = GetRoomLists(mock_protocol(version=version, service_endpoint='example.com')) + with self.assertRaises(ErrorSchemaValidation) as e: + ws.parse(xml) + self.assertEqual(e.exception.args[0], 'YYY ZZZ (line: 123 position: 456)') + @requests_mock.mock(real_http=True) def test_error_too_many_objects_opened(self, m): # Test that we can parse ErrorTooManyObjectsOpened via ResponseMessage and return From d3962c04b7c771fe8effe4179347e4104d294e9b Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Jan 2022 23:30:28 +0100 Subject: [PATCH 152/509] Fix test --- tests/test_protocol.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 871c7cb7..4fb94ea4 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -204,11 +204,11 @@ def test_max_usage_count(self): )) session = protocol.get_session() protocol.release_session(session) - protocol.release_session(session) + self.assertEqual(session.usage_count, 1) for _ in range(2): session = protocol.get_session() protocol.release_session(session) - self.assertEqual(session.usage_count, 2) + self.assertEqual(session.usage_count, 3) tmp = Protocol.MAX_SESSION_USAGE_COUNT try: Protocol.MAX_SESSION_USAGE_COUNT = 1 From a81463dbfae0173941c8672d0cdeae256a1333fe Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 00:00:50 +0100 Subject: [PATCH 153/509] Mock the class method so we can revert --- tests/test_autodiscover.py | 4 +++- tests/test_protocol.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index ddfe21a2..ca1777f6 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -632,11 +632,13 @@ def test_raise_errors(self): def test_del_on_error(self): # Test that __del__ can handle exceptions on close() + tmp = AutodiscoverCache.close cache = AutodiscoverCache() - cache.close = Mock(side_effect=Exception('XXX')) + AutodiscoverCache.close = Mock(side_effect=Exception('XXX')) with self.assertRaises(Exception): cache.close() del cache + AutodiscoverCache.close = tmp def test_shelve_filename(self): major, minor = sys.version_info[:2] diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 4fb94ea4..607572e5 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -783,16 +783,18 @@ def test_disable_ssl_verification(self): def test_del_on_error(self): # Test that __del__ can handle exceptions on close() + tmp = Protocol.close protocol = Protocol(config=Configuration( service_endpoint='http://foo.example.org', credentials=Credentials(get_random_string(8), get_random_string(8)), auth_type=NOAUTH, version=Version(Build(15, 1)), retry_policy=FailFast(), max_connections=3 )) - protocol.close = Mock(side_effect=Exception('XXX')) + Protocol.close = Mock(side_effect=Exception('XXX')) with self.assertRaises(Exception): protocol.close() del protocol + Protocol.close = tmp @requests_mock.mock() def test_version_guess(self, m): From 8fc4f4da4db42edf4c07181ac5511feae4b898c5 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 00:25:40 +0100 Subject: [PATCH 154/509] Don't patch the global Account instance --- exchangelib/services/create_item.py | 2 +- tests/common.py | 12 ++++++++---- tests/test_folder.py | 13 +++++-------- tests/test_items/test_calendaritems.py | 23 ++++++++++------------- tests/test_items/test_messages.py | 10 ++++------ tests/test_protocol.py | 10 ++++------ 6 files changed, 32 insertions(+), 38 deletions(-) diff --git a/exchangelib/services/create_item.py b/exchangelib/services/create_item.py index 4cb33e4d..12657024 100644 --- a/exchangelib/services/create_item.py +++ b/exchangelib/services/create_item.py @@ -28,7 +28,7 @@ def call(self, items, folder, message_disposition, send_meeting_invitations): if not isinstance(folder, (BaseFolder, FolderId)): raise InvalidTypeError('folder', folder, (BaseFolder, FolderId)) if folder.account != self.account: - raise ValueError('"Folder must belong to this account') + raise ValueError(f'Folder must belong to account') if message_disposition == SAVE_ONLY and folder is None: raise AttributeError("Folder must be supplied when in save-only mode") if message_disposition == SEND_AND_SAVE_COPY and folder is None: diff --git a/tests/common.py b/tests/common.py index 9814acf9..9ae9a521 100644 --- a/tests/common.py +++ b/tests/common.py @@ -87,15 +87,19 @@ def setUpClass(cls): BaseProtocol.HTTP_ADAPTER_CLS = NoVerifyHTTPAdapter # Create an account shared by all tests - tz = zoneinfo.ZoneInfo('Europe/Copenhagen') + cls.tz = zoneinfo.ZoneInfo('Europe/Copenhagen') cls.retry_policy = FaultTolerance(max_wait=600) - config = Configuration( + cls.config = Configuration( server=settings['server'], credentials=Credentials(settings['username'], settings['password']), retry_policy=cls.retry_policy, ) - cls.account = Account(primary_smtp_address=settings['account'], access_type=DELEGATE, config=config, - locale='da_DK', default_timezone=tz) + cls.account = cls.get_account() + + @classmethod + def get_account(cls): + return Account(primary_smtp_address=cls.settings['account'], access_type=DELEGATE, config=cls.config, + locale='da_DK', default_timezone=cls.tz) def setUp(self): super().setUp() diff --git a/tests/test_folder.py b/tests/test_folder.py index 5bc5b764..84d905ab 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -162,14 +162,11 @@ def test_find_folders_multiple_roots(self): self.assertIn("All folders in 'roots' must have the same root hierarchy", e.exception.args[0]) def test_find_folders_compat(self): - coll = FolderCollection(account=self.account, folders=[self.account.root]) - tmp = self.account.version.build - try: - self.account.version.build = EXCHANGE_2007 # Need to set it after the last auto-config of version - with self.assertRaises(NotImplementedError) as e: - list(coll.find_folders(offset=1)) - finally: - self.account.version.build = tmp + account = self.get_account() + coll = FolderCollection(account=account, folders=[account.root]) + account.version.build = EXCHANGE_2007 # Need to set it after the last auto-config of version + with self.assertRaises(NotImplementedError) as e: + list(coll.find_folders(offset=1)) self.assertEqual( e.exception.args[0], "'offset' is only supported for Exchange 2010 servers and later" diff --git a/tests/test_items/test_calendaritems.py b/tests/test_items/test_calendaritems.py index c4a8258d..8f340895 100644 --- a/tests/test_items/test_calendaritems.py +++ b/tests/test_items/test_calendaritems.py @@ -557,16 +557,13 @@ def test_tz_field_for_field_name(self): CalendarItem(account=self.account).tz_field_for_field_name('end').name, '_end_timezone', ) - tmp = self.account.version.build - try: - self.account.version.build = EXCHANGE_2007 - self.assertEqual( - CalendarItem(account=self.account).tz_field_for_field_name('start').name, - '_meeting_timezone', - ) - self.assertEqual( - CalendarItem(account=self.account).tz_field_for_field_name('end').name, - '_meeting_timezone', - ) - finally: - self.account.version.build = tmp + account = self.get_account() + account.version.build = EXCHANGE_2007 + self.assertEqual( + CalendarItem(account=account).tz_field_for_field_name('start').name, + '_meeting_timezone', + ) + self.assertEqual( + CalendarItem(account=account).tz_field_for_field_name('end').name, + '_meeting_timezone', + ) diff --git a/tests/test_items/test_messages.py b/tests/test_items/test_messages.py index 18af0861..c1707c12 100644 --- a/tests/test_items/test_messages.py +++ b/tests/test_items/test_messages.py @@ -42,13 +42,11 @@ def test_send(self): def test_send_pre_2013(self): # Test < Exchange 2013 fallback for attachments and send-only mode item = self.get_test_item() + item.account = self.get_account() + item.folder = item.account.inbox item.attach(FileAttachment(name='file_attachment', content=b'file_attachment')) - tmp = self.account.version.build - try: - self.account.version.build = EXCHANGE_2010_SP2 - item.send(save_copy=False) - finally: - self.account.version.build = tmp + item.account.version.build = EXCHANGE_2010_SP2 + item.send(save_copy=False) self.assertIsNone(item.id) self.assertIsNone(item.changekey) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 607572e5..cc35a94c 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -394,12 +394,10 @@ def test_resolvenames(self): "Chunk size 500 is too high. ResolveNames supports returning at most 100 candidates for a lookup" ) tmp = self.account.protocol.version.build - try: - self.account.protocol.version.build = EXCHANGE_2010_SP1 - with self.assertRaises(NotImplementedError) as e: - self.account.protocol.resolve_names(names=['xxx@example.com'], shape='IdOnly') - finally: - self.account.protocol.version.build = tmp + self.account.protocol.version.build = EXCHANGE_2010_SP1 + with self.assertRaises(NotImplementedError) as e: + self.account.protocol.resolve_names(names=['xxx@example.com'], shape='IdOnly') + self.account.protocol.version.build = tmp self.assertEqual( e.exception.args[0], "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later" From 2c96510e642c72ef7d1a005afac71302cb0009c2 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 00:40:33 +0100 Subject: [PATCH 155/509] flake8 --- exchangelib/services/create_item.py | 2 +- tests/common.py | 2 +- tests/test_protocol.py | 2 +- tests/test_services.py | 11 ++++++----- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/exchangelib/services/create_item.py b/exchangelib/services/create_item.py index 12657024..27318a27 100644 --- a/exchangelib/services/create_item.py +++ b/exchangelib/services/create_item.py @@ -28,7 +28,7 @@ def call(self, items, folder, message_disposition, send_meeting_invitations): if not isinstance(folder, (BaseFolder, FolderId)): raise InvalidTypeError('folder', folder, (BaseFolder, FolderId)) if folder.account != self.account: - raise ValueError(f'Folder must belong to account') + raise ValueError('Folder must belong to account') if message_disposition == SAVE_ONLY and folder is None: raise AttributeError("Folder must be supplied when in save-only mode") if message_disposition == SEND_AND_SAVE_COPY and folder is None: diff --git a/tests/common.py b/tests/common.py index 9ae9a521..815634bc 100644 --- a/tests/common.py +++ b/tests/common.py @@ -99,7 +99,7 @@ def setUpClass(cls): @classmethod def get_account(cls): return Account(primary_smtp_address=cls.settings['account'], access_type=DELEGATE, config=cls.config, - locale='da_DK', default_timezone=cls.tz) + locale='da_DK', default_timezone=cls.tz) def setUp(self): super().setUp() diff --git a/tests/test_protocol.py b/tests/test_protocol.py index cc35a94c..61b915be 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -10,7 +10,7 @@ except ImportError: from backports import zoneinfo -from oauthlib.oauth2 import InvalidClientIdError, InvalidGrantError +from oauthlib.oauth2 import InvalidClientIdError import psutil import requests_mock diff --git a/tests/test_services.py b/tests/test_services.py index 8b11c2bd..e781828e 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -42,7 +42,7 @@ def test_inner_error_parsing(self): ErrorInternalServerError 0 - Cannot delete message because the destination folder is out of quota. + Cannot delete message because the folder is out of quota. ErrorQuotaExceededOnDelete 0 @@ -57,7 +57,7 @@ def test_inner_error_parsing(self): self.assertEqual( e.exception.args[0], "An internal server error occurred. The operation failed. (inner error: " - "ErrorQuotaExceededOnDelete('Cannot delete message because the destination folder is out of quota.'))" + "ErrorQuotaExceededOnDelete('Cannot delete message because the folder is out of quota.'))" ) def test_invalid_value_extras(self): @@ -126,10 +126,12 @@ def test_error_schema_validation(self): - a:ErrorSchemaValidation + a:ErrorSchemaValidation + XXX - ErrorSchemaValidation + + ErrorSchemaValidation YYY 123 @@ -304,7 +306,6 @@ def test_exceeded_connection_count(self): # Test server repeatedly returning ErrorExceededConnectionCount svc = ResolveNames(self.account.protocol) tmp = svc._get_soap_messages - orig_policy = self.account.protocol.config.retry_policy try: # We need to fail fast so we don't end up in an infinite loop svc._get_soap_messages = Mock(side_effect=ErrorExceededConnectionCount('XXX')) From 3b7c838c5d62af2258e05bb446d3e432e5f0a662 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 01:19:06 +0100 Subject: [PATCH 156/509] Reduce code duplication --- tests/test_protocol.py | 136 ++++++++++++----------------------------- 1 file changed, 40 insertions(+), 96 deletions(-) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 61b915be..46e9be17 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -35,21 +35,29 @@ class ProtocolTest(EWSTest): - def test_magic(self): - o = Protocol(config=Configuration( - service_endpoint='https://example.com/Foo.asmx', - credentials=Credentials(get_random_string(8), get_random_string(8)), - auth_type=NTLM, version=Version(Build(15, 1)), retry_policy=FailFast() + @staticmethod + def get_test_protocol(**kwargs): + return Protocol(config=Configuration( + server=kwargs.get('server'), + service_endpoint=kwargs.get('service_endpoint', f'https://{get_random_string(4)}.example.com/Foo.asmx'), + credentials=kwargs.get('credentials', Credentials(get_random_string(8), get_random_string(8))), + auth_type=kwargs.get('auth_type', NTLM), + version=kwargs.get('version', Version(Build(15, 1))), + retry_policy=kwargs.get('retry_policy', FailFast()), + max_connections=kwargs.get('max_connections'), )) - self.assertEqual(str(o), '''\ -EWS url: https://example.com/Foo.asmx + + def test_magic(self): + p = self.get_test_protocol() + self.assertEqual(str(p), f'''\ +EWS url: {p.service_endpoint} Product name: Microsoft Exchange Server 2016 EWS API version: Exchange2016 Build number: 15.1.0.0 EWS auth: NTLM''') - o.config.version = None - self.assertEqual(str(o), '''\ -EWS url: https://example.com/Foo.asmx + p.config.version = None + self.assertEqual(str(p), f'''\ +EWS url: {p.service_endpoint} Product name: [unknown] EWS API version: [unknown] Build number: [unknown] @@ -71,11 +79,7 @@ def test_init(self): def test_pickle(self): # Test that we can pickle, repr and str Protocols - o = Protocol(config=Configuration( - service_endpoint='https://example.com/Foo.asmx', - credentials=Credentials(get_random_string(8), get_random_string(8)), - auth_type=NTLM, version=Version(Build(15, 1)), retry_policy=FailFast() - )) + o = self.get_test_protocol() pickled_o = pickle.dumps(o) unpickled_o = pickle.loads(pickled_o) self.assertIsInstance(unpickled_o, type(o)) @@ -84,11 +88,7 @@ def test_pickle(self): @requests_mock.mock() def test_session(self, m): - protocol = Protocol(config=Configuration( - service_endpoint='https://example.com/Foo.asmx', - credentials=Credentials(get_random_string(8), get_random_string(8)), - auth_type=NTLM, version=Version(Build(15, 1)), retry_policy=FailFast() - )) + protocol = self.get_test_protocol() session = protocol.create_session() new_session = protocol.renew_session(session) self.assertNotEqual(id(session), id(new_session)) @@ -96,12 +96,11 @@ def test_session(self, m): @requests_mock.mock() def test_protocol_instance_caching(self, m): # Verify that we get the same Protocol instance for the same combination of (endpoint, credentials) - user, password = get_random_string(8), get_random_string(8) config = Configuration( - service_endpoint='https://example.com/Foo.asmx', credentials=Credentials(user, password), + service_endpoint='https://example.com/Foo.asmx', + credentials=Credentials(get_random_string(8), get_random_string(8)), auth_type=NTLM, version=Version(Build(15, 1)), retry_policy=FailFast() ) - # Test CachingProtocol.__getitem__ with self.assertRaises(KeyError): _ = Protocol[config] @@ -138,12 +137,7 @@ def conn_count(): return len([p for p in proc.connections() if p.raddr[0] in ip_addresses]) self.assertGreater(len(ip_addresses), 0) - protocol = Protocol(config=Configuration( - service_endpoint='http://httpbin.org', - credentials=Credentials(get_random_string(8), get_random_string(8)), - auth_type=NOAUTH, version=Version(Build(15, 1)), retry_policy=FailFast(), - max_connections=3 - )) + protocol = self.get_test_protocol(service_endpoint='http://httpbin.org', auth_type=NOAUTH, max_connections=3) # Merely getting a session should not create connections session = protocol.get_session() self.assertEqual(conn_count(), 0) @@ -174,12 +168,7 @@ def conn_count(): def test_decrease_poolsize(self): # Test increasing and decreasing the pool size max_connections = 3 - protocol = Protocol(config=Configuration( - service_endpoint='https://example.com/Foo.asmx', - credentials=Credentials(get_random_string(8), get_random_string(8)), - auth_type=NTLM, version=Version(Build(15, 1)), retry_policy=FailFast(), - max_connections=max_connections, - )) + protocol = self.get_test_protocol(max_connections=max_connections) self.assertEqual(protocol._session_pool.qsize(), 0) self.assertEqual(protocol.session_pool_size, 0) protocol.increase_poolsize() @@ -196,12 +185,7 @@ def test_decrease_poolsize(self): self.assertEqual(protocol._session_pool.qsize(), 1) def test_max_usage_count(self): - protocol = Protocol(config=Configuration( - service_endpoint='https://example.com/Foo.asmx', - credentials=Credentials(get_random_string(8), get_random_string(8)), - auth_type=NTLM, version=Version(Build(15, 1)), retry_policy=FailFast(), - max_connections=1 - )) + protocol = self.get_test_protocol(max_connections=1) session = protocol.get_session() protocol.release_session(session) self.assertEqual(session.usage_count, 1) @@ -782,12 +766,7 @@ def test_disable_ssl_verification(self): def test_del_on_error(self): # Test that __del__ can handle exceptions on close() tmp = Protocol.close - protocol = Protocol(config=Configuration( - service_endpoint='http://foo.example.org', - credentials=Credentials(get_random_string(8), get_random_string(8)), - auth_type=NOAUTH, version=Version(Build(15, 1)), retry_policy=FailFast(), - max_connections=3 - )) + protocol = self.get_test_protocol() Protocol.close = Mock(side_effect=Exception('XXX')) with self.assertRaises(Exception): protocol.close() @@ -796,13 +775,9 @@ def test_del_on_error(self): @requests_mock.mock() def test_version_guess(self, m): - protocol = Protocol(config=Configuration( - service_endpoint='https://example.com/EWS/Exchange.asmx', - credentials=Credentials(get_random_string(8), get_random_string(8)), - auth_type=NTLM, version=Version(Build(15, 1)), retry_policy=FailFast() - )) + protocol = self.get_test_protocol() # Test that we can get the version even on error responses - m.post('https://example.com/EWS/Exchange.asmx', status_code=200, content=b'''\ + m.post(protocol.service_endpoint, status_code=200, content=b'''\ @@ -827,7 +802,7 @@ def test_version_guess(self, m): self.assertEqual(protocol.version.build, Build(15, 1, 2345, 6789)) # Test exception when there are no version headers - m.post('https://example.com/EWS/Exchange.asmx', status_code=200, content=b'''\ + m.post(protocol.service_endpoint, status_code=200, content=b'''\ @@ -856,46 +831,27 @@ def test_version_guess(self, m): @patch('requests.sessions.Session.post', side_effect=ConnectionResetError('XXX')) def test_get_service_authtype(self, m): with self.assertRaises(TransportError) as e: - Protocol(config=Configuration( - service_endpoint='https://example.com/EWS/Exchange.asmx', - credentials=Credentials(get_random_string(8), get_random_string(8)), - auth_type=None, version=Version(Build(15, 1)), retry_policy=FailFast() - )) + self.get_test_protocol(auth_type=None) self.assertEqual(e.exception.args[0], 'XXX') with self.assertRaises(RateLimitError) as e: - Protocol(config=Configuration( - service_endpoint='https://example.com/EWS/Exchange.asmx', - credentials=Credentials(get_random_string(8), get_random_string(8)), - auth_type=None, version=Version(Build(15, 1)), retry_policy=FaultTolerance(max_wait=0.5) - )) + self.get_test_protocol(auth_type=None, retry_policy=FaultTolerance(max_wait=0.5)) self.assertEqual(e.exception.args[0], 'Max timeout reached') @patch('requests.sessions.Session.post', return_value=DummyResponse(status_code=401)) def test_get_service_authtype_401(self, m): with self.assertRaises(TransportError) as e: - Protocol(config=Configuration( - service_endpoint='https://example.com/EWS/Exchange.asmx', - credentials=Credentials(get_random_string(8), get_random_string(8)), - auth_type=None, version=Version(Build(15, 1)), retry_policy=FailFast() - )) + self.get_test_protocol(auth_type=None) self.assertEqual(e.exception.args[0], 'Failed to get auth type from service') @patch('requests.sessions.Session.post', return_value=DummyResponse(status_code=501)) def test_get_service_authtype_501(self, m): with self.assertRaises(TransportError) as e: - Protocol(config=Configuration( - service_endpoint='https://example.com/EWS/Exchange.asmx', - credentials=Credentials(get_random_string(8), get_random_string(8)), - auth_type=None, version=Version(Build(15, 1)), retry_policy=FailFast() - )) + self.get_test_protocol(auth_type=None) self.assertEqual(e.exception.args[0], 'Failed to get auth type from service') def test_create_session_failure(self): - protocol = Protocol(config=Configuration( - service_endpoint='https://example.com/Foo.asmx', credentials=None, - auth_type=NOAUTH, version=Version(Build(15, 1)), retry_policy=FailFast() - )) + protocol = self.get_test_protocol(auth_type=NOAUTH, credentials=None) with self.assertRaises(ValueError) as e: protocol.config.auth_type = None protocol.create_session() @@ -907,29 +863,17 @@ def test_create_session_failure(self): self.assertEqual(e.exception.args[0], "Auth type 'NTLM' requires credentials") def test_noauth_session(self): - self.assertEqual( - Protocol(config=Configuration( - service_endpoint='https://example.com/Foo.asmx', credentials=None, - auth_type=NOAUTH, version=Version(Build(15, 1)), retry_policy=FailFast() - )).create_session().auth, - None - ) + self.assertEqual(self.get_test_protocol(auth_type=NOAUTH, credentials=None).create_session().auth, None) def test_oauth2_session(self): # Only test failure cases until we have working OAuth2 credentials with self.assertRaises(InvalidClientIdError): - Protocol(config=Configuration( - service_endpoint='https://example.com/Foo.asmx', - credentials=OAuth2Credentials('XXX', 'YYY', 'ZZZZ'), - auth_type=OAUTH2, version=Version(Build(15, 1)), retry_policy=FailFast() - )).create_session() + self.get_test_protocol( + auth_type=OAUTH2, credentials=OAuth2Credentials('XXX', 'YYY', 'ZZZZ') + ).create_session() - protocol = Protocol(config=Configuration( - service_endpoint='https://example.com/Foo.asmx', - credentials=OAuth2AuthorizationCodeCredentials( - client_id='WWW', client_secret='XXX', authorization_code='YYY', access_token={'access_token': 'ZZZ'} - ), - auth_type=OAUTH2, version=Version(Build(15, 1)), retry_policy=FailFast() + protocol = self.get_test_protocol(auth_type=OAUTH2, credentials=OAuth2AuthorizationCodeCredentials( + client_id='WWW', client_secret='XXX', authorization_code='YYY', access_token={'access_token': 'ZZZ'} )) session = protocol.create_session() protocol.refresh_credentials(session) From b89739843ef266f95bf0119e8976ea419551076a Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 01:35:20 +0100 Subject: [PATCH 157/509] Replace the entire object, to avoid test crosstalk --- tests/test_folder.py | 4 ++-- tests/test_items/test_calendaritems.py | 2 +- tests/test_items/test_messages.py | 4 ++-- tests/test_protocol.py | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_folder.py b/tests/test_folder.py index 84d905ab..4a3c8718 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -15,7 +15,7 @@ from exchangelib.properties import Mailbox, InvalidField, EffectiveRights, PermissionSet, CalendarPermission, UserId from exchangelib.queryset import Q from exchangelib.services import GetFolder, DeleteFolder, FindFolder, EmptyFolder -from exchangelib.version import EXCHANGE_2007 +from exchangelib.version import Version, EXCHANGE_2007 from .common import EWSTest, get_random_string, get_random_int, get_random_bool, get_random_datetime, get_random_bytes,\ get_random_byte @@ -164,7 +164,7 @@ def test_find_folders_multiple_roots(self): def test_find_folders_compat(self): account = self.get_account() coll = FolderCollection(account=account, folders=[account.root]) - account.version.build = EXCHANGE_2007 # Need to set it after the last auto-config of version + account.version = Version(EXCHANGE_2007) # Need to set it after the last auto-config of version with self.assertRaises(NotImplementedError) as e: list(coll.find_folders(offset=1)) self.assertEqual( diff --git a/tests/test_items/test_calendaritems.py b/tests/test_items/test_calendaritems.py index 8f340895..cdcc2c1d 100644 --- a/tests/test_items/test_calendaritems.py +++ b/tests/test_items/test_calendaritems.py @@ -558,7 +558,7 @@ def test_tz_field_for_field_name(self): '_end_timezone', ) account = self.get_account() - account.version.build = EXCHANGE_2007 + account.version = Version(EXCHANGE_2007) self.assertEqual( CalendarItem(account=account).tz_field_for_field_name('start').name, '_meeting_timezone', diff --git a/tests/test_items/test_messages.py b/tests/test_items/test_messages.py index c1707c12..ec2da59c 100644 --- a/tests/test_items/test_messages.py +++ b/tests/test_items/test_messages.py @@ -6,7 +6,7 @@ from exchangelib.folders import Inbox from exchangelib.items import Message, ReplyToItem from exchangelib.queryset import DoesNotExist -from exchangelib.version import EXCHANGE_2010_SP2 +from exchangelib.version import Version, EXCHANGE_2010_SP2 from ..common import get_random_string from .test_basics import CommonItemTest @@ -45,7 +45,7 @@ def test_send_pre_2013(self): item.account = self.get_account() item.folder = item.account.inbox item.attach(FileAttachment(name='file_attachment', content=b'file_attachment')) - item.account.version.build = EXCHANGE_2010_SP2 + item.account.version = Version(EXCHANGE_2010_SP2) item.send(save_copy=False) self.assertIsNone(item.id) self.assertIsNone(item.changekey) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 46e9be17..d8965a0c 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -377,11 +377,11 @@ def test_resolvenames(self): e.exception.args[0], "Chunk size 500 is too high. ResolveNames supports returning at most 100 candidates for a lookup" ) - tmp = self.account.protocol.version.build - self.account.protocol.version.build = EXCHANGE_2010_SP1 + tmp = self.account.protocol.version + self.account.protocol.config.version = Version(EXCHANGE_2010_SP1) with self.assertRaises(NotImplementedError) as e: self.account.protocol.resolve_names(names=['xxx@example.com'], shape='IdOnly') - self.account.protocol.version.build = tmp + self.account.protocol.config.version = tmp self.assertEqual( e.exception.args[0], "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later" From 8dfcea1112e67ef61ffc153a532e2703d1856ebe Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 09:24:30 +0100 Subject: [PATCH 158/509] response is guaranteed to have a URL here --- exchangelib/autodiscover/discovery.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/exchangelib/autodiscover/discovery.py b/exchangelib/autodiscover/discovery.py index 4f377c7f..22b887cd 100644 --- a/exchangelib/autodiscover/discovery.py +++ b/exchangelib/autodiscover/discovery.py @@ -150,9 +150,6 @@ def resolver(self): return resolver def _build_response(self, ad_response): - ews_url = ad_response.protocol.ews_url - if not ews_url: - raise AutoDiscoverFailed("Response is missing an 'ews_url' value") if not ad_response.autodiscover_smtp_address: # Autodiscover does not always return an email address. In that case, the requesting email should be used ad_response.user.autodiscover_smtp_address = self.email @@ -160,7 +157,7 @@ def _build_response(self, ad_response): # We may not want to use the auth_package hints in the AD response. It could be incorrect and we can just guess. protocol = Protocol( config=Configuration( - service_endpoint=ews_url, + service_endpoint=ad_response.protocol.ews_url, credentials=self.credentials, version=ad_response.version, auth_type=self.auth_type, From 44cc874c34bc8937d1c1084fddc5833dc0b37abd Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 09:28:09 +0100 Subject: [PATCH 159/509] Add coverage of URL validator --- tests/test_autodiscover.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index bc4666fe..b1ee8c41 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -651,3 +651,34 @@ def test_shelve_filename_getuser_failure(self, m): # Test that shelve_filename can handle a failing getuser() major, minor = sys.version_info[:2] self.assertEqual(shelve_filename(), f'exchangelib.2.cache.exchangelib.py{major}{minor}') + + @requests_mock.mock(real_http=False) + def test_redirect_url_is_valid(self, m): + # This method is private but hard to get to otherwise + a = Autodiscovery('john@example.com') + + # Already visited + a._urls_visited.append('https://example.com') + self.assertFalse(a._redirect_url_is_valid('https://example.com')) + a._urls_visited.clear() + + # Max redirects exceeded + a._redirect_count = 10 + self.assertFalse(a._redirect_url_is_valid('https://example.com')) + a._redirect_count = 0 + + # Must be secure + self.assertFalse(a._redirect_url_is_valid('http://example.com')) + + # Bad response from URL + m.head('https://example.com', status_code=501) + self.assertFalse(a._redirect_url_is_valid(f'https://example.com')) + + # Does not resolve with DNS + url = f'https://{get_random_string(8)}.com' + m.head(url, status_code=200) + self.assertFalse(a._redirect_url_is_valid(url)) + + # OK response from URL. We need something that resolves with DNS + m.head(self.account.protocol.config.service_endpoint, status_code=200) + self.assertTrue(a._redirect_url_is_valid(self.account.protocol.config.service_endpoint)) \ No newline at end of file From 808fcd81126a26125c0a4c33b027d4f028090a3b Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 12:19:09 +0100 Subject: [PATCH 160/509] Remove retry_policy and auth_type from Autodiscovery. It's not needed there and caller can set these values on the returned protocol instead. While here, make Protocol auth_type guessing lazy. --- exchangelib/account.py | 16 +++++++---- exchangelib/autodiscover/discovery.py | 22 +++++---------- exchangelib/autodiscover/properties.py | 2 ++ exchangelib/protocol.py | 29 ++++++++++---------- exchangelib/version.py | 3 ++ tests/test_autodiscover.py | 38 +++++--------------------- tests/test_protocol.py | 12 +++----- 7 files changed, 48 insertions(+), 74 deletions(-) diff --git a/exchangelib/account.py b/exchangelib/account.py index 191eaacb..ae7ef6c9 100644 --- a/exchangelib/account.py +++ b/exchangelib/account.py @@ -108,14 +108,19 @@ def __init__(self, primary_smtp_address, fullname=None, access_type=None, autodi raise InvalidTypeError('config', config, Configuration) if autodiscover: if config: - retry_policy, auth_type = config.retry_policy, config.auth_type + auth_type, retry_policy, version = config.auth_type, config.retry_policy, config.version if not credentials: credentials = config.credentials else: - retry_policy, auth_type = None, None + auth_type, retry_policy, version = None, None, None self.ad_response, self.protocol = Autodiscovery( - email=primary_smtp_address, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy + email=primary_smtp_address, credentials=credentials ).discover() + # Let's not use the auth_package hint from the AD response. It could be incorrect and we can just guess. + self.protocol.config.auth_type = auth_type + self.protocol.config.retry_policy = retry_policy + if not self.protocol.config.version: + self.protocol.config.version = version primary_smtp_address = self.ad_response.autodiscover_smtp_address else: if not config: @@ -130,8 +135,9 @@ def __init__(self, primary_smtp_address, fullname=None, access_type=None, autodi self.affinity_cookie = None # We may need to override the default server version on a per-account basis because Microsoft may report one - # server version up-front but delegate account requests to an older backend server. - self.version = self.protocol.version + # server version up-front but delegate account requests to an older backend server. Create a new instance to + # avoid changing the protocol version. + self.version = self.protocol.version.copy() log.debug('Added account: %s', self) @property diff --git a/exchangelib/autodiscover/discovery.py b/exchangelib/autodiscover/discovery.py index 22b887cd..85858c5c 100644 --- a/exchangelib/autodiscover/discovery.py +++ b/exchangelib/autodiscover/discovery.py @@ -19,9 +19,12 @@ def discover(email, credentials=None, auth_type=None, retry_policy=None): - return Autodiscovery( - email=email, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy + ad_response, protocol = Autodiscovery( + email=email, credentials=credentials ).discover() + protocol.config.auth_typ = auth_type + protocol.config.retry_policy = retry_policy + return ad_response, protocol class SrvRecord: @@ -74,19 +77,15 @@ class Autodiscovery: 'timeout': AutodiscoverProtocol.TIMEOUT, } - def __init__(self, email, credentials=None, auth_type=None, retry_policy=None): + def __init__(self, email, credentials=None): """ :param email: The email address to autodiscover :param credentials: Credentials with authorization to make autodiscover lookups for this Account (Default value = None) - :param auth_type: (Default value = None) - :param retry_policy: (Default value = None) """ self.email = email self.credentials = credentials - self.auth_type = auth_type # The auth type that the resulting protocol instance should have - self.retry_policy = retry_policy # The retry policy that the resulting protocol instance should have self._urls_visited = [] # Collects HTTP and Autodiscover redirects self._redirect_count = 0 self._emails_visited = [] # Collects Autodiscover email redirects @@ -154,24 +153,17 @@ def _build_response(self, ad_response): # Autodiscover does not always return an email address. In that case, the requesting email should be used ad_response.user.autodiscover_smtp_address = self.email - # We may not want to use the auth_package hints in the AD response. It could be incorrect and we can just guess. protocol = Protocol( config=Configuration( service_endpoint=ad_response.protocol.ews_url, credentials=self.credentials, version=ad_response.version, - auth_type=self.auth_type, - retry_policy=self.retry_policy, + auth_type=ad_response.protocol.auth_type, ) ) return ad_response, protocol def _quick(self, protocol): - # Reset auth type and retry policy if we requested non-default values - if self.auth_type: - protocol.config.auth_type = self.auth_type - if self.retry_policy: - protocol.config.retry_policy = self.retry_policy try: r = self._get_authenticated_response(protocol=protocol) except TransportError as e: diff --git a/exchangelib/autodiscover/properties.py b/exchangelib/autodiscover/properties.py index 4fad1bfd..239e9a01 100644 --- a/exchangelib/autodiscover/properties.py +++ b/exchangelib/autodiscover/properties.py @@ -144,6 +144,8 @@ def auth_type(self): # Translates 'auth_package' value to our own 'auth_type' enum vals if not self.auth_required: return NOAUTH + if not self.auth_package: + return None return { # Missing in list are DIGEST and OAUTH2 'basic': BASIC, diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index e462be19..16848692 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -59,6 +59,8 @@ class BaseProtocol: def __init__(self, config): self.config = config + self._api_version_hint = None + self._session_pool_size = 0 self._session_pool_maxsize = config.max_connections or self.SESSION_POOLSIZE @@ -74,6 +76,9 @@ def service_endpoint(self): @property def auth_type(self): + # Autodetect authentication type if necessary + if self.config.auth_type is None: + self.config.auth_type = self.get_auth_type() return self.config.auth_type @property @@ -96,6 +101,15 @@ def retry_policy(self): def server(self): return self.config.server + def get_auth_type(self): + # Autodetect authentication type. We also set version hint here. + name = str(self.credentials) if self.credentials and str(self.credentials) else 'DUMMY' + auth_type, api_version_hint = get_service_authtype( + service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS, name=name + ) + self._api_version_hint = api_version_hint + return auth_type + def __getstate__(self): # The session pool and lock cannot be pickled state = self.__dict__.copy() @@ -238,8 +252,6 @@ def refresh_credentials(self, session): return self.renew_session(session) def create_session(self): - if self.auth_type is None: - raise ValueError('Cannot create session without knowing the auth type') if self.credentials is None: if self.auth_type in CREDENTIALS_REQUIRED: raise ValueError(f'Auth type {self.auth_type!r} requires credentials') @@ -435,20 +447,7 @@ class Protocol(BaseProtocol, metaclass=CachingProtocol): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._api_version_hint = None self._version_lock = Lock() - # Autodetect authentication type if necessary - if self.config.auth_type is None: - self.config.auth_type = self.get_auth_type() - - def get_auth_type(self): - # Autodetect authentication type. We also set version hint here. - name = str(self.credentials) if self.credentials and str(self.credentials) else 'DUMMY' - auth_type, api_version_hint = get_service_authtype( - service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS, name=name - ) - self._api_version_hint = api_version_hint - return auth_type @property def version(self): diff --git a/exchangelib/version.py b/exchangelib/version.py index dda9cf04..8ac2141d 100644 --- a/exchangelib/version.py +++ b/exchangelib/version.py @@ -266,6 +266,9 @@ def from_soap_header(cls, requested_api_version, header): api_version_from_server, api_version_from_server) return cls(build=build, api_version=api_version_from_server) + def copy(self): + return self.__class__(build=self.build, api_version=self.api_version) + def __eq__(self, other): if self.api_version != other.api_version: return False diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 6666baf8..074f948c 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -20,6 +20,7 @@ from exchangelib.protocol import FaultTolerance, FailFast from exchangelib.transport import NTLM, NOAUTH from exchangelib.util import get_domain, ParseError +from exchangelib.version import Version, EXCHANGE_2013 from .common import EWSTest, get_random_string @@ -55,28 +56,6 @@ def setUp(self): '''.encode() - self.dummy_ews_response = '''\ - - - - - - - - - - NoError - - - - - - - -'''.encode() @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here def test_magic(self, m): @@ -183,7 +162,6 @@ def test_autodiscover_cache(self, m): discovery = Autodiscovery( email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials, - retry_policy=self.retry_policy, ) # Not cached self.assertNotIn(discovery._cache_key, autodiscover_cache) @@ -253,14 +231,13 @@ def test_autodiscover_from_account(self, m): # Test that autodiscovery via account creation works # Mock the default endpoint that we test in step 1 of autodiscovery m.post(self.dummy_ad_endpoint, status_code=200, content=self.dummy_ad_response) - # Also mock the EWS URL. We try to guess its auth method as part of autodiscovery - m.post(self.dummy_ews_endpoint, status_code=200, content=self.dummy_ews_response) self.assertEqual(len(autodiscover_cache), 0) account = Account( primary_smtp_address=self.account.primary_smtp_address, config=Configuration( credentials=self.account.protocol.credentials, retry_policy=self.retry_policy, + version=Version(build=EXCHANGE_2013), ), autodiscover=True, locale='da_DK', @@ -298,7 +275,6 @@ def test_autodiscover_redirect(self, m): discovery = Autodiscovery( email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials, - retry_policy=self.retry_policy, ) discovery.discover() @@ -668,15 +644,15 @@ def test_redirect_url_is_valid(self, m): # Must be secure self.assertFalse(a._redirect_url_is_valid('http://example.com')) - # Bad response from URL - m.head('https://example.com', status_code=501) - self.assertFalse(a._redirect_url_is_valid(f'https://example.com')) - # Does not resolve with DNS url = f'https://{get_random_string(8)}.com' m.head(url, status_code=200) self.assertFalse(a._redirect_url_is_valid(url)) - # OK response from URL. We need something that resolves with DNS + # Bad response from URL on valid hostname + m.head(self.account.protocol.config.service_endpoint, status_code=501) + self.assertTrue(a._redirect_url_is_valid(self.account.protocol.config.service_endpoint)) + + # OK response from URL on valid hostname m.head(self.account.protocol.config.service_endpoint, status_code=200) self.assertTrue(a._redirect_url_is_valid(self.account.protocol.config.service_endpoint)) \ No newline at end of file diff --git a/tests/test_protocol.py b/tests/test_protocol.py index d8965a0c..52df8897 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -831,31 +831,27 @@ def test_version_guess(self, m): @patch('requests.sessions.Session.post', side_effect=ConnectionResetError('XXX')) def test_get_service_authtype(self, m): with self.assertRaises(TransportError) as e: - self.get_test_protocol(auth_type=None) + _ = self.get_test_protocol(auth_type=None).auth_type self.assertEqual(e.exception.args[0], 'XXX') with self.assertRaises(RateLimitError) as e: - self.get_test_protocol(auth_type=None, retry_policy=FaultTolerance(max_wait=0.5)) + _ = self.get_test_protocol(auth_type=None, retry_policy=FaultTolerance(max_wait=0.5)).auth_type self.assertEqual(e.exception.args[0], 'Max timeout reached') @patch('requests.sessions.Session.post', return_value=DummyResponse(status_code=401)) def test_get_service_authtype_401(self, m): with self.assertRaises(TransportError) as e: - self.get_test_protocol(auth_type=None) + _ = self.get_test_protocol(auth_type=None).auth_type self.assertEqual(e.exception.args[0], 'Failed to get auth type from service') @patch('requests.sessions.Session.post', return_value=DummyResponse(status_code=501)) def test_get_service_authtype_501(self, m): with self.assertRaises(TransportError) as e: - self.get_test_protocol(auth_type=None) + _ = self.get_test_protocol(auth_type=None).auth_type self.assertEqual(e.exception.args[0], 'Failed to get auth type from service') def test_create_session_failure(self): protocol = self.get_test_protocol(auth_type=NOAUTH, credentials=None) - with self.assertRaises(ValueError) as e: - protocol.config.auth_type = None - protocol.create_session() - self.assertEqual(e.exception.args[0], 'Cannot create session without knowing the auth type') with self.assertRaises(ValueError) as e: protocol.config.auth_type = NTLM protocol.credentials = None From ac857eafbae918c73a3e7710c2e9c9cea8d072b6 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 12:34:41 +0100 Subject: [PATCH 161/509] Add helper to generate Autodiscover XML --- tests/test_autodiscover.py | 122 ++++++++++--------------------------- 1 file changed, 33 insertions(+), 89 deletions(-) diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 074f948c..7fb350f0 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -39,24 +39,41 @@ def setUp(self): self.domain = get_domain(self.account.primary_smtp_address) self.dummy_ad_endpoint = f'https://{self.domain}/Autodiscover/Autodiscover.xml' self.dummy_ews_endpoint = 'https://expr.example.com/EWS/Exchange.asmx' - self.dummy_ad_response = f'''\ + self.dummy_ad_response = self.settings_xml(self.account.primary_smtp_address, self.dummy_ews_endpoint) + + @staticmethod + def settings_xml(address, ews_url): + return f'''\ - {self.account.primary_smtp_address} + {address} email settings EXPR - {self.dummy_ews_endpoint} + {ews_url} '''.encode() + @staticmethod + def redirect_address_xml(address): + return f'''\ + + + + + redirectAddr + {address} + + +'''.encode() + @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here def test_magic(self, m): # Just test we don't fail when calling repr() and str(). Insert a dummy cache entry for testing @@ -279,23 +296,8 @@ def test_autodiscover_redirect(self, m): discovery.discover() # Make sure we discover a different return address - m.post(self.dummy_ad_endpoint, status_code=200, content=b'''\ - - - - - john@example.com - - - email - settings - - EXPR - https://expr.example.com/EWS/Exchange.asmx - - - -''') + m.post(self.dummy_ad_endpoint, status_code=200, + content=self.settings_xml('john@example.com', 'https://expr.example.com/EWS/Exchange.asmx')) # Also mock the EWS URL. We try to guess its auth method as part of autodiscovery m.post('https://expr.example.com/EWS/Exchange.asmx', status_code=200) ad_response, _ = discovery.discover() @@ -303,40 +305,15 @@ def test_autodiscover_redirect(self, m): # Make sure we discover an address redirect to the same domain. We have to mock the same URL with two different # responses. We do that with a response list. - redirect_addr_content = f'''\ - - - - - redirectAddr - redirect_me@{self.domain} - - -'''.encode() - settings_content = f'''\ - - - - - redirected@{self.domain} - - - email - settings - - EXPR - https://redirected.{self.domain}/EWS/Exchange.asmx - - - -'''.encode() + m.post(self.dummy_ad_endpoint, [ + dict(status_code=200, content=self.redirect_address_xml(f'redirect_me@{self.domain}')), + dict(status_code=200, content=self.settings_xml( + f'redirected@{self.domain}', f'https://redirected.{self.domain}/EWS/Exchange.asmx' + )), + ]) # Also mock the EWS URL. We try to guess its auth method as part of autodiscovery m.post(f'https://redirected.{self.domain}/EWS/Exchange.asmx', status_code=200) - m.post(self.dummy_ad_endpoint, [ - dict(status_code=200, content=redirect_addr_content), - dict(status_code=200, content=settings_content), - ]) ad_response, _ = discovery.discover() self.assertEqual(ad_response.autodiscover_smtp_address, f'redirected@{self.domain}') self.assertEqual(ad_response.protocol.ews_url, f'https://redirected.{self.domain}/EWS/Exchange.asmx') @@ -344,16 +321,7 @@ def test_autodiscover_redirect(self, m): # Test that we catch circular redirects on the same domain with a primed cache. Just mock the endpoint to # return the same redirect response on every request. self.assertEqual(len(autodiscover_cache), 1) - m.post(self.dummy_ad_endpoint, status_code=200, content=f'''\ - - - - - redirectAddr - foo@{self.domain} - - -'''.encode()) + m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_address_xml(f'foo@{self.domain}')) self.assertEqual(len(autodiscover_cache), 1) with self.assertRaises(AutoDiscoverCircularRedirect): discovery.discover() @@ -366,33 +334,9 @@ def test_autodiscover_redirect(self, m): # Test that we can handle being asked to redirect to an address on a different domain # Don't use example.com to redirect - it does not resolve or answer on all ISPs - m.post(self.dummy_ad_endpoint, status_code=200, content=b'''\ - - - - - redirectAddr - john@httpbin.org - - -''') - m.post('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=200, content=b'''\ - - - - - john@redirected.httpbin.org - - - email - settings - - EXPR - https://httpbin.org/EWS/Exchange.asmx - - - -''') + m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_address_xml('john@httpbin.org')) + m.post('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=200, + content=self.settings_xml('john@redirected.httpbin.org', 'https://httpbin.org/EWS/Exchange.asmx')) # Also mock the EWS URL. We try to guess its auth method as part of autodiscovery m.post('https://httpbin.org/EWS/Exchange.asmx', status_code=200) ad_response, _ = discovery.discover() @@ -589,7 +533,7 @@ def test_parse_response(self): ''' with self.assertRaises(ValueError): - Autodiscover.from_bytes(xml).response.protocol.ews_url + _ = Autodiscover.from_bytes(xml).response.protocol.ews_url def test_raise_errors(self): with self.assertRaises(AutoDiscoverFailed) as e: From d461efdb81a50ea137c3ef90ec9de1ddbc90bfd4 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 12:49:14 +0100 Subject: [PATCH 162/509] Remove unnecessary EWS endpoint mocking --- tests/test_autodiscover.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 7fb350f0..23359734 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -174,8 +174,6 @@ def test_autodiscover_direct_gc(self, m): def test_autodiscover_cache(self, m): # Mock the default endpoint that we test in step 1 of autodiscovery m.post(self.dummy_ad_endpoint, status_code=200, content=self.dummy_ad_response) - # Also mock the EWS URL. We try to guess its auth method as part of autodiscovery - m.post(self.dummy_ews_endpoint, status_code=200) discovery = Autodiscovery( email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials, @@ -287,8 +285,6 @@ def test_autodiscover_redirect(self, m): # to send us into the correct code paths. # Mock the default endpoint that we test in step 1 of autodiscovery m.post(self.dummy_ad_endpoint, status_code=200, content=self.dummy_ad_response) - # Also mock the EWS URL. We try to guess its auth method as part of autodiscovery - m.post(self.dummy_ews_endpoint, status_code=200) discovery = Autodiscovery( email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials, @@ -298,8 +294,6 @@ def test_autodiscover_redirect(self, m): # Make sure we discover a different return address m.post(self.dummy_ad_endpoint, status_code=200, content=self.settings_xml('john@example.com', 'https://expr.example.com/EWS/Exchange.asmx')) - # Also mock the EWS URL. We try to guess its auth method as part of autodiscovery - m.post('https://expr.example.com/EWS/Exchange.asmx', status_code=200) ad_response, _ = discovery.discover() self.assertEqual(ad_response.autodiscover_smtp_address, 'john@example.com') @@ -337,8 +331,6 @@ def test_autodiscover_redirect(self, m): m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_address_xml('john@httpbin.org')) m.post('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=200, content=self.settings_xml('john@redirected.httpbin.org', 'https://httpbin.org/EWS/Exchange.asmx')) - # Also mock the EWS URL. We try to guess its auth method as part of autodiscovery - m.post('https://httpbin.org/EWS/Exchange.asmx', status_code=200) ad_response, _ = discovery.discover() self.assertEqual(ad_response.autodiscover_smtp_address, 'john@redirected.httpbin.org') self.assertEqual(ad_response.protocol.ews_url, 'https://httpbin.org/EWS/Exchange.asmx') From 6453ca8b050cb8de58177ab6c9ad3a96baafadce Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 13:21:10 +0100 Subject: [PATCH 163/509] Test redirect via HTTP 301 --- exchangelib/autodiscover/discovery.py | 1 + tests/test_autodiscover.py | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/exchangelib/autodiscover/discovery.py b/exchangelib/autodiscover/discovery.py index 85858c5c..833e777a 100644 --- a/exchangelib/autodiscover/discovery.py +++ b/exchangelib/autodiscover/discovery.py @@ -112,6 +112,7 @@ def discover(self): ad_response = self._step_1(hostname=domain) else: # This will cache the result + log.debug('Cache miss for key %s', cache_key) ad_response = self._step_1(hostname=domain) log.debug('Released autodiscover_cache_lock') diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 23359734..f406fd95 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -305,9 +305,6 @@ def test_autodiscover_redirect(self, m): f'redirected@{self.domain}', f'https://redirected.{self.domain}/EWS/Exchange.asmx' )), ]) - # Also mock the EWS URL. We try to guess its auth method as part of autodiscovery - m.post(f'https://redirected.{self.domain}/EWS/Exchange.asmx', status_code=200) - ad_response, _ = discovery.discover() self.assertEqual(ad_response.autodiscover_smtp_address, f'redirected@{self.domain}') self.assertEqual(ad_response.protocol.ews_url, f'https://redirected.{self.domain}/EWS/Exchange.asmx') @@ -335,6 +332,18 @@ def test_autodiscover_redirect(self, m): self.assertEqual(ad_response.autodiscover_smtp_address, 'john@redirected.httpbin.org') self.assertEqual(ad_response.protocol.ews_url, 'https://httpbin.org/EWS/Exchange.asmx') + # Test redirect via HTTP 301 + clear_cache() + discovery.email = self.account.primary_smtp_address + m.post(self.dummy_ad_endpoint, status_code=301, + headers=dict(location='https://httpbin.org/OtherPath/Autodiscover.xml')) + m.post('https://httpbin.org/OtherPath/Autodiscover.xml', status_code=200, + content=self.settings_xml('john@otherpath.httpbin.org', 'https://xxx.httpbin.org/EWS/Exchange.asmx')) + m.head('https://httpbin.org/OtherPath/Autodiscover.xml', status_code=200) + ad_response, _ = discovery.discover() + self.assertEqual(ad_response.autodiscover_smtp_address, 'john@otherpath.httpbin.org') + self.assertEqual(ad_response.protocol.ews_url, 'https://xxx.httpbin.org/EWS/Exchange.asmx') + def test_get_srv_records(self): from exchangelib.autodiscover.discovery import SrvRecord ad = Autodiscovery('foo@example.com') From 331bf1999cb27fa3cb0f5d466a08474a020c24cf Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 14:48:34 +0100 Subject: [PATCH 164/509] Log the reason when we redirect to a certain step for multiple reasons --- exchangelib/autodiscover/discovery.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/exchangelib/autodiscover/discovery.py b/exchangelib/autodiscover/discovery.py index 833e777a..64cd43e8 100644 --- a/exchangelib/autodiscover/discovery.py +++ b/exchangelib/autodiscover/discovery.py @@ -440,8 +440,11 @@ def _step_3(self, hostname): is_valid_response, ad = self._attempt_response(url=redirect_url) if is_valid_response: return self._step_5(ad=ad) + log.debug('Got invalid response') return self._step_4(hostname=hostname) + log.debug('Got invalid redirect URL') return self._step_4(hostname=hostname) + log.debug('Got no redirect URL') return self._step_4(hostname=hostname) def _step_4(self, hostname): @@ -475,7 +478,9 @@ def _step_4(self, hostname): is_valid_response, ad = self._attempt_response(url=redirect_url) if is_valid_response: return self._step_5(ad=ad) + log.debug('Got invalid response') return self._step_6() + log.debug('Got invalid redirect URL') return self._step_6() def _step_5(self, ad): @@ -509,8 +514,9 @@ def _step_5(self, ad): is_valid_response, ad = self._attempt_response(url=ad_response.redirect_url) if is_valid_response: return self._step_5(ad=ad) + log.debug('Got invalid response') return self._step_6() - log.debug('Invalid redirect URL: %s', ad_response.redirect_url) + log.debug('Invalid redirect URL') return self._step_6() # This could be an email redirect. Let outer layer handle this return ad_response From 4d6900f5008468166dd39ede42b7ee877435820c Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 14:53:20 +0100 Subject: [PATCH 165/509] Test uncommon paths through th autodiscover protocol --- tests/test_autodiscover.py | 162 +++++++++++++++++++++++++++++++++++-- 1 file changed, 156 insertions(+), 6 deletions(-) diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index f406fd95..2f6589b7 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -10,10 +10,10 @@ from exchangelib.account import Account from exchangelib.credentials import Credentials, DELEGATE -import exchangelib.autodiscover.discovery from exchangelib.autodiscover import close_connections, clear_cache, autodiscover_cache, AutodiscoverProtocol, \ - Autodiscovery, AutodiscoverCache + Autodiscovery, AutodiscoverCache, discover from exchangelib.autodiscover.cache import shelve_filename +from exchangelib.autodiscover.discovery import SrvRecord, _select_srv_host from exchangelib.autodiscover.properties import Autodiscover, Response, Account as ADAccount, ErrorResponse, Error from exchangelib.configuration import Configuration from exchangelib.errors import ErrorNonExistentMailbox, AutoDiscoverCircularRedirect, AutoDiscoverFailed @@ -74,6 +74,19 @@ def redirect_address_xml(address): '''.encode() + @staticmethod + def redirect_url_xml(ews_url): + return f'''\ + + + + + redirectUrl + {ews_url} + + +'''.encode() + @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here def test_magic(self, m): # Just test we don't fail when calling repr() and str(). Insert a dummy cache entry for testing @@ -102,7 +115,7 @@ def test_response_properties(self): def test_autodiscover_empty_cache(self): # A live test of the entire process with an empty cache - ad_response, protocol = exchangelib.autodiscover.discovery.discover( + ad_response, protocol = discover( email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials, retry_policy=self.retry_policy, @@ -128,7 +141,7 @@ def test_autodiscover_failure(self): retry_policy=self.retry_policy, )) with self.assertRaises(ErrorNonExistentMailbox): - exchangelib.autodiscover.discovery.discover( + discover( email='XXX.' + self.account.primary_smtp_address, credentials=self.account.protocol.credentials, retry_policy=self.retry_policy, @@ -344,8 +357,146 @@ def test_autodiscover_redirect(self, m): self.assertEqual(ad_response.autodiscover_smtp_address, 'john@otherpath.httpbin.org') self.assertEqual(ad_response.protocol.ews_url, 'https://xxx.httpbin.org/EWS/Exchange.asmx') + @requests_mock.mock(real_http=False) + def test_autodiscover_path_1_2_5(self, m): + # Test steps 1 -> 2 -> 5 + clear_cache() + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + + m.post(self.dummy_ad_endpoint, status_code=501) + m.post(f'https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=200, + content=self.settings_xml(f'xxxd@{self.domain}', f'https://xxx.{self.domain}/EWS/Exchange.asmx')) + ad_response, _ = d.discover() + self.assertEqual(ad_response.autodiscover_smtp_address, f'xxxd@{self.domain}') + self.assertEqual(ad_response.protocol.ews_url, f'https://xxx.{self.domain}/EWS/Exchange.asmx') + + @requests_mock.mock(real_http=False) + def test_autodiscover_path_1_2_3_invalid301_4(self, m): + # Test steps 1 -> 2 -> 3 -> invalid 301 URL -> 4 + clear_cache() + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + + m.post(self.dummy_ad_endpoint, status_code=501) + m.post(f'https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=501) + m.get(f'http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=301, + headers=dict(location=f'XXX')) + with self.assertRaises(AutoDiscoverFailed): + # Fails in step 4 with invalid SRV entry + ad_response, _ = d.discover() + + @requests_mock.mock(real_http=False) + def test_autodiscover_path_1_2_3_no301_4(self, m): + # Test steps 1 -> 2 -> 3 -> no 301 response -> 4 + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + + m.post(self.dummy_ad_endpoint, status_code=501) + m.post(f'https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=501) + m.get(f'http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=200) + + with self.assertRaises(AutoDiscoverFailed): + # Fails in step 4 with invalid SRV entry + ad_response, _ = d.discover() + + @requests_mock.mock(real_http=False) + def test_autodiscover_path_1_2_3_4_valid_srv_invalid_response(self, m): + # Test steps 1 -> 2 -> 3 -> 4 -> invalid response from SRV URL + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + m.post(self.dummy_ad_endpoint, status_code=501) + m.post(f'https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=501) + m.get(f'http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=200) + m.head('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=501) + m.post('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=501) + + tmp = d._get_srv_records + d._get_srv_records = Mock(return_value=[SrvRecord(1, 1, 443, 'httpbin.org')]) + try: + with self.assertRaises(AutoDiscoverFailed): + # Fails in step 4 with invalid response + ad_response, _ = d.discover() + finally: + d._get_srv_records = tmp + + @requests_mock.mock(real_http=False) + def test_autodiscover_path_1_2_3_4_valid_srv_valid_response(self, m): + # Test steps 1 -> 2 -> 3 -> 4 -> 5 + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + m.post(self.dummy_ad_endpoint, status_code=501) + m.post(f'https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=501) + m.get(f'http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=200) + m.head('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=200) + m.post('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=200, + content=self.settings_xml('john@redirected.httpbin.org', 'https://httpbin.org/EWS/Exchange.asmx')) + + tmp = d._get_srv_records + d._get_srv_records = Mock(return_value=[SrvRecord(1, 1, 443, 'httpbin.org')]) + try: + ad_response, _ = d.discover() + self.assertEqual(ad_response.autodiscover_smtp_address, 'john@redirected.httpbin.org') + self.assertEqual(ad_response.protocol.ews_url, f'https://httpbin.org/EWS/Exchange.asmx') + finally: + d._get_srv_records = tmp + + @requests_mock.mock(real_http=False) + def test_autodiscover_path_1_2_3_4_invalid_srv(self, m): + # Test steps 1 -> 2 -> 3 -> 4 -> invalid SRV URL + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + m.post(self.dummy_ad_endpoint, status_code=501) + m.post(f'https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=501) + m.get(f'http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=200) + m.head('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=501) + m.post('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=501) + + tmp = d._get_srv_records + d._get_srv_records = Mock(return_value=[SrvRecord(1, 1, 443, f'{get_random_string(8)}.com')]) + try: + with self.assertRaises(AutoDiscoverFailed): + # Fails in step 4 with invalid response + ad_response, _ = d.discover() + finally: + d._get_srv_records = tmp + + @requests_mock.mock(real_http=False) + def test_autodiscover_path_1_5_invalid_redirect_url(self, m): + # Test steps 1 -> -> 5 -> Invalid redirect URL + clear_cache() + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + + m.post(self.dummy_ad_endpoint, status_code=200, + content=self.redirect_url_xml(f'https://{get_random_string(8)}.com/EWS/Exchange.asmx')) + with self.assertRaises(AutoDiscoverFailed): + # Fails in step 5 with invalid redirect URL + ad_response, _ = d.discover() + + @requests_mock.mock(real_http=False) + def test_autodiscover_path_1_5_valid_redirect_url_invalid_response(self, m): + # Test steps 1 -> -> 5 -> Invalid response from redirect URL + clear_cache() + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + + m.post(self.dummy_ad_endpoint, status_code=200, + content=self.redirect_url_xml('https://httpbin.org/Autodiscover/Autodiscover.xml')) + m.head('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=501) + m.post('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=501) + with self.assertRaises(AutoDiscoverFailed): + # Fails in step 5 with invalid response + ad_response, _ = d.discover() + + @requests_mock.mock(real_http=False) + def test_autodiscover_path_1_5_valid_redirect_url_valid_response(self, m): + # Test steps 1 -> -> 5 -> Valid response from redirect URL -> 5 + clear_cache() + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + + m.post(self.dummy_ad_endpoint, status_code=200, + content=self.redirect_url_xml('https://httpbin.org/Autodiscover/Autodiscover.xml')) + m.head('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=200) + m.post('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=200, + content=self.settings_xml('john@redirected.httpbin.org', 'https://httpbin.org/EWS/Exchange.asmx')) + ad_response, _ = d.discover() + self.assertEqual(ad_response.autodiscover_smtp_address, 'john@redirected.httpbin.org') + self.assertEqual(ad_response.protocol.ews_url, f'https://httpbin.org/EWS/Exchange.asmx') + def test_get_srv_records(self): - from exchangelib.autodiscover.discovery import SrvRecord ad = Autodiscovery('foo@example.com') # Unknown domain self.assertEqual(ad._get_srv_records('example.XXXXX'), []) @@ -390,7 +541,6 @@ def to_text(): del ad.resolver def test_select_srv_host(self): - from exchangelib.autodiscover.discovery import _select_srv_host, SrvRecord with self.assertRaises(ValueError): # Empty list _select_srv_host([]) From 35cdcda6068badcd5d8be83a008c9818b4dcb846 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 15:06:54 +0100 Subject: [PATCH 166/509] Add new DNS exception seen in tests --- exchangelib/autodiscover/discovery.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/exchangelib/autodiscover/discovery.py b/exchangelib/autodiscover/discovery.py index 64cd43e8..606acf38 100644 --- a/exchangelib/autodiscover/discovery.py +++ b/exchangelib/autodiscover/discovery.py @@ -3,6 +3,7 @@ from urllib.parse import urlparse import dns.resolver +import dns.name from cached_property import threaded_cached_property from .cache import autodiscover_cache @@ -344,7 +345,7 @@ def _is_valid_hostname(self, hostname): log.debug('Checking if %s can be looked up in DNS', hostname) try: self.resolver.resolve(hostname) - except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): + except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.name.EmptyLabel): return False return True From 315f800671413e09431902eeb5212c3f9b8293ba Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 15:07:18 +0100 Subject: [PATCH 167/509] Improve safteyof check --- exchangelib/protocol.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 16848692..2e33ce45 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -159,7 +159,7 @@ def increase_poolsize(self): """Increases the session pool size. We increase by one session per call.""" # Create a single session and insert it into the pool. We need to protect this with a lock while we are changing # the pool size variable, to avoid race conditions. We must not exceed the pool size limit. - if self._session_pool_size == self._session_pool_maxsize: + if self._session_pool_size >= self._session_pool_maxsize: raise SessionPoolMaxSizeReached('Session pool size cannot be increased further') with self._session_pool_lock: if self._session_pool_size >= self._session_pool_maxsize: From 174508c4641e890f40e9986e4aab99ded639c8f7 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 16:06:52 +0100 Subject: [PATCH 168/509] Add get_random_hostname() and self.get_test_protocol() helpers --- tests/common.py | 13 +++++---- tests/test_autodiscover.py | 59 ++++++++++++++++---------------------- 2 files changed, 31 insertions(+), 41 deletions(-) diff --git a/tests/common.py b/tests/common.py index 815634bc..d135aef4 100644 --- a/tests/common.py +++ b/tests/common.py @@ -311,14 +311,15 @@ def get_random_bytes(length): return bytes(get_random_int(max_val=255) for _ in range(length)) -def get_random_url(): - path_len = random.randint(1, 16) +def get_random_hostname(): domain_len = random.randint(1, 30) tld_len = random.randint(2, 4) - return 'http://%s.%s/%s.html' % tuple(map( - lambda i: get_random_string(i, spaces=False, special=False).lower(), - (domain_len, tld_len, path_len) - )) + return '%s.%s' % tuple(get_random_string(i, spaces=False, special=False).lower() for i in (domain_len, tld_len)) + + +def get_random_url(): + path_len = random.randint(1, 16) + return 'http://%s/%s.html' % (get_random_hostname(), get_random_string(path_len, spaces=False, special=False)) def get_random_email(): diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 2f6589b7..e3e0eda5 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -21,7 +21,7 @@ from exchangelib.transport import NTLM, NOAUTH from exchangelib.util import get_domain, ParseError from exchangelib.version import Version, EXCHANGE_2013 -from .common import EWSTest, get_random_string +from .common import EWSTest, get_random_string, get_random_hostname class AutodiscoverTest(EWSTest): @@ -87,16 +87,20 @@ def redirect_url_xml(ews_url): '''.encode() + @staticmethod + def get_test_protocol(**kwargs): + return AutodiscoverProtocol(config=Configuration( + service_endpoint=kwargs.get('service_endpoint', 'https://example.com/Autodiscover/Autodiscover.xml'), + credentials=kwargs.get('credentials', Credentials(get_random_string(8), get_random_string(8))), + auth_type=kwargs.get('auth_type', NTLM), + retry_policy=kwargs.get('retry_policy', FailFast()), + )) + @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here def test_magic(self, m): # Just test we don't fail when calling repr() and str(). Insert a dummy cache entry for testing - c = Credentials(get_random_string(8), get_random_string(8)) - autodiscover_cache[('example.com', c)] = AutodiscoverProtocol(config=Configuration( - service_endpoint='https://example.com/Autodiscover/Autodiscover.xml', - credentials=c, - auth_type=NTLM, - retry_policy=FailFast(), - )) + p = self.get_test_protocol() + autodiscover_cache[(p.config.server, p.config.credentials)] = p self.assertEqual(len(autodiscover_cache), 1) str(autodiscover_cache) repr(autodiscover_cache) @@ -134,12 +138,11 @@ def test_autodiscover_failure(self): # Autodiscovery may take a long time. Prime the cache with the autodiscover server from the config file ad_endpoint = f"https://{self.settings['autodiscover_server']}/Autodiscover/Autodiscover.xml" cache_key = (self.domain, self.account.protocol.credentials) - autodiscover_cache[cache_key] = AutodiscoverProtocol(config=Configuration( + autodiscover_cache[cache_key] = self.get_test_protocol( service_endpoint=ad_endpoint, credentials=self.account.protocol.credentials, - auth_type=NTLM, retry_policy=self.retry_policy, - )) + ) with self.assertRaises(ErrorNonExistentMailbox): discover( email='XXX.' + self.account.primary_smtp_address, @@ -160,28 +163,18 @@ def test_failed_login_via_account(self): @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here def test_close_autodiscover_connections(self, m): # A live test that we can close TCP connections - c = Credentials(get_random_string(8), get_random_string(8)) - autodiscover_cache[('example.com', c)] = AutodiscoverProtocol(config=Configuration( - service_endpoint='https://example.com/Autodiscover/Autodiscover.xml', - credentials=c, - auth_type=NTLM, - retry_policy=FailFast(), - )) + p = self.get_test_protocol() + autodiscover_cache[(p.config.server, p.config.credentials)] = p self.assertEqual(len(autodiscover_cache), 1) close_connections() @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here def test_autodiscover_direct_gc(self, m): # Test garbage collection of the autodiscover cache - c = Credentials(get_random_string(8), get_random_string(8)) - autodiscover_cache[('example.com', c)] = AutodiscoverProtocol(config=Configuration( - service_endpoint='https://example.com/Autodiscover/Autodiscover.xml', - credentials=c, - auth_type=NTLM, - retry_policy=FailFast(), - )) + p = self.get_test_protocol() + autodiscover_cache[(p.config.server, p.config.credentials)] = p self.assertEqual(len(autodiscover_cache), 1) - autodiscover_cache.__del__() + autodiscover_cache.__del__() # Don't use del() because that would remove the global object @requests_mock.mock(real_http=False) def test_autodiscover_cache(self, m): @@ -203,12 +196,8 @@ def test_autodiscover_cache(self, m): True ), autodiscover_cache) # Poison the cache with a failing autodiscover endpoint. discover() must handle this and rebuild the cache - autodiscover_cache[discovery._cache_key] = AutodiscoverProtocol(config=Configuration( - service_endpoint='https://example.com/Autodiscover/Autodiscover.xml', - credentials=Credentials(get_random_string(8), get_random_string(8)), - auth_type=NTLM, - retry_policy=FailFast(), - )) + p = self.get_test_protocol() + autodiscover_cache[discovery._cache_key] = p m.post('https://example.com/Autodiscover/Autodiscover.xml', status_code=404) discovery.discover() self.assertIn(discovery._cache_key, autodiscover_cache) @@ -447,7 +436,7 @@ def test_autodiscover_path_1_2_3_4_invalid_srv(self, m): m.post('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=501) tmp = d._get_srv_records - d._get_srv_records = Mock(return_value=[SrvRecord(1, 1, 443, f'{get_random_string(8)}.com')]) + d._get_srv_records = Mock(return_value=[SrvRecord(1, 1, 443, get_random_hostname())]) try: with self.assertRaises(AutoDiscoverFailed): # Fails in step 4 with invalid response @@ -462,7 +451,7 @@ def test_autodiscover_path_1_5_invalid_redirect_url(self, m): d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) m.post(self.dummy_ad_endpoint, status_code=200, - content=self.redirect_url_xml(f'https://{get_random_string(8)}.com/EWS/Exchange.asmx')) + content=self.redirect_url_xml(f'https://{get_random_hostname()}/EWS/Exchange.asmx')) with self.assertRaises(AutoDiscoverFailed): # Fails in step 5 with invalid redirect URL ad_response, _ = d.discover() @@ -740,7 +729,7 @@ def test_redirect_url_is_valid(self, m): self.assertFalse(a._redirect_url_is_valid('http://example.com')) # Does not resolve with DNS - url = f'https://{get_random_string(8)}.com' + url = f'https://{get_random_hostname()}' m.head(url, status_code=200) self.assertFalse(a._redirect_url_is_valid(url)) From def1926ab8e08ffe43092ad8b85b3a26d8ca94cc Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 16:10:58 +0100 Subject: [PATCH 169/509] Ensure valid service endpoint formatting --- tests/test_protocol.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 52df8897..d8c46bf1 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -31,7 +31,8 @@ from exchangelib.version import Build, Version, EXCHANGE_2010_SP1 from exchangelib.winzone import CLDR_TO_MS_TIMEZONE_MAP -from .common import EWSTest, get_random_datetime_range, get_random_string, RANDOM_DATE_MIN, RANDOM_DATE_MAX +from .common import EWSTest, get_random_datetime_range, get_random_string, get_random_hostname, RANDOM_DATE_MIN, \ + RANDOM_DATE_MAX class ProtocolTest(EWSTest): @@ -39,7 +40,7 @@ class ProtocolTest(EWSTest): def get_test_protocol(**kwargs): return Protocol(config=Configuration( server=kwargs.get('server'), - service_endpoint=kwargs.get('service_endpoint', f'https://{get_random_string(4)}.example.com/Foo.asmx'), + service_endpoint=kwargs.get('service_endpoint', f'https://{get_random_hostname()}/Foo.asmx'), credentials=kwargs.get('credentials', Credentials(get_random_string(8), get_random_string(8))), auth_type=kwargs.get('auth_type', NTLM), version=kwargs.get('version', Version(Build(15, 1))), From d2c5f859c81ee98f2e5e419c3e31bab9ee82243c Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 17:20:03 +0100 Subject: [PATCH 170/509] Reduce duplication of hostname --- tests/test_autodiscover.py | 94 +++++++++++++++++++++----------------- tests/test_protocol.py | 14 +++--- tests/test_util.py | 24 +++++----- 3 files changed, 72 insertions(+), 60 deletions(-) diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index e3e0eda5..66dca08c 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -327,48 +327,53 @@ def test_autodiscover_redirect(self, m): # Test that we can handle being asked to redirect to an address on a different domain # Don't use example.com to redirect - it does not resolve or answer on all ISPs - m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_address_xml('john@httpbin.org')) - m.post('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=200, - content=self.settings_xml('john@redirected.httpbin.org', 'https://httpbin.org/EWS/Exchange.asmx')) + ews_hostname = 'httpbin.org' + redirect_email = f'john@redirected.{ews_hostname}' + ews_url = f'https://{ews_hostname}/EWS/Exchange.asmx' + m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_address_xml(f'john@{ews_hostname}')) + m.post(f'https://{ews_hostname}/Autodiscover/Autodiscover.xml', status_code=200, + content=self.settings_xml(redirect_email, ews_url)) ad_response, _ = discovery.discover() - self.assertEqual(ad_response.autodiscover_smtp_address, 'john@redirected.httpbin.org') - self.assertEqual(ad_response.protocol.ews_url, 'https://httpbin.org/EWS/Exchange.asmx') + self.assertEqual(ad_response.autodiscover_smtp_address, redirect_email) + self.assertEqual(ad_response.protocol.ews_url, ews_url) # Test redirect via HTTP 301 clear_cache() + redirect_url = f'https://{ews_hostname}/OtherPath/Autodiscover.xml' + redirect_email = f'john@otherpath.{ews_hostname}' + ews_url = f'https://xxx.{ews_hostname}/EWS/Exchange.asmx' discovery.email = self.account.primary_smtp_address - m.post(self.dummy_ad_endpoint, status_code=301, - headers=dict(location='https://httpbin.org/OtherPath/Autodiscover.xml')) - m.post('https://httpbin.org/OtherPath/Autodiscover.xml', status_code=200, - content=self.settings_xml('john@otherpath.httpbin.org', 'https://xxx.httpbin.org/EWS/Exchange.asmx')) - m.head('https://httpbin.org/OtherPath/Autodiscover.xml', status_code=200) + m.post(self.dummy_ad_endpoint, status_code=301, headers=dict(location=redirect_url)) + m.post(redirect_url, status_code=200, content=self.settings_xml(redirect_email, ews_url)) + m.head(redirect_url, status_code=200) ad_response, _ = discovery.discover() - self.assertEqual(ad_response.autodiscover_smtp_address, 'john@otherpath.httpbin.org') - self.assertEqual(ad_response.protocol.ews_url, 'https://xxx.httpbin.org/EWS/Exchange.asmx') + self.assertEqual(ad_response.autodiscover_smtp_address, redirect_email) + self.assertEqual(ad_response.protocol.ews_url, ews_url) @requests_mock.mock(real_http=False) def test_autodiscover_path_1_2_5(self, m): # Test steps 1 -> 2 -> 5 clear_cache() d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) - + ews_url = f'https://xxx.{self.domain}/EWS/Exchange.asmx' + email = f'xxxd@{self.domain}' m.post(self.dummy_ad_endpoint, status_code=501) m.post(f'https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=200, - content=self.settings_xml(f'xxxd@{self.domain}', f'https://xxx.{self.domain}/EWS/Exchange.asmx')) + content=self.settings_xml(email, ews_url)) ad_response, _ = d.discover() - self.assertEqual(ad_response.autodiscover_smtp_address, f'xxxd@{self.domain}') - self.assertEqual(ad_response.protocol.ews_url, f'https://xxx.{self.domain}/EWS/Exchange.asmx') + self.assertEqual(ad_response.autodiscover_smtp_address, email) + self.assertEqual(ad_response.protocol.ews_url, ews_url) @requests_mock.mock(real_http=False) def test_autodiscover_path_1_2_3_invalid301_4(self, m): # Test steps 1 -> 2 -> 3 -> invalid 301 URL -> 4 clear_cache() d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) - m.post(self.dummy_ad_endpoint, status_code=501) m.post(f'https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=501) m.get(f'http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=301, - headers=dict(location=f'XXX')) + headers=dict(location='XXX')) + with self.assertRaises(AutoDiscoverFailed): # Fails in step 4 with invalid SRV entry ad_response, _ = d.discover() @@ -377,7 +382,6 @@ def test_autodiscover_path_1_2_3_invalid301_4(self, m): def test_autodiscover_path_1_2_3_no301_4(self, m): # Test steps 1 -> 2 -> 3 -> no 301 response -> 4 d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) - m.post(self.dummy_ad_endpoint, status_code=501) m.post(f'https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=501) m.get(f'http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=200) @@ -390,14 +394,15 @@ def test_autodiscover_path_1_2_3_no301_4(self, m): def test_autodiscover_path_1_2_3_4_valid_srv_invalid_response(self, m): # Test steps 1 -> 2 -> 3 -> 4 -> invalid response from SRV URL d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + redirect_srv = 'httpbin.org' m.post(self.dummy_ad_endpoint, status_code=501) m.post(f'https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=501) m.get(f'http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=200) - m.head('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=501) - m.post('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=501) + m.head(f'https://{redirect_srv}/Autodiscover/Autodiscover.xml', status_code=501) + m.post(f'https://{redirect_srv}/Autodiscover/Autodiscover.xml', status_code=501) tmp = d._get_srv_records - d._get_srv_records = Mock(return_value=[SrvRecord(1, 1, 443, 'httpbin.org')]) + d._get_srv_records = Mock(return_value=[SrvRecord(1, 1, 443, redirect_srv)]) try: with self.assertRaises(AutoDiscoverFailed): # Fails in step 4 with invalid response @@ -409,19 +414,22 @@ def test_autodiscover_path_1_2_3_4_valid_srv_invalid_response(self, m): def test_autodiscover_path_1_2_3_4_valid_srv_valid_response(self, m): # Test steps 1 -> 2 -> 3 -> 4 -> 5 d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + redirect_srv = 'httpbin.org' + ews_url = f'https://{redirect_srv}/EWS/Exchange.asmx' + redirect_email = f'john@redirected.{redirect_srv}' m.post(self.dummy_ad_endpoint, status_code=501) m.post(f'https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=501) m.get(f'http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=200) - m.head('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=200) - m.post('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=200, - content=self.settings_xml('john@redirected.httpbin.org', 'https://httpbin.org/EWS/Exchange.asmx')) + m.head(f'https://{redirect_srv}/Autodiscover/Autodiscover.xml', status_code=200) + m.post(f'https://{redirect_srv}/Autodiscover/Autodiscover.xml', status_code=200, + content=self.settings_xml(redirect_email, ews_url)) tmp = d._get_srv_records - d._get_srv_records = Mock(return_value=[SrvRecord(1, 1, 443, 'httpbin.org')]) + d._get_srv_records = Mock(return_value=[SrvRecord(1, 1, 443, redirect_srv)]) try: ad_response, _ = d.discover() - self.assertEqual(ad_response.autodiscover_smtp_address, 'john@redirected.httpbin.org') - self.assertEqual(ad_response.protocol.ews_url, f'https://httpbin.org/EWS/Exchange.asmx') + self.assertEqual(ad_response.autodiscover_smtp_address, redirect_email) + self.assertEqual(ad_response.protocol.ews_url, ews_url) finally: d._get_srv_records = tmp @@ -432,8 +440,6 @@ def test_autodiscover_path_1_2_3_4_invalid_srv(self, m): m.post(self.dummy_ad_endpoint, status_code=501) m.post(f'https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=501) m.get(f'http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=200) - m.head('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=501) - m.post('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=501) tmp = d._get_srv_records d._get_srv_records = Mock(return_value=[SrvRecord(1, 1, 443, get_random_hostname())]) @@ -449,9 +455,9 @@ def test_autodiscover_path_1_5_invalid_redirect_url(self, m): # Test steps 1 -> -> 5 -> Invalid redirect URL clear_cache() d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) - m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_url_xml(f'https://{get_random_hostname()}/EWS/Exchange.asmx')) + with self.assertRaises(AutoDiscoverFailed): # Fails in step 5 with invalid redirect URL ad_response, _ = d.discover() @@ -461,11 +467,11 @@ def test_autodiscover_path_1_5_valid_redirect_url_invalid_response(self, m): # Test steps 1 -> -> 5 -> Invalid response from redirect URL clear_cache() d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + redirect_url = 'https://httpbin.org/Autodiscover/Autodiscover.xml' + m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_url_xml(redirect_url)) + m.head(redirect_url, status_code=501) + m.post(redirect_url, status_code=501) - m.post(self.dummy_ad_endpoint, status_code=200, - content=self.redirect_url_xml('https://httpbin.org/Autodiscover/Autodiscover.xml')) - m.head('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=501) - m.post('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=501) with self.assertRaises(AutoDiscoverFailed): # Fails in step 5 with invalid response ad_response, _ = d.discover() @@ -475,15 +481,17 @@ def test_autodiscover_path_1_5_valid_redirect_url_valid_response(self, m): # Test steps 1 -> -> 5 -> Valid response from redirect URL -> 5 clear_cache() d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + redirect_hostname = 'httpbin.org' + redirect_url = f'https://{redirect_hostname}/Autodiscover/Autodiscover.xml' + ews_url = f'https://{redirect_hostname}/EWS/Exchange.asmx' + email = f'john@redirected.{redirect_hostname}' + m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_url_xml(redirect_url)) + m.head(redirect_url, status_code=200) + m.post(redirect_url, status_code=200, content=self.settings_xml(email, ews_url)) - m.post(self.dummy_ad_endpoint, status_code=200, - content=self.redirect_url_xml('https://httpbin.org/Autodiscover/Autodiscover.xml')) - m.head('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=200) - m.post('https://httpbin.org/Autodiscover/Autodiscover.xml', status_code=200, - content=self.settings_xml('john@redirected.httpbin.org', 'https://httpbin.org/EWS/Exchange.asmx')) ad_response, _ = d.discover() - self.assertEqual(ad_response.autodiscover_smtp_address, 'john@redirected.httpbin.org') - self.assertEqual(ad_response.protocol.ews_url, f'https://httpbin.org/EWS/Exchange.asmx') + self.assertEqual(ad_response.autodiscover_smtp_address, email) + self.assertEqual(ad_response.protocol.ews_url, ews_url) def test_get_srv_records(self): ad = Autodiscovery('foo@example.com') @@ -739,4 +747,4 @@ def test_redirect_url_is_valid(self, m): # OK response from URL on valid hostname m.head(self.account.protocol.config.service_endpoint, status_code=200) - self.assertTrue(a._redirect_url_is_valid(self.account.protocol.config.service_endpoint)) \ No newline at end of file + self.assertTrue(a._redirect_url_is_valid(self.account.protocol.config.service_endpoint)) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index d8c46bf1..d9330c7a 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -130,30 +130,32 @@ def test_protocol_instance_caching(self, m): def test_close(self): # Don't use example.com here - it does not resolve or answer on all ISPs proc = psutil.Process() + hostname = 'httpbin.org' ip_addresses = {info[4][0] for info in socket.getaddrinfo( - 'httpbin.org', 80, socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_IP + hostname, 80, socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_IP )} def conn_count(): return len([p for p in proc.connections() if p.raddr[0] in ip_addresses]) self.assertGreater(len(ip_addresses), 0) - protocol = self.get_test_protocol(service_endpoint='http://httpbin.org', auth_type=NOAUTH, max_connections=3) + url = f'http://{hostname}' + protocol = self.get_test_protocol(service_endpoint=url, auth_type=NOAUTH, max_connections=3) # Merely getting a session should not create connections session = protocol.get_session() self.assertEqual(conn_count(), 0) # Open one URL - we have 1 connection - session.get('http://httpbin.org') + session.get(url) self.assertEqual(conn_count(), 1) # Open the same URL - we should still have 1 connection - session.get('http://httpbin.org') + session.get(url) self.assertEqual(conn_count(), 1) # Open some more connections s2 = protocol.get_session() - s2.get('http://httpbin.org') + s2.get(url) s3 = protocol.get_session() - s3.get('http://httpbin.org') + s3.get(url) self.assertEqual(conn_count(), 3) # Releasing the sessions does not close the connections diff --git a/tests/test_util.py b/tests/test_util.py index a3ea0a16..1661b974 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -82,26 +82,28 @@ def test_peek(self): @requests_mock.mock() def test_get_redirect_url(self, m): - m.get('https://httpbin.org/redirect-to', status_code=302, headers={'location': 'https://example.com/'}) - r = requests.get('https://httpbin.org/redirect-to?url=https://example.com/', allow_redirects=False) + hostname = 'httpbin.org' + url = f'https://{hostname}/redirect-to' + m.get(url, status_code=302, headers={'location': 'https://example.com/'}) + r = requests.get(f'{url}?url=https://example.com/', allow_redirects=False) self.assertEqual(get_redirect_url(r), 'https://example.com/') - m.get('https://httpbin.org/redirect-to', status_code=302, headers={'location': 'http://example.com/'}) - r = requests.get('https://httpbin.org/redirect-to?url=http://example.com/', allow_redirects=False) + m.get(url, status_code=302, headers={'location': 'http://example.com/'}) + r = requests.get(f'{url}?url=http://example.com/', allow_redirects=False) self.assertEqual(get_redirect_url(r), 'http://example.com/') - m.get('https://httpbin.org/redirect-to', status_code=302, headers={'location': '/example'}) - r = requests.get('https://httpbin.org/redirect-to?url=/example', allow_redirects=False) - self.assertEqual(get_redirect_url(r), 'https://httpbin.org/example') + m.get(url, status_code=302, headers={'location': '/example'}) + r = requests.get(f'{url}?url=/example', allow_redirects=False) + self.assertEqual(get_redirect_url(r), f'https://{hostname}/example') - m.get('https://httpbin.org/redirect-to', status_code=302, headers={'location': 'https://example.com'}) + m.get(url, status_code=302, headers={'location': 'https://example.com'}) with self.assertRaises(RelativeRedirect): - r = requests.get('https://httpbin.org/redirect-to?url=https://example.com', allow_redirects=False) + r = requests.get(f'{url}?url=https://example.com', allow_redirects=False) get_redirect_url(r, require_relative=True) - m.get('https://httpbin.org/redirect-to', status_code=302, headers={'location': '/example'}) + m.get(url, status_code=302, headers={'location': '/example'}) with self.assertRaises(RelativeRedirect): - r = requests.get('https://httpbin.org/redirect-to?url=/example', allow_redirects=False) + r = requests.get(f'{url}?url=/example', allow_redirects=False) get_redirect_url(r, allow_relative=False) def test_to_xml(self): From aa26ad0b98c598ec1493f1a0f61d445fc5ed84ed Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 17:30:48 +0100 Subject: [PATCH 171/509] Let GenericItemTest actually test generic Item and Folder classes --- tests/test_items/test_basics.py | 13 ++++---- tests/test_items/test_generic.py | 49 +++++++++++++------------------ tests/test_items/test_messages.py | 25 ++++++++++++++++ 3 files changed, 53 insertions(+), 34 deletions(-) diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index 9796a652..00aa05a9 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -35,9 +35,12 @@ def setUpClass(cls): def setUp(self): super().setUp() - self.test_folder = getattr(self.account, self.TEST_FOLDER) - self.assertEqual(type(self.test_folder), self.FOLDER_CLASS) - self.assertEqual(self.test_folder.DISTINGUISHED_FOLDER_ID, self.TEST_FOLDER) + if self.TEST_FOLDER: + self.test_folder = getattr(self.account, self.TEST_FOLDER) + self.assertEqual(type(self.test_folder), self.FOLDER_CLASS) + self.assertEqual(self.test_folder.DISTINGUISHED_FOLDER_ID, self.TEST_FOLDER) + else: + self.test_folder = None def tearDown(self): # Delete all test items and delivery receipts @@ -693,7 +696,7 @@ def test_item(self): self.assertEqual(old, new, (f.name, old, new)) try: - self.ITEM_CLASS.register('extern_id', ExternId) + item.__class__.register('extern_id', ExternId) # Test extern_id = None, which deletes the extended property entirely extern_id = None item.extern_id = extern_id @@ -705,4 +708,4 @@ def test_item(self): item = self.get_item_by_id(wipe2_ids[0]) self.assertEqual(item.extern_id, extern_id) finally: - self.ITEM_CLASS.deregister('extern_id') + item.__class__.deregister('extern_id') diff --git a/tests/test_items/test_generic.py b/tests/test_items/test_generic.py index 3f7236e9..3aa1b6b8 100644 --- a/tests/test_items/test_generic.py +++ b/tests/test_items/test_generic.py @@ -8,8 +8,8 @@ from exchangelib.errors import ErrorItemNotFound, ErrorInternalServerError from exchangelib.extended_properties import ExtendedProperty, ExternId from exchangelib.fields import ExtendedPropertyField, CharField -from exchangelib.folders import Inbox, FolderCollection, Root -from exchangelib.items import CalendarItem, Message +from exchangelib.folders import Folder, FolderCollection, Root +from exchangelib.items import CalendarItem, Message, Item from exchangelib.queryset import QuerySet from exchangelib.restriction import Restriction, Q from exchangelib.services import CreateItem, UpdateItem, DeleteItem, FindItem, FindPeople @@ -22,9 +22,12 @@ class GenericItemTest(CommonItemTest): """Tests that don't need to be run for every single folder type""" - TEST_FOLDER = 'inbox' - FOLDER_CLASS = Inbox - ITEM_CLASS = Message + FOLDER_CLASS = Folder + ITEM_CLASS = Item + + def setUp(self): + super().setUp() + self.test_folder = self.get_test_folder(self.account.inbox).save() def test_validation(self): item = self.get_test_item() @@ -140,30 +143,6 @@ def test_invalid_direct_args(self): with self.assertRaises(ValueError): item.delete(suppress_read_receipts='XXX') - def test_invalid_kwargs_on_send(self): - # Only Message class has the send() method - item = self.get_test_item() - item.account = None - with self.assertRaises(ValueError): - item.send() # Must have account on send - item = self.get_test_item() - item.save() - with self.assertRaises(TypeError) as e: - item.send(copy_to_folder='XXX', save_copy=True) # Invalid folder - self.assertEqual( - e.exception.args[0], - "'saved_item_folder' 'XXX' must be of type (, " - ")" - ) - item_id, changekey = item.id, item.changekey - item.delete() - item.id, item.changekey = item_id, changekey - with self.assertRaises(ErrorItemNotFound): - item.send() # Item disappeared - item = self.get_test_item() - with self.assertRaises(AttributeError): - item.send(copy_to_folder=self.account.trash, save_copy=False) # Inconsistent args - def test_invalid_createitem_args(self): with self.assertRaises(ValueError) as e: CreateItem(account=self.account).call( @@ -409,6 +388,9 @@ def test_order_by(self): try: self.ITEM_CLASS.register('extern_id', ExternId) + if self.ITEM_CLASS == Item: + # An Item saved in Inbox becomes a Message + Message.register('extern_id', ExternId) # Test order_by() on ExtendedProperty test_items = [] for i in range(4): @@ -429,11 +411,17 @@ def test_order_by(self): ) finally: self.ITEM_CLASS.deregister('extern_id') + if self.ITEM_CLASS == Item: + # An Item saved in Inbox becomes a Message + Message.deregister('extern_id') self.bulk_delete(qs) # Test sorting on multiple fields try: self.ITEM_CLASS.register('extern_id', ExternId) + if self.ITEM_CLASS == Item: + # An Item saved in Inbox becomes a Message + Message.register('extern_id', ExternId) test_items = [] for i in range(2): for j in range(2): @@ -475,6 +463,9 @@ def test_order_by(self): ) finally: self.ITEM_CLASS.deregister('extern_id') + if self.ITEM_CLASS == Item: + # An Item saved in Inbox becomes a Message + Message.deregister('extern_id') def test_order_by_with_empty_values(self): # Test order_by() when some values are empty diff --git a/tests/test_items/test_messages.py b/tests/test_items/test_messages.py index ec2da59c..7a9abaf9 100644 --- a/tests/test_items/test_messages.py +++ b/tests/test_items/test_messages.py @@ -3,6 +3,7 @@ import time from exchangelib.attachments import FileAttachment +from exchangelib.errors import ErrorItemNotFound from exchangelib.folders import Inbox from exchangelib.items import Message, ReplyToItem from exchangelib.queryset import DoesNotExist @@ -210,3 +211,27 @@ def test_mime_content(self): categories=self.categories, ).save() self.assertEqual(self.test_folder.get(subject=subject).body, body) + + def test_invalid_kwargs_on_send(self): + # Only Message class has the send() method + item = self.get_test_item() + item.account = None + with self.assertRaises(ValueError): + item.send() # Must have account on send + item = self.get_test_item() + item.save() + with self.assertRaises(TypeError) as e: + item.send(copy_to_folder='XXX', save_copy=True) # Invalid folder + self.assertEqual( + e.exception.args[0], + "'saved_item_folder' 'XXX' must be of type (, " + ")" + ) + item_id, changekey = item.id, item.changekey + item.delete() + item.id, item.changekey = item_id, changekey + with self.assertRaises(ErrorItemNotFound): + item.send() # Item disappeared + item = self.get_test_item() + with self.assertRaises(AttributeError): + item.send(copy_to_folder=self.account.trash, save_copy=False) # Inconsistent args From 7fef11c5463b2d05cd13f3ca8d9a8fa6cdd9a291 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 17:31:44 +0100 Subject: [PATCH 172/509] Skip testing fields that were already tested on the generic item --- tests/test_items/test_basics.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index 00aa05a9..524adb27 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -13,7 +13,7 @@ from exchangelib.fields import TextField, BodyField, FieldPath, CultureField, IdField, ChoiceField, AttachmentField,\ BooleanField from exchangelib.indexed_properties import SingleFieldIndexedElement, MultiFieldIndexedElement -from exchangelib.items import CalendarItem, Contact, Task, DistributionList, BaseItem +from exchangelib.items import CalendarItem, Contact, Task, DistributionList, BaseItem, Item from exchangelib.properties import Mailbox, Attendee from exchangelib.queryset import Q from exchangelib.util import value_to_xml_text @@ -340,6 +340,10 @@ def test_filter_on_list_fields(self): continue if issubclass(f.value_cls, SingleFieldIndexedElement): continue + # This test is exceptionally slow. Only test list fields that are not also found on the base Item class + if self.ITEM_CLASS != Item: + if f.name in Item.FIELDS: + continue fields.append(f) if not fields: self.skipTest('No matching list fields on this model') From 8a4c5d3197370b816f1c77c4694ecda6e18ae646 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 18:02:19 +0100 Subject: [PATCH 173/509] Support AttachmentId in to_item_id() and remove redundancy --- exchangelib/attachments.py | 6 ++++++ exchangelib/services/common.py | 30 +++++++++++++++--------------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/exchangelib/attachments.py b/exchangelib/attachments.py index 7828b648..274f8335 100644 --- a/exchangelib/attachments.py +++ b/exchangelib/attachments.py @@ -26,6 +26,12 @@ class AttachmentId(EWSElement): root_id = IdField(field_uri=ROOT_ID_ATTR) root_changekey = IdField(field_uri=ROOT_CHANGEKEY_ATTR) + def __init__(self, *args, **kwargs): + if not kwargs: + # Allow to set attributes without keyword + kwargs = dict(zip(self._slots_keys, args)) + super().__init__(**kwargs) + class Attachment(EWSElement, metaclass=EWSMeta): """Base class for FileAttachment and ItemAttachment.""" diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index a1bce1d7..8ea0b86c 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -843,7 +843,7 @@ def _get_next_offset(paging_infos): def to_item_id(item, item_cls): # Coerce a tuple, dict or object to an 'item_cls' instance. Used to create [Parent][Item|Folder]Id instances from a # variety of input. - if isinstance(item, BaseItemId): + if isinstance(item, (BaseItemId, AttachmentId)): # Allow any BaseItemId subclass to pass unaltered return item if isinstance(item, (BaseFolder, BaseItem)): @@ -851,7 +851,7 @@ def to_item_id(item, item_cls): return item.to_id() except ValueError: return item - if isinstance(item, (tuple, list)): + if isinstance(item, (str, tuple, list)): return item_cls(*item) return item_cls(item.id, item.changekey) @@ -873,26 +873,26 @@ def shape_element(tag, shape, additional_fields, version): return shape_elem +def _ids_element(items, item_cls, version, tag): + item_ids = create_element(tag) + for item in items: + if isinstance(item, Exception): + # If the input for a service is a QuerySet, it can be difficult to remove exceptions before now + continue + set_xml_value(item_ids, to_item_id(item, item_cls), version=version) + return item_ids + + def folder_ids_element(folders, version, tag='m:FolderIds'): - folder_ids = create_element(tag) - for folder in folders: - set_xml_value(folder_ids, to_item_id(folder, FolderId), version=version) - return folder_ids + return _ids_element(folders, FolderId, version, tag) def item_ids_element(items, version, tag='m:ItemIds'): - item_ids = create_element(tag) - for item in items: - set_xml_value(item_ids, to_item_id(item, ItemId), version=version) - return item_ids + return _ids_element(items, ItemId, version, tag) def attachment_ids_element(items, version, tag='m:AttachmentIds'): - attachment_ids = create_element(tag) - for item in items: - attachment_id = item if isinstance(item, AttachmentId) else AttachmentId(id=item) - set_xml_value(attachment_ids, attachment_id, version=version) - return attachment_ids + return _ids_element(items, AttachmentId, version, tag) def parse_folder_elem(elem, folder, account): From d1865f885d05be6992e4f4239219e621bc764b42 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 21:30:07 +0100 Subject: [PATCH 174/509] Fix logic error - test for expected, not > 0 --- tests/test_items/test_basics.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index 524adb27..35739f9e 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -291,21 +291,21 @@ def _run_filter_tests(self, qs, f, filter_kwargs, val): with self.subTest(f=f, kw=kw): retries = 0 matches = qs.filter(**kw).count() + # __in with an empty list returns an empty result + expected = 0 if f.is_list and not val and list(kw)[0].endswith('__in') else 1 if f.is_complex: # Complex fields sometimes fail a search using generated data. In production, # they almost always work anyway. Give it one more try after 10 seconds; it seems EWS does # some sort of indexing that needs to catch up. - if not matches and isinstance(f, BodyField): - # The body field is particularly nasty in this area. Give up - continue for _ in range(5): - if matches: + if matches == expected: + break + if isinstance(f, BodyField): + # The body field is particularly nasty in this area. Give up break retries += 1 time.sleep(retries*retries) # Exponential sleep matches = qs.filter(**kw).count() - # __in with an empty list returns an empty result - expected = 0 if f.is_list and not val and list(kw)[0].endswith('__in') else 1 self.assertEqual(matches, expected, (f.name, val, kw, retries)) def test_filter_on_simple_fields(self): From 2c3fcae4164c09012d812a039301e7d96bfc91d0 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 21:31:11 +0100 Subject: [PATCH 175/509] Filter exceptions when chunking - _ids_element is called in get_payload which is too late --- exchangelib/services/common.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 8ea0b86c..e40ce56a 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -219,7 +219,9 @@ def _chunked_get_elements(self, payload_func, items, **kwargs): :param kwargs: Same as arguments for .call(), except for the 'items' argument :return: Same as ._get_elements() """ - for i, chunk in enumerate(chunkify(items, self.chunk_size), start=1): + # If the input for a service is a QuerySet, it can be difficult to remove exceptions before now + filtered_items = filter(lambda i: not isinstance(i, Exception), items) + for i, chunk in enumerate(chunkify(filtered_items, self.chunk_size), start=1): log.debug('Processing chunk %s containing %s items', i, len(chunk)) yield from self._get_elements(payload=payload_func(chunk, **kwargs)) @@ -876,9 +878,6 @@ def shape_element(tag, shape, additional_fields, version): def _ids_element(items, item_cls, version, tag): item_ids = create_element(tag) for item in items: - if isinstance(item, Exception): - # If the input for a service is a QuerySet, it can be difficult to remove exceptions before now - continue set_xml_value(item_ids, to_item_id(item, item_cls), version=version) return item_ids From 7cca212e2599a44fa6f39112f96c2dd0334c603f Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 21:31:54 +0100 Subject: [PATCH 176/509] Also delete test folder if needed. Print error but never fail in tearDown --- tests/test_items/test_basics.py | 16 ++++++++++------ tests/test_items/test_generic.py | 4 ---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index 35739f9e..93ba2ebe 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -37,16 +37,20 @@ def setUp(self): super().setUp() if self.TEST_FOLDER: self.test_folder = getattr(self.account, self.TEST_FOLDER) - self.assertEqual(type(self.test_folder), self.FOLDER_CLASS) - self.assertEqual(self.test_folder.DISTINGUISHED_FOLDER_ID, self.TEST_FOLDER) else: - self.test_folder = None + self.test_folder = self.get_test_folder(self.account.inbox).save() def tearDown(self): # Delete all test items and delivery receipts - self.test_folder.filter( - Q(categories__contains=self.categories) | Q(subject__startswith='Delivered: Subject: ') - ).delete() + try: + self.test_folder.filter( + Q(categories__contains=self.categories) | Q(subject__startswith='Delivered: Subject: ') + ).delete() + if not self.TEST_FOLDER: + self.test_folder.delete() + except Exception as e: + print(f'Exception in tearDown of {self}: {e}') + pass super().tearDown() def get_random_insert_kwargs(self): diff --git a/tests/test_items/test_generic.py b/tests/test_items/test_generic.py index 3aa1b6b8..467e9fb7 100644 --- a/tests/test_items/test_generic.py +++ b/tests/test_items/test_generic.py @@ -25,10 +25,6 @@ class GenericItemTest(CommonItemTest): FOLDER_CLASS = Folder ITEM_CLASS = Item - def setUp(self): - super().setUp() - self.test_folder = self.get_test_folder(self.account.inbox).save() - def test_validation(self): item = self.get_test_item() item.clean() From b8f2e4e500200b641df125964bf412036c636e9a Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 22:53:36 +0100 Subject: [PATCH 177/509] Unbreak test for body field search --- tests/test_items/test_basics.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index 93ba2ebe..db6f66be 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -301,12 +301,12 @@ def _run_filter_tests(self, qs, f, filter_kwargs, val): # Complex fields sometimes fail a search using generated data. In production, # they almost always work anyway. Give it one more try after 10 seconds; it seems EWS does # some sort of indexing that needs to catch up. + if isinstance(f, BodyField) and matches != expected: + # The body field is particularly nasty in this area. Give up + continue for _ in range(5): if matches == expected: break - if isinstance(f, BodyField): - # The body field is particularly nasty in this area. Give up - break retries += 1 time.sleep(retries*retries) # Exponential sleep matches = qs.filter(**kw).count() From b73e56299f6a6b12e297a65f5a24137423f1132b Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 23:38:36 +0100 Subject: [PATCH 178/509] Work around race condition between FindFolder and GetFolder --- exchangelib/folders/base.py | 6 ++---- exchangelib/folders/queryset.py | 7 ++++++- exchangelib/folders/roots.py | 7 +++++-- exchangelib/queryset.py | 1 + 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 28c1d67b..104f7059 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -4,9 +4,9 @@ from operator import attrgetter from .collections import FolderCollection, SyncCompleted, PullSubscription, PushSubscription, StreamingSubscription -from .queryset import SingleFolderQuerySet, SHALLOW as SHALLOW_FOLDERS, DEEP as DEEP_FOLDERS +from .queryset import SingleFolderQuerySet, MISSING_FOLDER_ERRORS, SHALLOW as SHALLOW_FOLDERS, DEEP as DEEP_FOLDERS from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorCannotEmptyFolder, ErrorCannotDeleteObject, \ - ErrorDeleteDistinguishedFolder, ErrorNoPublicFolderReplicaAvailable, ErrorItemNotFound, InvalidTypeError + ErrorDeleteDistinguishedFolder, ErrorItemNotFound, InvalidTypeError from ..fields import IntegerField, CharField, FieldPath, EffectiveRightsField, PermissionSetField, EWSElementField, \ Field, IdElementField, InvalidField from ..items import CalendarItem, RegisterMixIn, ITEM_CLASSES, HARD_DELETE, SHALLOW as SHALLOW_ITEMS @@ -18,8 +18,6 @@ log = logging.getLogger(__name__) -MISSING_FOLDER_ERRORS = (ErrorFolderNotFound, ErrorItemNotFound, ErrorNoPublicFolderReplicaAvailable) - class BaseFolder(RegisterMixIn, SearchableMixIn, metaclass=EWSMeta): """Base class for all classes that implement a folder.""" diff --git a/exchangelib/folders/queryset.py b/exchangelib/folders/queryset.py index 76b45476..44c0f348 100644 --- a/exchangelib/folders/queryset.py +++ b/exchangelib/folders/queryset.py @@ -1,7 +1,7 @@ import logging from copy import deepcopy -from ..errors import InvalidTypeError +from ..errors import ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorItemNotFound, InvalidTypeError from ..properties import InvalidField, FolderId from ..queryset import DoesNotExist, MultipleObjectsReturned from ..restriction import Q @@ -12,6 +12,8 @@ DEEP = 'Deep' FOLDER_TRAVERSAL_CHOICES = (SHALLOW, DEEP, SOFT_DELETED) +MISSING_FOLDER_ERRORS = (ErrorFolderNotFound, ErrorItemNotFound, ErrorNoPublicFolderReplicaAvailable) + log = logging.getLogger(__name__) @@ -138,6 +140,9 @@ def _query(self): account=self.folder_collection.account, folders=resolveable_folders ).get_folders(additional_fields=complex_fields) for f, complex_f in zip(resolveable_folders, complex_folders): + if isinstance(f, MISSING_FOLDER_ERRORS): + # We were unlucky. The folder disappeared between the FindFolder and the GetFolder calls + continue if isinstance(complex_f, Exception): yield complex_f continue diff --git a/exchangelib/folders/roots.py b/exchangelib/folders/roots.py index 70f55ade..9c3eb273 100644 --- a/exchangelib/folders/roots.py +++ b/exchangelib/folders/roots.py @@ -1,11 +1,11 @@ import logging from threading import Lock -from .base import BaseFolder, MISSING_FOLDER_ERRORS +from .base import BaseFolder from .collections import FolderCollection from .known_folders import MsgFolderRoot, NON_DELETABLE_FOLDERS, WELLKNOWN_FOLDERS_IN_ROOT, \ WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT -from .queryset import SingleFolderQuerySet, SHALLOW +from .queryset import SingleFolderQuerySet, SHALLOW, MISSING_FOLDER_ERRORS from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorInvalidOperation from ..fields import EffectiveRightsField from ..properties import EWSMeta @@ -176,6 +176,9 @@ def _folders_map(self): if isinstance(f, ErrorAccessDenied): # We may not have FindFolder access, or GetFolder access, either to this folder or at all continue + if isinstance(f, MISSING_FOLDER_ERRORS): + # We were unlucky. The folder disappeared between the FindFolder and the GetFolder calls + continue if isinstance(f, Exception): raise f if f.id in folders_map: diff --git a/exchangelib/queryset.py b/exchangelib/queryset.py index 024b3eb0..e7af75d7 100644 --- a/exchangelib/queryset.py +++ b/exchangelib/queryset.py @@ -218,6 +218,7 @@ def _query(self): if complex_fields_requested: # The FindItem service does not support complex field types. Tell find_items() to return # (id, changekey) tuples, and pass that to fetch(). + # TODO: There's a race condition from we call FindItem until we call GetItem where items can disappear find_kwargs['additional_fields'] = None items = self.folder_collection.account.fetch( ids=self.folder_collection.find_items(self.q, **find_kwargs), From d7ded608aed6defdff1a3bb02b6af643abec19b1 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 12 Jan 2022 23:46:40 +0100 Subject: [PATCH 179/509] Avoid race condition between FindItem and GetItem --- exchangelib/queryset.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/exchangelib/queryset.py b/exchangelib/queryset.py index e7af75d7..d8c3cfbe 100644 --- a/exchangelib/queryset.py +++ b/exchangelib/queryset.py @@ -3,7 +3,7 @@ from copy import deepcopy from itertools import islice -from .errors import MultipleObjectsReturned, DoesNotExist, InvalidEnumValue, InvalidTypeError +from .errors import MultipleObjectsReturned, DoesNotExist, InvalidEnumValue, InvalidTypeError, ErrorItemNotFound from .fields import FieldPath, FieldOrder from .items import CalendarItem, ID_ONLY from .properties import InvalidField @@ -12,6 +12,8 @@ log = logging.getLogger(__name__) +MISSING_ITEM_ERRORS = (ErrorItemNotFound,) + class SearchableMixIn: """Implement a search API for inheritance.""" @@ -218,13 +220,14 @@ def _query(self): if complex_fields_requested: # The FindItem service does not support complex field types. Tell find_items() to return # (id, changekey) tuples, and pass that to fetch(). - # TODO: There's a race condition from we call FindItem until we call GetItem where items can disappear find_kwargs['additional_fields'] = None - items = self.folder_collection.account.fetch( + unfiltered_items = self.folder_collection.account.fetch( ids=self.folder_collection.find_items(self.q, **find_kwargs), only_fields=additional_fields, chunk_size=self.chunk_size, ) + # We may be unlucky that the item disappeared between the FindItem and the GetItem calls + items = filter(lambda i: not isinstance(i, MISSING_ITEM_ERRORS), unfiltered_items) else: if not additional_fields: # If additional_fields is the empty set, we only requested ID and changekey fields. We can then From 0b2548e90d2ea890d9db55d2a963aee7ba26108e Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 13 Jan 2022 00:07:29 +0100 Subject: [PATCH 180/509] Don't exit on failing index field searches --- tests/test_items/test_basics.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index db6f66be..7399f62b 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -298,18 +298,18 @@ def _run_filter_tests(self, qs, f, filter_kwargs, val): # __in with an empty list returns an empty result expected = 0 if f.is_list and not val and list(kw)[0].endswith('__in') else 1 if f.is_complex: - # Complex fields sometimes fail a search using generated data. In production, - # they almost always work anyway. Give it one more try after 10 seconds; it seems EWS does - # some sort of indexing that needs to catch up. - if isinstance(f, BodyField) and matches != expected: - # The body field is particularly nasty in this area. Give up - continue + # Complex fields sometimes fail a search using generated data. In practice, they almost always + # work anyway. Try a couple of times; it seems EWS has a search index that needs to catch up. for _ in range(5): if matches == expected: break retries += 1 time.sleep(retries*retries) # Exponential sleep matches = qs.filter(**kw).count() + if isinstance(f, (BodyField, SingleFieldIndexedElement, MultiFieldIndexedElement)) \ + and matches != expected: + # These fields are particularly flaky when filtering. Give up without failing. + continue self.assertEqual(matches, expected, (f.name, val, kw, retries)) def test_filter_on_simple_fields(self): From 16be611e0e88c9275ed0e0a1e7b6f411560b6e57 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 13 Jan 2022 00:18:05 +0100 Subject: [PATCH 181/509] Don't wait for retry timeout on these fields --- tests/test_items/test_basics.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index 7399f62b..4c166187 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -300,16 +300,16 @@ def _run_filter_tests(self, qs, f, filter_kwargs, val): if f.is_complex: # Complex fields sometimes fail a search using generated data. In practice, they almost always # work anyway. Try a couple of times; it seems EWS has a search index that needs to catch up. + if isinstance(f, (BodyField, SingleFieldIndexedElement, MultiFieldIndexedElement)) \ + and matches != expected: + # These fields are particularly flaky when filtering. Give up early without failing. + continue for _ in range(5): if matches == expected: break retries += 1 time.sleep(retries*retries) # Exponential sleep matches = qs.filter(**kw).count() - if isinstance(f, (BodyField, SingleFieldIndexedElement, MultiFieldIndexedElement)) \ - and matches != expected: - # These fields are particularly flaky when filtering. Give up without failing. - continue self.assertEqual(matches, expected, (f.name, val, kw, retries)) def test_filter_on_simple_fields(self): From 0fd3ef2c85cdb0207f359b81bf97ca41f578d56a Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 13 Jan 2022 00:27:37 +0100 Subject: [PATCH 182/509] Match the correct Field class --- tests/test_items/test_basics.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index 4c166187..53b6abf7 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -11,7 +11,7 @@ ErrorPropertyUpdate, ErrorInvalidPropertySet from exchangelib.extended_properties import ExternId from exchangelib.fields import TextField, BodyField, FieldPath, CultureField, IdField, ChoiceField, AttachmentField,\ - BooleanField + BooleanField, IndexedField from exchangelib.indexed_properties import SingleFieldIndexedElement, MultiFieldIndexedElement from exchangelib.items import CalendarItem, Contact, Task, DistributionList, BaseItem, Item from exchangelib.properties import Mailbox, Attendee @@ -297,19 +297,18 @@ def _run_filter_tests(self, qs, f, filter_kwargs, val): matches = qs.filter(**kw).count() # __in with an empty list returns an empty result expected = 0 if f.is_list and not val and list(kw)[0].endswith('__in') else 1 - if f.is_complex: + if f.is_complex and matches != expected: # Complex fields sometimes fail a search using generated data. In practice, they almost always # work anyway. Try a couple of times; it seems EWS has a search index that needs to catch up. - if isinstance(f, (BodyField, SingleFieldIndexedElement, MultiFieldIndexedElement)) \ - and matches != expected: + if isinstance(f, (BodyField, IndexedField)): # These fields are particularly flaky when filtering. Give up early without failing. continue for _ in range(5): - if matches == expected: - break retries += 1 time.sleep(retries*retries) # Exponential sleep matches = qs.filter(**kw).count() + if matches == expected: + break self.assertEqual(matches, expected, (f.name, val, kw, retries)) def test_filter_on_simple_fields(self): From 463e70559a3f2b1b6b9c359144f5bfef86658c99 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 13 Jan 2022 11:15:19 +0100 Subject: [PATCH 183/509] Ignire missing foler errors here as well --- exchangelib/folders/roots.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/exchangelib/folders/roots.py b/exchangelib/folders/roots.py index 9c3eb273..4f8c8126 100644 --- a/exchangelib/folders/roots.py +++ b/exchangelib/folders/roots.py @@ -303,6 +303,9 @@ def get_children(self, folder): for f in SingleFolderQuerySet(account=self.account, folder=folder).depth( self.DEFAULT_FOLDER_TRAVERSAL_DEPTH ).all(): + if isinstance(f, MISSING_FOLDER_ERRORS): + # We were unlucky. The folder disappeared between the FindFolder and the GetFolder calls + continue if isinstance(f, Exception): raise f children_map[f.id] = f From ba4464e80c407a3216d46ab596f4690feaa0cb21 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 13 Jan 2022 11:45:01 +0100 Subject: [PATCH 184/509] Don't delete folders that other tests may berelying on. Be prepared for disappearing folders. --- tests/test_folder.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_folder.py b/tests/test_folder.py index 4a3c8718..b0e1e72e 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -350,7 +350,11 @@ def test_refresh(self): if field.is_read_only: continue setattr(f, field.name, self.random_val(field)) - f.refresh() + try: + f.refresh() + except ErrorItemNotFound: + # Folder disappeared while we were running this test + continue for field in f.FIELDS: if field.name == 'changekey': # folders may change while we're testing @@ -568,10 +572,6 @@ def test_create_update_empty_delete(self): # Invalid ID f.save() - # Delete all subfolders of inbox - for c in self.account.inbox.children: - c.delete() - with self.assertRaises(ErrorDeleteDistinguishedFolder): self.account.inbox.delete() From f4bb9d725826ff2e10b80faa29e398d91e18e3ff Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 13 Jan 2022 12:05:54 +0100 Subject: [PATCH 185/509] Don't change global state of folders --- tests/test_folder.py | 52 +++++++++++++++++--------------------------- 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/tests/test_folder.py b/tests/test_folder.py index b0e1e72e..9090e2bf 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -27,11 +27,6 @@ def get_random_str_tuple(tuple_length, str_length): class FolderTest(EWSTest): def test_folders(self): - for f in self.account.root.walk(): - if isinstance(f, System): - # No access to system folder, apparently - continue - f.test_access() # Test shortcuts for f, cls in ( (self.account.trash, DeletedItems), @@ -336,33 +331,26 @@ def test_counts(self): def test_refresh(self): # Test that we can refresh folders - for f in self.account.root.walk(): - with self.subTest(f=f): - if isinstance(f, System): - # Can't refresh the 'System' folder for some reason - continue - old_values = {} - for field in f.FIELDS: - old_values[field.name] = getattr(f, field.name) - if field.name in ('account', 'id', 'changekey', 'parent_folder_id'): - # These are needed for a successful refresh() - continue - if field.is_read_only: - continue - setattr(f, field.name, self.random_val(field)) - try: - f.refresh() - except ErrorItemNotFound: - # Folder disappeared while we were running this test - continue - for field in f.FIELDS: - if field.name == 'changekey': - # folders may change while we're testing - continue - if field.is_read_only: - # count values may change during the test - continue - self.assertEqual(getattr(f, field.name), old_values[field.name], (f, field.name)) + f = Folder(parent=self.account.inbox, name=get_random_string(16)).save() + f.refresh() + old_values = {} + for field in f.FIELDS: + old_values[field.name] = getattr(f, field.name) + if field.name in ('account', 'id', 'changekey', 'parent_folder_id'): + # These are needed for a successful refresh() + continue + if field.is_read_only: + continue + setattr(f, field.name, self.random_val(field)) + f.refresh() + for field in f.FIELDS: + if field.name == 'changekey': + # folders may change while we're testing + continue + if field.is_read_only: + # count values may change during the test + continue + self.assertEqual(getattr(f, field.name), old_values[field.name], (f, field.name)) # Test refresh of root orig_name = self.account.root.name From f83a5d19086752b115cd92b3b611891f1ea7d78b Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Fri, 14 Jan 2022 11:24:52 +0100 Subject: [PATCH 186/509] Re-generate docs --- docs/exchangelib/account.html | 143 +- docs/exchangelib/attachments.html | 112 +- docs/exchangelib/autodiscover/cache.html | 8 +- docs/exchangelib/autodiscover/discovery.html | 219 +- docs/exchangelib/autodiscover/index.html | 114 +- docs/exchangelib/autodiscover/properties.html | 157 +- docs/exchangelib/autodiscover/protocol.html | 18 +- docs/exchangelib/configuration.html | 63 +- docs/exchangelib/credentials.html | 32 +- docs/exchangelib/errors.html | 152 +- docs/exchangelib/ewsdatetime.html | 152 +- docs/exchangelib/extended_properties.html | 65 +- docs/exchangelib/fields.html | 2374 ++++++++-------- docs/exchangelib/folders/base.html | 843 ++---- docs/exchangelib/folders/collections.html | 752 ++++-- docs/exchangelib/folders/index.html | 1867 ++++++++----- docs/exchangelib/folders/known_folders.html | 760 +++++- docs/exchangelib/folders/queryset.html | 67 +- docs/exchangelib/folders/roots.html | 194 +- docs/exchangelib/index.html | 1354 ++++------ docs/exchangelib/indexed_properties.html | 14 +- docs/exchangelib/items/base.html | 54 +- docs/exchangelib/items/calendar_item.html | 62 +- docs/exchangelib/items/contact.html | 8 +- docs/exchangelib/items/index.html | 87 +- docs/exchangelib/items/item.html | 47 +- docs/exchangelib/items/message.html | 21 +- docs/exchangelib/properties.html | 1569 ++++++++--- docs/exchangelib/protocol.html | 576 ++-- docs/exchangelib/queryset.html | 522 ++-- docs/exchangelib/recurrence.html | 221 +- docs/exchangelib/restriction.html | 175 +- docs/exchangelib/services/archive_item.html | 64 +- docs/exchangelib/services/common.html | 980 +++---- docs/exchangelib/services/convert_id.html | 70 +- .../services/create_attachment.html | 60 +- docs/exchangelib/services/create_folder.html | 59 +- docs/exchangelib/services/create_item.html | 155 +- .../services/create_user_configuration.html | 18 +- .../services/delete_attachment.html | 47 +- docs/exchangelib/services/delete_folder.html | 31 +- docs/exchangelib/services/delete_item.html | 156 +- .../services/delete_user_configuration.html | 18 +- docs/exchangelib/services/empty_folder.html | 54 +- docs/exchangelib/services/expand_dl.html | 32 +- docs/exchangelib/services/export_items.html | 43 +- docs/exchangelib/services/find_folder.html | 156 +- docs/exchangelib/services/find_item.html | 165 +- docs/exchangelib/services/find_people.html | 208 +- docs/exchangelib/services/get_attachment.html | 64 +- docs/exchangelib/services/get_delegate.html | 65 +- docs/exchangelib/services/get_events.html | 40 +- docs/exchangelib/services/get_folder.html | 43 +- docs/exchangelib/services/get_item.html | 61 +- docs/exchangelib/services/get_mail_tips.html | 32 +- docs/exchangelib/services/get_persona.html | 76 +- docs/exchangelib/services/get_room_lists.html | 26 +- docs/exchangelib/services/get_rooms.html | 54 +- .../services/get_searchable_mailboxes.html | 61 +- .../services/get_server_time_zones.html | 192 +- .../services/get_streaming_events.html | 96 +- .../services/get_user_availability.html | 47 +- .../services/get_user_configuration.html | 61 +- .../services/get_user_oof_settings.html | 45 +- docs/exchangelib/services/index.html | 2379 +++++++---------- docs/exchangelib/services/mark_as_junk.html | 48 +- docs/exchangelib/services/move_folder.html | 71 +- docs/exchangelib/services/move_item.html | 72 +- docs/exchangelib/services/resolve_names.html | 143 +- docs/exchangelib/services/send_item.html | 64 +- .../services/send_notification.html | 135 +- .../services/set_user_oof_settings.html | 32 +- docs/exchangelib/services/subscribe.html | 115 +- .../services/sync_folder_hierarchy.html | 171 +- .../services/sync_folder_items.html | 165 +- docs/exchangelib/services/unsubscribe.html | 18 +- docs/exchangelib/services/update_folder.html | 478 +++- docs/exchangelib/services/update_item.html | 471 +--- .../services/update_user_configuration.html | 12 +- docs/exchangelib/services/upload_items.html | 83 +- docs/exchangelib/settings.html | 48 +- docs/exchangelib/transport.html | 72 +- docs/exchangelib/util.html | 287 +- docs/exchangelib/version.html | 117 +- docs/exchangelib/winzone.html | 17 +- 85 files changed, 10541 insertions(+), 10538 deletions(-) diff --git a/docs/exchangelib/account.html b/docs/exchangelib/account.html index 46108c30..dfccef1b 100644 --- a/docs/exchangelib/account.html +++ b/docs/exchangelib/account.html @@ -26,7 +26,7 @@

    Module exchangelib.account

    Expand source code -
    from locale import getlocale
    +
    import locale as stdlib_locale
     from logging import getLogger
     
     from cached_property import threaded_cached_property
    @@ -34,7 +34,7 @@ 

    Module exchangelib.account

    from .autodiscover import Autodiscovery from .configuration import Configuration from .credentials import DELEGATE, IMPERSONATION, ACCESS_TYPES -from .errors import UnknownTimeZone +from .errors import UnknownTimeZone, InvalidEnumValue, InvalidTypeError from .ewsdatetime import EWSTimeZone, UTC from .fields import FieldPath from .folders import Folder, AdminAuditLogs, ArchiveDeletedItems, ArchiveInbox, ArchiveMsgFolderRoot, \ @@ -44,7 +44,7 @@

    Module exchangelib.account

    Notes, Outbox, PeopleConnect, PublicFoldersRoot, QuickContacts, RecipientCache, RecoverableItemsDeletions, \ RecoverableItemsPurges, RecoverableItemsRoot, RecoverableItemsVersions, Root, SearchFolders, SentItems, \ ServerFailures, SyncIssues, Tasks, ToDoSearch, VoiceMail -from .items import HARD_DELETE, AUTO_RESOLVE, SEND_TO_NONE, SAVE_ONLY, ALL_OCCURRENCIES, ID_ONLY +from .items import HARD_DELETE, AUTO_RESOLVE, SEND_TO_NONE, SAVE_ONLY, ALL_OCCURRENCES, ID_ONLY from .properties import Mailbox, SendingAs from .protocol import Protocol from .queryset import QuerySet @@ -73,10 +73,7 @@

    Module exchangelib.account

    self.sid = sid def __eq__(self, other): - for k in self.__dict__: - if getattr(self, k) != getattr(other, k): - return False - return True + return all(getattr(self, k) == getattr(other, k) for k in self.__dict__) def __hash__(self): return hash(repr(self)) @@ -107,25 +104,26 @@

    Module exchangelib.account

    :return: """ if '@' not in primary_smtp_address: - raise ValueError("primary_smtp_address %r is not an email address" % primary_smtp_address) + raise ValueError(f"primary_smtp_address {primary_smtp_address!r} is not an email address") self.fullname = fullname # Assume delegate access if individual credentials are provided. Else, assume service user with impersonation self.access_type = access_type or (DELEGATE if credentials else IMPERSONATION) if self.access_type not in ACCESS_TYPES: - raise ValueError("'access_type' %r must be one of %s" % (self.access_type, ACCESS_TYPES)) + raise InvalidEnumValue('access_type', self.access_type, ACCESS_TYPES) try: - self.locale = locale or getlocale()[0] or None # get_locale() might not be able to determine the locale + # get_locale() might not be able to determine the locale + self.locale = locale or stdlib_locale.getlocale()[0] or None except ValueError as e: # getlocale() may throw ValueError if it fails to parse the system locale log.warning('Failed to get locale (%s)', e) self.locale = None if not isinstance(self.locale, (type(None), str)): - raise ValueError("Expected 'locale' to be a string, got %r" % self.locale) + raise InvalidTypeError('locale', self.locale, str) if default_timezone: try: self.default_timezone = EWSTimeZone.from_timezone(default_timezone) except TypeError: - raise ValueError("Expected 'default_timezone' to be an EWSTimeZone, got %r" % default_timezone) + raise InvalidTypeError('default_timezone', default_timezone, EWSTimeZone) else: try: self.default_timezone = EWSTimeZone.localzone() @@ -135,17 +133,22 @@

    Module exchangelib.account

    log.warning('%s. Fallback to UTC', e.args[0]) self.default_timezone = UTC if not isinstance(config, (Configuration, type(None))): - raise ValueError("Expected 'config' to be a Configuration, got %r" % config) + raise InvalidTypeError('config', config, Configuration) if autodiscover: if config: - retry_policy, auth_type = config.retry_policy, config.auth_type + auth_type, retry_policy, version = config.auth_type, config.retry_policy, config.version if not credentials: credentials = config.credentials else: - retry_policy, auth_type = None, None + auth_type, retry_policy, version = None, None, None self.ad_response, self.protocol = Autodiscovery( - email=primary_smtp_address, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy + email=primary_smtp_address, credentials=credentials ).discover() + # Let's not use the auth_package hint from the AD response. It could be incorrect and we can just guess. + self.protocol.config.auth_type = auth_type + self.protocol.config.retry_policy = retry_policy + if not self.protocol.config.version: + self.protocol.config.version = version primary_smtp_address = self.ad_response.autodiscover_smtp_address else: if not config: @@ -160,8 +163,9 @@

    Module exchangelib.account

    self.affinity_cookie = None # We may need to override the default server version on a per-account basis because Microsoft may report one - # server version up-front but delegate account requests to an older backend server. - self.version = self.protocol.version + # server version up-front but delegate account requests to an older backend server. Create a new instance to + # avoid changing the protocol version. + self.version = self.protocol.version.copy() log.debug('Added account: %s', self) @property @@ -479,7 +483,7 @@

    Module exchangelib.account

    ))) def bulk_delete(self, ids, delete_type=HARD_DELETE, send_meeting_cancellations=SEND_TO_NONE, - affected_task_occurrences=ALL_OCCURRENCIES, suppress_read_receipts=True, chunk_size=None): + affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True, chunk_size=None): """Bulk delete items. :param ids: an iterable of either (id, changekey) tuples or Item objects. @@ -488,14 +492,14 @@

    Module exchangelib.account

    :param send_meeting_cancellations: only applicable to CalendarItem. Possible values are specified in SEND_MEETING_CANCELLATIONS_CHOICES. (Default value = SEND_TO_NONE) :param affected_task_occurrences: only applicable for recurring Task items. Possible values are specified in - AFFECTED_TASK_OCCURRENCES_CHOICES. (Default value = ALL_OCCURRENCIES) + AFFECTED_TASK_OCCURRENCES_CHOICES. (Default value = ALL_OCCURRENCES) :param suppress_read_receipts: only supported from Exchange 2013. True or False. (Default value = True) :param chunk_size: The number of items to send to the server in a single request (Default value = None) :return: a list of either True or exception instances, in the same order as the input """ log.debug( - 'Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurences: %s)', + 'Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurrences: %s)', self, delete_type, send_meeting_cancellations, @@ -633,15 +637,11 @@

    Module exchangelib.account

    # We accept generators, so it's not always convenient for caller to know up-front if 'ids' is empty. Allow # empty 'ids' and return early. return - # GetPersona only accepts one persona ID per request. Crazy. - svc = GetPersona(account=self) - for i in ids: - yield svc.call(persona=i) + yield from GetPersona(account=self).call(personas=ids) @property def mail_tips(self): """See self.oof_settings about caching considerations.""" - # mail_tips_requested must be one of properties.MAIL_TIPS_TYPES return GetMailTips(protocol=self.protocol).get( sending_as=SendingAs(email_address=self.primary_smtp_address), recipients=[Mailbox(email_address=self.primary_smtp_address)], @@ -651,18 +651,12 @@

    Module exchangelib.account

    @property def delegates(self): """Return a list of DelegateUser objects representing the delegates that are set on this account.""" - delegates = [] - for d in GetDelegate(account=self).call(user_ids=None, include_permissions=True): - if isinstance(d, Exception): - raise d - delegates.append(d) - return delegates + return list(GetDelegate(account=self).call(user_ids=None, include_permissions=True)) def __str__(self): - txt = '%s' % self.primary_smtp_address if self.fullname: - txt += ' (%s)' % self.fullname - return txt
    + return f'{self.primary_smtp_address} ({self.fullname})' + return self.primary_smtp_address
    @@ -719,25 +713,26 @@

    Classes

    :return: """ if '@' not in primary_smtp_address: - raise ValueError("primary_smtp_address %r is not an email address" % primary_smtp_address) + raise ValueError(f"primary_smtp_address {primary_smtp_address!r} is not an email address") self.fullname = fullname # Assume delegate access if individual credentials are provided. Else, assume service user with impersonation self.access_type = access_type or (DELEGATE if credentials else IMPERSONATION) if self.access_type not in ACCESS_TYPES: - raise ValueError("'access_type' %r must be one of %s" % (self.access_type, ACCESS_TYPES)) + raise InvalidEnumValue('access_type', self.access_type, ACCESS_TYPES) try: - self.locale = locale or getlocale()[0] or None # get_locale() might not be able to determine the locale + # get_locale() might not be able to determine the locale + self.locale = locale or stdlib_locale.getlocale()[0] or None except ValueError as e: # getlocale() may throw ValueError if it fails to parse the system locale log.warning('Failed to get locale (%s)', e) self.locale = None if not isinstance(self.locale, (type(None), str)): - raise ValueError("Expected 'locale' to be a string, got %r" % self.locale) + raise InvalidTypeError('locale', self.locale, str) if default_timezone: try: self.default_timezone = EWSTimeZone.from_timezone(default_timezone) except TypeError: - raise ValueError("Expected 'default_timezone' to be an EWSTimeZone, got %r" % default_timezone) + raise InvalidTypeError('default_timezone', default_timezone, EWSTimeZone) else: try: self.default_timezone = EWSTimeZone.localzone() @@ -747,17 +742,22 @@

    Classes

    log.warning('%s. Fallback to UTC', e.args[0]) self.default_timezone = UTC if not isinstance(config, (Configuration, type(None))): - raise ValueError("Expected 'config' to be a Configuration, got %r" % config) + raise InvalidTypeError('config', config, Configuration) if autodiscover: if config: - retry_policy, auth_type = config.retry_policy, config.auth_type + auth_type, retry_policy, version = config.auth_type, config.retry_policy, config.version if not credentials: credentials = config.credentials else: - retry_policy, auth_type = None, None + auth_type, retry_policy, version = None, None, None self.ad_response, self.protocol = Autodiscovery( - email=primary_smtp_address, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy + email=primary_smtp_address, credentials=credentials ).discover() + # Let's not use the auth_package hint from the AD response. It could be incorrect and we can just guess. + self.protocol.config.auth_type = auth_type + self.protocol.config.retry_policy = retry_policy + if not self.protocol.config.version: + self.protocol.config.version = version primary_smtp_address = self.ad_response.autodiscover_smtp_address else: if not config: @@ -772,8 +772,9 @@

    Classes

    self.affinity_cookie = None # We may need to override the default server version on a per-account basis because Microsoft may report one - # server version up-front but delegate account requests to an older backend server. - self.version = self.protocol.version + # server version up-front but delegate account requests to an older backend server. Create a new instance to + # avoid changing the protocol version. + self.version = self.protocol.version.copy() log.debug('Added account: %s', self) @property @@ -1091,7 +1092,7 @@

    Classes

    ))) def bulk_delete(self, ids, delete_type=HARD_DELETE, send_meeting_cancellations=SEND_TO_NONE, - affected_task_occurrences=ALL_OCCURRENCIES, suppress_read_receipts=True, chunk_size=None): + affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True, chunk_size=None): """Bulk delete items. :param ids: an iterable of either (id, changekey) tuples or Item objects. @@ -1100,14 +1101,14 @@

    Classes

    :param send_meeting_cancellations: only applicable to CalendarItem. Possible values are specified in SEND_MEETING_CANCELLATIONS_CHOICES. (Default value = SEND_TO_NONE) :param affected_task_occurrences: only applicable for recurring Task items. Possible values are specified in - AFFECTED_TASK_OCCURRENCES_CHOICES. (Default value = ALL_OCCURRENCIES) + AFFECTED_TASK_OCCURRENCES_CHOICES. (Default value = ALL_OCCURRENCES) :param suppress_read_receipts: only supported from Exchange 2013. True or False. (Default value = True) :param chunk_size: The number of items to send to the server in a single request (Default value = None) :return: a list of either True or exception instances, in the same order as the input """ log.debug( - 'Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurences: %s)', + 'Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurrences: %s)', self, delete_type, send_meeting_cancellations, @@ -1245,15 +1246,11 @@

    Classes

    # We accept generators, so it's not always convenient for caller to know up-front if 'ids' is empty. Allow # empty 'ids' and return early. return - # GetPersona only accepts one persona ID per request. Crazy. - svc = GetPersona(account=self) - for i in ids: - yield svc.call(persona=i) + yield from GetPersona(account=self).call(personas=ids) @property def mail_tips(self): """See self.oof_settings about caching considerations.""" - # mail_tips_requested must be one of properties.MAIL_TIPS_TYPES return GetMailTips(protocol=self.protocol).get( sending_as=SendingAs(email_address=self.primary_smtp_address), recipients=[Mailbox(email_address=self.primary_smtp_address)], @@ -1263,18 +1260,12 @@

    Classes

    @property def delegates(self): """Return a list of DelegateUser objects representing the delegates that are set on this account.""" - delegates = [] - for d in GetDelegate(account=self).call(user_ids=None, include_permissions=True): - if isinstance(d, Exception): - raise d - delegates.append(d) - return delegates + return list(GetDelegate(account=self).call(user_ids=None, include_permissions=True)) def __str__(self): - txt = '%s' % self.primary_smtp_address if self.fullname: - txt += ' (%s)' % self.fullname - return txt
    + return f'{self.primary_smtp_address} ({self.fullname})' + return self.primary_smtp_address

    Instance variables

    @@ -1587,12 +1578,7 @@

    Instance variables

    @property
     def delegates(self):
         """Return a list of DelegateUser objects representing the delegates that are set on this account."""
    -    delegates = []
    -    for d in GetDelegate(account=self).call(user_ids=None, include_permissions=True):
    -        if isinstance(d, Exception):
    -            raise d
    -        delegates.append(d)
    -    return delegates
    + return list(GetDelegate(account=self).call(user_ids=None, include_permissions=True))
    var directory
    @@ -1801,7 +1787,6 @@

    Instance variables

    @property
     def mail_tips(self):
         """See self.oof_settings about caching considerations."""
    -    # mail_tips_requested must be one of properties.MAIL_TIPS_TYPES
         return GetMailTips(protocol=self.protocol).get(
             sending_as=SendingAs(email_address=self.primary_smtp_address),
             recipients=[Mailbox(email_address=self.primary_smtp_address)],
    @@ -2445,7 +2430,7 @@ 

    Methods

    :param send_meeting_cancellations: only applicable to CalendarItem. Possible values are specified in SEND_MEETING_CANCELLATIONS_CHOICES. (Default value = SEND_TO_NONE) :param affected_task_occurrences: only applicable for recurring Task items. Possible values are specified in -AFFECTED_TASK_OCCURRENCES_CHOICES. (Default value = ALL_OCCURRENCIES) +AFFECTED_TASK_OCCURRENCES_CHOICES. (Default value = ALL_OCCURRENCES) :param suppress_read_receipts: only supported from Exchange 2013. True or False. (Default value = True) :param chunk_size: The number of items to send to the server in a single request (Default value = None)

    :return: a list of either True or exception instances, in the same order as the input

    @@ -2454,7 +2439,7 @@

    Methods

    Expand source code
    def bulk_delete(self, ids, delete_type=HARD_DELETE, send_meeting_cancellations=SEND_TO_NONE,
    -                affected_task_occurrences=ALL_OCCURRENCIES, suppress_read_receipts=True, chunk_size=None):
    +                affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True, chunk_size=None):
         """Bulk delete items.
     
         :param ids: an iterable of either (id, changekey) tuples or Item objects.
    @@ -2463,14 +2448,14 @@ 

    Methods

    :param send_meeting_cancellations: only applicable to CalendarItem. Possible values are specified in SEND_MEETING_CANCELLATIONS_CHOICES. (Default value = SEND_TO_NONE) :param affected_task_occurrences: only applicable for recurring Task items. Possible values are specified in - AFFECTED_TASK_OCCURRENCES_CHOICES. (Default value = ALL_OCCURRENCIES) + AFFECTED_TASK_OCCURRENCES_CHOICES. (Default value = ALL_OCCURRENCES) :param suppress_read_receipts: only supported from Exchange 2013. True or False. (Default value = True) :param chunk_size: The number of items to send to the server in a single request (Default value = None) :return: a list of either True or exception instances, in the same order as the input """ log.debug( - 'Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurences: %s)', + 'Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurrences: %s)', self, delete_type, send_meeting_cancellations, @@ -2736,10 +2721,7 @@

    Methods

    # We accept generators, so it's not always convenient for caller to know up-front if 'ids' is empty. Allow # empty 'ids' and return early. return - # GetPersona only accepts one persona ID per request. Crazy. - svc = GetPersona(account=self) - for i in ids: - yield svc.call(persona=i)
    + yield from GetPersona(account=self).call(personas=ids)
    @@ -2824,10 +2806,7 @@

    Methods

    self.sid = sid def __eq__(self, other): - for k in self.__dict__: - if getattr(self, k) != getattr(other, k): - return False - return True + return all(getattr(self, k) == getattr(other, k) for k in self.__dict__) def __hash__(self): return hash(repr(self)) diff --git a/docs/exchangelib/attachments.html b/docs/exchangelib/attachments.html index 1648b859..ee303c78 100644 --- a/docs/exchangelib/attachments.html +++ b/docs/exchangelib/attachments.html @@ -30,10 +30,10 @@

    Module exchangelib.attachments

    import logging import mimetypes +from .errors import InvalidTypeError from .fields import BooleanField, TextField, IntegerField, URIField, DateTimeField, EWSElementField, Base64Field, \ ItemField, IdField, FieldPath from .properties import EWSElement, EWSMeta -from .services import GetAttachment, CreateAttachment, DeleteAttachment log = logging.getLogger(__name__) @@ -54,6 +54,12 @@

    Module exchangelib.attachments

    root_id = IdField(field_uri=ROOT_ID_ATTR) root_changekey = IdField(field_uri=ROOT_CHANGEKEY_ATTR) + def __init__(self, *args, **kwargs): + if not kwargs: + # Allow to set attributes without keyword + kwargs = dict(zip(self._slots_keys, args)) + super().__init__(**kwargs) + class Attachment(EWSElement, metaclass=EWSMeta): """Base class for FileAttachment and ItemAttachment.""" @@ -76,23 +82,20 @@

    Module exchangelib.attachments

    def clean(self, version=None): from .items import Item if self.parent_item is not None and not isinstance(self.parent_item, Item): - raise ValueError("'parent_item' value %r must be an Item instance" % self.parent_item) + raise InvalidTypeError('parent_item', self.parent_item, Item) if self.content_type is None and self.name is not None: self.content_type = mimetypes.guess_type(self.name)[0] or 'application/octet-stream' super().clean(version=version) def attach(self): + from .services import CreateAttachment # Adds this attachment to an item and updates the changekey of the parent item if self.attachment_id: raise ValueError('This attachment has already been created') if not self.parent_item or not self.parent_item.account: - raise ValueError('Parent item %s must have an account' % self.parent_item) + raise ValueError(f'Parent item {self.parent_item} must have an account') item = CreateAttachment(account=self.parent_item.account).get(parent_item=self.parent_item, items=[self]) attachment_id = item.attachment_id - if attachment_id.root_id != self.parent_item.id: - raise ValueError("'root_id' vs. 'id' mismatch") - if attachment_id.root_changekey == self.parent_item.changekey: - raise ValueError('root_id changekey match') self.parent_item.changekey = attachment_id.root_changekey # EWS does not like receiving root_id and root_changekey on subsequent requests attachment_id.root_id = None @@ -100,17 +103,13 @@

    Module exchangelib.attachments

    self.attachment_id = attachment_id def detach(self): + from .services import DeleteAttachment # Deletes an attachment remotely and updates the changekey of the parent item if not self.attachment_id: raise ValueError('This attachment has not been created') if not self.parent_item or not self.parent_item.account: - raise ValueError('Parent item %s must have an account' % self.parent_item) - root_item_id = DeleteAttachment(account=self.parent_item.account).get(items=[self.attachment_id]) - if root_item_id.id != self.parent_item.id: - raise ValueError("'root_item_id.id' mismatch") - if root_item_id.changekey == self.parent_item.changekey: - raise ValueError("'root_item_id.changekey' match") - self.parent_item.changekey = root_item_id.changekey + raise ValueError(f'Parent item {self.parent_item} must have an account') + DeleteAttachment(account=self.parent_item.account).get(items=[self.attachment_id]) self.parent_item = None self.attachment_id = None @@ -121,9 +120,10 @@

    Module exchangelib.attachments

    return hash(tuple(getattr(self, f) for f in self._slots_keys if f != 'parent_item')) def __repr__(self): - return self.__class__.__name__ + '(%s)' % ', '.join( - '%s=%r' % (f.name, getattr(self, f.name)) for f in self.FIELDS if f.name not in ('_item', '_content') + args_str = ', '.join( + f'{f.name}={getattr(self, f.name)!r}' for f in self.FIELDS if f.name not in ('_item', '_content') ) + return f'{self.__class__.__name__}({args_str})' class FileAttachment(Attachment): @@ -152,7 +152,7 @@

    Module exchangelib.attachments

    # Create a file-like object for the attachment content. We try hard to reduce memory consumption so we never # store the full attachment content in-memory. if not self.parent_item or not self.parent_item.account: - raise ValueError('%s must have an account' % self.__class__.__name__) + raise ValueError(f'{self.__class__.__name__} must have an account') self._fp = FileAttachmentIO(attachment=self) @property @@ -173,7 +173,7 @@

    Module exchangelib.attachments

    def content(self, value): """Replace the attachment content.""" if not isinstance(value, bytes): - raise ValueError("'value' %r must be a bytes object" % value) + raise InvalidTypeError('value', value, bytes) self._content = value @classmethod @@ -214,13 +214,14 @@

    Module exchangelib.attachments

    @property def item(self): from .folders import BaseFolder + from .services import GetAttachment if self.attachment_id is None: return self._item if self._item is not None: return self._item # We have an ID to the data but still haven't called GetAttachment to get the actual data. Do that now. if not self.parent_item or not self.parent_item.account: - raise ValueError('%s must have an account' % self.__class__.__name__) + raise ValueError(f'{self.__class__.__name__} must have an account') additional_fields = { FieldPath(field=f) for f in BaseFolder.allowed_item_fields(version=self.parent_item.account.version) } @@ -235,7 +236,7 @@

    Module exchangelib.attachments

    def item(self, value): from .items import Item if not isinstance(value, Item): - raise ValueError("'value' %r must be an Item object" % value) + raise InvalidTypeError('value', value, Item) self._item = value @classmethod @@ -251,6 +252,7 @@

    Module exchangelib.attachments

    def __init__(self, attachment): self._attachment = attachment + self._stream = None self._overflow = None def readable(self): @@ -272,6 +274,7 @@

    Module exchangelib.attachments

    return len(output) def __enter__(self): + from .services import GetAttachment self._stream = GetAttachment(account=self._attachment.parent_item.account).stream_file_content( attachment_id=self._attachment.attachment_id ) @@ -323,23 +326,20 @@

    Classes

    def clean(self, version=None): from .items import Item if self.parent_item is not None and not isinstance(self.parent_item, Item): - raise ValueError("'parent_item' value %r must be an Item instance" % self.parent_item) + raise InvalidTypeError('parent_item', self.parent_item, Item) if self.content_type is None and self.name is not None: self.content_type = mimetypes.guess_type(self.name)[0] or 'application/octet-stream' super().clean(version=version) def attach(self): + from .services import CreateAttachment # Adds this attachment to an item and updates the changekey of the parent item if self.attachment_id: raise ValueError('This attachment has already been created') if not self.parent_item or not self.parent_item.account: - raise ValueError('Parent item %s must have an account' % self.parent_item) + raise ValueError(f'Parent item {self.parent_item} must have an account') item = CreateAttachment(account=self.parent_item.account).get(parent_item=self.parent_item, items=[self]) attachment_id = item.attachment_id - if attachment_id.root_id != self.parent_item.id: - raise ValueError("'root_id' vs. 'id' mismatch") - if attachment_id.root_changekey == self.parent_item.changekey: - raise ValueError('root_id changekey match') self.parent_item.changekey = attachment_id.root_changekey # EWS does not like receiving root_id and root_changekey on subsequent requests attachment_id.root_id = None @@ -347,17 +347,13 @@

    Classes

    self.attachment_id = attachment_id def detach(self): + from .services import DeleteAttachment # Deletes an attachment remotely and updates the changekey of the parent item if not self.attachment_id: raise ValueError('This attachment has not been created') if not self.parent_item or not self.parent_item.account: - raise ValueError('Parent item %s must have an account' % self.parent_item) - root_item_id = DeleteAttachment(account=self.parent_item.account).get(items=[self.attachment_id]) - if root_item_id.id != self.parent_item.id: - raise ValueError("'root_item_id.id' mismatch") - if root_item_id.changekey == self.parent_item.changekey: - raise ValueError("'root_item_id.changekey' match") - self.parent_item.changekey = root_item_id.changekey + raise ValueError(f'Parent item {self.parent_item} must have an account') + DeleteAttachment(account=self.parent_item.account).get(items=[self.attachment_id]) self.parent_item = None self.attachment_id = None @@ -368,9 +364,10 @@

    Classes

    return hash(tuple(getattr(self, f) for f in self._slots_keys if f != 'parent_item')) def __repr__(self): - return self.__class__.__name__ + '(%s)' % ', '.join( - '%s=%r' % (f.name, getattr(self, f.name)) for f in self.FIELDS if f.name not in ('_item', '_content') - )
    + args_str = ', '.join( + f'{f.name}={getattr(self, f.name)!r}' for f in self.FIELDS if f.name not in ('_item', '_content') + ) + return f'{self.__class__.__name__}({args_str})'

    Ancestors

      @@ -439,17 +436,14 @@

      Methods

      Expand source code
      def attach(self):
      +    from .services import CreateAttachment
           # Adds this attachment to an item and updates the changekey of the parent item
           if self.attachment_id:
               raise ValueError('This attachment has already been created')
           if not self.parent_item or not self.parent_item.account:
      -        raise ValueError('Parent item %s must have an account' % self.parent_item)
      +        raise ValueError(f'Parent item {self.parent_item} must have an account')
           item = CreateAttachment(account=self.parent_item.account).get(parent_item=self.parent_item, items=[self])
           attachment_id = item.attachment_id
      -    if attachment_id.root_id != self.parent_item.id:
      -        raise ValueError("'root_id' vs. 'id' mismatch")
      -    if attachment_id.root_changekey == self.parent_item.changekey:
      -        raise ValueError('root_id changekey match')
           self.parent_item.changekey = attachment_id.root_changekey
           # EWS does not like receiving root_id and root_changekey on subsequent requests
           attachment_id.root_id = None
      @@ -469,7 +463,7 @@ 

      Methods

      def clean(self, version=None):
           from .items import Item
           if self.parent_item is not None and not isinstance(self.parent_item, Item):
      -        raise ValueError("'parent_item' value %r must be an Item instance" % self.parent_item)
      +        raise InvalidTypeError('parent_item', self.parent_item, Item)
           if self.content_type is None and self.name is not None:
               self.content_type = mimetypes.guess_type(self.name)[0] or 'application/octet-stream'
           super().clean(version=version)
      @@ -485,17 +479,13 @@

      Methods

      Expand source code
      def detach(self):
      +    from .services import DeleteAttachment
           # Deletes an attachment remotely and updates the changekey of the parent item
           if not self.attachment_id:
               raise ValueError('This attachment has not been created')
           if not self.parent_item or not self.parent_item.account:
      -        raise ValueError('Parent item %s must have an account' % self.parent_item)
      -    root_item_id = DeleteAttachment(account=self.parent_item.account).get(items=[self.attachment_id])
      -    if root_item_id.id != self.parent_item.id:
      -        raise ValueError("'root_item_id.id' mismatch")
      -    if root_item_id.changekey == self.parent_item.changekey:
      -        raise ValueError("'root_item_id.changekey' match")
      -    self.parent_item.changekey = root_item_id.changekey
      +        raise ValueError(f'Parent item {self.parent_item} must have an account')
      +    DeleteAttachment(account=self.parent_item.account).get(items=[self.attachment_id])
           self.parent_item = None
           self.attachment_id = None
      @@ -515,7 +505,7 @@

      Inherited members

      class AttachmentId -(**kwargs) +(*args, **kwargs)

      'id' and 'changekey' are UUIDs generated by Exchange.

      @@ -538,7 +528,13 @@

      Inherited members

      id = IdField(field_uri=ID_ATTR, is_required=True) root_id = IdField(field_uri=ROOT_ID_ATTR) - root_changekey = IdField(field_uri=ROOT_CHANGEKEY_ATTR)
      + root_changekey = IdField(field_uri=ROOT_CHANGEKEY_ATTR) + + def __init__(self, *args, **kwargs): + if not kwargs: + # Allow to set attributes without keyword + kwargs = dict(zip(self._slots_keys, args)) + super().__init__(**kwargs)

      Ancestors

        @@ -630,7 +626,7 @@

        Inherited members

        # Create a file-like object for the attachment content. We try hard to reduce memory consumption so we never # store the full attachment content in-memory. if not self.parent_item or not self.parent_item.account: - raise ValueError('%s must have an account' % self.__class__.__name__) + raise ValueError(f'{self.__class__.__name__} must have an account') self._fp = FileAttachmentIO(attachment=self) @property @@ -651,7 +647,7 @@

        Inherited members

        def content(self, value): """Replace the attachment content.""" if not isinstance(value, bytes): - raise ValueError("'value' %r must be a bytes object" % value) + raise InvalidTypeError('value', value, bytes) self._content = value @classmethod @@ -803,6 +799,7 @@

        Inherited members

        def __init__(self, attachment): self._attachment = attachment + self._stream = None self._overflow = None def readable(self): @@ -824,6 +821,7 @@

        Inherited members

        return len(output) def __enter__(self): + from .services import GetAttachment self._stream = GetAttachment(account=self._attachment.parent_item.account).stream_file_content( attachment_id=self._attachment.attachment_id ) @@ -919,13 +917,14 @@

        Methods

        @property def item(self): from .folders import BaseFolder + from .services import GetAttachment if self.attachment_id is None: return self._item if self._item is not None: return self._item # We have an ID to the data but still haven't called GetAttachment to get the actual data. Do that now. if not self.parent_item or not self.parent_item.account: - raise ValueError('%s must have an account' % self.__class__.__name__) + raise ValueError(f'{self.__class__.__name__} must have an account') additional_fields = { FieldPath(field=f) for f in BaseFolder.allowed_item_fields(version=self.parent_item.account.version) } @@ -940,7 +939,7 @@

        Methods

        def item(self, value): from .items import Item if not isinstance(value, Item): - raise ValueError("'value' %r must be an Item object" % value) + raise InvalidTypeError('value', value, Item) self._item = value @classmethod @@ -998,13 +997,14 @@

        Instance variables

        @property
         def item(self):
             from .folders import BaseFolder
        +    from .services import GetAttachment
             if self.attachment_id is None:
                 return self._item
             if self._item is not None:
                 return self._item
             # We have an ID to the data but still haven't called GetAttachment to get the actual data. Do that now.
             if not self.parent_item or not self.parent_item.account:
        -        raise ValueError('%s must have an account' % self.__class__.__name__)
        +        raise ValueError(f'{self.__class__.__name__} must have an account')
             additional_fields = {
                 FieldPath(field=f) for f in BaseFolder.allowed_item_fields(version=self.parent_item.account.version)
             }
        diff --git a/docs/exchangelib/autodiscover/cache.html b/docs/exchangelib/autodiscover/cache.html
        index 50219073..ce548cae 100644
        --- a/docs/exchangelib/autodiscover/cache.html
        +++ b/docs/exchangelib/autodiscover/cache.html
        @@ -54,9 +54,7 @@ 

        Module exchangelib.autodiscover.cache

        except KeyError: # getuser() fails on some systems. Provide a sane default. See issue #448 user = 'exchangelib' - return 'exchangelib.{version}.cache.{user}.py{major}{minor}'.format( - version=version, user=user, major=major, minor=minor - ) + return f'exchangelib.{version}.cache.{user}.py{major}{minor}' AUTODISCOVER_PERSISTENT_STORAGE = os.path.join(tempfile.gettempdir(), shelve_filename()) @@ -215,9 +213,7 @@

        Functions

        except KeyError: # getuser() fails on some systems. Provide a sane default. See issue #448 user = 'exchangelib' - return 'exchangelib.{version}.cache.{user}.py{major}{minor}'.format( - version=version, user=user, major=major, minor=minor - )
        + return f'exchangelib.{version}.cache.{user}.py{major}{minor}'
        diff --git a/docs/exchangelib/autodiscover/discovery.html b/docs/exchangelib/autodiscover/discovery.html index a1fe3124..ed62ba7f 100644 --- a/docs/exchangelib/autodiscover/discovery.html +++ b/docs/exchangelib/autodiscover/discovery.html @@ -31,28 +31,29 @@

        Module exchangelib.autodiscover.discovery

        from urllib.parse import urlparse import dns.resolver +import dns.name from cached_property import threaded_cached_property from .cache import autodiscover_cache from .properties import Autodiscover from .protocol import AutodiscoverProtocol from ..configuration import Configuration -from ..credentials import OAuth2Credentials from ..errors import AutoDiscoverFailed, AutoDiscoverCircularRedirect, TransportError, RedirectError, UnauthorizedError from ..protocol import Protocol, FailFast -from ..transport import get_auth_method_from_response, DEFAULT_HEADERS, NOAUTH, OAUTH2, GSSAPI, AUTH_TYPE_MAP, \ - CREDENTIALS_REQUIRED +from ..transport import get_auth_method_from_response, DEFAULT_HEADERS, NOAUTH, GSSAPI, AUTH_TYPE_MAP from ..util import post_ratelimited, get_domain, get_redirect_url, _back_off_if_needed, \ - DummyResponse, CONNECTION_ERRORS, TLS_ERRORS -from ..version import Version + DummyResponse, ParseError, CONNECTION_ERRORS, TLS_ERRORS log = logging.getLogger(__name__) def discover(email, credentials=None, auth_type=None, retry_policy=None): - return Autodiscovery( - email=email, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy + ad_response, protocol = Autodiscovery( + email=email, credentials=credentials ).discover() + protocol.config.auth_typ = auth_type + protocol.config.retry_policy = retry_policy + return ad_response, protocol class SrvRecord: @@ -65,10 +66,7 @@

        Module exchangelib.autodiscover.discovery

        self.srv = srv def __eq__(self, other): - for k in self.__dict__: - if getattr(self, k) != getattr(other, k): - return False - return True + return all(getattr(self, k) == getattr(other, k) for k in self.__dict__) class Autodiscovery: @@ -108,19 +106,15 @@

        Module exchangelib.autodiscover.discovery

        'timeout': AutodiscoverProtocol.TIMEOUT, } - def __init__(self, email, credentials=None, auth_type=None, retry_policy=None): + def __init__(self, email, credentials=None): """ :param email: The email address to autodiscover :param credentials: Credentials with authorization to make autodiscover lookups for this Account (Default value = None) - :param auth_type: (Default value = None) - :param retry_policy: (Default value = None) """ self.email = email self.credentials = credentials - self.auth_type = auth_type # The auth type that the resulting protocol instance should have - self.retry_policy = retry_policy # The retry policy that the resulting protocol instance should have self._urls_visited = [] # Collects HTTP and Autodiscover redirects self._redirect_count = 0 self._emails_visited = [] # Collects Autodiscover email redirects @@ -147,6 +141,7 @@

        Module exchangelib.autodiscover.discovery

        ad_response = self._step_1(hostname=domain) else: # This will cache the result + log.debug('Cache miss for key %s', cache_key) ad_response = self._step_1(hostname=domain) log.debug('Released autodiscover_cache_lock') @@ -184,52 +179,33 @@

        Module exchangelib.autodiscover.discovery

        return resolver def _build_response(self, ad_response): - ews_url = ad_response.ews_url - if not ews_url: - raise AutoDiscoverFailed("Response is missing an 'ews_url' value") if not ad_response.autodiscover_smtp_address: # Autodiscover does not always return an email address. In that case, the requesting email should be used ad_response.user.autodiscover_smtp_address = self.email - # Get the server version. Not all protocol entries have a server version so we cheat a bit and also look at the - # other ones that point to the same endpoint. - for protocol in ad_response.account.protocols: - if not protocol.ews_url or not protocol.server_version: - continue - if protocol.ews_url.lower() == ews_url.lower(): - version = Version(build=protocol.server_version) - break - else: - version = None - # We may not want to use the auth_package hints in the AD response. It could be incorrect and we can just guess. protocol = Protocol( config=Configuration( - service_endpoint=ews_url, + service_endpoint=ad_response.protocol.ews_url, credentials=self.credentials, - version=version, - auth_type=self.auth_type, - retry_policy=self.retry_policy, + version=ad_response.version, + auth_type=ad_response.protocol.auth_type, ) ) return ad_response, protocol def _quick(self, protocol): - # Reset auth type and retry policy if we requested non-default values - if self.auth_type: - protocol.config.auth_type = self.auth_type - if self.retry_policy: - protocol.config.retry_policy = self.retry_policy try: r = self._get_authenticated_response(protocol=protocol) except TransportError as e: - raise AutoDiscoverFailed('Response error: %s' % e) + raise AutoDiscoverFailed(f'Response error: {e}') if r.status_code == 200: try: ad = Autodiscover.from_bytes(bytes_content=r.content) + except ParseError as e: + raise AutoDiscoverFailed(f'Invalid response: {e}') + else: return self._step_5(ad=ad) - except ValueError as e: - raise AutoDiscoverFailed('Invalid response: %s' % e) - raise AutoDiscoverFailed('Invalid response code: %s' % r.status_code) + raise AutoDiscoverFailed(f'Invalid response code: {r.status_code}') def _redirect_url_is_valid(self, url): """Three separate responses can be “Redirect responses”: @@ -280,7 +256,7 @@

        Module exchangelib.autodiscover.discovery

        if not self._is_valid_hostname(hostname): # 'requests' is really bad at reporting that a hostname cannot be resolved. Let's check this separately. # Don't retry on DNS errors. They will most likely be persistent. - raise TransportError('%r has no DNS entry' % hostname) + raise TransportError(f'{hostname!r} has no DNS entry') kwargs = dict( url=url, headers=DEFAULT_HEADERS.copy(), allow_redirects=False, timeout=AutodiscoverProtocol.TIMEOUT @@ -301,7 +277,7 @@

        Module exchangelib.autodiscover.discovery

        # Don't retry on TLS errors. They will most likely be persistent. raise TransportError(str(e)) except CONNECTION_ERRORS as e: - r = DummyResponse(url=url, headers={}, request_headers=kwargs['headers']) + r = DummyResponse(url=url, request_headers=kwargs['headers']) total_wait = time.monotonic() - t_start if self.INITIAL_RETRY_POLICY.may_retry_on_error(response=r, wait=total_wait): log.debug("Connection error on URL %s (retry %s, error: %s). Cool down", url, retry, e) @@ -347,8 +323,7 @@

        Module exchangelib.autodiscover.discovery

        # isn't necessarily the right endpoint to use. raise TransportError(str(e)) except RedirectError as e: - r = DummyResponse(url=protocol.service_endpoint, headers={'location': e.url}, request_headers=None, - status_code=302) + r = DummyResponse(url=protocol.service_endpoint, headers={'location': e.url}, status_code=302) return r def _attempt_response(self, url): @@ -361,11 +336,6 @@

        Module exchangelib.autodiscover.discovery

        log.debug('Attempting to get a valid response from %s', url) try: auth_type, r = self._get_unauthenticated_response(url=url) - if isinstance(self.credentials, OAuth2Credentials): - # This type of credentials *must* use the OAuth auth type - auth_type = OAUTH2 - elif self.credentials is None and auth_type in CREDENTIALS_REQUIRED: - raise ValueError('Auth type %r was detected but no credentials were provided' % auth_type) ad_protocol = AutodiscoverProtocol( config=Configuration( service_endpoint=url, @@ -388,21 +358,22 @@

        Module exchangelib.autodiscover.discovery

        if r.status_code == 200: try: ad = Autodiscover.from_bytes(bytes_content=r.content) + except ParseError as e: + log.debug('Invalid response: %s', e) + else: # We got a valid response. Unless this is a URL redirect response, we cache the result if ad.response is None or not ad.response.redirect_url: cache_key = self._cache_key log.debug('Adding cache entry for key %s: %s', cache_key, ad_protocol.service_endpoint) autodiscover_cache[cache_key] = ad_protocol return True, ad - except ValueError as e: - log.debug('Invalid response: %s', e) return False, None def _is_valid_hostname(self, hostname): log.debug('Checking if %s can be looked up in DNS', hostname) try: self.resolver.resolve(hostname) - except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): + except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.name.EmptyLabel): return False return True @@ -422,7 +393,7 @@

        Module exchangelib.autodiscover.discovery

        log.debug('Attempting to get SRV records for %s', hostname) records = [] try: - answers = self.resolver.resolve('%s.' % hostname, 'SRV') + answers = self.resolver.resolve(f'{hostname}.', 'SRV') except (dns.resolver.NoNameservers, dns.resolver.NoAnswer, dns.resolver.NXDOMAIN) as e: log.debug('DNS lookup failure: %s', e) return records @@ -447,7 +418,7 @@

        Module exchangelib.autodiscover.discovery

        :param hostname: :return: """ - url = 'https://%s/Autodiscover/Autodiscover.xml' % hostname + url = f'https://{hostname}/Autodiscover/Autodiscover.xml' log.info('Step 1: Trying autodiscover on %r with email %r', url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: @@ -463,7 +434,7 @@

        Module exchangelib.autodiscover.discovery

        :param hostname: :return: """ - url = 'https://autodiscover.%s/Autodiscover/Autodiscover.xml' % hostname + url = f'https://autodiscover.{hostname}/Autodiscover/Autodiscover.xml' log.info('Step 2: Trying autodiscover on %r with email %r', url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: @@ -486,20 +457,23 @@

        Module exchangelib.autodiscover.discovery

        :param hostname: :return: """ - url = 'http://autodiscover.%s/Autodiscover/Autodiscover.xml' % hostname + url = f'http://autodiscover.{hostname}/Autodiscover/Autodiscover.xml' log.info('Step 3: Trying autodiscover on %r with email %r', url, self.email) try: _, r = self._get_unauthenticated_response(url=url, method='get') except TransportError: - r = DummyResponse(url=url, headers={}, request_headers={}) + r = DummyResponse(url=url) if r.status_code in (301, 302) and 'location' in r.headers: redirect_url = get_redirect_url(r) if self._redirect_url_is_valid(url=redirect_url): is_valid_response, ad = self._attempt_response(url=redirect_url) if is_valid_response: return self._step_5(ad=ad) + log.debug('Got invalid response') return self._step_4(hostname=hostname) + log.debug('Got invalid redirect URL') return self._step_4(hostname=hostname) + log.debug('Got no redirect URL') return self._step_4(hostname=hostname) def _step_4(self, hostname): @@ -519,7 +493,7 @@

        Module exchangelib.autodiscover.discovery

        :param hostname: :return: """ - dns_hostname = '_autodiscover._tcp.%s' % hostname + dns_hostname = f'_autodiscover._tcp.{hostname}' log.info('Step 4: Trying autodiscover on %r with email %r', dns_hostname, self.email) srv_records = self._get_srv_records(dns_hostname) try: @@ -528,12 +502,14 @@

        Module exchangelib.autodiscover.discovery

        srv_host = None if not srv_host: return self._step_6() - redirect_url = 'https://%s/Autodiscover/Autodiscover.xml' % srv_host + redirect_url = f'https://{srv_host}/Autodiscover/Autodiscover.xml' if self._redirect_url_is_valid(url=redirect_url): is_valid_response, ad = self._attempt_response(url=redirect_url) if is_valid_response: return self._step_5(ad=ad) + log.debug('Got invalid response') return self._step_6() + log.debug('Got invalid redirect URL') return self._step_6() def _step_5(self, ad): @@ -562,13 +538,14 @@

        Module exchangelib.autodiscover.discovery

        if ad_response.redirect_url: log.debug('Got a redirect URL: %s', ad_response.redirect_url) # We are diverging a bit from the protocol here. We will never get an HTTP 302 since earlier steps already - # followed the redirects where possible. Instead, we handle retirect responses here. + # followed the redirects where possible. Instead, we handle redirect responses here. if self._redirect_url_is_valid(url=ad_response.redirect_url): is_valid_response, ad = self._attempt_response(url=ad_response.redirect_url) if is_valid_response: return self._step_5(ad=ad) + log.debug('Got invalid response') return self._step_6() - log.debug('Invalid redirect URL: %s', ad_response.redirect_url) + log.debug('Invalid redirect URL') return self._step_6() # This could be an email redirect. Let outer layer handle this return ad_response @@ -579,8 +556,8 @@

        Module exchangelib.autodiscover.discovery

        future requests. """ raise AutoDiscoverFailed( - 'All steps in the autodiscover protocol failed for email %r. If you think this is an error, consider doing ' - 'an official test at https://testconnectivity.microsoft.com' % self.email) + f'All steps in the autodiscover protocol failed for email {self.email}. If you think this is an error, ' + f'consider doing an official test at https://testconnectivity.microsoft.com') def _select_srv_host(srv_records): @@ -619,9 +596,12 @@

        Functions

        Expand source code
        def discover(email, credentials=None, auth_type=None, retry_policy=None):
        -    return Autodiscovery(
        -        email=email, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy
        -    ).discover()
        + ad_response, protocol = Autodiscovery( + email=email, credentials=credentials + ).discover() + protocol.config.auth_typ = auth_type + protocol.config.retry_policy = retry_policy + return ad_response, protocol
    @@ -631,7 +611,7 @@

    Classes

    class Autodiscovery -(email, credentials=None, auth_type=None, retry_policy=None) +(email, credentials=None)

    Autodiscover is a Microsoft protocol for automatically getting the endpoint of the Exchange server and other @@ -652,10 +632,6 @@

    Classes

    implementation, start by doing an official test at https://testconnectivity.microsoft.com

    :param email: The email address to autodiscover :param credentials: Credentials with authorization to make autodiscover lookups for this Account -(Default value = None) -:param auth_type: -(Default value = None) -:param retry_policy: (Default value = None)

    @@ -698,19 +674,15 @@

    Classes

    'timeout': AutodiscoverProtocol.TIMEOUT, } - def __init__(self, email, credentials=None, auth_type=None, retry_policy=None): + def __init__(self, email, credentials=None): """ :param email: The email address to autodiscover :param credentials: Credentials with authorization to make autodiscover lookups for this Account (Default value = None) - :param auth_type: (Default value = None) - :param retry_policy: (Default value = None) """ self.email = email self.credentials = credentials - self.auth_type = auth_type # The auth type that the resulting protocol instance should have - self.retry_policy = retry_policy # The retry policy that the resulting protocol instance should have self._urls_visited = [] # Collects HTTP and Autodiscover redirects self._redirect_count = 0 self._emails_visited = [] # Collects Autodiscover email redirects @@ -737,6 +709,7 @@

    Classes

    ad_response = self._step_1(hostname=domain) else: # This will cache the result + log.debug('Cache miss for key %s', cache_key) ad_response = self._step_1(hostname=domain) log.debug('Released autodiscover_cache_lock') @@ -774,52 +747,33 @@

    Classes

    return resolver def _build_response(self, ad_response): - ews_url = ad_response.ews_url - if not ews_url: - raise AutoDiscoverFailed("Response is missing an 'ews_url' value") if not ad_response.autodiscover_smtp_address: # Autodiscover does not always return an email address. In that case, the requesting email should be used ad_response.user.autodiscover_smtp_address = self.email - # Get the server version. Not all protocol entries have a server version so we cheat a bit and also look at the - # other ones that point to the same endpoint. - for protocol in ad_response.account.protocols: - if not protocol.ews_url or not protocol.server_version: - continue - if protocol.ews_url.lower() == ews_url.lower(): - version = Version(build=protocol.server_version) - break - else: - version = None - # We may not want to use the auth_package hints in the AD response. It could be incorrect and we can just guess. protocol = Protocol( config=Configuration( - service_endpoint=ews_url, + service_endpoint=ad_response.protocol.ews_url, credentials=self.credentials, - version=version, - auth_type=self.auth_type, - retry_policy=self.retry_policy, + version=ad_response.version, + auth_type=ad_response.protocol.auth_type, ) ) return ad_response, protocol def _quick(self, protocol): - # Reset auth type and retry policy if we requested non-default values - if self.auth_type: - protocol.config.auth_type = self.auth_type - if self.retry_policy: - protocol.config.retry_policy = self.retry_policy try: r = self._get_authenticated_response(protocol=protocol) except TransportError as e: - raise AutoDiscoverFailed('Response error: %s' % e) + raise AutoDiscoverFailed(f'Response error: {e}') if r.status_code == 200: try: ad = Autodiscover.from_bytes(bytes_content=r.content) + except ParseError as e: + raise AutoDiscoverFailed(f'Invalid response: {e}') + else: return self._step_5(ad=ad) - except ValueError as e: - raise AutoDiscoverFailed('Invalid response: %s' % e) - raise AutoDiscoverFailed('Invalid response code: %s' % r.status_code) + raise AutoDiscoverFailed(f'Invalid response code: {r.status_code}') def _redirect_url_is_valid(self, url): """Three separate responses can be “Redirect responses”: @@ -870,7 +824,7 @@

    Classes

    if not self._is_valid_hostname(hostname): # 'requests' is really bad at reporting that a hostname cannot be resolved. Let's check this separately. # Don't retry on DNS errors. They will most likely be persistent. - raise TransportError('%r has no DNS entry' % hostname) + raise TransportError(f'{hostname!r} has no DNS entry') kwargs = dict( url=url, headers=DEFAULT_HEADERS.copy(), allow_redirects=False, timeout=AutodiscoverProtocol.TIMEOUT @@ -891,7 +845,7 @@

    Classes

    # Don't retry on TLS errors. They will most likely be persistent. raise TransportError(str(e)) except CONNECTION_ERRORS as e: - r = DummyResponse(url=url, headers={}, request_headers=kwargs['headers']) + r = DummyResponse(url=url, request_headers=kwargs['headers']) total_wait = time.monotonic() - t_start if self.INITIAL_RETRY_POLICY.may_retry_on_error(response=r, wait=total_wait): log.debug("Connection error on URL %s (retry %s, error: %s). Cool down", url, retry, e) @@ -937,8 +891,7 @@

    Classes

    # isn't necessarily the right endpoint to use. raise TransportError(str(e)) except RedirectError as e: - r = DummyResponse(url=protocol.service_endpoint, headers={'location': e.url}, request_headers=None, - status_code=302) + r = DummyResponse(url=protocol.service_endpoint, headers={'location': e.url}, status_code=302) return r def _attempt_response(self, url): @@ -951,11 +904,6 @@

    Classes

    log.debug('Attempting to get a valid response from %s', url) try: auth_type, r = self._get_unauthenticated_response(url=url) - if isinstance(self.credentials, OAuth2Credentials): - # This type of credentials *must* use the OAuth auth type - auth_type = OAUTH2 - elif self.credentials is None and auth_type in CREDENTIALS_REQUIRED: - raise ValueError('Auth type %r was detected but no credentials were provided' % auth_type) ad_protocol = AutodiscoverProtocol( config=Configuration( service_endpoint=url, @@ -978,21 +926,22 @@

    Classes

    if r.status_code == 200: try: ad = Autodiscover.from_bytes(bytes_content=r.content) + except ParseError as e: + log.debug('Invalid response: %s', e) + else: # We got a valid response. Unless this is a URL redirect response, we cache the result if ad.response is None or not ad.response.redirect_url: cache_key = self._cache_key log.debug('Adding cache entry for key %s: %s', cache_key, ad_protocol.service_endpoint) autodiscover_cache[cache_key] = ad_protocol return True, ad - except ValueError as e: - log.debug('Invalid response: %s', e) return False, None def _is_valid_hostname(self, hostname): log.debug('Checking if %s can be looked up in DNS', hostname) try: self.resolver.resolve(hostname) - except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): + except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.name.EmptyLabel): return False return True @@ -1012,7 +961,7 @@

    Classes

    log.debug('Attempting to get SRV records for %s', hostname) records = [] try: - answers = self.resolver.resolve('%s.' % hostname, 'SRV') + answers = self.resolver.resolve(f'{hostname}.', 'SRV') except (dns.resolver.NoNameservers, dns.resolver.NoAnswer, dns.resolver.NXDOMAIN) as e: log.debug('DNS lookup failure: %s', e) return records @@ -1037,7 +986,7 @@

    Classes

    :param hostname: :return: """ - url = 'https://%s/Autodiscover/Autodiscover.xml' % hostname + url = f'https://{hostname}/Autodiscover/Autodiscover.xml' log.info('Step 1: Trying autodiscover on %r with email %r', url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: @@ -1053,7 +1002,7 @@

    Classes

    :param hostname: :return: """ - url = 'https://autodiscover.%s/Autodiscover/Autodiscover.xml' % hostname + url = f'https://autodiscover.{hostname}/Autodiscover/Autodiscover.xml' log.info('Step 2: Trying autodiscover on %r with email %r', url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: @@ -1076,20 +1025,23 @@

    Classes

    :param hostname: :return: """ - url = 'http://autodiscover.%s/Autodiscover/Autodiscover.xml' % hostname + url = f'http://autodiscover.{hostname}/Autodiscover/Autodiscover.xml' log.info('Step 3: Trying autodiscover on %r with email %r', url, self.email) try: _, r = self._get_unauthenticated_response(url=url, method='get') except TransportError: - r = DummyResponse(url=url, headers={}, request_headers={}) + r = DummyResponse(url=url) if r.status_code in (301, 302) and 'location' in r.headers: redirect_url = get_redirect_url(r) if self._redirect_url_is_valid(url=redirect_url): is_valid_response, ad = self._attempt_response(url=redirect_url) if is_valid_response: return self._step_5(ad=ad) + log.debug('Got invalid response') return self._step_4(hostname=hostname) + log.debug('Got invalid redirect URL') return self._step_4(hostname=hostname) + log.debug('Got no redirect URL') return self._step_4(hostname=hostname) def _step_4(self, hostname): @@ -1109,7 +1061,7 @@

    Classes

    :param hostname: :return: """ - dns_hostname = '_autodiscover._tcp.%s' % hostname + dns_hostname = f'_autodiscover._tcp.{hostname}' log.info('Step 4: Trying autodiscover on %r with email %r', dns_hostname, self.email) srv_records = self._get_srv_records(dns_hostname) try: @@ -1118,12 +1070,14 @@

    Classes

    srv_host = None if not srv_host: return self._step_6() - redirect_url = 'https://%s/Autodiscover/Autodiscover.xml' % srv_host + redirect_url = f'https://{srv_host}/Autodiscover/Autodiscover.xml' if self._redirect_url_is_valid(url=redirect_url): is_valid_response, ad = self._attempt_response(url=redirect_url) if is_valid_response: return self._step_5(ad=ad) + log.debug('Got invalid response') return self._step_6() + log.debug('Got invalid redirect URL') return self._step_6() def _step_5(self, ad): @@ -1152,13 +1106,14 @@

    Classes

    if ad_response.redirect_url: log.debug('Got a redirect URL: %s', ad_response.redirect_url) # We are diverging a bit from the protocol here. We will never get an HTTP 302 since earlier steps already - # followed the redirects where possible. Instead, we handle retirect responses here. + # followed the redirects where possible. Instead, we handle redirect responses here. if self._redirect_url_is_valid(url=ad_response.redirect_url): is_valid_response, ad = self._attempt_response(url=ad_response.redirect_url) if is_valid_response: return self._step_5(ad=ad) + log.debug('Got invalid response') return self._step_6() - log.debug('Invalid redirect URL: %s', ad_response.redirect_url) + log.debug('Invalid redirect URL') return self._step_6() # This could be an email redirect. Let outer layer handle this return ad_response @@ -1169,8 +1124,8 @@

    Classes

    future requests. """ raise AutoDiscoverFailed( - 'All steps in the autodiscover protocol failed for email %r. If you think this is an error, consider doing ' - 'an official test at https://testconnectivity.microsoft.com' % self.email) + f'All steps in the autodiscover protocol failed for email {self.email}. If you think this is an error, ' + f'consider doing an official test at https://testconnectivity.microsoft.com')

    Class variables

    @@ -1270,6 +1225,7 @@

    Methods

    ad_response = self._step_1(hostname=domain) else: # This will cache the result + log.debug('Cache miss for key %s', cache_key) ad_response = self._step_1(hostname=domain) log.debug('Released autodiscover_cache_lock') @@ -1309,10 +1265,7 @@

    Methods

    self.srv = srv def __eq__(self, other): - for k in self.__dict__: - if getattr(self, k) != getattr(other, k): - return False - return True + return all(getattr(self, k) == getattr(other, k) for k in self.__dict__)
    diff --git a/docs/exchangelib/autodiscover/index.html b/docs/exchangelib/autodiscover/index.html index 4b434692..bdb04ad8 100644 --- a/docs/exchangelib/autodiscover/index.html +++ b/docs/exchangelib/autodiscover/index.html @@ -111,9 +111,12 @@

    Functions

    Expand source code
    def discover(email, credentials=None, auth_type=None, retry_policy=None):
    -    return Autodiscovery(
    -        email=email, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy
    -    ).discover()
    + ad_response, protocol = Autodiscovery( + email=email, credentials=credentials + ).discover() + protocol.config.auth_typ = auth_type + protocol.config.retry_policy = retry_policy + return ad_response, protocol
    @@ -293,12 +296,9 @@

    Methods

    TIMEOUT = 10 # Seconds def __str__(self): - return '''\ -Autodiscover endpoint: %s -Auth type: %s''' % ( - self.service_endpoint, - self.auth_type, - ) + return f'''\ +Autodiscover endpoint: {self.service_endpoint} +Auth type: {self.auth_type}'''

    Ancestors

      @@ -324,7 +324,7 @@

      Inherited members

      class Autodiscovery -(email, credentials=None, auth_type=None, retry_policy=None) +(email, credentials=None)

      Autodiscover is a Microsoft protocol for automatically getting the endpoint of the Exchange server and other @@ -345,10 +345,6 @@

      Inherited members

      implementation, start by doing an official test at https://testconnectivity.microsoft.com

      :param email: The email address to autodiscover :param credentials: Credentials with authorization to make autodiscover lookups for this Account -(Default value = None) -:param auth_type: -(Default value = None) -:param retry_policy: (Default value = None)

      @@ -391,19 +387,15 @@

      Inherited members

      'timeout': AutodiscoverProtocol.TIMEOUT, } - def __init__(self, email, credentials=None, auth_type=None, retry_policy=None): + def __init__(self, email, credentials=None): """ :param email: The email address to autodiscover :param credentials: Credentials with authorization to make autodiscover lookups for this Account (Default value = None) - :param auth_type: (Default value = None) - :param retry_policy: (Default value = None) """ self.email = email self.credentials = credentials - self.auth_type = auth_type # The auth type that the resulting protocol instance should have - self.retry_policy = retry_policy # The retry policy that the resulting protocol instance should have self._urls_visited = [] # Collects HTTP and Autodiscover redirects self._redirect_count = 0 self._emails_visited = [] # Collects Autodiscover email redirects @@ -430,6 +422,7 @@

      Inherited members

      ad_response = self._step_1(hostname=domain) else: # This will cache the result + log.debug('Cache miss for key %s', cache_key) ad_response = self._step_1(hostname=domain) log.debug('Released autodiscover_cache_lock') @@ -467,52 +460,33 @@

      Inherited members

      return resolver def _build_response(self, ad_response): - ews_url = ad_response.ews_url - if not ews_url: - raise AutoDiscoverFailed("Response is missing an 'ews_url' value") if not ad_response.autodiscover_smtp_address: # Autodiscover does not always return an email address. In that case, the requesting email should be used ad_response.user.autodiscover_smtp_address = self.email - # Get the server version. Not all protocol entries have a server version so we cheat a bit and also look at the - # other ones that point to the same endpoint. - for protocol in ad_response.account.protocols: - if not protocol.ews_url or not protocol.server_version: - continue - if protocol.ews_url.lower() == ews_url.lower(): - version = Version(build=protocol.server_version) - break - else: - version = None - # We may not want to use the auth_package hints in the AD response. It could be incorrect and we can just guess. protocol = Protocol( config=Configuration( - service_endpoint=ews_url, + service_endpoint=ad_response.protocol.ews_url, credentials=self.credentials, - version=version, - auth_type=self.auth_type, - retry_policy=self.retry_policy, + version=ad_response.version, + auth_type=ad_response.protocol.auth_type, ) ) return ad_response, protocol def _quick(self, protocol): - # Reset auth type and retry policy if we requested non-default values - if self.auth_type: - protocol.config.auth_type = self.auth_type - if self.retry_policy: - protocol.config.retry_policy = self.retry_policy try: r = self._get_authenticated_response(protocol=protocol) except TransportError as e: - raise AutoDiscoverFailed('Response error: %s' % e) + raise AutoDiscoverFailed(f'Response error: {e}') if r.status_code == 200: try: ad = Autodiscover.from_bytes(bytes_content=r.content) + except ParseError as e: + raise AutoDiscoverFailed(f'Invalid response: {e}') + else: return self._step_5(ad=ad) - except ValueError as e: - raise AutoDiscoverFailed('Invalid response: %s' % e) - raise AutoDiscoverFailed('Invalid response code: %s' % r.status_code) + raise AutoDiscoverFailed(f'Invalid response code: {r.status_code}') def _redirect_url_is_valid(self, url): """Three separate responses can be “Redirect responses”: @@ -563,7 +537,7 @@

      Inherited members

      if not self._is_valid_hostname(hostname): # 'requests' is really bad at reporting that a hostname cannot be resolved. Let's check this separately. # Don't retry on DNS errors. They will most likely be persistent. - raise TransportError('%r has no DNS entry' % hostname) + raise TransportError(f'{hostname!r} has no DNS entry') kwargs = dict( url=url, headers=DEFAULT_HEADERS.copy(), allow_redirects=False, timeout=AutodiscoverProtocol.TIMEOUT @@ -584,7 +558,7 @@

      Inherited members

      # Don't retry on TLS errors. They will most likely be persistent. raise TransportError(str(e)) except CONNECTION_ERRORS as e: - r = DummyResponse(url=url, headers={}, request_headers=kwargs['headers']) + r = DummyResponse(url=url, request_headers=kwargs['headers']) total_wait = time.monotonic() - t_start if self.INITIAL_RETRY_POLICY.may_retry_on_error(response=r, wait=total_wait): log.debug("Connection error on URL %s (retry %s, error: %s). Cool down", url, retry, e) @@ -630,8 +604,7 @@

      Inherited members

      # isn't necessarily the right endpoint to use. raise TransportError(str(e)) except RedirectError as e: - r = DummyResponse(url=protocol.service_endpoint, headers={'location': e.url}, request_headers=None, - status_code=302) + r = DummyResponse(url=protocol.service_endpoint, headers={'location': e.url}, status_code=302) return r def _attempt_response(self, url): @@ -644,11 +617,6 @@

      Inherited members

      log.debug('Attempting to get a valid response from %s', url) try: auth_type, r = self._get_unauthenticated_response(url=url) - if isinstance(self.credentials, OAuth2Credentials): - # This type of credentials *must* use the OAuth auth type - auth_type = OAUTH2 - elif self.credentials is None and auth_type in CREDENTIALS_REQUIRED: - raise ValueError('Auth type %r was detected but no credentials were provided' % auth_type) ad_protocol = AutodiscoverProtocol( config=Configuration( service_endpoint=url, @@ -671,21 +639,22 @@

      Inherited members

      if r.status_code == 200: try: ad = Autodiscover.from_bytes(bytes_content=r.content) + except ParseError as e: + log.debug('Invalid response: %s', e) + else: # We got a valid response. Unless this is a URL redirect response, we cache the result if ad.response is None or not ad.response.redirect_url: cache_key = self._cache_key log.debug('Adding cache entry for key %s: %s', cache_key, ad_protocol.service_endpoint) autodiscover_cache[cache_key] = ad_protocol return True, ad - except ValueError as e: - log.debug('Invalid response: %s', e) return False, None def _is_valid_hostname(self, hostname): log.debug('Checking if %s can be looked up in DNS', hostname) try: self.resolver.resolve(hostname) - except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): + except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.name.EmptyLabel): return False return True @@ -705,7 +674,7 @@

      Inherited members

      log.debug('Attempting to get SRV records for %s', hostname) records = [] try: - answers = self.resolver.resolve('%s.' % hostname, 'SRV') + answers = self.resolver.resolve(f'{hostname}.', 'SRV') except (dns.resolver.NoNameservers, dns.resolver.NoAnswer, dns.resolver.NXDOMAIN) as e: log.debug('DNS lookup failure: %s', e) return records @@ -730,7 +699,7 @@

      Inherited members

      :param hostname: :return: """ - url = 'https://%s/Autodiscover/Autodiscover.xml' % hostname + url = f'https://{hostname}/Autodiscover/Autodiscover.xml' log.info('Step 1: Trying autodiscover on %r with email %r', url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: @@ -746,7 +715,7 @@

      Inherited members

      :param hostname: :return: """ - url = 'https://autodiscover.%s/Autodiscover/Autodiscover.xml' % hostname + url = f'https://autodiscover.{hostname}/Autodiscover/Autodiscover.xml' log.info('Step 2: Trying autodiscover on %r with email %r', url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: @@ -769,20 +738,23 @@

      Inherited members

      :param hostname: :return: """ - url = 'http://autodiscover.%s/Autodiscover/Autodiscover.xml' % hostname + url = f'http://autodiscover.{hostname}/Autodiscover/Autodiscover.xml' log.info('Step 3: Trying autodiscover on %r with email %r', url, self.email) try: _, r = self._get_unauthenticated_response(url=url, method='get') except TransportError: - r = DummyResponse(url=url, headers={}, request_headers={}) + r = DummyResponse(url=url) if r.status_code in (301, 302) and 'location' in r.headers: redirect_url = get_redirect_url(r) if self._redirect_url_is_valid(url=redirect_url): is_valid_response, ad = self._attempt_response(url=redirect_url) if is_valid_response: return self._step_5(ad=ad) + log.debug('Got invalid response') return self._step_4(hostname=hostname) + log.debug('Got invalid redirect URL') return self._step_4(hostname=hostname) + log.debug('Got no redirect URL') return self._step_4(hostname=hostname) def _step_4(self, hostname): @@ -802,7 +774,7 @@

      Inherited members

      :param hostname: :return: """ - dns_hostname = '_autodiscover._tcp.%s' % hostname + dns_hostname = f'_autodiscover._tcp.{hostname}' log.info('Step 4: Trying autodiscover on %r with email %r', dns_hostname, self.email) srv_records = self._get_srv_records(dns_hostname) try: @@ -811,12 +783,14 @@

      Inherited members

      srv_host = None if not srv_host: return self._step_6() - redirect_url = 'https://%s/Autodiscover/Autodiscover.xml' % srv_host + redirect_url = f'https://{srv_host}/Autodiscover/Autodiscover.xml' if self._redirect_url_is_valid(url=redirect_url): is_valid_response, ad = self._attempt_response(url=redirect_url) if is_valid_response: return self._step_5(ad=ad) + log.debug('Got invalid response') return self._step_6() + log.debug('Got invalid redirect URL') return self._step_6() def _step_5(self, ad): @@ -845,13 +819,14 @@

      Inherited members

      if ad_response.redirect_url: log.debug('Got a redirect URL: %s', ad_response.redirect_url) # We are diverging a bit from the protocol here. We will never get an HTTP 302 since earlier steps already - # followed the redirects where possible. Instead, we handle retirect responses here. + # followed the redirects where possible. Instead, we handle redirect responses here. if self._redirect_url_is_valid(url=ad_response.redirect_url): is_valid_response, ad = self._attempt_response(url=ad_response.redirect_url) if is_valid_response: return self._step_5(ad=ad) + log.debug('Got invalid response') return self._step_6() - log.debug('Invalid redirect URL: %s', ad_response.redirect_url) + log.debug('Invalid redirect URL') return self._step_6() # This could be an email redirect. Let outer layer handle this return ad_response @@ -862,8 +837,8 @@

      Inherited members

      future requests. """ raise AutoDiscoverFailed( - 'All steps in the autodiscover protocol failed for email %r. If you think this is an error, consider doing ' - 'an official test at https://testconnectivity.microsoft.com' % self.email) + f'All steps in the autodiscover protocol failed for email {self.email}. If you think this is an error, ' + f'consider doing an official test at https://testconnectivity.microsoft.com')

      Class variables

      @@ -963,6 +938,7 @@

      Methods

      ad_response = self._step_1(hostname=domain) else: # This will cache the result + log.debug('Cache miss for key %s', cache_key) ad_response = self._step_1(hostname=domain) log.debug('Released autodiscover_cache_lock') diff --git a/docs/exchangelib/autodiscover/properties.html b/docs/exchangelib/autodiscover/properties.html index f25380a3..a5955442 100644 --- a/docs/exchangelib/autodiscover/properties.html +++ b/docs/exchangelib/autodiscover/properties.html @@ -33,6 +33,7 @@

      Module exchangelib.autodiscover.properties

      from ..transport import DEFAULT_ENCODING, NOAUTH, NTLM, BASIC, GSSAPI, SSPI, CBA from ..util import create_element, add_xml_child, to_xml, is_xml, xml_to_str, AUTODISCOVER_REQUEST_NS, \ AUTODISCOVER_BASE_NS, AUTODISCOVER_RESPONSE_NS as RNS, ParseError +from ..version import Version class AutodiscoverBase(EWSElement): @@ -171,6 +172,8 @@

      Module exchangelib.autodiscover.properties

      # Translates 'auth_package' value to our own 'auth_type' enum vals if not self.auth_required: return NOAUTH + if not self.auth_package: + return None return { # Missing in list are DIGEST and OAUTH2 'basic': BASIC, @@ -181,7 +184,7 @@

      Module exchangelib.autodiscover.properties

      'negotiate': SSPI, # Unsure about this one 'nego2': GSSAPI, 'anonymous': NOAUTH, # Seen in some docs even though it's not mentioned in MSDN - }.get(self.auth_package.lower(), NTLM) # Default to NTLM + }.get(self.auth_package.lower()) class Error(EWSElement): @@ -220,7 +223,7 @@

      Module exchangelib.autodiscover.properties

      @classmethod def from_xml(cls, elem, account): kwargs = {} - public_folder_information = elem.find('{%s}PublicFolderInformation' % cls.NAMESPACE) + public_folder_information = elem.find(f'{{{cls.NAMESPACE}}}PublicFolderInformation') for f in cls.FIELDS: if f.name == 'public_folder_smtp_address': if public_folder_information is None: @@ -269,24 +272,35 @@

      Module exchangelib.autodiscover.properties

      return None @property - def ews_url(self): - """Return the EWS URL contained in the response. + def version(self): + # Get the server version. Not all protocol entries have a server version so we cheat a bit and also look at the + # other ones that point to the same endpoint. + ews_url = self.protocol.ews_url + for protocol in self.account.protocols: + if not protocol.ews_url or not protocol.server_version: + continue + if protocol.ews_url.lower() == ews_url.lower(): + return Version(build=protocol.server_version) + return None + + @property + def protocol(self): + """Return the protocol containing an EWS URL. A response may contain a number of possible protocol types. EXPR is meant for EWS. See https://techcommunity.microsoft.com/t5/blogs/blogarticleprintpage/blog-id/Exchange/article-id/16 We allow fallback to EXCH if EXPR is not available, to support installations where EXPR is not available. - Additionally, some responses may contain and EXPR with no EWS URL. In that case, return the URL from EXCH, if - available. + Additionally, some responses may contain an EXPR with no EWS URL. In that case, return EXCH, if available. """ protocols = {p.type: p for p in self.account.protocols if p.ews_url} if Protocol.EXPR in protocols: - return protocols[Protocol.EXPR].ews_url + return protocols[Protocol.EXPR] if Protocol.EXCH in protocols: - return protocols[Protocol.EXCH].ews_url + return protocols[Protocol.EXCH] raise ValueError( - 'No EWS URL found in any of the available protocols: %s' % [str(p) for p in self.account.protocols] + f'No EWS URL found in any of the available protocols: {[str(p) for p in self.account.protocols]}' ) @@ -322,14 +336,11 @@

      Module exchangelib.autodiscover.properties

      :param bytes_content: :return: """ - if not is_xml(bytes_content) and not is_xml(bytes_content, expected_prefix=b'<Autodiscover '): - raise ValueError('Response is not XML: %s' % bytes_content) - try: - root = to_xml(bytes_content).getroot() - except ParseError: - raise ValueError('Error parsing XML: %s' % bytes_content) + if not is_xml(bytes_content): + raise ParseError(f'Response is not XML: {bytes_content}', '<not from file>', -1, 0) + root = to_xml(bytes_content).getroot() # May raise ParseError if root.tag != cls.response_tag(): - raise ValueError('Unknown root element in XML: %s' % bytes_content) + raise ParseError(f'Unknown root element in XML: {bytes_content}', '<not from file>', -1, 0) return cls.from_xml(elem=root, account=None) def raise_errors(self): @@ -339,9 +350,9 @@

      Module exchangelib.autodiscover.properties

      message = self.error_response.error.message if message in ('The e-mail address cannot be found.', "The email address can't be found."): raise ErrorNonExistentMailbox('The SMTP address has no mailbox associated with it') - raise AutoDiscoverFailed('Unknown error %s: %s' % (errorcode, message)) + raise AutoDiscoverFailed(f'Unknown error {errorcode}: {message}') except AttributeError: - raise AutoDiscoverFailed('Unknown autodiscover error response: %s' % self) + raise AutoDiscoverFailed(f'Unknown autodiscover error response: {self.error_response}') @staticmethod def payload(email): @@ -396,7 +407,7 @@

      Classes

      @classmethod def from_xml(cls, elem, account): kwargs = {} - public_folder_information = elem.find('{%s}PublicFolderInformation' % cls.NAMESPACE) + public_folder_information = elem.find(f'{{{cls.NAMESPACE}}}PublicFolderInformation') for f in cls.FIELDS: if f.name == 'public_folder_smtp_address': if public_folder_information is None: @@ -453,7 +464,7 @@

      Static methods

      @classmethod
       def from_xml(cls, elem, account):
           kwargs = {}
      -    public_folder_information = elem.find('{%s}PublicFolderInformation' % cls.NAMESPACE)
      +    public_folder_information = elem.find(f'{{{cls.NAMESPACE}}}PublicFolderInformation')
           for f in cls.FIELDS:
               if f.name == 'public_folder_smtp_address':
                   if public_folder_information is None:
      @@ -587,14 +598,11 @@ 

      Inherited members

      :param bytes_content: :return: """ - if not is_xml(bytes_content) and not is_xml(bytes_content, expected_prefix=b'<Autodiscover '): - raise ValueError('Response is not XML: %s' % bytes_content) - try: - root = to_xml(bytes_content).getroot() - except ParseError: - raise ValueError('Error parsing XML: %s' % bytes_content) + if not is_xml(bytes_content): + raise ParseError(f'Response is not XML: {bytes_content}', '<not from file>', -1, 0) + root = to_xml(bytes_content).getroot() # May raise ParseError if root.tag != cls.response_tag(): - raise ValueError('Unknown root element in XML: %s' % bytes_content) + raise ParseError(f'Unknown root element in XML: {bytes_content}', '<not from file>', -1, 0) return cls.from_xml(elem=root, account=None) def raise_errors(self): @@ -604,9 +612,9 @@

      Inherited members

      message = self.error_response.error.message if message in ('The e-mail address cannot be found.', "The email address can't be found."): raise ErrorNonExistentMailbox('The SMTP address has no mailbox associated with it') - raise AutoDiscoverFailed('Unknown error %s: %s' % (errorcode, message)) + raise AutoDiscoverFailed(f'Unknown error {errorcode}: {message}') except AttributeError: - raise AutoDiscoverFailed('Unknown autodiscover error response: %s' % self) + raise AutoDiscoverFailed(f'Unknown autodiscover error response: {self.error_response}') @staticmethod def payload(email): @@ -659,14 +667,11 @@

      Static methods

      :param bytes_content: :return: """ - if not is_xml(bytes_content) and not is_xml(bytes_content, expected_prefix=b'<Autodiscover '): - raise ValueError('Response is not XML: %s' % bytes_content) - try: - root = to_xml(bytes_content).getroot() - except ParseError: - raise ValueError('Error parsing XML: %s' % bytes_content) + if not is_xml(bytes_content): + raise ParseError(f'Response is not XML: {bytes_content}', '<not from file>', -1, 0) + root = to_xml(bytes_content).getroot() # May raise ParseError if root.tag != cls.response_tag(): - raise ValueError('Unknown root element in XML: %s' % bytes_content) + raise ParseError(f'Unknown root element in XML: {bytes_content}', '<not from file>', -1, 0) return cls.from_xml(elem=root, account=None)
      @@ -720,9 +725,9 @@

      Methods

      message = self.error_response.error.message if message in ('The e-mail address cannot be found.', "The email address can't be found."): raise ErrorNonExistentMailbox('The SMTP address has no mailbox associated with it') - raise AutoDiscoverFailed('Unknown error %s: %s' % (errorcode, message)) + raise AutoDiscoverFailed(f'Unknown error {errorcode}: {message}') except AttributeError: - raise AutoDiscoverFailed('Unknown autodiscover error response: %s' % self) + raise AutoDiscoverFailed(f'Unknown autodiscover error response: {self.error_response}') @@ -1284,6 +1289,8 @@

      Inherited members

      # Translates 'auth_package' value to our own 'auth_type' enum vals if not self.auth_required: return NOAUTH + if not self.auth_package: + return None return { # Missing in list are DIGEST and OAUTH2 'basic': BASIC, @@ -1294,7 +1301,7 @@

      Inherited members

      'negotiate': SSPI, # Unsure about this one 'nego2': GSSAPI, 'anonymous': NOAUTH, # Seen in some docs even though it's not mentioned in MSDN - }.get(self.auth_package.lower(), NTLM) # Default to NTLM + }.get(self.auth_package.lower())

      Ancestors

        @@ -1335,6 +1342,8 @@

        Instance variables

        # Translates 'auth_package' value to our own 'auth_type' enum vals if not self.auth_required: return NOAUTH + if not self.auth_package: + return None return { # Missing in list are DIGEST and OAUTH2 'basic': BASIC, @@ -1345,7 +1354,7 @@

        Instance variables

        'negotiate': SSPI, # Unsure about this one 'nego2': GSSAPI, 'anonymous': NOAUTH, # Seen in some docs even though it's not mentioned in MSDN - }.get(self.auth_package.lower(), NTLM) # Default to NTLM + }.get(self.auth_package.lower())
        var cert_principal_name
        @@ -1576,24 +1585,35 @@

        Inherited members

        return None @property - def ews_url(self): - """Return the EWS URL contained in the response. + def version(self): + # Get the server version. Not all protocol entries have a server version so we cheat a bit and also look at the + # other ones that point to the same endpoint. + ews_url = self.protocol.ews_url + for protocol in self.account.protocols: + if not protocol.ews_url or not protocol.server_version: + continue + if protocol.ews_url.lower() == ews_url.lower(): + return Version(build=protocol.server_version) + return None + + @property + def protocol(self): + """Return the protocol containing an EWS URL. A response may contain a number of possible protocol types. EXPR is meant for EWS. See https://techcommunity.microsoft.com/t5/blogs/blogarticleprintpage/blog-id/Exchange/article-id/16 We allow fallback to EXCH if EXPR is not available, to support installations where EXPR is not available. - Additionally, some responses may contain and EXPR with no EWS URL. In that case, return the URL from EXCH, if - available. + Additionally, some responses may contain an EXPR with no EWS URL. In that case, return EXCH, if available. """ protocols = {p.type: p for p in self.account.protocols if p.ews_url} if Protocol.EXPR in protocols: - return protocols[Protocol.EXPR].ews_url + return protocols[Protocol.EXPR] if Protocol.EXCH in protocols: - return protocols[Protocol.EXCH].ews_url + return protocols[Protocol.EXCH] raise ValueError( - 'No EWS URL found in any of the available protocols: %s' % [str(p) for p in self.account.protocols] + f'No EWS URL found in any of the available protocols: {[str(p) for p in self.account.protocols]}' )

        Ancestors

        @@ -1636,37 +1656,35 @@

        Instance variables

        return None -
        var ews_url
        +
        var protocol
        -

        Return the EWS URL contained in the response.

        +

        Return the protocol containing an EWS URL.

        A response may contain a number of possible protocol types. EXPR is meant for EWS. See https://techcommunity.microsoft.com/t5/blogs/blogarticleprintpage/blog-id/Exchange/article-id/16

        We allow fallback to EXCH if EXPR is not available, to support installations where EXPR is not available.

        -

        Additionally, some responses may contain and EXPR with no EWS URL. In that case, return the URL from EXCH, if -available.

        +

        Additionally, some responses may contain an EXPR with no EWS URL. In that case, return EXCH, if available.

        Expand source code
        @property
        -def ews_url(self):
        -    """Return the EWS URL contained in the response.
        +def protocol(self):
        +    """Return the protocol containing an EWS URL.
         
             A response may contain a number of possible protocol types. EXPR is meant for EWS. See
             https://techcommunity.microsoft.com/t5/blogs/blogarticleprintpage/blog-id/Exchange/article-id/16
         
             We allow fallback to EXCH if EXPR is not available, to support installations where EXPR is not available.
         
        -    Additionally, some responses may contain and EXPR with no EWS URL. In that case, return the URL from EXCH, if
        -    available.
        +    Additionally, some responses may contain an EXPR with no EWS URL. In that case, return EXCH, if available.
             """
             protocols = {p.type: p for p in self.account.protocols if p.ews_url}
             if Protocol.EXPR in protocols:
        -        return protocols[Protocol.EXPR].ews_url
        +        return protocols[Protocol.EXPR]
             if Protocol.EXCH in protocols:
        -        return protocols[Protocol.EXCH].ews_url
        +        return protocols[Protocol.EXCH]
             raise ValueError(
        -        'No EWS URL found in any of the available protocols: %s' % [str(p) for p in self.account.protocols]
        +        f'No EWS URL found in any of the available protocols: {[str(p) for p in self.account.protocols]}'
             )
        @@ -1708,6 +1726,26 @@

        Instance variables

        +
        var version
        +
        +
        +
        + +Expand source code + +
        @property
        +def version(self):
        +    # Get the server version. Not all protocol entries have a server version so we cheat a bit and also look at the
        +    # other ones that point to the same endpoint.
        +    ews_url = self.protocol.ews_url
        +    for protocol in self.account.protocols:
        +        if not protocol.ews_url or not protocol.server_version:
        +            continue
        +        if protocol.ews_url.lower() == ews_url.lower():
        +            return Version(build=protocol.server_version)
        +    return None
        +
        +

        Inherited members

      • diff --git a/docs/exchangelib/autodiscover/protocol.html b/docs/exchangelib/autodiscover/protocol.html index dafd886a..ea35a2b0 100644 --- a/docs/exchangelib/autodiscover/protocol.html +++ b/docs/exchangelib/autodiscover/protocol.html @@ -35,12 +35,9 @@

        Module exchangelib.autodiscover.protocol

        TIMEOUT = 10 # Seconds def __str__(self): - return '''\ -Autodiscover endpoint: %s -Auth type: %s''' % ( - self.service_endpoint, - self.auth_type, - ) + return f'''\ +Autodiscover endpoint: {self.service_endpoint} +Auth type: {self.auth_type}'''
        @@ -68,12 +65,9 @@

        Classes

        TIMEOUT = 10 # Seconds def __str__(self): - return '''\ -Autodiscover endpoint: %s -Auth type: %s''' % ( - self.service_endpoint, - self.auth_type, - ) + return f'''\ +Autodiscover endpoint: {self.service_endpoint} +Auth type: {self.auth_type}'''

        Ancestors

          diff --git a/docs/exchangelib/configuration.html b/docs/exchangelib/configuration.html index b8eacd5c..015f089f 100644 --- a/docs/exchangelib/configuration.html +++ b/docs/exchangelib/configuration.html @@ -30,14 +30,21 @@

          Module exchangelib.configuration

          from cached_property import threaded_cached_property -from .credentials import BaseCredentials, OAuth2Credentials +from .errors import InvalidEnumValue, InvalidTypeError +from .credentials import BaseCredentials, OAuth2Credentials, OAuth2AuthorizationCodeCredentials from .protocol import RetryPolicy, FailFast -from .transport import AUTH_TYPE_MAP, OAUTH2 +from .transport import AUTH_TYPE_MAP, OAUTH2, CREDENTIALS_REQUIRED from .util import split_url from .version import Version log = logging.getLogger(__name__) +DEFAULT_AUTH_TYPE = { + # This type of credentials *must* use the OAuth auth type + OAuth2Credentials: OAUTH2, + OAuth2AuthorizationCodeCredentials: OAUTH2, +} + class Configuration: """Contains information needed to create an authenticated connection to an EWS endpoint. @@ -70,26 +77,27 @@

          Module exchangelib.configuration

          def __init__(self, credentials=None, server=None, service_endpoint=None, auth_type=None, version=None, retry_policy=None, max_connections=None): if not isinstance(credentials, (BaseCredentials, type(None))): - raise ValueError("'credentials' %r must be a Credentials instance" % credentials) - if isinstance(credentials, OAuth2Credentials) and auth_type is None: - # This type of credentials *must* use the OAuth auth type - auth_type = OAUTH2 + raise InvalidTypeError('credentials', credentials, BaseCredentials) + if auth_type is None: + # Set a default auth type for the credentials where this makes sense + auth_type = DEFAULT_AUTH_TYPE.get(type(credentials)) + elif credentials is None and auth_type in CREDENTIALS_REQUIRED: + raise ValueError(f'Auth type {auth_type!r} was detected but no credentials were provided') if server and service_endpoint: raise AttributeError("Only one of 'server' or 'service_endpoint' must be provided") if auth_type is not None and auth_type not in AUTH_TYPE_MAP: - raise ValueError("'auth_type' %r must be one of %s" - % (auth_type, ', '.join("'%s'" % k for k in sorted(AUTH_TYPE_MAP)))) + raise InvalidEnumValue('auth_type', auth_type, AUTH_TYPE_MAP) if not retry_policy: retry_policy = FailFast() if not isinstance(version, (Version, type(None))): - raise ValueError("'version' %r must be a Version instance" % version) + raise InvalidTypeError('version', version, Version) if not isinstance(retry_policy, RetryPolicy): - raise ValueError("'retry_policy' %r must be a RetryPolicy instance" % retry_policy) + raise InvalidTypeError('retry_policy', retry_policy, RetryPolicy) if not isinstance(max_connections, (int, type(None))): - raise ValueError("'max_connections' must be an integer") + raise InvalidTypeError('max_connections', max_connections, int) self._credentials = credentials if server: - self.service_endpoint = 'https://%s/EWS/Exchange.asmx' % server + self.service_endpoint = f'https://{server}/EWS/Exchange.asmx' else: self.service_endpoint = service_endpoint self.auth_type = auth_type @@ -109,9 +117,10 @@

          Module exchangelib.configuration

          return split_url(self.service_endpoint)[1] def __repr__(self): - return self.__class__.__name__ + '(%s)' % ', '.join('%s=%r' % (k, getattr(self, k)) for k in ( + args_str = ', '.join(f'{k}={getattr(self, k)!r}' for k in ( 'credentials', 'service_endpoint', 'auth_type', 'version', 'retry_policy' - )) + )) + return f'{self.__class__.__name__}({args_str})'
        @@ -182,26 +191,27 @@

        Classes

        def __init__(self, credentials=None, server=None, service_endpoint=None, auth_type=None, version=None, retry_policy=None, max_connections=None): if not isinstance(credentials, (BaseCredentials, type(None))): - raise ValueError("'credentials' %r must be a Credentials instance" % credentials) - if isinstance(credentials, OAuth2Credentials) and auth_type is None: - # This type of credentials *must* use the OAuth auth type - auth_type = OAUTH2 + raise InvalidTypeError('credentials', credentials, BaseCredentials) + if auth_type is None: + # Set a default auth type for the credentials where this makes sense + auth_type = DEFAULT_AUTH_TYPE.get(type(credentials)) + elif credentials is None and auth_type in CREDENTIALS_REQUIRED: + raise ValueError(f'Auth type {auth_type!r} was detected but no credentials were provided') if server and service_endpoint: raise AttributeError("Only one of 'server' or 'service_endpoint' must be provided") if auth_type is not None and auth_type not in AUTH_TYPE_MAP: - raise ValueError("'auth_type' %r must be one of %s" - % (auth_type, ', '.join("'%s'" % k for k in sorted(AUTH_TYPE_MAP)))) + raise InvalidEnumValue('auth_type', auth_type, AUTH_TYPE_MAP) if not retry_policy: retry_policy = FailFast() if not isinstance(version, (Version, type(None))): - raise ValueError("'version' %r must be a Version instance" % version) + raise InvalidTypeError('version', version, Version) if not isinstance(retry_policy, RetryPolicy): - raise ValueError("'retry_policy' %r must be a RetryPolicy instance" % retry_policy) + raise InvalidTypeError('retry_policy', retry_policy, RetryPolicy) if not isinstance(max_connections, (int, type(None))): - raise ValueError("'max_connections' must be an integer") + raise InvalidTypeError('max_connections', max_connections, int) self._credentials = credentials if server: - self.service_endpoint = 'https://%s/EWS/Exchange.asmx' % server + self.service_endpoint = f'https://{server}/EWS/Exchange.asmx' else: self.service_endpoint = service_endpoint self.auth_type = auth_type @@ -221,9 +231,10 @@

        Classes

        return split_url(self.service_endpoint)[1] def __repr__(self): - return self.__class__.__name__ + '(%s)' % ', '.join('%s=%r' % (k, getattr(self, k)) for k in ( + args_str = ', '.join(f'{k}={getattr(self, k)!r}' for k in ( 'credentials', 'service_endpoint', 'auth_type', 'version', 'retry_policy' - )) + )) + return f'{self.__class__.__name__}({args_str})'

        Instance variables

        diff --git a/docs/exchangelib/credentials.html b/docs/exchangelib/credentials.html index bad277b3..ebdaabe9 100644 --- a/docs/exchangelib/credentials.html +++ b/docs/exchangelib/credentials.html @@ -26,7 +26,7 @@

        Module exchangelib.credentials

        Implements an Exchange user object and access types. Exchange provides two different ways of granting access for a login to a specific account. Impersonation is used mainly for service accounts that connect via EWS. Delegate is used for ad-hoc access e.g. granted manually by the user. -See http://blogs.msdn.com/b/exchangedev/archive/2009/06/15/exchange-impersonation-vs-delegate-access.aspx

        +See https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/impersonation-and-ews-in-exchange

        Expand source code @@ -35,12 +35,16 @@

        Module exchangelib.credentials

        Implements an Exchange user object and access types. Exchange provides two different ways of granting access for a login to a specific account. Impersonation is used mainly for service accounts that connect via EWS. Delegate is used for ad-hoc access e.g. granted manually by the user. -See http://blogs.msdn.com/b/exchangedev/archive/2009/06/15/exchange-impersonation-vs-delegate-access.aspx +See https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/impersonation-and-ews-in-exchange """ import abc import logging from threading import RLock +from oauthlib.oauth2 import OAuth2Token + +from .errors import InvalidTypeError + log = logging.getLogger(__name__) IMPERSONATION = 'impersonation' @@ -76,12 +80,7 @@

        Module exchangelib.credentials

        return (getattr(self, k) for k in self.__dict__ if k != '_lock') def __eq__(self, other): - for k in self.__dict__: - if k == '_lock': - continue - if getattr(self, k) != getattr(other, k): - return False - return True + return all(getattr(self, k) == getattr(other, k) for k in self.__dict__ if k != '_lock') def __hash__(self): return hash(tuple(self._get_hash_values())) @@ -173,7 +172,7 @@

        Module exchangelib.credentials

        """ # Ensure we don't update the object in the middle of a new session being created, which could cause a race. if not isinstance(access_token, dict): - raise ValueError("'access_token' must be an OAuth2Token") + raise InvalidTypeError('access_token', access_token, OAuth2Token) with self.lock: log.debug('%s auth token for %s', 'Refreshing' if self.access_token else 'Setting', self.client_id) self.access_token = access_token @@ -233,7 +232,7 @@

        Module exchangelib.credentials

        super().__init__(**kwargs) self.authorization_code = authorization_code if access_token is not None and not isinstance(access_token, dict): - raise ValueError("'access_token' must be an OAuth2Token") + raise InvalidTypeError('access_token', access_token, OAuth2Token) self.access_token = access_token def __repr__(self): @@ -297,12 +296,7 @@

        Classes

        return (getattr(self, k) for k in self.__dict__ if k != '_lock') def __eq__(self, other): - for k in self.__dict__: - if k == '_lock': - continue - if getattr(self, k) != getattr(other, k): - return False - return True + return all(getattr(self, k) == getattr(other, k) for k in self.__dict__ if k != '_lock') def __hash__(self): return hash(tuple(self._get_hash_values())) @@ -501,7 +495,7 @@

        Inherited members

        super().__init__(**kwargs) self.authorization_code = authorization_code if access_token is not None and not isinstance(access_token, dict): - raise ValueError("'access_token' must be an OAuth2Token") + raise InvalidTypeError('access_token', access_token, OAuth2Token) self.access_token = access_token def __repr__(self): @@ -589,7 +583,7 @@

        Inherited members

        """ # Ensure we don't update the object in the middle of a new session being created, which could cause a race. if not isinstance(access_token, dict): - raise ValueError("'access_token' must be an OAuth2Token") + raise InvalidTypeError('access_token', access_token, OAuth2Token) with self.lock: log.debug('%s auth token for %s', 'Refreshing' if self.access_token else 'Setting', self.client_id) self.access_token = access_token @@ -651,7 +645,7 @@

        Methods

        """ # Ensure we don't update the object in the middle of a new session being created, which could cause a race. if not isinstance(access_token, dict): - raise ValueError("'access_token' must be an OAuth2Token") + raise InvalidTypeError('access_token', access_token, OAuth2Token) with self.lock: log.debug('%s auth token for %s', 'Refreshing' if self.access_token else 'Setting', self.client_id) self.access_token = access_token diff --git a/docs/exchangelib/errors.html b/docs/exchangelib/errors.html index 83b6f544..f73471dd 100644 --- a/docs/exchangelib/errors.html +++ b/docs/exchangelib/errors.html @@ -40,6 +40,28 @@

        Module exchangelib.errors

        pass +class InvalidEnumValue(ValueError): + def __init__(self, field_name, value, choices): + self.field_name = field_name + self.value = value + self.choices = choices + super().__init__(str(self)) + + def __str__(self): + return f'{self.field_name!r} {self.value!r} must be one of {sorted(self.choices)}' + + +class InvalidTypeError(TypeError): + def __init__(self, field_name, value, valid_type): + self.field_name = field_name + self.value = value + self.valid_type = valid_type + super().__init__(str(self)) + + def __str__(self): + return f'{self.field_name!r} {self.value!r} must be of type {self.valid_type}' + + class EWSError(Exception): """Global error type within this module.""" @@ -72,10 +94,8 @@

        Module exchangelib.errors

        self.total_wait = total_wait def __str__(self): - return str( - '{value} (gave up after {total_wait:.3f} seconds. URL {url} returned status code {status_code})'.format( - value=self.value, url=self.url, status_code=self.status_code, total_wait=self.total_wait) - ) + return f'{self.value} (gave up after {self.total_wait:.3f} seconds. ' \ + f'URL {self.url} returned status code {self.status_code})' class SOAPError(TransportError): @@ -99,7 +119,7 @@

        Module exchangelib.errors

        super().__init__(str(self)) def __str__(self): - return 'We were redirected to %s' % self.url + return f'We were redirected to {self.url}' class RelativeRedirect(TransportError): @@ -118,22 +138,10 @@

        Module exchangelib.errors

        pass -class AutoDiscoverRedirect(AutoDiscoverError): - def __init__(self, redirect_email): - self.redirect_email = redirect_email - super().__init__(str(self)) - - def __str__(self): - return 'AutoDiscover redirects to %s' % self.redirect_email - - class NaiveDateTimeNotAllowed(ValueError): def __init__(self, local_dt): super().__init__() - from .ewsdatetime import EWSDateTime - if not isinstance(local_dt, EWSDateTime): - raise ValueError("'local_dt' value %r must be an EWSDateTime" % local_dt) - self.local_dt = local_dt + self.local_dt = local_dt # An EWSDateTime instance class UnknownTimeZone(EWSError): @@ -167,7 +175,7 @@

        Module exchangelib.errors

        super().__init__(str(self)) def __str__(self): - return 'CAS error: %s' % self.cas_error + return f'CAS error: {self.cas_error}' # Somewhat-authoritative list of possible response message error types from EWS. See full list at @@ -641,7 +649,6 @@

        Subclasses

        @@ -666,33 +673,6 @@

        Ancestors

      • builtins.BaseException
      -
      -class AutoDiscoverRedirect -(redirect_email) -
      -
      -

      Global error type within this module.

      -
      - -Expand source code - -
      class AutoDiscoverRedirect(AutoDiscoverError):
      -    def __init__(self, redirect_email):
      -        self.redirect_email = redirect_email
      -        super().__init__(str(self))
      -
      -    def __str__(self):
      -        return 'AutoDiscover redirects to %s' % self.redirect_email
      -
      -

      Ancestors

      - -
      class CASError (cas_error, response) @@ -715,7 +695,7 @@

      Ancestors

      super().__init__(str(self)) def __str__(self): - return 'CAS error: %s' % self.cas_error
      + return f'CAS error: {self.cas_error}'

      Ancestors

        @@ -8870,6 +8850,60 @@

        Ancestors

      • builtins.BaseException
      +
      +class InvalidEnumValue +(field_name, value, choices) +
      +
      +

      Inappropriate argument value (of correct type).

      +
      + +Expand source code + +
      class InvalidEnumValue(ValueError):
      +    def __init__(self, field_name, value, choices):
      +        self.field_name = field_name
      +        self.value = value
      +        self.choices = choices
      +        super().__init__(str(self))
      +
      +    def __str__(self):
      +        return f'{self.field_name!r} {self.value!r} must be one of {sorted(self.choices)}'
      +
      +

      Ancestors

      +
        +
      • builtins.ValueError
      • +
      • builtins.Exception
      • +
      • builtins.BaseException
      • +
      +
      +
      +class InvalidTypeError +(field_name, value, valid_type) +
      +
      +

      Inappropriate argument type.

      +
      + +Expand source code + +
      class InvalidTypeError(TypeError):
      +    def __init__(self, field_name, value, valid_type):
      +        self.field_name = field_name
      +        self.value = value
      +        self.valid_type = valid_type
      +        super().__init__(str(self))
      +
      +    def __str__(self):
      +        return f'{self.field_name!r} {self.value!r} must be of type {self.valid_type}'
      +
      +

      Ancestors

      +
        +
      • builtins.TypeError
      • +
      • builtins.Exception
      • +
      • builtins.BaseException
      • +
      +
      class MalformedResponseError (value) @@ -8923,10 +8957,7 @@

      Ancestors

      class NaiveDateTimeNotAllowed(ValueError):
           def __init__(self, local_dt):
               super().__init__()
      -        from .ewsdatetime import EWSDateTime
      -        if not isinstance(local_dt, EWSDateTime):
      -            raise ValueError("'local_dt' value %r must be an EWSDateTime" % local_dt)
      -        self.local_dt = local_dt
      + self.local_dt = local_dt # An EWSDateTime instance

      Ancestors

        @@ -8953,10 +8984,8 @@

        Ancestors

        self.total_wait = total_wait def __str__(self): - return str( - '{value} (gave up after {total_wait:.3f} seconds. URL {url} returned status code {status_code})'.format( - value=self.value, url=self.url, status_code=self.status_code, total_wait=self.total_wait) - ) + return f'{self.value} (gave up after {self.total_wait:.3f} seconds. ' \ + f'URL {self.url} returned status code {self.status_code})'

        Ancestors

          @@ -8985,7 +9014,7 @@

          Ancestors

          super().__init__(str(self)) def __str__(self): - return 'We were redirected to %s' % self.url + return f'We were redirected to {self.url}'

          Ancestors

            @@ -9601,9 +9630,6 @@

            AutoDiscoverFailed

          • -

            AutoDiscoverRedirect

            -
          • -
          • CASError

          • @@ -10768,6 +10794,12 @@

            ErrorWrongServerVersionDelegate

          • +

            InvalidEnumValue

            +
          • +
          • +

            InvalidTypeError

            +
          • +
          • MalformedResponseError

          • diff --git a/docs/exchangelib/ewsdatetime.html b/docs/exchangelib/ewsdatetime.html index 03ea9c67..1da4981d 100644 --- a/docs/exchangelib/ewsdatetime.html +++ b/docs/exchangelib/ewsdatetime.html @@ -28,7 +28,6 @@

            Module exchangelib.ewsdatetime

            import datetime
             import logging
            -import warnings
             
             try:
                 import zoneinfo
            @@ -36,7 +35,7 @@ 

            Module exchangelib.ewsdatetime

            from backports import zoneinfo import tzlocal -from .errors import NaiveDateTimeNotAllowed, UnknownTimeZone +from .errors import NaiveDateTimeNotAllowed, UnknownTimeZone, InvalidTypeError from .winzone import IANA_TO_MS_TIMEZONE_MAP, MS_TIMEZONE_TO_IANA_MAP log = logging.getLogger(__name__) @@ -81,7 +80,7 @@

            Module exchangelib.ewsdatetime

            @classmethod def from_date(cls, d): if type(d) is not datetime.date: - raise ValueError("%r must be a date instance" % d) + raise InvalidTypeError('d', d, datetime.date) return cls(d.year, d.month, d.day) @classmethod @@ -118,7 +117,7 @@

            Module exchangelib.ewsdatetime

            # Don't allow pytz or dateutil timezones here. They are not safe to use as direct input for datetime() tzinfo = EWSTimeZone.from_timezone(tzinfo) if not isinstance(tzinfo, (EWSTimeZone, type(None))): - raise ValueError('tzinfo %r must be an EWSTimeZone instance' % tzinfo) + raise InvalidTypeError('tzinfo', tzinfo, EWSTimeZone) if len(args) == 8: args = args[:7] + (tzinfo,) else: @@ -131,7 +130,7 @@

            Module exchangelib.ewsdatetime

            * 2009-01-15T13:45:56+01:00 """ if not self.tzinfo: - raise ValueError('%r must be timezone-aware' % self) + raise ValueError(f'{self!r} must be timezone-aware') if self.tzinfo.key == 'UTC': if self.microsecond: return self.strftime('%Y-%m-%dT%H:%M:%S.%fZ') @@ -141,7 +140,7 @@

            Module exchangelib.ewsdatetime

            @classmethod def from_datetime(cls, d): if type(d) is not datetime.datetime: - raise ValueError("%r must be a datetime instance" % d) + raise InvalidTypeError('d', d, datetime.datetime) if d.tzinfo is None: tz = None elif isinstance(d.tzinfo, EWSTimeZone): @@ -245,7 +244,7 @@

            Module exchangelib.ewsdatetime

            try: instance.ms_id = cls.IANA_TO_MS_MAP[instance.key][0] except KeyError: - raise UnknownTimeZone('No Windows timezone name found for timezone "%s"' % instance.key) + raise UnknownTimeZone(f'No Windows timezone name found for timezone {instance.key!r}') # We don't need the Windows long-format timezone name in long format. It's used in timezone XML elements, but # EWS happily accepts empty strings. For a full list of timezones supported by the target server, including @@ -272,7 +271,7 @@

            Module exchangelib.ewsdatetime

            # EWS sometimes returns an ID that has a region/location format, e.g. 'Europe/Copenhagen'. Try the # string unaltered. return cls(ms_id) - raise UnknownTimeZone("Windows timezone ID '%s' is unknown by CLDR" % ms_id) + raise UnknownTimeZone(f'Windows timezone ID {ms_id!r} is unknown by CLDR') @classmethod def from_pytz(cls, tz): @@ -312,7 +311,7 @@

            Module exchangelib.ewsdatetime

            'pytz_deprecation_shim': lambda z: cls.from_timezone(z.unwrap_shim()) }[tz_module](tz) except KeyError: - raise TypeError('Unsupported tzinfo type: %r' % tz) + raise TypeError(f'Unsupported tzinfo type: {tz!r}') @classmethod def localzone(cls): @@ -324,32 +323,6 @@

            Module exchangelib.ewsdatetime

            # Handle both old and new versions of tzlocal that may return pytz or zoneinfo objects, respectively return cls.from_timezone(tz) - @classmethod - def timezone(cls, location): - warnings.warn('replace EWSTimeZone.timezone() with just EWSTimeZone()', DeprecationWarning, stacklevel=2) - return cls(location) - - def normalize(self, dt, is_dst=False): - warnings.warn('normalization is now handled gracefully', DeprecationWarning, stacklevel=2) - return dt - - def localize(self, dt, is_dst=False): - warnings.warn('replace tz.localize() with dt.replace(tzinfo=tz)', DeprecationWarning, stacklevel=2) - if dt.tzinfo is not None: - raise ValueError('%r must be timezone-unaware' % dt) - dt = dt.replace(tzinfo=self) - if is_dst is not None: - # DST dates are assumed to always be after non-DST dates - dt_before = dt.replace(fold=0) - dt_after = dt.replace(fold=1) - dst_before = dt_before.dst() - dst_after = dt_after.dst() - if dst_before > dst_after: - dt = dt_before if is_dst else dt_after - elif dst_before < dst_after: - dt = dt_after if is_dst else dt_before - return dt - def fromutc(self, dt): t = super().fromutc(dt) if isinstance(t, EWSDateTime): @@ -434,7 +407,7 @@

            Classes

            @classmethod def from_date(cls, d): if type(d) is not datetime.date: - raise ValueError("%r must be a date instance" % d) + raise InvalidTypeError('d', d, datetime.date) return cls(d.year, d.month, d.day) @classmethod @@ -472,7 +445,7 @@

            Static methods

            @classmethod
             def from_date(cls, d):
                 if type(d) is not datetime.date:
            -        raise ValueError("%r must be a date instance" % d)
            +        raise InvalidTypeError('d', d, datetime.date)
                 return cls(d.year, d.month, d.day)
            @@ -565,7 +538,7 @@

            Methods

            # Don't allow pytz or dateutil timezones here. They are not safe to use as direct input for datetime() tzinfo = EWSTimeZone.from_timezone(tzinfo) if not isinstance(tzinfo, (EWSTimeZone, type(None))): - raise ValueError('tzinfo %r must be an EWSTimeZone instance' % tzinfo) + raise InvalidTypeError('tzinfo', tzinfo, EWSTimeZone) if len(args) == 8: args = args[:7] + (tzinfo,) else: @@ -578,7 +551,7 @@

            Methods

            * 2009-01-15T13:45:56+01:00 """ if not self.tzinfo: - raise ValueError('%r must be timezone-aware' % self) + raise ValueError(f'{self!r} must be timezone-aware') if self.tzinfo.key == 'UTC': if self.microsecond: return self.strftime('%Y-%m-%dT%H:%M:%S.%fZ') @@ -588,7 +561,7 @@

            Methods

            @classmethod def from_datetime(cls, d): if type(d) is not datetime.datetime: - raise ValueError("%r must be a datetime instance" % d) + raise InvalidTypeError('d', d, datetime.datetime) if d.tzinfo is None: tz = None elif isinstance(d.tzinfo, EWSTimeZone): @@ -694,7 +667,7 @@

            Static methods

            @classmethod
             def from_datetime(cls, d):
                 if type(d) is not datetime.datetime:
            -        raise ValueError("%r must be a datetime instance" % d)
            +        raise InvalidTypeError('d', d, datetime.datetime)
                 if d.tzinfo is None:
                     tz = None
                 elif isinstance(d.tzinfo, EWSTimeZone):
            @@ -855,7 +828,7 @@ 

            Methods

            * 2009-01-15T13:45:56+01:00 """ if not self.tzinfo: - raise ValueError('%r must be timezone-aware' % self) + raise ValueError(f'{self!r} must be timezone-aware') if self.tzinfo.key == 'UTC': if self.microsecond: return self.strftime('%Y-%m-%dT%H:%M:%S.%fZ') @@ -892,7 +865,7 @@

            Methods

            try: instance.ms_id = cls.IANA_TO_MS_MAP[instance.key][0] except KeyError: - raise UnknownTimeZone('No Windows timezone name found for timezone "%s"' % instance.key) + raise UnknownTimeZone(f'No Windows timezone name found for timezone {instance.key!r}') # We don't need the Windows long-format timezone name in long format. It's used in timezone XML elements, but # EWS happily accepts empty strings. For a full list of timezones supported by the target server, including @@ -919,7 +892,7 @@

            Methods

            # EWS sometimes returns an ID that has a region/location format, e.g. 'Europe/Copenhagen'. Try the # string unaltered. return cls(ms_id) - raise UnknownTimeZone("Windows timezone ID '%s' is unknown by CLDR" % ms_id) + raise UnknownTimeZone(f'Windows timezone ID {ms_id!r} is unknown by CLDR') @classmethod def from_pytz(cls, tz): @@ -959,7 +932,7 @@

            Methods

            'pytz_deprecation_shim': lambda z: cls.from_timezone(z.unwrap_shim()) }[tz_module](tz) except KeyError: - raise TypeError('Unsupported tzinfo type: %r' % tz) + raise TypeError(f'Unsupported tzinfo type: {tz!r}') @classmethod def localzone(cls): @@ -971,32 +944,6 @@

            Methods

            # Handle both old and new versions of tzlocal that may return pytz or zoneinfo objects, respectively return cls.from_timezone(tz) - @classmethod - def timezone(cls, location): - warnings.warn('replace EWSTimeZone.timezone() with just EWSTimeZone()', DeprecationWarning, stacklevel=2) - return cls(location) - - def normalize(self, dt, is_dst=False): - warnings.warn('normalization is now handled gracefully', DeprecationWarning, stacklevel=2) - return dt - - def localize(self, dt, is_dst=False): - warnings.warn('replace tz.localize() with dt.replace(tzinfo=tz)', DeprecationWarning, stacklevel=2) - if dt.tzinfo is not None: - raise ValueError('%r must be timezone-unaware' % dt) - dt = dt.replace(tzinfo=self) - if is_dst is not None: - # DST dates are assumed to always be after non-DST dates - dt_before = dt.replace(fold=0) - dt_after = dt.replace(fold=1) - dst_before = dt_before.dst() - dst_after = dt_after.dst() - if dst_before > dst_after: - dt = dt_before if is_dst else dt_after - elif dst_before < dst_after: - dt = dt_after if is_dst else dt_before - return dt - def fromutc(self, dt): t = super().fromutc(dt) if isinstance(t, EWSDateTime): @@ -1075,7 +1022,7 @@

            Static methods

            # EWS sometimes returns an ID that has a region/location format, e.g. 'Europe/Copenhagen'. Try the # string unaltered. return cls(ms_id) - raise UnknownTimeZone("Windows timezone ID '%s' is unknown by CLDR" % ms_id)
            + raise UnknownTimeZone(f'Windows timezone ID {ms_id!r} is unknown by CLDR')
            @@ -1117,7 +1064,7 @@

            Static methods

            'pytz_deprecation_shim': lambda z: cls.from_timezone(z.unwrap_shim()) }[tz_module](tz) except KeyError: - raise TypeError('Unsupported tzinfo type: %r' % tz)
            + raise TypeError(f'Unsupported tzinfo type: {tz!r}')
            @@ -1154,21 +1101,6 @@

            Static methods

            return cls.from_timezone(tz)
            -
            -def timezone(location) -
            -
            -
            -
            - -Expand source code - -
            @classmethod
            -def timezone(cls, location):
            -    warnings.warn('replace EWSTimeZone.timezone() with just EWSTimeZone()', DeprecationWarning, stacklevel=2)
            -    return cls(location)
            -
            -

            Methods

            @@ -1188,47 +1120,6 @@

            Methods

            return EWSDateTime.from_datetime(t) # We want to return EWSDateTime objects -
            -def localize(self, dt, is_dst=False) -
            -
            -
            -
            - -Expand source code - -
            def localize(self, dt, is_dst=False):
            -    warnings.warn('replace tz.localize() with dt.replace(tzinfo=tz)', DeprecationWarning, stacklevel=2)
            -    if dt.tzinfo is not None:
            -        raise ValueError('%r must be timezone-unaware' % dt)
            -    dt = dt.replace(tzinfo=self)
            -    if is_dst is not None:
            -        # DST dates are assumed to always be after non-DST dates
            -        dt_before = dt.replace(fold=0)
            -        dt_after = dt.replace(fold=1)
            -        dst_before = dt_before.dst()
            -        dst_after = dt_after.dst()
            -        if dst_before > dst_after:
            -            dt = dt_before if is_dst else dt_after
            -        elif dst_before < dst_after:
            -            dt = dt_after if is_dst else dt_before
            -    return dt
            -
            -
            -
            -def normalize(self, dt, is_dst=False) -
            -
            -
            -
            - -Expand source code - -
            def normalize(self, dt, is_dst=False):
            -    warnings.warn('normalization is now handled gracefully', DeprecationWarning, stacklevel=2)
            -    return dt
            -
            -
            @@ -1287,10 +1178,7 @@

            from_timezone

          • from_zoneinfo
          • fromutc
          • -
          • localize
          • localzone
          • -
          • normalize
          • -
          • timezone
        diff --git a/docs/exchangelib/extended_properties.html b/docs/exchangelib/extended_properties.html index a1f3a864..f085765f 100644 --- a/docs/exchangelib/extended_properties.html +++ b/docs/exchangelib/extended_properties.html @@ -29,6 +29,7 @@

        Module exchangelib.extended_properties

        import logging
         from decimal import Decimal
         
        +from .errors import InvalidEnumValue
         from .ewsdatetime import EWSDateTime
         from .properties import EWSElement, ExtendedFieldURI
         from .util import create_element, add_xml_child, get_xml_attrs, get_xml_attr, set_xml_value, value_to_xml_text, \
        @@ -147,9 +148,8 @@ 

        Module exchangelib.extended_properties

        "When 'distinguished_property_set_id' is set, 'property_id' or 'property_name' must also be set" ) if cls.distinguished_property_set_id not in cls.DISTINGUISHED_SETS: - raise ValueError( - "'distinguished_property_set_id' %r must be one of %s" - % (cls.distinguished_property_set_id, sorted(cls.DISTINGUISHED_SETS)) + raise InvalidEnumValue( + 'distinguished_property_set_id', cls.distinguished_property_set_id, cls.DISTINGUISHED_SETS ) @classmethod @@ -173,7 +173,7 @@

        Module exchangelib.extended_properties

        raise ValueError("When 'property_tag' is set, only 'property_type' must be set") if 0x8000 <= cls.property_tag_as_int() <= 0xFFFE: raise ValueError( - "'property_tag' value '%s' is reserved for custom properties" % cls.property_tag_as_hex() + f"'property_tag' value {cls.property_tag_as_hex()!r} is reserved for custom properties" ) @classmethod @@ -199,24 +199,20 @@

        Module exchangelib.extended_properties

        @classmethod def _validate_property_type(cls): if cls.property_type not in cls.PROPERTY_TYPES: - raise ValueError( - "'property_type' %r must be one of %s" % (cls.property_type, sorted(cls.PROPERTY_TYPES)) - ) + raise InvalidEnumValue('property_type', cls.property_type, cls.PROPERTY_TYPES) def clean(self, version=None): self.validate_cls() python_type = self.python_type() if self.is_array_type(): if not is_iterable(self.value): - raise ValueError("'%s' value %r must be a list" % (self.__class__.__name__, self.value)) + raise TypeError(f"Field {self.__class__.__name__!r} value {self.value!r} must be of type {list}") for v in self.value: if not isinstance(v, python_type): - raise TypeError( - "'%s' value element %r must be an instance of %s" % (self.__class__.__name__, v, python_type)) + raise TypeError(f"Field {self.__class__.__name__!r} list value {v!r} must be of type {python_type}") else: if not isinstance(self.value, python_type): - raise TypeError( - "'%s' value %r must be an instance of %s" % (self.__class__.__name__, self.value, python_type)) + raise TypeError(f"Field {self.__class__.__name__!r} value {self.value!r} must be of type {python_type}") @classmethod def _normalize_obj(cls, obj): @@ -251,12 +247,12 @@

        Module exchangelib.extended_properties

        # Gets value of this specific ExtendedProperty from a list of 'ExtendedProperty' XML elements python_type = cls.python_type() if cls.is_array_type(): - values = elem.find('{%s}Values' % TNS) + values = elem.find(f'{{{TNS}}}Values') return [ xml_text_to_value(value=val, value_type=python_type) - for val in get_xml_attrs(values, '{%s}Value' % TNS) + for val in get_xml_attrs(values, f'{{{TNS}}}Value') ] - extended_field_value = xml_text_to_value(value=get_xml_attr(elem, '{%s}Value' % TNS), value_type=python_type) + extended_field_value = xml_text_to_value(value=get_xml_attr(elem, f'{{{TNS}}}Value'), value_type=python_type) if python_type == str and not extended_field_value: # For string types, we want to return the empty string instead of None if the element was # actually found, but there was no XML value. For other types, it would be more problematic @@ -468,9 +464,8 @@

        Classes

        "When 'distinguished_property_set_id' is set, 'property_id' or 'property_name' must also be set" ) if cls.distinguished_property_set_id not in cls.DISTINGUISHED_SETS: - raise ValueError( - "'distinguished_property_set_id' %r must be one of %s" - % (cls.distinguished_property_set_id, sorted(cls.DISTINGUISHED_SETS)) + raise InvalidEnumValue( + 'distinguished_property_set_id', cls.distinguished_property_set_id, cls.DISTINGUISHED_SETS ) @classmethod @@ -494,7 +489,7 @@

        Classes

        raise ValueError("When 'property_tag' is set, only 'property_type' must be set") if 0x8000 <= cls.property_tag_as_int() <= 0xFFFE: raise ValueError( - "'property_tag' value '%s' is reserved for custom properties" % cls.property_tag_as_hex() + f"'property_tag' value {cls.property_tag_as_hex()!r} is reserved for custom properties" ) @classmethod @@ -520,24 +515,20 @@

        Classes

        @classmethod def _validate_property_type(cls): if cls.property_type not in cls.PROPERTY_TYPES: - raise ValueError( - "'property_type' %r must be one of %s" % (cls.property_type, sorted(cls.PROPERTY_TYPES)) - ) + raise InvalidEnumValue('property_type', cls.property_type, cls.PROPERTY_TYPES) def clean(self, version=None): self.validate_cls() python_type = self.python_type() if self.is_array_type(): if not is_iterable(self.value): - raise ValueError("'%s' value %r must be a list" % (self.__class__.__name__, self.value)) + raise TypeError(f"Field {self.__class__.__name__!r} value {self.value!r} must be of type {list}") for v in self.value: if not isinstance(v, python_type): - raise TypeError( - "'%s' value element %r must be an instance of %s" % (self.__class__.__name__, v, python_type)) + raise TypeError(f"Field {self.__class__.__name__!r} list value {v!r} must be of type {python_type}") else: if not isinstance(self.value, python_type): - raise TypeError( - "'%s' value %r must be an instance of %s" % (self.__class__.__name__, self.value, python_type)) + raise TypeError(f"Field {self.__class__.__name__!r} value {self.value!r} must be of type {python_type}") @classmethod def _normalize_obj(cls, obj): @@ -572,12 +563,12 @@

        Classes

        # Gets value of this specific ExtendedProperty from a list of 'ExtendedProperty' XML elements python_type = cls.python_type() if cls.is_array_type(): - values = elem.find('{%s}Values' % TNS) + values = elem.find(f'{{{TNS}}}Values') return [ xml_text_to_value(value=val, value_type=python_type) - for val in get_xml_attrs(values, '{%s}Value' % TNS) + for val in get_xml_attrs(values, f'{{{TNS}}}Value') ] - extended_field_value = xml_text_to_value(value=get_xml_attr(elem, '{%s}Value' % TNS), value_type=python_type) + extended_field_value = xml_text_to_value(value=get_xml_attr(elem, f'{{{TNS}}}Value'), value_type=python_type) if python_type == str and not extended_field_value: # For string types, we want to return the empty string instead of None if the element was # actually found, but there was no XML value. For other types, it would be more problematic @@ -732,12 +723,12 @@

        Static methods

        # Gets value of this specific ExtendedProperty from a list of 'ExtendedProperty' XML elements python_type = cls.python_type() if cls.is_array_type(): - values = elem.find('{%s}Values' % TNS) + values = elem.find(f'{{{TNS}}}Values') return [ xml_text_to_value(value=val, value_type=python_type) - for val in get_xml_attrs(values, '{%s}Value' % TNS) + for val in get_xml_attrs(values, f'{{{TNS}}}Value') ] - extended_field_value = xml_text_to_value(value=get_xml_attr(elem, '{%s}Value' % TNS), value_type=python_type) + extended_field_value = xml_text_to_value(value=get_xml_attr(elem, f'{{{TNS}}}Value'), value_type=python_type) if python_type == str and not extended_field_value: # For string types, we want to return the empty string instead of None if the element was # actually found, but there was no XML value. For other types, it would be more problematic @@ -890,15 +881,13 @@

        Methods

        python_type = self.python_type() if self.is_array_type(): if not is_iterable(self.value): - raise ValueError("'%s' value %r must be a list" % (self.__class__.__name__, self.value)) + raise TypeError(f"Field {self.__class__.__name__!r} value {self.value!r} must be of type {list}") for v in self.value: if not isinstance(v, python_type): - raise TypeError( - "'%s' value element %r must be an instance of %s" % (self.__class__.__name__, v, python_type)) + raise TypeError(f"Field {self.__class__.__name__!r} list value {v!r} must be of type {python_type}") else: if not isinstance(self.value, python_type): - raise TypeError( - "'%s' value %r must be an instance of %s" % (self.__class__.__name__, self.value, python_type))
        + raise TypeError(f"Field {self.__class__.__name__!r} value {self.value!r} must be of type {python_type}")
        diff --git a/docs/exchangelib/fields.html b/docs/exchangelib/fields.html index 1bb7ae2b..f49e88ea 100644 --- a/docs/exchangelib/fields.html +++ b/docs/exchangelib/fields.html @@ -29,14 +29,14 @@

        Module exchangelib.fields

        import abc
         import datetime
         import logging
        -from collections import OrderedDict
         from decimal import Decimal, InvalidOperation
         from importlib import import_module
         
        +from .errors import InvalidTypeError
         from .ewsdatetime import EWSDateTime, EWSDate, EWSTimeZone, NaiveDateTimeNotAllowed, UnknownTimeZone, UTC
         from .util import create_element, get_xml_attr, get_xml_attrs, set_xml_value, value_to_xml_text, is_iterable, \
             xml_text_to_value, TNS
        -from .version import Build, Version, EXCHANGE_2013
        +from .version import Build, EXCHANGE_2013
         
         log = logging.getLogger(__name__)
         
        @@ -91,7 +91,7 @@ 

        Module exchangelib.fields

        class InvalidFieldForVersion(ValueError): - """Used when a field is not supported on the given Exchnage version.""" + """Used when a field is not supported on the given Exchange version.""" class InvalidChoiceForVersion(ValueError): @@ -109,7 +109,7 @@

        Module exchangelib.fields

        'physical_addresses__Home__street' -> ('physical_addresses', 'Home', 'street') """ if not isinstance(field_path, str): - raise ValueError("Field path %r must be a string" % field_path) + raise InvalidTypeError('field_path', field_path, str) search_parts = field_path.split('__') field = search_parts[0] try: @@ -128,56 +128,51 @@

        Module exchangelib.fields

        label and SubField object. """ from .indexed_properties import SingleFieldIndexedElement, MultiFieldIndexedElement - fieldname, label, subfieldname = split_field_path(field_path) + fieldname, label, subfield_name = split_field_path(field_path) field = folder.get_item_field_by_fieldname(fieldname) subfield = None if isinstance(field, IndexedField): if strict and not label: raise ValueError( - "IndexedField path '%s' must specify label, e.g. '%s__%s'" - % (field_path, fieldname, field.value_cls.get_field_by_fieldname('label').default) + f"IndexedField path {field_path!r} must specify label, e.g. " + f"'{fieldname}__{field.value_cls.get_field_by_fieldname('label').default}'" ) valid_labels = field.value_cls.get_field_by_fieldname('label').supported_choices( version=folder.account.version ) if label and label not in valid_labels: raise ValueError( - "Label '%s' on IndexedField path '%s' must be one of %s" - % (label, field_path, ', '.join(valid_labels)) + f"Label {label!r} on IndexedField path {field_path!r} must be one of {sorted(valid_labels)}" ) if issubclass(field.value_cls, MultiFieldIndexedElement): - if strict and not subfieldname: + if strict and not subfield_name: raise ValueError( - "IndexedField path '%s' must specify subfield, e.g. '%s__%s__%s'" - % (field_path, fieldname, label, field.value_cls.FIELDS[1].name) + f"IndexedField path {field_path!r} must specify subfield, e.g. " + f"'{fieldname}__{label}__{field.value_cls.FIELDS[1].name}'" ) - if subfieldname: + if subfield_name: try: - subfield = field.value_cls.get_field_by_fieldname(subfieldname) + subfield = field.value_cls.get_field_by_fieldname(subfield_name) except ValueError: - fnames = ', '.join(f.name for f in field.value_cls.supported_fields( + field_names = ', '.join(f.name for f in field.value_cls.supported_fields( version=folder.account.version )) raise ValueError( - "Subfield '%s' on IndexedField path '%s' must be one of %s" - % (subfieldname, field_path, fnames) + f"Subfield {subfield_name!r} on IndexedField path {field_path!r} " + f"must be one of {sorted(field_names)}" ) else: if not issubclass(field.value_cls, SingleFieldIndexedElement): - raise ValueError("'field.value_cls' %r must be an SingleFieldIndexedElement instance" % field.value_cls) - if subfieldname: + raise InvalidTypeError('field.value_cls', field.value_cls, SingleFieldIndexedElement) + if subfield_name: raise ValueError( - "IndexedField path '%s' must not specify subfield, e.g. just '%s__%s'" - % (field_path, fieldname, label) + f"IndexedField path {field_path!r} must not specify subfield, e.g. just {fieldname}__{label}'" ) subfield = field.value_cls.value_field(version=folder.account.version) else: - if label or subfieldname: - raise ValueError( - "Field path '%s' must not specify label or subfield, e.g. just '%s'" - % (field_path, fieldname) - ) + if label or subfield_name: + raise ValueError(f"Field path {field_path!r} must not specify label or subfield, e.g. just {fieldname!r}") return field, label, subfield @@ -188,13 +183,13 @@

        Module exchangelib.fields

        """ def __init__(self, field, label=None, subfield=None): + """ + + :param field: A FieldURIField or ExtendedPropertyField instance + :param label: a str + :param subfield: A SubField instance + """ # 'label' and 'subfield' are only used for IndexedField fields - if not isinstance(field, (FieldURIField, ExtendedPropertyField)): - raise ValueError("'field' %r must be an FieldURIField, of ExtendedPropertyField instance" % field) - if label and not isinstance(label, str): - raise ValueError("'label' %r must be a %s instance" % (label, str)) - if subfield and not isinstance(subfield, SubField): - raise ValueError("'subfield' %r must be a SubField instance" % subfield) self.field = field self.label = label self.subfield = subfield @@ -208,11 +203,11 @@

        Module exchangelib.fields

        # For indexed properties, get either the full property set, the property with matching label, or a particular # subfield. if self.label: - for subitem in getattr(item, self.field.name): - if subitem.label == self.label: + for sub_item in getattr(item, self.field.name): + if sub_item.label == self.label: if self.subfield: - return getattr(subitem, self.subfield.name) - return subitem + return getattr(sub_item, self.subfield.name) + return sub_item return None # No item with this label return getattr(item, self.field.name) @@ -226,7 +221,7 @@

        Module exchangelib.fields

        def to_xml(self): if isinstance(self.field, IndexedField): if not self.label or not self.subfield: - raise ValueError("Field path for indexed field '%s' is missing label and/or subfield" % self.field.name) + raise ValueError(f"Field path for indexed field {self.field.name!r} is missing label and/or subfield") return self.subfield.field_uri_xml(field_uri=self.field.field_uri, label=self.label) return self.field.field_uri_xml() @@ -248,8 +243,8 @@

        Module exchangelib.fields

        if self.label: from .indexed_properties import SingleFieldIndexedElement if issubclass(self.field.value_cls, SingleFieldIndexedElement) or not self.subfield: - return '%s__%s' % (self.field.name, self.label) - return '%s__%s__%s' % (self.field.name, self.label, self.subfield.name) + return f'{self.field.name}__{self.label}' + return f'{self.field.name}__{self.label}__{self.subfield.name}' return self.field.name def __eq__(self, other): @@ -267,12 +262,12 @@

        Module exchangelib.fields

        class FieldOrder: """Holds values needed to call server-side sorting on a single field path.""" - def __init__(self, field_path, reverse=False): - if not isinstance(field_path, FieldPath): - raise ValueError("'field_path' %r must be a FieldPath instance" % field_path) - if not isinstance(reverse, bool): - raise ValueError("'reverse' %r must be a boolean" % reverse) + """ + + :param field_path: A FieldPath instance + :param reverse: A bool + """ self.field_path = field_path self.reverse = reverse @@ -305,7 +300,7 @@

        Module exchangelib.fields

        def __init__(self, name=None, is_required=False, is_required_after_save=False, is_read_only=False, is_read_only_after_send=False, is_searchable=True, is_attribute=False, default=None, supported_from=None, deprecated_from=None): - self.name = name + self.name = name # Usually set by the EWSMeta metaclass self.default = default # Default value if none is given self.is_required = is_required # Some fields cannot be deleted on update. Default to True if 'is_required' is set @@ -322,49 +317,48 @@

        Module exchangelib.fields

        # The Exchange build when this field was introduced. When talking with versions prior to this version, # we will ignore this field. if supported_from is not None and not isinstance(supported_from, Build): - raise ValueError("'supported_from' %r must be a Build instance" % supported_from) + raise InvalidTypeError('supported_from', supported_from, Build) self.supported_from = supported_from # The Exchange build when this field was deprecated. When talking with versions at or later than this version, # we will ignore this field. if deprecated_from is not None and not isinstance(deprecated_from, Build): - raise ValueError("'deprecated_from' %r must be a Build instance" % deprecated_from) + raise InvalidTypeError('deprecated_from', deprecated_from, Build) self.deprecated_from = deprecated_from def clean(self, value, version=None): if version and not self.supports_version(version): - raise InvalidFieldForVersion("Field '%s' does not support EWS builds prior to %s (server has %s)" % ( - self.name, self.supported_from, version)) + raise InvalidFieldForVersion( + f"Field {self.name!r} does not support EWS builds prior to {self.supported_from} (server has {version})" + ) if value is None: if self.is_required and self.default is None: - raise ValueError("'%s' is a required field with no default" % self.name) + raise ValueError(f"{self.name!r} is a required field with no default") return self.default if self.is_list: if not is_iterable(value): - raise ValueError("Field '%s' value %r must be a list" % (self.name, value)) + raise TypeError(f"Field {self.name!r} value {value!r} must be of type {list}") for v in value: if not isinstance(v, self.value_cls): - raise TypeError("Field '%s' value %r must be of type %s" % (self.name, v, self.value_cls)) + raise TypeError(f"Field {self.name!r} value {v!r} must be of type {self.value_cls}") if hasattr(v, 'clean'): v.clean(version=version) else: if not isinstance(value, self.value_cls): - raise TypeError("Field '%s' value %r must be of type %s" % (self.name, value, self.value_cls)) + raise TypeError(f"Field {self.name!r} value {value!r} must be of type {self.value_cls}") if hasattr(value, 'clean'): value.clean(version=version) return value @abc.abstractmethod def from_xml(self, elem, account): - pass + """Read a value from the given element""" @abc.abstractmethod def to_xml(self, value, version): - pass + """Convert this field to an XML element""" def supports_version(self, version): # 'version' is a Version instance, for convenience by callers - if not isinstance(version, Version): - raise ValueError("'version' %r must be a Version instance" % version) if self.supported_from and version.build < self.supported_from: return False if self.deprecated_from and version.build >= self.deprecated_from: @@ -376,15 +370,16 @@

        Module exchangelib.fields

        @abc.abstractmethod def __hash__(self): - pass + """Field instances must be hashable""" def __repr__(self): - return self.__class__.__name__ + '(%s)' % ', '.join('%s=%r' % (f, getattr(self, f)) for f in ( + args_str = ', '.join(f'{f}={getattr(self, f)!r}' for f in ( 'name', 'value_cls', 'is_list', 'is_complex', 'default')) + return f'{self.__class__.__name__}({args_str})' class FieldURIField(Field): - """A field that has a FieldURI value in EWS. This means it's value is contained in an XML element or arrtibute. It + """A field that has a FieldURI value in EWS. This means it's value is contained in an XML element or attribute. It may additionally be a label for searching, filtering and limiting fields. In that case, the FieldURI format will be 'itemtype:FieldName' """ @@ -425,18 +420,18 @@

        Module exchangelib.fields

        def field_uri_xml(self): from .properties import FieldURI if not self.field_uri: - raise ValueError("'field_uri' value is missing") + raise ValueError(f"'field_uri' value is missing on field '{self.name}'") return FieldURI(field_uri=self.field_uri).to_xml(version=None) def request_tag(self): if not self.field_uri_postfix: - raise ValueError("'field_uri_postfix' value is missing") - return 't:%s' % self.field_uri_postfix + raise ValueError(f"'field_uri_postfix' value is missing on field '{self.name}'") + return f't:{self.field_uri_postfix}' def response_tag(self): if not self.field_uri_postfix: - raise ValueError("'field_uri_postfix' value is missing") - return '{%s}%s' % (self.namespace, self.field_uri_postfix) + raise ValueError(f"'field_uri_postfix' value is missing on field '{self.name}'") + return f'{{{self.namespace}}}{self.field_uri_postfix}' def __hash__(self): return hash(self.field_uri) @@ -465,9 +460,9 @@

        Module exchangelib.fields

        def _clean_single_value(self, v): if self.min is not None and v < self.min: raise ValueError( - "Value %r on field '%s' must be greater than %s" % (v, self.name, self.min)) + f"Value {v!r} on field {self.name!r} must be greater than {self.min}") if self.max is not None and v > self.max: - raise ValueError("Value %r on field '%s' must be less than %s" % (v, self.name, self.max)) + raise ValueError(f"Value {v!r} on field {self.name!r} must be less than {self.max}") def clean(self, value, version=None): value = super().clean(value, version=version) @@ -506,25 +501,21 @@

        Module exchangelib.fields

        for i, v in enumerate(value): if isinstance(v, str): if v not in self.enum: - raise ValueError( - "List value '%s' on field '%s' must be one of %s" % (v, self.name, self.enum)) + raise ValueError(f"List value {v!r} on field {self.name!r} must be one of {sorted(self.enum)}") value[i] = self.enum.index(v) + 1 if not value: - raise ValueError("Value '%s' on field '%s' must not be empty" % (value, self.name)) + raise ValueError(f"Value {value!r} on field {self.name!r} must not be empty") if len(value) > len(set(value)): - raise ValueError("List entries '%s' on field '%s' must be unique" % (value, self.name)) + raise ValueError(f"List entries {value!r} on field {self.name!r} must be unique") else: if isinstance(value, str): if value not in self.enum: - raise ValueError( - "Value '%s' on field '%s' must be one of %s" % (value, self.name, self.enum)) + raise ValueError(f"Value {value!r} on field {self.name!r} must be one of {sorted(self.enum)}") value = self.enum.index(value) + 1 return super().clean(value, version=version) def as_string(self, value): # Converts an integer in the enum to its equivalent string - if isinstance(value, str): - return value if self.is_list: return [self.enum[v - 1] for v in sorted(value)] return self.enum[value - 1] @@ -554,6 +545,15 @@

        Module exchangelib.fields

        is_list = True +class WeekdaysField(EnumListField): + """Like EnumListField, allow a single value instead of a 1-element list.""" + + def clean(self, value, version=None): + if isinstance(value, (int, str)): + value = [value] + return super().clean(value, version) + + class EnumAsIntField(EnumField): """Like EnumField, but communicates values with EWS in integers.""" @@ -669,6 +669,25 @@

        Module exchangelib.fields

        return self.default +class TimeDeltaField(FieldURIField): + """A field that handles timedelta values.""" + + value_cls = datetime.timedelta + + def __init__(self, *args, **kwargs): + self.min = kwargs.pop('min', datetime.timedelta(0)) + self.max = kwargs.pop('max', datetime.timedelta(days=1)) + super().__init__(*args, **kwargs) + + def clean(self, value, version=None): + if self.min is not None and value < self.min: + raise ValueError( + f"Value {value!r} on field {self.name!r} must be greater than {self.min}") + if self.max is not None and value > self.max: + raise ValueError(f"Value {value!r} on field {self.name!r} must be less than {self.max}") + return super().clean(value, version=version) + + class DateTimeField(FieldURIField): """A field that handles datetime values.""" @@ -677,7 +696,7 @@

        Module exchangelib.fields

        def clean(self, value, version=None): if isinstance(value, datetime.datetime): if not value.tzinfo: - raise ValueError("Value '%s' on field '%s' must be timezone aware" % (value, self.name)) + raise ValueError(f"Value {value!r} on field {self.name!r} must be timezone aware") if type(value) is datetime.datetime: value = self.value_cls.from_datetime(value) return super().clean(value, version=version) @@ -761,7 +780,7 @@

        Module exchangelib.fields

        return self.default def to_xml(self, value, version): - attrs = OrderedDict([('Id', value.ms_id)]) + attrs = dict(Id=value.ms_id) if value.ms_name: attrs['Name'] = value.ms_name return create_element(self.request_tag(), attrs=attrs) @@ -779,12 +798,28 @@

        Module exchangelib.fields

        is_list = True + def __init__(self, *args, **kwargs): + self.list_elem_name = kwargs.pop('list_elem_name', 'String') + super().__init__(*args, **kwargs) + + def list_elem_request_tag(self): + return f't:{self.list_elem_name}' + + def list_elem_response_tag(self): + return f'{{{self.namespace}}}{self.list_elem_name}' + def from_xml(self, elem, account): iter_elem = elem.find(self.response_tag()) if iter_elem is not None: - return get_xml_attrs(iter_elem, '{%s}String' % TNS) + return get_xml_attrs(iter_elem, self.list_elem_response_tag()) return self.default + def to_xml(self, value, version): + field_elem = create_element(self.request_tag()) + for v in value: + field_elem.append(set_xml_value(create_element(self.list_elem_request_tag()), v, version=version)) + return field_elem + class MessageField(TextField): """A field that handles the Message element.""" @@ -795,14 +830,14 @@

        Module exchangelib.fields

        reply = elem.find(self.response_tag()) if reply is None: return None - message = reply.find('{%s}%s' % (TNS, self.INNER_ELEMENT_NAME)) + message = reply.find(f'{{{TNS}}}{self.INNER_ELEMENT_NAME}') if message is None: return None return message.text def to_xml(self, value, version): field_elem = create_element(self.request_tag()) - message = create_element('t:%s' % self.INNER_ELEMENT_NAME) + message = create_element(f't:{self.INNER_ELEMENT_NAME}') message.text = value return set_xml_value(field_elem, message, version=version) @@ -821,14 +856,8 @@

        Module exchangelib.fields

        def clean(self, value, version=None): value = super().clean(value, version=version) - if value is not None: - if self.is_list: - for v in value: - if len(v) > self.max_length: - raise ValueError("'%s' value '%s' exceeds length %s" % (self.name, v, self.max_length)) - else: - if len(value) > self.max_length: - raise ValueError("'%s' value '%s' exceeds length %s" % (self.name, value, self.max_length)) + if value is not None and len(value) > self.max_length: + raise ValueError(f"{self.name!r} value {value!r} exceeds length {self.max_length}") return value @@ -845,28 +874,28 @@

        Module exchangelib.fields

        self.is_attribute = True -class CharListField(CharField): - """Like CharField, but for lists of strings.""" - - is_list = True +class CharListField(TextListField): + """Like TextListField, but for string values with a limited length.""" def __init__(self, *args, **kwargs): - self.list_elem_name = kwargs.pop('list_elem_name', 'String') + self.max_length = kwargs.pop('max_length', 255) + if not 1 <= self.max_length <= 255: + # A field supporting messages longer than 255 chars should be TextField + raise ValueError("'max_length' must be in the range 1-255") super().__init__(*args, **kwargs) - def list_elem_tag(self): - return '{%s}%s' % (self.namespace, self.list_elem_name) - - def from_xml(self, elem, account): - iter_elem = elem.find(self.response_tag()) - if iter_elem is not None: - return get_xml_attrs(iter_elem, self.list_elem_tag()) - return self.default + def clean(self, value, version=None): + value = super().clean(value, version=version) + if value is not None: + for v in value: + if len(v) > self.max_length: + raise ValueError(f"{self.name!r} value {v!r} exceeds length {self.max_length}") + return value class URIField(TextField): """Helper to mark strings that must conform to xsd:anyURI. - If we want an URI validator, see http://stackoverflow.com/questions/14466585/is-this-regex-correct-for-xsdanyuri + If we want a URI validator, see https://stackoverflow.com/questions/14466585/is-this-regex-correct-for-xsdanyuri """ @@ -887,8 +916,6 @@

        Module exchangelib.fields

        def supports_version(self, version): # 'version' is a Version instance, for convenience by callers - if not isinstance(version, Version): - raise ValueError("'version' %r must be a Version instance" % version) if not self.supported_from: return True return version.build >= self.supported_from @@ -911,14 +938,14 @@

        Module exchangelib.fields

        if value in valid_choices_for_version: return value if value in valid_choices: - raise InvalidChoiceForVersion("Choice '%s' only supports EWS builds from %s to %s (server has %s)" % ( - self.name, self.supported_from or '*', self.deprecated_from or '*', version)) + raise InvalidChoiceForVersion( + f"Choice {self.name!r} only supports EWS builds from {self.supported_from or '*'} to " + f"{self.deprecated_from or '*'} (server has {version})" + ) else: if value in valid_choices: return value - raise ValueError("Invalid choice '%s' for field '%s'. Valid choices are: %s" % ( - value, self.name, ', '.join(valid_choices) - )) + raise ValueError(f"Invalid choice {value!r} for field {self.name!r}. Valid choices are {sorted(valid_choices)}") def supported_choices(self, version): return tuple(c.value for c in self.choices if c.supports_version(version)) @@ -963,12 +990,11 @@

        Module exchangelib.fields

        def to_xml(self, value, version): from .properties import Body, HTMLBody - field_elem = create_element(self.request_tag()) body_type = { Body: Body.body_type, HTMLBody: HTMLBody.body_type, }[type(value)] - field_elem.set('BodyType', body_type) + field_elem = create_element(self.request_tag(), attrs=dict(BodyType=body_type)) return set_xml_value(field_elem, value, version=version) @@ -1018,6 +1044,21 @@

        Module exchangelib.fields

        is_complex = True +class TransitionListField(EWSElementListField): + def __init__(self, *args, **kwargs): + from .properties import BaseTransition + kwargs['value_cls'] = BaseTransition + super().__init__(*args, **kwargs) + + def from_xml(self, elem, account): + iter_elem = elem.find(self.response_tag()) if self.field_uri else elem + if iter_elem is not None: + return [ + self.value_cls.transition_model_from_tag(e.tag).from_xml(elem=e, account=account) for e in iter_elem + ] + return self.default + + class AssociatedCalendarItemIdField(EWSElementField): is_complex = True @@ -1081,8 +1122,8 @@

        Module exchangelib.fields

        super().__init__(*args, **kwargs) -class BaseEmailField(EWSElementField): - """A base class for EWSElement classes that have an 'email_address' field that we want to provide helpers for.""" +class BaseEmailField(EWSElementField, metaclass=abc.ABCMeta): + """Base class for EWSElement classes that have an 'email_address' field that we want to provide helpers for.""" is_complex = True # FindItem only returns the name, not the email address @@ -1102,7 +1143,7 @@

        Module exchangelib.fields

        nested_elem = sub_elem.find(self.value_cls.response_tag()) if nested_elem is None: raise ValueError( - 'Expected XML element %r missing on field %r' % (self.value_cls.response_tag(), self.name) + f'Expected XML element {self.value_cls.response_tag()!r} missing on field {self.name!r}' ) return self.value_cls.from_xml(elem=nested_elem, account=account) return self.value_cls.from_xml(elem=sub_elem, account=account) @@ -1224,7 +1265,7 @@

        Module exchangelib.fields

        def clean(self, value, version=None): value = super().clean(value, version=version) if self.is_required and not value: - raise ValueError('Value for subfield %r must be non-empty' % self.name) + raise ValueError(f'Value for subfield {self.name!r} must be non-empty') return value def __hash__(self): @@ -1264,16 +1305,16 @@

        Module exchangelib.fields

        def field_uri_xml(self, field_uri, label): from .properties import IndexedFieldURI - return IndexedFieldURI(field_uri='%s:%s' % (field_uri, self.field_uri), field_index=label).to_xml(version=None) + return IndexedFieldURI(field_uri=f'{field_uri}:{self.field_uri}', field_index=label).to_xml(version=None) def request_tag(self): - return 't:%s' % self.field_uri + return f't:{self.field_uri}' def response_tag(self): - return '{%s}%s' % (self.namespace, self.field_uri) + return f'{{{self.namespace}}}{self.field_uri}' -class IndexedField(EWSElementField): +class IndexedField(EWSElementField, metaclass=abc.ABCMeta): """A base class for all indexed fields.""" PARENT_ELEMENT_NAME = None @@ -1282,14 +1323,14 @@

        Module exchangelib.fields

        from .indexed_properties import IndexedElement value_cls = kwargs['value_cls'] if not issubclass(value_cls, IndexedElement): - raise ValueError("'value_cls' %r must be a subclass of IndexedElement" % value_cls) + raise TypeError(f"'value_cls' {value_cls!r} must be a subclass of type {IndexedElement}") super().__init__(*args, **kwargs) def to_xml(self, value, version): - return set_xml_value(create_element('t:%s' % self.PARENT_ELEMENT_NAME), value, version) + return set_xml_value(create_element(f't:{self.PARENT_ELEMENT_NAME}'), value, version=version) def response_tag(self): - return '{%s}%s' % (self.namespace, self.PARENT_ELEMENT_NAME) + return f'{{{self.namespace}}}{self.PARENT_ELEMENT_NAME}' def __hash__(self): return hash(self.field_uri) @@ -1297,6 +1338,7 @@

        Module exchangelib.fields

        class EmailAddressesField(IndexedField): is_list = True + is_complex = True PARENT_ELEMENT_NAME = 'EmailAddresses' @@ -1309,7 +1351,7 @@

        Module exchangelib.fields

        if value is not None: default_labels = self.value_cls.LABEL_CHOICES if len(value) > len(default_labels): - raise ValueError('This field can handle at most %s values (value: %r)' % (len(default_labels), value)) + raise ValueError(f'This field can handle at most {len(default_labels)} values (value: {value})') tmp = [] for s, default_label in zip(value, default_labels): if not isinstance(s, str): @@ -1322,6 +1364,7 @@

        Module exchangelib.fields

        class PhoneNumberField(IndexedField): is_list = True + is_complex = True PARENT_ELEMENT_NAME = 'PhoneNumbers' @@ -1333,6 +1376,7 @@

        Module exchangelib.fields

        class PhysicalAddressField(IndexedField): is_list = True + is_complex = True PARENT_ELEMENT_NAME = 'PhysicalAddresses' @@ -1343,6 +1387,8 @@

        Module exchangelib.fields

        class ExtendedPropertyField(Field): + is_complex = True + def __init__(self, *args, **kwargs): self.value_cls = kwargs.pop('value_cls') super().__init__(*args, **kwargs) @@ -1350,7 +1396,7 @@

        Module exchangelib.fields

        def clean(self, value, version=None): if value is None: if self.is_required: - raise ValueError("'%s' is a required field" % self.name) + raise ValueError(f"{self.name!r} is a required field") return self.default if not isinstance(value, self.value_cls): # Allow keeping ExtendedProperty field values as their simple Python type, but run clean() anyway @@ -1383,11 +1429,9 @@

        Module exchangelib.fields

        extended_property = create_element(self.value_cls.request_tag()) set_xml_value(extended_property, self.field_uri_xml(), version=version) if isinstance(value, self.value_cls): - set_xml_value(extended_property, value, version=version) - else: - # Allow keeping ExtendedProperty field values as their simple Python type - set_xml_value(extended_property, self.value_cls(value), version=version) - return extended_property + return set_xml_value(extended_property, value, version=version) + # Allow keeping ExtendedProperty field values as their simple Python type + return set_xml_value(extended_property, self.value_cls(value), version=version) def __hash__(self): return hash(self.name) @@ -1418,7 +1462,7 @@

        Module exchangelib.fields

        class UnknownEntriesField(CharListField): def list_elem_tag(self): - return '{%s}UnknownEntry' % self.namespace + return f'{{{self.namespace}}}UnknownEntry' class PermissionSetField(EWSElementField): @@ -1513,9 +1557,9 @@

        Module exchangelib.fields

        first = next(iter(value)) except StopIteration: first = None - value_type = '%sArray' % cls.TYPES_MAP_REVERSED[type(first)] + value_type = f'{cls.TYPES_MAP_REVERSED[type(first)]}Array' if value_type not in cls.TYPES_MAP: - raise ValueError('%r is not a supported type' % value) + raise ValueError(f'{value!r} is not a supported type') return value_type return cls.TYPES_MAP_REVERSED[type(value)] @@ -1526,7 +1570,7 @@

        Module exchangelib.fields

        def clean(self, value, version=None): if value is None: if self.is_required and self.default is None: - raise ValueError("'%s' is a required field with no default" % self.name) + raise ValueError(f"{self.name!r} is a required field with no default") return self.default return value @@ -1534,8 +1578,8 @@

        Module exchangelib.fields

        field_elem = elem.find(self.response_tag()) if field_elem is None: return self.default - value_type_str = get_xml_attr(field_elem, '{%s}Type' % TNS) - value = get_xml_attr(field_elem, '{%s}Value' % TNS) + value_type_str = get_xml_attr(field_elem, f'{{{TNS}}}Type') + value = get_xml_attr(field_elem, f'{{{TNS}}}Value') if value_type_str == 'Byte': try: # The value is an unsigned integer in the range 0 -> 255. Convert it to a single byte @@ -1694,56 +1738,51 @@

        Functions

        label and SubField object. """ from .indexed_properties import SingleFieldIndexedElement, MultiFieldIndexedElement - fieldname, label, subfieldname = split_field_path(field_path) + fieldname, label, subfield_name = split_field_path(field_path) field = folder.get_item_field_by_fieldname(fieldname) subfield = None if isinstance(field, IndexedField): if strict and not label: raise ValueError( - "IndexedField path '%s' must specify label, e.g. '%s__%s'" - % (field_path, fieldname, field.value_cls.get_field_by_fieldname('label').default) + f"IndexedField path {field_path!r} must specify label, e.g. " + f"'{fieldname}__{field.value_cls.get_field_by_fieldname('label').default}'" ) valid_labels = field.value_cls.get_field_by_fieldname('label').supported_choices( version=folder.account.version ) if label and label not in valid_labels: raise ValueError( - "Label '%s' on IndexedField path '%s' must be one of %s" - % (label, field_path, ', '.join(valid_labels)) + f"Label {label!r} on IndexedField path {field_path!r} must be one of {sorted(valid_labels)}" ) if issubclass(field.value_cls, MultiFieldIndexedElement): - if strict and not subfieldname: + if strict and not subfield_name: raise ValueError( - "IndexedField path '%s' must specify subfield, e.g. '%s__%s__%s'" - % (field_path, fieldname, label, field.value_cls.FIELDS[1].name) + f"IndexedField path {field_path!r} must specify subfield, e.g. " + f"'{fieldname}__{label}__{field.value_cls.FIELDS[1].name}'" ) - if subfieldname: + if subfield_name: try: - subfield = field.value_cls.get_field_by_fieldname(subfieldname) + subfield = field.value_cls.get_field_by_fieldname(subfield_name) except ValueError: - fnames = ', '.join(f.name for f in field.value_cls.supported_fields( + field_names = ', '.join(f.name for f in field.value_cls.supported_fields( version=folder.account.version )) raise ValueError( - "Subfield '%s' on IndexedField path '%s' must be one of %s" - % (subfieldname, field_path, fnames) + f"Subfield {subfield_name!r} on IndexedField path {field_path!r} " + f"must be one of {sorted(field_names)}" ) else: if not issubclass(field.value_cls, SingleFieldIndexedElement): - raise ValueError("'field.value_cls' %r must be an SingleFieldIndexedElement instance" % field.value_cls) - if subfieldname: + raise InvalidTypeError('field.value_cls', field.value_cls, SingleFieldIndexedElement) + if subfield_name: raise ValueError( - "IndexedField path '%s' must not specify subfield, e.g. just '%s__%s'" - % (field_path, fieldname, label) + f"IndexedField path {field_path!r} must not specify subfield, e.g. just {fieldname}__{label}'" ) subfield = field.value_cls.value_field(version=folder.account.version) else: - if label or subfieldname: - raise ValueError( - "Field path '%s' must not specify label or subfield, e.g. just '%s'" - % (field_path, fieldname) - ) + if label or subfield_name: + raise ValueError(f"Field path {field_path!r} must not specify label or subfield, e.g. just {fieldname!r}") return field, label, subfield
        @@ -1772,7 +1811,7 @@

        Functions

        'physical_addresses__Home__street' -> ('physical_addresses', 'Home', 'street') """ if not isinstance(field_path, str): - raise ValueError("Field path %r must be a string" % field_path) + raise InvalidTypeError('field_path', field_path, str) search_parts = field_path.split('__') field = search_parts[0] try: @@ -1850,29 +1889,12 @@

        Class variables

        -

        Methods

        -
        -
        -def from_xml(self, elem, account) -
        -
        -
        -
        - -Expand source code - -
        def from_xml(self, elem, account):
        -    val = super().from_xml(elem=elem, account=account)
        -    if val is None:
        -        return val
        -    return tuple(name for name, mask in self.STATES.items() if bool(val & mask))
        -
        -
        -

        Inherited members

        -

        Methods

        -
        -
        -def from_xml(self, elem, account) -
        -
        -
        -
        - -Expand source code - -
        def from_xml(self, elem, account):
        -    from .attachments import FileAttachment, ItemAttachment
        -    iter_elem = elem.find(self.response_tag())
        -    # Look for both FileAttachment and ItemAttachment
        -    if iter_elem is not None:
        -        attachments = []
        -        for att_type in (ItemAttachment, FileAttachment):
        -            attachments.extend(
        -                [att_type.from_xml(elem=e, account=account) for e in iter_elem.findall(att_type.response_tag())]
        -            )
        -        return attachments
        -    return self.default
        -
        -
        -
        +

        Inherited members

        +
        class AttendeesField @@ -2044,6 +2042,15 @@

        Methods

        +

        Inherited members

        +
        class Base64Field @@ -2095,19 +2102,28 @@

        Class variables

        - an integer

        +

        Inherited members

        +
        class BaseEmailField (*args, **kwargs)
        -

        A base class for EWSElement classes that have an 'email_address' field that we want to provide helpers for.

        +

        Base class for EWSElement classes that have an 'email_address' field that we want to provide helpers for.

        Expand source code -
        class BaseEmailField(EWSElementField):
        -    """A base class for EWSElement classes that have an 'email_address' field that we want to provide helpers for."""
        +
        class BaseEmailField(EWSElementField, metaclass=abc.ABCMeta):
        +    """Base class for EWSElement classes that have an 'email_address' field that we want to provide helpers for."""
         
             is_complex = True  # FindItem only returns the name, not the email address
         
        @@ -2127,7 +2143,7 @@ 

        Class variables

        nested_elem = sub_elem.find(self.value_cls.response_tag()) if nested_elem is None: raise ValueError( - 'Expected XML element %r missing on field %r' % (self.value_cls.response_tag(), self.name) + f'Expected XML element {self.value_cls.response_tag()!r} missing on field {self.name!r}' ) return self.value_cls.from_xml(elem=nested_elem, account=account) return self.value_cls.from_xml(elem=sub_elem, account=account) @@ -2169,34 +2185,16 @@

        Methods

        return super().clean(value, version=version)
        -
        -def from_xml(self, elem, account) -
        -
        -
        -
        - -Expand source code - -
        def from_xml(self, elem, account):
        -    if self.field_uri is None:
        -        sub_elem = elem.find(self.value_cls.response_tag())
        -    else:
        -        sub_elem = elem.find(self.response_tag())
        -    if sub_elem is not None:
        -        if self.field_uri is not None:
        -            # We want the nested Mailbox, not the wrapper element
        -            nested_elem = sub_elem.find(self.value_cls.response_tag())
        -            if nested_elem is None:
        -                raise ValueError(
        -                    'Expected XML element %r missing on field %r' % (self.value_cls.response_tag(), self.name)
        -                )
        -            return self.value_cls.from_xml(elem=nested_elem, account=account)
        -        return self.value_cls.from_xml(elem=sub_elem, account=account)
        -    return self.default
        -
        -
        +

        Inherited members

        +
        class BodyContentAttributedValueField @@ -2229,6 +2227,15 @@

        Class variables

        +

        Inherited members

        +
        class BodyField @@ -2267,12 +2274,11 @@

        Class variables

        def to_xml(self, value, version): from .properties import Body, HTMLBody - field_elem = create_element(self.request_tag()) body_type = { Body: Body.body_type, HTMLBody: HTMLBody.body_type, }[type(value)] - field_elem.set('BodyType', body_type) + field_elem = create_element(self.request_tag(), attrs=dict(BodyType=body_type)) return set_xml_value(field_elem, value, version=version)

        Ancestors

        @@ -2298,53 +2304,13 @@

        Methods

        return super().clean(value, version=version)
        -
        -def from_xml(self, elem, account) -
        -
        -
        -
        - -Expand source code - -
        def from_xml(self, elem, account):
        -    from .properties import Body, HTMLBody
        -    field_elem = elem.find(self.response_tag())
        -    val = None if field_elem is None else field_elem.text or None
        -    if val is not None:
        -        body_type = field_elem.get('BodyType')
        -        return {
        -            Body.body_type: Body,
        -            HTMLBody.body_type: HTMLBody,
        -        }[body_type](val)
        -    return self.default
        -
        -
        -
        -def to_xml(self, value, version) -
        -
        -
        -
        - -Expand source code - -
        def to_xml(self, value, version):
        -    from .properties import Body, HTMLBody
        -    field_elem = create_element(self.request_tag())
        -    body_type = {
        -        Body: Body.body_type,
        -        HTMLBody: HTMLBody.body_type,
        -    }[type(value)]
        -    field_elem.set('BodyType', body_type)
        -    return set_xml_value(field_elem, value, version=version)
        -
        -

        Inherited members

        -

        Methods

        -
        -
        -def from_xml(self, elem, account) -
        -
        -
        -
        - -Expand source code - -
        def from_xml(self, elem, account):
        -    val = self._get_val_from_elem(elem)
        -    if val:
        -        try:
        -            return self.value_cls.from_hex_string(val)
        -        except (TypeError, ValueError):
        -            log.warning('Invalid server version string: %r', val)
        -    return val
        -
        -
        -

        Inherited members

        • CharField:
        • @@ -2471,14 +2426,8 @@

          Inherited members

          def clean(self, value, version=None): value = super().clean(value, version=version) - if value is not None: - if self.is_list: - for v in value: - if len(v) > self.max_length: - raise ValueError("'%s' value '%s' exceeds length %s" % (self.name, v, self.max_length)) - else: - if len(value) > self.max_length: - raise ValueError("'%s' value '%s' exceeds length %s" % (self.name, value, self.max_length)) + if value is not None and len(value) > self.max_length: + raise ValueError(f"{self.name!r} value {value!r} exceeds length {self.max_length}") return value

          Ancestors

          @@ -2490,7 +2439,6 @@

          Ancestors

          Subclasses

          • BuildField
          • -
          • CharListField
          • ChoiceField
          • CultureField
          • EmailAddressField
          • @@ -2516,14 +2464,8 @@

            Methods

            def clean(self, value, version=None):
                 value = super().clean(value, version=version)
            -    if value is not None:
            -        if self.is_list:
            -            for v in value:
            -                if len(v) > self.max_length:
            -                    raise ValueError("'%s' value '%s' exceeds length %s" % (self.name, v, self.max_length))
            -        else:
            -            if len(value) > self.max_length:
            -                raise ValueError("'%s' value '%s' exceeds length %s" % (self.name, value, self.max_length))
            +    if value is not None and len(value) > self.max_length:
            +        raise ValueError(f"{self.name!r} value {value!r} exceeds length {self.max_length}")
                 return value
            @@ -2532,6 +2474,8 @@

            Inherited members

      -

      Like CharField, but for lists of strings.

      +

      Like TextListField, but for string values with a limited length.

      Expand source code -
      class CharListField(CharField):
      -    """Like CharField, but for lists of strings."""
      -
      -    is_list = True
      +
      class CharListField(TextListField):
      +    """Like TextListField, but for string values with a limited length."""
       
           def __init__(self, *args, **kwargs):
      -        self.list_elem_name = kwargs.pop('list_elem_name', 'String')
      +        self.max_length = kwargs.pop('max_length', 255)
      +        if not 1 <= self.max_length <= 255:
      +            # A field supporting messages longer than 255 chars should be TextField
      +            raise ValueError("'max_length' must be in the range 1-255")
               super().__init__(*args, **kwargs)
       
      -    def list_elem_tag(self):
      -        return '{%s}%s' % (self.namespace, self.list_elem_name)
      -
      -    def from_xml(self, elem, account):
      -        iter_elem = elem.find(self.response_tag())
      -        if iter_elem is not None:
      -            return get_xml_attrs(iter_elem, self.list_elem_tag())
      -        return self.default
      + def clean(self, value, version=None): + value = super().clean(value, version=version) + if value is not None: + for v in value: + if len(v) > self.max_length: + raise ValueError(f"{self.name!r} value {v!r} exceeds length {self.max_length}") + return value

      Ancestors

        -
      • CharField
      • +
      • TextListField
      • TextField
      • FieldURIField
      • Field
      • @@ -2576,17 +2520,10 @@

        Subclasses

        -

        Class variables

        -
        -
        var is_list
        -
        -
        -
        -

        Methods

        -
        -def from_xml(self, elem, account) +
        +def clean(self, value, version=None)
        @@ -2594,32 +2531,23 @@

        Methods

        Expand source code -
        def from_xml(self, elem, account):
        -    iter_elem = elem.find(self.response_tag())
        -    if iter_elem is not None:
        -        return get_xml_attrs(iter_elem, self.list_elem_tag())
        -    return self.default
        - -
        -
        -def list_elem_tag(self) -
        -
        -
        -
        - -Expand source code - -
        def list_elem_tag(self):
        -    return '{%s}%s' % (self.namespace, self.list_elem_name)
        +
        def clean(self, value, version=None):
        +    value = super().clean(value, version=version)
        +    if value is not None:
        +        for v in value:
        +            if len(v) > self.max_length:
        +                raise ValueError(f"{self.name!r} value {v!r} exceeds length {self.max_length}")
        +    return value

        Inherited members

        @@ -2643,8 +2571,6 @@

        Inherited members

        def supports_version(self, version): # 'version' is a Version instance, for convenience by callers - if not isinstance(version, Version): - raise ValueError("'version' %r must be a Version instance" % version) if not self.supported_from: return True return version.build >= self.supported_from
        @@ -2662,8 +2588,6 @@

        Methods

        def supports_version(self, version):
             # 'version' is a Version instance, for convenience by callers
        -    if not isinstance(version, Version):
        -        raise ValueError("'version' %r must be a Version instance" % version)
             if not self.supported_from:
                 return True
             return version.build >= self.supported_from
        @@ -2698,14 +2622,14 @@

        Methods

        if value in valid_choices_for_version: return value if value in valid_choices: - raise InvalidChoiceForVersion("Choice '%s' only supports EWS builds from %s to %s (server has %s)" % ( - self.name, self.supported_from or '*', self.deprecated_from or '*', version)) + raise InvalidChoiceForVersion( + f"Choice {self.name!r} only supports EWS builds from {self.supported_from or '*'} to " + f"{self.deprecated_from or '*'} (server has {version})" + ) else: if value in valid_choices: return value - raise ValueError("Invalid choice '%s' for field '%s'. Valid choices are: %s" % ( - value, self.name, ', '.join(valid_choices) - )) + raise ValueError(f"Invalid choice {value!r} for field {self.name!r}. Valid choices are {sorted(valid_choices)}") def supported_choices(self, version): return tuple(c.value for c in self.choices if c.supports_version(version))
        @@ -2744,14 +2668,14 @@

        Methods

        if value in valid_choices_for_version: return value if value in valid_choices: - raise InvalidChoiceForVersion("Choice '%s' only supports EWS builds from %s to %s (server has %s)" % ( - self.name, self.supported_from or '*', self.deprecated_from or '*', version)) + raise InvalidChoiceForVersion( + f"Choice {self.name!r} only supports EWS builds from {self.supported_from or '*'} to " + f"{self.deprecated_from or '*'} (server has {version})" + ) else: if value in valid_choices: return value - raise ValueError("Invalid choice '%s' for field '%s'. Valid choices are: %s" % ( - value, self.name, ', '.join(valid_choices) - ))
        + raise ValueError(f"Invalid choice {value!r} for field {self.name!r}. Valid choices are {sorted(valid_choices)}")
      @@ -2772,6 +2696,8 @@

      Inherited members

      • CharField:
      • @@ -2801,6 +2727,8 @@

        Inherited members

        • CharField:
        • @@ -2862,6 +2790,15 @@

          Methods

          +

          Inherited members

          +
          class DateOrDateTimeField @@ -2933,28 +2870,13 @@

          Methods

          return super().clean(value=value, version=version)
          -
          -def from_xml(self, elem, account) -
          -
          -
          -
          - -Expand source code - -
          def from_xml(self, elem, account):
          -    val = self._get_val_from_elem(elem)
          -    if val is not None and len(val) == 16:
          -        # This is a date format with timezone info, as sent by task recurrences. Eg: '2006-01-09+01:00'
          -        return self._date_field.from_xml(elem=elem, account=account)
          -    return super().from_xml(elem=elem, account=account)
          -
          -

          Inherited members

          • DateTimeField:
          • @@ -3023,50 +2945,13 @@

            Methods

            return self._datetime_field.value_cls.combine(value, self._default_time).replace(tzinfo=UTC)
            -
            -def from_xml(self, elem, account) -
            -
            -
            -
            - -Expand source code - -
            def from_xml(self, elem, account):
            -    val = self._get_val_from_elem(elem)
            -    if val is not None and len(val) == 25:
            -        # This is a datetime string with timezone info, e.g. '2021-03-01T21:55:54+00:00'. We don't want to have
            -        # datetime values converted to UTC before converting to date. EWSDateTime.from_string() insists on
            -        # converting to UTC, but we don't have an EWSTimeZone we can convert the timezone info to. Instead, parse
            -        # the string with .fromisoformat().
            -        return datetime.datetime.fromisoformat(val).date()
            -    # Revert to default parsing of datetime strings
            -    res = self._datetime_field.from_xml(elem=elem, account=account)
            -    if res is None:
            -        return res
            -    return res.date()
            -
            -
            -
            -def to_xml(self, value, version) -
            -
            -
            -
            - -Expand source code - -
            def to_xml(self, value, version):
            -    # Convert date to datetime
            -    value = self.date_to_datetime(value)
            -    return self._datetime_field.to_xml(value=value, version=version)
            -
            -

            Inherited members

            • DateField:
            • @@ -3090,7 +2975,7 @@

              Inherited members

              def clean(self, value, version=None): if isinstance(value, datetime.datetime): if not value.tzinfo: - raise ValueError("Value '%s' on field '%s' must be timezone aware" % (value, self.name)) + raise ValueError(f"Value {value!r} on field {self.name!r} must be timezone aware") if type(value) is datetime.datetime: value = self.value_cls.from_datetime(value) return super().clean(value, version=version) @@ -3145,43 +3030,22 @@

              Methods

              def clean(self, value, version=None):
                   if isinstance(value, datetime.datetime):
                       if not value.tzinfo:
              -            raise ValueError("Value '%s' on field '%s' must be timezone aware" % (value, self.name))
              +            raise ValueError(f"Value {value!r} on field {self.name!r} must be timezone aware")
                       if type(value) is datetime.datetime:
                           value = self.value_cls.from_datetime(value)
                   return super().clean(value, version=version)
              -
              -def from_xml(self, elem, account) -
              -
              -
              -
              - -Expand source code - -
              def from_xml(self, elem, account):
              -    val = self._get_val_from_elem(elem)
              -    if val is not None:
              -        try:
              -            return self.value_cls.from_string(val)
              -        except ValueError as e:
              -            if isinstance(e, NaiveDateTimeNotAllowed):
              -                # We encountered a naive datetime
              -                if account:
              -                    # Convert to timezone-aware datetime using the default timezone of the account
              -                    tz = account.default_timezone
              -                    log.info('Found naive datetime %s on field %s. Assuming timezone %s', e.local_dt, self.name, tz)
              -                    return e.local_dt.replace(tzinfo=tz)
              -                # There's nothing we can do but return the naive date. It's better than assuming e.g. UTC.
              -                log.warning('Returning naive datetime %s on field %s', e.local_dt, self.name)
              -                return e.local_dt
              -            log.info("Cannot convert value '%s' on field '%s' to type %s", val, self.name, self.value_cls)
              -            return None
              -    return self.default
              -
              -
              +

              Inherited members

              +
              class DecimalField @@ -3208,6 +3072,8 @@

              Inherited members

              -

              A field that has a FieldURI value in EWS. This means it's value is contained in an XML element or arrtibute. It +

              A field that has a FieldURI value in EWS. This means it's value is contained in an XML element or attribute. It may additionally be a label for searching, filtering and limiting fields. In that case, the FieldURI format will be 'itemtype:FieldName'

              @@ -3303,44 +3169,16 @@

              Methods

              return super().clean(value=value, version=version)
              -
              -def from_xml(self, elem, account) -
              -
              -
              -
              - -Expand source code - -
              def from_xml(self, elem, account):
              -    from .properties import DictionaryEntry
              -    iter_elem = elem.find(self.response_tag())
              -    if iter_elem is not None:
              -        entries = [
              -            DictionaryEntry.from_xml(elem=e, account=account)
              -            for e in iter_elem.findall(DictionaryEntry.response_tag())
              -        ]
              -        return {e.key: e.value for e in entries}
              -    return self.default
              -
              -
              -
              -def to_xml(self, value, version) -
              -
              -
              -
              - -Expand source code - -
              def to_xml(self, value, version):
              -    from .properties import DictionaryEntry
              -    field_elem = create_element(self.request_tag())
              -    entries = [DictionaryEntry(key=k, value=v) for k, v in value.items()]
              -    return set_xml_value(field_elem, entries, version=version)
              -
              -
              +

              Inherited members

              +
              class EWSElementField @@ -3431,50 +3269,15 @@

              Instance variables

              -

              Methods

              -
              -
              -def from_xml(self, elem, account) -
              -
              -
              -
              - -Expand source code - -
              def from_xml(self, elem, account):
              -    if self.is_list:
              -        iter_elem = elem.find(self.response_tag())
              -        if iter_elem is not None:
              -            return [self.value_cls.from_xml(elem=e, account=account)
              -                    for e in iter_elem.findall(self.value_cls.response_tag())]
              -    else:
              -        if self.field_uri is None:
              -            sub_elem = elem.find(self.value_cls.response_tag())
              -        else:
              -            sub_elem = elem.find(self.response_tag())
              -        if sub_elem is not None:
              -            return self.value_cls.from_xml(elem=sub_elem, account=account)
              -    return self.default
              -
              -
              -
              -def to_xml(self, value, version) -
              -
              -
              -
              - -Expand source code - -
              def to_xml(self, value, version):
              -    if self.field_uri is None:
              -        return value.to_xml(version=version)
              -    field_elem = create_element(self.request_tag())
              -    return set_xml_value(field_elem, value, version=version)
              -
              -
              -
              +

              Inherited members

              +
              class EWSElementListField @@ -3510,6 +3313,7 @@

              Subclasses

            • PostalAddressAttributedValueField
            • ProtocolListField
            • StringAttributedValueField
            • +
            • TransitionListField

            Class variables

            @@ -3522,6 +3326,15 @@

            Class variables

            +

            Inherited members

            +
            class EffectiveRightsField @@ -3545,6 +3358,15 @@

            Ancestors

          • FieldURIField
          • Field
          +

          Inherited members

          +
          class EmailAddressAttributedValueField @@ -3569,6 +3391,15 @@

          Ancestors

        • FieldURIField
        • Field
        +

        Inherited members

        +
        class EmailAddressField @@ -3594,6 +3425,8 @@

        Inherited members

        • CharField:
        • @@ -3611,6 +3444,7 @@

          Inherited members

          class EmailAddressesField(IndexedField):
               is_list = True
          +    is_complex = True
           
               PARENT_ELEMENT_NAME = 'EmailAddresses'
           
          @@ -3623,7 +3457,7 @@ 

          Inherited members

          if value is not None: default_labels = self.value_cls.LABEL_CHOICES if len(value) > len(default_labels): - raise ValueError('This field can handle at most %s values (value: %r)' % (len(default_labels), value)) + raise ValueError(f'This field can handle at most {len(default_labels)} values (value: {value})') tmp = [] for s, default_label in zip(value, default_labels): if not isinstance(s, str): @@ -3646,6 +3480,10 @@

          Class variables

          +
          var is_complex
          +
          +
          +
          var is_list
          @@ -3666,7 +3504,7 @@

          Methods

          if value is not None: default_labels = self.value_cls.LABEL_CHOICES if len(value) > len(default_labels): - raise ValueError('This field can handle at most %s values (value: %r)' % (len(default_labels), value)) + raise ValueError(f'This field can handle at most {len(default_labels)} values (value: {value})') tmp = [] for s, default_label in zip(value, default_labels): if not isinstance(s, str): @@ -3678,13 +3516,22 @@

          Methods

          +

          Inherited members

          +
          class EmailField (*args, **kwargs)
          -

          A base class for EWSElement classes that have an 'email_address' field that we want to provide helpers for.

          +

          Base class for EWSElement classes that have an 'email_address' field that we want to provide helpers for.

          Expand source code @@ -3702,6 +3549,15 @@

          Ancestors

        • FieldURIField
        • Field
        +

        Inherited members

        +
        class EmailSubField @@ -3726,26 +3582,12 @@

        Ancestors

      • SubField
      • Field
      -

      Methods

      -
      -
      -def from_xml(self, elem, account) -
      -
      -
      -
      - -Expand source code - -
      def from_xml(self, elem, account):
      -    return elem.text or elem.get('Name')  # Sometimes elem.text is empty. Exchange saves the same in 'Name' attr
      -
      -
      -

      Inherited members

      -

      Methods

      -
      -
      -def from_xml(self, elem, account) -
      -
      -
      -
      - -Expand source code - -
      def from_xml(self, elem, account):
      -    return super(EnumField, self).from_xml(elem=elem, account=account)
      -
      -
      -
      -def to_xml(self, value, version) -
      -
      -
      -
      - -Expand source code - -
      def to_xml(self, value, version):
      -    field_elem = create_element(self.request_tag())
      -    return set_xml_value(field_elem, value, version=version)
      -
      -
      -

      Inherited members

      • EnumField:
      • @@ -3848,25 +3662,21 @@

        Inherited members

        for i, v in enumerate(value): if isinstance(v, str): if v not in self.enum: - raise ValueError( - "List value '%s' on field '%s' must be one of %s" % (v, self.name, self.enum)) + raise ValueError(f"List value {v!r} on field {self.name!r} must be one of {sorted(self.enum)}") value[i] = self.enum.index(v) + 1 if not value: - raise ValueError("Value '%s' on field '%s' must not be empty" % (value, self.name)) + raise ValueError(f"Value {value!r} on field {self.name!r} must not be empty") if len(value) > len(set(value)): - raise ValueError("List entries '%s' on field '%s' must be unique" % (value, self.name)) + raise ValueError(f"List entries {value!r} on field {self.name!r} must be unique") else: if isinstance(value, str): if value not in self.enum: - raise ValueError( - "Value '%s' on field '%s' must be one of %s" % (value, self.name, self.enum)) + raise ValueError(f"Value {value!r} on field {self.name!r} must be one of {sorted(self.enum)}") value = self.enum.index(value) + 1 return super().clean(value, version=version) def as_string(self, value): # Converts an integer in the enum to its equivalent string - if isinstance(value, str): - return value if self.is_list: return [self.enum[v - 1] for v in sorted(value)] return self.enum[value - 1] @@ -3913,8 +3723,6 @@

        Methods

        def as_string(self, value):
             # Converts an integer in the enum to its equivalent string
        -    if isinstance(value, str):
        -        return value
             if self.is_list:
                 return [self.enum[v - 1] for v in sorted(value)]
             return self.enum[value - 1]
        @@ -3935,65 +3743,27 @@

        Methods

        for i, v in enumerate(value): if isinstance(v, str): if v not in self.enum: - raise ValueError( - "List value '%s' on field '%s' must be one of %s" % (v, self.name, self.enum)) + raise ValueError(f"List value {v!r} on field {self.name!r} must be one of {sorted(self.enum)}") value[i] = self.enum.index(v) + 1 if not value: - raise ValueError("Value '%s' on field '%s' must not be empty" % (value, self.name)) + raise ValueError(f"Value {value!r} on field {self.name!r} must not be empty") if len(value) > len(set(value)): - raise ValueError("List entries '%s' on field '%s' must be unique" % (value, self.name)) + raise ValueError(f"List entries {value!r} on field {self.name!r} must be unique") else: if isinstance(value, str): if value not in self.enum: - raise ValueError( - "Value '%s' on field '%s' must be one of %s" % (value, self.name, self.enum)) + raise ValueError(f"Value {value!r} on field {self.name!r} must be one of {sorted(self.enum)}") value = self.enum.index(value) + 1 return super().clean(value, version=version)
        -
        -def from_xml(self, elem, account) -
        -
        -
        -
        - -Expand source code - -
        def from_xml(self, elem, account):
        -    val = self._get_val_from_elem(elem)
        -    if val is not None:
        -        try:
        -            if self.is_list:
        -                return [self.enum.index(v) + 1 for v in val.split(' ')]
        -            return self.enum.index(val) + 1
        -        except ValueError:
        -            log.warning("Cannot convert value '%s' on field '%s' to type %s", val, self.name, self.value_cls)
        -            return None
        -    return self.default
        -
        -
        -
        -def to_xml(self, value, version) -
        -
        -
        -
        - -Expand source code - -
        def to_xml(self, value, version):
        -    field_elem = create_element(self.request_tag())
        -    if self.is_list:
        -        return set_xml_value(field_elem, ' '.join(self.as_string(value)), version=version)
        -    return set_xml_value(field_elem, self.as_string(value), version=version)
        -
        -

        Inherited members

        +

        Subclasses

        +

        Class variables

        var is_list
        @@ -4032,6 +3806,8 @@

        Inherited members

        • EnumField:
        • @@ -4048,6 +3824,8 @@

          Inherited members

          Expand source code
          class ExtendedPropertyField(Field):
          +    is_complex = True
          +
               def __init__(self, *args, **kwargs):
                   self.value_cls = kwargs.pop('value_cls')
                   super().__init__(*args, **kwargs)
          @@ -4055,7 +3833,7 @@ 

          Inherited members

          def clean(self, value, version=None): if value is None: if self.is_required: - raise ValueError("'%s' is a required field" % self.name) + raise ValueError(f"{self.name!r} is a required field") return self.default if not isinstance(value, self.value_cls): # Allow keeping ExtendedProperty field values as their simple Python type, but run clean() anyway @@ -4088,11 +3866,9 @@

          Inherited members

          extended_property = create_element(self.value_cls.request_tag()) set_xml_value(extended_property, self.field_uri_xml(), version=version) if isinstance(value, self.value_cls): - set_xml_value(extended_property, value, version=version) - else: - # Allow keeping ExtendedProperty field values as their simple Python type - set_xml_value(extended_property, self.value_cls(value), version=version) - return extended_property + return set_xml_value(extended_property, value, version=version) + # Allow keeping ExtendedProperty field values as their simple Python type + return set_xml_value(extended_property, self.value_cls(value), version=version) def __hash__(self): return hash(self.name)
          @@ -4105,6 +3881,13 @@

          Subclasses

          +

          Class variables

          +
          +
          var is_complex
          +
          +
          +
          +

          Methods

          @@ -4119,7 +3902,7 @@

          Methods

          def clean(self, value, version=None):
               if value is None:
                   if self.is_required:
          -            raise ValueError("'%s' is a required field" % self.name)
          +            raise ValueError(f"{self.name!r} is a required field")
                   return self.default
               if not isinstance(value, self.value_cls):
                   # Allow keeping ExtendedProperty field values as their simple Python type, but run clean() anyway
          @@ -4152,44 +3935,16 @@ 

          Methods

          ).to_xml(version=None)
          -
          -def from_xml(self, elem, account) -
          -
          -
          -
          - -Expand source code - -
          def from_xml(self, elem, account):
          -    extended_properties = elem.findall(self.value_cls.response_tag())
          -    for extended_property in extended_properties:
          -        if self.value_cls.is_property_instance(extended_property):
          -            return self.value_cls.from_xml(elem=extended_property, account=account)
          -    return self.default
          -
          -
          -
          -def to_xml(self, value, version) -
          -
          -
          -
          - -Expand source code - -
          def to_xml(self, value, version):
          -    extended_property = create_element(self.value_cls.request_tag())
          -    set_xml_value(extended_property, self.field_uri_xml(), version=version)
          -    if isinstance(value, self.value_cls):
          -        set_xml_value(extended_property, value, version=version)
          -    else:
          -        # Allow keeping ExtendedProperty field values as their simple Python type
          -        set_xml_value(extended_property, self.value_cls(value), version=version)
          -    return extended_property
          -
          -
          +

          Inherited members

          +
          class ExtendedPropertyListField @@ -4216,6 +3971,15 @@

          Class variables

        +

        Inherited members

        +
        class Field @@ -4243,7 +4007,7 @@

        Class variables

        def __init__(self, name=None, is_required=False, is_required_after_save=False, is_read_only=False, is_read_only_after_send=False, is_searchable=True, is_attribute=False, default=None, supported_from=None, deprecated_from=None): - self.name = name + self.name = name # Usually set by the EWSMeta metaclass self.default = default # Default value if none is given self.is_required = is_required # Some fields cannot be deleted on update. Default to True if 'is_required' is set @@ -4260,49 +4024,48 @@

        Class variables

        # The Exchange build when this field was introduced. When talking with versions prior to this version, # we will ignore this field. if supported_from is not None and not isinstance(supported_from, Build): - raise ValueError("'supported_from' %r must be a Build instance" % supported_from) + raise InvalidTypeError('supported_from', supported_from, Build) self.supported_from = supported_from # The Exchange build when this field was deprecated. When talking with versions at or later than this version, # we will ignore this field. if deprecated_from is not None and not isinstance(deprecated_from, Build): - raise ValueError("'deprecated_from' %r must be a Build instance" % deprecated_from) + raise InvalidTypeError('deprecated_from', deprecated_from, Build) self.deprecated_from = deprecated_from def clean(self, value, version=None): if version and not self.supports_version(version): - raise InvalidFieldForVersion("Field '%s' does not support EWS builds prior to %s (server has %s)" % ( - self.name, self.supported_from, version)) + raise InvalidFieldForVersion( + f"Field {self.name!r} does not support EWS builds prior to {self.supported_from} (server has {version})" + ) if value is None: if self.is_required and self.default is None: - raise ValueError("'%s' is a required field with no default" % self.name) + raise ValueError(f"{self.name!r} is a required field with no default") return self.default if self.is_list: if not is_iterable(value): - raise ValueError("Field '%s' value %r must be a list" % (self.name, value)) + raise TypeError(f"Field {self.name!r} value {value!r} must be of type {list}") for v in value: if not isinstance(v, self.value_cls): - raise TypeError("Field '%s' value %r must be of type %s" % (self.name, v, self.value_cls)) + raise TypeError(f"Field {self.name!r} value {v!r} must be of type {self.value_cls}") if hasattr(v, 'clean'): v.clean(version=version) else: if not isinstance(value, self.value_cls): - raise TypeError("Field '%s' value %r must be of type %s" % (self.name, value, self.value_cls)) + raise TypeError(f"Field {self.name!r} value {value!r} must be of type {self.value_cls}") if hasattr(value, 'clean'): value.clean(version=version) return value @abc.abstractmethod def from_xml(self, elem, account): - pass + """Read a value from the given element""" @abc.abstractmethod def to_xml(self, value, version): - pass + """Convert this field to an XML element""" def supports_version(self, version): # 'version' is a Version instance, for convenience by callers - if not isinstance(version, Version): - raise ValueError("'version' %r must be a Version instance" % version) if self.supported_from and version.build < self.supported_from: return False if self.deprecated_from and version.build >= self.deprecated_from: @@ -4314,11 +4077,12 @@

        Class variables

        @abc.abstractmethod def __hash__(self): - pass + """Field instances must be hashable""" def __repr__(self): - return self.__class__.__name__ + '(%s)' % ', '.join('%s=%r' % (f, getattr(self, f)) for f in ( - 'name', 'value_cls', 'is_list', 'is_complex', 'default'))
        + args_str = ', '.join(f'{f}={getattr(self, f)!r}' for f in ( + 'name', 'value_cls', 'is_list', 'is_complex', 'default')) + return f'{self.__class__.__name__}({args_str})'

        Subclasses

          @@ -4354,23 +4118,24 @@

          Methods

          def clean(self, value, version=None):
               if version and not self.supports_version(version):
          -        raise InvalidFieldForVersion("Field '%s' does not support EWS builds prior to %s (server has %s)" % (
          -            self.name, self.supported_from, version))
          +        raise InvalidFieldForVersion(
          +            f"Field {self.name!r} does not support EWS builds prior to {self.supported_from} (server has {version})"
          +        )
               if value is None:
                   if self.is_required and self.default is None:
          -            raise ValueError("'%s' is a required field with no default" % self.name)
          +            raise ValueError(f"{self.name!r} is a required field with no default")
                   return self.default
               if self.is_list:
                   if not is_iterable(value):
          -            raise ValueError("Field '%s' value %r must be a list" % (self.name, value))
          +            raise TypeError(f"Field {self.name!r} value {value!r} must be of type {list}")
                   for v in value:
                       if not isinstance(v, self.value_cls):
          -                raise TypeError("Field '%s' value %r must be of type %s" % (self.name, v, self.value_cls))
          +                raise TypeError(f"Field {self.name!r} value {v!r} must be of type {self.value_cls}")
                       if hasattr(v, 'clean'):
                           v.clean(version=version)
               else:
                   if not isinstance(value, self.value_cls):
          -            raise TypeError("Field '%s' value %r must be of type %s" % (self.name, value, self.value_cls))
          +            raise TypeError(f"Field {self.name!r} value {value!r} must be of type {self.value_cls}")
                   if hasattr(value, 'clean'):
                       value.clean(version=version)
               return value
          @@ -4380,14 +4145,14 @@

          Methods

          def from_xml(self, elem, account)
        -
        +

        Read a value from the given element

        Expand source code
        @abc.abstractmethod
         def from_xml(self, elem, account):
        -    pass
        + """Read a value from the given element"""
        @@ -4401,8 +4166,6 @@

        Methods

        def supports_version(self, version):
             # 'version' is a Version instance, for convenience by callers
        -    if not isinstance(version, Version):
        -        raise ValueError("'version' %r must be a Version instance" % version)
             if self.supported_from and version.build < self.supported_from:
                 return False
             if self.deprecated_from and version.build >= self.deprecated_from:
        @@ -4414,14 +4177,14 @@ 

        Methods

        def to_xml(self, value, version)
        -
        +

        Convert this field to an XML element

        Expand source code
        @abc.abstractmethod
         def to_xml(self, value, version):
        -    pass
        + """Convert this field to an XML element"""
        @@ -4431,19 +4194,21 @@

        Methods

        (field_path, reverse=False)
      -

      Holds values needed to call server-side sorting on a single field path.

      +

      Holds values needed to call server-side sorting on a single field path.

      +

      :param field_path: A FieldPath instance +:param reverse: A bool

      Expand source code
      class FieldOrder:
           """Holds values needed to call server-side sorting on a single field path."""
      -
           def __init__(self, field_path, reverse=False):
      -        if not isinstance(field_path, FieldPath):
      -            raise ValueError("'field_path' %r must be a FieldPath instance" % field_path)
      -        if not isinstance(reverse, bool):
      -            raise ValueError("'reverse' %r must be a boolean" % reverse)
      +        """
      +
      +        :param field_path: A FieldPath instance
      +        :param reverse: A bool
      +        """
               self.field_path = field_path
               self.reverse = reverse
       
      @@ -4505,7 +4270,10 @@ 

      Methods

      Holds values needed to point to a single field. For indexed properties, we allow setting either field, field and label, or field, label and subfield. This allows pointing to either the full indexed property set, a -property with a specific label, or a particular subfield field on that property.

      +property with a specific label, or a particular subfield field on that property.

      +

      :param field: A FieldURIField or ExtendedPropertyField instance +:param label: a str +:param subfield: A SubField instance

      Expand source code @@ -4517,13 +4285,13 @@

      Methods

      """ def __init__(self, field, label=None, subfield=None): + """ + + :param field: A FieldURIField or ExtendedPropertyField instance + :param label: a str + :param subfield: A SubField instance + """ # 'label' and 'subfield' are only used for IndexedField fields - if not isinstance(field, (FieldURIField, ExtendedPropertyField)): - raise ValueError("'field' %r must be an FieldURIField, of ExtendedPropertyField instance" % field) - if label and not isinstance(label, str): - raise ValueError("'label' %r must be a %s instance" % (label, str)) - if subfield and not isinstance(subfield, SubField): - raise ValueError("'subfield' %r must be a SubField instance" % subfield) self.field = field self.label = label self.subfield = subfield @@ -4537,11 +4305,11 @@

      Methods

      # For indexed properties, get either the full property set, the property with matching label, or a particular # subfield. if self.label: - for subitem in getattr(item, self.field.name): - if subitem.label == self.label: + for sub_item in getattr(item, self.field.name): + if sub_item.label == self.label: if self.subfield: - return getattr(subitem, self.subfield.name) - return subitem + return getattr(sub_item, self.subfield.name) + return sub_item return None # No item with this label return getattr(item, self.field.name) @@ -4555,7 +4323,7 @@

      Methods

      def to_xml(self): if isinstance(self.field, IndexedField): if not self.label or not self.subfield: - raise ValueError("Field path for indexed field '%s' is missing label and/or subfield" % self.field.name) + raise ValueError(f"Field path for indexed field {self.field.name!r} is missing label and/or subfield") return self.subfield.field_uri_xml(field_uri=self.field.field_uri, label=self.label) return self.field.field_uri_xml() @@ -4577,8 +4345,8 @@

      Methods

      if self.label: from .indexed_properties import SingleFieldIndexedElement if issubclass(self.field.value_cls, SingleFieldIndexedElement) or not self.subfield: - return '%s__%s' % (self.field.name, self.label) - return '%s__%s__%s' % (self.field.name, self.label, self.subfield.name) + return f'{self.field.name}__{self.label}' + return f'{self.field.name}__{self.label}__{self.subfield.name}' return self.field.name def __eq__(self, other): @@ -4625,8 +4393,8 @@

      Instance variables

      if self.label: from .indexed_properties import SingleFieldIndexedElement if issubclass(self.field.value_cls, SingleFieldIndexedElement) or not self.subfield: - return '%s__%s' % (self.field.name, self.label) - return '%s__%s__%s' % (self.field.name, self.label, self.subfield.name) + return f'{self.field.name}__{self.label}' + return f'{self.field.name}__{self.label}__{self.subfield.name}' return self.field.name
      @@ -4686,11 +4454,11 @@

      Methods

      # For indexed properties, get either the full property set, the property with matching label, or a particular # subfield. if self.label: - for subitem in getattr(item, self.field.name): - if subitem.label == self.label: + for sub_item in getattr(item, self.field.name): + if sub_item.label == self.label: if self.subfield: - return getattr(subitem, self.subfield.name) - return subitem + return getattr(sub_item, self.subfield.name) + return sub_item return None # No item with this label return getattr(item, self.field.name) @@ -4707,7 +4475,7 @@

      Methods

      def to_xml(self):
           if isinstance(self.field, IndexedField):
               if not self.label or not self.subfield:
      -            raise ValueError("Field path for indexed field '%s' is missing label and/or subfield" % self.field.name)
      +            raise ValueError(f"Field path for indexed field {self.field.name!r} is missing label and/or subfield")
               return self.subfield.field_uri_xml(field_uri=self.field.field_uri, label=self.label)
           return self.field.field_uri_xml()
      @@ -4719,7 +4487,7 @@

      Methods

      (*args, **kwargs)
    -

    A field that has a FieldURI value in EWS. This means it's value is contained in an XML element or arrtibute. It +

    A field that has a FieldURI value in EWS. This means it's value is contained in an XML element or attribute. It may additionally be a label for searching, filtering and limiting fields. In that case, the FieldURI format will be 'itemtype:FieldName'

    @@ -4727,7 +4495,7 @@

    Methods

    Expand source code
    class FieldURIField(Field):
    -    """A field that has a FieldURI value in EWS. This means it's value is contained in an XML element or arrtibute. It
    +    """A field that has a FieldURI value in EWS. This means it's value is contained in an XML element or attribute. It
         may additionally be a label for searching, filtering and limiting fields. In that case, the FieldURI format will be
         'itemtype:FieldName'
         """
    @@ -4768,18 +4536,18 @@ 

    Methods

    def field_uri_xml(self): from .properties import FieldURI if not self.field_uri: - raise ValueError("'field_uri' value is missing") + raise ValueError(f"'field_uri' value is missing on field '{self.name}'") return FieldURI(field_uri=self.field_uri).to_xml(version=None) def request_tag(self): if not self.field_uri_postfix: - raise ValueError("'field_uri_postfix' value is missing") - return 't:%s' % self.field_uri_postfix + raise ValueError(f"'field_uri_postfix' value is missing on field '{self.name}'") + return f't:{self.field_uri_postfix}' def response_tag(self): if not self.field_uri_postfix: - raise ValueError("'field_uri_postfix' value is missing") - return '{%s}%s' % (self.namespace, self.field_uri_postfix) + raise ValueError(f"'field_uri_postfix' value is missing on field '{self.name}'") + return f'{{{self.namespace}}}{self.field_uri_postfix}' def __hash__(self): return hash(self.field_uri)
    @@ -4799,6 +4567,7 @@

    Subclasses

  • IntegerField
  • ItemField
  • TextField
  • +
  • TimeDeltaField
  • TimeField
  • TimeZoneField
  • TypeValueField
  • @@ -4817,30 +4586,10 @@

    Methods

    def field_uri_xml(self):
         from .properties import FieldURI
         if not self.field_uri:
    -        raise ValueError("'field_uri' value is missing")
    +        raise ValueError(f"'field_uri' value is missing on field '{self.name}'")
         return FieldURI(field_uri=self.field_uri).to_xml(version=None)
    -
    -def from_xml(self, elem, account) -
    -
    -
    -
    - -Expand source code - -
    def from_xml(self, elem, account):
    -    val = self._get_val_from_elem(elem)
    -    if val is not None:
    -        try:
    -            return xml_text_to_value(val, self.value_cls)
    -        except (ValueError, InvalidOperation):
    -            log.warning("Cannot convert value '%s' on field '%s' to type %s", val, self.name, self.value_cls)
    -            return None
    -    return self.default
    -
    -
    def request_tag(self)
    @@ -4852,8 +4601,8 @@

    Methods

    def request_tag(self):
         if not self.field_uri_postfix:
    -        raise ValueError("'field_uri_postfix' value is missing")
    -    return 't:%s' % self.field_uri_postfix
    + raise ValueError(f"'field_uri_postfix' value is missing on field '{self.name}'") + return f't:{self.field_uri_postfix}'
    @@ -4867,25 +4616,20 @@

    Methods

    def response_tag(self):
         if not self.field_uri_postfix:
    -        raise ValueError("'field_uri_postfix' value is missing")
    -    return '{%s}%s' % (self.namespace, self.field_uri_postfix)
    - - -
    -def to_xml(self, value, version) -
    -
    -
    -
    - -Expand source code - -
    def to_xml(self, value, version):
    -    field_elem = create_element(self.request_tag())
    -    return set_xml_value(field_elem, value, version=version)
    + raise ValueError(f"'field_uri_postfix' value is missing on field '{self.name}'") + return f'{{{self.namespace}}}{self.field_uri_postfix}'
    +

    Inherited members

    +
    class FreeBusyStatusField @@ -4916,6 +4660,8 @@

    Inherited members

    • ChoiceField:
    • @@ -4975,30 +4721,15 @@

      Class variables

      -

      Methods

      -
      -
      -def from_xml(self, elem, account) -
      -
      -
      -
      - -Expand source code - -
      def from_xml(self, elem, account):
      -    events = []
      -    for event in elem:
      -        # This may or may not be an event element. Could also be other child elements of Notification
      -        try:
      -            value_cls = self._event_types_map[event.tag]
      -        except KeyError:
      -            continue
      -        events.append(value_cls.from_xml(elem=event, account=account))
      -    return events or self.default
      -
      -
      -
      +

      Inherited members

      +
      class IdElementField @@ -5022,6 +4753,15 @@

      Ancestors

    • FieldURIField
    • Field
    +

    Inherited members

    +
    class IdField @@ -5058,6 +4798,8 @@

    Inherited members

    • CharField:
    • @@ -5073,7 +4815,7 @@

      Inherited members

      Expand source code -
      class IndexedField(EWSElementField):
      +
      class IndexedField(EWSElementField, metaclass=abc.ABCMeta):
           """A base class for all indexed fields."""
       
           PARENT_ELEMENT_NAME = None
      @@ -5082,14 +4824,14 @@ 

      Inherited members

      from .indexed_properties import IndexedElement value_cls = kwargs['value_cls'] if not issubclass(value_cls, IndexedElement): - raise ValueError("'value_cls' %r must be a subclass of IndexedElement" % value_cls) + raise TypeError(f"'value_cls' {value_cls!r} must be a subclass of type {IndexedElement}") super().__init__(*args, **kwargs) def to_xml(self, value, version): - return set_xml_value(create_element('t:%s' % self.PARENT_ELEMENT_NAME), value, version) + return set_xml_value(create_element(f't:{self.PARENT_ELEMENT_NAME}'), value, version=version) def response_tag(self): - return '{%s}%s' % (self.namespace, self.PARENT_ELEMENT_NAME) + return f'{{{self.namespace}}}{self.PARENT_ELEMENT_NAME}' def __hash__(self): return hash(self.field_uri)
      @@ -5125,23 +4867,19 @@

      Methods

      Expand source code
      def response_tag(self):
      -    return '{%s}%s' % (self.namespace, self.PARENT_ELEMENT_NAME)
      - - -
      -def to_xml(self, value, version) -
      -
      -
      -
      - -Expand source code - -
      def to_xml(self, value, version):
      -    return set_xml_value(create_element('t:%s' % self.PARENT_ELEMENT_NAME), value, version)
      + return f'{{{self.namespace}}}{self.PARENT_ELEMENT_NAME}'
      +

      Inherited members

      +
      class IntegerField @@ -5166,9 +4904,9 @@

      Methods

      def _clean_single_value(self, v): if self.min is not None and v < self.min: raise ValueError( - "Value %r on field '%s' must be greater than %s" % (v, self.name, self.min)) + f"Value {v!r} on field {self.name!r} must be greater than {self.min}") if self.max is not None and v > self.max: - raise ValueError("Value %r on field '%s' must be less than %s" % (v, self.name, self.max)) + raise ValueError(f"Value {v!r} on field {self.name!r} must be less than {self.max}") def clean(self, value, version=None): value = super().clean(value, version=version) @@ -5238,6 +4976,15 @@

      Methods

      +

      Inherited members

      +
      class InvalidChoiceForVersion @@ -5284,13 +5031,13 @@

      Ancestors

      (*args, **kwargs)
      -

      Used when a field is not supported on the given Exchnage version.

      +

      Used when a field is not supported on the given Exchange version.

      Expand source code
      class InvalidFieldForVersion(ValueError):
      -    """Used when a field is not supported on the given Exchnage version."""
      + """Used when a field is not supported on the given Exchange version."""

      Ancestors

        @@ -5304,7 +5051,7 @@

        Ancestors

        (*args, **kwargs)
    -

    A field that has a FieldURI value in EWS. This means it's value is contained in an XML element or arrtibute. It +

    A field that has a FieldURI value in EWS. This means it's value is contained in an XML element or attribute. It may additionally be a label for searching, filtering and limiting fields. In that case, the FieldURI format will be 'itemtype:FieldName'

    @@ -5350,41 +5097,15 @@

    Instance variables

    -

    Methods

    -
    -
    -def from_xml(self, elem, account) -
    -
    -
    -
    - -Expand source code - -
    def from_xml(self, elem, account):
    -    from .items import ITEM_CLASSES
    -    for item_cls in ITEM_CLASSES:
    -        item_elem = elem.find(item_cls.response_tag())
    -        if item_elem is not None:
    -            return item_cls.from_xml(elem=item_elem, account=account)
    -    return None
    -
    -
    -
    -def to_xml(self, value, version) -
    -
    -
    -
    - -Expand source code - -
    def to_xml(self, value, version):
    -    # We don't want to wrap in an Item element
    -    return value.to_xml(version=version)
    -
    -
    -
    +

    Inherited members

    +
    class LabelField @@ -5414,26 +5135,12 @@

    Ancestors

  • FieldURIField
  • Field
  • -

    Methods

    -
    -
    -def from_xml(self, elem, account) -
    -
    -
    -
    - -Expand source code - -
    def from_xml(self, elem, account):
    -    return elem.get(self.field_uri)
    -
    -
    -

    Inherited members

    -

    A base class for EWSElement classes that have an 'email_address' field that we want to provide helpers for.

    +

    Base class for EWSElement classes that have an 'email_address' field that we want to provide helpers for.

    Expand source code @@ -5462,6 +5169,15 @@

    Ancestors

  • FieldURIField
  • Field
  • +

    Inherited members

    +
    class MailboxListField @@ -5509,6 +5225,15 @@

    Methods

    +

    Inherited members

    +
    class MemberListField @@ -5562,6 +5287,15 @@

    Methods

    +

    Inherited members

    +
    class MessageField @@ -5582,14 +5316,14 @@

    Methods

    reply = elem.find(self.response_tag()) if reply is None: return None - message = reply.find('{%s}%s' % (TNS, self.INNER_ELEMENT_NAME)) + message = reply.find(f'{{{TNS}}}{self.INNER_ELEMENT_NAME}') if message is None: return None return message.text def to_xml(self, value, version): field_elem = create_element(self.request_tag()) - message = create_element('t:%s' % self.INNER_ELEMENT_NAME) + message = create_element(f't:{self.INNER_ELEMENT_NAME}') message.text = value return set_xml_value(field_elem, message, version=version)
    @@ -5606,48 +5340,12 @@

    Class variables

    -

    Methods

    -
    -
    -def from_xml(self, elem, account) -
    -
    -
    -
    - -Expand source code - -
    def from_xml(self, elem, account):
    -    reply = elem.find(self.response_tag())
    -    if reply is None:
    -        return None
    -    message = reply.find('{%s}%s' % (TNS, self.INNER_ELEMENT_NAME))
    -    if message is None:
    -        return None
    -    return message.text
    -
    -
    -
    -def to_xml(self, value, version) -
    -
    -
    -
    - -Expand source code - -
    def to_xml(self, value, version):
    -    field_elem = create_element(self.request_tag())
    -    message = create_element('t:%s' % self.INNER_ELEMENT_NAME)
    -    message.text = value
    -    return set_xml_value(field_elem, message, version=version)
    -
    -
    -

    Inherited members

    +

    Inherited members

    +
    class MimeContentField @@ -5705,6 +5412,8 @@

    Inherited members

    • Base64Field:
    • @@ -5744,13 +5453,13 @@

      Inherited members

      def field_uri_xml(self, field_uri, label): from .properties import IndexedFieldURI - return IndexedFieldURI(field_uri='%s:%s' % (field_uri, self.field_uri), field_index=label).to_xml(version=None) + return IndexedFieldURI(field_uri=f'{field_uri}:{self.field_uri}', field_index=label).to_xml(version=None) def request_tag(self): - return 't:%s' % self.field_uri + return f't:{self.field_uri}' def response_tag(self): - return '{%s}%s' % (self.namespace, self.field_uri)
      + return f'{{{self.namespace}}}{self.field_uri}'

      Ancestors

        @@ -5770,24 +5479,7 @@

        Methods

        def field_uri_xml(self, field_uri, label):
             from .properties import IndexedFieldURI
        -    return IndexedFieldURI(field_uri='%s:%s' % (field_uri, self.field_uri), field_index=label).to_xml(version=None)
        - - -
        -def from_xml(self, elem, account) -
        -
        -
        -
        - -Expand source code - -
        def from_xml(self, elem, account):
        -    field_elem = elem.find(self.response_tag())
        -    val = None if field_elem is None else field_elem.text or None
        -    if val is not None:
        -        return val
        -    return self.default
        + return IndexedFieldURI(field_uri=f'{field_uri}:{self.field_uri}', field_index=label).to_xml(version=None)
        @@ -5800,7 +5492,7 @@

        Methods

        Expand source code
        def request_tag(self):
        -    return 't:%s' % self.field_uri
        + return f't:{self.field_uri}'
        @@ -5813,21 +5505,7 @@

        Methods

        Expand source code
        def response_tag(self):
        -    return '{%s}%s' % (self.namespace, self.field_uri)
        - - -
        -def to_xml(self, value, version) -
        -
        -
        -
        - -Expand source code - -
        def to_xml(self, value, version):
        -    field_elem = create_element(self.request_tag())
        -    return set_xml_value(field_elem, value, version=version)
        + return f'{{{self.namespace}}}{self.field_uri}'
        @@ -5835,6 +5513,8 @@

        Inherited members

        +

        Inherited members

        +
        class ProtocolListField @@ -6144,29 +5908,22 @@

        Ancestors

      • FieldURIField
      • Field
      -

      Methods

      -
      -
      -def from_xml(self, elem, account) -
      -
      -
      -
      - -Expand source code - -
      def from_xml(self, elem, account):
      -    return [self.value_cls.from_xml(elem=e, account=account) for e in elem.findall(self.value_cls.response_tag())]
      -
      -
      -
      +

      Inherited members

      +
      class RecipientAddressField (*args, **kwargs)
      -

      A base class for EWSElement classes that have an 'email_address' field that we want to provide helpers for.

      +

      Base class for EWSElement classes that have an 'email_address' field that we want to provide helpers for.

      Expand source code @@ -6184,6 +5941,15 @@

      Ancestors

    • FieldURIField
    • Field
    +

    Inherited members

    +
    class RecurrenceField @@ -6219,22 +5985,15 @@

    Class variables

    -

    Methods

    -
    -
    -def to_xml(self, value, version) -
    -
    -
    -
    - -Expand source code - -
    def to_xml(self, value, version):
    -    return value.to_xml(version=version)
    -
    -
    -
    +

    Inherited members

    +
    class ReferenceItemIdField @@ -6270,22 +6029,15 @@

    Class variables

    -

    Methods

    -
    -
    -def to_xml(self, value, version) -
    -
    -
    -
    - -Expand source code - -
    def to_xml(self, value, version):
    -    return value.to_xml(version=version)
    -
    -
    -
    +

    Inherited members

    +
    class RoutingTypeField @@ -6315,6 +6067,8 @@

    Inherited members

    +

    Inherited members

    +
    class SubField @@ -6374,7 +6137,7 @@

    Ancestors

    def clean(self, value, version=None): value = super().clean(value, version=version) if self.is_required and not value: - raise ValueError('Value for subfield %r must be non-empty' % self.name) + raise ValueError(f'Value for subfield {self.name!r} must be non-empty') return value def __hash__(self): @@ -6440,37 +6203,20 @@

    Methods

    def clean(self, value, version=None):
         value = super().clean(value, version=version)
         if self.is_required and not value:
    -        raise ValueError('Value for subfield %r must be non-empty' % self.name)
    -    return value
    - - -
    -def from_xml(self, elem, account) -
    -
    -
    -
    - -Expand source code - -
    def from_xml(self, elem, account):
    -    return elem.text
    -
    -
    -
    -def to_xml(self, value, version) -
    -
    -
    -
    - -Expand source code - -
    def to_xml(self, value, version):
    +        raise ValueError(f'Value for subfield {self.name!r} must be non-empty')
         return value
    +

    Inherited members

    +
    class TaskRecurrenceField @@ -6506,22 +6252,15 @@

    Class variables

    -

    Methods

    -
    -
    -def to_xml(self, value, version) -
    -
    -
    -
    - -Expand source code - -
    def to_xml(self, value, version):
    -    return value.to_xml(version=version)
    -
    -
    -
    +

    Inherited members

    +
    class TextField @@ -6571,6 +6310,15 @@

    Class variables

    errors defaults to 'strict'.

    +

    Inherited members

    +
    class TextListField @@ -6587,11 +6335,27 @@

    Class variables

    is_list = True + def __init__(self, *args, **kwargs): + self.list_elem_name = kwargs.pop('list_elem_name', 'String') + super().__init__(*args, **kwargs) + + def list_elem_request_tag(self): + return f't:{self.list_elem_name}' + + def list_elem_response_tag(self): + return f'{{{self.namespace}}}{self.list_elem_name}' + def from_xml(self, elem, account): iter_elem = elem.find(self.response_tag()) if iter_elem is not None: - return get_xml_attrs(iter_elem, '{%s}String' % TNS) - return self.default
    + return get_xml_attrs(iter_elem, self.list_elem_response_tag()) + return self.default + + def to_xml(self, value, version): + field_elem = create_element(self.request_tag()) + for v in value: + field_elem.append(set_xml_value(create_element(self.list_elem_request_tag()), v, version=version)) + return field_elem

    Ancestors

    +

    Subclasses

    +

    Class variables

    var is_list
    @@ -6608,8 +6376,21 @@

    Class variables

    Methods

    -
    -def from_xml(self, elem, account) +
    +def list_elem_request_tag(self) +
    +
    +
    +
    + +Expand source code + +
    def list_elem_request_tag(self):
    +    return f't:{self.list_elem_name}'
    +
    +
    +
    +def list_elem_response_tag(self)
    @@ -6617,11 +6398,8 @@

    Methods

    Expand source code -
    def from_xml(self, elem, account):
    -    iter_elem = elem.find(self.response_tag())
    -    if iter_elem is not None:
    -        return get_xml_attrs(iter_elem, '{%s}String' % TNS)
    -    return self.default
    +
    def list_elem_response_tag(self):
    +    return f'{{{self.namespace}}}{self.list_elem_name}'
    @@ -6629,11 +6407,84 @@

    Inherited members

    +
    +class TimeDeltaField +(*args, **kwargs) +
    +
    +

    A field that handles timedelta values.

    +
    + +Expand source code + +
    class TimeDeltaField(FieldURIField):
    +    """A field that handles timedelta values."""
    +
    +    value_cls = datetime.timedelta
    +
    +    def __init__(self, *args, **kwargs):
    +        self.min = kwargs.pop('min', datetime.timedelta(0))
    +        self.max = kwargs.pop('max', datetime.timedelta(days=1))
    +        super().__init__(*args, **kwargs)
    +
    +    def clean(self, value, version=None):
    +        if self.min is not None and value < self.min:
    +            raise ValueError(
    +                f"Value {value!r} on field {self.name!r} must be greater than {self.min}")
    +        if self.max is not None and value > self.max:
    +            raise ValueError(f"Value {value!r} on field {self.name!r} must be less than {self.max}")
    +        return super().clean(value, version=version)
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var value_cls
    +
    +

    Difference between two datetime values.

    +
    +
    +

    Methods

    +
    +
    +def clean(self, value, version=None) +
    +
    +
    +
    + +Expand source code + +
    def clean(self, value, version=None):
    +    if self.min is not None and value < self.min:
    +        raise ValueError(
    +            f"Value {value!r} on field {self.name!r} must be greater than {self.min}")
    +    if self.max is not None and value > self.max:
    +        raise ValueError(f"Value {value!r} on field {self.name!r} must be less than {self.max}")
    +    return super().clean(value, version=version)
    +
    +
    +
    +

    Inherited members

    + +
    class TimeField (*args, **kwargs) @@ -6676,32 +6527,15 @@

    Class variables

    a tzinfo subclass. The remaining arguments may be ints.

    -

    Methods

    -
    -
    -def from_xml(self, elem, account) -
    -
    -
    -
    - -Expand source code - -
    def from_xml(self, elem, account):
    -    val = self._get_val_from_elem(elem)
    -    if val is not None:
    -        try:
    -            if ':' in val:
    -                # Assume a string of the form HH:MM:SS
    -                return datetime.datetime.strptime(val, '%H:%M:%S').time()
    -            # Assume an integer in minutes since midnight
    -            return (datetime.datetime(2000, 1, 1) + datetime.timedelta(minutes=int(val))).time()
    -        except ValueError:
    -            pass
    -    return self.default
    -
    -
    -
    +

    Inherited members

    +
    class TimeZoneField @@ -6740,7 +6574,7 @@

    Methods

    return self.default def to_xml(self, value, version): - attrs = OrderedDict([('Id', value.ms_id)]) + attrs = dict(Id=value.ms_id) if value.ms_name: attrs['Name'] = value.ms_name return create_element(self.request_tag(), attrs=attrs)
    @@ -6776,48 +6610,57 @@

    Methods

    return super().clean(value=value, version=version)
    -
    -def from_xml(self, elem, account) -
    -
    -
    -
    - -Expand source code - -
    def from_xml(self, elem, account):
    -    field_elem = elem.find(self.response_tag())
    -    if field_elem is not None:
    -        ms_id = field_elem.get('Id')
    -        ms_name = field_elem.get('Name')
    -        try:
    -            return self.value_cls.from_ms_id(ms_id or ms_name)
    -        except UnknownTimeZone:
    -            log.warning(
    -                "Cannot convert value '%s' on field '%s' to type %s (unknown timezone ID)",
    -                (ms_id or ms_name), self.name, self.value_cls
    -            )
    -            return None
    -    return self.default
    -
    + +

    Inherited members

    +
    -
    -def to_xml(self, value, version) +
    +class TransitionListField +(*args, **kwargs)
    -
    +

    Like EWSElementField, but for lists of EWSElement objects.

    Expand source code -
    def to_xml(self, value, version):
    -    attrs = OrderedDict([('Id', value.ms_id)])
    -    if value.ms_name:
    -        attrs['Name'] = value.ms_name
    -    return create_element(self.request_tag(), attrs=attrs)
    +
    class TransitionListField(EWSElementListField):
    +    def __init__(self, *args, **kwargs):
    +        from .properties import BaseTransition
    +        kwargs['value_cls'] = BaseTransition
    +        super().__init__(*args, **kwargs)
    +
    +    def from_xml(self, elem, account):
    +        iter_elem = elem.find(self.response_tag()) if self.field_uri else elem
    +        if iter_elem is not None:
    +            return [
    +                self.value_cls.transition_model_from_tag(e.tag).from_xml(elem=e, account=account) for e in iter_elem
    +            ]
    +        return self.default
    -
    - +

    Ancestors

    + +

    Inherited members

    +
    class TypeValueField @@ -6865,9 +6708,9 @@

    Methods

    first = next(iter(value)) except StopIteration: first = None - value_type = '%sArray' % cls.TYPES_MAP_REVERSED[type(first)] + value_type = f'{cls.TYPES_MAP_REVERSED[type(first)]}Array' if value_type not in cls.TYPES_MAP: - raise ValueError('%r is not a supported type' % value) + raise ValueError(f'{value!r} is not a supported type') return value_type return cls.TYPES_MAP_REVERSED[type(value)] @@ -6878,7 +6721,7 @@

    Methods

    def clean(self, value, version=None): if value is None: if self.is_required and self.default is None: - raise ValueError("'%s' is a required field with no default" % self.name) + raise ValueError(f"{self.name!r} is a required field with no default") return self.default return value @@ -6886,8 +6729,8 @@

    Methods

    field_elem = elem.find(self.response_tag()) if field_elem is None: return self.default - value_type_str = get_xml_attr(field_elem, '{%s}Type' % TNS) - value = get_xml_attr(field_elem, '{%s}Value' % TNS) + value_type_str = get_xml_attr(field_elem, f'{{{TNS}}}Type') + value = get_xml_attr(field_elem, f'{{{TNS}}}Value') if value_type_str == 'Byte': try: # The value is an unsigned integer in the range 0 -> 255. Convert it to a single byte @@ -6950,9 +6793,9 @@

    Static methods

    first = next(iter(value)) except StopIteration: first = None - value_type = '%sArray' % cls.TYPES_MAP_REVERSED[type(first)] + value_type = f'{cls.TYPES_MAP_REVERSED[type(first)]}Array' if value_type not in cls.TYPES_MAP: - raise ValueError('%r is not a supported type' % value) + raise ValueError(f'{value!r} is not a supported type') return value_type return cls.TYPES_MAP_REVERSED[type(value)]
    @@ -6986,62 +6829,21 @@

    Methods

    def clean(self, value, version=None):
         if value is None:
             if self.is_required and self.default is None:
    -            raise ValueError("'%s' is a required field with no default" % self.name)
    +            raise ValueError(f"{self.name!r} is a required field with no default")
             return self.default
         return value
    -
    -def from_xml(self, elem, account) -
    -
    -
    -
    - -Expand source code - -
    def from_xml(self, elem, account):
    -    field_elem = elem.find(self.response_tag())
    -    if field_elem is None:
    -        return self.default
    -    value_type_str = get_xml_attr(field_elem, '{%s}Type' % TNS)
    -    value = get_xml_attr(field_elem, '{%s}Value' % TNS)
    -    if value_type_str == 'Byte':
    -        try:
    -            # The value is an unsigned integer in the range 0 -> 255. Convert it to a single byte
    -            return xml_text_to_value(value, int).to_bytes(1, 'little', signed=False)
    -        except OverflowError as e:
    -            log.warning('Invalid byte value %r (%e)', value, e)
    -            return None
    -    value_type = self.TYPES_MAP[value_type_str]
    -    if self. is_array_type(value_type_str):
    -        return tuple(xml_text_to_value(value=v, value_type=value_type) for v in value.split(' '))
    -    return xml_text_to_value(value=value, value_type=value_type)
    -
    -
    -
    -def to_xml(self, value, version) -
    -
    -
    -
    - -Expand source code - -
    def to_xml(self, value, version):
    -    value_type_str = self.get_type(value)
    -    if value_type_str == 'Byte':
    -        # A single byte is encoded to an unsigned integer in the range 0 -> 255
    -        value = int.from_bytes(value, byteorder='little', signed=False)
    -    elif is_iterable(value):
    -        value = ' '.join(value_to_xml_text(v) for v in value)
    -    field_elem = create_element(self.request_tag())
    -    field_elem.append(set_xml_value(create_element('t:Type'), value_type_str, version=version))
    -    field_elem.append(set_xml_value(create_element('t:Value'), value, version=version))
    -    return field_elem
    -
    -
    +

    Inherited members

    +
    class URIField @@ -7049,14 +6851,14 @@

    Methods

    Helper to mark strings that must conform to xsd:anyURI. -If we want an URI validator, see http://stackoverflow.com/questions/14466585/is-this-regex-correct-for-xsdanyuri

    +If we want a URI validator, see https://stackoverflow.com/questions/14466585/is-this-regex-correct-for-xsdanyuri

    Expand source code
    class URIField(TextField):
         """Helper to mark strings that must conform to xsd:anyURI.
    -    If we want an URI validator, see http://stackoverflow.com/questions/14466585/is-this-regex-correct-for-xsdanyuri
    +    If we want a URI validator, see https://stackoverflow.com/questions/14466585/is-this-regex-correct-for-xsdanyuri
         """

    Ancestors

    @@ -7069,6 +6871,8 @@

    Inherited members

    • TextField:
    • @@ -7079,19 +6883,19 @@

      Inherited members

      (*args, **kwargs)
      -

      Like CharField, but for lists of strings.

      +

      Like TextListField, but for string values with a limited length.

      Expand source code
      class UnknownEntriesField(CharListField):
           def list_elem_tag(self):
      -        return '{%s}UnknownEntry' % self.namespace
      + return f'{{{self.namespace}}}UnknownEntry'

      Ancestors

      @@ -7116,11 +6920,68 @@

      Inherited members

    +
    +class WeekdaysField +(*args, **kwargs) +
    +
    +

    Like EnumListField, allow a single value instead of a 1-element list.

    +
    + +Expand source code + +
    class WeekdaysField(EnumListField):
    +    """Like EnumListField, allow a single value instead of a 1-element list."""
    +
    +    def clean(self, value, version=None):
    +        if isinstance(value, (int, str)):
    +            value = [value]
    +        return super().clean(value, version)
    +
    +

    Ancestors

    + +

    Methods

    +
    +
    +def clean(self, value, version=None) +
    +
    +
    +
    + +Expand source code + +
    def clean(self, value, version=None):
    +    if isinstance(value, (int, str)):
    +        value = [value]
    +    return super().clean(value, version)
    +
    +
    +
    +

    Inherited members

    + +
    @@ -7145,27 +7006,22 @@

    Index

  • @@ -7399,10 +7230,8 @@

    FieldURIField

  • @@ -7411,7 +7240,6 @@

    GenericEventListField

  • @@ -7426,7 +7254,6 @@

  • PARENT_ELEMENT_NAME
  • response_tag
  • -
  • to_xml
  • @@ -7448,16 +7275,11 @@

    ItemField

  • LabelField

    -
  • MailboxField

    @@ -7478,8 +7300,6 @@

    MessageField

  • @@ -7492,10 +7312,8 @@

    NamedSubField

  • @@ -7532,6 +7350,7 @@

    PhoneNumberField

  • @@ -7539,6 +7358,7 @@

    PhysicalAddressField

    @@ -7547,9 +7367,6 @@

    ProtocolListField

    -
  • RecipientAddressField

    @@ -7558,14 +7375,12 @@

    RecurrenceField

  • ReferenceItemIdField

  • @@ -7576,12 +7391,10 @@

    SubField

    -
  • diff --git a/docs/exchangelib/folders/base.html b/docs/exchangelib/folders/base.html index ce911729..fc009503 100644 --- a/docs/exchangelib/folders/base.html +++ b/docs/exchangelib/folders/base.html @@ -31,28 +31,21 @@

    Module exchangelib.folders.base

    from fnmatch import fnmatch from operator import attrgetter -from .collections import FolderCollection, SyncCompleted -from .queryset import SingleFolderQuerySet, SHALLOW as SHALLOW_FOLDERS, DEEP as DEEP_FOLDERS +from .collections import FolderCollection, SyncCompleted, PullSubscription, PushSubscription, StreamingSubscription +from .queryset import SingleFolderQuerySet, MISSING_FOLDER_ERRORS, SHALLOW as SHALLOW_FOLDERS, DEEP as DEEP_FOLDERS from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorCannotEmptyFolder, ErrorCannotDeleteObject, \ - ErrorDeleteDistinguishedFolder, ErrorNoPublicFolderReplicaAvailable, ErrorItemNotFound + ErrorDeleteDistinguishedFolder, ErrorItemNotFound, InvalidTypeError from ..fields import IntegerField, CharField, FieldPath, EffectiveRightsField, PermissionSetField, EWSElementField, \ Field, IdElementField, InvalidField -from ..items import CalendarItem, RegisterMixIn, ITEM_CLASSES, DELETE_TYPE_CHOICES, HARD_DELETE, \ - SHALLOW as SHALLOW_ITEMS +from ..items import CalendarItem, RegisterMixIn, ITEM_CLASSES, HARD_DELETE, SHALLOW as SHALLOW_ITEMS from ..properties import Mailbox, FolderId, ParentFolderId, DistinguishedFolderId, UserConfiguration, \ UserConfigurationName, UserConfigurationNameMNS, EWSMeta from ..queryset import SearchableMixIn, DoesNotExist -from ..services import CreateFolder, UpdateFolder, DeleteFolder, EmptyFolder, GetUserConfiguration, \ - CreateUserConfiguration, UpdateUserConfiguration, DeleteUserConfiguration, SubscribeToPush, SubscribeToPull, \ - Unsubscribe, GetEvents, GetStreamingEvents, MoveFolder -from ..services.get_user_configuration import ALL from ..util import TNS, require_id, is_iterable -from ..version import Version, EXCHANGE_2007_SP1, EXCHANGE_2010 +from ..version import EXCHANGE_2007_SP1, EXCHANGE_2010 log = logging.getLogger(__name__) -MISSING_FOLDER_ERRORS = (ErrorFolderNotFound, ErrorItemNotFound, ErrorNoPublicFolderReplicaAvailable) - class BaseFolder(RegisterMixIn, SearchableMixIn, metaclass=EWSMeta): """Base class for all classes that implement a folder.""" @@ -99,17 +92,17 @@

    Module exchangelib.folders.base

    @property @abc.abstractmethod def account(self): - pass + """Return the account this folder belongs to""" @property @abc.abstractmethod def root(self): - pass + """Return the root folder this folder belongs to""" @property @abc.abstractmethod def parent(self): - pass + """Return the parent folder of this folder""" @property def is_deletable(self): @@ -138,7 +131,7 @@

    Module exchangelib.folders.base

    @property def absolute(self): - return ''.join('/%s' % p.name for p in self.parts) + return ''.join(f'/{p.name}' for p in self.parts) def _walk(self): for c in self.children: @@ -192,29 +185,27 @@

    Module exchangelib.folders.base

    ├── exchangelib issues └── Mom """ - tree = '%s\n' % self.name + tree = f'{self.name}\n' children = list(self.children) for i, c in enumerate(sorted(children, key=attrgetter('name')), start=1): nodes = c.tree().split('\n') for j, node in enumerate(nodes, start=1): if i != len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += '├── %s\n' % node + tree += f'├── {node}\n' elif i != len(children) and j > 1: # Not the last child, and not name of child - tree += '│ %s\n' % node + tree += f'│ {node}\n' elif i == len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += '└── %s\n' % node + tree += f'└── {node}\n' else: # Last child, and not name of child - tree += ' %s\n' % node + tree += f' {node}\n' return tree.strip() @classmethod def supports_version(cls, version): # 'version' is a Version instance, for convenience by callers - if not isinstance(version, Version): - raise ValueError("'version' %r must be a Version instance" % version) if not cls.supported_from: return True return version.build >= cls.supported_from @@ -251,31 +242,18 @@

    Module exchangelib.folders.base

    try: return cls.ITEM_MODEL_MAP[tag] except KeyError: - raise ValueError('Item type %s was unexpected in a %s folder' % (tag, cls.__name__)) + raise ValueError(f'Item type {tag} was unexpected in a {cls.__name__} folder') @classmethod def allowed_item_fields(cls, version): # Return non-ID fields of all item classes allowed in this folder type fields = set() for item_model in cls.supported_item_models: - fields.update( - set(item_model.supported_fields(version=version)) - ) + fields.update(set(item_model.supported_fields(version=version))) return fields def validate_item_field(self, field, version): - # Takes a fieldname, Field or FieldPath object pointing to an item field, and checks that it is valid - # for the item types supported by this folder. - - # For each field, check if the field is valid for any of the item models supported by this folder - for item_model in self.supported_item_models: - try: - item_model.validate_field(field=field, version=version) - break - except InvalidField: - continue - else: - raise InvalidField("%r is not a valid field on %s" % (field, self.supported_item_models)) + FolderCollection(account=self.account, folders=[self]).validate_item_field(field=field, version=version) def normalize_fields(self, fields): # Takes a list of fieldnames, Field or FieldPath objects pointing to item fields. Turns them into FieldPath @@ -290,8 +268,6 @@

    Module exchangelib.folders.base

    elif isinstance(field_path, Field): field_path = FieldPath(field=field_path) fields[i] = field_path - if not isinstance(field_path, FieldPath): - raise ValueError("Field %r must be a string or FieldPath instance" % field_path) if field_path.field.name == 'start': has_start = True elif field_path.field.name == 'end': @@ -317,7 +293,7 @@

    Module exchangelib.folders.base

    return item_model.get_field_by_fieldname(fieldname) except InvalidField: pass - raise InvalidField("%r is not a valid field name on %s" % (fieldname, cls.supported_item_models)) + raise InvalidField(f"{fieldname!r} is not a valid field name on {cls.supported_item_models}") def get(self, *args, **kwargs): return FolderCollection(account=self.account, folders=[self]).get(*args, **kwargs) @@ -342,6 +318,7 @@

    Module exchangelib.folders.base

    return self.account.bulk_create(folder=self, items=items, *args, **kwargs) def save(self, update_fields=None): + from ..services import CreateFolder, UpdateFolder if self.id is None: # New folder if update_fields: @@ -375,6 +352,7 @@

    Module exchangelib.folders.base

    return self def move(self, to_folder): + from ..services import MoveFolder res = MoveFolder(account=self.account).get(folders=[self], to_folder=to_folder) folder_id, changekey = res.id, res.changekey if self.id != folder_id: @@ -385,15 +363,13 @@

    Module exchangelib.folders.base

    self.root.update_folder(self) # Update the folder in the cache def delete(self, delete_type=HARD_DELETE): - if delete_type not in DELETE_TYPE_CHOICES: - raise ValueError("'delete_type' %s must be one of %s" % (delete_type, DELETE_TYPE_CHOICES)) + from ..services import DeleteFolder DeleteFolder(account=self.account).get(folders=[self], delete_type=delete_type) self.root.remove_folder(self) # Remove the updated folder from the cache self._id = None def empty(self, delete_type=HARD_DELETE, delete_sub_folders=False): - if delete_type not in DELETE_TYPE_CHOICES: - raise ValueError("'delete_type' %s must be one of %s" % (delete_type, DELETE_TYPE_CHOICES)) + from ..services import EmptyFolder EmptyFolder(account=self.account).get( folders=[self], delete_type=delete_type, delete_sub_folders=delete_sub_folders ) @@ -401,14 +377,14 @@

    Module exchangelib.folders.base

    # We don't know exactly what was deleted, so invalidate the entire folder cache to be safe self.root.clear_cache() - def wipe(self, page_size=None, _seen=None, _level=0): + def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0): # Recursively deletes all items in this folder, and all subfolders and their content. Attempts to protect # distinguished folders from being deleted. Use with caution! _seen = _seen or set() if self.id in _seen: - raise RecursionError('We already tried to wipe %s' % self) + raise RecursionError(f'We already tried to wipe {self}') if _level > 16: - raise RecursionError('Max recursion level reached: %s' % _level) + raise RecursionError(f'Max recursion level reached: {_level}') _seen.add(self.id) log.warning('Wiping %s', self) has_distinguished_subfolders = any(f.is_distinguished for f in self.children) @@ -424,13 +400,18 @@

    Module exchangelib.folders.base

    self.empty(delete_sub_folders=False) except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): log.warning('Not allowed to empty %s. Trying to delete items instead', self) + kwargs = {} + if page_size is not None: + kwargs['page_size'] = page_size + if chunk_size is not None: + kwargs['chunk_size'] = chunk_size try: - self.all().delete(**dict(page_size=page_size) if page_size else {}) + self.all().delete(**kwargs) except (ErrorAccessDenied, ErrorCannotDeleteObject, ErrorItemNotFound): log.warning('Not allowed to delete items in %s', self) _level += 1 for f in self.children: - f.wipe(page_size=page_size, _seen=_seen, _level=_level) + f.wipe(page_size=page_size, chunk_size=chunk_size, _seen=_seen, _level=_level) # Remove non-distinguished children that are empty and have no subfolders if f.is_deletable and not f.children: log.warning('Deleting folder %s', f) @@ -457,7 +438,7 @@

    Module exchangelib.folders.base

    kwargs['name'] = cls.DISTINGUISHED_FOLDER_ID return kwargs - def to_folder_id(self): + def to_id(self): if self.is_distinguished: # Don't add the changekey here. When modifying folder content, we usually don't care if others have changed # the folder content since we fetched the changekey. @@ -471,29 +452,19 @@

    Module exchangelib.folders.base

    return FolderId(id=self.id, changekey=self.changekey) raise ValueError('Must be a distinguished folder or have an ID') - def to_xml(self, version): - try: - return self.to_folder_id().to_xml(version=version) - except ValueError: - return super().to_xml(version=version) - - def to_id_xml(self, version): - # Folder(name='Foo') is a perfectly valid ID to e.g. create a folder - return self.to_xml(version=version) - @classmethod def resolve(cls, account, folder): # Resolve a single folder folders = list(FolderCollection(account=account, folders=[folder]).resolve()) if not folders: - raise ErrorFolderNotFound('Could not find folder %r' % folder) + raise ErrorFolderNotFound(f'Could not find folder {folder!r}') if len(folders) != 1: - raise ValueError('Expected result length 1, but got %s' % folders) + raise ValueError(f'Expected result length 1, but got {folders}') f = folders[0] if isinstance(f, Exception): raise f if f.__class__ != cls: - raise ValueError("Expected folder %r to be a %s instance" % (f, cls)) + raise ValueError(f"Expected folder {f!r} to be a {cls} instance") return f @require_id @@ -507,7 +478,11 @@

    Module exchangelib.folders.base

    return self @require_id - def get_user_configuration(self, name, properties=ALL): + def get_user_configuration(self, name, properties=None): + from ..services import GetUserConfiguration + from ..services.get_user_configuration import ALL + if properties is None: + properties = ALL return GetUserConfiguration(account=self.account).get( user_configuration_name=UserConfigurationNameMNS(name=name, folder=self), properties=properties, @@ -515,6 +490,7 @@

    Module exchangelib.folders.base

    @require_id def create_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None): + from ..services import CreateUserConfiguration user_configuration = UserConfiguration( user_configuration_name=UserConfigurationName(name=name, folder=self), dictionary=dictionary, @@ -525,6 +501,7 @@

    Module exchangelib.folders.base

    @require_id def update_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None): + from ..services import UpdateUserConfiguration user_configuration = UserConfiguration( user_configuration_name=UserConfigurationName(name=name, folder=self), dictionary=dictionary, @@ -535,12 +512,13 @@

    Module exchangelib.folders.base

    @require_id def delete_user_configuration(self, name): + from ..services import DeleteUserConfiguration return DeleteUserConfiguration(account=self.account).get( user_configuration_name=UserConfigurationNameMNS(name=name, folder=self) ) @require_id - def subscribe_to_pull(self, event_types=SubscribeToPull.EVENT_TYPES, watermark=None, timeout=60): + def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): """Create a pull subscription. :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPull.EVENT_TYPES @@ -549,19 +527,15 @@

    Module exchangelib.folders.base

    GetEvents request for this subscription. :return: The subscription ID and a watermark """ - s_ids = list(FolderCollection(account=self.account, folders=[self]).subscribe_to_pull( + from ..services import SubscribeToPull + if event_types is None: + event_types = SubscribeToPull.EVENT_TYPES + return FolderCollection(account=self.account, folders=[self]).subscribe_to_pull( event_types=event_types, watermark=watermark, timeout=timeout, - )) - if len(s_ids) != 1: - raise ValueError('Expected result length 1, but got %s' % s_ids) - s_id = s_ids[0] - if isinstance(s_id, Exception): - raise s_id - return s_id + ) @require_id - def subscribe_to_push(self, callback_url, event_types=SubscribeToPush.EVENT_TYPES, watermark=None, - status_frequency=1): + def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1): """Create a push subscription. :param callback_url: A client-defined URL that the server will call @@ -570,32 +544,24 @@

    Module exchangelib.folders.base

    :param status_frequency: The frequency, in minutes, that the callback URL will be called with. :return: The subscription ID and a watermark """ - s_ids = list(FolderCollection(account=self.account, folders=[self]).subscribe_to_push( + from ..services import SubscribeToPush + if event_types is None: + event_types = SubscribeToPush.EVENT_TYPES + return FolderCollection(account=self.account, folders=[self]).subscribe_to_push( event_types=event_types, watermark=watermark, status_frequency=status_frequency, callback_url=callback_url, - )) - if len(s_ids) != 1: - raise ValueError('Expected result length 1, but got %s' % s_ids) - s_id = s_ids[0] - if isinstance(s_id, Exception): - raise s_id - return s_id + ) @require_id - def subscribe_to_streaming(self, event_types=SubscribeToPush.EVENT_TYPES): + def subscribe_to_streaming(self, event_types=None): """Create a streaming subscription. :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES :return: The subscription ID """ - s_ids = list(FolderCollection(account=self.account, folders=[self]).subscribe_to_streaming( - event_types=event_types, - )) - if len(s_ids) != 1: - raise ValueError('Expected result length 1, but got %s' % s_ids) - s_id = s_ids[0] - if isinstance(s_id, Exception): - raise s_id - return s_id + from ..services import SubscribeToStreaming + if event_types is None: + event_types = SubscribeToStreaming.EVENT_TYPES + return FolderCollection(account=self.account, folders=[self]).subscribe_to_streaming(event_types=event_types) @require_id def pull_subscription(self, **kwargs): @@ -618,18 +584,19 @@

    Module exchangelib.folders.base

    This method doesn't need the current folder instance, but it makes sense to keep the method along the other sync methods. """ + from ..services import Unsubscribe return Unsubscribe(account=self.account).get(subscription_id=subscription_id) def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None): """Return all item changes to a folder, as a generator. If sync_state is specified, get all item changes after this sync state. After fully consuming the generator, self.item_sync_state will hold the new sync state. - :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderitems service. + :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderItems service. :param only_fields: A list of string or FieldPath items specifying the fields to fetch. Default to all fields :param ignore: A list of Item IDs to ignore in the sync :param max_changes_returned: The max number of change :param sync_scope: Specify whether to return just items, or items and folder associated information. Possible - values are specified in SyncFolderitems.SYNC_SCOPES + values are specified in SyncFolderItems.SYNC_SCOPES :return: A generator of (change_type, item) tuples """ if not sync_state: @@ -651,7 +618,7 @@

    Module exchangelib.folders.base

    changes after this sync state. After fully consuming the generator, self.folder_sync_state will hold the new sync state. - :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderitems service. + :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderItems service. :param only_fields: A list of string or FieldPath items specifying the fields to fetch. Default to all fields :return: """ @@ -676,6 +643,7 @@

    Module exchangelib.folders.base

    This method doesn't need the current folder instance, but it makes sense to keep the method along the other sync methods. """ + from ..services import GetEvents svc = GetEvents(account=self.account) while True: notification = svc.get(subscription_id=subscription_id, watermark=watermark) @@ -696,9 +664,8 @@

    Module exchangelib.folders.base

    This method doesn't need the current folder instance, but it makes sense to keep the method along the other sync methods. """ - # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed - request_timeout = connection_timeout*60 + 60 - svc = GetStreamingEvents(account=self.account, timeout=request_timeout) + from ..services import GetStreamingEvents + svc = GetStreamingEvents(account=self.account) subscription_ids = subscription_id_or_ids if is_iterable(subscription_id_or_ids, generators_allowed=True) \ else [subscription_id_or_ids] for i, notification in enumerate( @@ -730,7 +697,7 @@

    Module exchangelib.folders.base

    try: return SingleFolderQuerySet(account=self.account, folder=self).depth(SHALLOW_FOLDERS).get(name=other) except DoesNotExist: - raise ErrorFolderNotFound("No subfolder with name '%s'" % other) + raise ErrorFolderNotFound(f"No subfolder with name {other!r}") def __truediv__(self, other): """Support the some_folder / 'child_folder' / 'child_of_child_folder' navigation syntax.""" @@ -743,7 +710,7 @@

    Module exchangelib.folders.base

    for c in self.children: if c.name == other: return c - raise ErrorFolderNotFound("No subfolder with name '%s'" % other) + raise ErrorFolderNotFound(f"No subfolder with name {other!r}") def __repr__(self): return self.__class__.__name__ + \ @@ -751,7 +718,7 @@

    Module exchangelib.folders.base

    self.folder_class, self.id, self.changekey)) def __str__(self): - return '%s (%s)' % (self.__class__.__name__, self.name) + return f'{self.__class__.__name__} ({self.name})' class Folder(BaseFolder): @@ -816,7 +783,7 @@

    Module exchangelib.folders.base

    folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound('Could not find distinguished folder %r' % cls.DISTINGUISHED_FOLDER_ID) + raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}') @property def parent(self): @@ -833,7 +800,7 @@

    Module exchangelib.folders.base

    self.parent_folder_id = None else: if not isinstance(value, BaseFolder): - raise ValueError("'value' %r must be a Folder instance" % value) + raise InvalidTypeError('value', value, BaseFolder) self.root = value.root self.parent_folder_id = ParentFolderId(id=value.id, changekey=value.changekey) @@ -841,7 +808,7 @@

    Module exchangelib.folders.base

    from .roots import RootOfHierarchy super().clean(version=version) if self.root and not isinstance(self.root, RootOfHierarchy): - raise ValueError("'root' %r must be a RootOfHierarchy instance" % self.root) + raise InvalidTypeError('root', self.root, RootOfHierarchy) @classmethod def from_xml_with_root(cls, elem, root): @@ -881,43 +848,7 @@

    Module exchangelib.folders.base

    pass if folder_cls == Folder: log.debug('Fallback to class Folder (folder_class %s, name %s)', folder.folder_class, folder.name) - return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS}) - - -class BaseSubscription(metaclass=abc.ABCMeta): - def __init__(self, folder, **subscription_kwargs): - self.folder = folder - self.subscription_kwargs = subscription_kwargs - self.subscription_id = None - - def __enter__(self): - pass - - def __exit__(self, *args, **kwargs): - self.folder.unsubscribe(subscription_id=self.subscription_id) - self.subscription_id = None - - -class PullSubscription(BaseSubscription): - def __enter__(self): - self.subscription_id, watermark = self.folder.subscribe_to_pull(**self.subscription_kwargs) - return self.subscription_id, watermark - - -class PushSubscription(BaseSubscription): - def __enter__(self): - self.subscription_id, watermark = self.folder.subscribe_to_push(**self.subscription_kwargs) - return self.subscription_id, watermark - - def __exit__(self, *args, **kwargs): - # Cannot unsubscribe to push subscriptions - pass - - -class StreamingSubscription(BaseSubscription): - def __enter__(self): - self.subscription_id = self.folder.subscribe_to_streaming(**self.subscription_kwargs) - return self.subscription_id
    + return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS})
    @@ -984,17 +915,17 @@

    Classes

    @property @abc.abstractmethod def account(self): - pass + """Return the account this folder belongs to""" @property @abc.abstractmethod def root(self): - pass + """Return the root folder this folder belongs to""" @property @abc.abstractmethod def parent(self): - pass + """Return the parent folder of this folder""" @property def is_deletable(self): @@ -1023,7 +954,7 @@

    Classes

    @property def absolute(self): - return ''.join('/%s' % p.name for p in self.parts) + return ''.join(f'/{p.name}' for p in self.parts) def _walk(self): for c in self.children: @@ -1077,29 +1008,27 @@

    Classes

    ├── exchangelib issues └── Mom """ - tree = '%s\n' % self.name + tree = f'{self.name}\n' children = list(self.children) for i, c in enumerate(sorted(children, key=attrgetter('name')), start=1): nodes = c.tree().split('\n') for j, node in enumerate(nodes, start=1): if i != len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += '├── %s\n' % node + tree += f'├── {node}\n' elif i != len(children) and j > 1: # Not the last child, and not name of child - tree += '│ %s\n' % node + tree += f'│ {node}\n' elif i == len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += '└── %s\n' % node + tree += f'└── {node}\n' else: # Last child, and not name of child - tree += ' %s\n' % node + tree += f' {node}\n' return tree.strip() @classmethod def supports_version(cls, version): # 'version' is a Version instance, for convenience by callers - if not isinstance(version, Version): - raise ValueError("'version' %r must be a Version instance" % version) if not cls.supported_from: return True return version.build >= cls.supported_from @@ -1136,31 +1065,18 @@

    Classes

    try: return cls.ITEM_MODEL_MAP[tag] except KeyError: - raise ValueError('Item type %s was unexpected in a %s folder' % (tag, cls.__name__)) + raise ValueError(f'Item type {tag} was unexpected in a {cls.__name__} folder') @classmethod def allowed_item_fields(cls, version): # Return non-ID fields of all item classes allowed in this folder type fields = set() for item_model in cls.supported_item_models: - fields.update( - set(item_model.supported_fields(version=version)) - ) + fields.update(set(item_model.supported_fields(version=version))) return fields def validate_item_field(self, field, version): - # Takes a fieldname, Field or FieldPath object pointing to an item field, and checks that it is valid - # for the item types supported by this folder. - - # For each field, check if the field is valid for any of the item models supported by this folder - for item_model in self.supported_item_models: - try: - item_model.validate_field(field=field, version=version) - break - except InvalidField: - continue - else: - raise InvalidField("%r is not a valid field on %s" % (field, self.supported_item_models)) + FolderCollection(account=self.account, folders=[self]).validate_item_field(field=field, version=version) def normalize_fields(self, fields): # Takes a list of fieldnames, Field or FieldPath objects pointing to item fields. Turns them into FieldPath @@ -1175,8 +1091,6 @@

    Classes

    elif isinstance(field_path, Field): field_path = FieldPath(field=field_path) fields[i] = field_path - if not isinstance(field_path, FieldPath): - raise ValueError("Field %r must be a string or FieldPath instance" % field_path) if field_path.field.name == 'start': has_start = True elif field_path.field.name == 'end': @@ -1202,7 +1116,7 @@

    Classes

    return item_model.get_field_by_fieldname(fieldname) except InvalidField: pass - raise InvalidField("%r is not a valid field name on %s" % (fieldname, cls.supported_item_models)) + raise InvalidField(f"{fieldname!r} is not a valid field name on {cls.supported_item_models}") def get(self, *args, **kwargs): return FolderCollection(account=self.account, folders=[self]).get(*args, **kwargs) @@ -1227,6 +1141,7 @@

    Classes

    return self.account.bulk_create(folder=self, items=items, *args, **kwargs) def save(self, update_fields=None): + from ..services import CreateFolder, UpdateFolder if self.id is None: # New folder if update_fields: @@ -1260,6 +1175,7 @@

    Classes

    return self def move(self, to_folder): + from ..services import MoveFolder res = MoveFolder(account=self.account).get(folders=[self], to_folder=to_folder) folder_id, changekey = res.id, res.changekey if self.id != folder_id: @@ -1270,15 +1186,13 @@

    Classes

    self.root.update_folder(self) # Update the folder in the cache def delete(self, delete_type=HARD_DELETE): - if delete_type not in DELETE_TYPE_CHOICES: - raise ValueError("'delete_type' %s must be one of %s" % (delete_type, DELETE_TYPE_CHOICES)) + from ..services import DeleteFolder DeleteFolder(account=self.account).get(folders=[self], delete_type=delete_type) self.root.remove_folder(self) # Remove the updated folder from the cache self._id = None def empty(self, delete_type=HARD_DELETE, delete_sub_folders=False): - if delete_type not in DELETE_TYPE_CHOICES: - raise ValueError("'delete_type' %s must be one of %s" % (delete_type, DELETE_TYPE_CHOICES)) + from ..services import EmptyFolder EmptyFolder(account=self.account).get( folders=[self], delete_type=delete_type, delete_sub_folders=delete_sub_folders ) @@ -1286,14 +1200,14 @@

    Classes

    # We don't know exactly what was deleted, so invalidate the entire folder cache to be safe self.root.clear_cache() - def wipe(self, page_size=None, _seen=None, _level=0): + def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0): # Recursively deletes all items in this folder, and all subfolders and their content. Attempts to protect # distinguished folders from being deleted. Use with caution! _seen = _seen or set() if self.id in _seen: - raise RecursionError('We already tried to wipe %s' % self) + raise RecursionError(f'We already tried to wipe {self}') if _level > 16: - raise RecursionError('Max recursion level reached: %s' % _level) + raise RecursionError(f'Max recursion level reached: {_level}') _seen.add(self.id) log.warning('Wiping %s', self) has_distinguished_subfolders = any(f.is_distinguished for f in self.children) @@ -1309,13 +1223,18 @@

    Classes

    self.empty(delete_sub_folders=False) except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): log.warning('Not allowed to empty %s. Trying to delete items instead', self) + kwargs = {} + if page_size is not None: + kwargs['page_size'] = page_size + if chunk_size is not None: + kwargs['chunk_size'] = chunk_size try: - self.all().delete(**dict(page_size=page_size) if page_size else {}) + self.all().delete(**kwargs) except (ErrorAccessDenied, ErrorCannotDeleteObject, ErrorItemNotFound): log.warning('Not allowed to delete items in %s', self) _level += 1 for f in self.children: - f.wipe(page_size=page_size, _seen=_seen, _level=_level) + f.wipe(page_size=page_size, chunk_size=chunk_size, _seen=_seen, _level=_level) # Remove non-distinguished children that are empty and have no subfolders if f.is_deletable and not f.children: log.warning('Deleting folder %s', f) @@ -1342,7 +1261,7 @@

    Classes

    kwargs['name'] = cls.DISTINGUISHED_FOLDER_ID return kwargs - def to_folder_id(self): + def to_id(self): if self.is_distinguished: # Don't add the changekey here. When modifying folder content, we usually don't care if others have changed # the folder content since we fetched the changekey. @@ -1356,29 +1275,19 @@

    Classes

    return FolderId(id=self.id, changekey=self.changekey) raise ValueError('Must be a distinguished folder or have an ID') - def to_xml(self, version): - try: - return self.to_folder_id().to_xml(version=version) - except ValueError: - return super().to_xml(version=version) - - def to_id_xml(self, version): - # Folder(name='Foo') is a perfectly valid ID to e.g. create a folder - return self.to_xml(version=version) - @classmethod def resolve(cls, account, folder): # Resolve a single folder folders = list(FolderCollection(account=account, folders=[folder]).resolve()) if not folders: - raise ErrorFolderNotFound('Could not find folder %r' % folder) + raise ErrorFolderNotFound(f'Could not find folder {folder!r}') if len(folders) != 1: - raise ValueError('Expected result length 1, but got %s' % folders) + raise ValueError(f'Expected result length 1, but got {folders}') f = folders[0] if isinstance(f, Exception): raise f if f.__class__ != cls: - raise ValueError("Expected folder %r to be a %s instance" % (f, cls)) + raise ValueError(f"Expected folder {f!r} to be a {cls} instance") return f @require_id @@ -1392,7 +1301,11 @@

    Classes

    return self @require_id - def get_user_configuration(self, name, properties=ALL): + def get_user_configuration(self, name, properties=None): + from ..services import GetUserConfiguration + from ..services.get_user_configuration import ALL + if properties is None: + properties = ALL return GetUserConfiguration(account=self.account).get( user_configuration_name=UserConfigurationNameMNS(name=name, folder=self), properties=properties, @@ -1400,6 +1313,7 @@

    Classes

    @require_id def create_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None): + from ..services import CreateUserConfiguration user_configuration = UserConfiguration( user_configuration_name=UserConfigurationName(name=name, folder=self), dictionary=dictionary, @@ -1410,6 +1324,7 @@

    Classes

    @require_id def update_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None): + from ..services import UpdateUserConfiguration user_configuration = UserConfiguration( user_configuration_name=UserConfigurationName(name=name, folder=self), dictionary=dictionary, @@ -1420,12 +1335,13 @@

    Classes

    @require_id def delete_user_configuration(self, name): + from ..services import DeleteUserConfiguration return DeleteUserConfiguration(account=self.account).get( user_configuration_name=UserConfigurationNameMNS(name=name, folder=self) ) @require_id - def subscribe_to_pull(self, event_types=SubscribeToPull.EVENT_TYPES, watermark=None, timeout=60): + def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): """Create a pull subscription. :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPull.EVENT_TYPES @@ -1434,19 +1350,15 @@

    Classes

    GetEvents request for this subscription. :return: The subscription ID and a watermark """ - s_ids = list(FolderCollection(account=self.account, folders=[self]).subscribe_to_pull( + from ..services import SubscribeToPull + if event_types is None: + event_types = SubscribeToPull.EVENT_TYPES + return FolderCollection(account=self.account, folders=[self]).subscribe_to_pull( event_types=event_types, watermark=watermark, timeout=timeout, - )) - if len(s_ids) != 1: - raise ValueError('Expected result length 1, but got %s' % s_ids) - s_id = s_ids[0] - if isinstance(s_id, Exception): - raise s_id - return s_id + ) @require_id - def subscribe_to_push(self, callback_url, event_types=SubscribeToPush.EVENT_TYPES, watermark=None, - status_frequency=1): + def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1): """Create a push subscription. :param callback_url: A client-defined URL that the server will call @@ -1455,32 +1367,24 @@

    Classes

    :param status_frequency: The frequency, in minutes, that the callback URL will be called with. :return: The subscription ID and a watermark """ - s_ids = list(FolderCollection(account=self.account, folders=[self]).subscribe_to_push( + from ..services import SubscribeToPush + if event_types is None: + event_types = SubscribeToPush.EVENT_TYPES + return FolderCollection(account=self.account, folders=[self]).subscribe_to_push( event_types=event_types, watermark=watermark, status_frequency=status_frequency, callback_url=callback_url, - )) - if len(s_ids) != 1: - raise ValueError('Expected result length 1, but got %s' % s_ids) - s_id = s_ids[0] - if isinstance(s_id, Exception): - raise s_id - return s_id + ) @require_id - def subscribe_to_streaming(self, event_types=SubscribeToPush.EVENT_TYPES): + def subscribe_to_streaming(self, event_types=None): """Create a streaming subscription. :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES :return: The subscription ID """ - s_ids = list(FolderCollection(account=self.account, folders=[self]).subscribe_to_streaming( - event_types=event_types, - )) - if len(s_ids) != 1: - raise ValueError('Expected result length 1, but got %s' % s_ids) - s_id = s_ids[0] - if isinstance(s_id, Exception): - raise s_id - return s_id + from ..services import SubscribeToStreaming + if event_types is None: + event_types = SubscribeToStreaming.EVENT_TYPES + return FolderCollection(account=self.account, folders=[self]).subscribe_to_streaming(event_types=event_types) @require_id def pull_subscription(self, **kwargs): @@ -1503,18 +1407,19 @@

    Classes

    This method doesn't need the current folder instance, but it makes sense to keep the method along the other sync methods. """ + from ..services import Unsubscribe return Unsubscribe(account=self.account).get(subscription_id=subscription_id) def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None): """Return all item changes to a folder, as a generator. If sync_state is specified, get all item changes after this sync state. After fully consuming the generator, self.item_sync_state will hold the new sync state. - :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderitems service. + :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderItems service. :param only_fields: A list of string or FieldPath items specifying the fields to fetch. Default to all fields :param ignore: A list of Item IDs to ignore in the sync :param max_changes_returned: The max number of change :param sync_scope: Specify whether to return just items, or items and folder associated information. Possible - values are specified in SyncFolderitems.SYNC_SCOPES + values are specified in SyncFolderItems.SYNC_SCOPES :return: A generator of (change_type, item) tuples """ if not sync_state: @@ -1536,7 +1441,7 @@

    Classes

    changes after this sync state. After fully consuming the generator, self.folder_sync_state will hold the new sync state. - :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderitems service. + :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderItems service. :param only_fields: A list of string or FieldPath items specifying the fields to fetch. Default to all fields :return: """ @@ -1561,6 +1466,7 @@

    Classes

    This method doesn't need the current folder instance, but it makes sense to keep the method along the other sync methods. """ + from ..services import GetEvents svc = GetEvents(account=self.account) while True: notification = svc.get(subscription_id=subscription_id, watermark=watermark) @@ -1581,9 +1487,8 @@

    Classes

    This method doesn't need the current folder instance, but it makes sense to keep the method along the other sync methods. """ - # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed - request_timeout = connection_timeout*60 + 60 - svc = GetStreamingEvents(account=self.account, timeout=request_timeout) + from ..services import GetStreamingEvents + svc = GetStreamingEvents(account=self.account) subscription_ids = subscription_id_or_ids if is_iterable(subscription_id_or_ids, generators_allowed=True) \ else [subscription_id_or_ids] for i, notification in enumerate( @@ -1615,7 +1520,7 @@

    Classes

    try: return SingleFolderQuerySet(account=self.account, folder=self).depth(SHALLOW_FOLDERS).get(name=other) except DoesNotExist: - raise ErrorFolderNotFound("No subfolder with name '%s'" % other) + raise ErrorFolderNotFound(f"No subfolder with name {other!r}") def __truediv__(self, other): """Support the some_folder / 'child_folder' / 'child_of_child_folder' navigation syntax.""" @@ -1628,7 +1533,7 @@

    Classes

    for c in self.children: if c.name == other: return c - raise ErrorFolderNotFound("No subfolder with name '%s'" % other) + raise ErrorFolderNotFound(f"No subfolder with name {other!r}") def __repr__(self): return self.__class__.__name__ + \ @@ -1636,7 +1541,7 @@

    Classes

    self.folder_class, self.id, self.changekey)) def __str__(self): - return '%s (%s)' % (self.__class__.__name__, self.name)
    + return f'{self.__class__.__name__} ({self.name})'

    Ancestors

      @@ -1725,9 +1630,7 @@

      Static methods

      # Return non-ID fields of all item classes allowed in this folder type fields = set() for item_model in cls.supported_item_models: - fields.update( - set(item_model.supported_fields(version=version)) - ) + fields.update(set(item_model.supported_fields(version=version))) return fields @@ -1777,7 +1680,7 @@

      Static methods

      return item_model.get_field_by_fieldname(fieldname) except InvalidField: pass - raise InvalidField("%r is not a valid field name on %s" % (fieldname, cls.supported_item_models)) + raise InvalidField(f"{fieldname!r} is not a valid field name on {cls.supported_item_models}")
      @@ -1794,7 +1697,7 @@

      Static methods

      try: return cls.ITEM_MODEL_MAP[tag] except KeyError: - raise ValueError('Item type %s was unexpected in a %s folder' % (tag, cls.__name__))
      + raise ValueError(f'Item type {tag} was unexpected in a {cls.__name__} folder')
      @@ -1827,14 +1730,14 @@

      Static methods

      # Resolve a single folder folders = list(FolderCollection(account=account, folders=[folder]).resolve()) if not folders: - raise ErrorFolderNotFound('Could not find folder %r' % folder) + raise ErrorFolderNotFound(f'Could not find folder {folder!r}') if len(folders) != 1: - raise ValueError('Expected result length 1, but got %s' % folders) + raise ValueError(f'Expected result length 1, but got {folders}') f = folders[0] if isinstance(f, Exception): raise f if f.__class__ != cls: - raise ValueError("Expected folder %r to be a %s instance" % (f, cls)) + raise ValueError(f"Expected folder {f!r} to be a {cls} instance") return f
      @@ -1850,8 +1753,6 @@

      Static methods

      @classmethod
       def supports_version(cls, version):
           # 'version' is a Version instance, for convenience by callers
      -    if not isinstance(version, Version):
      -        raise ValueError("'version' %r must be a Version instance" % version)
           if not cls.supported_from:
               return True
           return version.build >= cls.supported_from
      @@ -1869,12 +1770,12 @@

      Instance variables

      @property
       def absolute(self):
      -    return ''.join('/%s' % p.name for p in self.parts)
      + return ''.join(f'/{p.name}' for p in self.parts)
      var account
      -
      +

      Return the account this folder belongs to

      Expand source code @@ -1882,7 +1783,7 @@

      Instance variables

      @property
       @abc.abstractmethod
       def account(self):
      -    pass
      + """Return the account this folder belongs to"""
      var child_folder_count
      @@ -1949,7 +1850,7 @@

      Instance variables

      var parent
      -
      +

      Return the parent folder of this folder

      Expand source code @@ -1957,7 +1858,7 @@

      Instance variables

      @property
       @abc.abstractmethod
       def parent(self):
      -    pass
      + """Return the parent folder of this folder"""
      var parent_folder_id
      @@ -1983,7 +1884,7 @@

      Instance variables

      var root
      -
      +

      Return the root folder this folder belongs to

      Expand source code @@ -1991,7 +1892,7 @@

      Instance variables

      @property
       @abc.abstractmethod
       def root(self):
      -    pass
      + """Return the root folder this folder belongs to"""
      var total_count
      @@ -2005,19 +1906,6 @@

      Instance variables

      Methods

      -
      -def all(self) -
      -
      -
      -
      - -Expand source code - -
      def all(self):
      -    return FolderCollection(account=self.account, folders=[self]).all()
      -
      -
      def bulk_create(self, items, *args, **kwargs)
      @@ -2058,6 +1946,7 @@

      Methods

      @require_id
       def create_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None):
      +    from ..services import CreateUserConfiguration
           user_configuration = UserConfiguration(
               user_configuration_name=UserConfigurationName(name=name, folder=self),
               dictionary=dictionary,
      @@ -2077,8 +1966,7 @@ 

      Methods

      Expand source code
      def delete(self, delete_type=HARD_DELETE):
      -    if delete_type not in DELETE_TYPE_CHOICES:
      -        raise ValueError("'delete_type' %s must be one of %s" % (delete_type, DELETE_TYPE_CHOICES))
      +    from ..services import DeleteFolder
           DeleteFolder(account=self.account).get(folders=[self], delete_type=delete_type)
           self.root.remove_folder(self)  # Remove the updated folder from the cache
           self._id = None
      @@ -2095,6 +1983,7 @@

      Methods

      @require_id
       def delete_user_configuration(self, name):
      +    from ..services import DeleteUserConfiguration
           return DeleteUserConfiguration(account=self.account).get(
               user_configuration_name=UserConfigurationNameMNS(name=name, folder=self)
           )
      @@ -2110,8 +1999,7 @@

      Methods

      Expand source code
      def empty(self, delete_type=HARD_DELETE, delete_sub_folders=False):
      -    if delete_type not in DELETE_TYPE_CHOICES:
      -        raise ValueError("'delete_type' %s must be one of %s" % (delete_type, DELETE_TYPE_CHOICES))
      +    from ..services import EmptyFolder
           EmptyFolder(account=self.account).get(
               folders=[self], delete_type=delete_type, delete_sub_folders=delete_sub_folders
           )
      @@ -2120,45 +2008,6 @@ 

      Methods

      self.root.clear_cache()
      -
      -def exclude(self, *args, **kwargs) -
      -
      -
      -
      - -Expand source code - -
      def exclude(self, *args, **kwargs):
      -    return FolderCollection(account=self.account, folders=[self]).exclude(*args, **kwargs)
      -
      -
      -
      -def filter(self, *args, **kwargs) -
      -
      -
      -
      - -Expand source code - -
      def filter(self, *args, **kwargs):
      -    return FolderCollection(account=self.account, folders=[self]).filter(*args, **kwargs)
      -
      -
      -
      -def get(self, *args, **kwargs) -
      -
      -
      -
      - -Expand source code - -
      def get(self, *args, **kwargs):
      -    return FolderCollection(account=self.account, folders=[self]).get(*args, **kwargs)
      -
      -
      def get_events(self, subscription_id, watermark)
      @@ -2183,6 +2032,7 @@

      Methods

      This method doesn't need the current folder instance, but it makes sense to keep the method along the other sync methods. """ + from ..services import GetEvents svc = GetEvents(account=self.account) while True: notification = svc.get(subscription_id=subscription_id, watermark=watermark) @@ -2221,9 +2071,8 @@

      Methods

      This method doesn't need the current folder instance, but it makes sense to keep the method along the other sync methods. """ - # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed - request_timeout = connection_timeout*60 + 60 - svc = GetStreamingEvents(account=self.account, timeout=request_timeout) + from ..services import GetStreamingEvents + svc = GetStreamingEvents(account=self.account) subscription_ids = subscription_id_or_ids if is_iterable(subscription_id_or_ids, generators_allowed=True) \ else [subscription_id_or_ids] for i, notification in enumerate( @@ -2237,7 +2086,7 @@

      Methods

      -def get_user_configuration(self, name, properties='All') +def get_user_configuration(self, name, properties=None)
      @@ -2246,7 +2095,11 @@

      Methods

      Expand source code
      @require_id
      -def get_user_configuration(self, name, properties=ALL):
      +def get_user_configuration(self, name, properties=None):
      +    from ..services import GetUserConfiguration
      +    from ..services.get_user_configuration import ALL
      +    if properties is None:
      +        properties = ALL
           return GetUserConfiguration(account=self.account).get(
               user_configuration_name=UserConfigurationNameMNS(name=name, folder=self),
               properties=properties,
      @@ -2276,6 +2129,7 @@ 

      Methods

      Expand source code
      def move(self, to_folder):
      +    from ..services import MoveFolder
           res = MoveFolder(account=self.account).get(folders=[self], to_folder=to_folder)
           folder_id, changekey = res.id, res.changekey
           if self.id != folder_id:
      @@ -2286,19 +2140,6 @@ 

      Methods

      self.root.update_folder(self) # Update the folder in the cache
      -
      -def none(self) -
      -
      -
      -
      - -Expand source code - -
      def none(self):
      -    return FolderCollection(account=self.account, folders=[self]).none()
      -
      -
      def normalize_fields(self, fields)
      @@ -2321,8 +2162,6 @@

      Methods

      elif isinstance(field_path, Field): field_path = FieldPath(field=field_path) fields[i] = field_path - if not isinstance(field_path, FieldPath): - raise ValueError("Field %r must be a string or FieldPath instance" % field_path) if field_path.field.name == 'start': has_start = True elif field_path.field.name == 'end': @@ -2342,20 +2181,6 @@

      Methods

      return fields
      -
      -def people(self) -
      -
      -
      -
      - -Expand source code - -
      def people(self):
      -    # No point in using a FolderCollection because FindPeople only supports one folder
      -    return FolderCollection(account=self.account, folders=[self]).people()
      -
      -
      def pull_subscription(self, **kwargs)
      @@ -2414,6 +2239,7 @@

      Methods

      Expand source code
      def save(self, update_fields=None):
      +    from ..services import CreateFolder, UpdateFolder
           if self.id is None:
               # New folder
               if update_fields:
      @@ -2462,7 +2288,7 @@ 

      Methods

      -def subscribe_to_pull(self, event_types=('CopiedEvent', 'CreatedEvent', 'DeletedEvent', 'ModifiedEvent', 'MovedEvent', 'NewMailEvent', 'FreeBusyChangedEvent'), watermark=None, timeout=60) +def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60)

      Create a pull subscription.

      @@ -2476,7 +2302,7 @@

      Methods

      Expand source code
      @require_id
      -def subscribe_to_pull(self, event_types=SubscribeToPull.EVENT_TYPES, watermark=None, timeout=60):
      +def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60):
           """Create a pull subscription.
       
           :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPull.EVENT_TYPES
      @@ -2485,19 +2311,16 @@ 

      Methods

      GetEvents request for this subscription. :return: The subscription ID and a watermark """ - s_ids = list(FolderCollection(account=self.account, folders=[self]).subscribe_to_pull( + from ..services import SubscribeToPull + if event_types is None: + event_types = SubscribeToPull.EVENT_TYPES + return FolderCollection(account=self.account, folders=[self]).subscribe_to_pull( event_types=event_types, watermark=watermark, timeout=timeout, - )) - if len(s_ids) != 1: - raise ValueError('Expected result length 1, but got %s' % s_ids) - s_id = s_ids[0] - if isinstance(s_id, Exception): - raise s_id - return s_id
      + )
      -def subscribe_to_push(self, callback_url, event_types=('CopiedEvent', 'CreatedEvent', 'DeletedEvent', 'ModifiedEvent', 'MovedEvent', 'NewMailEvent', 'FreeBusyChangedEvent'), watermark=None, status_frequency=1) +def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1)

      Create a push subscription.

      @@ -2511,8 +2334,7 @@

      Methods

      Expand source code
      @require_id
      -def subscribe_to_push(self, callback_url, event_types=SubscribeToPush.EVENT_TYPES, watermark=None,
      -                      status_frequency=1):
      +def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1):
           """Create a push subscription.
       
           :param callback_url: A client-defined URL that the server will call
      @@ -2521,19 +2343,16 @@ 

      Methods

      :param status_frequency: The frequency, in minutes, that the callback URL will be called with. :return: The subscription ID and a watermark """ - s_ids = list(FolderCollection(account=self.account, folders=[self]).subscribe_to_push( + from ..services import SubscribeToPush + if event_types is None: + event_types = SubscribeToPush.EVENT_TYPES + return FolderCollection(account=self.account, folders=[self]).subscribe_to_push( event_types=event_types, watermark=watermark, status_frequency=status_frequency, callback_url=callback_url, - )) - if len(s_ids) != 1: - raise ValueError('Expected result length 1, but got %s' % s_ids) - s_id = s_ids[0] - if isinstance(s_id, Exception): - raise s_id - return s_id
      + )
      -def subscribe_to_streaming(self, event_types=('CopiedEvent', 'CreatedEvent', 'DeletedEvent', 'ModifiedEvent', 'MovedEvent', 'NewMailEvent', 'FreeBusyChangedEvent')) +def subscribe_to_streaming(self, event_types=None)

      Create a streaming subscription.

      @@ -2544,21 +2363,16 @@

      Methods

      Expand source code
      @require_id
      -def subscribe_to_streaming(self, event_types=SubscribeToPush.EVENT_TYPES):
      +def subscribe_to_streaming(self, event_types=None):
           """Create a streaming subscription.
       
           :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES
           :return: The subscription ID
           """
      -    s_ids = list(FolderCollection(account=self.account, folders=[self]).subscribe_to_streaming(
      -        event_types=event_types,
      -    ))
      -    if len(s_ids) != 1:
      -        raise ValueError('Expected result length 1, but got %s' % s_ids)
      -    s_id = s_ids[0]
      -    if isinstance(s_id, Exception):
      -        raise s_id
      -    return s_id
      + from ..services import SubscribeToStreaming + if event_types is None: + event_types = SubscribeToStreaming.EVENT_TYPES + return FolderCollection(account=self.account, folders=[self]).subscribe_to_streaming(event_types=event_types)
      @@ -2568,7 +2382,7 @@

      Methods

      Return all folder changes to a folder hierarchy, as a generator. If sync_state is specified, get all folder changes after this sync state. After fully consuming the generator, self.folder_sync_state will hold the new sync state.

      -

      :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderitems service. +

      :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderItems service. :param only_fields: A list of string or FieldPath items specifying the fields to fetch. Default to all fields :return:

      @@ -2580,7 +2394,7 @@

      Methods

      changes after this sync state. After fully consuming the generator, self.folder_sync_state will hold the new sync state. - :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderitems service. + :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderItems service. :param only_fields: A list of string or FieldPath items specifying the fields to fetch. Default to all fields :return: """ @@ -2602,12 +2416,12 @@

      Methods

      Return all item changes to a folder, as a generator. If sync_state is specified, get all item changes after this sync state. After fully consuming the generator, self.item_sync_state will hold the new sync state.

      -

      :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderitems service. +

      :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderItems service. :param only_fields: A list of string or FieldPath items specifying the fields to fetch. Default to all fields :param ignore: A list of Item IDs to ignore in the sync :param max_changes_returned: The max number of change :param sync_scope: Specify whether to return just items, or items and folder associated information. Possible -values are specified in SyncFolderitems.SYNC_SCOPES +values are specified in SyncFolderItems.SYNC_SCOPES :return: A generator of (change_type, item) tuples

      @@ -2617,12 +2431,12 @@

      Methods

      """Return all item changes to a folder, as a generator. If sync_state is specified, get all item changes after this sync state. After fully consuming the generator, self.item_sync_state will hold the new sync state. - :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderitems service. + :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderItems service. :param only_fields: A list of string or FieldPath items specifying the fields to fetch. Default to all fields :param ignore: A list of Item IDs to ignore in the sync :param max_changes_returned: The max number of change :param sync_scope: Specify whether to return just items, or items and folder associated information. Possible - values are specified in SyncFolderitems.SYNC_SCOPES + values are specified in SyncFolderItems.SYNC_SCOPES :return: A generator of (change_type, item) tuples """ if not sync_state: @@ -2658,8 +2472,8 @@

      Methods

      return True
      -
      -def to_folder_id(self) +
      +def to_id(self)
      @@ -2667,7 +2481,7 @@

      Methods

      Expand source code -
      def to_folder_id(self):
      +
      def to_id(self):
           if self.is_distinguished:
               # Don't add the changekey here. When modifying folder content, we usually don't care if others have changed
               # the folder content since we fetched the changekey.
      @@ -2682,36 +2496,6 @@ 

      Methods

      raise ValueError('Must be a distinguished folder or have an ID')
      -
      -def to_id_xml(self, version) -
      -
      -
      -
      - -Expand source code - -
      def to_id_xml(self, version):
      -    # Folder(name='Foo') is a perfectly valid ID to e.g. create a folder
      -    return self.to_xml(version=version)
      -
      -
      -
      -def to_xml(self, version) -
      -
      -
      -
      - -Expand source code - -
      def to_xml(self, version):
      -    try:
      -        return self.to_folder_id().to_xml(version=version)
      -    except ValueError:
      -        return super().to_xml(version=version)
      -
      -
      def tree(self)
      @@ -2740,22 +2524,22 @@

      Methods

      ├── exchangelib issues └── Mom """ - tree = '%s\n' % self.name + tree = f'{self.name}\n' children = list(self.children) for i, c in enumerate(sorted(children, key=attrgetter('name')), start=1): nodes = c.tree().split('\n') for j, node in enumerate(nodes, start=1): if i != len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += '├── %s\n' % node + tree += f'├── {node}\n' elif i != len(children) and j > 1: # Not the last child, and not name of child - tree += '│ %s\n' % node + tree += f'│ {node}\n' elif i == len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += '└── %s\n' % node + tree += f'└── {node}\n' else: # Last child, and not name of child - tree += ' %s\n' % node + tree += f' {node}\n' return tree.strip()
      @@ -2781,6 +2565,7 @@

      Methods

      This method doesn't need the current folder instance, but it makes sense to keep the method along the other sync methods. """ + from ..services import Unsubscribe return Unsubscribe(account=self.account).get(subscription_id=subscription_id)
      @@ -2795,6 +2580,7 @@

      Methods

      @require_id
       def update_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None):
      +    from ..services import UpdateUserConfiguration
           user_configuration = UserConfiguration(
               user_configuration_name=UserConfigurationName(name=name, folder=self),
               dictionary=dictionary,
      @@ -2814,18 +2600,7 @@ 

      Methods

      Expand source code
      def validate_item_field(self, field, version):
      -    # Takes a fieldname, Field or FieldPath object pointing to an item field, and checks that it is valid
      -    # for the item types supported by this folder.
      -
      -    # For each field, check if the field is valid for any of the item models supported by this folder
      -    for item_model in self.supported_item_models:
      -        try:
      -            item_model.validate_field(field=field, version=version)
      -            break
      -        except InvalidField:
      -            continue
      -    else:
      -        raise InvalidField("%r is not a valid field on %s" % (field, self.supported_item_models))
      + FolderCollection(account=self.account, folders=[self]).validate_item_field(field=field, version=version)
      @@ -2842,7 +2617,7 @@

      Methods

      -def wipe(self, page_size=None) +def wipe(self, page_size=None, chunk_size=None)
      @@ -2850,14 +2625,14 @@

      Methods

      Expand source code -
      def wipe(self, page_size=None, _seen=None, _level=0):
      +
      def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0):
           # Recursively deletes all items in this folder, and all subfolders and their content. Attempts to protect
           # distinguished folders from being deleted. Use with caution!
           _seen = _seen or set()
           if self.id in _seen:
      -        raise RecursionError('We already tried to wipe %s' % self)
      +        raise RecursionError(f'We already tried to wipe {self}')
           if _level > 16:
      -        raise RecursionError('Max recursion level reached: %s' % _level)
      +        raise RecursionError(f'Max recursion level reached: {_level}')
           _seen.add(self.id)
           log.warning('Wiping %s', self)
           has_distinguished_subfolders = any(f.is_distinguished for f in self.children)
      @@ -2873,13 +2648,18 @@ 

      Methods

      self.empty(delete_sub_folders=False) except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): log.warning('Not allowed to empty %s. Trying to delete items instead', self) + kwargs = {} + if page_size is not None: + kwargs['page_size'] = page_size + if chunk_size is not None: + kwargs['chunk_size'] = chunk_size try: - self.all().delete(**dict(page_size=page_size) if page_size else {}) + self.all().delete(**kwargs) except (ErrorAccessDenied, ErrorCannotDeleteObject, ErrorItemNotFound): log.warning('Not allowed to delete items in %s', self) _level += 1 for f in self.children: - f.wipe(page_size=page_size, _seen=_seen, _level=_level) + f.wipe(page_size=page_size, chunk_size=chunk_size, _seen=_seen, _level=_level) # Remove non-distinguished children that are empty and have no subfolders if f.is_deletable and not f.children: log.warning('Deleting folder %s', f) @@ -2902,36 +2682,16 @@

      Inherited members

    • validate_field
    - - -
    -class BaseSubscription -(folder, **subscription_kwargs) -
    -
    -
    -
    - -Expand source code - -
    class BaseSubscription(metaclass=abc.ABCMeta):
    -    def __init__(self, folder, **subscription_kwargs):
    -        self.folder = folder
    -        self.subscription_kwargs = subscription_kwargs
    -        self.subscription_id = None
    -
    -    def __enter__(self):
    -        pass
    -
    -    def __exit__(self, *args, **kwargs):
    -        self.folder.unsubscribe(subscription_id=self.subscription_id)
    -        self.subscription_id = None
    -
    -

    Subclasses

    +
  • SearchableMixIn: +
  • @@ -3006,7 +2766,7 @@

    Subclasses

    folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound('Could not find distinguished folder %r' % cls.DISTINGUISHED_FOLDER_ID) + raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}') @property def parent(self): @@ -3023,7 +2783,7 @@

    Subclasses

    self.parent_folder_id = None else: if not isinstance(value, BaseFolder): - raise ValueError("'value' %r must be a Folder instance" % value) + raise InvalidTypeError('value', value, BaseFolder) self.root = value.root self.parent_folder_id = ParentFolderId(id=value.id, changekey=value.changekey) @@ -3031,7 +2791,7 @@

    Subclasses

    from .roots import RootOfHierarchy super().clean(version=version) if self.root and not isinstance(self.root, RootOfHierarchy): - raise ValueError("'root' %r must be a RootOfHierarchy instance" % self.root) + raise InvalidTypeError('root', self.root, RootOfHierarchy) @classmethod def from_xml_with_root(cls, elem, root): @@ -3202,63 +2962,20 @@

    Static methods

    folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound('Could not find distinguished folder %r' % cls.DISTINGUISHED_FOLDER_ID)
    + raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}')

    Instance variables

    -
    var account
    -
    -
    -
    - -Expand source code - -
    @property
    -def account(self):
    -    if self.root is None:
    -        return None
    -    return self.root.account
    -
    -
    var effective_rights
    -
    var parent
    -
    -
    -
    - -Expand source code - -
    @property
    -def parent(self):
    -    if not self.parent_folder_id:
    -        return None
    -    if self.parent_folder_id.id == self.id:
    -        # Some folders have a parent that references itself. Avoid circular references here
    -        return None
    -    return self.root.get_folder(self.parent_folder_id)
    -
    -
    var permission_set
    -
    var root
    -
    -
    -
    - -Expand source code - -
    @property
    -def root(self):
    -    return self._root
    -
    -

    Methods

    @@ -3275,7 +2992,7 @@

    Methods

    from .roots import RootOfHierarchy super().clean(version=version) if self.root and not isinstance(self.root, RootOfHierarchy): - raise ValueError("'root' %r must be a RootOfHierarchy instance" % self.root)
    + raise InvalidTypeError('root', self.root, RootOfHierarchy)
    @@ -3284,16 +3001,25 @@

    Inherited members

  • BaseFolder: -
    -class PullSubscription -(folder, **subscription_kwargs) -
    -
    -
    -
    - -Expand source code - -
    class PullSubscription(BaseSubscription):
    -    def __enter__(self):
    -        self.subscription_id, watermark = self.folder.subscribe_to_pull(**self.subscription_kwargs)
    -        return self.subscription_id, watermark
    -
    -

    Ancestors

    - -
    -
    -class PushSubscription -(folder, **subscription_kwargs) -
    -
    -
    -
    - -Expand source code - -
    class PushSubscription(BaseSubscription):
    -    def __enter__(self):
    -        self.subscription_id, watermark = self.folder.subscribe_to_push(**self.subscription_kwargs)
    -        return self.subscription_id, watermark
    -
    -    def __exit__(self, *args, **kwargs):
    -        # Cannot unsubscribe to push subscriptions
    -        pass
    -
    -

    Ancestors

    - -
    -
    -class StreamingSubscription -(folder, **subscription_kwargs) -
    -
    -
    -
    - -Expand source code - -
    class StreamingSubscription(BaseSubscription):
    -    def __enter__(self):
    -        self.subscription_id = self.folder.subscribe_to_streaming(**self.subscription_kwargs)
    -        return self.subscription_id
    -
    -

    Ancestors

    - -
  • @@ -3404,7 +3066,6 @@

    NAMESPACE
  • absolute
  • account
  • -
  • all
  • allowed_item_fields
  • bulk_create
  • child_folder_count
  • @@ -3414,12 +3075,9 @@

    delete
  • delete_user_configuration
  • empty
  • -
  • exclude
  • -
  • filter
  • folder_class
  • folder_cls_from_container_class
  • folder_sync_state
  • -
  • get
  • get_events
  • get_folder_allowed
  • get_item_field_by_fieldname
  • @@ -3434,12 +3092,10 @@

    localized_names
  • move
  • name
  • -
  • none
  • normalize_fields
  • parent
  • parent_folder_id
  • parts
  • -
  • people
  • pull_subscription
  • push_subscription
  • refresh
  • @@ -3456,9 +3112,7 @@

    sync_hierarchy
  • sync_items
  • test_access
  • -
  • to_folder_id
  • -
  • to_id_xml
  • -
  • to_xml
  • +
  • to_id
  • total_count
  • tree
  • unread_count
  • @@ -3470,31 +3124,16 @@

    BaseSubscription

    - -
  • Folder

  • -
  • -

    PullSubscription

    -
  • -
  • -

    PushSubscription

    -
  • -
  • -

    StreamingSubscription

    -
  • diff --git a/docs/exchangelib/folders/collections.html b/docs/exchangelib/folders/collections.html index eae00689..3a871adc 100644 --- a/docs/exchangelib/folders/collections.html +++ b/docs/exchangelib/folders/collections.html @@ -26,18 +26,17 @@

    Module exchangelib.folders.collections

    Expand source code -
    import logging
    +
    import abc
    +import logging
     
     from cached_property import threaded_cached_property
     
    -from .queryset import FOLDER_TRAVERSAL_CHOICES
    +from ..errors import InvalidTypeError
     from ..fields import FieldPath, InvalidField
    -from ..items import Persona, ITEM_TRAVERSAL_CHOICES, SHAPE_CHOICES, ID_ONLY
    +from ..items import Persona, ID_ONLY
     from ..properties import CalendarView
     from ..queryset import QuerySet, SearchableMixIn, Q
     from ..restriction import Restriction
    -from ..services import FindFolder, GetFolder, FindItem, FindPeople, SyncFolderItems, SyncFolderHierarchy, \
    -    SubscribeToPull, SubscribeToPush, SubscribeToStreaming
     from ..util import require_account
     
     log = logging.getLogger(__name__)
    @@ -154,7 +153,8 @@ 

    Module exchangelib.folders.collections

    return tuple(item_model for folder in self.folders for item_model in folder.supported_item_models) def validate_item_field(self, field, version): - # For each field, check if the field is valid for any of the item models supported by this folder + # Takes a fieldname, Field or FieldPath object pointing to an item field, and checks that it is valid + # for the item types supported by this folder collection. for item_model in self.supported_item_models: try: item_model.validate_field(field=field, version=version) @@ -162,14 +162,35 @@

    Module exchangelib.folders.collections

    except InvalidField: continue else: - raise InvalidField("%r is not a valid field on %s" % (field, self.supported_item_models)) + raise InvalidField(f"{field!r} is not a valid field on {self.supported_item_models}") + + def _rinse_args(self, q, depth, additional_fields, field_validator): + if depth is None: + depth = self._get_default_item_traversal_depth() + if additional_fields: + for f in additional_fields: + field_validator(field=f, version=self.account.version) + if f.field.is_complex: + raise ValueError(f"Field {f.field.name!r} not supported for this service") + + # Build up any restrictions + if q.is_empty(): + restriction = None + query_string = None + elif q.query_string: + restriction = None + query_string = Restriction(q, folders=self.folders, applies_to=Restriction.ITEMS) + else: + restriction = Restriction(q, folders=self.folders, applies_to=Restriction.ITEMS) + query_string = None + return depth, restriction, query_string def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order_fields=None, calendar_view=None, page_size=None, max_items=None, offset=0): """Private method to call the FindItem service. :param q: a Q instance containing any restrictions - :param shape: controls whether to return (id, chanegkey) tuples or Item objects. If additional_fields is + :param shape: controls whether to return (id, changekey) tuples or Item objects. If additional_fields is non-null, we always return Item objects. (Default value = ID_ONLY) :param depth: controls the whether to return soft-deleted items or not. (Default value = None) :param additional_fields: the extra properties we want on the return objects. Default is no properties. Be aware @@ -182,36 +203,20 @@

    Module exchangelib.folders.collections

    :return: a generator for the returned item IDs or items """ + from ..services import FindItem if not self.folders: log.debug('Folder list is empty') return if q.is_never(): log.debug('Query will never return results') return - if shape not in SHAPE_CHOICES: - raise ValueError("'shape' %s must be one of %s" % (shape, SHAPE_CHOICES)) - if depth is None: - depth = self._get_default_item_traversal_depth() - if depth not in ITEM_TRAVERSAL_CHOICES: - raise ValueError("'depth' %s must be one of %s" % (depth, ITEM_TRAVERSAL_CHOICES)) - if additional_fields: - for f in additional_fields: - self.validate_item_field(field=f, version=self.account.version) - if f.field.is_complex: - raise ValueError("find_items() does not support field '%s'. Use fetch() instead" % f.field.name) + depth, restriction, query_string = self._rinse_args( + q=q, depth=depth, additional_fields=additional_fields, + field_validator=self.validate_item_field + ) if calendar_view is not None and not isinstance(calendar_view, CalendarView): - raise ValueError("'calendar_view' %s must be a CalendarView instance" % calendar_view) + raise InvalidTypeError('calendar_view', calendar_view, CalendarView) - # Build up any restrictions - if q.is_empty(): - restriction = None - query_string = None - elif q.query_string: - restriction = None - query_string = Restriction(q, folders=self.folders, applies_to=Restriction.ITEMS) - else: - restriction = Restriction(q, folders=self.folders, applies_to=Restriction.ITEMS) - query_string = None log.debug( 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', self.account, @@ -221,7 +226,7 @@

    Module exchangelib.folders.collections

    additional_fields, restriction.q if restriction else None, ) - yield from FindItem(account=self.account, chunk_size=page_size).call( + yield from FindItem(account=self.account, page_size=page_size).call( folders=self.folders, additional_fields=additional_fields, restriction=restriction, @@ -247,7 +252,7 @@

    Module exchangelib.folders.collections

    """Private method to call the FindPeople service. :param q: a Q instance containing any restrictions - :param shape: controls whether to return (id, chanegkey) tuples or Persona objects. If additional_fields is + :param shape: controls whether to return (id, changekey) tuples or Persona objects. If additional_fields is non-null, we always return Persona objects. (Default value = ID_ONLY) :param depth: controls the whether to return soft-deleted items or not. (Default value = None) :param additional_fields: the extra properties we want on the return objects. Default is no properties. @@ -258,35 +263,17 @@

    Module exchangelib.folders.collections

    :return: a generator for the returned personas """ + from ..services import FindPeople folder = self._get_single_folder() - if not folder: - return if q.is_never(): log.debug('Query will never return results') return - if shape not in SHAPE_CHOICES: - raise ValueError("'shape' %s must be one of %s" % (shape, SHAPE_CHOICES)) - if depth is None: - depth = self._get_default_item_traversal_depth() - if depth not in ITEM_TRAVERSAL_CHOICES: - raise ValueError("'depth' %s must be one of %s" % (depth, ITEM_TRAVERSAL_CHOICES)) - if additional_fields: - for f in additional_fields: - Persona.validate_field(field=f, version=self.account.version) - if f.field.is_complex: - raise ValueError("find_people() does not support field '%s'" % f.field.name) + depth, restriction, query_string = self._rinse_args( + q=q, depth=depth, additional_fields=additional_fields, + field_validator=Persona.validate_field + ) - # Build up any restrictions - if q.is_empty(): - restriction = None - query_string = None - elif q.query_string: - restriction = None - query_string = Restriction(q, folders=[folder], applies_to=Restriction.ITEMS) - else: - restriction = Restriction(q, folders=[folder], applies_to=Restriction.ITEMS) - query_string = None - yield from FindPeople(account=self.account, chunk_size=page_size).call( + yield from FindPeople(account=self.account, page_size=page_size).call( folder=folder, additional_fields=additional_fields, restriction=restriction, @@ -314,11 +301,11 @@

    Module exchangelib.folders.collections

    for f in self.folders: if isinstance(f, RootOfHierarchy): if has_non_roots: - raise ValueError('Cannot call GetFolder on a mix of folder types: {}'.format(self.folders)) + raise ValueError(f'Cannot call GetFolder on a mix of folder types: {self.folders}') has_roots = True else: if has_roots: - raise ValueError('Cannot call GetFolder on a mix of folder types: {}'.format(self.folders)) + raise ValueError(f'Cannot call GetFolder on a mix of folder types: {self.folders}') has_non_roots = True return RootOfHierarchy if has_roots else Folder @@ -327,8 +314,8 @@

    Module exchangelib.folders.collections

    if len(unique_depths) == 1: return unique_depths.pop() raise ValueError( - 'Folders in this collection do not have a common %s value. You need to define an explicit traversal depth' - 'with QuerySet.depth() (values: %s)' % (traversal_attr, unique_depths) + f'Folders in this collection do not have a common {traversal_attr} value. You need to define an explicit ' + f'traversal depth with QuerySet.depth() (values: {unique_depths})' ) def _get_default_item_traversal_depth(self): @@ -358,6 +345,7 @@

    Module exchangelib.folders.collections

    @require_account def find_folders(self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None, offset=0): + from ..services import FindFolder # 'depth' controls whether to return direct children or recurse into sub-folders from .base import BaseFolder, Folder if q is None: @@ -372,26 +360,22 @@

    Module exchangelib.folders.collections

    restriction = None else: restriction = Restriction(q, folders=self.folders, applies_to=Restriction.FOLDERS) - if shape not in SHAPE_CHOICES: - raise ValueError("'shape' %s must be one of %s" % (shape, SHAPE_CHOICES)) if depth is None: depth = self._get_default_folder_traversal_depth() - if depth not in FOLDER_TRAVERSAL_CHOICES: - raise ValueError("'depth' %s must be one of %s" % (depth, FOLDER_TRAVERSAL_CHOICES)) if additional_fields is None: # Default to all non-complex properties. Subfolders will always be of class Folder additional_fields = self.get_folder_fields(target_cls=Folder, is_complex=False) else: for f in additional_fields: if f.field.is_complex: - raise ValueError("find_folders() does not support field '%s'. Use get_folders()." % f.field.name) + raise ValueError(f"find_folders() does not support field {f.field.name!r}. Use get_folders().") # Add required fields additional_fields.update( (FieldPath(field=BaseFolder.get_field_by_fieldname(f)) for f in self.REQUIRED_FOLDER_FIELDS) ) - yield from FindFolder(account=self.account, chunk_size=page_size).call( + yield from FindFolder(account=self.account, page_size=page_size).call( folders=self.folders, additional_fields=additional_fields, restriction=restriction, @@ -402,6 +386,7 @@

    Module exchangelib.folders.collections

    ) def get_folders(self, additional_fields=None): + from ..services import GetFolder # Expand folders with their full set of properties from .base import BaseFolder if not self.folders: @@ -422,34 +407,62 @@

    Module exchangelib.folders.collections

    shape=ID_ONLY, ) - def subscribe_to_pull(self, event_types=SubscribeToPull.EVENT_TYPES, watermark=None, timeout=60): + def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): + from ..services import SubscribeToPull if not self.folders: log.debug('Folder list is empty') - return - yield from SubscribeToPull(account=self.account).call( + return None + if event_types is None: + event_types = SubscribeToPull.EVENT_TYPES + return SubscribeToPull(account=self.account).get( folders=self.folders, event_types=event_types, watermark=watermark, timeout=timeout, ) - def subscribe_to_push(self, callback_url, event_types=SubscribeToPush.EVENT_TYPES, watermark=None, - status_frequency=1): + def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1): + from ..services import SubscribeToPush if not self.folders: log.debug('Folder list is empty') - return - yield from SubscribeToPush(account=self.account).call( + return None + if event_types is None: + event_types = SubscribeToPush.EVENT_TYPES + return SubscribeToPush(account=self.account).get( folders=self.folders, event_types=event_types, watermark=watermark, status_frequency=status_frequency, url=callback_url, ) - def subscribe_to_streaming(self, event_types=SubscribeToPush.EVENT_TYPES): + def subscribe_to_streaming(self, event_types=None): + from ..services import SubscribeToStreaming if not self.folders: log.debug('Folder list is empty') - return - yield from SubscribeToStreaming(account=self.account).call(folders=self.folders, event_types=event_types) + return None + if event_types is None: + event_types = SubscribeToStreaming.EVENT_TYPES + return SubscribeToStreaming(account=self.account).get(folders=self.folders, event_types=event_types) + + def pull_subscription(self, **kwargs): + return PullSubscription(folder=self, **kwargs) + + def push_subscription(self, **kwargs): + return PushSubscription(folder=self, **kwargs) + + def streaming_subscription(self, **kwargs): + return StreamingSubscription(folder=self, **kwargs) + + def unsubscribe(self, subscription_id): + """Unsubscribe. Only applies to pull and streaming notifications. + + :param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|streaming]() + :return: True + + This method doesn't need the current collection instance, but it makes sense to keep the method along the other + sync methods. + """ + from ..services import Unsubscribe + return Unsubscribe(account=self.account).get(subscription_id=subscription_id) def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None): + from ..services import SyncFolderItems folder = self._get_single_folder() - if not folder: - return if only_fields is None: # We didn't restrict list of field paths. Get all fields from the server, including extended properties. additional_fields = {FieldPath(field=f) for f in folder.allowed_item_fields(version=self.account.version)} @@ -479,17 +492,19 @@

    Module exchangelib.folders.collections

    raise SyncCompleted(sync_state=svc.sync_state) def sync_hierarchy(self, sync_state=None, only_fields=None): + from ..services import SyncFolderHierarchy folder = self._get_single_folder() - if not folder: - return if only_fields is None: # We didn't restrict list of field paths. Get all fields from the server, including extended properties. additional_fields = {FieldPath(field=f) for f in folder.supported_fields(version=self.account.version)} else: - for f in only_fields: - folder.validate_field(field=f, version=self.account.version) - # Remove ItemId and ChangeKey. We get them unconditionally - additional_fields = {f for f in folder.normalize_fields(fields=only_fields) if not f.field.is_attribute} + additional_fields = set() + for field_name in only_fields: + folder.validate_field(field=field_name, version=self.account.version) + f = folder.get_field_by_fieldname(fieldname=field_name) + if not f.is_attribute: + # Remove ItemId and ChangeKey. We get them unconditionally + additional_fields.add(FieldPath(field=f)) # Add required fields additional_fields.update( @@ -510,7 +525,44 @@

    Module exchangelib.folders.collections

    sync_state = svc.sync_state # Set the new sync state in the next call if svc.includes_last_item_in_range: # Try again if there are more items break - raise SyncCompleted(sync_state=svc.sync_state)
    + raise SyncCompleted(sync_state=svc.sync_state) + + +class BaseSubscription(metaclass=abc.ABCMeta): + def __init__(self, folder, **subscription_kwargs): + self.folder = folder + self.subscription_kwargs = subscription_kwargs + self.subscription_id = None + + @abc.abstractmethod + def __enter__(self): + """Create the subscription""" + + def __exit__(self, *args, **kwargs): + self.folder.unsubscribe(subscription_id=self.subscription_id) + self.subscription_id = None + + +class PullSubscription(BaseSubscription): + def __enter__(self): + self.subscription_id, watermark = self.folder.subscribe_to_pull(**self.subscription_kwargs) + return self.subscription_id, watermark + + +class PushSubscription(BaseSubscription): + def __enter__(self): + self.subscription_id, watermark = self.folder.subscribe_to_push(**self.subscription_kwargs) + return self.subscription_id, watermark + + def __exit__(self, *args, **kwargs): + # Cannot unsubscribe to push subscriptions + pass + + +class StreamingSubscription(BaseSubscription): + def __enter__(self): + self.subscription_id = self.folder.subscribe_to_streaming(**self.subscription_kwargs) + return self.subscription_id
    @@ -522,6 +574,37 @@

    Module exchangelib.folders.collections

    Classes

    +
    +class BaseSubscription +(folder, **subscription_kwargs) +
    +
    +
    +
    + +Expand source code + +
    class BaseSubscription(metaclass=abc.ABCMeta):
    +    def __init__(self, folder, **subscription_kwargs):
    +        self.folder = folder
    +        self.subscription_kwargs = subscription_kwargs
    +        self.subscription_id = None
    +
    +    @abc.abstractmethod
    +    def __enter__(self):
    +        """Create the subscription"""
    +
    +    def __exit__(self, *args, **kwargs):
    +        self.folder.unsubscribe(subscription_id=self.subscription_id)
    +        self.subscription_id = None
    +
    +

    Subclasses

    + +
    class FolderCollection (account, folders) @@ -638,7 +721,8 @@

    Classes

    return tuple(item_model for folder in self.folders for item_model in folder.supported_item_models) def validate_item_field(self, field, version): - # For each field, check if the field is valid for any of the item models supported by this folder + # Takes a fieldname, Field or FieldPath object pointing to an item field, and checks that it is valid + # for the item types supported by this folder collection. for item_model in self.supported_item_models: try: item_model.validate_field(field=field, version=version) @@ -646,14 +730,35 @@

    Classes

    except InvalidField: continue else: - raise InvalidField("%r is not a valid field on %s" % (field, self.supported_item_models)) + raise InvalidField(f"{field!r} is not a valid field on {self.supported_item_models}") + + def _rinse_args(self, q, depth, additional_fields, field_validator): + if depth is None: + depth = self._get_default_item_traversal_depth() + if additional_fields: + for f in additional_fields: + field_validator(field=f, version=self.account.version) + if f.field.is_complex: + raise ValueError(f"Field {f.field.name!r} not supported for this service") + + # Build up any restrictions + if q.is_empty(): + restriction = None + query_string = None + elif q.query_string: + restriction = None + query_string = Restriction(q, folders=self.folders, applies_to=Restriction.ITEMS) + else: + restriction = Restriction(q, folders=self.folders, applies_to=Restriction.ITEMS) + query_string = None + return depth, restriction, query_string def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order_fields=None, calendar_view=None, page_size=None, max_items=None, offset=0): """Private method to call the FindItem service. :param q: a Q instance containing any restrictions - :param shape: controls whether to return (id, chanegkey) tuples or Item objects. If additional_fields is + :param shape: controls whether to return (id, changekey) tuples or Item objects. If additional_fields is non-null, we always return Item objects. (Default value = ID_ONLY) :param depth: controls the whether to return soft-deleted items or not. (Default value = None) :param additional_fields: the extra properties we want on the return objects. Default is no properties. Be aware @@ -666,36 +771,20 @@

    Classes

    :return: a generator for the returned item IDs or items """ + from ..services import FindItem if not self.folders: log.debug('Folder list is empty') return if q.is_never(): log.debug('Query will never return results') return - if shape not in SHAPE_CHOICES: - raise ValueError("'shape' %s must be one of %s" % (shape, SHAPE_CHOICES)) - if depth is None: - depth = self._get_default_item_traversal_depth() - if depth not in ITEM_TRAVERSAL_CHOICES: - raise ValueError("'depth' %s must be one of %s" % (depth, ITEM_TRAVERSAL_CHOICES)) - if additional_fields: - for f in additional_fields: - self.validate_item_field(field=f, version=self.account.version) - if f.field.is_complex: - raise ValueError("find_items() does not support field '%s'. Use fetch() instead" % f.field.name) + depth, restriction, query_string = self._rinse_args( + q=q, depth=depth, additional_fields=additional_fields, + field_validator=self.validate_item_field + ) if calendar_view is not None and not isinstance(calendar_view, CalendarView): - raise ValueError("'calendar_view' %s must be a CalendarView instance" % calendar_view) + raise InvalidTypeError('calendar_view', calendar_view, CalendarView) - # Build up any restrictions - if q.is_empty(): - restriction = None - query_string = None - elif q.query_string: - restriction = None - query_string = Restriction(q, folders=self.folders, applies_to=Restriction.ITEMS) - else: - restriction = Restriction(q, folders=self.folders, applies_to=Restriction.ITEMS) - query_string = None log.debug( 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', self.account, @@ -705,7 +794,7 @@

    Classes

    additional_fields, restriction.q if restriction else None, ) - yield from FindItem(account=self.account, chunk_size=page_size).call( + yield from FindItem(account=self.account, page_size=page_size).call( folders=self.folders, additional_fields=additional_fields, restriction=restriction, @@ -731,7 +820,7 @@

    Classes

    """Private method to call the FindPeople service. :param q: a Q instance containing any restrictions - :param shape: controls whether to return (id, chanegkey) tuples or Persona objects. If additional_fields is + :param shape: controls whether to return (id, changekey) tuples or Persona objects. If additional_fields is non-null, we always return Persona objects. (Default value = ID_ONLY) :param depth: controls the whether to return soft-deleted items or not. (Default value = None) :param additional_fields: the extra properties we want on the return objects. Default is no properties. @@ -742,35 +831,17 @@

    Classes

    :return: a generator for the returned personas """ + from ..services import FindPeople folder = self._get_single_folder() - if not folder: - return if q.is_never(): log.debug('Query will never return results') return - if shape not in SHAPE_CHOICES: - raise ValueError("'shape' %s must be one of %s" % (shape, SHAPE_CHOICES)) - if depth is None: - depth = self._get_default_item_traversal_depth() - if depth not in ITEM_TRAVERSAL_CHOICES: - raise ValueError("'depth' %s must be one of %s" % (depth, ITEM_TRAVERSAL_CHOICES)) - if additional_fields: - for f in additional_fields: - Persona.validate_field(field=f, version=self.account.version) - if f.field.is_complex: - raise ValueError("find_people() does not support field '%s'" % f.field.name) + depth, restriction, query_string = self._rinse_args( + q=q, depth=depth, additional_fields=additional_fields, + field_validator=Persona.validate_field + ) - # Build up any restrictions - if q.is_empty(): - restriction = None - query_string = None - elif q.query_string: - restriction = None - query_string = Restriction(q, folders=[folder], applies_to=Restriction.ITEMS) - else: - restriction = Restriction(q, folders=[folder], applies_to=Restriction.ITEMS) - query_string = None - yield from FindPeople(account=self.account, chunk_size=page_size).call( + yield from FindPeople(account=self.account, page_size=page_size).call( folder=folder, additional_fields=additional_fields, restriction=restriction, @@ -798,11 +869,11 @@

    Classes

    for f in self.folders: if isinstance(f, RootOfHierarchy): if has_non_roots: - raise ValueError('Cannot call GetFolder on a mix of folder types: {}'.format(self.folders)) + raise ValueError(f'Cannot call GetFolder on a mix of folder types: {self.folders}') has_roots = True else: if has_roots: - raise ValueError('Cannot call GetFolder on a mix of folder types: {}'.format(self.folders)) + raise ValueError(f'Cannot call GetFolder on a mix of folder types: {self.folders}') has_non_roots = True return RootOfHierarchy if has_roots else Folder @@ -811,8 +882,8 @@

    Classes

    if len(unique_depths) == 1: return unique_depths.pop() raise ValueError( - 'Folders in this collection do not have a common %s value. You need to define an explicit traversal depth' - 'with QuerySet.depth() (values: %s)' % (traversal_attr, unique_depths) + f'Folders in this collection do not have a common {traversal_attr} value. You need to define an explicit ' + f'traversal depth with QuerySet.depth() (values: {unique_depths})' ) def _get_default_item_traversal_depth(self): @@ -842,6 +913,7 @@

    Classes

    @require_account def find_folders(self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None, offset=0): + from ..services import FindFolder # 'depth' controls whether to return direct children or recurse into sub-folders from .base import BaseFolder, Folder if q is None: @@ -856,26 +928,22 @@

    Classes

    restriction = None else: restriction = Restriction(q, folders=self.folders, applies_to=Restriction.FOLDERS) - if shape not in SHAPE_CHOICES: - raise ValueError("'shape' %s must be one of %s" % (shape, SHAPE_CHOICES)) if depth is None: depth = self._get_default_folder_traversal_depth() - if depth not in FOLDER_TRAVERSAL_CHOICES: - raise ValueError("'depth' %s must be one of %s" % (depth, FOLDER_TRAVERSAL_CHOICES)) if additional_fields is None: # Default to all non-complex properties. Subfolders will always be of class Folder additional_fields = self.get_folder_fields(target_cls=Folder, is_complex=False) else: for f in additional_fields: if f.field.is_complex: - raise ValueError("find_folders() does not support field '%s'. Use get_folders()." % f.field.name) + raise ValueError(f"find_folders() does not support field {f.field.name!r}. Use get_folders().") # Add required fields additional_fields.update( (FieldPath(field=BaseFolder.get_field_by_fieldname(f)) for f in self.REQUIRED_FOLDER_FIELDS) ) - yield from FindFolder(account=self.account, chunk_size=page_size).call( + yield from FindFolder(account=self.account, page_size=page_size).call( folders=self.folders, additional_fields=additional_fields, restriction=restriction, @@ -886,6 +954,7 @@

    Classes

    ) def get_folders(self, additional_fields=None): + from ..services import GetFolder # Expand folders with their full set of properties from .base import BaseFolder if not self.folders: @@ -906,34 +975,62 @@

    Classes

    shape=ID_ONLY, ) - def subscribe_to_pull(self, event_types=SubscribeToPull.EVENT_TYPES, watermark=None, timeout=60): + def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): + from ..services import SubscribeToPull if not self.folders: log.debug('Folder list is empty') - return - yield from SubscribeToPull(account=self.account).call( + return None + if event_types is None: + event_types = SubscribeToPull.EVENT_TYPES + return SubscribeToPull(account=self.account).get( folders=self.folders, event_types=event_types, watermark=watermark, timeout=timeout, ) - def subscribe_to_push(self, callback_url, event_types=SubscribeToPush.EVENT_TYPES, watermark=None, - status_frequency=1): + def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1): + from ..services import SubscribeToPush if not self.folders: log.debug('Folder list is empty') - return - yield from SubscribeToPush(account=self.account).call( + return None + if event_types is None: + event_types = SubscribeToPush.EVENT_TYPES + return SubscribeToPush(account=self.account).get( folders=self.folders, event_types=event_types, watermark=watermark, status_frequency=status_frequency, url=callback_url, ) - def subscribe_to_streaming(self, event_types=SubscribeToPush.EVENT_TYPES): + def subscribe_to_streaming(self, event_types=None): + from ..services import SubscribeToStreaming if not self.folders: log.debug('Folder list is empty') - return - yield from SubscribeToStreaming(account=self.account).call(folders=self.folders, event_types=event_types) + return None + if event_types is None: + event_types = SubscribeToStreaming.EVENT_TYPES + return SubscribeToStreaming(account=self.account).get(folders=self.folders, event_types=event_types) + + def pull_subscription(self, **kwargs): + return PullSubscription(folder=self, **kwargs) + + def push_subscription(self, **kwargs): + return PushSubscription(folder=self, **kwargs) + + def streaming_subscription(self, **kwargs): + return StreamingSubscription(folder=self, **kwargs) + + def unsubscribe(self, subscription_id): + """Unsubscribe. Only applies to pull and streaming notifications. + + :param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|streaming]() + :return: True + + This method doesn't need the current collection instance, but it makes sense to keep the method along the other + sync methods. + """ + from ..services import Unsubscribe + return Unsubscribe(account=self.account).get(subscription_id=subscription_id) def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None): + from ..services import SyncFolderItems folder = self._get_single_folder() - if not folder: - return if only_fields is None: # We didn't restrict list of field paths. Get all fields from the server, including extended properties. additional_fields = {FieldPath(field=f) for f in folder.allowed_item_fields(version=self.account.version)} @@ -963,17 +1060,19 @@

    Classes

    raise SyncCompleted(sync_state=svc.sync_state) def sync_hierarchy(self, sync_state=None, only_fields=None): + from ..services import SyncFolderHierarchy folder = self._get_single_folder() - if not folder: - return if only_fields is None: # We didn't restrict list of field paths. Get all fields from the server, including extended properties. additional_fields = {FieldPath(field=f) for f in folder.supported_fields(version=self.account.version)} else: - for f in only_fields: - folder.validate_field(field=f, version=self.account.version) - # Remove ItemId and ChangeKey. We get them unconditionally - additional_fields = {f for f in folder.normalize_fields(fields=only_fields) if not f.field.is_attribute} + additional_fields = set() + for field_name in only_fields: + folder.validate_field(field=field_name, version=self.account.version) + f = folder.get_field_by_fieldname(fieldname=field_name) + if not f.is_attribute: + # Remove ItemId and ChangeKey. We get them unconditionally + additional_fields.add(FieldPath(field=f)) # Add required fields additional_fields.update( @@ -1047,19 +1146,6 @@

    Instance variables

    Methods

    -
    -def all(self) -
    -
    -
    -
    - -Expand source code - -
    def all(self):
    -    return QuerySet(self).all()
    -
    -
    def allowed_item_fields(self)
    @@ -1077,19 +1163,6 @@

    Methods

    return fields
    -
    -def exclude(self, *args, **kwargs) -
    -
    -
    -
    - -Expand source code - -
    def exclude(self, *args, **kwargs):
    -    return QuerySet(self).exclude(*args, **kwargs)
    -
    -
    def filter(self, *args, **kwargs)
    @@ -1156,6 +1229,7 @@

    Examples

    @require_account
     def find_folders(self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None,
                      offset=0):
    +    from ..services import FindFolder
         # 'depth' controls whether to return direct children or recurse into sub-folders
         from .base import BaseFolder, Folder
         if q is None:
    @@ -1170,26 +1244,22 @@ 

    Examples

    restriction = None else: restriction = Restriction(q, folders=self.folders, applies_to=Restriction.FOLDERS) - if shape not in SHAPE_CHOICES: - raise ValueError("'shape' %s must be one of %s" % (shape, SHAPE_CHOICES)) if depth is None: depth = self._get_default_folder_traversal_depth() - if depth not in FOLDER_TRAVERSAL_CHOICES: - raise ValueError("'depth' %s must be one of %s" % (depth, FOLDER_TRAVERSAL_CHOICES)) if additional_fields is None: # Default to all non-complex properties. Subfolders will always be of class Folder additional_fields = self.get_folder_fields(target_cls=Folder, is_complex=False) else: for f in additional_fields: if f.field.is_complex: - raise ValueError("find_folders() does not support field '%s'. Use get_folders()." % f.field.name) + raise ValueError(f"find_folders() does not support field {f.field.name!r}. Use get_folders().") # Add required fields additional_fields.update( (FieldPath(field=BaseFolder.get_field_by_fieldname(f)) for f in self.REQUIRED_FOLDER_FIELDS) ) - yield from FindFolder(account=self.account, chunk_size=page_size).call( + yield from FindFolder(account=self.account, page_size=page_size).call( folders=self.folders, additional_fields=additional_fields, restriction=restriction, @@ -1206,7 +1276,7 @@

    Examples

    Private method to call the FindItem service.

    :param q: a Q instance containing any restrictions -:param shape: controls whether to return (id, chanegkey) tuples or Item objects. If additional_fields is +:param shape: controls whether to return (id, changekey) tuples or Item objects. If additional_fields is non-null, we always return Item objects. (Default value = ID_ONLY) :param depth: controls the whether to return soft-deleted items or not. (Default value = None) :param additional_fields: the extra properties we want on the return objects. Default is no properties. Be aware @@ -1226,7 +1296,7 @@

    Examples

    """Private method to call the FindItem service. :param q: a Q instance containing any restrictions - :param shape: controls whether to return (id, chanegkey) tuples or Item objects. If additional_fields is + :param shape: controls whether to return (id, changekey) tuples or Item objects. If additional_fields is non-null, we always return Item objects. (Default value = ID_ONLY) :param depth: controls the whether to return soft-deleted items or not. (Default value = None) :param additional_fields: the extra properties we want on the return objects. Default is no properties. Be aware @@ -1239,36 +1309,20 @@

    Examples

    :return: a generator for the returned item IDs or items """ + from ..services import FindItem if not self.folders: log.debug('Folder list is empty') return if q.is_never(): log.debug('Query will never return results') return - if shape not in SHAPE_CHOICES: - raise ValueError("'shape' %s must be one of %s" % (shape, SHAPE_CHOICES)) - if depth is None: - depth = self._get_default_item_traversal_depth() - if depth not in ITEM_TRAVERSAL_CHOICES: - raise ValueError("'depth' %s must be one of %s" % (depth, ITEM_TRAVERSAL_CHOICES)) - if additional_fields: - for f in additional_fields: - self.validate_item_field(field=f, version=self.account.version) - if f.field.is_complex: - raise ValueError("find_items() does not support field '%s'. Use fetch() instead" % f.field.name) + depth, restriction, query_string = self._rinse_args( + q=q, depth=depth, additional_fields=additional_fields, + field_validator=self.validate_item_field + ) if calendar_view is not None and not isinstance(calendar_view, CalendarView): - raise ValueError("'calendar_view' %s must be a CalendarView instance" % calendar_view) + raise InvalidTypeError('calendar_view', calendar_view, CalendarView) - # Build up any restrictions - if q.is_empty(): - restriction = None - query_string = None - elif q.query_string: - restriction = None - query_string = Restriction(q, folders=self.folders, applies_to=Restriction.ITEMS) - else: - restriction = Restriction(q, folders=self.folders, applies_to=Restriction.ITEMS) - query_string = None log.debug( 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', self.account, @@ -1278,7 +1332,7 @@

    Examples

    additional_fields, restriction.q if restriction else None, ) - yield from FindItem(account=self.account, chunk_size=page_size).call( + yield from FindItem(account=self.account, page_size=page_size).call( folders=self.folders, additional_fields=additional_fields, restriction=restriction, @@ -1298,7 +1352,7 @@

    Examples

    Private method to call the FindPeople service.

    :param q: a Q instance containing any restrictions -:param shape: controls whether to return (id, chanegkey) tuples or Persona objects. If additional_fields is +:param shape: controls whether to return (id, changekey) tuples or Persona objects. If additional_fields is non-null, we always return Persona objects. (Default value = ID_ONLY) :param depth: controls the whether to return soft-deleted items or not. (Default value = None) :param additional_fields: the extra properties we want on the return objects. Default is no properties. @@ -1316,7 +1370,7 @@

    Examples

    """Private method to call the FindPeople service. :param q: a Q instance containing any restrictions - :param shape: controls whether to return (id, chanegkey) tuples or Persona objects. If additional_fields is + :param shape: controls whether to return (id, changekey) tuples or Persona objects. If additional_fields is non-null, we always return Persona objects. (Default value = ID_ONLY) :param depth: controls the whether to return soft-deleted items or not. (Default value = None) :param additional_fields: the extra properties we want on the return objects. Default is no properties. @@ -1327,35 +1381,17 @@

    Examples

    :return: a generator for the returned personas """ + from ..services import FindPeople folder = self._get_single_folder() - if not folder: - return if q.is_never(): log.debug('Query will never return results') return - if shape not in SHAPE_CHOICES: - raise ValueError("'shape' %s must be one of %s" % (shape, SHAPE_CHOICES)) - if depth is None: - depth = self._get_default_item_traversal_depth() - if depth not in ITEM_TRAVERSAL_CHOICES: - raise ValueError("'depth' %s must be one of %s" % (depth, ITEM_TRAVERSAL_CHOICES)) - if additional_fields: - for f in additional_fields: - Persona.validate_field(field=f, version=self.account.version) - if f.field.is_complex: - raise ValueError("find_people() does not support field '%s'" % f.field.name) + depth, restriction, query_string = self._rinse_args( + q=q, depth=depth, additional_fields=additional_fields, + field_validator=Persona.validate_field + ) - # Build up any restrictions - if q.is_empty(): - restriction = None - query_string = None - elif q.query_string: - restriction = None - query_string = Restriction(q, folders=[folder], applies_to=Restriction.ITEMS) - else: - restriction = Restriction(q, folders=[folder], applies_to=Restriction.ITEMS) - query_string = None - yield from FindPeople(account=self.account, chunk_size=page_size).call( + yield from FindPeople(account=self.account, page_size=page_size).call( folder=folder, additional_fields=additional_fields, restriction=restriction, @@ -1368,19 +1404,6 @@

    Examples

    )
    -
    -def get(self, *args, **kwargs) -
    -
    -
    -
    - -Expand source code - -
    def get(self, *args, **kwargs):
    -    return QuerySet(self).get(*args, **kwargs)
    -
    -
    def get_folder_fields(self, target_cls, is_complex=None)
    @@ -1407,6 +1430,7 @@

    Examples

    Expand source code
    def get_folders(self, additional_fields=None):
    +    from ..services import GetFolder
         # Expand folders with their full set of properties
         from .base import BaseFolder
         if not self.folders:
    @@ -1428,8 +1452,8 @@ 

    Examples

    )
    -
    -def none(self) +
    +def pull_subscription(self, **kwargs)
    @@ -1437,12 +1461,12 @@

    Examples

    Expand source code -
    def none(self):
    -    return QuerySet(self).none()
    +
    def pull_subscription(self, **kwargs):
    +    return PullSubscription(folder=self, **kwargs)
    -
    -def people(self) +
    +def push_subscription(self, **kwargs)
    @@ -1450,8 +1474,8 @@

    Examples

    Expand source code -
    def people(self):
    -    return QuerySet(self).people()
    +
    def push_subscription(self, **kwargs):
    +    return PushSubscription(folder=self, **kwargs)
    @@ -1480,8 +1504,21 @@

    Examples

    )
    +
    +def streaming_subscription(self, **kwargs) +
    +
    +
    +
    + +Expand source code + +
    def streaming_subscription(self, **kwargs):
    +    return StreamingSubscription(folder=self, **kwargs)
    +
    +
    -def subscribe_to_pull(self, event_types=('CopiedEvent', 'CreatedEvent', 'DeletedEvent', 'ModifiedEvent', 'MovedEvent', 'NewMailEvent', 'FreeBusyChangedEvent'), watermark=None, timeout=60) +def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60)
    @@ -1489,17 +1526,20 @@

    Examples

    Expand source code -
    def subscribe_to_pull(self, event_types=SubscribeToPull.EVENT_TYPES, watermark=None, timeout=60):
    +
    def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60):
    +    from ..services import SubscribeToPull
         if not self.folders:
             log.debug('Folder list is empty')
    -        return
    -    yield from SubscribeToPull(account=self.account).call(
    +        return None
    +    if event_types is None:
    +        event_types = SubscribeToPull.EVENT_TYPES
    +    return SubscribeToPull(account=self.account).get(
             folders=self.folders, event_types=event_types, watermark=watermark, timeout=timeout,
         )
    -def subscribe_to_push(self, callback_url, event_types=('CopiedEvent', 'CreatedEvent', 'DeletedEvent', 'ModifiedEvent', 'MovedEvent', 'NewMailEvent', 'FreeBusyChangedEvent'), watermark=None, status_frequency=1) +def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1)
    @@ -1507,19 +1547,21 @@

    Examples

    Expand source code -
    def subscribe_to_push(self, callback_url, event_types=SubscribeToPush.EVENT_TYPES, watermark=None,
    -                      status_frequency=1):
    +
    def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1):
    +    from ..services import SubscribeToPush
         if not self.folders:
             log.debug('Folder list is empty')
    -        return
    -    yield from SubscribeToPush(account=self.account).call(
    +        return None
    +    if event_types is None:
    +        event_types = SubscribeToPush.EVENT_TYPES
    +    return SubscribeToPush(account=self.account).get(
             folders=self.folders, event_types=event_types, watermark=watermark, status_frequency=status_frequency,
             url=callback_url,
         )
    -def subscribe_to_streaming(self, event_types=('CopiedEvent', 'CreatedEvent', 'DeletedEvent', 'ModifiedEvent', 'MovedEvent', 'NewMailEvent', 'FreeBusyChangedEvent')) +def subscribe_to_streaming(self, event_types=None)
    @@ -1527,11 +1569,14 @@

    Examples

    Expand source code -
    def subscribe_to_streaming(self, event_types=SubscribeToPush.EVENT_TYPES):
    +
    def subscribe_to_streaming(self, event_types=None):
    +    from ..services import SubscribeToStreaming
         if not self.folders:
             log.debug('Folder list is empty')
    -        return
    -    yield from SubscribeToStreaming(account=self.account).call(folders=self.folders, event_types=event_types)
    + return None + if event_types is None: + event_types = SubscribeToStreaming.EVENT_TYPES + return SubscribeToStreaming(account=self.account).get(folders=self.folders, event_types=event_types)
    @@ -1544,17 +1589,19 @@

    Examples

    Expand source code
    def sync_hierarchy(self, sync_state=None, only_fields=None):
    +    from ..services import SyncFolderHierarchy
         folder = self._get_single_folder()
    -    if not folder:
    -        return
         if only_fields is None:
             # We didn't restrict list of field paths. Get all fields from the server, including extended properties.
             additional_fields = {FieldPath(field=f) for f in folder.supported_fields(version=self.account.version)}
         else:
    -        for f in only_fields:
    -            folder.validate_field(field=f, version=self.account.version)
    -        # Remove ItemId and ChangeKey. We get them unconditionally
    -        additional_fields = {f for f in folder.normalize_fields(fields=only_fields) if not f.field.is_attribute}
    +        additional_fields = set()
    +        for field_name in only_fields:
    +            folder.validate_field(field=field_name, version=self.account.version)
    +            f = folder.get_field_by_fieldname(fieldname=field_name)
    +            if not f.is_attribute:
    +                # Remove ItemId and ChangeKey. We get them unconditionally
    +                additional_fields.add(FieldPath(field=f))
     
         # Add required fields
         additional_fields.update(
    @@ -1588,9 +1635,8 @@ 

    Examples

    Expand source code
    def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None):
    +    from ..services import SyncFolderItems
         folder = self._get_single_folder()
    -    if not folder:
    -        return
         if only_fields is None:
             # We didn't restrict list of field paths. Get all fields from the server, including extended properties.
             additional_fields = {FieldPath(field=f) for f in folder.allowed_item_fields(version=self.account.version)}
    @@ -1620,6 +1666,32 @@ 

    Examples

    raise SyncCompleted(sync_state=svc.sync_state)
    +
    +def unsubscribe(self, subscription_id) +
    +
    +

    Unsubscribe. Only applies to pull and streaming notifications.

    +

    :param subscription_id: A subscription ID as acquired by .subscribe_to_pull|streaming +:return: True

    +

    This method doesn't need the current collection instance, but it makes sense to keep the method along the other +sync methods.

    +
    + +Expand source code + +
    def unsubscribe(self, subscription_id):
    +    """Unsubscribe. Only applies to pull and streaming notifications.
    +
    +    :param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|streaming]()
    +    :return: True
    +
    +    This method doesn't need the current collection instance, but it makes sense to keep the method along the other
    +    sync methods.
    +    """
    +    from ..services import Unsubscribe
    +    return Unsubscribe(account=self.account).get(subscription_id=subscription_id)
    +
    +
    def validate_item_field(self, field, version)
    @@ -1630,7 +1702,8 @@

    Examples

    Expand source code
    def validate_item_field(self, field, version):
    -    # For each field, check if the field is valid for any of the item models supported by this folder
    +    # Takes a fieldname, Field or FieldPath object pointing to an item field, and checks that it is valid
    +    # for the item types supported by this folder collection.
         for item_model in self.supported_item_models:
             try:
                 item_model.validate_field(field=field, version=version)
    @@ -1638,7 +1711,7 @@ 

    Examples

    except InvalidField: continue else: - raise InvalidField("%r is not a valid field on %s" % (field, self.supported_item_models))
    + raise InvalidField(f"{field!r} is not a valid field on {self.supported_item_models}")
    @@ -1686,6 +1759,82 @@

    Examples

    +

    Inherited members

    + + +
    +class PullSubscription +(folder, **subscription_kwargs) +
    +
    +
    +
    + +Expand source code + +
    class PullSubscription(BaseSubscription):
    +    def __enter__(self):
    +        self.subscription_id, watermark = self.folder.subscribe_to_pull(**self.subscription_kwargs)
    +        return self.subscription_id, watermark
    +
    +

    Ancestors

    + +
    +
    +class PushSubscription +(folder, **subscription_kwargs) +
    +
    +
    +
    + +Expand source code + +
    class PushSubscription(BaseSubscription):
    +    def __enter__(self):
    +        self.subscription_id, watermark = self.folder.subscribe_to_push(**self.subscription_kwargs)
    +        return self.subscription_id, watermark
    +
    +    def __exit__(self, *args, **kwargs):
    +        # Cannot unsubscribe to push subscriptions
    +        pass
    +
    +

    Ancestors

    + +
    +
    +class StreamingSubscription +(folder, **subscription_kwargs) +
    +
    +
    +
    + +Expand source code + +
    class StreamingSubscription(BaseSubscription):
    +    def __enter__(self):
    +        self.subscription_id = self.folder.subscribe_to_streaming(**self.subscription_kwargs)
    +        return self.subscription_id
    +
    +

    Ancestors

    +
    class SyncCompleted @@ -1727,34 +1876,45 @@

    Index

  • Classes

    diff --git a/docs/exchangelib/folders/index.html b/docs/exchangelib/folders/index.html index b59eb683..98d925b3 100644 --- a/docs/exchangelib/folders/index.html +++ b/docs/exchangelib/folders/index.html @@ -104,7 +104,7 @@

    Classes

    (**kwargs)
  • -

    A base class to use until we have a more specific folder implementation for this folder.

    +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code @@ -144,17 +144,26 @@

    Inherited members

  • WellknownFolder:
    • ID_ELEMENT_CLS
    • +
    • account
    • add_field
    • +
    • all
    • deregister
    • +
    • exclude
    • +
    • filter
    • folder_cls_from_container_class
    • folder_sync_state
    • +
    • get
    • get_distinguished
    • get_events
    • get_streaming_events
    • is_distinguished
    • item_sync_state
    • +
    • none
    • +
    • parent
    • +
    • people
    • register
    • remove_field
    • +
    • root
    • subscribe_to_pull
    • subscribe_to_push
    • subscribe_to_streaming
    • @@ -213,17 +222,26 @@

      Inherited members

    • Contacts:
      • ID_ELEMENT_CLS
      • +
      • account
      • add_field
      • +
      • all
      • deregister
      • +
      • exclude
      • +
      • filter
      • folder_cls_from_container_class
      • folder_sync_state
      • +
      • get
      • get_distinguished
      • get_events
      • get_streaming_events
      • is_distinguished
      • item_sync_state
      • +
      • none
      • +
      • parent
      • +
      • people
      • register
      • remove_field
      • +
      • root
      • subscribe_to_pull
      • subscribe_to_push
      • subscribe_to_streaming
      • @@ -281,17 +299,26 @@

        Inherited members

      • Folder:
        • ID_ELEMENT_CLS
        • +
        • account
        • add_field
        • +
        • all
        • deregister
        • +
        • exclude
        • +
        • filter
        • folder_cls_from_container_class
        • folder_sync_state
        • +
        • get
        • get_distinguished
        • get_events
        • get_streaming_events
        • is_distinguished
        • item_sync_state
        • +
        • none
        • +
        • parent
        • +
        • people
        • register
        • remove_field
        • +
        • root
        • subscribe_to_pull
        • subscribe_to_push
        • subscribe_to_streaming
        • @@ -311,7 +338,7 @@

          Inherited members

          (**kwargs)
          -

          A base class to use until we have a more specific folder implementation for this folder.

          +

          Base class to use until we have a more specific folder implementation for this folder.

          Expand source code @@ -346,17 +373,26 @@

          Inherited members

        • WellknownFolder:
          • ID_ELEMENT_CLS
          • +
          • account
          • add_field
          • +
          • all
          • deregister
          • +
          • exclude
          • +
          • filter
          • folder_cls_from_container_class
          • folder_sync_state
          • +
          • get
          • get_distinguished
          • get_events
          • get_streaming_events
          • is_distinguished
          • item_sync_state
          • +
          • none
          • +
          • parent
          • +
          • people
          • register
          • remove_field
          • +
          • root
          • subscribe_to_pull
          • subscribe_to_push
          • subscribe_to_streaming
          • @@ -376,7 +412,7 @@

            Inherited members

            (**kwargs)
            -

            A base class to use until we have a more specific folder implementation for this folder.

            +

            Base class to use until we have a more specific folder implementation for this folder.

            Expand source code @@ -411,17 +447,26 @@

            Inherited members

          • WellknownFolder:
            • ID_ELEMENT_CLS
            • +
            • account
            • add_field
            • +
            • all
            • deregister
            • +
            • exclude
            • +
            • filter
            • folder_cls_from_container_class
            • folder_sync_state
            • +
            • get
            • get_distinguished
            • get_events
            • get_streaming_events
            • is_distinguished
            • item_sync_state
            • +
            • none
            • +
            • parent
            • +
            • people
            • register
            • remove_field
            • +
            • root
            • subscribe_to_pull
            • subscribe_to_push
            • subscribe_to_streaming
            • @@ -441,7 +486,7 @@

              Inherited members

              (**kwargs)
              -

              A base class to use until we have a more specific folder implementation for this folder.

              +

              Base class to use until we have a more specific folder implementation for this folder.

              Expand source code @@ -476,17 +521,26 @@

              Inherited members

            • WellknownFolder:
              • ID_ELEMENT_CLS
              • +
              • account
              • add_field
              • +
              • all
              • deregister
              • +
              • exclude
              • +
              • filter
              • folder_cls_from_container_class
              • folder_sync_state
              • +
              • get
              • get_distinguished
              • get_events
              • get_streaming_events
              • is_distinguished
              • item_sync_state
              • +
              • none
              • +
              • parent
              • +
              • people
              • register
              • remove_field
              • +
              • root
              • subscribe_to_pull
              • subscribe_to_push
              • subscribe_to_streaming
              • @@ -506,7 +560,7 @@

                Inherited members

                (**kwargs)
                -

                A base class to use until we have a more specific folder implementation for this folder.

                +

                Base class to use until we have a more specific folder implementation for this folder.

                Expand source code @@ -541,17 +595,26 @@

                Inherited members

              • WellknownFolder:
                • ID_ELEMENT_CLS
                • +
                • account
                • add_field
                • +
                • all
                • deregister
                • +
                • exclude
                • +
                • filter
                • folder_cls_from_container_class
                • folder_sync_state
                • +
                • get
                • get_distinguished
                • get_events
                • get_streaming_events
                • is_distinguished
                • item_sync_state
                • +
                • none
                • +
                • parent
                • +
                • people
                • register
                • remove_field
                • +
                • root
                • subscribe_to_pull
                • subscribe_to_push
                • subscribe_to_streaming
                • @@ -571,7 +634,7 @@

                  Inherited members

                  (**kwargs)
                  -

                  A base class to use until we have a more specific folder implementation for this folder.

                  +

                  Base class to use until we have a more specific folder implementation for this folder.

                  Expand source code @@ -606,17 +669,26 @@

                  Inherited members

                • WellknownFolder:
                  • ID_ELEMENT_CLS
                  • +
                  • account
                  • add_field
                  • +
                  • all
                  • deregister
                  • +
                  • exclude
                  • +
                  • filter
                  • folder_cls_from_container_class
                  • folder_sync_state
                  • +
                  • get
                  • get_distinguished
                  • get_events
                  • get_streaming_events
                  • is_distinguished
                  • item_sync_state
                  • +
                  • none
                  • +
                  • parent
                  • +
                  • people
                  • register
                  • remove_field
                  • +
                  • root
                  • subscribe_to_pull
                  • subscribe_to_push
                  • subscribe_to_streaming
                  • @@ -636,7 +708,7 @@

                    Inherited members

                    (**kwargs)
                    -

                    A base class to use until we have a more specific folder implementation for this folder.

                    +

                    Base class to use until we have a more specific folder implementation for this folder.

                    Expand source code @@ -671,17 +743,26 @@

                    Inherited members

                  • WellknownFolder:
                    • ID_ELEMENT_CLS
                    • +
                    • account
                    • add_field
                    • +
                    • all
                    • deregister
                    • +
                    • exclude
                    • +
                    • filter
                    • folder_cls_from_container_class
                    • folder_sync_state
                    • +
                    • get
                    • get_distinguished
                    • get_events
                    • get_streaming_events
                    • is_distinguished
                    • item_sync_state
                    • +
                    • none
                    • +
                    • parent
                    • +
                    • people
                    • register
                    • remove_field
                    • +
                    • root
                    • subscribe_to_pull
                    • subscribe_to_push
                    • subscribe_to_streaming
                    • @@ -701,7 +782,7 @@

                      Inherited members

                      (**kwargs)
                      -

                      A base class to use until we have a more specific folder implementation for this folder.

                      +

                      Base class to use until we have a more specific folder implementation for this folder.

                      Expand source code @@ -736,17 +817,26 @@

                      Inherited members

                    • WellknownFolder:
                      • ID_ELEMENT_CLS
                      • +
                      • account
                      • add_field
                      • +
                      • all
                      • deregister
                      • +
                      • exclude
                      • +
                      • filter
                      • folder_cls_from_container_class
                      • folder_sync_state
                      • +
                      • get
                      • get_distinguished
                      • get_events
                      • get_streaming_events
                      • is_distinguished
                      • item_sync_state
                      • +
                      • none
                      • +
                      • parent
                      • +
                      • people
                      • register
                      • remove_field
                      • +
                      • root
                      • subscribe_to_pull
                      • subscribe_to_push
                      • subscribe_to_streaming
                      • @@ -807,19 +897,28 @@

                        Inherited members

                      • RootOfHierarchy:
                        • ID_ELEMENT_CLS
                        • +
                        • account
                        • add_field
                        • +
                        • all
                        • deregister
                        • +
                        • exclude
                        • +
                        • filter
                        • folder_cls_from_container_class
                        • folder_cls_from_folder_name
                        • folder_sync_state
                        • +
                        • get
                        • get_default_folder
                        • get_distinguished
                        • get_events
                        • get_streaming_events
                        • is_distinguished
                        • item_sync_state
                        • +
                        • none
                        • +
                        • parent
                        • +
                        • people
                        • register
                        • remove_field
                        • +
                        • root
                        • subscribe_to_pull
                        • subscribe_to_push
                        • subscribe_to_streaming
                        • @@ -876,17 +975,26 @@

                          Inherited members

                        • Folder:
                          • ID_ELEMENT_CLS
                          • +
                          • account
                          • add_field
                          • +
                          • all
                          • deregister
                          • +
                          • exclude
                          • +
                          • filter
                          • folder_cls_from_container_class
                          • folder_sync_state
                          • +
                          • get
                          • get_distinguished
                          • get_events
                          • get_streaming_events
                          • is_distinguished
                          • item_sync_state
                          • +
                          • none
                          • +
                          • parent
                          • +
                          • people
                          • register
                          • remove_field
                          • +
                          • root
                          • subscribe_to_pull
                          • subscribe_to_push
                          • subscribe_to_streaming
                          • @@ -956,17 +1064,17 @@

                            Inherited members

                            @property @abc.abstractmethod def account(self): - pass + """Return the account this folder belongs to""" @property @abc.abstractmethod def root(self): - pass + """Return the root folder this folder belongs to""" @property @abc.abstractmethod def parent(self): - pass + """Return the parent folder of this folder""" @property def is_deletable(self): @@ -995,7 +1103,7 @@

                            Inherited members

                            @property def absolute(self): - return ''.join('/%s' % p.name for p in self.parts) + return ''.join(f'/{p.name}' for p in self.parts) def _walk(self): for c in self.children: @@ -1049,29 +1157,27 @@

                            Inherited members

                            ├── exchangelib issues └── Mom """ - tree = '%s\n' % self.name + tree = f'{self.name}\n' children = list(self.children) for i, c in enumerate(sorted(children, key=attrgetter('name')), start=1): nodes = c.tree().split('\n') for j, node in enumerate(nodes, start=1): if i != len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += '├── %s\n' % node + tree += f'├── {node}\n' elif i != len(children) and j > 1: # Not the last child, and not name of child - tree += '│ %s\n' % node + tree += f'│ {node}\n' elif i == len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += '└── %s\n' % node + tree += f'└── {node}\n' else: # Last child, and not name of child - tree += ' %s\n' % node + tree += f' {node}\n' return tree.strip() @classmethod def supports_version(cls, version): # 'version' is a Version instance, for convenience by callers - if not isinstance(version, Version): - raise ValueError("'version' %r must be a Version instance" % version) if not cls.supported_from: return True return version.build >= cls.supported_from @@ -1108,31 +1214,18 @@

                            Inherited members

                            try: return cls.ITEM_MODEL_MAP[tag] except KeyError: - raise ValueError('Item type %s was unexpected in a %s folder' % (tag, cls.__name__)) + raise ValueError(f'Item type {tag} was unexpected in a {cls.__name__} folder') @classmethod def allowed_item_fields(cls, version): # Return non-ID fields of all item classes allowed in this folder type fields = set() for item_model in cls.supported_item_models: - fields.update( - set(item_model.supported_fields(version=version)) - ) + fields.update(set(item_model.supported_fields(version=version))) return fields def validate_item_field(self, field, version): - # Takes a fieldname, Field or FieldPath object pointing to an item field, and checks that it is valid - # for the item types supported by this folder. - - # For each field, check if the field is valid for any of the item models supported by this folder - for item_model in self.supported_item_models: - try: - item_model.validate_field(field=field, version=version) - break - except InvalidField: - continue - else: - raise InvalidField("%r is not a valid field on %s" % (field, self.supported_item_models)) + FolderCollection(account=self.account, folders=[self]).validate_item_field(field=field, version=version) def normalize_fields(self, fields): # Takes a list of fieldnames, Field or FieldPath objects pointing to item fields. Turns them into FieldPath @@ -1147,8 +1240,6 @@

                            Inherited members

                            elif isinstance(field_path, Field): field_path = FieldPath(field=field_path) fields[i] = field_path - if not isinstance(field_path, FieldPath): - raise ValueError("Field %r must be a string or FieldPath instance" % field_path) if field_path.field.name == 'start': has_start = True elif field_path.field.name == 'end': @@ -1174,7 +1265,7 @@

                            Inherited members

                            return item_model.get_field_by_fieldname(fieldname) except InvalidField: pass - raise InvalidField("%r is not a valid field name on %s" % (fieldname, cls.supported_item_models)) + raise InvalidField(f"{fieldname!r} is not a valid field name on {cls.supported_item_models}") def get(self, *args, **kwargs): return FolderCollection(account=self.account, folders=[self]).get(*args, **kwargs) @@ -1199,6 +1290,7 @@

                            Inherited members

                            return self.account.bulk_create(folder=self, items=items, *args, **kwargs) def save(self, update_fields=None): + from ..services import CreateFolder, UpdateFolder if self.id is None: # New folder if update_fields: @@ -1232,6 +1324,7 @@

                            Inherited members

                            return self def move(self, to_folder): + from ..services import MoveFolder res = MoveFolder(account=self.account).get(folders=[self], to_folder=to_folder) folder_id, changekey = res.id, res.changekey if self.id != folder_id: @@ -1242,15 +1335,13 @@

                            Inherited members

                            self.root.update_folder(self) # Update the folder in the cache def delete(self, delete_type=HARD_DELETE): - if delete_type not in DELETE_TYPE_CHOICES: - raise ValueError("'delete_type' %s must be one of %s" % (delete_type, DELETE_TYPE_CHOICES)) + from ..services import DeleteFolder DeleteFolder(account=self.account).get(folders=[self], delete_type=delete_type) self.root.remove_folder(self) # Remove the updated folder from the cache self._id = None def empty(self, delete_type=HARD_DELETE, delete_sub_folders=False): - if delete_type not in DELETE_TYPE_CHOICES: - raise ValueError("'delete_type' %s must be one of %s" % (delete_type, DELETE_TYPE_CHOICES)) + from ..services import EmptyFolder EmptyFolder(account=self.account).get( folders=[self], delete_type=delete_type, delete_sub_folders=delete_sub_folders ) @@ -1258,14 +1349,14 @@

                            Inherited members

                            # We don't know exactly what was deleted, so invalidate the entire folder cache to be safe self.root.clear_cache() - def wipe(self, page_size=None, _seen=None, _level=0): + def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0): # Recursively deletes all items in this folder, and all subfolders and their content. Attempts to protect # distinguished folders from being deleted. Use with caution! _seen = _seen or set() if self.id in _seen: - raise RecursionError('We already tried to wipe %s' % self) + raise RecursionError(f'We already tried to wipe {self}') if _level > 16: - raise RecursionError('Max recursion level reached: %s' % _level) + raise RecursionError(f'Max recursion level reached: {_level}') _seen.add(self.id) log.warning('Wiping %s', self) has_distinguished_subfolders = any(f.is_distinguished for f in self.children) @@ -1281,13 +1372,18 @@

                            Inherited members

                            self.empty(delete_sub_folders=False) except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): log.warning('Not allowed to empty %s. Trying to delete items instead', self) + kwargs = {} + if page_size is not None: + kwargs['page_size'] = page_size + if chunk_size is not None: + kwargs['chunk_size'] = chunk_size try: - self.all().delete(**dict(page_size=page_size) if page_size else {}) + self.all().delete(**kwargs) except (ErrorAccessDenied, ErrorCannotDeleteObject, ErrorItemNotFound): log.warning('Not allowed to delete items in %s', self) _level += 1 for f in self.children: - f.wipe(page_size=page_size, _seen=_seen, _level=_level) + f.wipe(page_size=page_size, chunk_size=chunk_size, _seen=_seen, _level=_level) # Remove non-distinguished children that are empty and have no subfolders if f.is_deletable and not f.children: log.warning('Deleting folder %s', f) @@ -1314,7 +1410,7 @@

                            Inherited members

                            kwargs['name'] = cls.DISTINGUISHED_FOLDER_ID return kwargs - def to_folder_id(self): + def to_id(self): if self.is_distinguished: # Don't add the changekey here. When modifying folder content, we usually don't care if others have changed # the folder content since we fetched the changekey. @@ -1328,29 +1424,19 @@

                            Inherited members

                            return FolderId(id=self.id, changekey=self.changekey) raise ValueError('Must be a distinguished folder or have an ID') - def to_xml(self, version): - try: - return self.to_folder_id().to_xml(version=version) - except ValueError: - return super().to_xml(version=version) - - def to_id_xml(self, version): - # Folder(name='Foo') is a perfectly valid ID to e.g. create a folder - return self.to_xml(version=version) - @classmethod def resolve(cls, account, folder): # Resolve a single folder folders = list(FolderCollection(account=account, folders=[folder]).resolve()) if not folders: - raise ErrorFolderNotFound('Could not find folder %r' % folder) + raise ErrorFolderNotFound(f'Could not find folder {folder!r}') if len(folders) != 1: - raise ValueError('Expected result length 1, but got %s' % folders) + raise ValueError(f'Expected result length 1, but got {folders}') f = folders[0] if isinstance(f, Exception): raise f if f.__class__ != cls: - raise ValueError("Expected folder %r to be a %s instance" % (f, cls)) + raise ValueError(f"Expected folder {f!r} to be a {cls} instance") return f @require_id @@ -1364,7 +1450,11 @@

                            Inherited members

                            return self @require_id - def get_user_configuration(self, name, properties=ALL): + def get_user_configuration(self, name, properties=None): + from ..services import GetUserConfiguration + from ..services.get_user_configuration import ALL + if properties is None: + properties = ALL return GetUserConfiguration(account=self.account).get( user_configuration_name=UserConfigurationNameMNS(name=name, folder=self), properties=properties, @@ -1372,6 +1462,7 @@

                            Inherited members

                            @require_id def create_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None): + from ..services import CreateUserConfiguration user_configuration = UserConfiguration( user_configuration_name=UserConfigurationName(name=name, folder=self), dictionary=dictionary, @@ -1382,6 +1473,7 @@

                            Inherited members

                            @require_id def update_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None): + from ..services import UpdateUserConfiguration user_configuration = UserConfiguration( user_configuration_name=UserConfigurationName(name=name, folder=self), dictionary=dictionary, @@ -1392,12 +1484,13 @@

                            Inherited members

                            @require_id def delete_user_configuration(self, name): + from ..services import DeleteUserConfiguration return DeleteUserConfiguration(account=self.account).get( user_configuration_name=UserConfigurationNameMNS(name=name, folder=self) ) @require_id - def subscribe_to_pull(self, event_types=SubscribeToPull.EVENT_TYPES, watermark=None, timeout=60): + def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): """Create a pull subscription. :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPull.EVENT_TYPES @@ -1406,19 +1499,15 @@

                            Inherited members

                            GetEvents request for this subscription. :return: The subscription ID and a watermark """ - s_ids = list(FolderCollection(account=self.account, folders=[self]).subscribe_to_pull( + from ..services import SubscribeToPull + if event_types is None: + event_types = SubscribeToPull.EVENT_TYPES + return FolderCollection(account=self.account, folders=[self]).subscribe_to_pull( event_types=event_types, watermark=watermark, timeout=timeout, - )) - if len(s_ids) != 1: - raise ValueError('Expected result length 1, but got %s' % s_ids) - s_id = s_ids[0] - if isinstance(s_id, Exception): - raise s_id - return s_id + ) @require_id - def subscribe_to_push(self, callback_url, event_types=SubscribeToPush.EVENT_TYPES, watermark=None, - status_frequency=1): + def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1): """Create a push subscription. :param callback_url: A client-defined URL that the server will call @@ -1427,32 +1516,24 @@

                            Inherited members

                            :param status_frequency: The frequency, in minutes, that the callback URL will be called with. :return: The subscription ID and a watermark """ - s_ids = list(FolderCollection(account=self.account, folders=[self]).subscribe_to_push( + from ..services import SubscribeToPush + if event_types is None: + event_types = SubscribeToPush.EVENT_TYPES + return FolderCollection(account=self.account, folders=[self]).subscribe_to_push( event_types=event_types, watermark=watermark, status_frequency=status_frequency, callback_url=callback_url, - )) - if len(s_ids) != 1: - raise ValueError('Expected result length 1, but got %s' % s_ids) - s_id = s_ids[0] - if isinstance(s_id, Exception): - raise s_id - return s_id + ) @require_id - def subscribe_to_streaming(self, event_types=SubscribeToPush.EVENT_TYPES): + def subscribe_to_streaming(self, event_types=None): """Create a streaming subscription. :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES :return: The subscription ID """ - s_ids = list(FolderCollection(account=self.account, folders=[self]).subscribe_to_streaming( - event_types=event_types, - )) - if len(s_ids) != 1: - raise ValueError('Expected result length 1, but got %s' % s_ids) - s_id = s_ids[0] - if isinstance(s_id, Exception): - raise s_id - return s_id + from ..services import SubscribeToStreaming + if event_types is None: + event_types = SubscribeToStreaming.EVENT_TYPES + return FolderCollection(account=self.account, folders=[self]).subscribe_to_streaming(event_types=event_types) @require_id def pull_subscription(self, **kwargs): @@ -1475,18 +1556,19 @@

                            Inherited members

                            This method doesn't need the current folder instance, but it makes sense to keep the method along the other sync methods. """ + from ..services import Unsubscribe return Unsubscribe(account=self.account).get(subscription_id=subscription_id) def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None): """Return all item changes to a folder, as a generator. If sync_state is specified, get all item changes after this sync state. After fully consuming the generator, self.item_sync_state will hold the new sync state. - :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderitems service. + :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderItems service. :param only_fields: A list of string or FieldPath items specifying the fields to fetch. Default to all fields :param ignore: A list of Item IDs to ignore in the sync :param max_changes_returned: The max number of change :param sync_scope: Specify whether to return just items, or items and folder associated information. Possible - values are specified in SyncFolderitems.SYNC_SCOPES + values are specified in SyncFolderItems.SYNC_SCOPES :return: A generator of (change_type, item) tuples """ if not sync_state: @@ -1508,7 +1590,7 @@

                            Inherited members

                            changes after this sync state. After fully consuming the generator, self.folder_sync_state will hold the new sync state. - :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderitems service. + :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderItems service. :param only_fields: A list of string or FieldPath items specifying the fields to fetch. Default to all fields :return: """ @@ -1533,6 +1615,7 @@

                            Inherited members

                            This method doesn't need the current folder instance, but it makes sense to keep the method along the other sync methods. """ + from ..services import GetEvents svc = GetEvents(account=self.account) while True: notification = svc.get(subscription_id=subscription_id, watermark=watermark) @@ -1553,9 +1636,8 @@

                            Inherited members

                            This method doesn't need the current folder instance, but it makes sense to keep the method along the other sync methods. """ - # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed - request_timeout = connection_timeout*60 + 60 - svc = GetStreamingEvents(account=self.account, timeout=request_timeout) + from ..services import GetStreamingEvents + svc = GetStreamingEvents(account=self.account) subscription_ids = subscription_id_or_ids if is_iterable(subscription_id_or_ids, generators_allowed=True) \ else [subscription_id_or_ids] for i, notification in enumerate( @@ -1587,7 +1669,7 @@

                            Inherited members

                            try: return SingleFolderQuerySet(account=self.account, folder=self).depth(SHALLOW_FOLDERS).get(name=other) except DoesNotExist: - raise ErrorFolderNotFound("No subfolder with name '%s'" % other) + raise ErrorFolderNotFound(f"No subfolder with name {other!r}") def __truediv__(self, other): """Support the some_folder / 'child_folder' / 'child_of_child_folder' navigation syntax.""" @@ -1600,7 +1682,7 @@

                            Inherited members

                            for c in self.children: if c.name == other: return c - raise ErrorFolderNotFound("No subfolder with name '%s'" % other) + raise ErrorFolderNotFound(f"No subfolder with name {other!r}") def __repr__(self): return self.__class__.__name__ + \ @@ -1608,7 +1690,7 @@

                            Inherited members

                            self.folder_class, self.id, self.changekey)) def __str__(self): - return '%s (%s)' % (self.__class__.__name__, self.name) + return f'{self.__class__.__name__} ({self.name})'
                    • Ancestors

                        @@ -1697,9 +1779,7 @@

                        Static methods

                        # Return non-ID fields of all item classes allowed in this folder type fields = set() for item_model in cls.supported_item_models: - fields.update( - set(item_model.supported_fields(version=version)) - ) + fields.update(set(item_model.supported_fields(version=version))) return fields
                  • @@ -1749,7 +1829,7 @@

                    Static methods

                    return item_model.get_field_by_fieldname(fieldname) except InvalidField: pass - raise InvalidField("%r is not a valid field name on %s" % (fieldname, cls.supported_item_models)) + raise InvalidField(f"{fieldname!r} is not a valid field name on {cls.supported_item_models}")
                • @@ -1766,7 +1846,7 @@

                  Static methods

                  try: return cls.ITEM_MODEL_MAP[tag] except KeyError: - raise ValueError('Item type %s was unexpected in a %s folder' % (tag, cls.__name__))
                  + raise ValueError(f'Item type {tag} was unexpected in a {cls.__name__} folder')
              • @@ -1799,14 +1879,14 @@

                Static methods

                # Resolve a single folder folders = list(FolderCollection(account=account, folders=[folder]).resolve()) if not folders: - raise ErrorFolderNotFound('Could not find folder %r' % folder) + raise ErrorFolderNotFound(f'Could not find folder {folder!r}') if len(folders) != 1: - raise ValueError('Expected result length 1, but got %s' % folders) + raise ValueError(f'Expected result length 1, but got {folders}') f = folders[0] if isinstance(f, Exception): raise f if f.__class__ != cls: - raise ValueError("Expected folder %r to be a %s instance" % (f, cls)) + raise ValueError(f"Expected folder {f!r} to be a {cls} instance") return f
            • @@ -1822,8 +1902,6 @@

              Static methods

              @classmethod
               def supports_version(cls, version):
                   # 'version' is a Version instance, for convenience by callers
              -    if not isinstance(version, Version):
              -        raise ValueError("'version' %r must be a Version instance" % version)
                   if not cls.supported_from:
                       return True
                   return version.build >= cls.supported_from
              @@ -1841,12 +1919,12 @@

              Instance variables

          • @property
             def absolute(self):
            -    return ''.join('/%s' % p.name for p in self.parts)
            + return ''.join(f'/{p.name}' for p in self.parts)
            var account
            -
            +

            Return the account this folder belongs to

            Expand source code @@ -1854,7 +1932,7 @@

            Instance variables

            @property
             @abc.abstractmethod
             def account(self):
            -    pass
            + """Return the account this folder belongs to"""
            var child_folder_count
            @@ -1921,7 +1999,7 @@

            Instance variables

        • var parent
          -
          +

          Return the parent folder of this folder

          Expand source code @@ -1929,7 +2007,7 @@

          Instance variables

          @property
           @abc.abstractmethod
           def parent(self):
          -    pass
          + """Return the parent folder of this folder"""
          var parent_folder_id
          @@ -1955,7 +2033,7 @@

          Instance variables

  • var root
    -
    +

    Return the root folder this folder belongs to

    Expand source code @@ -1963,7 +2041,7 @@

    Instance variables

    @property
     @abc.abstractmethod
     def root(self):
    -    pass
    + """Return the root folder this folder belongs to"""
    var total_count
    @@ -1977,19 +2055,6 @@

    Instance variables

    Methods

    -
    -def all(self) -
    -
    -
    -
    - -Expand source code - -
    def all(self):
    -    return FolderCollection(account=self.account, folders=[self]).all()
    -
    -
    def bulk_create(self, items, *args, **kwargs)
    @@ -2030,6 +2095,7 @@

    Methods

    @require_id
     def create_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None):
    +    from ..services import CreateUserConfiguration
         user_configuration = UserConfiguration(
             user_configuration_name=UserConfigurationName(name=name, folder=self),
             dictionary=dictionary,
    @@ -2049,8 +2115,7 @@ 

    Methods

    Expand source code
    def delete(self, delete_type=HARD_DELETE):
    -    if delete_type not in DELETE_TYPE_CHOICES:
    -        raise ValueError("'delete_type' %s must be one of %s" % (delete_type, DELETE_TYPE_CHOICES))
    +    from ..services import DeleteFolder
         DeleteFolder(account=self.account).get(folders=[self], delete_type=delete_type)
         self.root.remove_folder(self)  # Remove the updated folder from the cache
         self._id = None
    @@ -2067,6 +2132,7 @@

    Methods

    @require_id
     def delete_user_configuration(self, name):
    +    from ..services import DeleteUserConfiguration
         return DeleteUserConfiguration(account=self.account).get(
             user_configuration_name=UserConfigurationNameMNS(name=name, folder=self)
         )
    @@ -2082,8 +2148,7 @@

    Methods

    Expand source code
    def empty(self, delete_type=HARD_DELETE, delete_sub_folders=False):
    -    if delete_type not in DELETE_TYPE_CHOICES:
    -        raise ValueError("'delete_type' %s must be one of %s" % (delete_type, DELETE_TYPE_CHOICES))
    +    from ..services import EmptyFolder
         EmptyFolder(account=self.account).get(
             folders=[self], delete_type=delete_type, delete_sub_folders=delete_sub_folders
         )
    @@ -2092,45 +2157,6 @@ 

    Methods

    self.root.clear_cache()
    -
    -def exclude(self, *args, **kwargs) -
    -
    -
    -
    - -Expand source code - -
    def exclude(self, *args, **kwargs):
    -    return FolderCollection(account=self.account, folders=[self]).exclude(*args, **kwargs)
    -
    -
    -
    -def filter(self, *args, **kwargs) -
    -
    -
    -
    - -Expand source code - -
    def filter(self, *args, **kwargs):
    -    return FolderCollection(account=self.account, folders=[self]).filter(*args, **kwargs)
    -
    -
    -
    -def get(self, *args, **kwargs) -
    -
    -
    -
    - -Expand source code - -
    def get(self, *args, **kwargs):
    -    return FolderCollection(account=self.account, folders=[self]).get(*args, **kwargs)
    -
    -
    def get_events(self, subscription_id, watermark)
    @@ -2155,6 +2181,7 @@

    Methods

    This method doesn't need the current folder instance, but it makes sense to keep the method along the other sync methods. """ + from ..services import GetEvents svc = GetEvents(account=self.account) while True: notification = svc.get(subscription_id=subscription_id, watermark=watermark) @@ -2193,9 +2220,8 @@

    Methods

    This method doesn't need the current folder instance, but it makes sense to keep the method along the other sync methods. """ - # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed - request_timeout = connection_timeout*60 + 60 - svc = GetStreamingEvents(account=self.account, timeout=request_timeout) + from ..services import GetStreamingEvents + svc = GetStreamingEvents(account=self.account) subscription_ids = subscription_id_or_ids if is_iterable(subscription_id_or_ids, generators_allowed=True) \ else [subscription_id_or_ids] for i, notification in enumerate( @@ -2209,7 +2235,7 @@

    Methods

    -def get_user_configuration(self, name, properties='All') +def get_user_configuration(self, name, properties=None)
    @@ -2218,7 +2244,11 @@

    Methods

    Expand source code
    @require_id
    -def get_user_configuration(self, name, properties=ALL):
    +def get_user_configuration(self, name, properties=None):
    +    from ..services import GetUserConfiguration
    +    from ..services.get_user_configuration import ALL
    +    if properties is None:
    +        properties = ALL
         return GetUserConfiguration(account=self.account).get(
             user_configuration_name=UserConfigurationNameMNS(name=name, folder=self),
             properties=properties,
    @@ -2248,6 +2278,7 @@ 

    Methods

    Expand source code
    def move(self, to_folder):
    +    from ..services import MoveFolder
         res = MoveFolder(account=self.account).get(folders=[self], to_folder=to_folder)
         folder_id, changekey = res.id, res.changekey
         if self.id != folder_id:
    @@ -2258,19 +2289,6 @@ 

    Methods

    self.root.update_folder(self) # Update the folder in the cache
    -
    -def none(self) -
    -
    -
    -
    - -Expand source code - -
    def none(self):
    -    return FolderCollection(account=self.account, folders=[self]).none()
    -
    -
    def normalize_fields(self, fields)
    @@ -2293,8 +2311,6 @@

    Methods

    elif isinstance(field_path, Field): field_path = FieldPath(field=field_path) fields[i] = field_path - if not isinstance(field_path, FieldPath): - raise ValueError("Field %r must be a string or FieldPath instance" % field_path) if field_path.field.name == 'start': has_start = True elif field_path.field.name == 'end': @@ -2314,20 +2330,6 @@

    Methods

    return fields
    -
    -def people(self) -
    -
    -
    -
    - -Expand source code - -
    def people(self):
    -    # No point in using a FolderCollection because FindPeople only supports one folder
    -    return FolderCollection(account=self.account, folders=[self]).people()
    -
    -
    def pull_subscription(self, **kwargs)
    @@ -2386,6 +2388,7 @@

    Methods

    Expand source code
    def save(self, update_fields=None):
    +    from ..services import CreateFolder, UpdateFolder
         if self.id is None:
             # New folder
             if update_fields:
    @@ -2434,7 +2437,7 @@ 

    Methods

    -def subscribe_to_pull(self, event_types=('CopiedEvent', 'CreatedEvent', 'DeletedEvent', 'ModifiedEvent', 'MovedEvent', 'NewMailEvent', 'FreeBusyChangedEvent'), watermark=None, timeout=60) +def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60)

    Create a pull subscription.

    @@ -2448,7 +2451,7 @@

    Methods

    Expand source code
    @require_id
    -def subscribe_to_pull(self, event_types=SubscribeToPull.EVENT_TYPES, watermark=None, timeout=60):
    +def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60):
         """Create a pull subscription.
     
         :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPull.EVENT_TYPES
    @@ -2457,19 +2460,16 @@ 

    Methods

    GetEvents request for this subscription. :return: The subscription ID and a watermark """ - s_ids = list(FolderCollection(account=self.account, folders=[self]).subscribe_to_pull( + from ..services import SubscribeToPull + if event_types is None: + event_types = SubscribeToPull.EVENT_TYPES + return FolderCollection(account=self.account, folders=[self]).subscribe_to_pull( event_types=event_types, watermark=watermark, timeout=timeout, - )) - if len(s_ids) != 1: - raise ValueError('Expected result length 1, but got %s' % s_ids) - s_id = s_ids[0] - if isinstance(s_id, Exception): - raise s_id - return s_id
    + )
    -def subscribe_to_push(self, callback_url, event_types=('CopiedEvent', 'CreatedEvent', 'DeletedEvent', 'ModifiedEvent', 'MovedEvent', 'NewMailEvent', 'FreeBusyChangedEvent'), watermark=None, status_frequency=1) +def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1)

    Create a push subscription.

    @@ -2483,8 +2483,7 @@

    Methods

    Expand source code
    @require_id
    -def subscribe_to_push(self, callback_url, event_types=SubscribeToPush.EVENT_TYPES, watermark=None,
    -                      status_frequency=1):
    +def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1):
         """Create a push subscription.
     
         :param callback_url: A client-defined URL that the server will call
    @@ -2493,19 +2492,16 @@ 

    Methods

    :param status_frequency: The frequency, in minutes, that the callback URL will be called with. :return: The subscription ID and a watermark """ - s_ids = list(FolderCollection(account=self.account, folders=[self]).subscribe_to_push( + from ..services import SubscribeToPush + if event_types is None: + event_types = SubscribeToPush.EVENT_TYPES + return FolderCollection(account=self.account, folders=[self]).subscribe_to_push( event_types=event_types, watermark=watermark, status_frequency=status_frequency, callback_url=callback_url, - )) - if len(s_ids) != 1: - raise ValueError('Expected result length 1, but got %s' % s_ids) - s_id = s_ids[0] - if isinstance(s_id, Exception): - raise s_id - return s_id
    + )
    -def subscribe_to_streaming(self, event_types=('CopiedEvent', 'CreatedEvent', 'DeletedEvent', 'ModifiedEvent', 'MovedEvent', 'NewMailEvent', 'FreeBusyChangedEvent')) +def subscribe_to_streaming(self, event_types=None)

    Create a streaming subscription.

    @@ -2516,21 +2512,16 @@

    Methods

    Expand source code
    @require_id
    -def subscribe_to_streaming(self, event_types=SubscribeToPush.EVENT_TYPES):
    +def subscribe_to_streaming(self, event_types=None):
         """Create a streaming subscription.
     
         :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES
         :return: The subscription ID
         """
    -    s_ids = list(FolderCollection(account=self.account, folders=[self]).subscribe_to_streaming(
    -        event_types=event_types,
    -    ))
    -    if len(s_ids) != 1:
    -        raise ValueError('Expected result length 1, but got %s' % s_ids)
    -    s_id = s_ids[0]
    -    if isinstance(s_id, Exception):
    -        raise s_id
    -    return s_id
    + from ..services import SubscribeToStreaming + if event_types is None: + event_types = SubscribeToStreaming.EVENT_TYPES + return FolderCollection(account=self.account, folders=[self]).subscribe_to_streaming(event_types=event_types)
    @@ -2540,7 +2531,7 @@

    Methods

    Return all folder changes to a folder hierarchy, as a generator. If sync_state is specified, get all folder changes after this sync state. After fully consuming the generator, self.folder_sync_state will hold the new sync state.

    -

    :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderitems service. +

    :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderItems service. :param only_fields: A list of string or FieldPath items specifying the fields to fetch. Default to all fields :return:

    @@ -2552,7 +2543,7 @@

    Methods

    changes after this sync state. After fully consuming the generator, self.folder_sync_state will hold the new sync state. - :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderitems service. + :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderItems service. :param only_fields: A list of string or FieldPath items specifying the fields to fetch. Default to all fields :return: """ @@ -2574,12 +2565,12 @@

    Methods

    Return all item changes to a folder, as a generator. If sync_state is specified, get all item changes after this sync state. After fully consuming the generator, self.item_sync_state will hold the new sync state.

    -

    :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderitems service. +

    :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderItems service. :param only_fields: A list of string or FieldPath items specifying the fields to fetch. Default to all fields :param ignore: A list of Item IDs to ignore in the sync :param max_changes_returned: The max number of change :param sync_scope: Specify whether to return just items, or items and folder associated information. Possible -values are specified in SyncFolderitems.SYNC_SCOPES +values are specified in SyncFolderItems.SYNC_SCOPES :return: A generator of (change_type, item) tuples

    @@ -2589,12 +2580,12 @@

    Methods

    """Return all item changes to a folder, as a generator. If sync_state is specified, get all item changes after this sync state. After fully consuming the generator, self.item_sync_state will hold the new sync state. - :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderitems service. + :param sync_state: The state of the sync. Returned by a successful call to the SyncFolderItems service. :param only_fields: A list of string or FieldPath items specifying the fields to fetch. Default to all fields :param ignore: A list of Item IDs to ignore in the sync :param max_changes_returned: The max number of change :param sync_scope: Specify whether to return just items, or items and folder associated information. Possible - values are specified in SyncFolderitems.SYNC_SCOPES + values are specified in SyncFolderItems.SYNC_SCOPES :return: A generator of (change_type, item) tuples """ if not sync_state: @@ -2630,8 +2621,8 @@

    Methods

    return True
    -
    -def to_folder_id(self) +
    +def to_id(self)
    @@ -2639,7 +2630,7 @@

    Methods

    Expand source code -
    def to_folder_id(self):
    +
    def to_id(self):
         if self.is_distinguished:
             # Don't add the changekey here. When modifying folder content, we usually don't care if others have changed
             # the folder content since we fetched the changekey.
    @@ -2654,36 +2645,6 @@ 

    Methods

    raise ValueError('Must be a distinguished folder or have an ID')
    -
    -def to_id_xml(self, version) -
    -
    -
    -
    - -Expand source code - -
    def to_id_xml(self, version):
    -    # Folder(name='Foo') is a perfectly valid ID to e.g. create a folder
    -    return self.to_xml(version=version)
    -
    -
    -
    -def to_xml(self, version) -
    -
    -
    -
    - -Expand source code - -
    def to_xml(self, version):
    -    try:
    -        return self.to_folder_id().to_xml(version=version)
    -    except ValueError:
    -        return super().to_xml(version=version)
    -
    -
    def tree(self)
    @@ -2712,22 +2673,22 @@

    Methods

    ├── exchangelib issues └── Mom """ - tree = '%s\n' % self.name + tree = f'{self.name}\n' children = list(self.children) for i, c in enumerate(sorted(children, key=attrgetter('name')), start=1): nodes = c.tree().split('\n') for j, node in enumerate(nodes, start=1): if i != len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += '├── %s\n' % node + tree += f'├── {node}\n' elif i != len(children) and j > 1: # Not the last child, and not name of child - tree += '│ %s\n' % node + tree += f'│ {node}\n' elif i == len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += '└── %s\n' % node + tree += f'└── {node}\n' else: # Last child, and not name of child - tree += ' %s\n' % node + tree += f' {node}\n' return tree.strip()
    @@ -2753,6 +2714,7 @@

    Methods

    This method doesn't need the current folder instance, but it makes sense to keep the method along the other sync methods. """ + from ..services import Unsubscribe return Unsubscribe(account=self.account).get(subscription_id=subscription_id)
    @@ -2767,6 +2729,7 @@

    Methods

    @require_id
     def update_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None):
    +    from ..services import UpdateUserConfiguration
         user_configuration = UserConfiguration(
             user_configuration_name=UserConfigurationName(name=name, folder=self),
             dictionary=dictionary,
    @@ -2786,18 +2749,7 @@ 

    Methods

    Expand source code
    def validate_item_field(self, field, version):
    -    # Takes a fieldname, Field or FieldPath object pointing to an item field, and checks that it is valid
    -    # for the item types supported by this folder.
    -
    -    # For each field, check if the field is valid for any of the item models supported by this folder
    -    for item_model in self.supported_item_models:
    -        try:
    -            item_model.validate_field(field=field, version=version)
    -            break
    -        except InvalidField:
    -            continue
    -    else:
    -        raise InvalidField("%r is not a valid field on %s" % (field, self.supported_item_models))
    + FolderCollection(account=self.account, folders=[self]).validate_item_field(field=field, version=version)
    @@ -2814,7 +2766,7 @@

    Methods

    -def wipe(self, page_size=None) +def wipe(self, page_size=None, chunk_size=None)
    @@ -2822,14 +2774,14 @@

    Methods

    Expand source code -
    def wipe(self, page_size=None, _seen=None, _level=0):
    +
    def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0):
         # Recursively deletes all items in this folder, and all subfolders and their content. Attempts to protect
         # distinguished folders from being deleted. Use with caution!
         _seen = _seen or set()
         if self.id in _seen:
    -        raise RecursionError('We already tried to wipe %s' % self)
    +        raise RecursionError(f'We already tried to wipe {self}')
         if _level > 16:
    -        raise RecursionError('Max recursion level reached: %s' % _level)
    +        raise RecursionError(f'Max recursion level reached: {_level}')
         _seen.add(self.id)
         log.warning('Wiping %s', self)
         has_distinguished_subfolders = any(f.is_distinguished for f in self.children)
    @@ -2845,13 +2797,18 @@ 

    Methods

    self.empty(delete_sub_folders=False) except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): log.warning('Not allowed to empty %s. Trying to delete items instead', self) + kwargs = {} + if page_size is not None: + kwargs['page_size'] = page_size + if chunk_size is not None: + kwargs['chunk_size'] = chunk_size try: - self.all().delete(**dict(page_size=page_size) if page_size else {}) + self.all().delete(**kwargs) except (ErrorAccessDenied, ErrorCannotDeleteObject, ErrorItemNotFound): log.warning('Not allowed to delete items in %s', self) _level += 1 for f in self.children: - f.wipe(page_size=page_size, _seen=_seen, _level=_level) + f.wipe(page_size=page_size, chunk_size=chunk_size, _seen=_seen, _level=_level) # Remove non-distinguished children that are empty and have no subfolders if f.is_deletable and not f.children: log.warning('Deleting folder %s', f) @@ -2874,6 +2831,16 @@

    Inherited members

  • validate_field
  • +
  • SearchableMixIn: + +
  • @@ -2957,17 +2924,26 @@

    Inherited members

  • Folder:
  • -

    A base class to use until we have a more specific folder implementation for this folder.

    +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code @@ -3224,17 +3227,26 @@

    Inherited members

  • WellknownFolder:
  • Instance variables

    -
    var account
    -
    -
    -
    - -Expand source code - -
    @property
    -def account(self):
    -    if self.root is None:
    -        return None
    -    return self.root.account
    -
    -
    var effective_rights
    -
    var parent
    -
    -
    -
    - -Expand source code - -
    @property
    -def parent(self):
    -    if not self.parent_folder_id:
    -        return None
    -    if self.parent_folder_id.id == self.id:
    -        # Some folders have a parent that references itself. Avoid circular references here
    -        return None
    -    return self.root.get_folder(self.parent_folder_id)
    -
    -
    var permission_set
    -
    var root
    -
    -
    -
    - -Expand source code - -
    @property
    -def root(self):
    -    return self._root
    -
    -

    Methods

    @@ -4454,7 +4522,7 @@

    Methods

    from .roots import RootOfHierarchy super().clean(version=version) if self.root and not isinstance(self.root, RootOfHierarchy): - raise ValueError("'root' %r must be a RootOfHierarchy instance" % self.root)
    + raise InvalidTypeError('root', self.root, RootOfHierarchy)
    @@ -4463,16 +4531,25 @@

    Inherited members

  • BaseFolder:
  • @@ -225,7 +223,7 @@

    Classes

    def __init__(self, folder_collection): from .collections import FolderCollection if not isinstance(folder_collection, FolderCollection): - raise ValueError("'folder_collection' %r must be a FolderCollection instance" % folder_collection) + raise InvalidTypeError('folder_collection', folder_collection, FolderCollection) self.folder_collection = folder_collection self.q = Q() # Default to no restrictions self.only_fields = None @@ -255,7 +253,7 @@

    Classes

    only_fields.append(field_path) break else: - raise InvalidField("Unknown field %r on folders %s" % (arg, self.folder_collection.folders)) + raise InvalidField(f"Unknown field {arg!r} on folders {self.folder_collection.folders}") new_qs = self._copy_self() new_qs.only_fields = only_fields return new_qs @@ -285,7 +283,7 @@

    Classes

    if not folders: raise DoesNotExist('Could not find a child folder matching the query') if len(folders) != 1: - raise MultipleObjectsReturned('Expected result length 1, but got %s' % folders) + raise MultipleObjectsReturned(f'Expected result length 1, but got {folders}') f = folders[0] if isinstance(f, Exception): raise f @@ -327,6 +325,9 @@

    Classes

    # Fetch all properties for the found folders resolveable_folders = [] for f in folders: + if isinstance(f, Exception): + yield f + continue if not f.get_folder_allowed: log.debug('GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder', f) yield f @@ -338,15 +339,15 @@

    Classes

    account=self.folder_collection.account, folders=resolveable_folders ).get_folders(additional_fields=complex_fields) for f, complex_f in zip(resolveable_folders, complex_folders): - if isinstance(f, Exception): - yield f + if isinstance(f, MISSING_FOLDER_ERRORS): + # We were unlucky. The folder disappeared between the FindFolder and the GetFolder calls continue if isinstance(complex_f, Exception): yield complex_f continue # Add the extra field values to the folders we fetched with find_folders() if f.__class__ != complex_f.__class__: - raise ValueError('Type mismatch: %s vs %s' % (f, complex_f)) + raise ValueError(f'Type mismatch: {f} vs {complex_f}') for complex_field in complex_fields: field_name = complex_field.field.name setattr(f, field_name, getattr(complex_f, field_name)) @@ -436,7 +437,7 @@

    Methods

    if not folders: raise DoesNotExist('Could not find a child folder matching the query') if len(folders) != 1: - raise MultipleObjectsReturned('Expected result length 1, but got %s' % folders) + raise MultipleObjectsReturned(f'Expected result length 1, but got {folders}') f = folders[0] if isinstance(f, Exception): raise f @@ -465,7 +466,7 @@

    Methods

    only_fields.append(field_path) break else: - raise InvalidField("Unknown field %r on folders %s" % (arg, self.folder_collection.folders)) + raise InvalidField(f"Unknown field {arg!r} on folders {self.folder_collection.folders}") new_qs = self._copy_self() new_qs.only_fields = only_fields return new_qs
    @@ -495,15 +496,7 @@

    Methods

    return self.__class__(account=self.folder_collection.account, folder=self.folder_collection.folders[0]) def resolve(self): - folders = list(self.folder_collection.resolve()) - if not folders: - raise DoesNotExist('Could not find a folder matching the query') - if len(folders) != 1: - raise MultipleObjectsReturned('Expected result length 1, but got %s' % folders) - f = folders[0] - if isinstance(f, Exception): - raise f - return f
    + return list(self.folder_collection.resolve())[0]

    Ancestors

      @@ -521,15 +514,7 @@

      Methods

      Expand source code
      def resolve(self):
      -    folders = list(self.folder_collection.resolve())
      -    if not folders:
      -        raise DoesNotExist('Could not find a folder matching the query')
      -    if len(folders) != 1:
      -        raise MultipleObjectsReturned('Expected result length 1, but got %s' % folders)
      -    f = folders[0]
      -    if isinstance(f, Exception):
      -        raise f
      -    return f
      + return list(self.folder_collection.resolve())[0] diff --git a/docs/exchangelib/folders/roots.html b/docs/exchangelib/folders/roots.html index c4d65a7f..90c38f46 100644 --- a/docs/exchangelib/folders/roots.html +++ b/docs/exchangelib/folders/roots.html @@ -29,11 +29,11 @@

      Module exchangelib.folders.roots

      import logging
       from threading import Lock
       
      -from .base import BaseFolder, MISSING_FOLDER_ERRORS
      +from .base import BaseFolder
       from .collections import FolderCollection
       from .known_folders import MsgFolderRoot, NON_DELETABLE_FOLDERS, WELLKNOWN_FOLDERS_IN_ROOT, \
           WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT
      -from .queryset import SingleFolderQuerySet, SHALLOW
      +from .queryset import SingleFolderQuerySet, SHALLOW, MISSING_FOLDER_ERRORS
       from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorInvalidOperation
       from ..fields import EffectiveRightsField
       from ..properties import EWSMeta
      @@ -94,7 +94,7 @@ 

      Module exchangelib.folders.roots

      def get_folder(self, folder): if not folder.id: raise ValueError("'folder' must have an ID") - return self._folders_map.get(folder.id, None) + return self._folders_map.get(folder.id) def add_folder(self, folder): if not folder.id: @@ -132,21 +132,21 @@

      Module exchangelib.folders.roots

      :param account: """ if not cls.DISTINGUISHED_FOLDER_ID: - raise ValueError('Class %s must have a DISTINGUISHED_FOLDER_ID value' % cls) + raise ValueError(f'Class {cls} must have a DISTINGUISHED_FOLDER_ID value') try: return cls.resolve( account=account, folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound('Could not find distinguished folder %s' % cls.DISTINGUISHED_FOLDER_ID) + raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}') def get_default_folder(self, folder_cls): """Return the distinguished folder instance of type folder_cls belonging to this account. If no distinguished folder was found, try as best we can to return the default folder of type 'folder_cls' """ if not folder_cls.DISTINGUISHED_FOLDER_ID: - raise ValueError("'folder_cls' %s must have a DISTINGUISHED_FOLDER_ID value" % folder_cls) + raise ValueError(f"'folder_cls' {folder_cls} must have a DISTINGUISHED_FOLDER_ID value") # Use cached distinguished folder instance, but only if cache has already been prepped. This is an optimization # for accessing e.g. 'account.contacts' without fetching all folders of the account. if self._subfolders is not None: @@ -167,7 +167,7 @@

      Module exchangelib.folders.roots

      except MISSING_FOLDER_ERRORS: # The Exchange server does not return a distinguished folder of this type pass - raise ErrorFolderNotFound('No usable default %s folders' % folder_cls) + raise ErrorFolderNotFound(f'No usable default {folder_cls} folders') @property def _folders_map(self): @@ -204,6 +204,9 @@

      Module exchangelib.folders.roots

      if isinstance(f, ErrorAccessDenied): # We may not have FindFolder access, or GetFolder access, either to this folder or at all continue + if isinstance(f, MISSING_FOLDER_ERRORS): + # We were unlucky. The folder disappeared between the FindFolder and the GetFolder calls + continue if isinstance(f, Exception): raise f if f.id in folders_map: @@ -259,47 +262,46 @@

      Module exchangelib.folders.roots

      # Try to pick a suitable default folder. we do this by: # 1. Searching the full folder list for a folder with the distinguished folder name # 2. Searching TOIS for a direct child folder of the same type that is marked as distinguished - # 3. Searching TOIS for a direct child folder of the same type that is has a localized name + # 3. Searching TOIS for a direct child folder of the same type that has a localized name # 4. Searching root for a direct child folder of the same type that is marked as distinguished - # 5. Searching root for a direct child folder of the same type that is has a localized name + # 5. Searching root for a direct child folder of the same type that has a localized name log.debug('Searching default %s folder in full folder list', folder_cls) for f in self._folders_map.values(): - # Require exact class to not match e.g. RecipientCache instead of Contacts + # Require exact type, to avoid matching with subclasses (e.g. RecipientCache and Contacts) if f.__class__ == folder_cls and f.has_distinguished_name: log.debug('Found cached %s folder with default distinguished name', folder_cls) return f - # Try direct children of TOIS first, unless we're trying to get the TOIS folder. TOIS might not exist. + # Try direct children of TOIS first, unless we're trying to get the TOIS folder if folder_cls != MsgFolderRoot: try: return self._get_candidate(folder_cls=folder_cls, folder_coll=self.tois.children) except MISSING_FOLDER_ERRORS: - # No candidates, or TOIS does not exist, or we don't have access + # No candidates, or TOIS does not exist, or we don't have access to TOIS pass - # No candidates in TOIS. Try direct children of root. + # Finally, try direct children of root return self._get_candidate(folder_cls=folder_cls, folder_coll=self.children) def _get_candidate(self, folder_cls, folder_coll): - # Get a single the folder of the same type in folder_coll + # Look for a single useful folder of type folder_cls in folder_coll same_type = [f for f in folder_coll if f.__class__ == folder_cls] are_distinguished = [f for f in same_type if f.is_distinguished] if are_distinguished: candidates = are_distinguished else: candidates = [f for f in same_type if f.name.lower() in folder_cls.localized_names(self.account.locale)] - if candidates: - if len(candidates) > 1: - raise ValueError( - 'Multiple possible default %s folders: %s' % (folder_cls, [f.name for f in candidates]) - ) - if candidates[0].is_distinguished: - log.debug('Found cached distinguished %s folder', folder_cls) - else: - log.debug('Found cached %s folder with localized name', folder_cls) - return candidates[0] - raise ErrorFolderNotFound('No usable default %s folders' % folder_cls) + if not candidates: + raise ErrorFolderNotFound(f'No usable default {folder_cls} folders') + if len(candidates) > 1: + raise ValueError(f'Multiple possible default {folder_cls} folders: {[f.name for f in candidates]}') + candidate = candidates[0] + if candidate.is_distinguished: + log.debug('Found distinguished %s folder', folder_cls) + else: + log.debug('Found %s folder with localized name %s', folder_cls, candidate.name) + return candidate class PublicFoldersRoot(RootOfHierarchy): @@ -329,6 +331,9 @@

      Module exchangelib.folders.roots

      for f in SingleFolderQuerySet(account=self.account, folder=folder).depth( self.DEFAULT_FOLDER_TRAVERSAL_DEPTH ).all(): + if isinstance(f, MISSING_FOLDER_ERRORS): + # We were unlucky. The folder disappeared between the FindFolder and the GetFolder calls + continue if isinstance(f, Exception): raise f children_map[f.id] = f @@ -407,19 +412,28 @@

      Inherited members

    • RootOfHierarchy:
      • ID_ELEMENT_CLS
      • +
      • account
      • add_field
      • +
      • all
      • deregister
      • +
      • exclude
      • +
      • filter
      • folder_cls_from_container_class
      • folder_cls_from_folder_name
      • folder_sync_state
      • +
      • get
      • get_default_folder
      • get_distinguished
      • get_events
      • get_streaming_events
      • is_distinguished
      • item_sync_state
      • +
      • none
      • +
      • parent
      • +
      • people
      • register
      • remove_field
      • +
      • root
      • subscribe_to_pull
      • subscribe_to_push
      • subscribe_to_streaming
      • @@ -471,6 +485,9 @@

        Inherited members

        for f in SingleFolderQuerySet(account=self.account, folder=folder).depth( self.DEFAULT_FOLDER_TRAVERSAL_DEPTH ).all(): + if isinstance(f, MISSING_FOLDER_ERRORS): + # We were unlucky. The folder disappeared between the FindFolder and the GetFolder calls + continue if isinstance(f, Exception): raise f children_map[f.id] = f @@ -540,6 +557,9 @@

        Methods

        for f in SingleFolderQuerySet(account=self.account, folder=folder).depth( self.DEFAULT_FOLDER_TRAVERSAL_DEPTH ).all(): + if isinstance(f, MISSING_FOLDER_ERRORS): + # We were unlucky. The folder disappeared between the FindFolder and the GetFolder calls + continue if isinstance(f, Exception): raise f children_map[f.id] = f @@ -561,19 +581,28 @@

        Inherited members

      • RootOfHierarchy:
        • ID_ELEMENT_CLS
        • +
        • account
        • add_field
        • +
        • all
        • deregister
        • +
        • exclude
        • +
        • filter
        • folder_cls_from_container_class
        • folder_cls_from_folder_name
        • folder_sync_state
        • +
        • get
        • get_default_folder
        • get_distinguished
        • get_events
        • get_streaming_events
        • is_distinguished
        • item_sync_state
        • +
        • none
        • +
        • parent
        • +
        • people
        • register
        • remove_field
        • +
        • root
        • subscribe_to_pull
        • subscribe_to_push
        • subscribe_to_streaming
        • @@ -619,47 +648,46 @@

          Inherited members

          # Try to pick a suitable default folder. we do this by: # 1. Searching the full folder list for a folder with the distinguished folder name # 2. Searching TOIS for a direct child folder of the same type that is marked as distinguished - # 3. Searching TOIS for a direct child folder of the same type that is has a localized name + # 3. Searching TOIS for a direct child folder of the same type that has a localized name # 4. Searching root for a direct child folder of the same type that is marked as distinguished - # 5. Searching root for a direct child folder of the same type that is has a localized name + # 5. Searching root for a direct child folder of the same type that has a localized name log.debug('Searching default %s folder in full folder list', folder_cls) for f in self._folders_map.values(): - # Require exact class to not match e.g. RecipientCache instead of Contacts + # Require exact type, to avoid matching with subclasses (e.g. RecipientCache and Contacts) if f.__class__ == folder_cls and f.has_distinguished_name: log.debug('Found cached %s folder with default distinguished name', folder_cls) return f - # Try direct children of TOIS first, unless we're trying to get the TOIS folder. TOIS might not exist. + # Try direct children of TOIS first, unless we're trying to get the TOIS folder if folder_cls != MsgFolderRoot: try: return self._get_candidate(folder_cls=folder_cls, folder_coll=self.tois.children) except MISSING_FOLDER_ERRORS: - # No candidates, or TOIS does not exist, or we don't have access + # No candidates, or TOIS does not exist, or we don't have access to TOIS pass - # No candidates in TOIS. Try direct children of root. + # Finally, try direct children of root return self._get_candidate(folder_cls=folder_cls, folder_coll=self.children) def _get_candidate(self, folder_cls, folder_coll): - # Get a single the folder of the same type in folder_coll + # Look for a single useful folder of type folder_cls in folder_coll same_type = [f for f in folder_coll if f.__class__ == folder_cls] are_distinguished = [f for f in same_type if f.is_distinguished] if are_distinguished: candidates = are_distinguished else: candidates = [f for f in same_type if f.name.lower() in folder_cls.localized_names(self.account.locale)] - if candidates: - if len(candidates) > 1: - raise ValueError( - 'Multiple possible default %s folders: %s' % (folder_cls, [f.name for f in candidates]) - ) - if candidates[0].is_distinguished: - log.debug('Found cached distinguished %s folder', folder_cls) - else: - log.debug('Found cached %s folder with localized name', folder_cls) - return candidates[0] - raise ErrorFolderNotFound('No usable default %s folders' % folder_cls)
    • + if not candidates: + raise ErrorFolderNotFound(f'No usable default {folder_cls} folders') + if len(candidates) > 1: + raise ValueError(f'Multiple possible default {folder_cls} folders: {[f.name for f in candidates]}') + candidate = candidates[0] + if candidate.is_distinguished: + log.debug('Found distinguished %s folder', folder_cls) + else: + log.debug('Found %s folder with localized name %s', folder_cls, candidate.name) + return candidate

      Ancestors

        @@ -703,19 +731,28 @@

        Inherited members

      • RootOfHierarchy:
        • ID_ELEMENT_CLS
        • +
        • account
        • add_field
        • +
        • all
        • deregister
        • +
        • exclude
        • +
        • filter
        • folder_cls_from_container_class
        • folder_cls_from_folder_name
        • folder_sync_state
        • +
        • get
        • get_default_folder
        • get_distinguished
        • get_events
        • get_streaming_events
        • is_distinguished
        • item_sync_state
        • +
        • none
        • +
        • parent
        • +
        • people
        • register
        • remove_field
        • +
        • root
        • subscribe_to_pull
        • subscribe_to_push
        • subscribe_to_streaming
        • @@ -792,7 +829,7 @@

          Inherited members

          def get_folder(self, folder): if not folder.id: raise ValueError("'folder' must have an ID") - return self._folders_map.get(folder.id, None) + return self._folders_map.get(folder.id) def add_folder(self, folder): if not folder.id: @@ -830,21 +867,21 @@

          Inherited members

          :param account: """ if not cls.DISTINGUISHED_FOLDER_ID: - raise ValueError('Class %s must have a DISTINGUISHED_FOLDER_ID value' % cls) + raise ValueError(f'Class {cls} must have a DISTINGUISHED_FOLDER_ID value') try: return cls.resolve( account=account, folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound('Could not find distinguished folder %s' % cls.DISTINGUISHED_FOLDER_ID) + raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}') def get_default_folder(self, folder_cls): """Return the distinguished folder instance of type folder_cls belonging to this account. If no distinguished folder was found, try as best we can to return the default folder of type 'folder_cls' """ if not folder_cls.DISTINGUISHED_FOLDER_ID: - raise ValueError("'folder_cls' %s must have a DISTINGUISHED_FOLDER_ID value" % folder_cls) + raise ValueError(f"'folder_cls' {folder_cls} must have a DISTINGUISHED_FOLDER_ID value") # Use cached distinguished folder instance, but only if cache has already been prepped. This is an optimization # for accessing e.g. 'account.contacts' without fetching all folders of the account. if self._subfolders is not None: @@ -865,7 +902,7 @@

          Inherited members

          except MISSING_FOLDER_ERRORS: # The Exchange server does not return a distinguished folder of this type pass - raise ErrorFolderNotFound('No usable default %s folders' % folder_cls) + raise ErrorFolderNotFound(f'No usable default {folder_cls} folders') @property def _folders_map(self): @@ -902,6 +939,9 @@

          Inherited members

          if isinstance(f, ErrorAccessDenied): # We may not have FindFolder access, or GetFolder access, either to this folder or at all continue + if isinstance(f, MISSING_FOLDER_ERRORS): + # We were unlucky. The folder disappeared between the FindFolder and the GetFolder calls + continue if isinstance(f, Exception): raise f if f.id in folders_map: @@ -1019,59 +1059,23 @@

          Static methods

          :param account: """ if not cls.DISTINGUISHED_FOLDER_ID: - raise ValueError('Class %s must have a DISTINGUISHED_FOLDER_ID value' % cls) + raise ValueError(f'Class {cls} must have a DISTINGUISHED_FOLDER_ID value') try: return cls.resolve( account=account, folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound('Could not find distinguished folder %s' % cls.DISTINGUISHED_FOLDER_ID) + raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}')

          Instance variables

          -
          var account
          -
          -
          -
          - -Expand source code - -
          @property
          -def account(self):
          -    return self._account
          -
          -
          var effective_rights
          -
          var parent
          -
          -
          -
          - -Expand source code - -
          @property
          -def parent(self):
          -    return None
          -
          -
          -
          var root
          -
          -
          -
          - -Expand source code - -
          @property
          -def root(self):
          -    return self
          -
          -

          Methods

          @@ -1136,7 +1140,7 @@

          Methods

          folder was found, try as best we can to return the default folder of type 'folder_cls' """ if not folder_cls.DISTINGUISHED_FOLDER_ID: - raise ValueError("'folder_cls' %s must have a DISTINGUISHED_FOLDER_ID value" % folder_cls) + raise ValueError(f"'folder_cls' {folder_cls} must have a DISTINGUISHED_FOLDER_ID value") # Use cached distinguished folder instance, but only if cache has already been prepped. This is an optimization # for accessing e.g. 'account.contacts' without fetching all folders of the account. if self._subfolders is not None: @@ -1157,7 +1161,7 @@

          Methods

          except MISSING_FOLDER_ERRORS: # The Exchange server does not return a distinguished folder of this type pass - raise ErrorFolderNotFound('No usable default %s folders' % folder_cls) + raise ErrorFolderNotFound(f'No usable default {folder_cls} folders')
          @@ -1172,7 +1176,7 @@

          Methods

          def get_folder(self, folder):
               if not folder.id:
                   raise ValueError("'folder' must have an ID")
          -    return self._folders_map.get(folder.id, None)
          + return self._folders_map.get(folder.id)
          @@ -1214,16 +1218,25 @@

          Inherited members

        • BaseFolder:
        • diff --git a/docs/exchangelib/index.html b/docs/exchangelib/index.html index 2644e86d..ed261426 100644 --- a/docs/exchangelib/index.html +++ b/docs/exchangelib/index.html @@ -26,9 +26,7 @@

          Package exchangelib

          Expand source code -
          import sys
          -
          -from .account import Account, Identity
          +
          from .account import Account, Identity
           from .attachments import FileAttachment, ItemAttachment
           from .autodiscover import discover
           from .configuration import Configuration
          @@ -46,7 +44,7 @@ 

          Package exchangelib

          from .transport import BASIC, DIGEST, NTLM, GSSAPI, SSPI, OAUTH2, CBA from .version import Build, Version -__version__ = '4.6.2' +__version__ = '4.7.0' __all__ = [ '__version__', @@ -66,16 +64,12 @@

          Package exchangelib

          'Q', 'BASIC', 'DIGEST', 'NTLM', 'GSSAPI', 'SSPI', 'OAUTH2', 'CBA', 'Build', 'Version', + 'close_connections', ] # Set a default user agent, e.g. "exchangelib/3.1.1 (python-requests/2.22.0)" import requests.utils -BaseProtocol.USERAGENT = "%s/%s (%s)" % (__name__, __version__, requests.utils.default_user_agent()) - -# Support fromisoformat() in Python < 3.7 -if sys.version_info[:2] < (3, 7): - from backports.datetime_fromisoformat import MonkeyPatch - MonkeyPatch.patch_fromisoformat() +BaseProtocol.USERAGENT = f"{__name__}/{__version__} ({requests.utils.default_user_agent()})" def close_connections(): @@ -179,8 +173,7 @@

          Sub-modules

          exchangelib.winzone
          -

          A dict to translate from IANA location name to Windows timezone name. Translations taken from -…

          +

          A dict to translate from IANA location name to Windows timezone name. Translations taken from CLDR_WINZONE_URL

    @@ -201,6 +194,22 @@

    Functions

    UTC_NOW = lambda: EWSDateTime.now(tz=UTC)  # noqa: E731
    +
    +def close_connections() +
    +
    +
    +
    + +Expand source code + +
    def close_connections():
    +    from .autodiscover import close_connections as close_autodiscover_connections
    +    from .protocol import close_connections as close_protocol_connections
    +    close_autodiscover_connections()
    +    close_protocol_connections()
    +
    +
    def discover(email, credentials=None, auth_type=None, retry_policy=None)
    @@ -211,9 +220,12 @@

    Functions

    Expand source code
    def discover(email, credentials=None, auth_type=None, retry_policy=None):
    -    return Autodiscovery(
    -        email=email, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy
    -    ).discover()
    + ad_response, protocol = Autodiscovery( + email=email, credentials=credentials + ).discover() + protocol.config.auth_typ = auth_type + protocol.config.retry_policy = retry_policy + return ad_response, protocol
    @@ -318,25 +330,26 @@

    Inherited members

    :return: """ if '@' not in primary_smtp_address: - raise ValueError("primary_smtp_address %r is not an email address" % primary_smtp_address) + raise ValueError(f"primary_smtp_address {primary_smtp_address!r} is not an email address") self.fullname = fullname # Assume delegate access if individual credentials are provided. Else, assume service user with impersonation self.access_type = access_type or (DELEGATE if credentials else IMPERSONATION) if self.access_type not in ACCESS_TYPES: - raise ValueError("'access_type' %r must be one of %s" % (self.access_type, ACCESS_TYPES)) + raise InvalidEnumValue('access_type', self.access_type, ACCESS_TYPES) try: - self.locale = locale or getlocale()[0] or None # get_locale() might not be able to determine the locale + # get_locale() might not be able to determine the locale + self.locale = locale or stdlib_locale.getlocale()[0] or None except ValueError as e: # getlocale() may throw ValueError if it fails to parse the system locale log.warning('Failed to get locale (%s)', e) self.locale = None if not isinstance(self.locale, (type(None), str)): - raise ValueError("Expected 'locale' to be a string, got %r" % self.locale) + raise InvalidTypeError('locale', self.locale, str) if default_timezone: try: self.default_timezone = EWSTimeZone.from_timezone(default_timezone) except TypeError: - raise ValueError("Expected 'default_timezone' to be an EWSTimeZone, got %r" % default_timezone) + raise InvalidTypeError('default_timezone', default_timezone, EWSTimeZone) else: try: self.default_timezone = EWSTimeZone.localzone() @@ -346,17 +359,22 @@

    Inherited members

    log.warning('%s. Fallback to UTC', e.args[0]) self.default_timezone = UTC if not isinstance(config, (Configuration, type(None))): - raise ValueError("Expected 'config' to be a Configuration, got %r" % config) + raise InvalidTypeError('config', config, Configuration) if autodiscover: if config: - retry_policy, auth_type = config.retry_policy, config.auth_type + auth_type, retry_policy, version = config.auth_type, config.retry_policy, config.version if not credentials: credentials = config.credentials else: - retry_policy, auth_type = None, None + auth_type, retry_policy, version = None, None, None self.ad_response, self.protocol = Autodiscovery( - email=primary_smtp_address, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy + email=primary_smtp_address, credentials=credentials ).discover() + # Let's not use the auth_package hint from the AD response. It could be incorrect and we can just guess. + self.protocol.config.auth_type = auth_type + self.protocol.config.retry_policy = retry_policy + if not self.protocol.config.version: + self.protocol.config.version = version primary_smtp_address = self.ad_response.autodiscover_smtp_address else: if not config: @@ -371,8 +389,9 @@

    Inherited members

    self.affinity_cookie = None # We may need to override the default server version on a per-account basis because Microsoft may report one - # server version up-front but delegate account requests to an older backend server. - self.version = self.protocol.version + # server version up-front but delegate account requests to an older backend server. Create a new instance to + # avoid changing the protocol version. + self.version = self.protocol.version.copy() log.debug('Added account: %s', self) @property @@ -690,7 +709,7 @@

    Inherited members

    ))) def bulk_delete(self, ids, delete_type=HARD_DELETE, send_meeting_cancellations=SEND_TO_NONE, - affected_task_occurrences=ALL_OCCURRENCIES, suppress_read_receipts=True, chunk_size=None): + affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True, chunk_size=None): """Bulk delete items. :param ids: an iterable of either (id, changekey) tuples or Item objects. @@ -699,14 +718,14 @@

    Inherited members

    :param send_meeting_cancellations: only applicable to CalendarItem. Possible values are specified in SEND_MEETING_CANCELLATIONS_CHOICES. (Default value = SEND_TO_NONE) :param affected_task_occurrences: only applicable for recurring Task items. Possible values are specified in - AFFECTED_TASK_OCCURRENCES_CHOICES. (Default value = ALL_OCCURRENCIES) + AFFECTED_TASK_OCCURRENCES_CHOICES. (Default value = ALL_OCCURRENCES) :param suppress_read_receipts: only supported from Exchange 2013. True or False. (Default value = True) :param chunk_size: The number of items to send to the server in a single request (Default value = None) :return: a list of either True or exception instances, in the same order as the input """ log.debug( - 'Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurences: %s)', + 'Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurrences: %s)', self, delete_type, send_meeting_cancellations, @@ -844,15 +863,11 @@

    Inherited members

    # We accept generators, so it's not always convenient for caller to know up-front if 'ids' is empty. Allow # empty 'ids' and return early. return - # GetPersona only accepts one persona ID per request. Crazy. - svc = GetPersona(account=self) - for i in ids: - yield svc.call(persona=i) + yield from GetPersona(account=self).call(personas=ids) @property def mail_tips(self): """See self.oof_settings about caching considerations.""" - # mail_tips_requested must be one of properties.MAIL_TIPS_TYPES return GetMailTips(protocol=self.protocol).get( sending_as=SendingAs(email_address=self.primary_smtp_address), recipients=[Mailbox(email_address=self.primary_smtp_address)], @@ -862,18 +877,12 @@

    Inherited members

    @property def delegates(self): """Return a list of DelegateUser objects representing the delegates that are set on this account.""" - delegates = [] - for d in GetDelegate(account=self).call(user_ids=None, include_permissions=True): - if isinstance(d, Exception): - raise d - delegates.append(d) - return delegates + return list(GetDelegate(account=self).call(user_ids=None, include_permissions=True)) def __str__(self): - txt = '%s' % self.primary_smtp_address if self.fullname: - txt += ' (%s)' % self.fullname - return txt
    + return f'{self.primary_smtp_address} ({self.fullname})' + return self.primary_smtp_address

    Instance variables

    @@ -1186,12 +1195,7 @@

    Instance variables

    @property
     def delegates(self):
         """Return a list of DelegateUser objects representing the delegates that are set on this account."""
    -    delegates = []
    -    for d in GetDelegate(account=self).call(user_ids=None, include_permissions=True):
    -        if isinstance(d, Exception):
    -            raise d
    -        delegates.append(d)
    -    return delegates
    + return list(GetDelegate(account=self).call(user_ids=None, include_permissions=True))
    var directory
    @@ -1400,7 +1404,6 @@

    Instance variables

    @property
     def mail_tips(self):
         """See self.oof_settings about caching considerations."""
    -    # mail_tips_requested must be one of properties.MAIL_TIPS_TYPES
         return GetMailTips(protocol=self.protocol).get(
             sending_as=SendingAs(email_address=self.primary_smtp_address),
             recipients=[Mailbox(email_address=self.primary_smtp_address)],
    @@ -2044,7 +2047,7 @@ 

    Methods

    :param send_meeting_cancellations: only applicable to CalendarItem. Possible values are specified in SEND_MEETING_CANCELLATIONS_CHOICES. (Default value = SEND_TO_NONE) :param affected_task_occurrences: only applicable for recurring Task items. Possible values are specified in -AFFECTED_TASK_OCCURRENCES_CHOICES. (Default value = ALL_OCCURRENCIES) +AFFECTED_TASK_OCCURRENCES_CHOICES. (Default value = ALL_OCCURRENCES) :param suppress_read_receipts: only supported from Exchange 2013. True or False. (Default value = True) :param chunk_size: The number of items to send to the server in a single request (Default value = None)

    :return: a list of either True or exception instances, in the same order as the input

    @@ -2053,7 +2056,7 @@

    Methods

    Expand source code
    def bulk_delete(self, ids, delete_type=HARD_DELETE, send_meeting_cancellations=SEND_TO_NONE,
    -                affected_task_occurrences=ALL_OCCURRENCIES, suppress_read_receipts=True, chunk_size=None):
    +                affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True, chunk_size=None):
         """Bulk delete items.
     
         :param ids: an iterable of either (id, changekey) tuples or Item objects.
    @@ -2062,14 +2065,14 @@ 

    Methods

    :param send_meeting_cancellations: only applicable to CalendarItem. Possible values are specified in SEND_MEETING_CANCELLATIONS_CHOICES. (Default value = SEND_TO_NONE) :param affected_task_occurrences: only applicable for recurring Task items. Possible values are specified in - AFFECTED_TASK_OCCURRENCES_CHOICES. (Default value = ALL_OCCURRENCIES) + AFFECTED_TASK_OCCURRENCES_CHOICES. (Default value = ALL_OCCURRENCES) :param suppress_read_receipts: only supported from Exchange 2013. True or False. (Default value = True) :param chunk_size: The number of items to send to the server in a single request (Default value = None) :return: a list of either True or exception instances, in the same order as the input """ log.debug( - 'Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurences: %s)', + 'Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurrences: %s)', self, delete_type, send_meeting_cancellations, @@ -2335,10 +2338,7 @@

    Methods

    # We accept generators, so it's not always convenient for caller to know up-front if 'ids' is empty. Allow # empty 'ids' and return early. return - # GetPersona only accepts one persona ID per request. Crazy. - svc = GetPersona(account=self) - for i in ids: - yield svc.call(persona=i)
    + yield from GetPersona(account=self).call(personas=ids)
    @@ -2476,7 +2476,7 @@

    Inherited members

    # The maximum number of sessions (== TCP connections, see below) we will open to this service endpoint. Keep this # low unless you have an agreement with the Exchange admin on the receiving end to hammer the server and # rate-limiting policies have been disabled for the connecting user. Changing this setting only makes sense if - # you are using a thread pool to run multiple concurrent workers in this process. + # you are using threads to run multiple concurrent workers in this process. SESSION_POOLSIZE = 1 # We want only 1 TCP connection per Session object. We may have lots of different credentials hitting the server and # each credential needs its own session (NTLM auth will only send credentials once and then secure the connection, @@ -2496,12 +2496,9 @@

    Inherited members

    USERAGENT = None def __init__(self, config): - from .configuration import Configuration - if not isinstance(config, Configuration): - raise ValueError("'config' %r must be a Configuration instance" % config) - if not config.service_endpoint: - raise AttributeError("'config.service_endpoint' must be set") self.config = config + self._api_version_hint = None + self._session_pool_size = 0 self._session_pool_maxsize = config.max_connections or self.SESSION_POOLSIZE @@ -2517,6 +2514,9 @@

    Inherited members

    @property def auth_type(self): + # Autodetect authentication type if necessary + if self.config.auth_type is None: + self.config.auth_type = self.get_auth_type() return self.config.auth_type @property @@ -2539,6 +2539,15 @@

    Inherited members

    def server(self): return self.config.server + def get_auth_type(self): + # Autodetect authentication type. We also set version hint here. + name = str(self.credentials) if self.credentials and str(self.credentials) else 'DUMMY' + auth_type, api_version_hint = get_service_authtype( + service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS, name=name + ) + self._api_version_hint = api_version_hint + return auth_type + def __getstate__(self): # The session pool and lock cannot be pickled state = self.__dict__.copy() @@ -2588,7 +2597,7 @@

    Inherited members

    """Increases the session pool size. We increase by one session per call.""" # Create a single session and insert it into the pool. We need to protect this with a lock while we are changing # the pool size variable, to avoid race conditions. We must not exceed the pool size limit. - if self._session_pool_size == self._session_pool_maxsize: + if self._session_pool_size >= self._session_pool_maxsize: raise SessionPoolMaxSizeReached('Session pool size cannot be increased further') with self._session_pool_lock: if self._session_pool_size >= self._session_pool_maxsize: @@ -2644,13 +2653,10 @@

    Inherited members

    def release_session(self, session): # This should never fail, as we don't have more sessions than the queue contains log.debug('Server %s: Releasing session %s', self.server, session.session_id) - if self.MAX_SESSION_USAGE_COUNT and session.usage_count > self.MAX_SESSION_USAGE_COUNT: + if self.MAX_SESSION_USAGE_COUNT and session.usage_count >= self.MAX_SESSION_USAGE_COUNT: log.debug('Server %s: session %s usage exceeded limit. Discarding', self.server, session.session_id) session = self.renew_session(session) - try: - self._session_pool.put(session, block=False) - except Full: - log.debug('Server %s: Session pool was already full %s', self.server, session.session_id) + self._session_pool.put(session, block=False) @staticmethod def close_session(session): @@ -2684,11 +2690,9 @@

    Inherited members

    return self.renew_session(session) def create_session(self): - if self.auth_type is None: - raise ValueError('Cannot create session without knowing the auth type') if self.credentials is None: if self.auth_type in CREDENTIALS_REQUIRED: - raise ValueError('Auth type %r requires credentials' % self.auth_type) + raise ValueError(f'Auth type {self.auth_type!r} requires credentials') session = self.raw_session(self.service_endpoint) session.auth = get_auth_instance(auth_type=self.auth_type) else: @@ -2718,11 +2722,6 @@

    Inherited members

    return session def create_oauth2_session(self): - if self.auth_type != OAUTH2: - raise ValueError( - 'Auth type must be %r for credentials type %s' % (OAUTH2, self.credentials.__class__.__name__) - ) - has_token = False scope = ['https://outlook.office365.com/.default'] session_params = {} @@ -2765,7 +2764,7 @@

    Inherited members

    }) client = WebApplicationClient(self.credentials.client_id, **client_params) else: - token_url = 'https://login.microsoftonline.com/%s/oauth2/v2.0/token' % self.credentials.tenant_id + token_url = f'https://login.microsoftonline.com/{self.credentials.tenant_id}/oauth2/v2.0/token' client = BackendApplicationClient(client_id=self.credentials.client_id) session = self.raw_session(self.service_endpoint, oauth2_client=client, oauth2_session_params=session_params) @@ -2922,6 +2921,9 @@

    Instance variables

    @property
     def auth_type(self):
    +    # Autodetect authentication type if necessary
    +    if self.config.auth_type is None:
    +        self.config.auth_type = self.get_auth_type()
         return self.config.auth_type
    @@ -3018,11 +3020,6 @@

    Methods

    Expand source code
    def create_oauth2_session(self):
    -    if self.auth_type != OAUTH2:
    -        raise ValueError(
    -            'Auth type must be %r for credentials type %s' % (OAUTH2, self.credentials.__class__.__name__)
    -        )
    -
         has_token = False
         scope = ['https://outlook.office365.com/.default']
         session_params = {}
    @@ -3065,7 +3062,7 @@ 

    Methods

    }) client = WebApplicationClient(self.credentials.client_id, **client_params) else: - token_url = 'https://login.microsoftonline.com/%s/oauth2/v2.0/token' % self.credentials.tenant_id + token_url = f'https://login.microsoftonline.com/{self.credentials.tenant_id}/oauth2/v2.0/token' client = BackendApplicationClient(client_id=self.credentials.client_id) session = self.raw_session(self.service_endpoint, oauth2_client=client, oauth2_session_params=session_params) @@ -3092,11 +3089,9 @@

    Methods

    Expand source code
    def create_session(self):
    -    if self.auth_type is None:
    -        raise ValueError('Cannot create session without knowing the auth type')
         if self.credentials is None:
             if self.auth_type in CREDENTIALS_REQUIRED:
    -            raise ValueError('Auth type %r requires credentials' % self.auth_type)
    +            raise ValueError(f'Auth type {self.auth_type!r} requires credentials')
             session = self.raw_session(self.service_endpoint)
             session.auth = get_auth_instance(auth_type=self.auth_type)
         else:
    @@ -3155,6 +3150,25 @@ 

    Methods

    self._session_pool_size -= 1
    +
    +def get_auth_type(self) +
    +
    +
    +
    + +Expand source code + +
    def get_auth_type(self):
    +    # Autodetect authentication type. We also set version hint here.
    +    name = str(self.credentials) if self.credentials and str(self.credentials) else 'DUMMY'
    +    auth_type, api_version_hint = get_service_authtype(
    +        service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS, name=name
    +    )
    +    self._api_version_hint = api_version_hint
    +    return auth_type
    +
    +
    def get_session(self)
    @@ -3202,7 +3216,7 @@

    Methods

    """Increases the session pool size. We increase by one session per call.""" # Create a single session and insert it into the pool. We need to protect this with a lock while we are changing # the pool size variable, to avoid race conditions. We must not exceed the pool size limit. - if self._session_pool_size == self._session_pool_maxsize: + if self._session_pool_size >= self._session_pool_maxsize: raise SessionPoolMaxSizeReached('Session pool size cannot be increased further') with self._session_pool_lock: if self._session_pool_size >= self._session_pool_maxsize: @@ -3250,13 +3264,10 @@

    Methods

    def release_session(self, session):
         # This should never fail, as we don't have more sessions than the queue contains
         log.debug('Server %s: Releasing session %s', self.server, session.session_id)
    -    if self.MAX_SESSION_USAGE_COUNT and session.usage_count > self.MAX_SESSION_USAGE_COUNT:
    +    if self.MAX_SESSION_USAGE_COUNT and session.usage_count >= self.MAX_SESSION_USAGE_COUNT:
             log.debug('Server %s: session %s usage exceeded limit. Discarding', self.server, session.session_id)
             session = self.renew_session(session)
    -    try:
    -        self._session_pool.put(session, block=False)
    -    except Full:
    -        log.debug('Server %s: Session pool was already full %s', self.server, session.session_id)
    + self._session_pool.put(session, block=False)
    @@ -3398,19 +3409,19 @@

    Methods

    def __init__(self, major_version, minor_version, major_build=0, minor_build=0): if not isinstance(major_version, int): - raise ValueError("'major_version' must be an integer") + raise InvalidTypeError('major_version', major_version, int) if not isinstance(minor_version, int): - raise ValueError("'minor_version' must be an integer") + raise InvalidTypeError('minor_version', minor_version, int) if not isinstance(major_build, int): - raise ValueError("'major_build' must be an integer") + raise InvalidTypeError('major_build', major_build, int) if not isinstance(minor_build, int): - raise ValueError("'minor_build' must be an integer") + raise InvalidTypeError('minor_build', minor_build, int) self.major_version = major_version self.minor_version = minor_version self.major_build = major_build self.minor_build = minor_build if major_version < 8: - raise ValueError("Exchange major versions below 8 don't support EWS (%s)" % self) + raise ValueError(f"Exchange major versions below 8 don't support EWS ({self})") @classmethod def from_xml(cls, elem): @@ -3442,7 +3453,7 @@

    Methods

    :param s: """ - bin_s = '{:032b}'.format(int(s, 16)) # Convert string to 32-bit binary string + bin_s = f'{int(s, 16):032b}' # Convert string to 32-bit binary string major_version = int(bin_s[4:10], 2) minor_version = int(bin_s[10:16], 2) build_number = int(bin_s[17:32], 2) @@ -3454,7 +3465,7 @@

    Methods

    try: return self.API_VERSION_MAP[self.major_version][self.minor_version] except KeyError: - raise ValueError('API version for build %s is unknown' % self) + raise ValueError(f'API version for build {self} is unknown') def fullname(self): return VERSIONS[self.api_version()][1] @@ -3494,7 +3505,7 @@

    Methods

    return self.__cmp__(other) >= 0 def __str__(self): - return '%s.%s.%s.%s' % (self.major_version, self.minor_version, self.major_build, self.minor_build) + return f'{self.major_version}.{self.minor_version}.{self.major_build}.{self.minor_build}' def __repr__(self): return self.__class__.__name__ \ @@ -3540,7 +3551,7 @@

    Static methods

    :param s: """ - bin_s = '{:032b}'.format(int(s, 16)) # Convert string to 32-bit binary string + bin_s = f'{int(s, 16):032b}' # Convert string to 32-bit binary string major_version = int(bin_s[4:10], 2) minor_version = int(bin_s[10:16], 2) build_number = int(bin_s[17:32], 2) @@ -3610,7 +3621,7 @@

    Methods

    try: return self.API_VERSION_MAP[self.major_version][self.minor_version] except KeyError: - raise ValueError('API version for build %s is unknown' % self)
    + raise ValueError(f'API version for build {self} is unknown')
    @@ -3770,7 +3781,7 @@

    Methods

    def clean(self, version=None): super().clean(version=version) if self.start and self.end and self.end < self.start: - raise ValueError("'end' must be greater than 'start' (%s -> %s)" % (self.start, self.end)) + raise ValueError(f"'end' must be greater than 'start' ({self.start} -> {self.end})") if version: self.clean_timezone_fields(version=version) @@ -3805,7 +3816,7 @@

    Methods

    # the inverse of what we do in .to_xml(). Convert to the local timezone before getting the date. if field_name == 'end': val -= datetime.timedelta(days=1) - tz = getattr(item, '_%s_timezone' % field_name) + tz = getattr(item, f'_{field_name}_timezone') setattr(item, field_name, val.astimezone(tz).date()) return item @@ -3850,7 +3861,7 @@

    Methods

    # We already generated an XML element for this field, but it contains a plain date at this point, which # is invalid. Replace the value. field = self.get_field_by_fieldname(field_name) - set_xml_value(elem=elem.find(field.response_tag()), value=value, version=version) + set_xml_value(elem.find(field.response_tag()), value) return elem

    Ancestors

    @@ -3899,7 +3910,7 @@

    Static methods

    # the inverse of what we do in .to_xml(). Convert to the local timezone before getting the date. if field_name == 'end': val -= datetime.timedelta(days=1) - tz = getattr(item, '_%s_timezone' % field_name) + tz = getattr(item, f'_{field_name}_timezone') setattr(item, field_name, val.astimezone(tz).date()) return item @@ -4105,7 +4116,7 @@

    Methods

    def clean(self, version=None):
         super().clean(version=version)
         if self.start and self.end and self.end < self.start:
    -        raise ValueError("'end' must be greater than 'start' (%s -> %s)" % (self.start, self.end))
    +        raise ValueError(f"'end' must be greater than 'start' ({self.start} -> {self.end})")
         if version:
             self.clean_timezone_fields(version=version)
    @@ -4253,7 +4264,7 @@

    Methods

    # We already generated an XML element for this field, but it contains a plain date at this point, which # is invalid. Replace the value. field = self.get_field_by_fieldname(field_name) - set_xml_value(elem=elem.find(field.response_tag()), value=value, version=version) + set_xml_value(elem.find(field.response_tag()), value) return elem @@ -4407,26 +4418,27 @@

    Inherited members

    def __init__(self, credentials=None, server=None, service_endpoint=None, auth_type=None, version=None, retry_policy=None, max_connections=None): if not isinstance(credentials, (BaseCredentials, type(None))): - raise ValueError("'credentials' %r must be a Credentials instance" % credentials) - if isinstance(credentials, OAuth2Credentials) and auth_type is None: - # This type of credentials *must* use the OAuth auth type - auth_type = OAUTH2 + raise InvalidTypeError('credentials', credentials, BaseCredentials) + if auth_type is None: + # Set a default auth type for the credentials where this makes sense + auth_type = DEFAULT_AUTH_TYPE.get(type(credentials)) + elif credentials is None and auth_type in CREDENTIALS_REQUIRED: + raise ValueError(f'Auth type {auth_type!r} was detected but no credentials were provided') if server and service_endpoint: raise AttributeError("Only one of 'server' or 'service_endpoint' must be provided") if auth_type is not None and auth_type not in AUTH_TYPE_MAP: - raise ValueError("'auth_type' %r must be one of %s" - % (auth_type, ', '.join("'%s'" % k for k in sorted(AUTH_TYPE_MAP)))) + raise InvalidEnumValue('auth_type', auth_type, AUTH_TYPE_MAP) if not retry_policy: retry_policy = FailFast() if not isinstance(version, (Version, type(None))): - raise ValueError("'version' %r must be a Version instance" % version) + raise InvalidTypeError('version', version, Version) if not isinstance(retry_policy, RetryPolicy): - raise ValueError("'retry_policy' %r must be a RetryPolicy instance" % retry_policy) + raise InvalidTypeError('retry_policy', retry_policy, RetryPolicy) if not isinstance(max_connections, (int, type(None))): - raise ValueError("'max_connections' must be an integer") + raise InvalidTypeError('max_connections', max_connections, int) self._credentials = credentials if server: - self.service_endpoint = 'https://%s/EWS/Exchange.asmx' % server + self.service_endpoint = f'https://{server}/EWS/Exchange.asmx' else: self.service_endpoint = service_endpoint self.auth_type = auth_type @@ -4446,9 +4458,10 @@

    Inherited members

    return split_url(self.service_endpoint)[1] def __repr__(self): - return self.__class__.__name__ + '(%s)' % ', '.join('%s=%r' % (k, getattr(self, k)) for k in ( + args_str = ', '.join(f'{k}={getattr(self, k)!r}' for k in ( 'credentials', 'service_endpoint', 'auth_type', 'version', 'retry_policy' - )) + )) + return f'{self.__class__.__name__}({args_str})'

    Instance variables

    @@ -5088,7 +5101,7 @@

    Inherited members

    @classmethod def from_date(cls, d): if type(d) is not datetime.date: - raise ValueError("%r must be a date instance" % d) + raise InvalidTypeError('d', d, datetime.date) return cls(d.year, d.month, d.day) @classmethod @@ -5126,7 +5139,7 @@

    Static methods

    @classmethod
     def from_date(cls, d):
         if type(d) is not datetime.date:
    -        raise ValueError("%r must be a date instance" % d)
    +        raise InvalidTypeError('d', d, datetime.date)
         return cls(d.year, d.month, d.day)
    @@ -5219,7 +5232,7 @@

    Methods

    # Don't allow pytz or dateutil timezones here. They are not safe to use as direct input for datetime() tzinfo = EWSTimeZone.from_timezone(tzinfo) if not isinstance(tzinfo, (EWSTimeZone, type(None))): - raise ValueError('tzinfo %r must be an EWSTimeZone instance' % tzinfo) + raise InvalidTypeError('tzinfo', tzinfo, EWSTimeZone) if len(args) == 8: args = args[:7] + (tzinfo,) else: @@ -5232,7 +5245,7 @@

    Methods

    * 2009-01-15T13:45:56+01:00 """ if not self.tzinfo: - raise ValueError('%r must be timezone-aware' % self) + raise ValueError(f'{self!r} must be timezone-aware') if self.tzinfo.key == 'UTC': if self.microsecond: return self.strftime('%Y-%m-%dT%H:%M:%S.%fZ') @@ -5242,7 +5255,7 @@

    Methods

    @classmethod def from_datetime(cls, d): if type(d) is not datetime.datetime: - raise ValueError("%r must be a datetime instance" % d) + raise InvalidTypeError('d', d, datetime.datetime) if d.tzinfo is None: tz = None elif isinstance(d.tzinfo, EWSTimeZone): @@ -5348,7 +5361,7 @@

    Static methods

    @classmethod
     def from_datetime(cls, d):
         if type(d) is not datetime.datetime:
    -        raise ValueError("%r must be a datetime instance" % d)
    +        raise InvalidTypeError('d', d, datetime.datetime)
         if d.tzinfo is None:
             tz = None
         elif isinstance(d.tzinfo, EWSTimeZone):
    @@ -5509,7 +5522,7 @@ 

    Methods

    * 2009-01-15T13:45:56+01:00 """ if not self.tzinfo: - raise ValueError('%r must be timezone-aware' % self) + raise ValueError(f'{self!r} must be timezone-aware') if self.tzinfo.key == 'UTC': if self.microsecond: return self.strftime('%Y-%m-%dT%H:%M:%S.%fZ') @@ -5546,7 +5559,7 @@

    Methods

    try: instance.ms_id = cls.IANA_TO_MS_MAP[instance.key][0] except KeyError: - raise UnknownTimeZone('No Windows timezone name found for timezone "%s"' % instance.key) + raise UnknownTimeZone(f'No Windows timezone name found for timezone {instance.key!r}') # We don't need the Windows long-format timezone name in long format. It's used in timezone XML elements, but # EWS happily accepts empty strings. For a full list of timezones supported by the target server, including @@ -5573,7 +5586,7 @@

    Methods

    # EWS sometimes returns an ID that has a region/location format, e.g. 'Europe/Copenhagen'. Try the # string unaltered. return cls(ms_id) - raise UnknownTimeZone("Windows timezone ID '%s' is unknown by CLDR" % ms_id) + raise UnknownTimeZone(f'Windows timezone ID {ms_id!r} is unknown by CLDR') @classmethod def from_pytz(cls, tz): @@ -5613,7 +5626,7 @@

    Methods

    'pytz_deprecation_shim': lambda z: cls.from_timezone(z.unwrap_shim()) }[tz_module](tz) except KeyError: - raise TypeError('Unsupported tzinfo type: %r' % tz) + raise TypeError(f'Unsupported tzinfo type: {tz!r}') @classmethod def localzone(cls): @@ -5625,32 +5638,6 @@

    Methods

    # Handle both old and new versions of tzlocal that may return pytz or zoneinfo objects, respectively return cls.from_timezone(tz) - @classmethod - def timezone(cls, location): - warnings.warn('replace EWSTimeZone.timezone() with just EWSTimeZone()', DeprecationWarning, stacklevel=2) - return cls(location) - - def normalize(self, dt, is_dst=False): - warnings.warn('normalization is now handled gracefully', DeprecationWarning, stacklevel=2) - return dt - - def localize(self, dt, is_dst=False): - warnings.warn('replace tz.localize() with dt.replace(tzinfo=tz)', DeprecationWarning, stacklevel=2) - if dt.tzinfo is not None: - raise ValueError('%r must be timezone-unaware' % dt) - dt = dt.replace(tzinfo=self) - if is_dst is not None: - # DST dates are assumed to always be after non-DST dates - dt_before = dt.replace(fold=0) - dt_after = dt.replace(fold=1) - dst_before = dt_before.dst() - dst_after = dt_after.dst() - if dst_before > dst_after: - dt = dt_before if is_dst else dt_after - elif dst_before < dst_after: - dt = dt_after if is_dst else dt_before - return dt - def fromutc(self, dt): t = super().fromutc(dt) if isinstance(t, EWSDateTime): @@ -5729,7 +5716,7 @@

    Static methods

    # EWS sometimes returns an ID that has a region/location format, e.g. 'Europe/Copenhagen'. Try the # string unaltered. return cls(ms_id) - raise UnknownTimeZone("Windows timezone ID '%s' is unknown by CLDR" % ms_id)
    + raise UnknownTimeZone(f'Windows timezone ID {ms_id!r} is unknown by CLDR')
    @@ -5771,7 +5758,7 @@

    Static methods

    'pytz_deprecation_shim': lambda z: cls.from_timezone(z.unwrap_shim()) }[tz_module](tz) except KeyError: - raise TypeError('Unsupported tzinfo type: %r' % tz)
    + raise TypeError(f'Unsupported tzinfo type: {tz!r}')
    @@ -5808,21 +5795,6 @@

    Static methods

    return cls.from_timezone(tz)
    -
    -def timezone(location) -
    -
    -
    -
    - -Expand source code - -
    @classmethod
    -def timezone(cls, location):
    -    warnings.warn('replace EWSTimeZone.timezone() with just EWSTimeZone()', DeprecationWarning, stacklevel=2)
    -    return cls(location)
    -
    -

    Methods

    @@ -5842,47 +5814,6 @@

    Methods

    return EWSDateTime.from_datetime(t) # We want to return EWSDateTime objects -
    -def localize(self, dt, is_dst=False) -
    -
    -
    -
    - -Expand source code - -
    def localize(self, dt, is_dst=False):
    -    warnings.warn('replace tz.localize() with dt.replace(tzinfo=tz)', DeprecationWarning, stacklevel=2)
    -    if dt.tzinfo is not None:
    -        raise ValueError('%r must be timezone-unaware' % dt)
    -    dt = dt.replace(tzinfo=self)
    -    if is_dst is not None:
    -        # DST dates are assumed to always be after non-DST dates
    -        dt_before = dt.replace(fold=0)
    -        dt_after = dt.replace(fold=1)
    -        dst_before = dt_before.dst()
    -        dst_after = dt_after.dst()
    -        if dst_before > dst_after:
    -            dt = dt_before if is_dst else dt_after
    -        elif dst_before < dst_after:
    -            dt = dt_after if is_dst else dt_before
    -    return dt
    -
    -
    -
    -def normalize(self, dt, is_dst=False) -
    -
    -
    -
    - -Expand source code - -
    def normalize(self, dt, is_dst=False):
    -    warnings.warn('normalization is now handled gracefully', DeprecationWarning, stacklevel=2)
    -    return dt
    -
    -
    @@ -6005,9 +5936,8 @@

    Methods

    "When 'distinguished_property_set_id' is set, 'property_id' or 'property_name' must also be set" ) if cls.distinguished_property_set_id not in cls.DISTINGUISHED_SETS: - raise ValueError( - "'distinguished_property_set_id' %r must be one of %s" - % (cls.distinguished_property_set_id, sorted(cls.DISTINGUISHED_SETS)) + raise InvalidEnumValue( + 'distinguished_property_set_id', cls.distinguished_property_set_id, cls.DISTINGUISHED_SETS ) @classmethod @@ -6031,7 +5961,7 @@

    Methods

    raise ValueError("When 'property_tag' is set, only 'property_type' must be set") if 0x8000 <= cls.property_tag_as_int() <= 0xFFFE: raise ValueError( - "'property_tag' value '%s' is reserved for custom properties" % cls.property_tag_as_hex() + f"'property_tag' value {cls.property_tag_as_hex()!r} is reserved for custom properties" ) @classmethod @@ -6057,24 +5987,20 @@

    Methods

    @classmethod def _validate_property_type(cls): if cls.property_type not in cls.PROPERTY_TYPES: - raise ValueError( - "'property_type' %r must be one of %s" % (cls.property_type, sorted(cls.PROPERTY_TYPES)) - ) + raise InvalidEnumValue('property_type', cls.property_type, cls.PROPERTY_TYPES) def clean(self, version=None): self.validate_cls() python_type = self.python_type() if self.is_array_type(): if not is_iterable(self.value): - raise ValueError("'%s' value %r must be a list" % (self.__class__.__name__, self.value)) + raise TypeError(f"Field {self.__class__.__name__!r} value {self.value!r} must be of type {list}") for v in self.value: if not isinstance(v, python_type): - raise TypeError( - "'%s' value element %r must be an instance of %s" % (self.__class__.__name__, v, python_type)) + raise TypeError(f"Field {self.__class__.__name__!r} list value {v!r} must be of type {python_type}") else: if not isinstance(self.value, python_type): - raise TypeError( - "'%s' value %r must be an instance of %s" % (self.__class__.__name__, self.value, python_type)) + raise TypeError(f"Field {self.__class__.__name__!r} value {self.value!r} must be of type {python_type}") @classmethod def _normalize_obj(cls, obj): @@ -6109,12 +6035,12 @@

    Methods

    # Gets value of this specific ExtendedProperty from a list of 'ExtendedProperty' XML elements python_type = cls.python_type() if cls.is_array_type(): - values = elem.find('{%s}Values' % TNS) + values = elem.find(f'{{{TNS}}}Values') return [ xml_text_to_value(value=val, value_type=python_type) - for val in get_xml_attrs(values, '{%s}Value' % TNS) + for val in get_xml_attrs(values, f'{{{TNS}}}Value') ] - extended_field_value = xml_text_to_value(value=get_xml_attr(elem, '{%s}Value' % TNS), value_type=python_type) + extended_field_value = xml_text_to_value(value=get_xml_attr(elem, f'{{{TNS}}}Value'), value_type=python_type) if python_type == str and not extended_field_value: # For string types, we want to return the empty string instead of None if the element was # actually found, but there was no XML value. For other types, it would be more problematic @@ -6269,12 +6195,12 @@

    Static methods

    # Gets value of this specific ExtendedProperty from a list of 'ExtendedProperty' XML elements python_type = cls.python_type() if cls.is_array_type(): - values = elem.find('{%s}Values' % TNS) + values = elem.find(f'{{{TNS}}}Values') return [ xml_text_to_value(value=val, value_type=python_type) - for val in get_xml_attrs(values, '{%s}Value' % TNS) + for val in get_xml_attrs(values, f'{{{TNS}}}Value') ] - extended_field_value = xml_text_to_value(value=get_xml_attr(elem, '{%s}Value' % TNS), value_type=python_type) + extended_field_value = xml_text_to_value(value=get_xml_attr(elem, f'{{{TNS}}}Value'), value_type=python_type) if python_type == str and not extended_field_value: # For string types, we want to return the empty string instead of None if the element was # actually found, but there was no XML value. For other types, it would be more problematic @@ -6427,15 +6353,13 @@

    Methods

    python_type = self.python_type() if self.is_array_type(): if not is_iterable(self.value): - raise ValueError("'%s' value %r must be a list" % (self.__class__.__name__, self.value)) + raise TypeError(f"Field {self.__class__.__name__!r} value {self.value!r} must be of type {list}") for v in self.value: if not isinstance(v, python_type): - raise TypeError( - "'%s' value element %r must be an instance of %s" % (self.__class__.__name__, v, python_type)) + raise TypeError(f"Field {self.__class__.__name__!r} list value {v!r} must be of type {python_type}") else: if not isinstance(self.value, python_type): - raise TypeError( - "'%s' value %r must be an instance of %s" % (self.__class__.__name__, self.value, python_type))
    + raise TypeError(f"Field {self.__class__.__name__!r} value {self.value!r} must be of type {python_type}")
    @@ -6500,63 +6424,17 @@

    Ancestors

    -

    Instance variables

    -
    -
    var back_off_until
    -
    -
    -
    - -Expand source code - -
    @property
    -def back_off_until(self):
    -    return None
    -
    -
    -
    var fail_fast
    -
    -
    -
    - -Expand source code - -
    @property
    -def fail_fast(self):
    -    return True
    -
    -
    -
    -

    Methods

    -
    -
    -def back_off(self, seconds) -
    -
    -
    -
    - -Expand source code - -
    def back_off(self, seconds):
    -    raise ValueError('Cannot back off with fail-fast policy')
    -
    -
    -
    -def may_retry_on_error(self, response, wait) -
    -
    -
    -
    - -Expand source code - -
    def may_retry_on_error(self, response, wait):
    -    log.debug('No retry: no fail-fast policy')
    -    return False
    -
    -
    -
    +

    Inherited members

    +
    class FaultTolerance @@ -6687,80 +6565,17 @@

    Instance variables

    return self._back_off_until
    -
    var fail_fast
    -
    -
    -
    - -Expand source code - -
    @property
    -def fail_fast(self):
    -    return False
    -
    -
    -
    -

    Methods

    -
    -
    -def back_off(self, seconds) -
    -
    -
    -
    - -Expand source code - -
    def back_off(self, seconds):
    -    if seconds is None:
    -        seconds = self.DEFAULT_BACKOFF
    -    value = datetime.datetime.now() + datetime.timedelta(seconds=seconds)
    -    with self._back_off_lock:
    -        self._back_off_until = value
    -
    -
    -
    -def may_retry_on_error(self, response, wait) -
    -
    -
    -
    - -Expand source code - -
    def may_retry_on_error(self, response, wait):
    -    if response.status_code not in (301, 302, 401, 500, 503):
    -        # Don't retry if we didn't get a status code that we can hope to recover from
    -        log.debug('No retry: wrong status code %s', response.status_code)
    -        return False
    -    if wait > self.max_wait:
    -        # We lost patience. Session is cleaned up in outer loop
    -        raise RateLimitError(
    -            'Max timeout reached', url=response.url, status_code=response.status_code, total_wait=wait)
    -    if response.status_code == 401:
    -        # EWS sometimes throws 401's when it wants us to throttle connections. OK to retry.
    -        return True
    -    if response.headers.get('connection') == 'close':
    -        # Connection closed. OK to retry.
    -        return True
    -    if response.status_code == 302 and response.headers.get('location', '').lower() \
    -            == '/ews/genericerrorpage.htm?aspxerrorpath=/ews/exchange.asmx':
    -        # The genericerrorpage.htm/internalerror.asp is ridiculous behaviour for random outages. OK to retry.
    -        #
    -        # Redirect to '/internalsite/internalerror.asp' or '/internalsite/initparams.aspx' is caused by e.g. TLS
    -        # certificate f*ckups on the Exchange server. We should not retry those.
    -        return True
    -    if response.status_code == 503:
    -        # Internal server error. OK to retry.
    -        return True
    -    if response.status_code == 500 and b"Server Error in '/EWS' Application" in response.content:
    -        # "Server Error in '/EWS' Application" has been seen in highly concurrent settings. OK to retry.
    -        log.debug('Retry allowed: conditions met')
    -        return True
    -    return False
    -
    -
    +

    Inherited members

    +
    class FileAttachment @@ -6798,7 +6613,7 @@

    Methods

    # Create a file-like object for the attachment content. We try hard to reduce memory consumption so we never # store the full attachment content in-memory. if not self.parent_item or not self.parent_item.account: - raise ValueError('%s must have an account' % self.__class__.__name__) + raise ValueError(f'{self.__class__.__name__} must have an account') self._fp = FileAttachmentIO(attachment=self) @property @@ -6819,7 +6634,7 @@

    Methods

    def content(self, value): """Replace the attachment content.""" if not isinstance(value, bytes): - raise ValueError("'value' %r must be a bytes object" % value) + raise InvalidTypeError('value', value, bytes) self._content = value @classmethod @@ -7028,7 +6843,7 @@

    Inherited members

    folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound('Could not find distinguished folder %r' % cls.DISTINGUISHED_FOLDER_ID) + raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}') @property def parent(self): @@ -7045,7 +6860,7 @@

    Inherited members

    self.parent_folder_id = None else: if not isinstance(value, BaseFolder): - raise ValueError("'value' %r must be a Folder instance" % value) + raise InvalidTypeError('value', value, BaseFolder) self.root = value.root self.parent_folder_id = ParentFolderId(id=value.id, changekey=value.changekey) @@ -7053,7 +6868,7 @@

    Inherited members

    from .roots import RootOfHierarchy super().clean(version=version) if self.root and not isinstance(self.root, RootOfHierarchy): - raise ValueError("'root' %r must be a RootOfHierarchy instance" % self.root) + raise InvalidTypeError('root', self.root, RootOfHierarchy) @classmethod def from_xml_with_root(cls, elem, root): @@ -7224,63 +7039,20 @@

    Static methods

    folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound('Could not find distinguished folder %r' % cls.DISTINGUISHED_FOLDER_ID)
    + raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}')

    Instance variables

    -
    var account
    -
    -
    -
    - -Expand source code - -
    @property
    -def account(self):
    -    if self.root is None:
    -        return None
    -    return self.root.account
    -
    -
    var effective_rights
    -
    var parent
    -
    -
    -
    - -Expand source code - -
    @property
    -def parent(self):
    -    if not self.parent_folder_id:
    -        return None
    -    if self.parent_folder_id.id == self.id:
    -        # Some folders have a parent that references itself. Avoid circular references here
    -        return None
    -    return self.root.get_folder(self.parent_folder_id)
    -
    -
    var permission_set
    -
    var root
    -
    -
    -
    - -Expand source code - -
    @property
    -def root(self):
    -    return self._root
    -
    -

    Methods

    @@ -7297,7 +7069,7 @@

    Methods

    from .roots import RootOfHierarchy super().clean(version=version) if self.root and not isinstance(self.root, RootOfHierarchy): - raise ValueError("'root' %r must be a RootOfHierarchy instance" % self.root) + raise InvalidTypeError('root', self.root, RootOfHierarchy)
    @@ -7306,16 +7078,25 @@

    Inherited members

  • BaseFolder:
    • ID_ELEMENT_CLS
    • +
    • account
    • add_field
    • +
    • all
    • deregister
    • +
    • exclude
    • +
    • filter
    • folder_cls_from_container_class
    • folder_sync_state
    • +
    • get
    • get_events
    • get_streaming_events
    • is_distinguished
    • item_sync_state
    • +
    • none
    • +
    • parent
    • +
    • people
    • register
    • remove_field
    • +
    • root
    • subscribe_to_pull
    • subscribe_to_push
    • subscribe_to_streaming
    • @@ -7446,7 +7227,8 @@

      Inherited members

      return tuple(item_model for folder in self.folders for item_model in folder.supported_item_models) def validate_item_field(self, field, version): - # For each field, check if the field is valid for any of the item models supported by this folder + # Takes a fieldname, Field or FieldPath object pointing to an item field, and checks that it is valid + # for the item types supported by this folder collection. for item_model in self.supported_item_models: try: item_model.validate_field(field=field, version=version) @@ -7454,14 +7236,35 @@

      Inherited members

      except InvalidField: continue else: - raise InvalidField("%r is not a valid field on %s" % (field, self.supported_item_models)) + raise InvalidField(f"{field!r} is not a valid field on {self.supported_item_models}") + + def _rinse_args(self, q, depth, additional_fields, field_validator): + if depth is None: + depth = self._get_default_item_traversal_depth() + if additional_fields: + for f in additional_fields: + field_validator(field=f, version=self.account.version) + if f.field.is_complex: + raise ValueError(f"Field {f.field.name!r} not supported for this service") + + # Build up any restrictions + if q.is_empty(): + restriction = None + query_string = None + elif q.query_string: + restriction = None + query_string = Restriction(q, folders=self.folders, applies_to=Restriction.ITEMS) + else: + restriction = Restriction(q, folders=self.folders, applies_to=Restriction.ITEMS) + query_string = None + return depth, restriction, query_string def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order_fields=None, calendar_view=None, page_size=None, max_items=None, offset=0): """Private method to call the FindItem service. :param q: a Q instance containing any restrictions - :param shape: controls whether to return (id, chanegkey) tuples or Item objects. If additional_fields is + :param shape: controls whether to return (id, changekey) tuples or Item objects. If additional_fields is non-null, we always return Item objects. (Default value = ID_ONLY) :param depth: controls the whether to return soft-deleted items or not. (Default value = None) :param additional_fields: the extra properties we want on the return objects. Default is no properties. Be aware @@ -7474,36 +7277,20 @@

      Inherited members

      :return: a generator for the returned item IDs or items """ + from ..services import FindItem if not self.folders: log.debug('Folder list is empty') return if q.is_never(): log.debug('Query will never return results') return - if shape not in SHAPE_CHOICES: - raise ValueError("'shape' %s must be one of %s" % (shape, SHAPE_CHOICES)) - if depth is None: - depth = self._get_default_item_traversal_depth() - if depth not in ITEM_TRAVERSAL_CHOICES: - raise ValueError("'depth' %s must be one of %s" % (depth, ITEM_TRAVERSAL_CHOICES)) - if additional_fields: - for f in additional_fields: - self.validate_item_field(field=f, version=self.account.version) - if f.field.is_complex: - raise ValueError("find_items() does not support field '%s'. Use fetch() instead" % f.field.name) + depth, restriction, query_string = self._rinse_args( + q=q, depth=depth, additional_fields=additional_fields, + field_validator=self.validate_item_field + ) if calendar_view is not None and not isinstance(calendar_view, CalendarView): - raise ValueError("'calendar_view' %s must be a CalendarView instance" % calendar_view) + raise InvalidTypeError('calendar_view', calendar_view, CalendarView) - # Build up any restrictions - if q.is_empty(): - restriction = None - query_string = None - elif q.query_string: - restriction = None - query_string = Restriction(q, folders=self.folders, applies_to=Restriction.ITEMS) - else: - restriction = Restriction(q, folders=self.folders, applies_to=Restriction.ITEMS) - query_string = None log.debug( 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', self.account, @@ -7513,7 +7300,7 @@

      Inherited members

      additional_fields, restriction.q if restriction else None, ) - yield from FindItem(account=self.account, chunk_size=page_size).call( + yield from FindItem(account=self.account, page_size=page_size).call( folders=self.folders, additional_fields=additional_fields, restriction=restriction, @@ -7539,7 +7326,7 @@

      Inherited members

      """Private method to call the FindPeople service. :param q: a Q instance containing any restrictions - :param shape: controls whether to return (id, chanegkey) tuples or Persona objects. If additional_fields is + :param shape: controls whether to return (id, changekey) tuples or Persona objects. If additional_fields is non-null, we always return Persona objects. (Default value = ID_ONLY) :param depth: controls the whether to return soft-deleted items or not. (Default value = None) :param additional_fields: the extra properties we want on the return objects. Default is no properties. @@ -7550,35 +7337,17 @@

      Inherited members

      :return: a generator for the returned personas """ + from ..services import FindPeople folder = self._get_single_folder() - if not folder: - return if q.is_never(): log.debug('Query will never return results') return - if shape not in SHAPE_CHOICES: - raise ValueError("'shape' %s must be one of %s" % (shape, SHAPE_CHOICES)) - if depth is None: - depth = self._get_default_item_traversal_depth() - if depth not in ITEM_TRAVERSAL_CHOICES: - raise ValueError("'depth' %s must be one of %s" % (depth, ITEM_TRAVERSAL_CHOICES)) - if additional_fields: - for f in additional_fields: - Persona.validate_field(field=f, version=self.account.version) - if f.field.is_complex: - raise ValueError("find_people() does not support field '%s'" % f.field.name) + depth, restriction, query_string = self._rinse_args( + q=q, depth=depth, additional_fields=additional_fields, + field_validator=Persona.validate_field + ) - # Build up any restrictions - if q.is_empty(): - restriction = None - query_string = None - elif q.query_string: - restriction = None - query_string = Restriction(q, folders=[folder], applies_to=Restriction.ITEMS) - else: - restriction = Restriction(q, folders=[folder], applies_to=Restriction.ITEMS) - query_string = None - yield from FindPeople(account=self.account, chunk_size=page_size).call( + yield from FindPeople(account=self.account, page_size=page_size).call( folder=folder, additional_fields=additional_fields, restriction=restriction, @@ -7606,11 +7375,11 @@

      Inherited members

      for f in self.folders: if isinstance(f, RootOfHierarchy): if has_non_roots: - raise ValueError('Cannot call GetFolder on a mix of folder types: {}'.format(self.folders)) + raise ValueError(f'Cannot call GetFolder on a mix of folder types: {self.folders}') has_roots = True else: if has_roots: - raise ValueError('Cannot call GetFolder on a mix of folder types: {}'.format(self.folders)) + raise ValueError(f'Cannot call GetFolder on a mix of folder types: {self.folders}') has_non_roots = True return RootOfHierarchy if has_roots else Folder @@ -7619,8 +7388,8 @@

      Inherited members

      if len(unique_depths) == 1: return unique_depths.pop() raise ValueError( - 'Folders in this collection do not have a common %s value. You need to define an explicit traversal depth' - 'with QuerySet.depth() (values: %s)' % (traversal_attr, unique_depths) + f'Folders in this collection do not have a common {traversal_attr} value. You need to define an explicit ' + f'traversal depth with QuerySet.depth() (values: {unique_depths})' ) def _get_default_item_traversal_depth(self): @@ -7650,6 +7419,7 @@

      Inherited members

      @require_account def find_folders(self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None, offset=0): + from ..services import FindFolder # 'depth' controls whether to return direct children or recurse into sub-folders from .base import BaseFolder, Folder if q is None: @@ -7664,26 +7434,22 @@

      Inherited members

      restriction = None else: restriction = Restriction(q, folders=self.folders, applies_to=Restriction.FOLDERS) - if shape not in SHAPE_CHOICES: - raise ValueError("'shape' %s must be one of %s" % (shape, SHAPE_CHOICES)) if depth is None: depth = self._get_default_folder_traversal_depth() - if depth not in FOLDER_TRAVERSAL_CHOICES: - raise ValueError("'depth' %s must be one of %s" % (depth, FOLDER_TRAVERSAL_CHOICES)) if additional_fields is None: # Default to all non-complex properties. Subfolders will always be of class Folder additional_fields = self.get_folder_fields(target_cls=Folder, is_complex=False) else: for f in additional_fields: if f.field.is_complex: - raise ValueError("find_folders() does not support field '%s'. Use get_folders()." % f.field.name) + raise ValueError(f"find_folders() does not support field {f.field.name!r}. Use get_folders().") # Add required fields additional_fields.update( (FieldPath(field=BaseFolder.get_field_by_fieldname(f)) for f in self.REQUIRED_FOLDER_FIELDS) ) - yield from FindFolder(account=self.account, chunk_size=page_size).call( + yield from FindFolder(account=self.account, page_size=page_size).call( folders=self.folders, additional_fields=additional_fields, restriction=restriction, @@ -7694,6 +7460,7 @@

      Inherited members

      ) def get_folders(self, additional_fields=None): + from ..services import GetFolder # Expand folders with their full set of properties from .base import BaseFolder if not self.folders: @@ -7714,34 +7481,62 @@

      Inherited members

      shape=ID_ONLY, ) - def subscribe_to_pull(self, event_types=SubscribeToPull.EVENT_TYPES, watermark=None, timeout=60): + def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): + from ..services import SubscribeToPull if not self.folders: log.debug('Folder list is empty') - return - yield from SubscribeToPull(account=self.account).call( + return None + if event_types is None: + event_types = SubscribeToPull.EVENT_TYPES + return SubscribeToPull(account=self.account).get( folders=self.folders, event_types=event_types, watermark=watermark, timeout=timeout, ) - def subscribe_to_push(self, callback_url, event_types=SubscribeToPush.EVENT_TYPES, watermark=None, - status_frequency=1): + def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1): + from ..services import SubscribeToPush if not self.folders: log.debug('Folder list is empty') - return - yield from SubscribeToPush(account=self.account).call( + return None + if event_types is None: + event_types = SubscribeToPush.EVENT_TYPES + return SubscribeToPush(account=self.account).get( folders=self.folders, event_types=event_types, watermark=watermark, status_frequency=status_frequency, url=callback_url, ) - def subscribe_to_streaming(self, event_types=SubscribeToPush.EVENT_TYPES): + def subscribe_to_streaming(self, event_types=None): + from ..services import SubscribeToStreaming if not self.folders: log.debug('Folder list is empty') - return - yield from SubscribeToStreaming(account=self.account).call(folders=self.folders, event_types=event_types) + return None + if event_types is None: + event_types = SubscribeToStreaming.EVENT_TYPES + return SubscribeToStreaming(account=self.account).get(folders=self.folders, event_types=event_types) + + def pull_subscription(self, **kwargs): + return PullSubscription(folder=self, **kwargs) + + def push_subscription(self, **kwargs): + return PushSubscription(folder=self, **kwargs) + + def streaming_subscription(self, **kwargs): + return StreamingSubscription(folder=self, **kwargs) + + def unsubscribe(self, subscription_id): + """Unsubscribe. Only applies to pull and streaming notifications. + + :param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|streaming]() + :return: True + + This method doesn't need the current collection instance, but it makes sense to keep the method along the other + sync methods. + """ + from ..services import Unsubscribe + return Unsubscribe(account=self.account).get(subscription_id=subscription_id) def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None): + from ..services import SyncFolderItems folder = self._get_single_folder() - if not folder: - return if only_fields is None: # We didn't restrict list of field paths. Get all fields from the server, including extended properties. additional_fields = {FieldPath(field=f) for f in folder.allowed_item_fields(version=self.account.version)} @@ -7771,17 +7566,19 @@

      Inherited members

      raise SyncCompleted(sync_state=svc.sync_state) def sync_hierarchy(self, sync_state=None, only_fields=None): + from ..services import SyncFolderHierarchy folder = self._get_single_folder() - if not folder: - return if only_fields is None: # We didn't restrict list of field paths. Get all fields from the server, including extended properties. additional_fields = {FieldPath(field=f) for f in folder.supported_fields(version=self.account.version)} else: - for f in only_fields: - folder.validate_field(field=f, version=self.account.version) - # Remove ItemId and ChangeKey. We get them unconditionally - additional_fields = {f for f in folder.normalize_fields(fields=only_fields) if not f.field.is_attribute} + additional_fields = set() + for field_name in only_fields: + folder.validate_field(field=field_name, version=self.account.version) + f = folder.get_field_by_fieldname(fieldname=field_name) + if not f.is_attribute: + # Remove ItemId and ChangeKey. We get them unconditionally + additional_fields.add(FieldPath(field=f)) # Add required fields additional_fields.update( @@ -7855,19 +7652,6 @@

      Instance variables

      Methods

      -
      -def all(self) -
      -
      -
      -
      - -Expand source code - -
      def all(self):
      -    return QuerySet(self).all()
      -
      -
      def allowed_item_fields(self)
      @@ -7885,19 +7669,6 @@

      Methods

      return fields -
      -def exclude(self, *args, **kwargs) -
      -
      -
      -
      - -Expand source code - -
      def exclude(self, *args, **kwargs):
      -    return QuerySet(self).exclude(*args, **kwargs)
      -
      -
      def filter(self, *args, **kwargs)
      @@ -7964,6 +7735,7 @@

      Examples

      @require_account
       def find_folders(self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None,
                        offset=0):
      +    from ..services import FindFolder
           # 'depth' controls whether to return direct children or recurse into sub-folders
           from .base import BaseFolder, Folder
           if q is None:
      @@ -7978,26 +7750,22 @@ 

      Examples

      restriction = None else: restriction = Restriction(q, folders=self.folders, applies_to=Restriction.FOLDERS) - if shape not in SHAPE_CHOICES: - raise ValueError("'shape' %s must be one of %s" % (shape, SHAPE_CHOICES)) if depth is None: depth = self._get_default_folder_traversal_depth() - if depth not in FOLDER_TRAVERSAL_CHOICES: - raise ValueError("'depth' %s must be one of %s" % (depth, FOLDER_TRAVERSAL_CHOICES)) if additional_fields is None: # Default to all non-complex properties. Subfolders will always be of class Folder additional_fields = self.get_folder_fields(target_cls=Folder, is_complex=False) else: for f in additional_fields: if f.field.is_complex: - raise ValueError("find_folders() does not support field '%s'. Use get_folders()." % f.field.name) + raise ValueError(f"find_folders() does not support field {f.field.name!r}. Use get_folders().") # Add required fields additional_fields.update( (FieldPath(field=BaseFolder.get_field_by_fieldname(f)) for f in self.REQUIRED_FOLDER_FIELDS) ) - yield from FindFolder(account=self.account, chunk_size=page_size).call( + yield from FindFolder(account=self.account, page_size=page_size).call( folders=self.folders, additional_fields=additional_fields, restriction=restriction, @@ -8014,7 +7782,7 @@

      Examples

      Private method to call the FindItem service.

      :param q: a Q instance containing any restrictions -:param shape: controls whether to return (id, chanegkey) tuples or Item objects. If additional_fields is +:param shape: controls whether to return (id, changekey) tuples or Item objects. If additional_fields is non-null, we always return Item objects. (Default value = ID_ONLY) :param depth: controls the whether to return soft-deleted items or not. (Default value = None) :param additional_fields: the extra properties we want on the return objects. Default is no properties. Be aware @@ -8034,7 +7802,7 @@

      Examples

      """Private method to call the FindItem service. :param q: a Q instance containing any restrictions - :param shape: controls whether to return (id, chanegkey) tuples or Item objects. If additional_fields is + :param shape: controls whether to return (id, changekey) tuples or Item objects. If additional_fields is non-null, we always return Item objects. (Default value = ID_ONLY) :param depth: controls the whether to return soft-deleted items or not. (Default value = None) :param additional_fields: the extra properties we want on the return objects. Default is no properties. Be aware @@ -8047,36 +7815,20 @@

      Examples

      :return: a generator for the returned item IDs or items """ + from ..services import FindItem if not self.folders: log.debug('Folder list is empty') return if q.is_never(): log.debug('Query will never return results') return - if shape not in SHAPE_CHOICES: - raise ValueError("'shape' %s must be one of %s" % (shape, SHAPE_CHOICES)) - if depth is None: - depth = self._get_default_item_traversal_depth() - if depth not in ITEM_TRAVERSAL_CHOICES: - raise ValueError("'depth' %s must be one of %s" % (depth, ITEM_TRAVERSAL_CHOICES)) - if additional_fields: - for f in additional_fields: - self.validate_item_field(field=f, version=self.account.version) - if f.field.is_complex: - raise ValueError("find_items() does not support field '%s'. Use fetch() instead" % f.field.name) + depth, restriction, query_string = self._rinse_args( + q=q, depth=depth, additional_fields=additional_fields, + field_validator=self.validate_item_field + ) if calendar_view is not None and not isinstance(calendar_view, CalendarView): - raise ValueError("'calendar_view' %s must be a CalendarView instance" % calendar_view) + raise InvalidTypeError('calendar_view', calendar_view, CalendarView) - # Build up any restrictions - if q.is_empty(): - restriction = None - query_string = None - elif q.query_string: - restriction = None - query_string = Restriction(q, folders=self.folders, applies_to=Restriction.ITEMS) - else: - restriction = Restriction(q, folders=self.folders, applies_to=Restriction.ITEMS) - query_string = None log.debug( 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', self.account, @@ -8086,7 +7838,7 @@

      Examples

      additional_fields, restriction.q if restriction else None, ) - yield from FindItem(account=self.account, chunk_size=page_size).call( + yield from FindItem(account=self.account, page_size=page_size).call( folders=self.folders, additional_fields=additional_fields, restriction=restriction, @@ -8106,7 +7858,7 @@

      Examples

      Private method to call the FindPeople service.

      :param q: a Q instance containing any restrictions -:param shape: controls whether to return (id, chanegkey) tuples or Persona objects. If additional_fields is +:param shape: controls whether to return (id, changekey) tuples or Persona objects. If additional_fields is non-null, we always return Persona objects. (Default value = ID_ONLY) :param depth: controls the whether to return soft-deleted items or not. (Default value = None) :param additional_fields: the extra properties we want on the return objects. Default is no properties. @@ -8124,7 +7876,7 @@

      Examples

      """Private method to call the FindPeople service. :param q: a Q instance containing any restrictions - :param shape: controls whether to return (id, chanegkey) tuples or Persona objects. If additional_fields is + :param shape: controls whether to return (id, changekey) tuples or Persona objects. If additional_fields is non-null, we always return Persona objects. (Default value = ID_ONLY) :param depth: controls the whether to return soft-deleted items or not. (Default value = None) :param additional_fields: the extra properties we want on the return objects. Default is no properties. @@ -8135,35 +7887,17 @@

      Examples

      :return: a generator for the returned personas """ + from ..services import FindPeople folder = self._get_single_folder() - if not folder: - return if q.is_never(): log.debug('Query will never return results') return - if shape not in SHAPE_CHOICES: - raise ValueError("'shape' %s must be one of %s" % (shape, SHAPE_CHOICES)) - if depth is None: - depth = self._get_default_item_traversal_depth() - if depth not in ITEM_TRAVERSAL_CHOICES: - raise ValueError("'depth' %s must be one of %s" % (depth, ITEM_TRAVERSAL_CHOICES)) - if additional_fields: - for f in additional_fields: - Persona.validate_field(field=f, version=self.account.version) - if f.field.is_complex: - raise ValueError("find_people() does not support field '%s'" % f.field.name) + depth, restriction, query_string = self._rinse_args( + q=q, depth=depth, additional_fields=additional_fields, + field_validator=Persona.validate_field + ) - # Build up any restrictions - if q.is_empty(): - restriction = None - query_string = None - elif q.query_string: - restriction = None - query_string = Restriction(q, folders=[folder], applies_to=Restriction.ITEMS) - else: - restriction = Restriction(q, folders=[folder], applies_to=Restriction.ITEMS) - query_string = None - yield from FindPeople(account=self.account, chunk_size=page_size).call( + yield from FindPeople(account=self.account, page_size=page_size).call( folder=folder, additional_fields=additional_fields, restriction=restriction, @@ -8176,19 +7910,6 @@

      Examples

      )
      -
      -def get(self, *args, **kwargs) -
      -
      -
      -
      - -Expand source code - -
      def get(self, *args, **kwargs):
      -    return QuerySet(self).get(*args, **kwargs)
      -
      -
      def get_folder_fields(self, target_cls, is_complex=None)
      @@ -8215,6 +7936,7 @@

      Examples

      Expand source code
      def get_folders(self, additional_fields=None):
      +    from ..services import GetFolder
           # Expand folders with their full set of properties
           from .base import BaseFolder
           if not self.folders:
      @@ -8236,8 +7958,8 @@ 

      Examples

      )
      -
      -def none(self) +
      +def pull_subscription(self, **kwargs)
      @@ -8245,12 +7967,12 @@

      Examples

      Expand source code -
      def none(self):
      -    return QuerySet(self).none()
      +
      def pull_subscription(self, **kwargs):
      +    return PullSubscription(folder=self, **kwargs)
      -
      -def people(self) +
      +def push_subscription(self, **kwargs)
      @@ -8258,8 +7980,8 @@

      Examples

      Expand source code -
      def people(self):
      -    return QuerySet(self).people()
      +
      def push_subscription(self, **kwargs):
      +    return PushSubscription(folder=self, **kwargs)
      @@ -8288,8 +8010,21 @@

      Examples

      )
      +
      +def streaming_subscription(self, **kwargs) +
      +
      +
      +
      + +Expand source code + +
      def streaming_subscription(self, **kwargs):
      +    return StreamingSubscription(folder=self, **kwargs)
      +
      +
      -def subscribe_to_pull(self, event_types=('CopiedEvent', 'CreatedEvent', 'DeletedEvent', 'ModifiedEvent', 'MovedEvent', 'NewMailEvent', 'FreeBusyChangedEvent'), watermark=None, timeout=60) +def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60)
      @@ -8297,17 +8032,20 @@

      Examples

      Expand source code -
      def subscribe_to_pull(self, event_types=SubscribeToPull.EVENT_TYPES, watermark=None, timeout=60):
      +
      def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60):
      +    from ..services import SubscribeToPull
           if not self.folders:
               log.debug('Folder list is empty')
      -        return
      -    yield from SubscribeToPull(account=self.account).call(
      +        return None
      +    if event_types is None:
      +        event_types = SubscribeToPull.EVENT_TYPES
      +    return SubscribeToPull(account=self.account).get(
               folders=self.folders, event_types=event_types, watermark=watermark, timeout=timeout,
           )
      -def subscribe_to_push(self, callback_url, event_types=('CopiedEvent', 'CreatedEvent', 'DeletedEvent', 'ModifiedEvent', 'MovedEvent', 'NewMailEvent', 'FreeBusyChangedEvent'), watermark=None, status_frequency=1) +def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1)
      @@ -8315,19 +8053,21 @@

      Examples

      Expand source code -
      def subscribe_to_push(self, callback_url, event_types=SubscribeToPush.EVENT_TYPES, watermark=None,
      -                      status_frequency=1):
      +
      def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1):
      +    from ..services import SubscribeToPush
           if not self.folders:
               log.debug('Folder list is empty')
      -        return
      -    yield from SubscribeToPush(account=self.account).call(
      +        return None
      +    if event_types is None:
      +        event_types = SubscribeToPush.EVENT_TYPES
      +    return SubscribeToPush(account=self.account).get(
               folders=self.folders, event_types=event_types, watermark=watermark, status_frequency=status_frequency,
               url=callback_url,
           )
      -def subscribe_to_streaming(self, event_types=('CopiedEvent', 'CreatedEvent', 'DeletedEvent', 'ModifiedEvent', 'MovedEvent', 'NewMailEvent', 'FreeBusyChangedEvent')) +def subscribe_to_streaming(self, event_types=None)
      @@ -8335,11 +8075,14 @@

      Examples

      Expand source code -
      def subscribe_to_streaming(self, event_types=SubscribeToPush.EVENT_TYPES):
      +
      def subscribe_to_streaming(self, event_types=None):
      +    from ..services import SubscribeToStreaming
           if not self.folders:
               log.debug('Folder list is empty')
      -        return
      -    yield from SubscribeToStreaming(account=self.account).call(folders=self.folders, event_types=event_types)
      + return None + if event_types is None: + event_types = SubscribeToStreaming.EVENT_TYPES + return SubscribeToStreaming(account=self.account).get(folders=self.folders, event_types=event_types)
      @@ -8352,17 +8095,19 @@

      Examples

      Expand source code
      def sync_hierarchy(self, sync_state=None, only_fields=None):
      +    from ..services import SyncFolderHierarchy
           folder = self._get_single_folder()
      -    if not folder:
      -        return
           if only_fields is None:
               # We didn't restrict list of field paths. Get all fields from the server, including extended properties.
               additional_fields = {FieldPath(field=f) for f in folder.supported_fields(version=self.account.version)}
           else:
      -        for f in only_fields:
      -            folder.validate_field(field=f, version=self.account.version)
      -        # Remove ItemId and ChangeKey. We get them unconditionally
      -        additional_fields = {f for f in folder.normalize_fields(fields=only_fields) if not f.field.is_attribute}
      +        additional_fields = set()
      +        for field_name in only_fields:
      +            folder.validate_field(field=field_name, version=self.account.version)
      +            f = folder.get_field_by_fieldname(fieldname=field_name)
      +            if not f.is_attribute:
      +                # Remove ItemId and ChangeKey. We get them unconditionally
      +                additional_fields.add(FieldPath(field=f))
       
           # Add required fields
           additional_fields.update(
      @@ -8396,9 +8141,8 @@ 

      Examples

      Expand source code
      def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None):
      +    from ..services import SyncFolderItems
           folder = self._get_single_folder()
      -    if not folder:
      -        return
           if only_fields is None:
               # We didn't restrict list of field paths. Get all fields from the server, including extended properties.
               additional_fields = {FieldPath(field=f) for f in folder.allowed_item_fields(version=self.account.version)}
      @@ -8428,6 +8172,32 @@ 

      Examples

      raise SyncCompleted(sync_state=svc.sync_state)
      +
      +def unsubscribe(self, subscription_id) +
      +
      +

      Unsubscribe. Only applies to pull and streaming notifications.

      +

      :param subscription_id: A subscription ID as acquired by .subscribe_to_pull|streaming +:return: True

      +

      This method doesn't need the current collection instance, but it makes sense to keep the method along the other +sync methods.

      +
      + +Expand source code + +
      def unsubscribe(self, subscription_id):
      +    """Unsubscribe. Only applies to pull and streaming notifications.
      +
      +    :param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|streaming]()
      +    :return: True
      +
      +    This method doesn't need the current collection instance, but it makes sense to keep the method along the other
      +    sync methods.
      +    """
      +    from ..services import Unsubscribe
      +    return Unsubscribe(account=self.account).get(subscription_id=subscription_id)
      +
      +
      def validate_item_field(self, field, version)
      @@ -8438,7 +8208,8 @@

      Examples

      Expand source code
      def validate_item_field(self, field, version):
      -    # For each field, check if the field is valid for any of the item models supported by this folder
      +    # Takes a fieldname, Field or FieldPath object pointing to an item field, and checks that it is valid
      +    # for the item types supported by this folder collection.
           for item_model in self.supported_item_models:
               try:
                   item_model.validate_field(field=field, version=version)
      @@ -8446,7 +8217,7 @@ 

      Examples

      except InvalidField: continue else: - raise InvalidField("%r is not a valid field on %s" % (field, self.supported_item_models))
      + raise InvalidField(f"{field!r} is not a valid field on {self.supported_item_models}")
      @@ -8494,6 +8265,18 @@

      Examples

      +

      Inherited members

      +
      class ForwardItem @@ -8609,10 +8392,7 @@

      Inherited members

      self.sid = sid def __eq__(self, other): - for k in self.__dict__: - if getattr(self, k) != getattr(other, k): - return False - return True + return all(getattr(self, k) == getattr(other, k) for k in self.__dict__) def __hash__(self): return hash(repr(self)) @@ -8645,13 +8425,14 @@

      Inherited members

      @property def item(self): from .folders import BaseFolder + from .services import GetAttachment if self.attachment_id is None: return self._item if self._item is not None: return self._item # We have an ID to the data but still haven't called GetAttachment to get the actual data. Do that now. if not self.parent_item or not self.parent_item.account: - raise ValueError('%s must have an account' % self.__class__.__name__) + raise ValueError(f'{self.__class__.__name__} must have an account') additional_fields = { FieldPath(field=f) for f in BaseFolder.allowed_item_fields(version=self.parent_item.account.version) } @@ -8666,7 +8447,7 @@

      Inherited members

      def item(self, value): from .items import Item if not isinstance(value, Item): - raise ValueError("'value' %r must be an Item object" % value) + raise InvalidTypeError('value', value, Item) self._item = value @classmethod @@ -8724,13 +8505,14 @@

      Instance variables

      @property
       def item(self):
           from .folders import BaseFolder
      +    from .services import GetAttachment
           if self.attachment_id is None:
               return self._item
           if self._item is not None:
               return self._item
           # We have an ID to the data but still haven't called GetAttachment to get the actual data. Do that now.
           if not self.parent_item or not self.parent_item.account:
      -        raise ValueError('%s must have an account' % self.__class__.__name__)
      +        raise ValueError(f'{self.__class__.__name__} must have an account')
           additional_fields = {
               FieldPath(field=f) for f in BaseFolder.allowed_item_fields(version=self.parent_item.account.version)
           }
      @@ -8875,7 +8657,7 @@ 

      Inherited members

      # A OneOff Mailbox (a one-off member of a personal distribution list) may lack these fields, but other # Mailboxes require at least one. See also "Remarks" section of # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailbox - raise ValueError("Mailbox type %r must have either 'email_address' or 'item_id' set" % self.mailbox_type) + raise ValueError(f"Mailbox type {self.mailbox_type!r} must have either 'email_address' or 'item_id' set") def __hash__(self): # Exchange may add 'mailbox_type' and 'name' on insert. We're satisfied if the item_id or email address matches. @@ -8965,7 +8747,7 @@

      Methods

      # A OneOff Mailbox (a one-off member of a personal distribution list) may lack these fields, but other # Mailboxes require at least one. See also "Remarks" section of # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailbox - raise ValueError("Mailbox type %r must have either 'email_address' or 'item_id' set" % self.mailbox_type)
      + raise ValueError(f"Mailbox type {self.mailbox_type!r} must have either 'email_address' or 'item_id' set")
      @@ -9032,6 +8814,7 @@

      Inherited members

      @require_account def send(self, save_copy=True, copy_to_folder=None, conflict_resolution=AUTO_RESOLVE, send_meeting_invitations=SEND_TO_NONE): + from ..services import SendItem # Only sends a message. The message can either be an existing draft stored in EWS or a new message that does # not yet exist in EWS. if copy_to_folder and not save_copy: @@ -9081,12 +8864,10 @@

      Inherited members

      self.send(save_copy=False, conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations) else: - res = self._create( + self._create( message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations ) - if res is not True: - raise ValueError('Unexpected response in send-only mode') @require_id def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): @@ -9139,6 +8920,7 @@

      Inherited members

      :param move_item: If true, the item will be moved to the junk folder. :return: """ + from ..services import MarkAsJunk res = MarkAsJunk(account=self.account).get( items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item ) @@ -9311,6 +9093,7 @@

      Methods

      :param move_item: If true, the item will be moved to the junk folder. :return: """ + from ..services import MarkAsJunk res = MarkAsJunk(account=self.account).get( items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item ) @@ -9364,6 +9147,7 @@

      Methods

      @require_account
       def send(self, save_copy=True, copy_to_folder=None, conflict_resolution=AUTO_RESOLVE,
                send_meeting_invitations=SEND_TO_NONE):
      +    from ..services import SendItem
           # Only sends a message. The message can either be an existing draft stored in EWS or a new message that does
           # not yet exist in EWS.
           if copy_to_folder and not save_copy:
      @@ -9423,12 +9207,10 @@ 

      Methods

      self.send(save_copy=False, conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations) else: - res = self._create( + self._create( message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations - ) - if res is not True: - raise ValueError('Unexpected response in send-only mode')
      + )
      @@ -9559,7 +9341,7 @@

      Methods

      super().__init__(**kwargs) self.authorization_code = authorization_code if access_token is not None and not isinstance(access_token, dict): - raise ValueError("'access_token' must be an OAuth2Token") + raise InvalidTypeError('access_token', access_token, OAuth2Token) self.access_token = access_token def __repr__(self): @@ -9647,7 +9429,7 @@

      Inherited members

      """ # Ensure we don't update the object in the middle of a new session being created, which could cause a race. if not isinstance(access_token, dict): - raise ValueError("'access_token' must be an OAuth2Token") + raise InvalidTypeError('access_token', access_token, OAuth2Token) with self.lock: log.debug('%s auth token for %s', 'Refreshing' if self.access_token else 'Setting', self.client_id) self.access_token = access_token @@ -9709,7 +9491,7 @@

      Methods

      """ # Ensure we don't update the object in the middle of a new session being created, which could cause a race. if not isinstance(access_token, dict): - raise ValueError("'access_token' must be an OAuth2Token") + raise InvalidTypeError('access_token', access_token, OAuth2Token) with self.lock: log.debug('%s auth token for %s', 'Refreshing' if self.access_token else 'Setting', self.client_id) self.access_token = access_token
      @@ -9781,13 +9563,13 @@

      Inherited members

      super().clean(version=version) if self.state == self.SCHEDULED: if not self.start or not self.end: - raise ValueError("'start' and 'end' must be set when state is '%s'" % self.SCHEDULED) + raise ValueError(f"'start' and 'end' must be set when state is {self.SCHEDULED!r}") if self.start >= self.end: raise ValueError("'start' must be before 'end'") if self.end < datetime.datetime.now(tz=UTC): raise ValueError("'end' must be in the future") if self.state != self.DISABLED and (not self.internal_reply or not self.external_reply): - raise ValueError("'internal_reply' and 'external_reply' must be set when state is not '%s'" % self.DISABLED) + raise ValueError(f"'internal_reply' and 'external_reply' must be set when state is not {self.DISABLED!r}") @classmethod def from_xml(cls, elem, account): @@ -9801,28 +9583,26 @@

      Inherited members

      def to_xml(self, version): self.clean(version=version) - elem = create_element('t:%s' % self.REQUEST_ELEMENT_NAME) + elem = create_element(f't:{self.REQUEST_ELEMENT_NAME}') for attr in ('state', 'external_audience'): value = getattr(self, attr) - if value is None: - continue f = self.get_field_by_fieldname(attr) - set_xml_value(elem, f.to_xml(value, version=version), version=version) + set_xml_value(elem, f.to_xml(value, version=version)) if self.start or self.end: duration = create_element('t:Duration') if self.start: f = self.get_field_by_fieldname('start') - set_xml_value(duration, f.to_xml(self.start, version=version), version) + set_xml_value(duration, f.to_xml(self.start, version=version)) if self.end: f = self.get_field_by_fieldname('end') - set_xml_value(duration, f.to_xml(self.end, version=version), version) + set_xml_value(duration, f.to_xml(self.end, version=version)) elem.append(duration) for attr in ('internal_reply', 'external_reply'): value = getattr(self, attr) if value is None: value = '' # The value can be empty, but the XML element must always be present f = self.get_field_by_fieldname(attr) - set_xml_value(elem, f.to_xml(value, version=version), version) + set_xml_value(elem, f.to_xml(value, version=version)) return elem def __hash__(self): @@ -9937,13 +9717,13 @@

      Methods

      super().clean(version=version) if self.state == self.SCHEDULED: if not self.start or not self.end: - raise ValueError("'start' and 'end' must be set when state is '%s'" % self.SCHEDULED) + raise ValueError(f"'start' and 'end' must be set when state is {self.SCHEDULED!r}") if self.start >= self.end: raise ValueError("'start' must be before 'end'") if self.end < datetime.datetime.now(tz=UTC): raise ValueError("'end' must be in the future") if self.state != self.DISABLED and (not self.internal_reply or not self.external_reply): - raise ValueError("'internal_reply' and 'external_reply' must be set when state is not '%s'" % self.DISABLED)
      + raise ValueError(f"'internal_reply' and 'external_reply' must be set when state is not {self.DISABLED!r}")
      @@ -9957,28 +9737,26 @@

      Methods

      def to_xml(self, version):
           self.clean(version=version)
      -    elem = create_element('t:%s' % self.REQUEST_ELEMENT_NAME)
      +    elem = create_element(f't:{self.REQUEST_ELEMENT_NAME}')
           for attr in ('state', 'external_audience'):
               value = getattr(self, attr)
      -        if value is None:
      -            continue
               f = self.get_field_by_fieldname(attr)
      -        set_xml_value(elem, f.to_xml(value, version=version), version=version)
      +        set_xml_value(elem, f.to_xml(value, version=version))
           if self.start or self.end:
               duration = create_element('t:Duration')
               if self.start:
                   f = self.get_field_by_fieldname('start')
      -            set_xml_value(duration, f.to_xml(self.start, version=version), version)
      +            set_xml_value(duration, f.to_xml(self.start, version=version))
               if self.end:
                   f = self.get_field_by_fieldname('end')
      -            set_xml_value(duration, f.to_xml(self.end, version=version), version)
      +            set_xml_value(duration, f.to_xml(self.end, version=version))
               elem.append(duration)
           for attr in ('internal_reply', 'external_reply'):
               value = getattr(self, attr)
               if value is None:
                   value = ''  # The value can be empty, but the XML element must always be present
               f = self.get_field_by_fieldname(attr)
      -        set_xml_value(elem, f.to_xml(value, version=version), version)
      +        set_xml_value(elem, f.to_xml(value, version=version))
           return elem
      @@ -10104,13 +9882,13 @@

      Inherited members

      (*args, **kwargs)
      -

      A class with an API similar to Django Q objects. Used to implemnt advanced filtering logic.

      +

      A class with an API similar to Django Q objects. Used to implement advanced filtering logic.

      Expand source code
      class Q:
      -    """A class with an API similar to Django Q objects. Used to implemnt advanced filtering logic."""
      +    """A class with an API similar to Django Q objects. Used to implement advanced filtering logic."""
       
           # Connection types
           AND = 'AND'
      @@ -10176,7 +9954,7 @@ 

      Inherited members

      # Parse args which must now be Q objects for q in args: if not isinstance(q, self.__class__): - raise ValueError("Non-keyword arg %r must be a Q instance" % q) + raise TypeError(f"Non-keyword arg {q!r} must be of type {Q}") self.children.extend(args) # Parse keyword args and extract the filter @@ -10209,10 +9987,10 @@

      Inherited members

      # EWS doesn't have a 'range' operator. Emulate 'foo__range=(1, 2)' as 'foo__gte=1 and foo__lte=2' # (both values inclusive). if len(value) != 2: - raise ValueError("Value of lookup '%s' must have exactly 2 elements" % key) + raise ValueError(f"Value of lookup {key!r} must have exactly 2 elements") return ( - self.__class__(**{'%s__gte' % field_path: value[0]}), - self.__class__(**{'%s__lte' % field_path: value[1]}), + self.__class__(**{f'{field_path}__gte': value[0]}), + self.__class__(**{f'{field_path}__lte': value[1]}), ) # Filtering on list types is a bit quirky. The only lookup type I have found to work is: @@ -10235,7 +10013,7 @@

      Inherited members

      # EWS doesn't have an '__in' operator. Allow '__in' lookups on list and non-list field types, # specifying a list value. We'll emulate it as a set of OR'ed exact matches. if not is_iterable(value, generators_allowed=True): - raise ValueError("Value for lookup %r must be a list" % key) + raise TypeError(f"Value for lookup {key!r} must be of type {list}") children = tuple(self.__class__(**{field_path: v}) for v in value) if not children: # This is an '__in' operator with an empty list as the value. We interpret it to mean "is foo @@ -10255,7 +10033,7 @@

      Inherited members

      try: op = self._lookup_to_op(lookup) except KeyError: - raise ValueError("Lookup '%s' is not supported (called as '%s=%r')" % (lookup, key, value)) + raise ValueError(f"Lookup {lookup!r} is not supported (called as '{key}={value!r}')") else: field_path, op = key, self.EQ @@ -10358,7 +10136,7 @@

      Inherited members

      return create_element(xml_tag_map[op]) valid_ops = cls.EXACT, cls.IEXACT, cls.CONTAINS, cls.ICONTAINS, cls.STARTSWITH, cls.ISTARTSWITH if op not in valid_ops: - raise ValueError("'op' %s must be one of %s" % (op, valid_ops)) + raise InvalidEnumValue('op', op, valid_ops) # For description of Contains attribute values, see # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/contains @@ -10388,17 +10166,14 @@

      Inherited members

      elif op in (cls.STARTSWITH, cls.ISTARTSWITH): match_mode = 'Prefixed' else: - raise ValueError('Unsupported op: %s' % op) + raise ValueError(f'Unsupported op: {op}') if op in (cls.IEXACT, cls.ICONTAINS, cls.ISTARTSWITH): compare_mode = 'IgnoreCase' else: compare_mode = 'Exact' return create_element( 't:Contains', - attrs=OrderedDict([ - ('ContainmentMode', match_mode), - ('ContainmentComparison', compare_mode), - ]) + attrs=dict(ContainmentMode=match_mode, ContainmentComparison=compare_mode) ) def is_leaf(self): @@ -10420,18 +10195,18 @@

      Inherited members

      if self.query_string: return self.query_string if self.is_leaf(): - expr = '%s %s %r' % (self.field_path, self.op, self.value) + expr = f'{self.field_path} {self.op} {self.value!r}' else: # Sort children by field name so we get stable output (for easier testing). Children should never be empty. - expr = (' %s ' % (self.AND if self.conn_type == self.NOT else self.conn_type)).join( - (c.expr() if c.is_leaf() or c.conn_type == self.NOT else '(%s)' % c.expr()) + expr = f' {self.AND if self.conn_type == self.NOT else self.conn_type} '.join( + (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f'({c.expr()})') for c in sorted(self.children, key=lambda i: i.field_path or '') ) if self.conn_type == self.NOT: # Add the NOT operator. Put children in parens if there is more than one child. if self.is_leaf() or len(self.children) == 1: - return self.conn_type + ' %s' % expr - return self.conn_type + ' (%s)' % expr + return self.conn_type + f' {expr}' + return self.conn_type + f' ({expr})' return expr def to_xml(self, folders, version, applies_to): @@ -10462,25 +10237,23 @@

      Inherited members

      raise ValueError('Query strings cannot be combined with other settings') return if self.conn_type not in self.CONN_TYPES: - raise ValueError("'conn_type' %s must be one of %s" % (self.conn_type, self.CONN_TYPES)) + raise InvalidEnumValue('conn_type', self.conn_type, self.CONN_TYPES) if not self.is_leaf(): for q in self.children: if q.query_string and len(self.children) > 1: - raise ValueError( - 'A query string cannot be combined with other restrictions' - ) + raise ValueError('A query string cannot be combined with other restrictions') return if not self.field_path: raise ValueError("'field_path' must be set") if self.op not in self.OP_TYPES: - raise ValueError("'op' %s must be one of %s" % (self.op, self.OP_TYPES)) + raise InvalidEnumValue('op', self.op, self.OP_TYPES) if self.op == self.EXISTS and self.value is not True: raise ValueError("'value' must be True when operator is EXISTS") if self.value is None: - raise ValueError('Value for filter on field path "%s" cannot be None' % self.field_path) + raise ValueError(f'Value for filter on field path {self.field_path!r} cannot be None') if is_iterable(self.value, generators_allowed=True): raise ValueError( - 'Value %r for filter on field path "%s" must be a single value' % (self.value, self.field_path) + f'Value {self.value!r} for filter on field path {self.field_path!r} must be a single value' ) def _validate_field_path(self, field_path, folder, applies_to, version): @@ -10491,11 +10264,11 @@

      Inherited members

      else: folder.validate_item_field(field=field_path.field, version=version) if not field_path.field.is_searchable: - raise ValueError("EWS does not support filtering on field '%s'" % field_path.field.name) + raise ValueError(f"EWS does not support filtering on field {field_path.field.name!r}") if field_path.subfield and not field_path.subfield.is_searchable: - raise ValueError("EWS does not support filtering on subfield '%s'" % field_path.subfield.name) + raise ValueError(f"EWS does not support filtering on subfield {field_path.subfield.name!r}") if issubclass(field_path.field.value_cls, MultiFieldIndexedElement) and not field_path.subfield: - raise ValueError("Field path '%s' must contain a subfield" % self.field_path) + raise ValueError(f"Field path {self.field_path!r} must contain a subfield") def _get_field_path(self, folders, applies_to, version): # Convert the string field path to a real FieldPath object. The path is validated using the given folders. @@ -10512,7 +10285,7 @@

      Inherited members

      self._validate_field_path(field_path=field_path, folder=folder, applies_to=applies_to, version=version) break else: - raise InvalidField("Unknown field path %r on folders %s" % (self.field_path, folders)) + raise InvalidField(f"Unknown field path {self.field_path!r} on folders {folders}") return field_path def _get_clean_value(self, field_path, version): @@ -10548,10 +10321,8 @@

      Inherited members

      # We need to convert to datetime clean_value = field_path.field.date_to_datetime(clean_value) elem.append(field_path.to_xml()) - constant = create_element('t:Constant') if self.op != self.EXISTS: - # Use .set() to not fill up the create_element() cache with unique values - constant.set('Value', value_to_xml_text(clean_value)) + constant = create_element('t:Constant', attrs=dict(Value=value_to_xml_text(clean_value))) if self.op in self.CONTAINS_OPS: elem.append(constant) else: @@ -10622,10 +10393,10 @@

      Inherited members

      def __repr__(self): if self.is_leaf(): if self.query_string: - return self.__class__.__name__ + '(%r)' % self.query_string + return self.__class__.__name__ + f'({self.query_string!r})' if self.is_never(): - return self.__class__.__name__ + '(conn_type=%r)' % (self.conn_type) - return self.__class__.__name__ + '(%s %s %r)' % (self.field_path, self.op, self.value) + return self.__class__.__name__ + f'(conn_type={self.conn_type!r})' + return self.__class__.__name__ + f'({self.field_path} {self.op} {self.value!r})' sorted_children = tuple(sorted(self.children, key=lambda i: i.field_path or '')) if self.conn_type == self.NOT or len(self.children) > 1: return self.__class__.__name__ + repr((self.conn_type,) + sorted_children) @@ -10838,18 +10609,18 @@

      Methods

      if self.query_string: return self.query_string if self.is_leaf(): - expr = '%s %s %r' % (self.field_path, self.op, self.value) + expr = f'{self.field_path} {self.op} {self.value!r}' else: # Sort children by field name so we get stable output (for easier testing). Children should never be empty. - expr = (' %s ' % (self.AND if self.conn_type == self.NOT else self.conn_type)).join( - (c.expr() if c.is_leaf() or c.conn_type == self.NOT else '(%s)' % c.expr()) + expr = f' {self.AND if self.conn_type == self.NOT else self.conn_type} '.join( + (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f'({c.expr()})') for c in sorted(self.children, key=lambda i: i.field_path or '') ) if self.conn_type == self.NOT: # Add the NOT operator. Put children in parens if there is more than one child. if self.is_leaf() or len(self.children) == 1: - return self.conn_type + ' %s' % expr - return self.conn_type + ' (%s)' % expr + return self.conn_type + f' {expr}' + return self.conn_type + f' ({expr})' return expr
      @@ -10967,10 +10738,8 @@

      Methods

      # We need to convert to datetime clean_value = field_path.field.date_to_datetime(clean_value) elem.append(field_path.to_xml()) - constant = create_element('t:Constant') if self.op != self.EXISTS: - # Use .set() to not fill up the create_element() cache with unique values - constant.set('Value', value_to_xml_text(clean_value)) + constant = create_element('t:Constant', attrs=dict(Value=value_to_xml_text(clean_value))) if self.op in self.CONTAINS_OPS: elem.append(constant) else: @@ -11097,12 +10866,12 @@

      Inherited members

      @classmethod def from_xml(cls, elem, account): - id_elem = elem.find('{%s}Id' % TNS) + id_elem = elem.find(f'{{{TNS}}}Id') item_id_elem = id_elem.find(ItemId.response_tag()) kwargs = dict( - name=get_xml_attr(id_elem, '{%s}Name' % TNS), - email_address=get_xml_attr(id_elem, '{%s}EmailAddress' % TNS), - mailbox_type=get_xml_attr(id_elem, '{%s}MailboxType' % TNS), + name=get_xml_attr(id_elem, f'{{{TNS}}}Name'), + email_address=get_xml_attr(id_elem, f'{{{TNS}}}EmailAddress'), + mailbox_type=get_xml_attr(id_elem, f'{{{TNS}}}MailboxType'), item_id=ItemId.from_xml(elem=item_id_elem, account=account) if item_id_elem else None, ) cls._clear(elem) @@ -11133,12 +10902,12 @@

      Static methods

      @classmethod
       def from_xml(cls, elem, account):
      -    id_elem = elem.find('{%s}Id' % TNS)
      +    id_elem = elem.find(f'{{{TNS}}}Id')
           item_id_elem = id_elem.find(ItemId.response_tag())
           kwargs = dict(
      -        name=get_xml_attr(id_elem, '{%s}Name' % TNS),
      -        email_address=get_xml_attr(id_elem, '{%s}EmailAddress' % TNS),
      -        mailbox_type=get_xml_attr(id_elem, '{%s}MailboxType' % TNS),
      +        name=get_xml_attr(id_elem, f'{{{TNS}}}Name'),
      +        email_address=get_xml_attr(id_elem, f'{{{TNS}}}EmailAddress'),
      +        mailbox_type=get_xml_attr(id_elem, f'{{{TNS}}}MailboxType'),
               item_id=ItemId.from_xml(elem=item_id_elem, account=account) if item_id_elem else None,
           )
           cls._clear(elem)
      @@ -11178,7 +10947,7 @@ 

      Inherited members

      def response_tag(cls): # In a GetRoomLists response, room lists are delivered as Address elements. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/address-emailaddresstype - return '{%s}Address' % TNS
      + return f'{{{TNS}}}Address'

      Ancestors

        @@ -11211,7 +10980,7 @@

        Static methods

        def response_tag(cls): # In a GetRoomLists response, room lists are delivered as Address elements. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/address-emailaddresstype - return '{%s}Address' % TNS + return f'{{{TNS}}}Address' @@ -11289,7 +11058,7 @@

        Inherited members

        def get_folder(self, folder): if not folder.id: raise ValueError("'folder' must have an ID") - return self._folders_map.get(folder.id, None) + return self._folders_map.get(folder.id) def add_folder(self, folder): if not folder.id: @@ -11327,21 +11096,21 @@

        Inherited members

        :param account: """ if not cls.DISTINGUISHED_FOLDER_ID: - raise ValueError('Class %s must have a DISTINGUISHED_FOLDER_ID value' % cls) + raise ValueError(f'Class {cls} must have a DISTINGUISHED_FOLDER_ID value') try: return cls.resolve( account=account, folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound('Could not find distinguished folder %s' % cls.DISTINGUISHED_FOLDER_ID) + raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}') def get_default_folder(self, folder_cls): """Return the distinguished folder instance of type folder_cls belonging to this account. If no distinguished folder was found, try as best we can to return the default folder of type 'folder_cls' """ if not folder_cls.DISTINGUISHED_FOLDER_ID: - raise ValueError("'folder_cls' %s must have a DISTINGUISHED_FOLDER_ID value" % folder_cls) + raise ValueError(f"'folder_cls' {folder_cls} must have a DISTINGUISHED_FOLDER_ID value") # Use cached distinguished folder instance, but only if cache has already been prepped. This is an optimization # for accessing e.g. 'account.contacts' without fetching all folders of the account. if self._subfolders is not None: @@ -11362,7 +11131,7 @@

        Inherited members

        except MISSING_FOLDER_ERRORS: # The Exchange server does not return a distinguished folder of this type pass - raise ErrorFolderNotFound('No usable default %s folders' % folder_cls) + raise ErrorFolderNotFound(f'No usable default {folder_cls} folders') @property def _folders_map(self): @@ -11399,6 +11168,9 @@

        Inherited members

        if isinstance(f, ErrorAccessDenied): # We may not have FindFolder access, or GetFolder access, either to this folder or at all continue + if isinstance(f, MISSING_FOLDER_ERRORS): + # We were unlucky. The folder disappeared between the FindFolder and the GetFolder calls + continue if isinstance(f, Exception): raise f if f.id in folders_map: @@ -11516,59 +11288,23 @@

        Static methods

        :param account: """ if not cls.DISTINGUISHED_FOLDER_ID: - raise ValueError('Class %s must have a DISTINGUISHED_FOLDER_ID value' % cls) + raise ValueError(f'Class {cls} must have a DISTINGUISHED_FOLDER_ID value') try: return cls.resolve( account=account, folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound('Could not find distinguished folder %s' % cls.DISTINGUISHED_FOLDER_ID) + raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}')

        Instance variables

        -
        var account
        -
        -
        -
        - -Expand source code - -
        @property
        -def account(self):
        -    return self._account
        -
        -
        var effective_rights
        -
        var parent
        -
        -
        -
        - -Expand source code - -
        @property
        -def parent(self):
        -    return None
        -
        -
        -
        var root
        -
        -
        -
        - -Expand source code - -
        @property
        -def root(self):
        -    return self
        -
        -

        Methods

        @@ -11633,7 +11369,7 @@

        Methods

        folder was found, try as best we can to return the default folder of type 'folder_cls' """ if not folder_cls.DISTINGUISHED_FOLDER_ID: - raise ValueError("'folder_cls' %s must have a DISTINGUISHED_FOLDER_ID value" % folder_cls) + raise ValueError(f"'folder_cls' {folder_cls} must have a DISTINGUISHED_FOLDER_ID value") # Use cached distinguished folder instance, but only if cache has already been prepped. This is an optimization # for accessing e.g. 'account.contacts' without fetching all folders of the account. if self._subfolders is not None: @@ -11654,7 +11390,7 @@

        Methods

        except MISSING_FOLDER_ERRORS: # The Exchange server does not return a distinguished folder of this type pass - raise ErrorFolderNotFound('No usable default %s folders' % folder_cls) + raise ErrorFolderNotFound(f'No usable default {folder_cls} folders')
        @@ -11669,7 +11405,7 @@

        Methods

        def get_folder(self, folder):
             if not folder.id:
                 raise ValueError("'folder' must have an ID")
        -    return self._folders_map.get(folder.id, None)
        + return self._folders_map.get(folder.id)
        @@ -11711,16 +11447,25 @@

        Inherited members

      • BaseFolder:
        • ID_ELEMENT_CLS
        • +
        • account
        • add_field
        • +
        • all
        • deregister
        • +
        • exclude
        • +
        • filter
        • folder_cls_from_container_class
        • folder_sync_state
        • +
        • get
        • get_events
        • get_streaming_events
        • is_distinguished
        • item_sync_state
        • +
        • none
        • +
        • parent
        • +
        • people
        • register
        • remove_field
        • +
        • root
        • subscribe_to_pull
        • subscribe_to_push
        • subscribe_to_streaming
        • @@ -12183,7 +11928,7 @@

          Inherited members

          # https://stackoverflow.com/questions/33757805 def __new__(cls, uid): - payload = binascii.hexlify(bytearray('vCal-Uid\x01\x00\x00\x00{}\x00'.format(uid).encode('ascii'))) + payload = binascii.hexlify(bytearray(f'vCal-Uid\x01\x00\x00\x00{uid}\x00'.encode('ascii'))) length = binascii.hexlify(bytearray(struct.pack('<I', int(len(payload)/2)))) encoding = b''.join([ cls._HEADER, cls._EXCEPTION_REPLACEMENT_TIME, cls._CREATION_TIME, cls._RESERVED, length, payload @@ -12234,15 +11979,17 @@

          Static methods

          __slots__ = 'build', 'api_version' def __init__(self, build, api_version=None): - if not isinstance(build, (Build, type(None))): - raise ValueError("'build' must be a Build instance") - self.build = build if api_version is None: + if not isinstance(build, Build): + raise InvalidTypeError('build', build, Build) self.api_version = build.api_version() else: + if not isinstance(build, (Build, type(None))): + raise InvalidTypeError('build', build, Build) if not isinstance(api_version, str): - raise ValueError("'api_version' must be a string") + raise InvalidTypeError('api_version', api_version, str) self.api_version = api_version + self.build = build @property def fullname(self): @@ -12276,10 +12023,10 @@

          Static methods

          except ResponseMessageError as e: # We may have survived long enough to get a new version if not protocol.config.version.build: - raise TransportError('No valid version headers found in response (%r)' % e) + raise TransportError(f'No valid version headers found in response ({e!r})') if not protocol.config.version.build: raise TransportError('No valid version headers found in response') - return protocol.version + return protocol.config.version @staticmethod def _is_invalid_version_string(version): @@ -12288,13 +12035,13 @@

          Static methods

          @classmethod def from_soap_header(cls, requested_api_version, header): - info = header.find('{%s}ServerVersionInfo' % TNS) + info = header.find(f'{{{TNS}}}ServerVersionInfo') if info is None: - raise TransportError('No ServerVersionInfo in header: %r' % xml_to_str(header)) + raise TransportError(f'No ServerVersionInfo in header: {xml_to_str(header)!r}') try: build = Build.from_xml(elem=info) except ValueError: - raise TransportError('Bad ServerVersionInfo in response: %r' % xml_to_str(header)) + raise TransportError(f'Bad ServerVersionInfo in response: {xml_to_str(header)!r}') # Not all Exchange servers send the Version element api_version_from_server = info.get('Version') or build.api_version() if api_version_from_server != requested_api_version: @@ -12310,6 +12057,9 @@

          Static methods

          api_version_from_server, api_version_from_server) return cls(build=build, api_version=api_version_from_server) + def copy(self): + return self.__class__(build=self.build, api_version=self.api_version) + def __eq__(self, other): if self.api_version != other.api_version: return False @@ -12323,7 +12073,7 @@

          Static methods

          return self.__class__.__name__ + repr((self.build, self.api_version)) def __str__(self): - return 'Build=%s, API=%s, Fullname=%s' % (self.build, self.api_version, self.fullname)
          + return f'Build={self.build}, API={self.api_version}, Fullname={self.fullname}'

          Static methods

          @@ -12338,13 +12088,13 @@

          Static methods

          @classmethod
           def from_soap_header(cls, requested_api_version, header):
          -    info = header.find('{%s}ServerVersionInfo' % TNS)
          +    info = header.find(f'{{{TNS}}}ServerVersionInfo')
               if info is None:
          -        raise TransportError('No ServerVersionInfo in header: %r' % xml_to_str(header))
          +        raise TransportError(f'No ServerVersionInfo in header: {xml_to_str(header)!r}')
               try:
                   build = Build.from_xml(elem=info)
               except ValueError:
          -        raise TransportError('Bad ServerVersionInfo in response: %r' % xml_to_str(header))
          +        raise TransportError(f'Bad ServerVersionInfo in response: {xml_to_str(header)!r}')
               # Not all Exchange servers send the Version element
               api_version_from_server = info.get('Version') or build.api_version()
               if api_version_from_server != requested_api_version:
          @@ -12405,10 +12155,10 @@ 

          Static methods

          except ResponseMessageError as e: # We may have survived long enough to get a new version if not protocol.config.version.build: - raise TransportError('No valid version headers found in response (%r)' % e) + raise TransportError(f'No valid version headers found in response ({e!r})') if not protocol.config.version.build: raise TransportError('No valid version headers found in response') - return protocol.version
          + return protocol.config.version
          @@ -12435,6 +12185,22 @@

          Instance variables

      • +

        Methods

        +
        +
        +def copy(self) +
        +
        +
        +
        + +Expand source code + +
        def copy(self):
        +    return self.__class__(build=self.build, api_version=self.api_version)
        +
        +
        +
  • @@ -12475,6 +12241,7 @@

    Index

  • Functions

  • @@ -12578,6 +12345,7 @@

    B
  • credentials
  • decrease_poolsize
  • get_adapter
  • +
  • get_auth_type
  • get_session
  • increase_poolsize
  • raw_session
  • @@ -12797,10 +12565,7 @@

    EWS
  • from_timezone
  • from_zoneinfo
  • fromutc
  • -
  • localize
  • localzone
  • -
  • normalize
  • -
  • timezone
  • @@ -12832,21 +12597,12 @@

    FailFast

    -
  • FaultTolerance

  • @@ -12865,40 +12621,36 @@

    Folder

  • FolderCollection

    @@ -13123,7 +12875,6 @@

  • FIELDS
  • WELLKNOWN_FOLDERS
  • -
  • account
  • add_folder
  • clear_cache
  • effective_rights
  • @@ -13133,9 +12884,7 @@

    get_default_folder

  • get_distinguished
  • get_folder
  • -
  • parent
  • remove_folder
  • -
  • root
  • update_folder
  • @@ -13193,9 +12942,10 @@

    UID

  • Version

    -
      +
      • api_version
      • build
      • +
      • copy
      • from_soap_header
      • fullname
      • guess
      • diff --git a/docs/exchangelib/indexed_properties.html b/docs/exchangelib/indexed_properties.html index 95aeb10d..1cdf12c6 100644 --- a/docs/exchangelib/indexed_properties.html +++ b/docs/exchangelib/indexed_properties.html @@ -44,10 +44,10 @@

        Module exchangelib.indexed_properties

        """Base class for all classes that implement an indexed element with a single field.""" @classmethod - def value_field(cls, version=None): + def value_field(cls, version): fields = cls.supported_fields(version=version) if len(fields) != 1: - raise ValueError('This class must have only one field (found %s)' % (fields,)) + raise ValueError(f'Class {cls} must have only one value field (found {tuple(f.name for f in fields)})') return fields[0] @@ -434,10 +434,10 @@

        Inherited members

        """Base class for all classes that implement an indexed element with a single field.""" @classmethod - def value_field(cls, version=None): + def value_field(cls, version): fields = cls.supported_fields(version=version) if len(fields) != 1: - raise ValueError('This class must have only one field (found %s)' % (fields,)) + raise ValueError(f'Class {cls} must have only one value field (found {tuple(f.name for f in fields)})') return fields[0]

        Ancestors

        @@ -453,7 +453,7 @@

        Subclasses

        Static methods

        -def value_field(version=None) +def value_field(version)
        @@ -462,10 +462,10 @@

        Static methods

        Expand source code
        @classmethod
        -def value_field(cls, version=None):
        +def value_field(cls, version):
             fields = cls.supported_fields(version=version)
             if len(fields) != 1:
        -        raise ValueError('This class must have only one field (found %s)' % (fields,))
        +        raise ValueError(f'Class {cls} must have only one value field (found {tuple(f.name for f in fields)})')
             return fields[0]
        diff --git a/docs/exchangelib/items/base.html b/docs/exchangelib/items/base.html index c05cda80..24bd2be7 100644 --- a/docs/exchangelib/items/base.html +++ b/docs/exchangelib/items/base.html @@ -28,11 +28,11 @@

        Module exchangelib.items.base

        import logging
         
        +from ..errors import InvalidTypeError
         from ..extended_properties import ExtendedProperty
         from ..fields import BooleanField, ExtendedPropertyField, BodyField, MailboxField, MailboxListField, EWSElementField, \
             CharField, IdElementField, AttachmentField, ExtendedPropertyListField
         from ..properties import InvalidField, IdChangeKeyMixIn, EWSElement, ReferenceItemId, ItemId, EWSMeta
        -from ..services import CreateItem
         from ..util import require_account
         from ..version import EXCHANGE_2007_SP1
         
        @@ -71,9 +71,9 @@ 

        Module exchangelib.items.base

        # AffectedTaskOccurrences values. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteitem -ALL_OCCURRENCIES = 'AllOccurrences' +ALL_OCCURRENCES = 'AllOccurrences' SPECIFIED_OCCURRENCE_ONLY = 'SpecifiedOccurrenceOnly' -AFFECTED_TASK_OCCURRENCES_CHOICES = (ALL_OCCURRENCIES, SPECIFIED_OCCURRENCE_ONLY) +AFFECTED_TASK_OCCURRENCES_CHOICES = (ALL_OCCURRENCES, SPECIFIED_OCCURRENCE_ONLY) # ConflictResolution values. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem @@ -107,15 +107,15 @@

        Module exchangelib.items.base

        :return: """ if not cls.INSERT_AFTER_FIELD: - raise ValueError('Class %s is missing INSERT_AFTER_FIELD value' % cls) + raise ValueError(f'Class {cls} is missing INSERT_AFTER_FIELD value') try: cls.get_field_by_fieldname(attr_name) except InvalidField: pass else: - raise ValueError("'%s' is already registered" % attr_name) + raise ValueError(f"'attr_name' {attr_name!r} is already registered") if not issubclass(attr_cls, ExtendedProperty): - raise ValueError("%r must be a subclass of ExtendedProperty" % attr_cls) + raise TypeError(f"'attr_cls' {attr_cls!r} must be a subclass of type {ExtendedProperty}") # Check if class attributes are properly defined attr_cls.validate_cls() # ExtendedProperty is not a real field, but a placeholder in the fields list. See @@ -138,9 +138,9 @@

        Module exchangelib.items.base

        try: field = cls.get_field_by_fieldname(attr_name) except InvalidField: - raise ValueError("'%s' is not registered" % attr_name) + raise ValueError(f"{attr_name!r} is not registered") if not isinstance(field, ExtendedPropertyField): - raise ValueError("'%s' is not registered as an ExtendedProperty" % attr_name) + raise ValueError(f"{attr_name} is not registered as an ExtendedProperty") cls.remove_field(field) @@ -164,11 +164,11 @@

        Module exchangelib.items.base

        from ..account import Account self.account = kwargs.pop('account', None) if self.account is not None and not isinstance(self.account, Account): - raise ValueError("'account' %r must be an Account instance" % self.account) + raise InvalidTypeError('account', self.account, Account) self.folder = kwargs.pop('folder', None) if self.folder is not None: if not isinstance(self.folder, BaseFolder): - raise ValueError("'folder' %r must be a Folder instance" % self.folder) + raise InvalidTypeError('folder', self.folder, BaseFolder) if self.folder.account is not None: if self.account is not None: # Make sure the account from kwargs matches the folder account @@ -207,11 +207,12 @@

        Module exchangelib.items.base

        from ..account import Account self.account = kwargs.pop('account', None) if self.account is not None and not isinstance(self.account, Account): - raise ValueError("'account' %r must be an Account instance" % self.account) + raise InvalidTypeError('account', self.account, Account) super().__init__(**kwargs) @require_account def send(self, save_copy=True, copy_to_folder=None): + from ..services import CreateItem if copy_to_folder and not save_copy: raise AttributeError("'save_copy' must be True when 'copy_to_folder' is set") message_disposition = SEND_AND_SAVE_COPY if save_copy else SEND_ONLY @@ -229,6 +230,7 @@

        Module exchangelib.items.base

        :param folder: :return: """ + from ..services import CreateItem return CreateItem(account=self.account).get( items=[self], folder=folder, @@ -292,11 +294,11 @@

        Classes

        from ..account import Account self.account = kwargs.pop('account', None) if self.account is not None and not isinstance(self.account, Account): - raise ValueError("'account' %r must be an Account instance" % self.account) + raise InvalidTypeError('account', self.account, Account) self.folder = kwargs.pop('folder', None) if self.folder is not None: if not isinstance(self.folder, BaseFolder): - raise ValueError("'folder' %r must be a Folder instance" % self.folder) + raise InvalidTypeError('folder', self.folder, BaseFolder) if self.folder.account is not None: if self.account is not None: # Make sure the account from kwargs matches the folder account @@ -412,11 +414,12 @@

        Inherited members

        from ..account import Account self.account = kwargs.pop('account', None) if self.account is not None and not isinstance(self.account, Account): - raise ValueError("'account' %r must be an Account instance" % self.account) + raise InvalidTypeError('account', self.account, Account) super().__init__(**kwargs) @require_account def send(self, save_copy=True, copy_to_folder=None): + from ..services import CreateItem if copy_to_folder and not save_copy: raise AttributeError("'save_copy' must be True when 'copy_to_folder' is set") message_disposition = SEND_AND_SAVE_COPY if save_copy else SEND_ONLY @@ -434,6 +437,7 @@

        Inherited members

        :param folder: :return: """ + from ..services import CreateItem return CreateItem(account=self.account).get( items=[self], folder=folder, @@ -534,6 +538,7 @@

        Methods

        :param folder: :return: """ + from ..services import CreateItem return CreateItem(account=self.account).get( items=[self], folder=folder, @@ -553,6 +558,7 @@

        Methods

        @require_account
         def send(self, save_copy=True, copy_to_folder=None):
        +    from ..services import CreateItem
             if copy_to_folder and not save_copy:
                 raise AttributeError("'save_copy' must be True when 'copy_to_folder' is set")
             message_disposition = SEND_AND_SAVE_COPY if save_copy else SEND_ONLY
        @@ -667,15 +673,15 @@ 

        Inherited members

        :return: """ if not cls.INSERT_AFTER_FIELD: - raise ValueError('Class %s is missing INSERT_AFTER_FIELD value' % cls) + raise ValueError(f'Class {cls} is missing INSERT_AFTER_FIELD value') try: cls.get_field_by_fieldname(attr_name) except InvalidField: pass else: - raise ValueError("'%s' is already registered" % attr_name) + raise ValueError(f"'attr_name' {attr_name!r} is already registered") if not issubclass(attr_cls, ExtendedProperty): - raise ValueError("%r must be a subclass of ExtendedProperty" % attr_cls) + raise TypeError(f"'attr_cls' {attr_cls!r} must be a subclass of type {ExtendedProperty}") # Check if class attributes are properly defined attr_cls.validate_cls() # ExtendedProperty is not a real field, but a placeholder in the fields list. See @@ -698,9 +704,9 @@

        Inherited members

        try: field = cls.get_field_by_fieldname(attr_name) except InvalidField: - raise ValueError("'%s' is not registered" % attr_name) + raise ValueError(f"{attr_name!r} is not registered") if not isinstance(field, ExtendedPropertyField): - raise ValueError("'%s' is not registered as an ExtendedProperty" % attr_name) + raise ValueError(f"{attr_name} is not registered as an ExtendedProperty") cls.remove_field(field)

        Ancestors

        @@ -743,9 +749,9 @@

        Static methods

        try: field = cls.get_field_by_fieldname(attr_name) except InvalidField: - raise ValueError("'%s' is not registered" % attr_name) + raise ValueError(f"{attr_name!r} is not registered") if not isinstance(field, ExtendedPropertyField): - raise ValueError("'%s' is not registered as an ExtendedProperty" % attr_name) + raise ValueError(f"{attr_name} is not registered as an ExtendedProperty") cls.remove_field(field)
        @@ -770,15 +776,15 @@

        Static methods

        :return: """ if not cls.INSERT_AFTER_FIELD: - raise ValueError('Class %s is missing INSERT_AFTER_FIELD value' % cls) + raise ValueError(f'Class {cls} is missing INSERT_AFTER_FIELD value') try: cls.get_field_by_fieldname(attr_name) except InvalidField: pass else: - raise ValueError("'%s' is already registered" % attr_name) + raise ValueError(f"'attr_name' {attr_name!r} is already registered") if not issubclass(attr_cls, ExtendedProperty): - raise ValueError("%r must be a subclass of ExtendedProperty" % attr_cls) + raise TypeError(f"'attr_cls' {attr_cls!r} must be a subclass of type {ExtendedProperty}") # Check if class attributes are properly defined attr_cls.validate_cls() # ExtendedProperty is not a real field, but a placeholder in the fields list. See diff --git a/docs/exchangelib/items/calendar_item.html b/docs/exchangelib/items/calendar_item.html index eb6e9df4..92a2f9ec 100644 --- a/docs/exchangelib/items/calendar_item.html +++ b/docs/exchangelib/items/calendar_item.html @@ -39,7 +39,6 @@

        Module exchangelib.items.calendar_item

        AssociatedCalendarItemIdField, DateOrDateTimeField, EWSElementListField, AppointmentStateField from ..properties import Attendee, ReferenceItemId, OccurrenceItemId, RecurringMasterItemId, EWSMeta from ..recurrence import FirstOccurrence, LastOccurrence, Occurrence, DeletedOccurrence -from ..services import CreateItem from ..util import set_xml_value, require_account from ..version import EXCHANGE_2010, EXCHANGE_2013 @@ -209,7 +208,7 @@

        Module exchangelib.items.calendar_item

        def clean(self, version=None): super().clean(version=version) if self.start and self.end and self.end < self.start: - raise ValueError("'end' must be greater than 'start' (%s -> %s)" % (self.start, self.end)) + raise ValueError(f"'end' must be greater than 'start' ({self.start} -> {self.end})") if version: self.clean_timezone_fields(version=version) @@ -244,7 +243,7 @@

        Module exchangelib.items.calendar_item

        # the inverse of what we do in .to_xml(). Convert to the local timezone before getting the date. if field_name == 'end': val -= datetime.timedelta(days=1) - tz = getattr(item, '_%s_timezone' % field_name) + tz = getattr(item, f'_{field_name}_timezone') setattr(item, field_name, val.astimezone(tz).date()) return item @@ -289,12 +288,12 @@

        Module exchangelib.items.calendar_item

        # We already generated an XML element for this field, but it contains a plain date at this point, which # is invalid. Replace the value. field = self.get_field_by_fieldname(field_name) - set_xml_value(elem=elem.find(field.response_tag()), value=value, version=version) + set_xml_value(elem.find(field.response_tag()), value) return elem -class BaseMeetingItem(Item): - """A base class for meeting requests that share the same fields (Message, Request, Response, Cancellation) +class BaseMeetingItem(Item, metaclass=EWSMeta): + """Base class for meeting requests that share the same fields (Message, Request, Response, Cancellation) MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/responsecode Certain types are created as a side effect of doing something else. Meeting messages, for example, are created @@ -392,18 +391,13 @@

        Module exchangelib.items.calendar_item

        def send(self, message_disposition=SEND_AND_SAVE_COPY): # Some responses contain multiple response IDs, e.g. MeetingRequest.accept(). Return either the single ID or # the list of IDs. - res = list(CreateItem(account=self.account).call( + from ..services import CreateItem + return CreateItem(account=self.account).get( items=[self], folder=self.folder, message_disposition=message_disposition, send_meeting_invitations=SEND_TO_NONE, - )) - for r in res: - if isinstance(r, Exception): - raise r - if len(res) == 1: - return res[0] - return res + ) class AcceptItem(BaseMeetingReplyItem): @@ -591,7 +585,7 @@

        Inherited members

        (**kwargs)
        -

        A base class for meeting requests that share the same fields (Message, Request, Response, Cancellation)

        +

        Base class for meeting requests that share the same fields (Message, Request, Response, Cancellation)

        MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/responsecode Certain types are created as a side effect of doing something else. Meeting messages, for example, are created when you send a calendar item to attendees; they are not explicitly created.

        @@ -606,8 +600,8 @@

        Inherited members

        Expand source code -
        class BaseMeetingItem(Item):
        -    """A base class for meeting requests that share the same fields (Message, Request, Response, Cancellation)
        +
        class BaseMeetingItem(Item, metaclass=EWSMeta):
        +    """Base class for meeting requests that share the same fields (Message, Request, Response, Cancellation)
         
             MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/responsecode
                 Certain types are created as a side effect of doing something else. Meeting messages, for example, are created
        @@ -807,18 +801,13 @@ 

        Inherited members

        def send(self, message_disposition=SEND_AND_SAVE_COPY): # Some responses contain multiple response IDs, e.g. MeetingRequest.accept(). Return either the single ID or # the list of IDs. - res = list(CreateItem(account=self.account).call( + from ..services import CreateItem + return CreateItem(account=self.account).get( items=[self], folder=self.folder, message_disposition=message_disposition, send_meeting_invitations=SEND_TO_NONE, - )) - for r in res: - if isinstance(r, Exception): - raise r - if len(res) == 1: - return res[0] - return res
        + )

        Ancestors

          @@ -922,18 +911,13 @@

          Methods

          def send(self, message_disposition=SEND_AND_SAVE_COPY): # Some responses contain multiple response IDs, e.g. MeetingRequest.accept(). Return either the single ID or # the list of IDs. - res = list(CreateItem(account=self.account).call( + from ..services import CreateItem + return CreateItem(account=self.account).get( items=[self], folder=self.folder, message_disposition=message_disposition, send_meeting_invitations=SEND_TO_NONE, - )) - for r in res: - if isinstance(r, Exception): - raise r - if len(res) == 1: - return res[0] - return res + )
        @@ -1096,7 +1080,7 @@

        Inherited members

        def clean(self, version=None): super().clean(version=version) if self.start and self.end and self.end < self.start: - raise ValueError("'end' must be greater than 'start' (%s -> %s)" % (self.start, self.end)) + raise ValueError(f"'end' must be greater than 'start' ({self.start} -> {self.end})") if version: self.clean_timezone_fields(version=version) @@ -1131,7 +1115,7 @@

        Inherited members

        # the inverse of what we do in .to_xml(). Convert to the local timezone before getting the date. if field_name == 'end': val -= datetime.timedelta(days=1) - tz = getattr(item, '_%s_timezone' % field_name) + tz = getattr(item, f'_{field_name}_timezone') setattr(item, field_name, val.astimezone(tz).date()) return item @@ -1176,7 +1160,7 @@

        Inherited members

        # We already generated an XML element for this field, but it contains a plain date at this point, which # is invalid. Replace the value. field = self.get_field_by_fieldname(field_name) - set_xml_value(elem=elem.find(field.response_tag()), value=value, version=version) + set_xml_value(elem.find(field.response_tag()), value) return elem

        Ancestors

        @@ -1225,7 +1209,7 @@

        Static methods

        # the inverse of what we do in .to_xml(). Convert to the local timezone before getting the date. if field_name == 'end': val -= datetime.timedelta(days=1) - tz = getattr(item, '_%s_timezone' % field_name) + tz = getattr(item, f'_{field_name}_timezone') setattr(item, field_name, val.astimezone(tz).date()) return item @@ -1431,7 +1415,7 @@

        Methods

        def clean(self, version=None):
             super().clean(version=version)
             if self.start and self.end and self.end < self.start:
        -        raise ValueError("'end' must be greater than 'start' (%s -> %s)" % (self.start, self.end))
        +        raise ValueError(f"'end' must be greater than 'start' ({self.start} -> {self.end})")
             if version:
                 self.clean_timezone_fields(version=version)
        @@ -1579,7 +1563,7 @@

        Methods

        # We already generated an XML element for this field, but it contains a plain date at this point, which # is invalid. Replace the value. field = self.get_field_by_fieldname(field_name) - set_xml_value(elem=elem.find(field.response_tag()), value=value, version=version) + set_xml_value(elem.find(field.response_tag()), value) return elem diff --git a/docs/exchangelib/items/contact.html b/docs/exchangelib/items/contact.html index 21c25deb..c29127b4 100644 --- a/docs/exchangelib/items/contact.html +++ b/docs/exchangelib/items/contact.html @@ -185,7 +185,7 @@

        Module exchangelib.items.contact

        callback_phones = PhoneNumberAttributedValueField(field_uri='persona:CallbackPhones') car_phones = PhoneNumberAttributedValueField(field_uri='persona:CarPhones') home_faxes = PhoneNumberAttributedValueField(field_uri='persona:HomeFaxes') - orgnaization_main_phones = PhoneNumberAttributedValueField(field_uri='persona:OrganizationMainPhones') + organization_main_phones = PhoneNumberAttributedValueField(field_uri='persona:OrganizationMainPhones') other_faxes = PhoneNumberAttributedValueField(field_uri='persona:OtherFaxes') other_telephones = PhoneNumberAttributedValueField(field_uri='persona:OtherTelephones') other_phones2 = PhoneNumberAttributedValueField(field_uri='persona:OtherPhones2') @@ -703,7 +703,7 @@

        Inherited members

        callback_phones = PhoneNumberAttributedValueField(field_uri='persona:CallbackPhones') car_phones = PhoneNumberAttributedValueField(field_uri='persona:CarPhones') home_faxes = PhoneNumberAttributedValueField(field_uri='persona:HomeFaxes') - orgnaization_main_phones = PhoneNumberAttributedValueField(field_uri='persona:OrganizationMainPhones') + organization_main_phones = PhoneNumberAttributedValueField(field_uri='persona:OrganizationMainPhones') other_faxes = PhoneNumberAttributedValueField(field_uri='persona:OtherFaxes') other_telephones = PhoneNumberAttributedValueField(field_uri='persona:OtherTelephones') other_phones2 = PhoneNumberAttributedValueField(field_uri='persona:OtherPhones2') @@ -1020,7 +1020,7 @@

        Instance variables

        -
        var orgnaization_main_phones
        +
        var organization_main_phones
        @@ -1300,7 +1300,7 @@

        nickname
      • nicknames
      • office_locations
      • -
      • orgnaization_main_phones
      • +
      • organization_main_phones
      • other_addresses
      • other_faxes
      • other_phones2
      • diff --git a/docs/exchangelib/items/index.html b/docs/exchangelib/items/index.html index 5bfc22d9..1ef9bfed 100644 --- a/docs/exchangelib/items/index.html +++ b/docs/exchangelib/items/index.html @@ -29,7 +29,7 @@

        Module exchangelib.items

        from .base import RegisterMixIn, BulkCreateResult, MESSAGE_DISPOSITION_CHOICES, SAVE_ONLY, SEND_ONLY, \
             ID_ONLY, DEFAULT, ALL_PROPERTIES, SEND_MEETING_INVITATIONS_CHOICES, SEND_TO_NONE, SEND_ONLY_TO_ALL, \
             SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, SEND_ONLY_TO_CHANGED, SEND_TO_CHANGED_AND_SAVE_COPY, \
        -    SEND_MEETING_CANCELLATIONS_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES, ALL_OCCURRENCIES, \
        +    SEND_MEETING_CANCELLATIONS_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES, ALL_OCCURRENCES, \
             SPECIFIED_OCCURRENCE_ONLY, CONFLICT_RESOLUTION_CHOICES, NEVER_OVERWRITE, AUTO_RESOLVE, ALWAYS_OVERWRITE, \
             DELETE_TYPE_CHOICES, HARD_DELETE, SOFT_DELETE, MOVE_TO_DELETED_ITEMS, SEND_TO_ALL_AND_SAVE_COPY, \
             SEND_AND_SAVE_COPY, SHAPE_CHOICES
        @@ -65,7 +65,7 @@ 

        Module exchangelib.items

        'Contact', 'Persona', 'DistributionList', 'SEND_MEETING_INVITATIONS_CHOICES', 'SEND_TO_NONE', 'SEND_ONLY_TO_ALL', 'SEND_TO_ALL_AND_SAVE_COPY', 'SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES', 'SEND_ONLY_TO_CHANGED', 'SEND_TO_CHANGED_AND_SAVE_COPY', - 'SEND_MEETING_CANCELLATIONS_CHOICES', 'AFFECTED_TASK_OCCURRENCES_CHOICES', 'ALL_OCCURRENCIES', + 'SEND_MEETING_CANCELLATIONS_CHOICES', 'AFFECTED_TASK_OCCURRENCES_CHOICES', 'ALL_OCCURRENCES', 'SPECIFIED_OCCURRENCE_ONLY', 'CONFLICT_RESOLUTION_CHOICES', 'NEVER_OVERWRITE', 'AUTO_RESOLVE', 'ALWAYS_OVERWRITE', 'DELETE_TYPE_CHOICES', 'HARD_DELETE', 'SOFT_DELETE', 'MOVE_TO_DELETED_ITEMS', 'BaseItem', 'Item', 'BulkCreateResult', @@ -206,11 +206,11 @@

        Inherited members

        from ..account import Account self.account = kwargs.pop('account', None) if self.account is not None and not isinstance(self.account, Account): - raise ValueError("'account' %r must be an Account instance" % self.account) + raise InvalidTypeError('account', self.account, Account) self.folder = kwargs.pop('folder', None) if self.folder is not None: if not isinstance(self.folder, BaseFolder): - raise ValueError("'folder' %r must be a Folder instance" % self.folder) + raise InvalidTypeError('folder', self.folder, BaseFolder) if self.folder.account is not None: if self.account is not None: # Make sure the account from kwargs matches the folder account @@ -498,7 +498,7 @@

        Inherited members

        def clean(self, version=None): super().clean(version=version) if self.start and self.end and self.end < self.start: - raise ValueError("'end' must be greater than 'start' (%s -> %s)" % (self.start, self.end)) + raise ValueError(f"'end' must be greater than 'start' ({self.start} -> {self.end})") if version: self.clean_timezone_fields(version=version) @@ -533,7 +533,7 @@

        Inherited members

        # the inverse of what we do in .to_xml(). Convert to the local timezone before getting the date. if field_name == 'end': val -= datetime.timedelta(days=1) - tz = getattr(item, '_%s_timezone' % field_name) + tz = getattr(item, f'_{field_name}_timezone') setattr(item, field_name, val.astimezone(tz).date()) return item @@ -578,7 +578,7 @@

        Inherited members

        # We already generated an XML element for this field, but it contains a plain date at this point, which # is invalid. Replace the value. field = self.get_field_by_fieldname(field_name) - set_xml_value(elem=elem.find(field.response_tag()), value=value, version=version) + set_xml_value(elem.find(field.response_tag()), value) return elem

        Ancestors

        @@ -627,7 +627,7 @@

        Static methods

        # the inverse of what we do in .to_xml(). Convert to the local timezone before getting the date. if field_name == 'end': val -= datetime.timedelta(days=1) - tz = getattr(item, '_%s_timezone' % field_name) + tz = getattr(item, f'_{field_name}_timezone') setattr(item, field_name, val.astimezone(tz).date()) return item @@ -833,7 +833,7 @@

        Methods

        def clean(self, version=None):
             super().clean(version=version)
             if self.start and self.end and self.end < self.start:
        -        raise ValueError("'end' must be greater than 'start' (%s -> %s)" % (self.start, self.end))
        +        raise ValueError(f"'end' must be greater than 'start' ({self.start} -> {self.end})")
             if version:
                 self.clean_timezone_fields(version=version)
        @@ -981,7 +981,7 @@

        Methods

        # We already generated an XML element for this field, but it contains a plain date at this point, which # is invalid. Replace the value. field = self.get_field_by_fieldname(field_name) - set_xml_value(elem=elem.find(field.response_tag()), value=value, version=version) + set_xml_value(elem.find(field.response_tag()), value) return elem @@ -1631,7 +1631,7 @@

        Inherited members

        for a in self.attachments: if a.parent_item: if a.parent_item is not self: - raise ValueError("'parent_item' of attachment %s must point to this item" % a) + raise ValueError(f"'parent_item' of attachment {a} must point to this item") else: a.parent_item = self self.attach(self.attachments) @@ -1683,6 +1683,7 @@

        Inherited members

        def _create(self, message_disposition, send_meeting_invitations): # Return a BulkCreateResult because we want to return the ID of both the main item *and* attachments. In send # and send-and-save-copy mode, the server does not return an ID, so we just return True. + from ..services import CreateItem return CreateItem(account=self.account).get( items=[self], folder=self.folder, @@ -1720,8 +1721,9 @@

        Inherited members

        @require_account def _update(self, update_fieldnames, message_disposition, conflict_resolution, send_meeting_invitations): + from ..services import UpdateItem if not self.changekey: - raise ValueError('%s must have changekey' % self.__class__.__name__) + raise ValueError(f'{self.__class__.__name__} must have changekey') if not update_fieldnames: # The fields to update was not specified explicitly. Update all fields where update is possible update_fieldnames = self._update_fieldnames() @@ -1738,6 +1740,7 @@

        Inherited members

        def refresh(self): # Updates the item based on fresh data from EWS from ..folders import Folder + from ..services import GetItem additional_fields = { FieldPath(field=f) for f in Folder(root=self.account.root).allowed_item_fields(version=self.account.version) } @@ -1755,6 +1758,7 @@

        Inherited members

        @require_id def copy(self, to_folder): + from ..services import CopyItem # If 'to_folder' is a public folder or a folder in a different mailbox then None is returned return CopyItem(account=self.account).get( items=[self], @@ -1764,6 +1768,7 @@

        Inherited members

        @require_id def move(self, to_folder): + from ..services import MoveItem res = MoveItem(account=self.account).get( items=[self], to_folder=to_folder, @@ -1776,7 +1781,7 @@

        Inherited members

        self._id = self.ID_ELEMENT_CLS(*res) self.folder = to_folder - def move_to_trash(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES, + def move_to_trash(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True): # Delete and move to the trash folder. self._delete(delete_type=MOVE_TO_DELETED_ITEMS, send_meeting_cancellations=send_meeting_cancellations, @@ -1784,7 +1789,7 @@

        Inherited members

        self._id = None self.folder = self.account.trash - def soft_delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES, + def soft_delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True): # Delete and move to the dumpster, if it is enabled. self._delete(delete_type=SOFT_DELETE, send_meeting_cancellations=send_meeting_cancellations, @@ -1792,7 +1797,7 @@

        Inherited members

        self._id = None self.folder = self.account.recoverable_items_deletions - def delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES, + def delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True): # Remove the item permanently. No copies are stored anywhere. self._delete(delete_type=HARD_DELETE, send_meeting_cancellations=send_meeting_cancellations, @@ -1801,6 +1806,7 @@

        Inherited members

        @require_id def _delete(self, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): + from ..services import DeleteItem DeleteItem(account=self.account).get( items=[self], delete_type=delete_type, @@ -1811,6 +1817,7 @@

        Inherited members

        @require_id def archive(self, to_folder): + from ..services import ArchiveItem return ArchiveItem(account=self.account).get(items=[self], to_folder=to_folder, expect_result=True) def attach(self, attachments): @@ -2075,6 +2082,7 @@

        Methods

        @require_id
         def archive(self, to_folder):
        +    from ..services import ArchiveItem
             return ArchiveItem(account=self.account).get(items=[self], to_folder=to_folder, expect_result=True)
        @@ -2123,6 +2131,7 @@

        Methods

        @require_id
         def copy(self, to_folder):
        +    from ..services import CopyItem
             # If 'to_folder' is a public folder or a folder in a different mailbox then None is returned
             return CopyItem(account=self.account).get(
                 items=[self],
        @@ -2163,7 +2172,7 @@ 

        Methods

        Expand source code -
        def delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES,
        +
        def delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES,
                    suppress_read_receipts=True):
             # Remove the item permanently. No copies are stored anywhere.
             self._delete(delete_type=HARD_DELETE, send_meeting_cancellations=send_meeting_cancellations,
        @@ -2238,6 +2247,7 @@ 

        Methods

        @require_id
         def move(self, to_folder):
        +    from ..services import MoveItem
             res = MoveItem(account=self.account).get(
                 items=[self],
                 to_folder=to_folder,
        @@ -2260,7 +2270,7 @@ 

        Methods

        Expand source code -
        def move_to_trash(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES,
        +
        def move_to_trash(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES,
                           suppress_read_receipts=True):
             # Delete and move to the trash folder.
             self._delete(delete_type=MOVE_TO_DELETED_ITEMS, send_meeting_cancellations=send_meeting_cancellations,
        @@ -2282,6 +2292,7 @@ 

        Methods

        def refresh(self): # Updates the item based on fresh data from EWS from ..folders import Folder + from ..services import GetItem additional_fields = { FieldPath(field=f) for f in Folder(root=self.account.root).allowed_item_fields(version=self.account.version) } @@ -2358,7 +2369,7 @@

        Methods

        Expand source code -
        def soft_delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES,
        +
        def soft_delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES,
                         suppress_read_receipts=True):
             # Delete and move to the dumpster, if it is enabled.
             self._delete(delete_type=SOFT_DELETE, send_meeting_cancellations=send_meeting_cancellations,
        @@ -2808,6 +2819,7 @@ 

        Inherited members

        @require_account def send(self, save_copy=True, copy_to_folder=None, conflict_resolution=AUTO_RESOLVE, send_meeting_invitations=SEND_TO_NONE): + from ..services import SendItem # Only sends a message. The message can either be an existing draft stored in EWS or a new message that does # not yet exist in EWS. if copy_to_folder and not save_copy: @@ -2857,12 +2869,10 @@

        Inherited members

        self.send(save_copy=False, conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations) else: - res = self._create( + self._create( message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations ) - if res is not True: - raise ValueError('Unexpected response in send-only mode') @require_id def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): @@ -2915,6 +2925,7 @@

        Inherited members

        :param move_item: If true, the item will be moved to the junk folder. :return: """ + from ..services import MarkAsJunk res = MarkAsJunk(account=self.account).get( items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item ) @@ -3087,6 +3098,7 @@

        Methods

        :param move_item: If true, the item will be moved to the junk folder. :return: """ + from ..services import MarkAsJunk res = MarkAsJunk(account=self.account).get( items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item ) @@ -3140,6 +3152,7 @@

        Methods

        @require_account
         def send(self, save_copy=True, copy_to_folder=None, conflict_resolution=AUTO_RESOLVE,
                  send_meeting_invitations=SEND_TO_NONE):
        +    from ..services import SendItem
             # Only sends a message. The message can either be an existing draft stored in EWS or a new message that does
             # not yet exist in EWS.
             if copy_to_folder and not save_copy:
        @@ -3199,12 +3212,10 @@ 

        Methods

        self.send(save_copy=False, conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations) else: - res = self._create( + self._create( message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations - ) - if res is not True: - raise ValueError('Unexpected response in send-only mode')
        + )
        @@ -3305,7 +3316,7 @@

        Inherited members

        callback_phones = PhoneNumberAttributedValueField(field_uri='persona:CallbackPhones') car_phones = PhoneNumberAttributedValueField(field_uri='persona:CarPhones') home_faxes = PhoneNumberAttributedValueField(field_uri='persona:HomeFaxes') - orgnaization_main_phones = PhoneNumberAttributedValueField(field_uri='persona:OrganizationMainPhones') + organization_main_phones = PhoneNumberAttributedValueField(field_uri='persona:OrganizationMainPhones') other_faxes = PhoneNumberAttributedValueField(field_uri='persona:OtherFaxes') other_telephones = PhoneNumberAttributedValueField(field_uri='persona:OtherTelephones') other_phones2 = PhoneNumberAttributedValueField(field_uri='persona:OtherPhones2') @@ -3622,7 +3633,7 @@

        Instance variables

        -
        var orgnaization_main_phones
        +
        var organization_main_phones
        @@ -4035,15 +4046,15 @@

        Inherited members

        :return: """ if not cls.INSERT_AFTER_FIELD: - raise ValueError('Class %s is missing INSERT_AFTER_FIELD value' % cls) + raise ValueError(f'Class {cls} is missing INSERT_AFTER_FIELD value') try: cls.get_field_by_fieldname(attr_name) except InvalidField: pass else: - raise ValueError("'%s' is already registered" % attr_name) + raise ValueError(f"'attr_name' {attr_name!r} is already registered") if not issubclass(attr_cls, ExtendedProperty): - raise ValueError("%r must be a subclass of ExtendedProperty" % attr_cls) + raise TypeError(f"'attr_cls' {attr_cls!r} must be a subclass of type {ExtendedProperty}") # Check if class attributes are properly defined attr_cls.validate_cls() # ExtendedProperty is not a real field, but a placeholder in the fields list. See @@ -4066,9 +4077,9 @@

        Inherited members

        try: field = cls.get_field_by_fieldname(attr_name) except InvalidField: - raise ValueError("'%s' is not registered" % attr_name) + raise ValueError(f"{attr_name!r} is not registered") if not isinstance(field, ExtendedPropertyField): - raise ValueError("'%s' is not registered as an ExtendedProperty" % attr_name) + raise ValueError(f"{attr_name} is not registered as an ExtendedProperty") cls.remove_field(field)

        Ancestors

        @@ -4111,9 +4122,9 @@

        Static methods

        try: field = cls.get_field_by_fieldname(attr_name) except InvalidField: - raise ValueError("'%s' is not registered" % attr_name) + raise ValueError(f"{attr_name!r} is not registered") if not isinstance(field, ExtendedPropertyField): - raise ValueError("'%s' is not registered as an ExtendedProperty" % attr_name) + raise ValueError(f"{attr_name} is not registered as an ExtendedProperty") cls.remove_field(field)
        @@ -4138,15 +4149,15 @@

        Static methods

        :return: """ if not cls.INSERT_AFTER_FIELD: - raise ValueError('Class %s is missing INSERT_AFTER_FIELD value' % cls) + raise ValueError(f'Class {cls} is missing INSERT_AFTER_FIELD value') try: cls.get_field_by_fieldname(attr_name) except InvalidField: pass else: - raise ValueError("'%s' is already registered" % attr_name) + raise ValueError(f"'attr_name' {attr_name!r} is already registered") if not issubclass(attr_cls, ExtendedProperty): - raise ValueError("%r must be a subclass of ExtendedProperty" % attr_cls) + raise TypeError(f"'attr_cls' {attr_cls!r} must be a subclass of type {ExtendedProperty}") # Check if class attributes are properly defined attr_cls.validate_cls() # ExtendedProperty is not a real field, but a placeholder in the fields list. See @@ -5003,7 +5014,7 @@

        nickname
      • nicknames
      • office_locations
      • -
      • orgnaization_main_phones
      • +
      • organization_main_phones
      • other_addresses
      • other_faxes
      • other_phones2
      • diff --git a/docs/exchangelib/items/item.html b/docs/exchangelib/items/item.html index 38e7799d..eed17660 100644 --- a/docs/exchangelib/items/item.html +++ b/docs/exchangelib/items/item.html @@ -29,13 +29,12 @@

        Module exchangelib.items.item

        import logging
         
         from .base import BaseItem, SAVE_ONLY, SEND_AND_SAVE_COPY, ID_ONLY, SEND_TO_NONE, \
        -    AUTO_RESOLVE, SOFT_DELETE, HARD_DELETE, ALL_OCCURRENCIES, MOVE_TO_DELETED_ITEMS
        +    AUTO_RESOLVE, SOFT_DELETE, HARD_DELETE, ALL_OCCURRENCES, MOVE_TO_DELETED_ITEMS
         from ..fields import BooleanField, IntegerField, TextField, CharListField, ChoiceField, URIField, BodyField, \
             DateTimeField, MessageHeaderField, AttachmentField, Choice, EWSElementField, EffectiveRightsField, CultureField, \
             CharField, MimeContentField, FieldPath
         from ..properties import ConversationId, ParentFolderId, ReferenceItemId, OccurrenceItemId, RecurringMasterItemId, \
             ResponseObjects, Fields
        -from ..services import GetItem, CreateItem, UpdateItem, DeleteItem, MoveItem, CopyItem, ArchiveItem
         from ..util import is_iterable, require_account, require_id
         from ..version import EXCHANGE_2010, EXCHANGE_2013
         
        @@ -108,7 +107,7 @@ 

        Module exchangelib.items.item

        for a in self.attachments: if a.parent_item: if a.parent_item is not self: - raise ValueError("'parent_item' of attachment %s must point to this item" % a) + raise ValueError(f"'parent_item' of attachment {a} must point to this item") else: a.parent_item = self self.attach(self.attachments) @@ -160,6 +159,7 @@

        Module exchangelib.items.item

        def _create(self, message_disposition, send_meeting_invitations): # Return a BulkCreateResult because we want to return the ID of both the main item *and* attachments. In send # and send-and-save-copy mode, the server does not return an ID, so we just return True. + from ..services import CreateItem return CreateItem(account=self.account).get( items=[self], folder=self.folder, @@ -197,8 +197,9 @@

        Module exchangelib.items.item

        @require_account def _update(self, update_fieldnames, message_disposition, conflict_resolution, send_meeting_invitations): + from ..services import UpdateItem if not self.changekey: - raise ValueError('%s must have changekey' % self.__class__.__name__) + raise ValueError(f'{self.__class__.__name__} must have changekey') if not update_fieldnames: # The fields to update was not specified explicitly. Update all fields where update is possible update_fieldnames = self._update_fieldnames() @@ -215,6 +216,7 @@

        Module exchangelib.items.item

        def refresh(self): # Updates the item based on fresh data from EWS from ..folders import Folder + from ..services import GetItem additional_fields = { FieldPath(field=f) for f in Folder(root=self.account.root).allowed_item_fields(version=self.account.version) } @@ -232,6 +234,7 @@

        Module exchangelib.items.item

        @require_id def copy(self, to_folder): + from ..services import CopyItem # If 'to_folder' is a public folder or a folder in a different mailbox then None is returned return CopyItem(account=self.account).get( items=[self], @@ -241,6 +244,7 @@

        Module exchangelib.items.item

        @require_id def move(self, to_folder): + from ..services import MoveItem res = MoveItem(account=self.account).get( items=[self], to_folder=to_folder, @@ -253,7 +257,7 @@

        Module exchangelib.items.item

        self._id = self.ID_ELEMENT_CLS(*res) self.folder = to_folder - def move_to_trash(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES, + def move_to_trash(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True): # Delete and move to the trash folder. self._delete(delete_type=MOVE_TO_DELETED_ITEMS, send_meeting_cancellations=send_meeting_cancellations, @@ -261,7 +265,7 @@

        Module exchangelib.items.item

        self._id = None self.folder = self.account.trash - def soft_delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES, + def soft_delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True): # Delete and move to the dumpster, if it is enabled. self._delete(delete_type=SOFT_DELETE, send_meeting_cancellations=send_meeting_cancellations, @@ -269,7 +273,7 @@

        Module exchangelib.items.item

        self._id = None self.folder = self.account.recoverable_items_deletions - def delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES, + def delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True): # Remove the item permanently. No copies are stored anywhere. self._delete(delete_type=HARD_DELETE, send_meeting_cancellations=send_meeting_cancellations, @@ -278,6 +282,7 @@

        Module exchangelib.items.item

        @require_id def _delete(self, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): + from ..services import DeleteItem DeleteItem(account=self.account).get( items=[self], delete_type=delete_type, @@ -288,6 +293,7 @@

        Module exchangelib.items.item

        @require_id def archive(self, to_folder): + from ..services import ArchiveItem return ArchiveItem(account=self.account).get(items=[self], to_folder=to_folder, expect_result=True) def attach(self, attachments): @@ -446,7 +452,7 @@

        Classes

        for a in self.attachments: if a.parent_item: if a.parent_item is not self: - raise ValueError("'parent_item' of attachment %s must point to this item" % a) + raise ValueError(f"'parent_item' of attachment {a} must point to this item") else: a.parent_item = self self.attach(self.attachments) @@ -498,6 +504,7 @@

        Classes

        def _create(self, message_disposition, send_meeting_invitations): # Return a BulkCreateResult because we want to return the ID of both the main item *and* attachments. In send # and send-and-save-copy mode, the server does not return an ID, so we just return True. + from ..services import CreateItem return CreateItem(account=self.account).get( items=[self], folder=self.folder, @@ -535,8 +542,9 @@

        Classes

        @require_account def _update(self, update_fieldnames, message_disposition, conflict_resolution, send_meeting_invitations): + from ..services import UpdateItem if not self.changekey: - raise ValueError('%s must have changekey' % self.__class__.__name__) + raise ValueError(f'{self.__class__.__name__} must have changekey') if not update_fieldnames: # The fields to update was not specified explicitly. Update all fields where update is possible update_fieldnames = self._update_fieldnames() @@ -553,6 +561,7 @@

        Classes

        def refresh(self): # Updates the item based on fresh data from EWS from ..folders import Folder + from ..services import GetItem additional_fields = { FieldPath(field=f) for f in Folder(root=self.account.root).allowed_item_fields(version=self.account.version) } @@ -570,6 +579,7 @@

        Classes

        @require_id def copy(self, to_folder): + from ..services import CopyItem # If 'to_folder' is a public folder or a folder in a different mailbox then None is returned return CopyItem(account=self.account).get( items=[self], @@ -579,6 +589,7 @@

        Classes

        @require_id def move(self, to_folder): + from ..services import MoveItem res = MoveItem(account=self.account).get( items=[self], to_folder=to_folder, @@ -591,7 +602,7 @@

        Classes

        self._id = self.ID_ELEMENT_CLS(*res) self.folder = to_folder - def move_to_trash(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES, + def move_to_trash(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True): # Delete and move to the trash folder. self._delete(delete_type=MOVE_TO_DELETED_ITEMS, send_meeting_cancellations=send_meeting_cancellations, @@ -599,7 +610,7 @@

        Classes

        self._id = None self.folder = self.account.trash - def soft_delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES, + def soft_delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True): # Delete and move to the dumpster, if it is enabled. self._delete(delete_type=SOFT_DELETE, send_meeting_cancellations=send_meeting_cancellations, @@ -607,7 +618,7 @@

        Classes

        self._id = None self.folder = self.account.recoverable_items_deletions - def delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES, + def delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True): # Remove the item permanently. No copies are stored anywhere. self._delete(delete_type=HARD_DELETE, send_meeting_cancellations=send_meeting_cancellations, @@ -616,6 +627,7 @@

        Classes

        @require_id def _delete(self, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): + from ..services import DeleteItem DeleteItem(account=self.account).get( items=[self], delete_type=delete_type, @@ -626,6 +638,7 @@

        Classes

        @require_id def archive(self, to_folder): + from ..services import ArchiveItem return ArchiveItem(account=self.account).get(items=[self], to_folder=to_folder, expect_result=True) def attach(self, attachments): @@ -890,6 +903,7 @@

        Methods

        @require_id
         def archive(self, to_folder):
        +    from ..services import ArchiveItem
             return ArchiveItem(account=self.account).get(items=[self], to_folder=to_folder, expect_result=True)
        @@ -938,6 +952,7 @@

        Methods

        @require_id
         def copy(self, to_folder):
        +    from ..services import CopyItem
             # If 'to_folder' is a public folder or a folder in a different mailbox then None is returned
             return CopyItem(account=self.account).get(
                 items=[self],
        @@ -978,7 +993,7 @@ 

        Methods

        Expand source code -
        def delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES,
        +
        def delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES,
                    suppress_read_receipts=True):
             # Remove the item permanently. No copies are stored anywhere.
             self._delete(delete_type=HARD_DELETE, send_meeting_cancellations=send_meeting_cancellations,
        @@ -1053,6 +1068,7 @@ 

        Methods

        @require_id
         def move(self, to_folder):
        +    from ..services import MoveItem
             res = MoveItem(account=self.account).get(
                 items=[self],
                 to_folder=to_folder,
        @@ -1075,7 +1091,7 @@ 

        Methods

        Expand source code -
        def move_to_trash(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES,
        +
        def move_to_trash(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES,
                           suppress_read_receipts=True):
             # Delete and move to the trash folder.
             self._delete(delete_type=MOVE_TO_DELETED_ITEMS, send_meeting_cancellations=send_meeting_cancellations,
        @@ -1097,6 +1113,7 @@ 

        Methods

        def refresh(self): # Updates the item based on fresh data from EWS from ..folders import Folder + from ..services import GetItem additional_fields = { FieldPath(field=f) for f in Folder(root=self.account.root).allowed_item_fields(version=self.account.version) } @@ -1173,7 +1190,7 @@

        Methods

        Expand source code -
        def soft_delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCIES,
        +
        def soft_delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES,
                         suppress_read_receipts=True):
             # Delete and move to the dumpster, if it is enabled.
             self._delete(delete_type=SOFT_DELETE, send_meeting_cancellations=send_meeting_cancellations,
        diff --git a/docs/exchangelib/items/message.html b/docs/exchangelib/items/message.html
        index 4185a29a..df2d7089 100644
        --- a/docs/exchangelib/items/message.html
        +++ b/docs/exchangelib/items/message.html
        @@ -32,7 +32,6 @@ 

        Module exchangelib.items.message

        from .item import Item from ..fields import BooleanField, Base64Field, TextField, MailboxField, MailboxListField, CharField, EWSElementField from ..properties import ReferenceItemId, ReminderMessageData -from ..services import SendItem, MarkAsJunk from ..util import require_account, require_id from ..version import EXCHANGE_2013, EXCHANGE_2013_SP1 @@ -74,6 +73,7 @@

        Module exchangelib.items.message

        @require_account def send(self, save_copy=True, copy_to_folder=None, conflict_resolution=AUTO_RESOLVE, send_meeting_invitations=SEND_TO_NONE): + from ..services import SendItem # Only sends a message. The message can either be an existing draft stored in EWS or a new message that does # not yet exist in EWS. if copy_to_folder and not save_copy: @@ -123,12 +123,10 @@

        Module exchangelib.items.message

        self.send(save_copy=False, conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations) else: - res = self._create( + self._create( message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations ) - if res is not True: - raise ValueError('Unexpected response in send-only mode') @require_id def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): @@ -181,6 +179,7 @@

        Module exchangelib.items.message

        :param move_item: If true, the item will be moved to the junk folder. :return: """ + from ..services import MarkAsJunk res = MarkAsJunk(account=self.account).get( items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item ) @@ -309,6 +308,7 @@

        Inherited members

        @require_account def send(self, save_copy=True, copy_to_folder=None, conflict_resolution=AUTO_RESOLVE, send_meeting_invitations=SEND_TO_NONE): + from ..services import SendItem # Only sends a message. The message can either be an existing draft stored in EWS or a new message that does # not yet exist in EWS. if copy_to_folder and not save_copy: @@ -358,12 +358,10 @@

        Inherited members

        self.send(save_copy=False, conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations) else: - res = self._create( + self._create( message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations ) - if res is not True: - raise ValueError('Unexpected response in send-only mode') @require_id def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): @@ -416,6 +414,7 @@

        Inherited members

        :param move_item: If true, the item will be moved to the junk folder. :return: """ + from ..services import MarkAsJunk res = MarkAsJunk(account=self.account).get( items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item ) @@ -588,6 +587,7 @@

        Methods

        :param move_item: If true, the item will be moved to the junk folder. :return: """ + from ..services import MarkAsJunk res = MarkAsJunk(account=self.account).get( items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item ) @@ -641,6 +641,7 @@

        Methods

        @require_account
         def send(self, save_copy=True, copy_to_folder=None, conflict_resolution=AUTO_RESOLVE,
                  send_meeting_invitations=SEND_TO_NONE):
        +    from ..services import SendItem
             # Only sends a message. The message can either be an existing draft stored in EWS or a new message that does
             # not yet exist in EWS.
             if copy_to_folder and not save_copy:
        @@ -700,12 +701,10 @@ 

        Methods

        self.send(save_copy=False, conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations) else: - res = self._create( + self._create( message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations - ) - if res is not True: - raise ValueError('Unexpected response in send-only mode')
        + )
        diff --git a/docs/exchangelib/properties.html b/docs/exchangelib/properties.html index 2fe0294f..91a438d6 100644 --- a/docs/exchangelib/properties.html +++ b/docs/exchangelib/properties.html @@ -35,15 +35,15 @@

        Module exchangelib.properties

        from inspect import getmro from threading import Lock -from .errors import TimezoneDefinitionInvalidForYear +from .errors import TimezoneDefinitionInvalidForYear, InvalidTypeError from .fields import SubField, TextField, EmailAddressField, ChoiceField, DateTimeField, EWSElementField, MailboxField, \ Choice, BooleanField, IdField, ExtendedPropertyField, IntegerField, TimeField, EnumField, CharField, EmailField, \ EWSElementListField, EnumListField, FreeBusyStatusField, UnknownEntriesField, MessageField, RecipientAddressField, \ RoutingTypeField, WEEKDAY_NAMES, FieldPath, Field, AssociatedCalendarItemIdField, ReferenceItemIdField, \ Base64Field, TypeValueField, DictionaryField, IdElementField, CharListField, GenericEventListField, \ - InvalidField, InvalidFieldForVersion + DateTimeBackedDateField, TimeDeltaField, TransitionListField, InvalidField, InvalidFieldForVersion from .util import get_xml_attr, create_element, set_xml_value, value_to_xml_text, MNS, TNS -from .version import Version, EXCHANGE_2013, Build +from .version import EXCHANGE_2013, Build log = logging.getLogger(__name__) @@ -57,7 +57,7 @@

        Module exchangelib.properties

        for f in fields: # Check for duplicate field names if f.name in self._dict: - raise ValueError('Field %r is a duplicate' % f) + raise ValueError(f'Field {f!r} is a duplicate') self._dict[f.name] = f def __getitem__(self, idx_or_slice): @@ -80,9 +80,7 @@

        Module exchangelib.properties

        return self def __contains__(self, item): - if isinstance(item, str): - return item in self._dict - return super().__contains__(item) + return item in self._dict def copy(self): return self.__class__(*self) @@ -91,11 +89,11 @@

        Module exchangelib.properties

        for i, f in enumerate(self): if f.name == field_name: return i - raise ValueError('Unknown field name %r' % field_name) + raise ValueError(f'Unknown field name {field_name!r}') def insert(self, index, field): if field.name in self._dict: - raise ValueError('Field %r is a duplicate' % field) + raise ValueError(f'Field {field!r} is a duplicate') super().insert(index, field) self._dict[field.name] = field @@ -173,7 +171,7 @@

        Module exchangelib.properties

        # https://stackoverflow.com/questions/33757805 def __new__(cls, uid): - payload = binascii.hexlify(bytearray('vCal-Uid\x01\x00\x00\x00{}\x00'.format(uid).encode('ascii'))) + payload = binascii.hexlify(bytearray(f'vCal-Uid\x01\x00\x00\x00{uid}\x00'.encode('ascii'))) length = binascii.hexlify(bytearray(struct.pack('<I', int(len(payload)/2)))) encoding = b''.join([ cls._HEADER, cls._EXCEPTION_REPLACEMENT_TIME, cls._CREATION_TIME, cls._RESERVED, length, payload @@ -187,7 +185,7 @@

        Module exchangelib.properties

        def _mangle(field_name): - return '__%s' % field_name + return f'__{field_name}' class EWSMeta(type, metaclass=abc.ABCMeta): @@ -262,7 +260,7 @@

        Module exchangelib.properties

        for f in self.FIELDS: setattr(self, f.name, kwargs.pop(f.name, None)) if kwargs: - raise AttributeError("%s are invalid kwargs for this class" % ', '.join("'%s'" % k for k in kwargs)) + raise AttributeError(f"{sorted(kwargs.keys())!r} are invalid kwargs for this class") def __setattr__(self, key, value): # Avoid silently accepting spelling errors to field names that are not set via __init__. We need to be able to @@ -275,8 +273,9 @@

        Module exchangelib.properties

        if hasattr(self, key): # Property setters return super().__setattr__(key, value) - raise AttributeError('%r is not a valid attribute. See %s.FIELDS for valid field names' % ( - key, self.__class__.__name__)) + raise AttributeError( + f'{key!r} is not a valid attribute. See {self.__class__.__name__}.FIELDS for valid field names' + ) def clean(self, version=None): # Validate attribute values using the field validator @@ -311,17 +310,18 @@

        Module exchangelib.properties

        # WARNING: The order of addition of XML elements is VERY important. Exchange expects XML elements in a # specific, non-documented order and will fail with meaningless errors if the order is wrong. - # Call create_element() without args, to not fill up the cache with unique attribute values. - elem = create_element(self.request_tag()) - - # Add attributes + # Collect attributes + attrs = {} for f in self.attribute_fields(): if f.is_read_only: continue value = getattr(self, f.name) if value is None or (f.is_list and not value): continue - elem.set(f.field_uri, value_to_xml_text(getattr(self, f.name))) + attrs[f.field_uri] = value_to_xml_text(getattr(self, f.name)) + + # Create element with attributes + elem = create_element(self.request_tag(), attrs=attrs) # Add elements and values for f in self.supported_fields(version=version): @@ -330,25 +330,25 @@

        Module exchangelib.properties

        value = getattr(self, f.name) if value is None or (f.is_list and not value): continue - set_xml_value(elem, f.to_xml(value, version=version), version) + set_xml_value(elem, f.to_xml(value, version=version)) return elem @classmethod def request_tag(cls): if not cls.ELEMENT_NAME: - raise ValueError('Class %s is missing the ELEMENT_NAME attribute' % cls) + raise ValueError(f'Class {cls} is missing the ELEMENT_NAME attribute') return { - TNS: 't:%s' % cls.ELEMENT_NAME, - MNS: 'm:%s' % cls.ELEMENT_NAME, + TNS: f't:{cls.ELEMENT_NAME}', + MNS: f'm:{cls.ELEMENT_NAME}', }[cls.NAMESPACE] @classmethod def response_tag(cls): if not cls.NAMESPACE: - raise ValueError('Class %s is missing the NAMESPACE attribute' % cls) + raise ValueError(f'Class {cls} is missing the NAMESPACE attribute') if not cls.ELEMENT_NAME: - raise ValueError('Class %s is missing the ELEMENT_NAME attribute' % cls) - return '{%s}%s' % (cls.NAMESPACE, cls.ELEMENT_NAME) + raise ValueError(f'Class {cls} is missing the ELEMENT_NAME attribute') + return f'{{{cls.NAMESPACE}}}{cls.ELEMENT_NAME}' @classmethod def attribute_fields(cls): @@ -365,7 +365,7 @@

        Module exchangelib.properties

        try: return cls.FIELDS[fieldname] except KeyError: - raise InvalidField("'%s' is not a valid field name on '%s'" % (fieldname, cls.__name__)) + raise InvalidField(f"{fieldname!r} is not a valid field name on {cls.__name__}") @classmethod def validate_field(cls, field, version): @@ -375,21 +375,18 @@

        Module exchangelib.properties

        :param field: :param version: """ - if not isinstance(version, Version): - raise ValueError("'version' %r must be a Version instance" % version) # Allow both Field and FieldPath instances and string field paths as input if isinstance(field, str): field = cls.get_field_by_fieldname(fieldname=field) elif isinstance(field, FieldPath): field = field.field - if not isinstance(field, Field): - raise ValueError("Field %r must be a string, Field or FieldPath instance" % field) cls.get_field_by_fieldname(fieldname=field.name) # Will raise if field name is invalid if not field.supports_version(version): # The field exists but is not valid for this version raise InvalidFieldForVersion( - "Field '%s' is not supported on server version %s (supported from: %s, deprecated from: %s)" - % (field.name, version, field.supported_from, field.deprecated_from)) + f"Field {field.name!r} is not supported on server version {version} " + f"(supported from: {field.supported_from}, deprecated from: {field.deprecated_from})" + ) @classmethod def add_field(cls, field, insert_after): @@ -435,14 +432,14 @@

        Module exchangelib.properties

        return field_vals def __str__(self): - return self.__class__.__name__ + '(%s)' % ', '.join( - '%s=%r' % (name, val) for name, val in self._field_vals() if val is not None + args_str = ', '.join( + f'{name}={val!r}' for name, val in self._field_vals() if val is not None ) + return f'{self.__class__.__name__}({args_str})' def __repr__(self): - return self.__class__.__name__ + '(%s)' % ', '.join( - '%s=%r' % (name, val) for name, val in self._field_vals() - ) + args_str = ', '.join(f'{name}={val!r}' for name, val in self._field_vals()) + return f'{self.__class__.__name__}({args_str})' class MessageHeader(EWSElement): @@ -454,7 +451,7 @@

        Module exchangelib.properties

        value = SubField() -class BaseItemId(EWSElement): +class BaseItemId(EWSElement, metaclass=EWSMeta): """Base class for ItemId elements.""" ID_ATTR = None @@ -537,7 +534,7 @@

        Module exchangelib.properties

        @classmethod def response_tag(cls): # This element is in MNS in the request and TNS in the response... - return '{%s}%s' % (TNS, cls.ELEMENT_NAME) + return f'{{{TNS}}}{cls.ELEMENT_NAME}' class SourceId(ItemId): @@ -613,7 +610,7 @@

        Module exchangelib.properties

        # A OneOff Mailbox (a one-off member of a personal distribution list) may lack these fields, but other # Mailboxes require at least one. See also "Remarks" section of # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailbox - raise ValueError("Mailbox type %r must have either 'email_address' or 'item_id' set" % self.mailbox_type) + raise ValueError(f"Mailbox type {self.mailbox_type!r} must have either 'email_address' or 'item_id' set") def __hash__(self): # Exchange may add 'mailbox_type' and 'name' on insert. We're satisfied if the item_id or email address matches. @@ -690,7 +687,7 @@

        Module exchangelib.properties

        @classmethod def from_mailbox(cls, mailbox): if not isinstance(mailbox, Mailbox): - raise ValueError("'mailbox' %r must be a Mailbox instance" % mailbox) + raise InvalidTypeError('mailbox', mailbox, Mailbox) return cls(name=mailbox.name, email_address=mailbox.email_address, routing_type=mailbox.routing_type) @@ -736,6 +733,11 @@

        Module exchangelib.properties

        start = DateTimeField(field_uri='StartTime', is_required=True) end = DateTimeField(field_uri='EndTime', is_required=True) + def clean(self, version=None): + if self.start >= self.end: + raise ValueError(f"'start' must be less than 'end' ({self.start} -> {self.end})") + super().clean(version=version) + class FreeBusyViewOptions(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/freebusyviewoptions""" @@ -824,12 +826,15 @@

        Module exchangelib.properties

        :return: A Microsoft timezone ID, as a string """ candidates = set() - for tz_id, tz_name, tz_periods, tz_transitions, tz_transitions_groups in timezones: - candidate = self.from_server_timezone(tz_periods, tz_transitions, tz_transitions_groups, for_year) + for tz_definition in timezones: + candidate = self.from_server_timezone( + tz_definition=tz_definition, + for_year=for_year, + ) if candidate == self: - log.debug('Found exact candidate: %s (%s)', tz_id, tz_name) + log.debug('Found exact candidate: %s (%s)', tz_definition.id, tz_definition.name) # We prefer this timezone over anything else. Return immediately. - return tz_id + return tz_definition.id # Reduce list based on base bias and standard / daylight bias values if candidate.bias != self.bias: continue @@ -849,8 +854,8 @@

        Module exchangelib.properties

        continue if candidate.daylight_time.bias != self.daylight_time.bias: continue - log.debug('Found candidate with matching biases: %s (%s)', tz_id, tz_name) - candidates.add(tz_id) + log.debug('Found candidate with matching biases: %s (%s)', tz_definition.id, tz_definition.name) + candidates.add(tz_definition.id) if not candidates: raise ValueError('No server timezones match this timezone definition') if len(candidates) == 1: @@ -860,81 +865,10 @@

        Module exchangelib.properties

        return candidates.pop() @classmethod - def from_server_timezone(cls, periods, transitions, transitionsgroups, for_year): + def from_server_timezone(cls, tz_definition, for_year): # Creates a TimeZone object from the result of a GetServerTimeZones call with full timezone data - - # Get the default bias - bias = cls._get_bias(periods=periods, for_year=for_year) - - # Get a relevant transition ID - valid_tg_id = cls._get_valid_transition_id(transitions=transitions, for_year=for_year) - transitiongroup = transitionsgroups[valid_tg_id] - if not 0 <= len(transitiongroup) <= 2: - raise ValueError('Expected 0-2 transitions in transitionsgroup %s' % transitiongroup) - - standard_time, daylight_time = cls._get_std_and_dst(transitiongroup=transitiongroup, periods=periods, bias=bias) - return cls(bias=bias, standard_time=standard_time, daylight_time=daylight_time) - - @staticmethod - def _get_bias(periods, for_year): - # Set a default bias - valid_period = None - for (year, period_type), period in sorted(periods.items()): - if year > for_year: - break - if period_type != 'Standard': - continue - valid_period = period - if valid_period is None: - raise TimezoneDefinitionInvalidForYear('Year %s not included in periods %s' % (for_year, periods)) - return int(valid_period['bias'].total_seconds()) // 60 # Convert to minutes - - @staticmethod - def _get_valid_transition_id(transitions, for_year): - # Look through the transitions, and pick the relevant one according to the 'for_year' value - valid_tg_id = None - for tg_id, from_date in sorted(transitions.items()): - if from_date and from_date.year > for_year: - break - valid_tg_id = tg_id - if valid_tg_id is None: - raise ValueError('No valid transition for year %s: %s' % (for_year, transitions)) - return valid_tg_id - - @staticmethod - def _get_std_and_dst(transitiongroup, periods, bias): - # Return 'standard_time' and 'daylight_time' objects. We do unnecessary work here, but it keeps code simple. - standard_time, daylight_time = None, None - for transition in transitiongroup: - period = periods[transition['to']] - if len(transition) == 1: - # This is a simple transition representing a timezone with no DST. Some servers don't accept TimeZone - # elements without a STD and DST element (see issue #488). Return StandardTime and DaylightTime objects - # with dummy values and 0 bias - this satisfies the broken servers and hopefully doesn't break the - # well-behaving servers. - standard_time = StandardTime(bias=0, time=datetime.time(0), occurrence=1, iso_month=1, weekday=1) - daylight_time = DaylightTime(bias=0, time=datetime.time(0), occurrence=5, iso_month=12, weekday=7) - continue - # 'offset' is the time of day to transition, as timedelta since midnight. Must be a reasonable value - if not datetime.timedelta(0) <= transition['offset'] < datetime.timedelta(days=1): - raise ValueError("'offset' value %s must be be between 0 and 24 hours" % transition['offset']) - transition_kwargs = dict( - time=(datetime.datetime(2000, 1, 1) + transition['offset']).time(), - occurrence=transition['occurrence'], - iso_month=transition['iso_month'], - weekday=transition['iso_weekday'], - ) - if period['name'] == 'Standard': - transition_kwargs['bias'] = 0 - standard_time = StandardTime(**transition_kwargs) - continue - if period['name'] == 'Daylight': - dst_bias = int(period['bias'].total_seconds()) // 60 # Convert to minutes - transition_kwargs['bias'] = dst_bias - bias - daylight_time = DaylightTime(**transition_kwargs) - continue - raise ValueError('Unknown transition: %s' % transition) - return standard_time, daylight_time + std_time, daylight_time, period = tz_definition.get_std_and_dst(for_year=for_year) + return cls(bias=period.bias_in_minutes, standard_time=std_time, daylight_time=daylight_time) class CalendarView(EWSElement): @@ -1010,7 +944,7 @@

        Module exchangelib.properties

        @classmethod def from_xml(cls, elem, account): kwargs = {} - working_hours_elem = elem.find('{%s}WorkingHours' % TNS) + working_hours_elem = elem.find(f'{{{TNS}}}WorkingHours') for f in cls.FIELDS: if f.name in ['working_hours', 'working_hours_timezone']: if working_hours_elem is None: @@ -1032,7 +966,7 @@

        Module exchangelib.properties

        def response_tag(cls): # In a GetRoomLists response, room lists are delivered as Address elements. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/address-emailaddresstype - return '{%s}Address' % TNS + return f'{{{TNS}}}Address' class Room(Mailbox): @@ -1042,12 +976,12 @@

        Module exchangelib.properties

        @classmethod def from_xml(cls, elem, account): - id_elem = elem.find('{%s}Id' % TNS) + id_elem = elem.find(f'{{{TNS}}}Id') item_id_elem = id_elem.find(ItemId.response_tag()) kwargs = dict( - name=get_xml_attr(id_elem, '{%s}Name' % TNS), - email_address=get_xml_attr(id_elem, '{%s}EmailAddress' % TNS), - mailbox_type=get_xml_attr(id_elem, '{%s}MailboxType' % TNS), + name=get_xml_attr(id_elem, f'{{{TNS}}}Name'), + email_address=get_xml_attr(id_elem, f'{{{TNS}}}EmailAddress'), + mailbox_type=get_xml_attr(id_elem, f'{{{TNS}}}MailboxType'), item_id=ItemId.from_xml(elem=item_id_elem, account=account) if item_id_elem else None, ) cls._clear(elem) @@ -1246,7 +1180,7 @@

        Module exchangelib.properties

        @classmethod def duration_to_start_end(cls, elem, account): kwargs = {} - duration = elem.find('{%s}Duration' % TNS) + duration = elem.find(f'{{{TNS}}}Duration') if duration is not None: for attr in ('start', 'end'): f = cls.get_field_by_fieldname(attr) @@ -1307,7 +1241,7 @@

        Module exchangelib.properties

        @classmethod def response_tag(cls): # This element is in TNS in the request and MNS in the response... - return '{%s}%s' % (MNS, cls.ELEMENT_NAME) + return f'{{{MNS}}}{cls.ELEMENT_NAME}' class AlternatePublicFolderId(EWSElement): @@ -1465,9 +1399,9 @@

        Module exchangelib.properties

        ID_ELEMENT_CLS = None def __init__(self, **kwargs): - _id = self.ID_ELEMENT_CLS(kwargs.pop('id', None), kwargs.pop('changekey', None)) - if _id.id or _id.changekey: - kwargs['_id'] = _id + _id, _changekey = kwargs.pop('id', None), kwargs.pop('changekey', None) + if _id or _changekey: + kwargs['_id'] = self.ID_ELEMENT_CLS(_id, _changekey) super().__init__(**kwargs) @classmethod @@ -1508,8 +1442,10 @@

        Module exchangelib.properties

        return None, None return id_elem.get(cls.ID_ELEMENT_CLS.ID_ATTR), id_elem.get(cls.ID_ELEMENT_CLS.CHANGEKEY_ATTR) - def to_id_xml(self, version): - return self._id.to_xml(version=version) + def to_id(self): + if self._id is None: + raise ValueError('Must have an ID') + return self._id def __eq__(self, other): if isinstance(other, tuple): @@ -1544,7 +1480,7 @@

        Module exchangelib.properties

        def clean(self, version=None): from .folders import BaseFolder if isinstance(self.folder, BaseFolder): - self.folder = self.folder.to_folder_id() + self.folder = self.folder.to_id() super().clean(version=version) @classmethod @@ -1802,7 +1738,188 @@

        Module exchangelib.properties

        subscription_id = CharField(field_uri='SubscriptionId') previous_watermark = CharField(field_uri='PreviousWatermark') more_events = BooleanField(field_uri='MoreEvents') - events = GenericEventListField('')
        + events = GenericEventListField('') + + +class BaseTransition(EWSElement, metaclass=EWSMeta): + """Base class for all other transition classes""" + + to = CharField(field_uri='To') + kind = CharField(field_uri='Kind', is_attribute=True) # An attribute on the 'To' element + + @staticmethod + def transition_model_from_tag(tag): + return {cls.response_tag(): cls for cls in ( + Transition, AbsoluteDateTransition, RecurringDateTransition, RecurringDayTransition + )}[tag] + + @classmethod + def from_xml(cls, elem, account): + kind = elem.find(cls.get_field_by_fieldname('to').response_tag()).get('Kind') + res = super().from_xml(elem=elem, account=account) + res.kind = kind + return res + + +class Transition(BaseTransition): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/transition""" + + ELEMENT_NAME = 'Transition' + + +class AbsoluteDateTransition(BaseTransition): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/absolutedatetransition""" + + ELEMENT_NAME = 'AbsoluteDateTransition' + + date = DateTimeBackedDateField(field_uri='DateTime') + + +class RecurringDayTransition(BaseTransition): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recurringdaytransition""" + + ELEMENT_NAME = 'RecurringDayTransition' + + offset = TimeDeltaField(field_uri='TimeOffset') + month = IntegerField(field_uri='Month') + # Valid ISO 8601 weekday, as a number in range 1 -> 7 (1 being Monday) + day_of_week = EnumField(field_uri='DayOfWeek', enum=WEEKDAY_NAMES) + occurrence = IntegerField(field_uri='Occurrence') + + @classmethod + def from_xml(cls, elem, account): + res = super().from_xml(elem, account) + # See TimeZoneTransition.from_xml() + if res.occurrence == -1: + res.occurrence = 5 + return res + + +class RecurringDateTransition(BaseTransition): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recurringdatetransition""" + + ELEMENT_NAME = 'RecurringDateTransition' + + offset = TimeDeltaField(field_uri='TimeOffset') + month = IntegerField(field_uri='Month') + day = IntegerField(field_uri='Day') # Day of month + + +class Period(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/period""" + + ELEMENT_NAME = 'Period' + + id = CharField(field_uri='Id', is_attribute=True) + name = CharField(field_uri='Name', is_attribute=True) + bias = TimeDeltaField(field_uri='Bias', is_attribute=True) + + def _split_id(self): + to_year, to_type = self.id.rsplit('/', 1)[1].split('-') + return int(to_year), to_type + + @property + def year(self): + return self._split_id()[0] + + @property + def type(self): + return self._split_id()[1] + + @property + def bias_in_minutes(self): + return int(self.bias.total_seconds()) // 60 # Convert to minutes + + +class TransitionsGroup(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/transitionsgroup""" + + ELEMENT_NAME = 'TransitionsGroup' + + id = CharField(field_uri='Id', is_attribute=True) + transitions = TransitionListField(value_cls=BaseTransition) + + +class TimeZoneDefinition(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezonedefinition""" + + ELEMENT_NAME = 'TimeZoneDefinition' + + id = CharField(field_uri='Id', is_attribute=True) + name = CharField(field_uri='Name', is_attribute=True) + + periods = EWSElementListField(field_uri='Periods', value_cls=Period) + transitions_groups = EWSElementListField(field_uri='TransitionsGroups', value_cls=TransitionsGroup) + transitions = TransitionListField(field_uri='Transitions', value_cls=BaseTransition) + + @classmethod + def from_xml(cls, elem, account): + return super().from_xml(elem, account) + + def _get_standard_period(self, for_year): + # Look through periods and pick a relevant period according to the 'for_year' value + valid_period = None + for period in sorted(self.periods, key=lambda p: (p.year, p.type)): + if period.year > for_year: + break + if period.type != 'Standard': + continue + valid_period = period + if valid_period is None: + raise TimezoneDefinitionInvalidForYear(f'Year {for_year} not included in periods {self.periods}') + return valid_period + + def _get_transitions_group(self, for_year): + # Look through the transitions, and pick the relevant transition group according to the 'for_year' value + transitions_group = None + transitions_groups_map = {tg.id: tg for tg in self.transitions_groups} + for transition in sorted(self.transitions, key=lambda t: t.to): + if transition.kind != 'Group': + continue + if isinstance(transition, AbsoluteDateTransition) and transition.date.year > for_year: + break + transitions_group = transitions_groups_map[transition.to] + if transitions_group is None: + raise ValueError(f'No valid transition group for year {for_year}: {self.transitions}') + return transitions_group + + def get_std_and_dst(self, for_year): + # Return 'standard_time' and 'daylight_time' objects. We do unnecessary work here, but it keeps code simple. + transitions_group = self._get_transitions_group(for_year) + if not 0 <= len(transitions_group.transitions) <= 2: + raise ValueError(f'Expected 0-2 transitions in transitions group {transitions_group}') + + standard_period = self._get_standard_period(for_year) + periods_map = {p.id: p for p in self.periods} + standard_time, daylight_time = None, None + if len(transitions_group.transitions) == 1: + # This is a simple transition group representing a timezone with no DST. Some servers don't accept + # TimeZone elements without a STD and DST element (see issue #488). Return StandardTime and DaylightTime + # objects with dummy values and 0 bias - this satisfies the broken servers and hopefully doesn't break + # the well-behaving servers. + standard_time = StandardTime(bias=0, time=datetime.time(0), occurrence=1, iso_month=1, weekday=1) + daylight_time = DaylightTime(bias=0, time=datetime.time(0), occurrence=5, iso_month=12, weekday=7) + return standard_time, daylight_time, standard_period + for transition in transitions_group.transitions: + # 'offset' is the time of day to transition, as timedelta since midnight. Check that it's a reasonable value + transition.clean(version=None) + transition_kwargs = dict( + time=(datetime.datetime(2000, 1, 1) + transition.offset).time(), + occurrence=transition.occurrence, + iso_month=transition.month, + weekday=transition.day_of_week, + ) + period = periods_map[transition.to] + if period.name == 'Standard': + transition_kwargs['bias'] = 0 + standard_time = StandardTime(**transition_kwargs) + continue + if period.name == 'Daylight': + transition_kwargs['bias'] = period.bias_in_minutes - standard_period.bias_in_minutes + daylight_time = DaylightTime(**transition_kwargs) + continue + raise ValueError(f'Unknown transition: {transition}') + return standard_time, daylight_time, standard_period
        @@ -1814,6 +1931,58 @@

        Module exchangelib.properties

        Classes

        +
        +class AbsoluteDateTransition +(**kwargs) +
        +
        + +
        + +Expand source code + +
        class AbsoluteDateTransition(BaseTransition):
        +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/absolutedatetransition"""
        +
        +    ELEMENT_NAME = 'AbsoluteDateTransition'
        +
        +    date = DateTimeBackedDateField(field_uri='DateTime')
        +
        +

        Ancestors

        + +

        Class variables

        +
        +
        var ELEMENT_NAME
        +
        +
        +
        +
        var FIELDS
        +
        +
        +
        +
        +

        Instance variables

        +
        +
        var date
        +
        +
        +
        +
        +

        Inherited members

        + +
        class AcceptSharingInvitation (**kwargs) @@ -1932,7 +2101,7 @@

        Inherited members

        @classmethod def response_tag(cls): # This element is in TNS in the request and MNS in the response... - return '{%s}%s' % (MNS, cls.ELEMENT_NAME)
        + return f'{{{MNS}}}{cls.ELEMENT_NAME}'

        Ancestors

          @@ -1963,7 +2132,7 @@

          Static methods

          @classmethod
           def response_tag(cls):
               # This element is in TNS in the request and MNS in the response...
          -    return '{%s}%s' % (MNS, cls.ELEMENT_NAME)
          + return f'{{{MNS}}}{cls.ELEMENT_NAME}'
        @@ -2350,7 +2519,7 @@

        Inherited members

        @classmethod def from_mailbox(cls, mailbox): if not isinstance(mailbox, Mailbox): - raise ValueError("'mailbox' %r must be a Mailbox instance" % mailbox) + raise InvalidTypeError('mailbox', mailbox, Mailbox) return cls(name=mailbox.name, email_address=mailbox.email_address, routing_type=mailbox.routing_type)

        Ancestors

        @@ -2386,7 +2555,7 @@

        Static methods

        @classmethod
         def from_mailbox(cls, mailbox):
             if not isinstance(mailbox, Mailbox):
        -        raise ValueError("'mailbox' %r must be a Mailbox instance" % mailbox)
        +        raise InvalidTypeError('mailbox', mailbox, Mailbox)
             return cls(name=mailbox.name, email_address=mailbox.email_address, routing_type=mailbox.routing_type)
        @@ -2428,7 +2597,7 @@

        Inherited members

        Expand source code -
        class BaseItemId(EWSElement):
        +
        class BaseItemId(EWSElement, metaclass=EWSMeta):
             """Base class for ItemId elements."""
         
             ID_ATTR = None
        @@ -2570,6 +2739,112 @@ 

        Inherited members

      +
      +class BaseTransition +(**kwargs) +
      +
      +

      Base class for all other transition classes

      +
      + +Expand source code + +
      class BaseTransition(EWSElement, metaclass=EWSMeta):
      +    """Base class for all other transition classes"""
      +
      +    to = CharField(field_uri='To')
      +    kind = CharField(field_uri='Kind', is_attribute=True)  # An attribute on the 'To' element
      +
      +    @staticmethod
      +    def transition_model_from_tag(tag):
      +        return {cls.response_tag(): cls for cls in (
      +            Transition, AbsoluteDateTransition, RecurringDateTransition, RecurringDayTransition
      +        )}[tag]
      +
      +    @classmethod
      +    def from_xml(cls, elem, account):
      +        kind = elem.find(cls.get_field_by_fieldname('to').response_tag()).get('Kind')
      +        res = super().from_xml(elem=elem, account=account)
      +        res.kind = kind
      +        return res
      +
      +

      Ancestors

      + +

      Subclasses

      + +

      Class variables

      +
      +
      var FIELDS
      +
      +
      +
      +
      +

      Static methods

      +
      +
      +def from_xml(elem, account) +
      +
      +
      +
      + +Expand source code + +
      @classmethod
      +def from_xml(cls, elem, account):
      +    kind = elem.find(cls.get_field_by_fieldname('to').response_tag()).get('Kind')
      +    res = super().from_xml(elem=elem, account=account)
      +    res.kind = kind
      +    return res
      +
      +
      +
      +def transition_model_from_tag(tag) +
      +
      +
      +
      + +Expand source code + +
      @staticmethod
      +def transition_model_from_tag(tag):
      +    return {cls.response_tag(): cls for cls in (
      +        Transition, AbsoluteDateTransition, RecurringDateTransition, RecurringDayTransition
      +    )}[tag]
      +
      +
      +
      +

      Instance variables

      +
      +
      var kind
      +
      +
      +
      +
      var to
      +
      +
      +
      +
      +

      Inherited members

      + +
      class Body (...) @@ -3712,7 +3987,7 @@

      Inherited members

      for f in self.FIELDS: setattr(self, f.name, kwargs.pop(f.name, None)) if kwargs: - raise AttributeError("%s are invalid kwargs for this class" % ', '.join("'%s'" % k for k in kwargs)) + raise AttributeError(f"{sorted(kwargs.keys())!r} are invalid kwargs for this class") def __setattr__(self, key, value): # Avoid silently accepting spelling errors to field names that are not set via __init__. We need to be able to @@ -3725,8 +4000,9 @@

      Inherited members

      if hasattr(self, key): # Property setters return super().__setattr__(key, value) - raise AttributeError('%r is not a valid attribute. See %s.FIELDS for valid field names' % ( - key, self.__class__.__name__)) + raise AttributeError( + f'{key!r} is not a valid attribute. See {self.__class__.__name__}.FIELDS for valid field names' + ) def clean(self, version=None): # Validate attribute values using the field validator @@ -3761,17 +4037,18 @@

      Inherited members

      # WARNING: The order of addition of XML elements is VERY important. Exchange expects XML elements in a # specific, non-documented order and will fail with meaningless errors if the order is wrong. - # Call create_element() without args, to not fill up the cache with unique attribute values. - elem = create_element(self.request_tag()) - - # Add attributes + # Collect attributes + attrs = {} for f in self.attribute_fields(): if f.is_read_only: continue value = getattr(self, f.name) if value is None or (f.is_list and not value): continue - elem.set(f.field_uri, value_to_xml_text(getattr(self, f.name))) + attrs[f.field_uri] = value_to_xml_text(getattr(self, f.name)) + + # Create element with attributes + elem = create_element(self.request_tag(), attrs=attrs) # Add elements and values for f in self.supported_fields(version=version): @@ -3780,25 +4057,25 @@

      Inherited members

      value = getattr(self, f.name) if value is None or (f.is_list and not value): continue - set_xml_value(elem, f.to_xml(value, version=version), version) + set_xml_value(elem, f.to_xml(value, version=version)) return elem @classmethod def request_tag(cls): if not cls.ELEMENT_NAME: - raise ValueError('Class %s is missing the ELEMENT_NAME attribute' % cls) + raise ValueError(f'Class {cls} is missing the ELEMENT_NAME attribute') return { - TNS: 't:%s' % cls.ELEMENT_NAME, - MNS: 'm:%s' % cls.ELEMENT_NAME, + TNS: f't:{cls.ELEMENT_NAME}', + MNS: f'm:{cls.ELEMENT_NAME}', }[cls.NAMESPACE] @classmethod def response_tag(cls): if not cls.NAMESPACE: - raise ValueError('Class %s is missing the NAMESPACE attribute' % cls) + raise ValueError(f'Class {cls} is missing the NAMESPACE attribute') if not cls.ELEMENT_NAME: - raise ValueError('Class %s is missing the ELEMENT_NAME attribute' % cls) - return '{%s}%s' % (cls.NAMESPACE, cls.ELEMENT_NAME) + raise ValueError(f'Class {cls} is missing the ELEMENT_NAME attribute') + return f'{{{cls.NAMESPACE}}}{cls.ELEMENT_NAME}' @classmethod def attribute_fields(cls): @@ -3815,7 +4092,7 @@

      Inherited members

      try: return cls.FIELDS[fieldname] except KeyError: - raise InvalidField("'%s' is not a valid field name on '%s'" % (fieldname, cls.__name__)) + raise InvalidField(f"{fieldname!r} is not a valid field name on {cls.__name__}") @classmethod def validate_field(cls, field, version): @@ -3825,21 +4102,18 @@

      Inherited members

      :param field: :param version: """ - if not isinstance(version, Version): - raise ValueError("'version' %r must be a Version instance" % version) # Allow both Field and FieldPath instances and string field paths as input if isinstance(field, str): field = cls.get_field_by_fieldname(fieldname=field) elif isinstance(field, FieldPath): field = field.field - if not isinstance(field, Field): - raise ValueError("Field %r must be a string, Field or FieldPath instance" % field) cls.get_field_by_fieldname(fieldname=field.name) # Will raise if field name is invalid if not field.supports_version(version): # The field exists but is not valid for this version raise InvalidFieldForVersion( - "Field '%s' is not supported on server version %s (supported from: %s, deprecated from: %s)" - % (field.name, version, field.supported_from, field.deprecated_from)) + f"Field {field.name!r} is not supported on server version {version} " + f"(supported from: {field.supported_from}, deprecated from: {field.deprecated_from})" + ) @classmethod def add_field(cls, field, insert_after): @@ -3885,14 +4159,14 @@

      Inherited members

      return field_vals def __str__(self): - return self.__class__.__name__ + '(%s)' % ', '.join( - '%s=%r' % (name, val) for name, val in self._field_vals() if val is not None + args_str = ', '.join( + f'{name}={val!r}' for name, val in self._field_vals() if val is not None ) + return f'{self.__class__.__name__}({args_str})' def __repr__(self): - return self.__class__.__name__ + '(%s)' % ', '.join( - '%s=%r' % (name, val) for name, val in self._field_vals() - )
      + args_str = ', '.join(f'{name}={val!r}' for name, val in self._field_vals()) + return f'{self.__class__.__name__}({args_str})'

      Subclasses

        @@ -3913,6 +4187,7 @@

        Subclasses

      • AvailabilityMailbox
      • BaseItemId
      • BasePermission
      • +
      • BaseTransition
      • BodyContentAttributedValue
      • BodyContentValue
      • CalendarEvent
      • @@ -3940,6 +4215,7 @@

        Subclasses

      • MessageHeader
      • Notification
      • OutOfOffice
      • +
      • Period
      • PermissionSet
      • PersonaPhoneNumberTypeValue
      • PhoneNumber
      • @@ -3953,7 +4229,9 @@

        Subclasses

      • SuppressReadReceipt
      • TimeWindow
      • TimeZone
      • +
      • TimeZoneDefinition
      • TimeZoneTransition
      • +
      • TransitionsGroup
      • UserConfigurationName
      • UserId
      • WorkingPeriod
      • @@ -4050,7 +4328,7 @@

        Static methods

        try: return cls.FIELDS[fieldname] except KeyError: - raise InvalidField("'%s' is not a valid field name on '%s'" % (fieldname, cls.__name__))
        + raise InvalidField(f"{fieldname!r} is not a valid field name on {cls.__name__}")
        @@ -4088,10 +4366,10 @@

        Static methods

        @classmethod
         def request_tag(cls):
             if not cls.ELEMENT_NAME:
        -        raise ValueError('Class %s is missing the ELEMENT_NAME attribute' % cls)
        +        raise ValueError(f'Class {cls} is missing the ELEMENT_NAME attribute')
             return {
        -        TNS: 't:%s' % cls.ELEMENT_NAME,
        -        MNS: 'm:%s' % cls.ELEMENT_NAME,
        +        TNS: f't:{cls.ELEMENT_NAME}',
        +        MNS: f'm:{cls.ELEMENT_NAME}',
             }[cls.NAMESPACE]
        @@ -4107,10 +4385,10 @@

        Static methods

        @classmethod
         def response_tag(cls):
             if not cls.NAMESPACE:
        -        raise ValueError('Class %s is missing the NAMESPACE attribute' % cls)
        +        raise ValueError(f'Class {cls} is missing the NAMESPACE attribute')
             if not cls.ELEMENT_NAME:
        -        raise ValueError('Class %s is missing the ELEMENT_NAME attribute' % cls)
        -    return '{%s}%s' % (cls.NAMESPACE, cls.ELEMENT_NAME)
        + raise ValueError(f'Class {cls} is missing the ELEMENT_NAME attribute') + return f'{{{cls.NAMESPACE}}}{cls.ELEMENT_NAME}'
        @@ -4149,21 +4427,18 @@

        Static methods

        :param field: :param version: """ - if not isinstance(version, Version): - raise ValueError("'version' %r must be a Version instance" % version) # Allow both Field and FieldPath instances and string field paths as input if isinstance(field, str): field = cls.get_field_by_fieldname(fieldname=field) elif isinstance(field, FieldPath): field = field.field - if not isinstance(field, Field): - raise ValueError("Field %r must be a string, Field or FieldPath instance" % field) cls.get_field_by_fieldname(fieldname=field.name) # Will raise if field name is invalid if not field.supports_version(version): # The field exists but is not valid for this version raise InvalidFieldForVersion( - "Field '%s' is not supported on server version %s (supported from: %s, deprecated from: %s)" - % (field.name, version, field.supported_from, field.deprecated_from))
        + f"Field {field.name!r} is not supported on server version {version} " + f"(supported from: {field.supported_from}, deprecated from: {field.deprecated_from})" + ) @@ -4205,17 +4480,18 @@

        Methods

        # WARNING: The order of addition of XML elements is VERY important. Exchange expects XML elements in a # specific, non-documented order and will fail with meaningless errors if the order is wrong. - # Call create_element() without args, to not fill up the cache with unique attribute values. - elem = create_element(self.request_tag()) - - # Add attributes + # Collect attributes + attrs = {} for f in self.attribute_fields(): if f.is_read_only: continue value = getattr(self, f.name) if value is None or (f.is_list and not value): continue - elem.set(f.field_uri, value_to_xml_text(getattr(self, f.name))) + attrs[f.field_uri] = value_to_xml_text(getattr(self, f.name)) + + # Create element with attributes + elem = create_element(self.request_tag(), attrs=attrs) # Add elements and values for f in self.supported_fields(version=version): @@ -4224,7 +4500,7 @@

        Methods

        value = getattr(self, f.name) if value is None or (f.is_list and not value): continue - set_xml_value(elem, f.to_xml(value, version=version), version) + set_xml_value(elem, f.to_xml(value, version=version)) return elem @@ -4901,7 +5177,7 @@

        Inherited members

        for f in fields: # Check for duplicate field names if f.name in self._dict: - raise ValueError('Field %r is a duplicate' % f) + raise ValueError(f'Field {f!r} is a duplicate') self._dict[f.name] = f def __getitem__(self, idx_or_slice): @@ -4924,9 +5200,7 @@

        Inherited members

        return self def __contains__(self, item): - if isinstance(item, str): - return item in self._dict - return super().__contains__(item) + return item in self._dict def copy(self): return self.__class__(*self) @@ -4935,11 +5209,11 @@

        Inherited members

        for i, f in enumerate(self): if f.name == field_name: return i - raise ValueError('Unknown field name %r' % field_name) + raise ValueError(f'Unknown field name {field_name!r}') def insert(self, index, field): if field.name in self._dict: - raise ValueError('Field %r is a duplicate' % field) + raise ValueError(f'Field {field!r} is a duplicate') super().insert(index, field) self._dict[field.name] = field @@ -4997,7 +5271,7 @@

        Methods

        for i, f in enumerate(self): if f.name == field_name: return i - raise ValueError('Unknown field name %r' % field_name) + raise ValueError(f'Unknown field name {field_name!r}')
        @@ -5011,7 +5285,7 @@

        Methods

        def insert(self, index, field):
             if field.name in self._dict:
        -        raise ValueError('Field %r is a duplicate' % field)
        +        raise ValueError(f'Field {field!r} is a duplicate')
             super().insert(index, field)
             self._dict[field.name] = field
        @@ -5148,7 +5422,7 @@

        Inherited members

        @classmethod def from_xml(cls, elem, account): kwargs = {} - working_hours_elem = elem.find('{%s}WorkingHours' % TNS) + working_hours_elem = elem.find(f'{{{TNS}}}WorkingHours') for f in cls.FIELDS: if f.name in ['working_hours', 'working_hours_timezone']: if working_hours_elem is None: @@ -5192,7 +5466,7 @@

        Static methods

        @classmethod
         def from_xml(cls, elem, account):
             kwargs = {}
        -    working_hours_elem = elem.find('{%s}WorkingHours' % TNS)
        +    working_hours_elem = elem.find(f'{{{TNS}}}WorkingHours')
             for f in cls.FIELDS:
                 if f.name in ['working_hours', 'working_hours_timezone']:
                     if working_hours_elem is None:
        @@ -5368,9 +5642,9 @@ 

        Inherited members

        ID_ELEMENT_CLS = None def __init__(self, **kwargs): - _id = self.ID_ELEMENT_CLS(kwargs.pop('id', None), kwargs.pop('changekey', None)) - if _id.id or _id.changekey: - kwargs['_id'] = _id + _id, _changekey = kwargs.pop('id', None), kwargs.pop('changekey', None) + if _id or _changekey: + kwargs['_id'] = self.ID_ELEMENT_CLS(_id, _changekey) super().__init__(**kwargs) @classmethod @@ -5411,8 +5685,10 @@

        Inherited members

        return None, None return id_elem.get(cls.ID_ELEMENT_CLS.ID_ATTR), id_elem.get(cls.ID_ELEMENT_CLS.CHANGEKEY_ATTR) - def to_id_xml(self, version): - return self._id.to_xml(version=version) + def to_id(self): + if self._id is None: + raise ValueError('Must have an ID') + return self._id def __eq__(self, other): if isinstance(other, tuple): @@ -5514,8 +5790,8 @@

        Instance variables

        Methods

        -
        -def to_id_xml(self, version) +
        +def to_id(self)
        @@ -5523,8 +5799,10 @@

        Methods

        Expand source code -
        def to_id_xml(self, version):
        -    return self._id.to_xml(version=version)
        +
        def to_id(self):
        +    if self._id is None:
        +        raise ValueError('Must have an ID')
        +    return self._id
        @@ -5821,7 +6099,7 @@

        Inherited members

        # A OneOff Mailbox (a one-off member of a personal distribution list) may lack these fields, but other # Mailboxes require at least one. See also "Remarks" section of # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailbox - raise ValueError("Mailbox type %r must have either 'email_address' or 'item_id' set" % self.mailbox_type) + raise ValueError(f"Mailbox type {self.mailbox_type!r} must have either 'email_address' or 'item_id' set") def __hash__(self): # Exchange may add 'mailbox_type' and 'name' on insert. We're satisfied if the item_id or email address matches. @@ -5911,7 +6189,7 @@

        Methods

        # A OneOff Mailbox (a one-off member of a personal distribution list) may lack these fields, but other # Mailboxes require at least one. See also "Remarks" section of # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailbox - raise ValueError("Mailbox type %r must have either 'email_address' or 'item_id' set" % self.mailbox_type)
        + raise ValueError(f"Mailbox type {self.mailbox_type!r} must have either 'email_address' or 'item_id' set") @@ -6545,7 +6823,7 @@

        Inherited members

        @classmethod def duration_to_start_end(cls, elem, account): kwargs = {} - duration = elem.find('{%s}Duration' % TNS) + duration = elem.find(f'{{{TNS}}}Duration') if duration is not None: for attr in ('start', 'end'): f = cls.get_field_by_fieldname(attr) @@ -6591,7 +6869,7 @@

        Static methods

        @classmethod
         def duration_to_start_end(cls, elem, account):
             kwargs = {}
        -    duration = elem.find('{%s}Duration' % TNS)
        +    duration = elem.find(f'{{{TNS}}}Duration')
             if duration is not None:
                 for attr in ('start', 'end'):
                     f = cls.get_field_by_fieldname(attr)
        @@ -6732,32 +7010,145 @@ 

        Inherited members

      -
      -class Permission +
      +class Period (**kwargs)
      - +
      Expand source code -
      class Permission(BasePermission):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/permission"""
      +
      class Period(EWSElement):
      +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/period"""
       
      -    ELEMENT_NAME = 'Permission'
      -    LEVEL_CHOICES = (
      -        'None', 'Owner', 'PublishingEditor', 'Editor', 'PublishingAuthor', 'Author', 'NoneditingAuthor', 'Reviewer',
      -        'Contributor', 'Custom',
      -    )
      +    ELEMENT_NAME = 'Period'
       
      -    permission_level = ChoiceField(
      -        field_uri='CalendarPermissionLevel', choices={Choice(c) for c in LEVEL_CHOICES}, default=LEVEL_CHOICES[0]
      -    )
      + id = CharField(field_uri='Id', is_attribute=True) + name = CharField(field_uri='Name', is_attribute=True) + bias = TimeDeltaField(field_uri='Bias', is_attribute=True) + + def _split_id(self): + to_year, to_type = self.id.rsplit('/', 1)[1].split('-') + return int(to_year), to_type + + @property + def year(self): + return self._split_id()[0] + + @property + def type(self): + return self._split_id()[1] + + @property + def bias_in_minutes(self): + return int(self.bias.total_seconds()) // 60 # Convert to minutes

      Ancestors

      +

      Class variables

      +
      +
      var ELEMENT_NAME
      +
      +
      +
      +
      var FIELDS
      +
      +
      +
      +
      +

      Instance variables

      +
      +
      var bias
      +
      +
      +
      +
      var bias_in_minutes
      +
      +
      +
      + +Expand source code + +
      @property
      +def bias_in_minutes(self):
      +    return int(self.bias.total_seconds()) // 60  # Convert to minutes
      +
      +
      +
      var id
      +
      +
      +
      +
      var name
      +
      +
      +
      +
      var type
      +
      +
      +
      + +Expand source code + +
      @property
      +def type(self):
      +    return self._split_id()[1]
      +
      +
      +
      var year
      +
      +
      +
      + +Expand source code + +
      @property
      +def year(self):
      +    return self._split_id()[0]
      +
      +
      +
      +

      Inherited members

      + +
      +
      +class Permission +(**kwargs) +
      +
      + +
      + +Expand source code + +
      class Permission(BasePermission):
      +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/permission"""
      +
      +    ELEMENT_NAME = 'Permission'
      +    LEVEL_CHOICES = (
      +        'None', 'Owner', 'PublishingEditor', 'Editor', 'PublishingAuthor', 'Author', 'NoneditingAuthor', 'Reviewer',
      +        'Contributor', 'Custom',
      +    )
      +
      +    permission_level = ChoiceField(
      +        field_uri='CalendarPermissionLevel', choices={Choice(c) for c in LEVEL_CHOICES}, default=LEVEL_CHOICES[0]
      +    )
      +
      +

      Ancestors

      +

      Class variables

      @@ -6882,7 +7273,7 @@

      Inherited members

      @classmethod def response_tag(cls): # This element is in MNS in the request and TNS in the response... - return '{%s}%s' % (TNS, cls.ELEMENT_NAME)
      + return f'{{{TNS}}}{cls.ELEMENT_NAME}'

      Ancestors

        @@ -6915,7 +7306,7 @@

        Static methods

        @classmethod
         def response_tag(cls):
             # This element is in MNS in the request and TNS in the response...
        -    return '{%s}%s' % (TNS, cls.ELEMENT_NAME)
        + return f'{{{TNS}}}{cls.ELEMENT_NAME}'
      @@ -7332,6 +7723,165 @@

      Inherited members

    +
    +class RecurringDateTransition +(**kwargs) +
    +
    + +
    + +Expand source code + +
    class RecurringDateTransition(BaseTransition):
    +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recurringdatetransition"""
    +
    +    ELEMENT_NAME = 'RecurringDateTransition'
    +
    +    offset = TimeDeltaField(field_uri='TimeOffset')
    +    month = IntegerField(field_uri='Month')
    +    day = IntegerField(field_uri='Day')  # Day of month
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var ELEMENT_NAME
    +
    +
    +
    +
    var FIELDS
    +
    +
    +
    +
    +

    Instance variables

    +
    +
    var day
    +
    +
    +
    +
    var month
    +
    +
    +
    +
    var offset
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class RecurringDayTransition +(**kwargs) +
    +
    + +
    + +Expand source code + +
    class RecurringDayTransition(BaseTransition):
    +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recurringdaytransition"""
    +
    +    ELEMENT_NAME = 'RecurringDayTransition'
    +
    +    offset = TimeDeltaField(field_uri='TimeOffset')
    +    month = IntegerField(field_uri='Month')
    +    # Valid ISO 8601 weekday, as a number in range 1 -> 7 (1 being Monday)
    +    day_of_week = EnumField(field_uri='DayOfWeek', enum=WEEKDAY_NAMES)
    +    occurrence = IntegerField(field_uri='Occurrence')
    +
    +    @classmethod
    +    def from_xml(cls, elem, account):
    +        res = super().from_xml(elem, account)
    +        # See TimeZoneTransition.from_xml()
    +        if res.occurrence == -1:
    +            res.occurrence = 5
    +        return res
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var ELEMENT_NAME
    +
    +
    +
    +
    var FIELDS
    +
    +
    +
    +
    +

    Static methods

    +
    +
    +def from_xml(elem, account) +
    +
    +
    +
    + +Expand source code + +
    @classmethod
    +def from_xml(cls, elem, account):
    +    res = super().from_xml(elem, account)
    +    # See TimeZoneTransition.from_xml()
    +    if res.occurrence == -1:
    +        res.occurrence = 5
    +    return res
    +
    +
    +
    +

    Instance variables

    +
    +
    var day_of_week
    +
    +
    +
    +
    var month
    +
    +
    +
    +
    var occurrence
    +
    +
    +
    +
    var offset
    +
    +
    +
    +
    +

    Inherited members

    + +
    class RecurringMasterItemId (*args, **kwargs) @@ -7689,12 +8239,12 @@

    Inherited members

    @classmethod def from_xml(cls, elem, account): - id_elem = elem.find('{%s}Id' % TNS) + id_elem = elem.find(f'{{{TNS}}}Id') item_id_elem = id_elem.find(ItemId.response_tag()) kwargs = dict( - name=get_xml_attr(id_elem, '{%s}Name' % TNS), - email_address=get_xml_attr(id_elem, '{%s}EmailAddress' % TNS), - mailbox_type=get_xml_attr(id_elem, '{%s}MailboxType' % TNS), + name=get_xml_attr(id_elem, f'{{{TNS}}}Name'), + email_address=get_xml_attr(id_elem, f'{{{TNS}}}EmailAddress'), + mailbox_type=get_xml_attr(id_elem, f'{{{TNS}}}MailboxType'), item_id=ItemId.from_xml(elem=item_id_elem, account=account) if item_id_elem else None, ) cls._clear(elem) @@ -7725,12 +8275,12 @@

    Static methods

    @classmethod
     def from_xml(cls, elem, account):
    -    id_elem = elem.find('{%s}Id' % TNS)
    +    id_elem = elem.find(f'{{{TNS}}}Id')
         item_id_elem = id_elem.find(ItemId.response_tag())
         kwargs = dict(
    -        name=get_xml_attr(id_elem, '{%s}Name' % TNS),
    -        email_address=get_xml_attr(id_elem, '{%s}EmailAddress' % TNS),
    -        mailbox_type=get_xml_attr(id_elem, '{%s}MailboxType' % TNS),
    +        name=get_xml_attr(id_elem, f'{{{TNS}}}Name'),
    +        email_address=get_xml_attr(id_elem, f'{{{TNS}}}EmailAddress'),
    +        mailbox_type=get_xml_attr(id_elem, f'{{{TNS}}}MailboxType'),
             item_id=ItemId.from_xml(elem=item_id_elem, account=account) if item_id_elem else None,
         )
         cls._clear(elem)
    @@ -7770,7 +8320,7 @@ 

    Inherited members

    def response_tag(cls): # In a GetRoomLists response, room lists are delivered as Address elements. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/address-emailaddresstype - return '{%s}Address' % TNS
    + return f'{{{TNS}}}Address'

    Ancestors

      @@ -7803,7 +8353,7 @@

      Static methods

      def response_tag(cls): # In a GetRoomLists response, room lists are delivered as Address elements. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/address-emailaddresstype - return '{%s}Address' % TNS + return f'{{{TNS}}}Address' @@ -8264,7 +8814,12 @@

      Inherited members

      ELEMENT_NAME = 'TimeWindow' start = DateTimeField(field_uri='StartTime', is_required=True) - end = DateTimeField(field_uri='EndTime', is_required=True) + end = DateTimeField(field_uri='EndTime', is_required=True) + + def clean(self, version=None): + if self.start >= self.end: + raise ValueError(f"'start' must be less than 'end' ({self.start} -> {self.end})") + super().clean(version=version)

      Ancestors

        @@ -8292,6 +8847,24 @@

        Instance variables

        +

        Methods

        +
        +
        +def clean(self, version=None) +
        +
        +
        +
        + +Expand source code + +
        def clean(self, version=None):
        +    if self.start >= self.end:
        +        raise ValueError(f"'start' must be less than 'end' ({self.start} -> {self.end})")
        +    super().clean(version=version)
        +
        +
        +

        Inherited members

        • EWSElement: @@ -8334,12 +8907,15 @@

          Inherited members

          :return: A Microsoft timezone ID, as a string """ candidates = set() - for tz_id, tz_name, tz_periods, tz_transitions, tz_transitions_groups in timezones: - candidate = self.from_server_timezone(tz_periods, tz_transitions, tz_transitions_groups, for_year) + for tz_definition in timezones: + candidate = self.from_server_timezone( + tz_definition=tz_definition, + for_year=for_year, + ) if candidate == self: - log.debug('Found exact candidate: %s (%s)', tz_id, tz_name) + log.debug('Found exact candidate: %s (%s)', tz_definition.id, tz_definition.name) # We prefer this timezone over anything else. Return immediately. - return tz_id + return tz_definition.id # Reduce list based on base bias and standard / daylight bias values if candidate.bias != self.bias: continue @@ -8359,8 +8935,8 @@

          Inherited members

          continue if candidate.daylight_time.bias != self.daylight_time.bias: continue - log.debug('Found candidate with matching biases: %s (%s)', tz_id, tz_name) - candidates.add(tz_id) + log.debug('Found candidate with matching biases: %s (%s)', tz_definition.id, tz_definition.name) + candidates.add(tz_definition.id) if not candidates: raise ValueError('No server timezones match this timezone definition') if len(candidates) == 1: @@ -8370,81 +8946,10 @@

          Inherited members

          return candidates.pop() @classmethod - def from_server_timezone(cls, periods, transitions, transitionsgroups, for_year): + def from_server_timezone(cls, tz_definition, for_year): # Creates a TimeZone object from the result of a GetServerTimeZones call with full timezone data - - # Get the default bias - bias = cls._get_bias(periods=periods, for_year=for_year) - - # Get a relevant transition ID - valid_tg_id = cls._get_valid_transition_id(transitions=transitions, for_year=for_year) - transitiongroup = transitionsgroups[valid_tg_id] - if not 0 <= len(transitiongroup) <= 2: - raise ValueError('Expected 0-2 transitions in transitionsgroup %s' % transitiongroup) - - standard_time, daylight_time = cls._get_std_and_dst(transitiongroup=transitiongroup, periods=periods, bias=bias) - return cls(bias=bias, standard_time=standard_time, daylight_time=daylight_time) - - @staticmethod - def _get_bias(periods, for_year): - # Set a default bias - valid_period = None - for (year, period_type), period in sorted(periods.items()): - if year > for_year: - break - if period_type != 'Standard': - continue - valid_period = period - if valid_period is None: - raise TimezoneDefinitionInvalidForYear('Year %s not included in periods %s' % (for_year, periods)) - return int(valid_period['bias'].total_seconds()) // 60 # Convert to minutes - - @staticmethod - def _get_valid_transition_id(transitions, for_year): - # Look through the transitions, and pick the relevant one according to the 'for_year' value - valid_tg_id = None - for tg_id, from_date in sorted(transitions.items()): - if from_date and from_date.year > for_year: - break - valid_tg_id = tg_id - if valid_tg_id is None: - raise ValueError('No valid transition for year %s: %s' % (for_year, transitions)) - return valid_tg_id - - @staticmethod - def _get_std_and_dst(transitiongroup, periods, bias): - # Return 'standard_time' and 'daylight_time' objects. We do unnecessary work here, but it keeps code simple. - standard_time, daylight_time = None, None - for transition in transitiongroup: - period = periods[transition['to']] - if len(transition) == 1: - # This is a simple transition representing a timezone with no DST. Some servers don't accept TimeZone - # elements without a STD and DST element (see issue #488). Return StandardTime and DaylightTime objects - # with dummy values and 0 bias - this satisfies the broken servers and hopefully doesn't break the - # well-behaving servers. - standard_time = StandardTime(bias=0, time=datetime.time(0), occurrence=1, iso_month=1, weekday=1) - daylight_time = DaylightTime(bias=0, time=datetime.time(0), occurrence=5, iso_month=12, weekday=7) - continue - # 'offset' is the time of day to transition, as timedelta since midnight. Must be a reasonable value - if not datetime.timedelta(0) <= transition['offset'] < datetime.timedelta(days=1): - raise ValueError("'offset' value %s must be be between 0 and 24 hours" % transition['offset']) - transition_kwargs = dict( - time=(datetime.datetime(2000, 1, 1) + transition['offset']).time(), - occurrence=transition['occurrence'], - iso_month=transition['iso_month'], - weekday=transition['iso_weekday'], - ) - if period['name'] == 'Standard': - transition_kwargs['bias'] = 0 - standard_time = StandardTime(**transition_kwargs) - continue - if period['name'] == 'Daylight': - dst_bias = int(period['bias'].total_seconds()) // 60 # Convert to minutes - transition_kwargs['bias'] = dst_bias - bias - daylight_time = DaylightTime(**transition_kwargs) - continue - raise ValueError('Unknown transition: %s' % transition) - return standard_time, daylight_time + std_time, daylight_time, period = tz_definition.get_std_and_dst(for_year=for_year) + return cls(bias=period.bias_in_minutes, standard_time=std_time, daylight_time=daylight_time)

          Ancestors

            @@ -8464,7 +8969,7 @@

            Class variables

            Static methods

            -def from_server_timezone(periods, transitions, transitionsgroups, for_year) +def from_server_timezone(tz_definition, for_year)
            @@ -8473,20 +8978,10 @@

            Static methods

            Expand source code
            @classmethod
            -def from_server_timezone(cls, periods, transitions, transitionsgroups, for_year):
            +def from_server_timezone(cls, tz_definition, for_year):
                 # Creates a TimeZone object from the result of a GetServerTimeZones call with full timezone data
            -
            -    # Get the default bias
            -    bias = cls._get_bias(periods=periods, for_year=for_year)
            -
            -    # Get a relevant transition ID
            -    valid_tg_id = cls._get_valid_transition_id(transitions=transitions, for_year=for_year)
            -    transitiongroup = transitionsgroups[valid_tg_id]
            -    if not 0 <= len(transitiongroup) <= 2:
            -        raise ValueError('Expected 0-2 transitions in transitionsgroup %s' % transitiongroup)
            -
            -    standard_time, daylight_time = cls._get_std_and_dst(transitiongroup=transitiongroup, periods=periods, bias=bias)
            -    return cls(bias=bias, standard_time=standard_time, daylight_time=daylight_time)
            + std_time, daylight_time, period = tz_definition.get_std_and_dst(for_year=for_year) + return cls(bias=period.bias_in_minutes, standard_time=std_time, daylight_time=daylight_time)
            @@ -8532,12 +9027,15 @@

            Methods

            :return: A Microsoft timezone ID, as a string """ candidates = set() - for tz_id, tz_name, tz_periods, tz_transitions, tz_transitions_groups in timezones: - candidate = self.from_server_timezone(tz_periods, tz_transitions, tz_transitions_groups, for_year) + for tz_definition in timezones: + candidate = self.from_server_timezone( + tz_definition=tz_definition, + for_year=for_year, + ) if candidate == self: - log.debug('Found exact candidate: %s (%s)', tz_id, tz_name) + log.debug('Found exact candidate: %s (%s)', tz_definition.id, tz_definition.name) # We prefer this timezone over anything else. Return immediately. - return tz_id + return tz_definition.id # Reduce list based on base bias and standard / daylight bias values if candidate.bias != self.bias: continue @@ -8557,8 +9055,8 @@

            Methods

            continue if candidate.daylight_time.bias != self.daylight_time.bias: continue - log.debug('Found candidate with matching biases: %s (%s)', tz_id, tz_name) - candidates.add(tz_id) + log.debug('Found candidate with matching biases: %s (%s)', tz_definition.id, tz_definition.name) + candidates.add(tz_definition.id) if not candidates: raise ValueError('No server timezones match this timezone definition') if len(candidates) == 1: @@ -8581,6 +9079,215 @@

            Inherited members

          +
          +class TimeZoneDefinition +(**kwargs) +
          +
          + +
          + +Expand source code + +
          class TimeZoneDefinition(EWSElement):
          +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezonedefinition"""
          +
          +    ELEMENT_NAME = 'TimeZoneDefinition'
          +
          +    id = CharField(field_uri='Id', is_attribute=True)
          +    name = CharField(field_uri='Name', is_attribute=True)
          +
          +    periods = EWSElementListField(field_uri='Periods', value_cls=Period)
          +    transitions_groups = EWSElementListField(field_uri='TransitionsGroups', value_cls=TransitionsGroup)
          +    transitions = TransitionListField(field_uri='Transitions', value_cls=BaseTransition)
          +
          +    @classmethod
          +    def from_xml(cls, elem, account):
          +        return super().from_xml(elem, account)
          +
          +    def _get_standard_period(self, for_year):
          +        # Look through periods and pick a relevant period according to the 'for_year' value
          +        valid_period = None
          +        for period in sorted(self.periods, key=lambda p: (p.year, p.type)):
          +            if period.year > for_year:
          +                break
          +            if period.type != 'Standard':
          +                continue
          +            valid_period = period
          +        if valid_period is None:
          +            raise TimezoneDefinitionInvalidForYear(f'Year {for_year} not included in periods {self.periods}')
          +        return valid_period
          +
          +    def _get_transitions_group(self, for_year):
          +        # Look through the transitions, and pick the relevant transition group according to the 'for_year' value
          +        transitions_group = None
          +        transitions_groups_map = {tg.id: tg for tg in self.transitions_groups}
          +        for transition in sorted(self.transitions, key=lambda t: t.to):
          +            if transition.kind != 'Group':
          +                continue
          +            if isinstance(transition, AbsoluteDateTransition) and transition.date.year > for_year:
          +                break
          +            transitions_group = transitions_groups_map[transition.to]
          +        if transitions_group is None:
          +            raise ValueError(f'No valid transition group for year {for_year}: {self.transitions}')
          +        return transitions_group
          +
          +    def get_std_and_dst(self, for_year):
          +        # Return 'standard_time' and 'daylight_time' objects. We do unnecessary work here, but it keeps code simple.
          +        transitions_group = self._get_transitions_group(for_year)
          +        if not 0 <= len(transitions_group.transitions) <= 2:
          +            raise ValueError(f'Expected 0-2 transitions in transitions group {transitions_group}')
          +
          +        standard_period = self._get_standard_period(for_year)
          +        periods_map = {p.id: p for p in self.periods}
          +        standard_time, daylight_time = None, None
          +        if len(transitions_group.transitions) == 1:
          +            # This is a simple transition group representing a timezone with no DST. Some servers don't accept
          +            # TimeZone elements without a STD and DST element (see issue #488). Return StandardTime and DaylightTime
          +            # objects with dummy values and 0 bias - this satisfies the broken servers and hopefully doesn't break
          +            # the well-behaving servers.
          +            standard_time = StandardTime(bias=0, time=datetime.time(0), occurrence=1, iso_month=1, weekday=1)
          +            daylight_time = DaylightTime(bias=0, time=datetime.time(0), occurrence=5, iso_month=12, weekday=7)
          +            return standard_time, daylight_time, standard_period
          +        for transition in transitions_group.transitions:
          +            # 'offset' is the time of day to transition, as timedelta since midnight. Check that it's a reasonable value
          +            transition.clean(version=None)
          +            transition_kwargs = dict(
          +                time=(datetime.datetime(2000, 1, 1) + transition.offset).time(),
          +                occurrence=transition.occurrence,
          +                iso_month=transition.month,
          +                weekday=transition.day_of_week,
          +            )
          +            period = periods_map[transition.to]
          +            if period.name == 'Standard':
          +                transition_kwargs['bias'] = 0
          +                standard_time = StandardTime(**transition_kwargs)
          +                continue
          +            if period.name == 'Daylight':
          +                transition_kwargs['bias'] = period.bias_in_minutes - standard_period.bias_in_minutes
          +                daylight_time = DaylightTime(**transition_kwargs)
          +                continue
          +            raise ValueError(f'Unknown transition: {transition}')
          +        return standard_time, daylight_time, standard_period
          +
          +

          Ancestors

          + +

          Class variables

          +
          +
          var ELEMENT_NAME
          +
          +
          +
          +
          var FIELDS
          +
          +
          +
          +
          +

          Static methods

          +
          +
          +def from_xml(elem, account) +
          +
          +
          +
          + +Expand source code + +
          @classmethod
          +def from_xml(cls, elem, account):
          +    return super().from_xml(elem, account)
          +
          +
          +
          +

          Instance variables

          +
          +
          var id
          +
          +
          +
          +
          var name
          +
          +
          +
          +
          var periods
          +
          +
          +
          +
          var transitions
          +
          +
          +
          +
          var transitions_groups
          +
          +
          +
          +
          +

          Methods

          +
          +
          +def get_std_and_dst(self, for_year) +
          +
          +
          +
          + +Expand source code + +
          def get_std_and_dst(self, for_year):
          +    # Return 'standard_time' and 'daylight_time' objects. We do unnecessary work here, but it keeps code simple.
          +    transitions_group = self._get_transitions_group(for_year)
          +    if not 0 <= len(transitions_group.transitions) <= 2:
          +        raise ValueError(f'Expected 0-2 transitions in transitions group {transitions_group}')
          +
          +    standard_period = self._get_standard_period(for_year)
          +    periods_map = {p.id: p for p in self.periods}
          +    standard_time, daylight_time = None, None
          +    if len(transitions_group.transitions) == 1:
          +        # This is a simple transition group representing a timezone with no DST. Some servers don't accept
          +        # TimeZone elements without a STD and DST element (see issue #488). Return StandardTime and DaylightTime
          +        # objects with dummy values and 0 bias - this satisfies the broken servers and hopefully doesn't break
          +        # the well-behaving servers.
          +        standard_time = StandardTime(bias=0, time=datetime.time(0), occurrence=1, iso_month=1, weekday=1)
          +        daylight_time = DaylightTime(bias=0, time=datetime.time(0), occurrence=5, iso_month=12, weekday=7)
          +        return standard_time, daylight_time, standard_period
          +    for transition in transitions_group.transitions:
          +        # 'offset' is the time of day to transition, as timedelta since midnight. Check that it's a reasonable value
          +        transition.clean(version=None)
          +        transition_kwargs = dict(
          +            time=(datetime.datetime(2000, 1, 1) + transition.offset).time(),
          +            occurrence=transition.occurrence,
          +            iso_month=transition.month,
          +            weekday=transition.day_of_week,
          +        )
          +        period = periods_map[transition.to]
          +        if period.name == 'Standard':
          +            transition_kwargs['bias'] = 0
          +            standard_time = StandardTime(**transition_kwargs)
          +            continue
          +        if period.name == 'Daylight':
          +            transition_kwargs['bias'] = period.bias_in_minutes - standard_period.bias_in_minutes
          +            daylight_time = DaylightTime(**transition_kwargs)
          +            continue
          +        raise ValueError(f'Unknown transition: {transition}')
          +    return standard_time, daylight_time, standard_period
          +
          +
          +
          +

          Inherited members

          + +
          class TimeZoneTransition (**kwargs) @@ -8813,6 +9520,101 @@

          Inherited members

        +
        +class Transition +(**kwargs) +
        +
        + +
        + +Expand source code + +
        class Transition(BaseTransition):
        +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/transition"""
        +
        +    ELEMENT_NAME = 'Transition'
        +
        +

        Ancestors

        + +

        Class variables

        +
        +
        var ELEMENT_NAME
        +
        +
        +
        +
        +

        Inherited members

        + +
        +
        +class TransitionsGroup +(**kwargs) +
        +
        + +
        + +Expand source code + +
        class TransitionsGroup(EWSElement):
        +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/transitionsgroup"""
        +
        +    ELEMENT_NAME = 'TransitionsGroup'
        +
        +    id = CharField(field_uri='Id', is_attribute=True)
        +    transitions = TransitionListField(value_cls=BaseTransition)
        +
        +

        Ancestors

        + +

        Class variables

        +
        +
        var ELEMENT_NAME
        +
        +
        +
        +
        var FIELDS
        +
        +
        +
        +
        +

        Instance variables

        +
        +
        var id
        +
        +
        +
        +
        var transitions
        +
        +
        +
        +
        +

        Inherited members

        + +
        class UID (uid) @@ -8864,7 +9666,7 @@

        Inherited members

        # https://stackoverflow.com/questions/33757805 def __new__(cls, uid): - payload = binascii.hexlify(bytearray('vCal-Uid\x01\x00\x00\x00{}\x00'.format(uid).encode('ascii'))) + payload = binascii.hexlify(bytearray(f'vCal-Uid\x01\x00\x00\x00{uid}\x00'.encode('ascii'))) length = binascii.hexlify(bytearray(struct.pack('<I', int(len(payload)/2)))) encoding = b''.join([ cls._HEADER, cls._EXCEPTION_REPLACEMENT_TIME, cls._CREATION_TIME, cls._RESERVED, length, payload @@ -9000,7 +9802,7 @@

        Inherited members

        def clean(self, version=None): from .folders import BaseFolder if isinstance(self.folder, BaseFolder): - self.folder = self.folder.to_folder_id() + self.folder = self.folder.to_id() super().clean(version=version) @classmethod @@ -9084,7 +9886,7 @@

        Methods

        def clean(self, version=None):
             from .folders import BaseFolder
             if isinstance(self.folder, BaseFolder):
        -        self.folder = self.folder.to_folder_id()
        +        self.folder = self.folder.to_id()
             super().clean(version=version)
        @@ -9295,6 +10097,14 @@

        Index

      • Classes

        • +

          AbsoluteDateTransition

          + +
        • +
        • AcceptSharingInvitation

          • ELEMENT_NAME
          • @@ -9405,6 +10215,16 @@

            BaseTransition

            + + +
          • Body

          • @@ -9920,6 +10740,19 @@

            Period

            + +
          • +
          • Permission

            • ELEMENT_NAME
            • @@ -10011,6 +10844,28 @@

              RecurringDateTransition

              + + +
            • +

              RecurringDayTransition

              + +
            • +
            • RecurringMasterItemId

              @@ -10171,6 +11027,20 @@

              TimeZoneDefinition

              + +
            • +
            • TimeZoneTransition

              • FIELDS
              • @@ -10197,6 +11067,21 @@

                Transition

                + + +
              • +

                TransitionsGroup

                + +
              • +
              • UID

                • to_global_object_id
                • diff --git a/docs/exchangelib/protocol.html b/docs/exchangelib/protocol.html index 69ae17df..7dc3b0ee 100644 --- a/docs/exchangelib/protocol.html +++ b/docs/exchangelib/protocol.html @@ -39,18 +39,17 @@

                  Module exchangelib.protocol

                  import datetime import logging import os -from queue import LifoQueue, Empty, Full +from queue import LifoQueue, Empty from threading import Lock import requests.adapters import requests.sessions -import requests.utils from oauthlib.oauth2 import BackendApplicationClient, WebApplicationClient from requests_oauthlib import OAuth2Session from .credentials import OAuth2AuthorizationCodeCredentials, OAuth2Credentials from .errors import TransportError, SessionPoolMinSizeReached, SessionPoolMaxSizeReached, RateLimitError, CASError, \ - ErrorInvalidSchemaVersionForMailboxVersion, UnauthorizedError, MalformedResponseError + ErrorInvalidSchemaVersionForMailboxVersion, UnauthorizedError, MalformedResponseError, InvalidTypeError from .properties import FreeBusyViewOptions, MailboxData, TimeWindow, TimeZone, RoomList, DLMailbox from .services import GetServerTimeZones, GetRoomLists, GetRooms, ResolveNames, GetUserAvailability, \ GetSearchableMailboxes, ExpandDL, ConvertId @@ -70,7 +69,7 @@

                  Module exchangelib.protocol

                  # The maximum number of sessions (== TCP connections, see below) we will open to this service endpoint. Keep this # low unless you have an agreement with the Exchange admin on the receiving end to hammer the server and # rate-limiting policies have been disabled for the connecting user. Changing this setting only makes sense if - # you are using a thread pool to run multiple concurrent workers in this process. + # you are using threads to run multiple concurrent workers in this process. SESSION_POOLSIZE = 1 # We want only 1 TCP connection per Session object. We may have lots of different credentials hitting the server and # each credential needs its own session (NTLM auth will only send credentials once and then secure the connection, @@ -90,12 +89,9 @@

                  Module exchangelib.protocol

                  USERAGENT = None def __init__(self, config): - from .configuration import Configuration - if not isinstance(config, Configuration): - raise ValueError("'config' %r must be a Configuration instance" % config) - if not config.service_endpoint: - raise AttributeError("'config.service_endpoint' must be set") self.config = config + self._api_version_hint = None + self._session_pool_size = 0 self._session_pool_maxsize = config.max_connections or self.SESSION_POOLSIZE @@ -111,6 +107,9 @@

                  Module exchangelib.protocol

                  @property def auth_type(self): + # Autodetect authentication type if necessary + if self.config.auth_type is None: + self.config.auth_type = self.get_auth_type() return self.config.auth_type @property @@ -133,6 +132,15 @@

                  Module exchangelib.protocol

                  def server(self): return self.config.server + def get_auth_type(self): + # Autodetect authentication type. We also set version hint here. + name = str(self.credentials) if self.credentials and str(self.credentials) else 'DUMMY' + auth_type, api_version_hint = get_service_authtype( + service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS, name=name + ) + self._api_version_hint = api_version_hint + return auth_type + def __getstate__(self): # The session pool and lock cannot be pickled state = self.__dict__.copy() @@ -182,7 +190,7 @@

                  Module exchangelib.protocol

                  """Increases the session pool size. We increase by one session per call.""" # Create a single session and insert it into the pool. We need to protect this with a lock while we are changing # the pool size variable, to avoid race conditions. We must not exceed the pool size limit. - if self._session_pool_size == self._session_pool_maxsize: + if self._session_pool_size >= self._session_pool_maxsize: raise SessionPoolMaxSizeReached('Session pool size cannot be increased further') with self._session_pool_lock: if self._session_pool_size >= self._session_pool_maxsize: @@ -238,13 +246,10 @@

                  Module exchangelib.protocol

                  def release_session(self, session): # This should never fail, as we don't have more sessions than the queue contains log.debug('Server %s: Releasing session %s', self.server, session.session_id) - if self.MAX_SESSION_USAGE_COUNT and session.usage_count > self.MAX_SESSION_USAGE_COUNT: + if self.MAX_SESSION_USAGE_COUNT and session.usage_count >= self.MAX_SESSION_USAGE_COUNT: log.debug('Server %s: session %s usage exceeded limit. Discarding', self.server, session.session_id) session = self.renew_session(session) - try: - self._session_pool.put(session, block=False) - except Full: - log.debug('Server %s: Session pool was already full %s', self.server, session.session_id) + self._session_pool.put(session, block=False) @staticmethod def close_session(session): @@ -278,11 +283,9 @@

                  Module exchangelib.protocol

                  return self.renew_session(session) def create_session(self): - if self.auth_type is None: - raise ValueError('Cannot create session without knowing the auth type') if self.credentials is None: if self.auth_type in CREDENTIALS_REQUIRED: - raise ValueError('Auth type %r requires credentials' % self.auth_type) + raise ValueError(f'Auth type {self.auth_type!r} requires credentials') session = self.raw_session(self.service_endpoint) session.auth = get_auth_instance(auth_type=self.auth_type) else: @@ -312,11 +315,6 @@

                  Module exchangelib.protocol

                  return session def create_oauth2_session(self): - if self.auth_type != OAUTH2: - raise ValueError( - 'Auth type must be %r for credentials type %s' % (OAUTH2, self.credentials.__class__.__name__) - ) - has_token = False scope = ['https://outlook.office365.com/.default'] session_params = {} @@ -359,7 +357,7 @@

                  Module exchangelib.protocol

                  }) client = WebApplicationClient(self.credentials.client_id, **client_params) else: - token_url = 'https://login.microsoftonline.com/%s/oauth2/v2.0/token' % self.credentials.tenant_id + token_url = f'https://login.microsoftonline.com/{self.credentials.tenant_id}/oauth2/v2.0/token' client = BackendApplicationClient(client_id=self.credentials.client_id) session = self.raw_session(self.service_endpoint, oauth2_client=client, oauth2_session_params=session_params) @@ -403,8 +401,12 @@

                  Module exchangelib.protocol

                  # # We ignore auth_type from kwargs in the cache key. We trust caller to supply the correct auth_type - otherwise # __init__ will guess the correct auth type. - config = kwargs['config'] + from .configuration import Configuration + if not isinstance(config, Configuration): + raise InvalidTypeError('config', config, Configuration) + if not config.service_endpoint: + raise AttributeError("'config.service_endpoint' must be set") _protocol_cache_key = cls._cache_key(config) try: @@ -476,20 +478,7 @@

                  Module exchangelib.protocol

                  def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._api_version_hint = None self._version_lock = Lock() - # Autodetect authentication type if necessary - if self.config.auth_type is None: - self.config.auth_type = self.get_auth_type() - - def get_auth_type(self): - # Autodetect authentication type. We also set version hint here. - name = str(self.credentials) if self.credentials and str(self.credentials) else 'DUMMY' - auth_type, api_version_hint = get_service_authtype( - service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS, name=name - ) - self._api_version_hint = api_version_hint - return auth_type @property def version(self): @@ -508,7 +497,7 @@

                  Module exchangelib.protocol

                  (Default value = None) :param return_full_timezone_data: If true, also returns periods and transitions (Default value = False) - :return: A list of (tz_id, name, periods, transitions) tuples + :return: A generator of TimeZoneDefinition objects """ return GetServerTimeZones(protocol=self).call( timezones=timezones, return_full_timezone_data=return_full_timezone_data @@ -529,31 +518,12 @@

                  Module exchangelib.protocol

                  :return: A generator of FreeBusyView objects """ from .account import Account - for account, attendee_type, exclude_conflicts in accounts: - if not isinstance(account, (Account, str)): - raise ValueError("'accounts' item %r must be an 'Account' or 'str' instance" % account) - if attendee_type not in MailboxData.ATTENDEE_TYPES: - raise ValueError("'accounts' item %r must be one of %s" % (attendee_type, MailboxData.ATTENDEE_TYPES)) - if not isinstance(exclude_conflicts, bool): - raise ValueError("'accounts' item %r must be a 'bool' instance" % exclude_conflicts) - if start >= end: - raise ValueError("'start' must be less than 'end' (%s -> %s)" % (start, end)) - if not isinstance(merged_free_busy_interval, int): - raise ValueError("'merged_free_busy_interval' value %r must be an 'int'" % merged_free_busy_interval) - if requested_view not in FreeBusyViewOptions.REQUESTED_VIEWS: - raise ValueError( - "'requested_view' value %r must be one of %s" % (requested_view, FreeBusyViewOptions.REQUESTED_VIEWS)) - _, _, periods, transitions, transitions_groups = list(self.get_timezones( + tz_definition = list(self.get_timezones( timezones=[start.tzinfo], return_full_timezone_data=True ))[0] return GetUserAvailability(self).call( - timezone=TimeZone.from_server_timezone( - periods=periods, - transitions=transitions, - transitionsgroups=transitions_groups, - for_year=start.year - ), + timezone=TimeZone.from_server_timezone(tz_definition=tz_definition, for_year=start.year), mailbox_data=[MailboxData( email=account.primary_smtp_address if isinstance(account, Account) else account, attendee_type=attendee_type, @@ -570,12 +540,14 @@

                  Module exchangelib.protocol

                  return GetRoomLists(protocol=self).call() def get_rooms(self, roomlist): - return GetRooms(protocol=self).call(roomlist=RoomList(email_address=roomlist)) + return GetRooms(protocol=self).call(room_list=RoomList(email_address=roomlist)) - def resolve_names(self, names, return_full_contact_data=False, search_scope=None, shape=None): + def resolve_names(self, names, parent_folders=None, return_full_contact_data=False, search_scope=None, + shape=None): """Resolve accounts on the server using partial account data, e.g. an email address or initials. :param names: A list of identifiers to query + :param parent_folders: A list of contact folders to search in :param return_full_contact_data: If True, returns full contact data (Default value = False) :param search_scope: The scope to perform the search. Must be one of SEARCH_SCOPE_CHOICES (Default value = None) :param shape: (Default value = None) @@ -583,8 +555,8 @@

                  Module exchangelib.protocol

                  :return: A list of Mailbox items or, if return_full_contact_data is True, tuples of (Mailbox, Contact) items """ return list(ResolveNames(protocol=self).call( - unresolved_entries=names, return_full_contact_data=return_full_contact_data, search_scope=search_scope, - contact_data_shape=shape, + unresolved_entries=names, parent_folders=parent_folders, return_full_contact_data=return_full_contact_data, + search_scope=search_scope, contact_data_shape=shape, )) def expand_dl(self, distribution_list): @@ -604,7 +576,7 @@

                  Module exchangelib.protocol

                  This method is only available to users who have been assigned the Discovery Management RBAC role. See https://docs.microsoft.com/en-us/exchange/permissions-exo/permissions-exo - :param search_filter: Is set, must be a single email alias (Default value = None) + :param search_filter: If set, must be a single email alias (Default value = None) :param expand_group_membership: If True, returned distribution lists are expanded (Default value = False) :return: a list of SearchableMailbox, FailedMailbox or Exception instances @@ -625,19 +597,14 @@

                  Module exchangelib.protocol

                  return ConvertId(protocol=self).call(items=ids, destination_format=destination_format) def __getstate__(self): - # The lock and thread pool cannot be pickled + # The lock cannot be pickled state = super().__getstate__() del state['_version_lock'] - try: - del state['thread_pool'] - except KeyError: - # thread_pool is a cached property and may not exist - pass return state def __setstate__(self, state): - # Restore the lock. The thread pool is a cached property and will be recreated automatically. - self.__dict__.update(state) + # Restore the lock + super().__setstate__(state) self._version_lock = Lock() def __str__(self): @@ -647,12 +614,12 @@

                  Module exchangelib.protocol

                  else: fullname, api_version, build = '[unknown]', '[unknown]', '[unknown]' - return '''\ -EWS url: %s -Product name: %s -EWS API version: %s -Build number: %s -EWS auth: %s''' % (self.service_endpoint, fullname, api_version, build, self.auth_type) + return f'''\ +EWS url: {self.service_endpoint} +Product name: {fullname} +EWS API version: {api_version} +Build number: {build} +EWS auth: {self.auth_type}''' class NoVerifyHTTPAdapter(requests.adapters.HTTPAdapter): @@ -680,27 +647,26 @@

                  Module exchangelib.protocol

                  @property @abc.abstractmethod def fail_fast(self): - # Used to choose the error handling policy. When True, a fault-tolerant policy is used. False, a fail-fast - # policy is used. - pass + """Used to choose the error handling policy. When True, a fault-tolerant policy is used. False, a fail-fast + policy is used.""" @property @abc.abstractmethod def back_off_until(self): - pass + """Return a datetime to back off until""" @back_off_until.setter @abc.abstractmethod def back_off_until(self, value): - pass + """Setter for back off values""" @abc.abstractmethod def back_off(self, seconds): - pass + """Set a new back off until value""" @abc.abstractmethod def may_retry_on_error(self, response, wait): - pass + """Return whether retries should still be attempted""" def raise_response_errors(self, response): cas_error = response.headers.get('X-CasErrorCode') @@ -717,14 +683,14 @@

                  Module exchangelib.protocol

                  raise UnauthorizedError('The referenced account is currently locked out') if response.status_code == 401 and self.fail_fast: # This is a login failure - raise UnauthorizedError('Invalid credentials for %s' % response.url) + raise UnauthorizedError(f'Invalid credentials for {response.url}') if 'TimeoutException' in response.headers: # A header set by us on CONNECTION_ERRORS raise response.headers['TimeoutException'] # This could be anything. Let higher layers handle this raise MalformedResponseError( - 'Unknown failure in response. Code: %s headers: %s content: %s' - % (response.status_code, response.headers, response.text) + f'Unknown failure in response. Code: {response.status_code} headers: {response.headers} ' + f'content: {response.text}' ) @@ -873,7 +839,7 @@

                  Classes

                  # The maximum number of sessions (== TCP connections, see below) we will open to this service endpoint. Keep this # low unless you have an agreement with the Exchange admin on the receiving end to hammer the server and # rate-limiting policies have been disabled for the connecting user. Changing this setting only makes sense if - # you are using a thread pool to run multiple concurrent workers in this process. + # you are using threads to run multiple concurrent workers in this process. SESSION_POOLSIZE = 1 # We want only 1 TCP connection per Session object. We may have lots of different credentials hitting the server and # each credential needs its own session (NTLM auth will only send credentials once and then secure the connection, @@ -893,12 +859,9 @@

                  Classes

                  USERAGENT = None def __init__(self, config): - from .configuration import Configuration - if not isinstance(config, Configuration): - raise ValueError("'config' %r must be a Configuration instance" % config) - if not config.service_endpoint: - raise AttributeError("'config.service_endpoint' must be set") self.config = config + self._api_version_hint = None + self._session_pool_size = 0 self._session_pool_maxsize = config.max_connections or self.SESSION_POOLSIZE @@ -914,6 +877,9 @@

                  Classes

                  @property def auth_type(self): + # Autodetect authentication type if necessary + if self.config.auth_type is None: + self.config.auth_type = self.get_auth_type() return self.config.auth_type @property @@ -936,6 +902,15 @@

                  Classes

                  def server(self): return self.config.server + def get_auth_type(self): + # Autodetect authentication type. We also set version hint here. + name = str(self.credentials) if self.credentials and str(self.credentials) else 'DUMMY' + auth_type, api_version_hint = get_service_authtype( + service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS, name=name + ) + self._api_version_hint = api_version_hint + return auth_type + def __getstate__(self): # The session pool and lock cannot be pickled state = self.__dict__.copy() @@ -985,7 +960,7 @@

                  Classes

                  """Increases the session pool size. We increase by one session per call.""" # Create a single session and insert it into the pool. We need to protect this with a lock while we are changing # the pool size variable, to avoid race conditions. We must not exceed the pool size limit. - if self._session_pool_size == self._session_pool_maxsize: + if self._session_pool_size >= self._session_pool_maxsize: raise SessionPoolMaxSizeReached('Session pool size cannot be increased further') with self._session_pool_lock: if self._session_pool_size >= self._session_pool_maxsize: @@ -1041,13 +1016,10 @@

                  Classes

                  def release_session(self, session): # This should never fail, as we don't have more sessions than the queue contains log.debug('Server %s: Releasing session %s', self.server, session.session_id) - if self.MAX_SESSION_USAGE_COUNT and session.usage_count > self.MAX_SESSION_USAGE_COUNT: + if self.MAX_SESSION_USAGE_COUNT and session.usage_count >= self.MAX_SESSION_USAGE_COUNT: log.debug('Server %s: session %s usage exceeded limit. Discarding', self.server, session.session_id) session = self.renew_session(session) - try: - self._session_pool.put(session, block=False) - except Full: - log.debug('Server %s: Session pool was already full %s', self.server, session.session_id) + self._session_pool.put(session, block=False) @staticmethod def close_session(session): @@ -1081,11 +1053,9 @@

                  Classes

                  return self.renew_session(session) def create_session(self): - if self.auth_type is None: - raise ValueError('Cannot create session without knowing the auth type') if self.credentials is None: if self.auth_type in CREDENTIALS_REQUIRED: - raise ValueError('Auth type %r requires credentials' % self.auth_type) + raise ValueError(f'Auth type {self.auth_type!r} requires credentials') session = self.raw_session(self.service_endpoint) session.auth = get_auth_instance(auth_type=self.auth_type) else: @@ -1115,11 +1085,6 @@

                  Classes

                  return session def create_oauth2_session(self): - if self.auth_type != OAUTH2: - raise ValueError( - 'Auth type must be %r for credentials type %s' % (OAUTH2, self.credentials.__class__.__name__) - ) - has_token = False scope = ['https://outlook.office365.com/.default'] session_params = {} @@ -1162,7 +1127,7 @@

                  Classes

                  }) client = WebApplicationClient(self.credentials.client_id, **client_params) else: - token_url = 'https://login.microsoftonline.com/%s/oauth2/v2.0/token' % self.credentials.tenant_id + token_url = f'https://login.microsoftonline.com/{self.credentials.tenant_id}/oauth2/v2.0/token' client = BackendApplicationClient(client_id=self.credentials.client_id) session = self.raw_session(self.service_endpoint, oauth2_client=client, oauth2_session_params=session_params) @@ -1319,6 +1284,9 @@

                  Instance variables

                  @property
                   def auth_type(self):
                  +    # Autodetect authentication type if necessary
                  +    if self.config.auth_type is None:
                  +        self.config.auth_type = self.get_auth_type()
                       return self.config.auth_type
                  @@ -1415,11 +1383,6 @@

                  Methods

                  Expand source code
                  def create_oauth2_session(self):
                  -    if self.auth_type != OAUTH2:
                  -        raise ValueError(
                  -            'Auth type must be %r for credentials type %s' % (OAUTH2, self.credentials.__class__.__name__)
                  -        )
                  -
                       has_token = False
                       scope = ['https://outlook.office365.com/.default']
                       session_params = {}
                  @@ -1462,7 +1425,7 @@ 

                  Methods

                  }) client = WebApplicationClient(self.credentials.client_id, **client_params) else: - token_url = 'https://login.microsoftonline.com/%s/oauth2/v2.0/token' % self.credentials.tenant_id + token_url = f'https://login.microsoftonline.com/{self.credentials.tenant_id}/oauth2/v2.0/token' client = BackendApplicationClient(client_id=self.credentials.client_id) session = self.raw_session(self.service_endpoint, oauth2_client=client, oauth2_session_params=session_params) @@ -1489,11 +1452,9 @@

                  Methods

                  Expand source code
                  def create_session(self):
                  -    if self.auth_type is None:
                  -        raise ValueError('Cannot create session without knowing the auth type')
                       if self.credentials is None:
                           if self.auth_type in CREDENTIALS_REQUIRED:
                  -            raise ValueError('Auth type %r requires credentials' % self.auth_type)
                  +            raise ValueError(f'Auth type {self.auth_type!r} requires credentials')
                           session = self.raw_session(self.service_endpoint)
                           session.auth = get_auth_instance(auth_type=self.auth_type)
                       else:
                  @@ -1552,6 +1513,25 @@ 

                  Methods

                  self._session_pool_size -= 1
                  +
                  +def get_auth_type(self) +
                  +
                  +
                  +
                  + +Expand source code + +
                  def get_auth_type(self):
                  +    # Autodetect authentication type. We also set version hint here.
                  +    name = str(self.credentials) if self.credentials and str(self.credentials) else 'DUMMY'
                  +    auth_type, api_version_hint = get_service_authtype(
                  +        service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS, name=name
                  +    )
                  +    self._api_version_hint = api_version_hint
                  +    return auth_type
                  +
                  +
                  def get_session(self)
                  @@ -1599,7 +1579,7 @@

                  Methods

                  """Increases the session pool size. We increase by one session per call.""" # Create a single session and insert it into the pool. We need to protect this with a lock while we are changing # the pool size variable, to avoid race conditions. We must not exceed the pool size limit. - if self._session_pool_size == self._session_pool_maxsize: + if self._session_pool_size >= self._session_pool_maxsize: raise SessionPoolMaxSizeReached('Session pool size cannot be increased further') with self._session_pool_lock: if self._session_pool_size >= self._session_pool_maxsize: @@ -1647,13 +1627,10 @@

                  Methods

                  def release_session(self, session):
                       # This should never fail, as we don't have more sessions than the queue contains
                       log.debug('Server %s: Releasing session %s', self.server, session.session_id)
                  -    if self.MAX_SESSION_USAGE_COUNT and session.usage_count > self.MAX_SESSION_USAGE_COUNT:
                  +    if self.MAX_SESSION_USAGE_COUNT and session.usage_count >= self.MAX_SESSION_USAGE_COUNT:
                           log.debug('Server %s: session %s usage exceeded limit. Discarding', self.server, session.session_id)
                           session = self.renew_session(session)
                  -    try:
                  -        self._session_pool.put(session, block=False)
                  -    except Full:
                  -        log.debug('Server %s: Session pool was already full %s', self.server, session.session_id)
                  + self._session_pool.put(session, block=False)
                  @@ -1713,8 +1690,12 @@

                  Methods

                  # # We ignore auth_type from kwargs in the cache key. We trust caller to supply the correct auth_type - otherwise # __init__ will guess the correct auth type. - config = kwargs['config'] + from .configuration import Configuration + if not isinstance(config, Configuration): + raise InvalidTypeError('config', config, Configuration) + if not config.service_endpoint: + raise AttributeError("'config.service_endpoint' must be set") _protocol_cache_key = cls._cache_key(config) try: @@ -1838,63 +1819,17 @@

                  Ancestors

                  -

                  Instance variables

                  -
                  -
                  var back_off_until
                  -
                  -
                  -
                  - -Expand source code - -
                  @property
                  -def back_off_until(self):
                  -    return None
                  -
                  -
                  -
                  var fail_fast
                  -
                  -
                  -
                  - -Expand source code - -
                  @property
                  -def fail_fast(self):
                  -    return True
                  -
                  -
                  -
                  -

                  Methods

                  -
                  -
                  -def back_off(self, seconds) -
                  -
                  -
                  -
                  - -Expand source code - -
                  def back_off(self, seconds):
                  -    raise ValueError('Cannot back off with fail-fast policy')
                  -
                  -
                  -
                  -def may_retry_on_error(self, response, wait) -
                  -
                  -
                  -
                  - -Expand source code - -
                  def may_retry_on_error(self, response, wait):
                  -    log.debug('No retry: no fail-fast policy')
                  -    return False
                  -
                  -
                  -
                  +

                  Inherited members

                  +
                  class FaultTolerance @@ -2025,80 +1960,17 @@

                  Instance variables

                  return self._back_off_until
                  -
                  var fail_fast
                  -
                  -
                  -
                  - -Expand source code - -
                  @property
                  -def fail_fast(self):
                  -    return False
                  -
                  -
                  - -

                  Methods

                  -
                  -
                  -def back_off(self, seconds) -
                  -
                  -
                  -
                  - -Expand source code - -
                  def back_off(self, seconds):
                  -    if seconds is None:
                  -        seconds = self.DEFAULT_BACKOFF
                  -    value = datetime.datetime.now() + datetime.timedelta(seconds=seconds)
                  -    with self._back_off_lock:
                  -        self._back_off_until = value
                  -
                  -
                  -
                  -def may_retry_on_error(self, response, wait) -
                  -
                  -
                  -
                  - -Expand source code - -
                  def may_retry_on_error(self, response, wait):
                  -    if response.status_code not in (301, 302, 401, 500, 503):
                  -        # Don't retry if we didn't get a status code that we can hope to recover from
                  -        log.debug('No retry: wrong status code %s', response.status_code)
                  -        return False
                  -    if wait > self.max_wait:
                  -        # We lost patience. Session is cleaned up in outer loop
                  -        raise RateLimitError(
                  -            'Max timeout reached', url=response.url, status_code=response.status_code, total_wait=wait)
                  -    if response.status_code == 401:
                  -        # EWS sometimes throws 401's when it wants us to throttle connections. OK to retry.
                  -        return True
                  -    if response.headers.get('connection') == 'close':
                  -        # Connection closed. OK to retry.
                  -        return True
                  -    if response.status_code == 302 and response.headers.get('location', '').lower() \
                  -            == '/ews/genericerrorpage.htm?aspxerrorpath=/ews/exchange.asmx':
                  -        # The genericerrorpage.htm/internalerror.asp is ridiculous behaviour for random outages. OK to retry.
                  -        #
                  -        # Redirect to '/internalsite/internalerror.asp' or '/internalsite/initparams.aspx' is caused by e.g. TLS
                  -        # certificate f*ckups on the Exchange server. We should not retry those.
                  -        return True
                  -    if response.status_code == 503:
                  -        # Internal server error. OK to retry.
                  -        return True
                  -    if response.status_code == 500 and b"Server Error in '/EWS' Application" in response.content:
                  -        # "Server Error in '/EWS' Application" has been seen in highly concurrent settings. OK to retry.
                  -        log.debug('Retry allowed: conditions met')
                  -        return True
                  -    return False
                  -
                  -
                  +

                  Inherited members

                  +
                  class NoVerifyHTTPAdapter @@ -2170,20 +2042,7 @@

                  Methods

                  def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._api_version_hint = None self._version_lock = Lock() - # Autodetect authentication type if necessary - if self.config.auth_type is None: - self.config.auth_type = self.get_auth_type() - - def get_auth_type(self): - # Autodetect authentication type. We also set version hint here. - name = str(self.credentials) if self.credentials and str(self.credentials) else 'DUMMY' - auth_type, api_version_hint = get_service_authtype( - service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS, name=name - ) - self._api_version_hint = api_version_hint - return auth_type @property def version(self): @@ -2202,7 +2061,7 @@

                  Methods

                  (Default value = None) :param return_full_timezone_data: If true, also returns periods and transitions (Default value = False) - :return: A list of (tz_id, name, periods, transitions) tuples + :return: A generator of TimeZoneDefinition objects """ return GetServerTimeZones(protocol=self).call( timezones=timezones, return_full_timezone_data=return_full_timezone_data @@ -2223,31 +2082,12 @@

                  Methods

                  :return: A generator of FreeBusyView objects """ from .account import Account - for account, attendee_type, exclude_conflicts in accounts: - if not isinstance(account, (Account, str)): - raise ValueError("'accounts' item %r must be an 'Account' or 'str' instance" % account) - if attendee_type not in MailboxData.ATTENDEE_TYPES: - raise ValueError("'accounts' item %r must be one of %s" % (attendee_type, MailboxData.ATTENDEE_TYPES)) - if not isinstance(exclude_conflicts, bool): - raise ValueError("'accounts' item %r must be a 'bool' instance" % exclude_conflicts) - if start >= end: - raise ValueError("'start' must be less than 'end' (%s -> %s)" % (start, end)) - if not isinstance(merged_free_busy_interval, int): - raise ValueError("'merged_free_busy_interval' value %r must be an 'int'" % merged_free_busy_interval) - if requested_view not in FreeBusyViewOptions.REQUESTED_VIEWS: - raise ValueError( - "'requested_view' value %r must be one of %s" % (requested_view, FreeBusyViewOptions.REQUESTED_VIEWS)) - _, _, periods, transitions, transitions_groups = list(self.get_timezones( + tz_definition = list(self.get_timezones( timezones=[start.tzinfo], return_full_timezone_data=True ))[0] return GetUserAvailability(self).call( - timezone=TimeZone.from_server_timezone( - periods=periods, - transitions=transitions, - transitionsgroups=transitions_groups, - for_year=start.year - ), + timezone=TimeZone.from_server_timezone(tz_definition=tz_definition, for_year=start.year), mailbox_data=[MailboxData( email=account.primary_smtp_address if isinstance(account, Account) else account, attendee_type=attendee_type, @@ -2264,12 +2104,14 @@

                  Methods

                  return GetRoomLists(protocol=self).call() def get_rooms(self, roomlist): - return GetRooms(protocol=self).call(roomlist=RoomList(email_address=roomlist)) + return GetRooms(protocol=self).call(room_list=RoomList(email_address=roomlist)) - def resolve_names(self, names, return_full_contact_data=False, search_scope=None, shape=None): + def resolve_names(self, names, parent_folders=None, return_full_contact_data=False, search_scope=None, + shape=None): """Resolve accounts on the server using partial account data, e.g. an email address or initials. :param names: A list of identifiers to query + :param parent_folders: A list of contact folders to search in :param return_full_contact_data: If True, returns full contact data (Default value = False) :param search_scope: The scope to perform the search. Must be one of SEARCH_SCOPE_CHOICES (Default value = None) :param shape: (Default value = None) @@ -2277,8 +2119,8 @@

                  Methods

                  :return: A list of Mailbox items or, if return_full_contact_data is True, tuples of (Mailbox, Contact) items """ return list(ResolveNames(protocol=self).call( - unresolved_entries=names, return_full_contact_data=return_full_contact_data, search_scope=search_scope, - contact_data_shape=shape, + unresolved_entries=names, parent_folders=parent_folders, return_full_contact_data=return_full_contact_data, + search_scope=search_scope, contact_data_shape=shape, )) def expand_dl(self, distribution_list): @@ -2298,7 +2140,7 @@

                  Methods

                  This method is only available to users who have been assigned the Discovery Management RBAC role. See https://docs.microsoft.com/en-us/exchange/permissions-exo/permissions-exo - :param search_filter: Is set, must be a single email alias (Default value = None) + :param search_filter: If set, must be a single email alias (Default value = None) :param expand_group_membership: If True, returned distribution lists are expanded (Default value = False) :return: a list of SearchableMailbox, FailedMailbox or Exception instances @@ -2319,19 +2161,14 @@

                  Methods

                  return ConvertId(protocol=self).call(items=ids, destination_format=destination_format) def __getstate__(self): - # The lock and thread pool cannot be pickled + # The lock cannot be pickled state = super().__getstate__() del state['_version_lock'] - try: - del state['thread_pool'] - except KeyError: - # thread_pool is a cached property and may not exist - pass return state def __setstate__(self, state): - # Restore the lock. The thread pool is a cached property and will be recreated automatically. - self.__dict__.update(state) + # Restore the lock + super().__setstate__(state) self._version_lock = Lock() def __str__(self): @@ -2341,12 +2178,12 @@

                  Methods

                  else: fullname, api_version, build = '[unknown]', '[unknown]', '[unknown]' - return '''\ -EWS url: %s -Product name: %s -EWS API version: %s -Build number: %s -EWS auth: %s''' % (self.service_endpoint, fullname, api_version, build, self.auth_type)
                  + return f'''\ +EWS url: {self.service_endpoint} +Product name: {fullname} +EWS API version: {api_version} +Build number: {build} +EWS auth: {self.auth_type}'''

                  Ancestors

                    @@ -2421,25 +2258,6 @@

                    Methods

                    return list(ExpandDL(protocol=self).call(distribution_list=distribution_list))
                    -
                    -def get_auth_type(self) -
                    -
                    -
                    -
                    - -Expand source code - -
                    def get_auth_type(self):
                    -    # Autodetect authentication type. We also set version hint here.
                    -    name = str(self.credentials) if self.credentials and str(self.credentials) else 'DUMMY'
                    -    auth_type, api_version_hint = get_service_authtype(
                    -        service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS, name=name
                    -    )
                    -    self._api_version_hint = api_version_hint
                    -    return auth_type
                    -
                    -
                    def get_free_busy_info(self, accounts, start, end, merged_free_busy_interval=30, requested_view='DetailedMerged')
                    @@ -2473,31 +2291,12 @@

                    Methods

                    :return: A generator of FreeBusyView objects """ from .account import Account - for account, attendee_type, exclude_conflicts in accounts: - if not isinstance(account, (Account, str)): - raise ValueError("'accounts' item %r must be an 'Account' or 'str' instance" % account) - if attendee_type not in MailboxData.ATTENDEE_TYPES: - raise ValueError("'accounts' item %r must be one of %s" % (attendee_type, MailboxData.ATTENDEE_TYPES)) - if not isinstance(exclude_conflicts, bool): - raise ValueError("'accounts' item %r must be a 'bool' instance" % exclude_conflicts) - if start >= end: - raise ValueError("'start' must be less than 'end' (%s -> %s)" % (start, end)) - if not isinstance(merged_free_busy_interval, int): - raise ValueError("'merged_free_busy_interval' value %r must be an 'int'" % merged_free_busy_interval) - if requested_view not in FreeBusyViewOptions.REQUESTED_VIEWS: - raise ValueError( - "'requested_view' value %r must be one of %s" % (requested_view, FreeBusyViewOptions.REQUESTED_VIEWS)) - _, _, periods, transitions, transitions_groups = list(self.get_timezones( + tz_definition = list(self.get_timezones( timezones=[start.tzinfo], return_full_timezone_data=True ))[0] return GetUserAvailability(self).call( - timezone=TimeZone.from_server_timezone( - periods=periods, - transitions=transitions, - transitionsgroups=transitions_groups, - for_year=start.year - ), + timezone=TimeZone.from_server_timezone(tz_definition=tz_definition, for_year=start.year), mailbox_data=[MailboxData( email=account.primary_smtp_address if isinstance(account, Account) else account, attendee_type=attendee_type, @@ -2534,7 +2333,7 @@

                    Methods

                    Expand source code
                    def get_rooms(self, roomlist):
                    -    return GetRooms(protocol=self).call(roomlist=RoomList(email_address=roomlist))
                    + return GetRooms(protocol=self).call(room_list=RoomList(email_address=roomlist))
                    @@ -2544,7 +2343,7 @@

                    Methods

                    Call the GetSearchableMailboxes service to get mailboxes that can be searched.

                    This method is only available to users who have been assigned the Discovery Management RBAC role. See https://docs.microsoft.com/en-us/exchange/permissions-exo/permissions-exo

                    -

                    :param search_filter: Is set, must be a single email alias (Default value = None) +

                    :param search_filter: If set, must be a single email alias (Default value = None) :param expand_group_membership: If True, returned distribution lists are expanded (Default value = False)

                    :return: a list of SearchableMailbox, FailedMailbox or Exception instances

                    @@ -2557,7 +2356,7 @@

                    Methods

                    This method is only available to users who have been assigned the Discovery Management RBAC role. See https://docs.microsoft.com/en-us/exchange/permissions-exo/permissions-exo - :param search_filter: Is set, must be a single email alias (Default value = None) + :param search_filter: If set, must be a single email alias (Default value = None) :param expand_group_membership: If True, returned distribution lists are expanded (Default value = False) :return: a list of SearchableMailbox, FailedMailbox or Exception instances @@ -2576,7 +2375,7 @@

                    Methods

                    :param timezones: A list of EWSDateTime instances. If None, fetches all timezones from server (Default value = None) :param return_full_timezone_data: If true, also returns periods and transitions (Default value = False)

                    -

                    :return: A list of (tz_id, name, periods, transitions) tuples

                    +

                    :return: A generator of TimeZoneDefinition objects

                    Expand source code @@ -2588,7 +2387,7 @@

                    Methods

                    (Default value = None) :param return_full_timezone_data: If true, also returns periods and transitions (Default value = False) - :return: A list of (tz_id, name, periods, transitions) tuples + :return: A generator of TimeZoneDefinition objects """ return GetServerTimeZones(protocol=self).call( timezones=timezones, return_full_timezone_data=return_full_timezone_data @@ -2596,11 +2395,12 @@

                    Methods

                    -def resolve_names(self, names, return_full_contact_data=False, search_scope=None, shape=None) +def resolve_names(self, names, parent_folders=None, return_full_contact_data=False, search_scope=None, shape=None)

                    Resolve accounts on the server using partial account data, e.g. an email address or initials.

                    :param names: A list of identifiers to query +:param parent_folders: A list of contact folders to search in :param return_full_contact_data: If True, returns full contact data (Default value = False) :param search_scope: The scope to perform the search. Must be one of SEARCH_SCOPE_CHOICES (Default value = None) :param shape: (Default value = None)

                    @@ -2609,10 +2409,12 @@

                    Methods

                    Expand source code -
                    def resolve_names(self, names, return_full_contact_data=False, search_scope=None, shape=None):
                    +
                    def resolve_names(self, names, parent_folders=None, return_full_contact_data=False, search_scope=None,
                    +                  shape=None):
                         """Resolve accounts on the server using partial account data, e.g. an email address or initials.
                     
                         :param names: A list of identifiers to query
                    +    :param parent_folders: A list of contact folders to search in
                         :param return_full_contact_data: If True, returns full contact data (Default value = False)
                         :param search_scope: The scope to perform the search. Must be one of SEARCH_SCOPE_CHOICES (Default value = None)
                         :param shape: (Default value = None)
                    @@ -2620,8 +2422,8 @@ 

                    Methods

                    :return: A list of Mailbox items or, if return_full_contact_data is True, tuples of (Mailbox, Contact) items """ return list(ResolveNames(protocol=self).call( - unresolved_entries=names, return_full_contact_data=return_full_contact_data, search_scope=search_scope, - contact_data_shape=shape, + unresolved_entries=names, parent_folders=parent_folders, return_full_contact_data=return_full_contact_data, + search_scope=search_scope, contact_data_shape=shape, ))
                    @@ -2652,27 +2454,26 @@

                    Inherited members

                    @property @abc.abstractmethod def fail_fast(self): - # Used to choose the error handling policy. When True, a fault-tolerant policy is used. False, a fail-fast - # policy is used. - pass + """Used to choose the error handling policy. When True, a fault-tolerant policy is used. False, a fail-fast + policy is used.""" @property @abc.abstractmethod def back_off_until(self): - pass + """Return a datetime to back off until""" @back_off_until.setter @abc.abstractmethod def back_off_until(self, value): - pass + """Setter for back off values""" @abc.abstractmethod def back_off(self, seconds): - pass + """Set a new back off until value""" @abc.abstractmethod def may_retry_on_error(self, response, wait): - pass + """Return whether retries should still be attempted""" def raise_response_errors(self, response): cas_error = response.headers.get('X-CasErrorCode') @@ -2689,14 +2490,14 @@

                    Inherited members

                    raise UnauthorizedError('The referenced account is currently locked out') if response.status_code == 401 and self.fail_fast: # This is a login failure - raise UnauthorizedError('Invalid credentials for %s' % response.url) + raise UnauthorizedError(f'Invalid credentials for {response.url}') if 'TimeoutException' in response.headers: # A header set by us on CONNECTION_ERRORS raise response.headers['TimeoutException'] # This could be anything. Let higher layers handle this raise MalformedResponseError( - 'Unknown failure in response. Code: %s headers: %s content: %s' - % (response.status_code, response.headers, response.text) + f'Unknown failure in response. Code: {response.status_code} headers: {response.headers} ' + f'content: {response.text}' )

                    Subclasses

                    @@ -2708,7 +2509,7 @@

                    Instance variables

                    var back_off_until
                    -
                    +

                    Return a datetime to back off until

                    Expand source code @@ -2716,12 +2517,13 @@

                    Instance variables

                    @property
                     @abc.abstractmethod
                     def back_off_until(self):
                    -    pass
                    + """Return a datetime to back off until"""
                    var fail_fast
                    -
                    +

                    Used to choose the error handling policy. When True, a fault-tolerant policy is used. False, a fail-fast +policy is used.

                    Expand source code @@ -2729,9 +2531,8 @@

                    Instance variables

                    @property
                     @abc.abstractmethod
                     def fail_fast(self):
                    -    # Used to choose the error handling policy. When True, a fault-tolerant policy is used. False, a fail-fast
                    -    # policy is used.
                    -    pass
                    + """Used to choose the error handling policy. When True, a fault-tolerant policy is used. False, a fail-fast + policy is used."""
                    @@ -2741,28 +2542,28 @@

                    Methods

                    def back_off(self, seconds)
                    -
                    +

                    Set a new back off until value

                    Expand source code
                    @abc.abstractmethod
                     def back_off(self, seconds):
                    -    pass
                    + """Set a new back off until value"""
                    def may_retry_on_error(self, response, wait)
                    -
                    +

                    Return whether retries should still be attempted

                    Expand source code
                    @abc.abstractmethod
                     def may_retry_on_error(self, response, wait):
                    -    pass
                    + """Return whether retries should still be attempted"""
                    @@ -2789,14 +2590,14 @@

                    Methods

                    raise UnauthorizedError('The referenced account is currently locked out') if response.status_code == 401 and self.fail_fast: # This is a login failure - raise UnauthorizedError('Invalid credentials for %s' % response.url) + raise UnauthorizedError(f'Invalid credentials for {response.url}') if 'TimeoutException' in response.headers: # A header set by us on CONNECTION_ERRORS raise response.headers['TimeoutException'] # This could be anything. Let higher layers handle this raise MalformedResponseError( - 'Unknown failure in response. Code: %s headers: %s content: %s' - % (response.status_code, response.headers, response.text) + f'Unknown failure in response. Code: {response.status_code} headers: {response.headers} ' + f'content: {response.text}' )
                    @@ -2896,6 +2697,7 @@

                    credentials
                  • decrease_poolsize
                  • get_adapter
                  • +
                  • get_auth_type
                  • get_session
                  • increase_poolsize
                  • raw_session
                  • @@ -2917,21 +2719,12 @@

                    FailFast

                    -
                  • FaultTolerance

                  • @@ -2945,7 +2738,6 @@

                  • convert_ids
                  • expand_dl
                  • -
                  • get_auth_type
                  • get_free_busy_info
                  • get_roomlists
                  • get_rooms
                  • diff --git a/docs/exchangelib/queryset.html b/docs/exchangelib/queryset.html index 77484c3a..9205d88d 100644 --- a/docs/exchangelib/queryset.html +++ b/docs/exchangelib/queryset.html @@ -28,52 +28,52 @@

                    Module exchangelib.queryset

                    import abc
                     import logging
                    -import warnings
                     from copy import deepcopy
                     from itertools import islice
                     
                    -from .errors import MultipleObjectsReturned, DoesNotExist
                    +from .errors import MultipleObjectsReturned, DoesNotExist, InvalidEnumValue, InvalidTypeError, ErrorItemNotFound
                     from .fields import FieldPath, FieldOrder
                     from .items import CalendarItem, ID_ONLY
                     from .properties import InvalidField
                     from .restriction import Q
                    -from .services import CHUNK_SIZE
                     from .version import EXCHANGE_2010
                     
                     log = logging.getLogger(__name__)
                     
                    +MISSING_ITEM_ERRORS = (ErrorItemNotFound,)
                    +
                     
                     class SearchableMixIn:
                         """Implement a search API for inheritance."""
                     
                         @abc.abstractmethod
                         def get(self, *args, **kwargs):
                    -        pass
                    +        """Return a single object"""
                     
                         @abc.abstractmethod
                         def all(self):
                    -        pass
                    +        """Return all objects, unfiltered"""
                     
                         @abc.abstractmethod
                         def none(self):
                    -        pass
                    +        """Return an empty result"""
                     
                         @abc.abstractmethod
                         def filter(self, *args, **kwargs):
                    -        pass
                    +        """Apply filters to a query"""
                     
                         @abc.abstractmethod
                         def exclude(self, *args, **kwargs):
                    -        pass
                    +        """Apply filters to a query"""
                     
                         @abc.abstractmethod
                         def people(self):
                    -        pass
                    +        """Search for personas"""
                     
                     
                     class QuerySet(SearchableMixIn):
                    -    """A Django QuerySet-like class for querying items. Defers queries until the QuerySet is consumed. Supports chaining to
                    -    build up complex queries.
                    +    """A Django QuerySet-like class for querying items. Defers queries until the QuerySet is consumed. Supports
                    +    chaining to build up complex queries.
                     
                         Django QuerySet documentation: https://docs.djangoproject.com/en/dev/ref/models/querysets/
                         """
                    @@ -91,10 +91,10 @@ 

                    Module exchangelib.queryset

                    def __init__(self, folder_collection, request_type=ITEM): from .folders import FolderCollection if not isinstance(folder_collection, FolderCollection): - raise ValueError("folder_collection value '%s' must be a FolderCollection instance" % folder_collection) + raise InvalidTypeError('folder_collection', folder_collection, FolderCollection) self.folder_collection = folder_collection # A FolderCollection instance if request_type not in self.REQUEST_TYPES: - raise ValueError("'request_type' %r must be one of %s" % (request_type, self.REQUEST_TYPES)) + raise InvalidEnumValue('request_type', request_type, self.REQUEST_TYPES) self.request_type = request_type self.q = Q() # Default to no restrictions self.only_fields = None @@ -102,6 +102,7 @@

                    Module exchangelib.queryset

                    self.return_format = self.NONE self.calendar_view = None self.page_size = None + self.chunk_size = None self.max_items = None self.offset = 0 self._depth = None @@ -116,14 +117,6 @@

                    Module exchangelib.queryset

                    # items = list(qs) # new_qs = qs.exclude(bar='baz') # This should work, and should fetch from the server # - if not isinstance(self.q, Q): - raise ValueError("self.q value '%s' must be None or a Q instance" % self.q) - if not isinstance(self.only_fields, (type(None), tuple)): - raise ValueError("self.only_fields value '%s' must be None or a tuple" % self.only_fields) - if not isinstance(self.order_fields, (type(None), tuple)): - raise ValueError("self.order_fields value '%s' must be None or a tuple" % self.order_fields) - if self.return_format not in self.RETURN_TYPES: - raise ValueError("self.return_value '%s' must be one of %s" % (self.return_format, self.RETURN_TYPES)) # Only mutable objects need to be deepcopied. Folder should be the same object new_qs = self.__class__(self.folder_collection, request_type=self.request_type) new_qs.q = deepcopy(self.q) @@ -132,6 +125,7 @@

                    Module exchangelib.queryset

                    new_qs.return_format = self.return_format new_qs.calendar_view = self.calendar_view new_qs.page_size = self.page_size + new_qs.chunk_size = self.chunk_size new_qs.max_items = self.max_items new_qs.offset = self.offset new_qs._depth = self._depth @@ -146,7 +140,7 @@

                    Module exchangelib.queryset

                    return FieldPath.from_string(field_path=field_path, folder=folder) except InvalidField: pass - raise InvalidField("Unknown field path %r on folders %s" % (field_path, self.folder_collection.folders)) + raise InvalidField(f"Unknown field path {field_path!r} on folders {self.folder_collection.folders}") def _get_field_order(self, field_path): from .items import Persona @@ -160,7 +154,7 @@

                    Module exchangelib.queryset

                    return FieldOrder.from_string(field_path=field_path, folder=folder) except InvalidField: pass - raise InvalidField("Unknown field path %r on folders %s" % (field_path, self.folder_collection.folders)) + raise InvalidField(f"Unknown field path {field_path!r} on folders {self.folder_collection.folders}") @property def _id_field(self): @@ -172,7 +166,7 @@

                    Module exchangelib.queryset

                    def _additional_fields(self): if not isinstance(self.only_fields, tuple): - raise ValueError("'only_fields' value %r must be a tuple" % self.only_fields) + raise InvalidTypeError('only_fields', self.only_fields, tuple) # Remove ItemId and ChangeKey. We get them unconditionally additional_fields = {f for f in self.only_fields if not f.field.is_attribute} if self.request_type != self.ITEM: @@ -255,11 +249,13 @@

                    Module exchangelib.queryset

                    # The FindItem service does not support complex field types. Tell find_items() to return # (id, changekey) tuples, and pass that to fetch(). find_kwargs['additional_fields'] = None - items = self.folder_collection.account.fetch( + unfiltered_items = self.folder_collection.account.fetch( ids=self.folder_collection.find_items(self.q, **find_kwargs), only_fields=additional_fields, - chunk_size=self.page_size, + chunk_size=self.chunk_size, ) + # We may be unlucky that the item disappeared between the FindItem and the GetItem calls + items = filter(lambda i: not isinstance(i, MISSING_ITEM_ERRORS), unfiltered_items) else: if not additional_fields: # If additional_fields is the empty set, we only requested ID and changekey fields. We can then @@ -280,10 +276,10 @@

                    Module exchangelib.queryset

                    except TypeError as e: if 'unorderable types' not in e.args[0]: raise - raise ValueError(( - "Cannot sort on field '%s'. The field has no default value defined, and there are either items " - "with None values for this field, or the query contains exception instances (original error: %s).") - % (f.field_path, e)) + raise ValueError( + f"Cannot sort on field {f.field_path!r}. The field has no default value defined, and there are " + f"either items with None values for this field, or the query contains exception instances " + f"(original error: {e}).") if not extra_order_fields: return items @@ -335,6 +331,7 @@

                    Module exchangelib.queryset

                    raise IndexError() def _getitem_slice(self, s): + from .services import PAGE_SIZE if ((s.start or 0) < 0) or ((s.stop or 0) < 0) or ((s.step or 0) < 0): # islice() does not support negative start, stop and step. Make sure cache is full by iterating the full # query result, and then slice on the cache. @@ -348,7 +345,7 @@

                    Module exchangelib.queryset

                    new_qs.offset = s.start elif s.stop is not None: new_qs.max_items = s.stop - if new_qs.page_size is None and new_qs.max_items is not None and new_qs.max_items < CHUNK_SIZE: + if new_qs.page_size is None and new_qs.max_items is not None and new_qs.max_items < PAGE_SIZE: new_qs.page_size = new_qs.max_items return islice(new_qs.__iter__(), None, None, s.step) @@ -467,7 +464,7 @@

                    Module exchangelib.queryset

                    try: only_fields = tuple(self._get_field_path(arg) for arg in args) except ValueError as e: - raise ValueError("%s in only()" % e.args[0]) + raise ValueError(f"{e.args[0]} in only()") new_qs = self._copy_self() new_qs.only_fields = only_fields return new_qs @@ -481,7 +478,7 @@

                    Module exchangelib.queryset

                    try: order_fields = tuple(self._get_field_order(arg) for arg in args) except ValueError as e: - raise ValueError("%s in order_by()" % e.args[0]) + raise ValueError(f"{e.args[0]} in order_by()") new_qs = self._copy_self() new_qs.order_fields = order_fields return new_qs @@ -499,7 +496,7 @@

                    Module exchangelib.queryset

                    try: only_fields = tuple(self._get_field_path(arg) for arg in args) except ValueError as e: - raise ValueError("%s in values()" % e.args[0]) + raise ValueError(f"{e.args[0]} in values()") new_qs = self._copy_self() new_qs.only_fields = only_fields new_qs.return_format = self.VALUES @@ -511,13 +508,13 @@

                    Module exchangelib.queryset

                    """ flat = kwargs.pop('flat', False) if kwargs: - raise AttributeError('Unknown kwargs: %s' % kwargs) + raise AttributeError(f'Unknown kwargs: {kwargs}') if flat and len(args) != 1: raise ValueError('flat=True requires exactly one field name') try: only_fields = tuple(self._get_field_path(arg) for arg in args) except ValueError as e: - raise ValueError("%s in values_list()" % e.args[0]) + raise ValueError(f"{e.args[0]} in values_list()") new_qs = self._copy_self() new_qs.only_fields = only_fields new_qs.return_format = self.FLAT if flat else self.VALUES_LIST @@ -532,11 +529,6 @@

                    Module exchangelib.queryset

                    new_qs._depth = depth return new_qs - def iterator(self): - # Return an iterator over the results - warnings.warn('QuerySet no longer caches results. .iterator() is a no-op.', DeprecationWarning, stacklevel=2) - return self.__iter__() - ########################### # # Methods that end chaining @@ -562,16 +554,17 @@

                    Module exchangelib.queryset

                    return items[0] def count(self, page_size=1000): - """Get the query count, with as little effort as possible 'page_size' is the number of items to - fetch from the server per request. We're only fetching the IDs, so keep it high + """Get the query count, with as little effort as possible - :param page_size: (Default value = 1000) + :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. + (Default value = 1000) """ new_qs = self._copy_self() new_qs.only_fields = () new_qs.order_fields = None new_qs.return_format = self.NONE new_qs.page_size = page_size + # 'chunk_size' not needed since we never need to call GetItem return len(list(new_qs.__iter__())) def exists(self): @@ -587,42 +580,45 @@

                    Module exchangelib.queryset

                    new_qs.return_format = self.NONE return new_qs - def delete(self, page_size=1000, **delete_kwargs): - """Delete the items matching the query, with as little effort as possible. 'page_size' is the number of items - to fetch and delete per request. We're only fetching the IDs, so keep it high. + def delete(self, page_size=1000, chunk_size=100, **delete_kwargs): + """Delete the items matching the query, with as little effort as possible - :param page_size: (Default value = 1000) + :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. + (Default value = 1000) + :param chunk_size: The number of items to delete per request. (Default value = 100) :param delete_kwargs: """ ids = self._id_only_copy_self() ids.page_size = page_size return self.folder_collection.account.bulk_delete( ids=ids, - chunk_size=page_size, + chunk_size=chunk_size, **delete_kwargs ) - def send(self, page_size=1000, **send_kwargs): - """Send the items matching the query, with as little effort as possible. 'page_size' is the number of items - to fetch and send per request. We're only fetching the IDs, so keep it high. + def send(self, page_size=1000, chunk_size=100, **send_kwargs): + """Send the items matching the query, with as little effort as possible - :param page_size: (Default value = 1000) + :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. + (Default value = 1000) + :param chunk_size: The number of items to send per request. (Default value = 100) :param send_kwargs: """ ids = self._id_only_copy_self() ids.page_size = page_size return self.folder_collection.account.bulk_send( ids=ids, - chunk_size=page_size, + chunk_size=chunk_size, **send_kwargs ) - def copy(self, to_folder, page_size=1000, **copy_kwargs): - """Copy the items matching the query, with as little effort as possible. 'page_size' is the number of items - to fetch and copy per request. We're only fetching the IDs, so keep it high. + def copy(self, to_folder, page_size=1000, chunk_size=100, **copy_kwargs): + """Copy the items matching the query, with as little effort as possible :param to_folder: - :param page_size: (Default value = 1000) + :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. + (Default value = 1000) + :param chunk_size: The number of items to copy per request. (Default value = 100) :param copy_kwargs: """ ids = self._id_only_copy_self() @@ -630,58 +626,65 @@

                    Module exchangelib.queryset

                    return self.folder_collection.account.bulk_copy( ids=ids, to_folder=to_folder, - chunk_size=page_size, + chunk_size=chunk_size, **copy_kwargs ) - def move(self, to_folder, page_size=1000): + def move(self, to_folder, page_size=1000, chunk_size=100): """Move the items matching the query, with as little effort as possible. 'page_size' is the number of items to fetch and move per request. We're only fetching the IDs, so keep it high. :param to_folder: - :param page_size: (Default value = 1000) + :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. + (Default value = 1000) + :param chunk_size: The number of items to move per request. (Default value = 100) """ ids = self._id_only_copy_self() ids.page_size = page_size return self.folder_collection.account.bulk_move( ids=ids, to_folder=to_folder, - chunk_size=page_size, + chunk_size=chunk_size, ) - def archive(self, to_folder, page_size=1000): + def archive(self, to_folder, page_size=1000, chunk_size=100): """Archive the items matching the query, with as little effort as possible. 'page_size' is the number of items to fetch and move per request. We're only fetching the IDs, so keep it high. :param to_folder: - :param page_size: (Default value = 1000) + :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. + (Default value = 1000) + :param chunk_size: The number of items to archive per request. (Default value = 100) """ ids = self._id_only_copy_self() ids.page_size = page_size return self.folder_collection.account.bulk_archive( ids=ids, to_folder=to_folder, - chunk_size=page_size, + chunk_size=chunk_size, ) - def mark_as_junk(self, page_size=1000, **mark_as_junk_kwargs): + def mark_as_junk(self, page_size=1000, chunk_size=1000, **mark_as_junk_kwargs): """Mark the items matching the query as junk, with as little effort as possible. 'page_size' is the number of items to fetch and mark per request. We're only fetching the IDs, so keep it high. - :param page_size: (Default value = 1000) + :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. + (Default value = 1000) + :param chunk_size: The number of items to mark as junk per request. (Default value = 100) :param mark_as_junk_kwargs: """ ids = self._id_only_copy_self() ids.page_size = page_size return self.folder_collection.account.bulk_mark_as_junk( ids=ids, - chunk_size=page_size, + chunk_size=chunk_size, **mark_as_junk_kwargs ) def __str__(self): - fmt_args = [('q', str(self.q)), ('folders', '[%s]' % ', '.join(str(f) for f in self.folder_collection.folders))] - return self.__class__.__name__ + '(%s)' % ', '.join('%s=%s' % (k, v) for k, v in fmt_args) + fmt_args = [('q', str(self.q)), ('folders', f"[{', '.join(str(f) for f in self.folder_collection.folders)}]")] + args_str = ', '.join(f'{k}={v}' for k, v in fmt_args) + return f'{self.__class__.__name__}({args_str})' def _get_value_or_default(field, item): @@ -736,16 +739,16 @@

                    Classes

                    (folder_collection, request_type='item')

                  • -

                    A Django QuerySet-like class for querying items. Defers queries until the QuerySet is consumed. Supports chaining to -build up complex queries.

                    +

                    A Django QuerySet-like class for querying items. Defers queries until the QuerySet is consumed. Supports +chaining to build up complex queries.

                    Django QuerySet documentation: https://docs.djangoproject.com/en/dev/ref/models/querysets/

                    Expand source code
                    class QuerySet(SearchableMixIn):
                    -    """A Django QuerySet-like class for querying items. Defers queries until the QuerySet is consumed. Supports chaining to
                    -    build up complex queries.
                    +    """A Django QuerySet-like class for querying items. Defers queries until the QuerySet is consumed. Supports
                    +    chaining to build up complex queries.
                     
                         Django QuerySet documentation: https://docs.djangoproject.com/en/dev/ref/models/querysets/
                         """
                    @@ -763,10 +766,10 @@ 

                    Classes

                    def __init__(self, folder_collection, request_type=ITEM): from .folders import FolderCollection if not isinstance(folder_collection, FolderCollection): - raise ValueError("folder_collection value '%s' must be a FolderCollection instance" % folder_collection) + raise InvalidTypeError('folder_collection', folder_collection, FolderCollection) self.folder_collection = folder_collection # A FolderCollection instance if request_type not in self.REQUEST_TYPES: - raise ValueError("'request_type' %r must be one of %s" % (request_type, self.REQUEST_TYPES)) + raise InvalidEnumValue('request_type', request_type, self.REQUEST_TYPES) self.request_type = request_type self.q = Q() # Default to no restrictions self.only_fields = None @@ -774,6 +777,7 @@

                    Classes

                    self.return_format = self.NONE self.calendar_view = None self.page_size = None + self.chunk_size = None self.max_items = None self.offset = 0 self._depth = None @@ -788,14 +792,6 @@

                    Classes

                    # items = list(qs) # new_qs = qs.exclude(bar='baz') # This should work, and should fetch from the server # - if not isinstance(self.q, Q): - raise ValueError("self.q value '%s' must be None or a Q instance" % self.q) - if not isinstance(self.only_fields, (type(None), tuple)): - raise ValueError("self.only_fields value '%s' must be None or a tuple" % self.only_fields) - if not isinstance(self.order_fields, (type(None), tuple)): - raise ValueError("self.order_fields value '%s' must be None or a tuple" % self.order_fields) - if self.return_format not in self.RETURN_TYPES: - raise ValueError("self.return_value '%s' must be one of %s" % (self.return_format, self.RETURN_TYPES)) # Only mutable objects need to be deepcopied. Folder should be the same object new_qs = self.__class__(self.folder_collection, request_type=self.request_type) new_qs.q = deepcopy(self.q) @@ -804,6 +800,7 @@

                    Classes

                    new_qs.return_format = self.return_format new_qs.calendar_view = self.calendar_view new_qs.page_size = self.page_size + new_qs.chunk_size = self.chunk_size new_qs.max_items = self.max_items new_qs.offset = self.offset new_qs._depth = self._depth @@ -818,7 +815,7 @@

                    Classes

                    return FieldPath.from_string(field_path=field_path, folder=folder) except InvalidField: pass - raise InvalidField("Unknown field path %r on folders %s" % (field_path, self.folder_collection.folders)) + raise InvalidField(f"Unknown field path {field_path!r} on folders {self.folder_collection.folders}") def _get_field_order(self, field_path): from .items import Persona @@ -832,7 +829,7 @@

                    Classes

                    return FieldOrder.from_string(field_path=field_path, folder=folder) except InvalidField: pass - raise InvalidField("Unknown field path %r on folders %s" % (field_path, self.folder_collection.folders)) + raise InvalidField(f"Unknown field path {field_path!r} on folders {self.folder_collection.folders}") @property def _id_field(self): @@ -844,7 +841,7 @@

                    Classes

                    def _additional_fields(self): if not isinstance(self.only_fields, tuple): - raise ValueError("'only_fields' value %r must be a tuple" % self.only_fields) + raise InvalidTypeError('only_fields', self.only_fields, tuple) # Remove ItemId and ChangeKey. We get them unconditionally additional_fields = {f for f in self.only_fields if not f.field.is_attribute} if self.request_type != self.ITEM: @@ -927,11 +924,13 @@

                    Classes

                    # The FindItem service does not support complex field types. Tell find_items() to return # (id, changekey) tuples, and pass that to fetch(). find_kwargs['additional_fields'] = None - items = self.folder_collection.account.fetch( + unfiltered_items = self.folder_collection.account.fetch( ids=self.folder_collection.find_items(self.q, **find_kwargs), only_fields=additional_fields, - chunk_size=self.page_size, + chunk_size=self.chunk_size, ) + # We may be unlucky that the item disappeared between the FindItem and the GetItem calls + items = filter(lambda i: not isinstance(i, MISSING_ITEM_ERRORS), unfiltered_items) else: if not additional_fields: # If additional_fields is the empty set, we only requested ID and changekey fields. We can then @@ -952,10 +951,10 @@

                    Classes

                    except TypeError as e: if 'unorderable types' not in e.args[0]: raise - raise ValueError(( - "Cannot sort on field '%s'. The field has no default value defined, and there are either items " - "with None values for this field, or the query contains exception instances (original error: %s).") - % (f.field_path, e)) + raise ValueError( + f"Cannot sort on field {f.field_path!r}. The field has no default value defined, and there are " + f"either items with None values for this field, or the query contains exception instances " + f"(original error: {e}).") if not extra_order_fields: return items @@ -1007,6 +1006,7 @@

                    Classes

                    raise IndexError() def _getitem_slice(self, s): + from .services import PAGE_SIZE if ((s.start or 0) < 0) or ((s.stop or 0) < 0) or ((s.step or 0) < 0): # islice() does not support negative start, stop and step. Make sure cache is full by iterating the full # query result, and then slice on the cache. @@ -1020,7 +1020,7 @@

                    Classes

                    new_qs.offset = s.start elif s.stop is not None: new_qs.max_items = s.stop - if new_qs.page_size is None and new_qs.max_items is not None and new_qs.max_items < CHUNK_SIZE: + if new_qs.page_size is None and new_qs.max_items is not None and new_qs.max_items < PAGE_SIZE: new_qs.page_size = new_qs.max_items return islice(new_qs.__iter__(), None, None, s.step) @@ -1139,7 +1139,7 @@

                    Classes

                    try: only_fields = tuple(self._get_field_path(arg) for arg in args) except ValueError as e: - raise ValueError("%s in only()" % e.args[0]) + raise ValueError(f"{e.args[0]} in only()") new_qs = self._copy_self() new_qs.only_fields = only_fields return new_qs @@ -1153,7 +1153,7 @@

                    Classes

                    try: order_fields = tuple(self._get_field_order(arg) for arg in args) except ValueError as e: - raise ValueError("%s in order_by()" % e.args[0]) + raise ValueError(f"{e.args[0]} in order_by()") new_qs = self._copy_self() new_qs.order_fields = order_fields return new_qs @@ -1171,7 +1171,7 @@

                    Classes

                    try: only_fields = tuple(self._get_field_path(arg) for arg in args) except ValueError as e: - raise ValueError("%s in values()" % e.args[0]) + raise ValueError(f"{e.args[0]} in values()") new_qs = self._copy_self() new_qs.only_fields = only_fields new_qs.return_format = self.VALUES @@ -1183,13 +1183,13 @@

                    Classes

                    """ flat = kwargs.pop('flat', False) if kwargs: - raise AttributeError('Unknown kwargs: %s' % kwargs) + raise AttributeError(f'Unknown kwargs: {kwargs}') if flat and len(args) != 1: raise ValueError('flat=True requires exactly one field name') try: only_fields = tuple(self._get_field_path(arg) for arg in args) except ValueError as e: - raise ValueError("%s in values_list()" % e.args[0]) + raise ValueError(f"{e.args[0]} in values_list()") new_qs = self._copy_self() new_qs.only_fields = only_fields new_qs.return_format = self.FLAT if flat else self.VALUES_LIST @@ -1204,11 +1204,6 @@

                    Classes

                    new_qs._depth = depth return new_qs - def iterator(self): - # Return an iterator over the results - warnings.warn('QuerySet no longer caches results. .iterator() is a no-op.', DeprecationWarning, stacklevel=2) - return self.__iter__() - ########################### # # Methods that end chaining @@ -1234,16 +1229,17 @@

                    Classes

                    return items[0] def count(self, page_size=1000): - """Get the query count, with as little effort as possible 'page_size' is the number of items to - fetch from the server per request. We're only fetching the IDs, so keep it high + """Get the query count, with as little effort as possible - :param page_size: (Default value = 1000) + :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. + (Default value = 1000) """ new_qs = self._copy_self() new_qs.only_fields = () new_qs.order_fields = None new_qs.return_format = self.NONE new_qs.page_size = page_size + # 'chunk_size' not needed since we never need to call GetItem return len(list(new_qs.__iter__())) def exists(self): @@ -1259,42 +1255,45 @@

                    Classes

                    new_qs.return_format = self.NONE return new_qs - def delete(self, page_size=1000, **delete_kwargs): - """Delete the items matching the query, with as little effort as possible. 'page_size' is the number of items - to fetch and delete per request. We're only fetching the IDs, so keep it high. + def delete(self, page_size=1000, chunk_size=100, **delete_kwargs): + """Delete the items matching the query, with as little effort as possible - :param page_size: (Default value = 1000) + :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. + (Default value = 1000) + :param chunk_size: The number of items to delete per request. (Default value = 100) :param delete_kwargs: """ ids = self._id_only_copy_self() ids.page_size = page_size return self.folder_collection.account.bulk_delete( ids=ids, - chunk_size=page_size, + chunk_size=chunk_size, **delete_kwargs ) - def send(self, page_size=1000, **send_kwargs): - """Send the items matching the query, with as little effort as possible. 'page_size' is the number of items - to fetch and send per request. We're only fetching the IDs, so keep it high. + def send(self, page_size=1000, chunk_size=100, **send_kwargs): + """Send the items matching the query, with as little effort as possible - :param page_size: (Default value = 1000) + :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. + (Default value = 1000) + :param chunk_size: The number of items to send per request. (Default value = 100) :param send_kwargs: """ ids = self._id_only_copy_self() ids.page_size = page_size return self.folder_collection.account.bulk_send( ids=ids, - chunk_size=page_size, + chunk_size=chunk_size, **send_kwargs ) - def copy(self, to_folder, page_size=1000, **copy_kwargs): - """Copy the items matching the query, with as little effort as possible. 'page_size' is the number of items - to fetch and copy per request. We're only fetching the IDs, so keep it high. + def copy(self, to_folder, page_size=1000, chunk_size=100, **copy_kwargs): + """Copy the items matching the query, with as little effort as possible :param to_folder: - :param page_size: (Default value = 1000) + :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. + (Default value = 1000) + :param chunk_size: The number of items to copy per request. (Default value = 100) :param copy_kwargs: """ ids = self._id_only_copy_self() @@ -1302,58 +1301,65 @@

                    Classes

                    return self.folder_collection.account.bulk_copy( ids=ids, to_folder=to_folder, - chunk_size=page_size, + chunk_size=chunk_size, **copy_kwargs ) - def move(self, to_folder, page_size=1000): + def move(self, to_folder, page_size=1000, chunk_size=100): """Move the items matching the query, with as little effort as possible. 'page_size' is the number of items to fetch and move per request. We're only fetching the IDs, so keep it high. :param to_folder: - :param page_size: (Default value = 1000) + :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. + (Default value = 1000) + :param chunk_size: The number of items to move per request. (Default value = 100) """ ids = self._id_only_copy_self() ids.page_size = page_size return self.folder_collection.account.bulk_move( ids=ids, to_folder=to_folder, - chunk_size=page_size, + chunk_size=chunk_size, ) - def archive(self, to_folder, page_size=1000): + def archive(self, to_folder, page_size=1000, chunk_size=100): """Archive the items matching the query, with as little effort as possible. 'page_size' is the number of items to fetch and move per request. We're only fetching the IDs, so keep it high. :param to_folder: - :param page_size: (Default value = 1000) + :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. + (Default value = 1000) + :param chunk_size: The number of items to archive per request. (Default value = 100) """ ids = self._id_only_copy_self() ids.page_size = page_size return self.folder_collection.account.bulk_archive( ids=ids, to_folder=to_folder, - chunk_size=page_size, + chunk_size=chunk_size, ) - def mark_as_junk(self, page_size=1000, **mark_as_junk_kwargs): + def mark_as_junk(self, page_size=1000, chunk_size=1000, **mark_as_junk_kwargs): """Mark the items matching the query as junk, with as little effort as possible. 'page_size' is the number of items to fetch and mark per request. We're only fetching the IDs, so keep it high. - :param page_size: (Default value = 1000) + :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. + (Default value = 1000) + :param chunk_size: The number of items to mark as junk per request. (Default value = 100) :param mark_as_junk_kwargs: """ ids = self._id_only_copy_self() ids.page_size = page_size return self.folder_collection.account.bulk_mark_as_junk( ids=ids, - chunk_size=page_size, + chunk_size=chunk_size, **mark_as_junk_kwargs ) def __str__(self): - fmt_args = [('q', str(self.q)), ('folders', '[%s]' % ', '.join(str(f) for f in self.folder_collection.folders))] - return self.__class__.__name__ + '(%s)' % ', '.join('%s=%s' % (k, v) for k, v in fmt_args)
                    + fmt_args = [('q', str(self.q)), ('folders', f"[{', '.join(str(f) for f in self.folder_collection.folders)}]")] + args_str = ', '.join(f'{k}={v}' for k, v in fmt_args) + return f'{self.__class__.__name__}({args_str})'

                    Ancestors

                      @@ -1396,69 +1402,59 @@

                      Class variables

                      Methods

                      -
                      -def all(self) -
                      -
                      -
                      -
                      - -Expand source code - -
                      def all(self):
                      -    """ """
                      -    new_qs = self._copy_self()
                      -    return new_qs
                      -
                      -
                      -def archive(self, to_folder, page_size=1000) +def archive(self, to_folder, page_size=1000, chunk_size=100)

                      Archive the items matching the query, with as little effort as possible. 'page_size' is the number of items to fetch and move per request. We're only fetching the IDs, so keep it high.

                      :param to_folder: -:param page_size: (Default value = 1000)

                      +:param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. +(Default value = 1000) +:param chunk_size: The number of items to archive per request. (Default value = 100)

                    Expand source code -
                    def archive(self, to_folder, page_size=1000):
                    +
                    def archive(self, to_folder, page_size=1000, chunk_size=100):
                         """Archive the items matching the query, with as little effort as possible. 'page_size' is the number of items
                         to fetch and move per request. We're only fetching the IDs, so keep it high.
                     
                         :param to_folder:
                    -    :param page_size: (Default value = 1000)
                    +    :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high.
                    +    (Default value = 1000)
                    +    :param chunk_size: The number of items to archive per request. (Default value = 100)
                         """
                         ids = self._id_only_copy_self()
                         ids.page_size = page_size
                         return self.folder_collection.account.bulk_archive(
                             ids=ids,
                             to_folder=to_folder,
                    -        chunk_size=page_size,
                    +        chunk_size=chunk_size,
                         )
                    -def copy(self, to_folder, page_size=1000, **copy_kwargs) +def copy(self, to_folder, page_size=1000, chunk_size=100, **copy_kwargs)
                    -

                    Copy the items matching the query, with as little effort as possible. 'page_size' is the number of items -to fetch and copy per request. We're only fetching the IDs, so keep it high.

                    +

                    Copy the items matching the query, with as little effort as possible

                    :param to_folder: -:param page_size: +:param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. (Default value = 1000) +:param chunk_size: The number of items to copy per request. (Default value = 100) :param copy_kwargs:

                    Expand source code -
                    def copy(self, to_folder, page_size=1000, **copy_kwargs):
                    -    """Copy the items matching the query, with as little effort as possible. 'page_size' is the number of items
                    -    to fetch and copy per request. We're only fetching the IDs, so keep it high.
                    +
                    def copy(self, to_folder, page_size=1000, chunk_size=100, **copy_kwargs):
                    +    """Copy the items matching the query, with as little effort as possible
                     
                         :param to_folder:
                    -    :param page_size:  (Default value = 1000)
                    +    :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high.
                    +    (Default value = 1000)
                    +    :param chunk_size: The number of items to copy per request. (Default value = 100)
                         :param copy_kwargs:
                         """
                         ids = self._id_only_copy_self()
                    @@ -1466,7 +1462,7 @@ 

                    Methods

                    return self.folder_collection.account.bulk_copy( ids=ids, to_folder=to_folder, - chunk_size=page_size, + chunk_size=chunk_size, **copy_kwargs )
                    @@ -1475,53 +1471,54 @@

                    Methods

                    def count(self, page_size=1000)
                  -

                  Get the query count, with as little effort as possible 'page_size' is the number of items to -fetch from the server per request. We're only fetching the IDs, so keep it high

                  -

                  :param page_size: +

                  Get the query count, with as little effort as possible

                  +

                  :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. (Default value = 1000)

                  Expand source code
                  def count(self, page_size=1000):
                  -    """Get the query count, with as little effort as possible 'page_size' is the number of items to
                  -    fetch from the server per request. We're only fetching the IDs, so keep it high
                  +    """Get the query count, with as little effort as possible
                   
                  -    :param page_size:  (Default value = 1000)
                  +    :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high.
                  +    (Default value = 1000)
                       """
                       new_qs = self._copy_self()
                       new_qs.only_fields = ()
                       new_qs.order_fields = None
                       new_qs.return_format = self.NONE
                       new_qs.page_size = page_size
                  +    # 'chunk_size' not needed since we never need to call GetItem
                       return len(list(new_qs.__iter__()))
                  -def delete(self, page_size=1000, **delete_kwargs) +def delete(self, page_size=1000, chunk_size=100, **delete_kwargs)
                  -

                  Delete the items matching the query, with as little effort as possible. 'page_size' is the number of items -to fetch and delete per request. We're only fetching the IDs, so keep it high.

                  -

                  :param page_size: +

                  Delete the items matching the query, with as little effort as possible

                  +

                  :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. (Default value = 1000) +:param chunk_size: The number of items to delete per request. (Default value = 100) :param delete_kwargs:

                  Expand source code -
                  def delete(self, page_size=1000, **delete_kwargs):
                  -    """Delete the items matching the query, with as little effort as possible. 'page_size' is the number of items
                  -    to fetch and delete per request. We're only fetching the IDs, so keep it high.
                  +
                  def delete(self, page_size=1000, chunk_size=100, **delete_kwargs):
                  +    """Delete the items matching the query, with as little effort as possible
                   
                  -    :param page_size:  (Default value = 1000)
                  +    :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high.
                  +    (Default value = 1000)
                  +    :param chunk_size: The number of items to delete per request. (Default value = 100)
                       :param delete_kwargs:
                       """
                       ids = self._id_only_copy_self()
                       ids.page_size = page_size
                       return self.folder_collection.account.bulk_delete(
                           ids=ids,
                  -        chunk_size=page_size,
                  +        chunk_size=chunk_size,
                           **delete_kwargs
                       )
                  @@ -1546,22 +1543,6 @@

                  Methods

                  return new_qs
                  -
                  -def exclude(self, *args, **kwargs) -
                  -
                  -
                  -
                  - -Expand source code - -
                  def exclude(self, *args, **kwargs):
                  -    new_qs = self._copy_self()
                  -    q = ~Q(*args, **kwargs)
                  -    new_qs.q = new_qs.q & q
                  -    return new_qs
                  -
                  -
                  def exists(self)
                  @@ -1578,22 +1559,6 @@

                  Methods

                  return new_qs.count(page_size=1) > 0
                  -
                  -def filter(self, *args, **kwargs) -
                  -
                  -
                  -
                  - -Expand source code - -
                  def filter(self, *args, **kwargs):
                  -    new_qs = self._copy_self()
                  -    q = Q(*args, **kwargs)
                  -    new_qs.q = new_qs.q & q
                  -    return new_qs
                  -
                  -
                  def get(self, *args, **kwargs)
                  @@ -1622,94 +1587,70 @@

                  Methods

                  return items[0] -
                  -def iterator(self) -
                  -
                  -
                  -
                  - -Expand source code - -
                  def iterator(self):
                  -    # Return an iterator over the results
                  -    warnings.warn('QuerySet no longer caches results. .iterator() is a no-op.', DeprecationWarning, stacklevel=2)
                  -    return self.__iter__()
                  -
                  -
                  -def mark_as_junk(self, page_size=1000, **mark_as_junk_kwargs) +def mark_as_junk(self, page_size=1000, chunk_size=1000, **mark_as_junk_kwargs)

                  Mark the items matching the query as junk, with as little effort as possible. 'page_size' is the number of items to fetch and mark per request. We're only fetching the IDs, so keep it high.

                  -

                  :param page_size: +

                  :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. (Default value = 1000) +:param chunk_size: The number of items to mark as junk per request. (Default value = 100) :param mark_as_junk_kwargs:

                  Expand source code -
                  def mark_as_junk(self, page_size=1000, **mark_as_junk_kwargs):
                  +
                  def mark_as_junk(self, page_size=1000, chunk_size=1000, **mark_as_junk_kwargs):
                       """Mark the items matching the query as junk, with as little effort as possible. 'page_size' is the number of
                       items to fetch and mark per request. We're only fetching the IDs, so keep it high.
                   
                  -    :param page_size:  (Default value = 1000)
                  +    :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high.
                  +    (Default value = 1000)
                  +    :param chunk_size: The number of items to mark as junk per request. (Default value = 100)
                       :param mark_as_junk_kwargs:
                       """
                       ids = self._id_only_copy_self()
                       ids.page_size = page_size
                       return self.folder_collection.account.bulk_mark_as_junk(
                           ids=ids,
                  -        chunk_size=page_size,
                  +        chunk_size=chunk_size,
                           **mark_as_junk_kwargs
                       )
                  -def move(self, to_folder, page_size=1000) +def move(self, to_folder, page_size=1000, chunk_size=100)

                  Move the items matching the query, with as little effort as possible. 'page_size' is the number of items to fetch and move per request. We're only fetching the IDs, so keep it high.

                  :param to_folder: -:param page_size: (Default value = 1000)

                  +:param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. +(Default value = 1000) +:param chunk_size: The number of items to move per request. (Default value = 100)

                  Expand source code -
                  def move(self, to_folder, page_size=1000):
                  +
                  def move(self, to_folder, page_size=1000, chunk_size=100):
                       """Move the items matching the query, with as little effort as possible. 'page_size' is the number of items
                       to fetch and move per request. We're only fetching the IDs, so keep it high.
                   
                       :param to_folder:
                  -    :param page_size: (Default value = 1000)
                  +    :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high.
                  +    (Default value = 1000)
                  +    :param chunk_size: The number of items to move per request. (Default value = 100)
                       """
                       ids = self._id_only_copy_self()
                       ids.page_size = page_size
                       return self.folder_collection.account.bulk_move(
                           ids=ids,
                           to_folder=to_folder,
                  -        chunk_size=page_size,
                  +        chunk_size=chunk_size,
                       )
                  -
                  -def none(self) -
                  -
                  -
                  -
                  - -Expand source code - -
                  def none(self):
                  -    """ """
                  -    new_qs = self._copy_self()
                  -    new_qs.q = Q(conn_type=Q.NEVER)
                  -    return new_qs
                  -
                  -
                  def only(self, *args)
                  @@ -1724,7 +1665,7 @@

                  Methods

                  try: only_fields = tuple(self._get_field_path(arg) for arg in args) except ValueError as e: - raise ValueError("%s in only()" % e.args[0]) + raise ValueError(f"{e.args[0]} in only()") new_qs = self._copy_self() new_qs.only_fields = only_fields return new_qs
                  @@ -1749,7 +1690,7 @@

                  Methods

                  try: order_fields = tuple(self._get_field_order(arg) for arg in args) except ValueError as e: - raise ValueError("%s in order_by()" % e.args[0]) + raise ValueError(f"{e.args[0]} in order_by()") new_qs = self._copy_self() new_qs.order_fields = order_fields return new_qs
                  @@ -1791,30 +1732,31 @@

                  Methods

                  -def send(self, page_size=1000, **send_kwargs) +def send(self, page_size=1000, chunk_size=100, **send_kwargs)
                  -

                  Send the items matching the query, with as little effort as possible. 'page_size' is the number of items -to fetch and send per request. We're only fetching the IDs, so keep it high.

                  -

                  :param page_size: +

                  Send the items matching the query, with as little effort as possible

                  +

                  :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high. (Default value = 1000) +:param chunk_size: The number of items to send per request. (Default value = 100) :param send_kwargs:

                  Expand source code -
                  def send(self, page_size=1000, **send_kwargs):
                  -    """Send the items matching the query, with as little effort as possible. 'page_size' is the number of items
                  -    to fetch and send per request. We're only fetching the IDs, so keep it high.
                  +
                  def send(self, page_size=1000, chunk_size=100, **send_kwargs):
                  +    """Send the items matching the query, with as little effort as possible
                   
                  -    :param page_size:  (Default value = 1000)
                  +    :param page_size: The number of items to fetch per request. We're only fetching the IDs, so keep it high.
                  +    (Default value = 1000)
                  +    :param chunk_size: The number of items to send per request. (Default value = 100)
                       :param send_kwargs:
                       """
                       ids = self._id_only_copy_self()
                       ids.page_size = page_size
                       return self.folder_collection.account.bulk_send(
                           ids=ids,
                  -        chunk_size=page_size,
                  +        chunk_size=chunk_size,
                           **send_kwargs
                       )
                  @@ -1832,7 +1774,7 @@

                  Methods

                  try: only_fields = tuple(self._get_field_path(arg) for arg in args) except ValueError as e: - raise ValueError("%s in values()" % e.args[0]) + raise ValueError(f"{e.args[0]} in values()") new_qs = self._copy_self() new_qs.only_fields = only_fields new_qs.return_format = self.VALUES @@ -1855,13 +1797,13 @@

                  Methods

                  """ flat = kwargs.pop('flat', False) if kwargs: - raise AttributeError('Unknown kwargs: %s' % kwargs) + raise AttributeError(f'Unknown kwargs: {kwargs}') if flat and len(args) != 1: raise ValueError('flat=True requires exactly one field name') try: only_fields = tuple(self._get_field_path(arg) for arg in args) except ValueError as e: - raise ValueError("%s in values_list()" % e.args[0]) + raise ValueError(f"{e.args[0]} in values_list()") new_qs = self._copy_self() new_qs.only_fields = only_fields new_qs.return_format = self.FLAT if flat else self.VALUES_LIST @@ -1869,6 +1811,17 @@

                  Methods

                  +

                  Inherited members

                  +
                  class SearchableMixIn @@ -1884,27 +1837,27 @@

                  Methods

                  @abc.abstractmethod def get(self, *args, **kwargs): - pass + """Return a single object""" @abc.abstractmethod def all(self): - pass + """Return all objects, unfiltered""" @abc.abstractmethod def none(self): - pass + """Return an empty result""" @abc.abstractmethod def filter(self, *args, **kwargs): - pass + """Apply filters to a query""" @abc.abstractmethod def exclude(self, *args, **kwargs): - pass + """Apply filters to a query""" @abc.abstractmethod def people(self): - pass
                  + """Search for personas"""

                  Subclasses

                    @@ -1918,84 +1871,84 @@

                    Methods

                    def all(self)
                  -
                  +

                  Return all objects, unfiltered

                  Expand source code
                  @abc.abstractmethod
                   def all(self):
                  -    pass
                  + """Return all objects, unfiltered"""
                  def exclude(self, *args, **kwargs)
                  -
                  +

                  Apply filters to a query

                  Expand source code
                  @abc.abstractmethod
                   def exclude(self, *args, **kwargs):
                  -    pass
                  + """Apply filters to a query"""
                  def filter(self, *args, **kwargs)
                  -
                  +

                  Apply filters to a query

                  Expand source code
                  @abc.abstractmethod
                   def filter(self, *args, **kwargs):
                  -    pass
                  + """Apply filters to a query"""
                  def get(self, *args, **kwargs)
                  -
                  +

                  Return a single object

                  Expand source code
                  @abc.abstractmethod
                   def get(self, *args, **kwargs):
                  -    pass
                  + """Return a single object"""
                  def none(self)
                  -
                  +

                  Return an empty result

                  Expand source code
                  @abc.abstractmethod
                   def none(self):
                  -    pass
                  + """Return an empty result"""
                  def people(self)
                  -
                  +

                  Search for personas

                  Expand source code
                  @abc.abstractmethod
                   def people(self):
                  -    pass
                  + """Search for personas"""
                  @@ -2027,20 +1980,15 @@

                  RETURN_TYPES
                • VALUES
                • VALUES_LIST
                • -
                • all
                • archive
                • copy
                • count
                • delete
                • depth
                • -
                • exclude
                • exists
                • -
                • filter
                • get
                • -
                • iterator
                • mark_as_junk
                • move
                • -
                • none
                • only
                • order_by
                • people
                • diff --git a/docs/exchangelib/recurrence.html b/docs/exchangelib/recurrence.html index b39d803e..b769ddf6 100644 --- a/docs/exchangelib/recurrence.html +++ b/docs/exchangelib/recurrence.html @@ -28,8 +28,8 @@

                  Module exchangelib.recurrence

                  import logging
                   
                  -from .fields import IntegerField, EnumField, EnumListField, DateOrDateTimeField, DateTimeField, EWSElementField, \
                  -    IdElementField, MONTHS, WEEK_NUMBERS, WEEKDAYS
                  +from .fields import IntegerField, EnumField, WeekdaysField, DateOrDateTimeField, DateTimeField, EWSElementField, \
                  +    IdElementField, MONTHS, WEEK_NUMBERS, WEEKDAYS, WEEKDAY_NAMES
                   from .properties import EWSElement, IdChangeKeyMixIn, ItemId, EWSMeta
                   
                   log = logging.getLogger(__name__)
                  @@ -69,7 +69,7 @@ 

                  Module exchangelib.recurrence

                  month = EnumField(field_uri='Month', enum=MONTHS, is_required=True) def __str__(self): - return 'Occurs on day %s of %s' % (self.day_of_month, _month_to_str(self.month)) + return f'Occurs on day {self.day_of_month} of {_month_to_str(self.month)}' class RelativeYearlyPattern(Pattern): @@ -79,9 +79,9 @@

                  Module exchangelib.recurrence

                  ELEMENT_NAME = 'RelativeYearlyRecurrence' - # The weekday of the occurrence, as a valid ISO 8601 weekday number in range 1 -> 7 (1 being Monday). - # Alternatively, the weekday can be one of the DAY (or 8), WEEK_DAY (or 9) or WEEKEND_DAY (or 10) consts which - # is interpreted as the first day, weekday, or weekend day in the month, respectively. + # Valid ISO 8601 weekday, as a number in range 1 -> 7 (1 being Monday). The value can also be one of the DAY + # (or 8), WEEK_DAY (or 9) or WEEKEND_DAY (or 10) consts which is interpreted as the first day, weekday, or weekend + # day of the year. Despite the field name in EWS, this is not a list. weekday = EnumField(field_uri='DaysOfWeek', enum=WEEKDAYS, is_required=True) # Week number of the month, in range 1 -> 5. If 5 is specified, this assumes the last week of the month for # months that have only 4 weeks @@ -90,11 +90,8 @@

                  Module exchangelib.recurrence

                  month = EnumField(field_uri='Month', enum=MONTHS, is_required=True) def __str__(self): - return 'Occurs on weekday %s in the %s week of %s' % ( - _weekday_to_str(self.weekday), - _week_number_to_str(self.week_number), - _month_to_str(self.month) - ) + return f'Occurs on weekday {_weekday_to_str(self.weekday)} in the {_week_number_to_str(self.week_number)} ' \ + f'week of {_month_to_str(self.month)}' class AbsoluteMonthlyPattern(Pattern): @@ -111,7 +108,7 @@

                  Module exchangelib.recurrence

                  day_of_month = IntegerField(field_uri='DayOfMonth', min=1, max=31, is_required=True) def __str__(self): - return 'Occurs on day %s of every %s month(s)' % (self.day_of_month, self.interval) + return f'Occurs on day {self.day_of_month} of every {self.interval} month(s)' class RelativeMonthlyPattern(Pattern): @@ -123,20 +120,17 @@

                  Module exchangelib.recurrence

                  # Interval, in months, in range 1 -> 99 interval = IntegerField(field_uri='Interval', min=1, max=99, is_required=True) - # The weekday of the occurrence, as a valid ISO 8601 weekday number in range 1 -> 7 (1 being Monday). - # Alternatively, the weekday can be one of the DAY (or 8), WEEK_DAY (or 9) or WEEKEND_DAY (or 10) consts which - # is interpreted as the first day, weekday, or weekend day in the month, respectively. + # Valid ISO 8601 weekday, as a number in range 1 -> 7 (1 being Monday). The value can also be one of the DAY + # (or 8), WEEK_DAY (or 9) or WEEKEND_DAY (or 10) consts which is interpreted as the first day, weekday, or weekend + # day of the month. Despite the field name in EWS, this is not a list. weekday = EnumField(field_uri='DaysOfWeek', enum=WEEKDAYS, is_required=True) # Week number of the month, in range 1 -> 5. If 5 is specified, this assumes the last week of the month for # months that have only 4 weeks. week_number = EnumField(field_uri='DayOfWeekIndex', enum=WEEK_NUMBERS, is_required=True) def __str__(self): - return 'Occurs on weekday %s in the %s week of every %s month(s)' % ( - _weekday_to_str(self.weekday), - _week_number_to_str(self.week_number), - self.interval - ) + return f'Occurs on weekday {_weekday_to_str(self.weekday)} in the {_week_number_to_str(self.week_number)} ' \ + f'week of every {self.interval} month(s)' class WeeklyPattern(Pattern): @@ -147,20 +141,14 @@

                  Module exchangelib.recurrence

                  # Interval, in weeks, in range 1 -> 99 interval = IntegerField(field_uri='Interval', min=1, max=99, is_required=True) # List of valid ISO 8601 weekdays, as list of numbers in range 1 -> 7 (1 being Monday) - weekdays = EnumListField(field_uri='DaysOfWeek', enum=WEEKDAYS, is_required=True) + weekdays = WeekdaysField(field_uri='DaysOfWeek', enum=WEEKDAY_NAMES, is_required=True) # The first day of the week. Defaults to Monday - first_day_of_week = EnumField(field_uri='FirstDayOfWeek', enum=WEEKDAYS, default=1, is_required=True) + first_day_of_week = EnumField(field_uri='FirstDayOfWeek', enum=WEEKDAY_NAMES, default=1, is_required=True) def __str__(self): - if isinstance(self.weekdays, str): - weekdays = [self.weekdays] - elif isinstance(self.weekdays, int): - weekdays = [_weekday_to_str(self.weekdays)] - else: - weekdays = [_weekday_to_str(i) for i in self.weekdays] - return 'Occurs on weekdays %s of every %s week(s) where the first day of the week is %s' % ( - ', '.join(weekdays), self.interval, _weekday_to_str(self.first_day_of_week) - ) + weekdays = [_weekday_to_str(i) for i in self.get_field_by_fieldname('weekdays').clean(self.weekdays)] + return f'Occurs on weekdays {", ".join(weekdays)} of every {self.interval} week(s) where the first day of ' \ + f'the week is {_weekday_to_str(self.first_day_of_week)}' class DailyPattern(Pattern): @@ -172,7 +160,7 @@

                  Module exchangelib.recurrence

                  interval = IntegerField(field_uri='Interval', min=1, max=999, is_required=True) def __str__(self): - return 'Occurs every %s day(s)' % self.interval + return f'Occurs every {self.interval} day(s)' class YearlyRegeneration(Regeneration): @@ -184,7 +172,7 @@

                  Module exchangelib.recurrence

                  interval = IntegerField(field_uri='Interval', min=1, is_required=True) def __str__(self): - return 'Regenerates every %s year(s)' % self.interval + return f'Regenerates every {self.interval} year(s)' class MonthlyRegeneration(Regeneration): @@ -196,7 +184,7 @@

                  Module exchangelib.recurrence

                  interval = IntegerField(field_uri='Interval', min=1, is_required=True) def __str__(self): - return 'Regenerates every %s month(s)' % self.interval + return f'Regenerates every {self.interval} month(s)' class WeeklyRegeneration(Regeneration): @@ -208,7 +196,7 @@

                  Module exchangelib.recurrence

                  interval = IntegerField(field_uri='Interval', min=1, is_required=True) def __str__(self): - return 'Regenerates every %s week(s)' % self.interval + return f'Regenerates every {self.interval} week(s)' class DailyRegeneration(Regeneration): @@ -220,7 +208,7 @@

                  Module exchangelib.recurrence

                  interval = IntegerField(field_uri='Interval', min=1, is_required=True) def __str__(self): - return 'Regenerates every %s day(s)' % self.interval + return f'Regenerates every {self.interval} day(s)' class Boundary(EWSElement, metaclass=EWSMeta): @@ -236,7 +224,7 @@

                  Module exchangelib.recurrence

                  start = DateOrDateTimeField(field_uri='StartDate', is_required=True) def __str__(self): - return 'Starts on %s' % self.start + return f'Starts on {self.start}' class EndDatePattern(Boundary): @@ -250,7 +238,7 @@

                  Module exchangelib.recurrence

                  end = DateOrDateTimeField(field_uri='EndDate', is_required=True) def __str__(self): - return 'Starts on %s, ends on %s' % (self.start, self.end) + return f'Starts on {self.start}, ends on {self.end}' class NumberedPattern(Boundary): @@ -264,7 +252,7 @@

                  Module exchangelib.recurrence

                  number = IntegerField(field_uri='NumberOfOccurrences', min=1, max=999, is_required=True) def __str__(self): - return 'Starts on %s and occurs %s times' % (self.start, self.number) + return f'Starts on {self.start} and occurs {self.number} time(s)' class Occurrence(IdChangeKeyMixIn): @@ -320,7 +308,8 @@

                  Module exchangelib.recurrence

                  """ ELEMENT_NAME = 'Recurrence' - PATTERN_CLASSES = PATTERN_CLASSES + PATTERN_CLASS_MAP = {cls.response_tag(): cls for cls in PATTERN_CLASSES} + BOUNDARY_CLASS_MAP = {cls.response_tag(): cls for cls in BOUNDARY_CLASSES} pattern = EWSElementField(value_cls=Pattern) boundary = EWSElementField(value_cls=Boundary) @@ -345,26 +334,16 @@

                  Module exchangelib.recurrence

                  @classmethod def from_xml(cls, elem, account): - for pattern_cls in cls.PATTERN_CLASSES: - pattern_elem = elem.find(pattern_cls.response_tag()) - if pattern_elem is None: - continue - pattern = pattern_cls.from_xml(elem=pattern_elem, account=account) - break - else: - pattern = None - for boundary_cls in BOUNDARY_CLASSES: - boundary_elem = elem.find(boundary_cls.response_tag()) - if boundary_elem is None: - continue - boundary = boundary_cls.from_xml(elem=boundary_elem, account=account) - break - else: - boundary = None + pattern, boundary = None, None + for child_elem in elem: + if child_elem.tag in cls.PATTERN_CLASS_MAP: + pattern = cls.PATTERN_CLASS_MAP[child_elem.tag].from_xml(elem=child_elem, account=account) + elif child_elem.tag in cls.BOUNDARY_CLASS_MAP: + boundary = cls.BOUNDARY_CLASS_MAP[child_elem.tag].from_xml(elem=child_elem, account=account) return cls(pattern=pattern, boundary=boundary) def __str__(self): - return 'Pattern: %s, Boundary: %s' % (self.pattern, self.boundary) + return f'Pattern: {self.pattern}, Boundary: {self.boundary}' class TaskRecurrence(Recurrence): @@ -372,7 +351,7 @@

                  Module exchangelib.recurrence

                  https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recurrence-taskrecurrencetype """ - PATTERN_CLASSES = PATTERN_CLASSES + REGENERATION_CLASSES
                  + PATTERN_CLASS_MAP = {cls.response_tag(): cls for cls in PATTERN_CLASSES + REGENERATION_CLASSES}
                  @@ -409,7 +388,7 @@

                  Classes

                  day_of_month = IntegerField(field_uri='DayOfMonth', min=1, max=31, is_required=True) def __str__(self): - return 'Occurs on day %s of every %s month(s)' % (self.day_of_month, self.interval) + return f'Occurs on day {self.day_of_month} of every {self.interval} month(s)'

                  Ancestors

                    @@ -475,7 +454,7 @@

                    Inherited members

                    month = EnumField(field_uri='Month', enum=MONTHS, is_required=True) def __str__(self): - return 'Occurs on day %s of %s' % (self.day_of_month, _month_to_str(self.month)) + return f'Occurs on day {self.day_of_month} of {_month_to_str(self.month)}'

                    Ancestors

                      @@ -570,7 +549,7 @@

                      Inherited members

                      interval = IntegerField(field_uri='Interval', min=1, max=999, is_required=True) def __str__(self): - return 'Occurs every %s day(s)' % self.interval + return f'Occurs every {self.interval} day(s)'

                      Ancestors

                        @@ -626,7 +605,7 @@

                        Inherited members

                        interval = IntegerField(field_uri='Interval', min=1, is_required=True) def __str__(self): - return 'Regenerates every %s day(s)' % self.interval + return f'Regenerates every {self.interval} day(s)'

                        Ancestors

                          @@ -737,7 +716,7 @@

                          Inherited members

                          end = DateOrDateTimeField(field_uri='EndDate', is_required=True) def __str__(self): - return 'Starts on %s, ends on %s' % (self.start, self.end) + return f'Starts on {self.start}, ends on {self.end}'

                          Ancestors

                            @@ -879,7 +858,7 @@

                            Inherited members

                            interval = IntegerField(field_uri='Interval', min=1, is_required=True) def __str__(self): - return 'Regenerates every %s month(s)' % self.interval + return f'Regenerates every {self.interval} month(s)'

                            Ancestors

                              @@ -936,7 +915,7 @@

                              Inherited members

                              start = DateOrDateTimeField(field_uri='StartDate', is_required=True) def __str__(self): - return 'Starts on %s' % self.start + return f'Starts on {self.start}'

                              Ancestors

                                @@ -994,7 +973,7 @@

                                Inherited members

                                number = IntegerField(field_uri='NumberOfOccurrences', min=1, max=999, is_required=True) def __str__(self): - return 'Starts on %s and occurs %s times' % (self.start, self.number) + return f'Starts on {self.start} and occurs {self.number} time(s)'

                                Ancestors

                                  @@ -1168,7 +1147,8 @@

                                  Inherited members

                                  """ ELEMENT_NAME = 'Recurrence' - PATTERN_CLASSES = PATTERN_CLASSES + PATTERN_CLASS_MAP = {cls.response_tag(): cls for cls in PATTERN_CLASSES} + BOUNDARY_CLASS_MAP = {cls.response_tag(): cls for cls in BOUNDARY_CLASSES} pattern = EWSElementField(value_cls=Pattern) boundary = EWSElementField(value_cls=Boundary) @@ -1193,26 +1173,16 @@

                                  Inherited members

                                  @classmethod def from_xml(cls, elem, account): - for pattern_cls in cls.PATTERN_CLASSES: - pattern_elem = elem.find(pattern_cls.response_tag()) - if pattern_elem is None: - continue - pattern = pattern_cls.from_xml(elem=pattern_elem, account=account) - break - else: - pattern = None - for boundary_cls in BOUNDARY_CLASSES: - boundary_elem = elem.find(boundary_cls.response_tag()) - if boundary_elem is None: - continue - boundary = boundary_cls.from_xml(elem=boundary_elem, account=account) - break - else: - boundary = None + pattern, boundary = None, None + for child_elem in elem: + if child_elem.tag in cls.PATTERN_CLASS_MAP: + pattern = cls.PATTERN_CLASS_MAP[child_elem.tag].from_xml(elem=child_elem, account=account) + elif child_elem.tag in cls.BOUNDARY_CLASS_MAP: + boundary = cls.BOUNDARY_CLASS_MAP[child_elem.tag].from_xml(elem=child_elem, account=account) return cls(pattern=pattern, boundary=boundary) def __str__(self): - return 'Pattern: %s, Boundary: %s' % (self.pattern, self.boundary) + return f'Pattern: {self.pattern}, Boundary: {self.boundary}'

                                  Ancestors

                                    @@ -1224,6 +1194,10 @@

                                    Subclasses

                                  Class variables

                                  +
                                  var BOUNDARY_CLASS_MAP
                                  +
                                  +
                                  +
                                  var ELEMENT_NAME
                                  @@ -1232,7 +1206,7 @@

                                  Class variables

                                  -
                                  var PATTERN_CLASSES
                                  +
                                  var PATTERN_CLASS_MAP
                                  @@ -1250,22 +1224,12 @@

                                  Static methods

                                  @classmethod
                                   def from_xml(cls, elem, account):
                                  -    for pattern_cls in cls.PATTERN_CLASSES:
                                  -        pattern_elem = elem.find(pattern_cls.response_tag())
                                  -        if pattern_elem is None:
                                  -            continue
                                  -        pattern = pattern_cls.from_xml(elem=pattern_elem, account=account)
                                  -        break
                                  -    else:
                                  -        pattern = None
                                  -    for boundary_cls in BOUNDARY_CLASSES:
                                  -        boundary_elem = elem.find(boundary_cls.response_tag())
                                  -        if boundary_elem is None:
                                  -            continue
                                  -        boundary = boundary_cls.from_xml(elem=boundary_elem, account=account)
                                  -        break
                                  -    else:
                                  -        boundary = None
                                  +    pattern, boundary = None, None
                                  +    for child_elem in elem:
                                  +        if child_elem.tag in cls.PATTERN_CLASS_MAP:
                                  +            pattern = cls.PATTERN_CLASS_MAP[child_elem.tag].from_xml(elem=child_elem, account=account)
                                  +        elif child_elem.tag in cls.BOUNDARY_CLASS_MAP:
                                  +            boundary = cls.BOUNDARY_CLASS_MAP[child_elem.tag].from_xml(elem=child_elem, account=account)
                                       return cls(pattern=pattern, boundary=boundary)
                                  @@ -1350,20 +1314,17 @@

                                  Inherited members

                                  # Interval, in months, in range 1 -> 99 interval = IntegerField(field_uri='Interval', min=1, max=99, is_required=True) - # The weekday of the occurrence, as a valid ISO 8601 weekday number in range 1 -> 7 (1 being Monday). - # Alternatively, the weekday can be one of the DAY (or 8), WEEK_DAY (or 9) or WEEKEND_DAY (or 10) consts which - # is interpreted as the first day, weekday, or weekend day in the month, respectively. + # Valid ISO 8601 weekday, as a number in range 1 -> 7 (1 being Monday). The value can also be one of the DAY + # (or 8), WEEK_DAY (or 9) or WEEKEND_DAY (or 10) consts which is interpreted as the first day, weekday, or weekend + # day of the month. Despite the field name in EWS, this is not a list. weekday = EnumField(field_uri='DaysOfWeek', enum=WEEKDAYS, is_required=True) # Week number of the month, in range 1 -> 5. If 5 is specified, this assumes the last week of the month for # months that have only 4 weeks. week_number = EnumField(field_uri='DayOfWeekIndex', enum=WEEK_NUMBERS, is_required=True) def __str__(self): - return 'Occurs on weekday %s in the %s week of every %s month(s)' % ( - _weekday_to_str(self.weekday), - _week_number_to_str(self.week_number), - self.interval - ) + return f'Occurs on weekday {_weekday_to_str(self.weekday)} in the {_week_number_to_str(self.week_number)} ' \ + f'week of every {self.interval} month(s)'

                                  Ancestors

                                    @@ -1426,9 +1387,9 @@

                                    Inherited members

                                    ELEMENT_NAME = 'RelativeYearlyRecurrence' - # The weekday of the occurrence, as a valid ISO 8601 weekday number in range 1 -> 7 (1 being Monday). - # Alternatively, the weekday can be one of the DAY (or 8), WEEK_DAY (or 9) or WEEKEND_DAY (or 10) consts which - # is interpreted as the first day, weekday, or weekend day in the month, respectively. + # Valid ISO 8601 weekday, as a number in range 1 -> 7 (1 being Monday). The value can also be one of the DAY + # (or 8), WEEK_DAY (or 9) or WEEKEND_DAY (or 10) consts which is interpreted as the first day, weekday, or weekend + # day of the year. Despite the field name in EWS, this is not a list. weekday = EnumField(field_uri='DaysOfWeek', enum=WEEKDAYS, is_required=True) # Week number of the month, in range 1 -> 5. If 5 is specified, this assumes the last week of the month for # months that have only 4 weeks @@ -1437,11 +1398,8 @@

                                    Inherited members

                                    month = EnumField(field_uri='Month', enum=MONTHS, is_required=True) def __str__(self): - return 'Occurs on weekday %s in the %s week of %s' % ( - _weekday_to_str(self.weekday), - _week_number_to_str(self.week_number), - _month_to_str(self.month) - ) + return f'Occurs on weekday {_weekday_to_str(self.weekday)} in the {_week_number_to_str(self.week_number)} ' \ + f'week of {_month_to_str(self.month)}'

                                    Ancestors

                                      @@ -1502,7 +1460,7 @@

                                      Inherited members

                                      https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recurrence-taskrecurrencetype """ - PATTERN_CLASSES = PATTERN_CLASSES + REGENERATION_CLASSES + PATTERN_CLASS_MAP = {cls.response_tag(): cls for cls in PATTERN_CLASSES + REGENERATION_CLASSES}

                                      Ancestors

                                        @@ -1511,7 +1469,7 @@

                                        Ancestors

                                      Class variables

                                      -
                                      var PATTERN_CLASSES
                                      +
                                      var PATTERN_CLASS_MAP
                                      @@ -1546,20 +1504,14 @@

                                      Inherited members

                                      # Interval, in weeks, in range 1 -> 99 interval = IntegerField(field_uri='Interval', min=1, max=99, is_required=True) # List of valid ISO 8601 weekdays, as list of numbers in range 1 -> 7 (1 being Monday) - weekdays = EnumListField(field_uri='DaysOfWeek', enum=WEEKDAYS, is_required=True) + weekdays = WeekdaysField(field_uri='DaysOfWeek', enum=WEEKDAY_NAMES, is_required=True) # The first day of the week. Defaults to Monday - first_day_of_week = EnumField(field_uri='FirstDayOfWeek', enum=WEEKDAYS, default=1, is_required=True) + first_day_of_week = EnumField(field_uri='FirstDayOfWeek', enum=WEEKDAY_NAMES, default=1, is_required=True) def __str__(self): - if isinstance(self.weekdays, str): - weekdays = [self.weekdays] - elif isinstance(self.weekdays, int): - weekdays = [_weekday_to_str(self.weekdays)] - else: - weekdays = [_weekday_to_str(i) for i in self.weekdays] - return 'Occurs on weekdays %s of every %s week(s) where the first day of the week is %s' % ( - ', '.join(weekdays), self.interval, _weekday_to_str(self.first_day_of_week) - ) + weekdays = [_weekday_to_str(i) for i in self.get_field_by_fieldname('weekdays').clean(self.weekdays)] + return f'Occurs on weekdays {", ".join(weekdays)} of every {self.interval} week(s) where the first day of ' \ + f'the week is {_weekday_to_str(self.first_day_of_week)}'

                                      Ancestors

                                        @@ -1623,7 +1575,7 @@

                                        Inherited members

                                        interval = IntegerField(field_uri='Interval', min=1, is_required=True) def __str__(self): - return 'Regenerates every %s week(s)' % self.interval + return f'Regenerates every {self.interval} week(s)'

                                        Ancestors

                                          @@ -1680,7 +1632,7 @@

                                          Inherited members

                                          interval = IntegerField(field_uri='Interval', min=1, is_required=True) def __str__(self): - return 'Regenerates every %s year(s)' % self.interval + return f'Regenerates every {self.interval} year(s)'

                                          Ancestors

                                            @@ -1842,9 +1794,10 @@

                                            Recurrence

                                              +
                                            • BOUNDARY_CLASS_MAP
                                            • ELEMENT_NAME
                                            • FIELDS
                                            • -
                                            • PATTERN_CLASSES
                                            • +
                                            • PATTERN_CLASS_MAP
                                            • boundary
                                            • from_xml
                                            • pattern
                                            • @@ -1876,7 +1829,7 @@

                                              TaskRecurrence

                                            • diff --git a/docs/exchangelib/restriction.html b/docs/exchangelib/restriction.html index ed06a805..1816c016 100644 --- a/docs/exchangelib/restriction.html +++ b/docs/exchangelib/restriction.html @@ -27,9 +27,9 @@

                                              Module exchangelib.restriction

                                              Expand source code
                                              import logging
                                              -from collections import OrderedDict
                                               from copy import copy
                                               
                                              +from .errors import InvalidEnumValue
                                               from .fields import InvalidField, FieldPath, DateTimeBackedDateField
                                               from .util import create_element, xml_to_str, value_to_xml_text, is_iterable
                                               from .version import EXCHANGE_2010
                                              @@ -38,7 +38,7 @@ 

                                              Module exchangelib.restriction

                                              class Q: - """A class with an API similar to Django Q objects. Used to implemnt advanced filtering logic.""" + """A class with an API similar to Django Q objects. Used to implement advanced filtering logic.""" # Connection types AND = 'AND' @@ -104,7 +104,7 @@

                                              Module exchangelib.restriction

                                              # Parse args which must now be Q objects for q in args: if not isinstance(q, self.__class__): - raise ValueError("Non-keyword arg %r must be a Q instance" % q) + raise TypeError(f"Non-keyword arg {q!r} must be of type {Q}") self.children.extend(args) # Parse keyword args and extract the filter @@ -137,10 +137,10 @@

                                              Module exchangelib.restriction

                                              # EWS doesn't have a 'range' operator. Emulate 'foo__range=(1, 2)' as 'foo__gte=1 and foo__lte=2' # (both values inclusive). if len(value) != 2: - raise ValueError("Value of lookup '%s' must have exactly 2 elements" % key) + raise ValueError(f"Value of lookup {key!r} must have exactly 2 elements") return ( - self.__class__(**{'%s__gte' % field_path: value[0]}), - self.__class__(**{'%s__lte' % field_path: value[1]}), + self.__class__(**{f'{field_path}__gte': value[0]}), + self.__class__(**{f'{field_path}__lte': value[1]}), ) # Filtering on list types is a bit quirky. The only lookup type I have found to work is: @@ -163,7 +163,7 @@

                                              Module exchangelib.restriction

                                              # EWS doesn't have an '__in' operator. Allow '__in' lookups on list and non-list field types, # specifying a list value. We'll emulate it as a set of OR'ed exact matches. if not is_iterable(value, generators_allowed=True): - raise ValueError("Value for lookup %r must be a list" % key) + raise TypeError(f"Value for lookup {key!r} must be of type {list}") children = tuple(self.__class__(**{field_path: v}) for v in value) if not children: # This is an '__in' operator with an empty list as the value. We interpret it to mean "is foo @@ -183,7 +183,7 @@

                                              Module exchangelib.restriction

                                              try: op = self._lookup_to_op(lookup) except KeyError: - raise ValueError("Lookup '%s' is not supported (called as '%s=%r')" % (lookup, key, value)) + raise ValueError(f"Lookup {lookup!r} is not supported (called as '{key}={value!r}')") else: field_path, op = key, self.EQ @@ -286,7 +286,7 @@

                                              Module exchangelib.restriction

                                              return create_element(xml_tag_map[op]) valid_ops = cls.EXACT, cls.IEXACT, cls.CONTAINS, cls.ICONTAINS, cls.STARTSWITH, cls.ISTARTSWITH if op not in valid_ops: - raise ValueError("'op' %s must be one of %s" % (op, valid_ops)) + raise InvalidEnumValue('op', op, valid_ops) # For description of Contains attribute values, see # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/contains @@ -316,17 +316,14 @@

                                              Module exchangelib.restriction

                                              elif op in (cls.STARTSWITH, cls.ISTARTSWITH): match_mode = 'Prefixed' else: - raise ValueError('Unsupported op: %s' % op) + raise ValueError(f'Unsupported op: {op}') if op in (cls.IEXACT, cls.ICONTAINS, cls.ISTARTSWITH): compare_mode = 'IgnoreCase' else: compare_mode = 'Exact' return create_element( 't:Contains', - attrs=OrderedDict([ - ('ContainmentMode', match_mode), - ('ContainmentComparison', compare_mode), - ]) + attrs=dict(ContainmentMode=match_mode, ContainmentComparison=compare_mode) ) def is_leaf(self): @@ -348,18 +345,18 @@

                                              Module exchangelib.restriction

                                              if self.query_string: return self.query_string if self.is_leaf(): - expr = '%s %s %r' % (self.field_path, self.op, self.value) + expr = f'{self.field_path} {self.op} {self.value!r}' else: # Sort children by field name so we get stable output (for easier testing). Children should never be empty. - expr = (' %s ' % (self.AND if self.conn_type == self.NOT else self.conn_type)).join( - (c.expr() if c.is_leaf() or c.conn_type == self.NOT else '(%s)' % c.expr()) + expr = f' {self.AND if self.conn_type == self.NOT else self.conn_type} '.join( + (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f'({c.expr()})') for c in sorted(self.children, key=lambda i: i.field_path or '') ) if self.conn_type == self.NOT: # Add the NOT operator. Put children in parens if there is more than one child. if self.is_leaf() or len(self.children) == 1: - return self.conn_type + ' %s' % expr - return self.conn_type + ' (%s)' % expr + return self.conn_type + f' {expr}' + return self.conn_type + f' ({expr})' return expr def to_xml(self, folders, version, applies_to): @@ -390,25 +387,23 @@

                                              Module exchangelib.restriction

                                              raise ValueError('Query strings cannot be combined with other settings') return if self.conn_type not in self.CONN_TYPES: - raise ValueError("'conn_type' %s must be one of %s" % (self.conn_type, self.CONN_TYPES)) + raise InvalidEnumValue('conn_type', self.conn_type, self.CONN_TYPES) if not self.is_leaf(): for q in self.children: if q.query_string and len(self.children) > 1: - raise ValueError( - 'A query string cannot be combined with other restrictions' - ) + raise ValueError('A query string cannot be combined with other restrictions') return if not self.field_path: raise ValueError("'field_path' must be set") if self.op not in self.OP_TYPES: - raise ValueError("'op' %s must be one of %s" % (self.op, self.OP_TYPES)) + raise InvalidEnumValue('op', self.op, self.OP_TYPES) if self.op == self.EXISTS and self.value is not True: raise ValueError("'value' must be True when operator is EXISTS") if self.value is None: - raise ValueError('Value for filter on field path "%s" cannot be None' % self.field_path) + raise ValueError(f'Value for filter on field path {self.field_path!r} cannot be None') if is_iterable(self.value, generators_allowed=True): raise ValueError( - 'Value %r for filter on field path "%s" must be a single value' % (self.value, self.field_path) + f'Value {self.value!r} for filter on field path {self.field_path!r} must be a single value' ) def _validate_field_path(self, field_path, folder, applies_to, version): @@ -419,11 +414,11 @@

                                              Module exchangelib.restriction

                                              else: folder.validate_item_field(field=field_path.field, version=version) if not field_path.field.is_searchable: - raise ValueError("EWS does not support filtering on field '%s'" % field_path.field.name) + raise ValueError(f"EWS does not support filtering on field {field_path.field.name!r}") if field_path.subfield and not field_path.subfield.is_searchable: - raise ValueError("EWS does not support filtering on subfield '%s'" % field_path.subfield.name) + raise ValueError(f"EWS does not support filtering on subfield {field_path.subfield.name!r}") if issubclass(field_path.field.value_cls, MultiFieldIndexedElement) and not field_path.subfield: - raise ValueError("Field path '%s' must contain a subfield" % self.field_path) + raise ValueError(f"Field path {self.field_path!r} must contain a subfield") def _get_field_path(self, folders, applies_to, version): # Convert the string field path to a real FieldPath object. The path is validated using the given folders. @@ -440,7 +435,7 @@

                                              Module exchangelib.restriction

                                              self._validate_field_path(field_path=field_path, folder=folder, applies_to=applies_to, version=version) break else: - raise InvalidField("Unknown field path %r on folders %s" % (self.field_path, folders)) + raise InvalidField(f"Unknown field path {self.field_path!r} on folders {folders}") return field_path def _get_clean_value(self, field_path, version): @@ -476,10 +471,8 @@

                                              Module exchangelib.restriction

                                              # We need to convert to datetime clean_value = field_path.field.date_to_datetime(clean_value) elem.append(field_path.to_xml()) - constant = create_element('t:Constant') if self.op != self.EXISTS: - # Use .set() to not fill up the create_element() cache with unique values - constant.set('Value', value_to_xml_text(clean_value)) + constant = create_element('t:Constant', attrs=dict(Value=value_to_xml_text(clean_value))) if self.op in self.CONTAINS_OPS: elem.append(constant) else: @@ -550,10 +543,10 @@

                                              Module exchangelib.restriction

                                              def __repr__(self): if self.is_leaf(): if self.query_string: - return self.__class__.__name__ + '(%r)' % self.query_string + return self.__class__.__name__ + f'({self.query_string!r})' if self.is_never(): - return self.__class__.__name__ + '(conn_type=%r)' % (self.conn_type) - return self.__class__.__name__ + '(%s %s %r)' % (self.field_path, self.op, self.value) + return self.__class__.__name__ + f'(conn_type={self.conn_type!r})' + return self.__class__.__name__ + f'({self.field_path} {self.op} {self.value!r})' sorted_children = tuple(sorted(self.children, key=lambda i: i.field_path or '')) if self.conn_type == self.NOT or len(self.children) > 1: return self.__class__.__name__ + repr((self.conn_type,) + sorted_children) @@ -569,16 +562,13 @@

                                              Module exchangelib.restriction

                                              RESTRICTION_TYPES = (FOLDERS, ITEMS) def __init__(self, q, folders, applies_to): - if not isinstance(q, Q): - raise ValueError("'q' value %r must be a Q instance" % q) + """ + :param q: A Q instance + :param folders: A list of BaseFolder instances + :param applies_to: A member of the RESTRICTION_TYPES eum + """ if q.is_empty(): raise ValueError("Q object must not be empty") - from .folders import BaseFolder - for folder in folders: - if not isinstance(folder, BaseFolder): - raise ValueError("'folder' value %r must be a Folder instance" % folder) - if applies_to not in self.RESTRICTION_TYPES: - raise ValueError("'applies_to' must be one of %s" % (self.RESTRICTION_TYPES,)) self.q = q self.folders = folders self.applies_to = applies_to @@ -605,13 +595,13 @@

                                              Classes

                                              (*args, **kwargs)
      • -

        A class with an API similar to Django Q objects. Used to implemnt advanced filtering logic.

        +

        A class with an API similar to Django Q objects. Used to implement advanced filtering logic.

        Expand source code
        class Q:
        -    """A class with an API similar to Django Q objects. Used to implemnt advanced filtering logic."""
        +    """A class with an API similar to Django Q objects. Used to implement advanced filtering logic."""
         
             # Connection types
             AND = 'AND'
        @@ -677,7 +667,7 @@ 

        Classes

        # Parse args which must now be Q objects for q in args: if not isinstance(q, self.__class__): - raise ValueError("Non-keyword arg %r must be a Q instance" % q) + raise TypeError(f"Non-keyword arg {q!r} must be of type {Q}") self.children.extend(args) # Parse keyword args and extract the filter @@ -710,10 +700,10 @@

        Classes

        # EWS doesn't have a 'range' operator. Emulate 'foo__range=(1, 2)' as 'foo__gte=1 and foo__lte=2' # (both values inclusive). if len(value) != 2: - raise ValueError("Value of lookup '%s' must have exactly 2 elements" % key) + raise ValueError(f"Value of lookup {key!r} must have exactly 2 elements") return ( - self.__class__(**{'%s__gte' % field_path: value[0]}), - self.__class__(**{'%s__lte' % field_path: value[1]}), + self.__class__(**{f'{field_path}__gte': value[0]}), + self.__class__(**{f'{field_path}__lte': value[1]}), ) # Filtering on list types is a bit quirky. The only lookup type I have found to work is: @@ -736,7 +726,7 @@

        Classes

        # EWS doesn't have an '__in' operator. Allow '__in' lookups on list and non-list field types, # specifying a list value. We'll emulate it as a set of OR'ed exact matches. if not is_iterable(value, generators_allowed=True): - raise ValueError("Value for lookup %r must be a list" % key) + raise TypeError(f"Value for lookup {key!r} must be of type {list}") children = tuple(self.__class__(**{field_path: v}) for v in value) if not children: # This is an '__in' operator with an empty list as the value. We interpret it to mean "is foo @@ -756,7 +746,7 @@

        Classes

        try: op = self._lookup_to_op(lookup) except KeyError: - raise ValueError("Lookup '%s' is not supported (called as '%s=%r')" % (lookup, key, value)) + raise ValueError(f"Lookup {lookup!r} is not supported (called as '{key}={value!r}')") else: field_path, op = key, self.EQ @@ -859,7 +849,7 @@

        Classes

        return create_element(xml_tag_map[op]) valid_ops = cls.EXACT, cls.IEXACT, cls.CONTAINS, cls.ICONTAINS, cls.STARTSWITH, cls.ISTARTSWITH if op not in valid_ops: - raise ValueError("'op' %s must be one of %s" % (op, valid_ops)) + raise InvalidEnumValue('op', op, valid_ops) # For description of Contains attribute values, see # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/contains @@ -889,17 +879,14 @@

        Classes

        elif op in (cls.STARTSWITH, cls.ISTARTSWITH): match_mode = 'Prefixed' else: - raise ValueError('Unsupported op: %s' % op) + raise ValueError(f'Unsupported op: {op}') if op in (cls.IEXACT, cls.ICONTAINS, cls.ISTARTSWITH): compare_mode = 'IgnoreCase' else: compare_mode = 'Exact' return create_element( 't:Contains', - attrs=OrderedDict([ - ('ContainmentMode', match_mode), - ('ContainmentComparison', compare_mode), - ]) + attrs=dict(ContainmentMode=match_mode, ContainmentComparison=compare_mode) ) def is_leaf(self): @@ -921,18 +908,18 @@

        Classes

        if self.query_string: return self.query_string if self.is_leaf(): - expr = '%s %s %r' % (self.field_path, self.op, self.value) + expr = f'{self.field_path} {self.op} {self.value!r}' else: # Sort children by field name so we get stable output (for easier testing). Children should never be empty. - expr = (' %s ' % (self.AND if self.conn_type == self.NOT else self.conn_type)).join( - (c.expr() if c.is_leaf() or c.conn_type == self.NOT else '(%s)' % c.expr()) + expr = f' {self.AND if self.conn_type == self.NOT else self.conn_type} '.join( + (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f'({c.expr()})') for c in sorted(self.children, key=lambda i: i.field_path or '') ) if self.conn_type == self.NOT: # Add the NOT operator. Put children in parens if there is more than one child. if self.is_leaf() or len(self.children) == 1: - return self.conn_type + ' %s' % expr - return self.conn_type + ' (%s)' % expr + return self.conn_type + f' {expr}' + return self.conn_type + f' ({expr})' return expr def to_xml(self, folders, version, applies_to): @@ -963,25 +950,23 @@

        Classes

        raise ValueError('Query strings cannot be combined with other settings') return if self.conn_type not in self.CONN_TYPES: - raise ValueError("'conn_type' %s must be one of %s" % (self.conn_type, self.CONN_TYPES)) + raise InvalidEnumValue('conn_type', self.conn_type, self.CONN_TYPES) if not self.is_leaf(): for q in self.children: if q.query_string and len(self.children) > 1: - raise ValueError( - 'A query string cannot be combined with other restrictions' - ) + raise ValueError('A query string cannot be combined with other restrictions') return if not self.field_path: raise ValueError("'field_path' must be set") if self.op not in self.OP_TYPES: - raise ValueError("'op' %s must be one of %s" % (self.op, self.OP_TYPES)) + raise InvalidEnumValue('op', self.op, self.OP_TYPES) if self.op == self.EXISTS and self.value is not True: raise ValueError("'value' must be True when operator is EXISTS") if self.value is None: - raise ValueError('Value for filter on field path "%s" cannot be None' % self.field_path) + raise ValueError(f'Value for filter on field path {self.field_path!r} cannot be None') if is_iterable(self.value, generators_allowed=True): raise ValueError( - 'Value %r for filter on field path "%s" must be a single value' % (self.value, self.field_path) + f'Value {self.value!r} for filter on field path {self.field_path!r} must be a single value' ) def _validate_field_path(self, field_path, folder, applies_to, version): @@ -992,11 +977,11 @@

        Classes

        else: folder.validate_item_field(field=field_path.field, version=version) if not field_path.field.is_searchable: - raise ValueError("EWS does not support filtering on field '%s'" % field_path.field.name) + raise ValueError(f"EWS does not support filtering on field {field_path.field.name!r}") if field_path.subfield and not field_path.subfield.is_searchable: - raise ValueError("EWS does not support filtering on subfield '%s'" % field_path.subfield.name) + raise ValueError(f"EWS does not support filtering on subfield {field_path.subfield.name!r}") if issubclass(field_path.field.value_cls, MultiFieldIndexedElement) and not field_path.subfield: - raise ValueError("Field path '%s' must contain a subfield" % self.field_path) + raise ValueError(f"Field path {self.field_path!r} must contain a subfield") def _get_field_path(self, folders, applies_to, version): # Convert the string field path to a real FieldPath object. The path is validated using the given folders. @@ -1013,7 +998,7 @@

        Classes

        self._validate_field_path(field_path=field_path, folder=folder, applies_to=applies_to, version=version) break else: - raise InvalidField("Unknown field path %r on folders %s" % (self.field_path, folders)) + raise InvalidField(f"Unknown field path {self.field_path!r} on folders {folders}") return field_path def _get_clean_value(self, field_path, version): @@ -1049,10 +1034,8 @@

        Classes

        # We need to convert to datetime clean_value = field_path.field.date_to_datetime(clean_value) elem.append(field_path.to_xml()) - constant = create_element('t:Constant') if self.op != self.EXISTS: - # Use .set() to not fill up the create_element() cache with unique values - constant.set('Value', value_to_xml_text(clean_value)) + constant = create_element('t:Constant', attrs=dict(Value=value_to_xml_text(clean_value))) if self.op in self.CONTAINS_OPS: elem.append(constant) else: @@ -1123,10 +1106,10 @@

        Classes

        def __repr__(self): if self.is_leaf(): if self.query_string: - return self.__class__.__name__ + '(%r)' % self.query_string + return self.__class__.__name__ + f'({self.query_string!r})' if self.is_never(): - return self.__class__.__name__ + '(conn_type=%r)' % (self.conn_type) - return self.__class__.__name__ + '(%s %s %r)' % (self.field_path, self.op, self.value) + return self.__class__.__name__ + f'(conn_type={self.conn_type!r})' + return self.__class__.__name__ + f'({self.field_path} {self.op} {self.value!r})' sorted_children = tuple(sorted(self.children, key=lambda i: i.field_path or '')) if self.conn_type == self.NOT or len(self.children) > 1: return self.__class__.__name__ + repr((self.conn_type,) + sorted_children) @@ -1339,18 +1322,18 @@

        Methods

        if self.query_string: return self.query_string if self.is_leaf(): - expr = '%s %s %r' % (self.field_path, self.op, self.value) + expr = f'{self.field_path} {self.op} {self.value!r}' else: # Sort children by field name so we get stable output (for easier testing). Children should never be empty. - expr = (' %s ' % (self.AND if self.conn_type == self.NOT else self.conn_type)).join( - (c.expr() if c.is_leaf() or c.conn_type == self.NOT else '(%s)' % c.expr()) + expr = f' {self.AND if self.conn_type == self.NOT else self.conn_type} '.join( + (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f'({c.expr()})') for c in sorted(self.children, key=lambda i: i.field_path or '') ) if self.conn_type == self.NOT: # Add the NOT operator. Put children in parens if there is more than one child. if self.is_leaf() or len(self.children) == 1: - return self.conn_type + ' %s' % expr - return self.conn_type + ' (%s)' % expr + return self.conn_type + f' {expr}' + return self.conn_type + f' ({expr})' return expr
        @@ -1468,10 +1451,8 @@

        Methods

        # We need to convert to datetime clean_value = field_path.field.date_to_datetime(clean_value) elem.append(field_path.to_xml()) - constant = create_element('t:Constant') if self.op != self.EXISTS: - # Use .set() to not fill up the create_element() cache with unique values - constant.set('Value', value_to_xml_text(clean_value)) + constant = create_element('t:Constant', attrs=dict(Value=value_to_xml_text(clean_value))) if self.op in self.CONTAINS_OPS: elem.append(constant) else: @@ -1504,7 +1485,10 @@

        Methods

        (q, folders, applies_to)
    -

    Implement an EWS Restriction type.

    +

    Implement an EWS Restriction type.

    +

    :param q: A Q instance +:param folders: A list of BaseFolder instances +:param applies_to: A member of the RESTRICTION_TYPES eum

    Expand source code @@ -1518,16 +1502,13 @@

    Methods

    RESTRICTION_TYPES = (FOLDERS, ITEMS) def __init__(self, q, folders, applies_to): - if not isinstance(q, Q): - raise ValueError("'q' value %r must be a Q instance" % q) + """ + :param q: A Q instance + :param folders: A list of BaseFolder instances + :param applies_to: A member of the RESTRICTION_TYPES eum + """ if q.is_empty(): raise ValueError("Q object must not be empty") - from .folders import BaseFolder - for folder in folders: - if not isinstance(folder, BaseFolder): - raise ValueError("'folder' value %r must be a Folder instance" % folder) - if applies_to not in self.RESTRICTION_TYPES: - raise ValueError("'applies_to' must be one of %s" % (self.RESTRICTION_TYPES,)) self.q = q self.folders = folders self.applies_to = applies_to diff --git a/docs/exchangelib/services/archive_item.html b/docs/exchangelib/services/archive_item.html index 2ae2bfbf..37ef4ffa 100644 --- a/docs/exchangelib/services/archive_item.html +++ b/docs/exchangelib/services/archive_item.html @@ -26,7 +26,8 @@

    Module exchangelib.services.archive_item

    Expand source code -
    from .common import EWSAccountService, create_folder_ids_element, create_item_ids_element
    +
    from .common import EWSAccountService, folder_ids_element, item_ids_element
    +from ..items import Item
     from ..util import create_element, MNS
     from ..version import EXCHANGE_2013
     
    @@ -35,7 +36,7 @@ 

    Module exchangelib.services.archive_item

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/archiveitem-operation""" SERVICE_NAME = 'ArchiveItem' - element_container_name = '{%s}Items' % MNS + element_container_name = f'{{{MNS}}}Items' supported_from = EXCHANGE_2013 def call(self, items, to_folder): @@ -48,22 +49,16 @@

    Module exchangelib.services.archive_item

    """ return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, to_folder=to_folder)) - def _elems_to_objs(self, elems): - from ..items import Item - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Item.id_from_xml(elem) + def _elem_to_obj(self, elem): + return Item.id_from_xml(elem) def get_payload(self, items, to_folder): - archiveitem = create_element('m:%s' % self.SERVICE_NAME) - folder_id = create_folder_ids_element(tag='m:ArchiveSourceFolderId', folders=[to_folder], - version=self.account.version) - item_ids = create_item_ids_element(items=items, version=self.account.version) - archiveitem.append(folder_id) - archiveitem.append(item_ids) - return archiveitem
    + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append( + folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ArchiveSourceFolderId') + ) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload
    @@ -89,7 +84,7 @@

    Classes

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/archiveitem-operation""" SERVICE_NAME = 'ArchiveItem' - element_container_name = '{%s}Items' % MNS + element_container_name = f'{{{MNS}}}Items' supported_from = EXCHANGE_2013 def call(self, items, to_folder): @@ -102,22 +97,16 @@

    Classes

    """ return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, to_folder=to_folder)) - def _elems_to_objs(self, elems): - from ..items import Item - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Item.id_from_xml(elem) + def _elem_to_obj(self, elem): + return Item.id_from_xml(elem) def get_payload(self, items, to_folder): - archiveitem = create_element('m:%s' % self.SERVICE_NAME) - folder_id = create_folder_ids_element(tag='m:ArchiveSourceFolderId', folders=[to_folder], - version=self.account.version) - item_ids = create_item_ids_element(items=items, version=self.account.version) - archiveitem.append(folder_id) - archiveitem.append(item_ids) - return archiveitem + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append( + folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ArchiveSourceFolderId') + ) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload

    Ancestors

      @@ -174,13 +163,12 @@

      Methods

      Expand source code
      def get_payload(self, items, to_folder):
      -    archiveitem = create_element('m:%s' % self.SERVICE_NAME)
      -    folder_id = create_folder_ids_element(tag='m:ArchiveSourceFolderId', folders=[to_folder],
      -                                          version=self.account.version)
      -    item_ids = create_item_ids_element(items=items, version=self.account.version)
      -    archiveitem.append(folder_id)
      -    archiveitem.append(item_ids)
      -    return archiveitem
      + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append( + folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ArchiveSourceFolderId') + ) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload
    diff --git a/docs/exchangelib/services/common.html b/docs/exchangelib/services/common.html index 659ca5cf..c7a83634 100644 --- a/docs/exchangelib/services/common.html +++ b/docs/exchangelib/services/common.html @@ -32,6 +32,7 @@

    Module exchangelib.services.common

    from itertools import chain from .. import errors +from ..attachments import AttachmentId from ..credentials import IMPERSONATION, OAuth2Credentials from ..errors import EWSWarning, TransportError, SOAPError, ErrorTimeoutExpired, ErrorBatchProcessingStopped, \ ErrorQuotaExceeded, ErrorCannotDeleteObject, ErrorCreateItemAccessDenied, ErrorFolderNotFound, \ @@ -46,8 +47,11 @@

    Module exchangelib.services.common

    SessionPoolMinSizeReached, ErrorIncorrectSchemaVersion, ErrorInvalidRequest, ErrorCorruptData, \ ErrorCannotEmptyFolder, ErrorDeleteDistinguishedFolder, ErrorInvalidSubscription, ErrorInvalidWatermark, \ ErrorInvalidSyncStateData, ErrorNameResolutionNoResults, ErrorNameResolutionMultipleResults, \ - ErrorConnectionFailedTransientError -from ..properties import FieldURI, IndexedFieldURI, ExtendedFieldURI, ExceptionFieldURI, ItemId + ErrorConnectionFailedTransientError, ErrorDelegateNoUser, ErrorNotDelegate, InvalidTypeError +from ..folders import BaseFolder, Folder, RootOfHierarchy +from ..items import BaseItem +from ..properties import FieldURI, IndexedFieldURI, ExtendedFieldURI, ExceptionFieldURI, ItemId, FolderId, \ + DistinguishedFolderId, BaseItemId from ..transport import wrap from ..util import chunkify, create_element, add_xml_child, get_xml_attr, to_xml, post_ratelimited, \ xml_to_str, set_xml_value, SOAPNS, TNS, MNS, ENS, ParseError, DummyResponse @@ -55,7 +59,8 @@

    Module exchangelib.services.common

    log = logging.getLogger(__name__) -CHUNK_SIZE = 100 # A default chunk size for all services +PAGE_SIZE = 100 # A default page size for all paging services. This is the number of items we request per page +CHUNK_SIZE = 100 # A default chunk size for all services. This is the number of items we send in a single request KNOWN_EXCEPTIONS = ( ErrorAccessDenied, @@ -66,6 +71,7 @@

    Module exchangelib.services.common

    ErrorConnectionFailed, ErrorConnectionFailedTransientError, ErrorCreateItemAccessDenied, + ErrorDelegateNoUser, ErrorDeleteDistinguishedFolder, ErrorExceededConnectionCount, ErrorFolderNotFound, @@ -86,6 +92,7 @@

    Module exchangelib.services.common

    ErrorNonExistentMailbox, ErrorNoPublicFolderReplicaAvailable, ErrorNoRespondingCASInDestinationSite, + ErrorNotDelegate, ErrorQuotaExceeded, ErrorTimeoutExpired, RateLimitError, @@ -99,7 +106,7 @@

    Module exchangelib.services.common

    SERVICE_NAME = None # The name of the SOAP service element_container_name = None # The name of the XML element wrapping the collection of returned items paging_container_name = None # The name of the element that contains paging information and the paged results - returns_elements = True # If False, the service does not return response elements, just the RsponseCode status + returns_elements = True # If False, the service does not return response elements, just the ResponseCode status # Return exception instance instead of raising exceptions for the following errors when contained in an element ERRORS_TO_CATCH_IN_RESPONSE = ( EWSWarning, ErrorCannotDeleteObject, ErrorInvalidChangeKey, ErrorItemNotFound, ErrorItemSave, @@ -118,14 +125,15 @@

    Module exchangelib.services.common

    supports_paging = False def __init__(self, protocol, chunk_size=None, timeout=None): - self.chunk_size = chunk_size or CHUNK_SIZE # The number of items to send in a single request + self.chunk_size = chunk_size or CHUNK_SIZE if not isinstance(self.chunk_size, int): - raise ValueError("'chunk_size' %r must be an integer" % chunk_size) + raise InvalidTypeError('chunk_size', chunk_size, int) if self.chunk_size < 1: - raise ValueError("'chunk_size' must be a positive number") + raise ValueError(f"'chunk_size' {self.chunk_size} must be a positive number") if self.supported_from and protocol.version.build < self.supported_from: raise NotImplementedError( - '%r is only supported on %r and later' % (self.SERVICE_NAME, self.supported_from.fullname()) + f'{self.SERVICE_NAME!r} is only supported on {self.supported_from.fullname()!r} and later. ' + f'Your current version is {protocol.version.build.fullname()!r}.' ) self.protocol = protocol # Allow a service to override the default protocol timeout. Useful for streaming services @@ -171,20 +179,30 @@

    Module exchangelib.services.common

    return None if expect_result is False: if res: - raise ValueError('Expected result length 0, but got %r' % res) + raise ValueError(f'Expected result length 0, but got {res}') return None if len(res) != 1: - raise ValueError('Expected result length 1, but got %r' % res) + raise ValueError(f'Expected result length 1, but got {res}') return res[0] def parse(self, xml): """Used mostly for testing, when we want to parse static XML data.""" - resp = DummyResponse(url=None, headers=None, request_headers=None, content=xml) + resp = DummyResponse(content=xml, streaming=self.streaming) _, body = self._get_soap_parts(response=resp) return self._elems_to_objs(self._get_elements_in_response(response=self._get_soap_messages(body=body))) def _elems_to_objs(self, elems): """Takes a generator of XML elements and exceptions. Returns the equivalent Python objects (or exceptions).""" + for elem in elems: + # Allow None here. Some services don't return an ID if the target folder is outside the mailbox. + if isinstance(elem, (Exception, type(None))): + yield elem + continue + yield self._elem_to_obj(elem) + + def _elem_to_obj(self, elem): + if not self.returns_elements: + raise RuntimeError("Incorrect call to method when 'returns_elements' is False") raise NotImplementedError() @property @@ -229,7 +247,9 @@

    Module exchangelib.services.common

    :param kwargs: Same as arguments for .call(), except for the 'items' argument :return: Same as ._get_elements() """ - for i, chunk in enumerate(chunkify(items, self.chunk_size), start=1): + # If the input for a service is a QuerySet, it can be difficult to remove exceptions before now + filtered_items = filter(lambda i: not isinstance(i, Exception), items) + for i, chunk in enumerate(chunkify(filtered_items, self.chunk_size), start=1): log.debug('Processing chunk %s containing %s items', i, len(chunk)) yield from self._get_elements(payload=payload_func(chunk, **kwargs)) @@ -272,9 +292,9 @@

    Module exchangelib.services.common

    raise e # Re-raise as an ErrorServerBusy with a default delay of 5 minutes - raise ErrorServerBusy('Reraised from %s(%s)' % (e.__class__.__name__, e)) + raise ErrorServerBusy(f'Reraised from {e.__class__.__name__}({e})') except Exception: - # This may run from a thread pool, which obfuscates the stack trace. Print trace immediately. + # This may run in a thread, which obfuscates the stack trace. Print trace immediately. account = self.account if isinstance(self, EWSAccountService) else None log.warning('Account %s: Exception in _get_elements: %s', account, traceback.format_exc(20)) raise @@ -370,9 +390,7 @@

    Module exchangelib.services.common

    # In streaming mode, we may not have accessed the raw stream yet. Caller must handle this. r.close() # Release memory - raise self.NO_VALID_SERVER_VERSIONS( - 'Tried versions %s but all were invalid' % ', '.join(self._api_versions_to_try) - ) + raise self.NO_VALID_SERVER_VERSIONS(f'Tried versions {self._api_versions_to_try} but all were invalid') def _handle_backoff(self, e): """Take a request from the server to back off and checks the retry policy for what to do. Re-raise the @@ -412,17 +430,17 @@

    Module exchangelib.services.common

    @classmethod def _response_tag(cls): """Return the name of the element containing the service response.""" - return '{%s}%sResponse' % (MNS, cls.SERVICE_NAME) + return f'{{{MNS}}}{cls.SERVICE_NAME}Response' @staticmethod def _response_messages_tag(): """Return the name of the element containing service response messages.""" - return '{%s}ResponseMessages' % MNS + return f'{{{MNS}}}ResponseMessages' @classmethod def _response_message_tag(cls): """Return the name of the element of a single response message.""" - return '{%s}%sResponseMessage' % (MNS, cls.SERVICE_NAME) + return f'{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage' @classmethod def _get_soap_parts(cls, response, **parse_opts): @@ -430,12 +448,12 @@

    Module exchangelib.services.common

    try: root = to_xml(response.iter_content()) except ParseError as e: - raise SOAPError('Bad SOAP response: %s' % e) - header = root.find('{%s}Header' % SOAPNS) + raise SOAPError(f'Bad SOAP response: {e}') + header = root.find(f'{{{SOAPNS}}}Header') if header is None: # This is normal when the response contains SOAP-level errors log.debug('No header in XML response') - body = root.find('{%s}Body' % SOAPNS) + body = root.find(f'{{{SOAPNS}}}Body') if body is None: raise MalformedResponseError('No Body element in SOAP response') return header, body @@ -444,11 +462,9 @@

    Module exchangelib.services.common

    """Return the elements in the response containing the response messages. Raises any SOAP exceptions.""" response = body.find(self._response_tag()) if response is None: - fault = body.find('{%s}Fault' % SOAPNS) + fault = body.find(f'{{{SOAPNS}}}Fault') if fault is None: - raise SOAPError( - 'Unknown SOAP response (expected %s or Fault): %s' % (self._response_tag(), xml_to_str(body)) - ) + raise SOAPError(f'Unknown SOAP response (expected {self._response_tag()} or Fault): {xml_to_str(body)}') self._raise_soap_errors(fault=fault) # Will throw SOAPError or custom EWS error response_messages = response.find(self._response_messages_tag()) if response_messages is None: @@ -461,40 +477,43 @@

    Module exchangelib.services.common

    def _raise_soap_errors(cls, fault): """Parse error messages contained in SOAP headers and raise as exceptions defined in this package.""" # Fault: See http://www.w3.org/TR/2000/NOTE-SOAP-20000508/#_Toc478383507 - faultcode = get_xml_attr(fault, 'faultcode') - faultstring = get_xml_attr(fault, 'faultstring') - faultactor = get_xml_attr(fault, 'faultactor') + fault_code = get_xml_attr(fault, 'faultcode') + fault_string = get_xml_attr(fault, 'faultstring') + fault_actor = get_xml_attr(fault, 'faultactor') detail = fault.find('detail') if detail is not None: code, msg = None, '' - if detail.find('{%s}ResponseCode' % ENS) is not None: - code = get_xml_attr(detail, '{%s}ResponseCode' % ENS).strip() - if detail.find('{%s}Message' % ENS) is not None: - msg = get_xml_attr(detail, '{%s}Message' % ENS).strip() - msg_xml = detail.find('{%s}MessageXml' % TNS) # Crazy. Here, it's in the TNS namespace + if detail.find(f'{{{ENS}}}ResponseCode') is not None: + code = get_xml_attr(detail, f'{{{ENS}}}ResponseCode').strip() + if detail.find(f'{{{ENS}}}Message') is not None: + msg = get_xml_attr(detail, f'{{{ENS}}}Message').strip() + msg_xml = detail.find(f'{{{TNS}}}MessageXml') # Crazy. Here, it's in the TNS namespace if code == 'ErrorServerBusy': back_off = None try: - value = msg_xml.find('{%s}Value' % TNS) + value = msg_xml.find(f'{{{TNS}}}Value') if value.get('Name') == 'BackOffMilliseconds': back_off = int(value.text) / 1000.0 # Convert to seconds except (TypeError, AttributeError): pass raise ErrorServerBusy(msg, back_off=back_off) if code == 'ErrorSchemaValidation' and msg_xml is not None: - violation = get_xml_attr(msg_xml, '{%s}Violation' % TNS) - if violation is not None: - msg = '%s %s' % (msg, violation) + line_number = get_xml_attr(msg_xml, f'{{{TNS}}}LineNumber') + line_position = get_xml_attr(msg_xml, f'{{{TNS}}}LinePosition') + violation = get_xml_attr(msg_xml, f'{{{TNS}}}Violation') + if violation: + msg = f'{msg} {violation}' + if line_number or line_position: + msg = f'{msg} (line: {line_number} position: {line_position})' try: raise vars(errors)[code](msg) except KeyError: - detail = '%s: code: %s msg: %s (%s)' % (cls.SERVICE_NAME, code, msg, xml_to_str(detail)) + detail = f'{cls.SERVICE_NAME}: code: {code} msg: {msg} ({xml_to_str(detail)})' try: - raise vars(errors)[faultcode](faultstring) + raise vars(errors)[fault_code](fault_string) except KeyError: pass - raise SOAPError('SOAP error code: %s string: %s actor: %s detail: %s' % ( - faultcode, faultstring, faultactor, detail)) + raise SOAPError(f'SOAP error code: {fault_code} string: {fault_string} actor: {fault_actor} detail: {detail}') def _get_element_container(self, message, name=None): """Return the XML element in a response element that contains the elements we want the service to return. For @@ -524,19 +543,19 @@

    Module exchangelib.services.common

    response_class = message.get('ResponseClass') # ResponseCode, MessageText: See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/responsecode - response_code = get_xml_attr(message, '{%s}ResponseCode' % MNS) + response_code = get_xml_attr(message, f'{{{MNS}}}ResponseCode') if response_class == 'Success' and response_code == 'NoError': if not name: return message container = message.find(name) if container is None: - raise MalformedResponseError('No %s elements in ResponseMessage (%s)' % (name, xml_to_str(message))) + raise MalformedResponseError(f'No {name} elements in ResponseMessage ({xml_to_str(message)})') return container if response_code == 'NoError': return True # Raise any non-acceptable errors in the container, or return the container or the acceptable exception instance - msg_text = get_xml_attr(message, '{%s}MessageText' % MNS) - msg_xml = message.find('{%s}MessageXml' % MNS) + msg_text = get_xml_attr(message, f'{{{MNS}}}MessageText') + msg_xml = message.find(f'{{{MNS}}}MessageXml') if response_class == 'Warning': try: raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml) @@ -546,7 +565,7 @@

    Module exchangelib.services.common

    log.warning(str(e)) container = message.find(name) if container is None: - raise MalformedResponseError('No %s elements in ResponseMessage (%s)' % (name, xml_to_str(message))) + raise MalformedResponseError(f'No {name} elements in ResponseMessage ({xml_to_str(message)})') return container # rspclass == 'Error', or 'Success' and not 'NoError' try: @@ -558,30 +577,29 @@

    Module exchangelib.services.common

    def _get_exception(code, text, msg_xml): """Parse error messages contained in EWS responses and raise as exceptions defined in this package.""" if not code: - return TransportError('Empty ResponseCode in ResponseMessage (MessageText: %s, MessageXml: %s)' % ( - text, msg_xml)) + return TransportError(f'Empty ResponseCode in ResponseMessage (MessageText: {text}, MessageXml: {msg_xml})') if msg_xml is not None: # If this is an ErrorInvalidPropertyRequest error, the xml may contain a specific FieldURI for elem_cls in (FieldURI, IndexedFieldURI, ExtendedFieldURI, ExceptionFieldURI): elem = msg_xml.find(elem_cls.response_tag()) if elem is not None: field_uri = elem_cls.from_xml(elem, account=None) - text += ' (field: %s)' % field_uri + text += f' (field: {field_uri})' break # If this is an ErrorInvalidValueForProperty error, the xml may contain the name and value of the property if code == 'ErrorInvalidValueForProperty': msg_parts = {} - for elem in msg_xml.findall('{%s}Value' % TNS): + for elem in msg_xml.findall(f'{{{TNS}}}Value'): key, val = elem.get('Name'), elem.text if key: msg_parts[key] = val if msg_parts: - text += ' (%s)' % ', '.join('%s: %s' % (k, v) for k, v in msg_parts.items()) + text += f" ({', '.join(f'{k}: {v}' for k, v in msg_parts.items())})" # If this is an ErrorInternalServerError error, the xml may contain a more specific error code inner_code, inner_text = None, None - for value_elem in msg_xml.findall('{%s}Value' % TNS): + for value_elem in msg_xml.findall(f'{{{TNS}}}Value'): name = value_elem.get('Name') if name == 'InnerErrorResponseCode': inner_code = value_elem.text @@ -590,17 +608,18 @@

    Module exchangelib.services.common

    if inner_code: try: # Raise the error as the inner error code - return vars(errors)[inner_code]('%s (raised from: %s(%r))' % (inner_text, code, text)) + return vars(errors)[inner_code](f'{inner_text} (raised from: {code}({text!r}))') except KeyError: # Inner code is unknown to us. Just append to the original text - text += ' (inner error: %s(%r))' % (inner_code, inner_text) + text += f' (inner error: {inner_code}({inner_text!r}))' try: # Raise the error corresponding to the ResponseCode return vars(errors)[code](text) except KeyError: # Should not happen - return TransportError('Unknown ResponseCode in ResponseMessage: %s (MessageText: %s, MessageXml: %s)' % ( - code, text, msg_xml)) + return TransportError( + f'Unknown ResponseCode in ResponseMessage: {code} (MessageText: {text}, MessageXml: {msg_xml})' + ) def _get_elements_in_response(self, response): """Take a list of 'SomeServiceResponseMessage' elements and return the elements in each response message that @@ -653,46 +672,70 @@

    Module exchangelib.services.common

    return list(container) return [True] - def _get_elems_from_page(self, elem, max_items, total_item_count): - container = elem.find(self.element_container_name) - if container is None: - raise MalformedResponseError('No %s elements in ResponseMessage (%s)' % ( - self.element_container_name, xml_to_str(elem))) - for e in self._get_elements_in_container(container=container): - if max_items and total_item_count >= max_items: - # No need to continue. Break out of elements loop - log.debug("'max_items' count reached (elements)") - break - yield e - def _get_pages(self, payload_func, kwargs, expected_message_count): - """Request a page, or a list of pages if multiple collections are pages in a single request. Return each - page. - """ - payload = payload_func(**kwargs) - page_elems = list(self._get_elements(payload=payload)) - if len(page_elems) != expected_message_count: - raise MalformedResponseError( - "Expected %s items in 'response', got %s" % (expected_message_count, len(page_elems)) - ) - return page_elems +class EWSAccountService(EWSService, metaclass=abc.ABCMeta): + """Base class for services that act on items concerning a single Mailbox on the server.""" + + NO_VALID_SERVER_VERSIONS = ErrorInvalidSchemaVersionForMailboxVersion + # Marks services that need affinity to the backend server + prefer_affinity = False + + def __init__(self, *args, **kwargs): + self.account = kwargs.pop('account') + kwargs['protocol'] = self.account.protocol + super().__init__(*args, **kwargs) + + @property + def _version_hint(self): + return self.account.version + + @_version_hint.setter + def _version_hint(self, value): + self.account.version = value + + def _handle_response_cookies(self, session): + super()._handle_response_cookies(session=session) + + # See self._extra_headers() for documentation on affinity + if self.prefer_affinity: + for cookie in session.cookies: + if cookie.name == 'X-BackEndOverrideCookie': + self.account.affinity_cookie = cookie.value + break + + def _extra_headers(self, session): + headers = super()._extra_headers(session=session) + # See + # https://blogs.msdn.microsoft.com/webdav_101/2015/05/11/best-practices-ews-authentication-and-access-issues/ + headers['X-AnchorMailbox'] = self.account.primary_smtp_address + + # See + # https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/how-to-maintain-affinity-between-group-of-subscriptions-and-mailbox-server + if self.prefer_affinity: + headers['X-PreferServerAffinity'] = 'True' + if self.account.affinity_cookie: + headers['X-BackEndOverrideCookie'] = self.account.affinity_cookie + return headers + + @property + def _account_to_impersonate(self): + if self.account.access_type == IMPERSONATION: + return self.account.identity + return None + + @property + def _timezone(self): + return self.account.default_timezone - @staticmethod - def _get_next_offset(paging_infos): - next_offsets = {p['next_offset'] for p in paging_infos if p['next_offset'] is not None} - if not next_offsets: - # Paging is done for all messages - return None - # We cannot guarantee that all messages that have a next_offset also have the *same* next_offset. This is - # because the collections that we are iterating may change while iterating. We'll do our best but we cannot - # guarantee 100% consistency when large collections are simultaneously being changed on the server. - # - # It's not possible to supply a per-folder offset when iterating multiple folders, so we'll just have to - # choose something that is most likely to work. Select the lowest of all the values to at least make sure - # we don't miss any items, although we may then get duplicates ¯\_(ツ)_/¯ - if len(next_offsets) > 1: - log.warning('Inconsistent next_offset values: %r. Using lowest value', next_offsets) - return min(next_offsets) + +class EWSPagingService(EWSAccountService): + def __init__(self, *args, **kwargs): + self.page_size = kwargs.pop('page_size', None) or PAGE_SIZE + if not isinstance(self.page_size, int): + raise InvalidTypeError('page_size', self.page_size, int) + if self.page_size < 1: + raise ValueError(f"'page_size' {self.page_size} must be a positive number") + super().__init__(*args, **kwargs) def _paged_call(self, payload_func, max_items, folders, **kwargs): """Call a service that supports paging requests. Return a generator over all response items. Keeps track of @@ -784,80 +827,66 @@

    Module exchangelib.services.common

    paging_elem = None return paging_elem, next_offset + def _get_elems_from_page(self, elem, max_items, total_item_count): + container = elem.find(self.element_container_name) + if container is None: + raise MalformedResponseError( + f'No {self.element_container_name} elements in ResponseMessage ({xml_to_str(elem)})' + ) + for e in self._get_elements_in_container(container=container): + if max_items and total_item_count >= max_items: + # No need to continue. Break out of elements loop + log.debug("'max_items' count reached (elements)") + break + yield e -class EWSAccountService(EWSService, metaclass=abc.ABCMeta): - """Base class for services that act on items concerning a single Mailbox on the server.""" - - NO_VALID_SERVER_VERSIONS = ErrorInvalidSchemaVersionForMailboxVersion - # Marks services that need affinity to the backend server - prefer_affinity = False - - def __init__(self, *args, **kwargs): - self.account = kwargs.pop('account') - kwargs['protocol'] = self.account.protocol - super().__init__(*args, **kwargs) - - @property - def _version_hint(self): - return self.account.version - - @_version_hint.setter - def _version_hint(self, value): - self.account.version = value - - def _handle_response_cookies(self, session): - super()._handle_response_cookies(session=session) - - # See self._extra_headers() for documentation on affinity - if self.prefer_affinity: - for cookie in session.cookies: - if cookie.name == 'X-BackEndOverrideCookie': - self.account.affinity_cookie = cookie.value - break - - def _extra_headers(self, session): - headers = super()._extra_headers(session=session) - # See - # https://blogs.msdn.microsoft.com/webdav_101/2015/05/11/best-practices-ews-authentication-and-access-issues/ - headers['X-AnchorMailbox'] = self.account.primary_smtp_address - - # See - # https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/how-to-maintain-affinity-between-group-of-subscriptions-and-mailbox-server - if self.prefer_affinity: - headers['X-PreferServerAffinity'] = 'True' - if self.account.affinity_cookie: - headers['X-BackEndOverrideCookie'] = self.account.affinity_cookie - return headers - - @property - def _account_to_impersonate(self): - if self.account.access_type == IMPERSONATION: - return self.account.identity - return None + def _get_pages(self, payload_func, kwargs, expected_message_count): + """Request a page, or a list of pages if multiple collections are pages in a single request. Return each + page. + """ + payload = payload_func(**kwargs) + page_elems = list(self._get_elements(payload=payload)) + if len(page_elems) != expected_message_count: + raise MalformedResponseError( + f"Expected {expected_message_count} items in 'response', got {len(page_elems)}" + ) + return page_elems - @property - def _timezone(self): - return self.account.default_timezone + @staticmethod + def _get_next_offset(paging_infos): + next_offsets = {p['next_offset'] for p in paging_infos if p['next_offset'] is not None} + if not next_offsets: + # Paging is done for all messages + return None + # We cannot guarantee that all messages that have a next_offset also have the *same* next_offset. This is + # because the collections that we are iterating may change while iterating. We'll do our best but we cannot + # guarantee 100% consistency when large collections are simultaneously being changed on the server. + # + # It's not possible to supply a per-folder offset when iterating multiple folders, so we'll just have to + # choose something that is most likely to work. Select the lowest of all the values to at least make sure + # we don't miss any items, although we may then get duplicates ¯\_(ツ)_/¯ + if len(next_offsets) > 1: + log.warning('Inconsistent next_offset values: %r. Using lowest value', next_offsets) + return min(next_offsets) -def to_item_id(item, item_cls, version): +def to_item_id(item, item_cls): # Coerce a tuple, dict or object to an 'item_cls' instance. Used to create [Parent][Item|Folder]Id instances from a # variety of input. - if isinstance(item, item_cls): - # Allow any subclass of item_cls, e.g. OccurrenceItemId when ItemId is passed + if isinstance(item, (BaseItemId, AttachmentId)): + # Allow any BaseItemId subclass to pass unaltered return item - from ..folders import BaseFolder - from ..items import BaseItem if isinstance(item, (BaseFolder, BaseItem)): - return item.to_id_xml(version=version) - if isinstance(item, (tuple, list)): + try: + return item.to_id() + except ValueError: + return item + if isinstance(item, (str, tuple, list)): return item_cls(*item) - if isinstance(item, dict): - return item_cls(**item) return item_cls(item.id, item.changekey) -def create_shape_element(tag, shape, additional_fields, version): +def shape_element(tag, shape, additional_fields, version): shape_elem = create_element(tag) add_xml_child(shape_elem, 't:BaseShape', shape) if additional_fields: @@ -874,55 +903,38 @@

    Module exchangelib.services.common

    return shape_elem -def create_folder_ids_element(tag, folders, version): - from ..folders import FolderId - folder_ids = create_element(tag) - for folder in folders: - if not isinstance(folder, FolderId): - folder = to_item_id(folder, FolderId, version=version) - set_xml_value(folder_ids, folder, version=version) - if not len(folder_ids): - raise ValueError('"folders" must not be empty') - return folder_ids - - -def create_item_ids_element(items, version, tag='m:ItemIds'): +def _ids_element(items, item_cls, version, tag): item_ids = create_element(tag) for item in items: - set_xml_value(item_ids, to_item_id(item, ItemId, version=version), version=version) - if not len(item_ids): - raise ValueError('"items" must not be empty') + set_xml_value(item_ids, to_item_id(item, item_cls), version=version) return item_ids -def create_attachment_ids_element(items, version): - from ..attachments import AttachmentId - attachment_ids = create_element('m:AttachmentIds') - for item in items: - attachment_id = item if isinstance(item, AttachmentId) else AttachmentId(id=item) - set_xml_value(attachment_ids, attachment_id, version=version) - if not len(attachment_ids): - raise ValueError('"items" must not be empty') - return attachment_ids +def folder_ids_element(folders, version, tag='m:FolderIds'): + return _ids_element(folders, FolderId, version, tag) + + +def item_ids_element(items, version, tag='m:ItemIds'): + return _ids_element(items, ItemId, version, tag) + + +def attachment_ids_element(items, version, tag='m:AttachmentIds'): + return _ids_element(items, AttachmentId, version, tag) def parse_folder_elem(elem, folder, account): - from ..folders import BaseFolder, Folder, DistinguishedFolderId, RootOfHierarchy - if isinstance(elem, Exception): - return elem if isinstance(folder, RootOfHierarchy): f = folder.from_xml(elem=elem, account=folder.account) elif isinstance(folder, Folder): f = folder.from_xml_with_root(elem=elem, root=folder.root) elif isinstance(folder, DistinguishedFolderId): # We don't know the root, so assume account.root. - folder_cls = None for cls in account.root.WELLKNOWN_FOLDERS: if cls.DISTINGUISHED_FOLDER_ID == folder.id: folder_cls = cls break - if not folder_cls: - raise ValueError('Unknown distinguished folder ID: %s' % folder.id) + else: + raise ValueError(f'Unknown distinguished folder ID: {folder.id}') f = folder_cls.from_xml_with_root(elem=elem, root=account.root) else: # 'folder' is a generic FolderId instance. We don't know the root so assume account.root. @@ -941,28 +953,8 @@

    Module exchangelib.services.common

    Functions

    -
    -def create_attachment_ids_element(items, version) -
    -
    -
    -
    - -Expand source code - -
    def create_attachment_ids_element(items, version):
    -    from ..attachments import AttachmentId
    -    attachment_ids = create_element('m:AttachmentIds')
    -    for item in items:
    -        attachment_id = item if isinstance(item, AttachmentId) else AttachmentId(id=item)
    -        set_xml_value(attachment_ids, attachment_id, version=version)
    -    if not len(attachment_ids):
    -        raise ValueError('"items" must not be empty')
    -    return attachment_ids
    -
    -
    -
    -def create_folder_ids_element(tag, folders, version) +
    +def attachment_ids_element(items, version, tag='m:AttachmentIds')
    @@ -970,20 +962,12 @@

    Functions

    Expand source code -
    def create_folder_ids_element(tag, folders, version):
    -    from ..folders import FolderId
    -    folder_ids = create_element(tag)
    -    for folder in folders:
    -        if not isinstance(folder, FolderId):
    -            folder = to_item_id(folder, FolderId, version=version)
    -        set_xml_value(folder_ids, folder, version=version)
    -    if not len(folder_ids):
    -        raise ValueError('"folders" must not be empty')
    -    return folder_ids
    +
    def attachment_ids_element(items, version, tag='m:AttachmentIds'):
    +    return _ids_element(items, AttachmentId, version, tag)
    -
    -def create_item_ids_element(items, version, tag='m:ItemIds') +
    +def folder_ids_element(folders, version, tag='m:FolderIds')
    @@ -991,17 +975,12 @@

    Functions

    Expand source code -
    def create_item_ids_element(items, version, tag='m:ItemIds'):
    -    item_ids = create_element(tag)
    -    for item in items:
    -        set_xml_value(item_ids, to_item_id(item, ItemId, version=version), version=version)
    -    if not len(item_ids):
    -        raise ValueError('"items" must not be empty')
    -    return item_ids
    +
    def folder_ids_element(folders, version, tag='m:FolderIds'):
    +    return _ids_element(folders, FolderId, version, tag)
    -
    -def create_shape_element(tag, shape, additional_fields, version) +
    +def item_ids_element(items, version, tag='m:ItemIds')
    @@ -1009,21 +988,8 @@

    Functions

    Expand source code -
    def create_shape_element(tag, shape, additional_fields, version):
    -    shape_elem = create_element(tag)
    -    add_xml_child(shape_elem, 't:BaseShape', shape)
    -    if additional_fields:
    -        additional_properties = create_element('t:AdditionalProperties')
    -        expanded_fields = chain(*(f.expand(version=version) for f in additional_fields))
    -        # 'path' is insufficient to consistently sort additional properties. For example, we have both
    -        # 'contacts:Companies' and 'task:Companies' with path 'companies'. Sort by both 'field_uri' and 'path'.
    -        # Extended properties do not have a 'field_uri' value.
    -        set_xml_value(additional_properties, sorted(
    -            expanded_fields,
    -            key=lambda f: (getattr(f.field, 'field_uri', ''), f.path)
    -        ), version=version)
    -        shape_elem.append(additional_properties)
    -    return shape_elem
    +
    def item_ids_element(items, version, tag='m:ItemIds'):
    +    return _ids_element(items, ItemId, version, tag)
    @@ -1036,22 +1002,18 @@

    Functions

    Expand source code
    def parse_folder_elem(elem, folder, account):
    -    from ..folders import BaseFolder, Folder, DistinguishedFolderId, RootOfHierarchy
    -    if isinstance(elem, Exception):
    -        return elem
         if isinstance(folder, RootOfHierarchy):
             f = folder.from_xml(elem=elem, account=folder.account)
         elif isinstance(folder, Folder):
             f = folder.from_xml_with_root(elem=elem, root=folder.root)
         elif isinstance(folder, DistinguishedFolderId):
             # We don't know the root, so assume account.root.
    -        folder_cls = None
             for cls in account.root.WELLKNOWN_FOLDERS:
                 if cls.DISTINGUISHED_FOLDER_ID == folder.id:
                     folder_cls = cls
                     break
    -        if not folder_cls:
    -            raise ValueError('Unknown distinguished folder ID: %s' % folder.id)
    +        else:
    +            raise ValueError(f'Unknown distinguished folder ID: {folder.id}')
             f = folder_cls.from_xml_with_root(elem=elem, root=account.root)
         else:
             # 'folder' is a generic FolderId instance. We don't know the root so assume account.root.
    @@ -1063,8 +1025,34 @@ 

    Functions

    return f
    +
    +def shape_element(tag, shape, additional_fields, version) +
    +
    +
    +
    + +Expand source code + +
    def shape_element(tag, shape, additional_fields, version):
    +    shape_elem = create_element(tag)
    +    add_xml_child(shape_elem, 't:BaseShape', shape)
    +    if additional_fields:
    +        additional_properties = create_element('t:AdditionalProperties')
    +        expanded_fields = chain(*(f.expand(version=version) for f in additional_fields))
    +        # 'path' is insufficient to consistently sort additional properties. For example, we have both
    +        # 'contacts:Companies' and 'task:Companies' with path 'companies'. Sort by both 'field_uri' and 'path'.
    +        # Extended properties do not have a 'field_uri' value.
    +        set_xml_value(additional_properties, sorted(
    +            expanded_fields,
    +            key=lambda f: (getattr(f.field, 'field_uri', ''), f.path)
    +        ), version=version)
    +        shape_elem.append(additional_properties)
    +    return shape_elem
    +
    +
    -def to_item_id(item, item_cls, version) +def to_item_id(item, item_cls)
    @@ -1072,20 +1060,19 @@

    Functions

    Expand source code -
    def to_item_id(item, item_cls, version):
    +
    def to_item_id(item, item_cls):
         # Coerce a tuple, dict or object to an 'item_cls' instance. Used to create [Parent][Item|Folder]Id instances from a
         # variety of input.
    -    if isinstance(item, item_cls):
    -        # Allow any subclass of item_cls, e.g. OccurrenceItemId when ItemId is passed
    +    if isinstance(item, (BaseItemId, AttachmentId)):
    +        # Allow any BaseItemId subclass to pass unaltered
             return item
    -    from ..folders import BaseFolder
    -    from ..items import BaseItem
         if isinstance(item, (BaseFolder, BaseItem)):
    -        return item.to_id_xml(version=version)
    -    if isinstance(item, (tuple, list)):
    +        try:
    +            return item.to_id()
    +        except ValueError:
    +            return item
    +    if isinstance(item, (str, tuple, list)):
             return item_cls(*item)
    -    if isinstance(item, dict):
    -        return item_cls(**item)
         return item_cls(item.id, item.changekey)
    @@ -1165,6 +1152,7 @@

    Ancestors

    Subclasses

    @@ -1219,23 +1202,198 @@

    Inherited members

  • -
    -class EWSService -(protocol, chunk_size=None, timeout=None) +
    +class EWSPagingService +(*args, **kwargs)
    -

    Base class for all EWS services.

    +

    Base class for services that act on items concerning a single Mailbox on the server.

    Expand source code -
    class EWSService(metaclass=abc.ABCMeta):
    -    """Base class for all EWS services."""
    +
    class EWSPagingService(EWSAccountService):
    +    def __init__(self, *args, **kwargs):
    +        self.page_size = kwargs.pop('page_size', None) or PAGE_SIZE
    +        if not isinstance(self.page_size, int):
    +            raise InvalidTypeError('page_size', self.page_size, int)
    +        if self.page_size < 1:
    +            raise ValueError(f"'page_size' {self.page_size} must be a positive number")
    +        super().__init__(*args, **kwargs)
    +
    +    def _paged_call(self, payload_func, max_items, folders, **kwargs):
    +        """Call a service that supports paging requests. Return a generator over all response items. Keeps track of
    +        all paging-related counters.
    +        """
    +        paging_infos = {f: dict(item_count=0, next_offset=None) for f in folders}
    +        common_next_offset = kwargs['offset']
    +        total_item_count = 0
    +        while True:
    +            if not paging_infos:
    +                # Paging is done for all folders
    +                break
    +            log.debug('Getting page at offset %s (max_items %s)', common_next_offset, max_items)
    +            kwargs['offset'] = common_next_offset
    +            kwargs['folders'] = paging_infos.keys()  # Only request the paging of the remaining folders.
    +            pages = self._get_pages(payload_func, kwargs, len(paging_infos))
    +            for (page, next_offset), (f, paging_info) in zip(pages, list(paging_infos.items())):
    +                paging_info['next_offset'] = next_offset
    +                if isinstance(page, Exception):
    +                    # Assume this folder no longer works. Don't attempt to page it again.
    +                    log.debug('Exception occurred for folder %s. Removing.', f)
    +                    del paging_infos[f]
    +                    yield page
    +                    continue
    +                if page is not None:
    +                    for elem in self._get_elems_from_page(page, max_items, total_item_count):
    +                        paging_info['item_count'] += 1
    +                        total_item_count += 1
    +                        yield elem
    +                    if max_items and total_item_count >= max_items:
    +                        # No need to continue. Break out of inner loop
    +                        log.debug("'max_items' count reached (inner)")
    +                        break
    +                if not paging_info['next_offset']:
    +                    # Paging is done for this folder. Don't attempt to page it again.
    +                    log.debug('Paging has completed for folder %s. Removing.', f)
    +                    del paging_infos[f]
    +                    continue
    +                log.debug('Folder %s still has items', f)
    +                # Check sanity of paging offsets, but don't fail. When we are iterating huge collections that take a
    +                # long time to complete, the collection may change while we are iterating. This can affect the
    +                # 'next_offset' value and make it inconsistent with the number of already collected items.
    +                # We may have a mismatch if we stopped early due to reaching 'max_items'.
    +                if paging_info['next_offset'] != paging_info['item_count'] and (
    +                    not max_items or total_item_count < max_items
    +                ):
    +                    log.warning('Unexpected next offset: %s -> %s. Maybe the server-side collection has changed?',
    +                                paging_info['item_count'], paging_info['next_offset'])
    +            # Also break out of outer loop
    +            if max_items and total_item_count >= max_items:
    +                log.debug("'max_items' count reached (outer)")
    +                break
    +            common_next_offset = self._get_next_offset(paging_infos.values())
    +            if common_next_offset is None:
    +                # Paging is done for all folders
    +                break
    +
    +    @staticmethod
    +    def _get_paging_values(elem):
    +        """Read paging information from the paging container element."""
    +        offset_attr = elem.get('IndexedPagingOffset')
    +        next_offset = None if offset_attr is None else int(offset_attr)
    +        item_count = int(elem.get('TotalItemsInView'))
    +        is_last_page = elem.get('IncludesLastItemInRange').lower() in ('true', '0')
    +        log.debug('Got page with offset %s, item_count %s, last_page %s', next_offset, item_count, is_last_page)
    +        # Clean up contradictory paging values
    +        if next_offset is None and not is_last_page:
    +            log.debug("Not last page in range, but server didn't send a page offset. Assuming first page")
    +            next_offset = 1
    +        if next_offset is not None and is_last_page:
    +            if next_offset != item_count:
    +                log.debug("Last page in range, but we still got an offset. Assuming paging has completed")
    +            next_offset = None
    +        if not item_count and not is_last_page:
    +            log.debug("Not last page in range, but also no items left. Assuming paging has completed")
    +            next_offset = None
    +        if item_count and next_offset == 0:
    +            log.debug("Non-zero offset, but also no items left. Assuming paging has completed")
    +            next_offset = None
    +        return item_count, next_offset
    +
    +    def _get_page(self, message):
    +        """Get a single page from a request message, and return the container and next offset."""
    +        paging_elem = self._get_element_container(message=message, name=self.paging_container_name)
    +        if isinstance(paging_elem, Exception):
    +            return paging_elem, None
    +        item_count, next_offset = self._get_paging_values(paging_elem)
    +        if not item_count:
    +            paging_elem = None
    +        return paging_elem, next_offset
    +
    +    def _get_elems_from_page(self, elem, max_items, total_item_count):
    +        container = elem.find(self.element_container_name)
    +        if container is None:
    +            raise MalformedResponseError(
    +                f'No {self.element_container_name} elements in ResponseMessage ({xml_to_str(elem)})'
    +            )
    +        for e in self._get_elements_in_container(container=container):
    +            if max_items and total_item_count >= max_items:
    +                # No need to continue. Break out of elements loop
    +                log.debug("'max_items' count reached (elements)")
    +                break
    +            yield e
    +
    +    def _get_pages(self, payload_func, kwargs, expected_message_count):
    +        """Request a page, or a list of pages if multiple collections are pages in a single request. Return each
    +        page.
    +        """
    +        payload = payload_func(**kwargs)
    +        page_elems = list(self._get_elements(payload=payload))
    +        if len(page_elems) != expected_message_count:
    +            raise MalformedResponseError(
    +                f"Expected {expected_message_count} items in 'response', got {len(page_elems)}"
    +            )
    +        return page_elems
    +
    +    @staticmethod
    +    def _get_next_offset(paging_infos):
    +        next_offsets = {p['next_offset'] for p in paging_infos if p['next_offset'] is not None}
    +        if not next_offsets:
    +            # Paging is done for all messages
    +            return None
    +        # We cannot guarantee that all messages that have a next_offset also have the *same* next_offset. This is
    +        # because the collections that we are iterating may change while iterating. We'll do our best but we cannot
    +        # guarantee 100% consistency when large collections are simultaneously being changed on the server.
    +        #
    +        # It's not possible to supply a per-folder offset when iterating multiple folders, so we'll just have to
    +        # choose something that is most likely to work. Select the lowest of all the values to at least make sure
    +        # we don't miss any items, although we may then get duplicates ¯\_(ツ)_/¯
    +        if len(next_offsets) > 1:
    +            log.warning('Inconsistent next_offset values: %r. Using lowest value', next_offsets)
    +        return min(next_offsets)
    +
    +

    Ancestors

    + +

    Subclasses

    + +

    Inherited members

    + +
    +
    +class EWSService +(protocol, chunk_size=None, timeout=None) +
    +
    +

    Base class for all EWS services.

    +
    + +Expand source code + +
    class EWSService(metaclass=abc.ABCMeta):
    +    """Base class for all EWS services."""
     
         SERVICE_NAME = None  # The name of the SOAP service
         element_container_name = None  # The name of the XML element wrapping the collection of returned items
         paging_container_name = None  # The name of the element that contains paging information and the paged results
    -    returns_elements = True  # If False, the service does not return response elements, just the RsponseCode status
    +    returns_elements = True  # If False, the service does not return response elements, just the ResponseCode status
         # Return exception instance instead of raising exceptions for the following errors when contained in an element
         ERRORS_TO_CATCH_IN_RESPONSE = (
             EWSWarning, ErrorCannotDeleteObject, ErrorInvalidChangeKey, ErrorItemNotFound, ErrorItemSave,
    @@ -1254,14 +1412,15 @@ 

    Inherited members

    supports_paging = False def __init__(self, protocol, chunk_size=None, timeout=None): - self.chunk_size = chunk_size or CHUNK_SIZE # The number of items to send in a single request + self.chunk_size = chunk_size or CHUNK_SIZE if not isinstance(self.chunk_size, int): - raise ValueError("'chunk_size' %r must be an integer" % chunk_size) + raise InvalidTypeError('chunk_size', chunk_size, int) if self.chunk_size < 1: - raise ValueError("'chunk_size' must be a positive number") + raise ValueError(f"'chunk_size' {self.chunk_size} must be a positive number") if self.supported_from and protocol.version.build < self.supported_from: raise NotImplementedError( - '%r is only supported on %r and later' % (self.SERVICE_NAME, self.supported_from.fullname()) + f'{self.SERVICE_NAME!r} is only supported on {self.supported_from.fullname()!r} and later. ' + f'Your current version is {protocol.version.build.fullname()!r}.' ) self.protocol = protocol # Allow a service to override the default protocol timeout. Useful for streaming services @@ -1307,20 +1466,30 @@

    Inherited members

    return None if expect_result is False: if res: - raise ValueError('Expected result length 0, but got %r' % res) + raise ValueError(f'Expected result length 0, but got {res}') return None if len(res) != 1: - raise ValueError('Expected result length 1, but got %r' % res) + raise ValueError(f'Expected result length 1, but got {res}') return res[0] def parse(self, xml): """Used mostly for testing, when we want to parse static XML data.""" - resp = DummyResponse(url=None, headers=None, request_headers=None, content=xml) + resp = DummyResponse(content=xml, streaming=self.streaming) _, body = self._get_soap_parts(response=resp) return self._elems_to_objs(self._get_elements_in_response(response=self._get_soap_messages(body=body))) def _elems_to_objs(self, elems): """Takes a generator of XML elements and exceptions. Returns the equivalent Python objects (or exceptions).""" + for elem in elems: + # Allow None here. Some services don't return an ID if the target folder is outside the mailbox. + if isinstance(elem, (Exception, type(None))): + yield elem + continue + yield self._elem_to_obj(elem) + + def _elem_to_obj(self, elem): + if not self.returns_elements: + raise RuntimeError("Incorrect call to method when 'returns_elements' is False") raise NotImplementedError() @property @@ -1365,7 +1534,9 @@

    Inherited members

    :param kwargs: Same as arguments for .call(), except for the 'items' argument :return: Same as ._get_elements() """ - for i, chunk in enumerate(chunkify(items, self.chunk_size), start=1): + # If the input for a service is a QuerySet, it can be difficult to remove exceptions before now + filtered_items = filter(lambda i: not isinstance(i, Exception), items) + for i, chunk in enumerate(chunkify(filtered_items, self.chunk_size), start=1): log.debug('Processing chunk %s containing %s items', i, len(chunk)) yield from self._get_elements(payload=payload_func(chunk, **kwargs)) @@ -1408,9 +1579,9 @@

    Inherited members

    raise e # Re-raise as an ErrorServerBusy with a default delay of 5 minutes - raise ErrorServerBusy('Reraised from %s(%s)' % (e.__class__.__name__, e)) + raise ErrorServerBusy(f'Reraised from {e.__class__.__name__}({e})') except Exception: - # This may run from a thread pool, which obfuscates the stack trace. Print trace immediately. + # This may run in a thread, which obfuscates the stack trace. Print trace immediately. account = self.account if isinstance(self, EWSAccountService) else None log.warning('Account %s: Exception in _get_elements: %s', account, traceback.format_exc(20)) raise @@ -1506,9 +1677,7 @@

    Inherited members

    # In streaming mode, we may not have accessed the raw stream yet. Caller must handle this. r.close() # Release memory - raise self.NO_VALID_SERVER_VERSIONS( - 'Tried versions %s but all were invalid' % ', '.join(self._api_versions_to_try) - ) + raise self.NO_VALID_SERVER_VERSIONS(f'Tried versions {self._api_versions_to_try} but all were invalid') def _handle_backoff(self, e): """Take a request from the server to back off and checks the retry policy for what to do. Re-raise the @@ -1548,17 +1717,17 @@

    Inherited members

    @classmethod def _response_tag(cls): """Return the name of the element containing the service response.""" - return '{%s}%sResponse' % (MNS, cls.SERVICE_NAME) + return f'{{{MNS}}}{cls.SERVICE_NAME}Response' @staticmethod def _response_messages_tag(): """Return the name of the element containing service response messages.""" - return '{%s}ResponseMessages' % MNS + return f'{{{MNS}}}ResponseMessages' @classmethod def _response_message_tag(cls): """Return the name of the element of a single response message.""" - return '{%s}%sResponseMessage' % (MNS, cls.SERVICE_NAME) + return f'{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage' @classmethod def _get_soap_parts(cls, response, **parse_opts): @@ -1566,12 +1735,12 @@

    Inherited members

    try: root = to_xml(response.iter_content()) except ParseError as e: - raise SOAPError('Bad SOAP response: %s' % e) - header = root.find('{%s}Header' % SOAPNS) + raise SOAPError(f'Bad SOAP response: {e}') + header = root.find(f'{{{SOAPNS}}}Header') if header is None: # This is normal when the response contains SOAP-level errors log.debug('No header in XML response') - body = root.find('{%s}Body' % SOAPNS) + body = root.find(f'{{{SOAPNS}}}Body') if body is None: raise MalformedResponseError('No Body element in SOAP response') return header, body @@ -1580,11 +1749,9 @@

    Inherited members

    """Return the elements in the response containing the response messages. Raises any SOAP exceptions.""" response = body.find(self._response_tag()) if response is None: - fault = body.find('{%s}Fault' % SOAPNS) + fault = body.find(f'{{{SOAPNS}}}Fault') if fault is None: - raise SOAPError( - 'Unknown SOAP response (expected %s or Fault): %s' % (self._response_tag(), xml_to_str(body)) - ) + raise SOAPError(f'Unknown SOAP response (expected {self._response_tag()} or Fault): {xml_to_str(body)}') self._raise_soap_errors(fault=fault) # Will throw SOAPError or custom EWS error response_messages = response.find(self._response_messages_tag()) if response_messages is None: @@ -1597,40 +1764,43 @@

    Inherited members

    def _raise_soap_errors(cls, fault): """Parse error messages contained in SOAP headers and raise as exceptions defined in this package.""" # Fault: See http://www.w3.org/TR/2000/NOTE-SOAP-20000508/#_Toc478383507 - faultcode = get_xml_attr(fault, 'faultcode') - faultstring = get_xml_attr(fault, 'faultstring') - faultactor = get_xml_attr(fault, 'faultactor') + fault_code = get_xml_attr(fault, 'faultcode') + fault_string = get_xml_attr(fault, 'faultstring') + fault_actor = get_xml_attr(fault, 'faultactor') detail = fault.find('detail') if detail is not None: code, msg = None, '' - if detail.find('{%s}ResponseCode' % ENS) is not None: - code = get_xml_attr(detail, '{%s}ResponseCode' % ENS).strip() - if detail.find('{%s}Message' % ENS) is not None: - msg = get_xml_attr(detail, '{%s}Message' % ENS).strip() - msg_xml = detail.find('{%s}MessageXml' % TNS) # Crazy. Here, it's in the TNS namespace + if detail.find(f'{{{ENS}}}ResponseCode') is not None: + code = get_xml_attr(detail, f'{{{ENS}}}ResponseCode').strip() + if detail.find(f'{{{ENS}}}Message') is not None: + msg = get_xml_attr(detail, f'{{{ENS}}}Message').strip() + msg_xml = detail.find(f'{{{TNS}}}MessageXml') # Crazy. Here, it's in the TNS namespace if code == 'ErrorServerBusy': back_off = None try: - value = msg_xml.find('{%s}Value' % TNS) + value = msg_xml.find(f'{{{TNS}}}Value') if value.get('Name') == 'BackOffMilliseconds': back_off = int(value.text) / 1000.0 # Convert to seconds except (TypeError, AttributeError): pass raise ErrorServerBusy(msg, back_off=back_off) if code == 'ErrorSchemaValidation' and msg_xml is not None: - violation = get_xml_attr(msg_xml, '{%s}Violation' % TNS) - if violation is not None: - msg = '%s %s' % (msg, violation) + line_number = get_xml_attr(msg_xml, f'{{{TNS}}}LineNumber') + line_position = get_xml_attr(msg_xml, f'{{{TNS}}}LinePosition') + violation = get_xml_attr(msg_xml, f'{{{TNS}}}Violation') + if violation: + msg = f'{msg} {violation}' + if line_number or line_position: + msg = f'{msg} (line: {line_number} position: {line_position})' try: raise vars(errors)[code](msg) except KeyError: - detail = '%s: code: %s msg: %s (%s)' % (cls.SERVICE_NAME, code, msg, xml_to_str(detail)) + detail = f'{cls.SERVICE_NAME}: code: {code} msg: {msg} ({xml_to_str(detail)})' try: - raise vars(errors)[faultcode](faultstring) + raise vars(errors)[fault_code](fault_string) except KeyError: pass - raise SOAPError('SOAP error code: %s string: %s actor: %s detail: %s' % ( - faultcode, faultstring, faultactor, detail)) + raise SOAPError(f'SOAP error code: {fault_code} string: {fault_string} actor: {fault_actor} detail: {detail}') def _get_element_container(self, message, name=None): """Return the XML element in a response element that contains the elements we want the service to return. For @@ -1660,19 +1830,19 @@

    Inherited members

    response_class = message.get('ResponseClass') # ResponseCode, MessageText: See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/responsecode - response_code = get_xml_attr(message, '{%s}ResponseCode' % MNS) + response_code = get_xml_attr(message, f'{{{MNS}}}ResponseCode') if response_class == 'Success' and response_code == 'NoError': if not name: return message container = message.find(name) if container is None: - raise MalformedResponseError('No %s elements in ResponseMessage (%s)' % (name, xml_to_str(message))) + raise MalformedResponseError(f'No {name} elements in ResponseMessage ({xml_to_str(message)})') return container if response_code == 'NoError': return True # Raise any non-acceptable errors in the container, or return the container or the acceptable exception instance - msg_text = get_xml_attr(message, '{%s}MessageText' % MNS) - msg_xml = message.find('{%s}MessageXml' % MNS) + msg_text = get_xml_attr(message, f'{{{MNS}}}MessageText') + msg_xml = message.find(f'{{{MNS}}}MessageXml') if response_class == 'Warning': try: raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml) @@ -1682,7 +1852,7 @@

    Inherited members

    log.warning(str(e)) container = message.find(name) if container is None: - raise MalformedResponseError('No %s elements in ResponseMessage (%s)' % (name, xml_to_str(message))) + raise MalformedResponseError(f'No {name} elements in ResponseMessage ({xml_to_str(message)})') return container # rspclass == 'Error', or 'Success' and not 'NoError' try: @@ -1694,30 +1864,29 @@

    Inherited members

    def _get_exception(code, text, msg_xml): """Parse error messages contained in EWS responses and raise as exceptions defined in this package.""" if not code: - return TransportError('Empty ResponseCode in ResponseMessage (MessageText: %s, MessageXml: %s)' % ( - text, msg_xml)) + return TransportError(f'Empty ResponseCode in ResponseMessage (MessageText: {text}, MessageXml: {msg_xml})') if msg_xml is not None: # If this is an ErrorInvalidPropertyRequest error, the xml may contain a specific FieldURI for elem_cls in (FieldURI, IndexedFieldURI, ExtendedFieldURI, ExceptionFieldURI): elem = msg_xml.find(elem_cls.response_tag()) if elem is not None: field_uri = elem_cls.from_xml(elem, account=None) - text += ' (field: %s)' % field_uri + text += f' (field: {field_uri})' break # If this is an ErrorInvalidValueForProperty error, the xml may contain the name and value of the property if code == 'ErrorInvalidValueForProperty': msg_parts = {} - for elem in msg_xml.findall('{%s}Value' % TNS): + for elem in msg_xml.findall(f'{{{TNS}}}Value'): key, val = elem.get('Name'), elem.text if key: msg_parts[key] = val if msg_parts: - text += ' (%s)' % ', '.join('%s: %s' % (k, v) for k, v in msg_parts.items()) + text += f" ({', '.join(f'{k}: {v}' for k, v in msg_parts.items())})" # If this is an ErrorInternalServerError error, the xml may contain a more specific error code inner_code, inner_text = None, None - for value_elem in msg_xml.findall('{%s}Value' % TNS): + for value_elem in msg_xml.findall(f'{{{TNS}}}Value'): name = value_elem.get('Name') if name == 'InnerErrorResponseCode': inner_code = value_elem.text @@ -1726,17 +1895,18 @@

    Inherited members

    if inner_code: try: # Raise the error as the inner error code - return vars(errors)[inner_code]('%s (raised from: %s(%r))' % (inner_text, code, text)) + return vars(errors)[inner_code](f'{inner_text} (raised from: {code}({text!r}))') except KeyError: # Inner code is unknown to us. Just append to the original text - text += ' (inner error: %s(%r))' % (inner_code, inner_text) + text += f' (inner error: {inner_code}({inner_text!r}))' try: # Raise the error corresponding to the ResponseCode return vars(errors)[code](text) except KeyError: # Should not happen - return TransportError('Unknown ResponseCode in ResponseMessage: %s (MessageText: %s, MessageXml: %s)' % ( - code, text, msg_xml)) + return TransportError( + f'Unknown ResponseCode in ResponseMessage: {code} (MessageText: {text}, MessageXml: {msg_xml})' + ) def _get_elements_in_response(self, response): """Take a list of 'SomeServiceResponseMessage' elements and return the elements in each response message that @@ -1787,138 +1957,7 @@

    Inherited members

    """ if cls.returns_elements: return list(container) - return [True] - - def _get_elems_from_page(self, elem, max_items, total_item_count): - container = elem.find(self.element_container_name) - if container is None: - raise MalformedResponseError('No %s elements in ResponseMessage (%s)' % ( - self.element_container_name, xml_to_str(elem))) - for e in self._get_elements_in_container(container=container): - if max_items and total_item_count >= max_items: - # No need to continue. Break out of elements loop - log.debug("'max_items' count reached (elements)") - break - yield e - - def _get_pages(self, payload_func, kwargs, expected_message_count): - """Request a page, or a list of pages if multiple collections are pages in a single request. Return each - page. - """ - payload = payload_func(**kwargs) - page_elems = list(self._get_elements(payload=payload)) - if len(page_elems) != expected_message_count: - raise MalformedResponseError( - "Expected %s items in 'response', got %s" % (expected_message_count, len(page_elems)) - ) - return page_elems - - @staticmethod - def _get_next_offset(paging_infos): - next_offsets = {p['next_offset'] for p in paging_infos if p['next_offset'] is not None} - if not next_offsets: - # Paging is done for all messages - return None - # We cannot guarantee that all messages that have a next_offset also have the *same* next_offset. This is - # because the collections that we are iterating may change while iterating. We'll do our best but we cannot - # guarantee 100% consistency when large collections are simultaneously being changed on the server. - # - # It's not possible to supply a per-folder offset when iterating multiple folders, so we'll just have to - # choose something that is most likely to work. Select the lowest of all the values to at least make sure - # we don't miss any items, although we may then get duplicates ¯\_(ツ)_/¯ - if len(next_offsets) > 1: - log.warning('Inconsistent next_offset values: %r. Using lowest value', next_offsets) - return min(next_offsets) - - def _paged_call(self, payload_func, max_items, folders, **kwargs): - """Call a service that supports paging requests. Return a generator over all response items. Keeps track of - all paging-related counters. - """ - paging_infos = {f: dict(item_count=0, next_offset=None) for f in folders} - common_next_offset = kwargs['offset'] - total_item_count = 0 - while True: - if not paging_infos: - # Paging is done for all folders - break - log.debug('Getting page at offset %s (max_items %s)', common_next_offset, max_items) - kwargs['offset'] = common_next_offset - kwargs['folders'] = paging_infos.keys() # Only request the paging of the remaining folders. - pages = self._get_pages(payload_func, kwargs, len(paging_infos)) - for (page, next_offset), (f, paging_info) in zip(pages, list(paging_infos.items())): - paging_info['next_offset'] = next_offset - if isinstance(page, Exception): - # Assume this folder no longer works. Don't attempt to page it again. - log.debug('Exception occurred for folder %s. Removing.', f) - del paging_infos[f] - yield page - continue - if page is not None: - for elem in self._get_elems_from_page(page, max_items, total_item_count): - paging_info['item_count'] += 1 - total_item_count += 1 - yield elem - if max_items and total_item_count >= max_items: - # No need to continue. Break out of inner loop - log.debug("'max_items' count reached (inner)") - break - if not paging_info['next_offset']: - # Paging is done for this folder. Don't attempt to page it again. - log.debug('Paging has completed for folder %s. Removing.', f) - del paging_infos[f] - continue - log.debug('Folder %s still has items', f) - # Check sanity of paging offsets, but don't fail. When we are iterating huge collections that take a - # long time to complete, the collection may change while we are iterating. This can affect the - # 'next_offset' value and make it inconsistent with the number of already collected items. - # We may have a mismatch if we stopped early due to reaching 'max_items'. - if paging_info['next_offset'] != paging_info['item_count'] and ( - not max_items or total_item_count < max_items - ): - log.warning('Unexpected next offset: %s -> %s. Maybe the server-side collection has changed?', - paging_info['item_count'], paging_info['next_offset']) - # Also break out of outer loop - if max_items and total_item_count >= max_items: - log.debug("'max_items' count reached (outer)") - break - common_next_offset = self._get_next_offset(paging_infos.values()) - if common_next_offset is None: - # Paging is done for all folders - break - - @staticmethod - def _get_paging_values(elem): - """Read paging information from the paging container element.""" - offset_attr = elem.get('IndexedPagingOffset') - next_offset = None if offset_attr is None else int(offset_attr) - item_count = int(elem.get('TotalItemsInView')) - is_last_page = elem.get('IncludesLastItemInRange').lower() in ('true', '0') - log.debug('Got page with offset %s, item_count %s, last_page %s', next_offset, item_count, is_last_page) - # Clean up contradictory paging values - if next_offset is None and not is_last_page: - log.debug("Not last page in range, but server didn't send a page offset. Assuming first page") - next_offset = 1 - if next_offset is not None and is_last_page: - if next_offset != item_count: - log.debug("Last page in range, but we still got an offset. Assuming paging has completed") - next_offset = None - if not item_count and not is_last_page: - log.debug("Not last page in range, but also no items left. Assuming paging has completed") - next_offset = None - if item_count and next_offset == 0: - log.debug("Non-zero offset, but also no items left. Assuming paging has completed") - next_offset = None - return item_count, next_offset - - def _get_page(self, message): - """Get a single page from a request message, and return the container and next offset.""" - paging_elem = self._get_element_container(message=message, name=self.paging_container_name) - if isinstance(paging_elem, Exception): - return paging_elem, None - item_count, next_offset = self._get_paging_values(paging_elem) - if not item_count: - paging_elem = None - return paging_elem, next_offset
    + return [True]

    Subclasses

      @@ -2010,10 +2049,10 @@

      Methods

      return None if expect_result is False: if res: - raise ValueError('Expected result length 0, but got %r' % res) + raise ValueError(f'Expected result length 0, but got {res}') return None if len(res) != 1: - raise ValueError('Expected result length 1, but got %r' % res) + raise ValueError(f'Expected result length 1, but got {res}') return res[0]
    @@ -2028,7 +2067,7 @@

    Methods

    def parse(self, xml):
         """Used mostly for testing, when we want to parse static XML data."""
    -    resp = DummyResponse(url=None, headers=None, request_headers=None, content=xml)
    +    resp = DummyResponse(content=xml, streaming=self.streaming)
         _, body = self._get_soap_parts(response=resp)
         return self._elems_to_objs(self._get_elements_in_response(response=self._get_soap_messages(body=body)))
    @@ -2071,11 +2110,11 @@

    Index

  • Functions

  • @@ -2088,6 +2127,9 @@

    EWSPagingService

    + +
  • EWSService

    • ERRORS_TO_CATCH_IN_RESPONSE
    • diff --git a/docs/exchangelib/services/convert_id.html b/docs/exchangelib/services/convert_id.html index b1395cf9..392fb764 100644 --- a/docs/exchangelib/services/convert_id.html +++ b/docs/exchangelib/services/convert_id.html @@ -27,6 +27,7 @@

      Module exchangelib.services.convert_id

      Expand source code
      from .common import EWSService
      +from ..errors import InvalidEnumValue, InvalidTypeError
       from ..properties import AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId, ID_FORMATS
       from ..util import create_element, set_xml_value
       from ..version import EXCHANGE_2007_SP1
      @@ -41,36 +42,30 @@ 

      Module exchangelib.services.convert_id

      SERVICE_NAME = 'ConvertId' supported_from = EXCHANGE_2007_SP1 + cls_map = {cls.response_tag(): cls for cls in ( + AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId + )} def call(self, items, destination_format): if destination_format not in ID_FORMATS: - raise ValueError("'destination_format' %r must be one of %s" % (destination_format, ID_FORMATS)) + raise InvalidEnumValue('destination_format', destination_format, ID_FORMATS) return self._elems_to_objs( self._chunked_get_elements(self.get_payload, items=items, destination_format=destination_format) ) - def _elems_to_objs(self, elems): - cls_map = {cls.response_tag(): cls for cls in ( - AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId - )} - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield cls_map[elem.tag].from_xml(elem, account=None) + def _elem_to_obj(self, elem): + return self.cls_map[elem.tag].from_xml(elem, account=None) def get_payload(self, items, destination_format): supported_item_classes = AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId - convertid = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(DestinationFormat=destination_format)) + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DestinationFormat=destination_format)) item_ids = create_element('m:SourceIds') for item in items: if not isinstance(item, supported_item_classes): - raise ValueError("'item' value %r must be an instance of %r" % (item, supported_item_classes)) + raise InvalidTypeError('item', item, supported_item_classes) set_xml_value(item_ids, item, version=self.protocol.version) - if not len(item_ids): - raise ValueError('"items" must not be empty') - convertid.append(item_ids) - return convertid + payload.append(item_ids) + return payload @classmethod def _get_elements_in_container(cls, container): @@ -110,36 +105,30 @@

      Classes

      SERVICE_NAME = 'ConvertId' supported_from = EXCHANGE_2007_SP1 + cls_map = {cls.response_tag(): cls for cls in ( + AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId + )} def call(self, items, destination_format): if destination_format not in ID_FORMATS: - raise ValueError("'destination_format' %r must be one of %s" % (destination_format, ID_FORMATS)) + raise InvalidEnumValue('destination_format', destination_format, ID_FORMATS) return self._elems_to_objs( self._chunked_get_elements(self.get_payload, items=items, destination_format=destination_format) ) - def _elems_to_objs(self, elems): - cls_map = {cls.response_tag(): cls for cls in ( - AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId - )} - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield cls_map[elem.tag].from_xml(elem, account=None) + def _elem_to_obj(self, elem): + return self.cls_map[elem.tag].from_xml(elem, account=None) def get_payload(self, items, destination_format): supported_item_classes = AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId - convertid = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(DestinationFormat=destination_format)) + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DestinationFormat=destination_format)) item_ids = create_element('m:SourceIds') for item in items: if not isinstance(item, supported_item_classes): - raise ValueError("'item' value %r must be an instance of %r" % (item, supported_item_classes)) + raise InvalidTypeError('item', item, supported_item_classes) set_xml_value(item_ids, item, version=self.protocol.version) - if not len(item_ids): - raise ValueError('"items" must not be empty') - convertid.append(item_ids) - return convertid + payload.append(item_ids) + return payload @classmethod def _get_elements_in_container(cls, container): @@ -158,6 +147,10 @@

      Class variables

      +
      var cls_map
      +
      +
      +
      var supported_from
      @@ -176,7 +169,7 @@

      Methods

      def call(self, items, destination_format):
           if destination_format not in ID_FORMATS:
      -        raise ValueError("'destination_format' %r must be one of %s" % (destination_format, ID_FORMATS))
      +        raise InvalidEnumValue('destination_format', destination_format, ID_FORMATS)
           return self._elems_to_objs(
               self._chunked_get_elements(self.get_payload, items=items, destination_format=destination_format)
           )
      @@ -193,16 +186,14 @@

      Methods

      def get_payload(self, items, destination_format):
           supported_item_classes = AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId
      -    convertid = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(DestinationFormat=destination_format))
      +    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DestinationFormat=destination_format))
           item_ids = create_element('m:SourceIds')
           for item in items:
               if not isinstance(item, supported_item_classes):
      -            raise ValueError("'item' value %r must be an instance of %r" % (item, supported_item_classes))
      +            raise InvalidTypeError('item', item, supported_item_classes)
               set_xml_value(item_ids, item, version=self.protocol.version)
      -    if not len(item_ids):
      -        raise ValueError('"items" must not be empty')
      -    convertid.append(item_ids)
      -    return convertid
      + payload.append(item_ids) + return payload
      @@ -239,6 +230,7 @@

    • SERVICE_NAME
    • call
    • +
    • cls_map
    • get_payload
    • supported_from
    diff --git a/docs/exchangelib/services/create_attachment.html b/docs/exchangelib/services/create_attachment.html index aa611437..f28e5b5d 100644 --- a/docs/exchangelib/services/create_attachment.html +++ b/docs/exchangelib/services/create_attachment.html @@ -27,6 +27,8 @@

    Module exchangelib.services.create_attachment

    Expand source code
    from .common import EWSAccountService, to_item_id
    +from ..attachments import FileAttachment, ItemAttachment
    +from ..items import BaseItem
     from ..properties import ParentItemId
     from ..util import create_element, set_xml_value, MNS
     
    @@ -37,33 +39,25 @@ 

    Module exchangelib.services.create_attachment

    @@ -94,33 +88,25 @@

    Classes

    """ SERVICE_NAME = 'CreateAttachment' - element_container_name = '{%s}Attachments' % MNS + element_container_name = f'{{{MNS}}}Attachments' + cls_map = {cls.response_tag(): cls for cls in (FileAttachment, ItemAttachment)} def call(self, parent_item, items): return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, parent_item=parent_item)) - def _elems_to_objs(self, elems): - from ..attachments import FileAttachment, ItemAttachment - cls_map = {cls.response_tag(): cls for cls in (FileAttachment, ItemAttachment)} - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield cls_map[elem.tag].from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return self.cls_map[elem.tag].from_xml(elem=elem, account=self.account) def get_payload(self, items, parent_item): - from ..items import BaseItem - payload = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') version = self.account.version if isinstance(parent_item, BaseItem): # to_item_id() would convert this to a normal ItemId, but the service wants a ParentItemId parent_item = ParentItemId(parent_item.id, parent_item.changekey) - set_xml_value(payload, to_item_id(parent_item, ParentItemId, version=version), version=version) + set_xml_value(payload, to_item_id(parent_item, ParentItemId), version=self.account.version) attachments = create_element('m:Attachments') for item in items: - set_xml_value(attachments, item, version=self.account.version) - if not len(attachments): - raise ValueError('"items" must not be empty') + set_xml_value(attachments, item, version=version) payload.append(attachments) return payload
    @@ -135,6 +121,10 @@

    Class variables

    +
    var cls_map
    +
    +
    +
    var element_container_name
    @@ -165,18 +155,15 @@

    Methods

    Expand source code
    def get_payload(self, items, parent_item):
    -    from ..items import BaseItem
    -    payload = create_element('m:%s' % self.SERVICE_NAME)
    +    payload = create_element(f'm:{self.SERVICE_NAME}')
         version = self.account.version
         if isinstance(parent_item, BaseItem):
             # to_item_id() would convert this to a normal ItemId, but the service wants a ParentItemId
             parent_item = ParentItemId(parent_item.id, parent_item.changekey)
    -    set_xml_value(payload, to_item_id(parent_item, ParentItemId, version=version), version=version)
    +    set_xml_value(payload, to_item_id(parent_item, ParentItemId), version=self.account.version)
         attachments = create_element('m:Attachments')
         for item in items:
    -        set_xml_value(attachments, item, version=self.account.version)
    -    if not len(attachments):
    -        raise ValueError('"items" must not be empty')
    +        set_xml_value(attachments, item, version=version)
         payload.append(attachments)
         return payload
    @@ -215,6 +202,7 @@

  • SERVICE_NAME
  • call
  • +
  • cls_map
  • element_container_name
  • get_payload
  • diff --git a/docs/exchangelib/services/create_folder.html b/docs/exchangelib/services/create_folder.html index e4ab4ffd..7122a1b9 100644 --- a/docs/exchangelib/services/create_folder.html +++ b/docs/exchangelib/services/create_folder.html @@ -26,15 +26,19 @@

    Module exchangelib.services.create_folder

    Expand source code -
    from .common import EWSAccountService, parse_folder_elem, create_folder_ids_element
    -from ..util import create_element, set_xml_value, MNS
    +
    from .common import EWSAccountService, parse_folder_elem, folder_ids_element
    +from ..errors import ErrorFolderExists
    +from ..util import create_element, MNS
     
     
     class CreateFolder(EWSAccountService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createfolder-operation"""
     
         SERVICE_NAME = 'CreateFolder'
    -    element_container_name = '{%s}Folders' % MNS
    +    element_container_name = f'{{{MNS}}}Folders'
    +    ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (
    +        ErrorFolderExists,
    +    )
     
         def __init__(self, *args, **kwargs):
             super().__init__(*args, **kwargs)
    @@ -56,13 +60,12 @@ 

    Module exchangelib.services.create_folder

    yield parse_folder_elem(elem=elem, folder=folder, account=self.account) def get_payload(self, folders, parent_folder): - create_folder = create_element('m:%s' % self.SERVICE_NAME) - parentfolderid = create_element('m:ParentFolderId') - set_xml_value(parentfolderid, parent_folder, version=self.account.version) - set_xml_value(create_folder, parentfolderid, version=self.account.version) - folder_ids = create_folder_ids_element(tag='m:Folders', folders=folders, version=self.account.version) - create_folder.append(folder_ids) - return create_folder
    + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append( + folder_ids_element(folders=[parent_folder], version=self.account.version, tag='m:ParentFolderId') + ) + payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:Folders')) + return payload
    @@ -88,7 +91,10 @@

    Classes

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createfolder-operation""" SERVICE_NAME = 'CreateFolder' - element_container_name = '{%s}Folders' % MNS + element_container_name = f'{{{MNS}}}Folders' + ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + ( + ErrorFolderExists, + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -110,13 +116,12 @@

    Classes

    yield parse_folder_elem(elem=elem, folder=folder, account=self.account) def get_payload(self, folders, parent_folder): - create_folder = create_element('m:%s' % self.SERVICE_NAME) - parentfolderid = create_element('m:ParentFolderId') - set_xml_value(parentfolderid, parent_folder, version=self.account.version) - set_xml_value(create_folder, parentfolderid, version=self.account.version) - folder_ids = create_folder_ids_element(tag='m:Folders', folders=folders, version=self.account.version) - create_folder.append(folder_ids) - return create_folder
    + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append( + folder_ids_element(folders=[parent_folder], version=self.account.version, tag='m:ParentFolderId') + ) + payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:Folders')) + return payload

    Ancestors

      @@ -125,6 +130,10 @@

      Ancestors

    Class variables

    +
    var ERRORS_TO_CATCH_IN_RESPONSE
    +
    +
    +
    var SERVICE_NAME
    @@ -164,13 +173,12 @@

    Methods

    Expand source code
    def get_payload(self, folders, parent_folder):
    -    create_folder = create_element('m:%s' % self.SERVICE_NAME)
    -    parentfolderid = create_element('m:ParentFolderId')
    -    set_xml_value(parentfolderid, parent_folder, version=self.account.version)
    -    set_xml_value(create_folder, parentfolderid, version=self.account.version)
    -    folder_ids = create_folder_ids_element(tag='m:Folders', folders=folders, version=self.account.version)
    -    create_folder.append(folder_ids)
    -    return create_folder
    + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append( + folder_ids_element(folders=[parent_folder], version=self.account.version, tag='m:ParentFolderId') + ) + payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:Folders')) + return payload
    @@ -205,6 +213,7 @@

    Index

  • CreateFolder

      +
    • ERRORS_TO_CATCH_IN_RESPONSE
    • SERVICE_NAME
    • call
    • element_container_name
    • diff --git a/docs/exchangelib/services/create_item.html b/docs/exchangelib/services/create_item.html index 7f14def2..88a944d0 100644 --- a/docs/exchangelib/services/create_item.html +++ b/docs/exchangelib/services/create_item.html @@ -26,9 +26,12 @@

      Module exchangelib.services.create_item

      Expand source code -
      from collections import OrderedDict
      -
      -from .common import EWSAccountService
      +
      from .common import EWSAccountService, folder_ids_element
      +from ..errors import InvalidEnumValue, InvalidTypeError
      +from ..folders import BaseFolder
      +from ..items import SAVE_ONLY, SEND_AND_SAVE_COPY, SEND_ONLY, \
      +    SEND_MEETING_INVITATIONS_CHOICES, MESSAGE_DISPOSITION_CHOICES, BulkCreateResult
      +from ..properties import FolderId
       from ..util import create_element, set_xml_value, MNS
       
       
      @@ -40,25 +43,20 @@ 

      Module exchangelib.services.create_item

      """ SERVICE_NAME = 'CreateItem' - element_container_name = '{%s}Items' % MNS + element_container_name = f'{{{MNS}}}Items' def call(self, items, folder, message_disposition, send_meeting_invitations): - from ..folders import BaseFolder, FolderId - from ..items import SAVE_ONLY, SEND_AND_SAVE_COPY, SEND_ONLY, \ - SEND_MEETING_INVITATIONS_CHOICES, MESSAGE_DISPOSITION_CHOICES if message_disposition not in MESSAGE_DISPOSITION_CHOICES: - raise ValueError("'message_disposition' %s must be one of %s" % ( - message_disposition, MESSAGE_DISPOSITION_CHOICES - )) + raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES) if send_meeting_invitations not in SEND_MEETING_INVITATIONS_CHOICES: - raise ValueError("'send_meeting_invitations' %s must be one of %s" % ( - send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES - )) + raise InvalidEnumValue( + 'send_meeting_invitations', send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES + ) if folder is not None: if not isinstance(folder, (BaseFolder, FolderId)): - raise ValueError("'folder' %r must be a Folder or FolderId instance" % folder) + raise InvalidTypeError('folder', folder, (BaseFolder, FolderId)) if folder.account != self.account: - raise ValueError('"Folder must belong to this account') + raise ValueError('Folder must belong to account') if message_disposition == SAVE_ONLY and folder is None: raise AttributeError("Folder must be supplied when in save-only mode") if message_disposition == SEND_AND_SAVE_COPY and folder is None: @@ -73,16 +71,10 @@

      Module exchangelib.services.create_item

      send_meeting_invitations=send_meeting_invitations, )) - def _elems_to_objs(self, elems): - from ..items import BulkCreateResult - for elem in elems: - if isinstance(elem, (Exception, type(None))): - yield elem - continue - if isinstance(elem, bool): - yield elem - continue - yield BulkCreateResult.from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + if isinstance(elem, bool): + return elem + return BulkCreateResult.from_xml(elem=elem, account=self.account) @classmethod def _get_elements_in_container(cls, container): @@ -108,26 +100,21 @@

      Module exchangelib.services.create_item

      :param message_disposition: :param send_meeting_invitations: """ - createitem = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('MessageDisposition', message_disposition), - ('SendMeetingInvitations', send_meeting_invitations), - ]) + payload = create_element( + f'm:{self.SERVICE_NAME}', + attrs=dict(MessageDisposition=message_disposition, SendMeetingInvitations=send_meeting_invitations) ) if folder: - saveditemfolderid = create_element('m:SavedItemFolderId') - set_xml_value(saveditemfolderid, folder, version=self.account.version) - createitem.append(saveditemfolderid) + payload.append(folder_ids_element( + folders=[folder], version=self.account.version, tag='m:SavedItemFolderId' + )) item_elems = create_element('m:Items') for item in items: if not item.account: item.account = self.account set_xml_value(item_elems, item, version=self.account.version) - if not len(item_elems): - raise ValueError('"items" must not be empty') - createitem.append(item_elems) - return createitem
      + payload.append(item_elems) + return payload
  • @@ -159,25 +146,20 @@

    Classes

    """ SERVICE_NAME = 'CreateItem' - element_container_name = '{%s}Items' % MNS + element_container_name = f'{{{MNS}}}Items' def call(self, items, folder, message_disposition, send_meeting_invitations): - from ..folders import BaseFolder, FolderId - from ..items import SAVE_ONLY, SEND_AND_SAVE_COPY, SEND_ONLY, \ - SEND_MEETING_INVITATIONS_CHOICES, MESSAGE_DISPOSITION_CHOICES if message_disposition not in MESSAGE_DISPOSITION_CHOICES: - raise ValueError("'message_disposition' %s must be one of %s" % ( - message_disposition, MESSAGE_DISPOSITION_CHOICES - )) + raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES) if send_meeting_invitations not in SEND_MEETING_INVITATIONS_CHOICES: - raise ValueError("'send_meeting_invitations' %s must be one of %s" % ( - send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES - )) + raise InvalidEnumValue( + 'send_meeting_invitations', send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES + ) if folder is not None: if not isinstance(folder, (BaseFolder, FolderId)): - raise ValueError("'folder' %r must be a Folder or FolderId instance" % folder) + raise InvalidTypeError('folder', folder, (BaseFolder, FolderId)) if folder.account != self.account: - raise ValueError('"Folder must belong to this account') + raise ValueError('Folder must belong to account') if message_disposition == SAVE_ONLY and folder is None: raise AttributeError("Folder must be supplied when in save-only mode") if message_disposition == SEND_AND_SAVE_COPY and folder is None: @@ -192,16 +174,10 @@

    Classes

    send_meeting_invitations=send_meeting_invitations, )) - def _elems_to_objs(self, elems): - from ..items import BulkCreateResult - for elem in elems: - if isinstance(elem, (Exception, type(None))): - yield elem - continue - if isinstance(elem, bool): - yield elem - continue - yield BulkCreateResult.from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + if isinstance(elem, bool): + return elem + return BulkCreateResult.from_xml(elem=elem, account=self.account) @classmethod def _get_elements_in_container(cls, container): @@ -227,26 +203,21 @@

    Classes

    :param message_disposition: :param send_meeting_invitations: """ - createitem = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('MessageDisposition', message_disposition), - ('SendMeetingInvitations', send_meeting_invitations), - ]) + payload = create_element( + f'm:{self.SERVICE_NAME}', + attrs=dict(MessageDisposition=message_disposition, SendMeetingInvitations=send_meeting_invitations) ) if folder: - saveditemfolderid = create_element('m:SavedItemFolderId') - set_xml_value(saveditemfolderid, folder, version=self.account.version) - createitem.append(saveditemfolderid) + payload.append(folder_ids_element( + folders=[folder], version=self.account.version, tag='m:SavedItemFolderId' + )) item_elems = create_element('m:Items') for item in items: if not item.account: item.account = self.account set_xml_value(item_elems, item, version=self.account.version) - if not len(item_elems): - raise ValueError('"items" must not be empty') - createitem.append(item_elems) - return createitem
    + payload.append(item_elems) + return payload

    Ancestors

      @@ -276,22 +247,17 @@

      Methods

      Expand source code
      def call(self, items, folder, message_disposition, send_meeting_invitations):
      -    from ..folders import BaseFolder, FolderId
      -    from ..items import SAVE_ONLY, SEND_AND_SAVE_COPY, SEND_ONLY, \
      -        SEND_MEETING_INVITATIONS_CHOICES, MESSAGE_DISPOSITION_CHOICES
           if message_disposition not in MESSAGE_DISPOSITION_CHOICES:
      -        raise ValueError("'message_disposition' %s must be one of %s" % (
      -            message_disposition, MESSAGE_DISPOSITION_CHOICES
      -        ))
      +        raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES)
           if send_meeting_invitations not in SEND_MEETING_INVITATIONS_CHOICES:
      -        raise ValueError("'send_meeting_invitations' %s must be one of %s" % (
      -            send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES
      -        ))
      +        raise InvalidEnumValue(
      +            'send_meeting_invitations', send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES
      +        )
           if folder is not None:
               if not isinstance(folder, (BaseFolder, FolderId)):
      -            raise ValueError("'folder' %r must be a Folder or FolderId instance" % folder)
      +            raise InvalidTypeError('folder', folder, (BaseFolder, FolderId))
               if folder.account != self.account:
      -            raise ValueError('"Folder must belong to this account')
      +            raise ValueError('Folder must belong to account')
           if message_disposition == SAVE_ONLY and folder is None:
               raise AttributeError("Folder must be supplied when in save-only mode")
           if message_disposition == SEND_AND_SAVE_COPY and folder is None:
      @@ -348,26 +314,21 @@ 

      Methods

      :param message_disposition: :param send_meeting_invitations: """ - createitem = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('MessageDisposition', message_disposition), - ('SendMeetingInvitations', send_meeting_invitations), - ]) + payload = create_element( + f'm:{self.SERVICE_NAME}', + attrs=dict(MessageDisposition=message_disposition, SendMeetingInvitations=send_meeting_invitations) ) if folder: - saveditemfolderid = create_element('m:SavedItemFolderId') - set_xml_value(saveditemfolderid, folder, version=self.account.version) - createitem.append(saveditemfolderid) + payload.append(folder_ids_element( + folders=[folder], version=self.account.version, tag='m:SavedItemFolderId' + )) item_elems = create_element('m:Items') for item in items: if not item.account: item.account = self.account set_xml_value(item_elems, item, version=self.account.version) - if not len(item_elems): - raise ValueError('"items" must not be empty') - createitem.append(item_elems) - return createitem
      + payload.append(item_elems) + return payload

    diff --git a/docs/exchangelib/services/create_user_configuration.html b/docs/exchangelib/services/create_user_configuration.html index 9c026167..81b66f16 100644 --- a/docs/exchangelib/services/create_user_configuration.html +++ b/docs/exchangelib/services/create_user_configuration.html @@ -42,9 +42,9 @@

    Module exchangelib.services.create_user_configuration + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), user_configuration, version=self.protocol.version + )
    @@ -79,9 +79,9 @@

    Classes

    return self._get_elements(payload=self.get_payload(user_configuration=user_configuration)) def get_payload(self, user_configuration): - createuserconfiguration = create_element('m:%s' % self.SERVICE_NAME) - set_xml_value(createuserconfiguration, user_configuration, version=self.protocol.version) - return createuserconfiguration + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), user_configuration, version=self.protocol.version + )

    Ancestors

      @@ -124,9 +124,9 @@

      Methods

      Expand source code
      def get_payload(self, user_configuration):
      -    createuserconfiguration = create_element('m:%s' % self.SERVICE_NAME)
      -    set_xml_value(createuserconfiguration, user_configuration, version=self.protocol.version)
      -    return createuserconfiguration
      + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), user_configuration, version=self.protocol.version + ) diff --git a/docs/exchangelib/services/delete_attachment.html b/docs/exchangelib/services/delete_attachment.html index 85954913..093df9cb 100644 --- a/docs/exchangelib/services/delete_attachment.html +++ b/docs/exchangelib/services/delete_attachment.html @@ -26,9 +26,9 @@

      Module exchangelib.services.delete_attachment

      Expand source code -
      from .common import EWSAccountService, create_attachment_ids_element
      +
      from .common import EWSAccountService, attachment_ids_element
       from ..properties import RootItemId
      -from ..util import create_element
      +from ..util import create_element, set_xml_value
       
       
       class DeleteAttachment(EWSAccountService):
      @@ -41,22 +41,19 @@ 

      Module exchangelib.services.delete_attachment

      + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), + attachment_ids_element(items=items, version=self.account.version), + version=self.account.version + )
    @@ -89,22 +86,19 @@

    Classes

    def call(self, items): return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items)) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield RootItemId.from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return RootItemId.from_xml(elem=elem, account=self.account) @classmethod def _get_elements_in_container(cls, container): return container.findall(RootItemId.response_tag()) def get_payload(self, items): - payload = create_element('m:%s' % self.SERVICE_NAME) - attachment_ids = create_attachment_ids_element(items=items, version=self.account.version) - payload.append(attachment_ids) - return payload
    + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), + attachment_ids_element(items=items, version=self.account.version), + version=self.account.version + )

    Ancestors

      @@ -143,10 +137,11 @@

      Methods

      Expand source code
      def get_payload(self, items):
      -    payload = create_element('m:%s' % self.SERVICE_NAME)
      -    attachment_ids = create_attachment_ids_element(items=items, version=self.account.version)
      -    payload.append(attachment_ids)
      -    return payload
      + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), + attachment_ids_element(items=items, version=self.account.version), + version=self.account.version + ) diff --git a/docs/exchangelib/services/delete_folder.html b/docs/exchangelib/services/delete_folder.html index 8d309e8b..50875946 100644 --- a/docs/exchangelib/services/delete_folder.html +++ b/docs/exchangelib/services/delete_folder.html @@ -26,7 +26,9 @@

      Module exchangelib.services.delete_folder

      Expand source code -
      from .common import EWSAccountService, create_folder_ids_element
      +
      from .common import EWSAccountService, folder_ids_element
      +from ..errors import InvalidEnumValue
      +from ..items import DELETE_TYPE_CHOICES
       from ..util import create_element
       
       
      @@ -37,13 +39,14 @@ 

      Module exchangelib.services.delete_folder

      returns_elements = False def call(self, folders, delete_type): + if delete_type not in DELETE_TYPE_CHOICES: + raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES) return self._chunked_get_elements(self.get_payload, items=folders, delete_type=delete_type) def get_payload(self, folders, delete_type): - deletefolder = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(DeleteType=delete_type)) - folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version) - deletefolder.append(folder_ids) - return deletefolder
      + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DeleteType=delete_type)) + payload.append(folder_ids_element(folders=folders, version=self.account.version)) + return payload
    @@ -72,13 +75,14 @@

    Classes

    returns_elements = False def call(self, folders, delete_type): + if delete_type not in DELETE_TYPE_CHOICES: + raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES) return self._chunked_get_elements(self.get_payload, items=folders, delete_type=delete_type) def get_payload(self, folders, delete_type): - deletefolder = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(DeleteType=delete_type)) - folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version) - deletefolder.append(folder_ids) - return deletefolder + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DeleteType=delete_type)) + payload.append(folder_ids_element(folders=folders, version=self.account.version)) + return payload

    Ancestors

      @@ -108,6 +112,8 @@

      Methods

      Expand source code
      def call(self, folders, delete_type):
      +    if delete_type not in DELETE_TYPE_CHOICES:
      +        raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES)
           return self._chunked_get_elements(self.get_payload, items=folders, delete_type=delete_type)
      @@ -121,10 +127,9 @@

      Methods

      Expand source code
      def get_payload(self, folders, delete_type):
      -    deletefolder = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(DeleteType=delete_type))
      -    folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version)
      -    deletefolder.append(folder_ids)
      -    return deletefolder
      + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DeleteType=delete_type)) + payload.append(folder_ids_element(folders=folders, version=self.account.version)) + return payload diff --git a/docs/exchangelib/services/delete_item.html b/docs/exchangelib/services/delete_item.html index 22d10ebe..c5e1af14 100644 --- a/docs/exchangelib/services/delete_item.html +++ b/docs/exchangelib/services/delete_item.html @@ -26,9 +26,9 @@

      Module exchangelib.services.delete_item

      Expand source code -
      from collections import OrderedDict
      -
      -from .common import EWSAccountService, create_item_ids_element
      +
      from .common import EWSAccountService, item_ids_element
      +from ..errors import InvalidEnumValue
      +from ..items import DELETE_TYPE_CHOICES, SEND_MEETING_CANCELLATIONS_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES
       from ..util import create_element
       from ..version import EXCHANGE_2013_SP1
       
      @@ -44,21 +44,16 @@ 

      Module exchangelib.services.delete_item

      returns_elements = False def call(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): - from ..items import DELETE_TYPE_CHOICES, SEND_MEETING_CANCELLATIONS_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES if delete_type not in DELETE_TYPE_CHOICES: - raise ValueError("'delete_type' %s must be one of %s" % ( - delete_type, DELETE_TYPE_CHOICES - )) + raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES) if send_meeting_cancellations not in SEND_MEETING_CANCELLATIONS_CHOICES: - raise ValueError("'send_meeting_cancellations' %s must be one of %s" % ( - send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES - )) + raise InvalidEnumValue( + 'send_meeting_cancellations', send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES + ) if affected_task_occurrences not in AFFECTED_TASK_OCCURRENCES_CHOICES: - raise ValueError("'affected_task_occurrences' %s must be one of %s" % ( - affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES - )) - if suppress_read_receipts not in (True, False): - raise ValueError("'suppress_read_receipts' %s must be True or False" % suppress_read_receipts) + raise InvalidEnumValue( + 'affected_task_occurrences', affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES + ) return self._chunked_get_elements( self.get_payload, items=items, @@ -71,29 +66,16 @@

      Module exchangelib.services.delete_item

      def get_payload(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): # Takes a list of (id, changekey) tuples or Item objects and returns the XML for a DeleteItem request. + attrs = dict( + DeleteType=delete_type, + SendMeetingCancellations=send_meeting_cancellations, + AffectedTaskOccurrences=affected_task_occurrences, + ) if self.account.version.build >= EXCHANGE_2013_SP1: - deleteitem = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('DeleteType', delete_type), - ('SendMeetingCancellations', send_meeting_cancellations), - ('AffectedTaskOccurrences', affected_task_occurrences), - ('SuppressReadReceipts', 'true' if suppress_read_receipts else 'false'), - ]) - ) - else: - deleteitem = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('DeleteType', delete_type), - ('SendMeetingCancellations', send_meeting_cancellations), - ('AffectedTaskOccurrences', affected_task_occurrences), - ]) - ) - - item_ids = create_item_ids_element(items=items, version=self.account.version) - deleteitem.append(item_ids) - return deleteitem
      + attrs['SuppressReadReceipts'] = suppress_read_receipts + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload
    @@ -128,21 +110,16 @@

    Classes

    returns_elements = False def call(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): - from ..items import DELETE_TYPE_CHOICES, SEND_MEETING_CANCELLATIONS_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES if delete_type not in DELETE_TYPE_CHOICES: - raise ValueError("'delete_type' %s must be one of %s" % ( - delete_type, DELETE_TYPE_CHOICES - )) + raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES) if send_meeting_cancellations not in SEND_MEETING_CANCELLATIONS_CHOICES: - raise ValueError("'send_meeting_cancellations' %s must be one of %s" % ( - send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES - )) + raise InvalidEnumValue( + 'send_meeting_cancellations', send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES + ) if affected_task_occurrences not in AFFECTED_TASK_OCCURRENCES_CHOICES: - raise ValueError("'affected_task_occurrences' %s must be one of %s" % ( - affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES - )) - if suppress_read_receipts not in (True, False): - raise ValueError("'suppress_read_receipts' %s must be True or False" % suppress_read_receipts) + raise InvalidEnumValue( + 'affected_task_occurrences', affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES + ) return self._chunked_get_elements( self.get_payload, items=items, @@ -155,29 +132,16 @@

    Classes

    def get_payload(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): # Takes a list of (id, changekey) tuples or Item objects and returns the XML for a DeleteItem request. + attrs = dict( + DeleteType=delete_type, + SendMeetingCancellations=send_meeting_cancellations, + AffectedTaskOccurrences=affected_task_occurrences, + ) if self.account.version.build >= EXCHANGE_2013_SP1: - deleteitem = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('DeleteType', delete_type), - ('SendMeetingCancellations', send_meeting_cancellations), - ('AffectedTaskOccurrences', affected_task_occurrences), - ('SuppressReadReceipts', 'true' if suppress_read_receipts else 'false'), - ]) - ) - else: - deleteitem = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('DeleteType', delete_type), - ('SendMeetingCancellations', send_meeting_cancellations), - ('AffectedTaskOccurrences', affected_task_occurrences), - ]) - ) - - item_ids = create_item_ids_element(items=items, version=self.account.version) - deleteitem.append(item_ids) - return deleteitem + attrs['SuppressReadReceipts'] = suppress_read_receipts + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload

    Ancestors

      @@ -207,21 +171,16 @@

      Methods

      Expand source code
      def call(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts):
      -    from ..items import DELETE_TYPE_CHOICES, SEND_MEETING_CANCELLATIONS_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES
           if delete_type not in DELETE_TYPE_CHOICES:
      -        raise ValueError("'delete_type' %s must be one of %s" % (
      -            delete_type, DELETE_TYPE_CHOICES
      -        ))
      +        raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES)
           if send_meeting_cancellations not in SEND_MEETING_CANCELLATIONS_CHOICES:
      -        raise ValueError("'send_meeting_cancellations' %s must be one of %s" % (
      -            send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES
      -        ))
      +        raise InvalidEnumValue(
      +            'send_meeting_cancellations', send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES
      +        )
           if affected_task_occurrences not in AFFECTED_TASK_OCCURRENCES_CHOICES:
      -        raise ValueError("'affected_task_occurrences' %s must be one of %s" % (
      -            affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES
      -        ))
      -    if suppress_read_receipts not in (True, False):
      -        raise ValueError("'suppress_read_receipts' %s must be True or False" % suppress_read_receipts)
      +        raise InvalidEnumValue(
      +            'affected_task_occurrences', affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES
      +        )
           return self._chunked_get_elements(
               self.get_payload,
               items=items,
      @@ -244,29 +203,16 @@ 

      Methods

      def get_payload(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences,
                       suppress_read_receipts):
           # Takes a list of (id, changekey) tuples or Item objects and returns the XML for a DeleteItem request.
      +    attrs = dict(
      +        DeleteType=delete_type,
      +        SendMeetingCancellations=send_meeting_cancellations,
      +        AffectedTaskOccurrences=affected_task_occurrences,
      +    )
           if self.account.version.build >= EXCHANGE_2013_SP1:
      -        deleteitem = create_element(
      -            'm:%s' % self.SERVICE_NAME,
      -            attrs=OrderedDict([
      -                ('DeleteType', delete_type),
      -                ('SendMeetingCancellations', send_meeting_cancellations),
      -                ('AffectedTaskOccurrences', affected_task_occurrences),
      -                ('SuppressReadReceipts', 'true' if suppress_read_receipts else 'false'),
      -            ])
      -        )
      -    else:
      -        deleteitem = create_element(
      -            'm:%s' % self.SERVICE_NAME,
      -            attrs=OrderedDict([
      -                ('DeleteType', delete_type),
      -                ('SendMeetingCancellations', send_meeting_cancellations),
      -                ('AffectedTaskOccurrences', affected_task_occurrences),
      -             ])
      -        )
      -
      -    item_ids = create_item_ids_element(items=items, version=self.account.version)
      -    deleteitem.append(item_ids)
      -    return deleteitem
      + attrs['SuppressReadReceipts'] = suppress_read_receipts + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload
      diff --git a/docs/exchangelib/services/delete_user_configuration.html b/docs/exchangelib/services/delete_user_configuration.html index a343b22e..33ed2fe1 100644 --- a/docs/exchangelib/services/delete_user_configuration.html +++ b/docs/exchangelib/services/delete_user_configuration.html @@ -42,9 +42,9 @@

      Module exchangelib.services.delete_user_configuration + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), user_configuration_name, version=self.account.version + )

    @@ -79,9 +79,9 @@

    Classes

    return self._get_elements(payload=self.get_payload(user_configuration_name=user_configuration_name)) def get_payload(self, user_configuration_name): - deleteuserconfiguration = create_element('m:%s' % self.SERVICE_NAME) - set_xml_value(deleteuserconfiguration, user_configuration_name, version=self.account.version) - return deleteuserconfiguration + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), user_configuration_name, version=self.account.version + )

    Ancestors

      @@ -124,9 +124,9 @@

      Methods

      Expand source code
      def get_payload(self, user_configuration_name):
      -    deleteuserconfiguration = create_element('m:%s' % self.SERVICE_NAME)
      -    set_xml_value(deleteuserconfiguration, user_configuration_name, version=self.account.version)
      -    return deleteuserconfiguration
      + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), user_configuration_name, version=self.account.version + ) diff --git a/docs/exchangelib/services/empty_folder.html b/docs/exchangelib/services/empty_folder.html index bc4ed42d..b52a63cb 100644 --- a/docs/exchangelib/services/empty_folder.html +++ b/docs/exchangelib/services/empty_folder.html @@ -26,9 +26,9 @@

      Module exchangelib.services.empty_folder

      Expand source code -
      from collections import OrderedDict
      -
      -from .common import EWSAccountService, create_folder_ids_element
      +
      from .common import EWSAccountService, folder_ids_element
      +from ..errors import InvalidEnumValue
      +from ..items import DELETE_TYPE_CHOICES
       from ..util import create_element
       
       
      @@ -39,21 +39,19 @@ 

      Module exchangelib.services.empty_folder

      returns_elements = False def call(self, folders, delete_type, delete_sub_folders): + if delete_type not in DELETE_TYPE_CHOICES: + raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES) return self._chunked_get_elements( self.get_payload, items=folders, delete_type=delete_type, delete_sub_folders=delete_sub_folders ) def get_payload(self, folders, delete_type, delete_sub_folders): - emptyfolder = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('DeleteType', delete_type), - ('DeleteSubFolders', 'true' if delete_sub_folders else 'false'), - ]) + payload = create_element( + f'm:{self.SERVICE_NAME}', + attrs=dict(DeleteType=delete_type, DeleteSubFolders=delete_sub_folders) ) - folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version) - emptyfolder.append(folder_ids) - return emptyfolder
      + payload.append(folder_ids_element(folders=folders, version=self.account.version)) + return payload
    @@ -82,21 +80,19 @@

    Classes

    returns_elements = False def call(self, folders, delete_type, delete_sub_folders): + if delete_type not in DELETE_TYPE_CHOICES: + raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES) return self._chunked_get_elements( self.get_payload, items=folders, delete_type=delete_type, delete_sub_folders=delete_sub_folders ) def get_payload(self, folders, delete_type, delete_sub_folders): - emptyfolder = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('DeleteType', delete_type), - ('DeleteSubFolders', 'true' if delete_sub_folders else 'false'), - ]) + payload = create_element( + f'm:{self.SERVICE_NAME}', + attrs=dict(DeleteType=delete_type, DeleteSubFolders=delete_sub_folders) ) - folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version) - emptyfolder.append(folder_ids) - return emptyfolder + payload.append(folder_ids_element(folders=folders, version=self.account.version)) + return payload

    Ancestors

      @@ -126,6 +122,8 @@

      Methods

      Expand source code
      def call(self, folders, delete_type, delete_sub_folders):
      +    if delete_type not in DELETE_TYPE_CHOICES:
      +        raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES)
           return self._chunked_get_elements(
               self.get_payload, items=folders, delete_type=delete_type, delete_sub_folders=delete_sub_folders
           )
      @@ -141,16 +139,12 @@

      Methods

      Expand source code
      def get_payload(self, folders, delete_type, delete_sub_folders):
      -    emptyfolder = create_element(
      -        'm:%s' % self.SERVICE_NAME,
      -        attrs=OrderedDict([
      -            ('DeleteType', delete_type),
      -            ('DeleteSubFolders', 'true' if delete_sub_folders else 'false'),
      -        ])
      +    payload = create_element(
      +        f'm:{self.SERVICE_NAME}',
      +        attrs=dict(DeleteType=delete_type, DeleteSubFolders=delete_sub_folders)
           )
      -    folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version)
      -    emptyfolder.append(folder_ids)
      -    return emptyfolder
      + payload.append(folder_ids_element(folders=folders, version=self.account.version)) + return payload diff --git a/docs/exchangelib/services/expand_dl.html b/docs/exchangelib/services/expand_dl.html index 1684c363..c0591b64 100644 --- a/docs/exchangelib/services/expand_dl.html +++ b/docs/exchangelib/services/expand_dl.html @@ -36,23 +36,17 @@

      Module exchangelib.services.expand_dl

      """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/expanddl-operation""" SERVICE_NAME = 'ExpandDL' - element_container_name = '{%s}DLExpansion' % MNS + element_container_name = f'{{{MNS}}}DLExpansion' WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults def call(self, distribution_list): return self._elems_to_objs(self._get_elements(payload=self.get_payload(distribution_list=distribution_list))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Mailbox.from_xml(elem, account=None) + def _elem_to_obj(self, elem): + return Mailbox.from_xml(elem, account=None) def get_payload(self, distribution_list): - payload = create_element('m:%s' % self.SERVICE_NAME) - set_xml_value(payload, distribution_list, version=self.protocol.version) - return payload + return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), distribution_list, version=self.protocol.version)
    @@ -78,23 +72,17 @@

    Classes

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/expanddl-operation""" SERVICE_NAME = 'ExpandDL' - element_container_name = '{%s}DLExpansion' % MNS + element_container_name = f'{{{MNS}}}DLExpansion' WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults def call(self, distribution_list): return self._elems_to_objs(self._get_elements(payload=self.get_payload(distribution_list=distribution_list))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Mailbox.from_xml(elem, account=None) + def _elem_to_obj(self, elem): + return Mailbox.from_xml(elem, account=None) def get_payload(self, distribution_list): - payload = create_element('m:%s' % self.SERVICE_NAME) - set_xml_value(payload, distribution_list, version=self.protocol.version) - return payload + return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), distribution_list, version=self.protocol.version)

    Ancestors

      @@ -140,9 +128,7 @@

      Methods

      Expand source code
      def get_payload(self, distribution_list):
      -    payload = create_element('m:%s' % self.SERVICE_NAME)
      -    set_xml_value(payload, distribution_list, version=self.protocol.version)
      -    return payload
      + return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), distribution_list, version=self.protocol.version) diff --git a/docs/exchangelib/services/export_items.html b/docs/exchangelib/services/export_items.html index 267dc9e3..9e93423b 100644 --- a/docs/exchangelib/services/export_items.html +++ b/docs/exchangelib/services/export_items.html @@ -26,7 +26,7 @@

      Module exchangelib.services.export_items

      Expand source code -
      from .common import EWSAccountService, create_item_ids_element
      +
      from .common import EWSAccountService, item_ids_element
       from ..errors import ResponseMessageError
       from ..util import create_element, MNS
       
      @@ -36,23 +36,18 @@ 

      Module exchangelib.services.export_items

      ERRORS_TO_CATCH_IN_RESPONSE = ResponseMessageError SERVICE_NAME = 'ExportItems' - element_container_name = '{%s}Data' % MNS + element_container_name = f'{{{MNS}}}Data' def call(self, items): return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items)) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield elem.text # All we want is the 64bit string in the 'Data' tag + def _elem_to_obj(self, elem): + return elem.text # All we want is the 64bit string in the 'Data' tag def get_payload(self, items): - exportitems = create_element('m:%s' % self.SERVICE_NAME) - item_ids = create_item_ids_element(items=items, version=self.account.version) - exportitems.append(item_ids) - return exportitems + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload # We need to override this since ExportItemsResponseMessage is formatted a # little bit differently. . @@ -85,23 +80,18 @@

      Classes

      ERRORS_TO_CATCH_IN_RESPONSE = ResponseMessageError SERVICE_NAME = 'ExportItems' - element_container_name = '{%s}Data' % MNS + element_container_name = f'{{{MNS}}}Data' def call(self, items): return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items)) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield elem.text # All we want is the 64bit string in the 'Data' tag + def _elem_to_obj(self, elem): + return elem.text # All we want is the 64bit string in the 'Data' tag def get_payload(self, items): - exportitems = create_element('m:%s' % self.SERVICE_NAME) - item_ids = create_item_ids_element(items=items, version=self.account.version) - exportitems.append(item_ids) - return exportitems + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload # We need to override this since ExportItemsResponseMessage is formatted a # little bit differently. . @@ -154,10 +144,9 @@

      Methods

      Expand source code
      def get_payload(self, items):
      -    exportitems = create_element('m:%s' % self.SERVICE_NAME)
      -    item_ids = create_item_ids_element(items=items, version=self.account.version)
      -    exportitems.append(item_ids)
      -    return exportitems
      + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload
      diff --git a/docs/exchangelib/services/find_folder.html b/docs/exchangelib/services/find_folder.html index 720aea51..24912407 100644 --- a/docs/exchangelib/services/find_folder.html +++ b/docs/exchangelib/services/find_folder.html @@ -26,19 +26,21 @@

      Module exchangelib.services.find_folder

      Expand source code -
      from collections import OrderedDict
      -
      -from .common import EWSAccountService, create_shape_element
      -from ..util import create_element, set_xml_value, TNS, MNS
      +
      from .common import EWSPagingService, shape_element, folder_ids_element
      +from ..errors import InvalidEnumValue
      +from ..folders import Folder
      +from ..folders.queryset import FOLDER_TRAVERSAL_CHOICES
      +from ..items import SHAPE_CHOICES
      +from ..util import create_element, TNS, MNS
       from ..version import EXCHANGE_2010
       
       
      -class FindFolder(EWSAccountService):
      +class FindFolder(EWSPagingService):
           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/findfolder-operation"""
       
           SERVICE_NAME = 'FindFolder'
      -    element_container_name = '{%s}Folders' % TNS
      -    paging_container_name = '{%s}RootFolder' % MNS
      +    element_container_name = f'{{{TNS}}}Folders'
      +    paging_container_name = f'{{{MNS}}}RootFolder'
           supports_paging = True
       
           def __init__(self, *args, **kwargs):
      @@ -58,9 +60,13 @@ 

      Module exchangelib.services.find_folder

      :return: XML elements for the matching folders """ + if shape not in SHAPE_CHOICES: + raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + if depth not in FOLDER_TRAVERSAL_CHOICES: + raise InvalidEnumValue('depth', depth, FOLDER_TRAVERSAL_CHOICES) roots = {f.root for f in folders} if len(roots) != 1: - raise ValueError('FindFolder must be called with folders in the same root hierarchy (%r)' % roots) + raise ValueError(f"All folders in 'roots' must have the same root hierarchy ({roots})") self.root = roots.pop() return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, @@ -71,44 +77,32 @@

      Module exchangelib.services.find_folder

      restriction=restriction, shape=shape, depth=depth, - page_size=self.chunk_size, + page_size=self.page_size, offset=offset, ) )) - def _elems_to_objs(self, elems): - from ..folders import Folder - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Folder.from_xml_with_root(elem=elem, root=self.root) + def _elem_to_obj(self, elem): + return Folder.from_xml_with_root(elem=elem, root=self.root) def get_payload(self, folders, additional_fields, restriction, shape, depth, page_size, offset=0): - findfolder = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth)) - foldershape = create_shape_element( + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) + payload.append(shape_element( tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version - ) - findfolder.append(foldershape) + )) if self.account.version.build >= EXCHANGE_2010: - indexedpageviewitem = create_element( + indexed_page_folder_view = create_element( 'm:IndexedPageFolderView', - attrs=OrderedDict([ - ('MaxEntriesReturned', str(page_size)), - ('Offset', str(offset)), - ('BasePoint', 'Beginning'), - ]) + attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') ) - findfolder.append(indexedpageviewitem) + payload.append(indexed_page_folder_view) else: if offset != 0: - raise ValueError('Offsets are only supported from Exchange 2010') + raise NotImplementedError("'offset' is only supported for Exchange 2010 servers and later") if restriction: - findfolder.append(restriction.to_xml(version=self.account.version)) - parentfolderids = create_element('m:ParentFolderIds') - set_xml_value(parentfolderids, folders, version=self.account.version) - findfolder.append(parentfolderids) - return findfolder
      + payload.append(restriction.to_xml(version=self.account.version)) + payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds')) + return payload
    @@ -130,12 +124,12 @@

    Classes

    Expand source code -
    class FindFolder(EWSAccountService):
    +
    class FindFolder(EWSPagingService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/findfolder-operation"""
     
         SERVICE_NAME = 'FindFolder'
    -    element_container_name = '{%s}Folders' % TNS
    -    paging_container_name = '{%s}RootFolder' % MNS
    +    element_container_name = f'{{{TNS}}}Folders'
    +    paging_container_name = f'{{{MNS}}}RootFolder'
         supports_paging = True
     
         def __init__(self, *args, **kwargs):
    @@ -155,9 +149,13 @@ 

    Classes

    :return: XML elements for the matching folders """ + if shape not in SHAPE_CHOICES: + raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + if depth not in FOLDER_TRAVERSAL_CHOICES: + raise InvalidEnumValue('depth', depth, FOLDER_TRAVERSAL_CHOICES) roots = {f.root for f in folders} if len(roots) != 1: - raise ValueError('FindFolder must be called with folders in the same root hierarchy (%r)' % roots) + raise ValueError(f"All folders in 'roots' must have the same root hierarchy ({roots})") self.root = roots.pop() return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, @@ -168,47 +166,36 @@

    Classes

    restriction=restriction, shape=shape, depth=depth, - page_size=self.chunk_size, + page_size=self.page_size, offset=offset, ) )) - def _elems_to_objs(self, elems): - from ..folders import Folder - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Folder.from_xml_with_root(elem=elem, root=self.root) + def _elem_to_obj(self, elem): + return Folder.from_xml_with_root(elem=elem, root=self.root) def get_payload(self, folders, additional_fields, restriction, shape, depth, page_size, offset=0): - findfolder = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth)) - foldershape = create_shape_element( + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) + payload.append(shape_element( tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version - ) - findfolder.append(foldershape) + )) if self.account.version.build >= EXCHANGE_2010: - indexedpageviewitem = create_element( + indexed_page_folder_view = create_element( 'm:IndexedPageFolderView', - attrs=OrderedDict([ - ('MaxEntriesReturned', str(page_size)), - ('Offset', str(offset)), - ('BasePoint', 'Beginning'), - ]) + attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') ) - findfolder.append(indexedpageviewitem) + payload.append(indexed_page_folder_view) else: if offset != 0: - raise ValueError('Offsets are only supported from Exchange 2010') + raise NotImplementedError("'offset' is only supported for Exchange 2010 servers and later") if restriction: - findfolder.append(restriction.to_xml(version=self.account.version)) - parentfolderids = create_element('m:ParentFolderIds') - set_xml_value(parentfolderids, folders, version=self.account.version) - findfolder.append(parentfolderids) - return findfolder
    + payload.append(restriction.to_xml(version=self.account.version)) + payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds')) + return payload

    Ancestors

    @@ -263,9 +250,13 @@

    Methods

    :return: XML elements for the matching folders """ + if shape not in SHAPE_CHOICES: + raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + if depth not in FOLDER_TRAVERSAL_CHOICES: + raise InvalidEnumValue('depth', depth, FOLDER_TRAVERSAL_CHOICES) roots = {f.root for f in folders} if len(roots) != 1: - raise ValueError('FindFolder must be called with folders in the same root hierarchy (%r)' % roots) + raise ValueError(f"All folders in 'roots' must have the same root hierarchy ({roots})") self.root = roots.pop() return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, @@ -276,7 +267,7 @@

    Methods

    restriction=restriction, shape=shape, depth=depth, - page_size=self.chunk_size, + page_size=self.page_size, offset=offset, ) ))
    @@ -292,41 +283,34 @@

    Methods

    Expand source code
    def get_payload(self, folders, additional_fields, restriction, shape, depth, page_size, offset=0):
    -    findfolder = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth))
    -    foldershape = create_shape_element(
    +    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth))
    +    payload.append(shape_element(
             tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version
    -    )
    -    findfolder.append(foldershape)
    +    ))
         if self.account.version.build >= EXCHANGE_2010:
    -        indexedpageviewitem = create_element(
    +        indexed_page_folder_view = create_element(
                 'm:IndexedPageFolderView',
    -            attrs=OrderedDict([
    -                ('MaxEntriesReturned', str(page_size)),
    -                ('Offset', str(offset)),
    -                ('BasePoint', 'Beginning'),
    -            ])
    +            attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning')
             )
    -        findfolder.append(indexedpageviewitem)
    +        payload.append(indexed_page_folder_view)
         else:
             if offset != 0:
    -            raise ValueError('Offsets are only supported from Exchange 2010')
    +            raise NotImplementedError("'offset' is only supported for Exchange 2010 servers and later")
         if restriction:
    -        findfolder.append(restriction.to_xml(version=self.account.version))
    -    parentfolderids = create_element('m:ParentFolderIds')
    -    set_xml_value(parentfolderids, folders, version=self.account.version)
    -    findfolder.append(parentfolderids)
    -    return findfolder
    + payload.append(restriction.to_xml(version=self.account.version)) + payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds')) + return payload

    Inherited members

    diff --git a/docs/exchangelib/services/find_item.html b/docs/exchangelib/services/find_item.html index d2f23e50..d0701e68 100644 --- a/docs/exchangelib/services/find_item.html +++ b/docs/exchangelib/services/find_item.html @@ -26,18 +26,19 @@

    Module exchangelib.services.find_item

    Expand source code -
    from collections import OrderedDict
    -
    -from .common import EWSAccountService, create_shape_element
    +
    from .common import EWSPagingService, shape_element, folder_ids_element
    +from ..errors import InvalidEnumValue
    +from ..folders.base import BaseFolder
    +from ..items import Item, ID_ONLY, SHAPE_CHOICES, ITEM_TRAVERSAL_CHOICES
     from ..util import create_element, set_xml_value, TNS, MNS
     
     
    -class FindItem(EWSAccountService):
    +class FindItem(EWSPagingService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/finditem-operation"""
     
         SERVICE_NAME = 'FindItem'
    -    element_container_name = '{%s}Items' % TNS
    -    paging_container_name = '{%s}RootFolder' % MNS
    +    element_container_name = f'{{{TNS}}}Items'
    +    paging_container_name = f'{{{MNS}}}RootFolder'
         supports_paging = True
     
         def __init__(self, *args, **kwargs):
    @@ -63,6 +64,10 @@ 

    Module exchangelib.services.find_item

    :return: XML elements for the matching items """ + if shape not in SHAPE_CHOICES: + raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + if depth not in ITEM_TRAVERSAL_CHOICES: + raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape return self._elems_to_objs(self._paged_call( @@ -77,58 +82,42 @@

    Module exchangelib.services.find_item

    shape=shape, depth=depth, calendar_view=calendar_view, - page_size=self.chunk_size, + page_size=self.page_size, offset=offset, ) )) - def _elems_to_objs(self, elems): - from ..folders.base import BaseFolder - from ..items import Item, ID_ONLY - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - if self.shape == ID_ONLY and self.additional_fields is None: - yield Item.id_from_xml(elem) - continue - yield BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + if self.shape == ID_ONLY and self.additional_fields is None: + return Item.id_from_xml(elem) + return BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, calendar_view, page_size, offset=0): - finditem = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth)) - itemshape = create_shape_element( + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) + payload.append(shape_element( tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version - ) - finditem.append(itemshape) + )) if calendar_view is None: view_type = create_element( 'm:IndexedPageItemView', - attrs=OrderedDict([ - ('MaxEntriesReturned', str(page_size)), - ('Offset', str(offset)), - ('BasePoint', 'Beginning'), - ]) + attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') ) else: view_type = calendar_view.to_xml(version=self.account.version) - finditem.append(view_type) + payload.append(view_type) if restriction: - finditem.append(restriction.to_xml(version=self.account.version)) + payload.append(restriction.to_xml(version=self.account.version)) if order_fields: - finditem.append(set_xml_value( + payload.append(set_xml_value( create_element('m:SortOrder'), order_fields, version=self.account.version )) - finditem.append(set_xml_value( - create_element('m:ParentFolderIds'), - folders, - version=self.account.version - )) + payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds')) if query_string: - finditem.append(query_string.to_xml(version=self.account.version)) - return finditem
    + payload.append(query_string.to_xml(version=self.account.version)) + return payload
    @@ -150,12 +139,12 @@

    Classes

    Expand source code -
    class FindItem(EWSAccountService):
    +
    class FindItem(EWSPagingService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/finditem-operation"""
     
         SERVICE_NAME = 'FindItem'
    -    element_container_name = '{%s}Items' % TNS
    -    paging_container_name = '{%s}RootFolder' % MNS
    +    element_container_name = f'{{{TNS}}}Items'
    +    paging_container_name = f'{{{MNS}}}RootFolder'
         supports_paging = True
     
         def __init__(self, *args, **kwargs):
    @@ -181,6 +170,10 @@ 

    Classes

    :return: XML elements for the matching items """ + if shape not in SHAPE_CHOICES: + raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + if depth not in ITEM_TRAVERSAL_CHOICES: + raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape return self._elems_to_objs(self._paged_call( @@ -195,61 +188,46 @@

    Classes

    shape=shape, depth=depth, calendar_view=calendar_view, - page_size=self.chunk_size, + page_size=self.page_size, offset=offset, ) )) - def _elems_to_objs(self, elems): - from ..folders.base import BaseFolder - from ..items import Item, ID_ONLY - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - if self.shape == ID_ONLY and self.additional_fields is None: - yield Item.id_from_xml(elem) - continue - yield BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + if self.shape == ID_ONLY and self.additional_fields is None: + return Item.id_from_xml(elem) + return BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, calendar_view, page_size, offset=0): - finditem = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth)) - itemshape = create_shape_element( + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) + payload.append(shape_element( tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version - ) - finditem.append(itemshape) + )) if calendar_view is None: view_type = create_element( 'm:IndexedPageItemView', - attrs=OrderedDict([ - ('MaxEntriesReturned', str(page_size)), - ('Offset', str(offset)), - ('BasePoint', 'Beginning'), - ]) + attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') ) else: view_type = calendar_view.to_xml(version=self.account.version) - finditem.append(view_type) + payload.append(view_type) if restriction: - finditem.append(restriction.to_xml(version=self.account.version)) + payload.append(restriction.to_xml(version=self.account.version)) if order_fields: - finditem.append(set_xml_value( + payload.append(set_xml_value( create_element('m:SortOrder'), order_fields, version=self.account.version )) - finditem.append(set_xml_value( - create_element('m:ParentFolderIds'), - folders, - version=self.account.version - )) + payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds')) if query_string: - finditem.append(query_string.to_xml(version=self.account.version)) - return finditem
    + payload.append(query_string.to_xml(version=self.account.version)) + return payload

    Ancestors

    @@ -311,6 +289,10 @@

    Methods

    :return: XML elements for the matching items """ + if shape not in SHAPE_CHOICES: + raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + if depth not in ITEM_TRAVERSAL_CHOICES: + raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape return self._elems_to_objs(self._paged_call( @@ -325,7 +307,7 @@

    Methods

    shape=shape, depth=depth, calendar_view=calendar_view, - page_size=self.chunk_size, + page_size=self.page_size, offset=offset, ) )) @@ -342,50 +324,41 @@

    Methods

    def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth,
                     calendar_view, page_size, offset=0):
    -    finditem = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth))
    -    itemshape = create_shape_element(
    +    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth))
    +    payload.append(shape_element(
             tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version
    -    )
    -    finditem.append(itemshape)
    +    ))
         if calendar_view is None:
             view_type = create_element(
                 'm:IndexedPageItemView',
    -            attrs=OrderedDict([
    -                ('MaxEntriesReturned', str(page_size)),
    -                ('Offset', str(offset)),
    -                ('BasePoint', 'Beginning'),
    -            ])
    +            attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning')
             )
         else:
             view_type = calendar_view.to_xml(version=self.account.version)
    -    finditem.append(view_type)
    +    payload.append(view_type)
         if restriction:
    -        finditem.append(restriction.to_xml(version=self.account.version))
    +        payload.append(restriction.to_xml(version=self.account.version))
         if order_fields:
    -        finditem.append(set_xml_value(
    +        payload.append(set_xml_value(
                 create_element('m:SortOrder'),
                 order_fields,
                 version=self.account.version
             ))
    -    finditem.append(set_xml_value(
    -        create_element('m:ParentFolderIds'),
    -        folders,
    -        version=self.account.version
    -    ))
    +    payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds'))
         if query_string:
    -        finditem.append(query_string.to_xml(version=self.account.version))
    -    return finditem
    + payload.append(query_string.to_xml(version=self.account.version)) + return payload

    Inherited members

    diff --git a/docs/exchangelib/services/find_people.html b/docs/exchangelib/services/find_people.html index 8452aafe..db96c304 100644 --- a/docs/exchangelib/services/find_people.html +++ b/docs/exchangelib/services/find_people.html @@ -27,20 +27,20 @@

    Module exchangelib.services.find_people

    Expand source code
    import logging
    -from collections import OrderedDict
    -
    -from .common import EWSAccountService, create_shape_element
    +from .common import EWSPagingService, shape_element, folder_ids_element
    +from ..errors import InvalidEnumValue
    +from ..items import Persona, ID_ONLY, SHAPE_CHOICES, ITEM_TRAVERSAL_CHOICES
     from ..util import create_element, set_xml_value, MNS
     from ..version import EXCHANGE_2013
     
     log = logging.getLogger(__name__)
     
     
    -class FindPeople(EWSAccountService):
    +class FindPeople(EWSPagingService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/findpeople-operation"""
     
         SERVICE_NAME = 'FindPeople'
    -    element_container_name = '{%s}People' % MNS
    +    element_container_name = f'{{{MNS}}}People'
         supported_from = EXCHANGE_2013
         supports_paging = True
     
    @@ -51,7 +51,7 @@ 

    Module exchangelib.services.find_people

    self.shape = None def call(self, folder, additional_fields, restriction, order_fields, shape, query_string, depth, max_items, offset): - """Find items in an account. + """Find items in an account. This service can only be called on a single folder. :param folder: the Folder object to query :param additional_fields: the extra fields that should be returned with the item, as FieldPath objects @@ -65,12 +65,16 @@

    Module exchangelib.services.find_people

    :return: XML elements for the matching items """ + if shape not in SHAPE_CHOICES: + raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + if depth not in ITEM_TRAVERSAL_CHOICES: + raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - folders=[folder], # We can only query one folder, so there will only be one element in response + folders=[folder], # We just need the list to satisfy self._paged_call() **dict( additional_fields=additional_fields, restriction=restriction, @@ -78,65 +82,46 @@

    Module exchangelib.services.find_people

    query_string=query_string, shape=shape, depth=depth, - page_size=self.chunk_size, + page_size=self.page_size, offset=offset, ) )) - def _elems_to_objs(self, elems): - from ..items import Persona, ID_ONLY - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - if self.shape == ID_ONLY and self.additional_fields is None: - yield Persona.id_from_xml(elem) - continue - yield Persona.from_xml(elem, account=self.account) + def _elem_to_obj(self, elem): + if self.shape == ID_ONLY and self.additional_fields is None: + return Persona.id_from_xml(elem) + return Persona.from_xml(elem, account=self.account) def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, offset=0): - folders = list(folders) - if len(folders) != 1: - raise ValueError('%r can only query one folder' % self.SERVICE_NAME) - folder = folders[0] - findpeople = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth)) - personashape = create_shape_element( + # We actually only support a single folder, but self._paged_call() sends us a list + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) + payload.append(shape_element( tag='m:PersonaShape', shape=shape, additional_fields=additional_fields, version=self.account.version - ) - findpeople.append(personashape) - view_type = create_element( + )) + payload.append(create_element( 'm:IndexedPageItemView', - attrs=OrderedDict([ - ('MaxEntriesReturned', str(page_size)), - ('Offset', str(offset)), - ('BasePoint', 'Beginning'), - ]) - ) - findpeople.append(view_type) + attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') + )) if restriction: - findpeople.append(restriction.to_xml(version=self.account.version)) + payload.append(restriction.to_xml(version=self.account.version)) if order_fields: - findpeople.append(set_xml_value( + payload.append(set_xml_value( create_element('m:SortOrder'), order_fields, version=self.account.version )) - findpeople.append(set_xml_value( - create_element('m:ParentFolderId'), - folder, - version=self.account.version - )) + payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:ParentFolderId')) if query_string: - findpeople.append(query_string.to_xml(version=self.account.version)) - return findpeople + payload.append(query_string.to_xml(version=self.account.version)) + return payload @staticmethod def _get_paging_values(elem): """Find paging values. The paging element from FindPeople is different from other paging containers.""" - item_count = int(elem.find('{%s}TotalNumberOfPeopleInView' % MNS).text) - first_matching = int(elem.find('{%s}FirstMatchingRowIndex' % MNS).text) - first_loaded = int(elem.find('{%s}FirstLoadedRowIndex' % MNS).text) + item_count = int(elem.find(f'{{{MNS}}}TotalNumberOfPeopleInView').text) + first_matching = int(elem.find(f'{{{MNS}}}FirstMatchingRowIndex').text) + first_loaded = int(elem.find(f'{{{MNS}}}FirstLoadedRowIndex').text) log.debug('Got page with total items %s, first matching %s, first loaded %s ', item_count, first_matching, first_loaded) next_offset = None # GetPersona does not support fetching more pages @@ -162,11 +147,11 @@

    Classes

    Expand source code -
    class FindPeople(EWSAccountService):
    +
    class FindPeople(EWSPagingService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/findpeople-operation"""
     
         SERVICE_NAME = 'FindPeople'
    -    element_container_name = '{%s}People' % MNS
    +    element_container_name = f'{{{MNS}}}People'
         supported_from = EXCHANGE_2013
         supports_paging = True
     
    @@ -177,7 +162,7 @@ 

    Classes

    self.shape = None def call(self, folder, additional_fields, restriction, order_fields, shape, query_string, depth, max_items, offset): - """Find items in an account. + """Find items in an account. This service can only be called on a single folder. :param folder: the Folder object to query :param additional_fields: the extra fields that should be returned with the item, as FieldPath objects @@ -191,12 +176,16 @@

    Classes

    :return: XML elements for the matching items """ + if shape not in SHAPE_CHOICES: + raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + if depth not in ITEM_TRAVERSAL_CHOICES: + raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - folders=[folder], # We can only query one folder, so there will only be one element in response + folders=[folder], # We just need the list to satisfy self._paged_call() **dict( additional_fields=additional_fields, restriction=restriction, @@ -204,65 +193,46 @@

    Classes

    query_string=query_string, shape=shape, depth=depth, - page_size=self.chunk_size, + page_size=self.page_size, offset=offset, ) )) - def _elems_to_objs(self, elems): - from ..items import Persona, ID_ONLY - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - if self.shape == ID_ONLY and self.additional_fields is None: - yield Persona.id_from_xml(elem) - continue - yield Persona.from_xml(elem, account=self.account) + def _elem_to_obj(self, elem): + if self.shape == ID_ONLY and self.additional_fields is None: + return Persona.id_from_xml(elem) + return Persona.from_xml(elem, account=self.account) def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, offset=0): - folders = list(folders) - if len(folders) != 1: - raise ValueError('%r can only query one folder' % self.SERVICE_NAME) - folder = folders[0] - findpeople = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth)) - personashape = create_shape_element( + # We actually only support a single folder, but self._paged_call() sends us a list + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) + payload.append(shape_element( tag='m:PersonaShape', shape=shape, additional_fields=additional_fields, version=self.account.version - ) - findpeople.append(personashape) - view_type = create_element( + )) + payload.append(create_element( 'm:IndexedPageItemView', - attrs=OrderedDict([ - ('MaxEntriesReturned', str(page_size)), - ('Offset', str(offset)), - ('BasePoint', 'Beginning'), - ]) - ) - findpeople.append(view_type) + attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') + )) if restriction: - findpeople.append(restriction.to_xml(version=self.account.version)) + payload.append(restriction.to_xml(version=self.account.version)) if order_fields: - findpeople.append(set_xml_value( + payload.append(set_xml_value( create_element('m:SortOrder'), order_fields, version=self.account.version )) - findpeople.append(set_xml_value( - create_element('m:ParentFolderId'), - folder, - version=self.account.version - )) + payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:ParentFolderId')) if query_string: - findpeople.append(query_string.to_xml(version=self.account.version)) - return findpeople + payload.append(query_string.to_xml(version=self.account.version)) + return payload @staticmethod def _get_paging_values(elem): """Find paging values. The paging element from FindPeople is different from other paging containers.""" - item_count = int(elem.find('{%s}TotalNumberOfPeopleInView' % MNS).text) - first_matching = int(elem.find('{%s}FirstMatchingRowIndex' % MNS).text) - first_loaded = int(elem.find('{%s}FirstLoadedRowIndex' % MNS).text) + item_count = int(elem.find(f'{{{MNS}}}TotalNumberOfPeopleInView').text) + first_matching = int(elem.find(f'{{{MNS}}}FirstMatchingRowIndex').text) + first_loaded = int(elem.find(f'{{{MNS}}}FirstLoadedRowIndex').text) log.debug('Got page with total items %s, first matching %s, first loaded %s ', item_count, first_matching, first_loaded) next_offset = None # GetPersona does not support fetching more pages @@ -270,6 +240,7 @@

    Classes

    Ancestors

    @@ -298,7 +269,7 @@

    Methods

    def call(self, folder, additional_fields, restriction, order_fields, shape, query_string, depth, max_items, offset)
    -

    Find items in an account.

    +

    Find items in an account. This service can only be called on a single folder.

    :param folder: the Folder object to query :param additional_fields: the extra fields that should be returned with the item, as FieldPath objects :param restriction: a Restriction object for @@ -314,7 +285,7 @@

    Methods

    Expand source code
    def call(self, folder, additional_fields, restriction, order_fields, shape, query_string, depth, max_items, offset):
    -    """Find items in an account.
    +    """Find items in an account. This service can only be called on a single folder.
     
         :param folder: the Folder object to query
         :param additional_fields: the extra fields that should be returned with the item, as FieldPath objects
    @@ -328,12 +299,16 @@ 

    Methods

    :return: XML elements for the matching items """ + if shape not in SHAPE_CHOICES: + raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + if depth not in ITEM_TRAVERSAL_CHOICES: + raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - folders=[folder], # We can only query one folder, so there will only be one element in response + folders=[folder], # We just need the list to satisfy self._paged_call() **dict( additional_fields=additional_fields, restriction=restriction, @@ -341,7 +316,7 @@

    Methods

    query_string=query_string, shape=shape, depth=depth, - page_size=self.chunk_size, + page_size=self.page_size, offset=offset, ) ))
    @@ -358,51 +333,38 @@

    Methods

    def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size,
                     offset=0):
    -    folders = list(folders)
    -    if len(folders) != 1:
    -        raise ValueError('%r can only query one folder' % self.SERVICE_NAME)
    -    folder = folders[0]
    -    findpeople = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth))
    -    personashape = create_shape_element(
    +    # We actually only support a single folder, but self._paged_call() sends us a list
    +    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth))
    +    payload.append(shape_element(
             tag='m:PersonaShape', shape=shape, additional_fields=additional_fields, version=self.account.version
    -    )
    -    findpeople.append(personashape)
    -    view_type = create_element(
    +    ))
    +    payload.append(create_element(
             'm:IndexedPageItemView',
    -        attrs=OrderedDict([
    -            ('MaxEntriesReturned', str(page_size)),
    -            ('Offset', str(offset)),
    -            ('BasePoint', 'Beginning'),
    -        ])
    -    )
    -    findpeople.append(view_type)
    +        attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning')
    +    ))
         if restriction:
    -        findpeople.append(restriction.to_xml(version=self.account.version))
    +        payload.append(restriction.to_xml(version=self.account.version))
         if order_fields:
    -        findpeople.append(set_xml_value(
    +        payload.append(set_xml_value(
                 create_element('m:SortOrder'),
                 order_fields,
                 version=self.account.version
             ))
    -    findpeople.append(set_xml_value(
    -        create_element('m:ParentFolderId'),
    -        folder,
    -        version=self.account.version
    -    ))
    +    payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:ParentFolderId'))
         if query_string:
    -        findpeople.append(query_string.to_xml(version=self.account.version))
    -    return findpeople
    + payload.append(query_string.to_xml(version=self.account.version)) + return payload

    Inherited members

    diff --git a/docs/exchangelib/services/get_attachment.html b/docs/exchangelib/services/get_attachment.html index 5964391f..e635ab7e 100644 --- a/docs/exchangelib/services/get_attachment.html +++ b/docs/exchangelib/services/get_attachment.html @@ -28,7 +28,9 @@

    Module exchangelib.services.get_attachment

    from itertools import chain
     
    -from .common import EWSAccountService, create_attachment_ids_element
    +from .common import EWSAccountService, attachment_ids_element
    +from ..attachments import FileAttachment, ItemAttachment
    +from ..errors import InvalidEnumValue
     from ..util import create_element, add_xml_child, set_xml_value, DummyResponse, StreamingBase64Parser,\
         StreamingContentHandler, ElementNotFound, MNS
     
    @@ -40,27 +42,22 @@ 

    Module exchangelib.services.get_attachment

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getattachment-operation""" SERVICE_NAME = 'GetAttachment' - element_container_name = '{%s}Attachments' % MNS + element_container_name = f'{{{MNS}}}Attachments' + cls_map = {cls.response_tag(): cls for cls in (FileAttachment, ItemAttachment)} def call(self, items, include_mime_content, body_type, filter_html_content, additional_fields): if body_type and body_type not in BODY_TYPE_CHOICES: - raise ValueError("'body_type' %s must be one of %s" % (body_type, BODY_TYPE_CHOICES)) + raise InvalidEnumValue('body_type', body_type, BODY_TYPE_CHOICES) return self._elems_to_objs(self._chunked_get_elements( self.get_payload, items=items, include_mime_content=include_mime_content, body_type=body_type, filter_html_content=filter_html_content, additional_fields=additional_fields, )) - def _elems_to_objs(self, elems): - from ..attachments import FileAttachment, ItemAttachment - cls_map = {cls.response_tag(): cls for cls in (FileAttachment, ItemAttachment)} - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield cls_map[elem.tag].from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return self.cls_map[elem.tag].from_xml(elem=elem, account=self.account) def get_payload(self, items, include_mime_content, body_type, filter_html_content, additional_fields): - payload = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') shape_elem = create_element('m:AttachmentShape') if include_mime_content: add_xml_child(shape_elem, 't:IncludeMimeContent', 'true') @@ -78,8 +75,7 @@

    Module exchangelib.services.get_attachment

    shape_elem.append(additional_properties) if len(shape_elem): payload.append(shape_elem) - attachment_ids = create_attachment_ids_element(items=items, version=self.account.version) - payload.append(attachment_ids) + payload.append(attachment_ids_element(items=items, version=self.account.version)) return payload def _update_api_version(self, api_version, header, **parse_opts): @@ -99,7 +95,6 @@

    Module exchangelib.services.get_attachment

    if not parse_opts.get('stream_file_content', False): return super()._get_soap_messages(body, **parse_opts) - from ..attachments import FileAttachment # 'body' is actually the raw response passed on by '_get_soap_parts' r = body parser = StreamingBase64Parser() @@ -121,7 +116,7 @@

    Module exchangelib.services.get_attachment

    # When the returned XML does not contain a Content element, ElementNotFound is thrown by parser.parse(). # Let the non-streaming SOAP parser parse the response and hook into the normal exception handling. # Wrap in DummyResponse because _get_soap_parts() expects an iter_content() method. - response = DummyResponse(url=None, headers=None, request_headers=None, content=enf.data) + response = DummyResponse(content=enf.data) _, body = super()._get_soap_parts(response=response) res = super()._get_soap_messages(body=body) for e in self._get_elements_in_response(response=res): @@ -157,27 +152,22 @@

    Classes

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getattachment-operation""" SERVICE_NAME = 'GetAttachment' - element_container_name = '{%s}Attachments' % MNS + element_container_name = f'{{{MNS}}}Attachments' + cls_map = {cls.response_tag(): cls for cls in (FileAttachment, ItemAttachment)} def call(self, items, include_mime_content, body_type, filter_html_content, additional_fields): if body_type and body_type not in BODY_TYPE_CHOICES: - raise ValueError("'body_type' %s must be one of %s" % (body_type, BODY_TYPE_CHOICES)) + raise InvalidEnumValue('body_type', body_type, BODY_TYPE_CHOICES) return self._elems_to_objs(self._chunked_get_elements( self.get_payload, items=items, include_mime_content=include_mime_content, body_type=body_type, filter_html_content=filter_html_content, additional_fields=additional_fields, )) - def _elems_to_objs(self, elems): - from ..attachments import FileAttachment, ItemAttachment - cls_map = {cls.response_tag(): cls for cls in (FileAttachment, ItemAttachment)} - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield cls_map[elem.tag].from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return self.cls_map[elem.tag].from_xml(elem=elem, account=self.account) def get_payload(self, items, include_mime_content, body_type, filter_html_content, additional_fields): - payload = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') shape_elem = create_element('m:AttachmentShape') if include_mime_content: add_xml_child(shape_elem, 't:IncludeMimeContent', 'true') @@ -195,8 +185,7 @@

    Classes

    shape_elem.append(additional_properties) if len(shape_elem): payload.append(shape_elem) - attachment_ids = create_attachment_ids_element(items=items, version=self.account.version) - payload.append(attachment_ids) + payload.append(attachment_ids_element(items=items, version=self.account.version)) return payload def _update_api_version(self, api_version, header, **parse_opts): @@ -216,7 +205,6 @@

    Classes

    if not parse_opts.get('stream_file_content', False): return super()._get_soap_messages(body, **parse_opts) - from ..attachments import FileAttachment # 'body' is actually the raw response passed on by '_get_soap_parts' r = body parser = StreamingBase64Parser() @@ -238,7 +226,7 @@

    Classes

    # When the returned XML does not contain a Content element, ElementNotFound is thrown by parser.parse(). # Let the non-streaming SOAP parser parse the response and hook into the normal exception handling. # Wrap in DummyResponse because _get_soap_parts() expects an iter_content() method. - response = DummyResponse(url=None, headers=None, request_headers=None, content=enf.data) + response = DummyResponse(content=enf.data) _, body = super()._get_soap_parts(response=response) res = super()._get_soap_messages(body=body) for e in self._get_elements_in_response(response=res): @@ -261,6 +249,10 @@

    Class variables

    +
    var cls_map
    +
    +
    +
    var element_container_name
    @@ -279,7 +271,7 @@

    Methods

    def call(self, items, include_mime_content, body_type, filter_html_content, additional_fields):
         if body_type and body_type not in BODY_TYPE_CHOICES:
    -        raise ValueError("'body_type' %s must be one of %s" % (body_type, BODY_TYPE_CHOICES))
    +        raise InvalidEnumValue('body_type', body_type, BODY_TYPE_CHOICES)
         return self._elems_to_objs(self._chunked_get_elements(
             self.get_payload, items=items, include_mime_content=include_mime_content,
             body_type=body_type, filter_html_content=filter_html_content, additional_fields=additional_fields,
    @@ -296,7 +288,7 @@ 

    Methods

    Expand source code
    def get_payload(self, items, include_mime_content, body_type, filter_html_content, additional_fields):
    -    payload = create_element('m:%s' % self.SERVICE_NAME)
    +    payload = create_element(f'm:{self.SERVICE_NAME}')
         shape_elem = create_element('m:AttachmentShape')
         if include_mime_content:
             add_xml_child(shape_elem, 't:IncludeMimeContent', 'true')
    @@ -314,8 +306,7 @@ 

    Methods

    shape_elem.append(additional_properties) if len(shape_elem): payload.append(shape_elem) - attachment_ids = create_attachment_ids_element(items=items, version=self.account.version) - payload.append(attachment_ids) + payload.append(attachment_ids_element(items=items, version=self.account.version)) return payload
    @@ -341,7 +332,7 @@

    Methods

    # When the returned XML does not contain a Content element, ElementNotFound is thrown by parser.parse(). # Let the non-streaming SOAP parser parse the response and hook into the normal exception handling. # Wrap in DummyResponse because _get_soap_parts() expects an iter_content() method. - response = DummyResponse(url=None, headers=None, request_headers=None, content=enf.data) + response = DummyResponse(content=enf.data) _, body = super()._get_soap_parts(response=response) res = super()._get_soap_messages(body=body) for e in self._get_elements_in_response(response=res): @@ -388,6 +379,7 @@

  • SERVICE_NAME
  • call
  • +
  • cls_map
  • element_container_name
  • get_payload
  • stream_file_content
  • diff --git a/docs/exchangelib/services/get_delegate.html b/docs/exchangelib/services/get_delegate.html index 9faf5aff..c8c3ec03 100644 --- a/docs/exchangelib/services/get_delegate.html +++ b/docs/exchangelib/services/get_delegate.html @@ -27,7 +27,7 @@

    Module exchangelib.services.get_delegate

    Expand source code
    from .common import EWSAccountService
    -from ..properties import DLMailbox, DelegateUser  # The service expects a Mailbox element in the MNS namespace
    +from ..properties import DLMailbox, DelegateUser, UserId  # The service expects a Mailbox element in the MNS namespace
     from ..util import create_element, set_xml_value, MNS
     from ..version import EXCHANGE_2007_SP1
     
    @@ -36,6 +36,7 @@ 

    Module exchangelib.services.get_delegate

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getdelegate-operation""" SERVICE_NAME = 'GetDelegate' + ERRORS_TO_CATCH_IN_RESPONSE = () supported_from = EXCHANGE_2007_SP1 def call(self, user_ids, include_permissions): @@ -46,21 +47,19 @@

    Module exchangelib.services.get_delegate

    include_permissions=include_permissions, )) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield DelegateUser.from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return DelegateUser.from_xml(elem=elem, account=self.account) def get_payload(self, user_ids, mailbox, include_permissions): - payload = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=dict(IncludePermissions='true' if include_permissions else 'false'), - ) + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IncludePermissions=include_permissions)) set_xml_value(payload, mailbox, version=self.protocol.version) if user_ids != [None]: - set_xml_value(payload, user_ids, version=self.protocol.version) + user_ids_elem = create_element('m:UserIds') + for user_id in user_ids: + if isinstance(user_id, str): + user_id = UserId(primary_smtp_address=user_id) + set_xml_value(user_ids_elem, user_id, version=self.protocol.version) + set_xml_value(payload, user_ids_elem, version=self.protocol.version) return payload @classmethod @@ -69,7 +68,7 @@

    Module exchangelib.services.get_delegate

    @classmethod def _response_message_tag(cls): - return '{%s}DelegateUserResponseMessageType' % MNS
    + return f'{{{MNS}}}DelegateUserResponseMessageType'

    @@ -95,6 +94,7 @@

    Classes

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getdelegate-operation""" SERVICE_NAME = 'GetDelegate' + ERRORS_TO_CATCH_IN_RESPONSE = () supported_from = EXCHANGE_2007_SP1 def call(self, user_ids, include_permissions): @@ -105,21 +105,19 @@

    Classes

    include_permissions=include_permissions, )) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield DelegateUser.from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return DelegateUser.from_xml(elem=elem, account=self.account) def get_payload(self, user_ids, mailbox, include_permissions): - payload = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=dict(IncludePermissions='true' if include_permissions else 'false'), - ) + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IncludePermissions=include_permissions)) set_xml_value(payload, mailbox, version=self.protocol.version) if user_ids != [None]: - set_xml_value(payload, user_ids, version=self.protocol.version) + user_ids_elem = create_element('m:UserIds') + for user_id in user_ids: + if isinstance(user_id, str): + user_id = UserId(primary_smtp_address=user_id) + set_xml_value(user_ids_elem, user_id, version=self.protocol.version) + set_xml_value(payload, user_ids_elem, version=self.protocol.version) return payload @classmethod @@ -128,7 +126,7 @@

    Classes

    @classmethod def _response_message_tag(cls): - return '{%s}DelegateUserResponseMessageType' % MNS
    + return f'{{{MNS}}}DelegateUserResponseMessageType'

    Ancestors

      @@ -137,6 +135,10 @@

      Ancestors

    Class variables

    +
    var ERRORS_TO_CATCH_IN_RESPONSE
    +
    +
    +
    var SERVICE_NAME
    @@ -176,13 +178,15 @@

    Methods

    Expand source code
    def get_payload(self, user_ids, mailbox, include_permissions):
    -    payload = create_element(
    -        'm:%s' % self.SERVICE_NAME,
    -        attrs=dict(IncludePermissions='true' if include_permissions else 'false'),
    -    )
    +    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IncludePermissions=include_permissions))
         set_xml_value(payload, mailbox, version=self.protocol.version)
         if user_ids != [None]:
    -        set_xml_value(payload, user_ids, version=self.protocol.version)
    +        user_ids_elem = create_element('m:UserIds')
    +        for user_id in user_ids:
    +            if isinstance(user_id, str):
    +                user_id = UserId(primary_smtp_address=user_id)
    +            set_xml_value(user_ids_elem, user_id, version=self.protocol.version)
    +        set_xml_value(payload, user_ids_elem, version=self.protocol.version)
         return payload
    @@ -218,6 +222,7 @@

    Index

  • GetDelegate

      +
    • ERRORS_TO_CATCH_IN_RESPONSE
    • SERVICE_NAME
    • call
    • get_payload
    • diff --git a/docs/exchangelib/services/get_events.html b/docs/exchangelib/services/get_events.html index 1739f4f6..daf07773 100644 --- a/docs/exchangelib/services/get_events.html +++ b/docs/exchangelib/services/get_events.html @@ -48,22 +48,18 @@

      Module exchangelib.services.get_events

      subscription_id=subscription_id, watermark=watermark, ))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Notification.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return Notification.from_xml(elem=elem, account=None) @classmethod def _get_elements_in_container(cls, container): return container.findall(Notification.response_tag()) def get_payload(self, subscription_id, watermark): - getstreamingevents = create_element('m:%s' % self.SERVICE_NAME) - add_xml_child(getstreamingevents, 'm:SubscriptionId', subscription_id) - add_xml_child(getstreamingevents, 'm:Watermark', watermark) - return getstreamingevents + payload = create_element(f'm:{self.SERVICE_NAME}') + add_xml_child(payload, 'm:SubscriptionId', subscription_id) + add_xml_child(payload, 'm:Watermark', watermark) + return payload
  • @@ -99,22 +95,18 @@

    Classes

    subscription_id=subscription_id, watermark=watermark, ))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Notification.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return Notification.from_xml(elem=elem, account=None) @classmethod def _get_elements_in_container(cls, container): return container.findall(Notification.response_tag()) def get_payload(self, subscription_id, watermark): - getstreamingevents = create_element('m:%s' % self.SERVICE_NAME) - add_xml_child(getstreamingevents, 'm:SubscriptionId', subscription_id) - add_xml_child(getstreamingevents, 'm:Watermark', watermark) - return getstreamingevents + payload = create_element(f'm:{self.SERVICE_NAME}') + add_xml_child(payload, 'm:SubscriptionId', subscription_id) + add_xml_child(payload, 'm:Watermark', watermark) + return payload

    Ancestors

      @@ -159,10 +151,10 @@

      Methods

      Expand source code
      def get_payload(self, subscription_id, watermark):
      -    getstreamingevents = create_element('m:%s' % self.SERVICE_NAME)
      -    add_xml_child(getstreamingevents, 'm:SubscriptionId', subscription_id)
      -    add_xml_child(getstreamingevents, 'm:Watermark', watermark)
      -    return getstreamingevents
      + payload = create_element(f'm:{self.SERVICE_NAME}') + add_xml_child(payload, 'm:SubscriptionId', subscription_id) + add_xml_child(payload, 'm:Watermark', watermark) + return payload diff --git a/docs/exchangelib/services/get_folder.html b/docs/exchangelib/services/get_folder.html index 6e7d2e3d..39013eb3 100644 --- a/docs/exchangelib/services/get_folder.html +++ b/docs/exchangelib/services/get_folder.html @@ -26,8 +26,7 @@

      Module exchangelib.services.get_folder

      Expand source code -
      from .common import EWSAccountService, parse_folder_elem, create_folder_ids_element, \
      -    create_shape_element
      +
      from .common import EWSAccountService, parse_folder_elem, folder_ids_element, shape_element
       from ..errors import ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorInvalidOperation
       from ..util import create_element, MNS
       
      @@ -36,7 +35,7 @@ 

      Module exchangelib.services.get_folder

      """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getfolder-operation""" SERVICE_NAME = 'GetFolder' - element_container_name = '{%s}Folders' % MNS + element_container_name = f'{{{MNS}}}Folders' ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + ( ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorInvalidOperation, ) @@ -72,14 +71,12 @@

      Module exchangelib.services.get_folder

      yield parse_folder_elem(elem=elem, folder=folder, account=self.account) def get_payload(self, folders, additional_fields, shape): - getfolder = create_element('m:%s' % self.SERVICE_NAME) - foldershape = create_shape_element( + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(shape_element( tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version - ) - getfolder.append(foldershape) - folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version) - getfolder.append(folder_ids) - return getfolder
      + )) + payload.append(folder_ids_element(folders=folders, version=self.account.version)) + return payload
    @@ -105,7 +102,7 @@

    Classes

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getfolder-operation""" SERVICE_NAME = 'GetFolder' - element_container_name = '{%s}Folders' % MNS + element_container_name = f'{{{MNS}}}Folders' ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + ( ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorInvalidOperation, ) @@ -141,14 +138,12 @@

    Classes

    yield parse_folder_elem(elem=elem, folder=folder, account=self.account) def get_payload(self, folders, additional_fields, shape): - getfolder = create_element('m:%s' % self.SERVICE_NAME) - foldershape = create_shape_element( + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(shape_element( tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version - ) - getfolder.append(foldershape) - folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version) - getfolder.append(folder_ids) - return getfolder + )) + payload.append(folder_ids_element(folders=folders, version=self.account.version)) + return payload

    Ancestors

      @@ -215,14 +210,12 @@

      Methods

      Expand source code
      def get_payload(self, folders, additional_fields, shape):
      -    getfolder = create_element('m:%s' % self.SERVICE_NAME)
      -    foldershape = create_shape_element(
      +    payload = create_element(f'm:{self.SERVICE_NAME}')
      +    payload.append(shape_element(
               tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version
      -    )
      -    getfolder.append(foldershape)
      -    folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version)
      -    getfolder.append(folder_ids)
      -    return getfolder
      + )) + payload.append(folder_ids_element(folders=folders, version=self.account.version)) + return payload diff --git a/docs/exchangelib/services/get_item.html b/docs/exchangelib/services/get_item.html index b7f87717..7f96dc3a 100644 --- a/docs/exchangelib/services/get_item.html +++ b/docs/exchangelib/services/get_item.html @@ -26,7 +26,8 @@

      Module exchangelib.services.get_item

      Expand source code -
      from .common import EWSAccountService, create_item_ids_element, create_shape_element
      +
      from .common import EWSAccountService, item_ids_element, shape_element
      +from ..folders.base import BaseFolder
       from ..util import create_element, MNS
       
       
      @@ -34,7 +35,7 @@ 

      Module exchangelib.services.get_item

      """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getitem-operation""" SERVICE_NAME = 'GetItem' - element_container_name = '{%s}Items' % MNS + element_container_name = f'{{{MNS}}}Items' def call(self, items, additional_fields, shape): """Return all items in an account that correspond to a list of ID's, in stable order. @@ -49,23 +50,16 @@

      Module exchangelib.services.get_item

      self.get_payload, items=items, additional_fields=additional_fields, shape=shape, )) - def _elems_to_objs(self, elems): - from ..folders.base import BaseFolder - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) def get_payload(self, items, additional_fields, shape): - getitem = create_element('m:%s' % self.SERVICE_NAME) - itemshape = create_shape_element( + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(shape_element( tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version - ) - getitem.append(itemshape) - item_ids = create_item_ids_element(items=items, version=self.account.version) - getitem.append(item_ids) - return getitem
      + )) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload
    @@ -91,7 +85,7 @@

    Classes

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getitem-operation""" SERVICE_NAME = 'GetItem' - element_container_name = '{%s}Items' % MNS + element_container_name = f'{{{MNS}}}Items' def call(self, items, additional_fields, shape): """Return all items in an account that correspond to a list of ID's, in stable order. @@ -106,23 +100,16 @@

    Classes

    self.get_payload, items=items, additional_fields=additional_fields, shape=shape, )) - def _elems_to_objs(self, elems): - from ..folders.base import BaseFolder - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) def get_payload(self, items, additional_fields, shape): - getitem = create_element('m:%s' % self.SERVICE_NAME) - itemshape = create_shape_element( + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(shape_element( tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version - ) - getitem.append(itemshape) - item_ids = create_item_ids_element(items=items, version=self.account.version) - getitem.append(item_ids) - return getitem + )) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload

    Ancestors

      @@ -179,14 +166,12 @@

      Methods

      Expand source code
      def get_payload(self, items, additional_fields, shape):
      -    getitem = create_element('m:%s' % self.SERVICE_NAME)
      -    itemshape = create_shape_element(
      +    payload = create_element(f'm:{self.SERVICE_NAME}')
      +    payload.append(shape_element(
               tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version
      -    )
      -    getitem.append(itemshape)
      -    item_ids = create_item_ids_element(items=items, version=self.account.version)
      -    getitem.append(item_ids)
      -    return getitem
      + )) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload diff --git a/docs/exchangelib/services/get_mail_tips.html b/docs/exchangelib/services/get_mail_tips.html index 84ddc044..e81c5e98 100644 --- a/docs/exchangelib/services/get_mail_tips.html +++ b/docs/exchangelib/services/get_mail_tips.html @@ -44,22 +44,16 @@

      Module exchangelib.services.get_mail_tips

      mail_tips_requested=mail_tips_requested, )) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield MailTips.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return MailTips.from_xml(elem=elem, account=None) def get_payload(self, recipients, sending_as, mail_tips_requested): - payload = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') set_xml_value(payload, sending_as, version=self.protocol.version) recipients_elem = create_element('m:Recipients') for recipient in recipients: set_xml_value(recipients_elem, recipient, version=self.protocol.version) - if not len(recipients_elem): - raise ValueError('"recipients" must not be empty') payload.append(recipients_elem) if mail_tips_requested: @@ -72,7 +66,7 @@

      Module exchangelib.services.get_mail_tips

      @classmethod def _response_message_tag(cls): - return '{%s}MailTipsResponseMessageType' % MNS + return f'{{{MNS}}}MailTipsResponseMessageType'
    @@ -107,22 +101,16 @@

    Classes

    mail_tips_requested=mail_tips_requested, )) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield MailTips.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return MailTips.from_xml(elem=elem, account=None) def get_payload(self, recipients, sending_as, mail_tips_requested): - payload = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') set_xml_value(payload, sending_as, version=self.protocol.version) recipients_elem = create_element('m:Recipients') for recipient in recipients: set_xml_value(recipients_elem, recipient, version=self.protocol.version) - if not len(recipients_elem): - raise ValueError('"recipients" must not be empty') payload.append(recipients_elem) if mail_tips_requested: @@ -135,7 +123,7 @@

    Classes

    @classmethod def _response_message_tag(cls): - return '{%s}MailTipsResponseMessageType' % MNS + return f'{{{MNS}}}MailTipsResponseMessageType'

    Ancestors

      @@ -178,14 +166,12 @@

      Methods

      Expand source code
      def get_payload(self, recipients, sending_as,  mail_tips_requested):
      -    payload = create_element('m:%s' % self.SERVICE_NAME)
      +    payload = create_element(f'm:{self.SERVICE_NAME}')
           set_xml_value(payload, sending_as, version=self.protocol.version)
       
           recipients_elem = create_element('m:Recipients')
           for recipient in recipients:
               set_xml_value(recipients_elem, recipient, version=self.protocol.version)
      -    if not len(recipients_elem):
      -        raise ValueError('"recipients" must not be empty')
           payload.append(recipients_elem)
       
           if mail_tips_requested:
      diff --git a/docs/exchangelib/services/get_persona.html b/docs/exchangelib/services/get_persona.html
      index eefd5b0e..a77d3294 100644
      --- a/docs/exchangelib/services/get_persona.html
      +++ b/docs/exchangelib/services/get_persona.html
      @@ -27,6 +27,7 @@ 

      Module exchangelib.services.get_persona

      Expand source code
      from .common import EWSAccountService, to_item_id
      +from ..items import Persona
       from ..properties import PersonaId
       from ..util import create_element, set_xml_value, MNS
       
      @@ -36,33 +37,28 @@ 

      Module exchangelib.services.get_persona

      SERVICE_NAME = 'GetPersona' - def call(self, persona): - return self._elems_to_objs(self._get_elements(payload=self.get_payload(persona=persona))) + def call(self, personas): + # GetPersona only accepts one persona ID per request. Crazy. + for persona in personas: + yield from self._elems_to_objs(self._get_elements(payload=self.get_payload(persona=persona))) - def _elems_to_objs(self, elems): - from ..items import Persona - elements = list(elems) - if len(elements) != 1: - raise ValueError('Expected exactly one element in response') - elem = elements[0] - if isinstance(elem, Exception): - raise elem + def _elem_to_obj(self, elem): return Persona.from_xml(elem=elem, account=None) def get_payload(self, persona): - version = self.protocol.version - payload = create_element('m:%s' % self.SERVICE_NAME) - set_xml_value(payload, to_item_id(persona, PersonaId, version=version), version=version) - return payload + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), + to_item_id(persona, PersonaId), + version=self.protocol.version + ) @classmethod def _get_elements_in_container(cls, container): - from ..items import Persona - return container.findall('{%s}%s' % (MNS, Persona.ELEMENT_NAME)) + return container.findall(f'{{{MNS}}}{Persona.ELEMENT_NAME}') @classmethod def _response_tag(cls): - return '{%s}%sResponseMessage' % (MNS, cls.SERVICE_NAME)
      + return f'{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage'
    @@ -89,33 +85,28 @@

    Classes

    SERVICE_NAME = 'GetPersona' - def call(self, persona): - return self._elems_to_objs(self._get_elements(payload=self.get_payload(persona=persona))) + def call(self, personas): + # GetPersona only accepts one persona ID per request. Crazy. + for persona in personas: + yield from self._elems_to_objs(self._get_elements(payload=self.get_payload(persona=persona))) - def _elems_to_objs(self, elems): - from ..items import Persona - elements = list(elems) - if len(elements) != 1: - raise ValueError('Expected exactly one element in response') - elem = elements[0] - if isinstance(elem, Exception): - raise elem + def _elem_to_obj(self, elem): return Persona.from_xml(elem=elem, account=None) def get_payload(self, persona): - version = self.protocol.version - payload = create_element('m:%s' % self.SERVICE_NAME) - set_xml_value(payload, to_item_id(persona, PersonaId, version=version), version=version) - return payload + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), + to_item_id(persona, PersonaId), + version=self.protocol.version + ) @classmethod def _get_elements_in_container(cls, container): - from ..items import Persona - return container.findall('{%s}%s' % (MNS, Persona.ELEMENT_NAME)) + return container.findall(f'{{{MNS}}}{Persona.ELEMENT_NAME}') @classmethod def _response_tag(cls): - return '{%s}%sResponseMessage' % (MNS, cls.SERVICE_NAME) + return f'{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage'

    Ancestors

      @@ -132,7 +123,7 @@

      Class variables

      Methods

      -def call(self, persona) +def call(self, personas)
      @@ -140,8 +131,10 @@

      Methods

      Expand source code -
      def call(self, persona):
      -    return self._elems_to_objs(self._get_elements(payload=self.get_payload(persona=persona)))
      +
      def call(self, personas):
      +    # GetPersona only accepts one persona ID per request. Crazy.
      +    for persona in personas:
      +        yield from self._elems_to_objs(self._get_elements(payload=self.get_payload(persona=persona)))
      @@ -154,10 +147,11 @@

      Methods

      Expand source code
      def get_payload(self, persona):
      -    version = self.protocol.version
      -    payload = create_element('m:%s' % self.SERVICE_NAME)
      -    set_xml_value(payload, to_item_id(persona, PersonaId, version=version), version=version)
      -    return payload
      + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), + to_item_id(persona, PersonaId), + version=self.protocol.version + )
      diff --git a/docs/exchangelib/services/get_room_lists.html b/docs/exchangelib/services/get_room_lists.html index 8f3212d5..98b58862 100644 --- a/docs/exchangelib/services/get_room_lists.html +++ b/docs/exchangelib/services/get_room_lists.html @@ -36,21 +36,17 @@

      Module exchangelib.services.get_room_lists

      """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getroomlists-operation""" SERVICE_NAME = 'GetRoomLists' - element_container_name = '{%s}RoomLists' % MNS + element_container_name = f'{{{MNS}}}RoomLists' supported_from = EXCHANGE_2010 def call(self): return self._elems_to_objs(self._get_elements(payload=self.get_payload())) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield RoomList.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return RoomList.from_xml(elem=elem, account=None) def get_payload(self): - return create_element('m:%s' % self.SERVICE_NAME) + return create_element(f'm:{self.SERVICE_NAME}')
    @@ -76,21 +72,17 @@

    Classes

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getroomlists-operation""" SERVICE_NAME = 'GetRoomLists' - element_container_name = '{%s}RoomLists' % MNS + element_container_name = f'{{{MNS}}}RoomLists' supported_from = EXCHANGE_2010 def call(self): return self._elems_to_objs(self._get_elements(payload=self.get_payload())) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield RoomList.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return RoomList.from_xml(elem=elem, account=None) def get_payload(self): - return create_element('m:%s' % self.SERVICE_NAME) + return create_element(f'm:{self.SERVICE_NAME}')

    Ancestors

      @@ -136,7 +128,7 @@

      Methods

      Expand source code
      def get_payload(self):
      -    return create_element('m:%s' % self.SERVICE_NAME)
      + return create_element(f'm:{self.SERVICE_NAME}') diff --git a/docs/exchangelib/services/get_rooms.html b/docs/exchangelib/services/get_rooms.html index 7a54ed67..45a79a64 100644 --- a/docs/exchangelib/services/get_rooms.html +++ b/docs/exchangelib/services/get_rooms.html @@ -36,23 +36,17 @@

      Module exchangelib.services.get_rooms

      """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getrooms-operation""" SERVICE_NAME = 'GetRooms' - element_container_name = '{%s}Rooms' % MNS + element_container_name = f'{{{MNS}}}Rooms' supported_from = EXCHANGE_2010 - def call(self, roomlist): - return self._elems_to_objs(self._get_elements(payload=self.get_payload(roomlist=roomlist))) + def call(self, room_list): + return self._elems_to_objs(self._get_elements(payload=self.get_payload(room_list=room_list))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Room.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return Room.from_xml(elem=elem, account=None) - def get_payload(self, roomlist): - getrooms = create_element('m:%s' % self.SERVICE_NAME) - set_xml_value(getrooms, roomlist, version=self.protocol.version) - return getrooms + def get_payload(self, room_list): + return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), room_list, version=self.protocol.version)
    @@ -78,23 +72,17 @@

    Classes

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getrooms-operation""" SERVICE_NAME = 'GetRooms' - element_container_name = '{%s}Rooms' % MNS + element_container_name = f'{{{MNS}}}Rooms' supported_from = EXCHANGE_2010 - def call(self, roomlist): - return self._elems_to_objs(self._get_elements(payload=self.get_payload(roomlist=roomlist))) + def call(self, room_list): + return self._elems_to_objs(self._get_elements(payload=self.get_payload(room_list=room_list))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Room.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return Room.from_xml(elem=elem, account=None) - def get_payload(self, roomlist): - getrooms = create_element('m:%s' % self.SERVICE_NAME) - set_xml_value(getrooms, roomlist, version=self.protocol.version) - return getrooms + def get_payload(self, room_list): + return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), room_list, version=self.protocol.version)

    Ancestors

      @@ -118,7 +106,7 @@

      Class variables

      Methods

      -def call(self, roomlist) +def call(self, room_list)
      @@ -126,12 +114,12 @@

      Methods

      Expand source code -
      def call(self, roomlist):
      -    return self._elems_to_objs(self._get_elements(payload=self.get_payload(roomlist=roomlist)))
      +
      def call(self, room_list):
      +    return self._elems_to_objs(self._get_elements(payload=self.get_payload(room_list=room_list)))
      -def get_payload(self, roomlist) +def get_payload(self, room_list)
      @@ -139,10 +127,8 @@

      Methods

      Expand source code -
      def get_payload(self, roomlist):
      -    getrooms = create_element('m:%s' % self.SERVICE_NAME)
      -    set_xml_value(getrooms, roomlist, version=self.protocol.version)
      -    return getrooms
      +
      def get_payload(self, room_list):
      +    return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), room_list, version=self.protocol.version)
      diff --git a/docs/exchangelib/services/get_searchable_mailboxes.html b/docs/exchangelib/services/get_searchable_mailboxes.html index 06c119bd..b69e0382 100644 --- a/docs/exchangelib/services/get_searchable_mailboxes.html +++ b/docs/exchangelib/services/get_searchable_mailboxes.html @@ -39,9 +39,10 @@

      Module exchangelib.services.get_searchable_mailboxesModule exchangelib.services.get_searchable_mailboxesModule exchangelib.services.get_searchable_mailboxes + yield from self._get_elements_in_container(container=container)

    @@ -107,9 +98,10 @@

    Classes

    """ SERVICE_NAME = 'GetSearchableMailboxes' - element_container_name = '{%s}SearchableMailboxes' % MNS - failed_mailboxes_container_name = '{%s}FailedMailboxes' % MNS + element_container_name = f'{{{MNS}}}SearchableMailboxes' + failed_mailboxes_container_name = f'{{{MNS}}}FailedMailboxes' supported_from = EXCHANGE_2013 + cls_map = {cls.response_tag(): cls for cls in (SearchableMailbox, FailedMailbox)} def call(self, search_filter, expand_group_membership): return self._elems_to_objs(self._get_elements(payload=self.get_payload( @@ -117,16 +109,11 @@

    Classes

    expand_group_membership=expand_group_membership, ))) - def _elems_to_objs(self, elems): - cls_map = {cls.response_tag(): cls for cls in (SearchableMailbox, FailedMailbox)} - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield cls_map[elem.tag].from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return self.cls_map[elem.tag].from_xml(elem=elem, account=None) def get_payload(self, search_filter, expand_group_membership): - payload = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') if search_filter: add_xml_child(payload, 'm:SearchFilter', search_filter) if expand_group_membership is not None: @@ -137,16 +124,11 @@

    Classes

    for msg in response: for container_name in (self.element_container_name, self.failed_mailboxes_container_name): try: - container_or_exc = self._get_element_container(message=msg, name=container_name) + container = self._get_element_container(message=msg, name=container_name) except MalformedResponseError: - # Responses may contain no failed mailboxes. _get_element_container() does not accept this. - if container_name == self.failed_mailboxes_container_name: - continue - raise - if isinstance(container_or_exc, (bool, Exception)): - yield container_or_exc + # Responses may contain no mailboxes of either kind. _get_element_container() does not accept this. continue - yield from self._get_elements_in_container(container=container_or_exc)
    + yield from self._get_elements_in_container(container=container)

    Ancestors

      @@ -158,6 +140,10 @@

      Class variables

      +
      var cls_map
      +
      +
      +
      var element_container_name
      @@ -199,7 +185,7 @@

      Methods

      Expand source code
      def get_payload(self, search_filter, expand_group_membership):
      -    payload = create_element('m:%s' % self.SERVICE_NAME)
      +    payload = create_element(f'm:{self.SERVICE_NAME}')
           if search_filter:
               add_xml_child(payload, 'm:SearchFilter', search_filter)
           if expand_group_membership is not None:
      @@ -241,6 +227,7 @@ 

    • SERVICE_NAME
    • call
    • +
    • cls_map
    • element_container_name
    • failed_mailboxes_container_name
    • get_payload
    • diff --git a/docs/exchangelib/services/get_server_time_zones.html b/docs/exchangelib/services/get_server_time_zones.html index bb475e28..9143769e 100644 --- a/docs/exchangelib/services/get_server_time_zones.html +++ b/docs/exchangelib/services/get_server_time_zones.html @@ -26,13 +26,9 @@

      Module exchangelib.services.get_server_time_zones Expand source code -
      import datetime
      -
      -from .common import EWSService
      -from ..errors import NaiveDateTimeNotAllowed
      -from ..ewsdatetime import EWSDateTime
      -from ..fields import WEEKDAY_NAMES
      -from ..util import create_element, set_xml_value, xml_text_to_value, peek, TNS, MNS
      +
      from .common import EWSService
      +from ..properties import TimeZoneDefinition
      +from ..util import create_element, set_xml_value, peek, MNS
       from ..version import EXCHANGE_2010
       
       
      @@ -42,7 +38,7 @@ 

      Module exchangelib.services.get_server_time_zones """ SERVICE_NAME = 'GetServerTimeZones' - element_container_name = '{%s}TimeZoneDefinitions' % MNS + element_container_name = f'{{{MNS}}}TimeZoneDefinitions' supported_from = EXCHANGE_2010 def call(self, timezones=None, return_full_timezone_data=False): @@ -53,97 +49,21 @@

      Module exchangelib.services.get_server_time_zones def get_payload(self, timezones, return_full_timezone_data): payload = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=dict(ReturnFullTimeZoneData='true' if return_full_timezone_data else 'false'), + f'm:{self.SERVICE_NAME}', + attrs=dict(ReturnFullTimeZoneData=return_full_timezone_data), ) if timezones is not None: is_empty, timezones = peek(timezones) if not is_empty: tz_ids = create_element('m:Ids') for timezone in timezones: - tz_id = set_xml_value(create_element('t:Id'), timezone.ms_id, version=self.protocol.version) + tz_id = set_xml_value(create_element('t:Id'), timezone.ms_id) tz_ids.append(tz_id) payload.append(tz_ids) return payload - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - tz_id = elem.get('Id') - tz_name = elem.get('Name') - tz_periods = self._get_periods(elem) - tz_transitions_groups = self._get_transitions_groups(elem) - tz_transitions = self._get_transitions(elem) - yield tz_id, tz_name, tz_periods, tz_transitions, tz_transitions_groups - - @staticmethod - def _get_periods(timezonedef): - tz_periods = {} - periods = timezonedef.find('{%s}Periods' % TNS) - for period in periods.findall('{%s}Period' % TNS): - # Convert e.g. "trule:Microsoft/Registry/W. Europe Standard Time/2006-Daylight" to (2006, 'Daylight') - p_year, p_type = period.get('Id').rsplit('/', 1)[1].split('-') - tz_periods[(int(p_year), p_type)] = dict( - name=period.get('Name'), - bias=xml_text_to_value(period.get('Bias'), datetime.timedelta) - ) - return tz_periods - - @staticmethod - def _get_transitions_groups(timezonedef): - tz_transitions_groups = {} - transitiongroups = timezonedef.find('{%s}TransitionsGroups' % TNS) - if transitiongroups is not None: - for transitiongroup in transitiongroups.findall('{%s}TransitionsGroup' % TNS): - tg_id = int(transitiongroup.get('Id')) - tz_transitions_groups[tg_id] = [] - for transition in transitiongroup.findall('{%s}Transition' % TNS): - # Apply same conversion to To as for period IDs - to_year, to_type = transition.find('{%s}To' % TNS).text.rsplit('/', 1)[1].split('-') - tz_transitions_groups[tg_id].append(dict( - to=(int(to_year), to_type), - )) - for transition in transitiongroup.findall('{%s}RecurringDayTransition' % TNS): - # Apply same conversion to To as for period IDs - to_year, to_type = transition.find('{%s}To' % TNS).text.rsplit('/', 1)[1].split('-') - occurrence = xml_text_to_value(transition.find('{%s}Occurrence' % TNS).text, int) - if occurrence == -1: - # See TimeZoneTransition.from_xml() - occurrence = 5 - tz_transitions_groups[tg_id].append(dict( - to=(int(to_year), to_type), - offset=xml_text_to_value(transition.find('{%s}TimeOffset' % TNS).text, datetime.timedelta), - iso_month=xml_text_to_value(transition.find('{%s}Month' % TNS).text, int), - iso_weekday=WEEKDAY_NAMES.index(transition.find('{%s}DayOfWeek' % TNS).text) + 1, - occurrence=occurrence, - )) - return tz_transitions_groups - - @staticmethod - def _get_transitions(timezonedef): - tz_transitions = {} - transitions = timezonedef.find('{%s}Transitions' % TNS) - if transitions is not None: - for transition in transitions.findall('{%s}Transition' % TNS): - to = transition.find('{%s}To' % TNS) - if to.get('Kind') != 'Group': - raise ValueError('Unexpected "Kind" XML attr: %s' % to.get('Kind')) - tg_id = xml_text_to_value(to.text, int) - tz_transitions[tg_id] = None - for transition in transitions.findall('{%s}AbsoluteDateTransition' % TNS): - to = transition.find('{%s}To' % TNS) - if to.get('Kind') != 'Group': - raise ValueError('Unexpected "Kind" XML attr: %s' % to.get('Kind')) - tg_id = xml_text_to_value(to.text, int) - try: - t_date = xml_text_to_value(transition.find('{%s}DateTime' % TNS).text, EWSDateTime).date() - except NaiveDateTimeNotAllowed as e: - # We encountered a naive datetime. Don't worry. we just need the date - t_date = e.local_dt.date() - tz_transitions[tg_id] = t_date - return tz_transitions

      + def _elem_to_obj(self, elem): + return TimeZoneDefinition.from_xml(elem=elem, account=None)

    @@ -172,7 +92,7 @@

    Classes

    """ SERVICE_NAME = 'GetServerTimeZones' - element_container_name = '{%s}TimeZoneDefinitions' % MNS + element_container_name = f'{{{MNS}}}TimeZoneDefinitions' supported_from = EXCHANGE_2010 def call(self, timezones=None, return_full_timezone_data=False): @@ -183,97 +103,21 @@

    Classes

    def get_payload(self, timezones, return_full_timezone_data): payload = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=dict(ReturnFullTimeZoneData='true' if return_full_timezone_data else 'false'), + f'm:{self.SERVICE_NAME}', + attrs=dict(ReturnFullTimeZoneData=return_full_timezone_data), ) if timezones is not None: is_empty, timezones = peek(timezones) if not is_empty: tz_ids = create_element('m:Ids') for timezone in timezones: - tz_id = set_xml_value(create_element('t:Id'), timezone.ms_id, version=self.protocol.version) + tz_id = set_xml_value(create_element('t:Id'), timezone.ms_id) tz_ids.append(tz_id) payload.append(tz_ids) return payload - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - tz_id = elem.get('Id') - tz_name = elem.get('Name') - tz_periods = self._get_periods(elem) - tz_transitions_groups = self._get_transitions_groups(elem) - tz_transitions = self._get_transitions(elem) - yield tz_id, tz_name, tz_periods, tz_transitions, tz_transitions_groups - - @staticmethod - def _get_periods(timezonedef): - tz_periods = {} - periods = timezonedef.find('{%s}Periods' % TNS) - for period in periods.findall('{%s}Period' % TNS): - # Convert e.g. "trule:Microsoft/Registry/W. Europe Standard Time/2006-Daylight" to (2006, 'Daylight') - p_year, p_type = period.get('Id').rsplit('/', 1)[1].split('-') - tz_periods[(int(p_year), p_type)] = dict( - name=period.get('Name'), - bias=xml_text_to_value(period.get('Bias'), datetime.timedelta) - ) - return tz_periods - - @staticmethod - def _get_transitions_groups(timezonedef): - tz_transitions_groups = {} - transitiongroups = timezonedef.find('{%s}TransitionsGroups' % TNS) - if transitiongroups is not None: - for transitiongroup in transitiongroups.findall('{%s}TransitionsGroup' % TNS): - tg_id = int(transitiongroup.get('Id')) - tz_transitions_groups[tg_id] = [] - for transition in transitiongroup.findall('{%s}Transition' % TNS): - # Apply same conversion to To as for period IDs - to_year, to_type = transition.find('{%s}To' % TNS).text.rsplit('/', 1)[1].split('-') - tz_transitions_groups[tg_id].append(dict( - to=(int(to_year), to_type), - )) - for transition in transitiongroup.findall('{%s}RecurringDayTransition' % TNS): - # Apply same conversion to To as for period IDs - to_year, to_type = transition.find('{%s}To' % TNS).text.rsplit('/', 1)[1].split('-') - occurrence = xml_text_to_value(transition.find('{%s}Occurrence' % TNS).text, int) - if occurrence == -1: - # See TimeZoneTransition.from_xml() - occurrence = 5 - tz_transitions_groups[tg_id].append(dict( - to=(int(to_year), to_type), - offset=xml_text_to_value(transition.find('{%s}TimeOffset' % TNS).text, datetime.timedelta), - iso_month=xml_text_to_value(transition.find('{%s}Month' % TNS).text, int), - iso_weekday=WEEKDAY_NAMES.index(transition.find('{%s}DayOfWeek' % TNS).text) + 1, - occurrence=occurrence, - )) - return tz_transitions_groups - - @staticmethod - def _get_transitions(timezonedef): - tz_transitions = {} - transitions = timezonedef.find('{%s}Transitions' % TNS) - if transitions is not None: - for transition in transitions.findall('{%s}Transition' % TNS): - to = transition.find('{%s}To' % TNS) - if to.get('Kind') != 'Group': - raise ValueError('Unexpected "Kind" XML attr: %s' % to.get('Kind')) - tg_id = xml_text_to_value(to.text, int) - tz_transitions[tg_id] = None - for transition in transitions.findall('{%s}AbsoluteDateTransition' % TNS): - to = transition.find('{%s}To' % TNS) - if to.get('Kind') != 'Group': - raise ValueError('Unexpected "Kind" XML attr: %s' % to.get('Kind')) - tg_id = xml_text_to_value(to.text, int) - try: - t_date = xml_text_to_value(transition.find('{%s}DateTime' % TNS).text, EWSDateTime).date() - except NaiveDateTimeNotAllowed as e: - # We encountered a naive datetime. Don't worry. we just need the date - t_date = e.local_dt.date() - tz_transitions[tg_id] = t_date - return tz_transitions
    + def _elem_to_obj(self, elem): + return TimeZoneDefinition.from_xml(elem=elem, account=None)

    Ancestors

      @@ -323,15 +167,15 @@

      Methods

      def get_payload(self, timezones, return_full_timezone_data):
           payload = create_element(
      -        'm:%s' % self.SERVICE_NAME,
      -        attrs=dict(ReturnFullTimeZoneData='true' if return_full_timezone_data else 'false'),
      +        f'm:{self.SERVICE_NAME}',
      +        attrs=dict(ReturnFullTimeZoneData=return_full_timezone_data),
           )
           if timezones is not None:
               is_empty, timezones = peek(timezones)
               if not is_empty:
                   tz_ids = create_element('m:Ids')
                   for timezone in timezones:
      -                tz_id = set_xml_value(create_element('t:Id'), timezone.ms_id, version=self.protocol.version)
      +                tz_id = set_xml_value(create_element('t:Id'), timezone.ms_id)
                       tz_ids.append(tz_id)
                   payload.append(tz_ids)
           return payload
      diff --git a/docs/exchangelib/services/get_streaming_events.html b/docs/exchangelib/services/get_streaming_events.html index bcdab97b..d3b3aad1 100644 --- a/docs/exchangelib/services/get_streaming_events.html +++ b/docs/exchangelib/services/get_streaming_events.html @@ -29,12 +29,12 @@

      Module exchangelib.services.get_streaming_events<
      import logging
       
       from .common import EWSAccountService, add_xml_child
      -from ..errors import EWSError
      +from ..errors import EWSError, InvalidTypeError
       from ..properties import Notification
       from ..util import create_element, get_xml_attr, get_xml_attrs, MNS, DocumentYielder, DummyResponse
       
       log = logging.getLogger(__name__)
      -xml_log = logging.getLogger('%s.xml' % __name__)
      +xml_log = logging.getLogger(f'{__name__}.xml')
       
       
       class GetStreamingEvents(EWSAccountService):
      @@ -43,7 +43,7 @@ 

      Module exchangelib.services.get_streaming_events< """ SERVICE_NAME = 'GetStreamingEvents' - element_container_name = '{%s}Notifications' % MNS + element_container_name = f'{{{MNS}}}Notifications' prefer_affinity = True # Connection status values @@ -57,18 +57,18 @@

      Module exchangelib.services.get_streaming_events< self.streaming = True def call(self, subscription_ids, connection_timeout): + if not isinstance(connection_timeout, int): + raise InvalidTypeError('connection_timeout', connection_timeout, int) if connection_timeout < 1: - raise ValueError("'connection_timeout' must be a positive integer") + raise ValueError(f"'connection_timeout' {connection_timeout} must be a positive integer") + # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed + self.timeout = connection_timeout * 60 + 60 return self._elems_to_objs(self._get_elements(payload=self.get_payload( subscription_ids=subscription_ids, connection_timeout=connection_timeout, ))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Notification.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return Notification.from_xml(elem=elem, account=None) @classmethod def _get_soap_parts(cls, response, **parse_opts): @@ -81,8 +81,8 @@

      Module exchangelib.services.get_streaming_events< # XML response. r = body for i, doc in enumerate(DocumentYielder(r.iter_content()), start=1): - xml_log.debug('''Response XML (docs received: %(i)s): %(xml_response)s''', dict(i=i, xml_response=doc)) - response = DummyResponse(url=None, headers=None, request_headers=None, content=doc) + xml_log.debug('''Response XML (docs counter: %(i)s): %(xml_response)s''', dict(i=i, xml_response=doc)) + response = DummyResponse(content=doc) try: _, body = super()._get_soap_parts(response=response, **parse_opts) except Exception: @@ -96,9 +96,9 @@

      Module exchangelib.services.get_streaming_events< break def _get_element_container(self, message, name=None): - error_ids_elem = message.find('{%s}ErrorSubscriptionIds' % MNS) - error_ids = [] if error_ids_elem is None else get_xml_attrs(error_ids_elem, '{%s}SubscriptionId' % MNS) - self.connection_status = get_xml_attr(message, '{%s}ConnectionStatus' % MNS) # Either 'OK' or 'Closed' + error_ids_elem = message.find(f'{{{MNS}}}ErrorSubscriptionIds') + error_ids = [] if error_ids_elem is None else get_xml_attrs(error_ids_elem, f'{{{MNS}}}SubscriptionId') + self.connection_status = get_xml_attr(message, f'{{{MNS}}}ConnectionStatus') # Either 'OK' or 'Closed' log.debug('Connection status is: %s', self.connection_status) # Upstream normally expects to find a 'name' tag but our response does not always have it. We still want to # call upstream, to have exceptions raised. Return an empty list if there is no 'name' tag and no errors. @@ -111,21 +111,21 @@

      Module exchangelib.services.get_streaming_events< # subscriptions seem to never be returned even though the XML spec allows it. This means there's no point in # trying to collect any notifications here and delivering a combination of errors and return values. if error_ids: - e.value += ' (subscription IDs: %s)' % ', '.join(repr(i) for i in error_ids) + e.value += f' (subscription IDs: {error_ids})' raise e return [] if name is None else res def get_payload(self, subscription_ids, connection_timeout): - getstreamingevents = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') subscriptions_elem = create_element('m:SubscriptionIds') for subscription_id in subscription_ids: add_xml_child(subscriptions_elem, 't:SubscriptionId', subscription_id) if not len(subscriptions_elem): - raise ValueError('"subscription_ids" must not be empty') + raise ValueError("'subscription_ids' must not be empty") - getstreamingevents.append(subscriptions_elem) - add_xml_child(getstreamingevents, 'm:ConnectionTimeout', connection_timeout) - return getstreamingevents

      + payload.append(subscriptions_elem) + add_xml_child(payload, 'm:ConnectionTimeout', connection_timeout) + return payload

    @@ -154,7 +154,7 @@

    Classes

    """ SERVICE_NAME = 'GetStreamingEvents' - element_container_name = '{%s}Notifications' % MNS + element_container_name = f'{{{MNS}}}Notifications' prefer_affinity = True # Connection status values @@ -168,18 +168,18 @@

    Classes

    self.streaming = True def call(self, subscription_ids, connection_timeout): + if not isinstance(connection_timeout, int): + raise InvalidTypeError('connection_timeout', connection_timeout, int) if connection_timeout < 1: - raise ValueError("'connection_timeout' must be a positive integer") + raise ValueError(f"'connection_timeout' {connection_timeout} must be a positive integer") + # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed + self.timeout = connection_timeout * 60 + 60 return self._elems_to_objs(self._get_elements(payload=self.get_payload( subscription_ids=subscription_ids, connection_timeout=connection_timeout, ))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Notification.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return Notification.from_xml(elem=elem, account=None) @classmethod def _get_soap_parts(cls, response, **parse_opts): @@ -192,8 +192,8 @@

    Classes

    # XML response. r = body for i, doc in enumerate(DocumentYielder(r.iter_content()), start=1): - xml_log.debug('''Response XML (docs received: %(i)s): %(xml_response)s''', dict(i=i, xml_response=doc)) - response = DummyResponse(url=None, headers=None, request_headers=None, content=doc) + xml_log.debug('''Response XML (docs counter: %(i)s): %(xml_response)s''', dict(i=i, xml_response=doc)) + response = DummyResponse(content=doc) try: _, body = super()._get_soap_parts(response=response, **parse_opts) except Exception: @@ -207,9 +207,9 @@

    Classes

    break def _get_element_container(self, message, name=None): - error_ids_elem = message.find('{%s}ErrorSubscriptionIds' % MNS) - error_ids = [] if error_ids_elem is None else get_xml_attrs(error_ids_elem, '{%s}SubscriptionId' % MNS) - self.connection_status = get_xml_attr(message, '{%s}ConnectionStatus' % MNS) # Either 'OK' or 'Closed' + error_ids_elem = message.find(f'{{{MNS}}}ErrorSubscriptionIds') + error_ids = [] if error_ids_elem is None else get_xml_attrs(error_ids_elem, f'{{{MNS}}}SubscriptionId') + self.connection_status = get_xml_attr(message, f'{{{MNS}}}ConnectionStatus') # Either 'OK' or 'Closed' log.debug('Connection status is: %s', self.connection_status) # Upstream normally expects to find a 'name' tag but our response does not always have it. We still want to # call upstream, to have exceptions raised. Return an empty list if there is no 'name' tag and no errors. @@ -222,21 +222,21 @@

    Classes

    # subscriptions seem to never be returned even though the XML spec allows it. This means there's no point in # trying to collect any notifications here and delivering a combination of errors and return values. if error_ids: - e.value += ' (subscription IDs: %s)' % ', '.join(repr(i) for i in error_ids) + e.value += f' (subscription IDs: {error_ids})' raise e return [] if name is None else res def get_payload(self, subscription_ids, connection_timeout): - getstreamingevents = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') subscriptions_elem = create_element('m:SubscriptionIds') for subscription_id in subscription_ids: add_xml_child(subscriptions_elem, 't:SubscriptionId', subscription_id) if not len(subscriptions_elem): - raise ValueError('"subscription_ids" must not be empty') + raise ValueError("'subscription_ids' must not be empty") - getstreamingevents.append(subscriptions_elem) - add_xml_child(getstreamingevents, 'm:ConnectionTimeout', connection_timeout) - return getstreamingevents + payload.append(subscriptions_elem) + add_xml_child(payload, 'm:ConnectionTimeout', connection_timeout) + return payload

    Ancestors

      @@ -278,8 +278,12 @@

      Methods

      Expand source code
      def call(self, subscription_ids, connection_timeout):
      +    if not isinstance(connection_timeout, int):
      +        raise InvalidTypeError('connection_timeout', connection_timeout, int)
           if connection_timeout < 1:
      -        raise ValueError("'connection_timeout' must be a positive integer")
      +        raise ValueError(f"'connection_timeout' {connection_timeout} must be a positive integer")
      +    # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed
      +    self.timeout = connection_timeout * 60 + 60
           return self._elems_to_objs(self._get_elements(payload=self.get_payload(
                   subscription_ids=subscription_ids, connection_timeout=connection_timeout,
           )))
      @@ -295,16 +299,16 @@

      Methods

      Expand source code
      def get_payload(self, subscription_ids, connection_timeout):
      -    getstreamingevents = create_element('m:%s' % self.SERVICE_NAME)
      +    payload = create_element(f'm:{self.SERVICE_NAME}')
           subscriptions_elem = create_element('m:SubscriptionIds')
           for subscription_id in subscription_ids:
               add_xml_child(subscriptions_elem, 't:SubscriptionId', subscription_id)
           if not len(subscriptions_elem):
      -        raise ValueError('"subscription_ids" must not be empty')
      +        raise ValueError("'subscription_ids' must not be empty")
       
      -    getstreamingevents.append(subscriptions_elem)
      -    add_xml_child(getstreamingevents, 'm:ConnectionTimeout', connection_timeout)
      -    return getstreamingevents
      + payload.append(subscriptions_elem) + add_xml_child(payload, 'm:ConnectionTimeout', connection_timeout) + return payload diff --git a/docs/exchangelib/services/get_user_availability.html b/docs/exchangelib/services/get_user_availability.html index 01068164..1e50527e 100644 --- a/docs/exchangelib/services/get_user_availability.html +++ b/docs/exchangelib/services/get_user_availability.html @@ -48,39 +48,34 @@

      Module exchangelib.services.get_user_availability free_busy_view_options=free_busy_view_options ))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield FreeBusyView.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return FreeBusyView.from_xml(elem=elem, account=None) def get_payload(self, timezone, mailbox_data, free_busy_view_options): - payload = create_element('m:%sRequest' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}Request') set_xml_value(payload, timezone, version=self.protocol.version) mailbox_data_array = create_element('m:MailboxDataArray') set_xml_value(mailbox_data_array, mailbox_data, version=self.protocol.version) payload.append(mailbox_data_array) - set_xml_value(payload, free_busy_view_options, version=self.protocol.version) - return payload + return set_xml_value(payload, free_busy_view_options, version=self.protocol.version) @staticmethod def _response_messages_tag(): - return '{%s}FreeBusyResponseArray' % MNS + return f'{{{MNS}}}FreeBusyResponseArray' @classmethod def _response_message_tag(cls): - return '{%s}FreeBusyResponse' % MNS + return f'{{{MNS}}}FreeBusyResponse' def _get_elements_in_response(self, response): for msg in response: # Just check the response code and raise errors - self._get_element_container(message=msg.find('{%s}ResponseMessage' % MNS)) + self._get_element_container(message=msg.find(f'{{{MNS}}}ResponseMessage')) yield from self._get_elements_in_container(container=msg) @classmethod def _get_elements_in_container(cls, container): - return [container.find('{%s}FreeBusyView' % MNS)] + return [container.find(f'{{{MNS}}}FreeBusyView')]

    @@ -121,39 +116,34 @@

    Classes

    free_busy_view_options=free_busy_view_options ))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield FreeBusyView.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return FreeBusyView.from_xml(elem=elem, account=None) def get_payload(self, timezone, mailbox_data, free_busy_view_options): - payload = create_element('m:%sRequest' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}Request') set_xml_value(payload, timezone, version=self.protocol.version) mailbox_data_array = create_element('m:MailboxDataArray') set_xml_value(mailbox_data_array, mailbox_data, version=self.protocol.version) payload.append(mailbox_data_array) - set_xml_value(payload, free_busy_view_options, version=self.protocol.version) - return payload + return set_xml_value(payload, free_busy_view_options, version=self.protocol.version) @staticmethod def _response_messages_tag(): - return '{%s}FreeBusyResponseArray' % MNS + return f'{{{MNS}}}FreeBusyResponseArray' @classmethod def _response_message_tag(cls): - return '{%s}FreeBusyResponse' % MNS + return f'{{{MNS}}}FreeBusyResponse' def _get_elements_in_response(self, response): for msg in response: # Just check the response code and raise errors - self._get_element_container(message=msg.find('{%s}ResponseMessage' % MNS)) + self._get_element_container(message=msg.find(f'{{{MNS}}}ResponseMessage')) yield from self._get_elements_in_container(container=msg) @classmethod def _get_elements_in_container(cls, container): - return [container.find('{%s}FreeBusyView' % MNS)] + return [container.find(f'{{{MNS}}}FreeBusyView')]

    Ancestors

      @@ -197,13 +187,12 @@

      Methods

      Expand source code
      def get_payload(self, timezone, mailbox_data, free_busy_view_options):
      -    payload = create_element('m:%sRequest' % self.SERVICE_NAME)
      +    payload = create_element(f'm:{self.SERVICE_NAME}Request')
           set_xml_value(payload, timezone, version=self.protocol.version)
           mailbox_data_array = create_element('m:MailboxDataArray')
           set_xml_value(mailbox_data_array, mailbox_data, version=self.protocol.version)
           payload.append(mailbox_data_array)
      -    set_xml_value(payload, free_busy_view_options, version=self.protocol.version)
      -    return payload
      + return set_xml_value(payload, free_busy_view_options, version=self.protocol.version) diff --git a/docs/exchangelib/services/get_user_configuration.html b/docs/exchangelib/services/get_user_configuration.html index 1b913ec4..ce9e2a08 100644 --- a/docs/exchangelib/services/get_user_configuration.html +++ b/docs/exchangelib/services/get_user_configuration.html @@ -27,6 +27,7 @@

      Module exchangelib.services.get_user_configurationExpand source code
      from .common import EWSAccountService
      +from ..errors import InvalidEnumValue
       from ..properties import UserConfiguration
       from ..util import create_element, set_xml_value
       
      @@ -35,7 +36,7 @@ 

      Module exchangelib.services.get_user_configurationModule exchangelib.services.get_user_configuration

      + payload = create_element(f'm:{self.SERVICE_NAME}') + set_xml_value(payload, user_configuration_name, version=self.account.version) + payload.append( + set_xml_value(create_element('m:UserConfigurationProperties'), properties, version=self.account.version) + ) + return payload

    @@ -101,29 +98,25 @@

    Classes

    def call(self, user_configuration_name, properties): if properties not in PROPERTIES_CHOICES: - raise ValueError("'properties' %r must be one of %s" % (properties, PROPERTIES_CHOICES)) + raise InvalidEnumValue('properties', properties, PROPERTIES_CHOICES) return self._elems_to_objs(self._get_elements(payload=self.get_payload( user_configuration_name=user_configuration_name, properties=properties ))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield UserConfiguration.from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return UserConfiguration.from_xml(elem=elem, account=self.account) @classmethod def _get_elements_in_container(cls, container): return container.findall(UserConfiguration.response_tag()) def get_payload(self, user_configuration_name, properties): - getuserconfiguration = create_element('m:%s' % self.SERVICE_NAME) - set_xml_value(getuserconfiguration, user_configuration_name, version=self.account.version) - user_configuration_properties = create_element('m:UserConfigurationProperties') - set_xml_value(user_configuration_properties, properties, version=self.account.version) - getuserconfiguration.append(user_configuration_properties) - return getuserconfiguration + payload = create_element(f'm:{self.SERVICE_NAME}') + set_xml_value(payload, user_configuration_name, version=self.account.version) + payload.append( + set_xml_value(create_element('m:UserConfigurationProperties'), properties, version=self.account.version) + ) + return payload

    Ancestors

      @@ -150,7 +143,7 @@

      Methods

      def call(self, user_configuration_name, properties):
           if properties not in PROPERTIES_CHOICES:
      -        raise ValueError("'properties' %r must be one of %s" % (properties, PROPERTIES_CHOICES))
      +        raise InvalidEnumValue('properties', properties, PROPERTIES_CHOICES)
           return self._elems_to_objs(self._get_elements(payload=self.get_payload(
                   user_configuration_name=user_configuration_name, properties=properties
           )))
      @@ -166,12 +159,12 @@

      Methods

      Expand source code
      def get_payload(self, user_configuration_name, properties):
      -    getuserconfiguration = create_element('m:%s' % self.SERVICE_NAME)
      -    set_xml_value(getuserconfiguration, user_configuration_name, version=self.account.version)
      -    user_configuration_properties = create_element('m:UserConfigurationProperties')
      -    set_xml_value(user_configuration_properties, properties, version=self.account.version)
      -    getuserconfiguration.append(user_configuration_properties)
      -    return getuserconfiguration
      + payload = create_element(f'm:{self.SERVICE_NAME}') + set_xml_value(payload, user_configuration_name, version=self.account.version) + payload.append( + set_xml_value(create_element('m:UserConfigurationProperties'), properties, version=self.account.version) + ) + return payload diff --git a/docs/exchangelib/services/get_user_oof_settings.html b/docs/exchangelib/services/get_user_oof_settings.html index 0c99a278..84431936 100644 --- a/docs/exchangelib/services/get_user_oof_settings.html +++ b/docs/exchangelib/services/get_user_oof_settings.html @@ -38,21 +38,20 @@

      Module exchangelib.services.get_user_oof_settings """ SERVICE_NAME = 'GetUserOofSettings' - element_container_name = '{%s}OofSettings' % TNS + element_container_name = f'{{{TNS}}}OofSettings' def call(self, mailbox): return self._elems_to_objs(self._get_elements(payload=self.get_payload(mailbox=mailbox))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield OofSettings.from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return OofSettings.from_xml(elem=elem, account=self.account) def get_payload(self, mailbox): - payload = create_element('m:%sRequest' % self.SERVICE_NAME) - return set_xml_value(payload, AvailabilityMailbox.from_mailbox(mailbox), version=self.account.version) + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}Request'), + AvailabilityMailbox.from_mailbox(mailbox), + version=self.account.version + ) @classmethod def _get_elements_in_container(cls, container): @@ -66,7 +65,7 @@

      Module exchangelib.services.get_user_oof_settings @classmethod def _response_message_tag(cls): - return '{%s}ResponseMessage' % MNS + return f'{{{MNS}}}ResponseMessage'

    @@ -95,21 +94,20 @@

    Classes

    """ SERVICE_NAME = 'GetUserOofSettings' - element_container_name = '{%s}OofSettings' % TNS + element_container_name = f'{{{TNS}}}OofSettings' def call(self, mailbox): return self._elems_to_objs(self._get_elements(payload=self.get_payload(mailbox=mailbox))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield OofSettings.from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return OofSettings.from_xml(elem=elem, account=self.account) def get_payload(self, mailbox): - payload = create_element('m:%sRequest' % self.SERVICE_NAME) - return set_xml_value(payload, AvailabilityMailbox.from_mailbox(mailbox), version=self.account.version) + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}Request'), + AvailabilityMailbox.from_mailbox(mailbox), + version=self.account.version + ) @classmethod def _get_elements_in_container(cls, container): @@ -123,7 +121,7 @@

    Classes

    @classmethod def _response_message_tag(cls): - return '{%s}ResponseMessage' % MNS + return f'{{{MNS}}}ResponseMessage'

    Ancestors

      @@ -166,8 +164,11 @@

      Methods

      Expand source code
      def get_payload(self, mailbox):
      -    payload = create_element('m:%sRequest' % self.SERVICE_NAME)
      -    return set_xml_value(payload, AvailabilityMailbox.from_mailbox(mailbox), version=self.account.version)
      + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}Request'), + AvailabilityMailbox.from_mailbox(mailbox), + version=self.account.version + ) diff --git a/docs/exchangelib/services/index.html b/docs/exchangelib/services/index.html index 662c2a94..b9eae536 100644 --- a/docs/exchangelib/services/index.html +++ b/docs/exchangelib/services/index.html @@ -40,8 +40,8 @@

      Module exchangelib.services

      https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/ews-operations-in-exchange """ +from .common import CHUNK_SIZE, PAGE_SIZE from .archive_item import ArchiveItem -from .common import CHUNK_SIZE from .convert_id import ConvertId from .copy_item import CopyItem from .create_attachment import CreateAttachment @@ -91,6 +91,7 @@

      Module exchangelib.services

      __all__ = [ 'CHUNK_SIZE', + 'PAGE_SIZE', 'ArchiveItem', 'ConvertId', 'CopyItem', @@ -362,7 +363,7 @@

      Classes

      """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/archiveitem-operation""" SERVICE_NAME = 'ArchiveItem' - element_container_name = '{%s}Items' % MNS + element_container_name = f'{{{MNS}}}Items' supported_from = EXCHANGE_2013 def call(self, items, to_folder): @@ -375,22 +376,16 @@

      Classes

      """ return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, to_folder=to_folder)) - def _elems_to_objs(self, elems): - from ..items import Item - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Item.id_from_xml(elem) + def _elem_to_obj(self, elem): + return Item.id_from_xml(elem) def get_payload(self, items, to_folder): - archiveitem = create_element('m:%s' % self.SERVICE_NAME) - folder_id = create_folder_ids_element(tag='m:ArchiveSourceFolderId', folders=[to_folder], - version=self.account.version) - item_ids = create_item_ids_element(items=items, version=self.account.version) - archiveitem.append(folder_id) - archiveitem.append(item_ids) - return archiveitem + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append( + folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ArchiveSourceFolderId') + ) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload

      Ancestors

        @@ -447,13 +442,12 @@

        Methods

        Expand source code
        def get_payload(self, items, to_folder):
        -    archiveitem = create_element('m:%s' % self.SERVICE_NAME)
        -    folder_id = create_folder_ids_element(tag='m:ArchiveSourceFolderId', folders=[to_folder],
        -                                          version=self.account.version)
        -    item_ids = create_item_ids_element(items=items, version=self.account.version)
        -    archiveitem.append(folder_id)
        -    archiveitem.append(item_ids)
        -    return archiveitem
        + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append( + folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ArchiveSourceFolderId') + ) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload @@ -490,36 +484,30 @@

        Inherited members

        SERVICE_NAME = 'ConvertId' supported_from = EXCHANGE_2007_SP1 + cls_map = {cls.response_tag(): cls for cls in ( + AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId + )} def call(self, items, destination_format): if destination_format not in ID_FORMATS: - raise ValueError("'destination_format' %r must be one of %s" % (destination_format, ID_FORMATS)) + raise InvalidEnumValue('destination_format', destination_format, ID_FORMATS) return self._elems_to_objs( self._chunked_get_elements(self.get_payload, items=items, destination_format=destination_format) ) - def _elems_to_objs(self, elems): - cls_map = {cls.response_tag(): cls for cls in ( - AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId - )} - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield cls_map[elem.tag].from_xml(elem, account=None) + def _elem_to_obj(self, elem): + return self.cls_map[elem.tag].from_xml(elem, account=None) def get_payload(self, items, destination_format): supported_item_classes = AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId - convertid = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(DestinationFormat=destination_format)) + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DestinationFormat=destination_format)) item_ids = create_element('m:SourceIds') for item in items: if not isinstance(item, supported_item_classes): - raise ValueError("'item' value %r must be an instance of %r" % (item, supported_item_classes)) + raise InvalidTypeError('item', item, supported_item_classes) set_xml_value(item_ids, item, version=self.protocol.version) - if not len(item_ids): - raise ValueError('"items" must not be empty') - convertid.append(item_ids) - return convertid + payload.append(item_ids) + return payload @classmethod def _get_elements_in_container(cls, container): @@ -538,6 +526,10 @@

        Class variables

        +
        var cls_map
        +
        +
        +
        var supported_from
        @@ -556,7 +548,7 @@

        Methods

        def call(self, items, destination_format):
             if destination_format not in ID_FORMATS:
        -        raise ValueError("'destination_format' %r must be one of %s" % (destination_format, ID_FORMATS))
        +        raise InvalidEnumValue('destination_format', destination_format, ID_FORMATS)
             return self._elems_to_objs(
                 self._chunked_get_elements(self.get_payload, items=items, destination_format=destination_format)
             )
        @@ -573,16 +565,14 @@

        Methods

        def get_payload(self, items, destination_format):
             supported_item_classes = AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId
        -    convertid = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(DestinationFormat=destination_format))
        +    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DestinationFormat=destination_format))
             item_ids = create_element('m:SourceIds')
             for item in items:
                 if not isinstance(item, supported_item_classes):
        -            raise ValueError("'item' value %r must be an instance of %r" % (item, supported_item_classes))
        +            raise InvalidTypeError('item', item, supported_item_classes)
                 set_xml_value(item_ids, item, version=self.protocol.version)
        -    if not len(item_ids):
        -        raise ValueError('"items" must not be empty')
        -    convertid.append(item_ids)
        -    return convertid
        + payload.append(item_ids) + return payload
        @@ -655,33 +645,25 @@

        Inherited members

        """ SERVICE_NAME = 'CreateAttachment' - element_container_name = '{%s}Attachments' % MNS + element_container_name = f'{{{MNS}}}Attachments' + cls_map = {cls.response_tag(): cls for cls in (FileAttachment, ItemAttachment)} def call(self, parent_item, items): return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, parent_item=parent_item)) - def _elems_to_objs(self, elems): - from ..attachments import FileAttachment, ItemAttachment - cls_map = {cls.response_tag(): cls for cls in (FileAttachment, ItemAttachment)} - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield cls_map[elem.tag].from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return self.cls_map[elem.tag].from_xml(elem=elem, account=self.account) def get_payload(self, items, parent_item): - from ..items import BaseItem - payload = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') version = self.account.version if isinstance(parent_item, BaseItem): # to_item_id() would convert this to a normal ItemId, but the service wants a ParentItemId parent_item = ParentItemId(parent_item.id, parent_item.changekey) - set_xml_value(payload, to_item_id(parent_item, ParentItemId, version=version), version=version) + set_xml_value(payload, to_item_id(parent_item, ParentItemId), version=self.account.version) attachments = create_element('m:Attachments') for item in items: - set_xml_value(attachments, item, version=self.account.version) - if not len(attachments): - raise ValueError('"items" must not be empty') + set_xml_value(attachments, item, version=version) payload.append(attachments) return payload @@ -696,6 +678,10 @@

        Class variables

        +
        var cls_map
        +
        +
        +
        var element_container_name
        @@ -726,18 +712,15 @@

        Methods

        Expand source code
        def get_payload(self, items, parent_item):
        -    from ..items import BaseItem
        -    payload = create_element('m:%s' % self.SERVICE_NAME)
        +    payload = create_element(f'm:{self.SERVICE_NAME}')
             version = self.account.version
             if isinstance(parent_item, BaseItem):
                 # to_item_id() would convert this to a normal ItemId, but the service wants a ParentItemId
                 parent_item = ParentItemId(parent_item.id, parent_item.changekey)
        -    set_xml_value(payload, to_item_id(parent_item, ParentItemId, version=version), version=version)
        +    set_xml_value(payload, to_item_id(parent_item, ParentItemId), version=self.account.version)
             attachments = create_element('m:Attachments')
             for item in items:
        -        set_xml_value(attachments, item, version=self.account.version)
        -    if not len(attachments):
        -        raise ValueError('"items" must not be empty')
        +        set_xml_value(attachments, item, version=version)
             payload.append(attachments)
             return payload
        @@ -769,7 +752,10 @@

        Inherited members

        """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createfolder-operation""" SERVICE_NAME = 'CreateFolder' - element_container_name = '{%s}Folders' % MNS + element_container_name = f'{{{MNS}}}Folders' + ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + ( + ErrorFolderExists, + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -791,13 +777,12 @@

        Inherited members

        yield parse_folder_elem(elem=elem, folder=folder, account=self.account) def get_payload(self, folders, parent_folder): - create_folder = create_element('m:%s' % self.SERVICE_NAME) - parentfolderid = create_element('m:ParentFolderId') - set_xml_value(parentfolderid, parent_folder, version=self.account.version) - set_xml_value(create_folder, parentfolderid, version=self.account.version) - folder_ids = create_folder_ids_element(tag='m:Folders', folders=folders, version=self.account.version) - create_folder.append(folder_ids) - return create_folder + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append( + folder_ids_element(folders=[parent_folder], version=self.account.version, tag='m:ParentFolderId') + ) + payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:Folders')) + return payload

        Ancestors

          @@ -806,6 +791,10 @@

          Ancestors

        Class variables

        +
        var ERRORS_TO_CATCH_IN_RESPONSE
        +
        +
        +
        var SERVICE_NAME
        @@ -845,13 +834,12 @@

        Methods

        Expand source code
        def get_payload(self, folders, parent_folder):
        -    create_folder = create_element('m:%s' % self.SERVICE_NAME)
        -    parentfolderid = create_element('m:ParentFolderId')
        -    set_xml_value(parentfolderid, parent_folder, version=self.account.version)
        -    set_xml_value(create_folder, parentfolderid, version=self.account.version)
        -    folder_ids = create_folder_ids_element(tag='m:Folders', folders=folders, version=self.account.version)
        -    create_folder.append(folder_ids)
        -    return create_folder
        + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append( + folder_ids_element(folders=[parent_folder], version=self.account.version, tag='m:ParentFolderId') + ) + payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:Folders')) + return payload
        @@ -887,25 +875,20 @@

        Inherited members

        """ SERVICE_NAME = 'CreateItem' - element_container_name = '{%s}Items' % MNS + element_container_name = f'{{{MNS}}}Items' def call(self, items, folder, message_disposition, send_meeting_invitations): - from ..folders import BaseFolder, FolderId - from ..items import SAVE_ONLY, SEND_AND_SAVE_COPY, SEND_ONLY, \ - SEND_MEETING_INVITATIONS_CHOICES, MESSAGE_DISPOSITION_CHOICES if message_disposition not in MESSAGE_DISPOSITION_CHOICES: - raise ValueError("'message_disposition' %s must be one of %s" % ( - message_disposition, MESSAGE_DISPOSITION_CHOICES - )) + raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES) if send_meeting_invitations not in SEND_MEETING_INVITATIONS_CHOICES: - raise ValueError("'send_meeting_invitations' %s must be one of %s" % ( - send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES - )) + raise InvalidEnumValue( + 'send_meeting_invitations', send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES + ) if folder is not None: if not isinstance(folder, (BaseFolder, FolderId)): - raise ValueError("'folder' %r must be a Folder or FolderId instance" % folder) + raise InvalidTypeError('folder', folder, (BaseFolder, FolderId)) if folder.account != self.account: - raise ValueError('"Folder must belong to this account') + raise ValueError('Folder must belong to account') if message_disposition == SAVE_ONLY and folder is None: raise AttributeError("Folder must be supplied when in save-only mode") if message_disposition == SEND_AND_SAVE_COPY and folder is None: @@ -920,16 +903,10 @@

        Inherited members

        send_meeting_invitations=send_meeting_invitations, )) - def _elems_to_objs(self, elems): - from ..items import BulkCreateResult - for elem in elems: - if isinstance(elem, (Exception, type(None))): - yield elem - continue - if isinstance(elem, bool): - yield elem - continue - yield BulkCreateResult.from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + if isinstance(elem, bool): + return elem + return BulkCreateResult.from_xml(elem=elem, account=self.account) @classmethod def _get_elements_in_container(cls, container): @@ -955,26 +932,21 @@

        Inherited members

        :param message_disposition: :param send_meeting_invitations: """ - createitem = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('MessageDisposition', message_disposition), - ('SendMeetingInvitations', send_meeting_invitations), - ]) + payload = create_element( + f'm:{self.SERVICE_NAME}', + attrs=dict(MessageDisposition=message_disposition, SendMeetingInvitations=send_meeting_invitations) ) if folder: - saveditemfolderid = create_element('m:SavedItemFolderId') - set_xml_value(saveditemfolderid, folder, version=self.account.version) - createitem.append(saveditemfolderid) + payload.append(folder_ids_element( + folders=[folder], version=self.account.version, tag='m:SavedItemFolderId' + )) item_elems = create_element('m:Items') for item in items: if not item.account: item.account = self.account set_xml_value(item_elems, item, version=self.account.version) - if not len(item_elems): - raise ValueError('"items" must not be empty') - createitem.append(item_elems) - return createitem + payload.append(item_elems) + return payload

        Ancestors

          @@ -1004,22 +976,17 @@

          Methods

          Expand source code
          def call(self, items, folder, message_disposition, send_meeting_invitations):
          -    from ..folders import BaseFolder, FolderId
          -    from ..items import SAVE_ONLY, SEND_AND_SAVE_COPY, SEND_ONLY, \
          -        SEND_MEETING_INVITATIONS_CHOICES, MESSAGE_DISPOSITION_CHOICES
               if message_disposition not in MESSAGE_DISPOSITION_CHOICES:
          -        raise ValueError("'message_disposition' %s must be one of %s" % (
          -            message_disposition, MESSAGE_DISPOSITION_CHOICES
          -        ))
          +        raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES)
               if send_meeting_invitations not in SEND_MEETING_INVITATIONS_CHOICES:
          -        raise ValueError("'send_meeting_invitations' %s must be one of %s" % (
          -            send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES
          -        ))
          +        raise InvalidEnumValue(
          +            'send_meeting_invitations', send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES
          +        )
               if folder is not None:
                   if not isinstance(folder, (BaseFolder, FolderId)):
          -            raise ValueError("'folder' %r must be a Folder or FolderId instance" % folder)
          +            raise InvalidTypeError('folder', folder, (BaseFolder, FolderId))
                   if folder.account != self.account:
          -            raise ValueError('"Folder must belong to this account')
          +            raise ValueError('Folder must belong to account')
               if message_disposition == SAVE_ONLY and folder is None:
                   raise AttributeError("Folder must be supplied when in save-only mode")
               if message_disposition == SEND_AND_SAVE_COPY and folder is None:
          @@ -1076,26 +1043,21 @@ 

          Methods

          :param message_disposition: :param send_meeting_invitations: """ - createitem = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('MessageDisposition', message_disposition), - ('SendMeetingInvitations', send_meeting_invitations), - ]) + payload = create_element( + f'm:{self.SERVICE_NAME}', + attrs=dict(MessageDisposition=message_disposition, SendMeetingInvitations=send_meeting_invitations) ) if folder: - saveditemfolderid = create_element('m:SavedItemFolderId') - set_xml_value(saveditemfolderid, folder, version=self.account.version) - createitem.append(saveditemfolderid) + payload.append(folder_ids_element( + folders=[folder], version=self.account.version, tag='m:SavedItemFolderId' + )) item_elems = create_element('m:Items') for item in items: if not item.account: item.account = self.account set_xml_value(item_elems, item, version=self.account.version) - if not len(item_elems): - raise ValueError('"items" must not be empty') - createitem.append(item_elems) - return createitem
          + payload.append(item_elems) + return payload
        @@ -1134,9 +1096,9 @@

        Inherited members

        return self._get_elements(payload=self.get_payload(user_configuration=user_configuration)) def get_payload(self, user_configuration): - createuserconfiguration = create_element('m:%s' % self.SERVICE_NAME) - set_xml_value(createuserconfiguration, user_configuration, version=self.protocol.version) - return createuserconfiguration + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), user_configuration, version=self.protocol.version + )

        Ancestors

          @@ -1179,9 +1141,9 @@

          Methods

          Expand source code
          def get_payload(self, user_configuration):
          -    createuserconfiguration = create_element('m:%s' % self.SERVICE_NAME)
          -    set_xml_value(createuserconfiguration, user_configuration, version=self.protocol.version)
          -    return createuserconfiguration
          + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), user_configuration, version=self.protocol.version + ) @@ -1218,22 +1180,19 @@

          Inherited members

          def call(self, items): return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items)) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield RootItemId.from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return RootItemId.from_xml(elem=elem, account=self.account) @classmethod def _get_elements_in_container(cls, container): return container.findall(RootItemId.response_tag()) def get_payload(self, items): - payload = create_element('m:%s' % self.SERVICE_NAME) - attachment_ids = create_attachment_ids_element(items=items, version=self.account.version) - payload.append(attachment_ids) - return payload + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), + attachment_ids_element(items=items, version=self.account.version), + version=self.account.version + )

          Ancestors

            @@ -1272,10 +1231,11 @@

            Methods

            Expand source code
            def get_payload(self, items):
            -    payload = create_element('m:%s' % self.SERVICE_NAME)
            -    attachment_ids = create_attachment_ids_element(items=items, version=self.account.version)
            -    payload.append(attachment_ids)
            -    return payload
            + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), + attachment_ids_element(items=items, version=self.account.version), + version=self.account.version + ) @@ -1308,13 +1268,14 @@

            Inherited members

            returns_elements = False def call(self, folders, delete_type): + if delete_type not in DELETE_TYPE_CHOICES: + raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES) return self._chunked_get_elements(self.get_payload, items=folders, delete_type=delete_type) def get_payload(self, folders, delete_type): - deletefolder = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(DeleteType=delete_type)) - folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version) - deletefolder.append(folder_ids) - return deletefolder + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DeleteType=delete_type)) + payload.append(folder_ids_element(folders=folders, version=self.account.version)) + return payload

            Ancestors

              @@ -1344,6 +1305,8 @@

              Methods

              Expand source code
              def call(self, folders, delete_type):
              +    if delete_type not in DELETE_TYPE_CHOICES:
              +        raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES)
                   return self._chunked_get_elements(self.get_payload, items=folders, delete_type=delete_type)
              @@ -1357,10 +1320,9 @@

              Methods

              Expand source code
              def get_payload(self, folders, delete_type):
              -    deletefolder = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(DeleteType=delete_type))
              -    folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version)
              -    deletefolder.append(folder_ids)
              -    return deletefolder
              + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DeleteType=delete_type)) + payload.append(folder_ids_element(folders=folders, version=self.account.version)) + return payload @@ -1399,21 +1361,16 @@

              Inherited members

              returns_elements = False def call(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): - from ..items import DELETE_TYPE_CHOICES, SEND_MEETING_CANCELLATIONS_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES if delete_type not in DELETE_TYPE_CHOICES: - raise ValueError("'delete_type' %s must be one of %s" % ( - delete_type, DELETE_TYPE_CHOICES - )) + raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES) if send_meeting_cancellations not in SEND_MEETING_CANCELLATIONS_CHOICES: - raise ValueError("'send_meeting_cancellations' %s must be one of %s" % ( - send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES - )) + raise InvalidEnumValue( + 'send_meeting_cancellations', send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES + ) if affected_task_occurrences not in AFFECTED_TASK_OCCURRENCES_CHOICES: - raise ValueError("'affected_task_occurrences' %s must be one of %s" % ( - affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES - )) - if suppress_read_receipts not in (True, False): - raise ValueError("'suppress_read_receipts' %s must be True or False" % suppress_read_receipts) + raise InvalidEnumValue( + 'affected_task_occurrences', affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES + ) return self._chunked_get_elements( self.get_payload, items=items, @@ -1426,29 +1383,16 @@

              Inherited members

              def get_payload(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): # Takes a list of (id, changekey) tuples or Item objects and returns the XML for a DeleteItem request. + attrs = dict( + DeleteType=delete_type, + SendMeetingCancellations=send_meeting_cancellations, + AffectedTaskOccurrences=affected_task_occurrences, + ) if self.account.version.build >= EXCHANGE_2013_SP1: - deleteitem = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('DeleteType', delete_type), - ('SendMeetingCancellations', send_meeting_cancellations), - ('AffectedTaskOccurrences', affected_task_occurrences), - ('SuppressReadReceipts', 'true' if suppress_read_receipts else 'false'), - ]) - ) - else: - deleteitem = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('DeleteType', delete_type), - ('SendMeetingCancellations', send_meeting_cancellations), - ('AffectedTaskOccurrences', affected_task_occurrences), - ]) - ) - - item_ids = create_item_ids_element(items=items, version=self.account.version) - deleteitem.append(item_ids) - return deleteitem + attrs['SuppressReadReceipts'] = suppress_read_receipts + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload

              Ancestors

                @@ -1478,21 +1422,16 @@

                Methods

                Expand source code
                def call(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts):
                -    from ..items import DELETE_TYPE_CHOICES, SEND_MEETING_CANCELLATIONS_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES
                     if delete_type not in DELETE_TYPE_CHOICES:
                -        raise ValueError("'delete_type' %s must be one of %s" % (
                -            delete_type, DELETE_TYPE_CHOICES
                -        ))
                +        raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES)
                     if send_meeting_cancellations not in SEND_MEETING_CANCELLATIONS_CHOICES:
                -        raise ValueError("'send_meeting_cancellations' %s must be one of %s" % (
                -            send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES
                -        ))
                +        raise InvalidEnumValue(
                +            'send_meeting_cancellations', send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES
                +        )
                     if affected_task_occurrences not in AFFECTED_TASK_OCCURRENCES_CHOICES:
                -        raise ValueError("'affected_task_occurrences' %s must be one of %s" % (
                -            affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES
                -        ))
                -    if suppress_read_receipts not in (True, False):
                -        raise ValueError("'suppress_read_receipts' %s must be True or False" % suppress_read_receipts)
                +        raise InvalidEnumValue(
                +            'affected_task_occurrences', affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES
                +        )
                     return self._chunked_get_elements(
                         self.get_payload,
                         items=items,
                @@ -1515,29 +1454,16 @@ 

                Methods

                def get_payload(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences,
                                 suppress_read_receipts):
                     # Takes a list of (id, changekey) tuples or Item objects and returns the XML for a DeleteItem request.
                +    attrs = dict(
                +        DeleteType=delete_type,
                +        SendMeetingCancellations=send_meeting_cancellations,
                +        AffectedTaskOccurrences=affected_task_occurrences,
                +    )
                     if self.account.version.build >= EXCHANGE_2013_SP1:
                -        deleteitem = create_element(
                -            'm:%s' % self.SERVICE_NAME,
                -            attrs=OrderedDict([
                -                ('DeleteType', delete_type),
                -                ('SendMeetingCancellations', send_meeting_cancellations),
                -                ('AffectedTaskOccurrences', affected_task_occurrences),
                -                ('SuppressReadReceipts', 'true' if suppress_read_receipts else 'false'),
                -            ])
                -        )
                -    else:
                -        deleteitem = create_element(
                -            'm:%s' % self.SERVICE_NAME,
                -            attrs=OrderedDict([
                -                ('DeleteType', delete_type),
                -                ('SendMeetingCancellations', send_meeting_cancellations),
                -                ('AffectedTaskOccurrences', affected_task_occurrences),
                -             ])
                -        )
                -
                -    item_ids = create_item_ids_element(items=items, version=self.account.version)
                -    deleteitem.append(item_ids)
                -    return deleteitem
                + attrs['SuppressReadReceipts'] = suppress_read_receipts + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload
                @@ -1576,9 +1502,9 @@

                Inherited members

                return self._get_elements(payload=self.get_payload(user_configuration_name=user_configuration_name)) def get_payload(self, user_configuration_name): - deleteuserconfiguration = create_element('m:%s' % self.SERVICE_NAME) - set_xml_value(deleteuserconfiguration, user_configuration_name, version=self.account.version) - return deleteuserconfiguration + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), user_configuration_name, version=self.account.version + )

                Ancestors

                  @@ -1621,9 +1547,9 @@

                  Methods

                  Expand source code
                  def get_payload(self, user_configuration_name):
                  -    deleteuserconfiguration = create_element('m:%s' % self.SERVICE_NAME)
                  -    set_xml_value(deleteuserconfiguration, user_configuration_name, version=self.account.version)
                  -    return deleteuserconfiguration
                  + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), user_configuration_name, version=self.account.version + ) @@ -1656,21 +1582,19 @@

                  Inherited members

                  returns_elements = False def call(self, folders, delete_type, delete_sub_folders): + if delete_type not in DELETE_TYPE_CHOICES: + raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES) return self._chunked_get_elements( self.get_payload, items=folders, delete_type=delete_type, delete_sub_folders=delete_sub_folders ) def get_payload(self, folders, delete_type, delete_sub_folders): - emptyfolder = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('DeleteType', delete_type), - ('DeleteSubFolders', 'true' if delete_sub_folders else 'false'), - ]) + payload = create_element( + f'm:{self.SERVICE_NAME}', + attrs=dict(DeleteType=delete_type, DeleteSubFolders=delete_sub_folders) ) - folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version) - emptyfolder.append(folder_ids) - return emptyfolder + payload.append(folder_ids_element(folders=folders, version=self.account.version)) + return payload

                  Ancestors

                    @@ -1700,6 +1624,8 @@

                    Methods

                    Expand source code
                    def call(self, folders, delete_type, delete_sub_folders):
                    +    if delete_type not in DELETE_TYPE_CHOICES:
                    +        raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES)
                         return self._chunked_get_elements(
                             self.get_payload, items=folders, delete_type=delete_type, delete_sub_folders=delete_sub_folders
                         )
                    @@ -1715,16 +1641,12 @@

                    Methods

                    Expand source code
                    def get_payload(self, folders, delete_type, delete_sub_folders):
                    -    emptyfolder = create_element(
                    -        'm:%s' % self.SERVICE_NAME,
                    -        attrs=OrderedDict([
                    -            ('DeleteType', delete_type),
                    -            ('DeleteSubFolders', 'true' if delete_sub_folders else 'false'),
                    -        ])
                    +    payload = create_element(
                    +        f'm:{self.SERVICE_NAME}',
                    +        attrs=dict(DeleteType=delete_type, DeleteSubFolders=delete_sub_folders)
                         )
                    -    folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version)
                    -    emptyfolder.append(folder_ids)
                    -    return emptyfolder
                    + payload.append(folder_ids_element(folders=folders, version=self.account.version)) + return payload @@ -1754,23 +1676,17 @@

                    Inherited members

                    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/expanddl-operation""" SERVICE_NAME = 'ExpandDL' - element_container_name = '{%s}DLExpansion' % MNS + element_container_name = f'{{{MNS}}}DLExpansion' WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults def call(self, distribution_list): return self._elems_to_objs(self._get_elements(payload=self.get_payload(distribution_list=distribution_list))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Mailbox.from_xml(elem, account=None) + def _elem_to_obj(self, elem): + return Mailbox.from_xml(elem, account=None) def get_payload(self, distribution_list): - payload = create_element('m:%s' % self.SERVICE_NAME) - set_xml_value(payload, distribution_list, version=self.protocol.version) - return payload + return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), distribution_list, version=self.protocol.version)

                    Ancestors

                      @@ -1816,9 +1732,7 @@

                      Methods

                      Expand source code
                      def get_payload(self, distribution_list):
                      -    payload = create_element('m:%s' % self.SERVICE_NAME)
                      -    set_xml_value(payload, distribution_list, version=self.protocol.version)
                      -    return payload
                      + return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), distribution_list, version=self.protocol.version) @@ -1849,23 +1763,18 @@

                      Inherited members

                      ERRORS_TO_CATCH_IN_RESPONSE = ResponseMessageError SERVICE_NAME = 'ExportItems' - element_container_name = '{%s}Data' % MNS + element_container_name = f'{{{MNS}}}Data' def call(self, items): return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items)) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield elem.text # All we want is the 64bit string in the 'Data' tag + def _elem_to_obj(self, elem): + return elem.text # All we want is the 64bit string in the 'Data' tag def get_payload(self, items): - exportitems = create_element('m:%s' % self.SERVICE_NAME) - item_ids = create_item_ids_element(items=items, version=self.account.version) - exportitems.append(item_ids) - return exportitems + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload # We need to override this since ExportItemsResponseMessage is formatted a # little bit differently. . @@ -1918,10 +1827,9 @@

                      Methods

                      Expand source code
                      def get_payload(self, items):
                      -    exportitems = create_element('m:%s' % self.SERVICE_NAME)
                      -    item_ids = create_item_ids_element(items=items, version=self.account.version)
                      -    exportitems.append(item_ids)
                      -    return exportitems
                      + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload @@ -1947,12 +1855,12 @@

                      Inherited members

                      Expand source code -
                      class FindFolder(EWSAccountService):
                      +
                      class FindFolder(EWSPagingService):
                           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/findfolder-operation"""
                       
                           SERVICE_NAME = 'FindFolder'
                      -    element_container_name = '{%s}Folders' % TNS
                      -    paging_container_name = '{%s}RootFolder' % MNS
                      +    element_container_name = f'{{{TNS}}}Folders'
                      +    paging_container_name = f'{{{MNS}}}RootFolder'
                           supports_paging = True
                       
                           def __init__(self, *args, **kwargs):
                      @@ -1972,9 +1880,13 @@ 

                      Inherited members

                      :return: XML elements for the matching folders """ + if shape not in SHAPE_CHOICES: + raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + if depth not in FOLDER_TRAVERSAL_CHOICES: + raise InvalidEnumValue('depth', depth, FOLDER_TRAVERSAL_CHOICES) roots = {f.root for f in folders} if len(roots) != 1: - raise ValueError('FindFolder must be called with folders in the same root hierarchy (%r)' % roots) + raise ValueError(f"All folders in 'roots' must have the same root hierarchy ({roots})") self.root = roots.pop() return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, @@ -1985,47 +1897,36 @@

                      Inherited members

                      restriction=restriction, shape=shape, depth=depth, - page_size=self.chunk_size, + page_size=self.page_size, offset=offset, ) )) - def _elems_to_objs(self, elems): - from ..folders import Folder - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Folder.from_xml_with_root(elem=elem, root=self.root) + def _elem_to_obj(self, elem): + return Folder.from_xml_with_root(elem=elem, root=self.root) def get_payload(self, folders, additional_fields, restriction, shape, depth, page_size, offset=0): - findfolder = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth)) - foldershape = create_shape_element( + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) + payload.append(shape_element( tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version - ) - findfolder.append(foldershape) + )) if self.account.version.build >= EXCHANGE_2010: - indexedpageviewitem = create_element( + indexed_page_folder_view = create_element( 'm:IndexedPageFolderView', - attrs=OrderedDict([ - ('MaxEntriesReturned', str(page_size)), - ('Offset', str(offset)), - ('BasePoint', 'Beginning'), - ]) + attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') ) - findfolder.append(indexedpageviewitem) + payload.append(indexed_page_folder_view) else: if offset != 0: - raise ValueError('Offsets are only supported from Exchange 2010') + raise NotImplementedError("'offset' is only supported for Exchange 2010 servers and later") if restriction: - findfolder.append(restriction.to_xml(version=self.account.version)) - parentfolderids = create_element('m:ParentFolderIds') - set_xml_value(parentfolderids, folders, version=self.account.version) - findfolder.append(parentfolderids) - return findfolder
                      + payload.append(restriction.to_xml(version=self.account.version)) + payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds')) + return payload

                      Ancestors

                      @@ -2080,9 +1981,13 @@

                      Methods

                      :return: XML elements for the matching folders """ + if shape not in SHAPE_CHOICES: + raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + if depth not in FOLDER_TRAVERSAL_CHOICES: + raise InvalidEnumValue('depth', depth, FOLDER_TRAVERSAL_CHOICES) roots = {f.root for f in folders} if len(roots) != 1: - raise ValueError('FindFolder must be called with folders in the same root hierarchy (%r)' % roots) + raise ValueError(f"All folders in 'roots' must have the same root hierarchy ({roots})") self.root = roots.pop() return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, @@ -2093,7 +1998,7 @@

                      Methods

                      restriction=restriction, shape=shape, depth=depth, - page_size=self.chunk_size, + page_size=self.page_size, offset=offset, ) )) @@ -2109,41 +2014,34 @@

                      Methods

                      Expand source code
                      def get_payload(self, folders, additional_fields, restriction, shape, depth, page_size, offset=0):
                      -    findfolder = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth))
                      -    foldershape = create_shape_element(
                      +    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth))
                      +    payload.append(shape_element(
                               tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version
                      -    )
                      -    findfolder.append(foldershape)
                      +    ))
                           if self.account.version.build >= EXCHANGE_2010:
                      -        indexedpageviewitem = create_element(
                      +        indexed_page_folder_view = create_element(
                                   'm:IndexedPageFolderView',
                      -            attrs=OrderedDict([
                      -                ('MaxEntriesReturned', str(page_size)),
                      -                ('Offset', str(offset)),
                      -                ('BasePoint', 'Beginning'),
                      -            ])
                      +            attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning')
                               )
                      -        findfolder.append(indexedpageviewitem)
                      +        payload.append(indexed_page_folder_view)
                           else:
                               if offset != 0:
                      -            raise ValueError('Offsets are only supported from Exchange 2010')
                      +            raise NotImplementedError("'offset' is only supported for Exchange 2010 servers and later")
                           if restriction:
                      -        findfolder.append(restriction.to_xml(version=self.account.version))
                      -    parentfolderids = create_element('m:ParentFolderIds')
                      -    set_xml_value(parentfolderids, folders, version=self.account.version)
                      -    findfolder.append(parentfolderids)
                      -    return findfolder
                      + payload.append(restriction.to_xml(version=self.account.version)) + payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds')) + return payload

                      Inherited members

                      @@ -2158,12 +2056,12 @@

                      Inherited members

                      Expand source code -
                      class FindItem(EWSAccountService):
                      +
                      class FindItem(EWSPagingService):
                           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/finditem-operation"""
                       
                           SERVICE_NAME = 'FindItem'
                      -    element_container_name = '{%s}Items' % TNS
                      -    paging_container_name = '{%s}RootFolder' % MNS
                      +    element_container_name = f'{{{TNS}}}Items'
                      +    paging_container_name = f'{{{MNS}}}RootFolder'
                           supports_paging = True
                       
                           def __init__(self, *args, **kwargs):
                      @@ -2189,6 +2087,10 @@ 

                      Inherited members

                      :return: XML elements for the matching items """ + if shape not in SHAPE_CHOICES: + raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + if depth not in ITEM_TRAVERSAL_CHOICES: + raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape return self._elems_to_objs(self._paged_call( @@ -2203,61 +2105,46 @@

                      Inherited members

                      shape=shape, depth=depth, calendar_view=calendar_view, - page_size=self.chunk_size, + page_size=self.page_size, offset=offset, ) )) - def _elems_to_objs(self, elems): - from ..folders.base import BaseFolder - from ..items import Item, ID_ONLY - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - if self.shape == ID_ONLY and self.additional_fields is None: - yield Item.id_from_xml(elem) - continue - yield BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + if self.shape == ID_ONLY and self.additional_fields is None: + return Item.id_from_xml(elem) + return BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, calendar_view, page_size, offset=0): - finditem = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth)) - itemshape = create_shape_element( + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) + payload.append(shape_element( tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version - ) - finditem.append(itemshape) + )) if calendar_view is None: view_type = create_element( 'm:IndexedPageItemView', - attrs=OrderedDict([ - ('MaxEntriesReturned', str(page_size)), - ('Offset', str(offset)), - ('BasePoint', 'Beginning'), - ]) + attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') ) else: view_type = calendar_view.to_xml(version=self.account.version) - finditem.append(view_type) + payload.append(view_type) if restriction: - finditem.append(restriction.to_xml(version=self.account.version)) + payload.append(restriction.to_xml(version=self.account.version)) if order_fields: - finditem.append(set_xml_value( + payload.append(set_xml_value( create_element('m:SortOrder'), order_fields, version=self.account.version )) - finditem.append(set_xml_value( - create_element('m:ParentFolderIds'), - folders, - version=self.account.version - )) + payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds')) if query_string: - finditem.append(query_string.to_xml(version=self.account.version)) - return finditem
                      + payload.append(query_string.to_xml(version=self.account.version)) + return payload

                      Ancestors

                      @@ -2319,6 +2206,10 @@

                      Methods

                      :return: XML elements for the matching items """ + if shape not in SHAPE_CHOICES: + raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + if depth not in ITEM_TRAVERSAL_CHOICES: + raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape return self._elems_to_objs(self._paged_call( @@ -2333,7 +2224,7 @@

                      Methods

                      shape=shape, depth=depth, calendar_view=calendar_view, - page_size=self.chunk_size, + page_size=self.page_size, offset=offset, ) )) @@ -2350,50 +2241,41 @@

                      Methods

                      def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth,
                                       calendar_view, page_size, offset=0):
                      -    finditem = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth))
                      -    itemshape = create_shape_element(
                      +    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth))
                      +    payload.append(shape_element(
                               tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version
                      -    )
                      -    finditem.append(itemshape)
                      +    ))
                           if calendar_view is None:
                               view_type = create_element(
                                   'm:IndexedPageItemView',
                      -            attrs=OrderedDict([
                      -                ('MaxEntriesReturned', str(page_size)),
                      -                ('Offset', str(offset)),
                      -                ('BasePoint', 'Beginning'),
                      -            ])
                      +            attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning')
                               )
                           else:
                               view_type = calendar_view.to_xml(version=self.account.version)
                      -    finditem.append(view_type)
                      +    payload.append(view_type)
                           if restriction:
                      -        finditem.append(restriction.to_xml(version=self.account.version))
                      +        payload.append(restriction.to_xml(version=self.account.version))
                           if order_fields:
                      -        finditem.append(set_xml_value(
                      +        payload.append(set_xml_value(
                                   create_element('m:SortOrder'),
                                   order_fields,
                                   version=self.account.version
                               ))
                      -    finditem.append(set_xml_value(
                      -        create_element('m:ParentFolderIds'),
                      -        folders,
                      -        version=self.account.version
                      -    ))
                      +    payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds'))
                           if query_string:
                      -        finditem.append(query_string.to_xml(version=self.account.version))
                      -    return finditem
                      + payload.append(query_string.to_xml(version=self.account.version)) + return payload

                      Inherited members

                      @@ -2408,11 +2290,11 @@

                      Inherited members

                      Expand source code -
                      class FindPeople(EWSAccountService):
                      +
                      class FindPeople(EWSPagingService):
                           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/findpeople-operation"""
                       
                           SERVICE_NAME = 'FindPeople'
                      -    element_container_name = '{%s}People' % MNS
                      +    element_container_name = f'{{{MNS}}}People'
                           supported_from = EXCHANGE_2013
                           supports_paging = True
                       
                      @@ -2423,7 +2305,7 @@ 

                      Inherited members

                      self.shape = None def call(self, folder, additional_fields, restriction, order_fields, shape, query_string, depth, max_items, offset): - """Find items in an account. + """Find items in an account. This service can only be called on a single folder. :param folder: the Folder object to query :param additional_fields: the extra fields that should be returned with the item, as FieldPath objects @@ -2437,12 +2319,16 @@

                      Inherited members

                      :return: XML elements for the matching items """ + if shape not in SHAPE_CHOICES: + raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + if depth not in ITEM_TRAVERSAL_CHOICES: + raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - folders=[folder], # We can only query one folder, so there will only be one element in response + folders=[folder], # We just need the list to satisfy self._paged_call() **dict( additional_fields=additional_fields, restriction=restriction, @@ -2450,65 +2336,46 @@

                      Inherited members

                      query_string=query_string, shape=shape, depth=depth, - page_size=self.chunk_size, + page_size=self.page_size, offset=offset, ) )) - def _elems_to_objs(self, elems): - from ..items import Persona, ID_ONLY - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - if self.shape == ID_ONLY and self.additional_fields is None: - yield Persona.id_from_xml(elem) - continue - yield Persona.from_xml(elem, account=self.account) + def _elem_to_obj(self, elem): + if self.shape == ID_ONLY and self.additional_fields is None: + return Persona.id_from_xml(elem) + return Persona.from_xml(elem, account=self.account) def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, offset=0): - folders = list(folders) - if len(folders) != 1: - raise ValueError('%r can only query one folder' % self.SERVICE_NAME) - folder = folders[0] - findpeople = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth)) - personashape = create_shape_element( + # We actually only support a single folder, but self._paged_call() sends us a list + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) + payload.append(shape_element( tag='m:PersonaShape', shape=shape, additional_fields=additional_fields, version=self.account.version - ) - findpeople.append(personashape) - view_type = create_element( + )) + payload.append(create_element( 'm:IndexedPageItemView', - attrs=OrderedDict([ - ('MaxEntriesReturned', str(page_size)), - ('Offset', str(offset)), - ('BasePoint', 'Beginning'), - ]) - ) - findpeople.append(view_type) + attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') + )) if restriction: - findpeople.append(restriction.to_xml(version=self.account.version)) + payload.append(restriction.to_xml(version=self.account.version)) if order_fields: - findpeople.append(set_xml_value( + payload.append(set_xml_value( create_element('m:SortOrder'), order_fields, version=self.account.version )) - findpeople.append(set_xml_value( - create_element('m:ParentFolderId'), - folder, - version=self.account.version - )) + payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:ParentFolderId')) if query_string: - findpeople.append(query_string.to_xml(version=self.account.version)) - return findpeople + payload.append(query_string.to_xml(version=self.account.version)) + return payload @staticmethod def _get_paging_values(elem): """Find paging values. The paging element from FindPeople is different from other paging containers.""" - item_count = int(elem.find('{%s}TotalNumberOfPeopleInView' % MNS).text) - first_matching = int(elem.find('{%s}FirstMatchingRowIndex' % MNS).text) - first_loaded = int(elem.find('{%s}FirstLoadedRowIndex' % MNS).text) + item_count = int(elem.find(f'{{{MNS}}}TotalNumberOfPeopleInView').text) + first_matching = int(elem.find(f'{{{MNS}}}FirstMatchingRowIndex').text) + first_loaded = int(elem.find(f'{{{MNS}}}FirstLoadedRowIndex').text) log.debug('Got page with total items %s, first matching %s, first loaded %s ', item_count, first_matching, first_loaded) next_offset = None # GetPersona does not support fetching more pages @@ -2516,6 +2383,7 @@

                      Inherited members

                      Ancestors

                      @@ -2544,7 +2412,7 @@

                      Methods

                      def call(self, folder, additional_fields, restriction, order_fields, shape, query_string, depth, max_items, offset)
                      -

                      Find items in an account.

                      +

                      Find items in an account. This service can only be called on a single folder.

                      :param folder: the Folder object to query :param additional_fields: the extra fields that should be returned with the item, as FieldPath objects :param restriction: a Restriction object for @@ -2560,7 +2428,7 @@

                      Methods

                      Expand source code
                      def call(self, folder, additional_fields, restriction, order_fields, shape, query_string, depth, max_items, offset):
                      -    """Find items in an account.
                      +    """Find items in an account. This service can only be called on a single folder.
                       
                           :param folder: the Folder object to query
                           :param additional_fields: the extra fields that should be returned with the item, as FieldPath objects
                      @@ -2574,12 +2442,16 @@ 

                      Methods

                      :return: XML elements for the matching items """ + if shape not in SHAPE_CHOICES: + raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + if depth not in ITEM_TRAVERSAL_CHOICES: + raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape return self._elems_to_objs(self._paged_call( payload_func=self.get_payload, max_items=max_items, - folders=[folder], # We can only query one folder, so there will only be one element in response + folders=[folder], # We just need the list to satisfy self._paged_call() **dict( additional_fields=additional_fields, restriction=restriction, @@ -2587,7 +2459,7 @@

                      Methods

                      query_string=query_string, shape=shape, depth=depth, - page_size=self.chunk_size, + page_size=self.page_size, offset=offset, ) ))
                      @@ -2604,51 +2476,38 @@

                      Methods

                      def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size,
                                       offset=0):
                      -    folders = list(folders)
                      -    if len(folders) != 1:
                      -        raise ValueError('%r can only query one folder' % self.SERVICE_NAME)
                      -    folder = folders[0]
                      -    findpeople = create_element('m:%s' % self.SERVICE_NAME, attrs=dict(Traversal=depth))
                      -    personashape = create_shape_element(
                      +    # We actually only support a single folder, but self._paged_call() sends us a list
                      +    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth))
                      +    payload.append(shape_element(
                               tag='m:PersonaShape', shape=shape, additional_fields=additional_fields, version=self.account.version
                      -    )
                      -    findpeople.append(personashape)
                      -    view_type = create_element(
                      +    ))
                      +    payload.append(create_element(
                               'm:IndexedPageItemView',
                      -        attrs=OrderedDict([
                      -            ('MaxEntriesReturned', str(page_size)),
                      -            ('Offset', str(offset)),
                      -            ('BasePoint', 'Beginning'),
                      -        ])
                      -    )
                      -    findpeople.append(view_type)
                      +        attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning')
                      +    ))
                           if restriction:
                      -        findpeople.append(restriction.to_xml(version=self.account.version))
                      +        payload.append(restriction.to_xml(version=self.account.version))
                           if order_fields:
                      -        findpeople.append(set_xml_value(
                      +        payload.append(set_xml_value(
                                   create_element('m:SortOrder'),
                                   order_fields,
                                   version=self.account.version
                               ))
                      -    findpeople.append(set_xml_value(
                      -        create_element('m:ParentFolderId'),
                      -        folder,
                      -        version=self.account.version
                      -    ))
                      +    payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:ParentFolderId'))
                           if query_string:
                      -        findpeople.append(query_string.to_xml(version=self.account.version))
                      -    return findpeople
                      + payload.append(query_string.to_xml(version=self.account.version)) + return payload

                      Inherited members

                      @@ -2667,27 +2526,22 @@

                      Inherited members

                      """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getattachment-operation""" SERVICE_NAME = 'GetAttachment' - element_container_name = '{%s}Attachments' % MNS + element_container_name = f'{{{MNS}}}Attachments' + cls_map = {cls.response_tag(): cls for cls in (FileAttachment, ItemAttachment)} def call(self, items, include_mime_content, body_type, filter_html_content, additional_fields): if body_type and body_type not in BODY_TYPE_CHOICES: - raise ValueError("'body_type' %s must be one of %s" % (body_type, BODY_TYPE_CHOICES)) + raise InvalidEnumValue('body_type', body_type, BODY_TYPE_CHOICES) return self._elems_to_objs(self._chunked_get_elements( self.get_payload, items=items, include_mime_content=include_mime_content, body_type=body_type, filter_html_content=filter_html_content, additional_fields=additional_fields, )) - def _elems_to_objs(self, elems): - from ..attachments import FileAttachment, ItemAttachment - cls_map = {cls.response_tag(): cls for cls in (FileAttachment, ItemAttachment)} - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield cls_map[elem.tag].from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return self.cls_map[elem.tag].from_xml(elem=elem, account=self.account) def get_payload(self, items, include_mime_content, body_type, filter_html_content, additional_fields): - payload = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') shape_elem = create_element('m:AttachmentShape') if include_mime_content: add_xml_child(shape_elem, 't:IncludeMimeContent', 'true') @@ -2705,8 +2559,7 @@

                      Inherited members

                      shape_elem.append(additional_properties) if len(shape_elem): payload.append(shape_elem) - attachment_ids = create_attachment_ids_element(items=items, version=self.account.version) - payload.append(attachment_ids) + payload.append(attachment_ids_element(items=items, version=self.account.version)) return payload def _update_api_version(self, api_version, header, **parse_opts): @@ -2726,7 +2579,6 @@

                      Inherited members

                      if not parse_opts.get('stream_file_content', False): return super()._get_soap_messages(body, **parse_opts) - from ..attachments import FileAttachment # 'body' is actually the raw response passed on by '_get_soap_parts' r = body parser = StreamingBase64Parser() @@ -2748,7 +2600,7 @@

                      Inherited members

                      # When the returned XML does not contain a Content element, ElementNotFound is thrown by parser.parse(). # Let the non-streaming SOAP parser parse the response and hook into the normal exception handling. # Wrap in DummyResponse because _get_soap_parts() expects an iter_content() method. - response = DummyResponse(url=None, headers=None, request_headers=None, content=enf.data) + response = DummyResponse(content=enf.data) _, body = super()._get_soap_parts(response=response) res = super()._get_soap_messages(body=body) for e in self._get_elements_in_response(response=res): @@ -2771,6 +2623,10 @@

                      Class variables

                      +
                      var cls_map
                      +
                      +
                      +
                      var element_container_name
                      @@ -2789,7 +2645,7 @@

                      Methods

                      def call(self, items, include_mime_content, body_type, filter_html_content, additional_fields):
                           if body_type and body_type not in BODY_TYPE_CHOICES:
                      -        raise ValueError("'body_type' %s must be one of %s" % (body_type, BODY_TYPE_CHOICES))
                      +        raise InvalidEnumValue('body_type', body_type, BODY_TYPE_CHOICES)
                           return self._elems_to_objs(self._chunked_get_elements(
                               self.get_payload, items=items, include_mime_content=include_mime_content,
                               body_type=body_type, filter_html_content=filter_html_content, additional_fields=additional_fields,
                      @@ -2806,7 +2662,7 @@ 

                      Methods

                      Expand source code
                      def get_payload(self, items, include_mime_content, body_type, filter_html_content, additional_fields):
                      -    payload = create_element('m:%s' % self.SERVICE_NAME)
                      +    payload = create_element(f'm:{self.SERVICE_NAME}')
                           shape_elem = create_element('m:AttachmentShape')
                           if include_mime_content:
                               add_xml_child(shape_elem, 't:IncludeMimeContent', 'true')
                      @@ -2824,8 +2680,7 @@ 

                      Methods

                      shape_elem.append(additional_properties) if len(shape_elem): payload.append(shape_elem) - attachment_ids = create_attachment_ids_element(items=items, version=self.account.version) - payload.append(attachment_ids) + payload.append(attachment_ids_element(items=items, version=self.account.version)) return payload
                      @@ -2851,7 +2706,7 @@

                      Methods

                      # When the returned XML does not contain a Content element, ElementNotFound is thrown by parser.parse(). # Let the non-streaming SOAP parser parse the response and hook into the normal exception handling. # Wrap in DummyResponse because _get_soap_parts() expects an iter_content() method. - response = DummyResponse(url=None, headers=None, request_headers=None, content=enf.data) + response = DummyResponse(content=enf.data) _, body = super()._get_soap_parts(response=response) res = super()._get_soap_messages(body=body) for e in self._get_elements_in_response(response=res): @@ -2891,6 +2746,7 @@

                      Inherited members

                      """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getdelegate-operation""" SERVICE_NAME = 'GetDelegate' + ERRORS_TO_CATCH_IN_RESPONSE = () supported_from = EXCHANGE_2007_SP1 def call(self, user_ids, include_permissions): @@ -2901,21 +2757,19 @@

                      Inherited members

                      include_permissions=include_permissions, )) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield DelegateUser.from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return DelegateUser.from_xml(elem=elem, account=self.account) def get_payload(self, user_ids, mailbox, include_permissions): - payload = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=dict(IncludePermissions='true' if include_permissions else 'false'), - ) + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IncludePermissions=include_permissions)) set_xml_value(payload, mailbox, version=self.protocol.version) if user_ids != [None]: - set_xml_value(payload, user_ids, version=self.protocol.version) + user_ids_elem = create_element('m:UserIds') + for user_id in user_ids: + if isinstance(user_id, str): + user_id = UserId(primary_smtp_address=user_id) + set_xml_value(user_ids_elem, user_id, version=self.protocol.version) + set_xml_value(payload, user_ids_elem, version=self.protocol.version) return payload @classmethod @@ -2924,7 +2778,7 @@

                      Inherited members

                      @classmethod def _response_message_tag(cls): - return '{%s}DelegateUserResponseMessageType' % MNS
                      + return f'{{{MNS}}}DelegateUserResponseMessageType'

                      Ancestors

                        @@ -2933,6 +2787,10 @@

                        Ancestors

                      Class variables

                      +
                      var ERRORS_TO_CATCH_IN_RESPONSE
                      +
                      +
                      +
                      var SERVICE_NAME
                      @@ -2972,13 +2830,15 @@

                      Methods

                      Expand source code
                      def get_payload(self, user_ids, mailbox, include_permissions):
                      -    payload = create_element(
                      -        'm:%s' % self.SERVICE_NAME,
                      -        attrs=dict(IncludePermissions='true' if include_permissions else 'false'),
                      -    )
                      +    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IncludePermissions=include_permissions))
                           set_xml_value(payload, mailbox, version=self.protocol.version)
                           if user_ids != [None]:
                      -        set_xml_value(payload, user_ids, version=self.protocol.version)
                      +        user_ids_elem = create_element('m:UserIds')
                      +        for user_id in user_ids:
                      +            if isinstance(user_id, str):
                      +                user_id = UserId(primary_smtp_address=user_id)
                      +            set_xml_value(user_ids_elem, user_id, version=self.protocol.version)
                      +        set_xml_value(payload, user_ids_elem, version=self.protocol.version)
                           return payload
                      @@ -3019,22 +2879,18 @@

                      Inherited members

                      subscription_id=subscription_id, watermark=watermark, ))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Notification.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return Notification.from_xml(elem=elem, account=None) @classmethod def _get_elements_in_container(cls, container): return container.findall(Notification.response_tag()) def get_payload(self, subscription_id, watermark): - getstreamingevents = create_element('m:%s' % self.SERVICE_NAME) - add_xml_child(getstreamingevents, 'm:SubscriptionId', subscription_id) - add_xml_child(getstreamingevents, 'm:Watermark', watermark) - return getstreamingevents + payload = create_element(f'm:{self.SERVICE_NAME}') + add_xml_child(payload, 'm:SubscriptionId', subscription_id) + add_xml_child(payload, 'm:Watermark', watermark) + return payload

                      Ancestors

                        @@ -3079,10 +2935,10 @@

                        Methods

                        Expand source code
                        def get_payload(self, subscription_id, watermark):
                        -    getstreamingevents = create_element('m:%s' % self.SERVICE_NAME)
                        -    add_xml_child(getstreamingevents, 'm:SubscriptionId', subscription_id)
                        -    add_xml_child(getstreamingevents, 'm:Watermark', watermark)
                        -    return getstreamingevents
                        + payload = create_element(f'm:{self.SERVICE_NAME}') + add_xml_child(payload, 'm:SubscriptionId', subscription_id) + add_xml_child(payload, 'm:Watermark', watermark) + return payload
                      @@ -3112,7 +2968,7 @@

                      Inherited members

                      """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getfolder-operation""" SERVICE_NAME = 'GetFolder' - element_container_name = '{%s}Folders' % MNS + element_container_name = f'{{{MNS}}}Folders' ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + ( ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorInvalidOperation, ) @@ -3148,14 +3004,12 @@

                      Inherited members

                      yield parse_folder_elem(elem=elem, folder=folder, account=self.account) def get_payload(self, folders, additional_fields, shape): - getfolder = create_element('m:%s' % self.SERVICE_NAME) - foldershape = create_shape_element( + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(shape_element( tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version - ) - getfolder.append(foldershape) - folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version) - getfolder.append(folder_ids) - return getfolder + )) + payload.append(folder_ids_element(folders=folders, version=self.account.version)) + return payload

                      Ancestors

                        @@ -3222,14 +3076,12 @@

                        Methods

                        Expand source code
                        def get_payload(self, folders, additional_fields, shape):
                        -    getfolder = create_element('m:%s' % self.SERVICE_NAME)
                        -    foldershape = create_shape_element(
                        +    payload = create_element(f'm:{self.SERVICE_NAME}')
                        +    payload.append(shape_element(
                                 tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version
                        -    )
                        -    getfolder.append(foldershape)
                        -    folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version)
                        -    getfolder.append(folder_ids)
                        -    return getfolder
                        + )) + payload.append(folder_ids_element(folders=folders, version=self.account.version)) + return payload @@ -3259,7 +3111,7 @@

                        Inherited members

                        """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getitem-operation""" SERVICE_NAME = 'GetItem' - element_container_name = '{%s}Items' % MNS + element_container_name = f'{{{MNS}}}Items' def call(self, items, additional_fields, shape): """Return all items in an account that correspond to a list of ID's, in stable order. @@ -3274,23 +3126,16 @@

                        Inherited members

                        self.get_payload, items=items, additional_fields=additional_fields, shape=shape, )) - def _elems_to_objs(self, elems): - from ..folders.base import BaseFolder - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) def get_payload(self, items, additional_fields, shape): - getitem = create_element('m:%s' % self.SERVICE_NAME) - itemshape = create_shape_element( + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(shape_element( tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version - ) - getitem.append(itemshape) - item_ids = create_item_ids_element(items=items, version=self.account.version) - getitem.append(item_ids) - return getitem + )) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload

                        Ancestors

                          @@ -3347,14 +3192,12 @@

                          Methods

                          Expand source code
                          def get_payload(self, items, additional_fields, shape):
                          -    getitem = create_element('m:%s' % self.SERVICE_NAME)
                          -    itemshape = create_shape_element(
                          +    payload = create_element(f'm:{self.SERVICE_NAME}')
                          +    payload.append(shape_element(
                                   tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version
                          -    )
                          -    getitem.append(itemshape)
                          -    item_ids = create_item_ids_element(items=items, version=self.account.version)
                          -    getitem.append(item_ids)
                          -    return getitem
                          + )) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload @@ -3393,22 +3236,16 @@

                          Inherited members

                          mail_tips_requested=mail_tips_requested, )) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield MailTips.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return MailTips.from_xml(elem=elem, account=None) def get_payload(self, recipients, sending_as, mail_tips_requested): - payload = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') set_xml_value(payload, sending_as, version=self.protocol.version) recipients_elem = create_element('m:Recipients') for recipient in recipients: set_xml_value(recipients_elem, recipient, version=self.protocol.version) - if not len(recipients_elem): - raise ValueError('"recipients" must not be empty') payload.append(recipients_elem) if mail_tips_requested: @@ -3421,7 +3258,7 @@

                          Inherited members

                          @classmethod def _response_message_tag(cls): - return '{%s}MailTipsResponseMessageType' % MNS + return f'{{{MNS}}}MailTipsResponseMessageType'

                          Ancestors

                            @@ -3464,14 +3301,12 @@

                            Methods

                            Expand source code
                            def get_payload(self, recipients, sending_as,  mail_tips_requested):
                            -    payload = create_element('m:%s' % self.SERVICE_NAME)
                            +    payload = create_element(f'm:{self.SERVICE_NAME}')
                                 set_xml_value(payload, sending_as, version=self.protocol.version)
                             
                                 recipients_elem = create_element('m:Recipients')
                                 for recipient in recipients:
                                     set_xml_value(recipients_elem, recipient, version=self.protocol.version)
                            -    if not len(recipients_elem):
                            -        raise ValueError('"recipients" must not be empty')
                                 payload.append(recipients_elem)
                             
                                 if mail_tips_requested:
                            @@ -3507,33 +3342,28 @@ 

                            Inherited members

                            SERVICE_NAME = 'GetPersona' - def call(self, persona): - return self._elems_to_objs(self._get_elements(payload=self.get_payload(persona=persona))) + def call(self, personas): + # GetPersona only accepts one persona ID per request. Crazy. + for persona in personas: + yield from self._elems_to_objs(self._get_elements(payload=self.get_payload(persona=persona))) - def _elems_to_objs(self, elems): - from ..items import Persona - elements = list(elems) - if len(elements) != 1: - raise ValueError('Expected exactly one element in response') - elem = elements[0] - if isinstance(elem, Exception): - raise elem + def _elem_to_obj(self, elem): return Persona.from_xml(elem=elem, account=None) def get_payload(self, persona): - version = self.protocol.version - payload = create_element('m:%s' % self.SERVICE_NAME) - set_xml_value(payload, to_item_id(persona, PersonaId, version=version), version=version) - return payload + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), + to_item_id(persona, PersonaId), + version=self.protocol.version + ) @classmethod def _get_elements_in_container(cls, container): - from ..items import Persona - return container.findall('{%s}%s' % (MNS, Persona.ELEMENT_NAME)) + return container.findall(f'{{{MNS}}}{Persona.ELEMENT_NAME}') @classmethod def _response_tag(cls): - return '{%s}%sResponseMessage' % (MNS, cls.SERVICE_NAME)
                            + return f'{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage'

                            Ancestors

                              @@ -3550,7 +3380,7 @@

                              Class variables

                              Methods

                              -def call(self, persona) +def call(self, personas)
                              @@ -3558,8 +3388,10 @@

                              Methods

                              Expand source code -
                              def call(self, persona):
                              -    return self._elems_to_objs(self._get_elements(payload=self.get_payload(persona=persona)))
                              +
                              def call(self, personas):
                              +    # GetPersona only accepts one persona ID per request. Crazy.
                              +    for persona in personas:
                              +        yield from self._elems_to_objs(self._get_elements(payload=self.get_payload(persona=persona)))
                              @@ -3572,10 +3404,11 @@

                              Methods

                              Expand source code
                              def get_payload(self, persona):
                              -    version = self.protocol.version
                              -    payload = create_element('m:%s' % self.SERVICE_NAME)
                              -    set_xml_value(payload, to_item_id(persona, PersonaId, version=version), version=version)
                              -    return payload
                              + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}'), + to_item_id(persona, PersonaId), + version=self.protocol.version + )
                              @@ -3605,21 +3438,17 @@

                              Inherited members

                              """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getroomlists-operation""" SERVICE_NAME = 'GetRoomLists' - element_container_name = '{%s}RoomLists' % MNS + element_container_name = f'{{{MNS}}}RoomLists' supported_from = EXCHANGE_2010 def call(self): return self._elems_to_objs(self._get_elements(payload=self.get_payload())) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield RoomList.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return RoomList.from_xml(elem=elem, account=None) def get_payload(self): - return create_element('m:%s' % self.SERVICE_NAME) + return create_element(f'm:{self.SERVICE_NAME}')

                              Ancestors

                                @@ -3665,7 +3494,7 @@

                                Methods

                                Expand source code
                                def get_payload(self):
                                -    return create_element('m:%s' % self.SERVICE_NAME)
                                + return create_element(f'm:{self.SERVICE_NAME}') @@ -3695,23 +3524,17 @@

                                Inherited members

                                """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getrooms-operation""" SERVICE_NAME = 'GetRooms' - element_container_name = '{%s}Rooms' % MNS + element_container_name = f'{{{MNS}}}Rooms' supported_from = EXCHANGE_2010 - def call(self, roomlist): - return self._elems_to_objs(self._get_elements(payload=self.get_payload(roomlist=roomlist))) + def call(self, room_list): + return self._elems_to_objs(self._get_elements(payload=self.get_payload(room_list=room_list))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Room.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return Room.from_xml(elem=elem, account=None) - def get_payload(self, roomlist): - getrooms = create_element('m:%s' % self.SERVICE_NAME) - set_xml_value(getrooms, roomlist, version=self.protocol.version) - return getrooms + def get_payload(self, room_list): + return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), room_list, version=self.protocol.version)

                                Ancestors

                                  @@ -3735,7 +3558,7 @@

                                  Class variables

                                  Methods

                                  -def call(self, roomlist) +def call(self, room_list)
                                  @@ -3743,12 +3566,12 @@

                                  Methods

                                  Expand source code -
                                  def call(self, roomlist):
                                  -    return self._elems_to_objs(self._get_elements(payload=self.get_payload(roomlist=roomlist)))
                                  +
                                  def call(self, room_list):
                                  +    return self._elems_to_objs(self._get_elements(payload=self.get_payload(room_list=room_list)))
                                  -def get_payload(self, roomlist) +def get_payload(self, room_list)
                                  @@ -3756,10 +3579,8 @@

                                  Methods

                                  Expand source code -
                                  def get_payload(self, roomlist):
                                  -    getrooms = create_element('m:%s' % self.SERVICE_NAME)
                                  -    set_xml_value(getrooms, roomlist, version=self.protocol.version)
                                  -    return getrooms
                                  +
                                  def get_payload(self, room_list):
                                  +    return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), room_list, version=self.protocol.version)
                                  @@ -3792,9 +3613,10 @@

                                  Inherited members

                                  """ SERVICE_NAME = 'GetSearchableMailboxes' - element_container_name = '{%s}SearchableMailboxes' % MNS - failed_mailboxes_container_name = '{%s}FailedMailboxes' % MNS + element_container_name = f'{{{MNS}}}SearchableMailboxes' + failed_mailboxes_container_name = f'{{{MNS}}}FailedMailboxes' supported_from = EXCHANGE_2013 + cls_map = {cls.response_tag(): cls for cls in (SearchableMailbox, FailedMailbox)} def call(self, search_filter, expand_group_membership): return self._elems_to_objs(self._get_elements(payload=self.get_payload( @@ -3802,16 +3624,11 @@

                                  Inherited members

                                  expand_group_membership=expand_group_membership, ))) - def _elems_to_objs(self, elems): - cls_map = {cls.response_tag(): cls for cls in (SearchableMailbox, FailedMailbox)} - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield cls_map[elem.tag].from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return self.cls_map[elem.tag].from_xml(elem=elem, account=None) def get_payload(self, search_filter, expand_group_membership): - payload = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') if search_filter: add_xml_child(payload, 'm:SearchFilter', search_filter) if expand_group_membership is not None: @@ -3822,16 +3639,11 @@

                                  Inherited members

                                  for msg in response: for container_name in (self.element_container_name, self.failed_mailboxes_container_name): try: - container_or_exc = self._get_element_container(message=msg, name=container_name) + container = self._get_element_container(message=msg, name=container_name) except MalformedResponseError: - # Responses may contain no failed mailboxes. _get_element_container() does not accept this. - if container_name == self.failed_mailboxes_container_name: - continue - raise - if isinstance(container_or_exc, (bool, Exception)): - yield container_or_exc + # Responses may contain no mailboxes of either kind. _get_element_container() does not accept this. continue - yield from self._get_elements_in_container(container=container_or_exc) + yield from self._get_elements_in_container(container=container)

                                  Ancestors

                                    @@ -3843,6 +3655,10 @@

                                    Class variables

                                    +
                                    var cls_map
                                    +
                                    +
                                    +
                                    var element_container_name
                                    @@ -3884,7 +3700,7 @@

                                    Methods

                                    Expand source code
                                    def get_payload(self, search_filter, expand_group_membership):
                                    -    payload = create_element('m:%s' % self.SERVICE_NAME)
                                    +    payload = create_element(f'm:{self.SERVICE_NAME}')
                                         if search_filter:
                                             add_xml_child(payload, 'm:SearchFilter', search_filter)
                                         if expand_group_membership is not None:
                                    @@ -3922,7 +3738,7 @@ 

                                    Inherited members

                                    """ SERVICE_NAME = 'GetServerTimeZones' - element_container_name = '{%s}TimeZoneDefinitions' % MNS + element_container_name = f'{{{MNS}}}TimeZoneDefinitions' supported_from = EXCHANGE_2010 def call(self, timezones=None, return_full_timezone_data=False): @@ -3933,97 +3749,21 @@

                                    Inherited members

                                    def get_payload(self, timezones, return_full_timezone_data): payload = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=dict(ReturnFullTimeZoneData='true' if return_full_timezone_data else 'false'), + f'm:{self.SERVICE_NAME}', + attrs=dict(ReturnFullTimeZoneData=return_full_timezone_data), ) if timezones is not None: is_empty, timezones = peek(timezones) if not is_empty: tz_ids = create_element('m:Ids') for timezone in timezones: - tz_id = set_xml_value(create_element('t:Id'), timezone.ms_id, version=self.protocol.version) + tz_id = set_xml_value(create_element('t:Id'), timezone.ms_id) tz_ids.append(tz_id) payload.append(tz_ids) return payload - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - tz_id = elem.get('Id') - tz_name = elem.get('Name') - tz_periods = self._get_periods(elem) - tz_transitions_groups = self._get_transitions_groups(elem) - tz_transitions = self._get_transitions(elem) - yield tz_id, tz_name, tz_periods, tz_transitions, tz_transitions_groups - - @staticmethod - def _get_periods(timezonedef): - tz_periods = {} - periods = timezonedef.find('{%s}Periods' % TNS) - for period in periods.findall('{%s}Period' % TNS): - # Convert e.g. "trule:Microsoft/Registry/W. Europe Standard Time/2006-Daylight" to (2006, 'Daylight') - p_year, p_type = period.get('Id').rsplit('/', 1)[1].split('-') - tz_periods[(int(p_year), p_type)] = dict( - name=period.get('Name'), - bias=xml_text_to_value(period.get('Bias'), datetime.timedelta) - ) - return tz_periods - - @staticmethod - def _get_transitions_groups(timezonedef): - tz_transitions_groups = {} - transitiongroups = timezonedef.find('{%s}TransitionsGroups' % TNS) - if transitiongroups is not None: - for transitiongroup in transitiongroups.findall('{%s}TransitionsGroup' % TNS): - tg_id = int(transitiongroup.get('Id')) - tz_transitions_groups[tg_id] = [] - for transition in transitiongroup.findall('{%s}Transition' % TNS): - # Apply same conversion to To as for period IDs - to_year, to_type = transition.find('{%s}To' % TNS).text.rsplit('/', 1)[1].split('-') - tz_transitions_groups[tg_id].append(dict( - to=(int(to_year), to_type), - )) - for transition in transitiongroup.findall('{%s}RecurringDayTransition' % TNS): - # Apply same conversion to To as for period IDs - to_year, to_type = transition.find('{%s}To' % TNS).text.rsplit('/', 1)[1].split('-') - occurrence = xml_text_to_value(transition.find('{%s}Occurrence' % TNS).text, int) - if occurrence == -1: - # See TimeZoneTransition.from_xml() - occurrence = 5 - tz_transitions_groups[tg_id].append(dict( - to=(int(to_year), to_type), - offset=xml_text_to_value(transition.find('{%s}TimeOffset' % TNS).text, datetime.timedelta), - iso_month=xml_text_to_value(transition.find('{%s}Month' % TNS).text, int), - iso_weekday=WEEKDAY_NAMES.index(transition.find('{%s}DayOfWeek' % TNS).text) + 1, - occurrence=occurrence, - )) - return tz_transitions_groups - - @staticmethod - def _get_transitions(timezonedef): - tz_transitions = {} - transitions = timezonedef.find('{%s}Transitions' % TNS) - if transitions is not None: - for transition in transitions.findall('{%s}Transition' % TNS): - to = transition.find('{%s}To' % TNS) - if to.get('Kind') != 'Group': - raise ValueError('Unexpected "Kind" XML attr: %s' % to.get('Kind')) - tg_id = xml_text_to_value(to.text, int) - tz_transitions[tg_id] = None - for transition in transitions.findall('{%s}AbsoluteDateTransition' % TNS): - to = transition.find('{%s}To' % TNS) - if to.get('Kind') != 'Group': - raise ValueError('Unexpected "Kind" XML attr: %s' % to.get('Kind')) - tg_id = xml_text_to_value(to.text, int) - try: - t_date = xml_text_to_value(transition.find('{%s}DateTime' % TNS).text, EWSDateTime).date() - except NaiveDateTimeNotAllowed as e: - # We encountered a naive datetime. Don't worry. we just need the date - t_date = e.local_dt.date() - tz_transitions[tg_id] = t_date - return tz_transitions
                                    + def _elem_to_obj(self, elem): + return TimeZoneDefinition.from_xml(elem=elem, account=None)

                                    Ancestors

                                      @@ -4073,15 +3813,15 @@

                                      Methods

                                      def get_payload(self, timezones, return_full_timezone_data):
                                           payload = create_element(
                                      -        'm:%s' % self.SERVICE_NAME,
                                      -        attrs=dict(ReturnFullTimeZoneData='true' if return_full_timezone_data else 'false'),
                                      +        f'm:{self.SERVICE_NAME}',
                                      +        attrs=dict(ReturnFullTimeZoneData=return_full_timezone_data),
                                           )
                                           if timezones is not None:
                                               is_empty, timezones = peek(timezones)
                                               if not is_empty:
                                                   tz_ids = create_element('m:Ids')
                                                   for timezone in timezones:
                                      -                tz_id = set_xml_value(create_element('t:Id'), timezone.ms_id, version=self.protocol.version)
                                      +                tz_id = set_xml_value(create_element('t:Id'), timezone.ms_id)
                                                       tz_ids.append(tz_id)
                                                   payload.append(tz_ids)
                                           return payload
                                      @@ -4117,7 +3857,7 @@

                                      Inherited members

                                      """ SERVICE_NAME = 'GetStreamingEvents' - element_container_name = '{%s}Notifications' % MNS + element_container_name = f'{{{MNS}}}Notifications' prefer_affinity = True # Connection status values @@ -4131,18 +3871,18 @@

                                      Inherited members

                                      self.streaming = True def call(self, subscription_ids, connection_timeout): + if not isinstance(connection_timeout, int): + raise InvalidTypeError('connection_timeout', connection_timeout, int) if connection_timeout < 1: - raise ValueError("'connection_timeout' must be a positive integer") + raise ValueError(f"'connection_timeout' {connection_timeout} must be a positive integer") + # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed + self.timeout = connection_timeout * 60 + 60 return self._elems_to_objs(self._get_elements(payload=self.get_payload( subscription_ids=subscription_ids, connection_timeout=connection_timeout, ))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield Notification.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return Notification.from_xml(elem=elem, account=None) @classmethod def _get_soap_parts(cls, response, **parse_opts): @@ -4155,8 +3895,8 @@

                                      Inherited members

                                      # XML response. r = body for i, doc in enumerate(DocumentYielder(r.iter_content()), start=1): - xml_log.debug('''Response XML (docs received: %(i)s): %(xml_response)s''', dict(i=i, xml_response=doc)) - response = DummyResponse(url=None, headers=None, request_headers=None, content=doc) + xml_log.debug('''Response XML (docs counter: %(i)s): %(xml_response)s''', dict(i=i, xml_response=doc)) + response = DummyResponse(content=doc) try: _, body = super()._get_soap_parts(response=response, **parse_opts) except Exception: @@ -4170,9 +3910,9 @@

                                      Inherited members

                                      break def _get_element_container(self, message, name=None): - error_ids_elem = message.find('{%s}ErrorSubscriptionIds' % MNS) - error_ids = [] if error_ids_elem is None else get_xml_attrs(error_ids_elem, '{%s}SubscriptionId' % MNS) - self.connection_status = get_xml_attr(message, '{%s}ConnectionStatus' % MNS) # Either 'OK' or 'Closed' + error_ids_elem = message.find(f'{{{MNS}}}ErrorSubscriptionIds') + error_ids = [] if error_ids_elem is None else get_xml_attrs(error_ids_elem, f'{{{MNS}}}SubscriptionId') + self.connection_status = get_xml_attr(message, f'{{{MNS}}}ConnectionStatus') # Either 'OK' or 'Closed' log.debug('Connection status is: %s', self.connection_status) # Upstream normally expects to find a 'name' tag but our response does not always have it. We still want to # call upstream, to have exceptions raised. Return an empty list if there is no 'name' tag and no errors. @@ -4185,21 +3925,21 @@

                                      Inherited members

                                      # subscriptions seem to never be returned even though the XML spec allows it. This means there's no point in # trying to collect any notifications here and delivering a combination of errors and return values. if error_ids: - e.value += ' (subscription IDs: %s)' % ', '.join(repr(i) for i in error_ids) + e.value += f' (subscription IDs: {error_ids})' raise e return [] if name is None else res def get_payload(self, subscription_ids, connection_timeout): - getstreamingevents = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') subscriptions_elem = create_element('m:SubscriptionIds') for subscription_id in subscription_ids: add_xml_child(subscriptions_elem, 't:SubscriptionId', subscription_id) if not len(subscriptions_elem): - raise ValueError('"subscription_ids" must not be empty') + raise ValueError("'subscription_ids' must not be empty") - getstreamingevents.append(subscriptions_elem) - add_xml_child(getstreamingevents, 'm:ConnectionTimeout', connection_timeout) - return getstreamingevents + payload.append(subscriptions_elem) + add_xml_child(payload, 'm:ConnectionTimeout', connection_timeout) + return payload

                                      Ancestors

                                        @@ -4241,8 +3981,12 @@

                                        Methods

                                        Expand source code
                                        def call(self, subscription_ids, connection_timeout):
                                        +    if not isinstance(connection_timeout, int):
                                        +        raise InvalidTypeError('connection_timeout', connection_timeout, int)
                                             if connection_timeout < 1:
                                        -        raise ValueError("'connection_timeout' must be a positive integer")
                                        +        raise ValueError(f"'connection_timeout' {connection_timeout} must be a positive integer")
                                        +    # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed
                                        +    self.timeout = connection_timeout * 60 + 60
                                             return self._elems_to_objs(self._get_elements(payload=self.get_payload(
                                                     subscription_ids=subscription_ids, connection_timeout=connection_timeout,
                                             )))
                                        @@ -4258,16 +4002,16 @@

                                        Methods

                                        Expand source code
                                        def get_payload(self, subscription_ids, connection_timeout):
                                        -    getstreamingevents = create_element('m:%s' % self.SERVICE_NAME)
                                        +    payload = create_element(f'm:{self.SERVICE_NAME}')
                                             subscriptions_elem = create_element('m:SubscriptionIds')
                                             for subscription_id in subscription_ids:
                                                 add_xml_child(subscriptions_elem, 't:SubscriptionId', subscription_id)
                                             if not len(subscriptions_elem):
                                        -        raise ValueError('"subscription_ids" must not be empty')
                                        +        raise ValueError("'subscription_ids' must not be empty")
                                         
                                        -    getstreamingevents.append(subscriptions_elem)
                                        -    add_xml_child(getstreamingevents, 'm:ConnectionTimeout', connection_timeout)
                                        -    return getstreamingevents
                                        + payload.append(subscriptions_elem) + add_xml_child(payload, 'm:ConnectionTimeout', connection_timeout) + return payload
                                    @@ -4312,39 +4056,34 @@

                                    Inherited members

                                    free_busy_view_options=free_busy_view_options ))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield FreeBusyView.from_xml(elem=elem, account=None) + def _elem_to_obj(self, elem): + return FreeBusyView.from_xml(elem=elem, account=None) def get_payload(self, timezone, mailbox_data, free_busy_view_options): - payload = create_element('m:%sRequest' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}Request') set_xml_value(payload, timezone, version=self.protocol.version) mailbox_data_array = create_element('m:MailboxDataArray') set_xml_value(mailbox_data_array, mailbox_data, version=self.protocol.version) payload.append(mailbox_data_array) - set_xml_value(payload, free_busy_view_options, version=self.protocol.version) - return payload + return set_xml_value(payload, free_busy_view_options, version=self.protocol.version) @staticmethod def _response_messages_tag(): - return '{%s}FreeBusyResponseArray' % MNS + return f'{{{MNS}}}FreeBusyResponseArray' @classmethod def _response_message_tag(cls): - return '{%s}FreeBusyResponse' % MNS + return f'{{{MNS}}}FreeBusyResponse' def _get_elements_in_response(self, response): for msg in response: # Just check the response code and raise errors - self._get_element_container(message=msg.find('{%s}ResponseMessage' % MNS)) + self._get_element_container(message=msg.find(f'{{{MNS}}}ResponseMessage')) yield from self._get_elements_in_container(container=msg) @classmethod def _get_elements_in_container(cls, container): - return [container.find('{%s}FreeBusyView' % MNS)] + return [container.find(f'{{{MNS}}}FreeBusyView')]

                                    Ancestors

                                      @@ -4388,13 +4127,12 @@

                                      Methods

                                      Expand source code
                                      def get_payload(self, timezone, mailbox_data, free_busy_view_options):
                                      -    payload = create_element('m:%sRequest' % self.SERVICE_NAME)
                                      +    payload = create_element(f'm:{self.SERVICE_NAME}Request')
                                           set_xml_value(payload, timezone, version=self.protocol.version)
                                           mailbox_data_array = create_element('m:MailboxDataArray')
                                           set_xml_value(mailbox_data_array, mailbox_data, version=self.protocol.version)
                                           payload.append(mailbox_data_array)
                                      -    set_xml_value(payload, free_busy_view_options, version=self.protocol.version)
                                      -    return payload
                                      + return set_xml_value(payload, free_busy_view_options, version=self.protocol.version) @@ -4430,29 +4168,25 @@

                                      Inherited members

                                      def call(self, user_configuration_name, properties): if properties not in PROPERTIES_CHOICES: - raise ValueError("'properties' %r must be one of %s" % (properties, PROPERTIES_CHOICES)) + raise InvalidEnumValue('properties', properties, PROPERTIES_CHOICES) return self._elems_to_objs(self._get_elements(payload=self.get_payload( user_configuration_name=user_configuration_name, properties=properties ))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield UserConfiguration.from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return UserConfiguration.from_xml(elem=elem, account=self.account) @classmethod def _get_elements_in_container(cls, container): return container.findall(UserConfiguration.response_tag()) def get_payload(self, user_configuration_name, properties): - getuserconfiguration = create_element('m:%s' % self.SERVICE_NAME) - set_xml_value(getuserconfiguration, user_configuration_name, version=self.account.version) - user_configuration_properties = create_element('m:UserConfigurationProperties') - set_xml_value(user_configuration_properties, properties, version=self.account.version) - getuserconfiguration.append(user_configuration_properties) - return getuserconfiguration + payload = create_element(f'm:{self.SERVICE_NAME}') + set_xml_value(payload, user_configuration_name, version=self.account.version) + payload.append( + set_xml_value(create_element('m:UserConfigurationProperties'), properties, version=self.account.version) + ) + return payload

                                      Ancestors

                                        @@ -4479,7 +4213,7 @@

                                        Methods

                                        def call(self, user_configuration_name, properties):
                                             if properties not in PROPERTIES_CHOICES:
                                        -        raise ValueError("'properties' %r must be one of %s" % (properties, PROPERTIES_CHOICES))
                                        +        raise InvalidEnumValue('properties', properties, PROPERTIES_CHOICES)
                                             return self._elems_to_objs(self._get_elements(payload=self.get_payload(
                                                     user_configuration_name=user_configuration_name, properties=properties
                                             )))
                                        @@ -4495,12 +4229,12 @@

                                        Methods

                                        Expand source code
                                        def get_payload(self, user_configuration_name, properties):
                                        -    getuserconfiguration = create_element('m:%s' % self.SERVICE_NAME)
                                        -    set_xml_value(getuserconfiguration, user_configuration_name, version=self.account.version)
                                        -    user_configuration_properties = create_element('m:UserConfigurationProperties')
                                        -    set_xml_value(user_configuration_properties, properties, version=self.account.version)
                                        -    getuserconfiguration.append(user_configuration_properties)
                                        -    return getuserconfiguration
                                        + payload = create_element(f'm:{self.SERVICE_NAME}') + set_xml_value(payload, user_configuration_name, version=self.account.version) + payload.append( + set_xml_value(create_element('m:UserConfigurationProperties'), properties, version=self.account.version) + ) + return payload @@ -4533,21 +4267,20 @@

                                        Inherited members

                                        """ SERVICE_NAME = 'GetUserOofSettings' - element_container_name = '{%s}OofSettings' % TNS + element_container_name = f'{{{TNS}}}OofSettings' def call(self, mailbox): return self._elems_to_objs(self._get_elements(payload=self.get_payload(mailbox=mailbox))) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield OofSettings.from_xml(elem=elem, account=self.account) + def _elem_to_obj(self, elem): + return OofSettings.from_xml(elem=elem, account=self.account) def get_payload(self, mailbox): - payload = create_element('m:%sRequest' % self.SERVICE_NAME) - return set_xml_value(payload, AvailabilityMailbox.from_mailbox(mailbox), version=self.account.version) + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}Request'), + AvailabilityMailbox.from_mailbox(mailbox), + version=self.account.version + ) @classmethod def _get_elements_in_container(cls, container): @@ -4561,7 +4294,7 @@

                                        Inherited members

                                        @classmethod def _response_message_tag(cls): - return '{%s}ResponseMessage' % MNS + return f'{{{MNS}}}ResponseMessage'

                                        Ancestors

                                          @@ -4604,8 +4337,11 @@

                                          Methods

                                          Expand source code
                                          def get_payload(self, mailbox):
                                          -    payload = create_element('m:%sRequest' % self.SERVICE_NAME)
                                          -    return set_xml_value(payload, AvailabilityMailbox.from_mailbox(mailbox), version=self.account.version)
                                          + return set_xml_value( + create_element(f'm:{self.SERVICE_NAME}Request'), + AvailabilityMailbox.from_mailbox(mailbox), + version=self.account.version + ) @@ -4641,12 +4377,8 @@

                                          Inherited members

                                          self._chunked_get_elements(self.get_payload, items=items, is_junk=is_junk, move_item=move_item) ) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, (Exception, type(None))): - yield elem - continue - yield MovedItemId.id_from_xml(elem) + def _elem_to_obj(self, elem): + return MovedItemId.id_from_xml(elem) @classmethod def _get_elements_in_container(cls, container): @@ -4654,13 +4386,9 @@

                                          Inherited members

                                          def get_payload(self, items, is_junk, move_item): # Takes a list of items and returns either success or raises an error message - mark_as_junk = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=dict(IsJunk='true' if is_junk else 'false', MoveItem='true' if move_item else 'false') - ) - item_ids = create_item_ids_element(items=items, version=self.account.version) - mark_as_junk.append(item_ids) - return mark_as_junk + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IsJunk=is_junk, MoveItem=move_item)) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload

                                          Ancestors

                                            @@ -4702,13 +4430,9 @@

                                            Methods

                                            def get_payload(self, items, is_junk, move_item):
                                                 # Takes a list of items and returns either success or raises an error message
                                            -    mark_as_junk = create_element(
                                            -        'm:%s' % self.SERVICE_NAME,
                                            -        attrs=dict(IsJunk='true' if is_junk else 'false', MoveItem='true' if move_item else 'false')
                                            -    )
                                            -    item_ids = create_item_ids_element(items=items, version=self.account.version)
                                            -    mark_as_junk.append(item_ids)
                                            -    return mark_as_junk
                                            + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IsJunk=is_junk, MoveItem=move_item)) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload @@ -4738,31 +4462,22 @@

                                            Inherited members

                                            """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/movefolder-operation""" SERVICE_NAME = "MoveFolder" - element_container_name = '{%s}Folders' % MNS + element_container_name = f'{{{MNS}}}Folders' def call(self, folders, to_folder): - from ..folders import BaseFolder, FolderId if not isinstance(to_folder, (BaseFolder, FolderId)): - raise ValueError("'to_folder' %r must be a Folder or FolderId instance" % to_folder) + raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId)) return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=folders, to_folder=to_folder)) - def _elems_to_objs(self, elems): - from ..folders import FolderId - for elem in elems: - if isinstance(elem, (Exception, type(None))): - yield elem - continue - yield FolderId.from_xml(elem=elem.find(FolderId.response_tag()), account=self.account) + def _elem_to_obj(self, elem): + return FolderId.from_xml(elem=elem.find(FolderId.response_tag()), account=self.account) def get_payload(self, folders, to_folder): # Takes a list of folders and returns their new folder IDs - movefolder = create_element('m:%s' % self.SERVICE_NAME) - tofolderid = create_element('m:ToFolderId') - set_xml_value(tofolderid, to_folder, version=self.account.version) - movefolder.append(tofolderid) - folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version) - movefolder.append(folder_ids) - return movefolder + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) + payload.append(folder_ids_element(folders=folders, version=self.account.version)) + return payload

                                            Ancestors

                                              @@ -4792,9 +4507,8 @@

                                              Methods

                                              Expand source code
                                              def call(self, folders, to_folder):
                                              -    from ..folders import BaseFolder, FolderId
                                                   if not isinstance(to_folder, (BaseFolder, FolderId)):
                                              -        raise ValueError("'to_folder' %r must be a Folder or FolderId instance" % to_folder)
                                              +        raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId))
                                                   return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=folders, to_folder=to_folder))
                                              @@ -4809,13 +4523,10 @@

                                              Methods

                                              def get_payload(self, folders, to_folder):
                                                   # Takes a list of folders and returns their new folder IDs
                                              -    movefolder = create_element('m:%s' % self.SERVICE_NAME)
                                              -    tofolderid = create_element('m:ToFolderId')
                                              -    set_xml_value(tofolderid, to_folder, version=self.account.version)
                                              -    movefolder.append(tofolderid)
                                              -    folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version)
                                              -    movefolder.append(folder_ids)
                                              -    return movefolder
                                              + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) + payload.append(folder_ids_element(folders=folders, version=self.account.version)) + return payload @@ -4845,31 +4556,22 @@

                                              Inherited members

                                              """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/moveitem-operation""" SERVICE_NAME = 'MoveItem' - element_container_name = '{%s}Items' % MNS + element_container_name = f'{{{MNS}}}Items' def call(self, items, to_folder): - from ..folders import BaseFolder, FolderId if not isinstance(to_folder, (BaseFolder, FolderId)): - raise ValueError("'to_folder' %r must be a Folder or FolderId instance" % to_folder) + raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId)) return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, to_folder=to_folder)) - def _elems_to_objs(self, elems): - from ..items import Item - for elem in elems: - if isinstance(elem, (Exception, type(None))): - yield elem - continue - yield Item.id_from_xml(elem) + def _elem_to_obj(self, elem): + return Item.id_from_xml(elem) def get_payload(self, items, to_folder): # Takes a list of items and returns their new item IDs - moveitem = create_element('m:%s' % self.SERVICE_NAME) - tofolderid = create_element('m:ToFolderId') - set_xml_value(tofolderid, to_folder, version=self.account.version) - moveitem.append(tofolderid) - item_ids = create_item_ids_element(items=items, version=self.account.version) - moveitem.append(item_ids) - return moveitem + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload

                                              Ancestors

                                                @@ -4903,9 +4605,8 @@

                                                Methods

                                                Expand source code
                                                def call(self, items, to_folder):
                                                -    from ..folders import BaseFolder, FolderId
                                                     if not isinstance(to_folder, (BaseFolder, FolderId)):
                                                -        raise ValueError("'to_folder' %r must be a Folder or FolderId instance" % to_folder)
                                                +        raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId))
                                                     return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, to_folder=to_folder))
                                                @@ -4920,13 +4621,10 @@

                                                Methods

                                                def get_payload(self, items, to_folder):
                                                     # Takes a list of items and returns their new item IDs
                                                -    moveitem = create_element('m:%s' % self.SERVICE_NAME)
                                                -    tofolderid = create_element('m:ToFolderId')
                                                -    set_xml_value(tofolderid, to_folder, version=self.account.version)
                                                -    moveitem.append(tofolderid)
                                                -    item_ids = create_item_ids_element(items=items, version=self.account.version)
                                                -    moveitem.append(item_ids)
                                                -    return moveitem
                                                + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload @@ -4956,7 +4654,7 @@

                                                Inherited members

                                                """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/resolvenames-operation""" SERVICE_NAME = 'ResolveNames' - element_container_name = '{%s}ResolutionSet' % MNS + element_container_name = f'{{{MNS}}}ResolutionSet' ERRORS_TO_CATCH_IN_RESPONSE = ErrorNameResolutionNoResults WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults # Note: paging information is returned as attrs on the 'ResolutionSet' element, but this service does not @@ -4970,16 +4668,15 @@

                                                Inherited members

                                                def call(self, unresolved_entries, parent_folders=None, return_full_contact_data=False, search_scope=None, contact_data_shape=None): - from ..items import SHAPE_CHOICES, SEARCH_SCOPE_CHOICES if self.chunk_size > 100: - log.warning( - 'Chunk size %s is dangerously high. %s supports returning at most 100 candidates for a lookup', - self.chunk_size, self.SERVICE_NAME + raise ValueError( + f'Chunk size {self.chunk_size} is too high. {self.SERVICE_NAME} supports returning at most 100 ' + f'candidates for a lookup', ) if search_scope and search_scope not in SEARCH_SCOPE_CHOICES: - raise ValueError("'search_scope' %s must be one if %s" % (search_scope, SEARCH_SCOPE_CHOICES)) + raise InvalidEnumValue('search_scope', search_scope, SEARCH_SCOPE_CHOICES) if contact_data_shape and contact_data_shape not in SHAPE_CHOICES: - raise ValueError("'shape' %s must be one if %s" % (contact_data_shape, SHAPE_CHOICES)) + raise InvalidEnumValue('contact_data_shape', contact_data_shape, SHAPE_CHOICES) self.return_full_contact_data = return_full_contact_data return self._elems_to_objs(self._chunked_get_elements( self.get_payload, @@ -4990,42 +4687,33 @@

                                                Inherited members

                                                contact_data_shape=contact_data_shape, )) - def _elems_to_objs(self, elems): - from ..items import Contact - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - if self.return_full_contact_data: - mailbox_elem = elem.find(Mailbox.response_tag()) - contact_elem = elem.find(Contact.response_tag()) - yield ( - None if mailbox_elem is None else Mailbox.from_xml(elem=mailbox_elem, account=None), - None if contact_elem is None else Contact.from_xml(elem=contact_elem, account=None), - ) - else: - yield Mailbox.from_xml(elem=elem.find(Mailbox.response_tag()), account=None) + def _elem_to_obj(self, elem): + if self.return_full_contact_data: + mailbox_elem = elem.find(Mailbox.response_tag()) + contact_elem = elem.find(Contact.response_tag()) + return ( + None if mailbox_elem is None else Mailbox.from_xml(elem=mailbox_elem, account=None), + None if contact_elem is None else Contact.from_xml(elem=contact_elem, account=None), + ) + return Mailbox.from_xml(elem=elem.find(Mailbox.response_tag()), account=None) def get_payload(self, unresolved_entries, parent_folders, return_full_contact_data, search_scope, contact_data_shape): - payload = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=dict(ReturnFullContactData='true' if return_full_contact_data else 'false'), - ) + attrs = dict(ReturnFullContactData=return_full_contact_data) if search_scope: - payload.set('SearchScope', search_scope) + attrs['SearchScope'] = search_scope if contact_data_shape: if self.protocol.version.build < EXCHANGE_2010_SP2: raise NotImplementedError( "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later") - payload.set('ContactDataShape', contact_data_shape) + attrs['ContactDataShape'] = contact_data_shape + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) if parent_folders: - parentfolderids = create_element('m:ParentFolderIds') - set_xml_value(parentfolderids, parent_folders, version=self.protocol.version) + payload.append(folder_ids_element( + folders=parent_folders, version=self.protocol.version, tag='m:ParentFolderIds' + )) for entry in unresolved_entries: add_xml_child(payload, 'm:UnresolvedEntry', entry) - if not len(payload): - raise ValueError('"unresolved_entries" must not be empty') return payload

                                                Ancestors

                                                @@ -5068,16 +4756,15 @@

                                                Methods

                                                def call(self, unresolved_entries, parent_folders=None, return_full_contact_data=False, search_scope=None,
                                                          contact_data_shape=None):
                                                -    from ..items import SHAPE_CHOICES, SEARCH_SCOPE_CHOICES
                                                     if self.chunk_size > 100:
                                                -        log.warning(
                                                -            'Chunk size %s is dangerously high. %s supports returning at most 100 candidates for a lookup',
                                                -            self.chunk_size, self.SERVICE_NAME
                                                +        raise ValueError(
                                                +            f'Chunk size {self.chunk_size} is too high. {self.SERVICE_NAME} supports returning at most 100 '
                                                +            f'candidates for a lookup',
                                                         )
                                                     if search_scope and search_scope not in SEARCH_SCOPE_CHOICES:
                                                -        raise ValueError("'search_scope' %s must be one if %s" % (search_scope, SEARCH_SCOPE_CHOICES))
                                                +        raise InvalidEnumValue('search_scope', search_scope, SEARCH_SCOPE_CHOICES)
                                                     if contact_data_shape and contact_data_shape not in SHAPE_CHOICES:
                                                -        raise ValueError("'shape' %s must be one if %s" % (contact_data_shape, SHAPE_CHOICES))
                                                +        raise InvalidEnumValue('contact_data_shape', contact_data_shape, SHAPE_CHOICES)
                                                     self.return_full_contact_data = return_full_contact_data
                                                     return self._elems_to_objs(self._chunked_get_elements(
                                                         self.get_payload,
                                                @@ -5100,24 +4787,21 @@ 

                                                Methods

                                                def get_payload(self, unresolved_entries, parent_folders, return_full_contact_data, search_scope,
                                                                 contact_data_shape):
                                                -    payload = create_element(
                                                -        'm:%s' % self.SERVICE_NAME,
                                                -        attrs=dict(ReturnFullContactData='true' if return_full_contact_data else 'false'),
                                                -    )
                                                +    attrs = dict(ReturnFullContactData=return_full_contact_data)
                                                     if search_scope:
                                                -        payload.set('SearchScope', search_scope)
                                                +        attrs['SearchScope'] = search_scope
                                                     if contact_data_shape:
                                                         if self.protocol.version.build < EXCHANGE_2010_SP2:
                                                             raise NotImplementedError(
                                                                 "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later")
                                                -        payload.set('ContactDataShape', contact_data_shape)
                                                +        attrs['ContactDataShape'] = contact_data_shape
                                                +    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs)
                                                     if parent_folders:
                                                -        parentfolderids = create_element('m:ParentFolderIds')
                                                -        set_xml_value(parentfolderids, parent_folders, version=self.protocol.version)
                                                +        payload.append(folder_ids_element(
                                                +            folders=parent_folders, version=self.protocol.version, tag='m:ParentFolderIds'
                                                +        ))
                                                     for entry in unresolved_entries:
                                                         add_xml_child(payload, 'm:UnresolvedEntry', entry)
                                                -    if not len(payload):
                                                -        raise ValueError('"unresolved_entries" must not be empty')
                                                     return payload
                                                @@ -5151,23 +4835,18 @@

                                                Inherited members

                                                returns_elements = False def call(self, items, saved_item_folder): - from ..folders import BaseFolder, FolderId if saved_item_folder and not isinstance(saved_item_folder, (BaseFolder, FolderId)): - raise ValueError("'saved_item_folder' %r must be a Folder or FolderId instance" % saved_item_folder) + raise InvalidTypeError('saved_item_folder', saved_item_folder, (BaseFolder, FolderId)) return self._chunked_get_elements(self.get_payload, items=items, saved_item_folder=saved_item_folder) def get_payload(self, items, saved_item_folder): - senditem = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=dict(SaveItemToFolder='true' if saved_item_folder else 'false'), - ) - item_ids = create_item_ids_element(items=items, version=self.account.version) - senditem.append(item_ids) + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(SaveItemToFolder=bool(saved_item_folder))) + payload.append(item_ids_element(items=items, version=self.account.version)) if saved_item_folder: - saveditemfolderid = create_element('m:SavedItemFolderId') - set_xml_value(saveditemfolderid, saved_item_folder, version=self.account.version) - senditem.append(saveditemfolderid) - return senditem
                                                + payload.append(folder_ids_element( + folders=[saved_item_folder], version=self.account.version, tag='m:SavedItemFolderId' + )) + return payload

                                                Ancestors

                                                  @@ -5197,9 +4876,8 @@

                                                  Methods

                                                  Expand source code
                                                  def call(self, items, saved_item_folder):
                                                  -    from ..folders import BaseFolder, FolderId
                                                       if saved_item_folder and not isinstance(saved_item_folder, (BaseFolder, FolderId)):
                                                  -        raise ValueError("'saved_item_folder' %r must be a Folder or FolderId instance" % saved_item_folder)
                                                  +        raise InvalidTypeError('saved_item_folder', saved_item_folder, (BaseFolder, FolderId))
                                                       return self._chunked_get_elements(self.get_payload, items=items, saved_item_folder=saved_item_folder)
                                                  @@ -5213,17 +4891,13 @@

                                                  Methods

                                                  Expand source code
                                                  def get_payload(self, items, saved_item_folder):
                                                  -    senditem = create_element(
                                                  -        'm:%s' % self.SERVICE_NAME,
                                                  -        attrs=dict(SaveItemToFolder='true' if saved_item_folder else 'false'),
                                                  -    )
                                                  -    item_ids = create_item_ids_element(items=items, version=self.account.version)
                                                  -    senditem.append(item_ids)
                                                  +    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(SaveItemToFolder=bool(saved_item_folder)))
                                                  +    payload.append(item_ids_element(items=items, version=self.account.version))
                                                       if saved_item_folder:
                                                  -        saveditemfolderid = create_element('m:SavedItemFolderId')
                                                  -        set_xml_value(saveditemfolderid, saved_item_folder, version=self.account.version)
                                                  -        senditem.append(saveditemfolderid)
                                                  -    return senditem
                                                  + payload.append(folder_ids_element( + folders=[saved_item_folder], version=self.account.version, tag='m:SavedItemFolderId' + )) + return payload @@ -5245,7 +4919,9 @@

                                                  Inherited members

                                                  MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/sendnotification

                                                  -

                                                  This is not an actual EWS service you can call. We only use it to parse the XML body of push notifications.

                                                  +

                                                  This service is implemented backwards compared to other services. We use it to parse the XML body of push +notifications we receive on the callback URL defined in a push subscription, and to create responses to these +push notifications.

                                                  Expand source code @@ -5253,29 +4929,40 @@

                                                  Inherited members

                                                  class SendNotification(EWSService):
                                                       """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/sendnotification
                                                   
                                                  -    This is not an actual EWS service you can call. We only use it to parse the XML body of push notifications.
                                                  +    This service is implemented backwards compared to other services. We use it to parse the XML body of push
                                                  +    notifications we receive on the callback URL defined in a push subscription, and to create responses to these
                                                  +    push notifications.
                                                       """
                                                   
                                                       SERVICE_NAME = 'SendNotification'
                                                  +    OK = 'OK'
                                                  +    UNSUBSCRIBE = 'Unsubscribe'
                                                  +    STATUS_CHOICES = (OK, UNSUBSCRIBE)
                                                   
                                                  -    def call(self):
                                                  -        raise NotImplementedError()
                                                  +    def ok_payload(self):
                                                  +        return wrap(content=self.get_payload(status=self.OK))
                                                   
                                                  -    def _elems_to_objs(self, elems):
                                                  -        for elem in elems:
                                                  -            if isinstance(elem, Exception):
                                                  -                yield elem
                                                  -                continue
                                                  -            yield Notification.from_xml(elem=elem, account=None)
                                                  +    def unsubscribe_payload(self):
                                                  +        return wrap(content=self.get_payload(status=self.UNSUBSCRIBE))
                                                  +
                                                  +    def _elem_to_obj(self, elem):
                                                  +        return Notification.from_xml(elem=elem, account=None)
                                                   
                                                       @classmethod
                                                       def _response_tag(cls):
                                                           """Return the name of the element containing the service response."""
                                                  -        return '{%s}%s' % (MNS, cls.SERVICE_NAME)
                                                  +        return f'{{{MNS}}}{cls.SERVICE_NAME}'
                                                   
                                                       @classmethod
                                                       def _get_elements_in_container(cls, container):
                                                  -        return container.findall(Notification.response_tag())
                                                  + return container.findall(Notification.response_tag()) + + def get_payload(self, status): + if status not in self.STATUS_CHOICES: + raise InvalidEnumValue('status', status, self.STATUS_CHOICES) + payload = create_element(f'm:{self.SERVICE_NAME}Result') + add_xml_child(payload, 'm:SubscriptionStatus', status) + return payload

                                                  Ancestors

                                                    @@ -5283,15 +4970,27 @@

                                                    Ancestors

                                                  Class variables

                                                  +
                                                  var OK
                                                  +
                                                  +
                                                  +
                                                  var SERVICE_NAME
                                                  +
                                                  var STATUS_CHOICES
                                                  +
                                                  +
                                                  +
                                                  +
                                                  var UNSUBSCRIBE
                                                  +
                                                  +
                                                  +

                                                  Methods

                                                  -
                                                  -def call(self) +
                                                  +def get_payload(self, status)
                                                  @@ -5299,8 +4998,38 @@

                                                  Methods

                                                  Expand source code -
                                                  def call(self):
                                                  -    raise NotImplementedError()
                                                  +
                                                  def get_payload(self, status):
                                                  +    if status not in self.STATUS_CHOICES:
                                                  +        raise InvalidEnumValue('status', status, self.STATUS_CHOICES)
                                                  +    payload = create_element(f'm:{self.SERVICE_NAME}Result')
                                                  +    add_xml_child(payload, 'm:SubscriptionStatus', status)
                                                  +    return payload
                                                  + +
                                                  +
                                                  +def ok_payload(self) +
                                                  +
                                                  +
                                                  +
                                                  + +Expand source code + +
                                                  def ok_payload(self):
                                                  +    return wrap(content=self.get_payload(status=self.OK))
                                                  +
                                                  +
                                                  +
                                                  +def unsubscribe_payload(self) +
                                                  +
                                                  +
                                                  +
                                                  + +Expand source code + +
                                                  def unsubscribe_payload(self):
                                                  +    return wrap(content=self.get_payload(status=self.UNSUBSCRIBE))
                                                  @@ -5337,16 +5066,15 @@

                                                  Inherited members

                                                  def call(self, oof_settings, mailbox): if not isinstance(oof_settings, OofSettings): - raise ValueError("'oof_settings' %r must be an OofSettings instance" % oof_settings) + raise InvalidTypeError('oof_settings', oof_settings, OofSettings) if not isinstance(mailbox, Mailbox): - raise ValueError("'mailbox' %r must be an Mailbox instance" % mailbox) + raise InvalidTypeError('mailbox', mailbox, Mailbox) return self._get_elements(payload=self.get_payload(oof_settings=oof_settings, mailbox=mailbox)) def get_payload(self, oof_settings, mailbox): - payload = create_element('m:%sRequest' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}Request') set_xml_value(payload, AvailabilityMailbox.from_mailbox(mailbox), version=self.account.version) - set_xml_value(payload, oof_settings, version=self.account.version) - return payload + return set_xml_value(payload, oof_settings, version=self.account.version) def _get_element_container(self, message, name=None): message = message.find(self._response_message_tag()) @@ -5354,7 +5082,7 @@

                                                  Inherited members

                                                  @classmethod def _response_message_tag(cls): - return '{%s}ResponseMessage' % MNS
                                                  + return f'{{{MNS}}}ResponseMessage'

                                                  Ancestors

                                                    @@ -5385,9 +5113,9 @@

                                                    Methods

                                                    def call(self, oof_settings, mailbox):
                                                         if not isinstance(oof_settings, OofSettings):
                                                    -        raise ValueError("'oof_settings' %r must be an OofSettings instance" % oof_settings)
                                                    +        raise InvalidTypeError('oof_settings', oof_settings, OofSettings)
                                                         if not isinstance(mailbox, Mailbox):
                                                    -        raise ValueError("'mailbox' %r must be an Mailbox instance" % mailbox)
                                                    +        raise InvalidTypeError('mailbox', mailbox, Mailbox)
                                                         return self._get_elements(payload=self.get_payload(oof_settings=oof_settings, mailbox=mailbox))
                                                  @@ -5401,10 +5129,9 @@

                                                  Methods

                                                  Expand source code
                                                  def get_payload(self, oof_settings, mailbox):
                                                  -    payload = create_element('m:%sRequest' % self.SERVICE_NAME)
                                                  +    payload = create_element(f'm:{self.SERVICE_NAME}Request')
                                                       set_xml_value(payload, AvailabilityMailbox.from_mailbox(mailbox), version=self.account.version)
                                                  -    set_xml_value(payload, oof_settings, version=self.account.version)
                                                  -    return payload
                                                  + return set_xml_value(payload, oof_settings, version=self.account.version) @@ -5441,13 +5168,13 @@

                                                  Inherited members

                                                  ) def get_payload(self, folders, event_types, watermark, timeout): - subscribe = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') request_elem = self._partial_payload(folders=folders, event_types=event_types) if watermark: add_xml_child(request_elem, 'm:Watermark', watermark) add_xml_child(request_elem, 't:Timeout', timeout) # In minutes - subscribe.append(request_elem) - return subscribe + payload.append(request_elem) + return payload

                                                  Ancestors

                                                    @@ -5494,13 +5221,13 @@

                                                    Methods

                                                    Expand source code
                                                    def get_payload(self, folders, event_types, watermark, timeout):
                                                    -    subscribe = create_element('m:%s' % self.SERVICE_NAME)
                                                    +    payload = create_element(f'm:{self.SERVICE_NAME}')
                                                         request_elem = self._partial_payload(folders=folders, event_types=event_types)
                                                         if watermark:
                                                             add_xml_child(request_elem, 'm:Watermark', watermark)
                                                         add_xml_child(request_elem, 't:Timeout', timeout)  # In minutes
                                                    -    subscribe.append(request_elem)
                                                    -    return subscribe
                                                    + payload.append(request_elem) + return payload @@ -5536,14 +5263,14 @@

                                                    Inherited members

                                                    ) def get_payload(self, folders, event_types, watermark, status_frequency, url): - subscribe = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') request_elem = self._partial_payload(folders=folders, event_types=event_types) if watermark: add_xml_child(request_elem, 'm:Watermark', watermark) add_xml_child(request_elem, 't:StatusFrequency', status_frequency) # In minutes add_xml_child(request_elem, 't:URL', url) - subscribe.append(request_elem) - return subscribe + payload.append(request_elem) + return payload

                                                    Ancestors

                                                      @@ -5586,14 +5313,14 @@

                                                      Methods

                                                      Expand source code
                                                      def get_payload(self, folders, event_types, watermark, status_frequency, url):
                                                      -    subscribe = create_element('m:%s' % self.SERVICE_NAME)
                                                      +    payload = create_element(f'm:{self.SERVICE_NAME}')
                                                           request_elem = self._partial_payload(folders=folders, event_types=event_types)
                                                           if watermark:
                                                               add_xml_child(request_elem, 'm:Watermark', watermark)
                                                           add_xml_child(request_elem, 't:StatusFrequency', status_frequency)  # In minutes
                                                           add_xml_child(request_elem, 't:URL', url)
                                                      -    subscribe.append(request_elem)
                                                      -    return subscribe
                                                      + payload.append(request_elem) + return payload @@ -5626,22 +5353,17 @@

                                                      Inherited members

                                                      def call(self, folders, event_types): yield from self._partial_call(payload_func=self.get_payload, folders=folders, event_types=event_types) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield elem.text + def _elem_to_obj(self, elem): + return elem.text @classmethod def _get_elements_in_container(cls, container): - return [container.find('{%s}SubscriptionId' % MNS)] + return [container.find(f'{{{MNS}}}SubscriptionId')] def get_payload(self, folders, event_types): - subscribe = create_element('m:%s' % self.SERVICE_NAME) - request_elem = self._partial_payload(folders=folders, event_types=event_types) - subscribe.append(request_elem) - return subscribe + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(self._partial_payload(folders=folders, event_types=event_types)) + return payload

                                                      Ancestors

                                                        @@ -5685,10 +5407,9 @@

                                                        Methods

                                                        Expand source code
                                                        def get_payload(self, folders, event_types):
                                                        -    subscribe = create_element('m:%s' % self.SERVICE_NAME)
                                                        -    request_elem = self._partial_payload(folders=folders, event_types=event_types)
                                                        -    subscribe.append(request_elem)
                                                        -    return subscribe
                                                        + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(self._partial_payload(folders=folders, event_types=event_types)) + return payload @@ -5722,29 +5443,32 @@

                                                        Inherited members

                                                        SERVICE_NAME = 'SyncFolderHierarchy' shape_tag = 'm:FolderShape' - last_in_range_name = '{%s}IncludesLastFolderInRange' % MNS + last_in_range_name = f'{{{MNS}}}IncludesLastFolderInRange' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.folder = None # A hack to communicate parsing args to _elems_to_objs() def call(self, folder, shape, additional_fields, sync_state): self.sync_state = sync_state - change_types = self._change_types_map() - for elem in self._get_elements(payload=self.get_payload( + self.folder = folder + return self._elems_to_objs(self._get_elements(payload=self.get_payload( folder=folder, shape=shape, additional_fields=additional_fields, sync_state=sync_state, - )): - if isinstance(elem, Exception): - yield elem - continue - change_type = change_types[elem.tag] - if change_type == self.DELETE: - folder = FolderId.from_xml(elem=elem.find(FolderId.response_tag()), account=self.account) - else: - # We can't find() the element because we don't know which tag to look for. The change element can - # contain multiple folder types, each with their own tag. - folder_elem = elem[0] - folder = parse_folder_elem(elem=folder_elem, folder=folder, account=self.account) - yield change_type, folder + ))) + + def _elem_to_obj(self, elem): + change_type = self.change_types_map[elem.tag] + if change_type == self.DELETE: + folder = FolderId.from_xml(elem=elem.find(FolderId.response_tag()), account=self.account) + else: + # We can't find() the element because we don't know which tag to look for. The change element can + # contain multiple folder types, each with their own tag. + folder_elem = elem[0] + folder = parse_folder_elem(elem=folder_elem, folder=self.folder, account=self.account) + return change_type, folder def get_payload(self, folder, shape, additional_fields, sync_state): return self._partial_get_payload( @@ -5754,6 +5478,7 @@

                                                        Inherited members

                                                        Ancestors

                                                        @@ -5785,25 +5510,13 @@

                                                        Methods

                                                        def call(self, folder, shape, additional_fields, sync_state):
                                                             self.sync_state = sync_state
                                                        -    change_types = self._change_types_map()
                                                        -    for elem in self._get_elements(payload=self.get_payload(
                                                        +    self.folder = folder
                                                        +    return self._elems_to_objs(self._get_elements(payload=self.get_payload(
                                                                     folder=folder,
                                                                     shape=shape,
                                                                     additional_fields=additional_fields,
                                                                     sync_state=sync_state,
                                                        -    )):
                                                        -        if isinstance(elem, Exception):
                                                        -            yield elem
                                                        -            continue
                                                        -        change_type = change_types[elem.tag]
                                                        -        if change_type == self.DELETE:
                                                        -            folder = FolderId.from_xml(elem=elem.find(FolderId.response_tag()), account=self.account)
                                                        -        else:
                                                        -            # We can't find() the element because we don't know which tag to look for. The change element can
                                                        -            # contain multiple folder types, each with their own tag.
                                                        -            folder_elem = elem[0]
                                                        -            folder = parse_folder_elem(elem=folder_elem, folder=folder, account=self.account)
                                                        -        yield change_type, folder
                                                        + )))
                                                        @@ -5851,29 +5564,28 @@

                                                        Inherited members

                                                        """ SERVICE_NAME = 'SyncFolderItems' - SYNC_SCOPES = { + SYNC_SCOPES = ( 'NormalItems', 'NormalAndAssociatedItems', - } + ) # Extra change type READ_FLAG_CHANGE = 'read_flag_change' CHANGE_TYPES = SyncFolder.CHANGE_TYPES + (READ_FLAG_CHANGE,) shape_tag = 'm:ItemShape' - last_in_range_name = '{%s}IncludesLastItemInRange' % MNS - - def _change_types_map(self): - res = super()._change_types_map() - res['{%s}ReadFlagChange' % TNS] = self.READ_FLAG_CHANGE - return res + last_in_range_name = f'{{{MNS}}}IncludesLastItemInRange' + change_types_map = SyncFolder.change_types_map + change_types_map[f'{{{TNS}}}ReadFlagChange'] = READ_FLAG_CHANGE def call(self, folder, shape, additional_fields, sync_state, ignore, max_changes_returned, sync_scope): self.sync_state = sync_state if max_changes_returned is None: - max_changes_returned = self.chunk_size + max_changes_returned = self.page_size + if not isinstance(max_changes_returned, int): + raise InvalidTypeError('max_changes_returned', max_changes_returned, int) if max_changes_returned <= 0: - raise ValueError("'max_changes_returned' %s must be a positive integer" % max_changes_returned) + raise ValueError(f"'max_changes_returned' {max_changes_returned} must be a positive integer") if sync_scope is not None and sync_scope not in self.SYNC_SCOPES: - raise ValueError("'sync_scope' %s must be one of %r" % (sync_scope, self.SYNC_SCOPES)) + raise InvalidEnumValue('sync_scope', sync_scope, self.SYNC_SCOPES) return self._elems_to_objs(self._get_elements(payload=self.get_payload( folder=folder, shape=shape, @@ -5884,44 +5596,38 @@

                                                        Inherited members

                                                        sync_scope=sync_scope, ))) - def _elems_to_objs(self, elems): - from ..folders.base import BaseFolder - change_types = self._change_types_map() - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - change_type = change_types[elem.tag] - if change_type == self.READ_FLAG_CHANGE: - item = ( - ItemId.from_xml(elem=elem.find(ItemId.response_tag()), account=self.account), - xml_text_to_value(elem.find('{%s}IsRead' % TNS).text, bool) - ) - elif change_type == self.DELETE: - item = ItemId.from_xml(elem=elem.find(ItemId.response_tag()), account=self.account) - else: - # We can't find() the element because we don't know which tag to look for. The change element can - # contain multiple item types, each with their own tag. - item_elem = elem[0] - item = BaseFolder.item_model_from_tag(item_elem.tag).from_xml(elem=item_elem, account=self.account) - yield change_type, item + def _elem_to_obj(self, elem): + change_type = self.change_types_map[elem.tag] + if change_type == self.READ_FLAG_CHANGE: + item = ( + ItemId.from_xml(elem=elem.find(ItemId.response_tag()), account=self.account), + xml_text_to_value(elem.find(f'{{{TNS}}}IsRead').text, bool) + ) + elif change_type == self.DELETE: + item = ItemId.from_xml(elem=elem.find(ItemId.response_tag()), account=self.account) + else: + # We can't find() the element because we don't know which tag to look for. The change element can + # contain multiple item types, each with their own tag. + item_elem = elem[0] + item = BaseFolder.item_model_from_tag(item_elem.tag).from_xml(elem=item_elem, account=self.account) + return change_type, item def get_payload(self, folder, shape, additional_fields, sync_state, ignore, max_changes_returned, sync_scope): - syncfolderitems = self._partial_get_payload( + sync_folder_items = self._partial_get_payload( folder=folder, shape=shape, additional_fields=additional_fields, sync_state=sync_state ) is_empty, ignore = (True, None) if ignore is None else peek(ignore) if not is_empty: - item_ids = create_item_ids_element(items=ignore, version=self.account.version, tag='m:Ignore') - syncfolderitems.append(item_ids) - add_xml_child(syncfolderitems, 'm:MaxChangesReturned', max_changes_returned) + sync_folder_items.append(item_ids_element(items=ignore, version=self.account.version, tag='m:Ignore')) + add_xml_child(sync_folder_items, 'm:MaxChangesReturned', max_changes_returned) if sync_scope: - add_xml_child(syncfolderitems, 'm:SyncScope', sync_scope) - return syncfolderitems
                                                        + add_xml_child(sync_folder_items, 'm:SyncScope', sync_scope) + return sync_folder_items

                                                        Ancestors

                                                        @@ -5943,6 +5649,10 @@

                                                        Class variables

                                                        +
                                                        var change_types_map
                                                        +
                                                        +
                                                        +
                                                        var last_in_range_name
                                                        @@ -5966,11 +5676,13 @@

                                                        Methods

                                                        def call(self, folder, shape, additional_fields, sync_state, ignore, max_changes_returned, sync_scope):
                                                             self.sync_state = sync_state
                                                             if max_changes_returned is None:
                                                        -        max_changes_returned = self.chunk_size
                                                        +        max_changes_returned = self.page_size
                                                        +    if not isinstance(max_changes_returned, int):
                                                        +        raise InvalidTypeError('max_changes_returned', max_changes_returned, int)
                                                             if max_changes_returned <= 0:
                                                        -        raise ValueError("'max_changes_returned' %s must be a positive integer" % max_changes_returned)
                                                        +        raise ValueError(f"'max_changes_returned' {max_changes_returned} must be a positive integer")
                                                             if sync_scope is not None and sync_scope not in self.SYNC_SCOPES:
                                                        -        raise ValueError("'sync_scope' %s must be one of %r" % (sync_scope, self.SYNC_SCOPES))
                                                        +        raise InvalidEnumValue('sync_scope', sync_scope, self.SYNC_SCOPES)
                                                             return self._elems_to_objs(self._get_elements(payload=self.get_payload(
                                                                     folder=folder,
                                                                     shape=shape,
                                                        @@ -5992,17 +5704,16 @@ 

                                                        Methods

                                                        Expand source code
                                                        def get_payload(self, folder, shape, additional_fields, sync_state, ignore, max_changes_returned, sync_scope):
                                                        -    syncfolderitems = self._partial_get_payload(
                                                        +    sync_folder_items = self._partial_get_payload(
                                                                 folder=folder, shape=shape, additional_fields=additional_fields, sync_state=sync_state
                                                             )
                                                             is_empty, ignore = (True, None) if ignore is None else peek(ignore)
                                                             if not is_empty:
                                                        -        item_ids = create_item_ids_element(items=ignore, version=self.account.version, tag='m:Ignore')
                                                        -        syncfolderitems.append(item_ids)
                                                        -    add_xml_child(syncfolderitems, 'm:MaxChangesReturned', max_changes_returned)
                                                        +        sync_folder_items.append(item_ids_element(items=ignore, version=self.account.version, tag='m:Ignore'))
                                                        +    add_xml_child(sync_folder_items, 'm:MaxChangesReturned', max_changes_returned)
                                                             if sync_scope:
                                                        -        add_xml_child(syncfolderitems, 'm:SyncScope', sync_scope)
                                                        -    return syncfolderitems
                                                        + add_xml_child(sync_folder_items, 'm:SyncScope', sync_scope) + return sync_folder_items
                                                        @@ -6043,9 +5754,9 @@

                                                        Inherited members

                                                        return self._get_elements(payload=self.get_payload(subscription_id=subscription_id)) def get_payload(self, subscription_id): - unsubscribe = create_element('m:%s' % self.SERVICE_NAME) - add_xml_child(unsubscribe, 'm:SubscriptionId', subscription_id) - return unsubscribe + payload = create_element(f'm:{self.SERVICE_NAME}') + add_xml_child(payload, 'm:SubscriptionId', subscription_id) + return payload

                                                        Ancestors

                                                          @@ -6092,9 +5803,9 @@

                                                          Methods

                                                          Expand source code
                                                          def get_payload(self, subscription_id):
                                                          -    unsubscribe = create_element('m:%s' % self.SERVICE_NAME)
                                                          -    add_xml_child(unsubscribe, 'm:SubscriptionId', subscription_id)
                                                          -    return unsubscribe
                                                          + payload = create_element(f'm:{self.SERVICE_NAME}') + add_xml_child(payload, 'm:SubscriptionId', subscription_id) + return payload @@ -6120,11 +5831,15 @@

                                                          Inherited members

                                                          Expand source code -
                                                          class UpdateFolder(EWSAccountService):
                                                          +
                                                          class UpdateFolder(BaseUpdateService):
                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updatefolder-operation"""
                                                           
                                                               SERVICE_NAME = 'UpdateFolder'
                                                          -    element_container_name = '{%s}Folders' % MNS
                                                          +    SET_FIELD_ELEMENT_NAME = 't:SetFolderField'
                                                          +    DELETE_FIELD_ELEMENT_NAME = 't:DeleteFolderField'
                                                          +    CHANGE_ELEMENT_NAME = 't:FolderChange'
                                                          +    CHANGES_ELEMENT_NAME = 'm:FolderChanges'
                                                          +    element_container_name = f'{{{MNS}}}Folders'
                                                           
                                                               def __init__(self, *args, **kwargs):
                                                                   super().__init__(*args, **kwargs)
                                                          @@ -6143,78 +5858,44 @@ 

                                                          Inherited members

                                                          continue yield parse_folder_elem(elem=elem, folder=folder, account=self.account) - @staticmethod - def _sort_fieldnames(folder_model, fieldnames): - # Take a list of fieldnames and return the fields in the order they are mentioned in folder_model.FIELDS. - # Loop over FIELDS and not supported_fields(). Upstream should make sure not to update a non-supported field. - for f in folder_model.FIELDS: - if f.name in fieldnames: - yield f.name - - def _set_folder_elem(self, folder_model, field_path, value): - setfolderfield = create_element('t:SetFolderField') - set_xml_value(setfolderfield, field_path, version=self.account.version) - folder = create_element(folder_model.request_tag()) - field_elem = field_path.field.to_xml(value, version=self.account.version) - set_xml_value(folder, field_elem, version=self.account.version) - setfolderfield.append(folder) - return setfolderfield - - def _delete_folder_elem(self, field_path): - deletefolderfield = create_element('t:DeleteFolderField') - return set_xml_value(deletefolderfield, field_path, version=self.account.version) - - def _get_folder_update_elems(self, folder, fieldnames): - folder_model = folder.__class__ - fieldnames_set = set(fieldnames) - - for fieldname in self._sort_fieldnames(folder_model=folder_model, fieldnames=fieldnames_set): - field = folder_model.get_field_by_fieldname(fieldname) - if field.is_read_only: - raise ValueError('%s is a read-only field' % field.name) - value = field.clean(getattr(folder, field.name), version=self.account.version) # Make sure the value is OK - - if value is None or (field.is_list and not value): - # A value of None or [] means we want to remove this field from the item - if field.is_required or field.is_required_after_save: - raise ValueError('%s is a required field and may not be deleted' % field.name) - for field_path in FieldPath(field=field).expand(version=self.account.version): - yield self._delete_folder_elem(field_path=field_path) - continue - - yield self._set_folder_elem(folder_model=folder_model, field_path=FieldPath(field=field), value=value) + def _target_elem(self, target): + return to_item_id(target, FolderId) def get_payload(self, folders): - from ..folders import BaseFolder, FolderId - updatefolder = create_element('m:%s' % self.SERVICE_NAME) - folderchanges = create_element('m:FolderChanges') - version = self.account.version - for folder, fieldnames in folders: - folderchange = create_element('t:FolderChange') - if not isinstance(folder, (BaseFolder, FolderId)): - folder = to_item_id(folder, FolderId, version=version) - set_xml_value(folderchange, folder, version=version) - updates = create_element('t:Updates') - for elem in self._get_folder_update_elems(folder=folder, fieldnames=fieldnames): - updates.append(elem) - folderchange.append(updates) - folderchanges.append(folderchange) - if not len(folderchanges): - raise ValueError('"folders" must not be empty') - updatefolder.append(folderchanges) - return updatefolder
                                                          + # Takes a list of (Folder, fieldnames) tuples where 'Folder' is a instance of a subclass of Folder and + # 'fieldnames' are the attribute names that were updated. + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(self._changes_elem(target_changes=folders)) + return payload

                                                          Ancestors

                                                          Class variables

                                                          +
                                                          var CHANGES_ELEMENT_NAME
                                                          +
                                                          +
                                                          +
                                                          +
                                                          var CHANGE_ELEMENT_NAME
                                                          +
                                                          +
                                                          +
                                                          +
                                                          var DELETE_FIELD_ELEMENT_NAME
                                                          +
                                                          +
                                                          +
                                                          var SERVICE_NAME
                                                          +
                                                          var SET_FIELD_ELEMENT_NAME
                                                          +
                                                          +
                                                          +
                                                          var element_container_name
                                                          @@ -6248,35 +5929,22 @@

                                                          Methods

                                                          Expand source code
                                                          def get_payload(self, folders):
                                                          -    from ..folders import BaseFolder, FolderId
                                                          -    updatefolder = create_element('m:%s' % self.SERVICE_NAME)
                                                          -    folderchanges = create_element('m:FolderChanges')
                                                          -    version = self.account.version
                                                          -    for folder, fieldnames in folders:
                                                          -        folderchange = create_element('t:FolderChange')
                                                          -        if not isinstance(folder, (BaseFolder, FolderId)):
                                                          -            folder = to_item_id(folder, FolderId, version=version)
                                                          -        set_xml_value(folderchange, folder, version=version)
                                                          -        updates = create_element('t:Updates')
                                                          -        for elem in self._get_folder_update_elems(folder=folder, fieldnames=fieldnames):
                                                          -            updates.append(elem)
                                                          -        folderchange.append(updates)
                                                          -        folderchanges.append(folderchange)
                                                          -    if not len(folderchanges):
                                                          -        raise ValueError('"folders" must not be empty')
                                                          -    updatefolder.append(folderchanges)
                                                          -    return updatefolder
                                                          + # Takes a list of (Folder, fieldnames) tuples where 'Folder' is a instance of a subclass of Folder and + # 'fieldnames' are the attribute names that were updated. + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(self._changes_elem(target_changes=folders)) + return payload

                                                          Inherited members

                                                          @@ -6291,30 +5959,27 @@

                                                          Inherited members

                                                          Expand source code -
                                                          class UpdateItem(EWSAccountService):
                                                          +
                                                          class UpdateItem(BaseUpdateService):
                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem-operation"""
                                                           
                                                               SERVICE_NAME = 'UpdateItem'
                                                          -    element_container_name = '{%s}Items' % MNS
                                                          +    SET_FIELD_ELEMENT_NAME = 't:SetItemField'
                                                          +    DELETE_FIELD_ELEMENT_NAME = 't:DeleteItemField'
                                                          +    CHANGE_ELEMENT_NAME = 't:ItemChange'
                                                          +    CHANGES_ELEMENT_NAME = 'm:ItemChanges'
                                                          +    element_container_name = f'{{{MNS}}}Items'
                                                           
                                                               def call(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations,
                                                                        suppress_read_receipts):
                                                          -        from ..items import CONFLICT_RESOLUTION_CHOICES, MESSAGE_DISPOSITION_CHOICES, \
                                                          -            SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, SEND_ONLY
                                                                   if conflict_resolution not in CONFLICT_RESOLUTION_CHOICES:
                                                          -            raise ValueError("'conflict_resolution' %s must be one of %s" % (
                                                          -                conflict_resolution, CONFLICT_RESOLUTION_CHOICES
                                                          -            ))
                                                          +            raise InvalidEnumValue('conflict_resolution', conflict_resolution, CONFLICT_RESOLUTION_CHOICES)
                                                                   if message_disposition not in MESSAGE_DISPOSITION_CHOICES:
                                                          -            raise ValueError("'message_disposition' %s must be one of %s" % (
                                                          -                message_disposition, MESSAGE_DISPOSITION_CHOICES
                                                          -            ))
                                                          +            raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES)
                                                                   if send_meeting_invitations_or_cancellations not in SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES:
                                                          -            raise ValueError("'send_meeting_invitations_or_cancellations' %s must be one of %s" % (
                                                          -                send_meeting_invitations_or_cancellations, SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES
                                                          -            ))
                                                          -        if suppress_read_receipts not in (True, False):
                                                          -            raise ValueError("'suppress_read_receipts' %s must be True or False" % suppress_read_receipts)
                                                          +            raise InvalidEnumValue(
                                                          +                'send_meeting_invitations_or_cancellations', send_meeting_invitations_or_cancellations,
                                                          +                SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES
                                                          +            )
                                                                   if message_disposition == SEND_ONLY:
                                                                       raise ValueError('Cannot send-only existing objects. Use SendItem service instead')
                                                                   return self._elems_to_objs(self._chunked_get_elements(
                                                          @@ -6326,166 +5991,83 @@ 

                                                          Inherited members

                                                          suppress_read_receipts=suppress_read_receipts, )) - def _elems_to_objs(self, elems): - from ..items import Item - for elem in elems: - if isinstance(elem, (Exception, type(None))): - yield elem - continue - yield Item.id_from_xml(elem) + def _elem_to_obj(self, elem): + return Item.id_from_xml(elem) - def _delete_item_elem(self, field_path): - deleteitemfield = create_element('t:DeleteItemField') - return set_xml_value(deleteitemfield, field_path, version=self.account.version) - - def _set_item_elem(self, item_model, field_path, value): - setitemfield = create_element('t:SetItemField') - set_xml_value(setitemfield, field_path, version=self.account.version) - item_elem = create_element(item_model.request_tag()) - field_elem = field_path.field.to_xml(value, version=self.account.version) - set_xml_value(item_elem, field_elem, version=self.account.version) - setitemfield.append(item_elem) - return setitemfield - - @staticmethod - def _sorted_fields(item_model, fieldnames): - # Take a list of fieldnames and return the (unique) fields in the order they are mentioned in item_class.FIELDS. - # Checks that all fieldnames are valid. - unique_fieldnames = list(OrderedDict.fromkeys(fieldnames)) # Make field names unique ,but keep ordering - # Loop over FIELDS and not supported_fields(). Upstream should make sure not to update a non-supported field. - for f in item_model.FIELDS: - if f.name in unique_fieldnames: - unique_fieldnames.remove(f.name) - yield f - if unique_fieldnames: - raise ValueError("Field name(s) %s are not valid for a '%s' item" % ( - ', '.join("'%s'" % f for f in unique_fieldnames), item_model.__name__)) - - def _get_item_update_elems(self, item, fieldnames): - from ..items import CalendarItem + def _update_elems(self, target, fieldnames): fieldnames_copy = list(fieldnames) - if item.__class__ == CalendarItem: + if target.__class__ == CalendarItem: # For CalendarItem items where we update 'start' or 'end', we want to update internal timezone fields - item.clean_timezone_fields(version=self.account.version) # Possibly also sets timezone values + target.clean_timezone_fields(version=self.account.version) # Possibly also sets timezone values for field_name in ('start', 'end'): if field_name in fieldnames_copy: - tz_field_name = item.tz_field_for_field_name(field_name).name + tz_field_name = target.tz_field_for_field_name(field_name).name if tz_field_name not in fieldnames_copy: fieldnames_copy.append(tz_field_name) - for field in self._sorted_fields(item_model=item.__class__, fieldnames=fieldnames_copy): - if field.is_read_only: - raise ValueError('%s is a read-only field' % field.name) - value = self._get_item_value(item, field) - if value is None or (field.is_list and not value): - # A value of None or [] means we want to remove this field from the item - yield from self._get_delete_item_elems(field=field) - else: - yield from self._get_set_item_elems(item_model=item.__class__, field=field, value=value) - - def _get_item_value(self, item, field): - from ..items import CalendarItem - value = field.clean(getattr(item, field.name), version=self.account.version) # Make sure the value is OK - if item.__class__ == CalendarItem: + yield from super()._update_elems(target=target, fieldnames=fieldnames_copy) + + def _get_value(self, target, field): + value = super()._get_value(target, field) + + if target.__class__ == CalendarItem: # For CalendarItem items where we update 'start' or 'end', we want to send values in the local timezone if field.name in ('start', 'end'): if type(value) is EWSDate: # EWS always expects a datetime - return item.date_to_datetime(field_name=field.name) - tz_field_name = item.tz_field_for_field_name(field.name).name - return value.astimezone(getattr(item, tz_field_name)) + return target.date_to_datetime(field_name=field.name) + tz_field_name = target.tz_field_for_field_name(field.name).name + return value.astimezone(getattr(target, tz_field_name)) + return value - def _get_delete_item_elems(self, field): - if field.is_required or field.is_required_after_save: - raise ValueError('%s is a required field and may not be deleted' % field.name) - for field_path in FieldPath(field=field).expand(version=self.account.version): - yield self._delete_item_elem(field_path=field_path) - - def _get_set_item_elems(self, item_model, field, value): - if isinstance(field, IndexedField): - # Generate either set or delete elements for all combinations of labels and subfields - supported_labels = field.value_cls.get_field_by_fieldname('label')\ - .supported_choices(version=self.account.version) - seen_labels = set() - subfields = field.value_cls.supported_fields(version=self.account.version) - for v in value: - seen_labels.add(v.label) - for subfield in subfields: - field_path = FieldPath(field=field, label=v.label, subfield=subfield) - subfield_value = getattr(v, subfield.name) - if not subfield_value: - # Generate delete elements for blank subfield values - yield self._delete_item_elem(field_path=field_path) - else: - # Generate set elements for non-null subfield values - yield self._set_item_elem( - item_model=item_model, - field_path=field_path, - value=field.value_cls(**{'label': v.label, subfield.name: subfield_value}), - ) - # Generate delete elements for all subfields of all labels not mentioned in the list of values - for label in (label for label in supported_labels if label not in seen_labels): - for subfield in subfields: - yield self._delete_item_elem(field_path=FieldPath(field=field, label=label, subfield=subfield)) - else: - yield self._set_item_elem(item_model=item_model, field_path=FieldPath(field=field), value=value) + def _target_elem(self, target): + return to_item_id(target, ItemId) def get_payload(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, suppress_read_receipts): # Takes a list of (Item, fieldnames) tuples where 'Item' is a instance of a subclass of Item and 'fieldnames' - # are the attribute names that were updated. Returns the XML for an UpdateItem call. - # an UpdateItem request. + # are the attribute names that were updated. + attrs = dict( + ConflictResolution=conflict_resolution, + MessageDisposition=message_disposition, + SendMeetingInvitationsOrCancellations=send_meeting_invitations_or_cancellations, + ) if self.account.version.build >= EXCHANGE_2013_SP1: - updateitem = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('ConflictResolution', conflict_resolution), - ('MessageDisposition', message_disposition), - ('SendMeetingInvitationsOrCancellations', send_meeting_invitations_or_cancellations), - ('SuppressReadReceipts', 'true' if suppress_read_receipts else 'false'), - ]) - ) - else: - updateitem = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('ConflictResolution', conflict_resolution), - ('MessageDisposition', message_disposition), - ('SendMeetingInvitationsOrCancellations', send_meeting_invitations_or_cancellations), - ]) - ) - itemchanges = create_element('m:ItemChanges') - version = self.account.version - for item, fieldnames in items: - if not item.account: - item.account = self.account - if not fieldnames: - raise ValueError('"fieldnames" must not be empty') - itemchange = create_element('t:ItemChange') - set_xml_value(itemchange, to_item_id(item, ItemId, version=version), version=version) - updates = create_element('t:Updates') - for elem in self._get_item_update_elems(item=item, fieldnames=fieldnames): - updates.append(elem) - itemchange.append(updates) - itemchanges.append(itemchange) - if not len(itemchanges): - raise ValueError('"items" must not be empty') - updateitem.append(itemchanges) - return updateitem
                                                          + attrs['SuppressReadReceipts'] = suppress_read_receipts + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + payload.append(self._changes_elem(target_changes=items)) + return payload

                                                          Ancestors

                                                          Class variables

                                                          +
                                                          var CHANGES_ELEMENT_NAME
                                                          +
                                                          +
                                                          +
                                                          +
                                                          var CHANGE_ELEMENT_NAME
                                                          +
                                                          +
                                                          +
                                                          +
                                                          var DELETE_FIELD_ELEMENT_NAME
                                                          +
                                                          +
                                                          +
                                                          var SERVICE_NAME
                                                          +
                                                          var SET_FIELD_ELEMENT_NAME
                                                          +
                                                          +
                                                          +
                                                          var element_container_name
                                                          @@ -6504,22 +6086,15 @@

                                                          Methods

                                                          def call(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations,
                                                                    suppress_read_receipts):
                                                          -    from ..items import CONFLICT_RESOLUTION_CHOICES, MESSAGE_DISPOSITION_CHOICES, \
                                                          -        SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, SEND_ONLY
                                                               if conflict_resolution not in CONFLICT_RESOLUTION_CHOICES:
                                                          -        raise ValueError("'conflict_resolution' %s must be one of %s" % (
                                                          -            conflict_resolution, CONFLICT_RESOLUTION_CHOICES
                                                          -        ))
                                                          +        raise InvalidEnumValue('conflict_resolution', conflict_resolution, CONFLICT_RESOLUTION_CHOICES)
                                                               if message_disposition not in MESSAGE_DISPOSITION_CHOICES:
                                                          -        raise ValueError("'message_disposition' %s must be one of %s" % (
                                                          -            message_disposition, MESSAGE_DISPOSITION_CHOICES
                                                          -        ))
                                                          +        raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES)
                                                               if send_meeting_invitations_or_cancellations not in SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES:
                                                          -        raise ValueError("'send_meeting_invitations_or_cancellations' %s must be one of %s" % (
                                                          -            send_meeting_invitations_or_cancellations, SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES
                                                          -        ))
                                                          -    if suppress_read_receipts not in (True, False):
                                                          -        raise ValueError("'suppress_read_receipts' %s must be True or False" % suppress_read_receipts)
                                                          +        raise InvalidEnumValue(
                                                          +            'send_meeting_invitations_or_cancellations', send_meeting_invitations_or_cancellations,
                                                          +            SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES
                                                          +        )
                                                               if message_disposition == SEND_ONLY:
                                                                   raise ValueError('Cannot send-only existing objects. Use SendItem service instead')
                                                               return self._elems_to_objs(self._chunked_get_elements(
                                                          @@ -6544,56 +6119,28 @@ 

                                                          Methods

                                                          def get_payload(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations,
                                                                           suppress_read_receipts):
                                                               # Takes a list of (Item, fieldnames) tuples where 'Item' is a instance of a subclass of Item and 'fieldnames'
                                                          -    # are the attribute names that were updated. Returns the XML for an UpdateItem call.
                                                          -    # an UpdateItem request.
                                                          +    # are the attribute names that were updated.
                                                          +    attrs = dict(
                                                          +        ConflictResolution=conflict_resolution,
                                                          +        MessageDisposition=message_disposition,
                                                          +        SendMeetingInvitationsOrCancellations=send_meeting_invitations_or_cancellations,
                                                          +    )
                                                               if self.account.version.build >= EXCHANGE_2013_SP1:
                                                          -        updateitem = create_element(
                                                          -            'm:%s' % self.SERVICE_NAME,
                                                          -            attrs=OrderedDict([
                                                          -                ('ConflictResolution', conflict_resolution),
                                                          -                ('MessageDisposition', message_disposition),
                                                          -                ('SendMeetingInvitationsOrCancellations', send_meeting_invitations_or_cancellations),
                                                          -                ('SuppressReadReceipts', 'true' if suppress_read_receipts else 'false'),
                                                          -            ])
                                                          -        )
                                                          -    else:
                                                          -        updateitem = create_element(
                                                          -            'm:%s' % self.SERVICE_NAME,
                                                          -            attrs=OrderedDict([
                                                          -                ('ConflictResolution', conflict_resolution),
                                                          -                ('MessageDisposition', message_disposition),
                                                          -                ('SendMeetingInvitationsOrCancellations', send_meeting_invitations_or_cancellations),
                                                          -            ])
                                                          -        )
                                                          -    itemchanges = create_element('m:ItemChanges')
                                                          -    version = self.account.version
                                                          -    for item, fieldnames in items:
                                                          -        if not item.account:
                                                          -            item.account = self.account
                                                          -        if not fieldnames:
                                                          -            raise ValueError('"fieldnames" must not be empty')
                                                          -        itemchange = create_element('t:ItemChange')
                                                          -        set_xml_value(itemchange, to_item_id(item, ItemId, version=version), version=version)
                                                          -        updates = create_element('t:Updates')
                                                          -        for elem in self._get_item_update_elems(item=item, fieldnames=fieldnames):
                                                          -            updates.append(elem)
                                                          -        itemchange.append(updates)
                                                          -        itemchanges.append(itemchange)
                                                          -    if not len(itemchanges):
                                                          -        raise ValueError('"items" must not be empty')
                                                          -    updateitem.append(itemchanges)
                                                          -    return updateitem
                                                          + attrs['SuppressReadReceipts'] = suppress_read_receipts + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + payload.append(self._changes_elem(target_changes=items)) + return payload

                                                          Inherited members

                                                          @@ -6621,9 +6168,7 @@

                                                          Inherited members

                                                          return self._get_elements(payload=self.get_payload(user_configuration=user_configuration)) def get_payload(self, user_configuration): - updateuserconfiguration = create_element('m:%s' % self.SERVICE_NAME) - set_xml_value(updateuserconfiguration, user_configuration, version=self.account.version) - return updateuserconfiguration + return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), user_configuration, version=self.account.version)

                                                          Ancestors

                                                            @@ -6666,9 +6211,7 @@

                                                            Methods

                                                            Expand source code
                                                            def get_payload(self, user_configuration):
                                                            -    updateuserconfiguration = create_element('m:%s' % self.SERVICE_NAME)
                                                            -    set_xml_value(updateuserconfiguration, user_configuration, version=self.account.version)
                                                            -    return updateuserconfiguration
                                                            + return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), user_configuration, version=self.account.version) @@ -6699,7 +6242,7 @@

                                                            Inherited members

                                                            """ SERVICE_NAME = 'UploadItems' - element_container_name = '{%s}ItemId' % MNS + element_container_name = f'{{{MNS}}}ItemId' def call(self, items): # _pool_requests expects 'items', not 'data' @@ -6715,29 +6258,24 @@

                                                            Inherited members

                                                            :param items: """ - uploaditems = create_element('m:%s' % self.SERVICE_NAME) - itemselement = create_element('m:Items') - uploaditems.append(itemselement) + payload = create_element(f'm:{self.SERVICE_NAME}') + items_elem = create_element('m:Items') + payload.append(items_elem) for parent_folder, (item_id, is_associated, data_str) in items: # TODO: The full spec also allows the "UpdateOrCreate" create action. - item = create_element('t:Item', attrs=dict(CreateAction='Update' if item_id else 'CreateNew')) + attrs = dict(CreateAction='Update' if item_id else 'CreateNew') if is_associated is not None: - item.set('IsAssociated', 'true' if is_associated else 'false') - parentfolderid = ParentFolderId(parent_folder.id, parent_folder.changekey) - set_xml_value(item, parentfolderid, version=self.account.version) + attrs['IsAssociated'] = is_associated + item = create_element('t:Item', attrs=attrs) + set_xml_value(item, ParentFolderId(parent_folder.id, parent_folder.changekey), version=self.account.version) if item_id: - itemid = to_item_id(item_id, ItemId, version=self.account.version) - set_xml_value(item, itemid, version=self.account.version) + set_xml_value(item, to_item_id(item_id, ItemId), version=self.account.version) add_xml_child(item, 't:Data', data_str) - itemselement.append(item) - return uploaditems + items_elem.append(item) + return payload - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield elem.get(ItemId.ID_ATTR), elem.get(ItemId.CHANGEKEY_ATTR) + def _elem_to_obj(self, elem): + return elem.get(ItemId.ID_ATTR), elem.get(ItemId.CHANGEKEY_ATTR) @classmethod def _get_elements_in_container(cls, container): @@ -6799,22 +6337,21 @@

                                                            Methods

                                                            :param items: """ - uploaditems = create_element('m:%s' % self.SERVICE_NAME) - itemselement = create_element('m:Items') - uploaditems.append(itemselement) + payload = create_element(f'm:{self.SERVICE_NAME}') + items_elem = create_element('m:Items') + payload.append(items_elem) for parent_folder, (item_id, is_associated, data_str) in items: # TODO: The full spec also allows the "UpdateOrCreate" create action. - item = create_element('t:Item', attrs=dict(CreateAction='Update' if item_id else 'CreateNew')) + attrs = dict(CreateAction='Update' if item_id else 'CreateNew') if is_associated is not None: - item.set('IsAssociated', 'true' if is_associated else 'false') - parentfolderid = ParentFolderId(parent_folder.id, parent_folder.changekey) - set_xml_value(item, parentfolderid, version=self.account.version) + attrs['IsAssociated'] = is_associated + item = create_element('t:Item', attrs=attrs) + set_xml_value(item, ParentFolderId(parent_folder.id, parent_folder.changekey), version=self.account.version) if item_id: - itemid = to_item_id(item_id, ItemId, version=self.account.version) - set_xml_value(item, itemid, version=self.account.version) + set_xml_value(item, to_item_id(item_id, ItemId), version=self.account.version) add_xml_child(item, 't:Data', data_str) - itemselement.append(item) - return uploaditems + items_elem.append(item) + return payload @@ -6913,6 +6450,7 @@

                                                          • SERVICE_NAME
                                                          • call
                                                          • +
                                                          • cls_map
                                                          • get_payload
                                                          • supported_from
                                                          @@ -6928,6 +6466,7 @@

                                                        • SERVICE_NAME
                                                        • call
                                                        • +
                                                        • cls_map
                                                        • element_container_name
                                                        • get_payload
                                                        @@ -6935,6 +6474,7 @@

                                                        CreateFolder

    @@ -92,12 +84,8 @@

    Classes

    self._chunked_get_elements(self.get_payload, items=items, is_junk=is_junk, move_item=move_item) ) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, (Exception, type(None))): - yield elem - continue - yield MovedItemId.id_from_xml(elem) + def _elem_to_obj(self, elem): + return MovedItemId.id_from_xml(elem) @classmethod def _get_elements_in_container(cls, container): @@ -105,13 +93,9 @@

    Classes

    def get_payload(self, items, is_junk, move_item): # Takes a list of items and returns either success or raises an error message - mark_as_junk = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=dict(IsJunk='true' if is_junk else 'false', MoveItem='true' if move_item else 'false') - ) - item_ids = create_item_ids_element(items=items, version=self.account.version) - mark_as_junk.append(item_ids) - return mark_as_junk
    + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IsJunk=is_junk, MoveItem=move_item)) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload

    Ancestors

      @@ -153,13 +137,9 @@

      Methods

      def get_payload(self, items, is_junk, move_item):
           # Takes a list of items and returns either success or raises an error message
      -    mark_as_junk = create_element(
      -        'm:%s' % self.SERVICE_NAME,
      -        attrs=dict(IsJunk='true' if is_junk else 'false', MoveItem='true' if move_item else 'false')
      -    )
      -    item_ids = create_item_ids_element(items=items, version=self.account.version)
      -    mark_as_junk.append(item_ids)
      -    return mark_as_junk
      + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IsJunk=is_junk, MoveItem=move_item)) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload diff --git a/docs/exchangelib/services/move_folder.html b/docs/exchangelib/services/move_folder.html index 0671dd4c..1eb2fec8 100644 --- a/docs/exchangelib/services/move_folder.html +++ b/docs/exchangelib/services/move_folder.html @@ -26,39 +26,33 @@

      Module exchangelib.services.move_folder

      Expand source code -
      from .common import EWSAccountService, create_folder_ids_element
      -from ..util import create_element, set_xml_value, MNS
      +
      from .common import EWSAccountService, folder_ids_element
      +from ..errors import InvalidTypeError
      +from ..folders import BaseFolder
      +from ..properties import FolderId
      +from ..util import create_element, MNS
       
       
       class MoveFolder(EWSAccountService):
           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/movefolder-operation"""
       
           SERVICE_NAME = "MoveFolder"
      -    element_container_name = '{%s}Folders' % MNS
      +    element_container_name = f'{{{MNS}}}Folders'
       
           def call(self, folders, to_folder):
      -        from ..folders import BaseFolder, FolderId
               if not isinstance(to_folder, (BaseFolder, FolderId)):
      -            raise ValueError("'to_folder' %r must be a Folder or FolderId instance" % to_folder)
      +            raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId))
               return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=folders, to_folder=to_folder))
       
      -    def _elems_to_objs(self, elems):
      -        from ..folders import FolderId
      -        for elem in elems:
      -            if isinstance(elem, (Exception, type(None))):
      -                yield elem
      -                continue
      -            yield FolderId.from_xml(elem=elem.find(FolderId.response_tag()), account=self.account)
      +    def _elem_to_obj(self, elem):
      +        return FolderId.from_xml(elem=elem.find(FolderId.response_tag()), account=self.account)
       
           def get_payload(self, folders, to_folder):
               # Takes a list of folders and returns their new folder IDs
      -        movefolder = create_element('m:%s' % self.SERVICE_NAME)
      -        tofolderid = create_element('m:ToFolderId')
      -        set_xml_value(tofolderid, to_folder, version=self.account.version)
      -        movefolder.append(tofolderid)
      -        folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version)
      -        movefolder.append(folder_ids)
      -        return movefolder
      + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) + payload.append(folder_ids_element(folders=folders, version=self.account.version)) + return payload
    @@ -84,31 +78,22 @@

    Classes

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/movefolder-operation""" SERVICE_NAME = "MoveFolder" - element_container_name = '{%s}Folders' % MNS + element_container_name = f'{{{MNS}}}Folders' def call(self, folders, to_folder): - from ..folders import BaseFolder, FolderId if not isinstance(to_folder, (BaseFolder, FolderId)): - raise ValueError("'to_folder' %r must be a Folder or FolderId instance" % to_folder) + raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId)) return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=folders, to_folder=to_folder)) - def _elems_to_objs(self, elems): - from ..folders import FolderId - for elem in elems: - if isinstance(elem, (Exception, type(None))): - yield elem - continue - yield FolderId.from_xml(elem=elem.find(FolderId.response_tag()), account=self.account) + def _elem_to_obj(self, elem): + return FolderId.from_xml(elem=elem.find(FolderId.response_tag()), account=self.account) def get_payload(self, folders, to_folder): # Takes a list of folders and returns their new folder IDs - movefolder = create_element('m:%s' % self.SERVICE_NAME) - tofolderid = create_element('m:ToFolderId') - set_xml_value(tofolderid, to_folder, version=self.account.version) - movefolder.append(tofolderid) - folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version) - movefolder.append(folder_ids) - return movefolder + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) + payload.append(folder_ids_element(folders=folders, version=self.account.version)) + return payload

    Ancestors

      @@ -138,9 +123,8 @@

      Methods

      Expand source code
      def call(self, folders, to_folder):
      -    from ..folders import BaseFolder, FolderId
           if not isinstance(to_folder, (BaseFolder, FolderId)):
      -        raise ValueError("'to_folder' %r must be a Folder or FolderId instance" % to_folder)
      +        raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId))
           return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=folders, to_folder=to_folder))
      @@ -155,13 +139,10 @@

      Methods

      def get_payload(self, folders, to_folder):
           # Takes a list of folders and returns their new folder IDs
      -    movefolder = create_element('m:%s' % self.SERVICE_NAME)
      -    tofolderid = create_element('m:ToFolderId')
      -    set_xml_value(tofolderid, to_folder, version=self.account.version)
      -    movefolder.append(tofolderid)
      -    folder_ids = create_folder_ids_element(tag='m:FolderIds', folders=folders, version=self.account.version)
      -    movefolder.append(folder_ids)
      -    return movefolder
      + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) + payload.append(folder_ids_element(folders=folders, version=self.account.version)) + return payload diff --git a/docs/exchangelib/services/move_item.html b/docs/exchangelib/services/move_item.html index d9e86060..8fc63570 100644 --- a/docs/exchangelib/services/move_item.html +++ b/docs/exchangelib/services/move_item.html @@ -26,39 +26,34 @@

      Module exchangelib.services.move_item

      Expand source code -
      from .common import EWSAccountService, create_item_ids_element
      -from ..util import create_element, set_xml_value, MNS
      +
      from .common import EWSAccountService, item_ids_element, folder_ids_element
      +from ..errors import InvalidTypeError
      +from ..folders import BaseFolder
      +from ..items import Item
      +from ..properties import FolderId
      +from ..util import create_element, MNS
       
       
       class MoveItem(EWSAccountService):
           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/moveitem-operation"""
       
           SERVICE_NAME = 'MoveItem'
      -    element_container_name = '{%s}Items' % MNS
      +    element_container_name = f'{{{MNS}}}Items'
       
           def call(self, items, to_folder):
      -        from ..folders import BaseFolder, FolderId
               if not isinstance(to_folder, (BaseFolder, FolderId)):
      -            raise ValueError("'to_folder' %r must be a Folder or FolderId instance" % to_folder)
      +            raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId))
               return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, to_folder=to_folder))
       
      -    def _elems_to_objs(self, elems):
      -        from ..items import Item
      -        for elem in elems:
      -            if isinstance(elem, (Exception, type(None))):
      -                yield elem
      -                continue
      -            yield Item.id_from_xml(elem)
      +    def _elem_to_obj(self, elem):
      +        return Item.id_from_xml(elem)
       
           def get_payload(self, items, to_folder):
               # Takes a list of items and returns their new item IDs
      -        moveitem = create_element('m:%s' % self.SERVICE_NAME)
      -        tofolderid = create_element('m:ToFolderId')
      -        set_xml_value(tofolderid, to_folder, version=self.account.version)
      -        moveitem.append(tofolderid)
      -        item_ids = create_item_ids_element(items=items, version=self.account.version)
      -        moveitem.append(item_ids)
      -        return moveitem
      + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload
    @@ -84,31 +79,22 @@

    Classes

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/moveitem-operation""" SERVICE_NAME = 'MoveItem' - element_container_name = '{%s}Items' % MNS + element_container_name = f'{{{MNS}}}Items' def call(self, items, to_folder): - from ..folders import BaseFolder, FolderId if not isinstance(to_folder, (BaseFolder, FolderId)): - raise ValueError("'to_folder' %r must be a Folder or FolderId instance" % to_folder) + raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId)) return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, to_folder=to_folder)) - def _elems_to_objs(self, elems): - from ..items import Item - for elem in elems: - if isinstance(elem, (Exception, type(None))): - yield elem - continue - yield Item.id_from_xml(elem) + def _elem_to_obj(self, elem): + return Item.id_from_xml(elem) def get_payload(self, items, to_folder): # Takes a list of items and returns their new item IDs - moveitem = create_element('m:%s' % self.SERVICE_NAME) - tofolderid = create_element('m:ToFolderId') - set_xml_value(tofolderid, to_folder, version=self.account.version) - moveitem.append(tofolderid) - item_ids = create_item_ids_element(items=items, version=self.account.version) - moveitem.append(item_ids) - return moveitem + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload

    Ancestors

      @@ -142,9 +128,8 @@

      Methods

      Expand source code
      def call(self, items, to_folder):
      -    from ..folders import BaseFolder, FolderId
           if not isinstance(to_folder, (BaseFolder, FolderId)):
      -        raise ValueError("'to_folder' %r must be a Folder or FolderId instance" % to_folder)
      +        raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId))
           return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, to_folder=to_folder))
      @@ -159,13 +144,10 @@

      Methods

      def get_payload(self, items, to_folder):
           # Takes a list of items and returns their new item IDs
      -    moveitem = create_element('m:%s' % self.SERVICE_NAME)
      -    tofolderid = create_element('m:ToFolderId')
      -    set_xml_value(tofolderid, to_folder, version=self.account.version)
      -    moveitem.append(tofolderid)
      -    item_ids = create_item_ids_element(items=items, version=self.account.version)
      -    moveitem.append(item_ids)
      -    return moveitem
      + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) + payload.append(item_ids_element(items=items, version=self.account.version)) + return payload diff --git a/docs/exchangelib/services/resolve_names.html b/docs/exchangelib/services/resolve_names.html index 10377337..b3ebbfab 100644 --- a/docs/exchangelib/services/resolve_names.html +++ b/docs/exchangelib/services/resolve_names.html @@ -28,10 +28,11 @@

      Module exchangelib.services.resolve_names

      import logging
       
      -from .common import EWSService
      -from ..errors import ErrorNameResolutionNoResults, ErrorNameResolutionMultipleResults
      +from .common import EWSService, folder_ids_element
      +from ..errors import ErrorNameResolutionNoResults, ErrorNameResolutionMultipleResults, InvalidEnumValue
      +from ..items import SHAPE_CHOICES, SEARCH_SCOPE_CHOICES, Contact
       from ..properties import Mailbox
      -from ..util import create_element, set_xml_value, add_xml_child, MNS
      +from ..util import create_element, add_xml_child, MNS
       from ..version import EXCHANGE_2010_SP2
       
       log = logging.getLogger(__name__)
      @@ -41,7 +42,7 @@ 

      Module exchangelib.services.resolve_names

      """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/resolvenames-operation""" SERVICE_NAME = 'ResolveNames' - element_container_name = '{%s}ResolutionSet' % MNS + element_container_name = f'{{{MNS}}}ResolutionSet' ERRORS_TO_CATCH_IN_RESPONSE = ErrorNameResolutionNoResults WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults # Note: paging information is returned as attrs on the 'ResolutionSet' element, but this service does not @@ -55,16 +56,15 @@

      Module exchangelib.services.resolve_names

      def call(self, unresolved_entries, parent_folders=None, return_full_contact_data=False, search_scope=None, contact_data_shape=None): - from ..items import SHAPE_CHOICES, SEARCH_SCOPE_CHOICES if self.chunk_size > 100: - log.warning( - 'Chunk size %s is dangerously high. %s supports returning at most 100 candidates for a lookup', - self.chunk_size, self.SERVICE_NAME + raise ValueError( + f'Chunk size {self.chunk_size} is too high. {self.SERVICE_NAME} supports returning at most 100 ' + f'candidates for a lookup', ) if search_scope and search_scope not in SEARCH_SCOPE_CHOICES: - raise ValueError("'search_scope' %s must be one if %s" % (search_scope, SEARCH_SCOPE_CHOICES)) + raise InvalidEnumValue('search_scope', search_scope, SEARCH_SCOPE_CHOICES) if contact_data_shape and contact_data_shape not in SHAPE_CHOICES: - raise ValueError("'shape' %s must be one if %s" % (contact_data_shape, SHAPE_CHOICES)) + raise InvalidEnumValue('contact_data_shape', contact_data_shape, SHAPE_CHOICES) self.return_full_contact_data = return_full_contact_data return self._elems_to_objs(self._chunked_get_elements( self.get_payload, @@ -75,42 +75,33 @@

      Module exchangelib.services.resolve_names

      contact_data_shape=contact_data_shape, )) - def _elems_to_objs(self, elems): - from ..items import Contact - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - if self.return_full_contact_data: - mailbox_elem = elem.find(Mailbox.response_tag()) - contact_elem = elem.find(Contact.response_tag()) - yield ( - None if mailbox_elem is None else Mailbox.from_xml(elem=mailbox_elem, account=None), - None if contact_elem is None else Contact.from_xml(elem=contact_elem, account=None), - ) - else: - yield Mailbox.from_xml(elem=elem.find(Mailbox.response_tag()), account=None) + def _elem_to_obj(self, elem): + if self.return_full_contact_data: + mailbox_elem = elem.find(Mailbox.response_tag()) + contact_elem = elem.find(Contact.response_tag()) + return ( + None if mailbox_elem is None else Mailbox.from_xml(elem=mailbox_elem, account=None), + None if contact_elem is None else Contact.from_xml(elem=contact_elem, account=None), + ) + return Mailbox.from_xml(elem=elem.find(Mailbox.response_tag()), account=None) def get_payload(self, unresolved_entries, parent_folders, return_full_contact_data, search_scope, contact_data_shape): - payload = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=dict(ReturnFullContactData='true' if return_full_contact_data else 'false'), - ) + attrs = dict(ReturnFullContactData=return_full_contact_data) if search_scope: - payload.set('SearchScope', search_scope) + attrs['SearchScope'] = search_scope if contact_data_shape: if self.protocol.version.build < EXCHANGE_2010_SP2: raise NotImplementedError( "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later") - payload.set('ContactDataShape', contact_data_shape) + attrs['ContactDataShape'] = contact_data_shape + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) if parent_folders: - parentfolderids = create_element('m:ParentFolderIds') - set_xml_value(parentfolderids, parent_folders, version=self.protocol.version) + payload.append(folder_ids_element( + folders=parent_folders, version=self.protocol.version, tag='m:ParentFolderIds' + )) for entry in unresolved_entries: add_xml_child(payload, 'm:UnresolvedEntry', entry) - if not len(payload): - raise ValueError('"unresolved_entries" must not be empty') return payload
    @@ -137,7 +128,7 @@

    Classes

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/resolvenames-operation""" SERVICE_NAME = 'ResolveNames' - element_container_name = '{%s}ResolutionSet' % MNS + element_container_name = f'{{{MNS}}}ResolutionSet' ERRORS_TO_CATCH_IN_RESPONSE = ErrorNameResolutionNoResults WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults # Note: paging information is returned as attrs on the 'ResolutionSet' element, but this service does not @@ -151,16 +142,15 @@

    Classes

    def call(self, unresolved_entries, parent_folders=None, return_full_contact_data=False, search_scope=None, contact_data_shape=None): - from ..items import SHAPE_CHOICES, SEARCH_SCOPE_CHOICES if self.chunk_size > 100: - log.warning( - 'Chunk size %s is dangerously high. %s supports returning at most 100 candidates for a lookup', - self.chunk_size, self.SERVICE_NAME + raise ValueError( + f'Chunk size {self.chunk_size} is too high. {self.SERVICE_NAME} supports returning at most 100 ' + f'candidates for a lookup', ) if search_scope and search_scope not in SEARCH_SCOPE_CHOICES: - raise ValueError("'search_scope' %s must be one if %s" % (search_scope, SEARCH_SCOPE_CHOICES)) + raise InvalidEnumValue('search_scope', search_scope, SEARCH_SCOPE_CHOICES) if contact_data_shape and contact_data_shape not in SHAPE_CHOICES: - raise ValueError("'shape' %s must be one if %s" % (contact_data_shape, SHAPE_CHOICES)) + raise InvalidEnumValue('contact_data_shape', contact_data_shape, SHAPE_CHOICES) self.return_full_contact_data = return_full_contact_data return self._elems_to_objs(self._chunked_get_elements( self.get_payload, @@ -171,42 +161,33 @@

    Classes

    contact_data_shape=contact_data_shape, )) - def _elems_to_objs(self, elems): - from ..items import Contact - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - if self.return_full_contact_data: - mailbox_elem = elem.find(Mailbox.response_tag()) - contact_elem = elem.find(Contact.response_tag()) - yield ( - None if mailbox_elem is None else Mailbox.from_xml(elem=mailbox_elem, account=None), - None if contact_elem is None else Contact.from_xml(elem=contact_elem, account=None), - ) - else: - yield Mailbox.from_xml(elem=elem.find(Mailbox.response_tag()), account=None) + def _elem_to_obj(self, elem): + if self.return_full_contact_data: + mailbox_elem = elem.find(Mailbox.response_tag()) + contact_elem = elem.find(Contact.response_tag()) + return ( + None if mailbox_elem is None else Mailbox.from_xml(elem=mailbox_elem, account=None), + None if contact_elem is None else Contact.from_xml(elem=contact_elem, account=None), + ) + return Mailbox.from_xml(elem=elem.find(Mailbox.response_tag()), account=None) def get_payload(self, unresolved_entries, parent_folders, return_full_contact_data, search_scope, contact_data_shape): - payload = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=dict(ReturnFullContactData='true' if return_full_contact_data else 'false'), - ) + attrs = dict(ReturnFullContactData=return_full_contact_data) if search_scope: - payload.set('SearchScope', search_scope) + attrs['SearchScope'] = search_scope if contact_data_shape: if self.protocol.version.build < EXCHANGE_2010_SP2: raise NotImplementedError( "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later") - payload.set('ContactDataShape', contact_data_shape) + attrs['ContactDataShape'] = contact_data_shape + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) if parent_folders: - parentfolderids = create_element('m:ParentFolderIds') - set_xml_value(parentfolderids, parent_folders, version=self.protocol.version) + payload.append(folder_ids_element( + folders=parent_folders, version=self.protocol.version, tag='m:ParentFolderIds' + )) for entry in unresolved_entries: add_xml_child(payload, 'm:UnresolvedEntry', entry) - if not len(payload): - raise ValueError('"unresolved_entries" must not be empty') return payload

    Ancestors

    @@ -249,16 +230,15 @@

    Methods

    def call(self, unresolved_entries, parent_folders=None, return_full_contact_data=False, search_scope=None,
              contact_data_shape=None):
    -    from ..items import SHAPE_CHOICES, SEARCH_SCOPE_CHOICES
         if self.chunk_size > 100:
    -        log.warning(
    -            'Chunk size %s is dangerously high. %s supports returning at most 100 candidates for a lookup',
    -            self.chunk_size, self.SERVICE_NAME
    +        raise ValueError(
    +            f'Chunk size {self.chunk_size} is too high. {self.SERVICE_NAME} supports returning at most 100 '
    +            f'candidates for a lookup',
             )
         if search_scope and search_scope not in SEARCH_SCOPE_CHOICES:
    -        raise ValueError("'search_scope' %s must be one if %s" % (search_scope, SEARCH_SCOPE_CHOICES))
    +        raise InvalidEnumValue('search_scope', search_scope, SEARCH_SCOPE_CHOICES)
         if contact_data_shape and contact_data_shape not in SHAPE_CHOICES:
    -        raise ValueError("'shape' %s must be one if %s" % (contact_data_shape, SHAPE_CHOICES))
    +        raise InvalidEnumValue('contact_data_shape', contact_data_shape, SHAPE_CHOICES)
         self.return_full_contact_data = return_full_contact_data
         return self._elems_to_objs(self._chunked_get_elements(
             self.get_payload,
    @@ -281,24 +261,21 @@ 

    Methods

    def get_payload(self, unresolved_entries, parent_folders, return_full_contact_data, search_scope,
                     contact_data_shape):
    -    payload = create_element(
    -        'm:%s' % self.SERVICE_NAME,
    -        attrs=dict(ReturnFullContactData='true' if return_full_contact_data else 'false'),
    -    )
    +    attrs = dict(ReturnFullContactData=return_full_contact_data)
         if search_scope:
    -        payload.set('SearchScope', search_scope)
    +        attrs['SearchScope'] = search_scope
         if contact_data_shape:
             if self.protocol.version.build < EXCHANGE_2010_SP2:
                 raise NotImplementedError(
                     "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later")
    -        payload.set('ContactDataShape', contact_data_shape)
    +        attrs['ContactDataShape'] = contact_data_shape
    +    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs)
         if parent_folders:
    -        parentfolderids = create_element('m:ParentFolderIds')
    -        set_xml_value(parentfolderids, parent_folders, version=self.protocol.version)
    +        payload.append(folder_ids_element(
    +            folders=parent_folders, version=self.protocol.version, tag='m:ParentFolderIds'
    +        ))
         for entry in unresolved_entries:
             add_xml_child(payload, 'm:UnresolvedEntry', entry)
    -    if not len(payload):
    -        raise ValueError('"unresolved_entries" must not be empty')
         return payload
    diff --git a/docs/exchangelib/services/send_item.html b/docs/exchangelib/services/send_item.html index a9298a73..590243e4 100644 --- a/docs/exchangelib/services/send_item.html +++ b/docs/exchangelib/services/send_item.html @@ -26,8 +26,11 @@

    Module exchangelib.services.send_item

    Expand source code -
    from .common import EWSAccountService, create_item_ids_element
    -from ..util import create_element, set_xml_value
    +
    from .common import EWSAccountService, item_ids_element, folder_ids_element
    +from ..errors import InvalidTypeError
    +from ..folders import BaseFolder
    +from ..properties import FolderId
    +from ..util import create_element
     
     
     class SendItem(EWSAccountService):
    @@ -37,23 +40,18 @@ 

    Module exchangelib.services.send_item

    returns_elements = False def call(self, items, saved_item_folder): - from ..folders import BaseFolder, FolderId if saved_item_folder and not isinstance(saved_item_folder, (BaseFolder, FolderId)): - raise ValueError("'saved_item_folder' %r must be a Folder or FolderId instance" % saved_item_folder) + raise InvalidTypeError('saved_item_folder', saved_item_folder, (BaseFolder, FolderId)) return self._chunked_get_elements(self.get_payload, items=items, saved_item_folder=saved_item_folder) def get_payload(self, items, saved_item_folder): - senditem = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=dict(SaveItemToFolder='true' if saved_item_folder else 'false'), - ) - item_ids = create_item_ids_element(items=items, version=self.account.version) - senditem.append(item_ids) + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(SaveItemToFolder=bool(saved_item_folder))) + payload.append(item_ids_element(items=items, version=self.account.version)) if saved_item_folder: - saveditemfolderid = create_element('m:SavedItemFolderId') - set_xml_value(saveditemfolderid, saved_item_folder, version=self.account.version) - senditem.append(saveditemfolderid) - return senditem
    + payload.append(folder_ids_element( + folders=[saved_item_folder], version=self.account.version, tag='m:SavedItemFolderId' + )) + return payload
    @@ -82,23 +80,18 @@

    Classes

    returns_elements = False def call(self, items, saved_item_folder): - from ..folders import BaseFolder, FolderId if saved_item_folder and not isinstance(saved_item_folder, (BaseFolder, FolderId)): - raise ValueError("'saved_item_folder' %r must be a Folder or FolderId instance" % saved_item_folder) + raise InvalidTypeError('saved_item_folder', saved_item_folder, (BaseFolder, FolderId)) return self._chunked_get_elements(self.get_payload, items=items, saved_item_folder=saved_item_folder) def get_payload(self, items, saved_item_folder): - senditem = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=dict(SaveItemToFolder='true' if saved_item_folder else 'false'), - ) - item_ids = create_item_ids_element(items=items, version=self.account.version) - senditem.append(item_ids) + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(SaveItemToFolder=bool(saved_item_folder))) + payload.append(item_ids_element(items=items, version=self.account.version)) if saved_item_folder: - saveditemfolderid = create_element('m:SavedItemFolderId') - set_xml_value(saveditemfolderid, saved_item_folder, version=self.account.version) - senditem.append(saveditemfolderid) - return senditem
    + payload.append(folder_ids_element( + folders=[saved_item_folder], version=self.account.version, tag='m:SavedItemFolderId' + )) + return payload

    Ancestors

      @@ -128,9 +121,8 @@

      Methods

      Expand source code
      def call(self, items, saved_item_folder):
      -    from ..folders import BaseFolder, FolderId
           if saved_item_folder and not isinstance(saved_item_folder, (BaseFolder, FolderId)):
      -        raise ValueError("'saved_item_folder' %r must be a Folder or FolderId instance" % saved_item_folder)
      +        raise InvalidTypeError('saved_item_folder', saved_item_folder, (BaseFolder, FolderId))
           return self._chunked_get_elements(self.get_payload, items=items, saved_item_folder=saved_item_folder)
      @@ -144,17 +136,13 @@

      Methods

      Expand source code
      def get_payload(self, items, saved_item_folder):
      -    senditem = create_element(
      -        'm:%s' % self.SERVICE_NAME,
      -        attrs=dict(SaveItemToFolder='true' if saved_item_folder else 'false'),
      -    )
      -    item_ids = create_item_ids_element(items=items, version=self.account.version)
      -    senditem.append(item_ids)
      +    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(SaveItemToFolder=bool(saved_item_folder)))
      +    payload.append(item_ids_element(items=items, version=self.account.version))
           if saved_item_folder:
      -        saveditemfolderid = create_element('m:SavedItemFolderId')
      -        set_xml_value(saveditemfolderid, saved_item_folder, version=self.account.version)
      -        senditem.append(saveditemfolderid)
      -    return senditem
      + payload.append(folder_ids_element( + folders=[saved_item_folder], version=self.account.version, tag='m:SavedItemFolderId' + )) + return payload diff --git a/docs/exchangelib/services/send_notification.html b/docs/exchangelib/services/send_notification.html index 9a1ff449..665754f1 100644 --- a/docs/exchangelib/services/send_notification.html +++ b/docs/exchangelib/services/send_notification.html @@ -26,37 +26,50 @@

      Module exchangelib.services.send_notification

      Expand source code -
      from .common import EWSService
      +
      from .common import EWSService, add_xml_child
      +from ..errors import InvalidEnumValue
       from ..properties import Notification
      -from ..util import MNS
      +from ..transport import wrap
      +from ..util import create_element, MNS
       
       
       class SendNotification(EWSService):
           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/sendnotification
       
      -    This is not an actual EWS service you can call. We only use it to parse the XML body of push notifications.
      +    This service is implemented backwards compared to other services. We use it to parse the XML body of push
      +    notifications we receive on the callback URL defined in a push subscription, and to create responses to these
      +    push notifications.
           """
       
           SERVICE_NAME = 'SendNotification'
      +    OK = 'OK'
      +    UNSUBSCRIBE = 'Unsubscribe'
      +    STATUS_CHOICES = (OK, UNSUBSCRIBE)
       
      -    def call(self):
      -        raise NotImplementedError()
      +    def ok_payload(self):
      +        return wrap(content=self.get_payload(status=self.OK))
       
      -    def _elems_to_objs(self, elems):
      -        for elem in elems:
      -            if isinstance(elem, Exception):
      -                yield elem
      -                continue
      -            yield Notification.from_xml(elem=elem, account=None)
      +    def unsubscribe_payload(self):
      +        return wrap(content=self.get_payload(status=self.UNSUBSCRIBE))
      +
      +    def _elem_to_obj(self, elem):
      +        return Notification.from_xml(elem=elem, account=None)
       
           @classmethod
           def _response_tag(cls):
               """Return the name of the element containing the service response."""
      -        return '{%s}%s' % (MNS, cls.SERVICE_NAME)
      +        return f'{{{MNS}}}{cls.SERVICE_NAME}'
       
           @classmethod
           def _get_elements_in_container(cls, container):
      -        return container.findall(Notification.response_tag())
      + return container.findall(Notification.response_tag()) + + def get_payload(self, status): + if status not in self.STATUS_CHOICES: + raise InvalidEnumValue('status', status, self.STATUS_CHOICES) + payload = create_element(f'm:{self.SERVICE_NAME}Result') + add_xml_child(payload, 'm:SubscriptionStatus', status) + return payload
      @@ -74,7 +87,9 @@

      Classes

      MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/sendnotification

      -

      This is not an actual EWS service you can call. We only use it to parse the XML body of push notifications.

      +

      This service is implemented backwards compared to other services. We use it to parse the XML body of push +notifications we receive on the callback URL defined in a push subscription, and to create responses to these +push notifications.

      Expand source code @@ -82,29 +97,40 @@

      Classes

      class SendNotification(EWSService):
           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/sendnotification
       
      -    This is not an actual EWS service you can call. We only use it to parse the XML body of push notifications.
      +    This service is implemented backwards compared to other services. We use it to parse the XML body of push
      +    notifications we receive on the callback URL defined in a push subscription, and to create responses to these
      +    push notifications.
           """
       
           SERVICE_NAME = 'SendNotification'
      +    OK = 'OK'
      +    UNSUBSCRIBE = 'Unsubscribe'
      +    STATUS_CHOICES = (OK, UNSUBSCRIBE)
       
      -    def call(self):
      -        raise NotImplementedError()
      +    def ok_payload(self):
      +        return wrap(content=self.get_payload(status=self.OK))
       
      -    def _elems_to_objs(self, elems):
      -        for elem in elems:
      -            if isinstance(elem, Exception):
      -                yield elem
      -                continue
      -            yield Notification.from_xml(elem=elem, account=None)
      +    def unsubscribe_payload(self):
      +        return wrap(content=self.get_payload(status=self.UNSUBSCRIBE))
      +
      +    def _elem_to_obj(self, elem):
      +        return Notification.from_xml(elem=elem, account=None)
       
           @classmethod
           def _response_tag(cls):
               """Return the name of the element containing the service response."""
      -        return '{%s}%s' % (MNS, cls.SERVICE_NAME)
      +        return f'{{{MNS}}}{cls.SERVICE_NAME}'
       
           @classmethod
           def _get_elements_in_container(cls, container):
      -        return container.findall(Notification.response_tag())
      + return container.findall(Notification.response_tag()) + + def get_payload(self, status): + if status not in self.STATUS_CHOICES: + raise InvalidEnumValue('status', status, self.STATUS_CHOICES) + payload = create_element(f'm:{self.SERVICE_NAME}Result') + add_xml_child(payload, 'm:SubscriptionStatus', status) + return payload

      Ancestors

        @@ -112,15 +138,57 @@

        Ancestors

      Class variables

      +
      var OK
      +
      +
      +
      var SERVICE_NAME
      +
      var STATUS_CHOICES
      +
      +
      +
      +
      var UNSUBSCRIBE
      +
      +
      +

      Methods

      -
      -def call(self) +
      +def get_payload(self, status) +
      +
      +
      +
      + +Expand source code + +
      def get_payload(self, status):
      +    if status not in self.STATUS_CHOICES:
      +        raise InvalidEnumValue('status', status, self.STATUS_CHOICES)
      +    payload = create_element(f'm:{self.SERVICE_NAME}Result')
      +    add_xml_child(payload, 'm:SubscriptionStatus', status)
      +    return payload
      +
      +
      +
      +def ok_payload(self) +
      +
      +
      +
      + +Expand source code + +
      def ok_payload(self):
      +    return wrap(content=self.get_payload(status=self.OK))
      +
      +
      +
      +def unsubscribe_payload(self)
      @@ -128,8 +196,8 @@

      Methods

      Expand source code -
      def call(self):
      -    raise NotImplementedError()
      +
      def unsubscribe_payload(self):
      +    return wrap(content=self.get_payload(status=self.UNSUBSCRIBE))
      @@ -163,9 +231,14 @@

      Index

      • SendNotification

        - diff --git a/docs/exchangelib/services/set_user_oof_settings.html b/docs/exchangelib/services/set_user_oof_settings.html index 5c4df8fe..67d430b3 100644 --- a/docs/exchangelib/services/set_user_oof_settings.html +++ b/docs/exchangelib/services/set_user_oof_settings.html @@ -27,6 +27,7 @@

        Module exchangelib.services.set_user_oof_settings Expand source code
        from .common import EWSAccountService
        +from ..errors import InvalidTypeError
         from ..properties import AvailabilityMailbox, Mailbox
         from ..settings import OofSettings
         from ..util import create_element, set_xml_value, MNS
        @@ -42,16 +43,15 @@ 

        Module exchangelib.services.set_user_oof_settings def call(self, oof_settings, mailbox): if not isinstance(oof_settings, OofSettings): - raise ValueError("'oof_settings' %r must be an OofSettings instance" % oof_settings) + raise InvalidTypeError('oof_settings', oof_settings, OofSettings) if not isinstance(mailbox, Mailbox): - raise ValueError("'mailbox' %r must be an Mailbox instance" % mailbox) + raise InvalidTypeError('mailbox', mailbox, Mailbox) return self._get_elements(payload=self.get_payload(oof_settings=oof_settings, mailbox=mailbox)) def get_payload(self, oof_settings, mailbox): - payload = create_element('m:%sRequest' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}Request') set_xml_value(payload, AvailabilityMailbox.from_mailbox(mailbox), version=self.account.version) - set_xml_value(payload, oof_settings, version=self.account.version) - return payload + return set_xml_value(payload, oof_settings, version=self.account.version) def _get_element_container(self, message, name=None): message = message.find(self._response_message_tag()) @@ -59,7 +59,7 @@

        Module exchangelib.services.set_user_oof_settings @classmethod def _response_message_tag(cls): - return '{%s}ResponseMessage' % MNS

        + return f'{{{MNS}}}ResponseMessage'

      @@ -92,16 +92,15 @@

      Classes

      def call(self, oof_settings, mailbox): if not isinstance(oof_settings, OofSettings): - raise ValueError("'oof_settings' %r must be an OofSettings instance" % oof_settings) + raise InvalidTypeError('oof_settings', oof_settings, OofSettings) if not isinstance(mailbox, Mailbox): - raise ValueError("'mailbox' %r must be an Mailbox instance" % mailbox) + raise InvalidTypeError('mailbox', mailbox, Mailbox) return self._get_elements(payload=self.get_payload(oof_settings=oof_settings, mailbox=mailbox)) def get_payload(self, oof_settings, mailbox): - payload = create_element('m:%sRequest' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}Request') set_xml_value(payload, AvailabilityMailbox.from_mailbox(mailbox), version=self.account.version) - set_xml_value(payload, oof_settings, version=self.account.version) - return payload + return set_xml_value(payload, oof_settings, version=self.account.version) def _get_element_container(self, message, name=None): message = message.find(self._response_message_tag()) @@ -109,7 +108,7 @@

      Classes

      @classmethod def _response_message_tag(cls): - return '{%s}ResponseMessage' % MNS + return f'{{{MNS}}}ResponseMessage'

      Ancestors

        @@ -140,9 +139,9 @@

        Methods

        def call(self, oof_settings, mailbox):
             if not isinstance(oof_settings, OofSettings):
        -        raise ValueError("'oof_settings' %r must be an OofSettings instance" % oof_settings)
        +        raise InvalidTypeError('oof_settings', oof_settings, OofSettings)
             if not isinstance(mailbox, Mailbox):
        -        raise ValueError("'mailbox' %r must be an Mailbox instance" % mailbox)
        +        raise InvalidTypeError('mailbox', mailbox, Mailbox)
             return self._get_elements(payload=self.get_payload(oof_settings=oof_settings, mailbox=mailbox))
        @@ -156,10 +155,9 @@

        Methods

        Expand source code
        def get_payload(self, oof_settings, mailbox):
        -    payload = create_element('m:%sRequest' % self.SERVICE_NAME)
        +    payload = create_element(f'm:{self.SERVICE_NAME}Request')
             set_xml_value(payload, AvailabilityMailbox.from_mailbox(mailbox), version=self.account.version)
        -    set_xml_value(payload, oof_settings, version=self.account.version)
        -    return payload
        + return set_xml_value(payload, oof_settings, version=self.account.version) diff --git a/docs/exchangelib/services/subscribe.html b/docs/exchangelib/services/subscribe.html index 1acf1fc5..d896db2f 100644 --- a/docs/exchangelib/services/subscribe.html +++ b/docs/exchangelib/services/subscribe.html @@ -34,7 +34,7 @@

        Module exchangelib.services.subscribe

        """ import abc -from .common import EWSAccountService, create_folder_ids_element, add_xml_child +from .common import EWSAccountService, folder_ids_element, add_xml_child from ..util import create_element, MNS @@ -55,32 +55,28 @@

        Module exchangelib.services.subscribe

        def _partial_call(self, payload_func, folders, event_types, **kwargs): if set(event_types) - set(self.EVENT_TYPES): - raise ValueError("'event_types' values must consist of values in %s" % str(self.EVENT_TYPES)) + raise ValueError(f"'event_types' values must consist of values in {self.EVENT_TYPES}") return self._elems_to_objs(self._get_elements( payload=payload_func(folders=folders, event_types=event_types, **kwargs) )) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - subscription_elem, watermark_elem = elem - yield subscription_elem.text, watermark_elem.text + def _elem_to_obj(self, elem): + subscription_elem, watermark_elem = elem + return subscription_elem.text, watermark_elem.text @classmethod def _get_elements_in_container(cls, container): - return [(container.find('{%s}SubscriptionId' % MNS), container.find('{%s}Watermark' % MNS))] + return [(container.find(f'{{{MNS}}}SubscriptionId'), container.find(f'{{{MNS}}}Watermark'))] def _partial_payload(self, folders, event_types): request_elem = create_element(self.subscription_request_elem_tag) - folder_ids = create_folder_ids_element(tag='t:FolderIds', folders=folders, version=self.account.version) + folder_ids = folder_ids_element(folders=folders, version=self.account.version, tag='t:FolderIds') request_elem.append(folder_ids) event_types_elem = create_element('t:EventTypes') for event_type in event_types: add_xml_child(event_types_elem, 't:EventType', event_type) if not len(event_types_elem): - raise ValueError('"event_types" must not be empty') + raise ValueError("'event_types' must not be empty") request_elem.append(event_types_elem) return request_elem @@ -96,13 +92,13 @@

        Module exchangelib.services.subscribe

        ) def get_payload(self, folders, event_types, watermark, timeout): - subscribe = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') request_elem = self._partial_payload(folders=folders, event_types=event_types) if watermark: add_xml_child(request_elem, 'm:Watermark', watermark) add_xml_child(request_elem, 't:Timeout', timeout) # In minutes - subscribe.append(request_elem) - return subscribe + payload.append(request_elem) + return payload class SubscribeToPush(Subscribe): @@ -115,14 +111,14 @@

        Module exchangelib.services.subscribe

        ) def get_payload(self, folders, event_types, watermark, status_frequency, url): - subscribe = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') request_elem = self._partial_payload(folders=folders, event_types=event_types) if watermark: add_xml_child(request_elem, 'm:Watermark', watermark) add_xml_child(request_elem, 't:StatusFrequency', status_frequency) # In minutes add_xml_child(request_elem, 't:URL', url) - subscribe.append(request_elem) - return subscribe + payload.append(request_elem) + return payload class SubscribeToStreaming(Subscribe): @@ -132,22 +128,17 @@

        Module exchangelib.services.subscribe

        def call(self, folders, event_types): yield from self._partial_call(payload_func=self.get_payload, folders=folders, event_types=event_types) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield elem.text + def _elem_to_obj(self, elem): + return elem.text @classmethod def _get_elements_in_container(cls, container): - return [container.find('{%s}SubscriptionId' % MNS)] + return [container.find(f'{{{MNS}}}SubscriptionId')] def get_payload(self, folders, event_types): - subscribe = create_element('m:%s' % self.SERVICE_NAME) - request_elem = self._partial_payload(folders=folders, event_types=event_types) - subscribe.append(request_elem) - return subscribe + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(self._partial_payload(folders=folders, event_types=event_types)) + return payload
      @@ -186,32 +177,28 @@

      Classes

      def _partial_call(self, payload_func, folders, event_types, **kwargs): if set(event_types) - set(self.EVENT_TYPES): - raise ValueError("'event_types' values must consist of values in %s" % str(self.EVENT_TYPES)) + raise ValueError(f"'event_types' values must consist of values in {self.EVENT_TYPES}") return self._elems_to_objs(self._get_elements( payload=payload_func(folders=folders, event_types=event_types, **kwargs) )) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - subscription_elem, watermark_elem = elem - yield subscription_elem.text, watermark_elem.text + def _elem_to_obj(self, elem): + subscription_elem, watermark_elem = elem + return subscription_elem.text, watermark_elem.text @classmethod def _get_elements_in_container(cls, container): - return [(container.find('{%s}SubscriptionId' % MNS), container.find('{%s}Watermark' % MNS))] + return [(container.find(f'{{{MNS}}}SubscriptionId'), container.find(f'{{{MNS}}}Watermark'))] def _partial_payload(self, folders, event_types): request_elem = create_element(self.subscription_request_elem_tag) - folder_ids = create_folder_ids_element(tag='t:FolderIds', folders=folders, version=self.account.version) + folder_ids = folder_ids_element(folders=folders, version=self.account.version, tag='t:FolderIds') request_elem.append(folder_ids) event_types_elem = create_element('t:EventTypes') for event_type in event_types: add_xml_child(event_types_elem, 't:EventType', event_type) if not len(event_types_elem): - raise ValueError('"event_types" must not be empty') + raise ValueError("'event_types' must not be empty") request_elem.append(event_types_elem) return request_elem @@ -274,13 +261,13 @@

      Inherited members

      ) def get_payload(self, folders, event_types, watermark, timeout): - subscribe = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') request_elem = self._partial_payload(folders=folders, event_types=event_types) if watermark: add_xml_child(request_elem, 'm:Watermark', watermark) add_xml_child(request_elem, 't:Timeout', timeout) # In minutes - subscribe.append(request_elem) - return subscribe + payload.append(request_elem) + return payload

      Ancestors

        @@ -327,13 +314,13 @@

        Methods

        Expand source code
        def get_payload(self, folders, event_types, watermark, timeout):
        -    subscribe = create_element('m:%s' % self.SERVICE_NAME)
        +    payload = create_element(f'm:{self.SERVICE_NAME}')
             request_elem = self._partial_payload(folders=folders, event_types=event_types)
             if watermark:
                 add_xml_child(request_elem, 'm:Watermark', watermark)
             add_xml_child(request_elem, 't:Timeout', timeout)  # In minutes
        -    subscribe.append(request_elem)
        -    return subscribe
        + payload.append(request_elem) + return payload @@ -369,14 +356,14 @@

        Inherited members

        ) def get_payload(self, folders, event_types, watermark, status_frequency, url): - subscribe = create_element('m:%s' % self.SERVICE_NAME) + payload = create_element(f'm:{self.SERVICE_NAME}') request_elem = self._partial_payload(folders=folders, event_types=event_types) if watermark: add_xml_child(request_elem, 'm:Watermark', watermark) add_xml_child(request_elem, 't:StatusFrequency', status_frequency) # In minutes add_xml_child(request_elem, 't:URL', url) - subscribe.append(request_elem) - return subscribe + payload.append(request_elem) + return payload

        Ancestors

          @@ -419,14 +406,14 @@

          Methods

          Expand source code
          def get_payload(self, folders, event_types, watermark, status_frequency, url):
          -    subscribe = create_element('m:%s' % self.SERVICE_NAME)
          +    payload = create_element(f'm:{self.SERVICE_NAME}')
               request_elem = self._partial_payload(folders=folders, event_types=event_types)
               if watermark:
                   add_xml_child(request_elem, 'm:Watermark', watermark)
               add_xml_child(request_elem, 't:StatusFrequency', status_frequency)  # In minutes
               add_xml_child(request_elem, 't:URL', url)
          -    subscribe.append(request_elem)
          -    return subscribe
          + payload.append(request_elem) + return payload @@ -459,22 +446,17 @@

          Inherited members

          def call(self, folders, event_types): yield from self._partial_call(payload_func=self.get_payload, folders=folders, event_types=event_types) - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield elem.text + def _elem_to_obj(self, elem): + return elem.text @classmethod def _get_elements_in_container(cls, container): - return [container.find('{%s}SubscriptionId' % MNS)] + return [container.find(f'{{{MNS}}}SubscriptionId')] def get_payload(self, folders, event_types): - subscribe = create_element('m:%s' % self.SERVICE_NAME) - request_elem = self._partial_payload(folders=folders, event_types=event_types) - subscribe.append(request_elem) - return subscribe + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(self._partial_payload(folders=folders, event_types=event_types)) + return payload

          Ancestors

            @@ -518,10 +500,9 @@

            Methods

            Expand source code
            def get_payload(self, folders, event_types):
            -    subscribe = create_element('m:%s' % self.SERVICE_NAME)
            -    request_elem = self._partial_payload(folders=folders, event_types=event_types)
            -    subscribe.append(request_elem)
            -    return subscribe
            + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(self._partial_payload(folders=folders, event_types=event_types)) + return payload diff --git a/docs/exchangelib/services/sync_folder_hierarchy.html b/docs/exchangelib/services/sync_folder_hierarchy.html index ad503fa1..f1938238 100644 --- a/docs/exchangelib/services/sync_folder_hierarchy.html +++ b/docs/exchangelib/services/sync_folder_hierarchy.html @@ -29,17 +29,17 @@

            Module exchangelib.services.sync_folder_hierarchy
            import abc
             import logging
             
            -from .common import EWSAccountService, add_xml_child, create_folder_ids_element, create_shape_element, parse_folder_elem
            +from .common import EWSPagingService, add_xml_child, folder_ids_element, shape_element, parse_folder_elem
             from ..properties import FolderId
             from ..util import create_element, xml_text_to_value, MNS, TNS
             
             log = logging.getLogger(__name__)
             
             
            -class SyncFolder(EWSAccountService, metaclass=abc.ABCMeta):
            +class SyncFolder(EWSPagingService, metaclass=abc.ABCMeta):
                 """Base class for SyncFolderHierarchy and SyncFolderItems."""
             
            -    element_container_name = '{%s}Changes' % MNS
            +    element_container_name = f'{{{MNS}}}Changes'
                 # Change types
                 CREATE = 'create'
                 UPDATE = 'update'
            @@ -47,6 +47,11 @@ 

            Module exchangelib.services.sync_folder_hierarchy CHANGE_TYPES = (CREATE, UPDATE, DELETE) shape_tag = None last_in_range_name = None + change_types_map = { + f'{{{TNS}}}Create': CREATE, + f'{{{TNS}}}Update': UPDATE, + f'{{{TNS}}}Delete': DELETE, + } def __init__(self, *args, **kwargs): # These values are reset and set each time call() is consumed @@ -54,15 +59,8 @@

            Module exchangelib.services.sync_folder_hierarchy self.includes_last_item_in_range = None super().__init__(*args, **kwargs) - def _change_types_map(self): - return { - '{%s}Create' % TNS: self.CREATE, - '{%s}Update' % TNS: self.UPDATE, - '{%s}Delete' % TNS: self.DELETE, - } - def _get_element_container(self, message, name=None): - self.sync_state = message.find('{%s}SyncState' % MNS).text + self.sync_state = message.find(f'{{{MNS}}}SyncState').text log.debug('Sync state is: %s', self.sync_state) self.includes_last_item_in_range = xml_text_to_value( message.find(self.last_in_range_name).text, bool @@ -71,16 +69,14 @@

            Module exchangelib.services.sync_folder_hierarchy return super()._get_element_container(message=message, name=name) def _partial_get_payload(self, folder, shape, additional_fields, sync_state): - svc_elem = create_element('m:%s' % self.SERVICE_NAME) - foldershape = create_shape_element( + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(shape_element( tag=self.shape_tag, shape=shape, additional_fields=additional_fields, version=self.account.version - ) - svc_elem.append(foldershape) - folder_id = create_folder_ids_element(tag='m:SyncFolderId', folders=[folder], version=self.account.version) - svc_elem.append(folder_id) + )) + payload.append(folder_ids_element(folders=[folder], version=self.account.version, tag='m:SyncFolderId')) if sync_state: - add_xml_child(svc_elem, 'm:SyncState', sync_state) - return svc_elem + add_xml_child(payload, 'm:SyncState', sync_state) + return payload class SyncFolderHierarchy(SyncFolder): @@ -90,29 +86,32 @@

            Module exchangelib.services.sync_folder_hierarchy SERVICE_NAME = 'SyncFolderHierarchy' shape_tag = 'm:FolderShape' - last_in_range_name = '{%s}IncludesLastFolderInRange' % MNS + last_in_range_name = f'{{{MNS}}}IncludesLastFolderInRange' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.folder = None # A hack to communicate parsing args to _elems_to_objs() def call(self, folder, shape, additional_fields, sync_state): self.sync_state = sync_state - change_types = self._change_types_map() - for elem in self._get_elements(payload=self.get_payload( + self.folder = folder + return self._elems_to_objs(self._get_elements(payload=self.get_payload( folder=folder, shape=shape, additional_fields=additional_fields, sync_state=sync_state, - )): - if isinstance(elem, Exception): - yield elem - continue - change_type = change_types[elem.tag] - if change_type == self.DELETE: - folder = FolderId.from_xml(elem=elem.find(FolderId.response_tag()), account=self.account) - else: - # We can't find() the element because we don't know which tag to look for. The change element can - # contain multiple folder types, each with their own tag. - folder_elem = elem[0] - folder = parse_folder_elem(elem=folder_elem, folder=folder, account=self.account) - yield change_type, folder + ))) + + def _elem_to_obj(self, elem): + change_type = self.change_types_map[elem.tag] + if change_type == self.DELETE: + folder = FolderId.from_xml(elem=elem.find(FolderId.response_tag()), account=self.account) + else: + # We can't find() the element because we don't know which tag to look for. The change element can + # contain multiple folder types, each with their own tag. + folder_elem = elem[0] + folder = parse_folder_elem(elem=folder_elem, folder=self.folder, account=self.account) + return change_type, folder def get_payload(self, folder, shape, additional_fields, sync_state): return self._partial_get_payload( @@ -139,10 +138,10 @@

            Classes

            Expand source code -
            class SyncFolder(EWSAccountService, metaclass=abc.ABCMeta):
            +
            class SyncFolder(EWSPagingService, metaclass=abc.ABCMeta):
                 """Base class for SyncFolderHierarchy and SyncFolderItems."""
             
            -    element_container_name = '{%s}Changes' % MNS
            +    element_container_name = f'{{{MNS}}}Changes'
                 # Change types
                 CREATE = 'create'
                 UPDATE = 'update'
            @@ -150,6 +149,11 @@ 

            Classes

            CHANGE_TYPES = (CREATE, UPDATE, DELETE) shape_tag = None last_in_range_name = None + change_types_map = { + f'{{{TNS}}}Create': CREATE, + f'{{{TNS}}}Update': UPDATE, + f'{{{TNS}}}Delete': DELETE, + } def __init__(self, *args, **kwargs): # These values are reset and set each time call() is consumed @@ -157,15 +161,8 @@

            Classes

            self.includes_last_item_in_range = None super().__init__(*args, **kwargs) - def _change_types_map(self): - return { - '{%s}Create' % TNS: self.CREATE, - '{%s}Update' % TNS: self.UPDATE, - '{%s}Delete' % TNS: self.DELETE, - } - def _get_element_container(self, message, name=None): - self.sync_state = message.find('{%s}SyncState' % MNS).text + self.sync_state = message.find(f'{{{MNS}}}SyncState').text log.debug('Sync state is: %s', self.sync_state) self.includes_last_item_in_range = xml_text_to_value( message.find(self.last_in_range_name).text, bool @@ -174,19 +171,18 @@

            Classes

            return super()._get_element_container(message=message, name=name) def _partial_get_payload(self, folder, shape, additional_fields, sync_state): - svc_elem = create_element('m:%s' % self.SERVICE_NAME) - foldershape = create_shape_element( + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(shape_element( tag=self.shape_tag, shape=shape, additional_fields=additional_fields, version=self.account.version - ) - svc_elem.append(foldershape) - folder_id = create_folder_ids_element(tag='m:SyncFolderId', folders=[folder], version=self.account.version) - svc_elem.append(folder_id) + )) + payload.append(folder_ids_element(folders=[folder], version=self.account.version, tag='m:SyncFolderId')) if sync_state: - add_xml_child(svc_elem, 'm:SyncState', sync_state) - return svc_elem
            + add_xml_child(payload, 'm:SyncState', sync_state) + return payload

            Ancestors

            @@ -213,6 +209,10 @@

            Class variables

            +
            var change_types_map
            +
            +
            +
            var element_container_name
            @@ -228,12 +228,12 @@

            Class variables

            Inherited members

            @@ -256,29 +256,32 @@

            Inherited members

            SERVICE_NAME = 'SyncFolderHierarchy' shape_tag = 'm:FolderShape' - last_in_range_name = '{%s}IncludesLastFolderInRange' % MNS + last_in_range_name = f'{{{MNS}}}IncludesLastFolderInRange' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.folder = None # A hack to communicate parsing args to _elems_to_objs() def call(self, folder, shape, additional_fields, sync_state): self.sync_state = sync_state - change_types = self._change_types_map() - for elem in self._get_elements(payload=self.get_payload( + self.folder = folder + return self._elems_to_objs(self._get_elements(payload=self.get_payload( folder=folder, shape=shape, additional_fields=additional_fields, sync_state=sync_state, - )): - if isinstance(elem, Exception): - yield elem - continue - change_type = change_types[elem.tag] - if change_type == self.DELETE: - folder = FolderId.from_xml(elem=elem.find(FolderId.response_tag()), account=self.account) - else: - # We can't find() the element because we don't know which tag to look for. The change element can - # contain multiple folder types, each with their own tag. - folder_elem = elem[0] - folder = parse_folder_elem(elem=folder_elem, folder=folder, account=self.account) - yield change_type, folder + ))) + + def _elem_to_obj(self, elem): + change_type = self.change_types_map[elem.tag] + if change_type == self.DELETE: + folder = FolderId.from_xml(elem=elem.find(FolderId.response_tag()), account=self.account) + else: + # We can't find() the element because we don't know which tag to look for. The change element can + # contain multiple folder types, each with their own tag. + folder_elem = elem[0] + folder = parse_folder_elem(elem=folder_elem, folder=self.folder, account=self.account) + return change_type, folder def get_payload(self, folder, shape, additional_fields, sync_state): return self._partial_get_payload( @@ -288,6 +291,7 @@

            Inherited members

            Ancestors

            @@ -319,25 +323,13 @@

            Methods

            def call(self, folder, shape, additional_fields, sync_state):
                 self.sync_state = sync_state
            -    change_types = self._change_types_map()
            -    for elem in self._get_elements(payload=self.get_payload(
            +    self.folder = folder
            +    return self._elems_to_objs(self._get_elements(payload=self.get_payload(
                         folder=folder,
                         shape=shape,
                         additional_fields=additional_fields,
                         sync_state=sync_state,
            -    )):
            -        if isinstance(elem, Exception):
            -            yield elem
            -            continue
            -        change_type = change_types[elem.tag]
            -        if change_type == self.DELETE:
            -            folder = FolderId.from_xml(elem=elem.find(FolderId.response_tag()), account=self.account)
            -        else:
            -            # We can't find() the element because we don't know which tag to look for. The change element can
            -            # contain multiple folder types, each with their own tag.
            -            folder_elem = elem[0]
            -            folder = parse_folder_elem(elem=folder_elem, folder=folder, account=self.account)
            -        yield change_type, folder
            + )))
            @@ -391,6 +383,7 @@

            CREATE
          • DELETE
          • UPDATE
          • +
          • change_types_map
          • element_container_name
          • last_in_range_name
          • shape_tag
          • diff --git a/docs/exchangelib/services/sync_folder_items.html b/docs/exchangelib/services/sync_folder_items.html index cb098d99..8804be99 100644 --- a/docs/exchangelib/services/sync_folder_items.html +++ b/docs/exchangelib/services/sync_folder_items.html @@ -26,8 +26,10 @@

            Module exchangelib.services.sync_folder_items

            Expand source code -
            from .common import add_xml_child, create_item_ids_element
            +
            from .common import add_xml_child, item_ids_element
             from .sync_folder_hierarchy import SyncFolder
            +from ..errors import InvalidEnumValue, InvalidTypeError
            +from ..folders import BaseFolder
             from ..properties import ItemId
             from ..util import xml_text_to_value, peek, TNS, MNS
             
            @@ -38,29 +40,28 @@ 

            Module exchangelib.services.sync_folder_items

            Module exchangelib.services.sync_folder_items

            + add_xml_child(sync_folder_items, 'm:SyncScope', sync_scope) + return sync_folder_items

      @@ -133,29 +127,28 @@

      Classes

      """ SERVICE_NAME = 'SyncFolderItems' - SYNC_SCOPES = { + SYNC_SCOPES = ( 'NormalItems', 'NormalAndAssociatedItems', - } + ) # Extra change type READ_FLAG_CHANGE = 'read_flag_change' CHANGE_TYPES = SyncFolder.CHANGE_TYPES + (READ_FLAG_CHANGE,) shape_tag = 'm:ItemShape' - last_in_range_name = '{%s}IncludesLastItemInRange' % MNS - - def _change_types_map(self): - res = super()._change_types_map() - res['{%s}ReadFlagChange' % TNS] = self.READ_FLAG_CHANGE - return res + last_in_range_name = f'{{{MNS}}}IncludesLastItemInRange' + change_types_map = SyncFolder.change_types_map + change_types_map[f'{{{TNS}}}ReadFlagChange'] = READ_FLAG_CHANGE def call(self, folder, shape, additional_fields, sync_state, ignore, max_changes_returned, sync_scope): self.sync_state = sync_state if max_changes_returned is None: - max_changes_returned = self.chunk_size + max_changes_returned = self.page_size + if not isinstance(max_changes_returned, int): + raise InvalidTypeError('max_changes_returned', max_changes_returned, int) if max_changes_returned <= 0: - raise ValueError("'max_changes_returned' %s must be a positive integer" % max_changes_returned) + raise ValueError(f"'max_changes_returned' {max_changes_returned} must be a positive integer") if sync_scope is not None and sync_scope not in self.SYNC_SCOPES: - raise ValueError("'sync_scope' %s must be one of %r" % (sync_scope, self.SYNC_SCOPES)) + raise InvalidEnumValue('sync_scope', sync_scope, self.SYNC_SCOPES) return self._elems_to_objs(self._get_elements(payload=self.get_payload( folder=folder, shape=shape, @@ -166,44 +159,38 @@

      Classes

      sync_scope=sync_scope, ))) - def _elems_to_objs(self, elems): - from ..folders.base import BaseFolder - change_types = self._change_types_map() - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - change_type = change_types[elem.tag] - if change_type == self.READ_FLAG_CHANGE: - item = ( - ItemId.from_xml(elem=elem.find(ItemId.response_tag()), account=self.account), - xml_text_to_value(elem.find('{%s}IsRead' % TNS).text, bool) - ) - elif change_type == self.DELETE: - item = ItemId.from_xml(elem=elem.find(ItemId.response_tag()), account=self.account) - else: - # We can't find() the element because we don't know which tag to look for. The change element can - # contain multiple item types, each with their own tag. - item_elem = elem[0] - item = BaseFolder.item_model_from_tag(item_elem.tag).from_xml(elem=item_elem, account=self.account) - yield change_type, item + def _elem_to_obj(self, elem): + change_type = self.change_types_map[elem.tag] + if change_type == self.READ_FLAG_CHANGE: + item = ( + ItemId.from_xml(elem=elem.find(ItemId.response_tag()), account=self.account), + xml_text_to_value(elem.find(f'{{{TNS}}}IsRead').text, bool) + ) + elif change_type == self.DELETE: + item = ItemId.from_xml(elem=elem.find(ItemId.response_tag()), account=self.account) + else: + # We can't find() the element because we don't know which tag to look for. The change element can + # contain multiple item types, each with their own tag. + item_elem = elem[0] + item = BaseFolder.item_model_from_tag(item_elem.tag).from_xml(elem=item_elem, account=self.account) + return change_type, item def get_payload(self, folder, shape, additional_fields, sync_state, ignore, max_changes_returned, sync_scope): - syncfolderitems = self._partial_get_payload( + sync_folder_items = self._partial_get_payload( folder=folder, shape=shape, additional_fields=additional_fields, sync_state=sync_state ) is_empty, ignore = (True, None) if ignore is None else peek(ignore) if not is_empty: - item_ids = create_item_ids_element(items=ignore, version=self.account.version, tag='m:Ignore') - syncfolderitems.append(item_ids) - add_xml_child(syncfolderitems, 'm:MaxChangesReturned', max_changes_returned) + sync_folder_items.append(item_ids_element(items=ignore, version=self.account.version, tag='m:Ignore')) + add_xml_child(sync_folder_items, 'm:MaxChangesReturned', max_changes_returned) if sync_scope: - add_xml_child(syncfolderitems, 'm:SyncScope', sync_scope) - return syncfolderitems
      + add_xml_child(sync_folder_items, 'm:SyncScope', sync_scope) + return sync_folder_items

      Ancestors

      @@ -225,6 +212,10 @@

      Class variables

      +
      var change_types_map
      +
      +
      +
      var last_in_range_name
      @@ -248,11 +239,13 @@

      Methods

      def call(self, folder, shape, additional_fields, sync_state, ignore, max_changes_returned, sync_scope):
           self.sync_state = sync_state
           if max_changes_returned is None:
      -        max_changes_returned = self.chunk_size
      +        max_changes_returned = self.page_size
      +    if not isinstance(max_changes_returned, int):
      +        raise InvalidTypeError('max_changes_returned', max_changes_returned, int)
           if max_changes_returned <= 0:
      -        raise ValueError("'max_changes_returned' %s must be a positive integer" % max_changes_returned)
      +        raise ValueError(f"'max_changes_returned' {max_changes_returned} must be a positive integer")
           if sync_scope is not None and sync_scope not in self.SYNC_SCOPES:
      -        raise ValueError("'sync_scope' %s must be one of %r" % (sync_scope, self.SYNC_SCOPES))
      +        raise InvalidEnumValue('sync_scope', sync_scope, self.SYNC_SCOPES)
           return self._elems_to_objs(self._get_elements(payload=self.get_payload(
                   folder=folder,
                   shape=shape,
      @@ -274,17 +267,16 @@ 

      Methods

      Expand source code
      def get_payload(self, folder, shape, additional_fields, sync_state, ignore, max_changes_returned, sync_scope):
      -    syncfolderitems = self._partial_get_payload(
      +    sync_folder_items = self._partial_get_payload(
               folder=folder, shape=shape, additional_fields=additional_fields, sync_state=sync_state
           )
           is_empty, ignore = (True, None) if ignore is None else peek(ignore)
           if not is_empty:
      -        item_ids = create_item_ids_element(items=ignore, version=self.account.version, tag='m:Ignore')
      -        syncfolderitems.append(item_ids)
      -    add_xml_child(syncfolderitems, 'm:MaxChangesReturned', max_changes_returned)
      +        sync_folder_items.append(item_ids_element(items=ignore, version=self.account.version, tag='m:Ignore'))
      +    add_xml_child(sync_folder_items, 'm:MaxChangesReturned', max_changes_returned)
           if sync_scope:
      -        add_xml_child(syncfolderitems, 'm:SyncScope', sync_scope)
      -    return syncfolderitems
      + add_xml_child(sync_folder_items, 'm:SyncScope', sync_scope) + return sync_folder_items
      @@ -324,6 +316,7 @@

      SERVICE_NAME
    • SYNC_SCOPES
    • call
    • +
    • change_types_map
    • get_payload
    • last_in_range_name
    • shape_tag
    • diff --git a/docs/exchangelib/services/unsubscribe.html b/docs/exchangelib/services/unsubscribe.html index 3b656c5e..707171c5 100644 --- a/docs/exchangelib/services/unsubscribe.html +++ b/docs/exchangelib/services/unsubscribe.html @@ -44,9 +44,9 @@

      Module exchangelib.services.unsubscribe

      return self._get_elements(payload=self.get_payload(subscription_id=subscription_id)) def get_payload(self, subscription_id): - unsubscribe = create_element('m:%s' % self.SERVICE_NAME) - add_xml_child(unsubscribe, 'm:SubscriptionId', subscription_id) - return unsubscribe
      + payload = create_element(f'm:{self.SERVICE_NAME}') + add_xml_child(payload, 'm:SubscriptionId', subscription_id) + return payload

      @@ -83,9 +83,9 @@

      Classes

      return self._get_elements(payload=self.get_payload(subscription_id=subscription_id)) def get_payload(self, subscription_id): - unsubscribe = create_element('m:%s' % self.SERVICE_NAME) - add_xml_child(unsubscribe, 'm:SubscriptionId', subscription_id) - return unsubscribe + payload = create_element(f'm:{self.SERVICE_NAME}') + add_xml_child(payload, 'm:SubscriptionId', subscription_id) + return payload

      Ancestors

        @@ -132,9 +132,9 @@

        Methods

        Expand source code
        def get_payload(self, subscription_id):
        -    unsubscribe = create_element('m:%s' % self.SERVICE_NAME)
        -    add_xml_child(unsubscribe, 'm:SubscriptionId', subscription_id)
        -    return unsubscribe
        + payload = create_element(f'm:{self.SERVICE_NAME}') + add_xml_child(payload, 'm:SubscriptionId', subscription_id) + return payload diff --git a/docs/exchangelib/services/update_folder.html b/docs/exchangelib/services/update_folder.html index ea969a13..81183325 100644 --- a/docs/exchangelib/services/update_folder.html +++ b/docs/exchangelib/services/update_folder.html @@ -26,16 +26,133 @@

        Module exchangelib.services.update_folder

        Expand source code -
        from .common import EWSAccountService, parse_folder_elem, to_item_id
        -from ..fields import FieldPath
        +
        import abc
        +
        +from .common import EWSAccountService, parse_folder_elem, to_item_id
        +from ..fields import FieldPath, IndexedField
        +from ..properties import FolderId
         from ..util import create_element, set_xml_value, MNS
         
         
        -class UpdateFolder(EWSAccountService):
        +class BaseUpdateService(EWSAccountService, metaclass=abc.ABCMeta):
        +    """Base class for UpdateFolder and UpdateItem"""
        +    SET_FIELD_ELEMENT_NAME = None
        +    DELETE_FIELD_ELEMENT_NAME = None
        +    CHANGE_ELEMENT_NAME = None
        +    CHANGES_ELEMENT_NAME = None
        +
        +    @staticmethod
        +    def _sorted_fields(target_model, fieldnames):
        +        # Take a list of fieldnames and return the fields in the order they are mentioned in target_model.FIELDS.
        +        # Checks that all fieldnames are valid.
        +        fieldnames_copy = list(fieldnames)
        +        # Loop over FIELDS and not supported_fields(). Upstream should make sure not to update a non-supported field.
        +        for f in target_model.FIELDS:
        +            if f.name in fieldnames_copy:
        +                fieldnames_copy.remove(f.name)
        +                yield f
        +        if fieldnames_copy:
        +            raise ValueError(f"Field name(s) {fieldnames_copy} are not valid {target_model.__name__!r} fields")
        +
        +    def _get_value(self, target, field):
        +        return field.clean(getattr(target, field.name), version=self.account.version)  # Make sure the value is OK
        +
        +    def _set_field_elems(self, target_model, field, value):
        +        if isinstance(field, IndexedField):
        +            # Generate either set or delete elements for all combinations of labels and subfields
        +            supported_labels = field.value_cls.get_field_by_fieldname('label')\
        +                .supported_choices(version=self.account.version)
        +            seen_labels = set()
        +            subfields = field.value_cls.supported_fields(version=self.account.version)
        +            for v in value:
        +                seen_labels.add(v.label)
        +                for subfield in subfields:
        +                    field_path = FieldPath(field=field, label=v.label, subfield=subfield)
        +                    subfield_value = getattr(v, subfield.name)
        +                    if not subfield_value:
        +                        # Generate delete elements for blank subfield values
        +                        yield self._delete_field_elem(field_path=field_path)
        +                    else:
        +                        # Generate set elements for non-null subfield values
        +                        yield self._set_field_elem(
        +                            target_model=target_model,
        +                            field_path=field_path,
        +                            value=field.value_cls(**{'label': v.label, subfield.name: subfield_value}),
        +                        )
        +                # Generate delete elements for all subfields of all labels not mentioned in the list of values
        +                for label in (label for label in supported_labels if label not in seen_labels):
        +                    for subfield in subfields:
        +                        yield self._delete_field_elem(field_path=FieldPath(field=field, label=label, subfield=subfield))
        +        else:
        +            yield self._set_field_elem(target_model=target_model, field_path=FieldPath(field=field), value=value)
        +
        +    def _set_field_elem(self, target_model, field_path, value):
        +        set_field = create_element(self.SET_FIELD_ELEMENT_NAME)
        +        set_xml_value(set_field, field_path, version=self.account.version)
        +        folder = create_element(target_model.request_tag())
        +        field_elem = field_path.field.to_xml(value, version=self.account.version)
        +        set_xml_value(folder, field_elem, version=self.account.version)
        +        set_field.append(folder)
        +        return set_field
        +
        +    def _delete_field_elems(self, field):
        +        for field_path in FieldPath(field=field).expand(version=self.account.version):
        +            yield self._delete_field_elem(field_path=field_path)
        +
        +    def _delete_field_elem(self, field_path):
        +        delete_folder_field = create_element(self.DELETE_FIELD_ELEMENT_NAME)
        +        return set_xml_value(delete_folder_field, field_path, version=self.account.version)
        +
        +    def _update_elems(self, target, fieldnames):
        +        target_model = target.__class__
        +
        +        for field in self._sorted_fields(target_model=target_model, fieldnames=fieldnames):
        +            if field.is_read_only:
        +                raise ValueError(f'{field.name!r} is a read-only field')
        +            value = self._get_value(target, field)
        +
        +            if value is None or (field.is_list and not value):
        +                # A value of None or [] means we want to remove this field from the item
        +                if field.is_required or field.is_required_after_save:
        +                    raise ValueError(f'{field.name!r} is a required field and may not be deleted')
        +                yield from self._delete_field_elems(field)
        +                continue
        +
        +            yield from self._set_field_elems(target_model=target_model, field=field, value=value)
        +
        +    def _change_elem(self, target, fieldnames):
        +        if not fieldnames:
        +            raise ValueError("'fieldnames' must not be empty")
        +        change = create_element(self.CHANGE_ELEMENT_NAME)
        +        set_xml_value(change, self._target_elem(target), version=self.account.version)
        +        updates = create_element('t:Updates')
        +        for elem in self._update_elems(target=target, fieldnames=fieldnames):
        +            updates.append(elem)
        +        change.append(updates)
        +        return change
        +
        +    @abc.abstractmethod
        +    def _target_elem(self, target):
        +        """Convert the object to update to an XML element"""
        +
        +    def _changes_elem(self, target_changes):
        +        changes = create_element(self.CHANGES_ELEMENT_NAME)
        +        for target, fieldnames in target_changes:
        +            if not target.account:
        +                target.account = self.account
        +            changes.append(self._change_elem(target=target, fieldnames=fieldnames))
        +        return changes
        +
        +
        +class UpdateFolder(BaseUpdateService):
             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updatefolder-operation"""
         
             SERVICE_NAME = 'UpdateFolder'
        -    element_container_name = '{%s}Folders' % MNS
        +    SET_FIELD_ELEMENT_NAME = 't:SetFolderField'
        +    DELETE_FIELD_ELEMENT_NAME = 't:DeleteFolderField'
        +    CHANGE_ELEMENT_NAME = 't:FolderChange'
        +    CHANGES_ELEMENT_NAME = 'm:FolderChanges'
        +    element_container_name = f'{{{MNS}}}Folders'
         
             def __init__(self, *args, **kwargs):
                 super().__init__(*args, **kwargs)
        @@ -54,77 +171,186 @@ 

        Module exchangelib.services.update_folder

        continue yield parse_folder_elem(elem=elem, folder=folder, account=self.account) + def _target_elem(self, target): + return to_item_id(target, FolderId) + + def get_payload(self, folders): + # Takes a list of (Folder, fieldnames) tuples where 'Folder' is a instance of a subclass of Folder and + # 'fieldnames' are the attribute names that were updated. + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(self._changes_elem(target_changes=folders)) + return payload
        + +
      +
      +
      +
      +
      +
      +
      +
      +

      Classes

      +
      +
      +class BaseUpdateService +(*args, **kwargs) +
      +
      +

      Base class for UpdateFolder and UpdateItem

      +
      + +Expand source code + +
      class BaseUpdateService(EWSAccountService, metaclass=abc.ABCMeta):
      +    """Base class for UpdateFolder and UpdateItem"""
      +    SET_FIELD_ELEMENT_NAME = None
      +    DELETE_FIELD_ELEMENT_NAME = None
      +    CHANGE_ELEMENT_NAME = None
      +    CHANGES_ELEMENT_NAME = None
      +
           @staticmethod
      -    def _sort_fieldnames(folder_model, fieldnames):
      -        # Take a list of fieldnames and return the fields in the order they are mentioned in folder_model.FIELDS.
      +    def _sorted_fields(target_model, fieldnames):
      +        # Take a list of fieldnames and return the fields in the order they are mentioned in target_model.FIELDS.
      +        # Checks that all fieldnames are valid.
      +        fieldnames_copy = list(fieldnames)
               # Loop over FIELDS and not supported_fields(). Upstream should make sure not to update a non-supported field.
      -        for f in folder_model.FIELDS:
      -            if f.name in fieldnames:
      -                yield f.name
      -
      -    def _set_folder_elem(self, folder_model, field_path, value):
      -        setfolderfield = create_element('t:SetFolderField')
      -        set_xml_value(setfolderfield, field_path, version=self.account.version)
      -        folder = create_element(folder_model.request_tag())
      +        for f in target_model.FIELDS:
      +            if f.name in fieldnames_copy:
      +                fieldnames_copy.remove(f.name)
      +                yield f
      +        if fieldnames_copy:
      +            raise ValueError(f"Field name(s) {fieldnames_copy} are not valid {target_model.__name__!r} fields")
      +
      +    def _get_value(self, target, field):
      +        return field.clean(getattr(target, field.name), version=self.account.version)  # Make sure the value is OK
      +
      +    def _set_field_elems(self, target_model, field, value):
      +        if isinstance(field, IndexedField):
      +            # Generate either set or delete elements for all combinations of labels and subfields
      +            supported_labels = field.value_cls.get_field_by_fieldname('label')\
      +                .supported_choices(version=self.account.version)
      +            seen_labels = set()
      +            subfields = field.value_cls.supported_fields(version=self.account.version)
      +            for v in value:
      +                seen_labels.add(v.label)
      +                for subfield in subfields:
      +                    field_path = FieldPath(field=field, label=v.label, subfield=subfield)
      +                    subfield_value = getattr(v, subfield.name)
      +                    if not subfield_value:
      +                        # Generate delete elements for blank subfield values
      +                        yield self._delete_field_elem(field_path=field_path)
      +                    else:
      +                        # Generate set elements for non-null subfield values
      +                        yield self._set_field_elem(
      +                            target_model=target_model,
      +                            field_path=field_path,
      +                            value=field.value_cls(**{'label': v.label, subfield.name: subfield_value}),
      +                        )
      +                # Generate delete elements for all subfields of all labels not mentioned in the list of values
      +                for label in (label for label in supported_labels if label not in seen_labels):
      +                    for subfield in subfields:
      +                        yield self._delete_field_elem(field_path=FieldPath(field=field, label=label, subfield=subfield))
      +        else:
      +            yield self._set_field_elem(target_model=target_model, field_path=FieldPath(field=field), value=value)
      +
      +    def _set_field_elem(self, target_model, field_path, value):
      +        set_field = create_element(self.SET_FIELD_ELEMENT_NAME)
      +        set_xml_value(set_field, field_path, version=self.account.version)
      +        folder = create_element(target_model.request_tag())
               field_elem = field_path.field.to_xml(value, version=self.account.version)
               set_xml_value(folder, field_elem, version=self.account.version)
      -        setfolderfield.append(folder)
      -        return setfolderfield
      +        set_field.append(folder)
      +        return set_field
       
      -    def _delete_folder_elem(self, field_path):
      -        deletefolderfield = create_element('t:DeleteFolderField')
      -        return set_xml_value(deletefolderfield, field_path, version=self.account.version)
      +    def _delete_field_elems(self, field):
      +        for field_path in FieldPath(field=field).expand(version=self.account.version):
      +            yield self._delete_field_elem(field_path=field_path)
       
      -    def _get_folder_update_elems(self, folder, fieldnames):
      -        folder_model = folder.__class__
      -        fieldnames_set = set(fieldnames)
      +    def _delete_field_elem(self, field_path):
      +        delete_folder_field = create_element(self.DELETE_FIELD_ELEMENT_NAME)
      +        return set_xml_value(delete_folder_field, field_path, version=self.account.version)
       
      -        for fieldname in self._sort_fieldnames(folder_model=folder_model, fieldnames=fieldnames_set):
      -            field = folder_model.get_field_by_fieldname(fieldname)
      +    def _update_elems(self, target, fieldnames):
      +        target_model = target.__class__
      +
      +        for field in self._sorted_fields(target_model=target_model, fieldnames=fieldnames):
                   if field.is_read_only:
      -                raise ValueError('%s is a read-only field' % field.name)
      -            value = field.clean(getattr(folder, field.name), version=self.account.version)  # Make sure the value is OK
      +                raise ValueError(f'{field.name!r} is a read-only field')
      +            value = self._get_value(target, field)
       
                   if value is None or (field.is_list and not value):
                       # A value of None or [] means we want to remove this field from the item
                       if field.is_required or field.is_required_after_save:
      -                    raise ValueError('%s is a required field and may not be deleted' % field.name)
      -                for field_path in FieldPath(field=field).expand(version=self.account.version):
      -                    yield self._delete_folder_elem(field_path=field_path)
      +                    raise ValueError(f'{field.name!r} is a required field and may not be deleted')
      +                yield from self._delete_field_elems(field)
                       continue
       
      -            yield self._set_folder_elem(folder_model=folder_model, field_path=FieldPath(field=field), value=value)
      +            yield from self._set_field_elems(target_model=target_model, field=field, value=value)
       
      -    def get_payload(self, folders):
      -        from ..folders import BaseFolder, FolderId
      -        updatefolder = create_element('m:%s' % self.SERVICE_NAME)
      -        folderchanges = create_element('m:FolderChanges')
      -        version = self.account.version
      -        for folder, fieldnames in folders:
      -            folderchange = create_element('t:FolderChange')
      -            if not isinstance(folder, (BaseFolder, FolderId)):
      -                folder = to_item_id(folder, FolderId, version=version)
      -            set_xml_value(folderchange, folder, version=version)
      -            updates = create_element('t:Updates')
      -            for elem in self._get_folder_update_elems(folder=folder, fieldnames=fieldnames):
      -                updates.append(elem)
      -            folderchange.append(updates)
      -            folderchanges.append(folderchange)
      -        if not len(folderchanges):
      -            raise ValueError('"folders" must not be empty')
      -        updatefolder.append(folderchanges)
      -        return updatefolder
      + def _change_elem(self, target, fieldnames): + if not fieldnames: + raise ValueError("'fieldnames' must not be empty") + change = create_element(self.CHANGE_ELEMENT_NAME) + set_xml_value(change, self._target_elem(target), version=self.account.version) + updates = create_element('t:Updates') + for elem in self._update_elems(target=target, fieldnames=fieldnames): + updates.append(elem) + change.append(updates) + return change + + @abc.abstractmethod + def _target_elem(self, target): + """Convert the object to update to an XML element""" + + def _changes_elem(self, target_changes): + changes = create_element(self.CHANGES_ELEMENT_NAME) + for target, fieldnames in target_changes: + if not target.account: + target.account = self.account + changes.append(self._change_elem(target=target, fieldnames=fieldnames)) + return changes
      -
      -
      -
      -
      -
      -
      -
      -
      -

      Classes

      +

      Ancestors

      + +

      Subclasses

      + +

      Class variables

      +
      var CHANGES_ELEMENT_NAME
      +
      +
      +
      +
      var CHANGE_ELEMENT_NAME
      +
      +
      +
      +
      var DELETE_FIELD_ELEMENT_NAME
      +
      +
      +
      +
      var SET_FIELD_ELEMENT_NAME
      +
      +
      +
      +
      +

      Inherited members

      + +
      class UpdateFolder (*args, **kwargs) @@ -135,11 +361,15 @@

      Classes

      Expand source code -
      class UpdateFolder(EWSAccountService):
      +
      class UpdateFolder(BaseUpdateService):
           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updatefolder-operation"""
       
           SERVICE_NAME = 'UpdateFolder'
      -    element_container_name = '{%s}Folders' % MNS
      +    SET_FIELD_ELEMENT_NAME = 't:SetFolderField'
      +    DELETE_FIELD_ELEMENT_NAME = 't:DeleteFolderField'
      +    CHANGE_ELEMENT_NAME = 't:FolderChange'
      +    CHANGES_ELEMENT_NAME = 'm:FolderChanges'
      +    element_container_name = f'{{{MNS}}}Folders'
       
           def __init__(self, *args, **kwargs):
               super().__init__(*args, **kwargs)
      @@ -158,78 +388,44 @@ 

      Classes

      continue yield parse_folder_elem(elem=elem, folder=folder, account=self.account) - @staticmethod - def _sort_fieldnames(folder_model, fieldnames): - # Take a list of fieldnames and return the fields in the order they are mentioned in folder_model.FIELDS. - # Loop over FIELDS and not supported_fields(). Upstream should make sure not to update a non-supported field. - for f in folder_model.FIELDS: - if f.name in fieldnames: - yield f.name - - def _set_folder_elem(self, folder_model, field_path, value): - setfolderfield = create_element('t:SetFolderField') - set_xml_value(setfolderfield, field_path, version=self.account.version) - folder = create_element(folder_model.request_tag()) - field_elem = field_path.field.to_xml(value, version=self.account.version) - set_xml_value(folder, field_elem, version=self.account.version) - setfolderfield.append(folder) - return setfolderfield - - def _delete_folder_elem(self, field_path): - deletefolderfield = create_element('t:DeleteFolderField') - return set_xml_value(deletefolderfield, field_path, version=self.account.version) - - def _get_folder_update_elems(self, folder, fieldnames): - folder_model = folder.__class__ - fieldnames_set = set(fieldnames) - - for fieldname in self._sort_fieldnames(folder_model=folder_model, fieldnames=fieldnames_set): - field = folder_model.get_field_by_fieldname(fieldname) - if field.is_read_only: - raise ValueError('%s is a read-only field' % field.name) - value = field.clean(getattr(folder, field.name), version=self.account.version) # Make sure the value is OK - - if value is None or (field.is_list and not value): - # A value of None or [] means we want to remove this field from the item - if field.is_required or field.is_required_after_save: - raise ValueError('%s is a required field and may not be deleted' % field.name) - for field_path in FieldPath(field=field).expand(version=self.account.version): - yield self._delete_folder_elem(field_path=field_path) - continue - - yield self._set_folder_elem(folder_model=folder_model, field_path=FieldPath(field=field), value=value) + def _target_elem(self, target): + return to_item_id(target, FolderId) def get_payload(self, folders): - from ..folders import BaseFolder, FolderId - updatefolder = create_element('m:%s' % self.SERVICE_NAME) - folderchanges = create_element('m:FolderChanges') - version = self.account.version - for folder, fieldnames in folders: - folderchange = create_element('t:FolderChange') - if not isinstance(folder, (BaseFolder, FolderId)): - folder = to_item_id(folder, FolderId, version=version) - set_xml_value(folderchange, folder, version=version) - updates = create_element('t:Updates') - for elem in self._get_folder_update_elems(folder=folder, fieldnames=fieldnames): - updates.append(elem) - folderchange.append(updates) - folderchanges.append(folderchange) - if not len(folderchanges): - raise ValueError('"folders" must not be empty') - updatefolder.append(folderchanges) - return updatefolder
      + # Takes a list of (Folder, fieldnames) tuples where 'Folder' is a instance of a subclass of Folder and + # 'fieldnames' are the attribute names that were updated. + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(self._changes_elem(target_changes=folders)) + return payload

      Ancestors

      Class variables

      +
      var CHANGES_ELEMENT_NAME
      +
      +
      +
      +
      var CHANGE_ELEMENT_NAME
      +
      +
      +
      +
      var DELETE_FIELD_ELEMENT_NAME
      +
      +
      +
      var SERVICE_NAME
      +
      var SET_FIELD_ELEMENT_NAME
      +
      +
      +
      var element_container_name
      @@ -263,35 +459,22 @@

      Methods

      Expand source code
      def get_payload(self, folders):
      -    from ..folders import BaseFolder, FolderId
      -    updatefolder = create_element('m:%s' % self.SERVICE_NAME)
      -    folderchanges = create_element('m:FolderChanges')
      -    version = self.account.version
      -    for folder, fieldnames in folders:
      -        folderchange = create_element('t:FolderChange')
      -        if not isinstance(folder, (BaseFolder, FolderId)):
      -            folder = to_item_id(folder, FolderId, version=version)
      -        set_xml_value(folderchange, folder, version=version)
      -        updates = create_element('t:Updates')
      -        for elem in self._get_folder_update_elems(folder=folder, fieldnames=fieldnames):
      -            updates.append(elem)
      -        folderchange.append(updates)
      -        folderchanges.append(folderchange)
      -    if not len(folderchanges):
      -        raise ValueError('"folders" must not be empty')
      -    updatefolder.append(folderchanges)
      -    return updatefolder
      + # Takes a list of (Folder, fieldnames) tuples where 'Folder' is a instance of a subclass of Folder and + # 'fieldnames' are the attribute names that were updated. + payload = create_element(f'm:{self.SERVICE_NAME}') + payload.append(self._changes_elem(target_changes=folders)) + return payload

      Inherited members

      @@ -313,9 +496,22 @@

      Index

    • Classes

      • +

        BaseUpdateService

        + +
      • +
      • UpdateFolder

          +
        • CHANGES_ELEMENT_NAME
        • +
        • CHANGE_ELEMENT_NAME
        • +
        • DELETE_FIELD_ELEMENT_NAME
        • SERVICE_NAME
        • +
        • SET_FIELD_ELEMENT_NAME
        • call
        • element_container_name
        • get_payload
        • diff --git a/docs/exchangelib/services/update_item.html b/docs/exchangelib/services/update_item.html index d2c7bcfe..7f22eb3f 100644 --- a/docs/exchangelib/services/update_item.html +++ b/docs/exchangelib/services/update_item.html @@ -26,40 +26,38 @@

          Module exchangelib.services.update_item

          Expand source code -
          from collections import OrderedDict
          -
          -from .common import EWSAccountService, to_item_id
          +
          from .common import to_item_id
          +from ..errors import InvalidEnumValue
           from ..ewsdatetime import EWSDate
          -from ..fields import FieldPath, IndexedField
          +from ..items import CONFLICT_RESOLUTION_CHOICES, MESSAGE_DISPOSITION_CHOICES, \
          +    SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, SEND_ONLY, Item, CalendarItem
           from ..properties import ItemId
          -from ..util import create_element, set_xml_value, MNS
          +from ..util import create_element, MNS
           from ..version import EXCHANGE_2013_SP1
          +from .update_folder import BaseUpdateService
           
           
          -class UpdateItem(EWSAccountService):
          +class UpdateItem(BaseUpdateService):
               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem-operation"""
           
               SERVICE_NAME = 'UpdateItem'
          -    element_container_name = '{%s}Items' % MNS
          +    SET_FIELD_ELEMENT_NAME = 't:SetItemField'
          +    DELETE_FIELD_ELEMENT_NAME = 't:DeleteItemField'
          +    CHANGE_ELEMENT_NAME = 't:ItemChange'
          +    CHANGES_ELEMENT_NAME = 'm:ItemChanges'
          +    element_container_name = f'{{{MNS}}}Items'
           
               def call(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations,
                        suppress_read_receipts):
          -        from ..items import CONFLICT_RESOLUTION_CHOICES, MESSAGE_DISPOSITION_CHOICES, \
          -            SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, SEND_ONLY
                   if conflict_resolution not in CONFLICT_RESOLUTION_CHOICES:
          -            raise ValueError("'conflict_resolution' %s must be one of %s" % (
          -                conflict_resolution, CONFLICT_RESOLUTION_CHOICES
          -            ))
          +            raise InvalidEnumValue('conflict_resolution', conflict_resolution, CONFLICT_RESOLUTION_CHOICES)
                   if message_disposition not in MESSAGE_DISPOSITION_CHOICES:
          -            raise ValueError("'message_disposition' %s must be one of %s" % (
          -                message_disposition, MESSAGE_DISPOSITION_CHOICES
          -            ))
          +            raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES)
                   if send_meeting_invitations_or_cancellations not in SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES:
          -            raise ValueError("'send_meeting_invitations_or_cancellations' %s must be one of %s" % (
          -                send_meeting_invitations_or_cancellations, SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES
          -            ))
          -        if suppress_read_receipts not in (True, False):
          -            raise ValueError("'suppress_read_receipts' %s must be True or False" % suppress_read_receipts)
          +            raise InvalidEnumValue(
          +                'send_meeting_invitations_or_cancellations', send_meeting_invitations_or_cancellations,
          +                SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES
          +            )
                   if message_disposition == SEND_ONLY:
                       raise ValueError('Cannot send-only existing objects. Use SendItem service instead')
                   return self._elems_to_objs(self._chunked_get_elements(
          @@ -71,154 +69,54 @@ 

          Module exchangelib.services.update_item

          suppress_read_receipts=suppress_read_receipts, )) - def _elems_to_objs(self, elems): - from ..items import Item - for elem in elems: - if isinstance(elem, (Exception, type(None))): - yield elem - continue - yield Item.id_from_xml(elem) - - def _delete_item_elem(self, field_path): - deleteitemfield = create_element('t:DeleteItemField') - return set_xml_value(deleteitemfield, field_path, version=self.account.version) - - def _set_item_elem(self, item_model, field_path, value): - setitemfield = create_element('t:SetItemField') - set_xml_value(setitemfield, field_path, version=self.account.version) - item_elem = create_element(item_model.request_tag()) - field_elem = field_path.field.to_xml(value, version=self.account.version) - set_xml_value(item_elem, field_elem, version=self.account.version) - setitemfield.append(item_elem) - return setitemfield - - @staticmethod - def _sorted_fields(item_model, fieldnames): - # Take a list of fieldnames and return the (unique) fields in the order they are mentioned in item_class.FIELDS. - # Checks that all fieldnames are valid. - unique_fieldnames = list(OrderedDict.fromkeys(fieldnames)) # Make field names unique ,but keep ordering - # Loop over FIELDS and not supported_fields(). Upstream should make sure not to update a non-supported field. - for f in item_model.FIELDS: - if f.name in unique_fieldnames: - unique_fieldnames.remove(f.name) - yield f - if unique_fieldnames: - raise ValueError("Field name(s) %s are not valid for a '%s' item" % ( - ', '.join("'%s'" % f for f in unique_fieldnames), item_model.__name__)) + def _elem_to_obj(self, elem): + return Item.id_from_xml(elem) - def _get_item_update_elems(self, item, fieldnames): - from ..items import CalendarItem + def _update_elems(self, target, fieldnames): fieldnames_copy = list(fieldnames) - if item.__class__ == CalendarItem: + if target.__class__ == CalendarItem: # For CalendarItem items where we update 'start' or 'end', we want to update internal timezone fields - item.clean_timezone_fields(version=self.account.version) # Possibly also sets timezone values + target.clean_timezone_fields(version=self.account.version) # Possibly also sets timezone values for field_name in ('start', 'end'): if field_name in fieldnames_copy: - tz_field_name = item.tz_field_for_field_name(field_name).name + tz_field_name = target.tz_field_for_field_name(field_name).name if tz_field_name not in fieldnames_copy: fieldnames_copy.append(tz_field_name) - for field in self._sorted_fields(item_model=item.__class__, fieldnames=fieldnames_copy): - if field.is_read_only: - raise ValueError('%s is a read-only field' % field.name) - value = self._get_item_value(item, field) - if value is None or (field.is_list and not value): - # A value of None or [] means we want to remove this field from the item - yield from self._get_delete_item_elems(field=field) - else: - yield from self._get_set_item_elems(item_model=item.__class__, field=field, value=value) + yield from super()._update_elems(target=target, fieldnames=fieldnames_copy) - def _get_item_value(self, item, field): - from ..items import CalendarItem - value = field.clean(getattr(item, field.name), version=self.account.version) # Make sure the value is OK - if item.__class__ == CalendarItem: + def _get_value(self, target, field): + value = super()._get_value(target, field) + + if target.__class__ == CalendarItem: # For CalendarItem items where we update 'start' or 'end', we want to send values in the local timezone if field.name in ('start', 'end'): if type(value) is EWSDate: # EWS always expects a datetime - return item.date_to_datetime(field_name=field.name) - tz_field_name = item.tz_field_for_field_name(field.name).name - return value.astimezone(getattr(item, tz_field_name)) - return value + return target.date_to_datetime(field_name=field.name) + tz_field_name = target.tz_field_for_field_name(field.name).name + return value.astimezone(getattr(target, tz_field_name)) - def _get_delete_item_elems(self, field): - if field.is_required or field.is_required_after_save: - raise ValueError('%s is a required field and may not be deleted' % field.name) - for field_path in FieldPath(field=field).expand(version=self.account.version): - yield self._delete_item_elem(field_path=field_path) + return value - def _get_set_item_elems(self, item_model, field, value): - if isinstance(field, IndexedField): - # Generate either set or delete elements for all combinations of labels and subfields - supported_labels = field.value_cls.get_field_by_fieldname('label')\ - .supported_choices(version=self.account.version) - seen_labels = set() - subfields = field.value_cls.supported_fields(version=self.account.version) - for v in value: - seen_labels.add(v.label) - for subfield in subfields: - field_path = FieldPath(field=field, label=v.label, subfield=subfield) - subfield_value = getattr(v, subfield.name) - if not subfield_value: - # Generate delete elements for blank subfield values - yield self._delete_item_elem(field_path=field_path) - else: - # Generate set elements for non-null subfield values - yield self._set_item_elem( - item_model=item_model, - field_path=field_path, - value=field.value_cls(**{'label': v.label, subfield.name: subfield_value}), - ) - # Generate delete elements for all subfields of all labels not mentioned in the list of values - for label in (label for label in supported_labels if label not in seen_labels): - for subfield in subfields: - yield self._delete_item_elem(field_path=FieldPath(field=field, label=label, subfield=subfield)) - else: - yield self._set_item_elem(item_model=item_model, field_path=FieldPath(field=field), value=value) + def _target_elem(self, target): + return to_item_id(target, ItemId) def get_payload(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, suppress_read_receipts): # Takes a list of (Item, fieldnames) tuples where 'Item' is a instance of a subclass of Item and 'fieldnames' - # are the attribute names that were updated. Returns the XML for an UpdateItem call. - # an UpdateItem request. + # are the attribute names that were updated. + attrs = dict( + ConflictResolution=conflict_resolution, + MessageDisposition=message_disposition, + SendMeetingInvitationsOrCancellations=send_meeting_invitations_or_cancellations, + ) if self.account.version.build >= EXCHANGE_2013_SP1: - updateitem = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('ConflictResolution', conflict_resolution), - ('MessageDisposition', message_disposition), - ('SendMeetingInvitationsOrCancellations', send_meeting_invitations_or_cancellations), - ('SuppressReadReceipts', 'true' if suppress_read_receipts else 'false'), - ]) - ) - else: - updateitem = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('ConflictResolution', conflict_resolution), - ('MessageDisposition', message_disposition), - ('SendMeetingInvitationsOrCancellations', send_meeting_invitations_or_cancellations), - ]) - ) - itemchanges = create_element('m:ItemChanges') - version = self.account.version - for item, fieldnames in items: - if not item.account: - item.account = self.account - if not fieldnames: - raise ValueError('"fieldnames" must not be empty') - itemchange = create_element('t:ItemChange') - set_xml_value(itemchange, to_item_id(item, ItemId, version=version), version=version) - updates = create_element('t:Updates') - for elem in self._get_item_update_elems(item=item, fieldnames=fieldnames): - updates.append(elem) - itemchange.append(updates) - itemchanges.append(itemchange) - if not len(itemchanges): - raise ValueError('"items" must not be empty') - updateitem.append(itemchanges) - return updateitem
          + attrs['SuppressReadReceipts'] = suppress_read_receipts + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + payload.append(self._changes_elem(target_changes=items)) + return payload
    • @@ -240,30 +138,27 @@

      Classes

      Expand source code -
      class UpdateItem(EWSAccountService):
      +
      class UpdateItem(BaseUpdateService):
           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem-operation"""
       
           SERVICE_NAME = 'UpdateItem'
      -    element_container_name = '{%s}Items' % MNS
      +    SET_FIELD_ELEMENT_NAME = 't:SetItemField'
      +    DELETE_FIELD_ELEMENT_NAME = 't:DeleteItemField'
      +    CHANGE_ELEMENT_NAME = 't:ItemChange'
      +    CHANGES_ELEMENT_NAME = 'm:ItemChanges'
      +    element_container_name = f'{{{MNS}}}Items'
       
           def call(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations,
                    suppress_read_receipts):
      -        from ..items import CONFLICT_RESOLUTION_CHOICES, MESSAGE_DISPOSITION_CHOICES, \
      -            SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, SEND_ONLY
               if conflict_resolution not in CONFLICT_RESOLUTION_CHOICES:
      -            raise ValueError("'conflict_resolution' %s must be one of %s" % (
      -                conflict_resolution, CONFLICT_RESOLUTION_CHOICES
      -            ))
      +            raise InvalidEnumValue('conflict_resolution', conflict_resolution, CONFLICT_RESOLUTION_CHOICES)
               if message_disposition not in MESSAGE_DISPOSITION_CHOICES:
      -            raise ValueError("'message_disposition' %s must be one of %s" % (
      -                message_disposition, MESSAGE_DISPOSITION_CHOICES
      -            ))
      +            raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES)
               if send_meeting_invitations_or_cancellations not in SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES:
      -            raise ValueError("'send_meeting_invitations_or_cancellations' %s must be one of %s" % (
      -                send_meeting_invitations_or_cancellations, SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES
      -            ))
      -        if suppress_read_receipts not in (True, False):
      -            raise ValueError("'suppress_read_receipts' %s must be True or False" % suppress_read_receipts)
      +            raise InvalidEnumValue(
      +                'send_meeting_invitations_or_cancellations', send_meeting_invitations_or_cancellations,
      +                SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES
      +            )
               if message_disposition == SEND_ONLY:
                   raise ValueError('Cannot send-only existing objects. Use SendItem service instead')
               return self._elems_to_objs(self._chunked_get_elements(
      @@ -275,166 +170,83 @@ 

      Classes

      suppress_read_receipts=suppress_read_receipts, )) - def _elems_to_objs(self, elems): - from ..items import Item - for elem in elems: - if isinstance(elem, (Exception, type(None))): - yield elem - continue - yield Item.id_from_xml(elem) - - def _delete_item_elem(self, field_path): - deleteitemfield = create_element('t:DeleteItemField') - return set_xml_value(deleteitemfield, field_path, version=self.account.version) - - def _set_item_elem(self, item_model, field_path, value): - setitemfield = create_element('t:SetItemField') - set_xml_value(setitemfield, field_path, version=self.account.version) - item_elem = create_element(item_model.request_tag()) - field_elem = field_path.field.to_xml(value, version=self.account.version) - set_xml_value(item_elem, field_elem, version=self.account.version) - setitemfield.append(item_elem) - return setitemfield - - @staticmethod - def _sorted_fields(item_model, fieldnames): - # Take a list of fieldnames and return the (unique) fields in the order they are mentioned in item_class.FIELDS. - # Checks that all fieldnames are valid. - unique_fieldnames = list(OrderedDict.fromkeys(fieldnames)) # Make field names unique ,but keep ordering - # Loop over FIELDS and not supported_fields(). Upstream should make sure not to update a non-supported field. - for f in item_model.FIELDS: - if f.name in unique_fieldnames: - unique_fieldnames.remove(f.name) - yield f - if unique_fieldnames: - raise ValueError("Field name(s) %s are not valid for a '%s' item" % ( - ', '.join("'%s'" % f for f in unique_fieldnames), item_model.__name__)) + def _elem_to_obj(self, elem): + return Item.id_from_xml(elem) - def _get_item_update_elems(self, item, fieldnames): - from ..items import CalendarItem + def _update_elems(self, target, fieldnames): fieldnames_copy = list(fieldnames) - if item.__class__ == CalendarItem: + if target.__class__ == CalendarItem: # For CalendarItem items where we update 'start' or 'end', we want to update internal timezone fields - item.clean_timezone_fields(version=self.account.version) # Possibly also sets timezone values + target.clean_timezone_fields(version=self.account.version) # Possibly also sets timezone values for field_name in ('start', 'end'): if field_name in fieldnames_copy: - tz_field_name = item.tz_field_for_field_name(field_name).name + tz_field_name = target.tz_field_for_field_name(field_name).name if tz_field_name not in fieldnames_copy: fieldnames_copy.append(tz_field_name) - for field in self._sorted_fields(item_model=item.__class__, fieldnames=fieldnames_copy): - if field.is_read_only: - raise ValueError('%s is a read-only field' % field.name) - value = self._get_item_value(item, field) - if value is None or (field.is_list and not value): - # A value of None or [] means we want to remove this field from the item - yield from self._get_delete_item_elems(field=field) - else: - yield from self._get_set_item_elems(item_model=item.__class__, field=field, value=value) + yield from super()._update_elems(target=target, fieldnames=fieldnames_copy) - def _get_item_value(self, item, field): - from ..items import CalendarItem - value = field.clean(getattr(item, field.name), version=self.account.version) # Make sure the value is OK - if item.__class__ == CalendarItem: + def _get_value(self, target, field): + value = super()._get_value(target, field) + + if target.__class__ == CalendarItem: # For CalendarItem items where we update 'start' or 'end', we want to send values in the local timezone if field.name in ('start', 'end'): if type(value) is EWSDate: # EWS always expects a datetime - return item.date_to_datetime(field_name=field.name) - tz_field_name = item.tz_field_for_field_name(field.name).name - return value.astimezone(getattr(item, tz_field_name)) - return value + return target.date_to_datetime(field_name=field.name) + tz_field_name = target.tz_field_for_field_name(field.name).name + return value.astimezone(getattr(target, tz_field_name)) - def _get_delete_item_elems(self, field): - if field.is_required or field.is_required_after_save: - raise ValueError('%s is a required field and may not be deleted' % field.name) - for field_path in FieldPath(field=field).expand(version=self.account.version): - yield self._delete_item_elem(field_path=field_path) + return value - def _get_set_item_elems(self, item_model, field, value): - if isinstance(field, IndexedField): - # Generate either set or delete elements for all combinations of labels and subfields - supported_labels = field.value_cls.get_field_by_fieldname('label')\ - .supported_choices(version=self.account.version) - seen_labels = set() - subfields = field.value_cls.supported_fields(version=self.account.version) - for v in value: - seen_labels.add(v.label) - for subfield in subfields: - field_path = FieldPath(field=field, label=v.label, subfield=subfield) - subfield_value = getattr(v, subfield.name) - if not subfield_value: - # Generate delete elements for blank subfield values - yield self._delete_item_elem(field_path=field_path) - else: - # Generate set elements for non-null subfield values - yield self._set_item_elem( - item_model=item_model, - field_path=field_path, - value=field.value_cls(**{'label': v.label, subfield.name: subfield_value}), - ) - # Generate delete elements for all subfields of all labels not mentioned in the list of values - for label in (label for label in supported_labels if label not in seen_labels): - for subfield in subfields: - yield self._delete_item_elem(field_path=FieldPath(field=field, label=label, subfield=subfield)) - else: - yield self._set_item_elem(item_model=item_model, field_path=FieldPath(field=field), value=value) + def _target_elem(self, target): + return to_item_id(target, ItemId) def get_payload(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, suppress_read_receipts): # Takes a list of (Item, fieldnames) tuples where 'Item' is a instance of a subclass of Item and 'fieldnames' - # are the attribute names that were updated. Returns the XML for an UpdateItem call. - # an UpdateItem request. + # are the attribute names that were updated. + attrs = dict( + ConflictResolution=conflict_resolution, + MessageDisposition=message_disposition, + SendMeetingInvitationsOrCancellations=send_meeting_invitations_or_cancellations, + ) if self.account.version.build >= EXCHANGE_2013_SP1: - updateitem = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('ConflictResolution', conflict_resolution), - ('MessageDisposition', message_disposition), - ('SendMeetingInvitationsOrCancellations', send_meeting_invitations_or_cancellations), - ('SuppressReadReceipts', 'true' if suppress_read_receipts else 'false'), - ]) - ) - else: - updateitem = create_element( - 'm:%s' % self.SERVICE_NAME, - attrs=OrderedDict([ - ('ConflictResolution', conflict_resolution), - ('MessageDisposition', message_disposition), - ('SendMeetingInvitationsOrCancellations', send_meeting_invitations_or_cancellations), - ]) - ) - itemchanges = create_element('m:ItemChanges') - version = self.account.version - for item, fieldnames in items: - if not item.account: - item.account = self.account - if not fieldnames: - raise ValueError('"fieldnames" must not be empty') - itemchange = create_element('t:ItemChange') - set_xml_value(itemchange, to_item_id(item, ItemId, version=version), version=version) - updates = create_element('t:Updates') - for elem in self._get_item_update_elems(item=item, fieldnames=fieldnames): - updates.append(elem) - itemchange.append(updates) - itemchanges.append(itemchange) - if not len(itemchanges): - raise ValueError('"items" must not be empty') - updateitem.append(itemchanges) - return updateitem
      + attrs['SuppressReadReceipts'] = suppress_read_receipts + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + payload.append(self._changes_elem(target_changes=items)) + return payload

      Ancestors

      Class variables

      +
      var CHANGES_ELEMENT_NAME
      +
      +
      +
      +
      var CHANGE_ELEMENT_NAME
      +
      +
      +
      +
      var DELETE_FIELD_ELEMENT_NAME
      +
      +
      +
      var SERVICE_NAME
      +
      var SET_FIELD_ELEMENT_NAME
      +
      +
      +
      var element_container_name
      @@ -453,22 +265,15 @@

      Methods

      def call(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations,
                suppress_read_receipts):
      -    from ..items import CONFLICT_RESOLUTION_CHOICES, MESSAGE_DISPOSITION_CHOICES, \
      -        SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, SEND_ONLY
           if conflict_resolution not in CONFLICT_RESOLUTION_CHOICES:
      -        raise ValueError("'conflict_resolution' %s must be one of %s" % (
      -            conflict_resolution, CONFLICT_RESOLUTION_CHOICES
      -        ))
      +        raise InvalidEnumValue('conflict_resolution', conflict_resolution, CONFLICT_RESOLUTION_CHOICES)
           if message_disposition not in MESSAGE_DISPOSITION_CHOICES:
      -        raise ValueError("'message_disposition' %s must be one of %s" % (
      -            message_disposition, MESSAGE_DISPOSITION_CHOICES
      -        ))
      +        raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES)
           if send_meeting_invitations_or_cancellations not in SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES:
      -        raise ValueError("'send_meeting_invitations_or_cancellations' %s must be one of %s" % (
      -            send_meeting_invitations_or_cancellations, SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES
      -        ))
      -    if suppress_read_receipts not in (True, False):
      -        raise ValueError("'suppress_read_receipts' %s must be True or False" % suppress_read_receipts)
      +        raise InvalidEnumValue(
      +            'send_meeting_invitations_or_cancellations', send_meeting_invitations_or_cancellations,
      +            SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES
      +        )
           if message_disposition == SEND_ONLY:
               raise ValueError('Cannot send-only existing objects. Use SendItem service instead')
           return self._elems_to_objs(self._chunked_get_elements(
      @@ -493,56 +298,28 @@ 

      Methods

      def get_payload(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations,
                       suppress_read_receipts):
           # Takes a list of (Item, fieldnames) tuples where 'Item' is a instance of a subclass of Item and 'fieldnames'
      -    # are the attribute names that were updated. Returns the XML for an UpdateItem call.
      -    # an UpdateItem request.
      +    # are the attribute names that were updated.
      +    attrs = dict(
      +        ConflictResolution=conflict_resolution,
      +        MessageDisposition=message_disposition,
      +        SendMeetingInvitationsOrCancellations=send_meeting_invitations_or_cancellations,
      +    )
           if self.account.version.build >= EXCHANGE_2013_SP1:
      -        updateitem = create_element(
      -            'm:%s' % self.SERVICE_NAME,
      -            attrs=OrderedDict([
      -                ('ConflictResolution', conflict_resolution),
      -                ('MessageDisposition', message_disposition),
      -                ('SendMeetingInvitationsOrCancellations', send_meeting_invitations_or_cancellations),
      -                ('SuppressReadReceipts', 'true' if suppress_read_receipts else 'false'),
      -            ])
      -        )
      -    else:
      -        updateitem = create_element(
      -            'm:%s' % self.SERVICE_NAME,
      -            attrs=OrderedDict([
      -                ('ConflictResolution', conflict_resolution),
      -                ('MessageDisposition', message_disposition),
      -                ('SendMeetingInvitationsOrCancellations', send_meeting_invitations_or_cancellations),
      -            ])
      -        )
      -    itemchanges = create_element('m:ItemChanges')
      -    version = self.account.version
      -    for item, fieldnames in items:
      -        if not item.account:
      -            item.account = self.account
      -        if not fieldnames:
      -            raise ValueError('"fieldnames" must not be empty')
      -        itemchange = create_element('t:ItemChange')
      -        set_xml_value(itemchange, to_item_id(item, ItemId, version=version), version=version)
      -        updates = create_element('t:Updates')
      -        for elem in self._get_item_update_elems(item=item, fieldnames=fieldnames):
      -            updates.append(elem)
      -        itemchange.append(updates)
      -        itemchanges.append(itemchange)
      -    if not len(itemchanges):
      -        raise ValueError('"items" must not be empty')
      -    updateitem.append(itemchanges)
      -    return updateitem
      + attrs['SuppressReadReceipts'] = suppress_read_receipts + payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + payload.append(self._changes_elem(target_changes=items)) + return payload

      Inherited members

      @@ -566,7 +343,11 @@

      Index

    • UpdateItem

    • @@ -79,9 +77,7 @@

      Classes

      return self._get_elements(payload=self.get_payload(user_configuration=user_configuration)) def get_payload(self, user_configuration): - updateuserconfiguration = create_element('m:%s' % self.SERVICE_NAME) - set_xml_value(updateuserconfiguration, user_configuration, version=self.account.version) - return updateuserconfiguration + return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), user_configuration, version=self.account.version)

      Ancestors

        @@ -124,9 +120,7 @@

        Methods

        Expand source code
        def get_payload(self, user_configuration):
        -    updateuserconfiguration = create_element('m:%s' % self.SERVICE_NAME)
        -    set_xml_value(updateuserconfiguration, user_configuration, version=self.account.version)
        -    return updateuserconfiguration
        + return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), user_configuration, version=self.account.version) diff --git a/docs/exchangelib/services/upload_items.html b/docs/exchangelib/services/upload_items.html index b61e22ed..8a527c4d 100644 --- a/docs/exchangelib/services/upload_items.html +++ b/docs/exchangelib/services/upload_items.html @@ -36,7 +36,7 @@

        Module exchangelib.services.upload_items

        """ SERVICE_NAME = 'UploadItems' - element_container_name = '{%s}ItemId' % MNS + element_container_name = f'{{{MNS}}}ItemId' def call(self, items): # _pool_requests expects 'items', not 'data' @@ -52,29 +52,24 @@

        Module exchangelib.services.upload_items

        :param items: """ - uploaditems = create_element('m:%s' % self.SERVICE_NAME) - itemselement = create_element('m:Items') - uploaditems.append(itemselement) + payload = create_element(f'm:{self.SERVICE_NAME}') + items_elem = create_element('m:Items') + payload.append(items_elem) for parent_folder, (item_id, is_associated, data_str) in items: # TODO: The full spec also allows the "UpdateOrCreate" create action. - item = create_element('t:Item', attrs=dict(CreateAction='Update' if item_id else 'CreateNew')) + attrs = dict(CreateAction='Update' if item_id else 'CreateNew') if is_associated is not None: - item.set('IsAssociated', 'true' if is_associated else 'false') - parentfolderid = ParentFolderId(parent_folder.id, parent_folder.changekey) - set_xml_value(item, parentfolderid, version=self.account.version) + attrs['IsAssociated'] = is_associated + item = create_element('t:Item', attrs=attrs) + set_xml_value(item, ParentFolderId(parent_folder.id, parent_folder.changekey), version=self.account.version) if item_id: - itemid = to_item_id(item_id, ItemId, version=self.account.version) - set_xml_value(item, itemid, version=self.account.version) + set_xml_value(item, to_item_id(item_id, ItemId), version=self.account.version) add_xml_child(item, 't:Data', data_str) - itemselement.append(item) - return uploaditems + items_elem.append(item) + return payload - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield elem.get(ItemId.ID_ATTR), elem.get(ItemId.CHANGEKEY_ATTR) + def _elem_to_obj(self, elem): + return elem.get(ItemId.ID_ATTR), elem.get(ItemId.CHANGEKEY_ATTR) @classmethod def _get_elements_in_container(cls, container): @@ -105,7 +100,7 @@

        Classes

        """ SERVICE_NAME = 'UploadItems' - element_container_name = '{%s}ItemId' % MNS + element_container_name = f'{{{MNS}}}ItemId' def call(self, items): # _pool_requests expects 'items', not 'data' @@ -121,29 +116,24 @@

        Classes

        :param items: """ - uploaditems = create_element('m:%s' % self.SERVICE_NAME) - itemselement = create_element('m:Items') - uploaditems.append(itemselement) + payload = create_element(f'm:{self.SERVICE_NAME}') + items_elem = create_element('m:Items') + payload.append(items_elem) for parent_folder, (item_id, is_associated, data_str) in items: # TODO: The full spec also allows the "UpdateOrCreate" create action. - item = create_element('t:Item', attrs=dict(CreateAction='Update' if item_id else 'CreateNew')) + attrs = dict(CreateAction='Update' if item_id else 'CreateNew') if is_associated is not None: - item.set('IsAssociated', 'true' if is_associated else 'false') - parentfolderid = ParentFolderId(parent_folder.id, parent_folder.changekey) - set_xml_value(item, parentfolderid, version=self.account.version) + attrs['IsAssociated'] = is_associated + item = create_element('t:Item', attrs=attrs) + set_xml_value(item, ParentFolderId(parent_folder.id, parent_folder.changekey), version=self.account.version) if item_id: - itemid = to_item_id(item_id, ItemId, version=self.account.version) - set_xml_value(item, itemid, version=self.account.version) + set_xml_value(item, to_item_id(item_id, ItemId), version=self.account.version) add_xml_child(item, 't:Data', data_str) - itemselement.append(item) - return uploaditems + items_elem.append(item) + return payload - def _elems_to_objs(self, elems): - for elem in elems: - if isinstance(elem, Exception): - yield elem - continue - yield elem.get(ItemId.ID_ATTR), elem.get(ItemId.CHANGEKEY_ATTR) + def _elem_to_obj(self, elem): + return elem.get(ItemId.ID_ATTR), elem.get(ItemId.CHANGEKEY_ATTR) @classmethod def _get_elements_in_container(cls, container): @@ -205,22 +195,21 @@

        Methods

        :param items: """ - uploaditems = create_element('m:%s' % self.SERVICE_NAME) - itemselement = create_element('m:Items') - uploaditems.append(itemselement) + payload = create_element(f'm:{self.SERVICE_NAME}') + items_elem = create_element('m:Items') + payload.append(items_elem) for parent_folder, (item_id, is_associated, data_str) in items: # TODO: The full spec also allows the "UpdateOrCreate" create action. - item = create_element('t:Item', attrs=dict(CreateAction='Update' if item_id else 'CreateNew')) + attrs = dict(CreateAction='Update' if item_id else 'CreateNew') if is_associated is not None: - item.set('IsAssociated', 'true' if is_associated else 'false') - parentfolderid = ParentFolderId(parent_folder.id, parent_folder.changekey) - set_xml_value(item, parentfolderid, version=self.account.version) + attrs['IsAssociated'] = is_associated + item = create_element('t:Item', attrs=attrs) + set_xml_value(item, ParentFolderId(parent_folder.id, parent_folder.changekey), version=self.account.version) if item_id: - itemid = to_item_id(item_id, ItemId, version=self.account.version) - set_xml_value(item, itemid, version=self.account.version) + set_xml_value(item, to_item_id(item_id, ItemId), version=self.account.version) add_xml_child(item, 't:Data', data_str) - itemselement.append(item) - return uploaditems + items_elem.append(item) + return payload diff --git a/docs/exchangelib/settings.html b/docs/exchangelib/settings.html index 74410c0e..66bce215 100644 --- a/docs/exchangelib/settings.html +++ b/docs/exchangelib/settings.html @@ -57,13 +57,13 @@

        Module exchangelib.settings

        super().clean(version=version) if self.state == self.SCHEDULED: if not self.start or not self.end: - raise ValueError("'start' and 'end' must be set when state is '%s'" % self.SCHEDULED) + raise ValueError(f"'start' and 'end' must be set when state is {self.SCHEDULED!r}") if self.start >= self.end: raise ValueError("'start' must be before 'end'") if self.end < datetime.datetime.now(tz=UTC): raise ValueError("'end' must be in the future") if self.state != self.DISABLED and (not self.internal_reply or not self.external_reply): - raise ValueError("'internal_reply' and 'external_reply' must be set when state is not '%s'" % self.DISABLED) + raise ValueError(f"'internal_reply' and 'external_reply' must be set when state is not {self.DISABLED!r}") @classmethod def from_xml(cls, elem, account): @@ -77,28 +77,26 @@

        Module exchangelib.settings

        def to_xml(self, version): self.clean(version=version) - elem = create_element('t:%s' % self.REQUEST_ELEMENT_NAME) + elem = create_element(f't:{self.REQUEST_ELEMENT_NAME}') for attr in ('state', 'external_audience'): value = getattr(self, attr) - if value is None: - continue f = self.get_field_by_fieldname(attr) - set_xml_value(elem, f.to_xml(value, version=version), version=version) + set_xml_value(elem, f.to_xml(value, version=version)) if self.start or self.end: duration = create_element('t:Duration') if self.start: f = self.get_field_by_fieldname('start') - set_xml_value(duration, f.to_xml(self.start, version=version), version) + set_xml_value(duration, f.to_xml(self.start, version=version)) if self.end: f = self.get_field_by_fieldname('end') - set_xml_value(duration, f.to_xml(self.end, version=version), version) + set_xml_value(duration, f.to_xml(self.end, version=version)) elem.append(duration) for attr in ('internal_reply', 'external_reply'): value = getattr(self, attr) if value is None: value = '' # The value can be empty, but the XML element must always be present f = self.get_field_by_fieldname(attr) - set_xml_value(elem, f.to_xml(value, version=version), version) + set_xml_value(elem, f.to_xml(value, version=version)) return elem def __hash__(self): @@ -156,13 +154,13 @@

        Classes

        super().clean(version=version) if self.state == self.SCHEDULED: if not self.start or not self.end: - raise ValueError("'start' and 'end' must be set when state is '%s'" % self.SCHEDULED) + raise ValueError(f"'start' and 'end' must be set when state is {self.SCHEDULED!r}") if self.start >= self.end: raise ValueError("'start' must be before 'end'") if self.end < datetime.datetime.now(tz=UTC): raise ValueError("'end' must be in the future") if self.state != self.DISABLED and (not self.internal_reply or not self.external_reply): - raise ValueError("'internal_reply' and 'external_reply' must be set when state is not '%s'" % self.DISABLED) + raise ValueError(f"'internal_reply' and 'external_reply' must be set when state is not {self.DISABLED!r}") @classmethod def from_xml(cls, elem, account): @@ -176,28 +174,26 @@

        Classes

        def to_xml(self, version): self.clean(version=version) - elem = create_element('t:%s' % self.REQUEST_ELEMENT_NAME) + elem = create_element(f't:{self.REQUEST_ELEMENT_NAME}') for attr in ('state', 'external_audience'): value = getattr(self, attr) - if value is None: - continue f = self.get_field_by_fieldname(attr) - set_xml_value(elem, f.to_xml(value, version=version), version=version) + set_xml_value(elem, f.to_xml(value, version=version)) if self.start or self.end: duration = create_element('t:Duration') if self.start: f = self.get_field_by_fieldname('start') - set_xml_value(duration, f.to_xml(self.start, version=version), version) + set_xml_value(duration, f.to_xml(self.start, version=version)) if self.end: f = self.get_field_by_fieldname('end') - set_xml_value(duration, f.to_xml(self.end, version=version), version) + set_xml_value(duration, f.to_xml(self.end, version=version)) elem.append(duration) for attr in ('internal_reply', 'external_reply'): value = getattr(self, attr) if value is None: value = '' # The value can be empty, but the XML element must always be present f = self.get_field_by_fieldname(attr) - set_xml_value(elem, f.to_xml(value, version=version), version) + set_xml_value(elem, f.to_xml(value, version=version)) return elem def __hash__(self): @@ -312,13 +308,13 @@

        Methods

        super().clean(version=version) if self.state == self.SCHEDULED: if not self.start or not self.end: - raise ValueError("'start' and 'end' must be set when state is '%s'" % self.SCHEDULED) + raise ValueError(f"'start' and 'end' must be set when state is {self.SCHEDULED!r}") if self.start >= self.end: raise ValueError("'start' must be before 'end'") if self.end < datetime.datetime.now(tz=UTC): raise ValueError("'end' must be in the future") if self.state != self.DISABLED and (not self.internal_reply or not self.external_reply): - raise ValueError("'internal_reply' and 'external_reply' must be set when state is not '%s'" % self.DISABLED) + raise ValueError(f"'internal_reply' and 'external_reply' must be set when state is not {self.DISABLED!r}")
        @@ -332,28 +328,26 @@

        Methods

        def to_xml(self, version):
             self.clean(version=version)
        -    elem = create_element('t:%s' % self.REQUEST_ELEMENT_NAME)
        +    elem = create_element(f't:{self.REQUEST_ELEMENT_NAME}')
             for attr in ('state', 'external_audience'):
                 value = getattr(self, attr)
        -        if value is None:
        -            continue
                 f = self.get_field_by_fieldname(attr)
        -        set_xml_value(elem, f.to_xml(value, version=version), version=version)
        +        set_xml_value(elem, f.to_xml(value, version=version))
             if self.start or self.end:
                 duration = create_element('t:Duration')
                 if self.start:
                     f = self.get_field_by_fieldname('start')
        -            set_xml_value(duration, f.to_xml(self.start, version=version), version)
        +            set_xml_value(duration, f.to_xml(self.start, version=version))
                 if self.end:
                     f = self.get_field_by_fieldname('end')
        -            set_xml_value(duration, f.to_xml(self.end, version=version), version)
        +            set_xml_value(duration, f.to_xml(self.end, version=version))
                 elem.append(duration)
             for attr in ('internal_reply', 'external_reply'):
                 value = getattr(self, attr)
                 if value is None:
                     value = ''  # The value can be empty, but the XML element must always be present
                 f = self.get_field_by_fieldname(attr)
        -        set_xml_value(elem, f.to_xml(value, version=version), version)
        +        set_xml_value(elem, f.to_xml(value, version=version))
             return elem
        diff --git a/docs/exchangelib/transport.html b/docs/exchangelib/transport.html index b3a5d546..a289c55e 100644 --- a/docs/exchangelib/transport.html +++ b/docs/exchangelib/transport.html @@ -35,7 +35,7 @@

        Module exchangelib.transport

        from .errors import UnauthorizedError, TransportError from .util import create_element, add_xml_child, xml_to_str, ns_translation, _back_off_if_needed, \ - _retry_after, DummyResponse, CONNECTION_ERRORS + _retry_after, DummyResponse, CONNECTION_ERRORS, RETRY_WAIT log = logging.getLogger(__name__) @@ -74,10 +74,10 @@

        Module exchangelib.transport

        pass DEFAULT_ENCODING = 'utf-8' -DEFAULT_HEADERS = {'Content-Type': 'text/xml; charset=%s' % DEFAULT_ENCODING, 'Accept-Encoding': 'gzip, deflate'} +DEFAULT_HEADERS = {'Content-Type': f'text/xml; charset={DEFAULT_ENCODING}', 'Accept-Encoding': 'gzip, deflate'} -def wrap(content, api_version, account_to_impersonate=None, timezone=None): +def wrap(content, api_version=None, account_to_impersonate=None, timezone=None): """Generate the necessary boilerplate XML for a raw SOAP request. The XML is specific to the server version. ExchangeImpersonation allows to act as the user we want to impersonate. @@ -97,11 +97,12 @@

        Module exchangelib.transport

        """ envelope = create_element('s:Envelope', nsmap=ns_translation) header = create_element('s:Header') - requestserverversion = create_element('t:RequestServerVersion', attrs=dict(Version=api_version)) - header.append(requestserverversion) + if api_version: + request_server_version = create_element('t:RequestServerVersion', attrs=dict(Version=api_version)) + header.append(request_server_version) if account_to_impersonate: - exchangeimpersonation = create_element('t:ExchangeImpersonation') - connectingsid = create_element('t:ConnectingSID') + exchange_impersonation = create_element('t:ExchangeImpersonation') + connecting_sid = create_element('t:ConnectingSID') # We have multiple options for uniquely identifying the user. Here's a prioritized list in accordance with # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/connectingsid for attr, tag in ( @@ -112,16 +113,17 @@

        Module exchangelib.transport

        ): val = getattr(account_to_impersonate, attr) if val: - add_xml_child(connectingsid, 't:%s' % tag, val) + add_xml_child(connecting_sid, f't:{tag}', val) break - exchangeimpersonation.append(connectingsid) - header.append(exchangeimpersonation) + exchange_impersonation.append(connecting_sid) + header.append(exchange_impersonation) if timezone: - timezonecontext = create_element('t:TimeZoneContext') - timezonedefinition = create_element('t:TimeZoneDefinition', attrs=dict(Id=timezone.ms_id)) - timezonecontext.append(timezonedefinition) - header.append(timezonecontext) - envelope.append(header) + timezone_context = create_element('t:TimeZoneContext') + timezone_definition = create_element('t:TimeZoneDefinition', attrs=dict(Id=timezone.ms_id)) + timezone_context.append(timezone_definition) + header.append(timezone_context) + if len(header): + envelope.append(header) body = create_element('s:Body') body.append(content) envelope.append(body) @@ -154,7 +156,6 @@

        Module exchangelib.transport

        # respond when given a valid request. Try all known versions. Gross. from .protocol import BaseProtocol retry = 0 - wait = 10 # seconds t_start = time.monotonic() headers = DEFAULT_HEADERS.copy() for api_version in api_versions: @@ -172,9 +173,9 @@

        Module exchangelib.transport

        except CONNECTION_ERRORS as e: # Don't retry on TLS errors. They will most likely be persistent. total_wait = time.monotonic() - t_start - r = DummyResponse(url=service_endpoint, headers={}, request_headers=headers) + r = DummyResponse(url=service_endpoint, request_headers=headers) if retry_policy.may_retry_on_error(response=r, wait=total_wait): - wait = _retry_after(r, wait) + wait = _retry_after(r, RETRY_WAIT) log.info("Connection error on URL %s (retry %s, error: %s). Cool down %s secs", service_endpoint, retry, e, wait) retry_policy.back_off(wait) @@ -364,7 +365,6 @@

        Functions

        # respond when given a valid request. Try all known versions. Gross. from .protocol import BaseProtocol retry = 0 - wait = 10 # seconds t_start = time.monotonic() headers = DEFAULT_HEADERS.copy() for api_version in api_versions: @@ -382,9 +382,9 @@

        Functions

        except CONNECTION_ERRORS as e: # Don't retry on TLS errors. They will most likely be persistent. total_wait = time.monotonic() - t_start - r = DummyResponse(url=service_endpoint, headers={}, request_headers=headers) + r = DummyResponse(url=service_endpoint, request_headers=headers) if retry_policy.may_retry_on_error(response=r, wait=total_wait): - wait = _retry_after(r, wait) + wait = _retry_after(r, RETRY_WAIT) log.info("Connection error on URL %s (retry %s, error: %s). Cool down %s secs", service_endpoint, retry, e, wait) retry_policy.back_off(wait) @@ -404,7 +404,7 @@

        Functions

        -def wrap(content, api_version, account_to_impersonate=None, timezone=None) +def wrap(content, api_version=None, account_to_impersonate=None, timezone=None)

        Generate the necessary boilerplate XML for a raw SOAP request. The XML is specific to the server version. @@ -425,7 +425,7 @@

        Functions

        Expand source code -
        def wrap(content, api_version, account_to_impersonate=None, timezone=None):
        +
        def wrap(content, api_version=None, account_to_impersonate=None, timezone=None):
             """Generate the necessary boilerplate XML for a raw SOAP request. The XML is specific to the server version.
             ExchangeImpersonation allows to act as the user we want to impersonate.
         
        @@ -445,11 +445,12 @@ 

        Functions

        """ envelope = create_element('s:Envelope', nsmap=ns_translation) header = create_element('s:Header') - requestserverversion = create_element('t:RequestServerVersion', attrs=dict(Version=api_version)) - header.append(requestserverversion) + if api_version: + request_server_version = create_element('t:RequestServerVersion', attrs=dict(Version=api_version)) + header.append(request_server_version) if account_to_impersonate: - exchangeimpersonation = create_element('t:ExchangeImpersonation') - connectingsid = create_element('t:ConnectingSID') + exchange_impersonation = create_element('t:ExchangeImpersonation') + connecting_sid = create_element('t:ConnectingSID') # We have multiple options for uniquely identifying the user. Here's a prioritized list in accordance with # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/connectingsid for attr, tag in ( @@ -460,16 +461,17 @@

        Functions

        ): val = getattr(account_to_impersonate, attr) if val: - add_xml_child(connectingsid, 't:%s' % tag, val) + add_xml_child(connecting_sid, f't:{tag}', val) break - exchangeimpersonation.append(connectingsid) - header.append(exchangeimpersonation) + exchange_impersonation.append(connecting_sid) + header.append(exchange_impersonation) if timezone: - timezonecontext = create_element('t:TimeZoneContext') - timezonedefinition = create_element('t:TimeZoneDefinition', attrs=dict(Id=timezone.ms_id)) - timezonecontext.append(timezonedefinition) - header.append(timezonecontext) - envelope.append(header) + timezone_context = create_element('t:TimeZoneContext') + timezone_definition = create_element('t:TimeZoneDefinition', attrs=dict(Id=timezone.ms_id)) + timezone_context.append(timezone_definition) + header.append(timezone_context) + if len(header): + envelope.append(header) body = create_element('s:Body') body.append(content) envelope.append(body) diff --git a/docs/exchangelib/util.html b/docs/exchangelib/util.html index 7b6edb4a..53292197 100644 --- a/docs/exchangelib/util.html +++ b/docs/exchangelib/util.html @@ -36,7 +36,6 @@

        Module exchangelib.util

        import xml.sax.handler # nosec from base64 import b64decode, b64encode from codecs import BOM_UTF8 -from collections import OrderedDict from decimal import Decimal from functools import wraps from threading import get_ident @@ -52,17 +51,18 @@

        Module exchangelib.util

        from pygments.formatters.terminal import TerminalFormatter from pygments.lexers.html import XmlLexer -from .errors import TransportError, RateLimitError, RedirectError, RelativeRedirect, MalformedResponseError +from .errors import TransportError, RateLimitError, RedirectError, RelativeRedirect, MalformedResponseError, \ + InvalidTypeError log = logging.getLogger(__name__) -xml_log = logging.getLogger('%s.xml' % __name__) +xml_log = logging.getLogger(f'{__name__}.xml') def require_account(f): @wraps(f) def wrapper(self, *args, **kwargs): if not self.account: - raise ValueError('%s must have an account' % self.__class__.__name__) + raise ValueError(f'{self.__class__.__name__} must have an account') return f(self, *args, **kwargs) return wrapper @@ -71,9 +71,9 @@

        Module exchangelib.util

        @wraps(f) def wrapper(self, *args, **kwargs): if not self.account: - raise ValueError('%s must have an account' % self.__class__.__name__) + raise ValueError(f'{self.__class__.__name__} must have an account') if not self.id: - raise ValueError('%s must have an ID' % self.__class__.__name__) + raise ValueError(f'{self.__class__.__name__} must have an ID') return f(self, *args, **kwargs) return wrapper @@ -102,11 +102,11 @@

        Module exchangelib.util

        AUTODISCOVER_REQUEST_NS = 'http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006' AUTODISCOVER_RESPONSE_NS = 'http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a' -ns_translation = OrderedDict([ - ('s', SOAPNS), - ('m', MNS), - ('t', TNS), -]) +ns_translation = { + 's': SOAPNS, + 'm': MNS, + 't': TNS, +} for item in ns_translation.items(): lxml.etree.register_namespace(*item) @@ -181,7 +181,7 @@

        Module exchangelib.util

        :return: """ if xml_declaration and not encoding: - raise ValueError("'xml_declaration' is not supported when 'encoding' is None") + raise AttributeError("'xml_declaration' is not supported when 'encoding' is None") if encoding: return lxml.etree.tostring(tree, encoding=encoding, xml_declaration=True) return lxml.etree.tostring(tree, encoding=str, xml_declaration=False) @@ -195,7 +195,7 @@

        Module exchangelib.util

        def get_xml_attrs(tree, name): - return list(elem.text for elem in tree.findall(name) if elem.text is not None) + return [elem.text for elem in tree.findall(name) if elem.text is not None] def value_to_xml_text(value): @@ -231,7 +231,7 @@

        Module exchangelib.util

        return value.id if isinstance(value, AssociatedCalendarItemId): return value.id - raise TypeError('Unsupported type: %s (%s)' % (type(value), value)) + raise TypeError(f'Unsupported type: {type(value)} ({value})') def xml_text_to_value(value, value_type): @@ -258,7 +258,7 @@

        Module exchangelib.util

        }[value_type](value) -def set_xml_value(elem, value, version): +def set_xml_value(elem, value, version=None): from .ewsdatetime import EWSDateTime, EWSDate from .fields import FieldPath, FieldOrder from .properties import EWSElement @@ -267,28 +267,17 @@

        Module exchangelib.util

        elem.text = value_to_xml_text(value) elif isinstance(value, _element_class): elem.append(value) - elif is_iterable(value, generators_allowed=True): - for v in value: - if isinstance(v, (FieldPath, FieldOrder)): - elem.append(v.to_xml()) - elif isinstance(v, EWSElement): - if not isinstance(version, Version): - raise ValueError("'version' %r must be a Version instance" % version) - elem.append(v.to_xml(version=version)) - elif isinstance(v, _element_class): - elem.append(v) - elif isinstance(v, str): - add_xml_child(elem, 't:String', v) - else: - raise ValueError('Unsupported type %s for list element %s on elem %s' % (type(v), v, elem)) elif isinstance(value, (FieldPath, FieldOrder)): elem.append(value.to_xml()) elif isinstance(value, EWSElement): if not isinstance(version, Version): - raise ValueError("'version' %r must be a Version instance" % version) + raise InvalidTypeError('version', version, Version) elem.append(value.to_xml(version=version)) + elif is_iterable(value, generators_allowed=True): + for v in value: + set_xml_value(elem, v, version=version) else: - raise ValueError('Unsupported type %s for value %s on elem %s' % (type(value), value, elem)) + raise ValueError(f'Unsupported type {type(value)} for value {value} on elem {elem}') return elem @@ -297,15 +286,18 @@

        Module exchangelib.util

        def create_element(name, attrs=None, nsmap=None): - # Python versions prior to 3.6 do not preserve dict or kwarg ordering, so we cannot pull in attrs as **kwargs if we - # also want stable XML attribute output. Instead, let callers supply us with an OrderedDict instance. if ':' in name: ns, name = name.split(':') - name = '{%s}%s' % (ns_translation[ns], name) + name = f'{{{ns_translation[ns]}}}{name}' elem = _forgiving_parser.makeelement(name, nsmap=nsmap) if attrs: # Try hard to keep attribute order, to ensure deterministic output. This simplifies testing. + # Dicts in Python 3.6+ have stable ordering. for k, v in attrs.items(): + if isinstance(v, bool): + v = 'true' if v else 'false' + elif isinstance(v, int): + v = str(v) elem.set(k, v) return elem @@ -313,7 +305,7 @@

        Module exchangelib.util

        def add_xml_child(tree, name, value): # We're calling add_xml_child many places where we don't have the version handy. Don't pass EWSElement or list of # EWSElement to this function! - tree.append(set_xml_value(elem=create_element(name), value=value, version=None)) + tree.append(set_xml_value(elem=create_element(name), value=value)) class StreamingContentHandler(xml.sax.handler.ContentHandler): @@ -479,10 +471,12 @@

        Module exchangelib.util

        def __init__(self, content_iterator, document_tag='Envelope'): self._iterator = content_iterator - self._start_token = b'<%s' % document_tag.encode('utf-8') - self._end_token = b'/%s>' % document_tag.encode('utf-8') + self._document_tag = document_tag.encode() - def get_tag(self, stop_byte): + def _get_tag(self): + """Iterate over the bytes until we have a full tag in the buffer. If there's a '>' in an attr value, then we'll + exit on that, but it's OK becaus wejust need the plain tag name later. + """ tag_buffer = [b'<'] while True: try: @@ -490,10 +484,20 @@

        Module exchangelib.util

        except StopIteration: break tag_buffer.append(c) - if c == stop_byte: + if c == b'>': break return b''.join(tag_buffer) + @staticmethod + def _normalize_tag(tag): + """Returns the plain tag name given a range of tag formats: + * <tag> + * <ns:tag> + * <ns:tag foo='bar'> + * </ns:tag foo='bar'> + """ + return tag.strip(b'<>/').split(b' ')[0].split(b':')[-1] + def __iter__(self): """Consumes the content iterator, looking for start and end tags. Returns each document when we have fully collected it. @@ -504,17 +508,17 @@

        Module exchangelib.util

        while True: c = next(self._iterator) if not doc_started and c == b'<': - tag = self.get_tag(stop_byte=b' ') - if tag.startswith(self._start_token): + tag = self._get_tag() + if self._normalize_tag(tag) == self._document_tag: # Start of document. Collect bytes from this point buffer.append(tag) doc_started = True elif doc_started and c == b'<': - tag = self.get_tag(stop_byte=b'>') + tag = self._get_tag() buffer.append(tag) - if tag.endswith(self._end_token): + if self._normalize_tag(tag) == self._document_tag: # End of document. Yield a valid document and reset the buffer - yield b"<?xml version='1.0' encoding='utf-8'?>\n%s" % b''.join(buffer) + yield b"<?xml version='1.0' encoding='utf-8'?>\n" + b''.join(buffer) doc_started = False buffer = [] elif doc_started: @@ -546,19 +550,19 @@

        Module exchangelib.util

        raise ParseError(str(e), '<not from file>', e.lineno, e.offset) else: offending_excerpt = offending_line[max(0, e.offset - 20):e.offset + 20] - msg = '%s\nOffending text: [...]%s[...]' % (str(e), offending_excerpt) + msg = f'{e}\nOffending text: [...]{offending_excerpt.decode("utf-8", errors="ignore")}[...]' raise ParseError(msg, '<not from file>', e.lineno, e.offset) except TypeError: try: stream.seek(0) except (IndexError, io.UnsupportedOperation): pass - raise ParseError('This is not XML: %r' % stream.read(), '<not from file>', -1, 0) + raise ParseError(f'This is not XML: {stream.read()!r}', '<not from file>', -1, 0) if res.getroot() is None: try: stream.seek(0) - msg = 'No root element found: %r' % stream.read() + msg = f'No root element found: {stream.read()!r}' except (IndexError, io.UnsupportedOperation): msg = 'No root element found' raise ParseError(msg, '<not from file>', -1, 0) @@ -587,7 +591,7 @@

        Module exchangelib.util

        @staticmethod def parse_bytes(xml_bytes): - return lxml.etree.parse(io.BytesIO(xml_bytes), parser=_forgiving_parser) # nosec + return to_xml(xml_bytes) @classmethod def prettify_xml(cls, xml_bytes): @@ -624,7 +628,7 @@

        Module exchangelib.util

        record.args[key] = self.highlight_xml(self.prettify_xml(value)) except Exception as e: # Something bad happened, but we don't want to crash the program just because logging failed - print('XML highlighting failed: %s' % e) + print(f'XML highlighting failed: {e}') return super().emit(record) def is_tty(self): @@ -644,34 +648,37 @@

        Module exchangelib.util

        super().__init__(*args, **kwargs) def parse_bytes(self, xml_bytes): - root = lxml.etree.parse(io.BytesIO(xml_bytes), parser=_forgiving_parser) # nosec + root = to_xml(xml_bytes) for elem in root.iter(): # Anonymize element attribute values known to contain private data for attr in set(elem.keys()) & self.PRIVATE_TAGS: elem.set(attr, 'DEADBEEF=') # Anonymize anything requested by the caller for s in self.forbidden_strings: - elem.text.replace(s, '[REMOVED]') + if elem.text is not None: + elem.text = elem.text.replace(s, '[REMOVED]') return root class DummyRequest: """A class to fake a requests Request object for functions that expect this.""" - def __init__(self, headers): - self.headers = headers + def __init__(self, headers=None): + self.headers = headers or {} class DummyResponse: """A class to fake a requests Response object for functions that expect this.""" - def __init__(self, url, headers, request_headers, content=b'', status_code=503, history=None): + def __init__(self, url=None, headers=None, request_headers=None, content=b'', status_code=503, streaming=False, + history=None): self.status_code = status_code self.url = url - self.headers = headers - self.content = content + self.headers = headers or {} + self.content = iter((bytes([b]) for b in content)) if streaming else content self.text = content.decode('utf-8', errors='ignore') self.request = DummyRequest(headers=request_headers) + self.reason = '' self.history = history def iter_content(self): @@ -685,7 +692,7 @@

        Module exchangelib.util

        try: return email.split('@')[1].lower() except (IndexError, AttributeError): - raise ValueError("'%s' is not a valid email" % email) + raise ValueError(f"{email!r} is not a valid email") def split_url(url): @@ -697,7 +704,7 @@

        Module exchangelib.util

        def get_redirect_url(response, allow_relative=True, require_relative=False): # allow_relative=False throws RelativeRedirect error if scheme and hostname are equal to the request # require_relative=True throws RelativeRedirect error if scheme and hostname are not equal to the request - redirect_url = response.headers.get('location', None) + redirect_url = response.headers.get('location') if not redirect_url: raise TransportError('HTTP redirect but no location header') # At least some servers are kind enough to supply a new location. It may be relative @@ -714,10 +721,10 @@

        Module exchangelib.util

        if not redirect_path.startswith('/'): # The path is not top-level. Add response path redirect_path = (response_path or '/') + redirect_path - redirect_url = '%s://%s%s' % ('https' if redirect_has_ssl else 'http', redirect_server, redirect_path) + redirect_url = f"{'https' if redirect_has_ssl else 'http'}://{redirect_server}{redirect_path}" if redirect_url == request_url: # And some are mean enough to redirect to the same location - raise TransportError('Redirect to same location: %s' % redirect_url) + raise TransportError(f'Redirect to same location: {redirect_url}') if not allow_relative and (request_has_ssl == response_has_ssl and request_server == redirect_server): raise RelativeRedirect(redirect_url) if require_relative and (request_has_ssl != response_has_ssl or request_server != redirect_server): @@ -833,7 +840,7 @@

        Module exchangelib.util

        thread_id, retry, timeout, url, wait) d_start = time.monotonic() # Always create a dummy response for logging purposes, in case we fail in the following - r = DummyResponse(url=url, headers={}, request_headers=headers) + r = DummyResponse(url=url, request_headers=headers) try: r = session.post(url=url, headers=headers, data=data, allow_redirects=False, timeout=timeout, stream=stream) @@ -937,7 +944,7 @@

        Module exchangelib.util

        log.debug("'allow_redirects' only supports relative redirects (%s -> %s)", response.url, e.value) raise RedirectError(url=e.value) if not allow_redirects: - raise TransportError('Redirect not allowed but we were redirected (%s -> %s)' % (response.url, redirect_url)) + raise TransportError(f'Redirect not allowed but we were redirected ({response.url} -> {redirect_url})') log.debug('HTTP redirected to %s', redirect_url) redirects += 1 if redirects > MAX_REDIRECTS: @@ -976,7 +983,7 @@

        Functions

        def add_xml_child(tree, name, value):
             # We're calling add_xml_child many places where we don't have the version handy. Don't pass EWSElement or list of
             # EWSElement to this function!
        -    tree.append(set_xml_value(elem=create_element(name), value=value, version=None))
        + tree.append(set_xml_value(elem=create_element(name), value=value))
        @@ -1025,15 +1032,18 @@

        Functions

        Expand source code
        def create_element(name, attrs=None, nsmap=None):
        -    # Python versions prior to 3.6 do not preserve dict or kwarg ordering, so we cannot pull in attrs as **kwargs if we
        -    # also want stable XML attribute output. Instead, let callers supply us with an OrderedDict instance.
             if ':' in name:
                 ns, name = name.split(':')
        -        name = '{%s}%s' % (ns_translation[ns], name)
        +        name = f'{{{ns_translation[ns]}}}{name}'
             elem = _forgiving_parser.makeelement(name, nsmap=nsmap)
             if attrs:
                 # Try hard to keep attribute order, to ensure deterministic output. This simplifies testing.
        +        # Dicts in Python 3.6+ have stable ordering.
                 for k, v in attrs.items():
        +            if isinstance(v, bool):
        +                v = 'true' if v else 'false'
        +            elif isinstance(v, int):
        +                v = str(v)
                     elem.set(k, v)
             return elem
        @@ -1051,7 +1061,7 @@

        Functions

        try: return email.split('@')[1].lower() except (IndexError, AttributeError): - raise ValueError("'%s' is not a valid email" % email)
        + raise ValueError(f"{email!r} is not a valid email")
        @@ -1066,7 +1076,7 @@

        Functions

        def get_redirect_url(response, allow_relative=True, require_relative=False):
             # allow_relative=False throws RelativeRedirect error if scheme and hostname are equal to the request
             # require_relative=True throws RelativeRedirect error if scheme and hostname are not equal to the request
        -    redirect_url = response.headers.get('location', None)
        +    redirect_url = response.headers.get('location')
             if not redirect_url:
                 raise TransportError('HTTP redirect but no location header')
             # At least some servers are kind enough to supply a new location. It may be relative
        @@ -1083,10 +1093,10 @@ 

        Functions

        if not redirect_path.startswith('/'): # The path is not top-level. Add response path redirect_path = (response_path or '/') + redirect_path - redirect_url = '%s://%s%s' % ('https' if redirect_has_ssl else 'http', redirect_server, redirect_path) + redirect_url = f"{'https' if redirect_has_ssl else 'http'}://{redirect_server}{redirect_path}" if redirect_url == request_url: # And some are mean enough to redirect to the same location - raise TransportError('Redirect to same location: %s' % redirect_url) + raise TransportError(f'Redirect to same location: {redirect_url}') if not allow_relative and (request_has_ssl == response_has_ssl and request_server == redirect_server): raise RelativeRedirect(redirect_url) if require_relative and (request_has_ssl != response_has_ssl or request_server != redirect_server): @@ -1120,7 +1130,7 @@

        Functions

        Expand source code
        def get_xml_attrs(tree, name):
        -    return list(elem.text for elem in tree.findall(name) if elem.text is not None)
        + return [elem.text for elem in tree.findall(name) if elem.text is not None]
        @@ -1342,7 +1352,7 @@

        Functions

        thread_id, retry, timeout, url, wait) d_start = time.monotonic() # Always create a dummy response for logging purposes, in case we fail in the following - r = DummyResponse(url=url, headers={}, request_headers=headers) + r = DummyResponse(url=url, request_headers=headers) try: r = session.post(url=url, headers=headers, data=data, allow_redirects=False, timeout=timeout, stream=stream) @@ -1451,7 +1461,7 @@

        Functions

        @wraps(f) def wrapper(self, *args, **kwargs): if not self.account: - raise ValueError('%s must have an account' % self.__class__.__name__) + raise ValueError(f'{self.__class__.__name__} must have an account') return f(self, *args, **kwargs) return wrapper
        @@ -1469,9 +1479,9 @@

        Functions

        @wraps(f) def wrapper(self, *args, **kwargs): if not self.account: - raise ValueError('%s must have an account' % self.__class__.__name__) + raise ValueError(f'{self.__class__.__name__} must have an account') if not self.id: - raise ValueError('%s must have an ID' % self.__class__.__name__) + raise ValueError(f'{self.__class__.__name__} must have an ID') return f(self, *args, **kwargs) return wrapper
        @@ -1512,7 +1522,7 @@

        Functions

        -def set_xml_value(elem, value, version) +def set_xml_value(elem, value, version=None)
        @@ -1520,7 +1530,7 @@

        Functions

        Expand source code -
        def set_xml_value(elem, value, version):
        +
        def set_xml_value(elem, value, version=None):
             from .ewsdatetime import EWSDateTime, EWSDate
             from .fields import FieldPath, FieldOrder
             from .properties import EWSElement
        @@ -1529,28 +1539,17 @@ 

        Functions

        elem.text = value_to_xml_text(value) elif isinstance(value, _element_class): elem.append(value) - elif is_iterable(value, generators_allowed=True): - for v in value: - if isinstance(v, (FieldPath, FieldOrder)): - elem.append(v.to_xml()) - elif isinstance(v, EWSElement): - if not isinstance(version, Version): - raise ValueError("'version' %r must be a Version instance" % version) - elem.append(v.to_xml(version=version)) - elif isinstance(v, _element_class): - elem.append(v) - elif isinstance(v, str): - add_xml_child(elem, 't:String', v) - else: - raise ValueError('Unsupported type %s for list element %s on elem %s' % (type(v), v, elem)) elif isinstance(value, (FieldPath, FieldOrder)): elem.append(value.to_xml()) elif isinstance(value, EWSElement): if not isinstance(version, Version): - raise ValueError("'version' %r must be a Version instance" % version) + raise InvalidTypeError('version', version, Version) elem.append(value.to_xml(version=version)) + elif is_iterable(value, generators_allowed=True): + for v in value: + set_xml_value(elem, v, version=version) else: - raise ValueError('Unsupported type %s for value %s on elem %s' % (type(value), value, elem)) + raise ValueError(f'Unsupported type {type(value)} for value {value} on elem {elem}') return elem
        @@ -1601,19 +1600,19 @@

        Functions

        raise ParseError(str(e), '<not from file>', e.lineno, e.offset) else: offending_excerpt = offending_line[max(0, e.offset - 20):e.offset + 20] - msg = '%s\nOffending text: [...]%s[...]' % (str(e), offending_excerpt) + msg = f'{e}\nOffending text: [...]{offending_excerpt.decode("utf-8", errors="ignore")}[...]' raise ParseError(msg, '<not from file>', e.lineno, e.offset) except TypeError: try: stream.seek(0) except (IndexError, io.UnsupportedOperation): pass - raise ParseError('This is not XML: %r' % stream.read(), '<not from file>', -1, 0) + raise ParseError(f'This is not XML: {stream.read()!r}', '<not from file>', -1, 0) if res.getroot() is None: try: stream.seek(0) - msg = 'No root element found: %r' % stream.read() + msg = f'No root element found: {stream.read()!r}' except (IndexError, io.UnsupportedOperation): msg = 'No root element found' raise ParseError(msg, '<not from file>', -1, 0) @@ -1662,7 +1661,7 @@

        Functions

        return value.id if isinstance(value, AssociatedCalendarItemId): return value.id - raise TypeError('Unsupported type: %s (%s)' % (type(value), value))
        + raise TypeError(f'Unsupported type: {type(value)} ({value})')
        @@ -1722,7 +1721,7 @@

        Functions

        :return: """ if xml_declaration and not encoding: - raise ValueError("'xml_declaration' is not supported when 'encoding' is None") + raise AttributeError("'xml_declaration' is not supported when 'encoding' is None") if encoding: return lxml.etree.tostring(tree, encoding=encoding, xml_declaration=True) return lxml.etree.tostring(tree, encoding=str, xml_declaration=False)
        @@ -1754,14 +1753,15 @@

        Classes

        super().__init__(*args, **kwargs) def parse_bytes(self, xml_bytes): - root = lxml.etree.parse(io.BytesIO(xml_bytes), parser=_forgiving_parser) # nosec + root = to_xml(xml_bytes) for elem in root.iter(): # Anonymize element attribute values known to contain private data for attr in set(elem.keys()) & self.PRIVATE_TAGS: elem.set(attr, 'DEADBEEF=') # Anonymize anything requested by the caller for s in self.forbidden_strings: - elem.text.replace(s, '[REMOVED]') + if elem.text is not None: + elem.text = elem.text.replace(s, '[REMOVED]') return root

        Ancestors

        @@ -1790,14 +1790,15 @@

        Methods

        Expand source code
        def parse_bytes(self, xml_bytes):
        -    root = lxml.etree.parse(io.BytesIO(xml_bytes), parser=_forgiving_parser)  # nosec
        +    root = to_xml(xml_bytes)
             for elem in root.iter():
                 # Anonymize element attribute values known to contain private data
                 for attr in set(elem.keys()) & self.PRIVATE_TAGS:
                     elem.set(attr, 'DEADBEEF=')
                 # Anonymize anything requested by the caller
                 for s in self.forbidden_strings:
        -            elem.text.replace(s, '[REMOVED]')
        +            if elem.text is not None:
        +                elem.text = elem.text.replace(s, '[REMOVED]')
             return root
        @@ -1978,10 +1979,12 @@

        Methods

        def __init__(self, content_iterator, document_tag='Envelope'): self._iterator = content_iterator - self._start_token = b'<%s' % document_tag.encode('utf-8') - self._end_token = b'/%s>' % document_tag.encode('utf-8') + self._document_tag = document_tag.encode() - def get_tag(self, stop_byte): + def _get_tag(self): + """Iterate over the bytes until we have a full tag in the buffer. If there's a '>' in an attr value, then we'll + exit on that, but it's OK becaus wejust need the plain tag name later. + """ tag_buffer = [b'<'] while True: try: @@ -1989,10 +1992,20 @@

        Methods

        except StopIteration: break tag_buffer.append(c) - if c == stop_byte: + if c == b'>': break return b''.join(tag_buffer) + @staticmethod + def _normalize_tag(tag): + """Returns the plain tag name given a range of tag formats: + * <tag> + * <ns:tag> + * <ns:tag foo='bar'> + * </ns:tag foo='bar'> + """ + return tag.strip(b'<>/').split(b' ')[0].split(b':')[-1] + def __iter__(self): """Consumes the content iterator, looking for start and end tags. Returns each document when we have fully collected it. @@ -2003,17 +2016,17 @@

        Methods

        while True: c = next(self._iterator) if not doc_started and c == b'<': - tag = self.get_tag(stop_byte=b' ') - if tag.startswith(self._start_token): + tag = self._get_tag() + if self._normalize_tag(tag) == self._document_tag: # Start of document. Collect bytes from this point buffer.append(tag) doc_started = True elif doc_started and c == b'<': - tag = self.get_tag(stop_byte=b'>') + tag = self._get_tag() buffer.append(tag) - if tag.endswith(self._end_token): + if self._normalize_tag(tag) == self._document_tag: # End of document. Yield a valid document and reset the buffer - yield b"<?xml version='1.0' encoding='utf-8'?>\n%s" % b''.join(buffer) + yield b"<?xml version='1.0' encoding='utf-8'?>\n" + b''.join(buffer) doc_started = False buffer = [] elif doc_started: @@ -2021,35 +2034,10 @@

        Methods

        except StopIteration: return -

        Methods

        -
        -
        -def get_tag(self, stop_byte) -
        -
        -
        -
        - -Expand source code - -
        def get_tag(self, stop_byte):
        -    tag_buffer = [b'<']
        -    while True:
        -        try:
        -            c = next(self._iterator)
        -        except StopIteration:
        -            break
        -        tag_buffer.append(c)
        -        if c == stop_byte:
        -            break
        -    return b''.join(tag_buffer)
        -
        -
        -
        class DummyRequest -(headers) +(headers=None)

        A class to fake a requests Request object for functions that expect this.

        @@ -2060,13 +2048,13 @@

        Methods

        class DummyRequest:
             """A class to fake a requests Request object for functions that expect this."""
         
        -    def __init__(self, headers):
        -        self.headers = headers
        + def __init__(self, headers=None): + self.headers = headers or {}
        class DummyResponse -(url, headers, request_headers, content=b'', status_code=503, history=None) +(url=None, headers=None, request_headers=None, content=b'', status_code=503, streaming=False, history=None)

        A class to fake a requests Response object for functions that expect this.

        @@ -2077,13 +2065,15 @@

        Methods

        class DummyResponse:
             """A class to fake a requests Response object for functions that expect this."""
         
        -    def __init__(self, url, headers, request_headers, content=b'', status_code=503, history=None):
        +    def __init__(self, url=None, headers=None, request_headers=None, content=b'', status_code=503, streaming=False,
        +                 history=None):
                 self.status_code = status_code
                 self.url = url
        -        self.headers = headers
        -        self.content = content
        +        self.headers = headers or {}
        +        self.content = iter((bytes([b]) for b in content)) if streaming else content
                 self.text = content.decode('utf-8', errors='ignore')
                 self.request = DummyRequest(headers=request_headers)
        +        self.reason = ''
                 self.history = history
         
             def iter_content(self):
        @@ -2186,7 +2176,7 @@ 

        Ancestors

        @staticmethod def parse_bytes(xml_bytes): - return lxml.etree.parse(io.BytesIO(xml_bytes), parser=_forgiving_parser) # nosec + return to_xml(xml_bytes) @classmethod def prettify_xml(cls, xml_bytes): @@ -2223,7 +2213,7 @@

        Ancestors

        record.args[key] = self.highlight_xml(self.prettify_xml(value)) except Exception as e: # Something bad happened, but we don't want to crash the program just because logging failed - print('XML highlighting failed: %s' % e) + print(f'XML highlighting failed: {e}') return super().emit(record) def is_tty(self): @@ -2271,7 +2261,7 @@

        Static methods

        @staticmethod
         def parse_bytes(xml_bytes):
        -    return lxml.etree.parse(io.BytesIO(xml_bytes), parser=_forgiving_parser)  # nosec
        + return to_xml(xml_bytes)
        @@ -2330,7 +2320,7 @@

        Methods

        record.args[key] = self.highlight_xml(self.prettify_xml(value)) except Exception as e: # Something bad happened, but we don't want to crash the program just because logging failed - print('XML highlighting failed: %s' % e) + print(f'XML highlighting failed: {e}') return super().emit(record)
        @@ -2640,9 +2630,6 @@

        DocumentYielder

        -
      • DummyRequest

        diff --git a/docs/exchangelib/version.html b/docs/exchangelib/version.html index 4603c8a1..1bdae272 100644 --- a/docs/exchangelib/version.html +++ b/docs/exchangelib/version.html @@ -29,7 +29,7 @@

        Module exchangelib.version

        import logging
         import re
         
        -from .errors import TransportError, ResponseMessageError
        +from .errors import TransportError, ResponseMessageError, InvalidTypeError
         from .util import xml_to_str, TNS
         
         log = logging.getLogger(__name__)
        @@ -94,19 +94,19 @@ 

        Module exchangelib.version

        def __init__(self, major_version, minor_version, major_build=0, minor_build=0): if not isinstance(major_version, int): - raise ValueError("'major_version' must be an integer") + raise InvalidTypeError('major_version', major_version, int) if not isinstance(minor_version, int): - raise ValueError("'minor_version' must be an integer") + raise InvalidTypeError('minor_version', minor_version, int) if not isinstance(major_build, int): - raise ValueError("'major_build' must be an integer") + raise InvalidTypeError('major_build', major_build, int) if not isinstance(minor_build, int): - raise ValueError("'minor_build' must be an integer") + raise InvalidTypeError('minor_build', minor_build, int) self.major_version = major_version self.minor_version = minor_version self.major_build = major_build self.minor_build = minor_build if major_version < 8: - raise ValueError("Exchange major versions below 8 don't support EWS (%s)" % self) + raise ValueError(f"Exchange major versions below 8 don't support EWS ({self})") @classmethod def from_xml(cls, elem): @@ -138,7 +138,7 @@

        Module exchangelib.version

        :param s: """ - bin_s = '{:032b}'.format(int(s, 16)) # Convert string to 32-bit binary string + bin_s = f'{int(s, 16):032b}' # Convert string to 32-bit binary string major_version = int(bin_s[4:10], 2) minor_version = int(bin_s[10:16], 2) build_number = int(bin_s[17:32], 2) @@ -150,7 +150,7 @@

        Module exchangelib.version

        try: return self.API_VERSION_MAP[self.major_version][self.minor_version] except KeyError: - raise ValueError('API version for build %s is unknown' % self) + raise ValueError(f'API version for build {self} is unknown') def fullname(self): return VERSIONS[self.api_version()][1] @@ -190,7 +190,7 @@

        Module exchangelib.version

        return self.__cmp__(other) >= 0 def __str__(self): - return '%s.%s.%s.%s' % (self.major_version, self.minor_version, self.major_build, self.minor_build) + return f'{self.major_version}.{self.minor_version}.{self.major_build}.{self.minor_build}' def __repr__(self): return self.__class__.__name__ \ @@ -216,15 +216,17 @@

        Module exchangelib.version

        __slots__ = 'build', 'api_version' def __init__(self, build, api_version=None): - if not isinstance(build, (Build, type(None))): - raise ValueError("'build' must be a Build instance") - self.build = build if api_version is None: + if not isinstance(build, Build): + raise InvalidTypeError('build', build, Build) self.api_version = build.api_version() else: + if not isinstance(build, (Build, type(None))): + raise InvalidTypeError('build', build, Build) if not isinstance(api_version, str): - raise ValueError("'api_version' must be a string") + raise InvalidTypeError('api_version', api_version, str) self.api_version = api_version + self.build = build @property def fullname(self): @@ -258,10 +260,10 @@

        Module exchangelib.version

        except ResponseMessageError as e: # We may have survived long enough to get a new version if not protocol.config.version.build: - raise TransportError('No valid version headers found in response (%r)' % e) + raise TransportError(f'No valid version headers found in response ({e!r})') if not protocol.config.version.build: raise TransportError('No valid version headers found in response') - return protocol.version + return protocol.config.version @staticmethod def _is_invalid_version_string(version): @@ -270,13 +272,13 @@

        Module exchangelib.version

        @classmethod def from_soap_header(cls, requested_api_version, header): - info = header.find('{%s}ServerVersionInfo' % TNS) + info = header.find(f'{{{TNS}}}ServerVersionInfo') if info is None: - raise TransportError('No ServerVersionInfo in header: %r' % xml_to_str(header)) + raise TransportError(f'No ServerVersionInfo in header: {xml_to_str(header)!r}') try: build = Build.from_xml(elem=info) except ValueError: - raise TransportError('Bad ServerVersionInfo in response: %r' % xml_to_str(header)) + raise TransportError(f'Bad ServerVersionInfo in response: {xml_to_str(header)!r}') # Not all Exchange servers send the Version element api_version_from_server = info.get('Version') or build.api_version() if api_version_from_server != requested_api_version: @@ -292,6 +294,9 @@

        Module exchangelib.version

        api_version_from_server, api_version_from_server) return cls(build=build, api_version=api_version_from_server) + def copy(self): + return self.__class__(build=self.build, api_version=self.api_version) + def __eq__(self, other): if self.api_version != other.api_version: return False @@ -305,7 +310,7 @@

        Module exchangelib.version

        return self.__class__.__name__ + repr((self.build, self.api_version)) def __str__(self): - return 'Build=%s, API=%s, Fullname=%s' % (self.build, self.api_version, self.fullname)
        + return f'Build={self.build}, API={self.api_version}, Fullname={self.fullname}'
      @@ -356,19 +361,19 @@

      Classes

      def __init__(self, major_version, minor_version, major_build=0, minor_build=0): if not isinstance(major_version, int): - raise ValueError("'major_version' must be an integer") + raise InvalidTypeError('major_version', major_version, int) if not isinstance(minor_version, int): - raise ValueError("'minor_version' must be an integer") + raise InvalidTypeError('minor_version', minor_version, int) if not isinstance(major_build, int): - raise ValueError("'major_build' must be an integer") + raise InvalidTypeError('major_build', major_build, int) if not isinstance(minor_build, int): - raise ValueError("'minor_build' must be an integer") + raise InvalidTypeError('minor_build', minor_build, int) self.major_version = major_version self.minor_version = minor_version self.major_build = major_build self.minor_build = minor_build if major_version < 8: - raise ValueError("Exchange major versions below 8 don't support EWS (%s)" % self) + raise ValueError(f"Exchange major versions below 8 don't support EWS ({self})") @classmethod def from_xml(cls, elem): @@ -400,7 +405,7 @@

      Classes

      :param s: """ - bin_s = '{:032b}'.format(int(s, 16)) # Convert string to 32-bit binary string + bin_s = f'{int(s, 16):032b}' # Convert string to 32-bit binary string major_version = int(bin_s[4:10], 2) minor_version = int(bin_s[10:16], 2) build_number = int(bin_s[17:32], 2) @@ -412,7 +417,7 @@

      Classes

      try: return self.API_VERSION_MAP[self.major_version][self.minor_version] except KeyError: - raise ValueError('API version for build %s is unknown' % self) + raise ValueError(f'API version for build {self} is unknown') def fullname(self): return VERSIONS[self.api_version()][1] @@ -452,7 +457,7 @@

      Classes

      return self.__cmp__(other) >= 0 def __str__(self): - return '%s.%s.%s.%s' % (self.major_version, self.minor_version, self.major_build, self.minor_build) + return f'{self.major_version}.{self.minor_version}.{self.major_build}.{self.minor_build}' def __repr__(self): return self.__class__.__name__ \ @@ -498,7 +503,7 @@

      Static methods

      :param s: """ - bin_s = '{:032b}'.format(int(s, 16)) # Convert string to 32-bit binary string + bin_s = f'{int(s, 16):032b}' # Convert string to 32-bit binary string major_version = int(bin_s[4:10], 2) minor_version = int(bin_s[10:16], 2) build_number = int(bin_s[17:32], 2) @@ -568,7 +573,7 @@

      Methods

      try: return self.API_VERSION_MAP[self.major_version][self.minor_version] except KeyError: - raise ValueError('API version for build %s is unknown' % self) + raise ValueError(f'API version for build {self} is unknown')
      @@ -602,15 +607,17 @@

      Methods

      __slots__ = 'build', 'api_version' def __init__(self, build, api_version=None): - if not isinstance(build, (Build, type(None))): - raise ValueError("'build' must be a Build instance") - self.build = build if api_version is None: + if not isinstance(build, Build): + raise InvalidTypeError('build', build, Build) self.api_version = build.api_version() else: + if not isinstance(build, (Build, type(None))): + raise InvalidTypeError('build', build, Build) if not isinstance(api_version, str): - raise ValueError("'api_version' must be a string") + raise InvalidTypeError('api_version', api_version, str) self.api_version = api_version + self.build = build @property def fullname(self): @@ -644,10 +651,10 @@

      Methods

      except ResponseMessageError as e: # We may have survived long enough to get a new version if not protocol.config.version.build: - raise TransportError('No valid version headers found in response (%r)' % e) + raise TransportError(f'No valid version headers found in response ({e!r})') if not protocol.config.version.build: raise TransportError('No valid version headers found in response') - return protocol.version + return protocol.config.version @staticmethod def _is_invalid_version_string(version): @@ -656,13 +663,13 @@

      Methods

      @classmethod def from_soap_header(cls, requested_api_version, header): - info = header.find('{%s}ServerVersionInfo' % TNS) + info = header.find(f'{{{TNS}}}ServerVersionInfo') if info is None: - raise TransportError('No ServerVersionInfo in header: %r' % xml_to_str(header)) + raise TransportError(f'No ServerVersionInfo in header: {xml_to_str(header)!r}') try: build = Build.from_xml(elem=info) except ValueError: - raise TransportError('Bad ServerVersionInfo in response: %r' % xml_to_str(header)) + raise TransportError(f'Bad ServerVersionInfo in response: {xml_to_str(header)!r}') # Not all Exchange servers send the Version element api_version_from_server = info.get('Version') or build.api_version() if api_version_from_server != requested_api_version: @@ -678,6 +685,9 @@

      Methods

      api_version_from_server, api_version_from_server) return cls(build=build, api_version=api_version_from_server) + def copy(self): + return self.__class__(build=self.build, api_version=self.api_version) + def __eq__(self, other): if self.api_version != other.api_version: return False @@ -691,7 +701,7 @@

      Methods

      return self.__class__.__name__ + repr((self.build, self.api_version)) def __str__(self): - return 'Build=%s, API=%s, Fullname=%s' % (self.build, self.api_version, self.fullname)
      + return f'Build={self.build}, API={self.api_version}, Fullname={self.fullname}'

      Static methods

      @@ -706,13 +716,13 @@

      Static methods

      @classmethod
       def from_soap_header(cls, requested_api_version, header):
      -    info = header.find('{%s}ServerVersionInfo' % TNS)
      +    info = header.find(f'{{{TNS}}}ServerVersionInfo')
           if info is None:
      -        raise TransportError('No ServerVersionInfo in header: %r' % xml_to_str(header))
      +        raise TransportError(f'No ServerVersionInfo in header: {xml_to_str(header)!r}')
           try:
               build = Build.from_xml(elem=info)
           except ValueError:
      -        raise TransportError('Bad ServerVersionInfo in response: %r' % xml_to_str(header))
      +        raise TransportError(f'Bad ServerVersionInfo in response: {xml_to_str(header)!r}')
           # Not all Exchange servers send the Version element
           api_version_from_server = info.get('Version') or build.api_version()
           if api_version_from_server != requested_api_version:
      @@ -773,10 +783,10 @@ 

      Static methods

      except ResponseMessageError as e: # We may have survived long enough to get a new version if not protocol.config.version.build: - raise TransportError('No valid version headers found in response (%r)' % e) + raise TransportError(f'No valid version headers found in response ({e!r})') if not protocol.config.version.build: raise TransportError('No valid version headers found in response') - return protocol.version
      + return protocol.config.version
      @@ -803,6 +813,22 @@

      Instance variables

      +

      Methods

      +
      +
      +def copy(self) +
      +
      +
      +
      + +Expand source code + +
      def copy(self):
      +    return self.__class__(build=self.build, api_version=self.api_version)
      +
      +
      +
      @@ -836,9 +862,10 @@

    • Version

      -
        +
        • api_version
        • build
        • +
        • copy
        • from_soap_header
        • fullname
        • guess
        • diff --git a/docs/exchangelib/winzone.html b/docs/exchangelib/winzone.html index 04a54498..2c6b8def 100644 --- a/docs/exchangelib/winzone.html +++ b/docs/exchangelib/winzone.html @@ -5,8 +5,7 @@ exchangelib.winzone API documentation - + @@ -23,14 +22,12 @@

          Module exchangelib.winzone

          -

          A dict to translate from IANA location name to Windows timezone name. Translations taken from -http://unicode.org/repos/cldr/trunk/common/supplemental/windowsZones.xml

          +

          A dict to translate from IANA location name to Windows timezone name. Translations taken from CLDR_WINZONE_URL

          Expand source code -
          """A dict to translate from IANA location name to Windows timezone name. Translations taken from
          -http://unicode.org/repos/cldr/trunk/common/supplemental/windowsZones.xml
          +
          """A dict to translate from IANA location name to Windows timezone name. Translations taken from CLDR_WINZONE_URL
           """
           import re
           
          @@ -52,7 +49,7 @@ 

          Module exchangelib.winzone

          """ r = requests.get(CLDR_WINZONE_URL, timeout=timeout) if r.status_code != 200: - raise ValueError('Unexpected response: %s' % r) + raise ValueError(f'Unexpected response: {r}') tz_map = {} timezones_elem = to_xml(r.content).find('windowsZones').find('mapTimezones') type_version = timezones_elem.get('typeVersion') @@ -62,8 +59,6 @@

          Module exchangelib.winzone

          if e.get('territory') == DEFAULT_TERRITORY or location not in tz_map: # Prefer default territory. This is so MS_TIMEZONE_TO_IANA_MAP maps from MS timezone ID back to the # "preferred" region/location timezone name. - if not location: - raise ValueError('Expected location') tz_map[location] = e.get('other'), e.get('territory') return type_version, other_version, tz_map @@ -716,7 +711,7 @@

          Functions

          """ r = requests.get(CLDR_WINZONE_URL, timeout=timeout) if r.status_code != 200: - raise ValueError('Unexpected response: %s' % r) + raise ValueError(f'Unexpected response: {r}') tz_map = {} timezones_elem = to_xml(r.content).find('windowsZones').find('mapTimezones') type_version = timezones_elem.get('typeVersion') @@ -726,8 +721,6 @@

          Functions

          if e.get('territory') == DEFAULT_TERRITORY or location not in tz_map: # Prefer default territory. This is so MS_TIMEZONE_TO_IANA_MAP maps from MS timezone ID back to the # "preferred" region/location timezone name. - if not location: - raise ValueError('Expected location') tz_map[location] = e.get('other'), e.get('territory') return type_version, other_version, tz_map
          From 0dd62d7fd881b164bc8edbf7dac4d75bd16eb2d4 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Fri, 14 Jan 2022 11:25:10 +0100 Subject: [PATCH 187/509] Bump version --- CHANGELOG.md | 4 ++++ exchangelib/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fad24598..049c0b81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ Change Log HEAD ---- + + +4.7.0 +----- - Fixed some spelling mistakes: - `ALL_OCCURRENCIES` to `ALL_OCCURRENCES` in `exchangelib.items.base` - `Persona.orgnaization_main_phones` to `Persona.organization_main_phones` diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index 2f60dc98..85436753 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -16,7 +16,7 @@ from .transport import BASIC, DIGEST, NTLM, GSSAPI, SSPI, OAUTH2, CBA from .version import Build, Version -__version__ = '4.6.2' +__version__ = '4.7.0' __all__ = [ '__version__', From c7653778f8110d56608ad38c8a92d083c142e660 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sat, 15 Jan 2022 13:00:55 +0100 Subject: [PATCH 188/509] Make sure we have q default retry_policy value regardless of how we create an Account. Fixes #1035 --- exchangelib/account.py | 5 +++-- tests/test_account.py | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/exchangelib/account.py b/exchangelib/account.py index ae7ef6c9..39651abf 100644 --- a/exchangelib/account.py +++ b/exchangelib/account.py @@ -118,8 +118,9 @@ def __init__(self, primary_smtp_address, fullname=None, access_type=None, autodi ).discover() # Let's not use the auth_package hint from the AD response. It could be incorrect and we can just guess. self.protocol.config.auth_type = auth_type - self.protocol.config.retry_policy = retry_policy - if not self.protocol.config.version: + if retry_policy: + self.protocol.config.retry_policy = retry_policy + if version: self.protocol.config.version = version primary_smtp_address = self.ad_response.autodiscover_smtp_address else: diff --git a/tests/test_account.py b/tests/test_account.py index 2c1f6240..7e7554f7 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -307,3 +307,24 @@ def raise_response_errors(self, response): # Should succeed after credentials update account.protocol.credentials = self.account.protocol.credentials account.root.refresh() + + def test_protocol_default_values(self): + # Test that retry_policy and auth_type always get a value regardless of how we create an Account + c = Credentials(self.settings['username'], self.settings['password']) + a = Account(self.account.primary_smtp_address, autodiscover=False, config=Configuration( + server=self.settings['server'], + credentials=c, + )) + self.assertIsNotNone(a.protocol.auth_type) + self.assertIsNotNone(a.protocol.retry_policy) + + a = Account(self.account.primary_smtp_address, autodiscover=True, config=Configuration( + server=self.settings['server'], + credentials=c, + )) + self.assertIsNotNone(a.protocol.auth_type) + self.assertIsNotNone(a.protocol.retry_policy) + + a = Account(self.account.primary_smtp_address, autodiscover=True, credentials=c) + self.assertIsNotNone(a.protocol.auth_type) + self.assertIsNotNone(a.protocol.retry_policy) From 629a9cec1a10a5635928b2d8e6abdb71129f1a02 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 17 Jan 2022 16:22:53 +0100 Subject: [PATCH 189/509] Bump version --- CHANGELOG.md | 6 ++++++ docs/exchangelib/account.html | 10 ++++++---- docs/exchangelib/index.html | 7 ++++--- exchangelib/__init__.py | 2 +- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 049c0b81..dc336435 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ HEAD ---- +4.7.1 +----- +- Fixed issue where creating an Account with autodiscover and no config would + never set a default retry policy. + + 4.7.0 ----- - Fixed some spelling mistakes: diff --git a/docs/exchangelib/account.html b/docs/exchangelib/account.html index dfccef1b..eaaa09f9 100644 --- a/docs/exchangelib/account.html +++ b/docs/exchangelib/account.html @@ -146,8 +146,9 @@

          Module exchangelib.account

          ).discover() # Let's not use the auth_package hint from the AD response. It could be incorrect and we can just guess. self.protocol.config.auth_type = auth_type - self.protocol.config.retry_policy = retry_policy - if not self.protocol.config.version: + if retry_policy: + self.protocol.config.retry_policy = retry_policy + if version: self.protocol.config.version = version primary_smtp_address = self.ad_response.autodiscover_smtp_address else: @@ -755,8 +756,9 @@

          Classes

          ).discover() # Let's not use the auth_package hint from the AD response. It could be incorrect and we can just guess. self.protocol.config.auth_type = auth_type - self.protocol.config.retry_policy = retry_policy - if not self.protocol.config.version: + if retry_policy: + self.protocol.config.retry_policy = retry_policy + if version: self.protocol.config.version = version primary_smtp_address = self.ad_response.autodiscover_smtp_address else: diff --git a/docs/exchangelib/index.html b/docs/exchangelib/index.html index ed261426..7613db4b 100644 --- a/docs/exchangelib/index.html +++ b/docs/exchangelib/index.html @@ -44,7 +44,7 @@

          Package exchangelib

          from .transport import BASIC, DIGEST, NTLM, GSSAPI, SSPI, OAUTH2, CBA from .version import Build, Version -__version__ = '4.7.0' +__version__ = '4.7.1' __all__ = [ '__version__', @@ -372,8 +372,9 @@

          Inherited members

          ).discover() # Let's not use the auth_package hint from the AD response. It could be incorrect and we can just guess. self.protocol.config.auth_type = auth_type - self.protocol.config.retry_policy = retry_policy - if not self.protocol.config.version: + if retry_policy: + self.protocol.config.retry_policy = retry_policy + if version: self.protocol.config.version = version primary_smtp_address = self.ad_response.autodiscover_smtp_address else: diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index 85436753..b6677806 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -16,7 +16,7 @@ from .transport import BASIC, DIGEST, NTLM, GSSAPI, SSPI, OAUTH2, CBA from .version import Build, Version -__version__ = '4.7.0' +__version__ = '4.7.1' __all__ = [ '__version__', From 77de4b1acede93f23afba0a86c1b89e5ab9923f2 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 19 Jan 2022 10:06:59 +0100 Subject: [PATCH 190/509] Simplify session ID calculation. Remove unused protocol reference. --- exchangelib/protocol.py | 5 ++--- exchangelib/services/get_streaming_events.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 2e33ce45..234d8fd0 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -7,7 +7,7 @@ import abc import datetime import logging -import os +import random from queue import LifoQueue, Empty from threading import Lock @@ -277,9 +277,8 @@ def create_session(self): password=self.credentials.password) # Add some extra info - session.session_id = sum(map(ord, str(os.urandom(100)))) # Used for debugging messages in services + session.session_id = random.randint(10000, 99999) # Used for debugging messages in services session.usage_count = 0 - session.protocol = self log.debug('Server %s: Created session %s', self.server, session.session_id) return session diff --git a/exchangelib/services/get_streaming_events.py b/exchangelib/services/get_streaming_events.py index 877b7865..ed492a0a 100644 --- a/exchangelib/services/get_streaming_events.py +++ b/exchangelib/services/get_streaming_events.py @@ -53,7 +53,7 @@ def _get_soap_messages(self, body, **parse_opts): # XML response. r = body for i, doc in enumerate(DocumentYielder(r.iter_content()), start=1): - xml_log.debug('''Response XML (docs counter: %(i)s): %(xml_response)s''', dict(i=i, xml_response=doc)) + xml_log.debug('Response XML (docs counter: %(i)s): %(xml_response)s', dict(i=i, xml_response=doc)) response = DummyResponse(content=doc) try: _, body = super()._get_soap_parts(response=response, **parse_opts) From 4c43bad5860e3d5a6d6b646e3c9748b7dfa3e618 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 19 Jan 2022 10:08:26 +0100 Subject: [PATCH 191/509] Try even harder to not leak sessions from the session pool --- exchangelib/services/common.py | 12 +++++++++++- tests/test_items/test_sync.py | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index e40ce56a..088e4dd8 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -116,6 +116,16 @@ def __init__(self, protocol, chunk_size=None, timeout=None): self._streaming_session = None self._streaming_response = None + def __del__(self): + # pylint: disable=bare-except + try: + if self.streaming: + # Make sure to clean up lingering resources + self.stop_streaming() + except Exception: # nosec + # __del__ should never fail + pass + # The following two methods are the minimum required to be implemented by subclasses, but the name and number of # kwargs differs between services. Therefore, we cannot make these methods abstract. @@ -279,10 +289,10 @@ def _handle_response_cookies(self, session): def _get_response(self, payload, api_version): """Send the actual HTTP request and get the response.""" - session = self.protocol.get_session() if self.streaming: # Make sure to clean up lingering resources self.stop_streaming() + session = self.protocol.get_session() r, session = post_ratelimited( protocol=self.protocol, session=session, diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index 4dc860d6..4b100601 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -281,14 +281,18 @@ def test_streaming_with_other_calls(self): # Test calling GetItem while the streaming connection is still open. We need to bump the # connection count because the default count is 1 but we need 2 connections. + q_size = self.account.protocol._session_pool.qsize() self.account.protocol._session_pool_maxsize += 1 self.account.protocol.increase_poolsize() + self.assertEqual(self.account.protocol._session_pool.qsize(), q_size + 1) try: with test_folder.streaming_subscription() as subscription_id: i1 = self.get_test_item(folder=test_folder).save() for notification in test_folder.get_streaming_events( subscription_id, connection_timeout=1, max_notifications_returned=1 ): + # We're using one session for streaming, and have one in reserve for the following service call. + self.assertEqual(self.account.protocol._session_pool.qsize(), q_size) for e in notification.events: if isinstance(e, CreatedEvent) and e.event_type == CreatedEvent.ITEM \ and e.item_id.id == i1.id: @@ -296,6 +300,28 @@ def test_streaming_with_other_calls(self): finally: self.account.protocol.decrease_poolsize() self.account.protocol._session_pool_maxsize -= 1 + self.assertEqual(self.account.protocol._session_pool.qsize(), q_size) + + def test_streaming_incomplete_generator_consumption(self): + # Test that sessions are properly returned to the pool even when get_streaming_events() is not fully consumed. + # The generator needs to be garbage collected to release its session. + test_folder = self.account.drafts + q_size = self.account.protocol._session_pool.qsize() + self.account.protocol._session_pool_maxsize += 1 + self.account.protocol.increase_poolsize() + self.assertEqual(self.account.protocol._session_pool.qsize(), q_size + 1) + try: + with test_folder.streaming_subscription() as subscription_id: + # Generate an event and incompletely consume the generator + self.get_test_item(folder=test_folder).save() + it = test_folder.get_streaming_events(subscription_id, connection_timeout=1) + _ = next(it) + self.assertEqual(self.account.protocol._session_pool.qsize(), q_size) + del it + self.assertEqual(self.account.protocol._session_pool.qsize(), q_size + 1) + finally: + self.account.protocol.decrease_poolsize() + self.account.protocol._session_pool_maxsize -= 1 def test_streaming_invalid_subscription(self): # Test that we can get the failing subscription IDs from the response message From 55b47aa4a6c71d7547a8113bbd006237ea0f1604 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Fri, 21 Jan 2022 18:27:51 +0100 Subject: [PATCH 192/509] Fix enum value. Closes #1040 --- exchangelib/fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index b379ec9e..f1bafa66 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -543,7 +543,7 @@ class AppointmentStateField(IntegerField): NONE = 'None' MEETING = 'Meeting' RECEIVED = 'Received' - CANCELLED = 'Cancelled' + CANCELLED = 'Canceled' STATES = { NONE: 0x0000, MEETING: 0x0001, From 87f6776bb1766510e9a5d51061a190d057308fcb Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 26 Jan 2022 08:36:31 +0100 Subject: [PATCH 193/509] Catch ErrorItemCorrupt when fetching items. Fixes #1041 --- exchangelib/services/common.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 088e4dd8..bdc8f925 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -19,7 +19,7 @@ SessionPoolMinSizeReached, ErrorIncorrectSchemaVersion, ErrorInvalidRequest, ErrorCorruptData, \ ErrorCannotEmptyFolder, ErrorDeleteDistinguishedFolder, ErrorInvalidSubscription, ErrorInvalidWatermark, \ ErrorInvalidSyncStateData, ErrorNameResolutionNoResults, ErrorNameResolutionMultipleResults, \ - ErrorConnectionFailedTransientError, ErrorDelegateNoUser, ErrorNotDelegate, InvalidTypeError + ErrorConnectionFailedTransientError, ErrorDelegateNoUser, ErrorNotDelegate, InvalidTypeError, ErrorItemCorrupt from ..folders import BaseFolder, Folder, RootOfHierarchy from ..items import BaseItem from ..properties import FieldURI, IndexedFieldURI, ExtendedFieldURI, ExceptionFieldURI, ItemId, FolderId, \ @@ -56,6 +56,7 @@ ErrorInvalidSubscription, ErrorInvalidSyncStateData, ErrorInvalidWatermark, + ErrorItemCorrupt, ErrorItemNotFound, ErrorMailboxMoveInProgress, ErrorMailboxStoreUnavailable, @@ -83,7 +84,7 @@ class EWSService(metaclass=abc.ABCMeta): ERRORS_TO_CATCH_IN_RESPONSE = ( EWSWarning, ErrorCannotDeleteObject, ErrorInvalidChangeKey, ErrorItemNotFound, ErrorItemSave, ErrorInvalidIdMalformed, ErrorMessageSizeExceeded, ErrorCannotDeleteTaskOccurrence, - ErrorMimeContentConversionFailed, ErrorRecurrenceHasNoOccurrence, ErrorCorruptData + ErrorMimeContentConversionFailed, ErrorRecurrenceHasNoOccurrence, ErrorCorruptData, ErrorItemCorrupt ) # Similarly, define the warnings we want to return unraised WARNINGS_TO_CATCH_IN_RESPONSE = ErrorBatchProcessingStopped From 07eb7168586ac6ba0b6cbda5749d4bf00e63ee89 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 27 Jan 2022 10:07:04 +0100 Subject: [PATCH 194/509] Replace flake8 with black and isort --- .flake8 | 3 - .github/workflows/python-package.yml | 2 + exchangelib/__init__.py | 114 +- exchangelib/account.py | 298 ++- exchangelib/attachments.py | 103 +- exchangelib/autodiscover/__init__.py | 9 +- exchangelib/autodiscover/cache.py | 22 +- exchangelib/autodiscover/discovery.py | 168 +- exchangelib/autodiscover/properties.py | 241 +- exchangelib/autodiscover/protocol.py | 4 +- exchangelib/configuration.py | 41 +- exchangelib/credentials.py | 51 +- exchangelib/errors.py | 1930 +++++++++++++---- exchangelib/ewsdatetime.py | 77 +- exchangelib/extended_properties.py | 180 +- exchangelib/fields.py | 493 +++-- exchangelib/folders/__init__.py | 210 +- exchangelib/folders/base.py | 286 ++- exchangelib/folders/collections.py | 151 +- exchangelib/folders/known_folders.py | 406 ++-- exchangelib/folders/queryset.py | 33 +- exchangelib/folders/roots.py | 90 +- exchangelib/indexed_properties.py | 52 +- exchangelib/items/__init__.py | 162 +- exchangelib/items/base.py | 115 +- exchangelib/items/calendar_item.py | 368 ++-- exchangelib/items/contact.py | 397 ++-- exchangelib/items/item.py | 240 +- exchangelib/items/message.py | 126 +- exchangelib/items/post.py | 30 +- exchangelib/items/task.py | 135 +- exchangelib/properties.py | 1022 +++++---- exchangelib/protocol.py | 261 ++- exchangelib/queryset.py | 115 +- exchangelib/recurrence.py | 177 +- exchangelib/restriction.py | 193 +- exchangelib/services/__init__.py | 106 +- exchangelib/services/archive_item.py | 12 +- exchangelib/services/common.py | 339 +-- exchangelib/services/convert_id.py | 24 +- exchangelib/services/copy_item.py | 2 +- exchangelib/services/create_attachment.py | 12 +- exchangelib/services/create_folder.py | 28 +- exchangelib/services/create_item.py | 54 +- .../services/create_user_configuration.py | 6 +- exchangelib/services/delete_attachment.py | 8 +- exchangelib/services/delete_folder.py | 8 +- exchangelib/services/delete_item.py | 21 +- .../services/delete_user_configuration.py | 6 +- exchangelib/services/empty_folder.py | 9 +- exchangelib/services/expand_dl.py | 10 +- exchangelib/services/export_items.py | 10 +- exchangelib/services/find_folder.py | 38 +- exchangelib/services/find_item.py | 99 +- exchangelib/services/find_people.py | 91 +- exchangelib/services/get_attachment.py | 70 +- exchangelib/services/get_delegate.py | 28 +- exchangelib/services/get_events.py | 21 +- exchangelib/services/get_folder.py | 38 +- exchangelib/services/get_item.py | 29 +- exchangelib/services/get_mail_tips.py | 28 +- exchangelib/services/get_persona.py | 14 +- exchangelib/services/get_room_lists.py | 10 +- exchangelib/services/get_rooms.py | 10 +- .../services/get_searchable_mailboxes.py | 30 +- exchangelib/services/get_server_time_zones.py | 23 +- exchangelib/services/get_streaming_events.py | 47 +- exchangelib/services/get_user_availability.py | 30 +- .../services/get_user_configuration.py | 28 +- exchangelib/services/get_user_oof_settings.py | 14 +- exchangelib/services/mark_as_junk.py | 6 +- exchangelib/services/move_folder.py | 12 +- exchangelib/services/move_item.py | 14 +- exchangelib/services/resolve_names.py | 70 +- exchangelib/services/send_item.py | 14 +- exchangelib/services/send_notification.py | 18 +- exchangelib/services/set_user_oof_settings.py | 14 +- exchangelib/services/subscribe.py | 71 +- exchangelib/services/sync_folder_hierarchy.py | 64 +- exchangelib/services/sync_folder_items.py | 54 +- exchangelib/services/unsubscribe.py | 8 +- exchangelib/services/update_folder.py | 32 +- exchangelib/services/update_item.py | 83 +- .../services/update_user_configuration.py | 6 +- exchangelib/services/upload_items.py | 23 +- exchangelib/settings.py | 47 +- exchangelib/transport.py | 140 +- exchangelib/util.py | 274 +-- exchangelib/version.py | 132 +- exchangelib/winzone.py | 1220 +++++------ pyproject.toml | 7 + test-requirements.txt | 3 +- tests/__init__.py | 6 +- tests/common.py | 180 +- tests/test_account.py | 163 +- tests/test_attachments.py | 138 +- tests/test_autodiscover.py | 346 +-- tests/test_build.py | 16 +- tests/test_configuration.py | 32 +- tests/test_credentials.py | 54 +- tests/test_ewsdatetime.py | 161 +- tests/test_extended_properties.py | 124 +- tests/test_field.py | 167 +- tests/test_folder.py | 564 ++--- tests/test_items/test_basics.py | 251 ++- tests/test_items/test_bulk.py | 34 +- tests/test_items/test_calendaritems.py | 161 +- tests/test_items/test_contacts.py | 184 +- tests/test_items/test_generic.py | 679 +++--- tests/test_items/test_helpers.py | 25 +- tests/test_items/test_messages.py | 54 +- tests/test_items/test_queryset.py | 247 +-- tests/test_items/test_sync.py | 143 +- tests/test_items/test_tasks.py | 8 +- tests/test_properties.py | 198 +- tests/test_protocol.py | 390 ++-- tests/test_queryset.py | 33 +- tests/test_recurrence.py | 55 +- tests/test_restriction.py | 67 +- tests/test_services.py | 121 +- tests/test_source.py | 130 +- tests/test_transport.py | 88 +- tests/test_util.py | 232 +- tests/test_version.py | 86 +- 124 files changed, 9914 insertions(+), 7143 deletions(-) delete mode 100644 .flake8 create mode 100644 pyproject.toml diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 66c37ed1..00000000 --- a/.flake8 +++ /dev/null @@ -1,3 +0,0 @@ -[flake8] -max-line-length = 120 -exclude = .git,__pycache__,vendor diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index ea9bb563..a11d57c2 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -77,6 +77,8 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + black --check exchangelib tests + isort --check exchangelib tests unittest-parallel -j 4 --class-fixtures --coverage --coverage-source exchangelib coveralls --service=github diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index b6677806..e3726553 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -2,50 +2,108 @@ from .attachments import FileAttachment, ItemAttachment from .autodiscover import discover from .configuration import Configuration -from .credentials import DELEGATE, IMPERSONATION, Credentials, OAuth2Credentials, \ - OAuth2AuthorizationCodeCredentials -from .ewsdatetime import EWSDate, EWSDateTime, EWSTimeZone, UTC, UTC_NOW +from .credentials import DELEGATE, IMPERSONATION, Credentials, OAuth2AuthorizationCodeCredentials, OAuth2Credentials +from .ewsdatetime import UTC, UTC_NOW, EWSDate, EWSDateTime, EWSTimeZone from .extended_properties import ExtendedProperty -from .folders import Folder, RootOfHierarchy, FolderCollection, SHALLOW, DEEP -from .items import AcceptItem, TentativelyAcceptItem, DeclineItem, CalendarItem, CancelCalendarItem, Contact, \ - DistributionList, Message, PostItem, Task, ForwardItem, ReplyToItem, ReplyAllToItem -from .properties import Body, HTMLBody, ItemId, Mailbox, Attendee, Room, RoomList, UID, DLMailbox -from .protocol import FaultTolerance, FailFast, BaseProtocol, NoVerifyHTTPAdapter, TLSClientAuth +from .folders import DEEP, SHALLOW, Folder, FolderCollection, RootOfHierarchy +from .items import ( + AcceptItem, + CalendarItem, + CancelCalendarItem, + Contact, + DeclineItem, + DistributionList, + ForwardItem, + Message, + PostItem, + ReplyAllToItem, + ReplyToItem, + Task, + TentativelyAcceptItem, +) +from .properties import UID, Attendee, Body, DLMailbox, HTMLBody, ItemId, Mailbox, Room, RoomList +from .protocol import BaseProtocol, FailFast, FaultTolerance, NoVerifyHTTPAdapter, TLSClientAuth from .restriction import Q from .settings import OofSettings -from .transport import BASIC, DIGEST, NTLM, GSSAPI, SSPI, OAUTH2, CBA +from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = '4.7.1' +__version__ = "4.7.1" __all__ = [ - '__version__', - 'Account', 'Identity', - 'FileAttachment', 'ItemAttachment', - 'discover', - 'Configuration', - 'DELEGATE', 'IMPERSONATION', 'Credentials', 'OAuth2AuthorizationCodeCredentials', 'OAuth2Credentials', - 'EWSDate', 'EWSDateTime', 'EWSTimeZone', 'UTC', 'UTC_NOW', - 'ExtendedProperty', - 'Folder', 'RootOfHierarchy', 'FolderCollection', 'SHALLOW', 'DEEP', - 'AcceptItem', 'TentativelyAcceptItem', 'DeclineItem', 'CalendarItem', 'CancelCalendarItem', 'Contact', - 'DistributionList', 'Message', 'PostItem', 'Task', 'ForwardItem', 'ReplyToItem', 'ReplyAllToItem', - 'ItemId', 'Mailbox', 'DLMailbox', 'Attendee', 'Room', 'RoomList', 'Body', 'HTMLBody', 'UID', - 'FailFast', 'FaultTolerance', 'BaseProtocol', 'NoVerifyHTTPAdapter', 'TLSClientAuth', - 'OofSettings', - 'Q', - 'BASIC', 'DIGEST', 'NTLM', 'GSSAPI', 'SSPI', 'OAUTH2', 'CBA', - 'Build', 'Version', - 'close_connections', + "__version__", + "Account", + "Identity", + "FileAttachment", + "ItemAttachment", + "discover", + "Configuration", + "DELEGATE", + "IMPERSONATION", + "Credentials", + "OAuth2AuthorizationCodeCredentials", + "OAuth2Credentials", + "EWSDate", + "EWSDateTime", + "EWSTimeZone", + "UTC", + "UTC_NOW", + "ExtendedProperty", + "Folder", + "RootOfHierarchy", + "FolderCollection", + "SHALLOW", + "DEEP", + "AcceptItem", + "TentativelyAcceptItem", + "DeclineItem", + "CalendarItem", + "CancelCalendarItem", + "Contact", + "DistributionList", + "Message", + "PostItem", + "Task", + "ForwardItem", + "ReplyToItem", + "ReplyAllToItem", + "ItemId", + "Mailbox", + "DLMailbox", + "Attendee", + "Room", + "RoomList", + "Body", + "HTMLBody", + "UID", + "FailFast", + "FaultTolerance", + "BaseProtocol", + "NoVerifyHTTPAdapter", + "TLSClientAuth", + "OofSettings", + "Q", + "BASIC", + "DIGEST", + "NTLM", + "GSSAPI", + "SSPI", + "OAUTH2", + "CBA", + "Build", + "Version", + "close_connections", ] # Set a default user agent, e.g. "exchangelib/3.1.1 (python-requests/2.22.0)" import requests.utils + BaseProtocol.USERAGENT = f"{__name__}/{__version__} ({requests.utils.default_user_agent()})" def close_connections(): from .autodiscover import close_connections as close_autodiscover_connections from .protocol import close_connections as close_protocol_connections + close_autodiscover_connections() close_protocol_connections() diff --git a/exchangelib/account.py b/exchangelib/account.py index 39651abf..53bea5f5 100644 --- a/exchangelib/account.py +++ b/exchangelib/account.py @@ -5,23 +5,77 @@ from .autodiscover import Autodiscovery from .configuration import Configuration -from .credentials import DELEGATE, IMPERSONATION, ACCESS_TYPES -from .errors import UnknownTimeZone, InvalidEnumValue, InvalidTypeError -from .ewsdatetime import EWSTimeZone, UTC +from .credentials import ACCESS_TYPES, DELEGATE, IMPERSONATION +from .errors import InvalidEnumValue, InvalidTypeError, UnknownTimeZone +from .ewsdatetime import UTC, EWSTimeZone from .fields import FieldPath -from .folders import Folder, AdminAuditLogs, ArchiveDeletedItems, ArchiveInbox, ArchiveMsgFolderRoot, \ - ArchiveRecoverableItemsDeletions, ArchiveRecoverableItemsPurges, ArchiveRecoverableItemsRoot, \ - ArchiveRecoverableItemsVersions, ArchiveRoot, Calendar, Conflicts, Contacts, ConversationHistory, DeletedItems, \ - Directory, Drafts, Favorites, IMContactList, Inbox, Journal, JunkEmail, LocalFailures, MsgFolderRoot, MyContacts, \ - Notes, Outbox, PeopleConnect, PublicFoldersRoot, QuickContacts, RecipientCache, RecoverableItemsDeletions, \ - RecoverableItemsPurges, RecoverableItemsRoot, RecoverableItemsVersions, Root, SearchFolders, SentItems, \ - ServerFailures, SyncIssues, Tasks, ToDoSearch, VoiceMail -from .items import HARD_DELETE, AUTO_RESOLVE, SEND_TO_NONE, SAVE_ONLY, ALL_OCCURRENCES, ID_ONLY +from .folders import ( + AdminAuditLogs, + ArchiveDeletedItems, + ArchiveInbox, + ArchiveMsgFolderRoot, + ArchiveRecoverableItemsDeletions, + ArchiveRecoverableItemsPurges, + ArchiveRecoverableItemsRoot, + ArchiveRecoverableItemsVersions, + ArchiveRoot, + Calendar, + Conflicts, + Contacts, + ConversationHistory, + DeletedItems, + Directory, + Drafts, + Favorites, + Folder, + IMContactList, + Inbox, + Journal, + JunkEmail, + LocalFailures, + MsgFolderRoot, + MyContacts, + Notes, + Outbox, + PeopleConnect, + PublicFoldersRoot, + QuickContacts, + RecipientCache, + RecoverableItemsDeletions, + RecoverableItemsPurges, + RecoverableItemsRoot, + RecoverableItemsVersions, + Root, + SearchFolders, + SentItems, + ServerFailures, + SyncIssues, + Tasks, + ToDoSearch, + VoiceMail, +) +from .items import ALL_OCCURRENCES, AUTO_RESOLVE, HARD_DELETE, ID_ONLY, SAVE_ONLY, SEND_TO_NONE from .properties import Mailbox, SendingAs from .protocol import Protocol from .queryset import QuerySet -from .services import ExportItems, UploadItems, GetItem, CreateItem, UpdateItem, DeleteItem, MoveItem, SendItem, \ - CopyItem, GetUserOofSettings, SetUserOofSettings, GetMailTips, ArchiveItem, GetDelegate, MarkAsJunk, GetPersona +from .services import ( + ArchiveItem, + CopyItem, + CreateItem, + DeleteItem, + ExportItems, + GetDelegate, + GetItem, + GetMailTips, + GetPersona, + GetUserOofSettings, + MarkAsJunk, + MoveItem, + SendItem, + SetUserOofSettings, + UpdateItem, + UploadItems, +) from .util import get_domain, peek log = getLogger(__name__) @@ -57,8 +111,17 @@ def __repr__(self): class Account: """Models an Exchange server user account.""" - def __init__(self, primary_smtp_address, fullname=None, access_type=None, autodiscover=False, credentials=None, - config=None, locale=None, default_timezone=None): + def __init__( + self, + primary_smtp_address, + fullname=None, + access_type=None, + autodiscover=False, + credentials=None, + config=None, + locale=None, + default_timezone=None, + ): """ :param primary_smtp_address: The primary email address associated with the account on the Exchange server @@ -75,37 +138,37 @@ def __init__(self, primary_smtp_address, fullname=None, access_type=None, autodi assume values to be in the provided timezone. Defaults to the timezone of the host. :return: """ - if '@' not in primary_smtp_address: + if "@" not in primary_smtp_address: raise ValueError(f"primary_smtp_address {primary_smtp_address!r} is not an email address") self.fullname = fullname # Assume delegate access if individual credentials are provided. Else, assume service user with impersonation self.access_type = access_type or (DELEGATE if credentials else IMPERSONATION) if self.access_type not in ACCESS_TYPES: - raise InvalidEnumValue('access_type', self.access_type, ACCESS_TYPES) + raise InvalidEnumValue("access_type", self.access_type, ACCESS_TYPES) try: # get_locale() might not be able to determine the locale self.locale = locale or stdlib_locale.getlocale()[0] or None except ValueError as e: # getlocale() may throw ValueError if it fails to parse the system locale - log.warning('Failed to get locale (%s)', e) + log.warning("Failed to get locale (%s)", e) self.locale = None if not isinstance(self.locale, (type(None), str)): - raise InvalidTypeError('locale', self.locale, str) + raise InvalidTypeError("locale", self.locale, str) if default_timezone: try: self.default_timezone = EWSTimeZone.from_timezone(default_timezone) except TypeError: - raise InvalidTypeError('default_timezone', default_timezone, EWSTimeZone) + raise InvalidTypeError("default_timezone", default_timezone, EWSTimeZone) else: try: self.default_timezone = EWSTimeZone.localzone() except (ValueError, UnknownTimeZone) as e: # There is no translation from local timezone name to Windows timezone name, or e failed to find the # local timezone. - log.warning('%s. Fallback to UTC', e.args[0]) + log.warning("%s. Fallback to UTC", e.args[0]) self.default_timezone = UTC if not isinstance(config, (Configuration, type(None))): - raise InvalidTypeError('config', config, Configuration) + raise InvalidTypeError("config", config, Configuration) if autodiscover: if config: auth_type, retry_policy, version = config.auth_type, config.retry_policy, config.version @@ -125,7 +188,7 @@ def __init__(self, primary_smtp_address, fullname=None, access_type=None, autodi primary_smtp_address = self.ad_response.autodiscover_smtp_address else: if not config: - raise AttributeError('non-autodiscover requires a config') + raise AttributeError("non-autodiscover requires a config") self.ad_response = None self.protocol = Protocol(config=config) @@ -139,7 +202,7 @@ def __init__(self, primary_smtp_address, fullname=None, access_type=None, autodi # server version up-front but delegate account requests to an older backend server. Create a new instance to # avoid changing the protocol version. self.version = self.protocol.version.copy() - log.debug('Added account: %s', self) + log.debug("Added account: %s", self) @property def primary_smtp_address(self): @@ -347,7 +410,7 @@ def _consume_item_service(self, service_cls, items, chunk_size, kwargs): # We accept generators, so it's not always convenient for caller to know up-front if 'ids' is empty. Allow # empty 'ids' and return early. return - kwargs['items'] = items + kwargs["items"] = items yield from service_cls(account=self, chunk_size=chunk_size).call(**kwargs) def export(self, items, chunk_size=None): @@ -358,9 +421,7 @@ def export(self, items, chunk_size=None): :return: A list of strings, the exported representation of the object """ - return list( - self._consume_item_service(service_cls=ExportItems, items=items, chunk_size=chunk_size, kwargs={}) - ) + return list(self._consume_item_service(service_cls=ExportItems, items=items, chunk_size=chunk_size, kwargs={})) def upload(self, data, chunk_size=None): """Upload objects retrieved from an export to the given folders. @@ -382,12 +443,11 @@ def upload(self, data, chunk_size=None): -> [("idA", "changekey"), ("idB", "changekey"), ("idC", "changekey")] """ items = ((f, (None, False, d) if isinstance(d, str) else d) for f, d in data) - return list( - self._consume_item_service(service_cls=UploadItems, items=items, chunk_size=chunk_size, kwargs={}) - ) + return list(self._consume_item_service(service_cls=UploadItems, items=items, chunk_size=chunk_size, kwargs={})) - def bulk_create(self, folder, items, message_disposition=SAVE_ONLY, send_meeting_invitations=SEND_TO_NONE, - chunk_size=None): + def bulk_create( + self, folder, items, message_disposition=SAVE_ONLY, send_meeting_invitations=SEND_TO_NONE, chunk_size=None + ): """Create new items in 'folder'. :param folder: the folder to create the items in @@ -404,23 +464,36 @@ def bulk_create(self, folder, items, message_disposition=SAVE_ONLY, send_meeting """ if isinstance(items, QuerySet): # bulk_create() on a queryset does not make sense because it returns items that have already been created - raise ValueError('Cannot bulk create items from a QuerySet') + raise ValueError("Cannot bulk create items from a QuerySet") log.debug( - 'Adding items for %s (folder %s, message_disposition: %s, send_meeting_invitations: %s)', + "Adding items for %s (folder %s, message_disposition: %s, send_meeting_invitations: %s)", self, folder, message_disposition, send_meeting_invitations, ) - return list(self._consume_item_service(service_cls=CreateItem, items=items, chunk_size=chunk_size, kwargs=dict( - folder=folder, - message_disposition=message_disposition, - send_meeting_invitations=send_meeting_invitations, - ))) - - def bulk_update(self, items, conflict_resolution=AUTO_RESOLVE, message_disposition=SAVE_ONLY, - send_meeting_invitations_or_cancellations=SEND_TO_NONE, suppress_read_receipts=True, - chunk_size=None): + return list( + self._consume_item_service( + service_cls=CreateItem, + items=items, + chunk_size=chunk_size, + kwargs=dict( + folder=folder, + message_disposition=message_disposition, + send_meeting_invitations=send_meeting_invitations, + ), + ) + ) + + def bulk_update( + self, + items, + conflict_resolution=AUTO_RESOLVE, + message_disposition=SAVE_ONLY, + send_meeting_invitations_or_cancellations=SEND_TO_NONE, + suppress_read_receipts=True, + chunk_size=None, + ): """Bulk update existing items. :param items: a list of (Item, fieldnames) tuples, where 'Item' is an Item object, and 'fieldnames' is a list @@ -440,23 +513,37 @@ def bulk_update(self, items, conflict_resolution=AUTO_RESOLVE, message_dispositi # fact, it could be dangerous if the queryset contains an '.only()'. This would wipe out certain fields # entirely. if isinstance(items, QuerySet): - raise ValueError('Cannot bulk update on a queryset') + raise ValueError("Cannot bulk update on a queryset") log.debug( - 'Updating items for %s (conflict_resolution %s, message_disposition: %s, send_meeting_invitations: %s)', + "Updating items for %s (conflict_resolution %s, message_disposition: %s, send_meeting_invitations: %s)", self, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, ) - return list(self._consume_item_service(service_cls=UpdateItem, items=items, chunk_size=chunk_size, kwargs=dict( - conflict_resolution=conflict_resolution, - message_disposition=message_disposition, - send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations, - suppress_read_receipts=suppress_read_receipts, - ))) - - def bulk_delete(self, ids, delete_type=HARD_DELETE, send_meeting_cancellations=SEND_TO_NONE, - affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True, chunk_size=None): + return list( + self._consume_item_service( + service_cls=UpdateItem, + items=items, + chunk_size=chunk_size, + kwargs=dict( + conflict_resolution=conflict_resolution, + message_disposition=message_disposition, + send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations, + suppress_read_receipts=suppress_read_receipts, + ), + ) + ) + + def bulk_delete( + self, + ids, + delete_type=HARD_DELETE, + send_meeting_cancellations=SEND_TO_NONE, + affected_task_occurrences=ALL_OCCURRENCES, + suppress_read_receipts=True, + chunk_size=None, + ): """Bulk delete items. :param ids: an iterable of either (id, changekey) tuples or Item objects. @@ -472,19 +559,24 @@ def bulk_delete(self, ids, delete_type=HARD_DELETE, send_meeting_cancellations=S :return: a list of either True or exception instances, in the same order as the input """ log.debug( - 'Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurrences: %s)', + "Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurrences: %s)", self, delete_type, send_meeting_cancellations, affected_task_occurrences, ) return list( - self._consume_item_service(service_cls=DeleteItem, items=ids, chunk_size=chunk_size, kwargs=dict( - delete_type=delete_type, - send_meeting_cancellations=send_meeting_cancellations, - affected_task_occurrences=affected_task_occurrences, - suppress_read_receipts=suppress_read_receipts, - )) + self._consume_item_service( + service_cls=DeleteItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + delete_type=delete_type, + send_meeting_cancellations=send_meeting_cancellations, + affected_task_occurrences=affected_task_occurrences, + suppress_read_receipts=suppress_read_receipts, + ), + ) ) def bulk_send(self, ids, save_copy=True, copy_to_folder=None, chunk_size=None): @@ -502,9 +594,14 @@ def bulk_send(self, ids, save_copy=True, copy_to_folder=None, chunk_size=None): if save_copy and not copy_to_folder: copy_to_folder = self.sent # 'Sent' is default EWS behaviour return list( - self._consume_item_service(service_cls=SendItem, items=ids, chunk_size=chunk_size, kwargs=dict( - saved_item_folder=copy_to_folder, - )) + self._consume_item_service( + service_cls=SendItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + saved_item_folder=copy_to_folder, + ), + ) ) def bulk_copy(self, ids, to_folder, chunk_size=None): @@ -516,9 +613,16 @@ def bulk_copy(self, ids, to_folder, chunk_size=None): :return: Status for each send operation, in the same order as the input """ - return list(self._consume_item_service(service_cls=CopyItem, items=ids, chunk_size=chunk_size, kwargs=dict( - to_folder=to_folder, - ))) + return list( + self._consume_item_service( + service_cls=CopyItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + to_folder=to_folder, + ), + ) + ) def bulk_move(self, ids, to_folder, chunk_size=None): """Move items to another folder. @@ -530,9 +634,16 @@ def bulk_move(self, ids, to_folder, chunk_size=None): :return: The new IDs of the moved items, in the same order as the input. If 'to_folder' is a public folder or a folder in a different mailbox, an empty list is returned. """ - return list(self._consume_item_service(service_cls=MoveItem, items=ids, chunk_size=chunk_size, kwargs=dict( - to_folder=to_folder, - ))) + return list( + self._consume_item_service( + service_cls=MoveItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + to_folder=to_folder, + ), + ) + ) def bulk_archive(self, ids, to_folder, chunk_size=None): """Archive items to a folder in the archive mailbox. An archive mailbox must be enabled in order for this @@ -544,9 +655,15 @@ def bulk_archive(self, ids, to_folder, chunk_size=None): :return: A list containing True or an exception instance in stable order of the requested items """ - return list(self._consume_item_service(service_cls=ArchiveItem, items=ids, chunk_size=chunk_size, kwargs=dict( - to_folder=to_folder, - )) + return list( + self._consume_item_service( + service_cls=ArchiveItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + to_folder=to_folder, + ), + ) ) def bulk_mark_as_junk(self, ids, is_junk, move_item, chunk_size=None): @@ -560,10 +677,17 @@ def bulk_mark_as_junk(self, ids, is_junk, move_item, chunk_size=None): :return: A list containing the new IDs of the moved items, if items were moved, or True, or an exception instance, in stable order of the requested items. """ - return list(self._consume_item_service(service_cls=MarkAsJunk, items=ids, chunk_size=chunk_size, kwargs=dict( - is_junk=is_junk, - move_item=move_item, - ))) + return list( + self._consume_item_service( + service_cls=MarkAsJunk, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + is_junk=is_junk, + move_item=move_item, + ), + ) + ) def fetch(self, ids, folder=None, only_fields=None, chunk_size=None): """Fetch items by ID. @@ -588,13 +712,19 @@ def fetch(self, ids, folder=None, only_fields=None, chunk_size=None): for field in only_fields: validation_folder.validate_item_field(field=field, version=self.version) # Remove ItemId and ChangeKey. We get them unconditionally - additional_fields = {f for f in validation_folder.normalize_fields(fields=only_fields) - if not f.field.is_attribute} + additional_fields = { + f for f in validation_folder.normalize_fields(fields=only_fields) if not f.field.is_attribute + } # Always use IdOnly here, because AllProperties doesn't actually get *all* properties - yield from self._consume_item_service(service_cls=GetItem, items=ids, chunk_size=chunk_size, kwargs=dict( + yield from self._consume_item_service( + service_cls=GetItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( additional_fields=additional_fields, shape=ID_ONLY, - )) + ), + ) def fetch_personas(self, ids): """Fetch personas by ID. @@ -618,7 +748,7 @@ def mail_tips(self): return GetMailTips(protocol=self.protocol).get( sending_as=SendingAs(email_address=self.primary_smtp_address), recipients=[Mailbox(email_address=self.primary_smtp_address)], - mail_tips_requested='All', + mail_tips_requested="All", ) @property @@ -628,5 +758,5 @@ def delegates(self): def __str__(self): if self.fullname: - return f'{self.primary_smtp_address} ({self.fullname})' + return f"{self.primary_smtp_address} ({self.fullname})" return self.primary_smtp_address diff --git a/exchangelib/attachments.py b/exchangelib/attachments.py index 274f8335..c44634d3 100644 --- a/exchangelib/attachments.py +++ b/exchangelib/attachments.py @@ -3,8 +3,18 @@ import mimetypes from .errors import InvalidTypeError -from .fields import BooleanField, TextField, IntegerField, URIField, DateTimeField, EWSElementField, Base64Field, \ - ItemField, IdField, FieldPath +from .fields import ( + Base64Field, + BooleanField, + DateTimeField, + EWSElementField, + FieldPath, + IdField, + IntegerField, + ItemField, + TextField, + URIField, +) from .properties import EWSElement, EWSMeta log = logging.getLogger(__name__) @@ -16,11 +26,11 @@ class AttachmentId(EWSElement): MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/attachmentid """ - ELEMENT_NAME = 'AttachmentId' + ELEMENT_NAME = "AttachmentId" - ID_ATTR = 'Id' - ROOT_ID_ATTR = 'RootItemId' - ROOT_CHANGEKEY_ATTR = 'RootItemChangeKey' + ID_ATTR = "Id" + ROOT_ID_ATTR = "RootItemId" + ROOT_CHANGEKEY_ATTR = "RootItemChangeKey" id = IdField(field_uri=ID_ATTR, is_required=True) root_id = IdField(field_uri=ROOT_ID_ATTR) @@ -37,35 +47,37 @@ class Attachment(EWSElement, metaclass=EWSMeta): """Base class for FileAttachment and ItemAttachment.""" attachment_id = EWSElementField(value_cls=AttachmentId) - name = TextField(field_uri='Name') - content_type = TextField(field_uri='ContentType') - content_id = TextField(field_uri='ContentId') - content_location = URIField(field_uri='ContentLocation') - size = IntegerField(field_uri='Size', is_read_only=True) # Attachment size in bytes - last_modified_time = DateTimeField(field_uri='LastModifiedTime') - is_inline = BooleanField(field_uri='IsInline') + name = TextField(field_uri="Name") + content_type = TextField(field_uri="ContentType") + content_id = TextField(field_uri="ContentId") + content_location = URIField(field_uri="ContentLocation") + size = IntegerField(field_uri="Size", is_read_only=True) # Attachment size in bytes + last_modified_time = DateTimeField(field_uri="LastModifiedTime") + is_inline = BooleanField(field_uri="IsInline") - __slots__ = 'parent_item', + __slots__ = ("parent_item",) def __init__(self, **kwargs): - self.parent_item = kwargs.pop('parent_item', None) + self.parent_item = kwargs.pop("parent_item", None) super().__init__(**kwargs) def clean(self, version=None): from .items import Item + if self.parent_item is not None and not isinstance(self.parent_item, Item): - raise InvalidTypeError('parent_item', self.parent_item, Item) + raise InvalidTypeError("parent_item", self.parent_item, Item) if self.content_type is None and self.name is not None: - self.content_type = mimetypes.guess_type(self.name)[0] or 'application/octet-stream' + self.content_type = mimetypes.guess_type(self.name)[0] or "application/octet-stream" super().clean(version=version) def attach(self): from .services import CreateAttachment + # Adds this attachment to an item and updates the changekey of the parent item if self.attachment_id: - raise ValueError('This attachment has already been created') + raise ValueError("This attachment has already been created") if not self.parent_item or not self.parent_item.account: - raise ValueError(f'Parent item {self.parent_item} must have an account') + raise ValueError(f"Parent item {self.parent_item} must have an account") item = CreateAttachment(account=self.parent_item.account).get(parent_item=self.parent_item, items=[self]) attachment_id = item.attachment_id self.parent_item.changekey = attachment_id.root_changekey @@ -76,11 +88,12 @@ def attach(self): def detach(self): from .services import DeleteAttachment + # Deletes an attachment remotely and updates the changekey of the parent item if not self.attachment_id: - raise ValueError('This attachment has not been created') + raise ValueError("This attachment has not been created") if not self.parent_item or not self.parent_item.account: - raise ValueError(f'Parent item {self.parent_item} must have an account') + raise ValueError(f"Parent item {self.parent_item} must have an account") DeleteAttachment(account=self.parent_item.account).get(items=[self.attachment_id]) self.parent_item = None self.attachment_id = None @@ -89,27 +102,27 @@ def __hash__(self): if self.attachment_id: return hash(self.attachment_id) # Be careful to avoid recursion on the back-reference to the parent item - return hash(tuple(getattr(self, f) for f in self._slots_keys if f != 'parent_item')) + return hash(tuple(getattr(self, f) for f in self._slots_keys if f != "parent_item")) def __repr__(self): - args_str = ', '.join( - f'{f.name}={getattr(self, f.name)!r}' for f in self.FIELDS if f.name not in ('_item', '_content') + args_str = ", ".join( + f"{f.name}={getattr(self, f.name)!r}" for f in self.FIELDS if f.name not in ("_item", "_content") ) - return f'{self.__class__.__name__}({args_str})' + return f"{self.__class__.__name__}({args_str})" class FileAttachment(Attachment): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/fileattachment""" - ELEMENT_NAME = 'FileAttachment' + ELEMENT_NAME = "FileAttachment" - is_contact_photo = BooleanField(field_uri='IsContactPhoto') - _content = Base64Field(field_uri='Content') + is_contact_photo = BooleanField(field_uri="IsContactPhoto") + _content = Base64Field(field_uri="Content") - __slots__ = '_fp', + __slots__ = ("_fp",) def __init__(self, **kwargs): - kwargs['_content'] = kwargs.pop('content', None) + kwargs["_content"] = kwargs.pop("content", None) super().__init__(**kwargs) self._fp = None @@ -124,7 +137,7 @@ def _init_fp(self): # Create a file-like object for the attachment content. We try hard to reduce memory consumption so we never # store the full attachment content in-memory. if not self.parent_item or not self.parent_item.account: - raise ValueError(f'{self.__class__.__name__} must have an account') + raise ValueError(f"{self.__class__.__name__} must have an account") self._fp = FileAttachmentIO(attachment=self) @property @@ -145,13 +158,13 @@ def content(self): def content(self, value): """Replace the attachment content.""" if not isinstance(value, bytes): - raise InvalidTypeError('value', value, bytes) + raise InvalidTypeError("value", value, bytes) self._content = value @classmethod def from_xml(cls, elem, account): kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS} - kwargs['content'] = kwargs.pop('_content') + kwargs["content"] = kwargs.pop("_content") cls._clear(elem) return cls(**kwargs) @@ -162,7 +175,7 @@ def to_xml(self, version): def __getstate__(self): # The fp does not need to be pickled state = {k: getattr(self, k) for k in self._slots_keys} - del state['_fp'] + del state["_fp"] return state def __setstate__(self, state): @@ -175,30 +188,34 @@ def __setstate__(self, state): class ItemAttachment(Attachment): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/itemattachment""" - ELEMENT_NAME = 'ItemAttachment' + ELEMENT_NAME = "ItemAttachment" - _item = ItemField(field_uri='Item') + _item = ItemField(field_uri="Item") def __init__(self, **kwargs): - kwargs['_item'] = kwargs.pop('item', None) + kwargs["_item"] = kwargs.pop("item", None) super().__init__(**kwargs) @property def item(self): from .folders import BaseFolder from .services import GetAttachment + if self.attachment_id is None: return self._item if self._item is not None: return self._item # We have an ID to the data but still haven't called GetAttachment to get the actual data. Do that now. if not self.parent_item or not self.parent_item.account: - raise ValueError(f'{self.__class__.__name__} must have an account') + raise ValueError(f"{self.__class__.__name__} must have an account") additional_fields = { FieldPath(field=f) for f in BaseFolder.allowed_item_fields(version=self.parent_item.account.version) } attachment = GetAttachment(account=self.parent_item.account).get( - items=[self.attachment_id], include_mime_content=True, body_type=None, filter_html_content=None, + items=[self.attachment_id], + include_mime_content=True, + body_type=None, + filter_html_content=None, additional_fields=additional_fields, ) self._item = attachment.item @@ -207,14 +224,15 @@ def item(self): @item.setter def item(self, value): from .items import Item + if not isinstance(value, Item): - raise InvalidTypeError('value', value, Item) + raise InvalidTypeError("value", value, Item) self._item = value @classmethod def from_xml(cls, elem, account): kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS} - kwargs['item'] = kwargs.pop('_item') + kwargs["item"] = kwargs.pop("_item") cls._clear(elem) return cls(**kwargs) @@ -242,11 +260,12 @@ def readinto(self, b): return 0 else: output, self._overflow = chunk[:buf_size], chunk[buf_size:] - b[:len(output)] = output + b[: len(output)] = output return len(output) def __enter__(self): from .services import GetAttachment + self._stream = GetAttachment(account=self._attachment.parent_item.account).stream_file_content( attachment_id=self._attachment.attachment_id ) diff --git a/exchangelib/autodiscover/__init__.py b/exchangelib/autodiscover/__init__.py index 69e0e2c6..acc00a8e 100644 --- a/exchangelib/autodiscover/__init__.py +++ b/exchangelib/autodiscover/__init__.py @@ -14,6 +14,11 @@ def clear_cache(): __all__ = [ - 'AutodiscoverCache', 'AutodiscoverProtocol', 'Autodiscovery', 'discover', 'autodiscover_cache', - 'close_connections', 'clear_cache' + "AutodiscoverCache", + "AutodiscoverProtocol", + "Autodiscovery", + "discover", + "autodiscover_cache", + "close_connections", + "clear_cache", ] diff --git a/exchangelib/autodiscover/cache.py b/exchangelib/autodiscover/cache.py index e104ef8a..a4b18b1a 100644 --- a/exchangelib/autodiscover/cache.py +++ b/exchangelib/autodiscover/cache.py @@ -8,8 +8,8 @@ from contextlib import contextmanager from threading import RLock -from .protocol import AutodiscoverProtocol from ..configuration import Configuration +from .protocol import AutodiscoverProtocol log = logging.getLogger(__name__) @@ -25,8 +25,8 @@ def shelve_filename(): user = getpass.getuser() except KeyError: # getuser() fails on some systems. Provide a sane default. See issue #448 - user = 'exchangelib' - return f'exchangelib.{version}.cache.{user}.py{major}{minor}' + user = "exchangelib" + return f"exchangelib.{version}.cache.{user}.py{major}{minor}" AUTODISCOVER_PERSISTENT_STORAGE = os.path.join(tempfile.gettempdir(), shelve_filename()) @@ -42,13 +42,13 @@ def shelve_open_with_failover(filename): # Try to actually use the shelve. Some implementations may allow opening the file but then throw # errors on access. try: - _ = shelve_handle[''] + _ = shelve_handle[""] except KeyError: # The entry doesn't exist. This is expected. pass except Exception as e: - for f in glob.glob(filename + '*'): - log.warning('Deleting invalid cache file %s (%r)', f, e) + for f in glob.glob(filename + "*"): + log.warning("Deleting invalid cache file %s (%r)", f, e) os.unlink(f) shelve_handle = shelve.open(filename) yield shelve_handle @@ -100,9 +100,11 @@ def __getitem__(self, key): domain, credentials = key with shelve_open_with_failover(self._storage_file) as db: endpoint, auth_type, retry_policy = db[str(domain)] # It's OK to fail with KeyError here - protocol = AutodiscoverProtocol(config=Configuration( - service_endpoint=endpoint, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy - )) + protocol = AutodiscoverProtocol( + config=Configuration( + service_endpoint=endpoint, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy + ) + ) self._protocols[key] = protocol return protocol @@ -131,7 +133,7 @@ def __delitem__(self, key): def close(self): # Close all open connections for (domain, _), protocol in self._protocols.items(): - log.debug('Domain %s: Closing sessions', domain) + log.debug("Domain %s: Closing sessions", domain) protocol.close() del protocol self._protocols.clear() diff --git a/exchangelib/autodiscover/discovery.py b/exchangelib/autodiscover/discovery.py index 606acf38..2752b7a6 100644 --- a/exchangelib/autodiscover/discovery.py +++ b/exchangelib/autodiscover/discovery.py @@ -2,27 +2,33 @@ import time from urllib.parse import urlparse -import dns.resolver import dns.name +import dns.resolver from cached_property import threaded_cached_property +from ..configuration import Configuration +from ..errors import AutoDiscoverCircularRedirect, AutoDiscoverFailed, RedirectError, TransportError, UnauthorizedError +from ..protocol import FailFast, Protocol +from ..transport import AUTH_TYPE_MAP, DEFAULT_HEADERS, GSSAPI, NOAUTH, get_auth_method_from_response +from ..util import ( + CONNECTION_ERRORS, + TLS_ERRORS, + DummyResponse, + ParseError, + _back_off_if_needed, + get_domain, + get_redirect_url, + post_ratelimited, +) from .cache import autodiscover_cache from .properties import Autodiscover from .protocol import AutodiscoverProtocol -from ..configuration import Configuration -from ..errors import AutoDiscoverFailed, AutoDiscoverCircularRedirect, TransportError, RedirectError, UnauthorizedError -from ..protocol import Protocol, FailFast -from ..transport import get_auth_method_from_response, DEFAULT_HEADERS, NOAUTH, GSSAPI, AUTH_TYPE_MAP -from ..util import post_ratelimited, get_domain, get_redirect_url, _back_off_if_needed, \ - DummyResponse, ParseError, CONNECTION_ERRORS, TLS_ERRORS log = logging.getLogger(__name__) def discover(email, credentials=None, auth_type=None, retry_policy=None): - ad_response, protocol = Autodiscovery( - email=email, credentials=credentials - ).discover() + ad_response, protocol = Autodiscovery(email=email, credentials=credentials).discover() protocol.config.auth_typ = auth_type protocol.config.retry_policy = retry_policy return ad_response, protocol @@ -75,7 +81,7 @@ class Autodiscovery: MAX_REDIRECTS = 10 # Maximum number of URL redirects before we give up DNS_RESOLVER_KWARGS = {} DNS_RESOLVER_ATTRS = { - 'timeout': AutodiscoverProtocol.TIMEOUT, + "timeout": AutodiscoverProtocol.TIMEOUT, } def __init__(self, email, credentials=None): @@ -96,31 +102,31 @@ def discover(self): # Check the autodiscover cache to see if we already know the autodiscover service endpoint for this email # domain. Use a lock to guard against multiple threads competing to cache information. - log.debug('Waiting for autodiscover_cache lock') + log.debug("Waiting for autodiscover_cache lock") with autodiscover_cache: - log.debug('autodiscover_cache lock acquired') + log.debug("autodiscover_cache lock acquired") cache_key = self._cache_key domain = get_domain(self.email) if cache_key in autodiscover_cache: ad_protocol = autodiscover_cache[cache_key] - log.debug('Cache hit for key %s: %s', cache_key, ad_protocol.service_endpoint) + log.debug("Cache hit for key %s: %s", cache_key, ad_protocol.service_endpoint) try: ad_response = self._quick(protocol=ad_protocol) except AutoDiscoverFailed: # Autodiscover no longer works with this domain. Clear cache and try again after releasing the lock - log.debug('AD request failure. Removing cache for key %s', cache_key) + log.debug("AD request failure. Removing cache for key %s", cache_key) del autodiscover_cache[cache_key] ad_response = self._step_1(hostname=domain) else: # This will cache the result - log.debug('Cache miss for key %s', cache_key) + log.debug("Cache miss for key %s", cache_key) ad_response = self._step_1(hostname=domain) - log.debug('Released autodiscover_cache_lock') + log.debug("Released autodiscover_cache_lock") if ad_response.redirect_address: - log.debug('Got a redirect address: %s', ad_response.redirect_address) + log.debug("Got a redirect address: %s", ad_response.redirect_address) if ad_response.redirect_address.lower() in self._emails_visited: - raise AutoDiscoverCircularRedirect('We were redirected to an email address we have already seen') + raise AutoDiscoverCircularRedirect("We were redirected to an email address we have already seen") # Start over, but with the new email address self.email = ad_response.redirect_address @@ -169,15 +175,15 @@ def _quick(self, protocol): try: r = self._get_authenticated_response(protocol=protocol) except TransportError as e: - raise AutoDiscoverFailed(f'Response error: {e}') + raise AutoDiscoverFailed(f"Response error: {e}") if r.status_code == 200: try: ad = Autodiscover.from_bytes(bytes_content=r.content) except ParseError as e: - raise AutoDiscoverFailed(f'Invalid response: {e}') + raise AutoDiscoverFailed(f"Invalid response: {e}") else: return self._step_5(ad=ad) - raise AutoDiscoverFailed(f'Invalid response code: {r.status_code}') + raise AutoDiscoverFailed(f"Invalid response code: {r.status_code}") def _redirect_url_is_valid(self, url): """Three separate responses can be “Redirect responses”: @@ -193,29 +199,29 @@ def _redirect_url_is_valid(self, url): :return: """ if url.lower() in self._urls_visited: - log.warning('We have already tried this URL: %s', url) + log.warning("We have already tried this URL: %s", url) return False if self._redirect_count >= self.MAX_REDIRECTS: - log.warning('We reached max redirects at URL: %s', url) + log.warning("We reached max redirects at URL: %s", url) return False # We require TLS endpoints - if not url.startswith('https://'): - log.debug('Invalid scheme for URL: %s', url) + if not url.startswith("https://"): + log.debug("Invalid scheme for URL: %s", url) return False # Quick test that the endpoint responds and that TLS handshake is OK try: - self._get_unauthenticated_response(url, method='head') + self._get_unauthenticated_response(url, method="head") except TransportError as e: - log.debug('Response error on redirect URL %s: %s', url, e) + log.debug("Response error on redirect URL %s: %s", url, e) return False self._redirect_count += 1 return True - def _get_unauthenticated_response(self, url, method='post'): + def _get_unauthenticated_response(self, url, method="post"): """Get auth type by tasting headers from the server. Do POST requests be default. HEAD is too error prone, and some servers are set up to redirect to OWA on all requests except POST to the autodiscover endpoint. @@ -228,18 +234,18 @@ def _get_unauthenticated_response(self, url, method='post'): if not self._is_valid_hostname(hostname): # 'requests' is really bad at reporting that a hostname cannot be resolved. Let's check this separately. # Don't retry on DNS errors. They will most likely be persistent. - raise TransportError(f'{hostname!r} has no DNS entry') + raise TransportError(f"{hostname!r} has no DNS entry") kwargs = dict( url=url, headers=DEFAULT_HEADERS.copy(), allow_redirects=False, timeout=AutodiscoverProtocol.TIMEOUT ) - if method == 'post': - kwargs['data'] = Autodiscover.payload(email=self.email) + if method == "post": + kwargs["data"] = Autodiscover.payload(email=self.email) retry = 0 t_start = time.monotonic() while True: _back_off_if_needed(self.INITIAL_RETRY_POLICY.back_off_until) - log.debug('Trying to get response from %s', url) + log.debug("Trying to get response from %s", url) with AutodiscoverProtocol.raw_session(url) as s: try: r = getattr(s, method)(**kwargs) @@ -249,7 +255,7 @@ def _get_unauthenticated_response(self, url, method='post'): # Don't retry on TLS errors. They will most likely be persistent. raise TransportError(str(e)) except CONNECTION_ERRORS as e: - r = DummyResponse(url=url, request_headers=kwargs['headers']) + r = DummyResponse(url=url, request_headers=kwargs["headers"]) total_wait = time.monotonic() - t_start if self.INITIAL_RETRY_POLICY.may_retry_on_error(response=r, wait=total_wait): log.debug("Connection error on URL %s (retry %s, error: %s). Cool down", url, retry, e) @@ -265,12 +271,12 @@ def _get_unauthenticated_response(self, url, method='post'): except UnauthorizedError: # Failed to guess the auth type auth_type = NOAUTH - if r.status_code in (301, 302) and 'location' in r.headers: + if r.status_code in (301, 302) and "location" in r.headers: # Make the redirect URL absolute try: - r.headers['location'] = get_redirect_url(r) + r.headers["location"] = get_redirect_url(r) except TransportError: - del r.headers['location'] + del r.headers["location"] return auth_type, r def _get_authenticated_response(self, protocol): @@ -285,17 +291,24 @@ def _get_authenticated_response(self, protocol): session = protocol.get_session() if GSSAPI in AUTH_TYPE_MAP and isinstance(session.auth, AUTH_TYPE_MAP[GSSAPI]): # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/pox-autodiscover-request-for-exchange - headers['X-ClientCanHandle'] = 'Negotiate' + headers["X-ClientCanHandle"] = "Negotiate" try: - r, session = post_ratelimited(protocol=protocol, session=session, url=protocol.service_endpoint, - headers=headers, data=data, allow_redirects=False, stream=False) + r, session = post_ratelimited( + protocol=protocol, + session=session, + url=protocol.service_endpoint, + headers=headers, + data=data, + allow_redirects=False, + stream=False, + ) protocol.release_session(session) except UnauthorizedError as e: # It's entirely possible for the endpoint to ask for login. We should continue if login fails because this # isn't necessarily the right endpoint to use. raise TransportError(str(e)) except RedirectError as e: - r = DummyResponse(url=protocol.service_endpoint, headers={'location': e.url}, status_code=302) + r = DummyResponse(url=protocol.service_endpoint, headers={"location": e.url}, status_code=302) return r def _attempt_response(self, url): @@ -305,7 +318,7 @@ def _attempt_response(self, url): :return: """ self._urls_visited.append(url.lower()) - log.debug('Attempting to get a valid response from %s', url) + log.debug("Attempting to get a valid response from %s", url) try: auth_type, r = self._get_unauthenticated_response(url=url) ad_protocol = AutodiscoverProtocol( @@ -319,9 +332,9 @@ def _attempt_response(self, url): if auth_type != NOAUTH: r = self._get_authenticated_response(protocol=ad_protocol) except TransportError as e: - log.debug('Failed to get a response: %s', e) + log.debug("Failed to get a response: %s", e) return False, None - if r.status_code in (301, 302) and 'location' in r.headers: + if r.status_code in (301, 302) and "location" in r.headers: redirect_url = get_redirect_url(r) if self._redirect_url_is_valid(url=redirect_url): # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com @@ -331,18 +344,18 @@ def _attempt_response(self, url): try: ad = Autodiscover.from_bytes(bytes_content=r.content) except ParseError as e: - log.debug('Invalid response: %s', e) + log.debug("Invalid response: %s", e) else: # We got a valid response. Unless this is a URL redirect response, we cache the result if ad.response is None or not ad.response.redirect_url: cache_key = self._cache_key - log.debug('Adding cache entry for key %s: %s', cache_key, ad_protocol.service_endpoint) + log.debug("Adding cache entry for key %s: %s", cache_key, ad_protocol.service_endpoint) autodiscover_cache[cache_key] = ad_protocol return True, ad return False, None def _is_valid_hostname(self, hostname): - log.debug('Checking if %s can be looked up in DNS', hostname) + log.debug("Checking if %s can be looked up in DNS", hostname) try: self.resolver.resolve(hostname) except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.name.EmptyLabel): @@ -362,23 +375,23 @@ def _get_srv_records(self, hostname): :param hostname: :return: """ - log.debug('Attempting to get SRV records for %s', hostname) + log.debug("Attempting to get SRV records for %s", hostname) records = [] try: - answers = self.resolver.resolve(f'{hostname}.', 'SRV') + answers = self.resolver.resolve(f"{hostname}.", "SRV") except (dns.resolver.NoNameservers, dns.resolver.NoAnswer, dns.resolver.NXDOMAIN) as e: - log.debug('DNS lookup failure: %s', e) + log.debug("DNS lookup failure: %s", e) return records for rdata in answers: try: - vals = rdata.to_text().strip().rstrip('.').split(' ') + vals = rdata.to_text().strip().rstrip(".").split(" ") # Raise ValueError if the first three are not ints, and IndexError if there are less than 4 values priority, weight, port, srv = int(vals[0]), int(vals[1]), int(vals[2]), vals[3] record = SrvRecord(priority=priority, weight=weight, port=port, srv=srv) - log.debug('Found SRV record %s ', record) + log.debug("Found SRV record %s ", record) records.append(record) except (ValueError, IndexError): - log.debug('Incompatible SRV record for %s (%s)', hostname, rdata.to_text()) + log.debug("Incompatible SRV record for %s (%s)", hostname, rdata.to_text()) return records def _step_1(self, hostname): @@ -390,8 +403,8 @@ def _step_1(self, hostname): :param hostname: :return: """ - url = f'https://{hostname}/Autodiscover/Autodiscover.xml' - log.info('Step 1: Trying autodiscover on %r with email %r', url, self.email) + url = f"https://{hostname}/Autodiscover/Autodiscover.xml" + log.info("Step 1: Trying autodiscover on %r with email %r", url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: return self._step_5(ad=ad) @@ -406,8 +419,8 @@ def _step_2(self, hostname): :param hostname: :return: """ - url = f'https://autodiscover.{hostname}/Autodiscover/Autodiscover.xml' - log.info('Step 2: Trying autodiscover on %r with email %r', url, self.email) + url = f"https://autodiscover.{hostname}/Autodiscover/Autodiscover.xml" + log.info("Step 2: Trying autodiscover on %r with email %r", url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: return self._step_5(ad=ad) @@ -429,23 +442,23 @@ def _step_3(self, hostname): :param hostname: :return: """ - url = f'http://autodiscover.{hostname}/Autodiscover/Autodiscover.xml' - log.info('Step 3: Trying autodiscover on %r with email %r', url, self.email) + url = f"http://autodiscover.{hostname}/Autodiscover/Autodiscover.xml" + log.info("Step 3: Trying autodiscover on %r with email %r", url, self.email) try: - _, r = self._get_unauthenticated_response(url=url, method='get') + _, r = self._get_unauthenticated_response(url=url, method="get") except TransportError: r = DummyResponse(url=url) - if r.status_code in (301, 302) and 'location' in r.headers: + if r.status_code in (301, 302) and "location" in r.headers: redirect_url = get_redirect_url(r) if self._redirect_url_is_valid(url=redirect_url): is_valid_response, ad = self._attempt_response(url=redirect_url) if is_valid_response: return self._step_5(ad=ad) - log.debug('Got invalid response') + log.debug("Got invalid response") return self._step_4(hostname=hostname) - log.debug('Got invalid redirect URL') + log.debug("Got invalid redirect URL") return self._step_4(hostname=hostname) - log.debug('Got no redirect URL') + log.debug("Got no redirect URL") return self._step_4(hostname=hostname) def _step_4(self, hostname): @@ -465,8 +478,8 @@ def _step_4(self, hostname): :param hostname: :return: """ - dns_hostname = f'_autodiscover._tcp.{hostname}' - log.info('Step 4: Trying autodiscover on %r with email %r', dns_hostname, self.email) + dns_hostname = f"_autodiscover._tcp.{hostname}" + log.info("Step 4: Trying autodiscover on %r with email %r", dns_hostname, self.email) srv_records = self._get_srv_records(dns_hostname) try: srv_host = _select_srv_host(srv_records) @@ -474,14 +487,14 @@ def _step_4(self, hostname): srv_host = None if not srv_host: return self._step_6() - redirect_url = f'https://{srv_host}/Autodiscover/Autodiscover.xml' + redirect_url = f"https://{srv_host}/Autodiscover/Autodiscover.xml" if self._redirect_url_is_valid(url=redirect_url): is_valid_response, ad = self._attempt_response(url=redirect_url) if is_valid_response: return self._step_5(ad=ad) - log.debug('Got invalid response') + log.debug("Got invalid response") return self._step_6() - log.debug('Got invalid redirect URL') + log.debug("Got invalid redirect URL") return self._step_6() def _step_5(self, ad): @@ -501,23 +514,23 @@ def _step_5(self, ad): :param ad: :return: """ - log.info('Step 5: Checking response') + log.info("Step 5: Checking response") if ad.response is None: # This is not explicit in the protocol, but let's raise errors here ad.raise_errors() ad_response = ad.response if ad_response.redirect_url: - log.debug('Got a redirect URL: %s', ad_response.redirect_url) + log.debug("Got a redirect URL: %s", ad_response.redirect_url) # We are diverging a bit from the protocol here. We will never get an HTTP 302 since earlier steps already # followed the redirects where possible. Instead, we handle redirect responses here. if self._redirect_url_is_valid(url=ad_response.redirect_url): is_valid_response, ad = self._attempt_response(url=ad_response.redirect_url) if is_valid_response: return self._step_5(ad=ad) - log.debug('Got invalid response') + log.debug("Got invalid response") return self._step_6() - log.debug('Invalid redirect URL') + log.debug("Invalid redirect URL") return self._step_6() # This could be an email redirect. Let outer layer handle this return ad_response @@ -528,8 +541,9 @@ def _step_6(self): future requests. """ raise AutoDiscoverFailed( - f'All steps in the autodiscover protocol failed for email {self.email}. If you think this is an error, ' - f'consider doing an official test at https://testconnectivity.microsoft.com') + f"All steps in the autodiscover protocol failed for email {self.email}. If you think this is an error, " + f"consider doing an official test at https://testconnectivity.microsoft.com" + ) def _select_srv_host(srv_records): @@ -541,11 +555,11 @@ def _select_srv_host(srv_records): best_record = None for srv_record in srv_records: if srv_record.port != 443: - log.debug('Skipping SRV record %r (no TLS)', srv_record) + log.debug("Skipping SRV record %r (no TLS)", srv_record) continue # Assume port 443 will serve TLS. If not, autodiscover will probably also be broken for others. if best_record is None or best_record.priority < srv_record.priority: best_record = srv_record if not best_record: - raise ValueError('No suitable records') + raise ValueError("No suitable records") return best_record.srv diff --git a/exchangelib/autodiscover/properties.py b/exchangelib/autodiscover/properties.py index 239e9a01..bd61fe31 100644 --- a/exchangelib/autodiscover/properties.py +++ b/exchangelib/autodiscover/properties.py @@ -1,10 +1,21 @@ -from ..errors import ErrorNonExistentMailbox, AutoDiscoverFailed -from ..fields import TextField, EmailAddressField, ChoiceField, Choice, EWSElementField, OnOffField, BooleanField, \ - IntegerField, BuildField, ProtocolListField +from ..errors import AutoDiscoverFailed, ErrorNonExistentMailbox +from ..fields import ( + BooleanField, + BuildField, + Choice, + ChoiceField, + EmailAddressField, + EWSElementField, + IntegerField, + OnOffField, + ProtocolListField, + TextField, +) from ..properties import EWSElement -from ..transport import DEFAULT_ENCODING, NOAUTH, NTLM, BASIC, GSSAPI, SSPI, CBA -from ..util import create_element, add_xml_child, to_xml, is_xml, xml_to_str, AUTODISCOVER_REQUEST_NS, \ - AUTODISCOVER_BASE_NS, AUTODISCOVER_RESPONSE_NS as RNS, ParseError +from ..transport import BASIC, CBA, DEFAULT_ENCODING, GSSAPI, NOAUTH, NTLM, SSPI +from ..util import AUTODISCOVER_BASE_NS, AUTODISCOVER_REQUEST_NS +from ..util import AUTODISCOVER_RESPONSE_NS as RNS +from ..util import ParseError, add_xml_child, create_element, is_xml, to_xml, xml_to_str from ..version import Version @@ -15,40 +26,40 @@ class AutodiscoverBase(EWSElement): class User(AutodiscoverBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/user-pox""" - ELEMENT_NAME = 'User' + ELEMENT_NAME = "User" - display_name = TextField(field_uri='DisplayName', namespace=RNS) - legacy_dn = TextField(field_uri='LegacyDN', namespace=RNS) - deployment_id = TextField(field_uri='DeploymentId', namespace=RNS) # GUID format - autodiscover_smtp_address = EmailAddressField(field_uri='AutoDiscoverSMTPAddress', namespace=RNS) + display_name = TextField(field_uri="DisplayName", namespace=RNS) + legacy_dn = TextField(field_uri="LegacyDN", namespace=RNS) + deployment_id = TextField(field_uri="DeploymentId", namespace=RNS) # GUID format + autodiscover_smtp_address = EmailAddressField(field_uri="AutoDiscoverSMTPAddress", namespace=RNS) class IntExtUrlBase(AutodiscoverBase): - external_url = TextField(field_uri='ExternalUrl', namespace=RNS) - internal_url = TextField(field_uri='InternalUrl', namespace=RNS) + external_url = TextField(field_uri="ExternalUrl", namespace=RNS) + internal_url = TextField(field_uri="InternalUrl", namespace=RNS) class AddressBook(IntExtUrlBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/addressbook-pox""" - ELEMENT_NAME = 'AddressBook' + ELEMENT_NAME = "AddressBook" class MailStore(IntExtUrlBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailstore-pox""" - ELEMENT_NAME = 'MailStore' + ELEMENT_NAME = "MailStore" class NetworkRequirements(AutodiscoverBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/networkrequirements-pox""" - ELEMENT_NAME = 'NetworkRequirements' + ELEMENT_NAME = "NetworkRequirements" - ipv4_start = TextField(field_uri='IPv4Start', namespace=RNS) - ipv4_end = TextField(field_uri='IPv4End', namespace=RNS) - ipv6_start = TextField(field_uri='IPv6Start', namespace=RNS) - ipv6_end = TextField(field_uri='IPv6End', namespace=RNS) + ipv4_start = TextField(field_uri="IPv4Start", namespace=RNS) + ipv4_end = TextField(field_uri="IPv4End", namespace=RNS) + ipv6_start = TextField(field_uri="IPv6Start", namespace=RNS) + ipv6_end = TextField(field_uri="IPv6End", namespace=RNS) class SimpleProtocol(AutodiscoverBase): @@ -57,84 +68,86 @@ class SimpleProtocol(AutodiscoverBase): Used for the 'Internal' and 'External' elements that may contain a stripped-down version of the Protocol element. """ - ELEMENT_NAME = 'Protocol' - WEB = 'WEB' - EXCH = 'EXCH' - EXPR = 'EXPR' - EXHTTP = 'EXHTTP' + ELEMENT_NAME = "Protocol" + WEB = "WEB" + EXCH = "EXCH" + EXPR = "EXPR" + EXHTTP = "EXHTTP" TYPES = (WEB, EXCH, EXPR, EXHTTP) - type = ChoiceField(field_uri='Type', choices={Choice(c) for c in TYPES}, namespace=RNS) - as_url = TextField(field_uri='ASUrl', namespace=RNS) + type = ChoiceField(field_uri="Type", choices={Choice(c) for c in TYPES}, namespace=RNS) + as_url = TextField(field_uri="ASUrl", namespace=RNS) class IntExtBase(AutodiscoverBase): # TODO: 'OWAUrl' also has an AuthenticationMethod enum-style XML attribute with values: # WindowsIntegrated, FBA, NTLM, Digest, Basic - owa_url = TextField(field_uri='OWAUrl', namespace=RNS) + owa_url = TextField(field_uri="OWAUrl", namespace=RNS) protocol = EWSElementField(value_cls=SimpleProtocol) class Internal(IntExtBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/internal-pox""" - ELEMENT_NAME = 'Internal' + ELEMENT_NAME = "Internal" class External(IntExtBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/external-pox""" - ELEMENT_NAME = 'External' + ELEMENT_NAME = "External" class Protocol(SimpleProtocol): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/protocol-pox""" # Attribute 'Type' is ignored here. Has a name conflict with the child element and does not seem useful. - version = TextField(field_uri='Version', is_attribute=True, namespace=RNS) + version = TextField(field_uri="Version", is_attribute=True, namespace=RNS) internal = EWSElementField(value_cls=Internal) external = EWSElementField(value_cls=External) - ttl = IntegerField(field_uri='TTL', namespace=RNS, default=1) # TTL for this autodiscover response, in hours - server = TextField(field_uri='Server', namespace=RNS) - server_dn = TextField(field_uri='ServerDN', namespace=RNS) - server_version = BuildField(field_uri='ServerVersion', namespace=RNS) - mdb_dn = TextField(field_uri='MdbDN', namespace=RNS) - public_folder_server = TextField(field_uri='PublicFolderServer', namespace=RNS) - port = IntegerField(field_uri='Port', namespace=RNS, min=1, max=65535) - directory_port = IntegerField(field_uri='DirectoryPort', namespace=RNS, min=1, max=65535) - referral_port = IntegerField(field_uri='ReferralPort', namespace=RNS, min=1, max=65535) - ews_url = TextField(field_uri='EwsUrl', namespace=RNS) - emws_url = TextField(field_uri='EmwsUrl', namespace=RNS) - sharing_url = TextField(field_uri='SharingUrl', namespace=RNS) - ecp_url = TextField(field_uri='EcpUrl', namespace=RNS) - ecp_url_um = TextField(field_uri='EcpUrl-um', namespace=RNS) - ecp_url_aggr = TextField(field_uri='EcpUrl-aggr', namespace=RNS) - ecp_url_mt = TextField(field_uri='EcpUrl-mt', namespace=RNS) - ecp_url_ret = TextField(field_uri='EcpUrl-ret', namespace=RNS) - ecp_url_sms = TextField(field_uri='EcpUrl-sms', namespace=RNS) - ecp_url_publish = TextField(field_uri='EcpUrl-publish', namespace=RNS) - ecp_url_photo = TextField(field_uri='EcpUrl-photo', namespace=RNS) - ecp_url_tm = TextField(field_uri='EcpUrl-tm', namespace=RNS) - ecp_url_tm_creating = TextField(field_uri='EcpUrl-tmCreating', namespace=RNS) - ecp_url_tm_hiding = TextField(field_uri='EcpUrl-tmHiding', namespace=RNS) - ecp_url_tm_editing = TextField(field_uri='EcpUrl-tmEditing', namespace=RNS) - ecp_url_extinstall = TextField(field_uri='EcpUrl-extinstall', namespace=RNS) - oof_url = TextField(field_uri='OOFUrl', namespace=RNS) - oab_url = TextField(field_uri='OABUrl', namespace=RNS) - um_url = TextField(field_uri='UMUrl', namespace=RNS) - ews_partner_url = TextField(field_uri='EwsPartnerUrl', namespace=RNS) - login_name = TextField(field_uri='LoginName', namespace=RNS) - domain_required = OnOffField(field_uri='DomainRequired', namespace=RNS) - domain_name = TextField(field_uri='DomainName', namespace=RNS) - spa = OnOffField(field_uri='SPA', namespace=RNS, default=True) - auth_package = ChoiceField(field_uri='AuthPackage', namespace=RNS, choices={ - Choice(c) for c in ('basic', 'kerb', 'kerbntlm', 'ntlm', 'certificate', 'negotiate', 'nego2') - }) - cert_principal_name = TextField(field_uri='CertPrincipalName', namespace=RNS) - ssl = OnOffField(field_uri='SSL', namespace=RNS, default=True) - auth_required = OnOffField(field_uri='AuthRequired', namespace=RNS, default=True) - use_pop_path = OnOffField(field_uri='UsePOPAuth', namespace=RNS) - smtp_last = OnOffField(field_uri='SMTPLast', namespace=RNS, default=False) + ttl = IntegerField(field_uri="TTL", namespace=RNS, default=1) # TTL for this autodiscover response, in hours + server = TextField(field_uri="Server", namespace=RNS) + server_dn = TextField(field_uri="ServerDN", namespace=RNS) + server_version = BuildField(field_uri="ServerVersion", namespace=RNS) + mdb_dn = TextField(field_uri="MdbDN", namespace=RNS) + public_folder_server = TextField(field_uri="PublicFolderServer", namespace=RNS) + port = IntegerField(field_uri="Port", namespace=RNS, min=1, max=65535) + directory_port = IntegerField(field_uri="DirectoryPort", namespace=RNS, min=1, max=65535) + referral_port = IntegerField(field_uri="ReferralPort", namespace=RNS, min=1, max=65535) + ews_url = TextField(field_uri="EwsUrl", namespace=RNS) + emws_url = TextField(field_uri="EmwsUrl", namespace=RNS) + sharing_url = TextField(field_uri="SharingUrl", namespace=RNS) + ecp_url = TextField(field_uri="EcpUrl", namespace=RNS) + ecp_url_um = TextField(field_uri="EcpUrl-um", namespace=RNS) + ecp_url_aggr = TextField(field_uri="EcpUrl-aggr", namespace=RNS) + ecp_url_mt = TextField(field_uri="EcpUrl-mt", namespace=RNS) + ecp_url_ret = TextField(field_uri="EcpUrl-ret", namespace=RNS) + ecp_url_sms = TextField(field_uri="EcpUrl-sms", namespace=RNS) + ecp_url_publish = TextField(field_uri="EcpUrl-publish", namespace=RNS) + ecp_url_photo = TextField(field_uri="EcpUrl-photo", namespace=RNS) + ecp_url_tm = TextField(field_uri="EcpUrl-tm", namespace=RNS) + ecp_url_tm_creating = TextField(field_uri="EcpUrl-tmCreating", namespace=RNS) + ecp_url_tm_hiding = TextField(field_uri="EcpUrl-tmHiding", namespace=RNS) + ecp_url_tm_editing = TextField(field_uri="EcpUrl-tmEditing", namespace=RNS) + ecp_url_extinstall = TextField(field_uri="EcpUrl-extinstall", namespace=RNS) + oof_url = TextField(field_uri="OOFUrl", namespace=RNS) + oab_url = TextField(field_uri="OABUrl", namespace=RNS) + um_url = TextField(field_uri="UMUrl", namespace=RNS) + ews_partner_url = TextField(field_uri="EwsPartnerUrl", namespace=RNS) + login_name = TextField(field_uri="LoginName", namespace=RNS) + domain_required = OnOffField(field_uri="DomainRequired", namespace=RNS) + domain_name = TextField(field_uri="DomainName", namespace=RNS) + spa = OnOffField(field_uri="SPA", namespace=RNS, default=True) + auth_package = ChoiceField( + field_uri="AuthPackage", + namespace=RNS, + choices={Choice(c) for c in ("basic", "kerb", "kerbntlm", "ntlm", "certificate", "negotiate", "nego2")}, + ) + cert_principal_name = TextField(field_uri="CertPrincipalName", namespace=RNS) + ssl = OnOffField(field_uri="SSL", namespace=RNS, default=True) + auth_required = OnOffField(field_uri="AuthRequired", namespace=RNS, default=True) + use_pop_path = OnOffField(field_uri="UsePOPAuth", namespace=RNS) + smtp_last = OnOffField(field_uri="SMTPLast", namespace=RNS, default=False) network_requirements = EWSElementField(value_cls=NetworkRequirements) address_book = EWSElementField(value_cls=AddressBook) mail_store = EWSElementField(value_cls=MailStore) @@ -148,56 +161,56 @@ def auth_type(self): return None return { # Missing in list are DIGEST and OAUTH2 - 'basic': BASIC, - 'kerb': GSSAPI, - 'kerbntlm': NTLM, # Means client can chose between NTLM and GSSAPI - 'ntlm': NTLM, - 'certificate': CBA, - 'negotiate': SSPI, # Unsure about this one - 'nego2': GSSAPI, - 'anonymous': NOAUTH, # Seen in some docs even though it's not mentioned in MSDN + "basic": BASIC, + "kerb": GSSAPI, + "kerbntlm": NTLM, # Means client can chose between NTLM and GSSAPI + "ntlm": NTLM, + "certificate": CBA, + "negotiate": SSPI, # Unsure about this one + "nego2": GSSAPI, + "anonymous": NOAUTH, # Seen in some docs even though it's not mentioned in MSDN }.get(self.auth_package.lower()) class Error(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/error-pox""" - ELEMENT_NAME = 'Error' + ELEMENT_NAME = "Error" NAMESPACE = AUTODISCOVER_BASE_NS - id = TextField(field_uri='Id', namespace=AUTODISCOVER_BASE_NS, is_attribute=True) - time = TextField(field_uri='Time', namespace=AUTODISCOVER_BASE_NS, is_attribute=True) - code = TextField(field_uri='ErrorCode', namespace=AUTODISCOVER_BASE_NS) - message = TextField(field_uri='Message', namespace=AUTODISCOVER_BASE_NS) - debug_data = TextField(field_uri='DebugData', namespace=AUTODISCOVER_BASE_NS) + id = TextField(field_uri="Id", namespace=AUTODISCOVER_BASE_NS, is_attribute=True) + time = TextField(field_uri="Time", namespace=AUTODISCOVER_BASE_NS, is_attribute=True) + code = TextField(field_uri="ErrorCode", namespace=AUTODISCOVER_BASE_NS) + message = TextField(field_uri="Message", namespace=AUTODISCOVER_BASE_NS) + debug_data = TextField(field_uri="DebugData", namespace=AUTODISCOVER_BASE_NS) class Account(AutodiscoverBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/account-pox""" - ELEMENT_NAME = 'Account' - REDIRECT_URL = 'redirectUrl' - REDIRECT_ADDR = 'redirectAddr' - SETTINGS = 'settings' + ELEMENT_NAME = "Account" + REDIRECT_URL = "redirectUrl" + REDIRECT_ADDR = "redirectAddr" + SETTINGS = "settings" ACTIONS = (REDIRECT_URL, REDIRECT_ADDR, SETTINGS) - type = ChoiceField(field_uri='AccountType', namespace=RNS, choices={Choice('email')}) - action = ChoiceField(field_uri='Action', namespace=RNS, choices={Choice(p) for p in ACTIONS}) - microsoft_online = BooleanField(field_uri='MicrosoftOnline', namespace=RNS) - redirect_url = TextField(field_uri='RedirectURL', namespace=RNS) - redirect_address = EmailAddressField(field_uri='RedirectAddr', namespace=RNS) - image = TextField(field_uri='Image', namespace=RNS) # Path to image used for branding - service_home = TextField(field_uri='ServiceHome', namespace=RNS) # URL to website of ISP + type = ChoiceField(field_uri="AccountType", namespace=RNS, choices={Choice("email")}) + action = ChoiceField(field_uri="Action", namespace=RNS, choices={Choice(p) for p in ACTIONS}) + microsoft_online = BooleanField(field_uri="MicrosoftOnline", namespace=RNS) + redirect_url = TextField(field_uri="RedirectURL", namespace=RNS) + redirect_address = EmailAddressField(field_uri="RedirectAddr", namespace=RNS) + image = TextField(field_uri="Image", namespace=RNS) # Path to image used for branding + service_home = TextField(field_uri="ServiceHome", namespace=RNS) # URL to website of ISP protocols = ProtocolListField() # 'SmtpAddress' is inside the 'PublicFolderInformation' element - public_folder_smtp_address = TextField(field_uri='SmtpAddress', namespace=RNS) + public_folder_smtp_address = TextField(field_uri="SmtpAddress", namespace=RNS) @classmethod def from_xml(cls, elem, account): kwargs = {} - public_folder_information = elem.find(f'{{{cls.NAMESPACE}}}PublicFolderInformation') + public_folder_information = elem.find(f"{{{cls.NAMESPACE}}}PublicFolderInformation") for f in cls.FIELDS: - if f.name == 'public_folder_smtp_address': + if f.name == "public_folder_smtp_address": if public_folder_information is None: continue kwargs[f.name] = f.from_xml(elem=public_folder_information, account=account) @@ -210,7 +223,7 @@ def from_xml(cls, elem, account): class Response(AutodiscoverBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/response-pox""" - ELEMENT_NAME = 'Response' + ELEMENT_NAME = "Response" user = EWSElementField(value_cls=User) account = EWSElementField(value_cls=Account) @@ -272,7 +285,7 @@ def protocol(self): if Protocol.EXCH in protocols: return protocols[Protocol.EXCH] raise ValueError( - f'No EWS URL found in any of the available protocols: {[str(p) for p in self.account.protocols]}' + f"No EWS URL found in any of the available protocols: {[str(p) for p in self.account.protocols]}" ) @@ -282,14 +295,14 @@ class ErrorResponse(EWSElement): Like 'Response', but with a different namespace. """ - ELEMENT_NAME = 'Response' + ELEMENT_NAME = "Response" NAMESPACE = AUTODISCOVER_BASE_NS error = EWSElementField(value_cls=Error) class Autodiscover(EWSElement): - ELEMENT_NAME = 'Autodiscover' + ELEMENT_NAME = "Autodiscover" NAMESPACE = AUTODISCOVER_BASE_NS response = EWSElementField(value_cls=Response) @@ -309,10 +322,10 @@ def from_bytes(cls, bytes_content): :return: """ if not is_xml(bytes_content): - raise ParseError(f'Response is not XML: {bytes_content}', '', -1, 0) + raise ParseError(f"Response is not XML: {bytes_content}", "", -1, 0) root = to_xml(bytes_content).getroot() # May raise ParseError if root.tag != cls.response_tag(): - raise ParseError(f'Unknown root element in XML: {bytes_content}', '', -1, 0) + raise ParseError(f"Unknown root element in XML: {bytes_content}", "", -1, 0) return cls.from_xml(elem=root, account=None) def raise_errors(self): @@ -320,18 +333,18 @@ def raise_errors(self): try: errorcode = self.error_response.error.code message = self.error_response.error.message - if message in ('The e-mail address cannot be found.', "The email address can't be found."): - raise ErrorNonExistentMailbox('The SMTP address has no mailbox associated with it') - raise AutoDiscoverFailed(f'Unknown error {errorcode}: {message}') + if message in ("The e-mail address cannot be found.", "The email address can't be found."): + raise ErrorNonExistentMailbox("The SMTP address has no mailbox associated with it") + raise AutoDiscoverFailed(f"Unknown error {errorcode}: {message}") except AttributeError: - raise AutoDiscoverFailed(f'Unknown autodiscover error response: {self.error_response}') + raise AutoDiscoverFailed(f"Unknown autodiscover error response: {self.error_response}") @staticmethod def payload(email): # Builds a full Autodiscover XML request - payload = create_element('Autodiscover', attrs=dict(xmlns=AUTODISCOVER_REQUEST_NS)) - request = create_element('Request') - add_xml_child(request, 'EMailAddress', email) - add_xml_child(request, 'AcceptableResponseSchema', RNS) + payload = create_element("Autodiscover", attrs=dict(xmlns=AUTODISCOVER_REQUEST_NS)) + request = create_element("Request") + add_xml_child(request, "EMailAddress", email) + add_xml_child(request, "AcceptableResponseSchema", RNS) payload.append(request) return xml_to_str(payload, encoding=DEFAULT_ENCODING, xml_declaration=True) diff --git a/exchangelib/autodiscover/protocol.py b/exchangelib/autodiscover/protocol.py index 97f6ec46..a5fc044c 100644 --- a/exchangelib/autodiscover/protocol.py +++ b/exchangelib/autodiscover/protocol.py @@ -7,6 +7,6 @@ class AutodiscoverProtocol(BaseProtocol): TIMEOUT = 10 # Seconds def __str__(self): - return f'''\ + return f"""\ Autodiscover endpoint: {self.service_endpoint} -Auth type: {self.auth_type}''' +Auth type: {self.auth_type}""" diff --git a/exchangelib/configuration.py b/exchangelib/configuration.py index 1122e148..a1336ef3 100644 --- a/exchangelib/configuration.py +++ b/exchangelib/configuration.py @@ -2,10 +2,10 @@ from cached_property import threaded_cached_property +from .credentials import BaseCredentials, OAuth2AuthorizationCodeCredentials, OAuth2Credentials from .errors import InvalidEnumValue, InvalidTypeError -from .credentials import BaseCredentials, OAuth2Credentials, OAuth2AuthorizationCodeCredentials -from .protocol import RetryPolicy, FailFast -from .transport import AUTH_TYPE_MAP, OAUTH2, CREDENTIALS_REQUIRED +from .protocol import FailFast, RetryPolicy +from .transport import AUTH_TYPE_MAP, CREDENTIALS_REQUIRED, OAUTH2 from .util import split_url from .version import Version @@ -46,30 +46,38 @@ class Configuration: policies on the Exchange server. """ - def __init__(self, credentials=None, server=None, service_endpoint=None, auth_type=None, version=None, - retry_policy=None, max_connections=None): + def __init__( + self, + credentials=None, + server=None, + service_endpoint=None, + auth_type=None, + version=None, + retry_policy=None, + max_connections=None, + ): if not isinstance(credentials, (BaseCredentials, type(None))): - raise InvalidTypeError('credentials', credentials, BaseCredentials) + raise InvalidTypeError("credentials", credentials, BaseCredentials) if auth_type is None: # Set a default auth type for the credentials where this makes sense auth_type = DEFAULT_AUTH_TYPE.get(type(credentials)) elif credentials is None and auth_type in CREDENTIALS_REQUIRED: - raise ValueError(f'Auth type {auth_type!r} was detected but no credentials were provided') + raise ValueError(f"Auth type {auth_type!r} was detected but no credentials were provided") if server and service_endpoint: raise AttributeError("Only one of 'server' or 'service_endpoint' must be provided") if auth_type is not None and auth_type not in AUTH_TYPE_MAP: - raise InvalidEnumValue('auth_type', auth_type, AUTH_TYPE_MAP) + raise InvalidEnumValue("auth_type", auth_type, AUTH_TYPE_MAP) if not retry_policy: retry_policy = FailFast() if not isinstance(version, (Version, type(None))): - raise InvalidTypeError('version', version, Version) + raise InvalidTypeError("version", version, Version) if not isinstance(retry_policy, RetryPolicy): - raise InvalidTypeError('retry_policy', retry_policy, RetryPolicy) + raise InvalidTypeError("retry_policy", retry_policy, RetryPolicy) if not isinstance(max_connections, (int, type(None))): - raise InvalidTypeError('max_connections', max_connections, int) + raise InvalidTypeError("max_connections", max_connections, int) self._credentials = credentials if server: - self.service_endpoint = f'https://{server}/EWS/Exchange.asmx' + self.service_endpoint = f"https://{server}/EWS/Exchange.asmx" else: self.service_endpoint = service_endpoint self.auth_type = auth_type @@ -89,7 +97,8 @@ def server(self): return split_url(self.service_endpoint)[1] def __repr__(self): - args_str = ', '.join(f'{k}={getattr(self, k)!r}' for k in ( - 'credentials', 'service_endpoint', 'auth_type', 'version', 'retry_policy' - )) - return f'{self.__class__.__name__}({args_str})' + args_str = ", ".join( + f"{k}={getattr(self, k)!r}" + for k in ("credentials", "service_endpoint", "auth_type", "version", "retry_policy") + ) + return f"{self.__class__.__name__}({args_str})" diff --git a/exchangelib/credentials.py b/exchangelib/credentials.py index 1e5bb73e..869fde88 100644 --- a/exchangelib/credentials.py +++ b/exchangelib/credentials.py @@ -14,8 +14,8 @@ log = logging.getLogger(__name__) -IMPERSONATION = 'impersonation' -DELEGATE = 'delegate' +IMPERSONATION = "impersonation" +DELEGATE = "delegate" ACCESS_TYPES = (IMPERSONATION, DELEGATE) @@ -44,10 +44,10 @@ def refresh(self, session): """ def _get_hash_values(self): - return (getattr(self, k) for k in self.__dict__ if k != '_lock') + return (getattr(self, k) for k in self.__dict__ if k != "_lock") def __eq__(self, other): - return all(getattr(self, k) == getattr(other, k) for k in self.__dict__ if k != '_lock') + return all(getattr(self, k) == getattr(other, k) for k in self.__dict__ if k != "_lock") def __hash__(self): return hash(tuple(self._get_hash_values())) @@ -55,7 +55,7 @@ def __hash__(self): def __getstate__(self): # The lock cannot be pickled state = self.__dict__.copy() - del state['_lock'] + del state["_lock"] return state def __setstate__(self, state): @@ -74,15 +74,15 @@ class Credentials(BaseCredentials): password: Clear-text password """ - EMAIL = 'email' - DOMAIN = 'domain' - UPN = 'upn' + EMAIL = "email" + DOMAIN = "domain" + UPN = "upn" def __init__(self, username, password): super().__init__() - if username.count('@') == 1: + if username.count("@") == 1: self.type = self.EMAIL - elif username.count('\\') == 1: + elif username.count("\\") == 1: self.type = self.DOMAIN else: self.type = self.UPN @@ -93,7 +93,7 @@ def refresh(self, session): pass def __repr__(self): - return self.__class__.__name__ + repr((self.username, '********')) + return self.__class__.__name__ + repr((self.username, "********")) def __str__(self): return self.username @@ -139,31 +139,31 @@ def on_token_auto_refreshed(self, access_token): """ # Ensure we don't update the object in the middle of a new session being created, which could cause a race. if not isinstance(access_token, dict): - raise InvalidTypeError('access_token', access_token, OAuth2Token) + raise InvalidTypeError("access_token", access_token, OAuth2Token) with self.lock: - log.debug('%s auth token for %s', 'Refreshing' if self.access_token else 'Setting', self.client_id) + log.debug("%s auth token for %s", "Refreshing" if self.access_token else "Setting", self.client_id) self.access_token = access_token def _get_hash_values(self): # 'access_token' may be refreshed once in a while. This should not affect the hash signature. # 'identity' is just informational and should also not affect the hash signature. - return (getattr(self, k) for k in self.__dict__ if k not in ('_lock', 'identity', 'access_token')) + return (getattr(self, k) for k in self.__dict__ if k not in ("_lock", "identity", "access_token")) def sig(self): # Like hash(self), but pulls in the access token. Protocol.refresh_credentials() uses this to find out # if the access_token needs to be refreshed. res = [] for k in self.__dict__: - if k in ('_lock', 'identity'): + if k in ("_lock", "identity"): continue - if k == 'access_token': - res.append(self.access_token['access_token'] if self.access_token else None) + if k == "access_token": + res.append(self.access_token["access_token"] if self.access_token else None) continue res.append(getattr(self, k)) return hash(tuple(res)) def __repr__(self): - return self.__class__.__name__ + repr((self.client_id, '********')) + return self.__class__.__name__ + repr((self.client_id, "********")) def __str__(self): return self.client_id @@ -199,17 +199,20 @@ def __init__(self, authorization_code=None, access_token=None, **kwargs): super().__init__(**kwargs) self.authorization_code = authorization_code if access_token is not None and not isinstance(access_token, dict): - raise InvalidTypeError('access_token', access_token, OAuth2Token) + raise InvalidTypeError("access_token", access_token, OAuth2Token) self.access_token = access_token def __repr__(self): return self.__class__.__name__ + repr( - (self.client_id, '[client_secret]', '[authorization_code]', '[access_token]') + (self.client_id, "[client_secret]", "[authorization_code]", "[access_token]") ) def __str__(self): client_id = self.client_id - credential = '[access_token]' if self.access_token is not None else \ - ('[authorization_code]' if self.authorization_code is not None else None) - description = ' '.join(filter(None, [client_id, credential])) - return description or '[underspecified credentials]' + credential = ( + "[access_token]" + if self.access_token is not None + else ("[authorization_code]" if self.authorization_code is not None else None) + ) + description = " ".join(filter(None, [client_id, credential])) + return description or "[underspecified credentials]" diff --git a/exchangelib/errors.py b/exchangelib/errors.py index b8e88435..2336b6f8 100644 --- a/exchangelib/errors.py +++ b/exchangelib/errors.py @@ -1,4 +1,3 @@ -# flake8: noqa """Stores errors specific to this package, and mirrors all the possible errors that EWS can return.""" from urllib.parse import urlparse @@ -19,7 +18,7 @@ def __init__(self, field_name, value, choices): super().__init__(str(self)) def __str__(self): - return f'{self.field_name!r} {self.value!r} must be one of {sorted(self.choices)}' + return f"{self.field_name!r} {self.value!r} must be one of {sorted(self.choices)}" class InvalidTypeError(TypeError): @@ -30,7 +29,7 @@ def __init__(self, field_name, value, valid_type): super().__init__(str(self)) def __str__(self): - return f'{self.field_name!r} {self.value!r} must be of type {self.valid_type}' + return f"{self.field_name!r} {self.value!r} must be of type {self.valid_type}" class EWSError(Exception): @@ -65,8 +64,10 @@ def __init__(self, value, url, status_code, total_wait): self.total_wait = total_wait def __str__(self): - return f'{self.value} (gave up after {self.total_wait:.3f} seconds. ' \ - f'URL {self.url} returned status code {self.status_code})' + return ( + f"{self.value} (gave up after {self.total_wait:.3f} seconds. " + f"URL {self.url} returned status code {self.status_code})" + ) class SOAPError(TransportError): @@ -86,11 +87,11 @@ def __init__(self, url): parsed_url = urlparse(url) self.url = url self.server = parsed_url.hostname.lower() - self.has_ssl = parsed_url.scheme == 'https' + self.has_ssl = parsed_url.scheme == "https" super().__init__(str(self)) def __str__(self): - return f'We were redirected to {self.url}' + return f"We were redirected to {self.url}" class RelativeRedirect(TransportError): @@ -146,403 +147,1548 @@ def __init__(self, cas_error, response): super().__init__(str(self)) def __str__(self): - return f'CAS error: {self.cas_error}' + return f"CAS error: {self.cas_error}" # Somewhat-authoritative list of possible response message error types from EWS. See full list at # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/responsecode # -class ErrorAccessDenied(ResponseMessageError): pass -class ErrorAccessModeSpecified(ResponseMessageError): pass -class ErrorAccountDisabled(ResponseMessageError): pass -class ErrorAddDelegatesFailed(ResponseMessageError): pass -class ErrorAddressSpaceNotFound(ResponseMessageError): pass -class ErrorADOperation(ResponseMessageError): pass -class ErrorADSessionFilter(ResponseMessageError): pass -class ErrorADUnavailable(ResponseMessageError): pass -class ErrorAffectedTaskOccurrencesRequired(ResponseMessageError): pass -class ErrorApplyConversationActionFailed(ResponseMessageError): pass -class ErrorAttachmentSizeLimitExceeded(ResponseMessageError): pass -class ErrorAutoDiscoverFailed(ResponseMessageError): pass -class ErrorAvailabilityConfigNotFound(ResponseMessageError): pass -class ErrorBatchProcessingStopped(ResponseMessageError): pass -class ErrorCalendarCannotMoveOrCopyOccurrence(ResponseMessageError): pass -class ErrorCalendarCannotUpdateDeletedItem(ResponseMessageError): pass -class ErrorCalendarCannotUseIdForOccurrenceId(ResponseMessageError): pass -class ErrorCalendarCannotUseIdForRecurringMasterId(ResponseMessageError): pass -class ErrorCalendarDurationIsTooLong(ResponseMessageError): pass -class ErrorCalendarEndDateIsEarlierThanStartDate(ResponseMessageError): pass -class ErrorCalendarFolderIsInvalidForCalendarView(ResponseMessageError): pass -class ErrorCalendarInvalidAttributeValue(ResponseMessageError): pass -class ErrorCalendarInvalidDayForTimeChangePattern(ResponseMessageError): pass -class ErrorCalendarInvalidDayForWeeklyRecurrence(ResponseMessageError): pass -class ErrorCalendarInvalidPropertyState(ResponseMessageError): pass -class ErrorCalendarInvalidPropertyValue(ResponseMessageError): pass -class ErrorCalendarInvalidRecurrence(ResponseMessageError): pass -class ErrorCalendarInvalidTimeZone(ResponseMessageError): pass -class ErrorCalendarIsCancelledForAccept(ResponseMessageError): pass -class ErrorCalendarIsCancelledForDecline(ResponseMessageError): pass -class ErrorCalendarIsCancelledForRemove(ResponseMessageError): pass -class ErrorCalendarIsCancelledForTentative(ResponseMessageError): pass -class ErrorCalendarIsDelegatedForAccept(ResponseMessageError): pass -class ErrorCalendarIsDelegatedForDecline(ResponseMessageError): pass -class ErrorCalendarIsDelegatedForRemove(ResponseMessageError): pass -class ErrorCalendarIsDelegatedForTentative(ResponseMessageError): pass -class ErrorCalendarIsNotOrganizer(ResponseMessageError): pass -class ErrorCalendarIsOrganizerForAccept(ResponseMessageError): pass -class ErrorCalendarIsOrganizerForDecline(ResponseMessageError): pass -class ErrorCalendarIsOrganizerForRemove(ResponseMessageError): pass -class ErrorCalendarIsOrganizerForTentative(ResponseMessageError): pass -class ErrorCalendarMeetingRequestIsOutOfDate(ResponseMessageError): pass -class ErrorCalendarOccurrenceIndexIsOutOfRecurrenceRange(ResponseMessageError): pass -class ErrorCalendarOccurrenceIsDeletedFromRecurrence(ResponseMessageError): pass -class ErrorCalendarOutOfRange(ResponseMessageError): pass -class ErrorCalendarViewRangeTooBig(ResponseMessageError): pass -class ErrorCallerIsInvalidADAccount(ResponseMessageError): pass -class ErrorCannotCreateCalendarItemInNonCalendarFolder(ResponseMessageError): pass -class ErrorCannotCreateContactInNonContactFolder(ResponseMessageError): pass -class ErrorCannotCreatePostItemInNonMailFolder(ResponseMessageError): pass -class ErrorCannotCreateTaskInNonTaskFolder(ResponseMessageError): pass -class ErrorCannotDeleteObject(ResponseMessageError): pass -class ErrorCannotDeleteTaskOccurrence(ResponseMessageError): pass -class ErrorCannotEmptyFolder(ResponseMessageError): pass -class ErrorCannotOpenFileAttachment(ResponseMessageError): pass -class ErrorCannotSetCalendarPermissionOnNonCalendarFolder(ResponseMessageError): pass -class ErrorCannotSetNonCalendarPermissionOnCalendarFolder(ResponseMessageError): pass -class ErrorCannotSetPermissionUnknownEntries(ResponseMessageError): pass -class ErrorCannotUseFolderIdForItemId(ResponseMessageError): pass -class ErrorCannotUseItemIdForFolderId(ResponseMessageError): pass -class ErrorChangeKeyRequired(ResponseMessageError): pass -class ErrorChangeKeyRequiredForWriteOperations(ResponseMessageError): pass -class ErrorClientDisconnected(ResponseMessageError): pass -class ErrorConnectionFailed(ResponseMessageError): pass -class ErrorConnectionFailedTransientError(ResponseMessageError): pass -class ErrorContainsFilterWrongType(ResponseMessageError): pass -class ErrorContentConversionFailed(ResponseMessageError): pass -class ErrorCorruptData(ResponseMessageError): pass -class ErrorCreateItemAccessDenied(ResponseMessageError): pass -class ErrorCreateManagedFolderPartialCompletion(ResponseMessageError): pass -class ErrorCreateSubfolderAccessDenied(ResponseMessageError): pass -class ErrorCrossMailboxMoveCopy(ResponseMessageError): pass -class ErrorCrossSiteRequest(ResponseMessageError): pass -class ErrorDataSizeLimitExceeded(ResponseMessageError): pass -class ErrorDataSourceOperation(ResponseMessageError): pass -class ErrorDelegateAlreadyExists(ResponseMessageError): pass -class ErrorDelegateCannotAddOwner(ResponseMessageError): pass -class ErrorDelegateMissingConfiguration(ResponseMessageError): pass -class ErrorDelegateNoUser(ResponseMessageError): pass -class ErrorDelegateValidationFailed(ResponseMessageError): pass -class ErrorDeleteDistinguishedFolder(ResponseMessageError): pass -class ErrorDeleteItemsFailed(ResponseMessageError): pass -class ErrorDistinguishedUserNotSupported(ResponseMessageError): pass -class ErrorDistributionListMemberNotExist(ResponseMessageError): pass -class ErrorDuplicateInputFolderNames(ResponseMessageError): pass -class ErrorDuplicateSOAPHeader(ResponseMessageError): pass -class ErrorDuplicateUserIdsSpecified(ResponseMessageError): pass -class ErrorEmailAddressMismatch(ResponseMessageError): pass -class ErrorEventNotFound(ResponseMessageError): pass -class ErrorExceededConnectionCount(ResponseMessageError): pass -class ErrorExceededFindCountLimit(ResponseMessageError): pass -class ErrorExceededSubscriptionCount(ResponseMessageError): pass -class ErrorExpiredSubscription(ResponseMessageError): pass -class ErrorFolderCorrupt(ResponseMessageError): pass -class ErrorFolderExists(ResponseMessageError): pass -class ErrorFolderNotFound(ResponseMessageError): pass -class ErrorFolderPropertyRequestFailed(ResponseMessageError): pass -class ErrorFolderSave(ResponseMessageError): pass -class ErrorFolderSaveFailed(ResponseMessageError): pass -class ErrorFolderSavePropertyError(ResponseMessageError): pass -class ErrorFreeBusyDLLimitReached(ResponseMessageError): pass -class ErrorFreeBusyGenerationFailed(ResponseMessageError): pass -class ErrorGetServerSecurityDescriptorFailed(ResponseMessageError): pass -class ErrorImpersonateUserDenied(ResponseMessageError): pass -class ErrorImpersonationDenied(ResponseMessageError): pass -class ErrorImpersonationFailed(ResponseMessageError): pass -class ErrorInboxRulesValidationError(ResponseMessageError): pass -class ErrorIncorrectSchemaVersion(ResponseMessageError): pass -class ErrorIncorrectUpdatePropertyCount(ResponseMessageError): pass -class ErrorIndividualMailboxLimitReached(ResponseMessageError): pass -class ErrorInsufficientResources(ResponseMessageError): pass -class ErrorInternalServerError(ResponseMessageError): pass -class ErrorInternalServerTransientError(ResponseMessageError): pass -class ErrorInvalidAccessLevel(ResponseMessageError): pass -class ErrorInvalidArgument(ResponseMessageError): pass -class ErrorInvalidAttachmentId(ResponseMessageError): pass -class ErrorInvalidAttachmentSubfilter(ResponseMessageError): pass -class ErrorInvalidAttachmentSubfilterTextFilter(ResponseMessageError): pass -class ErrorInvalidAuthorizationContext(ResponseMessageError): pass -class ErrorInvalidChangeKey(ResponseMessageError): pass -class ErrorInvalidClientSecurityContext(ResponseMessageError): pass -class ErrorInvalidCompleteDate(ResponseMessageError): pass -class ErrorInvalidContactEmailAddress(ResponseMessageError): pass -class ErrorInvalidContactEmailIndex(ResponseMessageError): pass -class ErrorInvalidCrossForestCredentials(ResponseMessageError): pass -class ErrorInvalidDelegatePermission(ResponseMessageError): pass -class ErrorInvalidDelegateUserId(ResponseMessageError): pass -class ErrorInvalidExchangeImpersonationHeaderData(ResponseMessageError): pass -class ErrorInvalidExcludesRestriction(ResponseMessageError): pass -class ErrorInvalidExpressionTypeForSubFilter(ResponseMessageError): pass -class ErrorInvalidExtendedProperty(ResponseMessageError): pass -class ErrorInvalidExtendedPropertyValue(ResponseMessageError): pass -class ErrorInvalidExternalSharingInitiator(ResponseMessageError): pass -class ErrorInvalidExternalSharingSubscriber(ResponseMessageError): pass -class ErrorInvalidFederatedOrganizationId(ResponseMessageError): pass -class ErrorInvalidFolderId(ResponseMessageError): pass -class ErrorInvalidFolderTypeForOperation(ResponseMessageError): pass -class ErrorInvalidFractionalPagingParameters(ResponseMessageError): pass -class ErrorInvalidFreeBusyViewType(ResponseMessageError): pass -class ErrorInvalidGetSharingFolderRequest(ResponseMessageError): pass -class ErrorInvalidId(ResponseMessageError): pass -class ErrorInvalidIdEmpty(ResponseMessageError): pass -class ErrorInvalidIdMalformed(ResponseMessageError): pass -class ErrorInvalidIdMalformedEwsLegacyIdFormat(ResponseMessageError): pass -class ErrorInvalidIdMonikerTooLong(ResponseMessageError): pass -class ErrorInvalidIdNotAnItemAttachmentId(ResponseMessageError): pass -class ErrorInvalidIdReturnedByResolveNames(ResponseMessageError): pass -class ErrorInvalidIdStoreObjectIdTooLong(ResponseMessageError): pass -class ErrorInvalidIdTooManyAttachmentLevels(ResponseMessageError): pass -class ErrorInvalidIdXml(ResponseMessageError): pass -class ErrorInvalidIndexedPagingParameters(ResponseMessageError): pass -class ErrorInvalidInternetHeaderChildNodes(ResponseMessageError): pass -class ErrorInvalidItemForOperationAcceptItem(ResponseMessageError): pass -class ErrorInvalidItemForOperationCancelItem(ResponseMessageError): pass -class ErrorInvalidItemForOperationCreateItem(ResponseMessageError): pass -class ErrorInvalidItemForOperationCreateItemAttachment(ResponseMessageError): pass -class ErrorInvalidItemForOperationDeclineItem(ResponseMessageError): pass -class ErrorInvalidItemForOperationExpandDL(ResponseMessageError): pass -class ErrorInvalidItemForOperationRemoveItem(ResponseMessageError): pass -class ErrorInvalidItemForOperationSendItem(ResponseMessageError): pass -class ErrorInvalidItemForOperationTentative(ResponseMessageError): pass -class ErrorInvalidLicense(ResponseMessageError): pass -class ErrorInvalidLogonType(ResponseMessageError): pass -class ErrorInvalidMailbox(ResponseMessageError): pass -class ErrorInvalidManagedFolderProperty(ResponseMessageError): pass -class ErrorInvalidManagedFolderQuota(ResponseMessageError): pass -class ErrorInvalidManagedFolderSize(ResponseMessageError): pass -class ErrorInvalidMergedFreeBusyInterval(ResponseMessageError): pass -class ErrorInvalidNameForNameResolution(ResponseMessageError): pass -class ErrorInvalidNetworkServiceContext(ResponseMessageError): pass -class ErrorInvalidOofParameter(ResponseMessageError): pass -class ErrorInvalidOperation(ResponseMessageError): pass -class ErrorInvalidOrganizationRelationshipForFreeBusy(ResponseMessageError): pass -class ErrorInvalidPagingMaxRows(ResponseMessageError): pass -class ErrorInvalidParentFolder(ResponseMessageError): pass -class ErrorInvalidPercentCompleteValue(ResponseMessageError): pass -class ErrorInvalidPermissionSettings(ResponseMessageError): pass -class ErrorInvalidPhoneCallId(ResponseMessageError): pass -class ErrorInvalidPhoneNumber(ResponseMessageError): pass -class ErrorInvalidPropertyAppend(ResponseMessageError): pass -class ErrorInvalidPropertyDelete(ResponseMessageError): pass -class ErrorInvalidPropertyForExists(ResponseMessageError): pass -class ErrorInvalidPropertyForOperation(ResponseMessageError): pass -class ErrorInvalidPropertyRequest(ResponseMessageError): pass -class ErrorInvalidPropertySet(ResponseMessageError): pass -class ErrorInvalidPropertyUpdateSentMessage(ResponseMessageError): pass -class ErrorInvalidProxySecurityContext(ResponseMessageError): pass -class ErrorInvalidPullSubscriptionId(ResponseMessageError): pass -class ErrorInvalidPushSubscriptionUrl(ResponseMessageError): pass -class ErrorInvalidRecipients(ResponseMessageError): pass -class ErrorInvalidRecipientSubfilter(ResponseMessageError): pass -class ErrorInvalidRecipientSubfilterComparison(ResponseMessageError): pass -class ErrorInvalidRecipientSubfilterOrder(ResponseMessageError): pass -class ErrorInvalidRecipientSubfilterTextFilter(ResponseMessageError): pass -class ErrorInvalidReferenceItem(ResponseMessageError): pass -class ErrorInvalidRequest(ResponseMessageError): pass -class ErrorInvalidRestriction(ResponseMessageError): pass -class ErrorInvalidRoutingType(ResponseMessageError): pass -class ErrorInvalidScheduledOofDuration(ResponseMessageError): pass -class ErrorInvalidSchemaVersionForMailboxVersion(ResponseMessageError): pass -class ErrorInvalidSecurityDescriptor(ResponseMessageError): pass -class ErrorInvalidSendItemSaveSettings(ResponseMessageError): pass -class ErrorInvalidSerializedAccessToken(ResponseMessageError): pass -class ErrorInvalidServerVersion(ResponseMessageError): pass -class ErrorInvalidSharingData(ResponseMessageError): pass -class ErrorInvalidSharingMessage(ResponseMessageError): pass -class ErrorInvalidSid(ResponseMessageError): pass -class ErrorInvalidSIPUri(ResponseMessageError): pass -class ErrorInvalidSmtpAddress(ResponseMessageError): pass -class ErrorInvalidSubfilterType(ResponseMessageError): pass -class ErrorInvalidSubfilterTypeNotAttendeeType(ResponseMessageError): pass -class ErrorInvalidSubfilterTypeNotRecipientType(ResponseMessageError): pass -class ErrorInvalidSubscription(ResponseMessageError): pass -class ErrorInvalidSubscriptionRequest(ResponseMessageError): pass -class ErrorInvalidSyncStateData(ResponseMessageError): pass -class ErrorInvalidTimeInterval(ResponseMessageError): pass -class ErrorInvalidUserInfo(ResponseMessageError): pass -class ErrorInvalidUserOofSettings(ResponseMessageError): pass -class ErrorInvalidUserPrincipalName(ResponseMessageError): pass -class ErrorInvalidUserSid(ResponseMessageError): pass -class ErrorInvalidUserSidMissingUPN(ResponseMessageError): pass -class ErrorInvalidValueForProperty(ResponseMessageError): pass -class ErrorInvalidWatermark(ResponseMessageError): pass -class ErrorIPGatewayNotFound(ResponseMessageError): pass -class ErrorIrresolvableConflict(ResponseMessageError): pass -class ErrorItemCorrupt(ResponseMessageError): pass -class ErrorItemNotFound(ResponseMessageError): pass -class ErrorItemPropertyRequestFailed(ResponseMessageError): pass -class ErrorItemSave(ResponseMessageError): pass -class ErrorItemSavePropertyError(ResponseMessageError): pass -class ErrorLegacyMailboxFreeBusyViewTypeNotMerged(ResponseMessageError): pass -class ErrorLocalServerObjectNotFound(ResponseMessageError): pass -class ErrorLogonAsNetworkServiceFailed(ResponseMessageError): pass -class ErrorMailboxConfiguration(ResponseMessageError): pass -class ErrorMailboxDataArrayEmpty(ResponseMessageError): pass -class ErrorMailboxDataArrayTooBig(ResponseMessageError): pass -class ErrorMailboxFailover(ResponseMessageError): pass -class ErrorMailboxLogonFailed(ResponseMessageError): pass -class ErrorMailboxMoveInProgress(ResponseMessageError): pass -class ErrorMailboxStoreUnavailable(ResponseMessageError): pass -class ErrorMailRecipientNotFound(ResponseMessageError): pass -class ErrorMailTipsDisabled(ResponseMessageError): pass -class ErrorManagedFolderAlreadyExists(ResponseMessageError): pass -class ErrorManagedFolderNotFound(ResponseMessageError): pass -class ErrorManagedFoldersRootFailure(ResponseMessageError): pass -class ErrorMeetingSuggestionGenerationFailed(ResponseMessageError): pass -class ErrorMessageDispositionRequired(ResponseMessageError): pass -class ErrorMessageSizeExceeded(ResponseMessageError): pass -class ErrorMessageTrackingNoSuchDomain(ResponseMessageError): pass -class ErrorMessageTrackingPermanentError(ResponseMessageError): pass -class ErrorMessageTrackingTransientError(ResponseMessageError): pass -class ErrorMimeContentConversionFailed(ResponseMessageError): pass -class ErrorMimeContentInvalid(ResponseMessageError): pass -class ErrorMimeContentInvalidBase64String(ResponseMessageError): pass -class ErrorMissedNotificationEvents(ResponseMessageError): pass -class ErrorMissingArgument(ResponseMessageError): pass -class ErrorMissingEmailAddress(ResponseMessageError): pass -class ErrorMissingEmailAddressForManagedFolder(ResponseMessageError): pass -class ErrorMissingInformationEmailAddress(ResponseMessageError): pass -class ErrorMissingInformationReferenceItemId(ResponseMessageError): pass -class ErrorMissingInformationSharingFolderId(ResponseMessageError): pass -class ErrorMissingItemForCreateItemAttachment(ResponseMessageError): pass -class ErrorMissingManagedFolderId(ResponseMessageError): pass -class ErrorMissingRecipients(ResponseMessageError): pass -class ErrorMissingUserIdInformation(ResponseMessageError): pass -class ErrorMoreThanOneAccessModeSpecified(ResponseMessageError): pass -class ErrorMoveCopyFailed(ResponseMessageError): pass -class ErrorMoveDistinguishedFolder(ResponseMessageError): pass -class ErrorNameResolutionMultipleResults(ResponseMessageError): pass -class ErrorNameResolutionNoMailbox(ResponseMessageError): pass -class ErrorNameResolutionNoResults(ResponseMessageError): pass -class ErrorNewEventStreamConnectionOpened(ResponseMessageError): pass -class ErrorNoApplicableProxyCASServersAvailable(ResponseMessageError): pass -class ErrorNoCalendar(ResponseMessageError): pass -class ErrorNoDestinationCASDueToKerberosRequirements(ResponseMessageError): pass -class ErrorNoDestinationCASDueToSSLRequirements(ResponseMessageError): pass -class ErrorNoDestinationCASDueToVersionMismatch(ResponseMessageError): pass -class ErrorNoFolderClassOverride(ResponseMessageError): pass -class ErrorNoFreeBusyAccess(ResponseMessageError): pass -class ErrorNonExistentMailbox(ResponseMessageError): pass -class ErrorNonPrimarySmtpAddress(ResponseMessageError): pass -class ErrorNoPropertyTagForCustomProperties(ResponseMessageError): pass -class ErrorNoPublicFolderReplicaAvailable(ResponseMessageError): pass -class ErrorNoPublicFolderServerAvailable(ResponseMessageError): pass -class ErrorNoRespondingCASInDestinationSite(ResponseMessageError): pass -class ErrorNotAllowedExternalSharingByPolicy(ResponseMessageError): pass -class ErrorNotDelegate(ResponseMessageError): pass -class ErrorNotEnoughMemory(ResponseMessageError): pass -class ErrorNotSupportedSharingMessage(ResponseMessageError): pass -class ErrorObjectTypeChanged(ResponseMessageError): pass -class ErrorOccurrenceCrossingBoundary(ResponseMessageError): pass -class ErrorOccurrenceTimeSpanTooBig(ResponseMessageError): pass -class ErrorOperationNotAllowedWithPublicFolderRoot(ResponseMessageError): pass -class ErrorOrganizationNotFederated(ResponseMessageError): pass -class ErrorOutlookRuleBlobExists(ResponseMessageError): pass -class ErrorParentFolderIdRequired(ResponseMessageError): pass -class ErrorParentFolderNotFound(ResponseMessageError): pass -class ErrorPasswordChangeRequired(ResponseMessageError): pass -class ErrorPasswordExpired(ResponseMessageError): pass -class ErrorPermissionNotAllowedByPolicy(ResponseMessageError): pass -class ErrorPhoneNumberNotDialable(ResponseMessageError): pass -class ErrorPropertyUpdate(ResponseMessageError): pass -class ErrorPropertyValidationFailure(ResponseMessageError): pass -class ErrorProxiedSubscriptionCallFailure(ResponseMessageError): pass -class ErrorProxyCallFailed(ResponseMessageError): pass -class ErrorProxyGroupSidLimitExceeded(ResponseMessageError): pass -class ErrorProxyRequestNotAllowed(ResponseMessageError): pass -class ErrorProxyRequestProcessingFailed(ResponseMessageError): pass -class ErrorProxyServiceDiscoveryFailed(ResponseMessageError): pass -class ErrorProxyTokenExpired(ResponseMessageError): pass -class ErrorPublicFolderRequestProcessingFailed(ResponseMessageError): pass -class ErrorPublicFolderServerNotFound(ResponseMessageError): pass -class ErrorQueryFilterTooLong(ResponseMessageError): pass -class ErrorQuotaExceeded(ResponseMessageError): pass -class ErrorReadEventsFailed(ResponseMessageError): pass -class ErrorReadReceiptNotPending(ResponseMessageError): pass -class ErrorRecurrenceEndDateTooBig(ResponseMessageError): pass -class ErrorRecurrenceHasNoOccurrence(ResponseMessageError): pass -class ErrorRemoveDelegatesFailed(ResponseMessageError): pass -class ErrorRequestAborted(ResponseMessageError): pass -class ErrorRequestStreamTooBig(ResponseMessageError): pass -class ErrorRequiredPropertyMissing(ResponseMessageError): pass -class ErrorResolveNamesInvalidFolderType(ResponseMessageError): pass -class ErrorResolveNamesOnlyOneContactsFolderAllowed(ResponseMessageError): pass -class ErrorResponseSchemaValidation(ResponseMessageError): pass -class ErrorRestrictionTooComplex(ResponseMessageError): pass -class ErrorRestrictionTooLong(ResponseMessageError): pass -class ErrorResultSetTooBig(ResponseMessageError): pass -class ErrorRulesOverQuota(ResponseMessageError): pass -class ErrorSavedItemFolderNotFound(ResponseMessageError): pass -class ErrorSchemaValidation(ResponseMessageError): pass -class ErrorSearchFolderNotInitialized(ResponseMessageError): pass -class ErrorSendAsDenied(ResponseMessageError): pass -class ErrorSendMeetingCancellationsRequired(ResponseMessageError): pass -class ErrorSendMeetingInvitationsOrCancellationsRequired(ResponseMessageError): pass -class ErrorSendMeetingInvitationsRequired(ResponseMessageError): pass -class ErrorSentMeetingRequestUpdate(ResponseMessageError): pass -class ErrorSentTaskRequestUpdate(ResponseMessageError): pass +class ErrorAccessDenied(ResponseMessageError): + pass + + +class ErrorAccessModeSpecified(ResponseMessageError): + pass + + +class ErrorAccountDisabled(ResponseMessageError): + pass + + +class ErrorAddDelegatesFailed(ResponseMessageError): + pass + + +class ErrorAddressSpaceNotFound(ResponseMessageError): + pass + + +class ErrorADOperation(ResponseMessageError): + pass + + +class ErrorADSessionFilter(ResponseMessageError): + pass + + +class ErrorADUnavailable(ResponseMessageError): + pass + + +class ErrorAffectedTaskOccurrencesRequired(ResponseMessageError): + pass + + +class ErrorApplyConversationActionFailed(ResponseMessageError): + pass + + +class ErrorAttachmentSizeLimitExceeded(ResponseMessageError): + pass + + +class ErrorAutoDiscoverFailed(ResponseMessageError): + pass + + +class ErrorAvailabilityConfigNotFound(ResponseMessageError): + pass + + +class ErrorBatchProcessingStopped(ResponseMessageError): + pass + + +class ErrorCalendarCannotMoveOrCopyOccurrence(ResponseMessageError): + pass + + +class ErrorCalendarCannotUpdateDeletedItem(ResponseMessageError): + pass + + +class ErrorCalendarCannotUseIdForOccurrenceId(ResponseMessageError): + pass + + +class ErrorCalendarCannotUseIdForRecurringMasterId(ResponseMessageError): + pass + + +class ErrorCalendarDurationIsTooLong(ResponseMessageError): + pass + + +class ErrorCalendarEndDateIsEarlierThanStartDate(ResponseMessageError): + pass + + +class ErrorCalendarFolderIsInvalidForCalendarView(ResponseMessageError): + pass + + +class ErrorCalendarInvalidAttributeValue(ResponseMessageError): + pass + + +class ErrorCalendarInvalidDayForTimeChangePattern(ResponseMessageError): + pass + + +class ErrorCalendarInvalidDayForWeeklyRecurrence(ResponseMessageError): + pass + + +class ErrorCalendarInvalidPropertyState(ResponseMessageError): + pass + + +class ErrorCalendarInvalidPropertyValue(ResponseMessageError): + pass + + +class ErrorCalendarInvalidRecurrence(ResponseMessageError): + pass + + +class ErrorCalendarInvalidTimeZone(ResponseMessageError): + pass + + +class ErrorCalendarIsCancelledForAccept(ResponseMessageError): + pass + + +class ErrorCalendarIsCancelledForDecline(ResponseMessageError): + pass + + +class ErrorCalendarIsCancelledForRemove(ResponseMessageError): + pass + + +class ErrorCalendarIsCancelledForTentative(ResponseMessageError): + pass + + +class ErrorCalendarIsDelegatedForAccept(ResponseMessageError): + pass + + +class ErrorCalendarIsDelegatedForDecline(ResponseMessageError): + pass + + +class ErrorCalendarIsDelegatedForRemove(ResponseMessageError): + pass + + +class ErrorCalendarIsDelegatedForTentative(ResponseMessageError): + pass + + +class ErrorCalendarIsNotOrganizer(ResponseMessageError): + pass + + +class ErrorCalendarIsOrganizerForAccept(ResponseMessageError): + pass + + +class ErrorCalendarIsOrganizerForDecline(ResponseMessageError): + pass + + +class ErrorCalendarIsOrganizerForRemove(ResponseMessageError): + pass + + +class ErrorCalendarIsOrganizerForTentative(ResponseMessageError): + pass + + +class ErrorCalendarMeetingRequestIsOutOfDate(ResponseMessageError): + pass + + +class ErrorCalendarOccurrenceIndexIsOutOfRecurrenceRange(ResponseMessageError): + pass + + +class ErrorCalendarOccurrenceIsDeletedFromRecurrence(ResponseMessageError): + pass + + +class ErrorCalendarOutOfRange(ResponseMessageError): + pass + + +class ErrorCalendarViewRangeTooBig(ResponseMessageError): + pass + + +class ErrorCallerIsInvalidADAccount(ResponseMessageError): + pass + + +class ErrorCannotCreateCalendarItemInNonCalendarFolder(ResponseMessageError): + pass + + +class ErrorCannotCreateContactInNonContactFolder(ResponseMessageError): + pass + + +class ErrorCannotCreatePostItemInNonMailFolder(ResponseMessageError): + pass + + +class ErrorCannotCreateTaskInNonTaskFolder(ResponseMessageError): + pass + + +class ErrorCannotDeleteObject(ResponseMessageError): + pass + + +class ErrorCannotDeleteTaskOccurrence(ResponseMessageError): + pass + + +class ErrorCannotEmptyFolder(ResponseMessageError): + pass + + +class ErrorCannotOpenFileAttachment(ResponseMessageError): + pass + + +class ErrorCannotSetCalendarPermissionOnNonCalendarFolder(ResponseMessageError): + pass + + +class ErrorCannotSetNonCalendarPermissionOnCalendarFolder(ResponseMessageError): + pass + + +class ErrorCannotSetPermissionUnknownEntries(ResponseMessageError): + pass + + +class ErrorCannotUseFolderIdForItemId(ResponseMessageError): + pass + + +class ErrorCannotUseItemIdForFolderId(ResponseMessageError): + pass + + +class ErrorChangeKeyRequired(ResponseMessageError): + pass + + +class ErrorChangeKeyRequiredForWriteOperations(ResponseMessageError): + pass + + +class ErrorClientDisconnected(ResponseMessageError): + pass + + +class ErrorConnectionFailed(ResponseMessageError): + pass + + +class ErrorConnectionFailedTransientError(ResponseMessageError): + pass + + +class ErrorContainsFilterWrongType(ResponseMessageError): + pass + + +class ErrorContentConversionFailed(ResponseMessageError): + pass + + +class ErrorCorruptData(ResponseMessageError): + pass + + +class ErrorCreateItemAccessDenied(ResponseMessageError): + pass + + +class ErrorCreateManagedFolderPartialCompletion(ResponseMessageError): + pass + + +class ErrorCreateSubfolderAccessDenied(ResponseMessageError): + pass + + +class ErrorCrossMailboxMoveCopy(ResponseMessageError): + pass + + +class ErrorCrossSiteRequest(ResponseMessageError): + pass + + +class ErrorDataSizeLimitExceeded(ResponseMessageError): + pass + + +class ErrorDataSourceOperation(ResponseMessageError): + pass + + +class ErrorDelegateAlreadyExists(ResponseMessageError): + pass + + +class ErrorDelegateCannotAddOwner(ResponseMessageError): + pass + + +class ErrorDelegateMissingConfiguration(ResponseMessageError): + pass + + +class ErrorDelegateNoUser(ResponseMessageError): + pass + + +class ErrorDelegateValidationFailed(ResponseMessageError): + pass + + +class ErrorDeleteDistinguishedFolder(ResponseMessageError): + pass + + +class ErrorDeleteItemsFailed(ResponseMessageError): + pass + + +class ErrorDistinguishedUserNotSupported(ResponseMessageError): + pass + + +class ErrorDistributionListMemberNotExist(ResponseMessageError): + pass + + +class ErrorDuplicateInputFolderNames(ResponseMessageError): + pass + + +class ErrorDuplicateSOAPHeader(ResponseMessageError): + pass + + +class ErrorDuplicateUserIdsSpecified(ResponseMessageError): + pass + + +class ErrorEmailAddressMismatch(ResponseMessageError): + pass + + +class ErrorEventNotFound(ResponseMessageError): + pass + + +class ErrorExceededConnectionCount(ResponseMessageError): + pass + + +class ErrorExceededFindCountLimit(ResponseMessageError): + pass + + +class ErrorExceededSubscriptionCount(ResponseMessageError): + pass + + +class ErrorExpiredSubscription(ResponseMessageError): + pass + + +class ErrorFolderCorrupt(ResponseMessageError): + pass + + +class ErrorFolderExists(ResponseMessageError): + pass + + +class ErrorFolderNotFound(ResponseMessageError): + pass + + +class ErrorFolderPropertyRequestFailed(ResponseMessageError): + pass + + +class ErrorFolderSave(ResponseMessageError): + pass + + +class ErrorFolderSaveFailed(ResponseMessageError): + pass + + +class ErrorFolderSavePropertyError(ResponseMessageError): + pass + + +class ErrorFreeBusyDLLimitReached(ResponseMessageError): + pass + + +class ErrorFreeBusyGenerationFailed(ResponseMessageError): + pass + + +class ErrorGetServerSecurityDescriptorFailed(ResponseMessageError): + pass + + +class ErrorImpersonateUserDenied(ResponseMessageError): + pass + + +class ErrorImpersonationDenied(ResponseMessageError): + pass + + +class ErrorImpersonationFailed(ResponseMessageError): + pass + + +class ErrorInboxRulesValidationError(ResponseMessageError): + pass + + +class ErrorIncorrectSchemaVersion(ResponseMessageError): + pass + + +class ErrorIncorrectUpdatePropertyCount(ResponseMessageError): + pass + + +class ErrorIndividualMailboxLimitReached(ResponseMessageError): + pass + + +class ErrorInsufficientResources(ResponseMessageError): + pass + + +class ErrorInternalServerError(ResponseMessageError): + pass + + +class ErrorInternalServerTransientError(ResponseMessageError): + pass + + +class ErrorInvalidAccessLevel(ResponseMessageError): + pass + + +class ErrorInvalidArgument(ResponseMessageError): + pass + + +class ErrorInvalidAttachmentId(ResponseMessageError): + pass + + +class ErrorInvalidAttachmentSubfilter(ResponseMessageError): + pass + + +class ErrorInvalidAttachmentSubfilterTextFilter(ResponseMessageError): + pass + + +class ErrorInvalidAuthorizationContext(ResponseMessageError): + pass + + +class ErrorInvalidChangeKey(ResponseMessageError): + pass + + +class ErrorInvalidClientSecurityContext(ResponseMessageError): + pass + + +class ErrorInvalidCompleteDate(ResponseMessageError): + pass + + +class ErrorInvalidContactEmailAddress(ResponseMessageError): + pass + + +class ErrorInvalidContactEmailIndex(ResponseMessageError): + pass + + +class ErrorInvalidCrossForestCredentials(ResponseMessageError): + pass + + +class ErrorInvalidDelegatePermission(ResponseMessageError): + pass + + +class ErrorInvalidDelegateUserId(ResponseMessageError): + pass + + +class ErrorInvalidExchangeImpersonationHeaderData(ResponseMessageError): + pass + + +class ErrorInvalidExcludesRestriction(ResponseMessageError): + pass + + +class ErrorInvalidExpressionTypeForSubFilter(ResponseMessageError): + pass + + +class ErrorInvalidExtendedProperty(ResponseMessageError): + pass + + +class ErrorInvalidExtendedPropertyValue(ResponseMessageError): + pass + + +class ErrorInvalidExternalSharingInitiator(ResponseMessageError): + pass + + +class ErrorInvalidExternalSharingSubscriber(ResponseMessageError): + pass + + +class ErrorInvalidFederatedOrganizationId(ResponseMessageError): + pass + + +class ErrorInvalidFolderId(ResponseMessageError): + pass + + +class ErrorInvalidFolderTypeForOperation(ResponseMessageError): + pass + + +class ErrorInvalidFractionalPagingParameters(ResponseMessageError): + pass + + +class ErrorInvalidFreeBusyViewType(ResponseMessageError): + pass + + +class ErrorInvalidGetSharingFolderRequest(ResponseMessageError): + pass + + +class ErrorInvalidId(ResponseMessageError): + pass + + +class ErrorInvalidIdEmpty(ResponseMessageError): + pass + + +class ErrorInvalidIdMalformed(ResponseMessageError): + pass + + +class ErrorInvalidIdMalformedEwsLegacyIdFormat(ResponseMessageError): + pass + + +class ErrorInvalidIdMonikerTooLong(ResponseMessageError): + pass + + +class ErrorInvalidIdNotAnItemAttachmentId(ResponseMessageError): + pass + + +class ErrorInvalidIdReturnedByResolveNames(ResponseMessageError): + pass + + +class ErrorInvalidIdStoreObjectIdTooLong(ResponseMessageError): + pass + + +class ErrorInvalidIdTooManyAttachmentLevels(ResponseMessageError): + pass + + +class ErrorInvalidIdXml(ResponseMessageError): + pass + + +class ErrorInvalidIndexedPagingParameters(ResponseMessageError): + pass + + +class ErrorInvalidInternetHeaderChildNodes(ResponseMessageError): + pass + + +class ErrorInvalidItemForOperationAcceptItem(ResponseMessageError): + pass + + +class ErrorInvalidItemForOperationCancelItem(ResponseMessageError): + pass + + +class ErrorInvalidItemForOperationCreateItem(ResponseMessageError): + pass + + +class ErrorInvalidItemForOperationCreateItemAttachment(ResponseMessageError): + pass + + +class ErrorInvalidItemForOperationDeclineItem(ResponseMessageError): + pass + + +class ErrorInvalidItemForOperationExpandDL(ResponseMessageError): + pass + + +class ErrorInvalidItemForOperationRemoveItem(ResponseMessageError): + pass + + +class ErrorInvalidItemForOperationSendItem(ResponseMessageError): + pass + + +class ErrorInvalidItemForOperationTentative(ResponseMessageError): + pass + + +class ErrorInvalidLicense(ResponseMessageError): + pass + + +class ErrorInvalidLogonType(ResponseMessageError): + pass + + +class ErrorInvalidMailbox(ResponseMessageError): + pass + + +class ErrorInvalidManagedFolderProperty(ResponseMessageError): + pass + + +class ErrorInvalidManagedFolderQuota(ResponseMessageError): + pass + + +class ErrorInvalidManagedFolderSize(ResponseMessageError): + pass + + +class ErrorInvalidMergedFreeBusyInterval(ResponseMessageError): + pass + + +class ErrorInvalidNameForNameResolution(ResponseMessageError): + pass + + +class ErrorInvalidNetworkServiceContext(ResponseMessageError): + pass + + +class ErrorInvalidOofParameter(ResponseMessageError): + pass + + +class ErrorInvalidOperation(ResponseMessageError): + pass + + +class ErrorInvalidOrganizationRelationshipForFreeBusy(ResponseMessageError): + pass + + +class ErrorInvalidPagingMaxRows(ResponseMessageError): + pass + + +class ErrorInvalidParentFolder(ResponseMessageError): + pass + + +class ErrorInvalidPercentCompleteValue(ResponseMessageError): + pass + + +class ErrorInvalidPermissionSettings(ResponseMessageError): + pass + + +class ErrorInvalidPhoneCallId(ResponseMessageError): + pass + + +class ErrorInvalidPhoneNumber(ResponseMessageError): + pass + + +class ErrorInvalidPropertyAppend(ResponseMessageError): + pass + + +class ErrorInvalidPropertyDelete(ResponseMessageError): + pass + + +class ErrorInvalidPropertyForExists(ResponseMessageError): + pass + + +class ErrorInvalidPropertyForOperation(ResponseMessageError): + pass + + +class ErrorInvalidPropertyRequest(ResponseMessageError): + pass + + +class ErrorInvalidPropertySet(ResponseMessageError): + pass + + +class ErrorInvalidPropertyUpdateSentMessage(ResponseMessageError): + pass + + +class ErrorInvalidProxySecurityContext(ResponseMessageError): + pass + + +class ErrorInvalidPullSubscriptionId(ResponseMessageError): + pass + + +class ErrorInvalidPushSubscriptionUrl(ResponseMessageError): + pass + + +class ErrorInvalidRecipients(ResponseMessageError): + pass + + +class ErrorInvalidRecipientSubfilter(ResponseMessageError): + pass + + +class ErrorInvalidRecipientSubfilterComparison(ResponseMessageError): + pass + + +class ErrorInvalidRecipientSubfilterOrder(ResponseMessageError): + pass + + +class ErrorInvalidRecipientSubfilterTextFilter(ResponseMessageError): + pass + + +class ErrorInvalidReferenceItem(ResponseMessageError): + pass + + +class ErrorInvalidRequest(ResponseMessageError): + pass + + +class ErrorInvalidRestriction(ResponseMessageError): + pass + + +class ErrorInvalidRoutingType(ResponseMessageError): + pass + + +class ErrorInvalidScheduledOofDuration(ResponseMessageError): + pass + + +class ErrorInvalidSchemaVersionForMailboxVersion(ResponseMessageError): + pass + + +class ErrorInvalidSecurityDescriptor(ResponseMessageError): + pass + + +class ErrorInvalidSendItemSaveSettings(ResponseMessageError): + pass + + +class ErrorInvalidSerializedAccessToken(ResponseMessageError): + pass + + +class ErrorInvalidServerVersion(ResponseMessageError): + pass + + +class ErrorInvalidSharingData(ResponseMessageError): + pass + + +class ErrorInvalidSharingMessage(ResponseMessageError): + pass + + +class ErrorInvalidSid(ResponseMessageError): + pass + + +class ErrorInvalidSIPUri(ResponseMessageError): + pass + + +class ErrorInvalidSmtpAddress(ResponseMessageError): + pass + + +class ErrorInvalidSubfilterType(ResponseMessageError): + pass + + +class ErrorInvalidSubfilterTypeNotAttendeeType(ResponseMessageError): + pass + + +class ErrorInvalidSubfilterTypeNotRecipientType(ResponseMessageError): + pass + + +class ErrorInvalidSubscription(ResponseMessageError): + pass + + +class ErrorInvalidSubscriptionRequest(ResponseMessageError): + pass + + +class ErrorInvalidSyncStateData(ResponseMessageError): + pass + + +class ErrorInvalidTimeInterval(ResponseMessageError): + pass + + +class ErrorInvalidUserInfo(ResponseMessageError): + pass + + +class ErrorInvalidUserOofSettings(ResponseMessageError): + pass + + +class ErrorInvalidUserPrincipalName(ResponseMessageError): + pass + + +class ErrorInvalidUserSid(ResponseMessageError): + pass + + +class ErrorInvalidUserSidMissingUPN(ResponseMessageError): + pass + + +class ErrorInvalidValueForProperty(ResponseMessageError): + pass + + +class ErrorInvalidWatermark(ResponseMessageError): + pass + + +class ErrorIPGatewayNotFound(ResponseMessageError): + pass + + +class ErrorIrresolvableConflict(ResponseMessageError): + pass + + +class ErrorItemCorrupt(ResponseMessageError): + pass + + +class ErrorItemNotFound(ResponseMessageError): + pass + + +class ErrorItemPropertyRequestFailed(ResponseMessageError): + pass + + +class ErrorItemSave(ResponseMessageError): + pass + + +class ErrorItemSavePropertyError(ResponseMessageError): + pass + + +class ErrorLegacyMailboxFreeBusyViewTypeNotMerged(ResponseMessageError): + pass + + +class ErrorLocalServerObjectNotFound(ResponseMessageError): + pass + + +class ErrorLogonAsNetworkServiceFailed(ResponseMessageError): + pass + + +class ErrorMailboxConfiguration(ResponseMessageError): + pass + + +class ErrorMailboxDataArrayEmpty(ResponseMessageError): + pass + + +class ErrorMailboxDataArrayTooBig(ResponseMessageError): + pass + + +class ErrorMailboxFailover(ResponseMessageError): + pass + + +class ErrorMailboxLogonFailed(ResponseMessageError): + pass + + +class ErrorMailboxMoveInProgress(ResponseMessageError): + pass + + +class ErrorMailboxStoreUnavailable(ResponseMessageError): + pass + + +class ErrorMailRecipientNotFound(ResponseMessageError): + pass + + +class ErrorMailTipsDisabled(ResponseMessageError): + pass + + +class ErrorManagedFolderAlreadyExists(ResponseMessageError): + pass + + +class ErrorManagedFolderNotFound(ResponseMessageError): + pass + + +class ErrorManagedFoldersRootFailure(ResponseMessageError): + pass + + +class ErrorMeetingSuggestionGenerationFailed(ResponseMessageError): + pass + + +class ErrorMessageDispositionRequired(ResponseMessageError): + pass + + +class ErrorMessageSizeExceeded(ResponseMessageError): + pass + + +class ErrorMessageTrackingNoSuchDomain(ResponseMessageError): + pass + + +class ErrorMessageTrackingPermanentError(ResponseMessageError): + pass + + +class ErrorMessageTrackingTransientError(ResponseMessageError): + pass + + +class ErrorMimeContentConversionFailed(ResponseMessageError): + pass + + +class ErrorMimeContentInvalid(ResponseMessageError): + pass + + +class ErrorMimeContentInvalidBase64String(ResponseMessageError): + pass + + +class ErrorMissedNotificationEvents(ResponseMessageError): + pass + + +class ErrorMissingArgument(ResponseMessageError): + pass + + +class ErrorMissingEmailAddress(ResponseMessageError): + pass + + +class ErrorMissingEmailAddressForManagedFolder(ResponseMessageError): + pass + + +class ErrorMissingInformationEmailAddress(ResponseMessageError): + pass + + +class ErrorMissingInformationReferenceItemId(ResponseMessageError): + pass + + +class ErrorMissingInformationSharingFolderId(ResponseMessageError): + pass + + +class ErrorMissingItemForCreateItemAttachment(ResponseMessageError): + pass + + +class ErrorMissingManagedFolderId(ResponseMessageError): + pass + + +class ErrorMissingRecipients(ResponseMessageError): + pass + + +class ErrorMissingUserIdInformation(ResponseMessageError): + pass + + +class ErrorMoreThanOneAccessModeSpecified(ResponseMessageError): + pass + + +class ErrorMoveCopyFailed(ResponseMessageError): + pass + + +class ErrorMoveDistinguishedFolder(ResponseMessageError): + pass + + +class ErrorNameResolutionMultipleResults(ResponseMessageError): + pass + + +class ErrorNameResolutionNoMailbox(ResponseMessageError): + pass + + +class ErrorNameResolutionNoResults(ResponseMessageError): + pass + + +class ErrorNewEventStreamConnectionOpened(ResponseMessageError): + pass + + +class ErrorNoApplicableProxyCASServersAvailable(ResponseMessageError): + pass + + +class ErrorNoCalendar(ResponseMessageError): + pass + + +class ErrorNoDestinationCASDueToKerberosRequirements(ResponseMessageError): + pass + + +class ErrorNoDestinationCASDueToSSLRequirements(ResponseMessageError): + pass + + +class ErrorNoDestinationCASDueToVersionMismatch(ResponseMessageError): + pass + + +class ErrorNoFolderClassOverride(ResponseMessageError): + pass + + +class ErrorNoFreeBusyAccess(ResponseMessageError): + pass + + +class ErrorNonExistentMailbox(ResponseMessageError): + pass + + +class ErrorNonPrimarySmtpAddress(ResponseMessageError): + pass + + +class ErrorNoPropertyTagForCustomProperties(ResponseMessageError): + pass + + +class ErrorNoPublicFolderReplicaAvailable(ResponseMessageError): + pass + + +class ErrorNoPublicFolderServerAvailable(ResponseMessageError): + pass + + +class ErrorNoRespondingCASInDestinationSite(ResponseMessageError): + pass + + +class ErrorNotAllowedExternalSharingByPolicy(ResponseMessageError): + pass + + +class ErrorNotDelegate(ResponseMessageError): + pass + + +class ErrorNotEnoughMemory(ResponseMessageError): + pass + + +class ErrorNotSupportedSharingMessage(ResponseMessageError): + pass + + +class ErrorObjectTypeChanged(ResponseMessageError): + pass + + +class ErrorOccurrenceCrossingBoundary(ResponseMessageError): + pass + + +class ErrorOccurrenceTimeSpanTooBig(ResponseMessageError): + pass + + +class ErrorOperationNotAllowedWithPublicFolderRoot(ResponseMessageError): + pass + + +class ErrorOrganizationNotFederated(ResponseMessageError): + pass + + +class ErrorOutlookRuleBlobExists(ResponseMessageError): + pass + + +class ErrorParentFolderIdRequired(ResponseMessageError): + pass + + +class ErrorParentFolderNotFound(ResponseMessageError): + pass + + +class ErrorPasswordChangeRequired(ResponseMessageError): + pass + + +class ErrorPasswordExpired(ResponseMessageError): + pass + + +class ErrorPermissionNotAllowedByPolicy(ResponseMessageError): + pass + + +class ErrorPhoneNumberNotDialable(ResponseMessageError): + pass + + +class ErrorPropertyUpdate(ResponseMessageError): + pass + + +class ErrorPropertyValidationFailure(ResponseMessageError): + pass + + +class ErrorProxiedSubscriptionCallFailure(ResponseMessageError): + pass + + +class ErrorProxyCallFailed(ResponseMessageError): + pass + + +class ErrorProxyGroupSidLimitExceeded(ResponseMessageError): + pass + + +class ErrorProxyRequestNotAllowed(ResponseMessageError): + pass + + +class ErrorProxyRequestProcessingFailed(ResponseMessageError): + pass + + +class ErrorProxyServiceDiscoveryFailed(ResponseMessageError): + pass + + +class ErrorProxyTokenExpired(ResponseMessageError): + pass + + +class ErrorPublicFolderRequestProcessingFailed(ResponseMessageError): + pass + + +class ErrorPublicFolderServerNotFound(ResponseMessageError): + pass + + +class ErrorQueryFilterTooLong(ResponseMessageError): + pass + + +class ErrorQuotaExceeded(ResponseMessageError): + pass + + +class ErrorReadEventsFailed(ResponseMessageError): + pass + + +class ErrorReadReceiptNotPending(ResponseMessageError): + pass + + +class ErrorRecurrenceEndDateTooBig(ResponseMessageError): + pass + + +class ErrorRecurrenceHasNoOccurrence(ResponseMessageError): + pass + + +class ErrorRemoveDelegatesFailed(ResponseMessageError): + pass + + +class ErrorRequestAborted(ResponseMessageError): + pass + + +class ErrorRequestStreamTooBig(ResponseMessageError): + pass + + +class ErrorRequiredPropertyMissing(ResponseMessageError): + pass + + +class ErrorResolveNamesInvalidFolderType(ResponseMessageError): + pass + + +class ErrorResolveNamesOnlyOneContactsFolderAllowed(ResponseMessageError): + pass + + +class ErrorResponseSchemaValidation(ResponseMessageError): + pass + + +class ErrorRestrictionTooComplex(ResponseMessageError): + pass + + +class ErrorRestrictionTooLong(ResponseMessageError): + pass + + +class ErrorResultSetTooBig(ResponseMessageError): + pass + + +class ErrorRulesOverQuota(ResponseMessageError): + pass + + +class ErrorSavedItemFolderNotFound(ResponseMessageError): + pass + + +class ErrorSchemaValidation(ResponseMessageError): + pass + + +class ErrorSearchFolderNotInitialized(ResponseMessageError): + pass + + +class ErrorSendAsDenied(ResponseMessageError): + pass + + +class ErrorSendMeetingCancellationsRequired(ResponseMessageError): + pass + + +class ErrorSendMeetingInvitationsOrCancellationsRequired(ResponseMessageError): + pass + + +class ErrorSendMeetingInvitationsRequired(ResponseMessageError): + pass + + +class ErrorSentMeetingRequestUpdate(ResponseMessageError): + pass + + +class ErrorSentTaskRequestUpdate(ResponseMessageError): + pass class ErrorServerBusy(ResponseMessageError): def __init__(self, *args, **kwargs): - self.back_off = kwargs.pop('back_off', None) # Requested back off value in seconds + self.back_off = kwargs.pop("back_off", None) # Requested back off value in seconds super().__init__(*args, **kwargs) -class ErrorServiceDiscoveryFailed(ResponseMessageError): pass -class ErrorSharingNoExternalEwsAvailable(ResponseMessageError): pass -class ErrorSharingSynchronizationFailed(ResponseMessageError): pass -class ErrorStaleObject(ResponseMessageError): pass -class ErrorSubmissionQuotaExceeded(ResponseMessageError): pass -class ErrorSubscriptionAccessDenied(ResponseMessageError): pass -class ErrorSubscriptionDelegateAccessNotSupported(ResponseMessageError): pass -class ErrorSubscriptionNotFound(ResponseMessageError): pass -class ErrorSubscriptionUnsubsribed(ResponseMessageError): pass -class ErrorSyncFolderNotFound(ResponseMessageError): pass -class ErrorTimeIntervalTooBig(ResponseMessageError): pass -class ErrorTimeoutExpired(ResponseMessageError): pass -class ErrorTimeZone(ResponseMessageError): pass -class ErrorToFolderNotFound(ResponseMessageError): pass -class ErrorTokenSerializationDenied(ResponseMessageError): pass -class ErrorTooManyObjectsOpened(ResponseMessageError): pass -class ErrorUnableToGetUserOofSettings(ResponseMessageError): pass -class ErrorUnifiedMessagingDialPlanNotFound(ResponseMessageError): pass -class ErrorUnifiedMessagingRequestFailed(ResponseMessageError): pass -class ErrorUnifiedMessagingServerNotFound(ResponseMessageError): pass -class ErrorUnsupportedCulture(ResponseMessageError): pass -class ErrorUnsupportedMapiPropertyType(ResponseMessageError): pass -class ErrorUnsupportedMimeConversion(ResponseMessageError): pass -class ErrorUnsupportedPathForQuery(ResponseMessageError): pass -class ErrorUnsupportedPathForSortGroup(ResponseMessageError): pass -class ErrorUnsupportedPropertyDefinition(ResponseMessageError): pass -class ErrorUnsupportedQueryFilter(ResponseMessageError): pass -class ErrorUnsupportedRecurrence(ResponseMessageError): pass -class ErrorUnsupportedSubFilter(ResponseMessageError): pass -class ErrorUnsupportedTypeForConversion(ResponseMessageError): pass -class ErrorUpdateDelegatesFailed(ResponseMessageError): pass -class ErrorUpdatePropertyMismatch(ResponseMessageError): pass -class ErrorUserNotAllowedByPolicy(ResponseMessageError): pass -class ErrorUserNotUnifiedMessagingEnabled(ResponseMessageError): pass -class ErrorUserWithoutFederatedProxyAddress(ResponseMessageError): pass -class ErrorValueOutOfRange(ResponseMessageError): pass -class ErrorVirusDetected(ResponseMessageError): pass -class ErrorVirusMessageDeleted(ResponseMessageError): pass -class ErrorVoiceMailNotImplemented(ResponseMessageError): pass -class ErrorWebRequestInInvalidState(ResponseMessageError): pass -class ErrorWin32InteropError(ResponseMessageError): pass -class ErrorWorkingHoursSaveFailed(ResponseMessageError): pass -class ErrorWorkingHoursXmlMalformed(ResponseMessageError): pass -class ErrorWrongServerVersion(ResponseMessageError): pass -class ErrorWrongServerVersionDelegate(ResponseMessageError): pass +class ErrorServiceDiscoveryFailed(ResponseMessageError): + pass + + +class ErrorSharingNoExternalEwsAvailable(ResponseMessageError): + pass + + +class ErrorSharingSynchronizationFailed(ResponseMessageError): + pass + + +class ErrorStaleObject(ResponseMessageError): + pass + + +class ErrorSubmissionQuotaExceeded(ResponseMessageError): + pass + + +class ErrorSubscriptionAccessDenied(ResponseMessageError): + pass + + +class ErrorSubscriptionDelegateAccessNotSupported(ResponseMessageError): + pass + + +class ErrorSubscriptionNotFound(ResponseMessageError): + pass + + +class ErrorSubscriptionUnsubsribed(ResponseMessageError): + pass + + +class ErrorSyncFolderNotFound(ResponseMessageError): + pass + + +class ErrorTimeIntervalTooBig(ResponseMessageError): + pass + + +class ErrorTimeoutExpired(ResponseMessageError): + pass + + +class ErrorTimeZone(ResponseMessageError): + pass + + +class ErrorToFolderNotFound(ResponseMessageError): + pass + + +class ErrorTokenSerializationDenied(ResponseMessageError): + pass + + +class ErrorTooManyObjectsOpened(ResponseMessageError): + pass + + +class ErrorUnableToGetUserOofSettings(ResponseMessageError): + pass + + +class ErrorUnifiedMessagingDialPlanNotFound(ResponseMessageError): + pass + + +class ErrorUnifiedMessagingRequestFailed(ResponseMessageError): + pass + + +class ErrorUnifiedMessagingServerNotFound(ResponseMessageError): + pass + + +class ErrorUnsupportedCulture(ResponseMessageError): + pass + + +class ErrorUnsupportedMapiPropertyType(ResponseMessageError): + pass + + +class ErrorUnsupportedMimeConversion(ResponseMessageError): + pass + + +class ErrorUnsupportedPathForQuery(ResponseMessageError): + pass + + +class ErrorUnsupportedPathForSortGroup(ResponseMessageError): + pass + + +class ErrorUnsupportedPropertyDefinition(ResponseMessageError): + pass + + +class ErrorUnsupportedQueryFilter(ResponseMessageError): + pass + + +class ErrorUnsupportedRecurrence(ResponseMessageError): + pass + + +class ErrorUnsupportedSubFilter(ResponseMessageError): + pass + + +class ErrorUnsupportedTypeForConversion(ResponseMessageError): + pass + + +class ErrorUpdateDelegatesFailed(ResponseMessageError): + pass + + +class ErrorUpdatePropertyMismatch(ResponseMessageError): + pass + + +class ErrorUserNotAllowedByPolicy(ResponseMessageError): + pass + + +class ErrorUserNotUnifiedMessagingEnabled(ResponseMessageError): + pass + + +class ErrorUserWithoutFederatedProxyAddress(ResponseMessageError): + pass + + +class ErrorValueOutOfRange(ResponseMessageError): + pass + + +class ErrorVirusDetected(ResponseMessageError): + pass + + +class ErrorVirusMessageDeleted(ResponseMessageError): + pass + + +class ErrorVoiceMailNotImplemented(ResponseMessageError): + pass + + +class ErrorWebRequestInInvalidState(ResponseMessageError): + pass + + +class ErrorWin32InteropError(ResponseMessageError): + pass + + +class ErrorWorkingHoursSaveFailed(ResponseMessageError): + pass + + +class ErrorWorkingHoursXmlMalformed(ResponseMessageError): + pass + + +class ErrorWrongServerVersion(ResponseMessageError): + pass + + +class ErrorWrongServerVersionDelegate(ResponseMessageError): + pass # Microsoft recommends to cache the autodiscover data around 24 hours and perform autodiscover diff --git a/exchangelib/ewsdatetime.py b/exchangelib/ewsdatetime.py index db4768f7..834cd738 100644 --- a/exchangelib/ewsdatetime.py +++ b/exchangelib/ewsdatetime.py @@ -5,9 +5,10 @@ import zoneinfo except ImportError: from backports import zoneinfo + import tzlocal -from .errors import NaiveDateTimeNotAllowed, UnknownTimeZone, InvalidTypeError +from .errors import InvalidTypeError, NaiveDateTimeNotAllowed, UnknownTimeZone from .winzone import IANA_TO_MS_TIMEZONE_MAP, MS_TIMEZONE_TO_IANA_MAP log = logging.getLogger(__name__) @@ -16,7 +17,7 @@ class EWSDate(datetime.date): """Extends the normal date implementation to satisfy EWS.""" - __slots__ = '_year', '_month', '_day', '_hashcode' + __slots__ = "_year", "_month", "_day", "_hashcode" def ewsformat(self): """ISO 8601 format to satisfy xs:date as interpreted by EWS. Example: 2009-01-15.""" @@ -52,21 +53,21 @@ def fromordinal(cls, n): @classmethod def from_date(cls, d): if type(d) is not datetime.date: - raise InvalidTypeError('d', d, datetime.date) + raise InvalidTypeError("d", d, datetime.date) return cls(d.year, d.month, d.day) @classmethod def from_string(cls, date_string): # Sometimes, we'll receive a date string with timezone information. Not very useful. - if date_string.endswith('Z'): - date_fmt = '%Y-%m-%dZ' - elif ':' in date_string: - if '+' in date_string: - date_fmt = '%Y-%m-%d+%H:%M' + if date_string.endswith("Z"): + date_fmt = "%Y-%m-%dZ" + elif ":" in date_string: + if "+" in date_string: + date_fmt = "%Y-%m-%d+%H:%M" else: - date_fmt = '%Y-%m-%d-%H:%M' + date_fmt = "%Y-%m-%d-%H:%M" else: - date_fmt = '%Y-%m-%d' + date_fmt = "%Y-%m-%d" d = datetime.datetime.strptime(date_string, date_fmt).date() if isinstance(d, cls): return d @@ -76,7 +77,7 @@ def from_string(cls, date_string): class EWSDateTime(datetime.datetime): """Extends the normal datetime implementation to satisfy EWS.""" - __slots__ = '_year', '_month', '_day', '_hour', '_minute', '_second', '_microsecond', '_tzinfo', '_hashcode' + __slots__ = "_year", "_month", "_day", "_hour", "_minute", "_second", "_microsecond", "_tzinfo", "_hashcode" def __new__(cls, *args, **kwargs): # pylint: disable=arguments-differ @@ -84,16 +85,16 @@ def __new__(cls, *args, **kwargs): if len(args) == 8: tzinfo = args[7] else: - tzinfo = kwargs.get('tzinfo') + tzinfo = kwargs.get("tzinfo") if isinstance(tzinfo, zoneinfo.ZoneInfo): # Don't allow pytz or dateutil timezones here. They are not safe to use as direct input for datetime() tzinfo = EWSTimeZone.from_timezone(tzinfo) if not isinstance(tzinfo, (EWSTimeZone, type(None))): - raise InvalidTypeError('tzinfo', tzinfo, EWSTimeZone) + raise InvalidTypeError("tzinfo", tzinfo, EWSTimeZone) if len(args) == 8: args = args[:7] + (tzinfo,) else: - kwargs['tzinfo'] = tzinfo + kwargs["tzinfo"] = tzinfo return super().__new__(cls, *args, **kwargs) def ewsformat(self): @@ -102,17 +103,17 @@ def ewsformat(self): * 2009-01-15T13:45:56+01:00 """ if not self.tzinfo: - raise ValueError(f'{self!r} must be timezone-aware') - if self.tzinfo.key == 'UTC': + raise ValueError(f"{self!r} must be timezone-aware") + if self.tzinfo.key == "UTC": if self.microsecond: - return self.strftime('%Y-%m-%dT%H:%M:%S.%fZ') - return self.strftime('%Y-%m-%dT%H:%M:%SZ') + return self.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + return self.strftime("%Y-%m-%dT%H:%M:%SZ") return self.isoformat() @classmethod def from_datetime(cls, d): if type(d) is not datetime.datetime: - raise InvalidTypeError('d', d, datetime.datetime) + raise InvalidTypeError("d", d, datetime.datetime) if d.tzinfo is None: tz = None elif isinstance(d.tzinfo, EWSTimeZone): @@ -152,12 +153,12 @@ def __isub__(self, other): @classmethod def from_string(cls, date_string): # Parses several common datetime formats and returns timezone-aware EWSDateTime objects - if date_string.endswith('Z'): + if date_string.endswith("Z"): # UTC datetime - return super().strptime(date_string, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=UTC) + return super().strptime(date_string, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) if len(date_string) == 19: # This is probably a naive datetime. Don't allow this, but signal caller with an appropriate error - local_dt = super().strptime(date_string, '%Y-%m-%dT%H:%M:%S') + local_dt = super().strptime(date_string, "%Y-%m-%dT%H:%M:%S") raise NaiveDateTimeNotAllowed(local_dt) # This is probably a datetime value with timezone information. This comes in the form '+/-HH:MM'. aware_dt = datetime.datetime.fromisoformat(date_string).astimezone(UTC).replace(tzinfo=UTC) @@ -216,12 +217,12 @@ def __new__(cls, *args, **kwargs): try: instance.ms_id = cls.IANA_TO_MS_MAP[instance.key][0] except KeyError: - raise UnknownTimeZone(f'No Windows timezone name found for timezone {instance.key!r}') + raise UnknownTimeZone(f"No Windows timezone name found for timezone {instance.key!r}") # We don't need the Windows long-format timezone name in long format. It's used in timezone XML elements, but # EWS happily accepts empty strings. For a full list of timezones supported by the target server, including # long-format names, see output of services.GetServerTimeZones(account.protocol).call() - instance.ms_name = '' + instance.ms_name = "" return instance def __eq__(self, other): @@ -239,11 +240,11 @@ def from_ms_id(cls, ms_id): try: return cls(cls.MS_TO_IANA_MAP[ms_id]) except KeyError: - if '/' in ms_id: + if "/" in ms_id: # EWS sometimes returns an ID that has a region/location format, e.g. 'Europe/Copenhagen'. Try the # string unaltered. return cls(ms_id) - raise UnknownTimeZone(f'Windows timezone ID {ms_id!r} is unknown by CLDR') + raise UnknownTimeZone(f"Windows timezone ID {ms_id!r} is unknown by CLDR") @classmethod def from_pytz(cls, tz): @@ -258,8 +259,8 @@ def from_datetime(cls, tz): def from_dateutil(cls, tz): # Objects returned by dateutil.tz.tzlocal() and dateutil.tz.gettz() are not supported. They # don't contain enough information to reliably match them with a CLDR timezone. - if hasattr(tz, '_filename'): - key = '/'.join(tz._filename.split('/')[-2:]) + if hasattr(tz, "_filename"): + key = "/".join(tz._filename.split("/")[-2:]) return cls(key) return cls(tz.tzname(datetime.datetime.now())) @@ -271,19 +272,19 @@ def from_zoneinfo(cls, tz): def from_timezone(cls, tz): # Support multiple tzinfo implementations. We could use isinstance(), but then we'd have to have pytz # and dateutil as dependencies for this package. - tz_module = tz.__class__.__module__.split('.')[0] + tz_module = tz.__class__.__module__.split(".")[0] try: return { - cls.__module__.split('.')[0]: lambda z: z, - 'backports': cls.from_zoneinfo, - 'datetime': cls.from_datetime, - 'dateutil': cls.from_dateutil, - 'pytz': cls.from_pytz, - 'zoneinfo': cls.from_zoneinfo, - 'pytz_deprecation_shim': lambda z: cls.from_timezone(z.unwrap_shim()) + cls.__module__.split(".")[0]: lambda z: z, + "backports": cls.from_zoneinfo, + "datetime": cls.from_datetime, + "dateutil": cls.from_dateutil, + "pytz": cls.from_pytz, + "zoneinfo": cls.from_zoneinfo, + "pytz_deprecation_shim": lambda z: cls.from_timezone(z.unwrap_shim()), }[tz_module](tz) except KeyError: - raise TypeError(f'Unsupported tzinfo type: {tz!r}') + raise TypeError(f"Unsupported tzinfo type: {tz!r}") @classmethod def localzone(cls): @@ -302,5 +303,5 @@ def fromutc(self, dt): return EWSDateTime.from_datetime(t) # We want to return EWSDateTime objects -UTC = EWSTimeZone('UTC') +UTC = EWSTimeZone("UTC") UTC_NOW = lambda: EWSDateTime.now(tz=UTC) # noqa: E731 diff --git a/exchangelib/extended_properties.py b/exchangelib/extended_properties.py index 5e325364..b050b3ce 100644 --- a/exchangelib/extended_properties.py +++ b/exchangelib/extended_properties.py @@ -4,8 +4,17 @@ from .errors import InvalidEnumValue from .ewsdatetime import EWSDateTime from .properties import EWSElement, ExtendedFieldURI -from .util import create_element, add_xml_child, get_xml_attrs, get_xml_attr, set_xml_value, value_to_xml_text, \ - xml_text_to_value, is_iterable, TNS +from .util import ( + TNS, + add_xml_child, + create_element, + get_xml_attr, + get_xml_attrs, + is_iterable, + set_xml_value, + value_to_xml_text, + xml_text_to_value, +) log = logging.getLogger(__name__) @@ -13,72 +22,72 @@ class ExtendedProperty(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/extendedproperty""" - ELEMENT_NAME = 'ExtendedProperty' + ELEMENT_NAME = "ExtendedProperty" # Enum values: https://docs.microsoft.com/en-us/dotnet/api/exchangewebservices.distinguishedpropertysettype DISTINGUISHED_SETS = { - 'Address', - 'Appointment', - 'CalendarAssistant', - 'Common', - 'InternetHeaders', - 'Meeting', - 'PublicStrings', - 'Sharing', - 'Task', - 'UnifiedMessaging', + "Address", + "Appointment", + "CalendarAssistant", + "Common", + "InternetHeaders", + "Meeting", + "PublicStrings", + "Sharing", + "Task", + "UnifiedMessaging", } # Enum values: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/extendedfielduri PROPERTY_TYPES = { - 'ApplicationTime', - 'Binary', - 'BinaryArray', - 'Boolean', - 'CLSID', - 'CLSIDArray', - 'Currency', - 'CurrencyArray', - 'Double', - 'DoubleArray', + "ApplicationTime", + "Binary", + "BinaryArray", + "Boolean", + "CLSID", + "CLSIDArray", + "Currency", + "CurrencyArray", + "Double", + "DoubleArray", # 'Error', - 'Float', - 'FloatArray', - 'Integer', - 'IntegerArray', - 'Long', - 'LongArray', + "Float", + "FloatArray", + "Integer", + "IntegerArray", + "Long", + "LongArray", # 'Null', # 'Object', # 'ObjectArray', - 'Short', - 'ShortArray', - 'SystemTime', - 'SystemTimeArray', - 'String', - 'StringArray', + "Short", + "ShortArray", + "SystemTime", + "SystemTimeArray", + "String", + "StringArray", } # The commented-out types cannot be used for setting or getting (see docs) and are thus not very useful here # Translation table between common distinguished_property_set_id and property_set_id values. See # https://docs.microsoft.com/en-us/office/client-developer/outlook/mapi/commonly-used-property-sets # ID values must be lowercase. DISTINGUISHED_SET_NAME_TO_ID_MAP = { - 'Address': '00062004-0000-0000-c000-000000000046', - 'AirSync': '71035549-0739-4dcb-9163-00f0580dbbdf', - 'Appointment': '00062002-0000-0000-c000-000000000046', - 'Common': '00062008-0000-0000-c000-000000000046', - 'InternetHeaders': '00020386-0000-0000-c000-000000000046', - 'Log': '0006200a-0000-0000-c000-000000000046', - 'Mapi': '00020328-0000-0000-c000-000000000046', - 'Meeting': '6ed8da90-450b-101b-98da-00aa003f1305', - 'Messaging': '41f28f13-83f4-4114-a584-eedb5a6b0bff', - 'Note': '0006200e-0000-0000-c000-000000000046', - 'PostRss': '00062041-0000-0000-c000-000000000046', - 'PublicStrings': '00020329-0000-0000-c000-000000000046', - 'Remote': '00062014-0000-0000-c000-000000000046', - 'Report': '00062013-0000-0000-c000-000000000046', - 'Sharing': '00062040-0000-0000-c000-000000000046', - 'Task': '00062003-0000-0000-c000-000000000046', - 'UnifiedMessaging': '4442858e-a9e3-4e80-b900-317a210cc15b', + "Address": "00062004-0000-0000-c000-000000000046", + "AirSync": "71035549-0739-4dcb-9163-00f0580dbbdf", + "Appointment": "00062002-0000-0000-c000-000000000046", + "Common": "00062008-0000-0000-c000-000000000046", + "InternetHeaders": "00020386-0000-0000-c000-000000000046", + "Log": "0006200a-0000-0000-c000-000000000046", + "Mapi": "00020328-0000-0000-c000-000000000046", + "Meeting": "6ed8da90-450b-101b-98da-00aa003f1305", + "Messaging": "41f28f13-83f4-4114-a584-eedb5a6b0bff", + "Note": "0006200e-0000-0000-c000-000000000046", + "PostRss": "00062041-0000-0000-c000-000000000046", + "PublicStrings": "00020329-0000-0000-c000-000000000046", + "Remote": "00062014-0000-0000-c000-000000000046", + "Report": "00062013-0000-0000-c000-000000000046", + "Sharing": "00062040-0000-0000-c000-000000000046", + "Task": "00062003-0000-0000-c000-000000000046", + "UnifiedMessaging": "4442858e-a9e3-4e80-b900-317a210cc15b", } DISTINGUISHED_SET_ID_TO_NAME_MAP = {v: k for k, v in DISTINGUISHED_SET_NAME_TO_ID_MAP.items()} @@ -87,15 +96,15 @@ class ExtendedProperty(EWSElement): property_tag = None # hex integer (e.g. 0x8000) or string ('0x8000') property_name = None property_id = None # integer as hex-formatted int (e.g. 0x8000) or normal int (32768) - property_type = '' + property_type = "" - __slots__ = 'value', + __slots__ = ("value",) def __init__(self, *args, **kwargs): if not kwargs: # Allow to set attributes without keyword kwargs = dict(zip(self._slots_keys, args)) - self.value = kwargs.pop('value') + self.value = kwargs.pop("value") super().__init__(**kwargs) @classmethod @@ -121,7 +130,7 @@ def _validate_distinguished_property_set_id(cls): ) if cls.distinguished_property_set_id not in cls.DISTINGUISHED_SETS: raise InvalidEnumValue( - 'distinguished_property_set_id', cls.distinguished_property_set_id, cls.DISTINGUISHED_SETS + "distinguished_property_set_id", cls.distinguished_property_set_id, cls.DISTINGUISHED_SETS ) @classmethod @@ -132,16 +141,12 @@ def _validate_property_set_id(cls): "When 'property_set_id' is set, 'distinguished_property_set_id' and 'property_tag' must be None" ) if not any([cls.property_id, cls.property_name]): - raise ValueError( - "When 'property_set_id' is set, 'property_id' or 'property_name' must also be set" - ) + raise ValueError("When 'property_set_id' is set, 'property_id' or 'property_name' must also be set") @classmethod def _validate_property_tag(cls): if cls.property_tag: - if any([ - cls.distinguished_property_set_id, cls.property_set_id, cls.property_name, cls.property_id - ]): + if any([cls.distinguished_property_set_id, cls.property_set_id, cls.property_name, cls.property_id]): raise ValueError("When 'property_tag' is set, only 'property_type' must be set") if 0x8000 <= cls.property_tag_as_int() <= 0xFFFE: raise ValueError( @@ -171,7 +176,7 @@ def _validate_property_id(cls): @classmethod def _validate_property_type(cls): if cls.property_type not in cls.PROPERTY_TYPES: - raise InvalidEnumValue('property_type', cls.property_type, cls.PROPERTY_TYPES) + raise InvalidEnumValue("property_type", cls.property_type, cls.PROPERTY_TYPES) def clean(self, version=None): self.validate_cls() @@ -219,30 +224,29 @@ def from_xml(cls, elem, account): # Gets value of this specific ExtendedProperty from a list of 'ExtendedProperty' XML elements python_type = cls.python_type() if cls.is_array_type(): - values = elem.find(f'{{{TNS}}}Values') + values = elem.find(f"{{{TNS}}}Values") return [ - xml_text_to_value(value=val, value_type=python_type) - for val in get_xml_attrs(values, f'{{{TNS}}}Value') + xml_text_to_value(value=val, value_type=python_type) for val in get_xml_attrs(values, f"{{{TNS}}}Value") ] - extended_field_value = xml_text_to_value(value=get_xml_attr(elem, f'{{{TNS}}}Value'), value_type=python_type) + extended_field_value = xml_text_to_value(value=get_xml_attr(elem, f"{{{TNS}}}Value"), value_type=python_type) if python_type == str and not extended_field_value: # For string types, we want to return the empty string instead of None if the element was # actually found, but there was no XML value. For other types, it would be more problematic # to make that distinction, e.g. return False for bool, 0 for int, etc. - return '' + return "" return extended_field_value def to_xml(self, version): if self.is_array_type(): - values = create_element('t:Values') + values = create_element("t:Values") for v in self.value: - add_xml_child(values, 't:Value', v) + add_xml_child(values, "t:Value", v) return values - return set_xml_value(create_element('t:Value'), self.value, version=version) + return set_xml_value(create_element("t:Value"), self.value, version=version) @classmethod def is_array_type(cls): - return cls.property_type.endswith('Array') + return cls.property_type.endswith("Array") @classmethod def property_tag_as_int(cls): @@ -259,18 +263,18 @@ def python_type(cls): # Return the best equivalent for a Python type for the property type of this class base_type = cls.property_type[:-5] if cls.is_array_type() else cls.property_type return { - 'ApplicationTime': Decimal, - 'Binary': bytes, - 'Boolean': bool, - 'CLSID': str, - 'Currency': int, - 'Double': Decimal, - 'Float': Decimal, - 'Integer': int, - 'Long': int, - 'Short': int, - 'SystemTime': EWSDateTime, - 'String': str, + "ApplicationTime": Decimal, + "Binary": bytes, + "Boolean": bool, + "CLSID": str, + "Currency": int, + "Double": Decimal, + "Float": Decimal, + "Integer": int, + "Long": int, + "Short": int, + "SystemTime": EWSDateTime, + "String": str, }[base_type] @classmethod @@ -291,9 +295,9 @@ class ExternId(ExtendedProperty): from an external system. """ - property_set_id = 'c11ff724-aa03-4555-9952-8fa248a11c3e' # This is arbitrary. We just want a unique UUID. - property_name = 'External ID' - property_type = 'String' + property_set_id = "c11ff724-aa03-4555-9952-8fa248a11c3e" # This is arbitrary. We just want a unique UUID. + property_name = "External ID" + property_type = "String" class Flag(ExtendedProperty): @@ -304,4 +308,4 @@ class Flag(ExtendedProperty): """ property_tag = 0x1090 - property_type = 'Integer' + property_type = "Integer" diff --git a/exchangelib/fields.py b/exchangelib/fields.py index f1bafa66..78e19133 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -5,52 +5,60 @@ from importlib import import_module from .errors import InvalidTypeError -from .ewsdatetime import EWSDateTime, EWSDate, EWSTimeZone, NaiveDateTimeNotAllowed, UnknownTimeZone, UTC -from .util import create_element, get_xml_attr, get_xml_attrs, set_xml_value, value_to_xml_text, is_iterable, \ - xml_text_to_value, TNS -from .version import Build, EXCHANGE_2013 +from .ewsdatetime import UTC, EWSDate, EWSDateTime, EWSTimeZone, NaiveDateTimeNotAllowed, UnknownTimeZone +from .util import ( + TNS, + create_element, + get_xml_attr, + get_xml_attrs, + is_iterable, + set_xml_value, + value_to_xml_text, + xml_text_to_value, +) +from .version import EXCHANGE_2013, Build log = logging.getLogger(__name__) # DayOfWeekIndex enum. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/dayofweekindex -FIRST = 'First' -SECOND = 'Second' -THIRD = 'Third' -FOURTH = 'Fourth' -LAST = 'Last' +FIRST = "First" +SECOND = "Second" +THIRD = "Third" +FOURTH = "Fourth" +LAST = "Last" WEEK_NUMBERS = (FIRST, SECOND, THIRD, FOURTH, LAST) # Month enum -JANUARY = 'January' -FEBRUARY = 'February' -MARCH = 'March' -APRIL = 'April' -MAY = 'May' -JUNE = 'June' -JULY = 'July' -AUGUST = 'August' -SEPTEMBER = 'September' -OCTOBER = 'October' -NOVEMBER = 'November' -DECEMBER = 'December' +JANUARY = "January" +FEBRUARY = "February" +MARCH = "March" +APRIL = "April" +MAY = "May" +JUNE = "June" +JULY = "July" +AUGUST = "August" +SEPTEMBER = "September" +OCTOBER = "October" +NOVEMBER = "November" +DECEMBER = "December" MONTHS = (JANUARY, FEBRUARY, MARCH, APRIL, MAY, JUNE, JULY, AUGUST, SEPTEMBER, OCTOBER, NOVEMBER, DECEMBER) # Weekday enum -MONDAY = 'Monday' -TUESDAY = 'Tuesday' -WEDNESDAY = 'Wednesday' -THURSDAY = 'Thursday' -FRIDAY = 'Friday' -SATURDAY = 'Saturday' -SUNDAY = 'Sunday' +MONDAY = "Monday" +TUESDAY = "Tuesday" +WEDNESDAY = "Wednesday" +THURSDAY = "Thursday" +FRIDAY = "Friday" +SATURDAY = "Saturday" +SUNDAY = "Sunday" WEEKDAY_NAMES = (MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY) # Used for weekday recurrences except weekly recurrences. E.g. for "First WeekendDay in March" -DAY = 'Day' -WEEK_DAY = 'Weekday' # Non-weekend day -WEEKEND_DAY = 'WeekendDay' +DAY = "Day" +WEEK_DAY = "Weekday" # Non-weekend day +WEEKEND_DAY = "WeekendDay" EXTRA_WEEKDAY_OPTIONS = (DAY, WEEK_DAY, WEEKEND_DAY) # DaysOfWeek enum: See @@ -81,8 +89,8 @@ def split_field_path(field_path): 'physical_addresses__Home__street' -> ('physical_addresses', 'Home', 'street') """ if not isinstance(field_path, str): - raise InvalidTypeError('field_path', field_path, str) - search_parts = field_path.split('__') + raise InvalidTypeError("field_path", field_path, str) + search_parts = field_path.split("__") field = search_parts[0] try: label = search_parts[1] @@ -99,7 +107,8 @@ def resolve_field_path(field_path, folder, strict=True): """Take the name of a field, or '__'-delimited path to a subfield, and return the corresponding Field object, label and SubField object. """ - from .indexed_properties import SingleFieldIndexedElement, MultiFieldIndexedElement + from .indexed_properties import MultiFieldIndexedElement, SingleFieldIndexedElement + fieldname, label, subfield_name = split_field_path(field_path) field = folder.get_item_field_by_fieldname(fieldname) subfield = None @@ -109,9 +118,7 @@ def resolve_field_path(field_path, folder, strict=True): f"IndexedField path {field_path!r} must specify label, e.g. " f"'{fieldname}__{field.value_cls.get_field_by_fieldname('label').default}'" ) - valid_labels = field.value_cls.get_field_by_fieldname('label').supported_choices( - version=folder.account.version - ) + valid_labels = field.value_cls.get_field_by_fieldname("label").supported_choices(version=folder.account.version) if label and label not in valid_labels: raise ValueError( f"Label {label!r} on IndexedField path {field_path!r} must be one of {sorted(valid_labels)}" @@ -127,16 +134,16 @@ def resolve_field_path(field_path, folder, strict=True): try: subfield = field.value_cls.get_field_by_fieldname(subfield_name) except ValueError: - field_names = ', '.join(f.name for f in field.value_cls.supported_fields( - version=folder.account.version - )) + field_names = ", ".join( + f.name for f in field.value_cls.supported_fields(version=folder.account.version) + ) raise ValueError( f"Subfield {subfield_name!r} on IndexedField path {field_path!r} " f"must be one of {sorted(field_names)}" ) else: if not issubclass(field.value_cls, SingleFieldIndexedElement): - raise InvalidTypeError('field.value_cls', field.value_cls, SingleFieldIndexedElement) + raise InvalidTypeError("field.value_cls", field.value_cls, SingleFieldIndexedElement) if subfield_name: raise ValueError( f"IndexedField path {field_path!r} must not specify subfield, e.g. just {fieldname}__{label}'" @@ -201,8 +208,11 @@ def expand(self, version): # If this path does not point to a specific subfield on an indexed property, return all the possible path # combinations for this field path. if isinstance(self.field, IndexedField): - labels = [self.label] if self.label \ - else self.field.value_cls.get_field_by_fieldname('label').supported_choices(version=version) + labels = ( + [self.label] + if self.label + else self.field.value_cls.get_field_by_fieldname("label").supported_choices(version=version) + ) subfields = [self.subfield] if self.subfield else self.field.value_cls.supported_fields(version=version) for label in labels: for subfield in subfields: @@ -214,9 +224,10 @@ def expand(self, version): def path(self): if self.label: from .indexed_properties import SingleFieldIndexedElement + if issubclass(self.field.value_cls, SingleFieldIndexedElement) or not self.subfield: - return f'{self.field.name}__{self.label}' - return f'{self.field.name}__{self.label}__{self.subfield.name}' + return f"{self.field.name}__{self.label}" + return f"{self.field.name}__{self.label}__{self.subfield.name}" return self.field.name def __eq__(self, other): @@ -234,6 +245,7 @@ def __hash__(self): class FieldOrder: """Holds values needed to call server-side sorting on a single field path.""" + def __init__(self, field_path, reverse=False): """ @@ -246,12 +258,12 @@ def __init__(self, field_path, reverse=False): @classmethod def from_string(cls, field_path, folder): return cls( - field_path=FieldPath.from_string(field_path=field_path.lstrip('-'), folder=folder, strict=True), - reverse=field_path.startswith('-') + field_path=FieldPath.from_string(field_path=field_path.lstrip("-"), folder=folder, strict=True), + reverse=field_path.startswith("-"), ) def to_xml(self): - field_order = create_element('t:FieldOrder', attrs=dict(Order='Descending' if self.reverse else 'Ascending')) + field_order = create_element("t:FieldOrder", attrs=dict(Order="Descending" if self.reverse else "Ascending")) field_order.append(self.field_path.to_xml()) return field_order @@ -269,9 +281,19 @@ class Field(metaclass=abc.ABCMeta): # is_complex = False - def __init__(self, name=None, is_required=False, is_required_after_save=False, is_read_only=False, - is_read_only_after_send=False, is_searchable=True, is_attribute=False, default=None, - supported_from=None, deprecated_from=None): + def __init__( + self, + name=None, + is_required=False, + is_required_after_save=False, + is_read_only=False, + is_read_only_after_send=False, + is_searchable=True, + is_attribute=False, + default=None, + supported_from=None, + deprecated_from=None, + ): self.name = name # Usually set by the EWSMeta metaclass self.default = default # Default value if none is given self.is_required = is_required @@ -289,12 +311,12 @@ def __init__(self, name=None, is_required=False, is_required_after_save=False, i # The Exchange build when this field was introduced. When talking with versions prior to this version, # we will ignore this field. if supported_from is not None and not isinstance(supported_from, Build): - raise InvalidTypeError('supported_from', supported_from, Build) + raise InvalidTypeError("supported_from", supported_from, Build) self.supported_from = supported_from # The Exchange build when this field was deprecated. When talking with versions at or later than this version, # we will ignore this field. if deprecated_from is not None and not isinstance(deprecated_from, Build): - raise InvalidTypeError('deprecated_from', deprecated_from, Build) + raise InvalidTypeError("deprecated_from", deprecated_from, Build) self.deprecated_from = deprecated_from def clean(self, value, version=None): @@ -312,12 +334,12 @@ def clean(self, value, version=None): for v in value: if not isinstance(v, self.value_cls): raise TypeError(f"Field {self.name!r} value {v!r} must be of type {self.value_cls}") - if hasattr(v, 'clean'): + if hasattr(v, "clean"): v.clean(version=version) else: if not isinstance(value, self.value_cls): raise TypeError(f"Field {self.name!r} value {value!r} must be of type {self.value_cls}") - if hasattr(value, 'clean'): + if hasattr(value, "clean"): value.clean(version=version) return value @@ -345,9 +367,10 @@ def __hash__(self): """Field instances must be hashable""" def __repr__(self): - args_str = ', '.join(f'{f}={getattr(self, f)!r}' for f in ( - 'name', 'value_cls', 'is_list', 'is_complex', 'default')) - return f'{self.__class__.__name__}({args_str})' + args_str = ", ".join( + f"{f}={getattr(self, f)!r}" for f in ("name", "value_cls", "is_list", "is_complex", "default") + ) + return f"{self.__class__.__name__}({args_str})" class FieldURIField(Field): @@ -357,16 +380,16 @@ class FieldURIField(Field): """ def __init__(self, *args, **kwargs): - self.field_uri = kwargs.pop('field_uri', None) - self.namespace = kwargs.pop('namespace', TNS) + self.field_uri = kwargs.pop("field_uri", None) + self.namespace = kwargs.pop("namespace", TNS) super().__init__(*args, **kwargs) # See all valid FieldURI values at # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/fielduri # The field_uri has a prefix when the FieldURI points to an Item field. if self.field_uri is None: self.field_uri_postfix = None - elif ':' in self.field_uri: - self.field_uri_postfix = self.field_uri.split(':')[1] + elif ":" in self.field_uri: + self.field_uri_postfix = self.field_uri.split(":")[1] else: self.field_uri_postfix = self.field_uri @@ -391,6 +414,7 @@ def to_xml(self, value, version): def field_uri_xml(self): from .properties import FieldURI + if not self.field_uri: raise ValueError(f"'field_uri' value is missing on field '{self.name}'") return FieldURI(field_uri=self.field_uri).to_xml(version=None) @@ -398,12 +422,12 @@ def field_uri_xml(self): def request_tag(self): if not self.field_uri_postfix: raise ValueError(f"'field_uri_postfix' value is missing on field '{self.name}'") - return f't:{self.field_uri_postfix}' + return f"t:{self.field_uri_postfix}" def response_tag(self): if not self.field_uri_postfix: raise ValueError(f"'field_uri_postfix' value is missing on field '{self.name}'") - return f'{{{self.namespace}}}{self.field_uri_postfix}' + return f"{{{self.namespace}}}{self.field_uri_postfix}" def __hash__(self): return hash(self.field_uri) @@ -425,14 +449,13 @@ class IntegerField(FieldURIField): value_cls = int def __init__(self, *args, **kwargs): - self.min = kwargs.pop('min', None) - self.max = kwargs.pop('max', None) + self.min = kwargs.pop("min", None) + self.max = kwargs.pop("max", None) super().__init__(*args, **kwargs) def _clean_single_value(self, v): if self.min is not None and v < self.min: - raise ValueError( - f"Value {v!r} on field {self.name!r} must be greater than {self.min}") + raise ValueError(f"Value {v!r} on field {self.name!r} must be greater than {self.min}") if self.max is not None and v > self.max: raise ValueError(f"Value {v!r} on field {self.name!r} must be less than {self.max}") @@ -459,12 +482,12 @@ class EnumField(IntegerField): """ def __init__(self, *args, **kwargs): - self.enum = kwargs.pop('enum') + self.enum = kwargs.pop("enum") # Set different min/max defaults than IntegerField - if 'max' in kwargs: + if "max" in kwargs: raise AttributeError("EnumField does not support the 'max' attribute") - kwargs['min'] = kwargs.pop('min', 1) - kwargs['max'] = kwargs['min'] + len(self.enum) - 1 + kwargs["min"] = kwargs.pop("min", 1) + kwargs["max"] = kwargs["min"] + len(self.enum) - 1 super().__init__(*args, **kwargs) def clean(self, value, version=None): @@ -497,7 +520,7 @@ def from_xml(self, elem, account): if val is not None: try: if self.is_list: - return [self.enum.index(v) + 1 for v in val.split(' ')] + return [self.enum.index(v) + 1 for v in val.split(" ")] return self.enum.index(val) + 1 except ValueError: log.warning("Cannot convert value '%s' on field '%s' to type %s", val, self.name, self.value_cls) @@ -507,7 +530,7 @@ def from_xml(self, elem, account): def to_xml(self, value, version): field_elem = create_element(self.request_tag()) if self.is_list: - return set_xml_value(field_elem, ' '.join(self.as_string(value)), version=version) + return set_xml_value(field_elem, " ".join(self.as_string(value)), version=version) return set_xml_value(field_elem, self.as_string(value), version=version) @@ -540,10 +563,10 @@ def to_xml(self, value, version): class AppointmentStateField(IntegerField): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/appointmentstate""" - NONE = 'None' - MEETING = 'Meeting' - RECEIVED = 'Received' - CANCELLED = 'Canceled' + NONE = "None" + MEETING = "Meeting" + RECEIVED = "Received" + CANCELLED = "Canceled" STATES = { NONE: 0x0000, MEETING: 0x0001, @@ -565,8 +588,8 @@ class Base64Field(FieldURIField): is_complex = True def __init__(self, *args, **kwargs): - if 'is_searchable' not in kwargs: - kwargs['is_searchable'] = False + if "is_searchable" not in kwargs: + kwargs["is_searchable"] = False super().__init__(*args, **kwargs) @@ -594,7 +617,7 @@ class DateTimeBackedDateField(DateField): def __init__(self, *args, **kwargs): # Not all fields assume a default time of 00:00, so make this configurable - self._default_time = kwargs.pop('default_time', datetime.time(0, 0)) + self._default_time = kwargs.pop("default_time", datetime.time(0, 0)) super().__init__(*args, **kwargs) # Create internal field to handle datetime-only logic self._datetime_field = DateTimeField(*args, **kwargs) @@ -631,9 +654,9 @@ def from_xml(self, elem, account): val = self._get_val_from_elem(elem) if val is not None: try: - if ':' in val: + if ":" in val: # Assume a string of the form HH:MM:SS - return datetime.datetime.strptime(val, '%H:%M:%S').time() + return datetime.datetime.strptime(val, "%H:%M:%S").time() # Assume an integer in minutes since midnight return (datetime.datetime(2000, 1, 1) + datetime.timedelta(minutes=int(val))).time() except ValueError: @@ -647,14 +670,13 @@ class TimeDeltaField(FieldURIField): value_cls = datetime.timedelta def __init__(self, *args, **kwargs): - self.min = kwargs.pop('min', datetime.timedelta(0)) - self.max = kwargs.pop('max', datetime.timedelta(days=1)) + self.min = kwargs.pop("min", datetime.timedelta(0)) + self.max = kwargs.pop("max", datetime.timedelta(days=1)) super().__init__(*args, **kwargs) def clean(self, value, version=None): if self.min is not None and value < self.min: - raise ValueError( - f"Value {value!r} on field {self.name!r} must be greater than {self.min}") + raise ValueError(f"Value {value!r} on field {self.name!r} must be greater than {self.min}") if self.max is not None and value > self.max: raise ValueError(f"Value {value!r} on field {self.name!r} must be less than {self.max}") return super().clean(value, version=version) @@ -684,10 +706,10 @@ def from_xml(self, elem, account): if account: # Convert to timezone-aware datetime using the default timezone of the account tz = account.default_timezone - log.info('Found naive datetime %s on field %s. Assuming timezone %s', e.local_dt, self.name, tz) + log.info("Found naive datetime %s on field %s. Assuming timezone %s", e.local_dt, self.name, tz) return e.local_dt.replace(tzinfo=tz) # There's nothing we can do but return the naive date. It's better than assuming e.g. UTC. - log.warning('Returning naive datetime %s on field %s', e.local_dt, self.name) + log.warning("Returning naive datetime %s on field %s", e.local_dt, self.name) return e.local_dt log.info("Cannot convert value '%s' on field '%s' to type %s", val, self.name, self.value_cls) return None @@ -739,14 +761,16 @@ def clean(self, value, version=None): def from_xml(self, elem, account): field_elem = elem.find(self.response_tag()) if field_elem is not None: - ms_id = field_elem.get('Id') - ms_name = field_elem.get('Name') + ms_id = field_elem.get("Id") + ms_name = field_elem.get("Name") try: return self.value_cls.from_ms_id(ms_id or ms_name) except UnknownTimeZone: log.warning( "Cannot convert value '%s' on field '%s' to type %s (unknown timezone ID)", - (ms_id or ms_name), self.name, self.value_cls + (ms_id or ms_name), + self.name, + self.value_cls, ) return None return self.default @@ -754,7 +778,7 @@ def from_xml(self, elem, account): def to_xml(self, value, version): attrs = dict(Id=value.ms_id) if value.ms_name: - attrs['Name'] = value.ms_name + attrs["Name"] = value.ms_name return create_element(self.request_tag(), attrs=attrs) @@ -771,14 +795,14 @@ class TextListField(TextField): is_list = True def __init__(self, *args, **kwargs): - self.list_elem_name = kwargs.pop('list_elem_name', 'String') + self.list_elem_name = kwargs.pop("list_elem_name", "String") super().__init__(*args, **kwargs) def list_elem_request_tag(self): - return f't:{self.list_elem_name}' + return f"t:{self.list_elem_name}" def list_elem_response_tag(self): - return f'{{{self.namespace}}}{self.list_elem_name}' + return f"{{{self.namespace}}}{self.list_elem_name}" def from_xml(self, elem, account): iter_elem = elem.find(self.response_tag()) @@ -796,20 +820,20 @@ def to_xml(self, value, version): class MessageField(TextField): """A field that handles the Message element.""" - INNER_ELEMENT_NAME = 'Message' + INNER_ELEMENT_NAME = "Message" def from_xml(self, elem, account): reply = elem.find(self.response_tag()) if reply is None: return None - message = reply.find(f'{{{TNS}}}{self.INNER_ELEMENT_NAME}') + message = reply.find(f"{{{TNS}}}{self.INNER_ELEMENT_NAME}") if message is None: return None return message.text def to_xml(self, value, version): field_elem = create_element(self.request_tag()) - message = create_element(f't:{self.INNER_ELEMENT_NAME}') + message = create_element(f"t:{self.INNER_ELEMENT_NAME}") message.text = value return set_xml_value(field_elem, message, version=version) @@ -820,7 +844,7 @@ class CharField(TextField): is_complex = False def __init__(self, *args, **kwargs): - self.max_length = kwargs.pop('max_length', 255) + self.max_length = kwargs.pop("max_length", 255) if not 1 <= self.max_length <= 255: # A field supporting messages longer than 255 chars should be TextField raise ValueError("'max_length' must be in the range 1-255") @@ -850,7 +874,7 @@ class CharListField(TextListField): """Like TextListField, but for string values with a limited length.""" def __init__(self, *args, **kwargs): - self.max_length = kwargs.pop('max_length', 255) + self.max_length = kwargs.pop("max_length", 255) if not 1 <= self.max_length <= 255: # A field supporting messages longer than 255 chars should be TextField raise ValueError("'max_length' must be in the range 1-255") @@ -897,7 +921,7 @@ class ChoiceField(CharField): """Like CharField, but restricts the value to a limited set of strings.""" def __init__(self, *args, **kwargs): - self.choices = kwargs.pop('choices') + self.choices = kwargs.pop("choices") super().__init__(*args, **kwargs) def clean(self, value, version=None): @@ -923,15 +947,21 @@ def supported_choices(self, version): return tuple(c.value for c in self.choices if c.supports_version(version)) -FREE_BUSY_CHOICES = [Choice('Free'), Choice('Tentative'), Choice('Busy'), Choice('OOF'), Choice('NoData'), - Choice('WorkingElsewhere', supported_from=EXCHANGE_2013)] +FREE_BUSY_CHOICES = [ + Choice("Free"), + Choice("Tentative"), + Choice("Busy"), + Choice("OOF"), + Choice("NoData"), + Choice("WorkingElsewhere", supported_from=EXCHANGE_2013), +] class FreeBusyStatusField(ChoiceField): """Like ChoiceField, but specifically for Free/Busy values.""" def __init__(self, *args, **kwargs): - kwargs['choices'] = set(FREE_BUSY_CHOICES) + kwargs["choices"] = set(FREE_BUSY_CHOICES) super().__init__(*args, **kwargs) @@ -940,6 +970,7 @@ class BodyField(TextField): def __init__(self, *args, **kwargs): from .properties import Body + self.value_cls = Body super().__init__(*args, **kwargs) @@ -950,18 +981,19 @@ def clean(self, value, version=None): def from_xml(self, elem, account): from .properties import Body, HTMLBody + field_elem = elem.find(self.response_tag()) val = None if field_elem is None else field_elem.text or None if val is not None: - body_type = field_elem.get('BodyType') - return { - Body.body_type: Body, - HTMLBody.body_type: HTMLBody, - }[body_type](val) + body_type = field_elem.get("BodyType") + return {Body.body_type: Body, HTMLBody.body_type: HTMLBody,}[ + body_type + ](val) return self.default def to_xml(self, value, version): from .properties import Body, HTMLBody + body_type = { Body: Body.body_type, HTMLBody: HTMLBody.body_type, @@ -974,9 +1006,9 @@ class EWSElementField(FieldURIField): """A generic field for any EWSElement object.""" def __init__(self, *args, **kwargs): - self._value_cls = kwargs.pop('value_cls') - if 'namespace' not in kwargs: - kwargs['namespace'] = self.value_cls.NAMESPACE + self._value_cls = kwargs.pop("value_cls") + if "namespace" not in kwargs: + kwargs["namespace"] = self.value_cls.NAMESPACE super().__init__(*args, **kwargs) @property @@ -984,15 +1016,17 @@ def value_cls(self): if isinstance(self._value_cls, str): # Support 'value_cls' as string to allow self-referencing classes. The class must be importable from the # top-level module. - self._value_cls = getattr(import_module(self.__module__.split('.')[0]), self._value_cls) + self._value_cls = getattr(import_module(self.__module__.split(".")[0]), self._value_cls) return self._value_cls def from_xml(self, elem, account): if self.is_list: iter_elem = elem.find(self.response_tag()) if iter_elem is not None: - return [self.value_cls.from_xml(elem=e, account=account) - for e in iter_elem.findall(self.value_cls.response_tag())] + return [ + self.value_cls.from_xml(elem=e, account=account) + for e in iter_elem.findall(self.value_cls.response_tag()) + ] else: if self.field_uri is None: sub_elem = elem.find(self.value_cls.response_tag()) @@ -1019,7 +1053,8 @@ class EWSElementListField(EWSElementField): class TransitionListField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import BaseTransition - kwargs['value_cls'] = BaseTransition + + kwargs["value_cls"] = BaseTransition super().__init__(*args, **kwargs) def from_xml(self, elem, account): @@ -1036,7 +1071,8 @@ class AssociatedCalendarItemIdField(EWSElementField): def __init__(self, *args, **kwargs): from .properties import AssociatedCalendarItemId - kwargs['value_cls'] = AssociatedCalendarItemId + + kwargs["value_cls"] = AssociatedCalendarItemId super().__init__(*args, **kwargs) def to_xml(self, value, version): @@ -1048,7 +1084,8 @@ class RecurrenceField(EWSElementField): def __init__(self, *args, **kwargs): from .recurrence import Recurrence - kwargs['value_cls'] = Recurrence + + kwargs["value_cls"] = Recurrence super().__init__(*args, **kwargs) def to_xml(self, value, version): @@ -1060,7 +1097,8 @@ class TaskRecurrenceField(EWSElementField): def __init__(self, *args, **kwargs): from .recurrence import TaskRecurrence - kwargs['value_cls'] = TaskRecurrence + + kwargs["value_cls"] = TaskRecurrence super().__init__(*args, **kwargs) def to_xml(self, value, version): @@ -1072,7 +1110,8 @@ class ReferenceItemIdField(EWSElementField): def __init__(self, *args, **kwargs): from .properties import ReferenceItemId - kwargs['value_cls'] = ReferenceItemId + + kwargs["value_cls"] = ReferenceItemId super().__init__(*args, **kwargs) def to_xml(self, value, version): @@ -1090,7 +1129,8 @@ class OccurrenceListField(OccurrenceField): class MessageHeaderField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import MessageHeader - kwargs['value_cls'] = MessageHeader + + kwargs["value_cls"] = MessageHeader super().__init__(*args, **kwargs) @@ -1115,7 +1155,7 @@ def from_xml(self, elem, account): nested_elem = sub_elem.find(self.value_cls.response_tag()) if nested_elem is None: raise ValueError( - f'Expected XML element {self.value_cls.response_tag()!r} missing on field {self.name!r}' + f"Expected XML element {self.value_cls.response_tag()!r} missing on field {self.name!r}" ) return self.value_cls.from_xml(elem=nested_elem, account=account) return self.value_cls.from_xml(elem=sub_elem, account=account) @@ -1125,28 +1165,32 @@ def from_xml(self, elem, account): class EmailField(BaseEmailField): def __init__(self, *args, **kwargs): from .properties import Email - kwargs['value_cls'] = Email + + kwargs["value_cls"] = Email super().__init__(*args, **kwargs) class RecipientAddressField(BaseEmailField): def __init__(self, *args, **kwargs): from .properties import RecipientAddress - kwargs['value_cls'] = RecipientAddress + + kwargs["value_cls"] = RecipientAddress super().__init__(*args, **kwargs) class MailboxField(BaseEmailField): def __init__(self, *args, **kwargs): from .properties import Mailbox - kwargs['value_cls'] = Mailbox + + kwargs["value_cls"] = Mailbox super().__init__(*args, **kwargs) class MailboxListField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import Mailbox - kwargs['value_cls'] = Mailbox + + kwargs["value_cls"] = Mailbox super().__init__(*args, **kwargs) def clean(self, value, version=None): @@ -1158,29 +1202,33 @@ def clean(self, value, version=None): class MemberListField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import Member - kwargs['value_cls'] = Member + + kwargs["value_cls"] = Member super().__init__(*args, **kwargs) def clean(self, value, version=None): from .properties import Mailbox + if value is not None: - value = [ - self.value_cls(mailbox=Mailbox(email_address=s)) if isinstance(s, str) else s for s in value - ] + value = [self.value_cls(mailbox=Mailbox(email_address=s)) if isinstance(s, str) else s for s in value] return super().clean(value, version=version) class AttendeesField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import Attendee - kwargs['value_cls'] = Attendee + + kwargs["value_cls"] = Attendee super().__init__(*args, **kwargs) def clean(self, value, version=None): from .properties import Mailbox + if value is not None: - value = [self.value_cls(mailbox=Mailbox(email_address=s), response_type='Accept') - if isinstance(s, str) else s for s in value] + value = [ + self.value_cls(mailbox=Mailbox(email_address=s), response_type="Accept") if isinstance(s, str) else s + for s in value + ] return super().clean(value, version=version) @@ -1189,11 +1237,13 @@ class AttachmentField(EWSElementListField): def __init__(self, *args, **kwargs): from .attachments import Attachment - kwargs['value_cls'] = Attachment + + kwargs["value_cls"] = Attachment super().__init__(*args, **kwargs) def from_xml(self, elem, account): from .attachments import FileAttachment, ItemAttachment + iter_elem = elem.find(self.response_tag()) # Look for both FileAttachment and ItemAttachment if iter_elem is not None: @@ -1232,12 +1282,13 @@ def to_xml(self, value, version): @staticmethod def field_uri_xml(field_uri, label): from .properties import IndexedFieldURI + return IndexedFieldURI(field_uri=field_uri, field_index=label).to_xml(version=None) def clean(self, value, version=None): value = super().clean(value, version=version) if self.is_required and not value: - raise ValueError(f'Value for subfield {self.name!r} must be non-empty') + raise ValueError(f"Value for subfield {self.name!r} must be non-empty") return value def __hash__(self): @@ -1250,7 +1301,7 @@ class EmailSubField(SubField): value_cls = str def from_xml(self, elem, account): - return elem.text or elem.get('Name') # Sometimes elem.text is empty. Exchange saves the same in 'Name' attr + return elem.text or elem.get("Name") # Sometimes elem.text is empty. Exchange saves the same in 'Name' attr class NamedSubField(SubField): @@ -1259,8 +1310,8 @@ class NamedSubField(SubField): value_cls = str def __init__(self, *args, **kwargs): - self.field_uri = kwargs.pop('field_uri') - if ':' in self.field_uri: + self.field_uri = kwargs.pop("field_uri") + if ":" in self.field_uri: raise ValueError("'field_uri' value must not contain a colon") super().__init__(*args, **kwargs) @@ -1277,13 +1328,14 @@ def to_xml(self, value, version): def field_uri_xml(self, field_uri, label): from .properties import IndexedFieldURI - return IndexedFieldURI(field_uri=f'{field_uri}:{self.field_uri}', field_index=label).to_xml(version=None) + + return IndexedFieldURI(field_uri=f"{field_uri}:{self.field_uri}", field_index=label).to_xml(version=None) def request_tag(self): - return f't:{self.field_uri}' + return f"t:{self.field_uri}" def response_tag(self): - return f'{{{self.namespace}}}{self.field_uri}' + return f"{{{self.namespace}}}{self.field_uri}" class IndexedField(EWSElementField, metaclass=abc.ABCMeta): @@ -1293,16 +1345,17 @@ class IndexedField(EWSElementField, metaclass=abc.ABCMeta): def __init__(self, *args, **kwargs): from .indexed_properties import IndexedElement - value_cls = kwargs['value_cls'] + + value_cls = kwargs["value_cls"] if not issubclass(value_cls, IndexedElement): raise TypeError(f"'value_cls' {value_cls!r} must be a subclass of type {IndexedElement}") super().__init__(*args, **kwargs) def to_xml(self, value, version): - return set_xml_value(create_element(f't:{self.PARENT_ELEMENT_NAME}'), value, version=version) + return set_xml_value(create_element(f"t:{self.PARENT_ELEMENT_NAME}"), value, version=version) def response_tag(self): - return f'{{{self.namespace}}}{self.PARENT_ELEMENT_NAME}' + return f"{{{self.namespace}}}{self.PARENT_ELEMENT_NAME}" def __hash__(self): return hash(self.field_uri) @@ -1312,18 +1365,19 @@ class EmailAddressesField(IndexedField): is_list = True is_complex = True - PARENT_ELEMENT_NAME = 'EmailAddresses' + PARENT_ELEMENT_NAME = "EmailAddresses" def __init__(self, *args, **kwargs): from .indexed_properties import EmailAddress - kwargs['value_cls'] = EmailAddress + + kwargs["value_cls"] = EmailAddress super().__init__(*args, **kwargs) def clean(self, value, version=None): if value is not None: default_labels = self.value_cls.LABEL_CHOICES if len(value) > len(default_labels): - raise ValueError(f'This field can handle at most {len(default_labels)} values (value: {value})') + raise ValueError(f"This field can handle at most {len(default_labels)} values (value: {value})") tmp = [] for s, default_label in zip(value, default_labels): if not isinstance(s, str): @@ -1338,11 +1392,12 @@ class PhoneNumberField(IndexedField): is_list = True is_complex = True - PARENT_ELEMENT_NAME = 'PhoneNumbers' + PARENT_ELEMENT_NAME = "PhoneNumbers" def __init__(self, *args, **kwargs): from .indexed_properties import PhoneNumber - kwargs['value_cls'] = PhoneNumber + + kwargs["value_cls"] = PhoneNumber super().__init__(*args, **kwargs) @@ -1350,11 +1405,12 @@ class PhysicalAddressField(IndexedField): is_list = True is_complex = True - PARENT_ELEMENT_NAME = 'PhysicalAddresses' + PARENT_ELEMENT_NAME = "PhysicalAddresses" def __init__(self, *args, **kwargs): from .indexed_properties import PhysicalAddress - kwargs['value_cls'] = PhysicalAddress + + kwargs["value_cls"] = PhysicalAddress super().__init__(*args, **kwargs) @@ -1362,7 +1418,7 @@ class ExtendedPropertyField(Field): is_complex = True def __init__(self, *args, **kwargs): - self.value_cls = kwargs.pop('value_cls') + self.value_cls = kwargs.pop("value_cls") super().__init__(*args, **kwargs) def clean(self, value, version=None): @@ -1380,6 +1436,7 @@ def clean(self, value, version=None): def field_uri_xml(self): from .properties import ExtendedFieldURI + cls = self.value_cls return ExtendedFieldURI( distinguished_property_set_id=cls.distinguished_property_set_id, @@ -1417,10 +1474,12 @@ class ItemField(FieldURIField): @property def value_cls(self): from .items import Item + return Item def from_xml(self, elem, account): from .items import ITEM_CLASSES + for item_cls in ITEM_CLASSES: item_elem = elem.find(item_cls.response_tag()) if item_elem is not None: @@ -1434,7 +1493,7 @@ def to_xml(self, value, version): class UnknownEntriesField(CharListField): def list_elem_tag(self): - return f'{{{self.namespace}}}UnknownEntry' + return f"{{{self.namespace}}}UnknownEntry" class PermissionSetField(EWSElementField): @@ -1442,14 +1501,16 @@ class PermissionSetField(EWSElementField): def __init__(self, *args, **kwargs): from .properties import PermissionSet - kwargs['value_cls'] = PermissionSet + + kwargs["value_cls"] = PermissionSet super().__init__(*args, **kwargs) class EffectiveRightsField(EWSElementField): def __init__(self, *args, **kwargs): from .properties import EffectiveRights - kwargs['value_cls'] = EffectiveRights + + kwargs["value_cls"] = EffectiveRights super().__init__(*args, **kwargs) @@ -1464,7 +1525,7 @@ def from_xml(self, elem, account): try: return self.value_cls.from_hex_string(val) except (TypeError, ValueError): - log.warning('Invalid server version string: %r', val) + log.warning("Invalid server version string: %r", val) return val @@ -1472,7 +1533,8 @@ class ProtocolListField(EWSElementListField): # There is not containing element for this field. Just multiple 'Protocol' elements on the 'Account' element. def __init__(self, *args, **kwargs): from .autodiscover.properties import Protocol - kwargs['value_cls'] = Protocol + + kwargs["value_cls"] = Protocol super().__init__(*args, **kwargs) def from_xml(self, elem, account): @@ -1481,15 +1543,15 @@ def from_xml(self, elem, account): class RoutingTypeField(ChoiceField): def __init__(self, *args, **kwargs): - kwargs['choices'] = {Choice('SMTP'), Choice('EX')} - kwargs['default'] = 'SMTP' + kwargs["choices"] = {Choice("SMTP"), Choice("EX")} + kwargs["default"] = "SMTP" super().__init__(*args, **kwargs) class IdElementField(EWSElementField): def __init__(self, *args, **kwargs): - kwargs['is_searchable'] = False - kwargs['is_read_only'] = True + kwargs["is_searchable"] = False + kwargs["is_read_only"] = True super().__init__(*args, **kwargs) @@ -1497,47 +1559,47 @@ class TypeValueField(FieldURIField): """This field type has no value_cls because values may have many different types.""" TYPES_MAP = { - 'Boolean': bool, - 'Integer32': int, - 'UnsignedInteger32': int, - 'Integer64': int, - 'UnsignedInteger64': int, + "Boolean": bool, + "Integer32": int, + "UnsignedInteger32": int, + "Integer64": int, + "UnsignedInteger64": int, # Python doesn't have a single-byte type to represent 'Byte' - 'ByteArray': bytes, - 'String': str, - 'StringArray': str, # A list of strings - 'DateTime': EWSDateTime, + "ByteArray": bytes, + "String": str, + "StringArray": str, # A list of strings + "DateTime": EWSDateTime, } TYPES_MAP_REVERSED = { - bool: 'Boolean', - int: 'Integer64', + bool: "Boolean", + int: "Integer64", # Python doesn't have a single-byte type to represent 'Byte' - bytes: 'ByteArray', - str: 'String', - datetime.datetime: 'DateTime', - EWSDateTime: 'DateTime', + bytes: "ByteArray", + str: "String", + datetime.datetime: "DateTime", + EWSDateTime: "DateTime", } @classmethod def get_type(cls, value): if isinstance(value, bytes) and len(value) == 1: # This is a single byte. Translate it to the 'Byte' type - return 'Byte' + return "Byte" if is_iterable(value): # We don't allow generators as values, so keep the logic simple try: first = next(iter(value)) except StopIteration: first = None - value_type = f'{cls.TYPES_MAP_REVERSED[type(first)]}Array' + value_type = f"{cls.TYPES_MAP_REVERSED[type(first)]}Array" if value_type not in cls.TYPES_MAP: - raise ValueError(f'{value!r} is not a supported type') + raise ValueError(f"{value!r} is not a supported type") return value_type return cls.TYPES_MAP_REVERSED[type(value)] @classmethod def is_array_type(cls, value_type): - return value_type == 'StringArray' + return value_type == "StringArray" def clean(self, value, version=None): if value is None: @@ -1550,30 +1612,30 @@ def from_xml(self, elem, account): field_elem = elem.find(self.response_tag()) if field_elem is None: return self.default - value_type_str = get_xml_attr(field_elem, f'{{{TNS}}}Type') - value = get_xml_attr(field_elem, f'{{{TNS}}}Value') - if value_type_str == 'Byte': + value_type_str = get_xml_attr(field_elem, f"{{{TNS}}}Type") + value = get_xml_attr(field_elem, f"{{{TNS}}}Value") + if value_type_str == "Byte": try: # The value is an unsigned integer in the range 0 -> 255. Convert it to a single byte - return xml_text_to_value(value, int).to_bytes(1, 'little', signed=False) + return xml_text_to_value(value, int).to_bytes(1, "little", signed=False) except OverflowError as e: - log.warning('Invalid byte value %r (%e)', value, e) + log.warning("Invalid byte value %r (%e)", value, e) return None value_type = self.TYPES_MAP[value_type_str] - if self. is_array_type(value_type_str): - return tuple(xml_text_to_value(value=v, value_type=value_type) for v in value.split(' ')) + if self.is_array_type(value_type_str): + return tuple(xml_text_to_value(value=v, value_type=value_type) for v in value.split(" ")) return xml_text_to_value(value=value, value_type=value_type) def to_xml(self, value, version): value_type_str = self.get_type(value) - if value_type_str == 'Byte': + if value_type_str == "Byte": # A single byte is encoded to an unsigned integer in the range 0 -> 255 - value = int.from_bytes(value, byteorder='little', signed=False) + value = int.from_bytes(value, byteorder="little", signed=False) elif is_iterable(value): - value = ' '.join(value_to_xml_text(v) for v in value) + value = " ".join(value_to_xml_text(v) for v in value) field_elem = create_element(self.request_tag()) - field_elem.append(set_xml_value(create_element('t:Type'), value_type_str, version=version)) - field_elem.append(set_xml_value(create_element('t:Value'), value, version=version)) + field_elem.append(set_xml_value(create_element("t:Type"), value_type_str, version=version)) + field_elem.append(set_xml_value(create_element("t:Value"), value, version=version)) return field_elem @@ -1582,6 +1644,7 @@ class DictionaryField(FieldURIField): def from_xml(self, elem, account): from .properties import DictionaryEntry + iter_elem = elem.find(self.response_tag()) if iter_elem is not None: entries = [ @@ -1605,6 +1668,7 @@ def clean(self, value, version=None): def to_xml(self, value, version): from .properties import DictionaryEntry + field_elem = create_element(self.request_tag()) entries = [DictionaryEntry(key=k, value=v) for k, v in value.items()] return set_xml_value(field_elem, entries, version=version) @@ -1615,7 +1679,8 @@ class PersonaPhoneNumberField(EWSElementField): def __init__(self, *args, **kwargs): from .properties import PhoneNumber - kwargs['value_cls'] = PhoneNumber + + kwargs["value_cls"] = PhoneNumber super().__init__(*args, **kwargs) @@ -1624,35 +1689,40 @@ class BodyContentAttributedValueField(EWSElementField): def __init__(self, *args, **kwargs): from .properties import BodyContentAttributedValue - kwargs['value_cls'] = BodyContentAttributedValue + + kwargs["value_cls"] = BodyContentAttributedValue super().__init__(*args, **kwargs) class StringAttributedValueField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import StringAttributedValue - kwargs['value_cls'] = StringAttributedValue + + kwargs["value_cls"] = StringAttributedValue super().__init__(*args, **kwargs) class PhoneNumberAttributedValueField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import PhoneNumberAttributedValue - kwargs['value_cls'] = PhoneNumberAttributedValue + + kwargs["value_cls"] = PhoneNumberAttributedValue super().__init__(*args, **kwargs) class EmailAddressAttributedValueField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import EmailAddressAttributedValue - kwargs['value_cls'] = EmailAddressAttributedValue + + kwargs["value_cls"] = EmailAddressAttributedValue super().__init__(*args, **kwargs) class PostalAddressAttributedValueField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import PostalAddressAttributedValue - kwargs['value_cls'] = PostalAddressAttributedValue + + kwargs["value_cls"] = PostalAddressAttributedValue super().__init__(*args, **kwargs) @@ -1666,13 +1736,28 @@ def _event_types_map(self): return {v.response_tag(): v for v in self.value_classes} def __init__(self, *args, **kwargs): - from .properties import CopiedEvent, CreatedEvent, DeletedEvent, ModifiedEvent, MovedEvent, \ - NewMailEvent, StatusEvent, FreeBusyChangedEvent - kwargs['value_cls'] = None # Parent class requires this kwarg - kwargs['namespace'] = None # Parent class requires this kwarg + from .properties import ( + CopiedEvent, + CreatedEvent, + DeletedEvent, + FreeBusyChangedEvent, + ModifiedEvent, + MovedEvent, + NewMailEvent, + StatusEvent, + ) + + kwargs["value_cls"] = None # Parent class requires this kwarg + kwargs["namespace"] = None # Parent class requires this kwarg super().__init__(*args, **kwargs) self.value_classes = ( - CopiedEvent, CreatedEvent, DeletedEvent, ModifiedEvent, MovedEvent, NewMailEvent, StatusEvent, + CopiedEvent, + CreatedEvent, + DeletedEvent, + ModifiedEvent, + MovedEvent, + NewMailEvent, + StatusEvent, FreeBusyChangedEvent, ) diff --git a/exchangelib/folders/__init__.py b/exchangelib/folders/__init__.py index 6782111a..426f7e04 100644 --- a/exchangelib/folders/__init__.py +++ b/exchangelib/folders/__init__.py @@ -1,39 +1,181 @@ +from ..properties import DistinguishedFolderId, FolderId from .base import BaseFolder, Folder from .collections import FolderCollection -from .known_folders import AdminAuditLogs, AllContacts, AllItems, ArchiveDeletedItems, ArchiveInbox, \ - ArchiveMsgFolderRoot, ArchiveRecoverableItemsDeletions, ArchiveRecoverableItemsPurges, \ - ArchiveRecoverableItemsRoot, ArchiveRecoverableItemsVersions, Audits, Calendar, CalendarLogging, CommonViews, \ - Conflicts, Contacts, ConversationHistory, ConversationSettings, DefaultFoldersChangeHistory, DeferredAction, \ - DeletedItems, Directory, Drafts, ExchangeSyncData, Favorites, Files, FreebusyData, Friends, GALContacts, \ - GraphAnalytics, IMContactList, Inbox, Journal, JunkEmail, LocalFailures, Location, MailboxAssociations, Messages, \ - MsgFolderRoot, MyContacts, MyContactsExtended, NonDeletableFolderMixin, Notes, Outbox, ParkedMessages, \ - PassThroughSearchResults, PdpProfileV2Secured, PeopleConnect, QuickContacts, RSSFeeds, RecipientCache, \ - RecoverableItemsDeletions, RecoverableItemsPurges, RecoverableItemsRoot, RecoverableItemsVersions, Reminders, \ - Schedule, SearchFolders, SentItems, ServerFailures, Sharing, Shortcuts, Signal, SmsAndChatsSync, SpoolerQueue, \ - SyncIssues, System, Tasks, TemporarySaves, ToDoSearch, Views, VoiceMail, WellknownFolder, WorkingSet, \ - Companies, OrganizationalContacts, PeopleCentricConversationBuddies, NON_DELETABLE_FOLDERS -from .queryset import FolderQuerySet, SingleFolderQuerySet, FOLDER_TRAVERSAL_CHOICES, SHALLOW, DEEP, SOFT_DELETED -from .roots import Root, ArchiveRoot, PublicFoldersRoot, RootOfHierarchy -from ..properties import FolderId, DistinguishedFolderId +from .known_folders import ( + NON_DELETABLE_FOLDERS, + AdminAuditLogs, + AllContacts, + AllItems, + ArchiveDeletedItems, + ArchiveInbox, + ArchiveMsgFolderRoot, + ArchiveRecoverableItemsDeletions, + ArchiveRecoverableItemsPurges, + ArchiveRecoverableItemsRoot, + ArchiveRecoverableItemsVersions, + Audits, + Calendar, + CalendarLogging, + CommonViews, + Companies, + Conflicts, + Contacts, + ConversationHistory, + ConversationSettings, + DefaultFoldersChangeHistory, + DeferredAction, + DeletedItems, + Directory, + Drafts, + ExchangeSyncData, + Favorites, + Files, + FreebusyData, + Friends, + GALContacts, + GraphAnalytics, + IMContactList, + Inbox, + Journal, + JunkEmail, + LocalFailures, + Location, + MailboxAssociations, + Messages, + MsgFolderRoot, + MyContacts, + MyContactsExtended, + NonDeletableFolderMixin, + Notes, + OrganizationalContacts, + Outbox, + ParkedMessages, + PassThroughSearchResults, + PdpProfileV2Secured, + PeopleCentricConversationBuddies, + PeopleConnect, + QuickContacts, + RecipientCache, + RecoverableItemsDeletions, + RecoverableItemsPurges, + RecoverableItemsRoot, + RecoverableItemsVersions, + Reminders, + RSSFeeds, + Schedule, + SearchFolders, + SentItems, + ServerFailures, + Sharing, + Shortcuts, + Signal, + SmsAndChatsSync, + SpoolerQueue, + SyncIssues, + System, + Tasks, + TemporarySaves, + ToDoSearch, + Views, + VoiceMail, + WellknownFolder, + WorkingSet, +) +from .queryset import DEEP, FOLDER_TRAVERSAL_CHOICES, SHALLOW, SOFT_DELETED, FolderQuerySet, SingleFolderQuerySet +from .roots import ArchiveRoot, PublicFoldersRoot, Root, RootOfHierarchy __all__ = [ - 'FolderId', 'DistinguishedFolderId', - 'FolderCollection', - 'BaseFolder', 'Folder', - 'AdminAuditLogs', 'AllContacts', 'AllItems', 'ArchiveDeletedItems', 'ArchiveInbox', 'ArchiveMsgFolderRoot', - 'ArchiveRecoverableItemsDeletions', 'ArchiveRecoverableItemsPurges', 'ArchiveRecoverableItemsRoot', - 'ArchiveRecoverableItemsVersions', 'Audits', 'Calendar', 'CalendarLogging', 'CommonViews', 'Conflicts', - 'Contacts', 'ConversationHistory', 'ConversationSettings', 'DefaultFoldersChangeHistory', 'DeferredAction', - 'DeletedItems', 'Directory', 'Drafts', 'ExchangeSyncData', 'Favorites', 'Files', 'FreebusyData', 'Friends', - 'GALContacts', 'GraphAnalytics', 'IMContactList', 'Inbox', 'Journal', 'JunkEmail', 'LocalFailures', - 'Location', 'MailboxAssociations', 'Messages', 'MsgFolderRoot', 'MyContacts', 'MyContactsExtended', - 'NonDeletableFolderMixin', 'Notes', 'Outbox', 'ParkedMessages', 'PassThroughSearchResults', - 'PdpProfileV2Secured', 'PeopleConnect', 'QuickContacts', 'RSSFeeds', 'RecipientCache', - 'RecoverableItemsDeletions', 'RecoverableItemsPurges', 'RecoverableItemsRoot', 'RecoverableItemsVersions', - 'Reminders', 'Schedule', 'SearchFolders', 'SentItems', 'ServerFailures', 'Sharing', 'Shortcuts', 'Signal', - 'SmsAndChatsSync', 'SpoolerQueue', 'SyncIssues', 'System', 'Tasks', 'TemporarySaves', 'ToDoSearch', 'Views', - 'VoiceMail', 'WellknownFolder', 'WorkingSet', 'Companies', 'OrganizationalContacts', - 'PeopleCentricConversationBuddies', 'NON_DELETABLE_FOLDERS', - 'FolderQuerySet', 'SingleFolderQuerySet', 'FOLDER_TRAVERSAL_CHOICES', 'SHALLOW', 'DEEP', 'SOFT_DELETED', - 'Root', 'ArchiveRoot', 'PublicFoldersRoot', 'RootOfHierarchy', + "FolderId", + "DistinguishedFolderId", + "FolderCollection", + "BaseFolder", + "Folder", + "AdminAuditLogs", + "AllContacts", + "AllItems", + "ArchiveDeletedItems", + "ArchiveInbox", + "ArchiveMsgFolderRoot", + "ArchiveRecoverableItemsDeletions", + "ArchiveRecoverableItemsPurges", + "ArchiveRecoverableItemsRoot", + "ArchiveRecoverableItemsVersions", + "Audits", + "Calendar", + "CalendarLogging", + "CommonViews", + "Conflicts", + "Contacts", + "ConversationHistory", + "ConversationSettings", + "DefaultFoldersChangeHistory", + "DeferredAction", + "DeletedItems", + "Directory", + "Drafts", + "ExchangeSyncData", + "Favorites", + "Files", + "FreebusyData", + "Friends", + "GALContacts", + "GraphAnalytics", + "IMContactList", + "Inbox", + "Journal", + "JunkEmail", + "LocalFailures", + "Location", + "MailboxAssociations", + "Messages", + "MsgFolderRoot", + "MyContacts", + "MyContactsExtended", + "NonDeletableFolderMixin", + "Notes", + "Outbox", + "ParkedMessages", + "PassThroughSearchResults", + "PdpProfileV2Secured", + "PeopleConnect", + "QuickContacts", + "RSSFeeds", + "RecipientCache", + "RecoverableItemsDeletions", + "RecoverableItemsPurges", + "RecoverableItemsRoot", + "RecoverableItemsVersions", + "Reminders", + "Schedule", + "SearchFolders", + "SentItems", + "ServerFailures", + "Sharing", + "Shortcuts", + "Signal", + "SmsAndChatsSync", + "SpoolerQueue", + "SyncIssues", + "System", + "Tasks", + "TemporarySaves", + "ToDoSearch", + "Views", + "VoiceMail", + "WellknownFolder", + "WorkingSet", + "Companies", + "OrganizationalContacts", + "PeopleCentricConversationBuddies", + "NON_DELETABLE_FOLDERS", + "FolderQuerySet", + "SingleFolderQuerySet", + "FOLDER_TRAVERSAL_CHOICES", + "SHALLOW", + "DEEP", + "SOFT_DELETED", + "Root", + "ArchiveRoot", + "PublicFoldersRoot", + "RootOfHierarchy", ] diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 104f7059..1300bc78 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -3,18 +3,47 @@ from fnmatch import fnmatch from operator import attrgetter -from .collections import FolderCollection, SyncCompleted, PullSubscription, PushSubscription, StreamingSubscription -from .queryset import SingleFolderQuerySet, MISSING_FOLDER_ERRORS, SHALLOW as SHALLOW_FOLDERS, DEEP as DEEP_FOLDERS -from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorCannotEmptyFolder, ErrorCannotDeleteObject, \ - ErrorDeleteDistinguishedFolder, ErrorItemNotFound, InvalidTypeError -from ..fields import IntegerField, CharField, FieldPath, EffectiveRightsField, PermissionSetField, EWSElementField, \ - Field, IdElementField, InvalidField -from ..items import CalendarItem, RegisterMixIn, ITEM_CLASSES, HARD_DELETE, SHALLOW as SHALLOW_ITEMS -from ..properties import Mailbox, FolderId, ParentFolderId, DistinguishedFolderId, UserConfiguration, \ - UserConfigurationName, UserConfigurationNameMNS, EWSMeta -from ..queryset import SearchableMixIn, DoesNotExist -from ..util import TNS, require_id, is_iterable +from ..errors import ( + ErrorAccessDenied, + ErrorCannotDeleteObject, + ErrorCannotEmptyFolder, + ErrorDeleteDistinguishedFolder, + ErrorFolderNotFound, + ErrorItemNotFound, + InvalidTypeError, +) +from ..fields import ( + CharField, + EffectiveRightsField, + EWSElementField, + Field, + FieldPath, + IdElementField, + IntegerField, + InvalidField, + PermissionSetField, +) +from ..items import HARD_DELETE, ITEM_CLASSES +from ..items import SHALLOW as SHALLOW_ITEMS +from ..items import CalendarItem, RegisterMixIn +from ..properties import ( + DistinguishedFolderId, + EWSMeta, + FolderId, + Mailbox, + ParentFolderId, + UserConfiguration, + UserConfigurationName, + UserConfigurationNameMNS, +) +from ..queryset import DoesNotExist, SearchableMixIn +from ..util import TNS, is_iterable, require_id from ..version import EXCHANGE_2007_SP1, EXCHANGE_2010 +from .collections import FolderCollection, PullSubscription, PushSubscription, StreamingSubscription, SyncCompleted +from .queryset import DEEP as DEEP_FOLDERS +from .queryset import MISSING_FOLDER_ERRORS +from .queryset import SHALLOW as SHALLOW_FOLDERS +from .queryset import SingleFolderQuerySet log = logging.getLogger(__name__) @@ -22,7 +51,7 @@ class BaseFolder(RegisterMixIn, SearchableMixIn, metaclass=EWSMeta): """Base class for all classes that implement a folder.""" - ELEMENT_NAME = 'Folder' + ELEMENT_NAME = "Folder" NAMESPACE = TNS # See https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/distinguishedfolderid DISTINGUISHED_FOLDER_ID = None @@ -41,24 +70,23 @@ class BaseFolder(RegisterMixIn, SearchableMixIn, metaclass=EWSMeta): ITEM_MODEL_MAP = {cls.response_tag(): cls for cls in ITEM_CLASSES} ID_ELEMENT_CLS = FolderId - _id = IdElementField(field_uri='folder:FolderId', value_cls=ID_ELEMENT_CLS) - parent_folder_id = EWSElementField(field_uri='folder:ParentFolderId', value_cls=ParentFolderId, - is_read_only=True) - folder_class = CharField(field_uri='folder:FolderClass', is_required_after_save=True) - name = CharField(field_uri='folder:DisplayName') - total_count = IntegerField(field_uri='folder:TotalCount', is_read_only=True) - child_folder_count = IntegerField(field_uri='folder:ChildFolderCount', is_read_only=True) - unread_count = IntegerField(field_uri='folder:UnreadCount', is_read_only=True) + _id = IdElementField(field_uri="folder:FolderId", value_cls=ID_ELEMENT_CLS) + parent_folder_id = EWSElementField(field_uri="folder:ParentFolderId", value_cls=ParentFolderId, is_read_only=True) + folder_class = CharField(field_uri="folder:FolderClass", is_required_after_save=True) + name = CharField(field_uri="folder:DisplayName") + total_count = IntegerField(field_uri="folder:TotalCount", is_read_only=True) + child_folder_count = IntegerField(field_uri="folder:ChildFolderCount", is_read_only=True) + unread_count = IntegerField(field_uri="folder:UnreadCount", is_read_only=True) - __slots__ = 'is_distinguished', 'item_sync_state', 'folder_sync_state' + __slots__ = "is_distinguished", "item_sync_state", "folder_sync_state" # Used to register extended properties - INSERT_AFTER_FIELD = 'child_folder_count' + INSERT_AFTER_FIELD = "child_folder_count" def __init__(self, **kwargs): - self.is_distinguished = kwargs.pop('is_distinguished', False) - self.item_sync_state = kwargs.pop('item_sync_state', None) - self.folder_sync_state = kwargs.pop('folder_sync_state', None) + self.is_distinguished = kwargs.pop("is_distinguished", False) + self.item_sync_state = kwargs.pop("item_sync_state", None) + self.folder_sync_state = kwargs.pop("folder_sync_state", None) super().__init__(**kwargs) @property @@ -103,7 +131,7 @@ def parts(self): @property def absolute(self): - return ''.join(f'/{p.name}' for p in self.parts) + return "".join(f"/{p.name}" for p in self.parts) def _walk(self): for c in self.children: @@ -114,23 +142,23 @@ def walk(self): return FolderCollection(account=self.account, folders=self._walk()) def _glob(self, pattern): - split_pattern = pattern.rsplit('/', 1) + split_pattern = pattern.rsplit("/", 1) head, tail = (split_pattern[0], None) if len(split_pattern) == 1 else split_pattern - if head == '': + if head == "": # We got an absolute path. Restart globbing at root - yield from self.root.glob(tail or '*') - elif head == '..': + yield from self.root.glob(tail or "*") + elif head == "..": # Relative path with reference to parent. Restart globbing at parent if not self.parent: - raise ValueError('Already at top') - yield from self.parent.glob(tail or '*') - elif head == '**': + raise ValueError("Already at top") + yield from self.parent.glob(tail or "*") + elif head == "**": # Match anything here or in any subfolder at arbitrary depth for c in self.walk(): # fnmatch() may be case-sensitive depending on operating system: # force a case-insensitive match since case appears not to # matter for folders in Exchange - if fnmatch(c.name.lower(), (tail or '*').lower()): + if fnmatch(c.name.lower(), (tail or "*").lower()): yield c else: # Regular pattern @@ -157,22 +185,22 @@ def tree(self): ├── exchangelib issues └── Mom """ - tree = f'{self.name}\n' + tree = f"{self.name}\n" children = list(self.children) - for i, c in enumerate(sorted(children, key=attrgetter('name')), start=1): - nodes = c.tree().split('\n') + for i, c in enumerate(sorted(children, key=attrgetter("name")), start=1): + nodes = c.tree().split("\n") for j, node in enumerate(nodes, start=1): if i != len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += f'├── {node}\n' + tree += f"├── {node}\n" elif i != len(children) and j > 1: # Not the last child, and not name of child - tree += f'│ {node}\n' + tree += f"│ {node}\n" elif i == len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += f'└── {node}\n' + tree += f"└── {node}\n" else: # Last child, and not name of child - tree += f' {node}\n' + tree += f" {node}\n" return tree.strip() @classmethod @@ -200,11 +228,29 @@ def folder_cls_from_container_class(container_class): :param container_class: :return: """ - from .known_folders import Messages, Tasks, Calendar, ConversationSettings, Contacts, GALContacts, Reminders, \ - RecipientCache, RSSFeeds + from .known_folders import ( + Calendar, + Contacts, + ConversationSettings, + GALContacts, + Messages, + RecipientCache, + Reminders, + RSSFeeds, + Tasks, + ) + for folder_cls in ( - Messages, Tasks, Calendar, ConversationSettings, Contacts, GALContacts, Reminders, RecipientCache, - RSSFeeds): + Messages, + Tasks, + Calendar, + ConversationSettings, + Contacts, + GALContacts, + Reminders, + RecipientCache, + RSSFeeds, + ): if folder_cls.CONTAINER_CLASS == container_class: return folder_cls raise KeyError() @@ -214,7 +260,7 @@ def item_model_from_tag(cls, tag): try: return cls.ITEM_MODEL_MAP[tag] except KeyError: - raise ValueError(f'Item type {tag} was unexpected in a {cls.__name__} folder') + raise ValueError(f"Item type {tag} was unexpected in a {cls.__name__} folder") @classmethod def allowed_item_fields(cls, version): @@ -240,9 +286,9 @@ def normalize_fields(self, fields): elif isinstance(field_path, Field): field_path = FieldPath(field=field_path) fields[i] = field_path - if field_path.field.name == 'start': + if field_path.field.name == "start": has_start = True - elif field_path.field.name == 'end': + elif field_path.field.name == "end": has_end = True # For CalendarItem items, we want to inject internal timezone fields. See also CalendarItem.clean() @@ -291,6 +337,7 @@ def bulk_create(self, items, *args, **kwargs): def save(self, update_fields=None): from ..services import CreateFolder, UpdateFolder + if self.id is None: # New folder if update_fields: @@ -309,7 +356,7 @@ def save(self, update_fields=None): # These cannot be changed continue if (f.is_required or f.is_required_after_save) and ( - getattr(self, f.name) is None or (f.is_list and not getattr(self, f.name)) + getattr(self, f.name) is None or (f.is_list and not getattr(self, f.name)) ): # These are required and cannot be deleted continue @@ -317,7 +364,7 @@ def save(self, update_fields=None): res = UpdateFolder(account=self.account).get(folders=[(self, update_fields)]) folder_id, changekey = res.id, res.changekey if self.id != folder_id: - raise ValueError('ID mismatch') + raise ValueError("ID mismatch") # Don't check changekey value. It may not change on no-op updates self.changekey = changekey self.root.update_folder(self) # Update the folder in the cache @@ -325,10 +372,11 @@ def save(self, update_fields=None): def move(self, to_folder): from ..services import MoveFolder + res = MoveFolder(account=self.account).get(folders=[self], to_folder=to_folder) folder_id, changekey = res.id, res.changekey if self.id != folder_id: - raise ValueError('ID mismatch') + raise ValueError("ID mismatch") # Don't check changekey value. It may not change on no-op moves self.changekey = changekey self.parent_folder_id = ParentFolderId(id=to_folder.id, changekey=to_folder.changekey) @@ -336,12 +384,14 @@ def move(self, to_folder): def delete(self, delete_type=HARD_DELETE): from ..services import DeleteFolder + DeleteFolder(account=self.account).get(folders=[self], delete_type=delete_type) self.root.remove_folder(self) # Remove the updated folder from the cache self._id = None def empty(self, delete_type=HARD_DELETE, delete_sub_folders=False): from ..services import EmptyFolder + EmptyFolder(account=self.account).get( folders=[self], delete_type=delete_type, delete_sub_folders=delete_sub_folders ) @@ -354,11 +404,11 @@ def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0): # distinguished folders from being deleted. Use with caution! _seen = _seen or set() if self.id in _seen: - raise RecursionError(f'We already tried to wipe {self}') + raise RecursionError(f"We already tried to wipe {self}") if _level > 16: - raise RecursionError(f'Max recursion level reached: {_level}') + raise RecursionError(f"Max recursion level reached: {_level}") _seen.add(self.id) - log.warning('Wiping %s', self) + log.warning("Wiping %s", self) has_distinguished_subfolders = any(f.is_distinguished for f in self.children) try: if has_distinguished_subfolders: @@ -371,26 +421,26 @@ def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0): raise # We already tried this self.empty(delete_sub_folders=False) except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): - log.warning('Not allowed to empty %s. Trying to delete items instead', self) + log.warning("Not allowed to empty %s. Trying to delete items instead", self) kwargs = {} if page_size is not None: - kwargs['page_size'] = page_size + kwargs["page_size"] = page_size if chunk_size is not None: - kwargs['chunk_size'] = chunk_size + kwargs["chunk_size"] = chunk_size try: self.all().delete(**kwargs) except (ErrorAccessDenied, ErrorCannotDeleteObject, ErrorItemNotFound): - log.warning('Not allowed to delete items in %s', self) + log.warning("Not allowed to delete items in %s", self) _level += 1 for f in self.children: f.wipe(page_size=page_size, chunk_size=chunk_size, _seen=_seen, _level=_level) # Remove non-distinguished children that are empty and have no subfolders if f.is_deletable and not f.children: - log.warning('Deleting folder %s', f) + log.warning("Deleting folder %s", f) try: f.delete() except ErrorDeleteDistinguishedFolder: - log.warning('Tried to delete a distinguished folder (%s)', f) + log.warning("Tried to delete a distinguished folder (%s)", f) def test_access(self): """Does a simple FindItem to test (read) access to the folder. Maybe the account doesn't exist, maybe the @@ -402,12 +452,12 @@ def test_access(self): @classmethod def _kwargs_from_elem(cls, elem, account): # Check for 'DisplayName' element before collecting kwargs because because that clears the elements - has_name_elem = elem.find(cls.get_field_by_fieldname('name').response_tag()) is not None + has_name_elem = elem.find(cls.get_field_by_fieldname("name").response_tag()) is not None kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS} - if has_name_elem and not kwargs['name']: + if has_name_elem and not kwargs["name"]: # When we request the 'DisplayName' property, some folders may still be returned with an empty value. # Assign a default name to these folders. - kwargs['name'] = cls.DISTINGUISHED_FOLDER_ID + kwargs["name"] = cls.DISTINGUISHED_FOLDER_ID return kwargs def to_id(self): @@ -416,22 +466,21 @@ def to_id(self): # the folder content since we fetched the changekey. if self.account: return DistinguishedFolderId( - id=self.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=self.account.primary_smtp_address) + id=self.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address) ) return DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID) if self.id: return FolderId(id=self.id, changekey=self.changekey) - raise ValueError('Must be a distinguished folder or have an ID') + raise ValueError("Must be a distinguished folder or have an ID") @classmethod def resolve(cls, account, folder): # Resolve a single folder folders = list(FolderCollection(account=account, folders=[folder]).resolve()) if not folders: - raise ErrorFolderNotFound(f'Could not find folder {folder!r}') + raise ErrorFolderNotFound(f"Could not find folder {folder!r}") if len(folders) != 1: - raise ValueError(f'Expected result length 1, but got {folders}') + raise ValueError(f"Expected result length 1, but got {folders}") f = folders[0] if isinstance(f, Exception): raise f @@ -443,7 +492,7 @@ def resolve(cls, account, folder): def refresh(self): fresh_folder = self.resolve(account=self.account, folder=self) if self.id != fresh_folder.id: - raise ValueError('ID mismatch') + raise ValueError("ID mismatch") # Apparently, the changekey may get updated for f in self.FIELDS: setattr(self, f.name, getattr(fresh_folder, f.name)) @@ -453,6 +502,7 @@ def refresh(self): def get_user_configuration(self, name, properties=None): from ..services import GetUserConfiguration from ..services.get_user_configuration import ALL + if properties is None: properties = ALL return GetUserConfiguration(account=self.account).get( @@ -463,6 +513,7 @@ def get_user_configuration(self, name, properties=None): @require_id def create_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None): from ..services import CreateUserConfiguration + user_configuration = UserConfiguration( user_configuration_name=UserConfigurationName(name=name, folder=self), dictionary=dictionary, @@ -474,6 +525,7 @@ def create_user_configuration(self, name, dictionary=None, xml_data=None, binary @require_id def update_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None): from ..services import UpdateUserConfiguration + user_configuration = UserConfiguration( user_configuration_name=UserConfigurationName(name=name, folder=self), dictionary=dictionary, @@ -485,6 +537,7 @@ def update_user_configuration(self, name, dictionary=None, xml_data=None, binary @require_id def delete_user_configuration(self, name): from ..services import DeleteUserConfiguration + return DeleteUserConfiguration(account=self.account).get( user_configuration_name=UserConfigurationNameMNS(name=name, folder=self) ) @@ -500,10 +553,13 @@ def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): :return: The subscription ID and a watermark """ from ..services import SubscribeToPull + if event_types is None: event_types = SubscribeToPull.EVENT_TYPES return FolderCollection(account=self.account, folders=[self]).subscribe_to_pull( - event_types=event_types, watermark=watermark, timeout=timeout, + event_types=event_types, + watermark=watermark, + timeout=timeout, ) @require_id @@ -517,10 +573,14 @@ def subscribe_to_push(self, callback_url, event_types=None, watermark=None, stat :return: The subscription ID and a watermark """ from ..services import SubscribeToPush + if event_types is None: event_types = SubscribeToPush.EVENT_TYPES return FolderCollection(account=self.account, folders=[self]).subscribe_to_push( - event_types=event_types, watermark=watermark, status_frequency=status_frequency, callback_url=callback_url, + event_types=event_types, + watermark=watermark, + status_frequency=status_frequency, + callback_url=callback_url, ) @require_id @@ -531,6 +591,7 @@ def subscribe_to_streaming(self, event_types=None): :return: The subscription ID """ from ..services import SubscribeToStreaming + if event_types is None: event_types = SubscribeToStreaming.EVENT_TYPES return FolderCollection(account=self.account, folders=[self]).subscribe_to_streaming(event_types=event_types) @@ -557,6 +618,7 @@ def unsubscribe(self, subscription_id): sync methods. """ from ..services import Unsubscribe + return Unsubscribe(account=self.account).get(subscription_id=subscription_id) def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None): @@ -616,6 +678,7 @@ def get_events(self, subscription_id, watermark): sync methods. """ from ..services import GetEvents + svc = GetEvents(account=self.account) while True: notification = svc.get(subscription_id=subscription_id, watermark=watermark) @@ -637,12 +700,15 @@ def get_streaming_events(self, subscription_id_or_ids, connection_timeout=1, max sync methods. """ from ..services import GetStreamingEvents + svc = GetStreamingEvents(account=self.account) - subscription_ids = subscription_id_or_ids if is_iterable(subscription_id_or_ids, generators_allowed=True) \ + subscription_ids = ( + subscription_id_or_ids + if is_iterable(subscription_id_or_ids, generators_allowed=True) else [subscription_id_or_ids] + ) for i, notification in enumerate( - svc.call(subscription_ids=subscription_ids, connection_timeout=connection_timeout), - start=1 + svc.call(subscription_ids=subscription_ids, connection_timeout=connection_timeout), start=1 ): yield notification if max_notifications_returned and i >= max_notifications_returned: @@ -659,10 +725,10 @@ def __floordiv__(self, other): :param other: :return: """ - if other == '..': - raise ValueError('Cannot get parent without a folder cache') + if other == "..": + raise ValueError("Cannot get parent without a folder cache") - if other == '.': + if other == ".": return self # Assume an exact match on the folder name in a shallow search will only return at most one folder @@ -673,11 +739,11 @@ def __floordiv__(self, other): def __truediv__(self, other): """Support the some_folder / 'child_folder' / 'child_of_child_folder' navigation syntax.""" - if other == '..': + if other == "..": if not self.parent: - raise ValueError('Already at top') + raise ValueError("Already at top") return self.parent - if other == '.': + if other == ".": return self for c in self.children: if c.name == other: @@ -685,35 +751,45 @@ def __truediv__(self, other): raise ErrorFolderNotFound(f"No subfolder with name {other!r}") def __repr__(self): - return self.__class__.__name__ + \ - repr((self.root, self.name, self.total_count, self.unread_count, self.child_folder_count, - self.folder_class, self.id, self.changekey)) + return self.__class__.__name__ + repr( + ( + self.root, + self.name, + self.total_count, + self.unread_count, + self.child_folder_count, + self.folder_class, + self.id, + self.changekey, + ) + ) def __str__(self): - return f'{self.__class__.__name__} ({self.name})' + return f"{self.__class__.__name__} ({self.name})" class Folder(BaseFolder): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/folder""" - permission_set = PermissionSetField(field_uri='folder:PermissionSet', supported_from=EXCHANGE_2007_SP1) - effective_rights = EffectiveRightsField(field_uri='folder:EffectiveRights', is_read_only=True, - supported_from=EXCHANGE_2007_SP1) + permission_set = PermissionSetField(field_uri="folder:PermissionSet", supported_from=EXCHANGE_2007_SP1) + effective_rights = EffectiveRightsField( + field_uri="folder:EffectiveRights", is_read_only=True, supported_from=EXCHANGE_2007_SP1 + ) - __slots__ = '_root', + __slots__ = ("_root",) def __init__(self, **kwargs): - self._root = kwargs.pop('root', None) # This is a pointer to the root of the folder hierarchy - parent = kwargs.pop('parent', None) + self._root = kwargs.pop("root", None) # This is a pointer to the root of the folder hierarchy + parent = kwargs.pop("parent", None) if parent: if self.root: if parent.root != self.root: raise ValueError("'parent.root' must match 'root'") else: self.root = parent.root - if 'parent_folder_id' in kwargs and parent.id != kwargs['parent_folder_id']: + if "parent_folder_id" in kwargs and parent.id != kwargs["parent_folder_id"]: raise ValueError("'parent_folder_id' must match 'parent' ID") - kwargs['parent_folder_id'] = ParentFolderId(id=parent.id, changekey=parent.changekey) + kwargs["parent_folder_id"] = ParentFolderId(id=parent.id, changekey=parent.changekey) super().__init__(**kwargs) @property @@ -733,13 +809,13 @@ def root(self, value): @classmethod def register(cls, *args, **kwargs): if cls is not Folder: - raise TypeError('For folders, custom fields must be registered on the Folder class') + raise TypeError("For folders, custom fields must be registered on the Folder class") return super().register(*args, **kwargs) @classmethod def deregister(cls, *args, **kwargs): if cls is not Folder: - raise TypeError('For folders, custom fields must be registered on the Folder class') + raise TypeError("For folders, custom fields must be registered on the Folder class") return super().deregister(*args, **kwargs) @classmethod @@ -751,11 +827,10 @@ def get_distinguished(cls, root): """ try: return cls.resolve( - account=root.account, - folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + account=root.account, folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}') + raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") @property def parent(self): @@ -772,15 +847,16 @@ def parent(self, value): self.parent_folder_id = None else: if not isinstance(value, BaseFolder): - raise InvalidTypeError('value', value, BaseFolder) + raise InvalidTypeError("value", value, BaseFolder) self.root = value.root self.parent_folder_id = ParentFolderId(id=value.id, changekey=value.changekey) def clean(self, version=None): from .roots import RootOfHierarchy + super().clean(version=version) if self.root and not isinstance(self.root, RootOfHierarchy): - raise InvalidTypeError('root', self.root, RootOfHierarchy) + raise InvalidTypeError("root", self.root, RootOfHierarchy) @classmethod def from_xml_with_root(cls, elem, root): @@ -806,18 +882,18 @@ def from_xml_with_root(cls, elem, root): if folder.name: try: # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative - folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, - locale=root.account.locale) - log.debug('Folder class %s matches localized folder name %s', folder_cls, folder.name) + folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) + log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) except KeyError: pass if folder.folder_class and folder_cls == Folder: try: folder_cls = cls.folder_cls_from_container_class(container_class=folder.folder_class) - log.debug('Folder class %s matches container class %s (%s)', folder_cls, folder.folder_class, - folder.name) + log.debug( + "Folder class %s matches container class %s (%s)", folder_cls, folder.folder_class, folder.name + ) except KeyError: pass if folder_cls == Folder: - log.debug('Fallback to class Folder (folder_class %s, name %s)', folder.folder_class, folder.name) + log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS}) diff --git a/exchangelib/folders/collections.py b/exchangelib/folders/collections.py index 2f4bd9f8..905bbc3a 100644 --- a/exchangelib/folders/collections.py +++ b/exchangelib/folders/collections.py @@ -5,9 +5,9 @@ from ..errors import InvalidTypeError from ..fields import FieldPath, InvalidField -from ..items import Persona, ID_ONLY +from ..items import ID_ONLY, Persona from ..properties import CalendarView -from ..queryset import QuerySet, SearchableMixIn, Q +from ..queryset import Q, QuerySet, SearchableMixIn from ..restriction import Restriction from ..util import require_account @@ -26,7 +26,7 @@ class FolderCollection(SearchableMixIn): """A class that implements an API for searching folders.""" # These fields are required in a FindFolder or GetFolder call to properly identify folder types - REQUIRED_FOLDER_FIELDS = ('name', 'folder_class') + REQUIRED_FOLDER_FIELDS = ("name", "folder_class") def __init__(self, account, folders): """Implement a search API on a collection of folders. @@ -157,8 +157,18 @@ def _rinse_args(self, q, depth, additional_fields, field_validator): query_string = None return depth, restriction, query_string - def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order_fields=None, - calendar_view=None, page_size=None, max_items=None, offset=0): + def find_items( + self, + q, + shape=ID_ONLY, + depth=None, + additional_fields=None, + order_fields=None, + calendar_view=None, + page_size=None, + max_items=None, + offset=0, + ): """Private method to call the FindItem service. :param q: a Q instance containing any restrictions @@ -176,21 +186,21 @@ def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order :return: a generator for the returned item IDs or items """ from ..services import FindItem + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return if q.is_never(): - log.debug('Query will never return results') + log.debug("Query will never return results") return depth, restriction, query_string = self._rinse_args( - q=q, depth=depth, additional_fields=additional_fields, - field_validator=self.validate_item_field + q=q, depth=depth, additional_fields=additional_fields, field_validator=self.validate_item_field ) if calendar_view is not None and not isinstance(calendar_view, CalendarView): - raise InvalidTypeError('calendar_view', calendar_view, CalendarView) + raise InvalidTypeError("calendar_view", calendar_view, CalendarView) log.debug( - 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', + "Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)", self.account, self.folders, shape, @@ -213,14 +223,23 @@ def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order def _get_single_folder(self): if len(self.folders) > 1: - raise ValueError('Syncing folder hierarchy can only be done on a single folder') + raise ValueError("Syncing folder hierarchy can only be done on a single folder") if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return None return self.folders[0] - def find_people(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order_fields=None, - page_size=None, max_items=None, offset=0): + def find_people( + self, + q, + shape=ID_ONLY, + depth=None, + additional_fields=None, + order_fields=None, + page_size=None, + max_items=None, + offset=0, + ): """Private method to call the FindPeople service. :param q: a Q instance containing any restrictions @@ -236,30 +255,31 @@ def find_people(self, q, shape=ID_ONLY, depth=None, additional_fields=None, orde :return: a generator for the returned personas """ from ..services import FindPeople + folder = self._get_single_folder() if q.is_never(): - log.debug('Query will never return results') + log.debug("Query will never return results") return depth, restriction, query_string = self._rinse_args( - q=q, depth=depth, additional_fields=additional_fields, - field_validator=Persona.validate_field + q=q, depth=depth, additional_fields=additional_fields, field_validator=Persona.validate_field ) yield from FindPeople(account=self.account, page_size=page_size).call( - folder=folder, - additional_fields=additional_fields, - restriction=restriction, - order_fields=order_fields, - shape=shape, - query_string=query_string, - depth=depth, - max_items=max_items, - offset=offset, + folder=folder, + additional_fields=additional_fields, + restriction=restriction, + order_fields=order_fields, + shape=shape, + query_string=query_string, + depth=depth, + max_items=max_items, + offset=offset, ) def get_folder_fields(self, target_cls, is_complex=None): return { - FieldPath(field=f) for f in target_cls.supported_fields(version=self.account.version) + FieldPath(field=f) + for f in target_cls.supported_fields(version=self.account.version) if is_complex is None or f.is_complex is is_complex } @@ -268,16 +288,17 @@ def _get_target_cls(self): # both folder types in self.folders, raise an error so we don't risk losing some fields in the query. from .base import Folder from .roots import RootOfHierarchy + has_roots = False has_non_roots = False for f in self.folders: if isinstance(f, RootOfHierarchy): if has_non_roots: - raise ValueError(f'Cannot call GetFolder on a mix of folder types: {self.folders}') + raise ValueError(f"Cannot call GetFolder on a mix of folder types: {self.folders}") has_roots = True else: if has_roots: - raise ValueError(f'Cannot call GetFolder on a mix of folder types: {self.folders}') + raise ValueError(f"Cannot call GetFolder on a mix of folder types: {self.folders}") has_non_roots = True return RootOfHierarchy if has_roots else Folder @@ -286,47 +307,51 @@ def _get_default_traversal_depth(self, traversal_attr): if len(unique_depths) == 1: return unique_depths.pop() raise ValueError( - f'Folders in this collection do not have a common {traversal_attr} value. You need to define an explicit ' - f'traversal depth with QuerySet.depth() (values: {unique_depths})' + f"Folders in this collection do not have a common {traversal_attr} value. You need to define an explicit " + f"traversal depth with QuerySet.depth() (values: {unique_depths})" ) def _get_default_item_traversal_depth(self): # When searching folders, some folders require 'Shallow' and others 'Associated' traversal depth. - return self._get_default_traversal_depth('DEFAULT_ITEM_TRAVERSAL_DEPTH') + return self._get_default_traversal_depth("DEFAULT_ITEM_TRAVERSAL_DEPTH") def _get_default_folder_traversal_depth(self): # When searching folders, some folders require 'Shallow' and others 'Deep' traversal depth. - return self._get_default_traversal_depth('DEFAULT_FOLDER_TRAVERSAL_DEPTH') + return self._get_default_traversal_depth("DEFAULT_FOLDER_TRAVERSAL_DEPTH") def resolve(self): # Looks up the folders or folder IDs in the collection and returns full Folder instances with all fields set. from .base import BaseFolder + resolveable_folders = [] for f in self.folders: if isinstance(f, BaseFolder) and not f.get_folder_allowed: - log.debug('GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder', f) + log.debug("GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder", f) yield f else: resolveable_folders.append(f) # Fetch all properties for the remaining folders of folder IDs additional_fields = self.get_folder_fields(target_cls=self._get_target_cls(), is_complex=None) yield from self.__class__(account=self.account, folders=resolveable_folders).get_folders( - additional_fields=additional_fields + additional_fields=additional_fields ) @require_account - def find_folders(self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None, - offset=0): + def find_folders( + self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None, offset=0 + ): from ..services import FindFolder + # 'depth' controls whether to return direct children or recurse into sub-folders from .base import BaseFolder, Folder + if q is None: q = Q() if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return if q.is_never(): - log.debug('Query will never return results') + log.debug("Query will never return results") return if q.is_empty(): restriction = None @@ -348,21 +373,23 @@ def find_folders(self, q=None, shape=ID_ONLY, depth=None, additional_fields=None ) yield from FindFolder(account=self.account, page_size=page_size).call( - folders=self.folders, - additional_fields=additional_fields, - restriction=restriction, - shape=shape, - depth=depth, - max_items=max_items, - offset=offset, + folders=self.folders, + additional_fields=additional_fields, + restriction=restriction, + shape=shape, + depth=depth, + max_items=max_items, + offset=offset, ) def get_folders(self, additional_fields=None): from ..services import GetFolder + # Expand folders with their full set of properties from .base import BaseFolder + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return if additional_fields is None: # Default to all complex properties @@ -374,38 +401,47 @@ def get_folders(self, additional_fields=None): ) yield from GetFolder(account=self.account).call( - folders=self.folders, - additional_fields=additional_fields, - shape=ID_ONLY, + folders=self.folders, + additional_fields=additional_fields, + shape=ID_ONLY, ) def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): from ..services import SubscribeToPull + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return None if event_types is None: event_types = SubscribeToPull.EVENT_TYPES return SubscribeToPull(account=self.account).get( - folders=self.folders, event_types=event_types, watermark=watermark, timeout=timeout, + folders=self.folders, + event_types=event_types, + watermark=watermark, + timeout=timeout, ) def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1): from ..services import SubscribeToPush + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return None if event_types is None: event_types = SubscribeToPush.EVENT_TYPES return SubscribeToPush(account=self.account).get( - folders=self.folders, event_types=event_types, watermark=watermark, status_frequency=status_frequency, + folders=self.folders, + event_types=event_types, + watermark=watermark, + status_frequency=status_frequency, url=callback_url, ) def subscribe_to_streaming(self, event_types=None): from ..services import SubscribeToStreaming + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return None if event_types is None: event_types = SubscribeToStreaming.EVENT_TYPES @@ -430,10 +466,12 @@ def unsubscribe(self, subscription_id): sync methods. """ from ..services import Unsubscribe + return Unsubscribe(account=self.account).get(subscription_id=subscription_id) def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None): from ..services import SyncFolderItems + folder = self._get_single_folder() if only_fields is None: # We didn't restrict list of field paths. Get all fields from the server, including extended properties. @@ -465,6 +503,7 @@ def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes def sync_hierarchy(self, sync_state=None, only_fields=None): from ..services import SyncFolderHierarchy + folder = self._get_single_folder() if only_fields is None: # We didn't restrict list of field paths. Get all fields from the server, including extended properties. diff --git a/exchangelib/folders/known_folders.py b/exchangelib/folders/known_folders.py index f383727e..51222bf1 100644 --- a/exchangelib/folders/known_folders.py +++ b/exchangelib/folders/known_folders.py @@ -1,28 +1,38 @@ -from .base import Folder -from .collections import FolderCollection -from ..items import CalendarItem, Contact, Message, Task, DistributionList, MeetingRequest, MeetingResponse, \ - MeetingCancellation, ITEM_CLASSES, ASSOCIATED +from ..items import ( + ASSOCIATED, + ITEM_CLASSES, + CalendarItem, + Contact, + DistributionList, + MeetingCancellation, + MeetingRequest, + MeetingResponse, + Message, + Task, +) from ..properties import EWSMeta from ..version import EXCHANGE_2010_SP1, EXCHANGE_2013, EXCHANGE_2013_SP1 +from .base import Folder +from .collections import FolderCollection class Calendar(Folder): """An interface for the Exchange calendar.""" - DISTINGUISHED_FOLDER_ID = 'calendar' - CONTAINER_CLASS = 'IPF.Appointment' + DISTINGUISHED_FOLDER_ID = "calendar" + CONTAINER_CLASS = "IPF.Appointment" supported_item_models = (CalendarItem,) LOCALIZED_NAMES = { - 'da_DK': ('Kalender',), - 'de_DE': ('Kalender',), - 'en_US': ('Calendar',), - 'es_ES': ('Calendario',), - 'fr_CA': ('Calendrier',), - 'nl_NL': ('Agenda',), - 'ru_RU': ('Календарь',), - 'sv_SE': ('Kalender',), - 'zh_CN': ('日历',), + "da_DK": ("Kalender",), + "de_DE": ("Kalender",), + "en_US": ("Calendar",), + "es_ES": ("Calendario",), + "fr_CA": ("Calendrier",), + "nl_NL": ("Agenda",), + "ru_RU": ("Календарь",), + "sv_SE": ("Kalender",), + "zh_CN": ("日历",), } def view(self, *args, **kwargs): @@ -30,141 +40,141 @@ def view(self, *args, **kwargs): class DeletedItems(Folder): - DISTINGUISHED_FOLDER_ID = 'deleteditems' - CONTAINER_CLASS = 'IPF.Note' + DISTINGUISHED_FOLDER_ID = "deleteditems" + CONTAINER_CLASS = "IPF.Note" supported_item_models = ITEM_CLASSES LOCALIZED_NAMES = { - 'da_DK': ('Slettet post',), - 'de_DE': ('Gelöschte Elemente',), - 'en_US': ('Deleted Items',), - 'es_ES': ('Elementos eliminados',), - 'fr_CA': ('Éléments supprimés',), - 'nl_NL': ('Verwijderde items',), - 'ru_RU': ('Удаленные',), - 'sv_SE': ('Borttaget',), - 'zh_CN': ('已删除邮件',), + "da_DK": ("Slettet post",), + "de_DE": ("Gelöschte Elemente",), + "en_US": ("Deleted Items",), + "es_ES": ("Elementos eliminados",), + "fr_CA": ("Éléments supprimés",), + "nl_NL": ("Verwijderde items",), + "ru_RU": ("Удаленные",), + "sv_SE": ("Borttaget",), + "zh_CN": ("已删除邮件",), } class Messages(Folder): - CONTAINER_CLASS = 'IPF.Note' + CONTAINER_CLASS = "IPF.Note" supported_item_models = (Message, MeetingRequest, MeetingResponse, MeetingCancellation) class Drafts(Messages): - DISTINGUISHED_FOLDER_ID = 'drafts' + DISTINGUISHED_FOLDER_ID = "drafts" LOCALIZED_NAMES = { - 'da_DK': ('Kladder',), - 'de_DE': ('Entwürfe',), - 'en_US': ('Drafts',), - 'es_ES': ('Borradores',), - 'fr_CA': ('Brouillons',), - 'nl_NL': ('Concepten',), - 'ru_RU': ('Черновики',), - 'sv_SE': ('Utkast',), - 'zh_CN': ('草稿',), + "da_DK": ("Kladder",), + "de_DE": ("Entwürfe",), + "en_US": ("Drafts",), + "es_ES": ("Borradores",), + "fr_CA": ("Brouillons",), + "nl_NL": ("Concepten",), + "ru_RU": ("Черновики",), + "sv_SE": ("Utkast",), + "zh_CN": ("草稿",), } class Inbox(Messages): - DISTINGUISHED_FOLDER_ID = 'inbox' + DISTINGUISHED_FOLDER_ID = "inbox" LOCALIZED_NAMES = { - 'da_DK': ('Indbakke',), - 'de_DE': ('Posteingang',), - 'en_US': ('Inbox',), - 'es_ES': ('Bandeja de entrada',), - 'fr_CA': ('Boîte de réception',), - 'nl_NL': ('Postvak IN',), - 'ru_RU': ('Входящие',), - 'sv_SE': ('Inkorgen',), - 'zh_CN': ('收件箱',), + "da_DK": ("Indbakke",), + "de_DE": ("Posteingang",), + "en_US": ("Inbox",), + "es_ES": ("Bandeja de entrada",), + "fr_CA": ("Boîte de réception",), + "nl_NL": ("Postvak IN",), + "ru_RU": ("Входящие",), + "sv_SE": ("Inkorgen",), + "zh_CN": ("收件箱",), } class Outbox(Messages): - DISTINGUISHED_FOLDER_ID = 'outbox' + DISTINGUISHED_FOLDER_ID = "outbox" LOCALIZED_NAMES = { - 'da_DK': ('Udbakke',), - 'de_DE': ('Postausgang',), - 'en_US': ('Outbox',), - 'es_ES': ('Bandeja de salida',), - 'fr_CA': (u"Boîte d'envoi",), - 'nl_NL': ('Postvak UIT',), - 'ru_RU': ('Исходящие',), - 'sv_SE': ('Utkorgen',), - 'zh_CN': ('发件箱',), + "da_DK": ("Udbakke",), + "de_DE": ("Postausgang",), + "en_US": ("Outbox",), + "es_ES": ("Bandeja de salida",), + "fr_CA": (u"Boîte d'envoi",), + "nl_NL": ("Postvak UIT",), + "ru_RU": ("Исходящие",), + "sv_SE": ("Utkorgen",), + "zh_CN": ("发件箱",), } class SentItems(Messages): - DISTINGUISHED_FOLDER_ID = 'sentitems' + DISTINGUISHED_FOLDER_ID = "sentitems" LOCALIZED_NAMES = { - 'da_DK': ('Sendt post',), - 'de_DE': ('Gesendete Elemente',), - 'en_US': ('Sent Items',), - 'es_ES': ('Elementos enviados',), - 'fr_CA': ('Éléments envoyés',), - 'nl_NL': ('Verzonden items',), - 'ru_RU': ('Отправленные',), - 'sv_SE': ('Skickat',), - 'zh_CN': ('已发送邮件',), + "da_DK": ("Sendt post",), + "de_DE": ("Gesendete Elemente",), + "en_US": ("Sent Items",), + "es_ES": ("Elementos enviados",), + "fr_CA": ("Éléments envoyés",), + "nl_NL": ("Verzonden items",), + "ru_RU": ("Отправленные",), + "sv_SE": ("Skickat",), + "zh_CN": ("已发送邮件",), } class JunkEmail(Messages): - DISTINGUISHED_FOLDER_ID = 'junkemail' + DISTINGUISHED_FOLDER_ID = "junkemail" LOCALIZED_NAMES = { - 'da_DK': ('Uønsket e-mail',), - 'de_DE': ('Junk-E-Mail',), - 'en_US': ('Junk E-mail',), - 'es_ES': ('Correo no deseado',), - 'fr_CA': ('Courrier indésirables',), - 'nl_NL': ('Ongewenste e-mail',), - 'ru_RU': ('Нежелательная почта',), - 'sv_SE': ('Skräppost',), - 'zh_CN': ('垃圾邮件',), + "da_DK": ("Uønsket e-mail",), + "de_DE": ("Junk-E-Mail",), + "en_US": ("Junk E-mail",), + "es_ES": ("Correo no deseado",), + "fr_CA": ("Courrier indésirables",), + "nl_NL": ("Ongewenste e-mail",), + "ru_RU": ("Нежелательная почта",), + "sv_SE": ("Skräppost",), + "zh_CN": ("垃圾邮件",), } class Tasks(Folder): - DISTINGUISHED_FOLDER_ID = 'tasks' - CONTAINER_CLASS = 'IPF.Task' + DISTINGUISHED_FOLDER_ID = "tasks" + CONTAINER_CLASS = "IPF.Task" supported_item_models = (Task,) LOCALIZED_NAMES = { - 'da_DK': ('Opgaver',), - 'de_DE': ('Aufgaben',), - 'en_US': ('Tasks',), - 'es_ES': ('Tareas',), - 'fr_CA': ('Tâches',), - 'nl_NL': ('Taken',), - 'ru_RU': ('Задачи',), - 'sv_SE': ('Uppgifter',), - 'zh_CN': ('任务',), + "da_DK": ("Opgaver",), + "de_DE": ("Aufgaben",), + "en_US": ("Tasks",), + "es_ES": ("Tareas",), + "fr_CA": ("Tâches",), + "nl_NL": ("Taken",), + "ru_RU": ("Задачи",), + "sv_SE": ("Uppgifter",), + "zh_CN": ("任务",), } class Contacts(Folder): - DISTINGUISHED_FOLDER_ID = 'contacts' - CONTAINER_CLASS = 'IPF.Contact' + DISTINGUISHED_FOLDER_ID = "contacts" + CONTAINER_CLASS = "IPF.Contact" supported_item_models = (Contact, DistributionList) LOCALIZED_NAMES = { - 'da_DK': ('Kontaktpersoner',), - 'de_DE': ('Kontakte',), - 'en_US': ('Contacts',), - 'es_ES': ('Contactos',), - 'fr_CA': ('Contacts',), - 'nl_NL': ('Contactpersonen',), - 'ru_RU': ('Контакты',), - 'sv_SE': ('Kontakter',), - 'zh_CN': ('联系人',), + "da_DK": ("Kontaktpersoner",), + "de_DE": ("Kontakte",), + "en_US": ("Contacts",), + "es_ES": ("Contactos",), + "fr_CA": ("Contacts",), + "nl_NL": ("Contactpersonen",), + "ru_RU": ("Контакты",), + "sv_SE": ("Kontakter",), + "zh_CN": ("联系人",), } @@ -175,175 +185,175 @@ class WellknownFolder(Folder, metaclass=EWSMeta): class AdminAuditLogs(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'adminauditlogs' + DISTINGUISHED_FOLDER_ID = "adminauditlogs" supported_from = EXCHANGE_2013 get_folder_allowed = False class ArchiveDeletedItems(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'archivedeleteditems' + DISTINGUISHED_FOLDER_ID = "archivedeleteditems" supported_from = EXCHANGE_2010_SP1 class ArchiveInbox(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'archiveinbox' + DISTINGUISHED_FOLDER_ID = "archiveinbox" supported_from = EXCHANGE_2013_SP1 class ArchiveMsgFolderRoot(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'archivemsgfolderroot' + DISTINGUISHED_FOLDER_ID = "archivemsgfolderroot" supported_from = EXCHANGE_2010_SP1 class ArchiveRecoverableItemsDeletions(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'archiverecoverableitemsdeletions' + DISTINGUISHED_FOLDER_ID = "archiverecoverableitemsdeletions" supported_from = EXCHANGE_2010_SP1 class ArchiveRecoverableItemsPurges(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'archiverecoverableitemspurges' + DISTINGUISHED_FOLDER_ID = "archiverecoverableitemspurges" supported_from = EXCHANGE_2010_SP1 class ArchiveRecoverableItemsRoot(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'archiverecoverableitemsroot' + DISTINGUISHED_FOLDER_ID = "archiverecoverableitemsroot" supported_from = EXCHANGE_2010_SP1 class ArchiveRecoverableItemsVersions(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'archiverecoverableitemsversions' + DISTINGUISHED_FOLDER_ID = "archiverecoverableitemsversions" supported_from = EXCHANGE_2010_SP1 class Conflicts(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'conflicts' + DISTINGUISHED_FOLDER_ID = "conflicts" supported_from = EXCHANGE_2013 class ConversationHistory(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'conversationhistory' + DISTINGUISHED_FOLDER_ID = "conversationhistory" supported_from = EXCHANGE_2013 class Directory(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'directory' + DISTINGUISHED_FOLDER_ID = "directory" supported_from = EXCHANGE_2013_SP1 class Favorites(WellknownFolder): - CONTAINER_CLASS = 'IPF.Note' - DISTINGUISHED_FOLDER_ID = 'favorites' + CONTAINER_CLASS = "IPF.Note" + DISTINGUISHED_FOLDER_ID = "favorites" supported_from = EXCHANGE_2013 class IMContactList(WellknownFolder): - CONTAINER_CLASS = 'IPF.Contact.MOC.ImContactList' - DISTINGUISHED_FOLDER_ID = 'imcontactlist' + CONTAINER_CLASS = "IPF.Contact.MOC.ImContactList" + DISTINGUISHED_FOLDER_ID = "imcontactlist" supported_from = EXCHANGE_2013 class Journal(WellknownFolder): - CONTAINER_CLASS = 'IPF.Journal' - DISTINGUISHED_FOLDER_ID = 'journal' + CONTAINER_CLASS = "IPF.Journal" + DISTINGUISHED_FOLDER_ID = "journal" class LocalFailures(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'localfailures' + DISTINGUISHED_FOLDER_ID = "localfailures" supported_from = EXCHANGE_2013 class MsgFolderRoot(WellknownFolder): """Also known as the 'Top of Information Store' folder.""" - DISTINGUISHED_FOLDER_ID = 'msgfolderroot' + DISTINGUISHED_FOLDER_ID = "msgfolderroot" LOCALIZED_NAMES = { - 'zh_CN': ('信息存储顶部',), + "zh_CN": ("信息存储顶部",), } class MyContacts(WellknownFolder): - CONTAINER_CLASS = 'IPF.Note' - DISTINGUISHED_FOLDER_ID = 'mycontacts' + CONTAINER_CLASS = "IPF.Note" + DISTINGUISHED_FOLDER_ID = "mycontacts" supported_from = EXCHANGE_2013 class Notes(WellknownFolder): - CONTAINER_CLASS = 'IPF.StickyNote' - DISTINGUISHED_FOLDER_ID = 'notes' + CONTAINER_CLASS = "IPF.StickyNote" + DISTINGUISHED_FOLDER_ID = "notes" LOCALIZED_NAMES = { - 'da_DK': ('Noter',), + "da_DK": ("Noter",), } class PeopleConnect(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'peopleconnect' + DISTINGUISHED_FOLDER_ID = "peopleconnect" supported_from = EXCHANGE_2013 class QuickContacts(WellknownFolder): - CONTAINER_CLASS = 'IPF.Contact.MOC.QuickContacts' - DISTINGUISHED_FOLDER_ID = 'quickcontacts' + CONTAINER_CLASS = "IPF.Contact.MOC.QuickContacts" + DISTINGUISHED_FOLDER_ID = "quickcontacts" supported_from = EXCHANGE_2013 class RecipientCache(Contacts): - DISTINGUISHED_FOLDER_ID = 'recipientcache' - CONTAINER_CLASS = 'IPF.Contact.RecipientCache' + DISTINGUISHED_FOLDER_ID = "recipientcache" + CONTAINER_CLASS = "IPF.Contact.RecipientCache" supported_from = EXCHANGE_2013 LOCALIZED_NAMES = {} class RecoverableItemsDeletions(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'recoverableitemsdeletions' + DISTINGUISHED_FOLDER_ID = "recoverableitemsdeletions" supported_from = EXCHANGE_2010_SP1 class RecoverableItemsPurges(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'recoverableitemspurges' + DISTINGUISHED_FOLDER_ID = "recoverableitemspurges" supported_from = EXCHANGE_2010_SP1 class RecoverableItemsRoot(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'recoverableitemsroot' + DISTINGUISHED_FOLDER_ID = "recoverableitemsroot" supported_from = EXCHANGE_2010_SP1 class RecoverableItemsVersions(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'recoverableitemsversions' + DISTINGUISHED_FOLDER_ID = "recoverableitemsversions" supported_from = EXCHANGE_2010_SP1 class SearchFolders(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'searchfolders' + DISTINGUISHED_FOLDER_ID = "searchfolders" class ServerFailures(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'serverfailures' + DISTINGUISHED_FOLDER_ID = "serverfailures" supported_from = EXCHANGE_2013 class SyncIssues(WellknownFolder): - CONTAINER_CLASS = 'IPF.Note' - DISTINGUISHED_FOLDER_ID = 'syncissues' + CONTAINER_CLASS = "IPF.Note" + DISTINGUISHED_FOLDER_ID = "syncissues" supported_from = EXCHANGE_2013 class ToDoSearch(WellknownFolder): - CONTAINER_CLASS = 'IPF.Task' - DISTINGUISHED_FOLDER_ID = 'todosearch' + CONTAINER_CLASS = "IPF.Task" + DISTINGUISHED_FOLDER_ID = "todosearch" supported_from = EXCHANGE_2013 LOCALIZED_NAMES = { - None: ('To-Do Search',), + None: ("To-Do Search",), } class VoiceMail(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'voicemail' - CONTAINER_CLASS = 'IPF.Note.Microsoft.Voicemail' + DISTINGUISHED_FOLDER_ID = "voicemail" + CONTAINER_CLASS = "IPF.Note.Microsoft.Voicemail" LOCALIZED_NAMES = { - None: ('Voice Mail',), + None: ("Voice Mail",), } @@ -356,251 +366,251 @@ def is_deletable(self): class AllContacts(NonDeletableFolderMixin, Contacts): - CONTAINER_CLASS = 'IPF.Note' + CONTAINER_CLASS = "IPF.Note" LOCALIZED_NAMES = { - None: ('AllContacts',), + None: ("AllContacts",), } class AllItems(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'IPF' + CONTAINER_CLASS = "IPF" LOCALIZED_NAMES = { - None: ('AllItems',), + None: ("AllItems",), } class Audits(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('Audits',), + None: ("Audits",), } get_folder_allowed = False class CalendarLogging(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('Calendar Logging',), + None: ("Calendar Logging",), } class CommonViews(NonDeletableFolderMixin, Folder): DEFAULT_ITEM_TRAVERSAL_DEPTH = ASSOCIATED LOCALIZED_NAMES = { - None: ('Common Views',), + None: ("Common Views",), } class Companies(NonDeletableFolderMixin, Contacts): DISTINGUISHED_FOLDER_ID = None - CONTAINTER_CLASS = 'IPF.Contact.Company' + CONTAINTER_CLASS = "IPF.Contact.Company" LOCALIZED_NAMES = { - None: ('Companies',), + None: ("Companies",), } class ConversationSettings(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'IPF.Configuration' + CONTAINER_CLASS = "IPF.Configuration" LOCALIZED_NAMES = { - 'da_DK': ('Indstillinger for samtalehandlinger',), + "da_DK": ("Indstillinger for samtalehandlinger",), } class DefaultFoldersChangeHistory(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'IPM.DefaultFolderHistoryItem' + CONTAINER_CLASS = "IPM.DefaultFolderHistoryItem" LOCALIZED_NAMES = { - None: ('DefaultFoldersChangeHistory',), + None: ("DefaultFoldersChangeHistory",), } class DeferredAction(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('Deferred Action',), + None: ("Deferred Action",), } class ExchangeSyncData(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('ExchangeSyncData',), + None: ("ExchangeSyncData",), } class Files(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'IPF.Files' + CONTAINER_CLASS = "IPF.Files" LOCALIZED_NAMES = { - 'da_DK': ('Filer',), + "da_DK": ("Filer",), } class FreebusyData(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('Freebusy Data',), + None: ("Freebusy Data",), } class Friends(NonDeletableFolderMixin, Contacts): - CONTAINER_CLASS = 'IPF.Note' + CONTAINER_CLASS = "IPF.Note" LOCALIZED_NAMES = { - 'de_DE': ('Bekannte',), + "de_DE": ("Bekannte",), } class GALContacts(NonDeletableFolderMixin, Contacts): DISTINGUISHED_FOLDER_ID = None - CONTAINER_CLASS = 'IPF.Contact.GalContacts' + CONTAINER_CLASS = "IPF.Contact.GalContacts" LOCALIZED_NAMES = { - None: ('GAL Contacts',), + None: ("GAL Contacts",), } class GraphAnalytics(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'IPF.StoreItem.GraphAnalytics' + CONTAINER_CLASS = "IPF.StoreItem.GraphAnalytics" LOCALIZED_NAMES = { - None: ('GraphAnalytics',), + None: ("GraphAnalytics",), } class Location(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('Location',), + None: ("Location",), } class MailboxAssociations(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('MailboxAssociations',), + None: ("MailboxAssociations",), } class MyContactsExtended(NonDeletableFolderMixin, Contacts): - CONTAINER_CLASS = 'IPF.Note' + CONTAINER_CLASS = "IPF.Note" LOCALIZED_NAMES = { - None: ('MyContactsExtended',), + None: ("MyContactsExtended",), } class OrganizationalContacts(NonDeletableFolderMixin, Contacts): DISTINGUISHED_FOLDER_ID = None - CONTAINTER_CLASS = 'IPF.Contact.OrganizationalContacts' + CONTAINTER_CLASS = "IPF.Contact.OrganizationalContacts" LOCALIZED_NAMES = { - None: ('Organizational Contacts',), + None: ("Organizational Contacts",), } class ParkedMessages(NonDeletableFolderMixin, Folder): CONTAINER_CLASS = None LOCALIZED_NAMES = { - None: ('ParkedMessages',), + None: ("ParkedMessages",), } class PassThroughSearchResults(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'IPF.StoreItem.PassThroughSearchResults' + CONTAINER_CLASS = "IPF.StoreItem.PassThroughSearchResults" LOCALIZED_NAMES = { - None: ('Pass-Through Search Results',), + None: ("Pass-Through Search Results",), } class PeopleCentricConversationBuddies(NonDeletableFolderMixin, Contacts): DISTINGUISHED_FOLDER_ID = None - CONTAINTER_CLASS = 'IPF.Contact.PeopleCentricConversationBuddies' + CONTAINTER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies" LOCALIZED_NAMES = { - None: ('PeopleCentricConversation Buddies',), + None: ("PeopleCentricConversation Buddies",), } class PdpProfileV2Secured(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'IPF.StoreItem.PdpProfileSecured' + CONTAINER_CLASS = "IPF.StoreItem.PdpProfileSecured" LOCALIZED_NAMES = { - None: ('PdpProfileV2Secured',), + None: ("PdpProfileV2Secured",), } class Reminders(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'Outlook.Reminder' + CONTAINER_CLASS = "Outlook.Reminder" LOCALIZED_NAMES = { - 'da_DK': ('Påmindelser',), + "da_DK": ("Påmindelser",), } class RSSFeeds(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'IPF.Note.OutlookHomepage' + CONTAINER_CLASS = "IPF.Note.OutlookHomepage" LOCALIZED_NAMES = { - None: ('RSS Feeds',), + None: ("RSS Feeds",), } class Schedule(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('Schedule',), + None: ("Schedule",), } class Sharing(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'IPF.Note' + CONTAINER_CLASS = "IPF.Note" LOCALIZED_NAMES = { - None: ('Sharing',), + None: ("Sharing",), } class Shortcuts(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('Shortcuts',), + None: ("Shortcuts",), } class Signal(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'IPF.StoreItem.Signal' + CONTAINER_CLASS = "IPF.StoreItem.Signal" LOCALIZED_NAMES = { - None: ('Signal',), + None: ("Signal",), } class SmsAndChatsSync(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'IPF.SmsAndChatsSync' + CONTAINER_CLASS = "IPF.SmsAndChatsSync" LOCALIZED_NAMES = { - None: ('SmsAndChatsSync',), + None: ("SmsAndChatsSync",), } class SpoolerQueue(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('Spooler Queue',), + None: ("Spooler Queue",), } class System(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('System',), + None: ("System",), } get_folder_allowed = False class System1(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('System1',), + None: ("System1",), } get_folder_allowed = False class TemporarySaves(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('TemporarySaves',), + None: ("TemporarySaves",), } class Views(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('Views',), + None: ("Views",), } class WorkingSet(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('Working Set',), + None: ("Working Set",), } diff --git a/exchangelib/folders/queryset.py b/exchangelib/folders/queryset.py index 44c0f348..083b74c5 100644 --- a/exchangelib/folders/queryset.py +++ b/exchangelib/folders/queryset.py @@ -1,15 +1,15 @@ import logging from copy import deepcopy -from ..errors import ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorItemNotFound, InvalidTypeError -from ..properties import InvalidField, FolderId +from ..errors import ErrorFolderNotFound, ErrorItemNotFound, ErrorNoPublicFolderReplicaAvailable, InvalidTypeError +from ..properties import FolderId, InvalidField from ..queryset import DoesNotExist, MultipleObjectsReturned from ..restriction import Q # Traversal enums -SHALLOW = 'Shallow' -SOFT_DELETED = 'SoftDeleted' -DEEP = 'Deep' +SHALLOW = "Shallow" +SOFT_DELETED = "SoftDeleted" +DEEP = "Deep" FOLDER_TRAVERSAL_CHOICES = (SHALLOW, DEEP, SOFT_DELETED) MISSING_FOLDER_ERRORS = (ErrorFolderNotFound, ErrorItemNotFound, ErrorNoPublicFolderReplicaAvailable) @@ -23,8 +23,9 @@ class FolderQuerySet: def __init__(self, folder_collection): from .collections import FolderCollection + if not isinstance(folder_collection, FolderCollection): - raise InvalidTypeError('folder_collection', folder_collection, FolderCollection) + raise InvalidTypeError("folder_collection", folder_collection, FolderCollection) self.folder_collection = folder_collection self.q = Q() # Default to no restrictions self.only_fields = None @@ -44,6 +45,7 @@ def _copy_self(self): def only(self, *args): """Restrict the fields returned. 'name' and 'folder_class' are always returned.""" from .base import Folder + # Subfolders will always be of class Folder all_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=None) all_fields.update(Folder.attribute_fields()) @@ -73,18 +75,19 @@ def get(self, *args, **kwargs): MultipleObjectsReturned if there are multiple results. """ from .collections import FolderCollection - if not args and set(kwargs) in ({'id'}, {'id', 'changekey'}): - folders = list(FolderCollection( - account=self.folder_collection.account, folders=[FolderId(**kwargs)] - ).resolve()) + + if not args and set(kwargs) in ({"id"}, {"id", "changekey"}): + folders = list( + FolderCollection(account=self.folder_collection.account, folders=[FolderId(**kwargs)]).resolve() + ) elif args or kwargs: folders = list(self.filter(*args, **kwargs)) else: folders = list(self.all()) if not folders: - raise DoesNotExist('Could not find a child folder matching the query') + raise DoesNotExist("Could not find a child folder matching the query") if len(folders) != 1: - raise MultipleObjectsReturned(f'Expected result length 1, but got {folders}') + raise MultipleObjectsReturned(f"Expected result length 1, but got {folders}") f = folders[0] if isinstance(f, Exception): raise f @@ -108,6 +111,7 @@ def __iter__(self): def _query(self): from .base import Folder from .collections import FolderCollection + if self.only_fields is None: # Subfolders will always be of class Folder non_complex_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=False) @@ -130,7 +134,7 @@ def _query(self): yield f continue if not f.get_folder_allowed: - log.debug('GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder', f) + log.debug("GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder", f) yield f else: resolveable_folders.append(f) @@ -148,7 +152,7 @@ def _query(self): continue # Add the extra field values to the folders we fetched with find_folders() if f.__class__ != complex_f.__class__: - raise ValueError(f'Type mismatch: {f} vs {complex_f}') + raise ValueError(f"Type mismatch: {f} vs {complex_f}") for complex_field in complex_fields: field_name = complex_field.field.name setattr(f, field_name, getattr(complex_f, field_name)) @@ -160,6 +164,7 @@ class SingleFolderQuerySet(FolderQuerySet): def __init__(self, account, folder): from .collections import FolderCollection + folder_collection = FolderCollection(account=account, folders=[folder]) super().__init__(folder_collection=folder_collection) diff --git a/exchangelib/folders/roots.py b/exchangelib/folders/roots.py index 4f8c8126..2218be6b 100644 --- a/exchangelib/folders/roots.py +++ b/exchangelib/folders/roots.py @@ -1,15 +1,19 @@ import logging from threading import Lock -from .base import BaseFolder -from .collections import FolderCollection -from .known_folders import MsgFolderRoot, NON_DELETABLE_FOLDERS, WELLKNOWN_FOLDERS_IN_ROOT, \ - WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT -from .queryset import SingleFolderQuerySet, SHALLOW, MISSING_FOLDER_ERRORS from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorInvalidOperation from ..fields import EffectiveRightsField from ..properties import EWSMeta from ..version import EXCHANGE_2007_SP1, EXCHANGE_2010_SP1 +from .base import BaseFolder +from .collections import FolderCollection +from .known_folders import ( + NON_DELETABLE_FOLDERS, + WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT, + WELLKNOWN_FOLDERS_IN_ROOT, + MsgFolderRoot, +) +from .queryset import MISSING_FOLDER_ERRORS, SHALLOW, SingleFolderQuerySet log = logging.getLogger(__name__) @@ -28,14 +32,15 @@ class RootOfHierarchy(BaseFolder, metaclass=EWSMeta): # This folder type also has 'folder:PermissionSet' on some server versions, but requesting it sometimes causes # 'ErrorAccessDenied', as reported by some users. Ignore it entirely for root folders - it's usefulness is # deemed minimal at best. - effective_rights = EffectiveRightsField(field_uri='folder:EffectiveRights', is_read_only=True, - supported_from=EXCHANGE_2007_SP1) + effective_rights = EffectiveRightsField( + field_uri="folder:EffectiveRights", is_read_only=True, supported_from=EXCHANGE_2007_SP1 + ) - __slots__ = '_account', '_subfolders' + __slots__ = "_account", "_subfolders" # A special folder that acts as the top of a folder hierarchy. Finds and caches subfolders at arbitrary depth. def __init__(self, **kwargs): - self._account = kwargs.pop('account', None) # A pointer back to the account holding the folder hierarchy + self._account = kwargs.pop("account", None) # A pointer back to the account holding the folder hierarchy super().__init__(**kwargs) self._subfolders = None # See self._folders_map() @@ -54,13 +59,13 @@ def parent(self): @classmethod def register(cls, *args, **kwargs): if cls is not RootOfHierarchy: - raise TypeError('For folder roots, custom fields must be registered on the RootOfHierarchy class') + raise TypeError("For folder roots, custom fields must be registered on the RootOfHierarchy class") return super().register(*args, **kwargs) @classmethod def deregister(cls, *args, **kwargs): if cls is not RootOfHierarchy: - raise TypeError('For folder roots, custom fields must be registered on the RootOfHierarchy class') + raise TypeError("For folder roots, custom fields must be registered on the RootOfHierarchy class") return super().deregister(*args, **kwargs) def get_folder(self, folder): @@ -104,14 +109,13 @@ def get_distinguished(cls, account): :param account: """ if not cls.DISTINGUISHED_FOLDER_ID: - raise ValueError(f'Class {cls} must have a DISTINGUISHED_FOLDER_ID value') + raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: return cls.resolve( - account=account, - folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + account=account, folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}') + raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") def get_default_folder(self, folder_cls): """Return the distinguished folder instance of type folder_cls belonging to this account. If no distinguished @@ -125,21 +129,21 @@ def get_default_folder(self, folder_cls): for f in self._folders_map.values(): # Require exact class, to not match subclasses, e.g. RecipientCache instead of Contacts if f.__class__ == folder_cls and f.is_distinguished: - log.debug('Found cached distinguished %s folder', folder_cls) + log.debug("Found cached distinguished %s folder", folder_cls) return f try: - log.debug('Requesting distinguished %s folder explicitly', folder_cls) + log.debug("Requesting distinguished %s folder explicitly", folder_cls) return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItems instead - log.debug('Testing default %s folder with FindItem', folder_cls) + log.debug("Testing default %s folder with FindItem", folder_cls) fld = folder_cls(root=self, name=folder_cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: # The Exchange server does not return a distinguished folder of this type pass - raise ErrorFolderNotFound(f'No usable default {folder_cls} folders') + raise ErrorFolderNotFound(f"No usable default {folder_cls} folders") @property def _folders_map(self): @@ -170,9 +174,9 @@ def _folders_map(self): if isinstance(f, Exception): raise f folders_map[f.id] = f - for f in SingleFolderQuerySet(account=self.account, folder=self).depth( - self.DEFAULT_FOLDER_TRAVERSAL_DEPTH - ).all(): + for f in ( + SingleFolderQuerySet(account=self.account, folder=self).depth(self.DEFAULT_FOLDER_TRAVERSAL_DEPTH).all() + ): if isinstance(f, ErrorAccessDenied): # We may not have FindFolder access, or GetFolder access, either to this folder or at all continue @@ -208,15 +212,25 @@ def folder_cls_from_folder_name(cls, folder_name, locale): def __repr__(self): # Let's not create an infinite loop when printing self.root - return self.__class__.__name__ + \ - repr((self.account, '[self]', self.name, self.total_count, self.unread_count, self.child_folder_count, - self.folder_class, self.id, self.changekey)) + return self.__class__.__name__ + repr( + ( + self.account, + "[self]", + self.name, + self.total_count, + self.unread_count, + self.child_folder_count, + self.folder_class, + self.id, + self.changekey, + ) + ) class Root(RootOfHierarchy): """The root of the standard folder hierarchy.""" - DISTINGUISHED_FOLDER_ID = 'root' + DISTINGUISHED_FOLDER_ID = "root" WELLKNOWN_FOLDERS = WELLKNOWN_FOLDERS_IN_ROOT @property @@ -237,12 +251,12 @@ def get_default_folder(self, folder_cls): # 3. Searching TOIS for a direct child folder of the same type that has a localized name # 4. Searching root for a direct child folder of the same type that is marked as distinguished # 5. Searching root for a direct child folder of the same type that has a localized name - log.debug('Searching default %s folder in full folder list', folder_cls) + log.debug("Searching default %s folder in full folder list", folder_cls) for f in self._folders_map.values(): # Require exact type, to avoid matching with subclasses (e.g. RecipientCache and Contacts) if f.__class__ == folder_cls and f.has_distinguished_name: - log.debug('Found cached %s folder with default distinguished name', folder_cls) + log.debug("Found cached %s folder with default distinguished name", folder_cls) return f # Try direct children of TOIS first, unless we're trying to get the TOIS folder @@ -265,21 +279,21 @@ def _get_candidate(self, folder_cls, folder_coll): else: candidates = [f for f in same_type if f.name.lower() in folder_cls.localized_names(self.account.locale)] if not candidates: - raise ErrorFolderNotFound(f'No usable default {folder_cls} folders') + raise ErrorFolderNotFound(f"No usable default {folder_cls} folders") if len(candidates) > 1: - raise ValueError(f'Multiple possible default {folder_cls} folders: {[f.name for f in candidates]}') + raise ValueError(f"Multiple possible default {folder_cls} folders: {[f.name for f in candidates]}") candidate = candidates[0] if candidate.is_distinguished: - log.debug('Found distinguished %s folder', folder_cls) + log.debug("Found distinguished %s folder", folder_cls) else: - log.debug('Found %s folder with localized name %s', folder_cls, candidate.name) + log.debug("Found %s folder with localized name %s", folder_cls, candidate.name) return candidate class PublicFoldersRoot(RootOfHierarchy): """The root of the public folders hierarchy. Not available on all mailboxes.""" - DISTINGUISHED_FOLDER_ID = 'publicfoldersroot' + DISTINGUISHED_FOLDER_ID = "publicfoldersroot" DEFAULT_FOLDER_TRAVERSAL_DEPTH = SHALLOW supported_from = EXCHANGE_2007_SP1 @@ -300,9 +314,11 @@ def get_children(self, folder): children_map = {} try: - for f in SingleFolderQuerySet(account=self.account, folder=folder).depth( - self.DEFAULT_FOLDER_TRAVERSAL_DEPTH - ).all(): + for f in ( + SingleFolderQuerySet(account=self.account, folder=folder) + .depth(self.DEFAULT_FOLDER_TRAVERSAL_DEPTH) + .all() + ): if isinstance(f, MISSING_FOLDER_ERRORS): # We were unlucky. The folder disappeared between the FindFolder and the GetFolder calls continue @@ -324,6 +340,6 @@ def get_children(self, folder): class ArchiveRoot(RootOfHierarchy): """The root of the archive folders hierarchy. Not available on all mailboxes.""" - DISTINGUISHED_FOLDER_ID = 'archiveroot' + DISTINGUISHED_FOLDER_ID = "archiveroot" supported_from = EXCHANGE_2010_SP1 WELLKNOWN_FOLDERS = WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT diff --git a/exchangelib/indexed_properties.py b/exchangelib/indexed_properties.py index 9c128d6a..503c09d1 100644 --- a/exchangelib/indexed_properties.py +++ b/exchangelib/indexed_properties.py @@ -1,6 +1,6 @@ import logging -from .fields import EmailSubField, LabelField, SubField, NamedSubField, Choice +from .fields import Choice, EmailSubField, LabelField, NamedSubField, SubField from .properties import EWSElement, EWSMeta log = logging.getLogger(__name__) @@ -19,31 +19,47 @@ class SingleFieldIndexedElement(IndexedElement, metaclass=EWSMeta): def value_field(cls, version): fields = cls.supported_fields(version=version) if len(fields) != 1: - raise ValueError(f'Class {cls} must have only one value field (found {tuple(f.name for f in fields)})') + raise ValueError(f"Class {cls} must have only one value field (found {tuple(f.name for f in fields)})") return fields[0] class EmailAddress(SingleFieldIndexedElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/entry-emailaddress""" - ELEMENT_NAME = 'Entry' - LABEL_CHOICES = ('EmailAddress1', 'EmailAddress2', 'EmailAddress3') + ELEMENT_NAME = "Entry" + LABEL_CHOICES = ("EmailAddress1", "EmailAddress2", "EmailAddress3") - label = LabelField(field_uri='Key', choices={Choice(c) for c in LABEL_CHOICES}, default=LABEL_CHOICES[0]) + label = LabelField(field_uri="Key", choices={Choice(c) for c in LABEL_CHOICES}, default=LABEL_CHOICES[0]) email = EmailSubField(is_required=True) class PhoneNumber(SingleFieldIndexedElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/entry-phonenumber""" - ELEMENT_NAME = 'Entry' + ELEMENT_NAME = "Entry" LABEL_CHOICES = ( - 'AssistantPhone', 'BusinessFax', 'BusinessPhone', 'BusinessPhone2', 'Callback', 'CarPhone', 'CompanyMainPhone', - 'HomeFax', 'HomePhone', 'HomePhone2', 'Isdn', 'MobilePhone', 'OtherFax', 'OtherTelephone', 'Pager', - 'PrimaryPhone', 'RadioPhone', 'Telex', 'TtyTddPhone' + "AssistantPhone", + "BusinessFax", + "BusinessPhone", + "BusinessPhone2", + "Callback", + "CarPhone", + "CompanyMainPhone", + "HomeFax", + "HomePhone", + "HomePhone2", + "Isdn", + "MobilePhone", + "OtherFax", + "OtherTelephone", + "Pager", + "PrimaryPhone", + "RadioPhone", + "Telex", + "TtyTddPhone", ) - label = LabelField(field_uri='Key', choices={Choice(c) for c in LABEL_CHOICES}, default='PrimaryPhone') + label = LabelField(field_uri="Key", choices={Choice(c) for c in LABEL_CHOICES}, default="PrimaryPhone") phone_number = SubField(is_required=True) @@ -54,15 +70,15 @@ class MultiFieldIndexedElement(IndexedElement, metaclass=EWSMeta): class PhysicalAddress(MultiFieldIndexedElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/entry-physicaladdress""" - ELEMENT_NAME = 'Entry' - LABEL_CHOICES = ('Business', 'Home', 'Other') + ELEMENT_NAME = "Entry" + LABEL_CHOICES = ("Business", "Home", "Other") - label = LabelField(field_uri='Key', choices={Choice(c) for c in LABEL_CHOICES}, default=LABEL_CHOICES[0]) - street = NamedSubField(field_uri='Street') # Street, house number, etc. - city = NamedSubField(field_uri='City') - state = NamedSubField(field_uri='State') - country = NamedSubField(field_uri='CountryOrRegion') - zipcode = NamedSubField(field_uri='PostalCode') + label = LabelField(field_uri="Key", choices={Choice(c) for c in LABEL_CHOICES}, default=LABEL_CHOICES[0]) + street = NamedSubField(field_uri="Street") # Street, house number, etc. + city = NamedSubField(field_uri="City") + state = NamedSubField(field_uri="State") + country = NamedSubField(field_uri="CountryOrRegion") + zipcode = NamedSubField(field_uri="PostalCode") def clean(self, version=None): if isinstance(self.zipcode, int): diff --git a/exchangelib/items/__init__.py b/exchangelib/items/__init__.py index d1244748..04bf6a6a 100644 --- a/exchangelib/items/__init__.py +++ b/exchangelib/items/__init__.py @@ -1,51 +1,139 @@ -from .base import RegisterMixIn, BulkCreateResult, MESSAGE_DISPOSITION_CHOICES, SAVE_ONLY, SEND_ONLY, \ - ID_ONLY, DEFAULT, ALL_PROPERTIES, SEND_MEETING_INVITATIONS_CHOICES, SEND_TO_NONE, SEND_ONLY_TO_ALL, \ - SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, SEND_ONLY_TO_CHANGED, SEND_TO_CHANGED_AND_SAVE_COPY, \ - SEND_MEETING_CANCELLATIONS_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES, ALL_OCCURRENCES, \ - SPECIFIED_OCCURRENCE_ONLY, CONFLICT_RESOLUTION_CHOICES, NEVER_OVERWRITE, AUTO_RESOLVE, ALWAYS_OVERWRITE, \ - DELETE_TYPE_CHOICES, HARD_DELETE, SOFT_DELETE, MOVE_TO_DELETED_ITEMS, SEND_TO_ALL_AND_SAVE_COPY, \ - SEND_AND_SAVE_COPY, SHAPE_CHOICES -from .calendar_item import CalendarItem, AcceptItem, TentativelyAcceptItem, DeclineItem, CancelCalendarItem, \ - MeetingMessage, MeetingRequest, MeetingResponse, MeetingCancellation, CONFERENCE_TYPES -from .contact import Contact, Persona, DistributionList +from .base import ( + AFFECTED_TASK_OCCURRENCES_CHOICES, + ALL_OCCURRENCES, + ALL_PROPERTIES, + ALWAYS_OVERWRITE, + AUTO_RESOLVE, + CONFLICT_RESOLUTION_CHOICES, + DEFAULT, + DELETE_TYPE_CHOICES, + HARD_DELETE, + ID_ONLY, + MESSAGE_DISPOSITION_CHOICES, + MOVE_TO_DELETED_ITEMS, + NEVER_OVERWRITE, + SAVE_ONLY, + SEND_AND_SAVE_COPY, + SEND_MEETING_CANCELLATIONS_CHOICES, + SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, + SEND_MEETING_INVITATIONS_CHOICES, + SEND_ONLY, + SEND_ONLY_TO_ALL, + SEND_ONLY_TO_CHANGED, + SEND_TO_ALL_AND_SAVE_COPY, + SEND_TO_CHANGED_AND_SAVE_COPY, + SEND_TO_NONE, + SHAPE_CHOICES, + SOFT_DELETE, + SPECIFIED_OCCURRENCE_ONLY, + BulkCreateResult, + RegisterMixIn, +) +from .calendar_item import ( + CONFERENCE_TYPES, + AcceptItem, + CalendarItem, + CancelCalendarItem, + DeclineItem, + MeetingCancellation, + MeetingMessage, + MeetingRequest, + MeetingResponse, + TentativelyAcceptItem, +) +from .contact import Contact, DistributionList, Persona from .item import BaseItem, Item -from .message import Message, ReplyToItem, ReplyAllToItem, ForwardItem +from .message import ForwardItem, Message, ReplyAllToItem, ReplyToItem from .post import PostItem, PostReplyItem from .task import Task # Traversal enums -SHALLOW = 'Shallow' -SOFT_DELETED = 'SoftDeleted' -ASSOCIATED = 'Associated' +SHALLOW = "Shallow" +SOFT_DELETED = "SoftDeleted" +ASSOCIATED = "Associated" ITEM_TRAVERSAL_CHOICES = (SHALLOW, SOFT_DELETED, ASSOCIATED) # Contacts search (ResolveNames) scope enums -ACTIVE_DIRECTORY = 'ActiveDirectory' -ACTIVE_DIRECTORY_CONTACTS = 'ActiveDirectoryContacts' -CONTACTS = 'Contacts' -CONTACTS_ACTIVE_DIRECTORY = 'ContactsActiveDirectory' +ACTIVE_DIRECTORY = "ActiveDirectory" +ACTIVE_DIRECTORY_CONTACTS = "ActiveDirectoryContacts" +CONTACTS = "Contacts" +CONTACTS_ACTIVE_DIRECTORY = "ContactsActiveDirectory" SEARCH_SCOPE_CHOICES = (ACTIVE_DIRECTORY, ACTIVE_DIRECTORY_CONTACTS, CONTACTS, CONTACTS_ACTIVE_DIRECTORY) -ITEM_CLASSES = (CalendarItem, Contact, DistributionList, Item, Message, MeetingMessage, MeetingRequest, - MeetingResponse, MeetingCancellation, PostItem, Task) +ITEM_CLASSES = ( + CalendarItem, + Contact, + DistributionList, + Item, + Message, + MeetingMessage, + MeetingRequest, + MeetingResponse, + MeetingCancellation, + PostItem, + Task, +) __all__ = [ - 'RegisterMixIn', 'MESSAGE_DISPOSITION_CHOICES', 'SAVE_ONLY', 'SEND_ONLY', 'SEND_AND_SAVE_COPY', - 'CalendarItem', 'AcceptItem', 'TentativelyAcceptItem', 'DeclineItem', 'CancelCalendarItem', - 'MeetingRequest', 'MeetingResponse', 'MeetingCancellation', 'CONFERENCE_TYPES', - 'Contact', 'Persona', 'DistributionList', - 'SEND_MEETING_INVITATIONS_CHOICES', 'SEND_TO_NONE', 'SEND_ONLY_TO_ALL', 'SEND_TO_ALL_AND_SAVE_COPY', - 'SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES', 'SEND_ONLY_TO_CHANGED', 'SEND_TO_CHANGED_AND_SAVE_COPY', - 'SEND_MEETING_CANCELLATIONS_CHOICES', 'AFFECTED_TASK_OCCURRENCES_CHOICES', 'ALL_OCCURRENCES', - 'SPECIFIED_OCCURRENCE_ONLY', 'CONFLICT_RESOLUTION_CHOICES', 'NEVER_OVERWRITE', 'AUTO_RESOLVE', 'ALWAYS_OVERWRITE', - 'DELETE_TYPE_CHOICES', 'HARD_DELETE', 'SOFT_DELETE', 'MOVE_TO_DELETED_ITEMS', 'BaseItem', 'Item', - 'BulkCreateResult', - 'Message', 'ReplyToItem', 'ReplyAllToItem', 'ForwardItem', - 'PostItem', 'PostReplyItem', - 'Task', - 'ITEM_TRAVERSAL_CHOICES', 'SHALLOW', 'SOFT_DELETED', 'ASSOCIATED', - 'SHAPE_CHOICES', 'ID_ONLY', 'DEFAULT', 'ALL_PROPERTIES', - 'SEARCH_SCOPE_CHOICES', 'ACTIVE_DIRECTORY', 'ACTIVE_DIRECTORY_CONTACTS', 'CONTACTS', 'CONTACTS_ACTIVE_DIRECTORY', - 'ITEM_CLASSES', + "RegisterMixIn", + "MESSAGE_DISPOSITION_CHOICES", + "SAVE_ONLY", + "SEND_ONLY", + "SEND_AND_SAVE_COPY", + "CalendarItem", + "AcceptItem", + "TentativelyAcceptItem", + "DeclineItem", + "CancelCalendarItem", + "MeetingRequest", + "MeetingResponse", + "MeetingCancellation", + "CONFERENCE_TYPES", + "Contact", + "Persona", + "DistributionList", + "SEND_MEETING_INVITATIONS_CHOICES", + "SEND_TO_NONE", + "SEND_ONLY_TO_ALL", + "SEND_TO_ALL_AND_SAVE_COPY", + "SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES", + "SEND_ONLY_TO_CHANGED", + "SEND_TO_CHANGED_AND_SAVE_COPY", + "SEND_MEETING_CANCELLATIONS_CHOICES", + "AFFECTED_TASK_OCCURRENCES_CHOICES", + "ALL_OCCURRENCES", + "SPECIFIED_OCCURRENCE_ONLY", + "CONFLICT_RESOLUTION_CHOICES", + "NEVER_OVERWRITE", + "AUTO_RESOLVE", + "ALWAYS_OVERWRITE", + "DELETE_TYPE_CHOICES", + "HARD_DELETE", + "SOFT_DELETE", + "MOVE_TO_DELETED_ITEMS", + "BaseItem", + "Item", + "BulkCreateResult", + "Message", + "ReplyToItem", + "ReplyAllToItem", + "ForwardItem", + "PostItem", + "PostReplyItem", + "Task", + "ITEM_TRAVERSAL_CHOICES", + "SHALLOW", + "SOFT_DELETED", + "ASSOCIATED", + "SHAPE_CHOICES", + "ID_ONLY", + "DEFAULT", + "ALL_PROPERTIES", + "SEARCH_SCOPE_CHOICES", + "ACTIVE_DIRECTORY", + "ACTIVE_DIRECTORY_CONTACTS", + "CONTACTS", + "CONTACTS_ACTIVE_DIRECTORY", + "ITEM_CLASSES", ] diff --git a/exchangelib/items/base.py b/exchangelib/items/base.py index 316baaad..31a5a040 100644 --- a/exchangelib/items/base.py +++ b/exchangelib/items/base.py @@ -2,27 +2,37 @@ from ..errors import InvalidTypeError from ..extended_properties import ExtendedProperty -from ..fields import BooleanField, ExtendedPropertyField, BodyField, MailboxField, MailboxListField, EWSElementField, \ - CharField, IdElementField, AttachmentField, ExtendedPropertyListField -from ..properties import InvalidField, IdChangeKeyMixIn, EWSElement, ReferenceItemId, ItemId, EWSMeta +from ..fields import ( + AttachmentField, + BodyField, + BooleanField, + CharField, + EWSElementField, + ExtendedPropertyField, + ExtendedPropertyListField, + IdElementField, + MailboxField, + MailboxListField, +) +from ..properties import EWSElement, EWSMeta, IdChangeKeyMixIn, InvalidField, ItemId, ReferenceItemId from ..util import require_account from ..version import EXCHANGE_2007_SP1 log = logging.getLogger(__name__) # Shape enums -ID_ONLY = 'IdOnly' -DEFAULT = 'Default' +ID_ONLY = "IdOnly" +DEFAULT = "Default" # AllProperties doesn't actually get all properties in FindItem, just the "first-class" ones. See # https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/email-properties-and-elements-in-ews-in-exchange -ALL_PROPERTIES = 'AllProperties' +ALL_PROPERTIES = "AllProperties" SHAPE_CHOICES = (ID_ONLY, DEFAULT, ALL_PROPERTIES) # MessageDisposition values. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createitem -SAVE_ONLY = 'SaveOnly' -SEND_ONLY = 'SendOnly' -SEND_AND_SAVE_COPY = 'SendAndSaveCopy' +SAVE_ONLY = "SaveOnly" +SEND_ONLY = "SendOnly" +SEND_AND_SAVE_COPY = "SendAndSaveCopy" MESSAGE_DISPOSITION_CHOICES = (SAVE_ONLY, SEND_ONLY, SEND_AND_SAVE_COPY) # SendMeetingInvitations values. See @@ -31,34 +41,39 @@ # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem # SendMeetingCancellations values. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteitem -SEND_TO_NONE = 'SendToNone' -SEND_ONLY_TO_ALL = 'SendOnlyToAll' -SEND_ONLY_TO_CHANGED = 'SendOnlyToChanged' -SEND_TO_ALL_AND_SAVE_COPY = 'SendToAllAndSaveCopy' -SEND_TO_CHANGED_AND_SAVE_COPY = 'SendToChangedAndSaveCopy' +SEND_TO_NONE = "SendToNone" +SEND_ONLY_TO_ALL = "SendOnlyToAll" +SEND_ONLY_TO_CHANGED = "SendOnlyToChanged" +SEND_TO_ALL_AND_SAVE_COPY = "SendToAllAndSaveCopy" +SEND_TO_CHANGED_AND_SAVE_COPY = "SendToChangedAndSaveCopy" SEND_MEETING_INVITATIONS_CHOICES = (SEND_TO_NONE, SEND_ONLY_TO_ALL, SEND_TO_ALL_AND_SAVE_COPY) -SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES = (SEND_TO_NONE, SEND_ONLY_TO_ALL, SEND_ONLY_TO_CHANGED, - SEND_TO_ALL_AND_SAVE_COPY, SEND_TO_CHANGED_AND_SAVE_COPY) +SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES = ( + SEND_TO_NONE, + SEND_ONLY_TO_ALL, + SEND_ONLY_TO_CHANGED, + SEND_TO_ALL_AND_SAVE_COPY, + SEND_TO_CHANGED_AND_SAVE_COPY, +) SEND_MEETING_CANCELLATIONS_CHOICES = (SEND_TO_NONE, SEND_ONLY_TO_ALL, SEND_TO_ALL_AND_SAVE_COPY) # AffectedTaskOccurrences values. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteitem -ALL_OCCURRENCES = 'AllOccurrences' -SPECIFIED_OCCURRENCE_ONLY = 'SpecifiedOccurrenceOnly' +ALL_OCCURRENCES = "AllOccurrences" +SPECIFIED_OCCURRENCE_ONLY = "SpecifiedOccurrenceOnly" AFFECTED_TASK_OCCURRENCES_CHOICES = (ALL_OCCURRENCES, SPECIFIED_OCCURRENCE_ONLY) # ConflictResolution values. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem -NEVER_OVERWRITE = 'NeverOverwrite' -AUTO_RESOLVE = 'AutoResolve' -ALWAYS_OVERWRITE = 'AlwaysOverwrite' +NEVER_OVERWRITE = "NeverOverwrite" +AUTO_RESOLVE = "AutoResolve" +ALWAYS_OVERWRITE = "AlwaysOverwrite" CONFLICT_RESOLUTION_CHOICES = (NEVER_OVERWRITE, AUTO_RESOLVE, ALWAYS_OVERWRITE) # DeleteType values. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteitem -HARD_DELETE = 'HardDelete' -SOFT_DELETE = 'SoftDelete' -MOVE_TO_DELETED_ITEMS = 'MoveToDeletedItems' +HARD_DELETE = "HardDelete" +SOFT_DELETE = "SoftDelete" +MOVE_TO_DELETED_ITEMS = "MoveToDeletedItems" DELETE_TYPE_CHOICES = (HARD_DELETE, SOFT_DELETE, MOVE_TO_DELETED_ITEMS) @@ -66,7 +81,7 @@ class RegisterMixIn(IdChangeKeyMixIn, metaclass=EWSMeta): """Base class for classes that can change their list of supported fields dynamically.""" # This class implements dynamic fields on an element class, so we need to include __dict__ in __slots__ - __slots__ = '__dict__', + __slots__ = ("__dict__",) INSERT_AFTER_FIELD = None @@ -79,7 +94,7 @@ def register(cls, attr_name, attr_cls): :return: """ if not cls.INSERT_AFTER_FIELD: - raise ValueError(f'Class {cls} is missing INSERT_AFTER_FIELD value') + raise ValueError(f"Class {cls} is missing INSERT_AFTER_FIELD value") try: cls.get_field_by_fieldname(attr_name) except InvalidField: @@ -120,9 +135,9 @@ class BaseItem(RegisterMixIn, metaclass=EWSMeta): """Base class for all other classes that implement EWS items.""" ID_ELEMENT_CLS = ItemId - _id = IdElementField(field_uri='item:ItemId', value_cls=ID_ELEMENT_CLS) + _id = IdElementField(field_uri="item:ItemId", value_cls=ID_ELEMENT_CLS) - __slots__ = 'account', 'folder' + __slots__ = "account", "folder" def __init__(self, **kwargs): """Pick out optional 'account' and 'folder' kwargs, and pass the rest to the parent class. @@ -132,15 +147,16 @@ def __init__(self, **kwargs): 'folder' is optional but allows calling 'save()'. If 'folder' has an account, and 'account' is not set, we use folder.account. """ - from ..folders import BaseFolder from ..account import Account - self.account = kwargs.pop('account', None) + from ..folders import BaseFolder + + self.account = kwargs.pop("account", None) if self.account is not None and not isinstance(self.account, Account): - raise InvalidTypeError('account', self.account, Account) - self.folder = kwargs.pop('folder', None) + raise InvalidTypeError("account", self.account, Account) + self.folder = kwargs.pop("folder", None) if self.folder is not None: if not isinstance(self.folder, BaseFolder): - raise InvalidTypeError('folder', self.folder, BaseFolder) + raise InvalidTypeError("folder", self.folder, BaseFolder) if self.folder.account is not None: if self.account is not None: # Make sure the account from kwargs matches the folder account @@ -159,32 +175,34 @@ def from_xml(cls, elem, account): class BaseReplyItem(EWSElement, metaclass=EWSMeta): """Base class for reply/forward elements that share the same fields.""" - subject = CharField(field_uri='Subject') - body = BodyField(field_uri='Body') # Accepts and returns Body or HTMLBody instances - to_recipients = MailboxListField(field_uri='ToRecipients') - cc_recipients = MailboxListField(field_uri='CcRecipients') - bcc_recipients = MailboxListField(field_uri='BccRecipients') - is_read_receipt_requested = BooleanField(field_uri='IsReadReceiptRequested') - is_delivery_receipt_requested = BooleanField(field_uri='IsDeliveryReceiptRequested') - author = MailboxField(field_uri='From') + subject = CharField(field_uri="Subject") + body = BodyField(field_uri="Body") # Accepts and returns Body or HTMLBody instances + to_recipients = MailboxListField(field_uri="ToRecipients") + cc_recipients = MailboxListField(field_uri="CcRecipients") + bcc_recipients = MailboxListField(field_uri="BccRecipients") + is_read_receipt_requested = BooleanField(field_uri="IsReadReceiptRequested") + is_delivery_receipt_requested = BooleanField(field_uri="IsDeliveryReceiptRequested") + author = MailboxField(field_uri="From") reference_item_id = EWSElementField(value_cls=ReferenceItemId) - new_body = BodyField(field_uri='NewBodyContent') # Accepts and returns Body or HTMLBody instances - received_by = MailboxField(field_uri='ReceivedBy', supported_from=EXCHANGE_2007_SP1) - received_by_representing = MailboxField(field_uri='ReceivedRepresenting', supported_from=EXCHANGE_2007_SP1) + new_body = BodyField(field_uri="NewBodyContent") # Accepts and returns Body or HTMLBody instances + received_by = MailboxField(field_uri="ReceivedBy", supported_from=EXCHANGE_2007_SP1) + received_by_representing = MailboxField(field_uri="ReceivedRepresenting", supported_from=EXCHANGE_2007_SP1) - __slots__ = 'account', + __slots__ = ("account",) def __init__(self, **kwargs): # 'account' is optional but allows calling 'send()' and 'save()' from ..account import Account - self.account = kwargs.pop('account', None) + + self.account = kwargs.pop("account", None) if self.account is not None and not isinstance(self.account, Account): - raise InvalidTypeError('account', self.account, Account) + raise InvalidTypeError("account", self.account, Account) super().__init__(**kwargs) @require_account def send(self, save_copy=True, copy_to_folder=None): from ..services import CreateItem + if copy_to_folder and not save_copy: raise AttributeError("'save_copy' must be True when 'copy_to_folder' is set") message_disposition = SEND_AND_SAVE_COPY if save_copy else SEND_ONLY @@ -203,6 +221,7 @@ def save(self, folder): :return: """ from ..services import CreateItem + return CreateItem(account=self.account).get( items=[self], folder=folder, @@ -214,7 +233,7 @@ def save(self, folder): class BulkCreateResult(BaseItem): """A dummy class to store return values from a CreateItem service call.""" - attachments = AttachmentField(field_uri='item:Attachments') # ItemAttachment or FileAttachment + attachments = AttachmentField(field_uri="item:Attachments") # ItemAttachment or FileAttachment def __init__(self, **kwargs): super().__init__(**kwargs) diff --git a/exchangelib/items/calendar_item.py b/exchangelib/items/calendar_item.py index e0c34bd3..6d973735 100644 --- a/exchangelib/items/calendar_item.py +++ b/exchangelib/items/calendar_item.py @@ -1,30 +1,52 @@ import datetime import logging -from .base import BaseItem, BaseReplyItem, SEND_AND_SAVE_COPY, SEND_TO_NONE -from .item import Item -from .message import Message from ..ewsdatetime import EWSDate, EWSDateTime -from ..fields import BooleanField, IntegerField, TextField, ChoiceField, URIField, BodyField, DateTimeField, \ - MessageHeaderField, AttachmentField, RecurrenceField, MailboxField, AttendeesField, Choice, OccurrenceField, \ - OccurrenceListField, TimeZoneField, CharField, EnumAsIntField, FreeBusyStatusField, ReferenceItemIdField, \ - AssociatedCalendarItemIdField, DateOrDateTimeField, EWSElementListField, AppointmentStateField -from ..properties import Attendee, ReferenceItemId, OccurrenceItemId, RecurringMasterItemId, EWSMeta -from ..recurrence import FirstOccurrence, LastOccurrence, Occurrence, DeletedOccurrence -from ..util import set_xml_value, require_account +from ..fields import ( + AppointmentStateField, + AssociatedCalendarItemIdField, + AttachmentField, + AttendeesField, + BodyField, + BooleanField, + CharField, + Choice, + ChoiceField, + DateOrDateTimeField, + DateTimeField, + EnumAsIntField, + EWSElementListField, + FreeBusyStatusField, + IntegerField, + MailboxField, + MessageHeaderField, + OccurrenceField, + OccurrenceListField, + RecurrenceField, + ReferenceItemIdField, + TextField, + TimeZoneField, + URIField, +) +from ..properties import Attendee, EWSMeta, OccurrenceItemId, RecurringMasterItemId, ReferenceItemId +from ..recurrence import DeletedOccurrence, FirstOccurrence, LastOccurrence, Occurrence +from ..util import require_account, set_xml_value from ..version import EXCHANGE_2010, EXCHANGE_2013 +from .base import SEND_AND_SAVE_COPY, SEND_TO_NONE, BaseItem, BaseReplyItem +from .item import Item +from .message import Message log = logging.getLogger(__name__) # Conference Type values. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/conferencetype -CONFERENCE_TYPES = ('NetMeeting', 'NetShow', 'Chat') +CONFERENCE_TYPES = ("NetMeeting", "NetShow", "Chat") # CalendarItemType enums -SINGLE = 'Single' -OCCURRENCE = 'Occurrence' -EXCEPTION = 'Exception' -RECURRING_MASTER = 'RecurringMaster' +SINGLE = "Single" +OCCURRENCE = "Occurrence" +EXCEPTION = "Exception" +RECURRING_MASTER = "RecurringMaster" CALENDAR_ITEM_CHOICES = (SINGLE, OCCURRENCE, EXCEPTION, RECURRING_MASTER) @@ -33,89 +55,92 @@ class AcceptDeclineMixIn: def accept(self, message_disposition=SEND_AND_SAVE_COPY, **kwargs): return AcceptItem( - account=self.account, - reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), - **kwargs + account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs ).send(message_disposition) def decline(self, message_disposition=SEND_AND_SAVE_COPY, **kwargs): return DeclineItem( - account=self.account, - reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), - **kwargs + account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs ).send(message_disposition) def tentatively_accept(self, message_disposition=SEND_AND_SAVE_COPY, **kwargs): return TentativelyAcceptItem( - account=self.account, - reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), - **kwargs + account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs ).send(message_disposition) class CalendarItem(Item, AcceptDeclineMixIn): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendaritem""" - ELEMENT_NAME = 'CalendarItem' - - uid = TextField(field_uri='calendar:UID', is_required_after_save=True, is_searchable=False) - recurrence_id = DateTimeField(field_uri='calendar:RecurrenceId', is_read_only=True) - start = DateOrDateTimeField(field_uri='calendar:Start', is_required=True) - end = DateOrDateTimeField(field_uri='calendar:End', is_required=True) - original_start = DateTimeField(field_uri='calendar:OriginalStart', is_read_only=True) - is_all_day = BooleanField(field_uri='calendar:IsAllDayEvent', is_required=True, default=False) - legacy_free_busy_status = FreeBusyStatusField(field_uri='calendar:LegacyFreeBusyStatus', is_required=True, - default='Busy') - location = TextField(field_uri='calendar:Location') - when = TextField(field_uri='calendar:When') - is_meeting = BooleanField(field_uri='calendar:IsMeeting', is_read_only=True) - is_cancelled = BooleanField(field_uri='calendar:IsCancelled', is_read_only=True) - is_recurring = BooleanField(field_uri='calendar:IsRecurring', is_read_only=True) - meeting_request_was_sent = BooleanField(field_uri='calendar:MeetingRequestWasSent', is_read_only=True) - is_response_requested = BooleanField(field_uri='calendar:IsResponseRequested', default=None, - is_required_after_save=True, is_searchable=False) - type = ChoiceField(field_uri='calendar:CalendarItemType', choices={Choice(c) for c in CALENDAR_ITEM_CHOICES}, - is_read_only=True) - my_response_type = ChoiceField(field_uri='calendar:MyResponseType', choices={ - Choice(c) for c in Attendee.RESPONSE_TYPES - }, is_read_only=True) - organizer = MailboxField(field_uri='calendar:Organizer', is_read_only=True) - required_attendees = AttendeesField(field_uri='calendar:RequiredAttendees', is_searchable=False) - optional_attendees = AttendeesField(field_uri='calendar:OptionalAttendees', is_searchable=False) - resources = AttendeesField(field_uri='calendar:Resources', is_searchable=False) - conflicting_meeting_count = IntegerField(field_uri='calendar:ConflictingMeetingCount', is_read_only=True) - adjacent_meeting_count = IntegerField(field_uri='calendar:AdjacentMeetingCount', is_read_only=True) - conflicting_meetings = EWSElementListField(field_uri='calendar:ConflictingMeetings', value_cls='CalendarItem', - namespace=Item.NAMESPACE, is_read_only=True) - adjacent_meetings = EWSElementListField(field_uri='calendar:AdjacentMeetings', value_cls='CalendarItem', - namespace=Item.NAMESPACE, is_read_only=True) - duration = CharField(field_uri='calendar:Duration', is_read_only=True) - appointment_reply_time = DateTimeField(field_uri='calendar:AppointmentReplyTime', is_read_only=True) - appointment_sequence_number = IntegerField(field_uri='calendar:AppointmentSequenceNumber', is_read_only=True) - appointment_state = AppointmentStateField(field_uri='calendar:AppointmentState', is_read_only=True) - recurrence = RecurrenceField(field_uri='calendar:Recurrence', is_searchable=False) - first_occurrence = OccurrenceField(field_uri='calendar:FirstOccurrence', value_cls=FirstOccurrence, - is_read_only=True) - last_occurrence = OccurrenceField(field_uri='calendar:LastOccurrence', value_cls=LastOccurrence, - is_read_only=True) - modified_occurrences = OccurrenceListField(field_uri='calendar:ModifiedOccurrences', value_cls=Occurrence, - is_read_only=True) - deleted_occurrences = OccurrenceListField(field_uri='calendar:DeletedOccurrences', value_cls=DeletedOccurrence, - is_read_only=True) - _meeting_timezone = TimeZoneField(field_uri='calendar:MeetingTimeZone', deprecated_from=EXCHANGE_2010, - is_searchable=False) - _start_timezone = TimeZoneField(field_uri='calendar:StartTimeZone', supported_from=EXCHANGE_2010, - is_searchable=False) - _end_timezone = TimeZoneField(field_uri='calendar:EndTimeZone', supported_from=EXCHANGE_2010, - is_searchable=False) - conference_type = EnumAsIntField(field_uri='calendar:ConferenceType', enum=CONFERENCE_TYPES, min=0, - default=None, is_required_after_save=True) - allow_new_time_proposal = BooleanField(field_uri='calendar:AllowNewTimeProposal', default=None, - is_required_after_save=True, is_searchable=False) - is_online_meeting = BooleanField(field_uri='calendar:IsOnlineMeeting', default=None, - is_read_only=True) - meeting_workspace_url = URIField(field_uri='calendar:MeetingWorkspaceUrl') - net_show_url = URIField(field_uri='calendar:NetShowUrl') + ELEMENT_NAME = "CalendarItem" + + uid = TextField(field_uri="calendar:UID", is_required_after_save=True, is_searchable=False) + recurrence_id = DateTimeField(field_uri="calendar:RecurrenceId", is_read_only=True) + start = DateOrDateTimeField(field_uri="calendar:Start", is_required=True) + end = DateOrDateTimeField(field_uri="calendar:End", is_required=True) + original_start = DateTimeField(field_uri="calendar:OriginalStart", is_read_only=True) + is_all_day = BooleanField(field_uri="calendar:IsAllDayEvent", is_required=True, default=False) + legacy_free_busy_status = FreeBusyStatusField( + field_uri="calendar:LegacyFreeBusyStatus", is_required=True, default="Busy" + ) + location = TextField(field_uri="calendar:Location") + when = TextField(field_uri="calendar:When") + is_meeting = BooleanField(field_uri="calendar:IsMeeting", is_read_only=True) + is_cancelled = BooleanField(field_uri="calendar:IsCancelled", is_read_only=True) + is_recurring = BooleanField(field_uri="calendar:IsRecurring", is_read_only=True) + meeting_request_was_sent = BooleanField(field_uri="calendar:MeetingRequestWasSent", is_read_only=True) + is_response_requested = BooleanField( + field_uri="calendar:IsResponseRequested", default=None, is_required_after_save=True, is_searchable=False + ) + type = ChoiceField( + field_uri="calendar:CalendarItemType", choices={Choice(c) for c in CALENDAR_ITEM_CHOICES}, is_read_only=True + ) + my_response_type = ChoiceField( + field_uri="calendar:MyResponseType", choices={Choice(c) for c in Attendee.RESPONSE_TYPES}, is_read_only=True + ) + organizer = MailboxField(field_uri="calendar:Organizer", is_read_only=True) + required_attendees = AttendeesField(field_uri="calendar:RequiredAttendees", is_searchable=False) + optional_attendees = AttendeesField(field_uri="calendar:OptionalAttendees", is_searchable=False) + resources = AttendeesField(field_uri="calendar:Resources", is_searchable=False) + conflicting_meeting_count = IntegerField(field_uri="calendar:ConflictingMeetingCount", is_read_only=True) + adjacent_meeting_count = IntegerField(field_uri="calendar:AdjacentMeetingCount", is_read_only=True) + conflicting_meetings = EWSElementListField( + field_uri="calendar:ConflictingMeetings", value_cls="CalendarItem", namespace=Item.NAMESPACE, is_read_only=True + ) + adjacent_meetings = EWSElementListField( + field_uri="calendar:AdjacentMeetings", value_cls="CalendarItem", namespace=Item.NAMESPACE, is_read_only=True + ) + duration = CharField(field_uri="calendar:Duration", is_read_only=True) + appointment_reply_time = DateTimeField(field_uri="calendar:AppointmentReplyTime", is_read_only=True) + appointment_sequence_number = IntegerField(field_uri="calendar:AppointmentSequenceNumber", is_read_only=True) + appointment_state = AppointmentStateField(field_uri="calendar:AppointmentState", is_read_only=True) + recurrence = RecurrenceField(field_uri="calendar:Recurrence", is_searchable=False) + first_occurrence = OccurrenceField( + field_uri="calendar:FirstOccurrence", value_cls=FirstOccurrence, is_read_only=True + ) + last_occurrence = OccurrenceField(field_uri="calendar:LastOccurrence", value_cls=LastOccurrence, is_read_only=True) + modified_occurrences = OccurrenceListField( + field_uri="calendar:ModifiedOccurrences", value_cls=Occurrence, is_read_only=True + ) + deleted_occurrences = OccurrenceListField( + field_uri="calendar:DeletedOccurrences", value_cls=DeletedOccurrence, is_read_only=True + ) + _meeting_timezone = TimeZoneField( + field_uri="calendar:MeetingTimeZone", deprecated_from=EXCHANGE_2010, is_searchable=False + ) + _start_timezone = TimeZoneField( + field_uri="calendar:StartTimeZone", supported_from=EXCHANGE_2010, is_searchable=False + ) + _end_timezone = TimeZoneField(field_uri="calendar:EndTimeZone", supported_from=EXCHANGE_2010, is_searchable=False) + conference_type = EnumAsIntField( + field_uri="calendar:ConferenceType", enum=CONFERENCE_TYPES, min=0, default=None, is_required_after_save=True + ) + allow_new_time_proposal = BooleanField( + field_uri="calendar:AllowNewTimeProposal", default=None, is_required_after_save=True, is_searchable=False + ) + is_online_meeting = BooleanField(field_uri="calendar:IsOnlineMeeting", default=None, is_read_only=True) + meeting_workspace_url = URIField(field_uri="calendar:MeetingWorkspaceUrl") + net_show_url = URIField(field_uri="calendar:NetShowUrl") def occurrence(self, index): """Get an occurrence of a recurring master by index. No query is sent to the server to actually fetch the item. @@ -186,9 +211,7 @@ def clean(self, version=None): def cancel(self, **kwargs): return CancelCalendarItem( - account=self.account, - reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), - **kwargs + account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs ).send() def _update_fieldnames(self): @@ -196,8 +219,8 @@ def _update_fieldnames(self): if self.type == OCCURRENCE: # Some CalendarItem fields cannot be updated when the item is an occurrence. The values are empty when we # receive them so would have been updated because they are set to None. - update_fields.remove('recurrence') - update_fields.remove('uid') + update_fields.remove("recurrence") + update_fields.remove("uid") return update_fields @classmethod @@ -207,15 +230,15 @@ def from_xml(cls, elem, account): # applicable. if not item.is_all_day: return item - for field_name in ('start', 'end'): + for field_name in ("start", "end"): val = getattr(item, field_name) if val is None: continue # Return just the date part of the value. Subtract 1 day from the date if this is the end field. This is # the inverse of what we do in .to_xml(). Convert to the local timezone before getting the date. - if field_name == 'end': + if field_name == "end": val -= datetime.timedelta(days=1) - tz = getattr(item, f'_{field_name}_timezone') + tz = getattr(item, f"_{field_name}_timezone") setattr(item, field_name, val.astimezone(tz).date()) return item @@ -223,11 +246,11 @@ def tz_field_for_field_name(self, field_name): meeting_tz_field, start_tz_field, end_tz_field = CalendarItem.timezone_fields() if self.account.version.build < EXCHANGE_2010: return meeting_tz_field - if field_name == 'start': + if field_name == "start": return start_tz_field - if field_name == 'end': + if field_name == "end": return end_tz_field - raise ValueError('Unsupported field_name') + raise ValueError("Unsupported field_name") def date_to_datetime(self, field_name): # EWS always expects a datetime. If we have a date value, then convert it to datetime in the local @@ -236,7 +259,7 @@ def date_to_datetime(self, field_name): value = getattr(self, field_name) tz = getattr(self, self.tz_field_for_field_name(field_name).name) value = EWSDateTime.combine(value, datetime.time(0, 0)).replace(tzinfo=tz) - if field_name == 'end': + if field_name == "end": value += datetime.timedelta(days=1) return value @@ -250,7 +273,7 @@ def to_xml(self, version): elem = super().to_xml(version=version) if not self.is_all_day: return elem - for field_name in ('start', 'end'): + for field_name in ("start", "end"): value = getattr(self, field_name) if value is None: continue @@ -274,96 +297,125 @@ class BaseMeetingItem(Item, metaclass=EWSMeta): Therefore BaseMeetingItem inherits from EWSElement has no save() or send() method """ - associated_calendar_item_id = AssociatedCalendarItemIdField(field_uri='meeting:AssociatedCalendarItemId') - is_delegated = BooleanField(field_uri='meeting:IsDelegated', is_read_only=True, default=False) - is_out_of_date = BooleanField(field_uri='meeting:IsOutOfDate', is_read_only=True, default=False) - has_been_processed = BooleanField(field_uri='meeting:HasBeenProcessed', is_read_only=True, default=False) - response_type = ChoiceField(field_uri='meeting:ResponseType', choices={ - Choice('Unknown'), Choice('Organizer'), Choice('Tentative'), Choice('Accept'), Choice('Decline'), - Choice('NoResponseReceived') - }, is_required=True, default='Unknown') - - effective_rights_idx = Item.FIELDS.index_by_name('effective_rights') - sender_idx = Message.FIELDS.index_by_name('sender') - reply_to_idx = Message.FIELDS.index_by_name('reply_to') - FIELDS = Item.FIELDS[:effective_rights_idx] \ - + Message.FIELDS[sender_idx:reply_to_idx + 1] \ + associated_calendar_item_id = AssociatedCalendarItemIdField(field_uri="meeting:AssociatedCalendarItemId") + is_delegated = BooleanField(field_uri="meeting:IsDelegated", is_read_only=True, default=False) + is_out_of_date = BooleanField(field_uri="meeting:IsOutOfDate", is_read_only=True, default=False) + has_been_processed = BooleanField(field_uri="meeting:HasBeenProcessed", is_read_only=True, default=False) + response_type = ChoiceField( + field_uri="meeting:ResponseType", + choices={ + Choice("Unknown"), + Choice("Organizer"), + Choice("Tentative"), + Choice("Accept"), + Choice("Decline"), + Choice("NoResponseReceived"), + }, + is_required=True, + default="Unknown", + ) + + effective_rights_idx = Item.FIELDS.index_by_name("effective_rights") + sender_idx = Message.FIELDS.index_by_name("sender") + reply_to_idx = Message.FIELDS.index_by_name("reply_to") + FIELDS = ( + Item.FIELDS[:effective_rights_idx] + + Message.FIELDS[sender_idx : reply_to_idx + 1] + Item.FIELDS[effective_rights_idx:] + ) class MeetingRequest(BaseMeetingItem, AcceptDeclineMixIn): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/meetingrequest""" - ELEMENT_NAME = 'MeetingRequest' - - meeting_request_type = ChoiceField(field_uri='meetingRequest:MeetingRequestType', choices={ - Choice('FullUpdate'), Choice('InformationalUpdate'), Choice('NewMeetingRequest'), Choice('None'), - Choice('Outdated'), Choice('PrincipalWantsCopy'), Choice('SilentUpdate') - }, default='None') - intended_free_busy_status = ChoiceField(field_uri='meetingRequest:IntendedFreeBusyStatus', choices={ - Choice('Free'), Choice('Tentative'), Choice('Busy'), Choice('OOF'), Choice('NoData') - }, is_required=True, default='Busy') + ELEMENT_NAME = "MeetingRequest" + + meeting_request_type = ChoiceField( + field_uri="meetingRequest:MeetingRequestType", + choices={ + Choice("FullUpdate"), + Choice("InformationalUpdate"), + Choice("NewMeetingRequest"), + Choice("None"), + Choice("Outdated"), + Choice("PrincipalWantsCopy"), + Choice("SilentUpdate"), + }, + default="None", + ) + intended_free_busy_status = ChoiceField( + field_uri="meetingRequest:IntendedFreeBusyStatus", + choices={Choice("Free"), Choice("Tentative"), Choice("Busy"), Choice("OOF"), Choice("NoData")}, + is_required=True, + default="Busy", + ) # This element also has some fields from CalendarItem - start_idx = CalendarItem.FIELDS.index_by_name('start') - is_response_requested_idx = CalendarItem.FIELDS.index_by_name('is_response_requested') - FIELDS = BaseMeetingItem.FIELDS \ - + CalendarItem.FIELDS[start_idx:is_response_requested_idx]\ - + CalendarItem.FIELDS[is_response_requested_idx + 1:] + start_idx = CalendarItem.FIELDS.index_by_name("start") + is_response_requested_idx = CalendarItem.FIELDS.index_by_name("is_response_requested") + FIELDS = ( + BaseMeetingItem.FIELDS + + CalendarItem.FIELDS[start_idx:is_response_requested_idx] + + CalendarItem.FIELDS[is_response_requested_idx + 1 :] + ) class MeetingMessage(BaseMeetingItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/meetingmessage""" - ELEMENT_NAME = 'MeetingMessage' + ELEMENT_NAME = "MeetingMessage" class MeetingResponse(BaseMeetingItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/meetingresponse""" - ELEMENT_NAME = 'MeetingResponse' + ELEMENT_NAME = "MeetingResponse" - received_by = MailboxField(field_uri='message:ReceivedBy', is_read_only=True) - received_representing = MailboxField(field_uri='message:ReceivedRepresenting', is_read_only=True) - proposed_start = DateTimeField(field_uri='meeting:ProposedStart', supported_from=EXCHANGE_2013) - proposed_end = DateTimeField(field_uri='meeting:ProposedEnd', supported_from=EXCHANGE_2013) + received_by = MailboxField(field_uri="message:ReceivedBy", is_read_only=True) + received_representing = MailboxField(field_uri="message:ReceivedRepresenting", is_read_only=True) + proposed_start = DateTimeField(field_uri="meeting:ProposedStart", supported_from=EXCHANGE_2013) + proposed_end = DateTimeField(field_uri="meeting:ProposedEnd", supported_from=EXCHANGE_2013) class MeetingCancellation(BaseMeetingItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/meetingcancellation""" - ELEMENT_NAME = 'MeetingCancellation' + ELEMENT_NAME = "MeetingCancellation" class BaseMeetingReplyItem(BaseItem, metaclass=EWSMeta): """Base class for meeting request reply items that share the same fields (Accept, TentativelyAccept, Decline).""" - item_class = CharField(field_uri='item:ItemClass', is_read_only=True) - sensitivity = ChoiceField(field_uri='item:Sensitivity', choices={ - Choice('Normal'), Choice('Personal'), Choice('Private'), Choice('Confidential') - }, is_required=True, default='Normal') - body = BodyField(field_uri='item:Body') # Accepts and returns Body or HTMLBody instances - attachments = AttachmentField(field_uri='item:Attachments') # ItemAttachment or FileAttachment - headers = MessageHeaderField(field_uri='item:InternetMessageHeaders', is_read_only=True) - - sender = Message.FIELDS['sender'] - to_recipients = Message.FIELDS['to_recipients'] - cc_recipients = Message.FIELDS['cc_recipients'] - bcc_recipients = Message.FIELDS['bcc_recipients'] - is_read_receipt_requested = Message.FIELDS['is_read_receipt_requested'] - is_delivery_receipt_requested = Message.FIELDS['is_delivery_receipt_requested'] - - reference_item_id = ReferenceItemIdField(field_uri='item:ReferenceItemId') - received_by = MailboxField(field_uri='message:ReceivedBy', is_read_only=True) - received_representing = MailboxField(field_uri='message:ReceivedRepresenting', is_read_only=True) - proposed_start = DateTimeField(field_uri='meeting:ProposedStart', supported_from=EXCHANGE_2013) - proposed_end = DateTimeField(field_uri='meeting:ProposedEnd', supported_from=EXCHANGE_2013) + item_class = CharField(field_uri="item:ItemClass", is_read_only=True) + sensitivity = ChoiceField( + field_uri="item:Sensitivity", + choices={Choice("Normal"), Choice("Personal"), Choice("Private"), Choice("Confidential")}, + is_required=True, + default="Normal", + ) + body = BodyField(field_uri="item:Body") # Accepts and returns Body or HTMLBody instances + attachments = AttachmentField(field_uri="item:Attachments") # ItemAttachment or FileAttachment + headers = MessageHeaderField(field_uri="item:InternetMessageHeaders", is_read_only=True) + + sender = Message.FIELDS["sender"] + to_recipients = Message.FIELDS["to_recipients"] + cc_recipients = Message.FIELDS["cc_recipients"] + bcc_recipients = Message.FIELDS["bcc_recipients"] + is_read_receipt_requested = Message.FIELDS["is_read_receipt_requested"] + is_delivery_receipt_requested = Message.FIELDS["is_delivery_receipt_requested"] + + reference_item_id = ReferenceItemIdField(field_uri="item:ReferenceItemId") + received_by = MailboxField(field_uri="message:ReceivedBy", is_read_only=True) + received_representing = MailboxField(field_uri="message:ReceivedRepresenting", is_read_only=True) + proposed_start = DateTimeField(field_uri="meeting:ProposedStart", supported_from=EXCHANGE_2013) + proposed_end = DateTimeField(field_uri="meeting:ProposedEnd", supported_from=EXCHANGE_2013) @require_account def send(self, message_disposition=SEND_AND_SAVE_COPY): # Some responses contain multiple response IDs, e.g. MeetingRequest.accept(). Return either the single ID or # the list of IDs. from ..services import CreateItem + return CreateItem(account=self.account).get( items=[self], folder=self.folder, @@ -375,24 +427,24 @@ def send(self, message_disposition=SEND_AND_SAVE_COPY): class AcceptItem(BaseMeetingReplyItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/acceptitem""" - ELEMENT_NAME = 'AcceptItem' + ELEMENT_NAME = "AcceptItem" class TentativelyAcceptItem(BaseMeetingReplyItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/tentativelyacceptitem""" - ELEMENT_NAME = 'TentativelyAcceptItem' + ELEMENT_NAME = "TentativelyAcceptItem" class DeclineItem(BaseMeetingReplyItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/declineitem""" - ELEMENT_NAME = 'DeclineItem' + ELEMENT_NAME = "DeclineItem" class CancelCalendarItem(BaseReplyItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/cancelcalendaritem""" - ELEMENT_NAME = 'CancelCalendarItem' - author_idx = BaseReplyItem.FIELDS.index_by_name('author') - FIELDS = BaseReplyItem.FIELDS[:author_idx] + BaseReplyItem.FIELDS[author_idx + 1:] + ELEMENT_NAME = "CancelCalendarItem" + author_idx = BaseReplyItem.FIELDS.index_by_name("author") + FIELDS = BaseReplyItem.FIELDS[:author_idx] + BaseReplyItem.FIELDS[author_idx + 1 :] diff --git a/exchangelib/items/contact.py b/exchangelib/items/contact.py index b0d7c038..bacb1440 100644 --- a/exchangelib/items/contact.py +++ b/exchangelib/items/contact.py @@ -1,16 +1,38 @@ import datetime import logging -from .item import Item -from ..fields import BooleanField, Base64Field, TextField, ChoiceField, URIField, DateTimeBackedDateField, \ - PhoneNumberField, EmailAddressesField, PhysicalAddressField, Choice, MemberListField, CharField, TextListField, \ - EmailAddressField, IdElementField, EWSElementField, DateTimeField, EWSElementListField, \ - BodyContentAttributedValueField, StringAttributedValueField, PhoneNumberAttributedValueField, \ - PersonaPhoneNumberField, EmailAddressAttributedValueField, PostalAddressAttributedValueField, MailboxField, \ - MailboxListField -from ..properties import PersonaId, IdChangeKeyMixIn, CompleteName, Attribution, EmailAddress, Address, FolderId +from ..fields import ( + Base64Field, + BodyContentAttributedValueField, + BooleanField, + CharField, + Choice, + ChoiceField, + DateTimeBackedDateField, + DateTimeField, + EmailAddressAttributedValueField, + EmailAddressesField, + EmailAddressField, + EWSElementField, + EWSElementListField, + IdElementField, + MailboxField, + MailboxListField, + MemberListField, + PersonaPhoneNumberField, + PhoneNumberAttributedValueField, + PhoneNumberField, + PhysicalAddressField, + PostalAddressAttributedValueField, + StringAttributedValueField, + TextField, + TextListField, + URIField, +) +from ..properties import Address, Attribution, CompleteName, EmailAddress, FolderId, IdChangeKeyMixIn, PersonaId from ..util import TNS from ..version import EXCHANGE_2010, EXCHANGE_2010_SP2 +from .item import Item log = logging.getLogger(__name__) @@ -18,190 +40,215 @@ class Contact(Item): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/contact""" - ELEMENT_NAME = 'Contact' + ELEMENT_NAME = "Contact" - file_as = TextField(field_uri='contacts:FileAs') - file_as_mapping = ChoiceField(field_uri='contacts:FileAsMapping', choices={ - Choice('None'), Choice('LastCommaFirst'), Choice('FirstSpaceLast'), Choice('Company'), - Choice('LastCommaFirstCompany'), Choice('CompanyLastFirst'), Choice('LastFirst'), - Choice('LastFirstCompany'), Choice('CompanyLastCommaFirst'), Choice('LastFirstSuffix'), - Choice('LastSpaceFirstCompany'), Choice('CompanyLastSpaceFirst'), Choice('LastSpaceFirst'), - Choice('DisplayName'), Choice('FirstName'), Choice('LastFirstMiddleSuffix'), Choice('LastName'), - Choice('Empty'), - }) - display_name = TextField(field_uri='contacts:DisplayName', is_required=True) - given_name = CharField(field_uri='contacts:GivenName') - initials = TextField(field_uri='contacts:Initials') - middle_name = CharField(field_uri='contacts:MiddleName') - nickname = TextField(field_uri='contacts:Nickname') - complete_name = EWSElementField(field_uri='contacts:CompleteName', value_cls=CompleteName, is_read_only=True) - company_name = TextField(field_uri='contacts:CompanyName') - email_addresses = EmailAddressesField(field_uri='contacts:EmailAddress') - physical_addresses = PhysicalAddressField(field_uri='contacts:PhysicalAddress') - phone_numbers = PhoneNumberField(field_uri='contacts:PhoneNumber') - assistant_name = TextField(field_uri='contacts:AssistantName') - birthday = DateTimeBackedDateField(field_uri='contacts:Birthday', default_time=datetime.time(11, 59)) - business_homepage = URIField(field_uri='contacts:BusinessHomePage') - children = TextListField(field_uri='contacts:Children') - companies = TextListField(field_uri='contacts:Companies', is_searchable=False) - contact_source = ChoiceField(field_uri='contacts:ContactSource', choices={ - Choice('Store'), Choice('ActiveDirectory') - }, is_read_only=True) - department = TextField(field_uri='contacts:Department') - generation = TextField(field_uri='contacts:Generation') - im_addresses = CharField(field_uri='contacts:ImAddresses', is_read_only=True) - job_title = TextField(field_uri='contacts:JobTitle') - manager = TextField(field_uri='contacts:Manager') - mileage = TextField(field_uri='contacts:Mileage') - office = TextField(field_uri='contacts:OfficeLocation') - postal_address_index = ChoiceField(field_uri='contacts:PostalAddressIndex', choices={ - Choice('Business'), Choice('Home'), Choice('Other'), Choice('None') - }, default='None', is_required_after_save=True) - profession = TextField(field_uri='contacts:Profession') - spouse_name = TextField(field_uri='contacts:SpouseName') - surname = CharField(field_uri='contacts:Surname') - wedding_anniversary = DateTimeBackedDateField(field_uri='contacts:WeddingAnniversary', - default_time=datetime.time(11, 59)) - has_picture = BooleanField(field_uri='contacts:HasPicture', supported_from=EXCHANGE_2010, is_read_only=True) - phonetic_full_name = TextField(field_uri='contacts:PhoneticFullName', supported_from=EXCHANGE_2010_SP2, - is_read_only=True) - phonetic_first_name = TextField(field_uri='contacts:PhoneticFirstName', supported_from=EXCHANGE_2010_SP2, - is_read_only=True) - phonetic_last_name = TextField(field_uri='contacts:PhoneticLastName', supported_from=EXCHANGE_2010_SP2, - is_read_only=True) - email_alias = EmailAddressField(field_uri='contacts:Alias', is_read_only=True, - supported_from=EXCHANGE_2010_SP2) + file_as = TextField(field_uri="contacts:FileAs") + file_as_mapping = ChoiceField( + field_uri="contacts:FileAsMapping", + choices={ + Choice("None"), + Choice("LastCommaFirst"), + Choice("FirstSpaceLast"), + Choice("Company"), + Choice("LastCommaFirstCompany"), + Choice("CompanyLastFirst"), + Choice("LastFirst"), + Choice("LastFirstCompany"), + Choice("CompanyLastCommaFirst"), + Choice("LastFirstSuffix"), + Choice("LastSpaceFirstCompany"), + Choice("CompanyLastSpaceFirst"), + Choice("LastSpaceFirst"), + Choice("DisplayName"), + Choice("FirstName"), + Choice("LastFirstMiddleSuffix"), + Choice("LastName"), + Choice("Empty"), + }, + ) + display_name = TextField(field_uri="contacts:DisplayName", is_required=True) + given_name = CharField(field_uri="contacts:GivenName") + initials = TextField(field_uri="contacts:Initials") + middle_name = CharField(field_uri="contacts:MiddleName") + nickname = TextField(field_uri="contacts:Nickname") + complete_name = EWSElementField(field_uri="contacts:CompleteName", value_cls=CompleteName, is_read_only=True) + company_name = TextField(field_uri="contacts:CompanyName") + email_addresses = EmailAddressesField(field_uri="contacts:EmailAddress") + physical_addresses = PhysicalAddressField(field_uri="contacts:PhysicalAddress") + phone_numbers = PhoneNumberField(field_uri="contacts:PhoneNumber") + assistant_name = TextField(field_uri="contacts:AssistantName") + birthday = DateTimeBackedDateField(field_uri="contacts:Birthday", default_time=datetime.time(11, 59)) + business_homepage = URIField(field_uri="contacts:BusinessHomePage") + children = TextListField(field_uri="contacts:Children") + companies = TextListField(field_uri="contacts:Companies", is_searchable=False) + contact_source = ChoiceField( + field_uri="contacts:ContactSource", choices={Choice("Store"), Choice("ActiveDirectory")}, is_read_only=True + ) + department = TextField(field_uri="contacts:Department") + generation = TextField(field_uri="contacts:Generation") + im_addresses = CharField(field_uri="contacts:ImAddresses", is_read_only=True) + job_title = TextField(field_uri="contacts:JobTitle") + manager = TextField(field_uri="contacts:Manager") + mileage = TextField(field_uri="contacts:Mileage") + office = TextField(field_uri="contacts:OfficeLocation") + postal_address_index = ChoiceField( + field_uri="contacts:PostalAddressIndex", + choices={Choice("Business"), Choice("Home"), Choice("Other"), Choice("None")}, + default="None", + is_required_after_save=True, + ) + profession = TextField(field_uri="contacts:Profession") + spouse_name = TextField(field_uri="contacts:SpouseName") + surname = CharField(field_uri="contacts:Surname") + wedding_anniversary = DateTimeBackedDateField( + field_uri="contacts:WeddingAnniversary", default_time=datetime.time(11, 59) + ) + has_picture = BooleanField(field_uri="contacts:HasPicture", supported_from=EXCHANGE_2010, is_read_only=True) + phonetic_full_name = TextField( + field_uri="contacts:PhoneticFullName", supported_from=EXCHANGE_2010_SP2, is_read_only=True + ) + phonetic_first_name = TextField( + field_uri="contacts:PhoneticFirstName", supported_from=EXCHANGE_2010_SP2, is_read_only=True + ) + phonetic_last_name = TextField( + field_uri="contacts:PhoneticLastName", supported_from=EXCHANGE_2010_SP2, is_read_only=True + ) + email_alias = EmailAddressField(field_uri="contacts:Alias", is_read_only=True, supported_from=EXCHANGE_2010_SP2) # 'notes' is documented in MSDN but apparently unused. Writing to it raises ErrorInvalidPropertyRequest. OWA # put entries into the 'notes' form field into the 'body' field. - notes = CharField(field_uri='contacts:Notes', supported_from=EXCHANGE_2010_SP2, is_read_only=True) + notes = CharField(field_uri="contacts:Notes", supported_from=EXCHANGE_2010_SP2, is_read_only=True) # 'photo' is documented in MSDN but apparently unused. Writing to it raises ErrorInvalidPropertyRequest. OWA # adds photos as FileAttachments on the contact item (with 'is_contact_photo=True'), which automatically flips # the 'has_picture' field. - photo = Base64Field(field_uri='contacts:Photo', supported_from=EXCHANGE_2010_SP2, is_read_only=True) - user_smime_certificate = Base64Field(field_uri='contacts:UserSMIMECertificate', supported_from=EXCHANGE_2010_SP2, - is_read_only=True) - ms_exchange_certificate = Base64Field(field_uri='contacts:MSExchangeCertificate', supported_from=EXCHANGE_2010_SP2, - is_read_only=True) - directory_id = TextField(field_uri='contacts:DirectoryId', supported_from=EXCHANGE_2010_SP2, is_read_only=True) - manager_mailbox = MailboxField(field_uri='contacts:ManagerMailbox', supported_from=EXCHANGE_2010_SP2, - is_read_only=True) - direct_reports = MailboxListField(field_uri='contacts:DirectReports', supported_from=EXCHANGE_2010_SP2, - is_read_only=True) + photo = Base64Field(field_uri="contacts:Photo", supported_from=EXCHANGE_2010_SP2, is_read_only=True) + user_smime_certificate = Base64Field( + field_uri="contacts:UserSMIMECertificate", supported_from=EXCHANGE_2010_SP2, is_read_only=True + ) + ms_exchange_certificate = Base64Field( + field_uri="contacts:MSExchangeCertificate", supported_from=EXCHANGE_2010_SP2, is_read_only=True + ) + directory_id = TextField(field_uri="contacts:DirectoryId", supported_from=EXCHANGE_2010_SP2, is_read_only=True) + manager_mailbox = MailboxField( + field_uri="contacts:ManagerMailbox", supported_from=EXCHANGE_2010_SP2, is_read_only=True + ) + direct_reports = MailboxListField( + field_uri="contacts:DirectReports", supported_from=EXCHANGE_2010_SP2, is_read_only=True + ) class Persona(IdChangeKeyMixIn): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/persona""" - ELEMENT_NAME = 'Persona' + ELEMENT_NAME = "Persona" ID_ELEMENT_CLS = PersonaId - _id = IdElementField(field_uri='persona:PersonaId', value_cls=ID_ELEMENT_CLS, namespace=TNS) - persona_type = CharField(field_uri='persona:PersonaType') - persona_object_type = TextField(field_uri='persona:PersonaObjectStatus') - creation_time = DateTimeField(field_uri='persona:CreationTime') - bodies = BodyContentAttributedValueField(field_uri='persona:Bodies') - display_name_first_last_sort_key = TextField(field_uri='persona:DisplayNameFirstLastSortKey') - display_name_last_first_sort_key = TextField(field_uri='persona:DisplayNameLastFirstSortKey') - company_sort_key = TextField(field_uri='persona:CompanyNameSortKey') - home_sort_key = TextField(field_uri='persona:HomeCitySortKey') - work_city_sort_key = TextField(field_uri='persona:WorkCitySortKey') - display_name_first_last_header = CharField(field_uri='persona:DisplayNameFirstLastHeader') - display_name_last_first_header = CharField(field_uri='persona:DisplayNameLastFirstHeader') - file_as_header = TextField(field_uri='persona:FileAsHeader') - display_name = CharField(field_uri='persona:DisplayName') - display_name_first_last = CharField(field_uri='persona:DisplayNameFirstLast') - display_name_last_first = CharField(field_uri='persona:DisplayNameLastFirst') - file_as = CharField(field_uri='persona:FileAs') - file_as_id = TextField(field_uri='persona:FileAsId') - display_name_prefix = CharField(field_uri='persona:DisplayNamePrefix') - given_name = CharField(field_uri='persona:GivenName') - middle_name = CharField(field_uri='persona:MiddleName') - surname = CharField(field_uri='persona:Surname') - generation = CharField(field_uri='persona:Generation') - nickname = TextField(field_uri='persona:Nickname') - yomi_company_name = TextField(field_uri='persona:YomiCompanyName') - yomi_first_name = TextField(field_uri='persona:YomiFirstName') - yomi_last_name = TextField(field_uri='persona:YomiLastName') - title = CharField(field_uri='persona:Title') - department = TextField(field_uri='persona:Department') - company_name = CharField(field_uri='persona:CompanyName') - email_address = EWSElementField(field_uri='persona:EmailAddress', value_cls=EmailAddress) - email_addresses = EWSElementListField(field_uri='persona:EmailAddresses', value_cls=Address) - PhoneNumber = PersonaPhoneNumberField(field_uri='persona:PhoneNumber') - im_address = CharField(field_uri='persona:ImAddress') - home_city = CharField(field_uri='persona:HomeCity') - work_city = CharField(field_uri='persona:WorkCity') - relevance_score = CharField(field_uri='persona:RelevanceScore') - folder_ids = EWSElementListField(field_uri='persona:FolderIds', value_cls=FolderId) - attributions = EWSElementListField(field_uri='persona:Attributions', value_cls=Attribution) - display_names = StringAttributedValueField(field_uri='persona:DisplayNames') - file_ases = StringAttributedValueField(field_uri='persona:FileAses') - file_as_ids = StringAttributedValueField(field_uri='persona:FileAsIds') - display_name_prefixes = StringAttributedValueField(field_uri='persona:DisplayNamePrefixes') - given_names = StringAttributedValueField(field_uri='persona:GivenNames') - middle_names = StringAttributedValueField(field_uri='persona:MiddleNames') - surnames = StringAttributedValueField(field_uri='persona:Surnames') - generations = StringAttributedValueField(field_uri='persona:Generations') - nicknames = StringAttributedValueField(field_uri='persona:Nicknames') - initials = StringAttributedValueField(field_uri='persona:Initials') - yomi_company_names = StringAttributedValueField(field_uri='persona:YomiCompanyNames') - yomi_first_names = StringAttributedValueField(field_uri='persona:YomiFirstNames') - yomi_last_names = StringAttributedValueField(field_uri='persona:YomiLastNames') - business_phone_numbers = PhoneNumberAttributedValueField(field_uri='persona:BusinessPhoneNumbers') - business_phone_numbers2 = PhoneNumberAttributedValueField(field_uri='persona:BusinessPhoneNumbers2') - home_phones = PhoneNumberAttributedValueField(field_uri='persona:HomePhones') - home_phones2 = PhoneNumberAttributedValueField(field_uri='persona:HomePhones2') - mobile_phones = PhoneNumberAttributedValueField(field_uri='persona:MobilePhones') - mobile_phones2 = PhoneNumberAttributedValueField(field_uri='persona:MobilePhones2') - assistant_phone_numbers = PhoneNumberAttributedValueField(field_uri='persona:AssistantPhoneNumbers') - callback_phones = PhoneNumberAttributedValueField(field_uri='persona:CallbackPhones') - car_phones = PhoneNumberAttributedValueField(field_uri='persona:CarPhones') - home_faxes = PhoneNumberAttributedValueField(field_uri='persona:HomeFaxes') - organization_main_phones = PhoneNumberAttributedValueField(field_uri='persona:OrganizationMainPhones') - other_faxes = PhoneNumberAttributedValueField(field_uri='persona:OtherFaxes') - other_telephones = PhoneNumberAttributedValueField(field_uri='persona:OtherTelephones') - other_phones2 = PhoneNumberAttributedValueField(field_uri='persona:OtherPhones2') - pagers = PhoneNumberAttributedValueField(field_uri='persona:Pagers') - radio_phones = PhoneNumberAttributedValueField(field_uri='persona:RadioPhones') - telex_numbers = PhoneNumberAttributedValueField(field_uri='persona:TelexNumbers') - tty_tdd_phone_numbers = PhoneNumberAttributedValueField(field_uri='persona:TTYTDDPhoneNumbers') - work_faxes = PhoneNumberAttributedValueField(field_uri='persona:WorkFaxes') - emails1 = EmailAddressAttributedValueField(field_uri='persona:Emails1') - emails2 = EmailAddressAttributedValueField(field_uri='persona:Emails2') - emails3 = EmailAddressAttributedValueField(field_uri='persona:Emails3') - business_home_pages = StringAttributedValueField(field_uri='persona:BusinessHomePages') - personal_home_pages = StringAttributedValueField(field_uri='persona:PersonalHomePages') - office_locations = StringAttributedValueField(field_uri='persona:OfficeLocations') - im_addresses = StringAttributedValueField(field_uri='persona:ImAddresses') - im_addresses2 = StringAttributedValueField(field_uri='persona:ImAddresses2') - im_addresses3 = StringAttributedValueField(field_uri='persona:ImAddresses3') - business_addresses = PostalAddressAttributedValueField(field_uri='persona:BusinessAddresses') - home_addresses = PostalAddressAttributedValueField(field_uri='persona:HomeAddresses') - other_addresses = PostalAddressAttributedValueField(field_uri='persona:OtherAddresses') - titles = StringAttributedValueField(field_uri='persona:Titles') - departments = StringAttributedValueField(field_uri='persona:Departments') - company_names = StringAttributedValueField(field_uri='persona:CompanyNames') - managers = StringAttributedValueField(field_uri='persona:Managers') - assistant_names = StringAttributedValueField(field_uri='persona:AssistantNames') - professions = StringAttributedValueField(field_uri='persona:Professions') - spouse_names = StringAttributedValueField(field_uri='persona:SpouseNames') - children = StringAttributedValueField(field_uri='persona:Children') - schools = StringAttributedValueField(field_uri='persona:Schools') - hobbies = StringAttributedValueField(field_uri='persona:Hobbies') - wedding_anniversaries = StringAttributedValueField(field_uri='persona:WeddingAnniversaries') - birthdays = StringAttributedValueField(field_uri='persona:Birthdays') - locations = StringAttributedValueField(field_uri='persona:Locations') + _id = IdElementField(field_uri="persona:PersonaId", value_cls=ID_ELEMENT_CLS, namespace=TNS) + persona_type = CharField(field_uri="persona:PersonaType") + persona_object_type = TextField(field_uri="persona:PersonaObjectStatus") + creation_time = DateTimeField(field_uri="persona:CreationTime") + bodies = BodyContentAttributedValueField(field_uri="persona:Bodies") + display_name_first_last_sort_key = TextField(field_uri="persona:DisplayNameFirstLastSortKey") + display_name_last_first_sort_key = TextField(field_uri="persona:DisplayNameLastFirstSortKey") + company_sort_key = TextField(field_uri="persona:CompanyNameSortKey") + home_sort_key = TextField(field_uri="persona:HomeCitySortKey") + work_city_sort_key = TextField(field_uri="persona:WorkCitySortKey") + display_name_first_last_header = CharField(field_uri="persona:DisplayNameFirstLastHeader") + display_name_last_first_header = CharField(field_uri="persona:DisplayNameLastFirstHeader") + file_as_header = TextField(field_uri="persona:FileAsHeader") + display_name = CharField(field_uri="persona:DisplayName") + display_name_first_last = CharField(field_uri="persona:DisplayNameFirstLast") + display_name_last_first = CharField(field_uri="persona:DisplayNameLastFirst") + file_as = CharField(field_uri="persona:FileAs") + file_as_id = TextField(field_uri="persona:FileAsId") + display_name_prefix = CharField(field_uri="persona:DisplayNamePrefix") + given_name = CharField(field_uri="persona:GivenName") + middle_name = CharField(field_uri="persona:MiddleName") + surname = CharField(field_uri="persona:Surname") + generation = CharField(field_uri="persona:Generation") + nickname = TextField(field_uri="persona:Nickname") + yomi_company_name = TextField(field_uri="persona:YomiCompanyName") + yomi_first_name = TextField(field_uri="persona:YomiFirstName") + yomi_last_name = TextField(field_uri="persona:YomiLastName") + title = CharField(field_uri="persona:Title") + department = TextField(field_uri="persona:Department") + company_name = CharField(field_uri="persona:CompanyName") + email_address = EWSElementField(field_uri="persona:EmailAddress", value_cls=EmailAddress) + email_addresses = EWSElementListField(field_uri="persona:EmailAddresses", value_cls=Address) + PhoneNumber = PersonaPhoneNumberField(field_uri="persona:PhoneNumber") + im_address = CharField(field_uri="persona:ImAddress") + home_city = CharField(field_uri="persona:HomeCity") + work_city = CharField(field_uri="persona:WorkCity") + relevance_score = CharField(field_uri="persona:RelevanceScore") + folder_ids = EWSElementListField(field_uri="persona:FolderIds", value_cls=FolderId) + attributions = EWSElementListField(field_uri="persona:Attributions", value_cls=Attribution) + display_names = StringAttributedValueField(field_uri="persona:DisplayNames") + file_ases = StringAttributedValueField(field_uri="persona:FileAses") + file_as_ids = StringAttributedValueField(field_uri="persona:FileAsIds") + display_name_prefixes = StringAttributedValueField(field_uri="persona:DisplayNamePrefixes") + given_names = StringAttributedValueField(field_uri="persona:GivenNames") + middle_names = StringAttributedValueField(field_uri="persona:MiddleNames") + surnames = StringAttributedValueField(field_uri="persona:Surnames") + generations = StringAttributedValueField(field_uri="persona:Generations") + nicknames = StringAttributedValueField(field_uri="persona:Nicknames") + initials = StringAttributedValueField(field_uri="persona:Initials") + yomi_company_names = StringAttributedValueField(field_uri="persona:YomiCompanyNames") + yomi_first_names = StringAttributedValueField(field_uri="persona:YomiFirstNames") + yomi_last_names = StringAttributedValueField(field_uri="persona:YomiLastNames") + business_phone_numbers = PhoneNumberAttributedValueField(field_uri="persona:BusinessPhoneNumbers") + business_phone_numbers2 = PhoneNumberAttributedValueField(field_uri="persona:BusinessPhoneNumbers2") + home_phones = PhoneNumberAttributedValueField(field_uri="persona:HomePhones") + home_phones2 = PhoneNumberAttributedValueField(field_uri="persona:HomePhones2") + mobile_phones = PhoneNumberAttributedValueField(field_uri="persona:MobilePhones") + mobile_phones2 = PhoneNumberAttributedValueField(field_uri="persona:MobilePhones2") + assistant_phone_numbers = PhoneNumberAttributedValueField(field_uri="persona:AssistantPhoneNumbers") + callback_phones = PhoneNumberAttributedValueField(field_uri="persona:CallbackPhones") + car_phones = PhoneNumberAttributedValueField(field_uri="persona:CarPhones") + home_faxes = PhoneNumberAttributedValueField(field_uri="persona:HomeFaxes") + organization_main_phones = PhoneNumberAttributedValueField(field_uri="persona:OrganizationMainPhones") + other_faxes = PhoneNumberAttributedValueField(field_uri="persona:OtherFaxes") + other_telephones = PhoneNumberAttributedValueField(field_uri="persona:OtherTelephones") + other_phones2 = PhoneNumberAttributedValueField(field_uri="persona:OtherPhones2") + pagers = PhoneNumberAttributedValueField(field_uri="persona:Pagers") + radio_phones = PhoneNumberAttributedValueField(field_uri="persona:RadioPhones") + telex_numbers = PhoneNumberAttributedValueField(field_uri="persona:TelexNumbers") + tty_tdd_phone_numbers = PhoneNumberAttributedValueField(field_uri="persona:TTYTDDPhoneNumbers") + work_faxes = PhoneNumberAttributedValueField(field_uri="persona:WorkFaxes") + emails1 = EmailAddressAttributedValueField(field_uri="persona:Emails1") + emails2 = EmailAddressAttributedValueField(field_uri="persona:Emails2") + emails3 = EmailAddressAttributedValueField(field_uri="persona:Emails3") + business_home_pages = StringAttributedValueField(field_uri="persona:BusinessHomePages") + personal_home_pages = StringAttributedValueField(field_uri="persona:PersonalHomePages") + office_locations = StringAttributedValueField(field_uri="persona:OfficeLocations") + im_addresses = StringAttributedValueField(field_uri="persona:ImAddresses") + im_addresses2 = StringAttributedValueField(field_uri="persona:ImAddresses2") + im_addresses3 = StringAttributedValueField(field_uri="persona:ImAddresses3") + business_addresses = PostalAddressAttributedValueField(field_uri="persona:BusinessAddresses") + home_addresses = PostalAddressAttributedValueField(field_uri="persona:HomeAddresses") + other_addresses = PostalAddressAttributedValueField(field_uri="persona:OtherAddresses") + titles = StringAttributedValueField(field_uri="persona:Titles") + departments = StringAttributedValueField(field_uri="persona:Departments") + company_names = StringAttributedValueField(field_uri="persona:CompanyNames") + managers = StringAttributedValueField(field_uri="persona:Managers") + assistant_names = StringAttributedValueField(field_uri="persona:AssistantNames") + professions = StringAttributedValueField(field_uri="persona:Professions") + spouse_names = StringAttributedValueField(field_uri="persona:SpouseNames") + children = StringAttributedValueField(field_uri="persona:Children") + schools = StringAttributedValueField(field_uri="persona:Schools") + hobbies = StringAttributedValueField(field_uri="persona:Hobbies") + wedding_anniversaries = StringAttributedValueField(field_uri="persona:WeddingAnniversaries") + birthdays = StringAttributedValueField(field_uri="persona:Birthdays") + locations = StringAttributedValueField(field_uri="persona:Locations") # ExtendedPropertyAttributedValueField('extended_properties', field_uri='persona:ExtendedProperties') class DistributionList(Item): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/distributionlist""" - ELEMENT_NAME = 'DistributionList' + ELEMENT_NAME = "DistributionList" - display_name = CharField(field_uri='contacts:DisplayName', is_required=True) - file_as = CharField(field_uri='contacts:FileAs', is_read_only=True) - contact_source = ChoiceField(field_uri='contacts:ContactSource', choices={ - Choice('Store'), Choice('ActiveDirectory') - }, is_read_only=True) - members = MemberListField(field_uri='distributionlist:Members') + display_name = CharField(field_uri="contacts:DisplayName", is_required=True) + file_as = CharField(field_uri="contacts:FileAs", is_read_only=True) + contact_source = ChoiceField( + field_uri="contacts:ContactSource", choices={Choice("Store"), Choice("ActiveDirectory")}, is_read_only=True + ) + members = MemberListField(field_uri="distributionlist:Members") diff --git a/exchangelib/items/item.py b/exchangelib/items/item.py index a15850b7..a9c75666 100644 --- a/exchangelib/items/item.py +++ b/exchangelib/items/item.py @@ -1,14 +1,47 @@ import logging -from .base import BaseItem, SAVE_ONLY, SEND_AND_SAVE_COPY, ID_ONLY, SEND_TO_NONE, \ - AUTO_RESOLVE, SOFT_DELETE, HARD_DELETE, ALL_OCCURRENCES, MOVE_TO_DELETED_ITEMS -from ..fields import BooleanField, IntegerField, TextField, CharListField, ChoiceField, URIField, BodyField, \ - DateTimeField, MessageHeaderField, AttachmentField, Choice, EWSElementField, EffectiveRightsField, CultureField, \ - CharField, MimeContentField, FieldPath -from ..properties import ConversationId, ParentFolderId, ReferenceItemId, OccurrenceItemId, RecurringMasterItemId, \ - ResponseObjects, Fields +from ..fields import ( + AttachmentField, + BodyField, + BooleanField, + CharField, + CharListField, + Choice, + ChoiceField, + CultureField, + DateTimeField, + EffectiveRightsField, + EWSElementField, + FieldPath, + IntegerField, + MessageHeaderField, + MimeContentField, + TextField, + URIField, +) +from ..properties import ( + ConversationId, + Fields, + OccurrenceItemId, + ParentFolderId, + RecurringMasterItemId, + ReferenceItemId, + ResponseObjects, +) from ..util import is_iterable, require_account, require_id from ..version import EXCHANGE_2010, EXCHANGE_2013 +from .base import ( + ALL_OCCURRENCES, + AUTO_RESOLVE, + HARD_DELETE, + ID_ONLY, + MOVE_TO_DELETED_ITEMS, + SAVE_ONLY, + SEND_AND_SAVE_COPY, + SEND_TO_NONE, + SOFT_DELETE, + BaseItem, +) log = logging.getLogger(__name__) @@ -16,62 +49,75 @@ class Item(BaseItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/item""" - ELEMENT_NAME = 'Item' - - mime_content = MimeContentField(field_uri='item:MimeContent', is_read_only_after_send=True) - _id = BaseItem.FIELDS['_id'] - parent_folder_id = EWSElementField(field_uri='item:ParentFolderId', value_cls=ParentFolderId, is_read_only=True) - item_class = CharField(field_uri='item:ItemClass', is_read_only=True) - subject = CharField(field_uri='item:Subject') - sensitivity = ChoiceField(field_uri='item:Sensitivity', choices={ - Choice('Normal'), Choice('Personal'), Choice('Private'), Choice('Confidential') - }, is_required=True, default='Normal') - text_body = TextField(field_uri='item:TextBody', is_read_only=True, supported_from=EXCHANGE_2013) - body = BodyField(field_uri='item:Body') # Accepts and returns Body or HTMLBody instances - attachments = AttachmentField(field_uri='item:Attachments') # ItemAttachment or FileAttachment - datetime_received = DateTimeField(field_uri='item:DateTimeReceived', is_read_only=True) - size = IntegerField(field_uri='item:Size', is_read_only=True) # Item size in bytes - categories = CharListField(field_uri='item:Categories') - importance = ChoiceField(field_uri='item:Importance', choices={ - Choice('Low'), Choice('Normal'), Choice('High') - }, is_required=True, default='Normal') - in_reply_to = TextField(field_uri='item:InReplyTo') - is_submitted = BooleanField(field_uri='item:IsSubmitted', is_read_only=True) - is_draft = BooleanField(field_uri='item:IsDraft', is_read_only=True) - is_from_me = BooleanField(field_uri='item:IsFromMe', is_read_only=True) - is_resend = BooleanField(field_uri='item:IsResend', is_read_only=True) - is_unmodified = BooleanField(field_uri='item:IsUnmodified', is_read_only=True) - headers = MessageHeaderField(field_uri='item:InternetMessageHeaders', is_read_only=True) - datetime_sent = DateTimeField(field_uri='item:DateTimeSent', is_read_only=True) - datetime_created = DateTimeField(field_uri='item:DateTimeCreated', is_read_only=True) - response_objects = EWSElementField(field_uri='item:ResponseObjects', value_cls=ResponseObjects, - is_read_only=True,) + ELEMENT_NAME = "Item" + + mime_content = MimeContentField(field_uri="item:MimeContent", is_read_only_after_send=True) + _id = BaseItem.FIELDS["_id"] + parent_folder_id = EWSElementField(field_uri="item:ParentFolderId", value_cls=ParentFolderId, is_read_only=True) + item_class = CharField(field_uri="item:ItemClass", is_read_only=True) + subject = CharField(field_uri="item:Subject") + sensitivity = ChoiceField( + field_uri="item:Sensitivity", + choices={Choice("Normal"), Choice("Personal"), Choice("Private"), Choice("Confidential")}, + is_required=True, + default="Normal", + ) + text_body = TextField(field_uri="item:TextBody", is_read_only=True, supported_from=EXCHANGE_2013) + body = BodyField(field_uri="item:Body") # Accepts and returns Body or HTMLBody instances + attachments = AttachmentField(field_uri="item:Attachments") # ItemAttachment or FileAttachment + datetime_received = DateTimeField(field_uri="item:DateTimeReceived", is_read_only=True) + size = IntegerField(field_uri="item:Size", is_read_only=True) # Item size in bytes + categories = CharListField(field_uri="item:Categories") + importance = ChoiceField( + field_uri="item:Importance", + choices={Choice("Low"), Choice("Normal"), Choice("High")}, + is_required=True, + default="Normal", + ) + in_reply_to = TextField(field_uri="item:InReplyTo") + is_submitted = BooleanField(field_uri="item:IsSubmitted", is_read_only=True) + is_draft = BooleanField(field_uri="item:IsDraft", is_read_only=True) + is_from_me = BooleanField(field_uri="item:IsFromMe", is_read_only=True) + is_resend = BooleanField(field_uri="item:IsResend", is_read_only=True) + is_unmodified = BooleanField(field_uri="item:IsUnmodified", is_read_only=True) + headers = MessageHeaderField(field_uri="item:InternetMessageHeaders", is_read_only=True) + datetime_sent = DateTimeField(field_uri="item:DateTimeSent", is_read_only=True) + datetime_created = DateTimeField(field_uri="item:DateTimeCreated", is_read_only=True) + response_objects = EWSElementField( + field_uri="item:ResponseObjects", + value_cls=ResponseObjects, + is_read_only=True, + ) # Placeholder for ResponseObjects - reminder_due_by = DateTimeField(field_uri='item:ReminderDueBy', is_required_after_save=True, is_searchable=False) - reminder_is_set = BooleanField(field_uri='item:ReminderIsSet', is_required=True, default=False) - reminder_minutes_before_start = IntegerField(field_uri='item:ReminderMinutesBeforeStart', - is_required_after_save=True, min=0, default=0) - display_cc = TextField(field_uri='item:DisplayCc', is_read_only=True) - display_to = TextField(field_uri='item:DisplayTo', is_read_only=True) - has_attachments = BooleanField(field_uri='item:HasAttachments', is_read_only=True) + reminder_due_by = DateTimeField(field_uri="item:ReminderDueBy", is_required_after_save=True, is_searchable=False) + reminder_is_set = BooleanField(field_uri="item:ReminderIsSet", is_required=True, default=False) + reminder_minutes_before_start = IntegerField( + field_uri="item:ReminderMinutesBeforeStart", is_required_after_save=True, min=0, default=0 + ) + display_cc = TextField(field_uri="item:DisplayCc", is_read_only=True) + display_to = TextField(field_uri="item:DisplayTo", is_read_only=True) + has_attachments = BooleanField(field_uri="item:HasAttachments", is_read_only=True) # ExtendedProperty fields go here - culture = CultureField(field_uri='item:Culture', is_required_after_save=True, is_searchable=False) - effective_rights = EffectiveRightsField(field_uri='item:EffectiveRights', is_read_only=True) - last_modified_name = CharField(field_uri='item:LastModifiedName', is_read_only=True) - last_modified_time = DateTimeField(field_uri='item:LastModifiedTime', is_read_only=True) - is_associated = BooleanField(field_uri='item:IsAssociated', is_read_only=True, supported_from=EXCHANGE_2010) - web_client_read_form_query_string = URIField(field_uri='item:WebClientReadFormQueryString', is_read_only=True, - supported_from=EXCHANGE_2010) - web_client_edit_form_query_string = URIField(field_uri='item:WebClientEditFormQueryString', is_read_only=True, - supported_from=EXCHANGE_2010) - conversation_id = EWSElementField(field_uri='item:ConversationId', value_cls=ConversationId, - is_read_only=True, supported_from=EXCHANGE_2010) - unique_body = BodyField(field_uri='item:UniqueBody', is_read_only=True, supported_from=EXCHANGE_2010) + culture = CultureField(field_uri="item:Culture", is_required_after_save=True, is_searchable=False) + effective_rights = EffectiveRightsField(field_uri="item:EffectiveRights", is_read_only=True) + last_modified_name = CharField(field_uri="item:LastModifiedName", is_read_only=True) + last_modified_time = DateTimeField(field_uri="item:LastModifiedTime", is_read_only=True) + is_associated = BooleanField(field_uri="item:IsAssociated", is_read_only=True, supported_from=EXCHANGE_2010) + web_client_read_form_query_string = URIField( + field_uri="item:WebClientReadFormQueryString", is_read_only=True, supported_from=EXCHANGE_2010 + ) + web_client_edit_form_query_string = URIField( + field_uri="item:WebClientEditFormQueryString", is_read_only=True, supported_from=EXCHANGE_2010 + ) + conversation_id = EWSElementField( + field_uri="item:ConversationId", value_cls=ConversationId, is_read_only=True, supported_from=EXCHANGE_2010 + ) + unique_body = BodyField(field_uri="item:UniqueBody", is_read_only=True, supported_from=EXCHANGE_2010) FIELDS = Fields() # Used to register extended properties - INSERT_AFTER_FIELD = 'has_attachments' + INSERT_AFTER_FIELD = "has_attachments" def __init__(self, **kwargs): super().__init__(**kwargs) @@ -88,16 +134,19 @@ def __init__(self, **kwargs): def save(self, update_fields=None, conflict_resolution=AUTO_RESOLVE, send_meeting_invitations=SEND_TO_NONE): from .task import Task + if self.id: item_id, changekey = self._update( update_fieldnames=update_fields, message_disposition=SAVE_ONLY, conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations + send_meeting_invitations=send_meeting_invitations, ) - if self.id != item_id \ - and not isinstance(self._id, (OccurrenceItemId, RecurringMasterItemId)) \ - and not isinstance(self, Task): + if ( + self.id != item_id + and not isinstance(self._id, (OccurrenceItemId, RecurringMasterItemId)) + and not isinstance(self, Task) + ): # When we update an item with an OccurrenceItemId as ID, EWS returns the ID of the occurrence, so # the ID of this item changes. # @@ -132,6 +181,7 @@ def _create(self, message_disposition, send_meeting_invitations): # Return a BulkCreateResult because we want to return the ID of both the main item *and* attachments. In send # and send-and-save-copy mode, the server does not return an ID, so we just return True. from ..services import CreateItem + return CreateItem(account=self.account).get( items=[self], folder=self.folder, @@ -141,27 +191,28 @@ def _create(self, message_disposition, send_meeting_invitations): def _update_fieldnames(self): from .contact import Contact, DistributionList + # Return the list of fields we are allowed to update update_fieldnames = [] for f in self.supported_fields(version=self.account.version): - if f.name == 'attachments': + if f.name == "attachments": # Attachments are handled separately after item creation continue if f.is_read_only: # These cannot be changed continue if (f.is_required or f.is_required_after_save) and ( - getattr(self, f.name) is None or (f.is_list and not getattr(self, f.name)) + getattr(self, f.name) is None or (f.is_list and not getattr(self, f.name)) ): # These are required and cannot be deleted continue if not self.is_draft and f.is_read_only_after_send: # These cannot be changed when the item is no longer a draft continue - if f.name == 'message_id' and f.is_read_only_after_send: + if f.name == "message_id" and f.is_read_only_after_send: # 'message_id' doesn't support updating, no matter the draft status continue - if f.name == 'mime_content' and isinstance(self, (Contact, DistributionList)): + if f.name == "mime_content" and isinstance(self, (Contact, DistributionList)): # Contact and DistributionList don't support updating mime_content, no matter the draft status continue update_fieldnames.append(f.name) @@ -170,8 +221,9 @@ def _update_fieldnames(self): @require_account def _update(self, update_fieldnames, message_disposition, conflict_resolution, send_meeting_invitations): from ..services import UpdateItem + if not self.changekey: - raise ValueError(f'{self.__class__.__name__} must have changekey') + raise ValueError(f"{self.__class__.__name__} must have changekey") if not update_fieldnames: # The fields to update was not specified explicitly. Update all fields where update is possible update_fieldnames = self._update_fieldnames() @@ -189,6 +241,7 @@ def refresh(self): # Updates the item based on fresh data from EWS from ..folders import Folder from ..services import GetItem + additional_fields = { FieldPath(field=f) for f in Folder(root=self.account.root).allowed_item_fields(version=self.account.version) } @@ -207,6 +260,7 @@ def refresh(self): @require_id def copy(self, to_folder): from ..services import CopyItem + # If 'to_folder' is a public folder or a folder in a different mailbox then None is returned return CopyItem(account=self.account).get( items=[self], @@ -217,6 +271,7 @@ def copy(self, to_folder): @require_id def move(self, to_folder): from ..services import MoveItem + res = MoveItem(account=self.account).get( items=[self], to_folder=to_folder, @@ -229,32 +284,57 @@ def move(self, to_folder): self._id = self.ID_ELEMENT_CLS(*res) self.folder = to_folder - def move_to_trash(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, - suppress_read_receipts=True): + def move_to_trash( + self, + send_meeting_cancellations=SEND_TO_NONE, + affected_task_occurrences=ALL_OCCURRENCES, + suppress_read_receipts=True, + ): # Delete and move to the trash folder. - self._delete(delete_type=MOVE_TO_DELETED_ITEMS, send_meeting_cancellations=send_meeting_cancellations, - affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts) + self._delete( + delete_type=MOVE_TO_DELETED_ITEMS, + send_meeting_cancellations=send_meeting_cancellations, + affected_task_occurrences=affected_task_occurrences, + suppress_read_receipts=suppress_read_receipts, + ) self._id = None self.folder = self.account.trash - def soft_delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, - suppress_read_receipts=True): + def soft_delete( + self, + send_meeting_cancellations=SEND_TO_NONE, + affected_task_occurrences=ALL_OCCURRENCES, + suppress_read_receipts=True, + ): # Delete and move to the dumpster, if it is enabled. - self._delete(delete_type=SOFT_DELETE, send_meeting_cancellations=send_meeting_cancellations, - affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts) + self._delete( + delete_type=SOFT_DELETE, + send_meeting_cancellations=send_meeting_cancellations, + affected_task_occurrences=affected_task_occurrences, + suppress_read_receipts=suppress_read_receipts, + ) self._id = None self.folder = self.account.recoverable_items_deletions - def delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, - suppress_read_receipts=True): + def delete( + self, + send_meeting_cancellations=SEND_TO_NONE, + affected_task_occurrences=ALL_OCCURRENCES, + suppress_read_receipts=True, + ): # Remove the item permanently. No copies are stored anywhere. - self._delete(delete_type=HARD_DELETE, send_meeting_cancellations=send_meeting_cancellations, - affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts) + self._delete( + delete_type=HARD_DELETE, + send_meeting_cancellations=send_meeting_cancellations, + affected_task_occurrences=affected_task_occurrences, + suppress_read_receipts=suppress_read_receipts, + ) self._id, self.folder = None, None @require_id def _delete(self, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): from ..services import DeleteItem + DeleteItem(account=self.account).get( items=[self], delete_type=delete_type, @@ -266,6 +346,7 @@ def _delete(self, delete_type, send_meeting_cancellations, affected_task_occurre @require_id def archive(self, to_folder): from ..services import ArchiveItem + return ArchiveItem(account=self.account).get(items=[self], to_folder=to_folder, expect_result=True) def attach(self, attachments): @@ -304,7 +385,7 @@ def detach(self, attachments): attachments = list(attachments) for a in attachments: if a.parent_item is not self: - raise ValueError('Attachment does not belong to this item') + raise ValueError("Attachment does not belong to this item") if self.id: # Item is already created. Detach the attachment server-side now a.detach() @@ -314,6 +395,7 @@ def detach(self, attachments): @require_id def create_forward(self, subject, body, to_recipients, cc_recipients=None, bcc_recipients=None): from .message import ForwardItem + return ForwardItem( account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), diff --git a/exchangelib/items/message.py b/exchangelib/items/message.py index a2a5b537..e31956a2 100644 --- a/exchangelib/items/message.py +++ b/exchangelib/items/message.py @@ -1,11 +1,11 @@ import logging -from .base import BaseReplyItem, AUTO_RESOLVE, SEND_TO_NONE, SEND_ONLY, SEND_AND_SAVE_COPY -from .item import Item -from ..fields import BooleanField, Base64Field, TextField, MailboxField, MailboxListField, CharField, EWSElementField +from ..fields import Base64Field, BooleanField, CharField, EWSElementField, MailboxField, MailboxListField, TextField from ..properties import ReferenceItemId, ReminderMessageData from ..util import require_account, require_id from ..version import EXCHANGE_2013, EXCHANGE_2013_SP1 +from .base import AUTO_RESOLVE, SEND_AND_SAVE_COPY, SEND_ONLY, SEND_TO_NONE, BaseReplyItem +from .item import Item log = logging.getLogger(__name__) @@ -15,37 +15,52 @@ class Message(Item): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/message-ex15websvcsotherref """ - ELEMENT_NAME = 'Message' - - sender = MailboxField(field_uri='message:Sender', is_read_only=True, is_read_only_after_send=True) - to_recipients = MailboxListField(field_uri='message:ToRecipients', is_read_only_after_send=True, - is_searchable=False) - cc_recipients = MailboxListField(field_uri='message:CcRecipients', is_read_only_after_send=True, - is_searchable=False) - bcc_recipients = MailboxListField(field_uri='message:BccRecipients', is_read_only_after_send=True, - is_searchable=False) - is_read_receipt_requested = BooleanField(field_uri='message:IsReadReceiptRequested', - is_required=True, default=False, is_read_only_after_send=True) - is_delivery_receipt_requested = BooleanField(field_uri='message:IsDeliveryReceiptRequested', is_required=True, - default=False, is_read_only_after_send=True) - conversation_index = Base64Field(field_uri='message:ConversationIndex', is_read_only=True) - conversation_topic = CharField(field_uri='message:ConversationTopic', is_read_only=True) + ELEMENT_NAME = "Message" + + sender = MailboxField(field_uri="message:Sender", is_read_only=True, is_read_only_after_send=True) + to_recipients = MailboxListField( + field_uri="message:ToRecipients", is_read_only_after_send=True, is_searchable=False + ) + cc_recipients = MailboxListField( + field_uri="message:CcRecipients", is_read_only_after_send=True, is_searchable=False + ) + bcc_recipients = MailboxListField( + field_uri="message:BccRecipients", is_read_only_after_send=True, is_searchable=False + ) + is_read_receipt_requested = BooleanField( + field_uri="message:IsReadReceiptRequested", is_required=True, default=False, is_read_only_after_send=True + ) + is_delivery_receipt_requested = BooleanField( + field_uri="message:IsDeliveryReceiptRequested", is_required=True, default=False, is_read_only_after_send=True + ) + conversation_index = Base64Field(field_uri="message:ConversationIndex", is_read_only=True) + conversation_topic = CharField(field_uri="message:ConversationTopic", is_read_only=True) # Rename 'From' to 'author'. We can't use fieldname 'from' since it's a Python keyword. - author = MailboxField(field_uri='message:From', is_read_only_after_send=True) - message_id = CharField(field_uri='message:InternetMessageId', is_read_only_after_send=True) - is_read = BooleanField(field_uri='message:IsRead', is_required=True, default=False) - is_response_requested = BooleanField(field_uri='message:IsResponseRequested', default=False, is_required=True) - references = TextField(field_uri='message:References') - reply_to = MailboxListField(field_uri='message:ReplyTo', is_read_only_after_send=True, is_searchable=False) - received_by = MailboxField(field_uri='message:ReceivedBy', is_read_only=True) - received_representing = MailboxField(field_uri='message:ReceivedRepresenting', is_read_only=True) - reminder_message_data = EWSElementField(field_uri='message:ReminderMessageData', value_cls=ReminderMessageData, - supported_from=EXCHANGE_2013_SP1, is_read_only=True) + author = MailboxField(field_uri="message:From", is_read_only_after_send=True) + message_id = CharField(field_uri="message:InternetMessageId", is_read_only_after_send=True) + is_read = BooleanField(field_uri="message:IsRead", is_required=True, default=False) + is_response_requested = BooleanField(field_uri="message:IsResponseRequested", default=False, is_required=True) + references = TextField(field_uri="message:References") + reply_to = MailboxListField(field_uri="message:ReplyTo", is_read_only_after_send=True, is_searchable=False) + received_by = MailboxField(field_uri="message:ReceivedBy", is_read_only=True) + received_representing = MailboxField(field_uri="message:ReceivedRepresenting", is_read_only=True) + reminder_message_data = EWSElementField( + field_uri="message:ReminderMessageData", + value_cls=ReminderMessageData, + supported_from=EXCHANGE_2013_SP1, + is_read_only=True, + ) @require_account - def send(self, save_copy=True, copy_to_folder=None, conflict_resolution=AUTO_RESOLVE, - send_meeting_invitations=SEND_TO_NONE): + def send( + self, + save_copy=True, + copy_to_folder=None, + conflict_resolution=AUTO_RESOLVE, + send_meeting_invitations=SEND_TO_NONE, + ): from ..services import SendItem + # Only sends a message. The message can either be an existing draft stored in EWS or a new message that does # not yet exist in EWS. if copy_to_folder and not save_copy: @@ -63,42 +78,48 @@ def send(self, save_copy=True, copy_to_folder=None, conflict_resolution=AUTO_RES if copy_to_folder: # This would better be done via send_and_save() but lets just support it here self.folder = copy_to_folder - return self.send_and_save(conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) + return self.send_and_save( + conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations + ) if self.account.version.build < EXCHANGE_2013 and self.attachments: # At least some versions prior to Exchange 2013 can't send attachments immediately. You need to first save, # then attach, then send. This is done in send_and_save(). send() will delete the item again. - self.send_and_save(conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) + self.send_and_save( + conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations + ) return None self._create(message_disposition=SEND_ONLY, send_meeting_invitations=send_meeting_invitations) return None - def send_and_save(self, update_fields=None, conflict_resolution=AUTO_RESOLVE, - send_meeting_invitations=SEND_TO_NONE): + def send_and_save( + self, update_fields=None, conflict_resolution=AUTO_RESOLVE, send_meeting_invitations=SEND_TO_NONE + ): # Sends Message and saves a copy in the parent folder. Does not return an ItemId. if self.id: self._update( update_fieldnames=update_fields, message_disposition=SEND_AND_SAVE_COPY, conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations + send_meeting_invitations=send_meeting_invitations, ) else: if self.account.version.build < EXCHANGE_2013 and self.attachments: # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need # to first save, then attach, then send. This is done in save(). - self.save(update_fields=update_fields, conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) - self.send(save_copy=False, conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) - else: - self._create( - message_disposition=SEND_AND_SAVE_COPY, - send_meeting_invitations=send_meeting_invitations + self.save( + update_fields=update_fields, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, + ) + self.send( + save_copy=False, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, ) + else: + self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations) @require_id def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): @@ -117,13 +138,7 @@ def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bc ) def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): - self.create_reply( - subject, - body, - to_recipients, - cc_recipients, - bcc_recipients - ).send() + self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients).send() @require_id def create_reply_all(self, subject, body): @@ -152,6 +167,7 @@ def mark_as_junk(self, is_junk=True, move_item=True): :return: """ from ..services import MarkAsJunk + res = MarkAsJunk(account=self.account).get( items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item ) @@ -164,16 +180,16 @@ def mark_as_junk(self, is_junk=True, move_item=True): class ReplyToItem(BaseReplyItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/replytoitem""" - ELEMENT_NAME = 'ReplyToItem' + ELEMENT_NAME = "ReplyToItem" class ReplyAllToItem(BaseReplyItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/replyalltoitem""" - ELEMENT_NAME = 'ReplyAllToItem' + ELEMENT_NAME = "ReplyAllToItem" class ForwardItem(BaseReplyItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/forwarditem""" - ELEMENT_NAME = 'ForwardItem' + ELEMENT_NAME = "ForwardItem" diff --git a/exchangelib/items/post.py b/exchangelib/items/post.py index 629b5cb2..d52bf6f8 100644 --- a/exchangelib/items/post.py +++ b/exchangelib/items/post.py @@ -1,8 +1,8 @@ import logging +from ..fields import BodyField, DateTimeField, MailboxField, TextField from .item import Item from .message import Message -from ..fields import TextField, BodyField, DateTimeField, MailboxField log = logging.getLogger(__name__) @@ -10,29 +10,29 @@ class PostItem(Item): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/postitem""" - ELEMENT_NAME = 'PostItem' + ELEMENT_NAME = "PostItem" - conversation_index = Message.FIELDS['conversation_index'] - conversation_topic = Message.FIELDS['conversation_topic'] + conversation_index = Message.FIELDS["conversation_index"] + conversation_topic = Message.FIELDS["conversation_topic"] - author = Message.FIELDS['author'] - message_id = Message.FIELDS['message_id'] - is_read = Message.FIELDS['is_read'] + author = Message.FIELDS["author"] + message_id = Message.FIELDS["message_id"] + is_read = Message.FIELDS["is_read"] - posted_time = DateTimeField(field_uri='postitem:PostedTime', is_read_only=True) - references = TextField(field_uri='message:References') - sender = MailboxField(field_uri='message:Sender', is_read_only=True, is_read_only_after_send=True) + posted_time = DateTimeField(field_uri="postitem:PostedTime", is_read_only=True) + references = TextField(field_uri="message:References") + sender = MailboxField(field_uri="message:Sender", is_read_only=True, is_read_only_after_send=True) class PostReplyItem(Item): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/postreplyitem""" - ELEMENT_NAME = 'PostReplyItem' + ELEMENT_NAME = "PostReplyItem" # This element only has Item fields up to, and including, 'culture' # TDO: Plus all message fields - new_body = BodyField(field_uri='NewBodyContent') # Accepts and returns Body or HTMLBody instances + new_body = BodyField(field_uri="NewBodyContent") # Accepts and returns Body or HTMLBody instances - culture_idx = Item.FIELDS.index_by_name('culture') - sender_idx = Message.FIELDS.index_by_name('sender') - FIELDS = Item.FIELDS[:culture_idx + 1] + Message.FIELDS[sender_idx:] + culture_idx = Item.FIELDS.index_by_name("culture") + sender_idx = Message.FIELDS.index_by_name("sender") + FIELDS = Item.FIELDS[: culture_idx + 1] + Message.FIELDS[sender_idx:] diff --git a/exchangelib/items/task.py b/exchangelib/items/task.py index 218bdfcd..f0d0efd2 100644 --- a/exchangelib/items/task.py +++ b/exchangelib/items/task.py @@ -2,10 +2,21 @@ import logging from decimal import Decimal +from ..ewsdatetime import UTC, EWSDateTime +from ..fields import ( + BooleanField, + CharField, + Choice, + ChoiceField, + DateTimeBackedDateField, + DateTimeField, + DecimalField, + IntegerField, + TaskRecurrenceField, + TextField, + TextListField, +) from .item import Item -from ..ewsdatetime import EWSDateTime, UTC -from ..fields import BooleanField, IntegerField, DecimalField, TextField, ChoiceField, DateTimeField, Choice, \ - CharField, TextListField, TaskRecurrenceField, DateTimeBackedDateField log = logging.getLogger(__name__) @@ -13,49 +24,78 @@ class Task(Item): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/task""" - ELEMENT_NAME = 'Task' - NOT_STARTED = 'NotStarted' - COMPLETED = 'Completed' + ELEMENT_NAME = "Task" + NOT_STARTED = "NotStarted" + COMPLETED = "Completed" - actual_work = IntegerField(field_uri='task:ActualWork', min=0) - assigned_time = DateTimeField(field_uri='task:AssignedTime', is_read_only=True) - billing_information = TextField(field_uri='task:BillingInformation') - change_count = IntegerField(field_uri='task:ChangeCount', is_read_only=True, min=0) - companies = TextListField(field_uri='task:Companies') + actual_work = IntegerField(field_uri="task:ActualWork", min=0) + assigned_time = DateTimeField(field_uri="task:AssignedTime", is_read_only=True) + billing_information = TextField(field_uri="task:BillingInformation") + change_count = IntegerField(field_uri="task:ChangeCount", is_read_only=True, min=0) + companies = TextListField(field_uri="task:Companies") # 'complete_date' can be set, but is ignored by the server, which sets it to now() - complete_date = DateTimeField(field_uri='task:CompleteDate', is_read_only=True) - contacts = TextListField(field_uri='task:Contacts') - delegation_state = ChoiceField(field_uri='task:DelegationState', choices={ - Choice('NoMatch'), Choice('OwnNew'), Choice('Owned'), Choice('Accepted'), Choice('Declined'), Choice('Max') - }, is_read_only=True) - delegator = CharField(field_uri='task:Delegator', is_read_only=True) - due_date = DateTimeBackedDateField(field_uri='task:DueDate') - is_editable = BooleanField(field_uri='task:IsAssignmentEditable', is_read_only=True) - is_complete = BooleanField(field_uri='task:IsComplete', is_read_only=True) - is_recurring = BooleanField(field_uri='task:IsRecurring', is_read_only=True) - is_team_task = BooleanField(field_uri='task:IsTeamTask', is_read_only=True) - mileage = TextField(field_uri='task:Mileage') - owner = CharField(field_uri='task:Owner', is_read_only=True) - percent_complete = DecimalField(field_uri='task:PercentComplete', is_required=True, default=Decimal(0.0), - min=Decimal(0), max=Decimal(100), is_searchable=False) - recurrence = TaskRecurrenceField(field_uri='task:Recurrence', is_searchable=False) - start_date = DateTimeBackedDateField(field_uri='task:StartDate') - status = ChoiceField(field_uri='task:Status', choices={ - Choice(NOT_STARTED), Choice('InProgress'), Choice(COMPLETED), Choice('WaitingOnOthers'), Choice('Deferred') - }, is_required=True, is_searchable=False, default=NOT_STARTED) - status_description = CharField(field_uri='task:StatusDescription', is_read_only=True) - total_work = IntegerField(field_uri='task:TotalWork', min=0) + complete_date = DateTimeField(field_uri="task:CompleteDate", is_read_only=True) + contacts = TextListField(field_uri="task:Contacts") + delegation_state = ChoiceField( + field_uri="task:DelegationState", + choices={ + Choice("NoMatch"), + Choice("OwnNew"), + Choice("Owned"), + Choice("Accepted"), + Choice("Declined"), + Choice("Max"), + }, + is_read_only=True, + ) + delegator = CharField(field_uri="task:Delegator", is_read_only=True) + due_date = DateTimeBackedDateField(field_uri="task:DueDate") + is_editable = BooleanField(field_uri="task:IsAssignmentEditable", is_read_only=True) + is_complete = BooleanField(field_uri="task:IsComplete", is_read_only=True) + is_recurring = BooleanField(field_uri="task:IsRecurring", is_read_only=True) + is_team_task = BooleanField(field_uri="task:IsTeamTask", is_read_only=True) + mileage = TextField(field_uri="task:Mileage") + owner = CharField(field_uri="task:Owner", is_read_only=True) + percent_complete = DecimalField( + field_uri="task:PercentComplete", + is_required=True, + default=Decimal(0.0), + min=Decimal(0), + max=Decimal(100), + is_searchable=False, + ) + recurrence = TaskRecurrenceField(field_uri="task:Recurrence", is_searchable=False) + start_date = DateTimeBackedDateField(field_uri="task:StartDate") + status = ChoiceField( + field_uri="task:Status", + choices={ + Choice(NOT_STARTED), + Choice("InProgress"), + Choice(COMPLETED), + Choice("WaitingOnOthers"), + Choice("Deferred"), + }, + is_required=True, + is_searchable=False, + default=NOT_STARTED, + ) + status_description = CharField(field_uri="task:StatusDescription", is_read_only=True) + total_work = IntegerField(field_uri="task:TotalWork", min=0) def clean(self, version=None): super().clean(version=version) if self.due_date and self.start_date and self.due_date < self.start_date: - log.warning("'due_date' must be greater than 'start_date' (%s vs %s). Resetting 'due_date'", - self.due_date, self.start_date) + log.warning( + "'due_date' must be greater than 'start_date' (%s vs %s). Resetting 'due_date'", + self.due_date, + self.start_date, + ) self.due_date = self.start_date if self.complete_date: if self.status != self.COMPLETED: - log.warning("'status' must be '%s' when 'complete_date' is set (%s). Resetting", - self.COMPLETED, self.status) + log.warning( + "'status' must be '%s' when 'complete_date' is set (%s). Resetting", self.COMPLETED, self.status + ) self.status = self.COMPLETED now = datetime.datetime.now(tz=UTC) if (self.complete_date - now).total_seconds() > 120: @@ -64,19 +104,28 @@ def clean(self, version=None): log.warning("'complete_date' must be in the past (%s vs %s). Resetting", self.complete_date, now) self.complete_date = now if self.start_date and self.complete_date.date() < self.start_date: - log.warning("'complete_date' must be greater than 'start_date' (%s vs %s). Resetting", - self.complete_date, self.start_date) + log.warning( + "'complete_date' must be greater than 'start_date' (%s vs %s). Resetting", + self.complete_date, + self.start_date, + ) self.complete_date = EWSDateTime.combine(self.start_date, datetime.time(0, 0)).replace(tzinfo=UTC) if self.percent_complete is not None: if self.status == self.COMPLETED and self.percent_complete != Decimal(100): # percent_complete must be 100% if task is complete - log.warning("'percent_complete' must be 100 when 'status' is '%s' (%s). Resetting", - self.COMPLETED, self.percent_complete) + log.warning( + "'percent_complete' must be 100 when 'status' is '%s' (%s). Resetting", + self.COMPLETED, + self.percent_complete, + ) self.percent_complete = Decimal(100) elif self.status == self.NOT_STARTED and self.percent_complete != Decimal(0): # percent_complete must be 0% if task is not started - log.warning("'percent_complete' must be 0 when 'status' is '%s' (%s). Resetting", - self.NOT_STARTED, self.percent_complete) + log.warning( + "'percent_complete' must be 0 when 'status' is '%s' (%s). Resetting", + self.NOT_STARTED, + self.percent_complete, + ) self.percent_complete = Decimal(0) def complete(self): diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 76862ce5..6d1d908b 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -7,14 +7,49 @@ from inspect import getmro from threading import Lock -from .errors import TimezoneDefinitionInvalidForYear, InvalidTypeError -from .fields import SubField, TextField, EmailAddressField, ChoiceField, DateTimeField, EWSElementField, MailboxField, \ - Choice, BooleanField, IdField, ExtendedPropertyField, IntegerField, TimeField, EnumField, CharField, EmailField, \ - EWSElementListField, EnumListField, FreeBusyStatusField, UnknownEntriesField, MessageField, RecipientAddressField, \ - RoutingTypeField, WEEKDAY_NAMES, FieldPath, Field, AssociatedCalendarItemIdField, ReferenceItemIdField, \ - Base64Field, TypeValueField, DictionaryField, IdElementField, CharListField, GenericEventListField, \ - DateTimeBackedDateField, TimeDeltaField, TransitionListField, InvalidField, InvalidFieldForVersion -from .util import get_xml_attr, create_element, set_xml_value, value_to_xml_text, MNS, TNS +from .errors import InvalidTypeError, TimezoneDefinitionInvalidForYear +from .fields import ( + WEEKDAY_NAMES, + AssociatedCalendarItemIdField, + Base64Field, + BooleanField, + CharField, + CharListField, + Choice, + ChoiceField, + DateTimeBackedDateField, + DateTimeField, + DictionaryField, + EmailAddressField, + EmailField, + EnumField, + EnumListField, + EWSElementField, + EWSElementListField, + ExtendedPropertyField, + Field, + FieldPath, + FreeBusyStatusField, + GenericEventListField, + IdElementField, + IdField, + IntegerField, + InvalidField, + InvalidFieldForVersion, + MailboxField, + MessageField, + RecipientAddressField, + ReferenceItemIdField, + RoutingTypeField, + SubField, + TextField, + TimeDeltaField, + TimeField, + TransitionListField, + TypeValueField, + UnknownEntriesField, +) +from .util import MNS, TNS, create_element, get_xml_attr, set_xml_value, value_to_xml_text from .version import EXCHANGE_2013, Build log = logging.getLogger(__name__) @@ -29,7 +64,7 @@ def __init__(self, *fields): for f in fields: # Check for duplicate field names if f.name in self._dict: - raise ValueError(f'Field {f!r} is a duplicate') + raise ValueError(f"Field {f!r} is a duplicate") self._dict[f.name] = f def __getitem__(self, idx_or_slice): @@ -61,11 +96,11 @@ def index_by_name(self, field_name): for i, f in enumerate(self): if f.name == field_name: return i - raise ValueError(f'Unknown field name {field_name!r}') + raise ValueError(f"Unknown field name {field_name!r}") def insert(self, index, field): if field.name in self._dict: - raise ValueError(f'Field {field!r} is a duplicate') + raise ValueError(f"Field {field!r} is a duplicate") super().insert(index, field) self._dict[field.name] = field @@ -84,7 +119,7 @@ class Body(str): MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/body """ - body_type = 'Text' + body_type = "Text" def __add__(self, other): # Make sure Body('') + 'foo' returns a Body type @@ -105,7 +140,7 @@ class HTMLBody(Body): MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/body """ - body_type = 'HTML' + body_type = "HTML" class UID(bytes): @@ -120,22 +155,15 @@ class GlobalObjectId(ExtendedProperty): account.calendar.filter(global_object_id=UID('261cbc18-1f65-5a0a-bd11-23b1e224cc2f')) """ - _HEADER = binascii.hexlify(bytearray(( - 0x04, 0x00, 0x00, 0x00, - 0x82, 0x00, 0xE0, 0x00, - 0x74, 0xC5, 0xB7, 0x10, - 0x1A, 0x82, 0xE0, 0x08))) + _HEADER = binascii.hexlify( + bytearray((0x04, 0x00, 0x00, 0x00, 0x82, 0x00, 0xE0, 0x00, 0x74, 0xC5, 0xB7, 0x10, 0x1A, 0x82, 0xE0, 0x08)) + ) - _EXCEPTION_REPLACEMENT_TIME = binascii.hexlify(bytearray(( - 0, 0, 0, 0))) + _EXCEPTION_REPLACEMENT_TIME = binascii.hexlify(bytearray((0, 0, 0, 0))) - _CREATION_TIME = binascii.hexlify(bytearray(( - 0, 0, 0, 0, - 0, 0, 0, 0))) + _CREATION_TIME = binascii.hexlify(bytearray((0, 0, 0, 0, 0, 0, 0, 0))) - _RESERVED = binascii.hexlify(bytearray(( - 0, 0, 0, 0, - 0, 0, 0, 0))) + _RESERVED = binascii.hexlify(bytearray((0, 0, 0, 0, 0, 0, 0, 0))) # https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxocal/1d3aac05-a7b9-45cc-a213-47f0a0a2c5c1 # https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-asemail/e7424ddc-dd10-431e-a0b7-5c794863370e @@ -143,12 +171,12 @@ class GlobalObjectId(ExtendedProperty): # https://stackoverflow.com/questions/33757805 def __new__(cls, uid): - payload = binascii.hexlify(bytearray(f'vCal-Uid\x01\x00\x00\x00{uid}\x00'.encode('ascii'))) - length = binascii.hexlify(bytearray(struct.pack('= self.end: @@ -714,27 +747,30 @@ def clean(self, version=None): class FreeBusyViewOptions(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/freebusyviewoptions""" - ELEMENT_NAME = 'FreeBusyViewOptions' - REQUESTED_VIEWS = {'MergedOnly', 'FreeBusy', 'FreeBusyMerged', 'Detailed', 'DetailedMerged'} + ELEMENT_NAME = "FreeBusyViewOptions" + REQUESTED_VIEWS = {"MergedOnly", "FreeBusy", "FreeBusyMerged", "Detailed", "DetailedMerged"} time_window = EWSElementField(value_cls=TimeWindow, is_required=True) # Interval value is in minutes - merged_free_busy_interval = IntegerField(field_uri='MergedFreeBusyIntervalInMinutes', min=5, max=1440, default=30, - is_required=True) - requested_view = ChoiceField(field_uri='RequestedView', choices={Choice(c) for c in REQUESTED_VIEWS}, - is_required=True) # Choice('None') is also valid, but only for responses + merged_free_busy_interval = IntegerField( + field_uri="MergedFreeBusyIntervalInMinutes", min=5, max=1440, default=30, is_required=True + ) + requested_view = ChoiceField( + field_uri="RequestedView", choices={Choice(c) for c in REQUESTED_VIEWS}, is_required=True + ) # Choice('None') is also valid, but only for responses class Attendee(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/attendee""" - ELEMENT_NAME = 'Attendee' - RESPONSE_TYPES = {'Unknown', 'Organizer', 'Tentative', 'Accept', 'Decline', 'NoResponseReceived'} + ELEMENT_NAME = "Attendee" + RESPONSE_TYPES = {"Unknown", "Organizer", "Tentative", "Accept", "Decline", "NoResponseReceived"} mailbox = MailboxField(is_required=True) - response_type = ChoiceField(field_uri='ResponseType', choices={Choice(c) for c in RESPONSE_TYPES}, - default='Unknown') - last_response_time = DateTimeField(field_uri='LastResponseTime') + response_type = ChoiceField( + field_uri="ResponseType", choices={Choice(c) for c in RESPONSE_TYPES}, default="Unknown" + ) + last_response_time = DateTimeField(field_uri="LastResponseTime") def __hash__(self): return hash(self.mailbox) @@ -743,11 +779,11 @@ def __hash__(self): class TimeZoneTransition(EWSElement, metaclass=EWSMeta): """Base class for StandardTime and DaylightTime classes.""" - bias = IntegerField(field_uri='Bias', is_required=True) # Offset from the default bias, in minutes - time = TimeField(field_uri='Time', is_required=True) - occurrence = IntegerField(field_uri='DayOrder', is_required=True) # n'th occurrence of weekday in iso_month - iso_month = IntegerField(field_uri='Month', is_required=True) - weekday = EnumField(field_uri='DayOfWeek', enum=WEEKDAY_NAMES, is_required=True) + bias = IntegerField(field_uri="Bias", is_required=True) # Offset from the default bias, in minutes + time = TimeField(field_uri="Time", is_required=True) + occurrence = IntegerField(field_uri="DayOrder", is_required=True) # n'th occurrence of weekday in iso_month + iso_month = IntegerField(field_uri="Month", is_required=True) + weekday = EnumField(field_uri="DayOfWeek", enum=WEEKDAY_NAMES, is_required=True) # 'Year' is not implemented yet @classmethod @@ -769,21 +805,21 @@ def clean(self, version=None): class StandardTime(TimeZoneTransition): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/standardtime""" - ELEMENT_NAME = 'StandardTime' + ELEMENT_NAME = "StandardTime" class DaylightTime(TimeZoneTransition): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/daylighttime""" - ELEMENT_NAME = 'DaylightTime' + ELEMENT_NAME = "DaylightTime" class TimeZone(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezone-availability""" - ELEMENT_NAME = 'TimeZone' + ELEMENT_NAME = "TimeZone" - bias = IntegerField(field_uri='Bias', is_required=True) # Standard (non-DST) offset from UTC, in minutes + bias = IntegerField(field_uri="Bias", is_required=True) # Standard (non-DST) offset from UTC, in minutes standard_time = EWSElementField(value_cls=StandardTime) daylight_time = EWSElementField(value_cls=DaylightTime) @@ -804,7 +840,7 @@ def to_server_timezone(self, timezones, for_year): for_year=for_year, ) if candidate == self: - log.debug('Found exact candidate: %s (%s)', tz_definition.id, tz_definition.name) + log.debug("Found exact candidate: %s (%s)", tz_definition.id, tz_definition.name) # We prefer this timezone over anything else. Return immediately. return tz_definition.id # Reduce list based on base bias and standard / daylight bias values @@ -826,14 +862,14 @@ def to_server_timezone(self, timezones, for_year): continue if candidate.daylight_time.bias != self.daylight_time.bias: continue - log.debug('Found candidate with matching biases: %s (%s)', tz_definition.id, tz_definition.name) + log.debug("Found candidate with matching biases: %s (%s)", tz_definition.id, tz_definition.name) candidates.add(tz_definition.id) if not candidates: - raise ValueError('No server timezones match this timezone definition') + raise ValueError("No server timezones match this timezone definition") if len(candidates) == 1: - log.info('Could not find an exact timezone match for %s. Selecting the best candidate', self) + log.info("Could not find an exact timezone match for %s. Selecting the best candidate", self) else: - log.warning('Could not find an exact timezone match for %s. Selecting a random candidate', self) + log.warning("Could not find an exact timezone match for %s. Selecting a random candidate", self) return candidates.pop() @classmethod @@ -846,12 +882,12 @@ def from_server_timezone(cls, tz_definition, for_year): class CalendarView(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendarview""" - ELEMENT_NAME = 'CalendarView' + ELEMENT_NAME = "CalendarView" NAMESPACE = MNS - start = DateTimeField(field_uri='StartDate', is_required=True, is_attribute=True) - end = DateTimeField(field_uri='EndDate', is_required=True, is_attribute=True) - max_items = IntegerField(field_uri='MaxEntriesReturned', min=1, is_attribute=True) + start = DateTimeField(field_uri="StartDate", is_required=True, is_attribute=True) + end = DateTimeField(field_uri="EndDate", is_required=True, is_attribute=True) + max_items = IntegerField(field_uri="MaxEntriesReturned", min=1, is_attribute=True) def clean(self, version=None): super().clean(version=version) @@ -862,53 +898,61 @@ def clean(self, version=None): class CalendarEventDetails(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendareventdetails""" - ELEMENT_NAME = 'CalendarEventDetails' + ELEMENT_NAME = "CalendarEventDetails" - id = CharField(field_uri='ID') - subject = CharField(field_uri='Subject') - location = CharField(field_uri='Location') - is_meeting = BooleanField(field_uri='IsMeeting') - is_recurring = BooleanField(field_uri='IsRecurring') - is_exception = BooleanField(field_uri='IsException') - is_reminder_set = BooleanField(field_uri='IsReminderSet') - is_private = BooleanField(field_uri='IsPrivate') + id = CharField(field_uri="ID") + subject = CharField(field_uri="Subject") + location = CharField(field_uri="Location") + is_meeting = BooleanField(field_uri="IsMeeting") + is_recurring = BooleanField(field_uri="IsRecurring") + is_exception = BooleanField(field_uri="IsException") + is_reminder_set = BooleanField(field_uri="IsReminderSet") + is_private = BooleanField(field_uri="IsPrivate") class CalendarEvent(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendarevent""" - ELEMENT_NAME = 'CalendarEvent' + ELEMENT_NAME = "CalendarEvent" - start = DateTimeField(field_uri='StartTime') - end = DateTimeField(field_uri='EndTime') - busy_type = FreeBusyStatusField(field_uri='BusyType', is_required=True, default='Busy') + start = DateTimeField(field_uri="StartTime") + end = DateTimeField(field_uri="EndTime") + busy_type = FreeBusyStatusField(field_uri="BusyType", is_required=True, default="Busy") details = EWSElementField(value_cls=CalendarEventDetails) class WorkingPeriod(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/workingperiod""" - ELEMENT_NAME = 'WorkingPeriod' + ELEMENT_NAME = "WorkingPeriod" - weekdays = EnumListField(field_uri='DayOfWeek', enum=WEEKDAY_NAMES, is_required=True) - start = TimeField(field_uri='StartTimeInMinutes', is_required=True) - end = TimeField(field_uri='EndTimeInMinutes', is_required=True) + weekdays = EnumListField(field_uri="DayOfWeek", enum=WEEKDAY_NAMES, is_required=True) + start = TimeField(field_uri="StartTimeInMinutes", is_required=True) + end = TimeField(field_uri="EndTimeInMinutes", is_required=True) class FreeBusyView(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/freebusyview""" - ELEMENT_NAME = 'FreeBusyView' + ELEMENT_NAME = "FreeBusyView" NAMESPACE = MNS - view_type = ChoiceField(field_uri='FreeBusyViewType', choices={ - Choice('None'), Choice('MergedOnly'), Choice('FreeBusy'), Choice('FreeBusyMerged'), Choice('Detailed'), - Choice('DetailedMerged'), - }, is_required=True) + view_type = ChoiceField( + field_uri="FreeBusyViewType", + choices={ + Choice("None"), + Choice("MergedOnly"), + Choice("FreeBusy"), + Choice("FreeBusyMerged"), + Choice("Detailed"), + Choice("DetailedMerged"), + }, + is_required=True, + ) # A string of digits. Each digit points to a position in .fields.FREE_BUSY_CHOICES - merged = CharField(field_uri='MergedFreeBusy') - calendar_events = EWSElementListField(field_uri='CalendarEventArray', value_cls=CalendarEvent) + merged = CharField(field_uri="MergedFreeBusy") + calendar_events = EWSElementListField(field_uri="CalendarEventArray", value_cls=CalendarEvent) # WorkingPeriod is located inside the WorkingPeriodArray element which is inside the WorkingHours element - working_hours = EWSElementListField(field_uri='WorkingPeriodArray', value_cls=WorkingPeriod) + working_hours = EWSElementListField(field_uri="WorkingPeriodArray", value_cls=WorkingPeriod) # TimeZone is also inside the WorkingHours element. It contains information about the timezone which the # account is located in. working_hours_timezone = EWSElementField(value_cls=TimeZone) @@ -916,9 +960,9 @@ class FreeBusyView(EWSElement): @classmethod def from_xml(cls, elem, account): kwargs = {} - working_hours_elem = elem.find(f'{{{TNS}}}WorkingHours') + working_hours_elem = elem.find(f"{{{TNS}}}WorkingHours") for f in cls.FIELDS: - if f.name in ['working_hours', 'working_hours_timezone']: + if f.name in ["working_hours", "working_hours_timezone"]: if working_hours_elem is None: continue kwargs[f.name] = f.from_xml(elem=working_hours_elem, account=account) @@ -931,29 +975,29 @@ def from_xml(cls, elem, account): class RoomList(Mailbox): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/roomlist""" - ELEMENT_NAME = 'RoomList' + ELEMENT_NAME = "RoomList" NAMESPACE = MNS @classmethod def response_tag(cls): # In a GetRoomLists response, room lists are delivered as Address elements. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/address-emailaddresstype - return f'{{{TNS}}}Address' + return f"{{{TNS}}}Address" class Room(Mailbox): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/room""" - ELEMENT_NAME = 'Room' + ELEMENT_NAME = "Room" @classmethod def from_xml(cls, elem, account): - id_elem = elem.find(f'{{{TNS}}}Id') + id_elem = elem.find(f"{{{TNS}}}Id") item_id_elem = id_elem.find(ItemId.response_tag()) kwargs = dict( - name=get_xml_attr(id_elem, f'{{{TNS}}}Name'), - email_address=get_xml_attr(id_elem, f'{{{TNS}}}EmailAddress'), - mailbox_type=get_xml_attr(id_elem, f'{{{TNS}}}MailboxType'), + name=get_xml_attr(id_elem, f"{{{TNS}}}Name"), + email_address=get_xml_attr(id_elem, f"{{{TNS}}}EmailAddress"), + mailbox_type=get_xml_attr(id_elem, f"{{{TNS}}}MailboxType"), item_id=ItemId.from_xml(elem=item_id_elem, account=account) if item_id_elem else None, ) cls._clear(elem) @@ -965,12 +1009,12 @@ class Member(EWSElement): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/member-ex15websvcsotherref """ - ELEMENT_NAME = 'Member' + ELEMENT_NAME = "Member" mailbox = MailboxField(is_required=True) - status = ChoiceField(field_uri='Status', choices={ - Choice('Unrecognized'), Choice('Normal'), Choice('Demoted') - }, default='Normal') + status = ChoiceField( + field_uri="Status", choices={Choice("Unrecognized"), Choice("Normal"), Choice("Demoted")}, default="Normal" + ) def __hash__(self): return hash(self.mailbox) @@ -979,58 +1023,74 @@ def __hash__(self): class UserId(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/userid""" - ELEMENT_NAME = 'UserId' + ELEMENT_NAME = "UserId" - sid = CharField(field_uri='SID') - primary_smtp_address = EmailAddressField(field_uri='PrimarySmtpAddress') - display_name = CharField(field_uri='DisplayName') - distinguished_user = ChoiceField(field_uri='DistinguishedUser', choices={ - Choice('Default'), Choice('Anonymous') - }) - external_user_identity = CharField(field_uri='ExternalUserIdentity') + sid = CharField(field_uri="SID") + primary_smtp_address = EmailAddressField(field_uri="PrimarySmtpAddress") + display_name = CharField(field_uri="DisplayName") + distinguished_user = ChoiceField(field_uri="DistinguishedUser", choices={Choice("Default"), Choice("Anonymous")}) + external_user_identity = CharField(field_uri="ExternalUserIdentity") class BasePermission(EWSElement, metaclass=EWSMeta): """Base class for the Permission and CalendarPermission classes""" - PERMISSION_ENUM = {Choice('None'), Choice('Owned'), Choice('All')} + PERMISSION_ENUM = {Choice("None"), Choice("Owned"), Choice("All")} - can_create_items = BooleanField(field_uri='CanCreateItems', default=False) - can_create_subfolders = BooleanField(field_uri='CanCreateSubfolders', default=False) - is_folder_owner = BooleanField(field_uri='IsFolderOwner', default=False) - is_folder_visible = BooleanField(field_uri='IsFolderVisible', default=False) - is_folder_contact = BooleanField(field_uri='IsFolderContact', default=False) - edit_items = ChoiceField(field_uri='EditItems', choices=PERMISSION_ENUM, default='None') - delete_items = ChoiceField(field_uri='DeleteItems', choices=PERMISSION_ENUM, default='None') - read_items = ChoiceField(field_uri='ReadItems', choices={Choice('None'), Choice('FullDetails')}, default='None') + can_create_items = BooleanField(field_uri="CanCreateItems", default=False) + can_create_subfolders = BooleanField(field_uri="CanCreateSubfolders", default=False) + is_folder_owner = BooleanField(field_uri="IsFolderOwner", default=False) + is_folder_visible = BooleanField(field_uri="IsFolderVisible", default=False) + is_folder_contact = BooleanField(field_uri="IsFolderContact", default=False) + edit_items = ChoiceField(field_uri="EditItems", choices=PERMISSION_ENUM, default="None") + delete_items = ChoiceField(field_uri="DeleteItems", choices=PERMISSION_ENUM, default="None") + read_items = ChoiceField(field_uri="ReadItems", choices={Choice("None"), Choice("FullDetails")}, default="None") user_id = EWSElementField(value_cls=UserId, is_required=True) class Permission(BasePermission): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/permission""" - ELEMENT_NAME = 'Permission' + ELEMENT_NAME = "Permission" LEVEL_CHOICES = ( - 'None', 'Owner', 'PublishingEditor', 'Editor', 'PublishingAuthor', 'Author', 'NoneditingAuthor', 'Reviewer', - 'Contributor', 'Custom', + "None", + "Owner", + "PublishingEditor", + "Editor", + "PublishingAuthor", + "Author", + "NoneditingAuthor", + "Reviewer", + "Contributor", + "Custom", ) permission_level = ChoiceField( - field_uri='CalendarPermissionLevel', choices={Choice(c) for c in LEVEL_CHOICES}, default=LEVEL_CHOICES[0] + field_uri="CalendarPermissionLevel", choices={Choice(c) for c in LEVEL_CHOICES}, default=LEVEL_CHOICES[0] ) class CalendarPermission(BasePermission): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendarpermission""" - ELEMENT_NAME = 'CalendarPermission' + ELEMENT_NAME = "CalendarPermission" LEVEL_CHOICES = ( - 'None', 'Owner', 'PublishingEditor', 'Editor', 'PublishingAuthor', 'Author', 'NoneditingAuthor', 'Reviewer', - 'Contributor', 'FreeBusyTimeOnly', 'FreeBusyTimeAndSubjectAndLocation', 'Custom', + "None", + "Owner", + "PublishingEditor", + "Editor", + "PublishingAuthor", + "Author", + "NoneditingAuthor", + "Reviewer", + "Contributor", + "FreeBusyTimeOnly", + "FreeBusyTimeAndSubjectAndLocation", + "Custom", ) calendar_permission_level = ChoiceField( - field_uri='CalendarPermissionLevel', choices={Choice(c) for c in LEVEL_CHOICES}, default=LEVEL_CHOICES[0] + field_uri="CalendarPermissionLevel", choices={Choice(c) for c in LEVEL_CHOICES}, default=LEVEL_CHOICES[0] ) @@ -1042,25 +1102,25 @@ class PermissionSet(EWSElement): """ # For simplicity, we implement the two distinct but equally names elements as one class. - ELEMENT_NAME = 'PermissionSet' + ELEMENT_NAME = "PermissionSet" - permissions = EWSElementListField(field_uri='Permissions', value_cls=Permission) - calendar_permissions = EWSElementListField(field_uri='CalendarPermissions', value_cls=CalendarPermission) - unknown_entries = UnknownEntriesField(field_uri='UnknownEntries') + permissions = EWSElementListField(field_uri="Permissions", value_cls=Permission) + calendar_permissions = EWSElementListField(field_uri="CalendarPermissions", value_cls=CalendarPermission) + unknown_entries = UnknownEntriesField(field_uri="UnknownEntries") class EffectiveRights(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/effectiverights""" - ELEMENT_NAME = 'EffectiveRights' + ELEMENT_NAME = "EffectiveRights" - create_associated = BooleanField(field_uri='CreateAssociated', default=False) - create_contents = BooleanField(field_uri='CreateContents', default=False) - create_hierarchy = BooleanField(field_uri='CreateHierarchy', default=False) - delete = BooleanField(field_uri='Delete', default=False) - modify = BooleanField(field_uri='Modify', default=False) - read = BooleanField(field_uri='Read', default=False) - view_private_items = BooleanField(field_uri='ViewPrivateItems', default=False) + create_associated = BooleanField(field_uri="CreateAssociated", default=False) + create_contents = BooleanField(field_uri="CreateContents", default=False) + create_hierarchy = BooleanField(field_uri="CreateHierarchy", default=False) + delete = BooleanField(field_uri="Delete", default=False) + modify = BooleanField(field_uri="Modify", default=False) + read = BooleanField(field_uri="Read", default=False) + view_private_items = BooleanField(field_uri="ViewPrivateItems", default=False) def __contains__(self, item): return getattr(self, item, False) @@ -1069,92 +1129,102 @@ def __contains__(self, item): class DelegatePermissions(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/delegatepermissions""" - ELEMENT_NAME = 'DelegatePermissions' + ELEMENT_NAME = "DelegatePermissions" PERMISSION_LEVEL_CHOICES = { - Choice('None'), Choice('Editor'), Choice('Reviewer'), Choice('Author'), Choice('Custom'), - } - - calendar_folder_permission_level = ChoiceField(field_uri='CalendarFolderPermissionLevel', - choices=PERMISSION_LEVEL_CHOICES, default='None') - tasks_folder_permission_level = ChoiceField(field_uri='TasksFolderPermissionLevel', - choices=PERMISSION_LEVEL_CHOICES, default='None') - inbox_folder_permission_level = ChoiceField(field_uri='InboxFolderPermissionLevel', - choices=PERMISSION_LEVEL_CHOICES, default='None') - contacts_folder_permission_level = ChoiceField(field_uri='ContactsFolderPermissionLevel', - choices=PERMISSION_LEVEL_CHOICES, default='None') - notes_folder_permission_level = ChoiceField(field_uri='NotesFolderPermissionLevel', - choices=PERMISSION_LEVEL_CHOICES, default='None') - journal_folder_permission_level = ChoiceField(field_uri='JournalFolderPermissionLevel', - choices=PERMISSION_LEVEL_CHOICES, default='None') + Choice("None"), + Choice("Editor"), + Choice("Reviewer"), + Choice("Author"), + Choice("Custom"), + } + + calendar_folder_permission_level = ChoiceField( + field_uri="CalendarFolderPermissionLevel", choices=PERMISSION_LEVEL_CHOICES, default="None" + ) + tasks_folder_permission_level = ChoiceField( + field_uri="TasksFolderPermissionLevel", choices=PERMISSION_LEVEL_CHOICES, default="None" + ) + inbox_folder_permission_level = ChoiceField( + field_uri="InboxFolderPermissionLevel", choices=PERMISSION_LEVEL_CHOICES, default="None" + ) + contacts_folder_permission_level = ChoiceField( + field_uri="ContactsFolderPermissionLevel", choices=PERMISSION_LEVEL_CHOICES, default="None" + ) + notes_folder_permission_level = ChoiceField( + field_uri="NotesFolderPermissionLevel", choices=PERMISSION_LEVEL_CHOICES, default="None" + ) + journal_folder_permission_level = ChoiceField( + field_uri="JournalFolderPermissionLevel", choices=PERMISSION_LEVEL_CHOICES, default="None" + ) class DelegateUser(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/delegateuser""" - ELEMENT_NAME = 'DelegateUser' + ELEMENT_NAME = "DelegateUser" NAMESPACE = MNS user_id = EWSElementField(value_cls=UserId) delegate_permissions = EWSElementField(value_cls=DelegatePermissions) - receive_copies_of_meeting_messages = BooleanField(field_uri='ReceiveCopiesOfMeetingMessages', default=False) - view_private_items = BooleanField(field_uri='ViewPrivateItems', default=False) + receive_copies_of_meeting_messages = BooleanField(field_uri="ReceiveCopiesOfMeetingMessages", default=False) + view_private_items = BooleanField(field_uri="ViewPrivateItems", default=False) class SearchableMailbox(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/searchablemailbox""" - ELEMENT_NAME = 'SearchableMailbox' + ELEMENT_NAME = "SearchableMailbox" - guid = CharField(field_uri='Guid') - primary_smtp_address = EmailAddressField(field_uri='PrimarySmtpAddress') - is_external = BooleanField(field_uri='IsExternalMailbox') - external_email = EmailAddressField(field_uri='ExternalEmailAddress') - display_name = CharField(field_uri='DisplayName') - is_membership_group = BooleanField(field_uri='IsMembershipGroup') - reference_id = CharField(field_uri='ReferenceId') + guid = CharField(field_uri="Guid") + primary_smtp_address = EmailAddressField(field_uri="PrimarySmtpAddress") + is_external = BooleanField(field_uri="IsExternalMailbox") + external_email = EmailAddressField(field_uri="ExternalEmailAddress") + display_name = CharField(field_uri="DisplayName") + is_membership_group = BooleanField(field_uri="IsMembershipGroup") + reference_id = CharField(field_uri="ReferenceId") class FailedMailbox(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/failedmailbox""" - ELEMENT_NAME = 'FailedMailbox' + ELEMENT_NAME = "FailedMailbox" - mailbox = CharField(field_uri='Mailbox') - error_code = IntegerField(field_uri='ErrorCode') - error_message = CharField(field_uri='ErrorMessage') - is_archive = BooleanField(field_uri='IsArchive') + mailbox = CharField(field_uri="Mailbox") + error_code = IntegerField(field_uri="ErrorCode") + error_message = CharField(field_uri="ErrorMessage") + is_archive = BooleanField(field_uri="IsArchive") # MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailtipsrequested MAIL_TIPS_TYPES = ( - 'All', - 'OutOfOfficeMessage', - 'MailboxFullStatus', - 'CustomMailTip', - 'ExternalMemberCount', - 'TotalMemberCount', - 'MaxMessageSize', - 'DeliveryRestriction', - 'ModerationStatus', - 'InvalidRecipient', + "All", + "OutOfOfficeMessage", + "MailboxFullStatus", + "CustomMailTip", + "ExternalMemberCount", + "TotalMemberCount", + "MaxMessageSize", + "DeliveryRestriction", + "ModerationStatus", + "InvalidRecipient", ) class OutOfOffice(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/outofoffice""" - ELEMENT_NAME = 'OutOfOffice' + ELEMENT_NAME = "OutOfOffice" - reply_body = MessageField(field_uri='ReplyBody') - start = DateTimeField(field_uri='StartTime', is_required=False) - end = DateTimeField(field_uri='EndTime', is_required=False) + reply_body = MessageField(field_uri="ReplyBody") + start = DateTimeField(field_uri="StartTime", is_required=False) + end = DateTimeField(field_uri="EndTime", is_required=False) @classmethod def duration_to_start_end(cls, elem, account): kwargs = {} - duration = elem.find(f'{{{TNS}}}Duration') + duration = elem.find(f"{{{TNS}}}Duration") if duration is not None: - for attr in ('start', 'end'): + for attr in ("start", "end"): f = cls.get_field_by_fieldname(attr) kwargs[attr] = f.from_xml(elem=duration, account=account) return kwargs @@ -1162,7 +1232,7 @@ def duration_to_start_end(cls, elem, account): @classmethod def from_xml(cls, elem, account): kwargs = {} - for attr in ('reply_body',): + for attr in ("reply_body",): f = cls.get_field_by_fieldname(attr) kwargs[attr] = f.from_xml(elem=elem, account=account) kwargs.update(cls.duration_to_start_end(elem=elem, account=account)) @@ -1173,28 +1243,28 @@ def from_xml(cls, elem, account): class MailTips(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailtips""" - ELEMENT_NAME = 'MailTips' + ELEMENT_NAME = "MailTips" NAMESPACE = MNS recipient_address = RecipientAddressField() - pending_mail_tips = ChoiceField(field_uri='PendingMailTips', choices={Choice(c) for c in MAIL_TIPS_TYPES}) + pending_mail_tips = ChoiceField(field_uri="PendingMailTips", choices={Choice(c) for c in MAIL_TIPS_TYPES}) out_of_office = EWSElementField(value_cls=OutOfOffice) - mailbox_full = BooleanField(field_uri='MailboxFull') - custom_mail_tip = TextField(field_uri='CustomMailTip') - total_member_count = IntegerField(field_uri='TotalMemberCount') - external_member_count = IntegerField(field_uri='ExternalMemberCount') - max_message_size = IntegerField(field_uri='MaxMessageSize') - delivery_restricted = BooleanField(field_uri='DeliveryRestricted') - is_moderated = BooleanField(field_uri='IsModerated') - invalid_recipient = BooleanField(field_uri='InvalidRecipient') - - -ENTRY_ID = 'EntryId' # The base64-encoded PR_ENTRYID property -EWS_ID = 'EwsId' # The EWS format used in Exchange 2007 SP1 and later -EWS_LEGACY_ID = 'EwsLegacyId' # The EWS format used in Exchange 2007 before SP1 -HEX_ENTRY_ID = 'HexEntryId' # The hexadecimal representation of the PR_ENTRYID property -OWA_ID = 'OwaId' # The OWA format for Exchange 2007 and 2010 -STORE_ID = 'StoreId' # The Exchange Store format + mailbox_full = BooleanField(field_uri="MailboxFull") + custom_mail_tip = TextField(field_uri="CustomMailTip") + total_member_count = IntegerField(field_uri="TotalMemberCount") + external_member_count = IntegerField(field_uri="ExternalMemberCount") + max_message_size = IntegerField(field_uri="MaxMessageSize") + delivery_restricted = BooleanField(field_uri="DeliveryRestricted") + is_moderated = BooleanField(field_uri="IsModerated") + invalid_recipient = BooleanField(field_uri="InvalidRecipient") + + +ENTRY_ID = "EntryId" # The base64-encoded PR_ENTRYID property +EWS_ID = "EwsId" # The EWS format used in Exchange 2007 SP1 and later +EWS_LEGACY_ID = "EwsLegacyId" # The EWS format used in Exchange 2007 before SP1 +HEX_ENTRY_ID = "HexEntryId" # The hexadecimal representation of the PR_ENTRYID property +OWA_ID = "OwaId" # The OWA format for Exchange 2007 and 2010 +STORE_ID = "StoreId" # The Exchange Store format # IdFormat enum ID_FORMATS = (ENTRY_ID, EWS_ID, EWS_LEGACY_ID, HEX_ENTRY_ID, OWA_ID, STORE_ID) @@ -1202,28 +1272,30 @@ class MailTips(EWSElement): class AlternateId(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/alternateid""" - ELEMENT_NAME = 'AlternateId' + ELEMENT_NAME = "AlternateId" - id = CharField(field_uri='Id', is_required=True, is_attribute=True) - format = ChoiceField(field_uri='Format', is_required=True, is_attribute=True, - choices={Choice(c) for c in ID_FORMATS}) - mailbox = EmailAddressField(field_uri='Mailbox', is_required=True, is_attribute=True) - is_archive = BooleanField(field_uri='IsArchive', is_required=False, is_attribute=True) + id = CharField(field_uri="Id", is_required=True, is_attribute=True) + format = ChoiceField( + field_uri="Format", is_required=True, is_attribute=True, choices={Choice(c) for c in ID_FORMATS} + ) + mailbox = EmailAddressField(field_uri="Mailbox", is_required=True, is_attribute=True) + is_archive = BooleanField(field_uri="IsArchive", is_required=False, is_attribute=True) @classmethod def response_tag(cls): # This element is in TNS in the request and MNS in the response... - return f'{{{MNS}}}{cls.ELEMENT_NAME}' + return f"{{{MNS}}}{cls.ELEMENT_NAME}" class AlternatePublicFolderId(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/alternatepublicfolderid""" - ELEMENT_NAME = 'AlternatePublicFolderId' + ELEMENT_NAME = "AlternatePublicFolderId" - folder_id = CharField(field_uri='FolderId', is_required=True, is_attribute=True) - format = ChoiceField(field_uri='Format', is_required=True, is_attribute=True, - choices={Choice(c) for c in ID_FORMATS}) + folder_id = CharField(field_uri="FolderId", is_required=True, is_attribute=True) + format = ChoiceField( + field_uri="Format", is_required=True, is_attribute=True, choices={Choice(c) for c in ID_FORMATS} + ) class AlternatePublicFolderItemId(EWSElement): @@ -1231,136 +1303,140 @@ class AlternatePublicFolderItemId(EWSElement): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/alternatepublicfolderitemid """ - ELEMENT_NAME = 'AlternatePublicFolderItemId' + ELEMENT_NAME = "AlternatePublicFolderItemId" - folder_id = CharField(field_uri='FolderId', is_required=True, is_attribute=True) - format = ChoiceField(field_uri='Format', is_required=True, is_attribute=True, - choices={Choice(c) for c in ID_FORMATS}) - item_id = CharField(field_uri='ItemId', is_required=True, is_attribute=True) + folder_id = CharField(field_uri="FolderId", is_required=True, is_attribute=True) + format = ChoiceField( + field_uri="Format", is_required=True, is_attribute=True, choices={Choice(c) for c in ID_FORMATS} + ) + item_id = CharField(field_uri="ItemId", is_required=True, is_attribute=True) class FieldURI(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/fielduri""" - ELEMENT_NAME = 'FieldURI' + ELEMENT_NAME = "FieldURI" - field_uri = CharField(field_uri='FieldURI', is_attribute=True, is_required=True) + field_uri = CharField(field_uri="FieldURI", is_attribute=True, is_required=True) class IndexedFieldURI(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/indexedfielduri""" - ELEMENT_NAME = 'IndexedFieldURI' + ELEMENT_NAME = "IndexedFieldURI" - field_uri = CharField(field_uri='FieldURI', is_attribute=True, is_required=True) - field_index = CharField(field_uri='FieldIndex', is_attribute=True, is_required=True) + field_uri = CharField(field_uri="FieldURI", is_attribute=True, is_required=True) + field_index = CharField(field_uri="FieldIndex", is_attribute=True, is_required=True) class ExtendedFieldURI(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/extendedfielduri""" - ELEMENT_NAME = 'ExtendedFieldURI' + ELEMENT_NAME = "ExtendedFieldURI" - distinguished_property_set_id = CharField(field_uri='DistinguishedPropertySetId', is_attribute=True) - property_set_id = CharField(field_uri='PropertySetId', is_attribute=True) - property_tag = CharField(field_uri='PropertyTag', is_attribute=True) - property_name = CharField(field_uri='PropertyName', is_attribute=True) - property_id = CharField(field_uri='PropertyId', is_attribute=True) - property_type = CharField(field_uri='PropertyType', is_attribute=True) + distinguished_property_set_id = CharField(field_uri="DistinguishedPropertySetId", is_attribute=True) + property_set_id = CharField(field_uri="PropertySetId", is_attribute=True) + property_tag = CharField(field_uri="PropertyTag", is_attribute=True) + property_name = CharField(field_uri="PropertyName", is_attribute=True) + property_id = CharField(field_uri="PropertyId", is_attribute=True) + property_type = CharField(field_uri="PropertyType", is_attribute=True) class ExceptionFieldURI(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exceptionfielduri""" - ELEMENT_NAME = 'ExceptionFieldURI' + ELEMENT_NAME = "ExceptionFieldURI" - field_uri = CharField(field_uri='FieldURI', is_attribute=True, is_required=True) + field_uri = CharField(field_uri="FieldURI", is_attribute=True, is_required=True) class CompleteName(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/completename""" - ELEMENT_NAME = 'CompleteName' + ELEMENT_NAME = "CompleteName" - title = CharField(field_uri='Title') - first_name = CharField(field_uri='FirstName') - middle_name = CharField(field_uri='MiddleName') - last_name = CharField(field_uri='LastName') - suffix = CharField(field_uri='Suffix') - initials = CharField(field_uri='Initials') - full_name = CharField(field_uri='FullName') - nickname = CharField(field_uri='Nickname') - yomi_first_name = CharField(field_uri='YomiFirstName') - yomi_last_name = CharField(field_uri='YomiLastName') + title = CharField(field_uri="Title") + first_name = CharField(field_uri="FirstName") + middle_name = CharField(field_uri="MiddleName") + last_name = CharField(field_uri="LastName") + suffix = CharField(field_uri="Suffix") + initials = CharField(field_uri="Initials") + full_name = CharField(field_uri="FullName") + nickname = CharField(field_uri="Nickname") + yomi_first_name = CharField(field_uri="YomiFirstName") + yomi_last_name = CharField(field_uri="YomiLastName") class ReminderMessageData(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/remindermessagedata""" - ELEMENT_NAME = 'ReminderMessageData' + ELEMENT_NAME = "ReminderMessageData" - reminder_text = CharField(field_uri='ReminderText') - location = CharField(field_uri='Location') - start_time = TimeField(field_uri='StartTime') - end_time = TimeField(field_uri='EndTime') - associated_calendar_item_id = AssociatedCalendarItemIdField(field_uri='AssociatedCalendarItemId', - supported_from=Build(15, 0, 913, 9)) + reminder_text = CharField(field_uri="ReminderText") + location = CharField(field_uri="Location") + start_time = TimeField(field_uri="StartTime") + end_time = TimeField(field_uri="EndTime") + associated_calendar_item_id = AssociatedCalendarItemIdField( + field_uri="AssociatedCalendarItemId", supported_from=Build(15, 0, 913, 9) + ) class AcceptSharingInvitation(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/acceptsharinginvitation""" - ELEMENT_NAME = 'AcceptSharingInvitation' + ELEMENT_NAME = "AcceptSharingInvitation" - reference_item_id = ReferenceItemIdField(field_uri='item:ReferenceItemId') + reference_item_id = ReferenceItemIdField(field_uri="item:ReferenceItemId") class SuppressReadReceipt(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/suppressreadreceipt""" - ELEMENT_NAME = 'SuppressReadReceipt' + ELEMENT_NAME = "SuppressReadReceipt" - reference_item_id = ReferenceItemIdField(field_uri='item:ReferenceItemId') + reference_item_id = ReferenceItemIdField(field_uri="item:ReferenceItemId") class RemoveItem(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/removeitem""" - ELEMENT_NAME = 'RemoveItem' + ELEMENT_NAME = "RemoveItem" - reference_item_id = ReferenceItemIdField(field_uri='item:ReferenceItemId') + reference_item_id = ReferenceItemIdField(field_uri="item:ReferenceItemId") class ResponseObjects(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/responseobjects""" - ELEMENT_NAME = 'ResponseObjects' + ELEMENT_NAME = "ResponseObjects" NAMESPACE = EWSElement.NAMESPACE - accept_item = EWSElementField(field_uri='AcceptItem', value_cls='AcceptItem', namespace=NAMESPACE) - tentatively_accept_item = EWSElementField(field_uri='TentativelyAcceptItem', value_cls='TentativelyAcceptItem', - namespace=NAMESPACE) - decline_item = EWSElementField(field_uri='DeclineItem', value_cls='DeclineItem', namespace=NAMESPACE) - reply_to_item = EWSElementField(field_uri='ReplyToItem', value_cls='ReplyToItem', namespace=NAMESPACE) - forward_item = EWSElementField(field_uri='ForwardItem', value_cls='ForwardItem', namespace=NAMESPACE) - reply_all_to_item = EWSElementField(field_uri='ReplyAllToItem', value_cls='ReplyAllToItem', namespace=NAMESPACE) - cancel_calendar_item = EWSElementField(field_uri='CancelCalendarItem', value_cls='CancelCalendarItem', - namespace=NAMESPACE) - remove_item = EWSElementField(field_uri='RemoveItem', value_cls=RemoveItem) - post_reply_item = EWSElementField(field_uri='PostReplyItem', value_cls='PostReplyItem', - namespace=EWSElement.NAMESPACE) - success_read_receipt = EWSElementField(field_uri='SuppressReadReceipt', value_cls=SuppressReadReceipt) - accept_sharing_invitation = EWSElementField(field_uri='AcceptSharingInvitation', - value_cls=AcceptSharingInvitation) + accept_item = EWSElementField(field_uri="AcceptItem", value_cls="AcceptItem", namespace=NAMESPACE) + tentatively_accept_item = EWSElementField( + field_uri="TentativelyAcceptItem", value_cls="TentativelyAcceptItem", namespace=NAMESPACE + ) + decline_item = EWSElementField(field_uri="DeclineItem", value_cls="DeclineItem", namespace=NAMESPACE) + reply_to_item = EWSElementField(field_uri="ReplyToItem", value_cls="ReplyToItem", namespace=NAMESPACE) + forward_item = EWSElementField(field_uri="ForwardItem", value_cls="ForwardItem", namespace=NAMESPACE) + reply_all_to_item = EWSElementField(field_uri="ReplyAllToItem", value_cls="ReplyAllToItem", namespace=NAMESPACE) + cancel_calendar_item = EWSElementField( + field_uri="CancelCalendarItem", value_cls="CancelCalendarItem", namespace=NAMESPACE + ) + remove_item = EWSElementField(field_uri="RemoveItem", value_cls=RemoveItem) + post_reply_item = EWSElementField( + field_uri="PostReplyItem", value_cls="PostReplyItem", namespace=EWSElement.NAMESPACE + ) + success_read_receipt = EWSElementField(field_uri="SuppressReadReceipt", value_cls=SuppressReadReceipt) + accept_sharing_invitation = EWSElementField(field_uri="AcceptSharingInvitation", value_cls=AcceptSharingInvitation) class PhoneNumber(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/phonenumber""" - ELEMENT_NAME = 'PhoneNumber' + ELEMENT_NAME = "PhoneNumber" - number = CharField(field_uri='Number') - type = CharField(field_uri='Type') + number = CharField(field_uri="Number") + type = CharField(field_uri="Type") class IdChangeKeyMixIn(EWSElement, metaclass=EWSMeta): @@ -1371,14 +1447,14 @@ class IdChangeKeyMixIn(EWSElement, metaclass=EWSMeta): ID_ELEMENT_CLS = None def __init__(self, **kwargs): - _id, _changekey = kwargs.pop('id', None), kwargs.pop('changekey', None) + _id, _changekey = kwargs.pop("id", None), kwargs.pop("changekey", None) if _id or _changekey: - kwargs['_id'] = self.ID_ELEMENT_CLS(_id, _changekey) + kwargs["_id"] = self.ID_ELEMENT_CLS(_id, _changekey) super().__init__(**kwargs) @classmethod def get_field_by_fieldname(cls, fieldname): - if fieldname in ('id', 'changekey'): + if fieldname in ("id", "changekey"): return cls.ID_ELEMENT_CLS.get_field_by_fieldname(fieldname=fieldname) return super().get_field_by_fieldname(fieldname=fieldname) @@ -1416,7 +1492,7 @@ def id_from_xml(cls, elem): def to_id(self): if self._id is None: - raise ValueError('Must have an ID') + raise ValueError("Must have an ID") return self._id def __eq__(self, other): @@ -1434,23 +1510,24 @@ def __hash__(self): class DictionaryEntry(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/dictionaryentry""" - ELEMENT_NAME = 'DictionaryEntry' + ELEMENT_NAME = "DictionaryEntry" - key = TypeValueField(field_uri='DictionaryKey') - value = TypeValueField(field_uri='DictionaryValue') + key = TypeValueField(field_uri="DictionaryKey") + value = TypeValueField(field_uri="DictionaryValue") class UserConfigurationName(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/userconfigurationname""" - ELEMENT_NAME = 'UserConfigurationName' + ELEMENT_NAME = "UserConfigurationName" NAMESPACE = TNS - name = CharField(field_uri='Name', is_attribute=True) + name = CharField(field_uri="Name", is_attribute=True) folder = EWSElementField(value_cls=FolderId) def clean(self, version=None): from .folders import BaseFolder + if isinstance(self.folder, BaseFolder): self.folder = self.folder.to_id() super().clean(version=version) @@ -1478,29 +1555,29 @@ class UserConfigurationNameMNS(UserConfigurationName): class UserConfiguration(IdChangeKeyMixIn): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/userconfiguration""" - ELEMENT_NAME = 'UserConfiguration' + ELEMENT_NAME = "UserConfiguration" NAMESPACE = MNS ID_ELEMENT_CLS = ItemId - _id = IdElementField(field_uri='ItemId', value_cls=ID_ELEMENT_CLS) + _id = IdElementField(field_uri="ItemId", value_cls=ID_ELEMENT_CLS) user_configuration_name = EWSElementField(value_cls=UserConfigurationName) - dictionary = DictionaryField(field_uri='Dictionary') - xml_data = Base64Field(field_uri='XmlData') - binary_data = Base64Field(field_uri='BinaryData') + dictionary = DictionaryField(field_uri="Dictionary") + xml_data = Base64Field(field_uri="XmlData") + binary_data = Base64Field(field_uri="BinaryData") class Attribution(IdChangeKeyMixIn): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/phonenumber""" - ELEMENT_NAME = 'Attribution' + ELEMENT_NAME = "Attribution" ID_ELEMENT_CLS = SourceId - ID = CharField(field_uri='Id') - _id = IdElementField(field_uri='SourceId', value_cls=ID_ELEMENT_CLS) - display_name = CharField(field_uri='DisplayName') - is_writable = BooleanField(field_uri='IsWritable') - is_quick_contact = BooleanField(field_uri='IsQuickContact') - is_hidden = BooleanField(field_uri='IsHidden') + ID = CharField(field_uri="Id") + _id = IdElementField(field_uri="SourceId", value_cls=ID_ELEMENT_CLS) + display_name = CharField(field_uri="DisplayName") + is_writable = BooleanField(field_uri="IsWritable") + is_quick_contact = BooleanField(field_uri="IsQuickContact") + is_hidden = BooleanField(field_uri="IsHidden") folder_id = EWSElementField(value_cls=FolderId) @@ -1509,10 +1586,10 @@ class BodyContentValue(EWSElement): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/value-bodycontenttype """ - ELEMENT_NAME = 'Value' + ELEMENT_NAME = "Value" - value = CharField(field_uri='Value') - body_type = CharField(field_uri='BodyType') + value = CharField(field_uri="Value") + body_type = CharField(field_uri="BodyType") class BodyContentAttributedValue(EWSElement): @@ -1520,10 +1597,10 @@ class BodyContentAttributedValue(EWSElement): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/bodycontentattributedvalue """ - ELEMENT_NAME = 'BodyContentAttributedValue' + ELEMENT_NAME = "BodyContentAttributedValue" value = EWSElementField(value_cls=BodyContentValue) - attributions = EWSElementListField(field_uri='Attributions', value_cls=Attribution) + attributions = EWSElementListField(field_uri="Attributions", value_cls=Attribution) class StringAttributedValue(EWSElement): @@ -1531,10 +1608,10 @@ class StringAttributedValue(EWSElement): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/stringattributedvalue """ - ELEMENT_NAME = 'StringAttributedValue' + ELEMENT_NAME = "StringAttributedValue" - value = CharField(field_uri='Value') - attributions = CharListField(field_uri='Attributions', list_elem_name='Attribution') + value = CharField(field_uri="Value") + attributions = CharListField(field_uri="Attributions", list_elem_name="Attribution") class PersonaPhoneNumberTypeValue(EWSElement): @@ -1542,10 +1619,10 @@ class PersonaPhoneNumberTypeValue(EWSElement): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/value-personaphonenumbertype """ - ELEMENT_NAME = 'Value' + ELEMENT_NAME = "Value" - number = CharField(field_uri='Number') - type = CharField(field_uri='Type') + number = CharField(field_uri="Number") + type = CharField(field_uri="Type") class PhoneNumberAttributedValue(EWSElement): @@ -1553,10 +1630,10 @@ class PhoneNumberAttributedValue(EWSElement): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/phonenumberattributedvalue """ - ELEMENT_NAME = 'PhoneNumberAttributedValue' + ELEMENT_NAME = "PhoneNumberAttributedValue" value = EWSElementField(value_cls=PersonaPhoneNumberTypeValue) - attributions = CharListField(field_uri='Attributions', list_elem_name='Attribution') + attributions = CharListField(field_uri="Attributions", list_elem_name="Attribution") class EmailAddressTypeValue(Mailbox): @@ -1564,9 +1641,9 @@ class EmailAddressTypeValue(Mailbox): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/value-emailaddresstype """ - ELEMENT_NAME = 'Value' + ELEMENT_NAME = "Value" - original_display_name = TextField(field_uri='OriginalDisplayName') + original_display_name = TextField(field_uri="OriginalDisplayName") class EmailAddressAttributedValue(EWSElement): @@ -1574,10 +1651,10 @@ class EmailAddressAttributedValue(EWSElement): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/emailaddressattributedvalue """ - ELEMENT_NAME = 'EmailAddressAttributedValue' + ELEMENT_NAME = "EmailAddressAttributedValue" value = EWSElementField(value_cls=EmailAddressTypeValue) - attributions = EWSElementListField(field_uri='Attributions', value_cls=Attribution) + attributions = EWSElementListField(field_uri="Attributions", value_cls=Attribution) class PersonaPostalAddressTypeValue(Mailbox): @@ -1585,23 +1662,23 @@ class PersonaPostalAddressTypeValue(Mailbox): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/value-personapostaladdresstype """ - ELEMENT_NAME = 'Value' - - street = TextField(field_uri='Street') - city = TextField(field_uri='City') - state = TextField(field_uri='State') - country = TextField(field_uri='Country') - postal_code = TextField(field_uri='PostalCode') - post_office_box = TextField(field_uri='PostOfficeBox') - type = TextField(field_uri='Type') - latitude = TextField(field_uri='Latitude') - longitude = TextField(field_uri='Longitude') - accuracy = TextField(field_uri='Accuracy') - altitude = TextField(field_uri='Altitude') - altitude_accuracy = TextField(field_uri='AltitudeAccuracy') - formatted_address = TextField(field_uri='FormattedAddress') - location_uri = TextField(field_uri='LocationUri') - location_source = TextField(field_uri='LocationSource') + ELEMENT_NAME = "Value" + + street = TextField(field_uri="Street") + city = TextField(field_uri="City") + state = TextField(field_uri="State") + country = TextField(field_uri="Country") + postal_code = TextField(field_uri="PostalCode") + post_office_box = TextField(field_uri="PostOfficeBox") + type = TextField(field_uri="Type") + latitude = TextField(field_uri="Latitude") + longitude = TextField(field_uri="Longitude") + accuracy = TextField(field_uri="Accuracy") + altitude = TextField(field_uri="Altitude") + altitude_accuracy = TextField(field_uri="AltitudeAccuracy") + formatted_address = TextField(field_uri="FormattedAddress") + location_uri = TextField(field_uri="LocationUri") + location_source = TextField(field_uri="LocationSource") class PostalAddressAttributedValue(EWSElement): @@ -1609,28 +1686,28 @@ class PostalAddressAttributedValue(EWSElement): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/postaladdressattributedvalue """ - ELEMENT_NAME = 'PostalAddressAttributedValue' + ELEMENT_NAME = "PostalAddressAttributedValue" value = EWSElementField(value_cls=PersonaPostalAddressTypeValue) - attributions = EWSElementListField(field_uri='Attributions', value_cls=Attribution) + attributions = EWSElementListField(field_uri="Attributions", value_cls=Attribution) class Event(EWSElement, metaclass=EWSMeta): """Base class for all event types.""" - watermark = CharField(field_uri='Watermark') + watermark = CharField(field_uri="Watermark") class TimestampEvent(Event, metaclass=EWSMeta): """Base class for both item and folder events with a timestamp.""" - FOLDER = 'folder' - ITEM = 'item' + FOLDER = "folder" + ITEM = "item" - timestamp = DateTimeField(field_uri='TimeStamp') - item_id = EWSElementField(field_uri='ItemId', value_cls=ItemId) - folder_id = EWSElementField(field_uri='FolderId', value_cls=FolderId) - parent_folder_id = EWSElementField(field_uri='ParentFolderId', value_cls=ParentFolderId) + timestamp = DateTimeField(field_uri="TimeStamp") + item_id = EWSElementField(field_uri="ItemId", value_cls=ItemId) + folder_id = EWSElementField(field_uri="FolderId", value_cls=FolderId) + parent_folder_id = EWSElementField(field_uri="ParentFolderId", value_cls=ParentFolderId) @property def event_type(self): @@ -1644,59 +1721,59 @@ def event_type(self): class OldTimestampEvent(TimestampEvent, metaclass=EWSMeta): """Base class for both item and folder copy/move events.""" - old_item_id = EWSElementField(field_uri='OldItemId', value_cls=ItemId) - old_folder_id = EWSElementField(field_uri='OldFolderId', value_cls=FolderId) - old_parent_folder_id = EWSElementField(field_uri='OldParentFolderId', value_cls=ParentFolderId) + old_item_id = EWSElementField(field_uri="OldItemId", value_cls=ItemId) + old_folder_id = EWSElementField(field_uri="OldFolderId", value_cls=FolderId) + old_parent_folder_id = EWSElementField(field_uri="OldParentFolderId", value_cls=ParentFolderId) class CopiedEvent(OldTimestampEvent): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/copiedevent""" - ELEMENT_NAME = 'CopiedEvent' + ELEMENT_NAME = "CopiedEvent" class CreatedEvent(TimestampEvent): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createdevent""" - ELEMENT_NAME = 'CreatedEvent' + ELEMENT_NAME = "CreatedEvent" class DeletedEvent(TimestampEvent): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deletedevent""" - ELEMENT_NAME = 'DeletedEvent' + ELEMENT_NAME = "DeletedEvent" class ModifiedEvent(TimestampEvent): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/modifiedevent""" - ELEMENT_NAME = 'ModifiedEvent' + ELEMENT_NAME = "ModifiedEvent" - unread_count = IntegerField(field_uri='UnreadCount') + unread_count = IntegerField(field_uri="UnreadCount") class MovedEvent(OldTimestampEvent): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/movedevent""" - ELEMENT_NAME = 'MovedEvent' + ELEMENT_NAME = "MovedEvent" class NewMailEvent(TimestampEvent): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/newmailevent""" - ELEMENT_NAME = 'NewMailEvent' + ELEMENT_NAME = "NewMailEvent" class StatusEvent(Event): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/statusevent""" - ELEMENT_NAME = 'StatusEvent' + ELEMENT_NAME = "StatusEvent" class FreeBusyChangedEvent(TimestampEvent): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/freebusychangedevent""" - ELEMENT_NAME = 'FreeBusyChangedEvent' + ELEMENT_NAME = "FreeBusyChangedEvent" class Notification(EWSElement): @@ -1704,30 +1781,31 @@ class Notification(EWSElement): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/notification-ex15websvcsotherref """ - ELEMENT_NAME = 'Notification' + ELEMENT_NAME = "Notification" NAMESPACE = MNS - subscription_id = CharField(field_uri='SubscriptionId') - previous_watermark = CharField(field_uri='PreviousWatermark') - more_events = BooleanField(field_uri='MoreEvents') - events = GenericEventListField('') + subscription_id = CharField(field_uri="SubscriptionId") + previous_watermark = CharField(field_uri="PreviousWatermark") + more_events = BooleanField(field_uri="MoreEvents") + events = GenericEventListField("") class BaseTransition(EWSElement, metaclass=EWSMeta): """Base class for all other transition classes""" - to = CharField(field_uri='To') - kind = CharField(field_uri='Kind', is_attribute=True) # An attribute on the 'To' element + to = CharField(field_uri="To") + kind = CharField(field_uri="Kind", is_attribute=True) # An attribute on the 'To' element @staticmethod def transition_model_from_tag(tag): - return {cls.response_tag(): cls for cls in ( - Transition, AbsoluteDateTransition, RecurringDateTransition, RecurringDayTransition - )}[tag] + return { + cls.response_tag(): cls + for cls in (Transition, AbsoluteDateTransition, RecurringDateTransition, RecurringDayTransition) + }[tag] @classmethod def from_xml(cls, elem, account): - kind = elem.find(cls.get_field_by_fieldname('to').response_tag()).get('Kind') + kind = elem.find(cls.get_field_by_fieldname("to").response_tag()).get("Kind") res = super().from_xml(elem=elem, account=account) res.kind = kind return res @@ -1736,27 +1814,27 @@ def from_xml(cls, elem, account): class Transition(BaseTransition): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/transition""" - ELEMENT_NAME = 'Transition' + ELEMENT_NAME = "Transition" class AbsoluteDateTransition(BaseTransition): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/absolutedatetransition""" - ELEMENT_NAME = 'AbsoluteDateTransition' + ELEMENT_NAME = "AbsoluteDateTransition" - date = DateTimeBackedDateField(field_uri='DateTime') + date = DateTimeBackedDateField(field_uri="DateTime") class RecurringDayTransition(BaseTransition): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recurringdaytransition""" - ELEMENT_NAME = 'RecurringDayTransition' + ELEMENT_NAME = "RecurringDayTransition" - offset = TimeDeltaField(field_uri='TimeOffset') - month = IntegerField(field_uri='Month') + offset = TimeDeltaField(field_uri="TimeOffset") + month = IntegerField(field_uri="Month") # Valid ISO 8601 weekday, as a number in range 1 -> 7 (1 being Monday) - day_of_week = EnumField(field_uri='DayOfWeek', enum=WEEKDAY_NAMES) - occurrence = IntegerField(field_uri='Occurrence') + day_of_week = EnumField(field_uri="DayOfWeek", enum=WEEKDAY_NAMES) + occurrence = IntegerField(field_uri="Occurrence") @classmethod def from_xml(cls, elem, account): @@ -1770,24 +1848,24 @@ def from_xml(cls, elem, account): class RecurringDateTransition(BaseTransition): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recurringdatetransition""" - ELEMENT_NAME = 'RecurringDateTransition' + ELEMENT_NAME = "RecurringDateTransition" - offset = TimeDeltaField(field_uri='TimeOffset') - month = IntegerField(field_uri='Month') - day = IntegerField(field_uri='Day') # Day of month + offset = TimeDeltaField(field_uri="TimeOffset") + month = IntegerField(field_uri="Month") + day = IntegerField(field_uri="Day") # Day of month class Period(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/period""" - ELEMENT_NAME = 'Period' + ELEMENT_NAME = "Period" - id = CharField(field_uri='Id', is_attribute=True) - name = CharField(field_uri='Name', is_attribute=True) - bias = TimeDeltaField(field_uri='Bias', is_attribute=True) + id = CharField(field_uri="Id", is_attribute=True) + name = CharField(field_uri="Name", is_attribute=True) + bias = TimeDeltaField(field_uri="Bias", is_attribute=True) def _split_id(self): - to_year, to_type = self.id.rsplit('/', 1)[1].split('-') + to_year, to_type = self.id.rsplit("/", 1)[1].split("-") return int(to_year), to_type @property @@ -1806,23 +1884,23 @@ def bias_in_minutes(self): class TransitionsGroup(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/transitionsgroup""" - ELEMENT_NAME = 'TransitionsGroup' + ELEMENT_NAME = "TransitionsGroup" - id = CharField(field_uri='Id', is_attribute=True) + id = CharField(field_uri="Id", is_attribute=True) transitions = TransitionListField(value_cls=BaseTransition) class TimeZoneDefinition(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezonedefinition""" - ELEMENT_NAME = 'TimeZoneDefinition' + ELEMENT_NAME = "TimeZoneDefinition" - id = CharField(field_uri='Id', is_attribute=True) - name = CharField(field_uri='Name', is_attribute=True) + id = CharField(field_uri="Id", is_attribute=True) + name = CharField(field_uri="Name", is_attribute=True) - periods = EWSElementListField(field_uri='Periods', value_cls=Period) - transitions_groups = EWSElementListField(field_uri='TransitionsGroups', value_cls=TransitionsGroup) - transitions = TransitionListField(field_uri='Transitions', value_cls=BaseTransition) + periods = EWSElementListField(field_uri="Periods", value_cls=Period) + transitions_groups = EWSElementListField(field_uri="TransitionsGroups", value_cls=TransitionsGroup) + transitions = TransitionListField(field_uri="Transitions", value_cls=BaseTransition) @classmethod def from_xml(cls, elem, account): @@ -1834,11 +1912,11 @@ def _get_standard_period(self, for_year): for period in sorted(self.periods, key=lambda p: (p.year, p.type)): if period.year > for_year: break - if period.type != 'Standard': + if period.type != "Standard": continue valid_period = period if valid_period is None: - raise TimezoneDefinitionInvalidForYear(f'Year {for_year} not included in periods {self.periods}') + raise TimezoneDefinitionInvalidForYear(f"Year {for_year} not included in periods {self.periods}") return valid_period def _get_transitions_group(self, for_year): @@ -1846,20 +1924,20 @@ def _get_transitions_group(self, for_year): transitions_group = None transitions_groups_map = {tg.id: tg for tg in self.transitions_groups} for transition in sorted(self.transitions, key=lambda t: t.to): - if transition.kind != 'Group': + if transition.kind != "Group": continue if isinstance(transition, AbsoluteDateTransition) and transition.date.year > for_year: break transitions_group = transitions_groups_map[transition.to] if transitions_group is None: - raise ValueError(f'No valid transition group for year {for_year}: {self.transitions}') + raise ValueError(f"No valid transition group for year {for_year}: {self.transitions}") return transitions_group def get_std_and_dst(self, for_year): # Return 'standard_time' and 'daylight_time' objects. We do unnecessary work here, but it keeps code simple. transitions_group = self._get_transitions_group(for_year) if not 0 <= len(transitions_group.transitions) <= 2: - raise ValueError(f'Expected 0-2 transitions in transitions group {transitions_group}') + raise ValueError(f"Expected 0-2 transitions in transitions group {transitions_group}") standard_period = self._get_standard_period(for_year) periods_map = {p.id: p for p in self.periods} @@ -1882,13 +1960,13 @@ def get_std_and_dst(self, for_year): weekday=transition.day_of_week, ) period = periods_map[transition.to] - if period.name == 'Standard': - transition_kwargs['bias'] = 0 + if period.name == "Standard": + transition_kwargs["bias"] = 0 standard_time = StandardTime(**transition_kwargs) continue - if period.name == 'Daylight': - transition_kwargs['bias'] = period.bias_in_minutes - standard_period.bias_in_minutes + if period.name == "Daylight": + transition_kwargs["bias"] = period.bias_in_minutes - standard_period.bias_in_minutes daylight_time = DaylightTime(**transition_kwargs) continue - raise ValueError(f'Unknown transition: {transition}') + raise ValueError(f"Unknown transition: {transition}") return standard_time, daylight_time, standard_period diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 234d8fd0..84daa965 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -8,7 +8,7 @@ import datetime import logging import random -from queue import LifoQueue, Empty +from queue import Empty, LifoQueue from threading import Lock import requests.adapters @@ -17,13 +17,30 @@ from requests_oauthlib import OAuth2Session from .credentials import OAuth2AuthorizationCodeCredentials, OAuth2Credentials -from .errors import TransportError, SessionPoolMinSizeReached, SessionPoolMaxSizeReached, RateLimitError, CASError, \ - ErrorInvalidSchemaVersionForMailboxVersion, UnauthorizedError, MalformedResponseError, InvalidTypeError -from .properties import FreeBusyViewOptions, MailboxData, TimeWindow, TimeZone, RoomList, DLMailbox -from .services import GetServerTimeZones, GetRoomLists, GetRooms, ResolveNames, GetUserAvailability, \ - GetSearchableMailboxes, ExpandDL, ConvertId -from .transport import get_auth_instance, get_service_authtype, NTLM, OAUTH2, CREDENTIALS_REQUIRED, DEFAULT_HEADERS -from .version import Version, API_VERSIONS +from .errors import ( + CASError, + ErrorInvalidSchemaVersionForMailboxVersion, + InvalidTypeError, + MalformedResponseError, + RateLimitError, + SessionPoolMaxSizeReached, + SessionPoolMinSizeReached, + TransportError, + UnauthorizedError, +) +from .properties import DLMailbox, FreeBusyViewOptions, MailboxData, RoomList, TimeWindow, TimeZone +from .services import ( + ConvertId, + ExpandDL, + GetRoomLists, + GetRooms, + GetSearchableMailboxes, + GetServerTimeZones, + GetUserAvailability, + ResolveNames, +) +from .transport import CREDENTIALS_REQUIRED, DEFAULT_HEADERS, NTLM, OAUTH2, get_auth_instance, get_service_authtype +from .version import API_VERSIONS, Version log = logging.getLogger(__name__) @@ -103,7 +120,7 @@ def server(self): def get_auth_type(self): # Autodetect authentication type. We also set version hint here. - name = str(self.credentials) if self.credentials and str(self.credentials) else 'DUMMY' + name = str(self.credentials) if self.credentials and str(self.credentials) else "DUMMY" auth_type, api_version_hint = get_service_authtype( service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS, name=name ) @@ -113,8 +130,8 @@ def get_auth_type(self): def __getstate__(self): # The session pool and lock cannot be pickled state = self.__dict__.copy() - del state['_session_pool'] - del state['_session_pool_lock'] + del state["_session_pool"] + del state["_session_pool_lock"] return state def __setstate__(self, state): @@ -132,7 +149,7 @@ def __del__(self): pass def close(self): - log.debug('Server %s: Closing sessions', self.server) + log.debug("Server %s: Closing sessions", self.server) while True: try: session = self._session_pool.get(block=False) @@ -160,13 +177,17 @@ def increase_poolsize(self): # Create a single session and insert it into the pool. We need to protect this with a lock while we are changing # the pool size variable, to avoid race conditions. We must not exceed the pool size limit. if self._session_pool_size >= self._session_pool_maxsize: - raise SessionPoolMaxSizeReached('Session pool size cannot be increased further') + raise SessionPoolMaxSizeReached("Session pool size cannot be increased further") with self._session_pool_lock: if self._session_pool_size >= self._session_pool_maxsize: - log.debug('Session pool size was increased in another thread') + log.debug("Session pool size was increased in another thread") return - log.debug('Server %s: Increasing session pool size from %s to %s', self.server, self._session_pool_size, - self._session_pool_size + 1) + log.debug( + "Server %s: Increasing session pool size from %s to %s", + self.server, + self._session_pool_size, + self._session_pool_size + 1, + ) self._session_pool.put(self.create_session(), block=False) self._session_pool_size += 1 @@ -177,13 +198,17 @@ def decrease_poolsize(self): # Take a single session from the pool and discard it. We need to protect this with a lock while we are changing # the pool size variable, to avoid race conditions. We must keep at least one session in the pool. if self._session_pool_size <= 1: - raise SessionPoolMinSizeReached('Session pool size cannot be decreased further') + raise SessionPoolMinSizeReached("Session pool size cannot be decreased further") with self._session_pool_lock: if self._session_pool_size <= 1: - log.debug('Session pool size was decreased in another thread') + log.debug("Session pool size was decreased in another thread") return - log.warning('Server %s: Decreasing session pool size from %s to %s', self.server, self._session_pool_size, - self._session_pool_size - 1) + log.warning( + "Server %s: Decreasing session pool size from %s to %s", + self.server, + self._session_pool_size, + self._session_pool_size - 1, + ) session = self.get_session() self.close_session(session) self._session_pool_size -= 1 @@ -194,7 +219,7 @@ def get_session(self): _timeout = 60 # Rate-limit messages about session starvation try: session = self._session_pool.get(block=False) - log.debug('Server %s: Got session immediately', self.server) + log.debug("Server %s: Got session immediately", self.server) except Empty: try: self.increase_poolsize() @@ -202,21 +227,21 @@ def get_session(self): pass while True: try: - log.debug('Server %s: Waiting for session', self.server) + log.debug("Server %s: Waiting for session", self.server) session = self._session_pool.get(timeout=_timeout) break except Empty: # This is normal when we have many worker threads starving for available sessions - log.debug('Server %s: No sessions available for %s seconds', self.server, _timeout) - log.debug('Server %s: Got session %s', self.server, session.session_id) + log.debug("Server %s: No sessions available for %s seconds", self.server, _timeout) + log.debug("Server %s: Got session %s", self.server, session.session_id) session.usage_count += 1 return session def release_session(self, session): # This should never fail, as we don't have more sessions than the queue contains - log.debug('Server %s: Releasing session %s', self.server, session.session_id) + log.debug("Server %s: Releasing session %s", self.server, session.session_id) if self.MAX_SESSION_USAGE_COUNT and session.usage_count >= self.MAX_SESSION_USAGE_COUNT: - log.debug('Server %s: session %s usage exceeded limit. Discarding', self.server, session.session_id) + log.debug("Server %s: session %s usage exceeded limit. Discarding", self.server, session.session_id) session = self.renew_session(session) self._session_pool.put(session, block=False) @@ -227,13 +252,13 @@ def close_session(session): def retire_session(self, session): # The session is useless. Close it completely and place a fresh session in the pool - log.debug('Server %s: Retiring session %s', self.server, session.session_id) + log.debug("Server %s: Retiring session %s", self.server, session.session_id) self.close_session(session) self.release_session(self.create_session()) def renew_session(self, session): # The session is useless. Close it completely and place a fresh session in the pool - log.debug('Server %s: Renewing session %s', self.server, session.session_id) + log.debug("Server %s: Renewing session %s", self.server, session.session_id) self.close_session(session) return self.create_session() @@ -254,7 +279,7 @@ def refresh_credentials(self, session): def create_session(self): if self.credentials is None: if self.auth_type in CREDENTIALS_REQUIRED: - raise ValueError(f'Auth type {self.auth_type!r} requires credentials') + raise ValueError(f"Auth type {self.auth_type!r} requires credentials") session = self.raw_session(self.service_endpoint) session.auth = get_auth_instance(auth_type=self.auth_type) else: @@ -269,42 +294,43 @@ def create_session(self): session.credentials_sig = self.credentials.sig() else: if self.auth_type == NTLM and self.credentials.type == self.credentials.EMAIL: - username = '\\' + self.credentials.username + username = "\\" + self.credentials.username else: username = self.credentials.username session = self.raw_session(self.service_endpoint) - session.auth = get_auth_instance(auth_type=self.auth_type, username=username, - password=self.credentials.password) + session.auth = get_auth_instance( + auth_type=self.auth_type, username=username, password=self.credentials.password + ) # Add some extra info session.session_id = random.randint(10000, 99999) # Used for debugging messages in services session.usage_count = 0 - log.debug('Server %s: Created session %s', self.server, session.session_id) + log.debug("Server %s: Created session %s", self.server, session.session_id) return session def create_oauth2_session(self): has_token = False - scope = ['https://outlook.office365.com/.default'] + scope = ["https://outlook.office365.com/.default"] session_params = {} token_params = {} if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials): # Ask for a refresh token - scope.append('offline_access') + scope.append("offline_access") # We don't know (or need) the Microsoft tenant ID. Use # common/ to let Microsoft select the appropriate tenant # for the provided authorization code or refresh token. # # Suppress looks-like-password warning from Bandit. - token_url = 'https://login.microsoftonline.com/common/oauth2/v2.0/token' # nosec + token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token" # nosec client_params = {} has_token = self.credentials.access_token is not None if has_token: - session_params['token'] = self.credentials.access_token + session_params["token"] = self.credentials.access_token elif self.credentials.authorization_code is not None: - token_params['code'] = self.credentials.authorization_code + token_params["code"] = self.credentials.authorization_code self.credentials.authorization_code = None if self.credentials.client_id is not None and self.credentials.client_secret is not None: @@ -315,25 +341,32 @@ def create_oauth2_session(self): # covers cases where the caller doesn't have access to # the client secret but is working with a service that # can provide it refreshed tokens on a limited basis). - session_params.update({ - 'auto_refresh_kwargs': { - 'client_id': self.credentials.client_id, - 'client_secret': self.credentials.client_secret, - }, - 'auto_refresh_url': token_url, - 'token_updater': self.credentials.on_token_auto_refreshed, - }) + session_params.update( + { + "auto_refresh_kwargs": { + "client_id": self.credentials.client_id, + "client_secret": self.credentials.client_secret, + }, + "auto_refresh_url": token_url, + "token_updater": self.credentials.on_token_auto_refreshed, + } + ) client = WebApplicationClient(self.credentials.client_id, **client_params) else: - token_url = f'https://login.microsoftonline.com/{self.credentials.tenant_id}/oauth2/v2.0/token' + token_url = f"https://login.microsoftonline.com/{self.credentials.tenant_id}/oauth2/v2.0/token" client = BackendApplicationClient(client_id=self.credentials.client_id) session = self.raw_session(self.service_endpoint, oauth2_client=client, oauth2_session_params=session_params) if not has_token: # Fetch the token explicitly -- it doesn't occur implicitly - token = session.fetch_token(token_url=token_url, client_id=self.credentials.client_id, - client_secret=self.credentials.client_secret, scope=scope, - timeout=self.TIMEOUT, **token_params) + token = session.fetch_token( + token_url=token_url, + client_id=self.credentials.client_id, + client_secret=self.credentials.client_secret, + scope=scope, + timeout=self.TIMEOUT, + **token_params, + ) # Allow the credentials object to update its copy of the new # token, and give the application an opportunity to cache it self.credentials.on_token_auto_refreshed(token) @@ -348,7 +381,7 @@ def raw_session(cls, prefix, oauth2_client=None, oauth2_session_params=None): else: session = requests.sessions.Session() session.headers.update(DEFAULT_HEADERS) - session.headers['User-Agent'] = cls.USERAGENT + session.headers["User-Agent"] = cls.USERAGENT session.mount(prefix, adapter=cls.get_adapter()) return session @@ -369,10 +402,11 @@ def __call__(cls, *args, **kwargs): # # We ignore auth_type from kwargs in the cache key. We trust caller to supply the correct auth_type - otherwise # __init__ will guess the correct auth type. - config = kwargs['config'] + config = kwargs["config"] from .configuration import Configuration + if not isinstance(config, Configuration): - raise InvalidTypeError('config', config, Configuration) + raise InvalidTypeError("config", config, Configuration) if not config.service_endpoint: raise AttributeError("'config.service_endpoint' must be set") _protocol_cache_key = cls._cache_key(config) @@ -389,7 +423,7 @@ def __call__(cls, *args, **kwargs): # Acquire lock to guard against multiple threads competing to cache information. Having a per-server lock is # probably overkill although it would reduce lock contention. - log.debug('Waiting for _protocol_cache_lock') + log.debug("Waiting for _protocol_cache_lock") with cls._protocol_cache_lock: try: protocol, _ = cls._protocol_cache[_protocol_cache_key] @@ -407,7 +441,7 @@ def __call__(cls, *args, **kwargs): protocol = super().__call__(*args, **kwargs) except TransportError as e: # This can happen if, for example, autodiscover supplies us with a bogus EWS endpoint - log.warning('Failed to create cached protocol with key %s: %s', _protocol_cache_key, e) + log.warning("Failed to create cached protocol with key %s: %s", _protocol_cache_key, e) cls._protocol_cache[_protocol_cache_key] = e, datetime.datetime.now() raise e cls._protocol_cache[_protocol_cache_key] = protocol, datetime.datetime.now() @@ -471,7 +505,7 @@ def get_timezones(self, timezones=None, return_full_timezone_data=False): timezones=timezones, return_full_timezone_data=return_full_timezone_data ) - def get_free_busy_info(self, accounts, start, end, merged_free_busy_interval=30, requested_view='DetailedMerged'): + def get_free_busy_info(self, accounts, start, end, merged_free_busy_interval=30, requested_view="DetailedMerged"): """Return free/busy information for a list of accounts. :param accounts: A list of (account, attendee_type, exclude_conflicts) tuples, where account is either an @@ -486,22 +520,23 @@ def get_free_busy_info(self, accounts, start, end, merged_free_busy_interval=30, :return: A generator of FreeBusyView objects """ from .account import Account - tz_definition = list(self.get_timezones( - timezones=[start.tzinfo], - return_full_timezone_data=True - ))[0] + + tz_definition = list(self.get_timezones(timezones=[start.tzinfo], return_full_timezone_data=True))[0] return GetUserAvailability(self).call( - timezone=TimeZone.from_server_timezone(tz_definition=tz_definition, for_year=start.year), - mailbox_data=[MailboxData( + timezone=TimeZone.from_server_timezone(tz_definition=tz_definition, for_year=start.year), + mailbox_data=[ + MailboxData( email=account.primary_smtp_address if isinstance(account, Account) else account, attendee_type=attendee_type, - exclude_conflicts=exclude_conflicts - ) for account, attendee_type, exclude_conflicts in accounts], - free_busy_view_options=FreeBusyViewOptions( - time_window=TimeWindow(start=start, end=end), - merged_free_busy_interval=merged_free_busy_interval, - requested_view=requested_view, - ), + exclude_conflicts=exclude_conflicts, + ) + for account, attendee_type, exclude_conflicts in accounts + ], + free_busy_view_options=FreeBusyViewOptions( + time_window=TimeWindow(start=start, end=end), + merged_free_busy_interval=merged_free_busy_interval, + requested_view=requested_view, + ), ) def get_roomlists(self): @@ -510,8 +545,7 @@ def get_roomlists(self): def get_rooms(self, roomlist): return GetRooms(protocol=self).call(room_list=RoomList(email_address=roomlist)) - def resolve_names(self, names, parent_folders=None, return_full_contact_data=False, search_scope=None, - shape=None): + def resolve_names(self, names, parent_folders=None, return_full_contact_data=False, search_scope=None, shape=None): """Resolve accounts on the server using partial account data, e.g. an email address or initials. :param names: A list of identifiers to query @@ -522,10 +556,15 @@ def resolve_names(self, names, parent_folders=None, return_full_contact_data=Fal :return: A list of Mailbox items or, if return_full_contact_data is True, tuples of (Mailbox, Contact) items """ - return list(ResolveNames(protocol=self).call( - unresolved_entries=names, parent_folders=parent_folders, return_full_contact_data=return_full_contact_data, - search_scope=search_scope, contact_data_shape=shape, - )) + return list( + ResolveNames(protocol=self).call( + unresolved_entries=names, + parent_folders=parent_folders, + return_full_contact_data=return_full_contact_data, + search_scope=search_scope, + contact_data_shape=shape, + ) + ) def expand_dl(self, distribution_list): """Expand distribution list into it's members. @@ -535,7 +574,7 @@ def expand_dl(self, distribution_list): :return: List of Mailbox items that are members of the distribution list """ if isinstance(distribution_list, str): - distribution_list = DLMailbox(email_address=distribution_list, mailbox_type='PublicDL') + distribution_list = DLMailbox(email_address=distribution_list, mailbox_type="PublicDL") return list(ExpandDL(protocol=self).call(distribution_list=distribution_list)) def get_searchable_mailboxes(self, search_filter=None, expand_group_membership=False): @@ -549,10 +588,12 @@ def get_searchable_mailboxes(self, search_filter=None, expand_group_membership=F :return: a list of SearchableMailbox, FailedMailbox or Exception instances """ - return list(GetSearchableMailboxes(protocol=self).call( - search_filter=search_filter, - expand_group_membership=expand_group_membership, - )) + return list( + GetSearchableMailboxes(protocol=self).call( + search_filter=search_filter, + expand_group_membership=expand_group_membership, + ) + ) def convert_ids(self, ids, destination_format): """Convert item and folder IDs between multiple formats. @@ -567,7 +608,7 @@ def convert_ids(self, ids, destination_format): def __getstate__(self): # The lock cannot be pickled state = super().__getstate__() - del state['_version_lock'] + del state["_version_lock"] return state def __setstate__(self, state): @@ -580,14 +621,14 @@ def __str__(self): if self.config.version: fullname, api_version, build = self.version.fullname, self.version.api_version, self.version.build else: - fullname, api_version, build = '[unknown]', '[unknown]', '[unknown]' + fullname, api_version, build = "[unknown]", "[unknown]", "[unknown]" - return f'''\ + return f"""\ EWS url: {self.service_endpoint} Product name: {fullname} EWS API version: {api_version} Build number: {build} -EWS auth: {self.auth_type}''' +EWS auth: {self.auth_type}""" class NoVerifyHTTPAdapter(requests.adapters.HTTPAdapter): @@ -605,7 +646,7 @@ class TLSClientAuth(requests.adapters.HTTPAdapter): cert_file = None def init_poolmanager(self, *args, **kwargs): - kwargs['cert_file'] = self.cert_file + kwargs["cert_file"] = self.cert_file return super().init_poolmanager(*args, **kwargs) @@ -637,28 +678,30 @@ def may_retry_on_error(self, response, wait): """Return whether retries should still be attempted""" def raise_response_errors(self, response): - cas_error = response.headers.get('X-CasErrorCode') + cas_error = response.headers.get("X-CasErrorCode") if cas_error: - if cas_error.startswith('CAS error:'): + if cas_error.startswith("CAS error:"): # Remove unnecessary text - cas_error = cas_error.split(':', 1)[1].strip() + cas_error = cas_error.split(":", 1)[1].strip() raise CASError(cas_error=cas_error, response=response) - if response.status_code == 500 and (b'The specified server version is invalid' in response.content or - b'ErrorInvalidSchemaVersionForMailboxVersion' in response.content): + if response.status_code == 500 and ( + b"The specified server version is invalid" in response.content + or b"ErrorInvalidSchemaVersionForMailboxVersion" in response.content + ): # Another way of communicating invalid schema versions - raise ErrorInvalidSchemaVersionForMailboxVersion('Invalid server version') - if b'The referenced account is currently locked out' in response.content: - raise UnauthorizedError('The referenced account is currently locked out') + raise ErrorInvalidSchemaVersionForMailboxVersion("Invalid server version") + if b"The referenced account is currently locked out" in response.content: + raise UnauthorizedError("The referenced account is currently locked out") if response.status_code == 401 and self.fail_fast: # This is a login failure - raise UnauthorizedError(f'Invalid credentials for {response.url}') - if 'TimeoutException' in response.headers: + raise UnauthorizedError(f"Invalid credentials for {response.url}") + if "TimeoutException" in response.headers: # A header set by us on CONNECTION_ERRORS - raise response.headers['TimeoutException'] + raise response.headers["TimeoutException"] # This could be anything. Let higher layers handle this raise MalformedResponseError( - f'Unknown failure in response. Code: {response.status_code} headers: {response.headers} ' - f'content: {response.text}' + f"Unknown failure in response. Code: {response.status_code} headers: {response.headers} " + f"content: {response.text}" ) @@ -674,10 +717,10 @@ def back_off_until(self): return None def back_off(self, seconds): - raise ValueError('Cannot back off with fail-fast policy') + raise ValueError("Cannot back off with fail-fast policy") def may_retry_on_error(self, response, wait): - log.debug('No retry: no fail-fast policy') + log.debug("No retry: no fail-fast policy") return False @@ -697,7 +740,7 @@ def __init__(self, max_wait=3600): def __getstate__(self): # Locks cannot be pickled state = self.__dict__.copy() - del state['_back_off_lock'] + del state["_back_off_lock"] return state def __setstate__(self, state): @@ -737,20 +780,24 @@ def back_off(self, seconds): def may_retry_on_error(self, response, wait): if response.status_code not in (301, 302, 401, 500, 503): # Don't retry if we didn't get a status code that we can hope to recover from - log.debug('No retry: wrong status code %s', response.status_code) + log.debug("No retry: wrong status code %s", response.status_code) return False if wait > self.max_wait: # We lost patience. Session is cleaned up in outer loop raise RateLimitError( - 'Max timeout reached', url=response.url, status_code=response.status_code, total_wait=wait) + "Max timeout reached", url=response.url, status_code=response.status_code, total_wait=wait + ) if response.status_code == 401: # EWS sometimes throws 401's when it wants us to throttle connections. OK to retry. return True - if response.headers.get('connection') == 'close': + if response.headers.get("connection") == "close": # Connection closed. OK to retry. return True - if response.status_code == 302 and response.headers.get('location', '').lower() \ - == '/ews/genericerrorpage.htm?aspxerrorpath=/ews/exchange.asmx': + if ( + response.status_code == 302 + and response.headers.get("location", "").lower() + == "/ews/genericerrorpage.htm?aspxerrorpath=/ews/exchange.asmx" + ): # The genericerrorpage.htm/internalerror.asp is ridiculous behaviour for random outages. OK to retry. # # Redirect to '/internalsite/internalerror.asp' or '/internalsite/initparams.aspx' is caused by e.g. TLS @@ -761,6 +808,6 @@ def may_retry_on_error(self, response, wait): return True if response.status_code == 500 and b"Server Error in '/EWS' Application" in response.content: # "Server Error in '/EWS' Application" has been seen in highly concurrent settings. OK to retry. - log.debug('Retry allowed: conditions met') + log.debug("Retry allowed: conditions met") return True return False diff --git a/exchangelib/queryset.py b/exchangelib/queryset.py index d8c3cfbe..797c3723 100644 --- a/exchangelib/queryset.py +++ b/exchangelib/queryset.py @@ -3,9 +3,9 @@ from copy import deepcopy from itertools import islice -from .errors import MultipleObjectsReturned, DoesNotExist, InvalidEnumValue, InvalidTypeError, ErrorItemNotFound -from .fields import FieldPath, FieldOrder -from .items import CalendarItem, ID_ONLY +from .errors import DoesNotExist, ErrorItemNotFound, InvalidEnumValue, InvalidTypeError, MultipleObjectsReturned +from .fields import FieldOrder, FieldPath +from .items import ID_ONLY, CalendarItem from .properties import InvalidField from .restriction import Q from .version import EXCHANGE_2010 @@ -50,23 +50,24 @@ class QuerySet(SearchableMixIn): Django QuerySet documentation: https://docs.djangoproject.com/en/dev/ref/models/querysets/ """ - VALUES = 'values' - VALUES_LIST = 'values_list' - FLAT = 'flat' - NONE = 'none' + VALUES = "values" + VALUES_LIST = "values_list" + FLAT = "flat" + NONE = "none" RETURN_TYPES = (VALUES, VALUES_LIST, FLAT, NONE) - ITEM = 'item' - PERSONA = 'persona' + ITEM = "item" + PERSONA = "persona" REQUEST_TYPES = (ITEM, PERSONA) def __init__(self, folder_collection, request_type=ITEM): from .folders import FolderCollection + if not isinstance(folder_collection, FolderCollection): - raise InvalidTypeError('folder_collection', folder_collection, FolderCollection) + raise InvalidTypeError("folder_collection", folder_collection, FolderCollection) self.folder_collection = folder_collection # A FolderCollection instance if request_type not in self.REQUEST_TYPES: - raise InvalidEnumValue('request_type', request_type, self.REQUEST_TYPES) + raise InvalidEnumValue("request_type", request_type, self.REQUEST_TYPES) self.request_type = request_type self.q = Q() # Default to no restrictions self.only_fields = None @@ -105,6 +106,7 @@ def _copy_self(self): def _get_field_path(self, field_path): from .items import Persona + if self.request_type == self.PERSONA: return FieldPath(field=Persona.get_field_by_fieldname(field_path)) for folder in self.folder_collection: @@ -116,10 +118,11 @@ def _get_field_path(self, field_path): def _get_field_order(self, field_path): from .items import Persona + if self.request_type == self.PERSONA: return FieldOrder( - field_path=FieldPath(field=Persona.get_field_by_fieldname(field_path.lstrip('-'))), - reverse=field_path.startswith('-'), + field_path=FieldPath(field=Persona.get_field_by_fieldname(field_path.lstrip("-"))), + reverse=field_path.startswith("-"), ) for folder in self.folder_collection: try: @@ -130,23 +133,23 @@ def _get_field_order(self, field_path): @property def _id_field(self): - return self._get_field_path('id') + return self._get_field_path("id") @property def _changekey_field(self): - return self._get_field_path('changekey') + return self._get_field_path("changekey") def _additional_fields(self): if not isinstance(self.only_fields, tuple): - raise InvalidTypeError('only_fields', self.only_fields, tuple) + raise InvalidTypeError("only_fields", self.only_fields, tuple) # Remove ItemId and ChangeKey. We get them unconditionally additional_fields = {f for f in self.only_fields if not f.field.is_attribute} if self.request_type != self.ITEM: return additional_fields # For CalendarItem items, we want to inject internal timezone fields into the requested fields. - has_start = 'start' in {f.field.name for f in additional_fields} - has_end = 'end' in {f.field.name for f in additional_fields} + has_start = "start" in {f.field.name for f in additional_fields} + has_end = "end" in {f.field.name for f in additional_fields} meeting_tz_field, start_tz_field, end_tz_field = CalendarItem.timezone_fields() if self.folder_collection.account.version.build < EXCHANGE_2010: if has_start or has_end: @@ -207,20 +210,20 @@ def _query(self): ) if self.request_type == self.PERSONA: if complex_fields_requested: - find_kwargs['additional_fields'] = None + find_kwargs["additional_fields"] = None items = self.folder_collection.account.fetch_personas( ids=self.folder_collection.find_people(self.q, **find_kwargs) ) else: if not additional_fields: - find_kwargs['additional_fields'] = None + find_kwargs["additional_fields"] = None items = self.folder_collection.find_people(self.q, **find_kwargs) else: - find_kwargs['calendar_view'] = self.calendar_view + find_kwargs["calendar_view"] = self.calendar_view if complex_fields_requested: # The FindItem service does not support complex field types. Tell find_items() to return # (id, changekey) tuples, and pass that to fetch(). - find_kwargs['additional_fields'] = None + find_kwargs["additional_fields"] = None unfiltered_items = self.folder_collection.account.fetch( ids=self.folder_collection.find_items(self.q, **find_kwargs), only_fields=additional_fields, @@ -233,7 +236,7 @@ def _query(self): # If additional_fields is the empty set, we only requested ID and changekey fields. We can then # take a shortcut by using (shape=ID_ONLY, additional_fields=None) to tell find_items() to return # (id, changekey) tuples. We'll post-process those later. - find_kwargs['additional_fields'] = None + find_kwargs["additional_fields"] = None items = self.folder_collection.find_items(self.q, **find_kwargs) if not must_sort_clientside: @@ -246,12 +249,13 @@ def _query(self): try: items = sorted(items, key=lambda i: _get_sort_value_or_default(i, f), reverse=f.reverse) except TypeError as e: - if 'unorderable types' not in e.args[0]: + if "unorderable types" not in e.args[0]: raise raise ValueError( f"Cannot sort on field {f.field_path!r}. The field has no default value defined, and there are " f"either items with None values for this field, or the query contains exception instances " - f"(original error: {e}).") + f"(original error: {e})." + ) if not extra_order_fields: return items @@ -265,7 +269,7 @@ def __iter__(self): if self.q.is_never(): return - log.debug('Initializing cache') + log.debug("Initializing cache") yield from self._format_items(items=self._query(), return_format=self.return_format) """Do not implement __len__. The implementation of list() tries to preallocate memory by calling __len__ on the @@ -290,7 +294,7 @@ def __getitem__(self, idx_or_slice): def _getitem_idx(self, idx): if idx < 0: # Support negative indexes by reversing the queryset and negating the index value - reverse_idx = -(idx+1) + reverse_idx = -(idx + 1) return self.reverse()[reverse_idx] # Optimize by setting an exact offset and fetching only 1 item new_qs = self._copy_self() @@ -304,6 +308,7 @@ def _getitem_idx(self, idx): def _getitem_slice(self, s): from .services import PAGE_SIZE + if ((s.start or 0) < 0) or ((s.stop or 0) < 0) or ((s.step or 0) < 0): # islice() does not support negative start, stop and step. Make sure cache is full by iterating the full # query result, and then slice on the cache. @@ -350,6 +355,7 @@ def _item_yielder(self, iterable, item_func, id_only_func, changekey_only_func, def _as_items(self, iterable): from .items import Item + return self._item_yielder( iterable=iterable, item_func=lambda i: i, @@ -360,18 +366,18 @@ def _as_items(self, iterable): def _as_values(self, iterable): if not self.only_fields: - raise ValueError('values() requires at least one field name') + raise ValueError("values() requires at least one field name") return self._item_yielder( iterable=iterable, item_func=lambda i: {f.path: _get_value_or_default(f, i) for f in self.only_fields}, - id_only_func=lambda item_id, changekey: {'id': item_id}, - changekey_only_func=lambda item_id, changekey: {'changekey': changekey}, - id_and_changekey_func=lambda item_id, changekey: {'id': item_id, 'changekey': changekey}, + id_only_func=lambda item_id, changekey: {"id": item_id}, + changekey_only_func=lambda item_id, changekey: {"changekey": changekey}, + id_and_changekey_func=lambda item_id, changekey: {"id": item_id, "changekey": changekey}, ) def _as_values_list(self, iterable): if not self.only_fields: - raise ValueError('values_list() requires at least one field name') + raise ValueError("values_list() requires at least one field name") return self._item_yielder( iterable=iterable, item_func=lambda i: tuple(_get_value_or_default(f, i) for f in self.only_fields), @@ -382,7 +388,7 @@ def _as_values_list(self, iterable): def _as_flat_values_list(self, iterable): if not self.only_fields or len(self.only_fields) != 1: - raise ValueError('flat=True requires exactly one field name') + raise ValueError("flat=True requires exactly one field name") return self._item_yielder( iterable=iterable, item_func=lambda i: _get_value_or_default(self.only_fields[0], i), @@ -458,7 +464,7 @@ def order_by(self, *args): def reverse(self): """Reverses the ordering of the queryset.""" if not self.order_fields: - raise ValueError('Reversing only makes sense if there are order_by fields') + raise ValueError("Reversing only makes sense if there are order_by fields") new_qs = self._copy_self() for f in new_qs.order_fields: f.reverse = not f.reverse @@ -478,11 +484,11 @@ def values_list(self, *args, **kwargs): """Return the values of the specified field names as a list of lists. If called with flat=True and only one field name, returns a list of values. """ - flat = kwargs.pop('flat', False) + flat = kwargs.pop("flat", False) if kwargs: - raise AttributeError(f'Unknown kwargs: {kwargs}') + raise AttributeError(f"Unknown kwargs: {kwargs}") if flat and len(args) != 1: - raise ValueError('flat=True requires exactly one field name') + raise ValueError("flat=True requires exactly one field name") try: only_fields = tuple(self._get_field_path(arg) for arg in args) except ValueError as e: @@ -509,12 +515,12 @@ def depth(self, depth): def get(self, *args, **kwargs): """Assume the query will return exactly one item. Return that item.""" - if not args and set(kwargs) in ({'id'}, {'id', 'changekey'}): + if not args and set(kwargs) in ({"id"}, {"id", "changekey"}): # We allow calling get(id=..., changekey=...) to get a single item, but only if exactly these two # kwargs are present. account = self.folder_collection.account - item_id = self._id_field.field.clean(kwargs['id'], version=account.version) - changekey = self._changekey_field.field.clean(kwargs.get('changekey'), version=account.version) + item_id = self._id_field.field.clean(kwargs["id"], version=account.version) + changekey = self._changekey_field.field.clean(kwargs.get("changekey"), version=account.version) items = list(account.fetch(ids=[(item_id, changekey)], only_fields=self.only_fields)) else: new_qs = self.filter(*args, **kwargs) @@ -562,11 +568,7 @@ def delete(self, page_size=1000, chunk_size=100, **delete_kwargs): """ ids = self._id_only_copy_self() ids.page_size = page_size - return self.folder_collection.account.bulk_delete( - ids=ids, - chunk_size=chunk_size, - **delete_kwargs - ) + return self.folder_collection.account.bulk_delete(ids=ids, chunk_size=chunk_size, **delete_kwargs) def send(self, page_size=1000, chunk_size=100, **send_kwargs): """Send the items matching the query, with as little effort as possible @@ -578,11 +580,7 @@ def send(self, page_size=1000, chunk_size=100, **send_kwargs): """ ids = self._id_only_copy_self() ids.page_size = page_size - return self.folder_collection.account.bulk_send( - ids=ids, - chunk_size=chunk_size, - **send_kwargs - ) + return self.folder_collection.account.bulk_send(ids=ids, chunk_size=chunk_size, **send_kwargs) def copy(self, to_folder, page_size=1000, chunk_size=100, **copy_kwargs): """Copy the items matching the query, with as little effort as possible @@ -596,10 +594,7 @@ def copy(self, to_folder, page_size=1000, chunk_size=100, **copy_kwargs): ids = self._id_only_copy_self() ids.page_size = page_size return self.folder_collection.account.bulk_copy( - ids=ids, - to_folder=to_folder, - chunk_size=chunk_size, - **copy_kwargs + ids=ids, to_folder=to_folder, chunk_size=chunk_size, **copy_kwargs ) def move(self, to_folder, page_size=1000, chunk_size=100): @@ -647,16 +642,12 @@ def mark_as_junk(self, page_size=1000, chunk_size=1000, **mark_as_junk_kwargs): """ ids = self._id_only_copy_self() ids.page_size = page_size - return self.folder_collection.account.bulk_mark_as_junk( - ids=ids, - chunk_size=chunk_size, - **mark_as_junk_kwargs - ) + return self.folder_collection.account.bulk_mark_as_junk(ids=ids, chunk_size=chunk_size, **mark_as_junk_kwargs) def __str__(self): - fmt_args = [('q', str(self.q)), ('folders', f"[{', '.join(str(f) for f in self.folder_collection.folders)}]")] - args_str = ', '.join(f'{k}={v}' for k, v in fmt_args) - return f'{self.__class__.__name__}({args_str})' + fmt_args = [("q", str(self.q)), ("folders", f"[{', '.join(str(f) for f in self.folder_collection.folders)}]")] + args_str = ", ".join(f"{k}={v}" for k, v in fmt_args) + return f"{self.__class__.__name__}({args_str})" def _get_value_or_default(field, item): diff --git a/exchangelib/recurrence.py b/exchangelib/recurrence.py index e6192a46..3fdc5f20 100644 --- a/exchangelib/recurrence.py +++ b/exchangelib/recurrence.py @@ -1,14 +1,25 @@ import logging -from .fields import IntegerField, EnumField, WeekdaysField, DateOrDateTimeField, DateTimeField, EWSElementField, \ - IdElementField, MONTHS, WEEK_NUMBERS, WEEKDAYS, WEEKDAY_NAMES -from .properties import EWSElement, IdChangeKeyMixIn, ItemId, EWSMeta +from .fields import ( + MONTHS, + WEEK_NUMBERS, + WEEKDAY_NAMES, + WEEKDAYS, + DateOrDateTimeField, + DateTimeField, + EnumField, + EWSElementField, + IdElementField, + IntegerField, + WeekdaysField, +) +from .properties import EWSElement, EWSMeta, IdChangeKeyMixIn, ItemId log = logging.getLogger(__name__) def _month_to_str(month): - return MONTHS[month-1] if isinstance(month, int) else month + return MONTHS[month - 1] if isinstance(month, int) else month def _weekday_to_str(weekday): @@ -32,16 +43,16 @@ class AbsoluteYearlyPattern(Pattern): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/absoluteyearlyrecurrence """ - ELEMENT_NAME = 'AbsoluteYearlyRecurrence' + ELEMENT_NAME = "AbsoluteYearlyRecurrence" # The day of month of an occurrence, in range 1 -> 31. If a particular month has less days than the day_of_month # value, the last day in the month is assumed - day_of_month = IntegerField(field_uri='DayOfMonth', min=1, max=31, is_required=True) + day_of_month = IntegerField(field_uri="DayOfMonth", min=1, max=31, is_required=True) # The month of the year, from 1 - 12 - month = EnumField(field_uri='Month', enum=MONTHS, is_required=True) + month = EnumField(field_uri="Month", enum=MONTHS, is_required=True) def __str__(self): - return f'Occurs on day {self.day_of_month} of {_month_to_str(self.month)}' + return f"Occurs on day {self.day_of_month} of {_month_to_str(self.month)}" class RelativeYearlyPattern(Pattern): @@ -49,21 +60,23 @@ class RelativeYearlyPattern(Pattern): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/relativeyearlyrecurrence """ - ELEMENT_NAME = 'RelativeYearlyRecurrence' + ELEMENT_NAME = "RelativeYearlyRecurrence" # Valid ISO 8601 weekday, as a number in range 1 -> 7 (1 being Monday). The value can also be one of the DAY # (or 8), WEEK_DAY (or 9) or WEEKEND_DAY (or 10) consts which is interpreted as the first day, weekday, or weekend # day of the year. Despite the field name in EWS, this is not a list. - weekday = EnumField(field_uri='DaysOfWeek', enum=WEEKDAYS, is_required=True) + weekday = EnumField(field_uri="DaysOfWeek", enum=WEEKDAYS, is_required=True) # Week number of the month, in range 1 -> 5. If 5 is specified, this assumes the last week of the month for # months that have only 4 weeks - week_number = EnumField(field_uri='DayOfWeekIndex', enum=WEEK_NUMBERS, is_required=True) + week_number = EnumField(field_uri="DayOfWeekIndex", enum=WEEK_NUMBERS, is_required=True) # The month of the year, from 1 - 12 - month = EnumField(field_uri='Month', enum=MONTHS, is_required=True) + month = EnumField(field_uri="Month", enum=MONTHS, is_required=True) def __str__(self): - return f'Occurs on weekday {_weekday_to_str(self.weekday)} in the {_week_number_to_str(self.week_number)} ' \ - f'week of {_month_to_str(self.month)}' + return ( + f"Occurs on weekday {_weekday_to_str(self.weekday)} in the {_week_number_to_str(self.week_number)} " + f"week of {_month_to_str(self.month)}" + ) class AbsoluteMonthlyPattern(Pattern): @@ -71,16 +84,16 @@ class AbsoluteMonthlyPattern(Pattern): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/absolutemonthlyrecurrence """ - ELEMENT_NAME = 'AbsoluteMonthlyRecurrence' + ELEMENT_NAME = "AbsoluteMonthlyRecurrence" # Interval, in months, in range 1 -> 99 - interval = IntegerField(field_uri='Interval', min=1, max=99, is_required=True) + interval = IntegerField(field_uri="Interval", min=1, max=99, is_required=True) # The day of month of an occurrence, in range 1 -> 31. If a particular month has less days than the day_of_month # value, the last day in the month is assumed - day_of_month = IntegerField(field_uri='DayOfMonth', min=1, max=31, is_required=True) + day_of_month = IntegerField(field_uri="DayOfMonth", min=1, max=31, is_required=True) def __str__(self): - return f'Occurs on day {self.day_of_month} of every {self.interval} month(s)' + return f"Occurs on day {self.day_of_month} of every {self.interval} month(s)" class RelativeMonthlyPattern(Pattern): @@ -88,99 +101,103 @@ class RelativeMonthlyPattern(Pattern): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/relativemonthlyrecurrence """ - ELEMENT_NAME = 'RelativeMonthlyRecurrence' + ELEMENT_NAME = "RelativeMonthlyRecurrence" # Interval, in months, in range 1 -> 99 - interval = IntegerField(field_uri='Interval', min=1, max=99, is_required=True) + interval = IntegerField(field_uri="Interval", min=1, max=99, is_required=True) # Valid ISO 8601 weekday, as a number in range 1 -> 7 (1 being Monday). The value can also be one of the DAY # (or 8), WEEK_DAY (or 9) or WEEKEND_DAY (or 10) consts which is interpreted as the first day, weekday, or weekend # day of the month. Despite the field name in EWS, this is not a list. - weekday = EnumField(field_uri='DaysOfWeek', enum=WEEKDAYS, is_required=True) + weekday = EnumField(field_uri="DaysOfWeek", enum=WEEKDAYS, is_required=True) # Week number of the month, in range 1 -> 5. If 5 is specified, this assumes the last week of the month for # months that have only 4 weeks. - week_number = EnumField(field_uri='DayOfWeekIndex', enum=WEEK_NUMBERS, is_required=True) + week_number = EnumField(field_uri="DayOfWeekIndex", enum=WEEK_NUMBERS, is_required=True) def __str__(self): - return f'Occurs on weekday {_weekday_to_str(self.weekday)} in the {_week_number_to_str(self.week_number)} ' \ - f'week of every {self.interval} month(s)' + return ( + f"Occurs on weekday {_weekday_to_str(self.weekday)} in the {_week_number_to_str(self.week_number)} " + f"week of every {self.interval} month(s)" + ) class WeeklyPattern(Pattern): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/weeklyrecurrence""" - ELEMENT_NAME = 'WeeklyRecurrence' + ELEMENT_NAME = "WeeklyRecurrence" # Interval, in weeks, in range 1 -> 99 - interval = IntegerField(field_uri='Interval', min=1, max=99, is_required=True) + interval = IntegerField(field_uri="Interval", min=1, max=99, is_required=True) # List of valid ISO 8601 weekdays, as list of numbers in range 1 -> 7 (1 being Monday) - weekdays = WeekdaysField(field_uri='DaysOfWeek', enum=WEEKDAY_NAMES, is_required=True) + weekdays = WeekdaysField(field_uri="DaysOfWeek", enum=WEEKDAY_NAMES, is_required=True) # The first day of the week. Defaults to Monday - first_day_of_week = EnumField(field_uri='FirstDayOfWeek', enum=WEEKDAY_NAMES, default=1, is_required=True) + first_day_of_week = EnumField(field_uri="FirstDayOfWeek", enum=WEEKDAY_NAMES, default=1, is_required=True) def __str__(self): - weekdays = [_weekday_to_str(i) for i in self.get_field_by_fieldname('weekdays').clean(self.weekdays)] - return f'Occurs on weekdays {", ".join(weekdays)} of every {self.interval} week(s) where the first day of ' \ - f'the week is {_weekday_to_str(self.first_day_of_week)}' + weekdays = [_weekday_to_str(i) for i in self.get_field_by_fieldname("weekdays").clean(self.weekdays)] + return ( + f'Occurs on weekdays {", ".join(weekdays)} of every {self.interval} week(s) where the first day of ' + f"the week is {_weekday_to_str(self.first_day_of_week)}" + ) class DailyPattern(Pattern): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/dailyrecurrence""" - ELEMENT_NAME = 'DailyRecurrence' + ELEMENT_NAME = "DailyRecurrence" # Interval, in days, in range 1 -> 999 - interval = IntegerField(field_uri='Interval', min=1, max=999, is_required=True) + interval = IntegerField(field_uri="Interval", min=1, max=999, is_required=True) def __str__(self): - return f'Occurs every {self.interval} day(s)' + return f"Occurs every {self.interval} day(s)" class YearlyRegeneration(Regeneration): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/yearlyregeneration""" - ELEMENT_NAME = 'YearlyRegeneration' + ELEMENT_NAME = "YearlyRegeneration" # Interval, in years - interval = IntegerField(field_uri='Interval', min=1, is_required=True) + interval = IntegerField(field_uri="Interval", min=1, is_required=True) def __str__(self): - return f'Regenerates every {self.interval} year(s)' + return f"Regenerates every {self.interval} year(s)" class MonthlyRegeneration(Regeneration): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/monthlyregeneration""" - ELEMENT_NAME = 'MonthlyRegeneration' + ELEMENT_NAME = "MonthlyRegeneration" # Interval, in months - interval = IntegerField(field_uri='Interval', min=1, is_required=True) + interval = IntegerField(field_uri="Interval", min=1, is_required=True) def __str__(self): - return f'Regenerates every {self.interval} month(s)' + return f"Regenerates every {self.interval} month(s)" class WeeklyRegeneration(Regeneration): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/weeklyregeneration""" - ELEMENT_NAME = 'WeeklyRegeneration' + ELEMENT_NAME = "WeeklyRegeneration" # Interval, in weeks - interval = IntegerField(field_uri='Interval', min=1, is_required=True) + interval = IntegerField(field_uri="Interval", min=1, is_required=True) def __str__(self): - return f'Regenerates every {self.interval} week(s)' + return f"Regenerates every {self.interval} week(s)" class DailyRegeneration(Regeneration): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/dailyregeneration""" - ELEMENT_NAME = 'DailyRegeneration' + ELEMENT_NAME = "DailyRegeneration" # Interval, in days - interval = IntegerField(field_uri='Interval', min=1, is_required=True) + interval = IntegerField(field_uri="Interval", min=1, is_required=True) def __str__(self): - return f'Regenerates every {self.interval} day(s)' + return f"Regenerates every {self.interval} day(s)" class Boundary(EWSElement, metaclass=EWSMeta): @@ -190,56 +207,56 @@ class Boundary(EWSElement, metaclass=EWSMeta): class NoEndPattern(Boundary): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/noendrecurrence""" - ELEMENT_NAME = 'NoEndRecurrence' + ELEMENT_NAME = "NoEndRecurrence" # Start date, as EWSDate or EWSDateTime - start = DateOrDateTimeField(field_uri='StartDate', is_required=True) + start = DateOrDateTimeField(field_uri="StartDate", is_required=True) def __str__(self): - return f'Starts on {self.start}' + return f"Starts on {self.start}" class EndDatePattern(Boundary): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/enddaterecurrence""" - ELEMENT_NAME = 'EndDateRecurrence' + ELEMENT_NAME = "EndDateRecurrence" # Start date, as EWSDate or EWSDateTime - start = DateOrDateTimeField(field_uri='StartDate', is_required=True) + start = DateOrDateTimeField(field_uri="StartDate", is_required=True) # End date, as EWSDate - end = DateOrDateTimeField(field_uri='EndDate', is_required=True) + end = DateOrDateTimeField(field_uri="EndDate", is_required=True) def __str__(self): - return f'Starts on {self.start}, ends on {self.end}' + return f"Starts on {self.start}, ends on {self.end}" class NumberedPattern(Boundary): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/numberedrecurrence""" - ELEMENT_NAME = 'NumberedRecurrence' + ELEMENT_NAME = "NumberedRecurrence" # Start date, as EWSDate or EWSDateTime - start = DateOrDateTimeField(field_uri='StartDate', is_required=True) + start = DateOrDateTimeField(field_uri="StartDate", is_required=True) # The number of occurrences in this pattern, in range 1 -> 999 - number = IntegerField(field_uri='NumberOfOccurrences', min=1, max=999, is_required=True) + number = IntegerField(field_uri="NumberOfOccurrences", min=1, max=999, is_required=True) def __str__(self): - return f'Starts on {self.start} and occurs {self.number} time(s)' + return f"Starts on {self.start} and occurs {self.number} time(s)" class Occurrence(IdChangeKeyMixIn): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/occurrence""" - ELEMENT_NAME = 'Occurrence' + ELEMENT_NAME = "Occurrence" ID_ELEMENT_CLS = ItemId - _id = IdElementField(field_uri='ItemId', value_cls=ID_ELEMENT_CLS) + _id = IdElementField(field_uri="ItemId", value_cls=ID_ELEMENT_CLS) # The modified start time of the item, as EWSDateTime - start = DateTimeField(field_uri='Start') + start = DateTimeField(field_uri="Start") # The modified end time of the item, as EWSDateTime - end = DateTimeField(field_uri='End') + end = DateTimeField(field_uri="End") # The original start time of the item, as EWSDateTime - original_start = DateTimeField(field_uri='OriginalStart') + original_start = DateTimeField(field_uri="OriginalStart") # Container elements: @@ -250,26 +267,32 @@ class Occurrence(IdChangeKeyMixIn): class FirstOccurrence(Occurrence): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/firstoccurrence""" - ELEMENT_NAME = 'FirstOccurrence' + ELEMENT_NAME = "FirstOccurrence" class LastOccurrence(Occurrence): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/lastoccurrence""" - ELEMENT_NAME = 'LastOccurrence' + ELEMENT_NAME = "LastOccurrence" class DeletedOccurrence(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deletedoccurrence""" - ELEMENT_NAME = 'DeletedOccurrence' + ELEMENT_NAME = "DeletedOccurrence" # The modified start time of the item, as EWSDateTime - start = DateTimeField(field_uri='Start') + start = DateTimeField(field_uri="Start") -PATTERN_CLASSES = AbsoluteYearlyPattern, RelativeYearlyPattern, AbsoluteMonthlyPattern, RelativeMonthlyPattern, \ - WeeklyPattern, DailyPattern +PATTERN_CLASSES = ( + AbsoluteYearlyPattern, + RelativeYearlyPattern, + AbsoluteMonthlyPattern, + RelativeMonthlyPattern, + WeeklyPattern, + DailyPattern, +) REGENERATION_CLASSES = YearlyRegeneration, MonthlyRegeneration, WeeklyRegeneration, DailyRegeneration BOUNDARY_CLASSES = NoEndPattern, EndDatePattern, NumberedPattern @@ -279,7 +302,7 @@ class Recurrence(EWSElement): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recurrence-recurrencetype """ - ELEMENT_NAME = 'Recurrence' + ELEMENT_NAME = "Recurrence" PATTERN_CLASS_MAP = {cls.response_tag(): cls for cls in PATTERN_CLASSES} BOUNDARY_CLASS_MAP = {cls.response_tag(): cls for cls in BOUNDARY_CLASSES} @@ -288,18 +311,18 @@ class Recurrence(EWSElement): def __init__(self, **kwargs): # Allow specifying a start, end and/or number as a shortcut to creating a boundary - start = kwargs.pop('start', None) - end = kwargs.pop('end', None) - number = kwargs.pop('number', None) + start = kwargs.pop("start", None) + end = kwargs.pop("end", None) + number = kwargs.pop("number", None) if any([start, end, number]): - if 'boundary' in kwargs: + if "boundary" in kwargs: raise ValueError("'boundary' is not allowed in combination with 'start', 'end' or 'number'") if start and not end and not number: - kwargs['boundary'] = NoEndPattern(start=start) + kwargs["boundary"] = NoEndPattern(start=start) elif start and end and not number: - kwargs['boundary'] = EndDatePattern(start=start, end=end) + kwargs["boundary"] = EndDatePattern(start=start, end=end) elif start and number and not end: - kwargs['boundary'] = NumberedPattern(start=start, number=number) + kwargs["boundary"] = NumberedPattern(start=start, number=number) else: raise ValueError("Unsupported 'start', 'end', 'number' combination") super().__init__(**kwargs) @@ -315,7 +338,7 @@ def from_xml(cls, elem, account): return cls(pattern=pattern, boundary=boundary) def __str__(self): - return f'Pattern: {self.pattern}, Boundary: {self.boundary}' + return f"Pattern: {self.pattern}, Boundary: {self.boundary}" class TaskRecurrence(Recurrence): diff --git a/exchangelib/restriction.py b/exchangelib/restriction.py index 2df3caff..e189507f 100644 --- a/exchangelib/restriction.py +++ b/exchangelib/restriction.py @@ -2,8 +2,8 @@ from copy import copy from .errors import InvalidEnumValue -from .fields import InvalidField, FieldPath, DateTimeBackedDateField -from .util import create_element, xml_to_str, value_to_xml_text, is_iterable +from .fields import DateTimeBackedDateField, FieldPath, InvalidField +from .util import create_element, is_iterable, value_to_xml_text, xml_to_str from .version import EXCHANGE_2010 log = logging.getLogger(__name__) @@ -13,52 +13,65 @@ class Q: """A class with an API similar to Django Q objects. Used to implement advanced filtering logic.""" # Connection types - AND = 'AND' - OR = 'OR' - NOT = 'NOT' - NEVER = 'NEVER' # This is not specified by EWS. We use it for queries that will never match, e.g. 'foo__in=()' + AND = "AND" + OR = "OR" + NOT = "NOT" + NEVER = "NEVER" # This is not specified by EWS. We use it for queries that will never match, e.g. 'foo__in=()' CONN_TYPES = {AND, OR, NOT, NEVER} # EWS Operators - EQ = '==' - NE = '!=' - GT = '>' - GTE = '>=' - LT = '<' - LTE = '<=' - EXACT = 'exact' - IEXACT = 'iexact' - CONTAINS = 'contains' - ICONTAINS = 'icontains' - STARTSWITH = 'startswith' - ISTARTSWITH = 'istartswith' - EXISTS = 'exists' + EQ = "==" + NE = "!=" + GT = ">" + GTE = ">=" + LT = "<" + LTE = "<=" + EXACT = "exact" + IEXACT = "iexact" + CONTAINS = "contains" + ICONTAINS = "icontains" + STARTSWITH = "startswith" + ISTARTSWITH = "istartswith" + EXISTS = "exists" OP_TYPES = {EQ, NE, GT, GTE, LT, LTE, EXACT, IEXACT, CONTAINS, ICONTAINS, STARTSWITH, ISTARTSWITH, EXISTS} CONTAINS_OPS = {EXACT, IEXACT, CONTAINS, ICONTAINS, STARTSWITH, ISTARTSWITH} # Valid lookups - LOOKUP_RANGE = 'range' - LOOKUP_IN = 'in' - LOOKUP_NOT = 'not' - LOOKUP_GT = 'gt' - LOOKUP_GTE = 'gte' - LOOKUP_LT = 'lt' - LOOKUP_LTE = 'lte' - LOOKUP_EXACT = 'exact' - LOOKUP_IEXACT = 'iexact' - LOOKUP_CONTAINS = 'contains' - LOOKUP_ICONTAINS = 'icontains' - LOOKUP_STARTSWITH = 'startswith' - LOOKUP_ISTARTSWITH = 'istartswith' - LOOKUP_EXISTS = 'exists' - LOOKUP_TYPES = {LOOKUP_RANGE, LOOKUP_IN, LOOKUP_NOT, LOOKUP_GT, LOOKUP_GTE, LOOKUP_LT, LOOKUP_LTE, LOOKUP_EXACT, - LOOKUP_IEXACT, LOOKUP_CONTAINS, LOOKUP_ICONTAINS, LOOKUP_STARTSWITH, LOOKUP_ISTARTSWITH, - LOOKUP_EXISTS} - - __slots__ = 'conn_type', 'field_path', 'op', 'value', 'children', 'query_string' + LOOKUP_RANGE = "range" + LOOKUP_IN = "in" + LOOKUP_NOT = "not" + LOOKUP_GT = "gt" + LOOKUP_GTE = "gte" + LOOKUP_LT = "lt" + LOOKUP_LTE = "lte" + LOOKUP_EXACT = "exact" + LOOKUP_IEXACT = "iexact" + LOOKUP_CONTAINS = "contains" + LOOKUP_ICONTAINS = "icontains" + LOOKUP_STARTSWITH = "startswith" + LOOKUP_ISTARTSWITH = "istartswith" + LOOKUP_EXISTS = "exists" + LOOKUP_TYPES = { + LOOKUP_RANGE, + LOOKUP_IN, + LOOKUP_NOT, + LOOKUP_GT, + LOOKUP_GTE, + LOOKUP_LT, + LOOKUP_LTE, + LOOKUP_EXACT, + LOOKUP_IEXACT, + LOOKUP_CONTAINS, + LOOKUP_ICONTAINS, + LOOKUP_STARTSWITH, + LOOKUP_ISTARTSWITH, + LOOKUP_EXISTS, + } + + __slots__ = "conn_type", "field_path", "op", "value", "children", "query_string" def __init__(self, *args, **kwargs): - self.conn_type = kwargs.pop('conn_type', self.AND) + self.conn_type = kwargs.pop("conn_type", self.AND) self.field_path = None # Name of the field we want to filter on self.op = None @@ -82,9 +95,7 @@ def __init__(self, *args, **kwargs): # Parse keyword args and extract the filter is_single_kwarg = len(args) == 0 and len(kwargs) == 1 for key, value in kwargs.items(): - self.children.extend( - self._get_children_from_kwarg(key=key, value=value, is_single_kwarg=is_single_kwarg) - ) + self.children.extend(self._get_children_from_kwarg(key=key, value=value, is_single_kwarg=is_single_kwarg)) # Simplify this object self.reduce() @@ -96,7 +107,7 @@ def _get_children_from_kwarg(self, key, value, is_single_kwarg=False): """Generate Q objects corresponding to a single keyword argument. Make this a leaf if there are no children to generate. """ - key_parts = key.rsplit('__', 1) + key_parts = key.rsplit("__", 1) if len(key_parts) == 2 and key_parts[1] in self.LOOKUP_TYPES: # This is a kwarg with a lookup at the end field_path, lookup = key_parts @@ -111,8 +122,8 @@ def _get_children_from_kwarg(self, key, value, is_single_kwarg=False): if len(value) != 2: raise ValueError(f"Value of lookup {key!r} must have exactly 2 elements") return ( - self.__class__(**{f'{field_path}__gte': value[0]}), - self.__class__(**{f'{field_path}__lte': value[1]}), + self.__class__(**{f"{field_path}__gte": value[0]}), + self.__class__(**{f"{field_path}__lte": value[1]}), ) # Filtering on list types is a bit quirky. The only lookup type I have found to work is: @@ -215,6 +226,7 @@ def clean(self, version): validating. There's no reason to replicate much of that here. """ from .folders import Folder + self.to_xml(folders=[Folder()], version=version, applies_to=Restriction.ITEMS) @classmethod @@ -237,28 +249,28 @@ def _lookup_to_op(cls, lookup): @classmethod def _conn_to_xml(cls, conn_type): xml_tag_map = { - cls.AND: 't:And', - cls.OR: 't:Or', - cls.NOT: 't:Not', + cls.AND: "t:And", + cls.OR: "t:Or", + cls.NOT: "t:Not", } return create_element(xml_tag_map[conn_type]) @classmethod def _op_to_xml(cls, op): xml_tag_map = { - cls.EQ: 't:IsEqualTo', - cls.NE: 't:IsNotEqualTo', - cls.GTE: 't:IsGreaterThanOrEqualTo', - cls.LTE: 't:IsLessThanOrEqualTo', - cls.LT: 't:IsLessThan', - cls.GT: 't:IsGreaterThan', - cls.EXISTS: 't:Exists', + cls.EQ: "t:IsEqualTo", + cls.NE: "t:IsNotEqualTo", + cls.GTE: "t:IsGreaterThanOrEqualTo", + cls.LTE: "t:IsLessThanOrEqualTo", + cls.LT: "t:IsLessThan", + cls.GT: "t:IsGreaterThan", + cls.EXISTS: "t:Exists", } if op in xml_tag_map: return create_element(xml_tag_map[op]) valid_ops = cls.EXACT, cls.IEXACT, cls.CONTAINS, cls.ICONTAINS, cls.STARTSWITH, cls.ISTARTSWITH if op not in valid_ops: - raise InvalidEnumValue('op', op, valid_ops) + raise InvalidEnumValue("op", op, valid_ops) # For description of Contains attribute values, see # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/contains @@ -282,21 +294,18 @@ def _op_to_xml(cls, op): # https://en.wikipedia.org/wiki/Graphic_character#Spacing_and_non-spacing_characters # we shouldn't ignore them ('a' would match both 'a' and 'å', the latter having a non-spacing character). if op in {cls.EXACT, cls.IEXACT}: - match_mode = 'FullString' + match_mode = "FullString" elif op in (cls.CONTAINS, cls.ICONTAINS): - match_mode = 'Substring' + match_mode = "Substring" elif op in (cls.STARTSWITH, cls.ISTARTSWITH): - match_mode = 'Prefixed' + match_mode = "Prefixed" else: - raise ValueError(f'Unsupported op: {op}') + raise ValueError(f"Unsupported op: {op}") if op in (cls.IEXACT, cls.ICONTAINS, cls.ISTARTSWITH): - compare_mode = 'IgnoreCase' + compare_mode = "IgnoreCase" else: - compare_mode = 'Exact' - return create_element( - 't:Contains', - attrs=dict(ContainmentMode=match_mode, ContainmentComparison=compare_mode) - ) + compare_mode = "Exact" + return create_element("t:Contains", attrs=dict(ContainmentMode=match_mode, ContainmentComparison=compare_mode)) def is_leaf(self): return not self.children @@ -317,33 +326,33 @@ def expr(self): if self.query_string: return self.query_string if self.is_leaf(): - expr = f'{self.field_path} {self.op} {self.value!r}' + expr = f"{self.field_path} {self.op} {self.value!r}" else: # Sort children by field name so we get stable output (for easier testing). Children should never be empty. - expr = f' {self.AND if self.conn_type == self.NOT else self.conn_type} '.join( - (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f'({c.expr()})') - for c in sorted(self.children, key=lambda i: i.field_path or '') + expr = f" {self.AND if self.conn_type == self.NOT else self.conn_type} ".join( + (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f"({c.expr()})") + for c in sorted(self.children, key=lambda i: i.field_path or "") ) if self.conn_type == self.NOT: # Add the NOT operator. Put children in parens if there is more than one child. if self.is_leaf() or len(self.children) == 1: - return self.conn_type + f' {expr}' - return self.conn_type + f' ({expr})' + return self.conn_type + f" {expr}" + return self.conn_type + f" ({expr})" return expr def to_xml(self, folders, version, applies_to): if self.query_string: self._check_integrity() if version.build < EXCHANGE_2010: - raise NotImplementedError('QueryString filtering is only supported for Exchange 2010 servers and later') - elem = create_element('m:QueryString') + raise NotImplementedError("QueryString filtering is only supported for Exchange 2010 servers and later") + elem = create_element("m:QueryString") elem.text = self.query_string return elem # Translate this Q object to a valid Restriction XML tree elem = self.xml_elem(folders=folders, version=version, applies_to=applies_to) if elem is None: return None - restriction = create_element('m:Restriction') + restriction = create_element("m:Restriction") restriction.append(elem) return restriction @@ -356,30 +365,31 @@ def _check_integrity(self): return if self.query_string: if any([self.field_path, self.op, self.value, self.children]): - raise ValueError('Query strings cannot be combined with other settings') + raise ValueError("Query strings cannot be combined with other settings") return if self.conn_type not in self.CONN_TYPES: - raise InvalidEnumValue('conn_type', self.conn_type, self.CONN_TYPES) + raise InvalidEnumValue("conn_type", self.conn_type, self.CONN_TYPES) if not self.is_leaf(): for q in self.children: if q.query_string and len(self.children) > 1: - raise ValueError('A query string cannot be combined with other restrictions') + raise ValueError("A query string cannot be combined with other restrictions") return if not self.field_path: raise ValueError("'field_path' must be set") if self.op not in self.OP_TYPES: - raise InvalidEnumValue('op', self.op, self.OP_TYPES) + raise InvalidEnumValue("op", self.op, self.OP_TYPES) if self.op == self.EXISTS and self.value is not True: raise ValueError("'value' must be True when operator is EXISTS") if self.value is None: - raise ValueError(f'Value for filter on field path {self.field_path!r} cannot be None') + raise ValueError(f"Value for filter on field path {self.field_path!r} cannot be None") if is_iterable(self.value, generators_allowed=True): raise ValueError( - f'Value {self.value!r} for filter on field path {self.field_path!r} must be a single value' + f"Value {self.value!r} for filter on field path {self.field_path!r} must be a single value" ) def _validate_field_path(self, field_path, folder, applies_to, version): from .indexed_properties import MultiFieldIndexedElement + if applies_to == Restriction.FOLDERS: # This is a restriction on Folder fields folder.validate_field(field=field_path.field, version=version) @@ -424,6 +434,7 @@ def xml_elem(self, folders, version, applies_to): # Recursively build an XML tree structure of this Q object. If this is an empty leaf (the equivalent of Q()), # return None. from .indexed_properties import SingleFieldIndexedElement + # Don't check self.value just yet. We want to return error messages on the field path first, and then the value. # This is done in _get_field_path() and _get_clean_value(), respectively. self._check_integrity() @@ -444,11 +455,11 @@ def xml_elem(self, folders, version, applies_to): clean_value = field_path.field.date_to_datetime(clean_value) elem.append(field_path.to_xml()) if self.op != self.EXISTS: - constant = create_element('t:Constant', attrs=dict(Value=value_to_xml_text(clean_value))) + constant = create_element("t:Constant", attrs=dict(Value=value_to_xml_text(clean_value))) if self.op in self.CONTAINS_OPS: elem.append(constant) else: - uriorconst = create_element('t:FieldURIOrConstant') + uriorconst = create_element("t:FieldURIOrConstant") uriorconst.append(constant) elem.append(uriorconst) elif len(self.children) == 1: @@ -458,7 +469,7 @@ def xml_elem(self, folders, version, applies_to): # We have multiple children. If conn_type is NOT, then group children with AND. We'll add the NOT later elem = self._conn_to_xml(self.AND if self.conn_type == self.NOT else self.conn_type) # Sort children by field name so we get stable output (for easier testing). Children should never be empty - for c in sorted(self.children, key=lambda i: i.field_path or ''): + for c in sorted(self.children, key=lambda i: i.field_path or ""): elem.append(c.xml_elem(folders=folders, version=version, applies_to=applies_to)) if elem is None: return None # Should not be necessary, but play safe @@ -510,16 +521,16 @@ def __hash__(self): return hash(repr(self)) def __str__(self): - return self.expr() or 'Q()' + return self.expr() or "Q()" def __repr__(self): if self.is_leaf(): if self.query_string: - return self.__class__.__name__ + f'({self.query_string!r})' + return self.__class__.__name__ + f"({self.query_string!r})" if self.is_never(): - return self.__class__.__name__ + f'(conn_type={self.conn_type!r})' - return self.__class__.__name__ + f'({self.field_path} {self.op} {self.value!r})' - sorted_children = tuple(sorted(self.children, key=lambda i: i.field_path or '')) + return self.__class__.__name__ + f"(conn_type={self.conn_type!r})" + return self.__class__.__name__ + f"({self.field_path} {self.op} {self.value!r})" + sorted_children = tuple(sorted(self.children, key=lambda i: i.field_path or "")) if self.conn_type == self.NOT or len(self.children) > 1: return self.__class__.__name__ + repr((self.conn_type,) + sorted_children) return self.__class__.__name__ + repr(sorted_children) @@ -529,8 +540,8 @@ class Restriction: """Implement an EWS Restriction type.""" # The type of item the restriction applies to - FOLDERS = 'folders' - ITEMS = 'items' + FOLDERS = "folders" + ITEMS = "items" RESTRICTION_TYPES = (FOLDERS, ITEMS) def __init__(self, q, folders, applies_to): diff --git a/exchangelib/services/__init__.py b/exchangelib/services/__init__.py index f256c58e..4247d8af 100644 --- a/exchangelib/services/__init__.py +++ b/exchangelib/services/__init__.py @@ -7,8 +7,8 @@ https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/ews-operations-in-exchange """ -from .common import CHUNK_SIZE, PAGE_SIZE from .archive_item import ArchiveItem +from .common import CHUNK_SIZE, PAGE_SIZE from .convert_id import ConvertId from .copy_item import CopyItem from .create_attachment import CreateAttachment @@ -47,7 +47,7 @@ from .send_item import SendItem from .send_notification import SendNotification from .set_user_oof_settings import SetUserOofSettings -from .subscribe import SubscribeToStreaming, SubscribeToPull, SubscribeToPush +from .subscribe import SubscribeToPull, SubscribeToPush, SubscribeToStreaming from .sync_folder_hierarchy import SyncFolderHierarchy from .sync_folder_items import SyncFolderItems from .unsubscribe import Unsubscribe @@ -57,55 +57,55 @@ from .upload_items import UploadItems __all__ = [ - 'CHUNK_SIZE', - 'PAGE_SIZE', - 'ArchiveItem', - 'ConvertId', - 'CopyItem', - 'CreateAttachment', - 'CreateFolder', - 'CreateItem', - 'CreateUserConfiguration', - 'DeleteAttachment', - 'DeleteFolder', - 'DeleteUserConfiguration', - 'DeleteItem', - 'EmptyFolder', - 'ExpandDL', - 'ExportItems', - 'FindFolder', - 'FindItem', - 'FindPeople', - 'GetAttachment', - 'GetDelegate', - 'GetEvents', - 'GetFolder', - 'GetItem', - 'GetMailTips', - 'GetPersona', - 'GetRoomLists', - 'GetRooms', - 'GetSearchableMailboxes', - 'GetServerTimeZones', - 'GetStreamingEvents', - 'GetUserAvailability', - 'GetUserConfiguration', - 'GetUserOofSettings', - 'MarkAsJunk', - 'MoveFolder', - 'MoveItem', - 'ResolveNames', - 'SendItem', - 'SendNotification', - 'SetUserOofSettings', - 'SubscribeToPull', - 'SubscribeToPush', - 'SubscribeToStreaming', - 'SyncFolderHierarchy', - 'SyncFolderItems', - 'Unsubscribe', - 'UpdateFolder', - 'UpdateItem', - 'UpdateUserConfiguration', - 'UploadItems', + "CHUNK_SIZE", + "PAGE_SIZE", + "ArchiveItem", + "ConvertId", + "CopyItem", + "CreateAttachment", + "CreateFolder", + "CreateItem", + "CreateUserConfiguration", + "DeleteAttachment", + "DeleteFolder", + "DeleteUserConfiguration", + "DeleteItem", + "EmptyFolder", + "ExpandDL", + "ExportItems", + "FindFolder", + "FindItem", + "FindPeople", + "GetAttachment", + "GetDelegate", + "GetEvents", + "GetFolder", + "GetItem", + "GetMailTips", + "GetPersona", + "GetRoomLists", + "GetRooms", + "GetSearchableMailboxes", + "GetServerTimeZones", + "GetStreamingEvents", + "GetUserAvailability", + "GetUserConfiguration", + "GetUserOofSettings", + "MarkAsJunk", + "MoveFolder", + "MoveItem", + "ResolveNames", + "SendItem", + "SendNotification", + "SetUserOofSettings", + "SubscribeToPull", + "SubscribeToPush", + "SubscribeToStreaming", + "SyncFolderHierarchy", + "SyncFolderItems", + "Unsubscribe", + "UpdateFolder", + "UpdateItem", + "UpdateUserConfiguration", + "UploadItems", ] diff --git a/exchangelib/services/archive_item.py b/exchangelib/services/archive_item.py index a7f917dc..7abc9b75 100644 --- a/exchangelib/services/archive_item.py +++ b/exchangelib/services/archive_item.py @@ -1,14 +1,14 @@ -from .common import EWSAccountService, folder_ids_element, item_ids_element from ..items import Item -from ..util import create_element, MNS +from ..util import MNS, create_element from ..version import EXCHANGE_2013 +from .common import EWSAccountService, folder_ids_element, item_ids_element class ArchiveItem(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/archiveitem-operation""" - SERVICE_NAME = 'ArchiveItem' - element_container_name = f'{{{MNS}}}Items' + SERVICE_NAME = "ArchiveItem" + element_container_name = f"{{{MNS}}}Items" supported_from = EXCHANGE_2013 def call(self, items, to_folder): @@ -25,9 +25,9 @@ def _elem_to_obj(self, elem): return Item.id_from_xml(elem) def get_payload(self, items, to_folder): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") payload.append( - folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ArchiveSourceFolderId') + folder_ids_element(folders=[to_folder], version=self.account.version, tag="m:ArchiveSourceFolderId") ) payload.append(item_ids_element(items=items, version=self.account.version)) return payload diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index bdc8f925..ac65f739 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -6,27 +6,91 @@ from .. import errors from ..attachments import AttachmentId from ..credentials import IMPERSONATION, OAuth2Credentials -from ..errors import EWSWarning, TransportError, SOAPError, ErrorTimeoutExpired, ErrorBatchProcessingStopped, \ - ErrorQuotaExceeded, ErrorCannotDeleteObject, ErrorCreateItemAccessDenied, ErrorFolderNotFound, \ - ErrorNonExistentMailbox, ErrorMailboxStoreUnavailable, ErrorImpersonateUserDenied, ErrorInternalServerError, \ - ErrorInternalServerTransientError, ErrorNoRespondingCASInDestinationSite, ErrorImpersonationFailed, \ - ErrorMailboxMoveInProgress, ErrorAccessDenied, ErrorConnectionFailed, RateLimitError, ErrorServerBusy, \ - ErrorTooManyObjectsOpened, ErrorInvalidLicense, ErrorInvalidSchemaVersionForMailboxVersion, \ - ErrorInvalidServerVersion, ErrorItemNotFound, ErrorADUnavailable, ErrorInvalidChangeKey, \ - ErrorItemSave, ErrorInvalidIdMalformed, ErrorMessageSizeExceeded, UnauthorizedError, \ - ErrorCannotDeleteTaskOccurrence, ErrorMimeContentConversionFailed, ErrorRecurrenceHasNoOccurrence, \ - ErrorNoPublicFolderReplicaAvailable, MalformedResponseError, ErrorExceededConnectionCount, \ - SessionPoolMinSizeReached, ErrorIncorrectSchemaVersion, ErrorInvalidRequest, ErrorCorruptData, \ - ErrorCannotEmptyFolder, ErrorDeleteDistinguishedFolder, ErrorInvalidSubscription, ErrorInvalidWatermark, \ - ErrorInvalidSyncStateData, ErrorNameResolutionNoResults, ErrorNameResolutionMultipleResults, \ - ErrorConnectionFailedTransientError, ErrorDelegateNoUser, ErrorNotDelegate, InvalidTypeError, ErrorItemCorrupt +from ..errors import ( + ErrorAccessDenied, + ErrorADUnavailable, + ErrorBatchProcessingStopped, + ErrorCannotDeleteObject, + ErrorCannotDeleteTaskOccurrence, + ErrorCannotEmptyFolder, + ErrorConnectionFailed, + ErrorConnectionFailedTransientError, + ErrorCorruptData, + ErrorCreateItemAccessDenied, + ErrorDelegateNoUser, + ErrorDeleteDistinguishedFolder, + ErrorExceededConnectionCount, + ErrorFolderNotFound, + ErrorImpersonateUserDenied, + ErrorImpersonationFailed, + ErrorIncorrectSchemaVersion, + ErrorInternalServerError, + ErrorInternalServerTransientError, + ErrorInvalidChangeKey, + ErrorInvalidIdMalformed, + ErrorInvalidLicense, + ErrorInvalidRequest, + ErrorInvalidSchemaVersionForMailboxVersion, + ErrorInvalidServerVersion, + ErrorInvalidSubscription, + ErrorInvalidSyncStateData, + ErrorInvalidWatermark, + ErrorItemCorrupt, + ErrorItemNotFound, + ErrorItemSave, + ErrorMailboxMoveInProgress, + ErrorMailboxStoreUnavailable, + ErrorMessageSizeExceeded, + ErrorMimeContentConversionFailed, + ErrorNameResolutionMultipleResults, + ErrorNameResolutionNoResults, + ErrorNonExistentMailbox, + ErrorNoPublicFolderReplicaAvailable, + ErrorNoRespondingCASInDestinationSite, + ErrorNotDelegate, + ErrorQuotaExceeded, + ErrorRecurrenceHasNoOccurrence, + ErrorServerBusy, + ErrorTimeoutExpired, + ErrorTooManyObjectsOpened, + EWSWarning, + InvalidTypeError, + MalformedResponseError, + RateLimitError, + SessionPoolMinSizeReached, + SOAPError, + TransportError, + UnauthorizedError, +) from ..folders import BaseFolder, Folder, RootOfHierarchy from ..items import BaseItem -from ..properties import FieldURI, IndexedFieldURI, ExtendedFieldURI, ExceptionFieldURI, ItemId, FolderId, \ - DistinguishedFolderId, BaseItemId +from ..properties import ( + BaseItemId, + DistinguishedFolderId, + ExceptionFieldURI, + ExtendedFieldURI, + FieldURI, + FolderId, + IndexedFieldURI, + ItemId, +) from ..transport import wrap -from ..util import chunkify, create_element, add_xml_child, get_xml_attr, to_xml, post_ratelimited, \ - xml_to_str, set_xml_value, SOAPNS, TNS, MNS, ENS, ParseError, DummyResponse +from ..util import ( + ENS, + MNS, + SOAPNS, + TNS, + DummyResponse, + ParseError, + add_xml_child, + chunkify, + create_element, + get_xml_attr, + post_ratelimited, + set_xml_value, + to_xml, + xml_to_str, +) from ..version import API_VERSIONS, Version log = logging.getLogger(__name__) @@ -82,9 +146,18 @@ class EWSService(metaclass=abc.ABCMeta): returns_elements = True # If False, the service does not return response elements, just the ResponseCode status # Return exception instance instead of raising exceptions for the following errors when contained in an element ERRORS_TO_CATCH_IN_RESPONSE = ( - EWSWarning, ErrorCannotDeleteObject, ErrorInvalidChangeKey, ErrorItemNotFound, ErrorItemSave, - ErrorInvalidIdMalformed, ErrorMessageSizeExceeded, ErrorCannotDeleteTaskOccurrence, - ErrorMimeContentConversionFailed, ErrorRecurrenceHasNoOccurrence, ErrorCorruptData, ErrorItemCorrupt + EWSWarning, + ErrorCannotDeleteObject, + ErrorInvalidChangeKey, + ErrorItemNotFound, + ErrorItemSave, + ErrorInvalidIdMalformed, + ErrorMessageSizeExceeded, + ErrorCannotDeleteTaskOccurrence, + ErrorMimeContentConversionFailed, + ErrorRecurrenceHasNoOccurrence, + ErrorCorruptData, + ErrorItemCorrupt, ) # Similarly, define the warnings we want to return unraised WARNINGS_TO_CATCH_IN_RESPONSE = ErrorBatchProcessingStopped @@ -100,13 +173,13 @@ class EWSService(metaclass=abc.ABCMeta): def __init__(self, protocol, chunk_size=None, timeout=None): self.chunk_size = chunk_size or CHUNK_SIZE if not isinstance(self.chunk_size, int): - raise InvalidTypeError('chunk_size', chunk_size, int) + raise InvalidTypeError("chunk_size", chunk_size, int) if self.chunk_size < 1: raise ValueError(f"'chunk_size' {self.chunk_size} must be a positive number") if self.supported_from and protocol.version.build < self.supported_from: raise NotImplementedError( - f'{self.SERVICE_NAME!r} is only supported on {self.supported_from.fullname()!r} and later. ' - f'Your current version is {protocol.version.build.fullname()!r}.' + f"{self.SERVICE_NAME!r} is only supported on {self.supported_from.fullname()!r} and later. " + f"Your current version is {protocol.version.build.fullname()!r}." ) self.protocol = protocol # Allow a service to override the default protocol timeout. Useful for streaming services @@ -162,10 +235,10 @@ def get(self, expect_result=True, **kwargs): return None if expect_result is False: if res: - raise ValueError(f'Expected result length 0, but got {res}') + raise ValueError(f"Expected result length 0, but got {res}") return None if len(res) != 1: - raise ValueError(f'Expected result length 1, but got {res}') + raise ValueError(f"Expected result length 1, but got {res}") return res[0] def parse(self, xml): @@ -233,12 +306,12 @@ def _chunked_get_elements(self, payload_func, items, **kwargs): # If the input for a service is a QuerySet, it can be difficult to remove exceptions before now filtered_items = filter(lambda i: not isinstance(i, Exception), items) for i, chunk in enumerate(chunkify(filtered_items, self.chunk_size), start=1): - log.debug('Processing chunk %s containing %s items', i, len(chunk)) + log.debug("Processing chunk %s containing %s items", i, len(chunk)) yield from self._get_elements(payload=payload_func(chunk, **kwargs)) def stop_streaming(self): if not self.streaming: - raise RuntimeError('Attempt to stop a non-streaming service') + raise RuntimeError("Attempt to stop a non-streaming service") if self._streaming_response: self._streaming_response.close() # Release memory self._streaming_response = None @@ -275,11 +348,11 @@ def _get_elements(self, payload): raise e # Re-raise as an ErrorServerBusy with a default delay of 5 minutes - raise ErrorServerBusy(f'Reraised from {e.__class__.__name__}({e})') + raise ErrorServerBusy(f"Reraised from {e.__class__.__name__}({e})") except Exception: # This may run in a thread, which obfuscates the stack trace. Print trace immediately. account = self.account if isinstance(self, EWSAccountService) else None - log.warning('Account %s: Exception in _get_elements: %s', account, traceback.format_exc(20)) + log.warning("Account %s: Exception in _get_elements: %s", account, traceback.format_exc(20)) raise finally: if self.streaming: @@ -337,9 +410,9 @@ def _get_response_xml(self, payload, **parse_opts): # Microsoft really doesn't want to make our lives easy. The server may report one version in our initial version # guessing tango, but then the server may decide that any arbitrary legacy backend server may actually process # the request for an account. Prepare to handle version-related errors and set the server version per-account. - log.debug('Calling service %s', self.SERVICE_NAME) + log.debug("Calling service %s", self.SERVICE_NAME) for api_version in self._api_versions_to_try: - log.debug('Trying API version %s', api_version) + log.debug("Trying API version %s", api_version) r = self._get_response(payload=payload, api_version=api_version) if self.streaming: # Let 'requests' decode raw data automatically @@ -354,10 +427,14 @@ def _get_response_xml(self, payload, **parse_opts): self._update_api_version(api_version=api_version, header=header, **parse_opts) try: return self._get_soap_messages(body=body, **parse_opts) - except (ErrorInvalidServerVersion, ErrorIncorrectSchemaVersion, ErrorInvalidRequest, - ErrorInvalidSchemaVersionForMailboxVersion): + except ( + ErrorInvalidServerVersion, + ErrorIncorrectSchemaVersion, + ErrorInvalidRequest, + ErrorInvalidSchemaVersionForMailboxVersion, + ): # The guessed server version is wrong. Try the next version - log.debug('API version %s was invalid', api_version) + log.debug("API version %s was invalid", api_version) continue except ErrorExceededConnectionCount as e: # This indicates that the connecting user has too many open TCP connections to the server. Decrease @@ -373,7 +450,7 @@ def _get_response_xml(self, payload, **parse_opts): # In streaming mode, we may not have accessed the raw stream yet. Caller must handle this. r.close() # Release memory - raise self.NO_VALID_SERVER_VERSIONS(f'Tried versions {self._api_versions_to_try} but all were invalid') + raise self.NO_VALID_SERVER_VERSIONS(f"Tried versions {self._api_versions_to_try} but all were invalid") def _handle_backoff(self, e): """Take a request from the server to back off and checks the retry policy for what to do. Re-raise the @@ -382,7 +459,7 @@ def _handle_backoff(self, e): :param e: An ErrorServerBusy instance :return: """ - log.debug('Got ErrorServerBusy (back off %s seconds)', e.back_off) + log.debug("Got ErrorServerBusy (back off %s seconds)", e.back_off) # ErrorServerBusy is very often a symptom of sending too many requests. Scale back connections if possible. try: self.protocol.decrease_poolsize() @@ -400,12 +477,12 @@ def _update_api_version(self, api_version, header, **parse_opts): try: head_version = Version.from_soap_header(requested_api_version=api_version, header=header) except TransportError as te: - log.debug('Failed to update version info (%s)', te) + log.debug("Failed to update version info (%s)", te) return if self._version_hint == head_version: # Nothing to do return - log.debug('Found new version (%s -> %s)', self._version_hint, head_version) + log.debug("Found new version (%s -> %s)", self._version_hint, head_version) # The api_version that worked was different than our hint, or we never got a build version. Store the working # version. self._version_hint = head_version @@ -413,17 +490,17 @@ def _update_api_version(self, api_version, header, **parse_opts): @classmethod def _response_tag(cls): """Return the name of the element containing the service response.""" - return f'{{{MNS}}}{cls.SERVICE_NAME}Response' + return f"{{{MNS}}}{cls.SERVICE_NAME}Response" @staticmethod def _response_messages_tag(): """Return the name of the element containing service response messages.""" - return f'{{{MNS}}}ResponseMessages' + return f"{{{MNS}}}ResponseMessages" @classmethod def _response_message_tag(cls): """Return the name of the element of a single response message.""" - return f'{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage' + return f"{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage" @classmethod def _get_soap_parts(cls, response, **parse_opts): @@ -431,23 +508,23 @@ def _get_soap_parts(cls, response, **parse_opts): try: root = to_xml(response.iter_content()) except ParseError as e: - raise SOAPError(f'Bad SOAP response: {e}') - header = root.find(f'{{{SOAPNS}}}Header') + raise SOAPError(f"Bad SOAP response: {e}") + header = root.find(f"{{{SOAPNS}}}Header") if header is None: # This is normal when the response contains SOAP-level errors - log.debug('No header in XML response') - body = root.find(f'{{{SOAPNS}}}Body') + log.debug("No header in XML response") + body = root.find(f"{{{SOAPNS}}}Body") if body is None: - raise MalformedResponseError('No Body element in SOAP response') + raise MalformedResponseError("No Body element in SOAP response") return header, body def _get_soap_messages(self, body, **parse_opts): """Return the elements in the response containing the response messages. Raises any SOAP exceptions.""" response = body.find(self._response_tag()) if response is None: - fault = body.find(f'{{{SOAPNS}}}Fault') + fault = body.find(f"{{{SOAPNS}}}Fault") if fault is None: - raise SOAPError(f'Unknown SOAP response (expected {self._response_tag()} or Fault): {xml_to_str(body)}') + raise SOAPError(f"Unknown SOAP response (expected {self._response_tag()} or Fault): {xml_to_str(body)}") self._raise_soap_errors(fault=fault) # Will throw SOAPError or custom EWS error response_messages = response.find(self._response_messages_tag()) if response_messages is None: @@ -460,43 +537,43 @@ def _get_soap_messages(self, body, **parse_opts): def _raise_soap_errors(cls, fault): """Parse error messages contained in SOAP headers and raise as exceptions defined in this package.""" # Fault: See http://www.w3.org/TR/2000/NOTE-SOAP-20000508/#_Toc478383507 - fault_code = get_xml_attr(fault, 'faultcode') - fault_string = get_xml_attr(fault, 'faultstring') - fault_actor = get_xml_attr(fault, 'faultactor') - detail = fault.find('detail') + fault_code = get_xml_attr(fault, "faultcode") + fault_string = get_xml_attr(fault, "faultstring") + fault_actor = get_xml_attr(fault, "faultactor") + detail = fault.find("detail") if detail is not None: - code, msg = None, '' - if detail.find(f'{{{ENS}}}ResponseCode') is not None: - code = get_xml_attr(detail, f'{{{ENS}}}ResponseCode').strip() - if detail.find(f'{{{ENS}}}Message') is not None: - msg = get_xml_attr(detail, f'{{{ENS}}}Message').strip() - msg_xml = detail.find(f'{{{TNS}}}MessageXml') # Crazy. Here, it's in the TNS namespace - if code == 'ErrorServerBusy': + code, msg = None, "" + if detail.find(f"{{{ENS}}}ResponseCode") is not None: + code = get_xml_attr(detail, f"{{{ENS}}}ResponseCode").strip() + if detail.find(f"{{{ENS}}}Message") is not None: + msg = get_xml_attr(detail, f"{{{ENS}}}Message").strip() + msg_xml = detail.find(f"{{{TNS}}}MessageXml") # Crazy. Here, it's in the TNS namespace + if code == "ErrorServerBusy": back_off = None try: - value = msg_xml.find(f'{{{TNS}}}Value') - if value.get('Name') == 'BackOffMilliseconds': + value = msg_xml.find(f"{{{TNS}}}Value") + if value.get("Name") == "BackOffMilliseconds": back_off = int(value.text) / 1000.0 # Convert to seconds except (TypeError, AttributeError): pass raise ErrorServerBusy(msg, back_off=back_off) - if code == 'ErrorSchemaValidation' and msg_xml is not None: - line_number = get_xml_attr(msg_xml, f'{{{TNS}}}LineNumber') - line_position = get_xml_attr(msg_xml, f'{{{TNS}}}LinePosition') - violation = get_xml_attr(msg_xml, f'{{{TNS}}}Violation') + if code == "ErrorSchemaValidation" and msg_xml is not None: + line_number = get_xml_attr(msg_xml, f"{{{TNS}}}LineNumber") + line_position = get_xml_attr(msg_xml, f"{{{TNS}}}LinePosition") + violation = get_xml_attr(msg_xml, f"{{{TNS}}}Violation") if violation: - msg = f'{msg} {violation}' + msg = f"{msg} {violation}" if line_number or line_position: - msg = f'{msg} (line: {line_number} position: {line_position})' + msg = f"{msg} (line: {line_number} position: {line_position})" try: raise vars(errors)[code](msg) except KeyError: - detail = f'{cls.SERVICE_NAME}: code: {code} msg: {msg} ({xml_to_str(detail)})' + detail = f"{cls.SERVICE_NAME}: code: {code} msg: {msg} ({xml_to_str(detail)})" try: raise vars(errors)[fault_code](fault_string) except KeyError: pass - raise SOAPError(f'SOAP error code: {fault_code} string: {fault_string} actor: {fault_actor} detail: {detail}') + raise SOAPError(f"SOAP error code: {fault_code} string: {fault_string} actor: {fault_actor} detail: {detail}") def _get_element_container(self, message, name=None): """Return the XML element in a response element that contains the elements we want the service to return. For @@ -523,23 +600,23 @@ def _get_element_container(self, message, name=None): # ResponseClass is an XML attribute of various SomeServiceResponseMessage elements: Possible values are: # Success, Warning, Error. See e.g. # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/finditemresponsemessage - response_class = message.get('ResponseClass') + response_class = message.get("ResponseClass") # ResponseCode, MessageText: See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/responsecode - response_code = get_xml_attr(message, f'{{{MNS}}}ResponseCode') - if response_class == 'Success' and response_code == 'NoError': + response_code = get_xml_attr(message, f"{{{MNS}}}ResponseCode") + if response_class == "Success" and response_code == "NoError": if not name: return message container = message.find(name) if container is None: - raise MalformedResponseError(f'No {name} elements in ResponseMessage ({xml_to_str(message)})') + raise MalformedResponseError(f"No {name} elements in ResponseMessage ({xml_to_str(message)})") return container - if response_code == 'NoError': + if response_code == "NoError": return True # Raise any non-acceptable errors in the container, or return the container or the acceptable exception instance - msg_text = get_xml_attr(message, f'{{{MNS}}}MessageText') - msg_xml = message.find(f'{{{MNS}}}MessageXml') - if response_class == 'Warning': + msg_text = get_xml_attr(message, f"{{{MNS}}}MessageText") + msg_xml = message.find(f"{{{MNS}}}MessageXml") + if response_class == "Warning": try: raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml) except self.WARNINGS_TO_CATCH_IN_RESPONSE as e: @@ -548,7 +625,7 @@ def _get_element_container(self, message, name=None): log.warning(str(e)) container = message.find(name) if container is None: - raise MalformedResponseError(f'No {name} elements in ResponseMessage ({xml_to_str(message)})') + raise MalformedResponseError(f"No {name} elements in ResponseMessage ({xml_to_str(message)})") return container # rspclass == 'Error', or 'Success' and not 'NoError' try: @@ -560,21 +637,21 @@ def _get_element_container(self, message, name=None): def _get_exception(code, text, msg_xml): """Parse error messages contained in EWS responses and raise as exceptions defined in this package.""" if not code: - return TransportError(f'Empty ResponseCode in ResponseMessage (MessageText: {text}, MessageXml: {msg_xml})') + return TransportError(f"Empty ResponseCode in ResponseMessage (MessageText: {text}, MessageXml: {msg_xml})") if msg_xml is not None: # If this is an ErrorInvalidPropertyRequest error, the xml may contain a specific FieldURI for elem_cls in (FieldURI, IndexedFieldURI, ExtendedFieldURI, ExceptionFieldURI): elem = msg_xml.find(elem_cls.response_tag()) if elem is not None: field_uri = elem_cls.from_xml(elem, account=None) - text += f' (field: {field_uri})' + text += f" (field: {field_uri})" break # If this is an ErrorInvalidValueForProperty error, the xml may contain the name and value of the property - if code == 'ErrorInvalidValueForProperty': + if code == "ErrorInvalidValueForProperty": msg_parts = {} - for elem in msg_xml.findall(f'{{{TNS}}}Value'): - key, val = elem.get('Name'), elem.text + for elem in msg_xml.findall(f"{{{TNS}}}Value"): + key, val = elem.get("Name"), elem.text if key: msg_parts[key] = val if msg_parts: @@ -582,26 +659,26 @@ def _get_exception(code, text, msg_xml): # If this is an ErrorInternalServerError error, the xml may contain a more specific error code inner_code, inner_text = None, None - for value_elem in msg_xml.findall(f'{{{TNS}}}Value'): - name = value_elem.get('Name') - if name == 'InnerErrorResponseCode': + for value_elem in msg_xml.findall(f"{{{TNS}}}Value"): + name = value_elem.get("Name") + if name == "InnerErrorResponseCode": inner_code = value_elem.text - elif name == 'InnerErrorMessageText': + elif name == "InnerErrorMessageText": inner_text = value_elem.text if inner_code: try: # Raise the error as the inner error code - return vars(errors)[inner_code](f'{inner_text} (raised from: {code}({text!r}))') + return vars(errors)[inner_code](f"{inner_text} (raised from: {code}({text!r}))") except KeyError: # Inner code is unknown to us. Just append to the original text - text += f' (inner error: {inner_code}({inner_text!r}))' + text += f" (inner error: {inner_code}({inner_text!r}))" try: # Raise the error corresponding to the ResponseCode return vars(errors)[code](text) except KeyError: # Should not happen return TransportError( - f'Unknown ResponseCode in ResponseMessage: {code} (MessageText: {text}, MessageXml: {msg_xml})' + f"Unknown ResponseCode in ResponseMessage: {code} (MessageText: {text}, MessageXml: {msg_xml})" ) def _get_elements_in_response(self, response): @@ -664,8 +741,8 @@ class EWSAccountService(EWSService, metaclass=abc.ABCMeta): prefer_affinity = False def __init__(self, *args, **kwargs): - self.account = kwargs.pop('account') - kwargs['protocol'] = self.account.protocol + self.account = kwargs.pop("account") + kwargs["protocol"] = self.account.protocol super().__init__(*args, **kwargs) @property @@ -682,7 +759,7 @@ def _handle_response_cookies(self, session): # See self._extra_headers() for documentation on affinity if self.prefer_affinity: for cookie in session.cookies: - if cookie.name == 'X-BackEndOverrideCookie': + if cookie.name == "X-BackEndOverrideCookie": self.account.affinity_cookie = cookie.value break @@ -690,14 +767,14 @@ def _extra_headers(self, session): headers = super()._extra_headers(session=session) # See # https://blogs.msdn.microsoft.com/webdav_101/2015/05/11/best-practices-ews-authentication-and-access-issues/ - headers['X-AnchorMailbox'] = self.account.primary_smtp_address + headers["X-AnchorMailbox"] = self.account.primary_smtp_address # See # https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/how-to-maintain-affinity-between-group-of-subscriptions-and-mailbox-server if self.prefer_affinity: - headers['X-PreferServerAffinity'] = 'True' + headers["X-PreferServerAffinity"] = "True" if self.account.affinity_cookie: - headers['X-BackEndOverrideCookie'] = self.account.affinity_cookie + headers["X-BackEndOverrideCookie"] = self.account.affinity_cookie return headers @property @@ -713,9 +790,9 @@ def _timezone(self): class EWSPagingService(EWSAccountService): def __init__(self, *args, **kwargs): - self.page_size = kwargs.pop('page_size', None) or PAGE_SIZE + self.page_size = kwargs.pop("page_size", None) or PAGE_SIZE if not isinstance(self.page_size, int): - raise InvalidTypeError('page_size', self.page_size, int) + raise InvalidTypeError("page_size", self.page_size, int) if self.page_size < 1: raise ValueError(f"'page_size' {self.page_size} must be a positive number") super().__init__(*args, **kwargs) @@ -725,48 +802,51 @@ def _paged_call(self, payload_func, max_items, folders, **kwargs): all paging-related counters. """ paging_infos = {f: dict(item_count=0, next_offset=None) for f in folders} - common_next_offset = kwargs['offset'] + common_next_offset = kwargs["offset"] total_item_count = 0 while True: if not paging_infos: # Paging is done for all folders break - log.debug('Getting page at offset %s (max_items %s)', common_next_offset, max_items) - kwargs['offset'] = common_next_offset - kwargs['folders'] = paging_infos.keys() # Only request the paging of the remaining folders. + log.debug("Getting page at offset %s (max_items %s)", common_next_offset, max_items) + kwargs["offset"] = common_next_offset + kwargs["folders"] = paging_infos.keys() # Only request the paging of the remaining folders. pages = self._get_pages(payload_func, kwargs, len(paging_infos)) for (page, next_offset), (f, paging_info) in zip(pages, list(paging_infos.items())): - paging_info['next_offset'] = next_offset + paging_info["next_offset"] = next_offset if isinstance(page, Exception): # Assume this folder no longer works. Don't attempt to page it again. - log.debug('Exception occurred for folder %s. Removing.', f) + log.debug("Exception occurred for folder %s. Removing.", f) del paging_infos[f] yield page continue if page is not None: for elem in self._get_elems_from_page(page, max_items, total_item_count): - paging_info['item_count'] += 1 + paging_info["item_count"] += 1 total_item_count += 1 yield elem if max_items and total_item_count >= max_items: # No need to continue. Break out of inner loop log.debug("'max_items' count reached (inner)") break - if not paging_info['next_offset']: + if not paging_info["next_offset"]: # Paging is done for this folder. Don't attempt to page it again. - log.debug('Paging has completed for folder %s. Removing.', f) + log.debug("Paging has completed for folder %s. Removing.", f) del paging_infos[f] continue - log.debug('Folder %s still has items', f) + log.debug("Folder %s still has items", f) # Check sanity of paging offsets, but don't fail. When we are iterating huge collections that take a # long time to complete, the collection may change while we are iterating. This can affect the # 'next_offset' value and make it inconsistent with the number of already collected items. # We may have a mismatch if we stopped early due to reaching 'max_items'. - if paging_info['next_offset'] != paging_info['item_count'] and ( + if paging_info["next_offset"] != paging_info["item_count"] and ( not max_items or total_item_count < max_items ): - log.warning('Unexpected next offset: %s -> %s. Maybe the server-side collection has changed?', - paging_info['item_count'], paging_info['next_offset']) + log.warning( + "Unexpected next offset: %s -> %s. Maybe the server-side collection has changed?", + paging_info["item_count"], + paging_info["next_offset"], + ) # Also break out of outer loop if max_items and total_item_count >= max_items: log.debug("'max_items' count reached (outer)") @@ -779,11 +859,11 @@ def _paged_call(self, payload_func, max_items, folders, **kwargs): @staticmethod def _get_paging_values(elem): """Read paging information from the paging container element.""" - offset_attr = elem.get('IndexedPagingOffset') + offset_attr = elem.get("IndexedPagingOffset") next_offset = None if offset_attr is None else int(offset_attr) - item_count = int(elem.get('TotalItemsInView')) - is_last_page = elem.get('IncludesLastItemInRange').lower() in ('true', '0') - log.debug('Got page with offset %s, item_count %s, last_page %s', next_offset, item_count, is_last_page) + item_count = int(elem.get("TotalItemsInView")) + is_last_page = elem.get("IncludesLastItemInRange").lower() in ("true", "0") + log.debug("Got page with offset %s, item_count %s, last_page %s", next_offset, item_count, is_last_page) # Clean up contradictory paging values if next_offset is None and not is_last_page: log.debug("Not last page in range, but server didn't send a page offset. Assuming first page") @@ -814,7 +894,7 @@ def _get_elems_from_page(self, elem, max_items, total_item_count): container = elem.find(self.element_container_name) if container is None: raise MalformedResponseError( - f'No {self.element_container_name} elements in ResponseMessage ({xml_to_str(elem)})' + f"No {self.element_container_name} elements in ResponseMessage ({xml_to_str(elem)})" ) for e in self._get_elements_in_container(container=container): if max_items and total_item_count >= max_items: @@ -837,7 +917,7 @@ def _get_pages(self, payload_func, kwargs, expected_message_count): @staticmethod def _get_next_offset(paging_infos): - next_offsets = {p['next_offset'] for p in paging_infos if p['next_offset'] is not None} + next_offsets = {p["next_offset"] for p in paging_infos if p["next_offset"] is not None} if not next_offsets: # Paging is done for all messages return None @@ -849,7 +929,7 @@ def _get_next_offset(paging_infos): # choose something that is most likely to work. Select the lowest of all the values to at least make sure # we don't miss any items, although we may then get duplicates ¯\_(ツ)_/¯ if len(next_offsets) > 1: - log.warning('Inconsistent next_offset values: %r. Using lowest value', next_offsets) + log.warning("Inconsistent next_offset values: %r. Using lowest value", next_offsets) return min(next_offsets) @@ -871,17 +951,18 @@ def to_item_id(item, item_cls): def shape_element(tag, shape, additional_fields, version): shape_elem = create_element(tag) - add_xml_child(shape_elem, 't:BaseShape', shape) + add_xml_child(shape_elem, "t:BaseShape", shape) if additional_fields: - additional_properties = create_element('t:AdditionalProperties') + additional_properties = create_element("t:AdditionalProperties") expanded_fields = chain(*(f.expand(version=version) for f in additional_fields)) # 'path' is insufficient to consistently sort additional properties. For example, we have both # 'contacts:Companies' and 'task:Companies' with path 'companies'. Sort by both 'field_uri' and 'path'. # Extended properties do not have a 'field_uri' value. - set_xml_value(additional_properties, sorted( - expanded_fields, - key=lambda f: (getattr(f.field, 'field_uri', ''), f.path) - ), version=version) + set_xml_value( + additional_properties, + sorted(expanded_fields, key=lambda f: (getattr(f.field, "field_uri", ""), f.path)), + version=version, + ) shape_elem.append(additional_properties) return shape_elem @@ -893,15 +974,15 @@ def _ids_element(items, item_cls, version, tag): return item_ids -def folder_ids_element(folders, version, tag='m:FolderIds'): +def folder_ids_element(folders, version, tag="m:FolderIds"): return _ids_element(folders, FolderId, version, tag) -def item_ids_element(items, version, tag='m:ItemIds'): +def item_ids_element(items, version, tag="m:ItemIds"): return _ids_element(items, ItemId, version, tag) -def attachment_ids_element(items, version, tag='m:AttachmentIds'): +def attachment_ids_element(items, version, tag="m:AttachmentIds"): return _ids_element(items, AttachmentId, version, tag) @@ -917,7 +998,7 @@ def parse_folder_elem(elem, folder, account): folder_cls = cls break else: - raise ValueError(f'Unknown distinguished folder ID: {folder.id}') + raise ValueError(f"Unknown distinguished folder ID: {folder.id}") f = folder_cls.from_xml_with_root(elem=elem, root=account.root) else: # 'folder' is a generic FolderId instance. We don't know the root so assume account.root. diff --git a/exchangelib/services/convert_id.py b/exchangelib/services/convert_id.py index 8ab37f51..c9b8bef8 100644 --- a/exchangelib/services/convert_id.py +++ b/exchangelib/services/convert_id.py @@ -1,8 +1,8 @@ -from .common import EWSService from ..errors import InvalidEnumValue, InvalidTypeError -from ..properties import AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId, ID_FORMATS +from ..properties import ID_FORMATS, AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId from ..util import create_element, set_xml_value from ..version import EXCHANGE_2007_SP1 +from .common import EWSService class ConvertId(EWSService): @@ -12,15 +12,13 @@ class ConvertId(EWSService): MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/convertid-operation """ - SERVICE_NAME = 'ConvertId' + SERVICE_NAME = "ConvertId" supported_from = EXCHANGE_2007_SP1 - cls_map = {cls.response_tag(): cls for cls in ( - AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId - )} + cls_map = {cls.response_tag(): cls for cls in (AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId)} def call(self, items, destination_format): if destination_format not in ID_FORMATS: - raise InvalidEnumValue('destination_format', destination_format, ID_FORMATS) + raise InvalidEnumValue("destination_format", destination_format, ID_FORMATS) return self._elems_to_objs( self._chunked_get_elements(self.get_payload, items=items, destination_format=destination_format) ) @@ -30,11 +28,11 @@ def _elem_to_obj(self, elem): def get_payload(self, items, destination_format): supported_item_classes = AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DestinationFormat=destination_format)) - item_ids = create_element('m:SourceIds') + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(DestinationFormat=destination_format)) + item_ids = create_element("m:SourceIds") for item in items: if not isinstance(item, supported_item_classes): - raise InvalidTypeError('item', item, supported_item_classes) + raise InvalidTypeError("item", item, supported_item_classes) set_xml_value(item_ids, item, version=self.protocol.version) payload.append(item_ids) return payload @@ -42,6 +40,8 @@ def get_payload(self, items, destination_format): @classmethod def _get_elements_in_container(cls, container): # We may have other elements in here, e.g. 'ResponseCode'. Filter away those. - return container.findall(AlternateId.response_tag()) \ - + container.findall(AlternatePublicFolderId.response_tag()) \ + return ( + container.findall(AlternateId.response_tag()) + + container.findall(AlternatePublicFolderId.response_tag()) + container.findall(AlternatePublicFolderItemId.response_tag()) + ) diff --git a/exchangelib/services/copy_item.py b/exchangelib/services/copy_item.py index f5066a89..0cb6a0fb 100644 --- a/exchangelib/services/copy_item.py +++ b/exchangelib/services/copy_item.py @@ -4,4 +4,4 @@ class CopyItem(move_item.MoveItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/copyitem-operation""" - SERVICE_NAME = 'CopyItem' + SERVICE_NAME = "CopyItem" diff --git a/exchangelib/services/create_attachment.py b/exchangelib/services/create_attachment.py index 3878795e..993fafe9 100644 --- a/exchangelib/services/create_attachment.py +++ b/exchangelib/services/create_attachment.py @@ -1,8 +1,8 @@ -from .common import EWSAccountService, to_item_id from ..attachments import FileAttachment, ItemAttachment from ..items import BaseItem from ..properties import ParentItemId -from ..util import create_element, set_xml_value, MNS +from ..util import MNS, create_element, set_xml_value +from .common import EWSAccountService, to_item_id class CreateAttachment(EWSAccountService): @@ -10,8 +10,8 @@ class CreateAttachment(EWSAccountService): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createattachment-operation """ - SERVICE_NAME = 'CreateAttachment' - element_container_name = f'{{{MNS}}}Attachments' + SERVICE_NAME = "CreateAttachment" + element_container_name = f"{{{MNS}}}Attachments" cls_map = {cls.response_tag(): cls for cls in (FileAttachment, ItemAttachment)} def call(self, parent_item, items): @@ -21,13 +21,13 @@ def _elem_to_obj(self, elem): return self.cls_map[elem.tag].from_xml(elem=elem, account=self.account) def get_payload(self, items, parent_item): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") version = self.account.version if isinstance(parent_item, BaseItem): # to_item_id() would convert this to a normal ItemId, but the service wants a ParentItemId parent_item = ParentItemId(parent_item.id, parent_item.changekey) set_xml_value(payload, to_item_id(parent_item, ParentItemId), version=self.account.version) - attachments = create_element('m:Attachments') + attachments = create_element("m:Attachments") for item in items: set_xml_value(attachments, item, version=version) payload.append(attachments) diff --git a/exchangelib/services/create_folder.py b/exchangelib/services/create_folder.py index 1a17e2e1..738946f1 100644 --- a/exchangelib/services/create_folder.py +++ b/exchangelib/services/create_folder.py @@ -1,16 +1,14 @@ -from .common import EWSAccountService, parse_folder_elem, folder_ids_element from ..errors import ErrorFolderExists -from ..util import create_element, MNS +from ..util import MNS, create_element +from .common import EWSAccountService, folder_ids_element, parse_folder_elem class CreateFolder(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createfolder-operation""" - SERVICE_NAME = 'CreateFolder' - element_container_name = f'{{{MNS}}}Folders' - ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + ( - ErrorFolderExists, - ) + SERVICE_NAME = "CreateFolder" + element_container_name = f"{{{MNS}}}Folders" + ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (ErrorFolderExists,) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -20,9 +18,13 @@ def call(self, parent_folder, folders): # We can't easily find the correct folder class from the returned XML. Instead, return objects with the same # class as the folder instance it was requested with. self.folders = list(folders) # Convert to a list, in case 'folders' is a generator. We're iterating twice. - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, items=self.folders, parent_folder=parent_folder, - )) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=self.folders, + parent_folder=parent_folder, + ) + ) def _elems_to_objs(self, elems): for folder, elem in zip(self.folders, elems): @@ -32,9 +34,9 @@ def _elems_to_objs(self, elems): yield parse_folder_elem(elem=elem, folder=folder, account=self.account) def get_payload(self, folders, parent_folder): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") payload.append( - folder_ids_element(folders=[parent_folder], version=self.account.version, tag='m:ParentFolderId') + folder_ids_element(folders=[parent_folder], version=self.account.version, tag="m:ParentFolderId") ) - payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:Folders')) + payload.append(folder_ids_element(folders=folders, version=self.account.version, tag="m:Folders")) return payload diff --git a/exchangelib/services/create_item.py b/exchangelib/services/create_item.py index 27318a27..6cbbdfde 100644 --- a/exchangelib/services/create_item.py +++ b/exchangelib/services/create_item.py @@ -1,10 +1,16 @@ -from .common import EWSAccountService, folder_ids_element from ..errors import InvalidEnumValue, InvalidTypeError from ..folders import BaseFolder -from ..items import SAVE_ONLY, SEND_AND_SAVE_COPY, SEND_ONLY, \ - SEND_MEETING_INVITATIONS_CHOICES, MESSAGE_DISPOSITION_CHOICES, BulkCreateResult +from ..items import ( + MESSAGE_DISPOSITION_CHOICES, + SAVE_ONLY, + SEND_AND_SAVE_COPY, + SEND_MEETING_INVITATIONS_CHOICES, + SEND_ONLY, + BulkCreateResult, +) from ..properties import FolderId -from ..util import create_element, set_xml_value, MNS +from ..util import MNS, create_element, set_xml_value +from .common import EWSAccountService, folder_ids_element class CreateItem(EWSAccountService): @@ -14,34 +20,36 @@ class CreateItem(EWSAccountService): MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createitem-operation """ - SERVICE_NAME = 'CreateItem' - element_container_name = f'{{{MNS}}}Items' + SERVICE_NAME = "CreateItem" + element_container_name = f"{{{MNS}}}Items" def call(self, items, folder, message_disposition, send_meeting_invitations): if message_disposition not in MESSAGE_DISPOSITION_CHOICES: - raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES) + raise InvalidEnumValue("message_disposition", message_disposition, MESSAGE_DISPOSITION_CHOICES) if send_meeting_invitations not in SEND_MEETING_INVITATIONS_CHOICES: raise InvalidEnumValue( - 'send_meeting_invitations', send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES + "send_meeting_invitations", send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES ) if folder is not None: if not isinstance(folder, (BaseFolder, FolderId)): - raise InvalidTypeError('folder', folder, (BaseFolder, FolderId)) + raise InvalidTypeError("folder", folder, (BaseFolder, FolderId)) if folder.account != self.account: - raise ValueError('Folder must belong to account') + raise ValueError("Folder must belong to account") if message_disposition == SAVE_ONLY and folder is None: raise AttributeError("Folder must be supplied when in save-only mode") if message_disposition == SEND_AND_SAVE_COPY and folder is None: folder = self.account.sent # 'Sent' is default EWS behaviour if message_disposition == SEND_ONLY and folder is not None: raise AttributeError("Folder must be None in send-ony mode") - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, - items=items, - folder=folder, - message_disposition=message_disposition, - send_meeting_invitations=send_meeting_invitations, - )) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=items, + folder=folder, + message_disposition=message_disposition, + send_meeting_invitations=send_meeting_invitations, + ) + ) def _elem_to_obj(self, elem): if isinstance(elem, bool): @@ -73,14 +81,14 @@ def get_payload(self, items, folder, message_disposition, send_meeting_invitatio :param send_meeting_invitations: """ payload = create_element( - f'm:{self.SERVICE_NAME}', - attrs=dict(MessageDisposition=message_disposition, SendMeetingInvitations=send_meeting_invitations) + f"m:{self.SERVICE_NAME}", + attrs=dict(MessageDisposition=message_disposition, SendMeetingInvitations=send_meeting_invitations), ) if folder: - payload.append(folder_ids_element( - folders=[folder], version=self.account.version, tag='m:SavedItemFolderId' - )) - item_elems = create_element('m:Items') + payload.append( + folder_ids_element(folders=[folder], version=self.account.version, tag="m:SavedItemFolderId") + ) + item_elems = create_element("m:Items") for item in items: if not item.account: item.account = self.account diff --git a/exchangelib/services/create_user_configuration.py b/exchangelib/services/create_user_configuration.py index 7d6b7b3b..93bfcb84 100644 --- a/exchangelib/services/create_user_configuration.py +++ b/exchangelib/services/create_user_configuration.py @@ -1,5 +1,5 @@ -from .common import EWSAccountService from ..util import create_element, set_xml_value +from .common import EWSAccountService class CreateUserConfiguration(EWSAccountService): @@ -7,7 +7,7 @@ class CreateUserConfiguration(EWSAccountService): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createuserconfiguration-operation """ - SERVICE_NAME = 'CreateUserConfiguration' + SERVICE_NAME = "CreateUserConfiguration" returns_elements = False def call(self, user_configuration): @@ -15,5 +15,5 @@ def call(self, user_configuration): def get_payload(self, user_configuration): return set_xml_value( - create_element(f'm:{self.SERVICE_NAME}'), user_configuration, version=self.protocol.version + create_element(f"m:{self.SERVICE_NAME}"), user_configuration, version=self.protocol.version ) diff --git a/exchangelib/services/delete_attachment.py b/exchangelib/services/delete_attachment.py index cb71d041..c38fcfb5 100644 --- a/exchangelib/services/delete_attachment.py +++ b/exchangelib/services/delete_attachment.py @@ -1,6 +1,6 @@ -from .common import EWSAccountService, attachment_ids_element from ..properties import RootItemId from ..util import create_element, set_xml_value +from .common import EWSAccountService, attachment_ids_element class DeleteAttachment(EWSAccountService): @@ -8,7 +8,7 @@ class DeleteAttachment(EWSAccountService): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteattachment-operation """ - SERVICE_NAME = 'DeleteAttachment' + SERVICE_NAME = "DeleteAttachment" def call(self, items): return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items)) @@ -22,7 +22,7 @@ def _get_elements_in_container(cls, container): def get_payload(self, items): return set_xml_value( - create_element(f'm:{self.SERVICE_NAME}'), + create_element(f"m:{self.SERVICE_NAME}"), attachment_ids_element(items=items, version=self.account.version), - version=self.account.version + version=self.account.version, ) diff --git a/exchangelib/services/delete_folder.py b/exchangelib/services/delete_folder.py index a063d08c..54d3324a 100644 --- a/exchangelib/services/delete_folder.py +++ b/exchangelib/services/delete_folder.py @@ -1,21 +1,21 @@ -from .common import EWSAccountService, folder_ids_element from ..errors import InvalidEnumValue from ..items import DELETE_TYPE_CHOICES from ..util import create_element +from .common import EWSAccountService, folder_ids_element class DeleteFolder(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deletefolder-operation""" - SERVICE_NAME = 'DeleteFolder' + SERVICE_NAME = "DeleteFolder" returns_elements = False def call(self, folders, delete_type): if delete_type not in DELETE_TYPE_CHOICES: - raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES) + raise InvalidEnumValue("delete_type", delete_type, DELETE_TYPE_CHOICES) return self._chunked_get_elements(self.get_payload, items=folders, delete_type=delete_type) def get_payload(self, folders, delete_type): - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DeleteType=delete_type)) + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(DeleteType=delete_type)) payload.append(folder_ids_element(folders=folders, version=self.account.version)) return payload diff --git a/exchangelib/services/delete_item.py b/exchangelib/services/delete_item.py index 1aac043b..4a3ba6b9 100644 --- a/exchangelib/services/delete_item.py +++ b/exchangelib/services/delete_item.py @@ -1,8 +1,8 @@ -from .common import EWSAccountService, item_ids_element from ..errors import InvalidEnumValue -from ..items import DELETE_TYPE_CHOICES, SEND_MEETING_CANCELLATIONS_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES +from ..items import AFFECTED_TASK_OCCURRENCES_CHOICES, DELETE_TYPE_CHOICES, SEND_MEETING_CANCELLATIONS_CHOICES from ..util import create_element from ..version import EXCHANGE_2013_SP1 +from .common import EWSAccountService, item_ids_element class DeleteItem(EWSAccountService): @@ -12,19 +12,19 @@ class DeleteItem(EWSAccountService): MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteitem-operation """ - SERVICE_NAME = 'DeleteItem' + SERVICE_NAME = "DeleteItem" returns_elements = False def call(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): if delete_type not in DELETE_TYPE_CHOICES: - raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES) + raise InvalidEnumValue("delete_type", delete_type, DELETE_TYPE_CHOICES) if send_meeting_cancellations not in SEND_MEETING_CANCELLATIONS_CHOICES: raise InvalidEnumValue( - 'send_meeting_cancellations', send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES + "send_meeting_cancellations", send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES ) if affected_task_occurrences not in AFFECTED_TASK_OCCURRENCES_CHOICES: raise InvalidEnumValue( - 'affected_task_occurrences', affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES + "affected_task_occurrences", affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES ) return self._chunked_get_elements( self.get_payload, @@ -35,8 +35,9 @@ def call(self, items, delete_type, send_meeting_cancellations, affected_task_occ suppress_read_receipts=suppress_read_receipts, ) - def get_payload(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, - suppress_read_receipts): + def get_payload( + self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts + ): # Takes a list of (id, changekey) tuples or Item objects and returns the XML for a DeleteItem request. attrs = dict( DeleteType=delete_type, @@ -44,7 +45,7 @@ def get_payload(self, items, delete_type, send_meeting_cancellations, affected_t AffectedTaskOccurrences=affected_task_occurrences, ) if self.account.version.build >= EXCHANGE_2013_SP1: - attrs['SuppressReadReceipts'] = suppress_read_receipts - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + attrs["SuppressReadReceipts"] = suppress_read_receipts + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=attrs) payload.append(item_ids_element(items=items, version=self.account.version)) return payload diff --git a/exchangelib/services/delete_user_configuration.py b/exchangelib/services/delete_user_configuration.py index ca1cf039..cea4f08e 100644 --- a/exchangelib/services/delete_user_configuration.py +++ b/exchangelib/services/delete_user_configuration.py @@ -1,5 +1,5 @@ -from .common import EWSAccountService from ..util import create_element, set_xml_value +from .common import EWSAccountService class DeleteUserConfiguration(EWSAccountService): @@ -7,7 +7,7 @@ class DeleteUserConfiguration(EWSAccountService): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteuserconfiguration-operation """ - SERVICE_NAME = 'DeleteUserConfiguration' + SERVICE_NAME = "DeleteUserConfiguration" returns_elements = False def call(self, user_configuration_name): @@ -15,5 +15,5 @@ def call(self, user_configuration_name): def get_payload(self, user_configuration_name): return set_xml_value( - create_element(f'm:{self.SERVICE_NAME}'), user_configuration_name, version=self.account.version + create_element(f"m:{self.SERVICE_NAME}"), user_configuration_name, version=self.account.version ) diff --git a/exchangelib/services/empty_folder.py b/exchangelib/services/empty_folder.py index 209c832e..2663c71c 100644 --- a/exchangelib/services/empty_folder.py +++ b/exchangelib/services/empty_folder.py @@ -1,26 +1,25 @@ -from .common import EWSAccountService, folder_ids_element from ..errors import InvalidEnumValue from ..items import DELETE_TYPE_CHOICES from ..util import create_element +from .common import EWSAccountService, folder_ids_element class EmptyFolder(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/emptyfolder-operation""" - SERVICE_NAME = 'EmptyFolder' + SERVICE_NAME = "EmptyFolder" returns_elements = False def call(self, folders, delete_type, delete_sub_folders): if delete_type not in DELETE_TYPE_CHOICES: - raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES) + raise InvalidEnumValue("delete_type", delete_type, DELETE_TYPE_CHOICES) return self._chunked_get_elements( self.get_payload, items=folders, delete_type=delete_type, delete_sub_folders=delete_sub_folders ) def get_payload(self, folders, delete_type, delete_sub_folders): payload = create_element( - f'm:{self.SERVICE_NAME}', - attrs=dict(DeleteType=delete_type, DeleteSubFolders=delete_sub_folders) + f"m:{self.SERVICE_NAME}", attrs=dict(DeleteType=delete_type, DeleteSubFolders=delete_sub_folders) ) payload.append(folder_ids_element(folders=folders, version=self.account.version)) return payload diff --git a/exchangelib/services/expand_dl.py b/exchangelib/services/expand_dl.py index ec21b5a8..c20bb54f 100644 --- a/exchangelib/services/expand_dl.py +++ b/exchangelib/services/expand_dl.py @@ -1,14 +1,14 @@ -from .common import EWSService from ..errors import ErrorNameResolutionMultipleResults from ..properties import Mailbox -from ..util import create_element, set_xml_value, MNS +from ..util import MNS, create_element, set_xml_value +from .common import EWSService class ExpandDL(EWSService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/expanddl-operation""" - SERVICE_NAME = 'ExpandDL' - element_container_name = f'{{{MNS}}}DLExpansion' + SERVICE_NAME = "ExpandDL" + element_container_name = f"{{{MNS}}}DLExpansion" WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults def call(self, distribution_list): @@ -18,4 +18,4 @@ def _elem_to_obj(self, elem): return Mailbox.from_xml(elem, account=None) def get_payload(self, distribution_list): - return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), distribution_list, version=self.protocol.version) + return set_xml_value(create_element(f"m:{self.SERVICE_NAME}"), distribution_list, version=self.protocol.version) diff --git a/exchangelib/services/export_items.py b/exchangelib/services/export_items.py index 9663275e..94e977c7 100644 --- a/exchangelib/services/export_items.py +++ b/exchangelib/services/export_items.py @@ -1,14 +1,14 @@ -from .common import EWSAccountService, item_ids_element from ..errors import ResponseMessageError -from ..util import create_element, MNS +from ..util import MNS, create_element +from .common import EWSAccountService, item_ids_element class ExportItems(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exportitems-operation""" ERRORS_TO_CATCH_IN_RESPONSE = ResponseMessageError - SERVICE_NAME = 'ExportItems' - element_container_name = f'{{{MNS}}}Data' + SERVICE_NAME = "ExportItems" + element_container_name = f"{{{MNS}}}Data" def call(self, items): return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items)) @@ -17,7 +17,7 @@ def _elem_to_obj(self, elem): return elem.text # All we want is the 64bit string in the 'Data' tag def get_payload(self, items): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") payload.append(item_ids_element(items=items, version=self.account.version)) return payload diff --git a/exchangelib/services/find_folder.py b/exchangelib/services/find_folder.py index 6579e9d7..2aae3328 100644 --- a/exchangelib/services/find_folder.py +++ b/exchangelib/services/find_folder.py @@ -1,18 +1,18 @@ -from .common import EWSPagingService, shape_element, folder_ids_element from ..errors import InvalidEnumValue from ..folders import Folder from ..folders.queryset import FOLDER_TRAVERSAL_CHOICES from ..items import SHAPE_CHOICES -from ..util import create_element, TNS, MNS +from ..util import MNS, TNS, create_element from ..version import EXCHANGE_2010 +from .common import EWSPagingService, folder_ids_element, shape_element class FindFolder(EWSPagingService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/findfolder-operation""" - SERVICE_NAME = 'FindFolder' - element_container_name = f'{{{TNS}}}Folders' - paging_container_name = f'{{{MNS}}}RootFolder' + SERVICE_NAME = "FindFolder" + element_container_name = f"{{{TNS}}}Folders" + paging_container_name = f"{{{MNS}}}RootFolder" supports_paging = True def __init__(self, *args, **kwargs): @@ -33,14 +33,15 @@ def call(self, folders, additional_fields, restriction, shape, depth, max_items, :return: XML elements for the matching folders """ if shape not in SHAPE_CHOICES: - raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + raise InvalidEnumValue("shape", shape, SHAPE_CHOICES) if depth not in FOLDER_TRAVERSAL_CHOICES: - raise InvalidEnumValue('depth', depth, FOLDER_TRAVERSAL_CHOICES) + raise InvalidEnumValue("depth", depth, FOLDER_TRAVERSAL_CHOICES) roots = {f.root for f in folders} if len(roots) != 1: raise ValueError(f"All folders in 'roots' must have the same root hierarchy ({roots})") self.root = roots.pop() - return self._elems_to_objs(self._paged_call( + return self._elems_to_objs( + self._paged_call( payload_func=self.get_payload, max_items=max_items, folders=folders, @@ -51,21 +52,24 @@ def call(self, folders, additional_fields, restriction, shape, depth, max_items, depth=depth, page_size=self.page_size, offset=offset, - ) - )) + ), + ) + ) def _elem_to_obj(self, elem): return Folder.from_xml_with_root(elem=elem, root=self.root) def get_payload(self, folders, additional_fields, restriction, shape, depth, page_size, offset=0): - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) - payload.append(shape_element( - tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version - )) + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(Traversal=depth)) + payload.append( + shape_element( + tag="m:FolderShape", shape=shape, additional_fields=additional_fields, version=self.account.version + ) + ) if self.account.version.build >= EXCHANGE_2010: indexed_page_folder_view = create_element( - 'm:IndexedPageFolderView', - attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') + "m:IndexedPageFolderView", + attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint="Beginning"), ) payload.append(indexed_page_folder_view) else: @@ -73,5 +77,5 @@ def get_payload(self, folders, additional_fields, restriction, shape, depth, pag raise NotImplementedError("'offset' is only supported for Exchange 2010 servers and later") if restriction: payload.append(restriction.to_xml(version=self.account.version)) - payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds')) + payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag="m:ParentFolderIds")) return payload diff --git a/exchangelib/services/find_item.py b/exchangelib/services/find_item.py index 6ace4756..65bff3b4 100644 --- a/exchangelib/services/find_item.py +++ b/exchangelib/services/find_item.py @@ -1,16 +1,16 @@ -from .common import EWSPagingService, shape_element, folder_ids_element from ..errors import InvalidEnumValue from ..folders.base import BaseFolder -from ..items import Item, ID_ONLY, SHAPE_CHOICES, ITEM_TRAVERSAL_CHOICES -from ..util import create_element, set_xml_value, TNS, MNS +from ..items import ID_ONLY, ITEM_TRAVERSAL_CHOICES, SHAPE_CHOICES, Item +from ..util import MNS, TNS, create_element, set_xml_value +from .common import EWSPagingService, folder_ids_element, shape_element class FindItem(EWSPagingService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/finditem-operation""" - SERVICE_NAME = 'FindItem' - element_container_name = f'{{{TNS}}}Items' - paging_container_name = f'{{{MNS}}}RootFolder' + SERVICE_NAME = "FindItem" + element_container_name = f"{{{TNS}}}Items" + paging_container_name = f"{{{MNS}}}RootFolder" supports_paging = True def __init__(self, *args, **kwargs): @@ -19,8 +19,19 @@ def __init__(self, *args, **kwargs): self.additional_fields = None self.shape = None - def call(self, folders, additional_fields, restriction, order_fields, shape, query_string, depth, calendar_view, - max_items, offset): + def call( + self, + folders, + additional_fields, + restriction, + order_fields, + shape, + query_string, + depth, + calendar_view, + max_items, + offset, + ): """Find items in an account. :param folders: the folders to act on @@ -37,43 +48,57 @@ def call(self, folders, additional_fields, restriction, order_fields, shape, que :return: XML elements for the matching items """ if shape not in SHAPE_CHOICES: - raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + raise InvalidEnumValue("shape", shape, SHAPE_CHOICES) if depth not in ITEM_TRAVERSAL_CHOICES: - raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) + raise InvalidEnumValue("depth", depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape - return self._elems_to_objs(self._paged_call( - payload_func=self.get_payload, - max_items=max_items, - folders=folders, - **dict( - additional_fields=additional_fields, - restriction=restriction, - order_fields=order_fields, - query_string=query_string, - shape=shape, - depth=depth, - calendar_view=calendar_view, - page_size=self.page_size, - offset=offset, + return self._elems_to_objs( + self._paged_call( + payload_func=self.get_payload, + max_items=max_items, + folders=folders, + **dict( + additional_fields=additional_fields, + restriction=restriction, + order_fields=order_fields, + query_string=query_string, + shape=shape, + depth=depth, + calendar_view=calendar_view, + page_size=self.page_size, + offset=offset, + ), ) - )) + ) def _elem_to_obj(self, elem): if self.shape == ID_ONLY and self.additional_fields is None: return Item.id_from_xml(elem) return BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) - def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, - calendar_view, page_size, offset=0): - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) - payload.append(shape_element( - tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version - )) + def get_payload( + self, + folders, + additional_fields, + restriction, + order_fields, + query_string, + shape, + depth, + calendar_view, + page_size, + offset=0, + ): + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(Traversal=depth)) + payload.append( + shape_element( + tag="m:ItemShape", shape=shape, additional_fields=additional_fields, version=self.account.version + ) + ) if calendar_view is None: view_type = create_element( - 'm:IndexedPageItemView', - attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') + "m:IndexedPageItemView", attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint="Beginning") ) else: view_type = calendar_view.to_xml(version=self.account.version) @@ -81,12 +106,8 @@ def get_payload(self, folders, additional_fields, restriction, order_fields, que if restriction: payload.append(restriction.to_xml(version=self.account.version)) if order_fields: - payload.append(set_xml_value( - create_element('m:SortOrder'), - order_fields, - version=self.account.version - )) - payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds')) + payload.append(set_xml_value(create_element("m:SortOrder"), order_fields, version=self.account.version)) + payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag="m:ParentFolderIds")) if query_string: payload.append(query_string.to_xml(version=self.account.version)) return payload diff --git a/exchangelib/services/find_people.py b/exchangelib/services/find_people.py index 1eeea5e3..0c309837 100644 --- a/exchangelib/services/find_people.py +++ b/exchangelib/services/find_people.py @@ -1,9 +1,10 @@ import logging -from .common import EWSPagingService, shape_element, folder_ids_element + from ..errors import InvalidEnumValue -from ..items import Persona, ID_ONLY, SHAPE_CHOICES, ITEM_TRAVERSAL_CHOICES -from ..util import create_element, set_xml_value, MNS +from ..items import ID_ONLY, ITEM_TRAVERSAL_CHOICES, SHAPE_CHOICES, Persona +from ..util import MNS, create_element, set_xml_value from ..version import EXCHANGE_2013 +from .common import EWSPagingService, folder_ids_element, shape_element log = logging.getLogger(__name__) @@ -11,8 +12,8 @@ class FindPeople(EWSPagingService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/findpeople-operation""" - SERVICE_NAME = 'FindPeople' - element_container_name = f'{{{MNS}}}People' + SERVICE_NAME = "FindPeople" + element_container_name = f"{{{MNS}}}People" supported_from = EXCHANGE_2013 supports_paging = True @@ -38,52 +39,54 @@ def call(self, folder, additional_fields, restriction, order_fields, shape, quer :return: XML elements for the matching items """ if shape not in SHAPE_CHOICES: - raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + raise InvalidEnumValue("shape", shape, SHAPE_CHOICES) if depth not in ITEM_TRAVERSAL_CHOICES: - raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) + raise InvalidEnumValue("depth", depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape - return self._elems_to_objs(self._paged_call( - payload_func=self.get_payload, - max_items=max_items, - folders=[folder], # We just need the list to satisfy self._paged_call() - **dict( - additional_fields=additional_fields, - restriction=restriction, - order_fields=order_fields, - query_string=query_string, - shape=shape, - depth=depth, - page_size=self.page_size, - offset=offset, + return self._elems_to_objs( + self._paged_call( + payload_func=self.get_payload, + max_items=max_items, + folders=[folder], # We just need the list to satisfy self._paged_call() + **dict( + additional_fields=additional_fields, + restriction=restriction, + order_fields=order_fields, + query_string=query_string, + shape=shape, + depth=depth, + page_size=self.page_size, + offset=offset, + ), ) - )) + ) def _elem_to_obj(self, elem): if self.shape == ID_ONLY and self.additional_fields is None: return Persona.id_from_xml(elem) return Persona.from_xml(elem, account=self.account) - def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, - offset=0): + def get_payload( + self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, offset=0 + ): # We actually only support a single folder, but self._paged_call() sends us a list - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) - payload.append(shape_element( - tag='m:PersonaShape', shape=shape, additional_fields=additional_fields, version=self.account.version - )) - payload.append(create_element( - 'm:IndexedPageItemView', - attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') - )) + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(Traversal=depth)) + payload.append( + shape_element( + tag="m:PersonaShape", shape=shape, additional_fields=additional_fields, version=self.account.version + ) + ) + payload.append( + create_element( + "m:IndexedPageItemView", attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint="Beginning") + ) + ) if restriction: payload.append(restriction.to_xml(version=self.account.version)) if order_fields: - payload.append(set_xml_value( - create_element('m:SortOrder'), - order_fields, - version=self.account.version - )) - payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:ParentFolderId')) + payload.append(set_xml_value(create_element("m:SortOrder"), order_fields, version=self.account.version)) + payload.append(folder_ids_element(folders=folders, version=self.account.version, tag="m:ParentFolderId")) if query_string: payload.append(query_string.to_xml(version=self.account.version)) return payload @@ -91,10 +94,14 @@ def get_payload(self, folders, additional_fields, restriction, order_fields, que @staticmethod def _get_paging_values(elem): """Find paging values. The paging element from FindPeople is different from other paging containers.""" - item_count = int(elem.find(f'{{{MNS}}}TotalNumberOfPeopleInView').text) - first_matching = int(elem.find(f'{{{MNS}}}FirstMatchingRowIndex').text) - first_loaded = int(elem.find(f'{{{MNS}}}FirstLoadedRowIndex').text) - log.debug('Got page with total items %s, first matching %s, first loaded %s ', item_count, first_matching, - first_loaded) + item_count = int(elem.find(f"{{{MNS}}}TotalNumberOfPeopleInView").text) + first_matching = int(elem.find(f"{{{MNS}}}FirstMatchingRowIndex").text) + first_loaded = int(elem.find(f"{{{MNS}}}FirstLoadedRowIndex").text) + log.debug( + "Got page with total items %s, first matching %s, first loaded %s ", + item_count, + first_matching, + first_loaded, + ) next_offset = None # GetPersona does not support fetching more pages return item_count, next_offset diff --git a/exchangelib/services/get_attachment.py b/exchangelib/services/get_attachment.py index 084828c9..e34f3e27 100644 --- a/exchangelib/services/get_attachment.py +++ b/exchangelib/services/get_attachment.py @@ -1,49 +1,64 @@ from itertools import chain -from .common import EWSAccountService, attachment_ids_element from ..attachments import FileAttachment, ItemAttachment from ..errors import InvalidEnumValue -from ..util import create_element, add_xml_child, set_xml_value, DummyResponse, StreamingBase64Parser,\ - StreamingContentHandler, ElementNotFound, MNS +from ..util import ( + MNS, + DummyResponse, + ElementNotFound, + StreamingBase64Parser, + StreamingContentHandler, + add_xml_child, + create_element, + set_xml_value, +) +from .common import EWSAccountService, attachment_ids_element # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/bodytype -BODY_TYPE_CHOICES = ('Best', 'HTML', 'Text') +BODY_TYPE_CHOICES = ("Best", "HTML", "Text") class GetAttachment(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getattachment-operation""" - SERVICE_NAME = 'GetAttachment' - element_container_name = f'{{{MNS}}}Attachments' + SERVICE_NAME = "GetAttachment" + element_container_name = f"{{{MNS}}}Attachments" cls_map = {cls.response_tag(): cls for cls in (FileAttachment, ItemAttachment)} def call(self, items, include_mime_content, body_type, filter_html_content, additional_fields): if body_type and body_type not in BODY_TYPE_CHOICES: - raise InvalidEnumValue('body_type', body_type, BODY_TYPE_CHOICES) - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, items=items, include_mime_content=include_mime_content, - body_type=body_type, filter_html_content=filter_html_content, additional_fields=additional_fields, - )) + raise InvalidEnumValue("body_type", body_type, BODY_TYPE_CHOICES) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=items, + include_mime_content=include_mime_content, + body_type=body_type, + filter_html_content=filter_html_content, + additional_fields=additional_fields, + ) + ) def _elem_to_obj(self, elem): return self.cls_map[elem.tag].from_xml(elem=elem, account=self.account) def get_payload(self, items, include_mime_content, body_type, filter_html_content, additional_fields): - payload = create_element(f'm:{self.SERVICE_NAME}') - shape_elem = create_element('m:AttachmentShape') + payload = create_element(f"m:{self.SERVICE_NAME}") + shape_elem = create_element("m:AttachmentShape") if include_mime_content: - add_xml_child(shape_elem, 't:IncludeMimeContent', 'true') + add_xml_child(shape_elem, "t:IncludeMimeContent", "true") if body_type: - add_xml_child(shape_elem, 't:BodyType', body_type) + add_xml_child(shape_elem, "t:BodyType", body_type) if filter_html_content is not None: - add_xml_child(shape_elem, 't:FilterHtmlContent', 'true' if filter_html_content else 'false') + add_xml_child(shape_elem, "t:FilterHtmlContent", "true" if filter_html_content else "false") if additional_fields: - additional_properties = create_element('t:AdditionalProperties') + additional_properties = create_element("t:AdditionalProperties") expanded_fields = chain(*(f.expand(version=self.account.version) for f in additional_fields)) - set_xml_value(additional_properties, sorted( - expanded_fields, - key=lambda f: (getattr(f.field, 'field_uri', ''), f.path) - ), version=self.account.version) + set_xml_value( + additional_properties, + sorted(expanded_fields, key=lambda f: (getattr(f.field, "field_uri", ""), f.path)), + version=self.account.version, + ) shape_elem.append(additional_properties) if len(shape_elem): payload.append(shape_elem) @@ -51,26 +66,26 @@ def get_payload(self, items, include_mime_content, body_type, filter_html_conten return payload def _update_api_version(self, api_version, header, **parse_opts): - if not parse_opts.get('stream_file_content', False): + if not parse_opts.get("stream_file_content", False): super()._update_api_version(api_version, header, **parse_opts) # TODO: We're skipping this part in streaming mode because StreamingBase64Parser cannot parse the SOAP header @classmethod def _get_soap_parts(cls, response, **parse_opts): - if not parse_opts.get('stream_file_content', False): + if not parse_opts.get("stream_file_content", False): return super()._get_soap_parts(response, **parse_opts) # Pass the response unaltered. We want to use our custom streaming parser return None, response def _get_soap_messages(self, body, **parse_opts): - if not parse_opts.get('stream_file_content', False): + if not parse_opts.get("stream_file_content", False): return super()._get_soap_messages(body, **parse_opts) # 'body' is actually the raw response passed on by '_get_soap_parts' r = body parser = StreamingBase64Parser() - field = FileAttachment.get_field_by_fieldname('_content') + field = FileAttachment.get_field_by_fieldname("_content") handler = StreamingContentHandler(parser=parser, ns=field.namespace, element_name=field.field_uri) parser.setContentHandler(handler) return parser.parse(r) @@ -78,7 +93,10 @@ def _get_soap_messages(self, body, **parse_opts): def stream_file_content(self, attachment_id): # The streaming XML parser can only stream content of one attachment payload = self.get_payload( - items=[attachment_id], include_mime_content=False, body_type=None, filter_html_content=None, + items=[attachment_id], + include_mime_content=False, + body_type=None, + filter_html_content=None, additional_fields=None, ) self.streaming = True diff --git a/exchangelib/services/get_delegate.py b/exchangelib/services/get_delegate.py index 279c28ee..f0ebcdda 100644 --- a/exchangelib/services/get_delegate.py +++ b/exchangelib/services/get_delegate.py @@ -1,32 +1,34 @@ -from .common import EWSAccountService -from ..properties import DLMailbox, DelegateUser, UserId # The service expects a Mailbox element in the MNS namespace -from ..util import create_element, set_xml_value, MNS +from ..properties import DelegateUser, DLMailbox, UserId # The service expects a Mailbox element in the MNS namespace +from ..util import MNS, create_element, set_xml_value from ..version import EXCHANGE_2007_SP1 +from .common import EWSAccountService class GetDelegate(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getdelegate-operation""" - SERVICE_NAME = 'GetDelegate' + SERVICE_NAME = "GetDelegate" ERRORS_TO_CATCH_IN_RESPONSE = () supported_from = EXCHANGE_2007_SP1 def call(self, user_ids, include_permissions): - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, - items=user_ids or [None], - mailbox=DLMailbox(email_address=self.account.primary_smtp_address), - include_permissions=include_permissions, - )) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=user_ids or [None], + mailbox=DLMailbox(email_address=self.account.primary_smtp_address), + include_permissions=include_permissions, + ) + ) def _elem_to_obj(self, elem): return DelegateUser.from_xml(elem=elem, account=self.account) def get_payload(self, user_ids, mailbox, include_permissions): - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IncludePermissions=include_permissions)) + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(IncludePermissions=include_permissions)) set_xml_value(payload, mailbox, version=self.protocol.version) if user_ids != [None]: - user_ids_elem = create_element('m:UserIds') + user_ids_elem = create_element("m:UserIds") for user_id in user_ids: if isinstance(user_id, str): user_id = UserId(primary_smtp_address=user_id) @@ -40,4 +42,4 @@ def _get_elements_in_container(cls, container): @classmethod def _response_message_tag(cls): - return f'{{{MNS}}}DelegateUserResponseMessageType' + return f"{{{MNS}}}DelegateUserResponseMessageType" diff --git a/exchangelib/services/get_events.py b/exchangelib/services/get_events.py index aa2429b4..28ab86f1 100644 --- a/exchangelib/services/get_events.py +++ b/exchangelib/services/get_events.py @@ -1,8 +1,8 @@ import logging -from .common import EWSAccountService, add_xml_child from ..properties import Notification from ..util import create_element +from .common import EWSAccountService, add_xml_child log = logging.getLogger(__name__) @@ -12,13 +12,18 @@ class GetEvents(EWSAccountService): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getevents-operation """ - SERVICE_NAME = 'GetEvents' + SERVICE_NAME = "GetEvents" prefer_affinity = True def call(self, subscription_id, watermark): - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - subscription_id=subscription_id, watermark=watermark, - ))) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + subscription_id=subscription_id, + watermark=watermark, + ) + ) + ) def _elem_to_obj(self, elem): return Notification.from_xml(elem=elem, account=None) @@ -28,7 +33,7 @@ def _get_elements_in_container(cls, container): return container.findall(Notification.response_tag()) def get_payload(self, subscription_id, watermark): - payload = create_element(f'm:{self.SERVICE_NAME}') - add_xml_child(payload, 'm:SubscriptionId', subscription_id) - add_xml_child(payload, 'm:Watermark', watermark) + payload = create_element(f"m:{self.SERVICE_NAME}") + add_xml_child(payload, "m:SubscriptionId", subscription_id) + add_xml_child(payload, "m:Watermark", watermark) return payload diff --git a/exchangelib/services/get_folder.py b/exchangelib/services/get_folder.py index a151e3e1..856a509f 100644 --- a/exchangelib/services/get_folder.py +++ b/exchangelib/services/get_folder.py @@ -1,15 +1,17 @@ -from .common import EWSAccountService, parse_folder_elem, folder_ids_element, shape_element -from ..errors import ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorInvalidOperation -from ..util import create_element, MNS +from ..errors import ErrorFolderNotFound, ErrorInvalidOperation, ErrorNoPublicFolderReplicaAvailable +from ..util import MNS, create_element +from .common import EWSAccountService, folder_ids_element, parse_folder_elem, shape_element class GetFolder(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getfolder-operation""" - SERVICE_NAME = 'GetFolder' - element_container_name = f'{{{MNS}}}Folders' + SERVICE_NAME = "GetFolder" + element_container_name = f"{{{MNS}}}Folders" ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + ( - ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorInvalidOperation, + ErrorFolderNotFound, + ErrorNoPublicFolderReplicaAvailable, + ErrorInvalidOperation, ) def __init__(self, *args, **kwargs): @@ -28,12 +30,14 @@ def call(self, folders, additional_fields, shape): # We can't easily find the correct folder class from the returned XML. Instead, return objects with the same # class as the folder instance it was requested with. self.folders = list(folders) # Convert to a list, in case 'folders' is a generator. We're iterating twice. - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, - items=self.folders, - additional_fields=additional_fields, - shape=shape, - )) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=self.folders, + additional_fields=additional_fields, + shape=shape, + ) + ) def _elems_to_objs(self, elems): for folder, elem in zip(self.folders, elems): @@ -43,9 +47,11 @@ def _elems_to_objs(self, elems): yield parse_folder_elem(elem=elem, folder=folder, account=self.account) def get_payload(self, folders, additional_fields, shape): - payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(shape_element( - tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version - )) + payload = create_element(f"m:{self.SERVICE_NAME}") + payload.append( + shape_element( + tag="m:FolderShape", shape=shape, additional_fields=additional_fields, version=self.account.version + ) + ) payload.append(folder_ids_element(folders=folders, version=self.account.version)) return payload diff --git a/exchangelib/services/get_item.py b/exchangelib/services/get_item.py index eb7b06b3..198cdb17 100644 --- a/exchangelib/services/get_item.py +++ b/exchangelib/services/get_item.py @@ -1,13 +1,13 @@ -from .common import EWSAccountService, item_ids_element, shape_element from ..folders.base import BaseFolder -from ..util import create_element, MNS +from ..util import MNS, create_element +from .common import EWSAccountService, item_ids_element, shape_element class GetItem(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getitem-operation""" - SERVICE_NAME = 'GetItem' - element_container_name = f'{{{MNS}}}Items' + SERVICE_NAME = "GetItem" + element_container_name = f"{{{MNS}}}Items" def call(self, items, additional_fields, shape): """Return all items in an account that correspond to a list of ID's, in stable order. @@ -18,17 +18,24 @@ def call(self, items, additional_fields, shape): :return: XML elements for the items, in stable order """ - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, items=items, additional_fields=additional_fields, shape=shape, - )) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=items, + additional_fields=additional_fields, + shape=shape, + ) + ) def _elem_to_obj(self, elem): return BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) def get_payload(self, items, additional_fields, shape): - payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(shape_element( - tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version - )) + payload = create_element(f"m:{self.SERVICE_NAME}") + payload.append( + shape_element( + tag="m:ItemShape", shape=shape, additional_fields=additional_fields, version=self.account.version + ) + ) payload.append(item_ids_element(items=items, version=self.account.version)) return payload diff --git a/exchangelib/services/get_mail_tips.py b/exchangelib/services/get_mail_tips.py index 69f4dd5c..a01b6e39 100644 --- a/exchangelib/services/get_mail_tips.py +++ b/exchangelib/services/get_mail_tips.py @@ -1,29 +1,31 @@ -from .common import EWSService from ..properties import MailTips -from ..util import create_element, set_xml_value, MNS +from ..util import MNS, create_element, set_xml_value +from .common import EWSService class GetMailTips(EWSService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getmailtips-operation""" - SERVICE_NAME = 'GetMailTips' + SERVICE_NAME = "GetMailTips" def call(self, sending_as, recipients, mail_tips_requested): - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, - items=recipients, - sending_as=sending_as, - mail_tips_requested=mail_tips_requested, - )) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=recipients, + sending_as=sending_as, + mail_tips_requested=mail_tips_requested, + ) + ) def _elem_to_obj(self, elem): return MailTips.from_xml(elem=elem, account=None) - def get_payload(self, recipients, sending_as, mail_tips_requested): - payload = create_element(f'm:{self.SERVICE_NAME}') + def get_payload(self, recipients, sending_as, mail_tips_requested): + payload = create_element(f"m:{self.SERVICE_NAME}") set_xml_value(payload, sending_as, version=self.protocol.version) - recipients_elem = create_element('m:Recipients') + recipients_elem = create_element("m:Recipients") for recipient in recipients: set_xml_value(recipients_elem, recipient, version=self.protocol.version) payload.append(recipients_elem) @@ -38,4 +40,4 @@ def _get_elements_in_response(self, response): @classmethod def _response_message_tag(cls): - return f'{{{MNS}}}MailTipsResponseMessageType' + return f"{{{MNS}}}MailTipsResponseMessageType" diff --git a/exchangelib/services/get_persona.py b/exchangelib/services/get_persona.py index deaa4419..632a12dc 100644 --- a/exchangelib/services/get_persona.py +++ b/exchangelib/services/get_persona.py @@ -1,13 +1,13 @@ -from .common import EWSAccountService, to_item_id from ..items import Persona from ..properties import PersonaId -from ..util import create_element, set_xml_value, MNS +from ..util import MNS, create_element, set_xml_value +from .common import EWSAccountService, to_item_id class GetPersona(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getpersona-operation""" - SERVICE_NAME = 'GetPersona' + SERVICE_NAME = "GetPersona" def call(self, personas): # GetPersona only accepts one persona ID per request. Crazy. @@ -19,15 +19,13 @@ def _elem_to_obj(self, elem): def get_payload(self, persona): return set_xml_value( - create_element(f'm:{self.SERVICE_NAME}'), - to_item_id(persona, PersonaId), - version=self.protocol.version + create_element(f"m:{self.SERVICE_NAME}"), to_item_id(persona, PersonaId), version=self.protocol.version ) @classmethod def _get_elements_in_container(cls, container): - return container.findall(f'{{{MNS}}}{Persona.ELEMENT_NAME}') + return container.findall(f"{{{MNS}}}{Persona.ELEMENT_NAME}") @classmethod def _response_tag(cls): - return f'{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage' + return f"{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage" diff --git a/exchangelib/services/get_room_lists.py b/exchangelib/services/get_room_lists.py index 01260ab5..8f932084 100644 --- a/exchangelib/services/get_room_lists.py +++ b/exchangelib/services/get_room_lists.py @@ -1,14 +1,14 @@ -from .common import EWSService from ..properties import RoomList -from ..util import create_element, MNS +from ..util import MNS, create_element from ..version import EXCHANGE_2010 +from .common import EWSService class GetRoomLists(EWSService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getroomlists-operation""" - SERVICE_NAME = 'GetRoomLists' - element_container_name = f'{{{MNS}}}RoomLists' + SERVICE_NAME = "GetRoomLists" + element_container_name = f"{{{MNS}}}RoomLists" supported_from = EXCHANGE_2010 def call(self): @@ -18,4 +18,4 @@ def _elem_to_obj(self, elem): return RoomList.from_xml(elem=elem, account=None) def get_payload(self): - return create_element(f'm:{self.SERVICE_NAME}') + return create_element(f"m:{self.SERVICE_NAME}") diff --git a/exchangelib/services/get_rooms.py b/exchangelib/services/get_rooms.py index cdfbbe1e..d3fe54d0 100644 --- a/exchangelib/services/get_rooms.py +++ b/exchangelib/services/get_rooms.py @@ -1,14 +1,14 @@ -from .common import EWSService from ..properties import Room -from ..util import create_element, set_xml_value, MNS +from ..util import MNS, create_element, set_xml_value from ..version import EXCHANGE_2010 +from .common import EWSService class GetRooms(EWSService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getrooms-operation""" - SERVICE_NAME = 'GetRooms' - element_container_name = f'{{{MNS}}}Rooms' + SERVICE_NAME = "GetRooms" + element_container_name = f"{{{MNS}}}Rooms" supported_from = EXCHANGE_2010 def call(self, room_list): @@ -18,4 +18,4 @@ def _elem_to_obj(self, elem): return Room.from_xml(elem=elem, account=None) def get_payload(self, room_list): - return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), room_list, version=self.protocol.version) + return set_xml_value(create_element(f"m:{self.SERVICE_NAME}"), room_list, version=self.protocol.version) diff --git a/exchangelib/services/get_searchable_mailboxes.py b/exchangelib/services/get_searchable_mailboxes.py index 5b24a511..7915d276 100644 --- a/exchangelib/services/get_searchable_mailboxes.py +++ b/exchangelib/services/get_searchable_mailboxes.py @@ -1,8 +1,8 @@ -from .common import EWSService from ..errors import MalformedResponseError -from ..properties import SearchableMailbox, FailedMailbox -from ..util import create_element, add_xml_child, MNS +from ..properties import FailedMailbox, SearchableMailbox +from ..util import MNS, add_xml_child, create_element from ..version import EXCHANGE_2013 +from .common import EWSService class GetSearchableMailboxes(EWSService): @@ -10,27 +10,31 @@ class GetSearchableMailboxes(EWSService): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getsearchablemailboxes-operation """ - SERVICE_NAME = 'GetSearchableMailboxes' - element_container_name = f'{{{MNS}}}SearchableMailboxes' - failed_mailboxes_container_name = f'{{{MNS}}}FailedMailboxes' + SERVICE_NAME = "GetSearchableMailboxes" + element_container_name = f"{{{MNS}}}SearchableMailboxes" + failed_mailboxes_container_name = f"{{{MNS}}}FailedMailboxes" supported_from = EXCHANGE_2013 cls_map = {cls.response_tag(): cls for cls in (SearchableMailbox, FailedMailbox)} def call(self, search_filter, expand_group_membership): - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - search_filter=search_filter, - expand_group_membership=expand_group_membership, - ))) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + search_filter=search_filter, + expand_group_membership=expand_group_membership, + ) + ) + ) def _elem_to_obj(self, elem): return self.cls_map[elem.tag].from_xml(elem=elem, account=None) def get_payload(self, search_filter, expand_group_membership): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") if search_filter: - add_xml_child(payload, 'm:SearchFilter', search_filter) + add_xml_child(payload, "m:SearchFilter", search_filter) if expand_group_membership is not None: - add_xml_child(payload, 'm:ExpandGroupMembership', 'true' if expand_group_membership else 'false') + add_xml_child(payload, "m:ExpandGroupMembership", "true" if expand_group_membership else "false") return payload def _get_elements_in_response(self, response): diff --git a/exchangelib/services/get_server_time_zones.py b/exchangelib/services/get_server_time_zones.py index fadda221..e5b960b4 100644 --- a/exchangelib/services/get_server_time_zones.py +++ b/exchangelib/services/get_server_time_zones.py @@ -1,7 +1,7 @@ -from .common import EWSService from ..properties import TimeZoneDefinition -from ..util import create_element, set_xml_value, peek, MNS +from ..util import MNS, create_element, peek, set_xml_value from ..version import EXCHANGE_2010 +from .common import EWSService class GetServerTimeZones(EWSService): @@ -9,27 +9,28 @@ class GetServerTimeZones(EWSService): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getservertimezones-operation """ - SERVICE_NAME = 'GetServerTimeZones' - element_container_name = f'{{{MNS}}}TimeZoneDefinitions' + SERVICE_NAME = "GetServerTimeZones" + element_container_name = f"{{{MNS}}}TimeZoneDefinitions" supported_from = EXCHANGE_2010 def call(self, timezones=None, return_full_timezone_data=False): - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - timezones=timezones, - return_full_timezone_data=return_full_timezone_data - ))) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload(timezones=timezones, return_full_timezone_data=return_full_timezone_data) + ) + ) def get_payload(self, timezones, return_full_timezone_data): payload = create_element( - f'm:{self.SERVICE_NAME}', + f"m:{self.SERVICE_NAME}", attrs=dict(ReturnFullTimeZoneData=return_full_timezone_data), ) if timezones is not None: is_empty, timezones = peek(timezones) if not is_empty: - tz_ids = create_element('m:Ids') + tz_ids = create_element("m:Ids") for timezone in timezones: - tz_id = set_xml_value(create_element('t:Id'), timezone.ms_id) + tz_id = set_xml_value(create_element("t:Id"), timezone.ms_id) tz_ids.append(tz_id) payload.append(tz_ids) return payload diff --git a/exchangelib/services/get_streaming_events.py b/exchangelib/services/get_streaming_events.py index ed492a0a..fd5e53ff 100644 --- a/exchangelib/services/get_streaming_events.py +++ b/exchangelib/services/get_streaming_events.py @@ -1,12 +1,12 @@ import logging -from .common import EWSAccountService, add_xml_child from ..errors import EWSError, InvalidTypeError from ..properties import Notification -from ..util import create_element, get_xml_attr, get_xml_attrs, MNS, DocumentYielder, DummyResponse +from ..util import MNS, DocumentYielder, DummyResponse, create_element, get_xml_attr, get_xml_attrs +from .common import EWSAccountService, add_xml_child log = logging.getLogger(__name__) -xml_log = logging.getLogger(f'{__name__}.xml') +xml_log = logging.getLogger(f"{__name__}.xml") class GetStreamingEvents(EWSAccountService): @@ -14,13 +14,13 @@ class GetStreamingEvents(EWSAccountService): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getstreamingevents-operation """ - SERVICE_NAME = 'GetStreamingEvents' - element_container_name = f'{{{MNS}}}Notifications' + SERVICE_NAME = "GetStreamingEvents" + element_container_name = f"{{{MNS}}}Notifications" prefer_affinity = True # Connection status values - OK = 'OK' - CLOSED = 'Closed' + OK = "OK" + CLOSED = "Closed" def __init__(self, *args, **kwargs): # These values are set each time call() is consumed @@ -30,14 +30,19 @@ def __init__(self, *args, **kwargs): def call(self, subscription_ids, connection_timeout): if not isinstance(connection_timeout, int): - raise InvalidTypeError('connection_timeout', connection_timeout, int) + raise InvalidTypeError("connection_timeout", connection_timeout, int) if connection_timeout < 1: raise ValueError(f"'connection_timeout' {connection_timeout} must be a positive integer") # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed self.timeout = connection_timeout * 60 + 60 - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - subscription_ids=subscription_ids, connection_timeout=connection_timeout, - ))) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + subscription_ids=subscription_ids, + connection_timeout=connection_timeout, + ) + ) + ) def _elem_to_obj(self, elem): return Notification.from_xml(elem=elem, account=None) @@ -53,7 +58,7 @@ def _get_soap_messages(self, body, **parse_opts): # XML response. r = body for i, doc in enumerate(DocumentYielder(r.iter_content()), start=1): - xml_log.debug('Response XML (docs counter: %(i)s): %(xml_response)s', dict(i=i, xml_response=doc)) + xml_log.debug("Response XML (docs counter: %(i)s): %(xml_response)s", dict(i=i, xml_response=doc)) response = DummyResponse(content=doc) try: _, body = super()._get_soap_parts(response=response, **parse_opts) @@ -68,10 +73,10 @@ def _get_soap_messages(self, body, **parse_opts): break def _get_element_container(self, message, name=None): - error_ids_elem = message.find(f'{{{MNS}}}ErrorSubscriptionIds') - error_ids = [] if error_ids_elem is None else get_xml_attrs(error_ids_elem, f'{{{MNS}}}SubscriptionId') - self.connection_status = get_xml_attr(message, f'{{{MNS}}}ConnectionStatus') # Either 'OK' or 'Closed' - log.debug('Connection status is: %s', self.connection_status) + error_ids_elem = message.find(f"{{{MNS}}}ErrorSubscriptionIds") + error_ids = [] if error_ids_elem is None else get_xml_attrs(error_ids_elem, f"{{{MNS}}}SubscriptionId") + self.connection_status = get_xml_attr(message, f"{{{MNS}}}ConnectionStatus") # Either 'OK' or 'Closed' + log.debug("Connection status is: %s", self.connection_status) # Upstream normally expects to find a 'name' tag but our response does not always have it. We still want to # call upstream, to have exceptions raised. Return an empty list if there is no 'name' tag and no errors. if message.find(name) is None: @@ -83,18 +88,18 @@ def _get_element_container(self, message, name=None): # subscriptions seem to never be returned even though the XML spec allows it. This means there's no point in # trying to collect any notifications here and delivering a combination of errors and return values. if error_ids: - e.value += f' (subscription IDs: {error_ids})' + e.value += f" (subscription IDs: {error_ids})" raise e return [] if name is None else res def get_payload(self, subscription_ids, connection_timeout): - payload = create_element(f'm:{self.SERVICE_NAME}') - subscriptions_elem = create_element('m:SubscriptionIds') + payload = create_element(f"m:{self.SERVICE_NAME}") + subscriptions_elem = create_element("m:SubscriptionIds") for subscription_id in subscription_ids: - add_xml_child(subscriptions_elem, 't:SubscriptionId', subscription_id) + add_xml_child(subscriptions_elem, "t:SubscriptionId", subscription_id) if not len(subscriptions_elem): raise ValueError("'subscription_ids' must not be empty") payload.append(subscriptions_elem) - add_xml_child(payload, 'm:ConnectionTimeout', connection_timeout) + add_xml_child(payload, "m:ConnectionTimeout", connection_timeout) return payload diff --git a/exchangelib/services/get_user_availability.py b/exchangelib/services/get_user_availability.py index 95ed32e7..e6f20ad1 100644 --- a/exchangelib/services/get_user_availability.py +++ b/exchangelib/services/get_user_availability.py @@ -1,6 +1,6 @@ -from .common import EWSService from ..properties import FreeBusyView -from ..util import create_element, set_xml_value, MNS +from ..util import MNS, create_element, set_xml_value +from .common import EWSService class GetUserAvailability(EWSService): @@ -9,42 +9,44 @@ class GetUserAvailability(EWSService): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getuseravailability-operation """ - SERVICE_NAME = 'GetUserAvailability' + SERVICE_NAME = "GetUserAvailability" def call(self, timezone, mailbox_data, free_busy_view_options): # TODO: Also supports SuggestionsViewOptions, see # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/suggestionsviewoptions - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - timezone=timezone, - mailbox_data=mailbox_data, - free_busy_view_options=free_busy_view_options - ))) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + timezone=timezone, mailbox_data=mailbox_data, free_busy_view_options=free_busy_view_options + ) + ) + ) def _elem_to_obj(self, elem): return FreeBusyView.from_xml(elem=elem, account=None) def get_payload(self, timezone, mailbox_data, free_busy_view_options): - payload = create_element(f'm:{self.SERVICE_NAME}Request') + payload = create_element(f"m:{self.SERVICE_NAME}Request") set_xml_value(payload, timezone, version=self.protocol.version) - mailbox_data_array = create_element('m:MailboxDataArray') + mailbox_data_array = create_element("m:MailboxDataArray") set_xml_value(mailbox_data_array, mailbox_data, version=self.protocol.version) payload.append(mailbox_data_array) return set_xml_value(payload, free_busy_view_options, version=self.protocol.version) @staticmethod def _response_messages_tag(): - return f'{{{MNS}}}FreeBusyResponseArray' + return f"{{{MNS}}}FreeBusyResponseArray" @classmethod def _response_message_tag(cls): - return f'{{{MNS}}}FreeBusyResponse' + return f"{{{MNS}}}FreeBusyResponse" def _get_elements_in_response(self, response): for msg in response: # Just check the response code and raise errors - self._get_element_container(message=msg.find(f'{{{MNS}}}ResponseMessage')) + self._get_element_container(message=msg.find(f"{{{MNS}}}ResponseMessage")) yield from self._get_elements_in_container(container=msg) @classmethod def _get_elements_in_container(cls, container): - return [container.find(f'{{{MNS}}}FreeBusyView')] + return [container.find(f"{{{MNS}}}FreeBusyView")] diff --git a/exchangelib/services/get_user_configuration.py b/exchangelib/services/get_user_configuration.py index 7ddba345..984dcc4b 100644 --- a/exchangelib/services/get_user_configuration.py +++ b/exchangelib/services/get_user_configuration.py @@ -1,13 +1,13 @@ -from .common import EWSAccountService from ..errors import InvalidEnumValue from ..properties import UserConfiguration from ..util import create_element, set_xml_value +from .common import EWSAccountService -ID = 'Id' -DICTIONARY = 'Dictionary' -XML_DATA = 'XmlData' -BINARY_DATA = 'BinaryData' -ALL = 'All' +ID = "Id" +DICTIONARY = "Dictionary" +XML_DATA = "XmlData" +BINARY_DATA = "BinaryData" +ALL = "All" PROPERTIES_CHOICES = (ID, DICTIONARY, XML_DATA, BINARY_DATA, ALL) @@ -16,14 +16,16 @@ class GetUserConfiguration(EWSAccountService): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getuserconfiguration-operation """ - SERVICE_NAME = 'GetUserConfiguration' + SERVICE_NAME = "GetUserConfiguration" def call(self, user_configuration_name, properties): if properties not in PROPERTIES_CHOICES: - raise InvalidEnumValue('properties', properties, PROPERTIES_CHOICES) - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - user_configuration_name=user_configuration_name, properties=properties - ))) + raise InvalidEnumValue("properties", properties, PROPERTIES_CHOICES) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload(user_configuration_name=user_configuration_name, properties=properties) + ) + ) def _elem_to_obj(self, elem): return UserConfiguration.from_xml(elem=elem, account=self.account) @@ -33,9 +35,9 @@ def _get_elements_in_container(cls, container): return container.findall(UserConfiguration.response_tag()) def get_payload(self, user_configuration_name, properties): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") set_xml_value(payload, user_configuration_name, version=self.account.version) payload.append( - set_xml_value(create_element('m:UserConfigurationProperties'), properties, version=self.account.version) + set_xml_value(create_element("m:UserConfigurationProperties"), properties, version=self.account.version) ) return payload diff --git a/exchangelib/services/get_user_oof_settings.py b/exchangelib/services/get_user_oof_settings.py index 08571dcf..f511a952 100644 --- a/exchangelib/services/get_user_oof_settings.py +++ b/exchangelib/services/get_user_oof_settings.py @@ -1,7 +1,7 @@ -from .common import EWSAccountService from ..properties import AvailabilityMailbox from ..settings import OofSettings -from ..util import create_element, set_xml_value, MNS, TNS +from ..util import MNS, TNS, create_element, set_xml_value +from .common import EWSAccountService class GetUserOofSettings(EWSAccountService): @@ -9,8 +9,8 @@ class GetUserOofSettings(EWSAccountService): MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getuseroofsettings-operation """ - SERVICE_NAME = 'GetUserOofSettings' - element_container_name = f'{{{TNS}}}OofSettings' + SERVICE_NAME = "GetUserOofSettings" + element_container_name = f"{{{TNS}}}OofSettings" def call(self, mailbox): return self._elems_to_objs(self._get_elements(payload=self.get_payload(mailbox=mailbox))) @@ -20,9 +20,9 @@ def _elem_to_obj(self, elem): def get_payload(self, mailbox): return set_xml_value( - create_element(f'm:{self.SERVICE_NAME}Request'), + create_element(f"m:{self.SERVICE_NAME}Request"), AvailabilityMailbox.from_mailbox(mailbox), - version=self.account.version + version=self.account.version, ) @classmethod @@ -37,4 +37,4 @@ def _get_element_container(self, message, name=None): @classmethod def _response_message_tag(cls): - return f'{{{MNS}}}ResponseMessage' + return f"{{{MNS}}}ResponseMessage" diff --git a/exchangelib/services/mark_as_junk.py b/exchangelib/services/mark_as_junk.py index a1f0101e..edad8721 100644 --- a/exchangelib/services/mark_as_junk.py +++ b/exchangelib/services/mark_as_junk.py @@ -1,12 +1,12 @@ -from .common import EWSAccountService, item_ids_element from ..properties import MovedItemId from ..util import create_element +from .common import EWSAccountService, item_ids_element class MarkAsJunk(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/markasjunk-operation""" - SERVICE_NAME = 'MarkAsJunk' + SERVICE_NAME = "MarkAsJunk" def call(self, items, is_junk, move_item): return self._elems_to_objs( @@ -22,6 +22,6 @@ def _get_elements_in_container(cls, container): def get_payload(self, items, is_junk, move_item): # Takes a list of items and returns either success or raises an error message - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IsJunk=is_junk, MoveItem=move_item)) + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(IsJunk=is_junk, MoveItem=move_item)) payload.append(item_ids_element(items=items, version=self.account.version)) return payload diff --git a/exchangelib/services/move_folder.py b/exchangelib/services/move_folder.py index ca0de96c..fe271b55 100644 --- a/exchangelib/services/move_folder.py +++ b/exchangelib/services/move_folder.py @@ -1,19 +1,19 @@ -from .common import EWSAccountService, folder_ids_element from ..errors import InvalidTypeError from ..folders import BaseFolder from ..properties import FolderId -from ..util import create_element, MNS +from ..util import MNS, create_element +from .common import EWSAccountService, folder_ids_element class MoveFolder(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/movefolder-operation""" SERVICE_NAME = "MoveFolder" - element_container_name = f'{{{MNS}}}Folders' + element_container_name = f"{{{MNS}}}Folders" def call(self, folders, to_folder): if not isinstance(to_folder, (BaseFolder, FolderId)): - raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId)) + raise InvalidTypeError("to_folder", to_folder, (BaseFolder, FolderId)) return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=folders, to_folder=to_folder)) def _elem_to_obj(self, elem): @@ -21,7 +21,7 @@ def _elem_to_obj(self, elem): def get_payload(self, folders, to_folder): # Takes a list of folders and returns their new folder IDs - payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) + payload = create_element(f"m:{self.SERVICE_NAME}") + payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag="m:ToFolderId")) payload.append(folder_ids_element(folders=folders, version=self.account.version)) return payload diff --git a/exchangelib/services/move_item.py b/exchangelib/services/move_item.py index 57054869..a77757c5 100644 --- a/exchangelib/services/move_item.py +++ b/exchangelib/services/move_item.py @@ -1,20 +1,20 @@ -from .common import EWSAccountService, item_ids_element, folder_ids_element from ..errors import InvalidTypeError from ..folders import BaseFolder from ..items import Item from ..properties import FolderId -from ..util import create_element, MNS +from ..util import MNS, create_element +from .common import EWSAccountService, folder_ids_element, item_ids_element class MoveItem(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/moveitem-operation""" - SERVICE_NAME = 'MoveItem' - element_container_name = f'{{{MNS}}}Items' + SERVICE_NAME = "MoveItem" + element_container_name = f"{{{MNS}}}Items" def call(self, items, to_folder): if not isinstance(to_folder, (BaseFolder, FolderId)): - raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId)) + raise InvalidTypeError("to_folder", to_folder, (BaseFolder, FolderId)) return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, to_folder=to_folder)) def _elem_to_obj(self, elem): @@ -22,7 +22,7 @@ def _elem_to_obj(self, elem): def get_payload(self, items, to_folder): # Takes a list of items and returns their new item IDs - payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) + payload = create_element(f"m:{self.SERVICE_NAME}") + payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag="m:ToFolderId")) payload.append(item_ids_element(items=items, version=self.account.version)) return payload diff --git a/exchangelib/services/resolve_names.py b/exchangelib/services/resolve_names.py index 92453597..ae9fb596 100644 --- a/exchangelib/services/resolve_names.py +++ b/exchangelib/services/resolve_names.py @@ -1,11 +1,11 @@ import logging -from .common import EWSService, folder_ids_element -from ..errors import ErrorNameResolutionNoResults, ErrorNameResolutionMultipleResults, InvalidEnumValue -from ..items import SHAPE_CHOICES, SEARCH_SCOPE_CHOICES, Contact +from ..errors import ErrorNameResolutionMultipleResults, ErrorNameResolutionNoResults, InvalidEnumValue +from ..items import SEARCH_SCOPE_CHOICES, SHAPE_CHOICES, Contact from ..properties import Mailbox -from ..util import create_element, add_xml_child, MNS +from ..util import MNS, add_xml_child, create_element from ..version import EXCHANGE_2010_SP2 +from .common import EWSService, folder_ids_element log = logging.getLogger(__name__) @@ -13,8 +13,8 @@ class ResolveNames(EWSService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/resolvenames-operation""" - SERVICE_NAME = 'ResolveNames' - element_container_name = f'{{{MNS}}}ResolutionSet' + SERVICE_NAME = "ResolveNames" + element_container_name = f"{{{MNS}}}ResolutionSet" ERRORS_TO_CATCH_IN_RESPONSE = ErrorNameResolutionNoResults WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults # Note: paging information is returned as attrs on the 'ResolutionSet' element, but this service does not @@ -26,26 +26,34 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.return_full_contact_data = False # A hack to communicate parsing args to _elems_to_objs() - def call(self, unresolved_entries, parent_folders=None, return_full_contact_data=False, search_scope=None, - contact_data_shape=None): + def call( + self, + unresolved_entries, + parent_folders=None, + return_full_contact_data=False, + search_scope=None, + contact_data_shape=None, + ): if self.chunk_size > 100: raise ValueError( - f'Chunk size {self.chunk_size} is too high. {self.SERVICE_NAME} supports returning at most 100 ' - f'candidates for a lookup', + f"Chunk size {self.chunk_size} is too high. {self.SERVICE_NAME} supports returning at most 100 " + f"candidates for a lookup", ) if search_scope and search_scope not in SEARCH_SCOPE_CHOICES: - raise InvalidEnumValue('search_scope', search_scope, SEARCH_SCOPE_CHOICES) + raise InvalidEnumValue("search_scope", search_scope, SEARCH_SCOPE_CHOICES) if contact_data_shape and contact_data_shape not in SHAPE_CHOICES: - raise InvalidEnumValue('contact_data_shape', contact_data_shape, SHAPE_CHOICES) + raise InvalidEnumValue("contact_data_shape", contact_data_shape, SHAPE_CHOICES) self.return_full_contact_data = return_full_contact_data - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, - items=unresolved_entries, - parent_folders=parent_folders, - return_full_contact_data=return_full_contact_data, - search_scope=search_scope, - contact_data_shape=contact_data_shape, - )) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=unresolved_entries, + parent_folders=parent_folders, + return_full_contact_data=return_full_contact_data, + search_scope=search_scope, + contact_data_shape=contact_data_shape, + ) + ) def _elem_to_obj(self, elem): if self.return_full_contact_data: @@ -57,21 +65,23 @@ def _elem_to_obj(self, elem): ) return Mailbox.from_xml(elem=elem.find(Mailbox.response_tag()), account=None) - def get_payload(self, unresolved_entries, parent_folders, return_full_contact_data, search_scope, - contact_data_shape): + def get_payload( + self, unresolved_entries, parent_folders, return_full_contact_data, search_scope, contact_data_shape + ): attrs = dict(ReturnFullContactData=return_full_contact_data) if search_scope: - attrs['SearchScope'] = search_scope + attrs["SearchScope"] = search_scope if contact_data_shape: if self.protocol.version.build < EXCHANGE_2010_SP2: raise NotImplementedError( - "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later") - attrs['ContactDataShape'] = contact_data_shape - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later" + ) + attrs["ContactDataShape"] = contact_data_shape + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=attrs) if parent_folders: - payload.append(folder_ids_element( - folders=parent_folders, version=self.protocol.version, tag='m:ParentFolderIds' - )) + payload.append( + folder_ids_element(folders=parent_folders, version=self.protocol.version, tag="m:ParentFolderIds") + ) for entry in unresolved_entries: - add_xml_child(payload, 'm:UnresolvedEntry', entry) + add_xml_child(payload, "m:UnresolvedEntry", entry) return payload diff --git a/exchangelib/services/send_item.py b/exchangelib/services/send_item.py index e85af7d3..8a826d9f 100644 --- a/exchangelib/services/send_item.py +++ b/exchangelib/services/send_item.py @@ -1,26 +1,26 @@ -from .common import EWSAccountService, item_ids_element, folder_ids_element from ..errors import InvalidTypeError from ..folders import BaseFolder from ..properties import FolderId from ..util import create_element +from .common import EWSAccountService, folder_ids_element, item_ids_element class SendItem(EWSAccountService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/senditem-operation""" - SERVICE_NAME = 'SendItem' + SERVICE_NAME = "SendItem" returns_elements = False def call(self, items, saved_item_folder): if saved_item_folder and not isinstance(saved_item_folder, (BaseFolder, FolderId)): - raise InvalidTypeError('saved_item_folder', saved_item_folder, (BaseFolder, FolderId)) + raise InvalidTypeError("saved_item_folder", saved_item_folder, (BaseFolder, FolderId)) return self._chunked_get_elements(self.get_payload, items=items, saved_item_folder=saved_item_folder) def get_payload(self, items, saved_item_folder): - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(SaveItemToFolder=bool(saved_item_folder))) + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(SaveItemToFolder=bool(saved_item_folder))) payload.append(item_ids_element(items=items, version=self.account.version)) if saved_item_folder: - payload.append(folder_ids_element( - folders=[saved_item_folder], version=self.account.version, tag='m:SavedItemFolderId' - )) + payload.append( + folder_ids_element(folders=[saved_item_folder], version=self.account.version, tag="m:SavedItemFolderId") + ) return payload diff --git a/exchangelib/services/send_notification.py b/exchangelib/services/send_notification.py index 537194b4..387a02d9 100644 --- a/exchangelib/services/send_notification.py +++ b/exchangelib/services/send_notification.py @@ -1,8 +1,8 @@ -from .common import EWSService, add_xml_child from ..errors import InvalidEnumValue from ..properties import Notification from ..transport import wrap -from ..util import create_element, MNS +from ..util import MNS, create_element +from .common import EWSService, add_xml_child class SendNotification(EWSService): @@ -13,9 +13,9 @@ class SendNotification(EWSService): push notifications. """ - SERVICE_NAME = 'SendNotification' - OK = 'OK' - UNSUBSCRIBE = 'Unsubscribe' + SERVICE_NAME = "SendNotification" + OK = "OK" + UNSUBSCRIBE = "Unsubscribe" STATUS_CHOICES = (OK, UNSUBSCRIBE) def ok_payload(self): @@ -30,7 +30,7 @@ def _elem_to_obj(self, elem): @classmethod def _response_tag(cls): """Return the name of the element containing the service response.""" - return f'{{{MNS}}}{cls.SERVICE_NAME}' + return f"{{{MNS}}}{cls.SERVICE_NAME}" @classmethod def _get_elements_in_container(cls, container): @@ -38,7 +38,7 @@ def _get_elements_in_container(cls, container): def get_payload(self, status): if status not in self.STATUS_CHOICES: - raise InvalidEnumValue('status', status, self.STATUS_CHOICES) - payload = create_element(f'm:{self.SERVICE_NAME}Result') - add_xml_child(payload, 'm:SubscriptionStatus', status) + raise InvalidEnumValue("status", status, self.STATUS_CHOICES) + payload = create_element(f"m:{self.SERVICE_NAME}Result") + add_xml_child(payload, "m:SubscriptionStatus", status) return payload diff --git a/exchangelib/services/set_user_oof_settings.py b/exchangelib/services/set_user_oof_settings.py index 79bfcfee..d74e1bc4 100644 --- a/exchangelib/services/set_user_oof_settings.py +++ b/exchangelib/services/set_user_oof_settings.py @@ -1,8 +1,8 @@ -from .common import EWSAccountService from ..errors import InvalidTypeError from ..properties import AvailabilityMailbox, Mailbox from ..settings import OofSettings -from ..util import create_element, set_xml_value, MNS +from ..util import MNS, create_element, set_xml_value +from .common import EWSAccountService class SetUserOofSettings(EWSAccountService): @@ -10,18 +10,18 @@ class SetUserOofSettings(EWSAccountService): MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/setuseroofsettings-operation """ - SERVICE_NAME = 'SetUserOofSettings' + SERVICE_NAME = "SetUserOofSettings" returns_elements = False def call(self, oof_settings, mailbox): if not isinstance(oof_settings, OofSettings): - raise InvalidTypeError('oof_settings', oof_settings, OofSettings) + raise InvalidTypeError("oof_settings", oof_settings, OofSettings) if not isinstance(mailbox, Mailbox): - raise InvalidTypeError('mailbox', mailbox, Mailbox) + raise InvalidTypeError("mailbox", mailbox, Mailbox) return self._get_elements(payload=self.get_payload(oof_settings=oof_settings, mailbox=mailbox)) def get_payload(self, oof_settings, mailbox): - payload = create_element(f'm:{self.SERVICE_NAME}Request') + payload = create_element(f"m:{self.SERVICE_NAME}Request") set_xml_value(payload, AvailabilityMailbox.from_mailbox(mailbox), version=self.account.version) return set_xml_value(payload, oof_settings, version=self.account.version) @@ -31,4 +31,4 @@ def _get_element_container(self, message, name=None): @classmethod def _response_message_tag(cls): - return f'{{{MNS}}}ResponseMessage' + return f"{{{MNS}}}ResponseMessage" diff --git a/exchangelib/services/subscribe.py b/exchangelib/services/subscribe.py index ad43fd20..47e5d659 100644 --- a/exchangelib/services/subscribe.py +++ b/exchangelib/services/subscribe.py @@ -3,31 +3,31 @@ """ import abc -from .common import EWSAccountService, folder_ids_element, add_xml_child -from ..util import create_element, MNS +from ..util import MNS, create_element +from .common import EWSAccountService, add_xml_child, folder_ids_element class Subscribe(EWSAccountService, metaclass=abc.ABCMeta): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/subscribe-operation""" - SERVICE_NAME = 'Subscribe' + SERVICE_NAME = "Subscribe" EVENT_TYPES = ( - 'CopiedEvent', - 'CreatedEvent', - 'DeletedEvent', - 'ModifiedEvent', - 'MovedEvent', - 'NewMailEvent', - 'FreeBusyChangedEvent', + "CopiedEvent", + "CreatedEvent", + "DeletedEvent", + "ModifiedEvent", + "MovedEvent", + "NewMailEvent", + "FreeBusyChangedEvent", ) subscription_request_elem_tag = None def _partial_call(self, payload_func, folders, event_types, **kwargs): if set(event_types) - set(self.EVENT_TYPES): raise ValueError(f"'event_types' values must consist of values in {self.EVENT_TYPES}") - return self._elems_to_objs(self._get_elements( - payload=payload_func(folders=folders, event_types=event_types, **kwargs) - )) + return self._elems_to_objs( + self._get_elements(payload=payload_func(folders=folders, event_types=event_types, **kwargs)) + ) def _elem_to_obj(self, elem): subscription_elem, watermark_elem = elem @@ -35,15 +35,15 @@ def _elem_to_obj(self, elem): @classmethod def _get_elements_in_container(cls, container): - return [(container.find(f'{{{MNS}}}SubscriptionId'), container.find(f'{{{MNS}}}Watermark'))] + return [(container.find(f"{{{MNS}}}SubscriptionId"), container.find(f"{{{MNS}}}Watermark"))] def _partial_payload(self, folders, event_types): request_elem = create_element(self.subscription_request_elem_tag) - folder_ids = folder_ids_element(folders=folders, version=self.account.version, tag='t:FolderIds') + folder_ids = folder_ids_element(folders=folders, version=self.account.version, tag="t:FolderIds") request_elem.append(folder_ids) - event_types_elem = create_element('t:EventTypes') + event_types_elem = create_element("t:EventTypes") for event_type in event_types: - add_xml_child(event_types_elem, 't:EventType', event_type) + add_xml_child(event_types_elem, "t:EventType", event_type) if not len(event_types_elem): raise ValueError("'event_types' must not be empty") request_elem.append(event_types_elem) @@ -51,47 +51,54 @@ def _partial_payload(self, folders, event_types): class SubscribeToPull(Subscribe): - subscription_request_elem_tag = 'm:PullSubscriptionRequest' + subscription_request_elem_tag = "m:PullSubscriptionRequest" prefer_affinity = True def call(self, folders, event_types, watermark, timeout): yield from self._partial_call( - payload_func=self.get_payload, folders=folders, event_types=event_types, timeout=timeout, + payload_func=self.get_payload, + folders=folders, + event_types=event_types, + timeout=timeout, watermark=watermark, ) def get_payload(self, folders, event_types, watermark, timeout): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") request_elem = self._partial_payload(folders=folders, event_types=event_types) if watermark: - add_xml_child(request_elem, 'm:Watermark', watermark) - add_xml_child(request_elem, 't:Timeout', timeout) # In minutes + add_xml_child(request_elem, "m:Watermark", watermark) + add_xml_child(request_elem, "t:Timeout", timeout) # In minutes payload.append(request_elem) return payload class SubscribeToPush(Subscribe): - subscription_request_elem_tag = 'm:PushSubscriptionRequest' + subscription_request_elem_tag = "m:PushSubscriptionRequest" def call(self, folders, event_types, watermark, status_frequency, url): yield from self._partial_call( - payload_func=self.get_payload, folders=folders, event_types=event_types, - status_frequency=status_frequency, url=url, watermark=watermark, + payload_func=self.get_payload, + folders=folders, + event_types=event_types, + status_frequency=status_frequency, + url=url, + watermark=watermark, ) def get_payload(self, folders, event_types, watermark, status_frequency, url): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") request_elem = self._partial_payload(folders=folders, event_types=event_types) if watermark: - add_xml_child(request_elem, 'm:Watermark', watermark) - add_xml_child(request_elem, 't:StatusFrequency', status_frequency) # In minutes - add_xml_child(request_elem, 't:URL', url) + add_xml_child(request_elem, "m:Watermark", watermark) + add_xml_child(request_elem, "t:StatusFrequency", status_frequency) # In minutes + add_xml_child(request_elem, "t:URL", url) payload.append(request_elem) return payload class SubscribeToStreaming(Subscribe): - subscription_request_elem_tag = 'm:StreamingSubscriptionRequest' + subscription_request_elem_tag = "m:StreamingSubscriptionRequest" prefer_affinity = True def call(self, folders, event_types): @@ -102,9 +109,9 @@ def _elem_to_obj(self, elem): @classmethod def _get_elements_in_container(cls, container): - return [container.find(f'{{{MNS}}}SubscriptionId')] + return [container.find(f"{{{MNS}}}SubscriptionId")] def get_payload(self, folders, event_types): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") payload.append(self._partial_payload(folders=folders, event_types=event_types)) return payload diff --git a/exchangelib/services/sync_folder_hierarchy.py b/exchangelib/services/sync_folder_hierarchy.py index f01ee421..4547177b 100644 --- a/exchangelib/services/sync_folder_hierarchy.py +++ b/exchangelib/services/sync_folder_hierarchy.py @@ -1,9 +1,9 @@ import abc import logging -from .common import EWSPagingService, add_xml_child, folder_ids_element, shape_element, parse_folder_elem from ..properties import FolderId -from ..util import create_element, xml_text_to_value, MNS, TNS +from ..util import MNS, TNS, create_element, xml_text_to_value +from .common import EWSPagingService, add_xml_child, folder_ids_element, parse_folder_elem, shape_element log = logging.getLogger(__name__) @@ -11,18 +11,18 @@ class SyncFolder(EWSPagingService, metaclass=abc.ABCMeta): """Base class for SyncFolderHierarchy and SyncFolderItems.""" - element_container_name = f'{{{MNS}}}Changes' + element_container_name = f"{{{MNS}}}Changes" # Change types - CREATE = 'create' - UPDATE = 'update' - DELETE = 'delete' + CREATE = "create" + UPDATE = "update" + DELETE = "delete" CHANGE_TYPES = (CREATE, UPDATE, DELETE) shape_tag = None last_in_range_name = None change_types_map = { - f'{{{TNS}}}Create': CREATE, - f'{{{TNS}}}Update': UPDATE, - f'{{{TNS}}}Delete': DELETE, + f"{{{TNS}}}Create": CREATE, + f"{{{TNS}}}Update": UPDATE, + f"{{{TNS}}}Delete": DELETE, } def __init__(self, *args, **kwargs): @@ -32,22 +32,22 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def _get_element_container(self, message, name=None): - self.sync_state = message.find(f'{{{MNS}}}SyncState').text - log.debug('Sync state is: %s', self.sync_state) - self.includes_last_item_in_range = xml_text_to_value( - message.find(self.last_in_range_name).text, bool - ) - log.debug('Includes last item in range: %s', self.includes_last_item_in_range) + self.sync_state = message.find(f"{{{MNS}}}SyncState").text + log.debug("Sync state is: %s", self.sync_state) + self.includes_last_item_in_range = xml_text_to_value(message.find(self.last_in_range_name).text, bool) + log.debug("Includes last item in range: %s", self.includes_last_item_in_range) return super()._get_element_container(message=message, name=name) def _partial_get_payload(self, folder, shape, additional_fields, sync_state): - payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(shape_element( - tag=self.shape_tag, shape=shape, additional_fields=additional_fields, version=self.account.version - )) - payload.append(folder_ids_element(folders=[folder], version=self.account.version, tag='m:SyncFolderId')) + payload = create_element(f"m:{self.SERVICE_NAME}") + payload.append( + shape_element( + tag=self.shape_tag, shape=shape, additional_fields=additional_fields, version=self.account.version + ) + ) + payload.append(folder_ids_element(folders=[folder], version=self.account.version, tag="m:SyncFolderId")) if sync_state: - add_xml_child(payload, 'm:SyncState', sync_state) + add_xml_child(payload, "m:SyncState", sync_state) return payload @@ -56,9 +56,9 @@ class SyncFolderHierarchy(SyncFolder): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/syncfolderhierarchy-operation """ - SERVICE_NAME = 'SyncFolderHierarchy' - shape_tag = 'm:FolderShape' - last_in_range_name = f'{{{MNS}}}IncludesLastFolderInRange' + SERVICE_NAME = "SyncFolderHierarchy" + shape_tag = "m:FolderShape" + last_in_range_name = f"{{{MNS}}}IncludesLastFolderInRange" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -67,12 +67,16 @@ def __init__(self, *args, **kwargs): def call(self, folder, shape, additional_fields, sync_state): self.sync_state = sync_state self.folder = folder - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - folder=folder, - shape=shape, - additional_fields=additional_fields, - sync_state=sync_state, - ))) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + folder=folder, + shape=shape, + additional_fields=additional_fields, + sync_state=sync_state, + ) + ) + ) def _elem_to_obj(self, elem): change_type = self.change_types_map[elem.tag] diff --git a/exchangelib/services/sync_folder_items.py b/exchangelib/services/sync_folder_items.py index caa38f0f..2a51e8fa 100644 --- a/exchangelib/services/sync_folder_items.py +++ b/exchangelib/services/sync_folder_items.py @@ -1,9 +1,9 @@ -from .common import add_xml_child, item_ids_element -from .sync_folder_hierarchy import SyncFolder from ..errors import InvalidEnumValue, InvalidTypeError from ..folders import BaseFolder from ..properties import ItemId -from ..util import xml_text_to_value, peek, TNS, MNS +from ..util import MNS, TNS, peek, xml_text_to_value +from .common import add_xml_child, item_ids_element +from .sync_folder_hierarchy import SyncFolder class SyncFolderItems(SyncFolder): @@ -11,45 +11,49 @@ class SyncFolderItems(SyncFolder): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/syncfolderitems-operation """ - SERVICE_NAME = 'SyncFolderItems' + SERVICE_NAME = "SyncFolderItems" SYNC_SCOPES = ( - 'NormalItems', - 'NormalAndAssociatedItems', + "NormalItems", + "NormalAndAssociatedItems", ) # Extra change type - READ_FLAG_CHANGE = 'read_flag_change' + READ_FLAG_CHANGE = "read_flag_change" CHANGE_TYPES = SyncFolder.CHANGE_TYPES + (READ_FLAG_CHANGE,) - shape_tag = 'm:ItemShape' - last_in_range_name = f'{{{MNS}}}IncludesLastItemInRange' + shape_tag = "m:ItemShape" + last_in_range_name = f"{{{MNS}}}IncludesLastItemInRange" change_types_map = SyncFolder.change_types_map - change_types_map[f'{{{TNS}}}ReadFlagChange'] = READ_FLAG_CHANGE + change_types_map[f"{{{TNS}}}ReadFlagChange"] = READ_FLAG_CHANGE def call(self, folder, shape, additional_fields, sync_state, ignore, max_changes_returned, sync_scope): self.sync_state = sync_state if max_changes_returned is None: max_changes_returned = self.page_size if not isinstance(max_changes_returned, int): - raise InvalidTypeError('max_changes_returned', max_changes_returned, int) + raise InvalidTypeError("max_changes_returned", max_changes_returned, int) if max_changes_returned <= 0: raise ValueError(f"'max_changes_returned' {max_changes_returned} must be a positive integer") if sync_scope is not None and sync_scope not in self.SYNC_SCOPES: - raise InvalidEnumValue('sync_scope', sync_scope, self.SYNC_SCOPES) - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - folder=folder, - shape=shape, - additional_fields=additional_fields, - sync_state=sync_state, - ignore=ignore, - max_changes_returned=max_changes_returned, - sync_scope=sync_scope, - ))) + raise InvalidEnumValue("sync_scope", sync_scope, self.SYNC_SCOPES) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + folder=folder, + shape=shape, + additional_fields=additional_fields, + sync_state=sync_state, + ignore=ignore, + max_changes_returned=max_changes_returned, + sync_scope=sync_scope, + ) + ) + ) def _elem_to_obj(self, elem): change_type = self.change_types_map[elem.tag] if change_type == self.READ_FLAG_CHANGE: item = ( ItemId.from_xml(elem=elem.find(ItemId.response_tag()), account=self.account), - xml_text_to_value(elem.find(f'{{{TNS}}}IsRead').text, bool) + xml_text_to_value(elem.find(f"{{{TNS}}}IsRead").text, bool), ) elif change_type == self.DELETE: item = ItemId.from_xml(elem=elem.find(ItemId.response_tag()), account=self.account) @@ -66,8 +70,8 @@ def get_payload(self, folder, shape, additional_fields, sync_state, ignore, max_ ) is_empty, ignore = (True, None) if ignore is None else peek(ignore) if not is_empty: - sync_folder_items.append(item_ids_element(items=ignore, version=self.account.version, tag='m:Ignore')) - add_xml_child(sync_folder_items, 'm:MaxChangesReturned', max_changes_returned) + sync_folder_items.append(item_ids_element(items=ignore, version=self.account.version, tag="m:Ignore")) + add_xml_child(sync_folder_items, "m:MaxChangesReturned", max_changes_returned) if sync_scope: - add_xml_child(sync_folder_items, 'm:SyncScope', sync_scope) + add_xml_child(sync_folder_items, "m:SyncScope", sync_scope) return sync_folder_items diff --git a/exchangelib/services/unsubscribe.py b/exchangelib/services/unsubscribe.py index 4bb03566..b1217afc 100644 --- a/exchangelib/services/unsubscribe.py +++ b/exchangelib/services/unsubscribe.py @@ -1,5 +1,5 @@ -from .common import EWSAccountService, add_xml_child from ..util import create_element +from .common import EWSAccountService, add_xml_child class Unsubscribe(EWSAccountService): @@ -8,7 +8,7 @@ class Unsubscribe(EWSAccountService): MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/unsubscribe-operation """ - SERVICE_NAME = 'Unsubscribe' + SERVICE_NAME = "Unsubscribe" returns_elements = False prefer_affinity = True @@ -16,6 +16,6 @@ def call(self, subscription_id): return self._get_elements(payload=self.get_payload(subscription_id=subscription_id)) def get_payload(self, subscription_id): - payload = create_element(f'm:{self.SERVICE_NAME}') - add_xml_child(payload, 'm:SubscriptionId', subscription_id) + payload = create_element(f"m:{self.SERVICE_NAME}") + add_xml_child(payload, "m:SubscriptionId", subscription_id) return payload diff --git a/exchangelib/services/update_folder.py b/exchangelib/services/update_folder.py index 9ab2bdfa..aa113847 100644 --- a/exchangelib/services/update_folder.py +++ b/exchangelib/services/update_folder.py @@ -1,13 +1,14 @@ import abc -from .common import EWSAccountService, parse_folder_elem, to_item_id from ..fields import FieldPath, IndexedField from ..properties import FolderId -from ..util import create_element, set_xml_value, MNS +from ..util import MNS, create_element, set_xml_value +from .common import EWSAccountService, parse_folder_elem, to_item_id class BaseUpdateService(EWSAccountService, metaclass=abc.ABCMeta): """Base class for UpdateFolder and UpdateItem""" + SET_FIELD_ELEMENT_NAME = None DELETE_FIELD_ELEMENT_NAME = None CHANGE_ELEMENT_NAME = None @@ -32,8 +33,9 @@ def _get_value(self, target, field): def _set_field_elems(self, target_model, field, value): if isinstance(field, IndexedField): # Generate either set or delete elements for all combinations of labels and subfields - supported_labels = field.value_cls.get_field_by_fieldname('label')\ - .supported_choices(version=self.account.version) + supported_labels = field.value_cls.get_field_by_fieldname("label").supported_choices( + version=self.account.version + ) seen_labels = set() subfields = field.value_cls.supported_fields(version=self.account.version) for v in value: @@ -49,7 +51,7 @@ def _set_field_elems(self, target_model, field, value): yield self._set_field_elem( target_model=target_model, field_path=field_path, - value=field.value_cls(**{'label': v.label, subfield.name: subfield_value}), + value=field.value_cls(**{"label": v.label, subfield.name: subfield_value}), ) # Generate delete elements for all subfields of all labels not mentioned in the list of values for label in (label for label in supported_labels if label not in seen_labels): @@ -80,13 +82,13 @@ def _update_elems(self, target, fieldnames): for field in self._sorted_fields(target_model=target_model, fieldnames=fieldnames): if field.is_read_only: - raise ValueError(f'{field.name!r} is a read-only field') + raise ValueError(f"{field.name!r} is a read-only field") value = self._get_value(target, field) if value is None or (field.is_list and not value): # A value of None or [] means we want to remove this field from the item if field.is_required or field.is_required_after_save: - raise ValueError(f'{field.name!r} is a required field and may not be deleted') + raise ValueError(f"{field.name!r} is a required field and may not be deleted") yield from self._delete_field_elems(field) continue @@ -97,7 +99,7 @@ def _change_elem(self, target, fieldnames): raise ValueError("'fieldnames' must not be empty") change = create_element(self.CHANGE_ELEMENT_NAME) set_xml_value(change, self._target_elem(target), version=self.account.version) - updates = create_element('t:Updates') + updates = create_element("t:Updates") for elem in self._update_elems(target=target, fieldnames=fieldnames): updates.append(elem) change.append(updates) @@ -119,12 +121,12 @@ def _changes_elem(self, target_changes): class UpdateFolder(BaseUpdateService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updatefolder-operation""" - SERVICE_NAME = 'UpdateFolder' - SET_FIELD_ELEMENT_NAME = 't:SetFolderField' - DELETE_FIELD_ELEMENT_NAME = 't:DeleteFolderField' - CHANGE_ELEMENT_NAME = 't:FolderChange' - CHANGES_ELEMENT_NAME = 'm:FolderChanges' - element_container_name = f'{{{MNS}}}Folders' + SERVICE_NAME = "UpdateFolder" + SET_FIELD_ELEMENT_NAME = "t:SetFolderField" + DELETE_FIELD_ELEMENT_NAME = "t:DeleteFolderField" + CHANGE_ELEMENT_NAME = "t:FolderChange" + CHANGES_ELEMENT_NAME = "m:FolderChanges" + element_container_name = f"{{{MNS}}}Folders" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -149,6 +151,6 @@ def _target_elem(self, target): def get_payload(self, folders): # Takes a list of (Folder, fieldnames) tuples where 'Folder' is a instance of a subclass of Folder and # 'fieldnames' are the attribute names that were updated. - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") payload.append(self._changes_elem(target_changes=folders)) return payload diff --git a/exchangelib/services/update_item.py b/exchangelib/services/update_item.py index 877bb5bd..6fc828ea 100644 --- a/exchangelib/services/update_item.py +++ b/exchangelib/services/update_item.py @@ -1,45 +1,60 @@ -from .common import to_item_id from ..errors import InvalidEnumValue from ..ewsdatetime import EWSDate -from ..items import CONFLICT_RESOLUTION_CHOICES, MESSAGE_DISPOSITION_CHOICES, \ - SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, SEND_ONLY, Item, CalendarItem +from ..items import ( + CONFLICT_RESOLUTION_CHOICES, + MESSAGE_DISPOSITION_CHOICES, + SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, + SEND_ONLY, + CalendarItem, + Item, +) from ..properties import ItemId -from ..util import create_element, MNS +from ..util import MNS, create_element from ..version import EXCHANGE_2013_SP1 +from .common import to_item_id from .update_folder import BaseUpdateService class UpdateItem(BaseUpdateService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem-operation""" - SERVICE_NAME = 'UpdateItem' - SET_FIELD_ELEMENT_NAME = 't:SetItemField' - DELETE_FIELD_ELEMENT_NAME = 't:DeleteItemField' - CHANGE_ELEMENT_NAME = 't:ItemChange' - CHANGES_ELEMENT_NAME = 'm:ItemChanges' - element_container_name = f'{{{MNS}}}Items' + SERVICE_NAME = "UpdateItem" + SET_FIELD_ELEMENT_NAME = "t:SetItemField" + DELETE_FIELD_ELEMENT_NAME = "t:DeleteItemField" + CHANGE_ELEMENT_NAME = "t:ItemChange" + CHANGES_ELEMENT_NAME = "m:ItemChanges" + element_container_name = f"{{{MNS}}}Items" - def call(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, - suppress_read_receipts): + def call( + self, + items, + conflict_resolution, + message_disposition, + send_meeting_invitations_or_cancellations, + suppress_read_receipts, + ): if conflict_resolution not in CONFLICT_RESOLUTION_CHOICES: - raise InvalidEnumValue('conflict_resolution', conflict_resolution, CONFLICT_RESOLUTION_CHOICES) + raise InvalidEnumValue("conflict_resolution", conflict_resolution, CONFLICT_RESOLUTION_CHOICES) if message_disposition not in MESSAGE_DISPOSITION_CHOICES: - raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES) + raise InvalidEnumValue("message_disposition", message_disposition, MESSAGE_DISPOSITION_CHOICES) if send_meeting_invitations_or_cancellations not in SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES: raise InvalidEnumValue( - 'send_meeting_invitations_or_cancellations', send_meeting_invitations_or_cancellations, - SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES + "send_meeting_invitations_or_cancellations", + send_meeting_invitations_or_cancellations, + SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, ) if message_disposition == SEND_ONLY: - raise ValueError('Cannot send-only existing objects. Use SendItem service instead') - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, - items=items, - conflict_resolution=conflict_resolution, - message_disposition=message_disposition, - send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations, - suppress_read_receipts=suppress_read_receipts, - )) + raise ValueError("Cannot send-only existing objects. Use SendItem service instead") + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=items, + conflict_resolution=conflict_resolution, + message_disposition=message_disposition, + send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations, + suppress_read_receipts=suppress_read_receipts, + ) + ) def _elem_to_obj(self, elem): return Item.id_from_xml(elem) @@ -50,7 +65,7 @@ def _update_elems(self, target, fieldnames): if target.__class__ == CalendarItem: # For CalendarItem items where we update 'start' or 'end', we want to update internal timezone fields target.clean_timezone_fields(version=self.account.version) # Possibly also sets timezone values - for field_name in ('start', 'end'): + for field_name in ("start", "end"): if field_name in fieldnames_copy: tz_field_name = target.tz_field_for_field_name(field_name).name if tz_field_name not in fieldnames_copy: @@ -63,7 +78,7 @@ def _get_value(self, target, field): if target.__class__ == CalendarItem: # For CalendarItem items where we update 'start' or 'end', we want to send values in the local timezone - if field.name in ('start', 'end'): + if field.name in ("start", "end"): if type(value) is EWSDate: # EWS always expects a datetime return target.date_to_datetime(field_name=field.name) @@ -75,8 +90,14 @@ def _get_value(self, target, field): def _target_elem(self, target): return to_item_id(target, ItemId) - def get_payload(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, - suppress_read_receipts): + def get_payload( + self, + items, + conflict_resolution, + message_disposition, + send_meeting_invitations_or_cancellations, + suppress_read_receipts, + ): # Takes a list of (Item, fieldnames) tuples where 'Item' is a instance of a subclass of Item and 'fieldnames' # are the attribute names that were updated. attrs = dict( @@ -85,7 +106,7 @@ def get_payload(self, items, conflict_resolution, message_disposition, send_meet SendMeetingInvitationsOrCancellations=send_meeting_invitations_or_cancellations, ) if self.account.version.build >= EXCHANGE_2013_SP1: - attrs['SuppressReadReceipts'] = suppress_read_receipts - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + attrs["SuppressReadReceipts"] = suppress_read_receipts + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=attrs) payload.append(self._changes_elem(target_changes=items)) return payload diff --git a/exchangelib/services/update_user_configuration.py b/exchangelib/services/update_user_configuration.py index 2c7753d1..956e34bc 100644 --- a/exchangelib/services/update_user_configuration.py +++ b/exchangelib/services/update_user_configuration.py @@ -1,5 +1,5 @@ -from .common import EWSAccountService from ..util import create_element, set_xml_value +from .common import EWSAccountService class UpdateUserConfiguration(EWSAccountService): @@ -7,11 +7,11 @@ class UpdateUserConfiguration(EWSAccountService): https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateuserconfiguration-operation """ - SERVICE_NAME = 'UpdateUserConfiguration' + SERVICE_NAME = "UpdateUserConfiguration" returns_elements = False def call(self, user_configuration): return self._get_elements(payload=self.get_payload(user_configuration=user_configuration)) def get_payload(self, user_configuration): - return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), user_configuration, version=self.account.version) + return set_xml_value(create_element(f"m:{self.SERVICE_NAME}"), user_configuration, version=self.account.version) diff --git a/exchangelib/services/upload_items.py b/exchangelib/services/upload_items.py index 7618b810..60970a5e 100644 --- a/exchangelib/services/upload_items.py +++ b/exchangelib/services/upload_items.py @@ -1,14 +1,13 @@ -from .common import EWSAccountService, to_item_id from ..properties import ItemId, ParentFolderId -from ..util import create_element, set_xml_value, add_xml_child, MNS +from ..util import MNS, add_xml_child, create_element, set_xml_value +from .common import EWSAccountService, to_item_id class UploadItems(EWSAccountService): - """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/uploaditems-operation - """ + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/uploaditems-operation""" - SERVICE_NAME = 'UploadItems' - element_container_name = f'{{{MNS}}}ItemId' + SERVICE_NAME = "UploadItems" + element_container_name = f"{{{MNS}}}ItemId" def call(self, items): # _pool_requests expects 'items', not 'data' @@ -24,19 +23,19 @@ def get_payload(self, items): :param items: """ - payload = create_element(f'm:{self.SERVICE_NAME}') - items_elem = create_element('m:Items') + payload = create_element(f"m:{self.SERVICE_NAME}") + items_elem = create_element("m:Items") payload.append(items_elem) for parent_folder, (item_id, is_associated, data_str) in items: # TODO: The full spec also allows the "UpdateOrCreate" create action. - attrs = dict(CreateAction='Update' if item_id else 'CreateNew') + attrs = dict(CreateAction="Update" if item_id else "CreateNew") if is_associated is not None: - attrs['IsAssociated'] = is_associated - item = create_element('t:Item', attrs=attrs) + attrs["IsAssociated"] = is_associated + item = create_element("t:Item", attrs=attrs) set_xml_value(item, ParentFolderId(parent_folder.id, parent_folder.changekey), version=self.account.version) if item_id: set_xml_value(item, to_item_id(item_id, ItemId), version=self.account.version) - add_xml_child(item, 't:Data', data_str) + add_xml_child(item, "t:Data", data_str) items_elem.append(item) return payload diff --git a/exchangelib/settings.py b/exchangelib/settings.py index a495c202..35340b5b 100644 --- a/exchangelib/settings.py +++ b/exchangelib/settings.py @@ -1,7 +1,7 @@ import datetime from .ewsdatetime import UTC -from .fields import DateTimeField, MessageField, ChoiceField, Choice +from .fields import Choice, ChoiceField, DateTimeField, MessageField from .properties import EWSElement, OutOfOffice from .util import create_element, set_xml_value @@ -9,21 +9,22 @@ class OofSettings(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/oofsettings""" - ELEMENT_NAME = 'OofSettings' - REQUEST_ELEMENT_NAME = 'UserOofSettings' + ELEMENT_NAME = "OofSettings" + REQUEST_ELEMENT_NAME = "UserOofSettings" - ENABLED = 'Enabled' - SCHEDULED = 'Scheduled' - DISABLED = 'Disabled' + ENABLED = "Enabled" + SCHEDULED = "Scheduled" + DISABLED = "Disabled" STATE_CHOICES = (ENABLED, SCHEDULED, DISABLED) - state = ChoiceField(field_uri='OofState', is_required=True, choices={Choice(c) for c in STATE_CHOICES}) - external_audience = ChoiceField(field_uri='ExternalAudience', - choices={Choice('None'), Choice('Known'), Choice('All')}, default='All') - start = DateTimeField(field_uri='StartTime') - end = DateTimeField(field_uri='EndTime') - internal_reply = MessageField(field_uri='InternalReply') - external_reply = MessageField(field_uri='ExternalReply') + state = ChoiceField(field_uri="OofState", is_required=True, choices={Choice(c) for c in STATE_CHOICES}) + external_audience = ChoiceField( + field_uri="ExternalAudience", choices={Choice("None"), Choice("Known"), Choice("All")}, default="All" + ) + start = DateTimeField(field_uri="StartTime") + end = DateTimeField(field_uri="EndTime") + internal_reply = MessageField(field_uri="InternalReply") + external_reply = MessageField(field_uri="ExternalReply") def clean(self, version=None): super().clean(version=version) @@ -40,7 +41,7 @@ def clean(self, version=None): @classmethod def from_xml(cls, elem, account): kwargs = {} - for attr in ('state', 'external_audience', 'internal_reply', 'external_reply'): + for attr in ("state", "external_audience", "internal_reply", "external_reply"): f = cls.get_field_by_fieldname(attr) kwargs[attr] = f.from_xml(elem=elem, account=account) kwargs.update(OutOfOffice.duration_to_start_end(elem=elem, account=account)) @@ -49,24 +50,24 @@ def from_xml(cls, elem, account): def to_xml(self, version): self.clean(version=version) - elem = create_element(f't:{self.REQUEST_ELEMENT_NAME}') - for attr in ('state', 'external_audience'): + elem = create_element(f"t:{self.REQUEST_ELEMENT_NAME}") + for attr in ("state", "external_audience"): value = getattr(self, attr) f = self.get_field_by_fieldname(attr) set_xml_value(elem, f.to_xml(value, version=version)) if self.start or self.end: - duration = create_element('t:Duration') + duration = create_element("t:Duration") if self.start: - f = self.get_field_by_fieldname('start') + f = self.get_field_by_fieldname("start") set_xml_value(duration, f.to_xml(self.start, version=version)) if self.end: - f = self.get_field_by_fieldname('end') + f = self.get_field_by_fieldname("end") set_xml_value(duration, f.to_xml(self.end, version=version)) elem.append(duration) - for attr in ('internal_reply', 'external_reply'): + for attr in ("internal_reply", "external_reply"): value = getattr(self, attr) if value is None: - value = '' # The value can be empty, but the XML element must always be present + value = "" # The value can be empty, but the XML element must always be present f = self.get_field_by_fieldname(attr) set_xml_value(elem, f.to_xml(value, version=version)) return elem @@ -75,10 +76,10 @@ def __hash__(self): # Customize comparison if self.state == self.DISABLED: # All values except state are ignored by the server - relevant_attrs = ('state',) + relevant_attrs = ("state",) elif self.state != self.SCHEDULED: # 'start' and 'end' values are ignored by the server, and the server always returns today's date - relevant_attrs = tuple(f.name for f in self.FIELDS if f.name not in ('start', 'end')) + relevant_attrs = tuple(f.name for f in self.FIELDS if f.name not in ("start", "end")) else: relevant_attrs = tuple(f.name for f in self.FIELDS) return hash(tuple(getattr(self, attr) for attr in relevant_attrs)) diff --git a/exchangelib/transport.py b/exchangelib/transport.py index b682c332..c0931397 100644 --- a/exchangelib/transport.py +++ b/exchangelib/transport.py @@ -5,21 +5,30 @@ import requests_ntlm import requests_oauthlib -from .errors import UnauthorizedError, TransportError -from .util import create_element, add_xml_child, xml_to_str, ns_translation, _back_off_if_needed, \ - _retry_after, DummyResponse, CONNECTION_ERRORS, RETRY_WAIT +from .errors import TransportError, UnauthorizedError +from .util import ( + CONNECTION_ERRORS, + RETRY_WAIT, + DummyResponse, + _back_off_if_needed, + _retry_after, + add_xml_child, + create_element, + ns_translation, + xml_to_str, +) log = logging.getLogger(__name__) # Authentication method enums -NOAUTH = 'no authentication' -NTLM = 'NTLM' -BASIC = 'basic' -DIGEST = 'digest' -GSSAPI = 'gssapi' -SSPI = 'sspi' -OAUTH2 = 'OAuth 2.0' -CBA = 'CBA' # Certificate Based Authentication +NOAUTH = "no authentication" +NTLM = "NTLM" +BASIC = "basic" +DIGEST = "digest" +GSSAPI = "gssapi" +SSPI = "sspi" +OAUTH2 = "OAuth 2.0" +CBA = "CBA" # Certificate Based Authentication # The auth types that must be accompanied by a credentials object CREDENTIALS_REQUIRED = (NTLM, BASIC, DIGEST, OAUTH2) @@ -34,19 +43,21 @@ } try: import requests_gssapi + AUTH_TYPE_MAP[GSSAPI] = requests_gssapi.HTTPSPNEGOAuth except ImportError: # Kerberos auth is optional pass try: import requests_negotiate_sspi + AUTH_TYPE_MAP[SSPI] = requests_negotiate_sspi.HttpNegotiateAuth except ImportError: # SSPI auth is optional pass -DEFAULT_ENCODING = 'utf-8' -DEFAULT_HEADERS = {'Content-Type': f'text/xml; charset={DEFAULT_ENCODING}', 'Accept-Encoding': 'gzip, deflate'} +DEFAULT_ENCODING = "utf-8" +DEFAULT_HEADERS = {"Content-Type": f"text/xml; charset={DEFAULT_ENCODING}", "Accept-Encoding": "gzip, deflate"} def wrap(content, api_version=None, account_to_impersonate=None, timezone=None): @@ -67,36 +78,36 @@ def wrap(content, api_version=None, account_to_impersonate=None, timezone=None): :param account_to_impersonate: (Default value = None) :param timezone: (Default value = None) """ - envelope = create_element('s:Envelope', nsmap=ns_translation) - header = create_element('s:Header') + envelope = create_element("s:Envelope", nsmap=ns_translation) + header = create_element("s:Header") if api_version: - request_server_version = create_element('t:RequestServerVersion', attrs=dict(Version=api_version)) + request_server_version = create_element("t:RequestServerVersion", attrs=dict(Version=api_version)) header.append(request_server_version) if account_to_impersonate: - exchange_impersonation = create_element('t:ExchangeImpersonation') - connecting_sid = create_element('t:ConnectingSID') + exchange_impersonation = create_element("t:ExchangeImpersonation") + connecting_sid = create_element("t:ConnectingSID") # We have multiple options for uniquely identifying the user. Here's a prioritized list in accordance with # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/connectingsid for attr, tag in ( - ('sid', 'SID'), - ('upn', 'PrincipalName'), - ('smtp_address', 'SmtpAddress'), - ('primary_smtp_address', 'PrimarySmtpAddress'), + ("sid", "SID"), + ("upn", "PrincipalName"), + ("smtp_address", "SmtpAddress"), + ("primary_smtp_address", "PrimarySmtpAddress"), ): val = getattr(account_to_impersonate, attr) if val: - add_xml_child(connecting_sid, f't:{tag}', val) + add_xml_child(connecting_sid, f"t:{tag}", val) break exchange_impersonation.append(connecting_sid) header.append(exchange_impersonation) if timezone: - timezone_context = create_element('t:TimeZoneContext') - timezone_definition = create_element('t:TimeZoneDefinition', attrs=dict(Id=timezone.ms_id)) + timezone_context = create_element("t:TimeZoneContext") + timezone_definition = create_element("t:TimeZoneDefinition", attrs=dict(Id=timezone.ms_id)) timezone_context.append(timezone_definition) header.append(timezone_context) if len(header): envelope.append(header) - body = create_element('s:Body') + body = create_element("s:Body") body.append(content) envelope.append(body) return xml_to_str(envelope, encoding=DEFAULT_ENCODING, xml_declaration=True) @@ -127,19 +138,25 @@ def get_service_authtype(service_endpoint, retry_policy, api_versions, name): # We don't know the API version yet, but we need it to create a valid request because some Exchange servers only # respond when given a valid request. Try all known versions. Gross. from .protocol import BaseProtocol + retry = 0 t_start = time.monotonic() headers = DEFAULT_HEADERS.copy() for api_version in api_versions: data = dummy_xml(api_version=api_version, name=name) - log.debug('Requesting %s from %s', data, service_endpoint) + log.debug("Requesting %s from %s", data, service_endpoint) while True: _back_off_if_needed(retry_policy.back_off_until) - log.debug('Trying to get service auth type for %s', service_endpoint) + log.debug("Trying to get service auth type for %s", service_endpoint) with BaseProtocol.raw_session(service_endpoint) as s: try: - r = s.post(url=service_endpoint, headers=headers, data=data, allow_redirects=False, - timeout=BaseProtocol.TIMEOUT) + r = s.post( + url=service_endpoint, + headers=headers, + data=data, + allow_redirects=False, + timeout=BaseProtocol.TIMEOUT, + ) r.close() # Release memory break except CONNECTION_ERRORS as e: @@ -148,66 +165,71 @@ def get_service_authtype(service_endpoint, retry_policy, api_versions, name): r = DummyResponse(url=service_endpoint, request_headers=headers) if retry_policy.may_retry_on_error(response=r, wait=total_wait): wait = _retry_after(r, RETRY_WAIT) - log.info("Connection error on URL %s (retry %s, error: %s). Cool down %s secs", - service_endpoint, retry, e, wait) + log.info( + "Connection error on URL %s (retry %s, error: %s). Cool down %s secs", + service_endpoint, + retry, + e, + wait, + ) retry_policy.back_off(wait) retry += 1 continue raise TransportError(str(e)) from e if r.status_code not in (200, 401): - log.debug('Unexpected response: %s %s', r.status_code, r.reason) + log.debug("Unexpected response: %s %s", r.status_code, r.reason) continue try: auth_type = get_auth_method_from_response(response=r) - log.debug('Auth type is %s', auth_type) + log.debug("Auth type is %s", auth_type) return auth_type, api_version except UnauthorizedError: continue - raise TransportError('Failed to get auth type from service') + raise TransportError("Failed to get auth type from service") def get_auth_method_from_response(response): # First, get the auth method from headers. Then, test credentials. Don't handle redirects - burden is on caller. - log.debug('Request headers: %s', response.request.headers) - log.debug('Response headers: %s', response.headers) + log.debug("Request headers: %s", response.request.headers) + log.debug("Response headers: %s", response.headers) if response.status_code == 200: return NOAUTH # Get auth type from headers for key, val in response.headers.items(): - if key.lower() == 'www-authenticate': + if key.lower() == "www-authenticate": # Requests will combine multiple HTTP headers into one in 'request.headers' vals = _tokenize(val.lower()) for v in vals: - if v.startswith('realm'): - realm = v.split('=')[1].strip('"') - log.debug('realm: %s', realm) + if v.startswith("realm"): + realm = v.split("=")[1].strip('"') + log.debug("realm: %s", realm) # Prefer most secure auth method if more than one is offered. See discussion at # http://docs.oracle.com/javase/7/docs/technotes/guides/net/http-auth.html - if 'digest' in vals: + if "digest" in vals: return DIGEST - if 'ntlm' in vals: + if "ntlm" in vals: return NTLM - if 'basic' in vals: + if "basic" in vals: return BASIC - raise UnauthorizedError('No compatible auth type was reported by server') + raise UnauthorizedError("No compatible auth type was reported by server") def _tokenize(val): # Splits cookie auth values auth_methods = [] - auth_method = '' + auth_method = "" quote = False for c in val: - if c in (' ', ',') and not quote: - if auth_method not in ('', ','): + if c in (" ", ",") and not quote: + if auth_method not in ("", ","): auth_methods.append(auth_method) - auth_method = '' + auth_method = "" continue if c == '"': auth_method += c if quote: auth_methods.append(auth_method) - auth_method = '' + auth_method = "" quote = not quote continue auth_method += c @@ -219,10 +241,14 @@ def _tokenize(val): def dummy_xml(api_version, name): # Generate a minimal, valid EWS request from .services import ResolveNames # Avoid circular import - return wrap(content=ResolveNames(protocol=None).get_payload( - unresolved_entries=[name], - parent_folders=None, - return_full_contact_data=False, - search_scope=None, - contact_data_shape=None, - ), api_version=api_version) + + return wrap( + content=ResolveNames(protocol=None).get_payload( + unresolved_entries=[name], + parent_folders=None, + return_full_contact_data=False, + search_scope=None, + contact_data_shape=None, + ), + api_version=api_version, + ) diff --git a/exchangelib/util.py b/exchangelib/util.py index 6b6de253..e7d962ac 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -23,19 +23,26 @@ from pygments.formatters.terminal import TerminalFormatter from pygments.lexers.html import XmlLexer -from .errors import TransportError, RateLimitError, RedirectError, RelativeRedirect, MalformedResponseError, \ - InvalidTypeError +from .errors import ( + InvalidTypeError, + MalformedResponseError, + RateLimitError, + RedirectError, + RelativeRedirect, + TransportError, +) log = logging.getLogger(__name__) -xml_log = logging.getLogger(f'{__name__}.xml') +xml_log = logging.getLogger(f"{__name__}.xml") def require_account(f): @wraps(f) def wrapper(self, *args, **kwargs): if not self.account: - raise ValueError(f'{self.__class__.__name__} must have an account') + raise ValueError(f"{self.__class__.__name__} must have an account") return f(self, *args, **kwargs) + return wrapper @@ -43,10 +50,11 @@ def require_id(f): @wraps(f) def wrapper(self, *args, **kwargs): if not self.account: - raise ValueError(f'{self.__class__.__name__} must have an account') + raise ValueError(f"{self.__class__.__name__} must have an account") if not self.id: - raise ValueError(f'{self.__class__.__name__} must have an ID') + raise ValueError(f"{self.__class__.__name__} must have an ID") return f(self, *args, **kwargs) + return wrapper @@ -63,21 +71,21 @@ def __init__(self, msg, data): # Regex of UTF-8 control characters that are illegal in XML 1.0 (and XML 1.1) -_ILLEGAL_XML_CHARS_RE = re.compile('[\x00-\x08\x0b\x0c\x0e-\x1F\uD800-\uDFFF\uFFFE\uFFFF]') +_ILLEGAL_XML_CHARS_RE = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1F\uD800-\uDFFF\uFFFE\uFFFF]") # XML namespaces -SOAPNS = 'http://schemas.xmlsoap.org/soap/envelope/' -MNS = 'http://schemas.microsoft.com/exchange/services/2006/messages' -TNS = 'http://schemas.microsoft.com/exchange/services/2006/types' -ENS = 'http://schemas.microsoft.com/exchange/services/2006/errors' -AUTODISCOVER_BASE_NS = 'http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006' -AUTODISCOVER_REQUEST_NS = 'http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006' -AUTODISCOVER_RESPONSE_NS = 'http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a' +SOAPNS = "http://schemas.xmlsoap.org/soap/envelope/" +MNS = "http://schemas.microsoft.com/exchange/services/2006/messages" +TNS = "http://schemas.microsoft.com/exchange/services/2006/types" +ENS = "http://schemas.microsoft.com/exchange/services/2006/errors" +AUTODISCOVER_BASE_NS = "http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006" +AUTODISCOVER_REQUEST_NS = "http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006" +AUTODISCOVER_RESPONSE_NS = "http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a" ns_translation = { - 's': SOAPNS, - 'm': MNS, - 't': TNS, + "s": SOAPNS, + "m": MNS, + "t": TNS, } for item in ns_translation.items(): lxml.etree.register_namespace(*item) @@ -94,7 +102,7 @@ def is_iterable(value, generators_allowed=False): :return: True or False """ if generators_allowed: - if not isinstance(value, (bytes, str)) and hasattr(value, '__iter__'): + if not isinstance(value, (bytes, str)) and hasattr(value, "__iter__"): return True else: if isinstance(value, (tuple, list, set)): @@ -110,10 +118,11 @@ def chunkify(iterable, chunksize): :return: """ from .queryset import QuerySet - if hasattr(iterable, '__getitem__') and not isinstance(iterable, QuerySet): + + if hasattr(iterable, "__getitem__") and not isinstance(iterable, QuerySet): # tuple, list. QuerySet has __getitem__ but that evaluates the entire query greedily. We don't want that here. for i in range(0, len(iterable), chunksize): - yield iterable[i:i + chunksize] + yield iterable[i : i + chunksize] else: # generator, set, map, QuerySet chunk = [] @@ -132,7 +141,7 @@ def peek(iterable): :param iterable: :return: """ - if hasattr(iterable, '__len__'): + if hasattr(iterable, "__len__"): # tuple, list, set return not iterable, iterable # generator @@ -171,16 +180,17 @@ def get_xml_attrs(tree, name): def value_to_xml_text(value): - from .ewsdatetime import EWSTimeZone, EWSDateTime, EWSDate - from .indexed_properties import PhoneNumber, EmailAddress - from .properties import Mailbox, AssociatedCalendarItemId, Attendee, ConversationId + from .ewsdatetime import EWSDate, EWSDateTime, EWSTimeZone + from .indexed_properties import EmailAddress, PhoneNumber + from .properties import AssociatedCalendarItemId, Attendee, ConversationId, Mailbox + # We can't just create a map and look up with type(value) because we want to support subtypes if isinstance(value, str): return safe_xml_value(value) if isinstance(value, bool): - return '1' if value else '0' + return "1" if value else "0" if isinstance(value, bytes): - return b64encode(value).decode('ascii') + return b64encode(value).decode("ascii") if isinstance(value, (int, Decimal)): return str(value) if isinstance(value, datetime.time): @@ -203,20 +213,21 @@ def value_to_xml_text(value): return value.id if isinstance(value, AssociatedCalendarItemId): return value.id - raise TypeError(f'Unsupported type: {type(value)} ({value})') + raise TypeError(f"Unsupported type: {type(value)} ({value})") def xml_text_to_value(value, value_type): from .ewsdatetime import EWSDate, EWSDateTime + if value_type == str: return value if value_type == bool: try: return { - 'true': True, - 'on': True, - 'false': False, - 'off': False, + "true": True, + "on": True, + "false": False, + "off": False, }[value.lower()] except KeyError: return None @@ -231,10 +242,11 @@ def xml_text_to_value(value, value_type): def set_xml_value(elem, value, version=None): - from .ewsdatetime import EWSDateTime, EWSDate - from .fields import FieldPath, FieldOrder + from .ewsdatetime import EWSDate, EWSDateTime + from .fields import FieldOrder, FieldPath from .properties import EWSElement from .version import Version + if isinstance(value, (str, bool, bytes, int, Decimal, datetime.time, EWSDate, EWSDateTime)): elem.text = value_to_xml_text(value) elif isinstance(value, _element_class): @@ -243,31 +255,31 @@ def set_xml_value(elem, value, version=None): elem.append(value.to_xml()) elif isinstance(value, EWSElement): if not isinstance(version, Version): - raise InvalidTypeError('version', version, Version) + raise InvalidTypeError("version", version, Version) elem.append(value.to_xml(version=version)) elif is_iterable(value, generators_allowed=True): for v in value: set_xml_value(elem, v, version=version) else: - raise ValueError(f'Unsupported type {type(value)} for value {value} on elem {elem}') + raise ValueError(f"Unsupported type {type(value)} for value {value} on elem {elem}") return elem -def safe_xml_value(value, replacement='?'): +def safe_xml_value(value, replacement="?"): return _ILLEGAL_XML_CHARS_RE.sub(replacement, value) def create_element(name, attrs=None, nsmap=None): - if ':' in name: - ns, name = name.split(':') - name = f'{{{ns_translation[ns]}}}{name}' + if ":" in name: + ns, name = name.split(":") + name = f"{{{ns_translation[ns]}}}{name}" elem = _forgiving_parser.makeelement(name, nsmap=nsmap) if attrs: # Try hard to keep attribute order, to ensure deterministic output. This simplifies testing. # Dicts in Python 3.6+ have stable ordering. for k, v in attrs.items(): if isinstance(v, bool): - v = 'true' if v else 'false' + v = "true" if v else "false" elif isinstance(v, int): v = str(v) elem.set(k, v) @@ -323,9 +335,9 @@ def safe_b64decode(data): overflow = len(data) % 4 if overflow: if isinstance(data, str): - padding = '=' * (4 - overflow) + padding = "=" * (4 - overflow) else: - padding = b'=' * (4 - overflow) + padding = b"=" * (4 - overflow) data += padding return b64decode(data) @@ -359,7 +371,7 @@ def parse(self, r): self.close() if not self.element_found: data = bytes(collected_data) - raise ElementNotFound('The element to be streamed from was not found', data=bytes(data)) + raise ElementNotFound("The element to be streamed from was not found", data=bytes(data)) def feed(self, data, isFinal=0): """Yield the current content of the character buffer.""" @@ -367,13 +379,13 @@ def feed(self, data, isFinal=0): return self._decode_buffer() def _decode_buffer(self): - remainder = '' + remainder = "" for data in self.buffer: available = len(remainder) + len(data) overflow = available % 4 # Make sure we always decode a multiple of 4 if remainder: - data = (remainder + data) - remainder = '' + data = remainder + data + remainder = "" if overflow: remainder, data = data[-overflow:], data[:-overflow] if data: @@ -386,7 +398,7 @@ def _decode_buffer(self): recover=True, # This setting is non-default huge_tree=True, # This setting enables parsing huge attachments, mime_content and other large data ) -_element_class = _forgiving_parser.makeelement('x').__class__ +_element_class = _forgiving_parser.makeelement("x").__class__ class BytesGeneratorIO(io.RawIOBase): @@ -413,7 +425,7 @@ def read(self, size=-1): if self.closed: raise ValueError("read from a closed file") if self._next is None: - return b'' + return b"" if size is None: size = -1 @@ -441,7 +453,7 @@ def close(self): class DocumentYielder: """Look for XML documents in a streaming HTTP response and yield them as they become available from the stream.""" - def __init__(self, content_iterator, document_tag='Envelope'): + def __init__(self, content_iterator, document_tag="Envelope"): self._iterator = content_iterator self._document_tag = document_tag.encode() @@ -449,16 +461,16 @@ def _get_tag(self): """Iterate over the bytes until we have a full tag in the buffer. If there's a '>' in an attr value, then we'll exit on that, but it's OK becaus wejust need the plain tag name later. """ - tag_buffer = [b'<'] + tag_buffer = [b"<"] while True: try: c = next(self._iterator) except StopIteration: break tag_buffer.append(c) - if c == b'>': + if c == b">": break - return b''.join(tag_buffer) + return b"".join(tag_buffer) @staticmethod def _normalize_tag(tag): @@ -468,7 +480,7 @@ def _normalize_tag(tag): * * """ - return tag.strip(b'<>/').split(b' ')[0].split(b':')[-1] + return tag.strip(b"<>/").split(b" ")[0].split(b":")[-1] def __iter__(self): """Consumes the content iterator, looking for start and end tags. Returns each document when we have fully @@ -479,18 +491,18 @@ def __iter__(self): try: while True: c = next(self._iterator) - if not doc_started and c == b'<': + if not doc_started and c == b"<": tag = self._get_tag() if self._normalize_tag(tag) == self._document_tag: # Start of document. Collect bytes from this point buffer.append(tag) doc_started = True - elif doc_started and c == b'<': + elif doc_started and c == b"<": tag = self._get_tag() buffer.append(tag) if self._normalize_tag(tag) == self._document_tag: # End of document. Yield a valid document and reset the buffer - yield b"\n" + b''.join(buffer) + yield b"\n" + b"".join(buffer) doc_started = False buffer = [] elif doc_started: @@ -509,39 +521,39 @@ def to_xml(bytes_content): try: res = lxml.etree.parse(stream, parser=_forgiving_parser) # nosec except AssertionError as e: - raise ParseError(e.args[0], '', -1, 0) + raise ParseError(e.args[0], "", -1, 0) except lxml.etree.ParseError as e: - if hasattr(e, 'position'): + if hasattr(e, "position"): e.lineno, e.offset = e.position if not e.lineno: - raise ParseError(str(e), '', e.lineno, e.offset) + raise ParseError(str(e), "", e.lineno, e.offset) try: stream.seek(0) offending_line = stream.read().splitlines()[e.lineno - 1] except (IndexError, io.UnsupportedOperation): - raise ParseError(str(e), '', e.lineno, e.offset) + raise ParseError(str(e), "", e.lineno, e.offset) else: - offending_excerpt = offending_line[max(0, e.offset - 20):e.offset + 20] + offending_excerpt = offending_line[max(0, e.offset - 20) : e.offset + 20] msg = f'{e}\nOffending text: [...]{offending_excerpt.decode("utf-8", errors="ignore")}[...]' - raise ParseError(msg, '', e.lineno, e.offset) + raise ParseError(msg, "", e.lineno, e.offset) except TypeError: try: stream.seek(0) except (IndexError, io.UnsupportedOperation): pass - raise ParseError(f'This is not XML: {stream.read()!r}', '', -1, 0) + raise ParseError(f"This is not XML: {stream.read()!r}", "", -1, 0) if res.getroot() is None: try: stream.seek(0) - msg = f'No root element found: {stream.read()!r}' + msg = f"No root element found: {stream.read()!r}" except (IndexError, io.UnsupportedOperation): - msg = 'No root element found' - raise ParseError(msg, '', -1, 0) + msg = "No root element found" + raise ParseError(msg, "", -1, 0) return res -def is_xml(text, expected_prefix=b' 0: - log.warning('Server requested back off until %s. Sleeping %s seconds', back_off_until, sleep_secs) + log.warning("Server requested back off until %s. Sleeping %s seconds", back_off_until, sleep_secs) time.sleep(sleep_secs) return True return False def _need_new_credentials(response): - return response.status_code == 401 \ - and response.headers.get('TokenExpiredError') + return response.status_code == 401 and response.headers.get("TokenExpiredError") def _redirect_or_fail(response, redirects, allow_redirects): @@ -916,18 +948,18 @@ def _redirect_or_fail(response, redirects, allow_redirects): log.debug("'allow_redirects' only supports relative redirects (%s -> %s)", response.url, e.value) raise RedirectError(url=e.value) if not allow_redirects: - raise TransportError(f'Redirect not allowed but we were redirected ({response.url} -> {redirect_url})') - log.debug('HTTP redirected to %s', redirect_url) + raise TransportError(f"Redirect not allowed but we were redirected ({response.url} -> {redirect_url})") + log.debug("HTTP redirected to %s", redirect_url) redirects += 1 if redirects > MAX_REDIRECTS: - raise TransportError('Max redirect count exceeded') + raise TransportError("Max redirect count exceeded") return redirect_url, redirects def _retry_after(r, wait): """Either return the Retry-After header value or the default wait, whichever is larger.""" try: - retry_after = int(r.headers.get('Retry-After', '0')) + retry_after = int(r.headers.get("Retry-After", "0")) except ValueError: pass else: diff --git a/exchangelib/version.py b/exchangelib/version.py index 8ac2141d..1d000a1c 100644 --- a/exchangelib/version.py +++ b/exchangelib/version.py @@ -1,8 +1,8 @@ import logging import re -from .errors import TransportError, ResponseMessageError, InvalidTypeError -from .util import xml_to_str, TNS +from .errors import InvalidTypeError, ResponseMessageError, TransportError +from .util import TNS, xml_to_str log = logging.getLogger(__name__) @@ -16,20 +16,20 @@ # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/requestserverversion VERSIONS = { - 'Exchange2007': ('Exchange2007', 'Microsoft Exchange Server 2007'), - 'Exchange2007_SP1': ('Exchange2007_SP1', 'Microsoft Exchange Server 2007 SP1'), - 'Exchange2007_SP2': ('Exchange2007_SP1', 'Microsoft Exchange Server 2007 SP2'), - 'Exchange2007_SP3': ('Exchange2007_SP1', 'Microsoft Exchange Server 2007 SP3'), - 'Exchange2010': ('Exchange2010', 'Microsoft Exchange Server 2010'), - 'Exchange2010_SP1': ('Exchange2010_SP1', 'Microsoft Exchange Server 2010 SP1'), - 'Exchange2010_SP2': ('Exchange2010_SP2', 'Microsoft Exchange Server 2010 SP2'), - 'Exchange2010_SP3': ('Exchange2010_SP2', 'Microsoft Exchange Server 2010 SP3'), - 'Exchange2013': ('Exchange2013', 'Microsoft Exchange Server 2013'), - 'Exchange2013_SP1': ('Exchange2013_SP1', 'Microsoft Exchange Server 2013 SP1'), - 'Exchange2015': ('Exchange2015', 'Microsoft Exchange Server 2015'), - 'Exchange2015_SP1': ('Exchange2015_SP1', 'Microsoft Exchange Server 2015 SP1'), - 'Exchange2016': ('Exchange2016', 'Microsoft Exchange Server 2016'), - 'Exchange2019': ('Exchange2019', 'Microsoft Exchange Server 2019'), + "Exchange2007": ("Exchange2007", "Microsoft Exchange Server 2007"), + "Exchange2007_SP1": ("Exchange2007_SP1", "Microsoft Exchange Server 2007 SP1"), + "Exchange2007_SP2": ("Exchange2007_SP1", "Microsoft Exchange Server 2007 SP2"), + "Exchange2007_SP3": ("Exchange2007_SP1", "Microsoft Exchange Server 2007 SP3"), + "Exchange2010": ("Exchange2010", "Microsoft Exchange Server 2010"), + "Exchange2010_SP1": ("Exchange2010_SP1", "Microsoft Exchange Server 2010 SP1"), + "Exchange2010_SP2": ("Exchange2010_SP2", "Microsoft Exchange Server 2010 SP2"), + "Exchange2010_SP3": ("Exchange2010_SP2", "Microsoft Exchange Server 2010 SP3"), + "Exchange2013": ("Exchange2013", "Microsoft Exchange Server 2013"), + "Exchange2013_SP1": ("Exchange2013_SP1", "Microsoft Exchange Server 2013 SP1"), + "Exchange2015": ("Exchange2015", "Microsoft Exchange Server 2015"), + "Exchange2015_SP1": ("Exchange2015_SP1", "Microsoft Exchange Server 2015 SP1"), + "Exchange2016": ("Exchange2016", "Microsoft Exchange Server 2016"), + "Exchange2019": ("Exchange2019", "Microsoft Exchange Server 2019"), } # Build a list of unique API versions, used when guessing API version supported by the server. Use reverse order so we @@ -43,36 +43,36 @@ class Build: # List of build numbers here: https://docs.microsoft.com/en-us/exchange/new-features/build-numbers-and-release-dates API_VERSION_MAP = { 8: { - 0: 'Exchange2007', - 1: 'Exchange2007_SP1', - 2: 'Exchange2007_SP1', - 3: 'Exchange2007_SP1', + 0: "Exchange2007", + 1: "Exchange2007_SP1", + 2: "Exchange2007_SP1", + 3: "Exchange2007_SP1", }, 14: { - 0: 'Exchange2010', - 1: 'Exchange2010_SP1', - 2: 'Exchange2010_SP2', - 3: 'Exchange2010_SP2', + 0: "Exchange2010", + 1: "Exchange2010_SP1", + 2: "Exchange2010_SP2", + 3: "Exchange2010_SP2", }, 15: { - 0: 'Exchange2013', # Minor builds starting from 847 are Exchange2013_SP1, see api_version() - 1: 'Exchange2016', - 2: 'Exchange2019', - 20: 'Exchange2016', # This is Office365. See issue #221 + 0: "Exchange2013", # Minor builds starting from 847 are Exchange2013_SP1, see api_version() + 1: "Exchange2016", + 2: "Exchange2019", + 20: "Exchange2016", # This is Office365. See issue #221 }, } - __slots__ = 'major_version', 'minor_version', 'major_build', 'minor_build' + __slots__ = "major_version", "minor_version", "major_build", "minor_build" def __init__(self, major_version, minor_version, major_build=0, minor_build=0): if not isinstance(major_version, int): - raise InvalidTypeError('major_version', major_version, int) + raise InvalidTypeError("major_version", major_version, int) if not isinstance(minor_version, int): - raise InvalidTypeError('minor_version', minor_version, int) + raise InvalidTypeError("minor_version", minor_version, int) if not isinstance(major_build, int): - raise InvalidTypeError('major_build', major_build, int) + raise InvalidTypeError("major_build", major_build, int) if not isinstance(minor_build, int): - raise InvalidTypeError('minor_build', minor_build, int) + raise InvalidTypeError("minor_build", minor_build, int) self.major_version = major_version self.minor_version = minor_version self.major_build = major_build @@ -83,10 +83,10 @@ def __init__(self, major_version, minor_version, major_build=0, minor_build=0): @classmethod def from_xml(cls, elem): xml_elems_map = { - 'major_version': 'MajorVersion', - 'minor_version': 'MinorVersion', - 'major_build': 'MajorBuildNumber', - 'minor_build': 'MinorBuildNumber', + "major_version": "MajorVersion", + "minor_version": "MinorVersion", + "major_build": "MajorBuildNumber", + "minor_build": "MinorBuildNumber", } kwargs = {} for k, xml_elem in xml_elems_map.items(): @@ -110,7 +110,7 @@ def from_hex_string(cls, s): :param s: """ - bin_s = f'{int(s, 16):032b}' # Convert string to 32-bit binary string + bin_s = f"{int(s, 16):032b}" # Convert string to 32-bit binary string major_version = int(bin_s[4:10], 2) minor_version = int(bin_s[10:16], 2) build_number = int(bin_s[17:32], 2) @@ -118,11 +118,11 @@ def from_hex_string(cls, s): def api_version(self): if EXCHANGE_2013_SP1 <= self < EXCHANGE_2016: - return 'Exchange2013_SP1' + return "Exchange2013_SP1" try: return self.API_VERSION_MAP[self.major_version][self.minor_version] except KeyError: - raise ValueError(f'API version for build {self} is unknown') + raise ValueError(f"API version for build {self} is unknown") def fullname(self): return VERSIONS[self.api_version()][1] @@ -162,11 +162,12 @@ def __ge__(self, other): return self.__cmp__(other) >= 0 def __str__(self): - return f'{self.major_version}.{self.minor_version}.{self.major_build}.{self.minor_build}' + return f"{self.major_version}.{self.minor_version}.{self.major_build}.{self.minor_build}" def __repr__(self): - return self.__class__.__name__ \ - + repr((self.major_version, self.minor_version, self.major_build, self.minor_build)) + return self.__class__.__name__ + repr( + (self.major_version, self.minor_version, self.major_build, self.minor_build) + ) # Helpers for comparison operations elsewhere in this package @@ -185,18 +186,18 @@ def __repr__(self): class Version: """Holds information about the server version.""" - __slots__ = 'build', 'api_version' + __slots__ = "build", "api_version" def __init__(self, build, api_version=None): if api_version is None: if not isinstance(build, Build): - raise InvalidTypeError('build', build, Build) + raise InvalidTypeError("build", build, Build) self.api_version = build.api_version() else: if not isinstance(build, (Build, type(None))): - raise InvalidTypeError('build', build, Build) + raise InvalidTypeError("build", build, Build) if not isinstance(api_version, str): - raise InvalidTypeError('api_version', api_version, str) + raise InvalidTypeError("api_version", api_version, str) self.api_version = api_version self.build = build @@ -217,53 +218,62 @@ def guess(cls, protocol, api_version_hint=None): :param api_version_hint: (Default value = None) """ from .services import ResolveNames + # The protocol doesn't have a version yet, so default to latest supported version if we don't have a hint. api_version = api_version_hint or API_VERSIONS[0] - log.debug('Asking server for version info using API version %s', api_version) + log.debug("Asking server for version info using API version %s", api_version) # We don't know the build version yet. Hopefully, the server will report it in the SOAP header. Lots of # places expect a version to have a build, so this is a bit dangerous, but passing a fake build around is also # dangerous. Make sure the call to ResolveNames does not require a version build. protocol.config.version = Version(build=None, api_version=api_version) # Use ResolveNames as a minimal request to the server to test if the version is correct. If not, ResolveNames # will try to guess the version automatically. - name = str(protocol.credentials) if protocol.credentials and str(protocol.credentials) else 'DUMMY' + name = str(protocol.credentials) if protocol.credentials and str(protocol.credentials) else "DUMMY" try: list(ResolveNames(protocol=protocol).call(unresolved_entries=[name])) except ResponseMessageError as e: # We may have survived long enough to get a new version if not protocol.config.version.build: - raise TransportError(f'No valid version headers found in response ({e!r})') + raise TransportError(f"No valid version headers found in response ({e!r})") if not protocol.config.version.build: - raise TransportError('No valid version headers found in response') + raise TransportError("No valid version headers found in response") return protocol.config.version @staticmethod def _is_invalid_version_string(version): # Check if a version string is bogus, e.g. V2_, V2015_ or V2018_ - return re.match(r'V[0-9]{1,4}_.*', version) + return re.match(r"V[0-9]{1,4}_.*", version) @classmethod def from_soap_header(cls, requested_api_version, header): - info = header.find(f'{{{TNS}}}ServerVersionInfo') + info = header.find(f"{{{TNS}}}ServerVersionInfo") if info is None: - raise TransportError(f'No ServerVersionInfo in header: {xml_to_str(header)!r}') + raise TransportError(f"No ServerVersionInfo in header: {xml_to_str(header)!r}") try: build = Build.from_xml(elem=info) except ValueError: - raise TransportError(f'Bad ServerVersionInfo in response: {xml_to_str(header)!r}') + raise TransportError(f"Bad ServerVersionInfo in response: {xml_to_str(header)!r}") # Not all Exchange servers send the Version element - api_version_from_server = info.get('Version') or build.api_version() + api_version_from_server = info.get("Version") or build.api_version() if api_version_from_server != requested_api_version: if cls._is_invalid_version_string(api_version_from_server): # For unknown reasons, Office 365 may respond with an API version strings that is invalid in a request. # Detect these so we can fallback to a valid version string. - log.debug('API version "%s" worked but server reports version "%s". Using "%s"', requested_api_version, - api_version_from_server, requested_api_version) + log.debug( + 'API version "%s" worked but server reports version "%s". Using "%s"', + requested_api_version, + api_version_from_server, + requested_api_version, + ) api_version_from_server = requested_api_version else: # Trust API version from server response - log.debug('API version "%s" worked but server reports version "%s". Using "%s"', requested_api_version, - api_version_from_server, api_version_from_server) + log.debug( + 'API version "%s" worked but server reports version "%s". Using "%s"', + requested_api_version, + api_version_from_server, + api_version_from_server, + ) return cls(build=build, api_version=api_version_from_server) def copy(self): @@ -282,4 +292,4 @@ def __repr__(self): return self.__class__.__name__ + repr((self.build, self.api_version)) def __str__(self): - return f'Build={self.build}, API={self.api_version}, Fullname={self.fullname}' + return f"Build={self.build}, API={self.api_version}, Fullname={self.fullname}" diff --git a/exchangelib/winzone.py b/exchangelib/winzone.py index 7d6055d5..78d3631c 100644 --- a/exchangelib/winzone.py +++ b/exchangelib/winzone.py @@ -6,10 +6,10 @@ from .util import to_xml -CLDR_WINZONE_URL = 'https://raw.githubusercontent.com/unicode-org/cldr/master/common/supplemental/windowsZones.xml' -DEFAULT_TERRITORY = '001' -CLDR_WINZONE_TYPE_VERSION = '2021a' -CLDR_WINZONE_OTHER_VERSION = '7e11800' +CLDR_WINZONE_URL = "https://raw.githubusercontent.com/unicode-org/cldr/master/common/supplemental/windowsZones.xml" +DEFAULT_TERRITORY = "001" +CLDR_WINZONE_TYPE_VERSION = "2021a" +CLDR_WINZONE_OTHER_VERSION = "7e11800" def generate_map(timeout=10): @@ -20,17 +20,17 @@ def generate_map(timeout=10): """ r = requests.get(CLDR_WINZONE_URL, timeout=timeout) if r.status_code != 200: - raise ValueError(f'Unexpected response: {r}') + raise ValueError(f"Unexpected response: {r}") tz_map = {} - timezones_elem = to_xml(r.content).find('windowsZones').find('mapTimezones') - type_version = timezones_elem.get('typeVersion') - other_version = timezones_elem.get('otherVersion') - for e in timezones_elem.findall('mapZone'): - for location in re.split(r'\s+', e.get('type')): - if e.get('territory') == DEFAULT_TERRITORY or location not in tz_map: + timezones_elem = to_xml(r.content).find("windowsZones").find("mapTimezones") + type_version = timezones_elem.get("typeVersion") + other_version = timezones_elem.get("otherVersion") + for e in timezones_elem.findall("mapZone"): + for location in re.split(r"\s+", e.get("type")): + if e.get("territory") == DEFAULT_TERRITORY or location not in tz_map: # Prefer default territory. This is so MS_TIMEZONE_TO_IANA_MAP maps from MS timezone ID back to the # "preferred" region/location timezone name. - tz_map[location] = e.get('other'), e.get('territory') + tz_map[location] = e.get("other"), e.get("territory") return type_version, other_version, tz_map @@ -39,465 +39,465 @@ def generate_map(timeout=10): # # This list was generated from CLDR_WINZONE_URL version CLDR_WINZONE_VERSION. CLDR_TO_MS_TIMEZONE_MAP = { - 'Africa/Abidjan': ('Greenwich Standard Time', 'CI'), - 'Africa/Accra': ('Greenwich Standard Time', 'GH'), - 'Africa/Addis_Ababa': ('E. Africa Standard Time', 'ET'), - 'Africa/Algiers': ('W. Central Africa Standard Time', 'DZ'), - 'Africa/Asmera': ('E. Africa Standard Time', 'ER'), - 'Africa/Bamako': ('Greenwich Standard Time', 'ML'), - 'Africa/Bangui': ('W. Central Africa Standard Time', 'CF'), - 'Africa/Banjul': ('Greenwich Standard Time', 'GM'), - 'Africa/Bissau': ('Greenwich Standard Time', 'GW'), - 'Africa/Blantyre': ('South Africa Standard Time', 'MW'), - 'Africa/Brazzaville': ('W. Central Africa Standard Time', 'CG'), - 'Africa/Bujumbura': ('South Africa Standard Time', 'BI'), - 'Africa/Cairo': ('Egypt Standard Time', '001'), - 'Africa/Casablanca': ('Morocco Standard Time', '001'), - 'Africa/Ceuta': ('Romance Standard Time', 'ES'), - 'Africa/Conakry': ('Greenwich Standard Time', 'GN'), - 'Africa/Dakar': ('Greenwich Standard Time', 'SN'), - 'Africa/Dar_es_Salaam': ('E. Africa Standard Time', 'TZ'), - 'Africa/Djibouti': ('E. Africa Standard Time', 'DJ'), - 'Africa/Douala': ('W. Central Africa Standard Time', 'CM'), - 'Africa/El_Aaiun': ('Morocco Standard Time', 'EH'), - 'Africa/Freetown': ('Greenwich Standard Time', 'SL'), - 'Africa/Gaborone': ('South Africa Standard Time', 'BW'), - 'Africa/Harare': ('South Africa Standard Time', 'ZW'), - 'Africa/Johannesburg': ('South Africa Standard Time', '001'), - 'Africa/Juba': ('South Sudan Standard Time', '001'), - 'Africa/Kampala': ('E. Africa Standard Time', 'UG'), - 'Africa/Khartoum': ('Sudan Standard Time', '001'), - 'Africa/Kigali': ('South Africa Standard Time', 'RW'), - 'Africa/Kinshasa': ('W. Central Africa Standard Time', 'CD'), - 'Africa/Lagos': ('W. Central Africa Standard Time', '001'), - 'Africa/Libreville': ('W. Central Africa Standard Time', 'GA'), - 'Africa/Lome': ('Greenwich Standard Time', 'TG'), - 'Africa/Luanda': ('W. Central Africa Standard Time', 'AO'), - 'Africa/Lubumbashi': ('South Africa Standard Time', 'CD'), - 'Africa/Lusaka': ('South Africa Standard Time', 'ZM'), - 'Africa/Malabo': ('W. Central Africa Standard Time', 'GQ'), - 'Africa/Maputo': ('South Africa Standard Time', 'MZ'), - 'Africa/Maseru': ('South Africa Standard Time', 'LS'), - 'Africa/Mbabane': ('South Africa Standard Time', 'SZ'), - 'Africa/Mogadishu': ('E. Africa Standard Time', 'SO'), - 'Africa/Monrovia': ('Greenwich Standard Time', 'LR'), - 'Africa/Nairobi': ('E. Africa Standard Time', '001'), - 'Africa/Ndjamena': ('W. Central Africa Standard Time', 'TD'), - 'Africa/Niamey': ('W. Central Africa Standard Time', 'NE'), - 'Africa/Nouakchott': ('Greenwich Standard Time', 'MR'), - 'Africa/Ouagadougou': ('Greenwich Standard Time', 'BF'), - 'Africa/Porto-Novo': ('W. Central Africa Standard Time', 'BJ'), - 'Africa/Sao_Tome': ('Sao Tome Standard Time', '001'), - 'Africa/Tripoli': ('Libya Standard Time', '001'), - 'Africa/Tunis': ('W. Central Africa Standard Time', 'TN'), - 'Africa/Windhoek': ('Namibia Standard Time', '001'), - 'America/Adak': ('Aleutian Standard Time', '001'), - 'America/Anchorage': ('Alaskan Standard Time', '001'), - 'America/Anguilla': ('SA Western Standard Time', 'AI'), - 'America/Antigua': ('SA Western Standard Time', 'AG'), - 'America/Araguaina': ('Tocantins Standard Time', '001'), - 'America/Argentina/La_Rioja': ('Argentina Standard Time', 'AR'), - 'America/Argentina/Rio_Gallegos': ('Argentina Standard Time', 'AR'), - 'America/Argentina/Salta': ('Argentina Standard Time', 'AR'), - 'America/Argentina/San_Juan': ('Argentina Standard Time', 'AR'), - 'America/Argentina/San_Luis': ('Argentina Standard Time', 'AR'), - 'America/Argentina/Tucuman': ('Argentina Standard Time', 'AR'), - 'America/Argentina/Ushuaia': ('Argentina Standard Time', 'AR'), - 'America/Aruba': ('SA Western Standard Time', 'AW'), - 'America/Asuncion': ('Paraguay Standard Time', '001'), - 'America/Bahia': ('Bahia Standard Time', '001'), - 'America/Bahia_Banderas': ('Central Standard Time (Mexico)', 'MX'), - 'America/Barbados': ('SA Western Standard Time', 'BB'), - 'America/Belem': ('SA Eastern Standard Time', 'BR'), - 'America/Belize': ('Central America Standard Time', 'BZ'), - 'America/Blanc-Sablon': ('SA Western Standard Time', 'CA'), - 'America/Boa_Vista': ('SA Western Standard Time', 'BR'), - 'America/Bogota': ('SA Pacific Standard Time', '001'), - 'America/Boise': ('Mountain Standard Time', 'US'), - 'America/Buenos_Aires': ('Argentina Standard Time', '001'), - 'America/Cambridge_Bay': ('Mountain Standard Time', 'CA'), - 'America/Campo_Grande': ('Central Brazilian Standard Time', 'BR'), - 'America/Cancun': ('Eastern Standard Time (Mexico)', '001'), - 'America/Caracas': ('Venezuela Standard Time', '001'), - 'America/Catamarca': ('Argentina Standard Time', 'AR'), - 'America/Cayenne': ('SA Eastern Standard Time', '001'), - 'America/Cayman': ('SA Pacific Standard Time', 'KY'), - 'America/Chicago': ('Central Standard Time', '001'), - 'America/Chihuahua': ('Mountain Standard Time (Mexico)', '001'), - 'America/Coral_Harbour': ('SA Pacific Standard Time', 'CA'), - 'America/Cordoba': ('Argentina Standard Time', 'AR'), - 'America/Costa_Rica': ('Central America Standard Time', 'CR'), - 'America/Creston': ('US Mountain Standard Time', 'CA'), - 'America/Cuiaba': ('Central Brazilian Standard Time', '001'), - 'America/Curacao': ('SA Western Standard Time', 'CW'), - 'America/Danmarkshavn': ('Greenwich Standard Time', 'GL'), - 'America/Dawson': ('Yukon Standard Time', 'CA'), - 'America/Dawson_Creek': ('US Mountain Standard Time', 'CA'), - 'America/Denver': ('Mountain Standard Time', '001'), - 'America/Detroit': ('Eastern Standard Time', 'US'), - 'America/Dominica': ('SA Western Standard Time', 'DM'), - 'America/Edmonton': ('Mountain Standard Time', 'CA'), - 'America/Eirunepe': ('SA Pacific Standard Time', 'BR'), - 'America/El_Salvador': ('Central America Standard Time', 'SV'), - 'America/Fort_Nelson': ('US Mountain Standard Time', 'CA'), - 'America/Fortaleza': ('SA Eastern Standard Time', 'BR'), - 'America/Glace_Bay': ('Atlantic Standard Time', 'CA'), - 'America/Godthab': ('Greenland Standard Time', '001'), - 'America/Goose_Bay': ('Atlantic Standard Time', 'CA'), - 'America/Grand_Turk': ('Turks And Caicos Standard Time', '001'), - 'America/Grenada': ('SA Western Standard Time', 'GD'), - 'America/Guadeloupe': ('SA Western Standard Time', 'GP'), - 'America/Guatemala': ('Central America Standard Time', '001'), - 'America/Guayaquil': ('SA Pacific Standard Time', 'EC'), - 'America/Guyana': ('SA Western Standard Time', 'GY'), - 'America/Halifax': ('Atlantic Standard Time', '001'), - 'America/Havana': ('Cuba Standard Time', '001'), - 'America/Hermosillo': ('US Mountain Standard Time', 'MX'), - 'America/Indiana/Knox': ('Central Standard Time', 'US'), - 'America/Indiana/Marengo': ('US Eastern Standard Time', 'US'), - 'America/Indiana/Petersburg': ('Eastern Standard Time', 'US'), - 'America/Indiana/Tell_City': ('Central Standard Time', 'US'), - 'America/Indiana/Vevay': ('US Eastern Standard Time', 'US'), - 'America/Indiana/Vincennes': ('Eastern Standard Time', 'US'), - 'America/Indiana/Winamac': ('Eastern Standard Time', 'US'), - 'America/Indianapolis': ('US Eastern Standard Time', '001'), - 'America/Inuvik': ('Mountain Standard Time', 'CA'), - 'America/Iqaluit': ('Eastern Standard Time', 'CA'), - 'America/Jamaica': ('SA Pacific Standard Time', 'JM'), - 'America/Jujuy': ('Argentina Standard Time', 'AR'), - 'America/Juneau': ('Alaskan Standard Time', 'US'), - 'America/Kentucky/Monticello': ('Eastern Standard Time', 'US'), - 'America/Kralendijk': ('SA Western Standard Time', 'BQ'), - 'America/La_Paz': ('SA Western Standard Time', '001'), - 'America/Lima': ('SA Pacific Standard Time', 'PE'), - 'America/Los_Angeles': ('Pacific Standard Time', '001'), - 'America/Louisville': ('Eastern Standard Time', 'US'), - 'America/Lower_Princes': ('SA Western Standard Time', 'SX'), - 'America/Maceio': ('SA Eastern Standard Time', 'BR'), - 'America/Managua': ('Central America Standard Time', 'NI'), - 'America/Manaus': ('SA Western Standard Time', 'BR'), - 'America/Marigot': ('SA Western Standard Time', 'MF'), - 'America/Martinique': ('SA Western Standard Time', 'MQ'), - 'America/Matamoros': ('Central Standard Time', 'MX'), - 'America/Mazatlan': ('Mountain Standard Time (Mexico)', 'MX'), - 'America/Mendoza': ('Argentina Standard Time', 'AR'), - 'America/Menominee': ('Central Standard Time', 'US'), - 'America/Merida': ('Central Standard Time (Mexico)', 'MX'), - 'America/Metlakatla': ('Alaskan Standard Time', 'US'), - 'America/Mexico_City': ('Central Standard Time (Mexico)', '001'), - 'America/Miquelon': ('Saint Pierre Standard Time', '001'), - 'America/Moncton': ('Atlantic Standard Time', 'CA'), - 'America/Monterrey': ('Central Standard Time (Mexico)', 'MX'), - 'America/Montevideo': ('Montevideo Standard Time', '001'), - 'America/Montreal': ('Eastern Standard Time', 'CA'), - 'America/Montserrat': ('SA Western Standard Time', 'MS'), - 'America/Nassau': ('Eastern Standard Time', 'BS'), - 'America/New_York': ('Eastern Standard Time', '001'), - 'America/Nipigon': ('Eastern Standard Time', 'CA'), - 'America/Nome': ('Alaskan Standard Time', 'US'), - 'America/Noronha': ('UTC-02', 'BR'), - 'America/North_Dakota/Beulah': ('Central Standard Time', 'US'), - 'America/North_Dakota/Center': ('Central Standard Time', 'US'), - 'America/North_Dakota/New_Salem': ('Central Standard Time', 'US'), - 'America/Ojinaga': ('Mountain Standard Time', 'MX'), - 'America/Panama': ('SA Pacific Standard Time', 'PA'), - 'America/Pangnirtung': ('Eastern Standard Time', 'CA'), - 'America/Paramaribo': ('SA Eastern Standard Time', 'SR'), - 'America/Phoenix': ('US Mountain Standard Time', '001'), - 'America/Port-au-Prince': ('Haiti Standard Time', '001'), - 'America/Port_of_Spain': ('SA Western Standard Time', 'TT'), - 'America/Porto_Velho': ('SA Western Standard Time', 'BR'), - 'America/Puerto_Rico': ('SA Western Standard Time', 'PR'), - 'America/Punta_Arenas': ('Magallanes Standard Time', '001'), - 'America/Rainy_River': ('Central Standard Time', 'CA'), - 'America/Rankin_Inlet': ('Central Standard Time', 'CA'), - 'America/Recife': ('SA Eastern Standard Time', 'BR'), - 'America/Regina': ('Canada Central Standard Time', '001'), - 'America/Resolute': ('Central Standard Time', 'CA'), - 'America/Rio_Branco': ('SA Pacific Standard Time', 'BR'), - 'America/Santa_Isabel': ('Pacific Standard Time (Mexico)', 'MX'), - 'America/Santarem': ('SA Eastern Standard Time', 'BR'), - 'America/Santiago': ('Pacific SA Standard Time', '001'), - 'America/Santo_Domingo': ('SA Western Standard Time', 'DO'), - 'America/Sao_Paulo': ('E. South America Standard Time', '001'), - 'America/Scoresbysund': ('Azores Standard Time', 'GL'), - 'America/Sitka': ('Alaskan Standard Time', 'US'), - 'America/St_Barthelemy': ('SA Western Standard Time', 'BL'), - 'America/St_Johns': ('Newfoundland Standard Time', '001'), - 'America/St_Kitts': ('SA Western Standard Time', 'KN'), - 'America/St_Lucia': ('SA Western Standard Time', 'LC'), - 'America/St_Thomas': ('SA Western Standard Time', 'VI'), - 'America/St_Vincent': ('SA Western Standard Time', 'VC'), - 'America/Swift_Current': ('Canada Central Standard Time', 'CA'), - 'America/Tegucigalpa': ('Central America Standard Time', 'HN'), - 'America/Thule': ('Atlantic Standard Time', 'GL'), - 'America/Thunder_Bay': ('Eastern Standard Time', 'CA'), - 'America/Tijuana': ('Pacific Standard Time (Mexico)', '001'), - 'America/Toronto': ('Eastern Standard Time', 'CA'), - 'America/Tortola': ('SA Western Standard Time', 'VG'), - 'America/Vancouver': ('Pacific Standard Time', 'CA'), - 'America/Whitehorse': ('Yukon Standard Time', '001'), - 'America/Winnipeg': ('Central Standard Time', 'CA'), - 'America/Yakutat': ('Alaskan Standard Time', 'US'), - 'America/Yellowknife': ('Mountain Standard Time', 'CA'), - 'Antarctica/Casey': ('Central Pacific Standard Time', 'AQ'), - 'Antarctica/Davis': ('SE Asia Standard Time', 'AQ'), - 'Antarctica/DumontDUrville': ('West Pacific Standard Time', 'AQ'), - 'Antarctica/Macquarie': ('Tasmania Standard Time', 'AU'), - 'Antarctica/Mawson': ('West Asia Standard Time', 'AQ'), - 'Antarctica/McMurdo': ('New Zealand Standard Time', 'AQ'), - 'Antarctica/Palmer': ('SA Eastern Standard Time', 'AQ'), - 'Antarctica/Rothera': ('SA Eastern Standard Time', 'AQ'), - 'Antarctica/Syowa': ('E. Africa Standard Time', 'AQ'), - 'Antarctica/Vostok': ('Central Asia Standard Time', 'AQ'), - 'Arctic/Longyearbyen': ('W. Europe Standard Time', 'SJ'), - 'Asia/Aden': ('Arab Standard Time', 'YE'), - 'Asia/Almaty': ('Central Asia Standard Time', '001'), - 'Asia/Amman': ('Jordan Standard Time', '001'), - 'Asia/Anadyr': ('Russia Time Zone 11', 'RU'), - 'Asia/Aqtau': ('West Asia Standard Time', 'KZ'), - 'Asia/Aqtobe': ('West Asia Standard Time', 'KZ'), - 'Asia/Ashgabat': ('West Asia Standard Time', 'TM'), - 'Asia/Atyrau': ('West Asia Standard Time', 'KZ'), - 'Asia/Baghdad': ('Arabic Standard Time', '001'), - 'Asia/Bahrain': ('Arab Standard Time', 'BH'), - 'Asia/Baku': ('Azerbaijan Standard Time', '001'), - 'Asia/Bangkok': ('SE Asia Standard Time', '001'), - 'Asia/Barnaul': ('Altai Standard Time', '001'), - 'Asia/Beirut': ('Middle East Standard Time', '001'), - 'Asia/Bishkek': ('Central Asia Standard Time', 'KG'), - 'Asia/Brunei': ('Singapore Standard Time', 'BN'), - 'Asia/Calcutta': ('India Standard Time', '001'), - 'Asia/Chita': ('Transbaikal Standard Time', '001'), - 'Asia/Choibalsan': ('Ulaanbaatar Standard Time', 'MN'), - 'Asia/Colombo': ('Sri Lanka Standard Time', '001'), - 'Asia/Damascus': ('Syria Standard Time', '001'), - 'Asia/Dhaka': ('Bangladesh Standard Time', '001'), - 'Asia/Dili': ('Tokyo Standard Time', 'TL'), - 'Asia/Dubai': ('Arabian Standard Time', '001'), - 'Asia/Dushanbe': ('West Asia Standard Time', 'TJ'), - 'Asia/Famagusta': ('GTB Standard Time', 'CY'), - 'Asia/Gaza': ('West Bank Standard Time', 'PS'), - 'Asia/Hebron': ('West Bank Standard Time', '001'), - 'Asia/Hong_Kong': ('China Standard Time', 'HK'), - 'Asia/Hovd': ('W. Mongolia Standard Time', '001'), - 'Asia/Irkutsk': ('North Asia East Standard Time', '001'), - 'Asia/Jakarta': ('SE Asia Standard Time', 'ID'), - 'Asia/Jayapura': ('Tokyo Standard Time', 'ID'), - 'Asia/Jerusalem': ('Israel Standard Time', '001'), - 'Asia/Kabul': ('Afghanistan Standard Time', '001'), - 'Asia/Kamchatka': ('Russia Time Zone 11', '001'), - 'Asia/Karachi': ('Pakistan Standard Time', '001'), - 'Asia/Katmandu': ('Nepal Standard Time', '001'), - 'Asia/Khandyga': ('Yakutsk Standard Time', 'RU'), - 'Asia/Krasnoyarsk': ('North Asia Standard Time', '001'), - 'Asia/Kuala_Lumpur': ('Singapore Standard Time', 'MY'), - 'Asia/Kuching': ('Singapore Standard Time', 'MY'), - 'Asia/Kuwait': ('Arab Standard Time', 'KW'), - 'Asia/Macau': ('China Standard Time', 'MO'), - 'Asia/Magadan': ('Magadan Standard Time', '001'), - 'Asia/Makassar': ('Singapore Standard Time', 'ID'), - 'Asia/Manila': ('Singapore Standard Time', 'PH'), - 'Asia/Muscat': ('Arabian Standard Time', 'OM'), - 'Asia/Nicosia': ('GTB Standard Time', 'CY'), - 'Asia/Novokuznetsk': ('North Asia Standard Time', 'RU'), - 'Asia/Novosibirsk': ('N. Central Asia Standard Time', '001'), - 'Asia/Omsk': ('Omsk Standard Time', '001'), - 'Asia/Oral': ('West Asia Standard Time', 'KZ'), - 'Asia/Phnom_Penh': ('SE Asia Standard Time', 'KH'), - 'Asia/Pontianak': ('SE Asia Standard Time', 'ID'), - 'Asia/Pyongyang': ('North Korea Standard Time', '001'), - 'Asia/Qatar': ('Arab Standard Time', 'QA'), - 'Asia/Qostanay': ('Central Asia Standard Time', 'KZ'), - 'Asia/Qyzylorda': ('Qyzylorda Standard Time', '001'), - 'Asia/Rangoon': ('Myanmar Standard Time', '001'), - 'Asia/Riyadh': ('Arab Standard Time', '001'), - 'Asia/Saigon': ('SE Asia Standard Time', 'VN'), - 'Asia/Sakhalin': ('Sakhalin Standard Time', '001'), - 'Asia/Samarkand': ('West Asia Standard Time', 'UZ'), - 'Asia/Seoul': ('Korea Standard Time', '001'), - 'Asia/Shanghai': ('China Standard Time', '001'), - 'Asia/Singapore': ('Singapore Standard Time', '001'), - 'Asia/Srednekolymsk': ('Russia Time Zone 10', '001'), - 'Asia/Taipei': ('Taipei Standard Time', '001'), - 'Asia/Tashkent': ('West Asia Standard Time', '001'), - 'Asia/Tbilisi': ('Georgian Standard Time', '001'), - 'Asia/Tehran': ('Iran Standard Time', '001'), - 'Asia/Thimphu': ('Bangladesh Standard Time', 'BT'), - 'Asia/Tokyo': ('Tokyo Standard Time', '001'), - 'Asia/Tomsk': ('Tomsk Standard Time', '001'), - 'Asia/Ulaanbaatar': ('Ulaanbaatar Standard Time', '001'), - 'Asia/Urumqi': ('Central Asia Standard Time', 'CN'), - 'Asia/Ust-Nera': ('Vladivostok Standard Time', 'RU'), - 'Asia/Vientiane': ('SE Asia Standard Time', 'LA'), - 'Asia/Vladivostok': ('Vladivostok Standard Time', '001'), - 'Asia/Yakutsk': ('Yakutsk Standard Time', '001'), - 'Asia/Yekaterinburg': ('Ekaterinburg Standard Time', '001'), - 'Asia/Yerevan': ('Caucasus Standard Time', '001'), - 'Atlantic/Azores': ('Azores Standard Time', '001'), - 'Atlantic/Bermuda': ('Atlantic Standard Time', 'BM'), - 'Atlantic/Canary': ('GMT Standard Time', 'ES'), - 'Atlantic/Cape_Verde': ('Cape Verde Standard Time', '001'), - 'Atlantic/Faeroe': ('GMT Standard Time', 'FO'), - 'Atlantic/Madeira': ('GMT Standard Time', 'PT'), - 'Atlantic/Reykjavik': ('Greenwich Standard Time', '001'), - 'Atlantic/South_Georgia': ('UTC-02', 'GS'), - 'Atlantic/St_Helena': ('Greenwich Standard Time', 'SH'), - 'Atlantic/Stanley': ('SA Eastern Standard Time', 'FK'), - 'Australia/Adelaide': ('Cen. Australia Standard Time', '001'), - 'Australia/Brisbane': ('E. Australia Standard Time', '001'), - 'Australia/Broken_Hill': ('Cen. Australia Standard Time', 'AU'), - 'Australia/Currie': ('Tasmania Standard Time', 'AU'), - 'Australia/Darwin': ('AUS Central Standard Time', '001'), - 'Australia/Eucla': ('Aus Central W. Standard Time', '001'), - 'Australia/Hobart': ('Tasmania Standard Time', '001'), - 'Australia/Lindeman': ('E. Australia Standard Time', 'AU'), - 'Australia/Lord_Howe': ('Lord Howe Standard Time', '001'), - 'Australia/Melbourne': ('AUS Eastern Standard Time', 'AU'), - 'Australia/Perth': ('W. Australia Standard Time', '001'), - 'Australia/Sydney': ('AUS Eastern Standard Time', '001'), - 'CST6CDT': ('Central Standard Time', 'ZZ'), - 'EST5EDT': ('Eastern Standard Time', 'ZZ'), - 'Etc/GMT': ('UTC', 'ZZ'), - 'Etc/GMT+1': ('Cape Verde Standard Time', 'ZZ'), - 'Etc/GMT+10': ('Hawaiian Standard Time', 'ZZ'), - 'Etc/GMT+11': ('UTC-11', '001'), - 'Etc/GMT+12': ('Dateline Standard Time', '001'), - 'Etc/GMT+2': ('UTC-02', '001'), - 'Etc/GMT+3': ('SA Eastern Standard Time', 'ZZ'), - 'Etc/GMT+4': ('SA Western Standard Time', 'ZZ'), - 'Etc/GMT+5': ('SA Pacific Standard Time', 'ZZ'), - 'Etc/GMT+6': ('Central America Standard Time', 'ZZ'), - 'Etc/GMT+7': ('US Mountain Standard Time', 'ZZ'), - 'Etc/GMT+8': ('UTC-08', '001'), - 'Etc/GMT+9': ('UTC-09', '001'), - 'Etc/GMT-1': ('W. Central Africa Standard Time', 'ZZ'), - 'Etc/GMT-10': ('West Pacific Standard Time', 'ZZ'), - 'Etc/GMT-11': ('Central Pacific Standard Time', 'ZZ'), - 'Etc/GMT-12': ('UTC+12', '001'), - 'Etc/GMT-13': ('UTC+13', '001'), - 'Etc/GMT-14': ('Line Islands Standard Time', 'ZZ'), - 'Etc/GMT-2': ('South Africa Standard Time', 'ZZ'), - 'Etc/GMT-3': ('E. Africa Standard Time', 'ZZ'), - 'Etc/GMT-4': ('Arabian Standard Time', 'ZZ'), - 'Etc/GMT-5': ('West Asia Standard Time', 'ZZ'), - 'Etc/GMT-6': ('Central Asia Standard Time', 'ZZ'), - 'Etc/GMT-7': ('SE Asia Standard Time', 'ZZ'), - 'Etc/GMT-8': ('Singapore Standard Time', 'ZZ'), - 'Etc/GMT-9': ('Tokyo Standard Time', 'ZZ'), - 'Etc/UTC': ('UTC', '001'), - 'Europe/Amsterdam': ('W. Europe Standard Time', 'NL'), - 'Europe/Andorra': ('W. Europe Standard Time', 'AD'), - 'Europe/Astrakhan': ('Astrakhan Standard Time', '001'), - 'Europe/Athens': ('GTB Standard Time', 'GR'), - 'Europe/Belgrade': ('Central Europe Standard Time', 'RS'), - 'Europe/Berlin': ('W. Europe Standard Time', '001'), - 'Europe/Bratislava': ('Central Europe Standard Time', 'SK'), - 'Europe/Brussels': ('Romance Standard Time', 'BE'), - 'Europe/Bucharest': ('GTB Standard Time', '001'), - 'Europe/Budapest': ('Central Europe Standard Time', '001'), - 'Europe/Busingen': ('W. Europe Standard Time', 'DE'), - 'Europe/Chisinau': ('E. Europe Standard Time', '001'), - 'Europe/Copenhagen': ('Romance Standard Time', 'DK'), - 'Europe/Dublin': ('GMT Standard Time', 'IE'), - 'Europe/Gibraltar': ('W. Europe Standard Time', 'GI'), - 'Europe/Guernsey': ('GMT Standard Time', 'GG'), - 'Europe/Helsinki': ('FLE Standard Time', 'FI'), - 'Europe/Isle_of_Man': ('GMT Standard Time', 'IM'), - 'Europe/Istanbul': ('Turkey Standard Time', '001'), - 'Europe/Jersey': ('GMT Standard Time', 'JE'), - 'Europe/Kaliningrad': ('Kaliningrad Standard Time', '001'), - 'Europe/Kiev': ('FLE Standard Time', '001'), - 'Europe/Kirov': ('Russian Standard Time', 'RU'), - 'Europe/Lisbon': ('GMT Standard Time', 'PT'), - 'Europe/Ljubljana': ('Central Europe Standard Time', 'SI'), - 'Europe/London': ('GMT Standard Time', '001'), - 'Europe/Luxembourg': ('W. Europe Standard Time', 'LU'), - 'Europe/Madrid': ('Romance Standard Time', 'ES'), - 'Europe/Malta': ('W. Europe Standard Time', 'MT'), - 'Europe/Mariehamn': ('FLE Standard Time', 'AX'), - 'Europe/Minsk': ('Belarus Standard Time', '001'), - 'Europe/Monaco': ('W. Europe Standard Time', 'MC'), - 'Europe/Moscow': ('Russian Standard Time', '001'), - 'Europe/Oslo': ('W. Europe Standard Time', 'NO'), - 'Europe/Paris': ('Romance Standard Time', '001'), - 'Europe/Podgorica': ('Central Europe Standard Time', 'ME'), - 'Europe/Prague': ('Central Europe Standard Time', 'CZ'), - 'Europe/Riga': ('FLE Standard Time', 'LV'), - 'Europe/Rome': ('W. Europe Standard Time', 'IT'), - 'Europe/Samara': ('Russia Time Zone 3', '001'), - 'Europe/San_Marino': ('W. Europe Standard Time', 'SM'), - 'Europe/Sarajevo': ('Central European Standard Time', 'BA'), - 'Europe/Saratov': ('Saratov Standard Time', '001'), - 'Europe/Simferopol': ('Russian Standard Time', 'UA'), - 'Europe/Skopje': ('Central European Standard Time', 'MK'), - 'Europe/Sofia': ('FLE Standard Time', 'BG'), - 'Europe/Stockholm': ('W. Europe Standard Time', 'SE'), - 'Europe/Tallinn': ('FLE Standard Time', 'EE'), - 'Europe/Tirane': ('Central Europe Standard Time', 'AL'), - 'Europe/Ulyanovsk': ('Astrakhan Standard Time', 'RU'), - 'Europe/Uzhgorod': ('FLE Standard Time', 'UA'), - 'Europe/Vaduz': ('W. Europe Standard Time', 'LI'), - 'Europe/Vatican': ('W. Europe Standard Time', 'VA'), - 'Europe/Vienna': ('W. Europe Standard Time', 'AT'), - 'Europe/Vilnius': ('FLE Standard Time', 'LT'), - 'Europe/Volgograd': ('Volgograd Standard Time', '001'), - 'Europe/Warsaw': ('Central European Standard Time', '001'), - 'Europe/Zagreb': ('Central European Standard Time', 'HR'), - 'Europe/Zaporozhye': ('FLE Standard Time', 'UA'), - 'Europe/Zurich': ('W. Europe Standard Time', 'CH'), - 'Indian/Antananarivo': ('E. Africa Standard Time', 'MG'), - 'Indian/Chagos': ('Central Asia Standard Time', 'IO'), - 'Indian/Christmas': ('SE Asia Standard Time', 'CX'), - 'Indian/Cocos': ('Myanmar Standard Time', 'CC'), - 'Indian/Comoro': ('E. Africa Standard Time', 'KM'), - 'Indian/Kerguelen': ('West Asia Standard Time', 'TF'), - 'Indian/Mahe': ('Mauritius Standard Time', 'SC'), - 'Indian/Maldives': ('West Asia Standard Time', 'MV'), - 'Indian/Mauritius': ('Mauritius Standard Time', '001'), - 'Indian/Mayotte': ('E. Africa Standard Time', 'YT'), - 'Indian/Reunion': ('Mauritius Standard Time', 'RE'), - 'MST7MDT': ('Mountain Standard Time', 'ZZ'), - 'PST8PDT': ('Pacific Standard Time', 'ZZ'), - 'Pacific/Apia': ('Samoa Standard Time', '001'), - 'Pacific/Auckland': ('New Zealand Standard Time', '001'), - 'Pacific/Bougainville': ('Bougainville Standard Time', '001'), - 'Pacific/Chatham': ('Chatham Islands Standard Time', '001'), - 'Pacific/Easter': ('Easter Island Standard Time', '001'), - 'Pacific/Efate': ('Central Pacific Standard Time', 'VU'), - 'Pacific/Enderbury': ('UTC+13', 'KI'), - 'Pacific/Fakaofo': ('UTC+13', 'TK'), - 'Pacific/Fiji': ('Fiji Standard Time', '001'), - 'Pacific/Funafuti': ('UTC+12', 'TV'), - 'Pacific/Galapagos': ('Central America Standard Time', 'EC'), - 'Pacific/Gambier': ('UTC-09', 'PF'), - 'Pacific/Guadalcanal': ('Central Pacific Standard Time', '001'), - 'Pacific/Guam': ('West Pacific Standard Time', 'GU'), - 'Pacific/Honolulu': ('Hawaiian Standard Time', '001'), - 'Pacific/Johnston': ('Hawaiian Standard Time', 'UM'), - 'Pacific/Kiritimati': ('Line Islands Standard Time', '001'), - 'Pacific/Kosrae': ('Central Pacific Standard Time', 'FM'), - 'Pacific/Kwajalein': ('UTC+12', 'MH'), - 'Pacific/Majuro': ('UTC+12', 'MH'), - 'Pacific/Marquesas': ('Marquesas Standard Time', '001'), - 'Pacific/Midway': ('UTC-11', 'UM'), - 'Pacific/Nauru': ('UTC+12', 'NR'), - 'Pacific/Niue': ('UTC-11', 'NU'), - 'Pacific/Norfolk': ('Norfolk Standard Time', '001'), - 'Pacific/Noumea': ('Central Pacific Standard Time', 'NC'), - 'Pacific/Pago_Pago': ('UTC-11', 'AS'), - 'Pacific/Palau': ('Tokyo Standard Time', 'PW'), - 'Pacific/Pitcairn': ('UTC-08', 'PN'), - 'Pacific/Ponape': ('Central Pacific Standard Time', 'FM'), - 'Pacific/Port_Moresby': ('West Pacific Standard Time', '001'), - 'Pacific/Rarotonga': ('Hawaiian Standard Time', 'CK'), - 'Pacific/Saipan': ('West Pacific Standard Time', 'MP'), - 'Pacific/Tahiti': ('Hawaiian Standard Time', 'PF'), - 'Pacific/Tarawa': ('UTC+12', 'KI'), - 'Pacific/Tongatapu': ('Tonga Standard Time', '001'), - 'Pacific/Truk': ('West Pacific Standard Time', 'FM'), - 'Pacific/Wake': ('UTC+12', 'UM'), - 'Pacific/Wallis': ('UTC+12', 'WF'), + "Africa/Abidjan": ("Greenwich Standard Time", "CI"), + "Africa/Accra": ("Greenwich Standard Time", "GH"), + "Africa/Addis_Ababa": ("E. Africa Standard Time", "ET"), + "Africa/Algiers": ("W. Central Africa Standard Time", "DZ"), + "Africa/Asmera": ("E. Africa Standard Time", "ER"), + "Africa/Bamako": ("Greenwich Standard Time", "ML"), + "Africa/Bangui": ("W. Central Africa Standard Time", "CF"), + "Africa/Banjul": ("Greenwich Standard Time", "GM"), + "Africa/Bissau": ("Greenwich Standard Time", "GW"), + "Africa/Blantyre": ("South Africa Standard Time", "MW"), + "Africa/Brazzaville": ("W. Central Africa Standard Time", "CG"), + "Africa/Bujumbura": ("South Africa Standard Time", "BI"), + "Africa/Cairo": ("Egypt Standard Time", "001"), + "Africa/Casablanca": ("Morocco Standard Time", "001"), + "Africa/Ceuta": ("Romance Standard Time", "ES"), + "Africa/Conakry": ("Greenwich Standard Time", "GN"), + "Africa/Dakar": ("Greenwich Standard Time", "SN"), + "Africa/Dar_es_Salaam": ("E. Africa Standard Time", "TZ"), + "Africa/Djibouti": ("E. Africa Standard Time", "DJ"), + "Africa/Douala": ("W. Central Africa Standard Time", "CM"), + "Africa/El_Aaiun": ("Morocco Standard Time", "EH"), + "Africa/Freetown": ("Greenwich Standard Time", "SL"), + "Africa/Gaborone": ("South Africa Standard Time", "BW"), + "Africa/Harare": ("South Africa Standard Time", "ZW"), + "Africa/Johannesburg": ("South Africa Standard Time", "001"), + "Africa/Juba": ("South Sudan Standard Time", "001"), + "Africa/Kampala": ("E. Africa Standard Time", "UG"), + "Africa/Khartoum": ("Sudan Standard Time", "001"), + "Africa/Kigali": ("South Africa Standard Time", "RW"), + "Africa/Kinshasa": ("W. Central Africa Standard Time", "CD"), + "Africa/Lagos": ("W. Central Africa Standard Time", "001"), + "Africa/Libreville": ("W. Central Africa Standard Time", "GA"), + "Africa/Lome": ("Greenwich Standard Time", "TG"), + "Africa/Luanda": ("W. Central Africa Standard Time", "AO"), + "Africa/Lubumbashi": ("South Africa Standard Time", "CD"), + "Africa/Lusaka": ("South Africa Standard Time", "ZM"), + "Africa/Malabo": ("W. Central Africa Standard Time", "GQ"), + "Africa/Maputo": ("South Africa Standard Time", "MZ"), + "Africa/Maseru": ("South Africa Standard Time", "LS"), + "Africa/Mbabane": ("South Africa Standard Time", "SZ"), + "Africa/Mogadishu": ("E. Africa Standard Time", "SO"), + "Africa/Monrovia": ("Greenwich Standard Time", "LR"), + "Africa/Nairobi": ("E. Africa Standard Time", "001"), + "Africa/Ndjamena": ("W. Central Africa Standard Time", "TD"), + "Africa/Niamey": ("W. Central Africa Standard Time", "NE"), + "Africa/Nouakchott": ("Greenwich Standard Time", "MR"), + "Africa/Ouagadougou": ("Greenwich Standard Time", "BF"), + "Africa/Porto-Novo": ("W. Central Africa Standard Time", "BJ"), + "Africa/Sao_Tome": ("Sao Tome Standard Time", "001"), + "Africa/Tripoli": ("Libya Standard Time", "001"), + "Africa/Tunis": ("W. Central Africa Standard Time", "TN"), + "Africa/Windhoek": ("Namibia Standard Time", "001"), + "America/Adak": ("Aleutian Standard Time", "001"), + "America/Anchorage": ("Alaskan Standard Time", "001"), + "America/Anguilla": ("SA Western Standard Time", "AI"), + "America/Antigua": ("SA Western Standard Time", "AG"), + "America/Araguaina": ("Tocantins Standard Time", "001"), + "America/Argentina/La_Rioja": ("Argentina Standard Time", "AR"), + "America/Argentina/Rio_Gallegos": ("Argentina Standard Time", "AR"), + "America/Argentina/Salta": ("Argentina Standard Time", "AR"), + "America/Argentina/San_Juan": ("Argentina Standard Time", "AR"), + "America/Argentina/San_Luis": ("Argentina Standard Time", "AR"), + "America/Argentina/Tucuman": ("Argentina Standard Time", "AR"), + "America/Argentina/Ushuaia": ("Argentina Standard Time", "AR"), + "America/Aruba": ("SA Western Standard Time", "AW"), + "America/Asuncion": ("Paraguay Standard Time", "001"), + "America/Bahia": ("Bahia Standard Time", "001"), + "America/Bahia_Banderas": ("Central Standard Time (Mexico)", "MX"), + "America/Barbados": ("SA Western Standard Time", "BB"), + "America/Belem": ("SA Eastern Standard Time", "BR"), + "America/Belize": ("Central America Standard Time", "BZ"), + "America/Blanc-Sablon": ("SA Western Standard Time", "CA"), + "America/Boa_Vista": ("SA Western Standard Time", "BR"), + "America/Bogota": ("SA Pacific Standard Time", "001"), + "America/Boise": ("Mountain Standard Time", "US"), + "America/Buenos_Aires": ("Argentina Standard Time", "001"), + "America/Cambridge_Bay": ("Mountain Standard Time", "CA"), + "America/Campo_Grande": ("Central Brazilian Standard Time", "BR"), + "America/Cancun": ("Eastern Standard Time (Mexico)", "001"), + "America/Caracas": ("Venezuela Standard Time", "001"), + "America/Catamarca": ("Argentina Standard Time", "AR"), + "America/Cayenne": ("SA Eastern Standard Time", "001"), + "America/Cayman": ("SA Pacific Standard Time", "KY"), + "America/Chicago": ("Central Standard Time", "001"), + "America/Chihuahua": ("Mountain Standard Time (Mexico)", "001"), + "America/Coral_Harbour": ("SA Pacific Standard Time", "CA"), + "America/Cordoba": ("Argentina Standard Time", "AR"), + "America/Costa_Rica": ("Central America Standard Time", "CR"), + "America/Creston": ("US Mountain Standard Time", "CA"), + "America/Cuiaba": ("Central Brazilian Standard Time", "001"), + "America/Curacao": ("SA Western Standard Time", "CW"), + "America/Danmarkshavn": ("Greenwich Standard Time", "GL"), + "America/Dawson": ("Yukon Standard Time", "CA"), + "America/Dawson_Creek": ("US Mountain Standard Time", "CA"), + "America/Denver": ("Mountain Standard Time", "001"), + "America/Detroit": ("Eastern Standard Time", "US"), + "America/Dominica": ("SA Western Standard Time", "DM"), + "America/Edmonton": ("Mountain Standard Time", "CA"), + "America/Eirunepe": ("SA Pacific Standard Time", "BR"), + "America/El_Salvador": ("Central America Standard Time", "SV"), + "America/Fort_Nelson": ("US Mountain Standard Time", "CA"), + "America/Fortaleza": ("SA Eastern Standard Time", "BR"), + "America/Glace_Bay": ("Atlantic Standard Time", "CA"), + "America/Godthab": ("Greenland Standard Time", "001"), + "America/Goose_Bay": ("Atlantic Standard Time", "CA"), + "America/Grand_Turk": ("Turks And Caicos Standard Time", "001"), + "America/Grenada": ("SA Western Standard Time", "GD"), + "America/Guadeloupe": ("SA Western Standard Time", "GP"), + "America/Guatemala": ("Central America Standard Time", "001"), + "America/Guayaquil": ("SA Pacific Standard Time", "EC"), + "America/Guyana": ("SA Western Standard Time", "GY"), + "America/Halifax": ("Atlantic Standard Time", "001"), + "America/Havana": ("Cuba Standard Time", "001"), + "America/Hermosillo": ("US Mountain Standard Time", "MX"), + "America/Indiana/Knox": ("Central Standard Time", "US"), + "America/Indiana/Marengo": ("US Eastern Standard Time", "US"), + "America/Indiana/Petersburg": ("Eastern Standard Time", "US"), + "America/Indiana/Tell_City": ("Central Standard Time", "US"), + "America/Indiana/Vevay": ("US Eastern Standard Time", "US"), + "America/Indiana/Vincennes": ("Eastern Standard Time", "US"), + "America/Indiana/Winamac": ("Eastern Standard Time", "US"), + "America/Indianapolis": ("US Eastern Standard Time", "001"), + "America/Inuvik": ("Mountain Standard Time", "CA"), + "America/Iqaluit": ("Eastern Standard Time", "CA"), + "America/Jamaica": ("SA Pacific Standard Time", "JM"), + "America/Jujuy": ("Argentina Standard Time", "AR"), + "America/Juneau": ("Alaskan Standard Time", "US"), + "America/Kentucky/Monticello": ("Eastern Standard Time", "US"), + "America/Kralendijk": ("SA Western Standard Time", "BQ"), + "America/La_Paz": ("SA Western Standard Time", "001"), + "America/Lima": ("SA Pacific Standard Time", "PE"), + "America/Los_Angeles": ("Pacific Standard Time", "001"), + "America/Louisville": ("Eastern Standard Time", "US"), + "America/Lower_Princes": ("SA Western Standard Time", "SX"), + "America/Maceio": ("SA Eastern Standard Time", "BR"), + "America/Managua": ("Central America Standard Time", "NI"), + "America/Manaus": ("SA Western Standard Time", "BR"), + "America/Marigot": ("SA Western Standard Time", "MF"), + "America/Martinique": ("SA Western Standard Time", "MQ"), + "America/Matamoros": ("Central Standard Time", "MX"), + "America/Mazatlan": ("Mountain Standard Time (Mexico)", "MX"), + "America/Mendoza": ("Argentina Standard Time", "AR"), + "America/Menominee": ("Central Standard Time", "US"), + "America/Merida": ("Central Standard Time (Mexico)", "MX"), + "America/Metlakatla": ("Alaskan Standard Time", "US"), + "America/Mexico_City": ("Central Standard Time (Mexico)", "001"), + "America/Miquelon": ("Saint Pierre Standard Time", "001"), + "America/Moncton": ("Atlantic Standard Time", "CA"), + "America/Monterrey": ("Central Standard Time (Mexico)", "MX"), + "America/Montevideo": ("Montevideo Standard Time", "001"), + "America/Montreal": ("Eastern Standard Time", "CA"), + "America/Montserrat": ("SA Western Standard Time", "MS"), + "America/Nassau": ("Eastern Standard Time", "BS"), + "America/New_York": ("Eastern Standard Time", "001"), + "America/Nipigon": ("Eastern Standard Time", "CA"), + "America/Nome": ("Alaskan Standard Time", "US"), + "America/Noronha": ("UTC-02", "BR"), + "America/North_Dakota/Beulah": ("Central Standard Time", "US"), + "America/North_Dakota/Center": ("Central Standard Time", "US"), + "America/North_Dakota/New_Salem": ("Central Standard Time", "US"), + "America/Ojinaga": ("Mountain Standard Time", "MX"), + "America/Panama": ("SA Pacific Standard Time", "PA"), + "America/Pangnirtung": ("Eastern Standard Time", "CA"), + "America/Paramaribo": ("SA Eastern Standard Time", "SR"), + "America/Phoenix": ("US Mountain Standard Time", "001"), + "America/Port-au-Prince": ("Haiti Standard Time", "001"), + "America/Port_of_Spain": ("SA Western Standard Time", "TT"), + "America/Porto_Velho": ("SA Western Standard Time", "BR"), + "America/Puerto_Rico": ("SA Western Standard Time", "PR"), + "America/Punta_Arenas": ("Magallanes Standard Time", "001"), + "America/Rainy_River": ("Central Standard Time", "CA"), + "America/Rankin_Inlet": ("Central Standard Time", "CA"), + "America/Recife": ("SA Eastern Standard Time", "BR"), + "America/Regina": ("Canada Central Standard Time", "001"), + "America/Resolute": ("Central Standard Time", "CA"), + "America/Rio_Branco": ("SA Pacific Standard Time", "BR"), + "America/Santa_Isabel": ("Pacific Standard Time (Mexico)", "MX"), + "America/Santarem": ("SA Eastern Standard Time", "BR"), + "America/Santiago": ("Pacific SA Standard Time", "001"), + "America/Santo_Domingo": ("SA Western Standard Time", "DO"), + "America/Sao_Paulo": ("E. South America Standard Time", "001"), + "America/Scoresbysund": ("Azores Standard Time", "GL"), + "America/Sitka": ("Alaskan Standard Time", "US"), + "America/St_Barthelemy": ("SA Western Standard Time", "BL"), + "America/St_Johns": ("Newfoundland Standard Time", "001"), + "America/St_Kitts": ("SA Western Standard Time", "KN"), + "America/St_Lucia": ("SA Western Standard Time", "LC"), + "America/St_Thomas": ("SA Western Standard Time", "VI"), + "America/St_Vincent": ("SA Western Standard Time", "VC"), + "America/Swift_Current": ("Canada Central Standard Time", "CA"), + "America/Tegucigalpa": ("Central America Standard Time", "HN"), + "America/Thule": ("Atlantic Standard Time", "GL"), + "America/Thunder_Bay": ("Eastern Standard Time", "CA"), + "America/Tijuana": ("Pacific Standard Time (Mexico)", "001"), + "America/Toronto": ("Eastern Standard Time", "CA"), + "America/Tortola": ("SA Western Standard Time", "VG"), + "America/Vancouver": ("Pacific Standard Time", "CA"), + "America/Whitehorse": ("Yukon Standard Time", "001"), + "America/Winnipeg": ("Central Standard Time", "CA"), + "America/Yakutat": ("Alaskan Standard Time", "US"), + "America/Yellowknife": ("Mountain Standard Time", "CA"), + "Antarctica/Casey": ("Central Pacific Standard Time", "AQ"), + "Antarctica/Davis": ("SE Asia Standard Time", "AQ"), + "Antarctica/DumontDUrville": ("West Pacific Standard Time", "AQ"), + "Antarctica/Macquarie": ("Tasmania Standard Time", "AU"), + "Antarctica/Mawson": ("West Asia Standard Time", "AQ"), + "Antarctica/McMurdo": ("New Zealand Standard Time", "AQ"), + "Antarctica/Palmer": ("SA Eastern Standard Time", "AQ"), + "Antarctica/Rothera": ("SA Eastern Standard Time", "AQ"), + "Antarctica/Syowa": ("E. Africa Standard Time", "AQ"), + "Antarctica/Vostok": ("Central Asia Standard Time", "AQ"), + "Arctic/Longyearbyen": ("W. Europe Standard Time", "SJ"), + "Asia/Aden": ("Arab Standard Time", "YE"), + "Asia/Almaty": ("Central Asia Standard Time", "001"), + "Asia/Amman": ("Jordan Standard Time", "001"), + "Asia/Anadyr": ("Russia Time Zone 11", "RU"), + "Asia/Aqtau": ("West Asia Standard Time", "KZ"), + "Asia/Aqtobe": ("West Asia Standard Time", "KZ"), + "Asia/Ashgabat": ("West Asia Standard Time", "TM"), + "Asia/Atyrau": ("West Asia Standard Time", "KZ"), + "Asia/Baghdad": ("Arabic Standard Time", "001"), + "Asia/Bahrain": ("Arab Standard Time", "BH"), + "Asia/Baku": ("Azerbaijan Standard Time", "001"), + "Asia/Bangkok": ("SE Asia Standard Time", "001"), + "Asia/Barnaul": ("Altai Standard Time", "001"), + "Asia/Beirut": ("Middle East Standard Time", "001"), + "Asia/Bishkek": ("Central Asia Standard Time", "KG"), + "Asia/Brunei": ("Singapore Standard Time", "BN"), + "Asia/Calcutta": ("India Standard Time", "001"), + "Asia/Chita": ("Transbaikal Standard Time", "001"), + "Asia/Choibalsan": ("Ulaanbaatar Standard Time", "MN"), + "Asia/Colombo": ("Sri Lanka Standard Time", "001"), + "Asia/Damascus": ("Syria Standard Time", "001"), + "Asia/Dhaka": ("Bangladesh Standard Time", "001"), + "Asia/Dili": ("Tokyo Standard Time", "TL"), + "Asia/Dubai": ("Arabian Standard Time", "001"), + "Asia/Dushanbe": ("West Asia Standard Time", "TJ"), + "Asia/Famagusta": ("GTB Standard Time", "CY"), + "Asia/Gaza": ("West Bank Standard Time", "PS"), + "Asia/Hebron": ("West Bank Standard Time", "001"), + "Asia/Hong_Kong": ("China Standard Time", "HK"), + "Asia/Hovd": ("W. Mongolia Standard Time", "001"), + "Asia/Irkutsk": ("North Asia East Standard Time", "001"), + "Asia/Jakarta": ("SE Asia Standard Time", "ID"), + "Asia/Jayapura": ("Tokyo Standard Time", "ID"), + "Asia/Jerusalem": ("Israel Standard Time", "001"), + "Asia/Kabul": ("Afghanistan Standard Time", "001"), + "Asia/Kamchatka": ("Russia Time Zone 11", "001"), + "Asia/Karachi": ("Pakistan Standard Time", "001"), + "Asia/Katmandu": ("Nepal Standard Time", "001"), + "Asia/Khandyga": ("Yakutsk Standard Time", "RU"), + "Asia/Krasnoyarsk": ("North Asia Standard Time", "001"), + "Asia/Kuala_Lumpur": ("Singapore Standard Time", "MY"), + "Asia/Kuching": ("Singapore Standard Time", "MY"), + "Asia/Kuwait": ("Arab Standard Time", "KW"), + "Asia/Macau": ("China Standard Time", "MO"), + "Asia/Magadan": ("Magadan Standard Time", "001"), + "Asia/Makassar": ("Singapore Standard Time", "ID"), + "Asia/Manila": ("Singapore Standard Time", "PH"), + "Asia/Muscat": ("Arabian Standard Time", "OM"), + "Asia/Nicosia": ("GTB Standard Time", "CY"), + "Asia/Novokuznetsk": ("North Asia Standard Time", "RU"), + "Asia/Novosibirsk": ("N. Central Asia Standard Time", "001"), + "Asia/Omsk": ("Omsk Standard Time", "001"), + "Asia/Oral": ("West Asia Standard Time", "KZ"), + "Asia/Phnom_Penh": ("SE Asia Standard Time", "KH"), + "Asia/Pontianak": ("SE Asia Standard Time", "ID"), + "Asia/Pyongyang": ("North Korea Standard Time", "001"), + "Asia/Qatar": ("Arab Standard Time", "QA"), + "Asia/Qostanay": ("Central Asia Standard Time", "KZ"), + "Asia/Qyzylorda": ("Qyzylorda Standard Time", "001"), + "Asia/Rangoon": ("Myanmar Standard Time", "001"), + "Asia/Riyadh": ("Arab Standard Time", "001"), + "Asia/Saigon": ("SE Asia Standard Time", "VN"), + "Asia/Sakhalin": ("Sakhalin Standard Time", "001"), + "Asia/Samarkand": ("West Asia Standard Time", "UZ"), + "Asia/Seoul": ("Korea Standard Time", "001"), + "Asia/Shanghai": ("China Standard Time", "001"), + "Asia/Singapore": ("Singapore Standard Time", "001"), + "Asia/Srednekolymsk": ("Russia Time Zone 10", "001"), + "Asia/Taipei": ("Taipei Standard Time", "001"), + "Asia/Tashkent": ("West Asia Standard Time", "001"), + "Asia/Tbilisi": ("Georgian Standard Time", "001"), + "Asia/Tehran": ("Iran Standard Time", "001"), + "Asia/Thimphu": ("Bangladesh Standard Time", "BT"), + "Asia/Tokyo": ("Tokyo Standard Time", "001"), + "Asia/Tomsk": ("Tomsk Standard Time", "001"), + "Asia/Ulaanbaatar": ("Ulaanbaatar Standard Time", "001"), + "Asia/Urumqi": ("Central Asia Standard Time", "CN"), + "Asia/Ust-Nera": ("Vladivostok Standard Time", "RU"), + "Asia/Vientiane": ("SE Asia Standard Time", "LA"), + "Asia/Vladivostok": ("Vladivostok Standard Time", "001"), + "Asia/Yakutsk": ("Yakutsk Standard Time", "001"), + "Asia/Yekaterinburg": ("Ekaterinburg Standard Time", "001"), + "Asia/Yerevan": ("Caucasus Standard Time", "001"), + "Atlantic/Azores": ("Azores Standard Time", "001"), + "Atlantic/Bermuda": ("Atlantic Standard Time", "BM"), + "Atlantic/Canary": ("GMT Standard Time", "ES"), + "Atlantic/Cape_Verde": ("Cape Verde Standard Time", "001"), + "Atlantic/Faeroe": ("GMT Standard Time", "FO"), + "Atlantic/Madeira": ("GMT Standard Time", "PT"), + "Atlantic/Reykjavik": ("Greenwich Standard Time", "001"), + "Atlantic/South_Georgia": ("UTC-02", "GS"), + "Atlantic/St_Helena": ("Greenwich Standard Time", "SH"), + "Atlantic/Stanley": ("SA Eastern Standard Time", "FK"), + "Australia/Adelaide": ("Cen. Australia Standard Time", "001"), + "Australia/Brisbane": ("E. Australia Standard Time", "001"), + "Australia/Broken_Hill": ("Cen. Australia Standard Time", "AU"), + "Australia/Currie": ("Tasmania Standard Time", "AU"), + "Australia/Darwin": ("AUS Central Standard Time", "001"), + "Australia/Eucla": ("Aus Central W. Standard Time", "001"), + "Australia/Hobart": ("Tasmania Standard Time", "001"), + "Australia/Lindeman": ("E. Australia Standard Time", "AU"), + "Australia/Lord_Howe": ("Lord Howe Standard Time", "001"), + "Australia/Melbourne": ("AUS Eastern Standard Time", "AU"), + "Australia/Perth": ("W. Australia Standard Time", "001"), + "Australia/Sydney": ("AUS Eastern Standard Time", "001"), + "CST6CDT": ("Central Standard Time", "ZZ"), + "EST5EDT": ("Eastern Standard Time", "ZZ"), + "Etc/GMT": ("UTC", "ZZ"), + "Etc/GMT+1": ("Cape Verde Standard Time", "ZZ"), + "Etc/GMT+10": ("Hawaiian Standard Time", "ZZ"), + "Etc/GMT+11": ("UTC-11", "001"), + "Etc/GMT+12": ("Dateline Standard Time", "001"), + "Etc/GMT+2": ("UTC-02", "001"), + "Etc/GMT+3": ("SA Eastern Standard Time", "ZZ"), + "Etc/GMT+4": ("SA Western Standard Time", "ZZ"), + "Etc/GMT+5": ("SA Pacific Standard Time", "ZZ"), + "Etc/GMT+6": ("Central America Standard Time", "ZZ"), + "Etc/GMT+7": ("US Mountain Standard Time", "ZZ"), + "Etc/GMT+8": ("UTC-08", "001"), + "Etc/GMT+9": ("UTC-09", "001"), + "Etc/GMT-1": ("W. Central Africa Standard Time", "ZZ"), + "Etc/GMT-10": ("West Pacific Standard Time", "ZZ"), + "Etc/GMT-11": ("Central Pacific Standard Time", "ZZ"), + "Etc/GMT-12": ("UTC+12", "001"), + "Etc/GMT-13": ("UTC+13", "001"), + "Etc/GMT-14": ("Line Islands Standard Time", "ZZ"), + "Etc/GMT-2": ("South Africa Standard Time", "ZZ"), + "Etc/GMT-3": ("E. Africa Standard Time", "ZZ"), + "Etc/GMT-4": ("Arabian Standard Time", "ZZ"), + "Etc/GMT-5": ("West Asia Standard Time", "ZZ"), + "Etc/GMT-6": ("Central Asia Standard Time", "ZZ"), + "Etc/GMT-7": ("SE Asia Standard Time", "ZZ"), + "Etc/GMT-8": ("Singapore Standard Time", "ZZ"), + "Etc/GMT-9": ("Tokyo Standard Time", "ZZ"), + "Etc/UTC": ("UTC", "001"), + "Europe/Amsterdam": ("W. Europe Standard Time", "NL"), + "Europe/Andorra": ("W. Europe Standard Time", "AD"), + "Europe/Astrakhan": ("Astrakhan Standard Time", "001"), + "Europe/Athens": ("GTB Standard Time", "GR"), + "Europe/Belgrade": ("Central Europe Standard Time", "RS"), + "Europe/Berlin": ("W. Europe Standard Time", "001"), + "Europe/Bratislava": ("Central Europe Standard Time", "SK"), + "Europe/Brussels": ("Romance Standard Time", "BE"), + "Europe/Bucharest": ("GTB Standard Time", "001"), + "Europe/Budapest": ("Central Europe Standard Time", "001"), + "Europe/Busingen": ("W. Europe Standard Time", "DE"), + "Europe/Chisinau": ("E. Europe Standard Time", "001"), + "Europe/Copenhagen": ("Romance Standard Time", "DK"), + "Europe/Dublin": ("GMT Standard Time", "IE"), + "Europe/Gibraltar": ("W. Europe Standard Time", "GI"), + "Europe/Guernsey": ("GMT Standard Time", "GG"), + "Europe/Helsinki": ("FLE Standard Time", "FI"), + "Europe/Isle_of_Man": ("GMT Standard Time", "IM"), + "Europe/Istanbul": ("Turkey Standard Time", "001"), + "Europe/Jersey": ("GMT Standard Time", "JE"), + "Europe/Kaliningrad": ("Kaliningrad Standard Time", "001"), + "Europe/Kiev": ("FLE Standard Time", "001"), + "Europe/Kirov": ("Russian Standard Time", "RU"), + "Europe/Lisbon": ("GMT Standard Time", "PT"), + "Europe/Ljubljana": ("Central Europe Standard Time", "SI"), + "Europe/London": ("GMT Standard Time", "001"), + "Europe/Luxembourg": ("W. Europe Standard Time", "LU"), + "Europe/Madrid": ("Romance Standard Time", "ES"), + "Europe/Malta": ("W. Europe Standard Time", "MT"), + "Europe/Mariehamn": ("FLE Standard Time", "AX"), + "Europe/Minsk": ("Belarus Standard Time", "001"), + "Europe/Monaco": ("W. Europe Standard Time", "MC"), + "Europe/Moscow": ("Russian Standard Time", "001"), + "Europe/Oslo": ("W. Europe Standard Time", "NO"), + "Europe/Paris": ("Romance Standard Time", "001"), + "Europe/Podgorica": ("Central Europe Standard Time", "ME"), + "Europe/Prague": ("Central Europe Standard Time", "CZ"), + "Europe/Riga": ("FLE Standard Time", "LV"), + "Europe/Rome": ("W. Europe Standard Time", "IT"), + "Europe/Samara": ("Russia Time Zone 3", "001"), + "Europe/San_Marino": ("W. Europe Standard Time", "SM"), + "Europe/Sarajevo": ("Central European Standard Time", "BA"), + "Europe/Saratov": ("Saratov Standard Time", "001"), + "Europe/Simferopol": ("Russian Standard Time", "UA"), + "Europe/Skopje": ("Central European Standard Time", "MK"), + "Europe/Sofia": ("FLE Standard Time", "BG"), + "Europe/Stockholm": ("W. Europe Standard Time", "SE"), + "Europe/Tallinn": ("FLE Standard Time", "EE"), + "Europe/Tirane": ("Central Europe Standard Time", "AL"), + "Europe/Ulyanovsk": ("Astrakhan Standard Time", "RU"), + "Europe/Uzhgorod": ("FLE Standard Time", "UA"), + "Europe/Vaduz": ("W. Europe Standard Time", "LI"), + "Europe/Vatican": ("W. Europe Standard Time", "VA"), + "Europe/Vienna": ("W. Europe Standard Time", "AT"), + "Europe/Vilnius": ("FLE Standard Time", "LT"), + "Europe/Volgograd": ("Volgograd Standard Time", "001"), + "Europe/Warsaw": ("Central European Standard Time", "001"), + "Europe/Zagreb": ("Central European Standard Time", "HR"), + "Europe/Zaporozhye": ("FLE Standard Time", "UA"), + "Europe/Zurich": ("W. Europe Standard Time", "CH"), + "Indian/Antananarivo": ("E. Africa Standard Time", "MG"), + "Indian/Chagos": ("Central Asia Standard Time", "IO"), + "Indian/Christmas": ("SE Asia Standard Time", "CX"), + "Indian/Cocos": ("Myanmar Standard Time", "CC"), + "Indian/Comoro": ("E. Africa Standard Time", "KM"), + "Indian/Kerguelen": ("West Asia Standard Time", "TF"), + "Indian/Mahe": ("Mauritius Standard Time", "SC"), + "Indian/Maldives": ("West Asia Standard Time", "MV"), + "Indian/Mauritius": ("Mauritius Standard Time", "001"), + "Indian/Mayotte": ("E. Africa Standard Time", "YT"), + "Indian/Reunion": ("Mauritius Standard Time", "RE"), + "MST7MDT": ("Mountain Standard Time", "ZZ"), + "PST8PDT": ("Pacific Standard Time", "ZZ"), + "Pacific/Apia": ("Samoa Standard Time", "001"), + "Pacific/Auckland": ("New Zealand Standard Time", "001"), + "Pacific/Bougainville": ("Bougainville Standard Time", "001"), + "Pacific/Chatham": ("Chatham Islands Standard Time", "001"), + "Pacific/Easter": ("Easter Island Standard Time", "001"), + "Pacific/Efate": ("Central Pacific Standard Time", "VU"), + "Pacific/Enderbury": ("UTC+13", "KI"), + "Pacific/Fakaofo": ("UTC+13", "TK"), + "Pacific/Fiji": ("Fiji Standard Time", "001"), + "Pacific/Funafuti": ("UTC+12", "TV"), + "Pacific/Galapagos": ("Central America Standard Time", "EC"), + "Pacific/Gambier": ("UTC-09", "PF"), + "Pacific/Guadalcanal": ("Central Pacific Standard Time", "001"), + "Pacific/Guam": ("West Pacific Standard Time", "GU"), + "Pacific/Honolulu": ("Hawaiian Standard Time", "001"), + "Pacific/Johnston": ("Hawaiian Standard Time", "UM"), + "Pacific/Kiritimati": ("Line Islands Standard Time", "001"), + "Pacific/Kosrae": ("Central Pacific Standard Time", "FM"), + "Pacific/Kwajalein": ("UTC+12", "MH"), + "Pacific/Majuro": ("UTC+12", "MH"), + "Pacific/Marquesas": ("Marquesas Standard Time", "001"), + "Pacific/Midway": ("UTC-11", "UM"), + "Pacific/Nauru": ("UTC+12", "NR"), + "Pacific/Niue": ("UTC-11", "NU"), + "Pacific/Norfolk": ("Norfolk Standard Time", "001"), + "Pacific/Noumea": ("Central Pacific Standard Time", "NC"), + "Pacific/Pago_Pago": ("UTC-11", "AS"), + "Pacific/Palau": ("Tokyo Standard Time", "PW"), + "Pacific/Pitcairn": ("UTC-08", "PN"), + "Pacific/Ponape": ("Central Pacific Standard Time", "FM"), + "Pacific/Port_Moresby": ("West Pacific Standard Time", "001"), + "Pacific/Rarotonga": ("Hawaiian Standard Time", "CK"), + "Pacific/Saipan": ("West Pacific Standard Time", "MP"), + "Pacific/Tahiti": ("Hawaiian Standard Time", "PF"), + "Pacific/Tarawa": ("UTC+12", "KI"), + "Pacific/Tongatapu": ("Tonga Standard Time", "001"), + "Pacific/Truk": ("West Pacific Standard Time", "FM"), + "Pacific/Wake": ("UTC+12", "UM"), + "Pacific/Wallis": ("UTC+12", "WF"), } # Timezone names used by IANA but not mentioned in the CLDR. All of them have an alias in CLDR. This is essentially @@ -506,143 +506,143 @@ def generate_map(timeout=10): IANA_TO_MS_TIMEZONE_MAP = dict( CLDR_TO_MS_TIMEZONE_MAP, **{ - 'Africa/Asmara': CLDR_TO_MS_TIMEZONE_MAP['Africa/Nairobi'], - 'Africa/Timbuktu': CLDR_TO_MS_TIMEZONE_MAP['Africa/Abidjan'], - 'America/Argentina/Buenos_Aires': CLDR_TO_MS_TIMEZONE_MAP['America/Buenos_Aires'], - 'America/Argentina/Catamarca': CLDR_TO_MS_TIMEZONE_MAP['America/Catamarca'], - 'America/Argentina/ComodRivadavia': CLDR_TO_MS_TIMEZONE_MAP['America/Catamarca'], - 'America/Argentina/Cordoba': CLDR_TO_MS_TIMEZONE_MAP['America/Cordoba'], - 'America/Argentina/Jujuy': CLDR_TO_MS_TIMEZONE_MAP['America/Jujuy'], - 'America/Argentina/Mendoza': CLDR_TO_MS_TIMEZONE_MAP['America/Mendoza'], - 'America/Atikokan': CLDR_TO_MS_TIMEZONE_MAP['America/Coral_Harbour'], - 'America/Atka': CLDR_TO_MS_TIMEZONE_MAP['America/Adak'], - 'America/Ensenada': CLDR_TO_MS_TIMEZONE_MAP['America/Tijuana'], - 'America/Fort_Wayne': CLDR_TO_MS_TIMEZONE_MAP['America/Indianapolis'], - 'America/Indiana/Indianapolis': CLDR_TO_MS_TIMEZONE_MAP['America/Indianapolis'], - 'America/Kentucky/Louisville': CLDR_TO_MS_TIMEZONE_MAP['America/Louisville'], - 'America/Knox_IN': CLDR_TO_MS_TIMEZONE_MAP['America/Indiana/Knox'], - 'America/Nuuk': CLDR_TO_MS_TIMEZONE_MAP['America/Godthab'], - 'America/Porto_Acre': CLDR_TO_MS_TIMEZONE_MAP['America/Rio_Branco'], - 'America/Rosario': CLDR_TO_MS_TIMEZONE_MAP['America/Cordoba'], - 'America/Shiprock': CLDR_TO_MS_TIMEZONE_MAP['America/Denver'], - 'America/Virgin': CLDR_TO_MS_TIMEZONE_MAP['America/Port_of_Spain'], - 'Antarctica/South_Pole': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Auckland'], - 'Antarctica/Troll': CLDR_TO_MS_TIMEZONE_MAP['Europe/Oslo'], - 'Asia/Ashkhabad': CLDR_TO_MS_TIMEZONE_MAP['Asia/Ashgabat'], - 'Asia/Chongqing': CLDR_TO_MS_TIMEZONE_MAP['Asia/Shanghai'], - 'Asia/Chungking': CLDR_TO_MS_TIMEZONE_MAP['Asia/Shanghai'], - 'Asia/Dacca': CLDR_TO_MS_TIMEZONE_MAP['Asia/Dhaka'], - 'Asia/Harbin': CLDR_TO_MS_TIMEZONE_MAP['Asia/Shanghai'], - 'Asia/Ho_Chi_Minh': CLDR_TO_MS_TIMEZONE_MAP['Asia/Saigon'], - 'Asia/Istanbul': CLDR_TO_MS_TIMEZONE_MAP['Europe/Istanbul'], - 'Asia/Kashgar': CLDR_TO_MS_TIMEZONE_MAP['Asia/Urumqi'], - 'Asia/Kathmandu': CLDR_TO_MS_TIMEZONE_MAP['Asia/Katmandu'], - 'Asia/Kolkata': CLDR_TO_MS_TIMEZONE_MAP['Asia/Calcutta'], - 'Asia/Macao': CLDR_TO_MS_TIMEZONE_MAP['Asia/Macau'], - 'Asia/Tel_Aviv': CLDR_TO_MS_TIMEZONE_MAP['Asia/Jerusalem'], - 'Asia/Thimbu': CLDR_TO_MS_TIMEZONE_MAP['Asia/Thimphu'], - 'Asia/Ujung_Pandang': CLDR_TO_MS_TIMEZONE_MAP['Asia/Makassar'], - 'Asia/Ulan_Bator': CLDR_TO_MS_TIMEZONE_MAP['Asia/Ulaanbaatar'], - 'Asia/Yangon': CLDR_TO_MS_TIMEZONE_MAP['Asia/Rangoon'], - 'Atlantic/Faroe': CLDR_TO_MS_TIMEZONE_MAP['Atlantic/Faeroe'], - 'Atlantic/Jan_Mayen': CLDR_TO_MS_TIMEZONE_MAP['Europe/Oslo'], - 'Australia/ACT': CLDR_TO_MS_TIMEZONE_MAP['Australia/Sydney'], - 'Australia/Canberra': CLDR_TO_MS_TIMEZONE_MAP['Australia/Sydney'], - 'Australia/LHI': CLDR_TO_MS_TIMEZONE_MAP['Australia/Lord_Howe'], - 'Australia/NSW': CLDR_TO_MS_TIMEZONE_MAP['Australia/Sydney'], - 'Australia/North': CLDR_TO_MS_TIMEZONE_MAP['Australia/Darwin'], - 'Australia/Queensland': CLDR_TO_MS_TIMEZONE_MAP['Australia/Brisbane'], - 'Australia/South': CLDR_TO_MS_TIMEZONE_MAP['Australia/Adelaide'], - 'Australia/Tasmania': CLDR_TO_MS_TIMEZONE_MAP['Australia/Hobart'], - 'Australia/Victoria': CLDR_TO_MS_TIMEZONE_MAP['Australia/Melbourne'], - 'Australia/West': CLDR_TO_MS_TIMEZONE_MAP['Australia/Perth'], - 'Australia/Yancowinna': CLDR_TO_MS_TIMEZONE_MAP['Australia/Broken_Hill'], - 'Brazil/Acre': CLDR_TO_MS_TIMEZONE_MAP['America/Rio_Branco'], - 'Brazil/DeNoronha': CLDR_TO_MS_TIMEZONE_MAP['America/Noronha'], - 'Brazil/East': CLDR_TO_MS_TIMEZONE_MAP['America/Sao_Paulo'], - 'Brazil/West': CLDR_TO_MS_TIMEZONE_MAP['America/Manaus'], - 'CET': CLDR_TO_MS_TIMEZONE_MAP['Europe/Paris'], - 'Canada/Atlantic': CLDR_TO_MS_TIMEZONE_MAP['America/Halifax'], - 'Canada/Central': CLDR_TO_MS_TIMEZONE_MAP['America/Winnipeg'], - 'Canada/Eastern': CLDR_TO_MS_TIMEZONE_MAP['America/Toronto'], - 'Canada/Mountain': CLDR_TO_MS_TIMEZONE_MAP['America/Edmonton'], - 'Canada/Newfoundland': CLDR_TO_MS_TIMEZONE_MAP['America/St_Johns'], - 'Canada/Pacific': CLDR_TO_MS_TIMEZONE_MAP['America/Vancouver'], - 'Canada/Saskatchewan': CLDR_TO_MS_TIMEZONE_MAP['America/Regina'], - 'Canada/Yukon': CLDR_TO_MS_TIMEZONE_MAP['America/Whitehorse'], - 'Chile/Continental': CLDR_TO_MS_TIMEZONE_MAP['America/Santiago'], - 'Chile/EasterIsland': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Easter'], - 'Cuba': CLDR_TO_MS_TIMEZONE_MAP['America/Havana'], - 'EET': CLDR_TO_MS_TIMEZONE_MAP['Europe/Sofia'], - 'EST': CLDR_TO_MS_TIMEZONE_MAP['America/Cancun'], - 'Egypt': CLDR_TO_MS_TIMEZONE_MAP['Africa/Cairo'], - 'Eire': CLDR_TO_MS_TIMEZONE_MAP['Europe/Dublin'], - 'Etc/GMT+0': CLDR_TO_MS_TIMEZONE_MAP['Etc/GMT'], - 'Etc/GMT-0': CLDR_TO_MS_TIMEZONE_MAP['Etc/GMT'], - 'Etc/GMT0': CLDR_TO_MS_TIMEZONE_MAP['Etc/GMT'], - 'Etc/Greenwich': CLDR_TO_MS_TIMEZONE_MAP['Etc/GMT'], - 'Etc/UCT': CLDR_TO_MS_TIMEZONE_MAP['Etc/UTC'], - 'Etc/Universal': CLDR_TO_MS_TIMEZONE_MAP['Etc/UTC'], - 'Etc/Zulu': CLDR_TO_MS_TIMEZONE_MAP['Etc/UTC'], - 'Europe/Belfast': CLDR_TO_MS_TIMEZONE_MAP['Europe/London'], - 'Europe/Nicosia': CLDR_TO_MS_TIMEZONE_MAP['Asia/Nicosia'], - 'Europe/Tiraspol': CLDR_TO_MS_TIMEZONE_MAP['Europe/Chisinau'], - 'Factory': CLDR_TO_MS_TIMEZONE_MAP['Etc/UTC'], - 'GB': CLDR_TO_MS_TIMEZONE_MAP['Europe/London'], - 'GB-Eire': CLDR_TO_MS_TIMEZONE_MAP['Europe/London'], - 'GMT': CLDR_TO_MS_TIMEZONE_MAP['Etc/GMT'], - 'GMT+0': CLDR_TO_MS_TIMEZONE_MAP['Etc/GMT'], - 'GMT-0': CLDR_TO_MS_TIMEZONE_MAP['Etc/GMT'], - 'GMT0': CLDR_TO_MS_TIMEZONE_MAP['Etc/GMT'], - 'Greenwich': CLDR_TO_MS_TIMEZONE_MAP['Etc/GMT'], - 'HST': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Honolulu'], - 'Hongkong': CLDR_TO_MS_TIMEZONE_MAP['Asia/Hong_Kong'], - 'Iceland': CLDR_TO_MS_TIMEZONE_MAP['Atlantic/Reykjavik'], - 'Iran': CLDR_TO_MS_TIMEZONE_MAP['Asia/Tehran'], - 'Israel': CLDR_TO_MS_TIMEZONE_MAP['Asia/Jerusalem'], - 'Jamaica': CLDR_TO_MS_TIMEZONE_MAP['America/Jamaica'], - 'Japan': CLDR_TO_MS_TIMEZONE_MAP['Asia/Tokyo'], - 'Kwajalein': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Kwajalein'], - 'Libya': CLDR_TO_MS_TIMEZONE_MAP['Africa/Tripoli'], - 'MET': CLDR_TO_MS_TIMEZONE_MAP['Europe/Paris'], - 'MST': CLDR_TO_MS_TIMEZONE_MAP['America/Phoenix'], - 'Mexico/BajaNorte': CLDR_TO_MS_TIMEZONE_MAP['America/Tijuana'], - 'Mexico/BajaSur': CLDR_TO_MS_TIMEZONE_MAP['America/Mazatlan'], - 'Mexico/General': CLDR_TO_MS_TIMEZONE_MAP['America/Mexico_City'], - 'NZ': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Auckland'], - 'NZ-CHAT': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Chatham'], - 'Navajo': CLDR_TO_MS_TIMEZONE_MAP['America/Denver'], - 'PRC': CLDR_TO_MS_TIMEZONE_MAP['Asia/Shanghai'], - 'Pacific/Chuuk': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Truk'], - 'Pacific/Kanton': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Enderbury'], - 'Pacific/Pohnpei': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Ponape'], - 'Pacific/Samoa': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Pago_Pago'], - 'Pacific/Yap': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Truk'], - 'Poland': CLDR_TO_MS_TIMEZONE_MAP['Europe/Warsaw'], - 'Portugal': CLDR_TO_MS_TIMEZONE_MAP['Europe/Lisbon'], - 'ROC': CLDR_TO_MS_TIMEZONE_MAP['Asia/Taipei'], - 'ROK': CLDR_TO_MS_TIMEZONE_MAP['Asia/Seoul'], - 'Singapore': CLDR_TO_MS_TIMEZONE_MAP['Asia/Singapore'], - 'Turkey': CLDR_TO_MS_TIMEZONE_MAP['Europe/Istanbul'], - 'UCT': CLDR_TO_MS_TIMEZONE_MAP['Etc/UTC'], - 'US/Alaska': CLDR_TO_MS_TIMEZONE_MAP['America/Anchorage'], - 'US/Aleutian': CLDR_TO_MS_TIMEZONE_MAP['America/Adak'], - 'US/Arizona': CLDR_TO_MS_TIMEZONE_MAP['America/Phoenix'], - 'US/Central': CLDR_TO_MS_TIMEZONE_MAP['America/Chicago'], - 'US/East-Indiana': CLDR_TO_MS_TIMEZONE_MAP['America/Indianapolis'], - 'US/Eastern': CLDR_TO_MS_TIMEZONE_MAP['America/New_York'], - 'US/Hawaii': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Honolulu'], - 'US/Indiana-Starke': CLDR_TO_MS_TIMEZONE_MAP['America/Indiana/Knox'], - 'US/Michigan': CLDR_TO_MS_TIMEZONE_MAP['America/Detroit'], - 'US/Mountain': CLDR_TO_MS_TIMEZONE_MAP['America/Denver'], - 'US/Pacific': CLDR_TO_MS_TIMEZONE_MAP['America/Los_Angeles'], - 'US/Samoa': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Pago_Pago'], - 'UTC': CLDR_TO_MS_TIMEZONE_MAP['Etc/UTC'], - 'Universal': CLDR_TO_MS_TIMEZONE_MAP['Etc/UTC'], - 'W-SU': CLDR_TO_MS_TIMEZONE_MAP['Europe/Moscow'], - 'WET': CLDR_TO_MS_TIMEZONE_MAP['Europe/Lisbon'], - 'Zulu': CLDR_TO_MS_TIMEZONE_MAP['Etc/UTC'], - } + "Africa/Asmara": CLDR_TO_MS_TIMEZONE_MAP["Africa/Nairobi"], + "Africa/Timbuktu": CLDR_TO_MS_TIMEZONE_MAP["Africa/Abidjan"], + "America/Argentina/Buenos_Aires": CLDR_TO_MS_TIMEZONE_MAP["America/Buenos_Aires"], + "America/Argentina/Catamarca": CLDR_TO_MS_TIMEZONE_MAP["America/Catamarca"], + "America/Argentina/ComodRivadavia": CLDR_TO_MS_TIMEZONE_MAP["America/Catamarca"], + "America/Argentina/Cordoba": CLDR_TO_MS_TIMEZONE_MAP["America/Cordoba"], + "America/Argentina/Jujuy": CLDR_TO_MS_TIMEZONE_MAP["America/Jujuy"], + "America/Argentina/Mendoza": CLDR_TO_MS_TIMEZONE_MAP["America/Mendoza"], + "America/Atikokan": CLDR_TO_MS_TIMEZONE_MAP["America/Coral_Harbour"], + "America/Atka": CLDR_TO_MS_TIMEZONE_MAP["America/Adak"], + "America/Ensenada": CLDR_TO_MS_TIMEZONE_MAP["America/Tijuana"], + "America/Fort_Wayne": CLDR_TO_MS_TIMEZONE_MAP["America/Indianapolis"], + "America/Indiana/Indianapolis": CLDR_TO_MS_TIMEZONE_MAP["America/Indianapolis"], + "America/Kentucky/Louisville": CLDR_TO_MS_TIMEZONE_MAP["America/Louisville"], + "America/Knox_IN": CLDR_TO_MS_TIMEZONE_MAP["America/Indiana/Knox"], + "America/Nuuk": CLDR_TO_MS_TIMEZONE_MAP["America/Godthab"], + "America/Porto_Acre": CLDR_TO_MS_TIMEZONE_MAP["America/Rio_Branco"], + "America/Rosario": CLDR_TO_MS_TIMEZONE_MAP["America/Cordoba"], + "America/Shiprock": CLDR_TO_MS_TIMEZONE_MAP["America/Denver"], + "America/Virgin": CLDR_TO_MS_TIMEZONE_MAP["America/Port_of_Spain"], + "Antarctica/South_Pole": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Auckland"], + "Antarctica/Troll": CLDR_TO_MS_TIMEZONE_MAP["Europe/Oslo"], + "Asia/Ashkhabad": CLDR_TO_MS_TIMEZONE_MAP["Asia/Ashgabat"], + "Asia/Chongqing": CLDR_TO_MS_TIMEZONE_MAP["Asia/Shanghai"], + "Asia/Chungking": CLDR_TO_MS_TIMEZONE_MAP["Asia/Shanghai"], + "Asia/Dacca": CLDR_TO_MS_TIMEZONE_MAP["Asia/Dhaka"], + "Asia/Harbin": CLDR_TO_MS_TIMEZONE_MAP["Asia/Shanghai"], + "Asia/Ho_Chi_Minh": CLDR_TO_MS_TIMEZONE_MAP["Asia/Saigon"], + "Asia/Istanbul": CLDR_TO_MS_TIMEZONE_MAP["Europe/Istanbul"], + "Asia/Kashgar": CLDR_TO_MS_TIMEZONE_MAP["Asia/Urumqi"], + "Asia/Kathmandu": CLDR_TO_MS_TIMEZONE_MAP["Asia/Katmandu"], + "Asia/Kolkata": CLDR_TO_MS_TIMEZONE_MAP["Asia/Calcutta"], + "Asia/Macao": CLDR_TO_MS_TIMEZONE_MAP["Asia/Macau"], + "Asia/Tel_Aviv": CLDR_TO_MS_TIMEZONE_MAP["Asia/Jerusalem"], + "Asia/Thimbu": CLDR_TO_MS_TIMEZONE_MAP["Asia/Thimphu"], + "Asia/Ujung_Pandang": CLDR_TO_MS_TIMEZONE_MAP["Asia/Makassar"], + "Asia/Ulan_Bator": CLDR_TO_MS_TIMEZONE_MAP["Asia/Ulaanbaatar"], + "Asia/Yangon": CLDR_TO_MS_TIMEZONE_MAP["Asia/Rangoon"], + "Atlantic/Faroe": CLDR_TO_MS_TIMEZONE_MAP["Atlantic/Faeroe"], + "Atlantic/Jan_Mayen": CLDR_TO_MS_TIMEZONE_MAP["Europe/Oslo"], + "Australia/ACT": CLDR_TO_MS_TIMEZONE_MAP["Australia/Sydney"], + "Australia/Canberra": CLDR_TO_MS_TIMEZONE_MAP["Australia/Sydney"], + "Australia/LHI": CLDR_TO_MS_TIMEZONE_MAP["Australia/Lord_Howe"], + "Australia/NSW": CLDR_TO_MS_TIMEZONE_MAP["Australia/Sydney"], + "Australia/North": CLDR_TO_MS_TIMEZONE_MAP["Australia/Darwin"], + "Australia/Queensland": CLDR_TO_MS_TIMEZONE_MAP["Australia/Brisbane"], + "Australia/South": CLDR_TO_MS_TIMEZONE_MAP["Australia/Adelaide"], + "Australia/Tasmania": CLDR_TO_MS_TIMEZONE_MAP["Australia/Hobart"], + "Australia/Victoria": CLDR_TO_MS_TIMEZONE_MAP["Australia/Melbourne"], + "Australia/West": CLDR_TO_MS_TIMEZONE_MAP["Australia/Perth"], + "Australia/Yancowinna": CLDR_TO_MS_TIMEZONE_MAP["Australia/Broken_Hill"], + "Brazil/Acre": CLDR_TO_MS_TIMEZONE_MAP["America/Rio_Branco"], + "Brazil/DeNoronha": CLDR_TO_MS_TIMEZONE_MAP["America/Noronha"], + "Brazil/East": CLDR_TO_MS_TIMEZONE_MAP["America/Sao_Paulo"], + "Brazil/West": CLDR_TO_MS_TIMEZONE_MAP["America/Manaus"], + "CET": CLDR_TO_MS_TIMEZONE_MAP["Europe/Paris"], + "Canada/Atlantic": CLDR_TO_MS_TIMEZONE_MAP["America/Halifax"], + "Canada/Central": CLDR_TO_MS_TIMEZONE_MAP["America/Winnipeg"], + "Canada/Eastern": CLDR_TO_MS_TIMEZONE_MAP["America/Toronto"], + "Canada/Mountain": CLDR_TO_MS_TIMEZONE_MAP["America/Edmonton"], + "Canada/Newfoundland": CLDR_TO_MS_TIMEZONE_MAP["America/St_Johns"], + "Canada/Pacific": CLDR_TO_MS_TIMEZONE_MAP["America/Vancouver"], + "Canada/Saskatchewan": CLDR_TO_MS_TIMEZONE_MAP["America/Regina"], + "Canada/Yukon": CLDR_TO_MS_TIMEZONE_MAP["America/Whitehorse"], + "Chile/Continental": CLDR_TO_MS_TIMEZONE_MAP["America/Santiago"], + "Chile/EasterIsland": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Easter"], + "Cuba": CLDR_TO_MS_TIMEZONE_MAP["America/Havana"], + "EET": CLDR_TO_MS_TIMEZONE_MAP["Europe/Sofia"], + "EST": CLDR_TO_MS_TIMEZONE_MAP["America/Cancun"], + "Egypt": CLDR_TO_MS_TIMEZONE_MAP["Africa/Cairo"], + "Eire": CLDR_TO_MS_TIMEZONE_MAP["Europe/Dublin"], + "Etc/GMT+0": CLDR_TO_MS_TIMEZONE_MAP["Etc/GMT"], + "Etc/GMT-0": CLDR_TO_MS_TIMEZONE_MAP["Etc/GMT"], + "Etc/GMT0": CLDR_TO_MS_TIMEZONE_MAP["Etc/GMT"], + "Etc/Greenwich": CLDR_TO_MS_TIMEZONE_MAP["Etc/GMT"], + "Etc/UCT": CLDR_TO_MS_TIMEZONE_MAP["Etc/UTC"], + "Etc/Universal": CLDR_TO_MS_TIMEZONE_MAP["Etc/UTC"], + "Etc/Zulu": CLDR_TO_MS_TIMEZONE_MAP["Etc/UTC"], + "Europe/Belfast": CLDR_TO_MS_TIMEZONE_MAP["Europe/London"], + "Europe/Nicosia": CLDR_TO_MS_TIMEZONE_MAP["Asia/Nicosia"], + "Europe/Tiraspol": CLDR_TO_MS_TIMEZONE_MAP["Europe/Chisinau"], + "Factory": CLDR_TO_MS_TIMEZONE_MAP["Etc/UTC"], + "GB": CLDR_TO_MS_TIMEZONE_MAP["Europe/London"], + "GB-Eire": CLDR_TO_MS_TIMEZONE_MAP["Europe/London"], + "GMT": CLDR_TO_MS_TIMEZONE_MAP["Etc/GMT"], + "GMT+0": CLDR_TO_MS_TIMEZONE_MAP["Etc/GMT"], + "GMT-0": CLDR_TO_MS_TIMEZONE_MAP["Etc/GMT"], + "GMT0": CLDR_TO_MS_TIMEZONE_MAP["Etc/GMT"], + "Greenwich": CLDR_TO_MS_TIMEZONE_MAP["Etc/GMT"], + "HST": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Honolulu"], + "Hongkong": CLDR_TO_MS_TIMEZONE_MAP["Asia/Hong_Kong"], + "Iceland": CLDR_TO_MS_TIMEZONE_MAP["Atlantic/Reykjavik"], + "Iran": CLDR_TO_MS_TIMEZONE_MAP["Asia/Tehran"], + "Israel": CLDR_TO_MS_TIMEZONE_MAP["Asia/Jerusalem"], + "Jamaica": CLDR_TO_MS_TIMEZONE_MAP["America/Jamaica"], + "Japan": CLDR_TO_MS_TIMEZONE_MAP["Asia/Tokyo"], + "Kwajalein": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Kwajalein"], + "Libya": CLDR_TO_MS_TIMEZONE_MAP["Africa/Tripoli"], + "MET": CLDR_TO_MS_TIMEZONE_MAP["Europe/Paris"], + "MST": CLDR_TO_MS_TIMEZONE_MAP["America/Phoenix"], + "Mexico/BajaNorte": CLDR_TO_MS_TIMEZONE_MAP["America/Tijuana"], + "Mexico/BajaSur": CLDR_TO_MS_TIMEZONE_MAP["America/Mazatlan"], + "Mexico/General": CLDR_TO_MS_TIMEZONE_MAP["America/Mexico_City"], + "NZ": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Auckland"], + "NZ-CHAT": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Chatham"], + "Navajo": CLDR_TO_MS_TIMEZONE_MAP["America/Denver"], + "PRC": CLDR_TO_MS_TIMEZONE_MAP["Asia/Shanghai"], + "Pacific/Chuuk": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Truk"], + "Pacific/Kanton": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Enderbury"], + "Pacific/Pohnpei": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Ponape"], + "Pacific/Samoa": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Pago_Pago"], + "Pacific/Yap": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Truk"], + "Poland": CLDR_TO_MS_TIMEZONE_MAP["Europe/Warsaw"], + "Portugal": CLDR_TO_MS_TIMEZONE_MAP["Europe/Lisbon"], + "ROC": CLDR_TO_MS_TIMEZONE_MAP["Asia/Taipei"], + "ROK": CLDR_TO_MS_TIMEZONE_MAP["Asia/Seoul"], + "Singapore": CLDR_TO_MS_TIMEZONE_MAP["Asia/Singapore"], + "Turkey": CLDR_TO_MS_TIMEZONE_MAP["Europe/Istanbul"], + "UCT": CLDR_TO_MS_TIMEZONE_MAP["Etc/UTC"], + "US/Alaska": CLDR_TO_MS_TIMEZONE_MAP["America/Anchorage"], + "US/Aleutian": CLDR_TO_MS_TIMEZONE_MAP["America/Adak"], + "US/Arizona": CLDR_TO_MS_TIMEZONE_MAP["America/Phoenix"], + "US/Central": CLDR_TO_MS_TIMEZONE_MAP["America/Chicago"], + "US/East-Indiana": CLDR_TO_MS_TIMEZONE_MAP["America/Indianapolis"], + "US/Eastern": CLDR_TO_MS_TIMEZONE_MAP["America/New_York"], + "US/Hawaii": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Honolulu"], + "US/Indiana-Starke": CLDR_TO_MS_TIMEZONE_MAP["America/Indiana/Knox"], + "US/Michigan": CLDR_TO_MS_TIMEZONE_MAP["America/Detroit"], + "US/Mountain": CLDR_TO_MS_TIMEZONE_MAP["America/Denver"], + "US/Pacific": CLDR_TO_MS_TIMEZONE_MAP["America/Los_Angeles"], + "US/Samoa": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Pago_Pago"], + "UTC": CLDR_TO_MS_TIMEZONE_MAP["Etc/UTC"], + "Universal": CLDR_TO_MS_TIMEZONE_MAP["Etc/UTC"], + "W-SU": CLDR_TO_MS_TIMEZONE_MAP["Europe/Moscow"], + "WET": CLDR_TO_MS_TIMEZONE_MAP["Europe/Lisbon"], + "Zulu": CLDR_TO_MS_TIMEZONE_MAP["Etc/UTC"], + }, ) # Reverse map from Microsoft timezone ID to IANA timezone name. Non-IANA timezone ID's can be added here. @@ -650,6 +650,6 @@ def generate_map(timeout=10): # Use the CLDR map because the IANA map contains deprecated aliases that not all systems support {v[0]: k for k, v in CLDR_TO_MS_TIMEZONE_MAP.items() if v[1] == DEFAULT_TERRITORY}, **{ - 'tzone://Microsoft/Utc': 'UTC', - } + "tzone://Microsoft/Utc": "UTC", + }, ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..101ff259 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[tool.black] +line-length = 120 + +[tool.isort] +line_length = 120 +profile = "black" + diff --git a/test-requirements.txt b/test-requirements.txt index 78d7812e..e123b455 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,6 +1,7 @@ +black coverage coveralls -flake8 +isort psutil python-dateutil pytz diff --git a/tests/__init__.py b/tests/__init__.py index 2d2746dd..88dd5a5b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,8 +1,8 @@ import logging -import random import os -from unittest import TestLoader, TestSuite +import random import unittest.util +from unittest import TestLoader, TestSuite from exchangelib.util import PrettyXmlHandler @@ -24,7 +24,7 @@ def __iter__(self): # Always show full repr() output for object instances in unittest error messages unittest.util._MAX_LENGTH = 2000 -if os.environ.get('DEBUG', '').lower() in ('1', 'yes', 'true'): +if os.environ.get("DEBUG", "").lower() in ("1", "yes", "true"): logging.basicConfig(level=logging.DEBUG, handlers=[PrettyXmlHandler()]) else: logging.basicConfig(level=logging.CRITICAL) diff --git a/tests/common.py b/tests/common.py index d135aef4..254012ea 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,15 +1,16 @@ import abc -from collections import namedtuple import datetime -from decimal import Decimal import os import random import string import time import unittest import unittest.util +from collections import namedtuple +from decimal import Decimal from yaml import safe_load + try: import zoneinfo except ImportError: @@ -21,26 +22,58 @@ from exchangelib.credentials import DELEGATE, Credentials from exchangelib.errors import UnknownTimeZone from exchangelib.ewsdatetime import EWSTimeZone -from exchangelib.fields import BooleanField, IntegerField, DecimalField, TextField, EmailAddressField, URIField, \ - ChoiceField, BodyField, DateTimeField, Base64Field, PhoneNumberField, EmailAddressesField, TimeZoneField, \ - PhysicalAddressField, ExtendedPropertyField, MailboxField, AttendeesField, AttachmentField, CharListField, \ - MailboxListField, EWSElementField, CultureField, CharField, TextListField, PermissionSetField, MimeContentField, \ - DateField, DateTimeBackedDateField -from exchangelib.indexed_properties import EmailAddress, PhysicalAddress, PhoneNumber -from exchangelib.properties import Attendee, Mailbox, PermissionSet, Permission, UserId, CompleteName,\ - ReminderMessageData -from exchangelib.protocol import BaseProtocol, NoVerifyHTTPAdapter, FaultTolerance -from exchangelib.recurrence import Recurrence, TaskRecurrence, DailyPattern, DailyRegeneration +from exchangelib.fields import ( + AttachmentField, + AttendeesField, + Base64Field, + BodyField, + BooleanField, + CharField, + CharListField, + ChoiceField, + CultureField, + DateField, + DateTimeBackedDateField, + DateTimeField, + DecimalField, + EmailAddressesField, + EmailAddressField, + EWSElementField, + ExtendedPropertyField, + IntegerField, + MailboxField, + MailboxListField, + MimeContentField, + PermissionSetField, + PhoneNumberField, + PhysicalAddressField, + TextField, + TextListField, + TimeZoneField, + URIField, +) +from exchangelib.indexed_properties import EmailAddress, PhoneNumber, PhysicalAddress +from exchangelib.properties import ( + Attendee, + CompleteName, + Mailbox, + Permission, + PermissionSet, + ReminderMessageData, + UserId, +) +from exchangelib.protocol import BaseProtocol, FaultTolerance, NoVerifyHTTPAdapter +from exchangelib.recurrence import DailyPattern, DailyRegeneration, Recurrence, TaskRecurrence from exchangelib.util import DummyResponse -mock_account = namedtuple('mock_account', ('protocol', 'version')) -mock_protocol = namedtuple('mock_protocol', ('version', 'service_endpoint')) -mock_version = namedtuple('mock_version', ('build',)) +mock_account = namedtuple("mock_account", ("protocol", "version")) +mock_protocol = namedtuple("mock_protocol", ("version", "service_endpoint")) +mock_version = namedtuple("mock_version", ("build",)) -def mock_post(url, status_code, headers, text=''): +def mock_post(url, status_code, headers, text=""): return lambda **kwargs: DummyResponse( - url=url, headers=headers, request_headers={}, content=text.encode('utf-8'), status_code=status_code + url=url, headers=headers, request_headers={}, content=text.encode("utf-8"), status_code=status_code ) @@ -73,33 +106,38 @@ def setUpClass(cls): # If you want to test against your own server and account, create your own settings.yml with credentials for # that server. 'settings.yml.sample' is provided as a template. try: - with open(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'settings.yml')) as f: + with open(os.path.join(os.path.dirname(os.path.dirname(__file__)), "settings.yml")) as f: settings = safe_load(f) except FileNotFoundError: - print(f'Skipping {cls.__name__} - no settings.yml file found') - print('Copy settings.yml.sample to settings.yml and enter values for your test server') - raise unittest.SkipTest(f'Skipping {cls.__name__} - no settings.yml file found') + print(f"Skipping {cls.__name__} - no settings.yml file found") + print("Copy settings.yml.sample to settings.yml and enter values for your test server") + raise unittest.SkipTest(f"Skipping {cls.__name__} - no settings.yml file found") cls.settings = settings - cls.verify_ssl = settings.get('verify_ssl', True) + cls.verify_ssl = settings.get("verify_ssl", True) if not cls.verify_ssl: # Allow unverified TLS if requested in settings file BaseProtocol.HTTP_ADAPTER_CLS = NoVerifyHTTPAdapter # Create an account shared by all tests - cls.tz = zoneinfo.ZoneInfo('Europe/Copenhagen') + cls.tz = zoneinfo.ZoneInfo("Europe/Copenhagen") cls.retry_policy = FaultTolerance(max_wait=600) cls.config = Configuration( - server=settings['server'], - credentials=Credentials(settings['username'], settings['password']), + server=settings["server"], + credentials=Credentials(settings["username"], settings["password"]), retry_policy=cls.retry_policy, ) cls.account = cls.get_account() @classmethod def get_account(cls): - return Account(primary_smtp_address=cls.settings['account'], access_type=DELEGATE, config=cls.config, - locale='da_DK', default_timezone=cls.tz) + return Account( + primary_smtp_address=cls.settings["account"], + access_type=DELEGATE, + config=cls.config, + locale="da_DK", + default_timezone=cls.tz, + ) def setUp(self): super().setUp() @@ -117,21 +155,21 @@ def bulk_delete(self, ids): def random_val(self, field): if isinstance(field, ExtendedPropertyField): - if field.value_cls.property_type == 'StringArray': + if field.value_cls.property_type == "StringArray": return [get_random_string(255) for _ in range(random.randint(1, 4))] - if field.value_cls.property_type == 'IntegerArray': + if field.value_cls.property_type == "IntegerArray": return [get_random_int(0, 256) for _ in range(random.randint(1, 4))] - if field.value_cls.property_type == 'BinaryArray': + if field.value_cls.property_type == "BinaryArray": return [get_random_string(255).encode() for _ in range(random.randint(1, 4))] - if field.value_cls.property_type == 'String': + if field.value_cls.property_type == "String": return get_random_string(255) - if field.value_cls.property_type == 'Integer': + if field.value_cls.property_type == "Integer": return get_random_int(0, 256) - if field.value_cls.property_type == 'Binary': + if field.value_cls.property_type == "Binary": # In the test_extended_distinguished_property test, EWS rull return 4 NULL bytes after char 16 if we # send a longer bytes sequence. return get_random_string(16).encode() - raise ValueError(f'Unsupported field {field}') + raise ValueError(f"Unsupported field {field}") if isinstance(field, URIField): return get_random_url() if isinstance(field, EmailAddressField): @@ -139,7 +177,7 @@ def random_val(self, field): if isinstance(field, ChoiceField): return get_random_choice(field.supported_choices(version=self.account.version)) if isinstance(field, CultureField): - return get_random_choice(['da-DK', 'de-DE', 'en-US', 'es-ES', 'fr-CA', 'nl-NL', 'ru-RU', 'sv-SE']) + return get_random_choice(["da-DK", "de-DE", "en-US", "es-ES", "fr-CA", "nl-NL", "ru-RU", "sv-SE"]) if isinstance(field, BodyField): return get_random_string(400) if isinstance(field, CharListField): @@ -151,7 +189,7 @@ def random_val(self, field): if isinstance(field, TextField): return get_random_string(400) if isinstance(field, MimeContentField): - return get_random_string(400).encode('utf-8') + return get_random_string(400).encode("utf-8") if isinstance(field, Base64Field): return get_random_bytes(400) if isinstance(field, BooleanField): @@ -167,7 +205,7 @@ def random_val(self, field): if isinstance(field, DateTimeField): return get_random_datetime(tz=self.account.default_timezone) if isinstance(field, AttachmentField): - return [FileAttachment(name='my_file.txt', content=get_random_string(400).encode('utf-8'))] + return [FileAttachment(name="my_file.txt", content=get_random_string(400).encode("utf-8"))] if isinstance(field, MailboxListField): # email_address must be a real account on the server(?) # TODO: Mailbox has multiple optional args but vals must match server account, so we can't easily test @@ -189,32 +227,40 @@ def random_val(self, field): with_last_response_time = get_random_bool() if with_last_response_time: return [ - Attendee(mailbox=mbx, response_type='Accept', - last_response_time=get_random_datetime(tz=self.account.default_timezone)) + Attendee( + mailbox=mbx, + response_type="Accept", + last_response_time=get_random_datetime(tz=self.account.default_timezone), + ) ] if get_random_bool(): - return [Attendee(mailbox=mbx, response_type='Accept')] + return [Attendee(mailbox=mbx, response_type="Accept")] return [self.account.primary_smtp_address] if isinstance(field, EmailAddressesField): addrs = [] - for label in EmailAddress.get_field_by_fieldname('label').supported_choices(version=self.account.version): + for label in EmailAddress.get_field_by_fieldname("label").supported_choices(version=self.account.version): addr = EmailAddress(email=get_random_email()) addr.label = label addrs.append(addr) return addrs if isinstance(field, PhysicalAddressField): addrs = [] - for label in PhysicalAddress.get_field_by_fieldname('label')\ - .supported_choices(version=self.account.version): - addr = PhysicalAddress(street=get_random_string(32), city=get_random_string(32), - state=get_random_string(32), country=get_random_string(32), - zipcode=get_random_string(8)) + for label in PhysicalAddress.get_field_by_fieldname("label").supported_choices( + version=self.account.version + ): + addr = PhysicalAddress( + street=get_random_string(32), + city=get_random_string(32), + state=get_random_string(32), + country=get_random_string(32), + zipcode=get_random_string(8), + ) addr.label = label addrs.append(addr) return addrs if isinstance(field, PhoneNumberField): pns = [] - for label in PhoneNumber.get_field_by_fieldname('label').supported_choices(version=self.account.version): + for label in PhoneNumber.get_field_by_fieldname("label").supported_choices(version=self.account.version): pn = PhoneNumber(phone_number=get_random_string(16)) pn.label = label pns.append(pn) @@ -262,7 +308,7 @@ def random_val(self, field): ) ] ) - raise ValueError(f'Unknown field {field}') + raise ValueError(f"Unknown field {field}") def get_random_bool(): @@ -275,8 +321,8 @@ def get_random_int(min_val=0, max_val=2147483647): def get_random_decimal(min_val=0, max_val=100): precision = 2 - val = get_random_int(min_val, max_val * 10**precision) / 10.0**precision - return Decimal(f'{val:.2f}') + val = get_random_int(min_val, max_val * 10 ** precision) / 10.0 ** precision + return Decimal(f"{val:.2f}") def get_random_choice(choices): @@ -286,11 +332,11 @@ def get_random_choice(choices): def get_random_string(length, spaces=True, special=True): chars = string.ascii_letters + string.digits if special: - chars += ':.-_' + chars += ":.-_" if spaces: - chars += ' ' + chars += " " # We want random strings that don't end in spaces - Exchange strips these - res = ''.join(map(lambda i: random.choice(chars), range(length))).strip() + res = "".join(map(lambda i: random.choice(chars), range(length))).strip() if len(res) < length: # If strip() made the string shorter, make sure to fill it up res += get_random_string(length - len(res), spaces=False) @@ -314,22 +360,21 @@ def get_random_bytes(length): def get_random_hostname(): domain_len = random.randint(1, 30) tld_len = random.randint(2, 4) - return '%s.%s' % tuple(get_random_string(i, spaces=False, special=False).lower() for i in (domain_len, tld_len)) + return "%s.%s" % tuple(get_random_string(i, spaces=False, special=False).lower() for i in (domain_len, tld_len)) def get_random_url(): path_len = random.randint(1, 16) - return 'http://%s/%s.html' % (get_random_hostname(), get_random_string(path_len, spaces=False, special=False)) + return "http://%s/%s.html" % (get_random_hostname(), get_random_string(path_len, spaces=False, special=False)) def get_random_email(): account_len = random.randint(1, 6) domain_len = random.randint(1, 30) tld_len = random.randint(2, 4) - return '%s@%s.%s' % tuple(map( - lambda i: get_random_string(i, spaces=False, special=False).lower(), - (account_len, domain_len, tld_len) - )) + return "%s@%s.%s" % tuple( + map(lambda i: get_random_string(i, spaces=False, special=False).lower(), (account_len, domain_len, tld_len)) + ) def _total_minutes(tm): @@ -347,7 +392,7 @@ def get_random_time(start_time=datetime.time.min, end_time=datetime.time.max): # does not observe that, but IANA does. So random datetimes before 1996 will fail tests randomly. RANDOM_DATE_MIN = datetime.date(1996, 1, 1) RANDOM_DATE_MAX = datetime.date(2030, 1, 1) -UTC = zoneinfo.ZoneInfo('UTC') +UTC = zoneinfo.ZoneInfo("UTC") def get_random_date(start_date=RANDOM_DATE_MIN, end_date=RANDOM_DATE_MAX): @@ -359,8 +404,9 @@ def get_random_datetime(start_date=RANDOM_DATE_MIN, end_date=RANDOM_DATE_MAX, tz # Create a random datetime with minute precision. Both dates are inclusive. # Keep with a reasonable date range. A wider date range than the default values is unstable WRT timezones. random_date = get_random_date(start_date=start_date, end_date=end_date) - random_datetime = datetime.datetime.combine(random_date, datetime.time.min) \ - + datetime.timedelta(minutes=random.randint(0, 60 * 24)) + random_datetime = datetime.datetime.combine(random_date, datetime.time.min) + datetime.timedelta( + minutes=random.randint(0, 60 * 24) + ) return random_datetime.replace(tzinfo=tz) @@ -368,7 +414,9 @@ def get_random_datetime_range(start_date=RANDOM_DATE_MIN, end_date=RANDOM_DATE_M # Create two random datetimes. Both dates are inclusive. # Keep with a reasonable date range. A wider date range than the default values is unstable WRT timezones. # Calendar items raise ErrorCalendarDurationIsTooLong if duration is > 5 years. - return sorted([ - get_random_datetime(start_date=start_date, end_date=end_date, tz=tz), - get_random_datetime(start_date=start_date, end_date=end_date, tz=tz), - ]) + return sorted( + [ + get_random_datetime(start_date=start_date, end_date=end_date, tz=tz), + get_random_datetime(start_date=start_date, end_date=end_date, tz=tz), + ] + ) diff --git a/tests/test_account.py b/tests/test_account.py index 7e7554f7..b3287e7a 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -1,22 +1,35 @@ -from collections import namedtuple import pickle - +from collections import namedtuple from unittest.mock import patch from exchangelib.account import Account from exchangelib.attachments import FileAttachment from exchangelib.configuration import Configuration -from exchangelib.credentials import Credentials, DELEGATE -from exchangelib.errors import ErrorAccessDenied, ErrorFolderNotFound, UnauthorizedError, ErrorNotDelegate, \ - ErrorDelegateNoUser, UnknownTimeZone, ErrorInvalidUserSid +from exchangelib.credentials import DELEGATE, Credentials +from exchangelib.errors import ( + ErrorAccessDenied, + ErrorDelegateNoUser, + ErrorFolderNotFound, + ErrorInvalidUserSid, + ErrorNotDelegate, + UnauthorizedError, + UnknownTimeZone, +) from exchangelib.ewsdatetime import UTC from exchangelib.folders import Calendar from exchangelib.items import Message -from exchangelib.properties import DelegateUser, UserId, DelegatePermissions, SendingAs, MailTips, RecipientAddress, \ - OutOfOffice -from exchangelib.protocol import Protocol, FaultTolerance +from exchangelib.properties import ( + DelegatePermissions, + DelegateUser, + MailTips, + OutOfOffice, + RecipientAddress, + SendingAs, + UserId, +) +from exchangelib.protocol import FaultTolerance, Protocol from exchangelib.services import GetDelegate, GetMailTips -from exchangelib.version import Version, EXCHANGE_2007_SP1 +from exchangelib.version import EXCHANGE_2007_SP1, Version from .common import EWSTest @@ -25,47 +38,47 @@ class AccountTest(EWSTest): """Test features of the Account object.""" def test_magic(self): - self.account.fullname = 'John Doe' + self.account.fullname = "John Doe" self.assertIn(self.account.primary_smtp_address, str(self.account)) self.assertIn(self.account.fullname, str(self.account)) def test_validation(self): with self.assertRaises(ValueError) as e: # Must have valid email address - Account(primary_smtp_address='blah') + Account(primary_smtp_address="blah") self.assertEqual(str(e.exception), "primary_smtp_address 'blah' is not an email address") with self.assertRaises(AttributeError) as e: # Non-autodiscover requires a config - Account(primary_smtp_address='blah@example.com', autodiscover=False) - self.assertEqual(str(e.exception), 'non-autodiscover requires a config') + Account(primary_smtp_address="blah@example.com", autodiscover=False) + self.assertEqual(str(e.exception), "non-autodiscover requires a config") with self.assertRaises(ValueError) as e: - Account(primary_smtp_address='blah@example.com', access_type=123) + Account(primary_smtp_address="blah@example.com", access_type=123) self.assertEqual(str(e.exception), "'access_type' 123 must be one of ['delegate', 'impersonation']") with self.assertRaises(TypeError) as e: # locale must be a string - Account(primary_smtp_address='blah@example.com', locale=123) + Account(primary_smtp_address="blah@example.com", locale=123) self.assertEqual(str(e.exception), "'locale' 123 must be of type ") with self.assertRaises(TypeError) as e: # default timezone must be an EWSTimeZone - Account(primary_smtp_address='blah@example.com', default_timezone=123) + Account(primary_smtp_address="blah@example.com", default_timezone=123) self.assertEqual( str(e.exception), "'default_timezone' 123 must be of type " ) with self.assertRaises(TypeError) as e: # config must be a Configuration - Account(primary_smtp_address='blah@example.com', config=123) + Account(primary_smtp_address="blah@example.com", config=123) self.assertEqual( str(e.exception), "'config' 123 must be of type " ) - @patch('locale.getlocale', side_effect=ValueError()) + @patch("locale.getlocale", side_effect=ValueError()) def test_getlocale_failure(self, m): a = Account( primary_smtp_address=self.account.primary_smtp_address, access_type=DELEGATE, config=Configuration( service_endpoint=self.account.protocol.service_endpoint, - credentials=Credentials(self.account.protocol.credentials.username, 'WRONG_PASSWORD'), + credentials=Credentials(self.account.protocol.credentials.username, "WRONG_PASSWORD"), version=self.account.version, auth_type=self.account.protocol.auth_type, retry_policy=self.retry_policy, @@ -74,14 +87,14 @@ def test_getlocale_failure(self, m): ) self.assertEqual(a.locale, None) - @patch('tzlocal.get_localzone', side_effect=UnknownTimeZone('')) + @patch("tzlocal.get_localzone", side_effect=UnknownTimeZone("")) def test_tzlocal_failure(self, m): a = Account( primary_smtp_address=self.account.primary_smtp_address, access_type=DELEGATE, config=Configuration( service_endpoint=self.account.protocol.service_endpoint, - credentials=Credentials(self.account.protocol.credentials.username, 'WRONG_PASSWORD'), + credentials=Credentials(self.account.protocol.credentials.username, "WRONG_PASSWORD"), version=self.account.version, auth_type=self.account.protocol.auth_type, retry_policy=self.retry_policy, @@ -100,7 +113,7 @@ def test_get_default_folder(self): class MockCalendar1(Calendar): @classmethod def get_distinguished(cls, root): - raise ErrorAccessDenied('foo') + raise ErrorAccessDenied("foo") # Test an indirect folder lookup with FindItems folder = self.account.root.get_default_folder(MockCalendar1) @@ -111,7 +124,7 @@ def get_distinguished(cls, root): class MockCalendar2(Calendar): @classmethod def get_distinguished(cls, root): - raise ErrorFolderNotFound('foo') + raise ErrorFolderNotFound("foo") # Test using the one folder of this folder type with self.assertRaises(ErrorFolderNotFound): @@ -130,8 +143,8 @@ def get_distinguished(cls, root): def test_pickle(self): # Test that we can pickle various objects - item = Message(folder=self.account.inbox, subject='XXX', categories=self.categories).save() - attachment = FileAttachment(name='pickle_me.txt', content=b'') + item = Message(folder=self.account.inbox, subject="XXX", categories=self.categories).save() + attachment = FileAttachment(name="pickle_me.txt", content=b"") for o in ( FaultTolerance(max_wait=3600), self.account.protocol, @@ -153,12 +166,14 @@ def test_mail_tips(self): # Test that mail tips work self.assertEqual(self.account.mail_tips.recipient_address, self.account.primary_smtp_address) # recipients must not be empty - list(GetMailTips(protocol=self.account.protocol).call( - sending_as=SendingAs(email_address=self.account.primary_smtp_address), - recipients=[], - mail_tips_requested='All', - )) - xml = b'''\ + list( + GetMailTips(protocol=self.account.protocol).call( + sending_as=SendingAs(email_address=self.account.primary_smtp_address), + recipients=[], + mail_tips_requested="All", + ) + ) + xml = b"""\ @@ -185,14 +200,16 @@ def test_mail_tips(self): -''' +""" self.assertEqual( list(GetMailTips(protocol=None).parse(xml)), - [MailTips( - recipient_address=RecipientAddress(email_address='user2@contoso.com'), - out_of_office=OutOfOffice(), - custom_mail_tip='Hello World Mailtips', - )] + [ + MailTips( + recipient_address=RecipientAddress(email_address="user2@contoso.com"), + out_of_office=OutOfOffice(), + custom_mail_tip="Hello World Mailtips", + ) + ], ) def test_delegate(self): @@ -200,15 +217,17 @@ def test_delegate(self): # of a non-empty response. self.assertGreaterEqual(len(self.account.delegates), 0) with self.assertRaises(ErrorInvalidUserSid): - list(GetDelegate(account=self.account).call(user_ids=[UserId(sid='XXX')], include_permissions=True)) + list(GetDelegate(account=self.account).call(user_ids=[UserId(sid="XXX")], include_permissions=True)) with self.assertRaises(ErrorDelegateNoUser): - list(GetDelegate(account=self.account).call(user_ids=['foo@example.com'], include_permissions=True)) + list(GetDelegate(account=self.account).call(user_ids=["foo@example.com"], include_permissions=True)) with self.assertRaises(ErrorNotDelegate): - list(GetDelegate(account=self.account).call( - user_ids=[self.account.primary_smtp_address], include_permissions=True - )) + list( + GetDelegate(account=self.account).call( + user_ids=[self.account.primary_smtp_address], include_permissions=True + ) + ) - xml = b'''\ + xml = b"""\ @@ -237,13 +256,13 @@ def test_delegate(self): DelegatesAndMe -''' +""" - MockTZ = namedtuple('EWSTimeZone', ['ms_id']) - MockAccount = namedtuple('Account', ['access_type', 'primary_smtp_address', 'default_timezone', 'protocol']) - MockProtocol = namedtuple('Protocol', ['version']) + MockTZ = namedtuple("EWSTimeZone", ["ms_id"]) + MockAccount = namedtuple("Account", ["access_type", "primary_smtp_address", "default_timezone", "protocol"]) + MockProtocol = namedtuple("Protocol", ["version"]) p = MockProtocol(version=Version(build=EXCHANGE_2007_SP1)) - a = MockAccount(DELEGATE, 'foo@example.com', MockTZ('XXX'), protocol=p) + a = MockAccount(DELEGATE, "foo@example.com", MockTZ("XXX"), protocol=p) ws = GetDelegate(account=a) delegates = list(ws.parse(xml)) @@ -251,19 +270,19 @@ def test_delegate(self): delegates, [ DelegateUser( - user_id=UserId(sid='SOME_SID', primary_smtp_address='foo@example.com', display_name='Foo Bar'), + user_id=UserId(sid="SOME_SID", primary_smtp_address="foo@example.com", display_name="Foo Bar"), delegate_permissions=DelegatePermissions( - calendar_folder_permission_level='Author', - inbox_folder_permission_level='Reviewer', - contacts_folder_permission_level='None', - notes_folder_permission_level='None', - journal_folder_permission_level='None', - tasks_folder_permission_level='None', + calendar_folder_permission_level="Author", + inbox_folder_permission_level="Reviewer", + contacts_folder_permission_level="None", + notes_folder_permission_level="None", + journal_folder_permission_level="None", + tasks_folder_permission_level="None", ), receive_copies_of_meeting_messages=False, view_private_items=True, ) - ] + ], ) def test_login_failure_and_credentials_update(self): @@ -273,13 +292,13 @@ def test_login_failure_and_credentials_update(self): access_type=DELEGATE, config=Configuration( service_endpoint=self.account.protocol.service_endpoint, - credentials=Credentials(self.account.protocol.credentials.username, 'WRONG_PASSWORD'), + credentials=Credentials(self.account.protocol.credentials.username, "WRONG_PASSWORD"), version=self.account.version, auth_type=self.account.protocol.auth_type, retry_policy=self.retry_policy, ), autodiscover=False, - locale='da_DK', + locale="da_DK", ) # Should fail when credentials are wrong, but UnauthorizedError is caught and retried. Mock the needed methods @@ -291,7 +310,7 @@ def may_retry_on_error(self, response, wait): def raise_response_errors(self, response): if response.status_code == 401: - raise UnauthorizedError(f'Invalid credentials for {response.url}') + raise UnauthorizedError(f"Invalid credentials for {response.url}") return super().raise_response_errors(response) try: @@ -310,18 +329,26 @@ def raise_response_errors(self, response): def test_protocol_default_values(self): # Test that retry_policy and auth_type always get a value regardless of how we create an Account - c = Credentials(self.settings['username'], self.settings['password']) - a = Account(self.account.primary_smtp_address, autodiscover=False, config=Configuration( - server=self.settings['server'], - credentials=c, - )) + c = Credentials(self.settings["username"], self.settings["password"]) + a = Account( + self.account.primary_smtp_address, + autodiscover=False, + config=Configuration( + server=self.settings["server"], + credentials=c, + ), + ) self.assertIsNotNone(a.protocol.auth_type) self.assertIsNotNone(a.protocol.retry_policy) - a = Account(self.account.primary_smtp_address, autodiscover=True, config=Configuration( - server=self.settings['server'], - credentials=c, - )) + a = Account( + self.account.primary_smtp_address, + autodiscover=True, + config=Configuration( + server=self.settings["server"], + credentials=c, + ), + ) self.assertIsNotNone(a.protocol.auth_type) self.assertIsNotNone(a.protocol.retry_policy) diff --git a/tests/test_attachments.py b/tests/test_attachments.py index bb2bd629..4f17112f 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -1,5 +1,5 @@ -from exchangelib.attachments import FileAttachment, ItemAttachment, AttachmentId -from exchangelib.errors import ErrorItemNotFound, ErrorInvalidIdMalformed +from exchangelib.attachments import AttachmentId, FileAttachment, ItemAttachment +from exchangelib.errors import ErrorInvalidIdMalformed, ErrorItemNotFound from exchangelib.fields import FieldPath from exchangelib.folders import Inbox from exchangelib.items import Item, Message @@ -7,23 +7,23 @@ from exchangelib.services import GetAttachment from exchangelib.util import chunkify -from .test_items.test_basics import BaseItemTest from .common import get_random_string +from .test_items.test_basics import BaseItemTest class AttachmentsTest(BaseItemTest): - TEST_FOLDER = 'inbox' + TEST_FOLDER = "inbox" FOLDER_CLASS = Inbox ITEM_CLASS = Message def test_magic(self): - for item in (FileAttachment(name='XXX'), ItemAttachment(name='XXX')): - self.assertIn('name=', str(item)) + for item in (FileAttachment(name="XXX"), ItemAttachment(name="XXX")): + self.assertIn("name=", str(item)) self.assertIn(item.__class__.__name__, repr(item)) def test_attachment_failure(self): - att1 = FileAttachment(name='my_file_1.txt', content='Hello from unicode æøå'.encode('utf-8')) - att1.attachment_id = 'XXX' + att1 = FileAttachment(name="my_file_1.txt", content="Hello from unicode æøå".encode("utf-8")) + att1.attachment_id = "XXX" with self.assertRaises(ValueError) as e: att1.attach() self.assertEqual(e.exception.args[0], "This attachment has already been created") @@ -39,7 +39,7 @@ def test_attachment_failure(self): with self.assertRaises(ValueError) as e: att1.detach() self.assertEqual(e.exception.args[0], "This attachment has not been created") - att1.attachment_id = 'XXX' + att1.attachment_id = "XXX" with self.assertRaises(ValueError) as e: att1.detach() self.assertEqual(e.exception.args[0], "Parent item None must have an account") @@ -47,7 +47,7 @@ def test_attachment_failure(self): with self.assertRaises(ValueError) as e: att1.detach() self.assertEqual(e.exception.args[0], "Parent item Item(attachments=[]) must have an account") - att1.parent_item = 'XXX' + att1.parent_item = "XXX" with self.assertRaises(TypeError) as e: att1.clean() self.assertEqual( @@ -55,20 +55,20 @@ def test_attachment_failure(self): ) with self.assertRaises(ValueError) as e: Message(attachments=[att1]) - self.assertIn('must point to this item', e.exception.args[0]) + self.assertIn("must point to this item", e.exception.args[0]) att1.parent_item = None att1.attachment_id = None def test_file_attachment_properties(self): - binary_file_content = 'Hello from unicode æøå'.encode('utf-8') - att1 = FileAttachment(name='my_file_1.txt', content=binary_file_content) + binary_file_content = "Hello from unicode æøå".encode("utf-8") + att1 = FileAttachment(name="my_file_1.txt", content=binary_file_content) self.assertIn("name='my_file_1.txt'", str(att1)) att1.content = binary_file_content # Test property setter with self.assertRaises(TypeError) as e: - att1.content = 'XXX' + att1.content = "XXX" self.assertEqual(e.exception.args[0], "'value' 'XXX' must be of type ") self.assertEqual(att1.content, binary_file_content) # Test property getter - att1.attachment_id = 'xxx' + att1.attachment_id = "xxx" self.assertEqual(att1.content, binary_file_content) # Test property getter when attachment_id is set att1._content = None with self.assertRaises(ValueError): @@ -76,15 +76,15 @@ def test_file_attachment_properties(self): def test_item_attachment_properties(self): attached_item1 = self.get_test_item(folder=self.test_folder) - att1 = ItemAttachment(name='attachment1', item=attached_item1) + att1 = ItemAttachment(name="attachment1", item=attached_item1) self.assertIn("name='attachment1'", str(att1)) att1.item = attached_item1 # Test property setter with self.assertRaises(TypeError) as e: - att1.item = 'XXX' + att1.item = "XXX" self.assertEqual(e.exception.args[0], "'value' 'XXX' must be of type ") self.assertEqual(att1.item, attached_item1) # Test property getter self.assertEqual(att1.item, attached_item1) # Test property getter - att1.attachment_id = 'xxx' + att1.attachment_id = "xxx" self.assertEqual(att1.item, attached_item1) # Test property getter when attachment_id is set att1._item = None with self.assertRaises(ValueError): @@ -93,7 +93,7 @@ def test_item_attachment_properties(self): def test_item_attachments(self): item = self.get_test_item(folder=self.test_folder) attached_item1 = self.get_test_item(folder=self.test_folder) - att1 = ItemAttachment(name='attachment1', item=attached_item1) + att1 = ItemAttachment(name="attachment1", item=attached_item1) # Test __init__(attachments=...) and attach() on new item self.assertEqual(len(item.attachments), 0) @@ -103,24 +103,24 @@ def test_item_attachments(self): fresh_item = self.get_item_by_id(item) self.assertEqual(len(fresh_item.attachments), 1) fresh_attachments = sorted(fresh_item.attachments, key=lambda a: a.name) - self.assertEqual(fresh_attachments[0].name, 'attachment1') + self.assertEqual(fresh_attachments[0].name, "attachment1") self.assertEqual(fresh_attachments[0].item.subject, attached_item1.subject) self.assertEqual(fresh_attachments[0].item.body, attached_item1.body) # Same as 'body' because 'body' doesn't contain HTML self.assertEqual(fresh_attachments[0].item.text_body, attached_item1.body) # Test attach on saved object - att2 = ItemAttachment(name='attachment2', item=attached_item1) + att2 = ItemAttachment(name="attachment2", item=attached_item1) self.assertEqual(len(item.attachments), 1) item.attach(att2) self.assertEqual(len(item.attachments), 2) fresh_item = self.get_item_by_id(item) self.assertEqual(len(fresh_item.attachments), 2) fresh_attachments = sorted(fresh_item.attachments, key=lambda a: a.name) - self.assertEqual(fresh_attachments[0].name, 'attachment1') + self.assertEqual(fresh_attachments[0].name, "attachment1") self.assertEqual(fresh_attachments[0].item.subject, attached_item1.subject) self.assertEqual(fresh_attachments[0].item.body, attached_item1.body) - self.assertEqual(fresh_attachments[1].name, 'attachment2') + self.assertEqual(fresh_attachments[1].name, "attachment2") self.assertEqual(fresh_attachments[1].item.subject, attached_item1.subject) self.assertEqual(fresh_attachments[1].item.body, attached_item1.body) @@ -131,46 +131,55 @@ def test_item_attachments(self): fresh_item = self.get_item_by_id(item) self.assertEqual(len(fresh_item.attachments), 1) fresh_attachments = sorted(fresh_item.attachments, key=lambda a: a.name) - self.assertEqual(fresh_attachments[0].name, 'attachment2') + self.assertEqual(fresh_attachments[0].name, "attachment2") self.assertEqual(fresh_attachments[0].item.subject, attached_item1.subject) self.assertEqual(fresh_attachments[0].item.body, attached_item1.body) def test_raw_service_call(self): item = self.get_test_item(folder=self.test_folder) attached_item1 = self.get_test_item(folder=self.test_folder) - attached_item1.body = HTMLBody('Hello HTML') - att1 = ItemAttachment(name='attachment1', item=attached_item1) + attached_item1.body = HTMLBody("Hello HTML") + att1 = ItemAttachment(name="attachment1", item=attached_item1) item.attach(att1) item.save() with self.assertRaises(ValueError): # Bad body_type GetAttachment(account=att1.parent_item.account).get( - items=[att1.attachment_id], include_mime_content=True, body_type='XXX', filter_html_content=None, + items=[att1.attachment_id], + include_mime_content=True, + body_type="XXX", + filter_html_content=None, additional_fields=[], ) # Test body_type attachment = GetAttachment(account=att1.parent_item.account).get( - items=[att1.attachment_id], include_mime_content=True, body_type='Text', filter_html_content=None, - additional_fields=[FieldPath(field=self.ITEM_CLASS.get_field_by_fieldname('body'))], + items=[att1.attachment_id], + include_mime_content=True, + body_type="Text", + filter_html_content=None, + additional_fields=[FieldPath(field=self.ITEM_CLASS.get_field_by_fieldname("body"))], ) - self.assertEqual(attachment.item.body, 'Hello HTML\r\n') + self.assertEqual(attachment.item.body, "Hello HTML\r\n") # Test filter_html_content. I wonder what unsafe HTML is. attachment = GetAttachment(account=att1.parent_item.account).get( - items=[att1.attachment_id], include_mime_content=False, body_type='HTML', filter_html_content=True, - additional_fields=[FieldPath(field=self.ITEM_CLASS.get_field_by_fieldname('body'))], + items=[att1.attachment_id], + include_mime_content=False, + body_type="HTML", + filter_html_content=True, + additional_fields=[FieldPath(field=self.ITEM_CLASS.get_field_by_fieldname("body"))], ) self.assertEqual( attachment.item.body, '\r\n\r\n\r\n' - '\r\n\r\nHello HTML\r\n\r\n\r\n' + "\r\n\r\nHello HTML\r\n\r\n\r\n", ) def test_file_attachments(self): item = self.get_test_item(folder=self.test_folder) # Test __init__(attachments=...) and attach() on new item - binary_file_content = 'Hello from unicode æøå'.encode('utf-8') - att1 = FileAttachment(name='my_file_1.txt', content=binary_file_content) + binary_file_content = "Hello from unicode æøå".encode("utf-8") + att1 = FileAttachment(name="my_file_1.txt", content=binary_file_content) self.assertEqual(len(item.attachments), 0) item.attach(att1) self.assertEqual(len(item.attachments), 1) @@ -178,20 +187,20 @@ def test_file_attachments(self): fresh_item = self.get_item_by_id(item) self.assertEqual(len(fresh_item.attachments), 1) fresh_attachments = sorted(fresh_item.attachments, key=lambda a: a.name) - self.assertEqual(fresh_attachments[0].name, 'my_file_1.txt') + self.assertEqual(fresh_attachments[0].name, "my_file_1.txt") self.assertEqual(fresh_attachments[0].content, binary_file_content) # Test attach on saved object - att2 = FileAttachment(name='my_file_2.txt', content=binary_file_content) + att2 = FileAttachment(name="my_file_2.txt", content=binary_file_content) self.assertEqual(len(item.attachments), 1) item.attach(att2) self.assertEqual(len(item.attachments), 2) fresh_item = self.get_item_by_id(item) self.assertEqual(len(fresh_item.attachments), 2) fresh_attachments = sorted(fresh_item.attachments, key=lambda a: a.name) - self.assertEqual(fresh_attachments[0].name, 'my_file_1.txt') + self.assertEqual(fresh_attachments[0].name, "my_file_1.txt") self.assertEqual(fresh_attachments[0].content, binary_file_content) - self.assertEqual(fresh_attachments[1].name, 'my_file_2.txt') + self.assertEqual(fresh_attachments[1].name, "my_file_2.txt") self.assertEqual(fresh_attachments[1].content, binary_file_content) # Test detach @@ -201,13 +210,13 @@ def test_file_attachments(self): fresh_item = self.get_item_by_id(item) self.assertEqual(len(fresh_item.attachments), 1) fresh_attachments = sorted(fresh_item.attachments, key=lambda a: a.name) - self.assertEqual(fresh_attachments[0].name, 'my_file_2.txt') + self.assertEqual(fresh_attachments[0].name, "my_file_2.txt") self.assertEqual(fresh_attachments[0].content, binary_file_content) def test_streaming_file_attachments(self): item = self.get_test_item(folder=self.test_folder) - large_binary_file_content = get_random_string(2**10).encode('utf-8') - large_att = FileAttachment(name='my_large_file.txt', content=large_binary_file_content) + large_binary_file_content = get_random_string(2 ** 10).encode("utf-8") + large_att = FileAttachment(name="my_large_file.txt", content=large_binary_file_content) item.attach(large_att) item.save() @@ -232,47 +241,42 @@ def test_streaming_file_attachment_error(self): # Try to stram an attachment with malformed ID att = FileAttachment( parent_item=self.get_test_item(folder=self.test_folder), - attachment_id=AttachmentId(id='AAMk='), - name='dummy.txt', - content=b'', + attachment_id=AttachmentId(id="AAMk="), + name="dummy.txt", + content=b"", ) with self.assertRaises(ErrorInvalidIdMalformed): with att.fp as fp: fp.read() # Try to stream a non-existent attachment - att.attachment_id.id = \ - 'AAMkADQyYzZmYmUxLTJiYjItNDg2Ny1iMzNjLTIzYWE1NDgxNmZhNABGAAAAAADUebQDarW2Q7G2Ji8hKofPBwAl9iKCsfCfS' \ - 'a9cmjh+JCrCAAPJcuhjAABioKiOUTCQRI6Q5sRzi0pJAAHnDV3CAAABEgAQAN0zlxDrzlxAteU+kt84qOM=' + att.attachment_id.id = ( + "AAMkADQyYzZmYmUxLTJiYjItNDg2Ny1iMzNjLTIzYWE1NDgxNmZhNABGAAAAAADUebQDarW2Q7G2Ji8hKofPBwAl9iKCsfCfS" + "a9cmjh+JCrCAAPJcuhjAABioKiOUTCQRI6Q5sRzi0pJAAHnDV3CAAABEgAQAN0zlxDrzlxAteU+kt84qOM=" + ) with self.assertRaises(ErrorItemNotFound): with att.fp as fp: fp.read() def test_empty_file_attachment(self): item = self.get_test_item(folder=self.test_folder) - att1 = FileAttachment(name='empty_file.txt', content=b'') + att1 = FileAttachment(name="empty_file.txt", content=b"") item.attach(att1) item.save() fresh_item = self.get_item_by_id(item) - self.assertEqual( - fresh_item.attachments[0].content, - b'' - ) + self.assertEqual(fresh_item.attachments[0].content, b"") def test_both_attachment_types(self): item = self.get_test_item(folder=self.test_folder) attached_item = self.get_test_item(folder=self.test_folder).save() - item_attachment = ItemAttachment(name='item_attachment', item=attached_item) - file_attachment = FileAttachment(name='file_attachment', content=b'file_attachment') + item_attachment = ItemAttachment(name="item_attachment", item=attached_item) + file_attachment = FileAttachment(name="file_attachment", content=b"file_attachment") item.attach(item_attachment) item.attach(file_attachment) item.save() fresh_item = self.get_item_by_id(item) - self.assertSetEqual( - {a.name for a in fresh_item.attachments}, - {'item_attachment', 'file_attachment'} - ) + self.assertSetEqual({a.name for a in fresh_item.attachments}, {"item_attachment", "file_attachment"}) def test_recursive_attachments(self): # Test that we can handle an item which has an attached item, which has an attached item... @@ -282,32 +286,30 @@ def test_recursive_attachments(self): attached_item_level_3 = self.get_test_item(folder=self.test_folder) attached_item_level_3.save() - attachment_level_3 = ItemAttachment(name='attached_item_level_3', item=attached_item_level_3) + attachment_level_3 = ItemAttachment(name="attached_item_level_3", item=attached_item_level_3) attached_item_level_2.attach(attachment_level_3) attached_item_level_2.save() - attachment_level_2 = ItemAttachment(name='attached_item_level_2', item=attached_item_level_2) + attachment_level_2 = ItemAttachment(name="attached_item_level_2", item=attached_item_level_2) attached_item_level_1.attach(attachment_level_2) attached_item_level_1.save() - attachment_level_1 = ItemAttachment(name='attached_item_level_1', item=attached_item_level_1) + attachment_level_1 = ItemAttachment(name="attached_item_level_1", item=attached_item_level_1) item.attach(attachment_level_1) item.save() self.assertEqual( - item.attachments[0].item.attachments[0].item.attachments[0].item.subject, - attached_item_level_3.subject + item.attachments[0].item.attachments[0].item.attachments[0].item.subject, attached_item_level_3.subject ) # Also test a fresh item new_item = self.test_folder.get(id=item.id, changekey=item.changekey) self.assertEqual( - new_item.attachments[0].item.attachments[0].item.attachments[0].item.subject, - attached_item_level_3.subject + new_item.attachments[0].item.attachments[0].item.attachments[0].item.subject, attached_item_level_3.subject ) def test_detach_all(self): # Make sure that we can detach all by passing item.attachments item = self.get_test_item(folder=self.test_folder).save() - item.attach([FileAttachment(name='empty_file.txt', content=b'') for _ in range(6)]) + item.attach([FileAttachment(name="empty_file.txt", content=b"") for _ in range(6)]) self.assertEqual(len(item.attachments), 6) item.detach(item.attachments) self.assertEqual(len(item.attachments), 0) @@ -315,6 +317,6 @@ def test_detach_all(self): def test_detach_with_refresh(self): # Make sure that we can detach after refresh item = self.get_test_item(folder=self.test_folder).save() - item.attach(FileAttachment(name='empty_file.txt', content=b'')) + item.attach(FileAttachment(name="empty_file.txt", content=b"")) item.refresh() item.detach(item.attachments) diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 66dca08c..c1e5559c 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -1,7 +1,7 @@ -from collections import namedtuple import getpass import glob import sys +from collections import namedtuple from types import MethodType from unittest.mock import Mock, patch @@ -9,19 +9,28 @@ import requests_mock from exchangelib.account import Account -from exchangelib.credentials import Credentials, DELEGATE -from exchangelib.autodiscover import close_connections, clear_cache, autodiscover_cache, AutodiscoverProtocol, \ - Autodiscovery, AutodiscoverCache, discover +from exchangelib.autodiscover import ( + AutodiscoverCache, + AutodiscoverProtocol, + Autodiscovery, + autodiscover_cache, + clear_cache, + close_connections, + discover, +) from exchangelib.autodiscover.cache import shelve_filename from exchangelib.autodiscover.discovery import SrvRecord, _select_srv_host -from exchangelib.autodiscover.properties import Autodiscover, Response, Account as ADAccount, ErrorResponse, Error +from exchangelib.autodiscover.properties import Account as ADAccount +from exchangelib.autodiscover.properties import Autodiscover, Error, ErrorResponse, Response from exchangelib.configuration import Configuration -from exchangelib.errors import ErrorNonExistentMailbox, AutoDiscoverCircularRedirect, AutoDiscoverFailed -from exchangelib.protocol import FaultTolerance, FailFast -from exchangelib.transport import NTLM, NOAUTH -from exchangelib.util import get_domain, ParseError -from exchangelib.version import Version, EXCHANGE_2013 -from .common import EWSTest, get_random_string, get_random_hostname +from exchangelib.credentials import DELEGATE, Credentials +from exchangelib.errors import AutoDiscoverCircularRedirect, AutoDiscoverFailed, ErrorNonExistentMailbox +from exchangelib.protocol import FailFast, FaultTolerance +from exchangelib.transport import NOAUTH, NTLM +from exchangelib.util import ParseError, get_domain +from exchangelib.version import EXCHANGE_2013, Version + +from .common import EWSTest, get_random_hostname, get_random_string class AutodiscoverTest(EWSTest): @@ -37,13 +46,13 @@ def setUp(self): # Some mocking helpers self.domain = get_domain(self.account.primary_smtp_address) - self.dummy_ad_endpoint = f'https://{self.domain}/Autodiscover/Autodiscover.xml' - self.dummy_ews_endpoint = 'https://expr.example.com/EWS/Exchange.asmx' + self.dummy_ad_endpoint = f"https://{self.domain}/Autodiscover/Autodiscover.xml" + self.dummy_ews_endpoint = "https://expr.example.com/EWS/Exchange.asmx" self.dummy_ad_response = self.settings_xml(self.account.primary_smtp_address, self.dummy_ews_endpoint) @staticmethod def settings_xml(address, ews_url): - return f'''\ + return f"""\ @@ -59,11 +68,11 @@ def settings_xml(address, ews_url): -'''.encode() +""".encode() @staticmethod def redirect_address_xml(address): - return f'''\ + return f"""\ @@ -72,11 +81,11 @@ def redirect_address_xml(address): {address} -'''.encode() +""".encode() @staticmethod def redirect_url_xml(ews_url): - return f'''\ + return f"""\ @@ -85,16 +94,18 @@ def redirect_url_xml(ews_url): {ews_url} -'''.encode() +""".encode() @staticmethod def get_test_protocol(**kwargs): - return AutodiscoverProtocol(config=Configuration( - service_endpoint=kwargs.get('service_endpoint', 'https://example.com/Autodiscover/Autodiscover.xml'), - credentials=kwargs.get('credentials', Credentials(get_random_string(8), get_random_string(8))), - auth_type=kwargs.get('auth_type', NTLM), - retry_policy=kwargs.get('retry_policy', FailFast()), - )) + return AutodiscoverProtocol( + config=Configuration( + service_endpoint=kwargs.get("service_endpoint", "https://example.com/Autodiscover/Autodiscover.xml"), + credentials=kwargs.get("credentials", Credentials(get_random_string(8), get_random_string(8))), + auth_type=kwargs.get("auth_type", NTLM), + retry_policy=kwargs.get("retry_policy", FailFast()), + ) + ) @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here def test_magic(self, m): @@ -133,7 +144,7 @@ def test_autodiscover_empty_cache(self): def test_autodiscover_failure(self): # A live test that errors can be raised. Here, we try to aútodiscover a non-existing email address - if not self.settings.get('autodiscover_server'): + if not self.settings.get("autodiscover_server"): self.skipTest(f"Skipping {self.__class__.__name__} - no 'autodiscover_server' entry in settings.yml") # Autodiscovery may take a long time. Prime the cache with the autodiscover server from the config file ad_endpoint = f"https://{self.settings['autodiscover_server']}/Autodiscover/Autodiscover.xml" @@ -145,7 +156,7 @@ def test_autodiscover_failure(self): ) with self.assertRaises(ErrorNonExistentMailbox): discover( - email='XXX.' + self.account.primary_smtp_address, + email="XXX." + self.account.primary_smtp_address, credentials=self.account.protocol.credentials, retry_policy=self.retry_policy, ) @@ -155,9 +166,9 @@ def test_failed_login_via_account(self): Account( primary_smtp_address=self.account.primary_smtp_address, access_type=DELEGATE, - credentials=Credentials(self.account.protocol.credentials.username, 'WRONG_PASSWORD'), + credentials=Credentials(self.account.protocol.credentials.username, "WRONG_PASSWORD"), autodiscover=True, - locale='da_DK', + locale="da_DK", ) @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here @@ -190,15 +201,18 @@ def test_autodiscover_cache(self, m): # Now it's cached self.assertIn(discovery._cache_key, autodiscover_cache) # Make sure the cache can be looked by value, not by id(). This is important for multi-threading/processing - self.assertIn(( - self.account.primary_smtp_address.split('@')[1], - Credentials(self.account.protocol.credentials.username, self.account.protocol.credentials.password), - True - ), autodiscover_cache) + self.assertIn( + ( + self.account.primary_smtp_address.split("@")[1], + Credentials(self.account.protocol.credentials.username, self.account.protocol.credentials.password), + True, + ), + autodiscover_cache, + ) # Poison the cache with a failing autodiscover endpoint. discover() must handle this and rebuild the cache p = self.get_test_protocol() autodiscover_cache[discovery._cache_key] = p - m.post('https://example.com/Autodiscover/Autodiscover.xml', status_code=404) + m.post("https://example.com/Autodiscover/Autodiscover.xml", status_code=404) discovery.discover() self.assertIn(discovery._cache_key, autodiscover_cache) @@ -226,21 +240,21 @@ def _mock(slf, *args, **kwargs): @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here def test_corrupt_autodiscover_cache(self, m): # Insert a fake Protocol instance into the cache and test that we can recover - key = (2, 'foo', 4) - autodiscover_cache[key] = namedtuple('P', ['service_endpoint', 'auth_type', 'retry_policy'])(1, 'bar', 'baz') + key = (2, "foo", 4) + autodiscover_cache[key] = namedtuple("P", ["service_endpoint", "auth_type", "retry_policy"])(1, "bar", "baz") # Check that it exists. 'in' goes directly to the file self.assertTrue(key in autodiscover_cache) # Check that we can recover from a destroyed file - for db_file in glob.glob(autodiscover_cache._storage_file + '*'): - with open(db_file, 'w') as f: - f.write('XXX') + for db_file in glob.glob(autodiscover_cache._storage_file + "*"): + with open(db_file, "w") as f: + f.write("XXX") self.assertFalse(key in autodiscover_cache) # Check that we can recover from an empty file - for db_file in glob.glob(autodiscover_cache._storage_file + '*'): - with open(db_file, 'wb') as f: - f.write(b'') + for db_file in glob.glob(autodiscover_cache._storage_file + "*"): + with open(db_file, "wb") as f: + f.write(b"") self.assertFalse(key in autodiscover_cache) @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here @@ -257,7 +271,7 @@ def test_autodiscover_from_account(self, m): version=Version(build=EXCHANGE_2013), ), autodiscover=True, - locale='da_DK', + locale="da_DK", ) self.assertEqual(account.primary_smtp_address, self.account.primary_smtp_address) self.assertEqual(account.protocol.service_endpoint.lower(), self.dummy_ews_endpoint.lower()) @@ -272,7 +286,7 @@ def test_autodiscover_from_account(self, m): retry_policy=self.retry_policy, ), autodiscover=True, - locale='da_DK', + locale="da_DK", ) self.assertEqual(account.primary_smtp_address, self.account.primary_smtp_address) # Test cache manipulation @@ -294,27 +308,36 @@ def test_autodiscover_redirect(self, m): discovery.discover() # Make sure we discover a different return address - m.post(self.dummy_ad_endpoint, status_code=200, - content=self.settings_xml('john@example.com', 'https://expr.example.com/EWS/Exchange.asmx')) + m.post( + self.dummy_ad_endpoint, + status_code=200, + content=self.settings_xml("john@example.com", "https://expr.example.com/EWS/Exchange.asmx"), + ) ad_response, _ = discovery.discover() - self.assertEqual(ad_response.autodiscover_smtp_address, 'john@example.com') + self.assertEqual(ad_response.autodiscover_smtp_address, "john@example.com") # Make sure we discover an address redirect to the same domain. We have to mock the same URL with two different # responses. We do that with a response list. - m.post(self.dummy_ad_endpoint, [ - dict(status_code=200, content=self.redirect_address_xml(f'redirect_me@{self.domain}')), - dict(status_code=200, content=self.settings_xml( - f'redirected@{self.domain}', f'https://redirected.{self.domain}/EWS/Exchange.asmx' - )), - ]) + m.post( + self.dummy_ad_endpoint, + [ + dict(status_code=200, content=self.redirect_address_xml(f"redirect_me@{self.domain}")), + dict( + status_code=200, + content=self.settings_xml( + f"redirected@{self.domain}", f"https://redirected.{self.domain}/EWS/Exchange.asmx" + ), + ), + ], + ) ad_response, _ = discovery.discover() - self.assertEqual(ad_response.autodiscover_smtp_address, f'redirected@{self.domain}') - self.assertEqual(ad_response.protocol.ews_url, f'https://redirected.{self.domain}/EWS/Exchange.asmx') + self.assertEqual(ad_response.autodiscover_smtp_address, f"redirected@{self.domain}") + self.assertEqual(ad_response.protocol.ews_url, f"https://redirected.{self.domain}/EWS/Exchange.asmx") # Test that we catch circular redirects on the same domain with a primed cache. Just mock the endpoint to # return the same redirect response on every request. self.assertEqual(len(autodiscover_cache), 1) - m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_address_xml(f'foo@{self.domain}')) + m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_address_xml(f"foo@{self.domain}")) self.assertEqual(len(autodiscover_cache), 1) with self.assertRaises(AutoDiscoverCircularRedirect): discovery.discover() @@ -327,21 +350,24 @@ def test_autodiscover_redirect(self, m): # Test that we can handle being asked to redirect to an address on a different domain # Don't use example.com to redirect - it does not resolve or answer on all ISPs - ews_hostname = 'httpbin.org' - redirect_email = f'john@redirected.{ews_hostname}' - ews_url = f'https://{ews_hostname}/EWS/Exchange.asmx' - m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_address_xml(f'john@{ews_hostname}')) - m.post(f'https://{ews_hostname}/Autodiscover/Autodiscover.xml', status_code=200, - content=self.settings_xml(redirect_email, ews_url)) + ews_hostname = "httpbin.org" + redirect_email = f"john@redirected.{ews_hostname}" + ews_url = f"https://{ews_hostname}/EWS/Exchange.asmx" + m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_address_xml(f"john@{ews_hostname}")) + m.post( + f"https://{ews_hostname}/Autodiscover/Autodiscover.xml", + status_code=200, + content=self.settings_xml(redirect_email, ews_url), + ) ad_response, _ = discovery.discover() self.assertEqual(ad_response.autodiscover_smtp_address, redirect_email) self.assertEqual(ad_response.protocol.ews_url, ews_url) # Test redirect via HTTP 301 clear_cache() - redirect_url = f'https://{ews_hostname}/OtherPath/Autodiscover.xml' - redirect_email = f'john@otherpath.{ews_hostname}' - ews_url = f'https://xxx.{ews_hostname}/EWS/Exchange.asmx' + redirect_url = f"https://{ews_hostname}/OtherPath/Autodiscover.xml" + redirect_email = f"john@otherpath.{ews_hostname}" + ews_url = f"https://xxx.{ews_hostname}/EWS/Exchange.asmx" discovery.email = self.account.primary_smtp_address m.post(self.dummy_ad_endpoint, status_code=301, headers=dict(location=redirect_url)) m.post(redirect_url, status_code=200, content=self.settings_xml(redirect_email, ews_url)) @@ -355,11 +381,14 @@ def test_autodiscover_path_1_2_5(self, m): # Test steps 1 -> 2 -> 5 clear_cache() d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) - ews_url = f'https://xxx.{self.domain}/EWS/Exchange.asmx' - email = f'xxxd@{self.domain}' + ews_url = f"https://xxx.{self.domain}/EWS/Exchange.asmx" + email = f"xxxd@{self.domain}" m.post(self.dummy_ad_endpoint, status_code=501) - m.post(f'https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=200, - content=self.settings_xml(email, ews_url)) + m.post( + f"https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", + status_code=200, + content=self.settings_xml(email, ews_url), + ) ad_response, _ = d.discover() self.assertEqual(ad_response.autodiscover_smtp_address, email) self.assertEqual(ad_response.protocol.ews_url, ews_url) @@ -370,9 +399,12 @@ def test_autodiscover_path_1_2_3_invalid301_4(self, m): clear_cache() d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) m.post(self.dummy_ad_endpoint, status_code=501) - m.post(f'https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=501) - m.get(f'http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=301, - headers=dict(location='XXX')) + m.post(f"https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=501) + m.get( + f"http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", + status_code=301, + headers=dict(location="XXX"), + ) with self.assertRaises(AutoDiscoverFailed): # Fails in step 4 with invalid SRV entry @@ -383,8 +415,8 @@ def test_autodiscover_path_1_2_3_no301_4(self, m): # Test steps 1 -> 2 -> 3 -> no 301 response -> 4 d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) m.post(self.dummy_ad_endpoint, status_code=501) - m.post(f'https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=501) - m.get(f'http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=200) + m.post(f"https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=501) + m.get(f"http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=200) with self.assertRaises(AutoDiscoverFailed): # Fails in step 4 with invalid SRV entry @@ -394,12 +426,12 @@ def test_autodiscover_path_1_2_3_no301_4(self, m): def test_autodiscover_path_1_2_3_4_valid_srv_invalid_response(self, m): # Test steps 1 -> 2 -> 3 -> 4 -> invalid response from SRV URL d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) - redirect_srv = 'httpbin.org' + redirect_srv = "httpbin.org" m.post(self.dummy_ad_endpoint, status_code=501) - m.post(f'https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=501) - m.get(f'http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=200) - m.head(f'https://{redirect_srv}/Autodiscover/Autodiscover.xml', status_code=501) - m.post(f'https://{redirect_srv}/Autodiscover/Autodiscover.xml', status_code=501) + m.post(f"https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=501) + m.get(f"http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=200) + m.head(f"https://{redirect_srv}/Autodiscover/Autodiscover.xml", status_code=501) + m.post(f"https://{redirect_srv}/Autodiscover/Autodiscover.xml", status_code=501) tmp = d._get_srv_records d._get_srv_records = Mock(return_value=[SrvRecord(1, 1, 443, redirect_srv)]) @@ -414,15 +446,18 @@ def test_autodiscover_path_1_2_3_4_valid_srv_invalid_response(self, m): def test_autodiscover_path_1_2_3_4_valid_srv_valid_response(self, m): # Test steps 1 -> 2 -> 3 -> 4 -> 5 d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) - redirect_srv = 'httpbin.org' - ews_url = f'https://{redirect_srv}/EWS/Exchange.asmx' - redirect_email = f'john@redirected.{redirect_srv}' + redirect_srv = "httpbin.org" + ews_url = f"https://{redirect_srv}/EWS/Exchange.asmx" + redirect_email = f"john@redirected.{redirect_srv}" m.post(self.dummy_ad_endpoint, status_code=501) - m.post(f'https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=501) - m.get(f'http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=200) - m.head(f'https://{redirect_srv}/Autodiscover/Autodiscover.xml', status_code=200) - m.post(f'https://{redirect_srv}/Autodiscover/Autodiscover.xml', status_code=200, - content=self.settings_xml(redirect_email, ews_url)) + m.post(f"https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=501) + m.get(f"http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=200) + m.head(f"https://{redirect_srv}/Autodiscover/Autodiscover.xml", status_code=200) + m.post( + f"https://{redirect_srv}/Autodiscover/Autodiscover.xml", + status_code=200, + content=self.settings_xml(redirect_email, ews_url), + ) tmp = d._get_srv_records d._get_srv_records = Mock(return_value=[SrvRecord(1, 1, 443, redirect_srv)]) @@ -438,8 +473,8 @@ def test_autodiscover_path_1_2_3_4_invalid_srv(self, m): # Test steps 1 -> 2 -> 3 -> 4 -> invalid SRV URL d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) m.post(self.dummy_ad_endpoint, status_code=501) - m.post(f'https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=501) - m.get(f'http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml', status_code=200) + m.post(f"https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=501) + m.get(f"http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=200) tmp = d._get_srv_records d._get_srv_records = Mock(return_value=[SrvRecord(1, 1, 443, get_random_hostname())]) @@ -455,8 +490,11 @@ def test_autodiscover_path_1_5_invalid_redirect_url(self, m): # Test steps 1 -> -> 5 -> Invalid redirect URL clear_cache() d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) - m.post(self.dummy_ad_endpoint, status_code=200, - content=self.redirect_url_xml(f'https://{get_random_hostname()}/EWS/Exchange.asmx')) + m.post( + self.dummy_ad_endpoint, + status_code=200, + content=self.redirect_url_xml(f"https://{get_random_hostname()}/EWS/Exchange.asmx"), + ) with self.assertRaises(AutoDiscoverFailed): # Fails in step 5 with invalid redirect URL @@ -467,7 +505,7 @@ def test_autodiscover_path_1_5_valid_redirect_url_invalid_response(self, m): # Test steps 1 -> -> 5 -> Invalid response from redirect URL clear_cache() d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) - redirect_url = 'https://httpbin.org/Autodiscover/Autodiscover.xml' + redirect_url = "https://httpbin.org/Autodiscover/Autodiscover.xml" m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_url_xml(redirect_url)) m.head(redirect_url, status_code=501) m.post(redirect_url, status_code=501) @@ -481,10 +519,10 @@ def test_autodiscover_path_1_5_valid_redirect_url_valid_response(self, m): # Test steps 1 -> -> 5 -> Valid response from redirect URL -> 5 clear_cache() d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) - redirect_hostname = 'httpbin.org' - redirect_url = f'https://{redirect_hostname}/Autodiscover/Autodiscover.xml' - ews_url = f'https://{redirect_hostname}/EWS/Exchange.asmx' - email = f'john@redirected.{redirect_hostname}' + redirect_hostname = "httpbin.org" + redirect_url = f"https://{redirect_hostname}/Autodiscover/Autodiscover.xml" + ews_url = f"https://{redirect_hostname}/EWS/Exchange.asmx" + email = f"john@redirected.{redirect_hostname}" m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_url_xml(redirect_url)) m.head(redirect_url, status_code=200) m.post(redirect_url, status_code=200, content=self.settings_xml(email, ews_url)) @@ -494,11 +532,11 @@ def test_autodiscover_path_1_5_valid_redirect_url_valid_response(self, m): self.assertEqual(ad_response.protocol.ews_url, ews_url) def test_get_srv_records(self): - ad = Autodiscovery('foo@example.com') + ad = Autodiscovery("foo@example.com") # Unknown domain - self.assertEqual(ad._get_srv_records('example.XXXXX'), []) + self.assertEqual(ad._get_srv_records("example.XXXXX"), []) # No SRV record - self.assertEqual(ad._get_srv_records('example.com'), []) + self.assertEqual(ad._get_srv_records("example.com"), []) # Finding a real server that has a correct SRV record is not easy. Mock it _orig = dns.resolver.Resolver @@ -509,15 +547,15 @@ class A: @staticmethod def to_text(): # Return a valid record - return '1 2 3 example.com.' + return "1 2 3 example.com." + return [A()] dns.resolver.Resolver = _Mock1 del ad.resolver # Test a valid record self.assertEqual( - ad._get_srv_records('example.com.'), - [SrvRecord(priority=1, weight=2, port=3, srv='example.com')] + ad._get_srv_records("example.com."), [SrvRecord(priority=1, weight=2, port=3, srv="example.com")] ) class _Mock2: @@ -527,13 +565,14 @@ class A: @staticmethod def to_text(): # Return malformed data - return 'XXXXXXX' + return "XXXXXXX" + return [A()] dns.resolver.Resolver = _Mock2 del ad.resolver # Test an invalid record - self.assertEqual(ad._get_srv_records('example.com'), []) + self.assertEqual(ad._get_srv_records("example.com"), []) dns.resolver.Resolver = _orig del ad.resolver @@ -543,45 +582,48 @@ def test_select_srv_host(self): _select_srv_host([]) with self.assertRaises(ValueError): # No records with TLS port - _select_srv_host([SrvRecord(priority=1, weight=2, port=3, srv='example.com')]) + _select_srv_host([SrvRecord(priority=1, weight=2, port=3, srv="example.com")]) # One record self.assertEqual( - _select_srv_host([SrvRecord(priority=1, weight=2, port=443, srv='example.com')]), - 'example.com' + _select_srv_host([SrvRecord(priority=1, weight=2, port=443, srv="example.com")]), "example.com" ) # Highest priority record self.assertEqual( - _select_srv_host([ - SrvRecord(priority=10, weight=2, port=443, srv='10.example.com'), - SrvRecord(priority=1, weight=2, port=443, srv='1.example.com'), - ]), - '10.example.com' + _select_srv_host( + [ + SrvRecord(priority=10, weight=2, port=443, srv="10.example.com"), + SrvRecord(priority=1, weight=2, port=443, srv="1.example.com"), + ] + ), + "10.example.com", ) # Highest priority record no matter how it's sorted self.assertEqual( - _select_srv_host([ - SrvRecord(priority=1, weight=2, port=443, srv='1.example.com'), - SrvRecord(priority=10, weight=2, port=443, srv='10.example.com'), - ]), - '10.example.com' + _select_srv_host( + [ + SrvRecord(priority=1, weight=2, port=443, srv="1.example.com"), + SrvRecord(priority=10, weight=2, port=443, srv="10.example.com"), + ] + ), + "10.example.com", ) def test_parse_response(self): # Test parsing of various XML responses with self.assertRaises(ParseError) as e: - Autodiscover.from_bytes(b'XXX') # Invalid response + Autodiscover.from_bytes(b"XXX") # Invalid response self.assertEqual(e.exception.args[0], "Response is not XML: b'XXX'") - xml = b'''bar''' + xml = b"""bar""" with self.assertRaises(ParseError) as e: Autodiscover.from_bytes(xml) # Invalid XML response self.assertEqual( e.exception.args[0], - 'Unknown root element in XML: b\'bar\'' + 'Unknown root element in XML: b\'bar\'', ) # Redirect to different email address - xml = b'''\ + xml = b"""\ @@ -593,11 +635,11 @@ def test_parse_response(self): foo@example.com -''' - self.assertEqual(Autodiscover.from_bytes(xml).response.redirect_address, 'foo@example.com') +""" + self.assertEqual(Autodiscover.from_bytes(xml).response.redirect_address, "foo@example.com") # Redirect to different URL - xml = b'''\ + xml = b"""\ @@ -609,11 +651,11 @@ def test_parse_response(self): https://example.com/foo.asmx -''' - self.assertEqual(Autodiscover.from_bytes(xml).response.redirect_url, 'https://example.com/foo.asmx') +""" + self.assertEqual(Autodiscover.from_bytes(xml).response.redirect_url, "https://example.com/foo.asmx") # Select EXPR if it's there, and there are multiple available - xml = b'''\ + xml = b"""\ @@ -633,14 +675,13 @@ def test_parse_response(self): -''' +""" self.assertEqual( - Autodiscover.from_bytes(xml).response.protocol.ews_url, - 'https://expr.example.com/EWS/Exchange.asmx' + Autodiscover.from_bytes(xml).response.protocol.ews_url, "https://expr.example.com/EWS/Exchange.asmx" ) # Select EXPR if EXPR is unavailable - xml = b'''\ + xml = b"""\ @@ -656,14 +697,13 @@ def test_parse_response(self): -''' +""" self.assertEqual( - Autodiscover.from_bytes(xml).response.protocol.ews_url, - 'https://exch.example.com/EWS/Exchange.asmx' + Autodiscover.from_bytes(xml).response.protocol.ews_url, "https://exch.example.com/EWS/Exchange.asmx" ) # Fail if neither EXPR nor EXPR are unavailable - xml = b'''\ + xml = b"""\ @@ -679,30 +719,28 @@ def test_parse_response(self): -''' +""" with self.assertRaises(ValueError): _ = Autodiscover.from_bytes(xml).response.protocol.ews_url def test_raise_errors(self): with self.assertRaises(AutoDiscoverFailed) as e: Autodiscover().raise_errors() - self.assertEqual(e.exception.args[0], 'Unknown autodiscover error response: None') + self.assertEqual(e.exception.args[0], "Unknown autodiscover error response: None") with self.assertRaises(AutoDiscoverFailed) as e: - Autodiscover( - error_response=ErrorResponse(error=Error(code='YYY', message='XXX')) - ).raise_errors() - self.assertEqual(e.exception.args[0], 'Unknown error YYY: XXX') + Autodiscover(error_response=ErrorResponse(error=Error(code="YYY", message="XXX"))).raise_errors() + self.assertEqual(e.exception.args[0], "Unknown error YYY: XXX") with self.assertRaises(ErrorNonExistentMailbox) as e: Autodiscover( - error_response=ErrorResponse(error=Error(message='The e-mail address cannot be found.')) + error_response=ErrorResponse(error=Error(message="The e-mail address cannot be found.")) ).raise_errors() - self.assertEqual(e.exception.args[0], 'The SMTP address has no mailbox associated with it') + self.assertEqual(e.exception.args[0], "The SMTP address has no mailbox associated with it") def test_del_on_error(self): # Test that __del__ can handle exceptions on close() tmp = AutodiscoverCache.close cache = AutodiscoverCache() - AutodiscoverCache.close = Mock(side_effect=Exception('XXX')) + AutodiscoverCache.close = Mock(side_effect=Exception("XXX")) with self.assertRaises(Exception): cache.close() del cache @@ -710,34 +748,34 @@ def test_del_on_error(self): def test_shelve_filename(self): major, minor = sys.version_info[:2] - self.assertEqual(shelve_filename(), f'exchangelib.2.cache.{getpass.getuser()}.py{major}{minor}') + self.assertEqual(shelve_filename(), f"exchangelib.2.cache.{getpass.getuser()}.py{major}{minor}") - @patch('getpass.getuser', side_effect=KeyError()) + @patch("getpass.getuser", side_effect=KeyError()) def test_shelve_filename_getuser_failure(self, m): # Test that shelve_filename can handle a failing getuser() major, minor = sys.version_info[:2] - self.assertEqual(shelve_filename(), f'exchangelib.2.cache.exchangelib.py{major}{minor}') + self.assertEqual(shelve_filename(), f"exchangelib.2.cache.exchangelib.py{major}{minor}") @requests_mock.mock(real_http=False) def test_redirect_url_is_valid(self, m): # This method is private but hard to get to otherwise - a = Autodiscovery('john@example.com') + a = Autodiscovery("john@example.com") # Already visited - a._urls_visited.append('https://example.com') - self.assertFalse(a._redirect_url_is_valid('https://example.com')) + a._urls_visited.append("https://example.com") + self.assertFalse(a._redirect_url_is_valid("https://example.com")) a._urls_visited.clear() # Max redirects exceeded a._redirect_count = 10 - self.assertFalse(a._redirect_url_is_valid('https://example.com')) + self.assertFalse(a._redirect_url_is_valid("https://example.com")) a._redirect_count = 0 # Must be secure - self.assertFalse(a._redirect_url_is_valid('http://example.com')) + self.assertFalse(a._redirect_url_is_valid("http://example.com")) # Does not resolve with DNS - url = f'https://{get_random_hostname()}' + url = f"https://{get_random_hostname()}" m.head(url, status_code=200) self.assertFalse(a._redirect_url_is_valid(url)) diff --git a/tests/test_build.py b/tests/test_build.py index 5317f96a..94187bfc 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -7,7 +7,7 @@ class BuildTest(TimedTestCase): def test_magic(self): with self.assertRaises(ValueError): Build(7, 0) - self.assertEqual(str(Build(9, 8, 7, 6)), '9.8.7.6') + self.assertEqual(str(Build(9, 8, 7, 6)), "9.8.7.6") hash(Build(9, 8, 7, 6)) def test_compare(self): @@ -25,13 +25,13 @@ def test_compare(self): self.assertGreaterEqual(Build(15, 0, 1, 2), Build(15, 0, 1, 2)) def test_api_version(self): - self.assertEqual(Build(8, 0).api_version(), 'Exchange2007') - self.assertEqual(Build(8, 1).api_version(), 'Exchange2007_SP1') - self.assertEqual(Build(8, 2).api_version(), 'Exchange2007_SP1') - self.assertEqual(Build(8, 3).api_version(), 'Exchange2007_SP1') - self.assertEqual(Build(15, 0, 1, 1).api_version(), 'Exchange2013') - self.assertEqual(Build(15, 0, 1, 1).api_version(), 'Exchange2013') - self.assertEqual(Build(15, 0, 847, 0).api_version(), 'Exchange2013_SP1') + self.assertEqual(Build(8, 0).api_version(), "Exchange2007") + self.assertEqual(Build(8, 1).api_version(), "Exchange2007_SP1") + self.assertEqual(Build(8, 2).api_version(), "Exchange2007_SP1") + self.assertEqual(Build(8, 3).api_version(), "Exchange2007_SP1") + self.assertEqual(Build(15, 0, 1, 1).api_version(), "Exchange2013") + self.assertEqual(Build(15, 0, 1, 1).api_version(), "Exchange2013") + self.assertEqual(Build(15, 0, 847, 0).api_version(), "Exchange2013_SP1") with self.assertRaises(ValueError): Build(16, 0).api_version() with self.assertRaises(ValueError): diff --git a/tests/test_configuration.py b/tests/test_configuration.py index aadee840..a3620a52 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -6,9 +6,9 @@ from exchangelib.configuration import Configuration from exchangelib.credentials import Credentials -from exchangelib.transport import NTLM, AUTH_TYPE_MAP from exchangelib.protocol import FailFast, FaultTolerance -from exchangelib.version import Version, Build +from exchangelib.transport import AUTH_TYPE_MAP, NTLM +from exchangelib.version import Build, Version from .common import TimedTestCase, get_random_string @@ -16,42 +16,38 @@ class ConfigurationTest(TimedTestCase): def test_init(self): with self.assertRaises(TypeError) as e: - Configuration(credentials='foo') + Configuration(credentials="foo") self.assertEqual( - e.exception.args[0], - "'credentials' 'foo' must be of type " + e.exception.args[0], "'credentials' 'foo' must be of type " ) with self.assertRaises(ValueError) as e: Configuration(credentials=None, auth_type=NTLM) self.assertEqual(e.exception.args[0], "Auth type 'NTLM' was detected but no credentials were provided") with self.assertRaises(AttributeError) as e: - Configuration(server='foo', service_endpoint='bar') + Configuration(server="foo", service_endpoint="bar") self.assertEqual(e.exception.args[0], "Only one of 'server' or 'service_endpoint' must be provided") with self.assertRaises(ValueError) as e: - Configuration(auth_type='foo') - self.assertEqual( - e.exception.args[0], - f"'auth_type' 'foo' must be one of {sorted(AUTH_TYPE_MAP)}" - ) + Configuration(auth_type="foo") + self.assertEqual(e.exception.args[0], f"'auth_type' 'foo' must be one of {sorted(AUTH_TYPE_MAP)}") with self.assertRaises(TypeError) as e: - Configuration(version='foo') + Configuration(version="foo") self.assertEqual(e.exception.args[0], "'version' 'foo' must be of type ") with self.assertRaises(TypeError) as e: - Configuration(retry_policy='foo') + Configuration(retry_policy="foo") self.assertEqual( e.exception.args[0], "'retry_policy' 'foo' must be of type " ) with self.assertRaises(TypeError) as e: - Configuration(max_connections='foo') + Configuration(max_connections="foo") self.assertEqual(e.exception.args[0], "'max_connections' 'foo' must be of type ") self.assertEqual(Configuration().server, None) # Test that property works when service_endpoint is None def test_magic(self): config = Configuration( - server='example.com', + server="example.com", credentials=Credentials(get_random_string(8), get_random_string(8)), auth_type=NTLM, - version=Version(build=Build(15, 1, 2, 3), api_version='foo'), + version=Version(build=Build(15, 1, 2, 3), api_version="foo"), ) # Just test that these work str(config) @@ -62,10 +58,10 @@ def test_hardcode_all(self, m): # Test that we can hardcode everything without having a working server. This is useful if neither tasting or # guessing missing values works. Configuration( - server='example.com', + server="example.com", credentials=Credentials(get_random_string(8), get_random_string(8)), auth_type=NTLM, - version=Version(build=Build(15, 1, 2, 3), api_version='foo'), + version=Version(build=Build(15, 1, 2, 3), api_version="foo"), ) def test_fail_fast_back_off(self): diff --git a/tests/test_credentials.py b/tests/test_credentials.py index b8158699..92e688fb 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -1,7 +1,7 @@ import pickle from exchangelib.account import Identity -from exchangelib.credentials import Credentials, OAuth2Credentials, OAuth2AuthorizationCodeCredentials +from exchangelib.credentials import Credentials, OAuth2AuthorizationCodeCredentials, OAuth2Credentials from .common import TimedTestCase @@ -9,31 +9,35 @@ class CredentialsTest(TimedTestCase): def test_hash(self): # Test that we can use credentials as a dict key - self.assertEqual(hash(Credentials('a', 'b')), hash(Credentials('a', 'b'))) - self.assertNotEqual(hash(Credentials('a', 'b')), hash(Credentials('a', 'a'))) - self.assertNotEqual(hash(Credentials('a', 'b')), hash(Credentials('b', 'b'))) + self.assertEqual(hash(Credentials("a", "b")), hash(Credentials("a", "b"))) + self.assertNotEqual(hash(Credentials("a", "b")), hash(Credentials("a", "a"))) + self.assertNotEqual(hash(Credentials("a", "b")), hash(Credentials("b", "b"))) def test_equality(self): - self.assertEqual(Credentials('a', 'b'), Credentials('a', 'b')) - self.assertNotEqual(Credentials('a', 'b'), Credentials('a', 'a')) - self.assertNotEqual(Credentials('a', 'b'), Credentials('b', 'b')) + self.assertEqual(Credentials("a", "b"), Credentials("a", "b")) + self.assertNotEqual(Credentials("a", "b"), Credentials("a", "a")) + self.assertNotEqual(Credentials("a", "b"), Credentials("b", "b")) def test_type(self): - self.assertEqual(Credentials('a', 'b').type, Credentials.UPN) - self.assertEqual(Credentials('a@example.com', 'b').type, Credentials.EMAIL) - self.assertEqual(Credentials('a\\n', 'b').type, Credentials.DOMAIN) + self.assertEqual(Credentials("a", "b").type, Credentials.UPN) + self.assertEqual(Credentials("a@example.com", "b").type, Credentials.EMAIL) + self.assertEqual(Credentials("a\\n", "b").type, Credentials.DOMAIN) def test_pickle(self): # Test that we can pickle, hash, repr, str and compare various credentials types for o in ( - Identity('XXX', 'YYY', 'ZZZ', 'WWW'), - Credentials('XXX', 'YYY'), - OAuth2Credentials('XXX', 'YYY', 'ZZZZ'), - OAuth2Credentials('XXX', 'YYY', 'ZZZZ', identity=Identity('AAA')), - OAuth2AuthorizationCodeCredentials(client_id='WWW', client_secret='XXX'), + Identity("XXX", "YYY", "ZZZ", "WWW"), + Credentials("XXX", "YYY"), + OAuth2Credentials("XXX", "YYY", "ZZZZ"), + OAuth2Credentials("XXX", "YYY", "ZZZZ", identity=Identity("AAA")), + OAuth2AuthorizationCodeCredentials(client_id="WWW", client_secret="XXX"), OAuth2AuthorizationCodeCredentials( - client_id='WWW', client_secret='XXX', authorization_code='YYY', access_token={'access_token': 'ZZZ'}, - tenant_id='ZZZ', identity=Identity('AAA') + client_id="WWW", + client_secret="XXX", + authorization_code="YYY", + access_token={"access_token": "ZZZ"}, + tenant_id="ZZZ", + identity=Identity("AAA"), ), ): with self.subTest(o=o): @@ -46,24 +50,24 @@ def test_pickle(self): self.assertEqual(str(o), str(unpickled_o)) def test_plain(self): - Credentials('XXX', 'YYY').refresh('XXX') # No-op + Credentials("XXX", "YYY").refresh("XXX") # No-op def test_oauth_validation(self): with self.assertRaises(TypeError) as e: - OAuth2AuthorizationCodeCredentials(client_id='WWW', client_secret='XXX', access_token='XXX') + OAuth2AuthorizationCodeCredentials(client_id="WWW", client_secret="XXX", access_token="XXX") self.assertEqual( e.exception.args[0], - "'access_token' 'XXX' must be of type " + "'access_token' 'XXX' must be of type ", ) - c = OAuth2Credentials('XXX', 'YYY', 'ZZZZ') - c.refresh('XXX') # No-op + c = OAuth2Credentials("XXX", "YYY", "ZZZZ") + c.refresh("XXX") # No-op with self.assertRaises(TypeError) as e: - c.on_token_auto_refreshed('XXX') + c.on_token_auto_refreshed("XXX") self.assertEqual( e.exception.args[0], - "'access_token' 'XXX' must be of type " + "'access_token' 'XXX' must be of type ", ) - c.on_token_auto_refreshed(dict(access_token='XXX')) + c.on_token_auto_refreshed(dict(access_token="XXX")) self.assertIsInstance(c.sig(), int) diff --git a/tests/test_ewsdatetime.py b/tests/test_ewsdatetime.py index 9f84d5ed..ff550721 100644 --- a/tests/test_ewsdatetime.py +++ b/tests/test_ewsdatetime.py @@ -3,24 +3,29 @@ import dateutil.tz import pytz import requests_mock + try: import zoneinfo except ImportError: from backports import zoneinfo -from exchangelib.errors import UnknownTimeZone, NaiveDateTimeNotAllowed -from exchangelib.ewsdatetime import EWSDateTime, EWSDate, EWSTimeZone, UTC -from exchangelib.winzone import generate_map, CLDR_TO_MS_TIMEZONE_MAP, CLDR_WINZONE_URL, CLDR_WINZONE_TYPE_VERSION, \ - CLDR_WINZONE_OTHER_VERSION +from exchangelib.errors import NaiveDateTimeNotAllowed, UnknownTimeZone +from exchangelib.ewsdatetime import UTC, EWSDate, EWSDateTime, EWSTimeZone from exchangelib.util import CONNECTION_ERRORS +from exchangelib.winzone import ( + CLDR_TO_MS_TIMEZONE_MAP, + CLDR_WINZONE_OTHER_VERSION, + CLDR_WINZONE_TYPE_VERSION, + CLDR_WINZONE_URL, + generate_map, +) from .common import TimedTestCase class EWSDateTimeTest(TimedTestCase): - def test_super_methods(self): - tz = EWSTimeZone('Europe/Copenhagen') + tz = EWSTimeZone("Europe/Copenhagen") self.assertIsInstance(EWSDateTime.now(), EWSDateTime) self.assertIsInstance(EWSDateTime.now(tz=tz), EWSDateTime) self.assertIsInstance(EWSDateTime.utcnow(), EWSDateTime) @@ -30,10 +35,10 @@ def test_super_methods(self): def test_ewstimezone(self): # Test autogenerated translations - tz = EWSTimeZone('Europe/Copenhagen') + tz = EWSTimeZone("Europe/Copenhagen") self.assertIsInstance(tz, EWSTimeZone) - self.assertEqual(tz.key, 'Europe/Copenhagen') - self.assertEqual(tz.ms_id, 'Romance Standard Time') + self.assertEqual(tz.key, "Europe/Copenhagen") + self.assertEqual(tz.ms_id, "Romance Standard Time") # self.assertEqual(EWSTimeZone('Europe/Copenhagen').ms_name, '') # EWS works fine without the ms_name # Test localzone() @@ -41,14 +46,14 @@ def test_ewstimezone(self): self.assertIsInstance(tz, EWSTimeZone) # Test common helpers - tz = EWSTimeZone('UTC') + tz = EWSTimeZone("UTC") self.assertIsInstance(tz, EWSTimeZone) - self.assertEqual(tz.key, 'UTC') - self.assertEqual(tz.ms_id, 'UTC') - tz = EWSTimeZone('GMT') + self.assertEqual(tz.key, "UTC") + self.assertEqual(tz.ms_id, "UTC") + tz = EWSTimeZone("GMT") self.assertIsInstance(tz, EWSTimeZone) - self.assertEqual(tz.key, 'GMT') - self.assertEqual(tz.ms_id, 'UTC') + self.assertEqual(tz.key, "GMT") + self.assertEqual(tz.ms_id, "UTC") # Test mapper contents. Latest map from unicode.org has 394 entries self.assertGreater(len(EWSTimeZone.IANA_TO_MS_MAP), 300) @@ -59,93 +64,73 @@ def test_ewstimezone(self): self.assertIsInstance(v[0], str) # Test IANA exceptions - sanitized = list(t for t in zoneinfo.available_timezones() if not t.startswith('SystemV/') and t != 'localtime') + sanitized = list(t for t in zoneinfo.available_timezones() if not t.startswith("SystemV/") and t != "localtime") self.assertEqual(set(sanitized) - set(EWSTimeZone.IANA_TO_MS_MAP), set()) # Test timezone unknown by ZoneInfo with self.assertRaises(UnknownTimeZone) as e: - EWSTimeZone('UNKNOWN') - self.assertEqual(e.exception.args[0], 'No time zone found with key UNKNOWN') + EWSTimeZone("UNKNOWN") + self.assertEqual(e.exception.args[0], "No time zone found with key UNKNOWN") # Test timezone known by IANA but with no Winzone mapping with self.assertRaises(UnknownTimeZone) as e: - del EWSTimeZone.IANA_TO_MS_MAP['Africa/Tripoli'] - EWSTimeZone('Africa/Tripoli') + del EWSTimeZone.IANA_TO_MS_MAP["Africa/Tripoli"] + EWSTimeZone("Africa/Tripoli") self.assertEqual(e.exception.args[0], "No Windows timezone name found for timezone 'Africa/Tripoli'") # Test __eq__ with non-EWSTimeZone compare - self.assertFalse(EWSTimeZone('GMT') == zoneinfo.ZoneInfo('UTC')) + self.assertFalse(EWSTimeZone("GMT") == zoneinfo.ZoneInfo("UTC")) # Test from_ms_id() with non-standard MS ID - self.assertEqual(EWSTimeZone('Europe/Copenhagen'), EWSTimeZone.from_ms_id('Europe/Copenhagen')) + self.assertEqual(EWSTimeZone("Europe/Copenhagen"), EWSTimeZone.from_ms_id("Europe/Copenhagen")) def test_from_timezone(self): + self.assertEqual(EWSTimeZone("Europe/Copenhagen"), EWSTimeZone.from_timezone(EWSTimeZone("Europe/Copenhagen"))) self.assertEqual( - EWSTimeZone('Europe/Copenhagen'), - EWSTimeZone.from_timezone(EWSTimeZone('Europe/Copenhagen')) - ) - self.assertEqual( - EWSTimeZone('Europe/Copenhagen'), - EWSTimeZone.from_timezone(zoneinfo.ZoneInfo('Europe/Copenhagen')) + EWSTimeZone("Europe/Copenhagen"), EWSTimeZone.from_timezone(zoneinfo.ZoneInfo("Europe/Copenhagen")) ) self.assertEqual( - EWSTimeZone('Europe/Copenhagen'), - EWSTimeZone.from_timezone(dateutil.tz.gettz('Europe/Copenhagen')) + EWSTimeZone("Europe/Copenhagen"), EWSTimeZone.from_timezone(dateutil.tz.gettz("Europe/Copenhagen")) ) self.assertEqual( - EWSTimeZone('Europe/Copenhagen'), - EWSTimeZone.from_timezone(pytz.timezone('Europe/Copenhagen')) + EWSTimeZone("Europe/Copenhagen"), EWSTimeZone.from_timezone(pytz.timezone("Europe/Copenhagen")) ) - self.assertEqual( - EWSTimeZone('UTC'), - EWSTimeZone.from_timezone(dateutil.tz.UTC) - ) - self.assertEqual( - EWSTimeZone('UTC'), - EWSTimeZone.from_timezone(datetime.timezone.utc) - ) + self.assertEqual(EWSTimeZone("UTC"), EWSTimeZone.from_timezone(dateutil.tz.UTC)) + self.assertEqual(EWSTimeZone("UTC"), EWSTimeZone.from_timezone(datetime.timezone.utc)) def test_ewsdatetime(self): # Test a static timezone - tz = EWSTimeZone('Etc/GMT-5') + tz = EWSTimeZone("Etc/GMT-5") dt = EWSDateTime(2000, 1, 2, 3, 4, 5, 678901, tzinfo=tz) self.assertIsInstance(dt, EWSDateTime) self.assertIsInstance(dt.tzinfo, EWSTimeZone) self.assertEqual(dt.tzinfo.ms_id, tz.ms_id) self.assertEqual(dt.tzinfo.ms_name, tz.ms_name) - self.assertEqual(str(dt), '2000-01-02 03:04:05.678901+05:00') - self.assertEqual( - repr(dt), - "EWSDateTime(2000, 1, 2, 3, 4, 5, 678901, tzinfo=EWSTimeZone(key='Etc/GMT-5'))" - ) + self.assertEqual(str(dt), "2000-01-02 03:04:05.678901+05:00") + self.assertEqual(repr(dt), "EWSDateTime(2000, 1, 2, 3, 4, 5, 678901, tzinfo=EWSTimeZone(key='Etc/GMT-5'))") # Test a DST timezone - tz = EWSTimeZone('Europe/Copenhagen') + tz = EWSTimeZone("Europe/Copenhagen") dt = EWSDateTime(2000, 1, 2, 3, 4, 5, 678901, tzinfo=tz) self.assertIsInstance(dt, EWSDateTime) self.assertIsInstance(dt.tzinfo, EWSTimeZone) self.assertEqual(dt.tzinfo.ms_id, tz.ms_id) self.assertEqual(dt.tzinfo.ms_name, tz.ms_name) - self.assertEqual(str(dt), '2000-01-02 03:04:05.678901+01:00') + self.assertEqual(str(dt), "2000-01-02 03:04:05.678901+01:00") self.assertEqual( - repr(dt), - "EWSDateTime(2000, 1, 2, 3, 4, 5, 678901, tzinfo=EWSTimeZone(key='Europe/Copenhagen'))" + repr(dt), "EWSDateTime(2000, 1, 2, 3, 4, 5, 678901, tzinfo=EWSTimeZone(key='Europe/Copenhagen'))" ) # Test from_string with self.assertRaises(NaiveDateTimeNotAllowed): - EWSDateTime.from_string('2000-01-02T03:04:05') + EWSDateTime.from_string("2000-01-02T03:04:05") self.assertEqual( - EWSDateTime.from_string('2000-01-02T03:04:05+01:00'), - EWSDateTime(2000, 1, 2, 2, 4, 5, tzinfo=UTC) + EWSDateTime.from_string("2000-01-02T03:04:05+01:00"), EWSDateTime(2000, 1, 2, 2, 4, 5, tzinfo=UTC) ) - self.assertEqual( - EWSDateTime.from_string('2000-01-02T03:04:05Z'), - EWSDateTime(2000, 1, 2, 3, 4, 5, tzinfo=UTC) - ) - self.assertIsInstance(EWSDateTime.from_string('2000-01-02T03:04:05+01:00'), EWSDateTime) - self.assertIsInstance(EWSDateTime.from_string('2000-01-02T03:04:05Z'), EWSDateTime) + self.assertEqual(EWSDateTime.from_string("2000-01-02T03:04:05Z"), EWSDateTime(2000, 1, 2, 3, 4, 5, tzinfo=UTC)) + self.assertIsInstance(EWSDateTime.from_string("2000-01-02T03:04:05+01:00"), EWSDateTime) + self.assertIsInstance(EWSDateTime.from_string("2000-01-02T03:04:05Z"), EWSDateTime) # Test addition, subtraction, summertime etc self.assertIsInstance(dt + datetime.timedelta(days=1), EWSDateTime) @@ -154,25 +139,37 @@ def test_ewsdatetime(self): self.assertIsInstance(EWSDateTime.now(tz=tz), EWSDateTime) # Test various input for from_datetime() - self.assertEqual(dt, EWSDateTime.from_datetime( - datetime.datetime(2000, 1, 2, 3, 4, 5, 678901, tzinfo=EWSTimeZone('Europe/Copenhagen')) - )) - self.assertEqual(dt, EWSDateTime.from_datetime( - datetime.datetime(2000, 1, 2, 3, 4, 5, 678901, tzinfo=zoneinfo.ZoneInfo('Europe/Copenhagen')) - )) - self.assertEqual(dt, EWSDateTime.from_datetime( - datetime.datetime(2000, 1, 2, 3, 4, 5, 678901, tzinfo=dateutil.tz.gettz('Europe/Copenhagen')) - )) - self.assertEqual(dt, EWSDateTime.from_datetime( - datetime.datetime(2000, 1, 2, 3, 4, 5, 678901, tzinfo=pytz.timezone('Europe/Copenhagen')) - )) - - self.assertEqual(dt.ewsformat(), '2000-01-02T03:04:05.678901+01:00') - utc_tz = EWSTimeZone('UTC') - self.assertEqual(dt.astimezone(utc_tz).ewsformat(), '2000-01-02T02:04:05.678901Z') + self.assertEqual( + dt, + EWSDateTime.from_datetime( + datetime.datetime(2000, 1, 2, 3, 4, 5, 678901, tzinfo=EWSTimeZone("Europe/Copenhagen")) + ), + ) + self.assertEqual( + dt, + EWSDateTime.from_datetime( + datetime.datetime(2000, 1, 2, 3, 4, 5, 678901, tzinfo=zoneinfo.ZoneInfo("Europe/Copenhagen")) + ), + ) + self.assertEqual( + dt, + EWSDateTime.from_datetime( + datetime.datetime(2000, 1, 2, 3, 4, 5, 678901, tzinfo=dateutil.tz.gettz("Europe/Copenhagen")) + ), + ) + self.assertEqual( + dt, + EWSDateTime.from_datetime( + datetime.datetime(2000, 1, 2, 3, 4, 5, 678901, tzinfo=pytz.timezone("Europe/Copenhagen")) + ), + ) + + self.assertEqual(dt.ewsformat(), "2000-01-02T03:04:05.678901+01:00") + utc_tz = EWSTimeZone("UTC") + self.assertEqual(dt.astimezone(utc_tz).ewsformat(), "2000-01-02T02:04:05.678901Z") # Test summertime dt = EWSDateTime(2000, 8, 2, 3, 4, 5, 678901, tzinfo=tz) - self.assertEqual(dt.astimezone(utc_tz).ewsformat(), '2000-08-02T01:04:05.678901Z') + self.assertEqual(dt.astimezone(utc_tz).ewsformat(), "2000-08-02T01:04:05.678901Z") # Test in-place add and subtract dt = EWSDateTime(2000, 1, 2, 3, 4, 5, tzinfo=tz) @@ -190,7 +187,7 @@ def test_ewsdatetime(self): dt.ewsformat() # Test wrong tzinfo type with self.assertRaises(TypeError): - EWSDateTime(2000, 1, 2, 3, 4, 5, tzinfo=pytz.timezone('Europe/Copenhagen')) + EWSDateTime(2000, 1, 2, 3, 4, 5, tzinfo=pytz.timezone("Europe/Copenhagen")) with self.assertRaises(TypeError): EWSDateTime.from_datetime(EWSDateTime(2000, 1, 2, 3, 4, 5)) @@ -212,11 +209,11 @@ def test_generate_failure(self, m): generate_map() def test_ewsdate(self): - self.assertEqual(EWSDate(2000, 1, 1).ewsformat(), '2000-01-01') - self.assertEqual(EWSDate.from_string('2000-01-01'), EWSDate(2000, 1, 1)) - self.assertEqual(EWSDate.from_string('2000-01-01Z'), EWSDate(2000, 1, 1)) - self.assertEqual(EWSDate.from_string('2000-01-01+01:00'), EWSDate(2000, 1, 1)) - self.assertEqual(EWSDate.from_string('2000-01-01-01:00'), EWSDate(2000, 1, 1)) + self.assertEqual(EWSDate(2000, 1, 1).ewsformat(), "2000-01-01") + self.assertEqual(EWSDate.from_string("2000-01-01"), EWSDate(2000, 1, 1)) + self.assertEqual(EWSDate.from_string("2000-01-01Z"), EWSDate(2000, 1, 1)) + self.assertEqual(EWSDate.from_string("2000-01-01+01:00"), EWSDate(2000, 1, 1)) + self.assertEqual(EWSDate.from_string("2000-01-01-01:00"), EWSDate(2000, 1, 1)) self.assertIsInstance(EWSDate(2000, 1, 2) - EWSDate(2000, 1, 1), datetime.timedelta) self.assertIsInstance(EWSDate(2000, 1, 2) + datetime.timedelta(days=1), EWSDate) self.assertIsInstance(EWSDate(2000, 1, 2) - datetime.timedelta(days=1), EWSDate) diff --git a/tests/test_extended_properties.py b/tests/test_extended_properties.py index 3c079d9d..1fd89edb 100644 --- a/tests/test_extended_properties.py +++ b/tests/test_extended_properties.py @@ -1,6 +1,6 @@ from exchangelib.extended_properties import ExtendedProperty, Flag -from exchangelib.items import Message, CalendarItem, BaseItem from exchangelib.folders import Inbox +from exchangelib.items import BaseItem, CalendarItem, Message from exchangelib.properties import Mailbox from .common import get_random_int, get_random_url @@ -8,25 +8,25 @@ class ExtendedPropertyTest(BaseItemTest): - TEST_FOLDER = 'inbox' + TEST_FOLDER = "inbox" FOLDER_CLASS = Inbox ITEM_CLASS = Message def test_register(self): # Tests that we can register and de-register custom extended properties class TestProp(ExtendedProperty): - property_set_id = 'deadbeaf-cafe-cafe-cafe-deadbeefcafe' - property_name = 'Test Property' - property_type = 'Integer' + property_set_id = "deadbeaf-cafe-cafe-cafe-deadbeefcafe" + property_name = "Test Property" + property_type = "Integer" - attr_name = 'dead_beef' + attr_name = "dead_beef" # Before register self.assertNotIn(attr_name, {f.name for f in self.ITEM_CLASS.supported_fields(self.account.version)}) with self.assertRaises(ValueError): self.ITEM_CLASS.deregister(attr_name) # Not registered yet with self.assertRaises(ValueError): - self.ITEM_CLASS.deregister('subject') # Not an extended property + self.ITEM_CLASS.deregister("subject") # Not an extended property # Test that we can clean an item before and after registry item = self.ITEM_CLASS() @@ -56,17 +56,17 @@ class TestProp(ExtendedProperty): self.ITEM_CLASS.register(attr_name=attr_name, attr_cls=TestProp) self.assertEqual(e.exception.args[0], "'attr_name' 'dead_beef' is already registered") with self.assertRaises(TypeError) as e: - self.ITEM_CLASS.register(attr_name='XXX', attr_cls=Mailbox) + self.ITEM_CLASS.register(attr_name="XXX", attr_cls=Mailbox) self.assertEqual( e.exception.args[0], "'attr_cls' must be a subclass of type " - "" + "", ) with self.assertRaises(ValueError) as e: BaseItem.register(attr_name=attr_name, attr_cls=Mailbox) self.assertEqual( e.exception.args[0], - "Class is missing INSERT_AFTER_FIELD value" + "Class is missing INSERT_AFTER_FIELD value", ) finally: self.ITEM_CLASS.deregister(attr_name=attr_name) @@ -75,11 +75,11 @@ class TestProp(ExtendedProperty): def test_extended_property_arraytype(self): # Tests array type extended properties class TestArayProp(ExtendedProperty): - property_set_id = 'deadcafe-beef-beef-beef-deadcafebeef' - property_name = 'Test Array Property' - property_type = 'IntegerArray' + property_set_id = "deadcafe-beef-beef-beef-deadcafebeef" + property_name = "Test Array Property" + property_type = "IntegerArray" - attr_name = 'dead_beef_array' + attr_name = "dead_beef_array" self.ITEM_CLASS.register(attr_name=attr_name, attr_cls=TestArayProp) try: # Test item creation, refresh, and update @@ -98,7 +98,7 @@ class TestArayProp(ExtendedProperty): self.ITEM_CLASS.deregister(attr_name=attr_name) def test_extended_property_with_tag(self): - attr_name = 'my_flag' + attr_name = "my_flag" self.ITEM_CLASS.register(attr_name=attr_name, attr_cls=Flag) try: # Test item creation, refresh, and update @@ -118,14 +118,14 @@ def test_extended_property_with_tag(self): def test_extended_property_with_invalid_tag(self): class InvalidProp(ExtendedProperty): - property_tag = '0x8000' - property_type = 'Integer' + property_tag = "0x8000" + property_type = "Integer" with self.assertRaises(ValueError): - InvalidProp('Foo').clean() # property_tag is in protected range + InvalidProp("Foo").clean() # property_tag is in protected range def test_extended_property_with_string_tag(self): - attr_name = 'my_flag' + attr_name = "my_flag" self.ITEM_CLASS.register(attr_name=attr_name, attr_cls=Flag) try: # Test item creation, refresh, and update @@ -149,11 +149,11 @@ def test_extended_distinguished_property(self): self.skipTest("This extendedproperty doesn't work on CalendarItems") class MyMeeting(ExtendedProperty): - distinguished_property_set_id = 'Meeting' - property_type = 'Binary' + distinguished_property_set_id = "Meeting" + property_type = "Binary" property_id = 3 - attr_name = 'my_meeting' + attr_name = "my_meeting" self.ITEM_CLASS.register(attr_name=attr_name, attr_cls=MyMeeting) try: # Test item creation, refresh, and update @@ -173,11 +173,11 @@ class MyMeeting(ExtendedProperty): def test_extended_property_binary_array(self): class MyMeetingArray(ExtendedProperty): - property_set_id = '00062004-0000-0000-C000-000000000046' - property_type = 'BinaryArray' + property_set_id = "00062004-0000-0000-C000-000000000046" + property_type = "BinaryArray" property_id = 32852 - attr_name = 'my_meeting_array' + attr_name = "my_meeting_array" self.ITEM_CLASS.register(attr_name=attr_name, attr_cls=MyMeetingArray) try: @@ -199,87 +199,100 @@ class MyMeetingArray(ExtendedProperty): def test_extended_property_validation(self): # Must not have property_set_id or property_tag class TestProp1(ExtendedProperty): - distinguished_property_set_id = 'XXX' - property_set_id = 'YYY' + distinguished_property_set_id = "XXX" + property_set_id = "YYY" + with self.assertRaises(ValueError): TestProp1.validate_cls() # Must have property_id or property_name class TestProp2(ExtendedProperty): - distinguished_property_set_id = 'XXX' + distinguished_property_set_id = "XXX" + with self.assertRaises(ValueError): TestProp2.validate_cls() # distinguished_property_set_id must have a valid value class TestProp3(ExtendedProperty): - distinguished_property_set_id = 'XXX' - property_id = 'YYY' + distinguished_property_set_id = "XXX" + property_id = "YYY" + with self.assertRaises(ValueError): TestProp3.validate_cls() # Must not have distinguished_property_set_id or property_tag class TestProp4(ExtendedProperty): - property_set_id = 'XXX' - property_tag = 'YYY' + property_set_id = "XXX" + property_tag = "YYY" + with self.assertRaises(ValueError): TestProp4.validate_cls() # Must have property_id or property_name class TestProp5(ExtendedProperty): - property_set_id = 'XXX' + property_set_id = "XXX" + with self.assertRaises(ValueError): TestProp5.validate_cls() # property_tag is only compatible with property_type class TestProp6(ExtendedProperty): - property_tag = 'XXX' - property_set_id = 'YYY' + property_tag = "XXX" + property_set_id = "YYY" + with self.assertRaises(ValueError): TestProp6.validate_cls() # property_tag must be an integer or string that can be converted to int class TestProp7(ExtendedProperty): - property_tag = 'XXX' + property_tag = "XXX" + with self.assertRaises(ValueError): TestProp7.validate_cls() # property_tag must not be in the reserved range class TestProp8(ExtendedProperty): property_tag = 0x8001 + with self.assertRaises(ValueError): TestProp8.validate_cls() # Must not have property_id or property_tag class TestProp9(ExtendedProperty): - property_name = 'XXX' - property_id = 'YYY' + property_name = "XXX" + property_id = "YYY" + with self.assertRaises(ValueError): TestProp9.validate_cls() # Must have distinguished_property_set_id or property_set_id class TestProp10(ExtendedProperty): - property_name = 'XXX' + property_name = "XXX" + with self.assertRaises(ValueError): TestProp10.validate_cls() # Must not have property_name or property_tag class TestProp11(ExtendedProperty): - property_id = 'XXX' - property_name = 'YYY' + property_id = "XXX" + property_name = "YYY" + with self.assertRaises(ValueError): TestProp11.validate_cls() # This actually hits the check on property_name values # Must have distinguished_property_set_id or property_set_id class TestProp12(ExtendedProperty): - property_id = 'XXX' + property_id = "XXX" + with self.assertRaises(ValueError): TestProp12.validate_cls() # property_type must be a valid value class TestProp13(ExtendedProperty): - property_id = 'XXX' - property_set_id = 'YYY' - property_type = 'ZZZ' + property_id = "XXX" + property_set_id = "YYY" + property_type = "ZZZ" + with self.assertRaises(ValueError): TestProp13.validate_cls() @@ -298,7 +311,7 @@ class ExternalSharingFolderId(ExtendedProperty): self.ITEM_CLASS.register("sharing_url", ExternalSharingUrl) self.ITEM_CLASS.register("sharing_folder_id", ExternalSharingFolderId) - url, folder_id = get_random_url(), self.test_folder.id.encode('utf-8') + url, folder_id = get_random_url(), self.test_folder.id.encode("utf-8") m = self.get_test_item() m.sharing_url, m.sharing_folder_id = url, folder_id m.save() @@ -312,25 +325,24 @@ class ExternalSharingFolderId(ExtendedProperty): def test_via_queryset(self): class TestProp(ExtendedProperty): - property_set_id = 'deadbeaf-cafe-cafe-cafe-deadbeefcafe' - property_name = 'Test Property' - property_type = 'Integer' + property_set_id = "deadbeaf-cafe-cafe-cafe-deadbeefcafe" + property_name = "Test Property" + property_type = "Integer" class TestArayProp(ExtendedProperty): - property_set_id = 'deadcafe-beef-beef-beef-deadcafebeef' - property_name = 'Test Array Property' - property_type = 'IntegerArray' + property_set_id = "deadcafe-beef-beef-beef-deadcafebeef" + property_name = "Test Array Property" + property_type = "IntegerArray" - attr_name = 'dead_beef' - array_attr_name = 'dead_beef_array' + attr_name = "dead_beef" + array_attr_name = "dead_beef_array" self.ITEM_CLASS.register(attr_name=attr_name, attr_cls=TestProp) self.ITEM_CLASS.register(attr_name=array_attr_name, attr_cls=TestArayProp) try: item = self.get_test_item(folder=self.test_folder).save() self.assertEqual(self.test_folder.filter(**{attr_name: getattr(item, attr_name)}).count(), 1) self.assertEqual( - self.test_folder.filter(**{f'{array_attr_name}__contains': getattr(item, array_attr_name)}).count(), - 1 + self.test_folder.filter(**{f"{array_attr_name}__contains": getattr(item, array_attr_name)}).count(), 1 ) finally: self.ITEM_CLASS.deregister(attr_name=attr_name) diff --git a/tests/test_field.py b/tests/test_field.py index 3e7b428c..88e6d710 100644 --- a/tests/test_field.py +++ b/tests/test_field.py @@ -1,87 +1,104 @@ -from collections import namedtuple import datetime +from collections import namedtuple from decimal import Decimal + try: import zoneinfo except ImportError: from backports import zoneinfo from exchangelib.extended_properties import ExternId -from exchangelib.fields import BooleanField, IntegerField, DecimalField, TextField, ChoiceField, DateTimeField, \ - Base64Field, TimeZoneField, ExtendedPropertyField, CharListField, Choice, DateField, EnumField, EnumListField, \ - CharField, InvalidFieldForVersion, InvalidChoiceForVersion, ExtendedPropertyListField +from exchangelib.fields import ( + Base64Field, + BooleanField, + CharField, + CharListField, + Choice, + ChoiceField, + DateField, + DateTimeField, + DecimalField, + EnumField, + EnumListField, + ExtendedPropertyField, + ExtendedPropertyListField, + IntegerField, + InvalidChoiceForVersion, + InvalidFieldForVersion, + TextField, + TimeZoneField, +) from exchangelib.indexed_properties import SingleFieldIndexedElement -from exchangelib.version import Version, EXCHANGE_2007, EXCHANGE_2010, EXCHANGE_2013 -from exchangelib.util import to_xml, TNS +from exchangelib.util import TNS, to_xml +from exchangelib.version import EXCHANGE_2007, EXCHANGE_2010, EXCHANGE_2013, Version from .common import TimedTestCase class FieldTest(TimedTestCase): def test_value_validation(self): - field = TextField('foo', field_uri='bar', is_required=True, default=None) + field = TextField("foo", field_uri="bar", is_required=True, default=None) with self.assertRaises(ValueError) as e: field.clean(None) # Must have a default value on None input self.assertEqual(str(e.exception), "'foo' is a required field with no default") - field = TextField('foo', field_uri='bar', is_required=True, default='XXX') - self.assertEqual(field.clean(None), 'XXX') + field = TextField("foo", field_uri="bar", is_required=True, default="XXX") + self.assertEqual(field.clean(None), "XXX") - field = CharListField('foo', field_uri='bar') + field = CharListField("foo", field_uri="bar") with self.assertRaises(TypeError) as e: - field.clean('XXX') # Must be a list type + field.clean("XXX") # Must be a list type self.assertEqual(str(e.exception), "Field 'foo' value 'XXX' must be of type ") - field = CharListField('foo', field_uri='bar') + field = CharListField("foo", field_uri="bar") with self.assertRaises(TypeError) as e: field.clean([1, 2, 3]) # List items must be correct type self.assertEqual(str(e.exception), "Field 'foo' value 1 must be of type ") - field = CharField('foo', field_uri='bar') + field = CharField("foo", field_uri="bar") with self.assertRaises(TypeError) as e: field.clean(1) # Value must be correct type self.assertEqual(str(e.exception), "Field 'foo' value 1 must be of type ") with self.assertRaises(ValueError) as e: - field.clean('X' * 256) # Value length must be within max_length + field.clean("X" * 256) # Value length must be within max_length self.assertEqual( str(e.exception), "'foo' value 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" - "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' exceeds length 255" + "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' exceeds length 255", ) - field = DateTimeField('foo', field_uri='bar') + field = DateTimeField("foo", field_uri="bar") with self.assertRaises(ValueError) as e: field.clean(datetime.datetime(2017, 1, 1)) # Datetime values must be timezone aware self.assertEqual( - str(e.exception), - "Value datetime.datetime(2017, 1, 1, 0, 0) on field 'foo' must be timezone aware" + str(e.exception), "Value datetime.datetime(2017, 1, 1, 0, 0) on field 'foo' must be timezone aware" ) - field = ChoiceField('foo', field_uri='bar', choices=[Choice('foo'), Choice('bar')]) + field = ChoiceField("foo", field_uri="bar", choices=[Choice("foo"), Choice("bar")]) with self.assertRaises(ValueError) as e: - field.clean('XXX') # Value must be a valid choice + field.clean("XXX") # Value must be a valid choice self.assertEqual(str(e.exception), "Invalid choice 'XXX' for field 'foo'. Valid choices are ['bar', 'foo']") # A few tests on extended properties that override base methods - field = ExtendedPropertyField('foo', value_cls=ExternId, is_required=True) + field = ExtendedPropertyField("foo", value_cls=ExternId, is_required=True) with self.assertRaises(ValueError) as e: field.clean(None) # Value is required self.assertEqual(str(e.exception), "'foo' is a required field") with self.assertRaises(TypeError) as e: field.clean(123) # Correct type is required self.assertEqual(str(e.exception), "Field 'ExternId' value 123 must be of type ") - self.assertEqual(field.clean('XXX'), 'XXX') # We can clean a simple value and keep it as a simple value - self.assertEqual(field.clean(ExternId('XXX')), ExternId('XXX')) # We can clean an ExternId instance as well + self.assertEqual(field.clean("XXX"), "XXX") # We can clean a simple value and keep it as a simple value + self.assertEqual(field.clean(ExternId("XXX")), ExternId("XXX")) # We can clean an ExternId instance as well class ExternIdArray(ExternId): - property_type = 'StringArray' + property_type = "StringArray" - field = ExtendedPropertyListField('foo', value_cls=ExternIdArray, is_required=True) - with self.assertRaises(ValueError)as e: + field = ExtendedPropertyListField("foo", value_cls=ExternIdArray, is_required=True) + with self.assertRaises(ValueError) as e: field.clean(None) # Value is required self.assertEqual(str(e.exception), "'foo' is a required field") - with self.assertRaises(TypeError)as e: + with self.assertRaises(TypeError) as e: field.clean(123) # Must be an iterable self.assertEqual(str(e.exception), "Field 'ExternIdArray' value 123 must be of type ") with self.assertRaises(TypeError) as e: @@ -89,38 +106,38 @@ class ExternIdArray(ExternId): self.assertEqual(str(e.exception), "Field 'ExternIdArray' list value 123 must be of type ") # Test min/max on IntegerField - field = IntegerField('foo', field_uri='bar', min=5, max=10) + field = IntegerField("foo", field_uri="bar", min=5, max=10) with self.assertRaises(ValueError) as e: field.clean(2) self.assertEqual(str(e.exception), "Value 2 on field 'foo' must be greater than 5") - with self.assertRaises(ValueError)as e: + with self.assertRaises(ValueError) as e: field.clean(12) self.assertEqual(str(e.exception), "Value 12 on field 'foo' must be less than 10") # Test min/max on DecimalField - field = DecimalField('foo', field_uri='bar', min=5, max=10) + field = DecimalField("foo", field_uri="bar", min=5, max=10) with self.assertRaises(ValueError) as e: field.clean(Decimal(2)) self.assertEqual(str(e.exception), "Value Decimal('2') on field 'foo' must be greater than 5") - with self.assertRaises(ValueError)as e: + with self.assertRaises(ValueError) as e: field.clean(Decimal(12)) self.assertEqual(str(e.exception), "Value Decimal('12') on field 'foo' must be less than 10") # Test enum validation - field = EnumField('foo', field_uri='bar', enum=['a', 'b', 'c']) - with self.assertRaises(ValueError)as e: + field = EnumField("foo", field_uri="bar", enum=["a", "b", "c"]) + with self.assertRaises(ValueError) as e: field.clean(0) # Enums start at 1 self.assertEqual(str(e.exception), "Value 0 on field 'foo' must be greater than 1") with self.assertRaises(ValueError) as e: field.clean(4) # Spills over list self.assertEqual(str(e.exception), "Value 4 on field 'foo' must be less than 3") with self.assertRaises(ValueError) as e: - field.clean('d') # Value not in enum + field.clean("d") # Value not in enum self.assertEqual(str(e.exception), "Value 'd' on field 'foo' must be one of ['a', 'b', 'c']") # Test enum list validation - field = EnumListField('foo', field_uri='bar', enum=['a', 'b', 'c']) - with self.assertRaises(ValueError)as e: + field = EnumListField("foo", field_uri="bar", enum=["a", "b", "c"]) + with self.assertRaises(ValueError) as e: field.clean([]) self.assertEqual(str(e.exception), "Value [] on field 'foo' must not be empty") with self.assertRaises(ValueError) as e: @@ -130,109 +147,107 @@ class ExternIdArray(ExternId): field.clean([1, 1]) # Values must be unique self.assertEqual(str(e.exception), "List entries [1, 1] on field 'foo' must be unique") with self.assertRaises(ValueError) as e: - field.clean(['d']) + field.clean(["d"]) self.assertEqual(str(e.exception), "List value 'd' on field 'foo' must be one of ['a', 'b', 'c']") def test_garbage_input(self): # Test that we can survive garbage input for common field types - tz = zoneinfo.ZoneInfo('Europe/Copenhagen') - account = namedtuple('Account', ['default_timezone'])(default_timezone=tz) - payload = b'''\ + tz = zoneinfo.ZoneInfo("Europe/Copenhagen") + account = namedtuple("Account", ["default_timezone"])(default_timezone=tz) + payload = b"""\ THIS_IS_GARBAGE -''' - elem = to_xml(payload).find(f'{{{TNS}}}Item') +""" + elem = to_xml(payload).find(f"{{{TNS}}}Item") for field_cls in (Base64Field, BooleanField, IntegerField, DateField, DateTimeField, DecimalField): - field = field_cls('foo', field_uri='item:Foo', is_required=True, default='DUMMY') + field = field_cls("foo", field_uri="item:Foo", is_required=True, default="DUMMY") self.assertEqual(field.from_xml(elem=elem, account=account), None) # Test MS timezones - payload = b'''\ + payload = b"""\ -''' - elem = to_xml(payload).find(f'{{{TNS}}}Item') - field = TimeZoneField('foo', field_uri='item:Foo', default='DUMMY') +""" + elem = to_xml(payload).find(f"{{{TNS}}}Item") + field = TimeZoneField("foo", field_uri="item:Foo", default="DUMMY") self.assertEqual(field.from_xml(elem=elem, account=account), None) def test_versioned_field(self): - field = TextField('foo', field_uri='bar', supported_from=EXCHANGE_2010) + field = TextField("foo", field_uri="bar", supported_from=EXCHANGE_2010) with self.assertRaises(InvalidFieldForVersion): - field.clean('baz', version=Version(EXCHANGE_2007)) - field.clean('baz', version=Version(EXCHANGE_2010)) - field.clean('baz', version=Version(EXCHANGE_2013)) + field.clean("baz", version=Version(EXCHANGE_2007)) + field.clean("baz", version=Version(EXCHANGE_2010)) + field.clean("baz", version=Version(EXCHANGE_2013)) def test_versioned_choice(self): - field = ChoiceField('foo', field_uri='bar', choices={ - Choice('c1'), Choice('c2', supported_from=EXCHANGE_2010) - }) + field = ChoiceField("foo", field_uri="bar", choices={Choice("c1"), Choice("c2", supported_from=EXCHANGE_2010)}) with self.assertRaises(ValueError): - field.clean('XXX') # Value must be a valid choice - field.clean('c2', version=None) + field.clean("XXX") # Value must be a valid choice + field.clean("c2", version=None) with self.assertRaises(InvalidChoiceForVersion): - field.clean('c2', version=Version(EXCHANGE_2007)) - field.clean('c2', version=Version(EXCHANGE_2010)) - field.clean('c2', version=Version(EXCHANGE_2013)) + field.clean("c2", version=Version(EXCHANGE_2007)) + field.clean("c2", version=Version(EXCHANGE_2010)) + field.clean("c2", version=Version(EXCHANGE_2013)) def test_naive_datetime(self): # Test that we can survive naive datetimes on a datetime field - tz = zoneinfo.ZoneInfo('Europe/Copenhagen') - utc = zoneinfo.ZoneInfo('UTC') - account = namedtuple('Account', ['default_timezone'])(default_timezone=tz) + tz = zoneinfo.ZoneInfo("Europe/Copenhagen") + utc = zoneinfo.ZoneInfo("UTC") + account = namedtuple("Account", ["default_timezone"])(default_timezone=tz) default_value = datetime.datetime(2017, 1, 2, 3, 4, tzinfo=tz) - field = DateTimeField('foo', field_uri='item:DateTimeSent', default=default_value) + field = DateTimeField("foo", field_uri="item:DateTimeSent", default=default_value) # TZ-aware datetime string - payload = b'''\ + payload = b"""\ 2017-06-21T18:40:02Z -''' - elem = to_xml(payload).find(f'{{{TNS}}}Item') +""" + elem = to_xml(payload).find(f"{{{TNS}}}Item") self.assertEqual( field.from_xml(elem=elem, account=account), datetime.datetime(2017, 6, 21, 18, 40, 2, tzinfo=utc) ) # Naive datetime string is localized to tz of the account - payload = b'''\ + payload = b"""\ 2017-06-21T18:40:02 -''' - elem = to_xml(payload).find(f'{{{TNS}}}Item') +""" + elem = to_xml(payload).find(f"{{{TNS}}}Item") self.assertEqual( field.from_xml(elem=elem, account=account), datetime.datetime(2017, 6, 21, 18, 40, 2, tzinfo=tz) ) # Garbage string returns None - payload = b'''\ + payload = b"""\ THIS_IS_GARBAGE -''' - elem = to_xml(payload).find(f'{{{TNS}}}Item') +""" + elem = to_xml(payload).find(f"{{{TNS}}}Item") self.assertEqual(field.from_xml(elem=elem, account=account), None) # Element not found returns default value - payload = b'''\ + payload = b"""\ -''' - elem = to_xml(payload).find(f'{{{TNS}}}Item') +""" + elem = to_xml(payload).find(f"{{{TNS}}}Item") self.assertEqual(field.from_xml(elem=elem, account=account), default_value) def test_single_field_indexed_element(self): @@ -246,5 +261,5 @@ class TestField(SingleFieldIndexedElement): self.assertEqual( e.exception.args[0], "Class .TestField'> " - "must have only one value field (found ('a', 'b'))" + "must have only one value field (found ('a', 'b'))", ) diff --git a/tests/test_folder.py b/tests/test_folder.py index 9090e2bf..647bab46 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -1,24 +1,83 @@ from unittest.mock import Mock -from exchangelib.errors import ErrorDeleteDistinguishedFolder, ErrorObjectTypeChanged, DoesNotExist, \ - MultipleObjectsReturned, ErrorItemSave, ErrorItemNotFound, ErrorFolderExists, ErrorFolderNotFound, \ - ErrorCannotEmptyFolder +from exchangelib.errors import ( + DoesNotExist, + ErrorCannotEmptyFolder, + ErrorDeleteDistinguishedFolder, + ErrorFolderExists, + ErrorFolderNotFound, + ErrorItemNotFound, + ErrorItemSave, + ErrorObjectTypeChanged, + MultipleObjectsReturned, +) from exchangelib.extended_properties import ExtendedProperty +from exchangelib.folders import ( + NON_DELETABLE_FOLDERS, + SHALLOW, + AllContacts, + AllItems, + Calendar, + Companies, + Contacts, + ConversationSettings, + DefaultFoldersChangeHistory, + DeletedItems, + DistinguishedFolderId, + Drafts, + Favorites, + Files, + Folder, + FolderCollection, + FolderQuerySet, + Friends, + GALContacts, + GraphAnalytics, + IMContactList, + Inbox, + Journal, + JunkEmail, + Messages, + MyContacts, + MyContactsExtended, + Notes, + OrganizationalContacts, + Outbox, + PassThroughSearchResults, + PdpProfileV2Secured, + PeopleCentricConversationBuddies, + PublicFoldersRoot, + QuickContacts, + RecipientCache, + Reminders, + RootOfHierarchy, + RSSFeeds, + SentItems, + Sharing, + Signal, + SingleFolderQuerySet, + SmsAndChatsSync, + SyncIssues, + System, + Tasks, + ToDoSearch, + VoiceMail, +) from exchangelib.items import Message -from exchangelib.folders import Calendar, DeletedItems, Drafts, Inbox, Outbox, SentItems, JunkEmail, Messages, Tasks, \ - Contacts, Folder, RecipientCache, GALContacts, System, AllContacts, MyContactsExtended, Reminders, Favorites, \ - AllItems, ConversationSettings, Friends, RSSFeeds, Sharing, IMContactList, QuickContacts, Journal, Notes, \ - SyncIssues, MyContacts, ToDoSearch, FolderCollection, DistinguishedFolderId, Files, \ - DefaultFoldersChangeHistory, PassThroughSearchResults, SmsAndChatsSync, GraphAnalytics, Signal, \ - PdpProfileV2Secured, VoiceMail, FolderQuerySet, SingleFolderQuerySet, SHALLOW, RootOfHierarchy, Companies, \ - OrganizationalContacts, PeopleCentricConversationBuddies, PublicFoldersRoot, NON_DELETABLE_FOLDERS -from exchangelib.properties import Mailbox, InvalidField, EffectiveRights, PermissionSet, CalendarPermission, UserId +from exchangelib.properties import CalendarPermission, EffectiveRights, InvalidField, Mailbox, PermissionSet, UserId from exchangelib.queryset import Q -from exchangelib.services import GetFolder, DeleteFolder, FindFolder, EmptyFolder -from exchangelib.version import Version, EXCHANGE_2007 +from exchangelib.services import DeleteFolder, EmptyFolder, FindFolder, GetFolder +from exchangelib.version import EXCHANGE_2007, Version -from .common import EWSTest, get_random_string, get_random_int, get_random_bool, get_random_datetime, get_random_bytes,\ - get_random_byte +from .common import ( + EWSTest, + get_random_bool, + get_random_byte, + get_random_bytes, + get_random_datetime, + get_random_int, + get_random_string, +) def get_random_str_tuple(tuple_length, str_length): @@ -29,23 +88,23 @@ class FolderTest(EWSTest): def test_folders(self): # Test shortcuts for f, cls in ( - (self.account.trash, DeletedItems), - (self.account.drafts, Drafts), - (self.account.inbox, Inbox), - (self.account.outbox, Outbox), - (self.account.sent, SentItems), - (self.account.junk, JunkEmail), - (self.account.contacts, Contacts), - (self.account.tasks, Tasks), - (self.account.calendar, Calendar), + (self.account.trash, DeletedItems), + (self.account.drafts, Drafts), + (self.account.inbox, Inbox), + (self.account.outbox, Outbox), + (self.account.sent, SentItems), + (self.account.junk, JunkEmail), + (self.account.contacts, Contacts), + (self.account.tasks, Tasks), + (self.account.calendar, Calendar), ): with self.subTest(f=f, cls=cls): self.assertIsInstance(f, cls) f.test_access() # Test item field lookup - self.assertEqual(f.get_item_field_by_fieldname('subject').name, 'subject') + self.assertEqual(f.get_item_field_by_fieldname("subject").name, "subject") with self.assertRaises(ValueError): - f.get_item_field_by_fieldname('XXX') + f.get_item_field_by_fieldname("XXX") def test_folder_failure(self): # Folders must have an ID @@ -58,7 +117,7 @@ def test_folder_failure(self): with self.assertRaises(ValueError): self.account.root.remove_folder(Folder()) # Removing a non-existent folder is allowed - self.account.root.remove_folder(Folder(id='XXX')) + self.account.root.remove_folder(Folder(id="XXX")) # Must be called on a distinguished folder class with self.assertRaises(ValueError): RootOfHierarchy.get_distinguished(self.account) @@ -69,22 +128,22 @@ def test_folder_failure(self): Folder(root=self.account.public_folders_root, parent=self.account.inbox) self.assertEqual(e.exception.args[0], "'parent.root' must match 'root'") with self.assertRaises(ValueError) as e: - Folder(parent=self.account.inbox, parent_folder_id='XXX') + Folder(parent=self.account.inbox, parent_folder_id="XXX") self.assertEqual(e.exception.args[0], "'parent_folder_id' must match 'parent' ID") with self.assertRaises(TypeError) as e: - Folder(root='XXX').clean() + Folder(root="XXX").clean() self.assertEqual( e.exception.args[0], "'root' 'XXX' must be of type " ) with self.assertRaises(ValueError) as e: - Folder().save(update_fields=['name']) + Folder().save(update_fields=["name"]) self.assertEqual(e.exception.args[0], "'update_fields' is only valid for updates") with self.assertRaises(ValueError) as e: - Messages().validate_item_field('XXX', version=self.account.version) + Messages().validate_item_field("XXX", version=self.account.version) self.assertIn("'XXX' is not a valid field on", e.exception.args[0]) with self.assertRaises(ValueError) as e: - Folder.item_model_from_tag('XXX') - self.assertEqual(e.exception.args[0], 'Item type XXX was unexpected in a Folder folder') + Folder.item_model_from_tag("XXX") + self.assertEqual(e.exception.args[0], "Item type XXX was unexpected in a Folder folder") def test_public_folders_root(self): # Test account does not have a public folders root. Make a dummy query just to hit .get_children() @@ -97,54 +156,46 @@ def test_invalid_deletefolder_args(self): with self.assertRaises(ValueError) as e: DeleteFolder(account=self.account).call( folders=[], - delete_type='XXX', + delete_type="XXX", ) self.assertEqual( - e.exception.args[0], - "'delete_type' 'XXX' must be one of ['HardDelete', 'MoveToDeletedItems', 'SoftDelete']" + e.exception.args[0], "'delete_type' 'XXX' must be one of ['HardDelete', 'MoveToDeletedItems', 'SoftDelete']" ) def test_invalid_emptyfolder_args(self): with self.assertRaises(ValueError) as e: EmptyFolder(account=self.account).call( folders=[], - delete_type='XXX', + delete_type="XXX", delete_sub_folders=False, ) self.assertEqual( - e.exception.args[0], - "'delete_type' 'XXX' must be one of ['HardDelete', 'MoveToDeletedItems', 'SoftDelete']" + e.exception.args[0], "'delete_type' 'XXX' must be one of ['HardDelete', 'MoveToDeletedItems', 'SoftDelete']" ) def test_invalid_findfolder_args(self): with self.assertRaises(ValueError) as e: FindFolder(account=self.account).call( - folders=['XXX'], + folders=["XXX"], additional_fields=None, restriction=None, - shape='XXX', - depth='Shallow', + shape="XXX", + depth="Shallow", max_items=None, offset=None, ) - self.assertEqual( - e.exception.args[0], - "'shape' 'XXX' must be one of ['AllProperties', 'Default', 'IdOnly']" - ) + self.assertEqual(e.exception.args[0], "'shape' 'XXX' must be one of ['AllProperties', 'Default', 'IdOnly']") with self.assertRaises(ValueError) as e: FindFolder(account=self.account).call( - folders=['XXX'], + folders=["XXX"], additional_fields=None, restriction=None, - shape='IdOnly', - depth='XXX', + shape="IdOnly", + depth="XXX", max_items=None, offset=None, ) - self.assertEqual( - e.exception.args[0], - "'depth' 'XXX' must be one of ['Deep', 'Shallow', 'SoftDeleted']" - ) + self.assertEqual(e.exception.args[0], "'depth' 'XXX' must be one of ['Deep', 'Shallow', 'SoftDeleted']") def test_find_folders(self): folders = list(FolderCollection(account=self.account, folders=[self.account.root]).find_folders()) @@ -153,7 +204,7 @@ def test_find_folders(self): def test_find_folders_multiple_roots(self): coll = FolderCollection(account=self.account, folders=[self.account.root, self.account.public_folders_root]) with self.assertRaises(ValueError) as e: - list(coll.find_folders(depth='Shallow')) + list(coll.find_folders(depth="Shallow")) self.assertIn("All folders in 'roots' must have the same root hierarchy", e.exception.args[0]) def test_find_folders_compat(self): @@ -162,27 +213,36 @@ def test_find_folders_compat(self): account.version = Version(EXCHANGE_2007) # Need to set it after the last auto-config of version with self.assertRaises(NotImplementedError) as e: list(coll.find_folders(offset=1)) - self.assertEqual( - e.exception.args[0], - "'offset' is only supported for Exchange 2010 servers and later" - ) + self.assertEqual(e.exception.args[0], "'offset' is only supported for Exchange 2010 servers and later") def test_find_folders_with_restriction(self): # Exact match - folders = list(FolderCollection(account=self.account, folders=[self.account.root]) - .find_folders(q=Q(name='Top of Information Store'))) + folders = list( + FolderCollection(account=self.account, folders=[self.account.root]).find_folders( + q=Q(name="Top of Information Store") + ) + ) self.assertEqual(len(folders), 1, sorted(f.name for f in folders)) # Startswith - folders = list(FolderCollection(account=self.account, folders=[self.account.root]) - .find_folders(q=Q(name__startswith='Top of '))) + folders = list( + FolderCollection(account=self.account, folders=[self.account.root]).find_folders( + q=Q(name__startswith="Top of ") + ) + ) self.assertEqual(len(folders), 1, sorted(f.name for f in folders)) # Wrong case - folders = list(FolderCollection(account=self.account, folders=[self.account.root]) - .find_folders(q=Q(name__startswith='top of '))) + folders = list( + FolderCollection(account=self.account, folders=[self.account.root]).find_folders( + q=Q(name__startswith="top of ") + ) + ) self.assertEqual(len(folders), 0, sorted(f.name for f in folders)) # Case insensitive - folders = list(FolderCollection(account=self.account, folders=[self.account.root]) - .find_folders(q=Q(name__istartswith='top of '))) + folders = list( + FolderCollection(account=self.account, folders=[self.account.root]).find_folders( + q=Q(name__istartswith="top of ") + ) + ) self.assertEqual(len(folders), 1, sorted(f.name for f in folders)) def test_get_folders(self): @@ -190,24 +250,32 @@ def test_get_folders(self): self.assertEqual(len(folders), 1, sorted(f.name for f in folders)) # Test that GetFolder can handle FolderId instances - folders = list(FolderCollection(account=self.account, folders=[DistinguishedFolderId( - id=Inbox.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=self.account.primary_smtp_address) - )]).get_folders()) + folders = list( + FolderCollection( + account=self.account, + folders=[ + DistinguishedFolderId( + id=Inbox.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ) + ], + ).get_folders() + ) self.assertEqual(len(folders), 1, sorted(f.name for f in folders)) def test_get_folders_with_distinguished_id(self): # Test that we return an Inbox instance and not a generic Messages or Folder instance when we call GetFolder # with a DistinguishedFolderId instance with an ID of Inbox.DISTINGUISHED_FOLDER_ID. inbox_folder_id = DistinguishedFolderId( - id=Inbox.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=self.account.primary_smtp_address) + id=Inbox.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address) ) - inbox = list(GetFolder(account=self.account).call( - folders=[inbox_folder_id], - shape='IdOnly', - additional_fields=[], - ))[0] + inbox = list( + GetFolder(account=self.account).call( + folders=[inbox_folder_id], + shape="IdOnly", + additional_fields=[], + ) + )[0] self.assertIsInstance(inbox, Inbox) # Test via SingleFolderQuerySet @@ -218,63 +286,72 @@ def test_folder_grouping(self): # If you get errors here, you probably need to fill out [folder class].LOCALIZED_NAMES for your locale. for f in self.account.root.walk(): with self.subTest(f=f): - if isinstance(f, ( - Messages, DeletedItems, AllContacts, MyContactsExtended, Sharing, Favorites, SyncIssues, - MyContacts - )): - self.assertEqual(f.folder_class, 'IPF.Note') + if isinstance( + f, + ( + Messages, + DeletedItems, + AllContacts, + MyContactsExtended, + Sharing, + Favorites, + SyncIssues, + MyContacts, + ), + ): + self.assertEqual(f.folder_class, "IPF.Note") elif isinstance(f, Companies): - self.assertEqual(f.folder_class, 'IPF.Contact.Company') + self.assertEqual(f.folder_class, "IPF.Contact.Company") elif isinstance(f, OrganizationalContacts): - self.assertEqual(f.folder_class, 'IPF.Contact.OrganizationalContacts') + self.assertEqual(f.folder_class, "IPF.Contact.OrganizationalContacts") elif isinstance(f, PeopleCentricConversationBuddies): - self.assertEqual(f.folder_class, 'IPF.Contact.PeopleCentricConversationBuddies') + self.assertEqual(f.folder_class, "IPF.Contact.PeopleCentricConversationBuddies") elif isinstance(f, GALContacts): - self.assertEqual(f.folder_class, 'IPF.Contact.GalContacts') + self.assertEqual(f.folder_class, "IPF.Contact.GalContacts") elif isinstance(f, RecipientCache): - self.assertEqual(f.folder_class, 'IPF.Contact.RecipientCache') + self.assertEqual(f.folder_class, "IPF.Contact.RecipientCache") elif isinstance(f, Contacts): - self.assertEqual(f.folder_class, 'IPF.Contact') + self.assertEqual(f.folder_class, "IPF.Contact") elif isinstance(f, Calendar): - self.assertEqual(f.folder_class, 'IPF.Appointment') + self.assertEqual(f.folder_class, "IPF.Appointment") elif isinstance(f, (Tasks, ToDoSearch)): - self.assertEqual(f.folder_class, 'IPF.Task') + self.assertEqual(f.folder_class, "IPF.Task") elif isinstance(f, Reminders): - self.assertEqual(f.folder_class, 'Outlook.Reminder') + self.assertEqual(f.folder_class, "Outlook.Reminder") elif isinstance(f, AllItems): - self.assertEqual(f.folder_class, 'IPF') + self.assertEqual(f.folder_class, "IPF") elif isinstance(f, ConversationSettings): - self.assertEqual(f.folder_class, 'IPF.Configuration') + self.assertEqual(f.folder_class, "IPF.Configuration") elif isinstance(f, Files): - self.assertEqual(f.folder_class, 'IPF.Files') + self.assertEqual(f.folder_class, "IPF.Files") elif isinstance(f, Friends): - self.assertEqual(f.folder_class, 'IPF.Note') + self.assertEqual(f.folder_class, "IPF.Note") elif isinstance(f, RSSFeeds): - self.assertEqual(f.folder_class, 'IPF.Note.OutlookHomepage') + self.assertEqual(f.folder_class, "IPF.Note.OutlookHomepage") elif isinstance(f, IMContactList): - self.assertEqual(f.folder_class, 'IPF.Contact.MOC.ImContactList') + self.assertEqual(f.folder_class, "IPF.Contact.MOC.ImContactList") elif isinstance(f, QuickContacts): - self.assertEqual(f.folder_class, 'IPF.Contact.MOC.QuickContacts') + self.assertEqual(f.folder_class, "IPF.Contact.MOC.QuickContacts") elif isinstance(f, Journal): - self.assertEqual(f.folder_class, 'IPF.Journal') + self.assertEqual(f.folder_class, "IPF.Journal") elif isinstance(f, Notes): - self.assertEqual(f.folder_class, 'IPF.StickyNote') + self.assertEqual(f.folder_class, "IPF.StickyNote") elif isinstance(f, DefaultFoldersChangeHistory): - self.assertEqual(f.folder_class, 'IPM.DefaultFolderHistoryItem') + self.assertEqual(f.folder_class, "IPM.DefaultFolderHistoryItem") elif isinstance(f, PassThroughSearchResults): - self.assertEqual(f.folder_class, 'IPF.StoreItem.PassThroughSearchResults') + self.assertEqual(f.folder_class, "IPF.StoreItem.PassThroughSearchResults") elif isinstance(f, SmsAndChatsSync): - self.assertEqual(f.folder_class, 'IPF.SmsAndChatsSync') + self.assertEqual(f.folder_class, "IPF.SmsAndChatsSync") elif isinstance(f, GraphAnalytics): - self.assertEqual(f.folder_class, 'IPF.StoreItem.GraphAnalytics') + self.assertEqual(f.folder_class, "IPF.StoreItem.GraphAnalytics") elif isinstance(f, Signal): - self.assertEqual(f.folder_class, 'IPF.StoreItem.Signal') + self.assertEqual(f.folder_class, "IPF.StoreItem.Signal") elif isinstance(f, PdpProfileV2Secured): - self.assertEqual(f.folder_class, 'IPF.StoreItem.PdpProfileSecured') + self.assertEqual(f.folder_class, "IPF.StoreItem.PdpProfileSecured") elif isinstance(f, VoiceMail): - self.assertEqual(f.folder_class, 'IPF.Note.Microsoft.Voicemail') + self.assertEqual(f.folder_class, "IPF.Note.Microsoft.Voicemail") else: - self.assertIn(f.folder_class, (None, 'IPF'), (f.name, f.__class__.__name__, f.folder_class)) + self.assertIn(f.folder_class, (None, "IPF"), (f.name, f.__class__.__name__, f.folder_class)) self.assertIsInstance(f, Folder) def test_counts(self): @@ -288,7 +365,7 @@ def test_counts(self): # Create some items items = [] for i in range(3): - subject = f'Test Subject {i}' + subject = f"Test Subject {i}" item = Message(account=self.account, folder=f, is_read=False, subject=subject, categories=self.categories) item.save() items.append(item) @@ -336,7 +413,7 @@ def test_refresh(self): old_values = {} for field in f.FIELDS: old_values[field.name] = getattr(f, field.name) - if field.name in ('account', 'id', 'changekey', 'parent_folder_id'): + if field.name in ("account", "id", "changekey", "parent_folder_id"): # These are needed for a successful refresh() continue if field.is_read_only: @@ -344,7 +421,7 @@ def test_refresh(self): setattr(f, field.name, self.random_val(field)) f.refresh() for field in f.FIELDS: - if field.name == 'changekey': + if field.name == "changekey": # folders may change while we're testing continue if field.is_read_only: @@ -354,7 +431,7 @@ def test_refresh(self): # Test refresh of root orig_name = self.account.root.name - self.account.root.name = 'xxx' + self.account.root.name = "xxx" self.account.root.refresh() self.assertEqual(self.account.root.name, orig_name) @@ -366,18 +443,12 @@ def test_refresh(self): folder.refresh() # Must have an id def test_parent(self): - self.assertEqual( - self.account.calendar.parent.name, - 'Top of Information Store' - ) - self.assertEqual( - self.account.calendar.parent.parent.name, - 'root' - ) + self.assertEqual(self.account.calendar.parent.name, "Top of Information Store") + self.assertEqual(self.account.calendar.parent.parent.name, "root") # Setters parent = self.account.calendar.parent with self.assertRaises(TypeError) as e: - self.account.calendar.parent = 'XXX' + self.account.calendar.parent = "XXX" self.assertEqual( e.exception.args[0], "'value' 'XXX' must be of type " ) @@ -388,100 +459,83 @@ def test_parent(self): self.assertIsNone(Folder(id=self.account.inbox.id, parent=self.account.inbox).parent) def test_children(self): - self.assertIn( - 'Top of Information Store', - [c.name for c in self.account.root.children] - ) + self.assertIn("Top of Information Store", [c.name for c in self.account.root.children]) def test_parts(self): self.assertEqual( [p.name for p in self.account.calendar.parts], - ['root', 'Top of Information Store', self.account.calendar.name] + ["root", "Top of Information Store", self.account.calendar.name], ) def test_absolute(self): - self.assertEqual( - self.account.calendar.absolute, - '/root/Top of Information Store/' + self.account.calendar.name - ) + self.assertEqual(self.account.calendar.absolute, "/root/Top of Information Store/" + self.account.calendar.name) def test_walk(self): self.assertGreaterEqual(len(list(self.account.root.walk())), 20) self.assertGreaterEqual(len(list(self.account.contacts.walk())), 2) def test_tree(self): - self.assertTrue(self.account.root.tree().startswith('root')) + self.assertTrue(self.account.root.tree().startswith("root")) def test_glob(self): - self.assertGreaterEqual(len(list(self.account.root.glob('*'))), 5) - self.assertEqual(len(list(self.account.contacts.glob('GAL*'))), 1) - self.assertEqual(len(list(self.account.contacts.glob('gal*'))), 1) # Test case-insensitivity - self.assertGreaterEqual(len(list(self.account.contacts.glob('/'))), 5) - self.assertGreaterEqual(len(list(self.account.contacts.glob('../*'))), 5) - self.assertEqual(len(list(self.account.root.glob(f'**/{self.account.contacts.name}'))), 1) - self.assertEqual(len(list(self.account.root.glob(f'Top of*/{self.account.contacts.name}'))), 1) + self.assertGreaterEqual(len(list(self.account.root.glob("*"))), 5) + self.assertEqual(len(list(self.account.contacts.glob("GAL*"))), 1) + self.assertEqual(len(list(self.account.contacts.glob("gal*"))), 1) # Test case-insensitivity + self.assertGreaterEqual(len(list(self.account.contacts.glob("/"))), 5) + self.assertGreaterEqual(len(list(self.account.contacts.glob("../*"))), 5) + self.assertEqual(len(list(self.account.root.glob(f"**/{self.account.contacts.name}"))), 1) + self.assertEqual(len(list(self.account.root.glob(f"Top of*/{self.account.contacts.name}"))), 1) with self.assertRaises(ValueError) as e: - list(self.account.root.glob('../*')) - self.assertEqual(e.exception.args[0], 'Already at top') + list(self.account.root.glob("../*")) + self.assertEqual(e.exception.args[0], "Already at top") def test_collection_filtering(self): self.assertGreaterEqual(self.account.root.tois.children.all().count(), 0) self.assertGreaterEqual(self.account.root.tois.walk().all().count(), 0) - self.assertGreaterEqual(self.account.root.tois.glob('*').all().count(), 0) + self.assertGreaterEqual(self.account.root.tois.glob("*").all().count(), 0) def test_empty_collections(self): self.assertEqual(self.account.trash.children.all().count(), 0) self.assertEqual(self.account.trash.walk().all().count(), 0) - self.assertEqual(self.account.trash.glob('XXX').all().count(), 0) - self.assertEqual(list(self.account.trash.glob('XXX').get_folders()), []) - self.assertEqual(list(self.account.trash.glob('XXX').find_folders()), []) + self.assertEqual(self.account.trash.glob("XXX").all().count(), 0) + self.assertEqual(list(self.account.trash.glob("XXX").get_folders()), []) + self.assertEqual(list(self.account.trash.glob("XXX").find_folders()), []) def test_div_navigation(self): self.assertEqual( - (self.account.root / 'Top of Information Store' / self.account.calendar.name).id, - self.account.calendar.id - ) - self.assertEqual( - (self.account.root / 'Top of Information Store' / '..').id, - self.account.root.id - ) - self.assertEqual( - (self.account.root / '.').id, - self.account.root.id + (self.account.root / "Top of Information Store" / self.account.calendar.name).id, self.account.calendar.id ) + self.assertEqual((self.account.root / "Top of Information Store" / "..").id, self.account.root.id) + self.assertEqual((self.account.root / ".").id, self.account.root.id) with self.assertRaises(ValueError) as e: - _ = self.account.root / '..' - self.assertEqual(e.exception.args[0], 'Already at top') + _ = self.account.root / ".." + self.assertEqual(e.exception.args[0], "Already at top") # Test invalid subfolder with self.assertRaises(ErrorFolderNotFound): - _ = self.account.root / 'XXX' + _ = self.account.root / "XXX" def test_double_div_navigation(self): self.account.root.clear_cache() # Clear the cache # Test normal navigation self.assertEqual( - (self.account.root // 'Top of Information Store' // self.account.calendar.name).id, - self.account.calendar.id + (self.account.root // "Top of Information Store" // self.account.calendar.name).id, self.account.calendar.id ) self.assertIsNone(self.account.root._subfolders) # Test parent ('..') syntax. Should not work with self.assertRaises(ValueError) as e: - _ = self.account.root // 'Top of Information Store' // '..' - self.assertEqual(e.exception.args[0], 'Cannot get parent without a folder cache') + _ = self.account.root // "Top of Information Store" // ".." + self.assertEqual(e.exception.args[0], "Cannot get parent without a folder cache") self.assertIsNone(self.account.root._subfolders) # Test self ('.') syntax - self.assertEqual( - (self.account.root // '.').id, - self.account.root.id - ) + self.assertEqual((self.account.root // ".").id, self.account.root.id) # Test invalid subfolder with self.assertRaises(ErrorFolderNotFound): - _ = self.account.root // 'XXX' + _ = self.account.root // "XXX" # Check that this didn't trigger caching self.assertIsNone(self.account.root._subfolders) @@ -489,22 +543,22 @@ def test_double_div_navigation(self): def test_extended_properties(self): # Test extended properties on folders and folder roots. This extended prop gets the size (in bytes) of a folder class FolderSize(ExtendedProperty): - property_tag = 0x0e08 - property_type = 'Integer' + property_tag = 0x0E08 + property_type = "Integer" try: - Folder.register('size', FolderSize) + Folder.register("size", FolderSize) self.account.inbox.refresh() self.assertGreater(self.account.inbox.size, 0) finally: - Folder.deregister('size') + Folder.deregister("size") try: - RootOfHierarchy.register('size', FolderSize) + RootOfHierarchy.register("size", FolderSize) self.account.root.refresh() self.assertGreater(self.account.root.size, 0) finally: - RootOfHierarchy.deregister('size') + RootOfHierarchy.deregister("size") # Register and deregister is only allowed on Folder and RootOfHierarchy classes with self.assertRaises(TypeError): @@ -533,7 +587,7 @@ def test_create_update_empty_delete(self): with self.assertRaises(ErrorObjectTypeChanged): # FolderClass may not be changed f.folder_class = get_random_string(16) - f.save(update_fields=['folder_class']) + f.save(update_fields=["folder_class"]) # Create a subfolder Messages(parent=f, name=get_random_string(16)).save() @@ -570,7 +624,7 @@ def test_wipe_without_empty(self): self.assertEqual(len(list(f.children)), 1) tmp = f.empty try: - f.empty = Mock(side_effect=ErrorCannotEmptyFolder('XXX')) + f.empty = Mock(side_effect=ErrorCannotEmptyFolder("XXX")) f.wipe() finally: f.empty = tmp @@ -583,11 +637,11 @@ def test_move(self): f1_id, f1_changekey, f1_parent = f1.id, f1.changekey, f1.parent with self.assertRaises(TypeError) as e: - f1.move(to_folder='XXX') # Must be folder instance + f1.move(to_folder="XXX") # Must be folder instance self.assertEqual( e.exception.args[0], "'to_folder' 'XXX' must be of type (, " - ")" + ")", ) f1.move(f2) self.assertEqual(f1.id, f1_id) @@ -612,8 +666,8 @@ def test_generic_folder(self): f.delete() self.assertEqual(Folder().has_distinguished_name, None) - self.assertEqual(Inbox(name='XXX').has_distinguished_name, False) - self.assertEqual(Inbox(name='Inbox').has_distinguished_name, True) + self.assertEqual(Inbox(name="XXX").has_distinguished_name, False) + self.assertEqual(Inbox(name="Inbox").has_distinguished_name, True) self.assertEqual(Inbox(is_distinguished=False).is_deletable, True) self.assertEqual(Inbox(is_distinguished=True).is_deletable, False) @@ -639,55 +693,27 @@ def test_folder_query_set(self): folder_qs = SingleFolderQuerySet(account=self.account, folder=f0) try: # Test all() - self.assertSetEqual( - {f.name for f in folder_qs.all()}, - {f.name for f in (f1, f2, f21, f22)} - ) + self.assertSetEqual({f.name for f in folder_qs.all()}, {f.name for f in (f1, f2, f21, f22)}) # Test only() - self.assertSetEqual( - {f.name for f in folder_qs.only('name').all()}, - {f.name for f in (f1, f2, f21, f22)} - ) - self.assertSetEqual( - {f.child_folder_count for f in folder_qs.only('name').all()}, - {None} - ) + self.assertSetEqual({f.name for f in folder_qs.only("name").all()}, {f.name for f in (f1, f2, f21, f22)}) + self.assertSetEqual({f.child_folder_count for f in folder_qs.only("name").all()}, {None}) # Test depth() - self.assertSetEqual( - {f.name for f in folder_qs.depth(SHALLOW).all()}, - {f.name for f in (f1, f2)} - ) + self.assertSetEqual({f.name for f in folder_qs.depth(SHALLOW).all()}, {f.name for f in (f1, f2)}) # Test filter() + self.assertSetEqual({f.name for f in folder_qs.filter(name=f1.name)}, {f.name for f in (f1,)}) self.assertSetEqual( - {f.name for f in folder_qs.filter(name=f1.name)}, - {f.name for f in (f1,)} - ) - self.assertSetEqual( - {f.name for f in folder_qs.filter(name__in=[f1.name, f2.name])}, - {f.name for f in (f1, f2)} + {f.name for f in folder_qs.filter(name__in=[f1.name, f2.name])}, {f.name for f in (f1, f2)} ) # Test get() self.assertEqual(folder_qs.get(id=f2.id).name, f2.name) self.assertEqual(folder_qs.get(id=f2.id, changekey=f2.changekey).name, f2.name) - self.assertEqual( - folder_qs.get(name=f2.name).child_folder_count, - 2 - ) - self.assertEqual( - folder_qs.filter(name=f2.name).get().child_folder_count, - 2 - ) - self.assertEqual( - folder_qs.only('name').get(name=f2.name).name, - f2.name - ) - self.assertEqual( - folder_qs.only('name').get(name=f2.name).child_folder_count, - None - ) + self.assertEqual(folder_qs.get(name=f2.name).child_folder_count, 2) + self.assertEqual(folder_qs.filter(name=f2.name).get().child_folder_count, 2) + self.assertEqual(folder_qs.only("name").get(name=f2.name).name, f2.name) + self.assertEqual(folder_qs.only("name").get(name=f2.name).child_folder_count, None) with self.assertRaises(DoesNotExist): folder_qs.get(name=get_random_string(16)) with self.assertRaises(MultipleObjectsReturned): @@ -698,19 +724,19 @@ def test_folder_query_set(self): def test_folder_query_set_failures(self): with self.assertRaises(TypeError) as e: - FolderQuerySet('XXX') + FolderQuerySet("XXX") self.assertEqual( e.exception.args[0], - "'folder_collection' 'XXX' must be of type " + "'folder_collection' 'XXX' must be of type ", ) # Test FolderQuerySet._copy_cls() - self.assertEqual(list(FolderQuerySet(FolderCollection(account=self.account, folders=[])).only('name')), []) + self.assertEqual(list(FolderQuerySet(FolderCollection(account=self.account, folders=[])).only("name")), []) fld_qs = SingleFolderQuerySet(account=self.account, folder=self.account.inbox) with self.assertRaises(InvalidField) as e: - fld_qs.only('XXX') + fld_qs.only("XXX") self.assertIn("Unknown field 'XXX' on folders", e.exception.args[0]) with self.assertRaises(InvalidField) as e: - list(fld_qs.filter(XXX='XXX')) + list(fld_qs.filter(XXX="XXX")) self.assertIn("Unknown field path 'XXX' on folders", e.exception.args[0]) def test_user_configuration(self): @@ -723,10 +749,10 @@ def test_user_configuration(self): # Bad property with self.assertRaises(ValueError) as e: - f.get_user_configuration(name=name, properties='XXX') + f.get_user_configuration(name=name, properties="XXX") self.assertEqual( e.exception.args[0], - "'properties' 'XXX' must be one of ['All', 'BinaryData', 'Dictionary', 'Id', 'XmlData']" + "'properties' 'XXX' must be one of ['All', 'BinaryData', 'Dictionary', 'Id', 'XmlData']", ) # Should not exist yet @@ -743,7 +769,7 @@ def test_user_configuration(self): get_random_datetime(tz=self.account.default_timezone): get_random_string(8), get_random_str_tuple(4, 4): get_random_datetime(tz=self.account.default_timezone), } - xml_data = f'{get_random_string(16)}'.encode('utf-8') + xml_data = f"{get_random_string(16)}".encode("utf-8") binary_data = get_random_bytes(100) f.create_user_configuration(name=name, dictionary=dictionary, xml_data=xml_data, binary_data=binary_data) @@ -763,19 +789,19 @@ def test_user_configuration(self): # Update the config f.update_user_configuration( - name=name, dictionary={'bar': 'foo', 456: 'a', 'b': True}, xml_data=b'baz', binary_data=b'YYY' + name=name, dictionary={"bar": "foo", 456: "a", "b": True}, xml_data=b"baz", binary_data=b"YYY" ) # Fetch again and compare values config = f.get_user_configuration(name=name) - self.assertEqual(config.dictionary, {'bar': 'foo', 456: 'a', 'b': True}) - self.assertEqual(config.xml_data, b'baz') - self.assertEqual(config.binary_data, b'YYY') + self.assertEqual(config.dictionary, {"bar": "foo", 456: "a", "b": True}) + self.assertEqual(config.xml_data, b"baz") + self.assertEqual(config.binary_data, b"YYY") # Fetch again but only one property type - config = f.get_user_configuration(name=name, properties='XmlData') + config = f.get_user_configuration(name=name, properties="XmlData") self.assertEqual(config.dictionary, None) - self.assertEqual(config.xml_data, b'baz') + self.assertEqual(config.xml_data, b"baz") self.assertEqual(config.binary_data, None) # Delete the config @@ -788,7 +814,7 @@ def test_user_configuration(self): def test_permissionset_effectiverights_parsing(self): # Test static XML since server may not have any permission sets or effective rights - xml = b'''\ + xml = b"""\ @@ -856,7 +882,7 @@ def test_permissionset_effectiverights_parsing(self): -''' +""" ws = GetFolder(account=self.account) ws.folders = [self.account.calendar] res = list(ws.parse(xml)) @@ -865,9 +891,14 @@ def test_permissionset_effectiverights_parsing(self): self.assertEqual( fld.effective_rights, EffectiveRights( - create_associated=True, create_contents=True, create_hierarchy=True, delete=True, modify=True, - read=True, view_private_items=False - ) + create_associated=True, + create_contents=True, + create_hierarchy=True, + delete=True, + modify=True, + read=True, + view_private_items=False, + ), ) self.assertEqual( fld.permission_set, @@ -875,45 +906,60 @@ def test_permissionset_effectiverights_parsing(self): permissions=None, calendar_permissions=[ CalendarPermission( - can_create_items=False, can_create_subfolders=False, is_folder_owner=False, - is_folder_visible=True, is_folder_contact=False, edit_items='None', - delete_items='None', read_items='FullDetails', user_id=UserId( - sid='SID1', primary_smtp_address='user1@example.com', display_name='User 1', - distinguished_user=None, external_user_identity=None - ), calendar_permission_level='Reviewer' + can_create_items=False, + can_create_subfolders=False, + is_folder_owner=False, + is_folder_visible=True, + is_folder_contact=False, + edit_items="None", + delete_items="None", + read_items="FullDetails", + user_id=UserId( + sid="SID1", + primary_smtp_address="user1@example.com", + display_name="User 1", + distinguished_user=None, + external_user_identity=None, + ), + calendar_permission_level="Reviewer", ), CalendarPermission( - can_create_items=True, can_create_subfolders=False, is_folder_owner=False, - is_folder_visible=True, is_folder_contact=False, edit_items='All', delete_items='All', - read_items='FullDetails', user_id=UserId( - sid='SID2', primary_smtp_address='user2@example.com', display_name='User 2', - distinguished_user=None, external_user_identity=None - ), calendar_permission_level='Editor' - ) - ], unknown_entries=None + can_create_items=True, + can_create_subfolders=False, + is_folder_owner=False, + is_folder_visible=True, + is_folder_contact=False, + edit_items="All", + delete_items="All", + read_items="FullDetails", + user_id=UserId( + sid="SID2", + primary_smtp_address="user2@example.com", + display_name="User 2", + distinguished_user=None, + external_user_identity=None, + ), + calendar_permission_level="Editor", + ), + ], + unknown_entries=None, ), ) def test_get_candidate(self): # _get_candidate is a private method, but it's really difficult to recreate a situation where it's used. - f1 = Inbox(name='XXX', is_distinguished=True) + f1 = Inbox(name="XXX", is_distinguished=True) f2 = Inbox(name=Inbox.LOCALIZED_NAMES[self.account.locale][0]) with self.assertRaises(ErrorFolderNotFound) as e: self.account.root._get_candidate(folder_cls=Inbox, folder_coll=[]) self.assertEqual( e.exception.args[0], "No usable default folders" ) - self.assertEqual( - self.account.root._get_candidate(folder_cls=Inbox, folder_coll=[f1]), - f1 - ) - self.assertEqual( - self.account.root._get_candidate(folder_cls=Inbox, folder_coll=[f2]), - f2 - ) + self.assertEqual(self.account.root._get_candidate(folder_cls=Inbox, folder_coll=[f1]), f1) + self.assertEqual(self.account.root._get_candidate(folder_cls=Inbox, folder_coll=[f2]), f2) with self.assertRaises(ValueError) as e: self.account.root._get_candidate(folder_cls=Inbox, folder_coll=[f1, f1]) self.assertEqual( e.exception.args[0], - "Multiple possible default folders: ['XXX', 'XXX']" + "Multiple possible default folders: ['XXX', 'XXX']", ) diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index 53b6abf7..754c98af 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -1,25 +1,48 @@ import abc import datetime -from decimal import Decimal -from keyword import kwlist import time import unittest import unittest.util +from decimal import Decimal +from keyword import kwlist from dateutil.relativedelta import relativedelta -from exchangelib.errors import ErrorItemNotFound, ErrorUnsupportedPathForQuery, ErrorInvalidValueForProperty, \ - ErrorPropertyUpdate, ErrorInvalidPropertySet + +from exchangelib.errors import ( + ErrorInvalidPropertySet, + ErrorInvalidValueForProperty, + ErrorItemNotFound, + ErrorPropertyUpdate, + ErrorUnsupportedPathForQuery, +) from exchangelib.extended_properties import ExternId -from exchangelib.fields import TextField, BodyField, FieldPath, CultureField, IdField, ChoiceField, AttachmentField,\ - BooleanField, IndexedField -from exchangelib.indexed_properties import SingleFieldIndexedElement, MultiFieldIndexedElement -from exchangelib.items import CalendarItem, Contact, Task, DistributionList, BaseItem, Item -from exchangelib.properties import Mailbox, Attendee +from exchangelib.fields import ( + AttachmentField, + BodyField, + BooleanField, + ChoiceField, + CultureField, + FieldPath, + IdField, + IndexedField, + TextField, +) +from exchangelib.indexed_properties import MultiFieldIndexedElement, SingleFieldIndexedElement +from exchangelib.items import BaseItem, CalendarItem, Contact, DistributionList, Item, Task +from exchangelib.properties import Attendee, Mailbox from exchangelib.queryset import Q from exchangelib.util import value_to_xml_text -from ..common import EWSTest, get_random_string, get_random_datetime_range, get_random_date, \ - get_random_decimal, get_random_choice, get_random_substring, get_random_datetime +from ..common import ( + EWSTest, + get_random_choice, + get_random_date, + get_random_datetime, + get_random_datetime_range, + get_random_decimal, + get_random_string, + get_random_substring, +) class BaseItemTest(EWSTest, metaclass=abc.ABCMeta): @@ -44,12 +67,12 @@ def tearDown(self): # Delete all test items and delivery receipts try: self.test_folder.filter( - Q(categories__contains=self.categories) | Q(subject__startswith='Delivered: Subject: ') + Q(categories__contains=self.categories) | Q(subject__startswith="Delivered: Subject: ") ).delete() if not self.TEST_FOLDER: self.test_folder.delete() except Exception as e: - print(f'Exception in tearDown of {self}: {e}') + print(f"Exception in tearDown of {self}: {e}") pass super().tearDown() @@ -65,64 +88,67 @@ def get_random_insert_kwargs(self): if f.is_read_only: # These cannot be created continue - if f.name == 'mime_content': + if f.name == "mime_content": # This needs special formatting. See separate test_mime_content() test continue - if f.name == 'attachments': + if f.name == "attachments": # Testing attachments is heavy. Leave this to specific tests insert_kwargs[f.name] = [] continue - if f.name == 'resources': + if f.name == "resources": # The test server doesn't have any resources insert_kwargs[f.name] = [] continue - if f.name == 'optional_attendees': + if f.name == "optional_attendees": # 'optional_attendees' and 'required_attendees' are mutually exclusive insert_kwargs[f.name] = None continue - if f.name == 'start': + if f.name == "start": start = get_random_date() - insert_kwargs[f.name], insert_kwargs['end'] = \ - get_random_datetime_range(start_date=start, end_date=start, tz=self.account.default_timezone) - insert_kwargs['recurrence'] = self.random_val(self.ITEM_CLASS.get_field_by_fieldname('recurrence')) - insert_kwargs['recurrence'].boundary.start = insert_kwargs[f.name].date() + insert_kwargs[f.name], insert_kwargs["end"] = get_random_datetime_range( + start_date=start, end_date=start, tz=self.account.default_timezone + ) + insert_kwargs["recurrence"] = self.random_val(self.ITEM_CLASS.get_field_by_fieldname("recurrence")) + insert_kwargs["recurrence"].boundary.start = insert_kwargs[f.name].date() continue - if f.name == 'start_date': + if f.name == "start_date": insert_kwargs[f.name] = get_random_datetime().date() - insert_kwargs['due_date'] = insert_kwargs[f.name] + insert_kwargs["due_date"] = insert_kwargs[f.name] # Don't set 'recurrence' here. It's difficult to test updates so we'll test task recurrence separately - insert_kwargs['recurrence'] = None + insert_kwargs["recurrence"] = None continue - if f.name == 'end': + if f.name == "end": continue - if f.name == 'is_all_day': + if f.name == "is_all_day": # For CalendarItem instances, the 'is_all_day' attribute affects the 'start' and 'end' values. Changing # from 'false' to 'true' removes the time part of these datetimes. - insert_kwargs['is_all_day'] = False + insert_kwargs["is_all_day"] = False continue - if f.name == 'recurrence': + if f.name == "recurrence": continue - if f.name == 'due_date': + if f.name == "due_date": continue - if f.name == 'start_date': + if f.name == "start_date": continue - if f.name == 'status': + if f.name == "status": # Start with an incomplete task status = get_random_choice(set(f.supported_choices(version=self.account.version)) - {Task.COMPLETED}) insert_kwargs[f.name] = status if status == Task.NOT_STARTED: - insert_kwargs['percent_complete'] = Decimal(0) + insert_kwargs["percent_complete"] = Decimal(0) else: - insert_kwargs['percent_complete'] = get_random_decimal(1, 99) + insert_kwargs["percent_complete"] = get_random_decimal(1, 99) continue - if f.name == 'percent_complete': + if f.name == "percent_complete": continue insert_kwargs[f.name] = self.random_val(f) return insert_kwargs def get_item_fields(self): - return (self.ITEM_CLASS.get_field_by_fieldname('id'), self.ITEM_CLASS.get_field_by_fieldname('changekey')) \ - + tuple(f for f in self.ITEM_CLASS.FIELDS if f.name != '_id') + return ( + self.ITEM_CLASS.get_field_by_fieldname("id"), + self.ITEM_CLASS.get_field_by_fieldname("changekey"), + ) + tuple(f for f in self.ITEM_CLASS.FIELDS if f.name != "_id") def get_random_update_kwargs(self, item, insert_kwargs): update_kwargs = {} @@ -139,49 +165,50 @@ def get_random_update_kwargs(self, item, insert_kwargs): if not item.is_draft and f.is_read_only_after_send: # These cannot be changed when the item is no longer a draft continue - if f.name == 'message_id' and f.is_read_only_after_send: + if f.name == "message_id" and f.is_read_only_after_send: # Cannot be updated, regardless of draft status continue - if f.name == 'attachments': + if f.name == "attachments": # Testing attachments is heavy. Leave this to specific tests update_kwargs[f.name] = [] continue - if f.name == 'resources': + if f.name == "resources": # The test server doesn't have any resources update_kwargs[f.name] = [] continue if isinstance(f, AttachmentField): # Attachments are handled separately continue - if f.name == 'start': - start = get_random_date(start_date=insert_kwargs['end'].date()) - update_kwargs[f.name], update_kwargs['end'] = \ - get_random_datetime_range(start_date=start, end_date=start, tz=self.account.default_timezone) - update_kwargs['recurrence'] = self.random_val(self.ITEM_CLASS.get_field_by_fieldname('recurrence')) - update_kwargs['recurrence'].boundary.start = update_kwargs[f.name].date() + if f.name == "start": + start = get_random_date(start_date=insert_kwargs["end"].date()) + update_kwargs[f.name], update_kwargs["end"] = get_random_datetime_range( + start_date=start, end_date=start, tz=self.account.default_timezone + ) + update_kwargs["recurrence"] = self.random_val(self.ITEM_CLASS.get_field_by_fieldname("recurrence")) + update_kwargs["recurrence"].boundary.start = update_kwargs[f.name].date() continue - if f.name == 'start_date': + if f.name == "start_date": update_kwargs[f.name] = get_random_datetime().date() - update_kwargs['due_date'] = update_kwargs[f.name] + update_kwargs["due_date"] = update_kwargs[f.name] # Don't set 'recurrence' here. It's difficult to test updates so we'll test task recurrence separately - update_kwargs['recurrence'] = None + update_kwargs["recurrence"] = None continue - if f.name == 'end': + if f.name == "end": continue - if f.name == 'recurrence': + if f.name == "recurrence": continue - if f.name == 'due_date': + if f.name == "due_date": continue - if f.name == 'start_date': + if f.name == "start_date": continue - if f.name == 'status': + if f.name == "status": # Update task to a completed state update_kwargs[f.name] = Task.COMPLETED - update_kwargs['percent_complete'] = Decimal(100) + update_kwargs["percent_complete"] = Decimal(100) continue - if f.name == 'percent_complete': + if f.name == "percent_complete": continue - if f.name == 'reminder_is_set': + if f.name == "reminder_is_set": if self.ITEM_CLASS == Task: # Task type doesn't allow updating 'reminder_is_set' to True update_kwargs[f.name] = False @@ -200,16 +227,16 @@ def get_random_update_kwargs(self, item, insert_kwargs): update_kwargs[f.name] = self.random_val(f) if self.ITEM_CLASS == CalendarItem: # EWS always sets due date to 'start' - update_kwargs['reminder_due_by'] = update_kwargs['start'] - if update_kwargs.get('is_all_day', False): + update_kwargs["reminder_due_by"] = update_kwargs["start"] + if update_kwargs.get("is_all_day", False): # For is_all_day items, EWS will remove the time part of start and end values - update_kwargs['start'] = update_kwargs['start'].date() - update_kwargs['end'] = (update_kwargs['end'] + datetime.timedelta(days=1)).date() + update_kwargs["start"] = update_kwargs["start"].date() + update_kwargs["end"] = (update_kwargs["end"] + datetime.timedelta(days=1)).date() return update_kwargs def get_test_item(self, folder=None, categories=None): item_kwargs = self.get_random_insert_kwargs() - item_kwargs['categories'] = categories or self.categories + item_kwargs["categories"] = categories or self.categories return self.ITEM_CLASS(folder=folder or self.test_folder, **item_kwargs) def get_test_folder(self, folder=None): @@ -234,7 +261,7 @@ def test_field_names(self): def test_magic(self): item = self.get_test_item() - self.assertIn('subject=', str(item)) + self.assertIn("subject=", str(item)) self.assertIn(item.__class__.__name__, repr(item)) def test_queryset_nonsearchable_fields(self): @@ -242,12 +269,12 @@ def test_queryset_nonsearchable_fields(self): with self.subTest(f=f): if f.is_searchable or isinstance(f, IdField) or not f.supports_version(self.account.version): continue - if f.name in ('percent_complete', 'allow_new_time_proposal'): + if f.name in ("percent_complete", "allow_new_time_proposal"): # These fields don't raise an error when used in a filter, but also don't match anything in a filter continue try: filter_val = f.clean(self.random_val(f)) - filter_kwargs = {f'{f.name}__in': filter_val} if f.is_list else {f.name: filter_val} + filter_kwargs = {f"{f.name}__in": filter_val} if f.is_list else {f.name: filter_val} # We raise ValueError when searching on an is_searchable=False field with self.assertRaises(ValueError): @@ -262,12 +289,9 @@ def test_queryset_nonsearchable_fields(self): continue f.is_searchable = True - if f.name in ('reminder_due_by', 'conversation_index'): + if f.name in ("reminder_due_by", "conversation_index"): # Filtering is accepted but doesn't work - self.assertEqual( - self.test_folder.filter(**filter_kwargs).count(), - 0 - ) + self.assertEqual(self.test_folder.filter(**filter_kwargs).count(), 0) else: with self.assertRaises((ErrorUnsupportedPathForQuery, ErrorInvalidValueForProperty)): list(self.test_folder.filter(**filter_kwargs)) @@ -285,7 +309,7 @@ def _reduce_fields_for_filter(self, item, fields): if getattr(item, f.name) is None: # We cannot filter on None values continue - if self.ITEM_CLASS == Contact and f.name in ('body', 'display_name'): + if self.ITEM_CLASS == Contact and f.name in ("body", "display_name"): # filtering 'body' or 'display_name' on Contact items doesn't work at all. Error in EWS? continue yield f @@ -296,7 +320,7 @@ def _run_filter_tests(self, qs, f, filter_kwargs, val): retries = 0 matches = qs.filter(**kw).count() # __in with an empty list returns an empty result - expected = 0 if f.is_list and not val and list(kw)[0].endswith('__in') else 1 + expected = 0 if f.is_list and not val and list(kw)[0].endswith("__in") else 1 if f.is_complex and matches != expected: # Complex fields sometimes fail a search using generated data. In practice, they almost always # work anyway. Try a couple of times; it seems EWS has a search index that needs to catch up. @@ -305,7 +329,7 @@ def _run_filter_tests(self, qs, f, filter_kwargs, val): continue for _ in range(5): retries += 1 - time.sleep(retries*retries) # Exponential sleep + time.sleep(retries * retries) # Exponential sleep matches = qs.filter(**kw).count() if matches == expected: break @@ -320,16 +344,16 @@ def test_filter_on_simple_fields(self): continue fields.append(f) if not fields: - self.skipTest('No matching simple fields on this model') + self.skipTest("No matching simple fields on this model") item.save() common_qs = self.test_folder.filter(categories__contains=self.categories) for f in fields: val = getattr(item, f.name) # Filter with =, __in and __contains. We could have more filters here, but these should always match. - filter_kwargs = [{f.name: val}, {f'{f.name}__in': [val]}] + filter_kwargs = [{f.name: val}, {f"{f.name}__in": [val]}] if isinstance(f, TextField) and not isinstance(f, ChoiceField): # Choice fields cannot be filtered using __contains. Sort of makes sense. - filter_kwargs.append({f'{f.name}__contains': get_random_substring(val)}) + filter_kwargs.append({f"{f.name}__contains": get_random_substring(val)}) self._run_filter_tests(common_qs, f, filter_kwargs, val) def test_filter_on_list_fields(self): @@ -349,13 +373,13 @@ def test_filter_on_list_fields(self): continue fields.append(f) if not fields: - self.skipTest('No matching list fields on this model') + self.skipTest("No matching list fields on this model") item.save() common_qs = self.test_folder.filter(categories__contains=self.categories) for f in fields: val = getattr(item, f.name) # Filter multi-value fields with =, __in and __contains - filter_kwargs = [{f'{f.name}__in': val}, {f'{f.name}__contains': val}] + filter_kwargs = [{f"{f.name}__in": val}, {f"{f.name}__contains": val}] self._run_filter_tests(common_qs, f, filter_kwargs, val) def test_filter_on_single_field_index_fields(self): @@ -367,7 +391,7 @@ def test_filter_on_single_field_index_fields(self): continue fields.append(f) if not fields: - self.skipTest('No matching single index fields on this model') + self.skipTest("No matching single index fields on this model") item.save() common_qs = self.test_folder.filter(categories__contains=self.categories) for f in fields: @@ -380,10 +404,14 @@ def test_filter_on_single_field_index_fields(self): path, subval = field_path.path, field_path.get_value(item) if subval is None: continue - filter_kwargs.extend([ - {f.name: v}, {path: subval}, - {f'{path}__in': [subval]}, {f'{path}__contains': get_random_substring(subval)} - ]) + filter_kwargs.extend( + [ + {f.name: v}, + {path: subval}, + {f"{path}__in": [subval]}, + {f"{path}__contains": get_random_substring(subval)}, + ] + ) self._run_filter_tests(common_qs, f, filter_kwargs, val) def test_filter_on_multi_field_index_fields(self): @@ -395,7 +423,7 @@ def test_filter_on_multi_field_index_fields(self): continue fields.append(f) if not fields: - self.skipTest('No matching multi index fields on this model') + self.skipTest("No matching multi index fields on this model") item.save() common_qs = self.test_folder.filter(categories__contains=self.categories) for f in fields: @@ -409,9 +437,9 @@ def test_filter_on_multi_field_index_fields(self): path, subval = field_path.path, field_path.get_value(item) if subval is None: continue - filter_kwargs.extend([ - {path: subval}, {f'{path}__in': [subval]}, {f'{path}__contains': get_random_substring(subval)} - ]) + filter_kwargs.extend( + [{path: subval}, {f"{path}__in": [subval]}, {f"{path}__contains": get_random_substring(subval)}] + ) self._run_filter_tests(common_qs, f, filter_kwargs, val) def test_text_field_settings(self): @@ -432,10 +460,10 @@ def test_text_field_settings(self): continue if f.is_read_only: continue - if f.name == 'categories': + if f.name == "categories": # We're filtering on this one, so leave it alone continue - old_max_length = getattr(f, 'max_length', None) + old_max_length = getattr(f, "max_length", None) old_is_complex = f.is_complex try: # Set a string long enough to not be handled by FindItems @@ -450,7 +478,7 @@ def test_text_field_settings(self): item.save(update_fields=[f.name]) except ErrorPropertyUpdate: # Some fields throw this error when updated to a huge value - self.assertIn(f.name, ['given_name', 'middle_name', 'surname']) + self.assertIn(f.name, ["given_name", "middle_name", "surname"]) continue except ErrorInvalidPropertySet: # Some fields can not be updated after save @@ -478,13 +506,13 @@ def test_text_field_settings(self): if old_max_length: f.max_length = old_max_length else: - delattr(f, 'max_length') + delattr(f, "max_length") f.is_complex = old_is_complex def test_save_and_delete(self): # Test that we can create, update and delete single items using methods directly on the item. insert_kwargs = self.get_random_insert_kwargs() - insert_kwargs['categories'] = self.categories + insert_kwargs["categories"] = self.categories item = self.ITEM_CLASS(folder=self.test_folder, **insert_kwargs) self.assertIsNone(item.id) self.assertIsNone(item.changekey) @@ -503,10 +531,10 @@ def test_save_and_delete(self): if f.is_read_only and old is None: # Some fields are automatically set server-side continue - if f.name == 'reminder_due_by': + if f.name == "reminder_due_by": # EWS sets a default value if it is not set on insert. Ignore continue - if f.name == 'mime_content': + if f.name == "mime_content": # This will change depending on other contents fields continue if f.is_list: @@ -528,10 +556,10 @@ def test_save_and_delete(self): if f.is_read_only and old is None: # Some fields are automatically updated server-side continue - if f.name == 'mime_content': + if f.name == "mime_content": # This will change depending on other contents fields continue - if f.name == 'reminder_due_by': + if f.name == "reminder_due_by": if new is None: # EWS does not always return a value if reminder_is_set is False. continue @@ -540,7 +568,7 @@ def test_save_and_delete(self): # wanted it, and sometimes 30 days before or after. But only sometimes... old_date = old.astimezone(self.account.default_timezone).date() new_date = new.astimezone(self.account.default_timezone).date() - if getattr(item, 'is_all_day', False) and old_date == new_date: + if getattr(item, "is_all_day", False) and old_date == new_date: # There is some weirdness with the time part of the reminder_due_by value for all-day events item.reminder_due_by = new continue @@ -570,13 +598,13 @@ def test_save_and_delete(self): def test_item(self): # Test insert insert_kwargs = self.get_random_insert_kwargs() - insert_kwargs['categories'] = self.categories + insert_kwargs["categories"] = self.categories item = self.ITEM_CLASS(folder=self.test_folder, **insert_kwargs) # Test with generator as argument insert_ids = self.test_folder.bulk_create(items=(i for i in [item])) self.assertEqual(len(insert_ids), 1) self.assertIsInstance(insert_ids[0], BaseItem) - find_ids = list(self.test_folder.filter(categories__contains=item.categories).values_list('id', 'changekey')) + find_ids = list(self.test_folder.filter(categories__contains=item.categories).values_list("id", "changekey")) self.assertEqual(len(find_ids), 1) self.assertEqual(len(find_ids[0]), 2, find_ids[0]) self.assertEqual(insert_ids, find_ids) @@ -592,10 +620,10 @@ def test_item(self): continue if f.is_read_only: continue - if f.name == 'reminder_due_by': + if f.name == "reminder_due_by": # EWS sets a default value if it is not set on insert. Ignore continue - if f.name == 'mime_content': + if f.name == "mime_content": # This will change depending on other contents fields continue old, new = getattr(item, f.name), insert_kwargs[f.name] @@ -607,8 +635,8 @@ def test_item(self): update_kwargs = self.get_random_update_kwargs(item=item, insert_kwargs=insert_kwargs) if self.ITEM_CLASS in (Contact, DistributionList): # Contact and DistributionList don't support mime_type updates at all - update_kwargs.pop('mime_content', None) - update_fieldnames = [f for f in update_kwargs if f != 'attachments'] + update_kwargs.pop("mime_content", None) + update_fieldnames = [f for f in update_kwargs if f != "attachments"] for k, v in update_kwargs.items(): setattr(item, k, v) # Test with generator as argument @@ -629,11 +657,11 @@ def test_item(self): if f.is_read_only or f.is_read_only_after_send: # These cannot be changed continue - if f.name == 'mime_content': + if f.name == "mime_content": # This will change depending on other contents fields continue old, new = getattr(item, f.name), update_kwargs[f.name] - if f.name == 'reminder_due_by': + if f.name == "reminder_due_by": if old is None: # EWS does not always return a value if reminder_is_set is False. Set one now item.reminder_due_by = new @@ -643,7 +671,7 @@ def test_item(self): # wanted it, and sometimes 30 days before or after. But only sometimes... old_date = old.astimezone(self.account.default_timezone).date() new_date = new.astimezone(self.account.default_timezone).date() - if getattr(item, 'is_all_day', False) and old_date == new_date: + if getattr(item, "is_all_day", False) and old_date == new_date: # There is some weirdness with the time part of the reminder_due_by value for all-day events item.reminder_due_by = new continue @@ -682,8 +710,9 @@ def test_item(self): self.assertEqual(len(wipe_ids), 1) self.assertEqual(len(wipe_ids[0]), 2, wipe_ids) self.assertEqual(insert_ids[0].id, wipe_ids[0][0]) # ID should be the same - self.assertNotEqual(insert_ids[0].changekey, - wipe_ids[0][1]) # Changekey should not be the same when item is updated + self.assertNotEqual( + insert_ids[0].changekey, wipe_ids[0][1] + ) # Changekey should not be the same when item is updated item = self.get_item_by_id(wipe_ids[0]) for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): @@ -703,11 +732,11 @@ def test_item(self): self.assertEqual(old, new, (f.name, old, new)) try: - item.__class__.register('extern_id', ExternId) + item.__class__.register("extern_id", ExternId) # Test extern_id = None, which deletes the extended property entirely extern_id = None item.extern_id = extern_id - wipe2_ids = self.account.bulk_update([(item, ['extern_id'])]) + wipe2_ids = self.account.bulk_update([(item, ["extern_id"])]) self.assertEqual(len(wipe2_ids), 1) self.assertEqual(len(wipe2_ids[0]), 2, wipe2_ids) self.assertEqual(insert_ids[0].id, wipe2_ids[0][0]) # ID must be the same @@ -715,4 +744,4 @@ def test_item(self): item = self.get_item_by_id(wipe2_ids[0]) self.assertEqual(item.extern_id, extern_id) finally: - item.__class__.deregister('extern_id') + item.__class__.deregister("extern_id") diff --git a/tests/test_items/test_bulk.py b/tests/test_items/test_bulk.py index c16d1ac6..b1203386 100644 --- a/tests/test_items/test_bulk.py +++ b/tests/test_items/test_bulk.py @@ -1,15 +1,16 @@ import datetime -from exchangelib.errors import ErrorItemNotFound, ErrorInvalidChangeKey, ErrorInvalidIdMalformed + +from exchangelib.errors import ErrorInvalidChangeKey, ErrorInvalidIdMalformed, ErrorItemNotFound from exchangelib.fields import FieldPath -from exchangelib.folders import Inbox, Folder, Calendar -from exchangelib.items import Item, Message, SAVE_ONLY, SEND_ONLY, SEND_AND_SAVE_COPY, CalendarItem, BulkCreateResult +from exchangelib.folders import Calendar, Folder, Inbox +from exchangelib.items import SAVE_ONLY, SEND_AND_SAVE_COPY, SEND_ONLY, BulkCreateResult, CalendarItem, Item, Message from exchangelib.services import CreateItem from .test_basics import BaseItemTest class BulkMethodTest(BaseItemTest): - TEST_FOLDER = 'inbox' + TEST_FOLDER = "inbox" FOLDER_CLASS = Inbox ITEM_CLASS = Message @@ -22,13 +23,13 @@ def test_fetch(self): self.assertIsInstance(item, self.ITEM_CLASS) self.assertEqual(len(items), 2) - items = list(self.account.fetch(ids=ids, only_fields=['subject'])) + items = list(self.account.fetch(ids=ids, only_fields=["subject"])) self.assertEqual(len(items), 2) - items = list(self.account.fetch(ids=ids, only_fields=[FieldPath.from_string('subject', self.test_folder)])) + items = list(self.account.fetch(ids=ids, only_fields=[FieldPath.from_string("subject", self.test_folder)])) self.assertEqual(len(items), 2) - items = list(self.account.fetch(ids=ids, only_fields=['id', 'changekey'])) + items = list(self.account.fetch(ids=ids, only_fields=["id", "changekey"])) self.assertEqual(len(items), 2) def test_bulk_create(self): @@ -47,7 +48,7 @@ def test_no_account(self): item.account = None self.assertEqual(list(self.account.fetch(ids=[item]))[0].id, item.id) item.account = None - res = self.account.bulk_update(items=[(item, ('subject',))])[0] + res = self.account.bulk_update(items=[(item, ("subject",))])[0] item.id, item.changekey = res item.account = None res = self.account.bulk_copy(ids=[item], to_folder=self.account.trash)[0] @@ -125,14 +126,14 @@ def test_invalid_bulk_args(self): def test_bulk_failure(self): # Test that bulk_* can handle EWS errors and return the errors in order without losing non-failure results items1 = [self.get_test_item().save() for _ in range(3)] - items1[1].changekey = 'XXX' + items1[1].changekey = "XXX" for i, res in enumerate(self.account.bulk_delete(items1)): if i == 1: self.assertIsInstance(res, ErrorInvalidChangeKey) else: self.assertEqual(res, True) items2 = [self.get_test_item().save() for _ in range(3)] - items2[1].id = 'AAAA==' + items2[1].id = "AAAA==" for i, res in enumerate(self.account.bulk_delete(items2)): if i == 1: self.assertIsInstance(res, ErrorInvalidIdMalformed) @@ -148,7 +149,7 @@ def test_bulk_failure(self): def test_bulk_create_with_no_result(self): # Some CreateItem responses do not contain the ID of the created items. See issue#984 - xml = b'''\ + xml = b"""\ @@ -177,16 +178,13 @@ def test_bulk_create_with_no_result(self): -''' +""" ws = CreateItem(account=self.account) - self.assertListEqual( - list(ws.parse(xml)), - [True, True] - ) + self.assertListEqual(list(ws.parse(xml)), [True, True]) class CalendarBulkMethodTest(BaseItemTest): - TEST_FOLDER = 'calendar' + TEST_FOLDER = "calendar" FOLDER_CLASS = Calendar ITEM_CLASS = CalendarItem @@ -200,4 +198,4 @@ def test_no_account(self): res = self.test_folder.bulk_create(items=[item])[0] item.id, item.changekey = res.id, res.changekey item.account = None - self.account.bulk_update(items=[(item, ('start',))]) + self.account.bulk_update(items=[(item, ("start",))]) diff --git a/tests/test_items/test_calendaritems.py b/tests/test_items/test_calendaritems.py index cdcc2c1d..669edd0b 100644 --- a/tests/test_items/test_calendaritems.py +++ b/tests/test_items/test_calendaritems.py @@ -2,21 +2,31 @@ from exchangelib.errors import ErrorInvalidOperation, ErrorItemNotFound, ErrorMissingInformationReferenceItemId from exchangelib.ewsdatetime import UTC -from exchangelib.fields import NOVEMBER, WEEKEND_DAY, WEEK_DAY, THIRD, MONDAY, WEDNESDAY +from exchangelib.fields import MONDAY, NOVEMBER, THIRD, WEDNESDAY, WEEK_DAY, WEEKEND_DAY from exchangelib.folders import Calendar -from exchangelib.items import CalendarItem, BulkCreateResult -from exchangelib.items.calendar_item import MeetingRequest, AcceptItem, SINGLE, OCCURRENCE, EXCEPTION, RECURRING_MASTER -from exchangelib.recurrence import Recurrence, Occurrence, FirstOccurrence, LastOccurrence, DeletedOccurrence, \ - AbsoluteYearlyPattern, RelativeYearlyPattern, AbsoluteMonthlyPattern, RelativeMonthlyPattern, WeeklyPattern, \ - DailyPattern -from exchangelib.version import Version, EXCHANGE_2007 - -from ..common import get_random_string, get_random_datetime_range, get_random_date +from exchangelib.items import BulkCreateResult, CalendarItem +from exchangelib.items.calendar_item import EXCEPTION, OCCURRENCE, RECURRING_MASTER, SINGLE, AcceptItem, MeetingRequest +from exchangelib.recurrence import ( + AbsoluteMonthlyPattern, + AbsoluteYearlyPattern, + DailyPattern, + DeletedOccurrence, + FirstOccurrence, + LastOccurrence, + Occurrence, + Recurrence, + RelativeMonthlyPattern, + RelativeYearlyPattern, + WeeklyPattern, +) +from exchangelib.version import EXCHANGE_2007, Version + +from ..common import get_random_date, get_random_datetime_range, get_random_string from .test_basics import CommonItemTest class CalendarTest(CommonItemTest): - TEST_FOLDER = 'calendar' + TEST_FOLDER = "calendar" FOLDER_CLASS = Calendar ITEM_CLASS = CalendarItem @@ -43,21 +53,21 @@ def test_updating_timestamps(self): item.save() item.refresh() self.assertEqual(item.type, SINGLE) - for i in self.account.calendar.filter(categories__contains=self.categories).only('start', 'end', 'categories'): + for i in self.account.calendar.filter(categories__contains=self.categories).only("start", "end", "categories"): self.assertEqual(i.start, item.start) self.assertEqual(i.start.tzinfo, UTC) self.assertEqual(i.end, item.end) self.assertEqual(i.end.tzinfo, UTC) self.assertEqual(i._start_timezone, self.account.default_timezone) self.assertEqual(i._end_timezone, self.account.default_timezone) - i.save(update_fields=['start', 'end']) + i.save(update_fields=["start", "end"]) self.assertEqual(i.start, item.start) self.assertEqual(i.start.tzinfo, UTC) self.assertEqual(i.end, item.end) self.assertEqual(i.end.tzinfo, UTC) self.assertEqual(i._start_timezone, self.account.default_timezone) self.assertEqual(i._end_timezone, self.account.default_timezone) - for i in self.account.calendar.filter(categories__contains=self.categories).only('start', 'end', 'categories'): + for i in self.account.calendar.filter(categories__contains=self.categories).only("start", "end", "categories"): self.assertEqual(i.start, item.start) self.assertEqual(i.start.tzinfo, UTC) self.assertEqual(i.end, item.end) @@ -76,8 +86,8 @@ def test_update_to_non_utc_datetime(self): # 'ErrorOccurrenceTimeSpanTooBig' is we go back in time. start = get_random_date(start_date=item.start.date() + datetime.timedelta(days=1)) dt_start, dt_end = [ - dt.astimezone(self.account.default_timezone) for dt in - get_random_datetime_range(start_date=start, end_date=start, tz=self.account.default_timezone) + dt.astimezone(self.account.default_timezone) + for dt in get_random_datetime_range(start_date=start, end_date=start, tz=self.account.default_timezone) ] item.start, item.end = dt_start, dt_end item.recurrence.boundary.start = dt_start.date() @@ -90,16 +100,15 @@ def test_all_day_datetimes(self): # Test that we can use plain dates for start and end values for all-day items start = get_random_date() start_dt, end_dt = get_random_datetime_range( - start_date=start, - end_date=start + datetime.timedelta(days=365), - tz=self.account.default_timezone + start_date=start, end_date=start + datetime.timedelta(days=365), tz=self.account.default_timezone ) # Assign datetimes for start and end - item = self.ITEM_CLASS(folder=self.test_folder, start=start_dt, end=end_dt, is_all_day=True, - categories=self.categories).save() + item = self.ITEM_CLASS( + folder=self.test_folder, start=start_dt, end=end_dt, is_all_day=True, categories=self.categories + ).save() # Returned item start and end values should be EWSDate instances - item = self.test_folder.all().only('is_all_day', 'start', 'end').get(id=item.id, changekey=item.changekey) + item = self.test_folder.all().only("is_all_day", "start", "end").get(id=item.id, changekey=item.changekey) self.assertEqual(item.is_all_day, True) self.assertEqual(item.start, start_dt.date()) self.assertEqual(item.end, end_dt.date()) @@ -107,11 +116,16 @@ def test_all_day_datetimes(self): item.delete() # We are also allowed to assign plain dates as values for all-day items - item = self.ITEM_CLASS(folder=self.test_folder, start=start_dt.date(), end=end_dt.date(), is_all_day=True, - categories=self.categories).save() + item = self.ITEM_CLASS( + folder=self.test_folder, + start=start_dt.date(), + end=end_dt.date(), + is_all_day=True, + categories=self.categories, + ).save() # Returned item start and end values should be EWSDate instances - item = self.test_folder.all().only('is_all_day', 'start', 'end').get(id=item.id, changekey=item.changekey) + item = self.test_folder.all().only("is_all_day", "start", "end").get(id=item.id, changekey=item.changekey) self.assertEqual(item.is_all_day, True) self.assertEqual(item.start, start_dt.date()) self.assertEqual(item.end, end_dt.date()) @@ -144,54 +158,49 @@ def test_view(self): with self.assertRaises(ValueError): list(self.test_folder.view(start=item1.end, end=item1.start)) with self.assertRaises(TypeError): - list(self.test_folder.view(start='xxx', end=item1.end)) + list(self.test_folder.view(start="xxx", end=item1.end)) with self.assertRaises(ValueError): list(self.test_folder.view(start=item1.start, end=item1.end, max_items=0)) # Test dates self.assertEqual( - len([i for i in self.test_folder.view(start=item1.start, end=item1.end) if self.match_cat(i)]), - 1 + len([i for i in self.test_folder.view(start=item1.start, end=item1.end) if self.match_cat(i)]), 1 ) self.assertEqual( - len([i for i in self.test_folder.view(start=item1.start, end=item2.end) if self.match_cat(i)]), - 2 + len([i for i in self.test_folder.view(start=item1.start, end=item2.end) if self.match_cat(i)]), 2 ) # Edge cases. Get view from end of item1 to start of item2. Should logically return 0 items, but Exchange wants # it differently and returns item1 even though there is no overlap. self.assertEqual( - len([i for i in self.test_folder.view(start=item1.end, end=item2.start) if self.match_cat(i)]), - 1 + len([i for i in self.test_folder.view(start=item1.end, end=item2.start) if self.match_cat(i)]), 1 ) self.assertEqual( - len([i for i in self.test_folder.view(start=item1.start, end=item2.start) if self.match_cat(i)]), - 1 + len([i for i in self.test_folder.view(start=item1.start, end=item2.start) if self.match_cat(i)]), 1 ) # Test max_items self.assertEqual( - len([ - i for i in self.test_folder.view(start=item1.start, end=item2.end, max_items=9999) if self.match_cat(i) - ]), - 2 - ) - self.assertEqual( - self.test_folder.view(start=item1.start, end=item2.end, max_items=1).count(), - 1 + len( + [ + i + for i in self.test_folder.view(start=item1.start, end=item2.end, max_items=9999) + if self.match_cat(i) + ] + ), + 2, ) + self.assertEqual(self.test_folder.view(start=item1.start, end=item2.end, max_items=1).count(), 1) # Test client-side ordering self.assertListEqual( - [i.subject for i in qs.order_by('subject') if self.match_cat(i)], sorted([item1.subject, item2.subject]) + [i.subject for i in qs.order_by("subject") if self.match_cat(i)], sorted([item1.subject, item2.subject]) ) # Test client-side ordering on a field with no default value and no default value_cls value - self.assertListEqual( - [i.start for i in qs.order_by('-start') if self.match_cat(i)], [item2.start, item1.start] - ) + self.assertListEqual([i.start for i in qs.order_by("-start") if self.match_cat(i)], [item2.start, item1.start]) # Test client-side ordering on multiple fields. Intentionally sort first on a field where values are equal, # to see that we can sort on the 2nd field. self.assertListEqual( - [i.start for i in qs.order_by('categories', '-start') if self.match_cat(i)], [item2.start, item1.start] + [i.start for i in qs.order_by("categories", "-start") if self.match_cat(i)], [item2.start, item1.start] ) # Test chaining @@ -199,8 +208,8 @@ def test_view(self): with self.assertRaises(ErrorInvalidOperation): qs.filter(subject=item1.subject).count() # EWS does not allow restrictions self.assertListEqual( - [i for i in qs.order_by('subject').values('subject') if i['subject'] in (item1.subject, item2.subject)], - [{'subject': s} for s in sorted([item1.subject, item2.subject])] + [i for i in qs.order_by("subject").values("subject") if i["subject"] in (item1.subject, item2.subject)], + [{"subject": s} for s in sorted([item1.subject, item2.subject])], ) def test_client_side_ordering_on_mixed_all_day_and_normal(self): @@ -228,26 +237,26 @@ def test_client_side_ordering_on_mixed_all_day_and_normal(self): categories=self.categories, ) self.test_folder.bulk_create(items=[item1, item2]) - list(self.test_folder.view(start=start - datetime.timedelta(days=1), end=end).order_by('start')) - list(self.test_folder.view(start=start - datetime.timedelta(days=1), end=end).order_by('-start')) + list(self.test_folder.view(start=start - datetime.timedelta(days=1), end=end).order_by("start")) + list(self.test_folder.view(start=start - datetime.timedelta(days=1), end=end).order_by("-start")) # Test that client-side ordering on non-selected fields works - list(self.test_folder.view(start=start - datetime.timedelta(days=1), end=end).only('end').order_by('start')) - list(self.test_folder.view(start=start - datetime.timedelta(days=1), end=end).only('end').order_by('-start')) + list(self.test_folder.view(start=start - datetime.timedelta(days=1), end=end).only("end").order_by("start")) + list(self.test_folder.view(start=start - datetime.timedelta(days=1), end=end).only("end").order_by("-start")) def test_all_recurring_pattern_types(self): start = datetime.datetime(2016, 1, 1, 8, tzinfo=self.account.default_timezone) end = datetime.datetime(2016, 1, 1, 10, tzinfo=self.account.default_timezone) for pattern in ( - AbsoluteYearlyPattern(day_of_month=13, month=NOVEMBER), - RelativeYearlyPattern(weekday=1, week_number=THIRD, month=11), - RelativeYearlyPattern(weekday=WEEKEND_DAY, week_number=3, month=11), - AbsoluteMonthlyPattern(interval=3, day_of_month=13), - RelativeMonthlyPattern(interval=3, weekday=2, week_number=3), - RelativeMonthlyPattern(interval=3, weekday=WEEK_DAY, week_number=3), - WeeklyPattern(interval=3, weekdays=[MONDAY, WEDNESDAY], first_day_of_week=1), - DailyPattern(interval=1), + AbsoluteYearlyPattern(day_of_month=13, month=NOVEMBER), + RelativeYearlyPattern(weekday=1, week_number=THIRD, month=11), + RelativeYearlyPattern(weekday=WEEKEND_DAY, week_number=3, month=11), + AbsoluteMonthlyPattern(interval=3, day_of_month=13), + RelativeMonthlyPattern(interval=3, weekday=2, week_number=3), + RelativeMonthlyPattern(interval=3, weekday=WEEK_DAY, week_number=3), + WeeklyPattern(interval=3, weekdays=[MONDAY, WEDNESDAY], first_day_of_week=1), + DailyPattern(interval=1), ): master_item = self.ITEM_CLASS( folder=self.test_folder, @@ -405,14 +414,14 @@ def test_change_occurrence_via_index(self): second_occurrence = master_item.occurrence(index=2) second_occurrence.start = start + datetime.timedelta(days=1, hours=1) second_occurrence.end = end + datetime.timedelta(days=1, hours=1) - second_occurrence.save(update_fields=['start', 'end']) # Test that UpdateItem works with only a few fields + second_occurrence.save(update_fields=["start", "end"]) # Test that UpdateItem works with only a few fields second_occurrence = master_item.occurrence(index=2) second_occurrence.refresh() self.assertEqual(second_occurrence.subject, master_item.subject) second_occurrence.start += datetime.timedelta(hours=1) second_occurrence.end += datetime.timedelta(hours=1) - second_occurrence.save(update_fields=['start', 'end']) # Test that UpdateItem works after refresh + second_occurrence.save(update_fields=["start", "end"]) # Test that UpdateItem works after refresh # Test change on the master item master_item.refresh() @@ -480,7 +489,7 @@ def test_get_master_recurrence(self): master_from_occurrence = third_occurrence.recurring_master() master_from_occurrence.subject = get_random_string(16) - master_from_occurrence.save(update_fields=['subject']) # Test that UpdateItem works + master_from_occurrence.save(update_fields=["subject"]) # Test that UpdateItem works master_from_occurrence.delete() # Test that DeleteItem works with self.assertRaises(ErrorItemNotFound): @@ -499,7 +508,7 @@ def test_invalid_updateitem_items(self): start = item.start item.start = None with self.assertRaises(ValueError) as e: - self.account.bulk_update([(item, ['start'])]) + self.account.bulk_update([(item, ["start"])]) self.assertEqual(e.exception.args[0], "'start' is a required field with no default") item.start = start @@ -507,13 +516,13 @@ def test_invalid_updateitem_items(self): uid = item.uid item.uid = None with self.assertRaises(ValueError) as e: - self.account.bulk_update([(item, ['uid'])]) + self.account.bulk_update([(item, ["uid"])]) self.assertEqual(e.exception.args[0], "'uid' is a required field and may not be deleted") item.uid = uid item.is_meeting = None with self.assertRaises(ValueError) as e: - self.account.bulk_update([(item, ['is_meeting'])]) + self.account.bulk_update([(item, ["is_meeting"])]) self.assertEqual(e.exception.args[0], "'is_meeting' is a read-only field") def test_meeting_request(self): @@ -534,9 +543,7 @@ def test_meeting_request(self): def test_clean(self): start = get_random_date() start_dt, end_dt = get_random_datetime_range( - start_date=start, - end_date=start + datetime.timedelta(days=365), - tz=self.account.default_timezone + start_date=start, end_date=start + datetime.timedelta(days=365), tz=self.account.default_timezone ) with self.assertRaises(ValueError) as e: CalendarItem(start=end_dt, end=start_dt).clean(version=self.account.version) @@ -550,20 +557,20 @@ def test_clean(self): def test_tz_field_for_field_name(self): self.assertEqual( - CalendarItem(account=self.account).tz_field_for_field_name('start').name, - '_start_timezone', + CalendarItem(account=self.account).tz_field_for_field_name("start").name, + "_start_timezone", ) self.assertEqual( - CalendarItem(account=self.account).tz_field_for_field_name('end').name, - '_end_timezone', + CalendarItem(account=self.account).tz_field_for_field_name("end").name, + "_end_timezone", ) account = self.get_account() account.version = Version(EXCHANGE_2007) self.assertEqual( - CalendarItem(account=account).tz_field_for_field_name('start').name, - '_meeting_timezone', + CalendarItem(account=account).tz_field_for_field_name("start").name, + "_meeting_timezone", ) self.assertEqual( - CalendarItem(account=account).tz_field_for_field_name('end').name, - '_meeting_timezone', + CalendarItem(account=account).tz_field_for_field_name("end").name, + "_meeting_timezone", ) diff --git a/tests/test_items/test_contacts.py b/tests/test_items/test_contacts.py index bf169ad2..9498cee8 100644 --- a/tests/test_items/test_contacts.py +++ b/tests/test_items/test_contacts.py @@ -1,4 +1,5 @@ import datetime + try: import zoneinfo except ImportError: @@ -6,60 +7,75 @@ from exchangelib.errors import ErrorInvalidIdMalformed from exchangelib.folders import Contacts -from exchangelib.indexed_properties import EmailAddress, PhysicalAddress, PhoneNumber +from exchangelib.indexed_properties import EmailAddress, PhoneNumber, PhysicalAddress from exchangelib.items import Contact, DistributionList, Persona -from exchangelib.properties import Mailbox, Member, Attribution, SourceId, FolderId, StringAttributedValue, \ - PhoneNumberAttributedValue, PersonaPhoneNumberTypeValue, EmailAddress as EmailAddressProp -from exchangelib.services import GetPersona, FindPeople +from exchangelib.properties import Attribution +from exchangelib.properties import EmailAddress as EmailAddressProp +from exchangelib.properties import ( + FolderId, + Mailbox, + Member, + PersonaPhoneNumberTypeValue, + PhoneNumberAttributedValue, + SourceId, + StringAttributedValue, +) +from exchangelib.services import FindPeople, GetPersona -from ..common import get_random_string, get_random_email +from ..common import get_random_email, get_random_string from .test_basics import CommonItemTest class ContactsTest(CommonItemTest): - TEST_FOLDER = 'contacts' + TEST_FOLDER = "contacts" FOLDER_CLASS = Contacts ITEM_CLASS = Contact def test_order_by_on_indexed_field(self): # Test order_by() on IndexedField (simple and multi-subfield). Only Contact items have these test_items = [] - label = self.random_val(EmailAddress.get_field_by_fieldname('label')) + label = self.random_val(EmailAddress.get_field_by_fieldname("label")) for i in range(4): item = self.get_test_item() - item.email_addresses = [EmailAddress(email=f'{i}@foo.com', label=label)] + item.email_addresses = [EmailAddress(email=f"{i}@foo.com", label=label)] test_items.append(item) self.test_folder.bulk_create(items=test_items) qs = self.test_folder.filter(categories__contains=self.categories) self.assertEqual( - [i[0].email for i in qs.order_by(f'email_addresses__{label}') - .values_list('email_addresses', flat=True)], - ['0@foo.com', '1@foo.com', '2@foo.com', '3@foo.com'] + [i[0].email for i in qs.order_by(f"email_addresses__{label}").values_list("email_addresses", flat=True)], + ["0@foo.com", "1@foo.com", "2@foo.com", "3@foo.com"], ) self.assertEqual( - [i[0].email for i in qs.order_by(f'-email_addresses__{label}') - .values_list('email_addresses', flat=True)], - ['3@foo.com', '2@foo.com', '1@foo.com', '0@foo.com'] + [i[0].email for i in qs.order_by(f"-email_addresses__{label}").values_list("email_addresses", flat=True)], + ["3@foo.com", "2@foo.com", "1@foo.com", "0@foo.com"], ) self.bulk_delete(qs) test_items = [] - label = self.random_val(PhysicalAddress.get_field_by_fieldname('label')) + label = self.random_val(PhysicalAddress.get_field_by_fieldname("label")) for i in range(4): item = self.get_test_item() - item.physical_addresses = [PhysicalAddress(street=f'Elm St {i}', label=label)] + item.physical_addresses = [PhysicalAddress(street=f"Elm St {i}", label=label)] test_items.append(item) self.test_folder.bulk_create(items=test_items) qs = self.test_folder.filter(categories__contains=self.categories) self.assertEqual( - [i[0].street for i in qs.order_by(f'physical_addresses__{label}__street') - .values_list('physical_addresses', flat=True)], - ['Elm St 0', 'Elm St 1', 'Elm St 2', 'Elm St 3'] + [ + i[0].street + for i in qs.order_by(f"physical_addresses__{label}__street").values_list( + "physical_addresses", flat=True + ) + ], + ["Elm St 0", "Elm St 1", "Elm St 2", "Elm St 3"], ) self.assertEqual( - [i[0].street for i in qs.order_by(f'-physical_addresses__{label}__street') - .values_list('physical_addresses', flat=True)], - ['Elm St 3', 'Elm St 2', 'Elm St 1', 'Elm St 0'] + [ + i[0].street + for i in qs.order_by(f"-physical_addresses__{label}__street").values_list( + "physical_addresses", flat=True + ) + ], + ["Elm St 3", "Elm St 2", "Elm St 1", "Elm St 0"], ) self.bulk_delete(qs) @@ -67,35 +83,35 @@ def test_order_by_failure(self): # Test error handling on indexed properties with labels and subfields qs = self.test_folder.filter(categories__contains=self.categories) with self.assertRaises(ValueError): - qs.order_by('email_addresses') # Must have label + qs.order_by("email_addresses") # Must have label with self.assertRaises(ValueError): - qs.order_by('email_addresses__FOO') # Must have a valid label + qs.order_by("email_addresses__FOO") # Must have a valid label with self.assertRaises(ValueError): - qs.order_by('email_addresses__EmailAddress1__FOO') # Must not have a subfield + qs.order_by("email_addresses__EmailAddress1__FOO") # Must not have a subfield with self.assertRaises(ValueError): - qs.order_by('physical_addresses__Business') # Must have a subfield + qs.order_by("physical_addresses__Business") # Must have a subfield with self.assertRaises(ValueError): - qs.order_by('physical_addresses__Business__FOO') # Must have a valid subfield + qs.order_by("physical_addresses__Business__FOO") # Must have a valid subfield def test_update_on_single_field_indexed_field(self): - home = PhoneNumber(label='HomePhone', phone_number='123') - business = PhoneNumber(label='BusinessPhone', phone_number='456') + home = PhoneNumber(label="HomePhone", phone_number="123") + business = PhoneNumber(label="BusinessPhone", phone_number="456") item = self.get_test_item() item.phone_numbers = [home] item.save() item.phone_numbers = [business] - item.save(update_fields=['phone_numbers']) + item.save(update_fields=["phone_numbers"]) item.refresh() self.assertListEqual(item.phone_numbers, [business]) def test_update_on_multi_field_indexed_field(self): - home = PhysicalAddress(label='Home', street='ABC') - business = PhysicalAddress(label='Business', street='DEF', city='GHI') + home = PhysicalAddress(label="Home", street="ABC") + business = PhysicalAddress(label="Business", street="DEF", city="GHI") item = self.get_test_item() item.physical_addresses = [home] item.save() item.physical_addresses = [business] - item.save(update_fields=['physical_addresses']) + item.save(update_fields=["physical_addresses"]) item.refresh() self.assertListEqual(item.physical_addresses, [business]) @@ -109,7 +125,7 @@ def test_distribution_lists(self): # We set mailbox_type to OneOff because otherwise the email address must be an actual account dl.members = { - Member(mailbox=Mailbox(email_address=get_random_email(), mailbox_type='OneOff')) for _ in range(4) + Member(mailbox=Mailbox(email_address=get_random_email(), mailbox_type="OneOff")) for _ in range(4) } dl.save() new_dl = self.test_folder.get(categories__contains=dl.categories) @@ -120,22 +136,23 @@ def test_distribution_lists(self): def test_fetch_personas(self): # Test QuerySet input self.assertGreaterEqual( - len(list(self.account.fetch_personas(self.test_folder.people().filter(display_name='john')))), - 0 + len(list(self.account.fetch_personas(self.test_folder.people().filter(display_name="john")))), 0 ) def test_find_people(self): # The test server may not have any contacts. Just test that the FindPeople and GetPersona services work. self.assertGreaterEqual(len(list(self.test_folder.people())), 0) self.assertGreaterEqual( - len(list( - self.test_folder.people().only('display_name').filter(display_name='john').order_by('display_name') - )), - 0 + len( + list( + self.test_folder.people().only("display_name").filter(display_name="john").order_by("display_name") + ) + ), + 0, ) # Test with a querystring filter - self.assertGreaterEqual(len(list(self.test_folder.people().filter('DisplayName:john'))), 0) - xml = b'''\ + self.assertGreaterEqual(len(list(self.test_folder.people().filter("DisplayName:john"))), 0) + xml = b"""\ @@ -158,19 +175,21 @@ def test_find_people(self): 1 -''' +""" self.assertListEqual( list(FindPeople(account=self.account).parse(xml)), - [Persona( - id='AAAA=', - display_name='Foo B. Smith', - email_address=EmailAddressProp(name='Foo Smith', email_address='foo@example.com'), - relevance_score='2147483647', - )] + [ + Persona( + id="AAAA=", + display_name="Foo B. Smith", + email_address=EmailAddressProp(name="Foo Smith", email_address="foo@example.com"), + relevance_score="2147483647", + ) + ], ) def test_get_persona(self): - xml = b'''\ + xml = b"""\ @@ -227,41 +246,50 @@ def test_get_persona(self): -''' +""" ws = GetPersona(account=self.account) persona = list(ws.parse(xml))[0] - self.assertEqual(persona.id, 'AAQkADEzAQAKtOtR=') - self.assertEqual(persona.persona_type, 'Person') + self.assertEqual(persona.id, "AAQkADEzAQAKtOtR=") + self.assertEqual(persona.persona_type, "Person") self.assertEqual( - persona.creation_time, datetime.datetime(2012, 6, 1, 17, 0, 34, tzinfo=zoneinfo.ZoneInfo('UTC')) + persona.creation_time, datetime.datetime(2012, 6, 1, 17, 0, 34, tzinfo=zoneinfo.ZoneInfo("UTC")) ) - self.assertEqual(persona.display_name, 'Brian Johnson') - self.assertEqual(persona.relevance_score, '4255550110') - self.assertEqual(persona.attributions[0], Attribution( - ID=None, - _id=SourceId(id='AAMkA =', changekey='EQAAABY+'), - display_name='Outlook', - is_writable=True, - is_quick_contact=False, - is_hidden=False, - folder_id=FolderId(id='AAMkA=', changekey='AQAAAA==') - )) - self.assertEqual(persona.display_names, [ - StringAttributedValue(value='Brian Johnson', attributions=['2', '3']), - ]) - self.assertEqual(persona.mobile_phones, [ - PhoneNumberAttributedValue( - value=PersonaPhoneNumberTypeValue(number='(425)555-0110', type='Mobile'), - attributions=['0'], + self.assertEqual(persona.display_name, "Brian Johnson") + self.assertEqual(persona.relevance_score, "4255550110") + self.assertEqual( + persona.attributions[0], + Attribution( + ID=None, + _id=SourceId(id="AAMkA =", changekey="EQAAABY+"), + display_name="Outlook", + is_writable=True, + is_quick_contact=False, + is_hidden=False, + folder_id=FolderId(id="AAMkA=", changekey="AQAAAA=="), ), - PhoneNumberAttributedValue( - value=PersonaPhoneNumberTypeValue(number='(425)555-0111', type='Mobile'), - attributions=['1'], - ) - ]) + ) + self.assertEqual( + persona.display_names, + [ + StringAttributedValue(value="Brian Johnson", attributions=["2", "3"]), + ], + ) + self.assertEqual( + persona.mobile_phones, + [ + PhoneNumberAttributedValue( + value=PersonaPhoneNumberTypeValue(number="(425)555-0110", type="Mobile"), + attributions=["0"], + ), + PhoneNumberAttributedValue( + value=PersonaPhoneNumberTypeValue(number="(425)555-0111", type="Mobile"), + attributions=["1"], + ), + ], + ) def test_get_persona_failure(self): # The test server may not have any personas. Just test that the service response with something we can parse - persona = Persona(id='AAA=', changekey='xxx') + persona = Persona(id="AAA=", changekey="xxx") with self.assertRaises(ErrorInvalidIdMalformed): GetPersona(account=self.account).get(personas=[persona]) diff --git a/tests/test_items/test_generic.py b/tests/test_items/test_generic.py index 467e9fb7..004bec70 100644 --- a/tests/test_items/test_generic.py +++ b/tests/test_items/test_generic.py @@ -1,19 +1,20 @@ import datetime + try: import zoneinfo except ImportError: from backports import zoneinfo from exchangelib.attachments import ItemAttachment -from exchangelib.errors import ErrorItemNotFound, ErrorInternalServerError +from exchangelib.errors import ErrorInternalServerError, ErrorItemNotFound from exchangelib.extended_properties import ExtendedProperty, ExternId -from exchangelib.fields import ExtendedPropertyField, CharField +from exchangelib.fields import CharField, ExtendedPropertyField from exchangelib.folders import Folder, FolderCollection, Root -from exchangelib.items import CalendarItem, Message, Item +from exchangelib.items import CalendarItem, Item, Message from exchangelib.queryset import QuerySet -from exchangelib.restriction import Restriction, Q -from exchangelib.services import CreateItem, UpdateItem, DeleteItem, FindItem, FindPeople -from exchangelib.version import Build, EXCHANGE_2007, EXCHANGE_2013 +from exchangelib.restriction import Q, Restriction +from exchangelib.services import CreateItem, DeleteItem, FindItem, FindPeople, UpdateItem +from exchangelib.version import EXCHANGE_2007, EXCHANGE_2013, Build from ..common import get_random_string, mock_version from .test_basics import CommonItemTest @@ -33,34 +34,34 @@ def test_validation(self): # Test field max_length if isinstance(f, CharField) and f.max_length: with self.assertRaises(ValueError): - setattr(item, f.name, 'a' * (f.max_length + 1)) + setattr(item, f.name, "a" * (f.max_length + 1)) item.clean() - setattr(item, f.name, 'a') + setattr(item, f.name, "a") def test_invalid_direct_args(self): with self.assertRaises(TypeError) as e: - self.ITEM_CLASS(account='XXX') + self.ITEM_CLASS(account="XXX") self.assertEqual(e.exception.args[0], "'account' 'XXX' must be of type ") with self.assertRaises(TypeError) as e: - self.ITEM_CLASS(folder='XXX') + self.ITEM_CLASS(folder="XXX") self.assertEqual( e.exception.args[0], "'folder' 'XXX' must be of type " ) with self.assertRaises(ValueError) as e: - self.ITEM_CLASS(account=self.account, folder=self.FOLDER_CLASS(root=Root(account='XXX'))) + self.ITEM_CLASS(account=self.account, folder=self.FOLDER_CLASS(root=Root(account="XXX"))) self.assertEqual(e.exception.args[0], "'account' does not match 'folder.account'") item = self.get_test_item() item.account = None with self.assertRaises(ValueError): item.save() # Must have account on save item = self.get_test_item() - item.id = 'XXX' # Fake a saved item + item.id = "XXX" # Fake a saved item item.account = None with self.assertRaises(ValueError): item.save() # Must have account on update item = self.get_test_item() with self.assertRaises(ValueError): - item.save(update_fields=['foo', 'bar']) # update_fields is only valid on update + item.save(update_fields=["foo", "bar"]) # update_fields is only valid on update item = self.get_test_item() item.account = None @@ -102,11 +103,11 @@ def test_invalid_direct_args(self): item = self.get_test_item() item.save() with self.assertRaises(TypeError) as e: - item.move(to_folder='XXX') # Must be folder instance + item.move(to_folder="XXX") # Must be folder instance self.assertEqual( e.exception.args[0], "'to_folder' 'XXX' must be of type (, " - ")" + ")", ) item_id, changekey = item.id, item.changekey item.delete() @@ -130,137 +131,140 @@ def test_invalid_direct_args(self): item.delete() # Item disappeared item = self.get_test_item() with self.assertRaises(ValueError): - item._delete(delete_type='FOO', send_meeting_cancellations=None, affected_task_occurrences=None, - suppress_read_receipts=None) + item._delete( + delete_type="FOO", + send_meeting_cancellations=None, + affected_task_occurrences=None, + suppress_read_receipts=None, + ) with self.assertRaises(ValueError): - item.delete(send_meeting_cancellations='XXX') + item.delete(send_meeting_cancellations="XXX") with self.assertRaises(ValueError): - item.delete(affected_task_occurrences='XXX') + item.delete(affected_task_occurrences="XXX") with self.assertRaises(ValueError): - item.delete(suppress_read_receipts='XXX') + item.delete(suppress_read_receipts="XXX") def test_invalid_createitem_args(self): with self.assertRaises(ValueError) as e: CreateItem(account=self.account).call( items=[], folder=None, - message_disposition='XXX', - send_meeting_invitations='SendToNone', + message_disposition="XXX", + send_meeting_invitations="SendToNone", ) self.assertEqual( e.exception.args[0], - "'message_disposition' 'XXX' must be one of ['SaveOnly', 'SendAndSaveCopy', 'SendOnly']" + "'message_disposition' 'XXX' must be one of ['SaveOnly', 'SendAndSaveCopy', 'SendOnly']", ) with self.assertRaises(ValueError) as e: CreateItem(account=self.account).call( items=[], folder=None, - message_disposition='SaveOnly', - send_meeting_invitations='XXX', + message_disposition="SaveOnly", + send_meeting_invitations="XXX", ) self.assertEqual( e.exception.args[0], - "'send_meeting_invitations' 'XXX' must be one of ['SendOnlyToAll', 'SendToAllAndSaveCopy', 'SendToNone']" + "'send_meeting_invitations' 'XXX' must be one of ['SendOnlyToAll', 'SendToAllAndSaveCopy', 'SendToNone']", ) with self.assertRaises(TypeError) as e: CreateItem(account=self.account).call( items=[], - folder='XXX', - message_disposition='SaveOnly', - send_meeting_invitations='SendToNone', + folder="XXX", + message_disposition="SaveOnly", + send_meeting_invitations="SendToNone", ) self.assertEqual( e.exception.args[0], "'folder' 'XXX' must be of type (, " - ")" + ")", ) def test_invalid_deleteitem_args(self): with self.assertRaises(ValueError) as e: DeleteItem(account=self.account).call( items=[], - delete_type='XXX', - send_meeting_cancellations='SendToNone', - affected_task_occurrences='AllOccurrences', + delete_type="XXX", + send_meeting_cancellations="SendToNone", + affected_task_occurrences="AllOccurrences", suppress_read_receipts=True, ) self.assertEqual( - e.exception.args[0], - "'delete_type' 'XXX' must be one of ['HardDelete', 'MoveToDeletedItems', 'SoftDelete']" + e.exception.args[0], "'delete_type' 'XXX' must be one of ['HardDelete', 'MoveToDeletedItems', 'SoftDelete']" ) with self.assertRaises(ValueError) as e: DeleteItem(account=self.account).call( items=[], - delete_type='HardDelete', - send_meeting_cancellations='XXX', - affected_task_occurrences='AllOccurrences', + delete_type="HardDelete", + send_meeting_cancellations="XXX", + affected_task_occurrences="AllOccurrences", suppress_read_receipts=True, ) self.assertEqual( e.exception.args[0], - "'send_meeting_cancellations' 'XXX' must be one of ['SendOnlyToAll', 'SendToAllAndSaveCopy', 'SendToNone']" + "'send_meeting_cancellations' 'XXX' must be one of ['SendOnlyToAll', 'SendToAllAndSaveCopy', 'SendToNone']", ) with self.assertRaises(ValueError) as e: DeleteItem(account=self.account).call( items=[], - delete_type='HardDelete', - send_meeting_cancellations='SendToNone', - affected_task_occurrences='XXX', + delete_type="HardDelete", + send_meeting_cancellations="SendToNone", + affected_task_occurrences="XXX", suppress_read_receipts=True, ) self.assertEqual( e.exception.args[0], - "'affected_task_occurrences' 'XXX' must be one of ['AllOccurrences', 'SpecifiedOccurrenceOnly']" + "'affected_task_occurrences' 'XXX' must be one of ['AllOccurrences', 'SpecifiedOccurrenceOnly']", ) def test_invalid_updateitem_args(self): with self.assertRaises(ValueError) as e: UpdateItem(account=self.account).call( items=[], - conflict_resolution='XXX', - message_disposition='SaveOnly', - send_meeting_invitations_or_cancellations='SendToNone', + conflict_resolution="XXX", + message_disposition="SaveOnly", + send_meeting_invitations_or_cancellations="SendToNone", suppress_read_receipts=True, ) self.assertEqual( e.exception.args[0], - "'conflict_resolution' 'XXX' must be one of ['AlwaysOverwrite', 'AutoResolve', 'NeverOverwrite']" + "'conflict_resolution' 'XXX' must be one of ['AlwaysOverwrite', 'AutoResolve', 'NeverOverwrite']", ) with self.assertRaises(ValueError) as e: UpdateItem(account=self.account).call( items=[], - conflict_resolution='NeverOverwrite', - message_disposition='XXX', - send_meeting_invitations_or_cancellations='SendToNone', + conflict_resolution="NeverOverwrite", + message_disposition="XXX", + send_meeting_invitations_or_cancellations="SendToNone", suppress_read_receipts=True, ) self.assertEqual( e.exception.args[0], - "'message_disposition' 'XXX' must be one of ['SaveOnly', 'SendAndSaveCopy', 'SendOnly']" + "'message_disposition' 'XXX' must be one of ['SaveOnly', 'SendAndSaveCopy', 'SendOnly']", ) with self.assertRaises(ValueError) as e: UpdateItem(account=self.account).call( items=[], - conflict_resolution='NeverOverwrite', - message_disposition='SaveOnly', - send_meeting_invitations_or_cancellations='XXX', + conflict_resolution="NeverOverwrite", + message_disposition="SaveOnly", + send_meeting_invitations_or_cancellations="XXX", suppress_read_receipts=True, ) self.assertEqual( e.exception.args[0], "'send_meeting_invitations_or_cancellations' 'XXX' must be one of " - "['SendOnlyToAll', 'SendOnlyToChanged', 'SendToAllAndSaveCopy', 'SendToChangedAndSaveCopy', 'SendToNone']" + "['SendOnlyToAll', 'SendOnlyToChanged', 'SendToAllAndSaveCopy', 'SendToChangedAndSaveCopy', 'SendToNone']", ) def test_invalid_finditem_args(self): with self.assertRaises(TypeError) as e: - FindItem(account=self.account, page_size='XXX') + FindItem(account=self.account, page_size="XXX") self.assertEqual(e.exception.args[0], "'page_size' 'XXX' must be of type ") with self.assertRaises(ValueError) as e: FindItem(account=self.account, page_size=-1) self.assertEqual(e.exception.args[0], "'page_size' -1 must be a positive number") with self.assertRaises(TypeError) as e: - FindItem(account=self.account, chunk_size='XXX') + FindItem(account=self.account, chunk_size="XXX") self.assertEqual(e.exception.args[0], "'chunk_size' 'XXX' must be of type ") with self.assertRaises(ValueError) as e: FindItem(account=self.account, chunk_size=-1) @@ -271,34 +275,28 @@ def test_invalid_finditem_args(self): additional_fields=None, restriction=None, order_fields=None, - shape='XXX', + shape="XXX", query_string=None, - depth='Shallow', + depth="Shallow", calendar_view=None, max_items=None, offset=None, ) - self.assertEqual( - e.exception.args[0], - "'shape' 'XXX' must be one of ['AllProperties', 'Default', 'IdOnly']" - ) + self.assertEqual(e.exception.args[0], "'shape' 'XXX' must be one of ['AllProperties', 'Default', 'IdOnly']") with self.assertRaises(ValueError) as e: FindItem(account=self.account).call( folders=None, additional_fields=None, restriction=None, order_fields=None, - shape='IdOnly', + shape="IdOnly", query_string=None, - depth='XXX', + depth="XXX", calendar_view=None, max_items=None, offset=None, ) - self.assertEqual( - e.exception.args[0], - "'depth' 'XXX' must be one of ['Associated', 'Shallow', 'SoftDeleted']" - ) + self.assertEqual(e.exception.args[0], "'depth' 'XXX' must be one of ['Associated', 'Shallow', 'SoftDeleted']") def test_invalid_findpeople_args(self): with self.assertRaises(ValueError) as e: @@ -307,41 +305,35 @@ def test_invalid_findpeople_args(self): additional_fields=None, restriction=None, order_fields=None, - shape='XXX', + shape="XXX", query_string=None, - depth='Shallow', + depth="Shallow", max_items=None, offset=None, ) - self.assertEqual( - e.exception.args[0], - "'shape' 'XXX' must be one of ['AllProperties', 'Default', 'IdOnly']" - ) + self.assertEqual(e.exception.args[0], "'shape' 'XXX' must be one of ['AllProperties', 'Default', 'IdOnly']") with self.assertRaises(ValueError) as e: FindPeople(account=self.account).call( folder=None, additional_fields=None, restriction=None, order_fields=None, - shape='IdOnly', + shape="IdOnly", query_string=None, - depth='XXX', + depth="XXX", max_items=None, offset=None, ) - self.assertEqual( - e.exception.args[0], - "'depth' 'XXX' must be one of ['Associated', 'Shallow', 'SoftDeleted']" - ) + self.assertEqual(e.exception.args[0], "'depth' 'XXX' must be one of ['Associated', 'Shallow', 'SoftDeleted']") def test_unsupported_fields(self): # Create a field that is not supported by any current versions. Test that we fail when using this field class UnsupportedProp(ExtendedProperty): - property_set_id = 'deadcafe-beef-beef-beef-deadcafebeef' - property_name = 'Unsupported Property' - property_type = 'String' + property_set_id = "deadcafe-beef-beef-beef-deadcafebeef" + property_name = "Unsupported Property" + property_type = "String" - attr_name = 'unsupported_property' + attr_name = "unsupported_property" self.ITEM_CLASS.register(attr_name=attr_name, attr_cls=UnsupportedProp) try: for f in self.ITEM_CLASS.FIELDS: @@ -349,9 +341,9 @@ class UnsupportedProp(ExtendedProperty): f.supported_from = Build(99, 99, 99, 99) with self.assertRaises(ValueError): - self.test_folder.get(**{attr_name: 'XXX'}) + self.test_folder.get(**{attr_name: "XXX"}) with self.assertRaises(ValueError): - list(self.test_folder.filter(**{attr_name: 'XXX'})) + list(self.test_folder.filter(**{attr_name: "XXX"})) with self.assertRaises(ValueError): list(self.test_folder.all().only(attr_name)) with self.assertRaises(ValueError): @@ -366,102 +358,106 @@ def test_order_by(self): test_items = [] for i in range(4): item = self.get_test_item() - item.subject = f'Subj {i}' + item.subject = f"Subj {i}" test_items.append(item) self.test_folder.bulk_create(items=test_items) - qs = QuerySet( - folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) - ).filter(categories__contains=self.categories) + qs = QuerySet(folder_collection=FolderCollection(account=self.account, folders=[self.test_folder])).filter( + categories__contains=self.categories + ) self.assertEqual( - list(qs.order_by('subject').values_list('subject', flat=True)), - ['Subj 0', 'Subj 1', 'Subj 2', 'Subj 3'] + list(qs.order_by("subject").values_list("subject", flat=True)), ["Subj 0", "Subj 1", "Subj 2", "Subj 3"] ) self.assertEqual( - list(qs.order_by('-subject').values_list('subject', flat=True)), - ['Subj 3', 'Subj 2', 'Subj 1', 'Subj 0'] + list(qs.order_by("-subject").values_list("subject", flat=True)), ["Subj 3", "Subj 2", "Subj 1", "Subj 0"] ) self.bulk_delete(qs) try: - self.ITEM_CLASS.register('extern_id', ExternId) + self.ITEM_CLASS.register("extern_id", ExternId) if self.ITEM_CLASS == Item: # An Item saved in Inbox becomes a Message - Message.register('extern_id', ExternId) + Message.register("extern_id", ExternId) # Test order_by() on ExtendedProperty test_items = [] for i in range(4): item = self.get_test_item() - item.extern_id = f'ID {i}' + item.extern_id = f"ID {i}" test_items.append(item) self.test_folder.bulk_create(items=test_items) - qs = QuerySet( - folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) - ).filter(categories__contains=self.categories) + qs = QuerySet(folder_collection=FolderCollection(account=self.account, folders=[self.test_folder])).filter( + categories__contains=self.categories + ) self.assertEqual( - list(qs.order_by('extern_id').values_list('extern_id', flat=True)), - ['ID 0', 'ID 1', 'ID 2', 'ID 3'] + list(qs.order_by("extern_id").values_list("extern_id", flat=True)), ["ID 0", "ID 1", "ID 2", "ID 3"] ) self.assertEqual( - list(qs.order_by('-extern_id').values_list('extern_id', flat=True)), - ['ID 3', 'ID 2', 'ID 1', 'ID 0'] + list(qs.order_by("-extern_id").values_list("extern_id", flat=True)), ["ID 3", "ID 2", "ID 1", "ID 0"] ) finally: - self.ITEM_CLASS.deregister('extern_id') + self.ITEM_CLASS.deregister("extern_id") if self.ITEM_CLASS == Item: # An Item saved in Inbox becomes a Message - Message.deregister('extern_id') + Message.deregister("extern_id") self.bulk_delete(qs) # Test sorting on multiple fields try: - self.ITEM_CLASS.register('extern_id', ExternId) + self.ITEM_CLASS.register("extern_id", ExternId) if self.ITEM_CLASS == Item: # An Item saved in Inbox becomes a Message - Message.register('extern_id', ExternId) + Message.register("extern_id", ExternId) test_items = [] for i in range(2): for j in range(2): item = self.get_test_item() - item.subject = f'Subj {i}' - item.extern_id = f'ID {j}' + item.subject = f"Subj {i}" + item.extern_id = f"ID {j}" test_items.append(item) self.test_folder.bulk_create(items=test_items) - qs = QuerySet( - folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) - ).filter(categories__contains=self.categories) + qs = QuerySet(folder_collection=FolderCollection(account=self.account, folders=[self.test_folder])).filter( + categories__contains=self.categories + ) self.assertEqual( - list(qs.order_by('subject', 'extern_id').values('subject', 'extern_id')), - [{'subject': 'Subj 0', 'extern_id': 'ID 0'}, - {'subject': 'Subj 0', 'extern_id': 'ID 1'}, - {'subject': 'Subj 1', 'extern_id': 'ID 0'}, - {'subject': 'Subj 1', 'extern_id': 'ID 1'}] + list(qs.order_by("subject", "extern_id").values("subject", "extern_id")), + [ + {"subject": "Subj 0", "extern_id": "ID 0"}, + {"subject": "Subj 0", "extern_id": "ID 1"}, + {"subject": "Subj 1", "extern_id": "ID 0"}, + {"subject": "Subj 1", "extern_id": "ID 1"}, + ], ) self.assertEqual( - list(qs.order_by('-subject', 'extern_id').values('subject', 'extern_id')), - [{'subject': 'Subj 1', 'extern_id': 'ID 0'}, - {'subject': 'Subj 1', 'extern_id': 'ID 1'}, - {'subject': 'Subj 0', 'extern_id': 'ID 0'}, - {'subject': 'Subj 0', 'extern_id': 'ID 1'}] + list(qs.order_by("-subject", "extern_id").values("subject", "extern_id")), + [ + {"subject": "Subj 1", "extern_id": "ID 0"}, + {"subject": "Subj 1", "extern_id": "ID 1"}, + {"subject": "Subj 0", "extern_id": "ID 0"}, + {"subject": "Subj 0", "extern_id": "ID 1"}, + ], ) self.assertEqual( - list(qs.order_by('subject', '-extern_id').values('subject', 'extern_id')), - [{'subject': 'Subj 0', 'extern_id': 'ID 1'}, - {'subject': 'Subj 0', 'extern_id': 'ID 0'}, - {'subject': 'Subj 1', 'extern_id': 'ID 1'}, - {'subject': 'Subj 1', 'extern_id': 'ID 0'}] + list(qs.order_by("subject", "-extern_id").values("subject", "extern_id")), + [ + {"subject": "Subj 0", "extern_id": "ID 1"}, + {"subject": "Subj 0", "extern_id": "ID 0"}, + {"subject": "Subj 1", "extern_id": "ID 1"}, + {"subject": "Subj 1", "extern_id": "ID 0"}, + ], ) self.assertEqual( - list(qs.order_by('-subject', '-extern_id').values('subject', 'extern_id')), - [{'subject': 'Subj 1', 'extern_id': 'ID 1'}, - {'subject': 'Subj 1', 'extern_id': 'ID 0'}, - {'subject': 'Subj 0', 'extern_id': 'ID 1'}, - {'subject': 'Subj 0', 'extern_id': 'ID 0'}] + list(qs.order_by("-subject", "-extern_id").values("subject", "extern_id")), + [ + {"subject": "Subj 1", "extern_id": "ID 1"}, + {"subject": "Subj 1", "extern_id": "ID 0"}, + {"subject": "Subj 0", "extern_id": "ID 1"}, + {"subject": "Subj 0", "extern_id": "ID 0"}, + ], ) finally: - self.ITEM_CLASS.deregister('extern_id') + self.ITEM_CLASS.deregister("extern_id") if self.ITEM_CLASS == Item: # An Item saved in Inbox becomes a Message - Message.deregister('extern_id') + Message.deregister("extern_id") def test_order_by_with_empty_values(self): # Test order_by() when some values are empty @@ -469,21 +465,19 @@ def test_order_by_with_empty_values(self): for i in range(4): item = self.get_test_item() if i % 2 == 0: - item.subject = f'Subj {i}' + item.subject = f"Subj {i}" else: item.subject = None test_items.append(item) self.test_folder.bulk_create(items=test_items) - qs = QuerySet( - folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) - ).filter(categories__contains=self.categories) + qs = QuerySet(folder_collection=FolderCollection(account=self.account, folders=[self.test_folder])).filter( + categories__contains=self.categories + ) self.assertEqual( - list(qs.order_by('subject').values_list('subject', flat=True)), - [None, None, 'Subj 0', 'Subj 2'] + list(qs.order_by("subject").values_list("subject", flat=True)), [None, None, "Subj 0", "Subj 2"] ) self.assertEqual( - list(qs.order_by('-subject').values_list('subject', flat=True)), - ['Subj 2', 'Subj 0', None, None] + list(qs.order_by("-subject").values_list("subject", flat=True)), ["Subj 2", "Subj 0", None, None] ) def test_order_by_on_list_field(self): @@ -493,101 +487,58 @@ def test_order_by_on_list_field(self): item = self.get_test_item() item.subject = self.categories[0] # Make sure we have something unique to filter on if i % 2 == 0: - item.categories = [f'Cat {i}'] + item.categories = [f"Cat {i}"] else: item.categories = [] test_items.append(item) self.test_folder.bulk_create(items=test_items) - qs = QuerySet( - folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) - ).filter(subject=self.categories[0]) + qs = QuerySet(folder_collection=FolderCollection(account=self.account, folders=[self.test_folder])).filter( + subject=self.categories[0] + ) self.assertEqual( - list(qs.order_by('categories').values_list('categories', flat=True)), - [None, None, ['Cat 0'], ['Cat 2']] + list(qs.order_by("categories").values_list("categories", flat=True)), [None, None, ["Cat 0"], ["Cat 2"]] ) self.assertEqual( - list(qs.order_by('-categories').values_list('categories', flat=True)), - [['Cat 2'], ['Cat 0'], None, None] + list(qs.order_by("-categories").values_list("categories", flat=True)), [["Cat 2"], ["Cat 0"], None, None] ) def test_finditems(self): - now = datetime.datetime.now(tz=zoneinfo.ZoneInfo('UTC')) + now = datetime.datetime.now(tz=zoneinfo.ZoneInfo("UTC")) # Test argument types item = self.get_test_item() ids = self.test_folder.bulk_create(items=[item]) # No arguments. There may be leftover items in the folder, so just make sure there's at least one. - self.assertGreaterEqual( - self.test_folder.filter().count(), - 1 - ) + self.assertGreaterEqual(self.test_folder.filter().count(), 1) # Q object - self.assertEqual( - self.test_folder.filter(Q(subject=item.subject)).count(), - 1 - ) + self.assertEqual(self.test_folder.filter(Q(subject=item.subject)).count(), 1) # Multiple Q objects self.assertEqual( - self.test_folder.filter(Q(subject=item.subject), ~Q(subject=item.subject[:-3] + 'XXX')).count(), - 1 + self.test_folder.filter(Q(subject=item.subject), ~Q(subject=item.subject[:-3] + "XXX")).count(), 1 ) # Multiple Q object and kwargs self.assertEqual( - self.test_folder.filter(Q(subject=item.subject), categories__contains=item.categories).count(), - 1 + self.test_folder.filter(Q(subject=item.subject), categories__contains=item.categories).count(), 1 ) self.bulk_delete(ids) # Test categories which are handled specially - only '__contains' and '__in' lookups are supported - item = self.get_test_item(categories=['TestA', 'TestB']) + item = self.get_test_item(categories=["TestA", "TestB"]) ids = self.test_folder.bulk_create(items=[item]) common_qs = self.test_folder.filter(subject=item.subject) # Guard against other simultaneous runs - self.assertEqual( - common_qs.filter(categories__contains='ci6xahH1').count(), # Plain string - 0 - ) - self.assertEqual( - common_qs.filter(categories__contains=['ci6xahH1']).count(), # Same, but as list - 0 - ) - self.assertEqual( - common_qs.filter(categories__contains=['TestA', 'TestC']).count(), # One wrong category - 0 - ) - self.assertEqual( - common_qs.filter(categories__contains=['TESTA']).count(), # Test case insensitivity - 1 - ) - self.assertEqual( - common_qs.filter(categories__contains=['testa']).count(), # Test case insensitivity - 1 - ) - self.assertEqual( - common_qs.filter(categories__contains=['TestA']).count(), # Partial - 1 - ) - self.assertEqual( - common_qs.filter(categories__contains=item.categories).count(), # Exact match - 1 - ) + self.assertEqual(common_qs.filter(categories__contains="ci6xahH1").count(), 0) # Plain string + self.assertEqual(common_qs.filter(categories__contains=["ci6xahH1"]).count(), 0) # Same, but as list + self.assertEqual(common_qs.filter(categories__contains=["TestA", "TestC"]).count(), 0) # One wrong category + self.assertEqual(common_qs.filter(categories__contains=["TESTA"]).count(), 1) # Test case insensitivity + self.assertEqual(common_qs.filter(categories__contains=["testa"]).count(), 1) # Test case insensitivity + self.assertEqual(common_qs.filter(categories__contains=["TestA"]).count(), 1) # Partial + self.assertEqual(common_qs.filter(categories__contains=item.categories).count(), 1) # Exact match with self.assertRaises(TypeError): - common_qs.filter(categories__in='ci6xahH1').count() # Plain string is not supported - self.assertEqual( - common_qs.filter(categories__in=['ci6xahH1']).count(), # Same, but as list - 0 - ) - self.assertEqual( - common_qs.filter(categories__in=['TestA', 'TestC']).count(), # One wrong category - 1 - ) - self.assertEqual( - common_qs.filter(categories__in=['TestA']).count(), # Partial - 1 - ) - self.assertEqual( - common_qs.filter(categories__in=item.categories).count(), # Exact match - 1 - ) + common_qs.filter(categories__in="ci6xahH1").count() # Plain string is not supported + self.assertEqual(common_qs.filter(categories__in=["ci6xahH1"]).count(), 0) # Same, but as list + self.assertEqual(common_qs.filter(categories__in=["TestA", "TestC"]).count(), 1) # One wrong category + self.assertEqual(common_qs.filter(categories__in=["TestA"]).count(), 1) # Partial + self.assertEqual(common_qs.filter(categories__in=item.categories).count(), 1) # Exact match self.bulk_delete(ids) common_qs = self.test_folder.filter(categories__contains=self.categories) @@ -595,247 +546,146 @@ def test_finditems(self): two_hours = datetime.timedelta(hours=2) # Test 'exists' ids = self.test_folder.bulk_create(items=[self.get_test_item()]) - self.assertEqual( - common_qs.filter(datetime_created__exists=True).count(), - 1 - ) - self.assertEqual( - common_qs.filter(datetime_created__exists=False).count(), - 0 - ) + self.assertEqual(common_qs.filter(datetime_created__exists=True).count(), 1) + self.assertEqual(common_qs.filter(datetime_created__exists=False).count(), 0) self.bulk_delete(ids) # Test 'range' ids = self.test_folder.bulk_create(items=[self.get_test_item()]) - self.assertEqual( - common_qs.filter(datetime_created__range=(now + one_hour, now + two_hours)).count(), - 0 - ) - self.assertEqual( - common_qs.filter(datetime_created__range=(now - one_hour, now + one_hour)).count(), - 1 - ) + self.assertEqual(common_qs.filter(datetime_created__range=(now + one_hour, now + two_hours)).count(), 0) + self.assertEqual(common_qs.filter(datetime_created__range=(now - one_hour, now + one_hour)).count(), 1) self.bulk_delete(ids) # Test '>' ids = self.test_folder.bulk_create(items=[self.get_test_item()]) - self.assertEqual( - common_qs.filter(datetime_created__gt=now + one_hour).count(), - 0 - ) - self.assertEqual( - common_qs.filter(datetime_created__gt=now - one_hour).count(), - 1 - ) + self.assertEqual(common_qs.filter(datetime_created__gt=now + one_hour).count(), 0) + self.assertEqual(common_qs.filter(datetime_created__gt=now - one_hour).count(), 1) self.bulk_delete(ids) # Test '>=' ids = self.test_folder.bulk_create(items=[self.get_test_item()]) - self.assertEqual( - common_qs.filter(datetime_created__gte=now + one_hour).count(), - 0 - ) - self.assertEqual( - common_qs.filter(datetime_created__gte=now - one_hour).count(), - 1 - ) + self.assertEqual(common_qs.filter(datetime_created__gte=now + one_hour).count(), 0) + self.assertEqual(common_qs.filter(datetime_created__gte=now - one_hour).count(), 1) self.bulk_delete(ids) # Test '<' ids = self.test_folder.bulk_create(items=[self.get_test_item()]) - self.assertEqual( - common_qs.filter(datetime_created__lt=now - one_hour).count(), - 0 - ) - self.assertEqual( - common_qs.filter(datetime_created__lt=now + one_hour).count(), - 1 - ) + self.assertEqual(common_qs.filter(datetime_created__lt=now - one_hour).count(), 0) + self.assertEqual(common_qs.filter(datetime_created__lt=now + one_hour).count(), 1) self.bulk_delete(ids) # Test '<=' ids = self.test_folder.bulk_create(items=[self.get_test_item()]) - self.assertEqual( - common_qs.filter(datetime_created__lte=now - one_hour).count(), - 0 - ) - self.assertEqual( - common_qs.filter(datetime_created__lte=now + one_hour).count(), - 1 - ) + self.assertEqual(common_qs.filter(datetime_created__lte=now - one_hour).count(), 0) + self.assertEqual(common_qs.filter(datetime_created__lte=now + one_hour).count(), 1) self.bulk_delete(ids) # Test '=' item = self.get_test_item() ids = self.test_folder.bulk_create(items=[item]) - self.assertEqual( - common_qs.filter(subject=item.subject[:-3] + 'XXX').count(), - 0 - ) - self.assertEqual( - common_qs.filter(subject=item.subject).count(), - 1 - ) + self.assertEqual(common_qs.filter(subject=item.subject[:-3] + "XXX").count(), 0) + self.assertEqual(common_qs.filter(subject=item.subject).count(), 1) self.bulk_delete(ids) # Test '!=' item = self.get_test_item() ids = self.test_folder.bulk_create(items=[item]) - self.assertEqual( - common_qs.filter(subject__not=item.subject).count(), - 0 - ) - self.assertEqual( - common_qs.filter(subject__not=item.subject[:-3] + 'XXX').count(), - 1 - ) + self.assertEqual(common_qs.filter(subject__not=item.subject).count(), 0) + self.assertEqual(common_qs.filter(subject__not=item.subject[:-3] + "XXX").count(), 1) self.bulk_delete(ids) # Test 'exact' item = self.get_test_item() - item.subject = 'aA' + item.subject[2:] + item.subject = "aA" + item.subject[2:] ids = self.test_folder.bulk_create(items=[item]) - self.assertEqual( - common_qs.filter(subject__exact=item.subject[:-3] + 'XXX').count(), - 0 - ) - self.assertEqual( - common_qs.filter(subject__exact=item.subject.lower()).count(), - 0 - ) - self.assertEqual( - common_qs.filter(subject__exact=item.subject.upper()).count(), - 0 - ) - self.assertEqual( - common_qs.filter(subject__exact=item.subject).count(), - 1 - ) + self.assertEqual(common_qs.filter(subject__exact=item.subject[:-3] + "XXX").count(), 0) + self.assertEqual(common_qs.filter(subject__exact=item.subject.lower()).count(), 0) + self.assertEqual(common_qs.filter(subject__exact=item.subject.upper()).count(), 0) + self.assertEqual(common_qs.filter(subject__exact=item.subject).count(), 1) self.bulk_delete(ids) # Test 'iexact' item = self.get_test_item() - item.subject = 'aA' + item.subject[2:] + item.subject = "aA" + item.subject[2:] ids = self.test_folder.bulk_create(items=[item]) - self.assertEqual( - common_qs.filter(subject__iexact=item.subject[:-3] + 'XXX').count(), - 0 - ) + self.assertEqual(common_qs.filter(subject__iexact=item.subject[:-3] + "XXX").count(), 0) self.assertIn( common_qs.filter(subject__iexact=item.subject.lower()).count(), - (0, 1) # iexact search is broken on some EWS versions + (0, 1), # iexact search is broken on some EWS versions ) self.assertIn( common_qs.filter(subject__iexact=item.subject.upper()).count(), - (0, 1) # iexact search is broken on some EWS versions - ) - self.assertEqual( - common_qs.filter(subject__iexact=item.subject).count(), - 1 + (0, 1), # iexact search is broken on some EWS versions ) + self.assertEqual(common_qs.filter(subject__iexact=item.subject).count(), 1) self.bulk_delete(ids) # Test 'contains' item = self.get_test_item() - item.subject = item.subject[2:8] + 'aA' + item.subject[8:] + item.subject = item.subject[2:8] + "aA" + item.subject[8:] ids = self.test_folder.bulk_create(items=[item]) - self.assertEqual( - common_qs.filter(subject__contains=item.subject[2:14] + 'XXX').count(), - 0 - ) - self.assertEqual( - common_qs.filter(subject__contains=item.subject[2:14].lower()).count(), - 0 - ) - self.assertEqual( - common_qs.filter(subject__contains=item.subject[2:14].upper()).count(), - 0 - ) - self.assertEqual( - common_qs.filter(subject__contains=item.subject[2:14]).count(), - 1 - ) + self.assertEqual(common_qs.filter(subject__contains=item.subject[2:14] + "XXX").count(), 0) + self.assertEqual(common_qs.filter(subject__contains=item.subject[2:14].lower()).count(), 0) + self.assertEqual(common_qs.filter(subject__contains=item.subject[2:14].upper()).count(), 0) + self.assertEqual(common_qs.filter(subject__contains=item.subject[2:14]).count(), 1) self.bulk_delete(ids) # Test 'icontains' item = self.get_test_item() - item.subject = item.subject[2:8] + 'aA' + item.subject[8:] + item.subject = item.subject[2:8] + "aA" + item.subject[8:] ids = self.test_folder.bulk_create(items=[item]) - self.assertEqual( - common_qs.filter(subject__icontains=item.subject[2:14] + 'XXX').count(), - 0 - ) + self.assertEqual(common_qs.filter(subject__icontains=item.subject[2:14] + "XXX").count(), 0) self.assertIn( common_qs.filter(subject__icontains=item.subject[2:14].lower()).count(), - (0, 1) # icontains search is broken on some EWS versions + (0, 1), # icontains search is broken on some EWS versions ) self.assertIn( common_qs.filter(subject__icontains=item.subject[2:14].upper()).count(), - (0, 1) # icontains search is broken on some EWS versions - ) - self.assertEqual( - common_qs.filter(subject__icontains=item.subject[2:14]).count(), - 1 + (0, 1), # icontains search is broken on some EWS versions ) + self.assertEqual(common_qs.filter(subject__icontains=item.subject[2:14]).count(), 1) self.bulk_delete(ids) # Test 'startswith' item = self.get_test_item() - item.subject = 'aA' + item.subject[2:] + item.subject = "aA" + item.subject[2:] ids = self.test_folder.bulk_create(items=[item]) - self.assertEqual( - common_qs.filter(subject__startswith='XXX' + item.subject[:12]).count(), - 0 - ) - self.assertEqual( - common_qs.filter(subject__startswith=item.subject[:12].lower()).count(), - 0 - ) - self.assertEqual( - common_qs.filter(subject__startswith=item.subject[:12].upper()).count(), - 0 - ) - self.assertEqual( - common_qs.filter(subject__startswith=item.subject[:12]).count(), - 1 - ) + self.assertEqual(common_qs.filter(subject__startswith="XXX" + item.subject[:12]).count(), 0) + self.assertEqual(common_qs.filter(subject__startswith=item.subject[:12].lower()).count(), 0) + self.assertEqual(common_qs.filter(subject__startswith=item.subject[:12].upper()).count(), 0) + self.assertEqual(common_qs.filter(subject__startswith=item.subject[:12]).count(), 1) self.bulk_delete(ids) # Test 'istartswith' item = self.get_test_item() - item.subject = 'aA' + item.subject[2:] + item.subject = "aA" + item.subject[2:] ids = self.test_folder.bulk_create(items=[item]) - self.assertEqual( - common_qs.filter(subject__istartswith='XXX' + item.subject[:12]).count(), - 0 - ) + self.assertEqual(common_qs.filter(subject__istartswith="XXX" + item.subject[:12]).count(), 0) self.assertIn( common_qs.filter(subject__istartswith=item.subject[:12].lower()).count(), - (0, 1) # istartswith search is broken on some EWS versions + (0, 1), # istartswith search is broken on some EWS versions ) self.assertIn( common_qs.filter(subject__istartswith=item.subject[:12].upper()).count(), - (0, 1) # istartswith search is broken on some EWS versions - ) - self.assertEqual( - common_qs.filter(subject__istartswith=item.subject[:12]).count(), - 1 + (0, 1), # istartswith search is broken on some EWS versions ) + self.assertEqual(common_qs.filter(subject__istartswith=item.subject[:12]).count(), 1) self.bulk_delete(ids) def test_filter_with_querystring(self): # QueryString is only supported from Exchange 2010 with self.assertRaises(NotImplementedError): - Q('Subject:XXX').to_xml(self.test_folder, version=mock_version(build=EXCHANGE_2007), - applies_to=Restriction.ITEMS) + Q("Subject:XXX").to_xml( + self.test_folder, version=mock_version(build=EXCHANGE_2007), applies_to=Restriction.ITEMS + ) # We don't allow QueryString in combination with other restrictions with self.assertRaises(TypeError): - self.test_folder.filter('Subject:XXX', foo='bar') + self.test_folder.filter("Subject:XXX", foo="bar") with self.assertRaises(ValueError): - self.test_folder.filter('Subject:XXX').filter(foo='bar') + self.test_folder.filter("Subject:XXX").filter(foo="bar") with self.assertRaises(ValueError): - self.test_folder.filter(foo='bar').filter('Subject:XXX') + self.test_folder.filter(foo="bar").filter("Subject:XXX") item = self.get_test_item() item.subject = get_random_string(length=8, spaces=False, special=False) @@ -844,12 +694,9 @@ def test_filter_with_querystring(self): # I'm too impatient for that, so also allow empty results. This makes the test almost worthless but I blame EWS. # Also, some servers are misconfigured and don't support querystrings at all. Don't fail on that. try: - self.assertIn( - self.test_folder.filter(f'Subject:{item.subject}').count(), - (0, 1) - ) + self.assertIn(self.test_folder.filter(f"Subject:{item.subject}").count(), (0, 1)) except ErrorInternalServerError as e: - self.assertIn('AQS parser has been removed from Windows 2016 Server Core', e.args[0]) + self.assertIn("AQS parser has been removed from Windows 2016 Server Core", e.args[0]) def test_complex_fields(self): # Test that complex fields can be fetched using only(). This is a test for #141. @@ -859,14 +706,14 @@ def test_complex_fields(self): if not f.supports_version(self.account.version): # Cannot be used with this EWS version continue - if f.name in ('optional_attendees', 'required_attendees', 'resources'): + if f.name in ("optional_attendees", "required_attendees", "resources"): continue if f.is_read_only: continue - if f.name == 'reminder_due_by': + if f.name == "reminder_due_by": # EWS sets a default value if it is not set on insert. Ignore continue - if f.name == 'mime_content': + if f.name == "mime_content": # This will change depending on other contents fields continue old = getattr(item, f.name) @@ -877,7 +724,7 @@ def test_complex_fields(self): old, new = set(old or ()), set(new or ()) self.assertEqual(old, new, (f.name, old, new)) # Test field as one of the elements in only() - fresh_item = self.test_folder.all().only('subject', f.name).get(categories__contains=item.categories) + fresh_item = self.test_folder.all().only("subject", f.name).get(categories__contains=item.categories) new = getattr(fresh_item, f.name) if f.is_list: old, new = set(old or ()), set(new or ()) @@ -885,11 +732,11 @@ def test_complex_fields(self): def test_text_body(self): if self.account.version.build < EXCHANGE_2013: - self.skipTest('Exchange version too old') + self.skipTest("Exchange version too old") item = self.get_test_item() - item.body = 'X' * 500 # Make body longer than the normal 256 char text field limit + item.body = "X" * 500 # Make body longer than the normal 256 char text field limit item.save() - fresh_item = self.test_folder.filter(categories__contains=item.categories).only('text_body')[0] + fresh_item = self.test_folder.filter(categories__contains=item.categories).only("text_body")[0] self.assertEqual(fresh_item.text_body, item.body) def test_only_fields(self): @@ -902,15 +749,15 @@ def test_only_fields(self): if not f.supports_version(self.account.version): # Cannot be used with this EWS version continue - if f.name in ('optional_attendees', 'required_attendees', 'resources'): + if f.name in ("optional_attendees", "required_attendees", "resources"): continue - if f.name == 'reminder_due_by' and not item.reminder_is_set: + if f.name == "reminder_due_by" and not item.reminder_is_set: # We delete the due date if reminder is not set continue if f.is_read_only: continue self.assertIsNotNone(getattr(item, f.name), (f, getattr(item, f.name))) - only_fields = ('subject', 'body', 'categories') + only_fields = ("subject", "body", "categories") item = self.test_folder.all().only(*only_fields).get(categories__contains=item.categories) self.assertIsInstance(item, self.ITEM_CLASS) for f in self.ITEM_CLASS.FIELDS: @@ -923,7 +770,7 @@ def test_only_fields(self): self.assertIsNotNone(getattr(item, f.name), (f.name, getattr(item, f.name))) elif f.is_required: v = getattr(item, f.name) - if f.name == 'attachments': + if f.name == "attachments": self.assertEqual(v, [], (f.name, v)) elif f.default is None: self.assertIsNone(v, (f.name, v)) @@ -960,28 +807,40 @@ def to_dict(item): # uploading. This means mime_content and size can also change. Items also get new IDs on upload. And # meeting_count values are dependent on contents of current calendar. Form query strings contain the # item ID and will also change. - if f.name in {'_id', 'first_occurrence', 'last_occurrence', 'datetime_created', - 'last_modified_time', 'mime_content', 'size', 'conversation_id', - 'adjacent_meeting_count', 'conflicting_meeting_count', - 'web_client_read_form_query_string', 'web_client_edit_form_query_string'}: + if f.name in { + "_id", + "first_occurrence", + "last_occurrence", + "datetime_created", + "last_modified_time", + "mime_content", + "size", + "conversation_id", + "adjacent_meeting_count", + "conflicting_meeting_count", + "web_client_read_form_query_string", + "web_client_edit_form_query_string", + }: continue dict_item[f.name] = getattr(item, f.name) - if f.name == 'attachments': + if f.name == "attachments": # Attachments get new IDs on upload. Wipe them here so we can compare the other fields for a in dict_item[f.name]: a.attachment_id = None return dict_item inserted_items = list(self.account.fetch(insert_results)) - uploaded_data = sorted([to_dict(item) for item in inserted_items], key=lambda i: i['subject']) - original_data = sorted([to_dict(item) for item in items], key=lambda i: i['subject']) + uploaded_data = sorted([to_dict(item) for item in inserted_items], key=lambda i: i["subject"]) + original_data = sorted([to_dict(item) for item in items], key=lambda i: i["subject"]) self.assertListEqual(original_data, uploaded_data) # Test update instead of insert - update_results = self.account.upload([ - (self.test_folder, ((i.id, i.changekey), i.is_associated, data)) - for i, data in zip(inserted_items, export_results) - ]) + update_results = self.account.upload( + [ + (self.test_folder, ((i.id, i.changekey), i.is_associated, data)) + for i, data in zip(inserted_items, export_results) + ] + ) self.assertEqual(len(export_results), len(update_results), (export_results, update_results)) for i, result in zip(inserted_items, update_results): # Must be a completely new ItemId @@ -1023,7 +882,7 @@ def test_item_attachments(self): attached_item1 = self.get_test_item(folder=self.test_folder) attached_item1.attachments = [] attached_item1.save() - attachment1 = ItemAttachment(name='attachment1', item=attached_item1) + attachment1 = ItemAttachment(name="attachment1", item=attached_item1) item.attach(attachment1) self.assertEqual(len(item.attachments), 1) @@ -1031,7 +890,7 @@ def test_item_attachments(self): fresh_item = self.get_item_by_id(item) self.assertEqual(len(fresh_item.attachments), 1) fresh_attachments = sorted(fresh_item.attachments, key=lambda a: a.name) - self.assertEqual(fresh_attachments[0].name, 'attachment1') + self.assertEqual(fresh_attachments[0].name, "attachment1") self.assertIsInstance(fresh_attachments[0].item, self.ITEM_CLASS) for f in self.ITEM_CLASS.FIELDS: @@ -1045,13 +904,13 @@ def test_item_attachments(self): if isinstance(f, ExtendedPropertyField): # Attachments don't have these values. It may be possible to request it if we can find the FieldURI continue - if f.name == 'is_read': + if f.name == "is_read": # This is always true for item attachments? continue - if f.name == 'reminder_due_by': + if f.name == "reminder_due_by": # EWS sets a default value if it is not set on insert. Ignore continue - if f.name == 'mime_content': + if f.name == "mime_content": # This will change depending on other contents fields continue old_val = getattr(attached_item1, f.name) @@ -1064,14 +923,14 @@ def test_item_attachments(self): attached_item2 = self.get_test_item(folder=self.test_folder) attached_item2.attachments = [] attached_item2.save() - attachment2 = ItemAttachment(name='attachment2', item=attached_item2) + attachment2 = ItemAttachment(name="attachment2", item=attached_item2) item.attach(attachment2) self.assertEqual(len(item.attachments), 2) fresh_item = self.get_item_by_id(item) self.assertEqual(len(fresh_item.attachments), 2) fresh_attachments = sorted(fresh_item.attachments, key=lambda a: a.name) - self.assertEqual(fresh_attachments[0].name, 'attachment1') + self.assertEqual(fresh_attachments[0].name, "attachment1") self.assertIsInstance(fresh_attachments[0].item, self.ITEM_CLASS) for f in self.ITEM_CLASS.FIELDS: @@ -1085,13 +944,13 @@ def test_item_attachments(self): if isinstance(f, ExtendedPropertyField): # Attachments don't have these values. It may be possible to request it if we can find the FieldURI continue - if f.name == 'reminder_due_by': + if f.name == "reminder_due_by": # EWS sets a default value if it is not set on insert. Ignore continue - if f.name == 'is_read': + if f.name == "is_read": # This is always true for item attachments? continue - if f.name == 'mime_content': + if f.name == "mime_content": # This will change depending on other contents fields continue old_val = getattr(attached_item1, f.name) @@ -1100,7 +959,7 @@ def test_item_attachments(self): old_val, new_val = set(old_val or ()), set(new_val or ()) self.assertEqual(old_val, new_val, (f.name, old_val, new_val)) - self.assertEqual(fresh_attachments[1].name, 'attachment2') + self.assertEqual(fresh_attachments[1].name, "attachment2") self.assertIsInstance(fresh_attachments[1].item, self.ITEM_CLASS) for f in self.ITEM_CLASS.FIELDS: @@ -1113,13 +972,13 @@ def test_item_attachments(self): if isinstance(f, ExtendedPropertyField): # Attachments don't have these values. It may be possible to request it if we can find the FieldURI continue - if f.name == 'reminder_due_by': + if f.name == "reminder_due_by": # EWS sets a default value if it is not set on insert. Ignore continue - if f.name == 'is_read': + if f.name == "is_read": # This is always true for item attachments? continue - if f.name == 'mime_content': + if f.name == "mime_content": # This will change depending on other contents fields continue old_val = getattr(attached_item2, f.name) @@ -1130,7 +989,7 @@ def test_item_attachments(self): # Test detach with self.assertRaises(ValueError) as e: - attachment2.parent_item = 'XXX' + attachment2.parent_item = "XXX" item.detach(attachment2) self.assertEqual(e.exception.args[0], "Attachment does not belong to this item") attachment2.parent_item = item @@ -1152,13 +1011,13 @@ def test_item_attachments(self): if isinstance(f, ExtendedPropertyField): # Attachments don't have these values. It may be possible to request it if we can find the FieldURI continue - if f.name == 'reminder_due_by': + if f.name == "reminder_due_by": # EWS sets a default value if it is not set on insert. Ignore continue - if f.name == 'is_read': + if f.name == "is_read": # This is always true for item attachments? continue - if f.name == 'mime_content': + if f.name == "mime_content": # This will change depending on other contents fields continue old_val = getattr(attached_item1, f.name) @@ -1170,6 +1029,6 @@ def test_item_attachments(self): # Test attach with non-saved item attached_item3 = self.get_test_item(folder=self.test_folder) attached_item3.attachments = [] - attachment3 = ItemAttachment(name='attachment2', item=attached_item3) + attachment3 = ItemAttachment(name="attachment2", item=attached_item3) item.attach(attachment3) item.detach(attachment3) diff --git a/tests/test_items/test_helpers.py b/tests/test_items/test_helpers.py index 3100e0f6..592913c2 100644 --- a/tests/test_items/test_helpers.py +++ b/tests/test_items/test_helpers.py @@ -6,34 +6,33 @@ class ItemHelperTest(BaseItemTest): - TEST_FOLDER = 'inbox' + TEST_FOLDER = "inbox" FOLDER_CLASS = Inbox ITEM_CLASS = Message def test_save_with_update_fields(self): item = self.get_test_item() with self.assertRaises(ValueError): - item.save(update_fields=['subject']) # update_fields does not work on item creation + item.save(update_fields=["subject"]) # update_fields does not work on item creation item.save() - item.subject = 'XXX' - item.body = 'YYY' - item.save(update_fields=['subject']) + item.subject = "XXX" + item.body = "YYY" + item.save(update_fields=["subject"]) item.refresh() - self.assertEqual(item.subject, 'XXX') - self.assertNotEqual(item.body, 'YYY') + self.assertEqual(item.subject, "XXX") + self.assertNotEqual(item.body, "YYY") # Test invalid 'update_fields' input with self.assertRaises(ValueError) as e: - item.save(update_fields=['xxx']) + item.save(update_fields=["xxx"]) self.assertEqual( - e.exception.args[0], - f"Field name(s) ['xxx'] are not valid {self.ITEM_CLASS.__name__!r} fields" + e.exception.args[0], f"Field name(s) ['xxx'] are not valid {self.ITEM_CLASS.__name__!r} fields" ) with self.assertRaises(ValueError) as e: - item.save(update_fields='subject') + item.save(update_fields="subject") self.assertEqual( e.exception.args[0], - f"Field name(s) ['s', 'u', 'b', 'j', 'e', 'c', 't'] are not valid {self.ITEM_CLASS.__name__!r} fields" + f"Field name(s) ['s', 'u', 'b', 'j', 'e', 'c', 't'] are not valid {self.ITEM_CLASS.__name__!r} fields", ) def test_soft_delete(self): @@ -108,7 +107,7 @@ def test_refresh(self): # Test that we can refresh items, and that refresh fails if the item no longer exists on the server item = self.get_test_item().save() orig_subject = item.subject - item.subject = 'XXX' + item.subject = "XXX" item.refresh() self.assertEqual(item.subject, orig_subject) item.delete() diff --git a/tests/test_items/test_messages.py b/tests/test_items/test_messages.py index 7a9abaf9..6e14bfa0 100644 --- a/tests/test_items/test_messages.py +++ b/tests/test_items/test_messages.py @@ -1,13 +1,13 @@ +import time from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText -import time from exchangelib.attachments import FileAttachment from exchangelib.errors import ErrorItemNotFound from exchangelib.folders import Inbox from exchangelib.items import Message, ReplyToItem from exchangelib.queryset import DoesNotExist -from exchangelib.version import Version, EXCHANGE_2010_SP2 +from exchangelib.version import EXCHANGE_2010_SP2, Version from ..common import get_random_string from .test_basics import CommonItemTest @@ -15,7 +15,7 @@ class MessagesTest(CommonItemTest): # Just test one of the Message-type folders - TEST_FOLDER = 'inbox' + TEST_FOLDER = "inbox" FOLDER_CLASS = Inbox ITEM_CLASS = Message INCOMING_MESSAGE_TIMEOUT = 60 @@ -25,7 +25,7 @@ def get_incoming_message(self, subject): while True: t2 = time.monotonic() if t2 - t1 > self.INCOMING_MESSAGE_TIMEOUT: - self.skipTest(f'Too bad. Gave up in {self.id()} waiting for the incoming message to show up') + self.skipTest(f"Too bad. Gave up in {self.id()} waiting for the incoming message to show up") try: return self.account.inbox.get(subject=subject) except DoesNotExist: @@ -45,7 +45,7 @@ def test_send_pre_2013(self): item = self.get_test_item() item.account = self.get_account() item.folder = item.account.inbox - item.attach(FileAttachment(name='file_attachment', content=b'file_attachment')) + item.attach(FileAttachment(name="file_attachment", content=b"file_attachment")) item.account.version = Version(EXCHANGE_2010_SP2) item.send(save_copy=False) self.assertIsNone(item.id) @@ -113,8 +113,8 @@ def test_reply(self): item.folder = None item.send() # get_test_item() sets the to_recipients to the test account sent_item = self.get_incoming_message(item.subject) - new_subject = (f'Re: {sent_item.subject}')[:255] - sent_item.reply(subject=new_subject, body='Hello reply', to_recipients=[item.author]) + new_subject = (f"Re: {sent_item.subject}")[:255] + sent_item.reply(subject=new_subject, body="Hello reply", to_recipients=[item.author]) self.assertEqual(self.account.sent.filter(subject=new_subject).count(), 1) def test_create_reply(self): @@ -123,34 +123,34 @@ def test_create_reply(self): item.folder = None item.send() sent_item = self.get_incoming_message(item.subject) - new_subject = (f'Re: {sent_item.subject}')[:255] + new_subject = (f"Re: {sent_item.subject}")[:255] with self.assertRaises(ValueError) as e: tmp = sent_item.author try: sent_item.author = None - sent_item.create_reply(subject=new_subject, body='Hello reply').save(self.account.drafts) + sent_item.create_reply(subject=new_subject, body="Hello reply").save(self.account.drafts) finally: sent_item.author = tmp self.assertEqual(e.exception.args[0], "'to_recipients' must be set when message has no 'author'") - sent_item.create_reply(subject=new_subject, body='Hello reply', to_recipients=[item.author])\ - .save(self.account.drafts) + sent_item.create_reply(subject=new_subject, body="Hello reply", to_recipients=[item.author]).save( + self.account.drafts + ) self.assertEqual(self.account.drafts.filter(subject=new_subject).count(), 1) # Test with no to_recipients - sent_item.create_reply(subject=new_subject, body='Hello reply')\ - .save(self.account.drafts) + sent_item.create_reply(subject=new_subject, body="Hello reply").save(self.account.drafts) self.assertEqual(self.account.drafts.filter(subject=new_subject).count(), 2) def test_reply_all(self): with self.assertRaises(TypeError) as e: - ReplyToItem(account='XXX') + ReplyToItem(account="XXX") self.assertEqual(e.exception.args[0], "'account' 'XXX' must be of type ") # Test that we can reply-all a Message item. EWS only allows items that have been sent to receive a reply item = self.get_test_item(folder=None) item.folder = None item.send() sent_item = self.get_incoming_message(item.subject) - new_subject = (f'Re: {sent_item.subject}')[:255] - sent_item.reply_all(subject=new_subject, body='Hello reply') + new_subject = (f"Re: {sent_item.subject}")[:255] + sent_item.reply_all(subject=new_subject, body="Hello reply") self.assertEqual(self.account.sent.filter(subject=new_subject).count(), 1) def test_forward(self): @@ -159,8 +159,8 @@ def test_forward(self): item.folder = None item.send() sent_item = self.get_incoming_message(item.subject) - new_subject = (f'Re: {sent_item.subject}')[:255] - sent_item.forward(subject=new_subject, body='Hello reply', to_recipients=[item.author]) + new_subject = (f"Re: {sent_item.subject}")[:255] + sent_item.forward(subject=new_subject, body="Hello reply", to_recipients=[item.author]) self.assertEqual(self.account.sent.filter(subject=new_subject).count(), 1) def test_create_forward(self): @@ -169,8 +169,8 @@ def test_create_forward(self): item.folder = None item.send() sent_item = self.get_incoming_message(item.subject) - new_subject = (f'Re: {sent_item.subject}')[:255] - forward_item = sent_item.create_forward(subject=new_subject, body='Hello reply', to_recipients=[item.author]) + new_subject = (f"Re: {sent_item.subject}")[:255] + forward_item = sent_item.create_forward(subject=new_subject, body="Hello reply", to_recipients=[item.author]) with self.assertRaises(AttributeError) as e: forward_item.send(save_copy=False, copy_to_folder=self.account.sent) self.assertEqual(e.exception.args[0], "'save_copy' must be True when 'copy_to_folder' is set") @@ -198,11 +198,11 @@ def test_mime_content(self): # Tests the 'mime_content' field subject = get_random_string(16) msg = MIMEMultipart() - msg['From'] = self.account.primary_smtp_address - msg['To'] = self.account.primary_smtp_address - msg['Subject'] = subject - body = 'MIME test mail' - msg.attach(MIMEText(body, 'plain', _charset='utf-8')) + msg["From"] = self.account.primary_smtp_address + msg["To"] = self.account.primary_smtp_address + msg["Subject"] = subject + body = "MIME test mail" + msg.attach(MIMEText(body, "plain", _charset="utf-8")) mime_content = msg.as_bytes() self.ITEM_CLASS( folder=self.test_folder, @@ -221,11 +221,11 @@ def test_invalid_kwargs_on_send(self): item = self.get_test_item() item.save() with self.assertRaises(TypeError) as e: - item.send(copy_to_folder='XXX', save_copy=True) # Invalid folder + item.send(copy_to_folder="XXX", save_copy=True) # Invalid folder self.assertEqual( e.exception.args[0], "'saved_item_folder' 'XXX' must be of type (, " - ")" + ")", ) item_id, changekey = item.id, item.changekey item.delete() diff --git a/tests/test_items/test_queryset.py b/tests/test_items/test_queryset.py index 1da5d034..96c13afb 100644 --- a/tests/test_items/test_queryset.py +++ b/tests/test_items/test_queryset.py @@ -1,14 +1,14 @@ import time -from exchangelib.folders import Inbox, FolderCollection -from exchangelib.items import Message, SHALLOW, ASSOCIATED -from exchangelib.queryset import QuerySet, DoesNotExist, MultipleObjectsReturned +from exchangelib.folders import FolderCollection, Inbox +from exchangelib.items import ASSOCIATED, SHALLOW, Message +from exchangelib.queryset import DoesNotExist, MultipleObjectsReturned, QuerySet from .test_basics import BaseItemTest class ItemQuerySetTest(BaseItemTest): - TEST_FOLDER = 'inbox' + TEST_FOLDER = "inbox" FOLDER_CLASS = Inbox ITEM_CLASS = Message @@ -16,142 +16,107 @@ def test_querysets(self): test_items = [] for i in range(4): item = self.get_test_item() - item.subject = f'Item {i}' + item.subject = f"Item {i}" item.save() test_items.append(item) - qs = QuerySet( - folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) - ).filter(categories__contains=self.categories) + qs = QuerySet(folder_collection=FolderCollection(account=self.account, folders=[self.test_folder])).filter( + categories__contains=self.categories + ) test_cat = self.categories[0] self.assertEqual( {(i.subject, i.categories[0]) for i in qs}, - {('Item 0', test_cat), ('Item 1', test_cat), ('Item 2', test_cat), ('Item 3', test_cat)} - ) - self.assertEqual( - [(i.subject, i.categories[0]) for i in qs.none()], - [] + {("Item 0", test_cat), ("Item 1", test_cat), ("Item 2", test_cat), ("Item 3", test_cat)}, ) + self.assertEqual([(i.subject, i.categories[0]) for i in qs.none()], []) self.assertEqual( - [(i.subject, i.categories[0]) for i in qs.filter(subject__startswith='Item 2')], - [('Item 2', test_cat)] + [(i.subject, i.categories[0]) for i in qs.filter(subject__startswith="Item 2")], [("Item 2", test_cat)] ) self.assertEqual( - {(i.subject, i.categories[0]) for i in qs.exclude(subject__startswith='Item 2')}, - {('Item 0', test_cat), ('Item 1', test_cat), ('Item 3', test_cat)} + {(i.subject, i.categories[0]) for i in qs.exclude(subject__startswith="Item 2")}, + {("Item 0", test_cat), ("Item 1", test_cat), ("Item 3", test_cat)}, ) self.assertEqual( - {(i.subject, i.categories) for i in qs.only('subject')}, - {('Item 0', None), ('Item 1', None), ('Item 2', None), ('Item 3', None)} + {(i.subject, i.categories) for i in qs.only("subject")}, + {("Item 0", None), ("Item 1", None), ("Item 2", None), ("Item 3", None)}, ) self.assertEqual( - [(i.subject, i.categories[0]) for i in qs.order_by('subject')], - [('Item 0', test_cat), ('Item 1', test_cat), ('Item 2', test_cat), ('Item 3', test_cat)] + [(i.subject, i.categories[0]) for i in qs.order_by("subject")], + [("Item 0", test_cat), ("Item 1", test_cat), ("Item 2", test_cat), ("Item 3", test_cat)], ) self.assertEqual( # Test '-some_field' syntax for reverse sorting - [(i.subject, i.categories[0]) for i in qs.order_by('-subject')], - [('Item 3', test_cat), ('Item 2', test_cat), ('Item 1', test_cat), ('Item 0', test_cat)] + [(i.subject, i.categories[0]) for i in qs.order_by("-subject")], + [("Item 3", test_cat), ("Item 2", test_cat), ("Item 1", test_cat), ("Item 0", test_cat)], ) self.assertEqual( # Test ordering on a field that we don't need to fetch - [(i.subject, i.categories[0]) for i in qs.order_by('-subject').only('categories')], - [(None, test_cat), (None, test_cat), (None, test_cat), (None, test_cat)] + [(i.subject, i.categories[0]) for i in qs.order_by("-subject").only("categories")], + [(None, test_cat), (None, test_cat), (None, test_cat), (None, test_cat)], ) self.assertEqual( - [(i.subject, i.categories[0]) for i in qs.order_by('subject').reverse()], - [('Item 3', test_cat), ('Item 2', test_cat), ('Item 1', test_cat), ('Item 0', test_cat)] + [(i.subject, i.categories[0]) for i in qs.order_by("subject").reverse()], + [("Item 3", test_cat), ("Item 2", test_cat), ("Item 1", test_cat), ("Item 0", test_cat)], ) with self.assertRaises(TypeError): list(qs.values([])) self.assertEqual( - list(qs.order_by('subject').values('subject')), - [{'subject': 'Item 0'}, {'subject': 'Item 1'}, {'subject': 'Item 2'}, {'subject': 'Item 3'}] + list(qs.order_by("subject").values("subject")), + [{"subject": "Item 0"}, {"subject": "Item 1"}, {"subject": "Item 2"}, {"subject": "Item 3"}], ) # Test .values() in combinations of 'id' and 'changekey', which are handled specially + self.assertEqual(list(qs.order_by("subject").values("id")), [{"id": i.id} for i in test_items]) self.assertEqual( - list(qs.order_by('subject').values('id')), - [{'id': i.id} for i in test_items] + list(qs.order_by("subject").values("changekey")), [{"changekey": i.changekey} for i in test_items] ) self.assertEqual( - list(qs.order_by('subject').values('changekey')), - [{'changekey': i.changekey} for i in test_items] - ) - self.assertEqual( - list(qs.order_by('subject').values('id', 'changekey')), - [{k: getattr(i, k) for k in ('id', 'changekey')} for i in test_items] + list(qs.order_by("subject").values("id", "changekey")), + [{k: getattr(i, k) for k in ("id", "changekey")} for i in test_items], ) - self.assertEqual( - set(qs.values_list('subject')), - {('Item 0',), ('Item 1',), ('Item 2',), ('Item 3',)} - ) + self.assertEqual(set(qs.values_list("subject")), {("Item 0",), ("Item 1",), ("Item 2",), ("Item 3",)}) # Test .values_list() in combinations of 'id' and 'changekey', which are handled specially + self.assertEqual(list(qs.order_by("subject").values_list("id")), [(i.id,) for i in test_items]) + self.assertEqual(list(qs.order_by("subject").values_list("changekey")), [(i.changekey,) for i in test_items]) self.assertEqual( - list(qs.order_by('subject').values_list('id')), - [(i.id,) for i in test_items] - ) - self.assertEqual( - list(qs.order_by('subject').values_list('changekey')), - [(i.changekey,) for i in test_items] - ) - self.assertEqual( - list(qs.order_by('subject').values_list('id', 'changekey')), - [(i.id, i.changekey) for i in test_items] + list(qs.order_by("subject").values_list("id", "changekey")), [(i.id, i.changekey) for i in test_items] ) - self.assertEqual( - {i.subject for i in qs.only('subject')}, - {'Item 0', 'Item 1', 'Item 2', 'Item 3'} - ) + self.assertEqual({i.subject for i in qs.only("subject")}, {"Item 0", "Item 1", "Item 2", "Item 3"}) # Test .only() in combinations of 'id' and 'changekey', which are handled specially + self.assertEqual([(i.id,) for i in qs.order_by("subject").only("id")], [(i.id,) for i in test_items]) self.assertEqual( - [(i.id,) for i in qs.order_by('subject').only('id')], - [(i.id,) for i in test_items] + [(i.changekey,) for i in qs.order_by("subject").only("changekey")], [(i.changekey,) for i in test_items] ) self.assertEqual( - [(i.changekey,) for i in qs.order_by('subject').only('changekey')], - [(i.changekey,) for i in test_items] - ) - self.assertEqual( - [(i.id, i.changekey) for i in qs.order_by('subject').only('id', 'changekey')], - [(i.id, i.changekey) for i in test_items] + [(i.id, i.changekey) for i in qs.order_by("subject").only("id", "changekey")], + [(i.id, i.changekey) for i in test_items], ) with self.assertRaises(ValueError): - list(qs.values_list('id', 'changekey', flat=True)) + list(qs.values_list("id", "changekey", flat=True)) with self.assertRaises(AttributeError): - list(qs.values_list('id', xxx=True)) - self.assertEqual( - list(qs.order_by('subject').values_list('id', flat=True)), - [i.id for i in test_items] - ) + list(qs.values_list("id", xxx=True)) + self.assertEqual(list(qs.order_by("subject").values_list("id", flat=True)), [i.id for i in test_items]) self.assertEqual( - list(qs.order_by('subject').values_list('changekey', flat=True)), - [i.changekey for i in test_items] + list(qs.order_by("subject").values_list("changekey", flat=True)), [i.changekey for i in test_items] ) + self.assertEqual(set(qs.values_list("subject", flat=True)), {"Item 0", "Item 1", "Item 2", "Item 3"}) + self.assertEqual(qs.values_list("subject", flat=True).get(subject="Item 2"), "Item 2") self.assertEqual( - set(qs.values_list('subject', flat=True)), - {'Item 0', 'Item 1', 'Item 2', 'Item 3'} - ) - self.assertEqual( - qs.values_list('subject', flat=True).get(subject='Item 2'), - 'Item 2' - ) - self.assertEqual( - {(i.subject, i.categories[0]) for i in qs.exclude(subject__startswith='Item 2')}, - {('Item 0', test_cat), ('Item 1', test_cat), ('Item 3', test_cat)} + {(i.subject, i.categories[0]) for i in qs.exclude(subject__startswith="Item 2")}, + {("Item 0", test_cat), ("Item 1", test_cat), ("Item 3", test_cat)}, ) # Test that we can sort on a field that we don't want self.assertEqual( - [i.categories[0] for i in qs.only('categories').order_by('subject')], - [test_cat, test_cat, test_cat, test_cat] + [i.categories[0] for i in qs.only("categories").order_by("subject")], + [test_cat, test_cat, test_cat, test_cat], ) - self.assertEqual(qs.get(subject='Item 3').subject, 'Item 3') + self.assertEqual(qs.get(subject="Item 3").subject, "Item 3") with self.assertRaises(DoesNotExist): - qs.get(subject='Item XXX') + qs.get(subject="Item XXX") with self.assertRaises(MultipleObjectsReturned): - qs.get(subject__startswith='Item') + qs.get(subject__startswith="Item") # len() and count() self.assertEqual(qs.count(), 4) # Indexing and slicing @@ -162,24 +127,21 @@ def test_querysets(self): print(qs[99999]) # Exists self.assertEqual(qs.exists(), True) - self.assertEqual(qs.filter(subject='Test XXX').exists(), False) - self.assertEqual( - qs.filter(subject__startswith='Item').delete(), - [True, True, True, True] - ) + self.assertEqual(qs.filter(subject="Test XXX").exists(), False) + self.assertEqual(qs.filter(subject__startswith="Item").delete(), [True, True, True, True]) def test_queryset_failure(self): - qs = QuerySet( - folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) - ).filter(categories__contains=self.categories) + qs = QuerySet(folder_collection=FolderCollection(account=self.account, folders=[self.test_folder])).filter( + categories__contains=self.categories + ) with self.assertRaises(ValueError): - qs.order_by('XXX') + qs.order_by("XXX") with self.assertRaises(ValueError): - qs.values('XXX') + qs.values("XXX") with self.assertRaises(ValueError): - qs.values_list('XXX') + qs.values_list("XXX") with self.assertRaises(ValueError): - qs.only('XXX') + qs.only("XXX") with self.assertRaises(ValueError): qs.reverse() # We can't reverse when we haven't defined an order yet @@ -187,16 +149,18 @@ def test_cached_queryset_corner_cases(self): test_items = [] for i in range(4): item = self.get_test_item() - item.subject = f'Item {i}' + item.subject = f"Item {i}" item.save() test_items.append(item) - qs = QuerySet( - folder_collection=FolderCollection(account=self.account, folders=[self.test_folder]) - ).filter(categories__contains=self.categories).order_by('subject') + qs = ( + QuerySet(folder_collection=FolderCollection(account=self.account, folders=[self.test_folder])) + .filter(categories__contains=self.categories) + .order_by("subject") + ) with self.assertRaises(MultipleObjectsReturned): qs.get() # Get with a full cache - self.assertEqual(qs[2].subject, 'Item 2') # Index with a full cache - self.assertEqual(qs[-2].subject, 'Item 2') # Negative index with a full cache + self.assertEqual(qs[2].subject, "Item 2") # Index with a full cache + self.assertEqual(qs[-2].subject, "Item 2") # Negative index with a full cache qs.delete() # Delete with a full cache self.assertEqual(qs.count(), 0) # QuerySet is empty after delete self.assertEqual(list(qs.none()), []) @@ -206,7 +170,7 @@ def test_queryset_get_by_id(self): with self.assertRaises(ValueError): list(self.test_folder.filter(id__in=[item.id])) with self.assertRaises(ValueError): - list(self.test_folder.get(id=item.id, changekey=item.changekey, subject='XXX')) + list(self.test_folder.get(id=item.id, changekey=item.changekey, subject="XXX")) with self.assertRaises(ValueError): list(self.test_folder.get(id=None, changekey=item.changekey)) @@ -237,7 +201,7 @@ def test_queryset_get_by_id(self): self.assertEqual(item.body, get_item.body) # Test a get() with only() - get_item = self.test_folder.all().only('subject').get(id=item.id, changekey=item.changekey) + get_item = self.test_folder.all().only("subject").get(id=item.id, changekey=item.changekey) self.assertEqual(item.id, get_item.id) self.assertEqual(item.changekey, get_item.changekey) self.assertEqual(item.subject, get_item.subject) @@ -251,7 +215,7 @@ def test_paging(self): del i.attachments[:] items.append(i) self.test_folder.bulk_create(items=items) - ids = self.test_folder.filter(categories__contains=self.categories).values_list('id', 'changekey') + ids = self.test_folder.filter(categories__contains=self.categories).values_list("id", "changekey") ids.page_size = 10 self.bulk_delete(ids) @@ -260,77 +224,38 @@ def test_slicing(self): items = [] for i in range(4): item = self.get_test_item() - item.subject = f'Subj {i}' + item.subject = f"Subj {i}" del item.attachments[:] items.append(item) self.test_folder.bulk_create(items=items) - qs = self.test_folder.filter(categories__contains=self.categories).only('subject').order_by('subject') + qs = self.test_folder.filter(categories__contains=self.categories).only("subject").order_by("subject") # Test positive index - self.assertEqual( - qs._copy_self()[0].subject, - 'Subj 0' - ) + self.assertEqual(qs._copy_self()[0].subject, "Subj 0") # Test positive index - self.assertEqual( - qs._copy_self()[3].subject, - 'Subj 3' - ) + self.assertEqual(qs._copy_self()[3].subject, "Subj 3") # Test negative index - self.assertEqual( - qs._copy_self()[-2].subject, - 'Subj 2' - ) + self.assertEqual(qs._copy_self()[-2].subject, "Subj 2") # Test positive slice - self.assertEqual( - [i.subject for i in qs._copy_self()[0:2]], - ['Subj 0', 'Subj 1'] - ) + self.assertEqual([i.subject for i in qs._copy_self()[0:2]], ["Subj 0", "Subj 1"]) # Test positive slice - self.assertEqual( - [i.subject for i in qs._copy_self()[2:4]], - ['Subj 2', 'Subj 3'] - ) + self.assertEqual([i.subject for i in qs._copy_self()[2:4]], ["Subj 2", "Subj 3"]) # Test positive open slice - self.assertEqual( - [i.subject for i in qs._copy_self()[:2]], - ['Subj 0', 'Subj 1'] - ) + self.assertEqual([i.subject for i in qs._copy_self()[:2]], ["Subj 0", "Subj 1"]) # Test positive open slice - self.assertEqual( - [i.subject for i in qs._copy_self()[2:]], - ['Subj 2', 'Subj 3'] - ) + self.assertEqual([i.subject for i in qs._copy_self()[2:]], ["Subj 2", "Subj 3"]) # Test negative slice - self.assertEqual( - [i.subject for i in qs._copy_self()[-3:-1]], - ['Subj 1', 'Subj 2'] - ) + self.assertEqual([i.subject for i in qs._copy_self()[-3:-1]], ["Subj 1", "Subj 2"]) # Test negative slice - self.assertEqual( - [i.subject for i in qs._copy_self()[1:-1]], - ['Subj 1', 'Subj 2'] - ) + self.assertEqual([i.subject for i in qs._copy_self()[1:-1]], ["Subj 1", "Subj 2"]) # Test negative open slice - self.assertEqual( - [i.subject for i in qs._copy_self()[:-2]], - ['Subj 0', 'Subj 1'] - ) + self.assertEqual([i.subject for i in qs._copy_self()[:-2]], ["Subj 0", "Subj 1"]) # Test negative open slice - self.assertEqual( - [i.subject for i in qs._copy_self()[-2:]], - ['Subj 2', 'Subj 3'] - ) + self.assertEqual([i.subject for i in qs._copy_self()[-2:]], ["Subj 2", "Subj 3"]) # Test positive slice with step - self.assertEqual( - [i.subject for i in qs._copy_self()[0:4:2]], - ['Subj 0', 'Subj 2'] - ) + self.assertEqual([i.subject for i in qs._copy_self()[0:4:2]], ["Subj 0", "Subj 2"]) # Test negative slice with step - self.assertEqual( - [i.subject for i in qs._copy_self()[4:0:-2]], - ['Subj 3', 'Subj 1'] - ) + self.assertEqual([i.subject for i in qs._copy_self()[4:0:-2]], ["Subj 3", "Subj 1"]) def test_delete_via_queryset(self): self.get_test_item().save() diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index 4b100601..96d1a6a1 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -1,27 +1,26 @@ import time from exchangelib.errors import ErrorInvalidSubscription, ErrorSubscriptionNotFound, MalformedResponseError -from exchangelib.folders import Inbox, FolderCollection +from exchangelib.folders import FolderCollection, Inbox from exchangelib.items import Message -from exchangelib.properties import StatusEvent, CreatedEvent, ModifiedEvent, DeletedEvent, Notification, ItemId -from exchangelib.services import SendNotification, SubscribeToPull, GetStreamingEvents +from exchangelib.properties import CreatedEvent, DeletedEvent, ItemId, ModifiedEvent, Notification, StatusEvent +from exchangelib.services import GetStreamingEvents, SendNotification, SubscribeToPull from exchangelib.util import PrettyXmlHandler -from .test_basics import BaseItemTest from ..common import get_random_string +from .test_basics import BaseItemTest class SyncTest(BaseItemTest): - TEST_FOLDER = 'inbox' + TEST_FOLDER = "inbox" FOLDER_CLASS = Inbox ITEM_CLASS = Message def test_subscribe_invalid_kwargs(self): with self.assertRaises(ValueError) as e: - self.account.inbox.subscribe_to_pull(event_types=['XXX']) + self.account.inbox.subscribe_to_pull(event_types=["XXX"]) self.assertEqual( - e.exception.args[0], - f"'event_types' values must consist of values in {SubscribeToPull.EVENT_TYPES}" + e.exception.args[0], f"'event_types' values must consist of values in {SubscribeToPull.EVENT_TYPES}" ) with self.assertRaises(ValueError) as e: self.account.inbox.subscribe_to_pull(event_types=[]) @@ -48,15 +47,16 @@ def test_pull_subscribe(self): # Affinity cookie is not always sent by the server for pull subscriptions def test_push_subscribe(self): - with self.account.inbox.push_subscription( - callback_url='https://example.com/foo' - ) as (subscription_id, watermark): + with self.account.inbox.push_subscription(callback_url="https://example.com/foo") as ( + subscription_id, + watermark, + ): self.assertIsNotNone(subscription_id) self.assertIsNotNone(watermark) # Test with watermark with self.account.inbox.push_subscription( - callback_url='https://example.com/foo', - watermark=watermark, + callback_url="https://example.com/foo", + watermark=watermark, ) as (subscription_id, watermark): self.assertIsNotNone(subscription_id) self.assertIsNotNone(watermark) @@ -64,9 +64,10 @@ def test_push_subscribe(self): with self.assertRaises(ErrorInvalidSubscription): self.account.inbox.unsubscribe(subscription_id) # Test via folder collection - with self.account.root.tois.children.push_subscription( - callback_url='https://example.com/foo' - ) as (subscription_id, watermark): + with self.account.root.tois.children.push_subscription(callback_url="https://example.com/foo") as ( + subscription_id, + watermark, + ): self.assertIsNotNone(subscription_id) self.assertIsNotNone(watermark) with self.assertRaises(ErrorInvalidSubscription): @@ -74,7 +75,7 @@ def test_push_subscribe(self): def test_empty_folder_collection(self): self.assertEqual(FolderCollection(account=None, folders=[]).subscribe_to_pull(), None) - self.assertEqual(FolderCollection(account=None, folders=[]).subscribe_to_push('http://example.com'), None) + self.assertEqual(FolderCollection(account=None, folders=[]).subscribe_to_push("http://example.com"), None) self.assertEqual(FolderCollection(account=None, folders=[]).subscribe_to_streaming(), None) def test_streaming_subscribe(self): @@ -101,23 +102,23 @@ def test_sync_folder_hierarchy(self): list(test_folder.sync_hierarchy()) self.assertIsNotNone(test_folder.folder_sync_state) # Test non-default values - list(test_folder.sync_hierarchy(only_fields=['name'])) + list(test_folder.sync_hierarchy(only_fields=["name"])) # Test that we see a create event f1 = self.FOLDER_CLASS(parent=test_folder, name=get_random_string(8)).save() changes = list(test_folder.sync_hierarchy()) self.assertEqual(len(changes), 1) change_type, f = changes[0] - self.assertEqual(change_type, 'create') + self.assertEqual(change_type, "create") self.assertEqual(f.id, f1.id) # Test that we see an update event f1.name = get_random_string(8) - f1.save(update_fields=['name']) + f1.save(update_fields=["name"]) changes = list(test_folder.sync_hierarchy()) self.assertEqual(len(changes), 1) change_type, f = changes[0] - self.assertEqual(change_type, 'update') + self.assertEqual(change_type, "update") self.assertEqual(f.id, f1.id) # Test that we see a delete event @@ -126,23 +127,22 @@ def test_sync_folder_hierarchy(self): changes = list(test_folder.sync_hierarchy()) self.assertEqual(len(changes), 1) change_type, f = changes[0] - self.assertEqual(change_type, 'delete') + self.assertEqual(change_type, "delete") self.assertEqual(f.id, f1_id) def test_sync_folder_items(self): test_folder = self.get_test_folder().save() with self.assertRaises(TypeError) as e: - list(test_folder.sync_items(max_changes_returned='XXX')) + list(test_folder.sync_items(max_changes_returned="XXX")) self.assertEqual(e.exception.args[0], "'max_changes_returned' 'XXX' must be of type ") with self.assertRaises(ValueError) as e: list(test_folder.sync_items(max_changes_returned=-1)) self.assertEqual(e.exception.args[0], "'max_changes_returned' -1 must be a positive integer") with self.assertRaises(ValueError) as e: - list(test_folder.sync_items(sync_scope='XXX')) + list(test_folder.sync_items(sync_scope="XXX")) self.assertEqual( - e.exception.args[0], - "'sync_scope' 'XXX' must be one of ['NormalAndAssociatedItems', 'NormalItems']" + e.exception.args[0], "'sync_scope' 'XXX' must be one of ['NormalAndAssociatedItems', 'NormalItems']" ) # Test that item_sync_state is set after calling sync_hierarchy @@ -150,34 +150,34 @@ def test_sync_folder_items(self): list(test_folder.sync_items()) self.assertIsNotNone(test_folder.item_sync_state) # Test non-default values - list(test_folder.sync_items(only_fields=['subject'])) - list(test_folder.sync_items(sync_scope='NormalItems')) - list(test_folder.sync_items(ignore=[ItemId(id='AAA=')])) + list(test_folder.sync_items(only_fields=["subject"])) + list(test_folder.sync_items(sync_scope="NormalItems")) + list(test_folder.sync_items(ignore=[ItemId(id="AAA=")])) # Test that we see a create event i1 = self.get_test_item(folder=test_folder).save() changes = list(test_folder.sync_items()) self.assertEqual(len(changes), 1) change_type, i = changes[0] - self.assertEqual(change_type, 'create') + self.assertEqual(change_type, "create") self.assertEqual(i.id, i1.id) # Test that we see an update event i1.subject = get_random_string(8) - i1.save(update_fields=['subject']) + i1.save(update_fields=["subject"]) changes = list(test_folder.sync_items()) self.assertEqual(len(changes), 1) change_type, i = changes[0] - self.assertEqual(change_type, 'update') + self.assertEqual(change_type, "update") self.assertEqual(i.id, i1.id) # Test that we see a read_flag_change event i1.is_read = not i1.is_read - i1.save(update_fields=['is_read']) + i1.save(update_fields=["is_read"]) changes = list(test_folder.sync_items()) self.assertEqual(len(changes), 1) change_type, (i, read_state) = changes[0] - self.assertEqual(change_type, 'read_flag_change') + self.assertEqual(change_type, "read_flag_change") self.assertEqual(i.id, i1.id) self.assertEqual(read_state, i1.is_read) @@ -187,7 +187,7 @@ def test_sync_folder_items(self): changes = list(test_folder.sync_items()) self.assertEqual(len(changes), 1) change_type, i = changes[0] - self.assertEqual(change_type, 'delete') + self.assertEqual(change_type, "delete") self.assertEqual(i.id, i1_id) def _filter_events(self, notifications, event_cls, item_id): @@ -224,7 +224,7 @@ def test_pull_notifications(self): # Test that we see an update event i1.subject = get_random_string(8) - i1.save(update_fields=['subject']) + i1.save(update_fields=["subject"]) time.sleep(5) # For some reason, events do not trigger instantly notifications = list(test_folder.get_events(subscription_id, watermark)) modified_event, watermark = self._filter_events(notifications, ModifiedEvent, i1.id) @@ -248,9 +248,9 @@ def test_streaming_notifications(self): t1 = time.perf_counter() # Let's only wait for one notification so this test doesn't take forever. 'connection_timeout' is only # meant as a fallback. - notifications = list(test_folder.get_streaming_events( - subscription_id, connection_timeout=1, max_notifications_returned=1 - )) + notifications = list( + test_folder.get_streaming_events(subscription_id, connection_timeout=1, max_notifications_returned=1) + ) t2 = time.perf_counter() # Make sure we returned after 'max_notifications' instead of waiting for 'connection_timeout' self.assertLess(t2 - t1, 60) @@ -259,19 +259,19 @@ def test_streaming_notifications(self): # Test that we see an update event i1.subject = get_random_string(8) - i1.save(update_fields=['subject']) - notifications = list(test_folder.get_streaming_events( - subscription_id, connection_timeout=1, max_notifications_returned=1 - )) + i1.save(update_fields=["subject"]) + notifications = list( + test_folder.get_streaming_events(subscription_id, connection_timeout=1, max_notifications_returned=1) + ) modified_event, _ = self._filter_events(notifications, ModifiedEvent, i1.id) self.assertEqual(modified_event.item_id.id, i1.id) # Test that we see a delete event i1_id = i1.id i1.delete() - notifications = list(test_folder.get_streaming_events( - subscription_id, connection_timeout=1, max_notifications_returned=1 - )) + notifications = list( + test_folder.get_streaming_events(subscription_id, connection_timeout=1, max_notifications_returned=1) + ) deleted_event, _ = self._filter_events(notifications, DeletedEvent, i1_id) self.assertEqual(deleted_event.item_id.id, i1_id) @@ -294,9 +294,8 @@ def test_streaming_with_other_calls(self): # We're using one session for streaming, and have one in reserve for the following service call. self.assertEqual(self.account.protocol._session_pool.qsize(), q_size) for e in notification.events: - if isinstance(e, CreatedEvent) and e.event_type == CreatedEvent.ITEM \ - and e.item_id.id == i1.id: - test_folder.all().only('id').get(id=e.item_id.id) + if isinstance(e, CreatedEvent) and e.event_type == CreatedEvent.ITEM and e.item_id.id == i1.id: + test_folder.all().only("id").get(id=e.item_id.id) finally: self.account.protocol.decrease_poolsize() self.account.protocol._session_pool_maxsize -= 1 @@ -334,28 +333,30 @@ def test_streaming_invalid_subscription(self): # Test with bad connection_timeout with self.assertRaises(TypeError) as e: - list(test_folder.get_streaming_events('AAA-', connection_timeout='XXX', max_notifications_returned=1)) + list(test_folder.get_streaming_events("AAA-", connection_timeout="XXX", max_notifications_returned=1)) self.assertEqual(e.exception.args[0], "'connection_timeout' 'XXX' must be of type ") with self.assertRaises(ValueError) as e: - list(test_folder.get_streaming_events('AAA-', connection_timeout=-1, max_notifications_returned=1)) + list(test_folder.get_streaming_events("AAA-", connection_timeout=-1, max_notifications_returned=1)) self.assertEqual(e.exception.args[0], "'connection_timeout' -1 must be a positive integer") # Test a single bad notification with self.assertRaises(ErrorInvalidSubscription) as e: - list(test_folder.get_streaming_events('AAA-', connection_timeout=1, max_notifications_returned=1)) + list(test_folder.get_streaming_events("AAA-", connection_timeout=1, max_notifications_returned=1)) self.assertEqual(e.exception.value, "Subscription is invalid. (subscription IDs: ['AAA-'])") # Test a combination of a good and a bad notification with self.assertRaises(ErrorInvalidSubscription) as e: with test_folder.streaming_subscription() as subscription_id: self.get_test_item(folder=test_folder).save() - list(test_folder.get_streaming_events( - ('AAA-', subscription_id), connection_timeout=1, max_notifications_returned=1 - )) + list( + test_folder.get_streaming_events( + ("AAA-", subscription_id), connection_timeout=1, max_notifications_returned=1 + ) + ) self.assertEqual(e.exception.value, "Subscription is invalid. (subscription IDs: ['AAA-'])") def test_push_message_parsing(self): - xml = b'''\ + xml = b"""\ @@ -383,12 +384,18 @@ def test_push_message_parsing(self): -''' +""" ws = SendNotification(protocol=None) self.assertListEqual( list(ws.parse(xml)), - [Notification(subscription_id='XXXXX=', previous_watermark='AAAAA=', more_events=False, - events=[StatusEvent(watermark='BBBBB=')])] + [ + Notification( + subscription_id="XXXXX=", + previous_watermark="AAAAA=", + more_events=False, + events=[StatusEvent(watermark="BBBBB=")], + ) + ], ) def test_push_message_responses(self): @@ -396,10 +403,10 @@ def test_push_message_responses(self): ws = SendNotification(protocol=None) with self.assertRaises(ValueError): # Invalid status - ws.get_payload(status='XXX') + ws.get_payload(status="XXX") self.assertEqual( PrettyXmlHandler.prettify_xml(ws.ok_payload()), - b'''\ + b"""\ -''' +""", ) self.assertEqual( PrettyXmlHandler.prettify_xml(ws.unsubscribe_payload()), - b'''\ + b"""\ -''' +""", ) def test_get_streaming_events_connection_closed(self): # Test that we respect connection status - xml = b'''\ + xml = b"""\ @@ -447,7 +454,7 @@ def test_get_streaming_events_connection_closed(self): -''' +""" ws = GetStreamingEvents(account=self.account) self.assertEqual(ws.connection_status, None) list(ws.parse(xml)) @@ -456,9 +463,9 @@ def test_get_streaming_events_connection_closed(self): def test_get_streaming_events_bad_response(self): # Test special error handling in this service. It's almost impossible to trigger a ParseError through the # DocumentYielder, so we test with a SOAP message without a body element. - xml = b'''\ + xml = b"""\ -''' +""" with self.assertRaises(MalformedResponseError): list(GetStreamingEvents(account=self.account).parse(xml)) diff --git a/tests/test_items/test_tasks.py b/tests/test_items/test_tasks.py index 180e50e4..898db066 100644 --- a/tests/test_items/test_tasks.py +++ b/tests/test_items/test_tasks.py @@ -3,7 +3,7 @@ from exchangelib.folders import Tasks from exchangelib.items import Task -from exchangelib.recurrence import TaskRecurrence, DailyPattern, DailyRegeneration +from exchangelib.recurrence import DailyPattern, DailyRegeneration, TaskRecurrence from .test_basics import CommonItemTest @@ -11,7 +11,7 @@ class TasksTest(CommonItemTest): """Test Task instances and the Tasks folder.""" - TEST_FOLDER = 'tasks' + TEST_FOLDER = "tasks" FOLDER_CLASS = Tasks ITEM_CLASS = Task @@ -35,12 +35,12 @@ def test_task_validation(self): # We also reset complete date to start_date if it's before start_date self.assertEqual(task.complete_date.date(), task.start_date) - task = Task(percent_complete=Decimal('50.0'), status=Task.COMPLETED) + task = Task(percent_complete=Decimal("50.0"), status=Task.COMPLETED) task.clean() # We reset percent_complete to 100.0 if state is completed self.assertEqual(task.percent_complete, Decimal(100)) - task = Task(percent_complete=Decimal('50.0'), status=Task.NOT_STARTED) + task = Task(percent_complete=Decimal("50.0"), status=Task.NOT_STARTED) task.clean() # We reset percent_complete to 0.0 if state is not_started self.assertEqual(task.percent_complete, Decimal(0)) diff --git a/tests/test_properties.py b/tests/test_properties.py index c23a4f07..f9409654 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -1,85 +1,107 @@ from inspect import isclass from itertools import chain -from exchangelib.properties import HTMLBody, Body, Mailbox, DLMailbox, UID, ItemId -from exchangelib.fields import TextField, InvalidField, InvalidFieldForVersion +from exchangelib.extended_properties import ExternId, Flag +from exchangelib.fields import InvalidField, InvalidFieldForVersion, TextField from exchangelib.folders import Folder, RootOfHierarchy from exchangelib.indexed_properties import PhysicalAddress -from exchangelib.items import Item, BulkCreateResult -from exchangelib.properties import EWSElement, MessageHeader, Fields -from exchangelib.extended_properties import ExternId, Flag -from exchangelib.util import to_xml, TNS -from exchangelib.version import Version, EXCHANGE_2010, EXCHANGE_2013 +from exchangelib.items import BulkCreateResult, Item +from exchangelib.properties import UID, Body, DLMailbox, EWSElement, Fields, HTMLBody, ItemId, Mailbox, MessageHeader +from exchangelib.util import TNS, to_xml +from exchangelib.version import EXCHANGE_2010, EXCHANGE_2013, Version from .common import TimedTestCase class PropertiesTest(TimedTestCase): def test_ews_element_sanity(self): - from exchangelib import attachments, properties, items, folders, indexed_properties, extended_properties, \ - recurrence, settings - for module in (attachments, properties, items, folders, indexed_properties, extended_properties, recurrence, - settings): + from exchangelib import ( + attachments, + extended_properties, + folders, + indexed_properties, + items, + properties, + recurrence, + settings, + ) + + for module in ( + attachments, + properties, + items, + folders, + indexed_properties, + extended_properties, + recurrence, + settings, + ): for cls in vars(module).values(): if not isclass(cls) or not issubclass(cls, EWSElement): continue with self.subTest(cls=cls): # Make sure that we have an ELEMENT_NAME on all models - if cls != BulkCreateResult and not (cls.__doc__ and cls.__doc__.startswith('Base class ')): - self.assertIsNotNone(cls.ELEMENT_NAME, f'{cls} must have an ELEMENT_NAME') + if cls != BulkCreateResult and not (cls.__doc__ and cls.__doc__.startswith("Base class ")): + self.assertIsNotNone(cls.ELEMENT_NAME, f"{cls} must have an ELEMENT_NAME") # Assert that all FIELDS names are unique on the model. Also assert that the class defines # __slots__, that all fields are mentioned in __slots__ and that __slots__ is unique. field_names = set() - all_slots = tuple(chain(*(getattr(c, '__slots__', ()) for c in cls.__mro__))) - self.assertEqual(len(all_slots), len(set(all_slots)), - f'Model {cls}: __slots__ contains duplicates: {sorted(all_slots)}') + all_slots = tuple(chain(*(getattr(c, "__slots__", ()) for c in cls.__mro__))) + self.assertEqual( + len(all_slots), + len(set(all_slots)), + f"Model {cls}: __slots__ contains duplicates: {sorted(all_slots)}", + ) for f in cls.FIELDS: with self.subTest(f=f): - self.assertNotIn(f.name, field_names, - f'Field name {f.name!r} is not unique on model {cls.__name__!r}') - self.assertIn(f.name, all_slots, - f'Field name {f.name!r} is not in __slots__ on model {cls.__name__}') + self.assertNotIn( + f.name, field_names, f"Field name {f.name!r} is not unique on model {cls.__name__!r}" + ) + self.assertIn( + f.name, all_slots, f"Field name {f.name!r} is not in __slots__ on model {cls.__name__}" + ) field_names.add(f.name) # Finally, test that all models have a link to MSDN documentation if issubclass(cls, Folder): # We have a long list of folders subclasses. Don't require a docstring for each continue - self.assertIsNotNone(cls.__doc__, f'{cls} is missing a docstring') + self.assertIsNotNone(cls.__doc__, f"{cls} is missing a docstring") if cls in (DLMailbox, BulkCreateResult, ExternId, Flag): # Some classes are allowed to not have a link continue - if cls.__doc__.startswith('Base class '): + if cls.__doc__.startswith("Base class "): # Base classes don't have an MSDN link continue if issubclass(cls, RootOfHierarchy): # Root folders don't have an MSDN link continue # collapse multiline docstrings - docstring = ' '.join(doc.strip() for doc in cls.__doc__.split('\n')) - self.assertIn('MSDN: https://docs.microsoft.com', docstring, - f'{cls} is missing an MSDN link in the docstring') + docstring = " ".join(doc.strip() for doc in cls.__doc__.split("\n")) + self.assertIn( + "MSDN: https://docs.microsoft.com", docstring, f"{cls} is missing an MSDN link in the docstring" + ) def test_uid(self): # Test translation of calendar UIDs. See #453 self.assertEqual( - UID('261cbc18-1f65-5a0a-bd11-23b1e224cc2f'), - b'\x04\x00\x00\x00\x82\x00\xe0\x00t\xc5\xb7\x10\x1a\x82\xe0\x08\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' - b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x001\x00\x00\x00vCal-Uid\x01\x00\x00\x00' - b'261cbc18-1f65-5a0a-bd11-23b1e224cc2f\x00' + UID("261cbc18-1f65-5a0a-bd11-23b1e224cc2f"), + b"\x04\x00\x00\x00\x82\x00\xe0\x00t\xc5\xb7\x10\x1a\x82\xe0\x08\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x001\x00\x00\x00vCal-Uid\x01\x00\x00\x00" + b"261cbc18-1f65-5a0a-bd11-23b1e224cc2f\x00", ) self.assertEqual( UID.to_global_object_id( - '040000008200E00074C5B7101A82E00800000000FF7FEDCAA34CD701000000000000000010000000DA513DCB6FE1904891890D' - 'BA92380E52' + "040000008200E00074C5B7101A82E00800000000FF7FEDCAA34CD701000000000000000010000000DA513DCB6FE1904891890D" + "BA92380E52" ), - b'\x04\x00\x00\x00\x82\x00\xe0\x00t\xc5\xb7\x10\x1a\x82\xe0\x08\x00\x00\x00\x00\xff\x7f\xed\xca\xa3L\xd7' - b'\x01\x00\x00\x00\x00\x00\x00\x00\x00\x10\x00\x00\x00\xdaQ=\xcbo\xe1\x90H\x91\x89\r\xba\x928\x0eR' + b"\x04\x00\x00\x00\x82\x00\xe0\x00t\xc5\xb7\x10\x1a\x82\xe0\x08\x00\x00\x00\x00\xff\x7f\xed\xca\xa3L\xd7" + b"\x01\x00\x00\x00\x00\x00\x00\x00\x00\x10\x00\x00\x00\xdaQ=\xcbo\xe1\x90H\x91\x89\r\xba\x928\x0eR", ) def test_internet_message_headers(self): # Message headers are read-only, and an integration test is difficult because we can't reliably AND quickly # generate emails that pass through some relay server that adds headers. Create a unit test instead. - payload = b'''\ + payload = b"""\ @@ -89,21 +111,21 @@ def test_internet_message_headers(self): Contoso Mail foo@example.com -''' - headers_elem = to_xml(payload).find(f'{{{TNS}}}InternetMessageHeaders') +""" + headers_elem = to_xml(payload).find(f"{{{TNS}}}InternetMessageHeaders") headers = {} - for elem in headers_elem.findall(f'{{{TNS}}}InternetMessageHeader'): + for elem in headers_elem.findall(f"{{{TNS}}}InternetMessageHeader"): header = MessageHeader.from_xml(elem=elem, account=None) headers[header.name] = header.value self.assertDictEqual( headers, { - 'Received': 'from foo by bar', - 'DKIM-Signature': 'Hello from DKIM', - 'MIME-Version': '1.0', - 'X-Mailer': 'Contoso Mail', - 'Return-Path': 'foo@example.com', - } + "Received": "from foo by bar", + "DKIM-Signature": "Hello from DKIM", + "MIME-Version": "1.0", + "X-Mailer": "Contoso Mail", + "Return-Path": "foo@example.com", + }, ) def test_physical_address(self): @@ -115,15 +137,15 @@ def test_physical_address(self): def test_invalid_kwargs(self): with self.assertRaises(AttributeError): - Mailbox(foo='XXX') + Mailbox(foo="XXX") def test_invalid_field(self): - test_field = Item.get_field_by_fieldname(fieldname='text_body') + test_field = Item.get_field_by_fieldname(fieldname="text_body") self.assertIsInstance(test_field, TextField) - self.assertEqual(test_field.name, 'text_body') + self.assertEqual(test_field.name, "text_body") with self.assertRaises(InvalidField): - Item.get_field_by_fieldname(fieldname='xxx') + Item.get_field_by_fieldname(fieldname="xxx") Item.validate_field(field=test_field, version=Version(build=EXCHANGE_2013)) with self.assertRaises(InvalidFieldForVersion) as e: @@ -131,62 +153,62 @@ def test_invalid_field(self): self.assertEqual( e.exception.args[0], "Field 'text_body' is not supported on server version Build=14.0.0.0, API=Exchange2010, Fullname=Microsoft " - "Exchange Server 2010 (supported from: 15.0.0.0, deprecated from: None)" + "Exchange Server 2010 (supported from: 15.0.0.0, deprecated from: None)", ) def test_add_field(self): - field = TextField('foo', field_uri='bar') - Item.add_field(field, insert_after='subject') + field = TextField("foo", field_uri="bar") + Item.add_field(field, insert_after="subject") try: - self.assertEqual(Item.get_field_by_fieldname('foo'), field) + self.assertEqual(Item.get_field_by_fieldname("foo"), field) finally: Item.remove_field(field) def test_itemid_equality(self): - self.assertEqual(ItemId('X', 'Y'), ItemId('X', 'Y')) - self.assertNotEqual(ItemId('X', 'Y'), ItemId('X', 'Z')) - self.assertNotEqual(ItemId('Z', 'Y'), ItemId('X', 'Y')) - self.assertNotEqual(ItemId('X', 'Y'), ItemId('Z', 'Z')) - self.assertNotEqual(ItemId('X', 'Y'), None) + self.assertEqual(ItemId("X", "Y"), ItemId("X", "Y")) + self.assertNotEqual(ItemId("X", "Y"), ItemId("X", "Z")) + self.assertNotEqual(ItemId("Z", "Y"), ItemId("X", "Y")) + self.assertNotEqual(ItemId("X", "Y"), ItemId("Z", "Z")) + self.assertNotEqual(ItemId("X", "Y"), None) def test_mailbox(self): - mbx = Mailbox(name='XXX') + mbx = Mailbox(name="XXX") with self.assertRaises(ValueError): mbx.clean() # Must have either item_id or email_address set - mbx = Mailbox(email_address='XXX') - self.assertEqual(hash(mbx), hash('xxx')) - mbx.item_id = 'YYY' - self.assertEqual(hash(mbx), hash('YYY')) # If we have an item_id, use that for uniqueness + mbx = Mailbox(email_address="XXX") + self.assertEqual(hash(mbx), hash("xxx")) + mbx.item_id = "YYY" + self.assertEqual(hash(mbx), hash("YYY")) # If we have an item_id, use that for uniqueness def test_body(self): # Test that string formatting a Body and HTMLBody instance works and keeps the type - self.assertEqual(str(Body('foo')), 'foo') - self.assertEqual(str(Body('%s') % 'foo'), 'foo') - self.assertEqual(str(Body('{}').format('foo')), 'foo') - - self.assertIsInstance(Body('foo'), Body) - self.assertIsInstance(Body('') + 'foo', Body) - foo = Body('') - foo += 'foo' + self.assertEqual(str(Body("foo")), "foo") + self.assertEqual(str(Body("%s") % "foo"), "foo") + self.assertEqual(str(Body("{}").format("foo")), "foo") + + self.assertIsInstance(Body("foo"), Body) + self.assertIsInstance(Body("") + "foo", Body) + foo = Body("") + foo += "foo" self.assertIsInstance(foo, Body) - self.assertIsInstance(Body('%s') % 'foo', Body) - self.assertIsInstance(Body('{}').format('foo'), Body) + self.assertIsInstance(Body("%s") % "foo", Body) + self.assertIsInstance(Body("{}").format("foo"), Body) - self.assertEqual(str(HTMLBody('foo')), 'foo') - self.assertEqual(str(HTMLBody('%s') % 'foo'), 'foo') - self.assertEqual(str(HTMLBody('{}').format('foo')), 'foo') + self.assertEqual(str(HTMLBody("foo")), "foo") + self.assertEqual(str(HTMLBody("%s") % "foo"), "foo") + self.assertEqual(str(HTMLBody("{}").format("foo")), "foo") - self.assertIsInstance(HTMLBody('foo'), HTMLBody) - self.assertIsInstance(HTMLBody('') + 'foo', HTMLBody) - foo = HTMLBody('') - foo += 'foo' + self.assertIsInstance(HTMLBody("foo"), HTMLBody) + self.assertIsInstance(HTMLBody("") + "foo", HTMLBody) + foo = HTMLBody("") + foo += "foo" self.assertIsInstance(foo, HTMLBody) - self.assertIsInstance(HTMLBody('%s') % 'foo', HTMLBody) - self.assertIsInstance(HTMLBody('{}').format('foo'), HTMLBody) + self.assertIsInstance(HTMLBody("%s") % "foo", HTMLBody) + self.assertIsInstance(HTMLBody("{}").format("foo"), HTMLBody) def test_invalid_attribute(self): # For a random EWSElement subclass, test that we cannot assign an unsupported attribute - item = ItemId(id='xxx', changekey='yyy') + item = ItemId(id="xxx", changekey="yyy") with self.assertRaises(AttributeError) as e: item.invalid_attr = 123 self.assertEqual( @@ -196,16 +218,16 @@ def test_invalid_attribute(self): def test_fields(self): with self.assertRaises(ValueError) as e: Fields( - TextField(name='xxx'), - TextField(name='xxx'), + TextField(name="xxx"), + TextField(name="xxx"), ) - self.assertIn('is a duplicate', e.exception.args[0]) - fields = Fields(TextField(name='xxx'), TextField(name='yyy')) + self.assertIn("is a duplicate", e.exception.args[0]) + fields = Fields(TextField(name="xxx"), TextField(name="yyy")) self.assertEqual(fields, fields.copy()) self.assertFalse(123 in fields) with self.assertRaises(ValueError) as e: - fields.index_by_name('zzz') + fields.index_by_name("zzz") self.assertEqual(e.exception.args[0], "Unknown field name 'zzz'") with self.assertRaises(ValueError) as e: - fields.insert(0, TextField(name='xxx')) - self.assertIn('is a duplicate', e.exception.args[0]) + fields.insert(0, TextField(name="xxx")) + self.assertIn("is a duplicate", e.exception.args[0]) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index d9330c7a..774cb442 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -3,66 +3,107 @@ import pickle import socket import tempfile -from unittest.mock import Mock, patch import warnings +from unittest.mock import Mock, patch + try: import zoneinfo except ImportError: from backports import zoneinfo -from oauthlib.oauth2 import InvalidClientIdError import psutil import requests_mock +from oauthlib.oauth2 import InvalidClientIdError from exchangelib import close_connections -from exchangelib.credentials import Credentials, OAuth2Credentials, OAuth2AuthorizationCodeCredentials from exchangelib.configuration import Configuration -from exchangelib.items import CalendarItem, SEARCH_SCOPE_CHOICES -from exchangelib.errors import SessionPoolMinSizeReached, ErrorNameResolutionNoResults, ErrorAccessDenied, \ - TransportError, SessionPoolMaxSizeReached, TimezoneDefinitionInvalidForYear, RateLimitError -from exchangelib.properties import TimeZone, RoomList, FreeBusyView, AlternateId, ID_FORMATS, EWS_ID, \ - SearchableMailbox, FailedMailbox, Mailbox, DLMailbox, ItemId, MailboxData, FreeBusyViewOptions -from exchangelib.protocol import Protocol, BaseProtocol, NoVerifyHTTPAdapter, FailFast, FaultTolerance -from exchangelib.services import GetRoomLists, GetRooms, ResolveNames, GetSearchableMailboxes, \ - SetUserOofSettings, ExpandDL +from exchangelib.credentials import Credentials, OAuth2AuthorizationCodeCredentials, OAuth2Credentials +from exchangelib.errors import ( + ErrorAccessDenied, + ErrorNameResolutionNoResults, + RateLimitError, + SessionPoolMaxSizeReached, + SessionPoolMinSizeReached, + TimezoneDefinitionInvalidForYear, + TransportError, +) +from exchangelib.items import SEARCH_SCOPE_CHOICES, CalendarItem +from exchangelib.properties import ( + EWS_ID, + ID_FORMATS, + AlternateId, + DLMailbox, + FailedMailbox, + FreeBusyView, + FreeBusyViewOptions, + ItemId, + Mailbox, + MailboxData, + RoomList, + SearchableMailbox, + TimeZone, +) +from exchangelib.protocol import BaseProtocol, FailFast, FaultTolerance, NoVerifyHTTPAdapter, Protocol +from exchangelib.services import ( + ExpandDL, + GetRoomLists, + GetRooms, + GetSearchableMailboxes, + ResolveNames, + SetUserOofSettings, +) from exchangelib.settings import OofSettings from exchangelib.transport import NOAUTH, NTLM, OAUTH2 from exchangelib.util import DummyResponse -from exchangelib.version import Build, Version, EXCHANGE_2010_SP1 +from exchangelib.version import EXCHANGE_2010_SP1, Build, Version from exchangelib.winzone import CLDR_TO_MS_TIMEZONE_MAP -from .common import EWSTest, get_random_datetime_range, get_random_string, get_random_hostname, RANDOM_DATE_MIN, \ - RANDOM_DATE_MAX +from .common import ( + RANDOM_DATE_MAX, + RANDOM_DATE_MIN, + EWSTest, + get_random_datetime_range, + get_random_hostname, + get_random_string, +) class ProtocolTest(EWSTest): @staticmethod def get_test_protocol(**kwargs): - return Protocol(config=Configuration( - server=kwargs.get('server'), - service_endpoint=kwargs.get('service_endpoint', f'https://{get_random_hostname()}/Foo.asmx'), - credentials=kwargs.get('credentials', Credentials(get_random_string(8), get_random_string(8))), - auth_type=kwargs.get('auth_type', NTLM), - version=kwargs.get('version', Version(Build(15, 1))), - retry_policy=kwargs.get('retry_policy', FailFast()), - max_connections=kwargs.get('max_connections'), - )) + return Protocol( + config=Configuration( + server=kwargs.get("server"), + service_endpoint=kwargs.get("service_endpoint", f"https://{get_random_hostname()}/Foo.asmx"), + credentials=kwargs.get("credentials", Credentials(get_random_string(8), get_random_string(8))), + auth_type=kwargs.get("auth_type", NTLM), + version=kwargs.get("version", Version(Build(15, 1))), + retry_policy=kwargs.get("retry_policy", FailFast()), + max_connections=kwargs.get("max_connections"), + ) + ) def test_magic(self): p = self.get_test_protocol() - self.assertEqual(str(p), f'''\ + self.assertEqual( + str(p), + f"""\ EWS url: {p.service_endpoint} Product name: Microsoft Exchange Server 2016 EWS API version: Exchange2016 Build number: 15.1.0.0 -EWS auth: NTLM''') +EWS auth: NTLM""", + ) p.config.version = None - self.assertEqual(str(p), f'''\ + self.assertEqual( + str(p), + f"""\ EWS url: {p.service_endpoint} Product name: [unknown] EWS API version: [unknown] Build number: [unknown] -EWS auth: NTLM''') +EWS auth: NTLM""", + ) def test_close_connections_helper(self): # Just test that it doesn't break @@ -70,7 +111,7 @@ def test_close_connections_helper(self): def test_init(self): with self.assertRaises(TypeError) as e: - Protocol(config='XXX') + Protocol(config="XXX") self.assertEqual( e.exception.args[0], "'config' 'XXX' must be of type " ) @@ -98,9 +139,11 @@ def test_session(self, m): def test_protocol_instance_caching(self, m): # Verify that we get the same Protocol instance for the same combination of (endpoint, credentials) config = Configuration( - service_endpoint='https://example.com/Foo.asmx', + service_endpoint="https://example.com/Foo.asmx", credentials=Credentials(get_random_string(8), get_random_string(8)), - auth_type=NTLM, version=Version(Build(15, 1)), retry_policy=FailFast() + auth_type=NTLM, + version=Version(Build(15, 1)), + retry_policy=FailFast(), ) # Test CachingProtocol.__getitem__ with self.assertRaises(KeyError): @@ -130,16 +173,17 @@ def test_protocol_instance_caching(self, m): def test_close(self): # Don't use example.com here - it does not resolve or answer on all ISPs proc = psutil.Process() - hostname = 'httpbin.org' - ip_addresses = {info[4][0] for info in socket.getaddrinfo( - hostname, 80, socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_IP - )} + hostname = "httpbin.org" + ip_addresses = { + info[4][0] + for info in socket.getaddrinfo(hostname, 80, socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_IP) + } def conn_count(): return len([p for p in proc.connections() if p.raddr[0] in ip_addresses]) self.assertGreater(len(ip_addresses), 0) - url = f'http://{hostname}' + url = f"http://{hostname}" protocol = self.get_test_protocol(service_endpoint=url, auth_type=NOAUTH, max_connections=3) # Merely getting a session should not create connections session = protocol.get_session() @@ -211,8 +255,7 @@ def test_get_timezones(self): data = list(self.account.protocol.get_timezones()) self.assertAlmostEqual(len(list(self.account.protocol.get_timezones())), 130, delta=30, msg=data) # Test translation to TimeZone objects - for tz_definition in self.account.protocol.get_timezones( - return_full_timezone_data=True): + for tz_definition in self.account.protocol.get_timezones(return_full_timezone_data=True): try: tz = TimeZone.from_server_timezone( tz_definition=tz_definition, @@ -227,39 +270,38 @@ def test_get_free_busy_info(self): server_timezones = list(self.account.protocol.get_timezones(return_full_timezone_data=True)) start = datetime.datetime.now(tz=tz) end = datetime.datetime.now(tz=tz) + datetime.timedelta(hours=6) - accounts = [(self.account, 'Organizer', False)] + accounts = [(self.account, "Organizer", False)] with self.assertRaises(TypeError) as e: - self.account.protocol.get_free_busy_info(accounts=[(123, 'XXX', 'XXX')], start=start, end=end) + self.account.protocol.get_free_busy_info(accounts=[(123, "XXX", "XXX")], start=start, end=end) self.assertEqual( - e.exception.args[0], - "Field 'email' value 123 must be of type " + e.exception.args[0], "Field 'email' value 123 must be of type " ) with self.assertRaises(ValueError) as e: - self.account.protocol.get_free_busy_info(accounts=[(self.account, 'XXX', 'XXX')], start=start, end=end) + self.account.protocol.get_free_busy_info(accounts=[(self.account, "XXX", "XXX")], start=start, end=end) self.assertEqual( e.exception.args[0], - f"Invalid choice 'XXX' for field 'attendee_type'. Valid choices are {sorted(MailboxData.ATTENDEE_TYPES)}" + f"Invalid choice 'XXX' for field 'attendee_type'. Valid choices are {sorted(MailboxData.ATTENDEE_TYPES)}", ) with self.assertRaises(TypeError) as e: - self.account.protocol.get_free_busy_info(accounts=[(self.account, 'Organizer', 'X')], start=start, end=end) + self.account.protocol.get_free_busy_info(accounts=[(self.account, "Organizer", "X")], start=start, end=end) self.assertEqual(e.exception.args[0], "Field 'exclude_conflicts' value 'X' must be of type ") with self.assertRaises(ValueError) as e: self.account.protocol.get_free_busy_info(accounts=accounts, start=end, end=start) self.assertIn("'start' must be less than 'end'", e.exception.args[0]) with self.assertRaises(TypeError) as e: - self.account.protocol.get_free_busy_info(accounts=accounts, start=start, end=end, - merged_free_busy_interval='XXX') + self.account.protocol.get_free_busy_info( + accounts=accounts, start=start, end=end, merged_free_busy_interval="XXX" + ) self.assertEqual( - e.exception.args[0], - "Field 'merged_free_busy_interval' value 'XXX' must be of type " + e.exception.args[0], "Field 'merged_free_busy_interval' value 'XXX' must be of type " ) with self.assertRaises(ValueError) as e: - self.account.protocol.get_free_busy_info(accounts=accounts, start=start, end=end, requested_view='XXX') + self.account.protocol.get_free_busy_info(accounts=accounts, start=start, end=end, requested_view="XXX") self.assertEqual( e.exception.args[0], f"Invalid choice 'XXX' for field 'requested_view'. Valid choices are " - f"{sorted(FreeBusyViewOptions.REQUESTED_VIEWS)}" + f"{sorted(FreeBusyViewOptions.REQUESTED_VIEWS)}", ) for view_info in self.account.protocol.get_free_busy_info(accounts=accounts, start=start, end=end): @@ -270,7 +312,7 @@ def test_get_free_busy_info(self): # Test account as simple email for view_info in self.account.protocol.get_free_busy_info( - accounts=[(self.account.primary_smtp_address, 'Organizer', False)], start=start, end=end + accounts=[(self.account.primary_smtp_address, "Organizer", False)], start=start, end=end ): self.assertIsInstance(view_info, FreeBusyView) @@ -284,7 +326,7 @@ def test_get_roomlists(self): def test_get_roomlists_parsing(self): # Test static XML since server has no roomlists - xml = b'''\ + xml = b"""\ @@ -308,26 +350,25 @@ def test_get_roomlists_parsing(self): -''' +""" ws = GetRoomLists(self.account.protocol) self.assertSetEqual( - {rl.email_address for rl in ws.parse(xml)}, - {'roomlist1@example.com', 'roomlist2@example.com'} + {rl.email_address for rl in ws.parse(xml)}, {"roomlist1@example.com", "roomlist2@example.com"} ) def test_get_rooms(self): # The test server is not guaranteed to have any rooms or room lists which makes this test less useful - roomlist = RoomList(email_address='my.roomlist@example.com') + roomlist = RoomList(email_address="my.roomlist@example.com") ws = GetRooms(self.account.protocol) with self.assertRaises(ErrorNameResolutionNoResults): list(ws.call(room_list=roomlist)) # Test shortcut with self.assertRaises(ErrorNameResolutionNoResults): - list(self.account.protocol.get_rooms('my.roomlist@example.com')) + list(self.account.protocol.get_rooms("my.roomlist@example.com")) def test_get_rooms_parsing(self): # Test static XML since server has no rooms - xml = b'''\ + xml = b"""\ @@ -355,22 +396,16 @@ def test_get_rooms_parsing(self): -''' +""" ws = GetRooms(self.account.protocol) - self.assertSetEqual( - {r.email_address for r in ws.parse(xml)}, - {'room1@example.com', 'room2@example.com'} - ) + self.assertSetEqual({r.email_address for r in ws.parse(xml)}, {"room1@example.com", "room2@example.com"}) def test_resolvenames(self): with self.assertRaises(ValueError) as e: - self.account.protocol.resolve_names(names=[], search_scope='XXX') - self.assertEqual( - e.exception.args[0], - f"'search_scope' 'XXX' must be one of {sorted(SEARCH_SCOPE_CHOICES)}" - ) + self.account.protocol.resolve_names(names=[], search_scope="XXX") + self.assertEqual(e.exception.args[0], f"'search_scope' 'XXX' must be one of {sorted(SEARCH_SCOPE_CHOICES)}") with self.assertRaises(ValueError) as e: - self.account.protocol.resolve_names(names=[], shape='XXX') + self.account.protocol.resolve_names(names=[], shape="XXX") self.assertEqual( e.exception.args[0], "'contact_data_shape' 'XXX' must be one of ['AllProperties', 'Default', 'IdOnly']" ) @@ -378,59 +413,48 @@ def test_resolvenames(self): ResolveNames(protocol=self.account.protocol, chunk_size=500).call(unresolved_entries=None) self.assertEqual( e.exception.args[0], - "Chunk size 500 is too high. ResolveNames supports returning at most 100 candidates for a lookup" + "Chunk size 500 is too high. ResolveNames supports returning at most 100 candidates for a lookup", ) tmp = self.account.protocol.version self.account.protocol.config.version = Version(EXCHANGE_2010_SP1) with self.assertRaises(NotImplementedError) as e: - self.account.protocol.resolve_names(names=['xxx@example.com'], shape='IdOnly') + self.account.protocol.resolve_names(names=["xxx@example.com"], shape="IdOnly") self.account.protocol.config.version = tmp self.assertEqual( - e.exception.args[0], - "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later" + e.exception.args[0], "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later" ) + self.assertGreaterEqual(self.account.protocol.resolve_names(names=["xxx@example.com"]), []) self.assertGreaterEqual( - self.account.protocol.resolve_names(names=['xxx@example.com']), - [] + self.account.protocol.resolve_names(names=["xxx@example.com"], search_scope="ActiveDirectoryContacts"), [] ) self.assertGreaterEqual( - self.account.protocol.resolve_names(names=['xxx@example.com'], search_scope='ActiveDirectoryContacts'), - [] + self.account.protocol.resolve_names(names=["xxx@example.com"], shape="AllProperties"), [] ) self.assertGreaterEqual( - self.account.protocol.resolve_names(names=['xxx@example.com'], shape='AllProperties'), - [] - ) - self.assertGreaterEqual( - self.account.protocol.resolve_names(names=['xxx@example.com'], parent_folders=[self.account.contacts]), - [] + self.account.protocol.resolve_names(names=["xxx@example.com"], parent_folders=[self.account.contacts]), [] ) self.assertEqual( self.account.protocol.resolve_names(names=[self.account.primary_smtp_address]), - [Mailbox(email_address=self.account.primary_smtp_address)] + [Mailbox(email_address=self.account.primary_smtp_address)], ) # Test something that's not an email self.assertEqual( - self.account.protocol.resolve_names(names=['foo\\bar']), - [ErrorNameResolutionNoResults('No results were found.')] + self.account.protocol.resolve_names(names=["foo\\bar"]), + [ErrorNameResolutionNoResults("No results were found.")], ) # Test return_full_contact_data mailbox, contact = self.account.protocol.resolve_names( - names=[self.account.primary_smtp_address], - return_full_contact_data=True + names=[self.account.primary_smtp_address], return_full_contact_data=True )[0] - self.assertEqual( - mailbox, - Mailbox(email_address=self.account.primary_smtp_address) - ) + self.assertEqual(mailbox, Mailbox(email_address=self.account.primary_smtp_address)) self.assertListEqual( - [e.email.replace('SMTP:', '') for e in contact.email_addresses if e.label == 'EmailAddress1'], - [self.account.primary_smtp_address] + [e.email.replace("SMTP:", "") for e in contact.email_addresses if e.label == "EmailAddress1"], + [self.account.primary_smtp_address], ) def test_resolvenames_parsing(self): # Test static XML since server has no roomlists - xml = b'''\ + xml = b"""\ @@ -464,22 +488,19 @@ def test_resolvenames_parsing(self): -''' +""" ws = ResolveNames(self.account.protocol) ws.return_full_contact_data = False - self.assertSetEqual( - {m.email_address for m in ws.parse(xml)}, - {'anne@example.com', 'john@example.com'} - ) + self.assertSetEqual({m.email_address for m in ws.parse(xml)}, {"anne@example.com", "john@example.com"}) def test_get_searchable_mailboxes(self): # Insufficient privileges for the test account, so let's just test the exception with self.assertRaises(ErrorAccessDenied): - self.account.protocol.get_searchable_mailboxes(search_filter='non_existent_distro@example.com') + self.account.protocol.get_searchable_mailboxes(search_filter="non_existent_distro@example.com") with self.assertRaises(ErrorAccessDenied): self.account.protocol.get_searchable_mailboxes(expand_group_membership=True) - xml = b'''\ + xml = b"""\ -''' +""" ws = GetSearchableMailboxes(protocol=self.account.protocol) - self.assertListEqual(list(ws.parse(xml)), [ - SearchableMailbox( - guid='33a408fe-2574-4e3b-49f5-5e1e000a3035', - primary_smtp_address='LOLgroup@example.com', - is_external=False, - external_email=None, - display_name='LOLgroup', - is_membership_group=True, - reference_id='/o=First/ou=Exchange(FYLT)/cn=Recipients/cn=81213b958a0b5295b13b3f02b812bf1bc-LOLgroup', - ), - FailedMailbox( - mailbox='FAILgroup@example.com', - error_code=123, - error_message='Catastrophic Failure', - is_archive=True, - ), - ]) + self.assertListEqual( + list(ws.parse(xml)), + [ + SearchableMailbox( + guid="33a408fe-2574-4e3b-49f5-5e1e000a3035", + primary_smtp_address="LOLgroup@example.com", + is_external=False, + external_email=None, + display_name="LOLgroup", + is_membership_group=True, + reference_id="/o=First/ou=Exchange(FYLT)/cn=Recipients/cn=81213b958a0b5295b13b3f02b812bf1bc-LOLgroup", + ), + FailedMailbox( + mailbox="FAILgroup@example.com", + error_code=123, + error_message="Catastrophic Failure", + is_archive=True, + ), + ], + ) def test_expanddl(self): with self.assertRaises(ErrorNameResolutionNoResults): - self.account.protocol.expand_dl('non_existent_distro@example.com') + self.account.protocol.expand_dl("non_existent_distro@example.com") with self.assertRaises(ErrorNameResolutionNoResults): self.account.protocol.expand_dl( - DLMailbox(email_address='non_existent_distro@example.com', mailbox_type='PublicDL') + DLMailbox(email_address="non_existent_distro@example.com", mailbox_type="PublicDL") ) - xml = b'''\ + xml = b"""\ @@ -560,18 +584,18 @@ def test_expanddl(self): -''' +""" self.assertListEqual( list(ExpandDL(protocol=self.account.protocol).parse(xml)), [ - Mailbox(name='Foo Smith', email_address='foo@example.com'), - Mailbox(name='Bar Smith', email_address='bar@example.com'), - ] + Mailbox(name="Foo Smith", email_address="foo@example.com"), + Mailbox(name="Bar Smith", email_address="bar@example.com"), + ], ) def test_oof_settings(self): # First, ensure a common starting point - utc = zoneinfo.ZoneInfo('UTC') + utc = zoneinfo.ZoneInfo("UTC") self.account.oof_settings = OofSettings( state=OofSettings.DISABLED, start=datetime.datetime.combine(RANDOM_DATE_MIN, datetime.time.min, tzinfo=utc), @@ -580,18 +604,18 @@ def test_oof_settings(self): oof = OofSettings( state=OofSettings.ENABLED, - external_audience='None', + external_audience="None", internal_reply="I'm on holidays. See ya guys!", - external_reply='Dear Sir, your email has now been deleted.', + external_reply="Dear Sir, your email has now been deleted.", ) self.account.oof_settings = oof self.assertEqual(self.account.oof_settings, oof) oof = OofSettings( state=OofSettings.ENABLED, - external_audience='Known', - internal_reply='XXX', - external_reply='YYY', + external_audience="Known", + internal_reply="XXX", + external_reply="YYY", ) self.account.oof_settings = oof self.assertEqual(self.account.oof_settings, oof) @@ -601,7 +625,7 @@ def test_oof_settings(self): start, end = get_random_datetime_range(start_date=datetime.datetime.now(tz).date()) oof = OofSettings( state=OofSettings.SCHEDULED, - external_audience='Known', + external_audience="Known", internal_reply="I'm in the pub. See ya guys!", external_reply="I'm having a business dinner in town", start=start, @@ -616,19 +640,19 @@ def test_oof_settings(self): end=end, ) with self.assertRaises(TypeError): - self.account.oof_settings = 'XXX' + self.account.oof_settings = "XXX" with self.assertRaises(TypeError): SetUserOofSettings(account=self.account).get( oof_settings=oof, - mailbox='XXX', + mailbox="XXX", ) self.account.oof_settings = oof # TODO: For some reason, disabling OOF does not always work. Don't assert because we want a stable test suite if self.account.oof_settings != oof: - self.skipTest('Disabling OOF did not work') + self.skipTest("Disabling OOF did not work") def test_oof_settings_validation(self): - utc = zoneinfo.ZoneInfo('UTC') + utc = zoneinfo.ZoneInfo("UTC") with self.assertRaises(ValueError): # Needs a start and end OofSettings( @@ -657,24 +681,29 @@ def test_oof_settings_validation(self): ).clean(version=None) def test_convert_id(self): - i = 'AAMkADQyYzZmYmUxLTJiYjItNDg2Ny1iMzNjLTIzYWE1NDgxNmZhNABGAAAAAADUebQDarW2Q7G2Ji8hKofPBwAl9iKCsfCfSa9cmjh' \ - '+JCrCAAPJcuhjAAB0l+JSKvzBRYP+FXGewReXAABj6DrMAAA=' + i = ( + "AAMkADQyYzZmYmUxLTJiYjItNDg2Ny1iMzNjLTIzYWE1NDgxNmZhNABGAAAAAADUebQDarW2Q7G2Ji8hKofPBwAl9iKCsfCfSa9cmjh" + "+JCrCAAPJcuhjAAB0l+JSKvzBRYP+FXGewReXAABj6DrMAAA=" + ) for fmt in ID_FORMATS: - res = list(self.account.protocol.convert_ids( + res = list( + self.account.protocol.convert_ids( [AlternateId(id=i, format=EWS_ID, mailbox=self.account.primary_smtp_address)], - destination_format=fmt)) + destination_format=fmt, + ) + ) self.assertEqual(len(res), 1) self.assertEqual(res[0].format, fmt) # Test bad format with self.assertRaises(ValueError) as e: self.account.protocol.convert_ids( - [AlternateId(id=i, format=EWS_ID, mailbox=self.account.primary_smtp_address)], - destination_format='XXX') + [AlternateId(id=i, format=EWS_ID, mailbox=self.account.primary_smtp_address)], destination_format="XXX" + ) self.assertEqual(e.exception.args[0], f"'destination_format' 'XXX' must be one of {sorted(ID_FORMATS)}") # Test bad item type with self.assertRaises(TypeError) as e: - list(self.account.protocol.convert_ids([ItemId(id=1)], destination_format='EwsId')) - self.assertIn('must be of type', e.exception.args[0]) + list(self.account.protocol.convert_ids([ItemId(id=1)], destination_format="EwsId")) + self.assertIn("must be of type", e.exception.args[0]) def test_sessionpool(self): # First, empty the calendar @@ -683,7 +712,7 @@ def test_sessionpool(self): self.account.calendar.filter(start__lt=end, end__gt=start, categories__contains=self.categories).delete() items = [] for i in range(75): - subject = f'Test Subject {i}' + subject = f"Test Subject {i}" item = CalendarItem( start=start, end=end, @@ -693,15 +722,16 @@ def test_sessionpool(self): items.append(item) return_ids = self.account.calendar.bulk_create(items=items) self.assertEqual(len(return_ids), len(items)) - ids = self.account.calendar.filter(start__lt=end, end__gt=start, categories__contains=self.categories) \ - .values_list('id', 'changekey') + ids = self.account.calendar.filter( + start__lt=end, end__gt=start, categories__contains=self.categories + ).values_list("id", "changekey") self.assertEqual(ids.count(), len(items)) def test_disable_ssl_verification(self): # Test that we can make requests when SSL verification is turned off. I don't know how to mock TLS responses if not self.verify_ssl: # We can only run this test if we haven't already disabled TLS - self.skipTest('TLS verification already disabled') + self.skipTest("TLS verification already disabled") default_adapter_cls = BaseProtocol.HTTP_ADAPTER_CLS @@ -710,7 +740,8 @@ def test_disable_ssl_verification(self): # Smash TLS verification using an untrusted certificate with tempfile.NamedTemporaryFile() as f: - f.write(b'''\ + f.write( + b"""\ -----BEGIN CERTIFICATE----- MIIENzCCAx+gAwIBAgIJAOYfYfw7NCOcMA0GCSqGSIb3DQEBBQUAMIGxMQswCQYD VQQGEwJVUzERMA8GA1UECAwITWFyeWxhbmQxFDASBgNVBAcMC0ZvcmVzdCBIaWxs @@ -735,9 +766,10 @@ def test_disable_ssl_verification(self): 1Dh09xeeMnSa5zeV1HEDyJTqCXutLetwQ/IyfmMBhIx+nvB5f67pz/m+Dv6V0r3I p4HCcdnDUDGJbfqtoqsAATQQWO+WWuswB6mOhDbvPTxhRpZq6AkgWqv4S+u3M2GO r5p9FrBgavAw5bKO54C0oQKpN/5fta5l6Ws0 ------END CERTIFICATE-----''') +-----END CERTIFICATE-----""" + ) try: - os.environ['REQUESTS_CA_BUNDLE'] = f.name + os.environ["REQUESTS_CA_BUNDLE"] = f.name # Setting the credentials is just an easy way of resetting the session pool. This will let requests # pick up the new environment variable. Now the request should fail self.account.protocol.credentials = self.account.protocol.credentials @@ -746,7 +778,7 @@ def test_disable_ssl_verification(self): # Ignore ResourceWarning for unclosed socket. It does get closed. with self.assertRaises(TransportError) as e: self.account.root.all().exists() - self.assertIn('SSLError', e.exception.args[0]) + self.assertIn("SSLError", e.exception.args[0]) # Disable insecure TLS warnings with warnings.catch_warnings(): @@ -757,12 +789,12 @@ def test_disable_ssl_verification(self): self.account.root.all().exists() # Test that the custom adapter also works when validation is OK again - del os.environ['REQUESTS_CA_BUNDLE'] + del os.environ["REQUESTS_CA_BUNDLE"] self.account.protocol.credentials = self.account.protocol.credentials self.account.root.all().exists() finally: # Reset environment and connections - os.environ.pop('REQUESTS_CA_BUNDLE', None) # May already have been deleted + os.environ.pop("REQUESTS_CA_BUNDLE", None) # May already have been deleted BaseProtocol.HTTP_ADAPTER_CLS = default_adapter_cls self.account.protocol.credentials = self.account.protocol.credentials @@ -770,7 +802,7 @@ def test_del_on_error(self): # Test that __del__ can handle exceptions on close() tmp = Protocol.close protocol = self.get_test_protocol() - Protocol.close = Mock(side_effect=Exception('XXX')) + Protocol.close = Mock(side_effect=Exception("XXX")) with self.assertRaises(Exception): protocol.close() del protocol @@ -780,7 +812,10 @@ def test_del_on_error(self): def test_version_guess(self, m): protocol = self.get_test_protocol() # Test that we can get the version even on error responses - m.post(protocol.service_endpoint, status_code=200, content=b'''\ + m.post( + protocol.service_endpoint, + status_code=200, + content=b"""\ @@ -800,12 +835,16 @@ def test_version_guess(self, m): -''') +""", + ) Version.guess(protocol) self.assertEqual(protocol.version.build, Build(15, 1, 2345, 6789)) # Test exception when there are no version headers - m.post(protocol.service_endpoint, status_code=200, content=b'''\ + m.post( + protocol.service_endpoint, + status_code=200, + content=b"""\ @@ -823,35 +862,35 @@ def test_version_guess(self, m): -''') +""", + ) with self.assertRaises(TransportError) as e: Version.guess(protocol) self.assertEqual( - e.exception.args[0], - "No valid version headers found in response (ErrorNameResolutionMultipleResults('.'))" + e.exception.args[0], "No valid version headers found in response (ErrorNameResolutionMultipleResults('.'))" ) - @patch('requests.sessions.Session.post', side_effect=ConnectionResetError('XXX')) + @patch("requests.sessions.Session.post", side_effect=ConnectionResetError("XXX")) def test_get_service_authtype(self, m): with self.assertRaises(TransportError) as e: _ = self.get_test_protocol(auth_type=None).auth_type - self.assertEqual(e.exception.args[0], 'XXX') + self.assertEqual(e.exception.args[0], "XXX") with self.assertRaises(RateLimitError) as e: _ = self.get_test_protocol(auth_type=None, retry_policy=FaultTolerance(max_wait=0.5)).auth_type - self.assertEqual(e.exception.args[0], 'Max timeout reached') + self.assertEqual(e.exception.args[0], "Max timeout reached") - @patch('requests.sessions.Session.post', return_value=DummyResponse(status_code=401)) + @patch("requests.sessions.Session.post", return_value=DummyResponse(status_code=401)) def test_get_service_authtype_401(self, m): with self.assertRaises(TransportError) as e: _ = self.get_test_protocol(auth_type=None).auth_type - self.assertEqual(e.exception.args[0], 'Failed to get auth type from service') + self.assertEqual(e.exception.args[0], "Failed to get auth type from service") - @patch('requests.sessions.Session.post', return_value=DummyResponse(status_code=501)) + @patch("requests.sessions.Session.post", return_value=DummyResponse(status_code=501)) def test_get_service_authtype_501(self, m): with self.assertRaises(TransportError) as e: _ = self.get_test_protocol(auth_type=None).auth_type - self.assertEqual(e.exception.args[0], 'Failed to get auth type from service') + self.assertEqual(e.exception.args[0], "Failed to get auth type from service") def test_create_session_failure(self): protocol = self.get_test_protocol(auth_type=NOAUTH, credentials=None) @@ -868,11 +907,14 @@ def test_oauth2_session(self): # Only test failure cases until we have working OAuth2 credentials with self.assertRaises(InvalidClientIdError): self.get_test_protocol( - auth_type=OAUTH2, credentials=OAuth2Credentials('XXX', 'YYY', 'ZZZZ') + auth_type=OAUTH2, credentials=OAuth2Credentials("XXX", "YYY", "ZZZZ") ).create_session() - protocol = self.get_test_protocol(auth_type=OAUTH2, credentials=OAuth2AuthorizationCodeCredentials( - client_id='WWW', client_secret='XXX', authorization_code='YYY', access_token={'access_token': 'ZZZ'} - )) + protocol = self.get_test_protocol( + auth_type=OAUTH2, + credentials=OAuth2AuthorizationCodeCredentials( + client_id="WWW", client_secret="XXX", authorization_code="YYY", access_token={"access_token": "ZZZ"} + ), + ) session = protocol.create_session() protocol.refresh_credentials(session) diff --git a/tests/test_queryset.py b/tests/test_queryset.py index e6956a12..305ef3cb 100644 --- a/tests/test_queryset.py +++ b/tests/test_queryset.py @@ -1,9 +1,8 @@ # coding=utf-8 from collections import namedtuple -from exchangelib.folders import FolderCollection -from exchangelib.folders import Inbox -from exchangelib.queryset import QuerySet, Q +from exchangelib.folders import FolderCollection, Inbox +from exchangelib.queryset import Q, QuerySet from .common import TimedTestCase @@ -11,25 +10,23 @@ class QuerySetTest(TimedTestCase): def test_magic(self): self.assertEqual( - str( - QuerySet(folder_collection=FolderCollection(account=None, folders=[Inbox(root='XXX', name='FooBox')])) - ), - 'QuerySet(q=Q(), folders=[Inbox (FooBox)])' + str(QuerySet(folder_collection=FolderCollection(account=None, folders=[Inbox(root="XXX", name="FooBox")]))), + "QuerySet(q=Q(), folders=[Inbox (FooBox)])", ) def test_from_folder(self): - MockRoot = namedtuple('Root', ['account']) - folder = Inbox(root=MockRoot(account='XXX')) + MockRoot = namedtuple("Root", ["account"]) + folder = Inbox(root=MockRoot(account="XXX")) self.assertIsInstance(folder.all(), QuerySet) self.assertIsInstance(folder.none(), QuerySet) - self.assertIsInstance(folder.filter(subject='foo'), QuerySet) - self.assertIsInstance(folder.exclude(subject='foo'), QuerySet) + self.assertIsInstance(folder.filter(subject="foo"), QuerySet) + self.assertIsInstance(folder.exclude(subject="foo"), QuerySet) def test_queryset_copy(self): - qs = QuerySet(folder_collection=FolderCollection(account=None, folders=[Inbox(root='XXX')])) + qs = QuerySet(folder_collection=FolderCollection(account=None, folders=[Inbox(root="XXX")])) qs.q = Q() - qs.only_fields = ('a', 'b') - qs.order_fields = ('c', 'd') + qs.only_fields = ("a", "b") + qs.order_fields = ("c", "d") qs.return_format = QuerySet.NONE # Initially, immutable items have the same id() @@ -47,8 +44,8 @@ def test_queryset_copy(self): # Set the same values, forcing a new id() new_qs.q = Q() - new_qs.only_fields = ('a', 'b') - new_qs.order_fields = ('c', 'd') + new_qs.only_fields = ("a", "b") + new_qs.order_fields = ("c", "d") new_qs.return_format = QuerySet.NONE self.assertNotEqual(id(qs), id(new_qs)) @@ -61,8 +58,8 @@ def test_queryset_copy(self): # Set the new values, forcing a new id() new_qs.q = Q(foo=5) - new_qs.only_fields = ('c', 'd') - new_qs.order_fields = ('e', 'f') + new_qs.only_fields = ("c", "d") + new_qs.order_fields = ("e", "f") new_qs.return_format = QuerySet.VALUES self.assertNotEqual(id(qs), id(new_qs)) diff --git a/tests/test_recurrence.py b/tests/test_recurrence.py index 56f5540b..285efb14 100644 --- a/tests/test_recurrence.py +++ b/tests/test_recurrence.py @@ -1,9 +1,22 @@ import datetime -from exchangelib.fields import MONDAY, FEBRUARY, AUGUST, SECOND, LAST, SUNDAY, WEEKEND_DAY -from exchangelib.recurrence import Recurrence, AbsoluteYearlyPattern, RelativeYearlyPattern, AbsoluteMonthlyPattern, \ - RelativeMonthlyPattern, WeeklyPattern, DailyPattern, NoEndPattern, EndDatePattern, NumberedPattern, \ - YearlyRegeneration, MonthlyRegeneration, WeeklyRegeneration, DailyRegeneration +from exchangelib.fields import AUGUST, FEBRUARY, LAST, MONDAY, SECOND, SUNDAY, WEEKEND_DAY +from exchangelib.recurrence import ( + AbsoluteMonthlyPattern, + AbsoluteYearlyPattern, + DailyPattern, + DailyRegeneration, + EndDatePattern, + MonthlyRegeneration, + NoEndPattern, + NumberedPattern, + Recurrence, + RelativeMonthlyPattern, + RelativeYearlyPattern, + WeeklyPattern, + WeeklyRegeneration, + YearlyRegeneration, +) from .common import TimedTestCase @@ -11,53 +24,53 @@ class RecurrenceTest(TimedTestCase): def test_magic(self): pattern = AbsoluteYearlyPattern(month=FEBRUARY, day_of_month=28) - self.assertEqual(str(pattern), 'Occurs on day 28 of February') + self.assertEqual(str(pattern), "Occurs on day 28 of February") pattern = RelativeYearlyPattern(month=AUGUST, week_number=SECOND, weekday=WEEKEND_DAY) - self.assertEqual(str(pattern), 'Occurs on weekday WeekendDay in the Second week of August') + self.assertEqual(str(pattern), "Occurs on weekday WeekendDay in the Second week of August") pattern = AbsoluteMonthlyPattern(interval=3, day_of_month=31) - self.assertEqual(str(pattern), 'Occurs on day 31 of every 3 month(s)') + self.assertEqual(str(pattern), "Occurs on day 31 of every 3 month(s)") pattern = RelativeMonthlyPattern(interval=2, week_number=LAST, weekday=5) - self.assertEqual(str(pattern), 'Occurs on weekday Friday in the Last week of every 2 month(s)') + self.assertEqual(str(pattern), "Occurs on weekday Friday in the Last week of every 2 month(s)") pattern = WeeklyPattern(interval=4, weekdays=[1, 7], first_day_of_week=7) self.assertEqual( str(pattern), - 'Occurs on weekdays Monday, Sunday of every 4 week(s) where the first day of the week is Sunday' + "Occurs on weekdays Monday, Sunday of every 4 week(s) where the first day of the week is Sunday", ) pattern = WeeklyPattern(interval=4, weekdays=[MONDAY, SUNDAY], first_day_of_week=7) self.assertEqual( str(pattern), - 'Occurs on weekdays Monday, Sunday of every 4 week(s) where the first day of the week is Sunday' + "Occurs on weekdays Monday, Sunday of every 4 week(s) where the first day of the week is Sunday", ) pattern = DailyPattern(interval=6) - self.assertEqual(str(pattern), 'Occurs every 6 day(s)') + self.assertEqual(str(pattern), "Occurs every 6 day(s)") pattern = YearlyRegeneration(interval=6) - self.assertEqual(str(pattern), 'Regenerates every 6 year(s)') + self.assertEqual(str(pattern), "Regenerates every 6 year(s)") pattern = MonthlyRegeneration(interval=6) - self.assertEqual(str(pattern), 'Regenerates every 6 month(s)') + self.assertEqual(str(pattern), "Regenerates every 6 month(s)") pattern = WeeklyRegeneration(interval=6) - self.assertEqual(str(pattern), 'Regenerates every 6 week(s)') + self.assertEqual(str(pattern), "Regenerates every 6 week(s)") pattern = DailyRegeneration(interval=6) - self.assertEqual(str(pattern), 'Regenerates every 6 day(s)') + self.assertEqual(str(pattern), "Regenerates every 6 day(s)") d_start = datetime.date(2017, 9, 1) d_end = datetime.date(2017, 9, 7) boundary = NoEndPattern(start=d_start) - self.assertEqual(str(boundary), 'Starts on 2017-09-01') + self.assertEqual(str(boundary), "Starts on 2017-09-01") boundary = EndDatePattern(start=d_start, end=d_end) - self.assertEqual(str(boundary), 'Starts on 2017-09-01, ends on 2017-09-07') + self.assertEqual(str(boundary), "Starts on 2017-09-01, ends on 2017-09-07") boundary = NumberedPattern(start=d_start, number=1) - self.assertEqual(str(boundary), 'Starts on 2017-09-01 and occurs 1 time(s)') + self.assertEqual(str(boundary), "Starts on 2017-09-01 and occurs 1 time(s)") def test_validation(self): p = DailyPattern(interval=3) d_start = datetime.date(2017, 9, 1) d_end = datetime.date(2017, 9, 7) with self.assertRaises(ValueError): - Recurrence(pattern=p, boundary='foo', start='bar') # Specify *either* boundary *or* start, end and number + Recurrence(pattern=p, boundary="foo", start="bar") # Specify *either* boundary *or* start, end and number with self.assertRaises(ValueError): - Recurrence(pattern=p, start='foo', end='bar', number='baz') # number is invalid when end is present + Recurrence(pattern=p, start="foo", end="bar", number="baz") # number is invalid when end is present with self.assertRaises(ValueError): - Recurrence(pattern=p, end='bar', number='baz') # Must have start + Recurrence(pattern=p, end="bar", number="baz") # Must have start r = Recurrence(pattern=p, start=d_start) self.assertEqual(r.boundary, NoEndPattern(start=d_start)) r = Recurrence(pattern=p, start=d_start, end=d_end) diff --git a/tests/test_restriction.py b/tests/test_restriction.py index d8e13a77..0e31cae0 100644 --- a/tests/test_restriction.py +++ b/tests/test_restriction.py @@ -1,4 +1,5 @@ import datetime + try: import zoneinfo except ImportError: @@ -8,23 +9,23 @@ from exchangelib.queryset import Q from exchangelib.restriction import Restriction from exchangelib.util import xml_to_str -from exchangelib.version import Build, Version, EXCHANGE_2007 +from exchangelib.version import EXCHANGE_2007, Build, Version from .common import TimedTestCase, mock_account, mock_protocol class RestrictionTest(TimedTestCase): def test_magic(self): - self.assertEqual(str(Q()), 'Q()') + self.assertEqual(str(Q()), "Q()") def test_q(self): version = Version(build=EXCHANGE_2007) - account = mock_account(version=version, protocol=mock_protocol(version=version, service_endpoint='example.com')) + account = mock_account(version=version, protocol=mock_protocol(version=version, service_endpoint="example.com")) root = Root(account=account) - tz = zoneinfo.ZoneInfo('Europe/Copenhagen') + tz = zoneinfo.ZoneInfo("Europe/Copenhagen") start = datetime.datetime(2020, 9, 26, 8, 0, 0, tzinfo=tz) end = datetime.datetime(2020, 9, 26, 11, 0, 0, tzinfo=tz) - result = '''\ + result = """\ @@ -50,10 +51,10 @@ def test_q(self): -''' - q = Q(Q(categories__contains='FOO') | Q(categories__contains='BAR'), start__lt=end, end__gt=start) +""" + q = Q(Q(categories__contains="FOO") | Q(categories__contains="BAR"), start__lt=end, end__gt=start) r = Restriction(q, folders=[Calendar(root=root)], applies_to=Restriction.ITEMS) - self.assertEqual(str(r), ''.join(s.lstrip() for s in result.split('\n'))) + self.assertEqual(str(r), "".join(s.lstrip() for s in result.split("\n"))) # Test empty Q q = Q() self.assertEqual(q.to_xml(folders=[Calendar()], version=version, applies_to=Restriction.ITEMS), None) @@ -75,18 +76,18 @@ def test_q(self): def test_q_expr(self): self.assertEqual(Q().expr(), None) self.assertEqual((~Q()).expr(), None) - self.assertEqual(Q(x=5).expr(), 'x == 5') - self.assertEqual((~Q(x=5)).expr(), 'x != 5') - q = (Q(b__contains='a', x__contains=5) | Q(~Q(a__contains='c'), f__gt=3, c=6)) & ~Q(y=9, z__contains='b') + self.assertEqual(Q(x=5).expr(), "x == 5") + self.assertEqual((~Q(x=5)).expr(), "x != 5") + q = (Q(b__contains="a", x__contains=5) | Q(~Q(a__contains="c"), f__gt=3, c=6)) & ~Q(y=9, z__contains="b") self.assertEqual( str(q), # str() calls expr() "((b contains 'a' AND x contains 5) OR (NOT a contains 'c' AND c == 6 AND f > 3)) " - "AND NOT (y == 9 AND z contains 'b')" + "AND NOT (y == 9 AND z contains 'b')", ) self.assertEqual( repr(q), "Q('AND', Q('OR', Q('AND', Q(b contains 'a'), Q(x contains 5)), Q('AND', Q('NOT', Q(a contains 'c')), " - "Q(c == 6), Q(f > 3))), Q('NOT', Q('AND', Q(y == 9), Q(z contains 'b'))))" + "Q(c == 6), Q(f > 3))), Q('NOT', Q('AND', Q(y == 9), Q(z contains 'b'))))", ) # Test simulated IN expression in_q = Q(foo__in=[1, 2, 3]) @@ -95,7 +96,7 @@ def test_q_expr(self): def test_q_inversion(self): version = Version(build=EXCHANGE_2007) - account = mock_account(version=version, protocol=mock_protocol(version=version, service_endpoint='example.com')) + account = mock_account(version=version, protocol=mock_protocol(version=version, service_endpoint="example.com")) root = Root(account=account) self.assertEqual((~Q(foo=5)).op, Q.NE) self.assertEqual((~Q(foo__not=5)).op, Q.EQ) @@ -104,12 +105,12 @@ def test_q_inversion(self): self.assertEqual((~Q(foo__gt=5)).op, Q.LTE) self.assertEqual((~Q(foo__gte=5)).op, Q.LT) # Test not not Q on a non-leaf - self.assertEqual(Q(foo__contains=('bar', 'baz')).conn_type, Q.AND) - self.assertEqual((~Q(foo__contains=('bar', 'baz'))).conn_type, Q.NOT) - self.assertEqual((~~Q(foo__contains=('bar', 'baz'))).conn_type, Q.AND) - self.assertEqual(Q(foo__contains=('bar', 'baz')), ~~Q(foo__contains=('bar', 'baz'))) + self.assertEqual(Q(foo__contains=("bar", "baz")).conn_type, Q.AND) + self.assertEqual((~Q(foo__contains=("bar", "baz"))).conn_type, Q.NOT) + self.assertEqual((~~Q(foo__contains=("bar", "baz"))).conn_type, Q.AND) + self.assertEqual(Q(foo__contains=("bar", "baz")), ~~Q(foo__contains=("bar", "baz"))) # Test generated XML of 'Not' statement when there is only one child. Skip 't:And' between 't:Not' and 't:Or'. - result = '''\ + result = """\ @@ -127,11 +128,11 @@ def test_q_inversion(self): -''' - q = ~(Q(subject='bar') | Q(subject='baz')) +""" + q = ~(Q(subject="bar") | Q(subject="baz")) self.assertEqual( xml_to_str(q.to_xml(folders=[Calendar(root=root)], version=version, applies_to=Restriction.ITEMS)), - ''.join(s.lstrip() for s in result.split('\n')) + "".join(s.lstrip() for s in result.split("\n")), ) def test_q_boolean_ops(self): @@ -153,23 +154,23 @@ def test_q_never(self): self.assertTrue(~Q(foo__in=[]).is_empty()) # Negation should translate to a no-op # Test in combination with AND and OR - self.assertEqual(Q(foo__in=[], bar='baz'), Q(conn_type=Q.NEVER)) # NEVER removes all other args - self.assertEqual(Q(foo__in=[]) & Q(bar='baz'), Q(conn_type=Q.NEVER)) # NEVER removes all other args - self.assertEqual(Q(foo__in=[]) | Q(bar='baz'), Q(bar='baz')) # OR removes all 'never' args + self.assertEqual(Q(foo__in=[], bar="baz"), Q(conn_type=Q.NEVER)) # NEVER removes all other args + self.assertEqual(Q(foo__in=[]) & Q(bar="baz"), Q(conn_type=Q.NEVER)) # NEVER removes all other args + self.assertEqual(Q(foo__in=[]) | Q(bar="baz"), Q(bar="baz")) # OR removes all 'never' args def test_q_simplification(self): - self.assertEqual(Q(foo='bar') & Q(), Q(foo='bar')) - self.assertEqual(Q() & Q(foo='bar'), Q(foo='bar')) + self.assertEqual(Q(foo="bar") & Q(), Q(foo="bar")) + self.assertEqual(Q() & Q(foo="bar"), Q(foo="bar")) - self.assertEqual(Q('foo') & Q(), Q('foo')) - self.assertEqual(Q() & Q('foo'), Q('foo')) + self.assertEqual(Q("foo") & Q(), Q("foo")) + self.assertEqual(Q() & Q("foo"), Q("foo")) def test_q_querystring(self): - self.assertEqual(Q('this is a QS').expr(), 'this is a QS') - self.assertEqual(Q(Q('this is a QS')), Q('this is a QS')) - self.assertEqual(Q(Q(Q(Q('this is a QS')))), Q('this is a QS')) + self.assertEqual(Q("this is a QS").expr(), "this is a QS") + self.assertEqual(Q(Q("this is a QS")), Q("this is a QS")) + self.assertEqual(Q(Q(Q(Q("this is a QS")))), Q("this is a QS")) with self.assertRaises(ValueError): - Q('this is a QS') & Q(foo='bar') + Q("this is a QS") & Q(foo="bar") with self.assertRaises(TypeError): Q(5) diff --git a/tests/test_services.py b/tests/test_services.py index e781828e..405da91b 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1,33 +1,44 @@ -import requests_mock from unittest.mock import Mock -from exchangelib.errors import ErrorServerBusy, ErrorNonExistentMailbox, TransportError, MalformedResponseError, \ - ErrorInvalidServerVersion, ErrorTooManyObjectsOpened, SOAPError, ErrorExceededConnectionCount, \ - ErrorInternalServerError, ErrorInvalidValueForProperty, ErrorSchemaValidation +import requests_mock + +from exchangelib.errors import ( + ErrorExceededConnectionCount, + ErrorInternalServerError, + ErrorInvalidServerVersion, + ErrorInvalidValueForProperty, + ErrorNonExistentMailbox, + ErrorSchemaValidation, + ErrorServerBusy, + ErrorTooManyObjectsOpened, + MalformedResponseError, + SOAPError, + TransportError, +) from exchangelib.folders import FolderCollection -from exchangelib.protocol import FaultTolerance, FailFast -from exchangelib.services import GetServerTimeZones, GetRoomLists, GetRooms, ResolveNames, FindFolder, DeleteItem +from exchangelib.protocol import FailFast, FaultTolerance +from exchangelib.services import DeleteItem, FindFolder, GetRoomLists, GetRooms, GetServerTimeZones, ResolveNames from exchangelib.util import create_element from exchangelib.version import EXCHANGE_2007, EXCHANGE_2010 -from .common import EWSTest, mock_protocol, mock_version, mock_account, get_random_string +from .common import EWSTest, get_random_string, mock_account, mock_protocol, mock_version class ServicesTest(EWSTest): def test_invalid_server_version(self): # Test that we get a client-side error if we call a service that was only implemented in a later version version = mock_version(build=EXCHANGE_2007) - account = mock_account(version=version, protocol=mock_protocol(version=version, service_endpoint='example.com')) + account = mock_account(version=version, protocol=mock_protocol(version=version, service_endpoint="example.com")) with self.assertRaises(NotImplementedError): list(GetServerTimeZones(protocol=account.protocol).call()) with self.assertRaises(NotImplementedError): list(GetRoomLists(protocol=account.protocol).call()) with self.assertRaises(NotImplementedError): - list(GetRooms(protocol=account.protocol).call('XXX')) + list(GetRooms(protocol=account.protocol).call("XXX")) def test_inner_error_parsing(self): # Test that we can parse an exception response via SOAP body - xml = b'''\ + xml = b"""\ @@ -50,19 +61,19 @@ def test_inner_error_parsing(self): -''' +""" ws = DeleteItem(account=self.account) with self.assertRaises(ErrorInternalServerError) as e: list(ws.parse(xml)) self.assertEqual( e.exception.args[0], "An internal server error occurred. The operation failed. (inner error: " - "ErrorQuotaExceededOnDelete('Cannot delete message because the folder is out of quota.'))" + "ErrorQuotaExceededOnDelete('Cannot delete message because the folder is out of quota.'))", ) def test_invalid_value_extras(self): # Test that we can parse an exception response via SOAP body - xml = b'''\ + xml = b"""\ @@ -84,7 +95,7 @@ def test_invalid_value_extras(self): -''' +""" ws = DeleteItem(account=self.account) with self.assertRaises(ErrorInvalidValueForProperty) as e: list(ws.parse(xml)) @@ -92,7 +103,7 @@ def test_invalid_value_extras(self): def test_error_server_busy(self): # Test that we can parse an exception response via SOAP body - xml = b'''\ + xml = b"""\ @@ -112,16 +123,16 @@ def test_error_server_busy(self): -''' +""" version = mock_version(build=EXCHANGE_2010) - ws = GetRoomLists(mock_protocol(version=version, service_endpoint='example.com')) + ws = GetRoomLists(mock_protocol(version=version, service_endpoint="example.com")) with self.assertRaises(ErrorServerBusy) as e: ws.parse(xml) self.assertEqual(e.exception.back_off, 297.749) # Test that we correctly parse the BackOffMilliseconds value def test_error_schema_validation(self): # Test that we can parse extra info with ErrorSchemaValidation - xml = b'''\ + xml = b"""\ @@ -141,21 +152,21 @@ def test_error_schema_validation(self): -''' +""" version = mock_version(build=EXCHANGE_2010) - ws = GetRoomLists(mock_protocol(version=version, service_endpoint='example.com')) + ws = GetRoomLists(mock_protocol(version=version, service_endpoint="example.com")) with self.assertRaises(ErrorSchemaValidation) as e: ws.parse(xml) - self.assertEqual(e.exception.args[0], 'YYY ZZZ (line: 123 position: 456)') + self.assertEqual(e.exception.args[0], "YYY ZZZ (line: 123 position: 456)") @requests_mock.mock(real_http=True) def test_error_too_many_objects_opened(self, m): # Test that we can parse ErrorTooManyObjectsOpened via ResponseMessage and return version = mock_version(build=EXCHANGE_2010) - protocol = mock_protocol(version=version, service_endpoint='example.com') + protocol = mock_protocol(version=version, service_endpoint="example.com") account = mock_account(version=version, protocol=protocol) ws = FindFolder(account=account) - xml = b'''\ + xml = b"""\ -''' +""" # Just test that we can parse the error with self.assertRaises(ErrorTooManyObjectsOpened): list(ws.parse(xml)) @@ -189,7 +200,7 @@ def test_error_too_many_objects_opened(self, m): self.account.protocol.config.retry_policy = orig_policy def test_soap_error(self): - xml_template = '''\ + xml_template = """\ @@ -203,33 +214,31 @@ def test_soap_error(self): -''' +""" version = mock_version(build=EXCHANGE_2010) - protocol = mock_protocol(version=version, service_endpoint='example.com') + protocol = mock_protocol(version=version, service_endpoint="example.com") ws = GetRoomLists(protocol=protocol) - xml = xml_template.format( - faultcode='YYY', faultstring='AAA', responsecode='XXX', message='ZZZ' - ).encode('utf-8') + xml = xml_template.format(faultcode="YYY", faultstring="AAA", responsecode="XXX", message="ZZZ").encode("utf-8") with self.assertRaises(SOAPError) as e: ws.parse(xml) - self.assertIn('AAA', e.exception.args[0]) - self.assertIn('YYY', e.exception.args[0]) - self.assertIn('ZZZ', e.exception.args[0]) + self.assertIn("AAA", e.exception.args[0]) + self.assertIn("YYY", e.exception.args[0]) + self.assertIn("ZZZ", e.exception.args[0]) xml = xml_template.format( - faultcode='ErrorNonExistentMailbox', faultstring='AAA', responsecode='XXX', message='ZZZ' - ).encode('utf-8') + faultcode="ErrorNonExistentMailbox", faultstring="AAA", responsecode="XXX", message="ZZZ" + ).encode("utf-8") with self.assertRaises(ErrorNonExistentMailbox) as e: ws.parse(xml) - self.assertIn('AAA', e.exception.args[0]) + self.assertIn("AAA", e.exception.args[0]) xml = xml_template.format( - faultcode='XXX', faultstring='AAA', responsecode='ErrorNonExistentMailbox', message='YYY' - ).encode('utf-8') + faultcode="XXX", faultstring="AAA", responsecode="ErrorNonExistentMailbox", message="YYY" + ).encode("utf-8") with self.assertRaises(ErrorNonExistentMailbox) as e: ws.parse(xml) - self.assertIn('YYY', e.exception.args[0]) + self.assertIn("YYY", e.exception.args[0]) # Test bad XML (no body) - xml = b'''\ + xml = b"""\ @@ -237,12 +246,12 @@ def test_soap_error(self): xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types" /> -''' +""" with self.assertRaises(MalformedResponseError): ws.parse(xml) # Test bad XML (no fault) - xml = b'''\ + xml = b"""\ @@ -253,14 +262,14 @@ def test_soap_error(self): -''' +""" with self.assertRaises(SOAPError) as e: ws.parse(xml) - self.assertEqual(e.exception.args[0], 'SOAP error code: None string: None actor: None detail: None') + self.assertEqual(e.exception.args[0], "SOAP error code: None string: None actor: None detail: None") def test_element_container(self): ws = ResolveNames(self.account.protocol) - xml = b'''\ + xml = b"""\ @@ -272,11 +281,11 @@ def test_element_container(self): -''' +""" with self.assertRaises(TransportError) as e: # Missing ResolutionSet elements list(ws.parse(xml)) - self.assertIn('ResolutionSet elements in ResponseMessage', e.exception.args[0]) + self.assertIn("ResolutionSet elements in ResponseMessage", e.exception.args[0]) def test_get_elements(self): # Test that we can handle SOAP-level error messages @@ -284,7 +293,7 @@ def test_get_elements(self): # end up throwing ErrorInvalidServerVersion. We should make a more direct test. svc = ResolveNames(self.account.protocol) with self.assertRaises(ErrorInvalidServerVersion): - list(svc._get_elements(create_element('XXX'))) + list(svc._get_elements(create_element("XXX"))) def test_handle_backoff(self): # Test that we can handle backoff messages @@ -294,10 +303,10 @@ def test_handle_backoff(self): try: # We need to fail fast so we don't end up in an infinite loop self.account.protocol.config.retry_policy = FailFast() - svc._response_generator = Mock(side_effect=ErrorServerBusy('XXX', back_off=1)) + svc._response_generator = Mock(side_effect=ErrorServerBusy("XXX", back_off=1)) with self.assertRaises(ErrorServerBusy) as e: - list(svc._get_elements(create_element('XXX'))) - self.assertEqual(e.exception.args[0], 'XXX') + list(svc._get_elements(create_element("XXX"))) + self.assertEqual(e.exception.args[0], "XXX") finally: svc._response_generator = tmp self.account.protocol.config.retry_policy = orig_policy @@ -308,16 +317,16 @@ def test_exceeded_connection_count(self): tmp = svc._get_soap_messages try: # We need to fail fast so we don't end up in an infinite loop - svc._get_soap_messages = Mock(side_effect=ErrorExceededConnectionCount('XXX')) + svc._get_soap_messages = Mock(side_effect=ErrorExceededConnectionCount("XXX")) with self.assertRaises(ErrorExceededConnectionCount) as e: - list(svc.call(unresolved_entries=['XXX'])) - self.assertEqual(e.exception.args[0], 'XXX') + list(svc.call(unresolved_entries=["XXX"])) + self.assertEqual(e.exception.args[0], "XXX") finally: svc._get_soap_messages = tmp @requests_mock.mock() def test_invalid_soap_response(self, m): - m.post(self.account.protocol.service_endpoint, text='XXX') + m.post(self.account.protocol.service_endpoint, text="XXX") with self.assertRaises(SOAPError): self.account.inbox.all().count() @@ -326,7 +335,7 @@ def test_version_renegotiate(self): # autodiscover response returns a wrong server version for the account old_version = self.account.version.api_version try: - self.account.version.api_version = 'Exchange2016' # Newer EWS versions require a valid value + self.account.version.api_version = "Exchange2016" # Newer EWS versions require a valid value list(self.account.inbox.filter(subject=get_random_string(16))) self.assertEqual(old_version, self.account.version.api_version) finally: diff --git a/tests/test_source.py b/tests/test_source.py index 188a7a9a..1fdadaa7 100644 --- a/tests/test_source.py +++ b/tests/test_source.py @@ -1,21 +1,13 @@ -import flake8.defaults -import flake8.main.application - -from exchangelib.errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorItemNotFound, ErrorInvalidOperation, \ - ErrorNoPublicFolderReplicaAvailable +from exchangelib.errors import ( + ErrorAccessDenied, + ErrorFolderNotFound, + ErrorInvalidOperation, + ErrorItemNotFound, + ErrorNoPublicFolderReplicaAvailable, +) from exchangelib.properties import EWSElement -from .common import EWSTest, TimedTestCase - - -class StyleTest(TimedTestCase): - def test_flake8(self): - import exchangelib - flake8.defaults.MAX_LINE_LENGTH = 120 - app = flake8.main.application.Application() - app.run(exchangelib.__path__ + ['-j', '0']) # Multiprocessing doesn't work with parallel tests runners - # If this fails, look at stdout for actual error messages - self.assertEqual(app.result_count, 0) +from .common import EWSTest class CommonTest(EWSTest): @@ -25,62 +17,67 @@ def test_magic(self): self.assertIn(self.account.primary_smtp_address, str(self.account)) self.assertIn(str(self.account.version.build.major_version), repr(self.account.version)) for item in ( - self.account.protocol, - self.account.version, + self.account.protocol, + self.account.version, ): with self.subTest(item=item): # Just test that these at least don't throw errors repr(item) str(item) for attr in ( - 'admin_audit_logs', - 'archive_deleted_items', - 'archive_inbox', - 'archive_msg_folder_root', - 'archive_recoverable_items_deletions', - 'archive_recoverable_items_purges', - 'archive_recoverable_items_root', - 'archive_recoverable_items_versions', - 'archive_root', - 'calendar', - 'conflicts', - 'contacts', - 'conversation_history', - 'directory', - 'drafts', - 'favorites', - 'im_contact_list', - 'inbox', - 'journal', - 'junk', - 'local_failures', - 'msg_folder_root', - 'my_contacts', - 'notes', - 'outbox', - 'people_connect', - 'public_folders_root', - 'quick_contacts', - 'recipient_cache', - 'recoverable_items_deletions', - 'recoverable_items_purges', - 'recoverable_items_root', - 'recoverable_items_versions', - 'search_folders', - 'sent', - 'server_failures', - 'sync_issues', - 'tasks', - 'todo_search', - 'trash', - 'voice_mail', + "admin_audit_logs", + "archive_deleted_items", + "archive_inbox", + "archive_msg_folder_root", + "archive_recoverable_items_deletions", + "archive_recoverable_items_purges", + "archive_recoverable_items_root", + "archive_recoverable_items_versions", + "archive_root", + "calendar", + "conflicts", + "contacts", + "conversation_history", + "directory", + "drafts", + "favorites", + "im_contact_list", + "inbox", + "journal", + "junk", + "local_failures", + "msg_folder_root", + "my_contacts", + "notes", + "outbox", + "people_connect", + "public_folders_root", + "quick_contacts", + "recipient_cache", + "recoverable_items_deletions", + "recoverable_items_purges", + "recoverable_items_root", + "recoverable_items_versions", + "search_folders", + "sent", + "server_failures", + "sync_issues", + "tasks", + "todo_search", + "trash", + "voice_mail", ): with self.subTest(attr=attr): # Test distinguished folder shortcuts. Some may raise ErrorAccessDenied try: item = getattr(self.account, attr) - except (ErrorAccessDenied, ErrorFolderNotFound, ErrorItemNotFound, ErrorInvalidOperation, - ErrorNoPublicFolderReplicaAvailable): + except ( + ErrorAccessDenied, + ErrorFolderNotFound, + ErrorItemNotFound, + ErrorInvalidOperation, + ErrorNoPublicFolderReplicaAvailable, + ): continue else: repr(item) @@ -90,8 +87,15 @@ def test_magic(self): def test_from_xml(self): # Test for all EWSElement classes that they handle None as input to from_xml() import exchangelib - for mod in (exchangelib.attachments, exchangelib.extended_properties, exchangelib.indexed_properties, - exchangelib.folders, exchangelib.items, exchangelib.properties): + + for mod in ( + exchangelib.attachments, + exchangelib.extended_properties, + exchangelib.indexed_properties, + exchangelib.folders, + exchangelib.items, + exchangelib.properties, + ): for k, v in vars(mod).items(): with self.subTest(k=k, v=v): if type(v) is not type: diff --git a/tests/test_transport.py b/tests/test_transport.py index 0293c42f..4862eaae 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -3,9 +3,9 @@ import requests import requests_mock -from exchangelib.account import Identity, DELEGATE +from exchangelib.account import DELEGATE, Identity from exchangelib.errors import UnauthorizedError -from exchangelib.transport import wrap, get_auth_method_from_response, BASIC, NOAUTH, NTLM, DIGEST +from exchangelib.transport import BASIC, DIGEST, NOAUTH, NTLM, get_auth_method_from_response, wrap from exchangelib.util import PrettyXmlHandler, create_element from .common import TimedTestCase @@ -14,87 +14,89 @@ class TransportTest(TimedTestCase): @requests_mock.mock() def test_get_auth_method_from_response(self, m): - url = 'http://example.com/noauth' + url = "http://example.com/noauth" m.get(url, status_code=200) r = requests.get(url) self.assertEqual(get_auth_method_from_response(r), NOAUTH) # No authentication needed - url = 'http://example.com/redirect' - m.get(url, status_code=302, headers={'location': 'http://contoso.com'}) + url = "http://example.com/redirect" + m.get(url, status_code=302, headers={"location": "http://contoso.com"}) r = requests.get(url, allow_redirects=False) with self.assertRaises(UnauthorizedError): get_auth_method_from_response(r) # Redirect to another host - url = 'http://example.com/relativeredirect' - m.get(url, status_code=302, headers={'location': 'http://example.com/'}) + url = "http://example.com/relativeredirect" + m.get(url, status_code=302, headers={"location": "http://example.com/"}) r = requests.get(url, allow_redirects=False) with self.assertRaises(UnauthorizedError): get_auth_method_from_response(r) # Redirect to same host - url = 'http://example.com/internalerror' + url = "http://example.com/internalerror" m.get(url, status_code=501) r = requests.get(url) with self.assertRaises(UnauthorizedError): get_auth_method_from_response(r) # Non-401 status code - url = 'http://example.com/no_auth_headers' + url = "http://example.com/no_auth_headers" m.get(url, status_code=401) r = requests.get(url) with self.assertRaises(UnauthorizedError): get_auth_method_from_response(r) # 401 status code but no auth headers - url = 'http://example.com/no_supported_auth' - m.get(url, status_code=401, headers={'WWW-Authenticate': 'FANCYAUTH'}) + url = "http://example.com/no_supported_auth" + m.get(url, status_code=401, headers={"WWW-Authenticate": "FANCYAUTH"}) r = requests.get(url) with self.assertRaises(UnauthorizedError): get_auth_method_from_response(r) # 401 status code but no auth headers - url = 'http://example.com/basic_auth' - m.get(url, status_code=401, headers={'WWW-Authenticate': 'Basic'}) + url = "http://example.com/basic_auth" + m.get(url, status_code=401, headers={"WWW-Authenticate": "Basic"}) r = requests.get(url) self.assertEqual(get_auth_method_from_response(r), BASIC) - url = 'http://example.com/basic_auth_empty_realm' - m.get(url, status_code=401, headers={'WWW-Authenticate': 'Basic realm=""'}) + url = "http://example.com/basic_auth_empty_realm" + m.get(url, status_code=401, headers={"WWW-Authenticate": 'Basic realm=""'}) r = requests.get(url) self.assertEqual(get_auth_method_from_response(r), BASIC) - url = 'http://example.com/basic_auth_realm' - m.get(url, status_code=401, headers={'WWW-Authenticate': 'Basic realm="some realm"'}) + url = "http://example.com/basic_auth_realm" + m.get(url, status_code=401, headers={"WWW-Authenticate": 'Basic realm="some realm"'}) r = requests.get(url) self.assertEqual(get_auth_method_from_response(r), BASIC) - url = 'http://example.com/digest' - m.get(url, status_code=401, headers={ - 'WWW-Authenticate': 'Digest realm="foo@bar.com", qop="auth,auth-int", nonce="mumble", opaque="bumble"' - }) + url = "http://example.com/digest" + m.get( + url, + status_code=401, + headers={ + "WWW-Authenticate": 'Digest realm="foo@bar.com", qop="auth,auth-int", nonce="mumble", opaque="bumble"' + }, + ) r = requests.get(url) self.assertEqual(get_auth_method_from_response(r), DIGEST) - url = 'http://example.com/ntlm' - m.get(url, status_code=401, headers={'WWW-Authenticate': 'NTLM'}) + url = "http://example.com/ntlm" + m.get(url, status_code=401, headers={"WWW-Authenticate": "NTLM"}) r = requests.get(url) self.assertEqual(get_auth_method_from_response(r), NTLM) # Make sure we prefer the most secure auth method if multiple methods are supported - url = 'http://example.com/mixed' - m.get(url, status_code=401, headers={'WWW-Authenticate': 'Basic realm="X1", Digest realm="X2", NTLM'}) + url = "http://example.com/mixed" + m.get(url, status_code=401, headers={"WWW-Authenticate": 'Basic realm="X1", Digest realm="X2", NTLM'}) r = requests.get(url) self.assertEqual(get_auth_method_from_response(r), DIGEST) def test_wrap(self): # Test payload wrapper with both delegation, impersonation and timezones - MockTZ = namedtuple('EWSTimeZone', ['ms_id']) - MockAccount = namedtuple( - 'Account', ['access_type', 'identity', 'default_timezone'] - ) - content = create_element('AAA') - api_version = 'BBB' - account = MockAccount(access_type=DELEGATE, identity=None, default_timezone=MockTZ('XXX')) + MockTZ = namedtuple("EWSTimeZone", ["ms_id"]) + MockAccount = namedtuple("Account", ["access_type", "identity", "default_timezone"]) + content = create_element("AAA") + api_version = "BBB" + account = MockAccount(access_type=DELEGATE, identity=None, default_timezone=MockTZ("XXX")) wrapped = wrap(content=content, api_version=api_version, timezone=account.default_timezone) self.assertEqual( PrettyXmlHandler.prettify_xml(wrapped), - b''' + b""" -''') +""", + ) for attr, tag in ( - ('primary_smtp_address', 'PrimarySmtpAddress'), - ('upn', 'PrincipalName'), - ('sid', 'SID'), - ('smtp_address', 'SmtpAddress'), + ("primary_smtp_address", "PrimarySmtpAddress"), + ("upn", "PrincipalName"), + ("sid", "SID"), + ("smtp_address", "SmtpAddress"), ): - val = f'{attr}@example.com' + val = f"{attr}@example.com" account = MockAccount( - access_type=DELEGATE, identity=Identity(**{attr: val}), default_timezone=MockTZ('XXX') + access_type=DELEGATE, identity=Identity(**{attr: val}), default_timezone=MockTZ("XXX") ) wrapped = wrap( content=content, @@ -128,7 +131,7 @@ def test_wrap(self): ) self.assertEqual( PrettyXmlHandler.prettify_xml(wrapped), - f''' + f""" -'''.encode()) +""".encode(), + ) diff --git a/tests/test_util.py b/tests/test_util.py index 1661b974..aed59e6b 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,18 +1,38 @@ import io -from itertools import chain import logging +from itertools import chain from unittest.mock import patch import requests import requests_mock -from exchangelib.errors import RelativeRedirect, TransportError, RateLimitError, RedirectError, UnauthorizedError,\ - CASError -from exchangelib.protocol import FailFast, FaultTolerance import exchangelib.util -from exchangelib.util import chunkify, peek, get_redirect_url, get_domain, PrettyXmlHandler, to_xml, BOM_UTF8, \ - ParseError, post_ratelimited, safe_b64decode, CONNECTION_ERRORS, DocumentYielder, is_xml, xml_to_str, \ - AnonymizingXmlHandler +from exchangelib.errors import ( + CASError, + RateLimitError, + RedirectError, + RelativeRedirect, + TransportError, + UnauthorizedError, +) +from exchangelib.protocol import FailFast, FaultTolerance +from exchangelib.util import ( + BOM_UTF8, + CONNECTION_ERRORS, + AnonymizingXmlHandler, + DocumentYielder, + ParseError, + PrettyXmlHandler, + chunkify, + get_domain, + get_redirect_url, + is_xml, + peek, + post_ratelimited, + safe_b64decode, + to_xml, + xml_to_str, +) from .common import EWSTest, mock_post, mock_session_exception @@ -82,85 +102,92 @@ def test_peek(self): @requests_mock.mock() def test_get_redirect_url(self, m): - hostname = 'httpbin.org' - url = f'https://{hostname}/redirect-to' - m.get(url, status_code=302, headers={'location': 'https://example.com/'}) - r = requests.get(f'{url}?url=https://example.com/', allow_redirects=False) - self.assertEqual(get_redirect_url(r), 'https://example.com/') + hostname = "httpbin.org" + url = f"https://{hostname}/redirect-to" + m.get(url, status_code=302, headers={"location": "https://example.com/"}) + r = requests.get(f"{url}?url=https://example.com/", allow_redirects=False) + self.assertEqual(get_redirect_url(r), "https://example.com/") - m.get(url, status_code=302, headers={'location': 'http://example.com/'}) - r = requests.get(f'{url}?url=http://example.com/', allow_redirects=False) - self.assertEqual(get_redirect_url(r), 'http://example.com/') + m.get(url, status_code=302, headers={"location": "http://example.com/"}) + r = requests.get(f"{url}?url=http://example.com/", allow_redirects=False) + self.assertEqual(get_redirect_url(r), "http://example.com/") - m.get(url, status_code=302, headers={'location': '/example'}) - r = requests.get(f'{url}?url=/example', allow_redirects=False) - self.assertEqual(get_redirect_url(r), f'https://{hostname}/example') + m.get(url, status_code=302, headers={"location": "/example"}) + r = requests.get(f"{url}?url=/example", allow_redirects=False) + self.assertEqual(get_redirect_url(r), f"https://{hostname}/example") - m.get(url, status_code=302, headers={'location': 'https://example.com'}) + m.get(url, status_code=302, headers={"location": "https://example.com"}) with self.assertRaises(RelativeRedirect): - r = requests.get(f'{url}?url=https://example.com', allow_redirects=False) + r = requests.get(f"{url}?url=https://example.com", allow_redirects=False) get_redirect_url(r, require_relative=True) - m.get(url, status_code=302, headers={'location': '/example'}) + m.get(url, status_code=302, headers={"location": "/example"}) with self.assertRaises(RelativeRedirect): - r = requests.get(f'{url}?url=/example', allow_redirects=False) + r = requests.get(f"{url}?url=/example", allow_redirects=False) get_redirect_url(r, allow_relative=False) def test_to_xml(self): to_xml(b'') - to_xml(BOM_UTF8+b'') - to_xml(BOM_UTF8+b'&broken') + to_xml(BOM_UTF8 + b'') + to_xml(BOM_UTF8 + b'&broken') with self.assertRaises(ParseError): - to_xml(b'foo') + to_xml(b"foo") - @patch('lxml.etree.parse', side_effect=ParseError('', '', 1, 0)) + @patch("lxml.etree.parse", side_effect=ParseError("", "", 1, 0)) def test_to_xml_failure(self, m): # Not all lxml versions throw ParseError on the same XML, so we have to mock with self.assertRaises(ParseError) as e: - to_xml(b'Baz') - self.assertIn('Offending text: [...]BazBaz") + self.assertIn("Offending text: [...]BazBaz') - self.assertIn('XXX', e.exception.args[0]) + to_xml(b"Baz") + self.assertIn("XXX", e.exception.args[0]) - @patch('lxml.etree.parse', side_effect=TypeError('')) + @patch("lxml.etree.parse", side_effect=TypeError("")) def test_to_xml_failure_3(self, m): # Not all lxml versions throw ParseError on the same XML, so we have to mock with self.assertRaises(ParseError) as e: - to_xml(b'Baz') + to_xml(b"Baz") self.assertEqual(e.exception.args[0], "This is not XML: b'Baz'") def test_is_xml(self): self.assertEqual(is_xml(b''), True) - self.assertEqual(is_xml(BOM_UTF8+b''), True) - self.assertEqual(is_xml(b'XXX'), False) + self.assertEqual(is_xml(BOM_UTF8 + b''), True) + self.assertEqual(is_xml(b"XXX"), False) def test_xml_to_str(self): with self.assertRaises(AttributeError): - xml_to_str('XXX', encoding=None, xml_declaration=True) + xml_to_str("XXX", encoding=None, xml_declaration=True) def test_anonymizing_handler(self): - h = AnonymizingXmlHandler(forbidden_strings=('XXX', 'yyy')) - self.assertEqual(xml_to_str(h.parse_bytes(b'''\ + h = AnonymizingXmlHandler(forbidden_strings=("XXX", "yyy")) + self.assertEqual( + xml_to_str( + h.parse_bytes( + b"""\ XXX Hello yyy world -''')), '''\ +""" + ) + ), + """\ [REMOVED] Hello [REMOVED] world -''') +""", + ) def test_get_domain(self): - self.assertEqual(get_domain('foo@example.com'), 'example.com') + self.assertEqual(get_domain("foo@example.com"), "example.com") with self.assertRaises(ValueError): - get_domain('blah') + get_domain("blah") def test_pretty_xml_handler(self): # Test that a normal, non-XML log record is passed through unchanged @@ -169,29 +196,35 @@ def test_pretty_xml_handler(self): h = PrettyXmlHandler(stream=stream) self.assertTrue(h.is_tty()) r = logging.LogRecord( - name='baz', level=logging.INFO, pathname='/foo/bar', lineno=1, msg='hello', args=(), exc_info=None + name="baz", level=logging.INFO, pathname="/foo/bar", lineno=1, msg="hello", args=(), exc_info=None ) h.emit(r) h.stream.seek(0) - self.assertEqual(h.stream.read(), 'hello\n') + self.assertEqual(h.stream.read(), "hello\n") # Test formatting of an XML record. It should contain newlines and color codes. stream = io.StringIO() stream.isatty = lambda: True h = PrettyXmlHandler(stream=stream) r = logging.LogRecord( - name='baz', level=logging.DEBUG, pathname='/foo/bar', lineno=1, msg='hello %(xml_foo)s', - args=({'xml_foo': b'bar'},), exc_info=None) + name="baz", + level=logging.DEBUG, + pathname="/foo/bar", + lineno=1, + msg="hello %(xml_foo)s", + args=({"xml_foo": b'bar'},), + exc_info=None, + ) h.emit(r) h.stream.seek(0) self.assertEqual( h.stream.read(), "hello \x1b[36m\x1b[39;49;00m\n\x1b[94m" - "\x1b[39;49;00mbar\x1b[94m\x1b[39;49;00m\n" + "\x1b[39;49;00mbar\x1b[94m\x1b[39;49;00m\n", ) def test_post_ratelimited(self): - url = 'https://example.com' + url = "https://example.com" protocol = self.account.protocol orig_policy = protocol.config.retry_policy @@ -204,96 +237,99 @@ def test_post_ratelimited(self): protocol.config.retry_policy = FailFast() # Test the straight, HTTP 200 path - session.post = mock_post(url, 200, {}, 'foo') - r, session = post_ratelimited(protocol=protocol, session=session, url='http://', headers=None, data='') - self.assertEqual(r.content, b'foo') + session.post = mock_post(url, 200, {}, "foo") + r, session = post_ratelimited(protocol=protocol, session=session, url="http://", headers=None, data="") + self.assertEqual(r.content, b"foo") # Test exceptions raises by the POST request for err_cls in CONNECTION_ERRORS: session.post = mock_session_exception(err_cls) with self.assertRaises(err_cls): r, session = post_ratelimited( - protocol=protocol, session=session, url='http://', headers=None, data='') + protocol=protocol, session=session, url="http://", headers=None, data="" + ) # Test bad exit codes and headers session.post = mock_post(url, 401, {}) with self.assertRaises(UnauthorizedError): - r, session = post_ratelimited(protocol=protocol, session=session, url='http://', headers=None, data='') - session.post = mock_post(url, 999, {'connection': 'close'}) + r, session = post_ratelimited(protocol=protocol, session=session, url="http://", headers=None, data="") + session.post = mock_post(url, 999, {"connection": "close"}) with self.assertRaises(TransportError): - r, session = post_ratelimited(protocol=protocol, session=session, url='http://', headers=None, data='') - session.post = mock_post(url, 302, - {'location': '/ews/genericerrorpage.htm?aspxerrorpath=/ews/exchange.asmx'}) + r, session = post_ratelimited(protocol=protocol, session=session, url="http://", headers=None, data="") + session.post = mock_post( + url, 302, {"location": "/ews/genericerrorpage.htm?aspxerrorpath=/ews/exchange.asmx"} + ) with self.assertRaises(TransportError): - r, session = post_ratelimited(protocol=protocol, session=session, url='http://', headers=None, data='') + r, session = post_ratelimited(protocol=protocol, session=session, url="http://", headers=None, data="") session.post = mock_post(url, 503, {}) with self.assertRaises(TransportError): - r, session = post_ratelimited(protocol=protocol, session=session, url='http://', headers=None, data='') + r, session = post_ratelimited(protocol=protocol, session=session, url="http://", headers=None, data="") # No redirect header session.post = mock_post(url, 302, {}) with self.assertRaises(TransportError): - r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data='') + r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data="") # Redirect header to same location - session.post = mock_post(url, 302, {'location': url}) + session.post = mock_post(url, 302, {"location": url}) with self.assertRaises(TransportError): - r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data='') + r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data="") # Redirect header to relative location - session.post = mock_post(url, 302, {'location': url + '/foo'}) + session.post = mock_post(url, 302, {"location": url + "/foo"}) with self.assertRaises(RedirectError): - r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data='') + r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data="") # Redirect header to other location and allow_redirects=False - session.post = mock_post(url, 302, {'location': 'https://contoso.com'}) + session.post = mock_post(url, 302, {"location": "https://contoso.com"}) with self.assertRaises(TransportError): - r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data='') + r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data="") # Redirect header to other location and allow_redirects=True exchangelib.util.MAX_REDIRECTS = 0 - session.post = mock_post(url, 302, {'location': 'https://contoso.com'}) + session.post = mock_post(url, 302, {"location": "https://contoso.com"}) with self.assertRaises(TransportError): - r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data='', - allow_redirects=True) + r, session = post_ratelimited( + protocol=protocol, session=session, url=url, headers=None, data="", allow_redirects=True + ) # CAS error - session.post = mock_post(url, 999, {'X-CasErrorCode': 'AAARGH!'}) + session.post = mock_post(url, 999, {"X-CasErrorCode": "AAARGH!"}) with self.assertRaises(CASError): - r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data='') + r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data="") # Allow XML data in a non-HTTP 200 response session.post = mock_post(url, 500, {}, '') - r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data='') + r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data="") self.assertEqual(r.content, b'') # Bad status_code and bad text session.post = mock_post(url, 999, {}) with self.assertRaises(TransportError): - r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data='') + r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data="") # Test rate limit exceeded exchangelib.util.RETRY_WAIT = 1 protocol.config.retry_policy = FaultTolerance(max_wait=0.5) # Fail after first RETRY_WAIT - session.post = mock_post(url, 503, {'connection': 'close'}) + session.post = mock_post(url, 503, {"connection": "close"}) # Mock renew_session to return the same session so the session object's 'post' method is still mocked protocol.renew_session = lambda s: s with self.assertRaises(RateLimitError) as rle: - r, session = post_ratelimited(protocol=protocol, session=session, url='http://', headers=None, data='') + r, session = post_ratelimited(protocol=protocol, session=session, url="http://", headers=None, data="") self.assertEqual(rle.exception.status_code, 503) self.assertEqual(rle.exception.url, url) self.assertRegex( str(rle.exception), - r'Max timeout reached \(gave up after .* seconds. URL https://example.com returned status code 503\)' + r"Max timeout reached \(gave up after .* seconds. URL https://example.com returned status code 503\)", ) self.assertTrue(1 <= rle.exception.total_wait < 2) # One RETRY_WAIT plus some overhead # Test something larger than the default wait, so we retry at least once protocol.retry_policy.max_wait = 3 # Fail after second RETRY_WAIT - session.post = mock_post(url, 503, {'connection': 'close'}) + session.post = mock_post(url, 503, {"connection": "close"}) with self.assertRaises(RateLimitError) as rle: - r, session = post_ratelimited(protocol=protocol, session=session, url='http://', headers=None, data='') + r, session = post_ratelimited(protocol=protocol, session=session, url="http://", headers=None, data="") self.assertEqual(rle.exception.status_code, 503) self.assertEqual(rle.exception.url, url) self.assertRegex( str(rle.exception), - r'Max timeout reached \(gave up after .* seconds. URL https://example.com returned status code 503\)' + r"Max timeout reached \(gave up after .* seconds. URL https://example.com returned status code 503\)", ) # We double the wait for each retry, so this is RETRY_WAIT + 2*RETRY_WAIT plus some overhead self.assertTrue(3 <= rle.exception.total_wait < 4, rle.exception.total_wait) @@ -305,53 +341,53 @@ def test_post_ratelimited(self): exchangelib.util.MAX_REDIRECTS = MAX_REDIRECTS try: - delattr(protocol, 'renew_session') + delattr(protocol, "renew_session") except AttributeError: pass def test_safe_b64decode(self): # Test correctly padded string - self.assertEqual(safe_b64decode('SGVsbG8gd29ybGQ='), b'Hello world') + self.assertEqual(safe_b64decode("SGVsbG8gd29ybGQ="), b"Hello world") # Test incorrectly padded string - self.assertEqual(safe_b64decode('SGVsbG8gd29ybGQ'), b'Hello world') + self.assertEqual(safe_b64decode("SGVsbG8gd29ybGQ"), b"Hello world") # Test binary data - self.assertEqual(safe_b64decode(b'SGVsbG8gd29ybGQ='), b'Hello world') + self.assertEqual(safe_b64decode(b"SGVsbG8gd29ybGQ="), b"Hello world") # Test incorrectly padded binary data - self.assertEqual(safe_b64decode(b'SGVsbG8gd29ybGQ'), b'Hello world') + self.assertEqual(safe_b64decode(b"SGVsbG8gd29ybGQ"), b"Hello world") def test_document_yielder(self): self.assertListEqual( - list(DocumentYielder(_bytes_to_iter(b'a'), 'b')), - [b"\na"] + list(DocumentYielder(_bytes_to_iter(b"a"), "b")), + [b"\na"], ) self.assertListEqual( - list(DocumentYielder(_bytes_to_iter(b'acb'), 'b')), + list(DocumentYielder(_bytes_to_iter(b"acb"), "b")), [ b"\na", b"\nc", b"\nb", - ] + ], ) self.assertListEqual( - list(DocumentYielder(_bytes_to_iter(b''), 'XXX')), - [b"\n"] + list(DocumentYielder(_bytes_to_iter(b""), "XXX")), + [b"\n"], ) self.assertListEqual( - list(DocumentYielder(_bytes_to_iter(b''), 'XXX')), - [b"\n"] + list(DocumentYielder(_bytes_to_iter(b""), "XXX")), + [b"\n"], ) self.assertListEqual( - list(DocumentYielder(_bytes_to_iter(b""), 'XXX')), - [b"\n"] + list(DocumentYielder(_bytes_to_iter(b""), "XXX")), + [b"\n"], ) self.assertListEqual( - list(DocumentYielder(_bytes_to_iter(b""), 'XXX')), - [b"\n"] + list(DocumentYielder(_bytes_to_iter(b""), "XXX")), + [b"\n"], ) # Test 'dangerous' chars in attr values self.assertListEqual( - list(DocumentYielder(_bytes_to_iter(b""), 'XXX')), - [b"\n"] + list(DocumentYielder(_bytes_to_iter(b""), "XXX")), + [b"\n"], ) diff --git a/tests/test_version.py b/tests/test_version.py index 980e2c80..85b10ded 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,8 +1,8 @@ import requests_mock from exchangelib.errors import TransportError -from exchangelib.version import EXCHANGE_2007, Version, Build from exchangelib.util import to_xml +from exchangelib.version import EXCHANGE_2007, Build, Version from .common import TimedTestCase @@ -10,10 +10,10 @@ class VersionTest(TimedTestCase): def test_invalid_version_args(self): with self.assertRaises(TypeError) as e: - Version(build='XXX') + Version(build="XXX") self.assertEqual(e.exception.args[0], "'build' 'XXX' must be of type ") with self.assertRaises(TypeError) as e: - Version(build='XXX', api_version='XXX') + Version(build="XXX", api_version="XXX") self.assertEqual(e.exception.args[0], "'build' 'XXX' must be of type ") with self.assertRaises(TypeError) as e: Version(build=Build(15, 1, 2, 3), api_version=999) @@ -21,104 +21,116 @@ def test_invalid_version_args(self): def test_invalid_build_args(self): with self.assertRaises(TypeError) as e: - Build('XXX', 2, 3, 4) + Build("XXX", 2, 3, 4) self.assertEqual(e.exception.args[0], "'major_version' 'XXX' must be of type ") with self.assertRaises(TypeError) as e: - Build(1, 'XXX', 3, 4) + Build(1, "XXX", 3, 4) self.assertEqual(e.exception.args[0], "'minor_version' 'XXX' must be of type ") with self.assertRaises(TypeError) as e: - Build(1, 2, 'XXX', 4) + Build(1, 2, "XXX", 4) self.assertEqual(e.exception.args[0], "'major_build' 'XXX' must be of type ") with self.assertRaises(TypeError) as e: - Build(1, 2, 3, 'XXX') + Build(1, 2, 3, "XXX") self.assertEqual(e.exception.args[0], "'minor_build' 'XXX' must be of type ") def test_comparison(self): self.assertEqual(Version(Build(15, 1, 2, 3)), Version(Build(15, 1, 2, 3))) self.assertNotEqual(Version(Build(15, 1, 2, 3)), Version(Build(15, 1))) - self.assertNotEqual(Version(Build(15, 1, 2, 3), api_version='XXX'), Version(None, api_version='XXX')) - self.assertNotEqual(Version(None, api_version='XXX'), Version(Build(15, 1, 2), api_version='XXX')) - self.assertEqual(Version(Build(15, 1, 2, 3), 'XXX'), Version(Build(15, 1, 2, 3), 'XXX')) - self.assertNotEqual(Version(Build(15, 1, 2, 3), 'XXX'), Version(Build(15, 1, 2, 3), 'YYY')) - self.assertNotEqual(Version(Build(15, 1, 2, 3), 'XXX'), Version(Build(99, 88), 'XXX')) - self.assertNotEqual(Version(Build(15, 1, 2, 3), 'XXX'), Version(Build(99, 88), 'YYY')) + self.assertNotEqual(Version(Build(15, 1, 2, 3), api_version="XXX"), Version(None, api_version="XXX")) + self.assertNotEqual(Version(None, api_version="XXX"), Version(Build(15, 1, 2), api_version="XXX")) + self.assertEqual(Version(Build(15, 1, 2, 3), "XXX"), Version(Build(15, 1, 2, 3), "XXX")) + self.assertNotEqual(Version(Build(15, 1, 2, 3), "XXX"), Version(Build(15, 1, 2, 3), "YYY")) + self.assertNotEqual(Version(Build(15, 1, 2, 3), "XXX"), Version(Build(99, 88), "XXX")) + self.assertNotEqual(Version(Build(15, 1, 2, 3), "XXX"), Version(Build(99, 88), "YYY")) def test_default_api_version(self): # Test that a version gets a reasonable api_version value if we don't set one explicitly version = Version(build=Build(15, 1, 2, 3)) - self.assertEqual(version.api_version, 'Exchange2016') + self.assertEqual(version.api_version, "Exchange2016") @requests_mock.mock() # Just to make sure we don't make any requests def test_from_response(self, m): # Test fallback to suggested api_version value when there is a version mismatch and response version is fishy version = Version.from_soap_header( - 'Exchange2007', - to_xml(b'''\ + "Exchange2007", + to_xml( + b"""\ -''') +""" + ), ) self.assertEqual(version.api_version, EXCHANGE_2007.api_version()) - self.assertEqual(version.api_version, 'Exchange2007') + self.assertEqual(version.api_version, "Exchange2007") self.assertEqual(version.build, Build(15, 1, 845, 22)) # Test that override the suggested version if the response version is not fishy version = Version.from_soap_header( - 'Exchange2013', - to_xml(b'''\ + "Exchange2013", + to_xml( + b"""\ -''') +""" + ), ) - self.assertEqual(version.api_version, 'HELLO_FROM_EXCHANGELIB') + self.assertEqual(version.api_version, "HELLO_FROM_EXCHANGELIB") # Test that we override the suggested version with the version deduced from the build number if a version is not # present in the response version = Version.from_soap_header( - 'Exchange2013', - to_xml(b'''\ + "Exchange2013", + to_xml( + b"""\ -''') +""" + ), ) - self.assertEqual(version.api_version, 'Exchange2016') + self.assertEqual(version.api_version, "Exchange2016") # Test that we use the version deduced from the build number when a version is not present in the response and # there was no suggested version. version = Version.from_soap_header( None, - to_xml(b'''\ + to_xml( + b"""\ -''') +""" + ), ) - self.assertEqual(version.api_version, 'Exchange2016') + self.assertEqual(version.api_version, "Exchange2016") # Test various parse failures with self.assertRaises(TransportError) as e: Version.from_soap_header( - 'Exchange2013', - to_xml(b'''\ + "Exchange2013", + to_xml( + b"""\ -''') +""" + ), ) - self.assertIn('No ServerVersionInfo in header', e.exception.args[0]) + self.assertIn("No ServerVersionInfo in header", e.exception.args[0]) with self.assertRaises(TransportError) as e: Version.from_soap_header( - 'Exchange2013', - to_xml(b'''\ + "Exchange2013", + to_xml( + b"""\ -''') +""" + ), ) - self.assertIn('Bad ServerVersionInfo in response', e.exception.args[0]) + self.assertIn("Bad ServerVersionInfo in response", e.exception.args[0]) From 6749d1c51bca86152143d44294c9b1510a2dc05d Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 27 Jan 2022 10:34:29 +0100 Subject: [PATCH 195/509] Implement suggestions by DeepSource --- exchangelib/extended_properties.py | 11 ++++++----- exchangelib/fields.py | 4 +--- exchangelib/items/contact.py | 3 ++- exchangelib/queryset.py | 20 +++++++++----------- exchangelib/util.py | 2 -- 5 files changed, 18 insertions(+), 22 deletions(-) diff --git a/exchangelib/extended_properties.py b/exchangelib/extended_properties.py index b050b3ce..f62b1735 100644 --- a/exchangelib/extended_properties.py +++ b/exchangelib/extended_properties.py @@ -38,6 +38,11 @@ class ExtendedProperty(EWSElement): "UnifiedMessaging", } # Enum values: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/extendedfielduri + # The following types cannot be used for setting or getting (see docs) and are thus not very useful here: + # 'Error' + # 'Null' + # 'Object' + # 'ObjectArray' PROPERTY_TYPES = { "ApplicationTime", "Binary", @@ -49,23 +54,19 @@ class ExtendedProperty(EWSElement): "CurrencyArray", "Double", "DoubleArray", - # 'Error', "Float", "FloatArray", "Integer", "IntegerArray", "Long", "LongArray", - # 'Null', - # 'Object', - # 'ObjectArray', "Short", "ShortArray", "SystemTime", "SystemTimeArray", "String", "StringArray", - } # The commented-out types cannot be used for setting or getting (see docs) and are thus not very useful here + } # Translation table between common distinguished_property_set_id and property_set_id values. See # https://docs.microsoft.com/en-us/office/client-developer/outlook/mapi/commonly-used-property-sets diff --git a/exchangelib/fields.py b/exchangelib/fields.py index 78e19133..2775f683 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -986,9 +986,7 @@ def from_xml(self, elem, account): val = None if field_elem is None else field_elem.text or None if val is not None: body_type = field_elem.get("BodyType") - return {Body.body_type: Body, HTMLBody.body_type: HTMLBody,}[ - body_type - ](val) + return {Body.body_type: Body, HTMLBody.body_type: HTMLBody}[body_type](val) return self.default def to_xml(self, value, version): diff --git a/exchangelib/items/contact.py b/exchangelib/items/contact.py index bacb1440..2cfdedc4 100644 --- a/exchangelib/items/contact.py +++ b/exchangelib/items/contact.py @@ -238,7 +238,8 @@ class Persona(IdChangeKeyMixIn): wedding_anniversaries = StringAttributedValueField(field_uri="persona:WeddingAnniversaries") birthdays = StringAttributedValueField(field_uri="persona:Birthdays") locations = StringAttributedValueField(field_uri="persona:Locations") - # ExtendedPropertyAttributedValueField('extended_properties', field_uri='persona:ExtendedProperties') + # This class has an additional field of type "ExtendedPropertyAttributedValueField" and + # field_uri 'persona:ExtendedProperties' class DistributionList(Item): diff --git a/exchangelib/queryset.py b/exchangelib/queryset.py index 797c3723..7ae0e9dd 100644 --- a/exchangelib/queryset.py +++ b/exchangelib/queryset.py @@ -272,17 +272,15 @@ def __iter__(self): log.debug("Initializing cache") yield from self._format_items(items=self._query(), return_format=self.return_format) - """Do not implement __len__. The implementation of list() tries to preallocate memory by calling __len__ on the - given sequence, before calling __iter__. If we implemented __len__, we would end up calling FindItems twice, once - to get the result of self.count(), an once to return the actual result. - - Also, according to https://stackoverflow.com/questions/37189968/how-to-have-list-consume-iter-without-calling-len, - a __len__ implementation should be cheap. That does not hold for self.count(). - - def __len__(self): - # This queryset has no cache yet. Call the optimized counting implementation - return self.count() - """ + # Do not implement __len__. The implementation of list() tries to preallocate memory by calling __len__ on the + # given sequence, before calling __iter__. If we implemented __len__, we would end up calling FindItems twice, once + # to get the result of self.count(), and once to return the actual result. + # + # Also, according to https://stackoverflow.com/questions/37189968/how-to-have-list-consume-iter-without-calling-len, + # a __len__ implementation should be cheap. That does not hold for self.count(). + # + # def __len__(self): + # return self.count() def __getitem__(self, idx_or_slice): # Support indexing and slicing. This is non-greedy when possible (slicing start, stop and step are not negative, diff --git a/exchangelib/util.py b/exchangelib/util.py index e7d962ac..908dbee1 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -918,8 +918,6 @@ def post_ratelimited(protocol, session, url, headers, data, allow_redirects=Fals except MalformedResponseError as e: log.error("%s: %s\n%s\n%s", e.__class__.__name__, str(e), log_msg % log_vals, xml_log_msg % xml_log_vals) raise - except Exception: - raise log.debug("Session %s thread %s: Useful response from %s", session.session_id, thread_id, url) return r, session From 08b6dd4baca28ce8bcfd12cf0de6538164ec4bef Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 27 Jan 2022 10:40:21 +0100 Subject: [PATCH 196/509] Make hostname more random. Reuse get_random_hostname() for emails --- tests/common.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/common.py b/tests/common.py index 254012ea..2bcf4338 100644 --- a/tests/common.py +++ b/tests/common.py @@ -358,8 +358,8 @@ def get_random_bytes(length): def get_random_hostname(): - domain_len = random.randint(1, 30) - tld_len = random.randint(2, 4) + domain_len = random.randint(16, 32) + tld_len = random.randint(4, 6) return "%s.%s" % tuple(get_random_string(i, spaces=False, special=False).lower() for i in (domain_len, tld_len)) @@ -370,11 +370,7 @@ def get_random_url(): def get_random_email(): account_len = random.randint(1, 6) - domain_len = random.randint(1, 30) - tld_len = random.randint(2, 4) - return "%s@%s.%s" % tuple( - map(lambda i: get_random_string(i, spaces=False, special=False).lower(), (account_len, domain_len, tld_len)) - ) + return "%s@%s" % (get_random_string(account_len, spaces=False, special=False).lower(), get_random_hostname()) def _total_minutes(tm): From 33f015f428aaf7be681336377553e94331ebc753 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Fri, 28 Jan 2022 15:01:00 +0100 Subject: [PATCH 197/509] Add missing fields to Meeting* items --- CHANGELOG.md | 4 ++++ exchangelib/items/base.py | 2 +- exchangelib/items/calendar_item.py | 6 ++---- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc336435..b9a65276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ Change Log HEAD ---- +- Fixed field name to match API: `BaseReplyItem.received_by_representing` to +- `BaseReplyItem.received_representing` +- Added fields `received_by` and `received_representing` to `MeetingRequest`, +- `MeetingMessage` and `MeetingCancellation` 4.7.1 diff --git a/exchangelib/items/base.py b/exchangelib/items/base.py index 31a5a040..feff6535 100644 --- a/exchangelib/items/base.py +++ b/exchangelib/items/base.py @@ -186,7 +186,7 @@ class BaseReplyItem(EWSElement, metaclass=EWSMeta): reference_item_id = EWSElementField(value_cls=ReferenceItemId) new_body = BodyField(field_uri="NewBodyContent") # Accepts and returns Body or HTMLBody instances received_by = MailboxField(field_uri="ReceivedBy", supported_from=EXCHANGE_2007_SP1) - received_by_representing = MailboxField(field_uri="ReceivedRepresenting", supported_from=EXCHANGE_2007_SP1) + received_representing = MailboxField(field_uri="ReceivedRepresenting", supported_from=EXCHANGE_2007_SP1) __slots__ = ("account",) diff --git a/exchangelib/items/calendar_item.py b/exchangelib/items/calendar_item.py index 6d973735..4fc4df58 100644 --- a/exchangelib/items/calendar_item.py +++ b/exchangelib/items/calendar_item.py @@ -317,10 +317,10 @@ class BaseMeetingItem(Item, metaclass=EWSMeta): effective_rights_idx = Item.FIELDS.index_by_name("effective_rights") sender_idx = Message.FIELDS.index_by_name("sender") - reply_to_idx = Message.FIELDS.index_by_name("reply_to") + received_representing_idx = Message.FIELDS.index_by_name("received_representing") FIELDS = ( Item.FIELDS[:effective_rights_idx] - + Message.FIELDS[sender_idx : reply_to_idx + 1] + + Message.FIELDS[sender_idx : received_representing_idx + 1] + Item.FIELDS[effective_rights_idx:] ) @@ -371,8 +371,6 @@ class MeetingResponse(BaseMeetingItem): ELEMENT_NAME = "MeetingResponse" - received_by = MailboxField(field_uri="message:ReceivedBy", is_read_only=True) - received_representing = MailboxField(field_uri="message:ReceivedRepresenting", is_read_only=True) proposed_start = DateTimeField(field_uri="meeting:ProposedStart", supported_from=EXCHANGE_2013) proposed_end = DateTimeField(field_uri="meeting:ProposedEnd", supported_from=EXCHANGE_2013) From d6f8330c5fce0d300e8b25b87382a1fc003c71dc Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 30 Jan 2022 21:34:06 +0100 Subject: [PATCH 198/509] Bump version --- CHANGELOG.md | 9 +- docs/exchangelib/account.html | 695 ++-- docs/exchangelib/attachments.html | 221 +- docs/exchangelib/autodiscover/cache.html | 44 +- docs/exchangelib/autodiscover/discovery.html | 326 +- docs/exchangelib/autodiscover/index.html | 183 +- docs/exchangelib/autodiscover/properties.html | 501 +-- docs/exchangelib/autodiscover/protocol.html | 8 +- docs/exchangelib/configuration.html | 76 +- docs/exchangelib/credentials.html | 108 +- docs/exchangelib/errors.html | 3105 ++++++++++++----- docs/exchangelib/ewsdatetime.html | 207 +- docs/exchangelib/extended_properties.html | 410 +-- docs/exchangelib/fields.html | 976 +++--- docs/exchangelib/folders/base.html | 655 ++-- docs/exchangelib/folders/collections.html | 426 ++- docs/exchangelib/folders/index.html | 1384 +++++--- docs/exchangelib/folders/known_folders.html | 794 ++--- docs/exchangelib/folders/queryset.html | 70 +- docs/exchangelib/folders/roots.html | 189 +- docs/exchangelib/index.html | 2840 ++++++++------- docs/exchangelib/indexed_properties.html | 104 +- docs/exchangelib/items/base.html | 175 +- docs/exchangelib/items/calendar_item.html | 739 ++-- docs/exchangelib/items/contact.html | 759 ++-- docs/exchangelib/items/index.html | 1404 +++++--- docs/exchangelib/items/item.html | 499 ++- docs/exchangelib/items/message.html | 301 +- docs/exchangelib/items/post.html | 58 +- docs/exchangelib/items/task.html | 286 +- docs/exchangelib/properties.html | 2034 ++++++----- docs/exchangelib/protocol.html | 685 ++-- docs/exchangelib/queryset.html | 301 +- docs/exchangelib/recurrence.html | 325 +- docs/exchangelib/restriction.html | 408 ++- docs/exchangelib/services/archive_item.html | 26 +- docs/exchangelib/services/common.html | 609 ++-- docs/exchangelib/services/convert_id.html | 58 +- docs/exchangelib/services/copy_item.html | 4 +- .../services/create_attachment.html | 26 +- docs/exchangelib/services/create_folder.html | 70 +- docs/exchangelib/services/create_item.html | 132 +- .../services/create_user_configuration.html | 14 +- .../services/delete_attachment.html | 20 +- docs/exchangelib/services/delete_folder.html | 20 +- docs/exchangelib/services/delete_item.html | 55 +- .../services/delete_user_configuration.html | 14 +- docs/exchangelib/services/empty_folder.html | 23 +- docs/exchangelib/services/expand_dl.html | 20 +- docs/exchangelib/services/export_items.html | 20 +- docs/exchangelib/services/find_folder.html | 102 +- docs/exchangelib/services/find_item.html | 281 +- docs/exchangelib/services/find_people.html | 241 +- docs/exchangelib/services/get_attachment.html | 166 +- docs/exchangelib/services/get_delegate.html | 68 +- docs/exchangelib/services/get_events.html | 57 +- docs/exchangelib/services/get_folder.html | 94 +- docs/exchangelib/services/get_item.html | 77 +- docs/exchangelib/services/get_mail_tips.html | 74 +- docs/exchangelib/services/get_persona.html | 30 +- docs/exchangelib/services/get_room_lists.html | 20 +- docs/exchangelib/services/get_rooms.html | 20 +- .../services/get_searchable_mailboxes.html | 74 +- .../services/get_server_time_zones.html | 59 +- .../services/get_streaming_events.html | 109 +- .../services/get_user_availability.html | 74 +- .../services/get_user_configuration.html | 60 +- .../services/get_user_oof_settings.html | 30 +- docs/exchangelib/services/index.html | 1919 +++++----- docs/exchangelib/services/mark_as_junk.html | 14 +- docs/exchangelib/services/move_folder.html | 28 +- docs/exchangelib/services/move_item.html | 32 +- docs/exchangelib/services/resolve_names.html | 190 +- docs/exchangelib/services/send_item.html | 38 +- .../services/send_notification.html | 40 +- .../services/set_user_oof_settings.html | 32 +- docs/exchangelib/services/subscribe.html | 167 +- .../services/sync_folder_hierarchy.html | 140 +- .../services/sync_folder_items.html | 136 +- docs/exchangelib/services/unsubscribe.html | 20 +- docs/exchangelib/services/update_folder.html | 62 +- docs/exchangelib/services/update_item.html | 207 +- .../services/update_user_configuration.html | 14 +- docs/exchangelib/services/upload_items.html | 56 +- docs/exchangelib/settings.html | 108 +- docs/exchangelib/transport.html | 233 +- docs/exchangelib/util.html | 516 +-- docs/exchangelib/version.html | 279 +- docs/exchangelib/winzone.html | 1236 +++---- exchangelib/__init__.py | 2 +- setup.py | 2 +- 91 files changed, 17300 insertions(+), 12223 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9a65276..d3842602 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,15 @@ Change Log HEAD ---- + + +4.7.2 +----- - Fixed field name to match API: `BaseReplyItem.received_by_representing` to -- `BaseReplyItem.received_representing` + `BaseReplyItem.received_representing` - Added fields `received_by` and `received_representing` to `MeetingRequest`, -- `MeetingMessage` and `MeetingCancellation` + `MeetingMessage` and `MeetingCancellation` +- Fixed `AppointmentStateField.CANCELLED` enum value. 4.7.1 diff --git a/docs/exchangelib/account.html b/docs/exchangelib/account.html index eaaa09f9..4d547a5b 100644 --- a/docs/exchangelib/account.html +++ b/docs/exchangelib/account.html @@ -33,23 +33,77 @@

          Module exchangelib.account

          from .autodiscover import Autodiscovery from .configuration import Configuration -from .credentials import DELEGATE, IMPERSONATION, ACCESS_TYPES -from .errors import UnknownTimeZone, InvalidEnumValue, InvalidTypeError -from .ewsdatetime import EWSTimeZone, UTC +from .credentials import ACCESS_TYPES, DELEGATE, IMPERSONATION +from .errors import InvalidEnumValue, InvalidTypeError, UnknownTimeZone +from .ewsdatetime import UTC, EWSTimeZone from .fields import FieldPath -from .folders import Folder, AdminAuditLogs, ArchiveDeletedItems, ArchiveInbox, ArchiveMsgFolderRoot, \ - ArchiveRecoverableItemsDeletions, ArchiveRecoverableItemsPurges, ArchiveRecoverableItemsRoot, \ - ArchiveRecoverableItemsVersions, ArchiveRoot, Calendar, Conflicts, Contacts, ConversationHistory, DeletedItems, \ - Directory, Drafts, Favorites, IMContactList, Inbox, Journal, JunkEmail, LocalFailures, MsgFolderRoot, MyContacts, \ - Notes, Outbox, PeopleConnect, PublicFoldersRoot, QuickContacts, RecipientCache, RecoverableItemsDeletions, \ - RecoverableItemsPurges, RecoverableItemsRoot, RecoverableItemsVersions, Root, SearchFolders, SentItems, \ - ServerFailures, SyncIssues, Tasks, ToDoSearch, VoiceMail -from .items import HARD_DELETE, AUTO_RESOLVE, SEND_TO_NONE, SAVE_ONLY, ALL_OCCURRENCES, ID_ONLY +from .folders import ( + AdminAuditLogs, + ArchiveDeletedItems, + ArchiveInbox, + ArchiveMsgFolderRoot, + ArchiveRecoverableItemsDeletions, + ArchiveRecoverableItemsPurges, + ArchiveRecoverableItemsRoot, + ArchiveRecoverableItemsVersions, + ArchiveRoot, + Calendar, + Conflicts, + Contacts, + ConversationHistory, + DeletedItems, + Directory, + Drafts, + Favorites, + Folder, + IMContactList, + Inbox, + Journal, + JunkEmail, + LocalFailures, + MsgFolderRoot, + MyContacts, + Notes, + Outbox, + PeopleConnect, + PublicFoldersRoot, + QuickContacts, + RecipientCache, + RecoverableItemsDeletions, + RecoverableItemsPurges, + RecoverableItemsRoot, + RecoverableItemsVersions, + Root, + SearchFolders, + SentItems, + ServerFailures, + SyncIssues, + Tasks, + ToDoSearch, + VoiceMail, +) +from .items import ALL_OCCURRENCES, AUTO_RESOLVE, HARD_DELETE, ID_ONLY, SAVE_ONLY, SEND_TO_NONE from .properties import Mailbox, SendingAs from .protocol import Protocol from .queryset import QuerySet -from .services import ExportItems, UploadItems, GetItem, CreateItem, UpdateItem, DeleteItem, MoveItem, SendItem, \ - CopyItem, GetUserOofSettings, SetUserOofSettings, GetMailTips, ArchiveItem, GetDelegate, MarkAsJunk, GetPersona +from .services import ( + ArchiveItem, + CopyItem, + CreateItem, + DeleteItem, + ExportItems, + GetDelegate, + GetItem, + GetMailTips, + GetPersona, + GetUserOofSettings, + MarkAsJunk, + MoveItem, + SendItem, + SetUserOofSettings, + UpdateItem, + UploadItems, +) from .util import get_domain, peek log = getLogger(__name__) @@ -85,8 +139,17 @@

          Module exchangelib.account

          class Account: """Models an Exchange server user account.""" - def __init__(self, primary_smtp_address, fullname=None, access_type=None, autodiscover=False, credentials=None, - config=None, locale=None, default_timezone=None): + def __init__( + self, + primary_smtp_address, + fullname=None, + access_type=None, + autodiscover=False, + credentials=None, + config=None, + locale=None, + default_timezone=None, + ): """ :param primary_smtp_address: The primary email address associated with the account on the Exchange server @@ -103,37 +166,37 @@

          Module exchangelib.account

          assume values to be in the provided timezone. Defaults to the timezone of the host. :return: """ - if '@' not in primary_smtp_address: + if "@" not in primary_smtp_address: raise ValueError(f"primary_smtp_address {primary_smtp_address!r} is not an email address") self.fullname = fullname # Assume delegate access if individual credentials are provided. Else, assume service user with impersonation self.access_type = access_type or (DELEGATE if credentials else IMPERSONATION) if self.access_type not in ACCESS_TYPES: - raise InvalidEnumValue('access_type', self.access_type, ACCESS_TYPES) + raise InvalidEnumValue("access_type", self.access_type, ACCESS_TYPES) try: # get_locale() might not be able to determine the locale self.locale = locale or stdlib_locale.getlocale()[0] or None except ValueError as e: # getlocale() may throw ValueError if it fails to parse the system locale - log.warning('Failed to get locale (%s)', e) + log.warning("Failed to get locale (%s)", e) self.locale = None if not isinstance(self.locale, (type(None), str)): - raise InvalidTypeError('locale', self.locale, str) + raise InvalidTypeError("locale", self.locale, str) if default_timezone: try: self.default_timezone = EWSTimeZone.from_timezone(default_timezone) except TypeError: - raise InvalidTypeError('default_timezone', default_timezone, EWSTimeZone) + raise InvalidTypeError("default_timezone", default_timezone, EWSTimeZone) else: try: self.default_timezone = EWSTimeZone.localzone() except (ValueError, UnknownTimeZone) as e: # There is no translation from local timezone name to Windows timezone name, or e failed to find the # local timezone. - log.warning('%s. Fallback to UTC', e.args[0]) + log.warning("%s. Fallback to UTC", e.args[0]) self.default_timezone = UTC if not isinstance(config, (Configuration, type(None))): - raise InvalidTypeError('config', config, Configuration) + raise InvalidTypeError("config", config, Configuration) if autodiscover: if config: auth_type, retry_policy, version = config.auth_type, config.retry_policy, config.version @@ -153,7 +216,7 @@

          Module exchangelib.account

          primary_smtp_address = self.ad_response.autodiscover_smtp_address else: if not config: - raise AttributeError('non-autodiscover requires a config') + raise AttributeError("non-autodiscover requires a config") self.ad_response = None self.protocol = Protocol(config=config) @@ -167,7 +230,7 @@

          Module exchangelib.account

          # server version up-front but delegate account requests to an older backend server. Create a new instance to # avoid changing the protocol version. self.version = self.protocol.version.copy() - log.debug('Added account: %s', self) + log.debug("Added account: %s", self) @property def primary_smtp_address(self): @@ -375,7 +438,7 @@

          Module exchangelib.account

          # We accept generators, so it's not always convenient for caller to know up-front if 'ids' is empty. Allow # empty 'ids' and return early. return - kwargs['items'] = items + kwargs["items"] = items yield from service_cls(account=self, chunk_size=chunk_size).call(**kwargs) def export(self, items, chunk_size=None): @@ -386,9 +449,7 @@

          Module exchangelib.account

          :return: A list of strings, the exported representation of the object """ - return list( - self._consume_item_service(service_cls=ExportItems, items=items, chunk_size=chunk_size, kwargs={}) - ) + return list(self._consume_item_service(service_cls=ExportItems, items=items, chunk_size=chunk_size, kwargs={})) def upload(self, data, chunk_size=None): """Upload objects retrieved from an export to the given folders. @@ -410,12 +471,11 @@

          Module exchangelib.account

          -> [("idA", "changekey"), ("idB", "changekey"), ("idC", "changekey")] """ items = ((f, (None, False, d) if isinstance(d, str) else d) for f, d in data) - return list( - self._consume_item_service(service_cls=UploadItems, items=items, chunk_size=chunk_size, kwargs={}) - ) + return list(self._consume_item_service(service_cls=UploadItems, items=items, chunk_size=chunk_size, kwargs={})) - def bulk_create(self, folder, items, message_disposition=SAVE_ONLY, send_meeting_invitations=SEND_TO_NONE, - chunk_size=None): + def bulk_create( + self, folder, items, message_disposition=SAVE_ONLY, send_meeting_invitations=SEND_TO_NONE, chunk_size=None + ): """Create new items in 'folder'. :param folder: the folder to create the items in @@ -432,23 +492,36 @@

          Module exchangelib.account

          """ if isinstance(items, QuerySet): # bulk_create() on a queryset does not make sense because it returns items that have already been created - raise ValueError('Cannot bulk create items from a QuerySet') + raise ValueError("Cannot bulk create items from a QuerySet") log.debug( - 'Adding items for %s (folder %s, message_disposition: %s, send_meeting_invitations: %s)', + "Adding items for %s (folder %s, message_disposition: %s, send_meeting_invitations: %s)", self, folder, message_disposition, send_meeting_invitations, ) - return list(self._consume_item_service(service_cls=CreateItem, items=items, chunk_size=chunk_size, kwargs=dict( - folder=folder, - message_disposition=message_disposition, - send_meeting_invitations=send_meeting_invitations, - ))) - - def bulk_update(self, items, conflict_resolution=AUTO_RESOLVE, message_disposition=SAVE_ONLY, - send_meeting_invitations_or_cancellations=SEND_TO_NONE, suppress_read_receipts=True, - chunk_size=None): + return list( + self._consume_item_service( + service_cls=CreateItem, + items=items, + chunk_size=chunk_size, + kwargs=dict( + folder=folder, + message_disposition=message_disposition, + send_meeting_invitations=send_meeting_invitations, + ), + ) + ) + + def bulk_update( + self, + items, + conflict_resolution=AUTO_RESOLVE, + message_disposition=SAVE_ONLY, + send_meeting_invitations_or_cancellations=SEND_TO_NONE, + suppress_read_receipts=True, + chunk_size=None, + ): """Bulk update existing items. :param items: a list of (Item, fieldnames) tuples, where 'Item' is an Item object, and 'fieldnames' is a list @@ -468,23 +541,37 @@

          Module exchangelib.account

          # fact, it could be dangerous if the queryset contains an '.only()'. This would wipe out certain fields # entirely. if isinstance(items, QuerySet): - raise ValueError('Cannot bulk update on a queryset') + raise ValueError("Cannot bulk update on a queryset") log.debug( - 'Updating items for %s (conflict_resolution %s, message_disposition: %s, send_meeting_invitations: %s)', + "Updating items for %s (conflict_resolution %s, message_disposition: %s, send_meeting_invitations: %s)", self, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, ) - return list(self._consume_item_service(service_cls=UpdateItem, items=items, chunk_size=chunk_size, kwargs=dict( - conflict_resolution=conflict_resolution, - message_disposition=message_disposition, - send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations, - suppress_read_receipts=suppress_read_receipts, - ))) - - def bulk_delete(self, ids, delete_type=HARD_DELETE, send_meeting_cancellations=SEND_TO_NONE, - affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True, chunk_size=None): + return list( + self._consume_item_service( + service_cls=UpdateItem, + items=items, + chunk_size=chunk_size, + kwargs=dict( + conflict_resolution=conflict_resolution, + message_disposition=message_disposition, + send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations, + suppress_read_receipts=suppress_read_receipts, + ), + ) + ) + + def bulk_delete( + self, + ids, + delete_type=HARD_DELETE, + send_meeting_cancellations=SEND_TO_NONE, + affected_task_occurrences=ALL_OCCURRENCES, + suppress_read_receipts=True, + chunk_size=None, + ): """Bulk delete items. :param ids: an iterable of either (id, changekey) tuples or Item objects. @@ -500,19 +587,24 @@

          Module exchangelib.account

          :return: a list of either True or exception instances, in the same order as the input """ log.debug( - 'Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurrences: %s)', + "Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurrences: %s)", self, delete_type, send_meeting_cancellations, affected_task_occurrences, ) return list( - self._consume_item_service(service_cls=DeleteItem, items=ids, chunk_size=chunk_size, kwargs=dict( - delete_type=delete_type, - send_meeting_cancellations=send_meeting_cancellations, - affected_task_occurrences=affected_task_occurrences, - suppress_read_receipts=suppress_read_receipts, - )) + self._consume_item_service( + service_cls=DeleteItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + delete_type=delete_type, + send_meeting_cancellations=send_meeting_cancellations, + affected_task_occurrences=affected_task_occurrences, + suppress_read_receipts=suppress_read_receipts, + ), + ) ) def bulk_send(self, ids, save_copy=True, copy_to_folder=None, chunk_size=None): @@ -530,9 +622,14 @@

          Module exchangelib.account

          if save_copy and not copy_to_folder: copy_to_folder = self.sent # 'Sent' is default EWS behaviour return list( - self._consume_item_service(service_cls=SendItem, items=ids, chunk_size=chunk_size, kwargs=dict( - saved_item_folder=copy_to_folder, - )) + self._consume_item_service( + service_cls=SendItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + saved_item_folder=copy_to_folder, + ), + ) ) def bulk_copy(self, ids, to_folder, chunk_size=None): @@ -544,9 +641,16 @@

          Module exchangelib.account

          :return: Status for each send operation, in the same order as the input """ - return list(self._consume_item_service(service_cls=CopyItem, items=ids, chunk_size=chunk_size, kwargs=dict( - to_folder=to_folder, - ))) + return list( + self._consume_item_service( + service_cls=CopyItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + to_folder=to_folder, + ), + ) + ) def bulk_move(self, ids, to_folder, chunk_size=None): """Move items to another folder. @@ -558,9 +662,16 @@

          Module exchangelib.account

          :return: The new IDs of the moved items, in the same order as the input. If 'to_folder' is a public folder or a folder in a different mailbox, an empty list is returned. """ - return list(self._consume_item_service(service_cls=MoveItem, items=ids, chunk_size=chunk_size, kwargs=dict( - to_folder=to_folder, - ))) + return list( + self._consume_item_service( + service_cls=MoveItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + to_folder=to_folder, + ), + ) + ) def bulk_archive(self, ids, to_folder, chunk_size=None): """Archive items to a folder in the archive mailbox. An archive mailbox must be enabled in order for this @@ -572,9 +683,15 @@

          Module exchangelib.account

          :return: A list containing True or an exception instance in stable order of the requested items """ - return list(self._consume_item_service(service_cls=ArchiveItem, items=ids, chunk_size=chunk_size, kwargs=dict( - to_folder=to_folder, - )) + return list( + self._consume_item_service( + service_cls=ArchiveItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + to_folder=to_folder, + ), + ) ) def bulk_mark_as_junk(self, ids, is_junk, move_item, chunk_size=None): @@ -588,10 +705,17 @@

          Module exchangelib.account

          :return: A list containing the new IDs of the moved items, if items were moved, or True, or an exception instance, in stable order of the requested items. """ - return list(self._consume_item_service(service_cls=MarkAsJunk, items=ids, chunk_size=chunk_size, kwargs=dict( - is_junk=is_junk, - move_item=move_item, - ))) + return list( + self._consume_item_service( + service_cls=MarkAsJunk, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + is_junk=is_junk, + move_item=move_item, + ), + ) + ) def fetch(self, ids, folder=None, only_fields=None, chunk_size=None): """Fetch items by ID. @@ -616,13 +740,19 @@

          Module exchangelib.account

          for field in only_fields: validation_folder.validate_item_field(field=field, version=self.version) # Remove ItemId and ChangeKey. We get them unconditionally - additional_fields = {f for f in validation_folder.normalize_fields(fields=only_fields) - if not f.field.is_attribute} + additional_fields = { + f for f in validation_folder.normalize_fields(fields=only_fields) if not f.field.is_attribute + } # Always use IdOnly here, because AllProperties doesn't actually get *all* properties - yield from self._consume_item_service(service_cls=GetItem, items=ids, chunk_size=chunk_size, kwargs=dict( + yield from self._consume_item_service( + service_cls=GetItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( additional_fields=additional_fields, shape=ID_ONLY, - )) + ), + ) def fetch_personas(self, ids): """Fetch personas by ID. @@ -646,7 +776,7 @@

          Module exchangelib.account

          return GetMailTips(protocol=self.protocol).get( sending_as=SendingAs(email_address=self.primary_smtp_address), recipients=[Mailbox(email_address=self.primary_smtp_address)], - mail_tips_requested='All', + mail_tips_requested="All", ) @property @@ -656,7 +786,7 @@

          Module exchangelib.account

          def __str__(self): if self.fullname: - return f'{self.primary_smtp_address} ({self.fullname})' + return f"{self.primary_smtp_address} ({self.fullname})" return self.primary_smtp_address
          @@ -695,8 +825,17 @@

          Classes

          class Account:
               """Models an Exchange server user account."""
           
          -    def __init__(self, primary_smtp_address, fullname=None, access_type=None, autodiscover=False, credentials=None,
          -                 config=None, locale=None, default_timezone=None):
          +    def __init__(
          +        self,
          +        primary_smtp_address,
          +        fullname=None,
          +        access_type=None,
          +        autodiscover=False,
          +        credentials=None,
          +        config=None,
          +        locale=None,
          +        default_timezone=None,
          +    ):
                   """
           
                   :param primary_smtp_address: The primary email address associated with the account on the Exchange server
          @@ -713,37 +852,37 @@ 

          Classes

          assume values to be in the provided timezone. Defaults to the timezone of the host. :return: """ - if '@' not in primary_smtp_address: + if "@" not in primary_smtp_address: raise ValueError(f"primary_smtp_address {primary_smtp_address!r} is not an email address") self.fullname = fullname # Assume delegate access if individual credentials are provided. Else, assume service user with impersonation self.access_type = access_type or (DELEGATE if credentials else IMPERSONATION) if self.access_type not in ACCESS_TYPES: - raise InvalidEnumValue('access_type', self.access_type, ACCESS_TYPES) + raise InvalidEnumValue("access_type", self.access_type, ACCESS_TYPES) try: # get_locale() might not be able to determine the locale self.locale = locale or stdlib_locale.getlocale()[0] or None except ValueError as e: # getlocale() may throw ValueError if it fails to parse the system locale - log.warning('Failed to get locale (%s)', e) + log.warning("Failed to get locale (%s)", e) self.locale = None if not isinstance(self.locale, (type(None), str)): - raise InvalidTypeError('locale', self.locale, str) + raise InvalidTypeError("locale", self.locale, str) if default_timezone: try: self.default_timezone = EWSTimeZone.from_timezone(default_timezone) except TypeError: - raise InvalidTypeError('default_timezone', default_timezone, EWSTimeZone) + raise InvalidTypeError("default_timezone", default_timezone, EWSTimeZone) else: try: self.default_timezone = EWSTimeZone.localzone() except (ValueError, UnknownTimeZone) as e: # There is no translation from local timezone name to Windows timezone name, or e failed to find the # local timezone. - log.warning('%s. Fallback to UTC', e.args[0]) + log.warning("%s. Fallback to UTC", e.args[0]) self.default_timezone = UTC if not isinstance(config, (Configuration, type(None))): - raise InvalidTypeError('config', config, Configuration) + raise InvalidTypeError("config", config, Configuration) if autodiscover: if config: auth_type, retry_policy, version = config.auth_type, config.retry_policy, config.version @@ -763,7 +902,7 @@

          Classes

          primary_smtp_address = self.ad_response.autodiscover_smtp_address else: if not config: - raise AttributeError('non-autodiscover requires a config') + raise AttributeError("non-autodiscover requires a config") self.ad_response = None self.protocol = Protocol(config=config) @@ -777,7 +916,7 @@

          Classes

          # server version up-front but delegate account requests to an older backend server. Create a new instance to # avoid changing the protocol version. self.version = self.protocol.version.copy() - log.debug('Added account: %s', self) + log.debug("Added account: %s", self) @property def primary_smtp_address(self): @@ -985,7 +1124,7 @@

          Classes

          # We accept generators, so it's not always convenient for caller to know up-front if 'ids' is empty. Allow # empty 'ids' and return early. return - kwargs['items'] = items + kwargs["items"] = items yield from service_cls(account=self, chunk_size=chunk_size).call(**kwargs) def export(self, items, chunk_size=None): @@ -996,9 +1135,7 @@

          Classes

          :return: A list of strings, the exported representation of the object """ - return list( - self._consume_item_service(service_cls=ExportItems, items=items, chunk_size=chunk_size, kwargs={}) - ) + return list(self._consume_item_service(service_cls=ExportItems, items=items, chunk_size=chunk_size, kwargs={})) def upload(self, data, chunk_size=None): """Upload objects retrieved from an export to the given folders. @@ -1020,12 +1157,11 @@

          Classes

          -> [("idA", "changekey"), ("idB", "changekey"), ("idC", "changekey")] """ items = ((f, (None, False, d) if isinstance(d, str) else d) for f, d in data) - return list( - self._consume_item_service(service_cls=UploadItems, items=items, chunk_size=chunk_size, kwargs={}) - ) + return list(self._consume_item_service(service_cls=UploadItems, items=items, chunk_size=chunk_size, kwargs={})) - def bulk_create(self, folder, items, message_disposition=SAVE_ONLY, send_meeting_invitations=SEND_TO_NONE, - chunk_size=None): + def bulk_create( + self, folder, items, message_disposition=SAVE_ONLY, send_meeting_invitations=SEND_TO_NONE, chunk_size=None + ): """Create new items in 'folder'. :param folder: the folder to create the items in @@ -1042,23 +1178,36 @@

          Classes

          """ if isinstance(items, QuerySet): # bulk_create() on a queryset does not make sense because it returns items that have already been created - raise ValueError('Cannot bulk create items from a QuerySet') + raise ValueError("Cannot bulk create items from a QuerySet") log.debug( - 'Adding items for %s (folder %s, message_disposition: %s, send_meeting_invitations: %s)', + "Adding items for %s (folder %s, message_disposition: %s, send_meeting_invitations: %s)", self, folder, message_disposition, send_meeting_invitations, ) - return list(self._consume_item_service(service_cls=CreateItem, items=items, chunk_size=chunk_size, kwargs=dict( - folder=folder, - message_disposition=message_disposition, - send_meeting_invitations=send_meeting_invitations, - ))) - - def bulk_update(self, items, conflict_resolution=AUTO_RESOLVE, message_disposition=SAVE_ONLY, - send_meeting_invitations_or_cancellations=SEND_TO_NONE, suppress_read_receipts=True, - chunk_size=None): + return list( + self._consume_item_service( + service_cls=CreateItem, + items=items, + chunk_size=chunk_size, + kwargs=dict( + folder=folder, + message_disposition=message_disposition, + send_meeting_invitations=send_meeting_invitations, + ), + ) + ) + + def bulk_update( + self, + items, + conflict_resolution=AUTO_RESOLVE, + message_disposition=SAVE_ONLY, + send_meeting_invitations_or_cancellations=SEND_TO_NONE, + suppress_read_receipts=True, + chunk_size=None, + ): """Bulk update existing items. :param items: a list of (Item, fieldnames) tuples, where 'Item' is an Item object, and 'fieldnames' is a list @@ -1078,23 +1227,37 @@

          Classes

          # fact, it could be dangerous if the queryset contains an '.only()'. This would wipe out certain fields # entirely. if isinstance(items, QuerySet): - raise ValueError('Cannot bulk update on a queryset') + raise ValueError("Cannot bulk update on a queryset") log.debug( - 'Updating items for %s (conflict_resolution %s, message_disposition: %s, send_meeting_invitations: %s)', + "Updating items for %s (conflict_resolution %s, message_disposition: %s, send_meeting_invitations: %s)", self, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, ) - return list(self._consume_item_service(service_cls=UpdateItem, items=items, chunk_size=chunk_size, kwargs=dict( - conflict_resolution=conflict_resolution, - message_disposition=message_disposition, - send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations, - suppress_read_receipts=suppress_read_receipts, - ))) - - def bulk_delete(self, ids, delete_type=HARD_DELETE, send_meeting_cancellations=SEND_TO_NONE, - affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True, chunk_size=None): + return list( + self._consume_item_service( + service_cls=UpdateItem, + items=items, + chunk_size=chunk_size, + kwargs=dict( + conflict_resolution=conflict_resolution, + message_disposition=message_disposition, + send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations, + suppress_read_receipts=suppress_read_receipts, + ), + ) + ) + + def bulk_delete( + self, + ids, + delete_type=HARD_DELETE, + send_meeting_cancellations=SEND_TO_NONE, + affected_task_occurrences=ALL_OCCURRENCES, + suppress_read_receipts=True, + chunk_size=None, + ): """Bulk delete items. :param ids: an iterable of either (id, changekey) tuples or Item objects. @@ -1110,19 +1273,24 @@

          Classes

          :return: a list of either True or exception instances, in the same order as the input """ log.debug( - 'Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurrences: %s)', + "Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurrences: %s)", self, delete_type, send_meeting_cancellations, affected_task_occurrences, ) return list( - self._consume_item_service(service_cls=DeleteItem, items=ids, chunk_size=chunk_size, kwargs=dict( - delete_type=delete_type, - send_meeting_cancellations=send_meeting_cancellations, - affected_task_occurrences=affected_task_occurrences, - suppress_read_receipts=suppress_read_receipts, - )) + self._consume_item_service( + service_cls=DeleteItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + delete_type=delete_type, + send_meeting_cancellations=send_meeting_cancellations, + affected_task_occurrences=affected_task_occurrences, + suppress_read_receipts=suppress_read_receipts, + ), + ) ) def bulk_send(self, ids, save_copy=True, copy_to_folder=None, chunk_size=None): @@ -1140,9 +1308,14 @@

          Classes

          if save_copy and not copy_to_folder: copy_to_folder = self.sent # 'Sent' is default EWS behaviour return list( - self._consume_item_service(service_cls=SendItem, items=ids, chunk_size=chunk_size, kwargs=dict( - saved_item_folder=copy_to_folder, - )) + self._consume_item_service( + service_cls=SendItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + saved_item_folder=copy_to_folder, + ), + ) ) def bulk_copy(self, ids, to_folder, chunk_size=None): @@ -1154,9 +1327,16 @@

          Classes

          :return: Status for each send operation, in the same order as the input """ - return list(self._consume_item_service(service_cls=CopyItem, items=ids, chunk_size=chunk_size, kwargs=dict( - to_folder=to_folder, - ))) + return list( + self._consume_item_service( + service_cls=CopyItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + to_folder=to_folder, + ), + ) + ) def bulk_move(self, ids, to_folder, chunk_size=None): """Move items to another folder. @@ -1168,9 +1348,16 @@

          Classes

          :return: The new IDs of the moved items, in the same order as the input. If 'to_folder' is a public folder or a folder in a different mailbox, an empty list is returned. """ - return list(self._consume_item_service(service_cls=MoveItem, items=ids, chunk_size=chunk_size, kwargs=dict( - to_folder=to_folder, - ))) + return list( + self._consume_item_service( + service_cls=MoveItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + to_folder=to_folder, + ), + ) + ) def bulk_archive(self, ids, to_folder, chunk_size=None): """Archive items to a folder in the archive mailbox. An archive mailbox must be enabled in order for this @@ -1182,9 +1369,15 @@

          Classes

          :return: A list containing True or an exception instance in stable order of the requested items """ - return list(self._consume_item_service(service_cls=ArchiveItem, items=ids, chunk_size=chunk_size, kwargs=dict( - to_folder=to_folder, - )) + return list( + self._consume_item_service( + service_cls=ArchiveItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + to_folder=to_folder, + ), + ) ) def bulk_mark_as_junk(self, ids, is_junk, move_item, chunk_size=None): @@ -1198,10 +1391,17 @@

          Classes

          :return: A list containing the new IDs of the moved items, if items were moved, or True, or an exception instance, in stable order of the requested items. """ - return list(self._consume_item_service(service_cls=MarkAsJunk, items=ids, chunk_size=chunk_size, kwargs=dict( - is_junk=is_junk, - move_item=move_item, - ))) + return list( + self._consume_item_service( + service_cls=MarkAsJunk, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + is_junk=is_junk, + move_item=move_item, + ), + ) + ) def fetch(self, ids, folder=None, only_fields=None, chunk_size=None): """Fetch items by ID. @@ -1226,13 +1426,19 @@

          Classes

          for field in only_fields: validation_folder.validate_item_field(field=field, version=self.version) # Remove ItemId and ChangeKey. We get them unconditionally - additional_fields = {f for f in validation_folder.normalize_fields(fields=only_fields) - if not f.field.is_attribute} + additional_fields = { + f for f in validation_folder.normalize_fields(fields=only_fields) if not f.field.is_attribute + } # Always use IdOnly here, because AllProperties doesn't actually get *all* properties - yield from self._consume_item_service(service_cls=GetItem, items=ids, chunk_size=chunk_size, kwargs=dict( + yield from self._consume_item_service( + service_cls=GetItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( additional_fields=additional_fields, shape=ID_ONLY, - )) + ), + ) def fetch_personas(self, ids): """Fetch personas by ID. @@ -1256,7 +1462,7 @@

          Classes

          return GetMailTips(protocol=self.protocol).get( sending_as=SendingAs(email_address=self.primary_smtp_address), recipients=[Mailbox(email_address=self.primary_smtp_address)], - mail_tips_requested='All', + mail_tips_requested="All", ) @property @@ -1266,7 +1472,7 @@

          Classes

          def __str__(self): if self.fullname: - return f'{self.primary_smtp_address} ({self.fullname})' + return f"{self.primary_smtp_address} ({self.fullname})" return self.primary_smtp_address

          Instance variables

          @@ -1792,7 +1998,7 @@

          Instance variables

          return GetMailTips(protocol=self.protocol).get( sending_as=SendingAs(email_address=self.primary_smtp_address), recipients=[Mailbox(email_address=self.primary_smtp_address)], - mail_tips_requested='All', + mail_tips_requested="All", )
          @@ -2336,9 +2542,15 @@

          Methods

          :return: A list containing True or an exception instance in stable order of the requested items """ - return list(self._consume_item_service(service_cls=ArchiveItem, items=ids, chunk_size=chunk_size, kwargs=dict( - to_folder=to_folder, - )) + return list( + self._consume_item_service( + service_cls=ArchiveItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + to_folder=to_folder, + ), + ) ) @@ -2364,9 +2576,16 @@

          Methods

          :return: Status for each send operation, in the same order as the input """ - return list(self._consume_item_service(service_cls=CopyItem, items=ids, chunk_size=chunk_size, kwargs=dict( - to_folder=to_folder, - ))) + return list( + self._consume_item_service( + service_cls=CopyItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + to_folder=to_folder, + ), + ) + )
          @@ -2388,8 +2607,9 @@

          Methods

          Expand source code -
          def bulk_create(self, folder, items, message_disposition=SAVE_ONLY, send_meeting_invitations=SEND_TO_NONE,
          -                chunk_size=None):
          +
          def bulk_create(
          +    self, folder, items, message_disposition=SAVE_ONLY, send_meeting_invitations=SEND_TO_NONE, chunk_size=None
          +):
               """Create new items in 'folder'.
           
               :param folder: the folder to create the items in
          @@ -2406,19 +2626,26 @@ 

          Methods

          """ if isinstance(items, QuerySet): # bulk_create() on a queryset does not make sense because it returns items that have already been created - raise ValueError('Cannot bulk create items from a QuerySet') + raise ValueError("Cannot bulk create items from a QuerySet") log.debug( - 'Adding items for %s (folder %s, message_disposition: %s, send_meeting_invitations: %s)', + "Adding items for %s (folder %s, message_disposition: %s, send_meeting_invitations: %s)", self, folder, message_disposition, send_meeting_invitations, ) - return list(self._consume_item_service(service_cls=CreateItem, items=items, chunk_size=chunk_size, kwargs=dict( - folder=folder, - message_disposition=message_disposition, - send_meeting_invitations=send_meeting_invitations, - )))
          + return list( + self._consume_item_service( + service_cls=CreateItem, + items=items, + chunk_size=chunk_size, + kwargs=dict( + folder=folder, + message_disposition=message_disposition, + send_meeting_invitations=send_meeting_invitations, + ), + ) + )
          @@ -2440,8 +2667,15 @@

          Methods

          Expand source code -
          def bulk_delete(self, ids, delete_type=HARD_DELETE, send_meeting_cancellations=SEND_TO_NONE,
          -                affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True, chunk_size=None):
          +
          def bulk_delete(
          +    self,
          +    ids,
          +    delete_type=HARD_DELETE,
          +    send_meeting_cancellations=SEND_TO_NONE,
          +    affected_task_occurrences=ALL_OCCURRENCES,
          +    suppress_read_receipts=True,
          +    chunk_size=None,
          +):
               """Bulk delete items.
           
               :param ids: an iterable of either (id, changekey) tuples or Item objects.
          @@ -2457,19 +2691,24 @@ 

          Methods

          :return: a list of either True or exception instances, in the same order as the input """ log.debug( - 'Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurrences: %s)', + "Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurrences: %s)", self, delete_type, send_meeting_cancellations, affected_task_occurrences, ) return list( - self._consume_item_service(service_cls=DeleteItem, items=ids, chunk_size=chunk_size, kwargs=dict( - delete_type=delete_type, - send_meeting_cancellations=send_meeting_cancellations, - affected_task_occurrences=affected_task_occurrences, - suppress_read_receipts=suppress_read_receipts, - )) + self._consume_item_service( + service_cls=DeleteItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + delete_type=delete_type, + send_meeting_cancellations=send_meeting_cancellations, + affected_task_occurrences=affected_task_occurrences, + suppress_read_receipts=suppress_read_receipts, + ), + ) )
          @@ -2499,10 +2738,17 @@

          Methods

          :return: A list containing the new IDs of the moved items, if items were moved, or True, or an exception instance, in stable order of the requested items. """ - return list(self._consume_item_service(service_cls=MarkAsJunk, items=ids, chunk_size=chunk_size, kwargs=dict( - is_junk=is_junk, - move_item=move_item, - )))
          + return list( + self._consume_item_service( + service_cls=MarkAsJunk, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + is_junk=is_junk, + move_item=move_item, + ), + ) + )
          @@ -2529,9 +2775,16 @@

          Methods

          :return: The new IDs of the moved items, in the same order as the input. If 'to_folder' is a public folder or a folder in a different mailbox, an empty list is returned. """ - return list(self._consume_item_service(service_cls=MoveItem, items=ids, chunk_size=chunk_size, kwargs=dict( - to_folder=to_folder, - )))
          + return list( + self._consume_item_service( + service_cls=MoveItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + to_folder=to_folder, + ), + ) + )
          @@ -2563,9 +2816,14 @@

          Methods

          if save_copy and not copy_to_folder: copy_to_folder = self.sent # 'Sent' is default EWS behaviour return list( - self._consume_item_service(service_cls=SendItem, items=ids, chunk_size=chunk_size, kwargs=dict( - saved_item_folder=copy_to_folder, - )) + self._consume_item_service( + service_cls=SendItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + saved_item_folder=copy_to_folder, + ), + ) )
          @@ -2589,9 +2847,15 @@

          Methods

          Expand source code -
          def bulk_update(self, items, conflict_resolution=AUTO_RESOLVE, message_disposition=SAVE_ONLY,
          -                send_meeting_invitations_or_cancellations=SEND_TO_NONE, suppress_read_receipts=True,
          -                chunk_size=None):
          +
          def bulk_update(
          +    self,
          +    items,
          +    conflict_resolution=AUTO_RESOLVE,
          +    message_disposition=SAVE_ONLY,
          +    send_meeting_invitations_or_cancellations=SEND_TO_NONE,
          +    suppress_read_receipts=True,
          +    chunk_size=None,
          +):
               """Bulk update existing items.
           
               :param items: a list of (Item, fieldnames) tuples, where 'Item' is an Item object, and 'fieldnames' is a list
          @@ -2611,20 +2875,27 @@ 

          Methods

          # fact, it could be dangerous if the queryset contains an '.only()'. This would wipe out certain fields # entirely. if isinstance(items, QuerySet): - raise ValueError('Cannot bulk update on a queryset') + raise ValueError("Cannot bulk update on a queryset") log.debug( - 'Updating items for %s (conflict_resolution %s, message_disposition: %s, send_meeting_invitations: %s)', + "Updating items for %s (conflict_resolution %s, message_disposition: %s, send_meeting_invitations: %s)", self, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, ) - return list(self._consume_item_service(service_cls=UpdateItem, items=items, chunk_size=chunk_size, kwargs=dict( - conflict_resolution=conflict_resolution, - message_disposition=message_disposition, - send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations, - suppress_read_receipts=suppress_read_receipts, - )))
          + return list( + self._consume_item_service( + service_cls=UpdateItem, + items=items, + chunk_size=chunk_size, + kwargs=dict( + conflict_resolution=conflict_resolution, + message_disposition=message_disposition, + send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations, + suppress_read_receipts=suppress_read_receipts, + ), + ) + )
          @@ -2647,9 +2918,7 @@

          Methods

          :return: A list of strings, the exported representation of the object """ - return list( - self._consume_item_service(service_cls=ExportItems, items=items, chunk_size=chunk_size, kwargs={}) - )
          + return list(self._consume_item_service(service_cls=ExportItems, items=items, chunk_size=chunk_size, kwargs={}))
          @@ -2689,13 +2958,19 @@

          Methods

          for field in only_fields: validation_folder.validate_item_field(field=field, version=self.version) # Remove ItemId and ChangeKey. We get them unconditionally - additional_fields = {f for f in validation_folder.normalize_fields(fields=only_fields) - if not f.field.is_attribute} + additional_fields = { + f for f in validation_folder.normalize_fields(fields=only_fields) if not f.field.is_attribute + } # Always use IdOnly here, because AllProperties doesn't actually get *all* properties - yield from self._consume_item_service(service_cls=GetItem, items=ids, chunk_size=chunk_size, kwargs=dict( + yield from self._consume_item_service( + service_cls=GetItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( additional_fields=additional_fields, shape=ID_ONLY, - ))
          + ), + )
          @@ -2768,9 +3043,7 @@

          Methods

          -> [("idA", "changekey"), ("idB", "changekey"), ("idC", "changekey")] """ items = ((f, (None, False, d) if isinstance(d, str) else d) for f, d in data) - return list( - self._consume_item_service(service_cls=UploadItems, items=items, chunk_size=chunk_size, kwargs={}) - )
          + return list(self._consume_item_service(service_cls=UploadItems, items=items, chunk_size=chunk_size, kwargs={})) diff --git a/docs/exchangelib/attachments.html b/docs/exchangelib/attachments.html index ee303c78..4f0fe14d 100644 --- a/docs/exchangelib/attachments.html +++ b/docs/exchangelib/attachments.html @@ -31,8 +31,18 @@

          Module exchangelib.attachments

          import mimetypes from .errors import InvalidTypeError -from .fields import BooleanField, TextField, IntegerField, URIField, DateTimeField, EWSElementField, Base64Field, \ - ItemField, IdField, FieldPath +from .fields import ( + Base64Field, + BooleanField, + DateTimeField, + EWSElementField, + FieldPath, + IdField, + IntegerField, + ItemField, + TextField, + URIField, +) from .properties import EWSElement, EWSMeta log = logging.getLogger(__name__) @@ -44,11 +54,11 @@

          Module exchangelib.attachments

          MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/attachmentid """ - ELEMENT_NAME = 'AttachmentId' + ELEMENT_NAME = "AttachmentId" - ID_ATTR = 'Id' - ROOT_ID_ATTR = 'RootItemId' - ROOT_CHANGEKEY_ATTR = 'RootItemChangeKey' + ID_ATTR = "Id" + ROOT_ID_ATTR = "RootItemId" + ROOT_CHANGEKEY_ATTR = "RootItemChangeKey" id = IdField(field_uri=ID_ATTR, is_required=True) root_id = IdField(field_uri=ROOT_ID_ATTR) @@ -65,35 +75,37 @@

          Module exchangelib.attachments

          """Base class for FileAttachment and ItemAttachment.""" attachment_id = EWSElementField(value_cls=AttachmentId) - name = TextField(field_uri='Name') - content_type = TextField(field_uri='ContentType') - content_id = TextField(field_uri='ContentId') - content_location = URIField(field_uri='ContentLocation') - size = IntegerField(field_uri='Size', is_read_only=True) # Attachment size in bytes - last_modified_time = DateTimeField(field_uri='LastModifiedTime') - is_inline = BooleanField(field_uri='IsInline') + name = TextField(field_uri="Name") + content_type = TextField(field_uri="ContentType") + content_id = TextField(field_uri="ContentId") + content_location = URIField(field_uri="ContentLocation") + size = IntegerField(field_uri="Size", is_read_only=True) # Attachment size in bytes + last_modified_time = DateTimeField(field_uri="LastModifiedTime") + is_inline = BooleanField(field_uri="IsInline") - __slots__ = 'parent_item', + __slots__ = ("parent_item",) def __init__(self, **kwargs): - self.parent_item = kwargs.pop('parent_item', None) + self.parent_item = kwargs.pop("parent_item", None) super().__init__(**kwargs) def clean(self, version=None): from .items import Item + if self.parent_item is not None and not isinstance(self.parent_item, Item): - raise InvalidTypeError('parent_item', self.parent_item, Item) + raise InvalidTypeError("parent_item", self.parent_item, Item) if self.content_type is None and self.name is not None: - self.content_type = mimetypes.guess_type(self.name)[0] or 'application/octet-stream' + self.content_type = mimetypes.guess_type(self.name)[0] or "application/octet-stream" super().clean(version=version) def attach(self): from .services import CreateAttachment + # Adds this attachment to an item and updates the changekey of the parent item if self.attachment_id: - raise ValueError('This attachment has already been created') + raise ValueError("This attachment has already been created") if not self.parent_item or not self.parent_item.account: - raise ValueError(f'Parent item {self.parent_item} must have an account') + raise ValueError(f"Parent item {self.parent_item} must have an account") item = CreateAttachment(account=self.parent_item.account).get(parent_item=self.parent_item, items=[self]) attachment_id = item.attachment_id self.parent_item.changekey = attachment_id.root_changekey @@ -104,11 +116,12 @@

          Module exchangelib.attachments

          def detach(self): from .services import DeleteAttachment + # Deletes an attachment remotely and updates the changekey of the parent item if not self.attachment_id: - raise ValueError('This attachment has not been created') + raise ValueError("This attachment has not been created") if not self.parent_item or not self.parent_item.account: - raise ValueError(f'Parent item {self.parent_item} must have an account') + raise ValueError(f"Parent item {self.parent_item} must have an account") DeleteAttachment(account=self.parent_item.account).get(items=[self.attachment_id]) self.parent_item = None self.attachment_id = None @@ -117,27 +130,27 @@

          Module exchangelib.attachments

          if self.attachment_id: return hash(self.attachment_id) # Be careful to avoid recursion on the back-reference to the parent item - return hash(tuple(getattr(self, f) for f in self._slots_keys if f != 'parent_item')) + return hash(tuple(getattr(self, f) for f in self._slots_keys if f != "parent_item")) def __repr__(self): - args_str = ', '.join( - f'{f.name}={getattr(self, f.name)!r}' for f in self.FIELDS if f.name not in ('_item', '_content') + args_str = ", ".join( + f"{f.name}={getattr(self, f.name)!r}" for f in self.FIELDS if f.name not in ("_item", "_content") ) - return f'{self.__class__.__name__}({args_str})' + return f"{self.__class__.__name__}({args_str})" class FileAttachment(Attachment): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/fileattachment""" - ELEMENT_NAME = 'FileAttachment' + ELEMENT_NAME = "FileAttachment" - is_contact_photo = BooleanField(field_uri='IsContactPhoto') - _content = Base64Field(field_uri='Content') + is_contact_photo = BooleanField(field_uri="IsContactPhoto") + _content = Base64Field(field_uri="Content") - __slots__ = '_fp', + __slots__ = ("_fp",) def __init__(self, **kwargs): - kwargs['_content'] = kwargs.pop('content', None) + kwargs["_content"] = kwargs.pop("content", None) super().__init__(**kwargs) self._fp = None @@ -152,7 +165,7 @@

          Module exchangelib.attachments

          # Create a file-like object for the attachment content. We try hard to reduce memory consumption so we never # store the full attachment content in-memory. if not self.parent_item or not self.parent_item.account: - raise ValueError(f'{self.__class__.__name__} must have an account') + raise ValueError(f"{self.__class__.__name__} must have an account") self._fp = FileAttachmentIO(attachment=self) @property @@ -173,13 +186,13 @@

          Module exchangelib.attachments

          def content(self, value): """Replace the attachment content.""" if not isinstance(value, bytes): - raise InvalidTypeError('value', value, bytes) + raise InvalidTypeError("value", value, bytes) self._content = value @classmethod def from_xml(cls, elem, account): kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS} - kwargs['content'] = kwargs.pop('_content') + kwargs["content"] = kwargs.pop("_content") cls._clear(elem) return cls(**kwargs) @@ -190,7 +203,7 @@

          Module exchangelib.attachments

          def __getstate__(self): # The fp does not need to be pickled state = {k: getattr(self, k) for k in self._slots_keys} - del state['_fp'] + del state["_fp"] return state def __setstate__(self, state): @@ -203,30 +216,34 @@

          Module exchangelib.attachments

          class ItemAttachment(Attachment): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/itemattachment""" - ELEMENT_NAME = 'ItemAttachment' + ELEMENT_NAME = "ItemAttachment" - _item = ItemField(field_uri='Item') + _item = ItemField(field_uri="Item") def __init__(self, **kwargs): - kwargs['_item'] = kwargs.pop('item', None) + kwargs["_item"] = kwargs.pop("item", None) super().__init__(**kwargs) @property def item(self): from .folders import BaseFolder from .services import GetAttachment + if self.attachment_id is None: return self._item if self._item is not None: return self._item # We have an ID to the data but still haven't called GetAttachment to get the actual data. Do that now. if not self.parent_item or not self.parent_item.account: - raise ValueError(f'{self.__class__.__name__} must have an account') + raise ValueError(f"{self.__class__.__name__} must have an account") additional_fields = { FieldPath(field=f) for f in BaseFolder.allowed_item_fields(version=self.parent_item.account.version) } attachment = GetAttachment(account=self.parent_item.account).get( - items=[self.attachment_id], include_mime_content=True, body_type=None, filter_html_content=None, + items=[self.attachment_id], + include_mime_content=True, + body_type=None, + filter_html_content=None, additional_fields=additional_fields, ) self._item = attachment.item @@ -235,14 +252,15 @@

          Module exchangelib.attachments

          @item.setter def item(self, value): from .items import Item + if not isinstance(value, Item): - raise InvalidTypeError('value', value, Item) + raise InvalidTypeError("value", value, Item) self._item = value @classmethod def from_xml(cls, elem, account): kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS} - kwargs['item'] = kwargs.pop('_item') + kwargs["item"] = kwargs.pop("_item") cls._clear(elem) return cls(**kwargs) @@ -270,11 +288,12 @@

          Module exchangelib.attachments

          return 0 else: output, self._overflow = chunk[:buf_size], chunk[buf_size:] - b[:len(output)] = output + b[: len(output)] = output return len(output) def __enter__(self): from .services import GetAttachment + self._stream = GetAttachment(account=self._attachment.parent_item.account).stream_file_content( attachment_id=self._attachment.attachment_id ) @@ -309,35 +328,37 @@

          Classes

          """Base class for FileAttachment and ItemAttachment.""" attachment_id = EWSElementField(value_cls=AttachmentId) - name = TextField(field_uri='Name') - content_type = TextField(field_uri='ContentType') - content_id = TextField(field_uri='ContentId') - content_location = URIField(field_uri='ContentLocation') - size = IntegerField(field_uri='Size', is_read_only=True) # Attachment size in bytes - last_modified_time = DateTimeField(field_uri='LastModifiedTime') - is_inline = BooleanField(field_uri='IsInline') + name = TextField(field_uri="Name") + content_type = TextField(field_uri="ContentType") + content_id = TextField(field_uri="ContentId") + content_location = URIField(field_uri="ContentLocation") + size = IntegerField(field_uri="Size", is_read_only=True) # Attachment size in bytes + last_modified_time = DateTimeField(field_uri="LastModifiedTime") + is_inline = BooleanField(field_uri="IsInline") - __slots__ = 'parent_item', + __slots__ = ("parent_item",) def __init__(self, **kwargs): - self.parent_item = kwargs.pop('parent_item', None) + self.parent_item = kwargs.pop("parent_item", None) super().__init__(**kwargs) def clean(self, version=None): from .items import Item + if self.parent_item is not None and not isinstance(self.parent_item, Item): - raise InvalidTypeError('parent_item', self.parent_item, Item) + raise InvalidTypeError("parent_item", self.parent_item, Item) if self.content_type is None and self.name is not None: - self.content_type = mimetypes.guess_type(self.name)[0] or 'application/octet-stream' + self.content_type = mimetypes.guess_type(self.name)[0] or "application/octet-stream" super().clean(version=version) def attach(self): from .services import CreateAttachment + # Adds this attachment to an item and updates the changekey of the parent item if self.attachment_id: - raise ValueError('This attachment has already been created') + raise ValueError("This attachment has already been created") if not self.parent_item or not self.parent_item.account: - raise ValueError(f'Parent item {self.parent_item} must have an account') + raise ValueError(f"Parent item {self.parent_item} must have an account") item = CreateAttachment(account=self.parent_item.account).get(parent_item=self.parent_item, items=[self]) attachment_id = item.attachment_id self.parent_item.changekey = attachment_id.root_changekey @@ -348,11 +369,12 @@

          Classes

          def detach(self): from .services import DeleteAttachment + # Deletes an attachment remotely and updates the changekey of the parent item if not self.attachment_id: - raise ValueError('This attachment has not been created') + raise ValueError("This attachment has not been created") if not self.parent_item or not self.parent_item.account: - raise ValueError(f'Parent item {self.parent_item} must have an account') + raise ValueError(f"Parent item {self.parent_item} must have an account") DeleteAttachment(account=self.parent_item.account).get(items=[self.attachment_id]) self.parent_item = None self.attachment_id = None @@ -361,13 +383,13 @@

          Classes

          if self.attachment_id: return hash(self.attachment_id) # Be careful to avoid recursion on the back-reference to the parent item - return hash(tuple(getattr(self, f) for f in self._slots_keys if f != 'parent_item')) + return hash(tuple(getattr(self, f) for f in self._slots_keys if f != "parent_item")) def __repr__(self): - args_str = ', '.join( - f'{f.name}={getattr(self, f.name)!r}' for f in self.FIELDS if f.name not in ('_item', '_content') + args_str = ", ".join( + f"{f.name}={getattr(self, f.name)!r}" for f in self.FIELDS if f.name not in ("_item", "_content") ) - return f'{self.__class__.__name__}({args_str})' + return f"{self.__class__.__name__}({args_str})"

          Ancestors

            @@ -437,11 +459,12 @@

            Methods

            def attach(self):
                 from .services import CreateAttachment
            +
                 # Adds this attachment to an item and updates the changekey of the parent item
                 if self.attachment_id:
            -        raise ValueError('This attachment has already been created')
            +        raise ValueError("This attachment has already been created")
                 if not self.parent_item or not self.parent_item.account:
            -        raise ValueError(f'Parent item {self.parent_item} must have an account')
            +        raise ValueError(f"Parent item {self.parent_item} must have an account")
                 item = CreateAttachment(account=self.parent_item.account).get(parent_item=self.parent_item, items=[self])
                 attachment_id = item.attachment_id
                 self.parent_item.changekey = attachment_id.root_changekey
            @@ -462,10 +485,11 @@ 

            Methods

            def clean(self, version=None):
                 from .items import Item
            +
                 if self.parent_item is not None and not isinstance(self.parent_item, Item):
            -        raise InvalidTypeError('parent_item', self.parent_item, Item)
            +        raise InvalidTypeError("parent_item", self.parent_item, Item)
                 if self.content_type is None and self.name is not None:
            -        self.content_type = mimetypes.guess_type(self.name)[0] or 'application/octet-stream'
            +        self.content_type = mimetypes.guess_type(self.name)[0] or "application/octet-stream"
                 super().clean(version=version)
            @@ -480,11 +504,12 @@

            Methods

            def detach(self):
                 from .services import DeleteAttachment
            +
                 # Deletes an attachment remotely and updates the changekey of the parent item
                 if not self.attachment_id:
            -        raise ValueError('This attachment has not been created')
            +        raise ValueError("This attachment has not been created")
                 if not self.parent_item or not self.parent_item.account:
            -        raise ValueError(f'Parent item {self.parent_item} must have an account')
            +        raise ValueError(f"Parent item {self.parent_item} must have an account")
                 DeleteAttachment(account=self.parent_item.account).get(items=[self.attachment_id])
                 self.parent_item = None
                 self.attachment_id = None
            @@ -520,11 +545,11 @@

            Inherited members

            MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/attachmentid """ - ELEMENT_NAME = 'AttachmentId' + ELEMENT_NAME = "AttachmentId" - ID_ATTR = 'Id' - ROOT_ID_ATTR = 'RootItemId' - ROOT_CHANGEKEY_ATTR = 'RootItemChangeKey' + ID_ATTR = "Id" + ROOT_ID_ATTR = "RootItemId" + ROOT_CHANGEKEY_ATTR = "RootItemChangeKey" id = IdField(field_uri=ID_ATTR, is_required=True) root_id = IdField(field_uri=ROOT_ID_ATTR) @@ -603,15 +628,15 @@

            Inherited members

            class FileAttachment(Attachment):
                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/fileattachment"""
             
            -    ELEMENT_NAME = 'FileAttachment'
            +    ELEMENT_NAME = "FileAttachment"
             
            -    is_contact_photo = BooleanField(field_uri='IsContactPhoto')
            -    _content = Base64Field(field_uri='Content')
            +    is_contact_photo = BooleanField(field_uri="IsContactPhoto")
            +    _content = Base64Field(field_uri="Content")
             
            -    __slots__ = '_fp',
            +    __slots__ = ("_fp",)
             
                 def __init__(self, **kwargs):
            -        kwargs['_content'] = kwargs.pop('content', None)
            +        kwargs["_content"] = kwargs.pop("content", None)
                     super().__init__(**kwargs)
                     self._fp = None
             
            @@ -626,7 +651,7 @@ 

            Inherited members

            # Create a file-like object for the attachment content. We try hard to reduce memory consumption so we never # store the full attachment content in-memory. if not self.parent_item or not self.parent_item.account: - raise ValueError(f'{self.__class__.__name__} must have an account') + raise ValueError(f"{self.__class__.__name__} must have an account") self._fp = FileAttachmentIO(attachment=self) @property @@ -647,13 +672,13 @@

            Inherited members

            def content(self, value): """Replace the attachment content.""" if not isinstance(value, bytes): - raise InvalidTypeError('value', value, bytes) + raise InvalidTypeError("value", value, bytes) self._content = value @classmethod def from_xml(cls, elem, account): kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS} - kwargs['content'] = kwargs.pop('_content') + kwargs["content"] = kwargs.pop("_content") cls._clear(elem) return cls(**kwargs) @@ -664,7 +689,7 @@

            Inherited members

            def __getstate__(self): # The fp does not need to be pickled state = {k: getattr(self, k) for k in self._slots_keys} - del state['_fp'] + del state["_fp"] return state def __setstate__(self, state): @@ -703,7 +728,7 @@

            Static methods

            @classmethod
             def from_xml(cls, elem, account):
                 kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS}
            -    kwargs['content'] = kwargs.pop('_content')
            +    kwargs["content"] = kwargs.pop("_content")
                 cls._clear(elem)
                 return cls(**kwargs)
            @@ -817,11 +842,12 @@

            Inherited members

            return 0 else: output, self._overflow = chunk[:buf_size], chunk[buf_size:] - b[:len(output)] = output + b[: len(output)] = output return len(output) def __enter__(self): from .services import GetAttachment + self._stream = GetAttachment(account=self._attachment.parent_item.account).stream_file_content( attachment_id=self._attachment.attachment_id ) @@ -887,7 +913,7 @@

            Methods

            return 0 else: output, self._overflow = chunk[:buf_size], chunk[buf_size:] - b[:len(output)] = output + b[: len(output)] = output return len(output)
            @@ -906,30 +932,34 @@

            Methods

            class ItemAttachment(Attachment):
                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/itemattachment"""
             
            -    ELEMENT_NAME = 'ItemAttachment'
            +    ELEMENT_NAME = "ItemAttachment"
             
            -    _item = ItemField(field_uri='Item')
            +    _item = ItemField(field_uri="Item")
             
                 def __init__(self, **kwargs):
            -        kwargs['_item'] = kwargs.pop('item', None)
            +        kwargs["_item"] = kwargs.pop("item", None)
                     super().__init__(**kwargs)
             
                 @property
                 def item(self):
                     from .folders import BaseFolder
                     from .services import GetAttachment
            +
                     if self.attachment_id is None:
                         return self._item
                     if self._item is not None:
                         return self._item
                     # We have an ID to the data but still haven't called GetAttachment to get the actual data. Do that now.
                     if not self.parent_item or not self.parent_item.account:
            -            raise ValueError(f'{self.__class__.__name__} must have an account')
            +            raise ValueError(f"{self.__class__.__name__} must have an account")
                     additional_fields = {
                         FieldPath(field=f) for f in BaseFolder.allowed_item_fields(version=self.parent_item.account.version)
                     }
                     attachment = GetAttachment(account=self.parent_item.account).get(
            -            items=[self.attachment_id], include_mime_content=True, body_type=None, filter_html_content=None,
            +            items=[self.attachment_id],
            +            include_mime_content=True,
            +            body_type=None,
            +            filter_html_content=None,
                         additional_fields=additional_fields,
                     )
                     self._item = attachment.item
            @@ -938,14 +968,15 @@ 

            Methods

            @item.setter def item(self, value): from .items import Item + if not isinstance(value, Item): - raise InvalidTypeError('value', value, Item) + raise InvalidTypeError("value", value, Item) self._item = value @classmethod def from_xml(cls, elem, account): kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS} - kwargs['item'] = kwargs.pop('_item') + kwargs["item"] = kwargs.pop("_item") cls._clear(elem) return cls(**kwargs)
            @@ -979,7 +1010,7 @@

            Static methods

            @classmethod
             def from_xml(cls, elem, account):
                 kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS}
            -    kwargs['item'] = kwargs.pop('_item')
            +    kwargs["item"] = kwargs.pop("_item")
                 cls._clear(elem)
                 return cls(**kwargs)
            @@ -998,18 +1029,22 @@

            Instance variables

            def item(self): from .folders import BaseFolder from .services import GetAttachment + if self.attachment_id is None: return self._item if self._item is not None: return self._item # We have an ID to the data but still haven't called GetAttachment to get the actual data. Do that now. if not self.parent_item or not self.parent_item.account: - raise ValueError(f'{self.__class__.__name__} must have an account') + raise ValueError(f"{self.__class__.__name__} must have an account") additional_fields = { FieldPath(field=f) for f in BaseFolder.allowed_item_fields(version=self.parent_item.account.version) } attachment = GetAttachment(account=self.parent_item.account).get( - items=[self.attachment_id], include_mime_content=True, body_type=None, filter_html_content=None, + items=[self.attachment_id], + include_mime_content=True, + body_type=None, + filter_html_content=None, additional_fields=additional_fields, ) self._item = attachment.item diff --git a/docs/exchangelib/autodiscover/cache.html b/docs/exchangelib/autodiscover/cache.html index ce548cae..e7ddaec1 100644 --- a/docs/exchangelib/autodiscover/cache.html +++ b/docs/exchangelib/autodiscover/cache.html @@ -36,8 +36,8 @@

            Module exchangelib.autodiscover.cache

            from contextlib import contextmanager from threading import RLock -from .protocol import AutodiscoverProtocol from ..configuration import Configuration +from .protocol import AutodiscoverProtocol log = logging.getLogger(__name__) @@ -53,8 +53,8 @@

            Module exchangelib.autodiscover.cache

            user = getpass.getuser() except KeyError: # getuser() fails on some systems. Provide a sane default. See issue #448 - user = 'exchangelib' - return f'exchangelib.{version}.cache.{user}.py{major}{minor}' + user = "exchangelib" + return f"exchangelib.{version}.cache.{user}.py{major}{minor}" AUTODISCOVER_PERSISTENT_STORAGE = os.path.join(tempfile.gettempdir(), shelve_filename()) @@ -70,13 +70,13 @@

            Module exchangelib.autodiscover.cache

            # Try to actually use the shelve. Some implementations may allow opening the file but then throw # errors on access. try: - _ = shelve_handle[''] + _ = shelve_handle[""] except KeyError: # The entry doesn't exist. This is expected. pass except Exception as e: - for f in glob.glob(filename + '*'): - log.warning('Deleting invalid cache file %s (%r)', f, e) + for f in glob.glob(filename + "*"): + log.warning("Deleting invalid cache file %s (%r)", f, e) os.unlink(f) shelve_handle = shelve.open(filename) yield shelve_handle @@ -128,9 +128,11 @@

            Module exchangelib.autodiscover.cache

            domain, credentials = key with shelve_open_with_failover(self._storage_file) as db: endpoint, auth_type, retry_policy = db[str(domain)] # It's OK to fail with KeyError here - protocol = AutodiscoverProtocol(config=Configuration( - service_endpoint=endpoint, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy - )) + protocol = AutodiscoverProtocol( + config=Configuration( + service_endpoint=endpoint, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy + ) + ) self._protocols[key] = protocol return protocol @@ -159,7 +161,7 @@

            Module exchangelib.autodiscover.cache

            def close(self): # Close all open connections for (domain, _), protocol in self._protocols.items(): - log.debug('Domain %s: Closing sessions', domain) + log.debug("Domain %s: Closing sessions", domain) protocol.close() del protocol self._protocols.clear() @@ -212,8 +214,8 @@

            Functions

            user = getpass.getuser() except KeyError: # getuser() fails on some systems. Provide a sane default. See issue #448 - user = 'exchangelib' - return f'exchangelib.{version}.cache.{user}.py{major}{minor}'
            + user = "exchangelib" + return f"exchangelib.{version}.cache.{user}.py{major}{minor}"
            @@ -235,13 +237,13 @@

            Functions

            # Try to actually use the shelve. Some implementations may allow opening the file but then throw # errors on access. try: - _ = shelve_handle[''] + _ = shelve_handle[""] except KeyError: # The entry doesn't exist. This is expected. pass except Exception as e: - for f in glob.glob(filename + '*'): - log.warning('Deleting invalid cache file %s (%r)', f, e) + for f in glob.glob(filename + "*"): + log.warning("Deleting invalid cache file %s (%r)", f, e) os.unlink(f) shelve_handle = shelve.open(filename) yield shelve_handle
            @@ -317,9 +319,11 @@

            Classes

            domain, credentials = key with shelve_open_with_failover(self._storage_file) as db: endpoint, auth_type, retry_policy = db[str(domain)] # It's OK to fail with KeyError here - protocol = AutodiscoverProtocol(config=Configuration( - service_endpoint=endpoint, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy - )) + protocol = AutodiscoverProtocol( + config=Configuration( + service_endpoint=endpoint, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy + ) + ) self._protocols[key] = protocol return protocol @@ -348,7 +352,7 @@

            Classes

            def close(self): # Close all open connections for (domain, _), protocol in self._protocols.items(): - log.debug('Domain %s: Closing sessions', domain) + log.debug("Domain %s: Closing sessions", domain) protocol.close() del protocol self._protocols.clear() @@ -400,7 +404,7 @@

            Methods

            def close(self):
                 # Close all open connections
                 for (domain, _), protocol in self._protocols.items():
            -        log.debug('Domain %s: Closing sessions', domain)
            +        log.debug("Domain %s: Closing sessions", domain)
                     protocol.close()
                     del protocol
                 self._protocols.clear()
            diff --git a/docs/exchangelib/autodiscover/discovery.html b/docs/exchangelib/autodiscover/discovery.html index ed62ba7f..91649b6d 100644 --- a/docs/exchangelib/autodiscover/discovery.html +++ b/docs/exchangelib/autodiscover/discovery.html @@ -30,27 +30,33 @@

            Module exchangelib.autodiscover.discovery

            import time from urllib.parse import urlparse -import dns.resolver import dns.name +import dns.resolver from cached_property import threaded_cached_property +from ..configuration import Configuration +from ..errors import AutoDiscoverCircularRedirect, AutoDiscoverFailed, RedirectError, TransportError, UnauthorizedError +from ..protocol import FailFast, Protocol +from ..transport import AUTH_TYPE_MAP, DEFAULT_HEADERS, GSSAPI, NOAUTH, get_auth_method_from_response +from ..util import ( + CONNECTION_ERRORS, + TLS_ERRORS, + DummyResponse, + ParseError, + _back_off_if_needed, + get_domain, + get_redirect_url, + post_ratelimited, +) from .cache import autodiscover_cache from .properties import Autodiscover from .protocol import AutodiscoverProtocol -from ..configuration import Configuration -from ..errors import AutoDiscoverFailed, AutoDiscoverCircularRedirect, TransportError, RedirectError, UnauthorizedError -from ..protocol import Protocol, FailFast -from ..transport import get_auth_method_from_response, DEFAULT_HEADERS, NOAUTH, GSSAPI, AUTH_TYPE_MAP -from ..util import post_ratelimited, get_domain, get_redirect_url, _back_off_if_needed, \ - DummyResponse, ParseError, CONNECTION_ERRORS, TLS_ERRORS log = logging.getLogger(__name__) def discover(email, credentials=None, auth_type=None, retry_policy=None): - ad_response, protocol = Autodiscovery( - email=email, credentials=credentials - ).discover() + ad_response, protocol = Autodiscovery(email=email, credentials=credentials).discover() protocol.config.auth_typ = auth_type protocol.config.retry_policy = retry_policy return ad_response, protocol @@ -103,7 +109,7 @@

            Module exchangelib.autodiscover.discovery

            MAX_REDIRECTS = 10 # Maximum number of URL redirects before we give up DNS_RESOLVER_KWARGS = {} DNS_RESOLVER_ATTRS = { - 'timeout': AutodiscoverProtocol.TIMEOUT, + "timeout": AutodiscoverProtocol.TIMEOUT, } def __init__(self, email, credentials=None): @@ -124,31 +130,31 @@

            Module exchangelib.autodiscover.discovery

            # Check the autodiscover cache to see if we already know the autodiscover service endpoint for this email # domain. Use a lock to guard against multiple threads competing to cache information. - log.debug('Waiting for autodiscover_cache lock') + log.debug("Waiting for autodiscover_cache lock") with autodiscover_cache: - log.debug('autodiscover_cache lock acquired') + log.debug("autodiscover_cache lock acquired") cache_key = self._cache_key domain = get_domain(self.email) if cache_key in autodiscover_cache: ad_protocol = autodiscover_cache[cache_key] - log.debug('Cache hit for key %s: %s', cache_key, ad_protocol.service_endpoint) + log.debug("Cache hit for key %s: %s", cache_key, ad_protocol.service_endpoint) try: ad_response = self._quick(protocol=ad_protocol) except AutoDiscoverFailed: # Autodiscover no longer works with this domain. Clear cache and try again after releasing the lock - log.debug('AD request failure. Removing cache for key %s', cache_key) + log.debug("AD request failure. Removing cache for key %s", cache_key) del autodiscover_cache[cache_key] ad_response = self._step_1(hostname=domain) else: # This will cache the result - log.debug('Cache miss for key %s', cache_key) + log.debug("Cache miss for key %s", cache_key) ad_response = self._step_1(hostname=domain) - log.debug('Released autodiscover_cache_lock') + log.debug("Released autodiscover_cache_lock") if ad_response.redirect_address: - log.debug('Got a redirect address: %s', ad_response.redirect_address) + log.debug("Got a redirect address: %s", ad_response.redirect_address) if ad_response.redirect_address.lower() in self._emails_visited: - raise AutoDiscoverCircularRedirect('We were redirected to an email address we have already seen') + raise AutoDiscoverCircularRedirect("We were redirected to an email address we have already seen") # Start over, but with the new email address self.email = ad_response.redirect_address @@ -197,15 +203,15 @@

            Module exchangelib.autodiscover.discovery

            try: r = self._get_authenticated_response(protocol=protocol) except TransportError as e: - raise AutoDiscoverFailed(f'Response error: {e}') + raise AutoDiscoverFailed(f"Response error: {e}") if r.status_code == 200: try: ad = Autodiscover.from_bytes(bytes_content=r.content) except ParseError as e: - raise AutoDiscoverFailed(f'Invalid response: {e}') + raise AutoDiscoverFailed(f"Invalid response: {e}") else: return self._step_5(ad=ad) - raise AutoDiscoverFailed(f'Invalid response code: {r.status_code}') + raise AutoDiscoverFailed(f"Invalid response code: {r.status_code}") def _redirect_url_is_valid(self, url): """Three separate responses can be “Redirect responses”: @@ -221,29 +227,29 @@

            Module exchangelib.autodiscover.discovery

            :return: """ if url.lower() in self._urls_visited: - log.warning('We have already tried this URL: %s', url) + log.warning("We have already tried this URL: %s", url) return False if self._redirect_count >= self.MAX_REDIRECTS: - log.warning('We reached max redirects at URL: %s', url) + log.warning("We reached max redirects at URL: %s", url) return False # We require TLS endpoints - if not url.startswith('https://'): - log.debug('Invalid scheme for URL: %s', url) + if not url.startswith("https://"): + log.debug("Invalid scheme for URL: %s", url) return False # Quick test that the endpoint responds and that TLS handshake is OK try: - self._get_unauthenticated_response(url, method='head') + self._get_unauthenticated_response(url, method="head") except TransportError as e: - log.debug('Response error on redirect URL %s: %s', url, e) + log.debug("Response error on redirect URL %s: %s", url, e) return False self._redirect_count += 1 return True - def _get_unauthenticated_response(self, url, method='post'): + def _get_unauthenticated_response(self, url, method="post"): """Get auth type by tasting headers from the server. Do POST requests be default. HEAD is too error prone, and some servers are set up to redirect to OWA on all requests except POST to the autodiscover endpoint. @@ -256,18 +262,18 @@

            Module exchangelib.autodiscover.discovery

            if not self._is_valid_hostname(hostname): # 'requests' is really bad at reporting that a hostname cannot be resolved. Let's check this separately. # Don't retry on DNS errors. They will most likely be persistent. - raise TransportError(f'{hostname!r} has no DNS entry') + raise TransportError(f"{hostname!r} has no DNS entry") kwargs = dict( url=url, headers=DEFAULT_HEADERS.copy(), allow_redirects=False, timeout=AutodiscoverProtocol.TIMEOUT ) - if method == 'post': - kwargs['data'] = Autodiscover.payload(email=self.email) + if method == "post": + kwargs["data"] = Autodiscover.payload(email=self.email) retry = 0 t_start = time.monotonic() while True: _back_off_if_needed(self.INITIAL_RETRY_POLICY.back_off_until) - log.debug('Trying to get response from %s', url) + log.debug("Trying to get response from %s", url) with AutodiscoverProtocol.raw_session(url) as s: try: r = getattr(s, method)(**kwargs) @@ -277,7 +283,7 @@

            Module exchangelib.autodiscover.discovery

            # Don't retry on TLS errors. They will most likely be persistent. raise TransportError(str(e)) except CONNECTION_ERRORS as e: - r = DummyResponse(url=url, request_headers=kwargs['headers']) + r = DummyResponse(url=url, request_headers=kwargs["headers"]) total_wait = time.monotonic() - t_start if self.INITIAL_RETRY_POLICY.may_retry_on_error(response=r, wait=total_wait): log.debug("Connection error on URL %s (retry %s, error: %s). Cool down", url, retry, e) @@ -293,12 +299,12 @@

            Module exchangelib.autodiscover.discovery

            except UnauthorizedError: # Failed to guess the auth type auth_type = NOAUTH - if r.status_code in (301, 302) and 'location' in r.headers: + if r.status_code in (301, 302) and "location" in r.headers: # Make the redirect URL absolute try: - r.headers['location'] = get_redirect_url(r) + r.headers["location"] = get_redirect_url(r) except TransportError: - del r.headers['location'] + del r.headers["location"] return auth_type, r def _get_authenticated_response(self, protocol): @@ -313,17 +319,24 @@

            Module exchangelib.autodiscover.discovery

            session = protocol.get_session() if GSSAPI in AUTH_TYPE_MAP and isinstance(session.auth, AUTH_TYPE_MAP[GSSAPI]): # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/pox-autodiscover-request-for-exchange - headers['X-ClientCanHandle'] = 'Negotiate' + headers["X-ClientCanHandle"] = "Negotiate" try: - r, session = post_ratelimited(protocol=protocol, session=session, url=protocol.service_endpoint, - headers=headers, data=data, allow_redirects=False, stream=False) + r, session = post_ratelimited( + protocol=protocol, + session=session, + url=protocol.service_endpoint, + headers=headers, + data=data, + allow_redirects=False, + stream=False, + ) protocol.release_session(session) except UnauthorizedError as e: # It's entirely possible for the endpoint to ask for login. We should continue if login fails because this # isn't necessarily the right endpoint to use. raise TransportError(str(e)) except RedirectError as e: - r = DummyResponse(url=protocol.service_endpoint, headers={'location': e.url}, status_code=302) + r = DummyResponse(url=protocol.service_endpoint, headers={"location": e.url}, status_code=302) return r def _attempt_response(self, url): @@ -333,7 +346,7 @@

            Module exchangelib.autodiscover.discovery

            :return: """ self._urls_visited.append(url.lower()) - log.debug('Attempting to get a valid response from %s', url) + log.debug("Attempting to get a valid response from %s", url) try: auth_type, r = self._get_unauthenticated_response(url=url) ad_protocol = AutodiscoverProtocol( @@ -347,9 +360,9 @@

            Module exchangelib.autodiscover.discovery

            if auth_type != NOAUTH: r = self._get_authenticated_response(protocol=ad_protocol) except TransportError as e: - log.debug('Failed to get a response: %s', e) + log.debug("Failed to get a response: %s", e) return False, None - if r.status_code in (301, 302) and 'location' in r.headers: + if r.status_code in (301, 302) and "location" in r.headers: redirect_url = get_redirect_url(r) if self._redirect_url_is_valid(url=redirect_url): # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com @@ -359,18 +372,18 @@

            Module exchangelib.autodiscover.discovery

            try: ad = Autodiscover.from_bytes(bytes_content=r.content) except ParseError as e: - log.debug('Invalid response: %s', e) + log.debug("Invalid response: %s", e) else: # We got a valid response. Unless this is a URL redirect response, we cache the result if ad.response is None or not ad.response.redirect_url: cache_key = self._cache_key - log.debug('Adding cache entry for key %s: %s', cache_key, ad_protocol.service_endpoint) + log.debug("Adding cache entry for key %s: %s", cache_key, ad_protocol.service_endpoint) autodiscover_cache[cache_key] = ad_protocol return True, ad return False, None def _is_valid_hostname(self, hostname): - log.debug('Checking if %s can be looked up in DNS', hostname) + log.debug("Checking if %s can be looked up in DNS", hostname) try: self.resolver.resolve(hostname) except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.name.EmptyLabel): @@ -390,23 +403,23 @@

            Module exchangelib.autodiscover.discovery

            :param hostname: :return: """ - log.debug('Attempting to get SRV records for %s', hostname) + log.debug("Attempting to get SRV records for %s", hostname) records = [] try: - answers = self.resolver.resolve(f'{hostname}.', 'SRV') + answers = self.resolver.resolve(f"{hostname}.", "SRV") except (dns.resolver.NoNameservers, dns.resolver.NoAnswer, dns.resolver.NXDOMAIN) as e: - log.debug('DNS lookup failure: %s', e) + log.debug("DNS lookup failure: %s", e) return records for rdata in answers: try: - vals = rdata.to_text().strip().rstrip('.').split(' ') + vals = rdata.to_text().strip().rstrip(".").split(" ") # Raise ValueError if the first three are not ints, and IndexError if there are less than 4 values priority, weight, port, srv = int(vals[0]), int(vals[1]), int(vals[2]), vals[3] record = SrvRecord(priority=priority, weight=weight, port=port, srv=srv) - log.debug('Found SRV record %s ', record) + log.debug("Found SRV record %s ", record) records.append(record) except (ValueError, IndexError): - log.debug('Incompatible SRV record for %s (%s)', hostname, rdata.to_text()) + log.debug("Incompatible SRV record for %s (%s)", hostname, rdata.to_text()) return records def _step_1(self, hostname): @@ -418,8 +431,8 @@

            Module exchangelib.autodiscover.discovery

            :param hostname: :return: """ - url = f'https://{hostname}/Autodiscover/Autodiscover.xml' - log.info('Step 1: Trying autodiscover on %r with email %r', url, self.email) + url = f"https://{hostname}/Autodiscover/Autodiscover.xml" + log.info("Step 1: Trying autodiscover on %r with email %r", url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: return self._step_5(ad=ad) @@ -434,8 +447,8 @@

            Module exchangelib.autodiscover.discovery

            :param hostname: :return: """ - url = f'https://autodiscover.{hostname}/Autodiscover/Autodiscover.xml' - log.info('Step 2: Trying autodiscover on %r with email %r', url, self.email) + url = f"https://autodiscover.{hostname}/Autodiscover/Autodiscover.xml" + log.info("Step 2: Trying autodiscover on %r with email %r", url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: return self._step_5(ad=ad) @@ -457,23 +470,23 @@

            Module exchangelib.autodiscover.discovery

            :param hostname: :return: """ - url = f'http://autodiscover.{hostname}/Autodiscover/Autodiscover.xml' - log.info('Step 3: Trying autodiscover on %r with email %r', url, self.email) + url = f"http://autodiscover.{hostname}/Autodiscover/Autodiscover.xml" + log.info("Step 3: Trying autodiscover on %r with email %r", url, self.email) try: - _, r = self._get_unauthenticated_response(url=url, method='get') + _, r = self._get_unauthenticated_response(url=url, method="get") except TransportError: r = DummyResponse(url=url) - if r.status_code in (301, 302) and 'location' in r.headers: + if r.status_code in (301, 302) and "location" in r.headers: redirect_url = get_redirect_url(r) if self._redirect_url_is_valid(url=redirect_url): is_valid_response, ad = self._attempt_response(url=redirect_url) if is_valid_response: return self._step_5(ad=ad) - log.debug('Got invalid response') + log.debug("Got invalid response") return self._step_4(hostname=hostname) - log.debug('Got invalid redirect URL') + log.debug("Got invalid redirect URL") return self._step_4(hostname=hostname) - log.debug('Got no redirect URL') + log.debug("Got no redirect URL") return self._step_4(hostname=hostname) def _step_4(self, hostname): @@ -493,8 +506,8 @@

            Module exchangelib.autodiscover.discovery

            :param hostname: :return: """ - dns_hostname = f'_autodiscover._tcp.{hostname}' - log.info('Step 4: Trying autodiscover on %r with email %r', dns_hostname, self.email) + dns_hostname = f"_autodiscover._tcp.{hostname}" + log.info("Step 4: Trying autodiscover on %r with email %r", dns_hostname, self.email) srv_records = self._get_srv_records(dns_hostname) try: srv_host = _select_srv_host(srv_records) @@ -502,14 +515,14 @@

            Module exchangelib.autodiscover.discovery

            srv_host = None if not srv_host: return self._step_6() - redirect_url = f'https://{srv_host}/Autodiscover/Autodiscover.xml' + redirect_url = f"https://{srv_host}/Autodiscover/Autodiscover.xml" if self._redirect_url_is_valid(url=redirect_url): is_valid_response, ad = self._attempt_response(url=redirect_url) if is_valid_response: return self._step_5(ad=ad) - log.debug('Got invalid response') + log.debug("Got invalid response") return self._step_6() - log.debug('Got invalid redirect URL') + log.debug("Got invalid redirect URL") return self._step_6() def _step_5(self, ad): @@ -529,23 +542,23 @@

            Module exchangelib.autodiscover.discovery

            :param ad: :return: """ - log.info('Step 5: Checking response') + log.info("Step 5: Checking response") if ad.response is None: # This is not explicit in the protocol, but let's raise errors here ad.raise_errors() ad_response = ad.response if ad_response.redirect_url: - log.debug('Got a redirect URL: %s', ad_response.redirect_url) + log.debug("Got a redirect URL: %s", ad_response.redirect_url) # We are diverging a bit from the protocol here. We will never get an HTTP 302 since earlier steps already # followed the redirects where possible. Instead, we handle redirect responses here. if self._redirect_url_is_valid(url=ad_response.redirect_url): is_valid_response, ad = self._attempt_response(url=ad_response.redirect_url) if is_valid_response: return self._step_5(ad=ad) - log.debug('Got invalid response') + log.debug("Got invalid response") return self._step_6() - log.debug('Invalid redirect URL') + log.debug("Invalid redirect URL") return self._step_6() # This could be an email redirect. Let outer layer handle this return ad_response @@ -556,8 +569,9 @@

            Module exchangelib.autodiscover.discovery

            future requests. """ raise AutoDiscoverFailed( - f'All steps in the autodiscover protocol failed for email {self.email}. If you think this is an error, ' - f'consider doing an official test at https://testconnectivity.microsoft.com') + f"All steps in the autodiscover protocol failed for email {self.email}. If you think this is an error, " + f"consider doing an official test at https://testconnectivity.microsoft.com" + ) def _select_srv_host(srv_records): @@ -569,13 +583,13 @@

            Module exchangelib.autodiscover.discovery

            best_record = None for srv_record in srv_records: if srv_record.port != 443: - log.debug('Skipping SRV record %r (no TLS)', srv_record) + log.debug("Skipping SRV record %r (no TLS)", srv_record) continue # Assume port 443 will serve TLS. If not, autodiscover will probably also be broken for others. if best_record is None or best_record.priority < srv_record.priority: best_record = srv_record if not best_record: - raise ValueError('No suitable records') + raise ValueError("No suitable records") return best_record.srv @@ -596,9 +610,7 @@

            Functions

            Expand source code
            def discover(email, credentials=None, auth_type=None, retry_policy=None):
            -    ad_response, protocol = Autodiscovery(
            -        email=email, credentials=credentials
            -    ).discover()
            +    ad_response, protocol = Autodiscovery(email=email, credentials=credentials).discover()
                 protocol.config.auth_typ = auth_type
                 protocol.config.retry_policy = retry_policy
                 return ad_response, protocol
            @@ -671,7 +683,7 @@

            Classes

            MAX_REDIRECTS = 10 # Maximum number of URL redirects before we give up DNS_RESOLVER_KWARGS = {} DNS_RESOLVER_ATTRS = { - 'timeout': AutodiscoverProtocol.TIMEOUT, + "timeout": AutodiscoverProtocol.TIMEOUT, } def __init__(self, email, credentials=None): @@ -692,31 +704,31 @@

            Classes

            # Check the autodiscover cache to see if we already know the autodiscover service endpoint for this email # domain. Use a lock to guard against multiple threads competing to cache information. - log.debug('Waiting for autodiscover_cache lock') + log.debug("Waiting for autodiscover_cache lock") with autodiscover_cache: - log.debug('autodiscover_cache lock acquired') + log.debug("autodiscover_cache lock acquired") cache_key = self._cache_key domain = get_domain(self.email) if cache_key in autodiscover_cache: ad_protocol = autodiscover_cache[cache_key] - log.debug('Cache hit for key %s: %s', cache_key, ad_protocol.service_endpoint) + log.debug("Cache hit for key %s: %s", cache_key, ad_protocol.service_endpoint) try: ad_response = self._quick(protocol=ad_protocol) except AutoDiscoverFailed: # Autodiscover no longer works with this domain. Clear cache and try again after releasing the lock - log.debug('AD request failure. Removing cache for key %s', cache_key) + log.debug("AD request failure. Removing cache for key %s", cache_key) del autodiscover_cache[cache_key] ad_response = self._step_1(hostname=domain) else: # This will cache the result - log.debug('Cache miss for key %s', cache_key) + log.debug("Cache miss for key %s", cache_key) ad_response = self._step_1(hostname=domain) - log.debug('Released autodiscover_cache_lock') + log.debug("Released autodiscover_cache_lock") if ad_response.redirect_address: - log.debug('Got a redirect address: %s', ad_response.redirect_address) + log.debug("Got a redirect address: %s", ad_response.redirect_address) if ad_response.redirect_address.lower() in self._emails_visited: - raise AutoDiscoverCircularRedirect('We were redirected to an email address we have already seen') + raise AutoDiscoverCircularRedirect("We were redirected to an email address we have already seen") # Start over, but with the new email address self.email = ad_response.redirect_address @@ -765,15 +777,15 @@

            Classes

            try: r = self._get_authenticated_response(protocol=protocol) except TransportError as e: - raise AutoDiscoverFailed(f'Response error: {e}') + raise AutoDiscoverFailed(f"Response error: {e}") if r.status_code == 200: try: ad = Autodiscover.from_bytes(bytes_content=r.content) except ParseError as e: - raise AutoDiscoverFailed(f'Invalid response: {e}') + raise AutoDiscoverFailed(f"Invalid response: {e}") else: return self._step_5(ad=ad) - raise AutoDiscoverFailed(f'Invalid response code: {r.status_code}') + raise AutoDiscoverFailed(f"Invalid response code: {r.status_code}") def _redirect_url_is_valid(self, url): """Three separate responses can be “Redirect responses”: @@ -789,29 +801,29 @@

            Classes

            :return: """ if url.lower() in self._urls_visited: - log.warning('We have already tried this URL: %s', url) + log.warning("We have already tried this URL: %s", url) return False if self._redirect_count >= self.MAX_REDIRECTS: - log.warning('We reached max redirects at URL: %s', url) + log.warning("We reached max redirects at URL: %s", url) return False # We require TLS endpoints - if not url.startswith('https://'): - log.debug('Invalid scheme for URL: %s', url) + if not url.startswith("https://"): + log.debug("Invalid scheme for URL: %s", url) return False # Quick test that the endpoint responds and that TLS handshake is OK try: - self._get_unauthenticated_response(url, method='head') + self._get_unauthenticated_response(url, method="head") except TransportError as e: - log.debug('Response error on redirect URL %s: %s', url, e) + log.debug("Response error on redirect URL %s: %s", url, e) return False self._redirect_count += 1 return True - def _get_unauthenticated_response(self, url, method='post'): + def _get_unauthenticated_response(self, url, method="post"): """Get auth type by tasting headers from the server. Do POST requests be default. HEAD is too error prone, and some servers are set up to redirect to OWA on all requests except POST to the autodiscover endpoint. @@ -824,18 +836,18 @@

            Classes

            if not self._is_valid_hostname(hostname): # 'requests' is really bad at reporting that a hostname cannot be resolved. Let's check this separately. # Don't retry on DNS errors. They will most likely be persistent. - raise TransportError(f'{hostname!r} has no DNS entry') + raise TransportError(f"{hostname!r} has no DNS entry") kwargs = dict( url=url, headers=DEFAULT_HEADERS.copy(), allow_redirects=False, timeout=AutodiscoverProtocol.TIMEOUT ) - if method == 'post': - kwargs['data'] = Autodiscover.payload(email=self.email) + if method == "post": + kwargs["data"] = Autodiscover.payload(email=self.email) retry = 0 t_start = time.monotonic() while True: _back_off_if_needed(self.INITIAL_RETRY_POLICY.back_off_until) - log.debug('Trying to get response from %s', url) + log.debug("Trying to get response from %s", url) with AutodiscoverProtocol.raw_session(url) as s: try: r = getattr(s, method)(**kwargs) @@ -845,7 +857,7 @@

            Classes

            # Don't retry on TLS errors. They will most likely be persistent. raise TransportError(str(e)) except CONNECTION_ERRORS as e: - r = DummyResponse(url=url, request_headers=kwargs['headers']) + r = DummyResponse(url=url, request_headers=kwargs["headers"]) total_wait = time.monotonic() - t_start if self.INITIAL_RETRY_POLICY.may_retry_on_error(response=r, wait=total_wait): log.debug("Connection error on URL %s (retry %s, error: %s). Cool down", url, retry, e) @@ -861,12 +873,12 @@

            Classes

            except UnauthorizedError: # Failed to guess the auth type auth_type = NOAUTH - if r.status_code in (301, 302) and 'location' in r.headers: + if r.status_code in (301, 302) and "location" in r.headers: # Make the redirect URL absolute try: - r.headers['location'] = get_redirect_url(r) + r.headers["location"] = get_redirect_url(r) except TransportError: - del r.headers['location'] + del r.headers["location"] return auth_type, r def _get_authenticated_response(self, protocol): @@ -881,17 +893,24 @@

            Classes

            session = protocol.get_session() if GSSAPI in AUTH_TYPE_MAP and isinstance(session.auth, AUTH_TYPE_MAP[GSSAPI]): # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/pox-autodiscover-request-for-exchange - headers['X-ClientCanHandle'] = 'Negotiate' + headers["X-ClientCanHandle"] = "Negotiate" try: - r, session = post_ratelimited(protocol=protocol, session=session, url=protocol.service_endpoint, - headers=headers, data=data, allow_redirects=False, stream=False) + r, session = post_ratelimited( + protocol=protocol, + session=session, + url=protocol.service_endpoint, + headers=headers, + data=data, + allow_redirects=False, + stream=False, + ) protocol.release_session(session) except UnauthorizedError as e: # It's entirely possible for the endpoint to ask for login. We should continue if login fails because this # isn't necessarily the right endpoint to use. raise TransportError(str(e)) except RedirectError as e: - r = DummyResponse(url=protocol.service_endpoint, headers={'location': e.url}, status_code=302) + r = DummyResponse(url=protocol.service_endpoint, headers={"location": e.url}, status_code=302) return r def _attempt_response(self, url): @@ -901,7 +920,7 @@

            Classes

            :return: """ self._urls_visited.append(url.lower()) - log.debug('Attempting to get a valid response from %s', url) + log.debug("Attempting to get a valid response from %s", url) try: auth_type, r = self._get_unauthenticated_response(url=url) ad_protocol = AutodiscoverProtocol( @@ -915,9 +934,9 @@

            Classes

            if auth_type != NOAUTH: r = self._get_authenticated_response(protocol=ad_protocol) except TransportError as e: - log.debug('Failed to get a response: %s', e) + log.debug("Failed to get a response: %s", e) return False, None - if r.status_code in (301, 302) and 'location' in r.headers: + if r.status_code in (301, 302) and "location" in r.headers: redirect_url = get_redirect_url(r) if self._redirect_url_is_valid(url=redirect_url): # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com @@ -927,18 +946,18 @@

            Classes

            try: ad = Autodiscover.from_bytes(bytes_content=r.content) except ParseError as e: - log.debug('Invalid response: %s', e) + log.debug("Invalid response: %s", e) else: # We got a valid response. Unless this is a URL redirect response, we cache the result if ad.response is None or not ad.response.redirect_url: cache_key = self._cache_key - log.debug('Adding cache entry for key %s: %s', cache_key, ad_protocol.service_endpoint) + log.debug("Adding cache entry for key %s: %s", cache_key, ad_protocol.service_endpoint) autodiscover_cache[cache_key] = ad_protocol return True, ad return False, None def _is_valid_hostname(self, hostname): - log.debug('Checking if %s can be looked up in DNS', hostname) + log.debug("Checking if %s can be looked up in DNS", hostname) try: self.resolver.resolve(hostname) except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.name.EmptyLabel): @@ -958,23 +977,23 @@

            Classes

            :param hostname: :return: """ - log.debug('Attempting to get SRV records for %s', hostname) + log.debug("Attempting to get SRV records for %s", hostname) records = [] try: - answers = self.resolver.resolve(f'{hostname}.', 'SRV') + answers = self.resolver.resolve(f"{hostname}.", "SRV") except (dns.resolver.NoNameservers, dns.resolver.NoAnswer, dns.resolver.NXDOMAIN) as e: - log.debug('DNS lookup failure: %s', e) + log.debug("DNS lookup failure: %s", e) return records for rdata in answers: try: - vals = rdata.to_text().strip().rstrip('.').split(' ') + vals = rdata.to_text().strip().rstrip(".").split(" ") # Raise ValueError if the first three are not ints, and IndexError if there are less than 4 values priority, weight, port, srv = int(vals[0]), int(vals[1]), int(vals[2]), vals[3] record = SrvRecord(priority=priority, weight=weight, port=port, srv=srv) - log.debug('Found SRV record %s ', record) + log.debug("Found SRV record %s ", record) records.append(record) except (ValueError, IndexError): - log.debug('Incompatible SRV record for %s (%s)', hostname, rdata.to_text()) + log.debug("Incompatible SRV record for %s (%s)", hostname, rdata.to_text()) return records def _step_1(self, hostname): @@ -986,8 +1005,8 @@

            Classes

            :param hostname: :return: """ - url = f'https://{hostname}/Autodiscover/Autodiscover.xml' - log.info('Step 1: Trying autodiscover on %r with email %r', url, self.email) + url = f"https://{hostname}/Autodiscover/Autodiscover.xml" + log.info("Step 1: Trying autodiscover on %r with email %r", url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: return self._step_5(ad=ad) @@ -1002,8 +1021,8 @@

            Classes

            :param hostname: :return: """ - url = f'https://autodiscover.{hostname}/Autodiscover/Autodiscover.xml' - log.info('Step 2: Trying autodiscover on %r with email %r', url, self.email) + url = f"https://autodiscover.{hostname}/Autodiscover/Autodiscover.xml" + log.info("Step 2: Trying autodiscover on %r with email %r", url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: return self._step_5(ad=ad) @@ -1025,23 +1044,23 @@

            Classes

            :param hostname: :return: """ - url = f'http://autodiscover.{hostname}/Autodiscover/Autodiscover.xml' - log.info('Step 3: Trying autodiscover on %r with email %r', url, self.email) + url = f"http://autodiscover.{hostname}/Autodiscover/Autodiscover.xml" + log.info("Step 3: Trying autodiscover on %r with email %r", url, self.email) try: - _, r = self._get_unauthenticated_response(url=url, method='get') + _, r = self._get_unauthenticated_response(url=url, method="get") except TransportError: r = DummyResponse(url=url) - if r.status_code in (301, 302) and 'location' in r.headers: + if r.status_code in (301, 302) and "location" in r.headers: redirect_url = get_redirect_url(r) if self._redirect_url_is_valid(url=redirect_url): is_valid_response, ad = self._attempt_response(url=redirect_url) if is_valid_response: return self._step_5(ad=ad) - log.debug('Got invalid response') + log.debug("Got invalid response") return self._step_4(hostname=hostname) - log.debug('Got invalid redirect URL') + log.debug("Got invalid redirect URL") return self._step_4(hostname=hostname) - log.debug('Got no redirect URL') + log.debug("Got no redirect URL") return self._step_4(hostname=hostname) def _step_4(self, hostname): @@ -1061,8 +1080,8 @@

            Classes

            :param hostname: :return: """ - dns_hostname = f'_autodiscover._tcp.{hostname}' - log.info('Step 4: Trying autodiscover on %r with email %r', dns_hostname, self.email) + dns_hostname = f"_autodiscover._tcp.{hostname}" + log.info("Step 4: Trying autodiscover on %r with email %r", dns_hostname, self.email) srv_records = self._get_srv_records(dns_hostname) try: srv_host = _select_srv_host(srv_records) @@ -1070,14 +1089,14 @@

            Classes

            srv_host = None if not srv_host: return self._step_6() - redirect_url = f'https://{srv_host}/Autodiscover/Autodiscover.xml' + redirect_url = f"https://{srv_host}/Autodiscover/Autodiscover.xml" if self._redirect_url_is_valid(url=redirect_url): is_valid_response, ad = self._attempt_response(url=redirect_url) if is_valid_response: return self._step_5(ad=ad) - log.debug('Got invalid response') + log.debug("Got invalid response") return self._step_6() - log.debug('Got invalid redirect URL') + log.debug("Got invalid redirect URL") return self._step_6() def _step_5(self, ad): @@ -1097,23 +1116,23 @@

            Classes

            :param ad: :return: """ - log.info('Step 5: Checking response') + log.info("Step 5: Checking response") if ad.response is None: # This is not explicit in the protocol, but let's raise errors here ad.raise_errors() ad_response = ad.response if ad_response.redirect_url: - log.debug('Got a redirect URL: %s', ad_response.redirect_url) + log.debug("Got a redirect URL: %s", ad_response.redirect_url) # We are diverging a bit from the protocol here. We will never get an HTTP 302 since earlier steps already # followed the redirects where possible. Instead, we handle redirect responses here. if self._redirect_url_is_valid(url=ad_response.redirect_url): is_valid_response, ad = self._attempt_response(url=ad_response.redirect_url) if is_valid_response: return self._step_5(ad=ad) - log.debug('Got invalid response') + log.debug("Got invalid response") return self._step_6() - log.debug('Invalid redirect URL') + log.debug("Invalid redirect URL") return self._step_6() # This could be an email redirect. Let outer layer handle this return ad_response @@ -1124,8 +1143,9 @@

            Classes

            future requests. """ raise AutoDiscoverFailed( - f'All steps in the autodiscover protocol failed for email {self.email}. If you think this is an error, ' - f'consider doing an official test at https://testconnectivity.microsoft.com') + f"All steps in the autodiscover protocol failed for email {self.email}. If you think this is an error, " + f"consider doing an official test at https://testconnectivity.microsoft.com" + )

            Class variables

            @@ -1208,31 +1228,31 @@

            Methods

            # Check the autodiscover cache to see if we already know the autodiscover service endpoint for this email # domain. Use a lock to guard against multiple threads competing to cache information. - log.debug('Waiting for autodiscover_cache lock') + log.debug("Waiting for autodiscover_cache lock") with autodiscover_cache: - log.debug('autodiscover_cache lock acquired') + log.debug("autodiscover_cache lock acquired") cache_key = self._cache_key domain = get_domain(self.email) if cache_key in autodiscover_cache: ad_protocol = autodiscover_cache[cache_key] - log.debug('Cache hit for key %s: %s', cache_key, ad_protocol.service_endpoint) + log.debug("Cache hit for key %s: %s", cache_key, ad_protocol.service_endpoint) try: ad_response = self._quick(protocol=ad_protocol) except AutoDiscoverFailed: # Autodiscover no longer works with this domain. Clear cache and try again after releasing the lock - log.debug('AD request failure. Removing cache for key %s', cache_key) + log.debug("AD request failure. Removing cache for key %s", cache_key) del autodiscover_cache[cache_key] ad_response = self._step_1(hostname=domain) else: # This will cache the result - log.debug('Cache miss for key %s', cache_key) + log.debug("Cache miss for key %s", cache_key) ad_response = self._step_1(hostname=domain) - log.debug('Released autodiscover_cache_lock') + log.debug("Released autodiscover_cache_lock") if ad_response.redirect_address: - log.debug('Got a redirect address: %s', ad_response.redirect_address) + log.debug("Got a redirect address: %s", ad_response.redirect_address) if ad_response.redirect_address.lower() in self._emails_visited: - raise AutoDiscoverCircularRedirect('We were redirected to an email address we have already seen') + raise AutoDiscoverCircularRedirect("We were redirected to an email address we have already seen") # Start over, but with the new email address self.email = ad_response.redirect_address diff --git a/docs/exchangelib/autodiscover/index.html b/docs/exchangelib/autodiscover/index.html index bdb04ad8..cc63d723 100644 --- a/docs/exchangelib/autodiscover/index.html +++ b/docs/exchangelib/autodiscover/index.html @@ -42,8 +42,13 @@

            Module exchangelib.autodiscover

            __all__ = [ - 'AutodiscoverCache', 'AutodiscoverProtocol', 'Autodiscovery', 'discover', 'autodiscover_cache', - 'close_connections', 'clear_cache' + "AutodiscoverCache", + "AutodiscoverProtocol", + "Autodiscovery", + "discover", + "autodiscover_cache", + "close_connections", + "clear_cache", ] @@ -111,9 +116,7 @@

            Functions

            Expand source code
            def discover(email, credentials=None, auth_type=None, retry_policy=None):
            -    ad_response, protocol = Autodiscovery(
            -        email=email, credentials=credentials
            -    ).discover()
            +    ad_response, protocol = Autodiscovery(email=email, credentials=credentials).discover()
                 protocol.config.auth_typ = auth_type
                 protocol.config.retry_policy = retry_policy
                 return ad_response, protocol
            @@ -189,9 +192,11 @@

            Classes

            domain, credentials = key with shelve_open_with_failover(self._storage_file) as db: endpoint, auth_type, retry_policy = db[str(domain)] # It's OK to fail with KeyError here - protocol = AutodiscoverProtocol(config=Configuration( - service_endpoint=endpoint, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy - )) + protocol = AutodiscoverProtocol( + config=Configuration( + service_endpoint=endpoint, credentials=credentials, auth_type=auth_type, retry_policy=retry_policy + ) + ) self._protocols[key] = protocol return protocol @@ -220,7 +225,7 @@

            Classes

            def close(self): # Close all open connections for (domain, _), protocol in self._protocols.items(): - log.debug('Domain %s: Closing sessions', domain) + log.debug("Domain %s: Closing sessions", domain) protocol.close() del protocol self._protocols.clear() @@ -272,7 +277,7 @@

            Methods

            def close(self):
                 # Close all open connections
                 for (domain, _), protocol in self._protocols.items():
            -        log.debug('Domain %s: Closing sessions', domain)
            +        log.debug("Domain %s: Closing sessions", domain)
                     protocol.close()
                     del protocol
                 self._protocols.clear()
            @@ -296,9 +301,9 @@

            Methods

            TIMEOUT = 10 # Seconds def __str__(self): - return f'''\ + return f"""\ Autodiscover endpoint: {self.service_endpoint} -Auth type: {self.auth_type}''' +Auth type: {self.auth_type}"""

            Ancestors

              @@ -384,7 +389,7 @@

              Inherited members

              MAX_REDIRECTS = 10 # Maximum number of URL redirects before we give up DNS_RESOLVER_KWARGS = {} DNS_RESOLVER_ATTRS = { - 'timeout': AutodiscoverProtocol.TIMEOUT, + "timeout": AutodiscoverProtocol.TIMEOUT, } def __init__(self, email, credentials=None): @@ -405,31 +410,31 @@

              Inherited members

              # Check the autodiscover cache to see if we already know the autodiscover service endpoint for this email # domain. Use a lock to guard against multiple threads competing to cache information. - log.debug('Waiting for autodiscover_cache lock') + log.debug("Waiting for autodiscover_cache lock") with autodiscover_cache: - log.debug('autodiscover_cache lock acquired') + log.debug("autodiscover_cache lock acquired") cache_key = self._cache_key domain = get_domain(self.email) if cache_key in autodiscover_cache: ad_protocol = autodiscover_cache[cache_key] - log.debug('Cache hit for key %s: %s', cache_key, ad_protocol.service_endpoint) + log.debug("Cache hit for key %s: %s", cache_key, ad_protocol.service_endpoint) try: ad_response = self._quick(protocol=ad_protocol) except AutoDiscoverFailed: # Autodiscover no longer works with this domain. Clear cache and try again after releasing the lock - log.debug('AD request failure. Removing cache for key %s', cache_key) + log.debug("AD request failure. Removing cache for key %s", cache_key) del autodiscover_cache[cache_key] ad_response = self._step_1(hostname=domain) else: # This will cache the result - log.debug('Cache miss for key %s', cache_key) + log.debug("Cache miss for key %s", cache_key) ad_response = self._step_1(hostname=domain) - log.debug('Released autodiscover_cache_lock') + log.debug("Released autodiscover_cache_lock") if ad_response.redirect_address: - log.debug('Got a redirect address: %s', ad_response.redirect_address) + log.debug("Got a redirect address: %s", ad_response.redirect_address) if ad_response.redirect_address.lower() in self._emails_visited: - raise AutoDiscoverCircularRedirect('We were redirected to an email address we have already seen') + raise AutoDiscoverCircularRedirect("We were redirected to an email address we have already seen") # Start over, but with the new email address self.email = ad_response.redirect_address @@ -478,15 +483,15 @@

              Inherited members

              try: r = self._get_authenticated_response(protocol=protocol) except TransportError as e: - raise AutoDiscoverFailed(f'Response error: {e}') + raise AutoDiscoverFailed(f"Response error: {e}") if r.status_code == 200: try: ad = Autodiscover.from_bytes(bytes_content=r.content) except ParseError as e: - raise AutoDiscoverFailed(f'Invalid response: {e}') + raise AutoDiscoverFailed(f"Invalid response: {e}") else: return self._step_5(ad=ad) - raise AutoDiscoverFailed(f'Invalid response code: {r.status_code}') + raise AutoDiscoverFailed(f"Invalid response code: {r.status_code}") def _redirect_url_is_valid(self, url): """Three separate responses can be “Redirect responses”: @@ -502,29 +507,29 @@

              Inherited members

              :return: """ if url.lower() in self._urls_visited: - log.warning('We have already tried this URL: %s', url) + log.warning("We have already tried this URL: %s", url) return False if self._redirect_count >= self.MAX_REDIRECTS: - log.warning('We reached max redirects at URL: %s', url) + log.warning("We reached max redirects at URL: %s", url) return False # We require TLS endpoints - if not url.startswith('https://'): - log.debug('Invalid scheme for URL: %s', url) + if not url.startswith("https://"): + log.debug("Invalid scheme for URL: %s", url) return False # Quick test that the endpoint responds and that TLS handshake is OK try: - self._get_unauthenticated_response(url, method='head') + self._get_unauthenticated_response(url, method="head") except TransportError as e: - log.debug('Response error on redirect URL %s: %s', url, e) + log.debug("Response error on redirect URL %s: %s", url, e) return False self._redirect_count += 1 return True - def _get_unauthenticated_response(self, url, method='post'): + def _get_unauthenticated_response(self, url, method="post"): """Get auth type by tasting headers from the server. Do POST requests be default. HEAD is too error prone, and some servers are set up to redirect to OWA on all requests except POST to the autodiscover endpoint. @@ -537,18 +542,18 @@

              Inherited members

              if not self._is_valid_hostname(hostname): # 'requests' is really bad at reporting that a hostname cannot be resolved. Let's check this separately. # Don't retry on DNS errors. They will most likely be persistent. - raise TransportError(f'{hostname!r} has no DNS entry') + raise TransportError(f"{hostname!r} has no DNS entry") kwargs = dict( url=url, headers=DEFAULT_HEADERS.copy(), allow_redirects=False, timeout=AutodiscoverProtocol.TIMEOUT ) - if method == 'post': - kwargs['data'] = Autodiscover.payload(email=self.email) + if method == "post": + kwargs["data"] = Autodiscover.payload(email=self.email) retry = 0 t_start = time.monotonic() while True: _back_off_if_needed(self.INITIAL_RETRY_POLICY.back_off_until) - log.debug('Trying to get response from %s', url) + log.debug("Trying to get response from %s", url) with AutodiscoverProtocol.raw_session(url) as s: try: r = getattr(s, method)(**kwargs) @@ -558,7 +563,7 @@

              Inherited members

              # Don't retry on TLS errors. They will most likely be persistent. raise TransportError(str(e)) except CONNECTION_ERRORS as e: - r = DummyResponse(url=url, request_headers=kwargs['headers']) + r = DummyResponse(url=url, request_headers=kwargs["headers"]) total_wait = time.monotonic() - t_start if self.INITIAL_RETRY_POLICY.may_retry_on_error(response=r, wait=total_wait): log.debug("Connection error on URL %s (retry %s, error: %s). Cool down", url, retry, e) @@ -574,12 +579,12 @@

              Inherited members

              except UnauthorizedError: # Failed to guess the auth type auth_type = NOAUTH - if r.status_code in (301, 302) and 'location' in r.headers: + if r.status_code in (301, 302) and "location" in r.headers: # Make the redirect URL absolute try: - r.headers['location'] = get_redirect_url(r) + r.headers["location"] = get_redirect_url(r) except TransportError: - del r.headers['location'] + del r.headers["location"] return auth_type, r def _get_authenticated_response(self, protocol): @@ -594,17 +599,24 @@

              Inherited members

              session = protocol.get_session() if GSSAPI in AUTH_TYPE_MAP and isinstance(session.auth, AUTH_TYPE_MAP[GSSAPI]): # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/pox-autodiscover-request-for-exchange - headers['X-ClientCanHandle'] = 'Negotiate' + headers["X-ClientCanHandle"] = "Negotiate" try: - r, session = post_ratelimited(protocol=protocol, session=session, url=protocol.service_endpoint, - headers=headers, data=data, allow_redirects=False, stream=False) + r, session = post_ratelimited( + protocol=protocol, + session=session, + url=protocol.service_endpoint, + headers=headers, + data=data, + allow_redirects=False, + stream=False, + ) protocol.release_session(session) except UnauthorizedError as e: # It's entirely possible for the endpoint to ask for login. We should continue if login fails because this # isn't necessarily the right endpoint to use. raise TransportError(str(e)) except RedirectError as e: - r = DummyResponse(url=protocol.service_endpoint, headers={'location': e.url}, status_code=302) + r = DummyResponse(url=protocol.service_endpoint, headers={"location": e.url}, status_code=302) return r def _attempt_response(self, url): @@ -614,7 +626,7 @@

              Inherited members

              :return: """ self._urls_visited.append(url.lower()) - log.debug('Attempting to get a valid response from %s', url) + log.debug("Attempting to get a valid response from %s", url) try: auth_type, r = self._get_unauthenticated_response(url=url) ad_protocol = AutodiscoverProtocol( @@ -628,9 +640,9 @@

              Inherited members

              if auth_type != NOAUTH: r = self._get_authenticated_response(protocol=ad_protocol) except TransportError as e: - log.debug('Failed to get a response: %s', e) + log.debug("Failed to get a response: %s", e) return False, None - if r.status_code in (301, 302) and 'location' in r.headers: + if r.status_code in (301, 302) and "location" in r.headers: redirect_url = get_redirect_url(r) if self._redirect_url_is_valid(url=redirect_url): # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com @@ -640,18 +652,18 @@

              Inherited members

              try: ad = Autodiscover.from_bytes(bytes_content=r.content) except ParseError as e: - log.debug('Invalid response: %s', e) + log.debug("Invalid response: %s", e) else: # We got a valid response. Unless this is a URL redirect response, we cache the result if ad.response is None or not ad.response.redirect_url: cache_key = self._cache_key - log.debug('Adding cache entry for key %s: %s', cache_key, ad_protocol.service_endpoint) + log.debug("Adding cache entry for key %s: %s", cache_key, ad_protocol.service_endpoint) autodiscover_cache[cache_key] = ad_protocol return True, ad return False, None def _is_valid_hostname(self, hostname): - log.debug('Checking if %s can be looked up in DNS', hostname) + log.debug("Checking if %s can be looked up in DNS", hostname) try: self.resolver.resolve(hostname) except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.name.EmptyLabel): @@ -671,23 +683,23 @@

              Inherited members

              :param hostname: :return: """ - log.debug('Attempting to get SRV records for %s', hostname) + log.debug("Attempting to get SRV records for %s", hostname) records = [] try: - answers = self.resolver.resolve(f'{hostname}.', 'SRV') + answers = self.resolver.resolve(f"{hostname}.", "SRV") except (dns.resolver.NoNameservers, dns.resolver.NoAnswer, dns.resolver.NXDOMAIN) as e: - log.debug('DNS lookup failure: %s', e) + log.debug("DNS lookup failure: %s", e) return records for rdata in answers: try: - vals = rdata.to_text().strip().rstrip('.').split(' ') + vals = rdata.to_text().strip().rstrip(".").split(" ") # Raise ValueError if the first three are not ints, and IndexError if there are less than 4 values priority, weight, port, srv = int(vals[0]), int(vals[1]), int(vals[2]), vals[3] record = SrvRecord(priority=priority, weight=weight, port=port, srv=srv) - log.debug('Found SRV record %s ', record) + log.debug("Found SRV record %s ", record) records.append(record) except (ValueError, IndexError): - log.debug('Incompatible SRV record for %s (%s)', hostname, rdata.to_text()) + log.debug("Incompatible SRV record for %s (%s)", hostname, rdata.to_text()) return records def _step_1(self, hostname): @@ -699,8 +711,8 @@

              Inherited members

              :param hostname: :return: """ - url = f'https://{hostname}/Autodiscover/Autodiscover.xml' - log.info('Step 1: Trying autodiscover on %r with email %r', url, self.email) + url = f"https://{hostname}/Autodiscover/Autodiscover.xml" + log.info("Step 1: Trying autodiscover on %r with email %r", url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: return self._step_5(ad=ad) @@ -715,8 +727,8 @@

              Inherited members

              :param hostname: :return: """ - url = f'https://autodiscover.{hostname}/Autodiscover/Autodiscover.xml' - log.info('Step 2: Trying autodiscover on %r with email %r', url, self.email) + url = f"https://autodiscover.{hostname}/Autodiscover/Autodiscover.xml" + log.info("Step 2: Trying autodiscover on %r with email %r", url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: return self._step_5(ad=ad) @@ -738,23 +750,23 @@

              Inherited members

              :param hostname: :return: """ - url = f'http://autodiscover.{hostname}/Autodiscover/Autodiscover.xml' - log.info('Step 3: Trying autodiscover on %r with email %r', url, self.email) + url = f"http://autodiscover.{hostname}/Autodiscover/Autodiscover.xml" + log.info("Step 3: Trying autodiscover on %r with email %r", url, self.email) try: - _, r = self._get_unauthenticated_response(url=url, method='get') + _, r = self._get_unauthenticated_response(url=url, method="get") except TransportError: r = DummyResponse(url=url) - if r.status_code in (301, 302) and 'location' in r.headers: + if r.status_code in (301, 302) and "location" in r.headers: redirect_url = get_redirect_url(r) if self._redirect_url_is_valid(url=redirect_url): is_valid_response, ad = self._attempt_response(url=redirect_url) if is_valid_response: return self._step_5(ad=ad) - log.debug('Got invalid response') + log.debug("Got invalid response") return self._step_4(hostname=hostname) - log.debug('Got invalid redirect URL') + log.debug("Got invalid redirect URL") return self._step_4(hostname=hostname) - log.debug('Got no redirect URL') + log.debug("Got no redirect URL") return self._step_4(hostname=hostname) def _step_4(self, hostname): @@ -774,8 +786,8 @@

              Inherited members

              :param hostname: :return: """ - dns_hostname = f'_autodiscover._tcp.{hostname}' - log.info('Step 4: Trying autodiscover on %r with email %r', dns_hostname, self.email) + dns_hostname = f"_autodiscover._tcp.{hostname}" + log.info("Step 4: Trying autodiscover on %r with email %r", dns_hostname, self.email) srv_records = self._get_srv_records(dns_hostname) try: srv_host = _select_srv_host(srv_records) @@ -783,14 +795,14 @@

              Inherited members

              srv_host = None if not srv_host: return self._step_6() - redirect_url = f'https://{srv_host}/Autodiscover/Autodiscover.xml' + redirect_url = f"https://{srv_host}/Autodiscover/Autodiscover.xml" if self._redirect_url_is_valid(url=redirect_url): is_valid_response, ad = self._attempt_response(url=redirect_url) if is_valid_response: return self._step_5(ad=ad) - log.debug('Got invalid response') + log.debug("Got invalid response") return self._step_6() - log.debug('Got invalid redirect URL') + log.debug("Got invalid redirect URL") return self._step_6() def _step_5(self, ad): @@ -810,23 +822,23 @@

              Inherited members

              :param ad: :return: """ - log.info('Step 5: Checking response') + log.info("Step 5: Checking response") if ad.response is None: # This is not explicit in the protocol, but let's raise errors here ad.raise_errors() ad_response = ad.response if ad_response.redirect_url: - log.debug('Got a redirect URL: %s', ad_response.redirect_url) + log.debug("Got a redirect URL: %s", ad_response.redirect_url) # We are diverging a bit from the protocol here. We will never get an HTTP 302 since earlier steps already # followed the redirects where possible. Instead, we handle redirect responses here. if self._redirect_url_is_valid(url=ad_response.redirect_url): is_valid_response, ad = self._attempt_response(url=ad_response.redirect_url) if is_valid_response: return self._step_5(ad=ad) - log.debug('Got invalid response') + log.debug("Got invalid response") return self._step_6() - log.debug('Invalid redirect URL') + log.debug("Invalid redirect URL") return self._step_6() # This could be an email redirect. Let outer layer handle this return ad_response @@ -837,8 +849,9 @@

              Inherited members

              future requests. """ raise AutoDiscoverFailed( - f'All steps in the autodiscover protocol failed for email {self.email}. If you think this is an error, ' - f'consider doing an official test at https://testconnectivity.microsoft.com') + f"All steps in the autodiscover protocol failed for email {self.email}. If you think this is an error, " + f"consider doing an official test at https://testconnectivity.microsoft.com" + )

              Class variables

              @@ -921,31 +934,31 @@

              Methods

              # Check the autodiscover cache to see if we already know the autodiscover service endpoint for this email # domain. Use a lock to guard against multiple threads competing to cache information. - log.debug('Waiting for autodiscover_cache lock') + log.debug("Waiting for autodiscover_cache lock") with autodiscover_cache: - log.debug('autodiscover_cache lock acquired') + log.debug("autodiscover_cache lock acquired") cache_key = self._cache_key domain = get_domain(self.email) if cache_key in autodiscover_cache: ad_protocol = autodiscover_cache[cache_key] - log.debug('Cache hit for key %s: %s', cache_key, ad_protocol.service_endpoint) + log.debug("Cache hit for key %s: %s", cache_key, ad_protocol.service_endpoint) try: ad_response = self._quick(protocol=ad_protocol) except AutoDiscoverFailed: # Autodiscover no longer works with this domain. Clear cache and try again after releasing the lock - log.debug('AD request failure. Removing cache for key %s', cache_key) + log.debug("AD request failure. Removing cache for key %s", cache_key) del autodiscover_cache[cache_key] ad_response = self._step_1(hostname=domain) else: # This will cache the result - log.debug('Cache miss for key %s', cache_key) + log.debug("Cache miss for key %s", cache_key) ad_response = self._step_1(hostname=domain) - log.debug('Released autodiscover_cache_lock') + log.debug("Released autodiscover_cache_lock") if ad_response.redirect_address: - log.debug('Got a redirect address: %s', ad_response.redirect_address) + log.debug("Got a redirect address: %s", ad_response.redirect_address) if ad_response.redirect_address.lower() in self._emails_visited: - raise AutoDiscoverCircularRedirect('We were redirected to an email address we have already seen') + raise AutoDiscoverCircularRedirect("We were redirected to an email address we have already seen") # Start over, but with the new email address self.email = ad_response.redirect_address diff --git a/docs/exchangelib/autodiscover/properties.html b/docs/exchangelib/autodiscover/properties.html index a5955442..98e003f4 100644 --- a/docs/exchangelib/autodiscover/properties.html +++ b/docs/exchangelib/autodiscover/properties.html @@ -26,13 +26,24 @@

              Module exchangelib.autodiscover.properties

              Expand source code -
              from ..errors import ErrorNonExistentMailbox, AutoDiscoverFailed
              -from ..fields import TextField, EmailAddressField, ChoiceField, Choice, EWSElementField, OnOffField, BooleanField, \
              -    IntegerField, BuildField, ProtocolListField
              +
              from ..errors import AutoDiscoverFailed, ErrorNonExistentMailbox
              +from ..fields import (
              +    BooleanField,
              +    BuildField,
              +    Choice,
              +    ChoiceField,
              +    EmailAddressField,
              +    EWSElementField,
              +    IntegerField,
              +    OnOffField,
              +    ProtocolListField,
              +    TextField,
              +)
               from ..properties import EWSElement
              -from ..transport import DEFAULT_ENCODING, NOAUTH, NTLM, BASIC, GSSAPI, SSPI, CBA
              -from ..util import create_element, add_xml_child, to_xml, is_xml, xml_to_str, AUTODISCOVER_REQUEST_NS, \
              -    AUTODISCOVER_BASE_NS, AUTODISCOVER_RESPONSE_NS as RNS, ParseError
              +from ..transport import BASIC, CBA, DEFAULT_ENCODING, GSSAPI, NOAUTH, NTLM, SSPI
              +from ..util import AUTODISCOVER_BASE_NS, AUTODISCOVER_REQUEST_NS
              +from ..util import AUTODISCOVER_RESPONSE_NS as RNS
              +from ..util import ParseError, add_xml_child, create_element, is_xml, to_xml, xml_to_str
               from ..version import Version
               
               
              @@ -43,40 +54,40 @@ 

              Module exchangelib.autodiscover.properties

              class User(AutodiscoverBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/user-pox""" - ELEMENT_NAME = 'User' + ELEMENT_NAME = "User" - display_name = TextField(field_uri='DisplayName', namespace=RNS) - legacy_dn = TextField(field_uri='LegacyDN', namespace=RNS) - deployment_id = TextField(field_uri='DeploymentId', namespace=RNS) # GUID format - autodiscover_smtp_address = EmailAddressField(field_uri='AutoDiscoverSMTPAddress', namespace=RNS) + display_name = TextField(field_uri="DisplayName", namespace=RNS) + legacy_dn = TextField(field_uri="LegacyDN", namespace=RNS) + deployment_id = TextField(field_uri="DeploymentId", namespace=RNS) # GUID format + autodiscover_smtp_address = EmailAddressField(field_uri="AutoDiscoverSMTPAddress", namespace=RNS) class IntExtUrlBase(AutodiscoverBase): - external_url = TextField(field_uri='ExternalUrl', namespace=RNS) - internal_url = TextField(field_uri='InternalUrl', namespace=RNS) + external_url = TextField(field_uri="ExternalUrl", namespace=RNS) + internal_url = TextField(field_uri="InternalUrl", namespace=RNS) class AddressBook(IntExtUrlBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/addressbook-pox""" - ELEMENT_NAME = 'AddressBook' + ELEMENT_NAME = "AddressBook" class MailStore(IntExtUrlBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailstore-pox""" - ELEMENT_NAME = 'MailStore' + ELEMENT_NAME = "MailStore" class NetworkRequirements(AutodiscoverBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/networkrequirements-pox""" - ELEMENT_NAME = 'NetworkRequirements' + ELEMENT_NAME = "NetworkRequirements" - ipv4_start = TextField(field_uri='IPv4Start', namespace=RNS) - ipv4_end = TextField(field_uri='IPv4End', namespace=RNS) - ipv6_start = TextField(field_uri='IPv6Start', namespace=RNS) - ipv6_end = TextField(field_uri='IPv6End', namespace=RNS) + ipv4_start = TextField(field_uri="IPv4Start", namespace=RNS) + ipv4_end = TextField(field_uri="IPv4End", namespace=RNS) + ipv6_start = TextField(field_uri="IPv6Start", namespace=RNS) + ipv6_end = TextField(field_uri="IPv6End", namespace=RNS) class SimpleProtocol(AutodiscoverBase): @@ -85,84 +96,86 @@

              Module exchangelib.autodiscover.properties

              Used for the 'Internal' and 'External' elements that may contain a stripped-down version of the Protocol element. """ - ELEMENT_NAME = 'Protocol' - WEB = 'WEB' - EXCH = 'EXCH' - EXPR = 'EXPR' - EXHTTP = 'EXHTTP' + ELEMENT_NAME = "Protocol" + WEB = "WEB" + EXCH = "EXCH" + EXPR = "EXPR" + EXHTTP = "EXHTTP" TYPES = (WEB, EXCH, EXPR, EXHTTP) - type = ChoiceField(field_uri='Type', choices={Choice(c) for c in TYPES}, namespace=RNS) - as_url = TextField(field_uri='ASUrl', namespace=RNS) + type = ChoiceField(field_uri="Type", choices={Choice(c) for c in TYPES}, namespace=RNS) + as_url = TextField(field_uri="ASUrl", namespace=RNS) class IntExtBase(AutodiscoverBase): # TODO: 'OWAUrl' also has an AuthenticationMethod enum-style XML attribute with values: # WindowsIntegrated, FBA, NTLM, Digest, Basic - owa_url = TextField(field_uri='OWAUrl', namespace=RNS) + owa_url = TextField(field_uri="OWAUrl", namespace=RNS) protocol = EWSElementField(value_cls=SimpleProtocol) class Internal(IntExtBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/internal-pox""" - ELEMENT_NAME = 'Internal' + ELEMENT_NAME = "Internal" class External(IntExtBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/external-pox""" - ELEMENT_NAME = 'External' + ELEMENT_NAME = "External" class Protocol(SimpleProtocol): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/protocol-pox""" # Attribute 'Type' is ignored here. Has a name conflict with the child element and does not seem useful. - version = TextField(field_uri='Version', is_attribute=True, namespace=RNS) + version = TextField(field_uri="Version", is_attribute=True, namespace=RNS) internal = EWSElementField(value_cls=Internal) external = EWSElementField(value_cls=External) - ttl = IntegerField(field_uri='TTL', namespace=RNS, default=1) # TTL for this autodiscover response, in hours - server = TextField(field_uri='Server', namespace=RNS) - server_dn = TextField(field_uri='ServerDN', namespace=RNS) - server_version = BuildField(field_uri='ServerVersion', namespace=RNS) - mdb_dn = TextField(field_uri='MdbDN', namespace=RNS) - public_folder_server = TextField(field_uri='PublicFolderServer', namespace=RNS) - port = IntegerField(field_uri='Port', namespace=RNS, min=1, max=65535) - directory_port = IntegerField(field_uri='DirectoryPort', namespace=RNS, min=1, max=65535) - referral_port = IntegerField(field_uri='ReferralPort', namespace=RNS, min=1, max=65535) - ews_url = TextField(field_uri='EwsUrl', namespace=RNS) - emws_url = TextField(field_uri='EmwsUrl', namespace=RNS) - sharing_url = TextField(field_uri='SharingUrl', namespace=RNS) - ecp_url = TextField(field_uri='EcpUrl', namespace=RNS) - ecp_url_um = TextField(field_uri='EcpUrl-um', namespace=RNS) - ecp_url_aggr = TextField(field_uri='EcpUrl-aggr', namespace=RNS) - ecp_url_mt = TextField(field_uri='EcpUrl-mt', namespace=RNS) - ecp_url_ret = TextField(field_uri='EcpUrl-ret', namespace=RNS) - ecp_url_sms = TextField(field_uri='EcpUrl-sms', namespace=RNS) - ecp_url_publish = TextField(field_uri='EcpUrl-publish', namespace=RNS) - ecp_url_photo = TextField(field_uri='EcpUrl-photo', namespace=RNS) - ecp_url_tm = TextField(field_uri='EcpUrl-tm', namespace=RNS) - ecp_url_tm_creating = TextField(field_uri='EcpUrl-tmCreating', namespace=RNS) - ecp_url_tm_hiding = TextField(field_uri='EcpUrl-tmHiding', namespace=RNS) - ecp_url_tm_editing = TextField(field_uri='EcpUrl-tmEditing', namespace=RNS) - ecp_url_extinstall = TextField(field_uri='EcpUrl-extinstall', namespace=RNS) - oof_url = TextField(field_uri='OOFUrl', namespace=RNS) - oab_url = TextField(field_uri='OABUrl', namespace=RNS) - um_url = TextField(field_uri='UMUrl', namespace=RNS) - ews_partner_url = TextField(field_uri='EwsPartnerUrl', namespace=RNS) - login_name = TextField(field_uri='LoginName', namespace=RNS) - domain_required = OnOffField(field_uri='DomainRequired', namespace=RNS) - domain_name = TextField(field_uri='DomainName', namespace=RNS) - spa = OnOffField(field_uri='SPA', namespace=RNS, default=True) - auth_package = ChoiceField(field_uri='AuthPackage', namespace=RNS, choices={ - Choice(c) for c in ('basic', 'kerb', 'kerbntlm', 'ntlm', 'certificate', 'negotiate', 'nego2') - }) - cert_principal_name = TextField(field_uri='CertPrincipalName', namespace=RNS) - ssl = OnOffField(field_uri='SSL', namespace=RNS, default=True) - auth_required = OnOffField(field_uri='AuthRequired', namespace=RNS, default=True) - use_pop_path = OnOffField(field_uri='UsePOPAuth', namespace=RNS) - smtp_last = OnOffField(field_uri='SMTPLast', namespace=RNS, default=False) + ttl = IntegerField(field_uri="TTL", namespace=RNS, default=1) # TTL for this autodiscover response, in hours + server = TextField(field_uri="Server", namespace=RNS) + server_dn = TextField(field_uri="ServerDN", namespace=RNS) + server_version = BuildField(field_uri="ServerVersion", namespace=RNS) + mdb_dn = TextField(field_uri="MdbDN", namespace=RNS) + public_folder_server = TextField(field_uri="PublicFolderServer", namespace=RNS) + port = IntegerField(field_uri="Port", namespace=RNS, min=1, max=65535) + directory_port = IntegerField(field_uri="DirectoryPort", namespace=RNS, min=1, max=65535) + referral_port = IntegerField(field_uri="ReferralPort", namespace=RNS, min=1, max=65535) + ews_url = TextField(field_uri="EwsUrl", namespace=RNS) + emws_url = TextField(field_uri="EmwsUrl", namespace=RNS) + sharing_url = TextField(field_uri="SharingUrl", namespace=RNS) + ecp_url = TextField(field_uri="EcpUrl", namespace=RNS) + ecp_url_um = TextField(field_uri="EcpUrl-um", namespace=RNS) + ecp_url_aggr = TextField(field_uri="EcpUrl-aggr", namespace=RNS) + ecp_url_mt = TextField(field_uri="EcpUrl-mt", namespace=RNS) + ecp_url_ret = TextField(field_uri="EcpUrl-ret", namespace=RNS) + ecp_url_sms = TextField(field_uri="EcpUrl-sms", namespace=RNS) + ecp_url_publish = TextField(field_uri="EcpUrl-publish", namespace=RNS) + ecp_url_photo = TextField(field_uri="EcpUrl-photo", namespace=RNS) + ecp_url_tm = TextField(field_uri="EcpUrl-tm", namespace=RNS) + ecp_url_tm_creating = TextField(field_uri="EcpUrl-tmCreating", namespace=RNS) + ecp_url_tm_hiding = TextField(field_uri="EcpUrl-tmHiding", namespace=RNS) + ecp_url_tm_editing = TextField(field_uri="EcpUrl-tmEditing", namespace=RNS) + ecp_url_extinstall = TextField(field_uri="EcpUrl-extinstall", namespace=RNS) + oof_url = TextField(field_uri="OOFUrl", namespace=RNS) + oab_url = TextField(field_uri="OABUrl", namespace=RNS) + um_url = TextField(field_uri="UMUrl", namespace=RNS) + ews_partner_url = TextField(field_uri="EwsPartnerUrl", namespace=RNS) + login_name = TextField(field_uri="LoginName", namespace=RNS) + domain_required = OnOffField(field_uri="DomainRequired", namespace=RNS) + domain_name = TextField(field_uri="DomainName", namespace=RNS) + spa = OnOffField(field_uri="SPA", namespace=RNS, default=True) + auth_package = ChoiceField( + field_uri="AuthPackage", + namespace=RNS, + choices={Choice(c) for c in ("basic", "kerb", "kerbntlm", "ntlm", "certificate", "negotiate", "nego2")}, + ) + cert_principal_name = TextField(field_uri="CertPrincipalName", namespace=RNS) + ssl = OnOffField(field_uri="SSL", namespace=RNS, default=True) + auth_required = OnOffField(field_uri="AuthRequired", namespace=RNS, default=True) + use_pop_path = OnOffField(field_uri="UsePOPAuth", namespace=RNS) + smtp_last = OnOffField(field_uri="SMTPLast", namespace=RNS, default=False) network_requirements = EWSElementField(value_cls=NetworkRequirements) address_book = EWSElementField(value_cls=AddressBook) mail_store = EWSElementField(value_cls=MailStore) @@ -176,56 +189,56 @@

              Module exchangelib.autodiscover.properties

              return None return { # Missing in list are DIGEST and OAUTH2 - 'basic': BASIC, - 'kerb': GSSAPI, - 'kerbntlm': NTLM, # Means client can chose between NTLM and GSSAPI - 'ntlm': NTLM, - 'certificate': CBA, - 'negotiate': SSPI, # Unsure about this one - 'nego2': GSSAPI, - 'anonymous': NOAUTH, # Seen in some docs even though it's not mentioned in MSDN + "basic": BASIC, + "kerb": GSSAPI, + "kerbntlm": NTLM, # Means client can chose between NTLM and GSSAPI + "ntlm": NTLM, + "certificate": CBA, + "negotiate": SSPI, # Unsure about this one + "nego2": GSSAPI, + "anonymous": NOAUTH, # Seen in some docs even though it's not mentioned in MSDN }.get(self.auth_package.lower()) class Error(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/error-pox""" - ELEMENT_NAME = 'Error' + ELEMENT_NAME = "Error" NAMESPACE = AUTODISCOVER_BASE_NS - id = TextField(field_uri='Id', namespace=AUTODISCOVER_BASE_NS, is_attribute=True) - time = TextField(field_uri='Time', namespace=AUTODISCOVER_BASE_NS, is_attribute=True) - code = TextField(field_uri='ErrorCode', namespace=AUTODISCOVER_BASE_NS) - message = TextField(field_uri='Message', namespace=AUTODISCOVER_BASE_NS) - debug_data = TextField(field_uri='DebugData', namespace=AUTODISCOVER_BASE_NS) + id = TextField(field_uri="Id", namespace=AUTODISCOVER_BASE_NS, is_attribute=True) + time = TextField(field_uri="Time", namespace=AUTODISCOVER_BASE_NS, is_attribute=True) + code = TextField(field_uri="ErrorCode", namespace=AUTODISCOVER_BASE_NS) + message = TextField(field_uri="Message", namespace=AUTODISCOVER_BASE_NS) + debug_data = TextField(field_uri="DebugData", namespace=AUTODISCOVER_BASE_NS) class Account(AutodiscoverBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/account-pox""" - ELEMENT_NAME = 'Account' - REDIRECT_URL = 'redirectUrl' - REDIRECT_ADDR = 'redirectAddr' - SETTINGS = 'settings' + ELEMENT_NAME = "Account" + REDIRECT_URL = "redirectUrl" + REDIRECT_ADDR = "redirectAddr" + SETTINGS = "settings" ACTIONS = (REDIRECT_URL, REDIRECT_ADDR, SETTINGS) - type = ChoiceField(field_uri='AccountType', namespace=RNS, choices={Choice('email')}) - action = ChoiceField(field_uri='Action', namespace=RNS, choices={Choice(p) for p in ACTIONS}) - microsoft_online = BooleanField(field_uri='MicrosoftOnline', namespace=RNS) - redirect_url = TextField(field_uri='RedirectURL', namespace=RNS) - redirect_address = EmailAddressField(field_uri='RedirectAddr', namespace=RNS) - image = TextField(field_uri='Image', namespace=RNS) # Path to image used for branding - service_home = TextField(field_uri='ServiceHome', namespace=RNS) # URL to website of ISP + type = ChoiceField(field_uri="AccountType", namespace=RNS, choices={Choice("email")}) + action = ChoiceField(field_uri="Action", namespace=RNS, choices={Choice(p) for p in ACTIONS}) + microsoft_online = BooleanField(field_uri="MicrosoftOnline", namespace=RNS) + redirect_url = TextField(field_uri="RedirectURL", namespace=RNS) + redirect_address = EmailAddressField(field_uri="RedirectAddr", namespace=RNS) + image = TextField(field_uri="Image", namespace=RNS) # Path to image used for branding + service_home = TextField(field_uri="ServiceHome", namespace=RNS) # URL to website of ISP protocols = ProtocolListField() # 'SmtpAddress' is inside the 'PublicFolderInformation' element - public_folder_smtp_address = TextField(field_uri='SmtpAddress', namespace=RNS) + public_folder_smtp_address = TextField(field_uri="SmtpAddress", namespace=RNS) @classmethod def from_xml(cls, elem, account): kwargs = {} - public_folder_information = elem.find(f'{{{cls.NAMESPACE}}}PublicFolderInformation') + public_folder_information = elem.find(f"{{{cls.NAMESPACE}}}PublicFolderInformation") for f in cls.FIELDS: - if f.name == 'public_folder_smtp_address': + if f.name == "public_folder_smtp_address": if public_folder_information is None: continue kwargs[f.name] = f.from_xml(elem=public_folder_information, account=account) @@ -238,7 +251,7 @@

              Module exchangelib.autodiscover.properties

              class Response(AutodiscoverBase): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/response-pox""" - ELEMENT_NAME = 'Response' + ELEMENT_NAME = "Response" user = EWSElementField(value_cls=User) account = EWSElementField(value_cls=Account) @@ -300,7 +313,7 @@

              Module exchangelib.autodiscover.properties

              if Protocol.EXCH in protocols: return protocols[Protocol.EXCH] raise ValueError( - f'No EWS URL found in any of the available protocols: {[str(p) for p in self.account.protocols]}' + f"No EWS URL found in any of the available protocols: {[str(p) for p in self.account.protocols]}" ) @@ -310,14 +323,14 @@

              Module exchangelib.autodiscover.properties

              Like 'Response', but with a different namespace. """ - ELEMENT_NAME = 'Response' + ELEMENT_NAME = "Response" NAMESPACE = AUTODISCOVER_BASE_NS error = EWSElementField(value_cls=Error) class Autodiscover(EWSElement): - ELEMENT_NAME = 'Autodiscover' + ELEMENT_NAME = "Autodiscover" NAMESPACE = AUTODISCOVER_BASE_NS response = EWSElementField(value_cls=Response) @@ -337,10 +350,10 @@

              Module exchangelib.autodiscover.properties

              :return: """ if not is_xml(bytes_content): - raise ParseError(f'Response is not XML: {bytes_content}', '<not from file>', -1, 0) + raise ParseError(f"Response is not XML: {bytes_content}", "<not from file>", -1, 0) root = to_xml(bytes_content).getroot() # May raise ParseError if root.tag != cls.response_tag(): - raise ParseError(f'Unknown root element in XML: {bytes_content}', '<not from file>', -1, 0) + raise ParseError(f"Unknown root element in XML: {bytes_content}", "<not from file>", -1, 0) return cls.from_xml(elem=root, account=None) def raise_errors(self): @@ -348,19 +361,19 @@

              Module exchangelib.autodiscover.properties

              try: errorcode = self.error_response.error.code message = self.error_response.error.message - if message in ('The e-mail address cannot be found.', "The email address can't be found."): - raise ErrorNonExistentMailbox('The SMTP address has no mailbox associated with it') - raise AutoDiscoverFailed(f'Unknown error {errorcode}: {message}') + if message in ("The e-mail address cannot be found.", "The email address can't be found."): + raise ErrorNonExistentMailbox("The SMTP address has no mailbox associated with it") + raise AutoDiscoverFailed(f"Unknown error {errorcode}: {message}") except AttributeError: - raise AutoDiscoverFailed(f'Unknown autodiscover error response: {self.error_response}') + raise AutoDiscoverFailed(f"Unknown autodiscover error response: {self.error_response}") @staticmethod def payload(email): # Builds a full Autodiscover XML request - payload = create_element('Autodiscover', attrs=dict(xmlns=AUTODISCOVER_REQUEST_NS)) - request = create_element('Request') - add_xml_child(request, 'EMailAddress', email) - add_xml_child(request, 'AcceptableResponseSchema', RNS) + payload = create_element("Autodiscover", attrs=dict(xmlns=AUTODISCOVER_REQUEST_NS)) + request = create_element("Request") + add_xml_child(request, "EMailAddress", email) + add_xml_child(request, "AcceptableResponseSchema", RNS) payload.append(request) return xml_to_str(payload, encoding=DEFAULT_ENCODING, xml_declaration=True)
              @@ -387,29 +400,29 @@

              Classes

              class Account(AutodiscoverBase):
                   """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/account-pox"""
               
              -    ELEMENT_NAME = 'Account'
              -    REDIRECT_URL = 'redirectUrl'
              -    REDIRECT_ADDR = 'redirectAddr'
              -    SETTINGS = 'settings'
              +    ELEMENT_NAME = "Account"
              +    REDIRECT_URL = "redirectUrl"
              +    REDIRECT_ADDR = "redirectAddr"
              +    SETTINGS = "settings"
                   ACTIONS = (REDIRECT_URL, REDIRECT_ADDR, SETTINGS)
               
              -    type = ChoiceField(field_uri='AccountType', namespace=RNS, choices={Choice('email')})
              -    action = ChoiceField(field_uri='Action', namespace=RNS, choices={Choice(p) for p in ACTIONS})
              -    microsoft_online = BooleanField(field_uri='MicrosoftOnline', namespace=RNS)
              -    redirect_url = TextField(field_uri='RedirectURL', namespace=RNS)
              -    redirect_address = EmailAddressField(field_uri='RedirectAddr', namespace=RNS)
              -    image = TextField(field_uri='Image', namespace=RNS)  # Path to image used for branding
              -    service_home = TextField(field_uri='ServiceHome', namespace=RNS)  # URL to website of ISP
              +    type = ChoiceField(field_uri="AccountType", namespace=RNS, choices={Choice("email")})
              +    action = ChoiceField(field_uri="Action", namespace=RNS, choices={Choice(p) for p in ACTIONS})
              +    microsoft_online = BooleanField(field_uri="MicrosoftOnline", namespace=RNS)
              +    redirect_url = TextField(field_uri="RedirectURL", namespace=RNS)
              +    redirect_address = EmailAddressField(field_uri="RedirectAddr", namespace=RNS)
              +    image = TextField(field_uri="Image", namespace=RNS)  # Path to image used for branding
              +    service_home = TextField(field_uri="ServiceHome", namespace=RNS)  # URL to website of ISP
                   protocols = ProtocolListField()
                   # 'SmtpAddress' is inside the 'PublicFolderInformation' element
              -    public_folder_smtp_address = TextField(field_uri='SmtpAddress', namespace=RNS)
              +    public_folder_smtp_address = TextField(field_uri="SmtpAddress", namespace=RNS)
               
                   @classmethod
                   def from_xml(cls, elem, account):
                       kwargs = {}
              -        public_folder_information = elem.find(f'{{{cls.NAMESPACE}}}PublicFolderInformation')
              +        public_folder_information = elem.find(f"{{{cls.NAMESPACE}}}PublicFolderInformation")
                       for f in cls.FIELDS:
              -            if f.name == 'public_folder_smtp_address':
              +            if f.name == "public_folder_smtp_address":
                               if public_folder_information is None:
                                   continue
                               kwargs[f.name] = f.from_xml(elem=public_folder_information, account=account)
              @@ -464,9 +477,9 @@ 

              Static methods

              @classmethod
               def from_xml(cls, elem, account):
                   kwargs = {}
              -    public_folder_information = elem.find(f'{{{cls.NAMESPACE}}}PublicFolderInformation')
              +    public_folder_information = elem.find(f"{{{cls.NAMESPACE}}}PublicFolderInformation")
                   for f in cls.FIELDS:
              -        if f.name == 'public_folder_smtp_address':
              +        if f.name == "public_folder_smtp_address":
                           if public_folder_information is None:
                               continue
                           kwargs[f.name] = f.from_xml(elem=public_folder_information, account=account)
              @@ -541,7 +554,7 @@ 

              Inherited members

              class AddressBook(IntExtUrlBase):
                   """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/addressbook-pox"""
               
              -    ELEMENT_NAME = 'AddressBook'
              + ELEMENT_NAME = "AddressBook"

              Ancestors

                @@ -579,7 +592,7 @@

                Inherited members

                Expand source code
                class Autodiscover(EWSElement):
                -    ELEMENT_NAME = 'Autodiscover'
                +    ELEMENT_NAME = "Autodiscover"
                     NAMESPACE = AUTODISCOVER_BASE_NS
                 
                     response = EWSElementField(value_cls=Response)
                @@ -599,10 +612,10 @@ 

                Inherited members

                :return: """ if not is_xml(bytes_content): - raise ParseError(f'Response is not XML: {bytes_content}', '<not from file>', -1, 0) + raise ParseError(f"Response is not XML: {bytes_content}", "<not from file>", -1, 0) root = to_xml(bytes_content).getroot() # May raise ParseError if root.tag != cls.response_tag(): - raise ParseError(f'Unknown root element in XML: {bytes_content}', '<not from file>', -1, 0) + raise ParseError(f"Unknown root element in XML: {bytes_content}", "<not from file>", -1, 0) return cls.from_xml(elem=root, account=None) def raise_errors(self): @@ -610,19 +623,19 @@

                Inherited members

                try: errorcode = self.error_response.error.code message = self.error_response.error.message - if message in ('The e-mail address cannot be found.', "The email address can't be found."): - raise ErrorNonExistentMailbox('The SMTP address has no mailbox associated with it') - raise AutoDiscoverFailed(f'Unknown error {errorcode}: {message}') + if message in ("The e-mail address cannot be found.", "The email address can't be found."): + raise ErrorNonExistentMailbox("The SMTP address has no mailbox associated with it") + raise AutoDiscoverFailed(f"Unknown error {errorcode}: {message}") except AttributeError: - raise AutoDiscoverFailed(f'Unknown autodiscover error response: {self.error_response}') + raise AutoDiscoverFailed(f"Unknown autodiscover error response: {self.error_response}") @staticmethod def payload(email): # Builds a full Autodiscover XML request - payload = create_element('Autodiscover', attrs=dict(xmlns=AUTODISCOVER_REQUEST_NS)) - request = create_element('Request') - add_xml_child(request, 'EMailAddress', email) - add_xml_child(request, 'AcceptableResponseSchema', RNS) + payload = create_element("Autodiscover", attrs=dict(xmlns=AUTODISCOVER_REQUEST_NS)) + request = create_element("Request") + add_xml_child(request, "EMailAddress", email) + add_xml_child(request, "AcceptableResponseSchema", RNS) payload.append(request) return xml_to_str(payload, encoding=DEFAULT_ENCODING, xml_declaration=True)
                @@ -668,10 +681,10 @@

                Static methods

                :return: """ if not is_xml(bytes_content): - raise ParseError(f'Response is not XML: {bytes_content}', '<not from file>', -1, 0) + raise ParseError(f"Response is not XML: {bytes_content}", "<not from file>", -1, 0) root = to_xml(bytes_content).getroot() # May raise ParseError if root.tag != cls.response_tag(): - raise ParseError(f'Unknown root element in XML: {bytes_content}', '<not from file>', -1, 0) + raise ParseError(f"Unknown root element in XML: {bytes_content}", "<not from file>", -1, 0) return cls.from_xml(elem=root, account=None)
              @@ -687,10 +700,10 @@

              Static methods

              @staticmethod
               def payload(email):
                   # Builds a full Autodiscover XML request
              -    payload = create_element('Autodiscover', attrs=dict(xmlns=AUTODISCOVER_REQUEST_NS))
              -    request = create_element('Request')
              -    add_xml_child(request, 'EMailAddress', email)
              -    add_xml_child(request, 'AcceptableResponseSchema', RNS)
              +    payload = create_element("Autodiscover", attrs=dict(xmlns=AUTODISCOVER_REQUEST_NS))
              +    request = create_element("Request")
              +    add_xml_child(request, "EMailAddress", email)
              +    add_xml_child(request, "AcceptableResponseSchema", RNS)
                   payload.append(request)
                   return xml_to_str(payload, encoding=DEFAULT_ENCODING, xml_declaration=True)
              @@ -723,11 +736,11 @@

              Methods

              try: errorcode = self.error_response.error.code message = self.error_response.error.message - if message in ('The e-mail address cannot be found.', "The email address can't be found."): - raise ErrorNonExistentMailbox('The SMTP address has no mailbox associated with it') - raise AutoDiscoverFailed(f'Unknown error {errorcode}: {message}') + if message in ("The e-mail address cannot be found.", "The email address can't be found."): + raise ErrorNonExistentMailbox("The SMTP address has no mailbox associated with it") + raise AutoDiscoverFailed(f"Unknown error {errorcode}: {message}") except AttributeError: - raise AutoDiscoverFailed(f'Unknown autodiscover error response: {self.error_response}')
              + raise AutoDiscoverFailed(f"Unknown autodiscover error response: {self.error_response}")
              @@ -802,14 +815,14 @@

              Inherited members

              class Error(EWSElement):
                   """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/error-pox"""
               
              -    ELEMENT_NAME = 'Error'
              +    ELEMENT_NAME = "Error"
                   NAMESPACE = AUTODISCOVER_BASE_NS
               
              -    id = TextField(field_uri='Id', namespace=AUTODISCOVER_BASE_NS, is_attribute=True)
              -    time = TextField(field_uri='Time', namespace=AUTODISCOVER_BASE_NS, is_attribute=True)
              -    code = TextField(field_uri='ErrorCode', namespace=AUTODISCOVER_BASE_NS)
              -    message = TextField(field_uri='Message', namespace=AUTODISCOVER_BASE_NS)
              -    debug_data = TextField(field_uri='DebugData', namespace=AUTODISCOVER_BASE_NS)
              + id = TextField(field_uri="Id", namespace=AUTODISCOVER_BASE_NS, is_attribute=True) + time = TextField(field_uri="Time", namespace=AUTODISCOVER_BASE_NS, is_attribute=True) + code = TextField(field_uri="ErrorCode", namespace=AUTODISCOVER_BASE_NS) + message = TextField(field_uri="Message", namespace=AUTODISCOVER_BASE_NS) + debug_data = TextField(field_uri="DebugData", namespace=AUTODISCOVER_BASE_NS)

              Ancestors

                @@ -882,7 +895,7 @@

                Inherited members

                Like 'Response', but with a different namespace. """ - ELEMENT_NAME = 'Response' + ELEMENT_NAME = "Response" NAMESPACE = AUTODISCOVER_BASE_NS error = EWSElementField(value_cls=Error) @@ -938,7 +951,7 @@

                Inherited members

                class External(IntExtBase):
                     """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/external-pox"""
                 
                -    ELEMENT_NAME = 'External'
                + ELEMENT_NAME = "External"

                Ancestors

                  @@ -978,7 +991,7 @@

                  Inherited members

                  class IntExtBase(AutodiscoverBase):
                       # TODO: 'OWAUrl' also has an AuthenticationMethod enum-style XML attribute with values:
                       #  WindowsIntegrated, FBA, NTLM, Digest, Basic
                  -    owa_url = TextField(field_uri='OWAUrl', namespace=RNS)
                  +    owa_url = TextField(field_uri="OWAUrl", namespace=RNS)
                       protocol = EWSElementField(value_cls=SimpleProtocol)

                  Ancestors

                  @@ -1032,8 +1045,8 @@

                  Inherited members

                  Expand source code
                  class IntExtUrlBase(AutodiscoverBase):
                  -    external_url = TextField(field_uri='ExternalUrl', namespace=RNS)
                  -    internal_url = TextField(field_uri='InternalUrl', namespace=RNS)
                  + external_url = TextField(field_uri="ExternalUrl", namespace=RNS) + internal_url = TextField(field_uri="InternalUrl", namespace=RNS)

                  Ancestors

                    @@ -1088,7 +1101,7 @@

                    Inherited members

                    class Internal(IntExtBase):
                         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/internal-pox"""
                     
                    -    ELEMENT_NAME = 'Internal'
                    + ELEMENT_NAME = "Internal"

                    Ancestors

                      @@ -1128,7 +1141,7 @@

                      Inherited members

                      class MailStore(IntExtUrlBase):
                           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailstore-pox"""
                       
                      -    ELEMENT_NAME = 'MailStore'
                      + ELEMENT_NAME = "MailStore"

                      Ancestors

                        @@ -1168,12 +1181,12 @@

                        Inherited members

                        class NetworkRequirements(AutodiscoverBase):
                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/networkrequirements-pox"""
                         
                        -    ELEMENT_NAME = 'NetworkRequirements'
                        +    ELEMENT_NAME = "NetworkRequirements"
                         
                        -    ipv4_start = TextField(field_uri='IPv4Start', namespace=RNS)
                        -    ipv4_end = TextField(field_uri='IPv4End', namespace=RNS)
                        -    ipv6_start = TextField(field_uri='IPv6Start', namespace=RNS)
                        -    ipv6_end = TextField(field_uri='IPv6End', namespace=RNS)
                        + ipv4_start = TextField(field_uri="IPv4Start", namespace=RNS) + ipv4_end = TextField(field_uri="IPv4End", namespace=RNS) + ipv6_start = TextField(field_uri="IPv6Start", namespace=RNS) + ipv6_end = TextField(field_uri="IPv6End", namespace=RNS)

                        Ancestors

                          @@ -1236,50 +1249,52 @@

                          Inherited members

                          """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/protocol-pox""" # Attribute 'Type' is ignored here. Has a name conflict with the child element and does not seem useful. - version = TextField(field_uri='Version', is_attribute=True, namespace=RNS) + version = TextField(field_uri="Version", is_attribute=True, namespace=RNS) internal = EWSElementField(value_cls=Internal) external = EWSElementField(value_cls=External) - ttl = IntegerField(field_uri='TTL', namespace=RNS, default=1) # TTL for this autodiscover response, in hours - server = TextField(field_uri='Server', namespace=RNS) - server_dn = TextField(field_uri='ServerDN', namespace=RNS) - server_version = BuildField(field_uri='ServerVersion', namespace=RNS) - mdb_dn = TextField(field_uri='MdbDN', namespace=RNS) - public_folder_server = TextField(field_uri='PublicFolderServer', namespace=RNS) - port = IntegerField(field_uri='Port', namespace=RNS, min=1, max=65535) - directory_port = IntegerField(field_uri='DirectoryPort', namespace=RNS, min=1, max=65535) - referral_port = IntegerField(field_uri='ReferralPort', namespace=RNS, min=1, max=65535) - ews_url = TextField(field_uri='EwsUrl', namespace=RNS) - emws_url = TextField(field_uri='EmwsUrl', namespace=RNS) - sharing_url = TextField(field_uri='SharingUrl', namespace=RNS) - ecp_url = TextField(field_uri='EcpUrl', namespace=RNS) - ecp_url_um = TextField(field_uri='EcpUrl-um', namespace=RNS) - ecp_url_aggr = TextField(field_uri='EcpUrl-aggr', namespace=RNS) - ecp_url_mt = TextField(field_uri='EcpUrl-mt', namespace=RNS) - ecp_url_ret = TextField(field_uri='EcpUrl-ret', namespace=RNS) - ecp_url_sms = TextField(field_uri='EcpUrl-sms', namespace=RNS) - ecp_url_publish = TextField(field_uri='EcpUrl-publish', namespace=RNS) - ecp_url_photo = TextField(field_uri='EcpUrl-photo', namespace=RNS) - ecp_url_tm = TextField(field_uri='EcpUrl-tm', namespace=RNS) - ecp_url_tm_creating = TextField(field_uri='EcpUrl-tmCreating', namespace=RNS) - ecp_url_tm_hiding = TextField(field_uri='EcpUrl-tmHiding', namespace=RNS) - ecp_url_tm_editing = TextField(field_uri='EcpUrl-tmEditing', namespace=RNS) - ecp_url_extinstall = TextField(field_uri='EcpUrl-extinstall', namespace=RNS) - oof_url = TextField(field_uri='OOFUrl', namespace=RNS) - oab_url = TextField(field_uri='OABUrl', namespace=RNS) - um_url = TextField(field_uri='UMUrl', namespace=RNS) - ews_partner_url = TextField(field_uri='EwsPartnerUrl', namespace=RNS) - login_name = TextField(field_uri='LoginName', namespace=RNS) - domain_required = OnOffField(field_uri='DomainRequired', namespace=RNS) - domain_name = TextField(field_uri='DomainName', namespace=RNS) - spa = OnOffField(field_uri='SPA', namespace=RNS, default=True) - auth_package = ChoiceField(field_uri='AuthPackage', namespace=RNS, choices={ - Choice(c) for c in ('basic', 'kerb', 'kerbntlm', 'ntlm', 'certificate', 'negotiate', 'nego2') - }) - cert_principal_name = TextField(field_uri='CertPrincipalName', namespace=RNS) - ssl = OnOffField(field_uri='SSL', namespace=RNS, default=True) - auth_required = OnOffField(field_uri='AuthRequired', namespace=RNS, default=True) - use_pop_path = OnOffField(field_uri='UsePOPAuth', namespace=RNS) - smtp_last = OnOffField(field_uri='SMTPLast', namespace=RNS, default=False) + ttl = IntegerField(field_uri="TTL", namespace=RNS, default=1) # TTL for this autodiscover response, in hours + server = TextField(field_uri="Server", namespace=RNS) + server_dn = TextField(field_uri="ServerDN", namespace=RNS) + server_version = BuildField(field_uri="ServerVersion", namespace=RNS) + mdb_dn = TextField(field_uri="MdbDN", namespace=RNS) + public_folder_server = TextField(field_uri="PublicFolderServer", namespace=RNS) + port = IntegerField(field_uri="Port", namespace=RNS, min=1, max=65535) + directory_port = IntegerField(field_uri="DirectoryPort", namespace=RNS, min=1, max=65535) + referral_port = IntegerField(field_uri="ReferralPort", namespace=RNS, min=1, max=65535) + ews_url = TextField(field_uri="EwsUrl", namespace=RNS) + emws_url = TextField(field_uri="EmwsUrl", namespace=RNS) + sharing_url = TextField(field_uri="SharingUrl", namespace=RNS) + ecp_url = TextField(field_uri="EcpUrl", namespace=RNS) + ecp_url_um = TextField(field_uri="EcpUrl-um", namespace=RNS) + ecp_url_aggr = TextField(field_uri="EcpUrl-aggr", namespace=RNS) + ecp_url_mt = TextField(field_uri="EcpUrl-mt", namespace=RNS) + ecp_url_ret = TextField(field_uri="EcpUrl-ret", namespace=RNS) + ecp_url_sms = TextField(field_uri="EcpUrl-sms", namespace=RNS) + ecp_url_publish = TextField(field_uri="EcpUrl-publish", namespace=RNS) + ecp_url_photo = TextField(field_uri="EcpUrl-photo", namespace=RNS) + ecp_url_tm = TextField(field_uri="EcpUrl-tm", namespace=RNS) + ecp_url_tm_creating = TextField(field_uri="EcpUrl-tmCreating", namespace=RNS) + ecp_url_tm_hiding = TextField(field_uri="EcpUrl-tmHiding", namespace=RNS) + ecp_url_tm_editing = TextField(field_uri="EcpUrl-tmEditing", namespace=RNS) + ecp_url_extinstall = TextField(field_uri="EcpUrl-extinstall", namespace=RNS) + oof_url = TextField(field_uri="OOFUrl", namespace=RNS) + oab_url = TextField(field_uri="OABUrl", namespace=RNS) + um_url = TextField(field_uri="UMUrl", namespace=RNS) + ews_partner_url = TextField(field_uri="EwsPartnerUrl", namespace=RNS) + login_name = TextField(field_uri="LoginName", namespace=RNS) + domain_required = OnOffField(field_uri="DomainRequired", namespace=RNS) + domain_name = TextField(field_uri="DomainName", namespace=RNS) + spa = OnOffField(field_uri="SPA", namespace=RNS, default=True) + auth_package = ChoiceField( + field_uri="AuthPackage", + namespace=RNS, + choices={Choice(c) for c in ("basic", "kerb", "kerbntlm", "ntlm", "certificate", "negotiate", "nego2")}, + ) + cert_principal_name = TextField(field_uri="CertPrincipalName", namespace=RNS) + ssl = OnOffField(field_uri="SSL", namespace=RNS, default=True) + auth_required = OnOffField(field_uri="AuthRequired", namespace=RNS, default=True) + use_pop_path = OnOffField(field_uri="UsePOPAuth", namespace=RNS) + smtp_last = OnOffField(field_uri="SMTPLast", namespace=RNS, default=False) network_requirements = EWSElementField(value_cls=NetworkRequirements) address_book = EWSElementField(value_cls=AddressBook) mail_store = EWSElementField(value_cls=MailStore) @@ -1293,14 +1308,14 @@

                          Inherited members

                          return None return { # Missing in list are DIGEST and OAUTH2 - 'basic': BASIC, - 'kerb': GSSAPI, - 'kerbntlm': NTLM, # Means client can chose between NTLM and GSSAPI - 'ntlm': NTLM, - 'certificate': CBA, - 'negotiate': SSPI, # Unsure about this one - 'nego2': GSSAPI, - 'anonymous': NOAUTH, # Seen in some docs even though it's not mentioned in MSDN + "basic": BASIC, + "kerb": GSSAPI, + "kerbntlm": NTLM, # Means client can chose between NTLM and GSSAPI + "ntlm": NTLM, + "certificate": CBA, + "negotiate": SSPI, # Unsure about this one + "nego2": GSSAPI, + "anonymous": NOAUTH, # Seen in some docs even though it's not mentioned in MSDN }.get(self.auth_package.lower())

                          Ancestors

                          @@ -1346,14 +1361,14 @@

                          Instance variables

                          return None return { # Missing in list are DIGEST and OAUTH2 - 'basic': BASIC, - 'kerb': GSSAPI, - 'kerbntlm': NTLM, # Means client can chose between NTLM and GSSAPI - 'ntlm': NTLM, - 'certificate': CBA, - 'negotiate': SSPI, # Unsure about this one - 'nego2': GSSAPI, - 'anonymous': NOAUTH, # Seen in some docs even though it's not mentioned in MSDN + "basic": BASIC, + "kerb": GSSAPI, + "kerbntlm": NTLM, # Means client can chose between NTLM and GSSAPI + "ntlm": NTLM, + "certificate": CBA, + "negotiate": SSPI, # Unsure about this one + "nego2": GSSAPI, + "anonymous": NOAUTH, # Seen in some docs even though it's not mentioned in MSDN }.get(self.auth_package.lower()) @@ -1551,7 +1566,7 @@

                          Inherited members

                          class Response(AutodiscoverBase):
                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/response-pox"""
                           
                          -    ELEMENT_NAME = 'Response'
                          +    ELEMENT_NAME = "Response"
                           
                               user = EWSElementField(value_cls=User)
                               account = EWSElementField(value_cls=Account)
                          @@ -1613,7 +1628,7 @@ 

                          Inherited members

                          if Protocol.EXCH in protocols: return protocols[Protocol.EXCH] raise ValueError( - f'No EWS URL found in any of the available protocols: {[str(p) for p in self.account.protocols]}' + f"No EWS URL found in any of the available protocols: {[str(p) for p in self.account.protocols]}" )

                          Ancestors

                          @@ -1684,7 +1699,7 @@

                          Instance variables

                          if Protocol.EXCH in protocols: return protocols[Protocol.EXCH] raise ValueError( - f'No EWS URL found in any of the available protocols: {[str(p) for p in self.account.protocols]}' + f"No EWS URL found in any of the available protocols: {[str(p) for p in self.account.protocols]}" ) @@ -1776,15 +1791,15 @@

                          Inherited members

                          Used for the 'Internal' and 'External' elements that may contain a stripped-down version of the Protocol element. """ - ELEMENT_NAME = 'Protocol' - WEB = 'WEB' - EXCH = 'EXCH' - EXPR = 'EXPR' - EXHTTP = 'EXHTTP' + ELEMENT_NAME = "Protocol" + WEB = "WEB" + EXCH = "EXCH" + EXPR = "EXPR" + EXHTTP = "EXHTTP" TYPES = (WEB, EXCH, EXPR, EXHTTP) - type = ChoiceField(field_uri='Type', choices={Choice(c) for c in TYPES}, namespace=RNS) - as_url = TextField(field_uri='ASUrl', namespace=RNS) + type = ChoiceField(field_uri="Type", choices={Choice(c) for c in TYPES}, namespace=RNS) + as_url = TextField(field_uri="ASUrl", namespace=RNS)

                          Ancestors

                            @@ -1862,12 +1877,12 @@

                            Inherited members

                            class User(AutodiscoverBase):
                                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/user-pox"""
                             
                            -    ELEMENT_NAME = 'User'
                            +    ELEMENT_NAME = "User"
                             
                            -    display_name = TextField(field_uri='DisplayName', namespace=RNS)
                            -    legacy_dn = TextField(field_uri='LegacyDN', namespace=RNS)
                            -    deployment_id = TextField(field_uri='DeploymentId', namespace=RNS)  # GUID format
                            -    autodiscover_smtp_address = EmailAddressField(field_uri='AutoDiscoverSMTPAddress', namespace=RNS)
                            + display_name = TextField(field_uri="DisplayName", namespace=RNS) + legacy_dn = TextField(field_uri="LegacyDN", namespace=RNS) + deployment_id = TextField(field_uri="DeploymentId", namespace=RNS) # GUID format + autodiscover_smtp_address = EmailAddressField(field_uri="AutoDiscoverSMTPAddress", namespace=RNS)

                            Ancestors

                              diff --git a/docs/exchangelib/autodiscover/protocol.html b/docs/exchangelib/autodiscover/protocol.html index ea35a2b0..1b046bac 100644 --- a/docs/exchangelib/autodiscover/protocol.html +++ b/docs/exchangelib/autodiscover/protocol.html @@ -35,9 +35,9 @@

                              Module exchangelib.autodiscover.protocol

                              TIMEOUT = 10 # Seconds def __str__(self): - return f'''\ + return f"""\ Autodiscover endpoint: {self.service_endpoint} -Auth type: {self.auth_type}''' +Auth type: {self.auth_type}"""
                              @@ -65,9 +65,9 @@

                              Classes

                              TIMEOUT = 10 # Seconds def __str__(self): - return f'''\ + return f"""\ Autodiscover endpoint: {self.service_endpoint} -Auth type: {self.auth_type}''' +Auth type: {self.auth_type}"""

                              Ancestors

                                diff --git a/docs/exchangelib/configuration.html b/docs/exchangelib/configuration.html index 015f089f..1cf59d9b 100644 --- a/docs/exchangelib/configuration.html +++ b/docs/exchangelib/configuration.html @@ -30,10 +30,10 @@

                                Module exchangelib.configuration

                                from cached_property import threaded_cached_property +from .credentials import BaseCredentials, OAuth2AuthorizationCodeCredentials, OAuth2Credentials from .errors import InvalidEnumValue, InvalidTypeError -from .credentials import BaseCredentials, OAuth2Credentials, OAuth2AuthorizationCodeCredentials -from .protocol import RetryPolicy, FailFast -from .transport import AUTH_TYPE_MAP, OAUTH2, CREDENTIALS_REQUIRED +from .protocol import FailFast, RetryPolicy +from .transport import AUTH_TYPE_MAP, CREDENTIALS_REQUIRED, OAUTH2 from .util import split_url from .version import Version @@ -74,30 +74,38 @@

                                Module exchangelib.configuration

                                policies on the Exchange server. """ - def __init__(self, credentials=None, server=None, service_endpoint=None, auth_type=None, version=None, - retry_policy=None, max_connections=None): + def __init__( + self, + credentials=None, + server=None, + service_endpoint=None, + auth_type=None, + version=None, + retry_policy=None, + max_connections=None, + ): if not isinstance(credentials, (BaseCredentials, type(None))): - raise InvalidTypeError('credentials', credentials, BaseCredentials) + raise InvalidTypeError("credentials", credentials, BaseCredentials) if auth_type is None: # Set a default auth type for the credentials where this makes sense auth_type = DEFAULT_AUTH_TYPE.get(type(credentials)) elif credentials is None and auth_type in CREDENTIALS_REQUIRED: - raise ValueError(f'Auth type {auth_type!r} was detected but no credentials were provided') + raise ValueError(f"Auth type {auth_type!r} was detected but no credentials were provided") if server and service_endpoint: raise AttributeError("Only one of 'server' or 'service_endpoint' must be provided") if auth_type is not None and auth_type not in AUTH_TYPE_MAP: - raise InvalidEnumValue('auth_type', auth_type, AUTH_TYPE_MAP) + raise InvalidEnumValue("auth_type", auth_type, AUTH_TYPE_MAP) if not retry_policy: retry_policy = FailFast() if not isinstance(version, (Version, type(None))): - raise InvalidTypeError('version', version, Version) + raise InvalidTypeError("version", version, Version) if not isinstance(retry_policy, RetryPolicy): - raise InvalidTypeError('retry_policy', retry_policy, RetryPolicy) + raise InvalidTypeError("retry_policy", retry_policy, RetryPolicy) if not isinstance(max_connections, (int, type(None))): - raise InvalidTypeError('max_connections', max_connections, int) + raise InvalidTypeError("max_connections", max_connections, int) self._credentials = credentials if server: - self.service_endpoint = f'https://{server}/EWS/Exchange.asmx' + self.service_endpoint = f"https://{server}/EWS/Exchange.asmx" else: self.service_endpoint = service_endpoint self.auth_type = auth_type @@ -117,10 +125,11 @@

                                Module exchangelib.configuration

                                return split_url(self.service_endpoint)[1] def __repr__(self): - args_str = ', '.join(f'{k}={getattr(self, k)!r}' for k in ( - 'credentials', 'service_endpoint', 'auth_type', 'version', 'retry_policy' - )) - return f'{self.__class__.__name__}({args_str})' + args_str = ", ".join( + f"{k}={getattr(self, k)!r}" + for k in ("credentials", "service_endpoint", "auth_type", "version", "retry_policy") + ) + return f"{self.__class__.__name__}({args_str})"
                              @@ -188,30 +197,38 @@

                              Classes

                              policies on the Exchange server. """ - def __init__(self, credentials=None, server=None, service_endpoint=None, auth_type=None, version=None, - retry_policy=None, max_connections=None): + def __init__( + self, + credentials=None, + server=None, + service_endpoint=None, + auth_type=None, + version=None, + retry_policy=None, + max_connections=None, + ): if not isinstance(credentials, (BaseCredentials, type(None))): - raise InvalidTypeError('credentials', credentials, BaseCredentials) + raise InvalidTypeError("credentials", credentials, BaseCredentials) if auth_type is None: # Set a default auth type for the credentials where this makes sense auth_type = DEFAULT_AUTH_TYPE.get(type(credentials)) elif credentials is None and auth_type in CREDENTIALS_REQUIRED: - raise ValueError(f'Auth type {auth_type!r} was detected but no credentials were provided') + raise ValueError(f"Auth type {auth_type!r} was detected but no credentials were provided") if server and service_endpoint: raise AttributeError("Only one of 'server' or 'service_endpoint' must be provided") if auth_type is not None and auth_type not in AUTH_TYPE_MAP: - raise InvalidEnumValue('auth_type', auth_type, AUTH_TYPE_MAP) + raise InvalidEnumValue("auth_type", auth_type, AUTH_TYPE_MAP) if not retry_policy: retry_policy = FailFast() if not isinstance(version, (Version, type(None))): - raise InvalidTypeError('version', version, Version) + raise InvalidTypeError("version", version, Version) if not isinstance(retry_policy, RetryPolicy): - raise InvalidTypeError('retry_policy', retry_policy, RetryPolicy) + raise InvalidTypeError("retry_policy", retry_policy, RetryPolicy) if not isinstance(max_connections, (int, type(None))): - raise InvalidTypeError('max_connections', max_connections, int) + raise InvalidTypeError("max_connections", max_connections, int) self._credentials = credentials if server: - self.service_endpoint = f'https://{server}/EWS/Exchange.asmx' + self.service_endpoint = f"https://{server}/EWS/Exchange.asmx" else: self.service_endpoint = service_endpoint self.auth_type = auth_type @@ -231,10 +248,11 @@

                              Classes

                              return split_url(self.service_endpoint)[1] def __repr__(self): - args_str = ', '.join(f'{k}={getattr(self, k)!r}' for k in ( - 'credentials', 'service_endpoint', 'auth_type', 'version', 'retry_policy' - )) - return f'{self.__class__.__name__}({args_str})' + args_str = ", ".join( + f"{k}={getattr(self, k)!r}" + for k in ("credentials", "service_endpoint", "auth_type", "version", "retry_policy") + ) + return f"{self.__class__.__name__}({args_str})"

                              Instance variables

                              diff --git a/docs/exchangelib/credentials.html b/docs/exchangelib/credentials.html index ebdaabe9..9543bca5 100644 --- a/docs/exchangelib/credentials.html +++ b/docs/exchangelib/credentials.html @@ -47,8 +47,8 @@

                              Module exchangelib.credentials

                              log = logging.getLogger(__name__) -IMPERSONATION = 'impersonation' -DELEGATE = 'delegate' +IMPERSONATION = "impersonation" +DELEGATE = "delegate" ACCESS_TYPES = (IMPERSONATION, DELEGATE) @@ -77,10 +77,10 @@

                              Module exchangelib.credentials

                              """ def _get_hash_values(self): - return (getattr(self, k) for k in self.__dict__ if k != '_lock') + return (getattr(self, k) for k in self.__dict__ if k != "_lock") def __eq__(self, other): - return all(getattr(self, k) == getattr(other, k) for k in self.__dict__ if k != '_lock') + return all(getattr(self, k) == getattr(other, k) for k in self.__dict__ if k != "_lock") def __hash__(self): return hash(tuple(self._get_hash_values())) @@ -88,7 +88,7 @@

                              Module exchangelib.credentials

                              def __getstate__(self): # The lock cannot be pickled state = self.__dict__.copy() - del state['_lock'] + del state["_lock"] return state def __setstate__(self, state): @@ -107,15 +107,15 @@

                              Module exchangelib.credentials

                              password: Clear-text password """ - EMAIL = 'email' - DOMAIN = 'domain' - UPN = 'upn' + EMAIL = "email" + DOMAIN = "domain" + UPN = "upn" def __init__(self, username, password): super().__init__() - if username.count('@') == 1: + if username.count("@") == 1: self.type = self.EMAIL - elif username.count('\\') == 1: + elif username.count("\\") == 1: self.type = self.DOMAIN else: self.type = self.UPN @@ -126,7 +126,7 @@

                              Module exchangelib.credentials

                              pass def __repr__(self): - return self.__class__.__name__ + repr((self.username, '********')) + return self.__class__.__name__ + repr((self.username, "********")) def __str__(self): return self.username @@ -172,31 +172,31 @@

                              Module exchangelib.credentials

                              """ # Ensure we don't update the object in the middle of a new session being created, which could cause a race. if not isinstance(access_token, dict): - raise InvalidTypeError('access_token', access_token, OAuth2Token) + raise InvalidTypeError("access_token", access_token, OAuth2Token) with self.lock: - log.debug('%s auth token for %s', 'Refreshing' if self.access_token else 'Setting', self.client_id) + log.debug("%s auth token for %s", "Refreshing" if self.access_token else "Setting", self.client_id) self.access_token = access_token def _get_hash_values(self): # 'access_token' may be refreshed once in a while. This should not affect the hash signature. # 'identity' is just informational and should also not affect the hash signature. - return (getattr(self, k) for k in self.__dict__ if k not in ('_lock', 'identity', 'access_token')) + return (getattr(self, k) for k in self.__dict__ if k not in ("_lock", "identity", "access_token")) def sig(self): # Like hash(self), but pulls in the access token. Protocol.refresh_credentials() uses this to find out # if the access_token needs to be refreshed. res = [] for k in self.__dict__: - if k in ('_lock', 'identity'): + if k in ("_lock", "identity"): continue - if k == 'access_token': - res.append(self.access_token['access_token'] if self.access_token else None) + if k == "access_token": + res.append(self.access_token["access_token"] if self.access_token else None) continue res.append(getattr(self, k)) return hash(tuple(res)) def __repr__(self): - return self.__class__.__name__ + repr((self.client_id, '********')) + return self.__class__.__name__ + repr((self.client_id, "********")) def __str__(self): return self.client_id @@ -232,20 +232,23 @@

                              Module exchangelib.credentials

                              super().__init__(**kwargs) self.authorization_code = authorization_code if access_token is not None and not isinstance(access_token, dict): - raise InvalidTypeError('access_token', access_token, OAuth2Token) + raise InvalidTypeError("access_token", access_token, OAuth2Token) self.access_token = access_token def __repr__(self): return self.__class__.__name__ + repr( - (self.client_id, '[client_secret]', '[authorization_code]', '[access_token]') + (self.client_id, "[client_secret]", "[authorization_code]", "[access_token]") ) def __str__(self): client_id = self.client_id - credential = '[access_token]' if self.access_token is not None else \ - ('[authorization_code]' if self.authorization_code is not None else None) - description = ' '.join(filter(None, [client_id, credential])) - return description or '[underspecified credentials]' + credential = ( + "[access_token]" + if self.access_token is not None + else ("[authorization_code]" if self.authorization_code is not None else None) + ) + description = " ".join(filter(None, [client_id, credential])) + return description or "[underspecified credentials]"
                              @@ -293,10 +296,10 @@

                              Classes

                              """ def _get_hash_values(self): - return (getattr(self, k) for k in self.__dict__ if k != '_lock') + return (getattr(self, k) for k in self.__dict__ if k != "_lock") def __eq__(self, other): - return all(getattr(self, k) == getattr(other, k) for k in self.__dict__ if k != '_lock') + return all(getattr(self, k) == getattr(other, k) for k in self.__dict__ if k != "_lock") def __hash__(self): return hash(tuple(self._get_hash_values())) @@ -304,7 +307,7 @@

                              Classes

                              def __getstate__(self): # The lock cannot be pickled state = self.__dict__.copy() - del state['_lock'] + del state["_lock"] return state def __setstate__(self, state): @@ -385,15 +388,15 @@

                              Methods

                              password: Clear-text password """ - EMAIL = 'email' - DOMAIN = 'domain' - UPN = 'upn' + EMAIL = "email" + DOMAIN = "domain" + UPN = "upn" def __init__(self, username, password): super().__init__() - if username.count('@') == 1: + if username.count("@") == 1: self.type = self.EMAIL - elif username.count('\\') == 1: + elif username.count("\\") == 1: self.type = self.DOMAIN else: self.type = self.UPN @@ -404,7 +407,7 @@

                              Methods

                              pass def __repr__(self): - return self.__class__.__name__ + repr((self.username, '********')) + return self.__class__.__name__ + repr((self.username, "********")) def __str__(self): return self.username @@ -495,20 +498,23 @@

                              Inherited members

                              super().__init__(**kwargs) self.authorization_code = authorization_code if access_token is not None and not isinstance(access_token, dict): - raise InvalidTypeError('access_token', access_token, OAuth2Token) + raise InvalidTypeError("access_token", access_token, OAuth2Token) self.access_token = access_token def __repr__(self): return self.__class__.__name__ + repr( - (self.client_id, '[client_secret]', '[authorization_code]', '[access_token]') + (self.client_id, "[client_secret]", "[authorization_code]", "[access_token]") ) def __str__(self): client_id = self.client_id - credential = '[access_token]' if self.access_token is not None else \ - ('[authorization_code]' if self.authorization_code is not None else None) - description = ' '.join(filter(None, [client_id, credential])) - return description or '[underspecified credentials]' + credential = ( + "[access_token]" + if self.access_token is not None + else ("[authorization_code]" if self.authorization_code is not None else None) + ) + description = " ".join(filter(None, [client_id, credential])) + return description or "[underspecified credentials]"

                              Ancestors

                                @@ -583,31 +589,31 @@

                                Inherited members

                                """ # Ensure we don't update the object in the middle of a new session being created, which could cause a race. if not isinstance(access_token, dict): - raise InvalidTypeError('access_token', access_token, OAuth2Token) + raise InvalidTypeError("access_token", access_token, OAuth2Token) with self.lock: - log.debug('%s auth token for %s', 'Refreshing' if self.access_token else 'Setting', self.client_id) + log.debug("%s auth token for %s", "Refreshing" if self.access_token else "Setting", self.client_id) self.access_token = access_token def _get_hash_values(self): # 'access_token' may be refreshed once in a while. This should not affect the hash signature. # 'identity' is just informational and should also not affect the hash signature. - return (getattr(self, k) for k in self.__dict__ if k not in ('_lock', 'identity', 'access_token')) + return (getattr(self, k) for k in self.__dict__ if k not in ("_lock", "identity", "access_token")) def sig(self): # Like hash(self), but pulls in the access token. Protocol.refresh_credentials() uses this to find out # if the access_token needs to be refreshed. res = [] for k in self.__dict__: - if k in ('_lock', 'identity'): + if k in ("_lock", "identity"): continue - if k == 'access_token': - res.append(self.access_token['access_token'] if self.access_token else None) + if k == "access_token": + res.append(self.access_token["access_token"] if self.access_token else None) continue res.append(getattr(self, k)) return hash(tuple(res)) def __repr__(self): - return self.__class__.__name__ + repr((self.client_id, '********')) + return self.__class__.__name__ + repr((self.client_id, "********")) def __str__(self): return self.client_id @@ -645,9 +651,9 @@

                                Methods

                                """ # Ensure we don't update the object in the middle of a new session being created, which could cause a race. if not isinstance(access_token, dict): - raise InvalidTypeError('access_token', access_token, OAuth2Token) + raise InvalidTypeError("access_token", access_token, OAuth2Token) with self.lock: - log.debug('%s auth token for %s', 'Refreshing' if self.access_token else 'Setting', self.client_id) + log.debug("%s auth token for %s", "Refreshing" if self.access_token else "Setting", self.client_id) self.access_token = access_token @@ -665,10 +671,10 @@

                                Methods

                                # if the access_token needs to be refreshed. res = [] for k in self.__dict__: - if k in ('_lock', 'identity'): + if k in ("_lock", "identity"): continue - if k == 'access_token': - res.append(self.access_token['access_token'] if self.access_token else None) + if k == "access_token": + res.append(self.access_token["access_token"] if self.access_token else None) continue res.append(getattr(self, k)) return hash(tuple(res)) diff --git a/docs/exchangelib/errors.html b/docs/exchangelib/errors.html index f73471dd..704a5af7 100644 --- a/docs/exchangelib/errors.html +++ b/docs/exchangelib/errors.html @@ -27,8 +27,7 @@

                                Module exchangelib.errors

                                Expand source code -
                                # flake8: noqa
                                -"""Stores errors specific to this package, and mirrors all the possible errors that EWS can return."""
                                +
                                """Stores errors specific to this package, and mirrors all the possible errors that EWS can return."""
                                 from urllib.parse import urlparse
                                 
                                 
                                @@ -48,7 +47,7 @@ 

                                Module exchangelib.errors

                                super().__init__(str(self)) def __str__(self): - return f'{self.field_name!r} {self.value!r} must be one of {sorted(self.choices)}' + return f"{self.field_name!r} {self.value!r} must be one of {sorted(self.choices)}" class InvalidTypeError(TypeError): @@ -59,7 +58,7 @@

                                Module exchangelib.errors

                                super().__init__(str(self)) def __str__(self): - return f'{self.field_name!r} {self.value!r} must be of type {self.valid_type}' + return f"{self.field_name!r} {self.value!r} must be of type {self.valid_type}" class EWSError(Exception): @@ -94,8 +93,10 @@

                                Module exchangelib.errors

                                self.total_wait = total_wait def __str__(self): - return f'{self.value} (gave up after {self.total_wait:.3f} seconds. ' \ - f'URL {self.url} returned status code {self.status_code})' + return ( + f"{self.value} (gave up after {self.total_wait:.3f} seconds. " + f"URL {self.url} returned status code {self.status_code})" + ) class SOAPError(TransportError): @@ -115,11 +116,11 @@

                                Module exchangelib.errors

                                parsed_url = urlparse(url) self.url = url self.server = parsed_url.hostname.lower() - self.has_ssl = parsed_url.scheme == 'https' + self.has_ssl = parsed_url.scheme == "https" super().__init__(str(self)) def __str__(self): - return f'We were redirected to {self.url}' + return f"We were redirected to {self.url}" class RelativeRedirect(TransportError): @@ -175,403 +176,1548 @@

                                Module exchangelib.errors

                                super().__init__(str(self)) def __str__(self): - return f'CAS error: {self.cas_error}' + return f"CAS error: {self.cas_error}" # Somewhat-authoritative list of possible response message error types from EWS. See full list at # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/responsecode # -class ErrorAccessDenied(ResponseMessageError): pass -class ErrorAccessModeSpecified(ResponseMessageError): pass -class ErrorAccountDisabled(ResponseMessageError): pass -class ErrorAddDelegatesFailed(ResponseMessageError): pass -class ErrorAddressSpaceNotFound(ResponseMessageError): pass -class ErrorADOperation(ResponseMessageError): pass -class ErrorADSessionFilter(ResponseMessageError): pass -class ErrorADUnavailable(ResponseMessageError): pass -class ErrorAffectedTaskOccurrencesRequired(ResponseMessageError): pass -class ErrorApplyConversationActionFailed(ResponseMessageError): pass -class ErrorAttachmentSizeLimitExceeded(ResponseMessageError): pass -class ErrorAutoDiscoverFailed(ResponseMessageError): pass -class ErrorAvailabilityConfigNotFound(ResponseMessageError): pass -class ErrorBatchProcessingStopped(ResponseMessageError): pass -class ErrorCalendarCannotMoveOrCopyOccurrence(ResponseMessageError): pass -class ErrorCalendarCannotUpdateDeletedItem(ResponseMessageError): pass -class ErrorCalendarCannotUseIdForOccurrenceId(ResponseMessageError): pass -class ErrorCalendarCannotUseIdForRecurringMasterId(ResponseMessageError): pass -class ErrorCalendarDurationIsTooLong(ResponseMessageError): pass -class ErrorCalendarEndDateIsEarlierThanStartDate(ResponseMessageError): pass -class ErrorCalendarFolderIsInvalidForCalendarView(ResponseMessageError): pass -class ErrorCalendarInvalidAttributeValue(ResponseMessageError): pass -class ErrorCalendarInvalidDayForTimeChangePattern(ResponseMessageError): pass -class ErrorCalendarInvalidDayForWeeklyRecurrence(ResponseMessageError): pass -class ErrorCalendarInvalidPropertyState(ResponseMessageError): pass -class ErrorCalendarInvalidPropertyValue(ResponseMessageError): pass -class ErrorCalendarInvalidRecurrence(ResponseMessageError): pass -class ErrorCalendarInvalidTimeZone(ResponseMessageError): pass -class ErrorCalendarIsCancelledForAccept(ResponseMessageError): pass -class ErrorCalendarIsCancelledForDecline(ResponseMessageError): pass -class ErrorCalendarIsCancelledForRemove(ResponseMessageError): pass -class ErrorCalendarIsCancelledForTentative(ResponseMessageError): pass -class ErrorCalendarIsDelegatedForAccept(ResponseMessageError): pass -class ErrorCalendarIsDelegatedForDecline(ResponseMessageError): pass -class ErrorCalendarIsDelegatedForRemove(ResponseMessageError): pass -class ErrorCalendarIsDelegatedForTentative(ResponseMessageError): pass -class ErrorCalendarIsNotOrganizer(ResponseMessageError): pass -class ErrorCalendarIsOrganizerForAccept(ResponseMessageError): pass -class ErrorCalendarIsOrganizerForDecline(ResponseMessageError): pass -class ErrorCalendarIsOrganizerForRemove(ResponseMessageError): pass -class ErrorCalendarIsOrganizerForTentative(ResponseMessageError): pass -class ErrorCalendarMeetingRequestIsOutOfDate(ResponseMessageError): pass -class ErrorCalendarOccurrenceIndexIsOutOfRecurrenceRange(ResponseMessageError): pass -class ErrorCalendarOccurrenceIsDeletedFromRecurrence(ResponseMessageError): pass -class ErrorCalendarOutOfRange(ResponseMessageError): pass -class ErrorCalendarViewRangeTooBig(ResponseMessageError): pass -class ErrorCallerIsInvalidADAccount(ResponseMessageError): pass -class ErrorCannotCreateCalendarItemInNonCalendarFolder(ResponseMessageError): pass -class ErrorCannotCreateContactInNonContactFolder(ResponseMessageError): pass -class ErrorCannotCreatePostItemInNonMailFolder(ResponseMessageError): pass -class ErrorCannotCreateTaskInNonTaskFolder(ResponseMessageError): pass -class ErrorCannotDeleteObject(ResponseMessageError): pass -class ErrorCannotDeleteTaskOccurrence(ResponseMessageError): pass -class ErrorCannotEmptyFolder(ResponseMessageError): pass -class ErrorCannotOpenFileAttachment(ResponseMessageError): pass -class ErrorCannotSetCalendarPermissionOnNonCalendarFolder(ResponseMessageError): pass -class ErrorCannotSetNonCalendarPermissionOnCalendarFolder(ResponseMessageError): pass -class ErrorCannotSetPermissionUnknownEntries(ResponseMessageError): pass -class ErrorCannotUseFolderIdForItemId(ResponseMessageError): pass -class ErrorCannotUseItemIdForFolderId(ResponseMessageError): pass -class ErrorChangeKeyRequired(ResponseMessageError): pass -class ErrorChangeKeyRequiredForWriteOperations(ResponseMessageError): pass -class ErrorClientDisconnected(ResponseMessageError): pass -class ErrorConnectionFailed(ResponseMessageError): pass -class ErrorConnectionFailedTransientError(ResponseMessageError): pass -class ErrorContainsFilterWrongType(ResponseMessageError): pass -class ErrorContentConversionFailed(ResponseMessageError): pass -class ErrorCorruptData(ResponseMessageError): pass -class ErrorCreateItemAccessDenied(ResponseMessageError): pass -class ErrorCreateManagedFolderPartialCompletion(ResponseMessageError): pass -class ErrorCreateSubfolderAccessDenied(ResponseMessageError): pass -class ErrorCrossMailboxMoveCopy(ResponseMessageError): pass -class ErrorCrossSiteRequest(ResponseMessageError): pass -class ErrorDataSizeLimitExceeded(ResponseMessageError): pass -class ErrorDataSourceOperation(ResponseMessageError): pass -class ErrorDelegateAlreadyExists(ResponseMessageError): pass -class ErrorDelegateCannotAddOwner(ResponseMessageError): pass -class ErrorDelegateMissingConfiguration(ResponseMessageError): pass -class ErrorDelegateNoUser(ResponseMessageError): pass -class ErrorDelegateValidationFailed(ResponseMessageError): pass -class ErrorDeleteDistinguishedFolder(ResponseMessageError): pass -class ErrorDeleteItemsFailed(ResponseMessageError): pass -class ErrorDistinguishedUserNotSupported(ResponseMessageError): pass -class ErrorDistributionListMemberNotExist(ResponseMessageError): pass -class ErrorDuplicateInputFolderNames(ResponseMessageError): pass -class ErrorDuplicateSOAPHeader(ResponseMessageError): pass -class ErrorDuplicateUserIdsSpecified(ResponseMessageError): pass -class ErrorEmailAddressMismatch(ResponseMessageError): pass -class ErrorEventNotFound(ResponseMessageError): pass -class ErrorExceededConnectionCount(ResponseMessageError): pass -class ErrorExceededFindCountLimit(ResponseMessageError): pass -class ErrorExceededSubscriptionCount(ResponseMessageError): pass -class ErrorExpiredSubscription(ResponseMessageError): pass -class ErrorFolderCorrupt(ResponseMessageError): pass -class ErrorFolderExists(ResponseMessageError): pass -class ErrorFolderNotFound(ResponseMessageError): pass -class ErrorFolderPropertyRequestFailed(ResponseMessageError): pass -class ErrorFolderSave(ResponseMessageError): pass -class ErrorFolderSaveFailed(ResponseMessageError): pass -class ErrorFolderSavePropertyError(ResponseMessageError): pass -class ErrorFreeBusyDLLimitReached(ResponseMessageError): pass -class ErrorFreeBusyGenerationFailed(ResponseMessageError): pass -class ErrorGetServerSecurityDescriptorFailed(ResponseMessageError): pass -class ErrorImpersonateUserDenied(ResponseMessageError): pass -class ErrorImpersonationDenied(ResponseMessageError): pass -class ErrorImpersonationFailed(ResponseMessageError): pass -class ErrorInboxRulesValidationError(ResponseMessageError): pass -class ErrorIncorrectSchemaVersion(ResponseMessageError): pass -class ErrorIncorrectUpdatePropertyCount(ResponseMessageError): pass -class ErrorIndividualMailboxLimitReached(ResponseMessageError): pass -class ErrorInsufficientResources(ResponseMessageError): pass -class ErrorInternalServerError(ResponseMessageError): pass -class ErrorInternalServerTransientError(ResponseMessageError): pass -class ErrorInvalidAccessLevel(ResponseMessageError): pass -class ErrorInvalidArgument(ResponseMessageError): pass -class ErrorInvalidAttachmentId(ResponseMessageError): pass -class ErrorInvalidAttachmentSubfilter(ResponseMessageError): pass -class ErrorInvalidAttachmentSubfilterTextFilter(ResponseMessageError): pass -class ErrorInvalidAuthorizationContext(ResponseMessageError): pass -class ErrorInvalidChangeKey(ResponseMessageError): pass -class ErrorInvalidClientSecurityContext(ResponseMessageError): pass -class ErrorInvalidCompleteDate(ResponseMessageError): pass -class ErrorInvalidContactEmailAddress(ResponseMessageError): pass -class ErrorInvalidContactEmailIndex(ResponseMessageError): pass -class ErrorInvalidCrossForestCredentials(ResponseMessageError): pass -class ErrorInvalidDelegatePermission(ResponseMessageError): pass -class ErrorInvalidDelegateUserId(ResponseMessageError): pass -class ErrorInvalidExchangeImpersonationHeaderData(ResponseMessageError): pass -class ErrorInvalidExcludesRestriction(ResponseMessageError): pass -class ErrorInvalidExpressionTypeForSubFilter(ResponseMessageError): pass -class ErrorInvalidExtendedProperty(ResponseMessageError): pass -class ErrorInvalidExtendedPropertyValue(ResponseMessageError): pass -class ErrorInvalidExternalSharingInitiator(ResponseMessageError): pass -class ErrorInvalidExternalSharingSubscriber(ResponseMessageError): pass -class ErrorInvalidFederatedOrganizationId(ResponseMessageError): pass -class ErrorInvalidFolderId(ResponseMessageError): pass -class ErrorInvalidFolderTypeForOperation(ResponseMessageError): pass -class ErrorInvalidFractionalPagingParameters(ResponseMessageError): pass -class ErrorInvalidFreeBusyViewType(ResponseMessageError): pass -class ErrorInvalidGetSharingFolderRequest(ResponseMessageError): pass -class ErrorInvalidId(ResponseMessageError): pass -class ErrorInvalidIdEmpty(ResponseMessageError): pass -class ErrorInvalidIdMalformed(ResponseMessageError): pass -class ErrorInvalidIdMalformedEwsLegacyIdFormat(ResponseMessageError): pass -class ErrorInvalidIdMonikerTooLong(ResponseMessageError): pass -class ErrorInvalidIdNotAnItemAttachmentId(ResponseMessageError): pass -class ErrorInvalidIdReturnedByResolveNames(ResponseMessageError): pass -class ErrorInvalidIdStoreObjectIdTooLong(ResponseMessageError): pass -class ErrorInvalidIdTooManyAttachmentLevels(ResponseMessageError): pass -class ErrorInvalidIdXml(ResponseMessageError): pass -class ErrorInvalidIndexedPagingParameters(ResponseMessageError): pass -class ErrorInvalidInternetHeaderChildNodes(ResponseMessageError): pass -class ErrorInvalidItemForOperationAcceptItem(ResponseMessageError): pass -class ErrorInvalidItemForOperationCancelItem(ResponseMessageError): pass -class ErrorInvalidItemForOperationCreateItem(ResponseMessageError): pass -class ErrorInvalidItemForOperationCreateItemAttachment(ResponseMessageError): pass -class ErrorInvalidItemForOperationDeclineItem(ResponseMessageError): pass -class ErrorInvalidItemForOperationExpandDL(ResponseMessageError): pass -class ErrorInvalidItemForOperationRemoveItem(ResponseMessageError): pass -class ErrorInvalidItemForOperationSendItem(ResponseMessageError): pass -class ErrorInvalidItemForOperationTentative(ResponseMessageError): pass -class ErrorInvalidLicense(ResponseMessageError): pass -class ErrorInvalidLogonType(ResponseMessageError): pass -class ErrorInvalidMailbox(ResponseMessageError): pass -class ErrorInvalidManagedFolderProperty(ResponseMessageError): pass -class ErrorInvalidManagedFolderQuota(ResponseMessageError): pass -class ErrorInvalidManagedFolderSize(ResponseMessageError): pass -class ErrorInvalidMergedFreeBusyInterval(ResponseMessageError): pass -class ErrorInvalidNameForNameResolution(ResponseMessageError): pass -class ErrorInvalidNetworkServiceContext(ResponseMessageError): pass -class ErrorInvalidOofParameter(ResponseMessageError): pass -class ErrorInvalidOperation(ResponseMessageError): pass -class ErrorInvalidOrganizationRelationshipForFreeBusy(ResponseMessageError): pass -class ErrorInvalidPagingMaxRows(ResponseMessageError): pass -class ErrorInvalidParentFolder(ResponseMessageError): pass -class ErrorInvalidPercentCompleteValue(ResponseMessageError): pass -class ErrorInvalidPermissionSettings(ResponseMessageError): pass -class ErrorInvalidPhoneCallId(ResponseMessageError): pass -class ErrorInvalidPhoneNumber(ResponseMessageError): pass -class ErrorInvalidPropertyAppend(ResponseMessageError): pass -class ErrorInvalidPropertyDelete(ResponseMessageError): pass -class ErrorInvalidPropertyForExists(ResponseMessageError): pass -class ErrorInvalidPropertyForOperation(ResponseMessageError): pass -class ErrorInvalidPropertyRequest(ResponseMessageError): pass -class ErrorInvalidPropertySet(ResponseMessageError): pass -class ErrorInvalidPropertyUpdateSentMessage(ResponseMessageError): pass -class ErrorInvalidProxySecurityContext(ResponseMessageError): pass -class ErrorInvalidPullSubscriptionId(ResponseMessageError): pass -class ErrorInvalidPushSubscriptionUrl(ResponseMessageError): pass -class ErrorInvalidRecipients(ResponseMessageError): pass -class ErrorInvalidRecipientSubfilter(ResponseMessageError): pass -class ErrorInvalidRecipientSubfilterComparison(ResponseMessageError): pass -class ErrorInvalidRecipientSubfilterOrder(ResponseMessageError): pass -class ErrorInvalidRecipientSubfilterTextFilter(ResponseMessageError): pass -class ErrorInvalidReferenceItem(ResponseMessageError): pass -class ErrorInvalidRequest(ResponseMessageError): pass -class ErrorInvalidRestriction(ResponseMessageError): pass -class ErrorInvalidRoutingType(ResponseMessageError): pass -class ErrorInvalidScheduledOofDuration(ResponseMessageError): pass -class ErrorInvalidSchemaVersionForMailboxVersion(ResponseMessageError): pass -class ErrorInvalidSecurityDescriptor(ResponseMessageError): pass -class ErrorInvalidSendItemSaveSettings(ResponseMessageError): pass -class ErrorInvalidSerializedAccessToken(ResponseMessageError): pass -class ErrorInvalidServerVersion(ResponseMessageError): pass -class ErrorInvalidSharingData(ResponseMessageError): pass -class ErrorInvalidSharingMessage(ResponseMessageError): pass -class ErrorInvalidSid(ResponseMessageError): pass -class ErrorInvalidSIPUri(ResponseMessageError): pass -class ErrorInvalidSmtpAddress(ResponseMessageError): pass -class ErrorInvalidSubfilterType(ResponseMessageError): pass -class ErrorInvalidSubfilterTypeNotAttendeeType(ResponseMessageError): pass -class ErrorInvalidSubfilterTypeNotRecipientType(ResponseMessageError): pass -class ErrorInvalidSubscription(ResponseMessageError): pass -class ErrorInvalidSubscriptionRequest(ResponseMessageError): pass -class ErrorInvalidSyncStateData(ResponseMessageError): pass -class ErrorInvalidTimeInterval(ResponseMessageError): pass -class ErrorInvalidUserInfo(ResponseMessageError): pass -class ErrorInvalidUserOofSettings(ResponseMessageError): pass -class ErrorInvalidUserPrincipalName(ResponseMessageError): pass -class ErrorInvalidUserSid(ResponseMessageError): pass -class ErrorInvalidUserSidMissingUPN(ResponseMessageError): pass -class ErrorInvalidValueForProperty(ResponseMessageError): pass -class ErrorInvalidWatermark(ResponseMessageError): pass -class ErrorIPGatewayNotFound(ResponseMessageError): pass -class ErrorIrresolvableConflict(ResponseMessageError): pass -class ErrorItemCorrupt(ResponseMessageError): pass -class ErrorItemNotFound(ResponseMessageError): pass -class ErrorItemPropertyRequestFailed(ResponseMessageError): pass -class ErrorItemSave(ResponseMessageError): pass -class ErrorItemSavePropertyError(ResponseMessageError): pass -class ErrorLegacyMailboxFreeBusyViewTypeNotMerged(ResponseMessageError): pass -class ErrorLocalServerObjectNotFound(ResponseMessageError): pass -class ErrorLogonAsNetworkServiceFailed(ResponseMessageError): pass -class ErrorMailboxConfiguration(ResponseMessageError): pass -class ErrorMailboxDataArrayEmpty(ResponseMessageError): pass -class ErrorMailboxDataArrayTooBig(ResponseMessageError): pass -class ErrorMailboxFailover(ResponseMessageError): pass -class ErrorMailboxLogonFailed(ResponseMessageError): pass -class ErrorMailboxMoveInProgress(ResponseMessageError): pass -class ErrorMailboxStoreUnavailable(ResponseMessageError): pass -class ErrorMailRecipientNotFound(ResponseMessageError): pass -class ErrorMailTipsDisabled(ResponseMessageError): pass -class ErrorManagedFolderAlreadyExists(ResponseMessageError): pass -class ErrorManagedFolderNotFound(ResponseMessageError): pass -class ErrorManagedFoldersRootFailure(ResponseMessageError): pass -class ErrorMeetingSuggestionGenerationFailed(ResponseMessageError): pass -class ErrorMessageDispositionRequired(ResponseMessageError): pass -class ErrorMessageSizeExceeded(ResponseMessageError): pass -class ErrorMessageTrackingNoSuchDomain(ResponseMessageError): pass -class ErrorMessageTrackingPermanentError(ResponseMessageError): pass -class ErrorMessageTrackingTransientError(ResponseMessageError): pass -class ErrorMimeContentConversionFailed(ResponseMessageError): pass -class ErrorMimeContentInvalid(ResponseMessageError): pass -class ErrorMimeContentInvalidBase64String(ResponseMessageError): pass -class ErrorMissedNotificationEvents(ResponseMessageError): pass -class ErrorMissingArgument(ResponseMessageError): pass -class ErrorMissingEmailAddress(ResponseMessageError): pass -class ErrorMissingEmailAddressForManagedFolder(ResponseMessageError): pass -class ErrorMissingInformationEmailAddress(ResponseMessageError): pass -class ErrorMissingInformationReferenceItemId(ResponseMessageError): pass -class ErrorMissingInformationSharingFolderId(ResponseMessageError): pass -class ErrorMissingItemForCreateItemAttachment(ResponseMessageError): pass -class ErrorMissingManagedFolderId(ResponseMessageError): pass -class ErrorMissingRecipients(ResponseMessageError): pass -class ErrorMissingUserIdInformation(ResponseMessageError): pass -class ErrorMoreThanOneAccessModeSpecified(ResponseMessageError): pass -class ErrorMoveCopyFailed(ResponseMessageError): pass -class ErrorMoveDistinguishedFolder(ResponseMessageError): pass -class ErrorNameResolutionMultipleResults(ResponseMessageError): pass -class ErrorNameResolutionNoMailbox(ResponseMessageError): pass -class ErrorNameResolutionNoResults(ResponseMessageError): pass -class ErrorNewEventStreamConnectionOpened(ResponseMessageError): pass -class ErrorNoApplicableProxyCASServersAvailable(ResponseMessageError): pass -class ErrorNoCalendar(ResponseMessageError): pass -class ErrorNoDestinationCASDueToKerberosRequirements(ResponseMessageError): pass -class ErrorNoDestinationCASDueToSSLRequirements(ResponseMessageError): pass -class ErrorNoDestinationCASDueToVersionMismatch(ResponseMessageError): pass -class ErrorNoFolderClassOverride(ResponseMessageError): pass -class ErrorNoFreeBusyAccess(ResponseMessageError): pass -class ErrorNonExistentMailbox(ResponseMessageError): pass -class ErrorNonPrimarySmtpAddress(ResponseMessageError): pass -class ErrorNoPropertyTagForCustomProperties(ResponseMessageError): pass -class ErrorNoPublicFolderReplicaAvailable(ResponseMessageError): pass -class ErrorNoPublicFolderServerAvailable(ResponseMessageError): pass -class ErrorNoRespondingCASInDestinationSite(ResponseMessageError): pass -class ErrorNotAllowedExternalSharingByPolicy(ResponseMessageError): pass -class ErrorNotDelegate(ResponseMessageError): pass -class ErrorNotEnoughMemory(ResponseMessageError): pass -class ErrorNotSupportedSharingMessage(ResponseMessageError): pass -class ErrorObjectTypeChanged(ResponseMessageError): pass -class ErrorOccurrenceCrossingBoundary(ResponseMessageError): pass -class ErrorOccurrenceTimeSpanTooBig(ResponseMessageError): pass -class ErrorOperationNotAllowedWithPublicFolderRoot(ResponseMessageError): pass -class ErrorOrganizationNotFederated(ResponseMessageError): pass -class ErrorOutlookRuleBlobExists(ResponseMessageError): pass -class ErrorParentFolderIdRequired(ResponseMessageError): pass -class ErrorParentFolderNotFound(ResponseMessageError): pass -class ErrorPasswordChangeRequired(ResponseMessageError): pass -class ErrorPasswordExpired(ResponseMessageError): pass -class ErrorPermissionNotAllowedByPolicy(ResponseMessageError): pass -class ErrorPhoneNumberNotDialable(ResponseMessageError): pass -class ErrorPropertyUpdate(ResponseMessageError): pass -class ErrorPropertyValidationFailure(ResponseMessageError): pass -class ErrorProxiedSubscriptionCallFailure(ResponseMessageError): pass -class ErrorProxyCallFailed(ResponseMessageError): pass -class ErrorProxyGroupSidLimitExceeded(ResponseMessageError): pass -class ErrorProxyRequestNotAllowed(ResponseMessageError): pass -class ErrorProxyRequestProcessingFailed(ResponseMessageError): pass -class ErrorProxyServiceDiscoveryFailed(ResponseMessageError): pass -class ErrorProxyTokenExpired(ResponseMessageError): pass -class ErrorPublicFolderRequestProcessingFailed(ResponseMessageError): pass -class ErrorPublicFolderServerNotFound(ResponseMessageError): pass -class ErrorQueryFilterTooLong(ResponseMessageError): pass -class ErrorQuotaExceeded(ResponseMessageError): pass -class ErrorReadEventsFailed(ResponseMessageError): pass -class ErrorReadReceiptNotPending(ResponseMessageError): pass -class ErrorRecurrenceEndDateTooBig(ResponseMessageError): pass -class ErrorRecurrenceHasNoOccurrence(ResponseMessageError): pass -class ErrorRemoveDelegatesFailed(ResponseMessageError): pass -class ErrorRequestAborted(ResponseMessageError): pass -class ErrorRequestStreamTooBig(ResponseMessageError): pass -class ErrorRequiredPropertyMissing(ResponseMessageError): pass -class ErrorResolveNamesInvalidFolderType(ResponseMessageError): pass -class ErrorResolveNamesOnlyOneContactsFolderAllowed(ResponseMessageError): pass -class ErrorResponseSchemaValidation(ResponseMessageError): pass -class ErrorRestrictionTooComplex(ResponseMessageError): pass -class ErrorRestrictionTooLong(ResponseMessageError): pass -class ErrorResultSetTooBig(ResponseMessageError): pass -class ErrorRulesOverQuota(ResponseMessageError): pass -class ErrorSavedItemFolderNotFound(ResponseMessageError): pass -class ErrorSchemaValidation(ResponseMessageError): pass -class ErrorSearchFolderNotInitialized(ResponseMessageError): pass -class ErrorSendAsDenied(ResponseMessageError): pass -class ErrorSendMeetingCancellationsRequired(ResponseMessageError): pass -class ErrorSendMeetingInvitationsOrCancellationsRequired(ResponseMessageError): pass -class ErrorSendMeetingInvitationsRequired(ResponseMessageError): pass -class ErrorSentMeetingRequestUpdate(ResponseMessageError): pass -class ErrorSentTaskRequestUpdate(ResponseMessageError): pass +class ErrorAccessDenied(ResponseMessageError): + pass + + +class ErrorAccessModeSpecified(ResponseMessageError): + pass + + +class ErrorAccountDisabled(ResponseMessageError): + pass + + +class ErrorAddDelegatesFailed(ResponseMessageError): + pass + + +class ErrorAddressSpaceNotFound(ResponseMessageError): + pass + + +class ErrorADOperation(ResponseMessageError): + pass + + +class ErrorADSessionFilter(ResponseMessageError): + pass + + +class ErrorADUnavailable(ResponseMessageError): + pass + + +class ErrorAffectedTaskOccurrencesRequired(ResponseMessageError): + pass + + +class ErrorApplyConversationActionFailed(ResponseMessageError): + pass + + +class ErrorAttachmentSizeLimitExceeded(ResponseMessageError): + pass + + +class ErrorAutoDiscoverFailed(ResponseMessageError): + pass + + +class ErrorAvailabilityConfigNotFound(ResponseMessageError): + pass + + +class ErrorBatchProcessingStopped(ResponseMessageError): + pass + + +class ErrorCalendarCannotMoveOrCopyOccurrence(ResponseMessageError): + pass + + +class ErrorCalendarCannotUpdateDeletedItem(ResponseMessageError): + pass + + +class ErrorCalendarCannotUseIdForOccurrenceId(ResponseMessageError): + pass + + +class ErrorCalendarCannotUseIdForRecurringMasterId(ResponseMessageError): + pass + + +class ErrorCalendarDurationIsTooLong(ResponseMessageError): + pass + + +class ErrorCalendarEndDateIsEarlierThanStartDate(ResponseMessageError): + pass + + +class ErrorCalendarFolderIsInvalidForCalendarView(ResponseMessageError): + pass + + +class ErrorCalendarInvalidAttributeValue(ResponseMessageError): + pass + + +class ErrorCalendarInvalidDayForTimeChangePattern(ResponseMessageError): + pass + + +class ErrorCalendarInvalidDayForWeeklyRecurrence(ResponseMessageError): + pass + + +class ErrorCalendarInvalidPropertyState(ResponseMessageError): + pass + + +class ErrorCalendarInvalidPropertyValue(ResponseMessageError): + pass + + +class ErrorCalendarInvalidRecurrence(ResponseMessageError): + pass + + +class ErrorCalendarInvalidTimeZone(ResponseMessageError): + pass + + +class ErrorCalendarIsCancelledForAccept(ResponseMessageError): + pass + + +class ErrorCalendarIsCancelledForDecline(ResponseMessageError): + pass + + +class ErrorCalendarIsCancelledForRemove(ResponseMessageError): + pass + + +class ErrorCalendarIsCancelledForTentative(ResponseMessageError): + pass + + +class ErrorCalendarIsDelegatedForAccept(ResponseMessageError): + pass + + +class ErrorCalendarIsDelegatedForDecline(ResponseMessageError): + pass + + +class ErrorCalendarIsDelegatedForRemove(ResponseMessageError): + pass + + +class ErrorCalendarIsDelegatedForTentative(ResponseMessageError): + pass + + +class ErrorCalendarIsNotOrganizer(ResponseMessageError): + pass + + +class ErrorCalendarIsOrganizerForAccept(ResponseMessageError): + pass + + +class ErrorCalendarIsOrganizerForDecline(ResponseMessageError): + pass + + +class ErrorCalendarIsOrganizerForRemove(ResponseMessageError): + pass + + +class ErrorCalendarIsOrganizerForTentative(ResponseMessageError): + pass + + +class ErrorCalendarMeetingRequestIsOutOfDate(ResponseMessageError): + pass + + +class ErrorCalendarOccurrenceIndexIsOutOfRecurrenceRange(ResponseMessageError): + pass + + +class ErrorCalendarOccurrenceIsDeletedFromRecurrence(ResponseMessageError): + pass + + +class ErrorCalendarOutOfRange(ResponseMessageError): + pass + + +class ErrorCalendarViewRangeTooBig(ResponseMessageError): + pass + + +class ErrorCallerIsInvalidADAccount(ResponseMessageError): + pass + + +class ErrorCannotCreateCalendarItemInNonCalendarFolder(ResponseMessageError): + pass + + +class ErrorCannotCreateContactInNonContactFolder(ResponseMessageError): + pass + + +class ErrorCannotCreatePostItemInNonMailFolder(ResponseMessageError): + pass + + +class ErrorCannotCreateTaskInNonTaskFolder(ResponseMessageError): + pass + + +class ErrorCannotDeleteObject(ResponseMessageError): + pass + + +class ErrorCannotDeleteTaskOccurrence(ResponseMessageError): + pass + + +class ErrorCannotEmptyFolder(ResponseMessageError): + pass + + +class ErrorCannotOpenFileAttachment(ResponseMessageError): + pass + + +class ErrorCannotSetCalendarPermissionOnNonCalendarFolder(ResponseMessageError): + pass + + +class ErrorCannotSetNonCalendarPermissionOnCalendarFolder(ResponseMessageError): + pass + + +class ErrorCannotSetPermissionUnknownEntries(ResponseMessageError): + pass + + +class ErrorCannotUseFolderIdForItemId(ResponseMessageError): + pass + + +class ErrorCannotUseItemIdForFolderId(ResponseMessageError): + pass + + +class ErrorChangeKeyRequired(ResponseMessageError): + pass + + +class ErrorChangeKeyRequiredForWriteOperations(ResponseMessageError): + pass + + +class ErrorClientDisconnected(ResponseMessageError): + pass + + +class ErrorConnectionFailed(ResponseMessageError): + pass + + +class ErrorConnectionFailedTransientError(ResponseMessageError): + pass + + +class ErrorContainsFilterWrongType(ResponseMessageError): + pass + + +class ErrorContentConversionFailed(ResponseMessageError): + pass + + +class ErrorCorruptData(ResponseMessageError): + pass + + +class ErrorCreateItemAccessDenied(ResponseMessageError): + pass + + +class ErrorCreateManagedFolderPartialCompletion(ResponseMessageError): + pass + + +class ErrorCreateSubfolderAccessDenied(ResponseMessageError): + pass + + +class ErrorCrossMailboxMoveCopy(ResponseMessageError): + pass + + +class ErrorCrossSiteRequest(ResponseMessageError): + pass + + +class ErrorDataSizeLimitExceeded(ResponseMessageError): + pass + + +class ErrorDataSourceOperation(ResponseMessageError): + pass + + +class ErrorDelegateAlreadyExists(ResponseMessageError): + pass + + +class ErrorDelegateCannotAddOwner(ResponseMessageError): + pass + + +class ErrorDelegateMissingConfiguration(ResponseMessageError): + pass + + +class ErrorDelegateNoUser(ResponseMessageError): + pass + + +class ErrorDelegateValidationFailed(ResponseMessageError): + pass + + +class ErrorDeleteDistinguishedFolder(ResponseMessageError): + pass + + +class ErrorDeleteItemsFailed(ResponseMessageError): + pass + + +class ErrorDistinguishedUserNotSupported(ResponseMessageError): + pass + + +class ErrorDistributionListMemberNotExist(ResponseMessageError): + pass + + +class ErrorDuplicateInputFolderNames(ResponseMessageError): + pass + + +class ErrorDuplicateSOAPHeader(ResponseMessageError): + pass + + +class ErrorDuplicateUserIdsSpecified(ResponseMessageError): + pass + + +class ErrorEmailAddressMismatch(ResponseMessageError): + pass + + +class ErrorEventNotFound(ResponseMessageError): + pass + + +class ErrorExceededConnectionCount(ResponseMessageError): + pass + + +class ErrorExceededFindCountLimit(ResponseMessageError): + pass + + +class ErrorExceededSubscriptionCount(ResponseMessageError): + pass + + +class ErrorExpiredSubscription(ResponseMessageError): + pass + + +class ErrorFolderCorrupt(ResponseMessageError): + pass + + +class ErrorFolderExists(ResponseMessageError): + pass + + +class ErrorFolderNotFound(ResponseMessageError): + pass + + +class ErrorFolderPropertyRequestFailed(ResponseMessageError): + pass + + +class ErrorFolderSave(ResponseMessageError): + pass + + +class ErrorFolderSaveFailed(ResponseMessageError): + pass + + +class ErrorFolderSavePropertyError(ResponseMessageError): + pass + + +class ErrorFreeBusyDLLimitReached(ResponseMessageError): + pass + + +class ErrorFreeBusyGenerationFailed(ResponseMessageError): + pass + + +class ErrorGetServerSecurityDescriptorFailed(ResponseMessageError): + pass + + +class ErrorImpersonateUserDenied(ResponseMessageError): + pass + + +class ErrorImpersonationDenied(ResponseMessageError): + pass + + +class ErrorImpersonationFailed(ResponseMessageError): + pass + + +class ErrorInboxRulesValidationError(ResponseMessageError): + pass + + +class ErrorIncorrectSchemaVersion(ResponseMessageError): + pass + + +class ErrorIncorrectUpdatePropertyCount(ResponseMessageError): + pass + + +class ErrorIndividualMailboxLimitReached(ResponseMessageError): + pass + + +class ErrorInsufficientResources(ResponseMessageError): + pass + + +class ErrorInternalServerError(ResponseMessageError): + pass + + +class ErrorInternalServerTransientError(ResponseMessageError): + pass + + +class ErrorInvalidAccessLevel(ResponseMessageError): + pass + + +class ErrorInvalidArgument(ResponseMessageError): + pass + + +class ErrorInvalidAttachmentId(ResponseMessageError): + pass + + +class ErrorInvalidAttachmentSubfilter(ResponseMessageError): + pass + + +class ErrorInvalidAttachmentSubfilterTextFilter(ResponseMessageError): + pass + + +class ErrorInvalidAuthorizationContext(ResponseMessageError): + pass + + +class ErrorInvalidChangeKey(ResponseMessageError): + pass + + +class ErrorInvalidClientSecurityContext(ResponseMessageError): + pass + + +class ErrorInvalidCompleteDate(ResponseMessageError): + pass + + +class ErrorInvalidContactEmailAddress(ResponseMessageError): + pass + + +class ErrorInvalidContactEmailIndex(ResponseMessageError): + pass + + +class ErrorInvalidCrossForestCredentials(ResponseMessageError): + pass + + +class ErrorInvalidDelegatePermission(ResponseMessageError): + pass + + +class ErrorInvalidDelegateUserId(ResponseMessageError): + pass + + +class ErrorInvalidExchangeImpersonationHeaderData(ResponseMessageError): + pass + + +class ErrorInvalidExcludesRestriction(ResponseMessageError): + pass + + +class ErrorInvalidExpressionTypeForSubFilter(ResponseMessageError): + pass + + +class ErrorInvalidExtendedProperty(ResponseMessageError): + pass + + +class ErrorInvalidExtendedPropertyValue(ResponseMessageError): + pass + + +class ErrorInvalidExternalSharingInitiator(ResponseMessageError): + pass + + +class ErrorInvalidExternalSharingSubscriber(ResponseMessageError): + pass + + +class ErrorInvalidFederatedOrganizationId(ResponseMessageError): + pass + + +class ErrorInvalidFolderId(ResponseMessageError): + pass + + +class ErrorInvalidFolderTypeForOperation(ResponseMessageError): + pass + + +class ErrorInvalidFractionalPagingParameters(ResponseMessageError): + pass + + +class ErrorInvalidFreeBusyViewType(ResponseMessageError): + pass + + +class ErrorInvalidGetSharingFolderRequest(ResponseMessageError): + pass + + +class ErrorInvalidId(ResponseMessageError): + pass + + +class ErrorInvalidIdEmpty(ResponseMessageError): + pass + + +class ErrorInvalidIdMalformed(ResponseMessageError): + pass + + +class ErrorInvalidIdMalformedEwsLegacyIdFormat(ResponseMessageError): + pass + + +class ErrorInvalidIdMonikerTooLong(ResponseMessageError): + pass + + +class ErrorInvalidIdNotAnItemAttachmentId(ResponseMessageError): + pass + + +class ErrorInvalidIdReturnedByResolveNames(ResponseMessageError): + pass + + +class ErrorInvalidIdStoreObjectIdTooLong(ResponseMessageError): + pass + + +class ErrorInvalidIdTooManyAttachmentLevels(ResponseMessageError): + pass + + +class ErrorInvalidIdXml(ResponseMessageError): + pass + + +class ErrorInvalidIndexedPagingParameters(ResponseMessageError): + pass + + +class ErrorInvalidInternetHeaderChildNodes(ResponseMessageError): + pass + + +class ErrorInvalidItemForOperationAcceptItem(ResponseMessageError): + pass + + +class ErrorInvalidItemForOperationCancelItem(ResponseMessageError): + pass + + +class ErrorInvalidItemForOperationCreateItem(ResponseMessageError): + pass + + +class ErrorInvalidItemForOperationCreateItemAttachment(ResponseMessageError): + pass + + +class ErrorInvalidItemForOperationDeclineItem(ResponseMessageError): + pass + + +class ErrorInvalidItemForOperationExpandDL(ResponseMessageError): + pass + + +class ErrorInvalidItemForOperationRemoveItem(ResponseMessageError): + pass + + +class ErrorInvalidItemForOperationSendItem(ResponseMessageError): + pass + + +class ErrorInvalidItemForOperationTentative(ResponseMessageError): + pass + + +class ErrorInvalidLicense(ResponseMessageError): + pass + + +class ErrorInvalidLogonType(ResponseMessageError): + pass + + +class ErrorInvalidMailbox(ResponseMessageError): + pass + + +class ErrorInvalidManagedFolderProperty(ResponseMessageError): + pass + + +class ErrorInvalidManagedFolderQuota(ResponseMessageError): + pass + + +class ErrorInvalidManagedFolderSize(ResponseMessageError): + pass + + +class ErrorInvalidMergedFreeBusyInterval(ResponseMessageError): + pass + + +class ErrorInvalidNameForNameResolution(ResponseMessageError): + pass + + +class ErrorInvalidNetworkServiceContext(ResponseMessageError): + pass + + +class ErrorInvalidOofParameter(ResponseMessageError): + pass + + +class ErrorInvalidOperation(ResponseMessageError): + pass + + +class ErrorInvalidOrganizationRelationshipForFreeBusy(ResponseMessageError): + pass + + +class ErrorInvalidPagingMaxRows(ResponseMessageError): + pass + + +class ErrorInvalidParentFolder(ResponseMessageError): + pass + + +class ErrorInvalidPercentCompleteValue(ResponseMessageError): + pass + + +class ErrorInvalidPermissionSettings(ResponseMessageError): + pass + + +class ErrorInvalidPhoneCallId(ResponseMessageError): + pass + + +class ErrorInvalidPhoneNumber(ResponseMessageError): + pass + + +class ErrorInvalidPropertyAppend(ResponseMessageError): + pass + + +class ErrorInvalidPropertyDelete(ResponseMessageError): + pass + + +class ErrorInvalidPropertyForExists(ResponseMessageError): + pass + + +class ErrorInvalidPropertyForOperation(ResponseMessageError): + pass + + +class ErrorInvalidPropertyRequest(ResponseMessageError): + pass + + +class ErrorInvalidPropertySet(ResponseMessageError): + pass + + +class ErrorInvalidPropertyUpdateSentMessage(ResponseMessageError): + pass + + +class ErrorInvalidProxySecurityContext(ResponseMessageError): + pass + + +class ErrorInvalidPullSubscriptionId(ResponseMessageError): + pass + + +class ErrorInvalidPushSubscriptionUrl(ResponseMessageError): + pass + + +class ErrorInvalidRecipients(ResponseMessageError): + pass + + +class ErrorInvalidRecipientSubfilter(ResponseMessageError): + pass + + +class ErrorInvalidRecipientSubfilterComparison(ResponseMessageError): + pass + + +class ErrorInvalidRecipientSubfilterOrder(ResponseMessageError): + pass + + +class ErrorInvalidRecipientSubfilterTextFilter(ResponseMessageError): + pass + + +class ErrorInvalidReferenceItem(ResponseMessageError): + pass + + +class ErrorInvalidRequest(ResponseMessageError): + pass + + +class ErrorInvalidRestriction(ResponseMessageError): + pass + + +class ErrorInvalidRoutingType(ResponseMessageError): + pass + + +class ErrorInvalidScheduledOofDuration(ResponseMessageError): + pass + + +class ErrorInvalidSchemaVersionForMailboxVersion(ResponseMessageError): + pass + + +class ErrorInvalidSecurityDescriptor(ResponseMessageError): + pass + + +class ErrorInvalidSendItemSaveSettings(ResponseMessageError): + pass + + +class ErrorInvalidSerializedAccessToken(ResponseMessageError): + pass + + +class ErrorInvalidServerVersion(ResponseMessageError): + pass + + +class ErrorInvalidSharingData(ResponseMessageError): + pass + + +class ErrorInvalidSharingMessage(ResponseMessageError): + pass + + +class ErrorInvalidSid(ResponseMessageError): + pass + + +class ErrorInvalidSIPUri(ResponseMessageError): + pass + + +class ErrorInvalidSmtpAddress(ResponseMessageError): + pass + + +class ErrorInvalidSubfilterType(ResponseMessageError): + pass + + +class ErrorInvalidSubfilterTypeNotAttendeeType(ResponseMessageError): + pass + + +class ErrorInvalidSubfilterTypeNotRecipientType(ResponseMessageError): + pass + + +class ErrorInvalidSubscription(ResponseMessageError): + pass + + +class ErrorInvalidSubscriptionRequest(ResponseMessageError): + pass + + +class ErrorInvalidSyncStateData(ResponseMessageError): + pass + + +class ErrorInvalidTimeInterval(ResponseMessageError): + pass + + +class ErrorInvalidUserInfo(ResponseMessageError): + pass + + +class ErrorInvalidUserOofSettings(ResponseMessageError): + pass + + +class ErrorInvalidUserPrincipalName(ResponseMessageError): + pass + + +class ErrorInvalidUserSid(ResponseMessageError): + pass + + +class ErrorInvalidUserSidMissingUPN(ResponseMessageError): + pass + + +class ErrorInvalidValueForProperty(ResponseMessageError): + pass + + +class ErrorInvalidWatermark(ResponseMessageError): + pass + + +class ErrorIPGatewayNotFound(ResponseMessageError): + pass + + +class ErrorIrresolvableConflict(ResponseMessageError): + pass + + +class ErrorItemCorrupt(ResponseMessageError): + pass + + +class ErrorItemNotFound(ResponseMessageError): + pass + + +class ErrorItemPropertyRequestFailed(ResponseMessageError): + pass + + +class ErrorItemSave(ResponseMessageError): + pass + + +class ErrorItemSavePropertyError(ResponseMessageError): + pass + + +class ErrorLegacyMailboxFreeBusyViewTypeNotMerged(ResponseMessageError): + pass + + +class ErrorLocalServerObjectNotFound(ResponseMessageError): + pass + + +class ErrorLogonAsNetworkServiceFailed(ResponseMessageError): + pass + + +class ErrorMailboxConfiguration(ResponseMessageError): + pass + + +class ErrorMailboxDataArrayEmpty(ResponseMessageError): + pass + + +class ErrorMailboxDataArrayTooBig(ResponseMessageError): + pass + + +class ErrorMailboxFailover(ResponseMessageError): + pass + + +class ErrorMailboxLogonFailed(ResponseMessageError): + pass + + +class ErrorMailboxMoveInProgress(ResponseMessageError): + pass + + +class ErrorMailboxStoreUnavailable(ResponseMessageError): + pass + + +class ErrorMailRecipientNotFound(ResponseMessageError): + pass + + +class ErrorMailTipsDisabled(ResponseMessageError): + pass + + +class ErrorManagedFolderAlreadyExists(ResponseMessageError): + pass + + +class ErrorManagedFolderNotFound(ResponseMessageError): + pass + + +class ErrorManagedFoldersRootFailure(ResponseMessageError): + pass + + +class ErrorMeetingSuggestionGenerationFailed(ResponseMessageError): + pass + + +class ErrorMessageDispositionRequired(ResponseMessageError): + pass + + +class ErrorMessageSizeExceeded(ResponseMessageError): + pass + + +class ErrorMessageTrackingNoSuchDomain(ResponseMessageError): + pass + + +class ErrorMessageTrackingPermanentError(ResponseMessageError): + pass + + +class ErrorMessageTrackingTransientError(ResponseMessageError): + pass + + +class ErrorMimeContentConversionFailed(ResponseMessageError): + pass + + +class ErrorMimeContentInvalid(ResponseMessageError): + pass + + +class ErrorMimeContentInvalidBase64String(ResponseMessageError): + pass + + +class ErrorMissedNotificationEvents(ResponseMessageError): + pass + + +class ErrorMissingArgument(ResponseMessageError): + pass + + +class ErrorMissingEmailAddress(ResponseMessageError): + pass + + +class ErrorMissingEmailAddressForManagedFolder(ResponseMessageError): + pass + + +class ErrorMissingInformationEmailAddress(ResponseMessageError): + pass + + +class ErrorMissingInformationReferenceItemId(ResponseMessageError): + pass + + +class ErrorMissingInformationSharingFolderId(ResponseMessageError): + pass + + +class ErrorMissingItemForCreateItemAttachment(ResponseMessageError): + pass + + +class ErrorMissingManagedFolderId(ResponseMessageError): + pass + + +class ErrorMissingRecipients(ResponseMessageError): + pass + + +class ErrorMissingUserIdInformation(ResponseMessageError): + pass + + +class ErrorMoreThanOneAccessModeSpecified(ResponseMessageError): + pass + + +class ErrorMoveCopyFailed(ResponseMessageError): + pass + + +class ErrorMoveDistinguishedFolder(ResponseMessageError): + pass + + +class ErrorNameResolutionMultipleResults(ResponseMessageError): + pass + + +class ErrorNameResolutionNoMailbox(ResponseMessageError): + pass + + +class ErrorNameResolutionNoResults(ResponseMessageError): + pass + + +class ErrorNewEventStreamConnectionOpened(ResponseMessageError): + pass + + +class ErrorNoApplicableProxyCASServersAvailable(ResponseMessageError): + pass + + +class ErrorNoCalendar(ResponseMessageError): + pass + + +class ErrorNoDestinationCASDueToKerberosRequirements(ResponseMessageError): + pass + + +class ErrorNoDestinationCASDueToSSLRequirements(ResponseMessageError): + pass + + +class ErrorNoDestinationCASDueToVersionMismatch(ResponseMessageError): + pass + + +class ErrorNoFolderClassOverride(ResponseMessageError): + pass + + +class ErrorNoFreeBusyAccess(ResponseMessageError): + pass + + +class ErrorNonExistentMailbox(ResponseMessageError): + pass + + +class ErrorNonPrimarySmtpAddress(ResponseMessageError): + pass + + +class ErrorNoPropertyTagForCustomProperties(ResponseMessageError): + pass + + +class ErrorNoPublicFolderReplicaAvailable(ResponseMessageError): + pass + + +class ErrorNoPublicFolderServerAvailable(ResponseMessageError): + pass + + +class ErrorNoRespondingCASInDestinationSite(ResponseMessageError): + pass + + +class ErrorNotAllowedExternalSharingByPolicy(ResponseMessageError): + pass + + +class ErrorNotDelegate(ResponseMessageError): + pass + + +class ErrorNotEnoughMemory(ResponseMessageError): + pass + + +class ErrorNotSupportedSharingMessage(ResponseMessageError): + pass + + +class ErrorObjectTypeChanged(ResponseMessageError): + pass + + +class ErrorOccurrenceCrossingBoundary(ResponseMessageError): + pass + + +class ErrorOccurrenceTimeSpanTooBig(ResponseMessageError): + pass + + +class ErrorOperationNotAllowedWithPublicFolderRoot(ResponseMessageError): + pass + + +class ErrorOrganizationNotFederated(ResponseMessageError): + pass + + +class ErrorOutlookRuleBlobExists(ResponseMessageError): + pass + + +class ErrorParentFolderIdRequired(ResponseMessageError): + pass + + +class ErrorParentFolderNotFound(ResponseMessageError): + pass + + +class ErrorPasswordChangeRequired(ResponseMessageError): + pass + + +class ErrorPasswordExpired(ResponseMessageError): + pass + + +class ErrorPermissionNotAllowedByPolicy(ResponseMessageError): + pass + + +class ErrorPhoneNumberNotDialable(ResponseMessageError): + pass + + +class ErrorPropertyUpdate(ResponseMessageError): + pass + + +class ErrorPropertyValidationFailure(ResponseMessageError): + pass + + +class ErrorProxiedSubscriptionCallFailure(ResponseMessageError): + pass + + +class ErrorProxyCallFailed(ResponseMessageError): + pass + + +class ErrorProxyGroupSidLimitExceeded(ResponseMessageError): + pass + + +class ErrorProxyRequestNotAllowed(ResponseMessageError): + pass + + +class ErrorProxyRequestProcessingFailed(ResponseMessageError): + pass + + +class ErrorProxyServiceDiscoveryFailed(ResponseMessageError): + pass + + +class ErrorProxyTokenExpired(ResponseMessageError): + pass + + +class ErrorPublicFolderRequestProcessingFailed(ResponseMessageError): + pass + + +class ErrorPublicFolderServerNotFound(ResponseMessageError): + pass + + +class ErrorQueryFilterTooLong(ResponseMessageError): + pass + + +class ErrorQuotaExceeded(ResponseMessageError): + pass + + +class ErrorReadEventsFailed(ResponseMessageError): + pass + + +class ErrorReadReceiptNotPending(ResponseMessageError): + pass + + +class ErrorRecurrenceEndDateTooBig(ResponseMessageError): + pass + + +class ErrorRecurrenceHasNoOccurrence(ResponseMessageError): + pass + + +class ErrorRemoveDelegatesFailed(ResponseMessageError): + pass + + +class ErrorRequestAborted(ResponseMessageError): + pass + + +class ErrorRequestStreamTooBig(ResponseMessageError): + pass + + +class ErrorRequiredPropertyMissing(ResponseMessageError): + pass + + +class ErrorResolveNamesInvalidFolderType(ResponseMessageError): + pass + + +class ErrorResolveNamesOnlyOneContactsFolderAllowed(ResponseMessageError): + pass + + +class ErrorResponseSchemaValidation(ResponseMessageError): + pass + + +class ErrorRestrictionTooComplex(ResponseMessageError): + pass + + +class ErrorRestrictionTooLong(ResponseMessageError): + pass + + +class ErrorResultSetTooBig(ResponseMessageError): + pass + + +class ErrorRulesOverQuota(ResponseMessageError): + pass + + +class ErrorSavedItemFolderNotFound(ResponseMessageError): + pass + + +class ErrorSchemaValidation(ResponseMessageError): + pass + + +class ErrorSearchFolderNotInitialized(ResponseMessageError): + pass + + +class ErrorSendAsDenied(ResponseMessageError): + pass + + +class ErrorSendMeetingCancellationsRequired(ResponseMessageError): + pass + + +class ErrorSendMeetingInvitationsOrCancellationsRequired(ResponseMessageError): + pass + + +class ErrorSendMeetingInvitationsRequired(ResponseMessageError): + pass + + +class ErrorSentMeetingRequestUpdate(ResponseMessageError): + pass + + +class ErrorSentTaskRequestUpdate(ResponseMessageError): + pass + + +class ErrorServerBusy(ResponseMessageError): + def __init__(self, *args, **kwargs): + self.back_off = kwargs.pop("back_off", None) # Requested back off value in seconds + super().__init__(*args, **kwargs) + + +class ErrorServiceDiscoveryFailed(ResponseMessageError): + pass + + +class ErrorSharingNoExternalEwsAvailable(ResponseMessageError): + pass -class ErrorServerBusy(ResponseMessageError): - def __init__(self, *args, **kwargs): - self.back_off = kwargs.pop('back_off', None) # Requested back off value in seconds - super().__init__(*args, **kwargs) +class ErrorSharingSynchronizationFailed(ResponseMessageError): + pass + + +class ErrorStaleObject(ResponseMessageError): + pass + + +class ErrorSubmissionQuotaExceeded(ResponseMessageError): + pass + + +class ErrorSubscriptionAccessDenied(ResponseMessageError): + pass + + +class ErrorSubscriptionDelegateAccessNotSupported(ResponseMessageError): + pass + + +class ErrorSubscriptionNotFound(ResponseMessageError): + pass + + +class ErrorSubscriptionUnsubsribed(ResponseMessageError): + pass + + +class ErrorSyncFolderNotFound(ResponseMessageError): + pass + + +class ErrorTimeIntervalTooBig(ResponseMessageError): + pass + + +class ErrorTimeoutExpired(ResponseMessageError): + pass + + +class ErrorTimeZone(ResponseMessageError): + pass + + +class ErrorToFolderNotFound(ResponseMessageError): + pass + + +class ErrorTokenSerializationDenied(ResponseMessageError): + pass + + +class ErrorTooManyObjectsOpened(ResponseMessageError): + pass + + +class ErrorUnableToGetUserOofSettings(ResponseMessageError): + pass + + +class ErrorUnifiedMessagingDialPlanNotFound(ResponseMessageError): + pass + + +class ErrorUnifiedMessagingRequestFailed(ResponseMessageError): + pass + + +class ErrorUnifiedMessagingServerNotFound(ResponseMessageError): + pass + + +class ErrorUnsupportedCulture(ResponseMessageError): + pass + + +class ErrorUnsupportedMapiPropertyType(ResponseMessageError): + pass + + +class ErrorUnsupportedMimeConversion(ResponseMessageError): + pass + + +class ErrorUnsupportedPathForQuery(ResponseMessageError): + pass + + +class ErrorUnsupportedPathForSortGroup(ResponseMessageError): + pass + + +class ErrorUnsupportedPropertyDefinition(ResponseMessageError): + pass + + +class ErrorUnsupportedQueryFilter(ResponseMessageError): + pass + + +class ErrorUnsupportedRecurrence(ResponseMessageError): + pass + + +class ErrorUnsupportedSubFilter(ResponseMessageError): + pass + + +class ErrorUnsupportedTypeForConversion(ResponseMessageError): + pass + + +class ErrorUpdateDelegatesFailed(ResponseMessageError): + pass + + +class ErrorUpdatePropertyMismatch(ResponseMessageError): + pass + + +class ErrorUserNotAllowedByPolicy(ResponseMessageError): + pass + + +class ErrorUserNotUnifiedMessagingEnabled(ResponseMessageError): + pass + + +class ErrorUserWithoutFederatedProxyAddress(ResponseMessageError): + pass + + +class ErrorValueOutOfRange(ResponseMessageError): + pass + + +class ErrorVirusDetected(ResponseMessageError): + pass + + +class ErrorVirusMessageDeleted(ResponseMessageError): + pass + + +class ErrorVoiceMailNotImplemented(ResponseMessageError): + pass + + +class ErrorWebRequestInInvalidState(ResponseMessageError): + pass + + +class ErrorWin32InteropError(ResponseMessageError): + pass + + +class ErrorWorkingHoursSaveFailed(ResponseMessageError): + pass + + +class ErrorWorkingHoursXmlMalformed(ResponseMessageError): + pass + + +class ErrorWrongServerVersion(ResponseMessageError): + pass -class ErrorServiceDiscoveryFailed(ResponseMessageError): pass -class ErrorSharingNoExternalEwsAvailable(ResponseMessageError): pass -class ErrorSharingSynchronizationFailed(ResponseMessageError): pass -class ErrorStaleObject(ResponseMessageError): pass -class ErrorSubmissionQuotaExceeded(ResponseMessageError): pass -class ErrorSubscriptionAccessDenied(ResponseMessageError): pass -class ErrorSubscriptionDelegateAccessNotSupported(ResponseMessageError): pass -class ErrorSubscriptionNotFound(ResponseMessageError): pass -class ErrorSubscriptionUnsubsribed(ResponseMessageError): pass -class ErrorSyncFolderNotFound(ResponseMessageError): pass -class ErrorTimeIntervalTooBig(ResponseMessageError): pass -class ErrorTimeoutExpired(ResponseMessageError): pass -class ErrorTimeZone(ResponseMessageError): pass -class ErrorToFolderNotFound(ResponseMessageError): pass -class ErrorTokenSerializationDenied(ResponseMessageError): pass -class ErrorTooManyObjectsOpened(ResponseMessageError): pass -class ErrorUnableToGetUserOofSettings(ResponseMessageError): pass -class ErrorUnifiedMessagingDialPlanNotFound(ResponseMessageError): pass -class ErrorUnifiedMessagingRequestFailed(ResponseMessageError): pass -class ErrorUnifiedMessagingServerNotFound(ResponseMessageError): pass -class ErrorUnsupportedCulture(ResponseMessageError): pass -class ErrorUnsupportedMapiPropertyType(ResponseMessageError): pass -class ErrorUnsupportedMimeConversion(ResponseMessageError): pass -class ErrorUnsupportedPathForQuery(ResponseMessageError): pass -class ErrorUnsupportedPathForSortGroup(ResponseMessageError): pass -class ErrorUnsupportedPropertyDefinition(ResponseMessageError): pass -class ErrorUnsupportedQueryFilter(ResponseMessageError): pass -class ErrorUnsupportedRecurrence(ResponseMessageError): pass -class ErrorUnsupportedSubFilter(ResponseMessageError): pass -class ErrorUnsupportedTypeForConversion(ResponseMessageError): pass -class ErrorUpdateDelegatesFailed(ResponseMessageError): pass -class ErrorUpdatePropertyMismatch(ResponseMessageError): pass -class ErrorUserNotAllowedByPolicy(ResponseMessageError): pass -class ErrorUserNotUnifiedMessagingEnabled(ResponseMessageError): pass -class ErrorUserWithoutFederatedProxyAddress(ResponseMessageError): pass -class ErrorValueOutOfRange(ResponseMessageError): pass -class ErrorVirusDetected(ResponseMessageError): pass -class ErrorVirusMessageDeleted(ResponseMessageError): pass -class ErrorVoiceMailNotImplemented(ResponseMessageError): pass -class ErrorWebRequestInInvalidState(ResponseMessageError): pass -class ErrorWin32InteropError(ResponseMessageError): pass -class ErrorWorkingHoursSaveFailed(ResponseMessageError): pass -class ErrorWorkingHoursXmlMalformed(ResponseMessageError): pass -class ErrorWrongServerVersion(ResponseMessageError): pass -class ErrorWrongServerVersionDelegate(ResponseMessageError): pass +class ErrorWrongServerVersionDelegate(ResponseMessageError): + pass # Microsoft recommends to cache the autodiscover data around 24 hours and perform autodiscover @@ -695,7 +1841,7 @@

                                Ancestors

                                super().__init__(str(self)) def __str__(self): - return f'CAS error: {self.cas_error}'
                                + return f"CAS error: {self.cas_error}"

                                Ancestors

                                  @@ -793,7 +1939,8 @@

                                  Ancestors

                                  Expand source code -
                                  class ErrorADOperation(ResponseMessageError): pass
                                  +
                                  class ErrorADOperation(ResponseMessageError):
                                  +    pass

                                  Ancestors

                                    @@ -814,7 +1961,8 @@

                                    Ancestors

                                    Expand source code -
                                    class ErrorADSessionFilter(ResponseMessageError): pass
                                    +
                                    class ErrorADSessionFilter(ResponseMessageError):
                                    +    pass

                                    Ancestors

                                      @@ -835,7 +1983,8 @@

                                      Ancestors

                                      Expand source code -
                                      class ErrorADUnavailable(ResponseMessageError): pass
                                      +
                                      class ErrorADUnavailable(ResponseMessageError):
                                      +    pass

                                      Ancestors

                                        @@ -856,7 +2005,8 @@

                                        Ancestors

                                        Expand source code -
                                        class ErrorAccessDenied(ResponseMessageError): pass
                                        +
                                        class ErrorAccessDenied(ResponseMessageError):
                                        +    pass

                                        Ancestors

                                          @@ -877,7 +2027,8 @@

                                          Ancestors

                                          Expand source code -
                                          class ErrorAccessModeSpecified(ResponseMessageError): pass
                                          +
                                          class ErrorAccessModeSpecified(ResponseMessageError):
                                          +    pass

                                          Ancestors

                                            @@ -898,7 +2049,8 @@

                                            Ancestors

                                            Expand source code -
                                            class ErrorAccountDisabled(ResponseMessageError): pass
                                            +
                                            class ErrorAccountDisabled(ResponseMessageError):
                                            +    pass

                                            Ancestors

                                              @@ -919,7 +2071,8 @@

                                              Ancestors

                                              Expand source code -
                                              class ErrorAddDelegatesFailed(ResponseMessageError): pass
                                              +
                                              class ErrorAddDelegatesFailed(ResponseMessageError):
                                              +    pass

                                              Ancestors

                                                @@ -940,7 +2093,8 @@

                                                Ancestors

                                                Expand source code -
                                                class ErrorAddressSpaceNotFound(ResponseMessageError): pass
                                                +
                                                class ErrorAddressSpaceNotFound(ResponseMessageError):
                                                +    pass

                                                Ancestors

                                                  @@ -961,7 +2115,8 @@

                                                  Ancestors

                                                  Expand source code -
                                                  class ErrorAffectedTaskOccurrencesRequired(ResponseMessageError): pass
                                                  +
                                                  class ErrorAffectedTaskOccurrencesRequired(ResponseMessageError):
                                                  +    pass

                                                  Ancestors

                                                    @@ -982,7 +2137,8 @@

                                                    Ancestors

                                                    Expand source code -
                                                    class ErrorApplyConversationActionFailed(ResponseMessageError): pass
                                                    +
                                                    class ErrorApplyConversationActionFailed(ResponseMessageError):
                                                    +    pass

                                                    Ancestors

                                                      @@ -1003,7 +2159,8 @@

                                                      Ancestors

                                                      Expand source code -
                                                      class ErrorAttachmentSizeLimitExceeded(ResponseMessageError): pass
                                                      +
                                                      class ErrorAttachmentSizeLimitExceeded(ResponseMessageError):
                                                      +    pass

                                                      Ancestors

                                                        @@ -1024,7 +2181,8 @@

                                                        Ancestors

                                                        Expand source code -
                                                        class ErrorAutoDiscoverFailed(ResponseMessageError): pass
                                                        +
                                                        class ErrorAutoDiscoverFailed(ResponseMessageError):
                                                        +    pass

                                                        Ancestors

                                                          @@ -1045,7 +2203,8 @@

                                                          Ancestors

                                                          Expand source code -
                                                          class ErrorAvailabilityConfigNotFound(ResponseMessageError): pass
                                                          +
                                                          class ErrorAvailabilityConfigNotFound(ResponseMessageError):
                                                          +    pass

                                                          Ancestors

                                                            @@ -1066,7 +2225,8 @@

                                                            Ancestors

                                                            Expand source code -
                                                            class ErrorBatchProcessingStopped(ResponseMessageError): pass
                                                            +
                                                            class ErrorBatchProcessingStopped(ResponseMessageError):
                                                            +    pass

                                                            Ancestors

                                                              @@ -1087,7 +2247,8 @@

                                                              Ancestors

                                                              Expand source code -
                                                              class ErrorCalendarCannotMoveOrCopyOccurrence(ResponseMessageError): pass
                                                              +
                                                              class ErrorCalendarCannotMoveOrCopyOccurrence(ResponseMessageError):
                                                              +    pass

                                                              Ancestors

                                                                @@ -1108,7 +2269,8 @@

                                                                Ancestors

                                                                Expand source code -
                                                                class ErrorCalendarCannotUpdateDeletedItem(ResponseMessageError): pass
                                                                +
                                                                class ErrorCalendarCannotUpdateDeletedItem(ResponseMessageError):
                                                                +    pass

                                                                Ancestors

                                                                  @@ -1129,7 +2291,8 @@

                                                                  Ancestors

                                                                  Expand source code -
                                                                  class ErrorCalendarCannotUseIdForOccurrenceId(ResponseMessageError): pass
                                                                  +
                                                                  class ErrorCalendarCannotUseIdForOccurrenceId(ResponseMessageError):
                                                                  +    pass

                                                                  Ancestors

                                                                    @@ -1150,7 +2313,8 @@

                                                                    Ancestors

                                                                    Expand source code -
                                                                    class ErrorCalendarCannotUseIdForRecurringMasterId(ResponseMessageError): pass
                                                                    +
                                                                    class ErrorCalendarCannotUseIdForRecurringMasterId(ResponseMessageError):
                                                                    +    pass

                                                                    Ancestors

                                                                      @@ -1171,7 +2335,8 @@

                                                                      Ancestors

                                                                      Expand source code -
                                                                      class ErrorCalendarDurationIsTooLong(ResponseMessageError): pass
                                                                      +
                                                                      class ErrorCalendarDurationIsTooLong(ResponseMessageError):
                                                                      +    pass

                                                                      Ancestors

                                                                        @@ -1192,7 +2357,8 @@

                                                                        Ancestors

                                                                        Expand source code -
                                                                        class ErrorCalendarEndDateIsEarlierThanStartDate(ResponseMessageError): pass
                                                                        +
                                                                        class ErrorCalendarEndDateIsEarlierThanStartDate(ResponseMessageError):
                                                                        +    pass

                                                                        Ancestors

                                                                          @@ -1213,7 +2379,8 @@

                                                                          Ancestors

                                                                          Expand source code -
                                                                          class ErrorCalendarFolderIsInvalidForCalendarView(ResponseMessageError): pass
                                                                          +
                                                                          class ErrorCalendarFolderIsInvalidForCalendarView(ResponseMessageError):
                                                                          +    pass

                                                                          Ancestors

                                                                            @@ -1234,7 +2401,8 @@

                                                                            Ancestors

                                                                            Expand source code -
                                                                            class ErrorCalendarInvalidAttributeValue(ResponseMessageError): pass
                                                                            +
                                                                            class ErrorCalendarInvalidAttributeValue(ResponseMessageError):
                                                                            +    pass

                                                                            Ancestors

                                                                              @@ -1255,7 +2423,8 @@

                                                                              Ancestors

                                                                              Expand source code -
                                                                              class ErrorCalendarInvalidDayForTimeChangePattern(ResponseMessageError): pass
                                                                              +
                                                                              class ErrorCalendarInvalidDayForTimeChangePattern(ResponseMessageError):
                                                                              +    pass

                                                                              Ancestors

                                                                                @@ -1276,7 +2445,8 @@

                                                                                Ancestors

                                                                                Expand source code -
                                                                                class ErrorCalendarInvalidDayForWeeklyRecurrence(ResponseMessageError): pass
                                                                                +
                                                                                class ErrorCalendarInvalidDayForWeeklyRecurrence(ResponseMessageError):
                                                                                +    pass

                                                                                Ancestors

                                                                                  @@ -1297,7 +2467,8 @@

                                                                                  Ancestors

                                                                                  Expand source code -
                                                                                  class ErrorCalendarInvalidPropertyState(ResponseMessageError): pass
                                                                                  +
                                                                                  class ErrorCalendarInvalidPropertyState(ResponseMessageError):
                                                                                  +    pass

                                                                                  Ancestors

                                                                                    @@ -1318,7 +2489,8 @@

                                                                                    Ancestors

                                                                                    Expand source code -
                                                                                    class ErrorCalendarInvalidPropertyValue(ResponseMessageError): pass
                                                                                    +
                                                                                    class ErrorCalendarInvalidPropertyValue(ResponseMessageError):
                                                                                    +    pass

                                                                                    Ancestors

                                                                                      @@ -1339,7 +2511,8 @@

                                                                                      Ancestors

                                                                                      Expand source code -
                                                                                      class ErrorCalendarInvalidRecurrence(ResponseMessageError): pass
                                                                                      +
                                                                                      class ErrorCalendarInvalidRecurrence(ResponseMessageError):
                                                                                      +    pass

                                                                                      Ancestors

                                                                                        @@ -1360,7 +2533,8 @@

                                                                                        Ancestors

                                                                                        Expand source code -
                                                                                        class ErrorCalendarInvalidTimeZone(ResponseMessageError): pass
                                                                                        +
                                                                                        class ErrorCalendarInvalidTimeZone(ResponseMessageError):
                                                                                        +    pass

                                                                                        Ancestors

                                                                                          @@ -1381,7 +2555,8 @@

                                                                                          Ancestors

                                                                                          Expand source code -
                                                                                          class ErrorCalendarIsCancelledForAccept(ResponseMessageError): pass
                                                                                          +
                                                                                          class ErrorCalendarIsCancelledForAccept(ResponseMessageError):
                                                                                          +    pass

                                                                                          Ancestors

                                                                                            @@ -1402,7 +2577,8 @@

                                                                                            Ancestors

                                                                                            Expand source code -
                                                                                            class ErrorCalendarIsCancelledForDecline(ResponseMessageError): pass
                                                                                            +
                                                                                            class ErrorCalendarIsCancelledForDecline(ResponseMessageError):
                                                                                            +    pass

                                                                                            Ancestors

                                                                                              @@ -1423,7 +2599,8 @@

                                                                                              Ancestors

                                                                                              Expand source code -
                                                                                              class ErrorCalendarIsCancelledForRemove(ResponseMessageError): pass
                                                                                              +
                                                                                              class ErrorCalendarIsCancelledForRemove(ResponseMessageError):
                                                                                              +    pass

                                                                                              Ancestors

                                                                                                @@ -1444,7 +2621,8 @@

                                                                                                Ancestors

                                                                                                Expand source code -
                                                                                                class ErrorCalendarIsCancelledForTentative(ResponseMessageError): pass
                                                                                                +
                                                                                                class ErrorCalendarIsCancelledForTentative(ResponseMessageError):
                                                                                                +    pass

                                                                                                Ancestors

                                                                                                  @@ -1465,7 +2643,8 @@

                                                                                                  Ancestors

                                                                                                  Expand source code -
                                                                                                  class ErrorCalendarIsDelegatedForAccept(ResponseMessageError): pass
                                                                                                  +
                                                                                                  class ErrorCalendarIsDelegatedForAccept(ResponseMessageError):
                                                                                                  +    pass

                                                                                                  Ancestors

                                                                                                    @@ -1486,7 +2665,8 @@

                                                                                                    Ancestors

                                                                                                    Expand source code -
                                                                                                    class ErrorCalendarIsDelegatedForDecline(ResponseMessageError): pass
                                                                                                    +
                                                                                                    class ErrorCalendarIsDelegatedForDecline(ResponseMessageError):
                                                                                                    +    pass

                                                                                                    Ancestors

                                                                                                      @@ -1507,7 +2687,8 @@

                                                                                                      Ancestors

                                                                                                      Expand source code -
                                                                                                      class ErrorCalendarIsDelegatedForRemove(ResponseMessageError): pass
                                                                                                      +
                                                                                                      class ErrorCalendarIsDelegatedForRemove(ResponseMessageError):
                                                                                                      +    pass

                                                                                                      Ancestors

                                                                                                        @@ -1528,7 +2709,8 @@

                                                                                                        Ancestors

                                                                                                        Expand source code -
                                                                                                        class ErrorCalendarIsDelegatedForTentative(ResponseMessageError): pass
                                                                                                        +
                                                                                                        class ErrorCalendarIsDelegatedForTentative(ResponseMessageError):
                                                                                                        +    pass

                                                                                                        Ancestors

                                                                                                          @@ -1549,7 +2731,8 @@

                                                                                                          Ancestors

                                                                                                          Expand source code -
                                                                                                          class ErrorCalendarIsNotOrganizer(ResponseMessageError): pass
                                                                                                          +
                                                                                                          class ErrorCalendarIsNotOrganizer(ResponseMessageError):
                                                                                                          +    pass

                                                                                                          Ancestors

                                                                                                            @@ -1570,7 +2753,8 @@

                                                                                                            Ancestors

                                                                                                            Expand source code -
                                                                                                            class ErrorCalendarIsOrganizerForAccept(ResponseMessageError): pass
                                                                                                            +
                                                                                                            class ErrorCalendarIsOrganizerForAccept(ResponseMessageError):
                                                                                                            +    pass

                                                                                                            Ancestors

                                                                                                              @@ -1591,7 +2775,8 @@

                                                                                                              Ancestors

                                                                                                              Expand source code -
                                                                                                              class ErrorCalendarIsOrganizerForDecline(ResponseMessageError): pass
                                                                                                              +
                                                                                                              class ErrorCalendarIsOrganizerForDecline(ResponseMessageError):
                                                                                                              +    pass

                                                                                                              Ancestors

                                                                                                                @@ -1612,7 +2797,8 @@

                                                                                                                Ancestors

                                                                                                                Expand source code -
                                                                                                                class ErrorCalendarIsOrganizerForRemove(ResponseMessageError): pass
                                                                                                                +
                                                                                                                class ErrorCalendarIsOrganizerForRemove(ResponseMessageError):
                                                                                                                +    pass

                                                                                                                Ancestors

                                                                                                                  @@ -1633,7 +2819,8 @@

                                                                                                                  Ancestors

                                                                                                                  Expand source code -
                                                                                                                  class ErrorCalendarIsOrganizerForTentative(ResponseMessageError): pass
                                                                                                                  +
                                                                                                                  class ErrorCalendarIsOrganizerForTentative(ResponseMessageError):
                                                                                                                  +    pass

                                                                                                                  Ancestors

                                                                                                                    @@ -1654,7 +2841,8 @@

                                                                                                                    Ancestors

                                                                                                                    Expand source code -
                                                                                                                    class ErrorCalendarMeetingRequestIsOutOfDate(ResponseMessageError): pass
                                                                                                                    +
                                                                                                                    class ErrorCalendarMeetingRequestIsOutOfDate(ResponseMessageError):
                                                                                                                    +    pass

                                                                                                                    Ancestors

                                                                                                                      @@ -1675,7 +2863,8 @@

                                                                                                                      Ancestors

                                                                                                                      Expand source code -
                                                                                                                      class ErrorCalendarOccurrenceIndexIsOutOfRecurrenceRange(ResponseMessageError): pass
                                                                                                                      +
                                                                                                                      class ErrorCalendarOccurrenceIndexIsOutOfRecurrenceRange(ResponseMessageError):
                                                                                                                      +    pass

                                                                                                                      Ancestors

                                                                                                                        @@ -1696,7 +2885,8 @@

                                                                                                                        Ancestors

                                                                                                                        Expand source code -
                                                                                                                        class ErrorCalendarOccurrenceIsDeletedFromRecurrence(ResponseMessageError): pass
                                                                                                                        +
                                                                                                                        class ErrorCalendarOccurrenceIsDeletedFromRecurrence(ResponseMessageError):
                                                                                                                        +    pass

                                                                                                                        Ancestors

                                                                                                                          @@ -1717,7 +2907,8 @@

                                                                                                                          Ancestors

                                                                                                                          Expand source code -
                                                                                                                          class ErrorCalendarOutOfRange(ResponseMessageError): pass
                                                                                                                          +
                                                                                                                          class ErrorCalendarOutOfRange(ResponseMessageError):
                                                                                                                          +    pass

                                                                                                                          Ancestors

                                                                                                                            @@ -1738,7 +2929,8 @@

                                                                                                                            Ancestors

                                                                                                                            Expand source code -
                                                                                                                            class ErrorCalendarViewRangeTooBig(ResponseMessageError): pass
                                                                                                                            +
                                                                                                                            class ErrorCalendarViewRangeTooBig(ResponseMessageError):
                                                                                                                            +    pass

                                                                                                                            Ancestors

                                                                                                                              @@ -1759,7 +2951,8 @@

                                                                                                                              Ancestors

                                                                                                                              Expand source code -
                                                                                                                              class ErrorCallerIsInvalidADAccount(ResponseMessageError): pass
                                                                                                                              +
                                                                                                                              class ErrorCallerIsInvalidADAccount(ResponseMessageError):
                                                                                                                              +    pass

                                                                                                                              Ancestors

                                                                                                                                @@ -1780,7 +2973,8 @@

                                                                                                                                Ancestors

                                                                                                                                Expand source code -
                                                                                                                                class ErrorCannotCreateCalendarItemInNonCalendarFolder(ResponseMessageError): pass
                                                                                                                                +
                                                                                                                                class ErrorCannotCreateCalendarItemInNonCalendarFolder(ResponseMessageError):
                                                                                                                                +    pass

                                                                                                                                Ancestors

                                                                                                                                  @@ -1801,7 +2995,8 @@

                                                                                                                                  Ancestors

                                                                                                                                  Expand source code -
                                                                                                                                  class ErrorCannotCreateContactInNonContactFolder(ResponseMessageError): pass
                                                                                                                                  +
                                                                                                                                  class ErrorCannotCreateContactInNonContactFolder(ResponseMessageError):
                                                                                                                                  +    pass

                                                                                                                                  Ancestors

                                                                                                                                    @@ -1822,7 +3017,8 @@

                                                                                                                                    Ancestors

                                                                                                                                    Expand source code -
                                                                                                                                    class ErrorCannotCreatePostItemInNonMailFolder(ResponseMessageError): pass
                                                                                                                                    +
                                                                                                                                    class ErrorCannotCreatePostItemInNonMailFolder(ResponseMessageError):
                                                                                                                                    +    pass

                                                                                                                                    Ancestors

                                                                                                                                      @@ -1843,7 +3039,8 @@

                                                                                                                                      Ancestors

                                                                                                                                      Expand source code -
                                                                                                                                      class ErrorCannotCreateTaskInNonTaskFolder(ResponseMessageError): pass
                                                                                                                                      +
                                                                                                                                      class ErrorCannotCreateTaskInNonTaskFolder(ResponseMessageError):
                                                                                                                                      +    pass

                                                                                                                                      Ancestors

                                                                                                                                        @@ -1864,7 +3061,8 @@

                                                                                                                                        Ancestors

                                                                                                                                        Expand source code -
                                                                                                                                        class ErrorCannotDeleteObject(ResponseMessageError): pass
                                                                                                                                        +
                                                                                                                                        class ErrorCannotDeleteObject(ResponseMessageError):
                                                                                                                                        +    pass

                                                                                                                                        Ancestors

                                                                                                                                          @@ -1885,7 +3083,8 @@

                                                                                                                                          Ancestors

                                                                                                                                          Expand source code -
                                                                                                                                          class ErrorCannotDeleteTaskOccurrence(ResponseMessageError): pass
                                                                                                                                          +
                                                                                                                                          class ErrorCannotDeleteTaskOccurrence(ResponseMessageError):
                                                                                                                                          +    pass

                                                                                                                                          Ancestors

                                                                                                                                            @@ -1906,7 +3105,8 @@

                                                                                                                                            Ancestors

                                                                                                                                            Expand source code -
                                                                                                                                            class ErrorCannotEmptyFolder(ResponseMessageError): pass
                                                                                                                                            +
                                                                                                                                            class ErrorCannotEmptyFolder(ResponseMessageError):
                                                                                                                                            +    pass

                                                                                                                                            Ancestors

                                                                                                                                              @@ -1927,7 +3127,8 @@

                                                                                                                                              Ancestors

                                                                                                                                              Expand source code -
                                                                                                                                              class ErrorCannotOpenFileAttachment(ResponseMessageError): pass
                                                                                                                                              +
                                                                                                                                              class ErrorCannotOpenFileAttachment(ResponseMessageError):
                                                                                                                                              +    pass

                                                                                                                                              Ancestors

                                                                                                                                                @@ -1948,7 +3149,8 @@

                                                                                                                                                Ancestors

                                                                                                                                                Expand source code -
                                                                                                                                                class ErrorCannotSetCalendarPermissionOnNonCalendarFolder(ResponseMessageError): pass
                                                                                                                                                +
                                                                                                                                                class ErrorCannotSetCalendarPermissionOnNonCalendarFolder(ResponseMessageError):
                                                                                                                                                +    pass

                                                                                                                                                Ancestors

                                                                                                                                                  @@ -1969,7 +3171,8 @@

                                                                                                                                                  Ancestors

                                                                                                                                                  Expand source code -
                                                                                                                                                  class ErrorCannotSetNonCalendarPermissionOnCalendarFolder(ResponseMessageError): pass
                                                                                                                                                  +
                                                                                                                                                  class ErrorCannotSetNonCalendarPermissionOnCalendarFolder(ResponseMessageError):
                                                                                                                                                  +    pass

                                                                                                                                                  Ancestors

                                                                                                                                                    @@ -1990,7 +3193,8 @@

                                                                                                                                                    Ancestors

                                                                                                                                                    Expand source code -
                                                                                                                                                    class ErrorCannotSetPermissionUnknownEntries(ResponseMessageError): pass
                                                                                                                                                    +
                                                                                                                                                    class ErrorCannotSetPermissionUnknownEntries(ResponseMessageError):
                                                                                                                                                    +    pass

                                                                                                                                                    Ancestors

                                                                                                                                                      @@ -2011,7 +3215,8 @@

                                                                                                                                                      Ancestors

                                                                                                                                                      Expand source code -
                                                                                                                                                      class ErrorCannotUseFolderIdForItemId(ResponseMessageError): pass
                                                                                                                                                      +
                                                                                                                                                      class ErrorCannotUseFolderIdForItemId(ResponseMessageError):
                                                                                                                                                      +    pass

                                                                                                                                                      Ancestors

                                                                                                                                                        @@ -2032,7 +3237,8 @@

                                                                                                                                                        Ancestors

                                                                                                                                                        Expand source code -
                                                                                                                                                        class ErrorCannotUseItemIdForFolderId(ResponseMessageError): pass
                                                                                                                                                        +
                                                                                                                                                        class ErrorCannotUseItemIdForFolderId(ResponseMessageError):
                                                                                                                                                        +    pass

                                                                                                                                                        Ancestors

                                                                                                                                                          @@ -2053,7 +3259,8 @@

                                                                                                                                                          Ancestors

                                                                                                                                                          Expand source code -
                                                                                                                                                          class ErrorChangeKeyRequired(ResponseMessageError): pass
                                                                                                                                                          +
                                                                                                                                                          class ErrorChangeKeyRequired(ResponseMessageError):
                                                                                                                                                          +    pass

                                                                                                                                                          Ancestors

                                                                                                                                                            @@ -2074,7 +3281,8 @@

                                                                                                                                                            Ancestors

                                                                                                                                                            Expand source code -
                                                                                                                                                            class ErrorChangeKeyRequiredForWriteOperations(ResponseMessageError): pass
                                                                                                                                                            +
                                                                                                                                                            class ErrorChangeKeyRequiredForWriteOperations(ResponseMessageError):
                                                                                                                                                            +    pass

                                                                                                                                                            Ancestors

                                                                                                                                                              @@ -2095,7 +3303,8 @@

                                                                                                                                                              Ancestors

                                                                                                                                                              Expand source code -
                                                                                                                                                              class ErrorClientDisconnected(ResponseMessageError): pass
                                                                                                                                                              +
                                                                                                                                                              class ErrorClientDisconnected(ResponseMessageError):
                                                                                                                                                              +    pass

                                                                                                                                                              Ancestors

                                                                                                                                                                @@ -2116,7 +3325,8 @@

                                                                                                                                                                Ancestors

                                                                                                                                                                Expand source code -
                                                                                                                                                                class ErrorConnectionFailed(ResponseMessageError): pass
                                                                                                                                                                +
                                                                                                                                                                class ErrorConnectionFailed(ResponseMessageError):
                                                                                                                                                                +    pass

                                                                                                                                                                Ancestors

                                                                                                                                                                  @@ -2137,7 +3347,8 @@

                                                                                                                                                                  Ancestors

                                                                                                                                                                  Expand source code -
                                                                                                                                                                  class ErrorConnectionFailedTransientError(ResponseMessageError): pass
                                                                                                                                                                  +
                                                                                                                                                                  class ErrorConnectionFailedTransientError(ResponseMessageError):
                                                                                                                                                                  +    pass

                                                                                                                                                                  Ancestors

                                                                                                                                                                    @@ -2158,7 +3369,8 @@

                                                                                                                                                                    Ancestors

                                                                                                                                                                    Expand source code -
                                                                                                                                                                    class ErrorContainsFilterWrongType(ResponseMessageError): pass
                                                                                                                                                                    +
                                                                                                                                                                    class ErrorContainsFilterWrongType(ResponseMessageError):
                                                                                                                                                                    +    pass

                                                                                                                                                                    Ancestors

                                                                                                                                                                      @@ -2179,7 +3391,8 @@

                                                                                                                                                                      Ancestors

                                                                                                                                                                      Expand source code -
                                                                                                                                                                      class ErrorContentConversionFailed(ResponseMessageError): pass
                                                                                                                                                                      +
                                                                                                                                                                      class ErrorContentConversionFailed(ResponseMessageError):
                                                                                                                                                                      +    pass

                                                                                                                                                                      Ancestors

                                                                                                                                                                        @@ -2200,7 +3413,8 @@

                                                                                                                                                                        Ancestors

                                                                                                                                                                        Expand source code -
                                                                                                                                                                        class ErrorCorruptData(ResponseMessageError): pass
                                                                                                                                                                        +
                                                                                                                                                                        class ErrorCorruptData(ResponseMessageError):
                                                                                                                                                                        +    pass

                                                                                                                                                                        Ancestors

                                                                                                                                                                          @@ -2221,7 +3435,8 @@

                                                                                                                                                                          Ancestors

                                                                                                                                                                          Expand source code -
                                                                                                                                                                          class ErrorCreateItemAccessDenied(ResponseMessageError): pass
                                                                                                                                                                          +
                                                                                                                                                                          class ErrorCreateItemAccessDenied(ResponseMessageError):
                                                                                                                                                                          +    pass

                                                                                                                                                                          Ancestors

                                                                                                                                                                            @@ -2242,7 +3457,8 @@

                                                                                                                                                                            Ancestors

                                                                                                                                                                            Expand source code -
                                                                                                                                                                            class ErrorCreateManagedFolderPartialCompletion(ResponseMessageError): pass
                                                                                                                                                                            +
                                                                                                                                                                            class ErrorCreateManagedFolderPartialCompletion(ResponseMessageError):
                                                                                                                                                                            +    pass

                                                                                                                                                                            Ancestors

                                                                                                                                                                              @@ -2263,7 +3479,8 @@

                                                                                                                                                                              Ancestors

                                                                                                                                                                              Expand source code -
                                                                                                                                                                              class ErrorCreateSubfolderAccessDenied(ResponseMessageError): pass
                                                                                                                                                                              +
                                                                                                                                                                              class ErrorCreateSubfolderAccessDenied(ResponseMessageError):
                                                                                                                                                                              +    pass

                                                                                                                                                                              Ancestors

                                                                                                                                                                                @@ -2284,7 +3501,8 @@

                                                                                                                                                                                Ancestors

                                                                                                                                                                                Expand source code -
                                                                                                                                                                                class ErrorCrossMailboxMoveCopy(ResponseMessageError): pass
                                                                                                                                                                                +
                                                                                                                                                                                class ErrorCrossMailboxMoveCopy(ResponseMessageError):
                                                                                                                                                                                +    pass

                                                                                                                                                                                Ancestors

                                                                                                                                                                                  @@ -2305,7 +3523,8 @@

                                                                                                                                                                                  Ancestors

                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                  class ErrorCrossSiteRequest(ResponseMessageError): pass
                                                                                                                                                                                  +
                                                                                                                                                                                  class ErrorCrossSiteRequest(ResponseMessageError):
                                                                                                                                                                                  +    pass

                                                                                                                                                                                  Ancestors

                                                                                                                                                                                    @@ -2326,7 +3545,8 @@

                                                                                                                                                                                    Ancestors

                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                    class ErrorDataSizeLimitExceeded(ResponseMessageError): pass
                                                                                                                                                                                    +
                                                                                                                                                                                    class ErrorDataSizeLimitExceeded(ResponseMessageError):
                                                                                                                                                                                    +    pass

                                                                                                                                                                                    Ancestors

                                                                                                                                                                                      @@ -2347,7 +3567,8 @@

                                                                                                                                                                                      Ancestors

                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                      class ErrorDataSourceOperation(ResponseMessageError): pass
                                                                                                                                                                                      +
                                                                                                                                                                                      class ErrorDataSourceOperation(ResponseMessageError):
                                                                                                                                                                                      +    pass

                                                                                                                                                                                      Ancestors

                                                                                                                                                                                        @@ -2368,7 +3589,8 @@

                                                                                                                                                                                        Ancestors

                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                        class ErrorDelegateAlreadyExists(ResponseMessageError): pass
                                                                                                                                                                                        +
                                                                                                                                                                                        class ErrorDelegateAlreadyExists(ResponseMessageError):
                                                                                                                                                                                        +    pass

                                                                                                                                                                                        Ancestors

                                                                                                                                                                                          @@ -2389,7 +3611,8 @@

                                                                                                                                                                                          Ancestors

                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                          class ErrorDelegateCannotAddOwner(ResponseMessageError): pass
                                                                                                                                                                                          +
                                                                                                                                                                                          class ErrorDelegateCannotAddOwner(ResponseMessageError):
                                                                                                                                                                                          +    pass

                                                                                                                                                                                          Ancestors

                                                                                                                                                                                            @@ -2410,7 +3633,8 @@

                                                                                                                                                                                            Ancestors

                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                            class ErrorDelegateMissingConfiguration(ResponseMessageError): pass
                                                                                                                                                                                            +
                                                                                                                                                                                            class ErrorDelegateMissingConfiguration(ResponseMessageError):
                                                                                                                                                                                            +    pass

                                                                                                                                                                                            Ancestors

                                                                                                                                                                                              @@ -2431,7 +3655,8 @@

                                                                                                                                                                                              Ancestors

                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                              class ErrorDelegateNoUser(ResponseMessageError): pass
                                                                                                                                                                                              +
                                                                                                                                                                                              class ErrorDelegateNoUser(ResponseMessageError):
                                                                                                                                                                                              +    pass

                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                @@ -2452,7 +3677,8 @@

                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                class ErrorDelegateValidationFailed(ResponseMessageError): pass
                                                                                                                                                                                                +
                                                                                                                                                                                                class ErrorDelegateValidationFailed(ResponseMessageError):
                                                                                                                                                                                                +    pass

                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                  @@ -2473,7 +3699,8 @@

                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                  class ErrorDeleteDistinguishedFolder(ResponseMessageError): pass
                                                                                                                                                                                                  +
                                                                                                                                                                                                  class ErrorDeleteDistinguishedFolder(ResponseMessageError):
                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                    @@ -2494,7 +3721,8 @@

                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                    class ErrorDeleteItemsFailed(ResponseMessageError): pass
                                                                                                                                                                                                    +
                                                                                                                                                                                                    class ErrorDeleteItemsFailed(ResponseMessageError):
                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                      @@ -2515,7 +3743,8 @@

                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                      class ErrorDistinguishedUserNotSupported(ResponseMessageError): pass
                                                                                                                                                                                                      +
                                                                                                                                                                                                      class ErrorDistinguishedUserNotSupported(ResponseMessageError):
                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                        @@ -2536,7 +3765,8 @@

                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                        class ErrorDistributionListMemberNotExist(ResponseMessageError): pass
                                                                                                                                                                                                        +
                                                                                                                                                                                                        class ErrorDistributionListMemberNotExist(ResponseMessageError):
                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                          @@ -2557,7 +3787,8 @@

                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                          class ErrorDuplicateInputFolderNames(ResponseMessageError): pass
                                                                                                                                                                                                          +
                                                                                                                                                                                                          class ErrorDuplicateInputFolderNames(ResponseMessageError):
                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                            @@ -2578,7 +3809,8 @@

                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                            class ErrorDuplicateSOAPHeader(ResponseMessageError): pass
                                                                                                                                                                                                            +
                                                                                                                                                                                                            class ErrorDuplicateSOAPHeader(ResponseMessageError):
                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                              @@ -2599,7 +3831,8 @@

                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                              class ErrorDuplicateUserIdsSpecified(ResponseMessageError): pass
                                                                                                                                                                                                              +
                                                                                                                                                                                                              class ErrorDuplicateUserIdsSpecified(ResponseMessageError):
                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                @@ -2620,7 +3853,8 @@

                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                class ErrorEmailAddressMismatch(ResponseMessageError): pass
                                                                                                                                                                                                                +
                                                                                                                                                                                                                class ErrorEmailAddressMismatch(ResponseMessageError):
                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                  @@ -2641,7 +3875,8 @@

                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                  class ErrorEventNotFound(ResponseMessageError): pass
                                                                                                                                                                                                                  +
                                                                                                                                                                                                                  class ErrorEventNotFound(ResponseMessageError):
                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                    @@ -2662,7 +3897,8 @@

                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                    class ErrorExceededConnectionCount(ResponseMessageError): pass
                                                                                                                                                                                                                    +
                                                                                                                                                                                                                    class ErrorExceededConnectionCount(ResponseMessageError):
                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                      @@ -2683,7 +3919,8 @@

                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                      class ErrorExceededFindCountLimit(ResponseMessageError): pass
                                                                                                                                                                                                                      +
                                                                                                                                                                                                                      class ErrorExceededFindCountLimit(ResponseMessageError):
                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                        @@ -2704,7 +3941,8 @@

                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                        class ErrorExceededSubscriptionCount(ResponseMessageError): pass
                                                                                                                                                                                                                        +
                                                                                                                                                                                                                        class ErrorExceededSubscriptionCount(ResponseMessageError):
                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                          @@ -2725,7 +3963,8 @@

                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                          class ErrorExpiredSubscription(ResponseMessageError): pass
                                                                                                                                                                                                                          +
                                                                                                                                                                                                                          class ErrorExpiredSubscription(ResponseMessageError):
                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                            @@ -2746,7 +3985,8 @@

                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                            class ErrorFolderCorrupt(ResponseMessageError): pass
                                                                                                                                                                                                                            +
                                                                                                                                                                                                                            class ErrorFolderCorrupt(ResponseMessageError):
                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                              @@ -2767,7 +4007,8 @@

                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                              class ErrorFolderExists(ResponseMessageError): pass
                                                                                                                                                                                                                              +
                                                                                                                                                                                                                              class ErrorFolderExists(ResponseMessageError):
                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                @@ -2788,7 +4029,8 @@

                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                class ErrorFolderNotFound(ResponseMessageError): pass
                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                class ErrorFolderNotFound(ResponseMessageError):
                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                  @@ -2809,7 +4051,8 @@

                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                  class ErrorFolderPropertyRequestFailed(ResponseMessageError): pass
                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                  class ErrorFolderPropertyRequestFailed(ResponseMessageError):
                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                    @@ -2830,7 +4073,8 @@

                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                    class ErrorFolderSave(ResponseMessageError): pass
                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                    class ErrorFolderSave(ResponseMessageError):
                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                      @@ -2851,7 +4095,8 @@

                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                      class ErrorFolderSaveFailed(ResponseMessageError): pass
                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                      class ErrorFolderSaveFailed(ResponseMessageError):
                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                        @@ -2872,7 +4117,8 @@

                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                        class ErrorFolderSavePropertyError(ResponseMessageError): pass
                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                        class ErrorFolderSavePropertyError(ResponseMessageError):
                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                          @@ -2893,7 +4139,8 @@

                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                          class ErrorFreeBusyDLLimitReached(ResponseMessageError): pass
                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                          class ErrorFreeBusyDLLimitReached(ResponseMessageError):
                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                            @@ -2914,7 +4161,8 @@

                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                            class ErrorFreeBusyGenerationFailed(ResponseMessageError): pass
                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                            class ErrorFreeBusyGenerationFailed(ResponseMessageError):
                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                              @@ -2935,7 +4183,8 @@

                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                              class ErrorGetServerSecurityDescriptorFailed(ResponseMessageError): pass
                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                              class ErrorGetServerSecurityDescriptorFailed(ResponseMessageError):
                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                @@ -2956,7 +4205,8 @@

                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                class ErrorIPGatewayNotFound(ResponseMessageError): pass
                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                class ErrorIPGatewayNotFound(ResponseMessageError):
                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                  @@ -2977,7 +4227,8 @@

                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                  class ErrorImpersonateUserDenied(ResponseMessageError): pass
                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                  class ErrorImpersonateUserDenied(ResponseMessageError):
                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                    @@ -2998,7 +4249,8 @@

                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                    class ErrorImpersonationDenied(ResponseMessageError): pass
                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                    class ErrorImpersonationDenied(ResponseMessageError):
                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                      @@ -3019,7 +4271,8 @@

                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                      class ErrorImpersonationFailed(ResponseMessageError): pass
                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                      class ErrorImpersonationFailed(ResponseMessageError):
                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                        @@ -3040,7 +4293,8 @@

                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                        class ErrorInboxRulesValidationError(ResponseMessageError): pass
                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                        class ErrorInboxRulesValidationError(ResponseMessageError):
                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                          @@ -3061,7 +4315,8 @@

                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                          class ErrorIncorrectSchemaVersion(ResponseMessageError): pass
                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                          class ErrorIncorrectSchemaVersion(ResponseMessageError):
                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                            @@ -3082,7 +4337,8 @@

                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                            class ErrorIncorrectUpdatePropertyCount(ResponseMessageError): pass
                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                            class ErrorIncorrectUpdatePropertyCount(ResponseMessageError):
                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                              @@ -3103,7 +4359,8 @@

                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                              class ErrorIndividualMailboxLimitReached(ResponseMessageError): pass
                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                              class ErrorIndividualMailboxLimitReached(ResponseMessageError):
                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                @@ -3124,7 +4381,8 @@

                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                class ErrorInsufficientResources(ResponseMessageError): pass
                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                class ErrorInsufficientResources(ResponseMessageError):
                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                  @@ -3145,7 +4403,8 @@

                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                  class ErrorInternalServerError(ResponseMessageError): pass
                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                  class ErrorInternalServerError(ResponseMessageError):
                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                    @@ -3166,7 +4425,8 @@

                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                    class ErrorInternalServerTransientError(ResponseMessageError): pass
                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                    class ErrorInternalServerTransientError(ResponseMessageError):
                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                      @@ -3187,7 +4447,8 @@

                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                      class ErrorInvalidAccessLevel(ResponseMessageError): pass
                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                      class ErrorInvalidAccessLevel(ResponseMessageError):
                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                        @@ -3208,7 +4469,8 @@

                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                        class ErrorInvalidArgument(ResponseMessageError): pass
                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                        class ErrorInvalidArgument(ResponseMessageError):
                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                          @@ -3229,7 +4491,8 @@

                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                          class ErrorInvalidAttachmentId(ResponseMessageError): pass
                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                          class ErrorInvalidAttachmentId(ResponseMessageError):
                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                            @@ -3250,7 +4513,8 @@

                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                            class ErrorInvalidAttachmentSubfilter(ResponseMessageError): pass
                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                            class ErrorInvalidAttachmentSubfilter(ResponseMessageError):
                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                              @@ -3271,7 +4535,8 @@

                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                              class ErrorInvalidAttachmentSubfilterTextFilter(ResponseMessageError): pass
                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                              class ErrorInvalidAttachmentSubfilterTextFilter(ResponseMessageError):
                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                @@ -3292,7 +4557,8 @@

                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                class ErrorInvalidAuthorizationContext(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                class ErrorInvalidAuthorizationContext(ResponseMessageError):
                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                  @@ -3313,7 +4579,8 @@

                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                  class ErrorInvalidChangeKey(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                  class ErrorInvalidChangeKey(ResponseMessageError):
                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                    @@ -3334,7 +4601,8 @@

                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                    class ErrorInvalidClientSecurityContext(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                    class ErrorInvalidClientSecurityContext(ResponseMessageError):
                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                      @@ -3355,7 +4623,8 @@

                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                      class ErrorInvalidCompleteDate(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                      class ErrorInvalidCompleteDate(ResponseMessageError):
                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                        @@ -3376,7 +4645,8 @@

                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                        class ErrorInvalidContactEmailAddress(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                        class ErrorInvalidContactEmailAddress(ResponseMessageError):
                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                          @@ -3397,7 +4667,8 @@

                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                          class ErrorInvalidContactEmailIndex(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                          class ErrorInvalidContactEmailIndex(ResponseMessageError):
                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                            @@ -3418,7 +4689,8 @@

                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                            class ErrorInvalidCrossForestCredentials(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                            class ErrorInvalidCrossForestCredentials(ResponseMessageError):
                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                              @@ -3439,7 +4711,8 @@

                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                              class ErrorInvalidDelegatePermission(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                              class ErrorInvalidDelegatePermission(ResponseMessageError):
                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                @@ -3460,7 +4733,8 @@

                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                class ErrorInvalidDelegateUserId(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                class ErrorInvalidDelegateUserId(ResponseMessageError):
                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                  @@ -3481,7 +4755,8 @@

                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                  class ErrorInvalidExchangeImpersonationHeaderData(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                  class ErrorInvalidExchangeImpersonationHeaderData(ResponseMessageError):
                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                    @@ -3502,7 +4777,8 @@

                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                    class ErrorInvalidExcludesRestriction(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                    class ErrorInvalidExcludesRestriction(ResponseMessageError):
                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                      @@ -3523,7 +4799,8 @@

                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                      class ErrorInvalidExpressionTypeForSubFilter(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                      class ErrorInvalidExpressionTypeForSubFilter(ResponseMessageError):
                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                        @@ -3544,7 +4821,8 @@

                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                        class ErrorInvalidExtendedProperty(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                        class ErrorInvalidExtendedProperty(ResponseMessageError):
                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                          @@ -3565,7 +4843,8 @@

                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                          class ErrorInvalidExtendedPropertyValue(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                          class ErrorInvalidExtendedPropertyValue(ResponseMessageError):
                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                            @@ -3586,7 +4865,8 @@

                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                            class ErrorInvalidExternalSharingInitiator(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                            class ErrorInvalidExternalSharingInitiator(ResponseMessageError):
                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                              @@ -3607,7 +4887,8 @@

                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                              class ErrorInvalidExternalSharingSubscriber(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                              class ErrorInvalidExternalSharingSubscriber(ResponseMessageError):
                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                @@ -3628,7 +4909,8 @@

                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                class ErrorInvalidFederatedOrganizationId(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                class ErrorInvalidFederatedOrganizationId(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                  @@ -3649,7 +4931,8 @@

                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                  class ErrorInvalidFolderId(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                  class ErrorInvalidFolderId(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                    @@ -3670,7 +4953,8 @@

                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                    class ErrorInvalidFolderTypeForOperation(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                    class ErrorInvalidFolderTypeForOperation(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                      @@ -3691,7 +4975,8 @@

                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                      class ErrorInvalidFractionalPagingParameters(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                      class ErrorInvalidFractionalPagingParameters(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                        @@ -3712,7 +4997,8 @@

                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                        class ErrorInvalidFreeBusyViewType(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                        class ErrorInvalidFreeBusyViewType(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                          @@ -3733,7 +5019,8 @@

                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                          class ErrorInvalidGetSharingFolderRequest(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                          class ErrorInvalidGetSharingFolderRequest(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                            @@ -3754,7 +5041,8 @@

                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                            class ErrorInvalidId(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                            class ErrorInvalidId(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                              @@ -3775,7 +5063,8 @@

                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                              class ErrorInvalidIdEmpty(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                              class ErrorInvalidIdEmpty(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                @@ -3796,7 +5085,8 @@

                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                class ErrorInvalidIdMalformed(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                class ErrorInvalidIdMalformed(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                  @@ -3817,7 +5107,8 @@

                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                  class ErrorInvalidIdMalformedEwsLegacyIdFormat(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                  class ErrorInvalidIdMalformedEwsLegacyIdFormat(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                    @@ -3838,7 +5129,8 @@

                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                    class ErrorInvalidIdMonikerTooLong(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                    class ErrorInvalidIdMonikerTooLong(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                      @@ -3859,7 +5151,8 @@

                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                      class ErrorInvalidIdNotAnItemAttachmentId(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                      class ErrorInvalidIdNotAnItemAttachmentId(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                        @@ -3880,7 +5173,8 @@

                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                        class ErrorInvalidIdReturnedByResolveNames(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                        class ErrorInvalidIdReturnedByResolveNames(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                          @@ -3901,7 +5195,8 @@

                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                          class ErrorInvalidIdStoreObjectIdTooLong(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                          class ErrorInvalidIdStoreObjectIdTooLong(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                            @@ -3922,7 +5217,8 @@

                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                            class ErrorInvalidIdTooManyAttachmentLevels(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                            class ErrorInvalidIdTooManyAttachmentLevels(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                              @@ -3943,7 +5239,8 @@

                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                              class ErrorInvalidIdXml(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                              class ErrorInvalidIdXml(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                @@ -3964,7 +5261,8 @@

                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                class ErrorInvalidIndexedPagingParameters(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                class ErrorInvalidIndexedPagingParameters(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                  @@ -3985,7 +5283,8 @@

                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                  class ErrorInvalidInternetHeaderChildNodes(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                  class ErrorInvalidInternetHeaderChildNodes(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                    @@ -4006,7 +5305,8 @@

                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                    class ErrorInvalidItemForOperationAcceptItem(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                    class ErrorInvalidItemForOperationAcceptItem(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                      @@ -4027,7 +5327,8 @@

                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                      class ErrorInvalidItemForOperationCancelItem(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                      class ErrorInvalidItemForOperationCancelItem(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                        @@ -4048,7 +5349,8 @@

                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                        class ErrorInvalidItemForOperationCreateItem(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                        class ErrorInvalidItemForOperationCreateItem(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                          @@ -4069,7 +5371,8 @@

                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                          class ErrorInvalidItemForOperationCreateItemAttachment(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                          class ErrorInvalidItemForOperationCreateItemAttachment(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                            @@ -4090,7 +5393,8 @@

                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                            class ErrorInvalidItemForOperationDeclineItem(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                            class ErrorInvalidItemForOperationDeclineItem(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                              @@ -4111,7 +5415,8 @@

                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                              class ErrorInvalidItemForOperationExpandDL(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                              class ErrorInvalidItemForOperationExpandDL(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                @@ -4132,7 +5437,8 @@

                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                class ErrorInvalidItemForOperationRemoveItem(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                class ErrorInvalidItemForOperationRemoveItem(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                  @@ -4153,7 +5459,8 @@

                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                  class ErrorInvalidItemForOperationSendItem(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                  class ErrorInvalidItemForOperationSendItem(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                    @@ -4174,7 +5481,8 @@

                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                    class ErrorInvalidItemForOperationTentative(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                    class ErrorInvalidItemForOperationTentative(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                      @@ -4195,7 +5503,8 @@

                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                      class ErrorInvalidLicense(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                      class ErrorInvalidLicense(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                        @@ -4216,7 +5525,8 @@

                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                        class ErrorInvalidLogonType(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                        class ErrorInvalidLogonType(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                          @@ -4237,7 +5547,8 @@

                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                          class ErrorInvalidMailbox(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                          class ErrorInvalidMailbox(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                            @@ -4258,7 +5569,8 @@

                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                            class ErrorInvalidManagedFolderProperty(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                            class ErrorInvalidManagedFolderProperty(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                              @@ -4279,7 +5591,8 @@

                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                              class ErrorInvalidManagedFolderQuota(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                              class ErrorInvalidManagedFolderQuota(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                @@ -4300,7 +5613,8 @@

                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                class ErrorInvalidManagedFolderSize(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                class ErrorInvalidManagedFolderSize(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                  @@ -4321,7 +5635,8 @@

                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                  class ErrorInvalidMergedFreeBusyInterval(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                  class ErrorInvalidMergedFreeBusyInterval(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                    @@ -4342,7 +5657,8 @@

                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                    class ErrorInvalidNameForNameResolution(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                    class ErrorInvalidNameForNameResolution(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                      @@ -4363,7 +5679,8 @@

                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                      class ErrorInvalidNetworkServiceContext(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                      class ErrorInvalidNetworkServiceContext(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                        @@ -4384,7 +5701,8 @@

                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                        class ErrorInvalidOofParameter(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                        class ErrorInvalidOofParameter(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                          @@ -4405,7 +5723,8 @@

                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                          class ErrorInvalidOperation(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                          class ErrorInvalidOperation(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                            @@ -4426,7 +5745,8 @@

                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                            class ErrorInvalidOrganizationRelationshipForFreeBusy(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                            class ErrorInvalidOrganizationRelationshipForFreeBusy(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                              @@ -4447,7 +5767,8 @@

                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                              class ErrorInvalidPagingMaxRows(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                              class ErrorInvalidPagingMaxRows(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                @@ -4468,7 +5789,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                class ErrorInvalidParentFolder(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                class ErrorInvalidParentFolder(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                  @@ -4489,7 +5811,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorInvalidPercentCompleteValue(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorInvalidPercentCompleteValue(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                    @@ -4510,7 +5833,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorInvalidPermissionSettings(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorInvalidPermissionSettings(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                      @@ -4531,7 +5855,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorInvalidPhoneCallId(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorInvalidPhoneCallId(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                        @@ -4552,7 +5877,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorInvalidPhoneNumber(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorInvalidPhoneNumber(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                          @@ -4573,7 +5899,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorInvalidPropertyAppend(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorInvalidPropertyAppend(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                            @@ -4594,7 +5921,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorInvalidPropertyDelete(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorInvalidPropertyDelete(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                              @@ -4615,7 +5943,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorInvalidPropertyForExists(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorInvalidPropertyForExists(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                @@ -4636,7 +5965,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorInvalidPropertyForOperation(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorInvalidPropertyForOperation(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -4657,7 +5987,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorInvalidPropertyRequest(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorInvalidPropertyRequest(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -4678,7 +6009,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorInvalidPropertySet(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorInvalidPropertySet(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -4699,7 +6031,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorInvalidPropertyUpdateSentMessage(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorInvalidPropertyUpdateSentMessage(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -4720,7 +6053,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorInvalidProxySecurityContext(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorInvalidProxySecurityContext(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -4741,7 +6075,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorInvalidPullSubscriptionId(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorInvalidPullSubscriptionId(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -4762,7 +6097,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorInvalidPushSubscriptionUrl(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorInvalidPushSubscriptionUrl(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -4783,7 +6119,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorInvalidRecipientSubfilter(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorInvalidRecipientSubfilter(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -4804,7 +6141,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorInvalidRecipientSubfilterComparison(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorInvalidRecipientSubfilterComparison(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -4825,7 +6163,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorInvalidRecipientSubfilterOrder(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorInvalidRecipientSubfilterOrder(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -4846,7 +6185,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorInvalidRecipientSubfilterTextFilter(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorInvalidRecipientSubfilterTextFilter(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -4867,7 +6207,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorInvalidRecipients(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorInvalidRecipients(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -4888,7 +6229,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorInvalidReferenceItem(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorInvalidReferenceItem(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -4909,7 +6251,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorInvalidRequest(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorInvalidRequest(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -4930,7 +6273,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorInvalidRestriction(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorInvalidRestriction(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -4951,7 +6295,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorInvalidRoutingType(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorInvalidRoutingType(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -4972,7 +6317,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorInvalidSIPUri(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorInvalidSIPUri(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -4993,7 +6339,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorInvalidScheduledOofDuration(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorInvalidScheduledOofDuration(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -5014,7 +6361,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorInvalidSchemaVersionForMailboxVersion(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorInvalidSchemaVersionForMailboxVersion(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -5035,7 +6383,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorInvalidSecurityDescriptor(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorInvalidSecurityDescriptor(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -5056,7 +6405,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorInvalidSendItemSaveSettings(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorInvalidSendItemSaveSettings(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -5077,7 +6427,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorInvalidSerializedAccessToken(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorInvalidSerializedAccessToken(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -5098,7 +6449,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorInvalidServerVersion(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorInvalidServerVersion(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -5119,7 +6471,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorInvalidSharingData(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorInvalidSharingData(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -5140,7 +6493,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorInvalidSharingMessage(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorInvalidSharingMessage(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -5161,7 +6515,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorInvalidSid(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorInvalidSid(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -5182,7 +6537,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorInvalidSmtpAddress(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorInvalidSmtpAddress(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -5203,7 +6559,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorInvalidSubfilterType(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorInvalidSubfilterType(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -5224,7 +6581,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorInvalidSubfilterTypeNotAttendeeType(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorInvalidSubfilterTypeNotAttendeeType(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -5245,7 +6603,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorInvalidSubfilterTypeNotRecipientType(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorInvalidSubfilterTypeNotRecipientType(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -5266,7 +6625,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorInvalidSubscription(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorInvalidSubscription(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -5287,7 +6647,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorInvalidSubscriptionRequest(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorInvalidSubscriptionRequest(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -5308,7 +6669,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorInvalidSyncStateData(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorInvalidSyncStateData(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -5329,7 +6691,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorInvalidTimeInterval(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorInvalidTimeInterval(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -5350,7 +6713,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorInvalidUserInfo(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorInvalidUserInfo(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -5371,7 +6735,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorInvalidUserOofSettings(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorInvalidUserOofSettings(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -5392,7 +6757,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorInvalidUserPrincipalName(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorInvalidUserPrincipalName(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -5413,7 +6779,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorInvalidUserSid(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorInvalidUserSid(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -5434,7 +6801,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorInvalidUserSidMissingUPN(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorInvalidUserSidMissingUPN(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -5455,7 +6823,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorInvalidValueForProperty(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorInvalidValueForProperty(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -5476,7 +6845,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorInvalidWatermark(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorInvalidWatermark(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -5497,7 +6867,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorIrresolvableConflict(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorIrresolvableConflict(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -5518,7 +6889,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorItemCorrupt(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorItemCorrupt(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -5539,7 +6911,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorItemNotFound(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorItemNotFound(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -5560,7 +6933,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorItemPropertyRequestFailed(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorItemPropertyRequestFailed(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -5581,7 +6955,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorItemSave(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorItemSave(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -5602,7 +6977,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorItemSavePropertyError(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorItemSavePropertyError(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -5623,7 +6999,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorLegacyMailboxFreeBusyViewTypeNotMerged(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorLegacyMailboxFreeBusyViewTypeNotMerged(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -5644,7 +7021,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorLocalServerObjectNotFound(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorLocalServerObjectNotFound(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -5665,7 +7043,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorLogonAsNetworkServiceFailed(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorLogonAsNetworkServiceFailed(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -5686,7 +7065,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorMailRecipientNotFound(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorMailRecipientNotFound(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -5707,7 +7087,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorMailTipsDisabled(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorMailTipsDisabled(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -5728,7 +7109,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorMailboxConfiguration(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorMailboxConfiguration(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -5749,7 +7131,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorMailboxDataArrayEmpty(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorMailboxDataArrayEmpty(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -5770,7 +7153,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorMailboxDataArrayTooBig(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorMailboxDataArrayTooBig(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -5791,7 +7175,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorMailboxFailover(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorMailboxFailover(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -5812,7 +7197,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorMailboxLogonFailed(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorMailboxLogonFailed(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -5833,7 +7219,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorMailboxMoveInProgress(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorMailboxMoveInProgress(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -5854,7 +7241,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorMailboxStoreUnavailable(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorMailboxStoreUnavailable(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -5875,7 +7263,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorManagedFolderAlreadyExists(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorManagedFolderAlreadyExists(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -5896,7 +7285,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorManagedFolderNotFound(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorManagedFolderNotFound(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -5917,7 +7307,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorManagedFoldersRootFailure(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorManagedFoldersRootFailure(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -5938,7 +7329,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorMeetingSuggestionGenerationFailed(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorMeetingSuggestionGenerationFailed(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -5959,7 +7351,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorMessageDispositionRequired(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorMessageDispositionRequired(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -5980,7 +7373,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorMessageSizeExceeded(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorMessageSizeExceeded(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -6001,7 +7395,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorMessageTrackingNoSuchDomain(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorMessageTrackingNoSuchDomain(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -6022,7 +7417,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorMessageTrackingPermanentError(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorMessageTrackingPermanentError(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -6043,7 +7439,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorMessageTrackingTransientError(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorMessageTrackingTransientError(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -6064,7 +7461,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorMimeContentConversionFailed(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorMimeContentConversionFailed(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -6085,7 +7483,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorMimeContentInvalid(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorMimeContentInvalid(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -6106,7 +7505,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorMimeContentInvalidBase64String(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorMimeContentInvalidBase64String(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -6127,7 +7527,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorMissedNotificationEvents(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorMissedNotificationEvents(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -6148,7 +7549,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorMissingArgument(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorMissingArgument(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -6169,7 +7571,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorMissingEmailAddress(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorMissingEmailAddress(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -6190,7 +7593,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorMissingEmailAddressForManagedFolder(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorMissingEmailAddressForManagedFolder(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -6211,7 +7615,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorMissingInformationEmailAddress(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorMissingInformationEmailAddress(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -6232,7 +7637,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorMissingInformationReferenceItemId(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorMissingInformationReferenceItemId(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -6253,7 +7659,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorMissingInformationSharingFolderId(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorMissingInformationSharingFolderId(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -6274,7 +7681,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorMissingItemForCreateItemAttachment(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorMissingItemForCreateItemAttachment(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -6295,7 +7703,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorMissingManagedFolderId(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorMissingManagedFolderId(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -6316,7 +7725,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorMissingRecipients(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorMissingRecipients(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -6337,7 +7747,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorMissingUserIdInformation(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorMissingUserIdInformation(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -6358,7 +7769,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorMoreThanOneAccessModeSpecified(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorMoreThanOneAccessModeSpecified(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -6379,7 +7791,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorMoveCopyFailed(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorMoveCopyFailed(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -6400,7 +7813,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorMoveDistinguishedFolder(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorMoveDistinguishedFolder(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -6421,7 +7835,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorNameResolutionMultipleResults(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorNameResolutionMultipleResults(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -6442,7 +7857,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorNameResolutionNoMailbox(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorNameResolutionNoMailbox(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -6463,7 +7879,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorNameResolutionNoResults(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorNameResolutionNoResults(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -6484,7 +7901,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorNewEventStreamConnectionOpened(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorNewEventStreamConnectionOpened(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -6505,7 +7923,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorNoApplicableProxyCASServersAvailable(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorNoApplicableProxyCASServersAvailable(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -6526,7 +7945,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorNoCalendar(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorNoCalendar(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -6547,7 +7967,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorNoDestinationCASDueToKerberosRequirements(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorNoDestinationCASDueToKerberosRequirements(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -6568,7 +7989,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorNoDestinationCASDueToSSLRequirements(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorNoDestinationCASDueToSSLRequirements(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -6589,7 +8011,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorNoDestinationCASDueToVersionMismatch(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorNoDestinationCASDueToVersionMismatch(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -6610,7 +8033,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorNoFolderClassOverride(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorNoFolderClassOverride(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -6631,7 +8055,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorNoFreeBusyAccess(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorNoFreeBusyAccess(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -6652,7 +8077,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorNoPropertyTagForCustomProperties(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorNoPropertyTagForCustomProperties(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -6673,7 +8099,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorNoPublicFolderReplicaAvailable(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorNoPublicFolderReplicaAvailable(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -6694,7 +8121,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorNoPublicFolderServerAvailable(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorNoPublicFolderServerAvailable(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -6715,7 +8143,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorNoRespondingCASInDestinationSite(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorNoRespondingCASInDestinationSite(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -6736,7 +8165,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorNonExistentMailbox(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorNonExistentMailbox(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -6757,7 +8187,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorNonPrimarySmtpAddress(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorNonPrimarySmtpAddress(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -6778,7 +8209,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorNotAllowedExternalSharingByPolicy(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorNotAllowedExternalSharingByPolicy(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -6799,7 +8231,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorNotDelegate(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorNotDelegate(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -6820,7 +8253,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorNotEnoughMemory(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorNotEnoughMemory(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -6841,7 +8275,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorNotSupportedSharingMessage(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorNotSupportedSharingMessage(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -6862,7 +8297,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorObjectTypeChanged(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorObjectTypeChanged(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -6883,7 +8319,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorOccurrenceCrossingBoundary(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorOccurrenceCrossingBoundary(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -6904,7 +8341,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorOccurrenceTimeSpanTooBig(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorOccurrenceTimeSpanTooBig(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -6925,7 +8363,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorOperationNotAllowedWithPublicFolderRoot(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorOperationNotAllowedWithPublicFolderRoot(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -6946,7 +8385,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorOrganizationNotFederated(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorOrganizationNotFederated(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -6967,7 +8407,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorOutlookRuleBlobExists(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorOutlookRuleBlobExists(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -6988,7 +8429,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorParentFolderIdRequired(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorParentFolderIdRequired(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -7009,7 +8451,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorParentFolderNotFound(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorParentFolderNotFound(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -7030,7 +8473,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorPasswordChangeRequired(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorPasswordChangeRequired(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -7051,7 +8495,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorPasswordExpired(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorPasswordExpired(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -7072,7 +8517,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorPermissionNotAllowedByPolicy(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorPermissionNotAllowedByPolicy(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -7093,7 +8539,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorPhoneNumberNotDialable(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorPhoneNumberNotDialable(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -7114,7 +8561,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorPropertyUpdate(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorPropertyUpdate(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -7135,7 +8583,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorPropertyValidationFailure(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorPropertyValidationFailure(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -7156,7 +8605,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorProxiedSubscriptionCallFailure(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorProxiedSubscriptionCallFailure(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -7177,7 +8627,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorProxyCallFailed(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorProxyCallFailed(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -7198,7 +8649,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorProxyGroupSidLimitExceeded(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorProxyGroupSidLimitExceeded(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -7219,7 +8671,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorProxyRequestNotAllowed(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorProxyRequestNotAllowed(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -7240,7 +8693,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorProxyRequestProcessingFailed(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorProxyRequestProcessingFailed(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -7261,7 +8715,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorProxyServiceDiscoveryFailed(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorProxyServiceDiscoveryFailed(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -7282,7 +8737,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorProxyTokenExpired(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorProxyTokenExpired(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -7303,7 +8759,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorPublicFolderRequestProcessingFailed(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorPublicFolderRequestProcessingFailed(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -7324,7 +8781,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorPublicFolderServerNotFound(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorPublicFolderServerNotFound(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -7345,7 +8803,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorQueryFilterTooLong(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorQueryFilterTooLong(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -7366,7 +8825,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorQuotaExceeded(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorQuotaExceeded(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -7387,7 +8847,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorReadEventsFailed(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorReadEventsFailed(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -7408,7 +8869,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorReadReceiptNotPending(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorReadReceiptNotPending(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -7429,7 +8891,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorRecurrenceEndDateTooBig(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorRecurrenceEndDateTooBig(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -7450,7 +8913,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorRecurrenceHasNoOccurrence(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorRecurrenceHasNoOccurrence(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -7471,7 +8935,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorRemoveDelegatesFailed(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorRemoveDelegatesFailed(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -7492,7 +8957,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorRequestAborted(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorRequestAborted(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -7513,7 +8979,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorRequestStreamTooBig(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorRequestStreamTooBig(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -7534,7 +9001,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorRequiredPropertyMissing(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorRequiredPropertyMissing(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -7555,7 +9023,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorResolveNamesInvalidFolderType(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorResolveNamesInvalidFolderType(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -7576,7 +9045,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorResolveNamesOnlyOneContactsFolderAllowed(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorResolveNamesOnlyOneContactsFolderAllowed(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -7597,7 +9067,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorResponseSchemaValidation(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorResponseSchemaValidation(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -7618,7 +9089,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorRestrictionTooComplex(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorRestrictionTooComplex(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -7639,7 +9111,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorRestrictionTooLong(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorRestrictionTooLong(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -7660,7 +9133,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorResultSetTooBig(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorResultSetTooBig(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -7681,7 +9155,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorRulesOverQuota(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorRulesOverQuota(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -7702,7 +9177,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorSavedItemFolderNotFound(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorSavedItemFolderNotFound(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -7723,7 +9199,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorSchemaValidation(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorSchemaValidation(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -7744,7 +9221,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorSearchFolderNotInitialized(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorSearchFolderNotInitialized(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -7765,7 +9243,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorSendAsDenied(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorSendAsDenied(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -7786,7 +9265,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorSendMeetingCancellationsRequired(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorSendMeetingCancellationsRequired(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -7807,7 +9287,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorSendMeetingInvitationsOrCancellationsRequired(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorSendMeetingInvitationsOrCancellationsRequired(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -7828,7 +9309,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorSendMeetingInvitationsRequired(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorSendMeetingInvitationsRequired(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -7849,7 +9331,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorSentMeetingRequestUpdate(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorSentMeetingRequestUpdate(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -7870,7 +9353,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorSentTaskRequestUpdate(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorSentTaskRequestUpdate(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -7893,7 +9377,7 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorServerBusy(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           def __init__(self, *args, **kwargs):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      -        self.back_off = kwargs.pop('back_off', None)  # Requested back off value in seconds
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +        self.back_off = kwargs.pop("back_off", None)  # Requested back off value in seconds
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               super().__init__(*args, **kwargs)

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -7915,7 +9399,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorServiceDiscoveryFailed(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorServiceDiscoveryFailed(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -7936,7 +9421,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorSharingNoExternalEwsAvailable(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorSharingNoExternalEwsAvailable(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -7957,7 +9443,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorSharingSynchronizationFailed(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorSharingSynchronizationFailed(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -7978,7 +9465,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorStaleObject(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorStaleObject(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -7999,7 +9487,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorSubmissionQuotaExceeded(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorSubmissionQuotaExceeded(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -8020,7 +9509,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorSubscriptionAccessDenied(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorSubscriptionAccessDenied(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -8041,7 +9531,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorSubscriptionDelegateAccessNotSupported(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorSubscriptionDelegateAccessNotSupported(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -8062,7 +9553,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorSubscriptionNotFound(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorSubscriptionNotFound(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -8083,7 +9575,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorSubscriptionUnsubsribed(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorSubscriptionUnsubsribed(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -8104,7 +9597,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorSyncFolderNotFound(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorSyncFolderNotFound(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -8125,7 +9619,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorTimeIntervalTooBig(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorTimeIntervalTooBig(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -8146,7 +9641,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorTimeZone(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorTimeZone(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -8167,7 +9663,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorTimeoutExpired(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorTimeoutExpired(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -8188,7 +9685,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorToFolderNotFound(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorToFolderNotFound(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -8209,7 +9707,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorTokenSerializationDenied(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorTokenSerializationDenied(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -8230,7 +9729,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorTooManyObjectsOpened(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorTooManyObjectsOpened(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -8251,7 +9751,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorUnableToGetUserOofSettings(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorUnableToGetUserOofSettings(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -8272,7 +9773,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorUnifiedMessagingDialPlanNotFound(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorUnifiedMessagingDialPlanNotFound(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -8293,7 +9795,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorUnifiedMessagingRequestFailed(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorUnifiedMessagingRequestFailed(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -8314,7 +9817,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorUnifiedMessagingServerNotFound(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorUnifiedMessagingServerNotFound(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -8335,7 +9839,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorUnsupportedCulture(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorUnsupportedCulture(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -8356,7 +9861,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorUnsupportedMapiPropertyType(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorUnsupportedMapiPropertyType(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -8377,7 +9883,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorUnsupportedMimeConversion(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorUnsupportedMimeConversion(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -8398,7 +9905,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorUnsupportedPathForQuery(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorUnsupportedPathForQuery(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -8419,7 +9927,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorUnsupportedPathForSortGroup(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorUnsupportedPathForSortGroup(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -8440,7 +9949,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorUnsupportedPropertyDefinition(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorUnsupportedPropertyDefinition(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -8461,7 +9971,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorUnsupportedQueryFilter(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorUnsupportedQueryFilter(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -8482,7 +9993,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorUnsupportedRecurrence(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorUnsupportedRecurrence(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -8503,7 +10015,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorUnsupportedSubFilter(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorUnsupportedSubFilter(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -8524,7 +10037,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorUnsupportedTypeForConversion(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorUnsupportedTypeForConversion(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -8545,7 +10059,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorUpdateDelegatesFailed(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorUpdateDelegatesFailed(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -8566,7 +10081,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorUpdatePropertyMismatch(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorUpdatePropertyMismatch(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -8587,7 +10103,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorUserNotAllowedByPolicy(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorUserNotAllowedByPolicy(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -8608,7 +10125,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorUserNotUnifiedMessagingEnabled(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorUserNotUnifiedMessagingEnabled(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -8629,7 +10147,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorUserWithoutFederatedProxyAddress(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorUserWithoutFederatedProxyAddress(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -8650,7 +10169,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorValueOutOfRange(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorValueOutOfRange(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -8671,7 +10191,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorVirusDetected(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorVirusDetected(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -8692,7 +10213,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorVirusMessageDeleted(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                class ErrorVirusMessageDeleted(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -8713,7 +10235,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorVoiceMailNotImplemented(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  class ErrorVoiceMailNotImplemented(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -8734,7 +10257,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorWebRequestInInvalidState(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    class ErrorWebRequestInInvalidState(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -8755,7 +10279,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorWin32InteropError(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      class ErrorWin32InteropError(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @@ -8776,7 +10301,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorWorkingHoursSaveFailed(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class ErrorWorkingHoursSaveFailed(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          @@ -8797,7 +10323,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorWorkingHoursXmlMalformed(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          class ErrorWorkingHoursXmlMalformed(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            @@ -8818,7 +10345,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorWrongServerVersion(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            class ErrorWrongServerVersion(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              @@ -8839,7 +10367,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorWrongServerVersionDelegate(ResponseMessageError): pass
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              class ErrorWrongServerVersionDelegate(ResponseMessageError):
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              +    pass

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                @@ -8868,7 +10397,7 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                super().__init__(str(self)) def __str__(self): - return f'{self.field_name!r} {self.value!r} must be one of {sorted(self.choices)}' + return f"{self.field_name!r} {self.value!r} must be one of {sorted(self.choices)}"

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  @@ -8895,7 +10424,7 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  super().__init__(str(self)) def __str__(self): - return f'{self.field_name!r} {self.value!r} must be of type {self.valid_type}' + return f"{self.field_name!r} {self.value!r} must be of type {self.valid_type}"

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    @@ -8984,8 +10513,10 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    self.total_wait = total_wait def __str__(self): - return f'{self.value} (gave up after {self.total_wait:.3f} seconds. ' \ - f'URL {self.url} returned status code {self.status_code})' + return ( + f"{self.value} (gave up after {self.total_wait:.3f} seconds. " + f"URL {self.url} returned status code {self.status_code})" + )

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      @@ -9010,11 +10541,11 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      parsed_url = urlparse(url) self.url = url self.server = parsed_url.hostname.lower() - self.has_ssl = parsed_url.scheme == 'https' + self.has_ssl = parsed_url.scheme == "https" super().__init__(str(self)) def __str__(self): - return f'We were redirected to {self.url}' + return f"We were redirected to {self.url}"

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        diff --git a/docs/exchangelib/ewsdatetime.html b/docs/exchangelib/ewsdatetime.html index 1da4981d..f10a28fe 100644 --- a/docs/exchangelib/ewsdatetime.html +++ b/docs/exchangelib/ewsdatetime.html @@ -33,9 +33,10 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Module exchangelib.ewsdatetime

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        import zoneinfo except ImportError: from backports import zoneinfo + import tzlocal -from .errors import NaiveDateTimeNotAllowed, UnknownTimeZone, InvalidTypeError +from .errors import InvalidTypeError, NaiveDateTimeNotAllowed, UnknownTimeZone from .winzone import IANA_TO_MS_TIMEZONE_MAP, MS_TIMEZONE_TO_IANA_MAP log = logging.getLogger(__name__) @@ -44,7 +45,7 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Module exchangelib.ewsdatetime

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class EWSDate(datetime.date): """Extends the normal date implementation to satisfy EWS.""" - __slots__ = '_year', '_month', '_day', '_hashcode' + __slots__ = "_year", "_month", "_day", "_hashcode" def ewsformat(self): """ISO 8601 format to satisfy xs:date as interpreted by EWS. Example: 2009-01-15.""" @@ -80,21 +81,21 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Module exchangelib.ewsdatetime

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @classmethod def from_date(cls, d): if type(d) is not datetime.date: - raise InvalidTypeError('d', d, datetime.date) + raise InvalidTypeError("d", d, datetime.date) return cls(d.year, d.month, d.day) @classmethod def from_string(cls, date_string): # Sometimes, we'll receive a date string with timezone information. Not very useful. - if date_string.endswith('Z'): - date_fmt = '%Y-%m-%dZ' - elif ':' in date_string: - if '+' in date_string: - date_fmt = '%Y-%m-%d+%H:%M' + if date_string.endswith("Z"): + date_fmt = "%Y-%m-%dZ" + elif ":" in date_string: + if "+" in date_string: + date_fmt = "%Y-%m-%d+%H:%M" else: - date_fmt = '%Y-%m-%d-%H:%M' + date_fmt = "%Y-%m-%d-%H:%M" else: - date_fmt = '%Y-%m-%d' + date_fmt = "%Y-%m-%d" d = datetime.datetime.strptime(date_string, date_fmt).date() if isinstance(d, cls): return d @@ -104,7 +105,7 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Module exchangelib.ewsdatetime

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        class EWSDateTime(datetime.datetime): """Extends the normal datetime implementation to satisfy EWS.""" - __slots__ = '_year', '_month', '_day', '_hour', '_minute', '_second', '_microsecond', '_tzinfo', '_hashcode' + __slots__ = "_year", "_month", "_day", "_hour", "_minute", "_second", "_microsecond", "_tzinfo", "_hashcode" def __new__(cls, *args, **kwargs): # pylint: disable=arguments-differ @@ -112,16 +113,16 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Module exchangelib.ewsdatetime

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        if len(args) == 8: tzinfo = args[7] else: - tzinfo = kwargs.get('tzinfo') + tzinfo = kwargs.get("tzinfo") if isinstance(tzinfo, zoneinfo.ZoneInfo): # Don't allow pytz or dateutil timezones here. They are not safe to use as direct input for datetime() tzinfo = EWSTimeZone.from_timezone(tzinfo) if not isinstance(tzinfo, (EWSTimeZone, type(None))): - raise InvalidTypeError('tzinfo', tzinfo, EWSTimeZone) + raise InvalidTypeError("tzinfo", tzinfo, EWSTimeZone) if len(args) == 8: args = args[:7] + (tzinfo,) else: - kwargs['tzinfo'] = tzinfo + kwargs["tzinfo"] = tzinfo return super().__new__(cls, *args, **kwargs) def ewsformat(self): @@ -130,17 +131,17 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Module exchangelib.ewsdatetime

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        * 2009-01-15T13:45:56+01:00 """ if not self.tzinfo: - raise ValueError(f'{self!r} must be timezone-aware') - if self.tzinfo.key == 'UTC': + raise ValueError(f"{self!r} must be timezone-aware") + if self.tzinfo.key == "UTC": if self.microsecond: - return self.strftime('%Y-%m-%dT%H:%M:%S.%fZ') - return self.strftime('%Y-%m-%dT%H:%M:%SZ') + return self.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + return self.strftime("%Y-%m-%dT%H:%M:%SZ") return self.isoformat() @classmethod def from_datetime(cls, d): if type(d) is not datetime.datetime: - raise InvalidTypeError('d', d, datetime.datetime) + raise InvalidTypeError("d", d, datetime.datetime) if d.tzinfo is None: tz = None elif isinstance(d.tzinfo, EWSTimeZone): @@ -180,12 +181,12 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Module exchangelib.ewsdatetime

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        @classmethod def from_string(cls, date_string): # Parses several common datetime formats and returns timezone-aware EWSDateTime objects - if date_string.endswith('Z'): + if date_string.endswith("Z"): # UTC datetime - return super().strptime(date_string, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=UTC) + return super().strptime(date_string, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) if len(date_string) == 19: # This is probably a naive datetime. Don't allow this, but signal caller with an appropriate error - local_dt = super().strptime(date_string, '%Y-%m-%dT%H:%M:%S') + local_dt = super().strptime(date_string, "%Y-%m-%dT%H:%M:%S") raise NaiveDateTimeNotAllowed(local_dt) # This is probably a datetime value with timezone information. This comes in the form '+/-HH:MM'. aware_dt = datetime.datetime.fromisoformat(date_string).astimezone(UTC).replace(tzinfo=UTC) @@ -244,12 +245,12 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Module exchangelib.ewsdatetime

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        try: instance.ms_id = cls.IANA_TO_MS_MAP[instance.key][0] except KeyError: - raise UnknownTimeZone(f'No Windows timezone name found for timezone {instance.key!r}') + raise UnknownTimeZone(f"No Windows timezone name found for timezone {instance.key!r}") # We don't need the Windows long-format timezone name in long format. It's used in timezone XML elements, but # EWS happily accepts empty strings. For a full list of timezones supported by the target server, including # long-format names, see output of services.GetServerTimeZones(account.protocol).call() - instance.ms_name = '' + instance.ms_name = "" return instance def __eq__(self, other): @@ -267,11 +268,11 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Module exchangelib.ewsdatetime

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        try: return cls(cls.MS_TO_IANA_MAP[ms_id]) except KeyError: - if '/' in ms_id: + if "/" in ms_id: # EWS sometimes returns an ID that has a region/location format, e.g. 'Europe/Copenhagen'. Try the # string unaltered. return cls(ms_id) - raise UnknownTimeZone(f'Windows timezone ID {ms_id!r} is unknown by CLDR') + raise UnknownTimeZone(f"Windows timezone ID {ms_id!r} is unknown by CLDR") @classmethod def from_pytz(cls, tz): @@ -286,8 +287,8 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Module exchangelib.ewsdatetime

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        def from_dateutil(cls, tz): # Objects returned by dateutil.tz.tzlocal() and dateutil.tz.gettz() are not supported. They # don't contain enough information to reliably match them with a CLDR timezone. - if hasattr(tz, '_filename'): - key = '/'.join(tz._filename.split('/')[-2:]) + if hasattr(tz, "_filename"): + key = "/".join(tz._filename.split("/")[-2:]) return cls(key) return cls(tz.tzname(datetime.datetime.now())) @@ -299,19 +300,19 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Module exchangelib.ewsdatetime

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        def from_timezone(cls, tz): # Support multiple tzinfo implementations. We could use isinstance(), but then we'd have to have pytz # and dateutil as dependencies for this package. - tz_module = tz.__class__.__module__.split('.')[0] + tz_module = tz.__class__.__module__.split(".")[0] try: return { - cls.__module__.split('.')[0]: lambda z: z, - 'backports': cls.from_zoneinfo, - 'datetime': cls.from_datetime, - 'dateutil': cls.from_dateutil, - 'pytz': cls.from_pytz, - 'zoneinfo': cls.from_zoneinfo, - 'pytz_deprecation_shim': lambda z: cls.from_timezone(z.unwrap_shim()) + cls.__module__.split(".")[0]: lambda z: z, + "backports": cls.from_zoneinfo, + "datetime": cls.from_datetime, + "dateutil": cls.from_dateutil, + "pytz": cls.from_pytz, + "zoneinfo": cls.from_zoneinfo, + "pytz_deprecation_shim": lambda z: cls.from_timezone(z.unwrap_shim()), }[tz_module](tz) except KeyError: - raise TypeError(f'Unsupported tzinfo type: {tz!r}') + raise TypeError(f"Unsupported tzinfo type: {tz!r}") @classmethod def localzone(cls): @@ -330,7 +331,7 @@

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        Module exchangelib.ewsdatetime

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        return EWSDateTime.from_datetime(t) # We want to return EWSDateTime objects -UTC = EWSTimeZone('UTC') +UTC = EWSTimeZone("UTC") UTC_NOW = lambda: EWSDateTime.now(tz=UTC) # noqa: E731
                              @@ -371,7 +372,7 @@

                              Classes

                              class EWSDate(datetime.date):
                                   """Extends the normal date implementation to satisfy EWS."""
                               
                              -    __slots__ = '_year', '_month', '_day', '_hashcode'
                              +    __slots__ = "_year", "_month", "_day", "_hashcode"
                               
                                   def ewsformat(self):
                                       """ISO 8601 format to satisfy xs:date as interpreted by EWS. Example: 2009-01-15."""
                              @@ -407,21 +408,21 @@ 

                              Classes

                              @classmethod def from_date(cls, d): if type(d) is not datetime.date: - raise InvalidTypeError('d', d, datetime.date) + raise InvalidTypeError("d", d, datetime.date) return cls(d.year, d.month, d.day) @classmethod def from_string(cls, date_string): # Sometimes, we'll receive a date string with timezone information. Not very useful. - if date_string.endswith('Z'): - date_fmt = '%Y-%m-%dZ' - elif ':' in date_string: - if '+' in date_string: - date_fmt = '%Y-%m-%d+%H:%M' + if date_string.endswith("Z"): + date_fmt = "%Y-%m-%dZ" + elif ":" in date_string: + if "+" in date_string: + date_fmt = "%Y-%m-%d+%H:%M" else: - date_fmt = '%Y-%m-%d-%H:%M' + date_fmt = "%Y-%m-%d-%H:%M" else: - date_fmt = '%Y-%m-%d' + date_fmt = "%Y-%m-%d" d = datetime.datetime.strptime(date_string, date_fmt).date() if isinstance(d, cls): return d @@ -445,7 +446,7 @@

                              Static methods

                              @classmethod
                               def from_date(cls, d):
                                   if type(d) is not datetime.date:
                              -        raise InvalidTypeError('d', d, datetime.date)
                              +        raise InvalidTypeError("d", d, datetime.date)
                                   return cls(d.year, d.month, d.day)
                              @@ -461,15 +462,15 @@

                              Static methods

                              @classmethod
                               def from_string(cls, date_string):
                                   # Sometimes, we'll receive a date string with timezone information. Not very useful.
                              -    if date_string.endswith('Z'):
                              -        date_fmt = '%Y-%m-%dZ'
                              -    elif ':' in date_string:
                              -        if '+' in date_string:
                              -            date_fmt = '%Y-%m-%d+%H:%M'
                              +    if date_string.endswith("Z"):
                              +        date_fmt = "%Y-%m-%dZ"
                              +    elif ":" in date_string:
                              +        if "+" in date_string:
                              +            date_fmt = "%Y-%m-%d+%H:%M"
                                       else:
                              -            date_fmt = '%Y-%m-%d-%H:%M'
                              +            date_fmt = "%Y-%m-%d-%H:%M"
                                   else:
                              -        date_fmt = '%Y-%m-%d'
                              +        date_fmt = "%Y-%m-%d"
                                   d = datetime.datetime.strptime(date_string, date_fmt).date()
                                   if isinstance(d, cls):
                                       return d
                              @@ -525,7 +526,7 @@ 

                              Methods

                              class EWSDateTime(datetime.datetime):
                                   """Extends the normal datetime implementation to satisfy EWS."""
                               
                              -    __slots__ = '_year', '_month', '_day', '_hour', '_minute', '_second', '_microsecond', '_tzinfo', '_hashcode'
                              +    __slots__ = "_year", "_month", "_day", "_hour", "_minute", "_second", "_microsecond", "_tzinfo", "_hashcode"
                               
                                   def __new__(cls, *args, **kwargs):
                                       # pylint: disable=arguments-differ
                              @@ -533,16 +534,16 @@ 

                              Methods

                              if len(args) == 8: tzinfo = args[7] else: - tzinfo = kwargs.get('tzinfo') + tzinfo = kwargs.get("tzinfo") if isinstance(tzinfo, zoneinfo.ZoneInfo): # Don't allow pytz or dateutil timezones here. They are not safe to use as direct input for datetime() tzinfo = EWSTimeZone.from_timezone(tzinfo) if not isinstance(tzinfo, (EWSTimeZone, type(None))): - raise InvalidTypeError('tzinfo', tzinfo, EWSTimeZone) + raise InvalidTypeError("tzinfo", tzinfo, EWSTimeZone) if len(args) == 8: args = args[:7] + (tzinfo,) else: - kwargs['tzinfo'] = tzinfo + kwargs["tzinfo"] = tzinfo return super().__new__(cls, *args, **kwargs) def ewsformat(self): @@ -551,17 +552,17 @@

                              Methods

                              * 2009-01-15T13:45:56+01:00 """ if not self.tzinfo: - raise ValueError(f'{self!r} must be timezone-aware') - if self.tzinfo.key == 'UTC': + raise ValueError(f"{self!r} must be timezone-aware") + if self.tzinfo.key == "UTC": if self.microsecond: - return self.strftime('%Y-%m-%dT%H:%M:%S.%fZ') - return self.strftime('%Y-%m-%dT%H:%M:%SZ') + return self.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + return self.strftime("%Y-%m-%dT%H:%M:%SZ") return self.isoformat() @classmethod def from_datetime(cls, d): if type(d) is not datetime.datetime: - raise InvalidTypeError('d', d, datetime.datetime) + raise InvalidTypeError("d", d, datetime.datetime) if d.tzinfo is None: tz = None elif isinstance(d.tzinfo, EWSTimeZone): @@ -601,12 +602,12 @@

                              Methods

                              @classmethod def from_string(cls, date_string): # Parses several common datetime formats and returns timezone-aware EWSDateTime objects - if date_string.endswith('Z'): + if date_string.endswith("Z"): # UTC datetime - return super().strptime(date_string, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=UTC) + return super().strptime(date_string, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) if len(date_string) == 19: # This is probably a naive datetime. Don't allow this, but signal caller with an appropriate error - local_dt = super().strptime(date_string, '%Y-%m-%dT%H:%M:%S') + local_dt = super().strptime(date_string, "%Y-%m-%dT%H:%M:%S") raise NaiveDateTimeNotAllowed(local_dt) # This is probably a datetime value with timezone information. This comes in the form '+/-HH:MM'. aware_dt = datetime.datetime.fromisoformat(date_string).astimezone(UTC).replace(tzinfo=UTC) @@ -667,7 +668,7 @@

                              Static methods

                              @classmethod
                               def from_datetime(cls, d):
                                   if type(d) is not datetime.datetime:
                              -        raise InvalidTypeError('d', d, datetime.datetime)
                              +        raise InvalidTypeError("d", d, datetime.datetime)
                                   if d.tzinfo is None:
                                       tz = None
                                   elif isinstance(d.tzinfo, EWSTimeZone):
                              @@ -689,12 +690,12 @@ 

                              Static methods

                              @classmethod
                               def from_string(cls, date_string):
                                   # Parses several common datetime formats and returns timezone-aware EWSDateTime objects
                              -    if date_string.endswith('Z'):
                              +    if date_string.endswith("Z"):
                                       # UTC datetime
                              -        return super().strptime(date_string, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=UTC)
                              +        return super().strptime(date_string, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
                                   if len(date_string) == 19:
                                       # This is probably a naive datetime. Don't allow this, but signal caller with an appropriate error
                              -        local_dt = super().strptime(date_string, '%Y-%m-%dT%H:%M:%S')
                              +        local_dt = super().strptime(date_string, "%Y-%m-%dT%H:%M:%S")
                                       raise NaiveDateTimeNotAllowed(local_dt)
                                   # This is probably a datetime value with timezone information. This comes in the form '+/-HH:MM'.
                                   aware_dt = datetime.datetime.fromisoformat(date_string).astimezone(UTC).replace(tzinfo=UTC)
                              @@ -828,11 +829,11 @@ 

                              Methods

                              * 2009-01-15T13:45:56+01:00 """ if not self.tzinfo: - raise ValueError(f'{self!r} must be timezone-aware') - if self.tzinfo.key == 'UTC': + raise ValueError(f"{self!r} must be timezone-aware") + if self.tzinfo.key == "UTC": if self.microsecond: - return self.strftime('%Y-%m-%dT%H:%M:%S.%fZ') - return self.strftime('%Y-%m-%dT%H:%M:%SZ') + return self.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + return self.strftime("%Y-%m-%dT%H:%M:%SZ") return self.isoformat()
                              @@ -865,12 +866,12 @@

                              Methods

                              try: instance.ms_id = cls.IANA_TO_MS_MAP[instance.key][0] except KeyError: - raise UnknownTimeZone(f'No Windows timezone name found for timezone {instance.key!r}') + raise UnknownTimeZone(f"No Windows timezone name found for timezone {instance.key!r}") # We don't need the Windows long-format timezone name in long format. It's used in timezone XML elements, but # EWS happily accepts empty strings. For a full list of timezones supported by the target server, including # long-format names, see output of services.GetServerTimeZones(account.protocol).call() - instance.ms_name = '' + instance.ms_name = "" return instance def __eq__(self, other): @@ -888,11 +889,11 @@

                              Methods

                              try: return cls(cls.MS_TO_IANA_MAP[ms_id]) except KeyError: - if '/' in ms_id: + if "/" in ms_id: # EWS sometimes returns an ID that has a region/location format, e.g. 'Europe/Copenhagen'. Try the # string unaltered. return cls(ms_id) - raise UnknownTimeZone(f'Windows timezone ID {ms_id!r} is unknown by CLDR') + raise UnknownTimeZone(f"Windows timezone ID {ms_id!r} is unknown by CLDR") @classmethod def from_pytz(cls, tz): @@ -907,8 +908,8 @@

                              Methods

                              def from_dateutil(cls, tz): # Objects returned by dateutil.tz.tzlocal() and dateutil.tz.gettz() are not supported. They # don't contain enough information to reliably match them with a CLDR timezone. - if hasattr(tz, '_filename'): - key = '/'.join(tz._filename.split('/')[-2:]) + if hasattr(tz, "_filename"): + key = "/".join(tz._filename.split("/")[-2:]) return cls(key) return cls(tz.tzname(datetime.datetime.now())) @@ -920,19 +921,19 @@

                              Methods

                              def from_timezone(cls, tz): # Support multiple tzinfo implementations. We could use isinstance(), but then we'd have to have pytz # and dateutil as dependencies for this package. - tz_module = tz.__class__.__module__.split('.')[0] + tz_module = tz.__class__.__module__.split(".")[0] try: return { - cls.__module__.split('.')[0]: lambda z: z, - 'backports': cls.from_zoneinfo, - 'datetime': cls.from_datetime, - 'dateutil': cls.from_dateutil, - 'pytz': cls.from_pytz, - 'zoneinfo': cls.from_zoneinfo, - 'pytz_deprecation_shim': lambda z: cls.from_timezone(z.unwrap_shim()) + cls.__module__.split(".")[0]: lambda z: z, + "backports": cls.from_zoneinfo, + "datetime": cls.from_datetime, + "dateutil": cls.from_dateutil, + "pytz": cls.from_pytz, + "zoneinfo": cls.from_zoneinfo, + "pytz_deprecation_shim": lambda z: cls.from_timezone(z.unwrap_shim()), }[tz_module](tz) except KeyError: - raise TypeError(f'Unsupported tzinfo type: {tz!r}') + raise TypeError(f"Unsupported tzinfo type: {tz!r}") @classmethod def localzone(cls): @@ -996,8 +997,8 @@

                              Static methods

                              def from_dateutil(cls, tz): # Objects returned by dateutil.tz.tzlocal() and dateutil.tz.gettz() are not supported. They # don't contain enough information to reliably match them with a CLDR timezone. - if hasattr(tz, '_filename'): - key = '/'.join(tz._filename.split('/')[-2:]) + if hasattr(tz, "_filename"): + key = "/".join(tz._filename.split("/")[-2:]) return cls(key) return cls(tz.tzname(datetime.datetime.now()))
                              @@ -1018,11 +1019,11 @@

                              Static methods

                              try: return cls(cls.MS_TO_IANA_MAP[ms_id]) except KeyError: - if '/' in ms_id: + if "/" in ms_id: # EWS sometimes returns an ID that has a region/location format, e.g. 'Europe/Copenhagen'. Try the # string unaltered. return cls(ms_id) - raise UnknownTimeZone(f'Windows timezone ID {ms_id!r} is unknown by CLDR')
                              + raise UnknownTimeZone(f"Windows timezone ID {ms_id!r} is unknown by CLDR")
                              @@ -1052,19 +1053,19 @@

                              Static methods

                              def from_timezone(cls, tz): # Support multiple tzinfo implementations. We could use isinstance(), but then we'd have to have pytz # and dateutil as dependencies for this package. - tz_module = tz.__class__.__module__.split('.')[0] + tz_module = tz.__class__.__module__.split(".")[0] try: return { - cls.__module__.split('.')[0]: lambda z: z, - 'backports': cls.from_zoneinfo, - 'datetime': cls.from_datetime, - 'dateutil': cls.from_dateutil, - 'pytz': cls.from_pytz, - 'zoneinfo': cls.from_zoneinfo, - 'pytz_deprecation_shim': lambda z: cls.from_timezone(z.unwrap_shim()) + cls.__module__.split(".")[0]: lambda z: z, + "backports": cls.from_zoneinfo, + "datetime": cls.from_datetime, + "dateutil": cls.from_dateutil, + "pytz": cls.from_pytz, + "zoneinfo": cls.from_zoneinfo, + "pytz_deprecation_shim": lambda z: cls.from_timezone(z.unwrap_shim()), }[tz_module](tz) except KeyError: - raise TypeError(f'Unsupported tzinfo type: {tz!r}')
                              + raise TypeError(f"Unsupported tzinfo type: {tz!r}")
                              diff --git a/docs/exchangelib/extended_properties.html b/docs/exchangelib/extended_properties.html index f085765f..0a9595d8 100644 --- a/docs/exchangelib/extended_properties.html +++ b/docs/exchangelib/extended_properties.html @@ -32,8 +32,17 @@

                              Module exchangelib.extended_properties

                              from .errors import InvalidEnumValue from .ewsdatetime import EWSDateTime from .properties import EWSElement, ExtendedFieldURI -from .util import create_element, add_xml_child, get_xml_attrs, get_xml_attr, set_xml_value, value_to_xml_text, \ - xml_text_to_value, is_iterable, TNS +from .util import ( + TNS, + add_xml_child, + create_element, + get_xml_attr, + get_xml_attrs, + is_iterable, + set_xml_value, + value_to_xml_text, + xml_text_to_value, +) log = logging.getLogger(__name__) @@ -41,72 +50,73 @@

                              Module exchangelib.extended_properties

                              class ExtendedProperty(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/extendedproperty""" - ELEMENT_NAME = 'ExtendedProperty' + ELEMENT_NAME = "ExtendedProperty" # Enum values: https://docs.microsoft.com/en-us/dotnet/api/exchangewebservices.distinguishedpropertysettype DISTINGUISHED_SETS = { - 'Address', - 'Appointment', - 'CalendarAssistant', - 'Common', - 'InternetHeaders', - 'Meeting', - 'PublicStrings', - 'Sharing', - 'Task', - 'UnifiedMessaging', + "Address", + "Appointment", + "CalendarAssistant", + "Common", + "InternetHeaders", + "Meeting", + "PublicStrings", + "Sharing", + "Task", + "UnifiedMessaging", } # Enum values: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/extendedfielduri + # The following types cannot be used for setting or getting (see docs) and are thus not very useful here: + # 'Error' + # 'Null' + # 'Object' + # 'ObjectArray' PROPERTY_TYPES = { - 'ApplicationTime', - 'Binary', - 'BinaryArray', - 'Boolean', - 'CLSID', - 'CLSIDArray', - 'Currency', - 'CurrencyArray', - 'Double', - 'DoubleArray', - # 'Error', - 'Float', - 'FloatArray', - 'Integer', - 'IntegerArray', - 'Long', - 'LongArray', - # 'Null', - # 'Object', - # 'ObjectArray', - 'Short', - 'ShortArray', - 'SystemTime', - 'SystemTimeArray', - 'String', - 'StringArray', - } # The commented-out types cannot be used for setting or getting (see docs) and are thus not very useful here + "ApplicationTime", + "Binary", + "BinaryArray", + "Boolean", + "CLSID", + "CLSIDArray", + "Currency", + "CurrencyArray", + "Double", + "DoubleArray", + "Float", + "FloatArray", + "Integer", + "IntegerArray", + "Long", + "LongArray", + "Short", + "ShortArray", + "SystemTime", + "SystemTimeArray", + "String", + "StringArray", + } # Translation table between common distinguished_property_set_id and property_set_id values. See # https://docs.microsoft.com/en-us/office/client-developer/outlook/mapi/commonly-used-property-sets # ID values must be lowercase. DISTINGUISHED_SET_NAME_TO_ID_MAP = { - 'Address': '00062004-0000-0000-c000-000000000046', - 'AirSync': '71035549-0739-4dcb-9163-00f0580dbbdf', - 'Appointment': '00062002-0000-0000-c000-000000000046', - 'Common': '00062008-0000-0000-c000-000000000046', - 'InternetHeaders': '00020386-0000-0000-c000-000000000046', - 'Log': '0006200a-0000-0000-c000-000000000046', - 'Mapi': '00020328-0000-0000-c000-000000000046', - 'Meeting': '6ed8da90-450b-101b-98da-00aa003f1305', - 'Messaging': '41f28f13-83f4-4114-a584-eedb5a6b0bff', - 'Note': '0006200e-0000-0000-c000-000000000046', - 'PostRss': '00062041-0000-0000-c000-000000000046', - 'PublicStrings': '00020329-0000-0000-c000-000000000046', - 'Remote': '00062014-0000-0000-c000-000000000046', - 'Report': '00062013-0000-0000-c000-000000000046', - 'Sharing': '00062040-0000-0000-c000-000000000046', - 'Task': '00062003-0000-0000-c000-000000000046', - 'UnifiedMessaging': '4442858e-a9e3-4e80-b900-317a210cc15b', + "Address": "00062004-0000-0000-c000-000000000046", + "AirSync": "71035549-0739-4dcb-9163-00f0580dbbdf", + "Appointment": "00062002-0000-0000-c000-000000000046", + "Common": "00062008-0000-0000-c000-000000000046", + "InternetHeaders": "00020386-0000-0000-c000-000000000046", + "Log": "0006200a-0000-0000-c000-000000000046", + "Mapi": "00020328-0000-0000-c000-000000000046", + "Meeting": "6ed8da90-450b-101b-98da-00aa003f1305", + "Messaging": "41f28f13-83f4-4114-a584-eedb5a6b0bff", + "Note": "0006200e-0000-0000-c000-000000000046", + "PostRss": "00062041-0000-0000-c000-000000000046", + "PublicStrings": "00020329-0000-0000-c000-000000000046", + "Remote": "00062014-0000-0000-c000-000000000046", + "Report": "00062013-0000-0000-c000-000000000046", + "Sharing": "00062040-0000-0000-c000-000000000046", + "Task": "00062003-0000-0000-c000-000000000046", + "UnifiedMessaging": "4442858e-a9e3-4e80-b900-317a210cc15b", } DISTINGUISHED_SET_ID_TO_NAME_MAP = {v: k for k, v in DISTINGUISHED_SET_NAME_TO_ID_MAP.items()} @@ -115,15 +125,15 @@

                              Module exchangelib.extended_properties

                              property_tag = None # hex integer (e.g. 0x8000) or string ('0x8000') property_name = None property_id = None # integer as hex-formatted int (e.g. 0x8000) or normal int (32768) - property_type = '' + property_type = "" - __slots__ = 'value', + __slots__ = ("value",) def __init__(self, *args, **kwargs): if not kwargs: # Allow to set attributes without keyword kwargs = dict(zip(self._slots_keys, args)) - self.value = kwargs.pop('value') + self.value = kwargs.pop("value") super().__init__(**kwargs) @classmethod @@ -149,7 +159,7 @@

                              Module exchangelib.extended_properties

                              ) if cls.distinguished_property_set_id not in cls.DISTINGUISHED_SETS: raise InvalidEnumValue( - 'distinguished_property_set_id', cls.distinguished_property_set_id, cls.DISTINGUISHED_SETS + "distinguished_property_set_id", cls.distinguished_property_set_id, cls.DISTINGUISHED_SETS ) @classmethod @@ -160,16 +170,12 @@

                              Module exchangelib.extended_properties

                              "When 'property_set_id' is set, 'distinguished_property_set_id' and 'property_tag' must be None" ) if not any([cls.property_id, cls.property_name]): - raise ValueError( - "When 'property_set_id' is set, 'property_id' or 'property_name' must also be set" - ) + raise ValueError("When 'property_set_id' is set, 'property_id' or 'property_name' must also be set") @classmethod def _validate_property_tag(cls): if cls.property_tag: - if any([ - cls.distinguished_property_set_id, cls.property_set_id, cls.property_name, cls.property_id - ]): + if any([cls.distinguished_property_set_id, cls.property_set_id, cls.property_name, cls.property_id]): raise ValueError("When 'property_tag' is set, only 'property_type' must be set") if 0x8000 <= cls.property_tag_as_int() <= 0xFFFE: raise ValueError( @@ -199,7 +205,7 @@

                              Module exchangelib.extended_properties

                              @classmethod def _validate_property_type(cls): if cls.property_type not in cls.PROPERTY_TYPES: - raise InvalidEnumValue('property_type', cls.property_type, cls.PROPERTY_TYPES) + raise InvalidEnumValue("property_type", cls.property_type, cls.PROPERTY_TYPES) def clean(self, version=None): self.validate_cls() @@ -247,30 +253,29 @@

                              Module exchangelib.extended_properties

                              # Gets value of this specific ExtendedProperty from a list of 'ExtendedProperty' XML elements python_type = cls.python_type() if cls.is_array_type(): - values = elem.find(f'{{{TNS}}}Values') + values = elem.find(f"{{{TNS}}}Values") return [ - xml_text_to_value(value=val, value_type=python_type) - for val in get_xml_attrs(values, f'{{{TNS}}}Value') + xml_text_to_value(value=val, value_type=python_type) for val in get_xml_attrs(values, f"{{{TNS}}}Value") ] - extended_field_value = xml_text_to_value(value=get_xml_attr(elem, f'{{{TNS}}}Value'), value_type=python_type) + extended_field_value = xml_text_to_value(value=get_xml_attr(elem, f"{{{TNS}}}Value"), value_type=python_type) if python_type == str and not extended_field_value: # For string types, we want to return the empty string instead of None if the element was # actually found, but there was no XML value. For other types, it would be more problematic # to make that distinction, e.g. return False for bool, 0 for int, etc. - return '' + return "" return extended_field_value def to_xml(self, version): if self.is_array_type(): - values = create_element('t:Values') + values = create_element("t:Values") for v in self.value: - add_xml_child(values, 't:Value', v) + add_xml_child(values, "t:Value", v) return values - return set_xml_value(create_element('t:Value'), self.value, version=version) + return set_xml_value(create_element("t:Value"), self.value, version=version) @classmethod def is_array_type(cls): - return cls.property_type.endswith('Array') + return cls.property_type.endswith("Array") @classmethod def property_tag_as_int(cls): @@ -287,18 +292,18 @@

                              Module exchangelib.extended_properties

                              # Return the best equivalent for a Python type for the property type of this class base_type = cls.property_type[:-5] if cls.is_array_type() else cls.property_type return { - 'ApplicationTime': Decimal, - 'Binary': bytes, - 'Boolean': bool, - 'CLSID': str, - 'Currency': int, - 'Double': Decimal, - 'Float': Decimal, - 'Integer': int, - 'Long': int, - 'Short': int, - 'SystemTime': EWSDateTime, - 'String': str, + "ApplicationTime": Decimal, + "Binary": bytes, + "Boolean": bool, + "CLSID": str, + "Currency": int, + "Double": Decimal, + "Float": Decimal, + "Integer": int, + "Long": int, + "Short": int, + "SystemTime": EWSDateTime, + "String": str, }[base_type] @classmethod @@ -319,9 +324,9 @@

                              Module exchangelib.extended_properties

                              from an external system. """ - property_set_id = 'c11ff724-aa03-4555-9952-8fa248a11c3e' # This is arbitrary. We just want a unique UUID. - property_name = 'External ID' - property_type = 'String' + property_set_id = "c11ff724-aa03-4555-9952-8fa248a11c3e" # This is arbitrary. We just want a unique UUID. + property_name = "External ID" + property_type = "String" class Flag(ExtendedProperty): @@ -332,7 +337,7 @@

                              Module exchangelib.extended_properties

                              """ property_tag = 0x1090 - property_type = 'Integer'
                              + property_type = "Integer"
                              @@ -357,72 +362,73 @@

                              Classes

                              class ExtendedProperty(EWSElement):
                                   """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/extendedproperty"""
                               
                              -    ELEMENT_NAME = 'ExtendedProperty'
                              +    ELEMENT_NAME = "ExtendedProperty"
                               
                                   # Enum values: https://docs.microsoft.com/en-us/dotnet/api/exchangewebservices.distinguishedpropertysettype
                                   DISTINGUISHED_SETS = {
                              -        'Address',
                              -        'Appointment',
                              -        'CalendarAssistant',
                              -        'Common',
                              -        'InternetHeaders',
                              -        'Meeting',
                              -        'PublicStrings',
                              -        'Sharing',
                              -        'Task',
                              -        'UnifiedMessaging',
                              +        "Address",
                              +        "Appointment",
                              +        "CalendarAssistant",
                              +        "Common",
                              +        "InternetHeaders",
                              +        "Meeting",
                              +        "PublicStrings",
                              +        "Sharing",
                              +        "Task",
                              +        "UnifiedMessaging",
                                   }
                                   # Enum values: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/extendedfielduri
                              +    # The following types cannot be used for setting or getting (see docs) and are thus not very useful here:
                              +    # 'Error'
                              +    # 'Null'
                              +    # 'Object'
                              +    # 'ObjectArray'
                                   PROPERTY_TYPES = {
                              -        'ApplicationTime',
                              -        'Binary',
                              -        'BinaryArray',
                              -        'Boolean',
                              -        'CLSID',
                              -        'CLSIDArray',
                              -        'Currency',
                              -        'CurrencyArray',
                              -        'Double',
                              -        'DoubleArray',
                              -        # 'Error',
                              -        'Float',
                              -        'FloatArray',
                              -        'Integer',
                              -        'IntegerArray',
                              -        'Long',
                              -        'LongArray',
                              -        # 'Null',
                              -        # 'Object',
                              -        # 'ObjectArray',
                              -        'Short',
                              -        'ShortArray',
                              -        'SystemTime',
                              -        'SystemTimeArray',
                              -        'String',
                              -        'StringArray',
                              -    }  # The commented-out types cannot be used for setting or getting (see docs) and are thus not very useful here
                              +        "ApplicationTime",
                              +        "Binary",
                              +        "BinaryArray",
                              +        "Boolean",
                              +        "CLSID",
                              +        "CLSIDArray",
                              +        "Currency",
                              +        "CurrencyArray",
                              +        "Double",
                              +        "DoubleArray",
                              +        "Float",
                              +        "FloatArray",
                              +        "Integer",
                              +        "IntegerArray",
                              +        "Long",
                              +        "LongArray",
                              +        "Short",
                              +        "ShortArray",
                              +        "SystemTime",
                              +        "SystemTimeArray",
                              +        "String",
                              +        "StringArray",
                              +    }
                               
                                   # Translation table between common distinguished_property_set_id and property_set_id values. See
                                   # https://docs.microsoft.com/en-us/office/client-developer/outlook/mapi/commonly-used-property-sets
                                   # ID values must be lowercase.
                                   DISTINGUISHED_SET_NAME_TO_ID_MAP = {
                              -        'Address': '00062004-0000-0000-c000-000000000046',
                              -        'AirSync': '71035549-0739-4dcb-9163-00f0580dbbdf',
                              -        'Appointment': '00062002-0000-0000-c000-000000000046',
                              -        'Common': '00062008-0000-0000-c000-000000000046',
                              -        'InternetHeaders': '00020386-0000-0000-c000-000000000046',
                              -        'Log': '0006200a-0000-0000-c000-000000000046',
                              -        'Mapi': '00020328-0000-0000-c000-000000000046',
                              -        'Meeting': '6ed8da90-450b-101b-98da-00aa003f1305',
                              -        'Messaging': '41f28f13-83f4-4114-a584-eedb5a6b0bff',
                              -        'Note': '0006200e-0000-0000-c000-000000000046',
                              -        'PostRss': '00062041-0000-0000-c000-000000000046',
                              -        'PublicStrings': '00020329-0000-0000-c000-000000000046',
                              -        'Remote': '00062014-0000-0000-c000-000000000046',
                              -        'Report': '00062013-0000-0000-c000-000000000046',
                              -        'Sharing': '00062040-0000-0000-c000-000000000046',
                              -        'Task': '00062003-0000-0000-c000-000000000046',
                              -        'UnifiedMessaging': '4442858e-a9e3-4e80-b900-317a210cc15b',
                              +        "Address": "00062004-0000-0000-c000-000000000046",
                              +        "AirSync": "71035549-0739-4dcb-9163-00f0580dbbdf",
                              +        "Appointment": "00062002-0000-0000-c000-000000000046",
                              +        "Common": "00062008-0000-0000-c000-000000000046",
                              +        "InternetHeaders": "00020386-0000-0000-c000-000000000046",
                              +        "Log": "0006200a-0000-0000-c000-000000000046",
                              +        "Mapi": "00020328-0000-0000-c000-000000000046",
                              +        "Meeting": "6ed8da90-450b-101b-98da-00aa003f1305",
                              +        "Messaging": "41f28f13-83f4-4114-a584-eedb5a6b0bff",
                              +        "Note": "0006200e-0000-0000-c000-000000000046",
                              +        "PostRss": "00062041-0000-0000-c000-000000000046",
                              +        "PublicStrings": "00020329-0000-0000-c000-000000000046",
                              +        "Remote": "00062014-0000-0000-c000-000000000046",
                              +        "Report": "00062013-0000-0000-c000-000000000046",
                              +        "Sharing": "00062040-0000-0000-c000-000000000046",
                              +        "Task": "00062003-0000-0000-c000-000000000046",
                              +        "UnifiedMessaging": "4442858e-a9e3-4e80-b900-317a210cc15b",
                                   }
                                   DISTINGUISHED_SET_ID_TO_NAME_MAP = {v: k for k, v in DISTINGUISHED_SET_NAME_TO_ID_MAP.items()}
                               
                              @@ -431,15 +437,15 @@ 

                              Classes

                              property_tag = None # hex integer (e.g. 0x8000) or string ('0x8000') property_name = None property_id = None # integer as hex-formatted int (e.g. 0x8000) or normal int (32768) - property_type = '' + property_type = "" - __slots__ = 'value', + __slots__ = ("value",) def __init__(self, *args, **kwargs): if not kwargs: # Allow to set attributes without keyword kwargs = dict(zip(self._slots_keys, args)) - self.value = kwargs.pop('value') + self.value = kwargs.pop("value") super().__init__(**kwargs) @classmethod @@ -465,7 +471,7 @@

                              Classes

                              ) if cls.distinguished_property_set_id not in cls.DISTINGUISHED_SETS: raise InvalidEnumValue( - 'distinguished_property_set_id', cls.distinguished_property_set_id, cls.DISTINGUISHED_SETS + "distinguished_property_set_id", cls.distinguished_property_set_id, cls.DISTINGUISHED_SETS ) @classmethod @@ -476,16 +482,12 @@

                              Classes

                              "When 'property_set_id' is set, 'distinguished_property_set_id' and 'property_tag' must be None" ) if not any([cls.property_id, cls.property_name]): - raise ValueError( - "When 'property_set_id' is set, 'property_id' or 'property_name' must also be set" - ) + raise ValueError("When 'property_set_id' is set, 'property_id' or 'property_name' must also be set") @classmethod def _validate_property_tag(cls): if cls.property_tag: - if any([ - cls.distinguished_property_set_id, cls.property_set_id, cls.property_name, cls.property_id - ]): + if any([cls.distinguished_property_set_id, cls.property_set_id, cls.property_name, cls.property_id]): raise ValueError("When 'property_tag' is set, only 'property_type' must be set") if 0x8000 <= cls.property_tag_as_int() <= 0xFFFE: raise ValueError( @@ -515,7 +517,7 @@

                              Classes

                              @classmethod def _validate_property_type(cls): if cls.property_type not in cls.PROPERTY_TYPES: - raise InvalidEnumValue('property_type', cls.property_type, cls.PROPERTY_TYPES) + raise InvalidEnumValue("property_type", cls.property_type, cls.PROPERTY_TYPES) def clean(self, version=None): self.validate_cls() @@ -563,30 +565,29 @@

                              Classes

                              # Gets value of this specific ExtendedProperty from a list of 'ExtendedProperty' XML elements python_type = cls.python_type() if cls.is_array_type(): - values = elem.find(f'{{{TNS}}}Values') + values = elem.find(f"{{{TNS}}}Values") return [ - xml_text_to_value(value=val, value_type=python_type) - for val in get_xml_attrs(values, f'{{{TNS}}}Value') + xml_text_to_value(value=val, value_type=python_type) for val in get_xml_attrs(values, f"{{{TNS}}}Value") ] - extended_field_value = xml_text_to_value(value=get_xml_attr(elem, f'{{{TNS}}}Value'), value_type=python_type) + extended_field_value = xml_text_to_value(value=get_xml_attr(elem, f"{{{TNS}}}Value"), value_type=python_type) if python_type == str and not extended_field_value: # For string types, we want to return the empty string instead of None if the element was # actually found, but there was no XML value. For other types, it would be more problematic # to make that distinction, e.g. return False for bool, 0 for int, etc. - return '' + return "" return extended_field_value def to_xml(self, version): if self.is_array_type(): - values = create_element('t:Values') + values = create_element("t:Values") for v in self.value: - add_xml_child(values, 't:Value', v) + add_xml_child(values, "t:Value", v) return values - return set_xml_value(create_element('t:Value'), self.value, version=version) + return set_xml_value(create_element("t:Value"), self.value, version=version) @classmethod def is_array_type(cls): - return cls.property_type.endswith('Array') + return cls.property_type.endswith("Array") @classmethod def property_tag_as_int(cls): @@ -603,18 +604,18 @@

                              Classes

                              # Return the best equivalent for a Python type for the property type of this class base_type = cls.property_type[:-5] if cls.is_array_type() else cls.property_type return { - 'ApplicationTime': Decimal, - 'Binary': bytes, - 'Boolean': bool, - 'CLSID': str, - 'Currency': int, - 'Double': Decimal, - 'Float': Decimal, - 'Integer': int, - 'Long': int, - 'Short': int, - 'SystemTime': EWSDateTime, - 'String': str, + "ApplicationTime": Decimal, + "Binary": bytes, + "Boolean": bool, + "CLSID": str, + "Currency": int, + "Double": Decimal, + "Float": Decimal, + "Integer": int, + "Long": int, + "Short": int, + "SystemTime": EWSDateTime, + "String": str, }[base_type] @classmethod @@ -723,17 +724,16 @@

                              Static methods

                              # Gets value of this specific ExtendedProperty from a list of 'ExtendedProperty' XML elements python_type = cls.python_type() if cls.is_array_type(): - values = elem.find(f'{{{TNS}}}Values') + values = elem.find(f"{{{TNS}}}Values") return [ - xml_text_to_value(value=val, value_type=python_type) - for val in get_xml_attrs(values, f'{{{TNS}}}Value') + xml_text_to_value(value=val, value_type=python_type) for val in get_xml_attrs(values, f"{{{TNS}}}Value") ] - extended_field_value = xml_text_to_value(value=get_xml_attr(elem, f'{{{TNS}}}Value'), value_type=python_type) + extended_field_value = xml_text_to_value(value=get_xml_attr(elem, f"{{{TNS}}}Value"), value_type=python_type) if python_type == str and not extended_field_value: # For string types, we want to return the empty string instead of None if the element was # actually found, but there was no XML value. For other types, it would be more problematic # to make that distinction, e.g. return False for bool, 0 for int, etc. - return '' + return "" return extended_field_value
                              @@ -748,7 +748,7 @@

                              Static methods

                              @classmethod
                               def is_array_type(cls):
                              -    return cls.property_type.endswith('Array')
                              + return cls.property_type.endswith("Array")
                              @@ -822,18 +822,18 @@

                              Static methods

                              # Return the best equivalent for a Python type for the property type of this class base_type = cls.property_type[:-5] if cls.is_array_type() else cls.property_type return { - 'ApplicationTime': Decimal, - 'Binary': bytes, - 'Boolean': bool, - 'CLSID': str, - 'Currency': int, - 'Double': Decimal, - 'Float': Decimal, - 'Integer': int, - 'Long': int, - 'Short': int, - 'SystemTime': EWSDateTime, - 'String': str, + "ApplicationTime": Decimal, + "Binary": bytes, + "Boolean": bool, + "CLSID": str, + "Currency": int, + "Double": Decimal, + "Float": Decimal, + "Integer": int, + "Long": int, + "Short": int, + "SystemTime": EWSDateTime, + "String": str, }[base_type]
                              @@ -901,11 +901,11 @@

                              Methods

                              def to_xml(self, version):
                                   if self.is_array_type():
                              -        values = create_element('t:Values')
                              +        values = create_element("t:Values")
                                       for v in self.value:
                              -            add_xml_child(values, 't:Value', v)
                              +            add_xml_child(values, "t:Value", v)
                                       return values
                              -    return set_xml_value(create_element('t:Value'), self.value, version=version)
                              + return set_xml_value(create_element("t:Value"), self.value, version=version)
            @@ -937,9 +937,9 @@

            Inherited members

            from an external system. """ - property_set_id = 'c11ff724-aa03-4555-9952-8fa248a11c3e' # This is arbitrary. We just want a unique UUID. - property_name = 'External ID' - property_type = 'String' + property_set_id = "c11ff724-aa03-4555-9952-8fa248a11c3e" # This is arbitrary. We just want a unique UUID. + property_name = "External ID" + property_type = "String"

            Ancestors

              @@ -995,7 +995,7 @@

              Inherited members

              """ property_tag = 0x1090 - property_type = 'Integer' + property_type = "Integer"

              Ancestors

                diff --git a/docs/exchangelib/fields.html b/docs/exchangelib/fields.html index f49e88ea..e67f562f 100644 --- a/docs/exchangelib/fields.html +++ b/docs/exchangelib/fields.html @@ -33,52 +33,60 @@

                Module exchangelib.fields

                from importlib import import_module from .errors import InvalidTypeError -from .ewsdatetime import EWSDateTime, EWSDate, EWSTimeZone, NaiveDateTimeNotAllowed, UnknownTimeZone, UTC -from .util import create_element, get_xml_attr, get_xml_attrs, set_xml_value, value_to_xml_text, is_iterable, \ - xml_text_to_value, TNS -from .version import Build, EXCHANGE_2013 +from .ewsdatetime import UTC, EWSDate, EWSDateTime, EWSTimeZone, NaiveDateTimeNotAllowed, UnknownTimeZone +from .util import ( + TNS, + create_element, + get_xml_attr, + get_xml_attrs, + is_iterable, + set_xml_value, + value_to_xml_text, + xml_text_to_value, +) +from .version import EXCHANGE_2013, Build log = logging.getLogger(__name__) # DayOfWeekIndex enum. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/dayofweekindex -FIRST = 'First' -SECOND = 'Second' -THIRD = 'Third' -FOURTH = 'Fourth' -LAST = 'Last' +FIRST = "First" +SECOND = "Second" +THIRD = "Third" +FOURTH = "Fourth" +LAST = "Last" WEEK_NUMBERS = (FIRST, SECOND, THIRD, FOURTH, LAST) # Month enum -JANUARY = 'January' -FEBRUARY = 'February' -MARCH = 'March' -APRIL = 'April' -MAY = 'May' -JUNE = 'June' -JULY = 'July' -AUGUST = 'August' -SEPTEMBER = 'September' -OCTOBER = 'October' -NOVEMBER = 'November' -DECEMBER = 'December' +JANUARY = "January" +FEBRUARY = "February" +MARCH = "March" +APRIL = "April" +MAY = "May" +JUNE = "June" +JULY = "July" +AUGUST = "August" +SEPTEMBER = "September" +OCTOBER = "October" +NOVEMBER = "November" +DECEMBER = "December" MONTHS = (JANUARY, FEBRUARY, MARCH, APRIL, MAY, JUNE, JULY, AUGUST, SEPTEMBER, OCTOBER, NOVEMBER, DECEMBER) # Weekday enum -MONDAY = 'Monday' -TUESDAY = 'Tuesday' -WEDNESDAY = 'Wednesday' -THURSDAY = 'Thursday' -FRIDAY = 'Friday' -SATURDAY = 'Saturday' -SUNDAY = 'Sunday' +MONDAY = "Monday" +TUESDAY = "Tuesday" +WEDNESDAY = "Wednesday" +THURSDAY = "Thursday" +FRIDAY = "Friday" +SATURDAY = "Saturday" +SUNDAY = "Sunday" WEEKDAY_NAMES = (MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY) # Used for weekday recurrences except weekly recurrences. E.g. for "First WeekendDay in March" -DAY = 'Day' -WEEK_DAY = 'Weekday' # Non-weekend day -WEEKEND_DAY = 'WeekendDay' +DAY = "Day" +WEEK_DAY = "Weekday" # Non-weekend day +WEEKEND_DAY = "WeekendDay" EXTRA_WEEKDAY_OPTIONS = (DAY, WEEK_DAY, WEEKEND_DAY) # DaysOfWeek enum: See @@ -109,8 +117,8 @@

                Module exchangelib.fields

                'physical_addresses__Home__street' -> ('physical_addresses', 'Home', 'street') """ if not isinstance(field_path, str): - raise InvalidTypeError('field_path', field_path, str) - search_parts = field_path.split('__') + raise InvalidTypeError("field_path", field_path, str) + search_parts = field_path.split("__") field = search_parts[0] try: label = search_parts[1] @@ -127,7 +135,8 @@

                Module exchangelib.fields

                """Take the name of a field, or '__'-delimited path to a subfield, and return the corresponding Field object, label and SubField object. """ - from .indexed_properties import SingleFieldIndexedElement, MultiFieldIndexedElement + from .indexed_properties import MultiFieldIndexedElement, SingleFieldIndexedElement + fieldname, label, subfield_name = split_field_path(field_path) field = folder.get_item_field_by_fieldname(fieldname) subfield = None @@ -137,9 +146,7 @@

                Module exchangelib.fields

                f"IndexedField path {field_path!r} must specify label, e.g. " f"'{fieldname}__{field.value_cls.get_field_by_fieldname('label').default}'" ) - valid_labels = field.value_cls.get_field_by_fieldname('label').supported_choices( - version=folder.account.version - ) + valid_labels = field.value_cls.get_field_by_fieldname("label").supported_choices(version=folder.account.version) if label and label not in valid_labels: raise ValueError( f"Label {label!r} on IndexedField path {field_path!r} must be one of {sorted(valid_labels)}" @@ -155,16 +162,16 @@

                Module exchangelib.fields

                try: subfield = field.value_cls.get_field_by_fieldname(subfield_name) except ValueError: - field_names = ', '.join(f.name for f in field.value_cls.supported_fields( - version=folder.account.version - )) + field_names = ", ".join( + f.name for f in field.value_cls.supported_fields(version=folder.account.version) + ) raise ValueError( f"Subfield {subfield_name!r} on IndexedField path {field_path!r} " f"must be one of {sorted(field_names)}" ) else: if not issubclass(field.value_cls, SingleFieldIndexedElement): - raise InvalidTypeError('field.value_cls', field.value_cls, SingleFieldIndexedElement) + raise InvalidTypeError("field.value_cls", field.value_cls, SingleFieldIndexedElement) if subfield_name: raise ValueError( f"IndexedField path {field_path!r} must not specify subfield, e.g. just {fieldname}__{label}'" @@ -229,8 +236,11 @@

                Module exchangelib.fields

                # If this path does not point to a specific subfield on an indexed property, return all the possible path # combinations for this field path. if isinstance(self.field, IndexedField): - labels = [self.label] if self.label \ - else self.field.value_cls.get_field_by_fieldname('label').supported_choices(version=version) + labels = ( + [self.label] + if self.label + else self.field.value_cls.get_field_by_fieldname("label").supported_choices(version=version) + ) subfields = [self.subfield] if self.subfield else self.field.value_cls.supported_fields(version=version) for label in labels: for subfield in subfields: @@ -242,9 +252,10 @@

                Module exchangelib.fields

                def path(self): if self.label: from .indexed_properties import SingleFieldIndexedElement + if issubclass(self.field.value_cls, SingleFieldIndexedElement) or not self.subfield: - return f'{self.field.name}__{self.label}' - return f'{self.field.name}__{self.label}__{self.subfield.name}' + return f"{self.field.name}__{self.label}" + return f"{self.field.name}__{self.label}__{self.subfield.name}" return self.field.name def __eq__(self, other): @@ -262,6 +273,7 @@

                Module exchangelib.fields

                class FieldOrder: """Holds values needed to call server-side sorting on a single field path.""" + def __init__(self, field_path, reverse=False): """ @@ -274,12 +286,12 @@

                Module exchangelib.fields

                @classmethod def from_string(cls, field_path, folder): return cls( - field_path=FieldPath.from_string(field_path=field_path.lstrip('-'), folder=folder, strict=True), - reverse=field_path.startswith('-') + field_path=FieldPath.from_string(field_path=field_path.lstrip("-"), folder=folder, strict=True), + reverse=field_path.startswith("-"), ) def to_xml(self): - field_order = create_element('t:FieldOrder', attrs=dict(Order='Descending' if self.reverse else 'Ascending')) + field_order = create_element("t:FieldOrder", attrs=dict(Order="Descending" if self.reverse else "Ascending")) field_order.append(self.field_path.to_xml()) return field_order @@ -297,9 +309,19 @@

                Module exchangelib.fields

                # is_complex = False - def __init__(self, name=None, is_required=False, is_required_after_save=False, is_read_only=False, - is_read_only_after_send=False, is_searchable=True, is_attribute=False, default=None, - supported_from=None, deprecated_from=None): + def __init__( + self, + name=None, + is_required=False, + is_required_after_save=False, + is_read_only=False, + is_read_only_after_send=False, + is_searchable=True, + is_attribute=False, + default=None, + supported_from=None, + deprecated_from=None, + ): self.name = name # Usually set by the EWSMeta metaclass self.default = default # Default value if none is given self.is_required = is_required @@ -317,12 +339,12 @@

                Module exchangelib.fields

                # The Exchange build when this field was introduced. When talking with versions prior to this version, # we will ignore this field. if supported_from is not None and not isinstance(supported_from, Build): - raise InvalidTypeError('supported_from', supported_from, Build) + raise InvalidTypeError("supported_from", supported_from, Build) self.supported_from = supported_from # The Exchange build when this field was deprecated. When talking with versions at or later than this version, # we will ignore this field. if deprecated_from is not None and not isinstance(deprecated_from, Build): - raise InvalidTypeError('deprecated_from', deprecated_from, Build) + raise InvalidTypeError("deprecated_from", deprecated_from, Build) self.deprecated_from = deprecated_from def clean(self, value, version=None): @@ -340,12 +362,12 @@

                Module exchangelib.fields

                for v in value: if not isinstance(v, self.value_cls): raise TypeError(f"Field {self.name!r} value {v!r} must be of type {self.value_cls}") - if hasattr(v, 'clean'): + if hasattr(v, "clean"): v.clean(version=version) else: if not isinstance(value, self.value_cls): raise TypeError(f"Field {self.name!r} value {value!r} must be of type {self.value_cls}") - if hasattr(value, 'clean'): + if hasattr(value, "clean"): value.clean(version=version) return value @@ -373,9 +395,10 @@

                Module exchangelib.fields

                """Field instances must be hashable""" def __repr__(self): - args_str = ', '.join(f'{f}={getattr(self, f)!r}' for f in ( - 'name', 'value_cls', 'is_list', 'is_complex', 'default')) - return f'{self.__class__.__name__}({args_str})' + args_str = ", ".join( + f"{f}={getattr(self, f)!r}" for f in ("name", "value_cls", "is_list", "is_complex", "default") + ) + return f"{self.__class__.__name__}({args_str})" class FieldURIField(Field): @@ -385,16 +408,16 @@

                Module exchangelib.fields

                """ def __init__(self, *args, **kwargs): - self.field_uri = kwargs.pop('field_uri', None) - self.namespace = kwargs.pop('namespace', TNS) + self.field_uri = kwargs.pop("field_uri", None) + self.namespace = kwargs.pop("namespace", TNS) super().__init__(*args, **kwargs) # See all valid FieldURI values at # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/fielduri # The field_uri has a prefix when the FieldURI points to an Item field. if self.field_uri is None: self.field_uri_postfix = None - elif ':' in self.field_uri: - self.field_uri_postfix = self.field_uri.split(':')[1] + elif ":" in self.field_uri: + self.field_uri_postfix = self.field_uri.split(":")[1] else: self.field_uri_postfix = self.field_uri @@ -419,6 +442,7 @@

                Module exchangelib.fields

                def field_uri_xml(self): from .properties import FieldURI + if not self.field_uri: raise ValueError(f"'field_uri' value is missing on field '{self.name}'") return FieldURI(field_uri=self.field_uri).to_xml(version=None) @@ -426,12 +450,12 @@

                Module exchangelib.fields

                def request_tag(self): if not self.field_uri_postfix: raise ValueError(f"'field_uri_postfix' value is missing on field '{self.name}'") - return f't:{self.field_uri_postfix}' + return f"t:{self.field_uri_postfix}" def response_tag(self): if not self.field_uri_postfix: raise ValueError(f"'field_uri_postfix' value is missing on field '{self.name}'") - return f'{{{self.namespace}}}{self.field_uri_postfix}' + return f"{{{self.namespace}}}{self.field_uri_postfix}" def __hash__(self): return hash(self.field_uri) @@ -453,14 +477,13 @@

                Module exchangelib.fields

                value_cls = int def __init__(self, *args, **kwargs): - self.min = kwargs.pop('min', None) - self.max = kwargs.pop('max', None) + self.min = kwargs.pop("min", None) + self.max = kwargs.pop("max", None) super().__init__(*args, **kwargs) def _clean_single_value(self, v): if self.min is not None and v < self.min: - raise ValueError( - f"Value {v!r} on field {self.name!r} must be greater than {self.min}") + raise ValueError(f"Value {v!r} on field {self.name!r} must be greater than {self.min}") if self.max is not None and v > self.max: raise ValueError(f"Value {v!r} on field {self.name!r} must be less than {self.max}") @@ -487,12 +510,12 @@

                Module exchangelib.fields

                """ def __init__(self, *args, **kwargs): - self.enum = kwargs.pop('enum') + self.enum = kwargs.pop("enum") # Set different min/max defaults than IntegerField - if 'max' in kwargs: + if "max" in kwargs: raise AttributeError("EnumField does not support the 'max' attribute") - kwargs['min'] = kwargs.pop('min', 1) - kwargs['max'] = kwargs['min'] + len(self.enum) - 1 + kwargs["min"] = kwargs.pop("min", 1) + kwargs["max"] = kwargs["min"] + len(self.enum) - 1 super().__init__(*args, **kwargs) def clean(self, value, version=None): @@ -525,7 +548,7 @@

                Module exchangelib.fields

                if val is not None: try: if self.is_list: - return [self.enum.index(v) + 1 for v in val.split(' ')] + return [self.enum.index(v) + 1 for v in val.split(" ")] return self.enum.index(val) + 1 except ValueError: log.warning("Cannot convert value '%s' on field '%s' to type %s", val, self.name, self.value_cls) @@ -535,7 +558,7 @@

                Module exchangelib.fields

                def to_xml(self, value, version): field_elem = create_element(self.request_tag()) if self.is_list: - return set_xml_value(field_elem, ' '.join(self.as_string(value)), version=version) + return set_xml_value(field_elem, " ".join(self.as_string(value)), version=version) return set_xml_value(field_elem, self.as_string(value), version=version) @@ -568,10 +591,10 @@

                Module exchangelib.fields

                class AppointmentStateField(IntegerField): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/appointmentstate""" - NONE = 'None' - MEETING = 'Meeting' - RECEIVED = 'Received' - CANCELLED = 'Cancelled' + NONE = "None" + MEETING = "Meeting" + RECEIVED = "Received" + CANCELLED = "Canceled" STATES = { NONE: 0x0000, MEETING: 0x0001, @@ -593,8 +616,8 @@

                Module exchangelib.fields

                is_complex = True def __init__(self, *args, **kwargs): - if 'is_searchable' not in kwargs: - kwargs['is_searchable'] = False + if "is_searchable" not in kwargs: + kwargs["is_searchable"] = False super().__init__(*args, **kwargs) @@ -622,7 +645,7 @@

                Module exchangelib.fields

                def __init__(self, *args, **kwargs): # Not all fields assume a default time of 00:00, so make this configurable - self._default_time = kwargs.pop('default_time', datetime.time(0, 0)) + self._default_time = kwargs.pop("default_time", datetime.time(0, 0)) super().__init__(*args, **kwargs) # Create internal field to handle datetime-only logic self._datetime_field = DateTimeField(*args, **kwargs) @@ -659,9 +682,9 @@

                Module exchangelib.fields

                val = self._get_val_from_elem(elem) if val is not None: try: - if ':' in val: + if ":" in val: # Assume a string of the form HH:MM:SS - return datetime.datetime.strptime(val, '%H:%M:%S').time() + return datetime.datetime.strptime(val, "%H:%M:%S").time() # Assume an integer in minutes since midnight return (datetime.datetime(2000, 1, 1) + datetime.timedelta(minutes=int(val))).time() except ValueError: @@ -675,14 +698,13 @@

                Module exchangelib.fields

                value_cls = datetime.timedelta def __init__(self, *args, **kwargs): - self.min = kwargs.pop('min', datetime.timedelta(0)) - self.max = kwargs.pop('max', datetime.timedelta(days=1)) + self.min = kwargs.pop("min", datetime.timedelta(0)) + self.max = kwargs.pop("max", datetime.timedelta(days=1)) super().__init__(*args, **kwargs) def clean(self, value, version=None): if self.min is not None and value < self.min: - raise ValueError( - f"Value {value!r} on field {self.name!r} must be greater than {self.min}") + raise ValueError(f"Value {value!r} on field {self.name!r} must be greater than {self.min}") if self.max is not None and value > self.max: raise ValueError(f"Value {value!r} on field {self.name!r} must be less than {self.max}") return super().clean(value, version=version) @@ -712,10 +734,10 @@

                Module exchangelib.fields

                if account: # Convert to timezone-aware datetime using the default timezone of the account tz = account.default_timezone - log.info('Found naive datetime %s on field %s. Assuming timezone %s', e.local_dt, self.name, tz) + log.info("Found naive datetime %s on field %s. Assuming timezone %s", e.local_dt, self.name, tz) return e.local_dt.replace(tzinfo=tz) # There's nothing we can do but return the naive date. It's better than assuming e.g. UTC. - log.warning('Returning naive datetime %s on field %s', e.local_dt, self.name) + log.warning("Returning naive datetime %s on field %s", e.local_dt, self.name) return e.local_dt log.info("Cannot convert value '%s' on field '%s' to type %s", val, self.name, self.value_cls) return None @@ -767,14 +789,16 @@

                Module exchangelib.fields

                def from_xml(self, elem, account): field_elem = elem.find(self.response_tag()) if field_elem is not None: - ms_id = field_elem.get('Id') - ms_name = field_elem.get('Name') + ms_id = field_elem.get("Id") + ms_name = field_elem.get("Name") try: return self.value_cls.from_ms_id(ms_id or ms_name) except UnknownTimeZone: log.warning( "Cannot convert value '%s' on field '%s' to type %s (unknown timezone ID)", - (ms_id or ms_name), self.name, self.value_cls + (ms_id or ms_name), + self.name, + self.value_cls, ) return None return self.default @@ -782,7 +806,7 @@

                Module exchangelib.fields

                def to_xml(self, value, version): attrs = dict(Id=value.ms_id) if value.ms_name: - attrs['Name'] = value.ms_name + attrs["Name"] = value.ms_name return create_element(self.request_tag(), attrs=attrs) @@ -799,14 +823,14 @@

                Module exchangelib.fields

                is_list = True def __init__(self, *args, **kwargs): - self.list_elem_name = kwargs.pop('list_elem_name', 'String') + self.list_elem_name = kwargs.pop("list_elem_name", "String") super().__init__(*args, **kwargs) def list_elem_request_tag(self): - return f't:{self.list_elem_name}' + return f"t:{self.list_elem_name}" def list_elem_response_tag(self): - return f'{{{self.namespace}}}{self.list_elem_name}' + return f"{{{self.namespace}}}{self.list_elem_name}" def from_xml(self, elem, account): iter_elem = elem.find(self.response_tag()) @@ -824,20 +848,20 @@

                Module exchangelib.fields

                class MessageField(TextField): """A field that handles the Message element.""" - INNER_ELEMENT_NAME = 'Message' + INNER_ELEMENT_NAME = "Message" def from_xml(self, elem, account): reply = elem.find(self.response_tag()) if reply is None: return None - message = reply.find(f'{{{TNS}}}{self.INNER_ELEMENT_NAME}') + message = reply.find(f"{{{TNS}}}{self.INNER_ELEMENT_NAME}") if message is None: return None return message.text def to_xml(self, value, version): field_elem = create_element(self.request_tag()) - message = create_element(f't:{self.INNER_ELEMENT_NAME}') + message = create_element(f"t:{self.INNER_ELEMENT_NAME}") message.text = value return set_xml_value(field_elem, message, version=version) @@ -848,7 +872,7 @@

                Module exchangelib.fields

                is_complex = False def __init__(self, *args, **kwargs): - self.max_length = kwargs.pop('max_length', 255) + self.max_length = kwargs.pop("max_length", 255) if not 1 <= self.max_length <= 255: # A field supporting messages longer than 255 chars should be TextField raise ValueError("'max_length' must be in the range 1-255") @@ -878,7 +902,7 @@

                Module exchangelib.fields

                """Like TextListField, but for string values with a limited length.""" def __init__(self, *args, **kwargs): - self.max_length = kwargs.pop('max_length', 255) + self.max_length = kwargs.pop("max_length", 255) if not 1 <= self.max_length <= 255: # A field supporting messages longer than 255 chars should be TextField raise ValueError("'max_length' must be in the range 1-255") @@ -925,7 +949,7 @@

                Module exchangelib.fields

                """Like CharField, but restricts the value to a limited set of strings.""" def __init__(self, *args, **kwargs): - self.choices = kwargs.pop('choices') + self.choices = kwargs.pop("choices") super().__init__(*args, **kwargs) def clean(self, value, version=None): @@ -951,15 +975,21 @@

                Module exchangelib.fields

                return tuple(c.value for c in self.choices if c.supports_version(version)) -FREE_BUSY_CHOICES = [Choice('Free'), Choice('Tentative'), Choice('Busy'), Choice('OOF'), Choice('NoData'), - Choice('WorkingElsewhere', supported_from=EXCHANGE_2013)] +FREE_BUSY_CHOICES = [ + Choice("Free"), + Choice("Tentative"), + Choice("Busy"), + Choice("OOF"), + Choice("NoData"), + Choice("WorkingElsewhere", supported_from=EXCHANGE_2013), +] class FreeBusyStatusField(ChoiceField): """Like ChoiceField, but specifically for Free/Busy values.""" def __init__(self, *args, **kwargs): - kwargs['choices'] = set(FREE_BUSY_CHOICES) + kwargs["choices"] = set(FREE_BUSY_CHOICES) super().__init__(*args, **kwargs) @@ -968,6 +998,7 @@

                Module exchangelib.fields

                def __init__(self, *args, **kwargs): from .properties import Body + self.value_cls = Body super().__init__(*args, **kwargs) @@ -978,18 +1009,17 @@

                Module exchangelib.fields

                def from_xml(self, elem, account): from .properties import Body, HTMLBody + field_elem = elem.find(self.response_tag()) val = None if field_elem is None else field_elem.text or None if val is not None: - body_type = field_elem.get('BodyType') - return { - Body.body_type: Body, - HTMLBody.body_type: HTMLBody, - }[body_type](val) + body_type = field_elem.get("BodyType") + return {Body.body_type: Body, HTMLBody.body_type: HTMLBody}[body_type](val) return self.default def to_xml(self, value, version): from .properties import Body, HTMLBody + body_type = { Body: Body.body_type, HTMLBody: HTMLBody.body_type, @@ -1002,9 +1032,9 @@

                Module exchangelib.fields

                """A generic field for any EWSElement object.""" def __init__(self, *args, **kwargs): - self._value_cls = kwargs.pop('value_cls') - if 'namespace' not in kwargs: - kwargs['namespace'] = self.value_cls.NAMESPACE + self._value_cls = kwargs.pop("value_cls") + if "namespace" not in kwargs: + kwargs["namespace"] = self.value_cls.NAMESPACE super().__init__(*args, **kwargs) @property @@ -1012,15 +1042,17 @@

                Module exchangelib.fields

                if isinstance(self._value_cls, str): # Support 'value_cls' as string to allow self-referencing classes. The class must be importable from the # top-level module. - self._value_cls = getattr(import_module(self.__module__.split('.')[0]), self._value_cls) + self._value_cls = getattr(import_module(self.__module__.split(".")[0]), self._value_cls) return self._value_cls def from_xml(self, elem, account): if self.is_list: iter_elem = elem.find(self.response_tag()) if iter_elem is not None: - return [self.value_cls.from_xml(elem=e, account=account) - for e in iter_elem.findall(self.value_cls.response_tag())] + return [ + self.value_cls.from_xml(elem=e, account=account) + for e in iter_elem.findall(self.value_cls.response_tag()) + ] else: if self.field_uri is None: sub_elem = elem.find(self.value_cls.response_tag()) @@ -1047,7 +1079,8 @@

                Module exchangelib.fields

                class TransitionListField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import BaseTransition - kwargs['value_cls'] = BaseTransition + + kwargs["value_cls"] = BaseTransition super().__init__(*args, **kwargs) def from_xml(self, elem, account): @@ -1064,7 +1097,8 @@

                Module exchangelib.fields

                def __init__(self, *args, **kwargs): from .properties import AssociatedCalendarItemId - kwargs['value_cls'] = AssociatedCalendarItemId + + kwargs["value_cls"] = AssociatedCalendarItemId super().__init__(*args, **kwargs) def to_xml(self, value, version): @@ -1076,7 +1110,8 @@

                Module exchangelib.fields

                def __init__(self, *args, **kwargs): from .recurrence import Recurrence - kwargs['value_cls'] = Recurrence + + kwargs["value_cls"] = Recurrence super().__init__(*args, **kwargs) def to_xml(self, value, version): @@ -1088,7 +1123,8 @@

                Module exchangelib.fields

                def __init__(self, *args, **kwargs): from .recurrence import TaskRecurrence - kwargs['value_cls'] = TaskRecurrence + + kwargs["value_cls"] = TaskRecurrence super().__init__(*args, **kwargs) def to_xml(self, value, version): @@ -1100,7 +1136,8 @@

                Module exchangelib.fields

                def __init__(self, *args, **kwargs): from .properties import ReferenceItemId - kwargs['value_cls'] = ReferenceItemId + + kwargs["value_cls"] = ReferenceItemId super().__init__(*args, **kwargs) def to_xml(self, value, version): @@ -1118,7 +1155,8 @@

                Module exchangelib.fields

                class MessageHeaderField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import MessageHeader - kwargs['value_cls'] = MessageHeader + + kwargs["value_cls"] = MessageHeader super().__init__(*args, **kwargs) @@ -1143,7 +1181,7 @@

                Module exchangelib.fields

                nested_elem = sub_elem.find(self.value_cls.response_tag()) if nested_elem is None: raise ValueError( - f'Expected XML element {self.value_cls.response_tag()!r} missing on field {self.name!r}' + f"Expected XML element {self.value_cls.response_tag()!r} missing on field {self.name!r}" ) return self.value_cls.from_xml(elem=nested_elem, account=account) return self.value_cls.from_xml(elem=sub_elem, account=account) @@ -1153,28 +1191,32 @@

                Module exchangelib.fields

                class EmailField(BaseEmailField): def __init__(self, *args, **kwargs): from .properties import Email - kwargs['value_cls'] = Email + + kwargs["value_cls"] = Email super().__init__(*args, **kwargs) class RecipientAddressField(BaseEmailField): def __init__(self, *args, **kwargs): from .properties import RecipientAddress - kwargs['value_cls'] = RecipientAddress + + kwargs["value_cls"] = RecipientAddress super().__init__(*args, **kwargs) class MailboxField(BaseEmailField): def __init__(self, *args, **kwargs): from .properties import Mailbox - kwargs['value_cls'] = Mailbox + + kwargs["value_cls"] = Mailbox super().__init__(*args, **kwargs) class MailboxListField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import Mailbox - kwargs['value_cls'] = Mailbox + + kwargs["value_cls"] = Mailbox super().__init__(*args, **kwargs) def clean(self, value, version=None): @@ -1186,29 +1228,33 @@

                Module exchangelib.fields

                class MemberListField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import Member - kwargs['value_cls'] = Member + + kwargs["value_cls"] = Member super().__init__(*args, **kwargs) def clean(self, value, version=None): from .properties import Mailbox + if value is not None: - value = [ - self.value_cls(mailbox=Mailbox(email_address=s)) if isinstance(s, str) else s for s in value - ] + value = [self.value_cls(mailbox=Mailbox(email_address=s)) if isinstance(s, str) else s for s in value] return super().clean(value, version=version) class AttendeesField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import Attendee - kwargs['value_cls'] = Attendee + + kwargs["value_cls"] = Attendee super().__init__(*args, **kwargs) def clean(self, value, version=None): from .properties import Mailbox + if value is not None: - value = [self.value_cls(mailbox=Mailbox(email_address=s), response_type='Accept') - if isinstance(s, str) else s for s in value] + value = [ + self.value_cls(mailbox=Mailbox(email_address=s), response_type="Accept") if isinstance(s, str) else s + for s in value + ] return super().clean(value, version=version) @@ -1217,11 +1263,13 @@

                Module exchangelib.fields

                def __init__(self, *args, **kwargs): from .attachments import Attachment - kwargs['value_cls'] = Attachment + + kwargs["value_cls"] = Attachment super().__init__(*args, **kwargs) def from_xml(self, elem, account): from .attachments import FileAttachment, ItemAttachment + iter_elem = elem.find(self.response_tag()) # Look for both FileAttachment and ItemAttachment if iter_elem is not None: @@ -1260,12 +1308,13 @@

                Module exchangelib.fields

                @staticmethod def field_uri_xml(field_uri, label): from .properties import IndexedFieldURI + return IndexedFieldURI(field_uri=field_uri, field_index=label).to_xml(version=None) def clean(self, value, version=None): value = super().clean(value, version=version) if self.is_required and not value: - raise ValueError(f'Value for subfield {self.name!r} must be non-empty') + raise ValueError(f"Value for subfield {self.name!r} must be non-empty") return value def __hash__(self): @@ -1278,7 +1327,7 @@

                Module exchangelib.fields

                value_cls = str def from_xml(self, elem, account): - return elem.text or elem.get('Name') # Sometimes elem.text is empty. Exchange saves the same in 'Name' attr + return elem.text or elem.get("Name") # Sometimes elem.text is empty. Exchange saves the same in 'Name' attr class NamedSubField(SubField): @@ -1287,8 +1336,8 @@

                Module exchangelib.fields

                value_cls = str def __init__(self, *args, **kwargs): - self.field_uri = kwargs.pop('field_uri') - if ':' in self.field_uri: + self.field_uri = kwargs.pop("field_uri") + if ":" in self.field_uri: raise ValueError("'field_uri' value must not contain a colon") super().__init__(*args, **kwargs) @@ -1305,13 +1354,14 @@

                Module exchangelib.fields

                def field_uri_xml(self, field_uri, label): from .properties import IndexedFieldURI - return IndexedFieldURI(field_uri=f'{field_uri}:{self.field_uri}', field_index=label).to_xml(version=None) + + return IndexedFieldURI(field_uri=f"{field_uri}:{self.field_uri}", field_index=label).to_xml(version=None) def request_tag(self): - return f't:{self.field_uri}' + return f"t:{self.field_uri}" def response_tag(self): - return f'{{{self.namespace}}}{self.field_uri}' + return f"{{{self.namespace}}}{self.field_uri}" class IndexedField(EWSElementField, metaclass=abc.ABCMeta): @@ -1321,16 +1371,17 @@

                Module exchangelib.fields

                def __init__(self, *args, **kwargs): from .indexed_properties import IndexedElement - value_cls = kwargs['value_cls'] + + value_cls = kwargs["value_cls"] if not issubclass(value_cls, IndexedElement): raise TypeError(f"'value_cls' {value_cls!r} must be a subclass of type {IndexedElement}") super().__init__(*args, **kwargs) def to_xml(self, value, version): - return set_xml_value(create_element(f't:{self.PARENT_ELEMENT_NAME}'), value, version=version) + return set_xml_value(create_element(f"t:{self.PARENT_ELEMENT_NAME}"), value, version=version) def response_tag(self): - return f'{{{self.namespace}}}{self.PARENT_ELEMENT_NAME}' + return f"{{{self.namespace}}}{self.PARENT_ELEMENT_NAME}" def __hash__(self): return hash(self.field_uri) @@ -1340,18 +1391,19 @@

                Module exchangelib.fields

                is_list = True is_complex = True - PARENT_ELEMENT_NAME = 'EmailAddresses' + PARENT_ELEMENT_NAME = "EmailAddresses" def __init__(self, *args, **kwargs): from .indexed_properties import EmailAddress - kwargs['value_cls'] = EmailAddress + + kwargs["value_cls"] = EmailAddress super().__init__(*args, **kwargs) def clean(self, value, version=None): if value is not None: default_labels = self.value_cls.LABEL_CHOICES if len(value) > len(default_labels): - raise ValueError(f'This field can handle at most {len(default_labels)} values (value: {value})') + raise ValueError(f"This field can handle at most {len(default_labels)} values (value: {value})") tmp = [] for s, default_label in zip(value, default_labels): if not isinstance(s, str): @@ -1366,11 +1418,12 @@

                Module exchangelib.fields

                is_list = True is_complex = True - PARENT_ELEMENT_NAME = 'PhoneNumbers' + PARENT_ELEMENT_NAME = "PhoneNumbers" def __init__(self, *args, **kwargs): from .indexed_properties import PhoneNumber - kwargs['value_cls'] = PhoneNumber + + kwargs["value_cls"] = PhoneNumber super().__init__(*args, **kwargs) @@ -1378,11 +1431,12 @@

                Module exchangelib.fields

                is_list = True is_complex = True - PARENT_ELEMENT_NAME = 'PhysicalAddresses' + PARENT_ELEMENT_NAME = "PhysicalAddresses" def __init__(self, *args, **kwargs): from .indexed_properties import PhysicalAddress - kwargs['value_cls'] = PhysicalAddress + + kwargs["value_cls"] = PhysicalAddress super().__init__(*args, **kwargs) @@ -1390,7 +1444,7 @@

                Module exchangelib.fields

                is_complex = True def __init__(self, *args, **kwargs): - self.value_cls = kwargs.pop('value_cls') + self.value_cls = kwargs.pop("value_cls") super().__init__(*args, **kwargs) def clean(self, value, version=None): @@ -1408,6 +1462,7 @@

                Module exchangelib.fields

                def field_uri_xml(self): from .properties import ExtendedFieldURI + cls = self.value_cls return ExtendedFieldURI( distinguished_property_set_id=cls.distinguished_property_set_id, @@ -1445,10 +1500,12 @@

                Module exchangelib.fields

                @property def value_cls(self): from .items import Item + return Item def from_xml(self, elem, account): from .items import ITEM_CLASSES + for item_cls in ITEM_CLASSES: item_elem = elem.find(item_cls.response_tag()) if item_elem is not None: @@ -1462,7 +1519,7 @@

                Module exchangelib.fields

                class UnknownEntriesField(CharListField): def list_elem_tag(self): - return f'{{{self.namespace}}}UnknownEntry' + return f"{{{self.namespace}}}UnknownEntry" class PermissionSetField(EWSElementField): @@ -1470,14 +1527,16 @@

                Module exchangelib.fields

                def __init__(self, *args, **kwargs): from .properties import PermissionSet - kwargs['value_cls'] = PermissionSet + + kwargs["value_cls"] = PermissionSet super().__init__(*args, **kwargs) class EffectiveRightsField(EWSElementField): def __init__(self, *args, **kwargs): from .properties import EffectiveRights - kwargs['value_cls'] = EffectiveRights + + kwargs["value_cls"] = EffectiveRights super().__init__(*args, **kwargs) @@ -1492,7 +1551,7 @@

                Module exchangelib.fields

                try: return self.value_cls.from_hex_string(val) except (TypeError, ValueError): - log.warning('Invalid server version string: %r', val) + log.warning("Invalid server version string: %r", val) return val @@ -1500,7 +1559,8 @@

                Module exchangelib.fields

                # There is not containing element for this field. Just multiple 'Protocol' elements on the 'Account' element. def __init__(self, *args, **kwargs): from .autodiscover.properties import Protocol - kwargs['value_cls'] = Protocol + + kwargs["value_cls"] = Protocol super().__init__(*args, **kwargs) def from_xml(self, elem, account): @@ -1509,15 +1569,15 @@

                Module exchangelib.fields

                class RoutingTypeField(ChoiceField): def __init__(self, *args, **kwargs): - kwargs['choices'] = {Choice('SMTP'), Choice('EX')} - kwargs['default'] = 'SMTP' + kwargs["choices"] = {Choice("SMTP"), Choice("EX")} + kwargs["default"] = "SMTP" super().__init__(*args, **kwargs) class IdElementField(EWSElementField): def __init__(self, *args, **kwargs): - kwargs['is_searchable'] = False - kwargs['is_read_only'] = True + kwargs["is_searchable"] = False + kwargs["is_read_only"] = True super().__init__(*args, **kwargs) @@ -1525,47 +1585,47 @@

                Module exchangelib.fields

                """This field type has no value_cls because values may have many different types.""" TYPES_MAP = { - 'Boolean': bool, - 'Integer32': int, - 'UnsignedInteger32': int, - 'Integer64': int, - 'UnsignedInteger64': int, + "Boolean": bool, + "Integer32": int, + "UnsignedInteger32": int, + "Integer64": int, + "UnsignedInteger64": int, # Python doesn't have a single-byte type to represent 'Byte' - 'ByteArray': bytes, - 'String': str, - 'StringArray': str, # A list of strings - 'DateTime': EWSDateTime, + "ByteArray": bytes, + "String": str, + "StringArray": str, # A list of strings + "DateTime": EWSDateTime, } TYPES_MAP_REVERSED = { - bool: 'Boolean', - int: 'Integer64', + bool: "Boolean", + int: "Integer64", # Python doesn't have a single-byte type to represent 'Byte' - bytes: 'ByteArray', - str: 'String', - datetime.datetime: 'DateTime', - EWSDateTime: 'DateTime', + bytes: "ByteArray", + str: "String", + datetime.datetime: "DateTime", + EWSDateTime: "DateTime", } @classmethod def get_type(cls, value): if isinstance(value, bytes) and len(value) == 1: # This is a single byte. Translate it to the 'Byte' type - return 'Byte' + return "Byte" if is_iterable(value): # We don't allow generators as values, so keep the logic simple try: first = next(iter(value)) except StopIteration: first = None - value_type = f'{cls.TYPES_MAP_REVERSED[type(first)]}Array' + value_type = f"{cls.TYPES_MAP_REVERSED[type(first)]}Array" if value_type not in cls.TYPES_MAP: - raise ValueError(f'{value!r} is not a supported type') + raise ValueError(f"{value!r} is not a supported type") return value_type return cls.TYPES_MAP_REVERSED[type(value)] @classmethod def is_array_type(cls, value_type): - return value_type == 'StringArray' + return value_type == "StringArray" def clean(self, value, version=None): if value is None: @@ -1578,30 +1638,30 @@

                Module exchangelib.fields

                field_elem = elem.find(self.response_tag()) if field_elem is None: return self.default - value_type_str = get_xml_attr(field_elem, f'{{{TNS}}}Type') - value = get_xml_attr(field_elem, f'{{{TNS}}}Value') - if value_type_str == 'Byte': + value_type_str = get_xml_attr(field_elem, f"{{{TNS}}}Type") + value = get_xml_attr(field_elem, f"{{{TNS}}}Value") + if value_type_str == "Byte": try: # The value is an unsigned integer in the range 0 -> 255. Convert it to a single byte - return xml_text_to_value(value, int).to_bytes(1, 'little', signed=False) + return xml_text_to_value(value, int).to_bytes(1, "little", signed=False) except OverflowError as e: - log.warning('Invalid byte value %r (%e)', value, e) + log.warning("Invalid byte value %r (%e)", value, e) return None value_type = self.TYPES_MAP[value_type_str] - if self. is_array_type(value_type_str): - return tuple(xml_text_to_value(value=v, value_type=value_type) for v in value.split(' ')) + if self.is_array_type(value_type_str): + return tuple(xml_text_to_value(value=v, value_type=value_type) for v in value.split(" ")) return xml_text_to_value(value=value, value_type=value_type) def to_xml(self, value, version): value_type_str = self.get_type(value) - if value_type_str == 'Byte': + if value_type_str == "Byte": # A single byte is encoded to an unsigned integer in the range 0 -> 255 - value = int.from_bytes(value, byteorder='little', signed=False) + value = int.from_bytes(value, byteorder="little", signed=False) elif is_iterable(value): - value = ' '.join(value_to_xml_text(v) for v in value) + value = " ".join(value_to_xml_text(v) for v in value) field_elem = create_element(self.request_tag()) - field_elem.append(set_xml_value(create_element('t:Type'), value_type_str, version=version)) - field_elem.append(set_xml_value(create_element('t:Value'), value, version=version)) + field_elem.append(set_xml_value(create_element("t:Type"), value_type_str, version=version)) + field_elem.append(set_xml_value(create_element("t:Value"), value, version=version)) return field_elem @@ -1610,6 +1670,7 @@

                Module exchangelib.fields

                def from_xml(self, elem, account): from .properties import DictionaryEntry + iter_elem = elem.find(self.response_tag()) if iter_elem is not None: entries = [ @@ -1633,6 +1694,7 @@

                Module exchangelib.fields

                def to_xml(self, value, version): from .properties import DictionaryEntry + field_elem = create_element(self.request_tag()) entries = [DictionaryEntry(key=k, value=v) for k, v in value.items()] return set_xml_value(field_elem, entries, version=version) @@ -1643,7 +1705,8 @@

                Module exchangelib.fields

                def __init__(self, *args, **kwargs): from .properties import PhoneNumber - kwargs['value_cls'] = PhoneNumber + + kwargs["value_cls"] = PhoneNumber super().__init__(*args, **kwargs) @@ -1652,35 +1715,40 @@

                Module exchangelib.fields

                def __init__(self, *args, **kwargs): from .properties import BodyContentAttributedValue - kwargs['value_cls'] = BodyContentAttributedValue + + kwargs["value_cls"] = BodyContentAttributedValue super().__init__(*args, **kwargs) class StringAttributedValueField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import StringAttributedValue - kwargs['value_cls'] = StringAttributedValue + + kwargs["value_cls"] = StringAttributedValue super().__init__(*args, **kwargs) class PhoneNumberAttributedValueField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import PhoneNumberAttributedValue - kwargs['value_cls'] = PhoneNumberAttributedValue + + kwargs["value_cls"] = PhoneNumberAttributedValue super().__init__(*args, **kwargs) class EmailAddressAttributedValueField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import EmailAddressAttributedValue - kwargs['value_cls'] = EmailAddressAttributedValue + + kwargs["value_cls"] = EmailAddressAttributedValue super().__init__(*args, **kwargs) class PostalAddressAttributedValueField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import PostalAddressAttributedValue - kwargs['value_cls'] = PostalAddressAttributedValue + + kwargs["value_cls"] = PostalAddressAttributedValue super().__init__(*args, **kwargs) @@ -1694,13 +1762,28 @@

                Module exchangelib.fields

                return {v.response_tag(): v for v in self.value_classes} def __init__(self, *args, **kwargs): - from .properties import CopiedEvent, CreatedEvent, DeletedEvent, ModifiedEvent, MovedEvent, \ - NewMailEvent, StatusEvent, FreeBusyChangedEvent - kwargs['value_cls'] = None # Parent class requires this kwarg - kwargs['namespace'] = None # Parent class requires this kwarg + from .properties import ( + CopiedEvent, + CreatedEvent, + DeletedEvent, + FreeBusyChangedEvent, + ModifiedEvent, + MovedEvent, + NewMailEvent, + StatusEvent, + ) + + kwargs["value_cls"] = None # Parent class requires this kwarg + kwargs["namespace"] = None # Parent class requires this kwarg super().__init__(*args, **kwargs) self.value_classes = ( - CopiedEvent, CreatedEvent, DeletedEvent, ModifiedEvent, MovedEvent, NewMailEvent, StatusEvent, + CopiedEvent, + CreatedEvent, + DeletedEvent, + ModifiedEvent, + MovedEvent, + NewMailEvent, + StatusEvent, FreeBusyChangedEvent, ) @@ -1737,7 +1820,8 @@

                Functions

                """Take the name of a field, or '__'-delimited path to a subfield, and return the corresponding Field object, label and SubField object. """ - from .indexed_properties import SingleFieldIndexedElement, MultiFieldIndexedElement + from .indexed_properties import MultiFieldIndexedElement, SingleFieldIndexedElement + fieldname, label, subfield_name = split_field_path(field_path) field = folder.get_item_field_by_fieldname(fieldname) subfield = None @@ -1747,9 +1831,7 @@

                Functions

                f"IndexedField path {field_path!r} must specify label, e.g. " f"'{fieldname}__{field.value_cls.get_field_by_fieldname('label').default}'" ) - valid_labels = field.value_cls.get_field_by_fieldname('label').supported_choices( - version=folder.account.version - ) + valid_labels = field.value_cls.get_field_by_fieldname("label").supported_choices(version=folder.account.version) if label and label not in valid_labels: raise ValueError( f"Label {label!r} on IndexedField path {field_path!r} must be one of {sorted(valid_labels)}" @@ -1765,16 +1847,16 @@

                Functions

                try: subfield = field.value_cls.get_field_by_fieldname(subfield_name) except ValueError: - field_names = ', '.join(f.name for f in field.value_cls.supported_fields( - version=folder.account.version - )) + field_names = ", ".join( + f.name for f in field.value_cls.supported_fields(version=folder.account.version) + ) raise ValueError( f"Subfield {subfield_name!r} on IndexedField path {field_path!r} " f"must be one of {sorted(field_names)}" ) else: if not issubclass(field.value_cls, SingleFieldIndexedElement): - raise InvalidTypeError('field.value_cls', field.value_cls, SingleFieldIndexedElement) + raise InvalidTypeError("field.value_cls", field.value_cls, SingleFieldIndexedElement) if subfield_name: raise ValueError( f"IndexedField path {field_path!r} must not specify subfield, e.g. just {fieldname}__{label}'" @@ -1811,8 +1893,8 @@

                Functions

                'physical_addresses__Home__street' -> ('physical_addresses', 'Home', 'street') """ if not isinstance(field_path, str): - raise InvalidTypeError('field_path', field_path, str) - search_parts = field_path.split('__') + raise InvalidTypeError("field_path", field_path, str) + search_parts = field_path.split("__") field = search_parts[0] try: label = search_parts[1] @@ -1843,10 +1925,10 @@

                Classes

                class AppointmentStateField(IntegerField):
                     """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/appointmentstate"""
                 
                -    NONE = 'None'
                -    MEETING = 'Meeting'
                -    RECEIVED = 'Received'
                -    CANCELLED = 'Cancelled'
                +    NONE = "None"
                +    MEETING = "Meeting"
                +    RECEIVED = "Received"
                +    CANCELLED = "Canceled"
                     STATES = {
                         NONE: 0x0000,
                         MEETING: 0x0001,
                @@ -1915,7 +1997,8 @@ 

                Inherited members

                def __init__(self, *args, **kwargs): from .properties import AssociatedCalendarItemId - kwargs['value_cls'] = AssociatedCalendarItemId + + kwargs["value_cls"] = AssociatedCalendarItemId super().__init__(*args, **kwargs) def to_xml(self, value, version): @@ -1959,11 +2042,13 @@

                Inherited members

                def __init__(self, *args, **kwargs): from .attachments import Attachment - kwargs['value_cls'] = Attachment + + kwargs["value_cls"] = Attachment super().__init__(*args, **kwargs) def from_xml(self, elem, account): from .attachments import FileAttachment, ItemAttachment + iter_elem = elem.find(self.response_tag()) # Look for both FileAttachment and ItemAttachment if iter_elem is not None: @@ -2005,14 +2090,18 @@

                Inherited members

                class AttendeesField(EWSElementListField):
                     def __init__(self, *args, **kwargs):
                         from .properties import Attendee
                -        kwargs['value_cls'] = Attendee
                +
                +        kwargs["value_cls"] = Attendee
                         super().__init__(*args, **kwargs)
                 
                     def clean(self, value, version=None):
                         from .properties import Mailbox
                +
                         if value is not None:
                -            value = [self.value_cls(mailbox=Mailbox(email_address=s), response_type='Accept')
                -                     if isinstance(s, str) else s for s in value]
                +            value = [
                +                self.value_cls(mailbox=Mailbox(email_address=s), response_type="Accept") if isinstance(s, str) else s
                +                for s in value
                +            ]
                         return super().clean(value, version=version)

                Ancestors

                @@ -2035,9 +2124,12 @@

                Methods

                def clean(self, value, version=None):
                     from .properties import Mailbox
                +
                     if value is not None:
                -        value = [self.value_cls(mailbox=Mailbox(email_address=s), response_type='Accept')
                -                 if isinstance(s, str) else s for s in value]
                +        value = [
                +            self.value_cls(mailbox=Mailbox(email_address=s), response_type="Accept") if isinstance(s, str) else s
                +            for s in value
                +        ]
                     return super().clean(value, version=version)
                @@ -2069,8 +2161,8 @@

                Inherited members

                is_complex = True def __init__(self, *args, **kwargs): - if 'is_searchable' not in kwargs: - kwargs['is_searchable'] = False + if "is_searchable" not in kwargs: + kwargs["is_searchable"] = False super().__init__(*args, **kwargs)

                Ancestors

                @@ -2143,7 +2235,7 @@

                Inherited members

                nested_elem = sub_elem.find(self.value_cls.response_tag()) if nested_elem is None: raise ValueError( - f'Expected XML element {self.value_cls.response_tag()!r} missing on field {self.name!r}' + f"Expected XML element {self.value_cls.response_tag()!r} missing on field {self.name!r}" ) return self.value_cls.from_xml(elem=nested_elem, account=account) return self.value_cls.from_xml(elem=sub_elem, account=account) @@ -2211,7 +2303,8 @@

                Inherited members

                def __init__(self, *args, **kwargs): from .properties import BodyContentAttributedValue - kwargs['value_cls'] = BodyContentAttributedValue + + kwargs["value_cls"] = BodyContentAttributedValue super().__init__(*args, **kwargs)

                Ancestors

                @@ -2252,6 +2345,7 @@

                Inherited members

                def __init__(self, *args, **kwargs): from .properties import Body + self.value_cls = Body super().__init__(*args, **kwargs) @@ -2262,18 +2356,17 @@

                Inherited members

                def from_xml(self, elem, account): from .properties import Body, HTMLBody + field_elem = elem.find(self.response_tag()) val = None if field_elem is None else field_elem.text or None if val is not None: - body_type = field_elem.get('BodyType') - return { - Body.body_type: Body, - HTMLBody.body_type: HTMLBody, - }[body_type](val) + body_type = field_elem.get("BodyType") + return {Body.body_type: Body, HTMLBody.body_type: HTMLBody}[body_type](val) return self.default def to_xml(self, value, version): from .properties import Body, HTMLBody + body_type = { Body: Body.body_type, HTMLBody: HTMLBody.body_type, @@ -2381,7 +2474,7 @@

                Inherited members

                try: return self.value_cls.from_hex_string(val) except (TypeError, ValueError): - log.warning('Invalid server version string: %r', val) + log.warning("Invalid server version string: %r", val) return val

                Ancestors

                @@ -2418,7 +2511,7 @@

                Inherited members

                is_complex = False def __init__(self, *args, **kwargs): - self.max_length = kwargs.pop('max_length', 255) + self.max_length = kwargs.pop("max_length", 255) if not 1 <= self.max_length <= 255: # A field supporting messages longer than 255 chars should be TextField raise ValueError("'max_length' must be in the range 1-255") @@ -2495,7 +2588,7 @@

                Inherited members

                """Like TextListField, but for string values with a limited length.""" def __init__(self, *args, **kwargs): - self.max_length = kwargs.pop('max_length', 255) + self.max_length = kwargs.pop("max_length", 255) if not 1 <= self.max_length <= 255: # A field supporting messages longer than 255 chars should be TextField raise ValueError("'max_length' must be in the range 1-255") @@ -2609,7 +2702,7 @@

                Methods

                """Like CharField, but restricts the value to a limited set of strings.""" def __init__(self, *args, **kwargs): - self.choices = kwargs.pop('choices') + self.choices = kwargs.pop("choices") super().__init__(*args, **kwargs) def clean(self, value, version=None): @@ -2897,7 +2990,7 @@

                Inherited members

                def __init__(self, *args, **kwargs): # Not all fields assume a default time of 00:00, so make this configurable - self._default_time = kwargs.pop('default_time', datetime.time(0, 0)) + self._default_time = kwargs.pop("default_time", datetime.time(0, 0)) super().__init__(*args, **kwargs) # Create internal field to handle datetime-only logic self._datetime_field = DateTimeField(*args, **kwargs) @@ -2991,10 +3084,10 @@

                Inherited members

                if account: # Convert to timezone-aware datetime using the default timezone of the account tz = account.default_timezone - log.info('Found naive datetime %s on field %s. Assuming timezone %s', e.local_dt, self.name, tz) + log.info("Found naive datetime %s on field %s. Assuming timezone %s", e.local_dt, self.name, tz) return e.local_dt.replace(tzinfo=tz) # There's nothing we can do but return the naive date. It's better than assuming e.g. UTC. - log.warning('Returning naive datetime %s on field %s', e.local_dt, self.name) + log.warning("Returning naive datetime %s on field %s", e.local_dt, self.name) return e.local_dt log.info("Cannot convert value '%s' on field '%s' to type %s", val, self.name, self.value_cls) return None @@ -3096,6 +3189,7 @@

                Inherited members

                def from_xml(self, elem, account): from .properties import DictionaryEntry + iter_elem = elem.find(self.response_tag()) if iter_elem is not None: entries = [ @@ -3119,6 +3213,7 @@

                Inherited members

                def to_xml(self, value, version): from .properties import DictionaryEntry + field_elem = create_element(self.request_tag()) entries = [DictionaryEntry(key=k, value=v) for k, v in value.items()] return set_xml_value(field_elem, entries, version=version) @@ -3194,9 +3289,9 @@

                Inherited members

                """A generic field for any EWSElement object.""" def __init__(self, *args, **kwargs): - self._value_cls = kwargs.pop('value_cls') - if 'namespace' not in kwargs: - kwargs['namespace'] = self.value_cls.NAMESPACE + self._value_cls = kwargs.pop("value_cls") + if "namespace" not in kwargs: + kwargs["namespace"] = self.value_cls.NAMESPACE super().__init__(*args, **kwargs) @property @@ -3204,15 +3299,17 @@

                Inherited members

                if isinstance(self._value_cls, str): # Support 'value_cls' as string to allow self-referencing classes. The class must be importable from the # top-level module. - self._value_cls = getattr(import_module(self.__module__.split('.')[0]), self._value_cls) + self._value_cls = getattr(import_module(self.__module__.split(".")[0]), self._value_cls) return self._value_cls def from_xml(self, elem, account): if self.is_list: iter_elem = elem.find(self.response_tag()) if iter_elem is not None: - return [self.value_cls.from_xml(elem=e, account=account) - for e in iter_elem.findall(self.value_cls.response_tag())] + return [ + self.value_cls.from_xml(elem=e, account=account) + for e in iter_elem.findall(self.value_cls.response_tag()) + ] else: if self.field_uri is None: sub_elem = elem.find(self.value_cls.response_tag()) @@ -3264,7 +3361,7 @@

                Instance variables

                if isinstance(self._value_cls, str): # Support 'value_cls' as string to allow self-referencing classes. The class must be importable from the # top-level module. - self._value_cls = getattr(import_module(self.__module__.split('.')[0]), self._value_cls) + self._value_cls = getattr(import_module(self.__module__.split(".")[0]), self._value_cls) return self._value_cls @@ -3349,7 +3446,8 @@

                Inherited members

                class EffectiveRightsField(EWSElementField):
                     def __init__(self, *args, **kwargs):
                         from .properties import EffectiveRights
                -        kwargs['value_cls'] = EffectiveRights
                +
                +        kwargs["value_cls"] = EffectiveRights
                         super().__init__(*args, **kwargs)

                Ancestors

                @@ -3381,7 +3479,8 @@

                Inherited members

                class EmailAddressAttributedValueField(EWSElementListField):
                     def __init__(self, *args, **kwargs):
                         from .properties import EmailAddressAttributedValue
                -        kwargs['value_cls'] = EmailAddressAttributedValue
                +
                +        kwargs["value_cls"] = EmailAddressAttributedValue
                         super().__init__(*args, **kwargs)

                Ancestors

                @@ -3446,18 +3545,19 @@

                Inherited members

                is_list = True is_complex = True - PARENT_ELEMENT_NAME = 'EmailAddresses' + PARENT_ELEMENT_NAME = "EmailAddresses" def __init__(self, *args, **kwargs): from .indexed_properties import EmailAddress - kwargs['value_cls'] = EmailAddress + + kwargs["value_cls"] = EmailAddress super().__init__(*args, **kwargs) def clean(self, value, version=None): if value is not None: default_labels = self.value_cls.LABEL_CHOICES if len(value) > len(default_labels): - raise ValueError(f'This field can handle at most {len(default_labels)} values (value: {value})') + raise ValueError(f"This field can handle at most {len(default_labels)} values (value: {value})") tmp = [] for s, default_label in zip(value, default_labels): if not isinstance(s, str): @@ -3504,7 +3604,7 @@

                Methods

                if value is not None: default_labels = self.value_cls.LABEL_CHOICES if len(value) > len(default_labels): - raise ValueError(f'This field can handle at most {len(default_labels)} values (value: {value})') + raise ValueError(f"This field can handle at most {len(default_labels)} values (value: {value})") tmp = [] for s, default_label in zip(value, default_labels): if not isinstance(s, str): @@ -3539,7 +3639,8 @@

                Inherited members

                class EmailField(BaseEmailField):
                     def __init__(self, *args, **kwargs):
                         from .properties import Email
                -        kwargs['value_cls'] = Email
                +
                +        kwargs["value_cls"] = Email
                         super().__init__(*args, **kwargs)

                Ancestors

                @@ -3575,7 +3676,7 @@

                Inherited members

                value_cls = str def from_xml(self, elem, account): - return elem.text or elem.get('Name') # Sometimes elem.text is empty. Exchange saves the same in 'Name' attr + return elem.text or elem.get("Name") # Sometimes elem.text is empty. Exchange saves the same in 'Name' attr

                Ancestors

                  @@ -3648,12 +3749,12 @@

                  Inherited members

                  """ def __init__(self, *args, **kwargs): - self.enum = kwargs.pop('enum') + self.enum = kwargs.pop("enum") # Set different min/max defaults than IntegerField - if 'max' in kwargs: + if "max" in kwargs: raise AttributeError("EnumField does not support the 'max' attribute") - kwargs['min'] = kwargs.pop('min', 1) - kwargs['max'] = kwargs['min'] + len(self.enum) - 1 + kwargs["min"] = kwargs.pop("min", 1) + kwargs["max"] = kwargs["min"] + len(self.enum) - 1 super().__init__(*args, **kwargs) def clean(self, value, version=None): @@ -3686,7 +3787,7 @@

                  Inherited members

                  if val is not None: try: if self.is_list: - return [self.enum.index(v) + 1 for v in val.split(' ')] + return [self.enum.index(v) + 1 for v in val.split(" ")] return self.enum.index(val) + 1 except ValueError: log.warning("Cannot convert value '%s' on field '%s' to type %s", val, self.name, self.value_cls) @@ -3696,7 +3797,7 @@

                  Inherited members

                  def to_xml(self, value, version): field_elem = create_element(self.request_tag()) if self.is_list: - return set_xml_value(field_elem, ' '.join(self.as_string(value)), version=version) + return set_xml_value(field_elem, " ".join(self.as_string(value)), version=version) return set_xml_value(field_elem, self.as_string(value), version=version)

                  Ancestors

                  @@ -3827,7 +3928,7 @@

                  Inherited members

                  is_complex = True def __init__(self, *args, **kwargs): - self.value_cls = kwargs.pop('value_cls') + self.value_cls = kwargs.pop("value_cls") super().__init__(*args, **kwargs) def clean(self, value, version=None): @@ -3845,6 +3946,7 @@

                  Inherited members

                  def field_uri_xml(self): from .properties import ExtendedFieldURI + cls = self.value_cls return ExtendedFieldURI( distinguished_property_set_id=cls.distinguished_property_set_id, @@ -3924,6 +4026,7 @@

                  Methods

                  def field_uri_xml(self):
                       from .properties import ExtendedFieldURI
                  +
                       cls = self.value_cls
                       return ExtendedFieldURI(
                           distinguished_property_set_id=cls.distinguished_property_set_id,
                  @@ -4004,9 +4107,19 @@ 

                  Inherited members

                  # is_complex = False - def __init__(self, name=None, is_required=False, is_required_after_save=False, is_read_only=False, - is_read_only_after_send=False, is_searchable=True, is_attribute=False, default=None, - supported_from=None, deprecated_from=None): + def __init__( + self, + name=None, + is_required=False, + is_required_after_save=False, + is_read_only=False, + is_read_only_after_send=False, + is_searchable=True, + is_attribute=False, + default=None, + supported_from=None, + deprecated_from=None, + ): self.name = name # Usually set by the EWSMeta metaclass self.default = default # Default value if none is given self.is_required = is_required @@ -4024,12 +4137,12 @@

                  Inherited members

                  # The Exchange build when this field was introduced. When talking with versions prior to this version, # we will ignore this field. if supported_from is not None and not isinstance(supported_from, Build): - raise InvalidTypeError('supported_from', supported_from, Build) + raise InvalidTypeError("supported_from", supported_from, Build) self.supported_from = supported_from # The Exchange build when this field was deprecated. When talking with versions at or later than this version, # we will ignore this field. if deprecated_from is not None and not isinstance(deprecated_from, Build): - raise InvalidTypeError('deprecated_from', deprecated_from, Build) + raise InvalidTypeError("deprecated_from", deprecated_from, Build) self.deprecated_from = deprecated_from def clean(self, value, version=None): @@ -4047,12 +4160,12 @@

                  Inherited members

                  for v in value: if not isinstance(v, self.value_cls): raise TypeError(f"Field {self.name!r} value {v!r} must be of type {self.value_cls}") - if hasattr(v, 'clean'): + if hasattr(v, "clean"): v.clean(version=version) else: if not isinstance(value, self.value_cls): raise TypeError(f"Field {self.name!r} value {value!r} must be of type {self.value_cls}") - if hasattr(value, 'clean'): + if hasattr(value, "clean"): value.clean(version=version) return value @@ -4080,9 +4193,10 @@

                  Inherited members

                  """Field instances must be hashable""" def __repr__(self): - args_str = ', '.join(f'{f}={getattr(self, f)!r}' for f in ( - 'name', 'value_cls', 'is_list', 'is_complex', 'default')) - return f'{self.__class__.__name__}({args_str})'
                  + args_str = ", ".join( + f"{f}={getattr(self, f)!r}" for f in ("name", "value_cls", "is_list", "is_complex", "default") + ) + return f"{self.__class__.__name__}({args_str})"

                  Subclasses

                    @@ -4131,12 +4245,12 @@

                    Methods

                    for v in value: if not isinstance(v, self.value_cls): raise TypeError(f"Field {self.name!r} value {v!r} must be of type {self.value_cls}") - if hasattr(v, 'clean'): + if hasattr(v, "clean"): v.clean(version=version) else: if not isinstance(value, self.value_cls): raise TypeError(f"Field {self.name!r} value {value!r} must be of type {self.value_cls}") - if hasattr(value, 'clean'): + if hasattr(value, "clean"): value.clean(version=version) return value @@ -4203,6 +4317,7 @@

                    Methods

                    class FieldOrder:
                         """Holds values needed to call server-side sorting on a single field path."""
                    +
                         def __init__(self, field_path, reverse=False):
                             """
                     
                    @@ -4215,12 +4330,12 @@ 

                    Methods

                    @classmethod def from_string(cls, field_path, folder): return cls( - field_path=FieldPath.from_string(field_path=field_path.lstrip('-'), folder=folder, strict=True), - reverse=field_path.startswith('-') + field_path=FieldPath.from_string(field_path=field_path.lstrip("-"), folder=folder, strict=True), + reverse=field_path.startswith("-"), ) def to_xml(self): - field_order = create_element('t:FieldOrder', attrs=dict(Order='Descending' if self.reverse else 'Ascending')) + field_order = create_element("t:FieldOrder", attrs=dict(Order="Descending" if self.reverse else "Ascending")) field_order.append(self.field_path.to_xml()) return field_order
                    @@ -4238,8 +4353,8 @@

                    Static methods

                    @classmethod
                     def from_string(cls, field_path, folder):
                         return cls(
                    -        field_path=FieldPath.from_string(field_path=field_path.lstrip('-'), folder=folder, strict=True),
                    -        reverse=field_path.startswith('-')
                    +        field_path=FieldPath.from_string(field_path=field_path.lstrip("-"), folder=folder, strict=True),
                    +        reverse=field_path.startswith("-"),
                         )
                    @@ -4256,7 +4371,7 @@

                    Methods

                    Expand source code
                    def to_xml(self):
                    -    field_order = create_element('t:FieldOrder', attrs=dict(Order='Descending' if self.reverse else 'Ascending'))
                    +    field_order = create_element("t:FieldOrder", attrs=dict(Order="Descending" if self.reverse else "Ascending"))
                         field_order.append(self.field_path.to_xml())
                         return field_order
                    @@ -4331,8 +4446,11 @@

                    Methods

                    # If this path does not point to a specific subfield on an indexed property, return all the possible path # combinations for this field path. if isinstance(self.field, IndexedField): - labels = [self.label] if self.label \ - else self.field.value_cls.get_field_by_fieldname('label').supported_choices(version=version) + labels = ( + [self.label] + if self.label + else self.field.value_cls.get_field_by_fieldname("label").supported_choices(version=version) + ) subfields = [self.subfield] if self.subfield else self.field.value_cls.supported_fields(version=version) for label in labels: for subfield in subfields: @@ -4344,9 +4462,10 @@

                    Methods

                    def path(self): if self.label: from .indexed_properties import SingleFieldIndexedElement + if issubclass(self.field.value_cls, SingleFieldIndexedElement) or not self.subfield: - return f'{self.field.name}__{self.label}' - return f'{self.field.name}__{self.label}__{self.subfield.name}' + return f"{self.field.name}__{self.label}" + return f"{self.field.name}__{self.label}__{self.subfield.name}" return self.field.name def __eq__(self, other): @@ -4392,9 +4511,10 @@

                    Instance variables

                    def path(self): if self.label: from .indexed_properties import SingleFieldIndexedElement + if issubclass(self.field.value_cls, SingleFieldIndexedElement) or not self.subfield: - return f'{self.field.name}__{self.label}' - return f'{self.field.name}__{self.label}__{self.subfield.name}' + return f"{self.field.name}__{self.label}" + return f"{self.field.name}__{self.label}__{self.subfield.name}" return self.field.name @@ -4414,8 +4534,11 @@

                    Methods

                    # If this path does not point to a specific subfield on an indexed property, return all the possible path # combinations for this field path. if isinstance(self.field, IndexedField): - labels = [self.label] if self.label \ - else self.field.value_cls.get_field_by_fieldname('label').supported_choices(version=version) + labels = ( + [self.label] + if self.label + else self.field.value_cls.get_field_by_fieldname("label").supported_choices(version=version) + ) subfields = [self.subfield] if self.subfield else self.field.value_cls.supported_fields(version=version) for label in labels: for subfield in subfields: @@ -4501,16 +4624,16 @@

                    Methods

                    """ def __init__(self, *args, **kwargs): - self.field_uri = kwargs.pop('field_uri', None) - self.namespace = kwargs.pop('namespace', TNS) + self.field_uri = kwargs.pop("field_uri", None) + self.namespace = kwargs.pop("namespace", TNS) super().__init__(*args, **kwargs) # See all valid FieldURI values at # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/fielduri # The field_uri has a prefix when the FieldURI points to an Item field. if self.field_uri is None: self.field_uri_postfix = None - elif ':' in self.field_uri: - self.field_uri_postfix = self.field_uri.split(':')[1] + elif ":" in self.field_uri: + self.field_uri_postfix = self.field_uri.split(":")[1] else: self.field_uri_postfix = self.field_uri @@ -4535,6 +4658,7 @@

                    Methods

                    def field_uri_xml(self): from .properties import FieldURI + if not self.field_uri: raise ValueError(f"'field_uri' value is missing on field '{self.name}'") return FieldURI(field_uri=self.field_uri).to_xml(version=None) @@ -4542,12 +4666,12 @@

                    Methods

                    def request_tag(self): if not self.field_uri_postfix: raise ValueError(f"'field_uri_postfix' value is missing on field '{self.name}'") - return f't:{self.field_uri_postfix}' + return f"t:{self.field_uri_postfix}" def response_tag(self): if not self.field_uri_postfix: raise ValueError(f"'field_uri_postfix' value is missing on field '{self.name}'") - return f'{{{self.namespace}}}{self.field_uri_postfix}' + return f"{{{self.namespace}}}{self.field_uri_postfix}" def __hash__(self): return hash(self.field_uri) @@ -4585,6 +4709,7 @@

                    Methods

                    def field_uri_xml(self):
                         from .properties import FieldURI
                    +
                         if not self.field_uri:
                             raise ValueError(f"'field_uri' value is missing on field '{self.name}'")
                         return FieldURI(field_uri=self.field_uri).to_xml(version=None)
                    @@ -4602,7 +4727,7 @@

                    Methods

                    def request_tag(self):
                         if not self.field_uri_postfix:
                             raise ValueError(f"'field_uri_postfix' value is missing on field '{self.name}'")
                    -    return f't:{self.field_uri_postfix}'
                    + return f"t:{self.field_uri_postfix}"
                    @@ -4617,7 +4742,7 @@

                    Methods

                    def response_tag(self):
                         if not self.field_uri_postfix:
                             raise ValueError(f"'field_uri_postfix' value is missing on field '{self.name}'")
                    -    return f'{{{self.namespace}}}{self.field_uri_postfix}'
                    + return f"{{{self.namespace}}}{self.field_uri_postfix}"
                    @@ -4645,7 +4770,7 @@

                    Inherited members

                    """Like ChoiceField, but specifically for Free/Busy values.""" def __init__(self, *args, **kwargs): - kwargs['choices'] = set(FREE_BUSY_CHOICES) + kwargs["choices"] = set(FREE_BUSY_CHOICES) super().__init__(*args, **kwargs)

                    Ancestors

                    @@ -4687,13 +4812,28 @@

                    Inherited members

                    return {v.response_tag(): v for v in self.value_classes} def __init__(self, *args, **kwargs): - from .properties import CopiedEvent, CreatedEvent, DeletedEvent, ModifiedEvent, MovedEvent, \ - NewMailEvent, StatusEvent, FreeBusyChangedEvent - kwargs['value_cls'] = None # Parent class requires this kwarg - kwargs['namespace'] = None # Parent class requires this kwarg + from .properties import ( + CopiedEvent, + CreatedEvent, + DeletedEvent, + FreeBusyChangedEvent, + ModifiedEvent, + MovedEvent, + NewMailEvent, + StatusEvent, + ) + + kwargs["value_cls"] = None # Parent class requires this kwarg + kwargs["namespace"] = None # Parent class requires this kwarg super().__init__(*args, **kwargs) self.value_classes = ( - CopiedEvent, CreatedEvent, DeletedEvent, ModifiedEvent, MovedEvent, NewMailEvent, StatusEvent, + CopiedEvent, + CreatedEvent, + DeletedEvent, + ModifiedEvent, + MovedEvent, + NewMailEvent, + StatusEvent, FreeBusyChangedEvent, ) @@ -4743,8 +4883,8 @@

                    Inherited members

                    class IdElementField(EWSElementField):
                         def __init__(self, *args, **kwargs):
                    -        kwargs['is_searchable'] = False
                    -        kwargs['is_read_only'] = True
                    +        kwargs["is_searchable"] = False
                    +        kwargs["is_read_only"] = True
                             super().__init__(*args, **kwargs)

                    Ancestors

                    @@ -4822,16 +4962,17 @@

                    Inherited members

                    def __init__(self, *args, **kwargs): from .indexed_properties import IndexedElement - value_cls = kwargs['value_cls'] + + value_cls = kwargs["value_cls"] if not issubclass(value_cls, IndexedElement): raise TypeError(f"'value_cls' {value_cls!r} must be a subclass of type {IndexedElement}") super().__init__(*args, **kwargs) def to_xml(self, value, version): - return set_xml_value(create_element(f't:{self.PARENT_ELEMENT_NAME}'), value, version=version) + return set_xml_value(create_element(f"t:{self.PARENT_ELEMENT_NAME}"), value, version=version) def response_tag(self): - return f'{{{self.namespace}}}{self.PARENT_ELEMENT_NAME}' + return f"{{{self.namespace}}}{self.PARENT_ELEMENT_NAME}" def __hash__(self): return hash(self.field_uri) @@ -4867,7 +5008,7 @@

                    Methods

                    Expand source code
                    def response_tag(self):
                    -    return f'{{{self.namespace}}}{self.PARENT_ELEMENT_NAME}'
                    + return f"{{{self.namespace}}}{self.PARENT_ELEMENT_NAME}" @@ -4897,14 +5038,13 @@

                    Inherited members

                    value_cls = int def __init__(self, *args, **kwargs): - self.min = kwargs.pop('min', None) - self.max = kwargs.pop('max', None) + self.min = kwargs.pop("min", None) + self.max = kwargs.pop("max", None) super().__init__(*args, **kwargs) def _clean_single_value(self, v): if self.min is not None and v < self.min: - raise ValueError( - f"Value {v!r} on field {self.name!r} must be greater than {self.min}") + raise ValueError(f"Value {v!r} on field {self.name!r} must be greater than {self.min}") if self.max is not None and v > self.max: raise ValueError(f"Value {v!r} on field {self.name!r} must be less than {self.max}") @@ -5062,10 +5202,12 @@

                    Ancestors

                    @property def value_cls(self): from .items import Item + return Item def from_xml(self, elem, account): from .items import ITEM_CLASSES + for item_cls in ITEM_CLASSES: item_elem = elem.find(item_cls.response_tag()) if item_elem is not None: @@ -5093,6 +5235,7 @@

                    Instance variables

                    @property
                     def value_cls(self):
                         from .items import Item
                    +
                         return Item
                    @@ -5159,7 +5302,8 @@

                    Inherited members

                    class MailboxField(BaseEmailField):
                         def __init__(self, *args, **kwargs):
                             from .properties import Mailbox
                    -        kwargs['value_cls'] = Mailbox
                    +
                    +        kwargs["value_cls"] = Mailbox
                             super().__init__(*args, **kwargs)

                    Ancestors

                    @@ -5192,7 +5336,8 @@

                    Inherited members

                    class MailboxListField(EWSElementListField):
                         def __init__(self, *args, **kwargs):
                             from .properties import Mailbox
                    -        kwargs['value_cls'] = Mailbox
                    +
                    +        kwargs["value_cls"] = Mailbox
                             super().__init__(*args, **kwargs)
                     
                         def clean(self, value, version=None):
                    @@ -5248,15 +5393,15 @@ 

                    Inherited members

                    class MemberListField(EWSElementListField):
                         def __init__(self, *args, **kwargs):
                             from .properties import Member
                    -        kwargs['value_cls'] = Member
                    +
                    +        kwargs["value_cls"] = Member
                             super().__init__(*args, **kwargs)
                     
                         def clean(self, value, version=None):
                             from .properties import Mailbox
                    +
                             if value is not None:
                    -            value = [
                    -                self.value_cls(mailbox=Mailbox(email_address=s)) if isinstance(s, str) else s for s in value
                    -            ]
                    +            value = [self.value_cls(mailbox=Mailbox(email_address=s)) if isinstance(s, str) else s for s in value]
                             return super().clean(value, version=version)

                    Ancestors

                    @@ -5279,10 +5424,9 @@

                    Methods

                    def clean(self, value, version=None):
                         from .properties import Mailbox
                    +
                         if value is not None:
                    -        value = [
                    -            self.value_cls(mailbox=Mailbox(email_address=s)) if isinstance(s, str) else s for s in value
                    -        ]
                    +        value = [self.value_cls(mailbox=Mailbox(email_address=s)) if isinstance(s, str) else s for s in value]
                         return super().clean(value, version=version)
                    @@ -5310,20 +5454,20 @@

                    Inherited members

                    class MessageField(TextField):
                         """A field that handles the Message element."""
                     
                    -    INNER_ELEMENT_NAME = 'Message'
                    +    INNER_ELEMENT_NAME = "Message"
                     
                         def from_xml(self, elem, account):
                             reply = elem.find(self.response_tag())
                             if reply is None:
                                 return None
                    -        message = reply.find(f'{{{TNS}}}{self.INNER_ELEMENT_NAME}')
                    +        message = reply.find(f"{{{TNS}}}{self.INNER_ELEMENT_NAME}")
                             if message is None:
                                 return None
                             return message.text
                     
                         def to_xml(self, value, version):
                             field_elem = create_element(self.request_tag())
                    -        message = create_element(f't:{self.INNER_ELEMENT_NAME}')
                    +        message = create_element(f"t:{self.INNER_ELEMENT_NAME}")
                             message.text = value
                             return set_xml_value(field_elem, message, version=version)
                    @@ -5364,7 +5508,8 @@

                    Inherited members

                    class MessageHeaderField(EWSElementListField):
                         def __init__(self, *args, **kwargs):
                             from .properties import MessageHeader
                    -        kwargs['value_cls'] = MessageHeader
                    +
                    +        kwargs["value_cls"] = MessageHeader
                             super().__init__(*args, **kwargs)

                    Ancestors

                    @@ -5435,8 +5580,8 @@

                    Inherited members

                    value_cls = str def __init__(self, *args, **kwargs): - self.field_uri = kwargs.pop('field_uri') - if ':' in self.field_uri: + self.field_uri = kwargs.pop("field_uri") + if ":" in self.field_uri: raise ValueError("'field_uri' value must not contain a colon") super().__init__(*args, **kwargs) @@ -5453,13 +5598,14 @@

                    Inherited members

                    def field_uri_xml(self, field_uri, label): from .properties import IndexedFieldURI - return IndexedFieldURI(field_uri=f'{field_uri}:{self.field_uri}', field_index=label).to_xml(version=None) + + return IndexedFieldURI(field_uri=f"{field_uri}:{self.field_uri}", field_index=label).to_xml(version=None) def request_tag(self): - return f't:{self.field_uri}' + return f"t:{self.field_uri}" def response_tag(self): - return f'{{{self.namespace}}}{self.field_uri}'
                    + return f"{{{self.namespace}}}{self.field_uri}"

                    Ancestors

                      @@ -5479,7 +5625,8 @@

                      Methods

                      def field_uri_xml(self, field_uri, label):
                           from .properties import IndexedFieldURI
                      -    return IndexedFieldURI(field_uri=f'{field_uri}:{self.field_uri}', field_index=label).to_xml(version=None)
                      + + return IndexedFieldURI(field_uri=f"{field_uri}:{self.field_uri}", field_index=label).to_xml(version=None)
                      @@ -5492,7 +5639,7 @@

                      Methods

                      Expand source code
                      def request_tag(self):
                      -    return f't:{self.field_uri}'
                      + return f"t:{self.field_uri}"
                      @@ -5505,7 +5652,7 @@

                      Methods

                      Expand source code
                      def response_tag(self):
                      -    return f'{{{self.namespace}}}{self.field_uri}'
                      + return f"{{{self.namespace}}}{self.field_uri}"
                      @@ -5642,7 +5789,8 @@

                      Inherited members

                      def __init__(self, *args, **kwargs): from .properties import PermissionSet - kwargs['value_cls'] = PermissionSet + + kwargs["value_cls"] = PermissionSet super().__init__(*args, **kwargs)

                      Ancestors

                      @@ -5683,7 +5831,8 @@

                      Inherited members

                      def __init__(self, *args, **kwargs): from .properties import PhoneNumber - kwargs['value_cls'] = PhoneNumber + + kwargs["value_cls"] = PhoneNumber super().__init__(*args, **kwargs)

                      Ancestors

                      @@ -5722,7 +5871,8 @@

                      Inherited members

                      class PhoneNumberAttributedValueField(EWSElementListField):
                           def __init__(self, *args, **kwargs):
                               from .properties import PhoneNumberAttributedValue
                      -        kwargs['value_cls'] = PhoneNumberAttributedValue
                      +
                      +        kwargs["value_cls"] = PhoneNumberAttributedValue
                               super().__init__(*args, **kwargs)

                      Ancestors

                      @@ -5756,11 +5906,12 @@

                      Inherited members

                      is_list = True is_complex = True - PARENT_ELEMENT_NAME = 'PhoneNumbers' + PARENT_ELEMENT_NAME = "PhoneNumbers" def __init__(self, *args, **kwargs): from .indexed_properties import PhoneNumber - kwargs['value_cls'] = PhoneNumber + + kwargs["value_cls"] = PhoneNumber super().__init__(*args, **kwargs)

                      Ancestors

                      @@ -5809,11 +5960,12 @@

                      Inherited members

                      is_list = True is_complex = True - PARENT_ELEMENT_NAME = 'PhysicalAddresses' + PARENT_ELEMENT_NAME = "PhysicalAddresses" def __init__(self, *args, **kwargs): from .indexed_properties import PhysicalAddress - kwargs['value_cls'] = PhysicalAddress + + kwargs["value_cls"] = PhysicalAddress super().__init__(*args, **kwargs)

                      Ancestors

                      @@ -5861,7 +6013,8 @@

                      Inherited members

                      class PostalAddressAttributedValueField(EWSElementListField):
                           def __init__(self, *args, **kwargs):
                               from .properties import PostalAddressAttributedValue
                      -        kwargs['value_cls'] = PostalAddressAttributedValue
                      +
                      +        kwargs["value_cls"] = PostalAddressAttributedValue
                               super().__init__(*args, **kwargs)

                      Ancestors

                      @@ -5895,7 +6048,8 @@

                      Inherited members

                      # There is not containing element for this field. Just multiple 'Protocol' elements on the 'Account' element. def __init__(self, *args, **kwargs): from .autodiscover.properties import Protocol - kwargs['value_cls'] = Protocol + + kwargs["value_cls"] = Protocol super().__init__(*args, **kwargs) def from_xml(self, elem, account): @@ -5931,7 +6085,8 @@

                      Inherited members

                      class RecipientAddressField(BaseEmailField):
                           def __init__(self, *args, **kwargs):
                               from .properties import RecipientAddress
                      -        kwargs['value_cls'] = RecipientAddress
                      +
                      +        kwargs["value_cls"] = RecipientAddress
                               super().__init__(*args, **kwargs)

                      Ancestors

                      @@ -5966,7 +6121,8 @@

                      Inherited members

                      def __init__(self, *args, **kwargs): from .recurrence import Recurrence - kwargs['value_cls'] = Recurrence + + kwargs["value_cls"] = Recurrence super().__init__(*args, **kwargs) def to_xml(self, value, version): @@ -6010,7 +6166,8 @@

                      Inherited members

                      def __init__(self, *args, **kwargs): from .properties import ReferenceItemId - kwargs['value_cls'] = ReferenceItemId + + kwargs["value_cls"] = ReferenceItemId super().__init__(*args, **kwargs) def to_xml(self, value, version): @@ -6051,8 +6208,8 @@

                      Inherited members

                      class RoutingTypeField(ChoiceField):
                           def __init__(self, *args, **kwargs):
                      -        kwargs['choices'] = {Choice('SMTP'), Choice('EX')}
                      -        kwargs['default'] = 'SMTP'
                      +        kwargs["choices"] = {Choice("SMTP"), Choice("EX")}
                      +        kwargs["default"] = "SMTP"
                               super().__init__(*args, **kwargs)

                      Ancestors

                      @@ -6087,7 +6244,8 @@

                      Inherited members

                      class StringAttributedValueField(EWSElementListField):
                           def __init__(self, *args, **kwargs):
                               from .properties import StringAttributedValue
                      -        kwargs['value_cls'] = StringAttributedValue
                      +
                      +        kwargs["value_cls"] = StringAttributedValue
                               super().__init__(*args, **kwargs)

                      Ancestors

                      @@ -6132,12 +6290,13 @@

                      Inherited members

                      @staticmethod def field_uri_xml(field_uri, label): from .properties import IndexedFieldURI + return IndexedFieldURI(field_uri=field_uri, field_index=label).to_xml(version=None) def clean(self, value, version=None): value = super().clean(value, version=version) if self.is_required and not value: - raise ValueError(f'Value for subfield {self.name!r} must be non-empty') + raise ValueError(f"Value for subfield {self.name!r} must be non-empty") return value def __hash__(self): @@ -6185,6 +6344,7 @@

                      Static methods

                      @staticmethod
                       def field_uri_xml(field_uri, label):
                           from .properties import IndexedFieldURI
                      +
                           return IndexedFieldURI(field_uri=field_uri, field_index=label).to_xml(version=None)
                      @@ -6203,7 +6363,7 @@

                      Methods

                      def clean(self, value, version=None):
                           value = super().clean(value, version=version)
                           if self.is_required and not value:
                      -        raise ValueError(f'Value for subfield {self.name!r} must be non-empty')
                      +        raise ValueError(f"Value for subfield {self.name!r} must be non-empty")
                           return value
                      @@ -6233,7 +6393,8 @@

                      Inherited members

                      def __init__(self, *args, **kwargs): from .recurrence import TaskRecurrence - kwargs['value_cls'] = TaskRecurrence + + kwargs["value_cls"] = TaskRecurrence super().__init__(*args, **kwargs) def to_xml(self, value, version): @@ -6336,14 +6497,14 @@

                      Inherited members

                      is_list = True def __init__(self, *args, **kwargs): - self.list_elem_name = kwargs.pop('list_elem_name', 'String') + self.list_elem_name = kwargs.pop("list_elem_name", "String") super().__init__(*args, **kwargs) def list_elem_request_tag(self): - return f't:{self.list_elem_name}' + return f"t:{self.list_elem_name}" def list_elem_response_tag(self): - return f'{{{self.namespace}}}{self.list_elem_name}' + return f"{{{self.namespace}}}{self.list_elem_name}" def from_xml(self, elem, account): iter_elem = elem.find(self.response_tag()) @@ -6386,7 +6547,7 @@

                      Methods

                      Expand source code
                      def list_elem_request_tag(self):
                      -    return f't:{self.list_elem_name}'
                      + return f"t:{self.list_elem_name}"
                      @@ -6399,7 +6560,7 @@

                      Methods

                      Expand source code
                      def list_elem_response_tag(self):
                      -    return f'{{{self.namespace}}}{self.list_elem_name}'
                      + return f"{{{self.namespace}}}{self.list_elem_name}"
                      @@ -6430,14 +6591,13 @@

                      Inherited members

                      value_cls = datetime.timedelta def __init__(self, *args, **kwargs): - self.min = kwargs.pop('min', datetime.timedelta(0)) - self.max = kwargs.pop('max', datetime.timedelta(days=1)) + self.min = kwargs.pop("min", datetime.timedelta(0)) + self.max = kwargs.pop("max", datetime.timedelta(days=1)) super().__init__(*args, **kwargs) def clean(self, value, version=None): if self.min is not None and value < self.min: - raise ValueError( - f"Value {value!r} on field {self.name!r} must be greater than {self.min}") + raise ValueError(f"Value {value!r} on field {self.name!r} must be greater than {self.min}") if self.max is not None and value > self.max: raise ValueError(f"Value {value!r} on field {self.name!r} must be less than {self.max}") return super().clean(value, version=version) @@ -6467,8 +6627,7 @@

                      Methods

                      def clean(self, value, version=None):
                           if self.min is not None and value < self.min:
                      -        raise ValueError(
                      -            f"Value {value!r} on field {self.name!r} must be greater than {self.min}")
                      +        raise ValueError(f"Value {value!r} on field {self.name!r} must be greater than {self.min}")
                           if self.max is not None and value > self.max:
                               raise ValueError(f"Value {value!r} on field {self.name!r} must be less than {self.max}")
                           return super().clean(value, version=version)
                      @@ -6504,9 +6663,9 @@

                      Inherited members

                      val = self._get_val_from_elem(elem) if val is not None: try: - if ':' in val: + if ":" in val: # Assume a string of the form HH:MM:SS - return datetime.datetime.strptime(val, '%H:%M:%S').time() + return datetime.datetime.strptime(val, "%H:%M:%S").time() # Assume an integer in minutes since midnight return (datetime.datetime(2000, 1, 1) + datetime.timedelta(minutes=int(val))).time() except ValueError: @@ -6561,14 +6720,16 @@

                      Inherited members

                      def from_xml(self, elem, account): field_elem = elem.find(self.response_tag()) if field_elem is not None: - ms_id = field_elem.get('Id') - ms_name = field_elem.get('Name') + ms_id = field_elem.get("Id") + ms_name = field_elem.get("Name") try: return self.value_cls.from_ms_id(ms_id or ms_name) except UnknownTimeZone: log.warning( "Cannot convert value '%s' on field '%s' to type %s (unknown timezone ID)", - (ms_id or ms_name), self.name, self.value_cls + (ms_id or ms_name), + self.name, + self.value_cls, ) return None return self.default @@ -6576,7 +6737,7 @@

                      Inherited members

                      def to_xml(self, value, version): attrs = dict(Id=value.ms_id) if value.ms_name: - attrs['Name'] = value.ms_name + attrs["Name"] = value.ms_name return create_element(self.request_tag(), attrs=attrs)

                      Ancestors

                      @@ -6634,7 +6795,8 @@

                      Inherited members

                      class TransitionListField(EWSElementListField):
                           def __init__(self, *args, **kwargs):
                               from .properties import BaseTransition
                      -        kwargs['value_cls'] = BaseTransition
                      +
                      +        kwargs["value_cls"] = BaseTransition
                               super().__init__(*args, **kwargs)
                       
                           def from_xml(self, elem, account):
                      @@ -6676,47 +6838,47 @@ 

                      Inherited members

                      """This field type has no value_cls because values may have many different types.""" TYPES_MAP = { - 'Boolean': bool, - 'Integer32': int, - 'UnsignedInteger32': int, - 'Integer64': int, - 'UnsignedInteger64': int, + "Boolean": bool, + "Integer32": int, + "UnsignedInteger32": int, + "Integer64": int, + "UnsignedInteger64": int, # Python doesn't have a single-byte type to represent 'Byte' - 'ByteArray': bytes, - 'String': str, - 'StringArray': str, # A list of strings - 'DateTime': EWSDateTime, + "ByteArray": bytes, + "String": str, + "StringArray": str, # A list of strings + "DateTime": EWSDateTime, } TYPES_MAP_REVERSED = { - bool: 'Boolean', - int: 'Integer64', + bool: "Boolean", + int: "Integer64", # Python doesn't have a single-byte type to represent 'Byte' - bytes: 'ByteArray', - str: 'String', - datetime.datetime: 'DateTime', - EWSDateTime: 'DateTime', + bytes: "ByteArray", + str: "String", + datetime.datetime: "DateTime", + EWSDateTime: "DateTime", } @classmethod def get_type(cls, value): if isinstance(value, bytes) and len(value) == 1: # This is a single byte. Translate it to the 'Byte' type - return 'Byte' + return "Byte" if is_iterable(value): # We don't allow generators as values, so keep the logic simple try: first = next(iter(value)) except StopIteration: first = None - value_type = f'{cls.TYPES_MAP_REVERSED[type(first)]}Array' + value_type = f"{cls.TYPES_MAP_REVERSED[type(first)]}Array" if value_type not in cls.TYPES_MAP: - raise ValueError(f'{value!r} is not a supported type') + raise ValueError(f"{value!r} is not a supported type") return value_type return cls.TYPES_MAP_REVERSED[type(value)] @classmethod def is_array_type(cls, value_type): - return value_type == 'StringArray' + return value_type == "StringArray" def clean(self, value, version=None): if value is None: @@ -6729,30 +6891,30 @@

                      Inherited members

                      field_elem = elem.find(self.response_tag()) if field_elem is None: return self.default - value_type_str = get_xml_attr(field_elem, f'{{{TNS}}}Type') - value = get_xml_attr(field_elem, f'{{{TNS}}}Value') - if value_type_str == 'Byte': + value_type_str = get_xml_attr(field_elem, f"{{{TNS}}}Type") + value = get_xml_attr(field_elem, f"{{{TNS}}}Value") + if value_type_str == "Byte": try: # The value is an unsigned integer in the range 0 -> 255. Convert it to a single byte - return xml_text_to_value(value, int).to_bytes(1, 'little', signed=False) + return xml_text_to_value(value, int).to_bytes(1, "little", signed=False) except OverflowError as e: - log.warning('Invalid byte value %r (%e)', value, e) + log.warning("Invalid byte value %r (%e)", value, e) return None value_type = self.TYPES_MAP[value_type_str] - if self. is_array_type(value_type_str): - return tuple(xml_text_to_value(value=v, value_type=value_type) for v in value.split(' ')) + if self.is_array_type(value_type_str): + return tuple(xml_text_to_value(value=v, value_type=value_type) for v in value.split(" ")) return xml_text_to_value(value=value, value_type=value_type) def to_xml(self, value, version): value_type_str = self.get_type(value) - if value_type_str == 'Byte': + if value_type_str == "Byte": # A single byte is encoded to an unsigned integer in the range 0 -> 255 - value = int.from_bytes(value, byteorder='little', signed=False) + value = int.from_bytes(value, byteorder="little", signed=False) elif is_iterable(value): - value = ' '.join(value_to_xml_text(v) for v in value) + value = " ".join(value_to_xml_text(v) for v in value) field_elem = create_element(self.request_tag()) - field_elem.append(set_xml_value(create_element('t:Type'), value_type_str, version=version)) - field_elem.append(set_xml_value(create_element('t:Value'), value, version=version)) + field_elem.append(set_xml_value(create_element("t:Type"), value_type_str, version=version)) + field_elem.append(set_xml_value(create_element("t:Value"), value, version=version)) return field_elem

                      Ancestors

                      @@ -6786,16 +6948,16 @@

                      Static methods

                      def get_type(cls, value): if isinstance(value, bytes) and len(value) == 1: # This is a single byte. Translate it to the 'Byte' type - return 'Byte' + return "Byte" if is_iterable(value): # We don't allow generators as values, so keep the logic simple try: first = next(iter(value)) except StopIteration: first = None - value_type = f'{cls.TYPES_MAP_REVERSED[type(first)]}Array' + value_type = f"{cls.TYPES_MAP_REVERSED[type(first)]}Array" if value_type not in cls.TYPES_MAP: - raise ValueError(f'{value!r} is not a supported type') + raise ValueError(f"{value!r} is not a supported type") return value_type return cls.TYPES_MAP_REVERSED[type(value)] @@ -6811,7 +6973,7 @@

                      Static methods

                      @classmethod
                       def is_array_type(cls, value_type):
                      -    return value_type == 'StringArray'
                      + return value_type == "StringArray" @@ -6890,7 +7052,7 @@

                      Inherited members

                      class UnknownEntriesField(CharListField):
                           def list_elem_tag(self):
                      -        return f'{{{self.namespace}}}UnknownEntry'
                      + return f"{{{self.namespace}}}UnknownEntry"

                      Ancestors

                        @@ -6912,7 +7074,7 @@

                        Methods

                        Expand source code
                        def list_elem_tag(self):
                        -    return f'{{{self.namespace}}}UnknownEntry'
                        + return f"{{{self.namespace}}}UnknownEntry" diff --git a/docs/exchangelib/folders/base.html b/docs/exchangelib/folders/base.html index fc009503..df412a1b 100644 --- a/docs/exchangelib/folders/base.html +++ b/docs/exchangelib/folders/base.html @@ -31,18 +31,47 @@

                        Module exchangelib.folders.base

                        from fnmatch import fnmatch from operator import attrgetter -from .collections import FolderCollection, SyncCompleted, PullSubscription, PushSubscription, StreamingSubscription -from .queryset import SingleFolderQuerySet, MISSING_FOLDER_ERRORS, SHALLOW as SHALLOW_FOLDERS, DEEP as DEEP_FOLDERS -from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorCannotEmptyFolder, ErrorCannotDeleteObject, \ - ErrorDeleteDistinguishedFolder, ErrorItemNotFound, InvalidTypeError -from ..fields import IntegerField, CharField, FieldPath, EffectiveRightsField, PermissionSetField, EWSElementField, \ - Field, IdElementField, InvalidField -from ..items import CalendarItem, RegisterMixIn, ITEM_CLASSES, HARD_DELETE, SHALLOW as SHALLOW_ITEMS -from ..properties import Mailbox, FolderId, ParentFolderId, DistinguishedFolderId, UserConfiguration, \ - UserConfigurationName, UserConfigurationNameMNS, EWSMeta -from ..queryset import SearchableMixIn, DoesNotExist -from ..util import TNS, require_id, is_iterable +from ..errors import ( + ErrorAccessDenied, + ErrorCannotDeleteObject, + ErrorCannotEmptyFolder, + ErrorDeleteDistinguishedFolder, + ErrorFolderNotFound, + ErrorItemNotFound, + InvalidTypeError, +) +from ..fields import ( + CharField, + EffectiveRightsField, + EWSElementField, + Field, + FieldPath, + IdElementField, + IntegerField, + InvalidField, + PermissionSetField, +) +from ..items import HARD_DELETE, ITEM_CLASSES +from ..items import SHALLOW as SHALLOW_ITEMS +from ..items import CalendarItem, RegisterMixIn +from ..properties import ( + DistinguishedFolderId, + EWSMeta, + FolderId, + Mailbox, + ParentFolderId, + UserConfiguration, + UserConfigurationName, + UserConfigurationNameMNS, +) +from ..queryset import DoesNotExist, SearchableMixIn +from ..util import TNS, is_iterable, require_id from ..version import EXCHANGE_2007_SP1, EXCHANGE_2010 +from .collections import FolderCollection, PullSubscription, PushSubscription, StreamingSubscription, SyncCompleted +from .queryset import DEEP as DEEP_FOLDERS +from .queryset import MISSING_FOLDER_ERRORS +from .queryset import SHALLOW as SHALLOW_FOLDERS +from .queryset import SingleFolderQuerySet log = logging.getLogger(__name__) @@ -50,7 +79,7 @@

                        Module exchangelib.folders.base

                        class BaseFolder(RegisterMixIn, SearchableMixIn, metaclass=EWSMeta): """Base class for all classes that implement a folder.""" - ELEMENT_NAME = 'Folder' + ELEMENT_NAME = "Folder" NAMESPACE = TNS # See https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/distinguishedfolderid DISTINGUISHED_FOLDER_ID = None @@ -69,24 +98,23 @@

                        Module exchangelib.folders.base

                        ITEM_MODEL_MAP = {cls.response_tag(): cls for cls in ITEM_CLASSES} ID_ELEMENT_CLS = FolderId - _id = IdElementField(field_uri='folder:FolderId', value_cls=ID_ELEMENT_CLS) - parent_folder_id = EWSElementField(field_uri='folder:ParentFolderId', value_cls=ParentFolderId, - is_read_only=True) - folder_class = CharField(field_uri='folder:FolderClass', is_required_after_save=True) - name = CharField(field_uri='folder:DisplayName') - total_count = IntegerField(field_uri='folder:TotalCount', is_read_only=True) - child_folder_count = IntegerField(field_uri='folder:ChildFolderCount', is_read_only=True) - unread_count = IntegerField(field_uri='folder:UnreadCount', is_read_only=True) + _id = IdElementField(field_uri="folder:FolderId", value_cls=ID_ELEMENT_CLS) + parent_folder_id = EWSElementField(field_uri="folder:ParentFolderId", value_cls=ParentFolderId, is_read_only=True) + folder_class = CharField(field_uri="folder:FolderClass", is_required_after_save=True) + name = CharField(field_uri="folder:DisplayName") + total_count = IntegerField(field_uri="folder:TotalCount", is_read_only=True) + child_folder_count = IntegerField(field_uri="folder:ChildFolderCount", is_read_only=True) + unread_count = IntegerField(field_uri="folder:UnreadCount", is_read_only=True) - __slots__ = 'is_distinguished', 'item_sync_state', 'folder_sync_state' + __slots__ = "is_distinguished", "item_sync_state", "folder_sync_state" # Used to register extended properties - INSERT_AFTER_FIELD = 'child_folder_count' + INSERT_AFTER_FIELD = "child_folder_count" def __init__(self, **kwargs): - self.is_distinguished = kwargs.pop('is_distinguished', False) - self.item_sync_state = kwargs.pop('item_sync_state', None) - self.folder_sync_state = kwargs.pop('folder_sync_state', None) + self.is_distinguished = kwargs.pop("is_distinguished", False) + self.item_sync_state = kwargs.pop("item_sync_state", None) + self.folder_sync_state = kwargs.pop("folder_sync_state", None) super().__init__(**kwargs) @property @@ -131,7 +159,7 @@

                        Module exchangelib.folders.base

                        @property def absolute(self): - return ''.join(f'/{p.name}' for p in self.parts) + return "".join(f"/{p.name}" for p in self.parts) def _walk(self): for c in self.children: @@ -142,23 +170,23 @@

                        Module exchangelib.folders.base

                        return FolderCollection(account=self.account, folders=self._walk()) def _glob(self, pattern): - split_pattern = pattern.rsplit('/', 1) + split_pattern = pattern.rsplit("/", 1) head, tail = (split_pattern[0], None) if len(split_pattern) == 1 else split_pattern - if head == '': + if head == "": # We got an absolute path. Restart globbing at root - yield from self.root.glob(tail or '*') - elif head == '..': + yield from self.root.glob(tail or "*") + elif head == "..": # Relative path with reference to parent. Restart globbing at parent if not self.parent: - raise ValueError('Already at top') - yield from self.parent.glob(tail or '*') - elif head == '**': + raise ValueError("Already at top") + yield from self.parent.glob(tail or "*") + elif head == "**": # Match anything here or in any subfolder at arbitrary depth for c in self.walk(): # fnmatch() may be case-sensitive depending on operating system: # force a case-insensitive match since case appears not to # matter for folders in Exchange - if fnmatch(c.name.lower(), (tail or '*').lower()): + if fnmatch(c.name.lower(), (tail or "*").lower()): yield c else: # Regular pattern @@ -185,22 +213,22 @@

                        Module exchangelib.folders.base

                        ├── exchangelib issues └── Mom """ - tree = f'{self.name}\n' + tree = f"{self.name}\n" children = list(self.children) - for i, c in enumerate(sorted(children, key=attrgetter('name')), start=1): - nodes = c.tree().split('\n') + for i, c in enumerate(sorted(children, key=attrgetter("name")), start=1): + nodes = c.tree().split("\n") for j, node in enumerate(nodes, start=1): if i != len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += f'├── {node}\n' + tree += f"├── {node}\n" elif i != len(children) and j > 1: # Not the last child, and not name of child - tree += f'│ {node}\n' + tree += f"│ {node}\n" elif i == len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += f'└── {node}\n' + tree += f"└── {node}\n" else: # Last child, and not name of child - tree += f' {node}\n' + tree += f" {node}\n" return tree.strip() @classmethod @@ -228,11 +256,29 @@

                        Module exchangelib.folders.base

                        :param container_class: :return: """ - from .known_folders import Messages, Tasks, Calendar, ConversationSettings, Contacts, GALContacts, Reminders, \ - RecipientCache, RSSFeeds + from .known_folders import ( + Calendar, + Contacts, + ConversationSettings, + GALContacts, + Messages, + RecipientCache, + Reminders, + RSSFeeds, + Tasks, + ) + for folder_cls in ( - Messages, Tasks, Calendar, ConversationSettings, Contacts, GALContacts, Reminders, RecipientCache, - RSSFeeds): + Messages, + Tasks, + Calendar, + ConversationSettings, + Contacts, + GALContacts, + Reminders, + RecipientCache, + RSSFeeds, + ): if folder_cls.CONTAINER_CLASS == container_class: return folder_cls raise KeyError() @@ -242,7 +288,7 @@

                        Module exchangelib.folders.base

                        try: return cls.ITEM_MODEL_MAP[tag] except KeyError: - raise ValueError(f'Item type {tag} was unexpected in a {cls.__name__} folder') + raise ValueError(f"Item type {tag} was unexpected in a {cls.__name__} folder") @classmethod def allowed_item_fields(cls, version): @@ -268,9 +314,9 @@

                        Module exchangelib.folders.base

                        elif isinstance(field_path, Field): field_path = FieldPath(field=field_path) fields[i] = field_path - if field_path.field.name == 'start': + if field_path.field.name == "start": has_start = True - elif field_path.field.name == 'end': + elif field_path.field.name == "end": has_end = True # For CalendarItem items, we want to inject internal timezone fields. See also CalendarItem.clean() @@ -319,6 +365,7 @@

                        Module exchangelib.folders.base

                        def save(self, update_fields=None): from ..services import CreateFolder, UpdateFolder + if self.id is None: # New folder if update_fields: @@ -337,7 +384,7 @@

                        Module exchangelib.folders.base

                        # These cannot be changed continue if (f.is_required or f.is_required_after_save) and ( - getattr(self, f.name) is None or (f.is_list and not getattr(self, f.name)) + getattr(self, f.name) is None or (f.is_list and not getattr(self, f.name)) ): # These are required and cannot be deleted continue @@ -345,7 +392,7 @@

                        Module exchangelib.folders.base

                        res = UpdateFolder(account=self.account).get(folders=[(self, update_fields)]) folder_id, changekey = res.id, res.changekey if self.id != folder_id: - raise ValueError('ID mismatch') + raise ValueError("ID mismatch") # Don't check changekey value. It may not change on no-op updates self.changekey = changekey self.root.update_folder(self) # Update the folder in the cache @@ -353,10 +400,11 @@

                        Module exchangelib.folders.base

                        def move(self, to_folder): from ..services import MoveFolder + res = MoveFolder(account=self.account).get(folders=[self], to_folder=to_folder) folder_id, changekey = res.id, res.changekey if self.id != folder_id: - raise ValueError('ID mismatch') + raise ValueError("ID mismatch") # Don't check changekey value. It may not change on no-op moves self.changekey = changekey self.parent_folder_id = ParentFolderId(id=to_folder.id, changekey=to_folder.changekey) @@ -364,12 +412,14 @@

                        Module exchangelib.folders.base

                        def delete(self, delete_type=HARD_DELETE): from ..services import DeleteFolder + DeleteFolder(account=self.account).get(folders=[self], delete_type=delete_type) self.root.remove_folder(self) # Remove the updated folder from the cache self._id = None def empty(self, delete_type=HARD_DELETE, delete_sub_folders=False): from ..services import EmptyFolder + EmptyFolder(account=self.account).get( folders=[self], delete_type=delete_type, delete_sub_folders=delete_sub_folders ) @@ -382,11 +432,11 @@

                        Module exchangelib.folders.base

                        # distinguished folders from being deleted. Use with caution! _seen = _seen or set() if self.id in _seen: - raise RecursionError(f'We already tried to wipe {self}') + raise RecursionError(f"We already tried to wipe {self}") if _level > 16: - raise RecursionError(f'Max recursion level reached: {_level}') + raise RecursionError(f"Max recursion level reached: {_level}") _seen.add(self.id) - log.warning('Wiping %s', self) + log.warning("Wiping %s", self) has_distinguished_subfolders = any(f.is_distinguished for f in self.children) try: if has_distinguished_subfolders: @@ -399,26 +449,26 @@

                        Module exchangelib.folders.base

                        raise # We already tried this self.empty(delete_sub_folders=False) except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): - log.warning('Not allowed to empty %s. Trying to delete items instead', self) + log.warning("Not allowed to empty %s. Trying to delete items instead", self) kwargs = {} if page_size is not None: - kwargs['page_size'] = page_size + kwargs["page_size"] = page_size if chunk_size is not None: - kwargs['chunk_size'] = chunk_size + kwargs["chunk_size"] = chunk_size try: self.all().delete(**kwargs) except (ErrorAccessDenied, ErrorCannotDeleteObject, ErrorItemNotFound): - log.warning('Not allowed to delete items in %s', self) + log.warning("Not allowed to delete items in %s", self) _level += 1 for f in self.children: f.wipe(page_size=page_size, chunk_size=chunk_size, _seen=_seen, _level=_level) # Remove non-distinguished children that are empty and have no subfolders if f.is_deletable and not f.children: - log.warning('Deleting folder %s', f) + log.warning("Deleting folder %s", f) try: f.delete() except ErrorDeleteDistinguishedFolder: - log.warning('Tried to delete a distinguished folder (%s)', f) + log.warning("Tried to delete a distinguished folder (%s)", f) def test_access(self): """Does a simple FindItem to test (read) access to the folder. Maybe the account doesn't exist, maybe the @@ -430,12 +480,12 @@

                        Module exchangelib.folders.base

                        @classmethod def _kwargs_from_elem(cls, elem, account): # Check for 'DisplayName' element before collecting kwargs because because that clears the elements - has_name_elem = elem.find(cls.get_field_by_fieldname('name').response_tag()) is not None + has_name_elem = elem.find(cls.get_field_by_fieldname("name").response_tag()) is not None kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS} - if has_name_elem and not kwargs['name']: + if has_name_elem and not kwargs["name"]: # When we request the 'DisplayName' property, some folders may still be returned with an empty value. # Assign a default name to these folders. - kwargs['name'] = cls.DISTINGUISHED_FOLDER_ID + kwargs["name"] = cls.DISTINGUISHED_FOLDER_ID return kwargs def to_id(self): @@ -444,22 +494,21 @@

                        Module exchangelib.folders.base

                        # the folder content since we fetched the changekey. if self.account: return DistinguishedFolderId( - id=self.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=self.account.primary_smtp_address) + id=self.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address) ) return DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID) if self.id: return FolderId(id=self.id, changekey=self.changekey) - raise ValueError('Must be a distinguished folder or have an ID') + raise ValueError("Must be a distinguished folder or have an ID") @classmethod def resolve(cls, account, folder): # Resolve a single folder folders = list(FolderCollection(account=account, folders=[folder]).resolve()) if not folders: - raise ErrorFolderNotFound(f'Could not find folder {folder!r}') + raise ErrorFolderNotFound(f"Could not find folder {folder!r}") if len(folders) != 1: - raise ValueError(f'Expected result length 1, but got {folders}') + raise ValueError(f"Expected result length 1, but got {folders}") f = folders[0] if isinstance(f, Exception): raise f @@ -471,7 +520,7 @@

                        Module exchangelib.folders.base

                        def refresh(self): fresh_folder = self.resolve(account=self.account, folder=self) if self.id != fresh_folder.id: - raise ValueError('ID mismatch') + raise ValueError("ID mismatch") # Apparently, the changekey may get updated for f in self.FIELDS: setattr(self, f.name, getattr(fresh_folder, f.name)) @@ -481,6 +530,7 @@

                        Module exchangelib.folders.base

                        def get_user_configuration(self, name, properties=None): from ..services import GetUserConfiguration from ..services.get_user_configuration import ALL + if properties is None: properties = ALL return GetUserConfiguration(account=self.account).get( @@ -491,6 +541,7 @@

                        Module exchangelib.folders.base

                        @require_id def create_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None): from ..services import CreateUserConfiguration + user_configuration = UserConfiguration( user_configuration_name=UserConfigurationName(name=name, folder=self), dictionary=dictionary, @@ -502,6 +553,7 @@

                        Module exchangelib.folders.base

                        @require_id def update_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None): from ..services import UpdateUserConfiguration + user_configuration = UserConfiguration( user_configuration_name=UserConfigurationName(name=name, folder=self), dictionary=dictionary, @@ -513,6 +565,7 @@

                        Module exchangelib.folders.base

                        @require_id def delete_user_configuration(self, name): from ..services import DeleteUserConfiguration + return DeleteUserConfiguration(account=self.account).get( user_configuration_name=UserConfigurationNameMNS(name=name, folder=self) ) @@ -528,10 +581,13 @@

                        Module exchangelib.folders.base

                        :return: The subscription ID and a watermark """ from ..services import SubscribeToPull + if event_types is None: event_types = SubscribeToPull.EVENT_TYPES return FolderCollection(account=self.account, folders=[self]).subscribe_to_pull( - event_types=event_types, watermark=watermark, timeout=timeout, + event_types=event_types, + watermark=watermark, + timeout=timeout, ) @require_id @@ -545,10 +601,14 @@

                        Module exchangelib.folders.base

                        :return: The subscription ID and a watermark """ from ..services import SubscribeToPush + if event_types is None: event_types = SubscribeToPush.EVENT_TYPES return FolderCollection(account=self.account, folders=[self]).subscribe_to_push( - event_types=event_types, watermark=watermark, status_frequency=status_frequency, callback_url=callback_url, + event_types=event_types, + watermark=watermark, + status_frequency=status_frequency, + callback_url=callback_url, ) @require_id @@ -559,6 +619,7 @@

                        Module exchangelib.folders.base

                        :return: The subscription ID """ from ..services import SubscribeToStreaming + if event_types is None: event_types = SubscribeToStreaming.EVENT_TYPES return FolderCollection(account=self.account, folders=[self]).subscribe_to_streaming(event_types=event_types) @@ -585,6 +646,7 @@

                        Module exchangelib.folders.base

                        sync methods. """ from ..services import Unsubscribe + return Unsubscribe(account=self.account).get(subscription_id=subscription_id) def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None): @@ -644,6 +706,7 @@

                        Module exchangelib.folders.base

                        sync methods. """ from ..services import GetEvents + svc = GetEvents(account=self.account) while True: notification = svc.get(subscription_id=subscription_id, watermark=watermark) @@ -665,12 +728,15 @@

                        Module exchangelib.folders.base

                        sync methods. """ from ..services import GetStreamingEvents + svc = GetStreamingEvents(account=self.account) - subscription_ids = subscription_id_or_ids if is_iterable(subscription_id_or_ids, generators_allowed=True) \ + subscription_ids = ( + subscription_id_or_ids + if is_iterable(subscription_id_or_ids, generators_allowed=True) else [subscription_id_or_ids] + ) for i, notification in enumerate( - svc.call(subscription_ids=subscription_ids, connection_timeout=connection_timeout), - start=1 + svc.call(subscription_ids=subscription_ids, connection_timeout=connection_timeout), start=1 ): yield notification if max_notifications_returned and i >= max_notifications_returned: @@ -687,10 +753,10 @@

                        Module exchangelib.folders.base

                        :param other: :return: """ - if other == '..': - raise ValueError('Cannot get parent without a folder cache') + if other == "..": + raise ValueError("Cannot get parent without a folder cache") - if other == '.': + if other == ".": return self # Assume an exact match on the folder name in a shallow search will only return at most one folder @@ -701,11 +767,11 @@

                        Module exchangelib.folders.base

                        def __truediv__(self, other): """Support the some_folder / 'child_folder' / 'child_of_child_folder' navigation syntax.""" - if other == '..': + if other == "..": if not self.parent: - raise ValueError('Already at top') + raise ValueError("Already at top") return self.parent - if other == '.': + if other == ".": return self for c in self.children: if c.name == other: @@ -713,35 +779,45 @@

                        Module exchangelib.folders.base

                        raise ErrorFolderNotFound(f"No subfolder with name {other!r}") def __repr__(self): - return self.__class__.__name__ + \ - repr((self.root, self.name, self.total_count, self.unread_count, self.child_folder_count, - self.folder_class, self.id, self.changekey)) + return self.__class__.__name__ + repr( + ( + self.root, + self.name, + self.total_count, + self.unread_count, + self.child_folder_count, + self.folder_class, + self.id, + self.changekey, + ) + ) def __str__(self): - return f'{self.__class__.__name__} ({self.name})' + return f"{self.__class__.__name__} ({self.name})" class Folder(BaseFolder): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/folder""" - permission_set = PermissionSetField(field_uri='folder:PermissionSet', supported_from=EXCHANGE_2007_SP1) - effective_rights = EffectiveRightsField(field_uri='folder:EffectiveRights', is_read_only=True, - supported_from=EXCHANGE_2007_SP1) + permission_set = PermissionSetField(field_uri="folder:PermissionSet", supported_from=EXCHANGE_2007_SP1) + effective_rights = EffectiveRightsField( + field_uri="folder:EffectiveRights", is_read_only=True, supported_from=EXCHANGE_2007_SP1 + ) - __slots__ = '_root', + __slots__ = ("_root",) def __init__(self, **kwargs): - self._root = kwargs.pop('root', None) # This is a pointer to the root of the folder hierarchy - parent = kwargs.pop('parent', None) + self._root = kwargs.pop("root", None) # This is a pointer to the root of the folder hierarchy + parent = kwargs.pop("parent", None) if parent: if self.root: if parent.root != self.root: raise ValueError("'parent.root' must match 'root'") else: self.root = parent.root - if 'parent_folder_id' in kwargs and parent.id != kwargs['parent_folder_id']: + if "parent_folder_id" in kwargs and parent.id != kwargs["parent_folder_id"]: raise ValueError("'parent_folder_id' must match 'parent' ID") - kwargs['parent_folder_id'] = ParentFolderId(id=parent.id, changekey=parent.changekey) + kwargs["parent_folder_id"] = ParentFolderId(id=parent.id, changekey=parent.changekey) super().__init__(**kwargs) @property @@ -761,13 +837,13 @@

                        Module exchangelib.folders.base

                        @classmethod def register(cls, *args, **kwargs): if cls is not Folder: - raise TypeError('For folders, custom fields must be registered on the Folder class') + raise TypeError("For folders, custom fields must be registered on the Folder class") return super().register(*args, **kwargs) @classmethod def deregister(cls, *args, **kwargs): if cls is not Folder: - raise TypeError('For folders, custom fields must be registered on the Folder class') + raise TypeError("For folders, custom fields must be registered on the Folder class") return super().deregister(*args, **kwargs) @classmethod @@ -779,11 +855,10 @@

                        Module exchangelib.folders.base

                        """ try: return cls.resolve( - account=root.account, - folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + account=root.account, folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}') + raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") @property def parent(self): @@ -800,15 +875,16 @@

                        Module exchangelib.folders.base

                        self.parent_folder_id = None else: if not isinstance(value, BaseFolder): - raise InvalidTypeError('value', value, BaseFolder) + raise InvalidTypeError("value", value, BaseFolder) self.root = value.root self.parent_folder_id = ParentFolderId(id=value.id, changekey=value.changekey) def clean(self, version=None): from .roots import RootOfHierarchy + super().clean(version=version) if self.root and not isinstance(self.root, RootOfHierarchy): - raise InvalidTypeError('root', self.root, RootOfHierarchy) + raise InvalidTypeError("root", self.root, RootOfHierarchy) @classmethod def from_xml_with_root(cls, elem, root): @@ -834,20 +910,20 @@

                        Module exchangelib.folders.base

                        if folder.name: try: # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative - folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, - locale=root.account.locale) - log.debug('Folder class %s matches localized folder name %s', folder_cls, folder.name) + folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) + log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) except KeyError: pass if folder.folder_class and folder_cls == Folder: try: folder_cls = cls.folder_cls_from_container_class(container_class=folder.folder_class) - log.debug('Folder class %s matches container class %s (%s)', folder_cls, folder.folder_class, - folder.name) + log.debug( + "Folder class %s matches container class %s (%s)", folder_cls, folder.folder_class, folder.name + ) except KeyError: pass if folder_cls == Folder: - log.debug('Fallback to class Folder (folder_class %s, name %s)', folder.folder_class, folder.name) + log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS}) @@ -873,7 +949,7 @@

                        Classes

                        class BaseFolder(RegisterMixIn, SearchableMixIn, metaclass=EWSMeta):
                             """Base class for all classes that implement a folder."""
                         
                        -    ELEMENT_NAME = 'Folder'
                        +    ELEMENT_NAME = "Folder"
                             NAMESPACE = TNS
                             # See https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/distinguishedfolderid
                             DISTINGUISHED_FOLDER_ID = None
                        @@ -892,24 +968,23 @@ 

                        Classes

                        ITEM_MODEL_MAP = {cls.response_tag(): cls for cls in ITEM_CLASSES} ID_ELEMENT_CLS = FolderId - _id = IdElementField(field_uri='folder:FolderId', value_cls=ID_ELEMENT_CLS) - parent_folder_id = EWSElementField(field_uri='folder:ParentFolderId', value_cls=ParentFolderId, - is_read_only=True) - folder_class = CharField(field_uri='folder:FolderClass', is_required_after_save=True) - name = CharField(field_uri='folder:DisplayName') - total_count = IntegerField(field_uri='folder:TotalCount', is_read_only=True) - child_folder_count = IntegerField(field_uri='folder:ChildFolderCount', is_read_only=True) - unread_count = IntegerField(field_uri='folder:UnreadCount', is_read_only=True) + _id = IdElementField(field_uri="folder:FolderId", value_cls=ID_ELEMENT_CLS) + parent_folder_id = EWSElementField(field_uri="folder:ParentFolderId", value_cls=ParentFolderId, is_read_only=True) + folder_class = CharField(field_uri="folder:FolderClass", is_required_after_save=True) + name = CharField(field_uri="folder:DisplayName") + total_count = IntegerField(field_uri="folder:TotalCount", is_read_only=True) + child_folder_count = IntegerField(field_uri="folder:ChildFolderCount", is_read_only=True) + unread_count = IntegerField(field_uri="folder:UnreadCount", is_read_only=True) - __slots__ = 'is_distinguished', 'item_sync_state', 'folder_sync_state' + __slots__ = "is_distinguished", "item_sync_state", "folder_sync_state" # Used to register extended properties - INSERT_AFTER_FIELD = 'child_folder_count' + INSERT_AFTER_FIELD = "child_folder_count" def __init__(self, **kwargs): - self.is_distinguished = kwargs.pop('is_distinguished', False) - self.item_sync_state = kwargs.pop('item_sync_state', None) - self.folder_sync_state = kwargs.pop('folder_sync_state', None) + self.is_distinguished = kwargs.pop("is_distinguished", False) + self.item_sync_state = kwargs.pop("item_sync_state", None) + self.folder_sync_state = kwargs.pop("folder_sync_state", None) super().__init__(**kwargs) @property @@ -954,7 +1029,7 @@

                        Classes

                        @property def absolute(self): - return ''.join(f'/{p.name}' for p in self.parts) + return "".join(f"/{p.name}" for p in self.parts) def _walk(self): for c in self.children: @@ -965,23 +1040,23 @@

                        Classes

                        return FolderCollection(account=self.account, folders=self._walk()) def _glob(self, pattern): - split_pattern = pattern.rsplit('/', 1) + split_pattern = pattern.rsplit("/", 1) head, tail = (split_pattern[0], None) if len(split_pattern) == 1 else split_pattern - if head == '': + if head == "": # We got an absolute path. Restart globbing at root - yield from self.root.glob(tail or '*') - elif head == '..': + yield from self.root.glob(tail or "*") + elif head == "..": # Relative path with reference to parent. Restart globbing at parent if not self.parent: - raise ValueError('Already at top') - yield from self.parent.glob(tail or '*') - elif head == '**': + raise ValueError("Already at top") + yield from self.parent.glob(tail or "*") + elif head == "**": # Match anything here or in any subfolder at arbitrary depth for c in self.walk(): # fnmatch() may be case-sensitive depending on operating system: # force a case-insensitive match since case appears not to # matter for folders in Exchange - if fnmatch(c.name.lower(), (tail or '*').lower()): + if fnmatch(c.name.lower(), (tail or "*").lower()): yield c else: # Regular pattern @@ -1008,22 +1083,22 @@

                        Classes

                        ├── exchangelib issues └── Mom """ - tree = f'{self.name}\n' + tree = f"{self.name}\n" children = list(self.children) - for i, c in enumerate(sorted(children, key=attrgetter('name')), start=1): - nodes = c.tree().split('\n') + for i, c in enumerate(sorted(children, key=attrgetter("name")), start=1): + nodes = c.tree().split("\n") for j, node in enumerate(nodes, start=1): if i != len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += f'├── {node}\n' + tree += f"├── {node}\n" elif i != len(children) and j > 1: # Not the last child, and not name of child - tree += f'│ {node}\n' + tree += f"│ {node}\n" elif i == len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += f'└── {node}\n' + tree += f"└── {node}\n" else: # Last child, and not name of child - tree += f' {node}\n' + tree += f" {node}\n" return tree.strip() @classmethod @@ -1051,11 +1126,29 @@

                        Classes

                        :param container_class: :return: """ - from .known_folders import Messages, Tasks, Calendar, ConversationSettings, Contacts, GALContacts, Reminders, \ - RecipientCache, RSSFeeds + from .known_folders import ( + Calendar, + Contacts, + ConversationSettings, + GALContacts, + Messages, + RecipientCache, + Reminders, + RSSFeeds, + Tasks, + ) + for folder_cls in ( - Messages, Tasks, Calendar, ConversationSettings, Contacts, GALContacts, Reminders, RecipientCache, - RSSFeeds): + Messages, + Tasks, + Calendar, + ConversationSettings, + Contacts, + GALContacts, + Reminders, + RecipientCache, + RSSFeeds, + ): if folder_cls.CONTAINER_CLASS == container_class: return folder_cls raise KeyError() @@ -1065,7 +1158,7 @@

                        Classes

                        try: return cls.ITEM_MODEL_MAP[tag] except KeyError: - raise ValueError(f'Item type {tag} was unexpected in a {cls.__name__} folder') + raise ValueError(f"Item type {tag} was unexpected in a {cls.__name__} folder") @classmethod def allowed_item_fields(cls, version): @@ -1091,9 +1184,9 @@

                        Classes

                        elif isinstance(field_path, Field): field_path = FieldPath(field=field_path) fields[i] = field_path - if field_path.field.name == 'start': + if field_path.field.name == "start": has_start = True - elif field_path.field.name == 'end': + elif field_path.field.name == "end": has_end = True # For CalendarItem items, we want to inject internal timezone fields. See also CalendarItem.clean() @@ -1142,6 +1235,7 @@

                        Classes

                        def save(self, update_fields=None): from ..services import CreateFolder, UpdateFolder + if self.id is None: # New folder if update_fields: @@ -1160,7 +1254,7 @@

                        Classes

                        # These cannot be changed continue if (f.is_required or f.is_required_after_save) and ( - getattr(self, f.name) is None or (f.is_list and not getattr(self, f.name)) + getattr(self, f.name) is None or (f.is_list and not getattr(self, f.name)) ): # These are required and cannot be deleted continue @@ -1168,7 +1262,7 @@

                        Classes

                        res = UpdateFolder(account=self.account).get(folders=[(self, update_fields)]) folder_id, changekey = res.id, res.changekey if self.id != folder_id: - raise ValueError('ID mismatch') + raise ValueError("ID mismatch") # Don't check changekey value. It may not change on no-op updates self.changekey = changekey self.root.update_folder(self) # Update the folder in the cache @@ -1176,10 +1270,11 @@

                        Classes

                        def move(self, to_folder): from ..services import MoveFolder + res = MoveFolder(account=self.account).get(folders=[self], to_folder=to_folder) folder_id, changekey = res.id, res.changekey if self.id != folder_id: - raise ValueError('ID mismatch') + raise ValueError("ID mismatch") # Don't check changekey value. It may not change on no-op moves self.changekey = changekey self.parent_folder_id = ParentFolderId(id=to_folder.id, changekey=to_folder.changekey) @@ -1187,12 +1282,14 @@

                        Classes

                        def delete(self, delete_type=HARD_DELETE): from ..services import DeleteFolder + DeleteFolder(account=self.account).get(folders=[self], delete_type=delete_type) self.root.remove_folder(self) # Remove the updated folder from the cache self._id = None def empty(self, delete_type=HARD_DELETE, delete_sub_folders=False): from ..services import EmptyFolder + EmptyFolder(account=self.account).get( folders=[self], delete_type=delete_type, delete_sub_folders=delete_sub_folders ) @@ -1205,11 +1302,11 @@

                        Classes

                        # distinguished folders from being deleted. Use with caution! _seen = _seen or set() if self.id in _seen: - raise RecursionError(f'We already tried to wipe {self}') + raise RecursionError(f"We already tried to wipe {self}") if _level > 16: - raise RecursionError(f'Max recursion level reached: {_level}') + raise RecursionError(f"Max recursion level reached: {_level}") _seen.add(self.id) - log.warning('Wiping %s', self) + log.warning("Wiping %s", self) has_distinguished_subfolders = any(f.is_distinguished for f in self.children) try: if has_distinguished_subfolders: @@ -1222,26 +1319,26 @@

                        Classes

                        raise # We already tried this self.empty(delete_sub_folders=False) except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): - log.warning('Not allowed to empty %s. Trying to delete items instead', self) + log.warning("Not allowed to empty %s. Trying to delete items instead", self) kwargs = {} if page_size is not None: - kwargs['page_size'] = page_size + kwargs["page_size"] = page_size if chunk_size is not None: - kwargs['chunk_size'] = chunk_size + kwargs["chunk_size"] = chunk_size try: self.all().delete(**kwargs) except (ErrorAccessDenied, ErrorCannotDeleteObject, ErrorItemNotFound): - log.warning('Not allowed to delete items in %s', self) + log.warning("Not allowed to delete items in %s", self) _level += 1 for f in self.children: f.wipe(page_size=page_size, chunk_size=chunk_size, _seen=_seen, _level=_level) # Remove non-distinguished children that are empty and have no subfolders if f.is_deletable and not f.children: - log.warning('Deleting folder %s', f) + log.warning("Deleting folder %s", f) try: f.delete() except ErrorDeleteDistinguishedFolder: - log.warning('Tried to delete a distinguished folder (%s)', f) + log.warning("Tried to delete a distinguished folder (%s)", f) def test_access(self): """Does a simple FindItem to test (read) access to the folder. Maybe the account doesn't exist, maybe the @@ -1253,12 +1350,12 @@

                        Classes

                        @classmethod def _kwargs_from_elem(cls, elem, account): # Check for 'DisplayName' element before collecting kwargs because because that clears the elements - has_name_elem = elem.find(cls.get_field_by_fieldname('name').response_tag()) is not None + has_name_elem = elem.find(cls.get_field_by_fieldname("name").response_tag()) is not None kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS} - if has_name_elem and not kwargs['name']: + if has_name_elem and not kwargs["name"]: # When we request the 'DisplayName' property, some folders may still be returned with an empty value. # Assign a default name to these folders. - kwargs['name'] = cls.DISTINGUISHED_FOLDER_ID + kwargs["name"] = cls.DISTINGUISHED_FOLDER_ID return kwargs def to_id(self): @@ -1267,22 +1364,21 @@

                        Classes

                        # the folder content since we fetched the changekey. if self.account: return DistinguishedFolderId( - id=self.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=self.account.primary_smtp_address) + id=self.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address) ) return DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID) if self.id: return FolderId(id=self.id, changekey=self.changekey) - raise ValueError('Must be a distinguished folder or have an ID') + raise ValueError("Must be a distinguished folder or have an ID") @classmethod def resolve(cls, account, folder): # Resolve a single folder folders = list(FolderCollection(account=account, folders=[folder]).resolve()) if not folders: - raise ErrorFolderNotFound(f'Could not find folder {folder!r}') + raise ErrorFolderNotFound(f"Could not find folder {folder!r}") if len(folders) != 1: - raise ValueError(f'Expected result length 1, but got {folders}') + raise ValueError(f"Expected result length 1, but got {folders}") f = folders[0] if isinstance(f, Exception): raise f @@ -1294,7 +1390,7 @@

                        Classes

                        def refresh(self): fresh_folder = self.resolve(account=self.account, folder=self) if self.id != fresh_folder.id: - raise ValueError('ID mismatch') + raise ValueError("ID mismatch") # Apparently, the changekey may get updated for f in self.FIELDS: setattr(self, f.name, getattr(fresh_folder, f.name)) @@ -1304,6 +1400,7 @@

                        Classes

                        def get_user_configuration(self, name, properties=None): from ..services import GetUserConfiguration from ..services.get_user_configuration import ALL + if properties is None: properties = ALL return GetUserConfiguration(account=self.account).get( @@ -1314,6 +1411,7 @@

                        Classes

                        @require_id def create_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None): from ..services import CreateUserConfiguration + user_configuration = UserConfiguration( user_configuration_name=UserConfigurationName(name=name, folder=self), dictionary=dictionary, @@ -1325,6 +1423,7 @@

                        Classes

                        @require_id def update_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None): from ..services import UpdateUserConfiguration + user_configuration = UserConfiguration( user_configuration_name=UserConfigurationName(name=name, folder=self), dictionary=dictionary, @@ -1336,6 +1435,7 @@

                        Classes

                        @require_id def delete_user_configuration(self, name): from ..services import DeleteUserConfiguration + return DeleteUserConfiguration(account=self.account).get( user_configuration_name=UserConfigurationNameMNS(name=name, folder=self) ) @@ -1351,10 +1451,13 @@

                        Classes

                        :return: The subscription ID and a watermark """ from ..services import SubscribeToPull + if event_types is None: event_types = SubscribeToPull.EVENT_TYPES return FolderCollection(account=self.account, folders=[self]).subscribe_to_pull( - event_types=event_types, watermark=watermark, timeout=timeout, + event_types=event_types, + watermark=watermark, + timeout=timeout, ) @require_id @@ -1368,10 +1471,14 @@

                        Classes

                        :return: The subscription ID and a watermark """ from ..services import SubscribeToPush + if event_types is None: event_types = SubscribeToPush.EVENT_TYPES return FolderCollection(account=self.account, folders=[self]).subscribe_to_push( - event_types=event_types, watermark=watermark, status_frequency=status_frequency, callback_url=callback_url, + event_types=event_types, + watermark=watermark, + status_frequency=status_frequency, + callback_url=callback_url, ) @require_id @@ -1382,6 +1489,7 @@

                        Classes

                        :return: The subscription ID """ from ..services import SubscribeToStreaming + if event_types is None: event_types = SubscribeToStreaming.EVENT_TYPES return FolderCollection(account=self.account, folders=[self]).subscribe_to_streaming(event_types=event_types) @@ -1408,6 +1516,7 @@

                        Classes

                        sync methods. """ from ..services import Unsubscribe + return Unsubscribe(account=self.account).get(subscription_id=subscription_id) def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None): @@ -1467,6 +1576,7 @@

                        Classes

                        sync methods. """ from ..services import GetEvents + svc = GetEvents(account=self.account) while True: notification = svc.get(subscription_id=subscription_id, watermark=watermark) @@ -1488,12 +1598,15 @@

                        Classes

                        sync methods. """ from ..services import GetStreamingEvents + svc = GetStreamingEvents(account=self.account) - subscription_ids = subscription_id_or_ids if is_iterable(subscription_id_or_ids, generators_allowed=True) \ + subscription_ids = ( + subscription_id_or_ids + if is_iterable(subscription_id_or_ids, generators_allowed=True) else [subscription_id_or_ids] + ) for i, notification in enumerate( - svc.call(subscription_ids=subscription_ids, connection_timeout=connection_timeout), - start=1 + svc.call(subscription_ids=subscription_ids, connection_timeout=connection_timeout), start=1 ): yield notification if max_notifications_returned and i >= max_notifications_returned: @@ -1510,10 +1623,10 @@

                        Classes

                        :param other: :return: """ - if other == '..': - raise ValueError('Cannot get parent without a folder cache') + if other == "..": + raise ValueError("Cannot get parent without a folder cache") - if other == '.': + if other == ".": return self # Assume an exact match on the folder name in a shallow search will only return at most one folder @@ -1524,11 +1637,11 @@

                        Classes

                        def __truediv__(self, other): """Support the some_folder / 'child_folder' / 'child_of_child_folder' navigation syntax.""" - if other == '..': + if other == "..": if not self.parent: - raise ValueError('Already at top') + raise ValueError("Already at top") return self.parent - if other == '.': + if other == ".": return self for c in self.children: if c.name == other: @@ -1536,12 +1649,21 @@

                        Classes

                        raise ErrorFolderNotFound(f"No subfolder with name {other!r}") def __repr__(self): - return self.__class__.__name__ + \ - repr((self.root, self.name, self.total_count, self.unread_count, self.child_folder_count, - self.folder_class, self.id, self.changekey)) + return self.__class__.__name__ + repr( + ( + self.root, + self.name, + self.total_count, + self.unread_count, + self.child_folder_count, + self.folder_class, + self.id, + self.changekey, + ) + ) def __str__(self): - return f'{self.__class__.__name__} ({self.name})'
                        + return f"{self.__class__.__name__} ({self.name})"

                        Ancestors

                          @@ -1654,11 +1776,29 @@

                          Static methods

                          :param container_class: :return: """ - from .known_folders import Messages, Tasks, Calendar, ConversationSettings, Contacts, GALContacts, Reminders, \ - RecipientCache, RSSFeeds + from .known_folders import ( + Calendar, + Contacts, + ConversationSettings, + GALContacts, + Messages, + RecipientCache, + Reminders, + RSSFeeds, + Tasks, + ) + for folder_cls in ( - Messages, Tasks, Calendar, ConversationSettings, Contacts, GALContacts, Reminders, RecipientCache, - RSSFeeds): + Messages, + Tasks, + Calendar, + ConversationSettings, + Contacts, + GALContacts, + Reminders, + RecipientCache, + RSSFeeds, + ): if folder_cls.CONTAINER_CLASS == container_class: return folder_cls raise KeyError() @@ -1697,7 +1837,7 @@

                          Static methods

                          try: return cls.ITEM_MODEL_MAP[tag] except KeyError: - raise ValueError(f'Item type {tag} was unexpected in a {cls.__name__} folder') + raise ValueError(f"Item type {tag} was unexpected in a {cls.__name__} folder")
                          @@ -1730,9 +1870,9 @@

                          Static methods

                          # Resolve a single folder folders = list(FolderCollection(account=account, folders=[folder]).resolve()) if not folders: - raise ErrorFolderNotFound(f'Could not find folder {folder!r}') + raise ErrorFolderNotFound(f"Could not find folder {folder!r}") if len(folders) != 1: - raise ValueError(f'Expected result length 1, but got {folders}') + raise ValueError(f"Expected result length 1, but got {folders}") f = folders[0] if isinstance(f, Exception): raise f @@ -1770,7 +1910,7 @@

                          Instance variables

                          @property
                           def absolute(self):
                          -    return ''.join(f'/{p.name}' for p in self.parts)
                          + return "".join(f"/{p.name}" for p in self.parts)
                          var account
                          @@ -1947,6 +2087,7 @@

                          Methods

                          @require_id
                           def create_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None):
                               from ..services import CreateUserConfiguration
                          +
                               user_configuration = UserConfiguration(
                                   user_configuration_name=UserConfigurationName(name=name, folder=self),
                                   dictionary=dictionary,
                          @@ -1967,6 +2108,7 @@ 

                          Methods

                          def delete(self, delete_type=HARD_DELETE):
                               from ..services import DeleteFolder
                          +
                               DeleteFolder(account=self.account).get(folders=[self], delete_type=delete_type)
                               self.root.remove_folder(self)  # Remove the updated folder from the cache
                               self._id = None
                          @@ -1984,6 +2126,7 @@

                          Methods

                          @require_id
                           def delete_user_configuration(self, name):
                               from ..services import DeleteUserConfiguration
                          +
                               return DeleteUserConfiguration(account=self.account).get(
                                   user_configuration_name=UserConfigurationNameMNS(name=name, folder=self)
                               )
                          @@ -2000,6 +2143,7 @@

                          Methods

                          def empty(self, delete_type=HARD_DELETE, delete_sub_folders=False):
                               from ..services import EmptyFolder
                          +
                               EmptyFolder(account=self.account).get(
                                   folders=[self], delete_type=delete_type, delete_sub_folders=delete_sub_folders
                               )
                          @@ -2033,6 +2177,7 @@ 

                          Methods

                          sync methods. """ from ..services import GetEvents + svc = GetEvents(account=self.account) while True: notification = svc.get(subscription_id=subscription_id, watermark=watermark) @@ -2072,12 +2217,15 @@

                          Methods

                          sync methods. """ from ..services import GetStreamingEvents + svc = GetStreamingEvents(account=self.account) - subscription_ids = subscription_id_or_ids if is_iterable(subscription_id_or_ids, generators_allowed=True) \ + subscription_ids = ( + subscription_id_or_ids + if is_iterable(subscription_id_or_ids, generators_allowed=True) else [subscription_id_or_ids] + ) for i, notification in enumerate( - svc.call(subscription_ids=subscription_ids, connection_timeout=connection_timeout), - start=1 + svc.call(subscription_ids=subscription_ids, connection_timeout=connection_timeout), start=1 ): yield notification if max_notifications_returned and i >= max_notifications_returned: @@ -2098,6 +2246,7 @@

                          Methods

                          def get_user_configuration(self, name, properties=None): from ..services import GetUserConfiguration from ..services.get_user_configuration import ALL + if properties is None: properties = ALL return GetUserConfiguration(account=self.account).get( @@ -2130,10 +2279,11 @@

                          Methods

                          def move(self, to_folder):
                               from ..services import MoveFolder
                          +
                               res = MoveFolder(account=self.account).get(folders=[self], to_folder=to_folder)
                               folder_id, changekey = res.id, res.changekey
                               if self.id != folder_id:
                          -        raise ValueError('ID mismatch')
                          +        raise ValueError("ID mismatch")
                               # Don't check changekey value. It may not change on no-op moves
                               self.changekey = changekey
                               self.parent_folder_id = ParentFolderId(id=to_folder.id, changekey=to_folder.changekey)
                          @@ -2162,9 +2312,9 @@ 

                          Methods

                          elif isinstance(field_path, Field): field_path = FieldPath(field=field_path) fields[i] = field_path - if field_path.field.name == 'start': + if field_path.field.name == "start": has_start = True - elif field_path.field.name == 'end': + elif field_path.field.name == "end": has_end = True # For CalendarItem items, we want to inject internal timezone fields. See also CalendarItem.clean() @@ -2222,7 +2372,7 @@

                          Methods

                          def refresh(self): fresh_folder = self.resolve(account=self.account, folder=self) if self.id != fresh_folder.id: - raise ValueError('ID mismatch') + raise ValueError("ID mismatch") # Apparently, the changekey may get updated for f in self.FIELDS: setattr(self, f.name, getattr(fresh_folder, f.name)) @@ -2240,6 +2390,7 @@

                          Methods

                          def save(self, update_fields=None):
                               from ..services import CreateFolder, UpdateFolder
                          +
                               if self.id is None:
                                   # New folder
                                   if update_fields:
                          @@ -2258,7 +2409,7 @@ 

                          Methods

                          # These cannot be changed continue if (f.is_required or f.is_required_after_save) and ( - getattr(self, f.name) is None or (f.is_list and not getattr(self, f.name)) + getattr(self, f.name) is None or (f.is_list and not getattr(self, f.name)) ): # These are required and cannot be deleted continue @@ -2266,7 +2417,7 @@

                          Methods

                          res = UpdateFolder(account=self.account).get(folders=[(self, update_fields)]) folder_id, changekey = res.id, res.changekey if self.id != folder_id: - raise ValueError('ID mismatch') + raise ValueError("ID mismatch") # Don't check changekey value. It may not change on no-op updates self.changekey = changekey self.root.update_folder(self) # Update the folder in the cache @@ -2312,10 +2463,13 @@

                          Methods

                          :return: The subscription ID and a watermark """ from ..services import SubscribeToPull + if event_types is None: event_types = SubscribeToPull.EVENT_TYPES return FolderCollection(account=self.account, folders=[self]).subscribe_to_pull( - event_types=event_types, watermark=watermark, timeout=timeout, + event_types=event_types, + watermark=watermark, + timeout=timeout, )
                          @@ -2344,10 +2498,14 @@

                          Methods

                          :return: The subscription ID and a watermark """ from ..services import SubscribeToPush + if event_types is None: event_types = SubscribeToPush.EVENT_TYPES return FolderCollection(account=self.account, folders=[self]).subscribe_to_push( - event_types=event_types, watermark=watermark, status_frequency=status_frequency, callback_url=callback_url, + event_types=event_types, + watermark=watermark, + status_frequency=status_frequency, + callback_url=callback_url, )
                          @@ -2370,6 +2528,7 @@

                          Methods

                          :return: The subscription ID """ from ..services import SubscribeToStreaming + if event_types is None: event_types = SubscribeToStreaming.EVENT_TYPES return FolderCollection(account=self.account, folders=[self]).subscribe_to_streaming(event_types=event_types)
                          @@ -2487,13 +2646,12 @@

                          Methods

                          # the folder content since we fetched the changekey. if self.account: return DistinguishedFolderId( - id=self.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=self.account.primary_smtp_address) + id=self.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address) ) return DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID) if self.id: return FolderId(id=self.id, changekey=self.changekey) - raise ValueError('Must be a distinguished folder or have an ID')
                          + raise ValueError("Must be a distinguished folder or have an ID")
                          @@ -2524,22 +2682,22 @@

                          Methods

                          ├── exchangelib issues └── Mom """ - tree = f'{self.name}\n' + tree = f"{self.name}\n" children = list(self.children) - for i, c in enumerate(sorted(children, key=attrgetter('name')), start=1): - nodes = c.tree().split('\n') + for i, c in enumerate(sorted(children, key=attrgetter("name")), start=1): + nodes = c.tree().split("\n") for j, node in enumerate(nodes, start=1): if i != len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += f'├── {node}\n' + tree += f"├── {node}\n" elif i != len(children) and j > 1: # Not the last child, and not name of child - tree += f'│ {node}\n' + tree += f"│ {node}\n" elif i == len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += f'└── {node}\n' + tree += f"└── {node}\n" else: # Last child, and not name of child - tree += f' {node}\n' + tree += f" {node}\n" return tree.strip()
                          @@ -2566,6 +2724,7 @@

                          Methods

                          sync methods. """ from ..services import Unsubscribe + return Unsubscribe(account=self.account).get(subscription_id=subscription_id) @@ -2581,6 +2740,7 @@

                          Methods

                          @require_id
                           def update_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None):
                               from ..services import UpdateUserConfiguration
                          +
                               user_configuration = UserConfiguration(
                                   user_configuration_name=UserConfigurationName(name=name, folder=self),
                                   dictionary=dictionary,
                          @@ -2630,11 +2790,11 @@ 

                          Methods

                          # distinguished folders from being deleted. Use with caution! _seen = _seen or set() if self.id in _seen: - raise RecursionError(f'We already tried to wipe {self}') + raise RecursionError(f"We already tried to wipe {self}") if _level > 16: - raise RecursionError(f'Max recursion level reached: {_level}') + raise RecursionError(f"Max recursion level reached: {_level}") _seen.add(self.id) - log.warning('Wiping %s', self) + log.warning("Wiping %s", self) has_distinguished_subfolders = any(f.is_distinguished for f in self.children) try: if has_distinguished_subfolders: @@ -2647,26 +2807,26 @@

                          Methods

                          raise # We already tried this self.empty(delete_sub_folders=False) except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): - log.warning('Not allowed to empty %s. Trying to delete items instead', self) + log.warning("Not allowed to empty %s. Trying to delete items instead", self) kwargs = {} if page_size is not None: - kwargs['page_size'] = page_size + kwargs["page_size"] = page_size if chunk_size is not None: - kwargs['chunk_size'] = chunk_size + kwargs["chunk_size"] = chunk_size try: self.all().delete(**kwargs) except (ErrorAccessDenied, ErrorCannotDeleteObject, ErrorItemNotFound): - log.warning('Not allowed to delete items in %s', self) + log.warning("Not allowed to delete items in %s", self) _level += 1 for f in self.children: f.wipe(page_size=page_size, chunk_size=chunk_size, _seen=_seen, _level=_level) # Remove non-distinguished children that are empty and have no subfolders if f.is_deletable and not f.children: - log.warning('Deleting folder %s', f) + log.warning("Deleting folder %s", f) try: f.delete() except ErrorDeleteDistinguishedFolder: - log.warning('Tried to delete a distinguished folder (%s)', f)
                          + log.warning("Tried to delete a distinguished folder (%s)", f) @@ -2707,24 +2867,25 @@

                          Inherited members

                          class Folder(BaseFolder):
                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/folder"""
                           
                          -    permission_set = PermissionSetField(field_uri='folder:PermissionSet', supported_from=EXCHANGE_2007_SP1)
                          -    effective_rights = EffectiveRightsField(field_uri='folder:EffectiveRights', is_read_only=True,
                          -                                            supported_from=EXCHANGE_2007_SP1)
                          +    permission_set = PermissionSetField(field_uri="folder:PermissionSet", supported_from=EXCHANGE_2007_SP1)
                          +    effective_rights = EffectiveRightsField(
                          +        field_uri="folder:EffectiveRights", is_read_only=True, supported_from=EXCHANGE_2007_SP1
                          +    )
                           
                          -    __slots__ = '_root',
                          +    __slots__ = ("_root",)
                           
                               def __init__(self, **kwargs):
                          -        self._root = kwargs.pop('root', None)  # This is a pointer to the root of the folder hierarchy
                          -        parent = kwargs.pop('parent', None)
                          +        self._root = kwargs.pop("root", None)  # This is a pointer to the root of the folder hierarchy
                          +        parent = kwargs.pop("parent", None)
                                   if parent:
                                       if self.root:
                                           if parent.root != self.root:
                                               raise ValueError("'parent.root' must match 'root'")
                                       else:
                                           self.root = parent.root
                          -            if 'parent_folder_id' in kwargs and parent.id != kwargs['parent_folder_id']:
                          +            if "parent_folder_id" in kwargs and parent.id != kwargs["parent_folder_id"]:
                                           raise ValueError("'parent_folder_id' must match 'parent' ID")
                          -            kwargs['parent_folder_id'] = ParentFolderId(id=parent.id, changekey=parent.changekey)
                          +            kwargs["parent_folder_id"] = ParentFolderId(id=parent.id, changekey=parent.changekey)
                                   super().__init__(**kwargs)
                           
                               @property
                          @@ -2744,13 +2905,13 @@ 

                          Inherited members

                          @classmethod def register(cls, *args, **kwargs): if cls is not Folder: - raise TypeError('For folders, custom fields must be registered on the Folder class') + raise TypeError("For folders, custom fields must be registered on the Folder class") return super().register(*args, **kwargs) @classmethod def deregister(cls, *args, **kwargs): if cls is not Folder: - raise TypeError('For folders, custom fields must be registered on the Folder class') + raise TypeError("For folders, custom fields must be registered on the Folder class") return super().deregister(*args, **kwargs) @classmethod @@ -2762,11 +2923,10 @@

                          Inherited members

                          """ try: return cls.resolve( - account=root.account, - folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + account=root.account, folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}') + raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") @property def parent(self): @@ -2783,15 +2943,16 @@

                          Inherited members

                          self.parent_folder_id = None else: if not isinstance(value, BaseFolder): - raise InvalidTypeError('value', value, BaseFolder) + raise InvalidTypeError("value", value, BaseFolder) self.root = value.root self.parent_folder_id = ParentFolderId(id=value.id, changekey=value.changekey) def clean(self, version=None): from .roots import RootOfHierarchy + super().clean(version=version) if self.root and not isinstance(self.root, RootOfHierarchy): - raise InvalidTypeError('root', self.root, RootOfHierarchy) + raise InvalidTypeError("root", self.root, RootOfHierarchy) @classmethod def from_xml_with_root(cls, elem, root): @@ -2817,20 +2978,20 @@

                          Inherited members

                          if folder.name: try: # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative - folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, - locale=root.account.locale) - log.debug('Folder class %s matches localized folder name %s', folder_cls, folder.name) + folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) + log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) except KeyError: pass if folder.folder_class and folder_cls == Folder: try: folder_cls = cls.folder_cls_from_container_class(container_class=folder.folder_class) - log.debug('Folder class %s matches container class %s (%s)', folder_cls, folder.folder_class, - folder.name) + log.debug( + "Folder class %s matches container class %s (%s)", folder_cls, folder.folder_class, folder.name + ) except KeyError: pass if folder_cls == Folder: - log.debug('Fallback to class Folder (folder_class %s, name %s)', folder.folder_class, folder.name) + log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS})

                          Ancestors

                          @@ -2921,20 +3082,20 @@

                          Static methods

                          if folder.name: try: # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative - folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, - locale=root.account.locale) - log.debug('Folder class %s matches localized folder name %s', folder_cls, folder.name) + folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) + log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) except KeyError: pass if folder.folder_class and folder_cls == Folder: try: folder_cls = cls.folder_cls_from_container_class(container_class=folder.folder_class) - log.debug('Folder class %s matches container class %s (%s)', folder_cls, folder.folder_class, - folder.name) + log.debug( + "Folder class %s matches container class %s (%s)", folder_cls, folder.folder_class, folder.name + ) except KeyError: pass if folder_cls == Folder: - log.debug('Fallback to class Folder (folder_class %s, name %s)', folder.folder_class, folder.name) + log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS}) @@ -2958,11 +3119,10 @@

                          Static methods

                          """ try: return cls.resolve( - account=root.account, - folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + account=root.account, folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}') + raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") @@ -2990,9 +3150,10 @@

                          Methods

                          def clean(self, version=None):
                               from .roots import RootOfHierarchy
                          +
                               super().clean(version=version)
                               if self.root and not isinstance(self.root, RootOfHierarchy):
                          -        raise InvalidTypeError('root', self.root, RootOfHierarchy)
                          + raise InvalidTypeError("root", self.root, RootOfHierarchy) diff --git a/docs/exchangelib/folders/collections.html b/docs/exchangelib/folders/collections.html index 3a871adc..a1d05210 100644 --- a/docs/exchangelib/folders/collections.html +++ b/docs/exchangelib/folders/collections.html @@ -33,9 +33,9 @@

                          Module exchangelib.folders.collections

                          from ..errors import InvalidTypeError from ..fields import FieldPath, InvalidField -from ..items import Persona, ID_ONLY +from ..items import ID_ONLY, Persona from ..properties import CalendarView -from ..queryset import QuerySet, SearchableMixIn, Q +from ..queryset import Q, QuerySet, SearchableMixIn from ..restriction import Restriction from ..util import require_account @@ -54,7 +54,7 @@

                          Module exchangelib.folders.collections

                          """A class that implements an API for searching folders.""" # These fields are required in a FindFolder or GetFolder call to properly identify folder types - REQUIRED_FOLDER_FIELDS = ('name', 'folder_class') + REQUIRED_FOLDER_FIELDS = ("name", "folder_class") def __init__(self, account, folders): """Implement a search API on a collection of folders. @@ -185,8 +185,18 @@

                          Module exchangelib.folders.collections

                          query_string = None return depth, restriction, query_string - def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order_fields=None, - calendar_view=None, page_size=None, max_items=None, offset=0): + def find_items( + self, + q, + shape=ID_ONLY, + depth=None, + additional_fields=None, + order_fields=None, + calendar_view=None, + page_size=None, + max_items=None, + offset=0, + ): """Private method to call the FindItem service. :param q: a Q instance containing any restrictions @@ -204,21 +214,21 @@

                          Module exchangelib.folders.collections

                          :return: a generator for the returned item IDs or items """ from ..services import FindItem + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return if q.is_never(): - log.debug('Query will never return results') + log.debug("Query will never return results") return depth, restriction, query_string = self._rinse_args( - q=q, depth=depth, additional_fields=additional_fields, - field_validator=self.validate_item_field + q=q, depth=depth, additional_fields=additional_fields, field_validator=self.validate_item_field ) if calendar_view is not None and not isinstance(calendar_view, CalendarView): - raise InvalidTypeError('calendar_view', calendar_view, CalendarView) + raise InvalidTypeError("calendar_view", calendar_view, CalendarView) log.debug( - 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', + "Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)", self.account, self.folders, shape, @@ -241,14 +251,23 @@

                          Module exchangelib.folders.collections

                          def _get_single_folder(self): if len(self.folders) > 1: - raise ValueError('Syncing folder hierarchy can only be done on a single folder') + raise ValueError("Syncing folder hierarchy can only be done on a single folder") if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return None return self.folders[0] - def find_people(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order_fields=None, - page_size=None, max_items=None, offset=0): + def find_people( + self, + q, + shape=ID_ONLY, + depth=None, + additional_fields=None, + order_fields=None, + page_size=None, + max_items=None, + offset=0, + ): """Private method to call the FindPeople service. :param q: a Q instance containing any restrictions @@ -264,30 +283,31 @@

                          Module exchangelib.folders.collections

                          :return: a generator for the returned personas """ from ..services import FindPeople + folder = self._get_single_folder() if q.is_never(): - log.debug('Query will never return results') + log.debug("Query will never return results") return depth, restriction, query_string = self._rinse_args( - q=q, depth=depth, additional_fields=additional_fields, - field_validator=Persona.validate_field + q=q, depth=depth, additional_fields=additional_fields, field_validator=Persona.validate_field ) yield from FindPeople(account=self.account, page_size=page_size).call( - folder=folder, - additional_fields=additional_fields, - restriction=restriction, - order_fields=order_fields, - shape=shape, - query_string=query_string, - depth=depth, - max_items=max_items, - offset=offset, + folder=folder, + additional_fields=additional_fields, + restriction=restriction, + order_fields=order_fields, + shape=shape, + query_string=query_string, + depth=depth, + max_items=max_items, + offset=offset, ) def get_folder_fields(self, target_cls, is_complex=None): return { - FieldPath(field=f) for f in target_cls.supported_fields(version=self.account.version) + FieldPath(field=f) + for f in target_cls.supported_fields(version=self.account.version) if is_complex is None or f.is_complex is is_complex } @@ -296,16 +316,17 @@

                          Module exchangelib.folders.collections

                          # both folder types in self.folders, raise an error so we don't risk losing some fields in the query. from .base import Folder from .roots import RootOfHierarchy + has_roots = False has_non_roots = False for f in self.folders: if isinstance(f, RootOfHierarchy): if has_non_roots: - raise ValueError(f'Cannot call GetFolder on a mix of folder types: {self.folders}') + raise ValueError(f"Cannot call GetFolder on a mix of folder types: {self.folders}") has_roots = True else: if has_roots: - raise ValueError(f'Cannot call GetFolder on a mix of folder types: {self.folders}') + raise ValueError(f"Cannot call GetFolder on a mix of folder types: {self.folders}") has_non_roots = True return RootOfHierarchy if has_roots else Folder @@ -314,47 +335,51 @@

                          Module exchangelib.folders.collections

                          if len(unique_depths) == 1: return unique_depths.pop() raise ValueError( - f'Folders in this collection do not have a common {traversal_attr} value. You need to define an explicit ' - f'traversal depth with QuerySet.depth() (values: {unique_depths})' + f"Folders in this collection do not have a common {traversal_attr} value. You need to define an explicit " + f"traversal depth with QuerySet.depth() (values: {unique_depths})" ) def _get_default_item_traversal_depth(self): # When searching folders, some folders require 'Shallow' and others 'Associated' traversal depth. - return self._get_default_traversal_depth('DEFAULT_ITEM_TRAVERSAL_DEPTH') + return self._get_default_traversal_depth("DEFAULT_ITEM_TRAVERSAL_DEPTH") def _get_default_folder_traversal_depth(self): # When searching folders, some folders require 'Shallow' and others 'Deep' traversal depth. - return self._get_default_traversal_depth('DEFAULT_FOLDER_TRAVERSAL_DEPTH') + return self._get_default_traversal_depth("DEFAULT_FOLDER_TRAVERSAL_DEPTH") def resolve(self): # Looks up the folders or folder IDs in the collection and returns full Folder instances with all fields set. from .base import BaseFolder + resolveable_folders = [] for f in self.folders: if isinstance(f, BaseFolder) and not f.get_folder_allowed: - log.debug('GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder', f) + log.debug("GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder", f) yield f else: resolveable_folders.append(f) # Fetch all properties for the remaining folders of folder IDs additional_fields = self.get_folder_fields(target_cls=self._get_target_cls(), is_complex=None) yield from self.__class__(account=self.account, folders=resolveable_folders).get_folders( - additional_fields=additional_fields + additional_fields=additional_fields ) @require_account - def find_folders(self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None, - offset=0): + def find_folders( + self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None, offset=0 + ): from ..services import FindFolder + # 'depth' controls whether to return direct children or recurse into sub-folders from .base import BaseFolder, Folder + if q is None: q = Q() if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return if q.is_never(): - log.debug('Query will never return results') + log.debug("Query will never return results") return if q.is_empty(): restriction = None @@ -376,21 +401,23 @@

                          Module exchangelib.folders.collections

                          ) yield from FindFolder(account=self.account, page_size=page_size).call( - folders=self.folders, - additional_fields=additional_fields, - restriction=restriction, - shape=shape, - depth=depth, - max_items=max_items, - offset=offset, + folders=self.folders, + additional_fields=additional_fields, + restriction=restriction, + shape=shape, + depth=depth, + max_items=max_items, + offset=offset, ) def get_folders(self, additional_fields=None): from ..services import GetFolder + # Expand folders with their full set of properties from .base import BaseFolder + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return if additional_fields is None: # Default to all complex properties @@ -402,38 +429,47 @@

                          Module exchangelib.folders.collections

                          ) yield from GetFolder(account=self.account).call( - folders=self.folders, - additional_fields=additional_fields, - shape=ID_ONLY, + folders=self.folders, + additional_fields=additional_fields, + shape=ID_ONLY, ) def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): from ..services import SubscribeToPull + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return None if event_types is None: event_types = SubscribeToPull.EVENT_TYPES return SubscribeToPull(account=self.account).get( - folders=self.folders, event_types=event_types, watermark=watermark, timeout=timeout, + folders=self.folders, + event_types=event_types, + watermark=watermark, + timeout=timeout, ) def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1): from ..services import SubscribeToPush + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return None if event_types is None: event_types = SubscribeToPush.EVENT_TYPES return SubscribeToPush(account=self.account).get( - folders=self.folders, event_types=event_types, watermark=watermark, status_frequency=status_frequency, + folders=self.folders, + event_types=event_types, + watermark=watermark, + status_frequency=status_frequency, url=callback_url, ) def subscribe_to_streaming(self, event_types=None): from ..services import SubscribeToStreaming + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return None if event_types is None: event_types = SubscribeToStreaming.EVENT_TYPES @@ -458,10 +494,12 @@

                          Module exchangelib.folders.collections

                          sync methods. """ from ..services import Unsubscribe + return Unsubscribe(account=self.account).get(subscription_id=subscription_id) def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None): from ..services import SyncFolderItems + folder = self._get_single_folder() if only_fields is None: # We didn't restrict list of field paths. Get all fields from the server, including extended properties. @@ -493,6 +531,7 @@

                          Module exchangelib.folders.collections

                          def sync_hierarchy(self, sync_state=None, only_fields=None): from ..services import SyncFolderHierarchy + folder = self._get_single_folder() if only_fields is None: # We didn't restrict list of field paths. Get all fields from the server, including extended properties. @@ -622,7 +661,7 @@

                          Subclasses

                          """A class that implements an API for searching folders.""" # These fields are required in a FindFolder or GetFolder call to properly identify folder types - REQUIRED_FOLDER_FIELDS = ('name', 'folder_class') + REQUIRED_FOLDER_FIELDS = ("name", "folder_class") def __init__(self, account, folders): """Implement a search API on a collection of folders. @@ -753,8 +792,18 @@

                          Subclasses

                          query_string = None return depth, restriction, query_string - def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order_fields=None, - calendar_view=None, page_size=None, max_items=None, offset=0): + def find_items( + self, + q, + shape=ID_ONLY, + depth=None, + additional_fields=None, + order_fields=None, + calendar_view=None, + page_size=None, + max_items=None, + offset=0, + ): """Private method to call the FindItem service. :param q: a Q instance containing any restrictions @@ -772,21 +821,21 @@

                          Subclasses

                          :return: a generator for the returned item IDs or items """ from ..services import FindItem + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return if q.is_never(): - log.debug('Query will never return results') + log.debug("Query will never return results") return depth, restriction, query_string = self._rinse_args( - q=q, depth=depth, additional_fields=additional_fields, - field_validator=self.validate_item_field + q=q, depth=depth, additional_fields=additional_fields, field_validator=self.validate_item_field ) if calendar_view is not None and not isinstance(calendar_view, CalendarView): - raise InvalidTypeError('calendar_view', calendar_view, CalendarView) + raise InvalidTypeError("calendar_view", calendar_view, CalendarView) log.debug( - 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', + "Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)", self.account, self.folders, shape, @@ -809,14 +858,23 @@

                          Subclasses

                          def _get_single_folder(self): if len(self.folders) > 1: - raise ValueError('Syncing folder hierarchy can only be done on a single folder') + raise ValueError("Syncing folder hierarchy can only be done on a single folder") if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return None return self.folders[0] - def find_people(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order_fields=None, - page_size=None, max_items=None, offset=0): + def find_people( + self, + q, + shape=ID_ONLY, + depth=None, + additional_fields=None, + order_fields=None, + page_size=None, + max_items=None, + offset=0, + ): """Private method to call the FindPeople service. :param q: a Q instance containing any restrictions @@ -832,30 +890,31 @@

                          Subclasses

                          :return: a generator for the returned personas """ from ..services import FindPeople + folder = self._get_single_folder() if q.is_never(): - log.debug('Query will never return results') + log.debug("Query will never return results") return depth, restriction, query_string = self._rinse_args( - q=q, depth=depth, additional_fields=additional_fields, - field_validator=Persona.validate_field + q=q, depth=depth, additional_fields=additional_fields, field_validator=Persona.validate_field ) yield from FindPeople(account=self.account, page_size=page_size).call( - folder=folder, - additional_fields=additional_fields, - restriction=restriction, - order_fields=order_fields, - shape=shape, - query_string=query_string, - depth=depth, - max_items=max_items, - offset=offset, + folder=folder, + additional_fields=additional_fields, + restriction=restriction, + order_fields=order_fields, + shape=shape, + query_string=query_string, + depth=depth, + max_items=max_items, + offset=offset, ) def get_folder_fields(self, target_cls, is_complex=None): return { - FieldPath(field=f) for f in target_cls.supported_fields(version=self.account.version) + FieldPath(field=f) + for f in target_cls.supported_fields(version=self.account.version) if is_complex is None or f.is_complex is is_complex } @@ -864,16 +923,17 @@

                          Subclasses

                          # both folder types in self.folders, raise an error so we don't risk losing some fields in the query. from .base import Folder from .roots import RootOfHierarchy + has_roots = False has_non_roots = False for f in self.folders: if isinstance(f, RootOfHierarchy): if has_non_roots: - raise ValueError(f'Cannot call GetFolder on a mix of folder types: {self.folders}') + raise ValueError(f"Cannot call GetFolder on a mix of folder types: {self.folders}") has_roots = True else: if has_roots: - raise ValueError(f'Cannot call GetFolder on a mix of folder types: {self.folders}') + raise ValueError(f"Cannot call GetFolder on a mix of folder types: {self.folders}") has_non_roots = True return RootOfHierarchy if has_roots else Folder @@ -882,47 +942,51 @@

                          Subclasses

                          if len(unique_depths) == 1: return unique_depths.pop() raise ValueError( - f'Folders in this collection do not have a common {traversal_attr} value. You need to define an explicit ' - f'traversal depth with QuerySet.depth() (values: {unique_depths})' + f"Folders in this collection do not have a common {traversal_attr} value. You need to define an explicit " + f"traversal depth with QuerySet.depth() (values: {unique_depths})" ) def _get_default_item_traversal_depth(self): # When searching folders, some folders require 'Shallow' and others 'Associated' traversal depth. - return self._get_default_traversal_depth('DEFAULT_ITEM_TRAVERSAL_DEPTH') + return self._get_default_traversal_depth("DEFAULT_ITEM_TRAVERSAL_DEPTH") def _get_default_folder_traversal_depth(self): # When searching folders, some folders require 'Shallow' and others 'Deep' traversal depth. - return self._get_default_traversal_depth('DEFAULT_FOLDER_TRAVERSAL_DEPTH') + return self._get_default_traversal_depth("DEFAULT_FOLDER_TRAVERSAL_DEPTH") def resolve(self): # Looks up the folders or folder IDs in the collection and returns full Folder instances with all fields set. from .base import BaseFolder + resolveable_folders = [] for f in self.folders: if isinstance(f, BaseFolder) and not f.get_folder_allowed: - log.debug('GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder', f) + log.debug("GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder", f) yield f else: resolveable_folders.append(f) # Fetch all properties for the remaining folders of folder IDs additional_fields = self.get_folder_fields(target_cls=self._get_target_cls(), is_complex=None) yield from self.__class__(account=self.account, folders=resolveable_folders).get_folders( - additional_fields=additional_fields + additional_fields=additional_fields ) @require_account - def find_folders(self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None, - offset=0): + def find_folders( + self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None, offset=0 + ): from ..services import FindFolder + # 'depth' controls whether to return direct children or recurse into sub-folders from .base import BaseFolder, Folder + if q is None: q = Q() if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return if q.is_never(): - log.debug('Query will never return results') + log.debug("Query will never return results") return if q.is_empty(): restriction = None @@ -944,21 +1008,23 @@

                          Subclasses

                          ) yield from FindFolder(account=self.account, page_size=page_size).call( - folders=self.folders, - additional_fields=additional_fields, - restriction=restriction, - shape=shape, - depth=depth, - max_items=max_items, - offset=offset, + folders=self.folders, + additional_fields=additional_fields, + restriction=restriction, + shape=shape, + depth=depth, + max_items=max_items, + offset=offset, ) def get_folders(self, additional_fields=None): from ..services import GetFolder + # Expand folders with their full set of properties from .base import BaseFolder + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return if additional_fields is None: # Default to all complex properties @@ -970,38 +1036,47 @@

                          Subclasses

                          ) yield from GetFolder(account=self.account).call( - folders=self.folders, - additional_fields=additional_fields, - shape=ID_ONLY, + folders=self.folders, + additional_fields=additional_fields, + shape=ID_ONLY, ) def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): from ..services import SubscribeToPull + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return None if event_types is None: event_types = SubscribeToPull.EVENT_TYPES return SubscribeToPull(account=self.account).get( - folders=self.folders, event_types=event_types, watermark=watermark, timeout=timeout, + folders=self.folders, + event_types=event_types, + watermark=watermark, + timeout=timeout, ) def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1): from ..services import SubscribeToPush + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return None if event_types is None: event_types = SubscribeToPush.EVENT_TYPES return SubscribeToPush(account=self.account).get( - folders=self.folders, event_types=event_types, watermark=watermark, status_frequency=status_frequency, + folders=self.folders, + event_types=event_types, + watermark=watermark, + status_frequency=status_frequency, url=callback_url, ) def subscribe_to_streaming(self, event_types=None): from ..services import SubscribeToStreaming + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return None if event_types is None: event_types = SubscribeToStreaming.EVENT_TYPES @@ -1026,10 +1101,12 @@

                          Subclasses

                          sync methods. """ from ..services import Unsubscribe + return Unsubscribe(account=self.account).get(subscription_id=subscription_id) def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None): from ..services import SyncFolderItems + folder = self._get_single_folder() if only_fields is None: # We didn't restrict list of field paths. Get all fields from the server, including extended properties. @@ -1061,6 +1138,7 @@

                          Subclasses

                          def sync_hierarchy(self, sync_state=None, only_fields=None): from ..services import SyncFolderHierarchy + folder = self._get_single_folder() if only_fields is None: # We didn't restrict list of field paths. Get all fields from the server, including extended properties. @@ -1227,18 +1305,21 @@

                          Examples

                          Expand source code
                          @require_account
                          -def find_folders(self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None,
                          -                 offset=0):
                          +def find_folders(
                          +    self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None, offset=0
                          +):
                               from ..services import FindFolder
                          +
                               # 'depth' controls whether to return direct children or recurse into sub-folders
                               from .base import BaseFolder, Folder
                          +
                               if q is None:
                                   q = Q()
                               if not self.folders:
                          -        log.debug('Folder list is empty')
                          +        log.debug("Folder list is empty")
                                   return
                               if q.is_never():
                          -        log.debug('Query will never return results')
                          +        log.debug("Query will never return results")
                                   return
                               if q.is_empty():
                                   restriction = None
                          @@ -1260,13 +1341,13 @@ 

                          Examples

                          ) yield from FindFolder(account=self.account, page_size=page_size).call( - folders=self.folders, - additional_fields=additional_fields, - restriction=restriction, - shape=shape, - depth=depth, - max_items=max_items, - offset=offset, + folders=self.folders, + additional_fields=additional_fields, + restriction=restriction, + shape=shape, + depth=depth, + max_items=max_items, + offset=offset, )
                          @@ -1291,8 +1372,18 @@

                          Examples

                          Expand source code -
                          def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order_fields=None,
                          -               calendar_view=None, page_size=None, max_items=None, offset=0):
                          +
                          def find_items(
                          +    self,
                          +    q,
                          +    shape=ID_ONLY,
                          +    depth=None,
                          +    additional_fields=None,
                          +    order_fields=None,
                          +    calendar_view=None,
                          +    page_size=None,
                          +    max_items=None,
                          +    offset=0,
                          +):
                               """Private method to call the FindItem service.
                           
                               :param q: a Q instance containing any restrictions
                          @@ -1310,21 +1401,21 @@ 

                          Examples

                          :return: a generator for the returned item IDs or items """ from ..services import FindItem + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return if q.is_never(): - log.debug('Query will never return results') + log.debug("Query will never return results") return depth, restriction, query_string = self._rinse_args( - q=q, depth=depth, additional_fields=additional_fields, - field_validator=self.validate_item_field + q=q, depth=depth, additional_fields=additional_fields, field_validator=self.validate_item_field ) if calendar_view is not None and not isinstance(calendar_view, CalendarView): - raise InvalidTypeError('calendar_view', calendar_view, CalendarView) + raise InvalidTypeError("calendar_view", calendar_view, CalendarView) log.debug( - 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', + "Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)", self.account, self.folders, shape, @@ -1365,8 +1456,17 @@

                          Examples

                          Expand source code -
                          def find_people(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order_fields=None,
                          -                page_size=None, max_items=None, offset=0):
                          +
                          def find_people(
                          +    self,
                          +    q,
                          +    shape=ID_ONLY,
                          +    depth=None,
                          +    additional_fields=None,
                          +    order_fields=None,
                          +    page_size=None,
                          +    max_items=None,
                          +    offset=0,
                          +):
                               """Private method to call the FindPeople service.
                           
                               :param q: a Q instance containing any restrictions
                          @@ -1382,25 +1482,25 @@ 

                          Examples

                          :return: a generator for the returned personas """ from ..services import FindPeople + folder = self._get_single_folder() if q.is_never(): - log.debug('Query will never return results') + log.debug("Query will never return results") return depth, restriction, query_string = self._rinse_args( - q=q, depth=depth, additional_fields=additional_fields, - field_validator=Persona.validate_field + q=q, depth=depth, additional_fields=additional_fields, field_validator=Persona.validate_field ) yield from FindPeople(account=self.account, page_size=page_size).call( - folder=folder, - additional_fields=additional_fields, - restriction=restriction, - order_fields=order_fields, - shape=shape, - query_string=query_string, - depth=depth, - max_items=max_items, - offset=offset, + folder=folder, + additional_fields=additional_fields, + restriction=restriction, + order_fields=order_fields, + shape=shape, + query_string=query_string, + depth=depth, + max_items=max_items, + offset=offset, )
                          @@ -1415,7 +1515,8 @@

                          Examples

                          def get_folder_fields(self, target_cls, is_complex=None):
                               return {
                          -        FieldPath(field=f) for f in target_cls.supported_fields(version=self.account.version)
                          +        FieldPath(field=f)
                          +        for f in target_cls.supported_fields(version=self.account.version)
                                   if is_complex is None or f.is_complex is is_complex
                               }
                          @@ -1431,10 +1532,12 @@

                          Examples

                          def get_folders(self, additional_fields=None):
                               from ..services import GetFolder
                          +
                               # Expand folders with their full set of properties
                               from .base import BaseFolder
                          +
                               if not self.folders:
                          -        log.debug('Folder list is empty')
                          +        log.debug("Folder list is empty")
                                   return
                               if additional_fields is None:
                                   # Default to all complex properties
                          @@ -1446,9 +1549,9 @@ 

                          Examples

                          ) yield from GetFolder(account=self.account).call( - folders=self.folders, - additional_fields=additional_fields, - shape=ID_ONLY, + folders=self.folders, + additional_fields=additional_fields, + shape=ID_ONLY, )
                          @@ -1490,17 +1593,18 @@

                          Examples

                          def resolve(self):
                               # Looks up the folders or folder IDs in the collection and returns full Folder instances with all fields set.
                               from .base import BaseFolder
                          +
                               resolveable_folders = []
                               for f in self.folders:
                                   if isinstance(f, BaseFolder) and not f.get_folder_allowed:
                          -            log.debug('GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder', f)
                          +            log.debug("GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder", f)
                                       yield f
                                   else:
                                       resolveable_folders.append(f)
                               # Fetch all properties for the remaining folders of folder IDs
                               additional_fields = self.get_folder_fields(target_cls=self._get_target_cls(), is_complex=None)
                               yield from self.__class__(account=self.account, folders=resolveable_folders).get_folders(
                          -            additional_fields=additional_fields
                          +        additional_fields=additional_fields
                               )
                          @@ -1528,13 +1632,17 @@

                          Examples

                          def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60):
                               from ..services import SubscribeToPull
                          +
                               if not self.folders:
                          -        log.debug('Folder list is empty')
                          +        log.debug("Folder list is empty")
                                   return None
                               if event_types is None:
                                   event_types = SubscribeToPull.EVENT_TYPES
                               return SubscribeToPull(account=self.account).get(
                          -        folders=self.folders, event_types=event_types, watermark=watermark, timeout=timeout,
                          +        folders=self.folders,
                          +        event_types=event_types,
                          +        watermark=watermark,
                          +        timeout=timeout,
                               )
                          @@ -1549,13 +1657,17 @@

                          Examples

                          def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1):
                               from ..services import SubscribeToPush
                          +
                               if not self.folders:
                          -        log.debug('Folder list is empty')
                          +        log.debug("Folder list is empty")
                                   return None
                               if event_types is None:
                                   event_types = SubscribeToPush.EVENT_TYPES
                               return SubscribeToPush(account=self.account).get(
                          -        folders=self.folders, event_types=event_types, watermark=watermark, status_frequency=status_frequency,
                          +        folders=self.folders,
                          +        event_types=event_types,
                          +        watermark=watermark,
                          +        status_frequency=status_frequency,
                                   url=callback_url,
                               )
                          @@ -1571,8 +1683,9 @@

                          Examples

                          def subscribe_to_streaming(self, event_types=None):
                               from ..services import SubscribeToStreaming
                          +
                               if not self.folders:
                          -        log.debug('Folder list is empty')
                          +        log.debug("Folder list is empty")
                                   return None
                               if event_types is None:
                                   event_types = SubscribeToStreaming.EVENT_TYPES
                          @@ -1590,6 +1703,7 @@ 

                          Examples

                          def sync_hierarchy(self, sync_state=None, only_fields=None):
                               from ..services import SyncFolderHierarchy
                          +
                               folder = self._get_single_folder()
                               if only_fields is None:
                                   # We didn't restrict list of field paths. Get all fields from the server, including extended properties.
                          @@ -1636,6 +1750,7 @@ 

                          Examples

                          def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None):
                               from ..services import SyncFolderItems
                          +
                               folder = self._get_single_folder()
                               if only_fields is None:
                                   # We didn't restrict list of field paths. Get all fields from the server, including extended properties.
                          @@ -1689,6 +1804,7 @@ 

                          Examples

                          sync methods. """ from ..services import Unsubscribe + return Unsubscribe(account=self.account).get(subscription_id=subscription_id)
                          diff --git a/docs/exchangelib/folders/index.html b/docs/exchangelib/folders/index.html index 98d925b3..d29c8485 100644 --- a/docs/exchangelib/folders/index.html +++ b/docs/exchangelib/folders/index.html @@ -26,44 +26,186 @@

                          Module exchangelib.folders

                          Expand source code -
                          from .base import BaseFolder, Folder
                          +
                          from ..properties import DistinguishedFolderId, FolderId
                          +from .base import BaseFolder, Folder
                           from .collections import FolderCollection
                          -from .known_folders import AdminAuditLogs, AllContacts, AllItems, ArchiveDeletedItems, ArchiveInbox, \
                          -    ArchiveMsgFolderRoot, ArchiveRecoverableItemsDeletions, ArchiveRecoverableItemsPurges, \
                          -    ArchiveRecoverableItemsRoot, ArchiveRecoverableItemsVersions, Audits, Calendar, CalendarLogging, CommonViews, \
                          -    Conflicts, Contacts, ConversationHistory, ConversationSettings, DefaultFoldersChangeHistory, DeferredAction, \
                          -    DeletedItems, Directory, Drafts, ExchangeSyncData, Favorites, Files, FreebusyData, Friends, GALContacts, \
                          -    GraphAnalytics, IMContactList, Inbox, Journal, JunkEmail, LocalFailures, Location, MailboxAssociations, Messages, \
                          -    MsgFolderRoot, MyContacts, MyContactsExtended, NonDeletableFolderMixin, Notes, Outbox, ParkedMessages, \
                          -    PassThroughSearchResults, PdpProfileV2Secured, PeopleConnect, QuickContacts, RSSFeeds, RecipientCache, \
                          -    RecoverableItemsDeletions, RecoverableItemsPurges, RecoverableItemsRoot, RecoverableItemsVersions, Reminders, \
                          -    Schedule, SearchFolders, SentItems, ServerFailures, Sharing, Shortcuts, Signal, SmsAndChatsSync, SpoolerQueue, \
                          -    SyncIssues, System, Tasks, TemporarySaves, ToDoSearch, Views, VoiceMail, WellknownFolder, WorkingSet, \
                          -    Companies, OrganizationalContacts, PeopleCentricConversationBuddies, NON_DELETABLE_FOLDERS
                          -from .queryset import FolderQuerySet, SingleFolderQuerySet, FOLDER_TRAVERSAL_CHOICES, SHALLOW, DEEP, SOFT_DELETED
                          -from .roots import Root, ArchiveRoot, PublicFoldersRoot, RootOfHierarchy
                          -from ..properties import FolderId, DistinguishedFolderId
                          +from .known_folders import (
                          +    NON_DELETABLE_FOLDERS,
                          +    AdminAuditLogs,
                          +    AllContacts,
                          +    AllItems,
                          +    ArchiveDeletedItems,
                          +    ArchiveInbox,
                          +    ArchiveMsgFolderRoot,
                          +    ArchiveRecoverableItemsDeletions,
                          +    ArchiveRecoverableItemsPurges,
                          +    ArchiveRecoverableItemsRoot,
                          +    ArchiveRecoverableItemsVersions,
                          +    Audits,
                          +    Calendar,
                          +    CalendarLogging,
                          +    CommonViews,
                          +    Companies,
                          +    Conflicts,
                          +    Contacts,
                          +    ConversationHistory,
                          +    ConversationSettings,
                          +    DefaultFoldersChangeHistory,
                          +    DeferredAction,
                          +    DeletedItems,
                          +    Directory,
                          +    Drafts,
                          +    ExchangeSyncData,
                          +    Favorites,
                          +    Files,
                          +    FreebusyData,
                          +    Friends,
                          +    GALContacts,
                          +    GraphAnalytics,
                          +    IMContactList,
                          +    Inbox,
                          +    Journal,
                          +    JunkEmail,
                          +    LocalFailures,
                          +    Location,
                          +    MailboxAssociations,
                          +    Messages,
                          +    MsgFolderRoot,
                          +    MyContacts,
                          +    MyContactsExtended,
                          +    NonDeletableFolderMixin,
                          +    Notes,
                          +    OrganizationalContacts,
                          +    Outbox,
                          +    ParkedMessages,
                          +    PassThroughSearchResults,
                          +    PdpProfileV2Secured,
                          +    PeopleCentricConversationBuddies,
                          +    PeopleConnect,
                          +    QuickContacts,
                          +    RecipientCache,
                          +    RecoverableItemsDeletions,
                          +    RecoverableItemsPurges,
                          +    RecoverableItemsRoot,
                          +    RecoverableItemsVersions,
                          +    Reminders,
                          +    RSSFeeds,
                          +    Schedule,
                          +    SearchFolders,
                          +    SentItems,
                          +    ServerFailures,
                          +    Sharing,
                          +    Shortcuts,
                          +    Signal,
                          +    SmsAndChatsSync,
                          +    SpoolerQueue,
                          +    SyncIssues,
                          +    System,
                          +    Tasks,
                          +    TemporarySaves,
                          +    ToDoSearch,
                          +    Views,
                          +    VoiceMail,
                          +    WellknownFolder,
                          +    WorkingSet,
                          +)
                          +from .queryset import DEEP, FOLDER_TRAVERSAL_CHOICES, SHALLOW, SOFT_DELETED, FolderQuerySet, SingleFolderQuerySet
                          +from .roots import ArchiveRoot, PublicFoldersRoot, Root, RootOfHierarchy
                           
                           __all__ = [
                          -    'FolderId', 'DistinguishedFolderId',
                          -    'FolderCollection',
                          -    'BaseFolder', 'Folder',
                          -    'AdminAuditLogs', 'AllContacts', 'AllItems', 'ArchiveDeletedItems', 'ArchiveInbox', 'ArchiveMsgFolderRoot',
                          -    'ArchiveRecoverableItemsDeletions', 'ArchiveRecoverableItemsPurges', 'ArchiveRecoverableItemsRoot',
                          -    'ArchiveRecoverableItemsVersions', 'Audits', 'Calendar', 'CalendarLogging', 'CommonViews', 'Conflicts',
                          -    'Contacts', 'ConversationHistory', 'ConversationSettings', 'DefaultFoldersChangeHistory', 'DeferredAction',
                          -    'DeletedItems', 'Directory', 'Drafts', 'ExchangeSyncData', 'Favorites', 'Files', 'FreebusyData', 'Friends',
                          -    'GALContacts', 'GraphAnalytics', 'IMContactList', 'Inbox', 'Journal', 'JunkEmail', 'LocalFailures',
                          -    'Location', 'MailboxAssociations', 'Messages', 'MsgFolderRoot', 'MyContacts', 'MyContactsExtended',
                          -    'NonDeletableFolderMixin', 'Notes', 'Outbox', 'ParkedMessages', 'PassThroughSearchResults',
                          -    'PdpProfileV2Secured', 'PeopleConnect', 'QuickContacts', 'RSSFeeds', 'RecipientCache',
                          -    'RecoverableItemsDeletions', 'RecoverableItemsPurges', 'RecoverableItemsRoot', 'RecoverableItemsVersions',
                          -    'Reminders', 'Schedule', 'SearchFolders', 'SentItems', 'ServerFailures', 'Sharing', 'Shortcuts', 'Signal',
                          -    'SmsAndChatsSync', 'SpoolerQueue', 'SyncIssues', 'System', 'Tasks', 'TemporarySaves', 'ToDoSearch', 'Views',
                          -    'VoiceMail', 'WellknownFolder', 'WorkingSet', 'Companies', 'OrganizationalContacts',
                          -    'PeopleCentricConversationBuddies', 'NON_DELETABLE_FOLDERS',
                          -    'FolderQuerySet', 'SingleFolderQuerySet', 'FOLDER_TRAVERSAL_CHOICES', 'SHALLOW', 'DEEP', 'SOFT_DELETED',
                          -    'Root', 'ArchiveRoot', 'PublicFoldersRoot', 'RootOfHierarchy',
                          +    "FolderId",
                          +    "DistinguishedFolderId",
                          +    "FolderCollection",
                          +    "BaseFolder",
                          +    "Folder",
                          +    "AdminAuditLogs",
                          +    "AllContacts",
                          +    "AllItems",
                          +    "ArchiveDeletedItems",
                          +    "ArchiveInbox",
                          +    "ArchiveMsgFolderRoot",
                          +    "ArchiveRecoverableItemsDeletions",
                          +    "ArchiveRecoverableItemsPurges",
                          +    "ArchiveRecoverableItemsRoot",
                          +    "ArchiveRecoverableItemsVersions",
                          +    "Audits",
                          +    "Calendar",
                          +    "CalendarLogging",
                          +    "CommonViews",
                          +    "Conflicts",
                          +    "Contacts",
                          +    "ConversationHistory",
                          +    "ConversationSettings",
                          +    "DefaultFoldersChangeHistory",
                          +    "DeferredAction",
                          +    "DeletedItems",
                          +    "Directory",
                          +    "Drafts",
                          +    "ExchangeSyncData",
                          +    "Favorites",
                          +    "Files",
                          +    "FreebusyData",
                          +    "Friends",
                          +    "GALContacts",
                          +    "GraphAnalytics",
                          +    "IMContactList",
                          +    "Inbox",
                          +    "Journal",
                          +    "JunkEmail",
                          +    "LocalFailures",
                          +    "Location",
                          +    "MailboxAssociations",
                          +    "Messages",
                          +    "MsgFolderRoot",
                          +    "MyContacts",
                          +    "MyContactsExtended",
                          +    "NonDeletableFolderMixin",
                          +    "Notes",
                          +    "Outbox",
                          +    "ParkedMessages",
                          +    "PassThroughSearchResults",
                          +    "PdpProfileV2Secured",
                          +    "PeopleConnect",
                          +    "QuickContacts",
                          +    "RSSFeeds",
                          +    "RecipientCache",
                          +    "RecoverableItemsDeletions",
                          +    "RecoverableItemsPurges",
                          +    "RecoverableItemsRoot",
                          +    "RecoverableItemsVersions",
                          +    "Reminders",
                          +    "Schedule",
                          +    "SearchFolders",
                          +    "SentItems",
                          +    "ServerFailures",
                          +    "Sharing",
                          +    "Shortcuts",
                          +    "Signal",
                          +    "SmsAndChatsSync",
                          +    "SpoolerQueue",
                          +    "SyncIssues",
                          +    "System",
                          +    "Tasks",
                          +    "TemporarySaves",
                          +    "ToDoSearch",
                          +    "Views",
                          +    "VoiceMail",
                          +    "WellknownFolder",
                          +    "WorkingSet",
                          +    "Companies",
                          +    "OrganizationalContacts",
                          +    "PeopleCentricConversationBuddies",
                          +    "NON_DELETABLE_FOLDERS",
                          +    "FolderQuerySet",
                          +    "SingleFolderQuerySet",
                          +    "FOLDER_TRAVERSAL_CHOICES",
                          +    "SHALLOW",
                          +    "DEEP",
                          +    "SOFT_DELETED",
                          +    "Root",
                          +    "ArchiveRoot",
                          +    "PublicFoldersRoot",
                          +    "RootOfHierarchy",
                           ]
                          @@ -110,7 +252,7 @@

                          Classes

                          Expand source code
                          class AdminAuditLogs(WellknownFolder):
                          -    DISTINGUISHED_FOLDER_ID = 'adminauditlogs'
                          +    DISTINGUISHED_FOLDER_ID = "adminauditlogs"
                               supported_from = EXCHANGE_2013
                               get_folder_allowed = False
                          @@ -189,10 +331,10 @@

                          Inherited members

                          Expand source code
                          class AllContacts(NonDeletableFolderMixin, Contacts):
                          -    CONTAINER_CLASS = 'IPF.Note'
                          +    CONTAINER_CLASS = "IPF.Note"
                           
                               LOCALIZED_NAMES = {
                          -        None: ('AllContacts',),
                          +        None: ("AllContacts",),
                               }

                          Ancestors

                          @@ -267,10 +409,10 @@

                          Inherited members

                          Expand source code
                          class AllItems(NonDeletableFolderMixin, Folder):
                          -    CONTAINER_CLASS = 'IPF'
                          +    CONTAINER_CLASS = "IPF"
                           
                               LOCALIZED_NAMES = {
                          -        None: ('AllItems',),
                          +        None: ("AllItems",),
                               }

                          Ancestors

                          @@ -344,7 +486,7 @@

                          Inherited members

                          Expand source code
                          class ArchiveDeletedItems(WellknownFolder):
                          -    DISTINGUISHED_FOLDER_ID = 'archivedeleteditems'
                          +    DISTINGUISHED_FOLDER_ID = "archivedeleteditems"
                               supported_from = EXCHANGE_2010_SP1

                          Ancestors

                          @@ -418,7 +560,7 @@

                          Inherited members

                          Expand source code
                          class ArchiveInbox(WellknownFolder):
                          -    DISTINGUISHED_FOLDER_ID = 'archiveinbox'
                          +    DISTINGUISHED_FOLDER_ID = "archiveinbox"
                               supported_from = EXCHANGE_2013_SP1

                          Ancestors

                          @@ -492,7 +634,7 @@

                          Inherited members

                          Expand source code
                          class ArchiveMsgFolderRoot(WellknownFolder):
                          -    DISTINGUISHED_FOLDER_ID = 'archivemsgfolderroot'
                          +    DISTINGUISHED_FOLDER_ID = "archivemsgfolderroot"
                               supported_from = EXCHANGE_2010_SP1

                          Ancestors

                          @@ -566,7 +708,7 @@

                          Inherited members

                          Expand source code
                          class ArchiveRecoverableItemsDeletions(WellknownFolder):
                          -    DISTINGUISHED_FOLDER_ID = 'archiverecoverableitemsdeletions'
                          +    DISTINGUISHED_FOLDER_ID = "archiverecoverableitemsdeletions"
                               supported_from = EXCHANGE_2010_SP1

                          Ancestors

                          @@ -640,7 +782,7 @@

                          Inherited members

                          Expand source code
                          class ArchiveRecoverableItemsPurges(WellknownFolder):
                          -    DISTINGUISHED_FOLDER_ID = 'archiverecoverableitemspurges'
                          +    DISTINGUISHED_FOLDER_ID = "archiverecoverableitemspurges"
                               supported_from = EXCHANGE_2010_SP1

                          Ancestors

                          @@ -714,7 +856,7 @@

                          Inherited members

                          Expand source code
                          class ArchiveRecoverableItemsRoot(WellknownFolder):
                          -    DISTINGUISHED_FOLDER_ID = 'archiverecoverableitemsroot'
                          +    DISTINGUISHED_FOLDER_ID = "archiverecoverableitemsroot"
                               supported_from = EXCHANGE_2010_SP1

                          Ancestors

                          @@ -788,7 +930,7 @@

                          Inherited members

                          Expand source code
                          class ArchiveRecoverableItemsVersions(WellknownFolder):
                          -    DISTINGUISHED_FOLDER_ID = 'archiverecoverableitemsversions'
                          +    DISTINGUISHED_FOLDER_ID = "archiverecoverableitemsversions"
                               supported_from = EXCHANGE_2010_SP1

                          Ancestors

                          @@ -864,7 +1006,7 @@

                          Inherited members

                          class ArchiveRoot(RootOfHierarchy):
                               """The root of the archive folders hierarchy. Not available on all mailboxes."""
                           
                          -    DISTINGUISHED_FOLDER_ID = 'archiveroot'
                          +    DISTINGUISHED_FOLDER_ID = "archiveroot"
                               supported_from = EXCHANGE_2010_SP1
                               WELLKNOWN_FOLDERS = WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT
                          @@ -945,7 +1087,7 @@

                          Inherited members

                          class Audits(NonDeletableFolderMixin, Folder):
                               LOCALIZED_NAMES = {
                          -        None: ('Audits',),
                          +        None: ("Audits",),
                               }
                               get_folder_allowed = False
                          @@ -1022,7 +1164,7 @@

                          Inherited members

                          class BaseFolder(RegisterMixIn, SearchableMixIn, metaclass=EWSMeta):
                               """Base class for all classes that implement a folder."""
                           
                          -    ELEMENT_NAME = 'Folder'
                          +    ELEMENT_NAME = "Folder"
                               NAMESPACE = TNS
                               # See https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/distinguishedfolderid
                               DISTINGUISHED_FOLDER_ID = None
                          @@ -1041,24 +1183,23 @@ 

                          Inherited members

                          ITEM_MODEL_MAP = {cls.response_tag(): cls for cls in ITEM_CLASSES} ID_ELEMENT_CLS = FolderId - _id = IdElementField(field_uri='folder:FolderId', value_cls=ID_ELEMENT_CLS) - parent_folder_id = EWSElementField(field_uri='folder:ParentFolderId', value_cls=ParentFolderId, - is_read_only=True) - folder_class = CharField(field_uri='folder:FolderClass', is_required_after_save=True) - name = CharField(field_uri='folder:DisplayName') - total_count = IntegerField(field_uri='folder:TotalCount', is_read_only=True) - child_folder_count = IntegerField(field_uri='folder:ChildFolderCount', is_read_only=True) - unread_count = IntegerField(field_uri='folder:UnreadCount', is_read_only=True) + _id = IdElementField(field_uri="folder:FolderId", value_cls=ID_ELEMENT_CLS) + parent_folder_id = EWSElementField(field_uri="folder:ParentFolderId", value_cls=ParentFolderId, is_read_only=True) + folder_class = CharField(field_uri="folder:FolderClass", is_required_after_save=True) + name = CharField(field_uri="folder:DisplayName") + total_count = IntegerField(field_uri="folder:TotalCount", is_read_only=True) + child_folder_count = IntegerField(field_uri="folder:ChildFolderCount", is_read_only=True) + unread_count = IntegerField(field_uri="folder:UnreadCount", is_read_only=True) - __slots__ = 'is_distinguished', 'item_sync_state', 'folder_sync_state' + __slots__ = "is_distinguished", "item_sync_state", "folder_sync_state" # Used to register extended properties - INSERT_AFTER_FIELD = 'child_folder_count' + INSERT_AFTER_FIELD = "child_folder_count" def __init__(self, **kwargs): - self.is_distinguished = kwargs.pop('is_distinguished', False) - self.item_sync_state = kwargs.pop('item_sync_state', None) - self.folder_sync_state = kwargs.pop('folder_sync_state', None) + self.is_distinguished = kwargs.pop("is_distinguished", False) + self.item_sync_state = kwargs.pop("item_sync_state", None) + self.folder_sync_state = kwargs.pop("folder_sync_state", None) super().__init__(**kwargs) @property @@ -1103,7 +1244,7 @@

                          Inherited members

                          @property def absolute(self): - return ''.join(f'/{p.name}' for p in self.parts) + return "".join(f"/{p.name}" for p in self.parts) def _walk(self): for c in self.children: @@ -1114,23 +1255,23 @@

                          Inherited members

                          return FolderCollection(account=self.account, folders=self._walk()) def _glob(self, pattern): - split_pattern = pattern.rsplit('/', 1) + split_pattern = pattern.rsplit("/", 1) head, tail = (split_pattern[0], None) if len(split_pattern) == 1 else split_pattern - if head == '': + if head == "": # We got an absolute path. Restart globbing at root - yield from self.root.glob(tail or '*') - elif head == '..': + yield from self.root.glob(tail or "*") + elif head == "..": # Relative path with reference to parent. Restart globbing at parent if not self.parent: - raise ValueError('Already at top') - yield from self.parent.glob(tail or '*') - elif head == '**': + raise ValueError("Already at top") + yield from self.parent.glob(tail or "*") + elif head == "**": # Match anything here or in any subfolder at arbitrary depth for c in self.walk(): # fnmatch() may be case-sensitive depending on operating system: # force a case-insensitive match since case appears not to # matter for folders in Exchange - if fnmatch(c.name.lower(), (tail or '*').lower()): + if fnmatch(c.name.lower(), (tail or "*").lower()): yield c else: # Regular pattern @@ -1157,22 +1298,22 @@

                          Inherited members

                          ├── exchangelib issues └── Mom """ - tree = f'{self.name}\n' + tree = f"{self.name}\n" children = list(self.children) - for i, c in enumerate(sorted(children, key=attrgetter('name')), start=1): - nodes = c.tree().split('\n') + for i, c in enumerate(sorted(children, key=attrgetter("name")), start=1): + nodes = c.tree().split("\n") for j, node in enumerate(nodes, start=1): if i != len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += f'├── {node}\n' + tree += f"├── {node}\n" elif i != len(children) and j > 1: # Not the last child, and not name of child - tree += f'│ {node}\n' + tree += f"│ {node}\n" elif i == len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += f'└── {node}\n' + tree += f"└── {node}\n" else: # Last child, and not name of child - tree += f' {node}\n' + tree += f" {node}\n" return tree.strip() @classmethod @@ -1200,11 +1341,29 @@

                          Inherited members

                          :param container_class: :return: """ - from .known_folders import Messages, Tasks, Calendar, ConversationSettings, Contacts, GALContacts, Reminders, \ - RecipientCache, RSSFeeds + from .known_folders import ( + Calendar, + Contacts, + ConversationSettings, + GALContacts, + Messages, + RecipientCache, + Reminders, + RSSFeeds, + Tasks, + ) + for folder_cls in ( - Messages, Tasks, Calendar, ConversationSettings, Contacts, GALContacts, Reminders, RecipientCache, - RSSFeeds): + Messages, + Tasks, + Calendar, + ConversationSettings, + Contacts, + GALContacts, + Reminders, + RecipientCache, + RSSFeeds, + ): if folder_cls.CONTAINER_CLASS == container_class: return folder_cls raise KeyError() @@ -1214,7 +1373,7 @@

                          Inherited members

                          try: return cls.ITEM_MODEL_MAP[tag] except KeyError: - raise ValueError(f'Item type {tag} was unexpected in a {cls.__name__} folder') + raise ValueError(f"Item type {tag} was unexpected in a {cls.__name__} folder") @classmethod def allowed_item_fields(cls, version): @@ -1240,9 +1399,9 @@

                          Inherited members

                          elif isinstance(field_path, Field): field_path = FieldPath(field=field_path) fields[i] = field_path - if field_path.field.name == 'start': + if field_path.field.name == "start": has_start = True - elif field_path.field.name == 'end': + elif field_path.field.name == "end": has_end = True # For CalendarItem items, we want to inject internal timezone fields. See also CalendarItem.clean() @@ -1291,6 +1450,7 @@

                          Inherited members

                          def save(self, update_fields=None): from ..services import CreateFolder, UpdateFolder + if self.id is None: # New folder if update_fields: @@ -1309,7 +1469,7 @@

                          Inherited members

                          # These cannot be changed continue if (f.is_required or f.is_required_after_save) and ( - getattr(self, f.name) is None or (f.is_list and not getattr(self, f.name)) + getattr(self, f.name) is None or (f.is_list and not getattr(self, f.name)) ): # These are required and cannot be deleted continue @@ -1317,7 +1477,7 @@

                          Inherited members

                          res = UpdateFolder(account=self.account).get(folders=[(self, update_fields)]) folder_id, changekey = res.id, res.changekey if self.id != folder_id: - raise ValueError('ID mismatch') + raise ValueError("ID mismatch") # Don't check changekey value. It may not change on no-op updates self.changekey = changekey self.root.update_folder(self) # Update the folder in the cache @@ -1325,10 +1485,11 @@

                          Inherited members

                          def move(self, to_folder): from ..services import MoveFolder + res = MoveFolder(account=self.account).get(folders=[self], to_folder=to_folder) folder_id, changekey = res.id, res.changekey if self.id != folder_id: - raise ValueError('ID mismatch') + raise ValueError("ID mismatch") # Don't check changekey value. It may not change on no-op moves self.changekey = changekey self.parent_folder_id = ParentFolderId(id=to_folder.id, changekey=to_folder.changekey) @@ -1336,12 +1497,14 @@

                          Inherited members

                          def delete(self, delete_type=HARD_DELETE): from ..services import DeleteFolder + DeleteFolder(account=self.account).get(folders=[self], delete_type=delete_type) self.root.remove_folder(self) # Remove the updated folder from the cache self._id = None def empty(self, delete_type=HARD_DELETE, delete_sub_folders=False): from ..services import EmptyFolder + EmptyFolder(account=self.account).get( folders=[self], delete_type=delete_type, delete_sub_folders=delete_sub_folders ) @@ -1354,11 +1517,11 @@

                          Inherited members

                          # distinguished folders from being deleted. Use with caution! _seen = _seen or set() if self.id in _seen: - raise RecursionError(f'We already tried to wipe {self}') + raise RecursionError(f"We already tried to wipe {self}") if _level > 16: - raise RecursionError(f'Max recursion level reached: {_level}') + raise RecursionError(f"Max recursion level reached: {_level}") _seen.add(self.id) - log.warning('Wiping %s', self) + log.warning("Wiping %s", self) has_distinguished_subfolders = any(f.is_distinguished for f in self.children) try: if has_distinguished_subfolders: @@ -1371,26 +1534,26 @@

                          Inherited members

                          raise # We already tried this self.empty(delete_sub_folders=False) except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): - log.warning('Not allowed to empty %s. Trying to delete items instead', self) + log.warning("Not allowed to empty %s. Trying to delete items instead", self) kwargs = {} if page_size is not None: - kwargs['page_size'] = page_size + kwargs["page_size"] = page_size if chunk_size is not None: - kwargs['chunk_size'] = chunk_size + kwargs["chunk_size"] = chunk_size try: self.all().delete(**kwargs) except (ErrorAccessDenied, ErrorCannotDeleteObject, ErrorItemNotFound): - log.warning('Not allowed to delete items in %s', self) + log.warning("Not allowed to delete items in %s", self) _level += 1 for f in self.children: f.wipe(page_size=page_size, chunk_size=chunk_size, _seen=_seen, _level=_level) # Remove non-distinguished children that are empty and have no subfolders if f.is_deletable and not f.children: - log.warning('Deleting folder %s', f) + log.warning("Deleting folder %s", f) try: f.delete() except ErrorDeleteDistinguishedFolder: - log.warning('Tried to delete a distinguished folder (%s)', f) + log.warning("Tried to delete a distinguished folder (%s)", f) def test_access(self): """Does a simple FindItem to test (read) access to the folder. Maybe the account doesn't exist, maybe the @@ -1402,12 +1565,12 @@

                          Inherited members

                          @classmethod def _kwargs_from_elem(cls, elem, account): # Check for 'DisplayName' element before collecting kwargs because because that clears the elements - has_name_elem = elem.find(cls.get_field_by_fieldname('name').response_tag()) is not None + has_name_elem = elem.find(cls.get_field_by_fieldname("name").response_tag()) is not None kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS} - if has_name_elem and not kwargs['name']: + if has_name_elem and not kwargs["name"]: # When we request the 'DisplayName' property, some folders may still be returned with an empty value. # Assign a default name to these folders. - kwargs['name'] = cls.DISTINGUISHED_FOLDER_ID + kwargs["name"] = cls.DISTINGUISHED_FOLDER_ID return kwargs def to_id(self): @@ -1416,22 +1579,21 @@

                          Inherited members

                          # the folder content since we fetched the changekey. if self.account: return DistinguishedFolderId( - id=self.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=self.account.primary_smtp_address) + id=self.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address) ) return DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID) if self.id: return FolderId(id=self.id, changekey=self.changekey) - raise ValueError('Must be a distinguished folder or have an ID') + raise ValueError("Must be a distinguished folder or have an ID") @classmethod def resolve(cls, account, folder): # Resolve a single folder folders = list(FolderCollection(account=account, folders=[folder]).resolve()) if not folders: - raise ErrorFolderNotFound(f'Could not find folder {folder!r}') + raise ErrorFolderNotFound(f"Could not find folder {folder!r}") if len(folders) != 1: - raise ValueError(f'Expected result length 1, but got {folders}') + raise ValueError(f"Expected result length 1, but got {folders}") f = folders[0] if isinstance(f, Exception): raise f @@ -1443,7 +1605,7 @@

                          Inherited members

                          def refresh(self): fresh_folder = self.resolve(account=self.account, folder=self) if self.id != fresh_folder.id: - raise ValueError('ID mismatch') + raise ValueError("ID mismatch") # Apparently, the changekey may get updated for f in self.FIELDS: setattr(self, f.name, getattr(fresh_folder, f.name)) @@ -1453,6 +1615,7 @@

                          Inherited members

                          def get_user_configuration(self, name, properties=None): from ..services import GetUserConfiguration from ..services.get_user_configuration import ALL + if properties is None: properties = ALL return GetUserConfiguration(account=self.account).get( @@ -1463,6 +1626,7 @@

                          Inherited members

                          @require_id def create_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None): from ..services import CreateUserConfiguration + user_configuration = UserConfiguration( user_configuration_name=UserConfigurationName(name=name, folder=self), dictionary=dictionary, @@ -1474,6 +1638,7 @@

                          Inherited members

                          @require_id def update_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None): from ..services import UpdateUserConfiguration + user_configuration = UserConfiguration( user_configuration_name=UserConfigurationName(name=name, folder=self), dictionary=dictionary, @@ -1485,6 +1650,7 @@

                          Inherited members

                          @require_id def delete_user_configuration(self, name): from ..services import DeleteUserConfiguration + return DeleteUserConfiguration(account=self.account).get( user_configuration_name=UserConfigurationNameMNS(name=name, folder=self) ) @@ -1500,10 +1666,13 @@

                          Inherited members

                          :return: The subscription ID and a watermark """ from ..services import SubscribeToPull + if event_types is None: event_types = SubscribeToPull.EVENT_TYPES return FolderCollection(account=self.account, folders=[self]).subscribe_to_pull( - event_types=event_types, watermark=watermark, timeout=timeout, + event_types=event_types, + watermark=watermark, + timeout=timeout, ) @require_id @@ -1517,10 +1686,14 @@

                          Inherited members

                          :return: The subscription ID and a watermark """ from ..services import SubscribeToPush + if event_types is None: event_types = SubscribeToPush.EVENT_TYPES return FolderCollection(account=self.account, folders=[self]).subscribe_to_push( - event_types=event_types, watermark=watermark, status_frequency=status_frequency, callback_url=callback_url, + event_types=event_types, + watermark=watermark, + status_frequency=status_frequency, + callback_url=callback_url, ) @require_id @@ -1531,6 +1704,7 @@

                          Inherited members

                          :return: The subscription ID """ from ..services import SubscribeToStreaming + if event_types is None: event_types = SubscribeToStreaming.EVENT_TYPES return FolderCollection(account=self.account, folders=[self]).subscribe_to_streaming(event_types=event_types) @@ -1557,6 +1731,7 @@

                          Inherited members

                          sync methods. """ from ..services import Unsubscribe + return Unsubscribe(account=self.account).get(subscription_id=subscription_id) def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None): @@ -1616,6 +1791,7 @@

                          Inherited members

                          sync methods. """ from ..services import GetEvents + svc = GetEvents(account=self.account) while True: notification = svc.get(subscription_id=subscription_id, watermark=watermark) @@ -1637,12 +1813,15 @@

                          Inherited members

                          sync methods. """ from ..services import GetStreamingEvents + svc = GetStreamingEvents(account=self.account) - subscription_ids = subscription_id_or_ids if is_iterable(subscription_id_or_ids, generators_allowed=True) \ + subscription_ids = ( + subscription_id_or_ids + if is_iterable(subscription_id_or_ids, generators_allowed=True) else [subscription_id_or_ids] + ) for i, notification in enumerate( - svc.call(subscription_ids=subscription_ids, connection_timeout=connection_timeout), - start=1 + svc.call(subscription_ids=subscription_ids, connection_timeout=connection_timeout), start=1 ): yield notification if max_notifications_returned and i >= max_notifications_returned: @@ -1659,10 +1838,10 @@

                          Inherited members

                          :param other: :return: """ - if other == '..': - raise ValueError('Cannot get parent without a folder cache') + if other == "..": + raise ValueError("Cannot get parent without a folder cache") - if other == '.': + if other == ".": return self # Assume an exact match on the folder name in a shallow search will only return at most one folder @@ -1673,11 +1852,11 @@

                          Inherited members

                          def __truediv__(self, other): """Support the some_folder / 'child_folder' / 'child_of_child_folder' navigation syntax.""" - if other == '..': + if other == "..": if not self.parent: - raise ValueError('Already at top') + raise ValueError("Already at top") return self.parent - if other == '.': + if other == ".": return self for c in self.children: if c.name == other: @@ -1685,12 +1864,21 @@

                          Inherited members

                          raise ErrorFolderNotFound(f"No subfolder with name {other!r}") def __repr__(self): - return self.__class__.__name__ + \ - repr((self.root, self.name, self.total_count, self.unread_count, self.child_folder_count, - self.folder_class, self.id, self.changekey)) + return self.__class__.__name__ + repr( + ( + self.root, + self.name, + self.total_count, + self.unread_count, + self.child_folder_count, + self.folder_class, + self.id, + self.changekey, + ) + ) def __str__(self): - return f'{self.__class__.__name__} ({self.name})'
                          + return f"{self.__class__.__name__} ({self.name})"

                          Ancestors

                            @@ -1803,11 +1991,29 @@

                            Static methods

                            :param container_class: :return: """ - from .known_folders import Messages, Tasks, Calendar, ConversationSettings, Contacts, GALContacts, Reminders, \ - RecipientCache, RSSFeeds + from .known_folders import ( + Calendar, + Contacts, + ConversationSettings, + GALContacts, + Messages, + RecipientCache, + Reminders, + RSSFeeds, + Tasks, + ) + for folder_cls in ( - Messages, Tasks, Calendar, ConversationSettings, Contacts, GALContacts, Reminders, RecipientCache, - RSSFeeds): + Messages, + Tasks, + Calendar, + ConversationSettings, + Contacts, + GALContacts, + Reminders, + RecipientCache, + RSSFeeds, + ): if folder_cls.CONTAINER_CLASS == container_class: return folder_cls raise KeyError()
                          @@ -1846,7 +2052,7 @@

                          Static methods

                          try: return cls.ITEM_MODEL_MAP[tag] except KeyError: - raise ValueError(f'Item type {tag} was unexpected in a {cls.__name__} folder')
                          + raise ValueError(f"Item type {tag} was unexpected in a {cls.__name__} folder")
                          @@ -1879,9 +2085,9 @@

                          Static methods

                          # Resolve a single folder folders = list(FolderCollection(account=account, folders=[folder]).resolve()) if not folders: - raise ErrorFolderNotFound(f'Could not find folder {folder!r}') + raise ErrorFolderNotFound(f"Could not find folder {folder!r}") if len(folders) != 1: - raise ValueError(f'Expected result length 1, but got {folders}') + raise ValueError(f"Expected result length 1, but got {folders}") f = folders[0] if isinstance(f, Exception): raise f @@ -1919,7 +2125,7 @@

                          Instance variables

                          @property
                           def absolute(self):
                          -    return ''.join(f'/{p.name}' for p in self.parts)
                          + return "".join(f"/{p.name}" for p in self.parts)
                          var account
                          @@ -2096,6 +2302,7 @@

                          Methods

                          @require_id
                           def create_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None):
                               from ..services import CreateUserConfiguration
                          +
                               user_configuration = UserConfiguration(
                                   user_configuration_name=UserConfigurationName(name=name, folder=self),
                                   dictionary=dictionary,
                          @@ -2116,6 +2323,7 @@ 

                          Methods

                          def delete(self, delete_type=HARD_DELETE):
                               from ..services import DeleteFolder
                          +
                               DeleteFolder(account=self.account).get(folders=[self], delete_type=delete_type)
                               self.root.remove_folder(self)  # Remove the updated folder from the cache
                               self._id = None
                          @@ -2133,6 +2341,7 @@

                          Methods

                          @require_id
                           def delete_user_configuration(self, name):
                               from ..services import DeleteUserConfiguration
                          +
                               return DeleteUserConfiguration(account=self.account).get(
                                   user_configuration_name=UserConfigurationNameMNS(name=name, folder=self)
                               )
                          @@ -2149,6 +2358,7 @@

                          Methods

                          def empty(self, delete_type=HARD_DELETE, delete_sub_folders=False):
                               from ..services import EmptyFolder
                          +
                               EmptyFolder(account=self.account).get(
                                   folders=[self], delete_type=delete_type, delete_sub_folders=delete_sub_folders
                               )
                          @@ -2182,6 +2392,7 @@ 

                          Methods

                          sync methods. """ from ..services import GetEvents + svc = GetEvents(account=self.account) while True: notification = svc.get(subscription_id=subscription_id, watermark=watermark) @@ -2221,12 +2432,15 @@

                          Methods

                          sync methods. """ from ..services import GetStreamingEvents + svc = GetStreamingEvents(account=self.account) - subscription_ids = subscription_id_or_ids if is_iterable(subscription_id_or_ids, generators_allowed=True) \ + subscription_ids = ( + subscription_id_or_ids + if is_iterable(subscription_id_or_ids, generators_allowed=True) else [subscription_id_or_ids] + ) for i, notification in enumerate( - svc.call(subscription_ids=subscription_ids, connection_timeout=connection_timeout), - start=1 + svc.call(subscription_ids=subscription_ids, connection_timeout=connection_timeout), start=1 ): yield notification if max_notifications_returned and i >= max_notifications_returned: @@ -2247,6 +2461,7 @@

                          Methods

                          def get_user_configuration(self, name, properties=None): from ..services import GetUserConfiguration from ..services.get_user_configuration import ALL + if properties is None: properties = ALL return GetUserConfiguration(account=self.account).get( @@ -2279,10 +2494,11 @@

                          Methods

                          def move(self, to_folder):
                               from ..services import MoveFolder
                          +
                               res = MoveFolder(account=self.account).get(folders=[self], to_folder=to_folder)
                               folder_id, changekey = res.id, res.changekey
                               if self.id != folder_id:
                          -        raise ValueError('ID mismatch')
                          +        raise ValueError("ID mismatch")
                               # Don't check changekey value. It may not change on no-op moves
                               self.changekey = changekey
                               self.parent_folder_id = ParentFolderId(id=to_folder.id, changekey=to_folder.changekey)
                          @@ -2311,9 +2527,9 @@ 

                          Methods

                          elif isinstance(field_path, Field): field_path = FieldPath(field=field_path) fields[i] = field_path - if field_path.field.name == 'start': + if field_path.field.name == "start": has_start = True - elif field_path.field.name == 'end': + elif field_path.field.name == "end": has_end = True # For CalendarItem items, we want to inject internal timezone fields. See also CalendarItem.clean() @@ -2371,7 +2587,7 @@

                          Methods

                          def refresh(self): fresh_folder = self.resolve(account=self.account, folder=self) if self.id != fresh_folder.id: - raise ValueError('ID mismatch') + raise ValueError("ID mismatch") # Apparently, the changekey may get updated for f in self.FIELDS: setattr(self, f.name, getattr(fresh_folder, f.name)) @@ -2389,6 +2605,7 @@

                          Methods

                          def save(self, update_fields=None):
                               from ..services import CreateFolder, UpdateFolder
                          +
                               if self.id is None:
                                   # New folder
                                   if update_fields:
                          @@ -2407,7 +2624,7 @@ 

                          Methods

                          # These cannot be changed continue if (f.is_required or f.is_required_after_save) and ( - getattr(self, f.name) is None or (f.is_list and not getattr(self, f.name)) + getattr(self, f.name) is None or (f.is_list and not getattr(self, f.name)) ): # These are required and cannot be deleted continue @@ -2415,7 +2632,7 @@

                          Methods

                          res = UpdateFolder(account=self.account).get(folders=[(self, update_fields)]) folder_id, changekey = res.id, res.changekey if self.id != folder_id: - raise ValueError('ID mismatch') + raise ValueError("ID mismatch") # Don't check changekey value. It may not change on no-op updates self.changekey = changekey self.root.update_folder(self) # Update the folder in the cache @@ -2461,10 +2678,13 @@

                          Methods

                          :return: The subscription ID and a watermark """ from ..services import SubscribeToPull + if event_types is None: event_types = SubscribeToPull.EVENT_TYPES return FolderCollection(account=self.account, folders=[self]).subscribe_to_pull( - event_types=event_types, watermark=watermark, timeout=timeout, + event_types=event_types, + watermark=watermark, + timeout=timeout, )
                          @@ -2493,10 +2713,14 @@

                          Methods

                          :return: The subscription ID and a watermark """ from ..services import SubscribeToPush + if event_types is None: event_types = SubscribeToPush.EVENT_TYPES return FolderCollection(account=self.account, folders=[self]).subscribe_to_push( - event_types=event_types, watermark=watermark, status_frequency=status_frequency, callback_url=callback_url, + event_types=event_types, + watermark=watermark, + status_frequency=status_frequency, + callback_url=callback_url, )
                          @@ -2519,6 +2743,7 @@

                          Methods

                          :return: The subscription ID """ from ..services import SubscribeToStreaming + if event_types is None: event_types = SubscribeToStreaming.EVENT_TYPES return FolderCollection(account=self.account, folders=[self]).subscribe_to_streaming(event_types=event_types)
                          @@ -2636,13 +2861,12 @@

                          Methods

                          # the folder content since we fetched the changekey. if self.account: return DistinguishedFolderId( - id=self.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=self.account.primary_smtp_address) + id=self.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address) ) return DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID) if self.id: return FolderId(id=self.id, changekey=self.changekey) - raise ValueError('Must be a distinguished folder or have an ID')
                          + raise ValueError("Must be a distinguished folder or have an ID")
                          @@ -2673,22 +2897,22 @@

                          Methods

                          ├── exchangelib issues └── Mom """ - tree = f'{self.name}\n' + tree = f"{self.name}\n" children = list(self.children) - for i, c in enumerate(sorted(children, key=attrgetter('name')), start=1): - nodes = c.tree().split('\n') + for i, c in enumerate(sorted(children, key=attrgetter("name")), start=1): + nodes = c.tree().split("\n") for j, node in enumerate(nodes, start=1): if i != len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += f'├── {node}\n' + tree += f"├── {node}\n" elif i != len(children) and j > 1: # Not the last child, and not name of child - tree += f'│ {node}\n' + tree += f"│ {node}\n" elif i == len(children) and j == 1: # Not the last child, but the first node, which is the name of the child - tree += f'└── {node}\n' + tree += f"└── {node}\n" else: # Last child, and not name of child - tree += f' {node}\n' + tree += f" {node}\n" return tree.strip()
                          @@ -2715,6 +2939,7 @@

                          Methods

                          sync methods. """ from ..services import Unsubscribe + return Unsubscribe(account=self.account).get(subscription_id=subscription_id) @@ -2730,6 +2955,7 @@

                          Methods

                          @require_id
                           def update_user_configuration(self, name, dictionary=None, xml_data=None, binary_data=None):
                               from ..services import UpdateUserConfiguration
                          +
                               user_configuration = UserConfiguration(
                                   user_configuration_name=UserConfigurationName(name=name, folder=self),
                                   dictionary=dictionary,
                          @@ -2779,11 +3005,11 @@ 

                          Methods

                          # distinguished folders from being deleted. Use with caution! _seen = _seen or set() if self.id in _seen: - raise RecursionError(f'We already tried to wipe {self}') + raise RecursionError(f"We already tried to wipe {self}") if _level > 16: - raise RecursionError(f'Max recursion level reached: {_level}') + raise RecursionError(f"Max recursion level reached: {_level}") _seen.add(self.id) - log.warning('Wiping %s', self) + log.warning("Wiping %s", self) has_distinguished_subfolders = any(f.is_distinguished for f in self.children) try: if has_distinguished_subfolders: @@ -2796,26 +3022,26 @@

                          Methods

                          raise # We already tried this self.empty(delete_sub_folders=False) except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): - log.warning('Not allowed to empty %s. Trying to delete items instead', self) + log.warning("Not allowed to empty %s. Trying to delete items instead", self) kwargs = {} if page_size is not None: - kwargs['page_size'] = page_size + kwargs["page_size"] = page_size if chunk_size is not None: - kwargs['chunk_size'] = chunk_size + kwargs["chunk_size"] = chunk_size try: self.all().delete(**kwargs) except (ErrorAccessDenied, ErrorCannotDeleteObject, ErrorItemNotFound): - log.warning('Not allowed to delete items in %s', self) + log.warning("Not allowed to delete items in %s", self) _level += 1 for f in self.children: f.wipe(page_size=page_size, chunk_size=chunk_size, _seen=_seen, _level=_level) # Remove non-distinguished children that are empty and have no subfolders if f.is_deletable and not f.children: - log.warning('Deleting folder %s', f) + log.warning("Deleting folder %s", f) try: f.delete() except ErrorDeleteDistinguishedFolder: - log.warning('Tried to delete a distinguished folder (%s)', f)
                          + log.warning("Tried to delete a distinguished folder (%s)", f) @@ -2856,20 +3082,20 @@

                          Inherited members

                          class Calendar(Folder):
                               """An interface for the Exchange calendar."""
                           
                          -    DISTINGUISHED_FOLDER_ID = 'calendar'
                          -    CONTAINER_CLASS = 'IPF.Appointment'
                          +    DISTINGUISHED_FOLDER_ID = "calendar"
                          +    CONTAINER_CLASS = "IPF.Appointment"
                               supported_item_models = (CalendarItem,)
                           
                               LOCALIZED_NAMES = {
                          -        'da_DK': ('Kalender',),
                          -        'de_DE': ('Kalender',),
                          -        'en_US': ('Calendar',),
                          -        'es_ES': ('Calendario',),
                          -        'fr_CA': ('Calendrier',),
                          -        'nl_NL': ('Agenda',),
                          -        'ru_RU': ('Календарь',),
                          -        'sv_SE': ('Kalender',),
                          -        'zh_CN': ('日历',),
                          +        "da_DK": ("Kalender",),
                          +        "de_DE": ("Kalender",),
                          +        "en_US": ("Calendar",),
                          +        "es_ES": ("Calendario",),
                          +        "fr_CA": ("Calendrier",),
                          +        "nl_NL": ("Agenda",),
                          +        "ru_RU": ("Календарь",),
                          +        "sv_SE": ("Kalender",),
                          +        "zh_CN": ("日历",),
                               }
                           
                               def view(self, *args, **kwargs):
                          @@ -2970,7 +3196,7 @@ 

                          Inherited members

                          class CalendarLogging(NonDeletableFolderMixin, Folder):
                               LOCALIZED_NAMES = {
                          -        None: ('Calendar Logging',),
                          +        None: ("Calendar Logging",),
                               }

                          Ancestors

                          @@ -3042,7 +3268,7 @@

                          Inherited members

                          class CommonViews(NonDeletableFolderMixin, Folder):
                               DEFAULT_ITEM_TRAVERSAL_DEPTH = ASSOCIATED
                               LOCALIZED_NAMES = {
                          -        None: ('Common Views',),
                          +        None: ("Common Views",),
                               }

                          Ancestors

                          @@ -3117,9 +3343,9 @@

                          Inherited members

                          class Companies(NonDeletableFolderMixin, Contacts):
                               DISTINGUISHED_FOLDER_ID = None
                          -    CONTAINTER_CLASS = 'IPF.Contact.Company'
                          +    CONTAINTER_CLASS = "IPF.Contact.Company"
                               LOCALIZED_NAMES = {
                          -        None: ('Companies',),
                          +        None: ("Companies",),
                               }

                          Ancestors

                          @@ -3198,7 +3424,7 @@

                          Inherited members

                          Expand source code
                          class Conflicts(WellknownFolder):
                          -    DISTINGUISHED_FOLDER_ID = 'conflicts'
                          +    DISTINGUISHED_FOLDER_ID = "conflicts"
                               supported_from = EXCHANGE_2013

                          Ancestors

                          @@ -3272,20 +3498,20 @@

                          Inherited members

                          Expand source code
                          class Contacts(Folder):
                          -    DISTINGUISHED_FOLDER_ID = 'contacts'
                          -    CONTAINER_CLASS = 'IPF.Contact'
                          +    DISTINGUISHED_FOLDER_ID = "contacts"
                          +    CONTAINER_CLASS = "IPF.Contact"
                               supported_item_models = (Contact, DistributionList)
                           
                               LOCALIZED_NAMES = {
                          -        'da_DK': ('Kontaktpersoner',),
                          -        'de_DE': ('Kontakte',),
                          -        'en_US': ('Contacts',),
                          -        'es_ES': ('Contactos',),
                          -        'fr_CA': ('Contacts',),
                          -        'nl_NL': ('Contactpersonen',),
                          -        'ru_RU': ('Контакты',),
                          -        'sv_SE': ('Kontakter',),
                          -        'zh_CN': ('联系人',),
                          +        "da_DK": ("Kontaktpersoner",),
                          +        "de_DE": ("Kontakte",),
                          +        "en_US": ("Contacts",),
                          +        "es_ES": ("Contactos",),
                          +        "fr_CA": ("Contacts",),
                          +        "nl_NL": ("Contactpersonen",),
                          +        "ru_RU": ("Контакты",),
                          +        "sv_SE": ("Kontakter",),
                          +        "zh_CN": ("联系人",),
                               }

                          Ancestors

                          @@ -3377,7 +3603,7 @@

                          Inherited members

                          Expand source code
                          class ConversationHistory(WellknownFolder):
                          -    DISTINGUISHED_FOLDER_ID = 'conversationhistory'
                          +    DISTINGUISHED_FOLDER_ID = "conversationhistory"
                               supported_from = EXCHANGE_2013

                          Ancestors

                          @@ -3451,9 +3677,9 @@

                          Inherited members

                          Expand source code
                          class ConversationSettings(NonDeletableFolderMixin, Folder):
                          -    CONTAINER_CLASS = 'IPF.Configuration'
                          +    CONTAINER_CLASS = "IPF.Configuration"
                               LOCALIZED_NAMES = {
                          -        'da_DK': ('Indstillinger for samtalehandlinger',),
                          +        "da_DK": ("Indstillinger for samtalehandlinger",),
                               }

                          Ancestors

                          @@ -3527,9 +3753,9 @@

                          Inherited members

                          Expand source code
                          class DefaultFoldersChangeHistory(NonDeletableFolderMixin, Folder):
                          -    CONTAINER_CLASS = 'IPM.DefaultFolderHistoryItem'
                          +    CONTAINER_CLASS = "IPM.DefaultFolderHistoryItem"
                               LOCALIZED_NAMES = {
                          -        None: ('DefaultFoldersChangeHistory',),
                          +        None: ("DefaultFoldersChangeHistory",),
                               }

                          Ancestors

                          @@ -3604,7 +3830,7 @@

                          Inherited members

                          class DeferredAction(NonDeletableFolderMixin, Folder):
                               LOCALIZED_NAMES = {
                          -        None: ('Deferred Action',),
                          +        None: ("Deferred Action",),
                               }

                          Ancestors

                          @@ -3674,20 +3900,20 @@

                          Inherited members

                          Expand source code
                          class DeletedItems(Folder):
                          -    DISTINGUISHED_FOLDER_ID = 'deleteditems'
                          -    CONTAINER_CLASS = 'IPF.Note'
                          +    DISTINGUISHED_FOLDER_ID = "deleteditems"
                          +    CONTAINER_CLASS = "IPF.Note"
                               supported_item_models = ITEM_CLASSES
                           
                               LOCALIZED_NAMES = {
                          -        'da_DK': ('Slettet post',),
                          -        'de_DE': ('Gelöschte Elemente',),
                          -        'en_US': ('Deleted Items',),
                          -        'es_ES': ('Elementos eliminados',),
                          -        'fr_CA': ('Éléments supprimés',),
                          -        'nl_NL': ('Verwijderde items',),
                          -        'ru_RU': ('Удаленные',),
                          -        'sv_SE': ('Borttaget',),
                          -        'zh_CN': ('已删除邮件',),
                          +        "da_DK": ("Slettet post",),
                          +        "de_DE": ("Gelöschte Elemente",),
                          +        "en_US": ("Deleted Items",),
                          +        "es_ES": ("Elementos eliminados",),
                          +        "fr_CA": ("Éléments supprimés",),
                          +        "nl_NL": ("Verwijderde items",),
                          +        "ru_RU": ("Удаленные",),
                          +        "sv_SE": ("Borttaget",),
                          +        "zh_CN": ("已删除邮件",),
                               }

                          Ancestors

                          @@ -3768,7 +3994,7 @@

                          Inherited members

                          Expand source code
                          class Directory(WellknownFolder):
                          -    DISTINGUISHED_FOLDER_ID = 'directory'
                          +    DISTINGUISHED_FOLDER_ID = "directory"
                               supported_from = EXCHANGE_2013_SP1

                          Ancestors

                          @@ -3844,12 +4070,13 @@

                          Inherited members

                          class DistinguishedFolderId(FolderId):
                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/distinguishedfolderid"""
                           
                          -    ELEMENT_NAME = 'DistinguishedFolderId'
                          +    ELEMENT_NAME = "DistinguishedFolderId"
                           
                               mailbox = MailboxField()
                           
                               def clean(self, version=None):
                                   from .folders import PublicFoldersRoot
                          +
                                   super().clean(version=version)
                                   if self.id == PublicFoldersRoot.DISTINGUISHED_FOLDER_ID:
                                       # Avoid "ErrorInvalidOperation: It is not valid to specify a mailbox with the public folder root" from EWS
                          @@ -3893,6 +4120,7 @@ 

                          Methods

                          def clean(self, version=None):
                               from .folders import PublicFoldersRoot
                          +
                               super().clean(version=version)
                               if self.id == PublicFoldersRoot.DISTINGUISHED_FOLDER_ID:
                                   # Avoid "ErrorInvalidOperation: It is not valid to specify a mailbox with the public folder root" from EWS
                          @@ -3923,18 +4151,18 @@ 

                          Inherited members

                          Expand source code
                          class Drafts(Messages):
                          -    DISTINGUISHED_FOLDER_ID = 'drafts'
                          +    DISTINGUISHED_FOLDER_ID = "drafts"
                           
                               LOCALIZED_NAMES = {
                          -        'da_DK': ('Kladder',),
                          -        'de_DE': ('Entwürfe',),
                          -        'en_US': ('Drafts',),
                          -        'es_ES': ('Borradores',),
                          -        'fr_CA': ('Brouillons',),
                          -        'nl_NL': ('Concepten',),
                          -        'ru_RU': ('Черновики',),
                          -        'sv_SE': ('Utkast',),
                          -        'zh_CN': ('草稿',),
                          +        "da_DK": ("Kladder",),
                          +        "de_DE": ("Entwürfe",),
                          +        "en_US": ("Drafts",),
                          +        "es_ES": ("Borradores",),
                          +        "fr_CA": ("Brouillons",),
                          +        "nl_NL": ("Concepten",),
                          +        "ru_RU": ("Черновики",),
                          +        "sv_SE": ("Utkast",),
                          +        "zh_CN": ("草稿",),
                               }

                          Ancestors

                          @@ -4009,7 +4237,7 @@

                          Inherited members

                          class ExchangeSyncData(NonDeletableFolderMixin, Folder):
                               LOCALIZED_NAMES = {
                          -        None: ('ExchangeSyncData',),
                          +        None: ("ExchangeSyncData",),
                               }

                          Ancestors

                          @@ -4079,8 +4307,8 @@

                          Inherited members

                          Expand source code
                          class Favorites(WellknownFolder):
                          -    CONTAINER_CLASS = 'IPF.Note'
                          -    DISTINGUISHED_FOLDER_ID = 'favorites'
                          +    CONTAINER_CLASS = "IPF.Note"
                          +    DISTINGUISHED_FOLDER_ID = "favorites"
                               supported_from = EXCHANGE_2013

                          Ancestors

                          @@ -4158,10 +4386,10 @@

                          Inherited members

                          Expand source code
                          class Files(NonDeletableFolderMixin, Folder):
                          -    CONTAINER_CLASS = 'IPF.Files'
                          +    CONTAINER_CLASS = "IPF.Files"
                           
                               LOCALIZED_NAMES = {
                          -        'da_DK': ('Filer',),
                          +        "da_DK": ("Filer",),
                               }

                          Ancestors

                          @@ -4237,24 +4465,25 @@

                          Inherited members

                          class Folder(BaseFolder):
                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/folder"""
                           
                          -    permission_set = PermissionSetField(field_uri='folder:PermissionSet', supported_from=EXCHANGE_2007_SP1)
                          -    effective_rights = EffectiveRightsField(field_uri='folder:EffectiveRights', is_read_only=True,
                          -                                            supported_from=EXCHANGE_2007_SP1)
                          +    permission_set = PermissionSetField(field_uri="folder:PermissionSet", supported_from=EXCHANGE_2007_SP1)
                          +    effective_rights = EffectiveRightsField(
                          +        field_uri="folder:EffectiveRights", is_read_only=True, supported_from=EXCHANGE_2007_SP1
                          +    )
                           
                          -    __slots__ = '_root',
                          +    __slots__ = ("_root",)
                           
                               def __init__(self, **kwargs):
                          -        self._root = kwargs.pop('root', None)  # This is a pointer to the root of the folder hierarchy
                          -        parent = kwargs.pop('parent', None)
                          +        self._root = kwargs.pop("root", None)  # This is a pointer to the root of the folder hierarchy
                          +        parent = kwargs.pop("parent", None)
                                   if parent:
                                       if self.root:
                                           if parent.root != self.root:
                                               raise ValueError("'parent.root' must match 'root'")
                                       else:
                                           self.root = parent.root
                          -            if 'parent_folder_id' in kwargs and parent.id != kwargs['parent_folder_id']:
                          +            if "parent_folder_id" in kwargs and parent.id != kwargs["parent_folder_id"]:
                                           raise ValueError("'parent_folder_id' must match 'parent' ID")
                          -            kwargs['parent_folder_id'] = ParentFolderId(id=parent.id, changekey=parent.changekey)
                          +            kwargs["parent_folder_id"] = ParentFolderId(id=parent.id, changekey=parent.changekey)
                                   super().__init__(**kwargs)
                           
                               @property
                          @@ -4274,13 +4503,13 @@ 

                          Inherited members

                          @classmethod def register(cls, *args, **kwargs): if cls is not Folder: - raise TypeError('For folders, custom fields must be registered on the Folder class') + raise TypeError("For folders, custom fields must be registered on the Folder class") return super().register(*args, **kwargs) @classmethod def deregister(cls, *args, **kwargs): if cls is not Folder: - raise TypeError('For folders, custom fields must be registered on the Folder class') + raise TypeError("For folders, custom fields must be registered on the Folder class") return super().deregister(*args, **kwargs) @classmethod @@ -4292,11 +4521,10 @@

                          Inherited members

                          """ try: return cls.resolve( - account=root.account, - folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + account=root.account, folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}') + raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") @property def parent(self): @@ -4313,15 +4541,16 @@

                          Inherited members

                          self.parent_folder_id = None else: if not isinstance(value, BaseFolder): - raise InvalidTypeError('value', value, BaseFolder) + raise InvalidTypeError("value", value, BaseFolder) self.root = value.root self.parent_folder_id = ParentFolderId(id=value.id, changekey=value.changekey) def clean(self, version=None): from .roots import RootOfHierarchy + super().clean(version=version) if self.root and not isinstance(self.root, RootOfHierarchy): - raise InvalidTypeError('root', self.root, RootOfHierarchy) + raise InvalidTypeError("root", self.root, RootOfHierarchy) @classmethod def from_xml_with_root(cls, elem, root): @@ -4347,20 +4576,20 @@

                          Inherited members

                          if folder.name: try: # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative - folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, - locale=root.account.locale) - log.debug('Folder class %s matches localized folder name %s', folder_cls, folder.name) + folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) + log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) except KeyError: pass if folder.folder_class and folder_cls == Folder: try: folder_cls = cls.folder_cls_from_container_class(container_class=folder.folder_class) - log.debug('Folder class %s matches container class %s (%s)', folder_cls, folder.folder_class, - folder.name) + log.debug( + "Folder class %s matches container class %s (%s)", folder_cls, folder.folder_class, folder.name + ) except KeyError: pass if folder_cls == Folder: - log.debug('Fallback to class Folder (folder_class %s, name %s)', folder.folder_class, folder.name) + log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS})

                          Ancestors

                          @@ -4451,20 +4680,20 @@

                          Static methods

                          if folder.name: try: # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative - folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, - locale=root.account.locale) - log.debug('Folder class %s matches localized folder name %s', folder_cls, folder.name) + folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) + log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) except KeyError: pass if folder.folder_class and folder_cls == Folder: try: folder_cls = cls.folder_cls_from_container_class(container_class=folder.folder_class) - log.debug('Folder class %s matches container class %s (%s)', folder_cls, folder.folder_class, - folder.name) + log.debug( + "Folder class %s matches container class %s (%s)", folder_cls, folder.folder_class, folder.name + ) except KeyError: pass if folder_cls == Folder: - log.debug('Fallback to class Folder (folder_class %s, name %s)', folder.folder_class, folder.name) + log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS})
                          @@ -4488,11 +4717,10 @@

                          Static methods

                          """ try: return cls.resolve( - account=root.account, - folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + account=root.account, folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}')
                          + raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}")
                          @@ -4520,9 +4748,10 @@

                          Methods

                          def clean(self, version=None):
                               from .roots import RootOfHierarchy
                          +
                               super().clean(version=version)
                               if self.root and not isinstance(self.root, RootOfHierarchy):
                          -        raise InvalidTypeError('root', self.root, RootOfHierarchy)
                          + raise InvalidTypeError("root", self.root, RootOfHierarchy) @@ -4581,7 +4810,7 @@

                          Inherited members

                          """A class that implements an API for searching folders.""" # These fields are required in a FindFolder or GetFolder call to properly identify folder types - REQUIRED_FOLDER_FIELDS = ('name', 'folder_class') + REQUIRED_FOLDER_FIELDS = ("name", "folder_class") def __init__(self, account, folders): """Implement a search API on a collection of folders. @@ -4712,8 +4941,18 @@

                          Inherited members

                          query_string = None return depth, restriction, query_string - def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order_fields=None, - calendar_view=None, page_size=None, max_items=None, offset=0): + def find_items( + self, + q, + shape=ID_ONLY, + depth=None, + additional_fields=None, + order_fields=None, + calendar_view=None, + page_size=None, + max_items=None, + offset=0, + ): """Private method to call the FindItem service. :param q: a Q instance containing any restrictions @@ -4731,21 +4970,21 @@

                          Inherited members

                          :return: a generator for the returned item IDs or items """ from ..services import FindItem + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return if q.is_never(): - log.debug('Query will never return results') + log.debug("Query will never return results") return depth, restriction, query_string = self._rinse_args( - q=q, depth=depth, additional_fields=additional_fields, - field_validator=self.validate_item_field + q=q, depth=depth, additional_fields=additional_fields, field_validator=self.validate_item_field ) if calendar_view is not None and not isinstance(calendar_view, CalendarView): - raise InvalidTypeError('calendar_view', calendar_view, CalendarView) + raise InvalidTypeError("calendar_view", calendar_view, CalendarView) log.debug( - 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', + "Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)", self.account, self.folders, shape, @@ -4768,14 +5007,23 @@

                          Inherited members

                          def _get_single_folder(self): if len(self.folders) > 1: - raise ValueError('Syncing folder hierarchy can only be done on a single folder') + raise ValueError("Syncing folder hierarchy can only be done on a single folder") if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return None return self.folders[0] - def find_people(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order_fields=None, - page_size=None, max_items=None, offset=0): + def find_people( + self, + q, + shape=ID_ONLY, + depth=None, + additional_fields=None, + order_fields=None, + page_size=None, + max_items=None, + offset=0, + ): """Private method to call the FindPeople service. :param q: a Q instance containing any restrictions @@ -4791,30 +5039,31 @@

                          Inherited members

                          :return: a generator for the returned personas """ from ..services import FindPeople + folder = self._get_single_folder() if q.is_never(): - log.debug('Query will never return results') + log.debug("Query will never return results") return depth, restriction, query_string = self._rinse_args( - q=q, depth=depth, additional_fields=additional_fields, - field_validator=Persona.validate_field + q=q, depth=depth, additional_fields=additional_fields, field_validator=Persona.validate_field ) yield from FindPeople(account=self.account, page_size=page_size).call( - folder=folder, - additional_fields=additional_fields, - restriction=restriction, - order_fields=order_fields, - shape=shape, - query_string=query_string, - depth=depth, - max_items=max_items, - offset=offset, + folder=folder, + additional_fields=additional_fields, + restriction=restriction, + order_fields=order_fields, + shape=shape, + query_string=query_string, + depth=depth, + max_items=max_items, + offset=offset, ) def get_folder_fields(self, target_cls, is_complex=None): return { - FieldPath(field=f) for f in target_cls.supported_fields(version=self.account.version) + FieldPath(field=f) + for f in target_cls.supported_fields(version=self.account.version) if is_complex is None or f.is_complex is is_complex } @@ -4823,16 +5072,17 @@

                          Inherited members

                          # both folder types in self.folders, raise an error so we don't risk losing some fields in the query. from .base import Folder from .roots import RootOfHierarchy + has_roots = False has_non_roots = False for f in self.folders: if isinstance(f, RootOfHierarchy): if has_non_roots: - raise ValueError(f'Cannot call GetFolder on a mix of folder types: {self.folders}') + raise ValueError(f"Cannot call GetFolder on a mix of folder types: {self.folders}") has_roots = True else: if has_roots: - raise ValueError(f'Cannot call GetFolder on a mix of folder types: {self.folders}') + raise ValueError(f"Cannot call GetFolder on a mix of folder types: {self.folders}") has_non_roots = True return RootOfHierarchy if has_roots else Folder @@ -4841,47 +5091,51 @@

                          Inherited members

                          if len(unique_depths) == 1: return unique_depths.pop() raise ValueError( - f'Folders in this collection do not have a common {traversal_attr} value. You need to define an explicit ' - f'traversal depth with QuerySet.depth() (values: {unique_depths})' + f"Folders in this collection do not have a common {traversal_attr} value. You need to define an explicit " + f"traversal depth with QuerySet.depth() (values: {unique_depths})" ) def _get_default_item_traversal_depth(self): # When searching folders, some folders require 'Shallow' and others 'Associated' traversal depth. - return self._get_default_traversal_depth('DEFAULT_ITEM_TRAVERSAL_DEPTH') + return self._get_default_traversal_depth("DEFAULT_ITEM_TRAVERSAL_DEPTH") def _get_default_folder_traversal_depth(self): # When searching folders, some folders require 'Shallow' and others 'Deep' traversal depth. - return self._get_default_traversal_depth('DEFAULT_FOLDER_TRAVERSAL_DEPTH') + return self._get_default_traversal_depth("DEFAULT_FOLDER_TRAVERSAL_DEPTH") def resolve(self): # Looks up the folders or folder IDs in the collection and returns full Folder instances with all fields set. from .base import BaseFolder + resolveable_folders = [] for f in self.folders: if isinstance(f, BaseFolder) and not f.get_folder_allowed: - log.debug('GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder', f) + log.debug("GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder", f) yield f else: resolveable_folders.append(f) # Fetch all properties for the remaining folders of folder IDs additional_fields = self.get_folder_fields(target_cls=self._get_target_cls(), is_complex=None) yield from self.__class__(account=self.account, folders=resolveable_folders).get_folders( - additional_fields=additional_fields + additional_fields=additional_fields ) @require_account - def find_folders(self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None, - offset=0): + def find_folders( + self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None, offset=0 + ): from ..services import FindFolder + # 'depth' controls whether to return direct children or recurse into sub-folders from .base import BaseFolder, Folder + if q is None: q = Q() if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return if q.is_never(): - log.debug('Query will never return results') + log.debug("Query will never return results") return if q.is_empty(): restriction = None @@ -4903,21 +5157,23 @@

                          Inherited members

                          ) yield from FindFolder(account=self.account, page_size=page_size).call( - folders=self.folders, - additional_fields=additional_fields, - restriction=restriction, - shape=shape, - depth=depth, - max_items=max_items, - offset=offset, + folders=self.folders, + additional_fields=additional_fields, + restriction=restriction, + shape=shape, + depth=depth, + max_items=max_items, + offset=offset, ) def get_folders(self, additional_fields=None): from ..services import GetFolder + # Expand folders with their full set of properties from .base import BaseFolder + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return if additional_fields is None: # Default to all complex properties @@ -4929,38 +5185,47 @@

                          Inherited members

                          ) yield from GetFolder(account=self.account).call( - folders=self.folders, - additional_fields=additional_fields, - shape=ID_ONLY, + folders=self.folders, + additional_fields=additional_fields, + shape=ID_ONLY, ) def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): from ..services import SubscribeToPull + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return None if event_types is None: event_types = SubscribeToPull.EVENT_TYPES return SubscribeToPull(account=self.account).get( - folders=self.folders, event_types=event_types, watermark=watermark, timeout=timeout, + folders=self.folders, + event_types=event_types, + watermark=watermark, + timeout=timeout, ) def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1): from ..services import SubscribeToPush + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return None if event_types is None: event_types = SubscribeToPush.EVENT_TYPES return SubscribeToPush(account=self.account).get( - folders=self.folders, event_types=event_types, watermark=watermark, status_frequency=status_frequency, + folders=self.folders, + event_types=event_types, + watermark=watermark, + status_frequency=status_frequency, url=callback_url, ) def subscribe_to_streaming(self, event_types=None): from ..services import SubscribeToStreaming + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return None if event_types is None: event_types = SubscribeToStreaming.EVENT_TYPES @@ -4985,10 +5250,12 @@

                          Inherited members

                          sync methods. """ from ..services import Unsubscribe + return Unsubscribe(account=self.account).get(subscription_id=subscription_id) def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None): from ..services import SyncFolderItems + folder = self._get_single_folder() if only_fields is None: # We didn't restrict list of field paths. Get all fields from the server, including extended properties. @@ -5020,6 +5287,7 @@

                          Inherited members

                          def sync_hierarchy(self, sync_state=None, only_fields=None): from ..services import SyncFolderHierarchy + folder = self._get_single_folder() if only_fields is None: # We didn't restrict list of field paths. Get all fields from the server, including extended properties. @@ -5186,18 +5454,21 @@

                          Examples

                          Expand source code
                          @require_account
                          -def find_folders(self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None,
                          -                 offset=0):
                          +def find_folders(
                          +    self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None, offset=0
                          +):
                               from ..services import FindFolder
                          +
                               # 'depth' controls whether to return direct children or recurse into sub-folders
                               from .base import BaseFolder, Folder
                          +
                               if q is None:
                                   q = Q()
                               if not self.folders:
                          -        log.debug('Folder list is empty')
                          +        log.debug("Folder list is empty")
                                   return
                               if q.is_never():
                          -        log.debug('Query will never return results')
                          +        log.debug("Query will never return results")
                                   return
                               if q.is_empty():
                                   restriction = None
                          @@ -5219,13 +5490,13 @@ 

                          Examples

                          ) yield from FindFolder(account=self.account, page_size=page_size).call( - folders=self.folders, - additional_fields=additional_fields, - restriction=restriction, - shape=shape, - depth=depth, - max_items=max_items, - offset=offset, + folders=self.folders, + additional_fields=additional_fields, + restriction=restriction, + shape=shape, + depth=depth, + max_items=max_items, + offset=offset, )
                          @@ -5250,8 +5521,18 @@

                          Examples

                          Expand source code -
                          def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order_fields=None,
                          -               calendar_view=None, page_size=None, max_items=None, offset=0):
                          +
                          def find_items(
                          +    self,
                          +    q,
                          +    shape=ID_ONLY,
                          +    depth=None,
                          +    additional_fields=None,
                          +    order_fields=None,
                          +    calendar_view=None,
                          +    page_size=None,
                          +    max_items=None,
                          +    offset=0,
                          +):
                               """Private method to call the FindItem service.
                           
                               :param q: a Q instance containing any restrictions
                          @@ -5269,21 +5550,21 @@ 

                          Examples

                          :return: a generator for the returned item IDs or items """ from ..services import FindItem + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return if q.is_never(): - log.debug('Query will never return results') + log.debug("Query will never return results") return depth, restriction, query_string = self._rinse_args( - q=q, depth=depth, additional_fields=additional_fields, - field_validator=self.validate_item_field + q=q, depth=depth, additional_fields=additional_fields, field_validator=self.validate_item_field ) if calendar_view is not None and not isinstance(calendar_view, CalendarView): - raise InvalidTypeError('calendar_view', calendar_view, CalendarView) + raise InvalidTypeError("calendar_view", calendar_view, CalendarView) log.debug( - 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', + "Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)", self.account, self.folders, shape, @@ -5324,8 +5605,17 @@

                          Examples

                          Expand source code -
                          def find_people(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order_fields=None,
                          -                page_size=None, max_items=None, offset=0):
                          +
                          def find_people(
                          +    self,
                          +    q,
                          +    shape=ID_ONLY,
                          +    depth=None,
                          +    additional_fields=None,
                          +    order_fields=None,
                          +    page_size=None,
                          +    max_items=None,
                          +    offset=0,
                          +):
                               """Private method to call the FindPeople service.
                           
                               :param q: a Q instance containing any restrictions
                          @@ -5341,25 +5631,25 @@ 

                          Examples

                          :return: a generator for the returned personas """ from ..services import FindPeople + folder = self._get_single_folder() if q.is_never(): - log.debug('Query will never return results') + log.debug("Query will never return results") return depth, restriction, query_string = self._rinse_args( - q=q, depth=depth, additional_fields=additional_fields, - field_validator=Persona.validate_field + q=q, depth=depth, additional_fields=additional_fields, field_validator=Persona.validate_field ) yield from FindPeople(account=self.account, page_size=page_size).call( - folder=folder, - additional_fields=additional_fields, - restriction=restriction, - order_fields=order_fields, - shape=shape, - query_string=query_string, - depth=depth, - max_items=max_items, - offset=offset, + folder=folder, + additional_fields=additional_fields, + restriction=restriction, + order_fields=order_fields, + shape=shape, + query_string=query_string, + depth=depth, + max_items=max_items, + offset=offset, )
                          @@ -5374,7 +5664,8 @@

                          Examples

                          def get_folder_fields(self, target_cls, is_complex=None):
                               return {
                          -        FieldPath(field=f) for f in target_cls.supported_fields(version=self.account.version)
                          +        FieldPath(field=f)
                          +        for f in target_cls.supported_fields(version=self.account.version)
                                   if is_complex is None or f.is_complex is is_complex
                               }
                          @@ -5390,10 +5681,12 @@

                          Examples

                          def get_folders(self, additional_fields=None):
                               from ..services import GetFolder
                          +
                               # Expand folders with their full set of properties
                               from .base import BaseFolder
                          +
                               if not self.folders:
                          -        log.debug('Folder list is empty')
                          +        log.debug("Folder list is empty")
                                   return
                               if additional_fields is None:
                                   # Default to all complex properties
                          @@ -5405,9 +5698,9 @@ 

                          Examples

                          ) yield from GetFolder(account=self.account).call( - folders=self.folders, - additional_fields=additional_fields, - shape=ID_ONLY, + folders=self.folders, + additional_fields=additional_fields, + shape=ID_ONLY, )
                          @@ -5449,17 +5742,18 @@

                          Examples

                          def resolve(self):
                               # Looks up the folders or folder IDs in the collection and returns full Folder instances with all fields set.
                               from .base import BaseFolder
                          +
                               resolveable_folders = []
                               for f in self.folders:
                                   if isinstance(f, BaseFolder) and not f.get_folder_allowed:
                          -            log.debug('GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder', f)
                          +            log.debug("GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder", f)
                                       yield f
                                   else:
                                       resolveable_folders.append(f)
                               # Fetch all properties for the remaining folders of folder IDs
                               additional_fields = self.get_folder_fields(target_cls=self._get_target_cls(), is_complex=None)
                               yield from self.__class__(account=self.account, folders=resolveable_folders).get_folders(
                          -            additional_fields=additional_fields
                          +        additional_fields=additional_fields
                               )
                          @@ -5487,13 +5781,17 @@

                          Examples

                          def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60):
                               from ..services import SubscribeToPull
                          +
                               if not self.folders:
                          -        log.debug('Folder list is empty')
                          +        log.debug("Folder list is empty")
                                   return None
                               if event_types is None:
                                   event_types = SubscribeToPull.EVENT_TYPES
                               return SubscribeToPull(account=self.account).get(
                          -        folders=self.folders, event_types=event_types, watermark=watermark, timeout=timeout,
                          +        folders=self.folders,
                          +        event_types=event_types,
                          +        watermark=watermark,
                          +        timeout=timeout,
                               )
                          @@ -5508,13 +5806,17 @@

                          Examples

                          def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1):
                               from ..services import SubscribeToPush
                          +
                               if not self.folders:
                          -        log.debug('Folder list is empty')
                          +        log.debug("Folder list is empty")
                                   return None
                               if event_types is None:
                                   event_types = SubscribeToPush.EVENT_TYPES
                               return SubscribeToPush(account=self.account).get(
                          -        folders=self.folders, event_types=event_types, watermark=watermark, status_frequency=status_frequency,
                          +        folders=self.folders,
                          +        event_types=event_types,
                          +        watermark=watermark,
                          +        status_frequency=status_frequency,
                                   url=callback_url,
                               )
                          @@ -5530,8 +5832,9 @@

                          Examples

                          def subscribe_to_streaming(self, event_types=None):
                               from ..services import SubscribeToStreaming
                          +
                               if not self.folders:
                          -        log.debug('Folder list is empty')
                          +        log.debug("Folder list is empty")
                                   return None
                               if event_types is None:
                                   event_types = SubscribeToStreaming.EVENT_TYPES
                          @@ -5549,6 +5852,7 @@ 

                          Examples

                          def sync_hierarchy(self, sync_state=None, only_fields=None):
                               from ..services import SyncFolderHierarchy
                          +
                               folder = self._get_single_folder()
                               if only_fields is None:
                                   # We didn't restrict list of field paths. Get all fields from the server, including extended properties.
                          @@ -5595,6 +5899,7 @@ 

                          Examples

                          def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None):
                               from ..services import SyncFolderItems
                          +
                               folder = self._get_single_folder()
                               if only_fields is None:
                                   # We didn't restrict list of field paths. Get all fields from the server, including extended properties.
                          @@ -5648,6 +5953,7 @@ 

                          Examples

                          sync methods. """ from ..services import Unsubscribe + return Unsubscribe(account=self.account).get(subscription_id=subscription_id)
                          @@ -5744,7 +6050,7 @@

                          Inherited members

                          class FolderId(ItemId):
                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/folderid"""
                           
                          -    ELEMENT_NAME = 'FolderId'
                          + ELEMENT_NAME = "FolderId"

                          Ancestors

                            @@ -5790,8 +6096,9 @@

                            Inherited members

                            def __init__(self, folder_collection): from .collections import FolderCollection + if not isinstance(folder_collection, FolderCollection): - raise InvalidTypeError('folder_collection', folder_collection, FolderCollection) + raise InvalidTypeError("folder_collection", folder_collection, FolderCollection) self.folder_collection = folder_collection self.q = Q() # Default to no restrictions self.only_fields = None @@ -5811,6 +6118,7 @@

                            Inherited members

                            def only(self, *args): """Restrict the fields returned. 'name' and 'folder_class' are always returned.""" from .base import Folder + # Subfolders will always be of class Folder all_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=None) all_fields.update(Folder.attribute_fields()) @@ -5840,18 +6148,19 @@

                            Inherited members

                            MultipleObjectsReturned if there are multiple results. """ from .collections import FolderCollection - if not args and set(kwargs) in ({'id'}, {'id', 'changekey'}): - folders = list(FolderCollection( - account=self.folder_collection.account, folders=[FolderId(**kwargs)] - ).resolve()) + + if not args and set(kwargs) in ({"id"}, {"id", "changekey"}): + folders = list( + FolderCollection(account=self.folder_collection.account, folders=[FolderId(**kwargs)]).resolve() + ) elif args or kwargs: folders = list(self.filter(*args, **kwargs)) else: folders = list(self.all()) if not folders: - raise DoesNotExist('Could not find a child folder matching the query') + raise DoesNotExist("Could not find a child folder matching the query") if len(folders) != 1: - raise MultipleObjectsReturned(f'Expected result length 1, but got {folders}') + raise MultipleObjectsReturned(f"Expected result length 1, but got {folders}") f = folders[0] if isinstance(f, Exception): raise f @@ -5875,6 +6184,7 @@

                            Inherited members

                            def _query(self): from .base import Folder from .collections import FolderCollection + if self.only_fields is None: # Subfolders will always be of class Folder non_complex_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=False) @@ -5897,7 +6207,7 @@

                            Inherited members

                            yield f continue if not f.get_folder_allowed: - log.debug('GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder', f) + log.debug("GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder", f) yield f else: resolveable_folders.append(f) @@ -5915,7 +6225,7 @@

                            Inherited members

                            continue # Add the extra field values to the folders we fetched with find_folders() if f.__class__ != complex_f.__class__: - raise ValueError(f'Type mismatch: {f} vs {complex_f}') + raise ValueError(f"Type mismatch: {f} vs {complex_f}") for complex_field in complex_fields: field_name = complex_field.field.name setattr(f, field_name, getattr(complex_f, field_name)) @@ -5994,18 +6304,19 @@

                            Methods

                            MultipleObjectsReturned if there are multiple results. """ from .collections import FolderCollection - if not args and set(kwargs) in ({'id'}, {'id', 'changekey'}): - folders = list(FolderCollection( - account=self.folder_collection.account, folders=[FolderId(**kwargs)] - ).resolve()) + + if not args and set(kwargs) in ({"id"}, {"id", "changekey"}): + folders = list( + FolderCollection(account=self.folder_collection.account, folders=[FolderId(**kwargs)]).resolve() + ) elif args or kwargs: folders = list(self.filter(*args, **kwargs)) else: folders = list(self.all()) if not folders: - raise DoesNotExist('Could not find a child folder matching the query') + raise DoesNotExist("Could not find a child folder matching the query") if len(folders) != 1: - raise MultipleObjectsReturned(f'Expected result length 1, but got {folders}') + raise MultipleObjectsReturned(f"Expected result length 1, but got {folders}") f = folders[0] if isinstance(f, Exception): raise f @@ -6024,6 +6335,7 @@

                            Methods

                            def only(self, *args):
                                 """Restrict the fields returned. 'name' and 'folder_class' are always returned."""
                                 from .base import Folder
                            +
                                 # Subfolders will always be of class Folder
                                 all_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=None)
                                 all_fields.update(Folder.attribute_fields())
                            @@ -6054,7 +6366,7 @@ 

                            Methods

                            class FreebusyData(NonDeletableFolderMixin, Folder):
                                 LOCALIZED_NAMES = {
                            -        None: ('Freebusy Data',),
                            +        None: ("Freebusy Data",),
                                 }

                            Ancestors

                            @@ -6124,10 +6436,10 @@

                            Inherited members

                            Expand source code
                            class Friends(NonDeletableFolderMixin, Contacts):
                            -    CONTAINER_CLASS = 'IPF.Note'
                            +    CONTAINER_CLASS = "IPF.Note"
                             
                                 LOCALIZED_NAMES = {
                            -        'de_DE': ('Bekannte',),
                            +        "de_DE": ("Bekannte",),
                                 }

                            Ancestors

                            @@ -6203,10 +6515,10 @@

                            Inherited members

                            class GALContacts(NonDeletableFolderMixin, Contacts):
                                 DISTINGUISHED_FOLDER_ID = None
                            -    CONTAINER_CLASS = 'IPF.Contact.GalContacts'
                            +    CONTAINER_CLASS = "IPF.Contact.GalContacts"
                             
                                 LOCALIZED_NAMES = {
                            -        None: ('GAL Contacts',),
                            +        None: ("GAL Contacts",),
                                 }

                            Ancestors

                            @@ -6285,9 +6597,9 @@

                            Inherited members

                            Expand source code
                            class GraphAnalytics(NonDeletableFolderMixin, Folder):
                            -    CONTAINER_CLASS = 'IPF.StoreItem.GraphAnalytics'
                            +    CONTAINER_CLASS = "IPF.StoreItem.GraphAnalytics"
                                 LOCALIZED_NAMES = {
                            -        None: ('GraphAnalytics',),
                            +        None: ("GraphAnalytics",),
                                 }

                            Ancestors

                            @@ -6361,8 +6673,8 @@

                            Inherited members

                            Expand source code
                            class IMContactList(WellknownFolder):
                            -    CONTAINER_CLASS = 'IPF.Contact.MOC.ImContactList'
                            -    DISTINGUISHED_FOLDER_ID = 'imcontactlist'
                            +    CONTAINER_CLASS = "IPF.Contact.MOC.ImContactList"
                            +    DISTINGUISHED_FOLDER_ID = "imcontactlist"
                                 supported_from = EXCHANGE_2013

                            Ancestors

                            @@ -6440,18 +6752,18 @@

                            Inherited members

                            Expand source code
                            class Inbox(Messages):
                            -    DISTINGUISHED_FOLDER_ID = 'inbox'
                            +    DISTINGUISHED_FOLDER_ID = "inbox"
                             
                                 LOCALIZED_NAMES = {
                            -        'da_DK': ('Indbakke',),
                            -        'de_DE': ('Posteingang',),
                            -        'en_US': ('Inbox',),
                            -        'es_ES': ('Bandeja de entrada',),
                            -        'fr_CA': ('Boîte de réception',),
                            -        'nl_NL': ('Postvak IN',),
                            -        'ru_RU': ('Входящие',),
                            -        'sv_SE': ('Inkorgen',),
                            -        'zh_CN': ('收件箱',),
                            +        "da_DK": ("Indbakke",),
                            +        "de_DE": ("Posteingang",),
                            +        "en_US": ("Inbox",),
                            +        "es_ES": ("Bandeja de entrada",),
                            +        "fr_CA": ("Boîte de réception",),
                            +        "nl_NL": ("Postvak IN",),
                            +        "ru_RU": ("Входящие",),
                            +        "sv_SE": ("Inkorgen",),
                            +        "zh_CN": ("收件箱",),
                                 }

                            Ancestors

                            @@ -6525,8 +6837,8 @@

                            Inherited members

                            Expand source code
                            class Journal(WellknownFolder):
                            -    CONTAINER_CLASS = 'IPF.Journal'
                            -    DISTINGUISHED_FOLDER_ID = 'journal'
                            + CONTAINER_CLASS = "IPF.Journal" + DISTINGUISHED_FOLDER_ID = "journal"

                            Ancestors

                              @@ -6599,18 +6911,18 @@

                              Inherited members

                              Expand source code
                              class JunkEmail(Messages):
                              -    DISTINGUISHED_FOLDER_ID = 'junkemail'
                              +    DISTINGUISHED_FOLDER_ID = "junkemail"
                               
                                   LOCALIZED_NAMES = {
                              -        'da_DK': ('Uønsket e-mail',),
                              -        'de_DE': ('Junk-E-Mail',),
                              -        'en_US': ('Junk E-mail',),
                              -        'es_ES': ('Correo no deseado',),
                              -        'fr_CA': ('Courrier indésirables',),
                              -        'nl_NL': ('Ongewenste e-mail',),
                              -        'ru_RU': ('Нежелательная почта',),
                              -        'sv_SE': ('Skräppost',),
                              -        'zh_CN': ('垃圾邮件',),
                              +        "da_DK": ("Uønsket e-mail",),
                              +        "de_DE": ("Junk-E-Mail",),
                              +        "en_US": ("Junk E-mail",),
                              +        "es_ES": ("Correo no deseado",),
                              +        "fr_CA": ("Courrier indésirables",),
                              +        "nl_NL": ("Ongewenste e-mail",),
                              +        "ru_RU": ("Нежелательная почта",),
                              +        "sv_SE": ("Skräppost",),
                              +        "zh_CN": ("垃圾邮件",),
                                   }

                              Ancestors

                              @@ -6684,7 +6996,7 @@

                              Inherited members

                              Expand source code
                              class LocalFailures(WellknownFolder):
                              -    DISTINGUISHED_FOLDER_ID = 'localfailures'
                              +    DISTINGUISHED_FOLDER_ID = "localfailures"
                                   supported_from = EXCHANGE_2013

                              Ancestors

                              @@ -6759,7 +7071,7 @@

                              Inherited members

                              class Location(NonDeletableFolderMixin, Folder):
                                   LOCALIZED_NAMES = {
                              -        None: ('Location',),
                              +        None: ("Location",),
                                   }

                              Ancestors

                              @@ -6830,7 +7142,7 @@

                              Inherited members

                              class MailboxAssociations(NonDeletableFolderMixin, Folder):
                                   LOCALIZED_NAMES = {
                              -        None: ('MailboxAssociations',),
                              +        None: ("MailboxAssociations",),
                                   }

                              Ancestors

                              @@ -6900,7 +7212,7 @@

                              Inherited members

                              Expand source code
                              class Messages(Folder):
                              -    CONTAINER_CLASS = 'IPF.Note'
                              +    CONTAINER_CLASS = "IPF.Note"
                                   supported_item_models = (Message, MeetingRequest, MeetingResponse, MeetingCancellation)

                              Ancestors

                              @@ -6983,9 +7295,9 @@

                              Inherited members

                              class MsgFolderRoot(WellknownFolder):
                                   """Also known as the 'Top of Information Store' folder."""
                               
                              -    DISTINGUISHED_FOLDER_ID = 'msgfolderroot'
                              +    DISTINGUISHED_FOLDER_ID = "msgfolderroot"
                                   LOCALIZED_NAMES = {
                              -        'zh_CN': ('信息存储顶部',),
                              +        "zh_CN": ("信息存储顶部",),
                                   }

                              Ancestors

                              @@ -7059,8 +7371,8 @@

                              Inherited members

                              Expand source code
                              class MyContacts(WellknownFolder):
                              -    CONTAINER_CLASS = 'IPF.Note'
                              -    DISTINGUISHED_FOLDER_ID = 'mycontacts'
                              +    CONTAINER_CLASS = "IPF.Note"
                              +    DISTINGUISHED_FOLDER_ID = "mycontacts"
                                   supported_from = EXCHANGE_2013

                              Ancestors

                              @@ -7138,9 +7450,9 @@

                              Inherited members

                              Expand source code
                              class MyContactsExtended(NonDeletableFolderMixin, Contacts):
                              -    CONTAINER_CLASS = 'IPF.Note'
                              +    CONTAINER_CLASS = "IPF.Note"
                                   LOCALIZED_NAMES = {
                              -        None: ('MyContactsExtended',),
                              +        None: ("MyContactsExtended",),
                                   }

                              Ancestors

                              @@ -7286,10 +7598,10 @@

                              Instance variables

                              Expand source code
                              class Notes(WellknownFolder):
                              -    CONTAINER_CLASS = 'IPF.StickyNote'
                              -    DISTINGUISHED_FOLDER_ID = 'notes'
                              +    CONTAINER_CLASS = "IPF.StickyNote"
                              +    DISTINGUISHED_FOLDER_ID = "notes"
                                   LOCALIZED_NAMES = {
                              -        'da_DK': ('Noter',),
                              +        "da_DK": ("Noter",),
                                   }

                              Ancestors

                              @@ -7368,9 +7680,9 @@

                              Inherited members

                              class OrganizationalContacts(NonDeletableFolderMixin, Contacts):
                                   DISTINGUISHED_FOLDER_ID = None
                              -    CONTAINTER_CLASS = 'IPF.Contact.OrganizationalContacts'
                              +    CONTAINTER_CLASS = "IPF.Contact.OrganizationalContacts"
                                   LOCALIZED_NAMES = {
                              -        None: ('Organizational Contacts',),
                              +        None: ("Organizational Contacts",),
                                   }

                              Ancestors

                              @@ -7449,18 +7761,18 @@

                              Inherited members

                              Expand source code
                              class Outbox(Messages):
                              -    DISTINGUISHED_FOLDER_ID = 'outbox'
                              +    DISTINGUISHED_FOLDER_ID = "outbox"
                               
                                   LOCALIZED_NAMES = {
                              -        'da_DK': ('Udbakke',),
                              -        'de_DE': ('Postausgang',),
                              -        'en_US': ('Outbox',),
                              -        'es_ES': ('Bandeja de salida',),
                              -        'fr_CA': (u"Boîte d'envoi",),
                              -        'nl_NL': ('Postvak UIT',),
                              -        'ru_RU': ('Исходящие',),
                              -        'sv_SE': ('Utkorgen',),
                              -        'zh_CN': ('发件箱',),
                              +        "da_DK": ("Udbakke",),
                              +        "de_DE": ("Postausgang",),
                              +        "en_US": ("Outbox",),
                              +        "es_ES": ("Bandeja de salida",),
                              +        "fr_CA": (u"Boîte d'envoi",),
                              +        "nl_NL": ("Postvak UIT",),
                              +        "ru_RU": ("Исходящие",),
                              +        "sv_SE": ("Utkorgen",),
                              +        "zh_CN": ("发件箱",),
                                   }

                              Ancestors

                              @@ -7536,7 +7848,7 @@

                              Inherited members

                              class ParkedMessages(NonDeletableFolderMixin, Folder):
                                   CONTAINER_CLASS = None
                                   LOCALIZED_NAMES = {
                              -        None: ('ParkedMessages',),
                              +        None: ("ParkedMessages",),
                                   }

                              Ancestors

                              @@ -7610,9 +7922,9 @@

                              Inherited members

                              Expand source code
                              class PassThroughSearchResults(NonDeletableFolderMixin, Folder):
                              -    CONTAINER_CLASS = 'IPF.StoreItem.PassThroughSearchResults'
                              +    CONTAINER_CLASS = "IPF.StoreItem.PassThroughSearchResults"
                                   LOCALIZED_NAMES = {
                              -        None: ('Pass-Through Search Results',),
                              +        None: ("Pass-Through Search Results",),
                                   }

                              Ancestors

                              @@ -7686,9 +7998,9 @@

                              Inherited members

                              Expand source code
                              class PdpProfileV2Secured(NonDeletableFolderMixin, Folder):
                              -    CONTAINER_CLASS = 'IPF.StoreItem.PdpProfileSecured'
                              +    CONTAINER_CLASS = "IPF.StoreItem.PdpProfileSecured"
                                   LOCALIZED_NAMES = {
                              -        None: ('PdpProfileV2Secured',),
                              +        None: ("PdpProfileV2Secured",),
                                   }

                              Ancestors

                              @@ -7763,9 +8075,9 @@

                              Inherited members

                              class PeopleCentricConversationBuddies(NonDeletableFolderMixin, Contacts):
                                   DISTINGUISHED_FOLDER_ID = None
                              -    CONTAINTER_CLASS = 'IPF.Contact.PeopleCentricConversationBuddies'
                              +    CONTAINTER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies"
                                   LOCALIZED_NAMES = {
                              -        None: ('PeopleCentricConversation Buddies',),
                              +        None: ("PeopleCentricConversation Buddies",),
                                   }

                              Ancestors

                              @@ -7844,7 +8156,7 @@

                              Inherited members

                              Expand source code
                              class PeopleConnect(WellknownFolder):
                              -    DISTINGUISHED_FOLDER_ID = 'peopleconnect'
                              +    DISTINGUISHED_FOLDER_ID = "peopleconnect"
                                   supported_from = EXCHANGE_2013

                              Ancestors

                              @@ -7920,7 +8232,7 @@

                              Inherited members

                              class PublicFoldersRoot(RootOfHierarchy):
                                   """The root of the public folders hierarchy. Not available on all mailboxes."""
                               
                              -    DISTINGUISHED_FOLDER_ID = 'publicfoldersroot'
                              +    DISTINGUISHED_FOLDER_ID = "publicfoldersroot"
                                   DEFAULT_FOLDER_TRAVERSAL_DEPTH = SHALLOW
                                   supported_from = EXCHANGE_2007_SP1
                               
                              @@ -7941,9 +8253,11 @@ 

                              Inherited members

                              children_map = {} try: - for f in SingleFolderQuerySet(account=self.account, folder=folder).depth( - self.DEFAULT_FOLDER_TRAVERSAL_DEPTH - ).all(): + for f in ( + SingleFolderQuerySet(account=self.account, folder=folder) + .depth(self.DEFAULT_FOLDER_TRAVERSAL_DEPTH) + .all() + ): if isinstance(f, MISSING_FOLDER_ERRORS): # We were unlucky. The folder disappeared between the FindFolder and the GetFolder calls continue @@ -8013,9 +8327,11 @@

                              Methods

                              children_map = {} try: - for f in SingleFolderQuerySet(account=self.account, folder=folder).depth( - self.DEFAULT_FOLDER_TRAVERSAL_DEPTH - ).all(): + for f in ( + SingleFolderQuerySet(account=self.account, folder=folder) + .depth(self.DEFAULT_FOLDER_TRAVERSAL_DEPTH) + .all() + ): if isinstance(f, MISSING_FOLDER_ERRORS): # We were unlucky. The folder disappeared between the FindFolder and the GetFolder calls continue @@ -8087,8 +8403,8 @@

                              Inherited members

                              Expand source code
                              class QuickContacts(WellknownFolder):
                              -    CONTAINER_CLASS = 'IPF.Contact.MOC.QuickContacts'
                              -    DISTINGUISHED_FOLDER_ID = 'quickcontacts'
                              +    CONTAINER_CLASS = "IPF.Contact.MOC.QuickContacts"
                              +    DISTINGUISHED_FOLDER_ID = "quickcontacts"
                                   supported_from = EXCHANGE_2013

                              Ancestors

                              @@ -8166,9 +8482,9 @@

                              Inherited members

                              Expand source code
                              class RSSFeeds(NonDeletableFolderMixin, Folder):
                              -    CONTAINER_CLASS = 'IPF.Note.OutlookHomepage'
                              +    CONTAINER_CLASS = "IPF.Note.OutlookHomepage"
                                   LOCALIZED_NAMES = {
                              -        None: ('RSS Feeds',),
                              +        None: ("RSS Feeds",),
                                   }

                              Ancestors

                              @@ -8242,8 +8558,8 @@

                              Inherited members

                              Expand source code
                              class RecipientCache(Contacts):
                              -    DISTINGUISHED_FOLDER_ID = 'recipientcache'
                              -    CONTAINER_CLASS = 'IPF.Contact.RecipientCache'
                              +    DISTINGUISHED_FOLDER_ID = "recipientcache"
                              +    CONTAINER_CLASS = "IPF.Contact.RecipientCache"
                                   supported_from = EXCHANGE_2013
                               
                                   LOCALIZED_NAMES = {}
                              @@ -8327,7 +8643,7 @@

                              Inherited members

                              Expand source code
                              class RecoverableItemsDeletions(WellknownFolder):
                              -    DISTINGUISHED_FOLDER_ID = 'recoverableitemsdeletions'
                              +    DISTINGUISHED_FOLDER_ID = "recoverableitemsdeletions"
                                   supported_from = EXCHANGE_2010_SP1

                              Ancestors

                              @@ -8401,7 +8717,7 @@

                              Inherited members

                              Expand source code
                              class RecoverableItemsPurges(WellknownFolder):
                              -    DISTINGUISHED_FOLDER_ID = 'recoverableitemspurges'
                              +    DISTINGUISHED_FOLDER_ID = "recoverableitemspurges"
                                   supported_from = EXCHANGE_2010_SP1

                              Ancestors

                              @@ -8475,7 +8791,7 @@

                              Inherited members

                              Expand source code
                              class RecoverableItemsRoot(WellknownFolder):
                              -    DISTINGUISHED_FOLDER_ID = 'recoverableitemsroot'
                              +    DISTINGUISHED_FOLDER_ID = "recoverableitemsroot"
                                   supported_from = EXCHANGE_2010_SP1

                              Ancestors

                              @@ -8549,7 +8865,7 @@

                              Inherited members

                              Expand source code
                              class RecoverableItemsVersions(WellknownFolder):
                              -    DISTINGUISHED_FOLDER_ID = 'recoverableitemsversions'
                              +    DISTINGUISHED_FOLDER_ID = "recoverableitemsversions"
                                   supported_from = EXCHANGE_2010_SP1

                              Ancestors

                              @@ -8623,9 +8939,9 @@

                              Inherited members

                              Expand source code
                              class Reminders(NonDeletableFolderMixin, Folder):
                              -    CONTAINER_CLASS = 'Outlook.Reminder'
                              +    CONTAINER_CLASS = "Outlook.Reminder"
                                   LOCALIZED_NAMES = {
                              -        'da_DK': ('Påmindelser',),
                              +        "da_DK": ("Påmindelser",),
                                   }

                              Ancestors

                              @@ -8701,7 +9017,7 @@

                              Inherited members

                              class Root(RootOfHierarchy):
                                   """The root of the standard folder hierarchy."""
                               
                              -    DISTINGUISHED_FOLDER_ID = 'root'
                              +    DISTINGUISHED_FOLDER_ID = "root"
                                   WELLKNOWN_FOLDERS = WELLKNOWN_FOLDERS_IN_ROOT
                               
                                   @property
                              @@ -8722,12 +9038,12 @@ 

                              Inherited members

                              # 3. Searching TOIS for a direct child folder of the same type that has a localized name # 4. Searching root for a direct child folder of the same type that is marked as distinguished # 5. Searching root for a direct child folder of the same type that has a localized name - log.debug('Searching default %s folder in full folder list', folder_cls) + log.debug("Searching default %s folder in full folder list", folder_cls) for f in self._folders_map.values(): # Require exact type, to avoid matching with subclasses (e.g. RecipientCache and Contacts) if f.__class__ == folder_cls and f.has_distinguished_name: - log.debug('Found cached %s folder with default distinguished name', folder_cls) + log.debug("Found cached %s folder with default distinguished name", folder_cls) return f # Try direct children of TOIS first, unless we're trying to get the TOIS folder @@ -8750,14 +9066,14 @@

                              Inherited members

                              else: candidates = [f for f in same_type if f.name.lower() in folder_cls.localized_names(self.account.locale)] if not candidates: - raise ErrorFolderNotFound(f'No usable default {folder_cls} folders') + raise ErrorFolderNotFound(f"No usable default {folder_cls} folders") if len(candidates) > 1: - raise ValueError(f'Multiple possible default {folder_cls} folders: {[f.name for f in candidates]}') + raise ValueError(f"Multiple possible default {folder_cls} folders: {[f.name for f in candidates]}") candidate = candidates[0] if candidate.is_distinguished: - log.debug('Found distinguished %s folder', folder_cls) + log.debug("Found distinguished %s folder", folder_cls) else: - log.debug('Found %s folder with localized name %s', folder_cls, candidate.name) + log.debug("Found %s folder with localized name %s", folder_cls, candidate.name) return candidate

                              Ancestors

                              @@ -8862,14 +9178,15 @@

                              Inherited members

                              # This folder type also has 'folder:PermissionSet' on some server versions, but requesting it sometimes causes # 'ErrorAccessDenied', as reported by some users. Ignore it entirely for root folders - it's usefulness is # deemed minimal at best. - effective_rights = EffectiveRightsField(field_uri='folder:EffectiveRights', is_read_only=True, - supported_from=EXCHANGE_2007_SP1) + effective_rights = EffectiveRightsField( + field_uri="folder:EffectiveRights", is_read_only=True, supported_from=EXCHANGE_2007_SP1 + ) - __slots__ = '_account', '_subfolders' + __slots__ = "_account", "_subfolders" # A special folder that acts as the top of a folder hierarchy. Finds and caches subfolders at arbitrary depth. def __init__(self, **kwargs): - self._account = kwargs.pop('account', None) # A pointer back to the account holding the folder hierarchy + self._account = kwargs.pop("account", None) # A pointer back to the account holding the folder hierarchy super().__init__(**kwargs) self._subfolders = None # See self._folders_map() @@ -8888,13 +9205,13 @@

                              Inherited members

                              @classmethod def register(cls, *args, **kwargs): if cls is not RootOfHierarchy: - raise TypeError('For folder roots, custom fields must be registered on the RootOfHierarchy class') + raise TypeError("For folder roots, custom fields must be registered on the RootOfHierarchy class") return super().register(*args, **kwargs) @classmethod def deregister(cls, *args, **kwargs): if cls is not RootOfHierarchy: - raise TypeError('For folder roots, custom fields must be registered on the RootOfHierarchy class') + raise TypeError("For folder roots, custom fields must be registered on the RootOfHierarchy class") return super().deregister(*args, **kwargs) def get_folder(self, folder): @@ -8938,14 +9255,13 @@

                              Inherited members

                              :param account: """ if not cls.DISTINGUISHED_FOLDER_ID: - raise ValueError(f'Class {cls} must have a DISTINGUISHED_FOLDER_ID value') + raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: return cls.resolve( - account=account, - folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + account=account, folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}') + raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") def get_default_folder(self, folder_cls): """Return the distinguished folder instance of type folder_cls belonging to this account. If no distinguished @@ -8959,21 +9275,21 @@

                              Inherited members

                              for f in self._folders_map.values(): # Require exact class, to not match subclasses, e.g. RecipientCache instead of Contacts if f.__class__ == folder_cls and f.is_distinguished: - log.debug('Found cached distinguished %s folder', folder_cls) + log.debug("Found cached distinguished %s folder", folder_cls) return f try: - log.debug('Requesting distinguished %s folder explicitly', folder_cls) + log.debug("Requesting distinguished %s folder explicitly", folder_cls) return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItems instead - log.debug('Testing default %s folder with FindItem', folder_cls) + log.debug("Testing default %s folder with FindItem", folder_cls) fld = folder_cls(root=self, name=folder_cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: # The Exchange server does not return a distinguished folder of this type pass - raise ErrorFolderNotFound(f'No usable default {folder_cls} folders') + raise ErrorFolderNotFound(f"No usable default {folder_cls} folders") @property def _folders_map(self): @@ -9004,9 +9320,9 @@

                              Inherited members

                              if isinstance(f, Exception): raise f folders_map[f.id] = f - for f in SingleFolderQuerySet(account=self.account, folder=self).depth( - self.DEFAULT_FOLDER_TRAVERSAL_DEPTH - ).all(): + for f in ( + SingleFolderQuerySet(account=self.account, folder=self).depth(self.DEFAULT_FOLDER_TRAVERSAL_DEPTH).all() + ): if isinstance(f, ErrorAccessDenied): # We may not have FindFolder access, or GetFolder access, either to this folder or at all continue @@ -9042,9 +9358,19 @@

                              Inherited members

                              def __repr__(self): # Let's not create an infinite loop when printing self.root - return self.__class__.__name__ + \ - repr((self.account, '[self]', self.name, self.total_count, self.unread_count, self.child_folder_count, - self.folder_class, self.id, self.changekey))
                              + return self.__class__.__name__ + repr( + ( + self.account, + "[self]", + self.name, + self.total_count, + self.unread_count, + self.child_folder_count, + self.folder_class, + self.id, + self.changekey, + ) + )

                          Ancestors

                            @@ -9130,14 +9456,13 @@

                            Static methods

                            :param account: """ if not cls.DISTINGUISHED_FOLDER_ID: - raise ValueError(f'Class {cls} must have a DISTINGUISHED_FOLDER_ID value') + raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: return cls.resolve( - account=account, - folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + account=account, folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}')
                          + raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}")
                          @@ -9218,21 +9543,21 @@

                          Methods

                          for f in self._folders_map.values(): # Require exact class, to not match subclasses, e.g. RecipientCache instead of Contacts if f.__class__ == folder_cls and f.is_distinguished: - log.debug('Found cached distinguished %s folder', folder_cls) + log.debug("Found cached distinguished %s folder", folder_cls) return f try: - log.debug('Requesting distinguished %s folder explicitly', folder_cls) + log.debug("Requesting distinguished %s folder explicitly", folder_cls) return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItems instead - log.debug('Testing default %s folder with FindItem', folder_cls) + log.debug("Testing default %s folder with FindItem", folder_cls) fld = folder_cls(root=self, name=folder_cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: # The Exchange server does not return a distinguished folder of this type pass - raise ErrorFolderNotFound(f'No usable default {folder_cls} folders')
                          + raise ErrorFolderNotFound(f"No usable default {folder_cls} folders")
                          @@ -9334,7 +9659,7 @@

                          Inherited members

                          class Schedule(NonDeletableFolderMixin, Folder):
                               LOCALIZED_NAMES = {
                          -        None: ('Schedule',),
                          +        None: ("Schedule",),
                               }

                          Ancestors

                          @@ -9404,7 +9729,7 @@

                          Inherited members

                          Expand source code
                          class SearchFolders(WellknownFolder):
                          -    DISTINGUISHED_FOLDER_ID = 'searchfolders'
                          + DISTINGUISHED_FOLDER_ID = "searchfolders"

                          Ancestors

                            @@ -9473,18 +9798,18 @@

                            Inherited members

                            Expand source code
                            class SentItems(Messages):
                            -    DISTINGUISHED_FOLDER_ID = 'sentitems'
                            +    DISTINGUISHED_FOLDER_ID = "sentitems"
                             
                                 LOCALIZED_NAMES = {
                            -        'da_DK': ('Sendt post',),
                            -        'de_DE': ('Gesendete Elemente',),
                            -        'en_US': ('Sent Items',),
                            -        'es_ES': ('Elementos enviados',),
                            -        'fr_CA': ('Éléments envoyés',),
                            -        'nl_NL': ('Verzonden items',),
                            -        'ru_RU': ('Отправленные',),
                            -        'sv_SE': ('Skickat',),
                            -        'zh_CN': ('已发送邮件',),
                            +        "da_DK": ("Sendt post",),
                            +        "de_DE": ("Gesendete Elemente",),
                            +        "en_US": ("Sent Items",),
                            +        "es_ES": ("Elementos enviados",),
                            +        "fr_CA": ("Éléments envoyés",),
                            +        "nl_NL": ("Verzonden items",),
                            +        "ru_RU": ("Отправленные",),
                            +        "sv_SE": ("Skickat",),
                            +        "zh_CN": ("已发送邮件",),
                                 }

                            Ancestors

                            @@ -9558,7 +9883,7 @@

                            Inherited members

                            Expand source code
                            class ServerFailures(WellknownFolder):
                            -    DISTINGUISHED_FOLDER_ID = 'serverfailures'
                            +    DISTINGUISHED_FOLDER_ID = "serverfailures"
                                 supported_from = EXCHANGE_2013

                            Ancestors

                            @@ -9632,9 +9957,9 @@

                            Inherited members

                            Expand source code
                            class Sharing(NonDeletableFolderMixin, Folder):
                            -    CONTAINER_CLASS = 'IPF.Note'
                            +    CONTAINER_CLASS = "IPF.Note"
                                 LOCALIZED_NAMES = {
                            -        None: ('Sharing',),
                            +        None: ("Sharing",),
                                 }

                            Ancestors

                            @@ -9709,7 +10034,7 @@

                            Inherited members

                            class Shortcuts(NonDeletableFolderMixin, Folder):
                                 LOCALIZED_NAMES = {
                            -        None: ('Shortcuts',),
                            +        None: ("Shortcuts",),
                                 }

                            Ancestors

                            @@ -9779,9 +10104,9 @@

                            Inherited members

                            Expand source code
                            class Signal(NonDeletableFolderMixin, Folder):
                            -    CONTAINER_CLASS = 'IPF.StoreItem.Signal'
                            +    CONTAINER_CLASS = "IPF.StoreItem.Signal"
                                 LOCALIZED_NAMES = {
                            -        None: ('Signal',),
                            +        None: ("Signal",),
                                 }

                            Ancestors

                            @@ -9859,6 +10184,7 @@

                            Inherited members

                            def __init__(self, account, folder): from .collections import FolderCollection + folder_collection = FolderCollection(account=account, folders=[folder]) super().__init__(folder_collection=folder_collection) @@ -9911,9 +10237,9 @@

                            Inherited members

                            Expand source code
                            class SmsAndChatsSync(NonDeletableFolderMixin, Folder):
                            -    CONTAINER_CLASS = 'IPF.SmsAndChatsSync'
                            +    CONTAINER_CLASS = "IPF.SmsAndChatsSync"
                                 LOCALIZED_NAMES = {
                            -        None: ('SmsAndChatsSync',),
                            +        None: ("SmsAndChatsSync",),
                                 }

                            Ancestors

                            @@ -9988,7 +10314,7 @@

                            Inherited members

                            class SpoolerQueue(NonDeletableFolderMixin, Folder):
                                 LOCALIZED_NAMES = {
                            -        None: ('Spooler Queue',),
                            +        None: ("Spooler Queue",),
                                 }

                            Ancestors

                            @@ -10058,8 +10384,8 @@

                            Inherited members

                            Expand source code
                            class SyncIssues(WellknownFolder):
                            -    CONTAINER_CLASS = 'IPF.Note'
                            -    DISTINGUISHED_FOLDER_ID = 'syncissues'
                            +    CONTAINER_CLASS = "IPF.Note"
                            +    DISTINGUISHED_FOLDER_ID = "syncissues"
                                 supported_from = EXCHANGE_2013

                            Ancestors

                            @@ -10138,7 +10464,7 @@

                            Inherited members

                            class System(NonDeletableFolderMixin, Folder):
                                 LOCALIZED_NAMES = {
                            -        None: ('System',),
                            +        None: ("System",),
                                 }
                                 get_folder_allowed = False
                            @@ -10213,20 +10539,20 @@

                            Inherited members

                            Expand source code
                            class Tasks(Folder):
                            -    DISTINGUISHED_FOLDER_ID = 'tasks'
                            -    CONTAINER_CLASS = 'IPF.Task'
                            +    DISTINGUISHED_FOLDER_ID = "tasks"
                            +    CONTAINER_CLASS = "IPF.Task"
                                 supported_item_models = (Task,)
                             
                                 LOCALIZED_NAMES = {
                            -        'da_DK': ('Opgaver',),
                            -        'de_DE': ('Aufgaben',),
                            -        'en_US': ('Tasks',),
                            -        'es_ES': ('Tareas',),
                            -        'fr_CA': ('Tâches',),
                            -        'nl_NL': ('Taken',),
                            -        'ru_RU': ('Задачи',),
                            -        'sv_SE': ('Uppgifter',),
                            -        'zh_CN': ('任务',),
                            +        "da_DK": ("Opgaver",),
                            +        "de_DE": ("Aufgaben",),
                            +        "en_US": ("Tasks",),
                            +        "es_ES": ("Tareas",),
                            +        "fr_CA": ("Tâches",),
                            +        "nl_NL": ("Taken",),
                            +        "ru_RU": ("Задачи",),
                            +        "sv_SE": ("Uppgifter",),
                            +        "zh_CN": ("任务",),
                                 }

                            Ancestors

                            @@ -10308,7 +10634,7 @@

                            Inherited members

                            class TemporarySaves(NonDeletableFolderMixin, Folder):
                                 LOCALIZED_NAMES = {
                            -        None: ('TemporarySaves',),
                            +        None: ("TemporarySaves",),
                                 }

                            Ancestors

                            @@ -10378,12 +10704,12 @@

                            Inherited members

                            Expand source code
                            class ToDoSearch(WellknownFolder):
                            -    CONTAINER_CLASS = 'IPF.Task'
                            -    DISTINGUISHED_FOLDER_ID = 'todosearch'
                            +    CONTAINER_CLASS = "IPF.Task"
                            +    DISTINGUISHED_FOLDER_ID = "todosearch"
                                 supported_from = EXCHANGE_2013
                             
                                 LOCALIZED_NAMES = {
                            -        None: ('To-Do Search',),
                            +        None: ("To-Do Search",),
                                 }

                            Ancestors

                            @@ -10466,7 +10792,7 @@

                            Inherited members

                            class Views(NonDeletableFolderMixin, Folder):
                                 LOCALIZED_NAMES = {
                            -        None: ('Views',),
                            +        None: ("Views",),
                                 }

                            Ancestors

                            @@ -10536,10 +10862,10 @@

                            Inherited members

                            Expand source code
                            class VoiceMail(WellknownFolder):
                            -    DISTINGUISHED_FOLDER_ID = 'voicemail'
                            -    CONTAINER_CLASS = 'IPF.Note.Microsoft.Voicemail'
                            +    DISTINGUISHED_FOLDER_ID = "voicemail"
                            +    CONTAINER_CLASS = "IPF.Note.Microsoft.Voicemail"
                                 LOCALIZED_NAMES = {
                            -        None: ('Voice Mail',),
                            +        None: ("Voice Mail",),
                                 }

                            Ancestors

                            @@ -10720,7 +11046,7 @@

                            Inherited members

                            class WorkingSet(NonDeletableFolderMixin, Folder):
                                 LOCALIZED_NAMES = {
                            -        None: ('Working Set',),
                            +        None: ("Working Set",),
                                 }

                            Ancestors

                            diff --git a/docs/exchangelib/folders/known_folders.html b/docs/exchangelib/folders/known_folders.html index 6fbd6162..f55eba1d 100644 --- a/docs/exchangelib/folders/known_folders.html +++ b/docs/exchangelib/folders/known_folders.html @@ -26,31 +26,41 @@

                            Module exchangelib.folders.known_folders

                            Expand source code -
                            from .base import Folder
                            -from .collections import FolderCollection
                            -from ..items import CalendarItem, Contact, Message, Task, DistributionList, MeetingRequest, MeetingResponse, \
                            -    MeetingCancellation, ITEM_CLASSES, ASSOCIATED
                            +
                            from ..items import (
                            +    ASSOCIATED,
                            +    ITEM_CLASSES,
                            +    CalendarItem,
                            +    Contact,
                            +    DistributionList,
                            +    MeetingCancellation,
                            +    MeetingRequest,
                            +    MeetingResponse,
                            +    Message,
                            +    Task,
                            +)
                             from ..properties import EWSMeta
                             from ..version import EXCHANGE_2010_SP1, EXCHANGE_2013, EXCHANGE_2013_SP1
                            +from .base import Folder
                            +from .collections import FolderCollection
                             
                             
                             class Calendar(Folder):
                                 """An interface for the Exchange calendar."""
                             
                            -    DISTINGUISHED_FOLDER_ID = 'calendar'
                            -    CONTAINER_CLASS = 'IPF.Appointment'
                            +    DISTINGUISHED_FOLDER_ID = "calendar"
                            +    CONTAINER_CLASS = "IPF.Appointment"
                                 supported_item_models = (CalendarItem,)
                             
                                 LOCALIZED_NAMES = {
                            -        'da_DK': ('Kalender',),
                            -        'de_DE': ('Kalender',),
                            -        'en_US': ('Calendar',),
                            -        'es_ES': ('Calendario',),
                            -        'fr_CA': ('Calendrier',),
                            -        'nl_NL': ('Agenda',),
                            -        'ru_RU': ('Календарь',),
                            -        'sv_SE': ('Kalender',),
                            -        'zh_CN': ('日历',),
                            +        "da_DK": ("Kalender",),
                            +        "de_DE": ("Kalender",),
                            +        "en_US": ("Calendar",),
                            +        "es_ES": ("Calendario",),
                            +        "fr_CA": ("Calendrier",),
                            +        "nl_NL": ("Agenda",),
                            +        "ru_RU": ("Календарь",),
                            +        "sv_SE": ("Kalender",),
                            +        "zh_CN": ("日历",),
                                 }
                             
                                 def view(self, *args, **kwargs):
                            @@ -58,141 +68,141 @@ 

                            Module exchangelib.folders.known_folders

                            class DeletedItems(Folder): - DISTINGUISHED_FOLDER_ID = 'deleteditems' - CONTAINER_CLASS = 'IPF.Note' + DISTINGUISHED_FOLDER_ID = "deleteditems" + CONTAINER_CLASS = "IPF.Note" supported_item_models = ITEM_CLASSES LOCALIZED_NAMES = { - 'da_DK': ('Slettet post',), - 'de_DE': ('Gelöschte Elemente',), - 'en_US': ('Deleted Items',), - 'es_ES': ('Elementos eliminados',), - 'fr_CA': ('Éléments supprimés',), - 'nl_NL': ('Verwijderde items',), - 'ru_RU': ('Удаленные',), - 'sv_SE': ('Borttaget',), - 'zh_CN': ('已删除邮件',), + "da_DK": ("Slettet post",), + "de_DE": ("Gelöschte Elemente",), + "en_US": ("Deleted Items",), + "es_ES": ("Elementos eliminados",), + "fr_CA": ("Éléments supprimés",), + "nl_NL": ("Verwijderde items",), + "ru_RU": ("Удаленные",), + "sv_SE": ("Borttaget",), + "zh_CN": ("已删除邮件",), } class Messages(Folder): - CONTAINER_CLASS = 'IPF.Note' + CONTAINER_CLASS = "IPF.Note" supported_item_models = (Message, MeetingRequest, MeetingResponse, MeetingCancellation) class Drafts(Messages): - DISTINGUISHED_FOLDER_ID = 'drafts' + DISTINGUISHED_FOLDER_ID = "drafts" LOCALIZED_NAMES = { - 'da_DK': ('Kladder',), - 'de_DE': ('Entwürfe',), - 'en_US': ('Drafts',), - 'es_ES': ('Borradores',), - 'fr_CA': ('Brouillons',), - 'nl_NL': ('Concepten',), - 'ru_RU': ('Черновики',), - 'sv_SE': ('Utkast',), - 'zh_CN': ('草稿',), + "da_DK": ("Kladder",), + "de_DE": ("Entwürfe",), + "en_US": ("Drafts",), + "es_ES": ("Borradores",), + "fr_CA": ("Brouillons",), + "nl_NL": ("Concepten",), + "ru_RU": ("Черновики",), + "sv_SE": ("Utkast",), + "zh_CN": ("草稿",), } class Inbox(Messages): - DISTINGUISHED_FOLDER_ID = 'inbox' + DISTINGUISHED_FOLDER_ID = "inbox" LOCALIZED_NAMES = { - 'da_DK': ('Indbakke',), - 'de_DE': ('Posteingang',), - 'en_US': ('Inbox',), - 'es_ES': ('Bandeja de entrada',), - 'fr_CA': ('Boîte de réception',), - 'nl_NL': ('Postvak IN',), - 'ru_RU': ('Входящие',), - 'sv_SE': ('Inkorgen',), - 'zh_CN': ('收件箱',), + "da_DK": ("Indbakke",), + "de_DE": ("Posteingang",), + "en_US": ("Inbox",), + "es_ES": ("Bandeja de entrada",), + "fr_CA": ("Boîte de réception",), + "nl_NL": ("Postvak IN",), + "ru_RU": ("Входящие",), + "sv_SE": ("Inkorgen",), + "zh_CN": ("收件箱",), } class Outbox(Messages): - DISTINGUISHED_FOLDER_ID = 'outbox' + DISTINGUISHED_FOLDER_ID = "outbox" LOCALIZED_NAMES = { - 'da_DK': ('Udbakke',), - 'de_DE': ('Postausgang',), - 'en_US': ('Outbox',), - 'es_ES': ('Bandeja de salida',), - 'fr_CA': (u"Boîte d'envoi",), - 'nl_NL': ('Postvak UIT',), - 'ru_RU': ('Исходящие',), - 'sv_SE': ('Utkorgen',), - 'zh_CN': ('发件箱',), + "da_DK": ("Udbakke",), + "de_DE": ("Postausgang",), + "en_US": ("Outbox",), + "es_ES": ("Bandeja de salida",), + "fr_CA": (u"Boîte d'envoi",), + "nl_NL": ("Postvak UIT",), + "ru_RU": ("Исходящие",), + "sv_SE": ("Utkorgen",), + "zh_CN": ("发件箱",), } class SentItems(Messages): - DISTINGUISHED_FOLDER_ID = 'sentitems' + DISTINGUISHED_FOLDER_ID = "sentitems" LOCALIZED_NAMES = { - 'da_DK': ('Sendt post',), - 'de_DE': ('Gesendete Elemente',), - 'en_US': ('Sent Items',), - 'es_ES': ('Elementos enviados',), - 'fr_CA': ('Éléments envoyés',), - 'nl_NL': ('Verzonden items',), - 'ru_RU': ('Отправленные',), - 'sv_SE': ('Skickat',), - 'zh_CN': ('已发送邮件',), + "da_DK": ("Sendt post",), + "de_DE": ("Gesendete Elemente",), + "en_US": ("Sent Items",), + "es_ES": ("Elementos enviados",), + "fr_CA": ("Éléments envoyés",), + "nl_NL": ("Verzonden items",), + "ru_RU": ("Отправленные",), + "sv_SE": ("Skickat",), + "zh_CN": ("已发送邮件",), } class JunkEmail(Messages): - DISTINGUISHED_FOLDER_ID = 'junkemail' + DISTINGUISHED_FOLDER_ID = "junkemail" LOCALIZED_NAMES = { - 'da_DK': ('Uønsket e-mail',), - 'de_DE': ('Junk-E-Mail',), - 'en_US': ('Junk E-mail',), - 'es_ES': ('Correo no deseado',), - 'fr_CA': ('Courrier indésirables',), - 'nl_NL': ('Ongewenste e-mail',), - 'ru_RU': ('Нежелательная почта',), - 'sv_SE': ('Skräppost',), - 'zh_CN': ('垃圾邮件',), + "da_DK": ("Uønsket e-mail",), + "de_DE": ("Junk-E-Mail",), + "en_US": ("Junk E-mail",), + "es_ES": ("Correo no deseado",), + "fr_CA": ("Courrier indésirables",), + "nl_NL": ("Ongewenste e-mail",), + "ru_RU": ("Нежелательная почта",), + "sv_SE": ("Skräppost",), + "zh_CN": ("垃圾邮件",), } class Tasks(Folder): - DISTINGUISHED_FOLDER_ID = 'tasks' - CONTAINER_CLASS = 'IPF.Task' + DISTINGUISHED_FOLDER_ID = "tasks" + CONTAINER_CLASS = "IPF.Task" supported_item_models = (Task,) LOCALIZED_NAMES = { - 'da_DK': ('Opgaver',), - 'de_DE': ('Aufgaben',), - 'en_US': ('Tasks',), - 'es_ES': ('Tareas',), - 'fr_CA': ('Tâches',), - 'nl_NL': ('Taken',), - 'ru_RU': ('Задачи',), - 'sv_SE': ('Uppgifter',), - 'zh_CN': ('任务',), + "da_DK": ("Opgaver",), + "de_DE": ("Aufgaben",), + "en_US": ("Tasks",), + "es_ES": ("Tareas",), + "fr_CA": ("Tâches",), + "nl_NL": ("Taken",), + "ru_RU": ("Задачи",), + "sv_SE": ("Uppgifter",), + "zh_CN": ("任务",), } class Contacts(Folder): - DISTINGUISHED_FOLDER_ID = 'contacts' - CONTAINER_CLASS = 'IPF.Contact' + DISTINGUISHED_FOLDER_ID = "contacts" + CONTAINER_CLASS = "IPF.Contact" supported_item_models = (Contact, DistributionList) LOCALIZED_NAMES = { - 'da_DK': ('Kontaktpersoner',), - 'de_DE': ('Kontakte',), - 'en_US': ('Contacts',), - 'es_ES': ('Contactos',), - 'fr_CA': ('Contacts',), - 'nl_NL': ('Contactpersonen',), - 'ru_RU': ('Контакты',), - 'sv_SE': ('Kontakter',), - 'zh_CN': ('联系人',), + "da_DK": ("Kontaktpersoner",), + "de_DE": ("Kontakte",), + "en_US": ("Contacts",), + "es_ES": ("Contactos",), + "fr_CA": ("Contacts",), + "nl_NL": ("Contactpersonen",), + "ru_RU": ("Контакты",), + "sv_SE": ("Kontakter",), + "zh_CN": ("联系人",), } @@ -203,175 +213,175 @@

                            Module exchangelib.folders.known_folders

                            class AdminAuditLogs(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'adminauditlogs' + DISTINGUISHED_FOLDER_ID = "adminauditlogs" supported_from = EXCHANGE_2013 get_folder_allowed = False class ArchiveDeletedItems(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'archivedeleteditems' + DISTINGUISHED_FOLDER_ID = "archivedeleteditems" supported_from = EXCHANGE_2010_SP1 class ArchiveInbox(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'archiveinbox' + DISTINGUISHED_FOLDER_ID = "archiveinbox" supported_from = EXCHANGE_2013_SP1 class ArchiveMsgFolderRoot(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'archivemsgfolderroot' + DISTINGUISHED_FOLDER_ID = "archivemsgfolderroot" supported_from = EXCHANGE_2010_SP1 class ArchiveRecoverableItemsDeletions(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'archiverecoverableitemsdeletions' + DISTINGUISHED_FOLDER_ID = "archiverecoverableitemsdeletions" supported_from = EXCHANGE_2010_SP1 class ArchiveRecoverableItemsPurges(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'archiverecoverableitemspurges' + DISTINGUISHED_FOLDER_ID = "archiverecoverableitemspurges" supported_from = EXCHANGE_2010_SP1 class ArchiveRecoverableItemsRoot(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'archiverecoverableitemsroot' + DISTINGUISHED_FOLDER_ID = "archiverecoverableitemsroot" supported_from = EXCHANGE_2010_SP1 class ArchiveRecoverableItemsVersions(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'archiverecoverableitemsversions' + DISTINGUISHED_FOLDER_ID = "archiverecoverableitemsversions" supported_from = EXCHANGE_2010_SP1 class Conflicts(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'conflicts' + DISTINGUISHED_FOLDER_ID = "conflicts" supported_from = EXCHANGE_2013 class ConversationHistory(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'conversationhistory' + DISTINGUISHED_FOLDER_ID = "conversationhistory" supported_from = EXCHANGE_2013 class Directory(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'directory' + DISTINGUISHED_FOLDER_ID = "directory" supported_from = EXCHANGE_2013_SP1 class Favorites(WellknownFolder): - CONTAINER_CLASS = 'IPF.Note' - DISTINGUISHED_FOLDER_ID = 'favorites' + CONTAINER_CLASS = "IPF.Note" + DISTINGUISHED_FOLDER_ID = "favorites" supported_from = EXCHANGE_2013 class IMContactList(WellknownFolder): - CONTAINER_CLASS = 'IPF.Contact.MOC.ImContactList' - DISTINGUISHED_FOLDER_ID = 'imcontactlist' + CONTAINER_CLASS = "IPF.Contact.MOC.ImContactList" + DISTINGUISHED_FOLDER_ID = "imcontactlist" supported_from = EXCHANGE_2013 class Journal(WellknownFolder): - CONTAINER_CLASS = 'IPF.Journal' - DISTINGUISHED_FOLDER_ID = 'journal' + CONTAINER_CLASS = "IPF.Journal" + DISTINGUISHED_FOLDER_ID = "journal" class LocalFailures(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'localfailures' + DISTINGUISHED_FOLDER_ID = "localfailures" supported_from = EXCHANGE_2013 class MsgFolderRoot(WellknownFolder): """Also known as the 'Top of Information Store' folder.""" - DISTINGUISHED_FOLDER_ID = 'msgfolderroot' + DISTINGUISHED_FOLDER_ID = "msgfolderroot" LOCALIZED_NAMES = { - 'zh_CN': ('信息存储顶部',), + "zh_CN": ("信息存储顶部",), } class MyContacts(WellknownFolder): - CONTAINER_CLASS = 'IPF.Note' - DISTINGUISHED_FOLDER_ID = 'mycontacts' + CONTAINER_CLASS = "IPF.Note" + DISTINGUISHED_FOLDER_ID = "mycontacts" supported_from = EXCHANGE_2013 class Notes(WellknownFolder): - CONTAINER_CLASS = 'IPF.StickyNote' - DISTINGUISHED_FOLDER_ID = 'notes' + CONTAINER_CLASS = "IPF.StickyNote" + DISTINGUISHED_FOLDER_ID = "notes" LOCALIZED_NAMES = { - 'da_DK': ('Noter',), + "da_DK": ("Noter",), } class PeopleConnect(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'peopleconnect' + DISTINGUISHED_FOLDER_ID = "peopleconnect" supported_from = EXCHANGE_2013 class QuickContacts(WellknownFolder): - CONTAINER_CLASS = 'IPF.Contact.MOC.QuickContacts' - DISTINGUISHED_FOLDER_ID = 'quickcontacts' + CONTAINER_CLASS = "IPF.Contact.MOC.QuickContacts" + DISTINGUISHED_FOLDER_ID = "quickcontacts" supported_from = EXCHANGE_2013 class RecipientCache(Contacts): - DISTINGUISHED_FOLDER_ID = 'recipientcache' - CONTAINER_CLASS = 'IPF.Contact.RecipientCache' + DISTINGUISHED_FOLDER_ID = "recipientcache" + CONTAINER_CLASS = "IPF.Contact.RecipientCache" supported_from = EXCHANGE_2013 LOCALIZED_NAMES = {} class RecoverableItemsDeletions(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'recoverableitemsdeletions' + DISTINGUISHED_FOLDER_ID = "recoverableitemsdeletions" supported_from = EXCHANGE_2010_SP1 class RecoverableItemsPurges(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'recoverableitemspurges' + DISTINGUISHED_FOLDER_ID = "recoverableitemspurges" supported_from = EXCHANGE_2010_SP1 class RecoverableItemsRoot(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'recoverableitemsroot' + DISTINGUISHED_FOLDER_ID = "recoverableitemsroot" supported_from = EXCHANGE_2010_SP1 class RecoverableItemsVersions(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'recoverableitemsversions' + DISTINGUISHED_FOLDER_ID = "recoverableitemsversions" supported_from = EXCHANGE_2010_SP1 class SearchFolders(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'searchfolders' + DISTINGUISHED_FOLDER_ID = "searchfolders" class ServerFailures(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'serverfailures' + DISTINGUISHED_FOLDER_ID = "serverfailures" supported_from = EXCHANGE_2013 class SyncIssues(WellknownFolder): - CONTAINER_CLASS = 'IPF.Note' - DISTINGUISHED_FOLDER_ID = 'syncissues' + CONTAINER_CLASS = "IPF.Note" + DISTINGUISHED_FOLDER_ID = "syncissues" supported_from = EXCHANGE_2013 class ToDoSearch(WellknownFolder): - CONTAINER_CLASS = 'IPF.Task' - DISTINGUISHED_FOLDER_ID = 'todosearch' + CONTAINER_CLASS = "IPF.Task" + DISTINGUISHED_FOLDER_ID = "todosearch" supported_from = EXCHANGE_2013 LOCALIZED_NAMES = { - None: ('To-Do Search',), + None: ("To-Do Search",), } class VoiceMail(WellknownFolder): - DISTINGUISHED_FOLDER_ID = 'voicemail' - CONTAINER_CLASS = 'IPF.Note.Microsoft.Voicemail' + DISTINGUISHED_FOLDER_ID = "voicemail" + CONTAINER_CLASS = "IPF.Note.Microsoft.Voicemail" LOCALIZED_NAMES = { - None: ('Voice Mail',), + None: ("Voice Mail",), } @@ -384,251 +394,251 @@

                            Module exchangelib.folders.known_folders

                            class AllContacts(NonDeletableFolderMixin, Contacts): - CONTAINER_CLASS = 'IPF.Note' + CONTAINER_CLASS = "IPF.Note" LOCALIZED_NAMES = { - None: ('AllContacts',), + None: ("AllContacts",), } class AllItems(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'IPF' + CONTAINER_CLASS = "IPF" LOCALIZED_NAMES = { - None: ('AllItems',), + None: ("AllItems",), } class Audits(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('Audits',), + None: ("Audits",), } get_folder_allowed = False class CalendarLogging(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('Calendar Logging',), + None: ("Calendar Logging",), } class CommonViews(NonDeletableFolderMixin, Folder): DEFAULT_ITEM_TRAVERSAL_DEPTH = ASSOCIATED LOCALIZED_NAMES = { - None: ('Common Views',), + None: ("Common Views",), } class Companies(NonDeletableFolderMixin, Contacts): DISTINGUISHED_FOLDER_ID = None - CONTAINTER_CLASS = 'IPF.Contact.Company' + CONTAINTER_CLASS = "IPF.Contact.Company" LOCALIZED_NAMES = { - None: ('Companies',), + None: ("Companies",), } class ConversationSettings(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'IPF.Configuration' + CONTAINER_CLASS = "IPF.Configuration" LOCALIZED_NAMES = { - 'da_DK': ('Indstillinger for samtalehandlinger',), + "da_DK": ("Indstillinger for samtalehandlinger",), } class DefaultFoldersChangeHistory(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'IPM.DefaultFolderHistoryItem' + CONTAINER_CLASS = "IPM.DefaultFolderHistoryItem" LOCALIZED_NAMES = { - None: ('DefaultFoldersChangeHistory',), + None: ("DefaultFoldersChangeHistory",), } class DeferredAction(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('Deferred Action',), + None: ("Deferred Action",), } class ExchangeSyncData(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('ExchangeSyncData',), + None: ("ExchangeSyncData",), } class Files(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'IPF.Files' + CONTAINER_CLASS = "IPF.Files" LOCALIZED_NAMES = { - 'da_DK': ('Filer',), + "da_DK": ("Filer",), } class FreebusyData(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('Freebusy Data',), + None: ("Freebusy Data",), } class Friends(NonDeletableFolderMixin, Contacts): - CONTAINER_CLASS = 'IPF.Note' + CONTAINER_CLASS = "IPF.Note" LOCALIZED_NAMES = { - 'de_DE': ('Bekannte',), + "de_DE": ("Bekannte",), } class GALContacts(NonDeletableFolderMixin, Contacts): DISTINGUISHED_FOLDER_ID = None - CONTAINER_CLASS = 'IPF.Contact.GalContacts' + CONTAINER_CLASS = "IPF.Contact.GalContacts" LOCALIZED_NAMES = { - None: ('GAL Contacts',), + None: ("GAL Contacts",), } class GraphAnalytics(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'IPF.StoreItem.GraphAnalytics' + CONTAINER_CLASS = "IPF.StoreItem.GraphAnalytics" LOCALIZED_NAMES = { - None: ('GraphAnalytics',), + None: ("GraphAnalytics",), } class Location(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('Location',), + None: ("Location",), } class MailboxAssociations(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('MailboxAssociations',), + None: ("MailboxAssociations",), } class MyContactsExtended(NonDeletableFolderMixin, Contacts): - CONTAINER_CLASS = 'IPF.Note' + CONTAINER_CLASS = "IPF.Note" LOCALIZED_NAMES = { - None: ('MyContactsExtended',), + None: ("MyContactsExtended",), } class OrganizationalContacts(NonDeletableFolderMixin, Contacts): DISTINGUISHED_FOLDER_ID = None - CONTAINTER_CLASS = 'IPF.Contact.OrganizationalContacts' + CONTAINTER_CLASS = "IPF.Contact.OrganizationalContacts" LOCALIZED_NAMES = { - None: ('Organizational Contacts',), + None: ("Organizational Contacts",), } class ParkedMessages(NonDeletableFolderMixin, Folder): CONTAINER_CLASS = None LOCALIZED_NAMES = { - None: ('ParkedMessages',), + None: ("ParkedMessages",), } class PassThroughSearchResults(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'IPF.StoreItem.PassThroughSearchResults' + CONTAINER_CLASS = "IPF.StoreItem.PassThroughSearchResults" LOCALIZED_NAMES = { - None: ('Pass-Through Search Results',), + None: ("Pass-Through Search Results",), } class PeopleCentricConversationBuddies(NonDeletableFolderMixin, Contacts): DISTINGUISHED_FOLDER_ID = None - CONTAINTER_CLASS = 'IPF.Contact.PeopleCentricConversationBuddies' + CONTAINTER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies" LOCALIZED_NAMES = { - None: ('PeopleCentricConversation Buddies',), + None: ("PeopleCentricConversation Buddies",), } class PdpProfileV2Secured(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'IPF.StoreItem.PdpProfileSecured' + CONTAINER_CLASS = "IPF.StoreItem.PdpProfileSecured" LOCALIZED_NAMES = { - None: ('PdpProfileV2Secured',), + None: ("PdpProfileV2Secured",), } class Reminders(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'Outlook.Reminder' + CONTAINER_CLASS = "Outlook.Reminder" LOCALIZED_NAMES = { - 'da_DK': ('Påmindelser',), + "da_DK": ("Påmindelser",), } class RSSFeeds(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'IPF.Note.OutlookHomepage' + CONTAINER_CLASS = "IPF.Note.OutlookHomepage" LOCALIZED_NAMES = { - None: ('RSS Feeds',), + None: ("RSS Feeds",), } class Schedule(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('Schedule',), + None: ("Schedule",), } class Sharing(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'IPF.Note' + CONTAINER_CLASS = "IPF.Note" LOCALIZED_NAMES = { - None: ('Sharing',), + None: ("Sharing",), } class Shortcuts(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('Shortcuts',), + None: ("Shortcuts",), } class Signal(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'IPF.StoreItem.Signal' + CONTAINER_CLASS = "IPF.StoreItem.Signal" LOCALIZED_NAMES = { - None: ('Signal',), + None: ("Signal",), } class SmsAndChatsSync(NonDeletableFolderMixin, Folder): - CONTAINER_CLASS = 'IPF.SmsAndChatsSync' + CONTAINER_CLASS = "IPF.SmsAndChatsSync" LOCALIZED_NAMES = { - None: ('SmsAndChatsSync',), + None: ("SmsAndChatsSync",), } class SpoolerQueue(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('Spooler Queue',), + None: ("Spooler Queue",), } class System(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('System',), + None: ("System",), } get_folder_allowed = False class System1(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('System1',), + None: ("System1",), } get_folder_allowed = False class TemporarySaves(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('TemporarySaves',), + None: ("TemporarySaves",), } class Views(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('Views',), + None: ("Views",), } class WorkingSet(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { - None: ('Working Set',), + None: ("Working Set",), } @@ -739,7 +749,7 @@

                            Classes

                            Expand source code
                            class AdminAuditLogs(WellknownFolder):
                            -    DISTINGUISHED_FOLDER_ID = 'adminauditlogs'
                            +    DISTINGUISHED_FOLDER_ID = "adminauditlogs"
                                 supported_from = EXCHANGE_2013
                                 get_folder_allowed = False
                            @@ -818,10 +828,10 @@

                            Inherited members

                            Expand source code
                            class AllContacts(NonDeletableFolderMixin, Contacts):
                            -    CONTAINER_CLASS = 'IPF.Note'
                            +    CONTAINER_CLASS = "IPF.Note"
                             
                                 LOCALIZED_NAMES = {
                            -        None: ('AllContacts',),
                            +        None: ("AllContacts",),
                                 }

                            Ancestors

                            @@ -896,10 +906,10 @@

                            Inherited members

                            Expand source code
                            class AllItems(NonDeletableFolderMixin, Folder):
                            -    CONTAINER_CLASS = 'IPF'
                            +    CONTAINER_CLASS = "IPF"
                             
                                 LOCALIZED_NAMES = {
                            -        None: ('AllItems',),
                            +        None: ("AllItems",),
                                 }

                            Ancestors

                            @@ -973,7 +983,7 @@

                            Inherited members

                            Expand source code
                            class ArchiveDeletedItems(WellknownFolder):
                            -    DISTINGUISHED_FOLDER_ID = 'archivedeleteditems'
                            +    DISTINGUISHED_FOLDER_ID = "archivedeleteditems"
                                 supported_from = EXCHANGE_2010_SP1

                            Ancestors

                            @@ -1047,7 +1057,7 @@

                            Inherited members

                            Expand source code
                            class ArchiveInbox(WellknownFolder):
                            -    DISTINGUISHED_FOLDER_ID = 'archiveinbox'
                            +    DISTINGUISHED_FOLDER_ID = "archiveinbox"
                                 supported_from = EXCHANGE_2013_SP1

                            Ancestors

                            @@ -1121,7 +1131,7 @@

                            Inherited members

                            Expand source code
                            class ArchiveMsgFolderRoot(WellknownFolder):
                            -    DISTINGUISHED_FOLDER_ID = 'archivemsgfolderroot'
                            +    DISTINGUISHED_FOLDER_ID = "archivemsgfolderroot"
                                 supported_from = EXCHANGE_2010_SP1

                            Ancestors

                            @@ -1195,7 +1205,7 @@

                            Inherited members

                            Expand source code
                            class ArchiveRecoverableItemsDeletions(WellknownFolder):
                            -    DISTINGUISHED_FOLDER_ID = 'archiverecoverableitemsdeletions'
                            +    DISTINGUISHED_FOLDER_ID = "archiverecoverableitemsdeletions"
                                 supported_from = EXCHANGE_2010_SP1

                            Ancestors

                            @@ -1269,7 +1279,7 @@

                            Inherited members

                            Expand source code
                            class ArchiveRecoverableItemsPurges(WellknownFolder):
                            -    DISTINGUISHED_FOLDER_ID = 'archiverecoverableitemspurges'
                            +    DISTINGUISHED_FOLDER_ID = "archiverecoverableitemspurges"
                                 supported_from = EXCHANGE_2010_SP1

                            Ancestors

                            @@ -1343,7 +1353,7 @@

                            Inherited members

                            Expand source code
                            class ArchiveRecoverableItemsRoot(WellknownFolder):
                            -    DISTINGUISHED_FOLDER_ID = 'archiverecoverableitemsroot'
                            +    DISTINGUISHED_FOLDER_ID = "archiverecoverableitemsroot"
                                 supported_from = EXCHANGE_2010_SP1

                            Ancestors

                            @@ -1417,7 +1427,7 @@

                            Inherited members

                            Expand source code
                            class ArchiveRecoverableItemsVersions(WellknownFolder):
                            -    DISTINGUISHED_FOLDER_ID = 'archiverecoverableitemsversions'
                            +    DISTINGUISHED_FOLDER_ID = "archiverecoverableitemsversions"
                                 supported_from = EXCHANGE_2010_SP1

                            Ancestors

                            @@ -1492,7 +1502,7 @@

                            Inherited members

                            class Audits(NonDeletableFolderMixin, Folder):
                                 LOCALIZED_NAMES = {
                            -        None: ('Audits',),
                            +        None: ("Audits",),
                                 }
                                 get_folder_allowed = False
                            @@ -1569,20 +1579,20 @@

                            Inherited members

                            class Calendar(Folder):
                                 """An interface for the Exchange calendar."""
                             
                            -    DISTINGUISHED_FOLDER_ID = 'calendar'
                            -    CONTAINER_CLASS = 'IPF.Appointment'
                            +    DISTINGUISHED_FOLDER_ID = "calendar"
                            +    CONTAINER_CLASS = "IPF.Appointment"
                                 supported_item_models = (CalendarItem,)
                             
                                 LOCALIZED_NAMES = {
                            -        'da_DK': ('Kalender',),
                            -        'de_DE': ('Kalender',),
                            -        'en_US': ('Calendar',),
                            -        'es_ES': ('Calendario',),
                            -        'fr_CA': ('Calendrier',),
                            -        'nl_NL': ('Agenda',),
                            -        'ru_RU': ('Календарь',),
                            -        'sv_SE': ('Kalender',),
                            -        'zh_CN': ('日历',),
                            +        "da_DK": ("Kalender",),
                            +        "de_DE": ("Kalender",),
                            +        "en_US": ("Calendar",),
                            +        "es_ES": ("Calendario",),
                            +        "fr_CA": ("Calendrier",),
                            +        "nl_NL": ("Agenda",),
                            +        "ru_RU": ("Календарь",),
                            +        "sv_SE": ("Kalender",),
                            +        "zh_CN": ("日历",),
                                 }
                             
                                 def view(self, *args, **kwargs):
                            @@ -1683,7 +1693,7 @@ 

                            Inherited members

                            class CalendarLogging(NonDeletableFolderMixin, Folder):
                                 LOCALIZED_NAMES = {
                            -        None: ('Calendar Logging',),
                            +        None: ("Calendar Logging",),
                                 }

                            Ancestors

                            @@ -1755,7 +1765,7 @@

                            Inherited members

                            class CommonViews(NonDeletableFolderMixin, Folder):
                                 DEFAULT_ITEM_TRAVERSAL_DEPTH = ASSOCIATED
                                 LOCALIZED_NAMES = {
                            -        None: ('Common Views',),
                            +        None: ("Common Views",),
                                 }

                            Ancestors

                            @@ -1830,9 +1840,9 @@

                            Inherited members

                            class Companies(NonDeletableFolderMixin, Contacts):
                                 DISTINGUISHED_FOLDER_ID = None
                            -    CONTAINTER_CLASS = 'IPF.Contact.Company'
                            +    CONTAINTER_CLASS = "IPF.Contact.Company"
                                 LOCALIZED_NAMES = {
                            -        None: ('Companies',),
                            +        None: ("Companies",),
                                 }

                            Ancestors

                            @@ -1911,7 +1921,7 @@

                            Inherited members

                            Expand source code
                            class Conflicts(WellknownFolder):
                            -    DISTINGUISHED_FOLDER_ID = 'conflicts'
                            +    DISTINGUISHED_FOLDER_ID = "conflicts"
                                 supported_from = EXCHANGE_2013

                            Ancestors

                            @@ -1985,20 +1995,20 @@

                            Inherited members

                            Expand source code
                            class Contacts(Folder):
                            -    DISTINGUISHED_FOLDER_ID = 'contacts'
                            -    CONTAINER_CLASS = 'IPF.Contact'
                            +    DISTINGUISHED_FOLDER_ID = "contacts"
                            +    CONTAINER_CLASS = "IPF.Contact"
                                 supported_item_models = (Contact, DistributionList)
                             
                                 LOCALIZED_NAMES = {
                            -        'da_DK': ('Kontaktpersoner',),
                            -        'de_DE': ('Kontakte',),
                            -        'en_US': ('Contacts',),
                            -        'es_ES': ('Contactos',),
                            -        'fr_CA': ('Contacts',),
                            -        'nl_NL': ('Contactpersonen',),
                            -        'ru_RU': ('Контакты',),
                            -        'sv_SE': ('Kontakter',),
                            -        'zh_CN': ('联系人',),
                            +        "da_DK": ("Kontaktpersoner",),
                            +        "de_DE": ("Kontakte",),
                            +        "en_US": ("Contacts",),
                            +        "es_ES": ("Contactos",),
                            +        "fr_CA": ("Contacts",),
                            +        "nl_NL": ("Contactpersonen",),
                            +        "ru_RU": ("Контакты",),
                            +        "sv_SE": ("Kontakter",),
                            +        "zh_CN": ("联系人",),
                                 }

                            Ancestors

                            @@ -2090,7 +2100,7 @@

                            Inherited members

                            Expand source code
                            class ConversationHistory(WellknownFolder):
                            -    DISTINGUISHED_FOLDER_ID = 'conversationhistory'
                            +    DISTINGUISHED_FOLDER_ID = "conversationhistory"
                                 supported_from = EXCHANGE_2013

                            Ancestors

                            @@ -2164,9 +2174,9 @@

                            Inherited members

                            Expand source code
                            class ConversationSettings(NonDeletableFolderMixin, Folder):
                            -    CONTAINER_CLASS = 'IPF.Configuration'
                            +    CONTAINER_CLASS = "IPF.Configuration"
                                 LOCALIZED_NAMES = {
                            -        'da_DK': ('Indstillinger for samtalehandlinger',),
                            +        "da_DK": ("Indstillinger for samtalehandlinger",),
                                 }

                            Ancestors

                            @@ -2240,9 +2250,9 @@

                            Inherited members

                            Expand source code
                            class DefaultFoldersChangeHistory(NonDeletableFolderMixin, Folder):
                            -    CONTAINER_CLASS = 'IPM.DefaultFolderHistoryItem'
                            +    CONTAINER_CLASS = "IPM.DefaultFolderHistoryItem"
                                 LOCALIZED_NAMES = {
                            -        None: ('DefaultFoldersChangeHistory',),
                            +        None: ("DefaultFoldersChangeHistory",),
                                 }

                            Ancestors

                            @@ -2317,7 +2327,7 @@

                            Inherited members

                            class DeferredAction(NonDeletableFolderMixin, Folder):
                                 LOCALIZED_NAMES = {
                            -        None: ('Deferred Action',),
                            +        None: ("Deferred Action",),
                                 }

                            Ancestors

                            @@ -2387,20 +2397,20 @@

                            Inherited members

                            Expand source code
                            class DeletedItems(Folder):
                            -    DISTINGUISHED_FOLDER_ID = 'deleteditems'
                            -    CONTAINER_CLASS = 'IPF.Note'
                            +    DISTINGUISHED_FOLDER_ID = "deleteditems"
                            +    CONTAINER_CLASS = "IPF.Note"
                                 supported_item_models = ITEM_CLASSES
                             
                                 LOCALIZED_NAMES = {
                            -        'da_DK': ('Slettet post',),
                            -        'de_DE': ('Gelöschte Elemente',),
                            -        'en_US': ('Deleted Items',),
                            -        'es_ES': ('Elementos eliminados',),
                            -        'fr_CA': ('Éléments supprimés',),
                            -        'nl_NL': ('Verwijderde items',),
                            -        'ru_RU': ('Удаленные',),
                            -        'sv_SE': ('Borttaget',),
                            -        'zh_CN': ('已删除邮件',),
                            +        "da_DK": ("Slettet post",),
                            +        "de_DE": ("Gelöschte Elemente",),
                            +        "en_US": ("Deleted Items",),
                            +        "es_ES": ("Elementos eliminados",),
                            +        "fr_CA": ("Éléments supprimés",),
                            +        "nl_NL": ("Verwijderde items",),
                            +        "ru_RU": ("Удаленные",),
                            +        "sv_SE": ("Borttaget",),
                            +        "zh_CN": ("已删除邮件",),
                                 }

                            Ancestors

                            @@ -2481,7 +2491,7 @@

                            Inherited members

                            Expand source code
                            class Directory(WellknownFolder):
                            -    DISTINGUISHED_FOLDER_ID = 'directory'
                            +    DISTINGUISHED_FOLDER_ID = "directory"
                                 supported_from = EXCHANGE_2013_SP1

                            Ancestors

                            @@ -2555,18 +2565,18 @@

                            Inherited members

                            Expand source code
                            class Drafts(Messages):
                            -    DISTINGUISHED_FOLDER_ID = 'drafts'
                            +    DISTINGUISHED_FOLDER_ID = "drafts"
                             
                                 LOCALIZED_NAMES = {
                            -        'da_DK': ('Kladder',),
                            -        'de_DE': ('Entwürfe',),
                            -        'en_US': ('Drafts',),
                            -        'es_ES': ('Borradores',),
                            -        'fr_CA': ('Brouillons',),
                            -        'nl_NL': ('Concepten',),
                            -        'ru_RU': ('Черновики',),
                            -        'sv_SE': ('Utkast',),
                            -        'zh_CN': ('草稿',),
                            +        "da_DK": ("Kladder",),
                            +        "de_DE": ("Entwürfe",),
                            +        "en_US": ("Drafts",),
                            +        "es_ES": ("Borradores",),
                            +        "fr_CA": ("Brouillons",),
                            +        "nl_NL": ("Concepten",),
                            +        "ru_RU": ("Черновики",),
                            +        "sv_SE": ("Utkast",),
                            +        "zh_CN": ("草稿",),
                                 }

                            Ancestors

                            @@ -2641,7 +2651,7 @@

                            Inherited members

                            class ExchangeSyncData(NonDeletableFolderMixin, Folder):
                                 LOCALIZED_NAMES = {
                            -        None: ('ExchangeSyncData',),
                            +        None: ("ExchangeSyncData",),
                                 }

                            Ancestors

                            @@ -2711,8 +2721,8 @@

                            Inherited members

                            Expand source code
                            class Favorites(WellknownFolder):
                            -    CONTAINER_CLASS = 'IPF.Note'
                            -    DISTINGUISHED_FOLDER_ID = 'favorites'
                            +    CONTAINER_CLASS = "IPF.Note"
                            +    DISTINGUISHED_FOLDER_ID = "favorites"
                                 supported_from = EXCHANGE_2013

                            Ancestors

                            @@ -2790,10 +2800,10 @@

                            Inherited members

                            Expand source code
                            class Files(NonDeletableFolderMixin, Folder):
                            -    CONTAINER_CLASS = 'IPF.Files'
                            +    CONTAINER_CLASS = "IPF.Files"
                             
                                 LOCALIZED_NAMES = {
                            -        'da_DK': ('Filer',),
                            +        "da_DK": ("Filer",),
                                 }

                            Ancestors

                            @@ -2868,7 +2878,7 @@

                            Inherited members

                            class FreebusyData(NonDeletableFolderMixin, Folder):
                                 LOCALIZED_NAMES = {
                            -        None: ('Freebusy Data',),
                            +        None: ("Freebusy Data",),
                                 }

                            Ancestors

                            @@ -2938,10 +2948,10 @@

                            Inherited members

                            Expand source code
                            class Friends(NonDeletableFolderMixin, Contacts):
                            -    CONTAINER_CLASS = 'IPF.Note'
                            +    CONTAINER_CLASS = "IPF.Note"
                             
                                 LOCALIZED_NAMES = {
                            -        'de_DE': ('Bekannte',),
                            +        "de_DE": ("Bekannte",),
                                 }

                            Ancestors

                            @@ -3017,10 +3027,10 @@

                            Inherited members

                            class GALContacts(NonDeletableFolderMixin, Contacts):
                                 DISTINGUISHED_FOLDER_ID = None
                            -    CONTAINER_CLASS = 'IPF.Contact.GalContacts'
                            +    CONTAINER_CLASS = "IPF.Contact.GalContacts"
                             
                                 LOCALIZED_NAMES = {
                            -        None: ('GAL Contacts',),
                            +        None: ("GAL Contacts",),
                                 }

                            Ancestors

                            @@ -3099,9 +3109,9 @@

                            Inherited members

                            Expand source code
                            class GraphAnalytics(NonDeletableFolderMixin, Folder):
                            -    CONTAINER_CLASS = 'IPF.StoreItem.GraphAnalytics'
                            +    CONTAINER_CLASS = "IPF.StoreItem.GraphAnalytics"
                                 LOCALIZED_NAMES = {
                            -        None: ('GraphAnalytics',),
                            +        None: ("GraphAnalytics",),
                                 }

                            Ancestors

                            @@ -3175,8 +3185,8 @@

                            Inherited members

                            Expand source code
                            class IMContactList(WellknownFolder):
                            -    CONTAINER_CLASS = 'IPF.Contact.MOC.ImContactList'
                            -    DISTINGUISHED_FOLDER_ID = 'imcontactlist'
                            +    CONTAINER_CLASS = "IPF.Contact.MOC.ImContactList"
                            +    DISTINGUISHED_FOLDER_ID = "imcontactlist"
                                 supported_from = EXCHANGE_2013

                            Ancestors

                            @@ -3254,18 +3264,18 @@

                            Inherited members

                            Expand source code
                            class Inbox(Messages):
                            -    DISTINGUISHED_FOLDER_ID = 'inbox'
                            +    DISTINGUISHED_FOLDER_ID = "inbox"
                             
                                 LOCALIZED_NAMES = {
                            -        'da_DK': ('Indbakke',),
                            -        'de_DE': ('Posteingang',),
                            -        'en_US': ('Inbox',),
                            -        'es_ES': ('Bandeja de entrada',),
                            -        'fr_CA': ('Boîte de réception',),
                            -        'nl_NL': ('Postvak IN',),
                            -        'ru_RU': ('Входящие',),
                            -        'sv_SE': ('Inkorgen',),
                            -        'zh_CN': ('收件箱',),
                            +        "da_DK": ("Indbakke",),
                            +        "de_DE": ("Posteingang",),
                            +        "en_US": ("Inbox",),
                            +        "es_ES": ("Bandeja de entrada",),
                            +        "fr_CA": ("Boîte de réception",),
                            +        "nl_NL": ("Postvak IN",),
                            +        "ru_RU": ("Входящие",),
                            +        "sv_SE": ("Inkorgen",),
                            +        "zh_CN": ("收件箱",),
                                 }

                            Ancestors

                            @@ -3339,8 +3349,8 @@

                            Inherited members

                            Expand source code
                            class Journal(WellknownFolder):
                            -    CONTAINER_CLASS = 'IPF.Journal'
                            -    DISTINGUISHED_FOLDER_ID = 'journal'
                            + CONTAINER_CLASS = "IPF.Journal" + DISTINGUISHED_FOLDER_ID = "journal"

                            Ancestors

                              @@ -3413,18 +3423,18 @@

                              Inherited members

                              Expand source code
                              class JunkEmail(Messages):
                              -    DISTINGUISHED_FOLDER_ID = 'junkemail'
                              +    DISTINGUISHED_FOLDER_ID = "junkemail"
                               
                                   LOCALIZED_NAMES = {
                              -        'da_DK': ('Uønsket e-mail',),
                              -        'de_DE': ('Junk-E-Mail',),
                              -        'en_US': ('Junk E-mail',),
                              -        'es_ES': ('Correo no deseado',),
                              -        'fr_CA': ('Courrier indésirables',),
                              -        'nl_NL': ('Ongewenste e-mail',),
                              -        'ru_RU': ('Нежелательная почта',),
                              -        'sv_SE': ('Skräppost',),
                              -        'zh_CN': ('垃圾邮件',),
                              +        "da_DK": ("Uønsket e-mail",),
                              +        "de_DE": ("Junk-E-Mail",),
                              +        "en_US": ("Junk E-mail",),
                              +        "es_ES": ("Correo no deseado",),
                              +        "fr_CA": ("Courrier indésirables",),
                              +        "nl_NL": ("Ongewenste e-mail",),
                              +        "ru_RU": ("Нежелательная почта",),
                              +        "sv_SE": ("Skräppost",),
                              +        "zh_CN": ("垃圾邮件",),
                                   }

                              Ancestors

                              @@ -3498,7 +3508,7 @@

                              Inherited members

                              Expand source code
                              class LocalFailures(WellknownFolder):
                              -    DISTINGUISHED_FOLDER_ID = 'localfailures'
                              +    DISTINGUISHED_FOLDER_ID = "localfailures"
                                   supported_from = EXCHANGE_2013

                              Ancestors

                              @@ -3573,7 +3583,7 @@

                              Inherited members

                              class Location(NonDeletableFolderMixin, Folder):
                                   LOCALIZED_NAMES = {
                              -        None: ('Location',),
                              +        None: ("Location",),
                                   }

                              Ancestors

                              @@ -3644,7 +3654,7 @@

                              Inherited members

                              class MailboxAssociations(NonDeletableFolderMixin, Folder):
                                   LOCALIZED_NAMES = {
                              -        None: ('MailboxAssociations',),
                              +        None: ("MailboxAssociations",),
                                   }

                              Ancestors

                              @@ -3714,7 +3724,7 @@

                              Inherited members

                              Expand source code
                              class Messages(Folder):
                              -    CONTAINER_CLASS = 'IPF.Note'
                              +    CONTAINER_CLASS = "IPF.Note"
                                   supported_item_models = (Message, MeetingRequest, MeetingResponse, MeetingCancellation)

                              Ancestors

                              @@ -3797,9 +3807,9 @@

                              Inherited members

                              class MsgFolderRoot(WellknownFolder):
                                   """Also known as the 'Top of Information Store' folder."""
                               
                              -    DISTINGUISHED_FOLDER_ID = 'msgfolderroot'
                              +    DISTINGUISHED_FOLDER_ID = "msgfolderroot"
                                   LOCALIZED_NAMES = {
                              -        'zh_CN': ('信息存储顶部',),
                              +        "zh_CN": ("信息存储顶部",),
                                   }

                              Ancestors

                              @@ -3873,8 +3883,8 @@

                              Inherited members

                              Expand source code
                              class MyContacts(WellknownFolder):
                              -    CONTAINER_CLASS = 'IPF.Note'
                              -    DISTINGUISHED_FOLDER_ID = 'mycontacts'
                              +    CONTAINER_CLASS = "IPF.Note"
                              +    DISTINGUISHED_FOLDER_ID = "mycontacts"
                                   supported_from = EXCHANGE_2013

                              Ancestors

                              @@ -3952,9 +3962,9 @@

                              Inherited members

                              Expand source code
                              class MyContactsExtended(NonDeletableFolderMixin, Contacts):
                              -    CONTAINER_CLASS = 'IPF.Note'
                              +    CONTAINER_CLASS = "IPF.Note"
                                   LOCALIZED_NAMES = {
                              -        None: ('MyContactsExtended',),
                              +        None: ("MyContactsExtended",),
                                   }

                              Ancestors

                              @@ -4100,10 +4110,10 @@

                              Instance variables

                              Expand source code
                              class Notes(WellknownFolder):
                              -    CONTAINER_CLASS = 'IPF.StickyNote'
                              -    DISTINGUISHED_FOLDER_ID = 'notes'
                              +    CONTAINER_CLASS = "IPF.StickyNote"
                              +    DISTINGUISHED_FOLDER_ID = "notes"
                                   LOCALIZED_NAMES = {
                              -        'da_DK': ('Noter',),
                              +        "da_DK": ("Noter",),
                                   }

                              Ancestors

                              @@ -4182,9 +4192,9 @@

                              Inherited members

                              class OrganizationalContacts(NonDeletableFolderMixin, Contacts):
                                   DISTINGUISHED_FOLDER_ID = None
                              -    CONTAINTER_CLASS = 'IPF.Contact.OrganizationalContacts'
                              +    CONTAINTER_CLASS = "IPF.Contact.OrganizationalContacts"
                                   LOCALIZED_NAMES = {
                              -        None: ('Organizational Contacts',),
                              +        None: ("Organizational Contacts",),
                                   }

                              Ancestors

                              @@ -4263,18 +4273,18 @@

                              Inherited members

                              Expand source code
                              class Outbox(Messages):
                              -    DISTINGUISHED_FOLDER_ID = 'outbox'
                              +    DISTINGUISHED_FOLDER_ID = "outbox"
                               
                                   LOCALIZED_NAMES = {
                              -        'da_DK': ('Udbakke',),
                              -        'de_DE': ('Postausgang',),
                              -        'en_US': ('Outbox',),
                              -        'es_ES': ('Bandeja de salida',),
                              -        'fr_CA': (u"Boîte d'envoi",),
                              -        'nl_NL': ('Postvak UIT',),
                              -        'ru_RU': ('Исходящие',),
                              -        'sv_SE': ('Utkorgen',),
                              -        'zh_CN': ('发件箱',),
                              +        "da_DK": ("Udbakke",),
                              +        "de_DE": ("Postausgang",),
                              +        "en_US": ("Outbox",),
                              +        "es_ES": ("Bandeja de salida",),
                              +        "fr_CA": (u"Boîte d'envoi",),
                              +        "nl_NL": ("Postvak UIT",),
                              +        "ru_RU": ("Исходящие",),
                              +        "sv_SE": ("Utkorgen",),
                              +        "zh_CN": ("发件箱",),
                                   }

                              Ancestors

                              @@ -4350,7 +4360,7 @@

                              Inherited members

                              class ParkedMessages(NonDeletableFolderMixin, Folder):
                                   CONTAINER_CLASS = None
                                   LOCALIZED_NAMES = {
                              -        None: ('ParkedMessages',),
                              +        None: ("ParkedMessages",),
                                   }

                              Ancestors

                              @@ -4424,9 +4434,9 @@

                              Inherited members

                              Expand source code
                              class PassThroughSearchResults(NonDeletableFolderMixin, Folder):
                              -    CONTAINER_CLASS = 'IPF.StoreItem.PassThroughSearchResults'
                              +    CONTAINER_CLASS = "IPF.StoreItem.PassThroughSearchResults"
                                   LOCALIZED_NAMES = {
                              -        None: ('Pass-Through Search Results',),
                              +        None: ("Pass-Through Search Results",),
                                   }

                              Ancestors

                              @@ -4500,9 +4510,9 @@

                              Inherited members

                              Expand source code
                              class PdpProfileV2Secured(NonDeletableFolderMixin, Folder):
                              -    CONTAINER_CLASS = 'IPF.StoreItem.PdpProfileSecured'
                              +    CONTAINER_CLASS = "IPF.StoreItem.PdpProfileSecured"
                                   LOCALIZED_NAMES = {
                              -        None: ('PdpProfileV2Secured',),
                              +        None: ("PdpProfileV2Secured",),
                                   }

                              Ancestors

                              @@ -4577,9 +4587,9 @@

                              Inherited members

                              class PeopleCentricConversationBuddies(NonDeletableFolderMixin, Contacts):
                                   DISTINGUISHED_FOLDER_ID = None
                              -    CONTAINTER_CLASS = 'IPF.Contact.PeopleCentricConversationBuddies'
                              +    CONTAINTER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies"
                                   LOCALIZED_NAMES = {
                              -        None: ('PeopleCentricConversation Buddies',),
                              +        None: ("PeopleCentricConversation Buddies",),
                                   }

                              Ancestors

                              @@ -4658,7 +4668,7 @@

                              Inherited members

                              Expand source code
                              class PeopleConnect(WellknownFolder):
                              -    DISTINGUISHED_FOLDER_ID = 'peopleconnect'
                              +    DISTINGUISHED_FOLDER_ID = "peopleconnect"
                                   supported_from = EXCHANGE_2013

                              Ancestors

                              @@ -4732,8 +4742,8 @@

                              Inherited members

                              Expand source code
                              class QuickContacts(WellknownFolder):
                              -    CONTAINER_CLASS = 'IPF.Contact.MOC.QuickContacts'
                              -    DISTINGUISHED_FOLDER_ID = 'quickcontacts'
                              +    CONTAINER_CLASS = "IPF.Contact.MOC.QuickContacts"
                              +    DISTINGUISHED_FOLDER_ID = "quickcontacts"
                                   supported_from = EXCHANGE_2013

                              Ancestors

                              @@ -4811,9 +4821,9 @@

                              Inherited members

                              Expand source code
                              class RSSFeeds(NonDeletableFolderMixin, Folder):
                              -    CONTAINER_CLASS = 'IPF.Note.OutlookHomepage'
                              +    CONTAINER_CLASS = "IPF.Note.OutlookHomepage"
                                   LOCALIZED_NAMES = {
                              -        None: ('RSS Feeds',),
                              +        None: ("RSS Feeds",),
                                   }

                              Ancestors

                              @@ -4887,8 +4897,8 @@

                              Inherited members

                              Expand source code
                              class RecipientCache(Contacts):
                              -    DISTINGUISHED_FOLDER_ID = 'recipientcache'
                              -    CONTAINER_CLASS = 'IPF.Contact.RecipientCache'
                              +    DISTINGUISHED_FOLDER_ID = "recipientcache"
                              +    CONTAINER_CLASS = "IPF.Contact.RecipientCache"
                                   supported_from = EXCHANGE_2013
                               
                                   LOCALIZED_NAMES = {}
                              @@ -4972,7 +4982,7 @@

                              Inherited members

                              Expand source code
                              class RecoverableItemsDeletions(WellknownFolder):
                              -    DISTINGUISHED_FOLDER_ID = 'recoverableitemsdeletions'
                              +    DISTINGUISHED_FOLDER_ID = "recoverableitemsdeletions"
                                   supported_from = EXCHANGE_2010_SP1

                              Ancestors

                              @@ -5046,7 +5056,7 @@

                              Inherited members

                              Expand source code
                              class RecoverableItemsPurges(WellknownFolder):
                              -    DISTINGUISHED_FOLDER_ID = 'recoverableitemspurges'
                              +    DISTINGUISHED_FOLDER_ID = "recoverableitemspurges"
                                   supported_from = EXCHANGE_2010_SP1

                              Ancestors

                              @@ -5120,7 +5130,7 @@

                              Inherited members

                              Expand source code
                              class RecoverableItemsRoot(WellknownFolder):
                              -    DISTINGUISHED_FOLDER_ID = 'recoverableitemsroot'
                              +    DISTINGUISHED_FOLDER_ID = "recoverableitemsroot"
                                   supported_from = EXCHANGE_2010_SP1

                              Ancestors

                              @@ -5194,7 +5204,7 @@

                              Inherited members

                              Expand source code
                              class RecoverableItemsVersions(WellknownFolder):
                              -    DISTINGUISHED_FOLDER_ID = 'recoverableitemsversions'
                              +    DISTINGUISHED_FOLDER_ID = "recoverableitemsversions"
                                   supported_from = EXCHANGE_2010_SP1

                              Ancestors

                              @@ -5268,9 +5278,9 @@

                              Inherited members

                              Expand source code
                              class Reminders(NonDeletableFolderMixin, Folder):
                              -    CONTAINER_CLASS = 'Outlook.Reminder'
                              +    CONTAINER_CLASS = "Outlook.Reminder"
                                   LOCALIZED_NAMES = {
                              -        'da_DK': ('Påmindelser',),
                              +        "da_DK": ("Påmindelser",),
                                   }

                              Ancestors

                              @@ -5345,7 +5355,7 @@

                              Inherited members

                              class Schedule(NonDeletableFolderMixin, Folder):
                                   LOCALIZED_NAMES = {
                              -        None: ('Schedule',),
                              +        None: ("Schedule",),
                                   }

                              Ancestors

                              @@ -5415,7 +5425,7 @@

                              Inherited members

                              Expand source code
                              class SearchFolders(WellknownFolder):
                              -    DISTINGUISHED_FOLDER_ID = 'searchfolders'
                              + DISTINGUISHED_FOLDER_ID = "searchfolders"

                            Ancestors

                              @@ -5484,18 +5494,18 @@

                              Inherited members

                              Expand source code
                              class SentItems(Messages):
                              -    DISTINGUISHED_FOLDER_ID = 'sentitems'
                              +    DISTINGUISHED_FOLDER_ID = "sentitems"
                               
                                   LOCALIZED_NAMES = {
                              -        'da_DK': ('Sendt post',),
                              -        'de_DE': ('Gesendete Elemente',),
                              -        'en_US': ('Sent Items',),
                              -        'es_ES': ('Elementos enviados',),
                              -        'fr_CA': ('Éléments envoyés',),
                              -        'nl_NL': ('Verzonden items',),
                              -        'ru_RU': ('Отправленные',),
                              -        'sv_SE': ('Skickat',),
                              -        'zh_CN': ('已发送邮件',),
                              +        "da_DK": ("Sendt post",),
                              +        "de_DE": ("Gesendete Elemente",),
                              +        "en_US": ("Sent Items",),
                              +        "es_ES": ("Elementos enviados",),
                              +        "fr_CA": ("Éléments envoyés",),
                              +        "nl_NL": ("Verzonden items",),
                              +        "ru_RU": ("Отправленные",),
                              +        "sv_SE": ("Skickat",),
                              +        "zh_CN": ("已发送邮件",),
                                   }

                              Ancestors

                              @@ -5569,7 +5579,7 @@

                              Inherited members

                              Expand source code
                              class ServerFailures(WellknownFolder):
                              -    DISTINGUISHED_FOLDER_ID = 'serverfailures'
                              +    DISTINGUISHED_FOLDER_ID = "serverfailures"
                                   supported_from = EXCHANGE_2013

                              Ancestors

                              @@ -5643,9 +5653,9 @@

                              Inherited members

                              Expand source code
                              class Sharing(NonDeletableFolderMixin, Folder):
                              -    CONTAINER_CLASS = 'IPF.Note'
                              +    CONTAINER_CLASS = "IPF.Note"
                                   LOCALIZED_NAMES = {
                              -        None: ('Sharing',),
                              +        None: ("Sharing",),
                                   }

                              Ancestors

                              @@ -5720,7 +5730,7 @@

                              Inherited members

                              class Shortcuts(NonDeletableFolderMixin, Folder):
                                   LOCALIZED_NAMES = {
                              -        None: ('Shortcuts',),
                              +        None: ("Shortcuts",),
                                   }

                              Ancestors

                              @@ -5790,9 +5800,9 @@

                              Inherited members

                              Expand source code
                              class Signal(NonDeletableFolderMixin, Folder):
                              -    CONTAINER_CLASS = 'IPF.StoreItem.Signal'
                              +    CONTAINER_CLASS = "IPF.StoreItem.Signal"
                                   LOCALIZED_NAMES = {
                              -        None: ('Signal',),
                              +        None: ("Signal",),
                                   }

                              Ancestors

                              @@ -5866,9 +5876,9 @@

                              Inherited members

                              Expand source code
                              class SmsAndChatsSync(NonDeletableFolderMixin, Folder):
                              -    CONTAINER_CLASS = 'IPF.SmsAndChatsSync'
                              +    CONTAINER_CLASS = "IPF.SmsAndChatsSync"
                                   LOCALIZED_NAMES = {
                              -        None: ('SmsAndChatsSync',),
                              +        None: ("SmsAndChatsSync",),
                                   }

                              Ancestors

                              @@ -5943,7 +5953,7 @@

                              Inherited members

                              class SpoolerQueue(NonDeletableFolderMixin, Folder):
                                   LOCALIZED_NAMES = {
                              -        None: ('Spooler Queue',),
                              +        None: ("Spooler Queue",),
                                   }

                              Ancestors

                              @@ -6013,8 +6023,8 @@

                              Inherited members

                              Expand source code
                              class SyncIssues(WellknownFolder):
                              -    CONTAINER_CLASS = 'IPF.Note'
                              -    DISTINGUISHED_FOLDER_ID = 'syncissues'
                              +    CONTAINER_CLASS = "IPF.Note"
                              +    DISTINGUISHED_FOLDER_ID = "syncissues"
                                   supported_from = EXCHANGE_2013

                              Ancestors

                              @@ -6093,7 +6103,7 @@

                              Inherited members

                              class System(NonDeletableFolderMixin, Folder):
                                   LOCALIZED_NAMES = {
                              -        None: ('System',),
                              +        None: ("System",),
                                   }
                                   get_folder_allowed = False
                              @@ -6169,7 +6179,7 @@

                              Inherited members

                              class System1(NonDeletableFolderMixin, Folder):
                                   LOCALIZED_NAMES = {
                              -        None: ('System1',),
                              +        None: ("System1",),
                                   }
                                   get_folder_allowed = False
                              @@ -6244,20 +6254,20 @@

                              Inherited members

                              Expand source code
                              class Tasks(Folder):
                              -    DISTINGUISHED_FOLDER_ID = 'tasks'
                              -    CONTAINER_CLASS = 'IPF.Task'
                              +    DISTINGUISHED_FOLDER_ID = "tasks"
                              +    CONTAINER_CLASS = "IPF.Task"
                                   supported_item_models = (Task,)
                               
                                   LOCALIZED_NAMES = {
                              -        'da_DK': ('Opgaver',),
                              -        'de_DE': ('Aufgaben',),
                              -        'en_US': ('Tasks',),
                              -        'es_ES': ('Tareas',),
                              -        'fr_CA': ('Tâches',),
                              -        'nl_NL': ('Taken',),
                              -        'ru_RU': ('Задачи',),
                              -        'sv_SE': ('Uppgifter',),
                              -        'zh_CN': ('任务',),
                              +        "da_DK": ("Opgaver",),
                              +        "de_DE": ("Aufgaben",),
                              +        "en_US": ("Tasks",),
                              +        "es_ES": ("Tareas",),
                              +        "fr_CA": ("Tâches",),
                              +        "nl_NL": ("Taken",),
                              +        "ru_RU": ("Задачи",),
                              +        "sv_SE": ("Uppgifter",),
                              +        "zh_CN": ("任务",),
                                   }

                              Ancestors

                              @@ -6339,7 +6349,7 @@

                              Inherited members

                              class TemporarySaves(NonDeletableFolderMixin, Folder):
                                   LOCALIZED_NAMES = {
                              -        None: ('TemporarySaves',),
                              +        None: ("TemporarySaves",),
                                   }

                              Ancestors

                              @@ -6409,12 +6419,12 @@

                              Inherited members

                              Expand source code
                              class ToDoSearch(WellknownFolder):
                              -    CONTAINER_CLASS = 'IPF.Task'
                              -    DISTINGUISHED_FOLDER_ID = 'todosearch'
                              +    CONTAINER_CLASS = "IPF.Task"
                              +    DISTINGUISHED_FOLDER_ID = "todosearch"
                                   supported_from = EXCHANGE_2013
                               
                                   LOCALIZED_NAMES = {
                              -        None: ('To-Do Search',),
                              +        None: ("To-Do Search",),
                                   }

                              Ancestors

                              @@ -6497,7 +6507,7 @@

                              Inherited members

                              class Views(NonDeletableFolderMixin, Folder):
                                   LOCALIZED_NAMES = {
                              -        None: ('Views',),
                              +        None: ("Views",),
                                   }

                              Ancestors

                              @@ -6567,10 +6577,10 @@

                              Inherited members

                              Expand source code
                              class VoiceMail(WellknownFolder):
                              -    DISTINGUISHED_FOLDER_ID = 'voicemail'
                              -    CONTAINER_CLASS = 'IPF.Note.Microsoft.Voicemail'
                              +    DISTINGUISHED_FOLDER_ID = "voicemail"
                              +    CONTAINER_CLASS = "IPF.Note.Microsoft.Voicemail"
                                   LOCALIZED_NAMES = {
                              -        None: ('Voice Mail',),
                              +        None: ("Voice Mail",),
                                   }

                              Ancestors

                              @@ -6751,7 +6761,7 @@

                              Inherited members

                              class WorkingSet(NonDeletableFolderMixin, Folder):
                                   LOCALIZED_NAMES = {
                              -        None: ('Working Set',),
                              +        None: ("Working Set",),
                                   }

                              Ancestors

                              diff --git a/docs/exchangelib/folders/queryset.html b/docs/exchangelib/folders/queryset.html index 2db1a571..b2b11337 100644 --- a/docs/exchangelib/folders/queryset.html +++ b/docs/exchangelib/folders/queryset.html @@ -29,15 +29,15 @@

                              Module exchangelib.folders.queryset

                              import logging
                               from copy import deepcopy
                               
                              -from ..errors import ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorItemNotFound, InvalidTypeError
                              -from ..properties import InvalidField, FolderId
                              +from ..errors import ErrorFolderNotFound, ErrorItemNotFound, ErrorNoPublicFolderReplicaAvailable, InvalidTypeError
                              +from ..properties import FolderId, InvalidField
                               from ..queryset import DoesNotExist, MultipleObjectsReturned
                               from ..restriction import Q
                               
                               # Traversal enums
                              -SHALLOW = 'Shallow'
                              -SOFT_DELETED = 'SoftDeleted'
                              -DEEP = 'Deep'
                              +SHALLOW = "Shallow"
                              +SOFT_DELETED = "SoftDeleted"
                              +DEEP = "Deep"
                               FOLDER_TRAVERSAL_CHOICES = (SHALLOW, DEEP, SOFT_DELETED)
                               
                               MISSING_FOLDER_ERRORS = (ErrorFolderNotFound, ErrorItemNotFound, ErrorNoPublicFolderReplicaAvailable)
                              @@ -51,8 +51,9 @@ 

                              Module exchangelib.folders.queryset

                              def __init__(self, folder_collection): from .collections import FolderCollection + if not isinstance(folder_collection, FolderCollection): - raise InvalidTypeError('folder_collection', folder_collection, FolderCollection) + raise InvalidTypeError("folder_collection", folder_collection, FolderCollection) self.folder_collection = folder_collection self.q = Q() # Default to no restrictions self.only_fields = None @@ -72,6 +73,7 @@

                              Module exchangelib.folders.queryset

                              def only(self, *args): """Restrict the fields returned. 'name' and 'folder_class' are always returned.""" from .base import Folder + # Subfolders will always be of class Folder all_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=None) all_fields.update(Folder.attribute_fields()) @@ -101,18 +103,19 @@

                              Module exchangelib.folders.queryset

                              MultipleObjectsReturned if there are multiple results. """ from .collections import FolderCollection - if not args and set(kwargs) in ({'id'}, {'id', 'changekey'}): - folders = list(FolderCollection( - account=self.folder_collection.account, folders=[FolderId(**kwargs)] - ).resolve()) + + if not args and set(kwargs) in ({"id"}, {"id", "changekey"}): + folders = list( + FolderCollection(account=self.folder_collection.account, folders=[FolderId(**kwargs)]).resolve() + ) elif args or kwargs: folders = list(self.filter(*args, **kwargs)) else: folders = list(self.all()) if not folders: - raise DoesNotExist('Could not find a child folder matching the query') + raise DoesNotExist("Could not find a child folder matching the query") if len(folders) != 1: - raise MultipleObjectsReturned(f'Expected result length 1, but got {folders}') + raise MultipleObjectsReturned(f"Expected result length 1, but got {folders}") f = folders[0] if isinstance(f, Exception): raise f @@ -136,6 +139,7 @@

                              Module exchangelib.folders.queryset

                              def _query(self): from .base import Folder from .collections import FolderCollection + if self.only_fields is None: # Subfolders will always be of class Folder non_complex_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=False) @@ -158,7 +162,7 @@

                              Module exchangelib.folders.queryset

                              yield f continue if not f.get_folder_allowed: - log.debug('GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder', f) + log.debug("GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder", f) yield f else: resolveable_folders.append(f) @@ -176,7 +180,7 @@

                              Module exchangelib.folders.queryset

                              continue # Add the extra field values to the folders we fetched with find_folders() if f.__class__ != complex_f.__class__: - raise ValueError(f'Type mismatch: {f} vs {complex_f}') + raise ValueError(f"Type mismatch: {f} vs {complex_f}") for complex_field in complex_fields: field_name = complex_field.field.name setattr(f, field_name, getattr(complex_f, field_name)) @@ -188,6 +192,7 @@

                              Module exchangelib.folders.queryset

                              def __init__(self, account, folder): from .collections import FolderCollection + folder_collection = FolderCollection(account=account, folders=[folder]) super().__init__(folder_collection=folder_collection) @@ -222,8 +227,9 @@

                              Classes

                              def __init__(self, folder_collection): from .collections import FolderCollection + if not isinstance(folder_collection, FolderCollection): - raise InvalidTypeError('folder_collection', folder_collection, FolderCollection) + raise InvalidTypeError("folder_collection", folder_collection, FolderCollection) self.folder_collection = folder_collection self.q = Q() # Default to no restrictions self.only_fields = None @@ -243,6 +249,7 @@

                              Classes

                              def only(self, *args): """Restrict the fields returned. 'name' and 'folder_class' are always returned.""" from .base import Folder + # Subfolders will always be of class Folder all_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=None) all_fields.update(Folder.attribute_fields()) @@ -272,18 +279,19 @@

                              Classes

                              MultipleObjectsReturned if there are multiple results. """ from .collections import FolderCollection - if not args and set(kwargs) in ({'id'}, {'id', 'changekey'}): - folders = list(FolderCollection( - account=self.folder_collection.account, folders=[FolderId(**kwargs)] - ).resolve()) + + if not args and set(kwargs) in ({"id"}, {"id", "changekey"}): + folders = list( + FolderCollection(account=self.folder_collection.account, folders=[FolderId(**kwargs)]).resolve() + ) elif args or kwargs: folders = list(self.filter(*args, **kwargs)) else: folders = list(self.all()) if not folders: - raise DoesNotExist('Could not find a child folder matching the query') + raise DoesNotExist("Could not find a child folder matching the query") if len(folders) != 1: - raise MultipleObjectsReturned(f'Expected result length 1, but got {folders}') + raise MultipleObjectsReturned(f"Expected result length 1, but got {folders}") f = folders[0] if isinstance(f, Exception): raise f @@ -307,6 +315,7 @@

                              Classes

                              def _query(self): from .base import Folder from .collections import FolderCollection + if self.only_fields is None: # Subfolders will always be of class Folder non_complex_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=False) @@ -329,7 +338,7 @@

                              Classes

                              yield f continue if not f.get_folder_allowed: - log.debug('GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder', f) + log.debug("GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder", f) yield f else: resolveable_folders.append(f) @@ -347,7 +356,7 @@

                              Classes

                              continue # Add the extra field values to the folders we fetched with find_folders() if f.__class__ != complex_f.__class__: - raise ValueError(f'Type mismatch: {f} vs {complex_f}') + raise ValueError(f"Type mismatch: {f} vs {complex_f}") for complex_field in complex_fields: field_name = complex_field.field.name setattr(f, field_name, getattr(complex_f, field_name)) @@ -426,18 +435,19 @@

                              Methods

                              MultipleObjectsReturned if there are multiple results. """ from .collections import FolderCollection - if not args and set(kwargs) in ({'id'}, {'id', 'changekey'}): - folders = list(FolderCollection( - account=self.folder_collection.account, folders=[FolderId(**kwargs)] - ).resolve()) + + if not args and set(kwargs) in ({"id"}, {"id", "changekey"}): + folders = list( + FolderCollection(account=self.folder_collection.account, folders=[FolderId(**kwargs)]).resolve() + ) elif args or kwargs: folders = list(self.filter(*args, **kwargs)) else: folders = list(self.all()) if not folders: - raise DoesNotExist('Could not find a child folder matching the query') + raise DoesNotExist("Could not find a child folder matching the query") if len(folders) != 1: - raise MultipleObjectsReturned(f'Expected result length 1, but got {folders}') + raise MultipleObjectsReturned(f"Expected result length 1, but got {folders}") f = folders[0] if isinstance(f, Exception): raise f @@ -456,6 +466,7 @@

                              Methods

                              def only(self, *args):
                                   """Restrict the fields returned. 'name' and 'folder_class' are always returned."""
                                   from .base import Folder
                              +
                                   # Subfolders will always be of class Folder
                                   all_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=None)
                                   all_fields.update(Folder.attribute_fields())
                              @@ -489,6 +500,7 @@ 

                              Methods

                              def __init__(self, account, folder): from .collections import FolderCollection + folder_collection = FolderCollection(account=account, folders=[folder]) super().__init__(folder_collection=folder_collection) diff --git a/docs/exchangelib/folders/roots.html b/docs/exchangelib/folders/roots.html index 90c38f46..557f7715 100644 --- a/docs/exchangelib/folders/roots.html +++ b/docs/exchangelib/folders/roots.html @@ -29,15 +29,19 @@

                              Module exchangelib.folders.roots

                              import logging
                               from threading import Lock
                               
                              -from .base import BaseFolder
                              -from .collections import FolderCollection
                              -from .known_folders import MsgFolderRoot, NON_DELETABLE_FOLDERS, WELLKNOWN_FOLDERS_IN_ROOT, \
                              -    WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT
                              -from .queryset import SingleFolderQuerySet, SHALLOW, MISSING_FOLDER_ERRORS
                               from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorInvalidOperation
                               from ..fields import EffectiveRightsField
                               from ..properties import EWSMeta
                               from ..version import EXCHANGE_2007_SP1, EXCHANGE_2010_SP1
                              +from .base import BaseFolder
                              +from .collections import FolderCollection
                              +from .known_folders import (
                              +    NON_DELETABLE_FOLDERS,
                              +    WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT,
                              +    WELLKNOWN_FOLDERS_IN_ROOT,
                              +    MsgFolderRoot,
                              +)
                              +from .queryset import MISSING_FOLDER_ERRORS, SHALLOW, SingleFolderQuerySet
                               
                               log = logging.getLogger(__name__)
                               
                              @@ -56,14 +60,15 @@ 

                              Module exchangelib.folders.roots

                              # This folder type also has 'folder:PermissionSet' on some server versions, but requesting it sometimes causes # 'ErrorAccessDenied', as reported by some users. Ignore it entirely for root folders - it's usefulness is # deemed minimal at best. - effective_rights = EffectiveRightsField(field_uri='folder:EffectiveRights', is_read_only=True, - supported_from=EXCHANGE_2007_SP1) + effective_rights = EffectiveRightsField( + field_uri="folder:EffectiveRights", is_read_only=True, supported_from=EXCHANGE_2007_SP1 + ) - __slots__ = '_account', '_subfolders' + __slots__ = "_account", "_subfolders" # A special folder that acts as the top of a folder hierarchy. Finds and caches subfolders at arbitrary depth. def __init__(self, **kwargs): - self._account = kwargs.pop('account', None) # A pointer back to the account holding the folder hierarchy + self._account = kwargs.pop("account", None) # A pointer back to the account holding the folder hierarchy super().__init__(**kwargs) self._subfolders = None # See self._folders_map() @@ -82,13 +87,13 @@

                              Module exchangelib.folders.roots

                              @classmethod def register(cls, *args, **kwargs): if cls is not RootOfHierarchy: - raise TypeError('For folder roots, custom fields must be registered on the RootOfHierarchy class') + raise TypeError("For folder roots, custom fields must be registered on the RootOfHierarchy class") return super().register(*args, **kwargs) @classmethod def deregister(cls, *args, **kwargs): if cls is not RootOfHierarchy: - raise TypeError('For folder roots, custom fields must be registered on the RootOfHierarchy class') + raise TypeError("For folder roots, custom fields must be registered on the RootOfHierarchy class") return super().deregister(*args, **kwargs) def get_folder(self, folder): @@ -132,14 +137,13 @@

                              Module exchangelib.folders.roots

                              :param account: """ if not cls.DISTINGUISHED_FOLDER_ID: - raise ValueError(f'Class {cls} must have a DISTINGUISHED_FOLDER_ID value') + raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: return cls.resolve( - account=account, - folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + account=account, folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}') + raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") def get_default_folder(self, folder_cls): """Return the distinguished folder instance of type folder_cls belonging to this account. If no distinguished @@ -153,21 +157,21 @@

                              Module exchangelib.folders.roots

                              for f in self._folders_map.values(): # Require exact class, to not match subclasses, e.g. RecipientCache instead of Contacts if f.__class__ == folder_cls and f.is_distinguished: - log.debug('Found cached distinguished %s folder', folder_cls) + log.debug("Found cached distinguished %s folder", folder_cls) return f try: - log.debug('Requesting distinguished %s folder explicitly', folder_cls) + log.debug("Requesting distinguished %s folder explicitly", folder_cls) return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItems instead - log.debug('Testing default %s folder with FindItem', folder_cls) + log.debug("Testing default %s folder with FindItem", folder_cls) fld = folder_cls(root=self, name=folder_cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: # The Exchange server does not return a distinguished folder of this type pass - raise ErrorFolderNotFound(f'No usable default {folder_cls} folders') + raise ErrorFolderNotFound(f"No usable default {folder_cls} folders") @property def _folders_map(self): @@ -198,9 +202,9 @@

                              Module exchangelib.folders.roots

                              if isinstance(f, Exception): raise f folders_map[f.id] = f - for f in SingleFolderQuerySet(account=self.account, folder=self).depth( - self.DEFAULT_FOLDER_TRAVERSAL_DEPTH - ).all(): + for f in ( + SingleFolderQuerySet(account=self.account, folder=self).depth(self.DEFAULT_FOLDER_TRAVERSAL_DEPTH).all() + ): if isinstance(f, ErrorAccessDenied): # We may not have FindFolder access, or GetFolder access, either to this folder or at all continue @@ -236,15 +240,25 @@

                              Module exchangelib.folders.roots

                              def __repr__(self): # Let's not create an infinite loop when printing self.root - return self.__class__.__name__ + \ - repr((self.account, '[self]', self.name, self.total_count, self.unread_count, self.child_folder_count, - self.folder_class, self.id, self.changekey)) + return self.__class__.__name__ + repr( + ( + self.account, + "[self]", + self.name, + self.total_count, + self.unread_count, + self.child_folder_count, + self.folder_class, + self.id, + self.changekey, + ) + ) class Root(RootOfHierarchy): """The root of the standard folder hierarchy.""" - DISTINGUISHED_FOLDER_ID = 'root' + DISTINGUISHED_FOLDER_ID = "root" WELLKNOWN_FOLDERS = WELLKNOWN_FOLDERS_IN_ROOT @property @@ -265,12 +279,12 @@

                              Module exchangelib.folders.roots

                              # 3. Searching TOIS for a direct child folder of the same type that has a localized name # 4. Searching root for a direct child folder of the same type that is marked as distinguished # 5. Searching root for a direct child folder of the same type that has a localized name - log.debug('Searching default %s folder in full folder list', folder_cls) + log.debug("Searching default %s folder in full folder list", folder_cls) for f in self._folders_map.values(): # Require exact type, to avoid matching with subclasses (e.g. RecipientCache and Contacts) if f.__class__ == folder_cls and f.has_distinguished_name: - log.debug('Found cached %s folder with default distinguished name', folder_cls) + log.debug("Found cached %s folder with default distinguished name", folder_cls) return f # Try direct children of TOIS first, unless we're trying to get the TOIS folder @@ -293,21 +307,21 @@

                              Module exchangelib.folders.roots

                              else: candidates = [f for f in same_type if f.name.lower() in folder_cls.localized_names(self.account.locale)] if not candidates: - raise ErrorFolderNotFound(f'No usable default {folder_cls} folders') + raise ErrorFolderNotFound(f"No usable default {folder_cls} folders") if len(candidates) > 1: - raise ValueError(f'Multiple possible default {folder_cls} folders: {[f.name for f in candidates]}') + raise ValueError(f"Multiple possible default {folder_cls} folders: {[f.name for f in candidates]}") candidate = candidates[0] if candidate.is_distinguished: - log.debug('Found distinguished %s folder', folder_cls) + log.debug("Found distinguished %s folder", folder_cls) else: - log.debug('Found %s folder with localized name %s', folder_cls, candidate.name) + log.debug("Found %s folder with localized name %s", folder_cls, candidate.name) return candidate class PublicFoldersRoot(RootOfHierarchy): """The root of the public folders hierarchy. Not available on all mailboxes.""" - DISTINGUISHED_FOLDER_ID = 'publicfoldersroot' + DISTINGUISHED_FOLDER_ID = "publicfoldersroot" DEFAULT_FOLDER_TRAVERSAL_DEPTH = SHALLOW supported_from = EXCHANGE_2007_SP1 @@ -328,9 +342,11 @@

                              Module exchangelib.folders.roots

                              children_map = {} try: - for f in SingleFolderQuerySet(account=self.account, folder=folder).depth( - self.DEFAULT_FOLDER_TRAVERSAL_DEPTH - ).all(): + for f in ( + SingleFolderQuerySet(account=self.account, folder=folder) + .depth(self.DEFAULT_FOLDER_TRAVERSAL_DEPTH) + .all() + ): if isinstance(f, MISSING_FOLDER_ERRORS): # We were unlucky. The folder disappeared between the FindFolder and the GetFolder calls continue @@ -352,7 +368,7 @@

                              Module exchangelib.folders.roots

                              class ArchiveRoot(RootOfHierarchy): """The root of the archive folders hierarchy. Not available on all mailboxes.""" - DISTINGUISHED_FOLDER_ID = 'archiveroot' + DISTINGUISHED_FOLDER_ID = "archiveroot" supported_from = EXCHANGE_2010_SP1 WELLKNOWN_FOLDERS = WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT
                              @@ -379,7 +395,7 @@

                              Classes

                              class ArchiveRoot(RootOfHierarchy):
                                   """The root of the archive folders hierarchy. Not available on all mailboxes."""
                               
                              -    DISTINGUISHED_FOLDER_ID = 'archiveroot'
                              +    DISTINGUISHED_FOLDER_ID = "archiveroot"
                                   supported_from = EXCHANGE_2010_SP1
                                   WELLKNOWN_FOLDERS = WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT
                              @@ -461,7 +477,7 @@

                              Inherited members

                              class PublicFoldersRoot(RootOfHierarchy):
                                   """The root of the public folders hierarchy. Not available on all mailboxes."""
                               
                              -    DISTINGUISHED_FOLDER_ID = 'publicfoldersroot'
                              +    DISTINGUISHED_FOLDER_ID = "publicfoldersroot"
                                   DEFAULT_FOLDER_TRAVERSAL_DEPTH = SHALLOW
                                   supported_from = EXCHANGE_2007_SP1
                               
                              @@ -482,9 +498,11 @@ 

                              Inherited members

                              children_map = {} try: - for f in SingleFolderQuerySet(account=self.account, folder=folder).depth( - self.DEFAULT_FOLDER_TRAVERSAL_DEPTH - ).all(): + for f in ( + SingleFolderQuerySet(account=self.account, folder=folder) + .depth(self.DEFAULT_FOLDER_TRAVERSAL_DEPTH) + .all() + ): if isinstance(f, MISSING_FOLDER_ERRORS): # We were unlucky. The folder disappeared between the FindFolder and the GetFolder calls continue @@ -554,9 +572,11 @@

                              Methods

                              children_map = {} try: - for f in SingleFolderQuerySet(account=self.account, folder=folder).depth( - self.DEFAULT_FOLDER_TRAVERSAL_DEPTH - ).all(): + for f in ( + SingleFolderQuerySet(account=self.account, folder=folder) + .depth(self.DEFAULT_FOLDER_TRAVERSAL_DEPTH) + .all() + ): if isinstance(f, MISSING_FOLDER_ERRORS): # We were unlucky. The folder disappeared between the FindFolder and the GetFolder calls continue @@ -630,7 +650,7 @@

                              Inherited members

                              class Root(RootOfHierarchy):
                                   """The root of the standard folder hierarchy."""
                               
                              -    DISTINGUISHED_FOLDER_ID = 'root'
                              +    DISTINGUISHED_FOLDER_ID = "root"
                                   WELLKNOWN_FOLDERS = WELLKNOWN_FOLDERS_IN_ROOT
                               
                                   @property
                              @@ -651,12 +671,12 @@ 

                              Inherited members

                              # 3. Searching TOIS for a direct child folder of the same type that has a localized name # 4. Searching root for a direct child folder of the same type that is marked as distinguished # 5. Searching root for a direct child folder of the same type that has a localized name - log.debug('Searching default %s folder in full folder list', folder_cls) + log.debug("Searching default %s folder in full folder list", folder_cls) for f in self._folders_map.values(): # Require exact type, to avoid matching with subclasses (e.g. RecipientCache and Contacts) if f.__class__ == folder_cls and f.has_distinguished_name: - log.debug('Found cached %s folder with default distinguished name', folder_cls) + log.debug("Found cached %s folder with default distinguished name", folder_cls) return f # Try direct children of TOIS first, unless we're trying to get the TOIS folder @@ -679,14 +699,14 @@

                              Inherited members

                              else: candidates = [f for f in same_type if f.name.lower() in folder_cls.localized_names(self.account.locale)] if not candidates: - raise ErrorFolderNotFound(f'No usable default {folder_cls} folders') + raise ErrorFolderNotFound(f"No usable default {folder_cls} folders") if len(candidates) > 1: - raise ValueError(f'Multiple possible default {folder_cls} folders: {[f.name for f in candidates]}') + raise ValueError(f"Multiple possible default {folder_cls} folders: {[f.name for f in candidates]}") candidate = candidates[0] if candidate.is_distinguished: - log.debug('Found distinguished %s folder', folder_cls) + log.debug("Found distinguished %s folder", folder_cls) else: - log.debug('Found %s folder with localized name %s', folder_cls, candidate.name) + log.debug("Found %s folder with localized name %s", folder_cls, candidate.name) return candidate

                              Ancestors

                              @@ -791,14 +811,15 @@

                              Inherited members

                              # This folder type also has 'folder:PermissionSet' on some server versions, but requesting it sometimes causes # 'ErrorAccessDenied', as reported by some users. Ignore it entirely for root folders - it's usefulness is # deemed minimal at best. - effective_rights = EffectiveRightsField(field_uri='folder:EffectiveRights', is_read_only=True, - supported_from=EXCHANGE_2007_SP1) + effective_rights = EffectiveRightsField( + field_uri="folder:EffectiveRights", is_read_only=True, supported_from=EXCHANGE_2007_SP1 + ) - __slots__ = '_account', '_subfolders' + __slots__ = "_account", "_subfolders" # A special folder that acts as the top of a folder hierarchy. Finds and caches subfolders at arbitrary depth. def __init__(self, **kwargs): - self._account = kwargs.pop('account', None) # A pointer back to the account holding the folder hierarchy + self._account = kwargs.pop("account", None) # A pointer back to the account holding the folder hierarchy super().__init__(**kwargs) self._subfolders = None # See self._folders_map() @@ -817,13 +838,13 @@

                              Inherited members

                              @classmethod def register(cls, *args, **kwargs): if cls is not RootOfHierarchy: - raise TypeError('For folder roots, custom fields must be registered on the RootOfHierarchy class') + raise TypeError("For folder roots, custom fields must be registered on the RootOfHierarchy class") return super().register(*args, **kwargs) @classmethod def deregister(cls, *args, **kwargs): if cls is not RootOfHierarchy: - raise TypeError('For folder roots, custom fields must be registered on the RootOfHierarchy class') + raise TypeError("For folder roots, custom fields must be registered on the RootOfHierarchy class") return super().deregister(*args, **kwargs) def get_folder(self, folder): @@ -867,14 +888,13 @@

                              Inherited members

                              :param account: """ if not cls.DISTINGUISHED_FOLDER_ID: - raise ValueError(f'Class {cls} must have a DISTINGUISHED_FOLDER_ID value') + raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: return cls.resolve( - account=account, - folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + account=account, folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}') + raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") def get_default_folder(self, folder_cls): """Return the distinguished folder instance of type folder_cls belonging to this account. If no distinguished @@ -888,21 +908,21 @@

                              Inherited members

                              for f in self._folders_map.values(): # Require exact class, to not match subclasses, e.g. RecipientCache instead of Contacts if f.__class__ == folder_cls and f.is_distinguished: - log.debug('Found cached distinguished %s folder', folder_cls) + log.debug("Found cached distinguished %s folder", folder_cls) return f try: - log.debug('Requesting distinguished %s folder explicitly', folder_cls) + log.debug("Requesting distinguished %s folder explicitly", folder_cls) return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItems instead - log.debug('Testing default %s folder with FindItem', folder_cls) + log.debug("Testing default %s folder with FindItem", folder_cls) fld = folder_cls(root=self, name=folder_cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: # The Exchange server does not return a distinguished folder of this type pass - raise ErrorFolderNotFound(f'No usable default {folder_cls} folders') + raise ErrorFolderNotFound(f"No usable default {folder_cls} folders") @property def _folders_map(self): @@ -933,9 +953,9 @@

                              Inherited members

                              if isinstance(f, Exception): raise f folders_map[f.id] = f - for f in SingleFolderQuerySet(account=self.account, folder=self).depth( - self.DEFAULT_FOLDER_TRAVERSAL_DEPTH - ).all(): + for f in ( + SingleFolderQuerySet(account=self.account, folder=self).depth(self.DEFAULT_FOLDER_TRAVERSAL_DEPTH).all() + ): if isinstance(f, ErrorAccessDenied): # We may not have FindFolder access, or GetFolder access, either to this folder or at all continue @@ -971,9 +991,19 @@

                              Inherited members

                              def __repr__(self): # Let's not create an infinite loop when printing self.root - return self.__class__.__name__ + \ - repr((self.account, '[self]', self.name, self.total_count, self.unread_count, self.child_folder_count, - self.folder_class, self.id, self.changekey))
                              + return self.__class__.__name__ + repr( + ( + self.account, + "[self]", + self.name, + self.total_count, + self.unread_count, + self.child_folder_count, + self.folder_class, + self.id, + self.changekey, + ) + )

                              Ancestors

                                @@ -1059,14 +1089,13 @@

                                Static methods

                                :param account: """ if not cls.DISTINGUISHED_FOLDER_ID: - raise ValueError(f'Class {cls} must have a DISTINGUISHED_FOLDER_ID value') + raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: return cls.resolve( - account=account, - folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + account=account, folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}')
                              + raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}")
                            @@ -1147,21 +1176,21 @@

                            Methods

                            for f in self._folders_map.values(): # Require exact class, to not match subclasses, e.g. RecipientCache instead of Contacts if f.__class__ == folder_cls and f.is_distinguished: - log.debug('Found cached distinguished %s folder', folder_cls) + log.debug("Found cached distinguished %s folder", folder_cls) return f try: - log.debug('Requesting distinguished %s folder explicitly', folder_cls) + log.debug("Requesting distinguished %s folder explicitly", folder_cls) return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItems instead - log.debug('Testing default %s folder with FindItem', folder_cls) + log.debug("Testing default %s folder with FindItem", folder_cls) fld = folder_cls(root=self, name=folder_cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: # The Exchange server does not return a distinguished folder of this type pass - raise ErrorFolderNotFound(f'No usable default {folder_cls} folders') + raise ErrorFolderNotFound(f"No usable default {folder_cls} folders")
                            diff --git a/docs/exchangelib/index.html b/docs/exchangelib/index.html index 7613db4b..53a8450a 100644 --- a/docs/exchangelib/index.html +++ b/docs/exchangelib/index.html @@ -30,51 +30,109 @@

                            Package exchangelib

                            from .attachments import FileAttachment, ItemAttachment from .autodiscover import discover from .configuration import Configuration -from .credentials import DELEGATE, IMPERSONATION, Credentials, OAuth2Credentials, \ - OAuth2AuthorizationCodeCredentials -from .ewsdatetime import EWSDate, EWSDateTime, EWSTimeZone, UTC, UTC_NOW +from .credentials import DELEGATE, IMPERSONATION, Credentials, OAuth2AuthorizationCodeCredentials, OAuth2Credentials +from .ewsdatetime import UTC, UTC_NOW, EWSDate, EWSDateTime, EWSTimeZone from .extended_properties import ExtendedProperty -from .folders import Folder, RootOfHierarchy, FolderCollection, SHALLOW, DEEP -from .items import AcceptItem, TentativelyAcceptItem, DeclineItem, CalendarItem, CancelCalendarItem, Contact, \ - DistributionList, Message, PostItem, Task, ForwardItem, ReplyToItem, ReplyAllToItem -from .properties import Body, HTMLBody, ItemId, Mailbox, Attendee, Room, RoomList, UID, DLMailbox -from .protocol import FaultTolerance, FailFast, BaseProtocol, NoVerifyHTTPAdapter, TLSClientAuth +from .folders import DEEP, SHALLOW, Folder, FolderCollection, RootOfHierarchy +from .items import ( + AcceptItem, + CalendarItem, + CancelCalendarItem, + Contact, + DeclineItem, + DistributionList, + ForwardItem, + Message, + PostItem, + ReplyAllToItem, + ReplyToItem, + Task, + TentativelyAcceptItem, +) +from .properties import UID, Attendee, Body, DLMailbox, HTMLBody, ItemId, Mailbox, Room, RoomList +from .protocol import BaseProtocol, FailFast, FaultTolerance, NoVerifyHTTPAdapter, TLSClientAuth from .restriction import Q from .settings import OofSettings -from .transport import BASIC, DIGEST, NTLM, GSSAPI, SSPI, OAUTH2, CBA +from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = '4.7.1' +__version__ = "4.7.2" __all__ = [ - '__version__', - 'Account', 'Identity', - 'FileAttachment', 'ItemAttachment', - 'discover', - 'Configuration', - 'DELEGATE', 'IMPERSONATION', 'Credentials', 'OAuth2AuthorizationCodeCredentials', 'OAuth2Credentials', - 'EWSDate', 'EWSDateTime', 'EWSTimeZone', 'UTC', 'UTC_NOW', - 'ExtendedProperty', - 'Folder', 'RootOfHierarchy', 'FolderCollection', 'SHALLOW', 'DEEP', - 'AcceptItem', 'TentativelyAcceptItem', 'DeclineItem', 'CalendarItem', 'CancelCalendarItem', 'Contact', - 'DistributionList', 'Message', 'PostItem', 'Task', 'ForwardItem', 'ReplyToItem', 'ReplyAllToItem', - 'ItemId', 'Mailbox', 'DLMailbox', 'Attendee', 'Room', 'RoomList', 'Body', 'HTMLBody', 'UID', - 'FailFast', 'FaultTolerance', 'BaseProtocol', 'NoVerifyHTTPAdapter', 'TLSClientAuth', - 'OofSettings', - 'Q', - 'BASIC', 'DIGEST', 'NTLM', 'GSSAPI', 'SSPI', 'OAUTH2', 'CBA', - 'Build', 'Version', - 'close_connections', + "__version__", + "Account", + "Identity", + "FileAttachment", + "ItemAttachment", + "discover", + "Configuration", + "DELEGATE", + "IMPERSONATION", + "Credentials", + "OAuth2AuthorizationCodeCredentials", + "OAuth2Credentials", + "EWSDate", + "EWSDateTime", + "EWSTimeZone", + "UTC", + "UTC_NOW", + "ExtendedProperty", + "Folder", + "RootOfHierarchy", + "FolderCollection", + "SHALLOW", + "DEEP", + "AcceptItem", + "TentativelyAcceptItem", + "DeclineItem", + "CalendarItem", + "CancelCalendarItem", + "Contact", + "DistributionList", + "Message", + "PostItem", + "Task", + "ForwardItem", + "ReplyToItem", + "ReplyAllToItem", + "ItemId", + "Mailbox", + "DLMailbox", + "Attendee", + "Room", + "RoomList", + "Body", + "HTMLBody", + "UID", + "FailFast", + "FaultTolerance", + "BaseProtocol", + "NoVerifyHTTPAdapter", + "TLSClientAuth", + "OofSettings", + "Q", + "BASIC", + "DIGEST", + "NTLM", + "GSSAPI", + "SSPI", + "OAUTH2", + "CBA", + "Build", + "Version", + "close_connections", ] # Set a default user agent, e.g. "exchangelib/3.1.1 (python-requests/2.22.0)" import requests.utils + BaseProtocol.USERAGENT = f"{__name__}/{__version__} ({requests.utils.default_user_agent()})" def close_connections(): from .autodiscover import close_connections as close_autodiscover_connections from .protocol import close_connections as close_protocol_connections + close_autodiscover_connections() close_protocol_connections()
                            @@ -206,6 +264,7 @@

                            Functions

                            def close_connections():
                                 from .autodiscover import close_connections as close_autodiscover_connections
                                 from .protocol import close_connections as close_protocol_connections
                            +
                                 close_autodiscover_connections()
                                 close_protocol_connections()
                            @@ -220,9 +279,7 @@

                            Functions

                            Expand source code
                            def discover(email, credentials=None, auth_type=None, retry_policy=None):
                            -    ad_response, protocol = Autodiscovery(
                            -        email=email, credentials=credentials
                            -    ).discover()
                            +    ad_response, protocol = Autodiscovery(email=email, credentials=credentials).discover()
                                 protocol.config.auth_typ = auth_type
                                 protocol.config.retry_policy = retry_policy
                                 return ad_response, protocol
                            @@ -251,7 +308,7 @@

                            Classes

                            class AcceptItem(BaseMeetingReplyItem):
                                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/acceptitem"""
                             
                            -    ELEMENT_NAME = 'AcceptItem'
                            + ELEMENT_NAME = "AcceptItem"

                            Ancestors

                              @@ -311,8 +368,17 @@

                              Inherited members

                              class Account:
                                   """Models an Exchange server user account."""
                               
                              -    def __init__(self, primary_smtp_address, fullname=None, access_type=None, autodiscover=False, credentials=None,
                              -                 config=None, locale=None, default_timezone=None):
                              +    def __init__(
                              +        self,
                              +        primary_smtp_address,
                              +        fullname=None,
                              +        access_type=None,
                              +        autodiscover=False,
                              +        credentials=None,
                              +        config=None,
                              +        locale=None,
                              +        default_timezone=None,
                              +    ):
                                       """
                               
                                       :param primary_smtp_address: The primary email address associated with the account on the Exchange server
                              @@ -329,37 +395,37 @@ 

                              Inherited members

                              assume values to be in the provided timezone. Defaults to the timezone of the host. :return: """ - if '@' not in primary_smtp_address: + if "@" not in primary_smtp_address: raise ValueError(f"primary_smtp_address {primary_smtp_address!r} is not an email address") self.fullname = fullname # Assume delegate access if individual credentials are provided. Else, assume service user with impersonation self.access_type = access_type or (DELEGATE if credentials else IMPERSONATION) if self.access_type not in ACCESS_TYPES: - raise InvalidEnumValue('access_type', self.access_type, ACCESS_TYPES) + raise InvalidEnumValue("access_type", self.access_type, ACCESS_TYPES) try: # get_locale() might not be able to determine the locale self.locale = locale or stdlib_locale.getlocale()[0] or None except ValueError as e: # getlocale() may throw ValueError if it fails to parse the system locale - log.warning('Failed to get locale (%s)', e) + log.warning("Failed to get locale (%s)", e) self.locale = None if not isinstance(self.locale, (type(None), str)): - raise InvalidTypeError('locale', self.locale, str) + raise InvalidTypeError("locale", self.locale, str) if default_timezone: try: self.default_timezone = EWSTimeZone.from_timezone(default_timezone) except TypeError: - raise InvalidTypeError('default_timezone', default_timezone, EWSTimeZone) + raise InvalidTypeError("default_timezone", default_timezone, EWSTimeZone) else: try: self.default_timezone = EWSTimeZone.localzone() except (ValueError, UnknownTimeZone) as e: # There is no translation from local timezone name to Windows timezone name, or e failed to find the # local timezone. - log.warning('%s. Fallback to UTC', e.args[0]) + log.warning("%s. Fallback to UTC", e.args[0]) self.default_timezone = UTC if not isinstance(config, (Configuration, type(None))): - raise InvalidTypeError('config', config, Configuration) + raise InvalidTypeError("config", config, Configuration) if autodiscover: if config: auth_type, retry_policy, version = config.auth_type, config.retry_policy, config.version @@ -379,7 +445,7 @@

                              Inherited members

                              primary_smtp_address = self.ad_response.autodiscover_smtp_address else: if not config: - raise AttributeError('non-autodiscover requires a config') + raise AttributeError("non-autodiscover requires a config") self.ad_response = None self.protocol = Protocol(config=config) @@ -393,7 +459,7 @@

                              Inherited members

                              # server version up-front but delegate account requests to an older backend server. Create a new instance to # avoid changing the protocol version. self.version = self.protocol.version.copy() - log.debug('Added account: %s', self) + log.debug("Added account: %s", self) @property def primary_smtp_address(self): @@ -601,7 +667,7 @@

                              Inherited members

                              # We accept generators, so it's not always convenient for caller to know up-front if 'ids' is empty. Allow # empty 'ids' and return early. return - kwargs['items'] = items + kwargs["items"] = items yield from service_cls(account=self, chunk_size=chunk_size).call(**kwargs) def export(self, items, chunk_size=None): @@ -612,9 +678,7 @@

                              Inherited members

                              :return: A list of strings, the exported representation of the object """ - return list( - self._consume_item_service(service_cls=ExportItems, items=items, chunk_size=chunk_size, kwargs={}) - ) + return list(self._consume_item_service(service_cls=ExportItems, items=items, chunk_size=chunk_size, kwargs={})) def upload(self, data, chunk_size=None): """Upload objects retrieved from an export to the given folders. @@ -636,12 +700,11 @@

                              Inherited members

                              -> [("idA", "changekey"), ("idB", "changekey"), ("idC", "changekey")] """ items = ((f, (None, False, d) if isinstance(d, str) else d) for f, d in data) - return list( - self._consume_item_service(service_cls=UploadItems, items=items, chunk_size=chunk_size, kwargs={}) - ) + return list(self._consume_item_service(service_cls=UploadItems, items=items, chunk_size=chunk_size, kwargs={})) - def bulk_create(self, folder, items, message_disposition=SAVE_ONLY, send_meeting_invitations=SEND_TO_NONE, - chunk_size=None): + def bulk_create( + self, folder, items, message_disposition=SAVE_ONLY, send_meeting_invitations=SEND_TO_NONE, chunk_size=None + ): """Create new items in 'folder'. :param folder: the folder to create the items in @@ -658,23 +721,36 @@

                              Inherited members

                              """ if isinstance(items, QuerySet): # bulk_create() on a queryset does not make sense because it returns items that have already been created - raise ValueError('Cannot bulk create items from a QuerySet') + raise ValueError("Cannot bulk create items from a QuerySet") log.debug( - 'Adding items for %s (folder %s, message_disposition: %s, send_meeting_invitations: %s)', + "Adding items for %s (folder %s, message_disposition: %s, send_meeting_invitations: %s)", self, folder, message_disposition, send_meeting_invitations, ) - return list(self._consume_item_service(service_cls=CreateItem, items=items, chunk_size=chunk_size, kwargs=dict( - folder=folder, - message_disposition=message_disposition, - send_meeting_invitations=send_meeting_invitations, - ))) + return list( + self._consume_item_service( + service_cls=CreateItem, + items=items, + chunk_size=chunk_size, + kwargs=dict( + folder=folder, + message_disposition=message_disposition, + send_meeting_invitations=send_meeting_invitations, + ), + ) + ) - def bulk_update(self, items, conflict_resolution=AUTO_RESOLVE, message_disposition=SAVE_ONLY, - send_meeting_invitations_or_cancellations=SEND_TO_NONE, suppress_read_receipts=True, - chunk_size=None): + def bulk_update( + self, + items, + conflict_resolution=AUTO_RESOLVE, + message_disposition=SAVE_ONLY, + send_meeting_invitations_or_cancellations=SEND_TO_NONE, + suppress_read_receipts=True, + chunk_size=None, + ): """Bulk update existing items. :param items: a list of (Item, fieldnames) tuples, where 'Item' is an Item object, and 'fieldnames' is a list @@ -694,23 +770,37 @@

                              Inherited members

                              # fact, it could be dangerous if the queryset contains an '.only()'. This would wipe out certain fields # entirely. if isinstance(items, QuerySet): - raise ValueError('Cannot bulk update on a queryset') + raise ValueError("Cannot bulk update on a queryset") log.debug( - 'Updating items for %s (conflict_resolution %s, message_disposition: %s, send_meeting_invitations: %s)', + "Updating items for %s (conflict_resolution %s, message_disposition: %s, send_meeting_invitations: %s)", self, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, ) - return list(self._consume_item_service(service_cls=UpdateItem, items=items, chunk_size=chunk_size, kwargs=dict( - conflict_resolution=conflict_resolution, - message_disposition=message_disposition, - send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations, - suppress_read_receipts=suppress_read_receipts, - ))) + return list( + self._consume_item_service( + service_cls=UpdateItem, + items=items, + chunk_size=chunk_size, + kwargs=dict( + conflict_resolution=conflict_resolution, + message_disposition=message_disposition, + send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations, + suppress_read_receipts=suppress_read_receipts, + ), + ) + ) - def bulk_delete(self, ids, delete_type=HARD_DELETE, send_meeting_cancellations=SEND_TO_NONE, - affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True, chunk_size=None): + def bulk_delete( + self, + ids, + delete_type=HARD_DELETE, + send_meeting_cancellations=SEND_TO_NONE, + affected_task_occurrences=ALL_OCCURRENCES, + suppress_read_receipts=True, + chunk_size=None, + ): """Bulk delete items. :param ids: an iterable of either (id, changekey) tuples or Item objects. @@ -726,19 +816,24 @@

                              Inherited members

                              :return: a list of either True or exception instances, in the same order as the input """ log.debug( - 'Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurrences: %s)', + "Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurrences: %s)", self, delete_type, send_meeting_cancellations, affected_task_occurrences, ) return list( - self._consume_item_service(service_cls=DeleteItem, items=ids, chunk_size=chunk_size, kwargs=dict( - delete_type=delete_type, - send_meeting_cancellations=send_meeting_cancellations, - affected_task_occurrences=affected_task_occurrences, - suppress_read_receipts=suppress_read_receipts, - )) + self._consume_item_service( + service_cls=DeleteItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + delete_type=delete_type, + send_meeting_cancellations=send_meeting_cancellations, + affected_task_occurrences=affected_task_occurrences, + suppress_read_receipts=suppress_read_receipts, + ), + ) ) def bulk_send(self, ids, save_copy=True, copy_to_folder=None, chunk_size=None): @@ -756,9 +851,14 @@

                              Inherited members

                              if save_copy and not copy_to_folder: copy_to_folder = self.sent # 'Sent' is default EWS behaviour return list( - self._consume_item_service(service_cls=SendItem, items=ids, chunk_size=chunk_size, kwargs=dict( - saved_item_folder=copy_to_folder, - )) + self._consume_item_service( + service_cls=SendItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + saved_item_folder=copy_to_folder, + ), + ) ) def bulk_copy(self, ids, to_folder, chunk_size=None): @@ -770,9 +870,16 @@

                              Inherited members

                              :return: Status for each send operation, in the same order as the input """ - return list(self._consume_item_service(service_cls=CopyItem, items=ids, chunk_size=chunk_size, kwargs=dict( - to_folder=to_folder, - ))) + return list( + self._consume_item_service( + service_cls=CopyItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + to_folder=to_folder, + ), + ) + ) def bulk_move(self, ids, to_folder, chunk_size=None): """Move items to another folder. @@ -784,9 +891,16 @@

                              Inherited members

                              :return: The new IDs of the moved items, in the same order as the input. If 'to_folder' is a public folder or a folder in a different mailbox, an empty list is returned. """ - return list(self._consume_item_service(service_cls=MoveItem, items=ids, chunk_size=chunk_size, kwargs=dict( - to_folder=to_folder, - ))) + return list( + self._consume_item_service( + service_cls=MoveItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + to_folder=to_folder, + ), + ) + ) def bulk_archive(self, ids, to_folder, chunk_size=None): """Archive items to a folder in the archive mailbox. An archive mailbox must be enabled in order for this @@ -798,9 +912,15 @@

                              Inherited members

                              :return: A list containing True or an exception instance in stable order of the requested items """ - return list(self._consume_item_service(service_cls=ArchiveItem, items=ids, chunk_size=chunk_size, kwargs=dict( - to_folder=to_folder, - )) + return list( + self._consume_item_service( + service_cls=ArchiveItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + to_folder=to_folder, + ), + ) ) def bulk_mark_as_junk(self, ids, is_junk, move_item, chunk_size=None): @@ -814,10 +934,17 @@

                              Inherited members

                              :return: A list containing the new IDs of the moved items, if items were moved, or True, or an exception instance, in stable order of the requested items. """ - return list(self._consume_item_service(service_cls=MarkAsJunk, items=ids, chunk_size=chunk_size, kwargs=dict( - is_junk=is_junk, - move_item=move_item, - ))) + return list( + self._consume_item_service( + service_cls=MarkAsJunk, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + is_junk=is_junk, + move_item=move_item, + ), + ) + ) def fetch(self, ids, folder=None, only_fields=None, chunk_size=None): """Fetch items by ID. @@ -842,13 +969,19 @@

                              Inherited members

                              for field in only_fields: validation_folder.validate_item_field(field=field, version=self.version) # Remove ItemId and ChangeKey. We get them unconditionally - additional_fields = {f for f in validation_folder.normalize_fields(fields=only_fields) - if not f.field.is_attribute} + additional_fields = { + f for f in validation_folder.normalize_fields(fields=only_fields) if not f.field.is_attribute + } # Always use IdOnly here, because AllProperties doesn't actually get *all* properties - yield from self._consume_item_service(service_cls=GetItem, items=ids, chunk_size=chunk_size, kwargs=dict( + yield from self._consume_item_service( + service_cls=GetItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( additional_fields=additional_fields, shape=ID_ONLY, - )) + ), + ) def fetch_personas(self, ids): """Fetch personas by ID. @@ -872,7 +1005,7 @@

                              Inherited members

                              return GetMailTips(protocol=self.protocol).get( sending_as=SendingAs(email_address=self.primary_smtp_address), recipients=[Mailbox(email_address=self.primary_smtp_address)], - mail_tips_requested='All', + mail_tips_requested="All", ) @property @@ -882,7 +1015,7 @@

                              Inherited members

                              def __str__(self): if self.fullname: - return f'{self.primary_smtp_address} ({self.fullname})' + return f"{self.primary_smtp_address} ({self.fullname})" return self.primary_smtp_address

                              Instance variables

                              @@ -1408,7 +1541,7 @@

                              Instance variables

                              return GetMailTips(protocol=self.protocol).get( sending_as=SendingAs(email_address=self.primary_smtp_address), recipients=[Mailbox(email_address=self.primary_smtp_address)], - mail_tips_requested='All', + mail_tips_requested="All", ) @@ -1952,9 +2085,15 @@

                              Methods

                              :return: A list containing True or an exception instance in stable order of the requested items """ - return list(self._consume_item_service(service_cls=ArchiveItem, items=ids, chunk_size=chunk_size, kwargs=dict( - to_folder=to_folder, - )) + return list( + self._consume_item_service( + service_cls=ArchiveItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + to_folder=to_folder, + ), + ) ) @@ -1980,9 +2119,16 @@

                              Methods

                              :return: Status for each send operation, in the same order as the input """ - return list(self._consume_item_service(service_cls=CopyItem, items=ids, chunk_size=chunk_size, kwargs=dict( - to_folder=to_folder, - ))) + return list( + self._consume_item_service( + service_cls=CopyItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + to_folder=to_folder, + ), + ) + )
                              @@ -2004,8 +2150,9 @@

                              Methods

                              Expand source code -
                              def bulk_create(self, folder, items, message_disposition=SAVE_ONLY, send_meeting_invitations=SEND_TO_NONE,
                              -                chunk_size=None):
                              +
                              def bulk_create(
                              +    self, folder, items, message_disposition=SAVE_ONLY, send_meeting_invitations=SEND_TO_NONE, chunk_size=None
                              +):
                                   """Create new items in 'folder'.
                               
                                   :param folder: the folder to create the items in
                              @@ -2022,19 +2169,26 @@ 

                              Methods

                              """ if isinstance(items, QuerySet): # bulk_create() on a queryset does not make sense because it returns items that have already been created - raise ValueError('Cannot bulk create items from a QuerySet') + raise ValueError("Cannot bulk create items from a QuerySet") log.debug( - 'Adding items for %s (folder %s, message_disposition: %s, send_meeting_invitations: %s)', + "Adding items for %s (folder %s, message_disposition: %s, send_meeting_invitations: %s)", self, folder, message_disposition, send_meeting_invitations, ) - return list(self._consume_item_service(service_cls=CreateItem, items=items, chunk_size=chunk_size, kwargs=dict( - folder=folder, - message_disposition=message_disposition, - send_meeting_invitations=send_meeting_invitations, - )))
                              + return list( + self._consume_item_service( + service_cls=CreateItem, + items=items, + chunk_size=chunk_size, + kwargs=dict( + folder=folder, + message_disposition=message_disposition, + send_meeting_invitations=send_meeting_invitations, + ), + ) + )
                              @@ -2056,8 +2210,15 @@

                              Methods

                              Expand source code -
                              def bulk_delete(self, ids, delete_type=HARD_DELETE, send_meeting_cancellations=SEND_TO_NONE,
                              -                affected_task_occurrences=ALL_OCCURRENCES, suppress_read_receipts=True, chunk_size=None):
                              +
                              def bulk_delete(
                              +    self,
                              +    ids,
                              +    delete_type=HARD_DELETE,
                              +    send_meeting_cancellations=SEND_TO_NONE,
                              +    affected_task_occurrences=ALL_OCCURRENCES,
                              +    suppress_read_receipts=True,
                              +    chunk_size=None,
                              +):
                                   """Bulk delete items.
                               
                                   :param ids: an iterable of either (id, changekey) tuples or Item objects.
                              @@ -2073,19 +2234,24 @@ 

                              Methods

                              :return: a list of either True or exception instances, in the same order as the input """ log.debug( - 'Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurrences: %s)', + "Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurrences: %s)", self, delete_type, send_meeting_cancellations, affected_task_occurrences, ) return list( - self._consume_item_service(service_cls=DeleteItem, items=ids, chunk_size=chunk_size, kwargs=dict( - delete_type=delete_type, - send_meeting_cancellations=send_meeting_cancellations, - affected_task_occurrences=affected_task_occurrences, - suppress_read_receipts=suppress_read_receipts, - )) + self._consume_item_service( + service_cls=DeleteItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + delete_type=delete_type, + send_meeting_cancellations=send_meeting_cancellations, + affected_task_occurrences=affected_task_occurrences, + suppress_read_receipts=suppress_read_receipts, + ), + ) )
                              @@ -2115,10 +2281,17 @@

                              Methods

                              :return: A list containing the new IDs of the moved items, if items were moved, or True, or an exception instance, in stable order of the requested items. """ - return list(self._consume_item_service(service_cls=MarkAsJunk, items=ids, chunk_size=chunk_size, kwargs=dict( - is_junk=is_junk, - move_item=move_item, - )))
                              + return list( + self._consume_item_service( + service_cls=MarkAsJunk, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + is_junk=is_junk, + move_item=move_item, + ), + ) + )
                              @@ -2145,9 +2318,16 @@

                              Methods

                              :return: The new IDs of the moved items, in the same order as the input. If 'to_folder' is a public folder or a folder in a different mailbox, an empty list is returned. """ - return list(self._consume_item_service(service_cls=MoveItem, items=ids, chunk_size=chunk_size, kwargs=dict( - to_folder=to_folder, - )))
                              + return list( + self._consume_item_service( + service_cls=MoveItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + to_folder=to_folder, + ), + ) + )
                              @@ -2179,9 +2359,14 @@

                              Methods

                              if save_copy and not copy_to_folder: copy_to_folder = self.sent # 'Sent' is default EWS behaviour return list( - self._consume_item_service(service_cls=SendItem, items=ids, chunk_size=chunk_size, kwargs=dict( - saved_item_folder=copy_to_folder, - )) + self._consume_item_service( + service_cls=SendItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( + saved_item_folder=copy_to_folder, + ), + ) )
                              @@ -2205,9 +2390,15 @@

                              Methods

                              Expand source code -
                              def bulk_update(self, items, conflict_resolution=AUTO_RESOLVE, message_disposition=SAVE_ONLY,
                              -                send_meeting_invitations_or_cancellations=SEND_TO_NONE, suppress_read_receipts=True,
                              -                chunk_size=None):
                              +
                              def bulk_update(
                              +    self,
                              +    items,
                              +    conflict_resolution=AUTO_RESOLVE,
                              +    message_disposition=SAVE_ONLY,
                              +    send_meeting_invitations_or_cancellations=SEND_TO_NONE,
                              +    suppress_read_receipts=True,
                              +    chunk_size=None,
                              +):
                                   """Bulk update existing items.
                               
                                   :param items: a list of (Item, fieldnames) tuples, where 'Item' is an Item object, and 'fieldnames' is a list
                              @@ -2227,20 +2418,27 @@ 

                              Methods

                              # fact, it could be dangerous if the queryset contains an '.only()'. This would wipe out certain fields # entirely. if isinstance(items, QuerySet): - raise ValueError('Cannot bulk update on a queryset') + raise ValueError("Cannot bulk update on a queryset") log.debug( - 'Updating items for %s (conflict_resolution %s, message_disposition: %s, send_meeting_invitations: %s)', + "Updating items for %s (conflict_resolution %s, message_disposition: %s, send_meeting_invitations: %s)", self, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, ) - return list(self._consume_item_service(service_cls=UpdateItem, items=items, chunk_size=chunk_size, kwargs=dict( - conflict_resolution=conflict_resolution, - message_disposition=message_disposition, - send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations, - suppress_read_receipts=suppress_read_receipts, - )))
                              + return list( + self._consume_item_service( + service_cls=UpdateItem, + items=items, + chunk_size=chunk_size, + kwargs=dict( + conflict_resolution=conflict_resolution, + message_disposition=message_disposition, + send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations, + suppress_read_receipts=suppress_read_receipts, + ), + ) + )
                              @@ -2263,9 +2461,7 @@

                              Methods

                              :return: A list of strings, the exported representation of the object """ - return list( - self._consume_item_service(service_cls=ExportItems, items=items, chunk_size=chunk_size, kwargs={}) - )
                              + return list(self._consume_item_service(service_cls=ExportItems, items=items, chunk_size=chunk_size, kwargs={}))
                              @@ -2305,13 +2501,19 @@

                              Methods

                              for field in only_fields: validation_folder.validate_item_field(field=field, version=self.version) # Remove ItemId and ChangeKey. We get them unconditionally - additional_fields = {f for f in validation_folder.normalize_fields(fields=only_fields) - if not f.field.is_attribute} + additional_fields = { + f for f in validation_folder.normalize_fields(fields=only_fields) if not f.field.is_attribute + } # Always use IdOnly here, because AllProperties doesn't actually get *all* properties - yield from self._consume_item_service(service_cls=GetItem, items=ids, chunk_size=chunk_size, kwargs=dict( + yield from self._consume_item_service( + service_cls=GetItem, + items=ids, + chunk_size=chunk_size, + kwargs=dict( additional_fields=additional_fields, shape=ID_ONLY, - ))
                              + ), + )
                              @@ -2384,9 +2586,7 @@

                              Methods

                              -> [("idA", "changekey"), ("idB", "changekey"), ("idC", "changekey")] """ items = ((f, (None, False, d) if isinstance(d, str) else d) for f, d in data) - return list( - self._consume_item_service(service_cls=UploadItems, items=items, chunk_size=chunk_size, kwargs={}) - )
                              + return list(self._consume_item_service(service_cls=UploadItems, items=items, chunk_size=chunk_size, kwargs={})) @@ -2404,13 +2604,14 @@

                              Methods

                              class Attendee(EWSElement):
                                   """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/attendee"""
                               
                              -    ELEMENT_NAME = 'Attendee'
                              -    RESPONSE_TYPES = {'Unknown', 'Organizer', 'Tentative', 'Accept', 'Decline', 'NoResponseReceived'}
                              +    ELEMENT_NAME = "Attendee"
                              +    RESPONSE_TYPES = {"Unknown", "Organizer", "Tentative", "Accept", "Decline", "NoResponseReceived"}
                               
                                   mailbox = MailboxField(is_required=True)
                              -    response_type = ChoiceField(field_uri='ResponseType', choices={Choice(c) for c in RESPONSE_TYPES},
                              -                                default='Unknown')
                              -    last_response_time = DateTimeField(field_uri='LastResponseTime')
                              +    response_type = ChoiceField(
                              +        field_uri="ResponseType", choices={Choice(c) for c in RESPONSE_TYPES}, default="Unknown"
                              +    )
                              +    last_response_time = DateTimeField(field_uri="LastResponseTime")
                               
                                   def __hash__(self):
                                       return hash(self.mailbox)
                              @@ -2542,7 +2743,7 @@

                              Inherited members

                              def get_auth_type(self): # Autodetect authentication type. We also set version hint here. - name = str(self.credentials) if self.credentials and str(self.credentials) else 'DUMMY' + name = str(self.credentials) if self.credentials and str(self.credentials) else "DUMMY" auth_type, api_version_hint = get_service_authtype( service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS, name=name ) @@ -2552,8 +2753,8 @@

                              Inherited members

                              def __getstate__(self): # The session pool and lock cannot be pickled state = self.__dict__.copy() - del state['_session_pool'] - del state['_session_pool_lock'] + del state["_session_pool"] + del state["_session_pool_lock"] return state def __setstate__(self, state): @@ -2571,7 +2772,7 @@

                              Inherited members

                              pass def close(self): - log.debug('Server %s: Closing sessions', self.server) + log.debug("Server %s: Closing sessions", self.server) while True: try: session = self._session_pool.get(block=False) @@ -2599,13 +2800,17 @@

                              Inherited members

                              # Create a single session and insert it into the pool. We need to protect this with a lock while we are changing # the pool size variable, to avoid race conditions. We must not exceed the pool size limit. if self._session_pool_size >= self._session_pool_maxsize: - raise SessionPoolMaxSizeReached('Session pool size cannot be increased further') + raise SessionPoolMaxSizeReached("Session pool size cannot be increased further") with self._session_pool_lock: if self._session_pool_size >= self._session_pool_maxsize: - log.debug('Session pool size was increased in another thread') + log.debug("Session pool size was increased in another thread") return - log.debug('Server %s: Increasing session pool size from %s to %s', self.server, self._session_pool_size, - self._session_pool_size + 1) + log.debug( + "Server %s: Increasing session pool size from %s to %s", + self.server, + self._session_pool_size, + self._session_pool_size + 1, + ) self._session_pool.put(self.create_session(), block=False) self._session_pool_size += 1 @@ -2616,13 +2821,17 @@

                              Inherited members

                              # Take a single session from the pool and discard it. We need to protect this with a lock while we are changing # the pool size variable, to avoid race conditions. We must keep at least one session in the pool. if self._session_pool_size <= 1: - raise SessionPoolMinSizeReached('Session pool size cannot be decreased further') + raise SessionPoolMinSizeReached("Session pool size cannot be decreased further") with self._session_pool_lock: if self._session_pool_size <= 1: - log.debug('Session pool size was decreased in another thread') + log.debug("Session pool size was decreased in another thread") return - log.warning('Server %s: Decreasing session pool size from %s to %s', self.server, self._session_pool_size, - self._session_pool_size - 1) + log.warning( + "Server %s: Decreasing session pool size from %s to %s", + self.server, + self._session_pool_size, + self._session_pool_size - 1, + ) session = self.get_session() self.close_session(session) self._session_pool_size -= 1 @@ -2633,7 +2842,7 @@

                              Inherited members

                              _timeout = 60 # Rate-limit messages about session starvation try: session = self._session_pool.get(block=False) - log.debug('Server %s: Got session immediately', self.server) + log.debug("Server %s: Got session immediately", self.server) except Empty: try: self.increase_poolsize() @@ -2641,21 +2850,21 @@

                              Inherited members

                              pass while True: try: - log.debug('Server %s: Waiting for session', self.server) + log.debug("Server %s: Waiting for session", self.server) session = self._session_pool.get(timeout=_timeout) break except Empty: # This is normal when we have many worker threads starving for available sessions - log.debug('Server %s: No sessions available for %s seconds', self.server, _timeout) - log.debug('Server %s: Got session %s', self.server, session.session_id) + log.debug("Server %s: No sessions available for %s seconds", self.server, _timeout) + log.debug("Server %s: Got session %s", self.server, session.session_id) session.usage_count += 1 return session def release_session(self, session): # This should never fail, as we don't have more sessions than the queue contains - log.debug('Server %s: Releasing session %s', self.server, session.session_id) + log.debug("Server %s: Releasing session %s", self.server, session.session_id) if self.MAX_SESSION_USAGE_COUNT and session.usage_count >= self.MAX_SESSION_USAGE_COUNT: - log.debug('Server %s: session %s usage exceeded limit. Discarding', self.server, session.session_id) + log.debug("Server %s: session %s usage exceeded limit. Discarding", self.server, session.session_id) session = self.renew_session(session) self._session_pool.put(session, block=False) @@ -2666,13 +2875,13 @@

                              Inherited members

                              def retire_session(self, session): # The session is useless. Close it completely and place a fresh session in the pool - log.debug('Server %s: Retiring session %s', self.server, session.session_id) + log.debug("Server %s: Retiring session %s", self.server, session.session_id) self.close_session(session) self.release_session(self.create_session()) def renew_session(self, session): # The session is useless. Close it completely and place a fresh session in the pool - log.debug('Server %s: Renewing session %s', self.server, session.session_id) + log.debug("Server %s: Renewing session %s", self.server, session.session_id) self.close_session(session) return self.create_session() @@ -2693,7 +2902,7 @@

                              Inherited members

                              def create_session(self): if self.credentials is None: if self.auth_type in CREDENTIALS_REQUIRED: - raise ValueError(f'Auth type {self.auth_type!r} requires credentials') + raise ValueError(f"Auth type {self.auth_type!r} requires credentials") session = self.raw_session(self.service_endpoint) session.auth = get_auth_instance(auth_type=self.auth_type) else: @@ -2708,43 +2917,43 @@

                              Inherited members

                              session.credentials_sig = self.credentials.sig() else: if self.auth_type == NTLM and self.credentials.type == self.credentials.EMAIL: - username = '\\' + self.credentials.username + username = "\\" + self.credentials.username else: username = self.credentials.username session = self.raw_session(self.service_endpoint) - session.auth = get_auth_instance(auth_type=self.auth_type, username=username, - password=self.credentials.password) + session.auth = get_auth_instance( + auth_type=self.auth_type, username=username, password=self.credentials.password + ) # Add some extra info - session.session_id = sum(map(ord, str(os.urandom(100)))) # Used for debugging messages in services + session.session_id = random.randint(10000, 99999) # Used for debugging messages in services session.usage_count = 0 - session.protocol = self - log.debug('Server %s: Created session %s', self.server, session.session_id) + log.debug("Server %s: Created session %s", self.server, session.session_id) return session def create_oauth2_session(self): has_token = False - scope = ['https://outlook.office365.com/.default'] + scope = ["https://outlook.office365.com/.default"] session_params = {} token_params = {} if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials): # Ask for a refresh token - scope.append('offline_access') + scope.append("offline_access") # We don't know (or need) the Microsoft tenant ID. Use # common/ to let Microsoft select the appropriate tenant # for the provided authorization code or refresh token. # # Suppress looks-like-password warning from Bandit. - token_url = 'https://login.microsoftonline.com/common/oauth2/v2.0/token' # nosec + token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token" # nosec client_params = {} has_token = self.credentials.access_token is not None if has_token: - session_params['token'] = self.credentials.access_token + session_params["token"] = self.credentials.access_token elif self.credentials.authorization_code is not None: - token_params['code'] = self.credentials.authorization_code + token_params["code"] = self.credentials.authorization_code self.credentials.authorization_code = None if self.credentials.client_id is not None and self.credentials.client_secret is not None: @@ -2755,25 +2964,32 @@

                              Inherited members

                              # covers cases where the caller doesn't have access to # the client secret but is working with a service that # can provide it refreshed tokens on a limited basis). - session_params.update({ - 'auto_refresh_kwargs': { - 'client_id': self.credentials.client_id, - 'client_secret': self.credentials.client_secret, - }, - 'auto_refresh_url': token_url, - 'token_updater': self.credentials.on_token_auto_refreshed, - }) + session_params.update( + { + "auto_refresh_kwargs": { + "client_id": self.credentials.client_id, + "client_secret": self.credentials.client_secret, + }, + "auto_refresh_url": token_url, + "token_updater": self.credentials.on_token_auto_refreshed, + } + ) client = WebApplicationClient(self.credentials.client_id, **client_params) else: - token_url = f'https://login.microsoftonline.com/{self.credentials.tenant_id}/oauth2/v2.0/token' + token_url = f"https://login.microsoftonline.com/{self.credentials.tenant_id}/oauth2/v2.0/token" client = BackendApplicationClient(client_id=self.credentials.client_id) session = self.raw_session(self.service_endpoint, oauth2_client=client, oauth2_session_params=session_params) if not has_token: # Fetch the token explicitly -- it doesn't occur implicitly - token = session.fetch_token(token_url=token_url, client_id=self.credentials.client_id, - client_secret=self.credentials.client_secret, scope=scope, - timeout=self.TIMEOUT, **token_params) + token = session.fetch_token( + token_url=token_url, + client_id=self.credentials.client_id, + client_secret=self.credentials.client_secret, + scope=scope, + timeout=self.TIMEOUT, + **token_params, + ) # Allow the credentials object to update its copy of the new # token, and give the application an opportunity to cache it self.credentials.on_token_auto_refreshed(token) @@ -2788,7 +3004,7 @@

                              Inherited members

                              else: session = requests.sessions.Session() session.headers.update(DEFAULT_HEADERS) - session.headers['User-Agent'] = cls.USERAGENT + session.headers["User-Agent"] = cls.USERAGENT session.mount(prefix, adapter=cls.get_adapter()) return session @@ -2905,7 +3121,7 @@

                              Static methods

                              else: session = requests.sessions.Session() session.headers.update(DEFAULT_HEADERS) - session.headers['User-Agent'] = cls.USERAGENT + session.headers["User-Agent"] = cls.USERAGENT session.mount(prefix, adapter=cls.get_adapter()) return session @@ -3001,7 +3217,7 @@

                              Methods

                              Expand source code
                              def close(self):
                              -    log.debug('Server %s: Closing sessions', self.server)
                              +    log.debug("Server %s: Closing sessions", self.server)
                                   while True:
                                       try:
                                           session = self._session_pool.get(block=False)
                              @@ -3022,27 +3238,27 @@ 

                              Methods

                              def create_oauth2_session(self):
                                   has_token = False
                              -    scope = ['https://outlook.office365.com/.default']
                              +    scope = ["https://outlook.office365.com/.default"]
                                   session_params = {}
                                   token_params = {}
                               
                                   if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials):
                                       # Ask for a refresh token
                              -        scope.append('offline_access')
                              +        scope.append("offline_access")
                               
                                       # We don't know (or need) the Microsoft tenant ID. Use
                                       # common/ to let Microsoft select the appropriate tenant
                                       # for the provided authorization code or refresh token.
                                       #
                                       # Suppress looks-like-password warning from Bandit.
                              -        token_url = 'https://login.microsoftonline.com/common/oauth2/v2.0/token'  # nosec
                              +        token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token"  # nosec
                               
                                       client_params = {}
                                       has_token = self.credentials.access_token is not None
                                       if has_token:
                              -            session_params['token'] = self.credentials.access_token
                              +            session_params["token"] = self.credentials.access_token
                                       elif self.credentials.authorization_code is not None:
                              -            token_params['code'] = self.credentials.authorization_code
                              +            token_params["code"] = self.credentials.authorization_code
                                           self.credentials.authorization_code = None
                               
                                       if self.credentials.client_id is not None and self.credentials.client_secret is not None:
                              @@ -3053,25 +3269,32 @@ 

                              Methods

                              # covers cases where the caller doesn't have access to # the client secret but is working with a service that # can provide it refreshed tokens on a limited basis). - session_params.update({ - 'auto_refresh_kwargs': { - 'client_id': self.credentials.client_id, - 'client_secret': self.credentials.client_secret, - }, - 'auto_refresh_url': token_url, - 'token_updater': self.credentials.on_token_auto_refreshed, - }) + session_params.update( + { + "auto_refresh_kwargs": { + "client_id": self.credentials.client_id, + "client_secret": self.credentials.client_secret, + }, + "auto_refresh_url": token_url, + "token_updater": self.credentials.on_token_auto_refreshed, + } + ) client = WebApplicationClient(self.credentials.client_id, **client_params) else: - token_url = f'https://login.microsoftonline.com/{self.credentials.tenant_id}/oauth2/v2.0/token' + token_url = f"https://login.microsoftonline.com/{self.credentials.tenant_id}/oauth2/v2.0/token" client = BackendApplicationClient(client_id=self.credentials.client_id) session = self.raw_session(self.service_endpoint, oauth2_client=client, oauth2_session_params=session_params) if not has_token: # Fetch the token explicitly -- it doesn't occur implicitly - token = session.fetch_token(token_url=token_url, client_id=self.credentials.client_id, - client_secret=self.credentials.client_secret, scope=scope, - timeout=self.TIMEOUT, **token_params) + token = session.fetch_token( + token_url=token_url, + client_id=self.credentials.client_id, + client_secret=self.credentials.client_secret, + scope=scope, + timeout=self.TIMEOUT, + **token_params, + ) # Allow the credentials object to update its copy of the new # token, and give the application an opportunity to cache it self.credentials.on_token_auto_refreshed(token) @@ -3092,7 +3315,7 @@

                              Methods

                              def create_session(self):
                                   if self.credentials is None:
                                       if self.auth_type in CREDENTIALS_REQUIRED:
                              -            raise ValueError(f'Auth type {self.auth_type!r} requires credentials')
                              +            raise ValueError(f"Auth type {self.auth_type!r} requires credentials")
                                       session = self.raw_session(self.service_endpoint)
                                       session.auth = get_auth_instance(auth_type=self.auth_type)
                                   else:
                              @@ -3107,18 +3330,18 @@ 

                              Methods

                              session.credentials_sig = self.credentials.sig() else: if self.auth_type == NTLM and self.credentials.type == self.credentials.EMAIL: - username = '\\' + self.credentials.username + username = "\\" + self.credentials.username else: username = self.credentials.username session = self.raw_session(self.service_endpoint) - session.auth = get_auth_instance(auth_type=self.auth_type, username=username, - password=self.credentials.password) + session.auth = get_auth_instance( + auth_type=self.auth_type, username=username, password=self.credentials.password + ) # Add some extra info - session.session_id = sum(map(ord, str(os.urandom(100)))) # Used for debugging messages in services + session.session_id = random.randint(10000, 99999) # Used for debugging messages in services session.usage_count = 0 - session.protocol = self - log.debug('Server %s: Created session %s', self.server, session.session_id) + log.debug("Server %s: Created session %s", self.server, session.session_id) return session
                              @@ -3139,13 +3362,17 @@

                              Methods

                              # Take a single session from the pool and discard it. We need to protect this with a lock while we are changing # the pool size variable, to avoid race conditions. We must keep at least one session in the pool. if self._session_pool_size <= 1: - raise SessionPoolMinSizeReached('Session pool size cannot be decreased further') + raise SessionPoolMinSizeReached("Session pool size cannot be decreased further") with self._session_pool_lock: if self._session_pool_size <= 1: - log.debug('Session pool size was decreased in another thread') + log.debug("Session pool size was decreased in another thread") return - log.warning('Server %s: Decreasing session pool size from %s to %s', self.server, self._session_pool_size, - self._session_pool_size - 1) + log.warning( + "Server %s: Decreasing session pool size from %s to %s", + self.server, + self._session_pool_size, + self._session_pool_size - 1, + ) session = self.get_session() self.close_session(session) self._session_pool_size -= 1
                              @@ -3162,7 +3389,7 @@

                              Methods

                              def get_auth_type(self):
                                   # Autodetect authentication type. We also set version hint here.
                              -    name = str(self.credentials) if self.credentials and str(self.credentials) else 'DUMMY'
                              +    name = str(self.credentials) if self.credentials and str(self.credentials) else "DUMMY"
                                   auth_type, api_version_hint = get_service_authtype(
                                       service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS, name=name
                                   )
                              @@ -3185,7 +3412,7 @@ 

                              Methods

                              _timeout = 60 # Rate-limit messages about session starvation try: session = self._session_pool.get(block=False) - log.debug('Server %s: Got session immediately', self.server) + log.debug("Server %s: Got session immediately", self.server) except Empty: try: self.increase_poolsize() @@ -3193,13 +3420,13 @@

                              Methods

                              pass while True: try: - log.debug('Server %s: Waiting for session', self.server) + log.debug("Server %s: Waiting for session", self.server) session = self._session_pool.get(timeout=_timeout) break except Empty: # This is normal when we have many worker threads starving for available sessions - log.debug('Server %s: No sessions available for %s seconds', self.server, _timeout) - log.debug('Server %s: Got session %s', self.server, session.session_id) + log.debug("Server %s: No sessions available for %s seconds", self.server, _timeout) + log.debug("Server %s: Got session %s", self.server, session.session_id) session.usage_count += 1 return session
                              @@ -3218,13 +3445,17 @@

                              Methods

                              # Create a single session and insert it into the pool. We need to protect this with a lock while we are changing # the pool size variable, to avoid race conditions. We must not exceed the pool size limit. if self._session_pool_size >= self._session_pool_maxsize: - raise SessionPoolMaxSizeReached('Session pool size cannot be increased further') + raise SessionPoolMaxSizeReached("Session pool size cannot be increased further") with self._session_pool_lock: if self._session_pool_size >= self._session_pool_maxsize: - log.debug('Session pool size was increased in another thread') + log.debug("Session pool size was increased in another thread") return - log.debug('Server %s: Increasing session pool size from %s to %s', self.server, self._session_pool_size, - self._session_pool_size + 1) + log.debug( + "Server %s: Increasing session pool size from %s to %s", + self.server, + self._session_pool_size, + self._session_pool_size + 1, + ) self._session_pool.put(self.create_session(), block=False) self._session_pool_size += 1
                              @@ -3264,9 +3495,9 @@

                              Methods

                              def release_session(self, session):
                                   # This should never fail, as we don't have more sessions than the queue contains
                              -    log.debug('Server %s: Releasing session %s', self.server, session.session_id)
                              +    log.debug("Server %s: Releasing session %s", self.server, session.session_id)
                                   if self.MAX_SESSION_USAGE_COUNT and session.usage_count >= self.MAX_SESSION_USAGE_COUNT:
                              -        log.debug('Server %s: session %s usage exceeded limit. Discarding', self.server, session.session_id)
                              +        log.debug("Server %s: session %s usage exceeded limit. Discarding", self.server, session.session_id)
                                       session = self.renew_session(session)
                                   self._session_pool.put(session, block=False)
                              @@ -3282,7 +3513,7 @@

                              Methods

                              def renew_session(self, session):
                                   # The session is useless. Close it completely and place a fresh session in the pool
                              -    log.debug('Server %s: Renewing session %s', self.server, session.session_id)
                              +    log.debug("Server %s: Renewing session %s", self.server, session.session_id)
                                   self.close_session(session)
                                   return self.create_session()
                              @@ -3298,7 +3529,7 @@

                              Methods

                              def retire_session(self, session):
                                   # The session is useless. Close it completely and place a fresh session in the pool
                              -    log.debug('Server %s: Retiring session %s', self.server, session.session_id)
                              +    log.debug("Server %s: Retiring session %s", self.server, session.session_id)
                                   self.close_session(session)
                                   self.release_session(self.create_session())
                              @@ -3322,7 +3553,7 @@

                              Methods

                              MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/body """ - body_type = 'Text' + body_type = "Text" def __add__(self, other): # Make sure Body('') + 'foo' returns a Body type @@ -3387,36 +3618,36 @@

                              Methods

                              # List of build numbers here: https://docs.microsoft.com/en-us/exchange/new-features/build-numbers-and-release-dates API_VERSION_MAP = { 8: { - 0: 'Exchange2007', - 1: 'Exchange2007_SP1', - 2: 'Exchange2007_SP1', - 3: 'Exchange2007_SP1', + 0: "Exchange2007", + 1: "Exchange2007_SP1", + 2: "Exchange2007_SP1", + 3: "Exchange2007_SP1", }, 14: { - 0: 'Exchange2010', - 1: 'Exchange2010_SP1', - 2: 'Exchange2010_SP2', - 3: 'Exchange2010_SP2', + 0: "Exchange2010", + 1: "Exchange2010_SP1", + 2: "Exchange2010_SP2", + 3: "Exchange2010_SP2", }, 15: { - 0: 'Exchange2013', # Minor builds starting from 847 are Exchange2013_SP1, see api_version() - 1: 'Exchange2016', - 2: 'Exchange2019', - 20: 'Exchange2016', # This is Office365. See issue #221 + 0: "Exchange2013", # Minor builds starting from 847 are Exchange2013_SP1, see api_version() + 1: "Exchange2016", + 2: "Exchange2019", + 20: "Exchange2016", # This is Office365. See issue #221 }, } - __slots__ = 'major_version', 'minor_version', 'major_build', 'minor_build' + __slots__ = "major_version", "minor_version", "major_build", "minor_build" def __init__(self, major_version, minor_version, major_build=0, minor_build=0): if not isinstance(major_version, int): - raise InvalidTypeError('major_version', major_version, int) + raise InvalidTypeError("major_version", major_version, int) if not isinstance(minor_version, int): - raise InvalidTypeError('minor_version', minor_version, int) + raise InvalidTypeError("minor_version", minor_version, int) if not isinstance(major_build, int): - raise InvalidTypeError('major_build', major_build, int) + raise InvalidTypeError("major_build", major_build, int) if not isinstance(minor_build, int): - raise InvalidTypeError('minor_build', minor_build, int) + raise InvalidTypeError("minor_build", minor_build, int) self.major_version = major_version self.minor_version = minor_version self.major_build = major_build @@ -3427,10 +3658,10 @@

                              Methods

                              @classmethod def from_xml(cls, elem): xml_elems_map = { - 'major_version': 'MajorVersion', - 'minor_version': 'MinorVersion', - 'major_build': 'MajorBuildNumber', - 'minor_build': 'MinorBuildNumber', + "major_version": "MajorVersion", + "minor_version": "MinorVersion", + "major_build": "MajorBuildNumber", + "minor_build": "MinorBuildNumber", } kwargs = {} for k, xml_elem in xml_elems_map.items(): @@ -3454,7 +3685,7 @@

                              Methods

                              :param s: """ - bin_s = f'{int(s, 16):032b}' # Convert string to 32-bit binary string + bin_s = f"{int(s, 16):032b}" # Convert string to 32-bit binary string major_version = int(bin_s[4:10], 2) minor_version = int(bin_s[10:16], 2) build_number = int(bin_s[17:32], 2) @@ -3462,11 +3693,11 @@

                              Methods

                              def api_version(self): if EXCHANGE_2013_SP1 <= self < EXCHANGE_2016: - return 'Exchange2013_SP1' + return "Exchange2013_SP1" try: return self.API_VERSION_MAP[self.major_version][self.minor_version] except KeyError: - raise ValueError(f'API version for build {self} is unknown') + raise ValueError(f"API version for build {self} is unknown") def fullname(self): return VERSIONS[self.api_version()][1] @@ -3506,11 +3737,12 @@

                              Methods

                              return self.__cmp__(other) >= 0 def __str__(self): - return f'{self.major_version}.{self.minor_version}.{self.major_build}.{self.minor_build}' + return f"{self.major_version}.{self.minor_version}.{self.major_build}.{self.minor_build}" def __repr__(self): - return self.__class__.__name__ \ - + repr((self.major_version, self.minor_version, self.major_build, self.minor_build)) + return self.__class__.__name__ + repr( + (self.major_version, self.minor_version, self.major_build, self.minor_build) + )

                              Class variables

                              @@ -3552,7 +3784,7 @@

                              Static methods

                              :param s: """ - bin_s = f'{int(s, 16):032b}' # Convert string to 32-bit binary string + bin_s = f"{int(s, 16):032b}" # Convert string to 32-bit binary string major_version = int(bin_s[4:10], 2) minor_version = int(bin_s[10:16], 2) build_number = int(bin_s[17:32], 2) @@ -3571,10 +3803,10 @@

                              Static methods

                              @classmethod
                               def from_xml(cls, elem):
                                   xml_elems_map = {
                              -        'major_version': 'MajorVersion',
                              -        'minor_version': 'MinorVersion',
                              -        'major_build': 'MajorBuildNumber',
                              -        'minor_build': 'MinorBuildNumber',
                              +        "major_version": "MajorVersion",
                              +        "minor_version": "MinorVersion",
                              +        "major_build": "MajorBuildNumber",
                              +        "minor_build": "MinorBuildNumber",
                                   }
                                   kwargs = {}
                                   for k, xml_elem in xml_elems_map.items():
                              @@ -3618,11 +3850,11 @@ 

                              Methods

                              def api_version(self):
                                   if EXCHANGE_2013_SP1 <= self < EXCHANGE_2016:
                              -        return 'Exchange2013_SP1'
                              +        return "Exchange2013_SP1"
                                   try:
                                       return self.API_VERSION_MAP[self.major_version][self.minor_version]
                                   except KeyError:
                              -        raise ValueError(f'API version for build {self} is unknown')
                              + raise ValueError(f"API version for build {self} is unknown")
                              @@ -3658,66 +3890,75 @@

                              Methods

                              class CalendarItem(Item, AcceptDeclineMixIn):
                                   """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendaritem"""
                               
                              -    ELEMENT_NAME = 'CalendarItem'
                              -
                              -    uid = TextField(field_uri='calendar:UID', is_required_after_save=True, is_searchable=False)
                              -    recurrence_id = DateTimeField(field_uri='calendar:RecurrenceId', is_read_only=True)
                              -    start = DateOrDateTimeField(field_uri='calendar:Start', is_required=True)
                              -    end = DateOrDateTimeField(field_uri='calendar:End', is_required=True)
                              -    original_start = DateTimeField(field_uri='calendar:OriginalStart', is_read_only=True)
                              -    is_all_day = BooleanField(field_uri='calendar:IsAllDayEvent', is_required=True, default=False)
                              -    legacy_free_busy_status = FreeBusyStatusField(field_uri='calendar:LegacyFreeBusyStatus', is_required=True,
                              -                                                  default='Busy')
                              -    location = TextField(field_uri='calendar:Location')
                              -    when = TextField(field_uri='calendar:When')
                              -    is_meeting = BooleanField(field_uri='calendar:IsMeeting', is_read_only=True)
                              -    is_cancelled = BooleanField(field_uri='calendar:IsCancelled', is_read_only=True)
                              -    is_recurring = BooleanField(field_uri='calendar:IsRecurring', is_read_only=True)
                              -    meeting_request_was_sent = BooleanField(field_uri='calendar:MeetingRequestWasSent', is_read_only=True)
                              -    is_response_requested = BooleanField(field_uri='calendar:IsResponseRequested', default=None,
                              -                                         is_required_after_save=True, is_searchable=False)
                              -    type = ChoiceField(field_uri='calendar:CalendarItemType', choices={Choice(c) for c in CALENDAR_ITEM_CHOICES},
                              -                       is_read_only=True)
                              -    my_response_type = ChoiceField(field_uri='calendar:MyResponseType', choices={
                              -            Choice(c) for c in Attendee.RESPONSE_TYPES
                              -    }, is_read_only=True)
                              -    organizer = MailboxField(field_uri='calendar:Organizer', is_read_only=True)
                              -    required_attendees = AttendeesField(field_uri='calendar:RequiredAttendees', is_searchable=False)
                              -    optional_attendees = AttendeesField(field_uri='calendar:OptionalAttendees', is_searchable=False)
                              -    resources = AttendeesField(field_uri='calendar:Resources', is_searchable=False)
                              -    conflicting_meeting_count = IntegerField(field_uri='calendar:ConflictingMeetingCount', is_read_only=True)
                              -    adjacent_meeting_count = IntegerField(field_uri='calendar:AdjacentMeetingCount', is_read_only=True)
                              -    conflicting_meetings = EWSElementListField(field_uri='calendar:ConflictingMeetings', value_cls='CalendarItem',
                              -                                               namespace=Item.NAMESPACE, is_read_only=True)
                              -    adjacent_meetings = EWSElementListField(field_uri='calendar:AdjacentMeetings', value_cls='CalendarItem',
                              -                                            namespace=Item.NAMESPACE, is_read_only=True)
                              -    duration = CharField(field_uri='calendar:Duration', is_read_only=True)
                              -    appointment_reply_time = DateTimeField(field_uri='calendar:AppointmentReplyTime', is_read_only=True)
                              -    appointment_sequence_number = IntegerField(field_uri='calendar:AppointmentSequenceNumber', is_read_only=True)
                              -    appointment_state = AppointmentStateField(field_uri='calendar:AppointmentState', is_read_only=True)
                              -    recurrence = RecurrenceField(field_uri='calendar:Recurrence', is_searchable=False)
                              -    first_occurrence = OccurrenceField(field_uri='calendar:FirstOccurrence', value_cls=FirstOccurrence,
                              -                                       is_read_only=True)
                              -    last_occurrence = OccurrenceField(field_uri='calendar:LastOccurrence', value_cls=LastOccurrence,
                              -                                      is_read_only=True)
                              -    modified_occurrences = OccurrenceListField(field_uri='calendar:ModifiedOccurrences', value_cls=Occurrence,
                              -                                               is_read_only=True)
                              -    deleted_occurrences = OccurrenceListField(field_uri='calendar:DeletedOccurrences', value_cls=DeletedOccurrence,
                              -                                              is_read_only=True)
                              -    _meeting_timezone = TimeZoneField(field_uri='calendar:MeetingTimeZone', deprecated_from=EXCHANGE_2010,
                              -                                      is_searchable=False)
                              -    _start_timezone = TimeZoneField(field_uri='calendar:StartTimeZone', supported_from=EXCHANGE_2010,
                              -                                    is_searchable=False)
                              -    _end_timezone = TimeZoneField(field_uri='calendar:EndTimeZone', supported_from=EXCHANGE_2010,
                              -                                  is_searchable=False)
                              -    conference_type = EnumAsIntField(field_uri='calendar:ConferenceType', enum=CONFERENCE_TYPES, min=0,
                              -                                     default=None, is_required_after_save=True)
                              -    allow_new_time_proposal = BooleanField(field_uri='calendar:AllowNewTimeProposal', default=None,
                              -                                           is_required_after_save=True, is_searchable=False)
                              -    is_online_meeting = BooleanField(field_uri='calendar:IsOnlineMeeting', default=None,
                              -                                     is_read_only=True)
                              -    meeting_workspace_url = URIField(field_uri='calendar:MeetingWorkspaceUrl')
                              -    net_show_url = URIField(field_uri='calendar:NetShowUrl')
                              +    ELEMENT_NAME = "CalendarItem"
                              +
                              +    uid = TextField(field_uri="calendar:UID", is_required_after_save=True, is_searchable=False)
                              +    recurrence_id = DateTimeField(field_uri="calendar:RecurrenceId", is_read_only=True)
                              +    start = DateOrDateTimeField(field_uri="calendar:Start", is_required=True)
                              +    end = DateOrDateTimeField(field_uri="calendar:End", is_required=True)
                              +    original_start = DateTimeField(field_uri="calendar:OriginalStart", is_read_only=True)
                              +    is_all_day = BooleanField(field_uri="calendar:IsAllDayEvent", is_required=True, default=False)
                              +    legacy_free_busy_status = FreeBusyStatusField(
                              +        field_uri="calendar:LegacyFreeBusyStatus", is_required=True, default="Busy"
                              +    )
                              +    location = TextField(field_uri="calendar:Location")
                              +    when = TextField(field_uri="calendar:When")
                              +    is_meeting = BooleanField(field_uri="calendar:IsMeeting", is_read_only=True)
                              +    is_cancelled = BooleanField(field_uri="calendar:IsCancelled", is_read_only=True)
                              +    is_recurring = BooleanField(field_uri="calendar:IsRecurring", is_read_only=True)
                              +    meeting_request_was_sent = BooleanField(field_uri="calendar:MeetingRequestWasSent", is_read_only=True)
                              +    is_response_requested = BooleanField(
                              +        field_uri="calendar:IsResponseRequested", default=None, is_required_after_save=True, is_searchable=False
                              +    )
                              +    type = ChoiceField(
                              +        field_uri="calendar:CalendarItemType", choices={Choice(c) for c in CALENDAR_ITEM_CHOICES}, is_read_only=True
                              +    )
                              +    my_response_type = ChoiceField(
                              +        field_uri="calendar:MyResponseType", choices={Choice(c) for c in Attendee.RESPONSE_TYPES}, is_read_only=True
                              +    )
                              +    organizer = MailboxField(field_uri="calendar:Organizer", is_read_only=True)
                              +    required_attendees = AttendeesField(field_uri="calendar:RequiredAttendees", is_searchable=False)
                              +    optional_attendees = AttendeesField(field_uri="calendar:OptionalAttendees", is_searchable=False)
                              +    resources = AttendeesField(field_uri="calendar:Resources", is_searchable=False)
                              +    conflicting_meeting_count = IntegerField(field_uri="calendar:ConflictingMeetingCount", is_read_only=True)
                              +    adjacent_meeting_count = IntegerField(field_uri="calendar:AdjacentMeetingCount", is_read_only=True)
                              +    conflicting_meetings = EWSElementListField(
                              +        field_uri="calendar:ConflictingMeetings", value_cls="CalendarItem", namespace=Item.NAMESPACE, is_read_only=True
                              +    )
                              +    adjacent_meetings = EWSElementListField(
                              +        field_uri="calendar:AdjacentMeetings", value_cls="CalendarItem", namespace=Item.NAMESPACE, is_read_only=True
                              +    )
                              +    duration = CharField(field_uri="calendar:Duration", is_read_only=True)
                              +    appointment_reply_time = DateTimeField(field_uri="calendar:AppointmentReplyTime", is_read_only=True)
                              +    appointment_sequence_number = IntegerField(field_uri="calendar:AppointmentSequenceNumber", is_read_only=True)
                              +    appointment_state = AppointmentStateField(field_uri="calendar:AppointmentState", is_read_only=True)
                              +    recurrence = RecurrenceField(field_uri="calendar:Recurrence", is_searchable=False)
                              +    first_occurrence = OccurrenceField(
                              +        field_uri="calendar:FirstOccurrence", value_cls=FirstOccurrence, is_read_only=True
                              +    )
                              +    last_occurrence = OccurrenceField(field_uri="calendar:LastOccurrence", value_cls=LastOccurrence, is_read_only=True)
                              +    modified_occurrences = OccurrenceListField(
                              +        field_uri="calendar:ModifiedOccurrences", value_cls=Occurrence, is_read_only=True
                              +    )
                              +    deleted_occurrences = OccurrenceListField(
                              +        field_uri="calendar:DeletedOccurrences", value_cls=DeletedOccurrence, is_read_only=True
                              +    )
                              +    _meeting_timezone = TimeZoneField(
                              +        field_uri="calendar:MeetingTimeZone", deprecated_from=EXCHANGE_2010, is_searchable=False
                              +    )
                              +    _start_timezone = TimeZoneField(
                              +        field_uri="calendar:StartTimeZone", supported_from=EXCHANGE_2010, is_searchable=False
                              +    )
                              +    _end_timezone = TimeZoneField(field_uri="calendar:EndTimeZone", supported_from=EXCHANGE_2010, is_searchable=False)
                              +    conference_type = EnumAsIntField(
                              +        field_uri="calendar:ConferenceType", enum=CONFERENCE_TYPES, min=0, default=None, is_required_after_save=True
                              +    )
                              +    allow_new_time_proposal = BooleanField(
                              +        field_uri="calendar:AllowNewTimeProposal", default=None, is_required_after_save=True, is_searchable=False
                              +    )
                              +    is_online_meeting = BooleanField(field_uri="calendar:IsOnlineMeeting", default=None, is_read_only=True)
                              +    meeting_workspace_url = URIField(field_uri="calendar:MeetingWorkspaceUrl")
                              +    net_show_url = URIField(field_uri="calendar:NetShowUrl")
                               
                                   def occurrence(self, index):
                                       """Get an occurrence of a recurring master by index. No query is sent to the server to actually fetch the item.
                              @@ -3788,9 +4029,7 @@ 

                              Methods

                              def cancel(self, **kwargs): return CancelCalendarItem( - account=self.account, - reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), - **kwargs + account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs ).send() def _update_fieldnames(self): @@ -3798,8 +4037,8 @@

                              Methods

                              if self.type == OCCURRENCE: # Some CalendarItem fields cannot be updated when the item is an occurrence. The values are empty when we # receive them so would have been updated because they are set to None. - update_fields.remove('recurrence') - update_fields.remove('uid') + update_fields.remove("recurrence") + update_fields.remove("uid") return update_fields @classmethod @@ -3809,15 +4048,15 @@

                              Methods

                              # applicable. if not item.is_all_day: return item - for field_name in ('start', 'end'): + for field_name in ("start", "end"): val = getattr(item, field_name) if val is None: continue # Return just the date part of the value. Subtract 1 day from the date if this is the end field. This is # the inverse of what we do in .to_xml(). Convert to the local timezone before getting the date. - if field_name == 'end': + if field_name == "end": val -= datetime.timedelta(days=1) - tz = getattr(item, f'_{field_name}_timezone') + tz = getattr(item, f"_{field_name}_timezone") setattr(item, field_name, val.astimezone(tz).date()) return item @@ -3825,11 +4064,11 @@

                              Methods

                              meeting_tz_field, start_tz_field, end_tz_field = CalendarItem.timezone_fields() if self.account.version.build < EXCHANGE_2010: return meeting_tz_field - if field_name == 'start': + if field_name == "start": return start_tz_field - if field_name == 'end': + if field_name == "end": return end_tz_field - raise ValueError('Unsupported field_name') + raise ValueError("Unsupported field_name") def date_to_datetime(self, field_name): # EWS always expects a datetime. If we have a date value, then convert it to datetime in the local @@ -3838,7 +4077,7 @@

                              Methods

                              value = getattr(self, field_name) tz = getattr(self, self.tz_field_for_field_name(field_name).name) value = EWSDateTime.combine(value, datetime.time(0, 0)).replace(tzinfo=tz) - if field_name == 'end': + if field_name == "end": value += datetime.timedelta(days=1) return value @@ -3852,7 +4091,7 @@

                              Methods

                              elem = super().to_xml(version=version) if not self.is_all_day: return elem - for field_name in ('start', 'end'): + for field_name in ("start", "end"): value = getattr(self, field_name) if value is None: continue @@ -3903,15 +4142,15 @@

                              Static methods

                              # applicable. if not item.is_all_day: return item - for field_name in ('start', 'end'): + for field_name in ("start", "end"): val = getattr(item, field_name) if val is None: continue # Return just the date part of the value. Subtract 1 day from the date if this is the end field. This is # the inverse of what we do in .to_xml(). Convert to the local timezone before getting the date. - if field_name == 'end': + if field_name == "end": val -= datetime.timedelta(days=1) - tz = getattr(item, f'_{field_name}_timezone') + tz = getattr(item, f"_{field_name}_timezone") setattr(item, field_name, val.astimezone(tz).date()) return item
                              @@ -4099,9 +4338,7 @@

                              Methods

                              def cancel(self, **kwargs):
                                   return CancelCalendarItem(
                              -        account=self.account,
                              -        reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey),
                              -        **kwargs
                              +        account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs
                                   ).send()
                              @@ -4174,7 +4411,7 @@

                              Methods

                              value = getattr(self, field_name) tz = getattr(self, self.tz_field_for_field_name(field_name).name) value = EWSDateTime.combine(value, datetime.time(0, 0)).replace(tzinfo=tz) - if field_name == 'end': + if field_name == "end": value += datetime.timedelta(days=1) return value
                              @@ -4255,7 +4492,7 @@

                              Methods

                              elem = super().to_xml(version=version) if not self.is_all_day: return elem - for field_name in ('start', 'end'): + for field_name in ("start", "end"): value = getattr(self, field_name) if value is None: continue @@ -4282,11 +4519,11 @@

                              Methods

                              meeting_tz_field, start_tz_field, end_tz_field = CalendarItem.timezone_fields() if self.account.version.build < EXCHANGE_2010: return meeting_tz_field - if field_name == 'start': + if field_name == "start": return start_tz_field - if field_name == 'end': + if field_name == "end": return end_tz_field - raise ValueError('Unsupported field_name') + raise ValueError("Unsupported field_name")
                              @@ -4322,9 +4559,9 @@

                              Inherited members

                              class CancelCalendarItem(BaseReplyItem):
                                   """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/cancelcalendaritem"""
                               
                              -    ELEMENT_NAME = 'CancelCalendarItem'
                              -    author_idx = BaseReplyItem.FIELDS.index_by_name('author')
                              -    FIELDS = BaseReplyItem.FIELDS[:author_idx] + BaseReplyItem.FIELDS[author_idx + 1:]
                              + ELEMENT_NAME = "CancelCalendarItem" + author_idx = BaseReplyItem.FIELDS.index_by_name("author") + FIELDS = BaseReplyItem.FIELDS[:author_idx] + BaseReplyItem.FIELDS[author_idx + 1 :]

                              Ancestors

                                @@ -4416,30 +4653,38 @@

                                Inherited members

                                policies on the Exchange server. """ - def __init__(self, credentials=None, server=None, service_endpoint=None, auth_type=None, version=None, - retry_policy=None, max_connections=None): + def __init__( + self, + credentials=None, + server=None, + service_endpoint=None, + auth_type=None, + version=None, + retry_policy=None, + max_connections=None, + ): if not isinstance(credentials, (BaseCredentials, type(None))): - raise InvalidTypeError('credentials', credentials, BaseCredentials) + raise InvalidTypeError("credentials", credentials, BaseCredentials) if auth_type is None: # Set a default auth type for the credentials where this makes sense auth_type = DEFAULT_AUTH_TYPE.get(type(credentials)) elif credentials is None and auth_type in CREDENTIALS_REQUIRED: - raise ValueError(f'Auth type {auth_type!r} was detected but no credentials were provided') + raise ValueError(f"Auth type {auth_type!r} was detected but no credentials were provided") if server and service_endpoint: raise AttributeError("Only one of 'server' or 'service_endpoint' must be provided") if auth_type is not None and auth_type not in AUTH_TYPE_MAP: - raise InvalidEnumValue('auth_type', auth_type, AUTH_TYPE_MAP) + raise InvalidEnumValue("auth_type", auth_type, AUTH_TYPE_MAP) if not retry_policy: retry_policy = FailFast() if not isinstance(version, (Version, type(None))): - raise InvalidTypeError('version', version, Version) + raise InvalidTypeError("version", version, Version) if not isinstance(retry_policy, RetryPolicy): - raise InvalidTypeError('retry_policy', retry_policy, RetryPolicy) + raise InvalidTypeError("retry_policy", retry_policy, RetryPolicy) if not isinstance(max_connections, (int, type(None))): - raise InvalidTypeError('max_connections', max_connections, int) + raise InvalidTypeError("max_connections", max_connections, int) self._credentials = credentials if server: - self.service_endpoint = f'https://{server}/EWS/Exchange.asmx' + self.service_endpoint = f"https://{server}/EWS/Exchange.asmx" else: self.service_endpoint = service_endpoint self.auth_type = auth_type @@ -4459,10 +4704,11 @@

                                Inherited members

                                return split_url(self.service_endpoint)[1] def __repr__(self): - args_str = ', '.join(f'{k}={getattr(self, k)!r}' for k in ( - 'credentials', 'service_endpoint', 'auth_type', 'version', 'retry_policy' - )) - return f'{self.__class__.__name__}({args_str})' + args_str = ", ".join( + f"{k}={getattr(self, k)!r}" + for k in ("credentials", "service_endpoint", "auth_type", "version", "retry_policy") + ) + return f"{self.__class__.__name__}({args_str})"

                                Instance variables

                                @@ -4522,75 +4768,100 @@

                                Instance variables

                                class Contact(Item):
                                     """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/contact"""
                                 
                                -    ELEMENT_NAME = 'Contact'
                                -
                                -    file_as = TextField(field_uri='contacts:FileAs')
                                -    file_as_mapping = ChoiceField(field_uri='contacts:FileAsMapping', choices={
                                -        Choice('None'), Choice('LastCommaFirst'), Choice('FirstSpaceLast'), Choice('Company'),
                                -        Choice('LastCommaFirstCompany'), Choice('CompanyLastFirst'), Choice('LastFirst'),
                                -        Choice('LastFirstCompany'), Choice('CompanyLastCommaFirst'), Choice('LastFirstSuffix'),
                                -        Choice('LastSpaceFirstCompany'), Choice('CompanyLastSpaceFirst'), Choice('LastSpaceFirst'),
                                -        Choice('DisplayName'), Choice('FirstName'), Choice('LastFirstMiddleSuffix'), Choice('LastName'),
                                -        Choice('Empty'),
                                -    })
                                -    display_name = TextField(field_uri='contacts:DisplayName', is_required=True)
                                -    given_name = CharField(field_uri='contacts:GivenName')
                                -    initials = TextField(field_uri='contacts:Initials')
                                -    middle_name = CharField(field_uri='contacts:MiddleName')
                                -    nickname = TextField(field_uri='contacts:Nickname')
                                -    complete_name = EWSElementField(field_uri='contacts:CompleteName', value_cls=CompleteName, is_read_only=True)
                                -    company_name = TextField(field_uri='contacts:CompanyName')
                                -    email_addresses = EmailAddressesField(field_uri='contacts:EmailAddress')
                                -    physical_addresses = PhysicalAddressField(field_uri='contacts:PhysicalAddress')
                                -    phone_numbers = PhoneNumberField(field_uri='contacts:PhoneNumber')
                                -    assistant_name = TextField(field_uri='contacts:AssistantName')
                                -    birthday = DateTimeBackedDateField(field_uri='contacts:Birthday', default_time=datetime.time(11, 59))
                                -    business_homepage = URIField(field_uri='contacts:BusinessHomePage')
                                -    children = TextListField(field_uri='contacts:Children')
                                -    companies = TextListField(field_uri='contacts:Companies', is_searchable=False)
                                -    contact_source = ChoiceField(field_uri='contacts:ContactSource', choices={
                                -        Choice('Store'), Choice('ActiveDirectory')
                                -    }, is_read_only=True)
                                -    department = TextField(field_uri='contacts:Department')
                                -    generation = TextField(field_uri='contacts:Generation')
                                -    im_addresses = CharField(field_uri='contacts:ImAddresses', is_read_only=True)
                                -    job_title = TextField(field_uri='contacts:JobTitle')
                                -    manager = TextField(field_uri='contacts:Manager')
                                -    mileage = TextField(field_uri='contacts:Mileage')
                                -    office = TextField(field_uri='contacts:OfficeLocation')
                                -    postal_address_index = ChoiceField(field_uri='contacts:PostalAddressIndex', choices={
                                -        Choice('Business'), Choice('Home'), Choice('Other'), Choice('None')
                                -    }, default='None', is_required_after_save=True)
                                -    profession = TextField(field_uri='contacts:Profession')
                                -    spouse_name = TextField(field_uri='contacts:SpouseName')
                                -    surname = CharField(field_uri='contacts:Surname')
                                -    wedding_anniversary = DateTimeBackedDateField(field_uri='contacts:WeddingAnniversary',
                                -                                                  default_time=datetime.time(11, 59))
                                -    has_picture = BooleanField(field_uri='contacts:HasPicture', supported_from=EXCHANGE_2010, is_read_only=True)
                                -    phonetic_full_name = TextField(field_uri='contacts:PhoneticFullName', supported_from=EXCHANGE_2010_SP2,
                                -                                   is_read_only=True)
                                -    phonetic_first_name = TextField(field_uri='contacts:PhoneticFirstName', supported_from=EXCHANGE_2010_SP2,
                                -                                    is_read_only=True)
                                -    phonetic_last_name = TextField(field_uri='contacts:PhoneticLastName', supported_from=EXCHANGE_2010_SP2,
                                -                                   is_read_only=True)
                                -    email_alias = EmailAddressField(field_uri='contacts:Alias', is_read_only=True,
                                -                                    supported_from=EXCHANGE_2010_SP2)
                                +    ELEMENT_NAME = "Contact"
                                +
                                +    file_as = TextField(field_uri="contacts:FileAs")
                                +    file_as_mapping = ChoiceField(
                                +        field_uri="contacts:FileAsMapping",
                                +        choices={
                                +            Choice("None"),
                                +            Choice("LastCommaFirst"),
                                +            Choice("FirstSpaceLast"),
                                +            Choice("Company"),
                                +            Choice("LastCommaFirstCompany"),
                                +            Choice("CompanyLastFirst"),
                                +            Choice("LastFirst"),
                                +            Choice("LastFirstCompany"),
                                +            Choice("CompanyLastCommaFirst"),
                                +            Choice("LastFirstSuffix"),
                                +            Choice("LastSpaceFirstCompany"),
                                +            Choice("CompanyLastSpaceFirst"),
                                +            Choice("LastSpaceFirst"),
                                +            Choice("DisplayName"),
                                +            Choice("FirstName"),
                                +            Choice("LastFirstMiddleSuffix"),
                                +            Choice("LastName"),
                                +            Choice("Empty"),
                                +        },
                                +    )
                                +    display_name = TextField(field_uri="contacts:DisplayName", is_required=True)
                                +    given_name = CharField(field_uri="contacts:GivenName")
                                +    initials = TextField(field_uri="contacts:Initials")
                                +    middle_name = CharField(field_uri="contacts:MiddleName")
                                +    nickname = TextField(field_uri="contacts:Nickname")
                                +    complete_name = EWSElementField(field_uri="contacts:CompleteName", value_cls=CompleteName, is_read_only=True)
                                +    company_name = TextField(field_uri="contacts:CompanyName")
                                +    email_addresses = EmailAddressesField(field_uri="contacts:EmailAddress")
                                +    physical_addresses = PhysicalAddressField(field_uri="contacts:PhysicalAddress")
                                +    phone_numbers = PhoneNumberField(field_uri="contacts:PhoneNumber")
                                +    assistant_name = TextField(field_uri="contacts:AssistantName")
                                +    birthday = DateTimeBackedDateField(field_uri="contacts:Birthday", default_time=datetime.time(11, 59))
                                +    business_homepage = URIField(field_uri="contacts:BusinessHomePage")
                                +    children = TextListField(field_uri="contacts:Children")
                                +    companies = TextListField(field_uri="contacts:Companies", is_searchable=False)
                                +    contact_source = ChoiceField(
                                +        field_uri="contacts:ContactSource", choices={Choice("Store"), Choice("ActiveDirectory")}, is_read_only=True
                                +    )
                                +    department = TextField(field_uri="contacts:Department")
                                +    generation = TextField(field_uri="contacts:Generation")
                                +    im_addresses = CharField(field_uri="contacts:ImAddresses", is_read_only=True)
                                +    job_title = TextField(field_uri="contacts:JobTitle")
                                +    manager = TextField(field_uri="contacts:Manager")
                                +    mileage = TextField(field_uri="contacts:Mileage")
                                +    office = TextField(field_uri="contacts:OfficeLocation")
                                +    postal_address_index = ChoiceField(
                                +        field_uri="contacts:PostalAddressIndex",
                                +        choices={Choice("Business"), Choice("Home"), Choice("Other"), Choice("None")},
                                +        default="None",
                                +        is_required_after_save=True,
                                +    )
                                +    profession = TextField(field_uri="contacts:Profession")
                                +    spouse_name = TextField(field_uri="contacts:SpouseName")
                                +    surname = CharField(field_uri="contacts:Surname")
                                +    wedding_anniversary = DateTimeBackedDateField(
                                +        field_uri="contacts:WeddingAnniversary", default_time=datetime.time(11, 59)
                                +    )
                                +    has_picture = BooleanField(field_uri="contacts:HasPicture", supported_from=EXCHANGE_2010, is_read_only=True)
                                +    phonetic_full_name = TextField(
                                +        field_uri="contacts:PhoneticFullName", supported_from=EXCHANGE_2010_SP2, is_read_only=True
                                +    )
                                +    phonetic_first_name = TextField(
                                +        field_uri="contacts:PhoneticFirstName", supported_from=EXCHANGE_2010_SP2, is_read_only=True
                                +    )
                                +    phonetic_last_name = TextField(
                                +        field_uri="contacts:PhoneticLastName", supported_from=EXCHANGE_2010_SP2, is_read_only=True
                                +    )
                                +    email_alias = EmailAddressField(field_uri="contacts:Alias", is_read_only=True, supported_from=EXCHANGE_2010_SP2)
                                     # 'notes' is documented in MSDN but apparently unused. Writing to it raises ErrorInvalidPropertyRequest. OWA
                                     # put entries into the 'notes' form field into the 'body' field.
                                -    notes = CharField(field_uri='contacts:Notes', supported_from=EXCHANGE_2010_SP2, is_read_only=True)
                                +    notes = CharField(field_uri="contacts:Notes", supported_from=EXCHANGE_2010_SP2, is_read_only=True)
                                     # 'photo' is documented in MSDN but apparently unused. Writing to it raises ErrorInvalidPropertyRequest. OWA
                                     # adds photos as FileAttachments on the contact item (with 'is_contact_photo=True'), which automatically flips
                                     # the 'has_picture' field.
                                -    photo = Base64Field(field_uri='contacts:Photo', supported_from=EXCHANGE_2010_SP2, is_read_only=True)
                                -    user_smime_certificate = Base64Field(field_uri='contacts:UserSMIMECertificate', supported_from=EXCHANGE_2010_SP2,
                                -                                         is_read_only=True)
                                -    ms_exchange_certificate = Base64Field(field_uri='contacts:MSExchangeCertificate', supported_from=EXCHANGE_2010_SP2,
                                -                                          is_read_only=True)
                                -    directory_id = TextField(field_uri='contacts:DirectoryId', supported_from=EXCHANGE_2010_SP2, is_read_only=True)
                                -    manager_mailbox = MailboxField(field_uri='contacts:ManagerMailbox', supported_from=EXCHANGE_2010_SP2,
                                -                                   is_read_only=True)
                                -    direct_reports = MailboxListField(field_uri='contacts:DirectReports', supported_from=EXCHANGE_2010_SP2,
                                -                                      is_read_only=True)
                                + photo = Base64Field(field_uri="contacts:Photo", supported_from=EXCHANGE_2010_SP2, is_read_only=True) + user_smime_certificate = Base64Field( + field_uri="contacts:UserSMIMECertificate", supported_from=EXCHANGE_2010_SP2, is_read_only=True + ) + ms_exchange_certificate = Base64Field( + field_uri="contacts:MSExchangeCertificate", supported_from=EXCHANGE_2010_SP2, is_read_only=True + ) + directory_id = TextField(field_uri="contacts:DirectoryId", supported_from=EXCHANGE_2010_SP2, is_read_only=True) + manager_mailbox = MailboxField( + field_uri="contacts:ManagerMailbox", supported_from=EXCHANGE_2010_SP2, is_read_only=True + ) + direct_reports = MailboxListField( + field_uri="contacts:DirectReports", supported_from=EXCHANGE_2010_SP2, is_read_only=True + )

                                Ancestors

                                  @@ -4826,15 +5097,15 @@

                                  Inherited members

                                  password: Clear-text password """ - EMAIL = 'email' - DOMAIN = 'domain' - UPN = 'upn' + EMAIL = "email" + DOMAIN = "domain" + UPN = "upn" def __init__(self, username, password): super().__init__() - if username.count('@') == 1: + if username.count("@") == 1: self.type = self.EMAIL - elif username.count('\\') == 1: + elif username.count("\\") == 1: self.type = self.DOMAIN else: self.type = self.UPN @@ -4845,7 +5116,7 @@

                                  Inherited members

                                  pass def __repr__(self): - return self.__class__.__name__ + repr((self.username, '********')) + return self.__class__.__name__ + repr((self.username, "********")) def __str__(self): return self.username @@ -4935,7 +5206,7 @@

                                  Inherited members

                                  class DeclineItem(BaseMeetingReplyItem):
                                       """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/declineitem"""
                                   
                                  -    ELEMENT_NAME = 'DeclineItem'
                                  + ELEMENT_NAME = "DeclineItem"

                                  Ancestors

                                    @@ -4987,14 +5258,14 @@

                                    Inherited members

                                    class DistributionList(Item):
                                         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/distributionlist"""
                                     
                                    -    ELEMENT_NAME = 'DistributionList'
                                    +    ELEMENT_NAME = "DistributionList"
                                     
                                    -    display_name = CharField(field_uri='contacts:DisplayName', is_required=True)
                                    -    file_as = CharField(field_uri='contacts:FileAs', is_read_only=True)
                                    -    contact_source = ChoiceField(field_uri='contacts:ContactSource', choices={
                                    -        Choice('Store'), Choice('ActiveDirectory')
                                    -    }, is_read_only=True)
                                    -    members = MemberListField(field_uri='distributionlist:Members')
                                    + display_name = CharField(field_uri="contacts:DisplayName", is_required=True) + file_as = CharField(field_uri="contacts:FileAs", is_read_only=True) + contact_source = ChoiceField( + field_uri="contacts:ContactSource", choices={Choice("Store"), Choice("ActiveDirectory")}, is_read_only=True + ) + members = MemberListField(field_uri="distributionlist:Members")

                                    Ancestors

                                      @@ -5066,7 +5337,7 @@

                                      Inherited members

                                      class EWSDate(datetime.date):
                                           """Extends the normal date implementation to satisfy EWS."""
                                       
                                      -    __slots__ = '_year', '_month', '_day', '_hashcode'
                                      +    __slots__ = "_year", "_month", "_day", "_hashcode"
                                       
                                           def ewsformat(self):
                                               """ISO 8601 format to satisfy xs:date as interpreted by EWS. Example: 2009-01-15."""
                                      @@ -5102,21 +5373,21 @@ 

                                      Inherited members

                                      @classmethod def from_date(cls, d): if type(d) is not datetime.date: - raise InvalidTypeError('d', d, datetime.date) + raise InvalidTypeError("d", d, datetime.date) return cls(d.year, d.month, d.day) @classmethod def from_string(cls, date_string): # Sometimes, we'll receive a date string with timezone information. Not very useful. - if date_string.endswith('Z'): - date_fmt = '%Y-%m-%dZ' - elif ':' in date_string: - if '+' in date_string: - date_fmt = '%Y-%m-%d+%H:%M' + if date_string.endswith("Z"): + date_fmt = "%Y-%m-%dZ" + elif ":" in date_string: + if "+" in date_string: + date_fmt = "%Y-%m-%d+%H:%M" else: - date_fmt = '%Y-%m-%d-%H:%M' + date_fmt = "%Y-%m-%d-%H:%M" else: - date_fmt = '%Y-%m-%d' + date_fmt = "%Y-%m-%d" d = datetime.datetime.strptime(date_string, date_fmt).date() if isinstance(d, cls): return d @@ -5140,7 +5411,7 @@

                                      Static methods

                                      @classmethod
                                       def from_date(cls, d):
                                           if type(d) is not datetime.date:
                                      -        raise InvalidTypeError('d', d, datetime.date)
                                      +        raise InvalidTypeError("d", d, datetime.date)
                                           return cls(d.year, d.month, d.day)
                                      @@ -5156,15 +5427,15 @@

                                      Static methods

                                      @classmethod
                                       def from_string(cls, date_string):
                                           # Sometimes, we'll receive a date string with timezone information. Not very useful.
                                      -    if date_string.endswith('Z'):
                                      -        date_fmt = '%Y-%m-%dZ'
                                      -    elif ':' in date_string:
                                      -        if '+' in date_string:
                                      -            date_fmt = '%Y-%m-%d+%H:%M'
                                      +    if date_string.endswith("Z"):
                                      +        date_fmt = "%Y-%m-%dZ"
                                      +    elif ":" in date_string:
                                      +        if "+" in date_string:
                                      +            date_fmt = "%Y-%m-%d+%H:%M"
                                               else:
                                      -            date_fmt = '%Y-%m-%d-%H:%M'
                                      +            date_fmt = "%Y-%m-%d-%H:%M"
                                           else:
                                      -        date_fmt = '%Y-%m-%d'
                                      +        date_fmt = "%Y-%m-%d"
                                           d = datetime.datetime.strptime(date_string, date_fmt).date()
                                           if isinstance(d, cls):
                                               return d
                                      @@ -5220,7 +5491,7 @@ 

                                      Methods

                                      class EWSDateTime(datetime.datetime):
                                           """Extends the normal datetime implementation to satisfy EWS."""
                                       
                                      -    __slots__ = '_year', '_month', '_day', '_hour', '_minute', '_second', '_microsecond', '_tzinfo', '_hashcode'
                                      +    __slots__ = "_year", "_month", "_day", "_hour", "_minute", "_second", "_microsecond", "_tzinfo", "_hashcode"
                                       
                                           def __new__(cls, *args, **kwargs):
                                               # pylint: disable=arguments-differ
                                      @@ -5228,16 +5499,16 @@ 

                                      Methods

                                      if len(args) == 8: tzinfo = args[7] else: - tzinfo = kwargs.get('tzinfo') + tzinfo = kwargs.get("tzinfo") if isinstance(tzinfo, zoneinfo.ZoneInfo): # Don't allow pytz or dateutil timezones here. They are not safe to use as direct input for datetime() tzinfo = EWSTimeZone.from_timezone(tzinfo) if not isinstance(tzinfo, (EWSTimeZone, type(None))): - raise InvalidTypeError('tzinfo', tzinfo, EWSTimeZone) + raise InvalidTypeError("tzinfo", tzinfo, EWSTimeZone) if len(args) == 8: args = args[:7] + (tzinfo,) else: - kwargs['tzinfo'] = tzinfo + kwargs["tzinfo"] = tzinfo return super().__new__(cls, *args, **kwargs) def ewsformat(self): @@ -5246,17 +5517,17 @@

                                      Methods

                                      * 2009-01-15T13:45:56+01:00 """ if not self.tzinfo: - raise ValueError(f'{self!r} must be timezone-aware') - if self.tzinfo.key == 'UTC': + raise ValueError(f"{self!r} must be timezone-aware") + if self.tzinfo.key == "UTC": if self.microsecond: - return self.strftime('%Y-%m-%dT%H:%M:%S.%fZ') - return self.strftime('%Y-%m-%dT%H:%M:%SZ') + return self.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + return self.strftime("%Y-%m-%dT%H:%M:%SZ") return self.isoformat() @classmethod def from_datetime(cls, d): if type(d) is not datetime.datetime: - raise InvalidTypeError('d', d, datetime.datetime) + raise InvalidTypeError("d", d, datetime.datetime) if d.tzinfo is None: tz = None elif isinstance(d.tzinfo, EWSTimeZone): @@ -5296,12 +5567,12 @@

                                      Methods

                                      @classmethod def from_string(cls, date_string): # Parses several common datetime formats and returns timezone-aware EWSDateTime objects - if date_string.endswith('Z'): + if date_string.endswith("Z"): # UTC datetime - return super().strptime(date_string, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=UTC) + return super().strptime(date_string, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) if len(date_string) == 19: # This is probably a naive datetime. Don't allow this, but signal caller with an appropriate error - local_dt = super().strptime(date_string, '%Y-%m-%dT%H:%M:%S') + local_dt = super().strptime(date_string, "%Y-%m-%dT%H:%M:%S") raise NaiveDateTimeNotAllowed(local_dt) # This is probably a datetime value with timezone information. This comes in the form '+/-HH:MM'. aware_dt = datetime.datetime.fromisoformat(date_string).astimezone(UTC).replace(tzinfo=UTC) @@ -5362,7 +5633,7 @@

                                      Static methods

                                      @classmethod
                                       def from_datetime(cls, d):
                                           if type(d) is not datetime.datetime:
                                      -        raise InvalidTypeError('d', d, datetime.datetime)
                                      +        raise InvalidTypeError("d", d, datetime.datetime)
                                           if d.tzinfo is None:
                                               tz = None
                                           elif isinstance(d.tzinfo, EWSTimeZone):
                                      @@ -5384,12 +5655,12 @@ 

                                      Static methods

                                      @classmethod
                                       def from_string(cls, date_string):
                                           # Parses several common datetime formats and returns timezone-aware EWSDateTime objects
                                      -    if date_string.endswith('Z'):
                                      +    if date_string.endswith("Z"):
                                               # UTC datetime
                                      -        return super().strptime(date_string, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=UTC)
                                      +        return super().strptime(date_string, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
                                           if len(date_string) == 19:
                                               # This is probably a naive datetime. Don't allow this, but signal caller with an appropriate error
                                      -        local_dt = super().strptime(date_string, '%Y-%m-%dT%H:%M:%S')
                                      +        local_dt = super().strptime(date_string, "%Y-%m-%dT%H:%M:%S")
                                               raise NaiveDateTimeNotAllowed(local_dt)
                                           # This is probably a datetime value with timezone information. This comes in the form '+/-HH:MM'.
                                           aware_dt = datetime.datetime.fromisoformat(date_string).astimezone(UTC).replace(tzinfo=UTC)
                                      @@ -5523,11 +5794,11 @@ 

                                      Methods

                                      * 2009-01-15T13:45:56+01:00 """ if not self.tzinfo: - raise ValueError(f'{self!r} must be timezone-aware') - if self.tzinfo.key == 'UTC': + raise ValueError(f"{self!r} must be timezone-aware") + if self.tzinfo.key == "UTC": if self.microsecond: - return self.strftime('%Y-%m-%dT%H:%M:%S.%fZ') - return self.strftime('%Y-%m-%dT%H:%M:%SZ') + return self.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + return self.strftime("%Y-%m-%dT%H:%M:%SZ") return self.isoformat()
                                      @@ -5560,12 +5831,12 @@

                                      Methods

                                      try: instance.ms_id = cls.IANA_TO_MS_MAP[instance.key][0] except KeyError: - raise UnknownTimeZone(f'No Windows timezone name found for timezone {instance.key!r}') + raise UnknownTimeZone(f"No Windows timezone name found for timezone {instance.key!r}") # We don't need the Windows long-format timezone name in long format. It's used in timezone XML elements, but # EWS happily accepts empty strings. For a full list of timezones supported by the target server, including # long-format names, see output of services.GetServerTimeZones(account.protocol).call() - instance.ms_name = '' + instance.ms_name = "" return instance def __eq__(self, other): @@ -5583,11 +5854,11 @@

                                      Methods

                                      try: return cls(cls.MS_TO_IANA_MAP[ms_id]) except KeyError: - if '/' in ms_id: + if "/" in ms_id: # EWS sometimes returns an ID that has a region/location format, e.g. 'Europe/Copenhagen'. Try the # string unaltered. return cls(ms_id) - raise UnknownTimeZone(f'Windows timezone ID {ms_id!r} is unknown by CLDR') + raise UnknownTimeZone(f"Windows timezone ID {ms_id!r} is unknown by CLDR") @classmethod def from_pytz(cls, tz): @@ -5602,8 +5873,8 @@

                                      Methods

                                      def from_dateutil(cls, tz): # Objects returned by dateutil.tz.tzlocal() and dateutil.tz.gettz() are not supported. They # don't contain enough information to reliably match them with a CLDR timezone. - if hasattr(tz, '_filename'): - key = '/'.join(tz._filename.split('/')[-2:]) + if hasattr(tz, "_filename"): + key = "/".join(tz._filename.split("/")[-2:]) return cls(key) return cls(tz.tzname(datetime.datetime.now())) @@ -5615,19 +5886,19 @@

                                      Methods

                                      def from_timezone(cls, tz): # Support multiple tzinfo implementations. We could use isinstance(), but then we'd have to have pytz # and dateutil as dependencies for this package. - tz_module = tz.__class__.__module__.split('.')[0] + tz_module = tz.__class__.__module__.split(".")[0] try: return { - cls.__module__.split('.')[0]: lambda z: z, - 'backports': cls.from_zoneinfo, - 'datetime': cls.from_datetime, - 'dateutil': cls.from_dateutil, - 'pytz': cls.from_pytz, - 'zoneinfo': cls.from_zoneinfo, - 'pytz_deprecation_shim': lambda z: cls.from_timezone(z.unwrap_shim()) + cls.__module__.split(".")[0]: lambda z: z, + "backports": cls.from_zoneinfo, + "datetime": cls.from_datetime, + "dateutil": cls.from_dateutil, + "pytz": cls.from_pytz, + "zoneinfo": cls.from_zoneinfo, + "pytz_deprecation_shim": lambda z: cls.from_timezone(z.unwrap_shim()), }[tz_module](tz) except KeyError: - raise TypeError(f'Unsupported tzinfo type: {tz!r}') + raise TypeError(f"Unsupported tzinfo type: {tz!r}") @classmethod def localzone(cls): @@ -5691,8 +5962,8 @@

                                      Static methods

                                      def from_dateutil(cls, tz): # Objects returned by dateutil.tz.tzlocal() and dateutil.tz.gettz() are not supported. They # don't contain enough information to reliably match them with a CLDR timezone. - if hasattr(tz, '_filename'): - key = '/'.join(tz._filename.split('/')[-2:]) + if hasattr(tz, "_filename"): + key = "/".join(tz._filename.split("/")[-2:]) return cls(key) return cls(tz.tzname(datetime.datetime.now()))
                                      @@ -5713,11 +5984,11 @@

                                      Static methods

                                      try: return cls(cls.MS_TO_IANA_MAP[ms_id]) except KeyError: - if '/' in ms_id: + if "/" in ms_id: # EWS sometimes returns an ID that has a region/location format, e.g. 'Europe/Copenhagen'. Try the # string unaltered. return cls(ms_id) - raise UnknownTimeZone(f'Windows timezone ID {ms_id!r} is unknown by CLDR')
                                      + raise UnknownTimeZone(f"Windows timezone ID {ms_id!r} is unknown by CLDR")
                                      @@ -5747,19 +6018,19 @@

                                      Static methods

                                      def from_timezone(cls, tz): # Support multiple tzinfo implementations. We could use isinstance(), but then we'd have to have pytz # and dateutil as dependencies for this package. - tz_module = tz.__class__.__module__.split('.')[0] + tz_module = tz.__class__.__module__.split(".")[0] try: return { - cls.__module__.split('.')[0]: lambda z: z, - 'backports': cls.from_zoneinfo, - 'datetime': cls.from_datetime, - 'dateutil': cls.from_dateutil, - 'pytz': cls.from_pytz, - 'zoneinfo': cls.from_zoneinfo, - 'pytz_deprecation_shim': lambda z: cls.from_timezone(z.unwrap_shim()) + cls.__module__.split(".")[0]: lambda z: z, + "backports": cls.from_zoneinfo, + "datetime": cls.from_datetime, + "dateutil": cls.from_dateutil, + "pytz": cls.from_pytz, + "zoneinfo": cls.from_zoneinfo, + "pytz_deprecation_shim": lambda z: cls.from_timezone(z.unwrap_shim()), }[tz_module](tz) except KeyError: - raise TypeError(f'Unsupported tzinfo type: {tz!r}')
                                      + raise TypeError(f"Unsupported tzinfo type: {tz!r}")
                                      @@ -5830,72 +6101,73 @@

                                      Methods

                                      class ExtendedProperty(EWSElement):
                                           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/extendedproperty"""
                                       
                                      -    ELEMENT_NAME = 'ExtendedProperty'
                                      +    ELEMENT_NAME = "ExtendedProperty"
                                       
                                           # Enum values: https://docs.microsoft.com/en-us/dotnet/api/exchangewebservices.distinguishedpropertysettype
                                           DISTINGUISHED_SETS = {
                                      -        'Address',
                                      -        'Appointment',
                                      -        'CalendarAssistant',
                                      -        'Common',
                                      -        'InternetHeaders',
                                      -        'Meeting',
                                      -        'PublicStrings',
                                      -        'Sharing',
                                      -        'Task',
                                      -        'UnifiedMessaging',
                                      +        "Address",
                                      +        "Appointment",
                                      +        "CalendarAssistant",
                                      +        "Common",
                                      +        "InternetHeaders",
                                      +        "Meeting",
                                      +        "PublicStrings",
                                      +        "Sharing",
                                      +        "Task",
                                      +        "UnifiedMessaging",
                                           }
                                           # Enum values: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/extendedfielduri
                                      +    # The following types cannot be used for setting or getting (see docs) and are thus not very useful here:
                                      +    # 'Error'
                                      +    # 'Null'
                                      +    # 'Object'
                                      +    # 'ObjectArray'
                                           PROPERTY_TYPES = {
                                      -        'ApplicationTime',
                                      -        'Binary',
                                      -        'BinaryArray',
                                      -        'Boolean',
                                      -        'CLSID',
                                      -        'CLSIDArray',
                                      -        'Currency',
                                      -        'CurrencyArray',
                                      -        'Double',
                                      -        'DoubleArray',
                                      -        # 'Error',
                                      -        'Float',
                                      -        'FloatArray',
                                      -        'Integer',
                                      -        'IntegerArray',
                                      -        'Long',
                                      -        'LongArray',
                                      -        # 'Null',
                                      -        # 'Object',
                                      -        # 'ObjectArray',
                                      -        'Short',
                                      -        'ShortArray',
                                      -        'SystemTime',
                                      -        'SystemTimeArray',
                                      -        'String',
                                      -        'StringArray',
                                      -    }  # The commented-out types cannot be used for setting or getting (see docs) and are thus not very useful here
                                      +        "ApplicationTime",
                                      +        "Binary",
                                      +        "BinaryArray",
                                      +        "Boolean",
                                      +        "CLSID",
                                      +        "CLSIDArray",
                                      +        "Currency",
                                      +        "CurrencyArray",
                                      +        "Double",
                                      +        "DoubleArray",
                                      +        "Float",
                                      +        "FloatArray",
                                      +        "Integer",
                                      +        "IntegerArray",
                                      +        "Long",
                                      +        "LongArray",
                                      +        "Short",
                                      +        "ShortArray",
                                      +        "SystemTime",
                                      +        "SystemTimeArray",
                                      +        "String",
                                      +        "StringArray",
                                      +    }
                                       
                                           # Translation table between common distinguished_property_set_id and property_set_id values. See
                                           # https://docs.microsoft.com/en-us/office/client-developer/outlook/mapi/commonly-used-property-sets
                                           # ID values must be lowercase.
                                           DISTINGUISHED_SET_NAME_TO_ID_MAP = {
                                      -        'Address': '00062004-0000-0000-c000-000000000046',
                                      -        'AirSync': '71035549-0739-4dcb-9163-00f0580dbbdf',
                                      -        'Appointment': '00062002-0000-0000-c000-000000000046',
                                      -        'Common': '00062008-0000-0000-c000-000000000046',
                                      -        'InternetHeaders': '00020386-0000-0000-c000-000000000046',
                                      -        'Log': '0006200a-0000-0000-c000-000000000046',
                                      -        'Mapi': '00020328-0000-0000-c000-000000000046',
                                      -        'Meeting': '6ed8da90-450b-101b-98da-00aa003f1305',
                                      -        'Messaging': '41f28f13-83f4-4114-a584-eedb5a6b0bff',
                                      -        'Note': '0006200e-0000-0000-c000-000000000046',
                                      -        'PostRss': '00062041-0000-0000-c000-000000000046',
                                      -        'PublicStrings': '00020329-0000-0000-c000-000000000046',
                                      -        'Remote': '00062014-0000-0000-c000-000000000046',
                                      -        'Report': '00062013-0000-0000-c000-000000000046',
                                      -        'Sharing': '00062040-0000-0000-c000-000000000046',
                                      -        'Task': '00062003-0000-0000-c000-000000000046',
                                      -        'UnifiedMessaging': '4442858e-a9e3-4e80-b900-317a210cc15b',
                                      +        "Address": "00062004-0000-0000-c000-000000000046",
                                      +        "AirSync": "71035549-0739-4dcb-9163-00f0580dbbdf",
                                      +        "Appointment": "00062002-0000-0000-c000-000000000046",
                                      +        "Common": "00062008-0000-0000-c000-000000000046",
                                      +        "InternetHeaders": "00020386-0000-0000-c000-000000000046",
                                      +        "Log": "0006200a-0000-0000-c000-000000000046",
                                      +        "Mapi": "00020328-0000-0000-c000-000000000046",
                                      +        "Meeting": "6ed8da90-450b-101b-98da-00aa003f1305",
                                      +        "Messaging": "41f28f13-83f4-4114-a584-eedb5a6b0bff",
                                      +        "Note": "0006200e-0000-0000-c000-000000000046",
                                      +        "PostRss": "00062041-0000-0000-c000-000000000046",
                                      +        "PublicStrings": "00020329-0000-0000-c000-000000000046",
                                      +        "Remote": "00062014-0000-0000-c000-000000000046",
                                      +        "Report": "00062013-0000-0000-c000-000000000046",
                                      +        "Sharing": "00062040-0000-0000-c000-000000000046",
                                      +        "Task": "00062003-0000-0000-c000-000000000046",
                                      +        "UnifiedMessaging": "4442858e-a9e3-4e80-b900-317a210cc15b",
                                           }
                                           DISTINGUISHED_SET_ID_TO_NAME_MAP = {v: k for k, v in DISTINGUISHED_SET_NAME_TO_ID_MAP.items()}
                                       
                                      @@ -5904,15 +6176,15 @@ 

                                      Methods

                                      property_tag = None # hex integer (e.g. 0x8000) or string ('0x8000') property_name = None property_id = None # integer as hex-formatted int (e.g. 0x8000) or normal int (32768) - property_type = '' + property_type = "" - __slots__ = 'value', + __slots__ = ("value",) def __init__(self, *args, **kwargs): if not kwargs: # Allow to set attributes without keyword kwargs = dict(zip(self._slots_keys, args)) - self.value = kwargs.pop('value') + self.value = kwargs.pop("value") super().__init__(**kwargs) @classmethod @@ -5938,7 +6210,7 @@

                                      Methods

                                      ) if cls.distinguished_property_set_id not in cls.DISTINGUISHED_SETS: raise InvalidEnumValue( - 'distinguished_property_set_id', cls.distinguished_property_set_id, cls.DISTINGUISHED_SETS + "distinguished_property_set_id", cls.distinguished_property_set_id, cls.DISTINGUISHED_SETS ) @classmethod @@ -5949,16 +6221,12 @@

                                      Methods

                                      "When 'property_set_id' is set, 'distinguished_property_set_id' and 'property_tag' must be None" ) if not any([cls.property_id, cls.property_name]): - raise ValueError( - "When 'property_set_id' is set, 'property_id' or 'property_name' must also be set" - ) + raise ValueError("When 'property_set_id' is set, 'property_id' or 'property_name' must also be set") @classmethod def _validate_property_tag(cls): if cls.property_tag: - if any([ - cls.distinguished_property_set_id, cls.property_set_id, cls.property_name, cls.property_id - ]): + if any([cls.distinguished_property_set_id, cls.property_set_id, cls.property_name, cls.property_id]): raise ValueError("When 'property_tag' is set, only 'property_type' must be set") if 0x8000 <= cls.property_tag_as_int() <= 0xFFFE: raise ValueError( @@ -5988,7 +6256,7 @@

                                      Methods

                                      @classmethod def _validate_property_type(cls): if cls.property_type not in cls.PROPERTY_TYPES: - raise InvalidEnumValue('property_type', cls.property_type, cls.PROPERTY_TYPES) + raise InvalidEnumValue("property_type", cls.property_type, cls.PROPERTY_TYPES) def clean(self, version=None): self.validate_cls() @@ -6036,30 +6304,29 @@

                                      Methods

                                      # Gets value of this specific ExtendedProperty from a list of 'ExtendedProperty' XML elements python_type = cls.python_type() if cls.is_array_type(): - values = elem.find(f'{{{TNS}}}Values') + values = elem.find(f"{{{TNS}}}Values") return [ - xml_text_to_value(value=val, value_type=python_type) - for val in get_xml_attrs(values, f'{{{TNS}}}Value') + xml_text_to_value(value=val, value_type=python_type) for val in get_xml_attrs(values, f"{{{TNS}}}Value") ] - extended_field_value = xml_text_to_value(value=get_xml_attr(elem, f'{{{TNS}}}Value'), value_type=python_type) + extended_field_value = xml_text_to_value(value=get_xml_attr(elem, f"{{{TNS}}}Value"), value_type=python_type) if python_type == str and not extended_field_value: # For string types, we want to return the empty string instead of None if the element was # actually found, but there was no XML value. For other types, it would be more problematic # to make that distinction, e.g. return False for bool, 0 for int, etc. - return '' + return "" return extended_field_value def to_xml(self, version): if self.is_array_type(): - values = create_element('t:Values') + values = create_element("t:Values") for v in self.value: - add_xml_child(values, 't:Value', v) + add_xml_child(values, "t:Value", v) return values - return set_xml_value(create_element('t:Value'), self.value, version=version) + return set_xml_value(create_element("t:Value"), self.value, version=version) @classmethod def is_array_type(cls): - return cls.property_type.endswith('Array') + return cls.property_type.endswith("Array") @classmethod def property_tag_as_int(cls): @@ -6076,18 +6343,18 @@

                                      Methods

                                      # Return the best equivalent for a Python type for the property type of this class base_type = cls.property_type[:-5] if cls.is_array_type() else cls.property_type return { - 'ApplicationTime': Decimal, - 'Binary': bytes, - 'Boolean': bool, - 'CLSID': str, - 'Currency': int, - 'Double': Decimal, - 'Float': Decimal, - 'Integer': int, - 'Long': int, - 'Short': int, - 'SystemTime': EWSDateTime, - 'String': str, + "ApplicationTime": Decimal, + "Binary": bytes, + "Boolean": bool, + "CLSID": str, + "Currency": int, + "Double": Decimal, + "Float": Decimal, + "Integer": int, + "Long": int, + "Short": int, + "SystemTime": EWSDateTime, + "String": str, }[base_type] @classmethod @@ -6196,17 +6463,16 @@

                                      Static methods

                                      # Gets value of this specific ExtendedProperty from a list of 'ExtendedProperty' XML elements python_type = cls.python_type() if cls.is_array_type(): - values = elem.find(f'{{{TNS}}}Values') + values = elem.find(f"{{{TNS}}}Values") return [ - xml_text_to_value(value=val, value_type=python_type) - for val in get_xml_attrs(values, f'{{{TNS}}}Value') + xml_text_to_value(value=val, value_type=python_type) for val in get_xml_attrs(values, f"{{{TNS}}}Value") ] - extended_field_value = xml_text_to_value(value=get_xml_attr(elem, f'{{{TNS}}}Value'), value_type=python_type) + extended_field_value = xml_text_to_value(value=get_xml_attr(elem, f"{{{TNS}}}Value"), value_type=python_type) if python_type == str and not extended_field_value: # For string types, we want to return the empty string instead of None if the element was # actually found, but there was no XML value. For other types, it would be more problematic # to make that distinction, e.g. return False for bool, 0 for int, etc. - return '' + return "" return extended_field_value
                                      @@ -6221,7 +6487,7 @@

                                      Static methods

                                      @classmethod
                                       def is_array_type(cls):
                                      -    return cls.property_type.endswith('Array')
                                      + return cls.property_type.endswith("Array")
                                      @@ -6295,18 +6561,18 @@

                                      Static methods

                                      # Return the best equivalent for a Python type for the property type of this class base_type = cls.property_type[:-5] if cls.is_array_type() else cls.property_type return { - 'ApplicationTime': Decimal, - 'Binary': bytes, - 'Boolean': bool, - 'CLSID': str, - 'Currency': int, - 'Double': Decimal, - 'Float': Decimal, - 'Integer': int, - 'Long': int, - 'Short': int, - 'SystemTime': EWSDateTime, - 'String': str, + "ApplicationTime": Decimal, + "Binary": bytes, + "Boolean": bool, + "CLSID": str, + "Currency": int, + "Double": Decimal, + "Float": Decimal, + "Integer": int, + "Long": int, + "Short": int, + "SystemTime": EWSDateTime, + "String": str, }[base_type]
                                      @@ -6374,11 +6640,11 @@

                                      Methods

                                      def to_xml(self, version):
                                           if self.is_array_type():
                                      -        values = create_element('t:Values')
                                      +        values = create_element("t:Values")
                                               for v in self.value:
                                      -            add_xml_child(values, 't:Value', v)
                                      +            add_xml_child(values, "t:Value", v)
                                               return values
                                      -    return set_xml_value(create_element('t:Value'), self.value, version=version)
                                      + return set_xml_value(create_element("t:Value"), self.value, version=version)
                                @@ -6415,10 +6681,10 @@

                                Inherited members

                                return None def back_off(self, seconds): - raise ValueError('Cannot back off with fail-fast policy') + raise ValueError("Cannot back off with fail-fast policy") def may_retry_on_error(self, response, wait): - log.debug('No retry: no fail-fast policy') + log.debug("No retry: no fail-fast policy") return False

                                Ancestors

                                @@ -6464,7 +6730,7 @@

                                Inherited members

                                def __getstate__(self): # Locks cannot be pickled state = self.__dict__.copy() - del state['_back_off_lock'] + del state["_back_off_lock"] return state def __setstate__(self, state): @@ -6504,20 +6770,24 @@

                                Inherited members

                                def may_retry_on_error(self, response, wait): if response.status_code not in (301, 302, 401, 500, 503): # Don't retry if we didn't get a status code that we can hope to recover from - log.debug('No retry: wrong status code %s', response.status_code) + log.debug("No retry: wrong status code %s", response.status_code) return False if wait > self.max_wait: # We lost patience. Session is cleaned up in outer loop raise RateLimitError( - 'Max timeout reached', url=response.url, status_code=response.status_code, total_wait=wait) + "Max timeout reached", url=response.url, status_code=response.status_code, total_wait=wait + ) if response.status_code == 401: # EWS sometimes throws 401's when it wants us to throttle connections. OK to retry. return True - if response.headers.get('connection') == 'close': + if response.headers.get("connection") == "close": # Connection closed. OK to retry. return True - if response.status_code == 302 and response.headers.get('location', '').lower() \ - == '/ews/genericerrorpage.htm?aspxerrorpath=/ews/exchange.asmx': + if ( + response.status_code == 302 + and response.headers.get("location", "").lower() + == "/ews/genericerrorpage.htm?aspxerrorpath=/ews/exchange.asmx" + ): # The genericerrorpage.htm/internalerror.asp is ridiculous behaviour for random outages. OK to retry. # # Redirect to '/internalsite/internalerror.asp' or '/internalsite/initparams.aspx' is caused by e.g. TLS @@ -6528,7 +6798,7 @@

                                Inherited members

                                return True if response.status_code == 500 and b"Server Error in '/EWS' Application" in response.content: # "Server Error in '/EWS' Application" has been seen in highly concurrent settings. OK to retry. - log.debug('Retry allowed: conditions met') + log.debug("Retry allowed: conditions met") return True return False @@ -6591,15 +6861,15 @@

                                Inherited members

                                class FileAttachment(Attachment):
                                     """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/fileattachment"""
                                 
                                -    ELEMENT_NAME = 'FileAttachment'
                                +    ELEMENT_NAME = "FileAttachment"
                                 
                                -    is_contact_photo = BooleanField(field_uri='IsContactPhoto')
                                -    _content = Base64Field(field_uri='Content')
                                +    is_contact_photo = BooleanField(field_uri="IsContactPhoto")
                                +    _content = Base64Field(field_uri="Content")
                                 
                                -    __slots__ = '_fp',
                                +    __slots__ = ("_fp",)
                                 
                                     def __init__(self, **kwargs):
                                -        kwargs['_content'] = kwargs.pop('content', None)
                                +        kwargs["_content"] = kwargs.pop("content", None)
                                         super().__init__(**kwargs)
                                         self._fp = None
                                 
                                @@ -6614,7 +6884,7 @@ 

                                Inherited members

                                # Create a file-like object for the attachment content. We try hard to reduce memory consumption so we never # store the full attachment content in-memory. if not self.parent_item or not self.parent_item.account: - raise ValueError(f'{self.__class__.__name__} must have an account') + raise ValueError(f"{self.__class__.__name__} must have an account") self._fp = FileAttachmentIO(attachment=self) @property @@ -6635,13 +6905,13 @@

                                Inherited members

                                def content(self, value): """Replace the attachment content.""" if not isinstance(value, bytes): - raise InvalidTypeError('value', value, bytes) + raise InvalidTypeError("value", value, bytes) self._content = value @classmethod def from_xml(cls, elem, account): kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS} - kwargs['content'] = kwargs.pop('_content') + kwargs["content"] = kwargs.pop("_content") cls._clear(elem) return cls(**kwargs) @@ -6652,7 +6922,7 @@

                                Inherited members

                                def __getstate__(self): # The fp does not need to be pickled state = {k: getattr(self, k) for k in self._slots_keys} - del state['_fp'] + del state["_fp"] return state def __setstate__(self, state): @@ -6691,7 +6961,7 @@

                                Static methods

                                @classmethod
                                 def from_xml(cls, elem, account):
                                     kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS}
                                -    kwargs['content'] = kwargs.pop('_content')
                                +    kwargs["content"] = kwargs.pop("_content")
                                     cls._clear(elem)
                                     return cls(**kwargs)
                                @@ -6785,24 +7055,25 @@

                                Inherited members

                                class Folder(BaseFolder):
                                     """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/folder"""
                                 
                                -    permission_set = PermissionSetField(field_uri='folder:PermissionSet', supported_from=EXCHANGE_2007_SP1)
                                -    effective_rights = EffectiveRightsField(field_uri='folder:EffectiveRights', is_read_only=True,
                                -                                            supported_from=EXCHANGE_2007_SP1)
                                +    permission_set = PermissionSetField(field_uri="folder:PermissionSet", supported_from=EXCHANGE_2007_SP1)
                                +    effective_rights = EffectiveRightsField(
                                +        field_uri="folder:EffectiveRights", is_read_only=True, supported_from=EXCHANGE_2007_SP1
                                +    )
                                 
                                -    __slots__ = '_root',
                                +    __slots__ = ("_root",)
                                 
                                     def __init__(self, **kwargs):
                                -        self._root = kwargs.pop('root', None)  # This is a pointer to the root of the folder hierarchy
                                -        parent = kwargs.pop('parent', None)
                                +        self._root = kwargs.pop("root", None)  # This is a pointer to the root of the folder hierarchy
                                +        parent = kwargs.pop("parent", None)
                                         if parent:
                                             if self.root:
                                                 if parent.root != self.root:
                                                     raise ValueError("'parent.root' must match 'root'")
                                             else:
                                                 self.root = parent.root
                                -            if 'parent_folder_id' in kwargs and parent.id != kwargs['parent_folder_id']:
                                +            if "parent_folder_id" in kwargs and parent.id != kwargs["parent_folder_id"]:
                                                 raise ValueError("'parent_folder_id' must match 'parent' ID")
                                -            kwargs['parent_folder_id'] = ParentFolderId(id=parent.id, changekey=parent.changekey)
                                +            kwargs["parent_folder_id"] = ParentFolderId(id=parent.id, changekey=parent.changekey)
                                         super().__init__(**kwargs)
                                 
                                     @property
                                @@ -6822,13 +7093,13 @@ 

                                Inherited members

                                @classmethod def register(cls, *args, **kwargs): if cls is not Folder: - raise TypeError('For folders, custom fields must be registered on the Folder class') + raise TypeError("For folders, custom fields must be registered on the Folder class") return super().register(*args, **kwargs) @classmethod def deregister(cls, *args, **kwargs): if cls is not Folder: - raise TypeError('For folders, custom fields must be registered on the Folder class') + raise TypeError("For folders, custom fields must be registered on the Folder class") return super().deregister(*args, **kwargs) @classmethod @@ -6840,11 +7111,10 @@

                                Inherited members

                                """ try: return cls.resolve( - account=root.account, - folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + account=root.account, folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}') + raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") @property def parent(self): @@ -6861,15 +7131,16 @@

                                Inherited members

                                self.parent_folder_id = None else: if not isinstance(value, BaseFolder): - raise InvalidTypeError('value', value, BaseFolder) + raise InvalidTypeError("value", value, BaseFolder) self.root = value.root self.parent_folder_id = ParentFolderId(id=value.id, changekey=value.changekey) def clean(self, version=None): from .roots import RootOfHierarchy + super().clean(version=version) if self.root and not isinstance(self.root, RootOfHierarchy): - raise InvalidTypeError('root', self.root, RootOfHierarchy) + raise InvalidTypeError("root", self.root, RootOfHierarchy) @classmethod def from_xml_with_root(cls, elem, root): @@ -6895,20 +7166,20 @@

                                Inherited members

                                if folder.name: try: # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative - folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, - locale=root.account.locale) - log.debug('Folder class %s matches localized folder name %s', folder_cls, folder.name) + folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) + log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) except KeyError: pass if folder.folder_class and folder_cls == Folder: try: folder_cls = cls.folder_cls_from_container_class(container_class=folder.folder_class) - log.debug('Folder class %s matches container class %s (%s)', folder_cls, folder.folder_class, - folder.name) + log.debug( + "Folder class %s matches container class %s (%s)", folder_cls, folder.folder_class, folder.name + ) except KeyError: pass if folder_cls == Folder: - log.debug('Fallback to class Folder (folder_class %s, name %s)', folder.folder_class, folder.name) + log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS})

                                Ancestors

                                @@ -6999,20 +7270,20 @@

                                Static methods

                                if folder.name: try: # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative - folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, - locale=root.account.locale) - log.debug('Folder class %s matches localized folder name %s', folder_cls, folder.name) + folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) + log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) except KeyError: pass if folder.folder_class and folder_cls == Folder: try: folder_cls = cls.folder_cls_from_container_class(container_class=folder.folder_class) - log.debug('Folder class %s matches container class %s (%s)', folder_cls, folder.folder_class, - folder.name) + log.debug( + "Folder class %s matches container class %s (%s)", folder_cls, folder.folder_class, folder.name + ) except KeyError: pass if folder_cls == Folder: - log.debug('Fallback to class Folder (folder_class %s, name %s)', folder.folder_class, folder.name) + log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS})
                                @@ -7036,11 +7307,10 @@

                                Static methods

                                """ try: return cls.resolve( - account=root.account, - folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + account=root.account, folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}') + raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") @@ -7068,9 +7338,10 @@

                                Methods

                                def clean(self, version=None):
                                     from .roots import RootOfHierarchy
                                +
                                     super().clean(version=version)
                                     if self.root and not isinstance(self.root, RootOfHierarchy):
                                -        raise InvalidTypeError('root', self.root, RootOfHierarchy)
                                + raise InvalidTypeError("root", self.root, RootOfHierarchy) @@ -7129,7 +7400,7 @@

                                Inherited members

                                """A class that implements an API for searching folders.""" # These fields are required in a FindFolder or GetFolder call to properly identify folder types - REQUIRED_FOLDER_FIELDS = ('name', 'folder_class') + REQUIRED_FOLDER_FIELDS = ("name", "folder_class") def __init__(self, account, folders): """Implement a search API on a collection of folders. @@ -7260,8 +7531,18 @@

                                Inherited members

                                query_string = None return depth, restriction, query_string - def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order_fields=None, - calendar_view=None, page_size=None, max_items=None, offset=0): + def find_items( + self, + q, + shape=ID_ONLY, + depth=None, + additional_fields=None, + order_fields=None, + calendar_view=None, + page_size=None, + max_items=None, + offset=0, + ): """Private method to call the FindItem service. :param q: a Q instance containing any restrictions @@ -7279,21 +7560,21 @@

                                Inherited members

                                :return: a generator for the returned item IDs or items """ from ..services import FindItem + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return if q.is_never(): - log.debug('Query will never return results') + log.debug("Query will never return results") return depth, restriction, query_string = self._rinse_args( - q=q, depth=depth, additional_fields=additional_fields, - field_validator=self.validate_item_field + q=q, depth=depth, additional_fields=additional_fields, field_validator=self.validate_item_field ) if calendar_view is not None and not isinstance(calendar_view, CalendarView): - raise InvalidTypeError('calendar_view', calendar_view, CalendarView) + raise InvalidTypeError("calendar_view", calendar_view, CalendarView) log.debug( - 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', + "Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)", self.account, self.folders, shape, @@ -7316,14 +7597,23 @@

                                Inherited members

                                def _get_single_folder(self): if len(self.folders) > 1: - raise ValueError('Syncing folder hierarchy can only be done on a single folder') + raise ValueError("Syncing folder hierarchy can only be done on a single folder") if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return None return self.folders[0] - def find_people(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order_fields=None, - page_size=None, max_items=None, offset=0): + def find_people( + self, + q, + shape=ID_ONLY, + depth=None, + additional_fields=None, + order_fields=None, + page_size=None, + max_items=None, + offset=0, + ): """Private method to call the FindPeople service. :param q: a Q instance containing any restrictions @@ -7339,30 +7629,31 @@

                                Inherited members

                                :return: a generator for the returned personas """ from ..services import FindPeople + folder = self._get_single_folder() if q.is_never(): - log.debug('Query will never return results') + log.debug("Query will never return results") return depth, restriction, query_string = self._rinse_args( - q=q, depth=depth, additional_fields=additional_fields, - field_validator=Persona.validate_field + q=q, depth=depth, additional_fields=additional_fields, field_validator=Persona.validate_field ) yield from FindPeople(account=self.account, page_size=page_size).call( - folder=folder, - additional_fields=additional_fields, - restriction=restriction, - order_fields=order_fields, - shape=shape, - query_string=query_string, - depth=depth, - max_items=max_items, - offset=offset, + folder=folder, + additional_fields=additional_fields, + restriction=restriction, + order_fields=order_fields, + shape=shape, + query_string=query_string, + depth=depth, + max_items=max_items, + offset=offset, ) def get_folder_fields(self, target_cls, is_complex=None): return { - FieldPath(field=f) for f in target_cls.supported_fields(version=self.account.version) + FieldPath(field=f) + for f in target_cls.supported_fields(version=self.account.version) if is_complex is None or f.is_complex is is_complex } @@ -7371,16 +7662,17 @@

                                Inherited members

                                # both folder types in self.folders, raise an error so we don't risk losing some fields in the query. from .base import Folder from .roots import RootOfHierarchy + has_roots = False has_non_roots = False for f in self.folders: if isinstance(f, RootOfHierarchy): if has_non_roots: - raise ValueError(f'Cannot call GetFolder on a mix of folder types: {self.folders}') + raise ValueError(f"Cannot call GetFolder on a mix of folder types: {self.folders}") has_roots = True else: if has_roots: - raise ValueError(f'Cannot call GetFolder on a mix of folder types: {self.folders}') + raise ValueError(f"Cannot call GetFolder on a mix of folder types: {self.folders}") has_non_roots = True return RootOfHierarchy if has_roots else Folder @@ -7389,47 +7681,51 @@

                                Inherited members

                                if len(unique_depths) == 1: return unique_depths.pop() raise ValueError( - f'Folders in this collection do not have a common {traversal_attr} value. You need to define an explicit ' - f'traversal depth with QuerySet.depth() (values: {unique_depths})' + f"Folders in this collection do not have a common {traversal_attr} value. You need to define an explicit " + f"traversal depth with QuerySet.depth() (values: {unique_depths})" ) def _get_default_item_traversal_depth(self): # When searching folders, some folders require 'Shallow' and others 'Associated' traversal depth. - return self._get_default_traversal_depth('DEFAULT_ITEM_TRAVERSAL_DEPTH') + return self._get_default_traversal_depth("DEFAULT_ITEM_TRAVERSAL_DEPTH") def _get_default_folder_traversal_depth(self): # When searching folders, some folders require 'Shallow' and others 'Deep' traversal depth. - return self._get_default_traversal_depth('DEFAULT_FOLDER_TRAVERSAL_DEPTH') + return self._get_default_traversal_depth("DEFAULT_FOLDER_TRAVERSAL_DEPTH") def resolve(self): # Looks up the folders or folder IDs in the collection and returns full Folder instances with all fields set. from .base import BaseFolder + resolveable_folders = [] for f in self.folders: if isinstance(f, BaseFolder) and not f.get_folder_allowed: - log.debug('GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder', f) + log.debug("GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder", f) yield f else: resolveable_folders.append(f) # Fetch all properties for the remaining folders of folder IDs additional_fields = self.get_folder_fields(target_cls=self._get_target_cls(), is_complex=None) yield from self.__class__(account=self.account, folders=resolveable_folders).get_folders( - additional_fields=additional_fields + additional_fields=additional_fields ) @require_account - def find_folders(self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None, - offset=0): + def find_folders( + self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None, offset=0 + ): from ..services import FindFolder + # 'depth' controls whether to return direct children or recurse into sub-folders from .base import BaseFolder, Folder + if q is None: q = Q() if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return if q.is_never(): - log.debug('Query will never return results') + log.debug("Query will never return results") return if q.is_empty(): restriction = None @@ -7451,21 +7747,23 @@

                                Inherited members

                                ) yield from FindFolder(account=self.account, page_size=page_size).call( - folders=self.folders, - additional_fields=additional_fields, - restriction=restriction, - shape=shape, - depth=depth, - max_items=max_items, - offset=offset, + folders=self.folders, + additional_fields=additional_fields, + restriction=restriction, + shape=shape, + depth=depth, + max_items=max_items, + offset=offset, ) def get_folders(self, additional_fields=None): from ..services import GetFolder + # Expand folders with their full set of properties from .base import BaseFolder + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return if additional_fields is None: # Default to all complex properties @@ -7477,38 +7775,47 @@

                                Inherited members

                                ) yield from GetFolder(account=self.account).call( - folders=self.folders, - additional_fields=additional_fields, - shape=ID_ONLY, + folders=self.folders, + additional_fields=additional_fields, + shape=ID_ONLY, ) def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): from ..services import SubscribeToPull + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return None if event_types is None: event_types = SubscribeToPull.EVENT_TYPES return SubscribeToPull(account=self.account).get( - folders=self.folders, event_types=event_types, watermark=watermark, timeout=timeout, + folders=self.folders, + event_types=event_types, + watermark=watermark, + timeout=timeout, ) def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1): from ..services import SubscribeToPush + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return None if event_types is None: event_types = SubscribeToPush.EVENT_TYPES return SubscribeToPush(account=self.account).get( - folders=self.folders, event_types=event_types, watermark=watermark, status_frequency=status_frequency, + folders=self.folders, + event_types=event_types, + watermark=watermark, + status_frequency=status_frequency, url=callback_url, ) def subscribe_to_streaming(self, event_types=None): from ..services import SubscribeToStreaming + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return None if event_types is None: event_types = SubscribeToStreaming.EVENT_TYPES @@ -7533,10 +7840,12 @@

                                Inherited members

                                sync methods. """ from ..services import Unsubscribe + return Unsubscribe(account=self.account).get(subscription_id=subscription_id) def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None): from ..services import SyncFolderItems + folder = self._get_single_folder() if only_fields is None: # We didn't restrict list of field paths. Get all fields from the server, including extended properties. @@ -7568,6 +7877,7 @@

                                Inherited members

                                def sync_hierarchy(self, sync_state=None, only_fields=None): from ..services import SyncFolderHierarchy + folder = self._get_single_folder() if only_fields is None: # We didn't restrict list of field paths. Get all fields from the server, including extended properties. @@ -7734,18 +8044,21 @@

                                Examples

                                Expand source code
                                @require_account
                                -def find_folders(self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None,
                                -                 offset=0):
                                +def find_folders(
                                +    self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None, offset=0
                                +):
                                     from ..services import FindFolder
                                +
                                     # 'depth' controls whether to return direct children or recurse into sub-folders
                                     from .base import BaseFolder, Folder
                                +
                                     if q is None:
                                         q = Q()
                                     if not self.folders:
                                -        log.debug('Folder list is empty')
                                +        log.debug("Folder list is empty")
                                         return
                                     if q.is_never():
                                -        log.debug('Query will never return results')
                                +        log.debug("Query will never return results")
                                         return
                                     if q.is_empty():
                                         restriction = None
                                @@ -7767,13 +8080,13 @@ 

                                Examples

                                ) yield from FindFolder(account=self.account, page_size=page_size).call( - folders=self.folders, - additional_fields=additional_fields, - restriction=restriction, - shape=shape, - depth=depth, - max_items=max_items, - offset=offset, + folders=self.folders, + additional_fields=additional_fields, + restriction=restriction, + shape=shape, + depth=depth, + max_items=max_items, + offset=offset, )
                                @@ -7798,8 +8111,18 @@

                                Examples

                                Expand source code -
                                def find_items(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order_fields=None,
                                -               calendar_view=None, page_size=None, max_items=None, offset=0):
                                +
                                def find_items(
                                +    self,
                                +    q,
                                +    shape=ID_ONLY,
                                +    depth=None,
                                +    additional_fields=None,
                                +    order_fields=None,
                                +    calendar_view=None,
                                +    page_size=None,
                                +    max_items=None,
                                +    offset=0,
                                +):
                                     """Private method to call the FindItem service.
                                 
                                     :param q: a Q instance containing any restrictions
                                @@ -7817,21 +8140,21 @@ 

                                Examples

                                :return: a generator for the returned item IDs or items """ from ..services import FindItem + if not self.folders: - log.debug('Folder list is empty') + log.debug("Folder list is empty") return if q.is_never(): - log.debug('Query will never return results') + log.debug("Query will never return results") return depth, restriction, query_string = self._rinse_args( - q=q, depth=depth, additional_fields=additional_fields, - field_validator=self.validate_item_field + q=q, depth=depth, additional_fields=additional_fields, field_validator=self.validate_item_field ) if calendar_view is not None and not isinstance(calendar_view, CalendarView): - raise InvalidTypeError('calendar_view', calendar_view, CalendarView) + raise InvalidTypeError("calendar_view", calendar_view, CalendarView) log.debug( - 'Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)', + "Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)", self.account, self.folders, shape, @@ -7872,8 +8195,17 @@

                                Examples

                                Expand source code -
                                def find_people(self, q, shape=ID_ONLY, depth=None, additional_fields=None, order_fields=None,
                                -                page_size=None, max_items=None, offset=0):
                                +
                                def find_people(
                                +    self,
                                +    q,
                                +    shape=ID_ONLY,
                                +    depth=None,
                                +    additional_fields=None,
                                +    order_fields=None,
                                +    page_size=None,
                                +    max_items=None,
                                +    offset=0,
                                +):
                                     """Private method to call the FindPeople service.
                                 
                                     :param q: a Q instance containing any restrictions
                                @@ -7889,25 +8221,25 @@ 

                                Examples

                                :return: a generator for the returned personas """ from ..services import FindPeople + folder = self._get_single_folder() if q.is_never(): - log.debug('Query will never return results') + log.debug("Query will never return results") return depth, restriction, query_string = self._rinse_args( - q=q, depth=depth, additional_fields=additional_fields, - field_validator=Persona.validate_field + q=q, depth=depth, additional_fields=additional_fields, field_validator=Persona.validate_field ) yield from FindPeople(account=self.account, page_size=page_size).call( - folder=folder, - additional_fields=additional_fields, - restriction=restriction, - order_fields=order_fields, - shape=shape, - query_string=query_string, - depth=depth, - max_items=max_items, - offset=offset, + folder=folder, + additional_fields=additional_fields, + restriction=restriction, + order_fields=order_fields, + shape=shape, + query_string=query_string, + depth=depth, + max_items=max_items, + offset=offset, )
                                @@ -7922,7 +8254,8 @@

                                Examples

                                def get_folder_fields(self, target_cls, is_complex=None):
                                     return {
                                -        FieldPath(field=f) for f in target_cls.supported_fields(version=self.account.version)
                                +        FieldPath(field=f)
                                +        for f in target_cls.supported_fields(version=self.account.version)
                                         if is_complex is None or f.is_complex is is_complex
                                     }
                                @@ -7938,10 +8271,12 @@

                                Examples

                                def get_folders(self, additional_fields=None):
                                     from ..services import GetFolder
                                +
                                     # Expand folders with their full set of properties
                                     from .base import BaseFolder
                                +
                                     if not self.folders:
                                -        log.debug('Folder list is empty')
                                +        log.debug("Folder list is empty")
                                         return
                                     if additional_fields is None:
                                         # Default to all complex properties
                                @@ -7953,9 +8288,9 @@ 

                                Examples

                                ) yield from GetFolder(account=self.account).call( - folders=self.folders, - additional_fields=additional_fields, - shape=ID_ONLY, + folders=self.folders, + additional_fields=additional_fields, + shape=ID_ONLY, )
                                @@ -7997,17 +8332,18 @@

                                Examples

                                def resolve(self):
                                     # Looks up the folders or folder IDs in the collection and returns full Folder instances with all fields set.
                                     from .base import BaseFolder
                                +
                                     resolveable_folders = []
                                     for f in self.folders:
                                         if isinstance(f, BaseFolder) and not f.get_folder_allowed:
                                -            log.debug('GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder', f)
                                +            log.debug("GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder", f)
                                             yield f
                                         else:
                                             resolveable_folders.append(f)
                                     # Fetch all properties for the remaining folders of folder IDs
                                     additional_fields = self.get_folder_fields(target_cls=self._get_target_cls(), is_complex=None)
                                     yield from self.__class__(account=self.account, folders=resolveable_folders).get_folders(
                                -            additional_fields=additional_fields
                                +        additional_fields=additional_fields
                                     )
                                @@ -8035,13 +8371,17 @@

                                Examples

                                def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60):
                                     from ..services import SubscribeToPull
                                +
                                     if not self.folders:
                                -        log.debug('Folder list is empty')
                                +        log.debug("Folder list is empty")
                                         return None
                                     if event_types is None:
                                         event_types = SubscribeToPull.EVENT_TYPES
                                     return SubscribeToPull(account=self.account).get(
                                -        folders=self.folders, event_types=event_types, watermark=watermark, timeout=timeout,
                                +        folders=self.folders,
                                +        event_types=event_types,
                                +        watermark=watermark,
                                +        timeout=timeout,
                                     )
                                @@ -8056,13 +8396,17 @@

                                Examples

                                def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1):
                                     from ..services import SubscribeToPush
                                +
                                     if not self.folders:
                                -        log.debug('Folder list is empty')
                                +        log.debug("Folder list is empty")
                                         return None
                                     if event_types is None:
                                         event_types = SubscribeToPush.EVENT_TYPES
                                     return SubscribeToPush(account=self.account).get(
                                -        folders=self.folders, event_types=event_types, watermark=watermark, status_frequency=status_frequency,
                                +        folders=self.folders,
                                +        event_types=event_types,
                                +        watermark=watermark,
                                +        status_frequency=status_frequency,
                                         url=callback_url,
                                     )
                                @@ -8078,8 +8422,9 @@

                                Examples

                                def subscribe_to_streaming(self, event_types=None):
                                     from ..services import SubscribeToStreaming
                                +
                                     if not self.folders:
                                -        log.debug('Folder list is empty')
                                +        log.debug("Folder list is empty")
                                         return None
                                     if event_types is None:
                                         event_types = SubscribeToStreaming.EVENT_TYPES
                                @@ -8097,6 +8442,7 @@ 

                                Examples

                                def sync_hierarchy(self, sync_state=None, only_fields=None):
                                     from ..services import SyncFolderHierarchy
                                +
                                     folder = self._get_single_folder()
                                     if only_fields is None:
                                         # We didn't restrict list of field paths. Get all fields from the server, including extended properties.
                                @@ -8143,6 +8489,7 @@ 

                                Examples

                                def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None):
                                     from ..services import SyncFolderItems
                                +
                                     folder = self._get_single_folder()
                                     if only_fields is None:
                                         # We didn't restrict list of field paths. Get all fields from the server, including extended properties.
                                @@ -8196,6 +8543,7 @@ 

                                Examples

                                sync methods. """ from ..services import Unsubscribe + return Unsubscribe(account=self.account).get(subscription_id=subscription_id)
                                @@ -8292,7 +8640,7 @@

                                Inherited members

                                class ForwardItem(BaseReplyItem):
                                     """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/forwarditem"""
                                 
                                -    ELEMENT_NAME = 'ForwardItem'
                                + ELEMENT_NAME = "ForwardItem"

                                Ancestors

                                  @@ -8337,7 +8685,7 @@

                                  Inherited members

                                  MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/body """ - body_type = 'HTML'
                                + body_type = "HTML"

                                Ancestors

                                  @@ -8415,30 +8763,34 @@

                                  Inherited members

                                  class ItemAttachment(Attachment):
                                       """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/itemattachment"""
                                   
                                  -    ELEMENT_NAME = 'ItemAttachment'
                                  +    ELEMENT_NAME = "ItemAttachment"
                                   
                                  -    _item = ItemField(field_uri='Item')
                                  +    _item = ItemField(field_uri="Item")
                                   
                                       def __init__(self, **kwargs):
                                  -        kwargs['_item'] = kwargs.pop('item', None)
                                  +        kwargs["_item"] = kwargs.pop("item", None)
                                           super().__init__(**kwargs)
                                   
                                       @property
                                       def item(self):
                                           from .folders import BaseFolder
                                           from .services import GetAttachment
                                  +
                                           if self.attachment_id is None:
                                               return self._item
                                           if self._item is not None:
                                               return self._item
                                           # We have an ID to the data but still haven't called GetAttachment to get the actual data. Do that now.
                                           if not self.parent_item or not self.parent_item.account:
                                  -            raise ValueError(f'{self.__class__.__name__} must have an account')
                                  +            raise ValueError(f"{self.__class__.__name__} must have an account")
                                           additional_fields = {
                                               FieldPath(field=f) for f in BaseFolder.allowed_item_fields(version=self.parent_item.account.version)
                                           }
                                           attachment = GetAttachment(account=self.parent_item.account).get(
                                  -            items=[self.attachment_id], include_mime_content=True, body_type=None, filter_html_content=None,
                                  +            items=[self.attachment_id],
                                  +            include_mime_content=True,
                                  +            body_type=None,
                                  +            filter_html_content=None,
                                               additional_fields=additional_fields,
                                           )
                                           self._item = attachment.item
                                  @@ -8447,14 +8799,15 @@ 

                                  Inherited members

                                  @item.setter def item(self, value): from .items import Item + if not isinstance(value, Item): - raise InvalidTypeError('value', value, Item) + raise InvalidTypeError("value", value, Item) self._item = value @classmethod def from_xml(cls, elem, account): kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS} - kwargs['item'] = kwargs.pop('_item') + kwargs["item"] = kwargs.pop("_item") cls._clear(elem) return cls(**kwargs)
                                  @@ -8488,7 +8841,7 @@

                                  Static methods

                                  @classmethod
                                   def from_xml(cls, elem, account):
                                       kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS}
                                  -    kwargs['item'] = kwargs.pop('_item')
                                  +    kwargs["item"] = kwargs.pop("_item")
                                       cls._clear(elem)
                                       return cls(**kwargs)
                                  @@ -8507,18 +8860,22 @@

                                  Instance variables

                                  def item(self): from .folders import BaseFolder from .services import GetAttachment + if self.attachment_id is None: return self._item if self._item is not None: return self._item # We have an ID to the data but still haven't called GetAttachment to get the actual data. Do that now. if not self.parent_item or not self.parent_item.account: - raise ValueError(f'{self.__class__.__name__} must have an account') + raise ValueError(f"{self.__class__.__name__} must have an account") additional_fields = { FieldPath(field=f) for f in BaseFolder.allowed_item_fields(version=self.parent_item.account.version) } attachment = GetAttachment(account=self.parent_item.account).get( - items=[self.attachment_id], include_mime_content=True, body_type=None, filter_html_content=None, + items=[self.attachment_id], + include_mime_content=True, + body_type=None, + filter_html_content=None, additional_fields=additional_fields, ) self._item = attachment.item @@ -8556,9 +8913,9 @@

                                  Inherited members

                                  MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/itemid """ - ELEMENT_NAME = 'ItemId' - ID_ATTR = 'Id' - CHANGEKEY_ATTR = 'ChangeKey' + ELEMENT_NAME = "ItemId" + ID_ATTR = "Id" + CHANGEKEY_ATTR = "ChangeKey" id = IdField(field_uri=ID_ATTR, is_required=True) changekey = IdField(field_uri=CHANGEKEY_ATTR, is_required=False)
                                @@ -8635,20 +8992,26 @@

                                Inherited members

                                class Mailbox(EWSElement):
                                     """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailbox"""
                                 
                                -    ELEMENT_NAME = 'Mailbox'
                                -    MAILBOX = 'Mailbox'
                                -    ONE_OFF = 'OneOff'
                                +    ELEMENT_NAME = "Mailbox"
                                +    MAILBOX = "Mailbox"
                                +    ONE_OFF = "OneOff"
                                     MAILBOX_TYPE_CHOICES = {
                                -            Choice(MAILBOX), Choice('PublicDL'), Choice('PrivateDL'), Choice('Contact'), Choice('PublicFolder'),
                                -            Choice('Unknown'), Choice(ONE_OFF), Choice('GroupMailbox', supported_from=EXCHANGE_2013)
                                -        }
                                +        Choice(MAILBOX),
                                +        Choice("PublicDL"),
                                +        Choice("PrivateDL"),
                                +        Choice("Contact"),
                                +        Choice("PublicFolder"),
                                +        Choice("Unknown"),
                                +        Choice(ONE_OFF),
                                +        Choice("GroupMailbox", supported_from=EXCHANGE_2013),
                                +    }
                                 
                                -    name = TextField(field_uri='Name')
                                -    email_address = EmailAddressField(field_uri='EmailAddress')
                                +    name = TextField(field_uri="Name")
                                +    email_address = EmailAddressField(field_uri="EmailAddress")
                                     # RoutingType values are not restricted:
                                     # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/routingtype-emailaddresstype
                                -    routing_type = TextField(field_uri='RoutingType', default='SMTP')
                                -    mailbox_type = ChoiceField(field_uri='MailboxType', choices=MAILBOX_TYPE_CHOICES, default=MAILBOX)
                                +    routing_type = TextField(field_uri="RoutingType", default="SMTP")
                                +    mailbox_type = ChoiceField(field_uri="MailboxType", choices=MAILBOX_TYPE_CHOICES, default=MAILBOX)
                                     item_id = EWSElementField(value_cls=ItemId, is_read_only=True)
                                 
                                     def clean(self, version=None):
                                @@ -8785,37 +9148,52 @@ 

                                Inherited members

                                https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/message-ex15websvcsotherref """ - ELEMENT_NAME = 'Message' - - sender = MailboxField(field_uri='message:Sender', is_read_only=True, is_read_only_after_send=True) - to_recipients = MailboxListField(field_uri='message:ToRecipients', is_read_only_after_send=True, - is_searchable=False) - cc_recipients = MailboxListField(field_uri='message:CcRecipients', is_read_only_after_send=True, - is_searchable=False) - bcc_recipients = MailboxListField(field_uri='message:BccRecipients', is_read_only_after_send=True, - is_searchable=False) - is_read_receipt_requested = BooleanField(field_uri='message:IsReadReceiptRequested', - is_required=True, default=False, is_read_only_after_send=True) - is_delivery_receipt_requested = BooleanField(field_uri='message:IsDeliveryReceiptRequested', is_required=True, - default=False, is_read_only_after_send=True) - conversation_index = Base64Field(field_uri='message:ConversationIndex', is_read_only=True) - conversation_topic = CharField(field_uri='message:ConversationTopic', is_read_only=True) + ELEMENT_NAME = "Message" + + sender = MailboxField(field_uri="message:Sender", is_read_only=True, is_read_only_after_send=True) + to_recipients = MailboxListField( + field_uri="message:ToRecipients", is_read_only_after_send=True, is_searchable=False + ) + cc_recipients = MailboxListField( + field_uri="message:CcRecipients", is_read_only_after_send=True, is_searchable=False + ) + bcc_recipients = MailboxListField( + field_uri="message:BccRecipients", is_read_only_after_send=True, is_searchable=False + ) + is_read_receipt_requested = BooleanField( + field_uri="message:IsReadReceiptRequested", is_required=True, default=False, is_read_only_after_send=True + ) + is_delivery_receipt_requested = BooleanField( + field_uri="message:IsDeliveryReceiptRequested", is_required=True, default=False, is_read_only_after_send=True + ) + conversation_index = Base64Field(field_uri="message:ConversationIndex", is_read_only=True) + conversation_topic = CharField(field_uri="message:ConversationTopic", is_read_only=True) # Rename 'From' to 'author'. We can't use fieldname 'from' since it's a Python keyword. - author = MailboxField(field_uri='message:From', is_read_only_after_send=True) - message_id = CharField(field_uri='message:InternetMessageId', is_read_only_after_send=True) - is_read = BooleanField(field_uri='message:IsRead', is_required=True, default=False) - is_response_requested = BooleanField(field_uri='message:IsResponseRequested', default=False, is_required=True) - references = TextField(field_uri='message:References') - reply_to = MailboxListField(field_uri='message:ReplyTo', is_read_only_after_send=True, is_searchable=False) - received_by = MailboxField(field_uri='message:ReceivedBy', is_read_only=True) - received_representing = MailboxField(field_uri='message:ReceivedRepresenting', is_read_only=True) - reminder_message_data = EWSElementField(field_uri='message:ReminderMessageData', value_cls=ReminderMessageData, - supported_from=EXCHANGE_2013_SP1, is_read_only=True) + author = MailboxField(field_uri="message:From", is_read_only_after_send=True) + message_id = CharField(field_uri="message:InternetMessageId", is_read_only_after_send=True) + is_read = BooleanField(field_uri="message:IsRead", is_required=True, default=False) + is_response_requested = BooleanField(field_uri="message:IsResponseRequested", default=False, is_required=True) + references = TextField(field_uri="message:References") + reply_to = MailboxListField(field_uri="message:ReplyTo", is_read_only_after_send=True, is_searchable=False) + received_by = MailboxField(field_uri="message:ReceivedBy", is_read_only=True) + received_representing = MailboxField(field_uri="message:ReceivedRepresenting", is_read_only=True) + reminder_message_data = EWSElementField( + field_uri="message:ReminderMessageData", + value_cls=ReminderMessageData, + supported_from=EXCHANGE_2013_SP1, + is_read_only=True, + ) @require_account - def send(self, save_copy=True, copy_to_folder=None, conflict_resolution=AUTO_RESOLVE, - send_meeting_invitations=SEND_TO_NONE): + def send( + self, + save_copy=True, + copy_to_folder=None, + conflict_resolution=AUTO_RESOLVE, + send_meeting_invitations=SEND_TO_NONE, + ): from ..services import SendItem + # Only sends a message. The message can either be an existing draft stored in EWS or a new message that does # not yet exist in EWS. if copy_to_folder and not save_copy: @@ -8833,42 +9211,48 @@

                                Inherited members

                                if copy_to_folder: # This would better be done via send_and_save() but lets just support it here self.folder = copy_to_folder - return self.send_and_save(conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) + return self.send_and_save( + conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations + ) if self.account.version.build < EXCHANGE_2013 and self.attachments: # At least some versions prior to Exchange 2013 can't send attachments immediately. You need to first save, # then attach, then send. This is done in send_and_save(). send() will delete the item again. - self.send_and_save(conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) + self.send_and_save( + conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations + ) return None self._create(message_disposition=SEND_ONLY, send_meeting_invitations=send_meeting_invitations) return None - def send_and_save(self, update_fields=None, conflict_resolution=AUTO_RESOLVE, - send_meeting_invitations=SEND_TO_NONE): + def send_and_save( + self, update_fields=None, conflict_resolution=AUTO_RESOLVE, send_meeting_invitations=SEND_TO_NONE + ): # Sends Message and saves a copy in the parent folder. Does not return an ItemId. if self.id: self._update( update_fieldnames=update_fields, message_disposition=SEND_AND_SAVE_COPY, conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations + send_meeting_invitations=send_meeting_invitations, ) else: if self.account.version.build < EXCHANGE_2013 and self.attachments: # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need # to first save, then attach, then send. This is done in save(). - self.save(update_fields=update_fields, conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) - self.send(save_copy=False, conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) - else: - self._create( - message_disposition=SEND_AND_SAVE_COPY, - send_meeting_invitations=send_meeting_invitations + self.save( + update_fields=update_fields, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, + ) + self.send( + save_copy=False, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, ) + else: + self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations) @require_id def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): @@ -8887,13 +9271,7 @@

                                Inherited members

                                ) def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): - self.create_reply( - subject, - body, - to_recipients, - cc_recipients, - bcc_recipients - ).send() + self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients).send() @require_id def create_reply_all(self, subject, body): @@ -8922,6 +9300,7 @@

                                Inherited members

                                :return: """ from ..services import MarkAsJunk + res = MarkAsJunk(account=self.account).get( items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item ) @@ -9095,6 +9474,7 @@

                                Methods

                                :return: """ from ..services import MarkAsJunk + res = MarkAsJunk(account=self.account).get( items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item ) @@ -9114,13 +9494,7 @@

                                Methods

                                Expand source code
                                def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None):
                                -    self.create_reply(
                                -        subject,
                                -        body,
                                -        to_recipients,
                                -        cc_recipients,
                                -        bcc_recipients
                                -    ).send()
                                + self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients).send()
                                @@ -9146,9 +9520,15 @@

                                Methods

                                Expand source code
                                @require_account
                                -def send(self, save_copy=True, copy_to_folder=None, conflict_resolution=AUTO_RESOLVE,
                                -         send_meeting_invitations=SEND_TO_NONE):
                                +def send(
                                +    self,
                                +    save_copy=True,
                                +    copy_to_folder=None,
                                +    conflict_resolution=AUTO_RESOLVE,
                                +    send_meeting_invitations=SEND_TO_NONE,
                                +):
                                     from ..services import SendItem
                                +
                                     # Only sends a message. The message can either be an existing draft stored in EWS or a new message that does
                                     # not yet exist in EWS.
                                     if copy_to_folder and not save_copy:
                                @@ -9166,14 +9546,16 @@ 

                                Methods

                                if copy_to_folder: # This would better be done via send_and_save() but lets just support it here self.folder = copy_to_folder - return self.send_and_save(conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) + return self.send_and_save( + conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations + ) if self.account.version.build < EXCHANGE_2013 and self.attachments: # At least some versions prior to Exchange 2013 can't send attachments immediately. You need to first save, # then attach, then send. This is done in send_and_save(). send() will delete the item again. - self.send_and_save(conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) + self.send_and_save( + conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations + ) return None self._create(message_disposition=SEND_ONLY, send_meeting_invitations=send_meeting_invitations) @@ -9189,29 +9571,33 @@

                                Methods

                                Expand source code -
                                def send_and_save(self, update_fields=None, conflict_resolution=AUTO_RESOLVE,
                                -                  send_meeting_invitations=SEND_TO_NONE):
                                +
                                def send_and_save(
                                +    self, update_fields=None, conflict_resolution=AUTO_RESOLVE, send_meeting_invitations=SEND_TO_NONE
                                +):
                                     # Sends Message and saves a copy in the parent folder. Does not return an ItemId.
                                     if self.id:
                                         self._update(
                                             update_fieldnames=update_fields,
                                             message_disposition=SEND_AND_SAVE_COPY,
                                             conflict_resolution=conflict_resolution,
                                -            send_meeting_invitations=send_meeting_invitations
                                +            send_meeting_invitations=send_meeting_invitations,
                                         )
                                     else:
                                         if self.account.version.build < EXCHANGE_2013 and self.attachments:
                                             # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need
                                             # to first save, then attach, then send. This is done in save().
                                -            self.save(update_fields=update_fields, conflict_resolution=conflict_resolution,
                                -                      send_meeting_invitations=send_meeting_invitations)
                                -            self.send(save_copy=False, conflict_resolution=conflict_resolution,
                                -                      send_meeting_invitations=send_meeting_invitations)
                                +            self.save(
                                +                update_fields=update_fields,
                                +                conflict_resolution=conflict_resolution,
                                +                send_meeting_invitations=send_meeting_invitations,
                                +            )
                                +            self.send(
                                +                save_copy=False,
                                +                conflict_resolution=conflict_resolution,
                                +                send_meeting_invitations=send_meeting_invitations,
                                +            )
                                         else:
                                -            self._create(
                                -                message_disposition=SEND_AND_SAVE_COPY,
                                -                send_meeting_invitations=send_meeting_invitations
                                -            )
                                + self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations)
                                @@ -9342,20 +9728,23 @@

                                Methods

                                super().__init__(**kwargs) self.authorization_code = authorization_code if access_token is not None and not isinstance(access_token, dict): - raise InvalidTypeError('access_token', access_token, OAuth2Token) + raise InvalidTypeError("access_token", access_token, OAuth2Token) self.access_token = access_token def __repr__(self): return self.__class__.__name__ + repr( - (self.client_id, '[client_secret]', '[authorization_code]', '[access_token]') + (self.client_id, "[client_secret]", "[authorization_code]", "[access_token]") ) def __str__(self): client_id = self.client_id - credential = '[access_token]' if self.access_token is not None else \ - ('[authorization_code]' if self.authorization_code is not None else None) - description = ' '.join(filter(None, [client_id, credential])) - return description or '[underspecified credentials]'
                                + credential = ( + "[access_token]" + if self.access_token is not None + else ("[authorization_code]" if self.authorization_code is not None else None) + ) + description = " ".join(filter(None, [client_id, credential])) + return description or "[underspecified credentials]"

                                Ancestors

                                  @@ -9430,31 +9819,31 @@

                                  Inherited members

                                  """ # Ensure we don't update the object in the middle of a new session being created, which could cause a race. if not isinstance(access_token, dict): - raise InvalidTypeError('access_token', access_token, OAuth2Token) + raise InvalidTypeError("access_token", access_token, OAuth2Token) with self.lock: - log.debug('%s auth token for %s', 'Refreshing' if self.access_token else 'Setting', self.client_id) + log.debug("%s auth token for %s", "Refreshing" if self.access_token else "Setting", self.client_id) self.access_token = access_token def _get_hash_values(self): # 'access_token' may be refreshed once in a while. This should not affect the hash signature. # 'identity' is just informational and should also not affect the hash signature. - return (getattr(self, k) for k in self.__dict__ if k not in ('_lock', 'identity', 'access_token')) + return (getattr(self, k) for k in self.__dict__ if k not in ("_lock", "identity", "access_token")) def sig(self): # Like hash(self), but pulls in the access token. Protocol.refresh_credentials() uses this to find out # if the access_token needs to be refreshed. res = [] for k in self.__dict__: - if k in ('_lock', 'identity'): + if k in ("_lock", "identity"): continue - if k == 'access_token': - res.append(self.access_token['access_token'] if self.access_token else None) + if k == "access_token": + res.append(self.access_token["access_token"] if self.access_token else None) continue res.append(getattr(self, k)) return hash(tuple(res)) def __repr__(self): - return self.__class__.__name__ + repr((self.client_id, '********')) + return self.__class__.__name__ + repr((self.client_id, "********")) def __str__(self): return self.client_id @@ -9492,9 +9881,9 @@

                                  Methods

                                  """ # Ensure we don't update the object in the middle of a new session being created, which could cause a race. if not isinstance(access_token, dict): - raise InvalidTypeError('access_token', access_token, OAuth2Token) + raise InvalidTypeError("access_token", access_token, OAuth2Token) with self.lock: - log.debug('%s auth token for %s', 'Refreshing' if self.access_token else 'Setting', self.client_id) + log.debug("%s auth token for %s", "Refreshing" if self.access_token else "Setting", self.client_id) self.access_token = access_token @@ -9512,10 +9901,10 @@

                                  Methods

                                  # if the access_token needs to be refreshed. res = [] for k in self.__dict__: - if k in ('_lock', 'identity'): + if k in ("_lock", "identity"): continue - if k == 'access_token': - res.append(self.access_token['access_token'] if self.access_token else None) + if k == "access_token": + res.append(self.access_token["access_token"] if self.access_token else None) continue res.append(getattr(self, k)) return hash(tuple(res)) @@ -9544,21 +9933,22 @@

                                  Inherited members

                                  class OofSettings(EWSElement):
                                       """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/oofsettings"""
                                   
                                  -    ELEMENT_NAME = 'OofSettings'
                                  -    REQUEST_ELEMENT_NAME = 'UserOofSettings'
                                  +    ELEMENT_NAME = "OofSettings"
                                  +    REQUEST_ELEMENT_NAME = "UserOofSettings"
                                   
                                  -    ENABLED = 'Enabled'
                                  -    SCHEDULED = 'Scheduled'
                                  -    DISABLED = 'Disabled'
                                  +    ENABLED = "Enabled"
                                  +    SCHEDULED = "Scheduled"
                                  +    DISABLED = "Disabled"
                                       STATE_CHOICES = (ENABLED, SCHEDULED, DISABLED)
                                   
                                  -    state = ChoiceField(field_uri='OofState', is_required=True, choices={Choice(c) for c in STATE_CHOICES})
                                  -    external_audience = ChoiceField(field_uri='ExternalAudience',
                                  -                                    choices={Choice('None'), Choice('Known'), Choice('All')}, default='All')
                                  -    start = DateTimeField(field_uri='StartTime')
                                  -    end = DateTimeField(field_uri='EndTime')
                                  -    internal_reply = MessageField(field_uri='InternalReply')
                                  -    external_reply = MessageField(field_uri='ExternalReply')
                                  +    state = ChoiceField(field_uri="OofState", is_required=True, choices={Choice(c) for c in STATE_CHOICES})
                                  +    external_audience = ChoiceField(
                                  +        field_uri="ExternalAudience", choices={Choice("None"), Choice("Known"), Choice("All")}, default="All"
                                  +    )
                                  +    start = DateTimeField(field_uri="StartTime")
                                  +    end = DateTimeField(field_uri="EndTime")
                                  +    internal_reply = MessageField(field_uri="InternalReply")
                                  +    external_reply = MessageField(field_uri="ExternalReply")
                                   
                                       def clean(self, version=None):
                                           super().clean(version=version)
                                  @@ -9575,7 +9965,7 @@ 

                                  Inherited members

                                  @classmethod def from_xml(cls, elem, account): kwargs = {} - for attr in ('state', 'external_audience', 'internal_reply', 'external_reply'): + for attr in ("state", "external_audience", "internal_reply", "external_reply"): f = cls.get_field_by_fieldname(attr) kwargs[attr] = f.from_xml(elem=elem, account=account) kwargs.update(OutOfOffice.duration_to_start_end(elem=elem, account=account)) @@ -9584,24 +9974,24 @@

                                  Inherited members

                                  def to_xml(self, version): self.clean(version=version) - elem = create_element(f't:{self.REQUEST_ELEMENT_NAME}') - for attr in ('state', 'external_audience'): + elem = create_element(f"t:{self.REQUEST_ELEMENT_NAME}") + for attr in ("state", "external_audience"): value = getattr(self, attr) f = self.get_field_by_fieldname(attr) set_xml_value(elem, f.to_xml(value, version=version)) if self.start or self.end: - duration = create_element('t:Duration') + duration = create_element("t:Duration") if self.start: - f = self.get_field_by_fieldname('start') + f = self.get_field_by_fieldname("start") set_xml_value(duration, f.to_xml(self.start, version=version)) if self.end: - f = self.get_field_by_fieldname('end') + f = self.get_field_by_fieldname("end") set_xml_value(duration, f.to_xml(self.end, version=version)) elem.append(duration) - for attr in ('internal_reply', 'external_reply'): + for attr in ("internal_reply", "external_reply"): value = getattr(self, attr) if value is None: - value = '' # The value can be empty, but the XML element must always be present + value = "" # The value can be empty, but the XML element must always be present f = self.get_field_by_fieldname(attr) set_xml_value(elem, f.to_xml(value, version=version)) return elem @@ -9610,10 +10000,10 @@

                                  Inherited members

                                  # Customize comparison if self.state == self.DISABLED: # All values except state are ignored by the server - relevant_attrs = ('state',) + relevant_attrs = ("state",) elif self.state != self.SCHEDULED: # 'start' and 'end' values are ignored by the server, and the server always returns today's date - relevant_attrs = tuple(f.name for f in self.FIELDS if f.name not in ('start', 'end')) + relevant_attrs = tuple(f.name for f in self.FIELDS if f.name not in ("start", "end")) else: relevant_attrs = tuple(f.name for f in self.FIELDS) return hash(tuple(getattr(self, attr) for attr in relevant_attrs))
                                  @@ -9667,7 +10057,7 @@

                                  Static methods

                                  @classmethod
                                   def from_xml(cls, elem, account):
                                       kwargs = {}
                                  -    for attr in ('state', 'external_audience', 'internal_reply', 'external_reply'):
                                  +    for attr in ("state", "external_audience", "internal_reply", "external_reply"):
                                           f = cls.get_field_by_fieldname(attr)
                                           kwargs[attr] = f.from_xml(elem=elem, account=account)
                                       kwargs.update(OutOfOffice.duration_to_start_end(elem=elem, account=account))
                                  @@ -9738,24 +10128,24 @@ 

                                  Methods

                                  def to_xml(self, version):
                                       self.clean(version=version)
                                  -    elem = create_element(f't:{self.REQUEST_ELEMENT_NAME}')
                                  -    for attr in ('state', 'external_audience'):
                                  +    elem = create_element(f"t:{self.REQUEST_ELEMENT_NAME}")
                                  +    for attr in ("state", "external_audience"):
                                           value = getattr(self, attr)
                                           f = self.get_field_by_fieldname(attr)
                                           set_xml_value(elem, f.to_xml(value, version=version))
                                       if self.start or self.end:
                                  -        duration = create_element('t:Duration')
                                  +        duration = create_element("t:Duration")
                                           if self.start:
                                  -            f = self.get_field_by_fieldname('start')
                                  +            f = self.get_field_by_fieldname("start")
                                               set_xml_value(duration, f.to_xml(self.start, version=version))
                                           if self.end:
                                  -            f = self.get_field_by_fieldname('end')
                                  +            f = self.get_field_by_fieldname("end")
                                               set_xml_value(duration, f.to_xml(self.end, version=version))
                                           elem.append(duration)
                                  -    for attr in ('internal_reply', 'external_reply'):
                                  +    for attr in ("internal_reply", "external_reply"):
                                           value = getattr(self, attr)
                                           if value is None:
                                  -            value = ''  # The value can be empty, but the XML element must always be present
                                  +            value = ""  # The value can be empty, but the XML element must always be present
                                           f = self.get_field_by_fieldname(attr)
                                           set_xml_value(elem, f.to_xml(value, version=version))
                                       return elem
                                  @@ -9792,18 +10182,18 @@

                                  Inherited members

                                  class PostItem(Item):
                                       """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/postitem"""
                                   
                                  -    ELEMENT_NAME = 'PostItem'
                                  +    ELEMENT_NAME = "PostItem"
                                   
                                  -    conversation_index = Message.FIELDS['conversation_index']
                                  -    conversation_topic = Message.FIELDS['conversation_topic']
                                  +    conversation_index = Message.FIELDS["conversation_index"]
                                  +    conversation_topic = Message.FIELDS["conversation_topic"]
                                   
                                  -    author = Message.FIELDS['author']
                                  -    message_id = Message.FIELDS['message_id']
                                  -    is_read = Message.FIELDS['is_read']
                                  +    author = Message.FIELDS["author"]
                                  +    message_id = Message.FIELDS["message_id"]
                                  +    is_read = Message.FIELDS["is_read"]
                                   
                                  -    posted_time = DateTimeField(field_uri='postitem:PostedTime', is_read_only=True)
                                  -    references = TextField(field_uri='message:References')
                                  -    sender = MailboxField(field_uri='message:Sender', is_read_only=True, is_read_only_after_send=True)
                                  + posted_time = DateTimeField(field_uri="postitem:PostedTime", is_read_only=True) + references = TextField(field_uri="message:References") + sender = MailboxField(field_uri="message:Sender", is_read_only=True, is_read_only_after_send=True)

                                  Ancestors

                                    @@ -9892,52 +10282,65 @@

                                    Inherited members

                                    """A class with an API similar to Django Q objects. Used to implement advanced filtering logic.""" # Connection types - AND = 'AND' - OR = 'OR' - NOT = 'NOT' - NEVER = 'NEVER' # This is not specified by EWS. We use it for queries that will never match, e.g. 'foo__in=()' + AND = "AND" + OR = "OR" + NOT = "NOT" + NEVER = "NEVER" # This is not specified by EWS. We use it for queries that will never match, e.g. 'foo__in=()' CONN_TYPES = {AND, OR, NOT, NEVER} # EWS Operators - EQ = '==' - NE = '!=' - GT = '>' - GTE = '>=' - LT = '<' - LTE = '<=' - EXACT = 'exact' - IEXACT = 'iexact' - CONTAINS = 'contains' - ICONTAINS = 'icontains' - STARTSWITH = 'startswith' - ISTARTSWITH = 'istartswith' - EXISTS = 'exists' + EQ = "==" + NE = "!=" + GT = ">" + GTE = ">=" + LT = "<" + LTE = "<=" + EXACT = "exact" + IEXACT = "iexact" + CONTAINS = "contains" + ICONTAINS = "icontains" + STARTSWITH = "startswith" + ISTARTSWITH = "istartswith" + EXISTS = "exists" OP_TYPES = {EQ, NE, GT, GTE, LT, LTE, EXACT, IEXACT, CONTAINS, ICONTAINS, STARTSWITH, ISTARTSWITH, EXISTS} CONTAINS_OPS = {EXACT, IEXACT, CONTAINS, ICONTAINS, STARTSWITH, ISTARTSWITH} # Valid lookups - LOOKUP_RANGE = 'range' - LOOKUP_IN = 'in' - LOOKUP_NOT = 'not' - LOOKUP_GT = 'gt' - LOOKUP_GTE = 'gte' - LOOKUP_LT = 'lt' - LOOKUP_LTE = 'lte' - LOOKUP_EXACT = 'exact' - LOOKUP_IEXACT = 'iexact' - LOOKUP_CONTAINS = 'contains' - LOOKUP_ICONTAINS = 'icontains' - LOOKUP_STARTSWITH = 'startswith' - LOOKUP_ISTARTSWITH = 'istartswith' - LOOKUP_EXISTS = 'exists' - LOOKUP_TYPES = {LOOKUP_RANGE, LOOKUP_IN, LOOKUP_NOT, LOOKUP_GT, LOOKUP_GTE, LOOKUP_LT, LOOKUP_LTE, LOOKUP_EXACT, - LOOKUP_IEXACT, LOOKUP_CONTAINS, LOOKUP_ICONTAINS, LOOKUP_STARTSWITH, LOOKUP_ISTARTSWITH, - LOOKUP_EXISTS} - - __slots__ = 'conn_type', 'field_path', 'op', 'value', 'children', 'query_string' + LOOKUP_RANGE = "range" + LOOKUP_IN = "in" + LOOKUP_NOT = "not" + LOOKUP_GT = "gt" + LOOKUP_GTE = "gte" + LOOKUP_LT = "lt" + LOOKUP_LTE = "lte" + LOOKUP_EXACT = "exact" + LOOKUP_IEXACT = "iexact" + LOOKUP_CONTAINS = "contains" + LOOKUP_ICONTAINS = "icontains" + LOOKUP_STARTSWITH = "startswith" + LOOKUP_ISTARTSWITH = "istartswith" + LOOKUP_EXISTS = "exists" + LOOKUP_TYPES = { + LOOKUP_RANGE, + LOOKUP_IN, + LOOKUP_NOT, + LOOKUP_GT, + LOOKUP_GTE, + LOOKUP_LT, + LOOKUP_LTE, + LOOKUP_EXACT, + LOOKUP_IEXACT, + LOOKUP_CONTAINS, + LOOKUP_ICONTAINS, + LOOKUP_STARTSWITH, + LOOKUP_ISTARTSWITH, + LOOKUP_EXISTS, + } + + __slots__ = "conn_type", "field_path", "op", "value", "children", "query_string" def __init__(self, *args, **kwargs): - self.conn_type = kwargs.pop('conn_type', self.AND) + self.conn_type = kwargs.pop("conn_type", self.AND) self.field_path = None # Name of the field we want to filter on self.op = None @@ -9961,9 +10364,7 @@

                                    Inherited members

                                    # Parse keyword args and extract the filter is_single_kwarg = len(args) == 0 and len(kwargs) == 1 for key, value in kwargs.items(): - self.children.extend( - self._get_children_from_kwarg(key=key, value=value, is_single_kwarg=is_single_kwarg) - ) + self.children.extend(self._get_children_from_kwarg(key=key, value=value, is_single_kwarg=is_single_kwarg)) # Simplify this object self.reduce() @@ -9975,7 +10376,7 @@

                                    Inherited members

                                    """Generate Q objects corresponding to a single keyword argument. Make this a leaf if there are no children to generate. """ - key_parts = key.rsplit('__', 1) + key_parts = key.rsplit("__", 1) if len(key_parts) == 2 and key_parts[1] in self.LOOKUP_TYPES: # This is a kwarg with a lookup at the end field_path, lookup = key_parts @@ -9990,8 +10391,8 @@

                                    Inherited members

                                    if len(value) != 2: raise ValueError(f"Value of lookup {key!r} must have exactly 2 elements") return ( - self.__class__(**{f'{field_path}__gte': value[0]}), - self.__class__(**{f'{field_path}__lte': value[1]}), + self.__class__(**{f"{field_path}__gte": value[0]}), + self.__class__(**{f"{field_path}__lte": value[1]}), ) # Filtering on list types is a bit quirky. The only lookup type I have found to work is: @@ -10094,6 +10495,7 @@

                                    Inherited members

                                    validating. There's no reason to replicate much of that here. """ from .folders import Folder + self.to_xml(folders=[Folder()], version=version, applies_to=Restriction.ITEMS) @classmethod @@ -10116,28 +10518,28 @@

                                    Inherited members

                                    @classmethod def _conn_to_xml(cls, conn_type): xml_tag_map = { - cls.AND: 't:And', - cls.OR: 't:Or', - cls.NOT: 't:Not', + cls.AND: "t:And", + cls.OR: "t:Or", + cls.NOT: "t:Not", } return create_element(xml_tag_map[conn_type]) @classmethod def _op_to_xml(cls, op): xml_tag_map = { - cls.EQ: 't:IsEqualTo', - cls.NE: 't:IsNotEqualTo', - cls.GTE: 't:IsGreaterThanOrEqualTo', - cls.LTE: 't:IsLessThanOrEqualTo', - cls.LT: 't:IsLessThan', - cls.GT: 't:IsGreaterThan', - cls.EXISTS: 't:Exists', + cls.EQ: "t:IsEqualTo", + cls.NE: "t:IsNotEqualTo", + cls.GTE: "t:IsGreaterThanOrEqualTo", + cls.LTE: "t:IsLessThanOrEqualTo", + cls.LT: "t:IsLessThan", + cls.GT: "t:IsGreaterThan", + cls.EXISTS: "t:Exists", } if op in xml_tag_map: return create_element(xml_tag_map[op]) valid_ops = cls.EXACT, cls.IEXACT, cls.CONTAINS, cls.ICONTAINS, cls.STARTSWITH, cls.ISTARTSWITH if op not in valid_ops: - raise InvalidEnumValue('op', op, valid_ops) + raise InvalidEnumValue("op", op, valid_ops) # For description of Contains attribute values, see # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/contains @@ -10161,21 +10563,18 @@

                                    Inherited members

                                    # https://en.wikipedia.org/wiki/Graphic_character#Spacing_and_non-spacing_characters # we shouldn't ignore them ('a' would match both 'a' and 'å', the latter having a non-spacing character). if op in {cls.EXACT, cls.IEXACT}: - match_mode = 'FullString' + match_mode = "FullString" elif op in (cls.CONTAINS, cls.ICONTAINS): - match_mode = 'Substring' + match_mode = "Substring" elif op in (cls.STARTSWITH, cls.ISTARTSWITH): - match_mode = 'Prefixed' + match_mode = "Prefixed" else: - raise ValueError(f'Unsupported op: {op}') + raise ValueError(f"Unsupported op: {op}") if op in (cls.IEXACT, cls.ICONTAINS, cls.ISTARTSWITH): - compare_mode = 'IgnoreCase' + compare_mode = "IgnoreCase" else: - compare_mode = 'Exact' - return create_element( - 't:Contains', - attrs=dict(ContainmentMode=match_mode, ContainmentComparison=compare_mode) - ) + compare_mode = "Exact" + return create_element("t:Contains", attrs=dict(ContainmentMode=match_mode, ContainmentComparison=compare_mode)) def is_leaf(self): return not self.children @@ -10196,33 +10595,33 @@

                                    Inherited members

                                    if self.query_string: return self.query_string if self.is_leaf(): - expr = f'{self.field_path} {self.op} {self.value!r}' + expr = f"{self.field_path} {self.op} {self.value!r}" else: # Sort children by field name so we get stable output (for easier testing). Children should never be empty. - expr = f' {self.AND if self.conn_type == self.NOT else self.conn_type} '.join( - (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f'({c.expr()})') - for c in sorted(self.children, key=lambda i: i.field_path or '') + expr = f" {self.AND if self.conn_type == self.NOT else self.conn_type} ".join( + (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f"({c.expr()})") + for c in sorted(self.children, key=lambda i: i.field_path or "") ) if self.conn_type == self.NOT: # Add the NOT operator. Put children in parens if there is more than one child. if self.is_leaf() or len(self.children) == 1: - return self.conn_type + f' {expr}' - return self.conn_type + f' ({expr})' + return self.conn_type + f" {expr}" + return self.conn_type + f" ({expr})" return expr def to_xml(self, folders, version, applies_to): if self.query_string: self._check_integrity() if version.build < EXCHANGE_2010: - raise NotImplementedError('QueryString filtering is only supported for Exchange 2010 servers and later') - elem = create_element('m:QueryString') + raise NotImplementedError("QueryString filtering is only supported for Exchange 2010 servers and later") + elem = create_element("m:QueryString") elem.text = self.query_string return elem # Translate this Q object to a valid Restriction XML tree elem = self.xml_elem(folders=folders, version=version, applies_to=applies_to) if elem is None: return None - restriction = create_element('m:Restriction') + restriction = create_element("m:Restriction") restriction.append(elem) return restriction @@ -10235,30 +10634,31 @@

                                    Inherited members

                                    return if self.query_string: if any([self.field_path, self.op, self.value, self.children]): - raise ValueError('Query strings cannot be combined with other settings') + raise ValueError("Query strings cannot be combined with other settings") return if self.conn_type not in self.CONN_TYPES: - raise InvalidEnumValue('conn_type', self.conn_type, self.CONN_TYPES) + raise InvalidEnumValue("conn_type", self.conn_type, self.CONN_TYPES) if not self.is_leaf(): for q in self.children: if q.query_string and len(self.children) > 1: - raise ValueError('A query string cannot be combined with other restrictions') + raise ValueError("A query string cannot be combined with other restrictions") return if not self.field_path: raise ValueError("'field_path' must be set") if self.op not in self.OP_TYPES: - raise InvalidEnumValue('op', self.op, self.OP_TYPES) + raise InvalidEnumValue("op", self.op, self.OP_TYPES) if self.op == self.EXISTS and self.value is not True: raise ValueError("'value' must be True when operator is EXISTS") if self.value is None: - raise ValueError(f'Value for filter on field path {self.field_path!r} cannot be None') + raise ValueError(f"Value for filter on field path {self.field_path!r} cannot be None") if is_iterable(self.value, generators_allowed=True): raise ValueError( - f'Value {self.value!r} for filter on field path {self.field_path!r} must be a single value' + f"Value {self.value!r} for filter on field path {self.field_path!r} must be a single value" ) def _validate_field_path(self, field_path, folder, applies_to, version): from .indexed_properties import MultiFieldIndexedElement + if applies_to == Restriction.FOLDERS: # This is a restriction on Folder fields folder.validate_field(field=field_path.field, version=version) @@ -10303,6 +10703,7 @@

                                    Inherited members

                                    # Recursively build an XML tree structure of this Q object. If this is an empty leaf (the equivalent of Q()), # return None. from .indexed_properties import SingleFieldIndexedElement + # Don't check self.value just yet. We want to return error messages on the field path first, and then the value. # This is done in _get_field_path() and _get_clean_value(), respectively. self._check_integrity() @@ -10323,11 +10724,11 @@

                                    Inherited members

                                    clean_value = field_path.field.date_to_datetime(clean_value) elem.append(field_path.to_xml()) if self.op != self.EXISTS: - constant = create_element('t:Constant', attrs=dict(Value=value_to_xml_text(clean_value))) + constant = create_element("t:Constant", attrs=dict(Value=value_to_xml_text(clean_value))) if self.op in self.CONTAINS_OPS: elem.append(constant) else: - uriorconst = create_element('t:FieldURIOrConstant') + uriorconst = create_element("t:FieldURIOrConstant") uriorconst.append(constant) elem.append(uriorconst) elif len(self.children) == 1: @@ -10337,7 +10738,7 @@

                                    Inherited members

                                    # We have multiple children. If conn_type is NOT, then group children with AND. We'll add the NOT later elem = self._conn_to_xml(self.AND if self.conn_type == self.NOT else self.conn_type) # Sort children by field name so we get stable output (for easier testing). Children should never be empty - for c in sorted(self.children, key=lambda i: i.field_path or ''): + for c in sorted(self.children, key=lambda i: i.field_path or ""): elem.append(c.xml_elem(folders=folders, version=version, applies_to=applies_to)) if elem is None: return None # Should not be necessary, but play safe @@ -10389,16 +10790,16 @@

                                    Inherited members

                                    return hash(repr(self)) def __str__(self): - return self.expr() or 'Q()' + return self.expr() or "Q()" def __repr__(self): if self.is_leaf(): if self.query_string: - return self.__class__.__name__ + f'({self.query_string!r})' + return self.__class__.__name__ + f"({self.query_string!r})" if self.is_never(): - return self.__class__.__name__ + f'(conn_type={self.conn_type!r})' - return self.__class__.__name__ + f'({self.field_path} {self.op} {self.value!r})' - sorted_children = tuple(sorted(self.children, key=lambda i: i.field_path or '')) + return self.__class__.__name__ + f"(conn_type={self.conn_type!r})" + return self.__class__.__name__ + f"({self.field_path} {self.op} {self.value!r})" + sorted_children = tuple(sorted(self.children, key=lambda i: i.field_path or "")) if self.conn_type == self.NOT or len(self.children) > 1: return self.__class__.__name__ + repr((self.conn_type,) + sorted_children) return self.__class__.__name__ + repr(sorted_children) @@ -10590,6 +10991,7 @@

                                    Methods

                                    validating. There's no reason to replicate much of that here. """ from .folders import Folder + self.to_xml(folders=[Folder()], version=version, applies_to=Restriction.ITEMS) @@ -10610,18 +11012,18 @@

                                    Methods

                                    if self.query_string: return self.query_string if self.is_leaf(): - expr = f'{self.field_path} {self.op} {self.value!r}' + expr = f"{self.field_path} {self.op} {self.value!r}" else: # Sort children by field name so we get stable output (for easier testing). Children should never be empty. - expr = f' {self.AND if self.conn_type == self.NOT else self.conn_type} '.join( - (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f'({c.expr()})') - for c in sorted(self.children, key=lambda i: i.field_path or '') + expr = f" {self.AND if self.conn_type == self.NOT else self.conn_type} ".join( + (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f"({c.expr()})") + for c in sorted(self.children, key=lambda i: i.field_path or "") ) if self.conn_type == self.NOT: # Add the NOT operator. Put children in parens if there is more than one child. if self.is_leaf() or len(self.children) == 1: - return self.conn_type + f' {expr}' - return self.conn_type + f' ({expr})' + return self.conn_type + f" {expr}" + return self.conn_type + f" ({expr})" return expr @@ -10694,15 +11096,15 @@

                                    Methods

                                    if self.query_string: self._check_integrity() if version.build < EXCHANGE_2010: - raise NotImplementedError('QueryString filtering is only supported for Exchange 2010 servers and later') - elem = create_element('m:QueryString') + raise NotImplementedError("QueryString filtering is only supported for Exchange 2010 servers and later") + elem = create_element("m:QueryString") elem.text = self.query_string return elem # Translate this Q object to a valid Restriction XML tree elem = self.xml_elem(folders=folders, version=version, applies_to=applies_to) if elem is None: return None - restriction = create_element('m:Restriction') + restriction = create_element("m:Restriction") restriction.append(elem) return restriction @@ -10720,6 +11122,7 @@

                                    Methods

                                    # Recursively build an XML tree structure of this Q object. If this is an empty leaf (the equivalent of Q()), # return None. from .indexed_properties import SingleFieldIndexedElement + # Don't check self.value just yet. We want to return error messages on the field path first, and then the value. # This is done in _get_field_path() and _get_clean_value(), respectively. self._check_integrity() @@ -10740,11 +11143,11 @@

                                    Methods

                                    clean_value = field_path.field.date_to_datetime(clean_value) elem.append(field_path.to_xml()) if self.op != self.EXISTS: - constant = create_element('t:Constant', attrs=dict(Value=value_to_xml_text(clean_value))) + constant = create_element("t:Constant", attrs=dict(Value=value_to_xml_text(clean_value))) if self.op in self.CONTAINS_OPS: elem.append(constant) else: - uriorconst = create_element('t:FieldURIOrConstant') + uriorconst = create_element("t:FieldURIOrConstant") uriorconst.append(constant) elem.append(uriorconst) elif len(self.children) == 1: @@ -10754,7 +11157,7 @@

                                    Methods

                                    # We have multiple children. If conn_type is NOT, then group children with AND. We'll add the NOT later elem = self._conn_to_xml(self.AND if self.conn_type == self.NOT else self.conn_type) # Sort children by field name so we get stable output (for easier testing). Children should never be empty - for c in sorted(self.children, key=lambda i: i.field_path or ''): + for c in sorted(self.children, key=lambda i: i.field_path or ""): elem.append(c.xml_elem(folders=folders, version=version, applies_to=applies_to)) if elem is None: return None # Should not be necessary, but play safe @@ -10781,7 +11184,7 @@

                                    Methods

                                    class ReplyAllToItem(BaseReplyItem):
                                         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/replyalltoitem"""
                                     
                                    -    ELEMENT_NAME = 'ReplyAllToItem'
                                    + ELEMENT_NAME = "ReplyAllToItem"

                                    Ancestors

                                      @@ -10822,7 +11225,7 @@

                                      Inherited members

                                      class ReplyToItem(BaseReplyItem):
                                           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/replytoitem"""
                                       
                                      -    ELEMENT_NAME = 'ReplyToItem'
                                      + ELEMENT_NAME = "ReplyToItem"

                                      Ancestors

                                        @@ -10863,16 +11266,16 @@

                                        Inherited members

                                        class Room(Mailbox):
                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/room"""
                                         
                                        -    ELEMENT_NAME = 'Room'
                                        +    ELEMENT_NAME = "Room"
                                         
                                             @classmethod
                                             def from_xml(cls, elem, account):
                                        -        id_elem = elem.find(f'{{{TNS}}}Id')
                                        +        id_elem = elem.find(f"{{{TNS}}}Id")
                                                 item_id_elem = id_elem.find(ItemId.response_tag())
                                                 kwargs = dict(
                                        -            name=get_xml_attr(id_elem, f'{{{TNS}}}Name'),
                                        -            email_address=get_xml_attr(id_elem, f'{{{TNS}}}EmailAddress'),
                                        -            mailbox_type=get_xml_attr(id_elem, f'{{{TNS}}}MailboxType'),
                                        +            name=get_xml_attr(id_elem, f"{{{TNS}}}Name"),
                                        +            email_address=get_xml_attr(id_elem, f"{{{TNS}}}EmailAddress"),
                                        +            mailbox_type=get_xml_attr(id_elem, f"{{{TNS}}}MailboxType"),
                                                     item_id=ItemId.from_xml(elem=item_id_elem, account=account) if item_id_elem else None,
                                                 )
                                                 cls._clear(elem)
                                        @@ -10903,12 +11306,12 @@ 

                                        Static methods

                                        @classmethod
                                         def from_xml(cls, elem, account):
                                        -    id_elem = elem.find(f'{{{TNS}}}Id')
                                        +    id_elem = elem.find(f"{{{TNS}}}Id")
                                             item_id_elem = id_elem.find(ItemId.response_tag())
                                             kwargs = dict(
                                        -        name=get_xml_attr(id_elem, f'{{{TNS}}}Name'),
                                        -        email_address=get_xml_attr(id_elem, f'{{{TNS}}}EmailAddress'),
                                        -        mailbox_type=get_xml_attr(id_elem, f'{{{TNS}}}MailboxType'),
                                        +        name=get_xml_attr(id_elem, f"{{{TNS}}}Name"),
                                        +        email_address=get_xml_attr(id_elem, f"{{{TNS}}}EmailAddress"),
                                        +        mailbox_type=get_xml_attr(id_elem, f"{{{TNS}}}MailboxType"),
                                                 item_id=ItemId.from_xml(elem=item_id_elem, account=account) if item_id_elem else None,
                                             )
                                             cls._clear(elem)
                                        @@ -10941,14 +11344,14 @@ 

                                        Inherited members

                                        class RoomList(Mailbox):
                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/roomlist"""
                                         
                                        -    ELEMENT_NAME = 'RoomList'
                                        +    ELEMENT_NAME = "RoomList"
                                             NAMESPACE = MNS
                                         
                                             @classmethod
                                             def response_tag(cls):
                                                 # In a GetRoomLists response, room lists are delivered as Address elements. See
                                                 # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/address-emailaddresstype
                                        -        return f'{{{TNS}}}Address'
                                        + return f"{{{TNS}}}Address"

                                        Ancestors

                                          @@ -10981,7 +11384,7 @@

                                          Static methods

                                          def response_tag(cls): # In a GetRoomLists response, room lists are delivered as Address elements. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/address-emailaddresstype - return f'{{{TNS}}}Address'
                                        + return f"{{{TNS}}}Address" @@ -11021,14 +11424,15 @@

                                        Inherited members

                                        # This folder type also has 'folder:PermissionSet' on some server versions, but requesting it sometimes causes # 'ErrorAccessDenied', as reported by some users. Ignore it entirely for root folders - it's usefulness is # deemed minimal at best. - effective_rights = EffectiveRightsField(field_uri='folder:EffectiveRights', is_read_only=True, - supported_from=EXCHANGE_2007_SP1) + effective_rights = EffectiveRightsField( + field_uri="folder:EffectiveRights", is_read_only=True, supported_from=EXCHANGE_2007_SP1 + ) - __slots__ = '_account', '_subfolders' + __slots__ = "_account", "_subfolders" # A special folder that acts as the top of a folder hierarchy. Finds and caches subfolders at arbitrary depth. def __init__(self, **kwargs): - self._account = kwargs.pop('account', None) # A pointer back to the account holding the folder hierarchy + self._account = kwargs.pop("account", None) # A pointer back to the account holding the folder hierarchy super().__init__(**kwargs) self._subfolders = None # See self._folders_map() @@ -11047,13 +11451,13 @@

                                        Inherited members

                                        @classmethod def register(cls, *args, **kwargs): if cls is not RootOfHierarchy: - raise TypeError('For folder roots, custom fields must be registered on the RootOfHierarchy class') + raise TypeError("For folder roots, custom fields must be registered on the RootOfHierarchy class") return super().register(*args, **kwargs) @classmethod def deregister(cls, *args, **kwargs): if cls is not RootOfHierarchy: - raise TypeError('For folder roots, custom fields must be registered on the RootOfHierarchy class') + raise TypeError("For folder roots, custom fields must be registered on the RootOfHierarchy class") return super().deregister(*args, **kwargs) def get_folder(self, folder): @@ -11097,14 +11501,13 @@

                                        Inherited members

                                        :param account: """ if not cls.DISTINGUISHED_FOLDER_ID: - raise ValueError(f'Class {cls} must have a DISTINGUISHED_FOLDER_ID value') + raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: return cls.resolve( - account=account, - folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + account=account, folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}') + raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") def get_default_folder(self, folder_cls): """Return the distinguished folder instance of type folder_cls belonging to this account. If no distinguished @@ -11118,21 +11521,21 @@

                                        Inherited members

                                        for f in self._folders_map.values(): # Require exact class, to not match subclasses, e.g. RecipientCache instead of Contacts if f.__class__ == folder_cls and f.is_distinguished: - log.debug('Found cached distinguished %s folder', folder_cls) + log.debug("Found cached distinguished %s folder", folder_cls) return f try: - log.debug('Requesting distinguished %s folder explicitly', folder_cls) + log.debug("Requesting distinguished %s folder explicitly", folder_cls) return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItems instead - log.debug('Testing default %s folder with FindItem', folder_cls) + log.debug("Testing default %s folder with FindItem", folder_cls) fld = folder_cls(root=self, name=folder_cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: # The Exchange server does not return a distinguished folder of this type pass - raise ErrorFolderNotFound(f'No usable default {folder_cls} folders') + raise ErrorFolderNotFound(f"No usable default {folder_cls} folders") @property def _folders_map(self): @@ -11163,9 +11566,9 @@

                                        Inherited members

                                        if isinstance(f, Exception): raise f folders_map[f.id] = f - for f in SingleFolderQuerySet(account=self.account, folder=self).depth( - self.DEFAULT_FOLDER_TRAVERSAL_DEPTH - ).all(): + for f in ( + SingleFolderQuerySet(account=self.account, folder=self).depth(self.DEFAULT_FOLDER_TRAVERSAL_DEPTH).all() + ): if isinstance(f, ErrorAccessDenied): # We may not have FindFolder access, or GetFolder access, either to this folder or at all continue @@ -11201,9 +11604,19 @@

                                        Inherited members

                                        def __repr__(self): # Let's not create an infinite loop when printing self.root - return self.__class__.__name__ + \ - repr((self.account, '[self]', self.name, self.total_count, self.unread_count, self.child_folder_count, - self.folder_class, self.id, self.changekey)) + return self.__class__.__name__ + repr( + ( + self.account, + "[self]", + self.name, + self.total_count, + self.unread_count, + self.child_folder_count, + self.folder_class, + self.id, + self.changekey, + ) + )

                                        Ancestors

                                          @@ -11289,14 +11702,13 @@

                                          Static methods

                                          :param account: """ if not cls.DISTINGUISHED_FOLDER_ID: - raise ValueError(f'Class {cls} must have a DISTINGUISHED_FOLDER_ID value') + raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: return cls.resolve( - account=account, - folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + account=account, folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) ) except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f'Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}') + raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") @@ -11377,21 +11789,21 @@

                                          Methods

                                          for f in self._folders_map.values(): # Require exact class, to not match subclasses, e.g. RecipientCache instead of Contacts if f.__class__ == folder_cls and f.is_distinguished: - log.debug('Found cached distinguished %s folder', folder_cls) + log.debug("Found cached distinguished %s folder", folder_cls) return f try: - log.debug('Requesting distinguished %s folder explicitly', folder_cls) + log.debug("Requesting distinguished %s folder explicitly", folder_cls) return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItems instead - log.debug('Testing default %s folder with FindItem', folder_cls) + log.debug("Testing default %s folder with FindItem", folder_cls) fld = folder_cls(root=self, name=folder_cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: # The Exchange server does not return a distinguished folder of this type pass - raise ErrorFolderNotFound(f'No usable default {folder_cls} folders') + raise ErrorFolderNotFound(f"No usable default {folder_cls} folders")
                                          @@ -11497,7 +11909,7 @@

                                          Inherited members

                                          cert_file = None def init_poolmanager(self, *args, **kwargs): - kwargs['cert_file'] = self.cert_file + kwargs["cert_file"] = self.cert_file return super().init_poolmanager(*args, **kwargs)

                                          Ancestors

                                          @@ -11531,7 +11943,7 @@

                                          Methods

                                          Expand source code
                                          def init_poolmanager(self, *args, **kwargs):
                                          -    kwargs['cert_file'] = self.cert_file
                                          +    kwargs["cert_file"] = self.cert_file
                                               return super().init_poolmanager(*args, **kwargs)
                                          @@ -11555,49 +11967,78 @@

                                          Methods

                                          class Task(Item):
                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/task"""
                                           
                                          -    ELEMENT_NAME = 'Task'
                                          -    NOT_STARTED = 'NotStarted'
                                          -    COMPLETED = 'Completed'
                                          +    ELEMENT_NAME = "Task"
                                          +    NOT_STARTED = "NotStarted"
                                          +    COMPLETED = "Completed"
                                           
                                          -    actual_work = IntegerField(field_uri='task:ActualWork', min=0)
                                          -    assigned_time = DateTimeField(field_uri='task:AssignedTime', is_read_only=True)
                                          -    billing_information = TextField(field_uri='task:BillingInformation')
                                          -    change_count = IntegerField(field_uri='task:ChangeCount', is_read_only=True, min=0)
                                          -    companies = TextListField(field_uri='task:Companies')
                                          +    actual_work = IntegerField(field_uri="task:ActualWork", min=0)
                                          +    assigned_time = DateTimeField(field_uri="task:AssignedTime", is_read_only=True)
                                          +    billing_information = TextField(field_uri="task:BillingInformation")
                                          +    change_count = IntegerField(field_uri="task:ChangeCount", is_read_only=True, min=0)
                                          +    companies = TextListField(field_uri="task:Companies")
                                               # 'complete_date' can be set, but is ignored by the server, which sets it to now()
                                          -    complete_date = DateTimeField(field_uri='task:CompleteDate', is_read_only=True)
                                          -    contacts = TextListField(field_uri='task:Contacts')
                                          -    delegation_state = ChoiceField(field_uri='task:DelegationState', choices={
                                          -        Choice('NoMatch'), Choice('OwnNew'), Choice('Owned'), Choice('Accepted'), Choice('Declined'), Choice('Max')
                                          -    }, is_read_only=True)
                                          -    delegator = CharField(field_uri='task:Delegator', is_read_only=True)
                                          -    due_date = DateTimeBackedDateField(field_uri='task:DueDate')
                                          -    is_editable = BooleanField(field_uri='task:IsAssignmentEditable', is_read_only=True)
                                          -    is_complete = BooleanField(field_uri='task:IsComplete', is_read_only=True)
                                          -    is_recurring = BooleanField(field_uri='task:IsRecurring', is_read_only=True)
                                          -    is_team_task = BooleanField(field_uri='task:IsTeamTask', is_read_only=True)
                                          -    mileage = TextField(field_uri='task:Mileage')
                                          -    owner = CharField(field_uri='task:Owner', is_read_only=True)
                                          -    percent_complete = DecimalField(field_uri='task:PercentComplete', is_required=True, default=Decimal(0.0),
                                          -                                    min=Decimal(0), max=Decimal(100), is_searchable=False)
                                          -    recurrence = TaskRecurrenceField(field_uri='task:Recurrence', is_searchable=False)
                                          -    start_date = DateTimeBackedDateField(field_uri='task:StartDate')
                                          -    status = ChoiceField(field_uri='task:Status', choices={
                                          -        Choice(NOT_STARTED), Choice('InProgress'), Choice(COMPLETED), Choice('WaitingOnOthers'), Choice('Deferred')
                                          -    }, is_required=True, is_searchable=False, default=NOT_STARTED)
                                          -    status_description = CharField(field_uri='task:StatusDescription', is_read_only=True)
                                          -    total_work = IntegerField(field_uri='task:TotalWork', min=0)
                                          +    complete_date = DateTimeField(field_uri="task:CompleteDate", is_read_only=True)
                                          +    contacts = TextListField(field_uri="task:Contacts")
                                          +    delegation_state = ChoiceField(
                                          +        field_uri="task:DelegationState",
                                          +        choices={
                                          +            Choice("NoMatch"),
                                          +            Choice("OwnNew"),
                                          +            Choice("Owned"),
                                          +            Choice("Accepted"),
                                          +            Choice("Declined"),
                                          +            Choice("Max"),
                                          +        },
                                          +        is_read_only=True,
                                          +    )
                                          +    delegator = CharField(field_uri="task:Delegator", is_read_only=True)
                                          +    due_date = DateTimeBackedDateField(field_uri="task:DueDate")
                                          +    is_editable = BooleanField(field_uri="task:IsAssignmentEditable", is_read_only=True)
                                          +    is_complete = BooleanField(field_uri="task:IsComplete", is_read_only=True)
                                          +    is_recurring = BooleanField(field_uri="task:IsRecurring", is_read_only=True)
                                          +    is_team_task = BooleanField(field_uri="task:IsTeamTask", is_read_only=True)
                                          +    mileage = TextField(field_uri="task:Mileage")
                                          +    owner = CharField(field_uri="task:Owner", is_read_only=True)
                                          +    percent_complete = DecimalField(
                                          +        field_uri="task:PercentComplete",
                                          +        is_required=True,
                                          +        default=Decimal(0.0),
                                          +        min=Decimal(0),
                                          +        max=Decimal(100),
                                          +        is_searchable=False,
                                          +    )
                                          +    recurrence = TaskRecurrenceField(field_uri="task:Recurrence", is_searchable=False)
                                          +    start_date = DateTimeBackedDateField(field_uri="task:StartDate")
                                          +    status = ChoiceField(
                                          +        field_uri="task:Status",
                                          +        choices={
                                          +            Choice(NOT_STARTED),
                                          +            Choice("InProgress"),
                                          +            Choice(COMPLETED),
                                          +            Choice("WaitingOnOthers"),
                                          +            Choice("Deferred"),
                                          +        },
                                          +        is_required=True,
                                          +        is_searchable=False,
                                          +        default=NOT_STARTED,
                                          +    )
                                          +    status_description = CharField(field_uri="task:StatusDescription", is_read_only=True)
                                          +    total_work = IntegerField(field_uri="task:TotalWork", min=0)
                                           
                                               def clean(self, version=None):
                                                   super().clean(version=version)
                                                   if self.due_date and self.start_date and self.due_date < self.start_date:
                                          -            log.warning("'due_date' must be greater than 'start_date' (%s vs %s). Resetting 'due_date'",
                                          -                        self.due_date, self.start_date)
                                          +            log.warning(
                                          +                "'due_date' must be greater than 'start_date' (%s vs %s). Resetting 'due_date'",
                                          +                self.due_date,
                                          +                self.start_date,
                                          +            )
                                                       self.due_date = self.start_date
                                                   if self.complete_date:
                                                       if self.status != self.COMPLETED:
                                          -                log.warning("'status' must be '%s' when 'complete_date' is set (%s). Resetting",
                                          -                            self.COMPLETED, self.status)
                                          +                log.warning(
                                          +                    "'status' must be '%s' when 'complete_date' is set (%s). Resetting", self.COMPLETED, self.status
                                          +                )
                                                           self.status = self.COMPLETED
                                                       now = datetime.datetime.now(tz=UTC)
                                                       if (self.complete_date - now).total_seconds() > 120:
                                          @@ -11606,19 +12047,28 @@ 

                                          Methods

                                          log.warning("'complete_date' must be in the past (%s vs %s). Resetting", self.complete_date, now) self.complete_date = now if self.start_date and self.complete_date.date() < self.start_date: - log.warning("'complete_date' must be greater than 'start_date' (%s vs %s). Resetting", - self.complete_date, self.start_date) + log.warning( + "'complete_date' must be greater than 'start_date' (%s vs %s). Resetting", + self.complete_date, + self.start_date, + ) self.complete_date = EWSDateTime.combine(self.start_date, datetime.time(0, 0)).replace(tzinfo=UTC) if self.percent_complete is not None: if self.status == self.COMPLETED and self.percent_complete != Decimal(100): # percent_complete must be 100% if task is complete - log.warning("'percent_complete' must be 100 when 'status' is '%s' (%s). Resetting", - self.COMPLETED, self.percent_complete) + log.warning( + "'percent_complete' must be 100 when 'status' is '%s' (%s). Resetting", + self.COMPLETED, + self.percent_complete, + ) self.percent_complete = Decimal(100) elif self.status == self.NOT_STARTED and self.percent_complete != Decimal(0): # percent_complete must be 0% if task is not started - log.warning("'percent_complete' must be 0 when 'status' is '%s' (%s). Resetting", - self.NOT_STARTED, self.percent_complete) + log.warning( + "'percent_complete' must be 0 when 'status' is '%s' (%s). Resetting", + self.NOT_STARTED, + self.percent_complete, + ) self.percent_complete = Decimal(0) def complete(self): @@ -11759,13 +12209,17 @@

                                          Methods

                                          def clean(self, version=None):
                                               super().clean(version=version)
                                               if self.due_date and self.start_date and self.due_date < self.start_date:
                                          -        log.warning("'due_date' must be greater than 'start_date' (%s vs %s). Resetting 'due_date'",
                                          -                    self.due_date, self.start_date)
                                          +        log.warning(
                                          +            "'due_date' must be greater than 'start_date' (%s vs %s). Resetting 'due_date'",
                                          +            self.due_date,
                                          +            self.start_date,
                                          +        )
                                                   self.due_date = self.start_date
                                               if self.complete_date:
                                                   if self.status != self.COMPLETED:
                                          -            log.warning("'status' must be '%s' when 'complete_date' is set (%s). Resetting",
                                          -                        self.COMPLETED, self.status)
                                          +            log.warning(
                                          +                "'status' must be '%s' when 'complete_date' is set (%s). Resetting", self.COMPLETED, self.status
                                          +            )
                                                       self.status = self.COMPLETED
                                                   now = datetime.datetime.now(tz=UTC)
                                                   if (self.complete_date - now).total_seconds() > 120:
                                          @@ -11774,19 +12228,28 @@ 

                                          Methods

                                          log.warning("'complete_date' must be in the past (%s vs %s). Resetting", self.complete_date, now) self.complete_date = now if self.start_date and self.complete_date.date() < self.start_date: - log.warning("'complete_date' must be greater than 'start_date' (%s vs %s). Resetting", - self.complete_date, self.start_date) + log.warning( + "'complete_date' must be greater than 'start_date' (%s vs %s). Resetting", + self.complete_date, + self.start_date, + ) self.complete_date = EWSDateTime.combine(self.start_date, datetime.time(0, 0)).replace(tzinfo=UTC) if self.percent_complete is not None: if self.status == self.COMPLETED and self.percent_complete != Decimal(100): # percent_complete must be 100% if task is complete - log.warning("'percent_complete' must be 100 when 'status' is '%s' (%s). Resetting", - self.COMPLETED, self.percent_complete) + log.warning( + "'percent_complete' must be 100 when 'status' is '%s' (%s). Resetting", + self.COMPLETED, + self.percent_complete, + ) self.percent_complete = Decimal(100) elif self.status == self.NOT_STARTED and self.percent_complete != Decimal(0): # percent_complete must be 0% if task is not started - log.warning("'percent_complete' must be 0 when 'status' is '%s' (%s). Resetting", - self.NOT_STARTED, self.percent_complete) + log.warning( + "'percent_complete' must be 0 when 'status' is '%s' (%s). Resetting", + self.NOT_STARTED, + self.percent_complete, + ) self.percent_complete = Decimal(0)
                                          @@ -11844,7 +12307,7 @@

                                          Inherited members

                                          class TentativelyAcceptItem(BaseMeetingReplyItem):
                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/tentativelyacceptitem"""
                                           
                                          -    ELEMENT_NAME = 'TentativelyAcceptItem'
                                          + ELEMENT_NAME = "TentativelyAcceptItem"

                                          Ancestors

                                            @@ -11906,22 +12369,15 @@

                                            Inherited members

                                            account.calendar.filter(global_object_id=UID('261cbc18-1f65-5a0a-bd11-23b1e224cc2f')) """ - _HEADER = binascii.hexlify(bytearray(( - 0x04, 0x00, 0x00, 0x00, - 0x82, 0x00, 0xE0, 0x00, - 0x74, 0xC5, 0xB7, 0x10, - 0x1A, 0x82, 0xE0, 0x08))) + _HEADER = binascii.hexlify( + bytearray((0x04, 0x00, 0x00, 0x00, 0x82, 0x00, 0xE0, 0x00, 0x74, 0xC5, 0xB7, 0x10, 0x1A, 0x82, 0xE0, 0x08)) + ) - _EXCEPTION_REPLACEMENT_TIME = binascii.hexlify(bytearray(( - 0, 0, 0, 0))) + _EXCEPTION_REPLACEMENT_TIME = binascii.hexlify(bytearray((0, 0, 0, 0))) - _CREATION_TIME = binascii.hexlify(bytearray(( - 0, 0, 0, 0, - 0, 0, 0, 0))) + _CREATION_TIME = binascii.hexlify(bytearray((0, 0, 0, 0, 0, 0, 0, 0))) - _RESERVED = binascii.hexlify(bytearray(( - 0, 0, 0, 0, - 0, 0, 0, 0))) + _RESERVED = binascii.hexlify(bytearray((0, 0, 0, 0, 0, 0, 0, 0))) # https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxocal/1d3aac05-a7b9-45cc-a213-47f0a0a2c5c1 # https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-asemail/e7424ddc-dd10-431e-a0b7-5c794863370e @@ -11929,12 +12385,12 @@

                                            Inherited members

                                            # https://stackoverflow.com/questions/33757805 def __new__(cls, uid): - payload = binascii.hexlify(bytearray(f'vCal-Uid\x01\x00\x00\x00{uid}\x00'.encode('ascii'))) - length = binascii.hexlify(bytearray(struct.pack('<I', int(len(payload)/2)))) - encoding = b''.join([ - cls._HEADER, cls._EXCEPTION_REPLACEMENT_TIME, cls._CREATION_TIME, cls._RESERVED, length, payload - ]) - return super().__new__(cls, codecs.decode(encoding, 'hex')) + payload = binascii.hexlify(bytearray(f"vCal-Uid\x01\x00\x00\x00{uid}\x00".encode("ascii"))) + length = binascii.hexlify(bytearray(struct.pack("<I", int(len(payload) / 2)))) + encoding = b"".join( + [cls._HEADER, cls._EXCEPTION_REPLACEMENT_TIME, cls._CREATION_TIME, cls._RESERVED, length, payload] + ) + return super().__new__(cls, codecs.decode(encoding, "hex")) @classmethod def to_global_object_id(cls, uid): @@ -11977,18 +12433,18 @@

                                            Static methods

                                            class Version:
                                                 """Holds information about the server version."""
                                             
                                            -    __slots__ = 'build', 'api_version'
                                            +    __slots__ = "build", "api_version"
                                             
                                                 def __init__(self, build, api_version=None):
                                                     if api_version is None:
                                                         if not isinstance(build, Build):
                                            -                raise InvalidTypeError('build', build, Build)
                                            +                raise InvalidTypeError("build", build, Build)
                                                         self.api_version = build.api_version()
                                                     else:
                                                         if not isinstance(build, (Build, type(None))):
                                            -                raise InvalidTypeError('build', build, Build)
                                            +                raise InvalidTypeError("build", build, Build)
                                                         if not isinstance(api_version, str):
                                            -                raise InvalidTypeError('api_version', api_version, str)
                                            +                raise InvalidTypeError("api_version", api_version, str)
                                                         self.api_version = api_version
                                                     self.build = build
                                             
                                            @@ -12009,53 +12465,62 @@ 

                                            Static methods

                                            :param api_version_hint: (Default value = None) """ from .services import ResolveNames + # The protocol doesn't have a version yet, so default to latest supported version if we don't have a hint. api_version = api_version_hint or API_VERSIONS[0] - log.debug('Asking server for version info using API version %s', api_version) + log.debug("Asking server for version info using API version %s", api_version) # We don't know the build version yet. Hopefully, the server will report it in the SOAP header. Lots of # places expect a version to have a build, so this is a bit dangerous, but passing a fake build around is also # dangerous. Make sure the call to ResolveNames does not require a version build. protocol.config.version = Version(build=None, api_version=api_version) # Use ResolveNames as a minimal request to the server to test if the version is correct. If not, ResolveNames # will try to guess the version automatically. - name = str(protocol.credentials) if protocol.credentials and str(protocol.credentials) else 'DUMMY' + name = str(protocol.credentials) if protocol.credentials and str(protocol.credentials) else "DUMMY" try: list(ResolveNames(protocol=protocol).call(unresolved_entries=[name])) except ResponseMessageError as e: # We may have survived long enough to get a new version if not protocol.config.version.build: - raise TransportError(f'No valid version headers found in response ({e!r})') + raise TransportError(f"No valid version headers found in response ({e!r})") if not protocol.config.version.build: - raise TransportError('No valid version headers found in response') + raise TransportError("No valid version headers found in response") return protocol.config.version @staticmethod def _is_invalid_version_string(version): # Check if a version string is bogus, e.g. V2_, V2015_ or V2018_ - return re.match(r'V[0-9]{1,4}_.*', version) + return re.match(r"V[0-9]{1,4}_.*", version) @classmethod def from_soap_header(cls, requested_api_version, header): - info = header.find(f'{{{TNS}}}ServerVersionInfo') + info = header.find(f"{{{TNS}}}ServerVersionInfo") if info is None: - raise TransportError(f'No ServerVersionInfo in header: {xml_to_str(header)!r}') + raise TransportError(f"No ServerVersionInfo in header: {xml_to_str(header)!r}") try: build = Build.from_xml(elem=info) except ValueError: - raise TransportError(f'Bad ServerVersionInfo in response: {xml_to_str(header)!r}') + raise TransportError(f"Bad ServerVersionInfo in response: {xml_to_str(header)!r}") # Not all Exchange servers send the Version element - api_version_from_server = info.get('Version') or build.api_version() + api_version_from_server = info.get("Version") or build.api_version() if api_version_from_server != requested_api_version: if cls._is_invalid_version_string(api_version_from_server): # For unknown reasons, Office 365 may respond with an API version strings that is invalid in a request. # Detect these so we can fallback to a valid version string. - log.debug('API version "%s" worked but server reports version "%s". Using "%s"', requested_api_version, - api_version_from_server, requested_api_version) + log.debug( + 'API version "%s" worked but server reports version "%s". Using "%s"', + requested_api_version, + api_version_from_server, + requested_api_version, + ) api_version_from_server = requested_api_version else: # Trust API version from server response - log.debug('API version "%s" worked but server reports version "%s". Using "%s"', requested_api_version, - api_version_from_server, api_version_from_server) + log.debug( + 'API version "%s" worked but server reports version "%s". Using "%s"', + requested_api_version, + api_version_from_server, + api_version_from_server, + ) return cls(build=build, api_version=api_version_from_server) def copy(self): @@ -12074,7 +12539,7 @@

                                            Static methods

                                            return self.__class__.__name__ + repr((self.build, self.api_version)) def __str__(self): - return f'Build={self.build}, API={self.api_version}, Fullname={self.fullname}'
                                            + return f"Build={self.build}, API={self.api_version}, Fullname={self.fullname}"

                                            Static methods

                                            @@ -12089,26 +12554,34 @@

                                            Static methods

                                            @classmethod
                                             def from_soap_header(cls, requested_api_version, header):
                                            -    info = header.find(f'{{{TNS}}}ServerVersionInfo')
                                            +    info = header.find(f"{{{TNS}}}ServerVersionInfo")
                                                 if info is None:
                                            -        raise TransportError(f'No ServerVersionInfo in header: {xml_to_str(header)!r}')
                                            +        raise TransportError(f"No ServerVersionInfo in header: {xml_to_str(header)!r}")
                                                 try:
                                                     build = Build.from_xml(elem=info)
                                                 except ValueError:
                                            -        raise TransportError(f'Bad ServerVersionInfo in response: {xml_to_str(header)!r}')
                                            +        raise TransportError(f"Bad ServerVersionInfo in response: {xml_to_str(header)!r}")
                                                 # Not all Exchange servers send the Version element
                                            -    api_version_from_server = info.get('Version') or build.api_version()
                                            +    api_version_from_server = info.get("Version") or build.api_version()
                                                 if api_version_from_server != requested_api_version:
                                                     if cls._is_invalid_version_string(api_version_from_server):
                                                         # For unknown reasons, Office 365 may respond with an API version strings that is invalid in a request.
                                                         # Detect these so we can fallback to a valid version string.
                                            -            log.debug('API version "%s" worked but server reports version "%s". Using "%s"', requested_api_version,
                                            -                      api_version_from_server, requested_api_version)
                                            +            log.debug(
                                            +                'API version "%s" worked but server reports version "%s". Using "%s"',
                                            +                requested_api_version,
                                            +                api_version_from_server,
                                            +                requested_api_version,
                                            +            )
                                                         api_version_from_server = requested_api_version
                                                     else:
                                                         # Trust API version from server response
                                            -            log.debug('API version "%s" worked but server reports version "%s". Using "%s"', requested_api_version,
                                            -                      api_version_from_server, api_version_from_server)
                                            +            log.debug(
                                            +                'API version "%s" worked but server reports version "%s". Using "%s"',
                                            +                requested_api_version,
                                            +                api_version_from_server,
                                            +                api_version_from_server,
                                            +            )
                                                 return cls(build=build, api_version=api_version_from_server)
                                            @@ -12141,24 +12614,25 @@

                                            Static methods

                                            :param api_version_hint: (Default value = None) """ from .services import ResolveNames + # The protocol doesn't have a version yet, so default to latest supported version if we don't have a hint. api_version = api_version_hint or API_VERSIONS[0] - log.debug('Asking server for version info using API version %s', api_version) + log.debug("Asking server for version info using API version %s", api_version) # We don't know the build version yet. Hopefully, the server will report it in the SOAP header. Lots of # places expect a version to have a build, so this is a bit dangerous, but passing a fake build around is also # dangerous. Make sure the call to ResolveNames does not require a version build. protocol.config.version = Version(build=None, api_version=api_version) # Use ResolveNames as a minimal request to the server to test if the version is correct. If not, ResolveNames # will try to guess the version automatically. - name = str(protocol.credentials) if protocol.credentials and str(protocol.credentials) else 'DUMMY' + name = str(protocol.credentials) if protocol.credentials and str(protocol.credentials) else "DUMMY" try: list(ResolveNames(protocol=protocol).call(unresolved_entries=[name])) except ResponseMessageError as e: # We may have survived long enough to get a new version if not protocol.config.version.build: - raise TransportError(f'No valid version headers found in response ({e!r})') + raise TransportError(f"No valid version headers found in response ({e!r})") if not protocol.config.version.build: - raise TransportError('No valid version headers found in response') + raise TransportError("No valid version headers found in response") return protocol.config.version diff --git a/docs/exchangelib/indexed_properties.html b/docs/exchangelib/indexed_properties.html index 1cdf12c6..797430b5 100644 --- a/docs/exchangelib/indexed_properties.html +++ b/docs/exchangelib/indexed_properties.html @@ -28,7 +28,7 @@

                                            Module exchangelib.indexed_properties

                                            import logging
                                             
                                            -from .fields import EmailSubField, LabelField, SubField, NamedSubField, Choice
                                            +from .fields import Choice, EmailSubField, LabelField, NamedSubField, SubField
                                             from .properties import EWSElement, EWSMeta
                                             
                                             log = logging.getLogger(__name__)
                                            @@ -47,31 +47,47 @@ 

                                            Module exchangelib.indexed_properties

                                            def value_field(cls, version): fields = cls.supported_fields(version=version) if len(fields) != 1: - raise ValueError(f'Class {cls} must have only one value field (found {tuple(f.name for f in fields)})') + raise ValueError(f"Class {cls} must have only one value field (found {tuple(f.name for f in fields)})") return fields[0] class EmailAddress(SingleFieldIndexedElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/entry-emailaddress""" - ELEMENT_NAME = 'Entry' - LABEL_CHOICES = ('EmailAddress1', 'EmailAddress2', 'EmailAddress3') + ELEMENT_NAME = "Entry" + LABEL_CHOICES = ("EmailAddress1", "EmailAddress2", "EmailAddress3") - label = LabelField(field_uri='Key', choices={Choice(c) for c in LABEL_CHOICES}, default=LABEL_CHOICES[0]) + label = LabelField(field_uri="Key", choices={Choice(c) for c in LABEL_CHOICES}, default=LABEL_CHOICES[0]) email = EmailSubField(is_required=True) class PhoneNumber(SingleFieldIndexedElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/entry-phonenumber""" - ELEMENT_NAME = 'Entry' + ELEMENT_NAME = "Entry" LABEL_CHOICES = ( - 'AssistantPhone', 'BusinessFax', 'BusinessPhone', 'BusinessPhone2', 'Callback', 'CarPhone', 'CompanyMainPhone', - 'HomeFax', 'HomePhone', 'HomePhone2', 'Isdn', 'MobilePhone', 'OtherFax', 'OtherTelephone', 'Pager', - 'PrimaryPhone', 'RadioPhone', 'Telex', 'TtyTddPhone' + "AssistantPhone", + "BusinessFax", + "BusinessPhone", + "BusinessPhone2", + "Callback", + "CarPhone", + "CompanyMainPhone", + "HomeFax", + "HomePhone", + "HomePhone2", + "Isdn", + "MobilePhone", + "OtherFax", + "OtherTelephone", + "Pager", + "PrimaryPhone", + "RadioPhone", + "Telex", + "TtyTddPhone", ) - label = LabelField(field_uri='Key', choices={Choice(c) for c in LABEL_CHOICES}, default='PrimaryPhone') + label = LabelField(field_uri="Key", choices={Choice(c) for c in LABEL_CHOICES}, default="PrimaryPhone") phone_number = SubField(is_required=True) @@ -82,15 +98,15 @@

                                            Module exchangelib.indexed_properties

                                            class PhysicalAddress(MultiFieldIndexedElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/entry-physicaladdress""" - ELEMENT_NAME = 'Entry' - LABEL_CHOICES = ('Business', 'Home', 'Other') + ELEMENT_NAME = "Entry" + LABEL_CHOICES = ("Business", "Home", "Other") - label = LabelField(field_uri='Key', choices={Choice(c) for c in LABEL_CHOICES}, default=LABEL_CHOICES[0]) - street = NamedSubField(field_uri='Street') # Street, house number, etc. - city = NamedSubField(field_uri='City') - state = NamedSubField(field_uri='State') - country = NamedSubField(field_uri='CountryOrRegion') - zipcode = NamedSubField(field_uri='PostalCode') + label = LabelField(field_uri="Key", choices={Choice(c) for c in LABEL_CHOICES}, default=LABEL_CHOICES[0]) + street = NamedSubField(field_uri="Street") # Street, house number, etc. + city = NamedSubField(field_uri="City") + state = NamedSubField(field_uri="State") + country = NamedSubField(field_uri="CountryOrRegion") + zipcode = NamedSubField(field_uri="PostalCode") def clean(self, version=None): if isinstance(self.zipcode, int): @@ -120,10 +136,10 @@

                                            Classes

                                            class EmailAddress(SingleFieldIndexedElement):
                                                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/entry-emailaddress"""
                                             
                                            -    ELEMENT_NAME = 'Entry'
                                            -    LABEL_CHOICES = ('EmailAddress1', 'EmailAddress2', 'EmailAddress3')
                                            +    ELEMENT_NAME = "Entry"
                                            +    LABEL_CHOICES = ("EmailAddress1", "EmailAddress2", "EmailAddress3")
                                             
                                            -    label = LabelField(field_uri='Key', choices={Choice(c) for c in LABEL_CHOICES}, default=LABEL_CHOICES[0])
                                            +    label = LabelField(field_uri="Key", choices={Choice(c) for c in LABEL_CHOICES}, default=LABEL_CHOICES[0])
                                                 email = EmailSubField(is_required=True)

                                            Ancestors

                                            @@ -260,14 +276,30 @@

                                            Inherited members

                                            class PhoneNumber(SingleFieldIndexedElement):
                                                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/entry-phonenumber"""
                                             
                                            -    ELEMENT_NAME = 'Entry'
                                            +    ELEMENT_NAME = "Entry"
                                                 LABEL_CHOICES = (
                                            -        'AssistantPhone', 'BusinessFax', 'BusinessPhone', 'BusinessPhone2', 'Callback', 'CarPhone', 'CompanyMainPhone',
                                            -        'HomeFax', 'HomePhone', 'HomePhone2', 'Isdn', 'MobilePhone', 'OtherFax', 'OtherTelephone', 'Pager',
                                            -        'PrimaryPhone', 'RadioPhone', 'Telex', 'TtyTddPhone'
                                            +        "AssistantPhone",
                                            +        "BusinessFax",
                                            +        "BusinessPhone",
                                            +        "BusinessPhone2",
                                            +        "Callback",
                                            +        "CarPhone",
                                            +        "CompanyMainPhone",
                                            +        "HomeFax",
                                            +        "HomePhone",
                                            +        "HomePhone2",
                                            +        "Isdn",
                                            +        "MobilePhone",
                                            +        "OtherFax",
                                            +        "OtherTelephone",
                                            +        "Pager",
                                            +        "PrimaryPhone",
                                            +        "RadioPhone",
                                            +        "Telex",
                                            +        "TtyTddPhone",
                                                 )
                                             
                                            -    label = LabelField(field_uri='Key', choices={Choice(c) for c in LABEL_CHOICES}, default='PrimaryPhone')
                                            +    label = LabelField(field_uri="Key", choices={Choice(c) for c in LABEL_CHOICES}, default="PrimaryPhone")
                                                 phone_number = SubField(is_required=True)

                                            Ancestors

                                            @@ -327,15 +359,15 @@

                                            Inherited members

                                            class PhysicalAddress(MultiFieldIndexedElement):
                                                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/entry-physicaladdress"""
                                             
                                            -    ELEMENT_NAME = 'Entry'
                                            -    LABEL_CHOICES = ('Business', 'Home', 'Other')
                                            +    ELEMENT_NAME = "Entry"
                                            +    LABEL_CHOICES = ("Business", "Home", "Other")
                                             
                                            -    label = LabelField(field_uri='Key', choices={Choice(c) for c in LABEL_CHOICES}, default=LABEL_CHOICES[0])
                                            -    street = NamedSubField(field_uri='Street')  # Street, house number, etc.
                                            -    city = NamedSubField(field_uri='City')
                                            -    state = NamedSubField(field_uri='State')
                                            -    country = NamedSubField(field_uri='CountryOrRegion')
                                            -    zipcode = NamedSubField(field_uri='PostalCode')
                                            +    label = LabelField(field_uri="Key", choices={Choice(c) for c in LABEL_CHOICES}, default=LABEL_CHOICES[0])
                                            +    street = NamedSubField(field_uri="Street")  # Street, house number, etc.
                                            +    city = NamedSubField(field_uri="City")
                                            +    state = NamedSubField(field_uri="State")
                                            +    country = NamedSubField(field_uri="CountryOrRegion")
                                            +    zipcode = NamedSubField(field_uri="PostalCode")
                                             
                                                 def clean(self, version=None):
                                                     if isinstance(self.zipcode, int):
                                            @@ -437,7 +469,7 @@ 

                                            Inherited members

                                            def value_field(cls, version): fields = cls.supported_fields(version=version) if len(fields) != 1: - raise ValueError(f'Class {cls} must have only one value field (found {tuple(f.name for f in fields)})') + raise ValueError(f"Class {cls} must have only one value field (found {tuple(f.name for f in fields)})") return fields[0]

                                            Ancestors

                                            @@ -465,7 +497,7 @@

                                            Static methods

                                            def value_field(cls, version): fields = cls.supported_fields(version=version) if len(fields) != 1: - raise ValueError(f'Class {cls} must have only one value field (found {tuple(f.name for f in fields)})') + raise ValueError(f"Class {cls} must have only one value field (found {tuple(f.name for f in fields)})") return fields[0]
                                            diff --git a/docs/exchangelib/items/base.html b/docs/exchangelib/items/base.html index 24bd2be7..8d516d51 100644 --- a/docs/exchangelib/items/base.html +++ b/docs/exchangelib/items/base.html @@ -30,27 +30,37 @@

                                            Module exchangelib.items.base

                                            from ..errors import InvalidTypeError from ..extended_properties import ExtendedProperty -from ..fields import BooleanField, ExtendedPropertyField, BodyField, MailboxField, MailboxListField, EWSElementField, \ - CharField, IdElementField, AttachmentField, ExtendedPropertyListField -from ..properties import InvalidField, IdChangeKeyMixIn, EWSElement, ReferenceItemId, ItemId, EWSMeta +from ..fields import ( + AttachmentField, + BodyField, + BooleanField, + CharField, + EWSElementField, + ExtendedPropertyField, + ExtendedPropertyListField, + IdElementField, + MailboxField, + MailboxListField, +) +from ..properties import EWSElement, EWSMeta, IdChangeKeyMixIn, InvalidField, ItemId, ReferenceItemId from ..util import require_account from ..version import EXCHANGE_2007_SP1 log = logging.getLogger(__name__) # Shape enums -ID_ONLY = 'IdOnly' -DEFAULT = 'Default' +ID_ONLY = "IdOnly" +DEFAULT = "Default" # AllProperties doesn't actually get all properties in FindItem, just the "first-class" ones. See # https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/email-properties-and-elements-in-ews-in-exchange -ALL_PROPERTIES = 'AllProperties' +ALL_PROPERTIES = "AllProperties" SHAPE_CHOICES = (ID_ONLY, DEFAULT, ALL_PROPERTIES) # MessageDisposition values. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createitem -SAVE_ONLY = 'SaveOnly' -SEND_ONLY = 'SendOnly' -SEND_AND_SAVE_COPY = 'SendAndSaveCopy' +SAVE_ONLY = "SaveOnly" +SEND_ONLY = "SendOnly" +SEND_AND_SAVE_COPY = "SendAndSaveCopy" MESSAGE_DISPOSITION_CHOICES = (SAVE_ONLY, SEND_ONLY, SEND_AND_SAVE_COPY) # SendMeetingInvitations values. See @@ -59,34 +69,39 @@

                                            Module exchangelib.items.base

                                            # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem # SendMeetingCancellations values. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteitem -SEND_TO_NONE = 'SendToNone' -SEND_ONLY_TO_ALL = 'SendOnlyToAll' -SEND_ONLY_TO_CHANGED = 'SendOnlyToChanged' -SEND_TO_ALL_AND_SAVE_COPY = 'SendToAllAndSaveCopy' -SEND_TO_CHANGED_AND_SAVE_COPY = 'SendToChangedAndSaveCopy' +SEND_TO_NONE = "SendToNone" +SEND_ONLY_TO_ALL = "SendOnlyToAll" +SEND_ONLY_TO_CHANGED = "SendOnlyToChanged" +SEND_TO_ALL_AND_SAVE_COPY = "SendToAllAndSaveCopy" +SEND_TO_CHANGED_AND_SAVE_COPY = "SendToChangedAndSaveCopy" SEND_MEETING_INVITATIONS_CHOICES = (SEND_TO_NONE, SEND_ONLY_TO_ALL, SEND_TO_ALL_AND_SAVE_COPY) -SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES = (SEND_TO_NONE, SEND_ONLY_TO_ALL, SEND_ONLY_TO_CHANGED, - SEND_TO_ALL_AND_SAVE_COPY, SEND_TO_CHANGED_AND_SAVE_COPY) +SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES = ( + SEND_TO_NONE, + SEND_ONLY_TO_ALL, + SEND_ONLY_TO_CHANGED, + SEND_TO_ALL_AND_SAVE_COPY, + SEND_TO_CHANGED_AND_SAVE_COPY, +) SEND_MEETING_CANCELLATIONS_CHOICES = (SEND_TO_NONE, SEND_ONLY_TO_ALL, SEND_TO_ALL_AND_SAVE_COPY) # AffectedTaskOccurrences values. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteitem -ALL_OCCURRENCES = 'AllOccurrences' -SPECIFIED_OCCURRENCE_ONLY = 'SpecifiedOccurrenceOnly' +ALL_OCCURRENCES = "AllOccurrences" +SPECIFIED_OCCURRENCE_ONLY = "SpecifiedOccurrenceOnly" AFFECTED_TASK_OCCURRENCES_CHOICES = (ALL_OCCURRENCES, SPECIFIED_OCCURRENCE_ONLY) # ConflictResolution values. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem -NEVER_OVERWRITE = 'NeverOverwrite' -AUTO_RESOLVE = 'AutoResolve' -ALWAYS_OVERWRITE = 'AlwaysOverwrite' +NEVER_OVERWRITE = "NeverOverwrite" +AUTO_RESOLVE = "AutoResolve" +ALWAYS_OVERWRITE = "AlwaysOverwrite" CONFLICT_RESOLUTION_CHOICES = (NEVER_OVERWRITE, AUTO_RESOLVE, ALWAYS_OVERWRITE) # DeleteType values. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteitem -HARD_DELETE = 'HardDelete' -SOFT_DELETE = 'SoftDelete' -MOVE_TO_DELETED_ITEMS = 'MoveToDeletedItems' +HARD_DELETE = "HardDelete" +SOFT_DELETE = "SoftDelete" +MOVE_TO_DELETED_ITEMS = "MoveToDeletedItems" DELETE_TYPE_CHOICES = (HARD_DELETE, SOFT_DELETE, MOVE_TO_DELETED_ITEMS) @@ -94,7 +109,7 @@

                                            Module exchangelib.items.base

                                            """Base class for classes that can change their list of supported fields dynamically.""" # This class implements dynamic fields on an element class, so we need to include __dict__ in __slots__ - __slots__ = '__dict__', + __slots__ = ("__dict__",) INSERT_AFTER_FIELD = None @@ -107,7 +122,7 @@

                                            Module exchangelib.items.base

                                            :return: """ if not cls.INSERT_AFTER_FIELD: - raise ValueError(f'Class {cls} is missing INSERT_AFTER_FIELD value') + raise ValueError(f"Class {cls} is missing INSERT_AFTER_FIELD value") try: cls.get_field_by_fieldname(attr_name) except InvalidField: @@ -148,9 +163,9 @@

                                            Module exchangelib.items.base

                                            """Base class for all other classes that implement EWS items.""" ID_ELEMENT_CLS = ItemId - _id = IdElementField(field_uri='item:ItemId', value_cls=ID_ELEMENT_CLS) + _id = IdElementField(field_uri="item:ItemId", value_cls=ID_ELEMENT_CLS) - __slots__ = 'account', 'folder' + __slots__ = "account", "folder" def __init__(self, **kwargs): """Pick out optional 'account' and 'folder' kwargs, and pass the rest to the parent class. @@ -160,15 +175,16 @@

                                            Module exchangelib.items.base

                                            'folder' is optional but allows calling 'save()'. If 'folder' has an account, and 'account' is not set, we use folder.account. """ - from ..folders import BaseFolder from ..account import Account - self.account = kwargs.pop('account', None) + from ..folders import BaseFolder + + self.account = kwargs.pop("account", None) if self.account is not None and not isinstance(self.account, Account): - raise InvalidTypeError('account', self.account, Account) - self.folder = kwargs.pop('folder', None) + raise InvalidTypeError("account", self.account, Account) + self.folder = kwargs.pop("folder", None) if self.folder is not None: if not isinstance(self.folder, BaseFolder): - raise InvalidTypeError('folder', self.folder, BaseFolder) + raise InvalidTypeError("folder", self.folder, BaseFolder) if self.folder.account is not None: if self.account is not None: # Make sure the account from kwargs matches the folder account @@ -187,32 +203,34 @@

                                            Module exchangelib.items.base

                                            class BaseReplyItem(EWSElement, metaclass=EWSMeta): """Base class for reply/forward elements that share the same fields.""" - subject = CharField(field_uri='Subject') - body = BodyField(field_uri='Body') # Accepts and returns Body or HTMLBody instances - to_recipients = MailboxListField(field_uri='ToRecipients') - cc_recipients = MailboxListField(field_uri='CcRecipients') - bcc_recipients = MailboxListField(field_uri='BccRecipients') - is_read_receipt_requested = BooleanField(field_uri='IsReadReceiptRequested') - is_delivery_receipt_requested = BooleanField(field_uri='IsDeliveryReceiptRequested') - author = MailboxField(field_uri='From') + subject = CharField(field_uri="Subject") + body = BodyField(field_uri="Body") # Accepts and returns Body or HTMLBody instances + to_recipients = MailboxListField(field_uri="ToRecipients") + cc_recipients = MailboxListField(field_uri="CcRecipients") + bcc_recipients = MailboxListField(field_uri="BccRecipients") + is_read_receipt_requested = BooleanField(field_uri="IsReadReceiptRequested") + is_delivery_receipt_requested = BooleanField(field_uri="IsDeliveryReceiptRequested") + author = MailboxField(field_uri="From") reference_item_id = EWSElementField(value_cls=ReferenceItemId) - new_body = BodyField(field_uri='NewBodyContent') # Accepts and returns Body or HTMLBody instances - received_by = MailboxField(field_uri='ReceivedBy', supported_from=EXCHANGE_2007_SP1) - received_by_representing = MailboxField(field_uri='ReceivedRepresenting', supported_from=EXCHANGE_2007_SP1) + new_body = BodyField(field_uri="NewBodyContent") # Accepts and returns Body or HTMLBody instances + received_by = MailboxField(field_uri="ReceivedBy", supported_from=EXCHANGE_2007_SP1) + received_representing = MailboxField(field_uri="ReceivedRepresenting", supported_from=EXCHANGE_2007_SP1) - __slots__ = 'account', + __slots__ = ("account",) def __init__(self, **kwargs): # 'account' is optional but allows calling 'send()' and 'save()' from ..account import Account - self.account = kwargs.pop('account', None) + + self.account = kwargs.pop("account", None) if self.account is not None and not isinstance(self.account, Account): - raise InvalidTypeError('account', self.account, Account) + raise InvalidTypeError("account", self.account, Account) super().__init__(**kwargs) @require_account def send(self, save_copy=True, copy_to_folder=None): from ..services import CreateItem + if copy_to_folder and not save_copy: raise AttributeError("'save_copy' must be True when 'copy_to_folder' is set") message_disposition = SEND_AND_SAVE_COPY if save_copy else SEND_ONLY @@ -231,6 +249,7 @@

                                            Module exchangelib.items.base

                                            :return: """ from ..services import CreateItem + return CreateItem(account=self.account).get( items=[self], folder=folder, @@ -242,7 +261,7 @@

                                            Module exchangelib.items.base

                                            class BulkCreateResult(BaseItem): """A dummy class to store return values from a CreateItem service call.""" - attachments = AttachmentField(field_uri='item:Attachments') # ItemAttachment or FileAttachment + attachments = AttachmentField(field_uri="item:Attachments") # ItemAttachment or FileAttachment def __init__(self, **kwargs): super().__init__(**kwargs) @@ -278,9 +297,9 @@

                                            Classes

                                            """Base class for all other classes that implement EWS items.""" ID_ELEMENT_CLS = ItemId - _id = IdElementField(field_uri='item:ItemId', value_cls=ID_ELEMENT_CLS) + _id = IdElementField(field_uri="item:ItemId", value_cls=ID_ELEMENT_CLS) - __slots__ = 'account', 'folder' + __slots__ = "account", "folder" def __init__(self, **kwargs): """Pick out optional 'account' and 'folder' kwargs, and pass the rest to the parent class. @@ -290,15 +309,16 @@

                                            Classes

                                            'folder' is optional but allows calling 'save()'. If 'folder' has an account, and 'account' is not set, we use folder.account. """ - from ..folders import BaseFolder from ..account import Account - self.account = kwargs.pop('account', None) + from ..folders import BaseFolder + + self.account = kwargs.pop("account", None) if self.account is not None and not isinstance(self.account, Account): - raise InvalidTypeError('account', self.account, Account) - self.folder = kwargs.pop('folder', None) + raise InvalidTypeError("account", self.account, Account) + self.folder = kwargs.pop("folder", None) if self.folder is not None: if not isinstance(self.folder, BaseFolder): - raise InvalidTypeError('folder', self.folder, BaseFolder) + raise InvalidTypeError("folder", self.folder, BaseFolder) if self.folder.account is not None: if self.account is not None: # Make sure the account from kwargs matches the folder account @@ -394,32 +414,34 @@

                                            Inherited members

                                            class BaseReplyItem(EWSElement, metaclass=EWSMeta):
                                                 """Base class for reply/forward elements that share the same fields."""
                                             
                                            -    subject = CharField(field_uri='Subject')
                                            -    body = BodyField(field_uri='Body')  # Accepts and returns Body or HTMLBody instances
                                            -    to_recipients = MailboxListField(field_uri='ToRecipients')
                                            -    cc_recipients = MailboxListField(field_uri='CcRecipients')
                                            -    bcc_recipients = MailboxListField(field_uri='BccRecipients')
                                            -    is_read_receipt_requested = BooleanField(field_uri='IsReadReceiptRequested')
                                            -    is_delivery_receipt_requested = BooleanField(field_uri='IsDeliveryReceiptRequested')
                                            -    author = MailboxField(field_uri='From')
                                            +    subject = CharField(field_uri="Subject")
                                            +    body = BodyField(field_uri="Body")  # Accepts and returns Body or HTMLBody instances
                                            +    to_recipients = MailboxListField(field_uri="ToRecipients")
                                            +    cc_recipients = MailboxListField(field_uri="CcRecipients")
                                            +    bcc_recipients = MailboxListField(field_uri="BccRecipients")
                                            +    is_read_receipt_requested = BooleanField(field_uri="IsReadReceiptRequested")
                                            +    is_delivery_receipt_requested = BooleanField(field_uri="IsDeliveryReceiptRequested")
                                            +    author = MailboxField(field_uri="From")
                                                 reference_item_id = EWSElementField(value_cls=ReferenceItemId)
                                            -    new_body = BodyField(field_uri='NewBodyContent')  # Accepts and returns Body or HTMLBody instances
                                            -    received_by = MailboxField(field_uri='ReceivedBy', supported_from=EXCHANGE_2007_SP1)
                                            -    received_by_representing = MailboxField(field_uri='ReceivedRepresenting', supported_from=EXCHANGE_2007_SP1)
                                            +    new_body = BodyField(field_uri="NewBodyContent")  # Accepts and returns Body or HTMLBody instances
                                            +    received_by = MailboxField(field_uri="ReceivedBy", supported_from=EXCHANGE_2007_SP1)
                                            +    received_representing = MailboxField(field_uri="ReceivedRepresenting", supported_from=EXCHANGE_2007_SP1)
                                             
                                            -    __slots__ = 'account',
                                            +    __slots__ = ("account",)
                                             
                                                 def __init__(self, **kwargs):
                                                     # 'account' is optional but allows calling 'send()' and 'save()'
                                                     from ..account import Account
                                            -        self.account = kwargs.pop('account', None)
                                            +
                                            +        self.account = kwargs.pop("account", None)
                                                     if self.account is not None and not isinstance(self.account, Account):
                                            -            raise InvalidTypeError('account', self.account, Account)
                                            +            raise InvalidTypeError("account", self.account, Account)
                                                     super().__init__(**kwargs)
                                             
                                                 @require_account
                                                 def send(self, save_copy=True, copy_to_folder=None):
                                                     from ..services import CreateItem
                                            +
                                                     if copy_to_folder and not save_copy:
                                                         raise AttributeError("'save_copy' must be True when 'copy_to_folder' is set")
                                                     message_disposition = SEND_AND_SAVE_COPY if save_copy else SEND_ONLY
                                            @@ -438,6 +460,7 @@ 

                                            Inherited members

                                            :return: """ from ..services import CreateItem + return CreateItem(account=self.account).get( items=[self], folder=folder, @@ -501,7 +524,7 @@

                                            Instance variables

                                            -
                                            var received_by_representing
                                            +
                                            var received_representing
                                            @@ -539,6 +562,7 @@

                                            Methods

                                            :return: """ from ..services import CreateItem + return CreateItem(account=self.account).get( items=[self], folder=folder, @@ -559,6 +583,7 @@

                                            Methods

                                            @require_account
                                             def send(self, save_copy=True, copy_to_folder=None):
                                                 from ..services import CreateItem
                                            +
                                                 if copy_to_folder and not save_copy:
                                                     raise AttributeError("'save_copy' must be True when 'copy_to_folder' is set")
                                                 message_disposition = SEND_AND_SAVE_COPY if save_copy else SEND_ONLY
                                            @@ -601,7 +626,7 @@ 

                                            Inherited members

                                            class BulkCreateResult(BaseItem):
                                                 """A dummy class to store return values from a CreateItem service call."""
                                             
                                            -    attachments = AttachmentField(field_uri='item:Attachments')  # ItemAttachment or FileAttachment
                                            +    attachments = AttachmentField(field_uri="item:Attachments")  # ItemAttachment or FileAttachment
                                             
                                                 def __init__(self, **kwargs):
                                                     super().__init__(**kwargs)
                                            @@ -660,7 +685,7 @@ 

                                            Inherited members

                                            """Base class for classes that can change their list of supported fields dynamically.""" # This class implements dynamic fields on an element class, so we need to include __dict__ in __slots__ - __slots__ = '__dict__', + __slots__ = ("__dict__",) INSERT_AFTER_FIELD = None @@ -673,7 +698,7 @@

                                            Inherited members

                                            :return: """ if not cls.INSERT_AFTER_FIELD: - raise ValueError(f'Class {cls} is missing INSERT_AFTER_FIELD value') + raise ValueError(f"Class {cls} is missing INSERT_AFTER_FIELD value") try: cls.get_field_by_fieldname(attr_name) except InvalidField: @@ -776,7 +801,7 @@

                                            Static methods

                                            :return: """ if not cls.INSERT_AFTER_FIELD: - raise ValueError(f'Class {cls} is missing INSERT_AFTER_FIELD value') + raise ValueError(f"Class {cls} is missing INSERT_AFTER_FIELD value") try: cls.get_field_by_fieldname(attr_name) except InvalidField: @@ -850,7 +875,7 @@

                                            is_read_receipt_requested
                                          • new_body
                                          • received_by
                                          • -
                                          • received_by_representing
                                          • +
                                          • received_representing
                                          • reference_item_id
                                          • save
                                          • send
                                          • diff --git a/docs/exchangelib/items/calendar_item.html b/docs/exchangelib/items/calendar_item.html index 92a2f9ec..e11ddafb 100644 --- a/docs/exchangelib/items/calendar_item.html +++ b/docs/exchangelib/items/calendar_item.html @@ -29,30 +29,52 @@

                                            Module exchangelib.items.calendar_item

                                            import datetime
                                             import logging
                                             
                                            -from .base import BaseItem, BaseReplyItem, SEND_AND_SAVE_COPY, SEND_TO_NONE
                                            -from .item import Item
                                            -from .message import Message
                                             from ..ewsdatetime import EWSDate, EWSDateTime
                                            -from ..fields import BooleanField, IntegerField, TextField, ChoiceField, URIField, BodyField, DateTimeField, \
                                            -    MessageHeaderField, AttachmentField, RecurrenceField, MailboxField, AttendeesField, Choice, OccurrenceField, \
                                            -    OccurrenceListField, TimeZoneField, CharField, EnumAsIntField, FreeBusyStatusField, ReferenceItemIdField, \
                                            -    AssociatedCalendarItemIdField, DateOrDateTimeField, EWSElementListField, AppointmentStateField
                                            -from ..properties import Attendee, ReferenceItemId, OccurrenceItemId, RecurringMasterItemId, EWSMeta
                                            -from ..recurrence import FirstOccurrence, LastOccurrence, Occurrence, DeletedOccurrence
                                            -from ..util import set_xml_value, require_account
                                            +from ..fields import (
                                            +    AppointmentStateField,
                                            +    AssociatedCalendarItemIdField,
                                            +    AttachmentField,
                                            +    AttendeesField,
                                            +    BodyField,
                                            +    BooleanField,
                                            +    CharField,
                                            +    Choice,
                                            +    ChoiceField,
                                            +    DateOrDateTimeField,
                                            +    DateTimeField,
                                            +    EnumAsIntField,
                                            +    EWSElementListField,
                                            +    FreeBusyStatusField,
                                            +    IntegerField,
                                            +    MailboxField,
                                            +    MessageHeaderField,
                                            +    OccurrenceField,
                                            +    OccurrenceListField,
                                            +    RecurrenceField,
                                            +    ReferenceItemIdField,
                                            +    TextField,
                                            +    TimeZoneField,
                                            +    URIField,
                                            +)
                                            +from ..properties import Attendee, EWSMeta, OccurrenceItemId, RecurringMasterItemId, ReferenceItemId
                                            +from ..recurrence import DeletedOccurrence, FirstOccurrence, LastOccurrence, Occurrence
                                            +from ..util import require_account, set_xml_value
                                             from ..version import EXCHANGE_2010, EXCHANGE_2013
                                            +from .base import SEND_AND_SAVE_COPY, SEND_TO_NONE, BaseItem, BaseReplyItem
                                            +from .item import Item
                                            +from .message import Message
                                             
                                             log = logging.getLogger(__name__)
                                             
                                             # Conference Type values. See
                                             # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/conferencetype
                                            -CONFERENCE_TYPES = ('NetMeeting', 'NetShow', 'Chat')
                                            +CONFERENCE_TYPES = ("NetMeeting", "NetShow", "Chat")
                                             
                                             # CalendarItemType enums
                                            -SINGLE = 'Single'
                                            -OCCURRENCE = 'Occurrence'
                                            -EXCEPTION = 'Exception'
                                            -RECURRING_MASTER = 'RecurringMaster'
                                            +SINGLE = "Single"
                                            +OCCURRENCE = "Occurrence"
                                            +EXCEPTION = "Exception"
                                            +RECURRING_MASTER = "RecurringMaster"
                                             CALENDAR_ITEM_CHOICES = (SINGLE, OCCURRENCE, EXCEPTION, RECURRING_MASTER)
                                             
                                             
                                            @@ -61,89 +83,92 @@ 

                                            Module exchangelib.items.calendar_item

                                            def accept(self, message_disposition=SEND_AND_SAVE_COPY, **kwargs): return AcceptItem( - account=self.account, - reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), - **kwargs + account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs ).send(message_disposition) def decline(self, message_disposition=SEND_AND_SAVE_COPY, **kwargs): return DeclineItem( - account=self.account, - reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), - **kwargs + account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs ).send(message_disposition) def tentatively_accept(self, message_disposition=SEND_AND_SAVE_COPY, **kwargs): return TentativelyAcceptItem( - account=self.account, - reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), - **kwargs + account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs ).send(message_disposition) class CalendarItem(Item, AcceptDeclineMixIn): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendaritem""" - ELEMENT_NAME = 'CalendarItem' - - uid = TextField(field_uri='calendar:UID', is_required_after_save=True, is_searchable=False) - recurrence_id = DateTimeField(field_uri='calendar:RecurrenceId', is_read_only=True) - start = DateOrDateTimeField(field_uri='calendar:Start', is_required=True) - end = DateOrDateTimeField(field_uri='calendar:End', is_required=True) - original_start = DateTimeField(field_uri='calendar:OriginalStart', is_read_only=True) - is_all_day = BooleanField(field_uri='calendar:IsAllDayEvent', is_required=True, default=False) - legacy_free_busy_status = FreeBusyStatusField(field_uri='calendar:LegacyFreeBusyStatus', is_required=True, - default='Busy') - location = TextField(field_uri='calendar:Location') - when = TextField(field_uri='calendar:When') - is_meeting = BooleanField(field_uri='calendar:IsMeeting', is_read_only=True) - is_cancelled = BooleanField(field_uri='calendar:IsCancelled', is_read_only=True) - is_recurring = BooleanField(field_uri='calendar:IsRecurring', is_read_only=True) - meeting_request_was_sent = BooleanField(field_uri='calendar:MeetingRequestWasSent', is_read_only=True) - is_response_requested = BooleanField(field_uri='calendar:IsResponseRequested', default=None, - is_required_after_save=True, is_searchable=False) - type = ChoiceField(field_uri='calendar:CalendarItemType', choices={Choice(c) for c in CALENDAR_ITEM_CHOICES}, - is_read_only=True) - my_response_type = ChoiceField(field_uri='calendar:MyResponseType', choices={ - Choice(c) for c in Attendee.RESPONSE_TYPES - }, is_read_only=True) - organizer = MailboxField(field_uri='calendar:Organizer', is_read_only=True) - required_attendees = AttendeesField(field_uri='calendar:RequiredAttendees', is_searchable=False) - optional_attendees = AttendeesField(field_uri='calendar:OptionalAttendees', is_searchable=False) - resources = AttendeesField(field_uri='calendar:Resources', is_searchable=False) - conflicting_meeting_count = IntegerField(field_uri='calendar:ConflictingMeetingCount', is_read_only=True) - adjacent_meeting_count = IntegerField(field_uri='calendar:AdjacentMeetingCount', is_read_only=True) - conflicting_meetings = EWSElementListField(field_uri='calendar:ConflictingMeetings', value_cls='CalendarItem', - namespace=Item.NAMESPACE, is_read_only=True) - adjacent_meetings = EWSElementListField(field_uri='calendar:AdjacentMeetings', value_cls='CalendarItem', - namespace=Item.NAMESPACE, is_read_only=True) - duration = CharField(field_uri='calendar:Duration', is_read_only=True) - appointment_reply_time = DateTimeField(field_uri='calendar:AppointmentReplyTime', is_read_only=True) - appointment_sequence_number = IntegerField(field_uri='calendar:AppointmentSequenceNumber', is_read_only=True) - appointment_state = AppointmentStateField(field_uri='calendar:AppointmentState', is_read_only=True) - recurrence = RecurrenceField(field_uri='calendar:Recurrence', is_searchable=False) - first_occurrence = OccurrenceField(field_uri='calendar:FirstOccurrence', value_cls=FirstOccurrence, - is_read_only=True) - last_occurrence = OccurrenceField(field_uri='calendar:LastOccurrence', value_cls=LastOccurrence, - is_read_only=True) - modified_occurrences = OccurrenceListField(field_uri='calendar:ModifiedOccurrences', value_cls=Occurrence, - is_read_only=True) - deleted_occurrences = OccurrenceListField(field_uri='calendar:DeletedOccurrences', value_cls=DeletedOccurrence, - is_read_only=True) - _meeting_timezone = TimeZoneField(field_uri='calendar:MeetingTimeZone', deprecated_from=EXCHANGE_2010, - is_searchable=False) - _start_timezone = TimeZoneField(field_uri='calendar:StartTimeZone', supported_from=EXCHANGE_2010, - is_searchable=False) - _end_timezone = TimeZoneField(field_uri='calendar:EndTimeZone', supported_from=EXCHANGE_2010, - is_searchable=False) - conference_type = EnumAsIntField(field_uri='calendar:ConferenceType', enum=CONFERENCE_TYPES, min=0, - default=None, is_required_after_save=True) - allow_new_time_proposal = BooleanField(field_uri='calendar:AllowNewTimeProposal', default=None, - is_required_after_save=True, is_searchable=False) - is_online_meeting = BooleanField(field_uri='calendar:IsOnlineMeeting', default=None, - is_read_only=True) - meeting_workspace_url = URIField(field_uri='calendar:MeetingWorkspaceUrl') - net_show_url = URIField(field_uri='calendar:NetShowUrl') + ELEMENT_NAME = "CalendarItem" + + uid = TextField(field_uri="calendar:UID", is_required_after_save=True, is_searchable=False) + recurrence_id = DateTimeField(field_uri="calendar:RecurrenceId", is_read_only=True) + start = DateOrDateTimeField(field_uri="calendar:Start", is_required=True) + end = DateOrDateTimeField(field_uri="calendar:End", is_required=True) + original_start = DateTimeField(field_uri="calendar:OriginalStart", is_read_only=True) + is_all_day = BooleanField(field_uri="calendar:IsAllDayEvent", is_required=True, default=False) + legacy_free_busy_status = FreeBusyStatusField( + field_uri="calendar:LegacyFreeBusyStatus", is_required=True, default="Busy" + ) + location = TextField(field_uri="calendar:Location") + when = TextField(field_uri="calendar:When") + is_meeting = BooleanField(field_uri="calendar:IsMeeting", is_read_only=True) + is_cancelled = BooleanField(field_uri="calendar:IsCancelled", is_read_only=True) + is_recurring = BooleanField(field_uri="calendar:IsRecurring", is_read_only=True) + meeting_request_was_sent = BooleanField(field_uri="calendar:MeetingRequestWasSent", is_read_only=True) + is_response_requested = BooleanField( + field_uri="calendar:IsResponseRequested", default=None, is_required_after_save=True, is_searchable=False + ) + type = ChoiceField( + field_uri="calendar:CalendarItemType", choices={Choice(c) for c in CALENDAR_ITEM_CHOICES}, is_read_only=True + ) + my_response_type = ChoiceField( + field_uri="calendar:MyResponseType", choices={Choice(c) for c in Attendee.RESPONSE_TYPES}, is_read_only=True + ) + organizer = MailboxField(field_uri="calendar:Organizer", is_read_only=True) + required_attendees = AttendeesField(field_uri="calendar:RequiredAttendees", is_searchable=False) + optional_attendees = AttendeesField(field_uri="calendar:OptionalAttendees", is_searchable=False) + resources = AttendeesField(field_uri="calendar:Resources", is_searchable=False) + conflicting_meeting_count = IntegerField(field_uri="calendar:ConflictingMeetingCount", is_read_only=True) + adjacent_meeting_count = IntegerField(field_uri="calendar:AdjacentMeetingCount", is_read_only=True) + conflicting_meetings = EWSElementListField( + field_uri="calendar:ConflictingMeetings", value_cls="CalendarItem", namespace=Item.NAMESPACE, is_read_only=True + ) + adjacent_meetings = EWSElementListField( + field_uri="calendar:AdjacentMeetings", value_cls="CalendarItem", namespace=Item.NAMESPACE, is_read_only=True + ) + duration = CharField(field_uri="calendar:Duration", is_read_only=True) + appointment_reply_time = DateTimeField(field_uri="calendar:AppointmentReplyTime", is_read_only=True) + appointment_sequence_number = IntegerField(field_uri="calendar:AppointmentSequenceNumber", is_read_only=True) + appointment_state = AppointmentStateField(field_uri="calendar:AppointmentState", is_read_only=True) + recurrence = RecurrenceField(field_uri="calendar:Recurrence", is_searchable=False) + first_occurrence = OccurrenceField( + field_uri="calendar:FirstOccurrence", value_cls=FirstOccurrence, is_read_only=True + ) + last_occurrence = OccurrenceField(field_uri="calendar:LastOccurrence", value_cls=LastOccurrence, is_read_only=True) + modified_occurrences = OccurrenceListField( + field_uri="calendar:ModifiedOccurrences", value_cls=Occurrence, is_read_only=True + ) + deleted_occurrences = OccurrenceListField( + field_uri="calendar:DeletedOccurrences", value_cls=DeletedOccurrence, is_read_only=True + ) + _meeting_timezone = TimeZoneField( + field_uri="calendar:MeetingTimeZone", deprecated_from=EXCHANGE_2010, is_searchable=False + ) + _start_timezone = TimeZoneField( + field_uri="calendar:StartTimeZone", supported_from=EXCHANGE_2010, is_searchable=False + ) + _end_timezone = TimeZoneField(field_uri="calendar:EndTimeZone", supported_from=EXCHANGE_2010, is_searchable=False) + conference_type = EnumAsIntField( + field_uri="calendar:ConferenceType", enum=CONFERENCE_TYPES, min=0, default=None, is_required_after_save=True + ) + allow_new_time_proposal = BooleanField( + field_uri="calendar:AllowNewTimeProposal", default=None, is_required_after_save=True, is_searchable=False + ) + is_online_meeting = BooleanField(field_uri="calendar:IsOnlineMeeting", default=None, is_read_only=True) + meeting_workspace_url = URIField(field_uri="calendar:MeetingWorkspaceUrl") + net_show_url = URIField(field_uri="calendar:NetShowUrl") def occurrence(self, index): """Get an occurrence of a recurring master by index. No query is sent to the server to actually fetch the item. @@ -214,9 +239,7 @@

                                            Module exchangelib.items.calendar_item

                                            def cancel(self, **kwargs): return CancelCalendarItem( - account=self.account, - reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), - **kwargs + account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs ).send() def _update_fieldnames(self): @@ -224,8 +247,8 @@

                                            Module exchangelib.items.calendar_item

                                            if self.type == OCCURRENCE: # Some CalendarItem fields cannot be updated when the item is an occurrence. The values are empty when we # receive them so would have been updated because they are set to None. - update_fields.remove('recurrence') - update_fields.remove('uid') + update_fields.remove("recurrence") + update_fields.remove("uid") return update_fields @classmethod @@ -235,15 +258,15 @@

                                            Module exchangelib.items.calendar_item

                                            # applicable. if not item.is_all_day: return item - for field_name in ('start', 'end'): + for field_name in ("start", "end"): val = getattr(item, field_name) if val is None: continue # Return just the date part of the value. Subtract 1 day from the date if this is the end field. This is # the inverse of what we do in .to_xml(). Convert to the local timezone before getting the date. - if field_name == 'end': + if field_name == "end": val -= datetime.timedelta(days=1) - tz = getattr(item, f'_{field_name}_timezone') + tz = getattr(item, f"_{field_name}_timezone") setattr(item, field_name, val.astimezone(tz).date()) return item @@ -251,11 +274,11 @@

                                            Module exchangelib.items.calendar_item

                                            meeting_tz_field, start_tz_field, end_tz_field = CalendarItem.timezone_fields() if self.account.version.build < EXCHANGE_2010: return meeting_tz_field - if field_name == 'start': + if field_name == "start": return start_tz_field - if field_name == 'end': + if field_name == "end": return end_tz_field - raise ValueError('Unsupported field_name') + raise ValueError("Unsupported field_name") def date_to_datetime(self, field_name): # EWS always expects a datetime. If we have a date value, then convert it to datetime in the local @@ -264,7 +287,7 @@

                                            Module exchangelib.items.calendar_item

                                            value = getattr(self, field_name) tz = getattr(self, self.tz_field_for_field_name(field_name).name) value = EWSDateTime.combine(value, datetime.time(0, 0)).replace(tzinfo=tz) - if field_name == 'end': + if field_name == "end": value += datetime.timedelta(days=1) return value @@ -278,7 +301,7 @@

                                            Module exchangelib.items.calendar_item

                                            elem = super().to_xml(version=version) if not self.is_all_day: return elem - for field_name in ('start', 'end'): + for field_name in ("start", "end"): value = getattr(self, field_name) if value is None: continue @@ -302,96 +325,123 @@

                                            Module exchangelib.items.calendar_item

                                            Therefore BaseMeetingItem inherits from EWSElement has no save() or send() method """ - associated_calendar_item_id = AssociatedCalendarItemIdField(field_uri='meeting:AssociatedCalendarItemId') - is_delegated = BooleanField(field_uri='meeting:IsDelegated', is_read_only=True, default=False) - is_out_of_date = BooleanField(field_uri='meeting:IsOutOfDate', is_read_only=True, default=False) - has_been_processed = BooleanField(field_uri='meeting:HasBeenProcessed', is_read_only=True, default=False) - response_type = ChoiceField(field_uri='meeting:ResponseType', choices={ - Choice('Unknown'), Choice('Organizer'), Choice('Tentative'), Choice('Accept'), Choice('Decline'), - Choice('NoResponseReceived') - }, is_required=True, default='Unknown') - - effective_rights_idx = Item.FIELDS.index_by_name('effective_rights') - sender_idx = Message.FIELDS.index_by_name('sender') - reply_to_idx = Message.FIELDS.index_by_name('reply_to') - FIELDS = Item.FIELDS[:effective_rights_idx] \ - + Message.FIELDS[sender_idx:reply_to_idx + 1] \ + associated_calendar_item_id = AssociatedCalendarItemIdField(field_uri="meeting:AssociatedCalendarItemId") + is_delegated = BooleanField(field_uri="meeting:IsDelegated", is_read_only=True, default=False) + is_out_of_date = BooleanField(field_uri="meeting:IsOutOfDate", is_read_only=True, default=False) + has_been_processed = BooleanField(field_uri="meeting:HasBeenProcessed", is_read_only=True, default=False) + response_type = ChoiceField( + field_uri="meeting:ResponseType", + choices={ + Choice("Unknown"), + Choice("Organizer"), + Choice("Tentative"), + Choice("Accept"), + Choice("Decline"), + Choice("NoResponseReceived"), + }, + is_required=True, + default="Unknown", + ) + + effective_rights_idx = Item.FIELDS.index_by_name("effective_rights") + sender_idx = Message.FIELDS.index_by_name("sender") + received_representing_idx = Message.FIELDS.index_by_name("received_representing") + FIELDS = ( + Item.FIELDS[:effective_rights_idx] + + Message.FIELDS[sender_idx : received_representing_idx + 1] + Item.FIELDS[effective_rights_idx:] + ) class MeetingRequest(BaseMeetingItem, AcceptDeclineMixIn): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/meetingrequest""" - ELEMENT_NAME = 'MeetingRequest' - - meeting_request_type = ChoiceField(field_uri='meetingRequest:MeetingRequestType', choices={ - Choice('FullUpdate'), Choice('InformationalUpdate'), Choice('NewMeetingRequest'), Choice('None'), - Choice('Outdated'), Choice('PrincipalWantsCopy'), Choice('SilentUpdate') - }, default='None') - intended_free_busy_status = ChoiceField(field_uri='meetingRequest:IntendedFreeBusyStatus', choices={ - Choice('Free'), Choice('Tentative'), Choice('Busy'), Choice('OOF'), Choice('NoData') - }, is_required=True, default='Busy') + ELEMENT_NAME = "MeetingRequest" + + meeting_request_type = ChoiceField( + field_uri="meetingRequest:MeetingRequestType", + choices={ + Choice("FullUpdate"), + Choice("InformationalUpdate"), + Choice("NewMeetingRequest"), + Choice("None"), + Choice("Outdated"), + Choice("PrincipalWantsCopy"), + Choice("SilentUpdate"), + }, + default="None", + ) + intended_free_busy_status = ChoiceField( + field_uri="meetingRequest:IntendedFreeBusyStatus", + choices={Choice("Free"), Choice("Tentative"), Choice("Busy"), Choice("OOF"), Choice("NoData")}, + is_required=True, + default="Busy", + ) # This element also has some fields from CalendarItem - start_idx = CalendarItem.FIELDS.index_by_name('start') - is_response_requested_idx = CalendarItem.FIELDS.index_by_name('is_response_requested') - FIELDS = BaseMeetingItem.FIELDS \ - + CalendarItem.FIELDS[start_idx:is_response_requested_idx]\ - + CalendarItem.FIELDS[is_response_requested_idx + 1:] + start_idx = CalendarItem.FIELDS.index_by_name("start") + is_response_requested_idx = CalendarItem.FIELDS.index_by_name("is_response_requested") + FIELDS = ( + BaseMeetingItem.FIELDS + + CalendarItem.FIELDS[start_idx:is_response_requested_idx] + + CalendarItem.FIELDS[is_response_requested_idx + 1 :] + ) class MeetingMessage(BaseMeetingItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/meetingmessage""" - ELEMENT_NAME = 'MeetingMessage' + ELEMENT_NAME = "MeetingMessage" class MeetingResponse(BaseMeetingItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/meetingresponse""" - ELEMENT_NAME = 'MeetingResponse' + ELEMENT_NAME = "MeetingResponse" - received_by = MailboxField(field_uri='message:ReceivedBy', is_read_only=True) - received_representing = MailboxField(field_uri='message:ReceivedRepresenting', is_read_only=True) - proposed_start = DateTimeField(field_uri='meeting:ProposedStart', supported_from=EXCHANGE_2013) - proposed_end = DateTimeField(field_uri='meeting:ProposedEnd', supported_from=EXCHANGE_2013) + proposed_start = DateTimeField(field_uri="meeting:ProposedStart", supported_from=EXCHANGE_2013) + proposed_end = DateTimeField(field_uri="meeting:ProposedEnd", supported_from=EXCHANGE_2013) class MeetingCancellation(BaseMeetingItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/meetingcancellation""" - ELEMENT_NAME = 'MeetingCancellation' + ELEMENT_NAME = "MeetingCancellation" class BaseMeetingReplyItem(BaseItem, metaclass=EWSMeta): """Base class for meeting request reply items that share the same fields (Accept, TentativelyAccept, Decline).""" - item_class = CharField(field_uri='item:ItemClass', is_read_only=True) - sensitivity = ChoiceField(field_uri='item:Sensitivity', choices={ - Choice('Normal'), Choice('Personal'), Choice('Private'), Choice('Confidential') - }, is_required=True, default='Normal') - body = BodyField(field_uri='item:Body') # Accepts and returns Body or HTMLBody instances - attachments = AttachmentField(field_uri='item:Attachments') # ItemAttachment or FileAttachment - headers = MessageHeaderField(field_uri='item:InternetMessageHeaders', is_read_only=True) - - sender = Message.FIELDS['sender'] - to_recipients = Message.FIELDS['to_recipients'] - cc_recipients = Message.FIELDS['cc_recipients'] - bcc_recipients = Message.FIELDS['bcc_recipients'] - is_read_receipt_requested = Message.FIELDS['is_read_receipt_requested'] - is_delivery_receipt_requested = Message.FIELDS['is_delivery_receipt_requested'] - - reference_item_id = ReferenceItemIdField(field_uri='item:ReferenceItemId') - received_by = MailboxField(field_uri='message:ReceivedBy', is_read_only=True) - received_representing = MailboxField(field_uri='message:ReceivedRepresenting', is_read_only=True) - proposed_start = DateTimeField(field_uri='meeting:ProposedStart', supported_from=EXCHANGE_2013) - proposed_end = DateTimeField(field_uri='meeting:ProposedEnd', supported_from=EXCHANGE_2013) + item_class = CharField(field_uri="item:ItemClass", is_read_only=True) + sensitivity = ChoiceField( + field_uri="item:Sensitivity", + choices={Choice("Normal"), Choice("Personal"), Choice("Private"), Choice("Confidential")}, + is_required=True, + default="Normal", + ) + body = BodyField(field_uri="item:Body") # Accepts and returns Body or HTMLBody instances + attachments = AttachmentField(field_uri="item:Attachments") # ItemAttachment or FileAttachment + headers = MessageHeaderField(field_uri="item:InternetMessageHeaders", is_read_only=True) + + sender = Message.FIELDS["sender"] + to_recipients = Message.FIELDS["to_recipients"] + cc_recipients = Message.FIELDS["cc_recipients"] + bcc_recipients = Message.FIELDS["bcc_recipients"] + is_read_receipt_requested = Message.FIELDS["is_read_receipt_requested"] + is_delivery_receipt_requested = Message.FIELDS["is_delivery_receipt_requested"] + + reference_item_id = ReferenceItemIdField(field_uri="item:ReferenceItemId") + received_by = MailboxField(field_uri="message:ReceivedBy", is_read_only=True) + received_representing = MailboxField(field_uri="message:ReceivedRepresenting", is_read_only=True) + proposed_start = DateTimeField(field_uri="meeting:ProposedStart", supported_from=EXCHANGE_2013) + proposed_end = DateTimeField(field_uri="meeting:ProposedEnd", supported_from=EXCHANGE_2013) @require_account def send(self, message_disposition=SEND_AND_SAVE_COPY): # Some responses contain multiple response IDs, e.g. MeetingRequest.accept(). Return either the single ID or # the list of IDs. from ..services import CreateItem + return CreateItem(account=self.account).get( items=[self], folder=self.folder, @@ -403,27 +453,27 @@

                                            Module exchangelib.items.calendar_item

                                            class AcceptItem(BaseMeetingReplyItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/acceptitem""" - ELEMENT_NAME = 'AcceptItem' + ELEMENT_NAME = "AcceptItem" class TentativelyAcceptItem(BaseMeetingReplyItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/tentativelyacceptitem""" - ELEMENT_NAME = 'TentativelyAcceptItem' + ELEMENT_NAME = "TentativelyAcceptItem" class DeclineItem(BaseMeetingReplyItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/declineitem""" - ELEMENT_NAME = 'DeclineItem' + ELEMENT_NAME = "DeclineItem" class CancelCalendarItem(BaseReplyItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/cancelcalendaritem""" - ELEMENT_NAME = 'CancelCalendarItem' - author_idx = BaseReplyItem.FIELDS.index_by_name('author') - FIELDS = BaseReplyItem.FIELDS[:author_idx] + BaseReplyItem.FIELDS[author_idx + 1:]
                                            + ELEMENT_NAME = "CancelCalendarItem" + author_idx = BaseReplyItem.FIELDS.index_by_name("author") + FIELDS = BaseReplyItem.FIELDS[:author_idx] + BaseReplyItem.FIELDS[author_idx + 1 :]
                                            @@ -449,23 +499,17 @@

                                            Classes

                                            def accept(self, message_disposition=SEND_AND_SAVE_COPY, **kwargs): return AcceptItem( - account=self.account, - reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), - **kwargs + account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs ).send(message_disposition) def decline(self, message_disposition=SEND_AND_SAVE_COPY, **kwargs): return DeclineItem( - account=self.account, - reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), - **kwargs + account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs ).send(message_disposition) def tentatively_accept(self, message_disposition=SEND_AND_SAVE_COPY, **kwargs): return TentativelyAcceptItem( - account=self.account, - reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), - **kwargs + account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs ).send(message_disposition)

                                            Subclasses

                                            @@ -486,9 +530,7 @@

                                            Methods

                                            def accept(self, message_disposition=SEND_AND_SAVE_COPY, **kwargs):
                                                 return AcceptItem(
                                            -        account=self.account,
                                            -        reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey),
                                            -        **kwargs
                                            +        account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs
                                                 ).send(message_disposition)
                                            @@ -503,9 +545,7 @@

                                            Methods

                                            def decline(self, message_disposition=SEND_AND_SAVE_COPY, **kwargs):
                                                 return DeclineItem(
                                            -        account=self.account,
                                            -        reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey),
                                            -        **kwargs
                                            +        account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs
                                                 ).send(message_disposition)
                                            @@ -520,9 +560,7 @@

                                            Methods

                                            def tentatively_accept(self, message_disposition=SEND_AND_SAVE_COPY, **kwargs):
                                                 return TentativelyAcceptItem(
                                            -        account=self.account,
                                            -        reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey),
                                            -        **kwargs
                                            +        account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs
                                                 ).send(message_disposition)
                                            @@ -546,7 +584,7 @@

                                            Methods

                                            class AcceptItem(BaseMeetingReplyItem):
                                                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/acceptitem"""
                                             
                                            -    ELEMENT_NAME = 'AcceptItem'
                                            + ELEMENT_NAME = "AcceptItem"

                                            Ancestors

                                              @@ -610,21 +648,32 @@

                                              Inherited members

                                              Therefore BaseMeetingItem inherits from EWSElement has no save() or send() method """ - associated_calendar_item_id = AssociatedCalendarItemIdField(field_uri='meeting:AssociatedCalendarItemId') - is_delegated = BooleanField(field_uri='meeting:IsDelegated', is_read_only=True, default=False) - is_out_of_date = BooleanField(field_uri='meeting:IsOutOfDate', is_read_only=True, default=False) - has_been_processed = BooleanField(field_uri='meeting:HasBeenProcessed', is_read_only=True, default=False) - response_type = ChoiceField(field_uri='meeting:ResponseType', choices={ - Choice('Unknown'), Choice('Organizer'), Choice('Tentative'), Choice('Accept'), Choice('Decline'), - Choice('NoResponseReceived') - }, is_required=True, default='Unknown') - - effective_rights_idx = Item.FIELDS.index_by_name('effective_rights') - sender_idx = Message.FIELDS.index_by_name('sender') - reply_to_idx = Message.FIELDS.index_by_name('reply_to') - FIELDS = Item.FIELDS[:effective_rights_idx] \ - + Message.FIELDS[sender_idx:reply_to_idx + 1] \ - + Item.FIELDS[effective_rights_idx:] + associated_calendar_item_id = AssociatedCalendarItemIdField(field_uri="meeting:AssociatedCalendarItemId") + is_delegated = BooleanField(field_uri="meeting:IsDelegated", is_read_only=True, default=False) + is_out_of_date = BooleanField(field_uri="meeting:IsOutOfDate", is_read_only=True, default=False) + has_been_processed = BooleanField(field_uri="meeting:HasBeenProcessed", is_read_only=True, default=False) + response_type = ChoiceField( + field_uri="meeting:ResponseType", + choices={ + Choice("Unknown"), + Choice("Organizer"), + Choice("Tentative"), + Choice("Accept"), + Choice("Decline"), + Choice("NoResponseReceived"), + }, + is_required=True, + default="Unknown", + ) + + effective_rights_idx = Item.FIELDS.index_by_name("effective_rights") + sender_idx = Message.FIELDS.index_by_name("sender") + received_representing_idx = Message.FIELDS.index_by_name("received_representing") + FIELDS = ( + Item.FIELDS[:effective_rights_idx] + + Message.FIELDS[sender_idx : received_representing_idx + 1] + + Item.FIELDS[effective_rights_idx:] + )

                                              Ancestors

                                                @@ -651,7 +700,7 @@

                                                Class variables

                                                -
                                                var reply_to_idx
                                                +
                                                var received_representing_idx
                                                @@ -718,6 +767,14 @@

                                                Instance variables

                                                +
                                                var received_by
                                                +
                                                +
                                                +
                                                +
                                                var received_representing
                                                +
                                                +
                                                +
                                                var references
                                                @@ -776,32 +833,36 @@

                                                Inherited members

                                                class BaseMeetingReplyItem(BaseItem, metaclass=EWSMeta):
                                                     """Base class for meeting request reply items that share the same fields (Accept, TentativelyAccept, Decline)."""
                                                 
                                                -    item_class = CharField(field_uri='item:ItemClass', is_read_only=True)
                                                -    sensitivity = ChoiceField(field_uri='item:Sensitivity', choices={
                                                -        Choice('Normal'), Choice('Personal'), Choice('Private'), Choice('Confidential')
                                                -    }, is_required=True, default='Normal')
                                                -    body = BodyField(field_uri='item:Body')  # Accepts and returns Body or HTMLBody instances
                                                -    attachments = AttachmentField(field_uri='item:Attachments')  # ItemAttachment or FileAttachment
                                                -    headers = MessageHeaderField(field_uri='item:InternetMessageHeaders', is_read_only=True)
                                                -
                                                -    sender = Message.FIELDS['sender']
                                                -    to_recipients = Message.FIELDS['to_recipients']
                                                -    cc_recipients = Message.FIELDS['cc_recipients']
                                                -    bcc_recipients = Message.FIELDS['bcc_recipients']
                                                -    is_read_receipt_requested = Message.FIELDS['is_read_receipt_requested']
                                                -    is_delivery_receipt_requested = Message.FIELDS['is_delivery_receipt_requested']
                                                -
                                                -    reference_item_id = ReferenceItemIdField(field_uri='item:ReferenceItemId')
                                                -    received_by = MailboxField(field_uri='message:ReceivedBy', is_read_only=True)
                                                -    received_representing = MailboxField(field_uri='message:ReceivedRepresenting', is_read_only=True)
                                                -    proposed_start = DateTimeField(field_uri='meeting:ProposedStart', supported_from=EXCHANGE_2013)
                                                -    proposed_end = DateTimeField(field_uri='meeting:ProposedEnd', supported_from=EXCHANGE_2013)
                                                +    item_class = CharField(field_uri="item:ItemClass", is_read_only=True)
                                                +    sensitivity = ChoiceField(
                                                +        field_uri="item:Sensitivity",
                                                +        choices={Choice("Normal"), Choice("Personal"), Choice("Private"), Choice("Confidential")},
                                                +        is_required=True,
                                                +        default="Normal",
                                                +    )
                                                +    body = BodyField(field_uri="item:Body")  # Accepts and returns Body or HTMLBody instances
                                                +    attachments = AttachmentField(field_uri="item:Attachments")  # ItemAttachment or FileAttachment
                                                +    headers = MessageHeaderField(field_uri="item:InternetMessageHeaders", is_read_only=True)
                                                +
                                                +    sender = Message.FIELDS["sender"]
                                                +    to_recipients = Message.FIELDS["to_recipients"]
                                                +    cc_recipients = Message.FIELDS["cc_recipients"]
                                                +    bcc_recipients = Message.FIELDS["bcc_recipients"]
                                                +    is_read_receipt_requested = Message.FIELDS["is_read_receipt_requested"]
                                                +    is_delivery_receipt_requested = Message.FIELDS["is_delivery_receipt_requested"]
                                                +
                                                +    reference_item_id = ReferenceItemIdField(field_uri="item:ReferenceItemId")
                                                +    received_by = MailboxField(field_uri="message:ReceivedBy", is_read_only=True)
                                                +    received_representing = MailboxField(field_uri="message:ReceivedRepresenting", is_read_only=True)
                                                +    proposed_start = DateTimeField(field_uri="meeting:ProposedStart", supported_from=EXCHANGE_2013)
                                                +    proposed_end = DateTimeField(field_uri="meeting:ProposedEnd", supported_from=EXCHANGE_2013)
                                                 
                                                     @require_account
                                                     def send(self, message_disposition=SEND_AND_SAVE_COPY):
                                                         # Some responses contain multiple response IDs, e.g. MeetingRequest.accept(). Return either the single ID or
                                                         # the list of IDs.
                                                         from ..services import CreateItem
                                                +
                                                         return CreateItem(account=self.account).get(
                                                             items=[self],
                                                             folder=self.folder,
                                                @@ -912,6 +973,7 @@ 

                                                Methods

                                                # Some responses contain multiple response IDs, e.g. MeetingRequest.accept(). Return either the single ID or # the list of IDs. from ..services import CreateItem + return CreateItem(account=self.account).get( items=[self], folder=self.folder, @@ -956,66 +1018,75 @@

                                                Inherited members

                                                class CalendarItem(Item, AcceptDeclineMixIn):
                                                     """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendaritem"""
                                                 
                                                -    ELEMENT_NAME = 'CalendarItem'
                                                -
                                                -    uid = TextField(field_uri='calendar:UID', is_required_after_save=True, is_searchable=False)
                                                -    recurrence_id = DateTimeField(field_uri='calendar:RecurrenceId', is_read_only=True)
                                                -    start = DateOrDateTimeField(field_uri='calendar:Start', is_required=True)
                                                -    end = DateOrDateTimeField(field_uri='calendar:End', is_required=True)
                                                -    original_start = DateTimeField(field_uri='calendar:OriginalStart', is_read_only=True)
                                                -    is_all_day = BooleanField(field_uri='calendar:IsAllDayEvent', is_required=True, default=False)
                                                -    legacy_free_busy_status = FreeBusyStatusField(field_uri='calendar:LegacyFreeBusyStatus', is_required=True,
                                                -                                                  default='Busy')
                                                -    location = TextField(field_uri='calendar:Location')
                                                -    when = TextField(field_uri='calendar:When')
                                                -    is_meeting = BooleanField(field_uri='calendar:IsMeeting', is_read_only=True)
                                                -    is_cancelled = BooleanField(field_uri='calendar:IsCancelled', is_read_only=True)
                                                -    is_recurring = BooleanField(field_uri='calendar:IsRecurring', is_read_only=True)
                                                -    meeting_request_was_sent = BooleanField(field_uri='calendar:MeetingRequestWasSent', is_read_only=True)
                                                -    is_response_requested = BooleanField(field_uri='calendar:IsResponseRequested', default=None,
                                                -                                         is_required_after_save=True, is_searchable=False)
                                                -    type = ChoiceField(field_uri='calendar:CalendarItemType', choices={Choice(c) for c in CALENDAR_ITEM_CHOICES},
                                                -                       is_read_only=True)
                                                -    my_response_type = ChoiceField(field_uri='calendar:MyResponseType', choices={
                                                -            Choice(c) for c in Attendee.RESPONSE_TYPES
                                                -    }, is_read_only=True)
                                                -    organizer = MailboxField(field_uri='calendar:Organizer', is_read_only=True)
                                                -    required_attendees = AttendeesField(field_uri='calendar:RequiredAttendees', is_searchable=False)
                                                -    optional_attendees = AttendeesField(field_uri='calendar:OptionalAttendees', is_searchable=False)
                                                -    resources = AttendeesField(field_uri='calendar:Resources', is_searchable=False)
                                                -    conflicting_meeting_count = IntegerField(field_uri='calendar:ConflictingMeetingCount', is_read_only=True)
                                                -    adjacent_meeting_count = IntegerField(field_uri='calendar:AdjacentMeetingCount', is_read_only=True)
                                                -    conflicting_meetings = EWSElementListField(field_uri='calendar:ConflictingMeetings', value_cls='CalendarItem',
                                                -                                               namespace=Item.NAMESPACE, is_read_only=True)
                                                -    adjacent_meetings = EWSElementListField(field_uri='calendar:AdjacentMeetings', value_cls='CalendarItem',
                                                -                                            namespace=Item.NAMESPACE, is_read_only=True)
                                                -    duration = CharField(field_uri='calendar:Duration', is_read_only=True)
                                                -    appointment_reply_time = DateTimeField(field_uri='calendar:AppointmentReplyTime', is_read_only=True)
                                                -    appointment_sequence_number = IntegerField(field_uri='calendar:AppointmentSequenceNumber', is_read_only=True)
                                                -    appointment_state = AppointmentStateField(field_uri='calendar:AppointmentState', is_read_only=True)
                                                -    recurrence = RecurrenceField(field_uri='calendar:Recurrence', is_searchable=False)
                                                -    first_occurrence = OccurrenceField(field_uri='calendar:FirstOccurrence', value_cls=FirstOccurrence,
                                                -                                       is_read_only=True)
                                                -    last_occurrence = OccurrenceField(field_uri='calendar:LastOccurrence', value_cls=LastOccurrence,
                                                -                                      is_read_only=True)
                                                -    modified_occurrences = OccurrenceListField(field_uri='calendar:ModifiedOccurrences', value_cls=Occurrence,
                                                -                                               is_read_only=True)
                                                -    deleted_occurrences = OccurrenceListField(field_uri='calendar:DeletedOccurrences', value_cls=DeletedOccurrence,
                                                -                                              is_read_only=True)
                                                -    _meeting_timezone = TimeZoneField(field_uri='calendar:MeetingTimeZone', deprecated_from=EXCHANGE_2010,
                                                -                                      is_searchable=False)
                                                -    _start_timezone = TimeZoneField(field_uri='calendar:StartTimeZone', supported_from=EXCHANGE_2010,
                                                -                                    is_searchable=False)
                                                -    _end_timezone = TimeZoneField(field_uri='calendar:EndTimeZone', supported_from=EXCHANGE_2010,
                                                -                                  is_searchable=False)
                                                -    conference_type = EnumAsIntField(field_uri='calendar:ConferenceType', enum=CONFERENCE_TYPES, min=0,
                                                -                                     default=None, is_required_after_save=True)
                                                -    allow_new_time_proposal = BooleanField(field_uri='calendar:AllowNewTimeProposal', default=None,
                                                -                                           is_required_after_save=True, is_searchable=False)
                                                -    is_online_meeting = BooleanField(field_uri='calendar:IsOnlineMeeting', default=None,
                                                -                                     is_read_only=True)
                                                -    meeting_workspace_url = URIField(field_uri='calendar:MeetingWorkspaceUrl')
                                                -    net_show_url = URIField(field_uri='calendar:NetShowUrl')
                                                +    ELEMENT_NAME = "CalendarItem"
                                                +
                                                +    uid = TextField(field_uri="calendar:UID", is_required_after_save=True, is_searchable=False)
                                                +    recurrence_id = DateTimeField(field_uri="calendar:RecurrenceId", is_read_only=True)
                                                +    start = DateOrDateTimeField(field_uri="calendar:Start", is_required=True)
                                                +    end = DateOrDateTimeField(field_uri="calendar:End", is_required=True)
                                                +    original_start = DateTimeField(field_uri="calendar:OriginalStart", is_read_only=True)
                                                +    is_all_day = BooleanField(field_uri="calendar:IsAllDayEvent", is_required=True, default=False)
                                                +    legacy_free_busy_status = FreeBusyStatusField(
                                                +        field_uri="calendar:LegacyFreeBusyStatus", is_required=True, default="Busy"
                                                +    )
                                                +    location = TextField(field_uri="calendar:Location")
                                                +    when = TextField(field_uri="calendar:When")
                                                +    is_meeting = BooleanField(field_uri="calendar:IsMeeting", is_read_only=True)
                                                +    is_cancelled = BooleanField(field_uri="calendar:IsCancelled", is_read_only=True)
                                                +    is_recurring = BooleanField(field_uri="calendar:IsRecurring", is_read_only=True)
                                                +    meeting_request_was_sent = BooleanField(field_uri="calendar:MeetingRequestWasSent", is_read_only=True)
                                                +    is_response_requested = BooleanField(
                                                +        field_uri="calendar:IsResponseRequested", default=None, is_required_after_save=True, is_searchable=False
                                                +    )
                                                +    type = ChoiceField(
                                                +        field_uri="calendar:CalendarItemType", choices={Choice(c) for c in CALENDAR_ITEM_CHOICES}, is_read_only=True
                                                +    )
                                                +    my_response_type = ChoiceField(
                                                +        field_uri="calendar:MyResponseType", choices={Choice(c) for c in Attendee.RESPONSE_TYPES}, is_read_only=True
                                                +    )
                                                +    organizer = MailboxField(field_uri="calendar:Organizer", is_read_only=True)
                                                +    required_attendees = AttendeesField(field_uri="calendar:RequiredAttendees", is_searchable=False)
                                                +    optional_attendees = AttendeesField(field_uri="calendar:OptionalAttendees", is_searchable=False)
                                                +    resources = AttendeesField(field_uri="calendar:Resources", is_searchable=False)
                                                +    conflicting_meeting_count = IntegerField(field_uri="calendar:ConflictingMeetingCount", is_read_only=True)
                                                +    adjacent_meeting_count = IntegerField(field_uri="calendar:AdjacentMeetingCount", is_read_only=True)
                                                +    conflicting_meetings = EWSElementListField(
                                                +        field_uri="calendar:ConflictingMeetings", value_cls="CalendarItem", namespace=Item.NAMESPACE, is_read_only=True
                                                +    )
                                                +    adjacent_meetings = EWSElementListField(
                                                +        field_uri="calendar:AdjacentMeetings", value_cls="CalendarItem", namespace=Item.NAMESPACE, is_read_only=True
                                                +    )
                                                +    duration = CharField(field_uri="calendar:Duration", is_read_only=True)
                                                +    appointment_reply_time = DateTimeField(field_uri="calendar:AppointmentReplyTime", is_read_only=True)
                                                +    appointment_sequence_number = IntegerField(field_uri="calendar:AppointmentSequenceNumber", is_read_only=True)
                                                +    appointment_state = AppointmentStateField(field_uri="calendar:AppointmentState", is_read_only=True)
                                                +    recurrence = RecurrenceField(field_uri="calendar:Recurrence", is_searchable=False)
                                                +    first_occurrence = OccurrenceField(
                                                +        field_uri="calendar:FirstOccurrence", value_cls=FirstOccurrence, is_read_only=True
                                                +    )
                                                +    last_occurrence = OccurrenceField(field_uri="calendar:LastOccurrence", value_cls=LastOccurrence, is_read_only=True)
                                                +    modified_occurrences = OccurrenceListField(
                                                +        field_uri="calendar:ModifiedOccurrences", value_cls=Occurrence, is_read_only=True
                                                +    )
                                                +    deleted_occurrences = OccurrenceListField(
                                                +        field_uri="calendar:DeletedOccurrences", value_cls=DeletedOccurrence, is_read_only=True
                                                +    )
                                                +    _meeting_timezone = TimeZoneField(
                                                +        field_uri="calendar:MeetingTimeZone", deprecated_from=EXCHANGE_2010, is_searchable=False
                                                +    )
                                                +    _start_timezone = TimeZoneField(
                                                +        field_uri="calendar:StartTimeZone", supported_from=EXCHANGE_2010, is_searchable=False
                                                +    )
                                                +    _end_timezone = TimeZoneField(field_uri="calendar:EndTimeZone", supported_from=EXCHANGE_2010, is_searchable=False)
                                                +    conference_type = EnumAsIntField(
                                                +        field_uri="calendar:ConferenceType", enum=CONFERENCE_TYPES, min=0, default=None, is_required_after_save=True
                                                +    )
                                                +    allow_new_time_proposal = BooleanField(
                                                +        field_uri="calendar:AllowNewTimeProposal", default=None, is_required_after_save=True, is_searchable=False
                                                +    )
                                                +    is_online_meeting = BooleanField(field_uri="calendar:IsOnlineMeeting", default=None, is_read_only=True)
                                                +    meeting_workspace_url = URIField(field_uri="calendar:MeetingWorkspaceUrl")
                                                +    net_show_url = URIField(field_uri="calendar:NetShowUrl")
                                                 
                                                     def occurrence(self, index):
                                                         """Get an occurrence of a recurring master by index. No query is sent to the server to actually fetch the item.
                                                @@ -1086,9 +1157,7 @@ 

                                                Inherited members

                                                def cancel(self, **kwargs): return CancelCalendarItem( - account=self.account, - reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), - **kwargs + account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs ).send() def _update_fieldnames(self): @@ -1096,8 +1165,8 @@

                                                Inherited members

                                                if self.type == OCCURRENCE: # Some CalendarItem fields cannot be updated when the item is an occurrence. The values are empty when we # receive them so would have been updated because they are set to None. - update_fields.remove('recurrence') - update_fields.remove('uid') + update_fields.remove("recurrence") + update_fields.remove("uid") return update_fields @classmethod @@ -1107,15 +1176,15 @@

                                                Inherited members

                                                # applicable. if not item.is_all_day: return item - for field_name in ('start', 'end'): + for field_name in ("start", "end"): val = getattr(item, field_name) if val is None: continue # Return just the date part of the value. Subtract 1 day from the date if this is the end field. This is # the inverse of what we do in .to_xml(). Convert to the local timezone before getting the date. - if field_name == 'end': + if field_name == "end": val -= datetime.timedelta(days=1) - tz = getattr(item, f'_{field_name}_timezone') + tz = getattr(item, f"_{field_name}_timezone") setattr(item, field_name, val.astimezone(tz).date()) return item @@ -1123,11 +1192,11 @@

                                                Inherited members

                                                meeting_tz_field, start_tz_field, end_tz_field = CalendarItem.timezone_fields() if self.account.version.build < EXCHANGE_2010: return meeting_tz_field - if field_name == 'start': + if field_name == "start": return start_tz_field - if field_name == 'end': + if field_name == "end": return end_tz_field - raise ValueError('Unsupported field_name') + raise ValueError("Unsupported field_name") def date_to_datetime(self, field_name): # EWS always expects a datetime. If we have a date value, then convert it to datetime in the local @@ -1136,7 +1205,7 @@

                                                Inherited members

                                                value = getattr(self, field_name) tz = getattr(self, self.tz_field_for_field_name(field_name).name) value = EWSDateTime.combine(value, datetime.time(0, 0)).replace(tzinfo=tz) - if field_name == 'end': + if field_name == "end": value += datetime.timedelta(days=1) return value @@ -1150,7 +1219,7 @@

                                                Inherited members

                                                elem = super().to_xml(version=version) if not self.is_all_day: return elem - for field_name in ('start', 'end'): + for field_name in ("start", "end"): value = getattr(self, field_name) if value is None: continue @@ -1201,15 +1270,15 @@

                                                Static methods

                                                # applicable. if not item.is_all_day: return item - for field_name in ('start', 'end'): + for field_name in ("start", "end"): val = getattr(item, field_name) if val is None: continue # Return just the date part of the value. Subtract 1 day from the date if this is the end field. This is # the inverse of what we do in .to_xml(). Convert to the local timezone before getting the date. - if field_name == 'end': + if field_name == "end": val -= datetime.timedelta(days=1) - tz = getattr(item, f'_{field_name}_timezone') + tz = getattr(item, f"_{field_name}_timezone") setattr(item, field_name, val.astimezone(tz).date()) return item
                                                @@ -1397,9 +1466,7 @@

                                                Methods

                                                def cancel(self, **kwargs):
                                                     return CancelCalendarItem(
                                                -        account=self.account,
                                                -        reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey),
                                                -        **kwargs
                                                +        account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs
                                                     ).send()
                                                @@ -1472,7 +1539,7 @@

                                                Methods

                                                value = getattr(self, field_name) tz = getattr(self, self.tz_field_for_field_name(field_name).name) value = EWSDateTime.combine(value, datetime.time(0, 0)).replace(tzinfo=tz) - if field_name == 'end': + if field_name == "end": value += datetime.timedelta(days=1) return value
                                                @@ -1553,7 +1620,7 @@

                                                Methods

                                                elem = super().to_xml(version=version) if not self.is_all_day: return elem - for field_name in ('start', 'end'): + for field_name in ("start", "end"): value = getattr(self, field_name) if value is None: continue @@ -1580,11 +1647,11 @@

                                                Methods

                                                meeting_tz_field, start_tz_field, end_tz_field = CalendarItem.timezone_fields() if self.account.version.build < EXCHANGE_2010: return meeting_tz_field - if field_name == 'start': + if field_name == "start": return start_tz_field - if field_name == 'end': + if field_name == "end": return end_tz_field - raise ValueError('Unsupported field_name') + raise ValueError("Unsupported field_name")
                                            @@ -1620,9 +1687,9 @@

                                            Inherited members

                                            class CancelCalendarItem(BaseReplyItem):
                                                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/cancelcalendaritem"""
                                             
                                            -    ELEMENT_NAME = 'CancelCalendarItem'
                                            -    author_idx = BaseReplyItem.FIELDS.index_by_name('author')
                                            -    FIELDS = BaseReplyItem.FIELDS[:author_idx] + BaseReplyItem.FIELDS[author_idx + 1:]
                                            + ELEMENT_NAME = "CancelCalendarItem" + author_idx = BaseReplyItem.FIELDS.index_by_name("author") + FIELDS = BaseReplyItem.FIELDS[:author_idx] + BaseReplyItem.FIELDS[author_idx + 1 :]

                                            Ancestors

                                              @@ -1676,7 +1743,7 @@

                                              Inherited members

                                              class DeclineItem(BaseMeetingReplyItem):
                                                   """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/declineitem"""
                                               
                                              -    ELEMENT_NAME = 'DeclineItem'
                                              + ELEMENT_NAME = "DeclineItem"

                                              Ancestors

                                                @@ -1728,7 +1795,7 @@

                                                Inherited members

                                                class MeetingCancellation(BaseMeetingItem):
                                                     """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/meetingcancellation"""
                                                 
                                                -    ELEMENT_NAME = 'MeetingCancellation'
                                                + ELEMENT_NAME = "MeetingCancellation"

                                                Ancestors

                                                  @@ -1783,7 +1850,7 @@

                                                  Inherited members

                                                  class MeetingMessage(BaseMeetingItem):
                                                       """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/meetingmessage"""
                                                   
                                                  -    ELEMENT_NAME = 'MeetingMessage'
                                                  + ELEMENT_NAME = "MeetingMessage"

                                                  Ancestors

                                                    @@ -1838,22 +1905,36 @@

                                                    Inherited members

                                                    class MeetingRequest(BaseMeetingItem, AcceptDeclineMixIn):
                                                         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/meetingrequest"""
                                                     
                                                    -    ELEMENT_NAME = 'MeetingRequest'
                                                    -
                                                    -    meeting_request_type = ChoiceField(field_uri='meetingRequest:MeetingRequestType', choices={
                                                    -        Choice('FullUpdate'), Choice('InformationalUpdate'), Choice('NewMeetingRequest'), Choice('None'),
                                                    -        Choice('Outdated'), Choice('PrincipalWantsCopy'), Choice('SilentUpdate')
                                                    -    }, default='None')
                                                    -    intended_free_busy_status = ChoiceField(field_uri='meetingRequest:IntendedFreeBusyStatus', choices={
                                                    -            Choice('Free'), Choice('Tentative'), Choice('Busy'), Choice('OOF'), Choice('NoData')
                                                    -    }, is_required=True, default='Busy')
                                                    +    ELEMENT_NAME = "MeetingRequest"
                                                    +
                                                    +    meeting_request_type = ChoiceField(
                                                    +        field_uri="meetingRequest:MeetingRequestType",
                                                    +        choices={
                                                    +            Choice("FullUpdate"),
                                                    +            Choice("InformationalUpdate"),
                                                    +            Choice("NewMeetingRequest"),
                                                    +            Choice("None"),
                                                    +            Choice("Outdated"),
                                                    +            Choice("PrincipalWantsCopy"),
                                                    +            Choice("SilentUpdate"),
                                                    +        },
                                                    +        default="None",
                                                    +    )
                                                    +    intended_free_busy_status = ChoiceField(
                                                    +        field_uri="meetingRequest:IntendedFreeBusyStatus",
                                                    +        choices={Choice("Free"), Choice("Tentative"), Choice("Busy"), Choice("OOF"), Choice("NoData")},
                                                    +        is_required=True,
                                                    +        default="Busy",
                                                    +    )
                                                     
                                                         # This element also has some fields from CalendarItem
                                                    -    start_idx = CalendarItem.FIELDS.index_by_name('start')
                                                    -    is_response_requested_idx = CalendarItem.FIELDS.index_by_name('is_response_requested')
                                                    -    FIELDS = BaseMeetingItem.FIELDS \
                                                    -        + CalendarItem.FIELDS[start_idx:is_response_requested_idx]\
                                                    -        + CalendarItem.FIELDS[is_response_requested_idx + 1:]
                                                    + start_idx = CalendarItem.FIELDS.index_by_name("start") + is_response_requested_idx = CalendarItem.FIELDS.index_by_name("is_response_requested") + FIELDS = ( + BaseMeetingItem.FIELDS + + CalendarItem.FIELDS[start_idx:is_response_requested_idx] + + CalendarItem.FIELDS[is_response_requested_idx + 1 :] + )

                                                    Ancestors

                                                      @@ -2072,12 +2153,10 @@

                                                      Inherited members

                                                      class MeetingResponse(BaseMeetingItem):
                                                           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/meetingresponse"""
                                                       
                                                      -    ELEMENT_NAME = 'MeetingResponse'
                                                      +    ELEMENT_NAME = "MeetingResponse"
                                                       
                                                      -    received_by = MailboxField(field_uri='message:ReceivedBy', is_read_only=True)
                                                      -    received_representing = MailboxField(field_uri='message:ReceivedRepresenting', is_read_only=True)
                                                      -    proposed_start = DateTimeField(field_uri='meeting:ProposedStart', supported_from=EXCHANGE_2013)
                                                      -    proposed_end = DateTimeField(field_uri='meeting:ProposedEnd', supported_from=EXCHANGE_2013)
                                                      + proposed_start = DateTimeField(field_uri="meeting:ProposedStart", supported_from=EXCHANGE_2013) + proposed_end = DateTimeField(field_uri="meeting:ProposedEnd", supported_from=EXCHANGE_2013)

                                                      Ancestors

                                                        @@ -2109,14 +2188,6 @@

                                                        Instance variables

                                                        -
                                                        var received_by
                                                        -
                                                        -
                                                        -
                                                        -
                                                        var received_representing
                                                        -
                                                        -
                                                        -

                                                        Inherited members

                                                          @@ -2155,7 +2226,7 @@

                                                          Inherited members

                                                          class TentativelyAcceptItem(BaseMeetingReplyItem):
                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/tentativelyacceptitem"""
                                                           
                                                          -    ELEMENT_NAME = 'TentativelyAcceptItem'
                                                          + ELEMENT_NAME = "TentativelyAcceptItem"

                                                          Ancestors

                                                        • diff --git a/docs/exchangelib/items/contact.html b/docs/exchangelib/items/contact.html index c29127b4..cd576c2f 100644 --- a/docs/exchangelib/items/contact.html +++ b/docs/exchangelib/items/contact.html @@ -29,16 +29,38 @@

                                                          Module exchangelib.items.contact

                                                          import datetime
                                                           import logging
                                                           
                                                          -from .item import Item
                                                          -from ..fields import BooleanField, Base64Field, TextField, ChoiceField, URIField, DateTimeBackedDateField, \
                                                          -    PhoneNumberField, EmailAddressesField, PhysicalAddressField, Choice, MemberListField, CharField, TextListField, \
                                                          -    EmailAddressField, IdElementField, EWSElementField, DateTimeField, EWSElementListField, \
                                                          -    BodyContentAttributedValueField, StringAttributedValueField, PhoneNumberAttributedValueField, \
                                                          -    PersonaPhoneNumberField, EmailAddressAttributedValueField, PostalAddressAttributedValueField, MailboxField, \
                                                          -    MailboxListField
                                                          -from ..properties import PersonaId, IdChangeKeyMixIn, CompleteName, Attribution, EmailAddress, Address, FolderId
                                                          +from ..fields import (
                                                          +    Base64Field,
                                                          +    BodyContentAttributedValueField,
                                                          +    BooleanField,
                                                          +    CharField,
                                                          +    Choice,
                                                          +    ChoiceField,
                                                          +    DateTimeBackedDateField,
                                                          +    DateTimeField,
                                                          +    EmailAddressAttributedValueField,
                                                          +    EmailAddressesField,
                                                          +    EmailAddressField,
                                                          +    EWSElementField,
                                                          +    EWSElementListField,
                                                          +    IdElementField,
                                                          +    MailboxField,
                                                          +    MailboxListField,
                                                          +    MemberListField,
                                                          +    PersonaPhoneNumberField,
                                                          +    PhoneNumberAttributedValueField,
                                                          +    PhoneNumberField,
                                                          +    PhysicalAddressField,
                                                          +    PostalAddressAttributedValueField,
                                                          +    StringAttributedValueField,
                                                          +    TextField,
                                                          +    TextListField,
                                                          +    URIField,
                                                          +)
                                                          +from ..properties import Address, Attribution, CompleteName, EmailAddress, FolderId, IdChangeKeyMixIn, PersonaId
                                                           from ..util import TNS
                                                           from ..version import EXCHANGE_2010, EXCHANGE_2010_SP2
                                                          +from .item import Item
                                                           
                                                           log = logging.getLogger(__name__)
                                                           
                                                          @@ -46,193 +68,219 @@ 

                                                          Module exchangelib.items.contact

                                                          class Contact(Item): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/contact""" - ELEMENT_NAME = 'Contact' + ELEMENT_NAME = "Contact" - file_as = TextField(field_uri='contacts:FileAs') - file_as_mapping = ChoiceField(field_uri='contacts:FileAsMapping', choices={ - Choice('None'), Choice('LastCommaFirst'), Choice('FirstSpaceLast'), Choice('Company'), - Choice('LastCommaFirstCompany'), Choice('CompanyLastFirst'), Choice('LastFirst'), - Choice('LastFirstCompany'), Choice('CompanyLastCommaFirst'), Choice('LastFirstSuffix'), - Choice('LastSpaceFirstCompany'), Choice('CompanyLastSpaceFirst'), Choice('LastSpaceFirst'), - Choice('DisplayName'), Choice('FirstName'), Choice('LastFirstMiddleSuffix'), Choice('LastName'), - Choice('Empty'), - }) - display_name = TextField(field_uri='contacts:DisplayName', is_required=True) - given_name = CharField(field_uri='contacts:GivenName') - initials = TextField(field_uri='contacts:Initials') - middle_name = CharField(field_uri='contacts:MiddleName') - nickname = TextField(field_uri='contacts:Nickname') - complete_name = EWSElementField(field_uri='contacts:CompleteName', value_cls=CompleteName, is_read_only=True) - company_name = TextField(field_uri='contacts:CompanyName') - email_addresses = EmailAddressesField(field_uri='contacts:EmailAddress') - physical_addresses = PhysicalAddressField(field_uri='contacts:PhysicalAddress') - phone_numbers = PhoneNumberField(field_uri='contacts:PhoneNumber') - assistant_name = TextField(field_uri='contacts:AssistantName') - birthday = DateTimeBackedDateField(field_uri='contacts:Birthday', default_time=datetime.time(11, 59)) - business_homepage = URIField(field_uri='contacts:BusinessHomePage') - children = TextListField(field_uri='contacts:Children') - companies = TextListField(field_uri='contacts:Companies', is_searchable=False) - contact_source = ChoiceField(field_uri='contacts:ContactSource', choices={ - Choice('Store'), Choice('ActiveDirectory') - }, is_read_only=True) - department = TextField(field_uri='contacts:Department') - generation = TextField(field_uri='contacts:Generation') - im_addresses = CharField(field_uri='contacts:ImAddresses', is_read_only=True) - job_title = TextField(field_uri='contacts:JobTitle') - manager = TextField(field_uri='contacts:Manager') - mileage = TextField(field_uri='contacts:Mileage') - office = TextField(field_uri='contacts:OfficeLocation') - postal_address_index = ChoiceField(field_uri='contacts:PostalAddressIndex', choices={ - Choice('Business'), Choice('Home'), Choice('Other'), Choice('None') - }, default='None', is_required_after_save=True) - profession = TextField(field_uri='contacts:Profession') - spouse_name = TextField(field_uri='contacts:SpouseName') - surname = CharField(field_uri='contacts:Surname') - wedding_anniversary = DateTimeBackedDateField(field_uri='contacts:WeddingAnniversary', - default_time=datetime.time(11, 59)) - has_picture = BooleanField(field_uri='contacts:HasPicture', supported_from=EXCHANGE_2010, is_read_only=True) - phonetic_full_name = TextField(field_uri='contacts:PhoneticFullName', supported_from=EXCHANGE_2010_SP2, - is_read_only=True) - phonetic_first_name = TextField(field_uri='contacts:PhoneticFirstName', supported_from=EXCHANGE_2010_SP2, - is_read_only=True) - phonetic_last_name = TextField(field_uri='contacts:PhoneticLastName', supported_from=EXCHANGE_2010_SP2, - is_read_only=True) - email_alias = EmailAddressField(field_uri='contacts:Alias', is_read_only=True, - supported_from=EXCHANGE_2010_SP2) + file_as = TextField(field_uri="contacts:FileAs") + file_as_mapping = ChoiceField( + field_uri="contacts:FileAsMapping", + choices={ + Choice("None"), + Choice("LastCommaFirst"), + Choice("FirstSpaceLast"), + Choice("Company"), + Choice("LastCommaFirstCompany"), + Choice("CompanyLastFirst"), + Choice("LastFirst"), + Choice("LastFirstCompany"), + Choice("CompanyLastCommaFirst"), + Choice("LastFirstSuffix"), + Choice("LastSpaceFirstCompany"), + Choice("CompanyLastSpaceFirst"), + Choice("LastSpaceFirst"), + Choice("DisplayName"), + Choice("FirstName"), + Choice("LastFirstMiddleSuffix"), + Choice("LastName"), + Choice("Empty"), + }, + ) + display_name = TextField(field_uri="contacts:DisplayName", is_required=True) + given_name = CharField(field_uri="contacts:GivenName") + initials = TextField(field_uri="contacts:Initials") + middle_name = CharField(field_uri="contacts:MiddleName") + nickname = TextField(field_uri="contacts:Nickname") + complete_name = EWSElementField(field_uri="contacts:CompleteName", value_cls=CompleteName, is_read_only=True) + company_name = TextField(field_uri="contacts:CompanyName") + email_addresses = EmailAddressesField(field_uri="contacts:EmailAddress") + physical_addresses = PhysicalAddressField(field_uri="contacts:PhysicalAddress") + phone_numbers = PhoneNumberField(field_uri="contacts:PhoneNumber") + assistant_name = TextField(field_uri="contacts:AssistantName") + birthday = DateTimeBackedDateField(field_uri="contacts:Birthday", default_time=datetime.time(11, 59)) + business_homepage = URIField(field_uri="contacts:BusinessHomePage") + children = TextListField(field_uri="contacts:Children") + companies = TextListField(field_uri="contacts:Companies", is_searchable=False) + contact_source = ChoiceField( + field_uri="contacts:ContactSource", choices={Choice("Store"), Choice("ActiveDirectory")}, is_read_only=True + ) + department = TextField(field_uri="contacts:Department") + generation = TextField(field_uri="contacts:Generation") + im_addresses = CharField(field_uri="contacts:ImAddresses", is_read_only=True) + job_title = TextField(field_uri="contacts:JobTitle") + manager = TextField(field_uri="contacts:Manager") + mileage = TextField(field_uri="contacts:Mileage") + office = TextField(field_uri="contacts:OfficeLocation") + postal_address_index = ChoiceField( + field_uri="contacts:PostalAddressIndex", + choices={Choice("Business"), Choice("Home"), Choice("Other"), Choice("None")}, + default="None", + is_required_after_save=True, + ) + profession = TextField(field_uri="contacts:Profession") + spouse_name = TextField(field_uri="contacts:SpouseName") + surname = CharField(field_uri="contacts:Surname") + wedding_anniversary = DateTimeBackedDateField( + field_uri="contacts:WeddingAnniversary", default_time=datetime.time(11, 59) + ) + has_picture = BooleanField(field_uri="contacts:HasPicture", supported_from=EXCHANGE_2010, is_read_only=True) + phonetic_full_name = TextField( + field_uri="contacts:PhoneticFullName", supported_from=EXCHANGE_2010_SP2, is_read_only=True + ) + phonetic_first_name = TextField( + field_uri="contacts:PhoneticFirstName", supported_from=EXCHANGE_2010_SP2, is_read_only=True + ) + phonetic_last_name = TextField( + field_uri="contacts:PhoneticLastName", supported_from=EXCHANGE_2010_SP2, is_read_only=True + ) + email_alias = EmailAddressField(field_uri="contacts:Alias", is_read_only=True, supported_from=EXCHANGE_2010_SP2) # 'notes' is documented in MSDN but apparently unused. Writing to it raises ErrorInvalidPropertyRequest. OWA # put entries into the 'notes' form field into the 'body' field. - notes = CharField(field_uri='contacts:Notes', supported_from=EXCHANGE_2010_SP2, is_read_only=True) + notes = CharField(field_uri="contacts:Notes", supported_from=EXCHANGE_2010_SP2, is_read_only=True) # 'photo' is documented in MSDN but apparently unused. Writing to it raises ErrorInvalidPropertyRequest. OWA # adds photos as FileAttachments on the contact item (with 'is_contact_photo=True'), which automatically flips # the 'has_picture' field. - photo = Base64Field(field_uri='contacts:Photo', supported_from=EXCHANGE_2010_SP2, is_read_only=True) - user_smime_certificate = Base64Field(field_uri='contacts:UserSMIMECertificate', supported_from=EXCHANGE_2010_SP2, - is_read_only=True) - ms_exchange_certificate = Base64Field(field_uri='contacts:MSExchangeCertificate', supported_from=EXCHANGE_2010_SP2, - is_read_only=True) - directory_id = TextField(field_uri='contacts:DirectoryId', supported_from=EXCHANGE_2010_SP2, is_read_only=True) - manager_mailbox = MailboxField(field_uri='contacts:ManagerMailbox', supported_from=EXCHANGE_2010_SP2, - is_read_only=True) - direct_reports = MailboxListField(field_uri='contacts:DirectReports', supported_from=EXCHANGE_2010_SP2, - is_read_only=True) + photo = Base64Field(field_uri="contacts:Photo", supported_from=EXCHANGE_2010_SP2, is_read_only=True) + user_smime_certificate = Base64Field( + field_uri="contacts:UserSMIMECertificate", supported_from=EXCHANGE_2010_SP2, is_read_only=True + ) + ms_exchange_certificate = Base64Field( + field_uri="contacts:MSExchangeCertificate", supported_from=EXCHANGE_2010_SP2, is_read_only=True + ) + directory_id = TextField(field_uri="contacts:DirectoryId", supported_from=EXCHANGE_2010_SP2, is_read_only=True) + manager_mailbox = MailboxField( + field_uri="contacts:ManagerMailbox", supported_from=EXCHANGE_2010_SP2, is_read_only=True + ) + direct_reports = MailboxListField( + field_uri="contacts:DirectReports", supported_from=EXCHANGE_2010_SP2, is_read_only=True + ) class Persona(IdChangeKeyMixIn): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/persona""" - ELEMENT_NAME = 'Persona' + ELEMENT_NAME = "Persona" ID_ELEMENT_CLS = PersonaId - _id = IdElementField(field_uri='persona:PersonaId', value_cls=ID_ELEMENT_CLS, namespace=TNS) - persona_type = CharField(field_uri='persona:PersonaType') - persona_object_type = TextField(field_uri='persona:PersonaObjectStatus') - creation_time = DateTimeField(field_uri='persona:CreationTime') - bodies = BodyContentAttributedValueField(field_uri='persona:Bodies') - display_name_first_last_sort_key = TextField(field_uri='persona:DisplayNameFirstLastSortKey') - display_name_last_first_sort_key = TextField(field_uri='persona:DisplayNameLastFirstSortKey') - company_sort_key = TextField(field_uri='persona:CompanyNameSortKey') - home_sort_key = TextField(field_uri='persona:HomeCitySortKey') - work_city_sort_key = TextField(field_uri='persona:WorkCitySortKey') - display_name_first_last_header = CharField(field_uri='persona:DisplayNameFirstLastHeader') - display_name_last_first_header = CharField(field_uri='persona:DisplayNameLastFirstHeader') - file_as_header = TextField(field_uri='persona:FileAsHeader') - display_name = CharField(field_uri='persona:DisplayName') - display_name_first_last = CharField(field_uri='persona:DisplayNameFirstLast') - display_name_last_first = CharField(field_uri='persona:DisplayNameLastFirst') - file_as = CharField(field_uri='persona:FileAs') - file_as_id = TextField(field_uri='persona:FileAsId') - display_name_prefix = CharField(field_uri='persona:DisplayNamePrefix') - given_name = CharField(field_uri='persona:GivenName') - middle_name = CharField(field_uri='persona:MiddleName') - surname = CharField(field_uri='persona:Surname') - generation = CharField(field_uri='persona:Generation') - nickname = TextField(field_uri='persona:Nickname') - yomi_company_name = TextField(field_uri='persona:YomiCompanyName') - yomi_first_name = TextField(field_uri='persona:YomiFirstName') - yomi_last_name = TextField(field_uri='persona:YomiLastName') - title = CharField(field_uri='persona:Title') - department = TextField(field_uri='persona:Department') - company_name = CharField(field_uri='persona:CompanyName') - email_address = EWSElementField(field_uri='persona:EmailAddress', value_cls=EmailAddress) - email_addresses = EWSElementListField(field_uri='persona:EmailAddresses', value_cls=Address) - PhoneNumber = PersonaPhoneNumberField(field_uri='persona:PhoneNumber') - im_address = CharField(field_uri='persona:ImAddress') - home_city = CharField(field_uri='persona:HomeCity') - work_city = CharField(field_uri='persona:WorkCity') - relevance_score = CharField(field_uri='persona:RelevanceScore') - folder_ids = EWSElementListField(field_uri='persona:FolderIds', value_cls=FolderId) - attributions = EWSElementListField(field_uri='persona:Attributions', value_cls=Attribution) - display_names = StringAttributedValueField(field_uri='persona:DisplayNames') - file_ases = StringAttributedValueField(field_uri='persona:FileAses') - file_as_ids = StringAttributedValueField(field_uri='persona:FileAsIds') - display_name_prefixes = StringAttributedValueField(field_uri='persona:DisplayNamePrefixes') - given_names = StringAttributedValueField(field_uri='persona:GivenNames') - middle_names = StringAttributedValueField(field_uri='persona:MiddleNames') - surnames = StringAttributedValueField(field_uri='persona:Surnames') - generations = StringAttributedValueField(field_uri='persona:Generations') - nicknames = StringAttributedValueField(field_uri='persona:Nicknames') - initials = StringAttributedValueField(field_uri='persona:Initials') - yomi_company_names = StringAttributedValueField(field_uri='persona:YomiCompanyNames') - yomi_first_names = StringAttributedValueField(field_uri='persona:YomiFirstNames') - yomi_last_names = StringAttributedValueField(field_uri='persona:YomiLastNames') - business_phone_numbers = PhoneNumberAttributedValueField(field_uri='persona:BusinessPhoneNumbers') - business_phone_numbers2 = PhoneNumberAttributedValueField(field_uri='persona:BusinessPhoneNumbers2') - home_phones = PhoneNumberAttributedValueField(field_uri='persona:HomePhones') - home_phones2 = PhoneNumberAttributedValueField(field_uri='persona:HomePhones2') - mobile_phones = PhoneNumberAttributedValueField(field_uri='persona:MobilePhones') - mobile_phones2 = PhoneNumberAttributedValueField(field_uri='persona:MobilePhones2') - assistant_phone_numbers = PhoneNumberAttributedValueField(field_uri='persona:AssistantPhoneNumbers') - callback_phones = PhoneNumberAttributedValueField(field_uri='persona:CallbackPhones') - car_phones = PhoneNumberAttributedValueField(field_uri='persona:CarPhones') - home_faxes = PhoneNumberAttributedValueField(field_uri='persona:HomeFaxes') - organization_main_phones = PhoneNumberAttributedValueField(field_uri='persona:OrganizationMainPhones') - other_faxes = PhoneNumberAttributedValueField(field_uri='persona:OtherFaxes') - other_telephones = PhoneNumberAttributedValueField(field_uri='persona:OtherTelephones') - other_phones2 = PhoneNumberAttributedValueField(field_uri='persona:OtherPhones2') - pagers = PhoneNumberAttributedValueField(field_uri='persona:Pagers') - radio_phones = PhoneNumberAttributedValueField(field_uri='persona:RadioPhones') - telex_numbers = PhoneNumberAttributedValueField(field_uri='persona:TelexNumbers') - tty_tdd_phone_numbers = PhoneNumberAttributedValueField(field_uri='persona:TTYTDDPhoneNumbers') - work_faxes = PhoneNumberAttributedValueField(field_uri='persona:WorkFaxes') - emails1 = EmailAddressAttributedValueField(field_uri='persona:Emails1') - emails2 = EmailAddressAttributedValueField(field_uri='persona:Emails2') - emails3 = EmailAddressAttributedValueField(field_uri='persona:Emails3') - business_home_pages = StringAttributedValueField(field_uri='persona:BusinessHomePages') - personal_home_pages = StringAttributedValueField(field_uri='persona:PersonalHomePages') - office_locations = StringAttributedValueField(field_uri='persona:OfficeLocations') - im_addresses = StringAttributedValueField(field_uri='persona:ImAddresses') - im_addresses2 = StringAttributedValueField(field_uri='persona:ImAddresses2') - im_addresses3 = StringAttributedValueField(field_uri='persona:ImAddresses3') - business_addresses = PostalAddressAttributedValueField(field_uri='persona:BusinessAddresses') - home_addresses = PostalAddressAttributedValueField(field_uri='persona:HomeAddresses') - other_addresses = PostalAddressAttributedValueField(field_uri='persona:OtherAddresses') - titles = StringAttributedValueField(field_uri='persona:Titles') - departments = StringAttributedValueField(field_uri='persona:Departments') - company_names = StringAttributedValueField(field_uri='persona:CompanyNames') - managers = StringAttributedValueField(field_uri='persona:Managers') - assistant_names = StringAttributedValueField(field_uri='persona:AssistantNames') - professions = StringAttributedValueField(field_uri='persona:Professions') - spouse_names = StringAttributedValueField(field_uri='persona:SpouseNames') - children = StringAttributedValueField(field_uri='persona:Children') - schools = StringAttributedValueField(field_uri='persona:Schools') - hobbies = StringAttributedValueField(field_uri='persona:Hobbies') - wedding_anniversaries = StringAttributedValueField(field_uri='persona:WeddingAnniversaries') - birthdays = StringAttributedValueField(field_uri='persona:Birthdays') - locations = StringAttributedValueField(field_uri='persona:Locations') - # ExtendedPropertyAttributedValueField('extended_properties', field_uri='persona:ExtendedProperties') + _id = IdElementField(field_uri="persona:PersonaId", value_cls=ID_ELEMENT_CLS, namespace=TNS) + persona_type = CharField(field_uri="persona:PersonaType") + persona_object_type = TextField(field_uri="persona:PersonaObjectStatus") + creation_time = DateTimeField(field_uri="persona:CreationTime") + bodies = BodyContentAttributedValueField(field_uri="persona:Bodies") + display_name_first_last_sort_key = TextField(field_uri="persona:DisplayNameFirstLastSortKey") + display_name_last_first_sort_key = TextField(field_uri="persona:DisplayNameLastFirstSortKey") + company_sort_key = TextField(field_uri="persona:CompanyNameSortKey") + home_sort_key = TextField(field_uri="persona:HomeCitySortKey") + work_city_sort_key = TextField(field_uri="persona:WorkCitySortKey") + display_name_first_last_header = CharField(field_uri="persona:DisplayNameFirstLastHeader") + display_name_last_first_header = CharField(field_uri="persona:DisplayNameLastFirstHeader") + file_as_header = TextField(field_uri="persona:FileAsHeader") + display_name = CharField(field_uri="persona:DisplayName") + display_name_first_last = CharField(field_uri="persona:DisplayNameFirstLast") + display_name_last_first = CharField(field_uri="persona:DisplayNameLastFirst") + file_as = CharField(field_uri="persona:FileAs") + file_as_id = TextField(field_uri="persona:FileAsId") + display_name_prefix = CharField(field_uri="persona:DisplayNamePrefix") + given_name = CharField(field_uri="persona:GivenName") + middle_name = CharField(field_uri="persona:MiddleName") + surname = CharField(field_uri="persona:Surname") + generation = CharField(field_uri="persona:Generation") + nickname = TextField(field_uri="persona:Nickname") + yomi_company_name = TextField(field_uri="persona:YomiCompanyName") + yomi_first_name = TextField(field_uri="persona:YomiFirstName") + yomi_last_name = TextField(field_uri="persona:YomiLastName") + title = CharField(field_uri="persona:Title") + department = TextField(field_uri="persona:Department") + company_name = CharField(field_uri="persona:CompanyName") + email_address = EWSElementField(field_uri="persona:EmailAddress", value_cls=EmailAddress) + email_addresses = EWSElementListField(field_uri="persona:EmailAddresses", value_cls=Address) + PhoneNumber = PersonaPhoneNumberField(field_uri="persona:PhoneNumber") + im_address = CharField(field_uri="persona:ImAddress") + home_city = CharField(field_uri="persona:HomeCity") + work_city = CharField(field_uri="persona:WorkCity") + relevance_score = CharField(field_uri="persona:RelevanceScore") + folder_ids = EWSElementListField(field_uri="persona:FolderIds", value_cls=FolderId) + attributions = EWSElementListField(field_uri="persona:Attributions", value_cls=Attribution) + display_names = StringAttributedValueField(field_uri="persona:DisplayNames") + file_ases = StringAttributedValueField(field_uri="persona:FileAses") + file_as_ids = StringAttributedValueField(field_uri="persona:FileAsIds") + display_name_prefixes = StringAttributedValueField(field_uri="persona:DisplayNamePrefixes") + given_names = StringAttributedValueField(field_uri="persona:GivenNames") + middle_names = StringAttributedValueField(field_uri="persona:MiddleNames") + surnames = StringAttributedValueField(field_uri="persona:Surnames") + generations = StringAttributedValueField(field_uri="persona:Generations") + nicknames = StringAttributedValueField(field_uri="persona:Nicknames") + initials = StringAttributedValueField(field_uri="persona:Initials") + yomi_company_names = StringAttributedValueField(field_uri="persona:YomiCompanyNames") + yomi_first_names = StringAttributedValueField(field_uri="persona:YomiFirstNames") + yomi_last_names = StringAttributedValueField(field_uri="persona:YomiLastNames") + business_phone_numbers = PhoneNumberAttributedValueField(field_uri="persona:BusinessPhoneNumbers") + business_phone_numbers2 = PhoneNumberAttributedValueField(field_uri="persona:BusinessPhoneNumbers2") + home_phones = PhoneNumberAttributedValueField(field_uri="persona:HomePhones") + home_phones2 = PhoneNumberAttributedValueField(field_uri="persona:HomePhones2") + mobile_phones = PhoneNumberAttributedValueField(field_uri="persona:MobilePhones") + mobile_phones2 = PhoneNumberAttributedValueField(field_uri="persona:MobilePhones2") + assistant_phone_numbers = PhoneNumberAttributedValueField(field_uri="persona:AssistantPhoneNumbers") + callback_phones = PhoneNumberAttributedValueField(field_uri="persona:CallbackPhones") + car_phones = PhoneNumberAttributedValueField(field_uri="persona:CarPhones") + home_faxes = PhoneNumberAttributedValueField(field_uri="persona:HomeFaxes") + organization_main_phones = PhoneNumberAttributedValueField(field_uri="persona:OrganizationMainPhones") + other_faxes = PhoneNumberAttributedValueField(field_uri="persona:OtherFaxes") + other_telephones = PhoneNumberAttributedValueField(field_uri="persona:OtherTelephones") + other_phones2 = PhoneNumberAttributedValueField(field_uri="persona:OtherPhones2") + pagers = PhoneNumberAttributedValueField(field_uri="persona:Pagers") + radio_phones = PhoneNumberAttributedValueField(field_uri="persona:RadioPhones") + telex_numbers = PhoneNumberAttributedValueField(field_uri="persona:TelexNumbers") + tty_tdd_phone_numbers = PhoneNumberAttributedValueField(field_uri="persona:TTYTDDPhoneNumbers") + work_faxes = PhoneNumberAttributedValueField(field_uri="persona:WorkFaxes") + emails1 = EmailAddressAttributedValueField(field_uri="persona:Emails1") + emails2 = EmailAddressAttributedValueField(field_uri="persona:Emails2") + emails3 = EmailAddressAttributedValueField(field_uri="persona:Emails3") + business_home_pages = StringAttributedValueField(field_uri="persona:BusinessHomePages") + personal_home_pages = StringAttributedValueField(field_uri="persona:PersonalHomePages") + office_locations = StringAttributedValueField(field_uri="persona:OfficeLocations") + im_addresses = StringAttributedValueField(field_uri="persona:ImAddresses") + im_addresses2 = StringAttributedValueField(field_uri="persona:ImAddresses2") + im_addresses3 = StringAttributedValueField(field_uri="persona:ImAddresses3") + business_addresses = PostalAddressAttributedValueField(field_uri="persona:BusinessAddresses") + home_addresses = PostalAddressAttributedValueField(field_uri="persona:HomeAddresses") + other_addresses = PostalAddressAttributedValueField(field_uri="persona:OtherAddresses") + titles = StringAttributedValueField(field_uri="persona:Titles") + departments = StringAttributedValueField(field_uri="persona:Departments") + company_names = StringAttributedValueField(field_uri="persona:CompanyNames") + managers = StringAttributedValueField(field_uri="persona:Managers") + assistant_names = StringAttributedValueField(field_uri="persona:AssistantNames") + professions = StringAttributedValueField(field_uri="persona:Professions") + spouse_names = StringAttributedValueField(field_uri="persona:SpouseNames") + children = StringAttributedValueField(field_uri="persona:Children") + schools = StringAttributedValueField(field_uri="persona:Schools") + hobbies = StringAttributedValueField(field_uri="persona:Hobbies") + wedding_anniversaries = StringAttributedValueField(field_uri="persona:WeddingAnniversaries") + birthdays = StringAttributedValueField(field_uri="persona:Birthdays") + locations = StringAttributedValueField(field_uri="persona:Locations") + # This class has an additional field of type "ExtendedPropertyAttributedValueField" and + # field_uri 'persona:ExtendedProperties' class DistributionList(Item): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/distributionlist""" - ELEMENT_NAME = 'DistributionList' + ELEMENT_NAME = "DistributionList" - display_name = CharField(field_uri='contacts:DisplayName', is_required=True) - file_as = CharField(field_uri='contacts:FileAs', is_read_only=True) - contact_source = ChoiceField(field_uri='contacts:ContactSource', choices={ - Choice('Store'), Choice('ActiveDirectory') - }, is_read_only=True) - members = MemberListField(field_uri='distributionlist:Members')
                                                          + display_name = CharField(field_uri="contacts:DisplayName", is_required=True) + file_as = CharField(field_uri="contacts:FileAs", is_read_only=True) + contact_source = ChoiceField( + field_uri="contacts:ContactSource", choices={Choice("Store"), Choice("ActiveDirectory")}, is_read_only=True + ) + members = MemberListField(field_uri="distributionlist:Members")
                                                          @@ -262,75 +310,100 @@

                                                          Classes

                                                          class Contact(Item):
                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/contact"""
                                                           
                                                          -    ELEMENT_NAME = 'Contact'
                                                          +    ELEMENT_NAME = "Contact"
                                                           
                                                          -    file_as = TextField(field_uri='contacts:FileAs')
                                                          -    file_as_mapping = ChoiceField(field_uri='contacts:FileAsMapping', choices={
                                                          -        Choice('None'), Choice('LastCommaFirst'), Choice('FirstSpaceLast'), Choice('Company'),
                                                          -        Choice('LastCommaFirstCompany'), Choice('CompanyLastFirst'), Choice('LastFirst'),
                                                          -        Choice('LastFirstCompany'), Choice('CompanyLastCommaFirst'), Choice('LastFirstSuffix'),
                                                          -        Choice('LastSpaceFirstCompany'), Choice('CompanyLastSpaceFirst'), Choice('LastSpaceFirst'),
                                                          -        Choice('DisplayName'), Choice('FirstName'), Choice('LastFirstMiddleSuffix'), Choice('LastName'),
                                                          -        Choice('Empty'),
                                                          -    })
                                                          -    display_name = TextField(field_uri='contacts:DisplayName', is_required=True)
                                                          -    given_name = CharField(field_uri='contacts:GivenName')
                                                          -    initials = TextField(field_uri='contacts:Initials')
                                                          -    middle_name = CharField(field_uri='contacts:MiddleName')
                                                          -    nickname = TextField(field_uri='contacts:Nickname')
                                                          -    complete_name = EWSElementField(field_uri='contacts:CompleteName', value_cls=CompleteName, is_read_only=True)
                                                          -    company_name = TextField(field_uri='contacts:CompanyName')
                                                          -    email_addresses = EmailAddressesField(field_uri='contacts:EmailAddress')
                                                          -    physical_addresses = PhysicalAddressField(field_uri='contacts:PhysicalAddress')
                                                          -    phone_numbers = PhoneNumberField(field_uri='contacts:PhoneNumber')
                                                          -    assistant_name = TextField(field_uri='contacts:AssistantName')
                                                          -    birthday = DateTimeBackedDateField(field_uri='contacts:Birthday', default_time=datetime.time(11, 59))
                                                          -    business_homepage = URIField(field_uri='contacts:BusinessHomePage')
                                                          -    children = TextListField(field_uri='contacts:Children')
                                                          -    companies = TextListField(field_uri='contacts:Companies', is_searchable=False)
                                                          -    contact_source = ChoiceField(field_uri='contacts:ContactSource', choices={
                                                          -        Choice('Store'), Choice('ActiveDirectory')
                                                          -    }, is_read_only=True)
                                                          -    department = TextField(field_uri='contacts:Department')
                                                          -    generation = TextField(field_uri='contacts:Generation')
                                                          -    im_addresses = CharField(field_uri='contacts:ImAddresses', is_read_only=True)
                                                          -    job_title = TextField(field_uri='contacts:JobTitle')
                                                          -    manager = TextField(field_uri='contacts:Manager')
                                                          -    mileage = TextField(field_uri='contacts:Mileage')
                                                          -    office = TextField(field_uri='contacts:OfficeLocation')
                                                          -    postal_address_index = ChoiceField(field_uri='contacts:PostalAddressIndex', choices={
                                                          -        Choice('Business'), Choice('Home'), Choice('Other'), Choice('None')
                                                          -    }, default='None', is_required_after_save=True)
                                                          -    profession = TextField(field_uri='contacts:Profession')
                                                          -    spouse_name = TextField(field_uri='contacts:SpouseName')
                                                          -    surname = CharField(field_uri='contacts:Surname')
                                                          -    wedding_anniversary = DateTimeBackedDateField(field_uri='contacts:WeddingAnniversary',
                                                          -                                                  default_time=datetime.time(11, 59))
                                                          -    has_picture = BooleanField(field_uri='contacts:HasPicture', supported_from=EXCHANGE_2010, is_read_only=True)
                                                          -    phonetic_full_name = TextField(field_uri='contacts:PhoneticFullName', supported_from=EXCHANGE_2010_SP2,
                                                          -                                   is_read_only=True)
                                                          -    phonetic_first_name = TextField(field_uri='contacts:PhoneticFirstName', supported_from=EXCHANGE_2010_SP2,
                                                          -                                    is_read_only=True)
                                                          -    phonetic_last_name = TextField(field_uri='contacts:PhoneticLastName', supported_from=EXCHANGE_2010_SP2,
                                                          -                                   is_read_only=True)
                                                          -    email_alias = EmailAddressField(field_uri='contacts:Alias', is_read_only=True,
                                                          -                                    supported_from=EXCHANGE_2010_SP2)
                                                          +    file_as = TextField(field_uri="contacts:FileAs")
                                                          +    file_as_mapping = ChoiceField(
                                                          +        field_uri="contacts:FileAsMapping",
                                                          +        choices={
                                                          +            Choice("None"),
                                                          +            Choice("LastCommaFirst"),
                                                          +            Choice("FirstSpaceLast"),
                                                          +            Choice("Company"),
                                                          +            Choice("LastCommaFirstCompany"),
                                                          +            Choice("CompanyLastFirst"),
                                                          +            Choice("LastFirst"),
                                                          +            Choice("LastFirstCompany"),
                                                          +            Choice("CompanyLastCommaFirst"),
                                                          +            Choice("LastFirstSuffix"),
                                                          +            Choice("LastSpaceFirstCompany"),
                                                          +            Choice("CompanyLastSpaceFirst"),
                                                          +            Choice("LastSpaceFirst"),
                                                          +            Choice("DisplayName"),
                                                          +            Choice("FirstName"),
                                                          +            Choice("LastFirstMiddleSuffix"),
                                                          +            Choice("LastName"),
                                                          +            Choice("Empty"),
                                                          +        },
                                                          +    )
                                                          +    display_name = TextField(field_uri="contacts:DisplayName", is_required=True)
                                                          +    given_name = CharField(field_uri="contacts:GivenName")
                                                          +    initials = TextField(field_uri="contacts:Initials")
                                                          +    middle_name = CharField(field_uri="contacts:MiddleName")
                                                          +    nickname = TextField(field_uri="contacts:Nickname")
                                                          +    complete_name = EWSElementField(field_uri="contacts:CompleteName", value_cls=CompleteName, is_read_only=True)
                                                          +    company_name = TextField(field_uri="contacts:CompanyName")
                                                          +    email_addresses = EmailAddressesField(field_uri="contacts:EmailAddress")
                                                          +    physical_addresses = PhysicalAddressField(field_uri="contacts:PhysicalAddress")
                                                          +    phone_numbers = PhoneNumberField(field_uri="contacts:PhoneNumber")
                                                          +    assistant_name = TextField(field_uri="contacts:AssistantName")
                                                          +    birthday = DateTimeBackedDateField(field_uri="contacts:Birthday", default_time=datetime.time(11, 59))
                                                          +    business_homepage = URIField(field_uri="contacts:BusinessHomePage")
                                                          +    children = TextListField(field_uri="contacts:Children")
                                                          +    companies = TextListField(field_uri="contacts:Companies", is_searchable=False)
                                                          +    contact_source = ChoiceField(
                                                          +        field_uri="contacts:ContactSource", choices={Choice("Store"), Choice("ActiveDirectory")}, is_read_only=True
                                                          +    )
                                                          +    department = TextField(field_uri="contacts:Department")
                                                          +    generation = TextField(field_uri="contacts:Generation")
                                                          +    im_addresses = CharField(field_uri="contacts:ImAddresses", is_read_only=True)
                                                          +    job_title = TextField(field_uri="contacts:JobTitle")
                                                          +    manager = TextField(field_uri="contacts:Manager")
                                                          +    mileage = TextField(field_uri="contacts:Mileage")
                                                          +    office = TextField(field_uri="contacts:OfficeLocation")
                                                          +    postal_address_index = ChoiceField(
                                                          +        field_uri="contacts:PostalAddressIndex",
                                                          +        choices={Choice("Business"), Choice("Home"), Choice("Other"), Choice("None")},
                                                          +        default="None",
                                                          +        is_required_after_save=True,
                                                          +    )
                                                          +    profession = TextField(field_uri="contacts:Profession")
                                                          +    spouse_name = TextField(field_uri="contacts:SpouseName")
                                                          +    surname = CharField(field_uri="contacts:Surname")
                                                          +    wedding_anniversary = DateTimeBackedDateField(
                                                          +        field_uri="contacts:WeddingAnniversary", default_time=datetime.time(11, 59)
                                                          +    )
                                                          +    has_picture = BooleanField(field_uri="contacts:HasPicture", supported_from=EXCHANGE_2010, is_read_only=True)
                                                          +    phonetic_full_name = TextField(
                                                          +        field_uri="contacts:PhoneticFullName", supported_from=EXCHANGE_2010_SP2, is_read_only=True
                                                          +    )
                                                          +    phonetic_first_name = TextField(
                                                          +        field_uri="contacts:PhoneticFirstName", supported_from=EXCHANGE_2010_SP2, is_read_only=True
                                                          +    )
                                                          +    phonetic_last_name = TextField(
                                                          +        field_uri="contacts:PhoneticLastName", supported_from=EXCHANGE_2010_SP2, is_read_only=True
                                                          +    )
                                                          +    email_alias = EmailAddressField(field_uri="contacts:Alias", is_read_only=True, supported_from=EXCHANGE_2010_SP2)
                                                               # 'notes' is documented in MSDN but apparently unused. Writing to it raises ErrorInvalidPropertyRequest. OWA
                                                               # put entries into the 'notes' form field into the 'body' field.
                                                          -    notes = CharField(field_uri='contacts:Notes', supported_from=EXCHANGE_2010_SP2, is_read_only=True)
                                                          +    notes = CharField(field_uri="contacts:Notes", supported_from=EXCHANGE_2010_SP2, is_read_only=True)
                                                               # 'photo' is documented in MSDN but apparently unused. Writing to it raises ErrorInvalidPropertyRequest. OWA
                                                               # adds photos as FileAttachments on the contact item (with 'is_contact_photo=True'), which automatically flips
                                                               # the 'has_picture' field.
                                                          -    photo = Base64Field(field_uri='contacts:Photo', supported_from=EXCHANGE_2010_SP2, is_read_only=True)
                                                          -    user_smime_certificate = Base64Field(field_uri='contacts:UserSMIMECertificate', supported_from=EXCHANGE_2010_SP2,
                                                          -                                         is_read_only=True)
                                                          -    ms_exchange_certificate = Base64Field(field_uri='contacts:MSExchangeCertificate', supported_from=EXCHANGE_2010_SP2,
                                                          -                                          is_read_only=True)
                                                          -    directory_id = TextField(field_uri='contacts:DirectoryId', supported_from=EXCHANGE_2010_SP2, is_read_only=True)
                                                          -    manager_mailbox = MailboxField(field_uri='contacts:ManagerMailbox', supported_from=EXCHANGE_2010_SP2,
                                                          -                                   is_read_only=True)
                                                          -    direct_reports = MailboxListField(field_uri='contacts:DirectReports', supported_from=EXCHANGE_2010_SP2,
                                                          -                                      is_read_only=True)
                                                          + photo = Base64Field(field_uri="contacts:Photo", supported_from=EXCHANGE_2010_SP2, is_read_only=True) + user_smime_certificate = Base64Field( + field_uri="contacts:UserSMIMECertificate", supported_from=EXCHANGE_2010_SP2, is_read_only=True + ) + ms_exchange_certificate = Base64Field( + field_uri="contacts:MSExchangeCertificate", supported_from=EXCHANGE_2010_SP2, is_read_only=True + ) + directory_id = TextField(field_uri="contacts:DirectoryId", supported_from=EXCHANGE_2010_SP2, is_read_only=True) + manager_mailbox = MailboxField( + field_uri="contacts:ManagerMailbox", supported_from=EXCHANGE_2010_SP2, is_read_only=True + ) + direct_reports = MailboxListField( + field_uri="contacts:DirectReports", supported_from=EXCHANGE_2010_SP2, is_read_only=True + )

                                                          Ancestors

                                                            @@ -559,14 +632,14 @@

                                                            Inherited members

                                                            class DistributionList(Item):
                                                                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/distributionlist"""
                                                             
                                                            -    ELEMENT_NAME = 'DistributionList'
                                                            +    ELEMENT_NAME = "DistributionList"
                                                             
                                                            -    display_name = CharField(field_uri='contacts:DisplayName', is_required=True)
                                                            -    file_as = CharField(field_uri='contacts:FileAs', is_read_only=True)
                                                            -    contact_source = ChoiceField(field_uri='contacts:ContactSource', choices={
                                                            -        Choice('Store'), Choice('ActiveDirectory')
                                                            -    }, is_read_only=True)
                                                            -    members = MemberListField(field_uri='distributionlist:Members')
                                                            + display_name = CharField(field_uri="contacts:DisplayName", is_required=True) + file_as = CharField(field_uri="contacts:FileAs", is_read_only=True) + contact_source = ChoiceField( + field_uri="contacts:ContactSource", choices={Choice("Store"), Choice("ActiveDirectory")}, is_read_only=True + ) + members = MemberListField(field_uri="distributionlist:Members")

                                                            Ancestors

                                                              @@ -638,105 +711,105 @@

                                                              Inherited members

                                                              class Persona(IdChangeKeyMixIn):
                                                                   """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/persona"""
                                                               
                                                              -    ELEMENT_NAME = 'Persona'
                                                              +    ELEMENT_NAME = "Persona"
                                                                   ID_ELEMENT_CLS = PersonaId
                                                               
                                                              -    _id = IdElementField(field_uri='persona:PersonaId', value_cls=ID_ELEMENT_CLS, namespace=TNS)
                                                              -    persona_type = CharField(field_uri='persona:PersonaType')
                                                              -    persona_object_type = TextField(field_uri='persona:PersonaObjectStatus')
                                                              -    creation_time = DateTimeField(field_uri='persona:CreationTime')
                                                              -    bodies = BodyContentAttributedValueField(field_uri='persona:Bodies')
                                                              -    display_name_first_last_sort_key = TextField(field_uri='persona:DisplayNameFirstLastSortKey')
                                                              -    display_name_last_first_sort_key = TextField(field_uri='persona:DisplayNameLastFirstSortKey')
                                                              -    company_sort_key = TextField(field_uri='persona:CompanyNameSortKey')
                                                              -    home_sort_key = TextField(field_uri='persona:HomeCitySortKey')
                                                              -    work_city_sort_key = TextField(field_uri='persona:WorkCitySortKey')
                                                              -    display_name_first_last_header = CharField(field_uri='persona:DisplayNameFirstLastHeader')
                                                              -    display_name_last_first_header = CharField(field_uri='persona:DisplayNameLastFirstHeader')
                                                              -    file_as_header = TextField(field_uri='persona:FileAsHeader')
                                                              -    display_name = CharField(field_uri='persona:DisplayName')
                                                              -    display_name_first_last = CharField(field_uri='persona:DisplayNameFirstLast')
                                                              -    display_name_last_first = CharField(field_uri='persona:DisplayNameLastFirst')
                                                              -    file_as = CharField(field_uri='persona:FileAs')
                                                              -    file_as_id = TextField(field_uri='persona:FileAsId')
                                                              -    display_name_prefix = CharField(field_uri='persona:DisplayNamePrefix')
                                                              -    given_name = CharField(field_uri='persona:GivenName')
                                                              -    middle_name = CharField(field_uri='persona:MiddleName')
                                                              -    surname = CharField(field_uri='persona:Surname')
                                                              -    generation = CharField(field_uri='persona:Generation')
                                                              -    nickname = TextField(field_uri='persona:Nickname')
                                                              -    yomi_company_name = TextField(field_uri='persona:YomiCompanyName')
                                                              -    yomi_first_name = TextField(field_uri='persona:YomiFirstName')
                                                              -    yomi_last_name = TextField(field_uri='persona:YomiLastName')
                                                              -    title = CharField(field_uri='persona:Title')
                                                              -    department = TextField(field_uri='persona:Department')
                                                              -    company_name = CharField(field_uri='persona:CompanyName')
                                                              -    email_address = EWSElementField(field_uri='persona:EmailAddress', value_cls=EmailAddress)
                                                              -    email_addresses = EWSElementListField(field_uri='persona:EmailAddresses', value_cls=Address)
                                                              -    PhoneNumber = PersonaPhoneNumberField(field_uri='persona:PhoneNumber')
                                                              -    im_address = CharField(field_uri='persona:ImAddress')
                                                              -    home_city = CharField(field_uri='persona:HomeCity')
                                                              -    work_city = CharField(field_uri='persona:WorkCity')
                                                              -    relevance_score = CharField(field_uri='persona:RelevanceScore')
                                                              -    folder_ids = EWSElementListField(field_uri='persona:FolderIds', value_cls=FolderId)
                                                              -    attributions = EWSElementListField(field_uri='persona:Attributions', value_cls=Attribution)
                                                              -    display_names = StringAttributedValueField(field_uri='persona:DisplayNames')
                                                              -    file_ases = StringAttributedValueField(field_uri='persona:FileAses')
                                                              -    file_as_ids = StringAttributedValueField(field_uri='persona:FileAsIds')
                                                              -    display_name_prefixes = StringAttributedValueField(field_uri='persona:DisplayNamePrefixes')
                                                              -    given_names = StringAttributedValueField(field_uri='persona:GivenNames')
                                                              -    middle_names = StringAttributedValueField(field_uri='persona:MiddleNames')
                                                              -    surnames = StringAttributedValueField(field_uri='persona:Surnames')
                                                              -    generations = StringAttributedValueField(field_uri='persona:Generations')
                                                              -    nicknames = StringAttributedValueField(field_uri='persona:Nicknames')
                                                              -    initials = StringAttributedValueField(field_uri='persona:Initials')
                                                              -    yomi_company_names = StringAttributedValueField(field_uri='persona:YomiCompanyNames')
                                                              -    yomi_first_names = StringAttributedValueField(field_uri='persona:YomiFirstNames')
                                                              -    yomi_last_names = StringAttributedValueField(field_uri='persona:YomiLastNames')
                                                              -    business_phone_numbers = PhoneNumberAttributedValueField(field_uri='persona:BusinessPhoneNumbers')
                                                              -    business_phone_numbers2 = PhoneNumberAttributedValueField(field_uri='persona:BusinessPhoneNumbers2')
                                                              -    home_phones = PhoneNumberAttributedValueField(field_uri='persona:HomePhones')
                                                              -    home_phones2 = PhoneNumberAttributedValueField(field_uri='persona:HomePhones2')
                                                              -    mobile_phones = PhoneNumberAttributedValueField(field_uri='persona:MobilePhones')
                                                              -    mobile_phones2 = PhoneNumberAttributedValueField(field_uri='persona:MobilePhones2')
                                                              -    assistant_phone_numbers = PhoneNumberAttributedValueField(field_uri='persona:AssistantPhoneNumbers')
                                                              -    callback_phones = PhoneNumberAttributedValueField(field_uri='persona:CallbackPhones')
                                                              -    car_phones = PhoneNumberAttributedValueField(field_uri='persona:CarPhones')
                                                              -    home_faxes = PhoneNumberAttributedValueField(field_uri='persona:HomeFaxes')
                                                              -    organization_main_phones = PhoneNumberAttributedValueField(field_uri='persona:OrganizationMainPhones')
                                                              -    other_faxes = PhoneNumberAttributedValueField(field_uri='persona:OtherFaxes')
                                                              -    other_telephones = PhoneNumberAttributedValueField(field_uri='persona:OtherTelephones')
                                                              -    other_phones2 = PhoneNumberAttributedValueField(field_uri='persona:OtherPhones2')
                                                              -    pagers = PhoneNumberAttributedValueField(field_uri='persona:Pagers')
                                                              -    radio_phones = PhoneNumberAttributedValueField(field_uri='persona:RadioPhones')
                                                              -    telex_numbers = PhoneNumberAttributedValueField(field_uri='persona:TelexNumbers')
                                                              -    tty_tdd_phone_numbers = PhoneNumberAttributedValueField(field_uri='persona:TTYTDDPhoneNumbers')
                                                              -    work_faxes = PhoneNumberAttributedValueField(field_uri='persona:WorkFaxes')
                                                              -    emails1 = EmailAddressAttributedValueField(field_uri='persona:Emails1')
                                                              -    emails2 = EmailAddressAttributedValueField(field_uri='persona:Emails2')
                                                              -    emails3 = EmailAddressAttributedValueField(field_uri='persona:Emails3')
                                                              -    business_home_pages = StringAttributedValueField(field_uri='persona:BusinessHomePages')
                                                              -    personal_home_pages = StringAttributedValueField(field_uri='persona:PersonalHomePages')
                                                              -    office_locations = StringAttributedValueField(field_uri='persona:OfficeLocations')
                                                              -    im_addresses = StringAttributedValueField(field_uri='persona:ImAddresses')
                                                              -    im_addresses2 = StringAttributedValueField(field_uri='persona:ImAddresses2')
                                                              -    im_addresses3 = StringAttributedValueField(field_uri='persona:ImAddresses3')
                                                              -    business_addresses = PostalAddressAttributedValueField(field_uri='persona:BusinessAddresses')
                                                              -    home_addresses = PostalAddressAttributedValueField(field_uri='persona:HomeAddresses')
                                                              -    other_addresses = PostalAddressAttributedValueField(field_uri='persona:OtherAddresses')
                                                              -    titles = StringAttributedValueField(field_uri='persona:Titles')
                                                              -    departments = StringAttributedValueField(field_uri='persona:Departments')
                                                              -    company_names = StringAttributedValueField(field_uri='persona:CompanyNames')
                                                              -    managers = StringAttributedValueField(field_uri='persona:Managers')
                                                              -    assistant_names = StringAttributedValueField(field_uri='persona:AssistantNames')
                                                              -    professions = StringAttributedValueField(field_uri='persona:Professions')
                                                              -    spouse_names = StringAttributedValueField(field_uri='persona:SpouseNames')
                                                              -    children = StringAttributedValueField(field_uri='persona:Children')
                                                              -    schools = StringAttributedValueField(field_uri='persona:Schools')
                                                              -    hobbies = StringAttributedValueField(field_uri='persona:Hobbies')
                                                              -    wedding_anniversaries = StringAttributedValueField(field_uri='persona:WeddingAnniversaries')
                                                              -    birthdays = StringAttributedValueField(field_uri='persona:Birthdays')
                                                              -    locations = StringAttributedValueField(field_uri='persona:Locations')
                                                              + _id = IdElementField(field_uri="persona:PersonaId", value_cls=ID_ELEMENT_CLS, namespace=TNS) + persona_type = CharField(field_uri="persona:PersonaType") + persona_object_type = TextField(field_uri="persona:PersonaObjectStatus") + creation_time = DateTimeField(field_uri="persona:CreationTime") + bodies = BodyContentAttributedValueField(field_uri="persona:Bodies") + display_name_first_last_sort_key = TextField(field_uri="persona:DisplayNameFirstLastSortKey") + display_name_last_first_sort_key = TextField(field_uri="persona:DisplayNameLastFirstSortKey") + company_sort_key = TextField(field_uri="persona:CompanyNameSortKey") + home_sort_key = TextField(field_uri="persona:HomeCitySortKey") + work_city_sort_key = TextField(field_uri="persona:WorkCitySortKey") + display_name_first_last_header = CharField(field_uri="persona:DisplayNameFirstLastHeader") + display_name_last_first_header = CharField(field_uri="persona:DisplayNameLastFirstHeader") + file_as_header = TextField(field_uri="persona:FileAsHeader") + display_name = CharField(field_uri="persona:DisplayName") + display_name_first_last = CharField(field_uri="persona:DisplayNameFirstLast") + display_name_last_first = CharField(field_uri="persona:DisplayNameLastFirst") + file_as = CharField(field_uri="persona:FileAs") + file_as_id = TextField(field_uri="persona:FileAsId") + display_name_prefix = CharField(field_uri="persona:DisplayNamePrefix") + given_name = CharField(field_uri="persona:GivenName") + middle_name = CharField(field_uri="persona:MiddleName") + surname = CharField(field_uri="persona:Surname") + generation = CharField(field_uri="persona:Generation") + nickname = TextField(field_uri="persona:Nickname") + yomi_company_name = TextField(field_uri="persona:YomiCompanyName") + yomi_first_name = TextField(field_uri="persona:YomiFirstName") + yomi_last_name = TextField(field_uri="persona:YomiLastName") + title = CharField(field_uri="persona:Title") + department = TextField(field_uri="persona:Department") + company_name = CharField(field_uri="persona:CompanyName") + email_address = EWSElementField(field_uri="persona:EmailAddress", value_cls=EmailAddress) + email_addresses = EWSElementListField(field_uri="persona:EmailAddresses", value_cls=Address) + PhoneNumber = PersonaPhoneNumberField(field_uri="persona:PhoneNumber") + im_address = CharField(field_uri="persona:ImAddress") + home_city = CharField(field_uri="persona:HomeCity") + work_city = CharField(field_uri="persona:WorkCity") + relevance_score = CharField(field_uri="persona:RelevanceScore") + folder_ids = EWSElementListField(field_uri="persona:FolderIds", value_cls=FolderId) + attributions = EWSElementListField(field_uri="persona:Attributions", value_cls=Attribution) + display_names = StringAttributedValueField(field_uri="persona:DisplayNames") + file_ases = StringAttributedValueField(field_uri="persona:FileAses") + file_as_ids = StringAttributedValueField(field_uri="persona:FileAsIds") + display_name_prefixes = StringAttributedValueField(field_uri="persona:DisplayNamePrefixes") + given_names = StringAttributedValueField(field_uri="persona:GivenNames") + middle_names = StringAttributedValueField(field_uri="persona:MiddleNames") + surnames = StringAttributedValueField(field_uri="persona:Surnames") + generations = StringAttributedValueField(field_uri="persona:Generations") + nicknames = StringAttributedValueField(field_uri="persona:Nicknames") + initials = StringAttributedValueField(field_uri="persona:Initials") + yomi_company_names = StringAttributedValueField(field_uri="persona:YomiCompanyNames") + yomi_first_names = StringAttributedValueField(field_uri="persona:YomiFirstNames") + yomi_last_names = StringAttributedValueField(field_uri="persona:YomiLastNames") + business_phone_numbers = PhoneNumberAttributedValueField(field_uri="persona:BusinessPhoneNumbers") + business_phone_numbers2 = PhoneNumberAttributedValueField(field_uri="persona:BusinessPhoneNumbers2") + home_phones = PhoneNumberAttributedValueField(field_uri="persona:HomePhones") + home_phones2 = PhoneNumberAttributedValueField(field_uri="persona:HomePhones2") + mobile_phones = PhoneNumberAttributedValueField(field_uri="persona:MobilePhones") + mobile_phones2 = PhoneNumberAttributedValueField(field_uri="persona:MobilePhones2") + assistant_phone_numbers = PhoneNumberAttributedValueField(field_uri="persona:AssistantPhoneNumbers") + callback_phones = PhoneNumberAttributedValueField(field_uri="persona:CallbackPhones") + car_phones = PhoneNumberAttributedValueField(field_uri="persona:CarPhones") + home_faxes = PhoneNumberAttributedValueField(field_uri="persona:HomeFaxes") + organization_main_phones = PhoneNumberAttributedValueField(field_uri="persona:OrganizationMainPhones") + other_faxes = PhoneNumberAttributedValueField(field_uri="persona:OtherFaxes") + other_telephones = PhoneNumberAttributedValueField(field_uri="persona:OtherTelephones") + other_phones2 = PhoneNumberAttributedValueField(field_uri="persona:OtherPhones2") + pagers = PhoneNumberAttributedValueField(field_uri="persona:Pagers") + radio_phones = PhoneNumberAttributedValueField(field_uri="persona:RadioPhones") + telex_numbers = PhoneNumberAttributedValueField(field_uri="persona:TelexNumbers") + tty_tdd_phone_numbers = PhoneNumberAttributedValueField(field_uri="persona:TTYTDDPhoneNumbers") + work_faxes = PhoneNumberAttributedValueField(field_uri="persona:WorkFaxes") + emails1 = EmailAddressAttributedValueField(field_uri="persona:Emails1") + emails2 = EmailAddressAttributedValueField(field_uri="persona:Emails2") + emails3 = EmailAddressAttributedValueField(field_uri="persona:Emails3") + business_home_pages = StringAttributedValueField(field_uri="persona:BusinessHomePages") + personal_home_pages = StringAttributedValueField(field_uri="persona:PersonalHomePages") + office_locations = StringAttributedValueField(field_uri="persona:OfficeLocations") + im_addresses = StringAttributedValueField(field_uri="persona:ImAddresses") + im_addresses2 = StringAttributedValueField(field_uri="persona:ImAddresses2") + im_addresses3 = StringAttributedValueField(field_uri="persona:ImAddresses3") + business_addresses = PostalAddressAttributedValueField(field_uri="persona:BusinessAddresses") + home_addresses = PostalAddressAttributedValueField(field_uri="persona:HomeAddresses") + other_addresses = PostalAddressAttributedValueField(field_uri="persona:OtherAddresses") + titles = StringAttributedValueField(field_uri="persona:Titles") + departments = StringAttributedValueField(field_uri="persona:Departments") + company_names = StringAttributedValueField(field_uri="persona:CompanyNames") + managers = StringAttributedValueField(field_uri="persona:Managers") + assistant_names = StringAttributedValueField(field_uri="persona:AssistantNames") + professions = StringAttributedValueField(field_uri="persona:Professions") + spouse_names = StringAttributedValueField(field_uri="persona:SpouseNames") + children = StringAttributedValueField(field_uri="persona:Children") + schools = StringAttributedValueField(field_uri="persona:Schools") + hobbies = StringAttributedValueField(field_uri="persona:Hobbies") + wedding_anniversaries = StringAttributedValueField(field_uri="persona:WeddingAnniversaries") + birthdays = StringAttributedValueField(field_uri="persona:Birthdays") + locations = StringAttributedValueField(field_uri="persona:Locations")

                                                              Ancestors

                                                                diff --git a/docs/exchangelib/items/index.html b/docs/exchangelib/items/index.html index 1ef9bfed..ca08feaa 100644 --- a/docs/exchangelib/items/index.html +++ b/docs/exchangelib/items/index.html @@ -26,56 +26,144 @@

                                                                Module exchangelib.items

                                                                Expand source code -
                                                                from .base import RegisterMixIn, BulkCreateResult, MESSAGE_DISPOSITION_CHOICES, SAVE_ONLY, SEND_ONLY, \
                                                                -    ID_ONLY, DEFAULT, ALL_PROPERTIES, SEND_MEETING_INVITATIONS_CHOICES, SEND_TO_NONE, SEND_ONLY_TO_ALL, \
                                                                -    SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, SEND_ONLY_TO_CHANGED, SEND_TO_CHANGED_AND_SAVE_COPY, \
                                                                -    SEND_MEETING_CANCELLATIONS_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES, ALL_OCCURRENCES, \
                                                                -    SPECIFIED_OCCURRENCE_ONLY, CONFLICT_RESOLUTION_CHOICES, NEVER_OVERWRITE, AUTO_RESOLVE, ALWAYS_OVERWRITE, \
                                                                -    DELETE_TYPE_CHOICES, HARD_DELETE, SOFT_DELETE, MOVE_TO_DELETED_ITEMS, SEND_TO_ALL_AND_SAVE_COPY, \
                                                                -    SEND_AND_SAVE_COPY, SHAPE_CHOICES
                                                                -from .calendar_item import CalendarItem, AcceptItem, TentativelyAcceptItem, DeclineItem, CancelCalendarItem, \
                                                                -    MeetingMessage, MeetingRequest, MeetingResponse, MeetingCancellation, CONFERENCE_TYPES
                                                                -from .contact import Contact, Persona, DistributionList
                                                                +
                                                                from .base import (
                                                                +    AFFECTED_TASK_OCCURRENCES_CHOICES,
                                                                +    ALL_OCCURRENCES,
                                                                +    ALL_PROPERTIES,
                                                                +    ALWAYS_OVERWRITE,
                                                                +    AUTO_RESOLVE,
                                                                +    CONFLICT_RESOLUTION_CHOICES,
                                                                +    DEFAULT,
                                                                +    DELETE_TYPE_CHOICES,
                                                                +    HARD_DELETE,
                                                                +    ID_ONLY,
                                                                +    MESSAGE_DISPOSITION_CHOICES,
                                                                +    MOVE_TO_DELETED_ITEMS,
                                                                +    NEVER_OVERWRITE,
                                                                +    SAVE_ONLY,
                                                                +    SEND_AND_SAVE_COPY,
                                                                +    SEND_MEETING_CANCELLATIONS_CHOICES,
                                                                +    SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES,
                                                                +    SEND_MEETING_INVITATIONS_CHOICES,
                                                                +    SEND_ONLY,
                                                                +    SEND_ONLY_TO_ALL,
                                                                +    SEND_ONLY_TO_CHANGED,
                                                                +    SEND_TO_ALL_AND_SAVE_COPY,
                                                                +    SEND_TO_CHANGED_AND_SAVE_COPY,
                                                                +    SEND_TO_NONE,
                                                                +    SHAPE_CHOICES,
                                                                +    SOFT_DELETE,
                                                                +    SPECIFIED_OCCURRENCE_ONLY,
                                                                +    BulkCreateResult,
                                                                +    RegisterMixIn,
                                                                +)
                                                                +from .calendar_item import (
                                                                +    CONFERENCE_TYPES,
                                                                +    AcceptItem,
                                                                +    CalendarItem,
                                                                +    CancelCalendarItem,
                                                                +    DeclineItem,
                                                                +    MeetingCancellation,
                                                                +    MeetingMessage,
                                                                +    MeetingRequest,
                                                                +    MeetingResponse,
                                                                +    TentativelyAcceptItem,
                                                                +)
                                                                +from .contact import Contact, DistributionList, Persona
                                                                 from .item import BaseItem, Item
                                                                -from .message import Message, ReplyToItem, ReplyAllToItem, ForwardItem
                                                                +from .message import ForwardItem, Message, ReplyAllToItem, ReplyToItem
                                                                 from .post import PostItem, PostReplyItem
                                                                 from .task import Task
                                                                 
                                                                 # Traversal enums
                                                                -SHALLOW = 'Shallow'
                                                                -SOFT_DELETED = 'SoftDeleted'
                                                                -ASSOCIATED = 'Associated'
                                                                +SHALLOW = "Shallow"
                                                                +SOFT_DELETED = "SoftDeleted"
                                                                +ASSOCIATED = "Associated"
                                                                 ITEM_TRAVERSAL_CHOICES = (SHALLOW, SOFT_DELETED, ASSOCIATED)
                                                                 
                                                                 # Contacts search (ResolveNames) scope enums
                                                                -ACTIVE_DIRECTORY = 'ActiveDirectory'
                                                                -ACTIVE_DIRECTORY_CONTACTS = 'ActiveDirectoryContacts'
                                                                -CONTACTS = 'Contacts'
                                                                -CONTACTS_ACTIVE_DIRECTORY = 'ContactsActiveDirectory'
                                                                +ACTIVE_DIRECTORY = "ActiveDirectory"
                                                                +ACTIVE_DIRECTORY_CONTACTS = "ActiveDirectoryContacts"
                                                                +CONTACTS = "Contacts"
                                                                +CONTACTS_ACTIVE_DIRECTORY = "ContactsActiveDirectory"
                                                                 SEARCH_SCOPE_CHOICES = (ACTIVE_DIRECTORY, ACTIVE_DIRECTORY_CONTACTS, CONTACTS, CONTACTS_ACTIVE_DIRECTORY)
                                                                 
                                                                 
                                                                -ITEM_CLASSES = (CalendarItem, Contact, DistributionList, Item, Message, MeetingMessage, MeetingRequest,
                                                                -                MeetingResponse, MeetingCancellation, PostItem, Task)
                                                                +ITEM_CLASSES = (
                                                                +    CalendarItem,
                                                                +    Contact,
                                                                +    DistributionList,
                                                                +    Item,
                                                                +    Message,
                                                                +    MeetingMessage,
                                                                +    MeetingRequest,
                                                                +    MeetingResponse,
                                                                +    MeetingCancellation,
                                                                +    PostItem,
                                                                +    Task,
                                                                +)
                                                                 
                                                                 __all__ = [
                                                                -    'RegisterMixIn', 'MESSAGE_DISPOSITION_CHOICES', 'SAVE_ONLY', 'SEND_ONLY', 'SEND_AND_SAVE_COPY',
                                                                -    'CalendarItem', 'AcceptItem', 'TentativelyAcceptItem', 'DeclineItem', 'CancelCalendarItem',
                                                                -    'MeetingRequest', 'MeetingResponse', 'MeetingCancellation', 'CONFERENCE_TYPES',
                                                                -    'Contact', 'Persona', 'DistributionList',
                                                                -    'SEND_MEETING_INVITATIONS_CHOICES', 'SEND_TO_NONE', 'SEND_ONLY_TO_ALL', 'SEND_TO_ALL_AND_SAVE_COPY',
                                                                -    'SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES', 'SEND_ONLY_TO_CHANGED', 'SEND_TO_CHANGED_AND_SAVE_COPY',
                                                                -    'SEND_MEETING_CANCELLATIONS_CHOICES', 'AFFECTED_TASK_OCCURRENCES_CHOICES', 'ALL_OCCURRENCES',
                                                                -    'SPECIFIED_OCCURRENCE_ONLY', 'CONFLICT_RESOLUTION_CHOICES', 'NEVER_OVERWRITE', 'AUTO_RESOLVE', 'ALWAYS_OVERWRITE',
                                                                -    'DELETE_TYPE_CHOICES', 'HARD_DELETE', 'SOFT_DELETE', 'MOVE_TO_DELETED_ITEMS', 'BaseItem', 'Item',
                                                                -    'BulkCreateResult',
                                                                -    'Message', 'ReplyToItem', 'ReplyAllToItem', 'ForwardItem',
                                                                -    'PostItem', 'PostReplyItem',
                                                                -    'Task',
                                                                -    'ITEM_TRAVERSAL_CHOICES', 'SHALLOW', 'SOFT_DELETED', 'ASSOCIATED',
                                                                -    'SHAPE_CHOICES', 'ID_ONLY', 'DEFAULT', 'ALL_PROPERTIES',
                                                                -    'SEARCH_SCOPE_CHOICES', 'ACTIVE_DIRECTORY', 'ACTIVE_DIRECTORY_CONTACTS', 'CONTACTS', 'CONTACTS_ACTIVE_DIRECTORY',
                                                                -    'ITEM_CLASSES',
                                                                +    "RegisterMixIn",
                                                                +    "MESSAGE_DISPOSITION_CHOICES",
                                                                +    "SAVE_ONLY",
                                                                +    "SEND_ONLY",
                                                                +    "SEND_AND_SAVE_COPY",
                                                                +    "CalendarItem",
                                                                +    "AcceptItem",
                                                                +    "TentativelyAcceptItem",
                                                                +    "DeclineItem",
                                                                +    "CancelCalendarItem",
                                                                +    "MeetingRequest",
                                                                +    "MeetingResponse",
                                                                +    "MeetingCancellation",
                                                                +    "CONFERENCE_TYPES",
                                                                +    "Contact",
                                                                +    "Persona",
                                                                +    "DistributionList",
                                                                +    "SEND_MEETING_INVITATIONS_CHOICES",
                                                                +    "SEND_TO_NONE",
                                                                +    "SEND_ONLY_TO_ALL",
                                                                +    "SEND_TO_ALL_AND_SAVE_COPY",
                                                                +    "SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES",
                                                                +    "SEND_ONLY_TO_CHANGED",
                                                                +    "SEND_TO_CHANGED_AND_SAVE_COPY",
                                                                +    "SEND_MEETING_CANCELLATIONS_CHOICES",
                                                                +    "AFFECTED_TASK_OCCURRENCES_CHOICES",
                                                                +    "ALL_OCCURRENCES",
                                                                +    "SPECIFIED_OCCURRENCE_ONLY",
                                                                +    "CONFLICT_RESOLUTION_CHOICES",
                                                                +    "NEVER_OVERWRITE",
                                                                +    "AUTO_RESOLVE",
                                                                +    "ALWAYS_OVERWRITE",
                                                                +    "DELETE_TYPE_CHOICES",
                                                                +    "HARD_DELETE",
                                                                +    "SOFT_DELETE",
                                                                +    "MOVE_TO_DELETED_ITEMS",
                                                                +    "BaseItem",
                                                                +    "Item",
                                                                +    "BulkCreateResult",
                                                                +    "Message",
                                                                +    "ReplyToItem",
                                                                +    "ReplyAllToItem",
                                                                +    "ForwardItem",
                                                                +    "PostItem",
                                                                +    "PostReplyItem",
                                                                +    "Task",
                                                                +    "ITEM_TRAVERSAL_CHOICES",
                                                                +    "SHALLOW",
                                                                +    "SOFT_DELETED",
                                                                +    "ASSOCIATED",
                                                                +    "SHAPE_CHOICES",
                                                                +    "ID_ONLY",
                                                                +    "DEFAULT",
                                                                +    "ALL_PROPERTIES",
                                                                +    "SEARCH_SCOPE_CHOICES",
                                                                +    "ACTIVE_DIRECTORY",
                                                                +    "ACTIVE_DIRECTORY_CONTACTS",
                                                                +    "CONTACTS",
                                                                +    "CONTACTS_ACTIVE_DIRECTORY",
                                                                +    "ITEM_CLASSES",
                                                                 ]
                                                          @@ -137,7 +225,7 @@

                                                          Classes

                                                          class AcceptItem(BaseMeetingReplyItem):
                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/acceptitem"""
                                                           
                                                          -    ELEMENT_NAME = 'AcceptItem'
                                                          + ELEMENT_NAME = "AcceptItem"

                                                          Ancestors

                                                            @@ -190,9 +278,9 @@

                                                            Inherited members

                                                            """Base class for all other classes that implement EWS items.""" ID_ELEMENT_CLS = ItemId - _id = IdElementField(field_uri='item:ItemId', value_cls=ID_ELEMENT_CLS) + _id = IdElementField(field_uri="item:ItemId", value_cls=ID_ELEMENT_CLS) - __slots__ = 'account', 'folder' + __slots__ = "account", "folder" def __init__(self, **kwargs): """Pick out optional 'account' and 'folder' kwargs, and pass the rest to the parent class. @@ -202,15 +290,16 @@

                                                            Inherited members

                                                            'folder' is optional but allows calling 'save()'. If 'folder' has an account, and 'account' is not set, we use folder.account. """ - from ..folders import BaseFolder from ..account import Account - self.account = kwargs.pop('account', None) + from ..folders import BaseFolder + + self.account = kwargs.pop("account", None) if self.account is not None and not isinstance(self.account, Account): - raise InvalidTypeError('account', self.account, Account) - self.folder = kwargs.pop('folder', None) + raise InvalidTypeError("account", self.account, Account) + self.folder = kwargs.pop("folder", None) if self.folder is not None: if not isinstance(self.folder, BaseFolder): - raise InvalidTypeError('folder', self.folder, BaseFolder) + raise InvalidTypeError("folder", self.folder, BaseFolder) if self.folder.account is not None: if self.account is not None: # Make sure the account from kwargs matches the folder account @@ -311,7 +400,7 @@

                                                            Inherited members

                                                            class BulkCreateResult(BaseItem):
                                                                 """A dummy class to store return values from a CreateItem service call."""
                                                             
                                                            -    attachments = AttachmentField(field_uri='item:Attachments')  # ItemAttachment or FileAttachment
                                                            +    attachments = AttachmentField(field_uri="item:Attachments")  # ItemAttachment or FileAttachment
                                                             
                                                                 def __init__(self, **kwargs):
                                                                     super().__init__(**kwargs)
                                                            @@ -374,66 +463,75 @@ 

                                                            Inherited members

                                                            class CalendarItem(Item, AcceptDeclineMixIn):
                                                                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendaritem"""
                                                             
                                                            -    ELEMENT_NAME = 'CalendarItem'
                                                            -
                                                            -    uid = TextField(field_uri='calendar:UID', is_required_after_save=True, is_searchable=False)
                                                            -    recurrence_id = DateTimeField(field_uri='calendar:RecurrenceId', is_read_only=True)
                                                            -    start = DateOrDateTimeField(field_uri='calendar:Start', is_required=True)
                                                            -    end = DateOrDateTimeField(field_uri='calendar:End', is_required=True)
                                                            -    original_start = DateTimeField(field_uri='calendar:OriginalStart', is_read_only=True)
                                                            -    is_all_day = BooleanField(field_uri='calendar:IsAllDayEvent', is_required=True, default=False)
                                                            -    legacy_free_busy_status = FreeBusyStatusField(field_uri='calendar:LegacyFreeBusyStatus', is_required=True,
                                                            -                                                  default='Busy')
                                                            -    location = TextField(field_uri='calendar:Location')
                                                            -    when = TextField(field_uri='calendar:When')
                                                            -    is_meeting = BooleanField(field_uri='calendar:IsMeeting', is_read_only=True)
                                                            -    is_cancelled = BooleanField(field_uri='calendar:IsCancelled', is_read_only=True)
                                                            -    is_recurring = BooleanField(field_uri='calendar:IsRecurring', is_read_only=True)
                                                            -    meeting_request_was_sent = BooleanField(field_uri='calendar:MeetingRequestWasSent', is_read_only=True)
                                                            -    is_response_requested = BooleanField(field_uri='calendar:IsResponseRequested', default=None,
                                                            -                                         is_required_after_save=True, is_searchable=False)
                                                            -    type = ChoiceField(field_uri='calendar:CalendarItemType', choices={Choice(c) for c in CALENDAR_ITEM_CHOICES},
                                                            -                       is_read_only=True)
                                                            -    my_response_type = ChoiceField(field_uri='calendar:MyResponseType', choices={
                                                            -            Choice(c) for c in Attendee.RESPONSE_TYPES
                                                            -    }, is_read_only=True)
                                                            -    organizer = MailboxField(field_uri='calendar:Organizer', is_read_only=True)
                                                            -    required_attendees = AttendeesField(field_uri='calendar:RequiredAttendees', is_searchable=False)
                                                            -    optional_attendees = AttendeesField(field_uri='calendar:OptionalAttendees', is_searchable=False)
                                                            -    resources = AttendeesField(field_uri='calendar:Resources', is_searchable=False)
                                                            -    conflicting_meeting_count = IntegerField(field_uri='calendar:ConflictingMeetingCount', is_read_only=True)
                                                            -    adjacent_meeting_count = IntegerField(field_uri='calendar:AdjacentMeetingCount', is_read_only=True)
                                                            -    conflicting_meetings = EWSElementListField(field_uri='calendar:ConflictingMeetings', value_cls='CalendarItem',
                                                            -                                               namespace=Item.NAMESPACE, is_read_only=True)
                                                            -    adjacent_meetings = EWSElementListField(field_uri='calendar:AdjacentMeetings', value_cls='CalendarItem',
                                                            -                                            namespace=Item.NAMESPACE, is_read_only=True)
                                                            -    duration = CharField(field_uri='calendar:Duration', is_read_only=True)
                                                            -    appointment_reply_time = DateTimeField(field_uri='calendar:AppointmentReplyTime', is_read_only=True)
                                                            -    appointment_sequence_number = IntegerField(field_uri='calendar:AppointmentSequenceNumber', is_read_only=True)
                                                            -    appointment_state = AppointmentStateField(field_uri='calendar:AppointmentState', is_read_only=True)
                                                            -    recurrence = RecurrenceField(field_uri='calendar:Recurrence', is_searchable=False)
                                                            -    first_occurrence = OccurrenceField(field_uri='calendar:FirstOccurrence', value_cls=FirstOccurrence,
                                                            -                                       is_read_only=True)
                                                            -    last_occurrence = OccurrenceField(field_uri='calendar:LastOccurrence', value_cls=LastOccurrence,
                                                            -                                      is_read_only=True)
                                                            -    modified_occurrences = OccurrenceListField(field_uri='calendar:ModifiedOccurrences', value_cls=Occurrence,
                                                            -                                               is_read_only=True)
                                                            -    deleted_occurrences = OccurrenceListField(field_uri='calendar:DeletedOccurrences', value_cls=DeletedOccurrence,
                                                            -                                              is_read_only=True)
                                                            -    _meeting_timezone = TimeZoneField(field_uri='calendar:MeetingTimeZone', deprecated_from=EXCHANGE_2010,
                                                            -                                      is_searchable=False)
                                                            -    _start_timezone = TimeZoneField(field_uri='calendar:StartTimeZone', supported_from=EXCHANGE_2010,
                                                            -                                    is_searchable=False)
                                                            -    _end_timezone = TimeZoneField(field_uri='calendar:EndTimeZone', supported_from=EXCHANGE_2010,
                                                            -                                  is_searchable=False)
                                                            -    conference_type = EnumAsIntField(field_uri='calendar:ConferenceType', enum=CONFERENCE_TYPES, min=0,
                                                            -                                     default=None, is_required_after_save=True)
                                                            -    allow_new_time_proposal = BooleanField(field_uri='calendar:AllowNewTimeProposal', default=None,
                                                            -                                           is_required_after_save=True, is_searchable=False)
                                                            -    is_online_meeting = BooleanField(field_uri='calendar:IsOnlineMeeting', default=None,
                                                            -                                     is_read_only=True)
                                                            -    meeting_workspace_url = URIField(field_uri='calendar:MeetingWorkspaceUrl')
                                                            -    net_show_url = URIField(field_uri='calendar:NetShowUrl')
                                                            +    ELEMENT_NAME = "CalendarItem"
                                                            +
                                                            +    uid = TextField(field_uri="calendar:UID", is_required_after_save=True, is_searchable=False)
                                                            +    recurrence_id = DateTimeField(field_uri="calendar:RecurrenceId", is_read_only=True)
                                                            +    start = DateOrDateTimeField(field_uri="calendar:Start", is_required=True)
                                                            +    end = DateOrDateTimeField(field_uri="calendar:End", is_required=True)
                                                            +    original_start = DateTimeField(field_uri="calendar:OriginalStart", is_read_only=True)
                                                            +    is_all_day = BooleanField(field_uri="calendar:IsAllDayEvent", is_required=True, default=False)
                                                            +    legacy_free_busy_status = FreeBusyStatusField(
                                                            +        field_uri="calendar:LegacyFreeBusyStatus", is_required=True, default="Busy"
                                                            +    )
                                                            +    location = TextField(field_uri="calendar:Location")
                                                            +    when = TextField(field_uri="calendar:When")
                                                            +    is_meeting = BooleanField(field_uri="calendar:IsMeeting", is_read_only=True)
                                                            +    is_cancelled = BooleanField(field_uri="calendar:IsCancelled", is_read_only=True)
                                                            +    is_recurring = BooleanField(field_uri="calendar:IsRecurring", is_read_only=True)
                                                            +    meeting_request_was_sent = BooleanField(field_uri="calendar:MeetingRequestWasSent", is_read_only=True)
                                                            +    is_response_requested = BooleanField(
                                                            +        field_uri="calendar:IsResponseRequested", default=None, is_required_after_save=True, is_searchable=False
                                                            +    )
                                                            +    type = ChoiceField(
                                                            +        field_uri="calendar:CalendarItemType", choices={Choice(c) for c in CALENDAR_ITEM_CHOICES}, is_read_only=True
                                                            +    )
                                                            +    my_response_type = ChoiceField(
                                                            +        field_uri="calendar:MyResponseType", choices={Choice(c) for c in Attendee.RESPONSE_TYPES}, is_read_only=True
                                                            +    )
                                                            +    organizer = MailboxField(field_uri="calendar:Organizer", is_read_only=True)
                                                            +    required_attendees = AttendeesField(field_uri="calendar:RequiredAttendees", is_searchable=False)
                                                            +    optional_attendees = AttendeesField(field_uri="calendar:OptionalAttendees", is_searchable=False)
                                                            +    resources = AttendeesField(field_uri="calendar:Resources", is_searchable=False)
                                                            +    conflicting_meeting_count = IntegerField(field_uri="calendar:ConflictingMeetingCount", is_read_only=True)
                                                            +    adjacent_meeting_count = IntegerField(field_uri="calendar:AdjacentMeetingCount", is_read_only=True)
                                                            +    conflicting_meetings = EWSElementListField(
                                                            +        field_uri="calendar:ConflictingMeetings", value_cls="CalendarItem", namespace=Item.NAMESPACE, is_read_only=True
                                                            +    )
                                                            +    adjacent_meetings = EWSElementListField(
                                                            +        field_uri="calendar:AdjacentMeetings", value_cls="CalendarItem", namespace=Item.NAMESPACE, is_read_only=True
                                                            +    )
                                                            +    duration = CharField(field_uri="calendar:Duration", is_read_only=True)
                                                            +    appointment_reply_time = DateTimeField(field_uri="calendar:AppointmentReplyTime", is_read_only=True)
                                                            +    appointment_sequence_number = IntegerField(field_uri="calendar:AppointmentSequenceNumber", is_read_only=True)
                                                            +    appointment_state = AppointmentStateField(field_uri="calendar:AppointmentState", is_read_only=True)
                                                            +    recurrence = RecurrenceField(field_uri="calendar:Recurrence", is_searchable=False)
                                                            +    first_occurrence = OccurrenceField(
                                                            +        field_uri="calendar:FirstOccurrence", value_cls=FirstOccurrence, is_read_only=True
                                                            +    )
                                                            +    last_occurrence = OccurrenceField(field_uri="calendar:LastOccurrence", value_cls=LastOccurrence, is_read_only=True)
                                                            +    modified_occurrences = OccurrenceListField(
                                                            +        field_uri="calendar:ModifiedOccurrences", value_cls=Occurrence, is_read_only=True
                                                            +    )
                                                            +    deleted_occurrences = OccurrenceListField(
                                                            +        field_uri="calendar:DeletedOccurrences", value_cls=DeletedOccurrence, is_read_only=True
                                                            +    )
                                                            +    _meeting_timezone = TimeZoneField(
                                                            +        field_uri="calendar:MeetingTimeZone", deprecated_from=EXCHANGE_2010, is_searchable=False
                                                            +    )
                                                            +    _start_timezone = TimeZoneField(
                                                            +        field_uri="calendar:StartTimeZone", supported_from=EXCHANGE_2010, is_searchable=False
                                                            +    )
                                                            +    _end_timezone = TimeZoneField(field_uri="calendar:EndTimeZone", supported_from=EXCHANGE_2010, is_searchable=False)
                                                            +    conference_type = EnumAsIntField(
                                                            +        field_uri="calendar:ConferenceType", enum=CONFERENCE_TYPES, min=0, default=None, is_required_after_save=True
                                                            +    )
                                                            +    allow_new_time_proposal = BooleanField(
                                                            +        field_uri="calendar:AllowNewTimeProposal", default=None, is_required_after_save=True, is_searchable=False
                                                            +    )
                                                            +    is_online_meeting = BooleanField(field_uri="calendar:IsOnlineMeeting", default=None, is_read_only=True)
                                                            +    meeting_workspace_url = URIField(field_uri="calendar:MeetingWorkspaceUrl")
                                                            +    net_show_url = URIField(field_uri="calendar:NetShowUrl")
                                                             
                                                                 def occurrence(self, index):
                                                                     """Get an occurrence of a recurring master by index. No query is sent to the server to actually fetch the item.
                                                            @@ -504,9 +602,7 @@ 

                                                            Inherited members

                                                            def cancel(self, **kwargs): return CancelCalendarItem( - account=self.account, - reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), - **kwargs + account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs ).send() def _update_fieldnames(self): @@ -514,8 +610,8 @@

                                                            Inherited members

                                                            if self.type == OCCURRENCE: # Some CalendarItem fields cannot be updated when the item is an occurrence. The values are empty when we # receive them so would have been updated because they are set to None. - update_fields.remove('recurrence') - update_fields.remove('uid') + update_fields.remove("recurrence") + update_fields.remove("uid") return update_fields @classmethod @@ -525,15 +621,15 @@

                                                            Inherited members

                                                            # applicable. if not item.is_all_day: return item - for field_name in ('start', 'end'): + for field_name in ("start", "end"): val = getattr(item, field_name) if val is None: continue # Return just the date part of the value. Subtract 1 day from the date if this is the end field. This is # the inverse of what we do in .to_xml(). Convert to the local timezone before getting the date. - if field_name == 'end': + if field_name == "end": val -= datetime.timedelta(days=1) - tz = getattr(item, f'_{field_name}_timezone') + tz = getattr(item, f"_{field_name}_timezone") setattr(item, field_name, val.astimezone(tz).date()) return item @@ -541,11 +637,11 @@

                                                            Inherited members

                                                            meeting_tz_field, start_tz_field, end_tz_field = CalendarItem.timezone_fields() if self.account.version.build < EXCHANGE_2010: return meeting_tz_field - if field_name == 'start': + if field_name == "start": return start_tz_field - if field_name == 'end': + if field_name == "end": return end_tz_field - raise ValueError('Unsupported field_name') + raise ValueError("Unsupported field_name") def date_to_datetime(self, field_name): # EWS always expects a datetime. If we have a date value, then convert it to datetime in the local @@ -554,7 +650,7 @@

                                                            Inherited members

                                                            value = getattr(self, field_name) tz = getattr(self, self.tz_field_for_field_name(field_name).name) value = EWSDateTime.combine(value, datetime.time(0, 0)).replace(tzinfo=tz) - if field_name == 'end': + if field_name == "end": value += datetime.timedelta(days=1) return value @@ -568,7 +664,7 @@

                                                            Inherited members

                                                            elem = super().to_xml(version=version) if not self.is_all_day: return elem - for field_name in ('start', 'end'): + for field_name in ("start", "end"): value = getattr(self, field_name) if value is None: continue @@ -619,15 +715,15 @@

                                                            Static methods

                                                            # applicable. if not item.is_all_day: return item - for field_name in ('start', 'end'): + for field_name in ("start", "end"): val = getattr(item, field_name) if val is None: continue # Return just the date part of the value. Subtract 1 day from the date if this is the end field. This is # the inverse of what we do in .to_xml(). Convert to the local timezone before getting the date. - if field_name == 'end': + if field_name == "end": val -= datetime.timedelta(days=1) - tz = getattr(item, f'_{field_name}_timezone') + tz = getattr(item, f"_{field_name}_timezone") setattr(item, field_name, val.astimezone(tz).date()) return item
                                                            @@ -815,9 +911,7 @@

                                                            Methods

                                                            def cancel(self, **kwargs):
                                                                 return CancelCalendarItem(
                                                            -        account=self.account,
                                                            -        reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey),
                                                            -        **kwargs
                                                            +        account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), **kwargs
                                                                 ).send()
                                                            @@ -890,7 +984,7 @@

                                                            Methods

                                                            value = getattr(self, field_name) tz = getattr(self, self.tz_field_for_field_name(field_name).name) value = EWSDateTime.combine(value, datetime.time(0, 0)).replace(tzinfo=tz) - if field_name == 'end': + if field_name == "end": value += datetime.timedelta(days=1) return value
                                                            @@ -971,7 +1065,7 @@

                                                            Methods

                                                            elem = super().to_xml(version=version) if not self.is_all_day: return elem - for field_name in ('start', 'end'): + for field_name in ("start", "end"): value = getattr(self, field_name) if value is None: continue @@ -998,11 +1092,11 @@

                                                            Methods

                                                            meeting_tz_field, start_tz_field, end_tz_field = CalendarItem.timezone_fields() if self.account.version.build < EXCHANGE_2010: return meeting_tz_field - if field_name == 'start': + if field_name == "start": return start_tz_field - if field_name == 'end': + if field_name == "end": return end_tz_field - raise ValueError('Unsupported field_name') + raise ValueError("Unsupported field_name") @@ -1038,9 +1132,9 @@

                                                            Inherited members

                                                            class CancelCalendarItem(BaseReplyItem):
                                                                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/cancelcalendaritem"""
                                                             
                                                            -    ELEMENT_NAME = 'CancelCalendarItem'
                                                            -    author_idx = BaseReplyItem.FIELDS.index_by_name('author')
                                                            -    FIELDS = BaseReplyItem.FIELDS[:author_idx] + BaseReplyItem.FIELDS[author_idx + 1:]
                                                            + ELEMENT_NAME = "CancelCalendarItem" + author_idx = BaseReplyItem.FIELDS.index_by_name("author") + FIELDS = BaseReplyItem.FIELDS[:author_idx] + BaseReplyItem.FIELDS[author_idx + 1 :]

                                                            Ancestors

                                                              @@ -1094,75 +1188,100 @@

                                                              Inherited members

                                                              class Contact(Item):
                                                                   """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/contact"""
                                                               
                                                              -    ELEMENT_NAME = 'Contact'
                                                              -
                                                              -    file_as = TextField(field_uri='contacts:FileAs')
                                                              -    file_as_mapping = ChoiceField(field_uri='contacts:FileAsMapping', choices={
                                                              -        Choice('None'), Choice('LastCommaFirst'), Choice('FirstSpaceLast'), Choice('Company'),
                                                              -        Choice('LastCommaFirstCompany'), Choice('CompanyLastFirst'), Choice('LastFirst'),
                                                              -        Choice('LastFirstCompany'), Choice('CompanyLastCommaFirst'), Choice('LastFirstSuffix'),
                                                              -        Choice('LastSpaceFirstCompany'), Choice('CompanyLastSpaceFirst'), Choice('LastSpaceFirst'),
                                                              -        Choice('DisplayName'), Choice('FirstName'), Choice('LastFirstMiddleSuffix'), Choice('LastName'),
                                                              -        Choice('Empty'),
                                                              -    })
                                                              -    display_name = TextField(field_uri='contacts:DisplayName', is_required=True)
                                                              -    given_name = CharField(field_uri='contacts:GivenName')
                                                              -    initials = TextField(field_uri='contacts:Initials')
                                                              -    middle_name = CharField(field_uri='contacts:MiddleName')
                                                              -    nickname = TextField(field_uri='contacts:Nickname')
                                                              -    complete_name = EWSElementField(field_uri='contacts:CompleteName', value_cls=CompleteName, is_read_only=True)
                                                              -    company_name = TextField(field_uri='contacts:CompanyName')
                                                              -    email_addresses = EmailAddressesField(field_uri='contacts:EmailAddress')
                                                              -    physical_addresses = PhysicalAddressField(field_uri='contacts:PhysicalAddress')
                                                              -    phone_numbers = PhoneNumberField(field_uri='contacts:PhoneNumber')
                                                              -    assistant_name = TextField(field_uri='contacts:AssistantName')
                                                              -    birthday = DateTimeBackedDateField(field_uri='contacts:Birthday', default_time=datetime.time(11, 59))
                                                              -    business_homepage = URIField(field_uri='contacts:BusinessHomePage')
                                                              -    children = TextListField(field_uri='contacts:Children')
                                                              -    companies = TextListField(field_uri='contacts:Companies', is_searchable=False)
                                                              -    contact_source = ChoiceField(field_uri='contacts:ContactSource', choices={
                                                              -        Choice('Store'), Choice('ActiveDirectory')
                                                              -    }, is_read_only=True)
                                                              -    department = TextField(field_uri='contacts:Department')
                                                              -    generation = TextField(field_uri='contacts:Generation')
                                                              -    im_addresses = CharField(field_uri='contacts:ImAddresses', is_read_only=True)
                                                              -    job_title = TextField(field_uri='contacts:JobTitle')
                                                              -    manager = TextField(field_uri='contacts:Manager')
                                                              -    mileage = TextField(field_uri='contacts:Mileage')
                                                              -    office = TextField(field_uri='contacts:OfficeLocation')
                                                              -    postal_address_index = ChoiceField(field_uri='contacts:PostalAddressIndex', choices={
                                                              -        Choice('Business'), Choice('Home'), Choice('Other'), Choice('None')
                                                              -    }, default='None', is_required_after_save=True)
                                                              -    profession = TextField(field_uri='contacts:Profession')
                                                              -    spouse_name = TextField(field_uri='contacts:SpouseName')
                                                              -    surname = CharField(field_uri='contacts:Surname')
                                                              -    wedding_anniversary = DateTimeBackedDateField(field_uri='contacts:WeddingAnniversary',
                                                              -                                                  default_time=datetime.time(11, 59))
                                                              -    has_picture = BooleanField(field_uri='contacts:HasPicture', supported_from=EXCHANGE_2010, is_read_only=True)
                                                              -    phonetic_full_name = TextField(field_uri='contacts:PhoneticFullName', supported_from=EXCHANGE_2010_SP2,
                                                              -                                   is_read_only=True)
                                                              -    phonetic_first_name = TextField(field_uri='contacts:PhoneticFirstName', supported_from=EXCHANGE_2010_SP2,
                                                              -                                    is_read_only=True)
                                                              -    phonetic_last_name = TextField(field_uri='contacts:PhoneticLastName', supported_from=EXCHANGE_2010_SP2,
                                                              -                                   is_read_only=True)
                                                              -    email_alias = EmailAddressField(field_uri='contacts:Alias', is_read_only=True,
                                                              -                                    supported_from=EXCHANGE_2010_SP2)
                                                              +    ELEMENT_NAME = "Contact"
                                                              +
                                                              +    file_as = TextField(field_uri="contacts:FileAs")
                                                              +    file_as_mapping = ChoiceField(
                                                              +        field_uri="contacts:FileAsMapping",
                                                              +        choices={
                                                              +            Choice("None"),
                                                              +            Choice("LastCommaFirst"),
                                                              +            Choice("FirstSpaceLast"),
                                                              +            Choice("Company"),
                                                              +            Choice("LastCommaFirstCompany"),
                                                              +            Choice("CompanyLastFirst"),
                                                              +            Choice("LastFirst"),
                                                              +            Choice("LastFirstCompany"),
                                                              +            Choice("CompanyLastCommaFirst"),
                                                              +            Choice("LastFirstSuffix"),
                                                              +            Choice("LastSpaceFirstCompany"),
                                                              +            Choice("CompanyLastSpaceFirst"),
                                                              +            Choice("LastSpaceFirst"),
                                                              +            Choice("DisplayName"),
                                                              +            Choice("FirstName"),
                                                              +            Choice("LastFirstMiddleSuffix"),
                                                              +            Choice("LastName"),
                                                              +            Choice("Empty"),
                                                              +        },
                                                              +    )
                                                              +    display_name = TextField(field_uri="contacts:DisplayName", is_required=True)
                                                              +    given_name = CharField(field_uri="contacts:GivenName")
                                                              +    initials = TextField(field_uri="contacts:Initials")
                                                              +    middle_name = CharField(field_uri="contacts:MiddleName")
                                                              +    nickname = TextField(field_uri="contacts:Nickname")
                                                              +    complete_name = EWSElementField(field_uri="contacts:CompleteName", value_cls=CompleteName, is_read_only=True)
                                                              +    company_name = TextField(field_uri="contacts:CompanyName")
                                                              +    email_addresses = EmailAddressesField(field_uri="contacts:EmailAddress")
                                                              +    physical_addresses = PhysicalAddressField(field_uri="contacts:PhysicalAddress")
                                                              +    phone_numbers = PhoneNumberField(field_uri="contacts:PhoneNumber")
                                                              +    assistant_name = TextField(field_uri="contacts:AssistantName")
                                                              +    birthday = DateTimeBackedDateField(field_uri="contacts:Birthday", default_time=datetime.time(11, 59))
                                                              +    business_homepage = URIField(field_uri="contacts:BusinessHomePage")
                                                              +    children = TextListField(field_uri="contacts:Children")
                                                              +    companies = TextListField(field_uri="contacts:Companies", is_searchable=False)
                                                              +    contact_source = ChoiceField(
                                                              +        field_uri="contacts:ContactSource", choices={Choice("Store"), Choice("ActiveDirectory")}, is_read_only=True
                                                              +    )
                                                              +    department = TextField(field_uri="contacts:Department")
                                                              +    generation = TextField(field_uri="contacts:Generation")
                                                              +    im_addresses = CharField(field_uri="contacts:ImAddresses", is_read_only=True)
                                                              +    job_title = TextField(field_uri="contacts:JobTitle")
                                                              +    manager = TextField(field_uri="contacts:Manager")
                                                              +    mileage = TextField(field_uri="contacts:Mileage")
                                                              +    office = TextField(field_uri="contacts:OfficeLocation")
                                                              +    postal_address_index = ChoiceField(
                                                              +        field_uri="contacts:PostalAddressIndex",
                                                              +        choices={Choice("Business"), Choice("Home"), Choice("Other"), Choice("None")},
                                                              +        default="None",
                                                              +        is_required_after_save=True,
                                                              +    )
                                                              +    profession = TextField(field_uri="contacts:Profession")
                                                              +    spouse_name = TextField(field_uri="contacts:SpouseName")
                                                              +    surname = CharField(field_uri="contacts:Surname")
                                                              +    wedding_anniversary = DateTimeBackedDateField(
                                                              +        field_uri="contacts:WeddingAnniversary", default_time=datetime.time(11, 59)
                                                              +    )
                                                              +    has_picture = BooleanField(field_uri="contacts:HasPicture", supported_from=EXCHANGE_2010, is_read_only=True)
                                                              +    phonetic_full_name = TextField(
                                                              +        field_uri="contacts:PhoneticFullName", supported_from=EXCHANGE_2010_SP2, is_read_only=True
                                                              +    )
                                                              +    phonetic_first_name = TextField(
                                                              +        field_uri="contacts:PhoneticFirstName", supported_from=EXCHANGE_2010_SP2, is_read_only=True
                                                              +    )
                                                              +    phonetic_last_name = TextField(
                                                              +        field_uri="contacts:PhoneticLastName", supported_from=EXCHANGE_2010_SP2, is_read_only=True
                                                              +    )
                                                              +    email_alias = EmailAddressField(field_uri="contacts:Alias", is_read_only=True, supported_from=EXCHANGE_2010_SP2)
                                                                   # 'notes' is documented in MSDN but apparently unused. Writing to it raises ErrorInvalidPropertyRequest. OWA
                                                                   # put entries into the 'notes' form field into the 'body' field.
                                                              -    notes = CharField(field_uri='contacts:Notes', supported_from=EXCHANGE_2010_SP2, is_read_only=True)
                                                              +    notes = CharField(field_uri="contacts:Notes", supported_from=EXCHANGE_2010_SP2, is_read_only=True)
                                                                   # 'photo' is documented in MSDN but apparently unused. Writing to it raises ErrorInvalidPropertyRequest. OWA
                                                                   # adds photos as FileAttachments on the contact item (with 'is_contact_photo=True'), which automatically flips
                                                                   # the 'has_picture' field.
                                                              -    photo = Base64Field(field_uri='contacts:Photo', supported_from=EXCHANGE_2010_SP2, is_read_only=True)
                                                              -    user_smime_certificate = Base64Field(field_uri='contacts:UserSMIMECertificate', supported_from=EXCHANGE_2010_SP2,
                                                              -                                         is_read_only=True)
                                                              -    ms_exchange_certificate = Base64Field(field_uri='contacts:MSExchangeCertificate', supported_from=EXCHANGE_2010_SP2,
                                                              -                                          is_read_only=True)
                                                              -    directory_id = TextField(field_uri='contacts:DirectoryId', supported_from=EXCHANGE_2010_SP2, is_read_only=True)
                                                              -    manager_mailbox = MailboxField(field_uri='contacts:ManagerMailbox', supported_from=EXCHANGE_2010_SP2,
                                                              -                                   is_read_only=True)
                                                              -    direct_reports = MailboxListField(field_uri='contacts:DirectReports', supported_from=EXCHANGE_2010_SP2,
                                                              -                                      is_read_only=True)
                                                              + photo = Base64Field(field_uri="contacts:Photo", supported_from=EXCHANGE_2010_SP2, is_read_only=True) + user_smime_certificate = Base64Field( + field_uri="contacts:UserSMIMECertificate", supported_from=EXCHANGE_2010_SP2, is_read_only=True + ) + ms_exchange_certificate = Base64Field( + field_uri="contacts:MSExchangeCertificate", supported_from=EXCHANGE_2010_SP2, is_read_only=True + ) + directory_id = TextField(field_uri="contacts:DirectoryId", supported_from=EXCHANGE_2010_SP2, is_read_only=True) + manager_mailbox = MailboxField( + field_uri="contacts:ManagerMailbox", supported_from=EXCHANGE_2010_SP2, is_read_only=True + ) + direct_reports = MailboxListField( + field_uri="contacts:DirectReports", supported_from=EXCHANGE_2010_SP2, is_read_only=True + )

                                                              Ancestors

                                                                @@ -1391,7 +1510,7 @@

                                                                Inherited members

                                                                class DeclineItem(BaseMeetingReplyItem):
                                                                     """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/declineitem"""
                                                                 
                                                                -    ELEMENT_NAME = 'DeclineItem'
                                                                + ELEMENT_NAME = "DeclineItem"

                                                                Ancestors

                                                                  @@ -1443,14 +1562,14 @@

                                                                  Inherited members

                                                                  class DistributionList(Item):
                                                                       """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/distributionlist"""
                                                                   
                                                                  -    ELEMENT_NAME = 'DistributionList'
                                                                  +    ELEMENT_NAME = "DistributionList"
                                                                   
                                                                  -    display_name = CharField(field_uri='contacts:DisplayName', is_required=True)
                                                                  -    file_as = CharField(field_uri='contacts:FileAs', is_read_only=True)
                                                                  -    contact_source = ChoiceField(field_uri='contacts:ContactSource', choices={
                                                                  -        Choice('Store'), Choice('ActiveDirectory')
                                                                  -    }, is_read_only=True)
                                                                  -    members = MemberListField(field_uri='distributionlist:Members')
                                                                  + display_name = CharField(field_uri="contacts:DisplayName", is_required=True) + file_as = CharField(field_uri="contacts:FileAs", is_read_only=True) + contact_source = ChoiceField( + field_uri="contacts:ContactSource", choices={Choice("Store"), Choice("ActiveDirectory")}, is_read_only=True + ) + members = MemberListField(field_uri="distributionlist:Members")

                                                                  Ancestors

                                                                    @@ -1522,7 +1641,7 @@

                                                                    Inherited members

                                                                    class ForwardItem(BaseReplyItem):
                                                                         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/forwarditem"""
                                                                     
                                                                    -    ELEMENT_NAME = 'ForwardItem'
                                                                    + ELEMENT_NAME = "ForwardItem"

                                                                    Ancestors

                                                                      @@ -1568,62 +1687,75 @@

                                                                      Inherited members

                                                                      class Item(BaseItem):
                                                                           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/item"""
                                                                       
                                                                      -    ELEMENT_NAME = 'Item'
                                                                      -
                                                                      -    mime_content = MimeContentField(field_uri='item:MimeContent', is_read_only_after_send=True)
                                                                      -    _id = BaseItem.FIELDS['_id']
                                                                      -    parent_folder_id = EWSElementField(field_uri='item:ParentFolderId', value_cls=ParentFolderId, is_read_only=True)
                                                                      -    item_class = CharField(field_uri='item:ItemClass', is_read_only=True)
                                                                      -    subject = CharField(field_uri='item:Subject')
                                                                      -    sensitivity = ChoiceField(field_uri='item:Sensitivity', choices={
                                                                      -        Choice('Normal'), Choice('Personal'), Choice('Private'), Choice('Confidential')
                                                                      -    }, is_required=True, default='Normal')
                                                                      -    text_body = TextField(field_uri='item:TextBody', is_read_only=True, supported_from=EXCHANGE_2013)
                                                                      -    body = BodyField(field_uri='item:Body')  # Accepts and returns Body or HTMLBody instances
                                                                      -    attachments = AttachmentField(field_uri='item:Attachments')  # ItemAttachment or FileAttachment
                                                                      -    datetime_received = DateTimeField(field_uri='item:DateTimeReceived', is_read_only=True)
                                                                      -    size = IntegerField(field_uri='item:Size', is_read_only=True)  # Item size in bytes
                                                                      -    categories = CharListField(field_uri='item:Categories')
                                                                      -    importance = ChoiceField(field_uri='item:Importance', choices={
                                                                      -        Choice('Low'), Choice('Normal'), Choice('High')
                                                                      -    }, is_required=True, default='Normal')
                                                                      -    in_reply_to = TextField(field_uri='item:InReplyTo')
                                                                      -    is_submitted = BooleanField(field_uri='item:IsSubmitted', is_read_only=True)
                                                                      -    is_draft = BooleanField(field_uri='item:IsDraft', is_read_only=True)
                                                                      -    is_from_me = BooleanField(field_uri='item:IsFromMe', is_read_only=True)
                                                                      -    is_resend = BooleanField(field_uri='item:IsResend', is_read_only=True)
                                                                      -    is_unmodified = BooleanField(field_uri='item:IsUnmodified', is_read_only=True)
                                                                      -    headers = MessageHeaderField(field_uri='item:InternetMessageHeaders', is_read_only=True)
                                                                      -    datetime_sent = DateTimeField(field_uri='item:DateTimeSent', is_read_only=True)
                                                                      -    datetime_created = DateTimeField(field_uri='item:DateTimeCreated', is_read_only=True)
                                                                      -    response_objects = EWSElementField(field_uri='item:ResponseObjects', value_cls=ResponseObjects,
                                                                      -                                       is_read_only=True,)
                                                                      +    ELEMENT_NAME = "Item"
                                                                      +
                                                                      +    mime_content = MimeContentField(field_uri="item:MimeContent", is_read_only_after_send=True)
                                                                      +    _id = BaseItem.FIELDS["_id"]
                                                                      +    parent_folder_id = EWSElementField(field_uri="item:ParentFolderId", value_cls=ParentFolderId, is_read_only=True)
                                                                      +    item_class = CharField(field_uri="item:ItemClass", is_read_only=True)
                                                                      +    subject = CharField(field_uri="item:Subject")
                                                                      +    sensitivity = ChoiceField(
                                                                      +        field_uri="item:Sensitivity",
                                                                      +        choices={Choice("Normal"), Choice("Personal"), Choice("Private"), Choice("Confidential")},
                                                                      +        is_required=True,
                                                                      +        default="Normal",
                                                                      +    )
                                                                      +    text_body = TextField(field_uri="item:TextBody", is_read_only=True, supported_from=EXCHANGE_2013)
                                                                      +    body = BodyField(field_uri="item:Body")  # Accepts and returns Body or HTMLBody instances
                                                                      +    attachments = AttachmentField(field_uri="item:Attachments")  # ItemAttachment or FileAttachment
                                                                      +    datetime_received = DateTimeField(field_uri="item:DateTimeReceived", is_read_only=True)
                                                                      +    size = IntegerField(field_uri="item:Size", is_read_only=True)  # Item size in bytes
                                                                      +    categories = CharListField(field_uri="item:Categories")
                                                                      +    importance = ChoiceField(
                                                                      +        field_uri="item:Importance",
                                                                      +        choices={Choice("Low"), Choice("Normal"), Choice("High")},
                                                                      +        is_required=True,
                                                                      +        default="Normal",
                                                                      +    )
                                                                      +    in_reply_to = TextField(field_uri="item:InReplyTo")
                                                                      +    is_submitted = BooleanField(field_uri="item:IsSubmitted", is_read_only=True)
                                                                      +    is_draft = BooleanField(field_uri="item:IsDraft", is_read_only=True)
                                                                      +    is_from_me = BooleanField(field_uri="item:IsFromMe", is_read_only=True)
                                                                      +    is_resend = BooleanField(field_uri="item:IsResend", is_read_only=True)
                                                                      +    is_unmodified = BooleanField(field_uri="item:IsUnmodified", is_read_only=True)
                                                                      +    headers = MessageHeaderField(field_uri="item:InternetMessageHeaders", is_read_only=True)
                                                                      +    datetime_sent = DateTimeField(field_uri="item:DateTimeSent", is_read_only=True)
                                                                      +    datetime_created = DateTimeField(field_uri="item:DateTimeCreated", is_read_only=True)
                                                                      +    response_objects = EWSElementField(
                                                                      +        field_uri="item:ResponseObjects",
                                                                      +        value_cls=ResponseObjects,
                                                                      +        is_read_only=True,
                                                                      +    )
                                                                           # Placeholder for ResponseObjects
                                                                      -    reminder_due_by = DateTimeField(field_uri='item:ReminderDueBy', is_required_after_save=True, is_searchable=False)
                                                                      -    reminder_is_set = BooleanField(field_uri='item:ReminderIsSet', is_required=True, default=False)
                                                                      -    reminder_minutes_before_start = IntegerField(field_uri='item:ReminderMinutesBeforeStart',
                                                                      -                                                 is_required_after_save=True, min=0, default=0)
                                                                      -    display_cc = TextField(field_uri='item:DisplayCc', is_read_only=True)
                                                                      -    display_to = TextField(field_uri='item:DisplayTo', is_read_only=True)
                                                                      -    has_attachments = BooleanField(field_uri='item:HasAttachments', is_read_only=True)
                                                                      +    reminder_due_by = DateTimeField(field_uri="item:ReminderDueBy", is_required_after_save=True, is_searchable=False)
                                                                      +    reminder_is_set = BooleanField(field_uri="item:ReminderIsSet", is_required=True, default=False)
                                                                      +    reminder_minutes_before_start = IntegerField(
                                                                      +        field_uri="item:ReminderMinutesBeforeStart", is_required_after_save=True, min=0, default=0
                                                                      +    )
                                                                      +    display_cc = TextField(field_uri="item:DisplayCc", is_read_only=True)
                                                                      +    display_to = TextField(field_uri="item:DisplayTo", is_read_only=True)
                                                                      +    has_attachments = BooleanField(field_uri="item:HasAttachments", is_read_only=True)
                                                                           # ExtendedProperty fields go here
                                                                      -    culture = CultureField(field_uri='item:Culture', is_required_after_save=True, is_searchable=False)
                                                                      -    effective_rights = EffectiveRightsField(field_uri='item:EffectiveRights', is_read_only=True)
                                                                      -    last_modified_name = CharField(field_uri='item:LastModifiedName', is_read_only=True)
                                                                      -    last_modified_time = DateTimeField(field_uri='item:LastModifiedTime', is_read_only=True)
                                                                      -    is_associated = BooleanField(field_uri='item:IsAssociated', is_read_only=True, supported_from=EXCHANGE_2010)
                                                                      -    web_client_read_form_query_string = URIField(field_uri='item:WebClientReadFormQueryString', is_read_only=True,
                                                                      -                                                 supported_from=EXCHANGE_2010)
                                                                      -    web_client_edit_form_query_string = URIField(field_uri='item:WebClientEditFormQueryString', is_read_only=True,
                                                                      -                                                 supported_from=EXCHANGE_2010)
                                                                      -    conversation_id = EWSElementField(field_uri='item:ConversationId', value_cls=ConversationId,
                                                                      -                                      is_read_only=True, supported_from=EXCHANGE_2010)
                                                                      -    unique_body = BodyField(field_uri='item:UniqueBody', is_read_only=True, supported_from=EXCHANGE_2010)
                                                                      +    culture = CultureField(field_uri="item:Culture", is_required_after_save=True, is_searchable=False)
                                                                      +    effective_rights = EffectiveRightsField(field_uri="item:EffectiveRights", is_read_only=True)
                                                                      +    last_modified_name = CharField(field_uri="item:LastModifiedName", is_read_only=True)
                                                                      +    last_modified_time = DateTimeField(field_uri="item:LastModifiedTime", is_read_only=True)
                                                                      +    is_associated = BooleanField(field_uri="item:IsAssociated", is_read_only=True, supported_from=EXCHANGE_2010)
                                                                      +    web_client_read_form_query_string = URIField(
                                                                      +        field_uri="item:WebClientReadFormQueryString", is_read_only=True, supported_from=EXCHANGE_2010
                                                                      +    )
                                                                      +    web_client_edit_form_query_string = URIField(
                                                                      +        field_uri="item:WebClientEditFormQueryString", is_read_only=True, supported_from=EXCHANGE_2010
                                                                      +    )
                                                                      +    conversation_id = EWSElementField(
                                                                      +        field_uri="item:ConversationId", value_cls=ConversationId, is_read_only=True, supported_from=EXCHANGE_2010
                                                                      +    )
                                                                      +    unique_body = BodyField(field_uri="item:UniqueBody", is_read_only=True, supported_from=EXCHANGE_2010)
                                                                       
                                                                           FIELDS = Fields()
                                                                       
                                                                           # Used to register extended properties
                                                                      -    INSERT_AFTER_FIELD = 'has_attachments'
                                                                      +    INSERT_AFTER_FIELD = "has_attachments"
                                                                       
                                                                           def __init__(self, **kwargs):
                                                                               super().__init__(**kwargs)
                                                                      @@ -1640,16 +1772,19 @@ 

                                                                      Inherited members

                                                                      def save(self, update_fields=None, conflict_resolution=AUTO_RESOLVE, send_meeting_invitations=SEND_TO_NONE): from .task import Task + if self.id: item_id, changekey = self._update( update_fieldnames=update_fields, message_disposition=SAVE_ONLY, conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations + send_meeting_invitations=send_meeting_invitations, ) - if self.id != item_id \ - and not isinstance(self._id, (OccurrenceItemId, RecurringMasterItemId)) \ - and not isinstance(self, Task): + if ( + self.id != item_id + and not isinstance(self._id, (OccurrenceItemId, RecurringMasterItemId)) + and not isinstance(self, Task) + ): # When we update an item with an OccurrenceItemId as ID, EWS returns the ID of the occurrence, so # the ID of this item changes. # @@ -1684,6 +1819,7 @@

                                                                      Inherited members

                                                                      # Return a BulkCreateResult because we want to return the ID of both the main item *and* attachments. In send # and send-and-save-copy mode, the server does not return an ID, so we just return True. from ..services import CreateItem + return CreateItem(account=self.account).get( items=[self], folder=self.folder, @@ -1693,27 +1829,28 @@

                                                                      Inherited members

                                                                      def _update_fieldnames(self): from .contact import Contact, DistributionList + # Return the list of fields we are allowed to update update_fieldnames = [] for f in self.supported_fields(version=self.account.version): - if f.name == 'attachments': + if f.name == "attachments": # Attachments are handled separately after item creation continue if f.is_read_only: # These cannot be changed continue if (f.is_required or f.is_required_after_save) and ( - getattr(self, f.name) is None or (f.is_list and not getattr(self, f.name)) + getattr(self, f.name) is None or (f.is_list and not getattr(self, f.name)) ): # These are required and cannot be deleted continue if not self.is_draft and f.is_read_only_after_send: # These cannot be changed when the item is no longer a draft continue - if f.name == 'message_id' and f.is_read_only_after_send: + if f.name == "message_id" and f.is_read_only_after_send: # 'message_id' doesn't support updating, no matter the draft status continue - if f.name == 'mime_content' and isinstance(self, (Contact, DistributionList)): + if f.name == "mime_content" and isinstance(self, (Contact, DistributionList)): # Contact and DistributionList don't support updating mime_content, no matter the draft status continue update_fieldnames.append(f.name) @@ -1722,8 +1859,9 @@

                                                                      Inherited members

                                                                      @require_account def _update(self, update_fieldnames, message_disposition, conflict_resolution, send_meeting_invitations): from ..services import UpdateItem + if not self.changekey: - raise ValueError(f'{self.__class__.__name__} must have changekey') + raise ValueError(f"{self.__class__.__name__} must have changekey") if not update_fieldnames: # The fields to update was not specified explicitly. Update all fields where update is possible update_fieldnames = self._update_fieldnames() @@ -1741,6 +1879,7 @@

                                                                      Inherited members

                                                                      # Updates the item based on fresh data from EWS from ..folders import Folder from ..services import GetItem + additional_fields = { FieldPath(field=f) for f in Folder(root=self.account.root).allowed_item_fields(version=self.account.version) } @@ -1759,6 +1898,7 @@

                                                                      Inherited members

                                                                      @require_id def copy(self, to_folder): from ..services import CopyItem + # If 'to_folder' is a public folder or a folder in a different mailbox then None is returned return CopyItem(account=self.account).get( items=[self], @@ -1769,6 +1909,7 @@

                                                                      Inherited members

                                                                      @require_id def move(self, to_folder): from ..services import MoveItem + res = MoveItem(account=self.account).get( items=[self], to_folder=to_folder, @@ -1781,32 +1922,57 @@

                                                                      Inherited members

                                                                      self._id = self.ID_ELEMENT_CLS(*res) self.folder = to_folder - def move_to_trash(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, - suppress_read_receipts=True): + def move_to_trash( + self, + send_meeting_cancellations=SEND_TO_NONE, + affected_task_occurrences=ALL_OCCURRENCES, + suppress_read_receipts=True, + ): # Delete and move to the trash folder. - self._delete(delete_type=MOVE_TO_DELETED_ITEMS, send_meeting_cancellations=send_meeting_cancellations, - affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts) + self._delete( + delete_type=MOVE_TO_DELETED_ITEMS, + send_meeting_cancellations=send_meeting_cancellations, + affected_task_occurrences=affected_task_occurrences, + suppress_read_receipts=suppress_read_receipts, + ) self._id = None self.folder = self.account.trash - def soft_delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, - suppress_read_receipts=True): + def soft_delete( + self, + send_meeting_cancellations=SEND_TO_NONE, + affected_task_occurrences=ALL_OCCURRENCES, + suppress_read_receipts=True, + ): # Delete and move to the dumpster, if it is enabled. - self._delete(delete_type=SOFT_DELETE, send_meeting_cancellations=send_meeting_cancellations, - affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts) + self._delete( + delete_type=SOFT_DELETE, + send_meeting_cancellations=send_meeting_cancellations, + affected_task_occurrences=affected_task_occurrences, + suppress_read_receipts=suppress_read_receipts, + ) self._id = None self.folder = self.account.recoverable_items_deletions - def delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, - suppress_read_receipts=True): + def delete( + self, + send_meeting_cancellations=SEND_TO_NONE, + affected_task_occurrences=ALL_OCCURRENCES, + suppress_read_receipts=True, + ): # Remove the item permanently. No copies are stored anywhere. - self._delete(delete_type=HARD_DELETE, send_meeting_cancellations=send_meeting_cancellations, - affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts) + self._delete( + delete_type=HARD_DELETE, + send_meeting_cancellations=send_meeting_cancellations, + affected_task_occurrences=affected_task_occurrences, + suppress_read_receipts=suppress_read_receipts, + ) self._id, self.folder = None, None @require_id def _delete(self, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): from ..services import DeleteItem + DeleteItem(account=self.account).get( items=[self], delete_type=delete_type, @@ -1818,6 +1984,7 @@

                                                                      Inherited members

                                                                      @require_id def archive(self, to_folder): from ..services import ArchiveItem + return ArchiveItem(account=self.account).get(items=[self], to_folder=to_folder, expect_result=True) def attach(self, attachments): @@ -1856,7 +2023,7 @@

                                                                      Inherited members

                                                                      attachments = list(attachments) for a in attachments: if a.parent_item is not self: - raise ValueError('Attachment does not belong to this item') + raise ValueError("Attachment does not belong to this item") if self.id: # Item is already created. Detach the attachment server-side now a.detach() @@ -1866,6 +2033,7 @@

                                                                      Inherited members

                                                                      @require_id def create_forward(self, subject, body, to_recipients, cc_recipients=None, bcc_recipients=None): from .message import ForwardItem + return ForwardItem( account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), @@ -2083,6 +2251,7 @@

                                                                      Methods

                                                                      @require_id
                                                                       def archive(self, to_folder):
                                                                           from ..services import ArchiveItem
                                                                      +
                                                                           return ArchiveItem(account=self.account).get(items=[self], to_folder=to_folder, expect_result=True)
                                                                      @@ -2132,6 +2301,7 @@

                                                                      Methods

                                                                      @require_id
                                                                       def copy(self, to_folder):
                                                                           from ..services import CopyItem
                                                                      +
                                                                           # If 'to_folder' is a public folder or a folder in a different mailbox then None is returned
                                                                           return CopyItem(account=self.account).get(
                                                                               items=[self],
                                                                      @@ -2152,6 +2322,7 @@ 

                                                                      Methods

                                                                      @require_id
                                                                       def create_forward(self, subject, body, to_recipients, cc_recipients=None, bcc_recipients=None):
                                                                           from .message import ForwardItem
                                                                      +
                                                                           return ForwardItem(
                                                                               account=self.account,
                                                                               reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey),
                                                                      @@ -2172,11 +2343,19 @@ 

                                                                      Methods

                                                                      Expand source code -
                                                                      def delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES,
                                                                      -           suppress_read_receipts=True):
                                                                      +
                                                                      def delete(
                                                                      +    self,
                                                                      +    send_meeting_cancellations=SEND_TO_NONE,
                                                                      +    affected_task_occurrences=ALL_OCCURRENCES,
                                                                      +    suppress_read_receipts=True,
                                                                      +):
                                                                           # Remove the item permanently. No copies are stored anywhere.
                                                                      -    self._delete(delete_type=HARD_DELETE, send_meeting_cancellations=send_meeting_cancellations,
                                                                      -                 affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts)
                                                                      +    self._delete(
                                                                      +        delete_type=HARD_DELETE,
                                                                      +        send_meeting_cancellations=send_meeting_cancellations,
                                                                      +        affected_task_occurrences=affected_task_occurrences,
                                                                      +        suppress_read_receipts=suppress_read_receipts,
                                                                      +    )
                                                                           self._id, self.folder = None, None
                                                                      @@ -2209,7 +2388,7 @@

                                                                      Methods

                                                                      attachments = list(attachments) for a in attachments: if a.parent_item is not self: - raise ValueError('Attachment does not belong to this item') + raise ValueError("Attachment does not belong to this item") if self.id: # Item is already created. Detach the attachment server-side now a.detach() @@ -2248,6 +2427,7 @@

                                                                      Methods

                                                                      @require_id
                                                                       def move(self, to_folder):
                                                                           from ..services import MoveItem
                                                                      +
                                                                           res = MoveItem(account=self.account).get(
                                                                               items=[self],
                                                                               to_folder=to_folder,
                                                                      @@ -2270,11 +2450,19 @@ 

                                                                      Methods

                                                                      Expand source code -
                                                                      def move_to_trash(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES,
                                                                      -                  suppress_read_receipts=True):
                                                                      +
                                                                      def move_to_trash(
                                                                      +    self,
                                                                      +    send_meeting_cancellations=SEND_TO_NONE,
                                                                      +    affected_task_occurrences=ALL_OCCURRENCES,
                                                                      +    suppress_read_receipts=True,
                                                                      +):
                                                                           # Delete and move to the trash folder.
                                                                      -    self._delete(delete_type=MOVE_TO_DELETED_ITEMS, send_meeting_cancellations=send_meeting_cancellations,
                                                                      -                 affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts)
                                                                      +    self._delete(
                                                                      +        delete_type=MOVE_TO_DELETED_ITEMS,
                                                                      +        send_meeting_cancellations=send_meeting_cancellations,
                                                                      +        affected_task_occurrences=affected_task_occurrences,
                                                                      +        suppress_read_receipts=suppress_read_receipts,
                                                                      +    )
                                                                           self._id = None
                                                                           self.folder = self.account.trash
                                                                      @@ -2293,6 +2481,7 @@

                                                                      Methods

                                                                      # Updates the item based on fresh data from EWS from ..folders import Folder from ..services import GetItem + additional_fields = { FieldPath(field=f) for f in Folder(root=self.account.root).allowed_item_fields(version=self.account.version) } @@ -2320,16 +2509,19 @@

                                                                      Methods

                                                                      def save(self, update_fields=None, conflict_resolution=AUTO_RESOLVE, send_meeting_invitations=SEND_TO_NONE):
                                                                           from .task import Task
                                                                      +
                                                                           if self.id:
                                                                               item_id, changekey = self._update(
                                                                                   update_fieldnames=update_fields,
                                                                                   message_disposition=SAVE_ONLY,
                                                                                   conflict_resolution=conflict_resolution,
                                                                      -            send_meeting_invitations=send_meeting_invitations
                                                                      +            send_meeting_invitations=send_meeting_invitations,
                                                                               )
                                                                      -        if self.id != item_id \
                                                                      -                and not isinstance(self._id, (OccurrenceItemId, RecurringMasterItemId)) \
                                                                      -                and not isinstance(self, Task):
                                                                      +        if (
                                                                      +            self.id != item_id
                                                                      +            and not isinstance(self._id, (OccurrenceItemId, RecurringMasterItemId))
                                                                      +            and not isinstance(self, Task)
                                                                      +        ):
                                                                                   # When we update an item with an OccurrenceItemId as ID, EWS returns the ID of the occurrence, so
                                                                                   # the ID of this item changes.
                                                                                   #
                                                                      @@ -2369,11 +2561,19 @@ 

                                                                      Methods

                                                                      Expand source code -
                                                                      def soft_delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES,
                                                                      -                suppress_read_receipts=True):
                                                                      +
                                                                      def soft_delete(
                                                                      +    self,
                                                                      +    send_meeting_cancellations=SEND_TO_NONE,
                                                                      +    affected_task_occurrences=ALL_OCCURRENCES,
                                                                      +    suppress_read_receipts=True,
                                                                      +):
                                                                           # Delete and move to the dumpster, if it is enabled.
                                                                      -    self._delete(delete_type=SOFT_DELETE, send_meeting_cancellations=send_meeting_cancellations,
                                                                      -                 affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts)
                                                                      +    self._delete(
                                                                      +        delete_type=SOFT_DELETE,
                                                                      +        send_meeting_cancellations=send_meeting_cancellations,
                                                                      +        affected_task_occurrences=affected_task_occurrences,
                                                                      +        suppress_read_receipts=suppress_read_receipts,
                                                                      +    )
                                                                           self._id = None
                                                                           self.folder = self.account.recoverable_items_deletions
                                                                      @@ -2414,7 +2614,7 @@

                                                                      Inherited members

                                                                      class MeetingCancellation(BaseMeetingItem):
                                                                           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/meetingcancellation"""
                                                                       
                                                                      -    ELEMENT_NAME = 'MeetingCancellation'
                                                                      + ELEMENT_NAME = "MeetingCancellation"

                                                                      Ancestors

                                                                        @@ -2469,22 +2669,36 @@

                                                                        Inherited members

                                                                        class MeetingRequest(BaseMeetingItem, AcceptDeclineMixIn):
                                                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/meetingrequest"""
                                                                         
                                                                        -    ELEMENT_NAME = 'MeetingRequest'
                                                                        +    ELEMENT_NAME = "MeetingRequest"
                                                                         
                                                                        -    meeting_request_type = ChoiceField(field_uri='meetingRequest:MeetingRequestType', choices={
                                                                        -        Choice('FullUpdate'), Choice('InformationalUpdate'), Choice('NewMeetingRequest'), Choice('None'),
                                                                        -        Choice('Outdated'), Choice('PrincipalWantsCopy'), Choice('SilentUpdate')
                                                                        -    }, default='None')
                                                                        -    intended_free_busy_status = ChoiceField(field_uri='meetingRequest:IntendedFreeBusyStatus', choices={
                                                                        -            Choice('Free'), Choice('Tentative'), Choice('Busy'), Choice('OOF'), Choice('NoData')
                                                                        -    }, is_required=True, default='Busy')
                                                                        +    meeting_request_type = ChoiceField(
                                                                        +        field_uri="meetingRequest:MeetingRequestType",
                                                                        +        choices={
                                                                        +            Choice("FullUpdate"),
                                                                        +            Choice("InformationalUpdate"),
                                                                        +            Choice("NewMeetingRequest"),
                                                                        +            Choice("None"),
                                                                        +            Choice("Outdated"),
                                                                        +            Choice("PrincipalWantsCopy"),
                                                                        +            Choice("SilentUpdate"),
                                                                        +        },
                                                                        +        default="None",
                                                                        +    )
                                                                        +    intended_free_busy_status = ChoiceField(
                                                                        +        field_uri="meetingRequest:IntendedFreeBusyStatus",
                                                                        +        choices={Choice("Free"), Choice("Tentative"), Choice("Busy"), Choice("OOF"), Choice("NoData")},
                                                                        +        is_required=True,
                                                                        +        default="Busy",
                                                                        +    )
                                                                         
                                                                             # This element also has some fields from CalendarItem
                                                                        -    start_idx = CalendarItem.FIELDS.index_by_name('start')
                                                                        -    is_response_requested_idx = CalendarItem.FIELDS.index_by_name('is_response_requested')
                                                                        -    FIELDS = BaseMeetingItem.FIELDS \
                                                                        -        + CalendarItem.FIELDS[start_idx:is_response_requested_idx]\
                                                                        -        + CalendarItem.FIELDS[is_response_requested_idx + 1:]
                                                                        + start_idx = CalendarItem.FIELDS.index_by_name("start") + is_response_requested_idx = CalendarItem.FIELDS.index_by_name("is_response_requested") + FIELDS = ( + BaseMeetingItem.FIELDS + + CalendarItem.FIELDS[start_idx:is_response_requested_idx] + + CalendarItem.FIELDS[is_response_requested_idx + 1 :] + )

                                                                      Ancestors

                                                                        @@ -2703,12 +2917,10 @@

                                                                        Inherited members

                                                                        class MeetingResponse(BaseMeetingItem):
                                                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/meetingresponse"""
                                                                         
                                                                        -    ELEMENT_NAME = 'MeetingResponse'
                                                                        +    ELEMENT_NAME = "MeetingResponse"
                                                                         
                                                                        -    received_by = MailboxField(field_uri='message:ReceivedBy', is_read_only=True)
                                                                        -    received_representing = MailboxField(field_uri='message:ReceivedRepresenting', is_read_only=True)
                                                                        -    proposed_start = DateTimeField(field_uri='meeting:ProposedStart', supported_from=EXCHANGE_2013)
                                                                        -    proposed_end = DateTimeField(field_uri='meeting:ProposedEnd', supported_from=EXCHANGE_2013)
                                                                        + proposed_start = DateTimeField(field_uri="meeting:ProposedStart", supported_from=EXCHANGE_2013) + proposed_end = DateTimeField(field_uri="meeting:ProposedEnd", supported_from=EXCHANGE_2013)

                                                                      Ancestors

                                                                        @@ -2740,14 +2952,6 @@

                                                                        Instance variables

                                                                        -
                                                                        var received_by
                                                                        -
                                                                        -
                                                                        -
                                                                        -
                                                                        var received_representing
                                                                        -
                                                                        -
                                                                        -

                                                                        Inherited members

                                                                          @@ -2789,37 +2993,52 @@

                                                                          Inherited members

                                                                          https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/message-ex15websvcsotherref """ - ELEMENT_NAME = 'Message' - - sender = MailboxField(field_uri='message:Sender', is_read_only=True, is_read_only_after_send=True) - to_recipients = MailboxListField(field_uri='message:ToRecipients', is_read_only_after_send=True, - is_searchable=False) - cc_recipients = MailboxListField(field_uri='message:CcRecipients', is_read_only_after_send=True, - is_searchable=False) - bcc_recipients = MailboxListField(field_uri='message:BccRecipients', is_read_only_after_send=True, - is_searchable=False) - is_read_receipt_requested = BooleanField(field_uri='message:IsReadReceiptRequested', - is_required=True, default=False, is_read_only_after_send=True) - is_delivery_receipt_requested = BooleanField(field_uri='message:IsDeliveryReceiptRequested', is_required=True, - default=False, is_read_only_after_send=True) - conversation_index = Base64Field(field_uri='message:ConversationIndex', is_read_only=True) - conversation_topic = CharField(field_uri='message:ConversationTopic', is_read_only=True) + ELEMENT_NAME = "Message" + + sender = MailboxField(field_uri="message:Sender", is_read_only=True, is_read_only_after_send=True) + to_recipients = MailboxListField( + field_uri="message:ToRecipients", is_read_only_after_send=True, is_searchable=False + ) + cc_recipients = MailboxListField( + field_uri="message:CcRecipients", is_read_only_after_send=True, is_searchable=False + ) + bcc_recipients = MailboxListField( + field_uri="message:BccRecipients", is_read_only_after_send=True, is_searchable=False + ) + is_read_receipt_requested = BooleanField( + field_uri="message:IsReadReceiptRequested", is_required=True, default=False, is_read_only_after_send=True + ) + is_delivery_receipt_requested = BooleanField( + field_uri="message:IsDeliveryReceiptRequested", is_required=True, default=False, is_read_only_after_send=True + ) + conversation_index = Base64Field(field_uri="message:ConversationIndex", is_read_only=True) + conversation_topic = CharField(field_uri="message:ConversationTopic", is_read_only=True) # Rename 'From' to 'author'. We can't use fieldname 'from' since it's a Python keyword. - author = MailboxField(field_uri='message:From', is_read_only_after_send=True) - message_id = CharField(field_uri='message:InternetMessageId', is_read_only_after_send=True) - is_read = BooleanField(field_uri='message:IsRead', is_required=True, default=False) - is_response_requested = BooleanField(field_uri='message:IsResponseRequested', default=False, is_required=True) - references = TextField(field_uri='message:References') - reply_to = MailboxListField(field_uri='message:ReplyTo', is_read_only_after_send=True, is_searchable=False) - received_by = MailboxField(field_uri='message:ReceivedBy', is_read_only=True) - received_representing = MailboxField(field_uri='message:ReceivedRepresenting', is_read_only=True) - reminder_message_data = EWSElementField(field_uri='message:ReminderMessageData', value_cls=ReminderMessageData, - supported_from=EXCHANGE_2013_SP1, is_read_only=True) + author = MailboxField(field_uri="message:From", is_read_only_after_send=True) + message_id = CharField(field_uri="message:InternetMessageId", is_read_only_after_send=True) + is_read = BooleanField(field_uri="message:IsRead", is_required=True, default=False) + is_response_requested = BooleanField(field_uri="message:IsResponseRequested", default=False, is_required=True) + references = TextField(field_uri="message:References") + reply_to = MailboxListField(field_uri="message:ReplyTo", is_read_only_after_send=True, is_searchable=False) + received_by = MailboxField(field_uri="message:ReceivedBy", is_read_only=True) + received_representing = MailboxField(field_uri="message:ReceivedRepresenting", is_read_only=True) + reminder_message_data = EWSElementField( + field_uri="message:ReminderMessageData", + value_cls=ReminderMessageData, + supported_from=EXCHANGE_2013_SP1, + is_read_only=True, + ) @require_account - def send(self, save_copy=True, copy_to_folder=None, conflict_resolution=AUTO_RESOLVE, - send_meeting_invitations=SEND_TO_NONE): + def send( + self, + save_copy=True, + copy_to_folder=None, + conflict_resolution=AUTO_RESOLVE, + send_meeting_invitations=SEND_TO_NONE, + ): from ..services import SendItem + # Only sends a message. The message can either be an existing draft stored in EWS or a new message that does # not yet exist in EWS. if copy_to_folder and not save_copy: @@ -2837,42 +3056,48 @@

                                                                          Inherited members

                                                                          if copy_to_folder: # This would better be done via send_and_save() but lets just support it here self.folder = copy_to_folder - return self.send_and_save(conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) + return self.send_and_save( + conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations + ) if self.account.version.build < EXCHANGE_2013 and self.attachments: # At least some versions prior to Exchange 2013 can't send attachments immediately. You need to first save, # then attach, then send. This is done in send_and_save(). send() will delete the item again. - self.send_and_save(conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) + self.send_and_save( + conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations + ) return None self._create(message_disposition=SEND_ONLY, send_meeting_invitations=send_meeting_invitations) return None - def send_and_save(self, update_fields=None, conflict_resolution=AUTO_RESOLVE, - send_meeting_invitations=SEND_TO_NONE): + def send_and_save( + self, update_fields=None, conflict_resolution=AUTO_RESOLVE, send_meeting_invitations=SEND_TO_NONE + ): # Sends Message and saves a copy in the parent folder. Does not return an ItemId. if self.id: self._update( update_fieldnames=update_fields, message_disposition=SEND_AND_SAVE_COPY, conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations + send_meeting_invitations=send_meeting_invitations, ) else: if self.account.version.build < EXCHANGE_2013 and self.attachments: # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need # to first save, then attach, then send. This is done in save(). - self.save(update_fields=update_fields, conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) - self.send(save_copy=False, conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) - else: - self._create( - message_disposition=SEND_AND_SAVE_COPY, - send_meeting_invitations=send_meeting_invitations + self.save( + update_fields=update_fields, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, ) + self.send( + save_copy=False, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, + ) + else: + self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations) @require_id def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): @@ -2891,13 +3116,7 @@

                                                                          Inherited members

                                                                          ) def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): - self.create_reply( - subject, - body, - to_recipients, - cc_recipients, - bcc_recipients - ).send() + self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients).send() @require_id def create_reply_all(self, subject, body): @@ -2926,6 +3145,7 @@

                                                                          Inherited members

                                                                          :return: """ from ..services import MarkAsJunk + res = MarkAsJunk(account=self.account).get( items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item ) @@ -3099,6 +3319,7 @@

                                                                          Methods

                                                                          :return: """ from ..services import MarkAsJunk + res = MarkAsJunk(account=self.account).get( items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item ) @@ -3118,13 +3339,7 @@

                                                                          Methods

                                                                          Expand source code
                                                                          def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None):
                                                                          -    self.create_reply(
                                                                          -        subject,
                                                                          -        body,
                                                                          -        to_recipients,
                                                                          -        cc_recipients,
                                                                          -        bcc_recipients
                                                                          -    ).send()
                                                                          + self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients).send()
                                                                      @@ -3150,9 +3365,15 @@

                                                                      Methods

                                                                      Expand source code
                                                                      @require_account
                                                                      -def send(self, save_copy=True, copy_to_folder=None, conflict_resolution=AUTO_RESOLVE,
                                                                      -         send_meeting_invitations=SEND_TO_NONE):
                                                                      +def send(
                                                                      +    self,
                                                                      +    save_copy=True,
                                                                      +    copy_to_folder=None,
                                                                      +    conflict_resolution=AUTO_RESOLVE,
                                                                      +    send_meeting_invitations=SEND_TO_NONE,
                                                                      +):
                                                                           from ..services import SendItem
                                                                      +
                                                                           # Only sends a message. The message can either be an existing draft stored in EWS or a new message that does
                                                                           # not yet exist in EWS.
                                                                           if copy_to_folder and not save_copy:
                                                                      @@ -3170,14 +3391,16 @@ 

                                                                      Methods

                                                                      if copy_to_folder: # This would better be done via send_and_save() but lets just support it here self.folder = copy_to_folder - return self.send_and_save(conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) + return self.send_and_save( + conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations + ) if self.account.version.build < EXCHANGE_2013 and self.attachments: # At least some versions prior to Exchange 2013 can't send attachments immediately. You need to first save, # then attach, then send. This is done in send_and_save(). send() will delete the item again. - self.send_and_save(conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) + self.send_and_save( + conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations + ) return None self._create(message_disposition=SEND_ONLY, send_meeting_invitations=send_meeting_invitations) @@ -3193,29 +3416,33 @@

                                                                      Methods

                                                                      Expand source code -
                                                                      def send_and_save(self, update_fields=None, conflict_resolution=AUTO_RESOLVE,
                                                                      -                  send_meeting_invitations=SEND_TO_NONE):
                                                                      +
                                                                      def send_and_save(
                                                                      +    self, update_fields=None, conflict_resolution=AUTO_RESOLVE, send_meeting_invitations=SEND_TO_NONE
                                                                      +):
                                                                           # Sends Message and saves a copy in the parent folder. Does not return an ItemId.
                                                                           if self.id:
                                                                               self._update(
                                                                                   update_fieldnames=update_fields,
                                                                                   message_disposition=SEND_AND_SAVE_COPY,
                                                                                   conflict_resolution=conflict_resolution,
                                                                      -            send_meeting_invitations=send_meeting_invitations
                                                                      +            send_meeting_invitations=send_meeting_invitations,
                                                                               )
                                                                           else:
                                                                               if self.account.version.build < EXCHANGE_2013 and self.attachments:
                                                                                   # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need
                                                                                   # to first save, then attach, then send. This is done in save().
                                                                      -            self.save(update_fields=update_fields, conflict_resolution=conflict_resolution,
                                                                      -                      send_meeting_invitations=send_meeting_invitations)
                                                                      -            self.send(save_copy=False, conflict_resolution=conflict_resolution,
                                                                      -                      send_meeting_invitations=send_meeting_invitations)
                                                                      +            self.save(
                                                                      +                update_fields=update_fields,
                                                                      +                conflict_resolution=conflict_resolution,
                                                                      +                send_meeting_invitations=send_meeting_invitations,
                                                                      +            )
                                                                      +            self.send(
                                                                      +                save_copy=False,
                                                                      +                conflict_resolution=conflict_resolution,
                                                                      +                send_meeting_invitations=send_meeting_invitations,
                                                                      +            )
                                                                               else:
                                                                      -            self._create(
                                                                      -                message_disposition=SEND_AND_SAVE_COPY,
                                                                      -                send_meeting_invitations=send_meeting_invitations
                                                                      -            )
                                                                      + self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations)
                                                                      @@ -3251,105 +3478,105 @@

                                                                      Inherited members

                                                                      class Persona(IdChangeKeyMixIn):
                                                                           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/persona"""
                                                                       
                                                                      -    ELEMENT_NAME = 'Persona'
                                                                      +    ELEMENT_NAME = "Persona"
                                                                           ID_ELEMENT_CLS = PersonaId
                                                                       
                                                                      -    _id = IdElementField(field_uri='persona:PersonaId', value_cls=ID_ELEMENT_CLS, namespace=TNS)
                                                                      -    persona_type = CharField(field_uri='persona:PersonaType')
                                                                      -    persona_object_type = TextField(field_uri='persona:PersonaObjectStatus')
                                                                      -    creation_time = DateTimeField(field_uri='persona:CreationTime')
                                                                      -    bodies = BodyContentAttributedValueField(field_uri='persona:Bodies')
                                                                      -    display_name_first_last_sort_key = TextField(field_uri='persona:DisplayNameFirstLastSortKey')
                                                                      -    display_name_last_first_sort_key = TextField(field_uri='persona:DisplayNameLastFirstSortKey')
                                                                      -    company_sort_key = TextField(field_uri='persona:CompanyNameSortKey')
                                                                      -    home_sort_key = TextField(field_uri='persona:HomeCitySortKey')
                                                                      -    work_city_sort_key = TextField(field_uri='persona:WorkCitySortKey')
                                                                      -    display_name_first_last_header = CharField(field_uri='persona:DisplayNameFirstLastHeader')
                                                                      -    display_name_last_first_header = CharField(field_uri='persona:DisplayNameLastFirstHeader')
                                                                      -    file_as_header = TextField(field_uri='persona:FileAsHeader')
                                                                      -    display_name = CharField(field_uri='persona:DisplayName')
                                                                      -    display_name_first_last = CharField(field_uri='persona:DisplayNameFirstLast')
                                                                      -    display_name_last_first = CharField(field_uri='persona:DisplayNameLastFirst')
                                                                      -    file_as = CharField(field_uri='persona:FileAs')
                                                                      -    file_as_id = TextField(field_uri='persona:FileAsId')
                                                                      -    display_name_prefix = CharField(field_uri='persona:DisplayNamePrefix')
                                                                      -    given_name = CharField(field_uri='persona:GivenName')
                                                                      -    middle_name = CharField(field_uri='persona:MiddleName')
                                                                      -    surname = CharField(field_uri='persona:Surname')
                                                                      -    generation = CharField(field_uri='persona:Generation')
                                                                      -    nickname = TextField(field_uri='persona:Nickname')
                                                                      -    yomi_company_name = TextField(field_uri='persona:YomiCompanyName')
                                                                      -    yomi_first_name = TextField(field_uri='persona:YomiFirstName')
                                                                      -    yomi_last_name = TextField(field_uri='persona:YomiLastName')
                                                                      -    title = CharField(field_uri='persona:Title')
                                                                      -    department = TextField(field_uri='persona:Department')
                                                                      -    company_name = CharField(field_uri='persona:CompanyName')
                                                                      -    email_address = EWSElementField(field_uri='persona:EmailAddress', value_cls=EmailAddress)
                                                                      -    email_addresses = EWSElementListField(field_uri='persona:EmailAddresses', value_cls=Address)
                                                                      -    PhoneNumber = PersonaPhoneNumberField(field_uri='persona:PhoneNumber')
                                                                      -    im_address = CharField(field_uri='persona:ImAddress')
                                                                      -    home_city = CharField(field_uri='persona:HomeCity')
                                                                      -    work_city = CharField(field_uri='persona:WorkCity')
                                                                      -    relevance_score = CharField(field_uri='persona:RelevanceScore')
                                                                      -    folder_ids = EWSElementListField(field_uri='persona:FolderIds', value_cls=FolderId)
                                                                      -    attributions = EWSElementListField(field_uri='persona:Attributions', value_cls=Attribution)
                                                                      -    display_names = StringAttributedValueField(field_uri='persona:DisplayNames')
                                                                      -    file_ases = StringAttributedValueField(field_uri='persona:FileAses')
                                                                      -    file_as_ids = StringAttributedValueField(field_uri='persona:FileAsIds')
                                                                      -    display_name_prefixes = StringAttributedValueField(field_uri='persona:DisplayNamePrefixes')
                                                                      -    given_names = StringAttributedValueField(field_uri='persona:GivenNames')
                                                                      -    middle_names = StringAttributedValueField(field_uri='persona:MiddleNames')
                                                                      -    surnames = StringAttributedValueField(field_uri='persona:Surnames')
                                                                      -    generations = StringAttributedValueField(field_uri='persona:Generations')
                                                                      -    nicknames = StringAttributedValueField(field_uri='persona:Nicknames')
                                                                      -    initials = StringAttributedValueField(field_uri='persona:Initials')
                                                                      -    yomi_company_names = StringAttributedValueField(field_uri='persona:YomiCompanyNames')
                                                                      -    yomi_first_names = StringAttributedValueField(field_uri='persona:YomiFirstNames')
                                                                      -    yomi_last_names = StringAttributedValueField(field_uri='persona:YomiLastNames')
                                                                      -    business_phone_numbers = PhoneNumberAttributedValueField(field_uri='persona:BusinessPhoneNumbers')
                                                                      -    business_phone_numbers2 = PhoneNumberAttributedValueField(field_uri='persona:BusinessPhoneNumbers2')
                                                                      -    home_phones = PhoneNumberAttributedValueField(field_uri='persona:HomePhones')
                                                                      -    home_phones2 = PhoneNumberAttributedValueField(field_uri='persona:HomePhones2')
                                                                      -    mobile_phones = PhoneNumberAttributedValueField(field_uri='persona:MobilePhones')
                                                                      -    mobile_phones2 = PhoneNumberAttributedValueField(field_uri='persona:MobilePhones2')
                                                                      -    assistant_phone_numbers = PhoneNumberAttributedValueField(field_uri='persona:AssistantPhoneNumbers')
                                                                      -    callback_phones = PhoneNumberAttributedValueField(field_uri='persona:CallbackPhones')
                                                                      -    car_phones = PhoneNumberAttributedValueField(field_uri='persona:CarPhones')
                                                                      -    home_faxes = PhoneNumberAttributedValueField(field_uri='persona:HomeFaxes')
                                                                      -    organization_main_phones = PhoneNumberAttributedValueField(field_uri='persona:OrganizationMainPhones')
                                                                      -    other_faxes = PhoneNumberAttributedValueField(field_uri='persona:OtherFaxes')
                                                                      -    other_telephones = PhoneNumberAttributedValueField(field_uri='persona:OtherTelephones')
                                                                      -    other_phones2 = PhoneNumberAttributedValueField(field_uri='persona:OtherPhones2')
                                                                      -    pagers = PhoneNumberAttributedValueField(field_uri='persona:Pagers')
                                                                      -    radio_phones = PhoneNumberAttributedValueField(field_uri='persona:RadioPhones')
                                                                      -    telex_numbers = PhoneNumberAttributedValueField(field_uri='persona:TelexNumbers')
                                                                      -    tty_tdd_phone_numbers = PhoneNumberAttributedValueField(field_uri='persona:TTYTDDPhoneNumbers')
                                                                      -    work_faxes = PhoneNumberAttributedValueField(field_uri='persona:WorkFaxes')
                                                                      -    emails1 = EmailAddressAttributedValueField(field_uri='persona:Emails1')
                                                                      -    emails2 = EmailAddressAttributedValueField(field_uri='persona:Emails2')
                                                                      -    emails3 = EmailAddressAttributedValueField(field_uri='persona:Emails3')
                                                                      -    business_home_pages = StringAttributedValueField(field_uri='persona:BusinessHomePages')
                                                                      -    personal_home_pages = StringAttributedValueField(field_uri='persona:PersonalHomePages')
                                                                      -    office_locations = StringAttributedValueField(field_uri='persona:OfficeLocations')
                                                                      -    im_addresses = StringAttributedValueField(field_uri='persona:ImAddresses')
                                                                      -    im_addresses2 = StringAttributedValueField(field_uri='persona:ImAddresses2')
                                                                      -    im_addresses3 = StringAttributedValueField(field_uri='persona:ImAddresses3')
                                                                      -    business_addresses = PostalAddressAttributedValueField(field_uri='persona:BusinessAddresses')
                                                                      -    home_addresses = PostalAddressAttributedValueField(field_uri='persona:HomeAddresses')
                                                                      -    other_addresses = PostalAddressAttributedValueField(field_uri='persona:OtherAddresses')
                                                                      -    titles = StringAttributedValueField(field_uri='persona:Titles')
                                                                      -    departments = StringAttributedValueField(field_uri='persona:Departments')
                                                                      -    company_names = StringAttributedValueField(field_uri='persona:CompanyNames')
                                                                      -    managers = StringAttributedValueField(field_uri='persona:Managers')
                                                                      -    assistant_names = StringAttributedValueField(field_uri='persona:AssistantNames')
                                                                      -    professions = StringAttributedValueField(field_uri='persona:Professions')
                                                                      -    spouse_names = StringAttributedValueField(field_uri='persona:SpouseNames')
                                                                      -    children = StringAttributedValueField(field_uri='persona:Children')
                                                                      -    schools = StringAttributedValueField(field_uri='persona:Schools')
                                                                      -    hobbies = StringAttributedValueField(field_uri='persona:Hobbies')
                                                                      -    wedding_anniversaries = StringAttributedValueField(field_uri='persona:WeddingAnniversaries')
                                                                      -    birthdays = StringAttributedValueField(field_uri='persona:Birthdays')
                                                                      -    locations = StringAttributedValueField(field_uri='persona:Locations')
                                                                      + _id = IdElementField(field_uri="persona:PersonaId", value_cls=ID_ELEMENT_CLS, namespace=TNS) + persona_type = CharField(field_uri="persona:PersonaType") + persona_object_type = TextField(field_uri="persona:PersonaObjectStatus") + creation_time = DateTimeField(field_uri="persona:CreationTime") + bodies = BodyContentAttributedValueField(field_uri="persona:Bodies") + display_name_first_last_sort_key = TextField(field_uri="persona:DisplayNameFirstLastSortKey") + display_name_last_first_sort_key = TextField(field_uri="persona:DisplayNameLastFirstSortKey") + company_sort_key = TextField(field_uri="persona:CompanyNameSortKey") + home_sort_key = TextField(field_uri="persona:HomeCitySortKey") + work_city_sort_key = TextField(field_uri="persona:WorkCitySortKey") + display_name_first_last_header = CharField(field_uri="persona:DisplayNameFirstLastHeader") + display_name_last_first_header = CharField(field_uri="persona:DisplayNameLastFirstHeader") + file_as_header = TextField(field_uri="persona:FileAsHeader") + display_name = CharField(field_uri="persona:DisplayName") + display_name_first_last = CharField(field_uri="persona:DisplayNameFirstLast") + display_name_last_first = CharField(field_uri="persona:DisplayNameLastFirst") + file_as = CharField(field_uri="persona:FileAs") + file_as_id = TextField(field_uri="persona:FileAsId") + display_name_prefix = CharField(field_uri="persona:DisplayNamePrefix") + given_name = CharField(field_uri="persona:GivenName") + middle_name = CharField(field_uri="persona:MiddleName") + surname = CharField(field_uri="persona:Surname") + generation = CharField(field_uri="persona:Generation") + nickname = TextField(field_uri="persona:Nickname") + yomi_company_name = TextField(field_uri="persona:YomiCompanyName") + yomi_first_name = TextField(field_uri="persona:YomiFirstName") + yomi_last_name = TextField(field_uri="persona:YomiLastName") + title = CharField(field_uri="persona:Title") + department = TextField(field_uri="persona:Department") + company_name = CharField(field_uri="persona:CompanyName") + email_address = EWSElementField(field_uri="persona:EmailAddress", value_cls=EmailAddress) + email_addresses = EWSElementListField(field_uri="persona:EmailAddresses", value_cls=Address) + PhoneNumber = PersonaPhoneNumberField(field_uri="persona:PhoneNumber") + im_address = CharField(field_uri="persona:ImAddress") + home_city = CharField(field_uri="persona:HomeCity") + work_city = CharField(field_uri="persona:WorkCity") + relevance_score = CharField(field_uri="persona:RelevanceScore") + folder_ids = EWSElementListField(field_uri="persona:FolderIds", value_cls=FolderId) + attributions = EWSElementListField(field_uri="persona:Attributions", value_cls=Attribution) + display_names = StringAttributedValueField(field_uri="persona:DisplayNames") + file_ases = StringAttributedValueField(field_uri="persona:FileAses") + file_as_ids = StringAttributedValueField(field_uri="persona:FileAsIds") + display_name_prefixes = StringAttributedValueField(field_uri="persona:DisplayNamePrefixes") + given_names = StringAttributedValueField(field_uri="persona:GivenNames") + middle_names = StringAttributedValueField(field_uri="persona:MiddleNames") + surnames = StringAttributedValueField(field_uri="persona:Surnames") + generations = StringAttributedValueField(field_uri="persona:Generations") + nicknames = StringAttributedValueField(field_uri="persona:Nicknames") + initials = StringAttributedValueField(field_uri="persona:Initials") + yomi_company_names = StringAttributedValueField(field_uri="persona:YomiCompanyNames") + yomi_first_names = StringAttributedValueField(field_uri="persona:YomiFirstNames") + yomi_last_names = StringAttributedValueField(field_uri="persona:YomiLastNames") + business_phone_numbers = PhoneNumberAttributedValueField(field_uri="persona:BusinessPhoneNumbers") + business_phone_numbers2 = PhoneNumberAttributedValueField(field_uri="persona:BusinessPhoneNumbers2") + home_phones = PhoneNumberAttributedValueField(field_uri="persona:HomePhones") + home_phones2 = PhoneNumberAttributedValueField(field_uri="persona:HomePhones2") + mobile_phones = PhoneNumberAttributedValueField(field_uri="persona:MobilePhones") + mobile_phones2 = PhoneNumberAttributedValueField(field_uri="persona:MobilePhones2") + assistant_phone_numbers = PhoneNumberAttributedValueField(field_uri="persona:AssistantPhoneNumbers") + callback_phones = PhoneNumberAttributedValueField(field_uri="persona:CallbackPhones") + car_phones = PhoneNumberAttributedValueField(field_uri="persona:CarPhones") + home_faxes = PhoneNumberAttributedValueField(field_uri="persona:HomeFaxes") + organization_main_phones = PhoneNumberAttributedValueField(field_uri="persona:OrganizationMainPhones") + other_faxes = PhoneNumberAttributedValueField(field_uri="persona:OtherFaxes") + other_telephones = PhoneNumberAttributedValueField(field_uri="persona:OtherTelephones") + other_phones2 = PhoneNumberAttributedValueField(field_uri="persona:OtherPhones2") + pagers = PhoneNumberAttributedValueField(field_uri="persona:Pagers") + radio_phones = PhoneNumberAttributedValueField(field_uri="persona:RadioPhones") + telex_numbers = PhoneNumberAttributedValueField(field_uri="persona:TelexNumbers") + tty_tdd_phone_numbers = PhoneNumberAttributedValueField(field_uri="persona:TTYTDDPhoneNumbers") + work_faxes = PhoneNumberAttributedValueField(field_uri="persona:WorkFaxes") + emails1 = EmailAddressAttributedValueField(field_uri="persona:Emails1") + emails2 = EmailAddressAttributedValueField(field_uri="persona:Emails2") + emails3 = EmailAddressAttributedValueField(field_uri="persona:Emails3") + business_home_pages = StringAttributedValueField(field_uri="persona:BusinessHomePages") + personal_home_pages = StringAttributedValueField(field_uri="persona:PersonalHomePages") + office_locations = StringAttributedValueField(field_uri="persona:OfficeLocations") + im_addresses = StringAttributedValueField(field_uri="persona:ImAddresses") + im_addresses2 = StringAttributedValueField(field_uri="persona:ImAddresses2") + im_addresses3 = StringAttributedValueField(field_uri="persona:ImAddresses3") + business_addresses = PostalAddressAttributedValueField(field_uri="persona:BusinessAddresses") + home_addresses = PostalAddressAttributedValueField(field_uri="persona:HomeAddresses") + other_addresses = PostalAddressAttributedValueField(field_uri="persona:OtherAddresses") + titles = StringAttributedValueField(field_uri="persona:Titles") + departments = StringAttributedValueField(field_uri="persona:Departments") + company_names = StringAttributedValueField(field_uri="persona:CompanyNames") + managers = StringAttributedValueField(field_uri="persona:Managers") + assistant_names = StringAttributedValueField(field_uri="persona:AssistantNames") + professions = StringAttributedValueField(field_uri="persona:Professions") + spouse_names = StringAttributedValueField(field_uri="persona:SpouseNames") + children = StringAttributedValueField(field_uri="persona:Children") + schools = StringAttributedValueField(field_uri="persona:Schools") + hobbies = StringAttributedValueField(field_uri="persona:Hobbies") + wedding_anniversaries = StringAttributedValueField(field_uri="persona:WeddingAnniversaries") + birthdays = StringAttributedValueField(field_uri="persona:Birthdays") + locations = StringAttributedValueField(field_uri="persona:Locations")

                                                                      Ancestors

                                                                        @@ -3784,18 +4011,18 @@

                                                                        Inherited members

                                                                        class PostItem(Item):
                                                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/postitem"""
                                                                         
                                                                        -    ELEMENT_NAME = 'PostItem'
                                                                        +    ELEMENT_NAME = "PostItem"
                                                                         
                                                                        -    conversation_index = Message.FIELDS['conversation_index']
                                                                        -    conversation_topic = Message.FIELDS['conversation_topic']
                                                                        +    conversation_index = Message.FIELDS["conversation_index"]
                                                                        +    conversation_topic = Message.FIELDS["conversation_topic"]
                                                                         
                                                                        -    author = Message.FIELDS['author']
                                                                        -    message_id = Message.FIELDS['message_id']
                                                                        -    is_read = Message.FIELDS['is_read']
                                                                        +    author = Message.FIELDS["author"]
                                                                        +    message_id = Message.FIELDS["message_id"]
                                                                        +    is_read = Message.FIELDS["is_read"]
                                                                         
                                                                        -    posted_time = DateTimeField(field_uri='postitem:PostedTime', is_read_only=True)
                                                                        -    references = TextField(field_uri='message:References')
                                                                        -    sender = MailboxField(field_uri='message:Sender', is_read_only=True, is_read_only_after_send=True)
                                                                        + posted_time = DateTimeField(field_uri="postitem:PostedTime", is_read_only=True) + references = TextField(field_uri="message:References") + sender = MailboxField(field_uri="message:Sender", is_read_only=True, is_read_only_after_send=True)

                                                                      Ancestors

                                                                        @@ -3888,15 +4115,15 @@

                                                                        Inherited members

                                                                        class PostReplyItem(Item):
                                                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/postreplyitem"""
                                                                         
                                                                        -    ELEMENT_NAME = 'PostReplyItem'
                                                                        +    ELEMENT_NAME = "PostReplyItem"
                                                                         
                                                                             # This element only has Item fields up to, and including, 'culture'
                                                                             # TDO: Plus all message fields
                                                                        -    new_body = BodyField(field_uri='NewBodyContent')  # Accepts and returns Body or HTMLBody instances
                                                                        +    new_body = BodyField(field_uri="NewBodyContent")  # Accepts and returns Body or HTMLBody instances
                                                                         
                                                                        -    culture_idx = Item.FIELDS.index_by_name('culture')
                                                                        -    sender_idx = Message.FIELDS.index_by_name('sender')
                                                                        -    FIELDS = Item.FIELDS[:culture_idx + 1] + Message.FIELDS[sender_idx:]
                                                                        + culture_idx = Item.FIELDS.index_by_name("culture") + sender_idx = Message.FIELDS.index_by_name("sender") + FIELDS = Item.FIELDS[: culture_idx + 1] + Message.FIELDS[sender_idx:]

                                                                      Ancestors

                                                                        @@ -4033,7 +4260,7 @@

                                                                        Inherited members

                                                                        """Base class for classes that can change their list of supported fields dynamically.""" # This class implements dynamic fields on an element class, so we need to include __dict__ in __slots__ - __slots__ = '__dict__', + __slots__ = ("__dict__",) INSERT_AFTER_FIELD = None @@ -4046,7 +4273,7 @@

                                                                        Inherited members

                                                                        :return: """ if not cls.INSERT_AFTER_FIELD: - raise ValueError(f'Class {cls} is missing INSERT_AFTER_FIELD value') + raise ValueError(f"Class {cls} is missing INSERT_AFTER_FIELD value") try: cls.get_field_by_fieldname(attr_name) except InvalidField: @@ -4149,7 +4376,7 @@

                                                                        Static methods

                                                                        :return: """ if not cls.INSERT_AFTER_FIELD: - raise ValueError(f'Class {cls} is missing INSERT_AFTER_FIELD value') + raise ValueError(f"Class {cls} is missing INSERT_AFTER_FIELD value") try: cls.get_field_by_fieldname(attr_name) except InvalidField: @@ -4197,7 +4424,7 @@

                                                                        Inherited members

                                                                        class ReplyAllToItem(BaseReplyItem):
                                                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/replyalltoitem"""
                                                                         
                                                                        -    ELEMENT_NAME = 'ReplyAllToItem'
                                                                        + ELEMENT_NAME = "ReplyAllToItem"

                                                                      Ancestors

                                                                        @@ -4238,7 +4465,7 @@

                                                                        Inherited members

                                                                        class ReplyToItem(BaseReplyItem):
                                                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/replytoitem"""
                                                                         
                                                                        -    ELEMENT_NAME = 'ReplyToItem'
                                                                        + ELEMENT_NAME = "ReplyToItem"

                                                                      Ancestors

                                                                        @@ -4284,49 +4511,78 @@

                                                                        Inherited members

                                                                        class Task(Item):
                                                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/task"""
                                                                         
                                                                        -    ELEMENT_NAME = 'Task'
                                                                        -    NOT_STARTED = 'NotStarted'
                                                                        -    COMPLETED = 'Completed'
                                                                        +    ELEMENT_NAME = "Task"
                                                                        +    NOT_STARTED = "NotStarted"
                                                                        +    COMPLETED = "Completed"
                                                                         
                                                                        -    actual_work = IntegerField(field_uri='task:ActualWork', min=0)
                                                                        -    assigned_time = DateTimeField(field_uri='task:AssignedTime', is_read_only=True)
                                                                        -    billing_information = TextField(field_uri='task:BillingInformation')
                                                                        -    change_count = IntegerField(field_uri='task:ChangeCount', is_read_only=True, min=0)
                                                                        -    companies = TextListField(field_uri='task:Companies')
                                                                        +    actual_work = IntegerField(field_uri="task:ActualWork", min=0)
                                                                        +    assigned_time = DateTimeField(field_uri="task:AssignedTime", is_read_only=True)
                                                                        +    billing_information = TextField(field_uri="task:BillingInformation")
                                                                        +    change_count = IntegerField(field_uri="task:ChangeCount", is_read_only=True, min=0)
                                                                        +    companies = TextListField(field_uri="task:Companies")
                                                                             # 'complete_date' can be set, but is ignored by the server, which sets it to now()
                                                                        -    complete_date = DateTimeField(field_uri='task:CompleteDate', is_read_only=True)
                                                                        -    contacts = TextListField(field_uri='task:Contacts')
                                                                        -    delegation_state = ChoiceField(field_uri='task:DelegationState', choices={
                                                                        -        Choice('NoMatch'), Choice('OwnNew'), Choice('Owned'), Choice('Accepted'), Choice('Declined'), Choice('Max')
                                                                        -    }, is_read_only=True)
                                                                        -    delegator = CharField(field_uri='task:Delegator', is_read_only=True)
                                                                        -    due_date = DateTimeBackedDateField(field_uri='task:DueDate')
                                                                        -    is_editable = BooleanField(field_uri='task:IsAssignmentEditable', is_read_only=True)
                                                                        -    is_complete = BooleanField(field_uri='task:IsComplete', is_read_only=True)
                                                                        -    is_recurring = BooleanField(field_uri='task:IsRecurring', is_read_only=True)
                                                                        -    is_team_task = BooleanField(field_uri='task:IsTeamTask', is_read_only=True)
                                                                        -    mileage = TextField(field_uri='task:Mileage')
                                                                        -    owner = CharField(field_uri='task:Owner', is_read_only=True)
                                                                        -    percent_complete = DecimalField(field_uri='task:PercentComplete', is_required=True, default=Decimal(0.0),
                                                                        -                                    min=Decimal(0), max=Decimal(100), is_searchable=False)
                                                                        -    recurrence = TaskRecurrenceField(field_uri='task:Recurrence', is_searchable=False)
                                                                        -    start_date = DateTimeBackedDateField(field_uri='task:StartDate')
                                                                        -    status = ChoiceField(field_uri='task:Status', choices={
                                                                        -        Choice(NOT_STARTED), Choice('InProgress'), Choice(COMPLETED), Choice('WaitingOnOthers'), Choice('Deferred')
                                                                        -    }, is_required=True, is_searchable=False, default=NOT_STARTED)
                                                                        -    status_description = CharField(field_uri='task:StatusDescription', is_read_only=True)
                                                                        -    total_work = IntegerField(field_uri='task:TotalWork', min=0)
                                                                        +    complete_date = DateTimeField(field_uri="task:CompleteDate", is_read_only=True)
                                                                        +    contacts = TextListField(field_uri="task:Contacts")
                                                                        +    delegation_state = ChoiceField(
                                                                        +        field_uri="task:DelegationState",
                                                                        +        choices={
                                                                        +            Choice("NoMatch"),
                                                                        +            Choice("OwnNew"),
                                                                        +            Choice("Owned"),
                                                                        +            Choice("Accepted"),
                                                                        +            Choice("Declined"),
                                                                        +            Choice("Max"),
                                                                        +        },
                                                                        +        is_read_only=True,
                                                                        +    )
                                                                        +    delegator = CharField(field_uri="task:Delegator", is_read_only=True)
                                                                        +    due_date = DateTimeBackedDateField(field_uri="task:DueDate")
                                                                        +    is_editable = BooleanField(field_uri="task:IsAssignmentEditable", is_read_only=True)
                                                                        +    is_complete = BooleanField(field_uri="task:IsComplete", is_read_only=True)
                                                                        +    is_recurring = BooleanField(field_uri="task:IsRecurring", is_read_only=True)
                                                                        +    is_team_task = BooleanField(field_uri="task:IsTeamTask", is_read_only=True)
                                                                        +    mileage = TextField(field_uri="task:Mileage")
                                                                        +    owner = CharField(field_uri="task:Owner", is_read_only=True)
                                                                        +    percent_complete = DecimalField(
                                                                        +        field_uri="task:PercentComplete",
                                                                        +        is_required=True,
                                                                        +        default=Decimal(0.0),
                                                                        +        min=Decimal(0),
                                                                        +        max=Decimal(100),
                                                                        +        is_searchable=False,
                                                                        +    )
                                                                        +    recurrence = TaskRecurrenceField(field_uri="task:Recurrence", is_searchable=False)
                                                                        +    start_date = DateTimeBackedDateField(field_uri="task:StartDate")
                                                                        +    status = ChoiceField(
                                                                        +        field_uri="task:Status",
                                                                        +        choices={
                                                                        +            Choice(NOT_STARTED),
                                                                        +            Choice("InProgress"),
                                                                        +            Choice(COMPLETED),
                                                                        +            Choice("WaitingOnOthers"),
                                                                        +            Choice("Deferred"),
                                                                        +        },
                                                                        +        is_required=True,
                                                                        +        is_searchable=False,
                                                                        +        default=NOT_STARTED,
                                                                        +    )
                                                                        +    status_description = CharField(field_uri="task:StatusDescription", is_read_only=True)
                                                                        +    total_work = IntegerField(field_uri="task:TotalWork", min=0)
                                                                         
                                                                             def clean(self, version=None):
                                                                                 super().clean(version=version)
                                                                                 if self.due_date and self.start_date and self.due_date < self.start_date:
                                                                        -            log.warning("'due_date' must be greater than 'start_date' (%s vs %s). Resetting 'due_date'",
                                                                        -                        self.due_date, self.start_date)
                                                                        +            log.warning(
                                                                        +                "'due_date' must be greater than 'start_date' (%s vs %s). Resetting 'due_date'",
                                                                        +                self.due_date,
                                                                        +                self.start_date,
                                                                        +            )
                                                                                     self.due_date = self.start_date
                                                                                 if self.complete_date:
                                                                                     if self.status != self.COMPLETED:
                                                                        -                log.warning("'status' must be '%s' when 'complete_date' is set (%s). Resetting",
                                                                        -                            self.COMPLETED, self.status)
                                                                        +                log.warning(
                                                                        +                    "'status' must be '%s' when 'complete_date' is set (%s). Resetting", self.COMPLETED, self.status
                                                                        +                )
                                                                                         self.status = self.COMPLETED
                                                                                     now = datetime.datetime.now(tz=UTC)
                                                                                     if (self.complete_date - now).total_seconds() > 120:
                                                                        @@ -4335,19 +4591,28 @@ 

                                                                        Inherited members

                                                                        log.warning("'complete_date' must be in the past (%s vs %s). Resetting", self.complete_date, now) self.complete_date = now if self.start_date and self.complete_date.date() < self.start_date: - log.warning("'complete_date' must be greater than 'start_date' (%s vs %s). Resetting", - self.complete_date, self.start_date) + log.warning( + "'complete_date' must be greater than 'start_date' (%s vs %s). Resetting", + self.complete_date, + self.start_date, + ) self.complete_date = EWSDateTime.combine(self.start_date, datetime.time(0, 0)).replace(tzinfo=UTC) if self.percent_complete is not None: if self.status == self.COMPLETED and self.percent_complete != Decimal(100): # percent_complete must be 100% if task is complete - log.warning("'percent_complete' must be 100 when 'status' is '%s' (%s). Resetting", - self.COMPLETED, self.percent_complete) + log.warning( + "'percent_complete' must be 100 when 'status' is '%s' (%s). Resetting", + self.COMPLETED, + self.percent_complete, + ) self.percent_complete = Decimal(100) elif self.status == self.NOT_STARTED and self.percent_complete != Decimal(0): # percent_complete must be 0% if task is not started - log.warning("'percent_complete' must be 0 when 'status' is '%s' (%s). Resetting", - self.NOT_STARTED, self.percent_complete) + log.warning( + "'percent_complete' must be 0 when 'status' is '%s' (%s). Resetting", + self.NOT_STARTED, + self.percent_complete, + ) self.percent_complete = Decimal(0) def complete(self): @@ -4488,13 +4753,17 @@

                                                                        Methods

                                                                        def clean(self, version=None):
                                                                             super().clean(version=version)
                                                                             if self.due_date and self.start_date and self.due_date < self.start_date:
                                                                        -        log.warning("'due_date' must be greater than 'start_date' (%s vs %s). Resetting 'due_date'",
                                                                        -                    self.due_date, self.start_date)
                                                                        +        log.warning(
                                                                        +            "'due_date' must be greater than 'start_date' (%s vs %s). Resetting 'due_date'",
                                                                        +            self.due_date,
                                                                        +            self.start_date,
                                                                        +        )
                                                                                 self.due_date = self.start_date
                                                                             if self.complete_date:
                                                                                 if self.status != self.COMPLETED:
                                                                        -            log.warning("'status' must be '%s' when 'complete_date' is set (%s). Resetting",
                                                                        -                        self.COMPLETED, self.status)
                                                                        +            log.warning(
                                                                        +                "'status' must be '%s' when 'complete_date' is set (%s). Resetting", self.COMPLETED, self.status
                                                                        +            )
                                                                                     self.status = self.COMPLETED
                                                                                 now = datetime.datetime.now(tz=UTC)
                                                                                 if (self.complete_date - now).total_seconds() > 120:
                                                                        @@ -4503,19 +4772,28 @@ 

                                                                        Methods

                                                                        log.warning("'complete_date' must be in the past (%s vs %s). Resetting", self.complete_date, now) self.complete_date = now if self.start_date and self.complete_date.date() < self.start_date: - log.warning("'complete_date' must be greater than 'start_date' (%s vs %s). Resetting", - self.complete_date, self.start_date) + log.warning( + "'complete_date' must be greater than 'start_date' (%s vs %s). Resetting", + self.complete_date, + self.start_date, + ) self.complete_date = EWSDateTime.combine(self.start_date, datetime.time(0, 0)).replace(tzinfo=UTC) if self.percent_complete is not None: if self.status == self.COMPLETED and self.percent_complete != Decimal(100): # percent_complete must be 100% if task is complete - log.warning("'percent_complete' must be 100 when 'status' is '%s' (%s). Resetting", - self.COMPLETED, self.percent_complete) + log.warning( + "'percent_complete' must be 100 when 'status' is '%s' (%s). Resetting", + self.COMPLETED, + self.percent_complete, + ) self.percent_complete = Decimal(100) elif self.status == self.NOT_STARTED and self.percent_complete != Decimal(0): # percent_complete must be 0% if task is not started - log.warning("'percent_complete' must be 0 when 'status' is '%s' (%s). Resetting", - self.NOT_STARTED, self.percent_complete) + log.warning( + "'percent_complete' must be 0 when 'status' is '%s' (%s). Resetting", + self.NOT_STARTED, + self.percent_complete, + ) self.percent_complete = Decimal(0)
                                                                        @@ -4573,7 +4851,7 @@

                                                                        Inherited members

                                                                        class TentativelyAcceptItem(BaseMeetingReplyItem):
                                                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/tentativelyacceptitem"""
                                                                         
                                                                        -    ELEMENT_NAME = 'TentativelyAcceptItem'
                                                                        + ELEMENT_NAME = "TentativelyAcceptItem"

                                                                        Ancestors

                                                                      • diff --git a/docs/exchangelib/items/item.html b/docs/exchangelib/items/item.html index eed17660..8bc52663 100644 --- a/docs/exchangelib/items/item.html +++ b/docs/exchangelib/items/item.html @@ -28,15 +28,48 @@

                                                                        Module exchangelib.items.item

                                                                        import logging
                                                                         
                                                                        -from .base import BaseItem, SAVE_ONLY, SEND_AND_SAVE_COPY, ID_ONLY, SEND_TO_NONE, \
                                                                        -    AUTO_RESOLVE, SOFT_DELETE, HARD_DELETE, ALL_OCCURRENCES, MOVE_TO_DELETED_ITEMS
                                                                        -from ..fields import BooleanField, IntegerField, TextField, CharListField, ChoiceField, URIField, BodyField, \
                                                                        -    DateTimeField, MessageHeaderField, AttachmentField, Choice, EWSElementField, EffectiveRightsField, CultureField, \
                                                                        -    CharField, MimeContentField, FieldPath
                                                                        -from ..properties import ConversationId, ParentFolderId, ReferenceItemId, OccurrenceItemId, RecurringMasterItemId, \
                                                                        -    ResponseObjects, Fields
                                                                        +from ..fields import (
                                                                        +    AttachmentField,
                                                                        +    BodyField,
                                                                        +    BooleanField,
                                                                        +    CharField,
                                                                        +    CharListField,
                                                                        +    Choice,
                                                                        +    ChoiceField,
                                                                        +    CultureField,
                                                                        +    DateTimeField,
                                                                        +    EffectiveRightsField,
                                                                        +    EWSElementField,
                                                                        +    FieldPath,
                                                                        +    IntegerField,
                                                                        +    MessageHeaderField,
                                                                        +    MimeContentField,
                                                                        +    TextField,
                                                                        +    URIField,
                                                                        +)
                                                                        +from ..properties import (
                                                                        +    ConversationId,
                                                                        +    Fields,
                                                                        +    OccurrenceItemId,
                                                                        +    ParentFolderId,
                                                                        +    RecurringMasterItemId,
                                                                        +    ReferenceItemId,
                                                                        +    ResponseObjects,
                                                                        +)
                                                                         from ..util import is_iterable, require_account, require_id
                                                                         from ..version import EXCHANGE_2010, EXCHANGE_2013
                                                                        +from .base import (
                                                                        +    ALL_OCCURRENCES,
                                                                        +    AUTO_RESOLVE,
                                                                        +    HARD_DELETE,
                                                                        +    ID_ONLY,
                                                                        +    MOVE_TO_DELETED_ITEMS,
                                                                        +    SAVE_ONLY,
                                                                        +    SEND_AND_SAVE_COPY,
                                                                        +    SEND_TO_NONE,
                                                                        +    SOFT_DELETE,
                                                                        +    BaseItem,
                                                                        +)
                                                                         
                                                                         log = logging.getLogger(__name__)
                                                                         
                                                                        @@ -44,62 +77,75 @@ 

                                                                        Module exchangelib.items.item

                                                                        class Item(BaseItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/item""" - ELEMENT_NAME = 'Item' - - mime_content = MimeContentField(field_uri='item:MimeContent', is_read_only_after_send=True) - _id = BaseItem.FIELDS['_id'] - parent_folder_id = EWSElementField(field_uri='item:ParentFolderId', value_cls=ParentFolderId, is_read_only=True) - item_class = CharField(field_uri='item:ItemClass', is_read_only=True) - subject = CharField(field_uri='item:Subject') - sensitivity = ChoiceField(field_uri='item:Sensitivity', choices={ - Choice('Normal'), Choice('Personal'), Choice('Private'), Choice('Confidential') - }, is_required=True, default='Normal') - text_body = TextField(field_uri='item:TextBody', is_read_only=True, supported_from=EXCHANGE_2013) - body = BodyField(field_uri='item:Body') # Accepts and returns Body or HTMLBody instances - attachments = AttachmentField(field_uri='item:Attachments') # ItemAttachment or FileAttachment - datetime_received = DateTimeField(field_uri='item:DateTimeReceived', is_read_only=True) - size = IntegerField(field_uri='item:Size', is_read_only=True) # Item size in bytes - categories = CharListField(field_uri='item:Categories') - importance = ChoiceField(field_uri='item:Importance', choices={ - Choice('Low'), Choice('Normal'), Choice('High') - }, is_required=True, default='Normal') - in_reply_to = TextField(field_uri='item:InReplyTo') - is_submitted = BooleanField(field_uri='item:IsSubmitted', is_read_only=True) - is_draft = BooleanField(field_uri='item:IsDraft', is_read_only=True) - is_from_me = BooleanField(field_uri='item:IsFromMe', is_read_only=True) - is_resend = BooleanField(field_uri='item:IsResend', is_read_only=True) - is_unmodified = BooleanField(field_uri='item:IsUnmodified', is_read_only=True) - headers = MessageHeaderField(field_uri='item:InternetMessageHeaders', is_read_only=True) - datetime_sent = DateTimeField(field_uri='item:DateTimeSent', is_read_only=True) - datetime_created = DateTimeField(field_uri='item:DateTimeCreated', is_read_only=True) - response_objects = EWSElementField(field_uri='item:ResponseObjects', value_cls=ResponseObjects, - is_read_only=True,) + ELEMENT_NAME = "Item" + + mime_content = MimeContentField(field_uri="item:MimeContent", is_read_only_after_send=True) + _id = BaseItem.FIELDS["_id"] + parent_folder_id = EWSElementField(field_uri="item:ParentFolderId", value_cls=ParentFolderId, is_read_only=True) + item_class = CharField(field_uri="item:ItemClass", is_read_only=True) + subject = CharField(field_uri="item:Subject") + sensitivity = ChoiceField( + field_uri="item:Sensitivity", + choices={Choice("Normal"), Choice("Personal"), Choice("Private"), Choice("Confidential")}, + is_required=True, + default="Normal", + ) + text_body = TextField(field_uri="item:TextBody", is_read_only=True, supported_from=EXCHANGE_2013) + body = BodyField(field_uri="item:Body") # Accepts and returns Body or HTMLBody instances + attachments = AttachmentField(field_uri="item:Attachments") # ItemAttachment or FileAttachment + datetime_received = DateTimeField(field_uri="item:DateTimeReceived", is_read_only=True) + size = IntegerField(field_uri="item:Size", is_read_only=True) # Item size in bytes + categories = CharListField(field_uri="item:Categories") + importance = ChoiceField( + field_uri="item:Importance", + choices={Choice("Low"), Choice("Normal"), Choice("High")}, + is_required=True, + default="Normal", + ) + in_reply_to = TextField(field_uri="item:InReplyTo") + is_submitted = BooleanField(field_uri="item:IsSubmitted", is_read_only=True) + is_draft = BooleanField(field_uri="item:IsDraft", is_read_only=True) + is_from_me = BooleanField(field_uri="item:IsFromMe", is_read_only=True) + is_resend = BooleanField(field_uri="item:IsResend", is_read_only=True) + is_unmodified = BooleanField(field_uri="item:IsUnmodified", is_read_only=True) + headers = MessageHeaderField(field_uri="item:InternetMessageHeaders", is_read_only=True) + datetime_sent = DateTimeField(field_uri="item:DateTimeSent", is_read_only=True) + datetime_created = DateTimeField(field_uri="item:DateTimeCreated", is_read_only=True) + response_objects = EWSElementField( + field_uri="item:ResponseObjects", + value_cls=ResponseObjects, + is_read_only=True, + ) # Placeholder for ResponseObjects - reminder_due_by = DateTimeField(field_uri='item:ReminderDueBy', is_required_after_save=True, is_searchable=False) - reminder_is_set = BooleanField(field_uri='item:ReminderIsSet', is_required=True, default=False) - reminder_minutes_before_start = IntegerField(field_uri='item:ReminderMinutesBeforeStart', - is_required_after_save=True, min=0, default=0) - display_cc = TextField(field_uri='item:DisplayCc', is_read_only=True) - display_to = TextField(field_uri='item:DisplayTo', is_read_only=True) - has_attachments = BooleanField(field_uri='item:HasAttachments', is_read_only=True) + reminder_due_by = DateTimeField(field_uri="item:ReminderDueBy", is_required_after_save=True, is_searchable=False) + reminder_is_set = BooleanField(field_uri="item:ReminderIsSet", is_required=True, default=False) + reminder_minutes_before_start = IntegerField( + field_uri="item:ReminderMinutesBeforeStart", is_required_after_save=True, min=0, default=0 + ) + display_cc = TextField(field_uri="item:DisplayCc", is_read_only=True) + display_to = TextField(field_uri="item:DisplayTo", is_read_only=True) + has_attachments = BooleanField(field_uri="item:HasAttachments", is_read_only=True) # ExtendedProperty fields go here - culture = CultureField(field_uri='item:Culture', is_required_after_save=True, is_searchable=False) - effective_rights = EffectiveRightsField(field_uri='item:EffectiveRights', is_read_only=True) - last_modified_name = CharField(field_uri='item:LastModifiedName', is_read_only=True) - last_modified_time = DateTimeField(field_uri='item:LastModifiedTime', is_read_only=True) - is_associated = BooleanField(field_uri='item:IsAssociated', is_read_only=True, supported_from=EXCHANGE_2010) - web_client_read_form_query_string = URIField(field_uri='item:WebClientReadFormQueryString', is_read_only=True, - supported_from=EXCHANGE_2010) - web_client_edit_form_query_string = URIField(field_uri='item:WebClientEditFormQueryString', is_read_only=True, - supported_from=EXCHANGE_2010) - conversation_id = EWSElementField(field_uri='item:ConversationId', value_cls=ConversationId, - is_read_only=True, supported_from=EXCHANGE_2010) - unique_body = BodyField(field_uri='item:UniqueBody', is_read_only=True, supported_from=EXCHANGE_2010) + culture = CultureField(field_uri="item:Culture", is_required_after_save=True, is_searchable=False) + effective_rights = EffectiveRightsField(field_uri="item:EffectiveRights", is_read_only=True) + last_modified_name = CharField(field_uri="item:LastModifiedName", is_read_only=True) + last_modified_time = DateTimeField(field_uri="item:LastModifiedTime", is_read_only=True) + is_associated = BooleanField(field_uri="item:IsAssociated", is_read_only=True, supported_from=EXCHANGE_2010) + web_client_read_form_query_string = URIField( + field_uri="item:WebClientReadFormQueryString", is_read_only=True, supported_from=EXCHANGE_2010 + ) + web_client_edit_form_query_string = URIField( + field_uri="item:WebClientEditFormQueryString", is_read_only=True, supported_from=EXCHANGE_2010 + ) + conversation_id = EWSElementField( + field_uri="item:ConversationId", value_cls=ConversationId, is_read_only=True, supported_from=EXCHANGE_2010 + ) + unique_body = BodyField(field_uri="item:UniqueBody", is_read_only=True, supported_from=EXCHANGE_2010) FIELDS = Fields() # Used to register extended properties - INSERT_AFTER_FIELD = 'has_attachments' + INSERT_AFTER_FIELD = "has_attachments" def __init__(self, **kwargs): super().__init__(**kwargs) @@ -116,16 +162,19 @@

                                                                        Module exchangelib.items.item

                                                                        def save(self, update_fields=None, conflict_resolution=AUTO_RESOLVE, send_meeting_invitations=SEND_TO_NONE): from .task import Task + if self.id: item_id, changekey = self._update( update_fieldnames=update_fields, message_disposition=SAVE_ONLY, conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations + send_meeting_invitations=send_meeting_invitations, ) - if self.id != item_id \ - and not isinstance(self._id, (OccurrenceItemId, RecurringMasterItemId)) \ - and not isinstance(self, Task): + if ( + self.id != item_id + and not isinstance(self._id, (OccurrenceItemId, RecurringMasterItemId)) + and not isinstance(self, Task) + ): # When we update an item with an OccurrenceItemId as ID, EWS returns the ID of the occurrence, so # the ID of this item changes. # @@ -160,6 +209,7 @@

                                                                        Module exchangelib.items.item

                                                                        # Return a BulkCreateResult because we want to return the ID of both the main item *and* attachments. In send # and send-and-save-copy mode, the server does not return an ID, so we just return True. from ..services import CreateItem + return CreateItem(account=self.account).get( items=[self], folder=self.folder, @@ -169,27 +219,28 @@

                                                                        Module exchangelib.items.item

                                                                        def _update_fieldnames(self): from .contact import Contact, DistributionList + # Return the list of fields we are allowed to update update_fieldnames = [] for f in self.supported_fields(version=self.account.version): - if f.name == 'attachments': + if f.name == "attachments": # Attachments are handled separately after item creation continue if f.is_read_only: # These cannot be changed continue if (f.is_required or f.is_required_after_save) and ( - getattr(self, f.name) is None or (f.is_list and not getattr(self, f.name)) + getattr(self, f.name) is None or (f.is_list and not getattr(self, f.name)) ): # These are required and cannot be deleted continue if not self.is_draft and f.is_read_only_after_send: # These cannot be changed when the item is no longer a draft continue - if f.name == 'message_id' and f.is_read_only_after_send: + if f.name == "message_id" and f.is_read_only_after_send: # 'message_id' doesn't support updating, no matter the draft status continue - if f.name == 'mime_content' and isinstance(self, (Contact, DistributionList)): + if f.name == "mime_content" and isinstance(self, (Contact, DistributionList)): # Contact and DistributionList don't support updating mime_content, no matter the draft status continue update_fieldnames.append(f.name) @@ -198,8 +249,9 @@

                                                                        Module exchangelib.items.item

                                                                        @require_account def _update(self, update_fieldnames, message_disposition, conflict_resolution, send_meeting_invitations): from ..services import UpdateItem + if not self.changekey: - raise ValueError(f'{self.__class__.__name__} must have changekey') + raise ValueError(f"{self.__class__.__name__} must have changekey") if not update_fieldnames: # The fields to update was not specified explicitly. Update all fields where update is possible update_fieldnames = self._update_fieldnames() @@ -217,6 +269,7 @@

                                                                        Module exchangelib.items.item

                                                                        # Updates the item based on fresh data from EWS from ..folders import Folder from ..services import GetItem + additional_fields = { FieldPath(field=f) for f in Folder(root=self.account.root).allowed_item_fields(version=self.account.version) } @@ -235,6 +288,7 @@

                                                                        Module exchangelib.items.item

                                                                        @require_id def copy(self, to_folder): from ..services import CopyItem + # If 'to_folder' is a public folder or a folder in a different mailbox then None is returned return CopyItem(account=self.account).get( items=[self], @@ -245,6 +299,7 @@

                                                                        Module exchangelib.items.item

                                                                        @require_id def move(self, to_folder): from ..services import MoveItem + res = MoveItem(account=self.account).get( items=[self], to_folder=to_folder, @@ -257,32 +312,57 @@

                                                                        Module exchangelib.items.item

                                                                        self._id = self.ID_ELEMENT_CLS(*res) self.folder = to_folder - def move_to_trash(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, - suppress_read_receipts=True): + def move_to_trash( + self, + send_meeting_cancellations=SEND_TO_NONE, + affected_task_occurrences=ALL_OCCURRENCES, + suppress_read_receipts=True, + ): # Delete and move to the trash folder. - self._delete(delete_type=MOVE_TO_DELETED_ITEMS, send_meeting_cancellations=send_meeting_cancellations, - affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts) + self._delete( + delete_type=MOVE_TO_DELETED_ITEMS, + send_meeting_cancellations=send_meeting_cancellations, + affected_task_occurrences=affected_task_occurrences, + suppress_read_receipts=suppress_read_receipts, + ) self._id = None self.folder = self.account.trash - def soft_delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, - suppress_read_receipts=True): + def soft_delete( + self, + send_meeting_cancellations=SEND_TO_NONE, + affected_task_occurrences=ALL_OCCURRENCES, + suppress_read_receipts=True, + ): # Delete and move to the dumpster, if it is enabled. - self._delete(delete_type=SOFT_DELETE, send_meeting_cancellations=send_meeting_cancellations, - affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts) + self._delete( + delete_type=SOFT_DELETE, + send_meeting_cancellations=send_meeting_cancellations, + affected_task_occurrences=affected_task_occurrences, + suppress_read_receipts=suppress_read_receipts, + ) self._id = None self.folder = self.account.recoverable_items_deletions - def delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, - suppress_read_receipts=True): + def delete( + self, + send_meeting_cancellations=SEND_TO_NONE, + affected_task_occurrences=ALL_OCCURRENCES, + suppress_read_receipts=True, + ): # Remove the item permanently. No copies are stored anywhere. - self._delete(delete_type=HARD_DELETE, send_meeting_cancellations=send_meeting_cancellations, - affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts) + self._delete( + delete_type=HARD_DELETE, + send_meeting_cancellations=send_meeting_cancellations, + affected_task_occurrences=affected_task_occurrences, + suppress_read_receipts=suppress_read_receipts, + ) self._id, self.folder = None, None @require_id def _delete(self, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): from ..services import DeleteItem + DeleteItem(account=self.account).get( items=[self], delete_type=delete_type, @@ -294,6 +374,7 @@

                                                                        Module exchangelib.items.item

                                                                        @require_id def archive(self, to_folder): from ..services import ArchiveItem + return ArchiveItem(account=self.account).get(items=[self], to_folder=to_folder, expect_result=True) def attach(self, attachments): @@ -332,7 +413,7 @@

                                                                        Module exchangelib.items.item

                                                                        attachments = list(attachments) for a in attachments: if a.parent_item is not self: - raise ValueError('Attachment does not belong to this item') + raise ValueError("Attachment does not belong to this item") if self.id: # Item is already created. Detach the attachment server-side now a.detach() @@ -342,6 +423,7 @@

                                                                        Module exchangelib.items.item

                                                                        @require_id def create_forward(self, subject, body, to_recipients, cc_recipients=None, bcc_recipients=None): from .message import ForwardItem + return ForwardItem( account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), @@ -389,62 +471,75 @@

                                                                        Classes

                                                                        class Item(BaseItem):
                                                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/item"""
                                                                         
                                                                        -    ELEMENT_NAME = 'Item'
                                                                        -
                                                                        -    mime_content = MimeContentField(field_uri='item:MimeContent', is_read_only_after_send=True)
                                                                        -    _id = BaseItem.FIELDS['_id']
                                                                        -    parent_folder_id = EWSElementField(field_uri='item:ParentFolderId', value_cls=ParentFolderId, is_read_only=True)
                                                                        -    item_class = CharField(field_uri='item:ItemClass', is_read_only=True)
                                                                        -    subject = CharField(field_uri='item:Subject')
                                                                        -    sensitivity = ChoiceField(field_uri='item:Sensitivity', choices={
                                                                        -        Choice('Normal'), Choice('Personal'), Choice('Private'), Choice('Confidential')
                                                                        -    }, is_required=True, default='Normal')
                                                                        -    text_body = TextField(field_uri='item:TextBody', is_read_only=True, supported_from=EXCHANGE_2013)
                                                                        -    body = BodyField(field_uri='item:Body')  # Accepts and returns Body or HTMLBody instances
                                                                        -    attachments = AttachmentField(field_uri='item:Attachments')  # ItemAttachment or FileAttachment
                                                                        -    datetime_received = DateTimeField(field_uri='item:DateTimeReceived', is_read_only=True)
                                                                        -    size = IntegerField(field_uri='item:Size', is_read_only=True)  # Item size in bytes
                                                                        -    categories = CharListField(field_uri='item:Categories')
                                                                        -    importance = ChoiceField(field_uri='item:Importance', choices={
                                                                        -        Choice('Low'), Choice('Normal'), Choice('High')
                                                                        -    }, is_required=True, default='Normal')
                                                                        -    in_reply_to = TextField(field_uri='item:InReplyTo')
                                                                        -    is_submitted = BooleanField(field_uri='item:IsSubmitted', is_read_only=True)
                                                                        -    is_draft = BooleanField(field_uri='item:IsDraft', is_read_only=True)
                                                                        -    is_from_me = BooleanField(field_uri='item:IsFromMe', is_read_only=True)
                                                                        -    is_resend = BooleanField(field_uri='item:IsResend', is_read_only=True)
                                                                        -    is_unmodified = BooleanField(field_uri='item:IsUnmodified', is_read_only=True)
                                                                        -    headers = MessageHeaderField(field_uri='item:InternetMessageHeaders', is_read_only=True)
                                                                        -    datetime_sent = DateTimeField(field_uri='item:DateTimeSent', is_read_only=True)
                                                                        -    datetime_created = DateTimeField(field_uri='item:DateTimeCreated', is_read_only=True)
                                                                        -    response_objects = EWSElementField(field_uri='item:ResponseObjects', value_cls=ResponseObjects,
                                                                        -                                       is_read_only=True,)
                                                                        +    ELEMENT_NAME = "Item"
                                                                        +
                                                                        +    mime_content = MimeContentField(field_uri="item:MimeContent", is_read_only_after_send=True)
                                                                        +    _id = BaseItem.FIELDS["_id"]
                                                                        +    parent_folder_id = EWSElementField(field_uri="item:ParentFolderId", value_cls=ParentFolderId, is_read_only=True)
                                                                        +    item_class = CharField(field_uri="item:ItemClass", is_read_only=True)
                                                                        +    subject = CharField(field_uri="item:Subject")
                                                                        +    sensitivity = ChoiceField(
                                                                        +        field_uri="item:Sensitivity",
                                                                        +        choices={Choice("Normal"), Choice("Personal"), Choice("Private"), Choice("Confidential")},
                                                                        +        is_required=True,
                                                                        +        default="Normal",
                                                                        +    )
                                                                        +    text_body = TextField(field_uri="item:TextBody", is_read_only=True, supported_from=EXCHANGE_2013)
                                                                        +    body = BodyField(field_uri="item:Body")  # Accepts and returns Body or HTMLBody instances
                                                                        +    attachments = AttachmentField(field_uri="item:Attachments")  # ItemAttachment or FileAttachment
                                                                        +    datetime_received = DateTimeField(field_uri="item:DateTimeReceived", is_read_only=True)
                                                                        +    size = IntegerField(field_uri="item:Size", is_read_only=True)  # Item size in bytes
                                                                        +    categories = CharListField(field_uri="item:Categories")
                                                                        +    importance = ChoiceField(
                                                                        +        field_uri="item:Importance",
                                                                        +        choices={Choice("Low"), Choice("Normal"), Choice("High")},
                                                                        +        is_required=True,
                                                                        +        default="Normal",
                                                                        +    )
                                                                        +    in_reply_to = TextField(field_uri="item:InReplyTo")
                                                                        +    is_submitted = BooleanField(field_uri="item:IsSubmitted", is_read_only=True)
                                                                        +    is_draft = BooleanField(field_uri="item:IsDraft", is_read_only=True)
                                                                        +    is_from_me = BooleanField(field_uri="item:IsFromMe", is_read_only=True)
                                                                        +    is_resend = BooleanField(field_uri="item:IsResend", is_read_only=True)
                                                                        +    is_unmodified = BooleanField(field_uri="item:IsUnmodified", is_read_only=True)
                                                                        +    headers = MessageHeaderField(field_uri="item:InternetMessageHeaders", is_read_only=True)
                                                                        +    datetime_sent = DateTimeField(field_uri="item:DateTimeSent", is_read_only=True)
                                                                        +    datetime_created = DateTimeField(field_uri="item:DateTimeCreated", is_read_only=True)
                                                                        +    response_objects = EWSElementField(
                                                                        +        field_uri="item:ResponseObjects",
                                                                        +        value_cls=ResponseObjects,
                                                                        +        is_read_only=True,
                                                                        +    )
                                                                             # Placeholder for ResponseObjects
                                                                        -    reminder_due_by = DateTimeField(field_uri='item:ReminderDueBy', is_required_after_save=True, is_searchable=False)
                                                                        -    reminder_is_set = BooleanField(field_uri='item:ReminderIsSet', is_required=True, default=False)
                                                                        -    reminder_minutes_before_start = IntegerField(field_uri='item:ReminderMinutesBeforeStart',
                                                                        -                                                 is_required_after_save=True, min=0, default=0)
                                                                        -    display_cc = TextField(field_uri='item:DisplayCc', is_read_only=True)
                                                                        -    display_to = TextField(field_uri='item:DisplayTo', is_read_only=True)
                                                                        -    has_attachments = BooleanField(field_uri='item:HasAttachments', is_read_only=True)
                                                                        +    reminder_due_by = DateTimeField(field_uri="item:ReminderDueBy", is_required_after_save=True, is_searchable=False)
                                                                        +    reminder_is_set = BooleanField(field_uri="item:ReminderIsSet", is_required=True, default=False)
                                                                        +    reminder_minutes_before_start = IntegerField(
                                                                        +        field_uri="item:ReminderMinutesBeforeStart", is_required_after_save=True, min=0, default=0
                                                                        +    )
                                                                        +    display_cc = TextField(field_uri="item:DisplayCc", is_read_only=True)
                                                                        +    display_to = TextField(field_uri="item:DisplayTo", is_read_only=True)
                                                                        +    has_attachments = BooleanField(field_uri="item:HasAttachments", is_read_only=True)
                                                                             # ExtendedProperty fields go here
                                                                        -    culture = CultureField(field_uri='item:Culture', is_required_after_save=True, is_searchable=False)
                                                                        -    effective_rights = EffectiveRightsField(field_uri='item:EffectiveRights', is_read_only=True)
                                                                        -    last_modified_name = CharField(field_uri='item:LastModifiedName', is_read_only=True)
                                                                        -    last_modified_time = DateTimeField(field_uri='item:LastModifiedTime', is_read_only=True)
                                                                        -    is_associated = BooleanField(field_uri='item:IsAssociated', is_read_only=True, supported_from=EXCHANGE_2010)
                                                                        -    web_client_read_form_query_string = URIField(field_uri='item:WebClientReadFormQueryString', is_read_only=True,
                                                                        -                                                 supported_from=EXCHANGE_2010)
                                                                        -    web_client_edit_form_query_string = URIField(field_uri='item:WebClientEditFormQueryString', is_read_only=True,
                                                                        -                                                 supported_from=EXCHANGE_2010)
                                                                        -    conversation_id = EWSElementField(field_uri='item:ConversationId', value_cls=ConversationId,
                                                                        -                                      is_read_only=True, supported_from=EXCHANGE_2010)
                                                                        -    unique_body = BodyField(field_uri='item:UniqueBody', is_read_only=True, supported_from=EXCHANGE_2010)
                                                                        +    culture = CultureField(field_uri="item:Culture", is_required_after_save=True, is_searchable=False)
                                                                        +    effective_rights = EffectiveRightsField(field_uri="item:EffectiveRights", is_read_only=True)
                                                                        +    last_modified_name = CharField(field_uri="item:LastModifiedName", is_read_only=True)
                                                                        +    last_modified_time = DateTimeField(field_uri="item:LastModifiedTime", is_read_only=True)
                                                                        +    is_associated = BooleanField(field_uri="item:IsAssociated", is_read_only=True, supported_from=EXCHANGE_2010)
                                                                        +    web_client_read_form_query_string = URIField(
                                                                        +        field_uri="item:WebClientReadFormQueryString", is_read_only=True, supported_from=EXCHANGE_2010
                                                                        +    )
                                                                        +    web_client_edit_form_query_string = URIField(
                                                                        +        field_uri="item:WebClientEditFormQueryString", is_read_only=True, supported_from=EXCHANGE_2010
                                                                        +    )
                                                                        +    conversation_id = EWSElementField(
                                                                        +        field_uri="item:ConversationId", value_cls=ConversationId, is_read_only=True, supported_from=EXCHANGE_2010
                                                                        +    )
                                                                        +    unique_body = BodyField(field_uri="item:UniqueBody", is_read_only=True, supported_from=EXCHANGE_2010)
                                                                         
                                                                             FIELDS = Fields()
                                                                         
                                                                             # Used to register extended properties
                                                                        -    INSERT_AFTER_FIELD = 'has_attachments'
                                                                        +    INSERT_AFTER_FIELD = "has_attachments"
                                                                         
                                                                             def __init__(self, **kwargs):
                                                                                 super().__init__(**kwargs)
                                                                        @@ -461,16 +556,19 @@ 

                                                                        Classes

                                                                        def save(self, update_fields=None, conflict_resolution=AUTO_RESOLVE, send_meeting_invitations=SEND_TO_NONE): from .task import Task + if self.id: item_id, changekey = self._update( update_fieldnames=update_fields, message_disposition=SAVE_ONLY, conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations + send_meeting_invitations=send_meeting_invitations, ) - if self.id != item_id \ - and not isinstance(self._id, (OccurrenceItemId, RecurringMasterItemId)) \ - and not isinstance(self, Task): + if ( + self.id != item_id + and not isinstance(self._id, (OccurrenceItemId, RecurringMasterItemId)) + and not isinstance(self, Task) + ): # When we update an item with an OccurrenceItemId as ID, EWS returns the ID of the occurrence, so # the ID of this item changes. # @@ -505,6 +603,7 @@

                                                                        Classes

                                                                        # Return a BulkCreateResult because we want to return the ID of both the main item *and* attachments. In send # and send-and-save-copy mode, the server does not return an ID, so we just return True. from ..services import CreateItem + return CreateItem(account=self.account).get( items=[self], folder=self.folder, @@ -514,27 +613,28 @@

                                                                        Classes

                                                                        def _update_fieldnames(self): from .contact import Contact, DistributionList + # Return the list of fields we are allowed to update update_fieldnames = [] for f in self.supported_fields(version=self.account.version): - if f.name == 'attachments': + if f.name == "attachments": # Attachments are handled separately after item creation continue if f.is_read_only: # These cannot be changed continue if (f.is_required or f.is_required_after_save) and ( - getattr(self, f.name) is None or (f.is_list and not getattr(self, f.name)) + getattr(self, f.name) is None or (f.is_list and not getattr(self, f.name)) ): # These are required and cannot be deleted continue if not self.is_draft and f.is_read_only_after_send: # These cannot be changed when the item is no longer a draft continue - if f.name == 'message_id' and f.is_read_only_after_send: + if f.name == "message_id" and f.is_read_only_after_send: # 'message_id' doesn't support updating, no matter the draft status continue - if f.name == 'mime_content' and isinstance(self, (Contact, DistributionList)): + if f.name == "mime_content" and isinstance(self, (Contact, DistributionList)): # Contact and DistributionList don't support updating mime_content, no matter the draft status continue update_fieldnames.append(f.name) @@ -543,8 +643,9 @@

                                                                        Classes

                                                                        @require_account def _update(self, update_fieldnames, message_disposition, conflict_resolution, send_meeting_invitations): from ..services import UpdateItem + if not self.changekey: - raise ValueError(f'{self.__class__.__name__} must have changekey') + raise ValueError(f"{self.__class__.__name__} must have changekey") if not update_fieldnames: # The fields to update was not specified explicitly. Update all fields where update is possible update_fieldnames = self._update_fieldnames() @@ -562,6 +663,7 @@

                                                                        Classes

                                                                        # Updates the item based on fresh data from EWS from ..folders import Folder from ..services import GetItem + additional_fields = { FieldPath(field=f) for f in Folder(root=self.account.root).allowed_item_fields(version=self.account.version) } @@ -580,6 +682,7 @@

                                                                        Classes

                                                                        @require_id def copy(self, to_folder): from ..services import CopyItem + # If 'to_folder' is a public folder or a folder in a different mailbox then None is returned return CopyItem(account=self.account).get( items=[self], @@ -590,6 +693,7 @@

                                                                        Classes

                                                                        @require_id def move(self, to_folder): from ..services import MoveItem + res = MoveItem(account=self.account).get( items=[self], to_folder=to_folder, @@ -602,32 +706,57 @@

                                                                        Classes

                                                                        self._id = self.ID_ELEMENT_CLS(*res) self.folder = to_folder - def move_to_trash(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, - suppress_read_receipts=True): + def move_to_trash( + self, + send_meeting_cancellations=SEND_TO_NONE, + affected_task_occurrences=ALL_OCCURRENCES, + suppress_read_receipts=True, + ): # Delete and move to the trash folder. - self._delete(delete_type=MOVE_TO_DELETED_ITEMS, send_meeting_cancellations=send_meeting_cancellations, - affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts) + self._delete( + delete_type=MOVE_TO_DELETED_ITEMS, + send_meeting_cancellations=send_meeting_cancellations, + affected_task_occurrences=affected_task_occurrences, + suppress_read_receipts=suppress_read_receipts, + ) self._id = None self.folder = self.account.trash - def soft_delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, - suppress_read_receipts=True): + def soft_delete( + self, + send_meeting_cancellations=SEND_TO_NONE, + affected_task_occurrences=ALL_OCCURRENCES, + suppress_read_receipts=True, + ): # Delete and move to the dumpster, if it is enabled. - self._delete(delete_type=SOFT_DELETE, send_meeting_cancellations=send_meeting_cancellations, - affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts) + self._delete( + delete_type=SOFT_DELETE, + send_meeting_cancellations=send_meeting_cancellations, + affected_task_occurrences=affected_task_occurrences, + suppress_read_receipts=suppress_read_receipts, + ) self._id = None self.folder = self.account.recoverable_items_deletions - def delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES, - suppress_read_receipts=True): + def delete( + self, + send_meeting_cancellations=SEND_TO_NONE, + affected_task_occurrences=ALL_OCCURRENCES, + suppress_read_receipts=True, + ): # Remove the item permanently. No copies are stored anywhere. - self._delete(delete_type=HARD_DELETE, send_meeting_cancellations=send_meeting_cancellations, - affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts) + self._delete( + delete_type=HARD_DELETE, + send_meeting_cancellations=send_meeting_cancellations, + affected_task_occurrences=affected_task_occurrences, + suppress_read_receipts=suppress_read_receipts, + ) self._id, self.folder = None, None @require_id def _delete(self, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): from ..services import DeleteItem + DeleteItem(account=self.account).get( items=[self], delete_type=delete_type, @@ -639,6 +768,7 @@

                                                                        Classes

                                                                        @require_id def archive(self, to_folder): from ..services import ArchiveItem + return ArchiveItem(account=self.account).get(items=[self], to_folder=to_folder, expect_result=True) def attach(self, attachments): @@ -677,7 +807,7 @@

                                                                        Classes

                                                                        attachments = list(attachments) for a in attachments: if a.parent_item is not self: - raise ValueError('Attachment does not belong to this item') + raise ValueError("Attachment does not belong to this item") if self.id: # Item is already created. Detach the attachment server-side now a.detach() @@ -687,6 +817,7 @@

                                                                        Classes

                                                                        @require_id def create_forward(self, subject, body, to_recipients, cc_recipients=None, bcc_recipients=None): from .message import ForwardItem + return ForwardItem( account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), @@ -904,6 +1035,7 @@

                                                                        Methods

                                                                        @require_id
                                                                         def archive(self, to_folder):
                                                                             from ..services import ArchiveItem
                                                                        +
                                                                             return ArchiveItem(account=self.account).get(items=[self], to_folder=to_folder, expect_result=True)
                                                                        @@ -953,6 +1085,7 @@

                                                                        Methods

                                                                        @require_id
                                                                         def copy(self, to_folder):
                                                                             from ..services import CopyItem
                                                                        +
                                                                             # If 'to_folder' is a public folder or a folder in a different mailbox then None is returned
                                                                             return CopyItem(account=self.account).get(
                                                                                 items=[self],
                                                                        @@ -973,6 +1106,7 @@ 

                                                                        Methods

                                                                        @require_id
                                                                         def create_forward(self, subject, body, to_recipients, cc_recipients=None, bcc_recipients=None):
                                                                             from .message import ForwardItem
                                                                        +
                                                                             return ForwardItem(
                                                                                 account=self.account,
                                                                                 reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey),
                                                                        @@ -993,11 +1127,19 @@ 

                                                                        Methods

                                                                        Expand source code -
                                                                        def delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES,
                                                                        -           suppress_read_receipts=True):
                                                                        +
                                                                        def delete(
                                                                        +    self,
                                                                        +    send_meeting_cancellations=SEND_TO_NONE,
                                                                        +    affected_task_occurrences=ALL_OCCURRENCES,
                                                                        +    suppress_read_receipts=True,
                                                                        +):
                                                                             # Remove the item permanently. No copies are stored anywhere.
                                                                        -    self._delete(delete_type=HARD_DELETE, send_meeting_cancellations=send_meeting_cancellations,
                                                                        -                 affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts)
                                                                        +    self._delete(
                                                                        +        delete_type=HARD_DELETE,
                                                                        +        send_meeting_cancellations=send_meeting_cancellations,
                                                                        +        affected_task_occurrences=affected_task_occurrences,
                                                                        +        suppress_read_receipts=suppress_read_receipts,
                                                                        +    )
                                                                             self._id, self.folder = None, None
                                                                        @@ -1030,7 +1172,7 @@

                                                                        Methods

                                                                        attachments = list(attachments) for a in attachments: if a.parent_item is not self: - raise ValueError('Attachment does not belong to this item') + raise ValueError("Attachment does not belong to this item") if self.id: # Item is already created. Detach the attachment server-side now a.detach() @@ -1069,6 +1211,7 @@

                                                                        Methods

                                                                        @require_id
                                                                         def move(self, to_folder):
                                                                             from ..services import MoveItem
                                                                        +
                                                                             res = MoveItem(account=self.account).get(
                                                                                 items=[self],
                                                                                 to_folder=to_folder,
                                                                        @@ -1091,11 +1234,19 @@ 

                                                                        Methods

                                                                        Expand source code -
                                                                        def move_to_trash(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES,
                                                                        -                  suppress_read_receipts=True):
                                                                        +
                                                                        def move_to_trash(
                                                                        +    self,
                                                                        +    send_meeting_cancellations=SEND_TO_NONE,
                                                                        +    affected_task_occurrences=ALL_OCCURRENCES,
                                                                        +    suppress_read_receipts=True,
                                                                        +):
                                                                             # Delete and move to the trash folder.
                                                                        -    self._delete(delete_type=MOVE_TO_DELETED_ITEMS, send_meeting_cancellations=send_meeting_cancellations,
                                                                        -                 affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts)
                                                                        +    self._delete(
                                                                        +        delete_type=MOVE_TO_DELETED_ITEMS,
                                                                        +        send_meeting_cancellations=send_meeting_cancellations,
                                                                        +        affected_task_occurrences=affected_task_occurrences,
                                                                        +        suppress_read_receipts=suppress_read_receipts,
                                                                        +    )
                                                                             self._id = None
                                                                             self.folder = self.account.trash
                                                                        @@ -1114,6 +1265,7 @@

                                                                        Methods

                                                                        # Updates the item based on fresh data from EWS from ..folders import Folder from ..services import GetItem + additional_fields = { FieldPath(field=f) for f in Folder(root=self.account.root).allowed_item_fields(version=self.account.version) } @@ -1141,16 +1293,19 @@

                                                                        Methods

                                                                        def save(self, update_fields=None, conflict_resolution=AUTO_RESOLVE, send_meeting_invitations=SEND_TO_NONE):
                                                                             from .task import Task
                                                                        +
                                                                             if self.id:
                                                                                 item_id, changekey = self._update(
                                                                                     update_fieldnames=update_fields,
                                                                                     message_disposition=SAVE_ONLY,
                                                                                     conflict_resolution=conflict_resolution,
                                                                        -            send_meeting_invitations=send_meeting_invitations
                                                                        +            send_meeting_invitations=send_meeting_invitations,
                                                                                 )
                                                                        -        if self.id != item_id \
                                                                        -                and not isinstance(self._id, (OccurrenceItemId, RecurringMasterItemId)) \
                                                                        -                and not isinstance(self, Task):
                                                                        +        if (
                                                                        +            self.id != item_id
                                                                        +            and not isinstance(self._id, (OccurrenceItemId, RecurringMasterItemId))
                                                                        +            and not isinstance(self, Task)
                                                                        +        ):
                                                                                     # When we update an item with an OccurrenceItemId as ID, EWS returns the ID of the occurrence, so
                                                                                     # the ID of this item changes.
                                                                                     #
                                                                        @@ -1190,11 +1345,19 @@ 

                                                                        Methods

                                                                        Expand source code -
                                                                        def soft_delete(self, send_meeting_cancellations=SEND_TO_NONE, affected_task_occurrences=ALL_OCCURRENCES,
                                                                        -                suppress_read_receipts=True):
                                                                        +
                                                                        def soft_delete(
                                                                        +    self,
                                                                        +    send_meeting_cancellations=SEND_TO_NONE,
                                                                        +    affected_task_occurrences=ALL_OCCURRENCES,
                                                                        +    suppress_read_receipts=True,
                                                                        +):
                                                                             # Delete and move to the dumpster, if it is enabled.
                                                                        -    self._delete(delete_type=SOFT_DELETE, send_meeting_cancellations=send_meeting_cancellations,
                                                                        -                 affected_task_occurrences=affected_task_occurrences, suppress_read_receipts=suppress_read_receipts)
                                                                        +    self._delete(
                                                                        +        delete_type=SOFT_DELETE,
                                                                        +        send_meeting_cancellations=send_meeting_cancellations,
                                                                        +        affected_task_occurrences=affected_task_occurrences,
                                                                        +        suppress_read_receipts=suppress_read_receipts,
                                                                        +    )
                                                                             self._id = None
                                                                             self.folder = self.account.recoverable_items_deletions
                                                                        diff --git a/docs/exchangelib/items/message.html b/docs/exchangelib/items/message.html index df2d7089..a5aafdb6 100644 --- a/docs/exchangelib/items/message.html +++ b/docs/exchangelib/items/message.html @@ -28,12 +28,12 @@

                                                                        Module exchangelib.items.message

                                                                        import logging
                                                                         
                                                                        -from .base import BaseReplyItem, AUTO_RESOLVE, SEND_TO_NONE, SEND_ONLY, SEND_AND_SAVE_COPY
                                                                        -from .item import Item
                                                                        -from ..fields import BooleanField, Base64Field, TextField, MailboxField, MailboxListField, CharField, EWSElementField
                                                                        +from ..fields import Base64Field, BooleanField, CharField, EWSElementField, MailboxField, MailboxListField, TextField
                                                                         from ..properties import ReferenceItemId, ReminderMessageData
                                                                         from ..util import require_account, require_id
                                                                         from ..version import EXCHANGE_2013, EXCHANGE_2013_SP1
                                                                        +from .base import AUTO_RESOLVE, SEND_AND_SAVE_COPY, SEND_ONLY, SEND_TO_NONE, BaseReplyItem
                                                                        +from .item import Item
                                                                         
                                                                         log = logging.getLogger(__name__)
                                                                         
                                                                        @@ -43,37 +43,52 @@ 

                                                                        Module exchangelib.items.message

                                                                        https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/message-ex15websvcsotherref """ - ELEMENT_NAME = 'Message' - - sender = MailboxField(field_uri='message:Sender', is_read_only=True, is_read_only_after_send=True) - to_recipients = MailboxListField(field_uri='message:ToRecipients', is_read_only_after_send=True, - is_searchable=False) - cc_recipients = MailboxListField(field_uri='message:CcRecipients', is_read_only_after_send=True, - is_searchable=False) - bcc_recipients = MailboxListField(field_uri='message:BccRecipients', is_read_only_after_send=True, - is_searchable=False) - is_read_receipt_requested = BooleanField(field_uri='message:IsReadReceiptRequested', - is_required=True, default=False, is_read_only_after_send=True) - is_delivery_receipt_requested = BooleanField(field_uri='message:IsDeliveryReceiptRequested', is_required=True, - default=False, is_read_only_after_send=True) - conversation_index = Base64Field(field_uri='message:ConversationIndex', is_read_only=True) - conversation_topic = CharField(field_uri='message:ConversationTopic', is_read_only=True) + ELEMENT_NAME = "Message" + + sender = MailboxField(field_uri="message:Sender", is_read_only=True, is_read_only_after_send=True) + to_recipients = MailboxListField( + field_uri="message:ToRecipients", is_read_only_after_send=True, is_searchable=False + ) + cc_recipients = MailboxListField( + field_uri="message:CcRecipients", is_read_only_after_send=True, is_searchable=False + ) + bcc_recipients = MailboxListField( + field_uri="message:BccRecipients", is_read_only_after_send=True, is_searchable=False + ) + is_read_receipt_requested = BooleanField( + field_uri="message:IsReadReceiptRequested", is_required=True, default=False, is_read_only_after_send=True + ) + is_delivery_receipt_requested = BooleanField( + field_uri="message:IsDeliveryReceiptRequested", is_required=True, default=False, is_read_only_after_send=True + ) + conversation_index = Base64Field(field_uri="message:ConversationIndex", is_read_only=True) + conversation_topic = CharField(field_uri="message:ConversationTopic", is_read_only=True) # Rename 'From' to 'author'. We can't use fieldname 'from' since it's a Python keyword. - author = MailboxField(field_uri='message:From', is_read_only_after_send=True) - message_id = CharField(field_uri='message:InternetMessageId', is_read_only_after_send=True) - is_read = BooleanField(field_uri='message:IsRead', is_required=True, default=False) - is_response_requested = BooleanField(field_uri='message:IsResponseRequested', default=False, is_required=True) - references = TextField(field_uri='message:References') - reply_to = MailboxListField(field_uri='message:ReplyTo', is_read_only_after_send=True, is_searchable=False) - received_by = MailboxField(field_uri='message:ReceivedBy', is_read_only=True) - received_representing = MailboxField(field_uri='message:ReceivedRepresenting', is_read_only=True) - reminder_message_data = EWSElementField(field_uri='message:ReminderMessageData', value_cls=ReminderMessageData, - supported_from=EXCHANGE_2013_SP1, is_read_only=True) + author = MailboxField(field_uri="message:From", is_read_only_after_send=True) + message_id = CharField(field_uri="message:InternetMessageId", is_read_only_after_send=True) + is_read = BooleanField(field_uri="message:IsRead", is_required=True, default=False) + is_response_requested = BooleanField(field_uri="message:IsResponseRequested", default=False, is_required=True) + references = TextField(field_uri="message:References") + reply_to = MailboxListField(field_uri="message:ReplyTo", is_read_only_after_send=True, is_searchable=False) + received_by = MailboxField(field_uri="message:ReceivedBy", is_read_only=True) + received_representing = MailboxField(field_uri="message:ReceivedRepresenting", is_read_only=True) + reminder_message_data = EWSElementField( + field_uri="message:ReminderMessageData", + value_cls=ReminderMessageData, + supported_from=EXCHANGE_2013_SP1, + is_read_only=True, + ) @require_account - def send(self, save_copy=True, copy_to_folder=None, conflict_resolution=AUTO_RESOLVE, - send_meeting_invitations=SEND_TO_NONE): + def send( + self, + save_copy=True, + copy_to_folder=None, + conflict_resolution=AUTO_RESOLVE, + send_meeting_invitations=SEND_TO_NONE, + ): from ..services import SendItem + # Only sends a message. The message can either be an existing draft stored in EWS or a new message that does # not yet exist in EWS. if copy_to_folder and not save_copy: @@ -91,42 +106,48 @@

                                                                        Module exchangelib.items.message

                                                                        if copy_to_folder: # This would better be done via send_and_save() but lets just support it here self.folder = copy_to_folder - return self.send_and_save(conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) + return self.send_and_save( + conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations + ) if self.account.version.build < EXCHANGE_2013 and self.attachments: # At least some versions prior to Exchange 2013 can't send attachments immediately. You need to first save, # then attach, then send. This is done in send_and_save(). send() will delete the item again. - self.send_and_save(conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) + self.send_and_save( + conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations + ) return None self._create(message_disposition=SEND_ONLY, send_meeting_invitations=send_meeting_invitations) return None - def send_and_save(self, update_fields=None, conflict_resolution=AUTO_RESOLVE, - send_meeting_invitations=SEND_TO_NONE): + def send_and_save( + self, update_fields=None, conflict_resolution=AUTO_RESOLVE, send_meeting_invitations=SEND_TO_NONE + ): # Sends Message and saves a copy in the parent folder. Does not return an ItemId. if self.id: self._update( update_fieldnames=update_fields, message_disposition=SEND_AND_SAVE_COPY, conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations + send_meeting_invitations=send_meeting_invitations, ) else: if self.account.version.build < EXCHANGE_2013 and self.attachments: # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need # to first save, then attach, then send. This is done in save(). - self.save(update_fields=update_fields, conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) - self.send(save_copy=False, conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) - else: - self._create( - message_disposition=SEND_AND_SAVE_COPY, - send_meeting_invitations=send_meeting_invitations + self.save( + update_fields=update_fields, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, ) + self.send( + save_copy=False, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, + ) + else: + self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations) @require_id def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): @@ -145,13 +166,7 @@

                                                                        Module exchangelib.items.message

                                                                        ) def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): - self.create_reply( - subject, - body, - to_recipients, - cc_recipients, - bcc_recipients - ).send() + self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients).send() @require_id def create_reply_all(self, subject, body): @@ -180,6 +195,7 @@

                                                                        Module exchangelib.items.message

                                                                        :return: """ from ..services import MarkAsJunk + res = MarkAsJunk(account=self.account).get( items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item ) @@ -192,19 +208,19 @@

                                                                        Module exchangelib.items.message

                                                                        class ReplyToItem(BaseReplyItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/replytoitem""" - ELEMENT_NAME = 'ReplyToItem' + ELEMENT_NAME = "ReplyToItem" class ReplyAllToItem(BaseReplyItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/replyalltoitem""" - ELEMENT_NAME = 'ReplyAllToItem' + ELEMENT_NAME = "ReplyAllToItem" class ForwardItem(BaseReplyItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/forwarditem""" - ELEMENT_NAME = 'ForwardItem'
                                                                        + ELEMENT_NAME = "ForwardItem"
                                                                        @@ -229,7 +245,7 @@

                                                                        Classes

                                                                        class ForwardItem(BaseReplyItem):
                                                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/forwarditem"""
                                                                         
                                                                        -    ELEMENT_NAME = 'ForwardItem'
                                                                        + ELEMENT_NAME = "ForwardItem"

                                                                        Ancestors

                                                                          @@ -278,37 +294,52 @@

                                                                          Inherited members

                                                                          https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/message-ex15websvcsotherref """ - ELEMENT_NAME = 'Message' - - sender = MailboxField(field_uri='message:Sender', is_read_only=True, is_read_only_after_send=True) - to_recipients = MailboxListField(field_uri='message:ToRecipients', is_read_only_after_send=True, - is_searchable=False) - cc_recipients = MailboxListField(field_uri='message:CcRecipients', is_read_only_after_send=True, - is_searchable=False) - bcc_recipients = MailboxListField(field_uri='message:BccRecipients', is_read_only_after_send=True, - is_searchable=False) - is_read_receipt_requested = BooleanField(field_uri='message:IsReadReceiptRequested', - is_required=True, default=False, is_read_only_after_send=True) - is_delivery_receipt_requested = BooleanField(field_uri='message:IsDeliveryReceiptRequested', is_required=True, - default=False, is_read_only_after_send=True) - conversation_index = Base64Field(field_uri='message:ConversationIndex', is_read_only=True) - conversation_topic = CharField(field_uri='message:ConversationTopic', is_read_only=True) + ELEMENT_NAME = "Message" + + sender = MailboxField(field_uri="message:Sender", is_read_only=True, is_read_only_after_send=True) + to_recipients = MailboxListField( + field_uri="message:ToRecipients", is_read_only_after_send=True, is_searchable=False + ) + cc_recipients = MailboxListField( + field_uri="message:CcRecipients", is_read_only_after_send=True, is_searchable=False + ) + bcc_recipients = MailboxListField( + field_uri="message:BccRecipients", is_read_only_after_send=True, is_searchable=False + ) + is_read_receipt_requested = BooleanField( + field_uri="message:IsReadReceiptRequested", is_required=True, default=False, is_read_only_after_send=True + ) + is_delivery_receipt_requested = BooleanField( + field_uri="message:IsDeliveryReceiptRequested", is_required=True, default=False, is_read_only_after_send=True + ) + conversation_index = Base64Field(field_uri="message:ConversationIndex", is_read_only=True) + conversation_topic = CharField(field_uri="message:ConversationTopic", is_read_only=True) # Rename 'From' to 'author'. We can't use fieldname 'from' since it's a Python keyword. - author = MailboxField(field_uri='message:From', is_read_only_after_send=True) - message_id = CharField(field_uri='message:InternetMessageId', is_read_only_after_send=True) - is_read = BooleanField(field_uri='message:IsRead', is_required=True, default=False) - is_response_requested = BooleanField(field_uri='message:IsResponseRequested', default=False, is_required=True) - references = TextField(field_uri='message:References') - reply_to = MailboxListField(field_uri='message:ReplyTo', is_read_only_after_send=True, is_searchable=False) - received_by = MailboxField(field_uri='message:ReceivedBy', is_read_only=True) - received_representing = MailboxField(field_uri='message:ReceivedRepresenting', is_read_only=True) - reminder_message_data = EWSElementField(field_uri='message:ReminderMessageData', value_cls=ReminderMessageData, - supported_from=EXCHANGE_2013_SP1, is_read_only=True) + author = MailboxField(field_uri="message:From", is_read_only_after_send=True) + message_id = CharField(field_uri="message:InternetMessageId", is_read_only_after_send=True) + is_read = BooleanField(field_uri="message:IsRead", is_required=True, default=False) + is_response_requested = BooleanField(field_uri="message:IsResponseRequested", default=False, is_required=True) + references = TextField(field_uri="message:References") + reply_to = MailboxListField(field_uri="message:ReplyTo", is_read_only_after_send=True, is_searchable=False) + received_by = MailboxField(field_uri="message:ReceivedBy", is_read_only=True) + received_representing = MailboxField(field_uri="message:ReceivedRepresenting", is_read_only=True) + reminder_message_data = EWSElementField( + field_uri="message:ReminderMessageData", + value_cls=ReminderMessageData, + supported_from=EXCHANGE_2013_SP1, + is_read_only=True, + ) @require_account - def send(self, save_copy=True, copy_to_folder=None, conflict_resolution=AUTO_RESOLVE, - send_meeting_invitations=SEND_TO_NONE): + def send( + self, + save_copy=True, + copy_to_folder=None, + conflict_resolution=AUTO_RESOLVE, + send_meeting_invitations=SEND_TO_NONE, + ): from ..services import SendItem + # Only sends a message. The message can either be an existing draft stored in EWS or a new message that does # not yet exist in EWS. if copy_to_folder and not save_copy: @@ -326,42 +357,48 @@

                                                                          Inherited members

                                                                          if copy_to_folder: # This would better be done via send_and_save() but lets just support it here self.folder = copy_to_folder - return self.send_and_save(conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) + return self.send_and_save( + conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations + ) if self.account.version.build < EXCHANGE_2013 and self.attachments: # At least some versions prior to Exchange 2013 can't send attachments immediately. You need to first save, # then attach, then send. This is done in send_and_save(). send() will delete the item again. - self.send_and_save(conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) + self.send_and_save( + conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations + ) return None self._create(message_disposition=SEND_ONLY, send_meeting_invitations=send_meeting_invitations) return None - def send_and_save(self, update_fields=None, conflict_resolution=AUTO_RESOLVE, - send_meeting_invitations=SEND_TO_NONE): + def send_and_save( + self, update_fields=None, conflict_resolution=AUTO_RESOLVE, send_meeting_invitations=SEND_TO_NONE + ): # Sends Message and saves a copy in the parent folder. Does not return an ItemId. if self.id: self._update( update_fieldnames=update_fields, message_disposition=SEND_AND_SAVE_COPY, conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations + send_meeting_invitations=send_meeting_invitations, ) else: if self.account.version.build < EXCHANGE_2013 and self.attachments: # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need # to first save, then attach, then send. This is done in save(). - self.save(update_fields=update_fields, conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) - self.send(save_copy=False, conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) - else: - self._create( - message_disposition=SEND_AND_SAVE_COPY, - send_meeting_invitations=send_meeting_invitations + self.save( + update_fields=update_fields, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, + ) + self.send( + save_copy=False, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, ) + else: + self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations) @require_id def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): @@ -380,13 +417,7 @@

                                                                          Inherited members

                                                                          ) def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): - self.create_reply( - subject, - body, - to_recipients, - cc_recipients, - bcc_recipients - ).send() + self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients).send() @require_id def create_reply_all(self, subject, body): @@ -415,6 +446,7 @@

                                                                          Inherited members

                                                                          :return: """ from ..services import MarkAsJunk + res = MarkAsJunk(account=self.account).get( items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item ) @@ -588,6 +620,7 @@

                                                                          Methods

                                                                          :return: """ from ..services import MarkAsJunk + res = MarkAsJunk(account=self.account).get( items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item ) @@ -607,13 +640,7 @@

                                                                          Methods

                                                                          Expand source code
                                                                          def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None):
                                                                          -    self.create_reply(
                                                                          -        subject,
                                                                          -        body,
                                                                          -        to_recipients,
                                                                          -        cc_recipients,
                                                                          -        bcc_recipients
                                                                          -    ).send()
                                                                          + self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients).send()
                                                                        @@ -639,9 +666,15 @@

                                                                        Methods

                                                                        Expand source code
                                                                        @require_account
                                                                        -def send(self, save_copy=True, copy_to_folder=None, conflict_resolution=AUTO_RESOLVE,
                                                                        -         send_meeting_invitations=SEND_TO_NONE):
                                                                        +def send(
                                                                        +    self,
                                                                        +    save_copy=True,
                                                                        +    copy_to_folder=None,
                                                                        +    conflict_resolution=AUTO_RESOLVE,
                                                                        +    send_meeting_invitations=SEND_TO_NONE,
                                                                        +):
                                                                             from ..services import SendItem
                                                                        +
                                                                             # Only sends a message. The message can either be an existing draft stored in EWS or a new message that does
                                                                             # not yet exist in EWS.
                                                                             if copy_to_folder and not save_copy:
                                                                        @@ -659,14 +692,16 @@ 

                                                                        Methods

                                                                        if copy_to_folder: # This would better be done via send_and_save() but lets just support it here self.folder = copy_to_folder - return self.send_and_save(conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) + return self.send_and_save( + conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations + ) if self.account.version.build < EXCHANGE_2013 and self.attachments: # At least some versions prior to Exchange 2013 can't send attachments immediately. You need to first save, # then attach, then send. This is done in send_and_save(). send() will delete the item again. - self.send_and_save(conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations) + self.send_and_save( + conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations + ) return None self._create(message_disposition=SEND_ONLY, send_meeting_invitations=send_meeting_invitations) @@ -682,29 +717,33 @@

                                                                        Methods

                                                                        Expand source code -
                                                                        def send_and_save(self, update_fields=None, conflict_resolution=AUTO_RESOLVE,
                                                                        -                  send_meeting_invitations=SEND_TO_NONE):
                                                                        +
                                                                        def send_and_save(
                                                                        +    self, update_fields=None, conflict_resolution=AUTO_RESOLVE, send_meeting_invitations=SEND_TO_NONE
                                                                        +):
                                                                             # Sends Message and saves a copy in the parent folder. Does not return an ItemId.
                                                                             if self.id:
                                                                                 self._update(
                                                                                     update_fieldnames=update_fields,
                                                                                     message_disposition=SEND_AND_SAVE_COPY,
                                                                                     conflict_resolution=conflict_resolution,
                                                                        -            send_meeting_invitations=send_meeting_invitations
                                                                        +            send_meeting_invitations=send_meeting_invitations,
                                                                                 )
                                                                             else:
                                                                                 if self.account.version.build < EXCHANGE_2013 and self.attachments:
                                                                                     # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need
                                                                                     # to first save, then attach, then send. This is done in save().
                                                                        -            self.save(update_fields=update_fields, conflict_resolution=conflict_resolution,
                                                                        -                      send_meeting_invitations=send_meeting_invitations)
                                                                        -            self.send(save_copy=False, conflict_resolution=conflict_resolution,
                                                                        -                      send_meeting_invitations=send_meeting_invitations)
                                                                        +            self.save(
                                                                        +                update_fields=update_fields,
                                                                        +                conflict_resolution=conflict_resolution,
                                                                        +                send_meeting_invitations=send_meeting_invitations,
                                                                        +            )
                                                                        +            self.send(
                                                                        +                save_copy=False,
                                                                        +                conflict_resolution=conflict_resolution,
                                                                        +                send_meeting_invitations=send_meeting_invitations,
                                                                        +            )
                                                                                 else:
                                                                        -            self._create(
                                                                        -                message_disposition=SEND_AND_SAVE_COPY,
                                                                        -                send_meeting_invitations=send_meeting_invitations
                                                                        -            )
                                                                        + self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations)
                                                                        @@ -740,7 +779,7 @@

                                                                        Inherited members

                                                                        class ReplyAllToItem(BaseReplyItem):
                                                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/replyalltoitem"""
                                                                         
                                                                        -    ELEMENT_NAME = 'ReplyAllToItem'
                                                                        + ELEMENT_NAME = "ReplyAllToItem"

                                                                        Ancestors

                                                                          @@ -781,7 +820,7 @@

                                                                          Inherited members

                                                                          class ReplyToItem(BaseReplyItem):
                                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/replytoitem"""
                                                                           
                                                                          -    ELEMENT_NAME = 'ReplyToItem'
                                                                          + ELEMENT_NAME = "ReplyToItem"

                                                                        Ancestors

                                                                          diff --git a/docs/exchangelib/items/post.html b/docs/exchangelib/items/post.html index 75ccf70b..a7dabb52 100644 --- a/docs/exchangelib/items/post.html +++ b/docs/exchangelib/items/post.html @@ -28,9 +28,9 @@

                                                                          Module exchangelib.items.post

                                                                          import logging
                                                                           
                                                                          +from ..fields import BodyField, DateTimeField, MailboxField, TextField
                                                                           from .item import Item
                                                                           from .message import Message
                                                                          -from ..fields import TextField, BodyField, DateTimeField, MailboxField
                                                                           
                                                                           log = logging.getLogger(__name__)
                                                                           
                                                                          @@ -38,32 +38,32 @@ 

                                                                          Module exchangelib.items.post

                                                                          class PostItem(Item): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/postitem""" - ELEMENT_NAME = 'PostItem' + ELEMENT_NAME = "PostItem" - conversation_index = Message.FIELDS['conversation_index'] - conversation_topic = Message.FIELDS['conversation_topic'] + conversation_index = Message.FIELDS["conversation_index"] + conversation_topic = Message.FIELDS["conversation_topic"] - author = Message.FIELDS['author'] - message_id = Message.FIELDS['message_id'] - is_read = Message.FIELDS['is_read'] + author = Message.FIELDS["author"] + message_id = Message.FIELDS["message_id"] + is_read = Message.FIELDS["is_read"] - posted_time = DateTimeField(field_uri='postitem:PostedTime', is_read_only=True) - references = TextField(field_uri='message:References') - sender = MailboxField(field_uri='message:Sender', is_read_only=True, is_read_only_after_send=True) + posted_time = DateTimeField(field_uri="postitem:PostedTime", is_read_only=True) + references = TextField(field_uri="message:References") + sender = MailboxField(field_uri="message:Sender", is_read_only=True, is_read_only_after_send=True) class PostReplyItem(Item): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/postreplyitem""" - ELEMENT_NAME = 'PostReplyItem' + ELEMENT_NAME = "PostReplyItem" # This element only has Item fields up to, and including, 'culture' # TDO: Plus all message fields - new_body = BodyField(field_uri='NewBodyContent') # Accepts and returns Body or HTMLBody instances + new_body = BodyField(field_uri="NewBodyContent") # Accepts and returns Body or HTMLBody instances - culture_idx = Item.FIELDS.index_by_name('culture') - sender_idx = Message.FIELDS.index_by_name('sender') - FIELDS = Item.FIELDS[:culture_idx + 1] + Message.FIELDS[sender_idx:]
                                                                          + culture_idx = Item.FIELDS.index_by_name("culture") + sender_idx = Message.FIELDS.index_by_name("sender") + FIELDS = Item.FIELDS[: culture_idx + 1] + Message.FIELDS[sender_idx:]
                                                                        @@ -93,18 +93,18 @@

                                                                        Classes

                                                                        class PostItem(Item):
                                                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/postitem"""
                                                                         
                                                                        -    ELEMENT_NAME = 'PostItem'
                                                                        +    ELEMENT_NAME = "PostItem"
                                                                         
                                                                        -    conversation_index = Message.FIELDS['conversation_index']
                                                                        -    conversation_topic = Message.FIELDS['conversation_topic']
                                                                        +    conversation_index = Message.FIELDS["conversation_index"]
                                                                        +    conversation_topic = Message.FIELDS["conversation_topic"]
                                                                         
                                                                        -    author = Message.FIELDS['author']
                                                                        -    message_id = Message.FIELDS['message_id']
                                                                        -    is_read = Message.FIELDS['is_read']
                                                                        +    author = Message.FIELDS["author"]
                                                                        +    message_id = Message.FIELDS["message_id"]
                                                                        +    is_read = Message.FIELDS["is_read"]
                                                                         
                                                                        -    posted_time = DateTimeField(field_uri='postitem:PostedTime', is_read_only=True)
                                                                        -    references = TextField(field_uri='message:References')
                                                                        -    sender = MailboxField(field_uri='message:Sender', is_read_only=True, is_read_only_after_send=True)
                                                                        + posted_time = DateTimeField(field_uri="postitem:PostedTime", is_read_only=True) + references = TextField(field_uri="message:References") + sender = MailboxField(field_uri="message:Sender", is_read_only=True, is_read_only_after_send=True)

                                                                        Ancestors

                                                                          @@ -197,15 +197,15 @@

                                                                          Inherited members

                                                                          class PostReplyItem(Item):
                                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/postreplyitem"""
                                                                           
                                                                          -    ELEMENT_NAME = 'PostReplyItem'
                                                                          +    ELEMENT_NAME = "PostReplyItem"
                                                                           
                                                                               # This element only has Item fields up to, and including, 'culture'
                                                                               # TDO: Plus all message fields
                                                                          -    new_body = BodyField(field_uri='NewBodyContent')  # Accepts and returns Body or HTMLBody instances
                                                                          +    new_body = BodyField(field_uri="NewBodyContent")  # Accepts and returns Body or HTMLBody instances
                                                                           
                                                                          -    culture_idx = Item.FIELDS.index_by_name('culture')
                                                                          -    sender_idx = Message.FIELDS.index_by_name('sender')
                                                                          -    FIELDS = Item.FIELDS[:culture_idx + 1] + Message.FIELDS[sender_idx:]
                                                                          + culture_idx = Item.FIELDS.index_by_name("culture") + sender_idx = Message.FIELDS.index_by_name("sender") + FIELDS = Item.FIELDS[: culture_idx + 1] + Message.FIELDS[sender_idx:]

                                                                        Ancestors

                                                                          diff --git a/docs/exchangelib/items/task.html b/docs/exchangelib/items/task.html index 4cdbe4a0..3dd931a9 100644 --- a/docs/exchangelib/items/task.html +++ b/docs/exchangelib/items/task.html @@ -30,10 +30,21 @@

                                                                          Module exchangelib.items.task

                                                                          import logging from decimal import Decimal +from ..ewsdatetime import UTC, EWSDateTime +from ..fields import ( + BooleanField, + CharField, + Choice, + ChoiceField, + DateTimeBackedDateField, + DateTimeField, + DecimalField, + IntegerField, + TaskRecurrenceField, + TextField, + TextListField, +) from .item import Item -from ..ewsdatetime import EWSDateTime, UTC -from ..fields import BooleanField, IntegerField, DecimalField, TextField, ChoiceField, DateTimeField, Choice, \ - CharField, TextListField, TaskRecurrenceField, DateTimeBackedDateField log = logging.getLogger(__name__) @@ -41,49 +52,78 @@

                                                                          Module exchangelib.items.task

                                                                          class Task(Item): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/task""" - ELEMENT_NAME = 'Task' - NOT_STARTED = 'NotStarted' - COMPLETED = 'Completed' + ELEMENT_NAME = "Task" + NOT_STARTED = "NotStarted" + COMPLETED = "Completed" - actual_work = IntegerField(field_uri='task:ActualWork', min=0) - assigned_time = DateTimeField(field_uri='task:AssignedTime', is_read_only=True) - billing_information = TextField(field_uri='task:BillingInformation') - change_count = IntegerField(field_uri='task:ChangeCount', is_read_only=True, min=0) - companies = TextListField(field_uri='task:Companies') + actual_work = IntegerField(field_uri="task:ActualWork", min=0) + assigned_time = DateTimeField(field_uri="task:AssignedTime", is_read_only=True) + billing_information = TextField(field_uri="task:BillingInformation") + change_count = IntegerField(field_uri="task:ChangeCount", is_read_only=True, min=0) + companies = TextListField(field_uri="task:Companies") # 'complete_date' can be set, but is ignored by the server, which sets it to now() - complete_date = DateTimeField(field_uri='task:CompleteDate', is_read_only=True) - contacts = TextListField(field_uri='task:Contacts') - delegation_state = ChoiceField(field_uri='task:DelegationState', choices={ - Choice('NoMatch'), Choice('OwnNew'), Choice('Owned'), Choice('Accepted'), Choice('Declined'), Choice('Max') - }, is_read_only=True) - delegator = CharField(field_uri='task:Delegator', is_read_only=True) - due_date = DateTimeBackedDateField(field_uri='task:DueDate') - is_editable = BooleanField(field_uri='task:IsAssignmentEditable', is_read_only=True) - is_complete = BooleanField(field_uri='task:IsComplete', is_read_only=True) - is_recurring = BooleanField(field_uri='task:IsRecurring', is_read_only=True) - is_team_task = BooleanField(field_uri='task:IsTeamTask', is_read_only=True) - mileage = TextField(field_uri='task:Mileage') - owner = CharField(field_uri='task:Owner', is_read_only=True) - percent_complete = DecimalField(field_uri='task:PercentComplete', is_required=True, default=Decimal(0.0), - min=Decimal(0), max=Decimal(100), is_searchable=False) - recurrence = TaskRecurrenceField(field_uri='task:Recurrence', is_searchable=False) - start_date = DateTimeBackedDateField(field_uri='task:StartDate') - status = ChoiceField(field_uri='task:Status', choices={ - Choice(NOT_STARTED), Choice('InProgress'), Choice(COMPLETED), Choice('WaitingOnOthers'), Choice('Deferred') - }, is_required=True, is_searchable=False, default=NOT_STARTED) - status_description = CharField(field_uri='task:StatusDescription', is_read_only=True) - total_work = IntegerField(field_uri='task:TotalWork', min=0) + complete_date = DateTimeField(field_uri="task:CompleteDate", is_read_only=True) + contacts = TextListField(field_uri="task:Contacts") + delegation_state = ChoiceField( + field_uri="task:DelegationState", + choices={ + Choice("NoMatch"), + Choice("OwnNew"), + Choice("Owned"), + Choice("Accepted"), + Choice("Declined"), + Choice("Max"), + }, + is_read_only=True, + ) + delegator = CharField(field_uri="task:Delegator", is_read_only=True) + due_date = DateTimeBackedDateField(field_uri="task:DueDate") + is_editable = BooleanField(field_uri="task:IsAssignmentEditable", is_read_only=True) + is_complete = BooleanField(field_uri="task:IsComplete", is_read_only=True) + is_recurring = BooleanField(field_uri="task:IsRecurring", is_read_only=True) + is_team_task = BooleanField(field_uri="task:IsTeamTask", is_read_only=True) + mileage = TextField(field_uri="task:Mileage") + owner = CharField(field_uri="task:Owner", is_read_only=True) + percent_complete = DecimalField( + field_uri="task:PercentComplete", + is_required=True, + default=Decimal(0.0), + min=Decimal(0), + max=Decimal(100), + is_searchable=False, + ) + recurrence = TaskRecurrenceField(field_uri="task:Recurrence", is_searchable=False) + start_date = DateTimeBackedDateField(field_uri="task:StartDate") + status = ChoiceField( + field_uri="task:Status", + choices={ + Choice(NOT_STARTED), + Choice("InProgress"), + Choice(COMPLETED), + Choice("WaitingOnOthers"), + Choice("Deferred"), + }, + is_required=True, + is_searchable=False, + default=NOT_STARTED, + ) + status_description = CharField(field_uri="task:StatusDescription", is_read_only=True) + total_work = IntegerField(field_uri="task:TotalWork", min=0) def clean(self, version=None): super().clean(version=version) if self.due_date and self.start_date and self.due_date < self.start_date: - log.warning("'due_date' must be greater than 'start_date' (%s vs %s). Resetting 'due_date'", - self.due_date, self.start_date) + log.warning( + "'due_date' must be greater than 'start_date' (%s vs %s). Resetting 'due_date'", + self.due_date, + self.start_date, + ) self.due_date = self.start_date if self.complete_date: if self.status != self.COMPLETED: - log.warning("'status' must be '%s' when 'complete_date' is set (%s). Resetting", - self.COMPLETED, self.status) + log.warning( + "'status' must be '%s' when 'complete_date' is set (%s). Resetting", self.COMPLETED, self.status + ) self.status = self.COMPLETED now = datetime.datetime.now(tz=UTC) if (self.complete_date - now).total_seconds() > 120: @@ -92,19 +132,28 @@

                                                                          Module exchangelib.items.task

                                                                          log.warning("'complete_date' must be in the past (%s vs %s). Resetting", self.complete_date, now) self.complete_date = now if self.start_date and self.complete_date.date() < self.start_date: - log.warning("'complete_date' must be greater than 'start_date' (%s vs %s). Resetting", - self.complete_date, self.start_date) + log.warning( + "'complete_date' must be greater than 'start_date' (%s vs %s). Resetting", + self.complete_date, + self.start_date, + ) self.complete_date = EWSDateTime.combine(self.start_date, datetime.time(0, 0)).replace(tzinfo=UTC) if self.percent_complete is not None: if self.status == self.COMPLETED and self.percent_complete != Decimal(100): # percent_complete must be 100% if task is complete - log.warning("'percent_complete' must be 100 when 'status' is '%s' (%s). Resetting", - self.COMPLETED, self.percent_complete) + log.warning( + "'percent_complete' must be 100 when 'status' is '%s' (%s). Resetting", + self.COMPLETED, + self.percent_complete, + ) self.percent_complete = Decimal(100) elif self.status == self.NOT_STARTED and self.percent_complete != Decimal(0): # percent_complete must be 0% if task is not started - log.warning("'percent_complete' must be 0 when 'status' is '%s' (%s). Resetting", - self.NOT_STARTED, self.percent_complete) + log.warning( + "'percent_complete' must be 0 when 'status' is '%s' (%s). Resetting", + self.NOT_STARTED, + self.percent_complete, + ) self.percent_complete = Decimal(0) def complete(self): @@ -141,49 +190,78 @@

                                                                          Classes

                                                                          class Task(Item):
                                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/task"""
                                                                           
                                                                          -    ELEMENT_NAME = 'Task'
                                                                          -    NOT_STARTED = 'NotStarted'
                                                                          -    COMPLETED = 'Completed'
                                                                          +    ELEMENT_NAME = "Task"
                                                                          +    NOT_STARTED = "NotStarted"
                                                                          +    COMPLETED = "Completed"
                                                                           
                                                                          -    actual_work = IntegerField(field_uri='task:ActualWork', min=0)
                                                                          -    assigned_time = DateTimeField(field_uri='task:AssignedTime', is_read_only=True)
                                                                          -    billing_information = TextField(field_uri='task:BillingInformation')
                                                                          -    change_count = IntegerField(field_uri='task:ChangeCount', is_read_only=True, min=0)
                                                                          -    companies = TextListField(field_uri='task:Companies')
                                                                          +    actual_work = IntegerField(field_uri="task:ActualWork", min=0)
                                                                          +    assigned_time = DateTimeField(field_uri="task:AssignedTime", is_read_only=True)
                                                                          +    billing_information = TextField(field_uri="task:BillingInformation")
                                                                          +    change_count = IntegerField(field_uri="task:ChangeCount", is_read_only=True, min=0)
                                                                          +    companies = TextListField(field_uri="task:Companies")
                                                                               # 'complete_date' can be set, but is ignored by the server, which sets it to now()
                                                                          -    complete_date = DateTimeField(field_uri='task:CompleteDate', is_read_only=True)
                                                                          -    contacts = TextListField(field_uri='task:Contacts')
                                                                          -    delegation_state = ChoiceField(field_uri='task:DelegationState', choices={
                                                                          -        Choice('NoMatch'), Choice('OwnNew'), Choice('Owned'), Choice('Accepted'), Choice('Declined'), Choice('Max')
                                                                          -    }, is_read_only=True)
                                                                          -    delegator = CharField(field_uri='task:Delegator', is_read_only=True)
                                                                          -    due_date = DateTimeBackedDateField(field_uri='task:DueDate')
                                                                          -    is_editable = BooleanField(field_uri='task:IsAssignmentEditable', is_read_only=True)
                                                                          -    is_complete = BooleanField(field_uri='task:IsComplete', is_read_only=True)
                                                                          -    is_recurring = BooleanField(field_uri='task:IsRecurring', is_read_only=True)
                                                                          -    is_team_task = BooleanField(field_uri='task:IsTeamTask', is_read_only=True)
                                                                          -    mileage = TextField(field_uri='task:Mileage')
                                                                          -    owner = CharField(field_uri='task:Owner', is_read_only=True)
                                                                          -    percent_complete = DecimalField(field_uri='task:PercentComplete', is_required=True, default=Decimal(0.0),
                                                                          -                                    min=Decimal(0), max=Decimal(100), is_searchable=False)
                                                                          -    recurrence = TaskRecurrenceField(field_uri='task:Recurrence', is_searchable=False)
                                                                          -    start_date = DateTimeBackedDateField(field_uri='task:StartDate')
                                                                          -    status = ChoiceField(field_uri='task:Status', choices={
                                                                          -        Choice(NOT_STARTED), Choice('InProgress'), Choice(COMPLETED), Choice('WaitingOnOthers'), Choice('Deferred')
                                                                          -    }, is_required=True, is_searchable=False, default=NOT_STARTED)
                                                                          -    status_description = CharField(field_uri='task:StatusDescription', is_read_only=True)
                                                                          -    total_work = IntegerField(field_uri='task:TotalWork', min=0)
                                                                          +    complete_date = DateTimeField(field_uri="task:CompleteDate", is_read_only=True)
                                                                          +    contacts = TextListField(field_uri="task:Contacts")
                                                                          +    delegation_state = ChoiceField(
                                                                          +        field_uri="task:DelegationState",
                                                                          +        choices={
                                                                          +            Choice("NoMatch"),
                                                                          +            Choice("OwnNew"),
                                                                          +            Choice("Owned"),
                                                                          +            Choice("Accepted"),
                                                                          +            Choice("Declined"),
                                                                          +            Choice("Max"),
                                                                          +        },
                                                                          +        is_read_only=True,
                                                                          +    )
                                                                          +    delegator = CharField(field_uri="task:Delegator", is_read_only=True)
                                                                          +    due_date = DateTimeBackedDateField(field_uri="task:DueDate")
                                                                          +    is_editable = BooleanField(field_uri="task:IsAssignmentEditable", is_read_only=True)
                                                                          +    is_complete = BooleanField(field_uri="task:IsComplete", is_read_only=True)
                                                                          +    is_recurring = BooleanField(field_uri="task:IsRecurring", is_read_only=True)
                                                                          +    is_team_task = BooleanField(field_uri="task:IsTeamTask", is_read_only=True)
                                                                          +    mileage = TextField(field_uri="task:Mileage")
                                                                          +    owner = CharField(field_uri="task:Owner", is_read_only=True)
                                                                          +    percent_complete = DecimalField(
                                                                          +        field_uri="task:PercentComplete",
                                                                          +        is_required=True,
                                                                          +        default=Decimal(0.0),
                                                                          +        min=Decimal(0),
                                                                          +        max=Decimal(100),
                                                                          +        is_searchable=False,
                                                                          +    )
                                                                          +    recurrence = TaskRecurrenceField(field_uri="task:Recurrence", is_searchable=False)
                                                                          +    start_date = DateTimeBackedDateField(field_uri="task:StartDate")
                                                                          +    status = ChoiceField(
                                                                          +        field_uri="task:Status",
                                                                          +        choices={
                                                                          +            Choice(NOT_STARTED),
                                                                          +            Choice("InProgress"),
                                                                          +            Choice(COMPLETED),
                                                                          +            Choice("WaitingOnOthers"),
                                                                          +            Choice("Deferred"),
                                                                          +        },
                                                                          +        is_required=True,
                                                                          +        is_searchable=False,
                                                                          +        default=NOT_STARTED,
                                                                          +    )
                                                                          +    status_description = CharField(field_uri="task:StatusDescription", is_read_only=True)
                                                                          +    total_work = IntegerField(field_uri="task:TotalWork", min=0)
                                                                           
                                                                               def clean(self, version=None):
                                                                                   super().clean(version=version)
                                                                                   if self.due_date and self.start_date and self.due_date < self.start_date:
                                                                          -            log.warning("'due_date' must be greater than 'start_date' (%s vs %s). Resetting 'due_date'",
                                                                          -                        self.due_date, self.start_date)
                                                                          +            log.warning(
                                                                          +                "'due_date' must be greater than 'start_date' (%s vs %s). Resetting 'due_date'",
                                                                          +                self.due_date,
                                                                          +                self.start_date,
                                                                          +            )
                                                                                       self.due_date = self.start_date
                                                                                   if self.complete_date:
                                                                                       if self.status != self.COMPLETED:
                                                                          -                log.warning("'status' must be '%s' when 'complete_date' is set (%s). Resetting",
                                                                          -                            self.COMPLETED, self.status)
                                                                          +                log.warning(
                                                                          +                    "'status' must be '%s' when 'complete_date' is set (%s). Resetting", self.COMPLETED, self.status
                                                                          +                )
                                                                                           self.status = self.COMPLETED
                                                                                       now = datetime.datetime.now(tz=UTC)
                                                                                       if (self.complete_date - now).total_seconds() > 120:
                                                                          @@ -192,19 +270,28 @@ 

                                                                          Classes

                                                                          log.warning("'complete_date' must be in the past (%s vs %s). Resetting", self.complete_date, now) self.complete_date = now if self.start_date and self.complete_date.date() < self.start_date: - log.warning("'complete_date' must be greater than 'start_date' (%s vs %s). Resetting", - self.complete_date, self.start_date) + log.warning( + "'complete_date' must be greater than 'start_date' (%s vs %s). Resetting", + self.complete_date, + self.start_date, + ) self.complete_date = EWSDateTime.combine(self.start_date, datetime.time(0, 0)).replace(tzinfo=UTC) if self.percent_complete is not None: if self.status == self.COMPLETED and self.percent_complete != Decimal(100): # percent_complete must be 100% if task is complete - log.warning("'percent_complete' must be 100 when 'status' is '%s' (%s). Resetting", - self.COMPLETED, self.percent_complete) + log.warning( + "'percent_complete' must be 100 when 'status' is '%s' (%s). Resetting", + self.COMPLETED, + self.percent_complete, + ) self.percent_complete = Decimal(100) elif self.status == self.NOT_STARTED and self.percent_complete != Decimal(0): # percent_complete must be 0% if task is not started - log.warning("'percent_complete' must be 0 when 'status' is '%s' (%s). Resetting", - self.NOT_STARTED, self.percent_complete) + log.warning( + "'percent_complete' must be 0 when 'status' is '%s' (%s). Resetting", + self.NOT_STARTED, + self.percent_complete, + ) self.percent_complete = Decimal(0) def complete(self): @@ -345,13 +432,17 @@

                                                                          Methods

                                                                          def clean(self, version=None):
                                                                               super().clean(version=version)
                                                                               if self.due_date and self.start_date and self.due_date < self.start_date:
                                                                          -        log.warning("'due_date' must be greater than 'start_date' (%s vs %s). Resetting 'due_date'",
                                                                          -                    self.due_date, self.start_date)
                                                                          +        log.warning(
                                                                          +            "'due_date' must be greater than 'start_date' (%s vs %s). Resetting 'due_date'",
                                                                          +            self.due_date,
                                                                          +            self.start_date,
                                                                          +        )
                                                                                   self.due_date = self.start_date
                                                                               if self.complete_date:
                                                                                   if self.status != self.COMPLETED:
                                                                          -            log.warning("'status' must be '%s' when 'complete_date' is set (%s). Resetting",
                                                                          -                        self.COMPLETED, self.status)
                                                                          +            log.warning(
                                                                          +                "'status' must be '%s' when 'complete_date' is set (%s). Resetting", self.COMPLETED, self.status
                                                                          +            )
                                                                                       self.status = self.COMPLETED
                                                                                   now = datetime.datetime.now(tz=UTC)
                                                                                   if (self.complete_date - now).total_seconds() > 120:
                                                                          @@ -360,19 +451,28 @@ 

                                                                          Methods

                                                                          log.warning("'complete_date' must be in the past (%s vs %s). Resetting", self.complete_date, now) self.complete_date = now if self.start_date and self.complete_date.date() < self.start_date: - log.warning("'complete_date' must be greater than 'start_date' (%s vs %s). Resetting", - self.complete_date, self.start_date) + log.warning( + "'complete_date' must be greater than 'start_date' (%s vs %s). Resetting", + self.complete_date, + self.start_date, + ) self.complete_date = EWSDateTime.combine(self.start_date, datetime.time(0, 0)).replace(tzinfo=UTC) if self.percent_complete is not None: if self.status == self.COMPLETED and self.percent_complete != Decimal(100): # percent_complete must be 100% if task is complete - log.warning("'percent_complete' must be 100 when 'status' is '%s' (%s). Resetting", - self.COMPLETED, self.percent_complete) + log.warning( + "'percent_complete' must be 100 when 'status' is '%s' (%s). Resetting", + self.COMPLETED, + self.percent_complete, + ) self.percent_complete = Decimal(100) elif self.status == self.NOT_STARTED and self.percent_complete != Decimal(0): # percent_complete must be 0% if task is not started - log.warning("'percent_complete' must be 0 when 'status' is '%s' (%s). Resetting", - self.NOT_STARTED, self.percent_complete) + log.warning( + "'percent_complete' must be 0 when 'status' is '%s' (%s). Resetting", + self.NOT_STARTED, + self.percent_complete, + ) self.percent_complete = Decimal(0)
                                                                          diff --git a/docs/exchangelib/properties.html b/docs/exchangelib/properties.html index 91a438d6..8684a767 100644 --- a/docs/exchangelib/properties.html +++ b/docs/exchangelib/properties.html @@ -35,14 +35,49 @@

                                                                          Module exchangelib.properties

                                                                          from inspect import getmro from threading import Lock -from .errors import TimezoneDefinitionInvalidForYear, InvalidTypeError -from .fields import SubField, TextField, EmailAddressField, ChoiceField, DateTimeField, EWSElementField, MailboxField, \ - Choice, BooleanField, IdField, ExtendedPropertyField, IntegerField, TimeField, EnumField, CharField, EmailField, \ - EWSElementListField, EnumListField, FreeBusyStatusField, UnknownEntriesField, MessageField, RecipientAddressField, \ - RoutingTypeField, WEEKDAY_NAMES, FieldPath, Field, AssociatedCalendarItemIdField, ReferenceItemIdField, \ - Base64Field, TypeValueField, DictionaryField, IdElementField, CharListField, GenericEventListField, \ - DateTimeBackedDateField, TimeDeltaField, TransitionListField, InvalidField, InvalidFieldForVersion -from .util import get_xml_attr, create_element, set_xml_value, value_to_xml_text, MNS, TNS +from .errors import InvalidTypeError, TimezoneDefinitionInvalidForYear +from .fields import ( + WEEKDAY_NAMES, + AssociatedCalendarItemIdField, + Base64Field, + BooleanField, + CharField, + CharListField, + Choice, + ChoiceField, + DateTimeBackedDateField, + DateTimeField, + DictionaryField, + EmailAddressField, + EmailField, + EnumField, + EnumListField, + EWSElementField, + EWSElementListField, + ExtendedPropertyField, + Field, + FieldPath, + FreeBusyStatusField, + GenericEventListField, + IdElementField, + IdField, + IntegerField, + InvalidField, + InvalidFieldForVersion, + MailboxField, + MessageField, + RecipientAddressField, + ReferenceItemIdField, + RoutingTypeField, + SubField, + TextField, + TimeDeltaField, + TimeField, + TransitionListField, + TypeValueField, + UnknownEntriesField, +) +from .util import MNS, TNS, create_element, get_xml_attr, set_xml_value, value_to_xml_text from .version import EXCHANGE_2013, Build log = logging.getLogger(__name__) @@ -57,7 +92,7 @@

                                                                          Module exchangelib.properties

                                                                          for f in fields: # Check for duplicate field names if f.name in self._dict: - raise ValueError(f'Field {f!r} is a duplicate') + raise ValueError(f"Field {f!r} is a duplicate") self._dict[f.name] = f def __getitem__(self, idx_or_slice): @@ -89,11 +124,11 @@

                                                                          Module exchangelib.properties

                                                                          for i, f in enumerate(self): if f.name == field_name: return i - raise ValueError(f'Unknown field name {field_name!r}') + raise ValueError(f"Unknown field name {field_name!r}") def insert(self, index, field): if field.name in self._dict: - raise ValueError(f'Field {field!r} is a duplicate') + raise ValueError(f"Field {field!r} is a duplicate") super().insert(index, field) self._dict[field.name] = field @@ -112,7 +147,7 @@

                                                                          Module exchangelib.properties

                                                                          MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/body """ - body_type = 'Text' + body_type = "Text" def __add__(self, other): # Make sure Body('') + 'foo' returns a Body type @@ -133,7 +168,7 @@

                                                                          Module exchangelib.properties

                                                                          MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/body """ - body_type = 'HTML' + body_type = "HTML" class UID(bytes): @@ -148,22 +183,15 @@

                                                                          Module exchangelib.properties

                                                                          account.calendar.filter(global_object_id=UID('261cbc18-1f65-5a0a-bd11-23b1e224cc2f')) """ - _HEADER = binascii.hexlify(bytearray(( - 0x04, 0x00, 0x00, 0x00, - 0x82, 0x00, 0xE0, 0x00, - 0x74, 0xC5, 0xB7, 0x10, - 0x1A, 0x82, 0xE0, 0x08))) + _HEADER = binascii.hexlify( + bytearray((0x04, 0x00, 0x00, 0x00, 0x82, 0x00, 0xE0, 0x00, 0x74, 0xC5, 0xB7, 0x10, 0x1A, 0x82, 0xE0, 0x08)) + ) - _EXCEPTION_REPLACEMENT_TIME = binascii.hexlify(bytearray(( - 0, 0, 0, 0))) + _EXCEPTION_REPLACEMENT_TIME = binascii.hexlify(bytearray((0, 0, 0, 0))) - _CREATION_TIME = binascii.hexlify(bytearray(( - 0, 0, 0, 0, - 0, 0, 0, 0))) + _CREATION_TIME = binascii.hexlify(bytearray((0, 0, 0, 0, 0, 0, 0, 0))) - _RESERVED = binascii.hexlify(bytearray(( - 0, 0, 0, 0, - 0, 0, 0, 0))) + _RESERVED = binascii.hexlify(bytearray((0, 0, 0, 0, 0, 0, 0, 0))) # https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxocal/1d3aac05-a7b9-45cc-a213-47f0a0a2c5c1 # https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-asemail/e7424ddc-dd10-431e-a0b7-5c794863370e @@ -171,12 +199,12 @@

                                                                          Module exchangelib.properties

                                                                          # https://stackoverflow.com/questions/33757805 def __new__(cls, uid): - payload = binascii.hexlify(bytearray(f'vCal-Uid\x01\x00\x00\x00{uid}\x00'.encode('ascii'))) - length = binascii.hexlify(bytearray(struct.pack('<I', int(len(payload)/2)))) - encoding = b''.join([ - cls._HEADER, cls._EXCEPTION_REPLACEMENT_TIME, cls._CREATION_TIME, cls._RESERVED, length, payload - ]) - return super().__new__(cls, codecs.decode(encoding, 'hex')) + payload = binascii.hexlify(bytearray(f"vCal-Uid\x01\x00\x00\x00{uid}\x00".encode("ascii"))) + length = binascii.hexlify(bytearray(struct.pack("<I", int(len(payload) / 2)))) + encoding = b"".join( + [cls._HEADER, cls._EXCEPTION_REPLACEMENT_TIME, cls._CREATION_TIME, cls._RESERVED, length, payload] + ) + return super().__new__(cls, codecs.decode(encoding, "hex")) @classmethod def to_global_object_id(cls, uid): @@ -185,7 +213,7 @@

                                                                          Module exchangelib.properties

                                                                          def _mangle(field_name): - return f'__{field_name}' + return f"__{field_name}" class EWSMeta(type, metaclass=abc.ABCMeta): @@ -202,23 +230,23 @@

                                                                          Module exchangelib.properties

                                                                          # Build a list of fields defined on this and all base classes base_fields = Fields() for base in bases: - if hasattr(base, 'FIELDS'): + if hasattr(base, "FIELDS"): base_fields += base.FIELDS # FIELDS defined on a model overrides the base class fields - fields = kwargs.get('FIELDS', base_fields) + local_fields + fields = kwargs.get("FIELDS", base_fields) + local_fields # Include all fields as class attributes so we can use them as instance attributes kwargs.update({_mangle(f.name): f for f in fields}) # Calculate __slots__ so we don't have to hard-code it on the model - kwargs['__slots__'] = tuple(f.name for f in fields if f.name not in base_fields) + kwargs.get('__slots__', ()) + kwargs["__slots__"] = tuple(f.name for f in fields if f.name not in base_fields) + kwargs.get("__slots__", ()) # FIELDS is mentioned in docs and expected by internal code. Add it here, but only if the class has its own # fields. Otherwise, we want the implicit FIELDS from the base class (used for injecting custom fields on the # Folder class, making the custom field available for subclasses). if local_fields: - kwargs['FIELDS'] = fields + kwargs["FIELDS"] = fields cls = super().__new__(mcs, name, bases, kwargs) cls._slots_keys = mcs._get_slots_keys(cls) return cls @@ -228,7 +256,7 @@

                                                                          Module exchangelib.properties

                                                                          seen = set() keys = [] for c in reversed(getmro(cls)): - if not hasattr(c, '__slots__'): + if not hasattr(c, "__slots__"): continue for k in c.__slots__: if k in seen: @@ -242,7 +270,7 @@

                                                                          Module exchangelib.properties

                                                                          def __getattribute__(cls, k): """Return Field instances via their mangled class attribute""" try: - return super().__getattribute__('__dict__')[_mangle(k)] + return super().__getattribute__("__dict__")[_mangle(k)] except KeyError: return super().__getattribute__(k) @@ -274,7 +302,7 @@

                                                                          Module exchangelib.properties

                                                                          # Property setters return super().__setattr__(key, value) raise AttributeError( - f'{key!r} is not a valid attribute. See {self.__class__.__name__}.FIELDS for valid field names' + f"{key!r} is not a valid attribute. See {self.__class__.__name__}.FIELDS for valid field names" ) def clean(self, version=None): @@ -336,19 +364,19 @@

                                                                          Module exchangelib.properties

                                                                          @classmethod def request_tag(cls): if not cls.ELEMENT_NAME: - raise ValueError(f'Class {cls} is missing the ELEMENT_NAME attribute') + raise ValueError(f"Class {cls} is missing the ELEMENT_NAME attribute") return { - TNS: f't:{cls.ELEMENT_NAME}', - MNS: f'm:{cls.ELEMENT_NAME}', + TNS: f"t:{cls.ELEMENT_NAME}", + MNS: f"m:{cls.ELEMENT_NAME}", }[cls.NAMESPACE] @classmethod def response_tag(cls): if not cls.NAMESPACE: - raise ValueError(f'Class {cls} is missing the NAMESPACE attribute') + raise ValueError(f"Class {cls} is missing the NAMESPACE attribute") if not cls.ELEMENT_NAME: - raise ValueError(f'Class {cls} is missing the ELEMENT_NAME attribute') - return f'{{{cls.NAMESPACE}}}{cls.ELEMENT_NAME}' + raise ValueError(f"Class {cls} is missing the ELEMENT_NAME attribute") + return f"{{{cls.NAMESPACE}}}{cls.ELEMENT_NAME}" @classmethod def attribute_fields(cls): @@ -432,22 +460,20 @@

                                                                          Module exchangelib.properties

                                                                          return field_vals def __str__(self): - args_str = ', '.join( - f'{name}={val!r}' for name, val in self._field_vals() if val is not None - ) - return f'{self.__class__.__name__}({args_str})' + args_str = ", ".join(f"{name}={val!r}" for name, val in self._field_vals() if val is not None) + return f"{self.__class__.__name__}({args_str})" def __repr__(self): - args_str = ', '.join(f'{name}={val!r}' for name, val in self._field_vals()) - return f'{self.__class__.__name__}({args_str})' + args_str = ", ".join(f"{name}={val!r}" for name, val in self._field_vals()) + return f"{self.__class__.__name__}({args_str})" class MessageHeader(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/internetmessageheader""" - ELEMENT_NAME = 'InternetMessageHeader' + ELEMENT_NAME = "InternetMessageHeader" - name = TextField(field_uri='HeaderName', is_attribute=True) + name = TextField(field_uri="HeaderName", is_attribute=True) value = SubField() @@ -470,9 +496,9 @@

                                                                          Module exchangelib.properties

                                                                          MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/itemid """ - ELEMENT_NAME = 'ItemId' - ID_ATTR = 'Id' - CHANGEKEY_ATTR = 'ChangeKey' + ELEMENT_NAME = "ItemId" + ID_ATTR = "Id" + CHANGEKEY_ATTR = "ChangeKey" id = IdField(field_uri=ID_ATTR, is_required=True) changekey = IdField(field_uri=CHANGEKEY_ATTR, is_required=False) @@ -481,17 +507,17 @@

                                                                          Module exchangelib.properties

                                                                          class ParentItemId(ItemId): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/parentitemid""" - ELEMENT_NAME = 'ParentItemId' + ELEMENT_NAME = "ParentItemId" NAMESPACE = MNS class RootItemId(BaseItemId): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/rootitemid""" - ELEMENT_NAME = 'RootItemId' + ELEMENT_NAME = "RootItemId" NAMESPACE = MNS - ID_ATTR = 'RootItemId' - CHANGEKEY_ATTR = 'RootItemChangeKey' + ID_ATTR = "RootItemId" + CHANGEKEY_ATTR = "RootItemChangeKey" id = IdField(field_uri=ID_ATTR, is_required=True) changekey = IdField(field_uri=CHANGEKEY_ATTR, is_required=True) @@ -502,13 +528,13 @@

                                                                          Module exchangelib.properties

                                                                          https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/associatedcalendaritemid """ - ELEMENT_NAME = 'AssociatedCalendarItemId' + ELEMENT_NAME = "AssociatedCalendarItemId" class ConversationId(ItemId): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/conversationid""" - ELEMENT_NAME = 'ConversationId' + ELEMENT_NAME = "ConversationId" # ChangeKey attribute is sometimes required, see MSDN link @@ -516,45 +542,45 @@

                                                                          Module exchangelib.properties

                                                                          class ParentFolderId(ItemId): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/parentfolderid""" - ELEMENT_NAME = 'ParentFolderId' + ELEMENT_NAME = "ParentFolderId" class ReferenceItemId(ItemId): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/referenceitemid""" - ELEMENT_NAME = 'ReferenceItemId' + ELEMENT_NAME = "ReferenceItemId" class PersonaId(ItemId): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/personaid""" - ELEMENT_NAME = 'PersonaId' + ELEMENT_NAME = "PersonaId" NAMESPACE = MNS @classmethod def response_tag(cls): # This element is in MNS in the request and TNS in the response... - return f'{{{TNS}}}{cls.ELEMENT_NAME}' + return f"{{{TNS}}}{cls.ELEMENT_NAME}" class SourceId(ItemId): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/sourceid""" - ELEMENT_NAME = 'SourceId' + ELEMENT_NAME = "SourceId" class FolderId(ItemId): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/folderid""" - ELEMENT_NAME = 'FolderId' + ELEMENT_NAME = "FolderId" class RecurringMasterItemId(BaseItemId): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recurringmasteritemid""" - ELEMENT_NAME = 'RecurringMasterItemId' - ID_ATTR = 'OccurrenceId' - CHANGEKEY_ATTR = 'ChangeKey' + ELEMENT_NAME = "RecurringMasterItemId" + ID_ATTR = "OccurrenceId" + CHANGEKEY_ATTR = "ChangeKey" id = IdField(field_uri=ID_ATTR, is_required=True) changekey = IdField(field_uri=CHANGEKEY_ATTR, is_required=False) @@ -563,19 +589,19 @@

                                                                          Module exchangelib.properties

                                                                          class OccurrenceItemId(BaseItemId): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/occurrenceitemid""" - ELEMENT_NAME = 'OccurrenceItemId' - ID_ATTR = 'RecurringMasterId' - CHANGEKEY_ATTR = 'ChangeKey' + ELEMENT_NAME = "OccurrenceItemId" + ID_ATTR = "RecurringMasterId" + CHANGEKEY_ATTR = "ChangeKey" id = IdField(field_uri=ID_ATTR, is_required=True) changekey = IdField(field_uri=CHANGEKEY_ATTR, is_required=False) - instance_index = IntegerField(field_uri='InstanceIndex', is_attribute=True, is_required=True, min=1) + instance_index = IntegerField(field_uri="InstanceIndex", is_attribute=True, is_required=True, min=1) class MovedItemId(ItemId): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/moveditemid""" - ELEMENT_NAME = 'MovedItemId' + ELEMENT_NAME = "MovedItemId" NAMESPACE = MNS @classmethod @@ -587,20 +613,26 @@

                                                                          Module exchangelib.properties

                                                                          class Mailbox(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailbox""" - ELEMENT_NAME = 'Mailbox' - MAILBOX = 'Mailbox' - ONE_OFF = 'OneOff' + ELEMENT_NAME = "Mailbox" + MAILBOX = "Mailbox" + ONE_OFF = "OneOff" MAILBOX_TYPE_CHOICES = { - Choice(MAILBOX), Choice('PublicDL'), Choice('PrivateDL'), Choice('Contact'), Choice('PublicFolder'), - Choice('Unknown'), Choice(ONE_OFF), Choice('GroupMailbox', supported_from=EXCHANGE_2013) - } - - name = TextField(field_uri='Name') - email_address = EmailAddressField(field_uri='EmailAddress') + Choice(MAILBOX), + Choice("PublicDL"), + Choice("PrivateDL"), + Choice("Contact"), + Choice("PublicFolder"), + Choice("Unknown"), + Choice(ONE_OFF), + Choice("GroupMailbox", supported_from=EXCHANGE_2013), + } + + name = TextField(field_uri="Name") + email_address = EmailAddressField(field_uri="EmailAddress") # RoutingType values are not restricted: # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/routingtype-emailaddresstype - routing_type = TextField(field_uri='RoutingType', default='SMTP') - mailbox_type = ChoiceField(field_uri='MailboxType', choices=MAILBOX_TYPE_CHOICES, default=MAILBOX) + routing_type = TextField(field_uri="RoutingType", default="SMTP") + mailbox_type = ChoiceField(field_uri="MailboxType", choices=MAILBOX_TYPE_CHOICES, default=MAILBOX) item_id = EWSElementField(value_cls=ItemId, is_read_only=True) def clean(self, version=None): @@ -633,7 +665,7 @@

                                                                          Module exchangelib.properties

                                                                          MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/sendingas """ - ELEMENT_NAME = 'SendingAs' + ELEMENT_NAME = "SendingAs" NAMESPACE = MNS @@ -643,7 +675,7 @@

                                                                          Module exchangelib.properties

                                                                          MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recipientaddress """ - ELEMENT_NAME = 'RecipientAddress' + ELEMENT_NAME = "RecipientAddress" class EmailAddress(Mailbox): @@ -652,7 +684,7 @@

                                                                          Module exchangelib.properties

                                                                          MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/emailaddress-emailaddresstype """ - ELEMENT_NAME = 'EmailAddress' + ELEMENT_NAME = "EmailAddress" class Address(Mailbox): @@ -661,7 +693,7 @@

                                                                          Module exchangelib.properties

                                                                          MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/address-emailaddresstype """ - ELEMENT_NAME = 'Address' + ELEMENT_NAME = "Address" class AvailabilityMailbox(EWSElement): @@ -670,13 +702,13 @@

                                                                          Module exchangelib.properties

                                                                          MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailbox-availability """ - ELEMENT_NAME = 'Mailbox' + ELEMENT_NAME = "Mailbox" - name = TextField(field_uri='Name') - email_address = EmailAddressField(field_uri='Address', is_required=True) + name = TextField(field_uri="Name") + email_address = EmailAddressField(field_uri="Address", is_required=True) # RoutingType values restricted to EX and SMTP: # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/routingtype-emailaddress - routing_type = RoutingTypeField(field_uri='RoutingType') + routing_type = RoutingTypeField(field_uri="RoutingType") def __hash__(self): # Exchange may add 'name' on insert. We're satisfied if the email address matches. @@ -687,7 +719,7 @@

                                                                          Module exchangelib.properties

                                                                          @classmethod def from_mailbox(cls, mailbox): if not isinstance(mailbox, Mailbox): - raise InvalidTypeError('mailbox', mailbox, Mailbox) + raise InvalidTypeError("mailbox", mailbox, Mailbox) return cls(name=mailbox.name, email_address=mailbox.email_address, routing_type=mailbox.routing_type) @@ -696,29 +728,30 @@

                                                                          Module exchangelib.properties

                                                                          MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/email-emailaddresstype """ - ELEMENT_NAME = 'Email' + ELEMENT_NAME = "Email" class MailboxData(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailboxdata""" - ELEMENT_NAME = 'MailboxData' - ATTENDEE_TYPES = {'Optional', 'Organizer', 'Required', 'Resource', 'Room'} + ELEMENT_NAME = "MailboxData" + ATTENDEE_TYPES = {"Optional", "Organizer", "Required", "Resource", "Room"} email = EmailField() - attendee_type = ChoiceField(field_uri='AttendeeType', choices={Choice(c) for c in ATTENDEE_TYPES}) - exclude_conflicts = BooleanField(field_uri='ExcludeConflicts') + attendee_type = ChoiceField(field_uri="AttendeeType", choices={Choice(c) for c in ATTENDEE_TYPES}) + exclude_conflicts = BooleanField(field_uri="ExcludeConflicts") class DistinguishedFolderId(FolderId): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/distinguishedfolderid""" - ELEMENT_NAME = 'DistinguishedFolderId' + ELEMENT_NAME = "DistinguishedFolderId" mailbox = MailboxField() def clean(self, version=None): from .folders import PublicFoldersRoot + super().clean(version=version) if self.id == PublicFoldersRoot.DISTINGUISHED_FOLDER_ID: # Avoid "ErrorInvalidOperation: It is not valid to specify a mailbox with the public folder root" from EWS @@ -728,10 +761,10 @@

                                                                          Module exchangelib.properties

                                                                          class TimeWindow(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timewindow""" - ELEMENT_NAME = 'TimeWindow' + ELEMENT_NAME = "TimeWindow" - start = DateTimeField(field_uri='StartTime', is_required=True) - end = DateTimeField(field_uri='EndTime', is_required=True) + start = DateTimeField(field_uri="StartTime", is_required=True) + end = DateTimeField(field_uri="EndTime", is_required=True) def clean(self, version=None): if self.start >= self.end: @@ -742,27 +775,30 @@

                                                                          Module exchangelib.properties

                                                                          class FreeBusyViewOptions(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/freebusyviewoptions""" - ELEMENT_NAME = 'FreeBusyViewOptions' - REQUESTED_VIEWS = {'MergedOnly', 'FreeBusy', 'FreeBusyMerged', 'Detailed', 'DetailedMerged'} + ELEMENT_NAME = "FreeBusyViewOptions" + REQUESTED_VIEWS = {"MergedOnly", "FreeBusy", "FreeBusyMerged", "Detailed", "DetailedMerged"} time_window = EWSElementField(value_cls=TimeWindow, is_required=True) # Interval value is in minutes - merged_free_busy_interval = IntegerField(field_uri='MergedFreeBusyIntervalInMinutes', min=5, max=1440, default=30, - is_required=True) - requested_view = ChoiceField(field_uri='RequestedView', choices={Choice(c) for c in REQUESTED_VIEWS}, - is_required=True) # Choice('None') is also valid, but only for responses + merged_free_busy_interval = IntegerField( + field_uri="MergedFreeBusyIntervalInMinutes", min=5, max=1440, default=30, is_required=True + ) + requested_view = ChoiceField( + field_uri="RequestedView", choices={Choice(c) for c in REQUESTED_VIEWS}, is_required=True + ) # Choice('None') is also valid, but only for responses class Attendee(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/attendee""" - ELEMENT_NAME = 'Attendee' - RESPONSE_TYPES = {'Unknown', 'Organizer', 'Tentative', 'Accept', 'Decline', 'NoResponseReceived'} + ELEMENT_NAME = "Attendee" + RESPONSE_TYPES = {"Unknown", "Organizer", "Tentative", "Accept", "Decline", "NoResponseReceived"} mailbox = MailboxField(is_required=True) - response_type = ChoiceField(field_uri='ResponseType', choices={Choice(c) for c in RESPONSE_TYPES}, - default='Unknown') - last_response_time = DateTimeField(field_uri='LastResponseTime') + response_type = ChoiceField( + field_uri="ResponseType", choices={Choice(c) for c in RESPONSE_TYPES}, default="Unknown" + ) + last_response_time = DateTimeField(field_uri="LastResponseTime") def __hash__(self): return hash(self.mailbox) @@ -771,11 +807,11 @@

                                                                          Module exchangelib.properties

                                                                          class TimeZoneTransition(EWSElement, metaclass=EWSMeta): """Base class for StandardTime and DaylightTime classes.""" - bias = IntegerField(field_uri='Bias', is_required=True) # Offset from the default bias, in minutes - time = TimeField(field_uri='Time', is_required=True) - occurrence = IntegerField(field_uri='DayOrder', is_required=True) # n'th occurrence of weekday in iso_month - iso_month = IntegerField(field_uri='Month', is_required=True) - weekday = EnumField(field_uri='DayOfWeek', enum=WEEKDAY_NAMES, is_required=True) + bias = IntegerField(field_uri="Bias", is_required=True) # Offset from the default bias, in minutes + time = TimeField(field_uri="Time", is_required=True) + occurrence = IntegerField(field_uri="DayOrder", is_required=True) # n'th occurrence of weekday in iso_month + iso_month = IntegerField(field_uri="Month", is_required=True) + weekday = EnumField(field_uri="DayOfWeek", enum=WEEKDAY_NAMES, is_required=True) # 'Year' is not implemented yet @classmethod @@ -797,21 +833,21 @@

                                                                          Module exchangelib.properties

                                                                          class StandardTime(TimeZoneTransition): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/standardtime""" - ELEMENT_NAME = 'StandardTime' + ELEMENT_NAME = "StandardTime" class DaylightTime(TimeZoneTransition): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/daylighttime""" - ELEMENT_NAME = 'DaylightTime' + ELEMENT_NAME = "DaylightTime" class TimeZone(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezone-availability""" - ELEMENT_NAME = 'TimeZone' + ELEMENT_NAME = "TimeZone" - bias = IntegerField(field_uri='Bias', is_required=True) # Standard (non-DST) offset from UTC, in minutes + bias = IntegerField(field_uri="Bias", is_required=True) # Standard (non-DST) offset from UTC, in minutes standard_time = EWSElementField(value_cls=StandardTime) daylight_time = EWSElementField(value_cls=DaylightTime) @@ -832,7 +868,7 @@

                                                                          Module exchangelib.properties

                                                                          for_year=for_year, ) if candidate == self: - log.debug('Found exact candidate: %s (%s)', tz_definition.id, tz_definition.name) + log.debug("Found exact candidate: %s (%s)", tz_definition.id, tz_definition.name) # We prefer this timezone over anything else. Return immediately. return tz_definition.id # Reduce list based on base bias and standard / daylight bias values @@ -854,14 +890,14 @@

                                                                          Module exchangelib.properties

                                                                          continue if candidate.daylight_time.bias != self.daylight_time.bias: continue - log.debug('Found candidate with matching biases: %s (%s)', tz_definition.id, tz_definition.name) + log.debug("Found candidate with matching biases: %s (%s)", tz_definition.id, tz_definition.name) candidates.add(tz_definition.id) if not candidates: - raise ValueError('No server timezones match this timezone definition') + raise ValueError("No server timezones match this timezone definition") if len(candidates) == 1: - log.info('Could not find an exact timezone match for %s. Selecting the best candidate', self) + log.info("Could not find an exact timezone match for %s. Selecting the best candidate", self) else: - log.warning('Could not find an exact timezone match for %s. Selecting a random candidate', self) + log.warning("Could not find an exact timezone match for %s. Selecting a random candidate", self) return candidates.pop() @classmethod @@ -874,12 +910,12 @@

                                                                          Module exchangelib.properties

                                                                          class CalendarView(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendarview""" - ELEMENT_NAME = 'CalendarView' + ELEMENT_NAME = "CalendarView" NAMESPACE = MNS - start = DateTimeField(field_uri='StartDate', is_required=True, is_attribute=True) - end = DateTimeField(field_uri='EndDate', is_required=True, is_attribute=True) - max_items = IntegerField(field_uri='MaxEntriesReturned', min=1, is_attribute=True) + start = DateTimeField(field_uri="StartDate", is_required=True, is_attribute=True) + end = DateTimeField(field_uri="EndDate", is_required=True, is_attribute=True) + max_items = IntegerField(field_uri="MaxEntriesReturned", min=1, is_attribute=True) def clean(self, version=None): super().clean(version=version) @@ -890,53 +926,61 @@

                                                                          Module exchangelib.properties

                                                                          class CalendarEventDetails(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendareventdetails""" - ELEMENT_NAME = 'CalendarEventDetails' + ELEMENT_NAME = "CalendarEventDetails" - id = CharField(field_uri='ID') - subject = CharField(field_uri='Subject') - location = CharField(field_uri='Location') - is_meeting = BooleanField(field_uri='IsMeeting') - is_recurring = BooleanField(field_uri='IsRecurring') - is_exception = BooleanField(field_uri='IsException') - is_reminder_set = BooleanField(field_uri='IsReminderSet') - is_private = BooleanField(field_uri='IsPrivate') + id = CharField(field_uri="ID") + subject = CharField(field_uri="Subject") + location = CharField(field_uri="Location") + is_meeting = BooleanField(field_uri="IsMeeting") + is_recurring = BooleanField(field_uri="IsRecurring") + is_exception = BooleanField(field_uri="IsException") + is_reminder_set = BooleanField(field_uri="IsReminderSet") + is_private = BooleanField(field_uri="IsPrivate") class CalendarEvent(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendarevent""" - ELEMENT_NAME = 'CalendarEvent' + ELEMENT_NAME = "CalendarEvent" - start = DateTimeField(field_uri='StartTime') - end = DateTimeField(field_uri='EndTime') - busy_type = FreeBusyStatusField(field_uri='BusyType', is_required=True, default='Busy') + start = DateTimeField(field_uri="StartTime") + end = DateTimeField(field_uri="EndTime") + busy_type = FreeBusyStatusField(field_uri="BusyType", is_required=True, default="Busy") details = EWSElementField(value_cls=CalendarEventDetails) class WorkingPeriod(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/workingperiod""" - ELEMENT_NAME = 'WorkingPeriod' + ELEMENT_NAME = "WorkingPeriod" - weekdays = EnumListField(field_uri='DayOfWeek', enum=WEEKDAY_NAMES, is_required=True) - start = TimeField(field_uri='StartTimeInMinutes', is_required=True) - end = TimeField(field_uri='EndTimeInMinutes', is_required=True) + weekdays = EnumListField(field_uri="DayOfWeek", enum=WEEKDAY_NAMES, is_required=True) + start = TimeField(field_uri="StartTimeInMinutes", is_required=True) + end = TimeField(field_uri="EndTimeInMinutes", is_required=True) class FreeBusyView(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/freebusyview""" - ELEMENT_NAME = 'FreeBusyView' + ELEMENT_NAME = "FreeBusyView" NAMESPACE = MNS - view_type = ChoiceField(field_uri='FreeBusyViewType', choices={ - Choice('None'), Choice('MergedOnly'), Choice('FreeBusy'), Choice('FreeBusyMerged'), Choice('Detailed'), - Choice('DetailedMerged'), - }, is_required=True) + view_type = ChoiceField( + field_uri="FreeBusyViewType", + choices={ + Choice("None"), + Choice("MergedOnly"), + Choice("FreeBusy"), + Choice("FreeBusyMerged"), + Choice("Detailed"), + Choice("DetailedMerged"), + }, + is_required=True, + ) # A string of digits. Each digit points to a position in .fields.FREE_BUSY_CHOICES - merged = CharField(field_uri='MergedFreeBusy') - calendar_events = EWSElementListField(field_uri='CalendarEventArray', value_cls=CalendarEvent) + merged = CharField(field_uri="MergedFreeBusy") + calendar_events = EWSElementListField(field_uri="CalendarEventArray", value_cls=CalendarEvent) # WorkingPeriod is located inside the WorkingPeriodArray element which is inside the WorkingHours element - working_hours = EWSElementListField(field_uri='WorkingPeriodArray', value_cls=WorkingPeriod) + working_hours = EWSElementListField(field_uri="WorkingPeriodArray", value_cls=WorkingPeriod) # TimeZone is also inside the WorkingHours element. It contains information about the timezone which the # account is located in. working_hours_timezone = EWSElementField(value_cls=TimeZone) @@ -944,9 +988,9 @@

                                                                          Module exchangelib.properties

                                                                          @classmethod def from_xml(cls, elem, account): kwargs = {} - working_hours_elem = elem.find(f'{{{TNS}}}WorkingHours') + working_hours_elem = elem.find(f"{{{TNS}}}WorkingHours") for f in cls.FIELDS: - if f.name in ['working_hours', 'working_hours_timezone']: + if f.name in ["working_hours", "working_hours_timezone"]: if working_hours_elem is None: continue kwargs[f.name] = f.from_xml(elem=working_hours_elem, account=account) @@ -959,29 +1003,29 @@

                                                                          Module exchangelib.properties

                                                                          class RoomList(Mailbox): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/roomlist""" - ELEMENT_NAME = 'RoomList' + ELEMENT_NAME = "RoomList" NAMESPACE = MNS @classmethod def response_tag(cls): # In a GetRoomLists response, room lists are delivered as Address elements. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/address-emailaddresstype - return f'{{{TNS}}}Address' + return f"{{{TNS}}}Address" class Room(Mailbox): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/room""" - ELEMENT_NAME = 'Room' + ELEMENT_NAME = "Room" @classmethod def from_xml(cls, elem, account): - id_elem = elem.find(f'{{{TNS}}}Id') + id_elem = elem.find(f"{{{TNS}}}Id") item_id_elem = id_elem.find(ItemId.response_tag()) kwargs = dict( - name=get_xml_attr(id_elem, f'{{{TNS}}}Name'), - email_address=get_xml_attr(id_elem, f'{{{TNS}}}EmailAddress'), - mailbox_type=get_xml_attr(id_elem, f'{{{TNS}}}MailboxType'), + name=get_xml_attr(id_elem, f"{{{TNS}}}Name"), + email_address=get_xml_attr(id_elem, f"{{{TNS}}}EmailAddress"), + mailbox_type=get_xml_attr(id_elem, f"{{{TNS}}}MailboxType"), item_id=ItemId.from_xml(elem=item_id_elem, account=account) if item_id_elem else None, ) cls._clear(elem) @@ -993,12 +1037,12 @@

                                                                          Module exchangelib.properties

                                                                          https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/member-ex15websvcsotherref """ - ELEMENT_NAME = 'Member' + ELEMENT_NAME = "Member" mailbox = MailboxField(is_required=True) - status = ChoiceField(field_uri='Status', choices={ - Choice('Unrecognized'), Choice('Normal'), Choice('Demoted') - }, default='Normal') + status = ChoiceField( + field_uri="Status", choices={Choice("Unrecognized"), Choice("Normal"), Choice("Demoted")}, default="Normal" + ) def __hash__(self): return hash(self.mailbox) @@ -1007,58 +1051,74 @@

                                                                          Module exchangelib.properties

                                                                          class UserId(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/userid""" - ELEMENT_NAME = 'UserId' + ELEMENT_NAME = "UserId" - sid = CharField(field_uri='SID') - primary_smtp_address = EmailAddressField(field_uri='PrimarySmtpAddress') - display_name = CharField(field_uri='DisplayName') - distinguished_user = ChoiceField(field_uri='DistinguishedUser', choices={ - Choice('Default'), Choice('Anonymous') - }) - external_user_identity = CharField(field_uri='ExternalUserIdentity') + sid = CharField(field_uri="SID") + primary_smtp_address = EmailAddressField(field_uri="PrimarySmtpAddress") + display_name = CharField(field_uri="DisplayName") + distinguished_user = ChoiceField(field_uri="DistinguishedUser", choices={Choice("Default"), Choice("Anonymous")}) + external_user_identity = CharField(field_uri="ExternalUserIdentity") class BasePermission(EWSElement, metaclass=EWSMeta): """Base class for the Permission and CalendarPermission classes""" - PERMISSION_ENUM = {Choice('None'), Choice('Owned'), Choice('All')} + PERMISSION_ENUM = {Choice("None"), Choice("Owned"), Choice("All")} - can_create_items = BooleanField(field_uri='CanCreateItems', default=False) - can_create_subfolders = BooleanField(field_uri='CanCreateSubfolders', default=False) - is_folder_owner = BooleanField(field_uri='IsFolderOwner', default=False) - is_folder_visible = BooleanField(field_uri='IsFolderVisible', default=False) - is_folder_contact = BooleanField(field_uri='IsFolderContact', default=False) - edit_items = ChoiceField(field_uri='EditItems', choices=PERMISSION_ENUM, default='None') - delete_items = ChoiceField(field_uri='DeleteItems', choices=PERMISSION_ENUM, default='None') - read_items = ChoiceField(field_uri='ReadItems', choices={Choice('None'), Choice('FullDetails')}, default='None') + can_create_items = BooleanField(field_uri="CanCreateItems", default=False) + can_create_subfolders = BooleanField(field_uri="CanCreateSubfolders", default=False) + is_folder_owner = BooleanField(field_uri="IsFolderOwner", default=False) + is_folder_visible = BooleanField(field_uri="IsFolderVisible", default=False) + is_folder_contact = BooleanField(field_uri="IsFolderContact", default=False) + edit_items = ChoiceField(field_uri="EditItems", choices=PERMISSION_ENUM, default="None") + delete_items = ChoiceField(field_uri="DeleteItems", choices=PERMISSION_ENUM, default="None") + read_items = ChoiceField(field_uri="ReadItems", choices={Choice("None"), Choice("FullDetails")}, default="None") user_id = EWSElementField(value_cls=UserId, is_required=True) class Permission(BasePermission): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/permission""" - ELEMENT_NAME = 'Permission' + ELEMENT_NAME = "Permission" LEVEL_CHOICES = ( - 'None', 'Owner', 'PublishingEditor', 'Editor', 'PublishingAuthor', 'Author', 'NoneditingAuthor', 'Reviewer', - 'Contributor', 'Custom', + "None", + "Owner", + "PublishingEditor", + "Editor", + "PublishingAuthor", + "Author", + "NoneditingAuthor", + "Reviewer", + "Contributor", + "Custom", ) permission_level = ChoiceField( - field_uri='CalendarPermissionLevel', choices={Choice(c) for c in LEVEL_CHOICES}, default=LEVEL_CHOICES[0] + field_uri="CalendarPermissionLevel", choices={Choice(c) for c in LEVEL_CHOICES}, default=LEVEL_CHOICES[0] ) class CalendarPermission(BasePermission): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendarpermission""" - ELEMENT_NAME = 'CalendarPermission' + ELEMENT_NAME = "CalendarPermission" LEVEL_CHOICES = ( - 'None', 'Owner', 'PublishingEditor', 'Editor', 'PublishingAuthor', 'Author', 'NoneditingAuthor', 'Reviewer', - 'Contributor', 'FreeBusyTimeOnly', 'FreeBusyTimeAndSubjectAndLocation', 'Custom', + "None", + "Owner", + "PublishingEditor", + "Editor", + "PublishingAuthor", + "Author", + "NoneditingAuthor", + "Reviewer", + "Contributor", + "FreeBusyTimeOnly", + "FreeBusyTimeAndSubjectAndLocation", + "Custom", ) calendar_permission_level = ChoiceField( - field_uri='CalendarPermissionLevel', choices={Choice(c) for c in LEVEL_CHOICES}, default=LEVEL_CHOICES[0] + field_uri="CalendarPermissionLevel", choices={Choice(c) for c in LEVEL_CHOICES}, default=LEVEL_CHOICES[0] ) @@ -1070,25 +1130,25 @@

                                                                          Module exchangelib.properties

                                                                          """ # For simplicity, we implement the two distinct but equally names elements as one class. - ELEMENT_NAME = 'PermissionSet' + ELEMENT_NAME = "PermissionSet" - permissions = EWSElementListField(field_uri='Permissions', value_cls=Permission) - calendar_permissions = EWSElementListField(field_uri='CalendarPermissions', value_cls=CalendarPermission) - unknown_entries = UnknownEntriesField(field_uri='UnknownEntries') + permissions = EWSElementListField(field_uri="Permissions", value_cls=Permission) + calendar_permissions = EWSElementListField(field_uri="CalendarPermissions", value_cls=CalendarPermission) + unknown_entries = UnknownEntriesField(field_uri="UnknownEntries") class EffectiveRights(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/effectiverights""" - ELEMENT_NAME = 'EffectiveRights' + ELEMENT_NAME = "EffectiveRights" - create_associated = BooleanField(field_uri='CreateAssociated', default=False) - create_contents = BooleanField(field_uri='CreateContents', default=False) - create_hierarchy = BooleanField(field_uri='CreateHierarchy', default=False) - delete = BooleanField(field_uri='Delete', default=False) - modify = BooleanField(field_uri='Modify', default=False) - read = BooleanField(field_uri='Read', default=False) - view_private_items = BooleanField(field_uri='ViewPrivateItems', default=False) + create_associated = BooleanField(field_uri="CreateAssociated", default=False) + create_contents = BooleanField(field_uri="CreateContents", default=False) + create_hierarchy = BooleanField(field_uri="CreateHierarchy", default=False) + delete = BooleanField(field_uri="Delete", default=False) + modify = BooleanField(field_uri="Modify", default=False) + read = BooleanField(field_uri="Read", default=False) + view_private_items = BooleanField(field_uri="ViewPrivateItems", default=False) def __contains__(self, item): return getattr(self, item, False) @@ -1097,92 +1157,102 @@

                                                                          Module exchangelib.properties

                                                                          class DelegatePermissions(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/delegatepermissions""" - ELEMENT_NAME = 'DelegatePermissions' + ELEMENT_NAME = "DelegatePermissions" PERMISSION_LEVEL_CHOICES = { - Choice('None'), Choice('Editor'), Choice('Reviewer'), Choice('Author'), Choice('Custom'), - } - - calendar_folder_permission_level = ChoiceField(field_uri='CalendarFolderPermissionLevel', - choices=PERMISSION_LEVEL_CHOICES, default='None') - tasks_folder_permission_level = ChoiceField(field_uri='TasksFolderPermissionLevel', - choices=PERMISSION_LEVEL_CHOICES, default='None') - inbox_folder_permission_level = ChoiceField(field_uri='InboxFolderPermissionLevel', - choices=PERMISSION_LEVEL_CHOICES, default='None') - contacts_folder_permission_level = ChoiceField(field_uri='ContactsFolderPermissionLevel', - choices=PERMISSION_LEVEL_CHOICES, default='None') - notes_folder_permission_level = ChoiceField(field_uri='NotesFolderPermissionLevel', - choices=PERMISSION_LEVEL_CHOICES, default='None') - journal_folder_permission_level = ChoiceField(field_uri='JournalFolderPermissionLevel', - choices=PERMISSION_LEVEL_CHOICES, default='None') + Choice("None"), + Choice("Editor"), + Choice("Reviewer"), + Choice("Author"), + Choice("Custom"), + } + + calendar_folder_permission_level = ChoiceField( + field_uri="CalendarFolderPermissionLevel", choices=PERMISSION_LEVEL_CHOICES, default="None" + ) + tasks_folder_permission_level = ChoiceField( + field_uri="TasksFolderPermissionLevel", choices=PERMISSION_LEVEL_CHOICES, default="None" + ) + inbox_folder_permission_level = ChoiceField( + field_uri="InboxFolderPermissionLevel", choices=PERMISSION_LEVEL_CHOICES, default="None" + ) + contacts_folder_permission_level = ChoiceField( + field_uri="ContactsFolderPermissionLevel", choices=PERMISSION_LEVEL_CHOICES, default="None" + ) + notes_folder_permission_level = ChoiceField( + field_uri="NotesFolderPermissionLevel", choices=PERMISSION_LEVEL_CHOICES, default="None" + ) + journal_folder_permission_level = ChoiceField( + field_uri="JournalFolderPermissionLevel", choices=PERMISSION_LEVEL_CHOICES, default="None" + ) class DelegateUser(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/delegateuser""" - ELEMENT_NAME = 'DelegateUser' + ELEMENT_NAME = "DelegateUser" NAMESPACE = MNS user_id = EWSElementField(value_cls=UserId) delegate_permissions = EWSElementField(value_cls=DelegatePermissions) - receive_copies_of_meeting_messages = BooleanField(field_uri='ReceiveCopiesOfMeetingMessages', default=False) - view_private_items = BooleanField(field_uri='ViewPrivateItems', default=False) + receive_copies_of_meeting_messages = BooleanField(field_uri="ReceiveCopiesOfMeetingMessages", default=False) + view_private_items = BooleanField(field_uri="ViewPrivateItems", default=False) class SearchableMailbox(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/searchablemailbox""" - ELEMENT_NAME = 'SearchableMailbox' + ELEMENT_NAME = "SearchableMailbox" - guid = CharField(field_uri='Guid') - primary_smtp_address = EmailAddressField(field_uri='PrimarySmtpAddress') - is_external = BooleanField(field_uri='IsExternalMailbox') - external_email = EmailAddressField(field_uri='ExternalEmailAddress') - display_name = CharField(field_uri='DisplayName') - is_membership_group = BooleanField(field_uri='IsMembershipGroup') - reference_id = CharField(field_uri='ReferenceId') + guid = CharField(field_uri="Guid") + primary_smtp_address = EmailAddressField(field_uri="PrimarySmtpAddress") + is_external = BooleanField(field_uri="IsExternalMailbox") + external_email = EmailAddressField(field_uri="ExternalEmailAddress") + display_name = CharField(field_uri="DisplayName") + is_membership_group = BooleanField(field_uri="IsMembershipGroup") + reference_id = CharField(field_uri="ReferenceId") class FailedMailbox(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/failedmailbox""" - ELEMENT_NAME = 'FailedMailbox' + ELEMENT_NAME = "FailedMailbox" - mailbox = CharField(field_uri='Mailbox') - error_code = IntegerField(field_uri='ErrorCode') - error_message = CharField(field_uri='ErrorMessage') - is_archive = BooleanField(field_uri='IsArchive') + mailbox = CharField(field_uri="Mailbox") + error_code = IntegerField(field_uri="ErrorCode") + error_message = CharField(field_uri="ErrorMessage") + is_archive = BooleanField(field_uri="IsArchive") # MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailtipsrequested MAIL_TIPS_TYPES = ( - 'All', - 'OutOfOfficeMessage', - 'MailboxFullStatus', - 'CustomMailTip', - 'ExternalMemberCount', - 'TotalMemberCount', - 'MaxMessageSize', - 'DeliveryRestriction', - 'ModerationStatus', - 'InvalidRecipient', + "All", + "OutOfOfficeMessage", + "MailboxFullStatus", + "CustomMailTip", + "ExternalMemberCount", + "TotalMemberCount", + "MaxMessageSize", + "DeliveryRestriction", + "ModerationStatus", + "InvalidRecipient", ) class OutOfOffice(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/outofoffice""" - ELEMENT_NAME = 'OutOfOffice' + ELEMENT_NAME = "OutOfOffice" - reply_body = MessageField(field_uri='ReplyBody') - start = DateTimeField(field_uri='StartTime', is_required=False) - end = DateTimeField(field_uri='EndTime', is_required=False) + reply_body = MessageField(field_uri="ReplyBody") + start = DateTimeField(field_uri="StartTime", is_required=False) + end = DateTimeField(field_uri="EndTime", is_required=False) @classmethod def duration_to_start_end(cls, elem, account): kwargs = {} - duration = elem.find(f'{{{TNS}}}Duration') + duration = elem.find(f"{{{TNS}}}Duration") if duration is not None: - for attr in ('start', 'end'): + for attr in ("start", "end"): f = cls.get_field_by_fieldname(attr) kwargs[attr] = f.from_xml(elem=duration, account=account) return kwargs @@ -1190,7 +1260,7 @@

                                                                          Module exchangelib.properties

                                                                          @classmethod def from_xml(cls, elem, account): kwargs = {} - for attr in ('reply_body',): + for attr in ("reply_body",): f = cls.get_field_by_fieldname(attr) kwargs[attr] = f.from_xml(elem=elem, account=account) kwargs.update(cls.duration_to_start_end(elem=elem, account=account)) @@ -1201,28 +1271,28 @@

                                                                          Module exchangelib.properties

                                                                          class MailTips(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailtips""" - ELEMENT_NAME = 'MailTips' + ELEMENT_NAME = "MailTips" NAMESPACE = MNS recipient_address = RecipientAddressField() - pending_mail_tips = ChoiceField(field_uri='PendingMailTips', choices={Choice(c) for c in MAIL_TIPS_TYPES}) + pending_mail_tips = ChoiceField(field_uri="PendingMailTips", choices={Choice(c) for c in MAIL_TIPS_TYPES}) out_of_office = EWSElementField(value_cls=OutOfOffice) - mailbox_full = BooleanField(field_uri='MailboxFull') - custom_mail_tip = TextField(field_uri='CustomMailTip') - total_member_count = IntegerField(field_uri='TotalMemberCount') - external_member_count = IntegerField(field_uri='ExternalMemberCount') - max_message_size = IntegerField(field_uri='MaxMessageSize') - delivery_restricted = BooleanField(field_uri='DeliveryRestricted') - is_moderated = BooleanField(field_uri='IsModerated') - invalid_recipient = BooleanField(field_uri='InvalidRecipient') - - -ENTRY_ID = 'EntryId' # The base64-encoded PR_ENTRYID property -EWS_ID = 'EwsId' # The EWS format used in Exchange 2007 SP1 and later -EWS_LEGACY_ID = 'EwsLegacyId' # The EWS format used in Exchange 2007 before SP1 -HEX_ENTRY_ID = 'HexEntryId' # The hexadecimal representation of the PR_ENTRYID property -OWA_ID = 'OwaId' # The OWA format for Exchange 2007 and 2010 -STORE_ID = 'StoreId' # The Exchange Store format + mailbox_full = BooleanField(field_uri="MailboxFull") + custom_mail_tip = TextField(field_uri="CustomMailTip") + total_member_count = IntegerField(field_uri="TotalMemberCount") + external_member_count = IntegerField(field_uri="ExternalMemberCount") + max_message_size = IntegerField(field_uri="MaxMessageSize") + delivery_restricted = BooleanField(field_uri="DeliveryRestricted") + is_moderated = BooleanField(field_uri="IsModerated") + invalid_recipient = BooleanField(field_uri="InvalidRecipient") + + +ENTRY_ID = "EntryId" # The base64-encoded PR_ENTRYID property +EWS_ID = "EwsId" # The EWS format used in Exchange 2007 SP1 and later +EWS_LEGACY_ID = "EwsLegacyId" # The EWS format used in Exchange 2007 before SP1 +HEX_ENTRY_ID = "HexEntryId" # The hexadecimal representation of the PR_ENTRYID property +OWA_ID = "OwaId" # The OWA format for Exchange 2007 and 2010 +STORE_ID = "StoreId" # The Exchange Store format # IdFormat enum ID_FORMATS = (ENTRY_ID, EWS_ID, EWS_LEGACY_ID, HEX_ENTRY_ID, OWA_ID, STORE_ID) @@ -1230,28 +1300,30 @@

                                                                          Module exchangelib.properties

                                                                          class AlternateId(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/alternateid""" - ELEMENT_NAME = 'AlternateId' + ELEMENT_NAME = "AlternateId" - id = CharField(field_uri='Id', is_required=True, is_attribute=True) - format = ChoiceField(field_uri='Format', is_required=True, is_attribute=True, - choices={Choice(c) for c in ID_FORMATS}) - mailbox = EmailAddressField(field_uri='Mailbox', is_required=True, is_attribute=True) - is_archive = BooleanField(field_uri='IsArchive', is_required=False, is_attribute=True) + id = CharField(field_uri="Id", is_required=True, is_attribute=True) + format = ChoiceField( + field_uri="Format", is_required=True, is_attribute=True, choices={Choice(c) for c in ID_FORMATS} + ) + mailbox = EmailAddressField(field_uri="Mailbox", is_required=True, is_attribute=True) + is_archive = BooleanField(field_uri="IsArchive", is_required=False, is_attribute=True) @classmethod def response_tag(cls): # This element is in TNS in the request and MNS in the response... - return f'{{{MNS}}}{cls.ELEMENT_NAME}' + return f"{{{MNS}}}{cls.ELEMENT_NAME}" class AlternatePublicFolderId(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/alternatepublicfolderid""" - ELEMENT_NAME = 'AlternatePublicFolderId' + ELEMENT_NAME = "AlternatePublicFolderId" - folder_id = CharField(field_uri='FolderId', is_required=True, is_attribute=True) - format = ChoiceField(field_uri='Format', is_required=True, is_attribute=True, - choices={Choice(c) for c in ID_FORMATS}) + folder_id = CharField(field_uri="FolderId", is_required=True, is_attribute=True) + format = ChoiceField( + field_uri="Format", is_required=True, is_attribute=True, choices={Choice(c) for c in ID_FORMATS} + ) class AlternatePublicFolderItemId(EWSElement): @@ -1259,136 +1331,140 @@

                                                                          Module exchangelib.properties

                                                                          https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/alternatepublicfolderitemid """ - ELEMENT_NAME = 'AlternatePublicFolderItemId' + ELEMENT_NAME = "AlternatePublicFolderItemId" - folder_id = CharField(field_uri='FolderId', is_required=True, is_attribute=True) - format = ChoiceField(field_uri='Format', is_required=True, is_attribute=True, - choices={Choice(c) for c in ID_FORMATS}) - item_id = CharField(field_uri='ItemId', is_required=True, is_attribute=True) + folder_id = CharField(field_uri="FolderId", is_required=True, is_attribute=True) + format = ChoiceField( + field_uri="Format", is_required=True, is_attribute=True, choices={Choice(c) for c in ID_FORMATS} + ) + item_id = CharField(field_uri="ItemId", is_required=True, is_attribute=True) class FieldURI(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/fielduri""" - ELEMENT_NAME = 'FieldURI' + ELEMENT_NAME = "FieldURI" - field_uri = CharField(field_uri='FieldURI', is_attribute=True, is_required=True) + field_uri = CharField(field_uri="FieldURI", is_attribute=True, is_required=True) class IndexedFieldURI(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/indexedfielduri""" - ELEMENT_NAME = 'IndexedFieldURI' + ELEMENT_NAME = "IndexedFieldURI" - field_uri = CharField(field_uri='FieldURI', is_attribute=True, is_required=True) - field_index = CharField(field_uri='FieldIndex', is_attribute=True, is_required=True) + field_uri = CharField(field_uri="FieldURI", is_attribute=True, is_required=True) + field_index = CharField(field_uri="FieldIndex", is_attribute=True, is_required=True) class ExtendedFieldURI(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/extendedfielduri""" - ELEMENT_NAME = 'ExtendedFieldURI' + ELEMENT_NAME = "ExtendedFieldURI" - distinguished_property_set_id = CharField(field_uri='DistinguishedPropertySetId', is_attribute=True) - property_set_id = CharField(field_uri='PropertySetId', is_attribute=True) - property_tag = CharField(field_uri='PropertyTag', is_attribute=True) - property_name = CharField(field_uri='PropertyName', is_attribute=True) - property_id = CharField(field_uri='PropertyId', is_attribute=True) - property_type = CharField(field_uri='PropertyType', is_attribute=True) + distinguished_property_set_id = CharField(field_uri="DistinguishedPropertySetId", is_attribute=True) + property_set_id = CharField(field_uri="PropertySetId", is_attribute=True) + property_tag = CharField(field_uri="PropertyTag", is_attribute=True) + property_name = CharField(field_uri="PropertyName", is_attribute=True) + property_id = CharField(field_uri="PropertyId", is_attribute=True) + property_type = CharField(field_uri="PropertyType", is_attribute=True) class ExceptionFieldURI(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exceptionfielduri""" - ELEMENT_NAME = 'ExceptionFieldURI' + ELEMENT_NAME = "ExceptionFieldURI" - field_uri = CharField(field_uri='FieldURI', is_attribute=True, is_required=True) + field_uri = CharField(field_uri="FieldURI", is_attribute=True, is_required=True) class CompleteName(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/completename""" - ELEMENT_NAME = 'CompleteName' + ELEMENT_NAME = "CompleteName" - title = CharField(field_uri='Title') - first_name = CharField(field_uri='FirstName') - middle_name = CharField(field_uri='MiddleName') - last_name = CharField(field_uri='LastName') - suffix = CharField(field_uri='Suffix') - initials = CharField(field_uri='Initials') - full_name = CharField(field_uri='FullName') - nickname = CharField(field_uri='Nickname') - yomi_first_name = CharField(field_uri='YomiFirstName') - yomi_last_name = CharField(field_uri='YomiLastName') + title = CharField(field_uri="Title") + first_name = CharField(field_uri="FirstName") + middle_name = CharField(field_uri="MiddleName") + last_name = CharField(field_uri="LastName") + suffix = CharField(field_uri="Suffix") + initials = CharField(field_uri="Initials") + full_name = CharField(field_uri="FullName") + nickname = CharField(field_uri="Nickname") + yomi_first_name = CharField(field_uri="YomiFirstName") + yomi_last_name = CharField(field_uri="YomiLastName") class ReminderMessageData(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/remindermessagedata""" - ELEMENT_NAME = 'ReminderMessageData' + ELEMENT_NAME = "ReminderMessageData" - reminder_text = CharField(field_uri='ReminderText') - location = CharField(field_uri='Location') - start_time = TimeField(field_uri='StartTime') - end_time = TimeField(field_uri='EndTime') - associated_calendar_item_id = AssociatedCalendarItemIdField(field_uri='AssociatedCalendarItemId', - supported_from=Build(15, 0, 913, 9)) + reminder_text = CharField(field_uri="ReminderText") + location = CharField(field_uri="Location") + start_time = TimeField(field_uri="StartTime") + end_time = TimeField(field_uri="EndTime") + associated_calendar_item_id = AssociatedCalendarItemIdField( + field_uri="AssociatedCalendarItemId", supported_from=Build(15, 0, 913, 9) + ) class AcceptSharingInvitation(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/acceptsharinginvitation""" - ELEMENT_NAME = 'AcceptSharingInvitation' + ELEMENT_NAME = "AcceptSharingInvitation" - reference_item_id = ReferenceItemIdField(field_uri='item:ReferenceItemId') + reference_item_id = ReferenceItemIdField(field_uri="item:ReferenceItemId") class SuppressReadReceipt(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/suppressreadreceipt""" - ELEMENT_NAME = 'SuppressReadReceipt' + ELEMENT_NAME = "SuppressReadReceipt" - reference_item_id = ReferenceItemIdField(field_uri='item:ReferenceItemId') + reference_item_id = ReferenceItemIdField(field_uri="item:ReferenceItemId") class RemoveItem(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/removeitem""" - ELEMENT_NAME = 'RemoveItem' + ELEMENT_NAME = "RemoveItem" - reference_item_id = ReferenceItemIdField(field_uri='item:ReferenceItemId') + reference_item_id = ReferenceItemIdField(field_uri="item:ReferenceItemId") class ResponseObjects(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/responseobjects""" - ELEMENT_NAME = 'ResponseObjects' + ELEMENT_NAME = "ResponseObjects" NAMESPACE = EWSElement.NAMESPACE - accept_item = EWSElementField(field_uri='AcceptItem', value_cls='AcceptItem', namespace=NAMESPACE) - tentatively_accept_item = EWSElementField(field_uri='TentativelyAcceptItem', value_cls='TentativelyAcceptItem', - namespace=NAMESPACE) - decline_item = EWSElementField(field_uri='DeclineItem', value_cls='DeclineItem', namespace=NAMESPACE) - reply_to_item = EWSElementField(field_uri='ReplyToItem', value_cls='ReplyToItem', namespace=NAMESPACE) - forward_item = EWSElementField(field_uri='ForwardItem', value_cls='ForwardItem', namespace=NAMESPACE) - reply_all_to_item = EWSElementField(field_uri='ReplyAllToItem', value_cls='ReplyAllToItem', namespace=NAMESPACE) - cancel_calendar_item = EWSElementField(field_uri='CancelCalendarItem', value_cls='CancelCalendarItem', - namespace=NAMESPACE) - remove_item = EWSElementField(field_uri='RemoveItem', value_cls=RemoveItem) - post_reply_item = EWSElementField(field_uri='PostReplyItem', value_cls='PostReplyItem', - namespace=EWSElement.NAMESPACE) - success_read_receipt = EWSElementField(field_uri='SuppressReadReceipt', value_cls=SuppressReadReceipt) - accept_sharing_invitation = EWSElementField(field_uri='AcceptSharingInvitation', - value_cls=AcceptSharingInvitation) + accept_item = EWSElementField(field_uri="AcceptItem", value_cls="AcceptItem", namespace=NAMESPACE) + tentatively_accept_item = EWSElementField( + field_uri="TentativelyAcceptItem", value_cls="TentativelyAcceptItem", namespace=NAMESPACE + ) + decline_item = EWSElementField(field_uri="DeclineItem", value_cls="DeclineItem", namespace=NAMESPACE) + reply_to_item = EWSElementField(field_uri="ReplyToItem", value_cls="ReplyToItem", namespace=NAMESPACE) + forward_item = EWSElementField(field_uri="ForwardItem", value_cls="ForwardItem", namespace=NAMESPACE) + reply_all_to_item = EWSElementField(field_uri="ReplyAllToItem", value_cls="ReplyAllToItem", namespace=NAMESPACE) + cancel_calendar_item = EWSElementField( + field_uri="CancelCalendarItem", value_cls="CancelCalendarItem", namespace=NAMESPACE + ) + remove_item = EWSElementField(field_uri="RemoveItem", value_cls=RemoveItem) + post_reply_item = EWSElementField( + field_uri="PostReplyItem", value_cls="PostReplyItem", namespace=EWSElement.NAMESPACE + ) + success_read_receipt = EWSElementField(field_uri="SuppressReadReceipt", value_cls=SuppressReadReceipt) + accept_sharing_invitation = EWSElementField(field_uri="AcceptSharingInvitation", value_cls=AcceptSharingInvitation) class PhoneNumber(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/phonenumber""" - ELEMENT_NAME = 'PhoneNumber' + ELEMENT_NAME = "PhoneNumber" - number = CharField(field_uri='Number') - type = CharField(field_uri='Type') + number = CharField(field_uri="Number") + type = CharField(field_uri="Type") class IdChangeKeyMixIn(EWSElement, metaclass=EWSMeta): @@ -1399,14 +1475,14 @@

                                                                          Module exchangelib.properties

                                                                          ID_ELEMENT_CLS = None def __init__(self, **kwargs): - _id, _changekey = kwargs.pop('id', None), kwargs.pop('changekey', None) + _id, _changekey = kwargs.pop("id", None), kwargs.pop("changekey", None) if _id or _changekey: - kwargs['_id'] = self.ID_ELEMENT_CLS(_id, _changekey) + kwargs["_id"] = self.ID_ELEMENT_CLS(_id, _changekey) super().__init__(**kwargs) @classmethod def get_field_by_fieldname(cls, fieldname): - if fieldname in ('id', 'changekey'): + if fieldname in ("id", "changekey"): return cls.ID_ELEMENT_CLS.get_field_by_fieldname(fieldname=fieldname) return super().get_field_by_fieldname(fieldname=fieldname) @@ -1444,7 +1520,7 @@

                                                                          Module exchangelib.properties

                                                                          def to_id(self): if self._id is None: - raise ValueError('Must have an ID') + raise ValueError("Must have an ID") return self._id def __eq__(self, other): @@ -1462,23 +1538,24 @@

                                                                          Module exchangelib.properties

                                                                          class DictionaryEntry(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/dictionaryentry""" - ELEMENT_NAME = 'DictionaryEntry' + ELEMENT_NAME = "DictionaryEntry" - key = TypeValueField(field_uri='DictionaryKey') - value = TypeValueField(field_uri='DictionaryValue') + key = TypeValueField(field_uri="DictionaryKey") + value = TypeValueField(field_uri="DictionaryValue") class UserConfigurationName(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/userconfigurationname""" - ELEMENT_NAME = 'UserConfigurationName' + ELEMENT_NAME = "UserConfigurationName" NAMESPACE = TNS - name = CharField(field_uri='Name', is_attribute=True) + name = CharField(field_uri="Name", is_attribute=True) folder = EWSElementField(value_cls=FolderId) def clean(self, version=None): from .folders import BaseFolder + if isinstance(self.folder, BaseFolder): self.folder = self.folder.to_id() super().clean(version=version) @@ -1506,29 +1583,29 @@

                                                                          Module exchangelib.properties

                                                                          class UserConfiguration(IdChangeKeyMixIn): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/userconfiguration""" - ELEMENT_NAME = 'UserConfiguration' + ELEMENT_NAME = "UserConfiguration" NAMESPACE = MNS ID_ELEMENT_CLS = ItemId - _id = IdElementField(field_uri='ItemId', value_cls=ID_ELEMENT_CLS) + _id = IdElementField(field_uri="ItemId", value_cls=ID_ELEMENT_CLS) user_configuration_name = EWSElementField(value_cls=UserConfigurationName) - dictionary = DictionaryField(field_uri='Dictionary') - xml_data = Base64Field(field_uri='XmlData') - binary_data = Base64Field(field_uri='BinaryData') + dictionary = DictionaryField(field_uri="Dictionary") + xml_data = Base64Field(field_uri="XmlData") + binary_data = Base64Field(field_uri="BinaryData") class Attribution(IdChangeKeyMixIn): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/phonenumber""" - ELEMENT_NAME = 'Attribution' + ELEMENT_NAME = "Attribution" ID_ELEMENT_CLS = SourceId - ID = CharField(field_uri='Id') - _id = IdElementField(field_uri='SourceId', value_cls=ID_ELEMENT_CLS) - display_name = CharField(field_uri='DisplayName') - is_writable = BooleanField(field_uri='IsWritable') - is_quick_contact = BooleanField(field_uri='IsQuickContact') - is_hidden = BooleanField(field_uri='IsHidden') + ID = CharField(field_uri="Id") + _id = IdElementField(field_uri="SourceId", value_cls=ID_ELEMENT_CLS) + display_name = CharField(field_uri="DisplayName") + is_writable = BooleanField(field_uri="IsWritable") + is_quick_contact = BooleanField(field_uri="IsQuickContact") + is_hidden = BooleanField(field_uri="IsHidden") folder_id = EWSElementField(value_cls=FolderId) @@ -1537,10 +1614,10 @@

                                                                          Module exchangelib.properties

                                                                          https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/value-bodycontenttype """ - ELEMENT_NAME = 'Value' + ELEMENT_NAME = "Value" - value = CharField(field_uri='Value') - body_type = CharField(field_uri='BodyType') + value = CharField(field_uri="Value") + body_type = CharField(field_uri="BodyType") class BodyContentAttributedValue(EWSElement): @@ -1548,10 +1625,10 @@

                                                                          Module exchangelib.properties

                                                                          https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/bodycontentattributedvalue """ - ELEMENT_NAME = 'BodyContentAttributedValue' + ELEMENT_NAME = "BodyContentAttributedValue" value = EWSElementField(value_cls=BodyContentValue) - attributions = EWSElementListField(field_uri='Attributions', value_cls=Attribution) + attributions = EWSElementListField(field_uri="Attributions", value_cls=Attribution) class StringAttributedValue(EWSElement): @@ -1559,10 +1636,10 @@

                                                                          Module exchangelib.properties

                                                                          https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/stringattributedvalue """ - ELEMENT_NAME = 'StringAttributedValue' + ELEMENT_NAME = "StringAttributedValue" - value = CharField(field_uri='Value') - attributions = CharListField(field_uri='Attributions', list_elem_name='Attribution') + value = CharField(field_uri="Value") + attributions = CharListField(field_uri="Attributions", list_elem_name="Attribution") class PersonaPhoneNumberTypeValue(EWSElement): @@ -1570,10 +1647,10 @@

                                                                          Module exchangelib.properties

                                                                          https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/value-personaphonenumbertype """ - ELEMENT_NAME = 'Value' + ELEMENT_NAME = "Value" - number = CharField(field_uri='Number') - type = CharField(field_uri='Type') + number = CharField(field_uri="Number") + type = CharField(field_uri="Type") class PhoneNumberAttributedValue(EWSElement): @@ -1581,10 +1658,10 @@

                                                                          Module exchangelib.properties

                                                                          https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/phonenumberattributedvalue """ - ELEMENT_NAME = 'PhoneNumberAttributedValue' + ELEMENT_NAME = "PhoneNumberAttributedValue" value = EWSElementField(value_cls=PersonaPhoneNumberTypeValue) - attributions = CharListField(field_uri='Attributions', list_elem_name='Attribution') + attributions = CharListField(field_uri="Attributions", list_elem_name="Attribution") class EmailAddressTypeValue(Mailbox): @@ -1592,9 +1669,9 @@

                                                                          Module exchangelib.properties

                                                                          https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/value-emailaddresstype """ - ELEMENT_NAME = 'Value' + ELEMENT_NAME = "Value" - original_display_name = TextField(field_uri='OriginalDisplayName') + original_display_name = TextField(field_uri="OriginalDisplayName") class EmailAddressAttributedValue(EWSElement): @@ -1602,10 +1679,10 @@

                                                                          Module exchangelib.properties

                                                                          https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/emailaddressattributedvalue """ - ELEMENT_NAME = 'EmailAddressAttributedValue' + ELEMENT_NAME = "EmailAddressAttributedValue" value = EWSElementField(value_cls=EmailAddressTypeValue) - attributions = EWSElementListField(field_uri='Attributions', value_cls=Attribution) + attributions = EWSElementListField(field_uri="Attributions", value_cls=Attribution) class PersonaPostalAddressTypeValue(Mailbox): @@ -1613,23 +1690,23 @@

                                                                          Module exchangelib.properties

                                                                          https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/value-personapostaladdresstype """ - ELEMENT_NAME = 'Value' - - street = TextField(field_uri='Street') - city = TextField(field_uri='City') - state = TextField(field_uri='State') - country = TextField(field_uri='Country') - postal_code = TextField(field_uri='PostalCode') - post_office_box = TextField(field_uri='PostOfficeBox') - type = TextField(field_uri='Type') - latitude = TextField(field_uri='Latitude') - longitude = TextField(field_uri='Longitude') - accuracy = TextField(field_uri='Accuracy') - altitude = TextField(field_uri='Altitude') - altitude_accuracy = TextField(field_uri='AltitudeAccuracy') - formatted_address = TextField(field_uri='FormattedAddress') - location_uri = TextField(field_uri='LocationUri') - location_source = TextField(field_uri='LocationSource') + ELEMENT_NAME = "Value" + + street = TextField(field_uri="Street") + city = TextField(field_uri="City") + state = TextField(field_uri="State") + country = TextField(field_uri="Country") + postal_code = TextField(field_uri="PostalCode") + post_office_box = TextField(field_uri="PostOfficeBox") + type = TextField(field_uri="Type") + latitude = TextField(field_uri="Latitude") + longitude = TextField(field_uri="Longitude") + accuracy = TextField(field_uri="Accuracy") + altitude = TextField(field_uri="Altitude") + altitude_accuracy = TextField(field_uri="AltitudeAccuracy") + formatted_address = TextField(field_uri="FormattedAddress") + location_uri = TextField(field_uri="LocationUri") + location_source = TextField(field_uri="LocationSource") class PostalAddressAttributedValue(EWSElement): @@ -1637,28 +1714,28 @@

                                                                          Module exchangelib.properties

                                                                          https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/postaladdressattributedvalue """ - ELEMENT_NAME = 'PostalAddressAttributedValue' + ELEMENT_NAME = "PostalAddressAttributedValue" value = EWSElementField(value_cls=PersonaPostalAddressTypeValue) - attributions = EWSElementListField(field_uri='Attributions', value_cls=Attribution) + attributions = EWSElementListField(field_uri="Attributions", value_cls=Attribution) class Event(EWSElement, metaclass=EWSMeta): """Base class for all event types.""" - watermark = CharField(field_uri='Watermark') + watermark = CharField(field_uri="Watermark") class TimestampEvent(Event, metaclass=EWSMeta): """Base class for both item and folder events with a timestamp.""" - FOLDER = 'folder' - ITEM = 'item' + FOLDER = "folder" + ITEM = "item" - timestamp = DateTimeField(field_uri='TimeStamp') - item_id = EWSElementField(field_uri='ItemId', value_cls=ItemId) - folder_id = EWSElementField(field_uri='FolderId', value_cls=FolderId) - parent_folder_id = EWSElementField(field_uri='ParentFolderId', value_cls=ParentFolderId) + timestamp = DateTimeField(field_uri="TimeStamp") + item_id = EWSElementField(field_uri="ItemId", value_cls=ItemId) + folder_id = EWSElementField(field_uri="FolderId", value_cls=FolderId) + parent_folder_id = EWSElementField(field_uri="ParentFolderId", value_cls=ParentFolderId) @property def event_type(self): @@ -1672,59 +1749,59 @@

                                                                          Module exchangelib.properties

                                                                          class OldTimestampEvent(TimestampEvent, metaclass=EWSMeta): """Base class for both item and folder copy/move events.""" - old_item_id = EWSElementField(field_uri='OldItemId', value_cls=ItemId) - old_folder_id = EWSElementField(field_uri='OldFolderId', value_cls=FolderId) - old_parent_folder_id = EWSElementField(field_uri='OldParentFolderId', value_cls=ParentFolderId) + old_item_id = EWSElementField(field_uri="OldItemId", value_cls=ItemId) + old_folder_id = EWSElementField(field_uri="OldFolderId", value_cls=FolderId) + old_parent_folder_id = EWSElementField(field_uri="OldParentFolderId", value_cls=ParentFolderId) class CopiedEvent(OldTimestampEvent): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/copiedevent""" - ELEMENT_NAME = 'CopiedEvent' + ELEMENT_NAME = "CopiedEvent" class CreatedEvent(TimestampEvent): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createdevent""" - ELEMENT_NAME = 'CreatedEvent' + ELEMENT_NAME = "CreatedEvent" class DeletedEvent(TimestampEvent): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deletedevent""" - ELEMENT_NAME = 'DeletedEvent' + ELEMENT_NAME = "DeletedEvent" class ModifiedEvent(TimestampEvent): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/modifiedevent""" - ELEMENT_NAME = 'ModifiedEvent' + ELEMENT_NAME = "ModifiedEvent" - unread_count = IntegerField(field_uri='UnreadCount') + unread_count = IntegerField(field_uri="UnreadCount") class MovedEvent(OldTimestampEvent): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/movedevent""" - ELEMENT_NAME = 'MovedEvent' + ELEMENT_NAME = "MovedEvent" class NewMailEvent(TimestampEvent): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/newmailevent""" - ELEMENT_NAME = 'NewMailEvent' + ELEMENT_NAME = "NewMailEvent" class StatusEvent(Event): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/statusevent""" - ELEMENT_NAME = 'StatusEvent' + ELEMENT_NAME = "StatusEvent" class FreeBusyChangedEvent(TimestampEvent): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/freebusychangedevent""" - ELEMENT_NAME = 'FreeBusyChangedEvent' + ELEMENT_NAME = "FreeBusyChangedEvent" class Notification(EWSElement): @@ -1732,30 +1809,31 @@

                                                                          Module exchangelib.properties

                                                                          https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/notification-ex15websvcsotherref """ - ELEMENT_NAME = 'Notification' + ELEMENT_NAME = "Notification" NAMESPACE = MNS - subscription_id = CharField(field_uri='SubscriptionId') - previous_watermark = CharField(field_uri='PreviousWatermark') - more_events = BooleanField(field_uri='MoreEvents') - events = GenericEventListField('') + subscription_id = CharField(field_uri="SubscriptionId") + previous_watermark = CharField(field_uri="PreviousWatermark") + more_events = BooleanField(field_uri="MoreEvents") + events = GenericEventListField("") class BaseTransition(EWSElement, metaclass=EWSMeta): """Base class for all other transition classes""" - to = CharField(field_uri='To') - kind = CharField(field_uri='Kind', is_attribute=True) # An attribute on the 'To' element + to = CharField(field_uri="To") + kind = CharField(field_uri="Kind", is_attribute=True) # An attribute on the 'To' element @staticmethod def transition_model_from_tag(tag): - return {cls.response_tag(): cls for cls in ( - Transition, AbsoluteDateTransition, RecurringDateTransition, RecurringDayTransition - )}[tag] + return { + cls.response_tag(): cls + for cls in (Transition, AbsoluteDateTransition, RecurringDateTransition, RecurringDayTransition) + }[tag] @classmethod def from_xml(cls, elem, account): - kind = elem.find(cls.get_field_by_fieldname('to').response_tag()).get('Kind') + kind = elem.find(cls.get_field_by_fieldname("to").response_tag()).get("Kind") res = super().from_xml(elem=elem, account=account) res.kind = kind return res @@ -1764,27 +1842,27 @@

                                                                          Module exchangelib.properties

                                                                          class Transition(BaseTransition): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/transition""" - ELEMENT_NAME = 'Transition' + ELEMENT_NAME = "Transition" class AbsoluteDateTransition(BaseTransition): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/absolutedatetransition""" - ELEMENT_NAME = 'AbsoluteDateTransition' + ELEMENT_NAME = "AbsoluteDateTransition" - date = DateTimeBackedDateField(field_uri='DateTime') + date = DateTimeBackedDateField(field_uri="DateTime") class RecurringDayTransition(BaseTransition): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recurringdaytransition""" - ELEMENT_NAME = 'RecurringDayTransition' + ELEMENT_NAME = "RecurringDayTransition" - offset = TimeDeltaField(field_uri='TimeOffset') - month = IntegerField(field_uri='Month') + offset = TimeDeltaField(field_uri="TimeOffset") + month = IntegerField(field_uri="Month") # Valid ISO 8601 weekday, as a number in range 1 -> 7 (1 being Monday) - day_of_week = EnumField(field_uri='DayOfWeek', enum=WEEKDAY_NAMES) - occurrence = IntegerField(field_uri='Occurrence') + day_of_week = EnumField(field_uri="DayOfWeek", enum=WEEKDAY_NAMES) + occurrence = IntegerField(field_uri="Occurrence") @classmethod def from_xml(cls, elem, account): @@ -1798,24 +1876,24 @@

                                                                          Module exchangelib.properties

                                                                          class RecurringDateTransition(BaseTransition): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recurringdatetransition""" - ELEMENT_NAME = 'RecurringDateTransition' + ELEMENT_NAME = "RecurringDateTransition" - offset = TimeDeltaField(field_uri='TimeOffset') - month = IntegerField(field_uri='Month') - day = IntegerField(field_uri='Day') # Day of month + offset = TimeDeltaField(field_uri="TimeOffset") + month = IntegerField(field_uri="Month") + day = IntegerField(field_uri="Day") # Day of month class Period(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/period""" - ELEMENT_NAME = 'Period' + ELEMENT_NAME = "Period" - id = CharField(field_uri='Id', is_attribute=True) - name = CharField(field_uri='Name', is_attribute=True) - bias = TimeDeltaField(field_uri='Bias', is_attribute=True) + id = CharField(field_uri="Id", is_attribute=True) + name = CharField(field_uri="Name", is_attribute=True) + bias = TimeDeltaField(field_uri="Bias", is_attribute=True) def _split_id(self): - to_year, to_type = self.id.rsplit('/', 1)[1].split('-') + to_year, to_type = self.id.rsplit("/", 1)[1].split("-") return int(to_year), to_type @property @@ -1834,23 +1912,23 @@

                                                                          Module exchangelib.properties

                                                                          class TransitionsGroup(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/transitionsgroup""" - ELEMENT_NAME = 'TransitionsGroup' + ELEMENT_NAME = "TransitionsGroup" - id = CharField(field_uri='Id', is_attribute=True) + id = CharField(field_uri="Id", is_attribute=True) transitions = TransitionListField(value_cls=BaseTransition) class TimeZoneDefinition(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezonedefinition""" - ELEMENT_NAME = 'TimeZoneDefinition' + ELEMENT_NAME = "TimeZoneDefinition" - id = CharField(field_uri='Id', is_attribute=True) - name = CharField(field_uri='Name', is_attribute=True) + id = CharField(field_uri="Id", is_attribute=True) + name = CharField(field_uri="Name", is_attribute=True) - periods = EWSElementListField(field_uri='Periods', value_cls=Period) - transitions_groups = EWSElementListField(field_uri='TransitionsGroups', value_cls=TransitionsGroup) - transitions = TransitionListField(field_uri='Transitions', value_cls=BaseTransition) + periods = EWSElementListField(field_uri="Periods", value_cls=Period) + transitions_groups = EWSElementListField(field_uri="TransitionsGroups", value_cls=TransitionsGroup) + transitions = TransitionListField(field_uri="Transitions", value_cls=BaseTransition) @classmethod def from_xml(cls, elem, account): @@ -1862,11 +1940,11 @@

                                                                          Module exchangelib.properties

                                                                          for period in sorted(self.periods, key=lambda p: (p.year, p.type)): if period.year > for_year: break - if period.type != 'Standard': + if period.type != "Standard": continue valid_period = period if valid_period is None: - raise TimezoneDefinitionInvalidForYear(f'Year {for_year} not included in periods {self.periods}') + raise TimezoneDefinitionInvalidForYear(f"Year {for_year} not included in periods {self.periods}") return valid_period def _get_transitions_group(self, for_year): @@ -1874,20 +1952,20 @@

                                                                          Module exchangelib.properties

                                                                          transitions_group = None transitions_groups_map = {tg.id: tg for tg in self.transitions_groups} for transition in sorted(self.transitions, key=lambda t: t.to): - if transition.kind != 'Group': + if transition.kind != "Group": continue if isinstance(transition, AbsoluteDateTransition) and transition.date.year > for_year: break transitions_group = transitions_groups_map[transition.to] if transitions_group is None: - raise ValueError(f'No valid transition group for year {for_year}: {self.transitions}') + raise ValueError(f"No valid transition group for year {for_year}: {self.transitions}") return transitions_group def get_std_and_dst(self, for_year): # Return 'standard_time' and 'daylight_time' objects. We do unnecessary work here, but it keeps code simple. transitions_group = self._get_transitions_group(for_year) if not 0 <= len(transitions_group.transitions) <= 2: - raise ValueError(f'Expected 0-2 transitions in transitions group {transitions_group}') + raise ValueError(f"Expected 0-2 transitions in transitions group {transitions_group}") standard_period = self._get_standard_period(for_year) periods_map = {p.id: p for p in self.periods} @@ -1910,15 +1988,15 @@

                                                                          Module exchangelib.properties

                                                                          weekday=transition.day_of_week, ) period = periods_map[transition.to] - if period.name == 'Standard': - transition_kwargs['bias'] = 0 + if period.name == "Standard": + transition_kwargs["bias"] = 0 standard_time = StandardTime(**transition_kwargs) continue - if period.name == 'Daylight': - transition_kwargs['bias'] = period.bias_in_minutes - standard_period.bias_in_minutes + if period.name == "Daylight": + transition_kwargs["bias"] = period.bias_in_minutes - standard_period.bias_in_minutes daylight_time = DaylightTime(**transition_kwargs) continue - raise ValueError(f'Unknown transition: {transition}') + raise ValueError(f"Unknown transition: {transition}") return standard_time, daylight_time, standard_period
                                                                          @@ -1944,9 +2022,9 @@

                                                                          Classes

                                                                          class AbsoluteDateTransition(BaseTransition):
                                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/absolutedatetransition"""
                                                                           
                                                                          -    ELEMENT_NAME = 'AbsoluteDateTransition'
                                                                          +    ELEMENT_NAME = "AbsoluteDateTransition"
                                                                           
                                                                          -    date = DateTimeBackedDateField(field_uri='DateTime')
                                                                          + date = DateTimeBackedDateField(field_uri="DateTime")

                                                                        Ancestors

                                                                          @@ -1996,9 +2074,9 @@

                                                                          Inherited members

                                                                          class AcceptSharingInvitation(EWSElement):
                                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/acceptsharinginvitation"""
                                                                           
                                                                          -    ELEMENT_NAME = 'AcceptSharingInvitation'
                                                                          +    ELEMENT_NAME = "AcceptSharingInvitation"
                                                                           
                                                                          -    reference_item_id = ReferenceItemIdField(field_uri='item:ReferenceItemId')
                                                                          + reference_item_id = ReferenceItemIdField(field_uri="item:ReferenceItemId")

                                                                        Ancestors

                                                                          @@ -2051,7 +2129,7 @@

                                                                          Inherited members

                                                                          MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/address-emailaddresstype """ - ELEMENT_NAME = 'Address' + ELEMENT_NAME = "Address"

                                                                          Ancestors

                                                                            @@ -2090,18 +2168,19 @@

                                                                            Inherited members

                                                                            class AlternateId(EWSElement):
                                                                                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/alternateid"""
                                                                             
                                                                            -    ELEMENT_NAME = 'AlternateId'
                                                                            +    ELEMENT_NAME = "AlternateId"
                                                                             
                                                                            -    id = CharField(field_uri='Id', is_required=True, is_attribute=True)
                                                                            -    format = ChoiceField(field_uri='Format', is_required=True, is_attribute=True,
                                                                            -                         choices={Choice(c) for c in ID_FORMATS})
                                                                            -    mailbox = EmailAddressField(field_uri='Mailbox', is_required=True, is_attribute=True)
                                                                            -    is_archive = BooleanField(field_uri='IsArchive', is_required=False, is_attribute=True)
                                                                            +    id = CharField(field_uri="Id", is_required=True, is_attribute=True)
                                                                            +    format = ChoiceField(
                                                                            +        field_uri="Format", is_required=True, is_attribute=True, choices={Choice(c) for c in ID_FORMATS}
                                                                            +    )
                                                                            +    mailbox = EmailAddressField(field_uri="Mailbox", is_required=True, is_attribute=True)
                                                                            +    is_archive = BooleanField(field_uri="IsArchive", is_required=False, is_attribute=True)
                                                                             
                                                                                 @classmethod
                                                                                 def response_tag(cls):
                                                                                     # This element is in TNS in the request and MNS in the response...
                                                                            -        return f'{{{MNS}}}{cls.ELEMENT_NAME}'
                                                                            + return f"{{{MNS}}}{cls.ELEMENT_NAME}"

                                                                            Ancestors

                                                                              @@ -2132,7 +2211,7 @@

                                                                              Static methods

                                                                              @classmethod
                                                                               def response_tag(cls):
                                                                                   # This element is in TNS in the request and MNS in the response...
                                                                              -    return f'{{{MNS}}}{cls.ELEMENT_NAME}'
                                                                              + return f"{{{MNS}}}{cls.ELEMENT_NAME}" @@ -2180,11 +2259,12 @@

                                                                              Inherited members

                                                                              class AlternatePublicFolderId(EWSElement):
                                                                                   """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/alternatepublicfolderid"""
                                                                               
                                                                              -    ELEMENT_NAME = 'AlternatePublicFolderId'
                                                                              +    ELEMENT_NAME = "AlternatePublicFolderId"
                                                                               
                                                                              -    folder_id = CharField(field_uri='FolderId', is_required=True, is_attribute=True)
                                                                              -    format = ChoiceField(field_uri='Format', is_required=True, is_attribute=True,
                                                                              -                         choices={Choice(c) for c in ID_FORMATS})
                                                                              + folder_id = CharField(field_uri="FolderId", is_required=True, is_attribute=True) + format = ChoiceField( + field_uri="Format", is_required=True, is_attribute=True, choices={Choice(c) for c in ID_FORMATS} + )

                                                                              Ancestors

                                                                                @@ -2240,12 +2320,13 @@

                                                                                Inherited members

                                                                                https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/alternatepublicfolderitemid """ - ELEMENT_NAME = 'AlternatePublicFolderItemId' + ELEMENT_NAME = "AlternatePublicFolderItemId" - folder_id = CharField(field_uri='FolderId', is_required=True, is_attribute=True) - format = ChoiceField(field_uri='Format', is_required=True, is_attribute=True, - choices={Choice(c) for c in ID_FORMATS}) - item_id = CharField(field_uri='ItemId', is_required=True, is_attribute=True) + folder_id = CharField(field_uri="FolderId", is_required=True, is_attribute=True) + format = ChoiceField( + field_uri="Format", is_required=True, is_attribute=True, choices={Choice(c) for c in ID_FORMATS} + ) + item_id = CharField(field_uri="ItemId", is_required=True, is_attribute=True)

                                                                                Ancestors

                                                                                  @@ -2305,7 +2386,7 @@

                                                                                  Inherited members

                                                                                  https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/associatedcalendaritemid """ - ELEMENT_NAME = 'AssociatedCalendarItemId' + ELEMENT_NAME = "AssociatedCalendarItemId"

                                                                                  Ancestors

                                                                                    @@ -2345,13 +2426,14 @@

                                                                                    Inherited members

                                                                                    class Attendee(EWSElement):
                                                                                         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/attendee"""
                                                                                     
                                                                                    -    ELEMENT_NAME = 'Attendee'
                                                                                    -    RESPONSE_TYPES = {'Unknown', 'Organizer', 'Tentative', 'Accept', 'Decline', 'NoResponseReceived'}
                                                                                    +    ELEMENT_NAME = "Attendee"
                                                                                    +    RESPONSE_TYPES = {"Unknown", "Organizer", "Tentative", "Accept", "Decline", "NoResponseReceived"}
                                                                                     
                                                                                         mailbox = MailboxField(is_required=True)
                                                                                    -    response_type = ChoiceField(field_uri='ResponseType', choices={Choice(c) for c in RESPONSE_TYPES},
                                                                                    -                                default='Unknown')
                                                                                    -    last_response_time = DateTimeField(field_uri='LastResponseTime')
                                                                                    +    response_type = ChoiceField(
                                                                                    +        field_uri="ResponseType", choices={Choice(c) for c in RESPONSE_TYPES}, default="Unknown"
                                                                                    +    )
                                                                                    +    last_response_time = DateTimeField(field_uri="LastResponseTime")
                                                                                     
                                                                                         def __hash__(self):
                                                                                             return hash(self.mailbox)
                                                                                    @@ -2415,15 +2497,15 @@

                                                                                    Inherited members

                                                                                    class Attribution(IdChangeKeyMixIn):
                                                                                         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/phonenumber"""
                                                                                     
                                                                                    -    ELEMENT_NAME = 'Attribution'
                                                                                    +    ELEMENT_NAME = "Attribution"
                                                                                         ID_ELEMENT_CLS = SourceId
                                                                                     
                                                                                    -    ID = CharField(field_uri='Id')
                                                                                    -    _id = IdElementField(field_uri='SourceId', value_cls=ID_ELEMENT_CLS)
                                                                                    -    display_name = CharField(field_uri='DisplayName')
                                                                                    -    is_writable = BooleanField(field_uri='IsWritable')
                                                                                    -    is_quick_contact = BooleanField(field_uri='IsQuickContact')
                                                                                    -    is_hidden = BooleanField(field_uri='IsHidden')
                                                                                    +    ID = CharField(field_uri="Id")
                                                                                    +    _id = IdElementField(field_uri="SourceId", value_cls=ID_ELEMENT_CLS)
                                                                                    +    display_name = CharField(field_uri="DisplayName")
                                                                                    +    is_writable = BooleanField(field_uri="IsWritable")
                                                                                    +    is_quick_contact = BooleanField(field_uri="IsQuickContact")
                                                                                    +    is_hidden = BooleanField(field_uri="IsHidden")
                                                                                         folder_id = EWSElementField(value_cls=FolderId)

                                                                                    Ancestors

                                                                                    @@ -2502,13 +2584,13 @@

                                                                                    Inherited members

                                                                                    MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailbox-availability """ - ELEMENT_NAME = 'Mailbox' + ELEMENT_NAME = "Mailbox" - name = TextField(field_uri='Name') - email_address = EmailAddressField(field_uri='Address', is_required=True) + name = TextField(field_uri="Name") + email_address = EmailAddressField(field_uri="Address", is_required=True) # RoutingType values restricted to EX and SMTP: # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/routingtype-emailaddress - routing_type = RoutingTypeField(field_uri='RoutingType') + routing_type = RoutingTypeField(field_uri="RoutingType") def __hash__(self): # Exchange may add 'name' on insert. We're satisfied if the email address matches. @@ -2519,7 +2601,7 @@

                                                                                    Inherited members

                                                                                    @classmethod def from_mailbox(cls, mailbox): if not isinstance(mailbox, Mailbox): - raise InvalidTypeError('mailbox', mailbox, Mailbox) + raise InvalidTypeError("mailbox", mailbox, Mailbox) return cls(name=mailbox.name, email_address=mailbox.email_address, routing_type=mailbox.routing_type)

                                                                                    Ancestors

                                                                                    @@ -2555,7 +2637,7 @@

                                                                                    Static methods

                                                                                    @classmethod
                                                                                     def from_mailbox(cls, mailbox):
                                                                                         if not isinstance(mailbox, Mailbox):
                                                                                    -        raise InvalidTypeError('mailbox', mailbox, Mailbox)
                                                                                    +        raise InvalidTypeError("mailbox", mailbox, Mailbox)
                                                                                         return cls(name=mailbox.name, email_address=mailbox.email_address, routing_type=mailbox.routing_type)
                                                                                    @@ -2656,16 +2738,16 @@

                                                                                    Inherited members

                                                                                    class BasePermission(EWSElement, metaclass=EWSMeta):
                                                                                         """Base class for the Permission and CalendarPermission classes"""
                                                                                     
                                                                                    -    PERMISSION_ENUM = {Choice('None'), Choice('Owned'), Choice('All')}
                                                                                    +    PERMISSION_ENUM = {Choice("None"), Choice("Owned"), Choice("All")}
                                                                                     
                                                                                    -    can_create_items = BooleanField(field_uri='CanCreateItems', default=False)
                                                                                    -    can_create_subfolders = BooleanField(field_uri='CanCreateSubfolders', default=False)
                                                                                    -    is_folder_owner = BooleanField(field_uri='IsFolderOwner', default=False)
                                                                                    -    is_folder_visible = BooleanField(field_uri='IsFolderVisible', default=False)
                                                                                    -    is_folder_contact = BooleanField(field_uri='IsFolderContact', default=False)
                                                                                    -    edit_items = ChoiceField(field_uri='EditItems', choices=PERMISSION_ENUM, default='None')
                                                                                    -    delete_items = ChoiceField(field_uri='DeleteItems', choices=PERMISSION_ENUM, default='None')
                                                                                    -    read_items = ChoiceField(field_uri='ReadItems', choices={Choice('None'), Choice('FullDetails')}, default='None')
                                                                                    +    can_create_items = BooleanField(field_uri="CanCreateItems", default=False)
                                                                                    +    can_create_subfolders = BooleanField(field_uri="CanCreateSubfolders", default=False)
                                                                                    +    is_folder_owner = BooleanField(field_uri="IsFolderOwner", default=False)
                                                                                    +    is_folder_visible = BooleanField(field_uri="IsFolderVisible", default=False)
                                                                                    +    is_folder_contact = BooleanField(field_uri="IsFolderContact", default=False)
                                                                                    +    edit_items = ChoiceField(field_uri="EditItems", choices=PERMISSION_ENUM, default="None")
                                                                                    +    delete_items = ChoiceField(field_uri="DeleteItems", choices=PERMISSION_ENUM, default="None")
                                                                                    +    read_items = ChoiceField(field_uri="ReadItems", choices={Choice("None"), Choice("FullDetails")}, default="None")
                                                                                         user_id = EWSElementField(value_cls=UserId, is_required=True)

                                                                                    Ancestors

                                                                                    @@ -2752,18 +2834,19 @@

                                                                                    Inherited members

                                                                                    class BaseTransition(EWSElement, metaclass=EWSMeta):
                                                                                         """Base class for all other transition classes"""
                                                                                     
                                                                                    -    to = CharField(field_uri='To')
                                                                                    -    kind = CharField(field_uri='Kind', is_attribute=True)  # An attribute on the 'To' element
                                                                                    +    to = CharField(field_uri="To")
                                                                                    +    kind = CharField(field_uri="Kind", is_attribute=True)  # An attribute on the 'To' element
                                                                                     
                                                                                         @staticmethod
                                                                                         def transition_model_from_tag(tag):
                                                                                    -        return {cls.response_tag(): cls for cls in (
                                                                                    -            Transition, AbsoluteDateTransition, RecurringDateTransition, RecurringDayTransition
                                                                                    -        )}[tag]
                                                                                    +        return {
                                                                                    +            cls.response_tag(): cls
                                                                                    +            for cls in (Transition, AbsoluteDateTransition, RecurringDateTransition, RecurringDayTransition)
                                                                                    +        }[tag]
                                                                                     
                                                                                         @classmethod
                                                                                         def from_xml(cls, elem, account):
                                                                                    -        kind = elem.find(cls.get_field_by_fieldname('to').response_tag()).get('Kind')
                                                                                    +        kind = elem.find(cls.get_field_by_fieldname("to").response_tag()).get("Kind")
                                                                                             res = super().from_xml(elem=elem, account=account)
                                                                                             res.kind = kind
                                                                                             return res
                                                                                    @@ -2799,7 +2882,7 @@

                                                                                    Static methods

                                                                                    @classmethod
                                                                                     def from_xml(cls, elem, account):
                                                                                    -    kind = elem.find(cls.get_field_by_fieldname('to').response_tag()).get('Kind')
                                                                                    +    kind = elem.find(cls.get_field_by_fieldname("to").response_tag()).get("Kind")
                                                                                         res = super().from_xml(elem=elem, account=account)
                                                                                         res.kind = kind
                                                                                         return res
                                                                                    @@ -2816,9 +2899,10 @@

                                                                                    Static methods

                                                                                    @staticmethod
                                                                                     def transition_model_from_tag(tag):
                                                                                    -    return {cls.response_tag(): cls for cls in (
                                                                                    -        Transition, AbsoluteDateTransition, RecurringDateTransition, RecurringDayTransition
                                                                                    -    )}[tag]
                                                                                    + return { + cls.response_tag(): cls + for cls in (Transition, AbsoluteDateTransition, RecurringDateTransition, RecurringDayTransition) + }[tag] @@ -2862,7 +2946,7 @@

                                                                                    Inherited members

                                                                                    MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/body """ - body_type = 'Text' + body_type = "Text" def __add__(self, other): # Make sure Body('') + 'foo' returns a Body type @@ -2927,10 +3011,10 @@

                                                                                    Methods

                                                                                    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/bodycontentattributedvalue """ - ELEMENT_NAME = 'BodyContentAttributedValue' + ELEMENT_NAME = "BodyContentAttributedValue" value = EWSElementField(value_cls=BodyContentValue) - attributions = EWSElementListField(field_uri='Attributions', value_cls=Attribution) + attributions = EWSElementListField(field_uri="Attributions", value_cls=Attribution)

                                                                                    Ancestors

                                                                                      @@ -2986,10 +3070,10 @@

                                                                                      Inherited members

                                                                                      https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/value-bodycontenttype """ - ELEMENT_NAME = 'Value' + ELEMENT_NAME = "Value" - value = CharField(field_uri='Value') - body_type = CharField(field_uri='BodyType') + value = CharField(field_uri="Value") + body_type = CharField(field_uri="BodyType")

                                                                                      Ancestors

                                                                                        @@ -3042,11 +3126,11 @@

                                                                                        Inherited members

                                                                                        class CalendarEvent(EWSElement):
                                                                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendarevent"""
                                                                                         
                                                                                        -    ELEMENT_NAME = 'CalendarEvent'
                                                                                        +    ELEMENT_NAME = "CalendarEvent"
                                                                                         
                                                                                        -    start = DateTimeField(field_uri='StartTime')
                                                                                        -    end = DateTimeField(field_uri='EndTime')
                                                                                        -    busy_type = FreeBusyStatusField(field_uri='BusyType', is_required=True, default='Busy')
                                                                                        +    start = DateTimeField(field_uri="StartTime")
                                                                                        +    end = DateTimeField(field_uri="EndTime")
                                                                                        +    busy_type = FreeBusyStatusField(field_uri="BusyType", is_required=True, default="Busy")
                                                                                             details = EWSElementField(value_cls=CalendarEventDetails)

                                                                                        Ancestors

                                                                                        @@ -3108,16 +3192,16 @@

                                                                                        Inherited members

                                                                                        class CalendarEventDetails(EWSElement):
                                                                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendareventdetails"""
                                                                                         
                                                                                        -    ELEMENT_NAME = 'CalendarEventDetails'
                                                                                        +    ELEMENT_NAME = "CalendarEventDetails"
                                                                                         
                                                                                        -    id = CharField(field_uri='ID')
                                                                                        -    subject = CharField(field_uri='Subject')
                                                                                        -    location = CharField(field_uri='Location')
                                                                                        -    is_meeting = BooleanField(field_uri='IsMeeting')
                                                                                        -    is_recurring = BooleanField(field_uri='IsRecurring')
                                                                                        -    is_exception = BooleanField(field_uri='IsException')
                                                                                        -    is_reminder_set = BooleanField(field_uri='IsReminderSet')
                                                                                        -    is_private = BooleanField(field_uri='IsPrivate')
                                                                                        + id = CharField(field_uri="ID") + subject = CharField(field_uri="Subject") + location = CharField(field_uri="Location") + is_meeting = BooleanField(field_uri="IsMeeting") + is_recurring = BooleanField(field_uri="IsRecurring") + is_exception = BooleanField(field_uri="IsException") + is_reminder_set = BooleanField(field_uri="IsReminderSet") + is_private = BooleanField(field_uri="IsPrivate")

                                                                                        Ancestors

                                                                                          @@ -3194,14 +3278,24 @@

                                                                                          Inherited members

                                                                                          class CalendarPermission(BasePermission):
                                                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendarpermission"""
                                                                                           
                                                                                          -    ELEMENT_NAME = 'CalendarPermission'
                                                                                          +    ELEMENT_NAME = "CalendarPermission"
                                                                                               LEVEL_CHOICES = (
                                                                                          -        'None', 'Owner', 'PublishingEditor', 'Editor', 'PublishingAuthor', 'Author', 'NoneditingAuthor', 'Reviewer',
                                                                                          -        'Contributor', 'FreeBusyTimeOnly', 'FreeBusyTimeAndSubjectAndLocation', 'Custom',
                                                                                          +        "None",
                                                                                          +        "Owner",
                                                                                          +        "PublishingEditor",
                                                                                          +        "Editor",
                                                                                          +        "PublishingAuthor",
                                                                                          +        "Author",
                                                                                          +        "NoneditingAuthor",
                                                                                          +        "Reviewer",
                                                                                          +        "Contributor",
                                                                                          +        "FreeBusyTimeOnly",
                                                                                          +        "FreeBusyTimeAndSubjectAndLocation",
                                                                                          +        "Custom",
                                                                                               )
                                                                                           
                                                                                               calendar_permission_level = ChoiceField(
                                                                                          -        field_uri='CalendarPermissionLevel', choices={Choice(c) for c in LEVEL_CHOICES}, default=LEVEL_CHOICES[0]
                                                                                          +        field_uri="CalendarPermissionLevel", choices={Choice(c) for c in LEVEL_CHOICES}, default=LEVEL_CHOICES[0]
                                                                                               )

                                                                                          Ancestors

                                                                                          @@ -3256,12 +3350,12 @@

                                                                                          Inherited members

                                                                                          class CalendarView(EWSElement):
                                                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/calendarview"""
                                                                                           
                                                                                          -    ELEMENT_NAME = 'CalendarView'
                                                                                          +    ELEMENT_NAME = "CalendarView"
                                                                                               NAMESPACE = MNS
                                                                                           
                                                                                          -    start = DateTimeField(field_uri='StartDate', is_required=True, is_attribute=True)
                                                                                          -    end = DateTimeField(field_uri='EndDate', is_required=True, is_attribute=True)
                                                                                          -    max_items = IntegerField(field_uri='MaxEntriesReturned', min=1, is_attribute=True)
                                                                                          +    start = DateTimeField(field_uri="StartDate", is_required=True, is_attribute=True)
                                                                                          +    end = DateTimeField(field_uri="EndDate", is_required=True, is_attribute=True)
                                                                                          +    max_items = IntegerField(field_uri="MaxEntriesReturned", min=1, is_attribute=True)
                                                                                           
                                                                                               def clean(self, version=None):
                                                                                                   super().clean(version=version)
                                                                                          @@ -3345,18 +3439,18 @@ 

                                                                                          Inherited members

                                                                                          class CompleteName(EWSElement):
                                                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/completename"""
                                                                                           
                                                                                          -    ELEMENT_NAME = 'CompleteName'
                                                                                          +    ELEMENT_NAME = "CompleteName"
                                                                                           
                                                                                          -    title = CharField(field_uri='Title')
                                                                                          -    first_name = CharField(field_uri='FirstName')
                                                                                          -    middle_name = CharField(field_uri='MiddleName')
                                                                                          -    last_name = CharField(field_uri='LastName')
                                                                                          -    suffix = CharField(field_uri='Suffix')
                                                                                          -    initials = CharField(field_uri='Initials')
                                                                                          -    full_name = CharField(field_uri='FullName')
                                                                                          -    nickname = CharField(field_uri='Nickname')
                                                                                          -    yomi_first_name = CharField(field_uri='YomiFirstName')
                                                                                          -    yomi_last_name = CharField(field_uri='YomiLastName')
                                                                                          + title = CharField(field_uri="Title") + first_name = CharField(field_uri="FirstName") + middle_name = CharField(field_uri="MiddleName") + last_name = CharField(field_uri="LastName") + suffix = CharField(field_uri="Suffix") + initials = CharField(field_uri="Initials") + full_name = CharField(field_uri="FullName") + nickname = CharField(field_uri="Nickname") + yomi_first_name = CharField(field_uri="YomiFirstName") + yomi_last_name = CharField(field_uri="YomiLastName")

                                                                                          Ancestors

                                                                                            @@ -3441,7 +3535,7 @@

                                                                                            Inherited members

                                                                                            class ConversationId(ItemId):
                                                                                                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/conversationid"""
                                                                                             
                                                                                            -    ELEMENT_NAME = 'ConversationId'
                                                                                            + ELEMENT_NAME = "ConversationId"

                                                                                            Ancestors

                                                                                              @@ -3481,7 +3575,7 @@

                                                                                              Inherited members

                                                                                              class CopiedEvent(OldTimestampEvent):
                                                                                                   """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/copiedevent"""
                                                                                               
                                                                                              -    ELEMENT_NAME = 'CopiedEvent'
                                                                                              + ELEMENT_NAME = "CopiedEvent"

                                                                                              Ancestors

                                                                                                @@ -3522,7 +3616,7 @@

                                                                                                Inherited members

                                                                                                class CreatedEvent(TimestampEvent):
                                                                                                     """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createdevent"""
                                                                                                 
                                                                                                -    ELEMENT_NAME = 'CreatedEvent'
                                                                                                + ELEMENT_NAME = "CreatedEvent"

                                                                                                Ancestors

                                                                                                  @@ -3601,7 +3695,7 @@

                                                                                                  Inherited members

                                                                                                  class DaylightTime(TimeZoneTransition):
                                                                                                       """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/daylighttime"""
                                                                                                   
                                                                                                  -    ELEMENT_NAME = 'DaylightTime'
                                                                                                  + ELEMENT_NAME = "DaylightTime"

                                                                                                  Ancestors

                                                                                                    @@ -3640,23 +3734,33 @@

                                                                                                    Inherited members

                                                                                                    class DelegatePermissions(EWSElement):
                                                                                                         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/delegatepermissions"""
                                                                                                     
                                                                                                    -    ELEMENT_NAME = 'DelegatePermissions'
                                                                                                    +    ELEMENT_NAME = "DelegatePermissions"
                                                                                                         PERMISSION_LEVEL_CHOICES = {
                                                                                                    -            Choice('None'), Choice('Editor'), Choice('Reviewer'), Choice('Author'), Choice('Custom'),
                                                                                                    -        }
                                                                                                    -
                                                                                                    -    calendar_folder_permission_level = ChoiceField(field_uri='CalendarFolderPermissionLevel',
                                                                                                    -                                                   choices=PERMISSION_LEVEL_CHOICES, default='None')
                                                                                                    -    tasks_folder_permission_level = ChoiceField(field_uri='TasksFolderPermissionLevel',
                                                                                                    -                                                choices=PERMISSION_LEVEL_CHOICES, default='None')
                                                                                                    -    inbox_folder_permission_level = ChoiceField(field_uri='InboxFolderPermissionLevel',
                                                                                                    -                                                choices=PERMISSION_LEVEL_CHOICES, default='None')
                                                                                                    -    contacts_folder_permission_level = ChoiceField(field_uri='ContactsFolderPermissionLevel',
                                                                                                    -                                                   choices=PERMISSION_LEVEL_CHOICES, default='None')
                                                                                                    -    notes_folder_permission_level = ChoiceField(field_uri='NotesFolderPermissionLevel',
                                                                                                    -                                                choices=PERMISSION_LEVEL_CHOICES, default='None')
                                                                                                    -    journal_folder_permission_level = ChoiceField(field_uri='JournalFolderPermissionLevel',
                                                                                                    -                                                  choices=PERMISSION_LEVEL_CHOICES, default='None')
                                                                                                    + Choice("None"), + Choice("Editor"), + Choice("Reviewer"), + Choice("Author"), + Choice("Custom"), + } + + calendar_folder_permission_level = ChoiceField( + field_uri="CalendarFolderPermissionLevel", choices=PERMISSION_LEVEL_CHOICES, default="None" + ) + tasks_folder_permission_level = ChoiceField( + field_uri="TasksFolderPermissionLevel", choices=PERMISSION_LEVEL_CHOICES, default="None" + ) + inbox_folder_permission_level = ChoiceField( + field_uri="InboxFolderPermissionLevel", choices=PERMISSION_LEVEL_CHOICES, default="None" + ) + contacts_folder_permission_level = ChoiceField( + field_uri="ContactsFolderPermissionLevel", choices=PERMISSION_LEVEL_CHOICES, default="None" + ) + notes_folder_permission_level = ChoiceField( + field_uri="NotesFolderPermissionLevel", choices=PERMISSION_LEVEL_CHOICES, default="None" + ) + journal_folder_permission_level = ChoiceField( + field_uri="JournalFolderPermissionLevel", choices=PERMISSION_LEVEL_CHOICES, default="None" + )

                                                                                                    Ancestors

                                                                                                      @@ -3729,13 +3833,13 @@

                                                                                                      Inherited members

                                                                                                      class DelegateUser(EWSElement):
                                                                                                           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/delegateuser"""
                                                                                                       
                                                                                                      -    ELEMENT_NAME = 'DelegateUser'
                                                                                                      +    ELEMENT_NAME = "DelegateUser"
                                                                                                           NAMESPACE = MNS
                                                                                                       
                                                                                                           user_id = EWSElementField(value_cls=UserId)
                                                                                                           delegate_permissions = EWSElementField(value_cls=DelegatePermissions)
                                                                                                      -    receive_copies_of_meeting_messages = BooleanField(field_uri='ReceiveCopiesOfMeetingMessages', default=False)
                                                                                                      -    view_private_items = BooleanField(field_uri='ViewPrivateItems', default=False)
                                                                                                      + receive_copies_of_meeting_messages = BooleanField(field_uri="ReceiveCopiesOfMeetingMessages", default=False) + view_private_items = BooleanField(field_uri="ViewPrivateItems", default=False)

                                                                                                      Ancestors

                                                                                                        @@ -3800,7 +3904,7 @@

                                                                                                        Inherited members

                                                                                                        class DeletedEvent(TimestampEvent):
                                                                                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deletedevent"""
                                                                                                         
                                                                                                        -    ELEMENT_NAME = 'DeletedEvent'
                                                                                                        + ELEMENT_NAME = "DeletedEvent"

                                                                                                        Ancestors

                                                                                                          @@ -3840,10 +3944,10 @@

                                                                                                          Inherited members

                                                                                                          class DictionaryEntry(EWSElement):
                                                                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/dictionaryentry"""
                                                                                                           
                                                                                                          -    ELEMENT_NAME = 'DictionaryEntry'
                                                                                                          +    ELEMENT_NAME = "DictionaryEntry"
                                                                                                           
                                                                                                          -    key = TypeValueField(field_uri='DictionaryKey')
                                                                                                          -    value = TypeValueField(field_uri='DictionaryValue')
                                                                                                          + key = TypeValueField(field_uri="DictionaryKey") + value = TypeValueField(field_uri="DictionaryValue")

                                                                                                          Ancestors

                                                                                                            @@ -3896,12 +4000,13 @@

                                                                                                            Inherited members

                                                                                                            class DistinguishedFolderId(FolderId):
                                                                                                                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/distinguishedfolderid"""
                                                                                                             
                                                                                                            -    ELEMENT_NAME = 'DistinguishedFolderId'
                                                                                                            +    ELEMENT_NAME = "DistinguishedFolderId"
                                                                                                             
                                                                                                                 mailbox = MailboxField()
                                                                                                             
                                                                                                                 def clean(self, version=None):
                                                                                                                     from .folders import PublicFoldersRoot
                                                                                                            +
                                                                                                                     super().clean(version=version)
                                                                                                                     if self.id == PublicFoldersRoot.DISTINGUISHED_FOLDER_ID:
                                                                                                                         # Avoid "ErrorInvalidOperation: It is not valid to specify a mailbox with the public folder root" from EWS
                                                                                                            @@ -3945,6 +4050,7 @@ 

                                                                                                            Methods

                                                                                                            def clean(self, version=None):
                                                                                                                 from .folders import PublicFoldersRoot
                                                                                                            +
                                                                                                                 super().clean(version=version)
                                                                                                                 if self.id == PublicFoldersRoot.DISTINGUISHED_FOLDER_ID:
                                                                                                                     # Avoid "ErrorInvalidOperation: It is not valid to specify a mailbox with the public folder root" from EWS
                                                                                                            @@ -4001,7 +4107,7 @@ 

                                                                                                            Inherited members

                                                                                                            # Property setters return super().__setattr__(key, value) raise AttributeError( - f'{key!r} is not a valid attribute. See {self.__class__.__name__}.FIELDS for valid field names' + f"{key!r} is not a valid attribute. See {self.__class__.__name__}.FIELDS for valid field names" ) def clean(self, version=None): @@ -4063,19 +4169,19 @@

                                                                                                            Inherited members

                                                                                                            @classmethod def request_tag(cls): if not cls.ELEMENT_NAME: - raise ValueError(f'Class {cls} is missing the ELEMENT_NAME attribute') + raise ValueError(f"Class {cls} is missing the ELEMENT_NAME attribute") return { - TNS: f't:{cls.ELEMENT_NAME}', - MNS: f'm:{cls.ELEMENT_NAME}', + TNS: f"t:{cls.ELEMENT_NAME}", + MNS: f"m:{cls.ELEMENT_NAME}", }[cls.NAMESPACE] @classmethod def response_tag(cls): if not cls.NAMESPACE: - raise ValueError(f'Class {cls} is missing the NAMESPACE attribute') + raise ValueError(f"Class {cls} is missing the NAMESPACE attribute") if not cls.ELEMENT_NAME: - raise ValueError(f'Class {cls} is missing the ELEMENT_NAME attribute') - return f'{{{cls.NAMESPACE}}}{cls.ELEMENT_NAME}' + raise ValueError(f"Class {cls} is missing the ELEMENT_NAME attribute") + return f"{{{cls.NAMESPACE}}}{cls.ELEMENT_NAME}" @classmethod def attribute_fields(cls): @@ -4159,14 +4265,12 @@

                                                                                                            Inherited members

                                                                                                            return field_vals def __str__(self): - args_str = ', '.join( - f'{name}={val!r}' for name, val in self._field_vals() if val is not None - ) - return f'{self.__class__.__name__}({args_str})' + args_str = ", ".join(f"{name}={val!r}" for name, val in self._field_vals() if val is not None) + return f"{self.__class__.__name__}({args_str})" def __repr__(self): - args_str = ', '.join(f'{name}={val!r}' for name, val in self._field_vals()) - return f'{self.__class__.__name__}({args_str})'
                                                                                                            + args_str = ", ".join(f"{name}={val!r}" for name, val in self._field_vals()) + return f"{self.__class__.__name__}({args_str})"

                                                                                                            Subclasses

                                                                                                              @@ -4366,10 +4470,10 @@

                                                                                                              Static methods

                                                                                                              @classmethod
                                                                                                               def request_tag(cls):
                                                                                                                   if not cls.ELEMENT_NAME:
                                                                                                              -        raise ValueError(f'Class {cls} is missing the ELEMENT_NAME attribute')
                                                                                                              +        raise ValueError(f"Class {cls} is missing the ELEMENT_NAME attribute")
                                                                                                                   return {
                                                                                                              -        TNS: f't:{cls.ELEMENT_NAME}',
                                                                                                              -        MNS: f'm:{cls.ELEMENT_NAME}',
                                                                                                              +        TNS: f"t:{cls.ELEMENT_NAME}",
                                                                                                              +        MNS: f"m:{cls.ELEMENT_NAME}",
                                                                                                                   }[cls.NAMESPACE]
                                                                                                              @@ -4385,10 +4489,10 @@

                                                                                                              Static methods

                                                                                                              @classmethod
                                                                                                               def response_tag(cls):
                                                                                                                   if not cls.NAMESPACE:
                                                                                                              -        raise ValueError(f'Class {cls} is missing the NAMESPACE attribute')
                                                                                                              +        raise ValueError(f"Class {cls} is missing the NAMESPACE attribute")
                                                                                                                   if not cls.ELEMENT_NAME:
                                                                                                              -        raise ValueError(f'Class {cls} is missing the ELEMENT_NAME attribute')
                                                                                                              -    return f'{{{cls.NAMESPACE}}}{cls.ELEMENT_NAME}'
                                                                                                              + raise ValueError(f"Class {cls} is missing the ELEMENT_NAME attribute") + return f"{{{cls.NAMESPACE}}}{cls.ELEMENT_NAME}"
                                                                                                              @@ -4532,23 +4636,23 @@

                                                                                                              Methods

                                                                                                              # Build a list of fields defined on this and all base classes base_fields = Fields() for base in bases: - if hasattr(base, 'FIELDS'): + if hasattr(base, "FIELDS"): base_fields += base.FIELDS # FIELDS defined on a model overrides the base class fields - fields = kwargs.get('FIELDS', base_fields) + local_fields + fields = kwargs.get("FIELDS", base_fields) + local_fields # Include all fields as class attributes so we can use them as instance attributes kwargs.update({_mangle(f.name): f for f in fields}) # Calculate __slots__ so we don't have to hard-code it on the model - kwargs['__slots__'] = tuple(f.name for f in fields if f.name not in base_fields) + kwargs.get('__slots__', ()) + kwargs["__slots__"] = tuple(f.name for f in fields if f.name not in base_fields) + kwargs.get("__slots__", ()) # FIELDS is mentioned in docs and expected by internal code. Add it here, but only if the class has its own # fields. Otherwise, we want the implicit FIELDS from the base class (used for injecting custom fields on the # Folder class, making the custom field available for subclasses). if local_fields: - kwargs['FIELDS'] = fields + kwargs["FIELDS"] = fields cls = super().__new__(mcs, name, bases, kwargs) cls._slots_keys = mcs._get_slots_keys(cls) return cls @@ -4558,7 +4662,7 @@

                                                                                                              Methods

                                                                                                              seen = set() keys = [] for c in reversed(getmro(cls)): - if not hasattr(c, '__slots__'): + if not hasattr(c, "__slots__"): continue for k in c.__slots__: if k in seen: @@ -4572,7 +4676,7 @@

                                                                                                              Methods

                                                                                                              def __getattribute__(cls, k): """Return Field instances via their mangled class attribute""" try: - return super().__getattribute__('__dict__')[_mangle(k)] + return super().__getattribute__("__dict__")[_mangle(k)] except KeyError: return super().__getattribute__(k)
                                                                                                              @@ -4594,15 +4698,15 @@

                                                                                                              Ancestors

                                                                                                              class EffectiveRights(EWSElement):
                                                                                                                   """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/effectiverights"""
                                                                                                               
                                                                                                              -    ELEMENT_NAME = 'EffectiveRights'
                                                                                                              +    ELEMENT_NAME = "EffectiveRights"
                                                                                                               
                                                                                                              -    create_associated = BooleanField(field_uri='CreateAssociated', default=False)
                                                                                                              -    create_contents = BooleanField(field_uri='CreateContents', default=False)
                                                                                                              -    create_hierarchy = BooleanField(field_uri='CreateHierarchy', default=False)
                                                                                                              -    delete = BooleanField(field_uri='Delete', default=False)
                                                                                                              -    modify = BooleanField(field_uri='Modify', default=False)
                                                                                                              -    read = BooleanField(field_uri='Read', default=False)
                                                                                                              -    view_private_items = BooleanField(field_uri='ViewPrivateItems', default=False)
                                                                                                              +    create_associated = BooleanField(field_uri="CreateAssociated", default=False)
                                                                                                              +    create_contents = BooleanField(field_uri="CreateContents", default=False)
                                                                                                              +    create_hierarchy = BooleanField(field_uri="CreateHierarchy", default=False)
                                                                                                              +    delete = BooleanField(field_uri="Delete", default=False)
                                                                                                              +    modify = BooleanField(field_uri="Modify", default=False)
                                                                                                              +    read = BooleanField(field_uri="Read", default=False)
                                                                                                              +    view_private_items = BooleanField(field_uri="ViewPrivateItems", default=False)
                                                                                                               
                                                                                                                   def __contains__(self, item):
                                                                                                                       return getattr(self, item, False)
                                                                                                              @@ -4681,7 +4785,7 @@

                                                                                                              Inherited members

                                                                                                              MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/email-emailaddresstype """ - ELEMENT_NAME = 'Email' + ELEMENT_NAME = "Email"

                                                                                                              Ancestors

                                                                                                                @@ -4724,7 +4828,7 @@

                                                                                                                Inherited members

                                                                                                                MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/emailaddress-emailaddresstype """ - ELEMENT_NAME = 'EmailAddress' + ELEMENT_NAME = "EmailAddress"

                                                                                                                Ancestors

                                                                                                                  @@ -4766,10 +4870,10 @@

                                                                                                                  Inherited members

                                                                                                                  https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/emailaddressattributedvalue """ - ELEMENT_NAME = 'EmailAddressAttributedValue' + ELEMENT_NAME = "EmailAddressAttributedValue" value = EWSElementField(value_cls=EmailAddressTypeValue) - attributions = EWSElementListField(field_uri='Attributions', value_cls=Attribution) + attributions = EWSElementListField(field_uri="Attributions", value_cls=Attribution)

                                                                                                                  Ancestors

                                                                                                                    @@ -4825,9 +4929,9 @@

                                                                                                                    Inherited members

                                                                                                                    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/value-emailaddresstype """ - ELEMENT_NAME = 'Value' + ELEMENT_NAME = "Value" - original_display_name = TextField(field_uri='OriginalDisplayName') + original_display_name = TextField(field_uri="OriginalDisplayName")

                                                                                                                    Ancestors

                                                                                                                      @@ -4877,7 +4981,7 @@

                                                                                                                      Inherited members

                                                                                                                      class Event(EWSElement, metaclass=EWSMeta):
                                                                                                                           """Base class for all event types."""
                                                                                                                       
                                                                                                                      -    watermark = CharField(field_uri='Watermark')
                                                                                                                      + watermark = CharField(field_uri="Watermark")

                                                                                                                      Ancestors

                                                                                                                        @@ -4927,9 +5031,9 @@

                                                                                                                        Inherited members

                                                                                                                        class ExceptionFieldURI(EWSElement):
                                                                                                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exceptionfielduri"""
                                                                                                                         
                                                                                                                        -    ELEMENT_NAME = 'ExceptionFieldURI'
                                                                                                                        +    ELEMENT_NAME = "ExceptionFieldURI"
                                                                                                                         
                                                                                                                        -    field_uri = CharField(field_uri='FieldURI', is_attribute=True, is_required=True)
                                                                                                                        + field_uri = CharField(field_uri="FieldURI", is_attribute=True, is_required=True)

                                                                                                                        Ancestors

                                                                                                                          @@ -4978,14 +5082,14 @@

                                                                                                                          Inherited members

                                                                                                                          class ExtendedFieldURI(EWSElement):
                                                                                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/extendedfielduri"""
                                                                                                                           
                                                                                                                          -    ELEMENT_NAME = 'ExtendedFieldURI'
                                                                                                                          +    ELEMENT_NAME = "ExtendedFieldURI"
                                                                                                                           
                                                                                                                          -    distinguished_property_set_id = CharField(field_uri='DistinguishedPropertySetId', is_attribute=True)
                                                                                                                          -    property_set_id = CharField(field_uri='PropertySetId', is_attribute=True)
                                                                                                                          -    property_tag = CharField(field_uri='PropertyTag', is_attribute=True)
                                                                                                                          -    property_name = CharField(field_uri='PropertyName', is_attribute=True)
                                                                                                                          -    property_id = CharField(field_uri='PropertyId', is_attribute=True)
                                                                                                                          -    property_type = CharField(field_uri='PropertyType', is_attribute=True)
                                                                                                                          + distinguished_property_set_id = CharField(field_uri="DistinguishedPropertySetId", is_attribute=True) + property_set_id = CharField(field_uri="PropertySetId", is_attribute=True) + property_tag = CharField(field_uri="PropertyTag", is_attribute=True) + property_name = CharField(field_uri="PropertyName", is_attribute=True) + property_id = CharField(field_uri="PropertyId", is_attribute=True) + property_type = CharField(field_uri="PropertyType", is_attribute=True)

                                                                                                                          Ancestors

                                                                                                                            @@ -5054,12 +5158,12 @@

                                                                                                                            Inherited members

                                                                                                                            class FailedMailbox(EWSElement):
                                                                                                                                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/failedmailbox"""
                                                                                                                             
                                                                                                                            -    ELEMENT_NAME = 'FailedMailbox'
                                                                                                                            +    ELEMENT_NAME = "FailedMailbox"
                                                                                                                             
                                                                                                                            -    mailbox = CharField(field_uri='Mailbox')
                                                                                                                            -    error_code = IntegerField(field_uri='ErrorCode')
                                                                                                                            -    error_message = CharField(field_uri='ErrorMessage')
                                                                                                                            -    is_archive = BooleanField(field_uri='IsArchive')
                                                                                                                            + mailbox = CharField(field_uri="Mailbox") + error_code = IntegerField(field_uri="ErrorCode") + error_message = CharField(field_uri="ErrorMessage") + is_archive = BooleanField(field_uri="IsArchive")

                                                                                                                            Ancestors

                                                                                                                              @@ -5120,9 +5224,9 @@

                                                                                                                              Inherited members

                                                                                                                              class FieldURI(EWSElement):
                                                                                                                                   """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/fielduri"""
                                                                                                                               
                                                                                                                              -    ELEMENT_NAME = 'FieldURI'
                                                                                                                              +    ELEMENT_NAME = "FieldURI"
                                                                                                                               
                                                                                                                              -    field_uri = CharField(field_uri='FieldURI', is_attribute=True, is_required=True)
                                                                                                                              + field_uri = CharField(field_uri="FieldURI", is_attribute=True, is_required=True)

                                                                                                                              Ancestors

                                                                                                                                @@ -5177,7 +5281,7 @@

                                                                                                                                Inherited members

                                                                                                                                for f in fields: # Check for duplicate field names if f.name in self._dict: - raise ValueError(f'Field {f!r} is a duplicate') + raise ValueError(f"Field {f!r} is a duplicate") self._dict[f.name] = f def __getitem__(self, idx_or_slice): @@ -5209,11 +5313,11 @@

                                                                                                                                Inherited members

                                                                                                                                for i, f in enumerate(self): if f.name == field_name: return i - raise ValueError(f'Unknown field name {field_name!r}') + raise ValueError(f"Unknown field name {field_name!r}") def insert(self, index, field): if field.name in self._dict: - raise ValueError(f'Field {field!r} is a duplicate') + raise ValueError(f"Field {field!r} is a duplicate") super().insert(index, field) self._dict[field.name] = field @@ -5271,7 +5375,7 @@

                                                                                                                                Methods

                                                                                                                                for i, f in enumerate(self): if f.name == field_name: return i - raise ValueError(f'Unknown field name {field_name!r}') + raise ValueError(f"Unknown field name {field_name!r}")
                                                                                                                                @@ -5285,7 +5389,7 @@

                                                                                                                                Methods

                                                                                                                                def insert(self, index, field):
                                                                                                                                     if field.name in self._dict:
                                                                                                                                -        raise ValueError(f'Field {field!r} is a duplicate')
                                                                                                                                +        raise ValueError(f"Field {field!r} is a duplicate")
                                                                                                                                     super().insert(index, field)
                                                                                                                                     self._dict[field.name] = field
                                                                                                                                @@ -5320,7 +5424,7 @@

                                                                                                                                Methods

                                                                                                                                class FolderId(ItemId):
                                                                                                                                     """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/folderid"""
                                                                                                                                 
                                                                                                                                -    ELEMENT_NAME = 'FolderId'
                                                                                                                                + ELEMENT_NAME = "FolderId"

                                                                                                                                Ancestors

                                                                                                                                  @@ -5364,7 +5468,7 @@

                                                                                                                                  Inherited members

                                                                                                                                  class FreeBusyChangedEvent(TimestampEvent):
                                                                                                                                       """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/freebusychangedevent"""
                                                                                                                                   
                                                                                                                                  -    ELEMENT_NAME = 'FreeBusyChangedEvent'
                                                                                                                                  + ELEMENT_NAME = "FreeBusyChangedEvent"

                                                                                                                                  Ancestors

                                                                                                                                    @@ -5404,17 +5508,25 @@

                                                                                                                                    Inherited members

                                                                                                                                    class FreeBusyView(EWSElement):
                                                                                                                                         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/freebusyview"""
                                                                                                                                     
                                                                                                                                    -    ELEMENT_NAME = 'FreeBusyView'
                                                                                                                                    +    ELEMENT_NAME = "FreeBusyView"
                                                                                                                                         NAMESPACE = MNS
                                                                                                                                    -    view_type = ChoiceField(field_uri='FreeBusyViewType', choices={
                                                                                                                                    -        Choice('None'), Choice('MergedOnly'), Choice('FreeBusy'), Choice('FreeBusyMerged'), Choice('Detailed'),
                                                                                                                                    -        Choice('DetailedMerged'),
                                                                                                                                    -    }, is_required=True)
                                                                                                                                    +    view_type = ChoiceField(
                                                                                                                                    +        field_uri="FreeBusyViewType",
                                                                                                                                    +        choices={
                                                                                                                                    +            Choice("None"),
                                                                                                                                    +            Choice("MergedOnly"),
                                                                                                                                    +            Choice("FreeBusy"),
                                                                                                                                    +            Choice("FreeBusyMerged"),
                                                                                                                                    +            Choice("Detailed"),
                                                                                                                                    +            Choice("DetailedMerged"),
                                                                                                                                    +        },
                                                                                                                                    +        is_required=True,
                                                                                                                                    +    )
                                                                                                                                         # A string of digits. Each digit points to a position in .fields.FREE_BUSY_CHOICES
                                                                                                                                    -    merged = CharField(field_uri='MergedFreeBusy')
                                                                                                                                    -    calendar_events = EWSElementListField(field_uri='CalendarEventArray', value_cls=CalendarEvent)
                                                                                                                                    +    merged = CharField(field_uri="MergedFreeBusy")
                                                                                                                                    +    calendar_events = EWSElementListField(field_uri="CalendarEventArray", value_cls=CalendarEvent)
                                                                                                                                         # WorkingPeriod is located inside the WorkingPeriodArray element which is inside the WorkingHours element
                                                                                                                                    -    working_hours = EWSElementListField(field_uri='WorkingPeriodArray', value_cls=WorkingPeriod)
                                                                                                                                    +    working_hours = EWSElementListField(field_uri="WorkingPeriodArray", value_cls=WorkingPeriod)
                                                                                                                                         # TimeZone is also inside the WorkingHours element. It contains information about the timezone which the
                                                                                                                                         # account is located in.
                                                                                                                                         working_hours_timezone = EWSElementField(value_cls=TimeZone)
                                                                                                                                    @@ -5422,9 +5534,9 @@ 

                                                                                                                                    Inherited members

                                                                                                                                    @classmethod def from_xml(cls, elem, account): kwargs = {} - working_hours_elem = elem.find(f'{{{TNS}}}WorkingHours') + working_hours_elem = elem.find(f"{{{TNS}}}WorkingHours") for f in cls.FIELDS: - if f.name in ['working_hours', 'working_hours_timezone']: + if f.name in ["working_hours", "working_hours_timezone"]: if working_hours_elem is None: continue kwargs[f.name] = f.from_xml(elem=working_hours_elem, account=account) @@ -5466,9 +5578,9 @@

                                                                                                                                    Static methods

                                                                                                                                    @classmethod
                                                                                                                                     def from_xml(cls, elem, account):
                                                                                                                                         kwargs = {}
                                                                                                                                    -    working_hours_elem = elem.find(f'{{{TNS}}}WorkingHours')
                                                                                                                                    +    working_hours_elem = elem.find(f"{{{TNS}}}WorkingHours")
                                                                                                                                         for f in cls.FIELDS:
                                                                                                                                    -        if f.name in ['working_hours', 'working_hours_timezone']:
                                                                                                                                    +        if f.name in ["working_hours", "working_hours_timezone"]:
                                                                                                                                                 if working_hours_elem is None:
                                                                                                                                                     continue
                                                                                                                                                 kwargs[f.name] = f.from_xml(elem=working_hours_elem, account=account)
                                                                                                                                    @@ -5527,15 +5639,17 @@ 

                                                                                                                                    Inherited members

                                                                                                                                    class FreeBusyViewOptions(EWSElement):
                                                                                                                                         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/freebusyviewoptions"""
                                                                                                                                     
                                                                                                                                    -    ELEMENT_NAME = 'FreeBusyViewOptions'
                                                                                                                                    -    REQUESTED_VIEWS = {'MergedOnly', 'FreeBusy', 'FreeBusyMerged', 'Detailed', 'DetailedMerged'}
                                                                                                                                    +    ELEMENT_NAME = "FreeBusyViewOptions"
                                                                                                                                    +    REQUESTED_VIEWS = {"MergedOnly", "FreeBusy", "FreeBusyMerged", "Detailed", "DetailedMerged"}
                                                                                                                                     
                                                                                                                                         time_window = EWSElementField(value_cls=TimeWindow, is_required=True)
                                                                                                                                         # Interval value is in minutes
                                                                                                                                    -    merged_free_busy_interval = IntegerField(field_uri='MergedFreeBusyIntervalInMinutes', min=5, max=1440, default=30,
                                                                                                                                    -                                             is_required=True)
                                                                                                                                    -    requested_view = ChoiceField(field_uri='RequestedView', choices={Choice(c) for c in REQUESTED_VIEWS},
                                                                                                                                    -                                 is_required=True)  # Choice('None') is also valid, but only for responses
                                                                                                                                    + merged_free_busy_interval = IntegerField( + field_uri="MergedFreeBusyIntervalInMinutes", min=5, max=1440, default=30, is_required=True + ) + requested_view = ChoiceField( + field_uri="RequestedView", choices={Choice(c) for c in REQUESTED_VIEWS}, is_required=True + ) # Choice('None') is also valid, but only for responses

                                                                                                                                    Ancestors

                                                                                                                                      @@ -5600,7 +5714,7 @@

                                                                                                                                      Inherited members

                                                                                                                                      MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/body """ - body_type = 'HTML'
                                                                                                                                    + body_type = "HTML"

                                                                                                                                    Ancestors

                                                                                                                                      @@ -5642,14 +5756,14 @@

                                                                                                                                      Inherited members

                                                                                                                                      ID_ELEMENT_CLS = None def __init__(self, **kwargs): - _id, _changekey = kwargs.pop('id', None), kwargs.pop('changekey', None) + _id, _changekey = kwargs.pop("id", None), kwargs.pop("changekey", None) if _id or _changekey: - kwargs['_id'] = self.ID_ELEMENT_CLS(_id, _changekey) + kwargs["_id"] = self.ID_ELEMENT_CLS(_id, _changekey) super().__init__(**kwargs) @classmethod def get_field_by_fieldname(cls, fieldname): - if fieldname in ('id', 'changekey'): + if fieldname in ("id", "changekey"): return cls.ID_ELEMENT_CLS.get_field_by_fieldname(fieldname=fieldname) return super().get_field_by_fieldname(fieldname=fieldname) @@ -5687,7 +5801,7 @@

                                                                                                                                      Inherited members

                                                                                                                                      def to_id(self): if self._id is None: - raise ValueError('Must have an ID') + raise ValueError("Must have an ID") return self._id def __eq__(self, other): @@ -5733,7 +5847,7 @@

                                                                                                                                      Static methods

                                                                                                                                      @classmethod
                                                                                                                                       def get_field_by_fieldname(cls, fieldname):
                                                                                                                                      -    if fieldname in ('id', 'changekey'):
                                                                                                                                      +    if fieldname in ("id", "changekey"):
                                                                                                                                               return cls.ID_ELEMENT_CLS.get_field_by_fieldname(fieldname=fieldname)
                                                                                                                                           return super().get_field_by_fieldname(fieldname=fieldname)
                                                                                                                                      @@ -5801,7 +5915,7 @@

                                                                                                                                      Methods

                                                                                                                                      def to_id(self):
                                                                                                                                           if self._id is None:
                                                                                                                                      -        raise ValueError('Must have an ID')
                                                                                                                                      +        raise ValueError("Must have an ID")
                                                                                                                                           return self._id
                                                                                                                                      @@ -5831,10 +5945,10 @@

                                                                                                                                      Inherited members

                                                                                                                                      class IndexedFieldURI(EWSElement):
                                                                                                                                           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/indexedfielduri"""
                                                                                                                                       
                                                                                                                                      -    ELEMENT_NAME = 'IndexedFieldURI'
                                                                                                                                      +    ELEMENT_NAME = "IndexedFieldURI"
                                                                                                                                       
                                                                                                                                      -    field_uri = CharField(field_uri='FieldURI', is_attribute=True, is_required=True)
                                                                                                                                      -    field_index = CharField(field_uri='FieldIndex', is_attribute=True, is_required=True)
                                                                                                                                      + field_uri = CharField(field_uri="FieldURI", is_attribute=True, is_required=True) + field_index = CharField(field_uri="FieldIndex", is_attribute=True, is_required=True)

                                                                                                                                      Ancestors

                                                                                                                                        @@ -5891,9 +6005,9 @@

                                                                                                                                        Inherited members

                                                                                                                                        MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/itemid """ - ELEMENT_NAME = 'ItemId' - ID_ATTR = 'Id' - CHANGEKEY_ATTR = 'ChangeKey' + ELEMENT_NAME = "ItemId" + ID_ATTR = "Id" + CHANGEKEY_ATTR = "ChangeKey" id = IdField(field_uri=ID_ATTR, is_required=True) changekey = IdField(field_uri=CHANGEKEY_ATTR, is_required=False) @@ -5970,20 +6084,20 @@

                                                                                                                                        Inherited members

                                                                                                                                        class MailTips(EWSElement):
                                                                                                                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailtips"""
                                                                                                                                         
                                                                                                                                        -    ELEMENT_NAME = 'MailTips'
                                                                                                                                        +    ELEMENT_NAME = "MailTips"
                                                                                                                                             NAMESPACE = MNS
                                                                                                                                         
                                                                                                                                             recipient_address = RecipientAddressField()
                                                                                                                                        -    pending_mail_tips = ChoiceField(field_uri='PendingMailTips', choices={Choice(c) for c in MAIL_TIPS_TYPES})
                                                                                                                                        +    pending_mail_tips = ChoiceField(field_uri="PendingMailTips", choices={Choice(c) for c in MAIL_TIPS_TYPES})
                                                                                                                                             out_of_office = EWSElementField(value_cls=OutOfOffice)
                                                                                                                                        -    mailbox_full = BooleanField(field_uri='MailboxFull')
                                                                                                                                        -    custom_mail_tip = TextField(field_uri='CustomMailTip')
                                                                                                                                        -    total_member_count = IntegerField(field_uri='TotalMemberCount')
                                                                                                                                        -    external_member_count = IntegerField(field_uri='ExternalMemberCount')
                                                                                                                                        -    max_message_size = IntegerField(field_uri='MaxMessageSize')
                                                                                                                                        -    delivery_restricted = BooleanField(field_uri='DeliveryRestricted')
                                                                                                                                        -    is_moderated = BooleanField(field_uri='IsModerated')
                                                                                                                                        -    invalid_recipient = BooleanField(field_uri='InvalidRecipient')
                                                                                                                                        + mailbox_full = BooleanField(field_uri="MailboxFull") + custom_mail_tip = TextField(field_uri="CustomMailTip") + total_member_count = IntegerField(field_uri="TotalMemberCount") + external_member_count = IntegerField(field_uri="ExternalMemberCount") + max_message_size = IntegerField(field_uri="MaxMessageSize") + delivery_restricted = BooleanField(field_uri="DeliveryRestricted") + is_moderated = BooleanField(field_uri="IsModerated") + invalid_recipient = BooleanField(field_uri="InvalidRecipient")

                                                                                                                                        Ancestors

                                                                                                                                          @@ -6076,20 +6190,26 @@

                                                                                                                                          Inherited members

                                                                                                                                          class Mailbox(EWSElement):
                                                                                                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailbox"""
                                                                                                                                           
                                                                                                                                          -    ELEMENT_NAME = 'Mailbox'
                                                                                                                                          -    MAILBOX = 'Mailbox'
                                                                                                                                          -    ONE_OFF = 'OneOff'
                                                                                                                                          +    ELEMENT_NAME = "Mailbox"
                                                                                                                                          +    MAILBOX = "Mailbox"
                                                                                                                                          +    ONE_OFF = "OneOff"
                                                                                                                                               MAILBOX_TYPE_CHOICES = {
                                                                                                                                          -            Choice(MAILBOX), Choice('PublicDL'), Choice('PrivateDL'), Choice('Contact'), Choice('PublicFolder'),
                                                                                                                                          -            Choice('Unknown'), Choice(ONE_OFF), Choice('GroupMailbox', supported_from=EXCHANGE_2013)
                                                                                                                                          -        }
                                                                                                                                          -
                                                                                                                                          -    name = TextField(field_uri='Name')
                                                                                                                                          -    email_address = EmailAddressField(field_uri='EmailAddress')
                                                                                                                                          +        Choice(MAILBOX),
                                                                                                                                          +        Choice("PublicDL"),
                                                                                                                                          +        Choice("PrivateDL"),
                                                                                                                                          +        Choice("Contact"),
                                                                                                                                          +        Choice("PublicFolder"),
                                                                                                                                          +        Choice("Unknown"),
                                                                                                                                          +        Choice(ONE_OFF),
                                                                                                                                          +        Choice("GroupMailbox", supported_from=EXCHANGE_2013),
                                                                                                                                          +    }
                                                                                                                                          +
                                                                                                                                          +    name = TextField(field_uri="Name")
                                                                                                                                          +    email_address = EmailAddressField(field_uri="EmailAddress")
                                                                                                                                               # RoutingType values are not restricted:
                                                                                                                                               # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/routingtype-emailaddresstype
                                                                                                                                          -    routing_type = TextField(field_uri='RoutingType', default='SMTP')
                                                                                                                                          -    mailbox_type = ChoiceField(field_uri='MailboxType', choices=MAILBOX_TYPE_CHOICES, default=MAILBOX)
                                                                                                                                          +    routing_type = TextField(field_uri="RoutingType", default="SMTP")
                                                                                                                                          +    mailbox_type = ChoiceField(field_uri="MailboxType", choices=MAILBOX_TYPE_CHOICES, default=MAILBOX)
                                                                                                                                               item_id = EWSElementField(value_cls=ItemId, is_read_only=True)
                                                                                                                                           
                                                                                                                                               def clean(self, version=None):
                                                                                                                                          @@ -6218,12 +6338,12 @@ 

                                                                                                                                          Inherited members

                                                                                                                                          class MailboxData(EWSElement):
                                                                                                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailboxdata"""
                                                                                                                                           
                                                                                                                                          -    ELEMENT_NAME = 'MailboxData'
                                                                                                                                          -    ATTENDEE_TYPES = {'Optional', 'Organizer', 'Required', 'Resource', 'Room'}
                                                                                                                                          +    ELEMENT_NAME = "MailboxData"
                                                                                                                                          +    ATTENDEE_TYPES = {"Optional", "Organizer", "Required", "Resource", "Room"}
                                                                                                                                           
                                                                                                                                               email = EmailField()
                                                                                                                                          -    attendee_type = ChoiceField(field_uri='AttendeeType', choices={Choice(c) for c in ATTENDEE_TYPES})
                                                                                                                                          -    exclude_conflicts = BooleanField(field_uri='ExcludeConflicts')
                                                                                                                                          + attendee_type = ChoiceField(field_uri="AttendeeType", choices={Choice(c) for c in ATTENDEE_TYPES}) + exclude_conflicts = BooleanField(field_uri="ExcludeConflicts")

                                                                                                                                          Ancestors

                                                                                                                                            @@ -6287,12 +6407,12 @@

                                                                                                                                            Inherited members

                                                                                                                                            https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/member-ex15websvcsotherref """ - ELEMENT_NAME = 'Member' + ELEMENT_NAME = "Member" mailbox = MailboxField(is_required=True) - status = ChoiceField(field_uri='Status', choices={ - Choice('Unrecognized'), Choice('Normal'), Choice('Demoted') - }, default='Normal') + status = ChoiceField( + field_uri="Status", choices={Choice("Unrecognized"), Choice("Normal"), Choice("Demoted")}, default="Normal" + ) def __hash__(self): return hash(self.mailbox) @@ -6348,9 +6468,9 @@

                                                                                                                                            Inherited members

                                                                                                                                            class MessageHeader(EWSElement):
                                                                                                                                                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/internetmessageheader"""
                                                                                                                                             
                                                                                                                                            -    ELEMENT_NAME = 'InternetMessageHeader'
                                                                                                                                            +    ELEMENT_NAME = "InternetMessageHeader"
                                                                                                                                             
                                                                                                                                            -    name = TextField(field_uri='HeaderName', is_attribute=True)
                                                                                                                                            +    name = TextField(field_uri="HeaderName", is_attribute=True)
                                                                                                                                                 value = SubField()

                                                                                                                                            Ancestors

                                                                                                                                            @@ -6404,9 +6524,9 @@

                                                                                                                                            Inherited members

                                                                                                                                            class ModifiedEvent(TimestampEvent):
                                                                                                                                                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/modifiedevent"""
                                                                                                                                             
                                                                                                                                            -    ELEMENT_NAME = 'ModifiedEvent'
                                                                                                                                            +    ELEMENT_NAME = "ModifiedEvent"
                                                                                                                                             
                                                                                                                                            -    unread_count = IntegerField(field_uri='UnreadCount')
                                                                                                                                            + unread_count = IntegerField(field_uri="UnreadCount")

                                                                                                                                            Ancestors

                                                                                                                                              @@ -6457,7 +6577,7 @@

                                                                                                                                              Inherited members

                                                                                                                                              class MovedEvent(OldTimestampEvent):
                                                                                                                                                   """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/movedevent"""
                                                                                                                                               
                                                                                                                                              -    ELEMENT_NAME = 'MovedEvent'
                                                                                                                                              + ELEMENT_NAME = "MovedEvent"

                                                                                                                                              Ancestors

                                                                                                                                                @@ -6498,7 +6618,7 @@

                                                                                                                                                Inherited members

                                                                                                                                                class MovedItemId(ItemId):
                                                                                                                                                     """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/moveditemid"""
                                                                                                                                                 
                                                                                                                                                -    ELEMENT_NAME = 'MovedItemId'
                                                                                                                                                +    ELEMENT_NAME = "MovedItemId"
                                                                                                                                                     NAMESPACE = MNS
                                                                                                                                                 
                                                                                                                                                     @classmethod
                                                                                                                                                @@ -6566,7 +6686,7 @@ 

                                                                                                                                                Inherited members

                                                                                                                                                class NewMailEvent(TimestampEvent):
                                                                                                                                                     """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/newmailevent"""
                                                                                                                                                 
                                                                                                                                                -    ELEMENT_NAME = 'NewMailEvent'
                                                                                                                                                + ELEMENT_NAME = "NewMailEvent"

                                                                                                                                                Ancestors

                                                                                                                                                  @@ -6609,13 +6729,13 @@

                                                                                                                                                  Inherited members

                                                                                                                                                  https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/notification-ex15websvcsotherref """ - ELEMENT_NAME = 'Notification' + ELEMENT_NAME = "Notification" NAMESPACE = MNS - subscription_id = CharField(field_uri='SubscriptionId') - previous_watermark = CharField(field_uri='PreviousWatermark') - more_events = BooleanField(field_uri='MoreEvents') - events = GenericEventListField('') + subscription_id = CharField(field_uri="SubscriptionId") + previous_watermark = CharField(field_uri="PreviousWatermark") + more_events = BooleanField(field_uri="MoreEvents") + events = GenericEventListField("")

                                                                                                                                                  Ancestors

                                                                                                                                                    @@ -6680,13 +6800,13 @@

                                                                                                                                                    Inherited members

                                                                                                                                                    class OccurrenceItemId(BaseItemId):
                                                                                                                                                         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/occurrenceitemid"""
                                                                                                                                                     
                                                                                                                                                    -    ELEMENT_NAME = 'OccurrenceItemId'
                                                                                                                                                    -    ID_ATTR = 'RecurringMasterId'
                                                                                                                                                    -    CHANGEKEY_ATTR = 'ChangeKey'
                                                                                                                                                    +    ELEMENT_NAME = "OccurrenceItemId"
                                                                                                                                                    +    ID_ATTR = "RecurringMasterId"
                                                                                                                                                    +    CHANGEKEY_ATTR = "ChangeKey"
                                                                                                                                                     
                                                                                                                                                         id = IdField(field_uri=ID_ATTR, is_required=True)
                                                                                                                                                         changekey = IdField(field_uri=CHANGEKEY_ATTR, is_required=False)
                                                                                                                                                    -    instance_index = IntegerField(field_uri='InstanceIndex', is_attribute=True, is_required=True, min=1)
                                                                                                                                                    + instance_index = IntegerField(field_uri="InstanceIndex", is_attribute=True, is_required=True, min=1)

                                                                                                                                                    Ancestors

                                                                                                                                                      @@ -6752,9 +6872,9 @@

                                                                                                                                                      Inherited members

                                                                                                                                                      class OldTimestampEvent(TimestampEvent, metaclass=EWSMeta):
                                                                                                                                                           """Base class for both item and folder copy/move events."""
                                                                                                                                                       
                                                                                                                                                      -    old_item_id = EWSElementField(field_uri='OldItemId', value_cls=ItemId)
                                                                                                                                                      -    old_folder_id = EWSElementField(field_uri='OldFolderId', value_cls=FolderId)
                                                                                                                                                      -    old_parent_folder_id = EWSElementField(field_uri='OldParentFolderId', value_cls=ParentFolderId)
                                                                                                                                                      + old_item_id = EWSElementField(field_uri="OldItemId", value_cls=ItemId) + old_folder_id = EWSElementField(field_uri="OldFolderId", value_cls=FolderId) + old_parent_folder_id = EWSElementField(field_uri="OldParentFolderId", value_cls=ParentFolderId)

                                                                                                                                                      Ancestors

                                                                                                                                                        @@ -6814,18 +6934,18 @@

                                                                                                                                                        Inherited members

                                                                                                                                                        class OutOfOffice(EWSElement):
                                                                                                                                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/outofoffice"""
                                                                                                                                                         
                                                                                                                                                        -    ELEMENT_NAME = 'OutOfOffice'
                                                                                                                                                        +    ELEMENT_NAME = "OutOfOffice"
                                                                                                                                                         
                                                                                                                                                        -    reply_body = MessageField(field_uri='ReplyBody')
                                                                                                                                                        -    start = DateTimeField(field_uri='StartTime', is_required=False)
                                                                                                                                                        -    end = DateTimeField(field_uri='EndTime', is_required=False)
                                                                                                                                                        +    reply_body = MessageField(field_uri="ReplyBody")
                                                                                                                                                        +    start = DateTimeField(field_uri="StartTime", is_required=False)
                                                                                                                                                        +    end = DateTimeField(field_uri="EndTime", is_required=False)
                                                                                                                                                         
                                                                                                                                                             @classmethod
                                                                                                                                                             def duration_to_start_end(cls, elem, account):
                                                                                                                                                                 kwargs = {}
                                                                                                                                                        -        duration = elem.find(f'{{{TNS}}}Duration')
                                                                                                                                                        +        duration = elem.find(f"{{{TNS}}}Duration")
                                                                                                                                                                 if duration is not None:
                                                                                                                                                        -            for attr in ('start', 'end'):
                                                                                                                                                        +            for attr in ("start", "end"):
                                                                                                                                                                         f = cls.get_field_by_fieldname(attr)
                                                                                                                                                                         kwargs[attr] = f.from_xml(elem=duration, account=account)
                                                                                                                                                                 return kwargs
                                                                                                                                                        @@ -6833,7 +6953,7 @@ 

                                                                                                                                                        Inherited members

                                                                                                                                                        @classmethod def from_xml(cls, elem, account): kwargs = {} - for attr in ('reply_body',): + for attr in ("reply_body",): f = cls.get_field_by_fieldname(attr) kwargs[attr] = f.from_xml(elem=elem, account=account) kwargs.update(cls.duration_to_start_end(elem=elem, account=account)) @@ -6869,9 +6989,9 @@

                                                                                                                                                        Static methods

                                                                                                                                                        @classmethod
                                                                                                                                                         def duration_to_start_end(cls, elem, account):
                                                                                                                                                             kwargs = {}
                                                                                                                                                        -    duration = elem.find(f'{{{TNS}}}Duration')
                                                                                                                                                        +    duration = elem.find(f"{{{TNS}}}Duration")
                                                                                                                                                             if duration is not None:
                                                                                                                                                        -        for attr in ('start', 'end'):
                                                                                                                                                        +        for attr in ("start", "end"):
                                                                                                                                                                     f = cls.get_field_by_fieldname(attr)
                                                                                                                                                                     kwargs[attr] = f.from_xml(elem=duration, account=account)
                                                                                                                                                             return kwargs
                                                                                                                                                        @@ -6889,7 +7009,7 @@

                                                                                                                                                        Static methods

                                                                                                                                                        @classmethod
                                                                                                                                                         def from_xml(cls, elem, account):
                                                                                                                                                             kwargs = {}
                                                                                                                                                        -    for attr in ('reply_body',):
                                                                                                                                                        +    for attr in ("reply_body",):
                                                                                                                                                                 f = cls.get_field_by_fieldname(attr)
                                                                                                                                                                 kwargs[attr] = f.from_xml(elem=elem, account=account)
                                                                                                                                                             kwargs.update(cls.duration_to_start_end(elem=elem, account=account))
                                                                                                                                                        @@ -6938,7 +7058,7 @@ 

                                                                                                                                                        Inherited members

                                                                                                                                                        class ParentFolderId(ItemId):
                                                                                                                                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/parentfolderid"""
                                                                                                                                                         
                                                                                                                                                        -    ELEMENT_NAME = 'ParentFolderId'
                                                                                                                                                        + ELEMENT_NAME = "ParentFolderId"

                                                                                                                                                        Ancestors

                                                                                                                                                          @@ -6978,7 +7098,7 @@

                                                                                                                                                          Inherited members

                                                                                                                                                          class ParentItemId(ItemId):
                                                                                                                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/parentitemid"""
                                                                                                                                                           
                                                                                                                                                          -    ELEMENT_NAME = 'ParentItemId'
                                                                                                                                                          +    ELEMENT_NAME = "ParentItemId"
                                                                                                                                                               NAMESPACE = MNS

                                                                                                                                                          Ancestors

                                                                                                                                                          @@ -7023,14 +7143,14 @@

                                                                                                                                                          Inherited members

                                                                                                                                                          class Period(EWSElement):
                                                                                                                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/period"""
                                                                                                                                                           
                                                                                                                                                          -    ELEMENT_NAME = 'Period'
                                                                                                                                                          +    ELEMENT_NAME = "Period"
                                                                                                                                                           
                                                                                                                                                          -    id = CharField(field_uri='Id', is_attribute=True)
                                                                                                                                                          -    name = CharField(field_uri='Name', is_attribute=True)
                                                                                                                                                          -    bias = TimeDeltaField(field_uri='Bias', is_attribute=True)
                                                                                                                                                          +    id = CharField(field_uri="Id", is_attribute=True)
                                                                                                                                                          +    name = CharField(field_uri="Name", is_attribute=True)
                                                                                                                                                          +    bias = TimeDeltaField(field_uri="Bias", is_attribute=True)
                                                                                                                                                           
                                                                                                                                                               def _split_id(self):
                                                                                                                                                          -        to_year, to_type = self.id.rsplit('/', 1)[1].split('-')
                                                                                                                                                          +        to_year, to_type = self.id.rsplit("/", 1)[1].split("-")
                                                                                                                                                                   return int(to_year), to_type
                                                                                                                                                           
                                                                                                                                                               @property
                                                                                                                                                          @@ -7136,14 +7256,22 @@ 

                                                                                                                                                          Inherited members

                                                                                                                                                          class Permission(BasePermission):
                                                                                                                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/permission"""
                                                                                                                                                           
                                                                                                                                                          -    ELEMENT_NAME = 'Permission'
                                                                                                                                                          +    ELEMENT_NAME = "Permission"
                                                                                                                                                               LEVEL_CHOICES = (
                                                                                                                                                          -        'None', 'Owner', 'PublishingEditor', 'Editor', 'PublishingAuthor', 'Author', 'NoneditingAuthor', 'Reviewer',
                                                                                                                                                          -        'Contributor', 'Custom',
                                                                                                                                                          +        "None",
                                                                                                                                                          +        "Owner",
                                                                                                                                                          +        "PublishingEditor",
                                                                                                                                                          +        "Editor",
                                                                                                                                                          +        "PublishingAuthor",
                                                                                                                                                          +        "Author",
                                                                                                                                                          +        "NoneditingAuthor",
                                                                                                                                                          +        "Reviewer",
                                                                                                                                                          +        "Contributor",
                                                                                                                                                          +        "Custom",
                                                                                                                                                               )
                                                                                                                                                           
                                                                                                                                                               permission_level = ChoiceField(
                                                                                                                                                          -        field_uri='CalendarPermissionLevel', choices={Choice(c) for c in LEVEL_CHOICES}, default=LEVEL_CHOICES[0]
                                                                                                                                                          +        field_uri="CalendarPermissionLevel", choices={Choice(c) for c in LEVEL_CHOICES}, default=LEVEL_CHOICES[0]
                                                                                                                                                               )

                                                                                                                                                          Ancestors

                                                                                                                                                          @@ -7206,11 +7334,11 @@

                                                                                                                                                          Inherited members

                                                                                                                                                          """ # For simplicity, we implement the two distinct but equally names elements as one class. - ELEMENT_NAME = 'PermissionSet' + ELEMENT_NAME = "PermissionSet" - permissions = EWSElementListField(field_uri='Permissions', value_cls=Permission) - calendar_permissions = EWSElementListField(field_uri='CalendarPermissions', value_cls=CalendarPermission) - unknown_entries = UnknownEntriesField(field_uri='UnknownEntries')
                                                                                                                                                          + permissions = EWSElementListField(field_uri="Permissions", value_cls=Permission) + calendar_permissions = EWSElementListField(field_uri="CalendarPermissions", value_cls=CalendarPermission) + unknown_entries = UnknownEntriesField(field_uri="UnknownEntries")

                                                                                                                                                        Ancestors

                                                                                                                                                          @@ -7267,13 +7395,13 @@

                                                                                                                                                          Inherited members

                                                                                                                                                          class PersonaId(ItemId):
                                                                                                                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/personaid"""
                                                                                                                                                           
                                                                                                                                                          -    ELEMENT_NAME = 'PersonaId'
                                                                                                                                                          +    ELEMENT_NAME = "PersonaId"
                                                                                                                                                               NAMESPACE = MNS
                                                                                                                                                           
                                                                                                                                                               @classmethod
                                                                                                                                                               def response_tag(cls):
                                                                                                                                                                   # This element is in MNS in the request and TNS in the response...
                                                                                                                                                          -        return f'{{{TNS}}}{cls.ELEMENT_NAME}'
                                                                                                                                                          + return f"{{{TNS}}}{cls.ELEMENT_NAME}"

                                                                                                                                                          Ancestors

                                                                                                                                                            @@ -7306,7 +7434,7 @@

                                                                                                                                                            Static methods

                                                                                                                                                            @classmethod
                                                                                                                                                             def response_tag(cls):
                                                                                                                                                                 # This element is in MNS in the request and TNS in the response...
                                                                                                                                                            -    return f'{{{TNS}}}{cls.ELEMENT_NAME}'
                                                                                                                                                            + return f"{{{TNS}}}{cls.ELEMENT_NAME}" @@ -7338,10 +7466,10 @@

                                                                                                                                                            Inherited members

                                                                                                                                                            https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/value-personaphonenumbertype """ - ELEMENT_NAME = 'Value' + ELEMENT_NAME = "Value" - number = CharField(field_uri='Number') - type = CharField(field_uri='Type') + number = CharField(field_uri="Number") + type = CharField(field_uri="Type")

                                                                                                                                                            Ancestors

                                                                                                                                                              @@ -7397,23 +7525,23 @@

                                                                                                                                                              Inherited members

                                                                                                                                                              https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/value-personapostaladdresstype """ - ELEMENT_NAME = 'Value' - - street = TextField(field_uri='Street') - city = TextField(field_uri='City') - state = TextField(field_uri='State') - country = TextField(field_uri='Country') - postal_code = TextField(field_uri='PostalCode') - post_office_box = TextField(field_uri='PostOfficeBox') - type = TextField(field_uri='Type') - latitude = TextField(field_uri='Latitude') - longitude = TextField(field_uri='Longitude') - accuracy = TextField(field_uri='Accuracy') - altitude = TextField(field_uri='Altitude') - altitude_accuracy = TextField(field_uri='AltitudeAccuracy') - formatted_address = TextField(field_uri='FormattedAddress') - location_uri = TextField(field_uri='LocationUri') - location_source = TextField(field_uri='LocationSource') + ELEMENT_NAME = "Value" + + street = TextField(field_uri="Street") + city = TextField(field_uri="City") + state = TextField(field_uri="State") + country = TextField(field_uri="Country") + postal_code = TextField(field_uri="PostalCode") + post_office_box = TextField(field_uri="PostOfficeBox") + type = TextField(field_uri="Type") + latitude = TextField(field_uri="Latitude") + longitude = TextField(field_uri="Longitude") + accuracy = TextField(field_uri="Accuracy") + altitude = TextField(field_uri="Altitude") + altitude_accuracy = TextField(field_uri="AltitudeAccuracy") + formatted_address = TextField(field_uri="FormattedAddress") + location_uri = TextField(field_uri="LocationUri") + location_source = TextField(field_uri="LocationSource")

                                                                                                                                                              Ancestors

                                                                                                                                                                @@ -7519,10 +7647,10 @@

                                                                                                                                                                Inherited members

                                                                                                                                                                class PhoneNumber(EWSElement):
                                                                                                                                                                     """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/phonenumber"""
                                                                                                                                                                 
                                                                                                                                                                -    ELEMENT_NAME = 'PhoneNumber'
                                                                                                                                                                +    ELEMENT_NAME = "PhoneNumber"
                                                                                                                                                                 
                                                                                                                                                                -    number = CharField(field_uri='Number')
                                                                                                                                                                -    type = CharField(field_uri='Type')
                                                                                                                                                                + number = CharField(field_uri="Number") + type = CharField(field_uri="Type")

                                                                                                                                                                Ancestors

                                                                                                                                                                  @@ -7578,10 +7706,10 @@

                                                                                                                                                                  Inherited members

                                                                                                                                                                  https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/phonenumberattributedvalue """ - ELEMENT_NAME = 'PhoneNumberAttributedValue' + ELEMENT_NAME = "PhoneNumberAttributedValue" value = EWSElementField(value_cls=PersonaPhoneNumberTypeValue) - attributions = CharListField(field_uri='Attributions', list_elem_name='Attribution') + attributions = CharListField(field_uri="Attributions", list_elem_name="Attribution")

                                                                                                                                                                  Ancestors

                                                                                                                                                                    @@ -7637,10 +7765,10 @@

                                                                                                                                                                    Inherited members

                                                                                                                                                                    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/postaladdressattributedvalue """ - ELEMENT_NAME = 'PostalAddressAttributedValue' + ELEMENT_NAME = "PostalAddressAttributedValue" value = EWSElementField(value_cls=PersonaPostalAddressTypeValue) - attributions = EWSElementListField(field_uri='Attributions', value_cls=Attribution) + attributions = EWSElementListField(field_uri="Attributions", value_cls=Attribution)

                                                                                                                                                                    Ancestors

                                                                                                                                                                      @@ -7697,7 +7825,7 @@

                                                                                                                                                                      Inherited members

                                                                                                                                                                      MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recipientaddress """ - ELEMENT_NAME = 'RecipientAddress' + ELEMENT_NAME = "RecipientAddress"

                                                                                                                                                                      Ancestors

                                                                                                                                                                        @@ -7736,11 +7864,11 @@

                                                                                                                                                                        Inherited members

                                                                                                                                                                        class RecurringDateTransition(BaseTransition):
                                                                                                                                                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recurringdatetransition"""
                                                                                                                                                                         
                                                                                                                                                                        -    ELEMENT_NAME = 'RecurringDateTransition'
                                                                                                                                                                        +    ELEMENT_NAME = "RecurringDateTransition"
                                                                                                                                                                         
                                                                                                                                                                        -    offset = TimeDeltaField(field_uri='TimeOffset')
                                                                                                                                                                        -    month = IntegerField(field_uri='Month')
                                                                                                                                                                        -    day = IntegerField(field_uri='Day')  # Day of month
                                                                                                                                                                        + offset = TimeDeltaField(field_uri="TimeOffset") + month = IntegerField(field_uri="Month") + day = IntegerField(field_uri="Day") # Day of month

                                                                                                                                                                        Ancestors

                                                                                                                                                                          @@ -7798,13 +7926,13 @@

                                                                                                                                                                          Inherited members

                                                                                                                                                                          class RecurringDayTransition(BaseTransition):
                                                                                                                                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recurringdaytransition"""
                                                                                                                                                                           
                                                                                                                                                                          -    ELEMENT_NAME = 'RecurringDayTransition'
                                                                                                                                                                          +    ELEMENT_NAME = "RecurringDayTransition"
                                                                                                                                                                           
                                                                                                                                                                          -    offset = TimeDeltaField(field_uri='TimeOffset')
                                                                                                                                                                          -    month = IntegerField(field_uri='Month')
                                                                                                                                                                          +    offset = TimeDeltaField(field_uri="TimeOffset")
                                                                                                                                                                          +    month = IntegerField(field_uri="Month")
                                                                                                                                                                               # Valid ISO 8601 weekday, as a number in range 1 -> 7 (1 being Monday)
                                                                                                                                                                          -    day_of_week = EnumField(field_uri='DayOfWeek', enum=WEEKDAY_NAMES)
                                                                                                                                                                          -    occurrence = IntegerField(field_uri='Occurrence')
                                                                                                                                                                          +    day_of_week = EnumField(field_uri="DayOfWeek", enum=WEEKDAY_NAMES)
                                                                                                                                                                          +    occurrence = IntegerField(field_uri="Occurrence")
                                                                                                                                                                           
                                                                                                                                                                               @classmethod
                                                                                                                                                                               def from_xml(cls, elem, account):
                                                                                                                                                                          @@ -7895,9 +8023,9 @@ 

                                                                                                                                                                          Inherited members

                                                                                                                                                                          class RecurringMasterItemId(BaseItemId):
                                                                                                                                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recurringmasteritemid"""
                                                                                                                                                                           
                                                                                                                                                                          -    ELEMENT_NAME = 'RecurringMasterItemId'
                                                                                                                                                                          -    ID_ATTR = 'OccurrenceId'
                                                                                                                                                                          -    CHANGEKEY_ATTR = 'ChangeKey'
                                                                                                                                                                          +    ELEMENT_NAME = "RecurringMasterItemId"
                                                                                                                                                                          +    ID_ATTR = "OccurrenceId"
                                                                                                                                                                          +    CHANGEKEY_ATTR = "ChangeKey"
                                                                                                                                                                           
                                                                                                                                                                               id = IdField(field_uri=ID_ATTR, is_required=True)
                                                                                                                                                                               changekey = IdField(field_uri=CHANGEKEY_ATTR, is_required=False)
                                                                                                                                                                          @@ -7962,7 +8090,7 @@

                                                                                                                                                                          Inherited members

                                                                                                                                                                          class ReferenceItemId(ItemId):
                                                                                                                                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/referenceitemid"""
                                                                                                                                                                           
                                                                                                                                                                          -    ELEMENT_NAME = 'ReferenceItemId'
                                                                                                                                                                          + ELEMENT_NAME = "ReferenceItemId"

                                                                                                                                                                          Ancestors

                                                                                                                                                                            @@ -8002,14 +8130,15 @@

                                                                                                                                                                            Inherited members

                                                                                                                                                                            class ReminderMessageData(EWSElement):
                                                                                                                                                                                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/remindermessagedata"""
                                                                                                                                                                             
                                                                                                                                                                            -    ELEMENT_NAME = 'ReminderMessageData'
                                                                                                                                                                            +    ELEMENT_NAME = "ReminderMessageData"
                                                                                                                                                                             
                                                                                                                                                                            -    reminder_text = CharField(field_uri='ReminderText')
                                                                                                                                                                            -    location = CharField(field_uri='Location')
                                                                                                                                                                            -    start_time = TimeField(field_uri='StartTime')
                                                                                                                                                                            -    end_time = TimeField(field_uri='EndTime')
                                                                                                                                                                            -    associated_calendar_item_id = AssociatedCalendarItemIdField(field_uri='AssociatedCalendarItemId',
                                                                                                                                                                            -                                                                supported_from=Build(15, 0, 913, 9))
                                                                                                                                                                            + reminder_text = CharField(field_uri="ReminderText") + location = CharField(field_uri="Location") + start_time = TimeField(field_uri="StartTime") + end_time = TimeField(field_uri="EndTime") + associated_calendar_item_id = AssociatedCalendarItemIdField( + field_uri="AssociatedCalendarItemId", supported_from=Build(15, 0, 913, 9) + )

                                                                                                                                                                            Ancestors

                                                                                                                                                                              @@ -8074,9 +8203,9 @@

                                                                                                                                                                              Inherited members

                                                                                                                                                                              class RemoveItem(EWSElement):
                                                                                                                                                                                   """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/removeitem"""
                                                                                                                                                                               
                                                                                                                                                                              -    ELEMENT_NAME = 'RemoveItem'
                                                                                                                                                                              +    ELEMENT_NAME = "RemoveItem"
                                                                                                                                                                               
                                                                                                                                                                              -    reference_item_id = ReferenceItemIdField(field_uri='item:ReferenceItemId')
                                                                                                                                                                              + reference_item_id = ReferenceItemIdField(field_uri="item:ReferenceItemId")

                                                                                                                                                                              Ancestors

                                                                                                                                                                                @@ -8125,24 +8254,26 @@

                                                                                                                                                                                Inherited members

                                                                                                                                                                                class ResponseObjects(EWSElement):
                                                                                                                                                                                     """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/responseobjects"""
                                                                                                                                                                                 
                                                                                                                                                                                -    ELEMENT_NAME = 'ResponseObjects'
                                                                                                                                                                                +    ELEMENT_NAME = "ResponseObjects"
                                                                                                                                                                                     NAMESPACE = EWSElement.NAMESPACE
                                                                                                                                                                                 
                                                                                                                                                                                -    accept_item = EWSElementField(field_uri='AcceptItem', value_cls='AcceptItem', namespace=NAMESPACE)
                                                                                                                                                                                -    tentatively_accept_item = EWSElementField(field_uri='TentativelyAcceptItem', value_cls='TentativelyAcceptItem',
                                                                                                                                                                                -                                              namespace=NAMESPACE)
                                                                                                                                                                                -    decline_item = EWSElementField(field_uri='DeclineItem', value_cls='DeclineItem', namespace=NAMESPACE)
                                                                                                                                                                                -    reply_to_item = EWSElementField(field_uri='ReplyToItem', value_cls='ReplyToItem', namespace=NAMESPACE)
                                                                                                                                                                                -    forward_item = EWSElementField(field_uri='ForwardItem', value_cls='ForwardItem', namespace=NAMESPACE)
                                                                                                                                                                                -    reply_all_to_item = EWSElementField(field_uri='ReplyAllToItem', value_cls='ReplyAllToItem', namespace=NAMESPACE)
                                                                                                                                                                                -    cancel_calendar_item = EWSElementField(field_uri='CancelCalendarItem', value_cls='CancelCalendarItem',
                                                                                                                                                                                -                                           namespace=NAMESPACE)
                                                                                                                                                                                -    remove_item = EWSElementField(field_uri='RemoveItem', value_cls=RemoveItem)
                                                                                                                                                                                -    post_reply_item = EWSElementField(field_uri='PostReplyItem', value_cls='PostReplyItem',
                                                                                                                                                                                -                                      namespace=EWSElement.NAMESPACE)
                                                                                                                                                                                -    success_read_receipt = EWSElementField(field_uri='SuppressReadReceipt', value_cls=SuppressReadReceipt)
                                                                                                                                                                                -    accept_sharing_invitation = EWSElementField(field_uri='AcceptSharingInvitation',
                                                                                                                                                                                -                                                value_cls=AcceptSharingInvitation)
                                                                                                                                                                                + accept_item = EWSElementField(field_uri="AcceptItem", value_cls="AcceptItem", namespace=NAMESPACE) + tentatively_accept_item = EWSElementField( + field_uri="TentativelyAcceptItem", value_cls="TentativelyAcceptItem", namespace=NAMESPACE + ) + decline_item = EWSElementField(field_uri="DeclineItem", value_cls="DeclineItem", namespace=NAMESPACE) + reply_to_item = EWSElementField(field_uri="ReplyToItem", value_cls="ReplyToItem", namespace=NAMESPACE) + forward_item = EWSElementField(field_uri="ForwardItem", value_cls="ForwardItem", namespace=NAMESPACE) + reply_all_to_item = EWSElementField(field_uri="ReplyAllToItem", value_cls="ReplyAllToItem", namespace=NAMESPACE) + cancel_calendar_item = EWSElementField( + field_uri="CancelCalendarItem", value_cls="CancelCalendarItem", namespace=NAMESPACE + ) + remove_item = EWSElementField(field_uri="RemoveItem", value_cls=RemoveItem) + post_reply_item = EWSElementField( + field_uri="PostReplyItem", value_cls="PostReplyItem", namespace=EWSElement.NAMESPACE + ) + success_read_receipt = EWSElementField(field_uri="SuppressReadReceipt", value_cls=SuppressReadReceipt) + accept_sharing_invitation = EWSElementField(field_uri="AcceptSharingInvitation", value_cls=AcceptSharingInvitation)

                                                                                                                                                                                Ancestors

                                                                                                                                                                                  @@ -8235,16 +8366,16 @@

                                                                                                                                                                                  Inherited members

                                                                                                                                                                                  class Room(Mailbox):
                                                                                                                                                                                       """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/room"""
                                                                                                                                                                                   
                                                                                                                                                                                  -    ELEMENT_NAME = 'Room'
                                                                                                                                                                                  +    ELEMENT_NAME = "Room"
                                                                                                                                                                                   
                                                                                                                                                                                       @classmethod
                                                                                                                                                                                       def from_xml(cls, elem, account):
                                                                                                                                                                                  -        id_elem = elem.find(f'{{{TNS}}}Id')
                                                                                                                                                                                  +        id_elem = elem.find(f"{{{TNS}}}Id")
                                                                                                                                                                                           item_id_elem = id_elem.find(ItemId.response_tag())
                                                                                                                                                                                           kwargs = dict(
                                                                                                                                                                                  -            name=get_xml_attr(id_elem, f'{{{TNS}}}Name'),
                                                                                                                                                                                  -            email_address=get_xml_attr(id_elem, f'{{{TNS}}}EmailAddress'),
                                                                                                                                                                                  -            mailbox_type=get_xml_attr(id_elem, f'{{{TNS}}}MailboxType'),
                                                                                                                                                                                  +            name=get_xml_attr(id_elem, f"{{{TNS}}}Name"),
                                                                                                                                                                                  +            email_address=get_xml_attr(id_elem, f"{{{TNS}}}EmailAddress"),
                                                                                                                                                                                  +            mailbox_type=get_xml_attr(id_elem, f"{{{TNS}}}MailboxType"),
                                                                                                                                                                                               item_id=ItemId.from_xml(elem=item_id_elem, account=account) if item_id_elem else None,
                                                                                                                                                                                           )
                                                                                                                                                                                           cls._clear(elem)
                                                                                                                                                                                  @@ -8275,12 +8406,12 @@ 

                                                                                                                                                                                  Static methods

                                                                                                                                                                                  @classmethod
                                                                                                                                                                                   def from_xml(cls, elem, account):
                                                                                                                                                                                  -    id_elem = elem.find(f'{{{TNS}}}Id')
                                                                                                                                                                                  +    id_elem = elem.find(f"{{{TNS}}}Id")
                                                                                                                                                                                       item_id_elem = id_elem.find(ItemId.response_tag())
                                                                                                                                                                                       kwargs = dict(
                                                                                                                                                                                  -        name=get_xml_attr(id_elem, f'{{{TNS}}}Name'),
                                                                                                                                                                                  -        email_address=get_xml_attr(id_elem, f'{{{TNS}}}EmailAddress'),
                                                                                                                                                                                  -        mailbox_type=get_xml_attr(id_elem, f'{{{TNS}}}MailboxType'),
                                                                                                                                                                                  +        name=get_xml_attr(id_elem, f"{{{TNS}}}Name"),
                                                                                                                                                                                  +        email_address=get_xml_attr(id_elem, f"{{{TNS}}}EmailAddress"),
                                                                                                                                                                                  +        mailbox_type=get_xml_attr(id_elem, f"{{{TNS}}}MailboxType"),
                                                                                                                                                                                           item_id=ItemId.from_xml(elem=item_id_elem, account=account) if item_id_elem else None,
                                                                                                                                                                                       )
                                                                                                                                                                                       cls._clear(elem)
                                                                                                                                                                                  @@ -8313,14 +8444,14 @@ 

                                                                                                                                                                                  Inherited members

                                                                                                                                                                                  class RoomList(Mailbox):
                                                                                                                                                                                       """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/roomlist"""
                                                                                                                                                                                   
                                                                                                                                                                                  -    ELEMENT_NAME = 'RoomList'
                                                                                                                                                                                  +    ELEMENT_NAME = "RoomList"
                                                                                                                                                                                       NAMESPACE = MNS
                                                                                                                                                                                   
                                                                                                                                                                                       @classmethod
                                                                                                                                                                                       def response_tag(cls):
                                                                                                                                                                                           # In a GetRoomLists response, room lists are delivered as Address elements. See
                                                                                                                                                                                           # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/address-emailaddresstype
                                                                                                                                                                                  -        return f'{{{TNS}}}Address'
                                                                                                                                                                                  + return f"{{{TNS}}}Address"

                                                                                                                                                                                  Ancestors

                                                                                                                                                                                    @@ -8353,7 +8484,7 @@

                                                                                                                                                                                    Static methods

                                                                                                                                                                                    def response_tag(cls): # In a GetRoomLists response, room lists are delivered as Address elements. See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/address-emailaddresstype - return f'{{{TNS}}}Address'
                                                                                                                                                                                  + return f"{{{TNS}}}Address" @@ -8382,10 +8513,10 @@

                                                                                                                                                                                  Inherited members

                                                                                                                                                                                  class RootItemId(BaseItemId):
                                                                                                                                                                                       """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/rootitemid"""
                                                                                                                                                                                   
                                                                                                                                                                                  -    ELEMENT_NAME = 'RootItemId'
                                                                                                                                                                                  +    ELEMENT_NAME = "RootItemId"
                                                                                                                                                                                       NAMESPACE = MNS
                                                                                                                                                                                  -    ID_ATTR = 'RootItemId'
                                                                                                                                                                                  -    CHANGEKEY_ATTR = 'RootItemChangeKey'
                                                                                                                                                                                  +    ID_ATTR = "RootItemId"
                                                                                                                                                                                  +    CHANGEKEY_ATTR = "RootItemChangeKey"
                                                                                                                                                                                   
                                                                                                                                                                                       id = IdField(field_uri=ID_ATTR, is_required=True)
                                                                                                                                                                                       changekey = IdField(field_uri=CHANGEKEY_ATTR, is_required=True)
                                                                                                                                                                                  @@ -8454,15 +8585,15 @@

                                                                                                                                                                                  Inherited members

                                                                                                                                                                                  class SearchableMailbox(EWSElement):
                                                                                                                                                                                       """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/searchablemailbox"""
                                                                                                                                                                                   
                                                                                                                                                                                  -    ELEMENT_NAME = 'SearchableMailbox'
                                                                                                                                                                                  +    ELEMENT_NAME = "SearchableMailbox"
                                                                                                                                                                                   
                                                                                                                                                                                  -    guid = CharField(field_uri='Guid')
                                                                                                                                                                                  -    primary_smtp_address = EmailAddressField(field_uri='PrimarySmtpAddress')
                                                                                                                                                                                  -    is_external = BooleanField(field_uri='IsExternalMailbox')
                                                                                                                                                                                  -    external_email = EmailAddressField(field_uri='ExternalEmailAddress')
                                                                                                                                                                                  -    display_name = CharField(field_uri='DisplayName')
                                                                                                                                                                                  -    is_membership_group = BooleanField(field_uri='IsMembershipGroup')
                                                                                                                                                                                  -    reference_id = CharField(field_uri='ReferenceId')
                                                                                                                                                                                  + guid = CharField(field_uri="Guid") + primary_smtp_address = EmailAddressField(field_uri="PrimarySmtpAddress") + is_external = BooleanField(field_uri="IsExternalMailbox") + external_email = EmailAddressField(field_uri="ExternalEmailAddress") + display_name = CharField(field_uri="DisplayName") + is_membership_group = BooleanField(field_uri="IsMembershipGroup") + reference_id = CharField(field_uri="ReferenceId")

                                                                                                                                                                                  Ancestors

                                                                                                                                                                                    @@ -8539,7 +8670,7 @@

                                                                                                                                                                                    Inherited members

                                                                                                                                                                                    MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/sendingas """ - ELEMENT_NAME = 'SendingAs' + ELEMENT_NAME = "SendingAs" NAMESPACE = MNS

                                                                                                                                                                                    Ancestors

                                                                                                                                                                                    @@ -8583,7 +8714,7 @@

                                                                                                                                                                                    Inherited members

                                                                                                                                                                                    class SourceId(ItemId):
                                                                                                                                                                                         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/sourceid"""
                                                                                                                                                                                     
                                                                                                                                                                                    -    ELEMENT_NAME = 'SourceId'
                                                                                                                                                                                    + ELEMENT_NAME = "SourceId"

                                                                                                                                                                                    Ancestors

                                                                                                                                                                                      @@ -8623,7 +8754,7 @@

                                                                                                                                                                                      Inherited members

                                                                                                                                                                                      class StandardTime(TimeZoneTransition):
                                                                                                                                                                                           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/standardtime"""
                                                                                                                                                                                       
                                                                                                                                                                                      -    ELEMENT_NAME = 'StandardTime'
                                                                                                                                                                                      + ELEMENT_NAME = "StandardTime"

                                                                                                                                                                                      Ancestors

                                                                                                                                                                                        @@ -8662,7 +8793,7 @@

                                                                                                                                                                                        Inherited members

                                                                                                                                                                                        class StatusEvent(Event):
                                                                                                                                                                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/statusevent"""
                                                                                                                                                                                         
                                                                                                                                                                                        -    ELEMENT_NAME = 'StatusEvent'
                                                                                                                                                                                        + ELEMENT_NAME = "StatusEvent"

                                                                                                                                                                                        Ancestors

                                                                                                                                                                                          @@ -8704,10 +8835,10 @@

                                                                                                                                                                                          Inherited members

                                                                                                                                                                                          https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/stringattributedvalue """ - ELEMENT_NAME = 'StringAttributedValue' + ELEMENT_NAME = "StringAttributedValue" - value = CharField(field_uri='Value') - attributions = CharListField(field_uri='Attributions', list_elem_name='Attribution') + value = CharField(field_uri="Value") + attributions = CharListField(field_uri="Attributions", list_elem_name="Attribution")

                                                                                                                                                                                          Ancestors

                                                                                                                                                                                            @@ -8760,9 +8891,9 @@

                                                                                                                                                                                            Inherited members

                                                                                                                                                                                            class SuppressReadReceipt(EWSElement):
                                                                                                                                                                                                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/suppressreadreceipt"""
                                                                                                                                                                                             
                                                                                                                                                                                            -    ELEMENT_NAME = 'SuppressReadReceipt'
                                                                                                                                                                                            +    ELEMENT_NAME = "SuppressReadReceipt"
                                                                                                                                                                                             
                                                                                                                                                                                            -    reference_item_id = ReferenceItemIdField(field_uri='item:ReferenceItemId')
                                                                                                                                                                                            + reference_item_id = ReferenceItemIdField(field_uri="item:ReferenceItemId")

                                                                                                                                                                                            Ancestors

                                                                                                                                                                                              @@ -8811,10 +8942,10 @@

                                                                                                                                                                                              Inherited members

                                                                                                                                                                                              class TimeWindow(EWSElement):
                                                                                                                                                                                                   """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timewindow"""
                                                                                                                                                                                               
                                                                                                                                                                                              -    ELEMENT_NAME = 'TimeWindow'
                                                                                                                                                                                              +    ELEMENT_NAME = "TimeWindow"
                                                                                                                                                                                               
                                                                                                                                                                                              -    start = DateTimeField(field_uri='StartTime', is_required=True)
                                                                                                                                                                                              -    end = DateTimeField(field_uri='EndTime', is_required=True)
                                                                                                                                                                                              +    start = DateTimeField(field_uri="StartTime", is_required=True)
                                                                                                                                                                                              +    end = DateTimeField(field_uri="EndTime", is_required=True)
                                                                                                                                                                                               
                                                                                                                                                                                                   def clean(self, version=None):
                                                                                                                                                                                                       if self.start >= self.end:
                                                                                                                                                                                              @@ -8890,9 +9021,9 @@ 

                                                                                                                                                                                              Inherited members

                                                                                                                                                                                              class TimeZone(EWSElement):
                                                                                                                                                                                                   """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezone-availability"""
                                                                                                                                                                                               
                                                                                                                                                                                              -    ELEMENT_NAME = 'TimeZone'
                                                                                                                                                                                              +    ELEMENT_NAME = "TimeZone"
                                                                                                                                                                                               
                                                                                                                                                                                              -    bias = IntegerField(field_uri='Bias', is_required=True)  # Standard (non-DST) offset from UTC, in minutes
                                                                                                                                                                                              +    bias = IntegerField(field_uri="Bias", is_required=True)  # Standard (non-DST) offset from UTC, in minutes
                                                                                                                                                                                                   standard_time = EWSElementField(value_cls=StandardTime)
                                                                                                                                                                                                   daylight_time = EWSElementField(value_cls=DaylightTime)
                                                                                                                                                                                               
                                                                                                                                                                                              @@ -8913,7 +9044,7 @@ 

                                                                                                                                                                                              Inherited members

                                                                                                                                                                                              for_year=for_year, ) if candidate == self: - log.debug('Found exact candidate: %s (%s)', tz_definition.id, tz_definition.name) + log.debug("Found exact candidate: %s (%s)", tz_definition.id, tz_definition.name) # We prefer this timezone over anything else. Return immediately. return tz_definition.id # Reduce list based on base bias and standard / daylight bias values @@ -8935,14 +9066,14 @@

                                                                                                                                                                                              Inherited members

                                                                                                                                                                                              continue if candidate.daylight_time.bias != self.daylight_time.bias: continue - log.debug('Found candidate with matching biases: %s (%s)', tz_definition.id, tz_definition.name) + log.debug("Found candidate with matching biases: %s (%s)", tz_definition.id, tz_definition.name) candidates.add(tz_definition.id) if not candidates: - raise ValueError('No server timezones match this timezone definition') + raise ValueError("No server timezones match this timezone definition") if len(candidates) == 1: - log.info('Could not find an exact timezone match for %s. Selecting the best candidate', self) + log.info("Could not find an exact timezone match for %s. Selecting the best candidate", self) else: - log.warning('Could not find an exact timezone match for %s. Selecting a random candidate', self) + log.warning("Could not find an exact timezone match for %s. Selecting a random candidate", self) return candidates.pop() @classmethod @@ -9033,7 +9164,7 @@

                                                                                                                                                                                              Methods

                                                                                                                                                                                              for_year=for_year, ) if candidate == self: - log.debug('Found exact candidate: %s (%s)', tz_definition.id, tz_definition.name) + log.debug("Found exact candidate: %s (%s)", tz_definition.id, tz_definition.name) # We prefer this timezone over anything else. Return immediately. return tz_definition.id # Reduce list based on base bias and standard / daylight bias values @@ -9055,14 +9186,14 @@

                                                                                                                                                                                              Methods

                                                                                                                                                                                              continue if candidate.daylight_time.bias != self.daylight_time.bias: continue - log.debug('Found candidate with matching biases: %s (%s)', tz_definition.id, tz_definition.name) + log.debug("Found candidate with matching biases: %s (%s)", tz_definition.id, tz_definition.name) candidates.add(tz_definition.id) if not candidates: - raise ValueError('No server timezones match this timezone definition') + raise ValueError("No server timezones match this timezone definition") if len(candidates) == 1: - log.info('Could not find an exact timezone match for %s. Selecting the best candidate', self) + log.info("Could not find an exact timezone match for %s. Selecting the best candidate", self) else: - log.warning('Could not find an exact timezone match for %s. Selecting a random candidate', self) + log.warning("Could not find an exact timezone match for %s. Selecting a random candidate", self) return candidates.pop()
                                                                                                                                                                                              @@ -9092,14 +9223,14 @@

                                                                                                                                                                                              Inherited members

                                                                                                                                                                                              class TimeZoneDefinition(EWSElement):
                                                                                                                                                                                                   """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezonedefinition"""
                                                                                                                                                                                               
                                                                                                                                                                                              -    ELEMENT_NAME = 'TimeZoneDefinition'
                                                                                                                                                                                              +    ELEMENT_NAME = "TimeZoneDefinition"
                                                                                                                                                                                               
                                                                                                                                                                                              -    id = CharField(field_uri='Id', is_attribute=True)
                                                                                                                                                                                              -    name = CharField(field_uri='Name', is_attribute=True)
                                                                                                                                                                                              +    id = CharField(field_uri="Id", is_attribute=True)
                                                                                                                                                                                              +    name = CharField(field_uri="Name", is_attribute=True)
                                                                                                                                                                                               
                                                                                                                                                                                              -    periods = EWSElementListField(field_uri='Periods', value_cls=Period)
                                                                                                                                                                                              -    transitions_groups = EWSElementListField(field_uri='TransitionsGroups', value_cls=TransitionsGroup)
                                                                                                                                                                                              -    transitions = TransitionListField(field_uri='Transitions', value_cls=BaseTransition)
                                                                                                                                                                                              +    periods = EWSElementListField(field_uri="Periods", value_cls=Period)
                                                                                                                                                                                              +    transitions_groups = EWSElementListField(field_uri="TransitionsGroups", value_cls=TransitionsGroup)
                                                                                                                                                                                              +    transitions = TransitionListField(field_uri="Transitions", value_cls=BaseTransition)
                                                                                                                                                                                               
                                                                                                                                                                                                   @classmethod
                                                                                                                                                                                                   def from_xml(cls, elem, account):
                                                                                                                                                                                              @@ -9111,11 +9242,11 @@ 

                                                                                                                                                                                              Inherited members

                                                                                                                                                                                              for period in sorted(self.periods, key=lambda p: (p.year, p.type)): if period.year > for_year: break - if period.type != 'Standard': + if period.type != "Standard": continue valid_period = period if valid_period is None: - raise TimezoneDefinitionInvalidForYear(f'Year {for_year} not included in periods {self.periods}') + raise TimezoneDefinitionInvalidForYear(f"Year {for_year} not included in periods {self.periods}") return valid_period def _get_transitions_group(self, for_year): @@ -9123,20 +9254,20 @@

                                                                                                                                                                                              Inherited members

                                                                                                                                                                                              transitions_group = None transitions_groups_map = {tg.id: tg for tg in self.transitions_groups} for transition in sorted(self.transitions, key=lambda t: t.to): - if transition.kind != 'Group': + if transition.kind != "Group": continue if isinstance(transition, AbsoluteDateTransition) and transition.date.year > for_year: break transitions_group = transitions_groups_map[transition.to] if transitions_group is None: - raise ValueError(f'No valid transition group for year {for_year}: {self.transitions}') + raise ValueError(f"No valid transition group for year {for_year}: {self.transitions}") return transitions_group def get_std_and_dst(self, for_year): # Return 'standard_time' and 'daylight_time' objects. We do unnecessary work here, but it keeps code simple. transitions_group = self._get_transitions_group(for_year) if not 0 <= len(transitions_group.transitions) <= 2: - raise ValueError(f'Expected 0-2 transitions in transitions group {transitions_group}') + raise ValueError(f"Expected 0-2 transitions in transitions group {transitions_group}") standard_period = self._get_standard_period(for_year) periods_map = {p.id: p for p in self.periods} @@ -9159,15 +9290,15 @@

                                                                                                                                                                                              Inherited members

                                                                                                                                                                                              weekday=transition.day_of_week, ) period = periods_map[transition.to] - if period.name == 'Standard': - transition_kwargs['bias'] = 0 + if period.name == "Standard": + transition_kwargs["bias"] = 0 standard_time = StandardTime(**transition_kwargs) continue - if period.name == 'Daylight': - transition_kwargs['bias'] = period.bias_in_minutes - standard_period.bias_in_minutes + if period.name == "Daylight": + transition_kwargs["bias"] = period.bias_in_minutes - standard_period.bias_in_minutes daylight_time = DaylightTime(**transition_kwargs) continue - raise ValueError(f'Unknown transition: {transition}') + raise ValueError(f"Unknown transition: {transition}") return standard_time, daylight_time, standard_period

                                                                                                                                                                                              Ancestors

                                                                                                                                                                                              @@ -9240,7 +9371,7 @@

                                                                                                                                                                                              Methods

                                                                                                                                                                                              # Return 'standard_time' and 'daylight_time' objects. We do unnecessary work here, but it keeps code simple. transitions_group = self._get_transitions_group(for_year) if not 0 <= len(transitions_group.transitions) <= 2: - raise ValueError(f'Expected 0-2 transitions in transitions group {transitions_group}') + raise ValueError(f"Expected 0-2 transitions in transitions group {transitions_group}") standard_period = self._get_standard_period(for_year) periods_map = {p.id: p for p in self.periods} @@ -9263,15 +9394,15 @@

                                                                                                                                                                                              Methods

                                                                                                                                                                                              weekday=transition.day_of_week, ) period = periods_map[transition.to] - if period.name == 'Standard': - transition_kwargs['bias'] = 0 + if period.name == "Standard": + transition_kwargs["bias"] = 0 standard_time = StandardTime(**transition_kwargs) continue - if period.name == 'Daylight': - transition_kwargs['bias'] = period.bias_in_minutes - standard_period.bias_in_minutes + if period.name == "Daylight": + transition_kwargs["bias"] = period.bias_in_minutes - standard_period.bias_in_minutes daylight_time = DaylightTime(**transition_kwargs) continue - raise ValueError(f'Unknown transition: {transition}') + raise ValueError(f"Unknown transition: {transition}") return standard_time, daylight_time, standard_period
                                                                                                                                                                                              @@ -9301,11 +9432,11 @@

                                                                                                                                                                                              Inherited members

                                                                                                                                                                                              class TimeZoneTransition(EWSElement, metaclass=EWSMeta):
                                                                                                                                                                                                   """Base class for StandardTime and DaylightTime classes."""
                                                                                                                                                                                               
                                                                                                                                                                                              -    bias = IntegerField(field_uri='Bias', is_required=True)  # Offset from the default bias, in minutes
                                                                                                                                                                                              -    time = TimeField(field_uri='Time', is_required=True)
                                                                                                                                                                                              -    occurrence = IntegerField(field_uri='DayOrder', is_required=True)  # n'th occurrence of weekday in iso_month
                                                                                                                                                                                              -    iso_month = IntegerField(field_uri='Month', is_required=True)
                                                                                                                                                                                              -    weekday = EnumField(field_uri='DayOfWeek', enum=WEEKDAY_NAMES, is_required=True)
                                                                                                                                                                                              +    bias = IntegerField(field_uri="Bias", is_required=True)  # Offset from the default bias, in minutes
                                                                                                                                                                                              +    time = TimeField(field_uri="Time", is_required=True)
                                                                                                                                                                                              +    occurrence = IntegerField(field_uri="DayOrder", is_required=True)  # n'th occurrence of weekday in iso_month
                                                                                                                                                                                              +    iso_month = IntegerField(field_uri="Month", is_required=True)
                                                                                                                                                                                              +    weekday = EnumField(field_uri="DayOfWeek", enum=WEEKDAY_NAMES, is_required=True)
                                                                                                                                                                                                   # 'Year' is not implemented yet
                                                                                                                                                                                               
                                                                                                                                                                                                   @classmethod
                                                                                                                                                                                              @@ -9428,13 +9559,13 @@ 

                                                                                                                                                                                              Inherited members

                                                                                                                                                                                              class TimestampEvent(Event, metaclass=EWSMeta):
                                                                                                                                                                                                   """Base class for both item and folder events with a timestamp."""
                                                                                                                                                                                               
                                                                                                                                                                                              -    FOLDER = 'folder'
                                                                                                                                                                                              -    ITEM = 'item'
                                                                                                                                                                                              +    FOLDER = "folder"
                                                                                                                                                                                              +    ITEM = "item"
                                                                                                                                                                                               
                                                                                                                                                                                              -    timestamp = DateTimeField(field_uri='TimeStamp')
                                                                                                                                                                                              -    item_id = EWSElementField(field_uri='ItemId', value_cls=ItemId)
                                                                                                                                                                                              -    folder_id = EWSElementField(field_uri='FolderId', value_cls=FolderId)
                                                                                                                                                                                              -    parent_folder_id = EWSElementField(field_uri='ParentFolderId', value_cls=ParentFolderId)
                                                                                                                                                                                              +    timestamp = DateTimeField(field_uri="TimeStamp")
                                                                                                                                                                                              +    item_id = EWSElementField(field_uri="ItemId", value_cls=ItemId)
                                                                                                                                                                                              +    folder_id = EWSElementField(field_uri="FolderId", value_cls=FolderId)
                                                                                                                                                                                              +    parent_folder_id = EWSElementField(field_uri="ParentFolderId", value_cls=ParentFolderId)
                                                                                                                                                                                               
                                                                                                                                                                                                   @property
                                                                                                                                                                                                   def event_type(self):
                                                                                                                                                                                              @@ -9533,7 +9664,7 @@ 

                                                                                                                                                                                              Inherited members

                                                                                                                                                                                              class Transition(BaseTransition):
                                                                                                                                                                                                   """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/transition"""
                                                                                                                                                                                               
                                                                                                                                                                                              -    ELEMENT_NAME = 'Transition'
                                                                                                                                                                                              + ELEMENT_NAME = "Transition"

                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                @@ -9572,9 +9703,9 @@

                                                                                                                                                                                                Inherited members

                                                                                                                                                                                                class TransitionsGroup(EWSElement):
                                                                                                                                                                                                     """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/transitionsgroup"""
                                                                                                                                                                                                 
                                                                                                                                                                                                -    ELEMENT_NAME = 'TransitionsGroup'
                                                                                                                                                                                                +    ELEMENT_NAME = "TransitionsGroup"
                                                                                                                                                                                                 
                                                                                                                                                                                                -    id = CharField(field_uri='Id', is_attribute=True)
                                                                                                                                                                                                +    id = CharField(field_uri="Id", is_attribute=True)
                                                                                                                                                                                                     transitions = TransitionListField(value_cls=BaseTransition)

                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                @@ -9643,22 +9774,15 @@

                                                                                                                                                                                                Inherited members

                                                                                                                                                                                                account.calendar.filter(global_object_id=UID('261cbc18-1f65-5a0a-bd11-23b1e224cc2f')) """ - _HEADER = binascii.hexlify(bytearray(( - 0x04, 0x00, 0x00, 0x00, - 0x82, 0x00, 0xE0, 0x00, - 0x74, 0xC5, 0xB7, 0x10, - 0x1A, 0x82, 0xE0, 0x08))) + _HEADER = binascii.hexlify( + bytearray((0x04, 0x00, 0x00, 0x00, 0x82, 0x00, 0xE0, 0x00, 0x74, 0xC5, 0xB7, 0x10, 0x1A, 0x82, 0xE0, 0x08)) + ) - _EXCEPTION_REPLACEMENT_TIME = binascii.hexlify(bytearray(( - 0, 0, 0, 0))) + _EXCEPTION_REPLACEMENT_TIME = binascii.hexlify(bytearray((0, 0, 0, 0))) - _CREATION_TIME = binascii.hexlify(bytearray(( - 0, 0, 0, 0, - 0, 0, 0, 0))) + _CREATION_TIME = binascii.hexlify(bytearray((0, 0, 0, 0, 0, 0, 0, 0))) - _RESERVED = binascii.hexlify(bytearray(( - 0, 0, 0, 0, - 0, 0, 0, 0))) + _RESERVED = binascii.hexlify(bytearray((0, 0, 0, 0, 0, 0, 0, 0))) # https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxocal/1d3aac05-a7b9-45cc-a213-47f0a0a2c5c1 # https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-asemail/e7424ddc-dd10-431e-a0b7-5c794863370e @@ -9666,12 +9790,12 @@

                                                                                                                                                                                                Inherited members

                                                                                                                                                                                                # https://stackoverflow.com/questions/33757805 def __new__(cls, uid): - payload = binascii.hexlify(bytearray(f'vCal-Uid\x01\x00\x00\x00{uid}\x00'.encode('ascii'))) - length = binascii.hexlify(bytearray(struct.pack('<I', int(len(payload)/2)))) - encoding = b''.join([ - cls._HEADER, cls._EXCEPTION_REPLACEMENT_TIME, cls._CREATION_TIME, cls._RESERVED, length, payload - ]) - return super().__new__(cls, codecs.decode(encoding, 'hex')) + payload = binascii.hexlify(bytearray(f"vCal-Uid\x01\x00\x00\x00{uid}\x00".encode("ascii"))) + length = binascii.hexlify(bytearray(struct.pack("<I", int(len(payload) / 2)))) + encoding = b"".join( + [cls._HEADER, cls._EXCEPTION_REPLACEMENT_TIME, cls._CREATION_TIME, cls._RESERVED, length, payload] + ) + return super().__new__(cls, codecs.decode(encoding, "hex")) @classmethod def to_global_object_id(cls, uid): @@ -9714,15 +9838,15 @@

                                                                                                                                                                                                Static methods

                                                                                                                                                                                                class UserConfiguration(IdChangeKeyMixIn):
                                                                                                                                                                                                     """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/userconfiguration"""
                                                                                                                                                                                                 
                                                                                                                                                                                                -    ELEMENT_NAME = 'UserConfiguration'
                                                                                                                                                                                                +    ELEMENT_NAME = "UserConfiguration"
                                                                                                                                                                                                     NAMESPACE = MNS
                                                                                                                                                                                                     ID_ELEMENT_CLS = ItemId
                                                                                                                                                                                                 
                                                                                                                                                                                                -    _id = IdElementField(field_uri='ItemId', value_cls=ID_ELEMENT_CLS)
                                                                                                                                                                                                +    _id = IdElementField(field_uri="ItemId", value_cls=ID_ELEMENT_CLS)
                                                                                                                                                                                                     user_configuration_name = EWSElementField(value_cls=UserConfigurationName)
                                                                                                                                                                                                -    dictionary = DictionaryField(field_uri='Dictionary')
                                                                                                                                                                                                -    xml_data = Base64Field(field_uri='XmlData')
                                                                                                                                                                                                -    binary_data = Base64Field(field_uri='BinaryData')
                                                                                                                                                                                                + dictionary = DictionaryField(field_uri="Dictionary") + xml_data = Base64Field(field_uri="XmlData") + binary_data = Base64Field(field_uri="BinaryData")

                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                @@ -9793,14 +9917,15 @@

                                                                                                                                                                                                Inherited members

                                                                                                                                                                                                class UserConfigurationName(EWSElement):
                                                                                                                                                                                                     """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/userconfigurationname"""
                                                                                                                                                                                                 
                                                                                                                                                                                                -    ELEMENT_NAME = 'UserConfigurationName'
                                                                                                                                                                                                +    ELEMENT_NAME = "UserConfigurationName"
                                                                                                                                                                                                     NAMESPACE = TNS
                                                                                                                                                                                                 
                                                                                                                                                                                                -    name = CharField(field_uri='Name', is_attribute=True)
                                                                                                                                                                                                +    name = CharField(field_uri="Name", is_attribute=True)
                                                                                                                                                                                                     folder = EWSElementField(value_cls=FolderId)
                                                                                                                                                                                                 
                                                                                                                                                                                                     def clean(self, version=None):
                                                                                                                                                                                                         from .folders import BaseFolder
                                                                                                                                                                                                +
                                                                                                                                                                                                         if isinstance(self.folder, BaseFolder):
                                                                                                                                                                                                             self.folder = self.folder.to_id()
                                                                                                                                                                                                         super().clean(version=version)
                                                                                                                                                                                                @@ -9885,6 +10010,7 @@ 

                                                                                                                                                                                                Methods

                                                                                                                                                                                                def clean(self, version=None):
                                                                                                                                                                                                     from .folders import BaseFolder
                                                                                                                                                                                                +
                                                                                                                                                                                                     if isinstance(self.folder, BaseFolder):
                                                                                                                                                                                                         self.folder = self.folder.to_id()
                                                                                                                                                                                                     super().clean(version=version)
                                                                                                                                                                                                @@ -9959,15 +10085,13 @@

                                                                                                                                                                                                Inherited members

                                                                                                                                                                                                class UserId(EWSElement):
                                                                                                                                                                                                     """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/userid"""
                                                                                                                                                                                                 
                                                                                                                                                                                                -    ELEMENT_NAME = 'UserId'
                                                                                                                                                                                                +    ELEMENT_NAME = "UserId"
                                                                                                                                                                                                 
                                                                                                                                                                                                -    sid = CharField(field_uri='SID')
                                                                                                                                                                                                -    primary_smtp_address = EmailAddressField(field_uri='PrimarySmtpAddress')
                                                                                                                                                                                                -    display_name = CharField(field_uri='DisplayName')
                                                                                                                                                                                                -    distinguished_user = ChoiceField(field_uri='DistinguishedUser', choices={
                                                                                                                                                                                                -        Choice('Default'), Choice('Anonymous')
                                                                                                                                                                                                -    })
                                                                                                                                                                                                -    external_user_identity = CharField(field_uri='ExternalUserIdentity')
                                                                                                                                                                                                + sid = CharField(field_uri="SID") + primary_smtp_address = EmailAddressField(field_uri="PrimarySmtpAddress") + display_name = CharField(field_uri="DisplayName") + distinguished_user = ChoiceField(field_uri="DistinguishedUser", choices={Choice("Default"), Choice("Anonymous")}) + external_user_identity = CharField(field_uri="ExternalUserIdentity")

                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                  @@ -10032,11 +10156,11 @@

                                                                                                                                                                                                  Inherited members

                                                                                                                                                                                                  class WorkingPeriod(EWSElement):
                                                                                                                                                                                                       """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/workingperiod"""
                                                                                                                                                                                                   
                                                                                                                                                                                                  -    ELEMENT_NAME = 'WorkingPeriod'
                                                                                                                                                                                                  +    ELEMENT_NAME = "WorkingPeriod"
                                                                                                                                                                                                   
                                                                                                                                                                                                  -    weekdays = EnumListField(field_uri='DayOfWeek', enum=WEEKDAY_NAMES, is_required=True)
                                                                                                                                                                                                  -    start = TimeField(field_uri='StartTimeInMinutes', is_required=True)
                                                                                                                                                                                                  -    end = TimeField(field_uri='EndTimeInMinutes', is_required=True)
                                                                                                                                                                                                  + weekdays = EnumListField(field_uri="DayOfWeek", enum=WEEKDAY_NAMES, is_required=True) + start = TimeField(field_uri="StartTimeInMinutes", is_required=True) + end = TimeField(field_uri="EndTimeInMinutes", is_required=True)

                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                    diff --git a/docs/exchangelib/protocol.html b/docs/exchangelib/protocol.html index 7dc3b0ee..bde37eaa 100644 --- a/docs/exchangelib/protocol.html +++ b/docs/exchangelib/protocol.html @@ -38,8 +38,8 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    import abc import datetime import logging -import os -from queue import LifoQueue, Empty +import random +from queue import Empty, LifoQueue from threading import Lock import requests.adapters @@ -48,13 +48,30 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    from requests_oauthlib import OAuth2Session from .credentials import OAuth2AuthorizationCodeCredentials, OAuth2Credentials -from .errors import TransportError, SessionPoolMinSizeReached, SessionPoolMaxSizeReached, RateLimitError, CASError, \ - ErrorInvalidSchemaVersionForMailboxVersion, UnauthorizedError, MalformedResponseError, InvalidTypeError -from .properties import FreeBusyViewOptions, MailboxData, TimeWindow, TimeZone, RoomList, DLMailbox -from .services import GetServerTimeZones, GetRoomLists, GetRooms, ResolveNames, GetUserAvailability, \ - GetSearchableMailboxes, ExpandDL, ConvertId -from .transport import get_auth_instance, get_service_authtype, NTLM, OAUTH2, CREDENTIALS_REQUIRED, DEFAULT_HEADERS -from .version import Version, API_VERSIONS +from .errors import ( + CASError, + ErrorInvalidSchemaVersionForMailboxVersion, + InvalidTypeError, + MalformedResponseError, + RateLimitError, + SessionPoolMaxSizeReached, + SessionPoolMinSizeReached, + TransportError, + UnauthorizedError, +) +from .properties import DLMailbox, FreeBusyViewOptions, MailboxData, RoomList, TimeWindow, TimeZone +from .services import ( + ConvertId, + ExpandDL, + GetRoomLists, + GetRooms, + GetSearchableMailboxes, + GetServerTimeZones, + GetUserAvailability, + ResolveNames, +) +from .transport import CREDENTIALS_REQUIRED, DEFAULT_HEADERS, NTLM, OAUTH2, get_auth_instance, get_service_authtype +from .version import API_VERSIONS, Version log = logging.getLogger(__name__) @@ -134,7 +151,7 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    def get_auth_type(self): # Autodetect authentication type. We also set version hint here. - name = str(self.credentials) if self.credentials and str(self.credentials) else 'DUMMY' + name = str(self.credentials) if self.credentials and str(self.credentials) else "DUMMY" auth_type, api_version_hint = get_service_authtype( service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS, name=name ) @@ -144,8 +161,8 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    def __getstate__(self): # The session pool and lock cannot be pickled state = self.__dict__.copy() - del state['_session_pool'] - del state['_session_pool_lock'] + del state["_session_pool"] + del state["_session_pool_lock"] return state def __setstate__(self, state): @@ -163,7 +180,7 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    pass def close(self): - log.debug('Server %s: Closing sessions', self.server) + log.debug("Server %s: Closing sessions", self.server) while True: try: session = self._session_pool.get(block=False) @@ -191,13 +208,17 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    # Create a single session and insert it into the pool. We need to protect this with a lock while we are changing # the pool size variable, to avoid race conditions. We must not exceed the pool size limit. if self._session_pool_size >= self._session_pool_maxsize: - raise SessionPoolMaxSizeReached('Session pool size cannot be increased further') + raise SessionPoolMaxSizeReached("Session pool size cannot be increased further") with self._session_pool_lock: if self._session_pool_size >= self._session_pool_maxsize: - log.debug('Session pool size was increased in another thread') + log.debug("Session pool size was increased in another thread") return - log.debug('Server %s: Increasing session pool size from %s to %s', self.server, self._session_pool_size, - self._session_pool_size + 1) + log.debug( + "Server %s: Increasing session pool size from %s to %s", + self.server, + self._session_pool_size, + self._session_pool_size + 1, + ) self._session_pool.put(self.create_session(), block=False) self._session_pool_size += 1 @@ -208,13 +229,17 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    # Take a single session from the pool and discard it. We need to protect this with a lock while we are changing # the pool size variable, to avoid race conditions. We must keep at least one session in the pool. if self._session_pool_size <= 1: - raise SessionPoolMinSizeReached('Session pool size cannot be decreased further') + raise SessionPoolMinSizeReached("Session pool size cannot be decreased further") with self._session_pool_lock: if self._session_pool_size <= 1: - log.debug('Session pool size was decreased in another thread') + log.debug("Session pool size was decreased in another thread") return - log.warning('Server %s: Decreasing session pool size from %s to %s', self.server, self._session_pool_size, - self._session_pool_size - 1) + log.warning( + "Server %s: Decreasing session pool size from %s to %s", + self.server, + self._session_pool_size, + self._session_pool_size - 1, + ) session = self.get_session() self.close_session(session) self._session_pool_size -= 1 @@ -225,7 +250,7 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    _timeout = 60 # Rate-limit messages about session starvation try: session = self._session_pool.get(block=False) - log.debug('Server %s: Got session immediately', self.server) + log.debug("Server %s: Got session immediately", self.server) except Empty: try: self.increase_poolsize() @@ -233,21 +258,21 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    pass while True: try: - log.debug('Server %s: Waiting for session', self.server) + log.debug("Server %s: Waiting for session", self.server) session = self._session_pool.get(timeout=_timeout) break except Empty: # This is normal when we have many worker threads starving for available sessions - log.debug('Server %s: No sessions available for %s seconds', self.server, _timeout) - log.debug('Server %s: Got session %s', self.server, session.session_id) + log.debug("Server %s: No sessions available for %s seconds", self.server, _timeout) + log.debug("Server %s: Got session %s", self.server, session.session_id) session.usage_count += 1 return session def release_session(self, session): # This should never fail, as we don't have more sessions than the queue contains - log.debug('Server %s: Releasing session %s', self.server, session.session_id) + log.debug("Server %s: Releasing session %s", self.server, session.session_id) if self.MAX_SESSION_USAGE_COUNT and session.usage_count >= self.MAX_SESSION_USAGE_COUNT: - log.debug('Server %s: session %s usage exceeded limit. Discarding', self.server, session.session_id) + log.debug("Server %s: session %s usage exceeded limit. Discarding", self.server, session.session_id) session = self.renew_session(session) self._session_pool.put(session, block=False) @@ -258,13 +283,13 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    def retire_session(self, session): # The session is useless. Close it completely and place a fresh session in the pool - log.debug('Server %s: Retiring session %s', self.server, session.session_id) + log.debug("Server %s: Retiring session %s", self.server, session.session_id) self.close_session(session) self.release_session(self.create_session()) def renew_session(self, session): # The session is useless. Close it completely and place a fresh session in the pool - log.debug('Server %s: Renewing session %s', self.server, session.session_id) + log.debug("Server %s: Renewing session %s", self.server, session.session_id) self.close_session(session) return self.create_session() @@ -285,7 +310,7 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    def create_session(self): if self.credentials is None: if self.auth_type in CREDENTIALS_REQUIRED: - raise ValueError(f'Auth type {self.auth_type!r} requires credentials') + raise ValueError(f"Auth type {self.auth_type!r} requires credentials") session = self.raw_session(self.service_endpoint) session.auth = get_auth_instance(auth_type=self.auth_type) else: @@ -300,43 +325,43 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    session.credentials_sig = self.credentials.sig() else: if self.auth_type == NTLM and self.credentials.type == self.credentials.EMAIL: - username = '\\' + self.credentials.username + username = "\\" + self.credentials.username else: username = self.credentials.username session = self.raw_session(self.service_endpoint) - session.auth = get_auth_instance(auth_type=self.auth_type, username=username, - password=self.credentials.password) + session.auth = get_auth_instance( + auth_type=self.auth_type, username=username, password=self.credentials.password + ) # Add some extra info - session.session_id = sum(map(ord, str(os.urandom(100)))) # Used for debugging messages in services + session.session_id = random.randint(10000, 99999) # Used for debugging messages in services session.usage_count = 0 - session.protocol = self - log.debug('Server %s: Created session %s', self.server, session.session_id) + log.debug("Server %s: Created session %s", self.server, session.session_id) return session def create_oauth2_session(self): has_token = False - scope = ['https://outlook.office365.com/.default'] + scope = ["https://outlook.office365.com/.default"] session_params = {} token_params = {} if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials): # Ask for a refresh token - scope.append('offline_access') + scope.append("offline_access") # We don't know (or need) the Microsoft tenant ID. Use # common/ to let Microsoft select the appropriate tenant # for the provided authorization code or refresh token. # # Suppress looks-like-password warning from Bandit. - token_url = 'https://login.microsoftonline.com/common/oauth2/v2.0/token' # nosec + token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token" # nosec client_params = {} has_token = self.credentials.access_token is not None if has_token: - session_params['token'] = self.credentials.access_token + session_params["token"] = self.credentials.access_token elif self.credentials.authorization_code is not None: - token_params['code'] = self.credentials.authorization_code + token_params["code"] = self.credentials.authorization_code self.credentials.authorization_code = None if self.credentials.client_id is not None and self.credentials.client_secret is not None: @@ -347,25 +372,32 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    # covers cases where the caller doesn't have access to # the client secret but is working with a service that # can provide it refreshed tokens on a limited basis). - session_params.update({ - 'auto_refresh_kwargs': { - 'client_id': self.credentials.client_id, - 'client_secret': self.credentials.client_secret, - }, - 'auto_refresh_url': token_url, - 'token_updater': self.credentials.on_token_auto_refreshed, - }) + session_params.update( + { + "auto_refresh_kwargs": { + "client_id": self.credentials.client_id, + "client_secret": self.credentials.client_secret, + }, + "auto_refresh_url": token_url, + "token_updater": self.credentials.on_token_auto_refreshed, + } + ) client = WebApplicationClient(self.credentials.client_id, **client_params) else: - token_url = f'https://login.microsoftonline.com/{self.credentials.tenant_id}/oauth2/v2.0/token' + token_url = f"https://login.microsoftonline.com/{self.credentials.tenant_id}/oauth2/v2.0/token" client = BackendApplicationClient(client_id=self.credentials.client_id) session = self.raw_session(self.service_endpoint, oauth2_client=client, oauth2_session_params=session_params) if not has_token: # Fetch the token explicitly -- it doesn't occur implicitly - token = session.fetch_token(token_url=token_url, client_id=self.credentials.client_id, - client_secret=self.credentials.client_secret, scope=scope, - timeout=self.TIMEOUT, **token_params) + token = session.fetch_token( + token_url=token_url, + client_id=self.credentials.client_id, + client_secret=self.credentials.client_secret, + scope=scope, + timeout=self.TIMEOUT, + **token_params, + ) # Allow the credentials object to update its copy of the new # token, and give the application an opportunity to cache it self.credentials.on_token_auto_refreshed(token) @@ -380,7 +412,7 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    else: session = requests.sessions.Session() session.headers.update(DEFAULT_HEADERS) - session.headers['User-Agent'] = cls.USERAGENT + session.headers["User-Agent"] = cls.USERAGENT session.mount(prefix, adapter=cls.get_adapter()) return session @@ -401,10 +433,11 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    # # We ignore auth_type from kwargs in the cache key. We trust caller to supply the correct auth_type - otherwise # __init__ will guess the correct auth type. - config = kwargs['config'] + config = kwargs["config"] from .configuration import Configuration + if not isinstance(config, Configuration): - raise InvalidTypeError('config', config, Configuration) + raise InvalidTypeError("config", config, Configuration) if not config.service_endpoint: raise AttributeError("'config.service_endpoint' must be set") _protocol_cache_key = cls._cache_key(config) @@ -421,7 +454,7 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    # Acquire lock to guard against multiple threads competing to cache information. Having a per-server lock is # probably overkill although it would reduce lock contention. - log.debug('Waiting for _protocol_cache_lock') + log.debug("Waiting for _protocol_cache_lock") with cls._protocol_cache_lock: try: protocol, _ = cls._protocol_cache[_protocol_cache_key] @@ -439,7 +472,7 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    protocol = super().__call__(*args, **kwargs) except TransportError as e: # This can happen if, for example, autodiscover supplies us with a bogus EWS endpoint - log.warning('Failed to create cached protocol with key %s: %s', _protocol_cache_key, e) + log.warning("Failed to create cached protocol with key %s: %s", _protocol_cache_key, e) cls._protocol_cache[_protocol_cache_key] = e, datetime.datetime.now() raise e cls._protocol_cache[_protocol_cache_key] = protocol, datetime.datetime.now() @@ -503,7 +536,7 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    timezones=timezones, return_full_timezone_data=return_full_timezone_data ) - def get_free_busy_info(self, accounts, start, end, merged_free_busy_interval=30, requested_view='DetailedMerged'): + def get_free_busy_info(self, accounts, start, end, merged_free_busy_interval=30, requested_view="DetailedMerged"): """Return free/busy information for a list of accounts. :param accounts: A list of (account, attendee_type, exclude_conflicts) tuples, where account is either an @@ -518,22 +551,23 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    :return: A generator of FreeBusyView objects """ from .account import Account - tz_definition = list(self.get_timezones( - timezones=[start.tzinfo], - return_full_timezone_data=True - ))[0] + + tz_definition = list(self.get_timezones(timezones=[start.tzinfo], return_full_timezone_data=True))[0] return GetUserAvailability(self).call( - timezone=TimeZone.from_server_timezone(tz_definition=tz_definition, for_year=start.year), - mailbox_data=[MailboxData( + timezone=TimeZone.from_server_timezone(tz_definition=tz_definition, for_year=start.year), + mailbox_data=[ + MailboxData( email=account.primary_smtp_address if isinstance(account, Account) else account, attendee_type=attendee_type, - exclude_conflicts=exclude_conflicts - ) for account, attendee_type, exclude_conflicts in accounts], - free_busy_view_options=FreeBusyViewOptions( - time_window=TimeWindow(start=start, end=end), - merged_free_busy_interval=merged_free_busy_interval, - requested_view=requested_view, - ), + exclude_conflicts=exclude_conflicts, + ) + for account, attendee_type, exclude_conflicts in accounts + ], + free_busy_view_options=FreeBusyViewOptions( + time_window=TimeWindow(start=start, end=end), + merged_free_busy_interval=merged_free_busy_interval, + requested_view=requested_view, + ), ) def get_roomlists(self): @@ -542,8 +576,7 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    def get_rooms(self, roomlist): return GetRooms(protocol=self).call(room_list=RoomList(email_address=roomlist)) - def resolve_names(self, names, parent_folders=None, return_full_contact_data=False, search_scope=None, - shape=None): + def resolve_names(self, names, parent_folders=None, return_full_contact_data=False, search_scope=None, shape=None): """Resolve accounts on the server using partial account data, e.g. an email address or initials. :param names: A list of identifiers to query @@ -554,10 +587,15 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    :return: A list of Mailbox items or, if return_full_contact_data is True, tuples of (Mailbox, Contact) items """ - return list(ResolveNames(protocol=self).call( - unresolved_entries=names, parent_folders=parent_folders, return_full_contact_data=return_full_contact_data, - search_scope=search_scope, contact_data_shape=shape, - )) + return list( + ResolveNames(protocol=self).call( + unresolved_entries=names, + parent_folders=parent_folders, + return_full_contact_data=return_full_contact_data, + search_scope=search_scope, + contact_data_shape=shape, + ) + ) def expand_dl(self, distribution_list): """Expand distribution list into it's members. @@ -567,7 +605,7 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    :return: List of Mailbox items that are members of the distribution list """ if isinstance(distribution_list, str): - distribution_list = DLMailbox(email_address=distribution_list, mailbox_type='PublicDL') + distribution_list = DLMailbox(email_address=distribution_list, mailbox_type="PublicDL") return list(ExpandDL(protocol=self).call(distribution_list=distribution_list)) def get_searchable_mailboxes(self, search_filter=None, expand_group_membership=False): @@ -581,10 +619,12 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    :return: a list of SearchableMailbox, FailedMailbox or Exception instances """ - return list(GetSearchableMailboxes(protocol=self).call( - search_filter=search_filter, - expand_group_membership=expand_group_membership, - )) + return list( + GetSearchableMailboxes(protocol=self).call( + search_filter=search_filter, + expand_group_membership=expand_group_membership, + ) + ) def convert_ids(self, ids, destination_format): """Convert item and folder IDs between multiple formats. @@ -599,7 +639,7 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    def __getstate__(self): # The lock cannot be pickled state = super().__getstate__() - del state['_version_lock'] + del state["_version_lock"] return state def __setstate__(self, state): @@ -612,14 +652,14 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    if self.config.version: fullname, api_version, build = self.version.fullname, self.version.api_version, self.version.build else: - fullname, api_version, build = '[unknown]', '[unknown]', '[unknown]' + fullname, api_version, build = "[unknown]", "[unknown]", "[unknown]" - return f'''\ + return f"""\ EWS url: {self.service_endpoint} Product name: {fullname} EWS API version: {api_version} Build number: {build} -EWS auth: {self.auth_type}''' +EWS auth: {self.auth_type}""" class NoVerifyHTTPAdapter(requests.adapters.HTTPAdapter): @@ -637,7 +677,7 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    cert_file = None def init_poolmanager(self, *args, **kwargs): - kwargs['cert_file'] = self.cert_file + kwargs["cert_file"] = self.cert_file return super().init_poolmanager(*args, **kwargs) @@ -669,28 +709,30 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    """Return whether retries should still be attempted""" def raise_response_errors(self, response): - cas_error = response.headers.get('X-CasErrorCode') + cas_error = response.headers.get("X-CasErrorCode") if cas_error: - if cas_error.startswith('CAS error:'): + if cas_error.startswith("CAS error:"): # Remove unnecessary text - cas_error = cas_error.split(':', 1)[1].strip() + cas_error = cas_error.split(":", 1)[1].strip() raise CASError(cas_error=cas_error, response=response) - if response.status_code == 500 and (b'The specified server version is invalid' in response.content or - b'ErrorInvalidSchemaVersionForMailboxVersion' in response.content): + if response.status_code == 500 and ( + b"The specified server version is invalid" in response.content + or b"ErrorInvalidSchemaVersionForMailboxVersion" in response.content + ): # Another way of communicating invalid schema versions - raise ErrorInvalidSchemaVersionForMailboxVersion('Invalid server version') - if b'The referenced account is currently locked out' in response.content: - raise UnauthorizedError('The referenced account is currently locked out') + raise ErrorInvalidSchemaVersionForMailboxVersion("Invalid server version") + if b"The referenced account is currently locked out" in response.content: + raise UnauthorizedError("The referenced account is currently locked out") if response.status_code == 401 and self.fail_fast: # This is a login failure - raise UnauthorizedError(f'Invalid credentials for {response.url}') - if 'TimeoutException' in response.headers: + raise UnauthorizedError(f"Invalid credentials for {response.url}") + if "TimeoutException" in response.headers: # A header set by us on CONNECTION_ERRORS - raise response.headers['TimeoutException'] + raise response.headers["TimeoutException"] # This could be anything. Let higher layers handle this raise MalformedResponseError( - f'Unknown failure in response. Code: {response.status_code} headers: {response.headers} ' - f'content: {response.text}' + f"Unknown failure in response. Code: {response.status_code} headers: {response.headers} " + f"content: {response.text}" ) @@ -706,10 +748,10 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    return None def back_off(self, seconds): - raise ValueError('Cannot back off with fail-fast policy') + raise ValueError("Cannot back off with fail-fast policy") def may_retry_on_error(self, response, wait): - log.debug('No retry: no fail-fast policy') + log.debug("No retry: no fail-fast policy") return False @@ -729,7 +771,7 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    def __getstate__(self): # Locks cannot be pickled state = self.__dict__.copy() - del state['_back_off_lock'] + del state["_back_off_lock"] return state def __setstate__(self, state): @@ -769,20 +811,24 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    def may_retry_on_error(self, response, wait): if response.status_code not in (301, 302, 401, 500, 503): # Don't retry if we didn't get a status code that we can hope to recover from - log.debug('No retry: wrong status code %s', response.status_code) + log.debug("No retry: wrong status code %s", response.status_code) return False if wait > self.max_wait: # We lost patience. Session is cleaned up in outer loop raise RateLimitError( - 'Max timeout reached', url=response.url, status_code=response.status_code, total_wait=wait) + "Max timeout reached", url=response.url, status_code=response.status_code, total_wait=wait + ) if response.status_code == 401: # EWS sometimes throws 401's when it wants us to throttle connections. OK to retry. return True - if response.headers.get('connection') == 'close': + if response.headers.get("connection") == "close": # Connection closed. OK to retry. return True - if response.status_code == 302 and response.headers.get('location', '').lower() \ - == '/ews/genericerrorpage.htm?aspxerrorpath=/ews/exchange.asmx': + if ( + response.status_code == 302 + and response.headers.get("location", "").lower() + == "/ews/genericerrorpage.htm?aspxerrorpath=/ews/exchange.asmx" + ): # The genericerrorpage.htm/internalerror.asp is ridiculous behaviour for random outages. OK to retry. # # Redirect to '/internalsite/internalerror.asp' or '/internalsite/initparams.aspx' is caused by e.g. TLS @@ -793,7 +839,7 @@

                                                                                                                                                                                                    Module exchangelib.protocol

                                                                                                                                                                                                    return True if response.status_code == 500 and b"Server Error in '/EWS' Application" in response.content: # "Server Error in '/EWS' Application" has been seen in highly concurrent settings. OK to retry. - log.debug('Retry allowed: conditions met') + log.debug("Retry allowed: conditions met") return True return False @@ -904,7 +950,7 @@

                                                                                                                                                                                                    Classes

                                                                                                                                                                                                    def get_auth_type(self): # Autodetect authentication type. We also set version hint here. - name = str(self.credentials) if self.credentials and str(self.credentials) else 'DUMMY' + name = str(self.credentials) if self.credentials and str(self.credentials) else "DUMMY" auth_type, api_version_hint = get_service_authtype( service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS, name=name ) @@ -914,8 +960,8 @@

                                                                                                                                                                                                    Classes

                                                                                                                                                                                                    def __getstate__(self): # The session pool and lock cannot be pickled state = self.__dict__.copy() - del state['_session_pool'] - del state['_session_pool_lock'] + del state["_session_pool"] + del state["_session_pool_lock"] return state def __setstate__(self, state): @@ -933,7 +979,7 @@

                                                                                                                                                                                                    Classes

                                                                                                                                                                                                    pass def close(self): - log.debug('Server %s: Closing sessions', self.server) + log.debug("Server %s: Closing sessions", self.server) while True: try: session = self._session_pool.get(block=False) @@ -961,13 +1007,17 @@

                                                                                                                                                                                                    Classes

                                                                                                                                                                                                    # Create a single session and insert it into the pool. We need to protect this with a lock while we are changing # the pool size variable, to avoid race conditions. We must not exceed the pool size limit. if self._session_pool_size >= self._session_pool_maxsize: - raise SessionPoolMaxSizeReached('Session pool size cannot be increased further') + raise SessionPoolMaxSizeReached("Session pool size cannot be increased further") with self._session_pool_lock: if self._session_pool_size >= self._session_pool_maxsize: - log.debug('Session pool size was increased in another thread') + log.debug("Session pool size was increased in another thread") return - log.debug('Server %s: Increasing session pool size from %s to %s', self.server, self._session_pool_size, - self._session_pool_size + 1) + log.debug( + "Server %s: Increasing session pool size from %s to %s", + self.server, + self._session_pool_size, + self._session_pool_size + 1, + ) self._session_pool.put(self.create_session(), block=False) self._session_pool_size += 1 @@ -978,13 +1028,17 @@

                                                                                                                                                                                                    Classes

                                                                                                                                                                                                    # Take a single session from the pool and discard it. We need to protect this with a lock while we are changing # the pool size variable, to avoid race conditions. We must keep at least one session in the pool. if self._session_pool_size <= 1: - raise SessionPoolMinSizeReached('Session pool size cannot be decreased further') + raise SessionPoolMinSizeReached("Session pool size cannot be decreased further") with self._session_pool_lock: if self._session_pool_size <= 1: - log.debug('Session pool size was decreased in another thread') + log.debug("Session pool size was decreased in another thread") return - log.warning('Server %s: Decreasing session pool size from %s to %s', self.server, self._session_pool_size, - self._session_pool_size - 1) + log.warning( + "Server %s: Decreasing session pool size from %s to %s", + self.server, + self._session_pool_size, + self._session_pool_size - 1, + ) session = self.get_session() self.close_session(session) self._session_pool_size -= 1 @@ -995,7 +1049,7 @@

                                                                                                                                                                                                    Classes

                                                                                                                                                                                                    _timeout = 60 # Rate-limit messages about session starvation try: session = self._session_pool.get(block=False) - log.debug('Server %s: Got session immediately', self.server) + log.debug("Server %s: Got session immediately", self.server) except Empty: try: self.increase_poolsize() @@ -1003,21 +1057,21 @@

                                                                                                                                                                                                    Classes

                                                                                                                                                                                                    pass while True: try: - log.debug('Server %s: Waiting for session', self.server) + log.debug("Server %s: Waiting for session", self.server) session = self._session_pool.get(timeout=_timeout) break except Empty: # This is normal when we have many worker threads starving for available sessions - log.debug('Server %s: No sessions available for %s seconds', self.server, _timeout) - log.debug('Server %s: Got session %s', self.server, session.session_id) + log.debug("Server %s: No sessions available for %s seconds", self.server, _timeout) + log.debug("Server %s: Got session %s", self.server, session.session_id) session.usage_count += 1 return session def release_session(self, session): # This should never fail, as we don't have more sessions than the queue contains - log.debug('Server %s: Releasing session %s', self.server, session.session_id) + log.debug("Server %s: Releasing session %s", self.server, session.session_id) if self.MAX_SESSION_USAGE_COUNT and session.usage_count >= self.MAX_SESSION_USAGE_COUNT: - log.debug('Server %s: session %s usage exceeded limit. Discarding', self.server, session.session_id) + log.debug("Server %s: session %s usage exceeded limit. Discarding", self.server, session.session_id) session = self.renew_session(session) self._session_pool.put(session, block=False) @@ -1028,13 +1082,13 @@

                                                                                                                                                                                                    Classes

                                                                                                                                                                                                    def retire_session(self, session): # The session is useless. Close it completely and place a fresh session in the pool - log.debug('Server %s: Retiring session %s', self.server, session.session_id) + log.debug("Server %s: Retiring session %s", self.server, session.session_id) self.close_session(session) self.release_session(self.create_session()) def renew_session(self, session): # The session is useless. Close it completely and place a fresh session in the pool - log.debug('Server %s: Renewing session %s', self.server, session.session_id) + log.debug("Server %s: Renewing session %s", self.server, session.session_id) self.close_session(session) return self.create_session() @@ -1055,7 +1109,7 @@

                                                                                                                                                                                                    Classes

                                                                                                                                                                                                    def create_session(self): if self.credentials is None: if self.auth_type in CREDENTIALS_REQUIRED: - raise ValueError(f'Auth type {self.auth_type!r} requires credentials') + raise ValueError(f"Auth type {self.auth_type!r} requires credentials") session = self.raw_session(self.service_endpoint) session.auth = get_auth_instance(auth_type=self.auth_type) else: @@ -1070,43 +1124,43 @@

                                                                                                                                                                                                    Classes

                                                                                                                                                                                                    session.credentials_sig = self.credentials.sig() else: if self.auth_type == NTLM and self.credentials.type == self.credentials.EMAIL: - username = '\\' + self.credentials.username + username = "\\" + self.credentials.username else: username = self.credentials.username session = self.raw_session(self.service_endpoint) - session.auth = get_auth_instance(auth_type=self.auth_type, username=username, - password=self.credentials.password) + session.auth = get_auth_instance( + auth_type=self.auth_type, username=username, password=self.credentials.password + ) # Add some extra info - session.session_id = sum(map(ord, str(os.urandom(100)))) # Used for debugging messages in services + session.session_id = random.randint(10000, 99999) # Used for debugging messages in services session.usage_count = 0 - session.protocol = self - log.debug('Server %s: Created session %s', self.server, session.session_id) + log.debug("Server %s: Created session %s", self.server, session.session_id) return session def create_oauth2_session(self): has_token = False - scope = ['https://outlook.office365.com/.default'] + scope = ["https://outlook.office365.com/.default"] session_params = {} token_params = {} if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials): # Ask for a refresh token - scope.append('offline_access') + scope.append("offline_access") # We don't know (or need) the Microsoft tenant ID. Use # common/ to let Microsoft select the appropriate tenant # for the provided authorization code or refresh token. # # Suppress looks-like-password warning from Bandit. - token_url = 'https://login.microsoftonline.com/common/oauth2/v2.0/token' # nosec + token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token" # nosec client_params = {} has_token = self.credentials.access_token is not None if has_token: - session_params['token'] = self.credentials.access_token + session_params["token"] = self.credentials.access_token elif self.credentials.authorization_code is not None: - token_params['code'] = self.credentials.authorization_code + token_params["code"] = self.credentials.authorization_code self.credentials.authorization_code = None if self.credentials.client_id is not None and self.credentials.client_secret is not None: @@ -1117,25 +1171,32 @@

                                                                                                                                                                                                    Classes

                                                                                                                                                                                                    # covers cases where the caller doesn't have access to # the client secret but is working with a service that # can provide it refreshed tokens on a limited basis). - session_params.update({ - 'auto_refresh_kwargs': { - 'client_id': self.credentials.client_id, - 'client_secret': self.credentials.client_secret, - }, - 'auto_refresh_url': token_url, - 'token_updater': self.credentials.on_token_auto_refreshed, - }) + session_params.update( + { + "auto_refresh_kwargs": { + "client_id": self.credentials.client_id, + "client_secret": self.credentials.client_secret, + }, + "auto_refresh_url": token_url, + "token_updater": self.credentials.on_token_auto_refreshed, + } + ) client = WebApplicationClient(self.credentials.client_id, **client_params) else: - token_url = f'https://login.microsoftonline.com/{self.credentials.tenant_id}/oauth2/v2.0/token' + token_url = f"https://login.microsoftonline.com/{self.credentials.tenant_id}/oauth2/v2.0/token" client = BackendApplicationClient(client_id=self.credentials.client_id) session = self.raw_session(self.service_endpoint, oauth2_client=client, oauth2_session_params=session_params) if not has_token: # Fetch the token explicitly -- it doesn't occur implicitly - token = session.fetch_token(token_url=token_url, client_id=self.credentials.client_id, - client_secret=self.credentials.client_secret, scope=scope, - timeout=self.TIMEOUT, **token_params) + token = session.fetch_token( + token_url=token_url, + client_id=self.credentials.client_id, + client_secret=self.credentials.client_secret, + scope=scope, + timeout=self.TIMEOUT, + **token_params, + ) # Allow the credentials object to update its copy of the new # token, and give the application an opportunity to cache it self.credentials.on_token_auto_refreshed(token) @@ -1150,7 +1211,7 @@

                                                                                                                                                                                                    Classes

                                                                                                                                                                                                    else: session = requests.sessions.Session() session.headers.update(DEFAULT_HEADERS) - session.headers['User-Agent'] = cls.USERAGENT + session.headers["User-Agent"] = cls.USERAGENT session.mount(prefix, adapter=cls.get_adapter()) return session @@ -1267,7 +1328,7 @@

                                                                                                                                                                                                    Static methods

                                                                                                                                                                                                    else: session = requests.sessions.Session() session.headers.update(DEFAULT_HEADERS) - session.headers['User-Agent'] = cls.USERAGENT + session.headers["User-Agent"] = cls.USERAGENT session.mount(prefix, adapter=cls.get_adapter()) return session @@ -1363,7 +1424,7 @@

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    Expand source code
                                                                                                                                                                                                    def close(self):
                                                                                                                                                                                                    -    log.debug('Server %s: Closing sessions', self.server)
                                                                                                                                                                                                    +    log.debug("Server %s: Closing sessions", self.server)
                                                                                                                                                                                                         while True:
                                                                                                                                                                                                             try:
                                                                                                                                                                                                                 session = self._session_pool.get(block=False)
                                                                                                                                                                                                    @@ -1384,27 +1445,27 @@ 

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    def create_oauth2_session(self):
                                                                                                                                                                                                         has_token = False
                                                                                                                                                                                                    -    scope = ['https://outlook.office365.com/.default']
                                                                                                                                                                                                    +    scope = ["https://outlook.office365.com/.default"]
                                                                                                                                                                                                         session_params = {}
                                                                                                                                                                                                         token_params = {}
                                                                                                                                                                                                     
                                                                                                                                                                                                         if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials):
                                                                                                                                                                                                             # Ask for a refresh token
                                                                                                                                                                                                    -        scope.append('offline_access')
                                                                                                                                                                                                    +        scope.append("offline_access")
                                                                                                                                                                                                     
                                                                                                                                                                                                             # We don't know (or need) the Microsoft tenant ID. Use
                                                                                                                                                                                                             # common/ to let Microsoft select the appropriate tenant
                                                                                                                                                                                                             # for the provided authorization code or refresh token.
                                                                                                                                                                                                             #
                                                                                                                                                                                                             # Suppress looks-like-password warning from Bandit.
                                                                                                                                                                                                    -        token_url = 'https://login.microsoftonline.com/common/oauth2/v2.0/token'  # nosec
                                                                                                                                                                                                    +        token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token"  # nosec
                                                                                                                                                                                                     
                                                                                                                                                                                                             client_params = {}
                                                                                                                                                                                                             has_token = self.credentials.access_token is not None
                                                                                                                                                                                                             if has_token:
                                                                                                                                                                                                    -            session_params['token'] = self.credentials.access_token
                                                                                                                                                                                                    +            session_params["token"] = self.credentials.access_token
                                                                                                                                                                                                             elif self.credentials.authorization_code is not None:
                                                                                                                                                                                                    -            token_params['code'] = self.credentials.authorization_code
                                                                                                                                                                                                    +            token_params["code"] = self.credentials.authorization_code
                                                                                                                                                                                                                 self.credentials.authorization_code = None
                                                                                                                                                                                                     
                                                                                                                                                                                                             if self.credentials.client_id is not None and self.credentials.client_secret is not None:
                                                                                                                                                                                                    @@ -1415,25 +1476,32 @@ 

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    # covers cases where the caller doesn't have access to # the client secret but is working with a service that # can provide it refreshed tokens on a limited basis). - session_params.update({ - 'auto_refresh_kwargs': { - 'client_id': self.credentials.client_id, - 'client_secret': self.credentials.client_secret, - }, - 'auto_refresh_url': token_url, - 'token_updater': self.credentials.on_token_auto_refreshed, - }) + session_params.update( + { + "auto_refresh_kwargs": { + "client_id": self.credentials.client_id, + "client_secret": self.credentials.client_secret, + }, + "auto_refresh_url": token_url, + "token_updater": self.credentials.on_token_auto_refreshed, + } + ) client = WebApplicationClient(self.credentials.client_id, **client_params) else: - token_url = f'https://login.microsoftonline.com/{self.credentials.tenant_id}/oauth2/v2.0/token' + token_url = f"https://login.microsoftonline.com/{self.credentials.tenant_id}/oauth2/v2.0/token" client = BackendApplicationClient(client_id=self.credentials.client_id) session = self.raw_session(self.service_endpoint, oauth2_client=client, oauth2_session_params=session_params) if not has_token: # Fetch the token explicitly -- it doesn't occur implicitly - token = session.fetch_token(token_url=token_url, client_id=self.credentials.client_id, - client_secret=self.credentials.client_secret, scope=scope, - timeout=self.TIMEOUT, **token_params) + token = session.fetch_token( + token_url=token_url, + client_id=self.credentials.client_id, + client_secret=self.credentials.client_secret, + scope=scope, + timeout=self.TIMEOUT, + **token_params, + ) # Allow the credentials object to update its copy of the new # token, and give the application an opportunity to cache it self.credentials.on_token_auto_refreshed(token) @@ -1454,7 +1522,7 @@

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    def create_session(self):
                                                                                                                                                                                                         if self.credentials is None:
                                                                                                                                                                                                             if self.auth_type in CREDENTIALS_REQUIRED:
                                                                                                                                                                                                    -            raise ValueError(f'Auth type {self.auth_type!r} requires credentials')
                                                                                                                                                                                                    +            raise ValueError(f"Auth type {self.auth_type!r} requires credentials")
                                                                                                                                                                                                             session = self.raw_session(self.service_endpoint)
                                                                                                                                                                                                             session.auth = get_auth_instance(auth_type=self.auth_type)
                                                                                                                                                                                                         else:
                                                                                                                                                                                                    @@ -1469,18 +1537,18 @@ 

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    session.credentials_sig = self.credentials.sig() else: if self.auth_type == NTLM and self.credentials.type == self.credentials.EMAIL: - username = '\\' + self.credentials.username + username = "\\" + self.credentials.username else: username = self.credentials.username session = self.raw_session(self.service_endpoint) - session.auth = get_auth_instance(auth_type=self.auth_type, username=username, - password=self.credentials.password) + session.auth = get_auth_instance( + auth_type=self.auth_type, username=username, password=self.credentials.password + ) # Add some extra info - session.session_id = sum(map(ord, str(os.urandom(100)))) # Used for debugging messages in services + session.session_id = random.randint(10000, 99999) # Used for debugging messages in services session.usage_count = 0 - session.protocol = self - log.debug('Server %s: Created session %s', self.server, session.session_id) + log.debug("Server %s: Created session %s", self.server, session.session_id) return session
                                                                                                                                                                                                    @@ -1501,13 +1569,17 @@

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    # Take a single session from the pool and discard it. We need to protect this with a lock while we are changing # the pool size variable, to avoid race conditions. We must keep at least one session in the pool. if self._session_pool_size <= 1: - raise SessionPoolMinSizeReached('Session pool size cannot be decreased further') + raise SessionPoolMinSizeReached("Session pool size cannot be decreased further") with self._session_pool_lock: if self._session_pool_size <= 1: - log.debug('Session pool size was decreased in another thread') + log.debug("Session pool size was decreased in another thread") return - log.warning('Server %s: Decreasing session pool size from %s to %s', self.server, self._session_pool_size, - self._session_pool_size - 1) + log.warning( + "Server %s: Decreasing session pool size from %s to %s", + self.server, + self._session_pool_size, + self._session_pool_size - 1, + ) session = self.get_session() self.close_session(session) self._session_pool_size -= 1
                                                                                                                                                                                                    @@ -1524,7 +1596,7 @@

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    def get_auth_type(self):
                                                                                                                                                                                                         # Autodetect authentication type. We also set version hint here.
                                                                                                                                                                                                    -    name = str(self.credentials) if self.credentials and str(self.credentials) else 'DUMMY'
                                                                                                                                                                                                    +    name = str(self.credentials) if self.credentials and str(self.credentials) else "DUMMY"
                                                                                                                                                                                                         auth_type, api_version_hint = get_service_authtype(
                                                                                                                                                                                                             service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS, name=name
                                                                                                                                                                                                         )
                                                                                                                                                                                                    @@ -1547,7 +1619,7 @@ 

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    _timeout = 60 # Rate-limit messages about session starvation try: session = self._session_pool.get(block=False) - log.debug('Server %s: Got session immediately', self.server) + log.debug("Server %s: Got session immediately", self.server) except Empty: try: self.increase_poolsize() @@ -1555,13 +1627,13 @@

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    pass while True: try: - log.debug('Server %s: Waiting for session', self.server) + log.debug("Server %s: Waiting for session", self.server) session = self._session_pool.get(timeout=_timeout) break except Empty: # This is normal when we have many worker threads starving for available sessions - log.debug('Server %s: No sessions available for %s seconds', self.server, _timeout) - log.debug('Server %s: Got session %s', self.server, session.session_id) + log.debug("Server %s: No sessions available for %s seconds", self.server, _timeout) + log.debug("Server %s: Got session %s", self.server, session.session_id) session.usage_count += 1 return session
                                                                                                                                                                                                    @@ -1580,13 +1652,17 @@

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    # Create a single session and insert it into the pool. We need to protect this with a lock while we are changing # the pool size variable, to avoid race conditions. We must not exceed the pool size limit. if self._session_pool_size >= self._session_pool_maxsize: - raise SessionPoolMaxSizeReached('Session pool size cannot be increased further') + raise SessionPoolMaxSizeReached("Session pool size cannot be increased further") with self._session_pool_lock: if self._session_pool_size >= self._session_pool_maxsize: - log.debug('Session pool size was increased in another thread') + log.debug("Session pool size was increased in another thread") return - log.debug('Server %s: Increasing session pool size from %s to %s', self.server, self._session_pool_size, - self._session_pool_size + 1) + log.debug( + "Server %s: Increasing session pool size from %s to %s", + self.server, + self._session_pool_size, + self._session_pool_size + 1, + ) self._session_pool.put(self.create_session(), block=False) self._session_pool_size += 1
                                                                                                                                                                                                    @@ -1626,9 +1702,9 @@

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    def release_session(self, session):
                                                                                                                                                                                                         # This should never fail, as we don't have more sessions than the queue contains
                                                                                                                                                                                                    -    log.debug('Server %s: Releasing session %s', self.server, session.session_id)
                                                                                                                                                                                                    +    log.debug("Server %s: Releasing session %s", self.server, session.session_id)
                                                                                                                                                                                                         if self.MAX_SESSION_USAGE_COUNT and session.usage_count >= self.MAX_SESSION_USAGE_COUNT:
                                                                                                                                                                                                    -        log.debug('Server %s: session %s usage exceeded limit. Discarding', self.server, session.session_id)
                                                                                                                                                                                                    +        log.debug("Server %s: session %s usage exceeded limit. Discarding", self.server, session.session_id)
                                                                                                                                                                                                             session = self.renew_session(session)
                                                                                                                                                                                                         self._session_pool.put(session, block=False)
                                                                                                                                                                                                    @@ -1644,7 +1720,7 @@

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    def renew_session(self, session):
                                                                                                                                                                                                         # The session is useless. Close it completely and place a fresh session in the pool
                                                                                                                                                                                                    -    log.debug('Server %s: Renewing session %s', self.server, session.session_id)
                                                                                                                                                                                                    +    log.debug("Server %s: Renewing session %s", self.server, session.session_id)
                                                                                                                                                                                                         self.close_session(session)
                                                                                                                                                                                                         return self.create_session()
                                                                                                                                                                                                    @@ -1660,7 +1736,7 @@

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    def retire_session(self, session):
                                                                                                                                                                                                         # The session is useless. Close it completely and place a fresh session in the pool
                                                                                                                                                                                                    -    log.debug('Server %s: Retiring session %s', self.server, session.session_id)
                                                                                                                                                                                                    +    log.debug("Server %s: Retiring session %s", self.server, session.session_id)
                                                                                                                                                                                                         self.close_session(session)
                                                                                                                                                                                                         self.release_session(self.create_session())
                                                                                                                                                                                                    @@ -1690,10 +1766,11 @@

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    # # We ignore auth_type from kwargs in the cache key. We trust caller to supply the correct auth_type - otherwise # __init__ will guess the correct auth type. - config = kwargs['config'] + config = kwargs["config"] from .configuration import Configuration + if not isinstance(config, Configuration): - raise InvalidTypeError('config', config, Configuration) + raise InvalidTypeError("config", config, Configuration) if not config.service_endpoint: raise AttributeError("'config.service_endpoint' must be set") _protocol_cache_key = cls._cache_key(config) @@ -1710,7 +1787,7 @@

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    # Acquire lock to guard against multiple threads competing to cache information. Having a per-server lock is # probably overkill although it would reduce lock contention. - log.debug('Waiting for _protocol_cache_lock') + log.debug("Waiting for _protocol_cache_lock") with cls._protocol_cache_lock: try: protocol, _ = cls._protocol_cache[_protocol_cache_key] @@ -1728,7 +1805,7 @@

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    protocol = super().__call__(*args, **kwargs) except TransportError as e: # This can happen if, for example, autodiscover supplies us with a bogus EWS endpoint - log.warning('Failed to create cached protocol with key %s: %s', _protocol_cache_key, e) + log.warning("Failed to create cached protocol with key %s: %s", _protocol_cache_key, e) cls._protocol_cache[_protocol_cache_key] = e, datetime.datetime.now() raise e cls._protocol_cache[_protocol_cache_key] = protocol, datetime.datetime.now() @@ -1809,10 +1886,10 @@

                                                                                                                                                                                                    Static methods

                                                                                                                                                                                                    return None def back_off(self, seconds): - raise ValueError('Cannot back off with fail-fast policy') + raise ValueError("Cannot back off with fail-fast policy") def may_retry_on_error(self, response, wait): - log.debug('No retry: no fail-fast policy') + log.debug("No retry: no fail-fast policy") return False

                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                    @@ -1858,7 +1935,7 @@

                                                                                                                                                                                                    Inherited members

                                                                                                                                                                                                    def __getstate__(self): # Locks cannot be pickled state = self.__dict__.copy() - del state['_back_off_lock'] + del state["_back_off_lock"] return state def __setstate__(self, state): @@ -1898,20 +1975,24 @@

                                                                                                                                                                                                    Inherited members

                                                                                                                                                                                                    def may_retry_on_error(self, response, wait): if response.status_code not in (301, 302, 401, 500, 503): # Don't retry if we didn't get a status code that we can hope to recover from - log.debug('No retry: wrong status code %s', response.status_code) + log.debug("No retry: wrong status code %s", response.status_code) return False if wait > self.max_wait: # We lost patience. Session is cleaned up in outer loop raise RateLimitError( - 'Max timeout reached', url=response.url, status_code=response.status_code, total_wait=wait) + "Max timeout reached", url=response.url, status_code=response.status_code, total_wait=wait + ) if response.status_code == 401: # EWS sometimes throws 401's when it wants us to throttle connections. OK to retry. return True - if response.headers.get('connection') == 'close': + if response.headers.get("connection") == "close": # Connection closed. OK to retry. return True - if response.status_code == 302 and response.headers.get('location', '').lower() \ - == '/ews/genericerrorpage.htm?aspxerrorpath=/ews/exchange.asmx': + if ( + response.status_code == 302 + and response.headers.get("location", "").lower() + == "/ews/genericerrorpage.htm?aspxerrorpath=/ews/exchange.asmx" + ): # The genericerrorpage.htm/internalerror.asp is ridiculous behaviour for random outages. OK to retry. # # Redirect to '/internalsite/internalerror.asp' or '/internalsite/initparams.aspx' is caused by e.g. TLS @@ -1922,7 +2003,7 @@

                                                                                                                                                                                                    Inherited members

                                                                                                                                                                                                    return True if response.status_code == 500 and b"Server Error in '/EWS' Application" in response.content: # "Server Error in '/EWS' Application" has been seen in highly concurrent settings. OK to retry. - log.debug('Retry allowed: conditions met') + log.debug("Retry allowed: conditions met") return True return False @@ -2067,7 +2148,7 @@

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    timezones=timezones, return_full_timezone_data=return_full_timezone_data ) - def get_free_busy_info(self, accounts, start, end, merged_free_busy_interval=30, requested_view='DetailedMerged'): + def get_free_busy_info(self, accounts, start, end, merged_free_busy_interval=30, requested_view="DetailedMerged"): """Return free/busy information for a list of accounts. :param accounts: A list of (account, attendee_type, exclude_conflicts) tuples, where account is either an @@ -2082,22 +2163,23 @@

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    :return: A generator of FreeBusyView objects """ from .account import Account - tz_definition = list(self.get_timezones( - timezones=[start.tzinfo], - return_full_timezone_data=True - ))[0] + + tz_definition = list(self.get_timezones(timezones=[start.tzinfo], return_full_timezone_data=True))[0] return GetUserAvailability(self).call( - timezone=TimeZone.from_server_timezone(tz_definition=tz_definition, for_year=start.year), - mailbox_data=[MailboxData( + timezone=TimeZone.from_server_timezone(tz_definition=tz_definition, for_year=start.year), + mailbox_data=[ + MailboxData( email=account.primary_smtp_address if isinstance(account, Account) else account, attendee_type=attendee_type, - exclude_conflicts=exclude_conflicts - ) for account, attendee_type, exclude_conflicts in accounts], - free_busy_view_options=FreeBusyViewOptions( - time_window=TimeWindow(start=start, end=end), - merged_free_busy_interval=merged_free_busy_interval, - requested_view=requested_view, - ), + exclude_conflicts=exclude_conflicts, + ) + for account, attendee_type, exclude_conflicts in accounts + ], + free_busy_view_options=FreeBusyViewOptions( + time_window=TimeWindow(start=start, end=end), + merged_free_busy_interval=merged_free_busy_interval, + requested_view=requested_view, + ), ) def get_roomlists(self): @@ -2106,8 +2188,7 @@

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    def get_rooms(self, roomlist): return GetRooms(protocol=self).call(room_list=RoomList(email_address=roomlist)) - def resolve_names(self, names, parent_folders=None, return_full_contact_data=False, search_scope=None, - shape=None): + def resolve_names(self, names, parent_folders=None, return_full_contact_data=False, search_scope=None, shape=None): """Resolve accounts on the server using partial account data, e.g. an email address or initials. :param names: A list of identifiers to query @@ -2118,10 +2199,15 @@

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    :return: A list of Mailbox items or, if return_full_contact_data is True, tuples of (Mailbox, Contact) items """ - return list(ResolveNames(protocol=self).call( - unresolved_entries=names, parent_folders=parent_folders, return_full_contact_data=return_full_contact_data, - search_scope=search_scope, contact_data_shape=shape, - )) + return list( + ResolveNames(protocol=self).call( + unresolved_entries=names, + parent_folders=parent_folders, + return_full_contact_data=return_full_contact_data, + search_scope=search_scope, + contact_data_shape=shape, + ) + ) def expand_dl(self, distribution_list): """Expand distribution list into it's members. @@ -2131,7 +2217,7 @@

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    :return: List of Mailbox items that are members of the distribution list """ if isinstance(distribution_list, str): - distribution_list = DLMailbox(email_address=distribution_list, mailbox_type='PublicDL') + distribution_list = DLMailbox(email_address=distribution_list, mailbox_type="PublicDL") return list(ExpandDL(protocol=self).call(distribution_list=distribution_list)) def get_searchable_mailboxes(self, search_filter=None, expand_group_membership=False): @@ -2145,10 +2231,12 @@

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    :return: a list of SearchableMailbox, FailedMailbox or Exception instances """ - return list(GetSearchableMailboxes(protocol=self).call( - search_filter=search_filter, - expand_group_membership=expand_group_membership, - )) + return list( + GetSearchableMailboxes(protocol=self).call( + search_filter=search_filter, + expand_group_membership=expand_group_membership, + ) + ) def convert_ids(self, ids, destination_format): """Convert item and folder IDs between multiple formats. @@ -2163,7 +2251,7 @@

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    def __getstate__(self): # The lock cannot be pickled state = super().__getstate__() - del state['_version_lock'] + del state["_version_lock"] return state def __setstate__(self, state): @@ -2176,14 +2264,14 @@

                                                                                                                                                                                                    Methods

                                                                                                                                                                                                    if self.config.version: fullname, api_version, build = self.version.fullname, self.version.api_version, self.version.build else: - fullname, api_version, build = '[unknown]', '[unknown]', '[unknown]' + fullname, api_version, build = "[unknown]", "[unknown]", "[unknown]" - return f'''\ + return f"""\ EWS url: {self.service_endpoint} Product name: {fullname} EWS API version: {api_version} Build number: {build} -EWS auth: {self.auth_type}''' +EWS auth: {self.auth_type}"""

                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                      @@ -2254,7 +2342,7 @@

                                                                                                                                                                                                      Methods

                                                                                                                                                                                                      :return: List of Mailbox items that are members of the distribution list """ if isinstance(distribution_list, str): - distribution_list = DLMailbox(email_address=distribution_list, mailbox_type='PublicDL') + distribution_list = DLMailbox(email_address=distribution_list, mailbox_type="PublicDL") return list(ExpandDL(protocol=self).call(distribution_list=distribution_list)) @@ -2276,7 +2364,7 @@

                                                                                                                                                                                                      Methods

                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                      def get_free_busy_info(self, accounts, start, end, merged_free_busy_interval=30, requested_view='DetailedMerged'):
                                                                                                                                                                                                      +
                                                                                                                                                                                                      def get_free_busy_info(self, accounts, start, end, merged_free_busy_interval=30, requested_view="DetailedMerged"):
                                                                                                                                                                                                           """Return free/busy information for a list of accounts.
                                                                                                                                                                                                       
                                                                                                                                                                                                           :param accounts: A list of (account, attendee_type, exclude_conflicts) tuples, where account is either an
                                                                                                                                                                                                      @@ -2291,22 +2379,23 @@ 

                                                                                                                                                                                                      Methods

                                                                                                                                                                                                      :return: A generator of FreeBusyView objects """ from .account import Account - tz_definition = list(self.get_timezones( - timezones=[start.tzinfo], - return_full_timezone_data=True - ))[0] + + tz_definition = list(self.get_timezones(timezones=[start.tzinfo], return_full_timezone_data=True))[0] return GetUserAvailability(self).call( - timezone=TimeZone.from_server_timezone(tz_definition=tz_definition, for_year=start.year), - mailbox_data=[MailboxData( + timezone=TimeZone.from_server_timezone(tz_definition=tz_definition, for_year=start.year), + mailbox_data=[ + MailboxData( email=account.primary_smtp_address if isinstance(account, Account) else account, attendee_type=attendee_type, - exclude_conflicts=exclude_conflicts - ) for account, attendee_type, exclude_conflicts in accounts], - free_busy_view_options=FreeBusyViewOptions( - time_window=TimeWindow(start=start, end=end), - merged_free_busy_interval=merged_free_busy_interval, - requested_view=requested_view, - ), + exclude_conflicts=exclude_conflicts, + ) + for account, attendee_type, exclude_conflicts in accounts + ], + free_busy_view_options=FreeBusyViewOptions( + time_window=TimeWindow(start=start, end=end), + merged_free_busy_interval=merged_free_busy_interval, + requested_view=requested_view, + ), )
                                                                                                                                                                                                      @@ -2361,10 +2450,12 @@

                                                                                                                                                                                                      Methods

                                                                                                                                                                                                      :return: a list of SearchableMailbox, FailedMailbox or Exception instances """ - return list(GetSearchableMailboxes(protocol=self).call( - search_filter=search_filter, - expand_group_membership=expand_group_membership, - ))
                                                                                                                                                                                                      + return list( + GetSearchableMailboxes(protocol=self).call( + search_filter=search_filter, + expand_group_membership=expand_group_membership, + ) + )
                                                                                                                                                                                                      @@ -2409,8 +2500,7 @@

                                                                                                                                                                                                      Methods

                                                                                                                                                                                                      Expand source code -
                                                                                                                                                                                                      def resolve_names(self, names, parent_folders=None, return_full_contact_data=False, search_scope=None,
                                                                                                                                                                                                      -                  shape=None):
                                                                                                                                                                                                      +
                                                                                                                                                                                                      def resolve_names(self, names, parent_folders=None, return_full_contact_data=False, search_scope=None, shape=None):
                                                                                                                                                                                                           """Resolve accounts on the server using partial account data, e.g. an email address or initials.
                                                                                                                                                                                                       
                                                                                                                                                                                                           :param names: A list of identifiers to query
                                                                                                                                                                                                      @@ -2421,10 +2511,15 @@ 

                                                                                                                                                                                                      Methods

                                                                                                                                                                                                      :return: A list of Mailbox items or, if return_full_contact_data is True, tuples of (Mailbox, Contact) items """ - return list(ResolveNames(protocol=self).call( - unresolved_entries=names, parent_folders=parent_folders, return_full_contact_data=return_full_contact_data, - search_scope=search_scope, contact_data_shape=shape, - ))
                                                                                                                                                                                                      + return list( + ResolveNames(protocol=self).call( + unresolved_entries=names, + parent_folders=parent_folders, + return_full_contact_data=return_full_contact_data, + search_scope=search_scope, + contact_data_shape=shape, + ) + )
                                                                                                                                                                                                      @@ -2476,28 +2571,30 @@

                                                                                                                                                                                                      Inherited members

                                                                                                                                                                                                      """Return whether retries should still be attempted""" def raise_response_errors(self, response): - cas_error = response.headers.get('X-CasErrorCode') + cas_error = response.headers.get("X-CasErrorCode") if cas_error: - if cas_error.startswith('CAS error:'): + if cas_error.startswith("CAS error:"): # Remove unnecessary text - cas_error = cas_error.split(':', 1)[1].strip() + cas_error = cas_error.split(":", 1)[1].strip() raise CASError(cas_error=cas_error, response=response) - if response.status_code == 500 and (b'The specified server version is invalid' in response.content or - b'ErrorInvalidSchemaVersionForMailboxVersion' in response.content): + if response.status_code == 500 and ( + b"The specified server version is invalid" in response.content + or b"ErrorInvalidSchemaVersionForMailboxVersion" in response.content + ): # Another way of communicating invalid schema versions - raise ErrorInvalidSchemaVersionForMailboxVersion('Invalid server version') - if b'The referenced account is currently locked out' in response.content: - raise UnauthorizedError('The referenced account is currently locked out') + raise ErrorInvalidSchemaVersionForMailboxVersion("Invalid server version") + if b"The referenced account is currently locked out" in response.content: + raise UnauthorizedError("The referenced account is currently locked out") if response.status_code == 401 and self.fail_fast: # This is a login failure - raise UnauthorizedError(f'Invalid credentials for {response.url}') - if 'TimeoutException' in response.headers: + raise UnauthorizedError(f"Invalid credentials for {response.url}") + if "TimeoutException" in response.headers: # A header set by us on CONNECTION_ERRORS - raise response.headers['TimeoutException'] + raise response.headers["TimeoutException"] # This could be anything. Let higher layers handle this raise MalformedResponseError( - f'Unknown failure in response. Code: {response.status_code} headers: {response.headers} ' - f'content: {response.text}' + f"Unknown failure in response. Code: {response.status_code} headers: {response.headers} " + f"content: {response.text}" )

                                                                                                                                                                                                      Subclasses

                                                                                                                                                                                                      @@ -2576,28 +2673,30 @@

                                                                                                                                                                                                      Methods

                                                                                                                                                                                                      Expand source code
                                                                                                                                                                                                      def raise_response_errors(self, response):
                                                                                                                                                                                                      -    cas_error = response.headers.get('X-CasErrorCode')
                                                                                                                                                                                                      +    cas_error = response.headers.get("X-CasErrorCode")
                                                                                                                                                                                                           if cas_error:
                                                                                                                                                                                                      -        if cas_error.startswith('CAS error:'):
                                                                                                                                                                                                      +        if cas_error.startswith("CAS error:"):
                                                                                                                                                                                                                   # Remove unnecessary text
                                                                                                                                                                                                      -            cas_error = cas_error.split(':', 1)[1].strip()
                                                                                                                                                                                                      +            cas_error = cas_error.split(":", 1)[1].strip()
                                                                                                                                                                                                               raise CASError(cas_error=cas_error, response=response)
                                                                                                                                                                                                      -    if response.status_code == 500 and (b'The specified server version is invalid' in response.content or
                                                                                                                                                                                                      -                                        b'ErrorInvalidSchemaVersionForMailboxVersion' in response.content):
                                                                                                                                                                                                      +    if response.status_code == 500 and (
                                                                                                                                                                                                      +        b"The specified server version is invalid" in response.content
                                                                                                                                                                                                      +        or b"ErrorInvalidSchemaVersionForMailboxVersion" in response.content
                                                                                                                                                                                                      +    ):
                                                                                                                                                                                                               # Another way of communicating invalid schema versions
                                                                                                                                                                                                      -        raise ErrorInvalidSchemaVersionForMailboxVersion('Invalid server version')
                                                                                                                                                                                                      -    if b'The referenced account is currently locked out' in response.content:
                                                                                                                                                                                                      -        raise UnauthorizedError('The referenced account is currently locked out')
                                                                                                                                                                                                      +        raise ErrorInvalidSchemaVersionForMailboxVersion("Invalid server version")
                                                                                                                                                                                                      +    if b"The referenced account is currently locked out" in response.content:
                                                                                                                                                                                                      +        raise UnauthorizedError("The referenced account is currently locked out")
                                                                                                                                                                                                           if response.status_code == 401 and self.fail_fast:
                                                                                                                                                                                                               # This is a login failure
                                                                                                                                                                                                      -        raise UnauthorizedError(f'Invalid credentials for {response.url}')
                                                                                                                                                                                                      -    if 'TimeoutException' in response.headers:
                                                                                                                                                                                                      +        raise UnauthorizedError(f"Invalid credentials for {response.url}")
                                                                                                                                                                                                      +    if "TimeoutException" in response.headers:
                                                                                                                                                                                                               # A header set by us on CONNECTION_ERRORS
                                                                                                                                                                                                      -        raise response.headers['TimeoutException']
                                                                                                                                                                                                      +        raise response.headers["TimeoutException"]
                                                                                                                                                                                                           # This could be anything. Let higher layers handle this
                                                                                                                                                                                                           raise MalformedResponseError(
                                                                                                                                                                                                      -        f'Unknown failure in response. Code: {response.status_code} headers: {response.headers} '
                                                                                                                                                                                                      -        f'content: {response.text}'
                                                                                                                                                                                                      +        f"Unknown failure in response. Code: {response.status_code} headers: {response.headers} "
                                                                                                                                                                                                      +        f"content: {response.text}"
                                                                                                                                                                                                           )
                                                                                                                                                                                                      @@ -2619,7 +2718,7 @@

                                                                                                                                                                                                      Methods

                                                                                                                                                                                                      cert_file = None def init_poolmanager(self, *args, **kwargs): - kwargs['cert_file'] = self.cert_file + kwargs["cert_file"] = self.cert_file return super().init_poolmanager(*args, **kwargs)

                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                      @@ -2653,7 +2752,7 @@

                                                                                                                                                                                                      Methods

                                                                                                                                                                                                      Expand source code
                                                                                                                                                                                                      def init_poolmanager(self, *args, **kwargs):
                                                                                                                                                                                                      -    kwargs['cert_file'] = self.cert_file
                                                                                                                                                                                                      +    kwargs["cert_file"] = self.cert_file
                                                                                                                                                                                                           return super().init_poolmanager(*args, **kwargs)
                                                                                                                                                                                                      diff --git a/docs/exchangelib/queryset.html b/docs/exchangelib/queryset.html index 9205d88d..cf3f0e0b 100644 --- a/docs/exchangelib/queryset.html +++ b/docs/exchangelib/queryset.html @@ -31,9 +31,9 @@

                                                                                                                                                                                                      Module exchangelib.queryset

                                                                                                                                                                                                      from copy import deepcopy from itertools import islice -from .errors import MultipleObjectsReturned, DoesNotExist, InvalidEnumValue, InvalidTypeError, ErrorItemNotFound -from .fields import FieldPath, FieldOrder -from .items import CalendarItem, ID_ONLY +from .errors import DoesNotExist, ErrorItemNotFound, InvalidEnumValue, InvalidTypeError, MultipleObjectsReturned +from .fields import FieldOrder, FieldPath +from .items import ID_ONLY, CalendarItem from .properties import InvalidField from .restriction import Q from .version import EXCHANGE_2010 @@ -78,23 +78,24 @@

                                                                                                                                                                                                      Module exchangelib.queryset

                                                                                                                                                                                                      Django QuerySet documentation: https://docs.djangoproject.com/en/dev/ref/models/querysets/ """ - VALUES = 'values' - VALUES_LIST = 'values_list' - FLAT = 'flat' - NONE = 'none' + VALUES = "values" + VALUES_LIST = "values_list" + FLAT = "flat" + NONE = "none" RETURN_TYPES = (VALUES, VALUES_LIST, FLAT, NONE) - ITEM = 'item' - PERSONA = 'persona' + ITEM = "item" + PERSONA = "persona" REQUEST_TYPES = (ITEM, PERSONA) def __init__(self, folder_collection, request_type=ITEM): from .folders import FolderCollection + if not isinstance(folder_collection, FolderCollection): - raise InvalidTypeError('folder_collection', folder_collection, FolderCollection) + raise InvalidTypeError("folder_collection", folder_collection, FolderCollection) self.folder_collection = folder_collection # A FolderCollection instance if request_type not in self.REQUEST_TYPES: - raise InvalidEnumValue('request_type', request_type, self.REQUEST_TYPES) + raise InvalidEnumValue("request_type", request_type, self.REQUEST_TYPES) self.request_type = request_type self.q = Q() # Default to no restrictions self.only_fields = None @@ -133,6 +134,7 @@

                                                                                                                                                                                                      Module exchangelib.queryset

                                                                                                                                                                                                      def _get_field_path(self, field_path): from .items import Persona + if self.request_type == self.PERSONA: return FieldPath(field=Persona.get_field_by_fieldname(field_path)) for folder in self.folder_collection: @@ -144,10 +146,11 @@

                                                                                                                                                                                                      Module exchangelib.queryset

                                                                                                                                                                                                      def _get_field_order(self, field_path): from .items import Persona + if self.request_type == self.PERSONA: return FieldOrder( - field_path=FieldPath(field=Persona.get_field_by_fieldname(field_path.lstrip('-'))), - reverse=field_path.startswith('-'), + field_path=FieldPath(field=Persona.get_field_by_fieldname(field_path.lstrip("-"))), + reverse=field_path.startswith("-"), ) for folder in self.folder_collection: try: @@ -158,23 +161,23 @@

                                                                                                                                                                                                      Module exchangelib.queryset

                                                                                                                                                                                                      @property def _id_field(self): - return self._get_field_path('id') + return self._get_field_path("id") @property def _changekey_field(self): - return self._get_field_path('changekey') + return self._get_field_path("changekey") def _additional_fields(self): if not isinstance(self.only_fields, tuple): - raise InvalidTypeError('only_fields', self.only_fields, tuple) + raise InvalidTypeError("only_fields", self.only_fields, tuple) # Remove ItemId and ChangeKey. We get them unconditionally additional_fields = {f for f in self.only_fields if not f.field.is_attribute} if self.request_type != self.ITEM: return additional_fields # For CalendarItem items, we want to inject internal timezone fields into the requested fields. - has_start = 'start' in {f.field.name for f in additional_fields} - has_end = 'end' in {f.field.name for f in additional_fields} + has_start = "start" in {f.field.name for f in additional_fields} + has_end = "end" in {f.field.name for f in additional_fields} meeting_tz_field, start_tz_field, end_tz_field = CalendarItem.timezone_fields() if self.folder_collection.account.version.build < EXCHANGE_2010: if has_start or has_end: @@ -235,20 +238,20 @@

                                                                                                                                                                                                      Module exchangelib.queryset

                                                                                                                                                                                                      ) if self.request_type == self.PERSONA: if complex_fields_requested: - find_kwargs['additional_fields'] = None + find_kwargs["additional_fields"] = None items = self.folder_collection.account.fetch_personas( ids=self.folder_collection.find_people(self.q, **find_kwargs) ) else: if not additional_fields: - find_kwargs['additional_fields'] = None + find_kwargs["additional_fields"] = None items = self.folder_collection.find_people(self.q, **find_kwargs) else: - find_kwargs['calendar_view'] = self.calendar_view + find_kwargs["calendar_view"] = self.calendar_view if complex_fields_requested: # The FindItem service does not support complex field types. Tell find_items() to return # (id, changekey) tuples, and pass that to fetch(). - find_kwargs['additional_fields'] = None + find_kwargs["additional_fields"] = None unfiltered_items = self.folder_collection.account.fetch( ids=self.folder_collection.find_items(self.q, **find_kwargs), only_fields=additional_fields, @@ -261,7 +264,7 @@

                                                                                                                                                                                                      Module exchangelib.queryset

                                                                                                                                                                                                      # If additional_fields is the empty set, we only requested ID and changekey fields. We can then # take a shortcut by using (shape=ID_ONLY, additional_fields=None) to tell find_items() to return # (id, changekey) tuples. We'll post-process those later. - find_kwargs['additional_fields'] = None + find_kwargs["additional_fields"] = None items = self.folder_collection.find_items(self.q, **find_kwargs) if not must_sort_clientside: @@ -274,12 +277,13 @@

                                                                                                                                                                                                      Module exchangelib.queryset

                                                                                                                                                                                                      try: items = sorted(items, key=lambda i: _get_sort_value_or_default(i, f), reverse=f.reverse) except TypeError as e: - if 'unorderable types' not in e.args[0]: + if "unorderable types" not in e.args[0]: raise raise ValueError( f"Cannot sort on field {f.field_path!r}. The field has no default value defined, and there are " f"either items with None values for this field, or the query contains exception instances " - f"(original error: {e}).") + f"(original error: {e})." + ) if not extra_order_fields: return items @@ -293,20 +297,18 @@

                                                                                                                                                                                                      Module exchangelib.queryset

                                                                                                                                                                                                      if self.q.is_never(): return - log.debug('Initializing cache') + log.debug("Initializing cache") yield from self._format_items(items=self._query(), return_format=self.return_format) - """Do not implement __len__. The implementation of list() tries to preallocate memory by calling __len__ on the - given sequence, before calling __iter__. If we implemented __len__, we would end up calling FindItems twice, once - to get the result of self.count(), an once to return the actual result. - - Also, according to https://stackoverflow.com/questions/37189968/how-to-have-list-consume-iter-without-calling-len, - a __len__ implementation should be cheap. That does not hold for self.count(). - - def __len__(self): - # This queryset has no cache yet. Call the optimized counting implementation - return self.count() - """ + # Do not implement __len__. The implementation of list() tries to preallocate memory by calling __len__ on the + # given sequence, before calling __iter__. If we implemented __len__, we would end up calling FindItems twice, once + # to get the result of self.count(), and once to return the actual result. + # + # Also, according to https://stackoverflow.com/questions/37189968/how-to-have-list-consume-iter-without-calling-len, + # a __len__ implementation should be cheap. That does not hold for self.count(). + # + # def __len__(self): + # return self.count() def __getitem__(self, idx_or_slice): # Support indexing and slicing. This is non-greedy when possible (slicing start, stop and step are not negative, @@ -318,7 +320,7 @@

                                                                                                                                                                                                      Module exchangelib.queryset

                                                                                                                                                                                                      def _getitem_idx(self, idx): if idx < 0: # Support negative indexes by reversing the queryset and negating the index value - reverse_idx = -(idx+1) + reverse_idx = -(idx + 1) return self.reverse()[reverse_idx] # Optimize by setting an exact offset and fetching only 1 item new_qs = self._copy_self() @@ -332,6 +334,7 @@

                                                                                                                                                                                                      Module exchangelib.queryset

                                                                                                                                                                                                      def _getitem_slice(self, s): from .services import PAGE_SIZE + if ((s.start or 0) < 0) or ((s.stop or 0) < 0) or ((s.step or 0) < 0): # islice() does not support negative start, stop and step. Make sure cache is full by iterating the full # query result, and then slice on the cache. @@ -378,6 +381,7 @@

                                                                                                                                                                                                      Module exchangelib.queryset

                                                                                                                                                                                                      def _as_items(self, iterable): from .items import Item + return self._item_yielder( iterable=iterable, item_func=lambda i: i, @@ -388,18 +392,18 @@

                                                                                                                                                                                                      Module exchangelib.queryset

                                                                                                                                                                                                      def _as_values(self, iterable): if not self.only_fields: - raise ValueError('values() requires at least one field name') + raise ValueError("values() requires at least one field name") return self._item_yielder( iterable=iterable, item_func=lambda i: {f.path: _get_value_or_default(f, i) for f in self.only_fields}, - id_only_func=lambda item_id, changekey: {'id': item_id}, - changekey_only_func=lambda item_id, changekey: {'changekey': changekey}, - id_and_changekey_func=lambda item_id, changekey: {'id': item_id, 'changekey': changekey}, + id_only_func=lambda item_id, changekey: {"id": item_id}, + changekey_only_func=lambda item_id, changekey: {"changekey": changekey}, + id_and_changekey_func=lambda item_id, changekey: {"id": item_id, "changekey": changekey}, ) def _as_values_list(self, iterable): if not self.only_fields: - raise ValueError('values_list() requires at least one field name') + raise ValueError("values_list() requires at least one field name") return self._item_yielder( iterable=iterable, item_func=lambda i: tuple(_get_value_or_default(f, i) for f in self.only_fields), @@ -410,7 +414,7 @@

                                                                                                                                                                                                      Module exchangelib.queryset

                                                                                                                                                                                                      def _as_flat_values_list(self, iterable): if not self.only_fields or len(self.only_fields) != 1: - raise ValueError('flat=True requires exactly one field name') + raise ValueError("flat=True requires exactly one field name") return self._item_yielder( iterable=iterable, item_func=lambda i: _get_value_or_default(self.only_fields[0], i), @@ -486,7 +490,7 @@

                                                                                                                                                                                                      Module exchangelib.queryset

                                                                                                                                                                                                      def reverse(self): """Reverses the ordering of the queryset.""" if not self.order_fields: - raise ValueError('Reversing only makes sense if there are order_by fields') + raise ValueError("Reversing only makes sense if there are order_by fields") new_qs = self._copy_self() for f in new_qs.order_fields: f.reverse = not f.reverse @@ -506,11 +510,11 @@

                                                                                                                                                                                                      Module exchangelib.queryset

                                                                                                                                                                                                      """Return the values of the specified field names as a list of lists. If called with flat=True and only one field name, returns a list of values. """ - flat = kwargs.pop('flat', False) + flat = kwargs.pop("flat", False) if kwargs: - raise AttributeError(f'Unknown kwargs: {kwargs}') + raise AttributeError(f"Unknown kwargs: {kwargs}") if flat and len(args) != 1: - raise ValueError('flat=True requires exactly one field name') + raise ValueError("flat=True requires exactly one field name") try: only_fields = tuple(self._get_field_path(arg) for arg in args) except ValueError as e: @@ -537,12 +541,12 @@

                                                                                                                                                                                                      Module exchangelib.queryset

                                                                                                                                                                                                      def get(self, *args, **kwargs): """Assume the query will return exactly one item. Return that item.""" - if not args and set(kwargs) in ({'id'}, {'id', 'changekey'}): + if not args and set(kwargs) in ({"id"}, {"id", "changekey"}): # We allow calling get(id=..., changekey=...) to get a single item, but only if exactly these two # kwargs are present. account = self.folder_collection.account - item_id = self._id_field.field.clean(kwargs['id'], version=account.version) - changekey = self._changekey_field.field.clean(kwargs.get('changekey'), version=account.version) + item_id = self._id_field.field.clean(kwargs["id"], version=account.version) + changekey = self._changekey_field.field.clean(kwargs.get("changekey"), version=account.version) items = list(account.fetch(ids=[(item_id, changekey)], only_fields=self.only_fields)) else: new_qs = self.filter(*args, **kwargs) @@ -590,11 +594,7 @@

                                                                                                                                                                                                      Module exchangelib.queryset

                                                                                                                                                                                                      """ ids = self._id_only_copy_self() ids.page_size = page_size - return self.folder_collection.account.bulk_delete( - ids=ids, - chunk_size=chunk_size, - **delete_kwargs - ) + return self.folder_collection.account.bulk_delete(ids=ids, chunk_size=chunk_size, **delete_kwargs) def send(self, page_size=1000, chunk_size=100, **send_kwargs): """Send the items matching the query, with as little effort as possible @@ -606,11 +606,7 @@

                                                                                                                                                                                                      Module exchangelib.queryset

                                                                                                                                                                                                      """ ids = self._id_only_copy_self() ids.page_size = page_size - return self.folder_collection.account.bulk_send( - ids=ids, - chunk_size=chunk_size, - **send_kwargs - ) + return self.folder_collection.account.bulk_send(ids=ids, chunk_size=chunk_size, **send_kwargs) def copy(self, to_folder, page_size=1000, chunk_size=100, **copy_kwargs): """Copy the items matching the query, with as little effort as possible @@ -624,10 +620,7 @@

                                                                                                                                                                                                      Module exchangelib.queryset

                                                                                                                                                                                                      ids = self._id_only_copy_self() ids.page_size = page_size return self.folder_collection.account.bulk_copy( - ids=ids, - to_folder=to_folder, - chunk_size=chunk_size, - **copy_kwargs + ids=ids, to_folder=to_folder, chunk_size=chunk_size, **copy_kwargs ) def move(self, to_folder, page_size=1000, chunk_size=100): @@ -675,16 +668,12 @@

                                                                                                                                                                                                      Module exchangelib.queryset

                                                                                                                                                                                                      """ ids = self._id_only_copy_self() ids.page_size = page_size - return self.folder_collection.account.bulk_mark_as_junk( - ids=ids, - chunk_size=chunk_size, - **mark_as_junk_kwargs - ) + return self.folder_collection.account.bulk_mark_as_junk(ids=ids, chunk_size=chunk_size, **mark_as_junk_kwargs) def __str__(self): - fmt_args = [('q', str(self.q)), ('folders', f"[{', '.join(str(f) for f in self.folder_collection.folders)}]")] - args_str = ', '.join(f'{k}={v}' for k, v in fmt_args) - return f'{self.__class__.__name__}({args_str})' + fmt_args = [("q", str(self.q)), ("folders", f"[{', '.join(str(f) for f in self.folder_collection.folders)}]")] + args_str = ", ".join(f"{k}={v}" for k, v in fmt_args) + return f"{self.__class__.__name__}({args_str})" def _get_value_or_default(field, item): @@ -753,23 +742,24 @@

                                                                                                                                                                                                      Classes

                                                                                                                                                                                                      Django QuerySet documentation: https://docs.djangoproject.com/en/dev/ref/models/querysets/ """ - VALUES = 'values' - VALUES_LIST = 'values_list' - FLAT = 'flat' - NONE = 'none' + VALUES = "values" + VALUES_LIST = "values_list" + FLAT = "flat" + NONE = "none" RETURN_TYPES = (VALUES, VALUES_LIST, FLAT, NONE) - ITEM = 'item' - PERSONA = 'persona' + ITEM = "item" + PERSONA = "persona" REQUEST_TYPES = (ITEM, PERSONA) def __init__(self, folder_collection, request_type=ITEM): from .folders import FolderCollection + if not isinstance(folder_collection, FolderCollection): - raise InvalidTypeError('folder_collection', folder_collection, FolderCollection) + raise InvalidTypeError("folder_collection", folder_collection, FolderCollection) self.folder_collection = folder_collection # A FolderCollection instance if request_type not in self.REQUEST_TYPES: - raise InvalidEnumValue('request_type', request_type, self.REQUEST_TYPES) + raise InvalidEnumValue("request_type", request_type, self.REQUEST_TYPES) self.request_type = request_type self.q = Q() # Default to no restrictions self.only_fields = None @@ -808,6 +798,7 @@

                                                                                                                                                                                                      Classes

                                                                                                                                                                                                      def _get_field_path(self, field_path): from .items import Persona + if self.request_type == self.PERSONA: return FieldPath(field=Persona.get_field_by_fieldname(field_path)) for folder in self.folder_collection: @@ -819,10 +810,11 @@

                                                                                                                                                                                                      Classes

                                                                                                                                                                                                      def _get_field_order(self, field_path): from .items import Persona + if self.request_type == self.PERSONA: return FieldOrder( - field_path=FieldPath(field=Persona.get_field_by_fieldname(field_path.lstrip('-'))), - reverse=field_path.startswith('-'), + field_path=FieldPath(field=Persona.get_field_by_fieldname(field_path.lstrip("-"))), + reverse=field_path.startswith("-"), ) for folder in self.folder_collection: try: @@ -833,23 +825,23 @@

                                                                                                                                                                                                      Classes

                                                                                                                                                                                                      @property def _id_field(self): - return self._get_field_path('id') + return self._get_field_path("id") @property def _changekey_field(self): - return self._get_field_path('changekey') + return self._get_field_path("changekey") def _additional_fields(self): if not isinstance(self.only_fields, tuple): - raise InvalidTypeError('only_fields', self.only_fields, tuple) + raise InvalidTypeError("only_fields", self.only_fields, tuple) # Remove ItemId and ChangeKey. We get them unconditionally additional_fields = {f for f in self.only_fields if not f.field.is_attribute} if self.request_type != self.ITEM: return additional_fields # For CalendarItem items, we want to inject internal timezone fields into the requested fields. - has_start = 'start' in {f.field.name for f in additional_fields} - has_end = 'end' in {f.field.name for f in additional_fields} + has_start = "start" in {f.field.name for f in additional_fields} + has_end = "end" in {f.field.name for f in additional_fields} meeting_tz_field, start_tz_field, end_tz_field = CalendarItem.timezone_fields() if self.folder_collection.account.version.build < EXCHANGE_2010: if has_start or has_end: @@ -910,20 +902,20 @@

                                                                                                                                                                                                      Classes

                                                                                                                                                                                                      ) if self.request_type == self.PERSONA: if complex_fields_requested: - find_kwargs['additional_fields'] = None + find_kwargs["additional_fields"] = None items = self.folder_collection.account.fetch_personas( ids=self.folder_collection.find_people(self.q, **find_kwargs) ) else: if not additional_fields: - find_kwargs['additional_fields'] = None + find_kwargs["additional_fields"] = None items = self.folder_collection.find_people(self.q, **find_kwargs) else: - find_kwargs['calendar_view'] = self.calendar_view + find_kwargs["calendar_view"] = self.calendar_view if complex_fields_requested: # The FindItem service does not support complex field types. Tell find_items() to return # (id, changekey) tuples, and pass that to fetch(). - find_kwargs['additional_fields'] = None + find_kwargs["additional_fields"] = None unfiltered_items = self.folder_collection.account.fetch( ids=self.folder_collection.find_items(self.q, **find_kwargs), only_fields=additional_fields, @@ -936,7 +928,7 @@

                                                                                                                                                                                                      Classes

                                                                                                                                                                                                      # If additional_fields is the empty set, we only requested ID and changekey fields. We can then # take a shortcut by using (shape=ID_ONLY, additional_fields=None) to tell find_items() to return # (id, changekey) tuples. We'll post-process those later. - find_kwargs['additional_fields'] = None + find_kwargs["additional_fields"] = None items = self.folder_collection.find_items(self.q, **find_kwargs) if not must_sort_clientside: @@ -949,12 +941,13 @@

                                                                                                                                                                                                      Classes

                                                                                                                                                                                                      try: items = sorted(items, key=lambda i: _get_sort_value_or_default(i, f), reverse=f.reverse) except TypeError as e: - if 'unorderable types' not in e.args[0]: + if "unorderable types" not in e.args[0]: raise raise ValueError( f"Cannot sort on field {f.field_path!r}. The field has no default value defined, and there are " f"either items with None values for this field, or the query contains exception instances " - f"(original error: {e}).") + f"(original error: {e})." + ) if not extra_order_fields: return items @@ -968,20 +961,18 @@

                                                                                                                                                                                                      Classes

                                                                                                                                                                                                      if self.q.is_never(): return - log.debug('Initializing cache') + log.debug("Initializing cache") yield from self._format_items(items=self._query(), return_format=self.return_format) - """Do not implement __len__. The implementation of list() tries to preallocate memory by calling __len__ on the - given sequence, before calling __iter__. If we implemented __len__, we would end up calling FindItems twice, once - to get the result of self.count(), an once to return the actual result. - - Also, according to https://stackoverflow.com/questions/37189968/how-to-have-list-consume-iter-without-calling-len, - a __len__ implementation should be cheap. That does not hold for self.count(). - - def __len__(self): - # This queryset has no cache yet. Call the optimized counting implementation - return self.count() - """ + # Do not implement __len__. The implementation of list() tries to preallocate memory by calling __len__ on the + # given sequence, before calling __iter__. If we implemented __len__, we would end up calling FindItems twice, once + # to get the result of self.count(), and once to return the actual result. + # + # Also, according to https://stackoverflow.com/questions/37189968/how-to-have-list-consume-iter-without-calling-len, + # a __len__ implementation should be cheap. That does not hold for self.count(). + # + # def __len__(self): + # return self.count() def __getitem__(self, idx_or_slice): # Support indexing and slicing. This is non-greedy when possible (slicing start, stop and step are not negative, @@ -993,7 +984,7 @@

                                                                                                                                                                                                      Classes

                                                                                                                                                                                                      def _getitem_idx(self, idx): if idx < 0: # Support negative indexes by reversing the queryset and negating the index value - reverse_idx = -(idx+1) + reverse_idx = -(idx + 1) return self.reverse()[reverse_idx] # Optimize by setting an exact offset and fetching only 1 item new_qs = self._copy_self() @@ -1007,6 +998,7 @@

                                                                                                                                                                                                      Classes

                                                                                                                                                                                                      def _getitem_slice(self, s): from .services import PAGE_SIZE + if ((s.start or 0) < 0) or ((s.stop or 0) < 0) or ((s.step or 0) < 0): # islice() does not support negative start, stop and step. Make sure cache is full by iterating the full # query result, and then slice on the cache. @@ -1053,6 +1045,7 @@

                                                                                                                                                                                                      Classes

                                                                                                                                                                                                      def _as_items(self, iterable): from .items import Item + return self._item_yielder( iterable=iterable, item_func=lambda i: i, @@ -1063,18 +1056,18 @@

                                                                                                                                                                                                      Classes

                                                                                                                                                                                                      def _as_values(self, iterable): if not self.only_fields: - raise ValueError('values() requires at least one field name') + raise ValueError("values() requires at least one field name") return self._item_yielder( iterable=iterable, item_func=lambda i: {f.path: _get_value_or_default(f, i) for f in self.only_fields}, - id_only_func=lambda item_id, changekey: {'id': item_id}, - changekey_only_func=lambda item_id, changekey: {'changekey': changekey}, - id_and_changekey_func=lambda item_id, changekey: {'id': item_id, 'changekey': changekey}, + id_only_func=lambda item_id, changekey: {"id": item_id}, + changekey_only_func=lambda item_id, changekey: {"changekey": changekey}, + id_and_changekey_func=lambda item_id, changekey: {"id": item_id, "changekey": changekey}, ) def _as_values_list(self, iterable): if not self.only_fields: - raise ValueError('values_list() requires at least one field name') + raise ValueError("values_list() requires at least one field name") return self._item_yielder( iterable=iterable, item_func=lambda i: tuple(_get_value_or_default(f, i) for f in self.only_fields), @@ -1085,7 +1078,7 @@

                                                                                                                                                                                                      Classes

                                                                                                                                                                                                      def _as_flat_values_list(self, iterable): if not self.only_fields or len(self.only_fields) != 1: - raise ValueError('flat=True requires exactly one field name') + raise ValueError("flat=True requires exactly one field name") return self._item_yielder( iterable=iterable, item_func=lambda i: _get_value_or_default(self.only_fields[0], i), @@ -1161,7 +1154,7 @@

                                                                                                                                                                                                      Classes

                                                                                                                                                                                                      def reverse(self): """Reverses the ordering of the queryset.""" if not self.order_fields: - raise ValueError('Reversing only makes sense if there are order_by fields') + raise ValueError("Reversing only makes sense if there are order_by fields") new_qs = self._copy_self() for f in new_qs.order_fields: f.reverse = not f.reverse @@ -1181,11 +1174,11 @@

                                                                                                                                                                                                      Classes

                                                                                                                                                                                                      """Return the values of the specified field names as a list of lists. If called with flat=True and only one field name, returns a list of values. """ - flat = kwargs.pop('flat', False) + flat = kwargs.pop("flat", False) if kwargs: - raise AttributeError(f'Unknown kwargs: {kwargs}') + raise AttributeError(f"Unknown kwargs: {kwargs}") if flat and len(args) != 1: - raise ValueError('flat=True requires exactly one field name') + raise ValueError("flat=True requires exactly one field name") try: only_fields = tuple(self._get_field_path(arg) for arg in args) except ValueError as e: @@ -1212,12 +1205,12 @@

                                                                                                                                                                                                      Classes

                                                                                                                                                                                                      def get(self, *args, **kwargs): """Assume the query will return exactly one item. Return that item.""" - if not args and set(kwargs) in ({'id'}, {'id', 'changekey'}): + if not args and set(kwargs) in ({"id"}, {"id", "changekey"}): # We allow calling get(id=..., changekey=...) to get a single item, but only if exactly these two # kwargs are present. account = self.folder_collection.account - item_id = self._id_field.field.clean(kwargs['id'], version=account.version) - changekey = self._changekey_field.field.clean(kwargs.get('changekey'), version=account.version) + item_id = self._id_field.field.clean(kwargs["id"], version=account.version) + changekey = self._changekey_field.field.clean(kwargs.get("changekey"), version=account.version) items = list(account.fetch(ids=[(item_id, changekey)], only_fields=self.only_fields)) else: new_qs = self.filter(*args, **kwargs) @@ -1265,11 +1258,7 @@

                                                                                                                                                                                                      Classes

                                                                                                                                                                                                      """ ids = self._id_only_copy_self() ids.page_size = page_size - return self.folder_collection.account.bulk_delete( - ids=ids, - chunk_size=chunk_size, - **delete_kwargs - ) + return self.folder_collection.account.bulk_delete(ids=ids, chunk_size=chunk_size, **delete_kwargs) def send(self, page_size=1000, chunk_size=100, **send_kwargs): """Send the items matching the query, with as little effort as possible @@ -1281,11 +1270,7 @@

                                                                                                                                                                                                      Classes

                                                                                                                                                                                                      """ ids = self._id_only_copy_self() ids.page_size = page_size - return self.folder_collection.account.bulk_send( - ids=ids, - chunk_size=chunk_size, - **send_kwargs - ) + return self.folder_collection.account.bulk_send(ids=ids, chunk_size=chunk_size, **send_kwargs) def copy(self, to_folder, page_size=1000, chunk_size=100, **copy_kwargs): """Copy the items matching the query, with as little effort as possible @@ -1299,10 +1284,7 @@

                                                                                                                                                                                                      Classes

                                                                                                                                                                                                      ids = self._id_only_copy_self() ids.page_size = page_size return self.folder_collection.account.bulk_copy( - ids=ids, - to_folder=to_folder, - chunk_size=chunk_size, - **copy_kwargs + ids=ids, to_folder=to_folder, chunk_size=chunk_size, **copy_kwargs ) def move(self, to_folder, page_size=1000, chunk_size=100): @@ -1350,16 +1332,12 @@

                                                                                                                                                                                                      Classes

                                                                                                                                                                                                      """ ids = self._id_only_copy_self() ids.page_size = page_size - return self.folder_collection.account.bulk_mark_as_junk( - ids=ids, - chunk_size=chunk_size, - **mark_as_junk_kwargs - ) + return self.folder_collection.account.bulk_mark_as_junk(ids=ids, chunk_size=chunk_size, **mark_as_junk_kwargs) def __str__(self): - fmt_args = [('q', str(self.q)), ('folders', f"[{', '.join(str(f) for f in self.folder_collection.folders)}]")] - args_str = ', '.join(f'{k}={v}' for k, v in fmt_args) - return f'{self.__class__.__name__}({args_str})' + fmt_args = [("q", str(self.q)), ("folders", f"[{', '.join(str(f) for f in self.folder_collection.folders)}]")] + args_str = ", ".join(f"{k}={v}" for k, v in fmt_args) + return f"{self.__class__.__name__}({args_str})"

                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                        @@ -1460,10 +1438,7 @@

                                                                                                                                                                                                        Methods

                                                                                                                                                                                                        ids = self._id_only_copy_self() ids.page_size = page_size return self.folder_collection.account.bulk_copy( - ids=ids, - to_folder=to_folder, - chunk_size=chunk_size, - **copy_kwargs + ids=ids, to_folder=to_folder, chunk_size=chunk_size, **copy_kwargs ) @@ -1516,11 +1491,7 @@

                                                                                                                                                                                                        Methods

                                                                                                                                                                                                        """ ids = self._id_only_copy_self() ids.page_size = page_size - return self.folder_collection.account.bulk_delete( - ids=ids, - chunk_size=chunk_size, - **delete_kwargs - ) + return self.folder_collection.account.bulk_delete(ids=ids, chunk_size=chunk_size, **delete_kwargs)
                                                                                                                                                                                                        @@ -1570,12 +1541,12 @@

                                                                                                                                                                                                        Methods

                                                                                                                                                                                                        def get(self, *args, **kwargs):
                                                                                                                                                                                                             """Assume the query will return exactly one item. Return that item."""
                                                                                                                                                                                                        -    if not args and set(kwargs) in ({'id'}, {'id', 'changekey'}):
                                                                                                                                                                                                        +    if not args and set(kwargs) in ({"id"}, {"id", "changekey"}):
                                                                                                                                                                                                                 # We allow calling get(id=..., changekey=...) to get a single item, but only if exactly these two
                                                                                                                                                                                                                 # kwargs are present.
                                                                                                                                                                                                                 account = self.folder_collection.account
                                                                                                                                                                                                        -        item_id = self._id_field.field.clean(kwargs['id'], version=account.version)
                                                                                                                                                                                                        -        changekey = self._changekey_field.field.clean(kwargs.get('changekey'), version=account.version)
                                                                                                                                                                                                        +        item_id = self._id_field.field.clean(kwargs["id"], version=account.version)
                                                                                                                                                                                                        +        changekey = self._changekey_field.field.clean(kwargs.get("changekey"), version=account.version)
                                                                                                                                                                                                                 items = list(account.fetch(ids=[(item_id, changekey)], only_fields=self.only_fields))
                                                                                                                                                                                                             else:
                                                                                                                                                                                                                 new_qs = self.filter(*args, **kwargs)
                                                                                                                                                                                                        @@ -1612,11 +1583,7 @@ 

                                                                                                                                                                                                        Methods

                                                                                                                                                                                                        """ ids = self._id_only_copy_self() ids.page_size = page_size - return self.folder_collection.account.bulk_mark_as_junk( - ids=ids, - chunk_size=chunk_size, - **mark_as_junk_kwargs - )
                                                                                                                                                                                                        + return self.folder_collection.account.bulk_mark_as_junk(ids=ids, chunk_size=chunk_size, **mark_as_junk_kwargs)
                                                                                                                                                                                                        @@ -1724,7 +1691,7 @@

                                                                                                                                                                                                        Methods

                                                                                                                                                                                                        def reverse(self):
                                                                                                                                                                                                             """Reverses the ordering of the queryset."""
                                                                                                                                                                                                             if not self.order_fields:
                                                                                                                                                                                                        -        raise ValueError('Reversing only makes sense if there are order_by fields')
                                                                                                                                                                                                        +        raise ValueError("Reversing only makes sense if there are order_by fields")
                                                                                                                                                                                                             new_qs = self._copy_self()
                                                                                                                                                                                                             for f in new_qs.order_fields:
                                                                                                                                                                                                                 f.reverse = not f.reverse
                                                                                                                                                                                                        @@ -1754,11 +1721,7 @@ 

                                                                                                                                                                                                        Methods

                                                                                                                                                                                                        """ ids = self._id_only_copy_self() ids.page_size = page_size - return self.folder_collection.account.bulk_send( - ids=ids, - chunk_size=chunk_size, - **send_kwargs - )
                                                                                                                                                                                                        + return self.folder_collection.account.bulk_send(ids=ids, chunk_size=chunk_size, **send_kwargs)
                                                                                                                                                                                                        @@ -1795,11 +1758,11 @@

                                                                                                                                                                                                        Methods

                                                                                                                                                                                                        """Return the values of the specified field names as a list of lists. If called with flat=True and only one field name, returns a list of values. """ - flat = kwargs.pop('flat', False) + flat = kwargs.pop("flat", False) if kwargs: - raise AttributeError(f'Unknown kwargs: {kwargs}') + raise AttributeError(f"Unknown kwargs: {kwargs}") if flat and len(args) != 1: - raise ValueError('flat=True requires exactly one field name') + raise ValueError("flat=True requires exactly one field name") try: only_fields = tuple(self._get_field_path(arg) for arg in args) except ValueError as e: diff --git a/docs/exchangelib/recurrence.html b/docs/exchangelib/recurrence.html index b769ddf6..1f9717d6 100644 --- a/docs/exchangelib/recurrence.html +++ b/docs/exchangelib/recurrence.html @@ -28,15 +28,26 @@

                                                                                                                                                                                                        Module exchangelib.recurrence

                                                                                                                                                                                                        import logging
                                                                                                                                                                                                         
                                                                                                                                                                                                        -from .fields import IntegerField, EnumField, WeekdaysField, DateOrDateTimeField, DateTimeField, EWSElementField, \
                                                                                                                                                                                                        -    IdElementField, MONTHS, WEEK_NUMBERS, WEEKDAYS, WEEKDAY_NAMES
                                                                                                                                                                                                        -from .properties import EWSElement, IdChangeKeyMixIn, ItemId, EWSMeta
                                                                                                                                                                                                        +from .fields import (
                                                                                                                                                                                                        +    MONTHS,
                                                                                                                                                                                                        +    WEEK_NUMBERS,
                                                                                                                                                                                                        +    WEEKDAY_NAMES,
                                                                                                                                                                                                        +    WEEKDAYS,
                                                                                                                                                                                                        +    DateOrDateTimeField,
                                                                                                                                                                                                        +    DateTimeField,
                                                                                                                                                                                                        +    EnumField,
                                                                                                                                                                                                        +    EWSElementField,
                                                                                                                                                                                                        +    IdElementField,
                                                                                                                                                                                                        +    IntegerField,
                                                                                                                                                                                                        +    WeekdaysField,
                                                                                                                                                                                                        +)
                                                                                                                                                                                                        +from .properties import EWSElement, EWSMeta, IdChangeKeyMixIn, ItemId
                                                                                                                                                                                                         
                                                                                                                                                                                                         log = logging.getLogger(__name__)
                                                                                                                                                                                                         
                                                                                                                                                                                                         
                                                                                                                                                                                                         def _month_to_str(month):
                                                                                                                                                                                                        -    return MONTHS[month-1] if isinstance(month, int) else month
                                                                                                                                                                                                        +    return MONTHS[month - 1] if isinstance(month, int) else month
                                                                                                                                                                                                         
                                                                                                                                                                                                         
                                                                                                                                                                                                         def _weekday_to_str(weekday):
                                                                                                                                                                                                        @@ -60,16 +71,16 @@ 

                                                                                                                                                                                                        Module exchangelib.recurrence

                                                                                                                                                                                                        https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/absoluteyearlyrecurrence """ - ELEMENT_NAME = 'AbsoluteYearlyRecurrence' + ELEMENT_NAME = "AbsoluteYearlyRecurrence" # The day of month of an occurrence, in range 1 -> 31. If a particular month has less days than the day_of_month # value, the last day in the month is assumed - day_of_month = IntegerField(field_uri='DayOfMonth', min=1, max=31, is_required=True) + day_of_month = IntegerField(field_uri="DayOfMonth", min=1, max=31, is_required=True) # The month of the year, from 1 - 12 - month = EnumField(field_uri='Month', enum=MONTHS, is_required=True) + month = EnumField(field_uri="Month", enum=MONTHS, is_required=True) def __str__(self): - return f'Occurs on day {self.day_of_month} of {_month_to_str(self.month)}' + return f"Occurs on day {self.day_of_month} of {_month_to_str(self.month)}" class RelativeYearlyPattern(Pattern): @@ -77,21 +88,23 @@

                                                                                                                                                                                                        Module exchangelib.recurrence

                                                                                                                                                                                                        https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/relativeyearlyrecurrence """ - ELEMENT_NAME = 'RelativeYearlyRecurrence' + ELEMENT_NAME = "RelativeYearlyRecurrence" # Valid ISO 8601 weekday, as a number in range 1 -> 7 (1 being Monday). The value can also be one of the DAY # (or 8), WEEK_DAY (or 9) or WEEKEND_DAY (or 10) consts which is interpreted as the first day, weekday, or weekend # day of the year. Despite the field name in EWS, this is not a list. - weekday = EnumField(field_uri='DaysOfWeek', enum=WEEKDAYS, is_required=True) + weekday = EnumField(field_uri="DaysOfWeek", enum=WEEKDAYS, is_required=True) # Week number of the month, in range 1 -> 5. If 5 is specified, this assumes the last week of the month for # months that have only 4 weeks - week_number = EnumField(field_uri='DayOfWeekIndex', enum=WEEK_NUMBERS, is_required=True) + week_number = EnumField(field_uri="DayOfWeekIndex", enum=WEEK_NUMBERS, is_required=True) # The month of the year, from 1 - 12 - month = EnumField(field_uri='Month', enum=MONTHS, is_required=True) + month = EnumField(field_uri="Month", enum=MONTHS, is_required=True) def __str__(self): - return f'Occurs on weekday {_weekday_to_str(self.weekday)} in the {_week_number_to_str(self.week_number)} ' \ - f'week of {_month_to_str(self.month)}' + return ( + f"Occurs on weekday {_weekday_to_str(self.weekday)} in the {_week_number_to_str(self.week_number)} " + f"week of {_month_to_str(self.month)}" + ) class AbsoluteMonthlyPattern(Pattern): @@ -99,16 +112,16 @@

                                                                                                                                                                                                        Module exchangelib.recurrence

                                                                                                                                                                                                        https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/absolutemonthlyrecurrence """ - ELEMENT_NAME = 'AbsoluteMonthlyRecurrence' + ELEMENT_NAME = "AbsoluteMonthlyRecurrence" # Interval, in months, in range 1 -> 99 - interval = IntegerField(field_uri='Interval', min=1, max=99, is_required=True) + interval = IntegerField(field_uri="Interval", min=1, max=99, is_required=True) # The day of month of an occurrence, in range 1 -> 31. If a particular month has less days than the day_of_month # value, the last day in the month is assumed - day_of_month = IntegerField(field_uri='DayOfMonth', min=1, max=31, is_required=True) + day_of_month = IntegerField(field_uri="DayOfMonth", min=1, max=31, is_required=True) def __str__(self): - return f'Occurs on day {self.day_of_month} of every {self.interval} month(s)' + return f"Occurs on day {self.day_of_month} of every {self.interval} month(s)" class RelativeMonthlyPattern(Pattern): @@ -116,99 +129,103 @@

                                                                                                                                                                                                        Module exchangelib.recurrence

                                                                                                                                                                                                        https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/relativemonthlyrecurrence """ - ELEMENT_NAME = 'RelativeMonthlyRecurrence' + ELEMENT_NAME = "RelativeMonthlyRecurrence" # Interval, in months, in range 1 -> 99 - interval = IntegerField(field_uri='Interval', min=1, max=99, is_required=True) + interval = IntegerField(field_uri="Interval", min=1, max=99, is_required=True) # Valid ISO 8601 weekday, as a number in range 1 -> 7 (1 being Monday). The value can also be one of the DAY # (or 8), WEEK_DAY (or 9) or WEEKEND_DAY (or 10) consts which is interpreted as the first day, weekday, or weekend # day of the month. Despite the field name in EWS, this is not a list. - weekday = EnumField(field_uri='DaysOfWeek', enum=WEEKDAYS, is_required=True) + weekday = EnumField(field_uri="DaysOfWeek", enum=WEEKDAYS, is_required=True) # Week number of the month, in range 1 -> 5. If 5 is specified, this assumes the last week of the month for # months that have only 4 weeks. - week_number = EnumField(field_uri='DayOfWeekIndex', enum=WEEK_NUMBERS, is_required=True) + week_number = EnumField(field_uri="DayOfWeekIndex", enum=WEEK_NUMBERS, is_required=True) def __str__(self): - return f'Occurs on weekday {_weekday_to_str(self.weekday)} in the {_week_number_to_str(self.week_number)} ' \ - f'week of every {self.interval} month(s)' + return ( + f"Occurs on weekday {_weekday_to_str(self.weekday)} in the {_week_number_to_str(self.week_number)} " + f"week of every {self.interval} month(s)" + ) class WeeklyPattern(Pattern): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/weeklyrecurrence""" - ELEMENT_NAME = 'WeeklyRecurrence' + ELEMENT_NAME = "WeeklyRecurrence" # Interval, in weeks, in range 1 -> 99 - interval = IntegerField(field_uri='Interval', min=1, max=99, is_required=True) + interval = IntegerField(field_uri="Interval", min=1, max=99, is_required=True) # List of valid ISO 8601 weekdays, as list of numbers in range 1 -> 7 (1 being Monday) - weekdays = WeekdaysField(field_uri='DaysOfWeek', enum=WEEKDAY_NAMES, is_required=True) + weekdays = WeekdaysField(field_uri="DaysOfWeek", enum=WEEKDAY_NAMES, is_required=True) # The first day of the week. Defaults to Monday - first_day_of_week = EnumField(field_uri='FirstDayOfWeek', enum=WEEKDAY_NAMES, default=1, is_required=True) + first_day_of_week = EnumField(field_uri="FirstDayOfWeek", enum=WEEKDAY_NAMES, default=1, is_required=True) def __str__(self): - weekdays = [_weekday_to_str(i) for i in self.get_field_by_fieldname('weekdays').clean(self.weekdays)] - return f'Occurs on weekdays {", ".join(weekdays)} of every {self.interval} week(s) where the first day of ' \ - f'the week is {_weekday_to_str(self.first_day_of_week)}' + weekdays = [_weekday_to_str(i) for i in self.get_field_by_fieldname("weekdays").clean(self.weekdays)] + return ( + f'Occurs on weekdays {", ".join(weekdays)} of every {self.interval} week(s) where the first day of ' + f"the week is {_weekday_to_str(self.first_day_of_week)}" + ) class DailyPattern(Pattern): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/dailyrecurrence""" - ELEMENT_NAME = 'DailyRecurrence' + ELEMENT_NAME = "DailyRecurrence" # Interval, in days, in range 1 -> 999 - interval = IntegerField(field_uri='Interval', min=1, max=999, is_required=True) + interval = IntegerField(field_uri="Interval", min=1, max=999, is_required=True) def __str__(self): - return f'Occurs every {self.interval} day(s)' + return f"Occurs every {self.interval} day(s)" class YearlyRegeneration(Regeneration): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/yearlyregeneration""" - ELEMENT_NAME = 'YearlyRegeneration' + ELEMENT_NAME = "YearlyRegeneration" # Interval, in years - interval = IntegerField(field_uri='Interval', min=1, is_required=True) + interval = IntegerField(field_uri="Interval", min=1, is_required=True) def __str__(self): - return f'Regenerates every {self.interval} year(s)' + return f"Regenerates every {self.interval} year(s)" class MonthlyRegeneration(Regeneration): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/monthlyregeneration""" - ELEMENT_NAME = 'MonthlyRegeneration' + ELEMENT_NAME = "MonthlyRegeneration" # Interval, in months - interval = IntegerField(field_uri='Interval', min=1, is_required=True) + interval = IntegerField(field_uri="Interval", min=1, is_required=True) def __str__(self): - return f'Regenerates every {self.interval} month(s)' + return f"Regenerates every {self.interval} month(s)" class WeeklyRegeneration(Regeneration): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/weeklyregeneration""" - ELEMENT_NAME = 'WeeklyRegeneration' + ELEMENT_NAME = "WeeklyRegeneration" # Interval, in weeks - interval = IntegerField(field_uri='Interval', min=1, is_required=True) + interval = IntegerField(field_uri="Interval", min=1, is_required=True) def __str__(self): - return f'Regenerates every {self.interval} week(s)' + return f"Regenerates every {self.interval} week(s)" class DailyRegeneration(Regeneration): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/dailyregeneration""" - ELEMENT_NAME = 'DailyRegeneration' + ELEMENT_NAME = "DailyRegeneration" # Interval, in days - interval = IntegerField(field_uri='Interval', min=1, is_required=True) + interval = IntegerField(field_uri="Interval", min=1, is_required=True) def __str__(self): - return f'Regenerates every {self.interval} day(s)' + return f"Regenerates every {self.interval} day(s)" class Boundary(EWSElement, metaclass=EWSMeta): @@ -218,56 +235,56 @@

                                                                                                                                                                                                        Module exchangelib.recurrence

                                                                                                                                                                                                        class NoEndPattern(Boundary): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/noendrecurrence""" - ELEMENT_NAME = 'NoEndRecurrence' + ELEMENT_NAME = "NoEndRecurrence" # Start date, as EWSDate or EWSDateTime - start = DateOrDateTimeField(field_uri='StartDate', is_required=True) + start = DateOrDateTimeField(field_uri="StartDate", is_required=True) def __str__(self): - return f'Starts on {self.start}' + return f"Starts on {self.start}" class EndDatePattern(Boundary): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/enddaterecurrence""" - ELEMENT_NAME = 'EndDateRecurrence' + ELEMENT_NAME = "EndDateRecurrence" # Start date, as EWSDate or EWSDateTime - start = DateOrDateTimeField(field_uri='StartDate', is_required=True) + start = DateOrDateTimeField(field_uri="StartDate", is_required=True) # End date, as EWSDate - end = DateOrDateTimeField(field_uri='EndDate', is_required=True) + end = DateOrDateTimeField(field_uri="EndDate", is_required=True) def __str__(self): - return f'Starts on {self.start}, ends on {self.end}' + return f"Starts on {self.start}, ends on {self.end}" class NumberedPattern(Boundary): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/numberedrecurrence""" - ELEMENT_NAME = 'NumberedRecurrence' + ELEMENT_NAME = "NumberedRecurrence" # Start date, as EWSDate or EWSDateTime - start = DateOrDateTimeField(field_uri='StartDate', is_required=True) + start = DateOrDateTimeField(field_uri="StartDate", is_required=True) # The number of occurrences in this pattern, in range 1 -> 999 - number = IntegerField(field_uri='NumberOfOccurrences', min=1, max=999, is_required=True) + number = IntegerField(field_uri="NumberOfOccurrences", min=1, max=999, is_required=True) def __str__(self): - return f'Starts on {self.start} and occurs {self.number} time(s)' + return f"Starts on {self.start} and occurs {self.number} time(s)" class Occurrence(IdChangeKeyMixIn): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/occurrence""" - ELEMENT_NAME = 'Occurrence' + ELEMENT_NAME = "Occurrence" ID_ELEMENT_CLS = ItemId - _id = IdElementField(field_uri='ItemId', value_cls=ID_ELEMENT_CLS) + _id = IdElementField(field_uri="ItemId", value_cls=ID_ELEMENT_CLS) # The modified start time of the item, as EWSDateTime - start = DateTimeField(field_uri='Start') + start = DateTimeField(field_uri="Start") # The modified end time of the item, as EWSDateTime - end = DateTimeField(field_uri='End') + end = DateTimeField(field_uri="End") # The original start time of the item, as EWSDateTime - original_start = DateTimeField(field_uri='OriginalStart') + original_start = DateTimeField(field_uri="OriginalStart") # Container elements: @@ -278,26 +295,32 @@

                                                                                                                                                                                                        Module exchangelib.recurrence

                                                                                                                                                                                                        class FirstOccurrence(Occurrence): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/firstoccurrence""" - ELEMENT_NAME = 'FirstOccurrence' + ELEMENT_NAME = "FirstOccurrence" class LastOccurrence(Occurrence): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/lastoccurrence""" - ELEMENT_NAME = 'LastOccurrence' + ELEMENT_NAME = "LastOccurrence" class DeletedOccurrence(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deletedoccurrence""" - ELEMENT_NAME = 'DeletedOccurrence' + ELEMENT_NAME = "DeletedOccurrence" # The modified start time of the item, as EWSDateTime - start = DateTimeField(field_uri='Start') + start = DateTimeField(field_uri="Start") -PATTERN_CLASSES = AbsoluteYearlyPattern, RelativeYearlyPattern, AbsoluteMonthlyPattern, RelativeMonthlyPattern, \ - WeeklyPattern, DailyPattern +PATTERN_CLASSES = ( + AbsoluteYearlyPattern, + RelativeYearlyPattern, + AbsoluteMonthlyPattern, + RelativeMonthlyPattern, + WeeklyPattern, + DailyPattern, +) REGENERATION_CLASSES = YearlyRegeneration, MonthlyRegeneration, WeeklyRegeneration, DailyRegeneration BOUNDARY_CLASSES = NoEndPattern, EndDatePattern, NumberedPattern @@ -307,7 +330,7 @@

                                                                                                                                                                                                        Module exchangelib.recurrence

                                                                                                                                                                                                        https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recurrence-recurrencetype """ - ELEMENT_NAME = 'Recurrence' + ELEMENT_NAME = "Recurrence" PATTERN_CLASS_MAP = {cls.response_tag(): cls for cls in PATTERN_CLASSES} BOUNDARY_CLASS_MAP = {cls.response_tag(): cls for cls in BOUNDARY_CLASSES} @@ -316,18 +339,18 @@

                                                                                                                                                                                                        Module exchangelib.recurrence

                                                                                                                                                                                                        def __init__(self, **kwargs): # Allow specifying a start, end and/or number as a shortcut to creating a boundary - start = kwargs.pop('start', None) - end = kwargs.pop('end', None) - number = kwargs.pop('number', None) + start = kwargs.pop("start", None) + end = kwargs.pop("end", None) + number = kwargs.pop("number", None) if any([start, end, number]): - if 'boundary' in kwargs: + if "boundary" in kwargs: raise ValueError("'boundary' is not allowed in combination with 'start', 'end' or 'number'") if start and not end and not number: - kwargs['boundary'] = NoEndPattern(start=start) + kwargs["boundary"] = NoEndPattern(start=start) elif start and end and not number: - kwargs['boundary'] = EndDatePattern(start=start, end=end) + kwargs["boundary"] = EndDatePattern(start=start, end=end) elif start and number and not end: - kwargs['boundary'] = NumberedPattern(start=start, number=number) + kwargs["boundary"] = NumberedPattern(start=start, number=number) else: raise ValueError("Unsupported 'start', 'end', 'number' combination") super().__init__(**kwargs) @@ -343,7 +366,7 @@

                                                                                                                                                                                                        Module exchangelib.recurrence

                                                                                                                                                                                                        return cls(pattern=pattern, boundary=boundary) def __str__(self): - return f'Pattern: {self.pattern}, Boundary: {self.boundary}' + return f"Pattern: {self.pattern}, Boundary: {self.boundary}" class TaskRecurrence(Recurrence): @@ -379,16 +402,16 @@

                                                                                                                                                                                                        Classes

                                                                                                                                                                                                        https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/absolutemonthlyrecurrence """ - ELEMENT_NAME = 'AbsoluteMonthlyRecurrence' + ELEMENT_NAME = "AbsoluteMonthlyRecurrence" # Interval, in months, in range 1 -> 99 - interval = IntegerField(field_uri='Interval', min=1, max=99, is_required=True) + interval = IntegerField(field_uri="Interval", min=1, max=99, is_required=True) # The day of month of an occurrence, in range 1 -> 31. If a particular month has less days than the day_of_month # value, the last day in the month is assumed - day_of_month = IntegerField(field_uri='DayOfMonth', min=1, max=31, is_required=True) + day_of_month = IntegerField(field_uri="DayOfMonth", min=1, max=31, is_required=True) def __str__(self): - return f'Occurs on day {self.day_of_month} of every {self.interval} month(s)'
                                                                                                                                                                                                        + return f"Occurs on day {self.day_of_month} of every {self.interval} month(s)"

                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                          @@ -445,16 +468,16 @@

                                                                                                                                                                                                          Inherited members

                                                                                                                                                                                                          https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/absoluteyearlyrecurrence """ - ELEMENT_NAME = 'AbsoluteYearlyRecurrence' + ELEMENT_NAME = "AbsoluteYearlyRecurrence" # The day of month of an occurrence, in range 1 -> 31. If a particular month has less days than the day_of_month # value, the last day in the month is assumed - day_of_month = IntegerField(field_uri='DayOfMonth', min=1, max=31, is_required=True) + day_of_month = IntegerField(field_uri="DayOfMonth", min=1, max=31, is_required=True) # The month of the year, from 1 - 12 - month = EnumField(field_uri='Month', enum=MONTHS, is_required=True) + month = EnumField(field_uri="Month", enum=MONTHS, is_required=True) def __str__(self): - return f'Occurs on day {self.day_of_month} of {_month_to_str(self.month)}' + return f"Occurs on day {self.day_of_month} of {_month_to_str(self.month)}"

                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                            @@ -543,13 +566,13 @@

                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                            class DailyPattern(Pattern):
                                                                                                                                                                                                                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/dailyrecurrence"""
                                                                                                                                                                                                             
                                                                                                                                                                                                            -    ELEMENT_NAME = 'DailyRecurrence'
                                                                                                                                                                                                            +    ELEMENT_NAME = "DailyRecurrence"
                                                                                                                                                                                                             
                                                                                                                                                                                                                 # Interval, in days, in range 1 -> 999
                                                                                                                                                                                                            -    interval = IntegerField(field_uri='Interval', min=1, max=999, is_required=True)
                                                                                                                                                                                                            +    interval = IntegerField(field_uri="Interval", min=1, max=999, is_required=True)
                                                                                                                                                                                                             
                                                                                                                                                                                                                 def __str__(self):
                                                                                                                                                                                                            -        return f'Occurs every {self.interval} day(s)'
                                                                                                                                                                                                            + return f"Occurs every {self.interval} day(s)"

                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                              @@ -599,13 +622,13 @@

                                                                                                                                                                                                              Inherited members

                                                                                                                                                                                                              class DailyRegeneration(Regeneration):
                                                                                                                                                                                                                   """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/dailyregeneration"""
                                                                                                                                                                                                               
                                                                                                                                                                                                              -    ELEMENT_NAME = 'DailyRegeneration'
                                                                                                                                                                                                              +    ELEMENT_NAME = "DailyRegeneration"
                                                                                                                                                                                                               
                                                                                                                                                                                                                   # Interval, in days
                                                                                                                                                                                                              -    interval = IntegerField(field_uri='Interval', min=1, is_required=True)
                                                                                                                                                                                                              +    interval = IntegerField(field_uri="Interval", min=1, is_required=True)
                                                                                                                                                                                                               
                                                                                                                                                                                                                   def __str__(self):
                                                                                                                                                                                                              -        return f'Regenerates every {self.interval} day(s)'
                                                                                                                                                                                                              + return f"Regenerates every {self.interval} day(s)"

                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                @@ -656,10 +679,10 @@

                                                                                                                                                                                                                Inherited members

                                                                                                                                                                                                                class DeletedOccurrence(EWSElement):
                                                                                                                                                                                                                     """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deletedoccurrence"""
                                                                                                                                                                                                                 
                                                                                                                                                                                                                -    ELEMENT_NAME = 'DeletedOccurrence'
                                                                                                                                                                                                                +    ELEMENT_NAME = "DeletedOccurrence"
                                                                                                                                                                                                                 
                                                                                                                                                                                                                     # The modified start time of the item, as EWSDateTime
                                                                                                                                                                                                                -    start = DateTimeField(field_uri='Start')
                                                                                                                                                                                                                + start = DateTimeField(field_uri="Start")

                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                  @@ -708,15 +731,15 @@

                                                                                                                                                                                                                  Inherited members

                                                                                                                                                                                                                  class EndDatePattern(Boundary):
                                                                                                                                                                                                                       """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/enddaterecurrence"""
                                                                                                                                                                                                                   
                                                                                                                                                                                                                  -    ELEMENT_NAME = 'EndDateRecurrence'
                                                                                                                                                                                                                  +    ELEMENT_NAME = "EndDateRecurrence"
                                                                                                                                                                                                                   
                                                                                                                                                                                                                       # Start date, as EWSDate or EWSDateTime
                                                                                                                                                                                                                  -    start = DateOrDateTimeField(field_uri='StartDate', is_required=True)
                                                                                                                                                                                                                  +    start = DateOrDateTimeField(field_uri="StartDate", is_required=True)
                                                                                                                                                                                                                       # End date, as EWSDate
                                                                                                                                                                                                                  -    end = DateOrDateTimeField(field_uri='EndDate', is_required=True)
                                                                                                                                                                                                                  +    end = DateOrDateTimeField(field_uri="EndDate", is_required=True)
                                                                                                                                                                                                                   
                                                                                                                                                                                                                       def __str__(self):
                                                                                                                                                                                                                  -        return f'Starts on {self.start}, ends on {self.end}'
                                                                                                                                                                                                                  + return f"Starts on {self.start}, ends on {self.end}"

                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                    @@ -770,7 +793,7 @@

                                                                                                                                                                                                                    Inherited members

                                                                                                                                                                                                                    class FirstOccurrence(Occurrence):
                                                                                                                                                                                                                         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/firstoccurrence"""
                                                                                                                                                                                                                     
                                                                                                                                                                                                                    -    ELEMENT_NAME = 'FirstOccurrence'
                                                                                                                                                                                                                    + ELEMENT_NAME = "FirstOccurrence"

                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                      @@ -811,7 +834,7 @@

                                                                                                                                                                                                                      Inherited members

                                                                                                                                                                                                                      class LastOccurrence(Occurrence):
                                                                                                                                                                                                                           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/lastoccurrence"""
                                                                                                                                                                                                                       
                                                                                                                                                                                                                      -    ELEMENT_NAME = 'LastOccurrence'
                                                                                                                                                                                                                      + ELEMENT_NAME = "LastOccurrence"

                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                        @@ -852,13 +875,13 @@

                                                                                                                                                                                                                        Inherited members

                                                                                                                                                                                                                        class MonthlyRegeneration(Regeneration):
                                                                                                                                                                                                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/monthlyregeneration"""
                                                                                                                                                                                                                         
                                                                                                                                                                                                                        -    ELEMENT_NAME = 'MonthlyRegeneration'
                                                                                                                                                                                                                        +    ELEMENT_NAME = "MonthlyRegeneration"
                                                                                                                                                                                                                         
                                                                                                                                                                                                                             # Interval, in months
                                                                                                                                                                                                                        -    interval = IntegerField(field_uri='Interval', min=1, is_required=True)
                                                                                                                                                                                                                        +    interval = IntegerField(field_uri="Interval", min=1, is_required=True)
                                                                                                                                                                                                                         
                                                                                                                                                                                                                             def __str__(self):
                                                                                                                                                                                                                        -        return f'Regenerates every {self.interval} month(s)'
                                                                                                                                                                                                                        + return f"Regenerates every {self.interval} month(s)"

                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                          @@ -909,13 +932,13 @@

                                                                                                                                                                                                                          Inherited members

                                                                                                                                                                                                                          class NoEndPattern(Boundary):
                                                                                                                                                                                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/noendrecurrence"""
                                                                                                                                                                                                                           
                                                                                                                                                                                                                          -    ELEMENT_NAME = 'NoEndRecurrence'
                                                                                                                                                                                                                          +    ELEMENT_NAME = "NoEndRecurrence"
                                                                                                                                                                                                                           
                                                                                                                                                                                                                               # Start date, as EWSDate or EWSDateTime
                                                                                                                                                                                                                          -    start = DateOrDateTimeField(field_uri='StartDate', is_required=True)
                                                                                                                                                                                                                          +    start = DateOrDateTimeField(field_uri="StartDate", is_required=True)
                                                                                                                                                                                                                           
                                                                                                                                                                                                                               def __str__(self):
                                                                                                                                                                                                                          -        return f'Starts on {self.start}'
                                                                                                                                                                                                                          + return f"Starts on {self.start}"

                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                            @@ -965,15 +988,15 @@

                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                            class NumberedPattern(Boundary):
                                                                                                                                                                                                                                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/numberedrecurrence"""
                                                                                                                                                                                                                             
                                                                                                                                                                                                                            -    ELEMENT_NAME = 'NumberedRecurrence'
                                                                                                                                                                                                                            +    ELEMENT_NAME = "NumberedRecurrence"
                                                                                                                                                                                                                             
                                                                                                                                                                                                                                 # Start date, as EWSDate or EWSDateTime
                                                                                                                                                                                                                            -    start = DateOrDateTimeField(field_uri='StartDate', is_required=True)
                                                                                                                                                                                                                            +    start = DateOrDateTimeField(field_uri="StartDate", is_required=True)
                                                                                                                                                                                                                                 # The number of occurrences in this pattern, in range 1 -> 999
                                                                                                                                                                                                                            -    number = IntegerField(field_uri='NumberOfOccurrences', min=1, max=999, is_required=True)
                                                                                                                                                                                                                            +    number = IntegerField(field_uri="NumberOfOccurrences", min=1, max=999, is_required=True)
                                                                                                                                                                                                                             
                                                                                                                                                                                                                                 def __str__(self):
                                                                                                                                                                                                                            -        return f'Starts on {self.start} and occurs {self.number} time(s)'
                                                                                                                                                                                                                            + return f"Starts on {self.start} and occurs {self.number} time(s)"

                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                              @@ -1027,16 +1050,16 @@

                                                                                                                                                                                                                              Inherited members

                                                                                                                                                                                                                              class Occurrence(IdChangeKeyMixIn):
                                                                                                                                                                                                                                   """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/occurrence"""
                                                                                                                                                                                                                               
                                                                                                                                                                                                                              -    ELEMENT_NAME = 'Occurrence'
                                                                                                                                                                                                                              +    ELEMENT_NAME = "Occurrence"
                                                                                                                                                                                                                                   ID_ELEMENT_CLS = ItemId
                                                                                                                                                                                                                               
                                                                                                                                                                                                                              -    _id = IdElementField(field_uri='ItemId', value_cls=ID_ELEMENT_CLS)
                                                                                                                                                                                                                              +    _id = IdElementField(field_uri="ItemId", value_cls=ID_ELEMENT_CLS)
                                                                                                                                                                                                                                   # The modified start time of the item, as EWSDateTime
                                                                                                                                                                                                                              -    start = DateTimeField(field_uri='Start')
                                                                                                                                                                                                                              +    start = DateTimeField(field_uri="Start")
                                                                                                                                                                                                                                   # The modified end time of the item, as EWSDateTime
                                                                                                                                                                                                                              -    end = DateTimeField(field_uri='End')
                                                                                                                                                                                                                              +    end = DateTimeField(field_uri="End")
                                                                                                                                                                                                                                   # The original start time of the item, as EWSDateTime
                                                                                                                                                                                                                              -    original_start = DateTimeField(field_uri='OriginalStart')
                                                                                                                                                                                                                              + original_start = DateTimeField(field_uri="OriginalStart")

                                                                                                                                                                                                                              Ancestors

                                                                                                                                                                                                                                @@ -1146,7 +1169,7 @@

                                                                                                                                                                                                                                Inherited members

                                                                                                                                                                                                                                https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/recurrence-recurrencetype """ - ELEMENT_NAME = 'Recurrence' + ELEMENT_NAME = "Recurrence" PATTERN_CLASS_MAP = {cls.response_tag(): cls for cls in PATTERN_CLASSES} BOUNDARY_CLASS_MAP = {cls.response_tag(): cls for cls in BOUNDARY_CLASSES} @@ -1155,18 +1178,18 @@

                                                                                                                                                                                                                                Inherited members

                                                                                                                                                                                                                                def __init__(self, **kwargs): # Allow specifying a start, end and/or number as a shortcut to creating a boundary - start = kwargs.pop('start', None) - end = kwargs.pop('end', None) - number = kwargs.pop('number', None) + start = kwargs.pop("start", None) + end = kwargs.pop("end", None) + number = kwargs.pop("number", None) if any([start, end, number]): - if 'boundary' in kwargs: + if "boundary" in kwargs: raise ValueError("'boundary' is not allowed in combination with 'start', 'end' or 'number'") if start and not end and not number: - kwargs['boundary'] = NoEndPattern(start=start) + kwargs["boundary"] = NoEndPattern(start=start) elif start and end and not number: - kwargs['boundary'] = EndDatePattern(start=start, end=end) + kwargs["boundary"] = EndDatePattern(start=start, end=end) elif start and number and not end: - kwargs['boundary'] = NumberedPattern(start=start, number=number) + kwargs["boundary"] = NumberedPattern(start=start, number=number) else: raise ValueError("Unsupported 'start', 'end', 'number' combination") super().__init__(**kwargs) @@ -1182,7 +1205,7 @@

                                                                                                                                                                                                                                Inherited members

                                                                                                                                                                                                                                return cls(pattern=pattern, boundary=boundary) def __str__(self): - return f'Pattern: {self.pattern}, Boundary: {self.boundary}' + return f"Pattern: {self.pattern}, Boundary: {self.boundary}"

                                                                                                                                                                                                                                Ancestors

                                                                                                                                                                                                                                  @@ -1310,21 +1333,23 @@

                                                                                                                                                                                                                                  Inherited members

                                                                                                                                                                                                                                  https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/relativemonthlyrecurrence """ - ELEMENT_NAME = 'RelativeMonthlyRecurrence' + ELEMENT_NAME = "RelativeMonthlyRecurrence" # Interval, in months, in range 1 -> 99 - interval = IntegerField(field_uri='Interval', min=1, max=99, is_required=True) + interval = IntegerField(field_uri="Interval", min=1, max=99, is_required=True) # Valid ISO 8601 weekday, as a number in range 1 -> 7 (1 being Monday). The value can also be one of the DAY # (or 8), WEEK_DAY (or 9) or WEEKEND_DAY (or 10) consts which is interpreted as the first day, weekday, or weekend # day of the month. Despite the field name in EWS, this is not a list. - weekday = EnumField(field_uri='DaysOfWeek', enum=WEEKDAYS, is_required=True) + weekday = EnumField(field_uri="DaysOfWeek", enum=WEEKDAYS, is_required=True) # Week number of the month, in range 1 -> 5. If 5 is specified, this assumes the last week of the month for # months that have only 4 weeks. - week_number = EnumField(field_uri='DayOfWeekIndex', enum=WEEK_NUMBERS, is_required=True) + week_number = EnumField(field_uri="DayOfWeekIndex", enum=WEEK_NUMBERS, is_required=True) def __str__(self): - return f'Occurs on weekday {_weekday_to_str(self.weekday)} in the {_week_number_to_str(self.week_number)} ' \ - f'week of every {self.interval} month(s)' + return ( + f"Occurs on weekday {_weekday_to_str(self.weekday)} in the {_week_number_to_str(self.week_number)} " + f"week of every {self.interval} month(s)" + )

                                                                                                                                                                                                                                  Ancestors

                                                                                                                                                                                                                                    @@ -1385,21 +1410,23 @@

                                                                                                                                                                                                                                    Inherited members

                                                                                                                                                                                                                                    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/relativeyearlyrecurrence """ - ELEMENT_NAME = 'RelativeYearlyRecurrence' + ELEMENT_NAME = "RelativeYearlyRecurrence" # Valid ISO 8601 weekday, as a number in range 1 -> 7 (1 being Monday). The value can also be one of the DAY # (or 8), WEEK_DAY (or 9) or WEEKEND_DAY (or 10) consts which is interpreted as the first day, weekday, or weekend # day of the year. Despite the field name in EWS, this is not a list. - weekday = EnumField(field_uri='DaysOfWeek', enum=WEEKDAYS, is_required=True) + weekday = EnumField(field_uri="DaysOfWeek", enum=WEEKDAYS, is_required=True) # Week number of the month, in range 1 -> 5. If 5 is specified, this assumes the last week of the month for # months that have only 4 weeks - week_number = EnumField(field_uri='DayOfWeekIndex', enum=WEEK_NUMBERS, is_required=True) + week_number = EnumField(field_uri="DayOfWeekIndex", enum=WEEK_NUMBERS, is_required=True) # The month of the year, from 1 - 12 - month = EnumField(field_uri='Month', enum=MONTHS, is_required=True) + month = EnumField(field_uri="Month", enum=MONTHS, is_required=True) def __str__(self): - return f'Occurs on weekday {_weekday_to_str(self.weekday)} in the {_week_number_to_str(self.week_number)} ' \ - f'week of {_month_to_str(self.month)}' + return ( + f"Occurs on weekday {_weekday_to_str(self.weekday)} in the {_week_number_to_str(self.week_number)} " + f"week of {_month_to_str(self.month)}" + )

                                                                                                                                                                                                                                    Ancestors

                                                                                                                                                                                                                                      @@ -1499,19 +1526,21 @@

                                                                                                                                                                                                                                      Inherited members

                                                                                                                                                                                                                                      class WeeklyPattern(Pattern):
                                                                                                                                                                                                                                           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/weeklyrecurrence"""
                                                                                                                                                                                                                                       
                                                                                                                                                                                                                                      -    ELEMENT_NAME = 'WeeklyRecurrence'
                                                                                                                                                                                                                                      +    ELEMENT_NAME = "WeeklyRecurrence"
                                                                                                                                                                                                                                       
                                                                                                                                                                                                                                           # Interval, in weeks, in range 1 -> 99
                                                                                                                                                                                                                                      -    interval = IntegerField(field_uri='Interval', min=1, max=99, is_required=True)
                                                                                                                                                                                                                                      +    interval = IntegerField(field_uri="Interval", min=1, max=99, is_required=True)
                                                                                                                                                                                                                                           # List of valid ISO 8601 weekdays, as list of numbers in range 1 -> 7 (1 being Monday)
                                                                                                                                                                                                                                      -    weekdays = WeekdaysField(field_uri='DaysOfWeek', enum=WEEKDAY_NAMES, is_required=True)
                                                                                                                                                                                                                                      +    weekdays = WeekdaysField(field_uri="DaysOfWeek", enum=WEEKDAY_NAMES, is_required=True)
                                                                                                                                                                                                                                           # The first day of the week. Defaults to Monday
                                                                                                                                                                                                                                      -    first_day_of_week = EnumField(field_uri='FirstDayOfWeek', enum=WEEKDAY_NAMES, default=1, is_required=True)
                                                                                                                                                                                                                                      +    first_day_of_week = EnumField(field_uri="FirstDayOfWeek", enum=WEEKDAY_NAMES, default=1, is_required=True)
                                                                                                                                                                                                                                       
                                                                                                                                                                                                                                           def __str__(self):
                                                                                                                                                                                                                                      -        weekdays = [_weekday_to_str(i) for i in self.get_field_by_fieldname('weekdays').clean(self.weekdays)]
                                                                                                                                                                                                                                      -        return f'Occurs on weekdays {", ".join(weekdays)} of every {self.interval} week(s) where the first day of ' \
                                                                                                                                                                                                                                      -               f'the week is {_weekday_to_str(self.first_day_of_week)}'
                                                                                                                                                                                                                                      + weekdays = [_weekday_to_str(i) for i in self.get_field_by_fieldname("weekdays").clean(self.weekdays)] + return ( + f'Occurs on weekdays {", ".join(weekdays)} of every {self.interval} week(s) where the first day of ' + f"the week is {_weekday_to_str(self.first_day_of_week)}" + )

                                                                                                                                                                                                                                      Ancestors

                                                                                                                                                                                                                                        @@ -1569,13 +1598,13 @@

                                                                                                                                                                                                                                        Inherited members

                                                                                                                                                                                                                                        class WeeklyRegeneration(Regeneration):
                                                                                                                                                                                                                                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/weeklyregeneration"""
                                                                                                                                                                                                                                         
                                                                                                                                                                                                                                        -    ELEMENT_NAME = 'WeeklyRegeneration'
                                                                                                                                                                                                                                        +    ELEMENT_NAME = "WeeklyRegeneration"
                                                                                                                                                                                                                                         
                                                                                                                                                                                                                                             # Interval, in weeks
                                                                                                                                                                                                                                        -    interval = IntegerField(field_uri='Interval', min=1, is_required=True)
                                                                                                                                                                                                                                        +    interval = IntegerField(field_uri="Interval", min=1, is_required=True)
                                                                                                                                                                                                                                         
                                                                                                                                                                                                                                             def __str__(self):
                                                                                                                                                                                                                                        -        return f'Regenerates every {self.interval} week(s)'
                                                                                                                                                                                                                                        + return f"Regenerates every {self.interval} week(s)"

                                                                                                                                                                                                                                        Ancestors

                                                                                                                                                                                                                                          @@ -1626,13 +1655,13 @@

                                                                                                                                                                                                                                          Inherited members

                                                                                                                                                                                                                                          class YearlyRegeneration(Regeneration):
                                                                                                                                                                                                                                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/yearlyregeneration"""
                                                                                                                                                                                                                                           
                                                                                                                                                                                                                                          -    ELEMENT_NAME = 'YearlyRegeneration'
                                                                                                                                                                                                                                          +    ELEMENT_NAME = "YearlyRegeneration"
                                                                                                                                                                                                                                           
                                                                                                                                                                                                                                               # Interval, in years
                                                                                                                                                                                                                                          -    interval = IntegerField(field_uri='Interval', min=1, is_required=True)
                                                                                                                                                                                                                                          +    interval = IntegerField(field_uri="Interval", min=1, is_required=True)
                                                                                                                                                                                                                                           
                                                                                                                                                                                                                                               def __str__(self):
                                                                                                                                                                                                                                          -        return f'Regenerates every {self.interval} year(s)'
                                                                                                                                                                                                                                          + return f"Regenerates every {self.interval} year(s)"

                                                                                                                                                                                                                                          Ancestors

                                                                                                                                                                                                                                            diff --git a/docs/exchangelib/restriction.html b/docs/exchangelib/restriction.html index 1816c016..8102f320 100644 --- a/docs/exchangelib/restriction.html +++ b/docs/exchangelib/restriction.html @@ -30,8 +30,8 @@

                                                                                                                                                                                                                                            Module exchangelib.restriction

                                                                                                                                                                                                                                            from copy import copy from .errors import InvalidEnumValue -from .fields import InvalidField, FieldPath, DateTimeBackedDateField -from .util import create_element, xml_to_str, value_to_xml_text, is_iterable +from .fields import DateTimeBackedDateField, FieldPath, InvalidField +from .util import create_element, is_iterable, value_to_xml_text, xml_to_str from .version import EXCHANGE_2010 log = logging.getLogger(__name__) @@ -41,52 +41,65 @@

                                                                                                                                                                                                                                            Module exchangelib.restriction

                                                                                                                                                                                                                                            """A class with an API similar to Django Q objects. Used to implement advanced filtering logic.""" # Connection types - AND = 'AND' - OR = 'OR' - NOT = 'NOT' - NEVER = 'NEVER' # This is not specified by EWS. We use it for queries that will never match, e.g. 'foo__in=()' + AND = "AND" + OR = "OR" + NOT = "NOT" + NEVER = "NEVER" # This is not specified by EWS. We use it for queries that will never match, e.g. 'foo__in=()' CONN_TYPES = {AND, OR, NOT, NEVER} # EWS Operators - EQ = '==' - NE = '!=' - GT = '>' - GTE = '>=' - LT = '<' - LTE = '<=' - EXACT = 'exact' - IEXACT = 'iexact' - CONTAINS = 'contains' - ICONTAINS = 'icontains' - STARTSWITH = 'startswith' - ISTARTSWITH = 'istartswith' - EXISTS = 'exists' + EQ = "==" + NE = "!=" + GT = ">" + GTE = ">=" + LT = "<" + LTE = "<=" + EXACT = "exact" + IEXACT = "iexact" + CONTAINS = "contains" + ICONTAINS = "icontains" + STARTSWITH = "startswith" + ISTARTSWITH = "istartswith" + EXISTS = "exists" OP_TYPES = {EQ, NE, GT, GTE, LT, LTE, EXACT, IEXACT, CONTAINS, ICONTAINS, STARTSWITH, ISTARTSWITH, EXISTS} CONTAINS_OPS = {EXACT, IEXACT, CONTAINS, ICONTAINS, STARTSWITH, ISTARTSWITH} # Valid lookups - LOOKUP_RANGE = 'range' - LOOKUP_IN = 'in' - LOOKUP_NOT = 'not' - LOOKUP_GT = 'gt' - LOOKUP_GTE = 'gte' - LOOKUP_LT = 'lt' - LOOKUP_LTE = 'lte' - LOOKUP_EXACT = 'exact' - LOOKUP_IEXACT = 'iexact' - LOOKUP_CONTAINS = 'contains' - LOOKUP_ICONTAINS = 'icontains' - LOOKUP_STARTSWITH = 'startswith' - LOOKUP_ISTARTSWITH = 'istartswith' - LOOKUP_EXISTS = 'exists' - LOOKUP_TYPES = {LOOKUP_RANGE, LOOKUP_IN, LOOKUP_NOT, LOOKUP_GT, LOOKUP_GTE, LOOKUP_LT, LOOKUP_LTE, LOOKUP_EXACT, - LOOKUP_IEXACT, LOOKUP_CONTAINS, LOOKUP_ICONTAINS, LOOKUP_STARTSWITH, LOOKUP_ISTARTSWITH, - LOOKUP_EXISTS} - - __slots__ = 'conn_type', 'field_path', 'op', 'value', 'children', 'query_string' + LOOKUP_RANGE = "range" + LOOKUP_IN = "in" + LOOKUP_NOT = "not" + LOOKUP_GT = "gt" + LOOKUP_GTE = "gte" + LOOKUP_LT = "lt" + LOOKUP_LTE = "lte" + LOOKUP_EXACT = "exact" + LOOKUP_IEXACT = "iexact" + LOOKUP_CONTAINS = "contains" + LOOKUP_ICONTAINS = "icontains" + LOOKUP_STARTSWITH = "startswith" + LOOKUP_ISTARTSWITH = "istartswith" + LOOKUP_EXISTS = "exists" + LOOKUP_TYPES = { + LOOKUP_RANGE, + LOOKUP_IN, + LOOKUP_NOT, + LOOKUP_GT, + LOOKUP_GTE, + LOOKUP_LT, + LOOKUP_LTE, + LOOKUP_EXACT, + LOOKUP_IEXACT, + LOOKUP_CONTAINS, + LOOKUP_ICONTAINS, + LOOKUP_STARTSWITH, + LOOKUP_ISTARTSWITH, + LOOKUP_EXISTS, + } + + __slots__ = "conn_type", "field_path", "op", "value", "children", "query_string" def __init__(self, *args, **kwargs): - self.conn_type = kwargs.pop('conn_type', self.AND) + self.conn_type = kwargs.pop("conn_type", self.AND) self.field_path = None # Name of the field we want to filter on self.op = None @@ -110,9 +123,7 @@

                                                                                                                                                                                                                                            Module exchangelib.restriction

                                                                                                                                                                                                                                            # Parse keyword args and extract the filter is_single_kwarg = len(args) == 0 and len(kwargs) == 1 for key, value in kwargs.items(): - self.children.extend( - self._get_children_from_kwarg(key=key, value=value, is_single_kwarg=is_single_kwarg) - ) + self.children.extend(self._get_children_from_kwarg(key=key, value=value, is_single_kwarg=is_single_kwarg)) # Simplify this object self.reduce() @@ -124,7 +135,7 @@

                                                                                                                                                                                                                                            Module exchangelib.restriction

                                                                                                                                                                                                                                            """Generate Q objects corresponding to a single keyword argument. Make this a leaf if there are no children to generate. """ - key_parts = key.rsplit('__', 1) + key_parts = key.rsplit("__", 1) if len(key_parts) == 2 and key_parts[1] in self.LOOKUP_TYPES: # This is a kwarg with a lookup at the end field_path, lookup = key_parts @@ -139,8 +150,8 @@

                                                                                                                                                                                                                                            Module exchangelib.restriction

                                                                                                                                                                                                                                            if len(value) != 2: raise ValueError(f"Value of lookup {key!r} must have exactly 2 elements") return ( - self.__class__(**{f'{field_path}__gte': value[0]}), - self.__class__(**{f'{field_path}__lte': value[1]}), + self.__class__(**{f"{field_path}__gte": value[0]}), + self.__class__(**{f"{field_path}__lte": value[1]}), ) # Filtering on list types is a bit quirky. The only lookup type I have found to work is: @@ -243,6 +254,7 @@

                                                                                                                                                                                                                                            Module exchangelib.restriction

                                                                                                                                                                                                                                            validating. There's no reason to replicate much of that here. """ from .folders import Folder + self.to_xml(folders=[Folder()], version=version, applies_to=Restriction.ITEMS) @classmethod @@ -265,28 +277,28 @@

                                                                                                                                                                                                                                            Module exchangelib.restriction

                                                                                                                                                                                                                                            @classmethod def _conn_to_xml(cls, conn_type): xml_tag_map = { - cls.AND: 't:And', - cls.OR: 't:Or', - cls.NOT: 't:Not', + cls.AND: "t:And", + cls.OR: "t:Or", + cls.NOT: "t:Not", } return create_element(xml_tag_map[conn_type]) @classmethod def _op_to_xml(cls, op): xml_tag_map = { - cls.EQ: 't:IsEqualTo', - cls.NE: 't:IsNotEqualTo', - cls.GTE: 't:IsGreaterThanOrEqualTo', - cls.LTE: 't:IsLessThanOrEqualTo', - cls.LT: 't:IsLessThan', - cls.GT: 't:IsGreaterThan', - cls.EXISTS: 't:Exists', + cls.EQ: "t:IsEqualTo", + cls.NE: "t:IsNotEqualTo", + cls.GTE: "t:IsGreaterThanOrEqualTo", + cls.LTE: "t:IsLessThanOrEqualTo", + cls.LT: "t:IsLessThan", + cls.GT: "t:IsGreaterThan", + cls.EXISTS: "t:Exists", } if op in xml_tag_map: return create_element(xml_tag_map[op]) valid_ops = cls.EXACT, cls.IEXACT, cls.CONTAINS, cls.ICONTAINS, cls.STARTSWITH, cls.ISTARTSWITH if op not in valid_ops: - raise InvalidEnumValue('op', op, valid_ops) + raise InvalidEnumValue("op", op, valid_ops) # For description of Contains attribute values, see # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/contains @@ -310,21 +322,18 @@

                                                                                                                                                                                                                                            Module exchangelib.restriction

                                                                                                                                                                                                                                            # https://en.wikipedia.org/wiki/Graphic_character#Spacing_and_non-spacing_characters # we shouldn't ignore them ('a' would match both 'a' and 'å', the latter having a non-spacing character). if op in {cls.EXACT, cls.IEXACT}: - match_mode = 'FullString' + match_mode = "FullString" elif op in (cls.CONTAINS, cls.ICONTAINS): - match_mode = 'Substring' + match_mode = "Substring" elif op in (cls.STARTSWITH, cls.ISTARTSWITH): - match_mode = 'Prefixed' + match_mode = "Prefixed" else: - raise ValueError(f'Unsupported op: {op}') + raise ValueError(f"Unsupported op: {op}") if op in (cls.IEXACT, cls.ICONTAINS, cls.ISTARTSWITH): - compare_mode = 'IgnoreCase' + compare_mode = "IgnoreCase" else: - compare_mode = 'Exact' - return create_element( - 't:Contains', - attrs=dict(ContainmentMode=match_mode, ContainmentComparison=compare_mode) - ) + compare_mode = "Exact" + return create_element("t:Contains", attrs=dict(ContainmentMode=match_mode, ContainmentComparison=compare_mode)) def is_leaf(self): return not self.children @@ -345,33 +354,33 @@

                                                                                                                                                                                                                                            Module exchangelib.restriction

                                                                                                                                                                                                                                            if self.query_string: return self.query_string if self.is_leaf(): - expr = f'{self.field_path} {self.op} {self.value!r}' + expr = f"{self.field_path} {self.op} {self.value!r}" else: # Sort children by field name so we get stable output (for easier testing). Children should never be empty. - expr = f' {self.AND if self.conn_type == self.NOT else self.conn_type} '.join( - (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f'({c.expr()})') - for c in sorted(self.children, key=lambda i: i.field_path or '') + expr = f" {self.AND if self.conn_type == self.NOT else self.conn_type} ".join( + (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f"({c.expr()})") + for c in sorted(self.children, key=lambda i: i.field_path or "") ) if self.conn_type == self.NOT: # Add the NOT operator. Put children in parens if there is more than one child. if self.is_leaf() or len(self.children) == 1: - return self.conn_type + f' {expr}' - return self.conn_type + f' ({expr})' + return self.conn_type + f" {expr}" + return self.conn_type + f" ({expr})" return expr def to_xml(self, folders, version, applies_to): if self.query_string: self._check_integrity() if version.build < EXCHANGE_2010: - raise NotImplementedError('QueryString filtering is only supported for Exchange 2010 servers and later') - elem = create_element('m:QueryString') + raise NotImplementedError("QueryString filtering is only supported for Exchange 2010 servers and later") + elem = create_element("m:QueryString") elem.text = self.query_string return elem # Translate this Q object to a valid Restriction XML tree elem = self.xml_elem(folders=folders, version=version, applies_to=applies_to) if elem is None: return None - restriction = create_element('m:Restriction') + restriction = create_element("m:Restriction") restriction.append(elem) return restriction @@ -384,30 +393,31 @@

                                                                                                                                                                                                                                            Module exchangelib.restriction

                                                                                                                                                                                                                                            return if self.query_string: if any([self.field_path, self.op, self.value, self.children]): - raise ValueError('Query strings cannot be combined with other settings') + raise ValueError("Query strings cannot be combined with other settings") return if self.conn_type not in self.CONN_TYPES: - raise InvalidEnumValue('conn_type', self.conn_type, self.CONN_TYPES) + raise InvalidEnumValue("conn_type", self.conn_type, self.CONN_TYPES) if not self.is_leaf(): for q in self.children: if q.query_string and len(self.children) > 1: - raise ValueError('A query string cannot be combined with other restrictions') + raise ValueError("A query string cannot be combined with other restrictions") return if not self.field_path: raise ValueError("'field_path' must be set") if self.op not in self.OP_TYPES: - raise InvalidEnumValue('op', self.op, self.OP_TYPES) + raise InvalidEnumValue("op", self.op, self.OP_TYPES) if self.op == self.EXISTS and self.value is not True: raise ValueError("'value' must be True when operator is EXISTS") if self.value is None: - raise ValueError(f'Value for filter on field path {self.field_path!r} cannot be None') + raise ValueError(f"Value for filter on field path {self.field_path!r} cannot be None") if is_iterable(self.value, generators_allowed=True): raise ValueError( - f'Value {self.value!r} for filter on field path {self.field_path!r} must be a single value' + f"Value {self.value!r} for filter on field path {self.field_path!r} must be a single value" ) def _validate_field_path(self, field_path, folder, applies_to, version): from .indexed_properties import MultiFieldIndexedElement + if applies_to == Restriction.FOLDERS: # This is a restriction on Folder fields folder.validate_field(field=field_path.field, version=version) @@ -452,6 +462,7 @@

                                                                                                                                                                                                                                            Module exchangelib.restriction

                                                                                                                                                                                                                                            # Recursively build an XML tree structure of this Q object. If this is an empty leaf (the equivalent of Q()), # return None. from .indexed_properties import SingleFieldIndexedElement + # Don't check self.value just yet. We want to return error messages on the field path first, and then the value. # This is done in _get_field_path() and _get_clean_value(), respectively. self._check_integrity() @@ -472,11 +483,11 @@

                                                                                                                                                                                                                                            Module exchangelib.restriction

                                                                                                                                                                                                                                            clean_value = field_path.field.date_to_datetime(clean_value) elem.append(field_path.to_xml()) if self.op != self.EXISTS: - constant = create_element('t:Constant', attrs=dict(Value=value_to_xml_text(clean_value))) + constant = create_element("t:Constant", attrs=dict(Value=value_to_xml_text(clean_value))) if self.op in self.CONTAINS_OPS: elem.append(constant) else: - uriorconst = create_element('t:FieldURIOrConstant') + uriorconst = create_element("t:FieldURIOrConstant") uriorconst.append(constant) elem.append(uriorconst) elif len(self.children) == 1: @@ -486,7 +497,7 @@

                                                                                                                                                                                                                                            Module exchangelib.restriction

                                                                                                                                                                                                                                            # We have multiple children. If conn_type is NOT, then group children with AND. We'll add the NOT later elem = self._conn_to_xml(self.AND if self.conn_type == self.NOT else self.conn_type) # Sort children by field name so we get stable output (for easier testing). Children should never be empty - for c in sorted(self.children, key=lambda i: i.field_path or ''): + for c in sorted(self.children, key=lambda i: i.field_path or ""): elem.append(c.xml_elem(folders=folders, version=version, applies_to=applies_to)) if elem is None: return None # Should not be necessary, but play safe @@ -538,16 +549,16 @@

                                                                                                                                                                                                                                            Module exchangelib.restriction

                                                                                                                                                                                                                                            return hash(repr(self)) def __str__(self): - return self.expr() or 'Q()' + return self.expr() or "Q()" def __repr__(self): if self.is_leaf(): if self.query_string: - return self.__class__.__name__ + f'({self.query_string!r})' + return self.__class__.__name__ + f"({self.query_string!r})" if self.is_never(): - return self.__class__.__name__ + f'(conn_type={self.conn_type!r})' - return self.__class__.__name__ + f'({self.field_path} {self.op} {self.value!r})' - sorted_children = tuple(sorted(self.children, key=lambda i: i.field_path or '')) + return self.__class__.__name__ + f"(conn_type={self.conn_type!r})" + return self.__class__.__name__ + f"({self.field_path} {self.op} {self.value!r})" + sorted_children = tuple(sorted(self.children, key=lambda i: i.field_path or "")) if self.conn_type == self.NOT or len(self.children) > 1: return self.__class__.__name__ + repr((self.conn_type,) + sorted_children) return self.__class__.__name__ + repr(sorted_children) @@ -557,8 +568,8 @@

                                                                                                                                                                                                                                            Module exchangelib.restriction

                                                                                                                                                                                                                                            """Implement an EWS Restriction type.""" # The type of item the restriction applies to - FOLDERS = 'folders' - ITEMS = 'items' + FOLDERS = "folders" + ITEMS = "items" RESTRICTION_TYPES = (FOLDERS, ITEMS) def __init__(self, q, folders, applies_to): @@ -604,52 +615,65 @@

                                                                                                                                                                                                                                            Classes

                                                                                                                                                                                                                                            """A class with an API similar to Django Q objects. Used to implement advanced filtering logic.""" # Connection types - AND = 'AND' - OR = 'OR' - NOT = 'NOT' - NEVER = 'NEVER' # This is not specified by EWS. We use it for queries that will never match, e.g. 'foo__in=()' + AND = "AND" + OR = "OR" + NOT = "NOT" + NEVER = "NEVER" # This is not specified by EWS. We use it for queries that will never match, e.g. 'foo__in=()' CONN_TYPES = {AND, OR, NOT, NEVER} # EWS Operators - EQ = '==' - NE = '!=' - GT = '>' - GTE = '>=' - LT = '<' - LTE = '<=' - EXACT = 'exact' - IEXACT = 'iexact' - CONTAINS = 'contains' - ICONTAINS = 'icontains' - STARTSWITH = 'startswith' - ISTARTSWITH = 'istartswith' - EXISTS = 'exists' + EQ = "==" + NE = "!=" + GT = ">" + GTE = ">=" + LT = "<" + LTE = "<=" + EXACT = "exact" + IEXACT = "iexact" + CONTAINS = "contains" + ICONTAINS = "icontains" + STARTSWITH = "startswith" + ISTARTSWITH = "istartswith" + EXISTS = "exists" OP_TYPES = {EQ, NE, GT, GTE, LT, LTE, EXACT, IEXACT, CONTAINS, ICONTAINS, STARTSWITH, ISTARTSWITH, EXISTS} CONTAINS_OPS = {EXACT, IEXACT, CONTAINS, ICONTAINS, STARTSWITH, ISTARTSWITH} # Valid lookups - LOOKUP_RANGE = 'range' - LOOKUP_IN = 'in' - LOOKUP_NOT = 'not' - LOOKUP_GT = 'gt' - LOOKUP_GTE = 'gte' - LOOKUP_LT = 'lt' - LOOKUP_LTE = 'lte' - LOOKUP_EXACT = 'exact' - LOOKUP_IEXACT = 'iexact' - LOOKUP_CONTAINS = 'contains' - LOOKUP_ICONTAINS = 'icontains' - LOOKUP_STARTSWITH = 'startswith' - LOOKUP_ISTARTSWITH = 'istartswith' - LOOKUP_EXISTS = 'exists' - LOOKUP_TYPES = {LOOKUP_RANGE, LOOKUP_IN, LOOKUP_NOT, LOOKUP_GT, LOOKUP_GTE, LOOKUP_LT, LOOKUP_LTE, LOOKUP_EXACT, - LOOKUP_IEXACT, LOOKUP_CONTAINS, LOOKUP_ICONTAINS, LOOKUP_STARTSWITH, LOOKUP_ISTARTSWITH, - LOOKUP_EXISTS} - - __slots__ = 'conn_type', 'field_path', 'op', 'value', 'children', 'query_string' + LOOKUP_RANGE = "range" + LOOKUP_IN = "in" + LOOKUP_NOT = "not" + LOOKUP_GT = "gt" + LOOKUP_GTE = "gte" + LOOKUP_LT = "lt" + LOOKUP_LTE = "lte" + LOOKUP_EXACT = "exact" + LOOKUP_IEXACT = "iexact" + LOOKUP_CONTAINS = "contains" + LOOKUP_ICONTAINS = "icontains" + LOOKUP_STARTSWITH = "startswith" + LOOKUP_ISTARTSWITH = "istartswith" + LOOKUP_EXISTS = "exists" + LOOKUP_TYPES = { + LOOKUP_RANGE, + LOOKUP_IN, + LOOKUP_NOT, + LOOKUP_GT, + LOOKUP_GTE, + LOOKUP_LT, + LOOKUP_LTE, + LOOKUP_EXACT, + LOOKUP_IEXACT, + LOOKUP_CONTAINS, + LOOKUP_ICONTAINS, + LOOKUP_STARTSWITH, + LOOKUP_ISTARTSWITH, + LOOKUP_EXISTS, + } + + __slots__ = "conn_type", "field_path", "op", "value", "children", "query_string" def __init__(self, *args, **kwargs): - self.conn_type = kwargs.pop('conn_type', self.AND) + self.conn_type = kwargs.pop("conn_type", self.AND) self.field_path = None # Name of the field we want to filter on self.op = None @@ -673,9 +697,7 @@

                                                                                                                                                                                                                                            Classes

                                                                                                                                                                                                                                            # Parse keyword args and extract the filter is_single_kwarg = len(args) == 0 and len(kwargs) == 1 for key, value in kwargs.items(): - self.children.extend( - self._get_children_from_kwarg(key=key, value=value, is_single_kwarg=is_single_kwarg) - ) + self.children.extend(self._get_children_from_kwarg(key=key, value=value, is_single_kwarg=is_single_kwarg)) # Simplify this object self.reduce() @@ -687,7 +709,7 @@

                                                                                                                                                                                                                                            Classes

                                                                                                                                                                                                                                            """Generate Q objects corresponding to a single keyword argument. Make this a leaf if there are no children to generate. """ - key_parts = key.rsplit('__', 1) + key_parts = key.rsplit("__", 1) if len(key_parts) == 2 and key_parts[1] in self.LOOKUP_TYPES: # This is a kwarg with a lookup at the end field_path, lookup = key_parts @@ -702,8 +724,8 @@

                                                                                                                                                                                                                                            Classes

                                                                                                                                                                                                                                            if len(value) != 2: raise ValueError(f"Value of lookup {key!r} must have exactly 2 elements") return ( - self.__class__(**{f'{field_path}__gte': value[0]}), - self.__class__(**{f'{field_path}__lte': value[1]}), + self.__class__(**{f"{field_path}__gte": value[0]}), + self.__class__(**{f"{field_path}__lte": value[1]}), ) # Filtering on list types is a bit quirky. The only lookup type I have found to work is: @@ -806,6 +828,7 @@

                                                                                                                                                                                                                                            Classes

                                                                                                                                                                                                                                            validating. There's no reason to replicate much of that here. """ from .folders import Folder + self.to_xml(folders=[Folder()], version=version, applies_to=Restriction.ITEMS) @classmethod @@ -828,28 +851,28 @@

                                                                                                                                                                                                                                            Classes

                                                                                                                                                                                                                                            @classmethod def _conn_to_xml(cls, conn_type): xml_tag_map = { - cls.AND: 't:And', - cls.OR: 't:Or', - cls.NOT: 't:Not', + cls.AND: "t:And", + cls.OR: "t:Or", + cls.NOT: "t:Not", } return create_element(xml_tag_map[conn_type]) @classmethod def _op_to_xml(cls, op): xml_tag_map = { - cls.EQ: 't:IsEqualTo', - cls.NE: 't:IsNotEqualTo', - cls.GTE: 't:IsGreaterThanOrEqualTo', - cls.LTE: 't:IsLessThanOrEqualTo', - cls.LT: 't:IsLessThan', - cls.GT: 't:IsGreaterThan', - cls.EXISTS: 't:Exists', + cls.EQ: "t:IsEqualTo", + cls.NE: "t:IsNotEqualTo", + cls.GTE: "t:IsGreaterThanOrEqualTo", + cls.LTE: "t:IsLessThanOrEqualTo", + cls.LT: "t:IsLessThan", + cls.GT: "t:IsGreaterThan", + cls.EXISTS: "t:Exists", } if op in xml_tag_map: return create_element(xml_tag_map[op]) valid_ops = cls.EXACT, cls.IEXACT, cls.CONTAINS, cls.ICONTAINS, cls.STARTSWITH, cls.ISTARTSWITH if op not in valid_ops: - raise InvalidEnumValue('op', op, valid_ops) + raise InvalidEnumValue("op", op, valid_ops) # For description of Contains attribute values, see # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/contains @@ -873,21 +896,18 @@

                                                                                                                                                                                                                                            Classes

                                                                                                                                                                                                                                            # https://en.wikipedia.org/wiki/Graphic_character#Spacing_and_non-spacing_characters # we shouldn't ignore them ('a' would match both 'a' and 'å', the latter having a non-spacing character). if op in {cls.EXACT, cls.IEXACT}: - match_mode = 'FullString' + match_mode = "FullString" elif op in (cls.CONTAINS, cls.ICONTAINS): - match_mode = 'Substring' + match_mode = "Substring" elif op in (cls.STARTSWITH, cls.ISTARTSWITH): - match_mode = 'Prefixed' + match_mode = "Prefixed" else: - raise ValueError(f'Unsupported op: {op}') + raise ValueError(f"Unsupported op: {op}") if op in (cls.IEXACT, cls.ICONTAINS, cls.ISTARTSWITH): - compare_mode = 'IgnoreCase' + compare_mode = "IgnoreCase" else: - compare_mode = 'Exact' - return create_element( - 't:Contains', - attrs=dict(ContainmentMode=match_mode, ContainmentComparison=compare_mode) - ) + compare_mode = "Exact" + return create_element("t:Contains", attrs=dict(ContainmentMode=match_mode, ContainmentComparison=compare_mode)) def is_leaf(self): return not self.children @@ -908,33 +928,33 @@

                                                                                                                                                                                                                                            Classes

                                                                                                                                                                                                                                            if self.query_string: return self.query_string if self.is_leaf(): - expr = f'{self.field_path} {self.op} {self.value!r}' + expr = f"{self.field_path} {self.op} {self.value!r}" else: # Sort children by field name so we get stable output (for easier testing). Children should never be empty. - expr = f' {self.AND if self.conn_type == self.NOT else self.conn_type} '.join( - (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f'({c.expr()})') - for c in sorted(self.children, key=lambda i: i.field_path or '') + expr = f" {self.AND if self.conn_type == self.NOT else self.conn_type} ".join( + (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f"({c.expr()})") + for c in sorted(self.children, key=lambda i: i.field_path or "") ) if self.conn_type == self.NOT: # Add the NOT operator. Put children in parens if there is more than one child. if self.is_leaf() or len(self.children) == 1: - return self.conn_type + f' {expr}' - return self.conn_type + f' ({expr})' + return self.conn_type + f" {expr}" + return self.conn_type + f" ({expr})" return expr def to_xml(self, folders, version, applies_to): if self.query_string: self._check_integrity() if version.build < EXCHANGE_2010: - raise NotImplementedError('QueryString filtering is only supported for Exchange 2010 servers and later') - elem = create_element('m:QueryString') + raise NotImplementedError("QueryString filtering is only supported for Exchange 2010 servers and later") + elem = create_element("m:QueryString") elem.text = self.query_string return elem # Translate this Q object to a valid Restriction XML tree elem = self.xml_elem(folders=folders, version=version, applies_to=applies_to) if elem is None: return None - restriction = create_element('m:Restriction') + restriction = create_element("m:Restriction") restriction.append(elem) return restriction @@ -947,30 +967,31 @@

                                                                                                                                                                                                                                            Classes

                                                                                                                                                                                                                                            return if self.query_string: if any([self.field_path, self.op, self.value, self.children]): - raise ValueError('Query strings cannot be combined with other settings') + raise ValueError("Query strings cannot be combined with other settings") return if self.conn_type not in self.CONN_TYPES: - raise InvalidEnumValue('conn_type', self.conn_type, self.CONN_TYPES) + raise InvalidEnumValue("conn_type", self.conn_type, self.CONN_TYPES) if not self.is_leaf(): for q in self.children: if q.query_string and len(self.children) > 1: - raise ValueError('A query string cannot be combined with other restrictions') + raise ValueError("A query string cannot be combined with other restrictions") return if not self.field_path: raise ValueError("'field_path' must be set") if self.op not in self.OP_TYPES: - raise InvalidEnumValue('op', self.op, self.OP_TYPES) + raise InvalidEnumValue("op", self.op, self.OP_TYPES) if self.op == self.EXISTS and self.value is not True: raise ValueError("'value' must be True when operator is EXISTS") if self.value is None: - raise ValueError(f'Value for filter on field path {self.field_path!r} cannot be None') + raise ValueError(f"Value for filter on field path {self.field_path!r} cannot be None") if is_iterable(self.value, generators_allowed=True): raise ValueError( - f'Value {self.value!r} for filter on field path {self.field_path!r} must be a single value' + f"Value {self.value!r} for filter on field path {self.field_path!r} must be a single value" ) def _validate_field_path(self, field_path, folder, applies_to, version): from .indexed_properties import MultiFieldIndexedElement + if applies_to == Restriction.FOLDERS: # This is a restriction on Folder fields folder.validate_field(field=field_path.field, version=version) @@ -1015,6 +1036,7 @@

                                                                                                                                                                                                                                            Classes

                                                                                                                                                                                                                                            # Recursively build an XML tree structure of this Q object. If this is an empty leaf (the equivalent of Q()), # return None. from .indexed_properties import SingleFieldIndexedElement + # Don't check self.value just yet. We want to return error messages on the field path first, and then the value. # This is done in _get_field_path() and _get_clean_value(), respectively. self._check_integrity() @@ -1035,11 +1057,11 @@

                                                                                                                                                                                                                                            Classes

                                                                                                                                                                                                                                            clean_value = field_path.field.date_to_datetime(clean_value) elem.append(field_path.to_xml()) if self.op != self.EXISTS: - constant = create_element('t:Constant', attrs=dict(Value=value_to_xml_text(clean_value))) + constant = create_element("t:Constant", attrs=dict(Value=value_to_xml_text(clean_value))) if self.op in self.CONTAINS_OPS: elem.append(constant) else: - uriorconst = create_element('t:FieldURIOrConstant') + uriorconst = create_element("t:FieldURIOrConstant") uriorconst.append(constant) elem.append(uriorconst) elif len(self.children) == 1: @@ -1049,7 +1071,7 @@

                                                                                                                                                                                                                                            Classes

                                                                                                                                                                                                                                            # We have multiple children. If conn_type is NOT, then group children with AND. We'll add the NOT later elem = self._conn_to_xml(self.AND if self.conn_type == self.NOT else self.conn_type) # Sort children by field name so we get stable output (for easier testing). Children should never be empty - for c in sorted(self.children, key=lambda i: i.field_path or ''): + for c in sorted(self.children, key=lambda i: i.field_path or ""): elem.append(c.xml_elem(folders=folders, version=version, applies_to=applies_to)) if elem is None: return None # Should not be necessary, but play safe @@ -1101,16 +1123,16 @@

                                                                                                                                                                                                                                            Classes

                                                                                                                                                                                                                                            return hash(repr(self)) def __str__(self): - return self.expr() or 'Q()' + return self.expr() or "Q()" def __repr__(self): if self.is_leaf(): if self.query_string: - return self.__class__.__name__ + f'({self.query_string!r})' + return self.__class__.__name__ + f"({self.query_string!r})" if self.is_never(): - return self.__class__.__name__ + f'(conn_type={self.conn_type!r})' - return self.__class__.__name__ + f'({self.field_path} {self.op} {self.value!r})' - sorted_children = tuple(sorted(self.children, key=lambda i: i.field_path or '')) + return self.__class__.__name__ + f"(conn_type={self.conn_type!r})" + return self.__class__.__name__ + f"({self.field_path} {self.op} {self.value!r})" + sorted_children = tuple(sorted(self.children, key=lambda i: i.field_path or "")) if self.conn_type == self.NOT or len(self.children) > 1: return self.__class__.__name__ + repr((self.conn_type,) + sorted_children) return self.__class__.__name__ + repr(sorted_children) @@ -1302,6 +1324,7 @@

                                                                                                                                                                                                                                            Methods

                                                                                                                                                                                                                                            validating. There's no reason to replicate much of that here. """ from .folders import Folder + self.to_xml(folders=[Folder()], version=version, applies_to=Restriction.ITEMS) @@ -1322,18 +1345,18 @@

                                                                                                                                                                                                                                            Methods

                                                                                                                                                                                                                                            if self.query_string: return self.query_string if self.is_leaf(): - expr = f'{self.field_path} {self.op} {self.value!r}' + expr = f"{self.field_path} {self.op} {self.value!r}" else: # Sort children by field name so we get stable output (for easier testing). Children should never be empty. - expr = f' {self.AND if self.conn_type == self.NOT else self.conn_type} '.join( - (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f'({c.expr()})') - for c in sorted(self.children, key=lambda i: i.field_path or '') + expr = f" {self.AND if self.conn_type == self.NOT else self.conn_type} ".join( + (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f"({c.expr()})") + for c in sorted(self.children, key=lambda i: i.field_path or "") ) if self.conn_type == self.NOT: # Add the NOT operator. Put children in parens if there is more than one child. if self.is_leaf() or len(self.children) == 1: - return self.conn_type + f' {expr}' - return self.conn_type + f' ({expr})' + return self.conn_type + f" {expr}" + return self.conn_type + f" ({expr})" return expr @@ -1406,15 +1429,15 @@

                                                                                                                                                                                                                                            Methods

                                                                                                                                                                                                                                            if self.query_string: self._check_integrity() if version.build < EXCHANGE_2010: - raise NotImplementedError('QueryString filtering is only supported for Exchange 2010 servers and later') - elem = create_element('m:QueryString') + raise NotImplementedError("QueryString filtering is only supported for Exchange 2010 servers and later") + elem = create_element("m:QueryString") elem.text = self.query_string return elem # Translate this Q object to a valid Restriction XML tree elem = self.xml_elem(folders=folders, version=version, applies_to=applies_to) if elem is None: return None - restriction = create_element('m:Restriction') + restriction = create_element("m:Restriction") restriction.append(elem) return restriction @@ -1432,6 +1455,7 @@

                                                                                                                                                                                                                                            Methods

                                                                                                                                                                                                                                            # Recursively build an XML tree structure of this Q object. If this is an empty leaf (the equivalent of Q()), # return None. from .indexed_properties import SingleFieldIndexedElement + # Don't check self.value just yet. We want to return error messages on the field path first, and then the value. # This is done in _get_field_path() and _get_clean_value(), respectively. self._check_integrity() @@ -1452,11 +1476,11 @@

                                                                                                                                                                                                                                            Methods

                                                                                                                                                                                                                                            clean_value = field_path.field.date_to_datetime(clean_value) elem.append(field_path.to_xml()) if self.op != self.EXISTS: - constant = create_element('t:Constant', attrs=dict(Value=value_to_xml_text(clean_value))) + constant = create_element("t:Constant", attrs=dict(Value=value_to_xml_text(clean_value))) if self.op in self.CONTAINS_OPS: elem.append(constant) else: - uriorconst = create_element('t:FieldURIOrConstant') + uriorconst = create_element("t:FieldURIOrConstant") uriorconst.append(constant) elem.append(uriorconst) elif len(self.children) == 1: @@ -1466,7 +1490,7 @@

                                                                                                                                                                                                                                            Methods

                                                                                                                                                                                                                                            # We have multiple children. If conn_type is NOT, then group children with AND. We'll add the NOT later elem = self._conn_to_xml(self.AND if self.conn_type == self.NOT else self.conn_type) # Sort children by field name so we get stable output (for easier testing). Children should never be empty - for c in sorted(self.children, key=lambda i: i.field_path or ''): + for c in sorted(self.children, key=lambda i: i.field_path or ""): elem.append(c.xml_elem(folders=folders, version=version, applies_to=applies_to)) if elem is None: return None # Should not be necessary, but play safe @@ -1497,8 +1521,8 @@

                                                                                                                                                                                                                                            Methods

                                                                                                                                                                                                                                            """Implement an EWS Restriction type.""" # The type of item the restriction applies to - FOLDERS = 'folders' - ITEMS = 'items' + FOLDERS = "folders" + ITEMS = "items" RESTRICTION_TYPES = (FOLDERS, ITEMS) def __init__(self, q, folders, applies_to): diff --git a/docs/exchangelib/services/archive_item.html b/docs/exchangelib/services/archive_item.html index 37ef4ffa..29068116 100644 --- a/docs/exchangelib/services/archive_item.html +++ b/docs/exchangelib/services/archive_item.html @@ -26,17 +26,17 @@

                                                                                                                                                                                                                                            Module exchangelib.services.archive_item

                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                            from .common import EWSAccountService, folder_ids_element, item_ids_element
                                                                                                                                                                                                                                            -from ..items import Item
                                                                                                                                                                                                                                            -from ..util import create_element, MNS
                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                            from ..items import Item
                                                                                                                                                                                                                                            +from ..util import MNS, create_element
                                                                                                                                                                                                                                             from ..version import EXCHANGE_2013
                                                                                                                                                                                                                                            +from .common import EWSAccountService, folder_ids_element, item_ids_element
                                                                                                                                                                                                                                             
                                                                                                                                                                                                                                             
                                                                                                                                                                                                                                             class ArchiveItem(EWSAccountService):
                                                                                                                                                                                                                                                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/archiveitem-operation"""
                                                                                                                                                                                                                                             
                                                                                                                                                                                                                                            -    SERVICE_NAME = 'ArchiveItem'
                                                                                                                                                                                                                                            -    element_container_name = f'{{{MNS}}}Items'
                                                                                                                                                                                                                                            +    SERVICE_NAME = "ArchiveItem"
                                                                                                                                                                                                                                            +    element_container_name = f"{{{MNS}}}Items"
                                                                                                                                                                                                                                                 supported_from = EXCHANGE_2013
                                                                                                                                                                                                                                             
                                                                                                                                                                                                                                                 def call(self, items, to_folder):
                                                                                                                                                                                                                                            @@ -53,9 +53,9 @@ 

                                                                                                                                                                                                                                            Module exchangelib.services.archive_item

                                                                                                                                                                                                                                            return Item.id_from_xml(elem) def get_payload(self, items, to_folder): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") payload.append( - folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ArchiveSourceFolderId') + folder_ids_element(folders=[to_folder], version=self.account.version, tag="m:ArchiveSourceFolderId") ) payload.append(item_ids_element(items=items, version=self.account.version)) return payload
                                                                                                                                                                                                                                            @@ -83,8 +83,8 @@

                                                                                                                                                                                                                                            Classes

                                                                                                                                                                                                                                            class ArchiveItem(EWSAccountService):
                                                                                                                                                                                                                                                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/archiveitem-operation"""
                                                                                                                                                                                                                                             
                                                                                                                                                                                                                                            -    SERVICE_NAME = 'ArchiveItem'
                                                                                                                                                                                                                                            -    element_container_name = f'{{{MNS}}}Items'
                                                                                                                                                                                                                                            +    SERVICE_NAME = "ArchiveItem"
                                                                                                                                                                                                                                            +    element_container_name = f"{{{MNS}}}Items"
                                                                                                                                                                                                                                                 supported_from = EXCHANGE_2013
                                                                                                                                                                                                                                             
                                                                                                                                                                                                                                                 def call(self, items, to_folder):
                                                                                                                                                                                                                                            @@ -101,9 +101,9 @@ 

                                                                                                                                                                                                                                            Classes

                                                                                                                                                                                                                                            return Item.id_from_xml(elem) def get_payload(self, items, to_folder): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") payload.append( - folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ArchiveSourceFolderId') + folder_ids_element(folders=[to_folder], version=self.account.version, tag="m:ArchiveSourceFolderId") ) payload.append(item_ids_element(items=items, version=self.account.version)) return payload
                                                                                                                                                                                                                                            @@ -163,9 +163,9 @@

                                                                                                                                                                                                                                            Methods

                                                                                                                                                                                                                                            Expand source code
                                                                                                                                                                                                                                            def get_payload(self, items, to_folder):
                                                                                                                                                                                                                                            -    payload = create_element(f'm:{self.SERVICE_NAME}')
                                                                                                                                                                                                                                            +    payload = create_element(f"m:{self.SERVICE_NAME}")
                                                                                                                                                                                                                                                 payload.append(
                                                                                                                                                                                                                                            -        folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ArchiveSourceFolderId')
                                                                                                                                                                                                                                            +        folder_ids_element(folders=[to_folder], version=self.account.version, tag="m:ArchiveSourceFolderId")
                                                                                                                                                                                                                                                 )
                                                                                                                                                                                                                                                 payload.append(item_ids_element(items=items, version=self.account.version))
                                                                                                                                                                                                                                                 return payload
                                                                                                                                                                                                                                            diff --git a/docs/exchangelib/services/common.html b/docs/exchangelib/services/common.html index c7a83634..a4ee88eb 100644 --- a/docs/exchangelib/services/common.html +++ b/docs/exchangelib/services/common.html @@ -34,27 +34,91 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            from .. import errors from ..attachments import AttachmentId from ..credentials import IMPERSONATION, OAuth2Credentials -from ..errors import EWSWarning, TransportError, SOAPError, ErrorTimeoutExpired, ErrorBatchProcessingStopped, \ - ErrorQuotaExceeded, ErrorCannotDeleteObject, ErrorCreateItemAccessDenied, ErrorFolderNotFound, \ - ErrorNonExistentMailbox, ErrorMailboxStoreUnavailable, ErrorImpersonateUserDenied, ErrorInternalServerError, \ - ErrorInternalServerTransientError, ErrorNoRespondingCASInDestinationSite, ErrorImpersonationFailed, \ - ErrorMailboxMoveInProgress, ErrorAccessDenied, ErrorConnectionFailed, RateLimitError, ErrorServerBusy, \ - ErrorTooManyObjectsOpened, ErrorInvalidLicense, ErrorInvalidSchemaVersionForMailboxVersion, \ - ErrorInvalidServerVersion, ErrorItemNotFound, ErrorADUnavailable, ErrorInvalidChangeKey, \ - ErrorItemSave, ErrorInvalidIdMalformed, ErrorMessageSizeExceeded, UnauthorizedError, \ - ErrorCannotDeleteTaskOccurrence, ErrorMimeContentConversionFailed, ErrorRecurrenceHasNoOccurrence, \ - ErrorNoPublicFolderReplicaAvailable, MalformedResponseError, ErrorExceededConnectionCount, \ - SessionPoolMinSizeReached, ErrorIncorrectSchemaVersion, ErrorInvalidRequest, ErrorCorruptData, \ - ErrorCannotEmptyFolder, ErrorDeleteDistinguishedFolder, ErrorInvalidSubscription, ErrorInvalidWatermark, \ - ErrorInvalidSyncStateData, ErrorNameResolutionNoResults, ErrorNameResolutionMultipleResults, \ - ErrorConnectionFailedTransientError, ErrorDelegateNoUser, ErrorNotDelegate, InvalidTypeError +from ..errors import ( + ErrorAccessDenied, + ErrorADUnavailable, + ErrorBatchProcessingStopped, + ErrorCannotDeleteObject, + ErrorCannotDeleteTaskOccurrence, + ErrorCannotEmptyFolder, + ErrorConnectionFailed, + ErrorConnectionFailedTransientError, + ErrorCorruptData, + ErrorCreateItemAccessDenied, + ErrorDelegateNoUser, + ErrorDeleteDistinguishedFolder, + ErrorExceededConnectionCount, + ErrorFolderNotFound, + ErrorImpersonateUserDenied, + ErrorImpersonationFailed, + ErrorIncorrectSchemaVersion, + ErrorInternalServerError, + ErrorInternalServerTransientError, + ErrorInvalidChangeKey, + ErrorInvalidIdMalformed, + ErrorInvalidLicense, + ErrorInvalidRequest, + ErrorInvalidSchemaVersionForMailboxVersion, + ErrorInvalidServerVersion, + ErrorInvalidSubscription, + ErrorInvalidSyncStateData, + ErrorInvalidWatermark, + ErrorItemCorrupt, + ErrorItemNotFound, + ErrorItemSave, + ErrorMailboxMoveInProgress, + ErrorMailboxStoreUnavailable, + ErrorMessageSizeExceeded, + ErrorMimeContentConversionFailed, + ErrorNameResolutionMultipleResults, + ErrorNameResolutionNoResults, + ErrorNonExistentMailbox, + ErrorNoPublicFolderReplicaAvailable, + ErrorNoRespondingCASInDestinationSite, + ErrorNotDelegate, + ErrorQuotaExceeded, + ErrorRecurrenceHasNoOccurrence, + ErrorServerBusy, + ErrorTimeoutExpired, + ErrorTooManyObjectsOpened, + EWSWarning, + InvalidTypeError, + MalformedResponseError, + RateLimitError, + SessionPoolMinSizeReached, + SOAPError, + TransportError, + UnauthorizedError, +) from ..folders import BaseFolder, Folder, RootOfHierarchy from ..items import BaseItem -from ..properties import FieldURI, IndexedFieldURI, ExtendedFieldURI, ExceptionFieldURI, ItemId, FolderId, \ - DistinguishedFolderId, BaseItemId +from ..properties import ( + BaseItemId, + DistinguishedFolderId, + ExceptionFieldURI, + ExtendedFieldURI, + FieldURI, + FolderId, + IndexedFieldURI, + ItemId, +) from ..transport import wrap -from ..util import chunkify, create_element, add_xml_child, get_xml_attr, to_xml, post_ratelimited, \ - xml_to_str, set_xml_value, SOAPNS, TNS, MNS, ENS, ParseError, DummyResponse +from ..util import ( + ENS, + MNS, + SOAPNS, + TNS, + DummyResponse, + ParseError, + add_xml_child, + chunkify, + create_element, + get_xml_attr, + post_ratelimited, + set_xml_value, + to_xml, + xml_to_str, +) from ..version import API_VERSIONS, Version log = logging.getLogger(__name__) @@ -84,6 +148,7 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            ErrorInvalidSubscription, ErrorInvalidSyncStateData, ErrorInvalidWatermark, + ErrorItemCorrupt, ErrorItemNotFound, ErrorMailboxMoveInProgress, ErrorMailboxStoreUnavailable, @@ -109,9 +174,18 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            returns_elements = True # If False, the service does not return response elements, just the ResponseCode status # Return exception instance instead of raising exceptions for the following errors when contained in an element ERRORS_TO_CATCH_IN_RESPONSE = ( - EWSWarning, ErrorCannotDeleteObject, ErrorInvalidChangeKey, ErrorItemNotFound, ErrorItemSave, - ErrorInvalidIdMalformed, ErrorMessageSizeExceeded, ErrorCannotDeleteTaskOccurrence, - ErrorMimeContentConversionFailed, ErrorRecurrenceHasNoOccurrence, ErrorCorruptData + EWSWarning, + ErrorCannotDeleteObject, + ErrorInvalidChangeKey, + ErrorItemNotFound, + ErrorItemSave, + ErrorInvalidIdMalformed, + ErrorMessageSizeExceeded, + ErrorCannotDeleteTaskOccurrence, + ErrorMimeContentConversionFailed, + ErrorRecurrenceHasNoOccurrence, + ErrorCorruptData, + ErrorItemCorrupt, ) # Similarly, define the warnings we want to return unraised WARNINGS_TO_CATCH_IN_RESPONSE = ErrorBatchProcessingStopped @@ -127,13 +201,13 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            def __init__(self, protocol, chunk_size=None, timeout=None): self.chunk_size = chunk_size or CHUNK_SIZE if not isinstance(self.chunk_size, int): - raise InvalidTypeError('chunk_size', chunk_size, int) + raise InvalidTypeError("chunk_size", chunk_size, int) if self.chunk_size < 1: raise ValueError(f"'chunk_size' {self.chunk_size} must be a positive number") if self.supported_from and protocol.version.build < self.supported_from: raise NotImplementedError( - f'{self.SERVICE_NAME!r} is only supported on {self.supported_from.fullname()!r} and later. ' - f'Your current version is {protocol.version.build.fullname()!r}.' + f"{self.SERVICE_NAME!r} is only supported on {self.supported_from.fullname()!r} and later. " + f"Your current version is {protocol.version.build.fullname()!r}." ) self.protocol = protocol # Allow a service to override the default protocol timeout. Useful for streaming services @@ -144,6 +218,16 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            self._streaming_session = None self._streaming_response = None + def __del__(self): + # pylint: disable=bare-except + try: + if self.streaming: + # Make sure to clean up lingering resources + self.stop_streaming() + except Exception: # nosec + # __del__ should never fail + pass + # The following two methods are the minimum required to be implemented by subclasses, but the name and number of # kwargs differs between services. Therefore, we cannot make these methods abstract. @@ -179,10 +263,10 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            return None if expect_result is False: if res: - raise ValueError(f'Expected result length 0, but got {res}') + raise ValueError(f"Expected result length 0, but got {res}") return None if len(res) != 1: - raise ValueError(f'Expected result length 1, but got {res}') + raise ValueError(f"Expected result length 1, but got {res}") return res[0] def parse(self, xml): @@ -250,12 +334,12 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            # If the input for a service is a QuerySet, it can be difficult to remove exceptions before now filtered_items = filter(lambda i: not isinstance(i, Exception), items) for i, chunk in enumerate(chunkify(filtered_items, self.chunk_size), start=1): - log.debug('Processing chunk %s containing %s items', i, len(chunk)) + log.debug("Processing chunk %s containing %s items", i, len(chunk)) yield from self._get_elements(payload=payload_func(chunk, **kwargs)) def stop_streaming(self): if not self.streaming: - raise RuntimeError('Attempt to stop a non-streaming service') + raise RuntimeError("Attempt to stop a non-streaming service") if self._streaming_response: self._streaming_response.close() # Release memory self._streaming_response = None @@ -292,11 +376,11 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            raise e # Re-raise as an ErrorServerBusy with a default delay of 5 minutes - raise ErrorServerBusy(f'Reraised from {e.__class__.__name__}({e})') + raise ErrorServerBusy(f"Reraised from {e.__class__.__name__}({e})") except Exception: # This may run in a thread, which obfuscates the stack trace. Print trace immediately. account = self.account if isinstance(self, EWSAccountService) else None - log.warning('Account %s: Exception in _get_elements: %s', account, traceback.format_exc(20)) + log.warning("Account %s: Exception in _get_elements: %s", account, traceback.format_exc(20)) raise finally: if self.streaming: @@ -307,10 +391,10 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            def _get_response(self, payload, api_version): """Send the actual HTTP request and get the response.""" - session = self.protocol.get_session() if self.streaming: # Make sure to clean up lingering resources self.stop_streaming() + session = self.protocol.get_session() r, session = post_ratelimited( protocol=self.protocol, session=session, @@ -354,9 +438,9 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            # Microsoft really doesn't want to make our lives easy. The server may report one version in our initial version # guessing tango, but then the server may decide that any arbitrary legacy backend server may actually process # the request for an account. Prepare to handle version-related errors and set the server version per-account. - log.debug('Calling service %s', self.SERVICE_NAME) + log.debug("Calling service %s", self.SERVICE_NAME) for api_version in self._api_versions_to_try: - log.debug('Trying API version %s', api_version) + log.debug("Trying API version %s", api_version) r = self._get_response(payload=payload, api_version=api_version) if self.streaming: # Let 'requests' decode raw data automatically @@ -371,10 +455,14 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            self._update_api_version(api_version=api_version, header=header, **parse_opts) try: return self._get_soap_messages(body=body, **parse_opts) - except (ErrorInvalidServerVersion, ErrorIncorrectSchemaVersion, ErrorInvalidRequest, - ErrorInvalidSchemaVersionForMailboxVersion): + except ( + ErrorInvalidServerVersion, + ErrorIncorrectSchemaVersion, + ErrorInvalidRequest, + ErrorInvalidSchemaVersionForMailboxVersion, + ): # The guessed server version is wrong. Try the next version - log.debug('API version %s was invalid', api_version) + log.debug("API version %s was invalid", api_version) continue except ErrorExceededConnectionCount as e: # This indicates that the connecting user has too many open TCP connections to the server. Decrease @@ -390,7 +478,7 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            # In streaming mode, we may not have accessed the raw stream yet. Caller must handle this. r.close() # Release memory - raise self.NO_VALID_SERVER_VERSIONS(f'Tried versions {self._api_versions_to_try} but all were invalid') + raise self.NO_VALID_SERVER_VERSIONS(f"Tried versions {self._api_versions_to_try} but all were invalid") def _handle_backoff(self, e): """Take a request from the server to back off and checks the retry policy for what to do. Re-raise the @@ -399,7 +487,7 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            :param e: An ErrorServerBusy instance :return: """ - log.debug('Got ErrorServerBusy (back off %s seconds)', e.back_off) + log.debug("Got ErrorServerBusy (back off %s seconds)", e.back_off) # ErrorServerBusy is very often a symptom of sending too many requests. Scale back connections if possible. try: self.protocol.decrease_poolsize() @@ -417,12 +505,12 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            try: head_version = Version.from_soap_header(requested_api_version=api_version, header=header) except TransportError as te: - log.debug('Failed to update version info (%s)', te) + log.debug("Failed to update version info (%s)", te) return if self._version_hint == head_version: # Nothing to do return - log.debug('Found new version (%s -> %s)', self._version_hint, head_version) + log.debug("Found new version (%s -> %s)", self._version_hint, head_version) # The api_version that worked was different than our hint, or we never got a build version. Store the working # version. self._version_hint = head_version @@ -430,17 +518,17 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            @classmethod def _response_tag(cls): """Return the name of the element containing the service response.""" - return f'{{{MNS}}}{cls.SERVICE_NAME}Response' + return f"{{{MNS}}}{cls.SERVICE_NAME}Response" @staticmethod def _response_messages_tag(): """Return the name of the element containing service response messages.""" - return f'{{{MNS}}}ResponseMessages' + return f"{{{MNS}}}ResponseMessages" @classmethod def _response_message_tag(cls): """Return the name of the element of a single response message.""" - return f'{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage' + return f"{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage" @classmethod def _get_soap_parts(cls, response, **parse_opts): @@ -448,23 +536,23 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            try: root = to_xml(response.iter_content()) except ParseError as e: - raise SOAPError(f'Bad SOAP response: {e}') - header = root.find(f'{{{SOAPNS}}}Header') + raise SOAPError(f"Bad SOAP response: {e}") + header = root.find(f"{{{SOAPNS}}}Header") if header is None: # This is normal when the response contains SOAP-level errors - log.debug('No header in XML response') - body = root.find(f'{{{SOAPNS}}}Body') + log.debug("No header in XML response") + body = root.find(f"{{{SOAPNS}}}Body") if body is None: - raise MalformedResponseError('No Body element in SOAP response') + raise MalformedResponseError("No Body element in SOAP response") return header, body def _get_soap_messages(self, body, **parse_opts): """Return the elements in the response containing the response messages. Raises any SOAP exceptions.""" response = body.find(self._response_tag()) if response is None: - fault = body.find(f'{{{SOAPNS}}}Fault') + fault = body.find(f"{{{SOAPNS}}}Fault") if fault is None: - raise SOAPError(f'Unknown SOAP response (expected {self._response_tag()} or Fault): {xml_to_str(body)}') + raise SOAPError(f"Unknown SOAP response (expected {self._response_tag()} or Fault): {xml_to_str(body)}") self._raise_soap_errors(fault=fault) # Will throw SOAPError or custom EWS error response_messages = response.find(self._response_messages_tag()) if response_messages is None: @@ -477,43 +565,43 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            def _raise_soap_errors(cls, fault): """Parse error messages contained in SOAP headers and raise as exceptions defined in this package.""" # Fault: See http://www.w3.org/TR/2000/NOTE-SOAP-20000508/#_Toc478383507 - fault_code = get_xml_attr(fault, 'faultcode') - fault_string = get_xml_attr(fault, 'faultstring') - fault_actor = get_xml_attr(fault, 'faultactor') - detail = fault.find('detail') + fault_code = get_xml_attr(fault, "faultcode") + fault_string = get_xml_attr(fault, "faultstring") + fault_actor = get_xml_attr(fault, "faultactor") + detail = fault.find("detail") if detail is not None: - code, msg = None, '' - if detail.find(f'{{{ENS}}}ResponseCode') is not None: - code = get_xml_attr(detail, f'{{{ENS}}}ResponseCode').strip() - if detail.find(f'{{{ENS}}}Message') is not None: - msg = get_xml_attr(detail, f'{{{ENS}}}Message').strip() - msg_xml = detail.find(f'{{{TNS}}}MessageXml') # Crazy. Here, it's in the TNS namespace - if code == 'ErrorServerBusy': + code, msg = None, "" + if detail.find(f"{{{ENS}}}ResponseCode") is not None: + code = get_xml_attr(detail, f"{{{ENS}}}ResponseCode").strip() + if detail.find(f"{{{ENS}}}Message") is not None: + msg = get_xml_attr(detail, f"{{{ENS}}}Message").strip() + msg_xml = detail.find(f"{{{TNS}}}MessageXml") # Crazy. Here, it's in the TNS namespace + if code == "ErrorServerBusy": back_off = None try: - value = msg_xml.find(f'{{{TNS}}}Value') - if value.get('Name') == 'BackOffMilliseconds': + value = msg_xml.find(f"{{{TNS}}}Value") + if value.get("Name") == "BackOffMilliseconds": back_off = int(value.text) / 1000.0 # Convert to seconds except (TypeError, AttributeError): pass raise ErrorServerBusy(msg, back_off=back_off) - if code == 'ErrorSchemaValidation' and msg_xml is not None: - line_number = get_xml_attr(msg_xml, f'{{{TNS}}}LineNumber') - line_position = get_xml_attr(msg_xml, f'{{{TNS}}}LinePosition') - violation = get_xml_attr(msg_xml, f'{{{TNS}}}Violation') + if code == "ErrorSchemaValidation" and msg_xml is not None: + line_number = get_xml_attr(msg_xml, f"{{{TNS}}}LineNumber") + line_position = get_xml_attr(msg_xml, f"{{{TNS}}}LinePosition") + violation = get_xml_attr(msg_xml, f"{{{TNS}}}Violation") if violation: - msg = f'{msg} {violation}' + msg = f"{msg} {violation}" if line_number or line_position: - msg = f'{msg} (line: {line_number} position: {line_position})' + msg = f"{msg} (line: {line_number} position: {line_position})" try: raise vars(errors)[code](msg) except KeyError: - detail = f'{cls.SERVICE_NAME}: code: {code} msg: {msg} ({xml_to_str(detail)})' + detail = f"{cls.SERVICE_NAME}: code: {code} msg: {msg} ({xml_to_str(detail)})" try: raise vars(errors)[fault_code](fault_string) except KeyError: pass - raise SOAPError(f'SOAP error code: {fault_code} string: {fault_string} actor: {fault_actor} detail: {detail}') + raise SOAPError(f"SOAP error code: {fault_code} string: {fault_string} actor: {fault_actor} detail: {detail}") def _get_element_container(self, message, name=None): """Return the XML element in a response element that contains the elements we want the service to return. For @@ -540,23 +628,23 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            # ResponseClass is an XML attribute of various SomeServiceResponseMessage elements: Possible values are: # Success, Warning, Error. See e.g. # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/finditemresponsemessage - response_class = message.get('ResponseClass') + response_class = message.get("ResponseClass") # ResponseCode, MessageText: See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/responsecode - response_code = get_xml_attr(message, f'{{{MNS}}}ResponseCode') - if response_class == 'Success' and response_code == 'NoError': + response_code = get_xml_attr(message, f"{{{MNS}}}ResponseCode") + if response_class == "Success" and response_code == "NoError": if not name: return message container = message.find(name) if container is None: - raise MalformedResponseError(f'No {name} elements in ResponseMessage ({xml_to_str(message)})') + raise MalformedResponseError(f"No {name} elements in ResponseMessage ({xml_to_str(message)})") return container - if response_code == 'NoError': + if response_code == "NoError": return True # Raise any non-acceptable errors in the container, or return the container or the acceptable exception instance - msg_text = get_xml_attr(message, f'{{{MNS}}}MessageText') - msg_xml = message.find(f'{{{MNS}}}MessageXml') - if response_class == 'Warning': + msg_text = get_xml_attr(message, f"{{{MNS}}}MessageText") + msg_xml = message.find(f"{{{MNS}}}MessageXml") + if response_class == "Warning": try: raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml) except self.WARNINGS_TO_CATCH_IN_RESPONSE as e: @@ -565,7 +653,7 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            log.warning(str(e)) container = message.find(name) if container is None: - raise MalformedResponseError(f'No {name} elements in ResponseMessage ({xml_to_str(message)})') + raise MalformedResponseError(f"No {name} elements in ResponseMessage ({xml_to_str(message)})") return container # rspclass == 'Error', or 'Success' and not 'NoError' try: @@ -577,21 +665,21 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            def _get_exception(code, text, msg_xml): """Parse error messages contained in EWS responses and raise as exceptions defined in this package.""" if not code: - return TransportError(f'Empty ResponseCode in ResponseMessage (MessageText: {text}, MessageXml: {msg_xml})') + return TransportError(f"Empty ResponseCode in ResponseMessage (MessageText: {text}, MessageXml: {msg_xml})") if msg_xml is not None: # If this is an ErrorInvalidPropertyRequest error, the xml may contain a specific FieldURI for elem_cls in (FieldURI, IndexedFieldURI, ExtendedFieldURI, ExceptionFieldURI): elem = msg_xml.find(elem_cls.response_tag()) if elem is not None: field_uri = elem_cls.from_xml(elem, account=None) - text += f' (field: {field_uri})' + text += f" (field: {field_uri})" break # If this is an ErrorInvalidValueForProperty error, the xml may contain the name and value of the property - if code == 'ErrorInvalidValueForProperty': + if code == "ErrorInvalidValueForProperty": msg_parts = {} - for elem in msg_xml.findall(f'{{{TNS}}}Value'): - key, val = elem.get('Name'), elem.text + for elem in msg_xml.findall(f"{{{TNS}}}Value"): + key, val = elem.get("Name"), elem.text if key: msg_parts[key] = val if msg_parts: @@ -599,26 +687,26 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            # If this is an ErrorInternalServerError error, the xml may contain a more specific error code inner_code, inner_text = None, None - for value_elem in msg_xml.findall(f'{{{TNS}}}Value'): - name = value_elem.get('Name') - if name == 'InnerErrorResponseCode': + for value_elem in msg_xml.findall(f"{{{TNS}}}Value"): + name = value_elem.get("Name") + if name == "InnerErrorResponseCode": inner_code = value_elem.text - elif name == 'InnerErrorMessageText': + elif name == "InnerErrorMessageText": inner_text = value_elem.text if inner_code: try: # Raise the error as the inner error code - return vars(errors)[inner_code](f'{inner_text} (raised from: {code}({text!r}))') + return vars(errors)[inner_code](f"{inner_text} (raised from: {code}({text!r}))") except KeyError: # Inner code is unknown to us. Just append to the original text - text += f' (inner error: {inner_code}({inner_text!r}))' + text += f" (inner error: {inner_code}({inner_text!r}))" try: # Raise the error corresponding to the ResponseCode return vars(errors)[code](text) except KeyError: # Should not happen return TransportError( - f'Unknown ResponseCode in ResponseMessage: {code} (MessageText: {text}, MessageXml: {msg_xml})' + f"Unknown ResponseCode in ResponseMessage: {code} (MessageText: {text}, MessageXml: {msg_xml})" ) def _get_elements_in_response(self, response): @@ -681,8 +769,8 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            prefer_affinity = False def __init__(self, *args, **kwargs): - self.account = kwargs.pop('account') - kwargs['protocol'] = self.account.protocol + self.account = kwargs.pop("account") + kwargs["protocol"] = self.account.protocol super().__init__(*args, **kwargs) @property @@ -699,7 +787,7 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            # See self._extra_headers() for documentation on affinity if self.prefer_affinity: for cookie in session.cookies: - if cookie.name == 'X-BackEndOverrideCookie': + if cookie.name == "X-BackEndOverrideCookie": self.account.affinity_cookie = cookie.value break @@ -707,14 +795,14 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            headers = super()._extra_headers(session=session) # See # https://blogs.msdn.microsoft.com/webdav_101/2015/05/11/best-practices-ews-authentication-and-access-issues/ - headers['X-AnchorMailbox'] = self.account.primary_smtp_address + headers["X-AnchorMailbox"] = self.account.primary_smtp_address # See # https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/how-to-maintain-affinity-between-group-of-subscriptions-and-mailbox-server if self.prefer_affinity: - headers['X-PreferServerAffinity'] = 'True' + headers["X-PreferServerAffinity"] = "True" if self.account.affinity_cookie: - headers['X-BackEndOverrideCookie'] = self.account.affinity_cookie + headers["X-BackEndOverrideCookie"] = self.account.affinity_cookie return headers @property @@ -730,9 +818,9 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            class EWSPagingService(EWSAccountService): def __init__(self, *args, **kwargs): - self.page_size = kwargs.pop('page_size', None) or PAGE_SIZE + self.page_size = kwargs.pop("page_size", None) or PAGE_SIZE if not isinstance(self.page_size, int): - raise InvalidTypeError('page_size', self.page_size, int) + raise InvalidTypeError("page_size", self.page_size, int) if self.page_size < 1: raise ValueError(f"'page_size' {self.page_size} must be a positive number") super().__init__(*args, **kwargs) @@ -742,48 +830,51 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            all paging-related counters. """ paging_infos = {f: dict(item_count=0, next_offset=None) for f in folders} - common_next_offset = kwargs['offset'] + common_next_offset = kwargs["offset"] total_item_count = 0 while True: if not paging_infos: # Paging is done for all folders break - log.debug('Getting page at offset %s (max_items %s)', common_next_offset, max_items) - kwargs['offset'] = common_next_offset - kwargs['folders'] = paging_infos.keys() # Only request the paging of the remaining folders. + log.debug("Getting page at offset %s (max_items %s)", common_next_offset, max_items) + kwargs["offset"] = common_next_offset + kwargs["folders"] = paging_infos.keys() # Only request the paging of the remaining folders. pages = self._get_pages(payload_func, kwargs, len(paging_infos)) for (page, next_offset), (f, paging_info) in zip(pages, list(paging_infos.items())): - paging_info['next_offset'] = next_offset + paging_info["next_offset"] = next_offset if isinstance(page, Exception): # Assume this folder no longer works. Don't attempt to page it again. - log.debug('Exception occurred for folder %s. Removing.', f) + log.debug("Exception occurred for folder %s. Removing.", f) del paging_infos[f] yield page continue if page is not None: for elem in self._get_elems_from_page(page, max_items, total_item_count): - paging_info['item_count'] += 1 + paging_info["item_count"] += 1 total_item_count += 1 yield elem if max_items and total_item_count >= max_items: # No need to continue. Break out of inner loop log.debug("'max_items' count reached (inner)") break - if not paging_info['next_offset']: + if not paging_info["next_offset"]: # Paging is done for this folder. Don't attempt to page it again. - log.debug('Paging has completed for folder %s. Removing.', f) + log.debug("Paging has completed for folder %s. Removing.", f) del paging_infos[f] continue - log.debug('Folder %s still has items', f) + log.debug("Folder %s still has items", f) # Check sanity of paging offsets, but don't fail. When we are iterating huge collections that take a # long time to complete, the collection may change while we are iterating. This can affect the # 'next_offset' value and make it inconsistent with the number of already collected items. # We may have a mismatch if we stopped early due to reaching 'max_items'. - if paging_info['next_offset'] != paging_info['item_count'] and ( + if paging_info["next_offset"] != paging_info["item_count"] and ( not max_items or total_item_count < max_items ): - log.warning('Unexpected next offset: %s -> %s. Maybe the server-side collection has changed?', - paging_info['item_count'], paging_info['next_offset']) + log.warning( + "Unexpected next offset: %s -> %s. Maybe the server-side collection has changed?", + paging_info["item_count"], + paging_info["next_offset"], + ) # Also break out of outer loop if max_items and total_item_count >= max_items: log.debug("'max_items' count reached (outer)") @@ -796,11 +887,11 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            @staticmethod def _get_paging_values(elem): """Read paging information from the paging container element.""" - offset_attr = elem.get('IndexedPagingOffset') + offset_attr = elem.get("IndexedPagingOffset") next_offset = None if offset_attr is None else int(offset_attr) - item_count = int(elem.get('TotalItemsInView')) - is_last_page = elem.get('IncludesLastItemInRange').lower() in ('true', '0') - log.debug('Got page with offset %s, item_count %s, last_page %s', next_offset, item_count, is_last_page) + item_count = int(elem.get("TotalItemsInView")) + is_last_page = elem.get("IncludesLastItemInRange").lower() in ("true", "0") + log.debug("Got page with offset %s, item_count %s, last_page %s", next_offset, item_count, is_last_page) # Clean up contradictory paging values if next_offset is None and not is_last_page: log.debug("Not last page in range, but server didn't send a page offset. Assuming first page") @@ -831,7 +922,7 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            container = elem.find(self.element_container_name) if container is None: raise MalformedResponseError( - f'No {self.element_container_name} elements in ResponseMessage ({xml_to_str(elem)})' + f"No {self.element_container_name} elements in ResponseMessage ({xml_to_str(elem)})" ) for e in self._get_elements_in_container(container=container): if max_items and total_item_count >= max_items: @@ -854,7 +945,7 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            @staticmethod def _get_next_offset(paging_infos): - next_offsets = {p['next_offset'] for p in paging_infos if p['next_offset'] is not None} + next_offsets = {p["next_offset"] for p in paging_infos if p["next_offset"] is not None} if not next_offsets: # Paging is done for all messages return None @@ -866,7 +957,7 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            # choose something that is most likely to work. Select the lowest of all the values to at least make sure # we don't miss any items, although we may then get duplicates ¯\_(ツ)_/¯ if len(next_offsets) > 1: - log.warning('Inconsistent next_offset values: %r. Using lowest value', next_offsets) + log.warning("Inconsistent next_offset values: %r. Using lowest value", next_offsets) return min(next_offsets) @@ -888,17 +979,18 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            def shape_element(tag, shape, additional_fields, version): shape_elem = create_element(tag) - add_xml_child(shape_elem, 't:BaseShape', shape) + add_xml_child(shape_elem, "t:BaseShape", shape) if additional_fields: - additional_properties = create_element('t:AdditionalProperties') + additional_properties = create_element("t:AdditionalProperties") expanded_fields = chain(*(f.expand(version=version) for f in additional_fields)) # 'path' is insufficient to consistently sort additional properties. For example, we have both # 'contacts:Companies' and 'task:Companies' with path 'companies'. Sort by both 'field_uri' and 'path'. # Extended properties do not have a 'field_uri' value. - set_xml_value(additional_properties, sorted( - expanded_fields, - key=lambda f: (getattr(f.field, 'field_uri', ''), f.path) - ), version=version) + set_xml_value( + additional_properties, + sorted(expanded_fields, key=lambda f: (getattr(f.field, "field_uri", ""), f.path)), + version=version, + ) shape_elem.append(additional_properties) return shape_elem @@ -910,15 +1002,15 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            return item_ids -def folder_ids_element(folders, version, tag='m:FolderIds'): +def folder_ids_element(folders, version, tag="m:FolderIds"): return _ids_element(folders, FolderId, version, tag) -def item_ids_element(items, version, tag='m:ItemIds'): +def item_ids_element(items, version, tag="m:ItemIds"): return _ids_element(items, ItemId, version, tag) -def attachment_ids_element(items, version, tag='m:AttachmentIds'): +def attachment_ids_element(items, version, tag="m:AttachmentIds"): return _ids_element(items, AttachmentId, version, tag) @@ -934,7 +1026,7 @@

                                                                                                                                                                                                                                            Module exchangelib.services.common

                                                                                                                                                                                                                                            folder_cls = cls break else: - raise ValueError(f'Unknown distinguished folder ID: {folder.id}') + raise ValueError(f"Unknown distinguished folder ID: {folder.id}") f = folder_cls.from_xml_with_root(elem=elem, root=account.root) else: # 'folder' is a generic FolderId instance. We don't know the root so assume account.root. @@ -962,7 +1054,7 @@

                                                                                                                                                                                                                                            Functions

                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                            def attachment_ids_element(items, version, tag='m:AttachmentIds'):
                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                            def attachment_ids_element(items, version, tag="m:AttachmentIds"):
                                                                                                                                                                                                                                                 return _ids_element(items, AttachmentId, version, tag)
                                                                                                                                                                                                                                            @@ -975,7 +1067,7 @@

                                                                                                                                                                                                                                            Functions

                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                            def folder_ids_element(folders, version, tag='m:FolderIds'):
                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                            def folder_ids_element(folders, version, tag="m:FolderIds"):
                                                                                                                                                                                                                                                 return _ids_element(folders, FolderId, version, tag)
                                                                                                                                                                                                                                            @@ -988,7 +1080,7 @@

                                                                                                                                                                                                                                            Functions

                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                            def item_ids_element(items, version, tag='m:ItemIds'):
                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                            def item_ids_element(items, version, tag="m:ItemIds"):
                                                                                                                                                                                                                                                 return _ids_element(items, ItemId, version, tag)
                                                                                                                                                                                                                                            @@ -1013,7 +1105,7 @@

                                                                                                                                                                                                                                            Functions

                                                                                                                                                                                                                                            folder_cls = cls break else: - raise ValueError(f'Unknown distinguished folder ID: {folder.id}') + raise ValueError(f"Unknown distinguished folder ID: {folder.id}") f = folder_cls.from_xml_with_root(elem=elem, root=account.root) else: # 'folder' is a generic FolderId instance. We don't know the root so assume account.root. @@ -1036,17 +1128,18 @@

                                                                                                                                                                                                                                            Functions

                                                                                                                                                                                                                                            def shape_element(tag, shape, additional_fields, version):
                                                                                                                                                                                                                                                 shape_elem = create_element(tag)
                                                                                                                                                                                                                                            -    add_xml_child(shape_elem, 't:BaseShape', shape)
                                                                                                                                                                                                                                            +    add_xml_child(shape_elem, "t:BaseShape", shape)
                                                                                                                                                                                                                                                 if additional_fields:
                                                                                                                                                                                                                                            -        additional_properties = create_element('t:AdditionalProperties')
                                                                                                                                                                                                                                            +        additional_properties = create_element("t:AdditionalProperties")
                                                                                                                                                                                                                                                     expanded_fields = chain(*(f.expand(version=version) for f in additional_fields))
                                                                                                                                                                                                                                                     # 'path' is insufficient to consistently sort additional properties. For example, we have both
                                                                                                                                                                                                                                                     # 'contacts:Companies' and 'task:Companies' with path 'companies'. Sort by both 'field_uri' and 'path'.
                                                                                                                                                                                                                                                     # Extended properties do not have a 'field_uri' value.
                                                                                                                                                                                                                                            -        set_xml_value(additional_properties, sorted(
                                                                                                                                                                                                                                            -            expanded_fields,
                                                                                                                                                                                                                                            -            key=lambda f: (getattr(f.field, 'field_uri', ''), f.path)
                                                                                                                                                                                                                                            -        ), version=version)
                                                                                                                                                                                                                                            +        set_xml_value(
                                                                                                                                                                                                                                            +            additional_properties,
                                                                                                                                                                                                                                            +            sorted(expanded_fields, key=lambda f: (getattr(f.field, "field_uri", ""), f.path)),
                                                                                                                                                                                                                                            +            version=version,
                                                                                                                                                                                                                                            +        )
                                                                                                                                                                                                                                                     shape_elem.append(additional_properties)
                                                                                                                                                                                                                                                 return shape_elem
                                                                                                                                                                                                                                            @@ -1099,8 +1192,8 @@

                                                                                                                                                                                                                                            Classes

                                                                                                                                                                                                                                            prefer_affinity = False def __init__(self, *args, **kwargs): - self.account = kwargs.pop('account') - kwargs['protocol'] = self.account.protocol + self.account = kwargs.pop("account") + kwargs["protocol"] = self.account.protocol super().__init__(*args, **kwargs) @property @@ -1117,7 +1210,7 @@

                                                                                                                                                                                                                                            Classes

                                                                                                                                                                                                                                            # See self._extra_headers() for documentation on affinity if self.prefer_affinity: for cookie in session.cookies: - if cookie.name == 'X-BackEndOverrideCookie': + if cookie.name == "X-BackEndOverrideCookie": self.account.affinity_cookie = cookie.value break @@ -1125,14 +1218,14 @@

                                                                                                                                                                                                                                            Classes

                                                                                                                                                                                                                                            headers = super()._extra_headers(session=session) # See # https://blogs.msdn.microsoft.com/webdav_101/2015/05/11/best-practices-ews-authentication-and-access-issues/ - headers['X-AnchorMailbox'] = self.account.primary_smtp_address + headers["X-AnchorMailbox"] = self.account.primary_smtp_address # See # https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/how-to-maintain-affinity-between-group-of-subscriptions-and-mailbox-server if self.prefer_affinity: - headers['X-PreferServerAffinity'] = 'True' + headers["X-PreferServerAffinity"] = "True" if self.account.affinity_cookie: - headers['X-BackEndOverrideCookie'] = self.account.affinity_cookie + headers["X-BackEndOverrideCookie"] = self.account.affinity_cookie return headers @property @@ -1214,9 +1307,9 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            class EWSPagingService(EWSAccountService):
                                                                                                                                                                                                                                                 def __init__(self, *args, **kwargs):
                                                                                                                                                                                                                                            -        self.page_size = kwargs.pop('page_size', None) or PAGE_SIZE
                                                                                                                                                                                                                                            +        self.page_size = kwargs.pop("page_size", None) or PAGE_SIZE
                                                                                                                                                                                                                                                     if not isinstance(self.page_size, int):
                                                                                                                                                                                                                                            -            raise InvalidTypeError('page_size', self.page_size, int)
                                                                                                                                                                                                                                            +            raise InvalidTypeError("page_size", self.page_size, int)
                                                                                                                                                                                                                                                     if self.page_size < 1:
                                                                                                                                                                                                                                                         raise ValueError(f"'page_size' {self.page_size} must be a positive number")
                                                                                                                                                                                                                                                     super().__init__(*args, **kwargs)
                                                                                                                                                                                                                                            @@ -1226,48 +1319,51 @@ 

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            all paging-related counters. """ paging_infos = {f: dict(item_count=0, next_offset=None) for f in folders} - common_next_offset = kwargs['offset'] + common_next_offset = kwargs["offset"] total_item_count = 0 while True: if not paging_infos: # Paging is done for all folders break - log.debug('Getting page at offset %s (max_items %s)', common_next_offset, max_items) - kwargs['offset'] = common_next_offset - kwargs['folders'] = paging_infos.keys() # Only request the paging of the remaining folders. + log.debug("Getting page at offset %s (max_items %s)", common_next_offset, max_items) + kwargs["offset"] = common_next_offset + kwargs["folders"] = paging_infos.keys() # Only request the paging of the remaining folders. pages = self._get_pages(payload_func, kwargs, len(paging_infos)) for (page, next_offset), (f, paging_info) in zip(pages, list(paging_infos.items())): - paging_info['next_offset'] = next_offset + paging_info["next_offset"] = next_offset if isinstance(page, Exception): # Assume this folder no longer works. Don't attempt to page it again. - log.debug('Exception occurred for folder %s. Removing.', f) + log.debug("Exception occurred for folder %s. Removing.", f) del paging_infos[f] yield page continue if page is not None: for elem in self._get_elems_from_page(page, max_items, total_item_count): - paging_info['item_count'] += 1 + paging_info["item_count"] += 1 total_item_count += 1 yield elem if max_items and total_item_count >= max_items: # No need to continue. Break out of inner loop log.debug("'max_items' count reached (inner)") break - if not paging_info['next_offset']: + if not paging_info["next_offset"]: # Paging is done for this folder. Don't attempt to page it again. - log.debug('Paging has completed for folder %s. Removing.', f) + log.debug("Paging has completed for folder %s. Removing.", f) del paging_infos[f] continue - log.debug('Folder %s still has items', f) + log.debug("Folder %s still has items", f) # Check sanity of paging offsets, but don't fail. When we are iterating huge collections that take a # long time to complete, the collection may change while we are iterating. This can affect the # 'next_offset' value and make it inconsistent with the number of already collected items. # We may have a mismatch if we stopped early due to reaching 'max_items'. - if paging_info['next_offset'] != paging_info['item_count'] and ( + if paging_info["next_offset"] != paging_info["item_count"] and ( not max_items or total_item_count < max_items ): - log.warning('Unexpected next offset: %s -> %s. Maybe the server-side collection has changed?', - paging_info['item_count'], paging_info['next_offset']) + log.warning( + "Unexpected next offset: %s -> %s. Maybe the server-side collection has changed?", + paging_info["item_count"], + paging_info["next_offset"], + ) # Also break out of outer loop if max_items and total_item_count >= max_items: log.debug("'max_items' count reached (outer)") @@ -1280,11 +1376,11 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            @staticmethod def _get_paging_values(elem): """Read paging information from the paging container element.""" - offset_attr = elem.get('IndexedPagingOffset') + offset_attr = elem.get("IndexedPagingOffset") next_offset = None if offset_attr is None else int(offset_attr) - item_count = int(elem.get('TotalItemsInView')) - is_last_page = elem.get('IncludesLastItemInRange').lower() in ('true', '0') - log.debug('Got page with offset %s, item_count %s, last_page %s', next_offset, item_count, is_last_page) + item_count = int(elem.get("TotalItemsInView")) + is_last_page = elem.get("IncludesLastItemInRange").lower() in ("true", "0") + log.debug("Got page with offset %s, item_count %s, last_page %s", next_offset, item_count, is_last_page) # Clean up contradictory paging values if next_offset is None and not is_last_page: log.debug("Not last page in range, but server didn't send a page offset. Assuming first page") @@ -1315,7 +1411,7 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            container = elem.find(self.element_container_name) if container is None: raise MalformedResponseError( - f'No {self.element_container_name} elements in ResponseMessage ({xml_to_str(elem)})' + f"No {self.element_container_name} elements in ResponseMessage ({xml_to_str(elem)})" ) for e in self._get_elements_in_container(container=container): if max_items and total_item_count >= max_items: @@ -1338,7 +1434,7 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            @staticmethod def _get_next_offset(paging_infos): - next_offsets = {p['next_offset'] for p in paging_infos if p['next_offset'] is not None} + next_offsets = {p["next_offset"] for p in paging_infos if p["next_offset"] is not None} if not next_offsets: # Paging is done for all messages return None @@ -1350,7 +1446,7 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            # choose something that is most likely to work. Select the lowest of all the values to at least make sure # we don't miss any items, although we may then get duplicates ¯\_(ツ)_/¯ if len(next_offsets) > 1: - log.warning('Inconsistent next_offset values: %r. Using lowest value', next_offsets) + log.warning("Inconsistent next_offset values: %r. Using lowest value", next_offsets) return min(next_offsets)

                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                            @@ -1396,9 +1492,18 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            returns_elements = True # If False, the service does not return response elements, just the ResponseCode status # Return exception instance instead of raising exceptions for the following errors when contained in an element ERRORS_TO_CATCH_IN_RESPONSE = ( - EWSWarning, ErrorCannotDeleteObject, ErrorInvalidChangeKey, ErrorItemNotFound, ErrorItemSave, - ErrorInvalidIdMalformed, ErrorMessageSizeExceeded, ErrorCannotDeleteTaskOccurrence, - ErrorMimeContentConversionFailed, ErrorRecurrenceHasNoOccurrence, ErrorCorruptData + EWSWarning, + ErrorCannotDeleteObject, + ErrorInvalidChangeKey, + ErrorItemNotFound, + ErrorItemSave, + ErrorInvalidIdMalformed, + ErrorMessageSizeExceeded, + ErrorCannotDeleteTaskOccurrence, + ErrorMimeContentConversionFailed, + ErrorRecurrenceHasNoOccurrence, + ErrorCorruptData, + ErrorItemCorrupt, ) # Similarly, define the warnings we want to return unraised WARNINGS_TO_CATCH_IN_RESPONSE = ErrorBatchProcessingStopped @@ -1414,13 +1519,13 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            def __init__(self, protocol, chunk_size=None, timeout=None): self.chunk_size = chunk_size or CHUNK_SIZE if not isinstance(self.chunk_size, int): - raise InvalidTypeError('chunk_size', chunk_size, int) + raise InvalidTypeError("chunk_size", chunk_size, int) if self.chunk_size < 1: raise ValueError(f"'chunk_size' {self.chunk_size} must be a positive number") if self.supported_from and protocol.version.build < self.supported_from: raise NotImplementedError( - f'{self.SERVICE_NAME!r} is only supported on {self.supported_from.fullname()!r} and later. ' - f'Your current version is {protocol.version.build.fullname()!r}.' + f"{self.SERVICE_NAME!r} is only supported on {self.supported_from.fullname()!r} and later. " + f"Your current version is {protocol.version.build.fullname()!r}." ) self.protocol = protocol # Allow a service to override the default protocol timeout. Useful for streaming services @@ -1431,6 +1536,16 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            self._streaming_session = None self._streaming_response = None + def __del__(self): + # pylint: disable=bare-except + try: + if self.streaming: + # Make sure to clean up lingering resources + self.stop_streaming() + except Exception: # nosec + # __del__ should never fail + pass + # The following two methods are the minimum required to be implemented by subclasses, but the name and number of # kwargs differs between services. Therefore, we cannot make these methods abstract. @@ -1466,10 +1581,10 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            return None if expect_result is False: if res: - raise ValueError(f'Expected result length 0, but got {res}') + raise ValueError(f"Expected result length 0, but got {res}") return None if len(res) != 1: - raise ValueError(f'Expected result length 1, but got {res}') + raise ValueError(f"Expected result length 1, but got {res}") return res[0] def parse(self, xml): @@ -1537,12 +1652,12 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            # If the input for a service is a QuerySet, it can be difficult to remove exceptions before now filtered_items = filter(lambda i: not isinstance(i, Exception), items) for i, chunk in enumerate(chunkify(filtered_items, self.chunk_size), start=1): - log.debug('Processing chunk %s containing %s items', i, len(chunk)) + log.debug("Processing chunk %s containing %s items", i, len(chunk)) yield from self._get_elements(payload=payload_func(chunk, **kwargs)) def stop_streaming(self): if not self.streaming: - raise RuntimeError('Attempt to stop a non-streaming service') + raise RuntimeError("Attempt to stop a non-streaming service") if self._streaming_response: self._streaming_response.close() # Release memory self._streaming_response = None @@ -1579,11 +1694,11 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            raise e # Re-raise as an ErrorServerBusy with a default delay of 5 minutes - raise ErrorServerBusy(f'Reraised from {e.__class__.__name__}({e})') + raise ErrorServerBusy(f"Reraised from {e.__class__.__name__}({e})") except Exception: # This may run in a thread, which obfuscates the stack trace. Print trace immediately. account = self.account if isinstance(self, EWSAccountService) else None - log.warning('Account %s: Exception in _get_elements: %s', account, traceback.format_exc(20)) + log.warning("Account %s: Exception in _get_elements: %s", account, traceback.format_exc(20)) raise finally: if self.streaming: @@ -1594,10 +1709,10 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            def _get_response(self, payload, api_version): """Send the actual HTTP request and get the response.""" - session = self.protocol.get_session() if self.streaming: # Make sure to clean up lingering resources self.stop_streaming() + session = self.protocol.get_session() r, session = post_ratelimited( protocol=self.protocol, session=session, @@ -1641,9 +1756,9 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            # Microsoft really doesn't want to make our lives easy. The server may report one version in our initial version # guessing tango, but then the server may decide that any arbitrary legacy backend server may actually process # the request for an account. Prepare to handle version-related errors and set the server version per-account. - log.debug('Calling service %s', self.SERVICE_NAME) + log.debug("Calling service %s", self.SERVICE_NAME) for api_version in self._api_versions_to_try: - log.debug('Trying API version %s', api_version) + log.debug("Trying API version %s", api_version) r = self._get_response(payload=payload, api_version=api_version) if self.streaming: # Let 'requests' decode raw data automatically @@ -1658,10 +1773,14 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            self._update_api_version(api_version=api_version, header=header, **parse_opts) try: return self._get_soap_messages(body=body, **parse_opts) - except (ErrorInvalidServerVersion, ErrorIncorrectSchemaVersion, ErrorInvalidRequest, - ErrorInvalidSchemaVersionForMailboxVersion): + except ( + ErrorInvalidServerVersion, + ErrorIncorrectSchemaVersion, + ErrorInvalidRequest, + ErrorInvalidSchemaVersionForMailboxVersion, + ): # The guessed server version is wrong. Try the next version - log.debug('API version %s was invalid', api_version) + log.debug("API version %s was invalid", api_version) continue except ErrorExceededConnectionCount as e: # This indicates that the connecting user has too many open TCP connections to the server. Decrease @@ -1677,7 +1796,7 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            # In streaming mode, we may not have accessed the raw stream yet. Caller must handle this. r.close() # Release memory - raise self.NO_VALID_SERVER_VERSIONS(f'Tried versions {self._api_versions_to_try} but all were invalid') + raise self.NO_VALID_SERVER_VERSIONS(f"Tried versions {self._api_versions_to_try} but all were invalid") def _handle_backoff(self, e): """Take a request from the server to back off and checks the retry policy for what to do. Re-raise the @@ -1686,7 +1805,7 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            :param e: An ErrorServerBusy instance :return: """ - log.debug('Got ErrorServerBusy (back off %s seconds)', e.back_off) + log.debug("Got ErrorServerBusy (back off %s seconds)", e.back_off) # ErrorServerBusy is very often a symptom of sending too many requests. Scale back connections if possible. try: self.protocol.decrease_poolsize() @@ -1704,12 +1823,12 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            try: head_version = Version.from_soap_header(requested_api_version=api_version, header=header) except TransportError as te: - log.debug('Failed to update version info (%s)', te) + log.debug("Failed to update version info (%s)", te) return if self._version_hint == head_version: # Nothing to do return - log.debug('Found new version (%s -> %s)', self._version_hint, head_version) + log.debug("Found new version (%s -> %s)", self._version_hint, head_version) # The api_version that worked was different than our hint, or we never got a build version. Store the working # version. self._version_hint = head_version @@ -1717,17 +1836,17 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            @classmethod def _response_tag(cls): """Return the name of the element containing the service response.""" - return f'{{{MNS}}}{cls.SERVICE_NAME}Response' + return f"{{{MNS}}}{cls.SERVICE_NAME}Response" @staticmethod def _response_messages_tag(): """Return the name of the element containing service response messages.""" - return f'{{{MNS}}}ResponseMessages' + return f"{{{MNS}}}ResponseMessages" @classmethod def _response_message_tag(cls): """Return the name of the element of a single response message.""" - return f'{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage' + return f"{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage" @classmethod def _get_soap_parts(cls, response, **parse_opts): @@ -1735,23 +1854,23 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            try: root = to_xml(response.iter_content()) except ParseError as e: - raise SOAPError(f'Bad SOAP response: {e}') - header = root.find(f'{{{SOAPNS}}}Header') + raise SOAPError(f"Bad SOAP response: {e}") + header = root.find(f"{{{SOAPNS}}}Header") if header is None: # This is normal when the response contains SOAP-level errors - log.debug('No header in XML response') - body = root.find(f'{{{SOAPNS}}}Body') + log.debug("No header in XML response") + body = root.find(f"{{{SOAPNS}}}Body") if body is None: - raise MalformedResponseError('No Body element in SOAP response') + raise MalformedResponseError("No Body element in SOAP response") return header, body def _get_soap_messages(self, body, **parse_opts): """Return the elements in the response containing the response messages. Raises any SOAP exceptions.""" response = body.find(self._response_tag()) if response is None: - fault = body.find(f'{{{SOAPNS}}}Fault') + fault = body.find(f"{{{SOAPNS}}}Fault") if fault is None: - raise SOAPError(f'Unknown SOAP response (expected {self._response_tag()} or Fault): {xml_to_str(body)}') + raise SOAPError(f"Unknown SOAP response (expected {self._response_tag()} or Fault): {xml_to_str(body)}") self._raise_soap_errors(fault=fault) # Will throw SOAPError or custom EWS error response_messages = response.find(self._response_messages_tag()) if response_messages is None: @@ -1764,43 +1883,43 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            def _raise_soap_errors(cls, fault): """Parse error messages contained in SOAP headers and raise as exceptions defined in this package.""" # Fault: See http://www.w3.org/TR/2000/NOTE-SOAP-20000508/#_Toc478383507 - fault_code = get_xml_attr(fault, 'faultcode') - fault_string = get_xml_attr(fault, 'faultstring') - fault_actor = get_xml_attr(fault, 'faultactor') - detail = fault.find('detail') + fault_code = get_xml_attr(fault, "faultcode") + fault_string = get_xml_attr(fault, "faultstring") + fault_actor = get_xml_attr(fault, "faultactor") + detail = fault.find("detail") if detail is not None: - code, msg = None, '' - if detail.find(f'{{{ENS}}}ResponseCode') is not None: - code = get_xml_attr(detail, f'{{{ENS}}}ResponseCode').strip() - if detail.find(f'{{{ENS}}}Message') is not None: - msg = get_xml_attr(detail, f'{{{ENS}}}Message').strip() - msg_xml = detail.find(f'{{{TNS}}}MessageXml') # Crazy. Here, it's in the TNS namespace - if code == 'ErrorServerBusy': + code, msg = None, "" + if detail.find(f"{{{ENS}}}ResponseCode") is not None: + code = get_xml_attr(detail, f"{{{ENS}}}ResponseCode").strip() + if detail.find(f"{{{ENS}}}Message") is not None: + msg = get_xml_attr(detail, f"{{{ENS}}}Message").strip() + msg_xml = detail.find(f"{{{TNS}}}MessageXml") # Crazy. Here, it's in the TNS namespace + if code == "ErrorServerBusy": back_off = None try: - value = msg_xml.find(f'{{{TNS}}}Value') - if value.get('Name') == 'BackOffMilliseconds': + value = msg_xml.find(f"{{{TNS}}}Value") + if value.get("Name") == "BackOffMilliseconds": back_off = int(value.text) / 1000.0 # Convert to seconds except (TypeError, AttributeError): pass raise ErrorServerBusy(msg, back_off=back_off) - if code == 'ErrorSchemaValidation' and msg_xml is not None: - line_number = get_xml_attr(msg_xml, f'{{{TNS}}}LineNumber') - line_position = get_xml_attr(msg_xml, f'{{{TNS}}}LinePosition') - violation = get_xml_attr(msg_xml, f'{{{TNS}}}Violation') + if code == "ErrorSchemaValidation" and msg_xml is not None: + line_number = get_xml_attr(msg_xml, f"{{{TNS}}}LineNumber") + line_position = get_xml_attr(msg_xml, f"{{{TNS}}}LinePosition") + violation = get_xml_attr(msg_xml, f"{{{TNS}}}Violation") if violation: - msg = f'{msg} {violation}' + msg = f"{msg} {violation}" if line_number or line_position: - msg = f'{msg} (line: {line_number} position: {line_position})' + msg = f"{msg} (line: {line_number} position: {line_position})" try: raise vars(errors)[code](msg) except KeyError: - detail = f'{cls.SERVICE_NAME}: code: {code} msg: {msg} ({xml_to_str(detail)})' + detail = f"{cls.SERVICE_NAME}: code: {code} msg: {msg} ({xml_to_str(detail)})" try: raise vars(errors)[fault_code](fault_string) except KeyError: pass - raise SOAPError(f'SOAP error code: {fault_code} string: {fault_string} actor: {fault_actor} detail: {detail}') + raise SOAPError(f"SOAP error code: {fault_code} string: {fault_string} actor: {fault_actor} detail: {detail}") def _get_element_container(self, message, name=None): """Return the XML element in a response element that contains the elements we want the service to return. For @@ -1827,23 +1946,23 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            # ResponseClass is an XML attribute of various SomeServiceResponseMessage elements: Possible values are: # Success, Warning, Error. See e.g. # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/finditemresponsemessage - response_class = message.get('ResponseClass') + response_class = message.get("ResponseClass") # ResponseCode, MessageText: See # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/responsecode - response_code = get_xml_attr(message, f'{{{MNS}}}ResponseCode') - if response_class == 'Success' and response_code == 'NoError': + response_code = get_xml_attr(message, f"{{{MNS}}}ResponseCode") + if response_class == "Success" and response_code == "NoError": if not name: return message container = message.find(name) if container is None: - raise MalformedResponseError(f'No {name} elements in ResponseMessage ({xml_to_str(message)})') + raise MalformedResponseError(f"No {name} elements in ResponseMessage ({xml_to_str(message)})") return container - if response_code == 'NoError': + if response_code == "NoError": return True # Raise any non-acceptable errors in the container, or return the container or the acceptable exception instance - msg_text = get_xml_attr(message, f'{{{MNS}}}MessageText') - msg_xml = message.find(f'{{{MNS}}}MessageXml') - if response_class == 'Warning': + msg_text = get_xml_attr(message, f"{{{MNS}}}MessageText") + msg_xml = message.find(f"{{{MNS}}}MessageXml") + if response_class == "Warning": try: raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml) except self.WARNINGS_TO_CATCH_IN_RESPONSE as e: @@ -1852,7 +1971,7 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            log.warning(str(e)) container = message.find(name) if container is None: - raise MalformedResponseError(f'No {name} elements in ResponseMessage ({xml_to_str(message)})') + raise MalformedResponseError(f"No {name} elements in ResponseMessage ({xml_to_str(message)})") return container # rspclass == 'Error', or 'Success' and not 'NoError' try: @@ -1864,21 +1983,21 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            def _get_exception(code, text, msg_xml): """Parse error messages contained in EWS responses and raise as exceptions defined in this package.""" if not code: - return TransportError(f'Empty ResponseCode in ResponseMessage (MessageText: {text}, MessageXml: {msg_xml})') + return TransportError(f"Empty ResponseCode in ResponseMessage (MessageText: {text}, MessageXml: {msg_xml})") if msg_xml is not None: # If this is an ErrorInvalidPropertyRequest error, the xml may contain a specific FieldURI for elem_cls in (FieldURI, IndexedFieldURI, ExtendedFieldURI, ExceptionFieldURI): elem = msg_xml.find(elem_cls.response_tag()) if elem is not None: field_uri = elem_cls.from_xml(elem, account=None) - text += f' (field: {field_uri})' + text += f" (field: {field_uri})" break # If this is an ErrorInvalidValueForProperty error, the xml may contain the name and value of the property - if code == 'ErrorInvalidValueForProperty': + if code == "ErrorInvalidValueForProperty": msg_parts = {} - for elem in msg_xml.findall(f'{{{TNS}}}Value'): - key, val = elem.get('Name'), elem.text + for elem in msg_xml.findall(f"{{{TNS}}}Value"): + key, val = elem.get("Name"), elem.text if key: msg_parts[key] = val if msg_parts: @@ -1886,26 +2005,26 @@

                                                                                                                                                                                                                                            Inherited members

                                                                                                                                                                                                                                            # If this is an ErrorInternalServerError error, the xml may contain a more specific error code inner_code, inner_text = None, None - for value_elem in msg_xml.findall(f'{{{TNS}}}Value'): - name = value_elem.get('Name') - if name == 'InnerErrorResponseCode': + for value_elem in msg_xml.findall(f"{{{TNS}}}Value"): + name = value_elem.get("Name") + if name == "InnerErrorResponseCode": inner_code = value_elem.text - elif name == 'InnerErrorMessageText': + elif name == "InnerErrorMessageText": inner_text = value_elem.text if inner_code: try: # Raise the error as the inner error code - return vars(errors)[inner_code](f'{inner_text} (raised from: {code}({text!r}))') + return vars(errors)[inner_code](f"{inner_text} (raised from: {code}({text!r}))") except KeyError: # Inner code is unknown to us. Just append to the original text - text += f' (inner error: {inner_code}({inner_text!r}))' + text += f" (inner error: {inner_code}({inner_text!r}))" try: # Raise the error corresponding to the ResponseCode return vars(errors)[code](text) except KeyError: # Should not happen return TransportError( - f'Unknown ResponseCode in ResponseMessage: {code} (MessageText: {text}, MessageXml: {msg_xml})' + f"Unknown ResponseCode in ResponseMessage: {code} (MessageText: {text}, MessageXml: {msg_xml})" ) def _get_elements_in_response(self, response): @@ -2049,10 +2168,10 @@

                                                                                                                                                                                                                                            Methods

                                                                                                                                                                                                                                            return None if expect_result is False: if res: - raise ValueError(f'Expected result length 0, but got {res}') + raise ValueError(f"Expected result length 0, but got {res}") return None if len(res) != 1: - raise ValueError(f'Expected result length 1, but got {res}') + raise ValueError(f"Expected result length 1, but got {res}") return res[0]
                                                                                                                                                                                                                                            @@ -2083,7 +2202,7 @@

                                                                                                                                                                                                                                            Methods

                                                                                                                                                                                                                                            def stop_streaming(self):
                                                                                                                                                                                                                                                 if not self.streaming:
                                                                                                                                                                                                                                            -        raise RuntimeError('Attempt to stop a non-streaming service')
                                                                                                                                                                                                                                            +        raise RuntimeError("Attempt to stop a non-streaming service")
                                                                                                                                                                                                                                                 if self._streaming_response:
                                                                                                                                                                                                                                                     self._streaming_response.close()  # Release memory
                                                                                                                                                                                                                                                     self._streaming_response = None
                                                                                                                                                                                                                                            diff --git a/docs/exchangelib/services/convert_id.html b/docs/exchangelib/services/convert_id.html
                                                                                                                                                                                                                                            index 392fb764..12f2f4bd 100644
                                                                                                                                                                                                                                            --- a/docs/exchangelib/services/convert_id.html
                                                                                                                                                                                                                                            +++ b/docs/exchangelib/services/convert_id.html
                                                                                                                                                                                                                                            @@ -26,11 +26,11 @@ 

                                                                                                                                                                                                                                            Module exchangelib.services.convert_id

                                                                                                                                                                                                                                            Expand source code -
                                                                                                                                                                                                                                            from .common import EWSService
                                                                                                                                                                                                                                            -from ..errors import InvalidEnumValue, InvalidTypeError
                                                                                                                                                                                                                                            -from ..properties import AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId, ID_FORMATS
                                                                                                                                                                                                                                            +
                                                                                                                                                                                                                                            from ..errors import InvalidEnumValue, InvalidTypeError
                                                                                                                                                                                                                                            +from ..properties import ID_FORMATS, AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId
                                                                                                                                                                                                                                             from ..util import create_element, set_xml_value
                                                                                                                                                                                                                                             from ..version import EXCHANGE_2007_SP1
                                                                                                                                                                                                                                            +from .common import EWSService
                                                                                                                                                                                                                                             
                                                                                                                                                                                                                                             
                                                                                                                                                                                                                                             class ConvertId(EWSService):
                                                                                                                                                                                                                                            @@ -40,15 +40,13 @@ 

                                                                                                                                                                                                                                            Module exchangelib.services.convert_id

                                                                                                                                                                                                                                            MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/convertid-operation """ - SERVICE_NAME = 'ConvertId' + SERVICE_NAME = "ConvertId" supported_from = EXCHANGE_2007_SP1 - cls_map = {cls.response_tag(): cls for cls in ( - AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId - )} + cls_map = {cls.response_tag(): cls for cls in (AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId)} def call(self, items, destination_format): if destination_format not in ID_FORMATS: - raise InvalidEnumValue('destination_format', destination_format, ID_FORMATS) + raise InvalidEnumValue("destination_format", destination_format, ID_FORMATS) return self._elems_to_objs( self._chunked_get_elements(self.get_payload, items=items, destination_format=destination_format) ) @@ -58,11 +56,11 @@

                                                                                                                                                                                                                                            Module exchangelib.services.convert_id

                                                                                                                                                                                                                                            def get_payload(self, items, destination_format): supported_item_classes = AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DestinationFormat=destination_format)) - item_ids = create_element('m:SourceIds') + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(DestinationFormat=destination_format)) + item_ids = create_element("m:SourceIds") for item in items: if not isinstance(item, supported_item_classes): - raise InvalidTypeError('item', item, supported_item_classes) + raise InvalidTypeError("item", item, supported_item_classes) set_xml_value(item_ids, item, version=self.protocol.version) payload.append(item_ids) return payload @@ -70,9 +68,11 @@

                                                                                                                                                                                                                                            Module exchangelib.services.convert_id

                                                                                                                                                                                                                                            @classmethod def _get_elements_in_container(cls, container): # We may have other elements in here, e.g. 'ResponseCode'. Filter away those. - return container.findall(AlternateId.response_tag()) \ - + container.findall(AlternatePublicFolderId.response_tag()) \ - + container.findall(AlternatePublicFolderItemId.response_tag())
                                                                                                                                                                                                                                            + return ( + container.findall(AlternateId.response_tag()) + + container.findall(AlternatePublicFolderId.response_tag()) + + container.findall(AlternatePublicFolderItemId.response_tag()) + )
                                                                                                                                                                                                                                            @@ -103,15 +103,13 @@

                                                                                                                                                                                                                                            Classes

                                                                                                                                                                                                                                            MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/convertid-operation """ - SERVICE_NAME = 'ConvertId' + SERVICE_NAME = "ConvertId" supported_from = EXCHANGE_2007_SP1 - cls_map = {cls.response_tag(): cls for cls in ( - AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId - )} + cls_map = {cls.response_tag(): cls for cls in (AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId)} def call(self, items, destination_format): if destination_format not in ID_FORMATS: - raise InvalidEnumValue('destination_format', destination_format, ID_FORMATS) + raise InvalidEnumValue("destination_format", destination_format, ID_FORMATS) return self._elems_to_objs( self._chunked_get_elements(self.get_payload, items=items, destination_format=destination_format) ) @@ -121,11 +119,11 @@

                                                                                                                                                                                                                                            Classes

                                                                                                                                                                                                                                            def get_payload(self, items, destination_format): supported_item_classes = AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DestinationFormat=destination_format)) - item_ids = create_element('m:SourceIds') + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(DestinationFormat=destination_format)) + item_ids = create_element("m:SourceIds") for item in items: if not isinstance(item, supported_item_classes): - raise InvalidTypeError('item', item, supported_item_classes) + raise InvalidTypeError("item", item, supported_item_classes) set_xml_value(item_ids, item, version=self.protocol.version) payload.append(item_ids) return payload @@ -133,9 +131,11 @@

                                                                                                                                                                                                                                            Classes

                                                                                                                                                                                                                                            @classmethod def _get_elements_in_container(cls, container): # We may have other elements in here, e.g. 'ResponseCode'. Filter away those. - return container.findall(AlternateId.response_tag()) \ - + container.findall(AlternatePublicFolderId.response_tag()) \ - + container.findall(AlternatePublicFolderItemId.response_tag())
                                                                                                                                                                                                                                            + return ( + container.findall(AlternateId.response_tag()) + + container.findall(AlternatePublicFolderId.response_tag()) + + container.findall(AlternatePublicFolderItemId.response_tag()) + )

                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                              @@ -169,7 +169,7 @@

                                                                                                                                                                                                                                              Methods

                                                                                                                                                                                                                                              def call(self, items, destination_format):
                                                                                                                                                                                                                                                   if destination_format not in ID_FORMATS:
                                                                                                                                                                                                                                              -        raise InvalidEnumValue('destination_format', destination_format, ID_FORMATS)
                                                                                                                                                                                                                                              +        raise InvalidEnumValue("destination_format", destination_format, ID_FORMATS)
                                                                                                                                                                                                                                                   return self._elems_to_objs(
                                                                                                                                                                                                                                                       self._chunked_get_elements(self.get_payload, items=items, destination_format=destination_format)
                                                                                                                                                                                                                                                   )
                                                                                                                                                                                                                                              @@ -186,11 +186,11 @@

                                                                                                                                                                                                                                              Methods

                                                                                                                                                                                                                                              def get_payload(self, items, destination_format):
                                                                                                                                                                                                                                                   supported_item_classes = AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId
                                                                                                                                                                                                                                              -    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DestinationFormat=destination_format))
                                                                                                                                                                                                                                              -    item_ids = create_element('m:SourceIds')
                                                                                                                                                                                                                                              +    payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(DestinationFormat=destination_format))
                                                                                                                                                                                                                                              +    item_ids = create_element("m:SourceIds")
                                                                                                                                                                                                                                                   for item in items:
                                                                                                                                                                                                                                                       if not isinstance(item, supported_item_classes):
                                                                                                                                                                                                                                              -            raise InvalidTypeError('item', item, supported_item_classes)
                                                                                                                                                                                                                                              +            raise InvalidTypeError("item", item, supported_item_classes)
                                                                                                                                                                                                                                                       set_xml_value(item_ids, item, version=self.protocol.version)
                                                                                                                                                                                                                                                   payload.append(item_ids)
                                                                                                                                                                                                                                                   return payload
                                                                                                                                                                                                                                              diff --git a/docs/exchangelib/services/copy_item.html b/docs/exchangelib/services/copy_item.html index 903219c7..8dcad32e 100644 --- a/docs/exchangelib/services/copy_item.html +++ b/docs/exchangelib/services/copy_item.html @@ -32,7 +32,7 @@

                                                                                                                                                                                                                                              Module exchangelib.services.copy_item

                                                                                                                                                                                                                                              class CopyItem(move_item.MoveItem): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/copyitem-operation""" - SERVICE_NAME = 'CopyItem'
                                                                                                                                                                                                                                            + SERVICE_NAME = "CopyItem"
                                                                                                                                                                                                                                            @@ -57,7 +57,7 @@

                                                                                                                                                                                                                                            Classes

                                                                                                                                                                                                                                            class CopyItem(move_item.MoveItem):
                                                                                                                                                                                                                                                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/copyitem-operation"""
                                                                                                                                                                                                                                             
                                                                                                                                                                                                                                            -    SERVICE_NAME = 'CopyItem'
                                                                                                                                                                                                                                            + SERVICE_NAME = "CopyItem"

                                                                                                                                                                                                                                            Ancestors

                                                                                                                                                                                                                                              diff --git a/docs/exchangelib/services/create_attachment.html b/docs/exchangelib/services/create_attachment.html index f28e5b5d..62cb3ce4 100644 --- a/docs/exchangelib/services/create_attachment.html +++ b/docs/exchangelib/services/create_attachment.html @@ -26,11 +26,11 @@

                                                                                                                                                                                                                                              Module exchangelib.services.create_attachment

                                                                                                                                                                                                                                              Expand source code -
                                                                                                                                                                                                                                              from .common import EWSAccountService, to_item_id
                                                                                                                                                                                                                                              -from ..attachments import FileAttachment, ItemAttachment
                                                                                                                                                                                                                                              +
                                                                                                                                                                                                                                              from ..attachments import FileAttachment, ItemAttachment
                                                                                                                                                                                                                                               from ..items import BaseItem
                                                                                                                                                                                                                                               from ..properties import ParentItemId
                                                                                                                                                                                                                                              -from ..util import create_element, set_xml_value, MNS
                                                                                                                                                                                                                                              +from ..util import MNS, create_element, set_xml_value
                                                                                                                                                                                                                                              +from .common import EWSAccountService, to_item_id
                                                                                                                                                                                                                                               
                                                                                                                                                                                                                                               
                                                                                                                                                                                                                                               class CreateAttachment(EWSAccountService):
                                                                                                                                                                                                                                              @@ -38,8 +38,8 @@ 

                                                                                                                                                                                                                                              Module exchangelib.services.create_attachment

                                                                                                                                                                                                                                              Module exchangelib.services.create_attachment
    • Classes
  • https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createattachment-operation """ - SERVICE_NAME = 'CreateAttachment' - element_container_name = f'{{{MNS}}}Attachments' + SERVICE_NAME = "CreateAttachment" + element_container_name = f"{{{MNS}}}Attachments" cls_map = {cls.response_tag(): cls for cls in (FileAttachment, ItemAttachment)} def call(self, parent_item, items): @@ -98,13 +98,13 @@

    Classes

    return self.cls_map[elem.tag].from_xml(elem=elem, account=self.account) def get_payload(self, items, parent_item): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") version = self.account.version if isinstance(parent_item, BaseItem): # to_item_id() would convert this to a normal ItemId, but the service wants a ParentItemId parent_item = ParentItemId(parent_item.id, parent_item.changekey) set_xml_value(payload, to_item_id(parent_item, ParentItemId), version=self.account.version) - attachments = create_element('m:Attachments') + attachments = create_element("m:Attachments") for item in items: set_xml_value(attachments, item, version=version) payload.append(attachments) @@ -155,13 +155,13 @@

    Methods

    Expand source code
    def get_payload(self, items, parent_item):
    -    payload = create_element(f'm:{self.SERVICE_NAME}')
    +    payload = create_element(f"m:{self.SERVICE_NAME}")
         version = self.account.version
         if isinstance(parent_item, BaseItem):
             # to_item_id() would convert this to a normal ItemId, but the service wants a ParentItemId
             parent_item = ParentItemId(parent_item.id, parent_item.changekey)
         set_xml_value(payload, to_item_id(parent_item, ParentItemId), version=self.account.version)
    -    attachments = create_element('m:Attachments')
    +    attachments = create_element("m:Attachments")
         for item in items:
             set_xml_value(attachments, item, version=version)
         payload.append(attachments)
    diff --git a/docs/exchangelib/services/create_folder.html b/docs/exchangelib/services/create_folder.html
    index 7122a1b9..23eb3d65 100644
    --- a/docs/exchangelib/services/create_folder.html
    +++ b/docs/exchangelib/services/create_folder.html
    @@ -26,19 +26,17 @@ 

    Module exchangelib.services.create_folder

    Expand source code -
    from .common import EWSAccountService, parse_folder_elem, folder_ids_element
    -from ..errors import ErrorFolderExists
    -from ..util import create_element, MNS
    +
    from ..errors import ErrorFolderExists
    +from ..util import MNS, create_element
    +from .common import EWSAccountService, folder_ids_element, parse_folder_elem
     
     
     class CreateFolder(EWSAccountService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createfolder-operation"""
     
    -    SERVICE_NAME = 'CreateFolder'
    -    element_container_name = f'{{{MNS}}}Folders'
    -    ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (
    -        ErrorFolderExists,
    -    )
    +    SERVICE_NAME = "CreateFolder"
    +    element_container_name = f"{{{MNS}}}Folders"
    +    ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (ErrorFolderExists,)
     
         def __init__(self, *args, **kwargs):
             super().__init__(*args, **kwargs)
    @@ -48,9 +46,13 @@ 

    Module exchangelib.services.create_folder

    # We can't easily find the correct folder class from the returned XML. Instead, return objects with the same # class as the folder instance it was requested with. self.folders = list(folders) # Convert to a list, in case 'folders' is a generator. We're iterating twice. - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, items=self.folders, parent_folder=parent_folder, - )) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=self.folders, + parent_folder=parent_folder, + ) + ) def _elems_to_objs(self, elems): for folder, elem in zip(self.folders, elems): @@ -60,11 +62,11 @@

    Module exchangelib.services.create_folder

    yield parse_folder_elem(elem=elem, folder=folder, account=self.account) def get_payload(self, folders, parent_folder): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") payload.append( - folder_ids_element(folders=[parent_folder], version=self.account.version, tag='m:ParentFolderId') + folder_ids_element(folders=[parent_folder], version=self.account.version, tag="m:ParentFolderId") ) - payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:Folders')) + payload.append(folder_ids_element(folders=folders, version=self.account.version, tag="m:Folders")) return payload
    @@ -90,11 +92,9 @@

    Classes

    class CreateFolder(EWSAccountService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createfolder-operation"""
     
    -    SERVICE_NAME = 'CreateFolder'
    -    element_container_name = f'{{{MNS}}}Folders'
    -    ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (
    -        ErrorFolderExists,
    -    )
    +    SERVICE_NAME = "CreateFolder"
    +    element_container_name = f"{{{MNS}}}Folders"
    +    ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (ErrorFolderExists,)
     
         def __init__(self, *args, **kwargs):
             super().__init__(*args, **kwargs)
    @@ -104,9 +104,13 @@ 

    Classes

    # We can't easily find the correct folder class from the returned XML. Instead, return objects with the same # class as the folder instance it was requested with. self.folders = list(folders) # Convert to a list, in case 'folders' is a generator. We're iterating twice. - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, items=self.folders, parent_folder=parent_folder, - )) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=self.folders, + parent_folder=parent_folder, + ) + ) def _elems_to_objs(self, elems): for folder, elem in zip(self.folders, elems): @@ -116,11 +120,11 @@

    Classes

    yield parse_folder_elem(elem=elem, folder=folder, account=self.account) def get_payload(self, folders, parent_folder): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") payload.append( - folder_ids_element(folders=[parent_folder], version=self.account.version, tag='m:ParentFolderId') + folder_ids_element(folders=[parent_folder], version=self.account.version, tag="m:ParentFolderId") ) - payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:Folders')) + payload.append(folder_ids_element(folders=folders, version=self.account.version, tag="m:Folders")) return payload

    Ancestors

    @@ -158,9 +162,13 @@

    Methods

    # We can't easily find the correct folder class from the returned XML. Instead, return objects with the same # class as the folder instance it was requested with. self.folders = list(folders) # Convert to a list, in case 'folders' is a generator. We're iterating twice. - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, items=self.folders, parent_folder=parent_folder, - ))
    + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=self.folders, + parent_folder=parent_folder, + ) + )
    @@ -173,11 +181,11 @@

    Methods

    Expand source code
    def get_payload(self, folders, parent_folder):
    -    payload = create_element(f'm:{self.SERVICE_NAME}')
    +    payload = create_element(f"m:{self.SERVICE_NAME}")
         payload.append(
    -        folder_ids_element(folders=[parent_folder], version=self.account.version, tag='m:ParentFolderId')
    +        folder_ids_element(folders=[parent_folder], version=self.account.version, tag="m:ParentFolderId")
         )
    -    payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:Folders'))
    +    payload.append(folder_ids_element(folders=folders, version=self.account.version, tag="m:Folders"))
         return payload
    diff --git a/docs/exchangelib/services/create_item.html b/docs/exchangelib/services/create_item.html index 88a944d0..09529f6e 100644 --- a/docs/exchangelib/services/create_item.html +++ b/docs/exchangelib/services/create_item.html @@ -26,13 +26,19 @@

    Module exchangelib.services.create_item

    Expand source code -
    from .common import EWSAccountService, folder_ids_element
    -from ..errors import InvalidEnumValue, InvalidTypeError
    +
    from ..errors import InvalidEnumValue, InvalidTypeError
     from ..folders import BaseFolder
    -from ..items import SAVE_ONLY, SEND_AND_SAVE_COPY, SEND_ONLY, \
    -    SEND_MEETING_INVITATIONS_CHOICES, MESSAGE_DISPOSITION_CHOICES, BulkCreateResult
    +from ..items import (
    +    MESSAGE_DISPOSITION_CHOICES,
    +    SAVE_ONLY,
    +    SEND_AND_SAVE_COPY,
    +    SEND_MEETING_INVITATIONS_CHOICES,
    +    SEND_ONLY,
    +    BulkCreateResult,
    +)
     from ..properties import FolderId
    -from ..util import create_element, set_xml_value, MNS
    +from ..util import MNS, create_element, set_xml_value
    +from .common import EWSAccountService, folder_ids_element
     
     
     class CreateItem(EWSAccountService):
    @@ -42,34 +48,36 @@ 

    Module exchangelib.services.create_item

    MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createitem-operation """ - SERVICE_NAME = 'CreateItem' - element_container_name = f'{{{MNS}}}Items' + SERVICE_NAME = "CreateItem" + element_container_name = f"{{{MNS}}}Items" def call(self, items, folder, message_disposition, send_meeting_invitations): if message_disposition not in MESSAGE_DISPOSITION_CHOICES: - raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES) + raise InvalidEnumValue("message_disposition", message_disposition, MESSAGE_DISPOSITION_CHOICES) if send_meeting_invitations not in SEND_MEETING_INVITATIONS_CHOICES: raise InvalidEnumValue( - 'send_meeting_invitations', send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES + "send_meeting_invitations", send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES ) if folder is not None: if not isinstance(folder, (BaseFolder, FolderId)): - raise InvalidTypeError('folder', folder, (BaseFolder, FolderId)) + raise InvalidTypeError("folder", folder, (BaseFolder, FolderId)) if folder.account != self.account: - raise ValueError('Folder must belong to account') + raise ValueError("Folder must belong to account") if message_disposition == SAVE_ONLY and folder is None: raise AttributeError("Folder must be supplied when in save-only mode") if message_disposition == SEND_AND_SAVE_COPY and folder is None: folder = self.account.sent # 'Sent' is default EWS behaviour if message_disposition == SEND_ONLY and folder is not None: raise AttributeError("Folder must be None in send-ony mode") - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, - items=items, - folder=folder, - message_disposition=message_disposition, - send_meeting_invitations=send_meeting_invitations, - )) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=items, + folder=folder, + message_disposition=message_disposition, + send_meeting_invitations=send_meeting_invitations, + ) + ) def _elem_to_obj(self, elem): if isinstance(elem, bool): @@ -101,14 +109,14 @@

    Module exchangelib.services.create_item

    :param send_meeting_invitations: """ payload = create_element( - f'm:{self.SERVICE_NAME}', - attrs=dict(MessageDisposition=message_disposition, SendMeetingInvitations=send_meeting_invitations) + f"m:{self.SERVICE_NAME}", + attrs=dict(MessageDisposition=message_disposition, SendMeetingInvitations=send_meeting_invitations), ) if folder: - payload.append(folder_ids_element( - folders=[folder], version=self.account.version, tag='m:SavedItemFolderId' - )) - item_elems = create_element('m:Items') + payload.append( + folder_ids_element(folders=[folder], version=self.account.version, tag="m:SavedItemFolderId") + ) + item_elems = create_element("m:Items") for item in items: if not item.account: item.account = self.account @@ -145,34 +153,36 @@

    Classes

    MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createitem-operation """ - SERVICE_NAME = 'CreateItem' - element_container_name = f'{{{MNS}}}Items' + SERVICE_NAME = "CreateItem" + element_container_name = f"{{{MNS}}}Items" def call(self, items, folder, message_disposition, send_meeting_invitations): if message_disposition not in MESSAGE_DISPOSITION_CHOICES: - raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES) + raise InvalidEnumValue("message_disposition", message_disposition, MESSAGE_DISPOSITION_CHOICES) if send_meeting_invitations not in SEND_MEETING_INVITATIONS_CHOICES: raise InvalidEnumValue( - 'send_meeting_invitations', send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES + "send_meeting_invitations", send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES ) if folder is not None: if not isinstance(folder, (BaseFolder, FolderId)): - raise InvalidTypeError('folder', folder, (BaseFolder, FolderId)) + raise InvalidTypeError("folder", folder, (BaseFolder, FolderId)) if folder.account != self.account: - raise ValueError('Folder must belong to account') + raise ValueError("Folder must belong to account") if message_disposition == SAVE_ONLY and folder is None: raise AttributeError("Folder must be supplied when in save-only mode") if message_disposition == SEND_AND_SAVE_COPY and folder is None: folder = self.account.sent # 'Sent' is default EWS behaviour if message_disposition == SEND_ONLY and folder is not None: raise AttributeError("Folder must be None in send-ony mode") - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, - items=items, - folder=folder, - message_disposition=message_disposition, - send_meeting_invitations=send_meeting_invitations, - )) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=items, + folder=folder, + message_disposition=message_disposition, + send_meeting_invitations=send_meeting_invitations, + ) + ) def _elem_to_obj(self, elem): if isinstance(elem, bool): @@ -204,14 +214,14 @@

    Classes

    :param send_meeting_invitations: """ payload = create_element( - f'm:{self.SERVICE_NAME}', - attrs=dict(MessageDisposition=message_disposition, SendMeetingInvitations=send_meeting_invitations) + f"m:{self.SERVICE_NAME}", + attrs=dict(MessageDisposition=message_disposition, SendMeetingInvitations=send_meeting_invitations), ) if folder: - payload.append(folder_ids_element( - folders=[folder], version=self.account.version, tag='m:SavedItemFolderId' - )) - item_elems = create_element('m:Items') + payload.append( + folder_ids_element(folders=[folder], version=self.account.version, tag="m:SavedItemFolderId") + ) + item_elems = create_element("m:Items") for item in items: if not item.account: item.account = self.account @@ -248,29 +258,31 @@

    Methods

    def call(self, items, folder, message_disposition, send_meeting_invitations):
         if message_disposition not in MESSAGE_DISPOSITION_CHOICES:
    -        raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES)
    +        raise InvalidEnumValue("message_disposition", message_disposition, MESSAGE_DISPOSITION_CHOICES)
         if send_meeting_invitations not in SEND_MEETING_INVITATIONS_CHOICES:
             raise InvalidEnumValue(
    -            'send_meeting_invitations', send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES
    +            "send_meeting_invitations", send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES
             )
         if folder is not None:
             if not isinstance(folder, (BaseFolder, FolderId)):
    -            raise InvalidTypeError('folder', folder, (BaseFolder, FolderId))
    +            raise InvalidTypeError("folder", folder, (BaseFolder, FolderId))
             if folder.account != self.account:
    -            raise ValueError('Folder must belong to account')
    +            raise ValueError("Folder must belong to account")
         if message_disposition == SAVE_ONLY and folder is None:
             raise AttributeError("Folder must be supplied when in save-only mode")
         if message_disposition == SEND_AND_SAVE_COPY and folder is None:
             folder = self.account.sent  # 'Sent' is default EWS behaviour
         if message_disposition == SEND_ONLY and folder is not None:
             raise AttributeError("Folder must be None in send-ony mode")
    -    return self._elems_to_objs(self._chunked_get_elements(
    -        self.get_payload,
    -        items=items,
    -        folder=folder,
    -        message_disposition=message_disposition,
    -        send_meeting_invitations=send_meeting_invitations,
    -    ))
    + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=items, + folder=folder, + message_disposition=message_disposition, + send_meeting_invitations=send_meeting_invitations, + ) + )
    @@ -315,14 +327,14 @@

    Methods

    :param send_meeting_invitations: """ payload = create_element( - f'm:{self.SERVICE_NAME}', - attrs=dict(MessageDisposition=message_disposition, SendMeetingInvitations=send_meeting_invitations) + f"m:{self.SERVICE_NAME}", + attrs=dict(MessageDisposition=message_disposition, SendMeetingInvitations=send_meeting_invitations), ) if folder: - payload.append(folder_ids_element( - folders=[folder], version=self.account.version, tag='m:SavedItemFolderId' - )) - item_elems = create_element('m:Items') + payload.append( + folder_ids_element(folders=[folder], version=self.account.version, tag="m:SavedItemFolderId") + ) + item_elems = create_element("m:Items") for item in items: if not item.account: item.account = self.account diff --git a/docs/exchangelib/services/create_user_configuration.html b/docs/exchangelib/services/create_user_configuration.html index 81b66f16..5db8b917 100644 --- a/docs/exchangelib/services/create_user_configuration.html +++ b/docs/exchangelib/services/create_user_configuration.html @@ -26,8 +26,8 @@

    Module exchangelib.services.create_user_configuration Expand source code -
    from .common import EWSAccountService
    -from ..util import create_element, set_xml_value
    +
    from ..util import create_element, set_xml_value
    +from .common import EWSAccountService
     
     
     class CreateUserConfiguration(EWSAccountService):
    @@ -35,7 +35,7 @@ 

    Module exchangelib.services.create_user_configurationModule exchangelib.services.create_user_configuration

    @@ -72,7 +72,7 @@

    Classes

    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createuserconfiguration-operation """ - SERVICE_NAME = 'CreateUserConfiguration' + SERVICE_NAME = "CreateUserConfiguration" returns_elements = False def call(self, user_configuration): @@ -80,7 +80,7 @@

    Classes

    def get_payload(self, user_configuration): return set_xml_value( - create_element(f'm:{self.SERVICE_NAME}'), user_configuration, version=self.protocol.version + create_element(f"m:{self.SERVICE_NAME}"), user_configuration, version=self.protocol.version )

    Ancestors

    @@ -125,7 +125,7 @@

    Methods

    def get_payload(self, user_configuration):
         return set_xml_value(
    -        create_element(f'm:{self.SERVICE_NAME}'), user_configuration, version=self.protocol.version
    +        create_element(f"m:{self.SERVICE_NAME}"), user_configuration, version=self.protocol.version
         )
    diff --git a/docs/exchangelib/services/delete_attachment.html b/docs/exchangelib/services/delete_attachment.html index 093df9cb..9585face 100644 --- a/docs/exchangelib/services/delete_attachment.html +++ b/docs/exchangelib/services/delete_attachment.html @@ -26,9 +26,9 @@

    Module exchangelib.services.delete_attachment

    Expand source code -
    from .common import EWSAccountService, attachment_ids_element
    -from ..properties import RootItemId
    +
    from ..properties import RootItemId
     from ..util import create_element, set_xml_value
    +from .common import EWSAccountService, attachment_ids_element
     
     
     class DeleteAttachment(EWSAccountService):
    @@ -36,7 +36,7 @@ 

    Module exchangelib.services.delete_attachment

    Module exchangelib.services.delete_attachment

    @@ -81,7 +81,7 @@

    Classes

    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteattachment-operation """ - SERVICE_NAME = 'DeleteAttachment' + SERVICE_NAME = "DeleteAttachment" def call(self, items): return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items)) @@ -95,9 +95,9 @@

    Classes

    def get_payload(self, items): return set_xml_value( - create_element(f'm:{self.SERVICE_NAME}'), + create_element(f"m:{self.SERVICE_NAME}"), attachment_ids_element(items=items, version=self.account.version), - version=self.account.version + version=self.account.version, )

    Ancestors

    @@ -138,9 +138,9 @@

    Methods

    def get_payload(self, items):
         return set_xml_value(
    -        create_element(f'm:{self.SERVICE_NAME}'),
    +        create_element(f"m:{self.SERVICE_NAME}"),
             attachment_ids_element(items=items, version=self.account.version),
    -        version=self.account.version
    +        version=self.account.version,
         )
    diff --git a/docs/exchangelib/services/delete_folder.html b/docs/exchangelib/services/delete_folder.html index 50875946..1df70f09 100644 --- a/docs/exchangelib/services/delete_folder.html +++ b/docs/exchangelib/services/delete_folder.html @@ -26,25 +26,25 @@

    Module exchangelib.services.delete_folder

    Expand source code -
    from .common import EWSAccountService, folder_ids_element
    -from ..errors import InvalidEnumValue
    +
    from ..errors import InvalidEnumValue
     from ..items import DELETE_TYPE_CHOICES
     from ..util import create_element
    +from .common import EWSAccountService, folder_ids_element
     
     
     class DeleteFolder(EWSAccountService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deletefolder-operation"""
     
    -    SERVICE_NAME = 'DeleteFolder'
    +    SERVICE_NAME = "DeleteFolder"
         returns_elements = False
     
         def call(self, folders, delete_type):
             if delete_type not in DELETE_TYPE_CHOICES:
    -            raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES)
    +            raise InvalidEnumValue("delete_type", delete_type, DELETE_TYPE_CHOICES)
             return self._chunked_get_elements(self.get_payload, items=folders, delete_type=delete_type)
     
         def get_payload(self, folders, delete_type):
    -        payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DeleteType=delete_type))
    +        payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(DeleteType=delete_type))
             payload.append(folder_ids_element(folders=folders, version=self.account.version))
             return payload
    @@ -71,16 +71,16 @@

    Classes

    class DeleteFolder(EWSAccountService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deletefolder-operation"""
     
    -    SERVICE_NAME = 'DeleteFolder'
    +    SERVICE_NAME = "DeleteFolder"
         returns_elements = False
     
         def call(self, folders, delete_type):
             if delete_type not in DELETE_TYPE_CHOICES:
    -            raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES)
    +            raise InvalidEnumValue("delete_type", delete_type, DELETE_TYPE_CHOICES)
             return self._chunked_get_elements(self.get_payload, items=folders, delete_type=delete_type)
     
         def get_payload(self, folders, delete_type):
    -        payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DeleteType=delete_type))
    +        payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(DeleteType=delete_type))
             payload.append(folder_ids_element(folders=folders, version=self.account.version))
             return payload
    @@ -113,7 +113,7 @@

    Methods

    def call(self, folders, delete_type):
         if delete_type not in DELETE_TYPE_CHOICES:
    -        raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES)
    +        raise InvalidEnumValue("delete_type", delete_type, DELETE_TYPE_CHOICES)
         return self._chunked_get_elements(self.get_payload, items=folders, delete_type=delete_type)
    @@ -127,7 +127,7 @@

    Methods

    Expand source code
    def get_payload(self, folders, delete_type):
    -    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DeleteType=delete_type))
    +    payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(DeleteType=delete_type))
         payload.append(folder_ids_element(folders=folders, version=self.account.version))
         return payload
    diff --git a/docs/exchangelib/services/delete_item.html b/docs/exchangelib/services/delete_item.html index c5e1af14..6cc6ebb3 100644 --- a/docs/exchangelib/services/delete_item.html +++ b/docs/exchangelib/services/delete_item.html @@ -26,11 +26,11 @@

    Module exchangelib.services.delete_item

    Expand source code -
    from .common import EWSAccountService, item_ids_element
    -from ..errors import InvalidEnumValue
    -from ..items import DELETE_TYPE_CHOICES, SEND_MEETING_CANCELLATIONS_CHOICES, AFFECTED_TASK_OCCURRENCES_CHOICES
    +
    from ..errors import InvalidEnumValue
    +from ..items import AFFECTED_TASK_OCCURRENCES_CHOICES, DELETE_TYPE_CHOICES, SEND_MEETING_CANCELLATIONS_CHOICES
     from ..util import create_element
     from ..version import EXCHANGE_2013_SP1
    +from .common import EWSAccountService, item_ids_element
     
     
     class DeleteItem(EWSAccountService):
    @@ -40,19 +40,19 @@ 

    Module exchangelib.services.delete_item

    MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteitem-operation """ - SERVICE_NAME = 'DeleteItem' + SERVICE_NAME = "DeleteItem" returns_elements = False def call(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): if delete_type not in DELETE_TYPE_CHOICES: - raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES) + raise InvalidEnumValue("delete_type", delete_type, DELETE_TYPE_CHOICES) if send_meeting_cancellations not in SEND_MEETING_CANCELLATIONS_CHOICES: raise InvalidEnumValue( - 'send_meeting_cancellations', send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES + "send_meeting_cancellations", send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES ) if affected_task_occurrences not in AFFECTED_TASK_OCCURRENCES_CHOICES: raise InvalidEnumValue( - 'affected_task_occurrences', affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES + "affected_task_occurrences", affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES ) return self._chunked_get_elements( self.get_payload, @@ -63,8 +63,9 @@

    Module exchangelib.services.delete_item

    suppress_read_receipts=suppress_read_receipts, ) - def get_payload(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, - suppress_read_receipts): + def get_payload( + self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts + ): # Takes a list of (id, changekey) tuples or Item objects and returns the XML for a DeleteItem request. attrs = dict( DeleteType=delete_type, @@ -72,8 +73,8 @@

    Module exchangelib.services.delete_item

    AffectedTaskOccurrences=affected_task_occurrences, ) if self.account.version.build >= EXCHANGE_2013_SP1: - attrs['SuppressReadReceipts'] = suppress_read_receipts - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + attrs["SuppressReadReceipts"] = suppress_read_receipts + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=attrs) payload.append(item_ids_element(items=items, version=self.account.version)) return payload
    @@ -106,19 +107,19 @@

    Classes

    MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteitem-operation """ - SERVICE_NAME = 'DeleteItem' + SERVICE_NAME = "DeleteItem" returns_elements = False def call(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): if delete_type not in DELETE_TYPE_CHOICES: - raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES) + raise InvalidEnumValue("delete_type", delete_type, DELETE_TYPE_CHOICES) if send_meeting_cancellations not in SEND_MEETING_CANCELLATIONS_CHOICES: raise InvalidEnumValue( - 'send_meeting_cancellations', send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES + "send_meeting_cancellations", send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES ) if affected_task_occurrences not in AFFECTED_TASK_OCCURRENCES_CHOICES: raise InvalidEnumValue( - 'affected_task_occurrences', affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES + "affected_task_occurrences", affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES ) return self._chunked_get_elements( self.get_payload, @@ -129,8 +130,9 @@

    Classes

    suppress_read_receipts=suppress_read_receipts, ) - def get_payload(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, - suppress_read_receipts): + def get_payload( + self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts + ): # Takes a list of (id, changekey) tuples or Item objects and returns the XML for a DeleteItem request. attrs = dict( DeleteType=delete_type, @@ -138,8 +140,8 @@

    Classes

    AffectedTaskOccurrences=affected_task_occurrences, ) if self.account.version.build >= EXCHANGE_2013_SP1: - attrs['SuppressReadReceipts'] = suppress_read_receipts - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + attrs["SuppressReadReceipts"] = suppress_read_receipts + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=attrs) payload.append(item_ids_element(items=items, version=self.account.version)) return payload
    @@ -172,14 +174,14 @@

    Methods

    def call(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts):
         if delete_type not in DELETE_TYPE_CHOICES:
    -        raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES)
    +        raise InvalidEnumValue("delete_type", delete_type, DELETE_TYPE_CHOICES)
         if send_meeting_cancellations not in SEND_MEETING_CANCELLATIONS_CHOICES:
             raise InvalidEnumValue(
    -            'send_meeting_cancellations', send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES
    +            "send_meeting_cancellations", send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES
             )
         if affected_task_occurrences not in AFFECTED_TASK_OCCURRENCES_CHOICES:
             raise InvalidEnumValue(
    -            'affected_task_occurrences', affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES
    +            "affected_task_occurrences", affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES
             )
         return self._chunked_get_elements(
             self.get_payload,
    @@ -200,8 +202,9 @@ 

    Methods

    Expand source code -
    def get_payload(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences,
    -                suppress_read_receipts):
    +
    def get_payload(
    +    self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts
    +):
         # Takes a list of (id, changekey) tuples or Item objects and returns the XML for a DeleteItem request.
         attrs = dict(
             DeleteType=delete_type,
    @@ -209,8 +212,8 @@ 

    Methods

    AffectedTaskOccurrences=affected_task_occurrences, ) if self.account.version.build >= EXCHANGE_2013_SP1: - attrs['SuppressReadReceipts'] = suppress_read_receipts - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + attrs["SuppressReadReceipts"] = suppress_read_receipts + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=attrs) payload.append(item_ids_element(items=items, version=self.account.version)) return payload
    diff --git a/docs/exchangelib/services/delete_user_configuration.html b/docs/exchangelib/services/delete_user_configuration.html index 33ed2fe1..176ffd43 100644 --- a/docs/exchangelib/services/delete_user_configuration.html +++ b/docs/exchangelib/services/delete_user_configuration.html @@ -26,8 +26,8 @@

    Module exchangelib.services.delete_user_configuration Expand source code -
    from .common import EWSAccountService
    -from ..util import create_element, set_xml_value
    +
    from ..util import create_element, set_xml_value
    +from .common import EWSAccountService
     
     
     class DeleteUserConfiguration(EWSAccountService):
    @@ -35,7 +35,7 @@ 

    Module exchangelib.services.delete_user_configurationModule exchangelib.services.delete_user_configuration

    @@ -72,7 +72,7 @@

    Classes

    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteuserconfiguration-operation """ - SERVICE_NAME = 'DeleteUserConfiguration' + SERVICE_NAME = "DeleteUserConfiguration" returns_elements = False def call(self, user_configuration_name): @@ -80,7 +80,7 @@

    Classes

    def get_payload(self, user_configuration_name): return set_xml_value( - create_element(f'm:{self.SERVICE_NAME}'), user_configuration_name, version=self.account.version + create_element(f"m:{self.SERVICE_NAME}"), user_configuration_name, version=self.account.version )

    Ancestors

    @@ -125,7 +125,7 @@

    Methods

    def get_payload(self, user_configuration_name):
         return set_xml_value(
    -        create_element(f'm:{self.SERVICE_NAME}'), user_configuration_name, version=self.account.version
    +        create_element(f"m:{self.SERVICE_NAME}"), user_configuration_name, version=self.account.version
         )
    diff --git a/docs/exchangelib/services/empty_folder.html b/docs/exchangelib/services/empty_folder.html index b52a63cb..caf06dc0 100644 --- a/docs/exchangelib/services/empty_folder.html +++ b/docs/exchangelib/services/empty_folder.html @@ -26,29 +26,28 @@

    Module exchangelib.services.empty_folder

    Expand source code -
    from .common import EWSAccountService, folder_ids_element
    -from ..errors import InvalidEnumValue
    +
    from ..errors import InvalidEnumValue
     from ..items import DELETE_TYPE_CHOICES
     from ..util import create_element
    +from .common import EWSAccountService, folder_ids_element
     
     
     class EmptyFolder(EWSAccountService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/emptyfolder-operation"""
     
    -    SERVICE_NAME = 'EmptyFolder'
    +    SERVICE_NAME = "EmptyFolder"
         returns_elements = False
     
         def call(self, folders, delete_type, delete_sub_folders):
             if delete_type not in DELETE_TYPE_CHOICES:
    -            raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES)
    +            raise InvalidEnumValue("delete_type", delete_type, DELETE_TYPE_CHOICES)
             return self._chunked_get_elements(
                 self.get_payload, items=folders, delete_type=delete_type, delete_sub_folders=delete_sub_folders
             )
     
         def get_payload(self, folders, delete_type, delete_sub_folders):
             payload = create_element(
    -            f'm:{self.SERVICE_NAME}',
    -            attrs=dict(DeleteType=delete_type, DeleteSubFolders=delete_sub_folders)
    +            f"m:{self.SERVICE_NAME}", attrs=dict(DeleteType=delete_type, DeleteSubFolders=delete_sub_folders)
             )
             payload.append(folder_ids_element(folders=folders, version=self.account.version))
             return payload
    @@ -76,20 +75,19 @@

    Classes

    class EmptyFolder(EWSAccountService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/emptyfolder-operation"""
     
    -    SERVICE_NAME = 'EmptyFolder'
    +    SERVICE_NAME = "EmptyFolder"
         returns_elements = False
     
         def call(self, folders, delete_type, delete_sub_folders):
             if delete_type not in DELETE_TYPE_CHOICES:
    -            raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES)
    +            raise InvalidEnumValue("delete_type", delete_type, DELETE_TYPE_CHOICES)
             return self._chunked_get_elements(
                 self.get_payload, items=folders, delete_type=delete_type, delete_sub_folders=delete_sub_folders
             )
     
         def get_payload(self, folders, delete_type, delete_sub_folders):
             payload = create_element(
    -            f'm:{self.SERVICE_NAME}',
    -            attrs=dict(DeleteType=delete_type, DeleteSubFolders=delete_sub_folders)
    +            f"m:{self.SERVICE_NAME}", attrs=dict(DeleteType=delete_type, DeleteSubFolders=delete_sub_folders)
             )
             payload.append(folder_ids_element(folders=folders, version=self.account.version))
             return payload
    @@ -123,7 +121,7 @@

    Methods

    def call(self, folders, delete_type, delete_sub_folders):
         if delete_type not in DELETE_TYPE_CHOICES:
    -        raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES)
    +        raise InvalidEnumValue("delete_type", delete_type, DELETE_TYPE_CHOICES)
         return self._chunked_get_elements(
             self.get_payload, items=folders, delete_type=delete_type, delete_sub_folders=delete_sub_folders
         )
    @@ -140,8 +138,7 @@

    Methods

    def get_payload(self, folders, delete_type, delete_sub_folders):
         payload = create_element(
    -        f'm:{self.SERVICE_NAME}',
    -        attrs=dict(DeleteType=delete_type, DeleteSubFolders=delete_sub_folders)
    +        f"m:{self.SERVICE_NAME}", attrs=dict(DeleteType=delete_type, DeleteSubFolders=delete_sub_folders)
         )
         payload.append(folder_ids_element(folders=folders, version=self.account.version))
         return payload
    diff --git a/docs/exchangelib/services/expand_dl.html b/docs/exchangelib/services/expand_dl.html index c0591b64..9aea3d8b 100644 --- a/docs/exchangelib/services/expand_dl.html +++ b/docs/exchangelib/services/expand_dl.html @@ -26,17 +26,17 @@

    Module exchangelib.services.expand_dl

    Expand source code -
    from .common import EWSService
    -from ..errors import ErrorNameResolutionMultipleResults
    +
    from ..errors import ErrorNameResolutionMultipleResults
     from ..properties import Mailbox
    -from ..util import create_element, set_xml_value, MNS
    +from ..util import MNS, create_element, set_xml_value
    +from .common import EWSService
     
     
     class ExpandDL(EWSService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/expanddl-operation"""
     
    -    SERVICE_NAME = 'ExpandDL'
    -    element_container_name = f'{{{MNS}}}DLExpansion'
    +    SERVICE_NAME = "ExpandDL"
    +    element_container_name = f"{{{MNS}}}DLExpansion"
         WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults
     
         def call(self, distribution_list):
    @@ -46,7 +46,7 @@ 

    Module exchangelib.services.expand_dl

    return Mailbox.from_xml(elem, account=None) def get_payload(self, distribution_list): - return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), distribution_list, version=self.protocol.version)
    + return set_xml_value(create_element(f"m:{self.SERVICE_NAME}"), distribution_list, version=self.protocol.version)
    @@ -71,8 +71,8 @@

    Classes

    class ExpandDL(EWSService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/expanddl-operation"""
     
    -    SERVICE_NAME = 'ExpandDL'
    -    element_container_name = f'{{{MNS}}}DLExpansion'
    +    SERVICE_NAME = "ExpandDL"
    +    element_container_name = f"{{{MNS}}}DLExpansion"
         WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults
     
         def call(self, distribution_list):
    @@ -82,7 +82,7 @@ 

    Classes

    return Mailbox.from_xml(elem, account=None) def get_payload(self, distribution_list): - return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), distribution_list, version=self.protocol.version)
    + return set_xml_value(create_element(f"m:{self.SERVICE_NAME}"), distribution_list, version=self.protocol.version)

    Ancestors

      @@ -128,7 +128,7 @@

      Methods

      Expand source code
      def get_payload(self, distribution_list):
      -    return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), distribution_list, version=self.protocol.version)
      + return set_xml_value(create_element(f"m:{self.SERVICE_NAME}"), distribution_list, version=self.protocol.version)

    diff --git a/docs/exchangelib/services/export_items.html b/docs/exchangelib/services/export_items.html index 9e93423b..1c36ae71 100644 --- a/docs/exchangelib/services/export_items.html +++ b/docs/exchangelib/services/export_items.html @@ -26,17 +26,17 @@

    Module exchangelib.services.export_items

    Expand source code -
    from .common import EWSAccountService, item_ids_element
    -from ..errors import ResponseMessageError
    -from ..util import create_element, MNS
    +
    from ..errors import ResponseMessageError
    +from ..util import MNS, create_element
    +from .common import EWSAccountService, item_ids_element
     
     
     class ExportItems(EWSAccountService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exportitems-operation"""
     
         ERRORS_TO_CATCH_IN_RESPONSE = ResponseMessageError
    -    SERVICE_NAME = 'ExportItems'
    -    element_container_name = f'{{{MNS}}}Data'
    +    SERVICE_NAME = "ExportItems"
    +    element_container_name = f"{{{MNS}}}Data"
     
         def call(self, items):
             return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items))
    @@ -45,7 +45,7 @@ 

    Module exchangelib.services.export_items

    return elem.text # All we want is the 64bit string in the 'Data' tag def get_payload(self, items): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") payload.append(item_ids_element(items=items, version=self.account.version)) return payload @@ -79,8 +79,8 @@

    Classes

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exportitems-operation""" ERRORS_TO_CATCH_IN_RESPONSE = ResponseMessageError - SERVICE_NAME = 'ExportItems' - element_container_name = f'{{{MNS}}}Data' + SERVICE_NAME = "ExportItems" + element_container_name = f"{{{MNS}}}Data" def call(self, items): return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items)) @@ -89,7 +89,7 @@

    Classes

    return elem.text # All we want is the 64bit string in the 'Data' tag def get_payload(self, items): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") payload.append(item_ids_element(items=items, version=self.account.version)) return payload @@ -144,7 +144,7 @@

    Methods

    Expand source code
    def get_payload(self, items):
    -    payload = create_element(f'm:{self.SERVICE_NAME}')
    +    payload = create_element(f"m:{self.SERVICE_NAME}")
         payload.append(item_ids_element(items=items, version=self.account.version))
         return payload
    diff --git a/docs/exchangelib/services/find_folder.html b/docs/exchangelib/services/find_folder.html index 24912407..0f728f8e 100644 --- a/docs/exchangelib/services/find_folder.html +++ b/docs/exchangelib/services/find_folder.html @@ -26,21 +26,21 @@

    Module exchangelib.services.find_folder

    Expand source code -
    from .common import EWSPagingService, shape_element, folder_ids_element
    -from ..errors import InvalidEnumValue
    +
    from ..errors import InvalidEnumValue
     from ..folders import Folder
     from ..folders.queryset import FOLDER_TRAVERSAL_CHOICES
     from ..items import SHAPE_CHOICES
    -from ..util import create_element, TNS, MNS
    +from ..util import MNS, TNS, create_element
     from ..version import EXCHANGE_2010
    +from .common import EWSPagingService, folder_ids_element, shape_element
     
     
     class FindFolder(EWSPagingService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/findfolder-operation"""
     
    -    SERVICE_NAME = 'FindFolder'
    -    element_container_name = f'{{{TNS}}}Folders'
    -    paging_container_name = f'{{{MNS}}}RootFolder'
    +    SERVICE_NAME = "FindFolder"
    +    element_container_name = f"{{{TNS}}}Folders"
    +    paging_container_name = f"{{{MNS}}}RootFolder"
         supports_paging = True
     
         def __init__(self, *args, **kwargs):
    @@ -61,14 +61,15 @@ 

    Module exchangelib.services.find_folder

    :return: XML elements for the matching folders """ if shape not in SHAPE_CHOICES: - raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + raise InvalidEnumValue("shape", shape, SHAPE_CHOICES) if depth not in FOLDER_TRAVERSAL_CHOICES: - raise InvalidEnumValue('depth', depth, FOLDER_TRAVERSAL_CHOICES) + raise InvalidEnumValue("depth", depth, FOLDER_TRAVERSAL_CHOICES) roots = {f.root for f in folders} if len(roots) != 1: raise ValueError(f"All folders in 'roots' must have the same root hierarchy ({roots})") self.root = roots.pop() - return self._elems_to_objs(self._paged_call( + return self._elems_to_objs( + self._paged_call( payload_func=self.get_payload, max_items=max_items, folders=folders, @@ -79,21 +80,24 @@

    Module exchangelib.services.find_folder

    depth=depth, page_size=self.page_size, offset=offset, - ) - )) + ), + ) + ) def _elem_to_obj(self, elem): return Folder.from_xml_with_root(elem=elem, root=self.root) def get_payload(self, folders, additional_fields, restriction, shape, depth, page_size, offset=0): - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) - payload.append(shape_element( - tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version - )) + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(Traversal=depth)) + payload.append( + shape_element( + tag="m:FolderShape", shape=shape, additional_fields=additional_fields, version=self.account.version + ) + ) if self.account.version.build >= EXCHANGE_2010: indexed_page_folder_view = create_element( - 'm:IndexedPageFolderView', - attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') + "m:IndexedPageFolderView", + attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint="Beginning"), ) payload.append(indexed_page_folder_view) else: @@ -101,7 +105,7 @@

    Module exchangelib.services.find_folder

    raise NotImplementedError("'offset' is only supported for Exchange 2010 servers and later") if restriction: payload.append(restriction.to_xml(version=self.account.version)) - payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds')) + payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag="m:ParentFolderIds")) return payload
    @@ -127,9 +131,9 @@

    Classes

    class FindFolder(EWSPagingService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/findfolder-operation"""
     
    -    SERVICE_NAME = 'FindFolder'
    -    element_container_name = f'{{{TNS}}}Folders'
    -    paging_container_name = f'{{{MNS}}}RootFolder'
    +    SERVICE_NAME = "FindFolder"
    +    element_container_name = f"{{{TNS}}}Folders"
    +    paging_container_name = f"{{{MNS}}}RootFolder"
         supports_paging = True
     
         def __init__(self, *args, **kwargs):
    @@ -150,14 +154,15 @@ 

    Classes

    :return: XML elements for the matching folders """ if shape not in SHAPE_CHOICES: - raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + raise InvalidEnumValue("shape", shape, SHAPE_CHOICES) if depth not in FOLDER_TRAVERSAL_CHOICES: - raise InvalidEnumValue('depth', depth, FOLDER_TRAVERSAL_CHOICES) + raise InvalidEnumValue("depth", depth, FOLDER_TRAVERSAL_CHOICES) roots = {f.root for f in folders} if len(roots) != 1: raise ValueError(f"All folders in 'roots' must have the same root hierarchy ({roots})") self.root = roots.pop() - return self._elems_to_objs(self._paged_call( + return self._elems_to_objs( + self._paged_call( payload_func=self.get_payload, max_items=max_items, folders=folders, @@ -168,21 +173,24 @@

    Classes

    depth=depth, page_size=self.page_size, offset=offset, - ) - )) + ), + ) + ) def _elem_to_obj(self, elem): return Folder.from_xml_with_root(elem=elem, root=self.root) def get_payload(self, folders, additional_fields, restriction, shape, depth, page_size, offset=0): - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) - payload.append(shape_element( - tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version - )) + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(Traversal=depth)) + payload.append( + shape_element( + tag="m:FolderShape", shape=shape, additional_fields=additional_fields, version=self.account.version + ) + ) if self.account.version.build >= EXCHANGE_2010: indexed_page_folder_view = create_element( - 'm:IndexedPageFolderView', - attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') + "m:IndexedPageFolderView", + attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint="Beginning"), ) payload.append(indexed_page_folder_view) else: @@ -190,7 +198,7 @@

    Classes

    raise NotImplementedError("'offset' is only supported for Exchange 2010 servers and later") if restriction: payload.append(restriction.to_xml(version=self.account.version)) - payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds')) + payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag="m:ParentFolderIds")) return payload

    Ancestors

    @@ -251,14 +259,15 @@

    Methods

    :return: XML elements for the matching folders """ if shape not in SHAPE_CHOICES: - raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + raise InvalidEnumValue("shape", shape, SHAPE_CHOICES) if depth not in FOLDER_TRAVERSAL_CHOICES: - raise InvalidEnumValue('depth', depth, FOLDER_TRAVERSAL_CHOICES) + raise InvalidEnumValue("depth", depth, FOLDER_TRAVERSAL_CHOICES) roots = {f.root for f in folders} if len(roots) != 1: raise ValueError(f"All folders in 'roots' must have the same root hierarchy ({roots})") self.root = roots.pop() - return self._elems_to_objs(self._paged_call( + return self._elems_to_objs( + self._paged_call( payload_func=self.get_payload, max_items=max_items, folders=folders, @@ -269,8 +278,9 @@

    Methods

    depth=depth, page_size=self.page_size, offset=offset, - ) - ))
    + ), + ) + )
    @@ -283,14 +293,16 @@

    Methods

    Expand source code
    def get_payload(self, folders, additional_fields, restriction, shape, depth, page_size, offset=0):
    -    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth))
    -    payload.append(shape_element(
    -        tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version
    -    ))
    +    payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(Traversal=depth))
    +    payload.append(
    +        shape_element(
    +            tag="m:FolderShape", shape=shape, additional_fields=additional_fields, version=self.account.version
    +        )
    +    )
         if self.account.version.build >= EXCHANGE_2010:
             indexed_page_folder_view = create_element(
    -            'm:IndexedPageFolderView',
    -            attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning')
    +            "m:IndexedPageFolderView",
    +            attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint="Beginning"),
             )
             payload.append(indexed_page_folder_view)
         else:
    @@ -298,7 +310,7 @@ 

    Methods

    raise NotImplementedError("'offset' is only supported for Exchange 2010 servers and later") if restriction: payload.append(restriction.to_xml(version=self.account.version)) - payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds')) + payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag="m:ParentFolderIds")) return payload
    diff --git a/docs/exchangelib/services/find_item.html b/docs/exchangelib/services/find_item.html index d0701e68..ea5dd93a 100644 --- a/docs/exchangelib/services/find_item.html +++ b/docs/exchangelib/services/find_item.html @@ -26,19 +26,19 @@

    Module exchangelib.services.find_item

    Expand source code -
    from .common import EWSPagingService, shape_element, folder_ids_element
    -from ..errors import InvalidEnumValue
    +
    from ..errors import InvalidEnumValue
     from ..folders.base import BaseFolder
    -from ..items import Item, ID_ONLY, SHAPE_CHOICES, ITEM_TRAVERSAL_CHOICES
    -from ..util import create_element, set_xml_value, TNS, MNS
    +from ..items import ID_ONLY, ITEM_TRAVERSAL_CHOICES, SHAPE_CHOICES, Item
    +from ..util import MNS, TNS, create_element, set_xml_value
    +from .common import EWSPagingService, folder_ids_element, shape_element
     
     
     class FindItem(EWSPagingService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/finditem-operation"""
     
    -    SERVICE_NAME = 'FindItem'
    -    element_container_name = f'{{{TNS}}}Items'
    -    paging_container_name = f'{{{MNS}}}RootFolder'
    +    SERVICE_NAME = "FindItem"
    +    element_container_name = f"{{{TNS}}}Items"
    +    paging_container_name = f"{{{MNS}}}RootFolder"
         supports_paging = True
     
         def __init__(self, *args, **kwargs):
    @@ -47,8 +47,19 @@ 

    Module exchangelib.services.find_item

    self.additional_fields = None self.shape = None - def call(self, folders, additional_fields, restriction, order_fields, shape, query_string, depth, calendar_view, - max_items, offset): + def call( + self, + folders, + additional_fields, + restriction, + order_fields, + shape, + query_string, + depth, + calendar_view, + max_items, + offset, + ): """Find items in an account. :param folders: the folders to act on @@ -65,43 +76,57 @@

    Module exchangelib.services.find_item

    :return: XML elements for the matching items """ if shape not in SHAPE_CHOICES: - raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + raise InvalidEnumValue("shape", shape, SHAPE_CHOICES) if depth not in ITEM_TRAVERSAL_CHOICES: - raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) + raise InvalidEnumValue("depth", depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape - return self._elems_to_objs(self._paged_call( - payload_func=self.get_payload, - max_items=max_items, - folders=folders, - **dict( - additional_fields=additional_fields, - restriction=restriction, - order_fields=order_fields, - query_string=query_string, - shape=shape, - depth=depth, - calendar_view=calendar_view, - page_size=self.page_size, - offset=offset, + return self._elems_to_objs( + self._paged_call( + payload_func=self.get_payload, + max_items=max_items, + folders=folders, + **dict( + additional_fields=additional_fields, + restriction=restriction, + order_fields=order_fields, + query_string=query_string, + shape=shape, + depth=depth, + calendar_view=calendar_view, + page_size=self.page_size, + offset=offset, + ), ) - )) + ) def _elem_to_obj(self, elem): if self.shape == ID_ONLY and self.additional_fields is None: return Item.id_from_xml(elem) return BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) - def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, - calendar_view, page_size, offset=0): - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) - payload.append(shape_element( - tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version - )) + def get_payload( + self, + folders, + additional_fields, + restriction, + order_fields, + query_string, + shape, + depth, + calendar_view, + page_size, + offset=0, + ): + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(Traversal=depth)) + payload.append( + shape_element( + tag="m:ItemShape", shape=shape, additional_fields=additional_fields, version=self.account.version + ) + ) if calendar_view is None: view_type = create_element( - 'm:IndexedPageItemView', - attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') + "m:IndexedPageItemView", attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint="Beginning") ) else: view_type = calendar_view.to_xml(version=self.account.version) @@ -109,12 +134,8 @@

    Module exchangelib.services.find_item

    if restriction: payload.append(restriction.to_xml(version=self.account.version)) if order_fields: - payload.append(set_xml_value( - create_element('m:SortOrder'), - order_fields, - version=self.account.version - )) - payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds')) + payload.append(set_xml_value(create_element("m:SortOrder"), order_fields, version=self.account.version)) + payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag="m:ParentFolderIds")) if query_string: payload.append(query_string.to_xml(version=self.account.version)) return payload
    @@ -142,9 +163,9 @@

    Classes

    class FindItem(EWSPagingService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/finditem-operation"""
     
    -    SERVICE_NAME = 'FindItem'
    -    element_container_name = f'{{{TNS}}}Items'
    -    paging_container_name = f'{{{MNS}}}RootFolder'
    +    SERVICE_NAME = "FindItem"
    +    element_container_name = f"{{{TNS}}}Items"
    +    paging_container_name = f"{{{MNS}}}RootFolder"
         supports_paging = True
     
         def __init__(self, *args, **kwargs):
    @@ -153,8 +174,19 @@ 

    Classes

    self.additional_fields = None self.shape = None - def call(self, folders, additional_fields, restriction, order_fields, shape, query_string, depth, calendar_view, - max_items, offset): + def call( + self, + folders, + additional_fields, + restriction, + order_fields, + shape, + query_string, + depth, + calendar_view, + max_items, + offset, + ): """Find items in an account. :param folders: the folders to act on @@ -171,43 +203,57 @@

    Classes

    :return: XML elements for the matching items """ if shape not in SHAPE_CHOICES: - raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + raise InvalidEnumValue("shape", shape, SHAPE_CHOICES) if depth not in ITEM_TRAVERSAL_CHOICES: - raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) + raise InvalidEnumValue("depth", depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape - return self._elems_to_objs(self._paged_call( - payload_func=self.get_payload, - max_items=max_items, - folders=folders, - **dict( - additional_fields=additional_fields, - restriction=restriction, - order_fields=order_fields, - query_string=query_string, - shape=shape, - depth=depth, - calendar_view=calendar_view, - page_size=self.page_size, - offset=offset, + return self._elems_to_objs( + self._paged_call( + payload_func=self.get_payload, + max_items=max_items, + folders=folders, + **dict( + additional_fields=additional_fields, + restriction=restriction, + order_fields=order_fields, + query_string=query_string, + shape=shape, + depth=depth, + calendar_view=calendar_view, + page_size=self.page_size, + offset=offset, + ), ) - )) + ) def _elem_to_obj(self, elem): if self.shape == ID_ONLY and self.additional_fields is None: return Item.id_from_xml(elem) return BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) - def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, - calendar_view, page_size, offset=0): - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) - payload.append(shape_element( - tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version - )) + def get_payload( + self, + folders, + additional_fields, + restriction, + order_fields, + query_string, + shape, + depth, + calendar_view, + page_size, + offset=0, + ): + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(Traversal=depth)) + payload.append( + shape_element( + tag="m:ItemShape", shape=shape, additional_fields=additional_fields, version=self.account.version + ) + ) if calendar_view is None: view_type = create_element( - 'm:IndexedPageItemView', - attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') + "m:IndexedPageItemView", attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint="Beginning") ) else: view_type = calendar_view.to_xml(version=self.account.version) @@ -215,12 +261,8 @@

    Classes

    if restriction: payload.append(restriction.to_xml(version=self.account.version)) if order_fields: - payload.append(set_xml_value( - create_element('m:SortOrder'), - order_fields, - version=self.account.version - )) - payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds')) + payload.append(set_xml_value(create_element("m:SortOrder"), order_fields, version=self.account.version)) + payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag="m:ParentFolderIds")) if query_string: payload.append(query_string.to_xml(version=self.account.version)) return payload
    @@ -272,8 +314,19 @@

    Methods

    Expand source code -
    def call(self, folders, additional_fields, restriction, order_fields, shape, query_string, depth, calendar_view,
    -         max_items, offset):
    +
    def call(
    +    self,
    +    folders,
    +    additional_fields,
    +    restriction,
    +    order_fields,
    +    shape,
    +    query_string,
    +    depth,
    +    calendar_view,
    +    max_items,
    +    offset,
    +):
         """Find items in an account.
     
         :param folders: the folders to act on
    @@ -290,27 +343,29 @@ 

    Methods

    :return: XML elements for the matching items """ if shape not in SHAPE_CHOICES: - raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + raise InvalidEnumValue("shape", shape, SHAPE_CHOICES) if depth not in ITEM_TRAVERSAL_CHOICES: - raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) + raise InvalidEnumValue("depth", depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape - return self._elems_to_objs(self._paged_call( - payload_func=self.get_payload, - max_items=max_items, - folders=folders, - **dict( - additional_fields=additional_fields, - restriction=restriction, - order_fields=order_fields, - query_string=query_string, - shape=shape, - depth=depth, - calendar_view=calendar_view, - page_size=self.page_size, - offset=offset, + return self._elems_to_objs( + self._paged_call( + payload_func=self.get_payload, + max_items=max_items, + folders=folders, + **dict( + additional_fields=additional_fields, + restriction=restriction, + order_fields=order_fields, + query_string=query_string, + shape=shape, + depth=depth, + calendar_view=calendar_view, + page_size=self.page_size, + offset=offset, + ), ) - ))
    + )
    @@ -322,16 +377,28 @@

    Methods

    Expand source code -
    def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth,
    -                calendar_view, page_size, offset=0):
    -    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth))
    -    payload.append(shape_element(
    -        tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version
    -    ))
    +
    def get_payload(
    +    self,
    +    folders,
    +    additional_fields,
    +    restriction,
    +    order_fields,
    +    query_string,
    +    shape,
    +    depth,
    +    calendar_view,
    +    page_size,
    +    offset=0,
    +):
    +    payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(Traversal=depth))
    +    payload.append(
    +        shape_element(
    +            tag="m:ItemShape", shape=shape, additional_fields=additional_fields, version=self.account.version
    +        )
    +    )
         if calendar_view is None:
             view_type = create_element(
    -            'm:IndexedPageItemView',
    -            attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning')
    +            "m:IndexedPageItemView", attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint="Beginning")
             )
         else:
             view_type = calendar_view.to_xml(version=self.account.version)
    @@ -339,12 +406,8 @@ 

    Methods

    if restriction: payload.append(restriction.to_xml(version=self.account.version)) if order_fields: - payload.append(set_xml_value( - create_element('m:SortOrder'), - order_fields, - version=self.account.version - )) - payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds')) + payload.append(set_xml_value(create_element("m:SortOrder"), order_fields, version=self.account.version)) + payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag="m:ParentFolderIds")) if query_string: payload.append(query_string.to_xml(version=self.account.version)) return payload
    diff --git a/docs/exchangelib/services/find_people.html b/docs/exchangelib/services/find_people.html index db96c304..acd0c50c 100644 --- a/docs/exchangelib/services/find_people.html +++ b/docs/exchangelib/services/find_people.html @@ -27,11 +27,12 @@

    Module exchangelib.services.find_people

    Expand source code
    import logging
    -from .common import EWSPagingService, shape_element, folder_ids_element
    +
     from ..errors import InvalidEnumValue
    -from ..items import Persona, ID_ONLY, SHAPE_CHOICES, ITEM_TRAVERSAL_CHOICES
    -from ..util import create_element, set_xml_value, MNS
    +from ..items import ID_ONLY, ITEM_TRAVERSAL_CHOICES, SHAPE_CHOICES, Persona
    +from ..util import MNS, create_element, set_xml_value
     from ..version import EXCHANGE_2013
    +from .common import EWSPagingService, folder_ids_element, shape_element
     
     log = logging.getLogger(__name__)
     
    @@ -39,8 +40,8 @@ 

    Module exchangelib.services.find_people

    class FindPeople(EWSPagingService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/findpeople-operation""" - SERVICE_NAME = 'FindPeople' - element_container_name = f'{{{MNS}}}People' + SERVICE_NAME = "FindPeople" + element_container_name = f"{{{MNS}}}People" supported_from = EXCHANGE_2013 supports_paging = True @@ -66,52 +67,54 @@

    Module exchangelib.services.find_people

    :return: XML elements for the matching items """ if shape not in SHAPE_CHOICES: - raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + raise InvalidEnumValue("shape", shape, SHAPE_CHOICES) if depth not in ITEM_TRAVERSAL_CHOICES: - raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) + raise InvalidEnumValue("depth", depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape - return self._elems_to_objs(self._paged_call( - payload_func=self.get_payload, - max_items=max_items, - folders=[folder], # We just need the list to satisfy self._paged_call() - **dict( - additional_fields=additional_fields, - restriction=restriction, - order_fields=order_fields, - query_string=query_string, - shape=shape, - depth=depth, - page_size=self.page_size, - offset=offset, + return self._elems_to_objs( + self._paged_call( + payload_func=self.get_payload, + max_items=max_items, + folders=[folder], # We just need the list to satisfy self._paged_call() + **dict( + additional_fields=additional_fields, + restriction=restriction, + order_fields=order_fields, + query_string=query_string, + shape=shape, + depth=depth, + page_size=self.page_size, + offset=offset, + ), ) - )) + ) def _elem_to_obj(self, elem): if self.shape == ID_ONLY and self.additional_fields is None: return Persona.id_from_xml(elem) return Persona.from_xml(elem, account=self.account) - def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, - offset=0): + def get_payload( + self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, offset=0 + ): # We actually only support a single folder, but self._paged_call() sends us a list - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) - payload.append(shape_element( - tag='m:PersonaShape', shape=shape, additional_fields=additional_fields, version=self.account.version - )) - payload.append(create_element( - 'm:IndexedPageItemView', - attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') - )) + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(Traversal=depth)) + payload.append( + shape_element( + tag="m:PersonaShape", shape=shape, additional_fields=additional_fields, version=self.account.version + ) + ) + payload.append( + create_element( + "m:IndexedPageItemView", attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint="Beginning") + ) + ) if restriction: payload.append(restriction.to_xml(version=self.account.version)) if order_fields: - payload.append(set_xml_value( - create_element('m:SortOrder'), - order_fields, - version=self.account.version - )) - payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:ParentFolderId')) + payload.append(set_xml_value(create_element("m:SortOrder"), order_fields, version=self.account.version)) + payload.append(folder_ids_element(folders=folders, version=self.account.version, tag="m:ParentFolderId")) if query_string: payload.append(query_string.to_xml(version=self.account.version)) return payload @@ -119,11 +122,15 @@

    Module exchangelib.services.find_people

    @staticmethod def _get_paging_values(elem): """Find paging values. The paging element from FindPeople is different from other paging containers.""" - item_count = int(elem.find(f'{{{MNS}}}TotalNumberOfPeopleInView').text) - first_matching = int(elem.find(f'{{{MNS}}}FirstMatchingRowIndex').text) - first_loaded = int(elem.find(f'{{{MNS}}}FirstLoadedRowIndex').text) - log.debug('Got page with total items %s, first matching %s, first loaded %s ', item_count, first_matching, - first_loaded) + item_count = int(elem.find(f"{{{MNS}}}TotalNumberOfPeopleInView").text) + first_matching = int(elem.find(f"{{{MNS}}}FirstMatchingRowIndex").text) + first_loaded = int(elem.find(f"{{{MNS}}}FirstLoadedRowIndex").text) + log.debug( + "Got page with total items %s, first matching %s, first loaded %s ", + item_count, + first_matching, + first_loaded, + ) next_offset = None # GetPersona does not support fetching more pages return item_count, next_offset
    @@ -150,8 +157,8 @@

    Classes

    class FindPeople(EWSPagingService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/findpeople-operation"""
     
    -    SERVICE_NAME = 'FindPeople'
    -    element_container_name = f'{{{MNS}}}People'
    +    SERVICE_NAME = "FindPeople"
    +    element_container_name = f"{{{MNS}}}People"
         supported_from = EXCHANGE_2013
         supports_paging = True
     
    @@ -177,52 +184,54 @@ 

    Classes

    :return: XML elements for the matching items """ if shape not in SHAPE_CHOICES: - raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + raise InvalidEnumValue("shape", shape, SHAPE_CHOICES) if depth not in ITEM_TRAVERSAL_CHOICES: - raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) + raise InvalidEnumValue("depth", depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape - return self._elems_to_objs(self._paged_call( - payload_func=self.get_payload, - max_items=max_items, - folders=[folder], # We just need the list to satisfy self._paged_call() - **dict( - additional_fields=additional_fields, - restriction=restriction, - order_fields=order_fields, - query_string=query_string, - shape=shape, - depth=depth, - page_size=self.page_size, - offset=offset, + return self._elems_to_objs( + self._paged_call( + payload_func=self.get_payload, + max_items=max_items, + folders=[folder], # We just need the list to satisfy self._paged_call() + **dict( + additional_fields=additional_fields, + restriction=restriction, + order_fields=order_fields, + query_string=query_string, + shape=shape, + depth=depth, + page_size=self.page_size, + offset=offset, + ), ) - )) + ) def _elem_to_obj(self, elem): if self.shape == ID_ONLY and self.additional_fields is None: return Persona.id_from_xml(elem) return Persona.from_xml(elem, account=self.account) - def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, - offset=0): + def get_payload( + self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, offset=0 + ): # We actually only support a single folder, but self._paged_call() sends us a list - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) - payload.append(shape_element( - tag='m:PersonaShape', shape=shape, additional_fields=additional_fields, version=self.account.version - )) - payload.append(create_element( - 'm:IndexedPageItemView', - attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') - )) + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(Traversal=depth)) + payload.append( + shape_element( + tag="m:PersonaShape", shape=shape, additional_fields=additional_fields, version=self.account.version + ) + ) + payload.append( + create_element( + "m:IndexedPageItemView", attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint="Beginning") + ) + ) if restriction: payload.append(restriction.to_xml(version=self.account.version)) if order_fields: - payload.append(set_xml_value( - create_element('m:SortOrder'), - order_fields, - version=self.account.version - )) - payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:ParentFolderId')) + payload.append(set_xml_value(create_element("m:SortOrder"), order_fields, version=self.account.version)) + payload.append(folder_ids_element(folders=folders, version=self.account.version, tag="m:ParentFolderId")) if query_string: payload.append(query_string.to_xml(version=self.account.version)) return payload @@ -230,11 +239,15 @@

    Classes

    @staticmethod def _get_paging_values(elem): """Find paging values. The paging element from FindPeople is different from other paging containers.""" - item_count = int(elem.find(f'{{{MNS}}}TotalNumberOfPeopleInView').text) - first_matching = int(elem.find(f'{{{MNS}}}FirstMatchingRowIndex').text) - first_loaded = int(elem.find(f'{{{MNS}}}FirstLoadedRowIndex').text) - log.debug('Got page with total items %s, first matching %s, first loaded %s ', item_count, first_matching, - first_loaded) + item_count = int(elem.find(f"{{{MNS}}}TotalNumberOfPeopleInView").text) + first_matching = int(elem.find(f"{{{MNS}}}FirstMatchingRowIndex").text) + first_loaded = int(elem.find(f"{{{MNS}}}FirstLoadedRowIndex").text) + log.debug( + "Got page with total items %s, first matching %s, first loaded %s ", + item_count, + first_matching, + first_loaded, + ) next_offset = None # GetPersona does not support fetching more pages return item_count, next_offset
    @@ -300,26 +313,28 @@

    Methods

    :return: XML elements for the matching items """ if shape not in SHAPE_CHOICES: - raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + raise InvalidEnumValue("shape", shape, SHAPE_CHOICES) if depth not in ITEM_TRAVERSAL_CHOICES: - raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) + raise InvalidEnumValue("depth", depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape - return self._elems_to_objs(self._paged_call( - payload_func=self.get_payload, - max_items=max_items, - folders=[folder], # We just need the list to satisfy self._paged_call() - **dict( - additional_fields=additional_fields, - restriction=restriction, - order_fields=order_fields, - query_string=query_string, - shape=shape, - depth=depth, - page_size=self.page_size, - offset=offset, + return self._elems_to_objs( + self._paged_call( + payload_func=self.get_payload, + max_items=max_items, + folders=[folder], # We just need the list to satisfy self._paged_call() + **dict( + additional_fields=additional_fields, + restriction=restriction, + order_fields=order_fields, + query_string=query_string, + shape=shape, + depth=depth, + page_size=self.page_size, + offset=offset, + ), ) - ))
    + )
    @@ -331,26 +346,26 @@

    Methods

    Expand source code -
    def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size,
    -                offset=0):
    +
    def get_payload(
    +    self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, offset=0
    +):
         # We actually only support a single folder, but self._paged_call() sends us a list
    -    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth))
    -    payload.append(shape_element(
    -        tag='m:PersonaShape', shape=shape, additional_fields=additional_fields, version=self.account.version
    -    ))
    -    payload.append(create_element(
    -        'm:IndexedPageItemView',
    -        attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning')
    -    ))
    +    payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(Traversal=depth))
    +    payload.append(
    +        shape_element(
    +            tag="m:PersonaShape", shape=shape, additional_fields=additional_fields, version=self.account.version
    +        )
    +    )
    +    payload.append(
    +        create_element(
    +            "m:IndexedPageItemView", attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint="Beginning")
    +        )
    +    )
         if restriction:
             payload.append(restriction.to_xml(version=self.account.version))
         if order_fields:
    -        payload.append(set_xml_value(
    -            create_element('m:SortOrder'),
    -            order_fields,
    -            version=self.account.version
    -        ))
    -    payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:ParentFolderId'))
    +        payload.append(set_xml_value(create_element("m:SortOrder"), order_fields, version=self.account.version))
    +    payload.append(folder_ids_element(folders=folders, version=self.account.version, tag="m:ParentFolderId"))
         if query_string:
             payload.append(query_string.to_xml(version=self.account.version))
         return payload
    diff --git a/docs/exchangelib/services/get_attachment.html b/docs/exchangelib/services/get_attachment.html index e635ab7e..39c2401c 100644 --- a/docs/exchangelib/services/get_attachment.html +++ b/docs/exchangelib/services/get_attachment.html @@ -28,50 +28,65 @@

    Module exchangelib.services.get_attachment

    from itertools import chain
     
    -from .common import EWSAccountService, attachment_ids_element
     from ..attachments import FileAttachment, ItemAttachment
     from ..errors import InvalidEnumValue
    -from ..util import create_element, add_xml_child, set_xml_value, DummyResponse, StreamingBase64Parser,\
    -    StreamingContentHandler, ElementNotFound, MNS
    +from ..util import (
    +    MNS,
    +    DummyResponse,
    +    ElementNotFound,
    +    StreamingBase64Parser,
    +    StreamingContentHandler,
    +    add_xml_child,
    +    create_element,
    +    set_xml_value,
    +)
    +from .common import EWSAccountService, attachment_ids_element
     
     # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/bodytype
    -BODY_TYPE_CHOICES = ('Best', 'HTML', 'Text')
    +BODY_TYPE_CHOICES = ("Best", "HTML", "Text")
     
     
     class GetAttachment(EWSAccountService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getattachment-operation"""
     
    -    SERVICE_NAME = 'GetAttachment'
    -    element_container_name = f'{{{MNS}}}Attachments'
    +    SERVICE_NAME = "GetAttachment"
    +    element_container_name = f"{{{MNS}}}Attachments"
         cls_map = {cls.response_tag(): cls for cls in (FileAttachment, ItemAttachment)}
     
         def call(self, items, include_mime_content, body_type, filter_html_content, additional_fields):
             if body_type and body_type not in BODY_TYPE_CHOICES:
    -            raise InvalidEnumValue('body_type', body_type, BODY_TYPE_CHOICES)
    -        return self._elems_to_objs(self._chunked_get_elements(
    -            self.get_payload, items=items, include_mime_content=include_mime_content,
    -            body_type=body_type, filter_html_content=filter_html_content, additional_fields=additional_fields,
    -        ))
    +            raise InvalidEnumValue("body_type", body_type, BODY_TYPE_CHOICES)
    +        return self._elems_to_objs(
    +            self._chunked_get_elements(
    +                self.get_payload,
    +                items=items,
    +                include_mime_content=include_mime_content,
    +                body_type=body_type,
    +                filter_html_content=filter_html_content,
    +                additional_fields=additional_fields,
    +            )
    +        )
     
         def _elem_to_obj(self, elem):
             return self.cls_map[elem.tag].from_xml(elem=elem, account=self.account)
     
         def get_payload(self, items, include_mime_content, body_type, filter_html_content, additional_fields):
    -        payload = create_element(f'm:{self.SERVICE_NAME}')
    -        shape_elem = create_element('m:AttachmentShape')
    +        payload = create_element(f"m:{self.SERVICE_NAME}")
    +        shape_elem = create_element("m:AttachmentShape")
             if include_mime_content:
    -            add_xml_child(shape_elem, 't:IncludeMimeContent', 'true')
    +            add_xml_child(shape_elem, "t:IncludeMimeContent", "true")
             if body_type:
    -            add_xml_child(shape_elem, 't:BodyType', body_type)
    +            add_xml_child(shape_elem, "t:BodyType", body_type)
             if filter_html_content is not None:
    -            add_xml_child(shape_elem, 't:FilterHtmlContent', 'true' if filter_html_content else 'false')
    +            add_xml_child(shape_elem, "t:FilterHtmlContent", "true" if filter_html_content else "false")
             if additional_fields:
    -            additional_properties = create_element('t:AdditionalProperties')
    +            additional_properties = create_element("t:AdditionalProperties")
                 expanded_fields = chain(*(f.expand(version=self.account.version) for f in additional_fields))
    -            set_xml_value(additional_properties, sorted(
    -                expanded_fields,
    -                key=lambda f: (getattr(f.field, 'field_uri', ''), f.path)
    -            ), version=self.account.version)
    +            set_xml_value(
    +                additional_properties,
    +                sorted(expanded_fields, key=lambda f: (getattr(f.field, "field_uri", ""), f.path)),
    +                version=self.account.version,
    +            )
                 shape_elem.append(additional_properties)
             if len(shape_elem):
                 payload.append(shape_elem)
    @@ -79,26 +94,26 @@ 

    Module exchangelib.services.get_attachment

    return payload def _update_api_version(self, api_version, header, **parse_opts): - if not parse_opts.get('stream_file_content', False): + if not parse_opts.get("stream_file_content", False): super()._update_api_version(api_version, header, **parse_opts) # TODO: We're skipping this part in streaming mode because StreamingBase64Parser cannot parse the SOAP header @classmethod def _get_soap_parts(cls, response, **parse_opts): - if not parse_opts.get('stream_file_content', False): + if not parse_opts.get("stream_file_content", False): return super()._get_soap_parts(response, **parse_opts) # Pass the response unaltered. We want to use our custom streaming parser return None, response def _get_soap_messages(self, body, **parse_opts): - if not parse_opts.get('stream_file_content', False): + if not parse_opts.get("stream_file_content", False): return super()._get_soap_messages(body, **parse_opts) # 'body' is actually the raw response passed on by '_get_soap_parts' r = body parser = StreamingBase64Parser() - field = FileAttachment.get_field_by_fieldname('_content') + field = FileAttachment.get_field_by_fieldname("_content") handler = StreamingContentHandler(parser=parser, ns=field.namespace, element_name=field.field_uri) parser.setContentHandler(handler) return parser.parse(r) @@ -106,7 +121,10 @@

    Module exchangelib.services.get_attachment

    def stream_file_content(self, attachment_id): # The streaming XML parser can only stream content of one attachment payload = self.get_payload( - items=[attachment_id], include_mime_content=False, body_type=None, filter_html_content=None, + items=[attachment_id], + include_mime_content=False, + body_type=None, + filter_html_content=None, additional_fields=None, ) self.streaming = True @@ -151,37 +169,44 @@

    Classes

    class GetAttachment(EWSAccountService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getattachment-operation"""
     
    -    SERVICE_NAME = 'GetAttachment'
    -    element_container_name = f'{{{MNS}}}Attachments'
    +    SERVICE_NAME = "GetAttachment"
    +    element_container_name = f"{{{MNS}}}Attachments"
         cls_map = {cls.response_tag(): cls for cls in (FileAttachment, ItemAttachment)}
     
         def call(self, items, include_mime_content, body_type, filter_html_content, additional_fields):
             if body_type and body_type not in BODY_TYPE_CHOICES:
    -            raise InvalidEnumValue('body_type', body_type, BODY_TYPE_CHOICES)
    -        return self._elems_to_objs(self._chunked_get_elements(
    -            self.get_payload, items=items, include_mime_content=include_mime_content,
    -            body_type=body_type, filter_html_content=filter_html_content, additional_fields=additional_fields,
    -        ))
    +            raise InvalidEnumValue("body_type", body_type, BODY_TYPE_CHOICES)
    +        return self._elems_to_objs(
    +            self._chunked_get_elements(
    +                self.get_payload,
    +                items=items,
    +                include_mime_content=include_mime_content,
    +                body_type=body_type,
    +                filter_html_content=filter_html_content,
    +                additional_fields=additional_fields,
    +            )
    +        )
     
         def _elem_to_obj(self, elem):
             return self.cls_map[elem.tag].from_xml(elem=elem, account=self.account)
     
         def get_payload(self, items, include_mime_content, body_type, filter_html_content, additional_fields):
    -        payload = create_element(f'm:{self.SERVICE_NAME}')
    -        shape_elem = create_element('m:AttachmentShape')
    +        payload = create_element(f"m:{self.SERVICE_NAME}")
    +        shape_elem = create_element("m:AttachmentShape")
             if include_mime_content:
    -            add_xml_child(shape_elem, 't:IncludeMimeContent', 'true')
    +            add_xml_child(shape_elem, "t:IncludeMimeContent", "true")
             if body_type:
    -            add_xml_child(shape_elem, 't:BodyType', body_type)
    +            add_xml_child(shape_elem, "t:BodyType", body_type)
             if filter_html_content is not None:
    -            add_xml_child(shape_elem, 't:FilterHtmlContent', 'true' if filter_html_content else 'false')
    +            add_xml_child(shape_elem, "t:FilterHtmlContent", "true" if filter_html_content else "false")
             if additional_fields:
    -            additional_properties = create_element('t:AdditionalProperties')
    +            additional_properties = create_element("t:AdditionalProperties")
                 expanded_fields = chain(*(f.expand(version=self.account.version) for f in additional_fields))
    -            set_xml_value(additional_properties, sorted(
    -                expanded_fields,
    -                key=lambda f: (getattr(f.field, 'field_uri', ''), f.path)
    -            ), version=self.account.version)
    +            set_xml_value(
    +                additional_properties,
    +                sorted(expanded_fields, key=lambda f: (getattr(f.field, "field_uri", ""), f.path)),
    +                version=self.account.version,
    +            )
                 shape_elem.append(additional_properties)
             if len(shape_elem):
                 payload.append(shape_elem)
    @@ -189,26 +214,26 @@ 

    Classes

    return payload def _update_api_version(self, api_version, header, **parse_opts): - if not parse_opts.get('stream_file_content', False): + if not parse_opts.get("stream_file_content", False): super()._update_api_version(api_version, header, **parse_opts) # TODO: We're skipping this part in streaming mode because StreamingBase64Parser cannot parse the SOAP header @classmethod def _get_soap_parts(cls, response, **parse_opts): - if not parse_opts.get('stream_file_content', False): + if not parse_opts.get("stream_file_content", False): return super()._get_soap_parts(response, **parse_opts) # Pass the response unaltered. We want to use our custom streaming parser return None, response def _get_soap_messages(self, body, **parse_opts): - if not parse_opts.get('stream_file_content', False): + if not parse_opts.get("stream_file_content", False): return super()._get_soap_messages(body, **parse_opts) # 'body' is actually the raw response passed on by '_get_soap_parts' r = body parser = StreamingBase64Parser() - field = FileAttachment.get_field_by_fieldname('_content') + field = FileAttachment.get_field_by_fieldname("_content") handler = StreamingContentHandler(parser=parser, ns=field.namespace, element_name=field.field_uri) parser.setContentHandler(handler) return parser.parse(r) @@ -216,7 +241,10 @@

    Classes

    def stream_file_content(self, attachment_id): # The streaming XML parser can only stream content of one attachment payload = self.get_payload( - items=[attachment_id], include_mime_content=False, body_type=None, filter_html_content=None, + items=[attachment_id], + include_mime_content=False, + body_type=None, + filter_html_content=None, additional_fields=None, ) self.streaming = True @@ -271,11 +299,17 @@

    Methods

    def call(self, items, include_mime_content, body_type, filter_html_content, additional_fields):
         if body_type and body_type not in BODY_TYPE_CHOICES:
    -        raise InvalidEnumValue('body_type', body_type, BODY_TYPE_CHOICES)
    -    return self._elems_to_objs(self._chunked_get_elements(
    -        self.get_payload, items=items, include_mime_content=include_mime_content,
    -        body_type=body_type, filter_html_content=filter_html_content, additional_fields=additional_fields,
    -    ))
    + raise InvalidEnumValue("body_type", body_type, BODY_TYPE_CHOICES) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=items, + include_mime_content=include_mime_content, + body_type=body_type, + filter_html_content=filter_html_content, + additional_fields=additional_fields, + ) + )
    @@ -288,21 +322,22 @@

    Methods

    Expand source code
    def get_payload(self, items, include_mime_content, body_type, filter_html_content, additional_fields):
    -    payload = create_element(f'm:{self.SERVICE_NAME}')
    -    shape_elem = create_element('m:AttachmentShape')
    +    payload = create_element(f"m:{self.SERVICE_NAME}")
    +    shape_elem = create_element("m:AttachmentShape")
         if include_mime_content:
    -        add_xml_child(shape_elem, 't:IncludeMimeContent', 'true')
    +        add_xml_child(shape_elem, "t:IncludeMimeContent", "true")
         if body_type:
    -        add_xml_child(shape_elem, 't:BodyType', body_type)
    +        add_xml_child(shape_elem, "t:BodyType", body_type)
         if filter_html_content is not None:
    -        add_xml_child(shape_elem, 't:FilterHtmlContent', 'true' if filter_html_content else 'false')
    +        add_xml_child(shape_elem, "t:FilterHtmlContent", "true" if filter_html_content else "false")
         if additional_fields:
    -        additional_properties = create_element('t:AdditionalProperties')
    +        additional_properties = create_element("t:AdditionalProperties")
             expanded_fields = chain(*(f.expand(version=self.account.version) for f in additional_fields))
    -        set_xml_value(additional_properties, sorted(
    -            expanded_fields,
    -            key=lambda f: (getattr(f.field, 'field_uri', ''), f.path)
    -        ), version=self.account.version)
    +        set_xml_value(
    +            additional_properties,
    +            sorted(expanded_fields, key=lambda f: (getattr(f.field, "field_uri", ""), f.path)),
    +            version=self.account.version,
    +        )
             shape_elem.append(additional_properties)
         if len(shape_elem):
             payload.append(shape_elem)
    @@ -322,7 +357,10 @@ 

    Methods

    def stream_file_content(self, attachment_id):
         # The streaming XML parser can only stream content of one attachment
         payload = self.get_payload(
    -        items=[attachment_id], include_mime_content=False, body_type=None, filter_html_content=None,
    +        items=[attachment_id],
    +        include_mime_content=False,
    +        body_type=None,
    +        filter_html_content=None,
             additional_fields=None,
         )
         self.streaming = True
    diff --git a/docs/exchangelib/services/get_delegate.html b/docs/exchangelib/services/get_delegate.html
    index c8c3ec03..93085f09 100644
    --- a/docs/exchangelib/services/get_delegate.html
    +++ b/docs/exchangelib/services/get_delegate.html
    @@ -26,35 +26,37 @@ 

    Module exchangelib.services.get_delegate

    Expand source code -
    from .common import EWSAccountService
    -from ..properties import DLMailbox, DelegateUser, UserId  # The service expects a Mailbox element in the MNS namespace
    -from ..util import create_element, set_xml_value, MNS
    +
    from ..properties import DelegateUser, DLMailbox, UserId  # The service expects a Mailbox element in the MNS namespace
    +from ..util import MNS, create_element, set_xml_value
     from ..version import EXCHANGE_2007_SP1
    +from .common import EWSAccountService
     
     
     class GetDelegate(EWSAccountService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getdelegate-operation"""
     
    -    SERVICE_NAME = 'GetDelegate'
    +    SERVICE_NAME = "GetDelegate"
         ERRORS_TO_CATCH_IN_RESPONSE = ()
         supported_from = EXCHANGE_2007_SP1
     
         def call(self, user_ids, include_permissions):
    -        return self._elems_to_objs(self._chunked_get_elements(
    -            self.get_payload,
    -            items=user_ids or [None],
    -            mailbox=DLMailbox(email_address=self.account.primary_smtp_address),
    -            include_permissions=include_permissions,
    -        ))
    +        return self._elems_to_objs(
    +            self._chunked_get_elements(
    +                self.get_payload,
    +                items=user_ids or [None],
    +                mailbox=DLMailbox(email_address=self.account.primary_smtp_address),
    +                include_permissions=include_permissions,
    +            )
    +        )
     
         def _elem_to_obj(self, elem):
             return DelegateUser.from_xml(elem=elem, account=self.account)
     
         def get_payload(self, user_ids, mailbox, include_permissions):
    -        payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IncludePermissions=include_permissions))
    +        payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(IncludePermissions=include_permissions))
             set_xml_value(payload, mailbox, version=self.protocol.version)
             if user_ids != [None]:
    -            user_ids_elem = create_element('m:UserIds')
    +            user_ids_elem = create_element("m:UserIds")
                 for user_id in user_ids:
                     if isinstance(user_id, str):
                         user_id = UserId(primary_smtp_address=user_id)
    @@ -68,7 +70,7 @@ 

    Module exchangelib.services.get_delegate

    @classmethod def _response_message_tag(cls): - return f'{{{MNS}}}DelegateUserResponseMessageType'
    + return f"{{{MNS}}}DelegateUserResponseMessageType"
    @@ -93,26 +95,28 @@

    Classes

    class GetDelegate(EWSAccountService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getdelegate-operation"""
     
    -    SERVICE_NAME = 'GetDelegate'
    +    SERVICE_NAME = "GetDelegate"
         ERRORS_TO_CATCH_IN_RESPONSE = ()
         supported_from = EXCHANGE_2007_SP1
     
         def call(self, user_ids, include_permissions):
    -        return self._elems_to_objs(self._chunked_get_elements(
    -            self.get_payload,
    -            items=user_ids or [None],
    -            mailbox=DLMailbox(email_address=self.account.primary_smtp_address),
    -            include_permissions=include_permissions,
    -        ))
    +        return self._elems_to_objs(
    +            self._chunked_get_elements(
    +                self.get_payload,
    +                items=user_ids or [None],
    +                mailbox=DLMailbox(email_address=self.account.primary_smtp_address),
    +                include_permissions=include_permissions,
    +            )
    +        )
     
         def _elem_to_obj(self, elem):
             return DelegateUser.from_xml(elem=elem, account=self.account)
     
         def get_payload(self, user_ids, mailbox, include_permissions):
    -        payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IncludePermissions=include_permissions))
    +        payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(IncludePermissions=include_permissions))
             set_xml_value(payload, mailbox, version=self.protocol.version)
             if user_ids != [None]:
    -            user_ids_elem = create_element('m:UserIds')
    +            user_ids_elem = create_element("m:UserIds")
                 for user_id in user_ids:
                     if isinstance(user_id, str):
                         user_id = UserId(primary_smtp_address=user_id)
    @@ -126,7 +130,7 @@ 

    Classes

    @classmethod def _response_message_tag(cls): - return f'{{{MNS}}}DelegateUserResponseMessageType'
    + return f"{{{MNS}}}DelegateUserResponseMessageType"

    Ancestors

      @@ -160,12 +164,14 @@

      Methods

      Expand source code
      def call(self, user_ids, include_permissions):
      -    return self._elems_to_objs(self._chunked_get_elements(
      -        self.get_payload,
      -        items=user_ids or [None],
      -        mailbox=DLMailbox(email_address=self.account.primary_smtp_address),
      -        include_permissions=include_permissions,
      -    ))
      + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=user_ids or [None], + mailbox=DLMailbox(email_address=self.account.primary_smtp_address), + include_permissions=include_permissions, + ) + )
    @@ -178,10 +184,10 @@

    Methods

    Expand source code
    def get_payload(self, user_ids, mailbox, include_permissions):
    -    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IncludePermissions=include_permissions))
    +    payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(IncludePermissions=include_permissions))
         set_xml_value(payload, mailbox, version=self.protocol.version)
         if user_ids != [None]:
    -        user_ids_elem = create_element('m:UserIds')
    +        user_ids_elem = create_element("m:UserIds")
             for user_id in user_ids:
                 if isinstance(user_id, str):
                     user_id = UserId(primary_smtp_address=user_id)
    diff --git a/docs/exchangelib/services/get_events.html b/docs/exchangelib/services/get_events.html
    index daf07773..d60e781d 100644
    --- a/docs/exchangelib/services/get_events.html
    +++ b/docs/exchangelib/services/get_events.html
    @@ -28,9 +28,9 @@ 

    Module exchangelib.services.get_events

    import logging
     
    -from .common import EWSAccountService, add_xml_child
     from ..properties import Notification
     from ..util import create_element
    +from .common import EWSAccountService, add_xml_child
     
     log = logging.getLogger(__name__)
     
    @@ -40,13 +40,18 @@ 

    Module exchangelib.services.get_events

    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getevents-operation """ - SERVICE_NAME = 'GetEvents' + SERVICE_NAME = "GetEvents" prefer_affinity = True def call(self, subscription_id, watermark): - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - subscription_id=subscription_id, watermark=watermark, - ))) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + subscription_id=subscription_id, + watermark=watermark, + ) + ) + ) def _elem_to_obj(self, elem): return Notification.from_xml(elem=elem, account=None) @@ -56,9 +61,9 @@

    Module exchangelib.services.get_events

    return container.findall(Notification.response_tag()) def get_payload(self, subscription_id, watermark): - payload = create_element(f'm:{self.SERVICE_NAME}') - add_xml_child(payload, 'm:SubscriptionId', subscription_id) - add_xml_child(payload, 'm:Watermark', watermark) + payload = create_element(f"m:{self.SERVICE_NAME}") + add_xml_child(payload, "m:SubscriptionId", subscription_id) + add_xml_child(payload, "m:Watermark", watermark) return payload
    @@ -87,13 +92,18 @@

    Classes

    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getevents-operation """ - SERVICE_NAME = 'GetEvents' + SERVICE_NAME = "GetEvents" prefer_affinity = True def call(self, subscription_id, watermark): - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - subscription_id=subscription_id, watermark=watermark, - ))) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + subscription_id=subscription_id, + watermark=watermark, + ) + ) + ) def _elem_to_obj(self, elem): return Notification.from_xml(elem=elem, account=None) @@ -103,9 +113,9 @@

    Classes

    return container.findall(Notification.response_tag()) def get_payload(self, subscription_id, watermark): - payload = create_element(f'm:{self.SERVICE_NAME}') - add_xml_child(payload, 'm:SubscriptionId', subscription_id) - add_xml_child(payload, 'm:Watermark', watermark) + payload = create_element(f"m:{self.SERVICE_NAME}") + add_xml_child(payload, "m:SubscriptionId", subscription_id) + add_xml_child(payload, "m:Watermark", watermark) return payload

    Ancestors

    @@ -136,9 +146,14 @@

    Methods

    Expand source code
    def call(self, subscription_id, watermark):
    -    return self._elems_to_objs(self._get_elements(payload=self.get_payload(
    -            subscription_id=subscription_id, watermark=watermark,
    -    )))
    + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + subscription_id=subscription_id, + watermark=watermark, + ) + ) + )
    @@ -151,9 +166,9 @@

    Methods

    Expand source code
    def get_payload(self, subscription_id, watermark):
    -    payload = create_element(f'm:{self.SERVICE_NAME}')
    -    add_xml_child(payload, 'm:SubscriptionId', subscription_id)
    -    add_xml_child(payload, 'm:Watermark', watermark)
    +    payload = create_element(f"m:{self.SERVICE_NAME}")
    +    add_xml_child(payload, "m:SubscriptionId", subscription_id)
    +    add_xml_child(payload, "m:Watermark", watermark)
         return payload
    diff --git a/docs/exchangelib/services/get_folder.html b/docs/exchangelib/services/get_folder.html index 39013eb3..2f47a12f 100644 --- a/docs/exchangelib/services/get_folder.html +++ b/docs/exchangelib/services/get_folder.html @@ -26,18 +26,20 @@

    Module exchangelib.services.get_folder

    Expand source code -
    from .common import EWSAccountService, parse_folder_elem, folder_ids_element, shape_element
    -from ..errors import ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorInvalidOperation
    -from ..util import create_element, MNS
    +
    from ..errors import ErrorFolderNotFound, ErrorInvalidOperation, ErrorNoPublicFolderReplicaAvailable
    +from ..util import MNS, create_element
    +from .common import EWSAccountService, folder_ids_element, parse_folder_elem, shape_element
     
     
     class GetFolder(EWSAccountService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getfolder-operation"""
     
    -    SERVICE_NAME = 'GetFolder'
    -    element_container_name = f'{{{MNS}}}Folders'
    +    SERVICE_NAME = "GetFolder"
    +    element_container_name = f"{{{MNS}}}Folders"
         ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (
    -        ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorInvalidOperation,
    +        ErrorFolderNotFound,
    +        ErrorNoPublicFolderReplicaAvailable,
    +        ErrorInvalidOperation,
         )
     
         def __init__(self, *args, **kwargs):
    @@ -56,12 +58,14 @@ 

    Module exchangelib.services.get_folder

    # We can't easily find the correct folder class from the returned XML. Instead, return objects with the same # class as the folder instance it was requested with. self.folders = list(folders) # Convert to a list, in case 'folders' is a generator. We're iterating twice. - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, - items=self.folders, - additional_fields=additional_fields, - shape=shape, - )) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=self.folders, + additional_fields=additional_fields, + shape=shape, + ) + ) def _elems_to_objs(self, elems): for folder, elem in zip(self.folders, elems): @@ -71,10 +75,12 @@

    Module exchangelib.services.get_folder

    yield parse_folder_elem(elem=elem, folder=folder, account=self.account) def get_payload(self, folders, additional_fields, shape): - payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(shape_element( - tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version - )) + payload = create_element(f"m:{self.SERVICE_NAME}") + payload.append( + shape_element( + tag="m:FolderShape", shape=shape, additional_fields=additional_fields, version=self.account.version + ) + ) payload.append(folder_ids_element(folders=folders, version=self.account.version)) return payload
    @@ -101,10 +107,12 @@

    Classes

    class GetFolder(EWSAccountService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getfolder-operation"""
     
    -    SERVICE_NAME = 'GetFolder'
    -    element_container_name = f'{{{MNS}}}Folders'
    +    SERVICE_NAME = "GetFolder"
    +    element_container_name = f"{{{MNS}}}Folders"
         ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (
    -        ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorInvalidOperation,
    +        ErrorFolderNotFound,
    +        ErrorNoPublicFolderReplicaAvailable,
    +        ErrorInvalidOperation,
         )
     
         def __init__(self, *args, **kwargs):
    @@ -123,12 +131,14 @@ 

    Classes

    # We can't easily find the correct folder class from the returned XML. Instead, return objects with the same # class as the folder instance it was requested with. self.folders = list(folders) # Convert to a list, in case 'folders' is a generator. We're iterating twice. - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, - items=self.folders, - additional_fields=additional_fields, - shape=shape, - )) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=self.folders, + additional_fields=additional_fields, + shape=shape, + ) + ) def _elems_to_objs(self, elems): for folder, elem in zip(self.folders, elems): @@ -138,10 +148,12 @@

    Classes

    yield parse_folder_elem(elem=elem, folder=folder, account=self.account) def get_payload(self, folders, additional_fields, shape): - payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(shape_element( - tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version - )) + payload = create_element(f"m:{self.SERVICE_NAME}") + payload.append( + shape_element( + tag="m:FolderShape", shape=shape, additional_fields=additional_fields, version=self.account.version + ) + ) payload.append(folder_ids_element(folders=folders, version=self.account.version)) return payload
    @@ -192,12 +204,14 @@

    Methods

    # We can't easily find the correct folder class from the returned XML. Instead, return objects with the same # class as the folder instance it was requested with. self.folders = list(folders) # Convert to a list, in case 'folders' is a generator. We're iterating twice. - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, - items=self.folders, - additional_fields=additional_fields, - shape=shape, - ))
    + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=self.folders, + additional_fields=additional_fields, + shape=shape, + ) + )
    @@ -210,10 +224,12 @@

    Methods

    Expand source code
    def get_payload(self, folders, additional_fields, shape):
    -    payload = create_element(f'm:{self.SERVICE_NAME}')
    -    payload.append(shape_element(
    -        tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version
    -    ))
    +    payload = create_element(f"m:{self.SERVICE_NAME}")
    +    payload.append(
    +        shape_element(
    +            tag="m:FolderShape", shape=shape, additional_fields=additional_fields, version=self.account.version
    +        )
    +    )
         payload.append(folder_ids_element(folders=folders, version=self.account.version))
         return payload
    diff --git a/docs/exchangelib/services/get_item.html b/docs/exchangelib/services/get_item.html index 7f96dc3a..92d00151 100644 --- a/docs/exchangelib/services/get_item.html +++ b/docs/exchangelib/services/get_item.html @@ -26,16 +26,16 @@

    Module exchangelib.services.get_item

    Expand source code -
    from .common import EWSAccountService, item_ids_element, shape_element
    -from ..folders.base import BaseFolder
    -from ..util import create_element, MNS
    +
    from ..folders.base import BaseFolder
    +from ..util import MNS, create_element
    +from .common import EWSAccountService, item_ids_element, shape_element
     
     
     class GetItem(EWSAccountService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getitem-operation"""
     
    -    SERVICE_NAME = 'GetItem'
    -    element_container_name = f'{{{MNS}}}Items'
    +    SERVICE_NAME = "GetItem"
    +    element_container_name = f"{{{MNS}}}Items"
     
         def call(self, items, additional_fields, shape):
             """Return all items in an account that correspond to a list of ID's, in stable order.
    @@ -46,18 +46,25 @@ 

    Module exchangelib.services.get_item

    :return: XML elements for the items, in stable order """ - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, items=items, additional_fields=additional_fields, shape=shape, - )) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=items, + additional_fields=additional_fields, + shape=shape, + ) + ) def _elem_to_obj(self, elem): return BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) def get_payload(self, items, additional_fields, shape): - payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(shape_element( - tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version - )) + payload = create_element(f"m:{self.SERVICE_NAME}") + payload.append( + shape_element( + tag="m:ItemShape", shape=shape, additional_fields=additional_fields, version=self.account.version + ) + ) payload.append(item_ids_element(items=items, version=self.account.version)) return payload
    @@ -84,8 +91,8 @@

    Classes

    class GetItem(EWSAccountService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getitem-operation"""
     
    -    SERVICE_NAME = 'GetItem'
    -    element_container_name = f'{{{MNS}}}Items'
    +    SERVICE_NAME = "GetItem"
    +    element_container_name = f"{{{MNS}}}Items"
     
         def call(self, items, additional_fields, shape):
             """Return all items in an account that correspond to a list of ID's, in stable order.
    @@ -96,18 +103,25 @@ 

    Classes

    :return: XML elements for the items, in stable order """ - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, items=items, additional_fields=additional_fields, shape=shape, - )) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=items, + additional_fields=additional_fields, + shape=shape, + ) + ) def _elem_to_obj(self, elem): return BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) def get_payload(self, items, additional_fields, shape): - payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(shape_element( - tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version - )) + payload = create_element(f"m:{self.SERVICE_NAME}") + payload.append( + shape_element( + tag="m:ItemShape", shape=shape, additional_fields=additional_fields, version=self.account.version + ) + ) payload.append(item_ids_element(items=items, version=self.account.version)) return payload
    @@ -151,9 +165,14 @@

    Methods

    :return: XML elements for the items, in stable order """ - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, items=items, additional_fields=additional_fields, shape=shape, - ))
    + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=items, + additional_fields=additional_fields, + shape=shape, + ) + )
    @@ -166,10 +185,12 @@

    Methods

    Expand source code
    def get_payload(self, items, additional_fields, shape):
    -    payload = create_element(f'm:{self.SERVICE_NAME}')
    -    payload.append(shape_element(
    -        tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version
    -    ))
    +    payload = create_element(f"m:{self.SERVICE_NAME}")
    +    payload.append(
    +        shape_element(
    +            tag="m:ItemShape", shape=shape, additional_fields=additional_fields, version=self.account.version
    +        )
    +    )
         payload.append(item_ids_element(items=items, version=self.account.version))
         return payload
    diff --git a/docs/exchangelib/services/get_mail_tips.html b/docs/exchangelib/services/get_mail_tips.html index e81c5e98..4254efe0 100644 --- a/docs/exchangelib/services/get_mail_tips.html +++ b/docs/exchangelib/services/get_mail_tips.html @@ -26,32 +26,34 @@

    Module exchangelib.services.get_mail_tips

    Expand source code -
    from .common import EWSService
    -from ..properties import MailTips
    -from ..util import create_element, set_xml_value, MNS
    +
    from ..properties import MailTips
    +from ..util import MNS, create_element, set_xml_value
    +from .common import EWSService
     
     
     class GetMailTips(EWSService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getmailtips-operation"""
     
    -    SERVICE_NAME = 'GetMailTips'
    +    SERVICE_NAME = "GetMailTips"
     
         def call(self, sending_as, recipients, mail_tips_requested):
    -        return self._elems_to_objs(self._chunked_get_elements(
    -            self.get_payload,
    -            items=recipients,
    -            sending_as=sending_as,
    -            mail_tips_requested=mail_tips_requested,
    -        ))
    +        return self._elems_to_objs(
    +            self._chunked_get_elements(
    +                self.get_payload,
    +                items=recipients,
    +                sending_as=sending_as,
    +                mail_tips_requested=mail_tips_requested,
    +            )
    +        )
     
         def _elem_to_obj(self, elem):
             return MailTips.from_xml(elem=elem, account=None)
     
    -    def get_payload(self, recipients, sending_as,  mail_tips_requested):
    -        payload = create_element(f'm:{self.SERVICE_NAME}')
    +    def get_payload(self, recipients, sending_as, mail_tips_requested):
    +        payload = create_element(f"m:{self.SERVICE_NAME}")
             set_xml_value(payload, sending_as, version=self.protocol.version)
     
    -        recipients_elem = create_element('m:Recipients')
    +        recipients_elem = create_element("m:Recipients")
             for recipient in recipients:
                 set_xml_value(recipients_elem, recipient, version=self.protocol.version)
             payload.append(recipients_elem)
    @@ -66,7 +68,7 @@ 

    Module exchangelib.services.get_mail_tips

    @classmethod def _response_message_tag(cls): - return f'{{{MNS}}}MailTipsResponseMessageType'
    + return f"{{{MNS}}}MailTipsResponseMessageType"
    @@ -91,24 +93,26 @@

    Classes

    class GetMailTips(EWSService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getmailtips-operation"""
     
    -    SERVICE_NAME = 'GetMailTips'
    +    SERVICE_NAME = "GetMailTips"
     
         def call(self, sending_as, recipients, mail_tips_requested):
    -        return self._elems_to_objs(self._chunked_get_elements(
    -            self.get_payload,
    -            items=recipients,
    -            sending_as=sending_as,
    -            mail_tips_requested=mail_tips_requested,
    -        ))
    +        return self._elems_to_objs(
    +            self._chunked_get_elements(
    +                self.get_payload,
    +                items=recipients,
    +                sending_as=sending_as,
    +                mail_tips_requested=mail_tips_requested,
    +            )
    +        )
     
         def _elem_to_obj(self, elem):
             return MailTips.from_xml(elem=elem, account=None)
     
    -    def get_payload(self, recipients, sending_as,  mail_tips_requested):
    -        payload = create_element(f'm:{self.SERVICE_NAME}')
    +    def get_payload(self, recipients, sending_as, mail_tips_requested):
    +        payload = create_element(f"m:{self.SERVICE_NAME}")
             set_xml_value(payload, sending_as, version=self.protocol.version)
     
    -        recipients_elem = create_element('m:Recipients')
    +        recipients_elem = create_element("m:Recipients")
             for recipient in recipients:
                 set_xml_value(recipients_elem, recipient, version=self.protocol.version)
             payload.append(recipients_elem)
    @@ -123,7 +127,7 @@ 

    Classes

    @classmethod def _response_message_tag(cls): - return f'{{{MNS}}}MailTipsResponseMessageType'
    + return f"{{{MNS}}}MailTipsResponseMessageType"

    Ancestors

      @@ -148,12 +152,14 @@

      Methods

      Expand source code
      def call(self, sending_as, recipients, mail_tips_requested):
      -    return self._elems_to_objs(self._chunked_get_elements(
      -        self.get_payload,
      -        items=recipients,
      -        sending_as=sending_as,
      -        mail_tips_requested=mail_tips_requested,
      -    ))
      + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=recipients, + sending_as=sending_as, + mail_tips_requested=mail_tips_requested, + ) + )
    @@ -165,11 +171,11 @@

    Methods

    Expand source code -
    def get_payload(self, recipients, sending_as,  mail_tips_requested):
    -    payload = create_element(f'm:{self.SERVICE_NAME}')
    +
    def get_payload(self, recipients, sending_as, mail_tips_requested):
    +    payload = create_element(f"m:{self.SERVICE_NAME}")
         set_xml_value(payload, sending_as, version=self.protocol.version)
     
    -    recipients_elem = create_element('m:Recipients')
    +    recipients_elem = create_element("m:Recipients")
         for recipient in recipients:
             set_xml_value(recipients_elem, recipient, version=self.protocol.version)
         payload.append(recipients_elem)
    diff --git a/docs/exchangelib/services/get_persona.html b/docs/exchangelib/services/get_persona.html
    index a77d3294..303897e6 100644
    --- a/docs/exchangelib/services/get_persona.html
    +++ b/docs/exchangelib/services/get_persona.html
    @@ -26,16 +26,16 @@ 

    Module exchangelib.services.get_persona

    Expand source code -
    from .common import EWSAccountService, to_item_id
    -from ..items import Persona
    +
    from ..items import Persona
     from ..properties import PersonaId
    -from ..util import create_element, set_xml_value, MNS
    +from ..util import MNS, create_element, set_xml_value
    +from .common import EWSAccountService, to_item_id
     
     
     class GetPersona(EWSAccountService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getpersona-operation"""
     
    -    SERVICE_NAME = 'GetPersona'
    +    SERVICE_NAME = "GetPersona"
     
         def call(self, personas):
             # GetPersona only accepts one persona ID per request. Crazy.
    @@ -47,18 +47,16 @@ 

    Module exchangelib.services.get_persona

    def get_payload(self, persona): return set_xml_value( - create_element(f'm:{self.SERVICE_NAME}'), - to_item_id(persona, PersonaId), - version=self.protocol.version + create_element(f"m:{self.SERVICE_NAME}"), to_item_id(persona, PersonaId), version=self.protocol.version ) @classmethod def _get_elements_in_container(cls, container): - return container.findall(f'{{{MNS}}}{Persona.ELEMENT_NAME}') + return container.findall(f"{{{MNS}}}{Persona.ELEMENT_NAME}") @classmethod def _response_tag(cls): - return f'{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage'
    + return f"{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage"
    @@ -83,7 +81,7 @@

    Classes

    class GetPersona(EWSAccountService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getpersona-operation"""
     
    -    SERVICE_NAME = 'GetPersona'
    +    SERVICE_NAME = "GetPersona"
     
         def call(self, personas):
             # GetPersona only accepts one persona ID per request. Crazy.
    @@ -95,18 +93,16 @@ 

    Classes

    def get_payload(self, persona): return set_xml_value( - create_element(f'm:{self.SERVICE_NAME}'), - to_item_id(persona, PersonaId), - version=self.protocol.version + create_element(f"m:{self.SERVICE_NAME}"), to_item_id(persona, PersonaId), version=self.protocol.version ) @classmethod def _get_elements_in_container(cls, container): - return container.findall(f'{{{MNS}}}{Persona.ELEMENT_NAME}') + return container.findall(f"{{{MNS}}}{Persona.ELEMENT_NAME}") @classmethod def _response_tag(cls): - return f'{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage'
    + return f"{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage"

    Ancestors

      @@ -148,9 +144,7 @@

      Methods

      def get_payload(self, persona):
           return set_xml_value(
      -        create_element(f'm:{self.SERVICE_NAME}'),
      -        to_item_id(persona, PersonaId),
      -        version=self.protocol.version
      +        create_element(f"m:{self.SERVICE_NAME}"), to_item_id(persona, PersonaId), version=self.protocol.version
           )
      diff --git a/docs/exchangelib/services/get_room_lists.html b/docs/exchangelib/services/get_room_lists.html index 98b58862..eb084e32 100644 --- a/docs/exchangelib/services/get_room_lists.html +++ b/docs/exchangelib/services/get_room_lists.html @@ -26,17 +26,17 @@

      Module exchangelib.services.get_room_lists

      Expand source code -
      from .common import EWSService
      -from ..properties import RoomList
      -from ..util import create_element, MNS
      +
      from ..properties import RoomList
      +from ..util import MNS, create_element
       from ..version import EXCHANGE_2010
      +from .common import EWSService
       
       
       class GetRoomLists(EWSService):
           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getroomlists-operation"""
       
      -    SERVICE_NAME = 'GetRoomLists'
      -    element_container_name = f'{{{MNS}}}RoomLists'
      +    SERVICE_NAME = "GetRoomLists"
      +    element_container_name = f"{{{MNS}}}RoomLists"
           supported_from = EXCHANGE_2010
       
           def call(self):
      @@ -46,7 +46,7 @@ 

      Module exchangelib.services.get_room_lists

      return RoomList.from_xml(elem=elem, account=None) def get_payload(self): - return create_element(f'm:{self.SERVICE_NAME}')
      + return create_element(f"m:{self.SERVICE_NAME}")
      @@ -71,8 +71,8 @@

      Classes

      class GetRoomLists(EWSService):
           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getroomlists-operation"""
       
      -    SERVICE_NAME = 'GetRoomLists'
      -    element_container_name = f'{{{MNS}}}RoomLists'
      +    SERVICE_NAME = "GetRoomLists"
      +    element_container_name = f"{{{MNS}}}RoomLists"
           supported_from = EXCHANGE_2010
       
           def call(self):
      @@ -82,7 +82,7 @@ 

      Classes

      return RoomList.from_xml(elem=elem, account=None) def get_payload(self): - return create_element(f'm:{self.SERVICE_NAME}')
      + return create_element(f"m:{self.SERVICE_NAME}")

    Ancestors

      @@ -128,7 +128,7 @@

      Methods

      Expand source code
      def get_payload(self):
      -    return create_element(f'm:{self.SERVICE_NAME}')
      + return create_element(f"m:{self.SERVICE_NAME}")
      diff --git a/docs/exchangelib/services/get_rooms.html b/docs/exchangelib/services/get_rooms.html index 45a79a64..ce91ce40 100644 --- a/docs/exchangelib/services/get_rooms.html +++ b/docs/exchangelib/services/get_rooms.html @@ -26,17 +26,17 @@

      Module exchangelib.services.get_rooms

      Expand source code -
      from .common import EWSService
      -from ..properties import Room
      -from ..util import create_element, set_xml_value, MNS
      +
      from ..properties import Room
      +from ..util import MNS, create_element, set_xml_value
       from ..version import EXCHANGE_2010
      +from .common import EWSService
       
       
       class GetRooms(EWSService):
           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getrooms-operation"""
       
      -    SERVICE_NAME = 'GetRooms'
      -    element_container_name = f'{{{MNS}}}Rooms'
      +    SERVICE_NAME = "GetRooms"
      +    element_container_name = f"{{{MNS}}}Rooms"
           supported_from = EXCHANGE_2010
       
           def call(self, room_list):
      @@ -46,7 +46,7 @@ 

      Module exchangelib.services.get_rooms

      return Room.from_xml(elem=elem, account=None) def get_payload(self, room_list): - return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), room_list, version=self.protocol.version)
      + return set_xml_value(create_element(f"m:{self.SERVICE_NAME}"), room_list, version=self.protocol.version)
      @@ -71,8 +71,8 @@

      Classes

      class GetRooms(EWSService):
           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getrooms-operation"""
       
      -    SERVICE_NAME = 'GetRooms'
      -    element_container_name = f'{{{MNS}}}Rooms'
      +    SERVICE_NAME = "GetRooms"
      +    element_container_name = f"{{{MNS}}}Rooms"
           supported_from = EXCHANGE_2010
       
           def call(self, room_list):
      @@ -82,7 +82,7 @@ 

      Classes

      return Room.from_xml(elem=elem, account=None) def get_payload(self, room_list): - return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), room_list, version=self.protocol.version)
      + return set_xml_value(create_element(f"m:{self.SERVICE_NAME}"), room_list, version=self.protocol.version)

      Ancestors

        @@ -128,7 +128,7 @@

        Methods

        Expand source code
        def get_payload(self, room_list):
        -    return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), room_list, version=self.protocol.version)
        + return set_xml_value(create_element(f"m:{self.SERVICE_NAME}"), room_list, version=self.protocol.version) diff --git a/docs/exchangelib/services/get_searchable_mailboxes.html b/docs/exchangelib/services/get_searchable_mailboxes.html index b69e0382..f9ba8faa 100644 --- a/docs/exchangelib/services/get_searchable_mailboxes.html +++ b/docs/exchangelib/services/get_searchable_mailboxes.html @@ -26,11 +26,11 @@

        Module exchangelib.services.get_searchable_mailboxes Expand source code -
        from .common import EWSService
        -from ..errors import MalformedResponseError
        -from ..properties import SearchableMailbox, FailedMailbox
        -from ..util import create_element, add_xml_child, MNS
        +
        from ..errors import MalformedResponseError
        +from ..properties import FailedMailbox, SearchableMailbox
        +from ..util import MNS, add_xml_child, create_element
         from ..version import EXCHANGE_2013
        +from .common import EWSService
         
         
         class GetSearchableMailboxes(EWSService):
        @@ -38,27 +38,31 @@ 

        Module exchangelib.services.get_searchable_mailboxesClasses

        https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getsearchablemailboxes-operation """ - SERVICE_NAME = 'GetSearchableMailboxes' - element_container_name = f'{{{MNS}}}SearchableMailboxes' - failed_mailboxes_container_name = f'{{{MNS}}}FailedMailboxes' + SERVICE_NAME = "GetSearchableMailboxes" + element_container_name = f"{{{MNS}}}SearchableMailboxes" + failed_mailboxes_container_name = f"{{{MNS}}}FailedMailboxes" supported_from = EXCHANGE_2013 cls_map = {cls.response_tag(): cls for cls in (SearchableMailbox, FailedMailbox)} def call(self, search_filter, expand_group_membership): - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - search_filter=search_filter, - expand_group_membership=expand_group_membership, - ))) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + search_filter=search_filter, + expand_group_membership=expand_group_membership, + ) + ) + ) def _elem_to_obj(self, elem): return self.cls_map[elem.tag].from_xml(elem=elem, account=None) def get_payload(self, search_filter, expand_group_membership): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") if search_filter: - add_xml_child(payload, 'm:SearchFilter', search_filter) + add_xml_child(payload, "m:SearchFilter", search_filter) if expand_group_membership is not None: - add_xml_child(payload, 'm:ExpandGroupMembership', 'true' if expand_group_membership else 'false') + add_xml_child(payload, "m:ExpandGroupMembership", "true" if expand_group_membership else "false") return payload def _get_elements_in_response(self, response): @@ -169,10 +177,14 @@

        Methods

        Expand source code
        def call(self, search_filter, expand_group_membership):
        -    return self._elems_to_objs(self._get_elements(payload=self.get_payload(
        -            search_filter=search_filter,
        -            expand_group_membership=expand_group_membership,
        -    )))
        + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + search_filter=search_filter, + expand_group_membership=expand_group_membership, + ) + ) + )
        @@ -185,11 +197,11 @@

        Methods

        Expand source code
        def get_payload(self, search_filter, expand_group_membership):
        -    payload = create_element(f'm:{self.SERVICE_NAME}')
        +    payload = create_element(f"m:{self.SERVICE_NAME}")
             if search_filter:
        -        add_xml_child(payload, 'm:SearchFilter', search_filter)
        +        add_xml_child(payload, "m:SearchFilter", search_filter)
             if expand_group_membership is not None:
        -        add_xml_child(payload, 'm:ExpandGroupMembership', 'true' if expand_group_membership else 'false')
        +        add_xml_child(payload, "m:ExpandGroupMembership", "true" if expand_group_membership else "false")
             return payload
        diff --git a/docs/exchangelib/services/get_server_time_zones.html b/docs/exchangelib/services/get_server_time_zones.html index 9143769e..0c81a838 100644 --- a/docs/exchangelib/services/get_server_time_zones.html +++ b/docs/exchangelib/services/get_server_time_zones.html @@ -26,10 +26,10 @@

        Module exchangelib.services.get_server_time_zones Expand source code -
        from .common import EWSService
        -from ..properties import TimeZoneDefinition
        -from ..util import create_element, set_xml_value, peek, MNS
        +
        from ..properties import TimeZoneDefinition
        +from ..util import MNS, create_element, peek, set_xml_value
         from ..version import EXCHANGE_2010
        +from .common import EWSService
         
         
         class GetServerTimeZones(EWSService):
        @@ -37,27 +37,28 @@ 

        Module exchangelib.services.get_server_time_zones https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getservertimezones-operation """ - SERVICE_NAME = 'GetServerTimeZones' - element_container_name = f'{{{MNS}}}TimeZoneDefinitions' + SERVICE_NAME = "GetServerTimeZones" + element_container_name = f"{{{MNS}}}TimeZoneDefinitions" supported_from = EXCHANGE_2010 def call(self, timezones=None, return_full_timezone_data=False): - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - timezones=timezones, - return_full_timezone_data=return_full_timezone_data - ))) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload(timezones=timezones, return_full_timezone_data=return_full_timezone_data) + ) + ) def get_payload(self, timezones, return_full_timezone_data): payload = create_element( - f'm:{self.SERVICE_NAME}', + f"m:{self.SERVICE_NAME}", attrs=dict(ReturnFullTimeZoneData=return_full_timezone_data), ) if timezones is not None: is_empty, timezones = peek(timezones) if not is_empty: - tz_ids = create_element('m:Ids') + tz_ids = create_element("m:Ids") for timezone in timezones: - tz_id = set_xml_value(create_element('t:Id'), timezone.ms_id) + tz_id = set_xml_value(create_element("t:Id"), timezone.ms_id) tz_ids.append(tz_id) payload.append(tz_ids) return payload @@ -91,27 +92,28 @@

        Classes

        https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getservertimezones-operation """ - SERVICE_NAME = 'GetServerTimeZones' - element_container_name = f'{{{MNS}}}TimeZoneDefinitions' + SERVICE_NAME = "GetServerTimeZones" + element_container_name = f"{{{MNS}}}TimeZoneDefinitions" supported_from = EXCHANGE_2010 def call(self, timezones=None, return_full_timezone_data=False): - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - timezones=timezones, - return_full_timezone_data=return_full_timezone_data - ))) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload(timezones=timezones, return_full_timezone_data=return_full_timezone_data) + ) + ) def get_payload(self, timezones, return_full_timezone_data): payload = create_element( - f'm:{self.SERVICE_NAME}', + f"m:{self.SERVICE_NAME}", attrs=dict(ReturnFullTimeZoneData=return_full_timezone_data), ) if timezones is not None: is_empty, timezones = peek(timezones) if not is_empty: - tz_ids = create_element('m:Ids') + tz_ids = create_element("m:Ids") for timezone in timezones: - tz_id = set_xml_value(create_element('t:Id'), timezone.ms_id) + tz_id = set_xml_value(create_element("t:Id"), timezone.ms_id) tz_ids.append(tz_id) payload.append(tz_ids) return payload @@ -150,10 +152,11 @@

        Methods

        Expand source code
        def call(self, timezones=None, return_full_timezone_data=False):
        -    return self._elems_to_objs(self._get_elements(payload=self.get_payload(
        -        timezones=timezones,
        -        return_full_timezone_data=return_full_timezone_data
        -    )))
        + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload(timezones=timezones, return_full_timezone_data=return_full_timezone_data) + ) + )
        @@ -167,15 +170,15 @@

        Methods

        def get_payload(self, timezones, return_full_timezone_data):
             payload = create_element(
        -        f'm:{self.SERVICE_NAME}',
        +        f"m:{self.SERVICE_NAME}",
                 attrs=dict(ReturnFullTimeZoneData=return_full_timezone_data),
             )
             if timezones is not None:
                 is_empty, timezones = peek(timezones)
                 if not is_empty:
        -            tz_ids = create_element('m:Ids')
        +            tz_ids = create_element("m:Ids")
                     for timezone in timezones:
        -                tz_id = set_xml_value(create_element('t:Id'), timezone.ms_id)
        +                tz_id = set_xml_value(create_element("t:Id"), timezone.ms_id)
                         tz_ids.append(tz_id)
                     payload.append(tz_ids)
             return payload
        diff --git a/docs/exchangelib/services/get_streaming_events.html b/docs/exchangelib/services/get_streaming_events.html index d3b3aad1..7306c649 100644 --- a/docs/exchangelib/services/get_streaming_events.html +++ b/docs/exchangelib/services/get_streaming_events.html @@ -28,13 +28,13 @@

        Module exchangelib.services.get_streaming_events<
        import logging
         
        -from .common import EWSAccountService, add_xml_child
         from ..errors import EWSError, InvalidTypeError
         from ..properties import Notification
        -from ..util import create_element, get_xml_attr, get_xml_attrs, MNS, DocumentYielder, DummyResponse
        +from ..util import MNS, DocumentYielder, DummyResponse, create_element, get_xml_attr, get_xml_attrs
        +from .common import EWSAccountService, add_xml_child
         
         log = logging.getLogger(__name__)
        -xml_log = logging.getLogger(f'{__name__}.xml')
        +xml_log = logging.getLogger(f"{__name__}.xml")
         
         
         class GetStreamingEvents(EWSAccountService):
        @@ -42,13 +42,13 @@ 

        Module exchangelib.services.get_streaming_events< https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getstreamingevents-operation """ - SERVICE_NAME = 'GetStreamingEvents' - element_container_name = f'{{{MNS}}}Notifications' + SERVICE_NAME = "GetStreamingEvents" + element_container_name = f"{{{MNS}}}Notifications" prefer_affinity = True # Connection status values - OK = 'OK' - CLOSED = 'Closed' + OK = "OK" + CLOSED = "Closed" def __init__(self, *args, **kwargs): # These values are set each time call() is consumed @@ -58,14 +58,19 @@

        Module exchangelib.services.get_streaming_events< def call(self, subscription_ids, connection_timeout): if not isinstance(connection_timeout, int): - raise InvalidTypeError('connection_timeout', connection_timeout, int) + raise InvalidTypeError("connection_timeout", connection_timeout, int) if connection_timeout < 1: raise ValueError(f"'connection_timeout' {connection_timeout} must be a positive integer") # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed self.timeout = connection_timeout * 60 + 60 - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - subscription_ids=subscription_ids, connection_timeout=connection_timeout, - ))) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + subscription_ids=subscription_ids, + connection_timeout=connection_timeout, + ) + ) + ) def _elem_to_obj(self, elem): return Notification.from_xml(elem=elem, account=None) @@ -81,7 +86,7 @@

        Module exchangelib.services.get_streaming_events< # XML response. r = body for i, doc in enumerate(DocumentYielder(r.iter_content()), start=1): - xml_log.debug('''Response XML (docs counter: %(i)s): %(xml_response)s''', dict(i=i, xml_response=doc)) + xml_log.debug("Response XML (docs counter: %(i)s): %(xml_response)s", dict(i=i, xml_response=doc)) response = DummyResponse(content=doc) try: _, body = super()._get_soap_parts(response=response, **parse_opts) @@ -96,10 +101,10 @@

        Module exchangelib.services.get_streaming_events< break def _get_element_container(self, message, name=None): - error_ids_elem = message.find(f'{{{MNS}}}ErrorSubscriptionIds') - error_ids = [] if error_ids_elem is None else get_xml_attrs(error_ids_elem, f'{{{MNS}}}SubscriptionId') - self.connection_status = get_xml_attr(message, f'{{{MNS}}}ConnectionStatus') # Either 'OK' or 'Closed' - log.debug('Connection status is: %s', self.connection_status) + error_ids_elem = message.find(f"{{{MNS}}}ErrorSubscriptionIds") + error_ids = [] if error_ids_elem is None else get_xml_attrs(error_ids_elem, f"{{{MNS}}}SubscriptionId") + self.connection_status = get_xml_attr(message, f"{{{MNS}}}ConnectionStatus") # Either 'OK' or 'Closed' + log.debug("Connection status is: %s", self.connection_status) # Upstream normally expects to find a 'name' tag but our response does not always have it. We still want to # call upstream, to have exceptions raised. Return an empty list if there is no 'name' tag and no errors. if message.find(name) is None: @@ -111,20 +116,20 @@

        Module exchangelib.services.get_streaming_events< # subscriptions seem to never be returned even though the XML spec allows it. This means there's no point in # trying to collect any notifications here and delivering a combination of errors and return values. if error_ids: - e.value += f' (subscription IDs: {error_ids})' + e.value += f" (subscription IDs: {error_ids})" raise e return [] if name is None else res def get_payload(self, subscription_ids, connection_timeout): - payload = create_element(f'm:{self.SERVICE_NAME}') - subscriptions_elem = create_element('m:SubscriptionIds') + payload = create_element(f"m:{self.SERVICE_NAME}") + subscriptions_elem = create_element("m:SubscriptionIds") for subscription_id in subscription_ids: - add_xml_child(subscriptions_elem, 't:SubscriptionId', subscription_id) + add_xml_child(subscriptions_elem, "t:SubscriptionId", subscription_id) if not len(subscriptions_elem): raise ValueError("'subscription_ids' must not be empty") payload.append(subscriptions_elem) - add_xml_child(payload, 'm:ConnectionTimeout', connection_timeout) + add_xml_child(payload, "m:ConnectionTimeout", connection_timeout) return payload

      @@ -153,13 +158,13 @@

      Classes

      https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getstreamingevents-operation """ - SERVICE_NAME = 'GetStreamingEvents' - element_container_name = f'{{{MNS}}}Notifications' + SERVICE_NAME = "GetStreamingEvents" + element_container_name = f"{{{MNS}}}Notifications" prefer_affinity = True # Connection status values - OK = 'OK' - CLOSED = 'Closed' + OK = "OK" + CLOSED = "Closed" def __init__(self, *args, **kwargs): # These values are set each time call() is consumed @@ -169,14 +174,19 @@

      Classes

      def call(self, subscription_ids, connection_timeout): if not isinstance(connection_timeout, int): - raise InvalidTypeError('connection_timeout', connection_timeout, int) + raise InvalidTypeError("connection_timeout", connection_timeout, int) if connection_timeout < 1: raise ValueError(f"'connection_timeout' {connection_timeout} must be a positive integer") # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed self.timeout = connection_timeout * 60 + 60 - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - subscription_ids=subscription_ids, connection_timeout=connection_timeout, - ))) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + subscription_ids=subscription_ids, + connection_timeout=connection_timeout, + ) + ) + ) def _elem_to_obj(self, elem): return Notification.from_xml(elem=elem, account=None) @@ -192,7 +202,7 @@

      Classes

      # XML response. r = body for i, doc in enumerate(DocumentYielder(r.iter_content()), start=1): - xml_log.debug('''Response XML (docs counter: %(i)s): %(xml_response)s''', dict(i=i, xml_response=doc)) + xml_log.debug("Response XML (docs counter: %(i)s): %(xml_response)s", dict(i=i, xml_response=doc)) response = DummyResponse(content=doc) try: _, body = super()._get_soap_parts(response=response, **parse_opts) @@ -207,10 +217,10 @@

      Classes

      break def _get_element_container(self, message, name=None): - error_ids_elem = message.find(f'{{{MNS}}}ErrorSubscriptionIds') - error_ids = [] if error_ids_elem is None else get_xml_attrs(error_ids_elem, f'{{{MNS}}}SubscriptionId') - self.connection_status = get_xml_attr(message, f'{{{MNS}}}ConnectionStatus') # Either 'OK' or 'Closed' - log.debug('Connection status is: %s', self.connection_status) + error_ids_elem = message.find(f"{{{MNS}}}ErrorSubscriptionIds") + error_ids = [] if error_ids_elem is None else get_xml_attrs(error_ids_elem, f"{{{MNS}}}SubscriptionId") + self.connection_status = get_xml_attr(message, f"{{{MNS}}}ConnectionStatus") # Either 'OK' or 'Closed' + log.debug("Connection status is: %s", self.connection_status) # Upstream normally expects to find a 'name' tag but our response does not always have it. We still want to # call upstream, to have exceptions raised. Return an empty list if there is no 'name' tag and no errors. if message.find(name) is None: @@ -222,20 +232,20 @@

      Classes

      # subscriptions seem to never be returned even though the XML spec allows it. This means there's no point in # trying to collect any notifications here and delivering a combination of errors and return values. if error_ids: - e.value += f' (subscription IDs: {error_ids})' + e.value += f" (subscription IDs: {error_ids})" raise e return [] if name is None else res def get_payload(self, subscription_ids, connection_timeout): - payload = create_element(f'm:{self.SERVICE_NAME}') - subscriptions_elem = create_element('m:SubscriptionIds') + payload = create_element(f"m:{self.SERVICE_NAME}") + subscriptions_elem = create_element("m:SubscriptionIds") for subscription_id in subscription_ids: - add_xml_child(subscriptions_elem, 't:SubscriptionId', subscription_id) + add_xml_child(subscriptions_elem, "t:SubscriptionId", subscription_id) if not len(subscriptions_elem): raise ValueError("'subscription_ids' must not be empty") payload.append(subscriptions_elem) - add_xml_child(payload, 'm:ConnectionTimeout', connection_timeout) + add_xml_child(payload, "m:ConnectionTimeout", connection_timeout) return payload

      Ancestors

      @@ -279,14 +289,19 @@

      Methods

      def call(self, subscription_ids, connection_timeout):
           if not isinstance(connection_timeout, int):
      -        raise InvalidTypeError('connection_timeout', connection_timeout, int)
      +        raise InvalidTypeError("connection_timeout", connection_timeout, int)
           if connection_timeout < 1:
               raise ValueError(f"'connection_timeout' {connection_timeout} must be a positive integer")
           # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed
           self.timeout = connection_timeout * 60 + 60
      -    return self._elems_to_objs(self._get_elements(payload=self.get_payload(
      -            subscription_ids=subscription_ids, connection_timeout=connection_timeout,
      -    )))
      + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + subscription_ids=subscription_ids, + connection_timeout=connection_timeout, + ) + ) + )
      @@ -299,15 +314,15 @@

      Methods

      Expand source code
      def get_payload(self, subscription_ids, connection_timeout):
      -    payload = create_element(f'm:{self.SERVICE_NAME}')
      -    subscriptions_elem = create_element('m:SubscriptionIds')
      +    payload = create_element(f"m:{self.SERVICE_NAME}")
      +    subscriptions_elem = create_element("m:SubscriptionIds")
           for subscription_id in subscription_ids:
      -        add_xml_child(subscriptions_elem, 't:SubscriptionId', subscription_id)
      +        add_xml_child(subscriptions_elem, "t:SubscriptionId", subscription_id)
           if not len(subscriptions_elem):
               raise ValueError("'subscription_ids' must not be empty")
       
           payload.append(subscriptions_elem)
      -    add_xml_child(payload, 'm:ConnectionTimeout', connection_timeout)
      +    add_xml_child(payload, "m:ConnectionTimeout", connection_timeout)
           return payload
      diff --git a/docs/exchangelib/services/get_user_availability.html b/docs/exchangelib/services/get_user_availability.html index 1e50527e..ba93a061 100644 --- a/docs/exchangelib/services/get_user_availability.html +++ b/docs/exchangelib/services/get_user_availability.html @@ -26,9 +26,9 @@

      Module exchangelib.services.get_user_availability Expand source code -
      from .common import EWSService
      -from ..properties import FreeBusyView
      -from ..util import create_element, set_xml_value, MNS
      +
      from ..properties import FreeBusyView
      +from ..util import MNS, create_element, set_xml_value
      +from .common import EWSService
       
       
       class GetUserAvailability(EWSService):
      @@ -37,45 +37,47 @@ 

      Module exchangelib.services.get_user_availability https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getuseravailability-operation """ - SERVICE_NAME = 'GetUserAvailability' + SERVICE_NAME = "GetUserAvailability" def call(self, timezone, mailbox_data, free_busy_view_options): # TODO: Also supports SuggestionsViewOptions, see # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/suggestionsviewoptions - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - timezone=timezone, - mailbox_data=mailbox_data, - free_busy_view_options=free_busy_view_options - ))) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + timezone=timezone, mailbox_data=mailbox_data, free_busy_view_options=free_busy_view_options + ) + ) + ) def _elem_to_obj(self, elem): return FreeBusyView.from_xml(elem=elem, account=None) def get_payload(self, timezone, mailbox_data, free_busy_view_options): - payload = create_element(f'm:{self.SERVICE_NAME}Request') + payload = create_element(f"m:{self.SERVICE_NAME}Request") set_xml_value(payload, timezone, version=self.protocol.version) - mailbox_data_array = create_element('m:MailboxDataArray') + mailbox_data_array = create_element("m:MailboxDataArray") set_xml_value(mailbox_data_array, mailbox_data, version=self.protocol.version) payload.append(mailbox_data_array) return set_xml_value(payload, free_busy_view_options, version=self.protocol.version) @staticmethod def _response_messages_tag(): - return f'{{{MNS}}}FreeBusyResponseArray' + return f"{{{MNS}}}FreeBusyResponseArray" @classmethod def _response_message_tag(cls): - return f'{{{MNS}}}FreeBusyResponse' + return f"{{{MNS}}}FreeBusyResponse" def _get_elements_in_response(self, response): for msg in response: # Just check the response code and raise errors - self._get_element_container(message=msg.find(f'{{{MNS}}}ResponseMessage')) + self._get_element_container(message=msg.find(f"{{{MNS}}}ResponseMessage")) yield from self._get_elements_in_container(container=msg) @classmethod def _get_elements_in_container(cls, container): - return [container.find(f'{{{MNS}}}FreeBusyView')]

      + return [container.find(f"{{{MNS}}}FreeBusyView")]
      @@ -105,45 +107,47 @@

      Classes

      https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getuseravailability-operation """ - SERVICE_NAME = 'GetUserAvailability' + SERVICE_NAME = "GetUserAvailability" def call(self, timezone, mailbox_data, free_busy_view_options): # TODO: Also supports SuggestionsViewOptions, see # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/suggestionsviewoptions - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - timezone=timezone, - mailbox_data=mailbox_data, - free_busy_view_options=free_busy_view_options - ))) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + timezone=timezone, mailbox_data=mailbox_data, free_busy_view_options=free_busy_view_options + ) + ) + ) def _elem_to_obj(self, elem): return FreeBusyView.from_xml(elem=elem, account=None) def get_payload(self, timezone, mailbox_data, free_busy_view_options): - payload = create_element(f'm:{self.SERVICE_NAME}Request') + payload = create_element(f"m:{self.SERVICE_NAME}Request") set_xml_value(payload, timezone, version=self.protocol.version) - mailbox_data_array = create_element('m:MailboxDataArray') + mailbox_data_array = create_element("m:MailboxDataArray") set_xml_value(mailbox_data_array, mailbox_data, version=self.protocol.version) payload.append(mailbox_data_array) return set_xml_value(payload, free_busy_view_options, version=self.protocol.version) @staticmethod def _response_messages_tag(): - return f'{{{MNS}}}FreeBusyResponseArray' + return f"{{{MNS}}}FreeBusyResponseArray" @classmethod def _response_message_tag(cls): - return f'{{{MNS}}}FreeBusyResponse' + return f"{{{MNS}}}FreeBusyResponse" def _get_elements_in_response(self, response): for msg in response: # Just check the response code and raise errors - self._get_element_container(message=msg.find(f'{{{MNS}}}ResponseMessage')) + self._get_element_container(message=msg.find(f"{{{MNS}}}ResponseMessage")) yield from self._get_elements_in_container(container=msg) @classmethod def _get_elements_in_container(cls, container): - return [container.find(f'{{{MNS}}}FreeBusyView')]
      + return [container.find(f"{{{MNS}}}FreeBusyView")]

      Ancestors

        @@ -170,11 +174,13 @@

        Methods

        def call(self, timezone, mailbox_data, free_busy_view_options):
             # TODO: Also supports SuggestionsViewOptions, see
             #  https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/suggestionsviewoptions
        -    return self._elems_to_objs(self._get_elements(payload=self.get_payload(
        -        timezone=timezone,
        -        mailbox_data=mailbox_data,
        -        free_busy_view_options=free_busy_view_options
        -    )))
        + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + timezone=timezone, mailbox_data=mailbox_data, free_busy_view_options=free_busy_view_options + ) + ) + )
        @@ -187,9 +193,9 @@

        Methods

        Expand source code
        def get_payload(self, timezone, mailbox_data, free_busy_view_options):
        -    payload = create_element(f'm:{self.SERVICE_NAME}Request')
        +    payload = create_element(f"m:{self.SERVICE_NAME}Request")
             set_xml_value(payload, timezone, version=self.protocol.version)
        -    mailbox_data_array = create_element('m:MailboxDataArray')
        +    mailbox_data_array = create_element("m:MailboxDataArray")
             set_xml_value(mailbox_data_array, mailbox_data, version=self.protocol.version)
             payload.append(mailbox_data_array)
             return set_xml_value(payload, free_busy_view_options, version=self.protocol.version)
        diff --git a/docs/exchangelib/services/get_user_configuration.html b/docs/exchangelib/services/get_user_configuration.html index ce9e2a08..746b6d1e 100644 --- a/docs/exchangelib/services/get_user_configuration.html +++ b/docs/exchangelib/services/get_user_configuration.html @@ -26,16 +26,16 @@

        Module exchangelib.services.get_user_configuration Expand source code -
        from .common import EWSAccountService
        -from ..errors import InvalidEnumValue
        +
        from ..errors import InvalidEnumValue
         from ..properties import UserConfiguration
         from ..util import create_element, set_xml_value
        +from .common import EWSAccountService
         
        -ID = 'Id'
        -DICTIONARY = 'Dictionary'
        -XML_DATA = 'XmlData'
        -BINARY_DATA = 'BinaryData'
        -ALL = 'All'
        +ID = "Id"
        +DICTIONARY = "Dictionary"
        +XML_DATA = "XmlData"
        +BINARY_DATA = "BinaryData"
        +ALL = "All"
         PROPERTIES_CHOICES = (ID, DICTIONARY, XML_DATA, BINARY_DATA, ALL)
         
         
        @@ -44,14 +44,16 @@ 

        Module exchangelib.services.get_user_configurationModule exchangelib.services.get_user_configuration

        @@ -94,14 +96,16 @@

        Classes

        https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getuserconfiguration-operation """ - SERVICE_NAME = 'GetUserConfiguration' + SERVICE_NAME = "GetUserConfiguration" def call(self, user_configuration_name, properties): if properties not in PROPERTIES_CHOICES: - raise InvalidEnumValue('properties', properties, PROPERTIES_CHOICES) - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - user_configuration_name=user_configuration_name, properties=properties - ))) + raise InvalidEnumValue("properties", properties, PROPERTIES_CHOICES) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload(user_configuration_name=user_configuration_name, properties=properties) + ) + ) def _elem_to_obj(self, elem): return UserConfiguration.from_xml(elem=elem, account=self.account) @@ -111,10 +115,10 @@

        Classes

        return container.findall(UserConfiguration.response_tag()) def get_payload(self, user_configuration_name, properties): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") set_xml_value(payload, user_configuration_name, version=self.account.version) payload.append( - set_xml_value(create_element('m:UserConfigurationProperties'), properties, version=self.account.version) + set_xml_value(create_element("m:UserConfigurationProperties"), properties, version=self.account.version) ) return payload
        @@ -143,10 +147,12 @@

        Methods

        def call(self, user_configuration_name, properties):
             if properties not in PROPERTIES_CHOICES:
        -        raise InvalidEnumValue('properties', properties, PROPERTIES_CHOICES)
        -    return self._elems_to_objs(self._get_elements(payload=self.get_payload(
        -            user_configuration_name=user_configuration_name, properties=properties
        -    )))
        + raise InvalidEnumValue("properties", properties, PROPERTIES_CHOICES) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload(user_configuration_name=user_configuration_name, properties=properties) + ) + )
        @@ -159,10 +165,10 @@

        Methods

        Expand source code
        def get_payload(self, user_configuration_name, properties):
        -    payload = create_element(f'm:{self.SERVICE_NAME}')
        +    payload = create_element(f"m:{self.SERVICE_NAME}")
             set_xml_value(payload, user_configuration_name, version=self.account.version)
             payload.append(
        -        set_xml_value(create_element('m:UserConfigurationProperties'), properties, version=self.account.version)
        +        set_xml_value(create_element("m:UserConfigurationProperties"), properties, version=self.account.version)
             )
             return payload
        diff --git a/docs/exchangelib/services/get_user_oof_settings.html b/docs/exchangelib/services/get_user_oof_settings.html index 84431936..84aa1ad4 100644 --- a/docs/exchangelib/services/get_user_oof_settings.html +++ b/docs/exchangelib/services/get_user_oof_settings.html @@ -26,10 +26,10 @@

        Module exchangelib.services.get_user_oof_settings Expand source code -
        from .common import EWSAccountService
        -from ..properties import AvailabilityMailbox
        +
        from ..properties import AvailabilityMailbox
         from ..settings import OofSettings
        -from ..util import create_element, set_xml_value, MNS, TNS
        +from ..util import MNS, TNS, create_element, set_xml_value
        +from .common import EWSAccountService
         
         
         class GetUserOofSettings(EWSAccountService):
        @@ -37,8 +37,8 @@ 

        Module exchangelib.services.get_user_oof_settings MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getuseroofsettings-operation """ - SERVICE_NAME = 'GetUserOofSettings' - element_container_name = f'{{{TNS}}}OofSettings' + SERVICE_NAME = "GetUserOofSettings" + element_container_name = f"{{{TNS}}}OofSettings" def call(self, mailbox): return self._elems_to_objs(self._get_elements(payload=self.get_payload(mailbox=mailbox))) @@ -48,9 +48,9 @@

        Module exchangelib.services.get_user_oof_settings def get_payload(self, mailbox): return set_xml_value( - create_element(f'm:{self.SERVICE_NAME}Request'), + create_element(f"m:{self.SERVICE_NAME}Request"), AvailabilityMailbox.from_mailbox(mailbox), - version=self.account.version + version=self.account.version, ) @classmethod @@ -65,7 +65,7 @@

        Module exchangelib.services.get_user_oof_settings @classmethod def _response_message_tag(cls): - return f'{{{MNS}}}ResponseMessage'

        + return f"{{{MNS}}}ResponseMessage"

      @@ -93,8 +93,8 @@

      Classes

      MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getuseroofsettings-operation """ - SERVICE_NAME = 'GetUserOofSettings' - element_container_name = f'{{{TNS}}}OofSettings' + SERVICE_NAME = "GetUserOofSettings" + element_container_name = f"{{{TNS}}}OofSettings" def call(self, mailbox): return self._elems_to_objs(self._get_elements(payload=self.get_payload(mailbox=mailbox))) @@ -104,9 +104,9 @@

      Classes

      def get_payload(self, mailbox): return set_xml_value( - create_element(f'm:{self.SERVICE_NAME}Request'), + create_element(f"m:{self.SERVICE_NAME}Request"), AvailabilityMailbox.from_mailbox(mailbox), - version=self.account.version + version=self.account.version, ) @classmethod @@ -121,7 +121,7 @@

      Classes

      @classmethod def _response_message_tag(cls): - return f'{{{MNS}}}ResponseMessage'
      + return f"{{{MNS}}}ResponseMessage"

      Ancestors

        @@ -165,9 +165,9 @@

        Methods

        def get_payload(self, mailbox):
             return set_xml_value(
        -        create_element(f'm:{self.SERVICE_NAME}Request'),
        +        create_element(f"m:{self.SERVICE_NAME}Request"),
                 AvailabilityMailbox.from_mailbox(mailbox),
        -        version=self.account.version
        +        version=self.account.version,
             )
        diff --git a/docs/exchangelib/services/index.html b/docs/exchangelib/services/index.html index b9eae536..b10f1196 100644 --- a/docs/exchangelib/services/index.html +++ b/docs/exchangelib/services/index.html @@ -40,8 +40,8 @@

        Module exchangelib.services

        https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/ews-operations-in-exchange """ -from .common import CHUNK_SIZE, PAGE_SIZE from .archive_item import ArchiveItem +from .common import CHUNK_SIZE, PAGE_SIZE from .convert_id import ConvertId from .copy_item import CopyItem from .create_attachment import CreateAttachment @@ -80,7 +80,7 @@

        Module exchangelib.services

        from .send_item import SendItem from .send_notification import SendNotification from .set_user_oof_settings import SetUserOofSettings -from .subscribe import SubscribeToStreaming, SubscribeToPull, SubscribeToPush +from .subscribe import SubscribeToPull, SubscribeToPush, SubscribeToStreaming from .sync_folder_hierarchy import SyncFolderHierarchy from .sync_folder_items import SyncFolderItems from .unsubscribe import Unsubscribe @@ -90,57 +90,57 @@

        Module exchangelib.services

        from .upload_items import UploadItems __all__ = [ - 'CHUNK_SIZE', - 'PAGE_SIZE', - 'ArchiveItem', - 'ConvertId', - 'CopyItem', - 'CreateAttachment', - 'CreateFolder', - 'CreateItem', - 'CreateUserConfiguration', - 'DeleteAttachment', - 'DeleteFolder', - 'DeleteUserConfiguration', - 'DeleteItem', - 'EmptyFolder', - 'ExpandDL', - 'ExportItems', - 'FindFolder', - 'FindItem', - 'FindPeople', - 'GetAttachment', - 'GetDelegate', - 'GetEvents', - 'GetFolder', - 'GetItem', - 'GetMailTips', - 'GetPersona', - 'GetRoomLists', - 'GetRooms', - 'GetSearchableMailboxes', - 'GetServerTimeZones', - 'GetStreamingEvents', - 'GetUserAvailability', - 'GetUserConfiguration', - 'GetUserOofSettings', - 'MarkAsJunk', - 'MoveFolder', - 'MoveItem', - 'ResolveNames', - 'SendItem', - 'SendNotification', - 'SetUserOofSettings', - 'SubscribeToPull', - 'SubscribeToPush', - 'SubscribeToStreaming', - 'SyncFolderHierarchy', - 'SyncFolderItems', - 'Unsubscribe', - 'UpdateFolder', - 'UpdateItem', - 'UpdateUserConfiguration', - 'UploadItems', + "CHUNK_SIZE", + "PAGE_SIZE", + "ArchiveItem", + "ConvertId", + "CopyItem", + "CreateAttachment", + "CreateFolder", + "CreateItem", + "CreateUserConfiguration", + "DeleteAttachment", + "DeleteFolder", + "DeleteUserConfiguration", + "DeleteItem", + "EmptyFolder", + "ExpandDL", + "ExportItems", + "FindFolder", + "FindItem", + "FindPeople", + "GetAttachment", + "GetDelegate", + "GetEvents", + "GetFolder", + "GetItem", + "GetMailTips", + "GetPersona", + "GetRoomLists", + "GetRooms", + "GetSearchableMailboxes", + "GetServerTimeZones", + "GetStreamingEvents", + "GetUserAvailability", + "GetUserConfiguration", + "GetUserOofSettings", + "MarkAsJunk", + "MoveFolder", + "MoveItem", + "ResolveNames", + "SendItem", + "SendNotification", + "SetUserOofSettings", + "SubscribeToPull", + "SubscribeToPush", + "SubscribeToStreaming", + "SyncFolderHierarchy", + "SyncFolderItems", + "Unsubscribe", + "UpdateFolder", + "UpdateItem", + "UpdateUserConfiguration", + "UploadItems", ]
      @@ -362,8 +362,8 @@

      Classes

      class ArchiveItem(EWSAccountService):
           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/archiveitem-operation"""
       
      -    SERVICE_NAME = 'ArchiveItem'
      -    element_container_name = f'{{{MNS}}}Items'
      +    SERVICE_NAME = "ArchiveItem"
      +    element_container_name = f"{{{MNS}}}Items"
           supported_from = EXCHANGE_2013
       
           def call(self, items, to_folder):
      @@ -380,9 +380,9 @@ 

      Classes

      return Item.id_from_xml(elem) def get_payload(self, items, to_folder): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") payload.append( - folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ArchiveSourceFolderId') + folder_ids_element(folders=[to_folder], version=self.account.version, tag="m:ArchiveSourceFolderId") ) payload.append(item_ids_element(items=items, version=self.account.version)) return payload
      @@ -442,9 +442,9 @@

      Methods

      Expand source code
      def get_payload(self, items, to_folder):
      -    payload = create_element(f'm:{self.SERVICE_NAME}')
      +    payload = create_element(f"m:{self.SERVICE_NAME}")
           payload.append(
      -        folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ArchiveSourceFolderId')
      +        folder_ids_element(folders=[to_folder], version=self.account.version, tag="m:ArchiveSourceFolderId")
           )
           payload.append(item_ids_element(items=items, version=self.account.version))
           return payload
      @@ -482,15 +482,13 @@

      Inherited members

      MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/convertid-operation """ - SERVICE_NAME = 'ConvertId' + SERVICE_NAME = "ConvertId" supported_from = EXCHANGE_2007_SP1 - cls_map = {cls.response_tag(): cls for cls in ( - AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId - )} + cls_map = {cls.response_tag(): cls for cls in (AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId)} def call(self, items, destination_format): if destination_format not in ID_FORMATS: - raise InvalidEnumValue('destination_format', destination_format, ID_FORMATS) + raise InvalidEnumValue("destination_format", destination_format, ID_FORMATS) return self._elems_to_objs( self._chunked_get_elements(self.get_payload, items=items, destination_format=destination_format) ) @@ -500,11 +498,11 @@

      Inherited members

      def get_payload(self, items, destination_format): supported_item_classes = AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DestinationFormat=destination_format)) - item_ids = create_element('m:SourceIds') + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(DestinationFormat=destination_format)) + item_ids = create_element("m:SourceIds") for item in items: if not isinstance(item, supported_item_classes): - raise InvalidTypeError('item', item, supported_item_classes) + raise InvalidTypeError("item", item, supported_item_classes) set_xml_value(item_ids, item, version=self.protocol.version) payload.append(item_ids) return payload @@ -512,9 +510,11 @@

      Inherited members

      @classmethod def _get_elements_in_container(cls, container): # We may have other elements in here, e.g. 'ResponseCode'. Filter away those. - return container.findall(AlternateId.response_tag()) \ - + container.findall(AlternatePublicFolderId.response_tag()) \ - + container.findall(AlternatePublicFolderItemId.response_tag()) + return ( + container.findall(AlternateId.response_tag()) + + container.findall(AlternatePublicFolderId.response_tag()) + + container.findall(AlternatePublicFolderItemId.response_tag()) + )

      Ancestors

        @@ -548,7 +548,7 @@

        Methods

        def call(self, items, destination_format):
             if destination_format not in ID_FORMATS:
        -        raise InvalidEnumValue('destination_format', destination_format, ID_FORMATS)
        +        raise InvalidEnumValue("destination_format", destination_format, ID_FORMATS)
             return self._elems_to_objs(
                 self._chunked_get_elements(self.get_payload, items=items, destination_format=destination_format)
             )
        @@ -565,11 +565,11 @@

        Methods

        def get_payload(self, items, destination_format):
             supported_item_classes = AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId
        -    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DestinationFormat=destination_format))
        -    item_ids = create_element('m:SourceIds')
        +    payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(DestinationFormat=destination_format))
        +    item_ids = create_element("m:SourceIds")
             for item in items:
                 if not isinstance(item, supported_item_classes):
        -            raise InvalidTypeError('item', item, supported_item_classes)
        +            raise InvalidTypeError("item", item, supported_item_classes)
                 set_xml_value(item_ids, item, version=self.protocol.version)
             payload.append(item_ids)
             return payload
        @@ -601,7 +601,7 @@

        Inherited members

        class CopyItem(move_item.MoveItem):
             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/copyitem-operation"""
         
        -    SERVICE_NAME = 'CopyItem'
        + SERVICE_NAME = "CopyItem"

        Ancestors

          @@ -644,8 +644,8 @@

          Inherited members

          https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createattachment-operation """ - SERVICE_NAME = 'CreateAttachment' - element_container_name = f'{{{MNS}}}Attachments' + SERVICE_NAME = "CreateAttachment" + element_container_name = f"{{{MNS}}}Attachments" cls_map = {cls.response_tag(): cls for cls in (FileAttachment, ItemAttachment)} def call(self, parent_item, items): @@ -655,13 +655,13 @@

          Inherited members

          return self.cls_map[elem.tag].from_xml(elem=elem, account=self.account) def get_payload(self, items, parent_item): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") version = self.account.version if isinstance(parent_item, BaseItem): # to_item_id() would convert this to a normal ItemId, but the service wants a ParentItemId parent_item = ParentItemId(parent_item.id, parent_item.changekey) set_xml_value(payload, to_item_id(parent_item, ParentItemId), version=self.account.version) - attachments = create_element('m:Attachments') + attachments = create_element("m:Attachments") for item in items: set_xml_value(attachments, item, version=version) payload.append(attachments) @@ -712,13 +712,13 @@

          Methods

          Expand source code
          def get_payload(self, items, parent_item):
          -    payload = create_element(f'm:{self.SERVICE_NAME}')
          +    payload = create_element(f"m:{self.SERVICE_NAME}")
               version = self.account.version
               if isinstance(parent_item, BaseItem):
                   # to_item_id() would convert this to a normal ItemId, but the service wants a ParentItemId
                   parent_item = ParentItemId(parent_item.id, parent_item.changekey)
               set_xml_value(payload, to_item_id(parent_item, ParentItemId), version=self.account.version)
          -    attachments = create_element('m:Attachments')
          +    attachments = create_element("m:Attachments")
               for item in items:
                   set_xml_value(attachments, item, version=version)
               payload.append(attachments)
          @@ -751,11 +751,9 @@ 

          Inherited members

          class CreateFolder(EWSAccountService):
               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createfolder-operation"""
           
          -    SERVICE_NAME = 'CreateFolder'
          -    element_container_name = f'{{{MNS}}}Folders'
          -    ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (
          -        ErrorFolderExists,
          -    )
          +    SERVICE_NAME = "CreateFolder"
          +    element_container_name = f"{{{MNS}}}Folders"
          +    ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (ErrorFolderExists,)
           
               def __init__(self, *args, **kwargs):
                   super().__init__(*args, **kwargs)
          @@ -765,9 +763,13 @@ 

          Inherited members

          # We can't easily find the correct folder class from the returned XML. Instead, return objects with the same # class as the folder instance it was requested with. self.folders = list(folders) # Convert to a list, in case 'folders' is a generator. We're iterating twice. - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, items=self.folders, parent_folder=parent_folder, - )) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=self.folders, + parent_folder=parent_folder, + ) + ) def _elems_to_objs(self, elems): for folder, elem in zip(self.folders, elems): @@ -777,11 +779,11 @@

          Inherited members

          yield parse_folder_elem(elem=elem, folder=folder, account=self.account) def get_payload(self, folders, parent_folder): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") payload.append( - folder_ids_element(folders=[parent_folder], version=self.account.version, tag='m:ParentFolderId') + folder_ids_element(folders=[parent_folder], version=self.account.version, tag="m:ParentFolderId") ) - payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:Folders')) + payload.append(folder_ids_element(folders=folders, version=self.account.version, tag="m:Folders")) return payload

          Ancestors

          @@ -819,9 +821,13 @@

          Methods

          # We can't easily find the correct folder class from the returned XML. Instead, return objects with the same # class as the folder instance it was requested with. self.folders = list(folders) # Convert to a list, in case 'folders' is a generator. We're iterating twice. - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, items=self.folders, parent_folder=parent_folder, - ))
          + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=self.folders, + parent_folder=parent_folder, + ) + )
          @@ -834,11 +840,11 @@

          Methods

          Expand source code
          def get_payload(self, folders, parent_folder):
          -    payload = create_element(f'm:{self.SERVICE_NAME}')
          +    payload = create_element(f"m:{self.SERVICE_NAME}")
               payload.append(
          -        folder_ids_element(folders=[parent_folder], version=self.account.version, tag='m:ParentFolderId')
          +        folder_ids_element(folders=[parent_folder], version=self.account.version, tag="m:ParentFolderId")
               )
          -    payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:Folders'))
          +    payload.append(folder_ids_element(folders=folders, version=self.account.version, tag="m:Folders"))
               return payload
          @@ -874,34 +880,36 @@

          Inherited members

          MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createitem-operation """ - SERVICE_NAME = 'CreateItem' - element_container_name = f'{{{MNS}}}Items' + SERVICE_NAME = "CreateItem" + element_container_name = f"{{{MNS}}}Items" def call(self, items, folder, message_disposition, send_meeting_invitations): if message_disposition not in MESSAGE_DISPOSITION_CHOICES: - raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES) + raise InvalidEnumValue("message_disposition", message_disposition, MESSAGE_DISPOSITION_CHOICES) if send_meeting_invitations not in SEND_MEETING_INVITATIONS_CHOICES: raise InvalidEnumValue( - 'send_meeting_invitations', send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES + "send_meeting_invitations", send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES ) if folder is not None: if not isinstance(folder, (BaseFolder, FolderId)): - raise InvalidTypeError('folder', folder, (BaseFolder, FolderId)) + raise InvalidTypeError("folder", folder, (BaseFolder, FolderId)) if folder.account != self.account: - raise ValueError('Folder must belong to account') + raise ValueError("Folder must belong to account") if message_disposition == SAVE_ONLY and folder is None: raise AttributeError("Folder must be supplied when in save-only mode") if message_disposition == SEND_AND_SAVE_COPY and folder is None: folder = self.account.sent # 'Sent' is default EWS behaviour if message_disposition == SEND_ONLY and folder is not None: raise AttributeError("Folder must be None in send-ony mode") - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, - items=items, - folder=folder, - message_disposition=message_disposition, - send_meeting_invitations=send_meeting_invitations, - )) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=items, + folder=folder, + message_disposition=message_disposition, + send_meeting_invitations=send_meeting_invitations, + ) + ) def _elem_to_obj(self, elem): if isinstance(elem, bool): @@ -933,14 +941,14 @@

          Inherited members

          :param send_meeting_invitations: """ payload = create_element( - f'm:{self.SERVICE_NAME}', - attrs=dict(MessageDisposition=message_disposition, SendMeetingInvitations=send_meeting_invitations) + f"m:{self.SERVICE_NAME}", + attrs=dict(MessageDisposition=message_disposition, SendMeetingInvitations=send_meeting_invitations), ) if folder: - payload.append(folder_ids_element( - folders=[folder], version=self.account.version, tag='m:SavedItemFolderId' - )) - item_elems = create_element('m:Items') + payload.append( + folder_ids_element(folders=[folder], version=self.account.version, tag="m:SavedItemFolderId") + ) + item_elems = create_element("m:Items") for item in items: if not item.account: item.account = self.account @@ -977,29 +985,31 @@

          Methods

          def call(self, items, folder, message_disposition, send_meeting_invitations):
               if message_disposition not in MESSAGE_DISPOSITION_CHOICES:
          -        raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES)
          +        raise InvalidEnumValue("message_disposition", message_disposition, MESSAGE_DISPOSITION_CHOICES)
               if send_meeting_invitations not in SEND_MEETING_INVITATIONS_CHOICES:
                   raise InvalidEnumValue(
          -            'send_meeting_invitations', send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES
          +            "send_meeting_invitations", send_meeting_invitations, SEND_MEETING_INVITATIONS_CHOICES
                   )
               if folder is not None:
                   if not isinstance(folder, (BaseFolder, FolderId)):
          -            raise InvalidTypeError('folder', folder, (BaseFolder, FolderId))
          +            raise InvalidTypeError("folder", folder, (BaseFolder, FolderId))
                   if folder.account != self.account:
          -            raise ValueError('Folder must belong to account')
          +            raise ValueError("Folder must belong to account")
               if message_disposition == SAVE_ONLY and folder is None:
                   raise AttributeError("Folder must be supplied when in save-only mode")
               if message_disposition == SEND_AND_SAVE_COPY and folder is None:
                   folder = self.account.sent  # 'Sent' is default EWS behaviour
               if message_disposition == SEND_ONLY and folder is not None:
                   raise AttributeError("Folder must be None in send-ony mode")
          -    return self._elems_to_objs(self._chunked_get_elements(
          -        self.get_payload,
          -        items=items,
          -        folder=folder,
          -        message_disposition=message_disposition,
          -        send_meeting_invitations=send_meeting_invitations,
          -    ))
          + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=items, + folder=folder, + message_disposition=message_disposition, + send_meeting_invitations=send_meeting_invitations, + ) + )
          @@ -1044,14 +1054,14 @@

          Methods

          :param send_meeting_invitations: """ payload = create_element( - f'm:{self.SERVICE_NAME}', - attrs=dict(MessageDisposition=message_disposition, SendMeetingInvitations=send_meeting_invitations) + f"m:{self.SERVICE_NAME}", + attrs=dict(MessageDisposition=message_disposition, SendMeetingInvitations=send_meeting_invitations), ) if folder: - payload.append(folder_ids_element( - folders=[folder], version=self.account.version, tag='m:SavedItemFolderId' - )) - item_elems = create_element('m:Items') + payload.append( + folder_ids_element(folders=[folder], version=self.account.version, tag="m:SavedItemFolderId") + ) + item_elems = create_element("m:Items") for item in items: if not item.account: item.account = self.account @@ -1089,7 +1099,7 @@

          Inherited members

          https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createuserconfiguration-operation """ - SERVICE_NAME = 'CreateUserConfiguration' + SERVICE_NAME = "CreateUserConfiguration" returns_elements = False def call(self, user_configuration): @@ -1097,7 +1107,7 @@

          Inherited members

          def get_payload(self, user_configuration): return set_xml_value( - create_element(f'm:{self.SERVICE_NAME}'), user_configuration, version=self.protocol.version + create_element(f"m:{self.SERVICE_NAME}"), user_configuration, version=self.protocol.version )

          Ancestors

          @@ -1142,7 +1152,7 @@

          Methods

          def get_payload(self, user_configuration):
               return set_xml_value(
          -        create_element(f'm:{self.SERVICE_NAME}'), user_configuration, version=self.protocol.version
          +        create_element(f"m:{self.SERVICE_NAME}"), user_configuration, version=self.protocol.version
               )
          @@ -1175,7 +1185,7 @@

          Inherited members

          https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteattachment-operation """ - SERVICE_NAME = 'DeleteAttachment' + SERVICE_NAME = "DeleteAttachment" def call(self, items): return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items)) @@ -1189,9 +1199,9 @@

          Inherited members

          def get_payload(self, items): return set_xml_value( - create_element(f'm:{self.SERVICE_NAME}'), + create_element(f"m:{self.SERVICE_NAME}"), attachment_ids_element(items=items, version=self.account.version), - version=self.account.version + version=self.account.version, )

          Ancestors

          @@ -1232,9 +1242,9 @@

          Methods

          def get_payload(self, items):
               return set_xml_value(
          -        create_element(f'm:{self.SERVICE_NAME}'),
          +        create_element(f"m:{self.SERVICE_NAME}"),
                   attachment_ids_element(items=items, version=self.account.version),
          -        version=self.account.version
          +        version=self.account.version,
               )
          @@ -1264,16 +1274,16 @@

          Inherited members

          class DeleteFolder(EWSAccountService):
               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deletefolder-operation"""
           
          -    SERVICE_NAME = 'DeleteFolder'
          +    SERVICE_NAME = "DeleteFolder"
               returns_elements = False
           
               def call(self, folders, delete_type):
                   if delete_type not in DELETE_TYPE_CHOICES:
          -            raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES)
          +            raise InvalidEnumValue("delete_type", delete_type, DELETE_TYPE_CHOICES)
                   return self._chunked_get_elements(self.get_payload, items=folders, delete_type=delete_type)
           
               def get_payload(self, folders, delete_type):
          -        payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DeleteType=delete_type))
          +        payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(DeleteType=delete_type))
                   payload.append(folder_ids_element(folders=folders, version=self.account.version))
                   return payload
          @@ -1306,7 +1316,7 @@

          Methods

          def call(self, folders, delete_type):
               if delete_type not in DELETE_TYPE_CHOICES:
          -        raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES)
          +        raise InvalidEnumValue("delete_type", delete_type, DELETE_TYPE_CHOICES)
               return self._chunked_get_elements(self.get_payload, items=folders, delete_type=delete_type)
          @@ -1320,7 +1330,7 @@

          Methods

          Expand source code
          def get_payload(self, folders, delete_type):
          -    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(DeleteType=delete_type))
          +    payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(DeleteType=delete_type))
               payload.append(folder_ids_element(folders=folders, version=self.account.version))
               return payload
          @@ -1357,19 +1367,19 @@

          Inherited members

          MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteitem-operation """ - SERVICE_NAME = 'DeleteItem' + SERVICE_NAME = "DeleteItem" returns_elements = False def call(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts): if delete_type not in DELETE_TYPE_CHOICES: - raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES) + raise InvalidEnumValue("delete_type", delete_type, DELETE_TYPE_CHOICES) if send_meeting_cancellations not in SEND_MEETING_CANCELLATIONS_CHOICES: raise InvalidEnumValue( - 'send_meeting_cancellations', send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES + "send_meeting_cancellations", send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES ) if affected_task_occurrences not in AFFECTED_TASK_OCCURRENCES_CHOICES: raise InvalidEnumValue( - 'affected_task_occurrences', affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES + "affected_task_occurrences", affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES ) return self._chunked_get_elements( self.get_payload, @@ -1380,8 +1390,9 @@

          Inherited members

          suppress_read_receipts=suppress_read_receipts, ) - def get_payload(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, - suppress_read_receipts): + def get_payload( + self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts + ): # Takes a list of (id, changekey) tuples or Item objects and returns the XML for a DeleteItem request. attrs = dict( DeleteType=delete_type, @@ -1389,8 +1400,8 @@

          Inherited members

          AffectedTaskOccurrences=affected_task_occurrences, ) if self.account.version.build >= EXCHANGE_2013_SP1: - attrs['SuppressReadReceipts'] = suppress_read_receipts - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + attrs["SuppressReadReceipts"] = suppress_read_receipts + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=attrs) payload.append(item_ids_element(items=items, version=self.account.version)) return payload @@ -1423,14 +1434,14 @@

          Methods

          def call(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts):
               if delete_type not in DELETE_TYPE_CHOICES:
          -        raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES)
          +        raise InvalidEnumValue("delete_type", delete_type, DELETE_TYPE_CHOICES)
               if send_meeting_cancellations not in SEND_MEETING_CANCELLATIONS_CHOICES:
                   raise InvalidEnumValue(
          -            'send_meeting_cancellations', send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES
          +            "send_meeting_cancellations", send_meeting_cancellations, SEND_MEETING_CANCELLATIONS_CHOICES
                   )
               if affected_task_occurrences not in AFFECTED_TASK_OCCURRENCES_CHOICES:
                   raise InvalidEnumValue(
          -            'affected_task_occurrences', affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES
          +            "affected_task_occurrences", affected_task_occurrences, AFFECTED_TASK_OCCURRENCES_CHOICES
                   )
               return self._chunked_get_elements(
                   self.get_payload,
          @@ -1451,8 +1462,9 @@ 

          Methods

          Expand source code -
          def get_payload(self, items, delete_type, send_meeting_cancellations, affected_task_occurrences,
          -                suppress_read_receipts):
          +
          def get_payload(
          +    self, items, delete_type, send_meeting_cancellations, affected_task_occurrences, suppress_read_receipts
          +):
               # Takes a list of (id, changekey) tuples or Item objects and returns the XML for a DeleteItem request.
               attrs = dict(
                   DeleteType=delete_type,
          @@ -1460,8 +1472,8 @@ 

          Methods

          AffectedTaskOccurrences=affected_task_occurrences, ) if self.account.version.build >= EXCHANGE_2013_SP1: - attrs['SuppressReadReceipts'] = suppress_read_receipts - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + attrs["SuppressReadReceipts"] = suppress_read_receipts + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=attrs) payload.append(item_ids_element(items=items, version=self.account.version)) return payload
          @@ -1495,7 +1507,7 @@

          Inherited members

          https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteuserconfiguration-operation """ - SERVICE_NAME = 'DeleteUserConfiguration' + SERVICE_NAME = "DeleteUserConfiguration" returns_elements = False def call(self, user_configuration_name): @@ -1503,7 +1515,7 @@

          Inherited members

          def get_payload(self, user_configuration_name): return set_xml_value( - create_element(f'm:{self.SERVICE_NAME}'), user_configuration_name, version=self.account.version + create_element(f"m:{self.SERVICE_NAME}"), user_configuration_name, version=self.account.version )

          Ancestors

          @@ -1548,7 +1560,7 @@

          Methods

          def get_payload(self, user_configuration_name):
               return set_xml_value(
          -        create_element(f'm:{self.SERVICE_NAME}'), user_configuration_name, version=self.account.version
          +        create_element(f"m:{self.SERVICE_NAME}"), user_configuration_name, version=self.account.version
               )
          @@ -1578,20 +1590,19 @@

          Inherited members

          class EmptyFolder(EWSAccountService):
               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/emptyfolder-operation"""
           
          -    SERVICE_NAME = 'EmptyFolder'
          +    SERVICE_NAME = "EmptyFolder"
               returns_elements = False
           
               def call(self, folders, delete_type, delete_sub_folders):
                   if delete_type not in DELETE_TYPE_CHOICES:
          -            raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES)
          +            raise InvalidEnumValue("delete_type", delete_type, DELETE_TYPE_CHOICES)
                   return self._chunked_get_elements(
                       self.get_payload, items=folders, delete_type=delete_type, delete_sub_folders=delete_sub_folders
                   )
           
               def get_payload(self, folders, delete_type, delete_sub_folders):
                   payload = create_element(
          -            f'm:{self.SERVICE_NAME}',
          -            attrs=dict(DeleteType=delete_type, DeleteSubFolders=delete_sub_folders)
          +            f"m:{self.SERVICE_NAME}", attrs=dict(DeleteType=delete_type, DeleteSubFolders=delete_sub_folders)
                   )
                   payload.append(folder_ids_element(folders=folders, version=self.account.version))
                   return payload
          @@ -1625,7 +1636,7 @@

          Methods

          def call(self, folders, delete_type, delete_sub_folders):
               if delete_type not in DELETE_TYPE_CHOICES:
          -        raise InvalidEnumValue('delete_type', delete_type, DELETE_TYPE_CHOICES)
          +        raise InvalidEnumValue("delete_type", delete_type, DELETE_TYPE_CHOICES)
               return self._chunked_get_elements(
                   self.get_payload, items=folders, delete_type=delete_type, delete_sub_folders=delete_sub_folders
               )
          @@ -1642,8 +1653,7 @@

          Methods

          def get_payload(self, folders, delete_type, delete_sub_folders):
               payload = create_element(
          -        f'm:{self.SERVICE_NAME}',
          -        attrs=dict(DeleteType=delete_type, DeleteSubFolders=delete_sub_folders)
          +        f"m:{self.SERVICE_NAME}", attrs=dict(DeleteType=delete_type, DeleteSubFolders=delete_sub_folders)
               )
               payload.append(folder_ids_element(folders=folders, version=self.account.version))
               return payload
          @@ -1675,8 +1685,8 @@

          Inherited members

          class ExpandDL(EWSService):
               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/expanddl-operation"""
           
          -    SERVICE_NAME = 'ExpandDL'
          -    element_container_name = f'{{{MNS}}}DLExpansion'
          +    SERVICE_NAME = "ExpandDL"
          +    element_container_name = f"{{{MNS}}}DLExpansion"
               WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults
           
               def call(self, distribution_list):
          @@ -1686,7 +1696,7 @@ 

          Inherited members

          return Mailbox.from_xml(elem, account=None) def get_payload(self, distribution_list): - return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), distribution_list, version=self.protocol.version)
          + return set_xml_value(create_element(f"m:{self.SERVICE_NAME}"), distribution_list, version=self.protocol.version)

          Ancestors

            @@ -1732,7 +1742,7 @@

            Methods

            Expand source code
            def get_payload(self, distribution_list):
            -    return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), distribution_list, version=self.protocol.version)
            + return set_xml_value(create_element(f"m:{self.SERVICE_NAME}"), distribution_list, version=self.protocol.version) @@ -1762,8 +1772,8 @@

            Inherited members

            """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exportitems-operation""" ERRORS_TO_CATCH_IN_RESPONSE = ResponseMessageError - SERVICE_NAME = 'ExportItems' - element_container_name = f'{{{MNS}}}Data' + SERVICE_NAME = "ExportItems" + element_container_name = f"{{{MNS}}}Data" def call(self, items): return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items)) @@ -1772,7 +1782,7 @@

            Inherited members

            return elem.text # All we want is the 64bit string in the 'Data' tag def get_payload(self, items): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") payload.append(item_ids_element(items=items, version=self.account.version)) return payload @@ -1827,7 +1837,7 @@

            Methods

            Expand source code
            def get_payload(self, items):
            -    payload = create_element(f'm:{self.SERVICE_NAME}')
            +    payload = create_element(f"m:{self.SERVICE_NAME}")
                 payload.append(item_ids_element(items=items, version=self.account.version))
                 return payload
            @@ -1858,9 +1868,9 @@

            Inherited members

            class FindFolder(EWSPagingService):
                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/findfolder-operation"""
             
            -    SERVICE_NAME = 'FindFolder'
            -    element_container_name = f'{{{TNS}}}Folders'
            -    paging_container_name = f'{{{MNS}}}RootFolder'
            +    SERVICE_NAME = "FindFolder"
            +    element_container_name = f"{{{TNS}}}Folders"
            +    paging_container_name = f"{{{MNS}}}RootFolder"
                 supports_paging = True
             
                 def __init__(self, *args, **kwargs):
            @@ -1881,14 +1891,15 @@ 

            Inherited members

            :return: XML elements for the matching folders """ if shape not in SHAPE_CHOICES: - raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + raise InvalidEnumValue("shape", shape, SHAPE_CHOICES) if depth not in FOLDER_TRAVERSAL_CHOICES: - raise InvalidEnumValue('depth', depth, FOLDER_TRAVERSAL_CHOICES) + raise InvalidEnumValue("depth", depth, FOLDER_TRAVERSAL_CHOICES) roots = {f.root for f in folders} if len(roots) != 1: raise ValueError(f"All folders in 'roots' must have the same root hierarchy ({roots})") self.root = roots.pop() - return self._elems_to_objs(self._paged_call( + return self._elems_to_objs( + self._paged_call( payload_func=self.get_payload, max_items=max_items, folders=folders, @@ -1899,21 +1910,24 @@

            Inherited members

            depth=depth, page_size=self.page_size, offset=offset, - ) - )) + ), + ) + ) def _elem_to_obj(self, elem): return Folder.from_xml_with_root(elem=elem, root=self.root) def get_payload(self, folders, additional_fields, restriction, shape, depth, page_size, offset=0): - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) - payload.append(shape_element( - tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version - )) + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(Traversal=depth)) + payload.append( + shape_element( + tag="m:FolderShape", shape=shape, additional_fields=additional_fields, version=self.account.version + ) + ) if self.account.version.build >= EXCHANGE_2010: indexed_page_folder_view = create_element( - 'm:IndexedPageFolderView', - attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') + "m:IndexedPageFolderView", + attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint="Beginning"), ) payload.append(indexed_page_folder_view) else: @@ -1921,7 +1935,7 @@

            Inherited members

            raise NotImplementedError("'offset' is only supported for Exchange 2010 servers and later") if restriction: payload.append(restriction.to_xml(version=self.account.version)) - payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds')) + payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag="m:ParentFolderIds")) return payload

            Ancestors

            @@ -1982,14 +1996,15 @@

            Methods

            :return: XML elements for the matching folders """ if shape not in SHAPE_CHOICES: - raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + raise InvalidEnumValue("shape", shape, SHAPE_CHOICES) if depth not in FOLDER_TRAVERSAL_CHOICES: - raise InvalidEnumValue('depth', depth, FOLDER_TRAVERSAL_CHOICES) + raise InvalidEnumValue("depth", depth, FOLDER_TRAVERSAL_CHOICES) roots = {f.root for f in folders} if len(roots) != 1: raise ValueError(f"All folders in 'roots' must have the same root hierarchy ({roots})") self.root = roots.pop() - return self._elems_to_objs(self._paged_call( + return self._elems_to_objs( + self._paged_call( payload_func=self.get_payload, max_items=max_items, folders=folders, @@ -2000,8 +2015,9 @@

            Methods

            depth=depth, page_size=self.page_size, offset=offset, - ) - )) + ), + ) + )
            @@ -2014,14 +2030,16 @@

            Methods

            Expand source code
            def get_payload(self, folders, additional_fields, restriction, shape, depth, page_size, offset=0):
            -    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth))
            -    payload.append(shape_element(
            -        tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version
            -    ))
            +    payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(Traversal=depth))
            +    payload.append(
            +        shape_element(
            +            tag="m:FolderShape", shape=shape, additional_fields=additional_fields, version=self.account.version
            +        )
            +    )
                 if self.account.version.build >= EXCHANGE_2010:
                     indexed_page_folder_view = create_element(
            -            'm:IndexedPageFolderView',
            -            attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning')
            +            "m:IndexedPageFolderView",
            +            attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint="Beginning"),
                     )
                     payload.append(indexed_page_folder_view)
                 else:
            @@ -2029,7 +2047,7 @@ 

            Methods

            raise NotImplementedError("'offset' is only supported for Exchange 2010 servers and later") if restriction: payload.append(restriction.to_xml(version=self.account.version)) - payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds')) + payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag="m:ParentFolderIds")) return payload
            @@ -2059,9 +2077,9 @@

            Inherited members

            class FindItem(EWSPagingService):
                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/finditem-operation"""
             
            -    SERVICE_NAME = 'FindItem'
            -    element_container_name = f'{{{TNS}}}Items'
            -    paging_container_name = f'{{{MNS}}}RootFolder'
            +    SERVICE_NAME = "FindItem"
            +    element_container_name = f"{{{TNS}}}Items"
            +    paging_container_name = f"{{{MNS}}}RootFolder"
                 supports_paging = True
             
                 def __init__(self, *args, **kwargs):
            @@ -2070,8 +2088,19 @@ 

            Inherited members

            self.additional_fields = None self.shape = None - def call(self, folders, additional_fields, restriction, order_fields, shape, query_string, depth, calendar_view, - max_items, offset): + def call( + self, + folders, + additional_fields, + restriction, + order_fields, + shape, + query_string, + depth, + calendar_view, + max_items, + offset, + ): """Find items in an account. :param folders: the folders to act on @@ -2088,43 +2117,57 @@

            Inherited members

            :return: XML elements for the matching items """ if shape not in SHAPE_CHOICES: - raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + raise InvalidEnumValue("shape", shape, SHAPE_CHOICES) if depth not in ITEM_TRAVERSAL_CHOICES: - raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) + raise InvalidEnumValue("depth", depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape - return self._elems_to_objs(self._paged_call( - payload_func=self.get_payload, - max_items=max_items, - folders=folders, - **dict( - additional_fields=additional_fields, - restriction=restriction, - order_fields=order_fields, - query_string=query_string, - shape=shape, - depth=depth, - calendar_view=calendar_view, - page_size=self.page_size, - offset=offset, + return self._elems_to_objs( + self._paged_call( + payload_func=self.get_payload, + max_items=max_items, + folders=folders, + **dict( + additional_fields=additional_fields, + restriction=restriction, + order_fields=order_fields, + query_string=query_string, + shape=shape, + depth=depth, + calendar_view=calendar_view, + page_size=self.page_size, + offset=offset, + ), ) - )) + ) def _elem_to_obj(self, elem): if self.shape == ID_ONLY and self.additional_fields is None: return Item.id_from_xml(elem) return BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) - def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, - calendar_view, page_size, offset=0): - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) - payload.append(shape_element( - tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version - )) + def get_payload( + self, + folders, + additional_fields, + restriction, + order_fields, + query_string, + shape, + depth, + calendar_view, + page_size, + offset=0, + ): + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(Traversal=depth)) + payload.append( + shape_element( + tag="m:ItemShape", shape=shape, additional_fields=additional_fields, version=self.account.version + ) + ) if calendar_view is None: view_type = create_element( - 'm:IndexedPageItemView', - attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') + "m:IndexedPageItemView", attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint="Beginning") ) else: view_type = calendar_view.to_xml(version=self.account.version) @@ -2132,12 +2175,8 @@

            Inherited members

            if restriction: payload.append(restriction.to_xml(version=self.account.version)) if order_fields: - payload.append(set_xml_value( - create_element('m:SortOrder'), - order_fields, - version=self.account.version - )) - payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds')) + payload.append(set_xml_value(create_element("m:SortOrder"), order_fields, version=self.account.version)) + payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag="m:ParentFolderIds")) if query_string: payload.append(query_string.to_xml(version=self.account.version)) return payload
            @@ -2189,8 +2228,19 @@

            Methods

            Expand source code -
            def call(self, folders, additional_fields, restriction, order_fields, shape, query_string, depth, calendar_view,
            -         max_items, offset):
            +
            def call(
            +    self,
            +    folders,
            +    additional_fields,
            +    restriction,
            +    order_fields,
            +    shape,
            +    query_string,
            +    depth,
            +    calendar_view,
            +    max_items,
            +    offset,
            +):
                 """Find items in an account.
             
                 :param folders: the folders to act on
            @@ -2207,27 +2257,29 @@ 

            Methods

            :return: XML elements for the matching items """ if shape not in SHAPE_CHOICES: - raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + raise InvalidEnumValue("shape", shape, SHAPE_CHOICES) if depth not in ITEM_TRAVERSAL_CHOICES: - raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) + raise InvalidEnumValue("depth", depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape - return self._elems_to_objs(self._paged_call( - payload_func=self.get_payload, - max_items=max_items, - folders=folders, - **dict( - additional_fields=additional_fields, - restriction=restriction, - order_fields=order_fields, - query_string=query_string, - shape=shape, - depth=depth, - calendar_view=calendar_view, - page_size=self.page_size, - offset=offset, + return self._elems_to_objs( + self._paged_call( + payload_func=self.get_payload, + max_items=max_items, + folders=folders, + **dict( + additional_fields=additional_fields, + restriction=restriction, + order_fields=order_fields, + query_string=query_string, + shape=shape, + depth=depth, + calendar_view=calendar_view, + page_size=self.page_size, + offset=offset, + ), ) - ))
            + )
            @@ -2239,16 +2291,28 @@

            Methods

            Expand source code -
            def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth,
            -                calendar_view, page_size, offset=0):
            -    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth))
            -    payload.append(shape_element(
            -        tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version
            -    ))
            +
            def get_payload(
            +    self,
            +    folders,
            +    additional_fields,
            +    restriction,
            +    order_fields,
            +    query_string,
            +    shape,
            +    depth,
            +    calendar_view,
            +    page_size,
            +    offset=0,
            +):
            +    payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(Traversal=depth))
            +    payload.append(
            +        shape_element(
            +            tag="m:ItemShape", shape=shape, additional_fields=additional_fields, version=self.account.version
            +        )
            +    )
                 if calendar_view is None:
                     view_type = create_element(
            -            'm:IndexedPageItemView',
            -            attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning')
            +            "m:IndexedPageItemView", attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint="Beginning")
                     )
                 else:
                     view_type = calendar_view.to_xml(version=self.account.version)
            @@ -2256,12 +2320,8 @@ 

            Methods

            if restriction: payload.append(restriction.to_xml(version=self.account.version)) if order_fields: - payload.append(set_xml_value( - create_element('m:SortOrder'), - order_fields, - version=self.account.version - )) - payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag='m:ParentFolderIds')) + payload.append(set_xml_value(create_element("m:SortOrder"), order_fields, version=self.account.version)) + payload.append(folder_ids_element(folders=folders, version=self.protocol.version, tag="m:ParentFolderIds")) if query_string: payload.append(query_string.to_xml(version=self.account.version)) return payload
            @@ -2293,8 +2353,8 @@

            Inherited members

            class FindPeople(EWSPagingService):
                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/findpeople-operation"""
             
            -    SERVICE_NAME = 'FindPeople'
            -    element_container_name = f'{{{MNS}}}People'
            +    SERVICE_NAME = "FindPeople"
            +    element_container_name = f"{{{MNS}}}People"
                 supported_from = EXCHANGE_2013
                 supports_paging = True
             
            @@ -2320,52 +2380,54 @@ 

            Inherited members

            :return: XML elements for the matching items """ if shape not in SHAPE_CHOICES: - raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + raise InvalidEnumValue("shape", shape, SHAPE_CHOICES) if depth not in ITEM_TRAVERSAL_CHOICES: - raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) + raise InvalidEnumValue("depth", depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape - return self._elems_to_objs(self._paged_call( - payload_func=self.get_payload, - max_items=max_items, - folders=[folder], # We just need the list to satisfy self._paged_call() - **dict( - additional_fields=additional_fields, - restriction=restriction, - order_fields=order_fields, - query_string=query_string, - shape=shape, - depth=depth, - page_size=self.page_size, - offset=offset, + return self._elems_to_objs( + self._paged_call( + payload_func=self.get_payload, + max_items=max_items, + folders=[folder], # We just need the list to satisfy self._paged_call() + **dict( + additional_fields=additional_fields, + restriction=restriction, + order_fields=order_fields, + query_string=query_string, + shape=shape, + depth=depth, + page_size=self.page_size, + offset=offset, + ), ) - )) + ) def _elem_to_obj(self, elem): if self.shape == ID_ONLY and self.additional_fields is None: return Persona.id_from_xml(elem) return Persona.from_xml(elem, account=self.account) - def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, - offset=0): + def get_payload( + self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, offset=0 + ): # We actually only support a single folder, but self._paged_call() sends us a list - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth)) - payload.append(shape_element( - tag='m:PersonaShape', shape=shape, additional_fields=additional_fields, version=self.account.version - )) - payload.append(create_element( - 'm:IndexedPageItemView', - attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning') - )) + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(Traversal=depth)) + payload.append( + shape_element( + tag="m:PersonaShape", shape=shape, additional_fields=additional_fields, version=self.account.version + ) + ) + payload.append( + create_element( + "m:IndexedPageItemView", attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint="Beginning") + ) + ) if restriction: payload.append(restriction.to_xml(version=self.account.version)) if order_fields: - payload.append(set_xml_value( - create_element('m:SortOrder'), - order_fields, - version=self.account.version - )) - payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:ParentFolderId')) + payload.append(set_xml_value(create_element("m:SortOrder"), order_fields, version=self.account.version)) + payload.append(folder_ids_element(folders=folders, version=self.account.version, tag="m:ParentFolderId")) if query_string: payload.append(query_string.to_xml(version=self.account.version)) return payload @@ -2373,11 +2435,15 @@

            Inherited members

            @staticmethod def _get_paging_values(elem): """Find paging values. The paging element from FindPeople is different from other paging containers.""" - item_count = int(elem.find(f'{{{MNS}}}TotalNumberOfPeopleInView').text) - first_matching = int(elem.find(f'{{{MNS}}}FirstMatchingRowIndex').text) - first_loaded = int(elem.find(f'{{{MNS}}}FirstLoadedRowIndex').text) - log.debug('Got page with total items %s, first matching %s, first loaded %s ', item_count, first_matching, - first_loaded) + item_count = int(elem.find(f"{{{MNS}}}TotalNumberOfPeopleInView").text) + first_matching = int(elem.find(f"{{{MNS}}}FirstMatchingRowIndex").text) + first_loaded = int(elem.find(f"{{{MNS}}}FirstLoadedRowIndex").text) + log.debug( + "Got page with total items %s, first matching %s, first loaded %s ", + item_count, + first_matching, + first_loaded, + ) next_offset = None # GetPersona does not support fetching more pages return item_count, next_offset
            @@ -2443,26 +2509,28 @@

            Methods

            :return: XML elements for the matching items """ if shape not in SHAPE_CHOICES: - raise InvalidEnumValue('shape', shape, SHAPE_CHOICES) + raise InvalidEnumValue("shape", shape, SHAPE_CHOICES) if depth not in ITEM_TRAVERSAL_CHOICES: - raise InvalidEnumValue('depth', depth, ITEM_TRAVERSAL_CHOICES) + raise InvalidEnumValue("depth", depth, ITEM_TRAVERSAL_CHOICES) self.additional_fields = additional_fields self.shape = shape - return self._elems_to_objs(self._paged_call( - payload_func=self.get_payload, - max_items=max_items, - folders=[folder], # We just need the list to satisfy self._paged_call() - **dict( - additional_fields=additional_fields, - restriction=restriction, - order_fields=order_fields, - query_string=query_string, - shape=shape, - depth=depth, - page_size=self.page_size, - offset=offset, + return self._elems_to_objs( + self._paged_call( + payload_func=self.get_payload, + max_items=max_items, + folders=[folder], # We just need the list to satisfy self._paged_call() + **dict( + additional_fields=additional_fields, + restriction=restriction, + order_fields=order_fields, + query_string=query_string, + shape=shape, + depth=depth, + page_size=self.page_size, + offset=offset, + ), ) - ))
            + )
            @@ -2474,26 +2542,26 @@

            Methods

            Expand source code -
            def get_payload(self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size,
            -                offset=0):
            +
            def get_payload(
            +    self, folders, additional_fields, restriction, order_fields, query_string, shape, depth, page_size, offset=0
            +):
                 # We actually only support a single folder, but self._paged_call() sends us a list
            -    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(Traversal=depth))
            -    payload.append(shape_element(
            -        tag='m:PersonaShape', shape=shape, additional_fields=additional_fields, version=self.account.version
            -    ))
            -    payload.append(create_element(
            -        'm:IndexedPageItemView',
            -        attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint='Beginning')
            -    ))
            +    payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(Traversal=depth))
            +    payload.append(
            +        shape_element(
            +            tag="m:PersonaShape", shape=shape, additional_fields=additional_fields, version=self.account.version
            +        )
            +    )
            +    payload.append(
            +        create_element(
            +            "m:IndexedPageItemView", attrs=dict(MaxEntriesReturned=page_size, Offset=offset, BasePoint="Beginning")
            +        )
            +    )
                 if restriction:
                     payload.append(restriction.to_xml(version=self.account.version))
                 if order_fields:
            -        payload.append(set_xml_value(
            -            create_element('m:SortOrder'),
            -            order_fields,
            -            version=self.account.version
            -        ))
            -    payload.append(folder_ids_element(folders=folders, version=self.account.version, tag='m:ParentFolderId'))
            +        payload.append(set_xml_value(create_element("m:SortOrder"), order_fields, version=self.account.version))
            +    payload.append(folder_ids_element(folders=folders, version=self.account.version, tag="m:ParentFolderId"))
                 if query_string:
                     payload.append(query_string.to_xml(version=self.account.version))
                 return payload
            @@ -2525,37 +2593,44 @@

            Inherited members

            class GetAttachment(EWSAccountService):
                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getattachment-operation"""
             
            -    SERVICE_NAME = 'GetAttachment'
            -    element_container_name = f'{{{MNS}}}Attachments'
            +    SERVICE_NAME = "GetAttachment"
            +    element_container_name = f"{{{MNS}}}Attachments"
                 cls_map = {cls.response_tag(): cls for cls in (FileAttachment, ItemAttachment)}
             
                 def call(self, items, include_mime_content, body_type, filter_html_content, additional_fields):
                     if body_type and body_type not in BODY_TYPE_CHOICES:
            -            raise InvalidEnumValue('body_type', body_type, BODY_TYPE_CHOICES)
            -        return self._elems_to_objs(self._chunked_get_elements(
            -            self.get_payload, items=items, include_mime_content=include_mime_content,
            -            body_type=body_type, filter_html_content=filter_html_content, additional_fields=additional_fields,
            -        ))
            +            raise InvalidEnumValue("body_type", body_type, BODY_TYPE_CHOICES)
            +        return self._elems_to_objs(
            +            self._chunked_get_elements(
            +                self.get_payload,
            +                items=items,
            +                include_mime_content=include_mime_content,
            +                body_type=body_type,
            +                filter_html_content=filter_html_content,
            +                additional_fields=additional_fields,
            +            )
            +        )
             
                 def _elem_to_obj(self, elem):
                     return self.cls_map[elem.tag].from_xml(elem=elem, account=self.account)
             
                 def get_payload(self, items, include_mime_content, body_type, filter_html_content, additional_fields):
            -        payload = create_element(f'm:{self.SERVICE_NAME}')
            -        shape_elem = create_element('m:AttachmentShape')
            +        payload = create_element(f"m:{self.SERVICE_NAME}")
            +        shape_elem = create_element("m:AttachmentShape")
                     if include_mime_content:
            -            add_xml_child(shape_elem, 't:IncludeMimeContent', 'true')
            +            add_xml_child(shape_elem, "t:IncludeMimeContent", "true")
                     if body_type:
            -            add_xml_child(shape_elem, 't:BodyType', body_type)
            +            add_xml_child(shape_elem, "t:BodyType", body_type)
                     if filter_html_content is not None:
            -            add_xml_child(shape_elem, 't:FilterHtmlContent', 'true' if filter_html_content else 'false')
            +            add_xml_child(shape_elem, "t:FilterHtmlContent", "true" if filter_html_content else "false")
                     if additional_fields:
            -            additional_properties = create_element('t:AdditionalProperties')
            +            additional_properties = create_element("t:AdditionalProperties")
                         expanded_fields = chain(*(f.expand(version=self.account.version) for f in additional_fields))
            -            set_xml_value(additional_properties, sorted(
            -                expanded_fields,
            -                key=lambda f: (getattr(f.field, 'field_uri', ''), f.path)
            -            ), version=self.account.version)
            +            set_xml_value(
            +                additional_properties,
            +                sorted(expanded_fields, key=lambda f: (getattr(f.field, "field_uri", ""), f.path)),
            +                version=self.account.version,
            +            )
                         shape_elem.append(additional_properties)
                     if len(shape_elem):
                         payload.append(shape_elem)
            @@ -2563,26 +2638,26 @@ 

            Inherited members

            return payload def _update_api_version(self, api_version, header, **parse_opts): - if not parse_opts.get('stream_file_content', False): + if not parse_opts.get("stream_file_content", False): super()._update_api_version(api_version, header, **parse_opts) # TODO: We're skipping this part in streaming mode because StreamingBase64Parser cannot parse the SOAP header @classmethod def _get_soap_parts(cls, response, **parse_opts): - if not parse_opts.get('stream_file_content', False): + if not parse_opts.get("stream_file_content", False): return super()._get_soap_parts(response, **parse_opts) # Pass the response unaltered. We want to use our custom streaming parser return None, response def _get_soap_messages(self, body, **parse_opts): - if not parse_opts.get('stream_file_content', False): + if not parse_opts.get("stream_file_content", False): return super()._get_soap_messages(body, **parse_opts) # 'body' is actually the raw response passed on by '_get_soap_parts' r = body parser = StreamingBase64Parser() - field = FileAttachment.get_field_by_fieldname('_content') + field = FileAttachment.get_field_by_fieldname("_content") handler = StreamingContentHandler(parser=parser, ns=field.namespace, element_name=field.field_uri) parser.setContentHandler(handler) return parser.parse(r) @@ -2590,7 +2665,10 @@

            Inherited members

            def stream_file_content(self, attachment_id): # The streaming XML parser can only stream content of one attachment payload = self.get_payload( - items=[attachment_id], include_mime_content=False, body_type=None, filter_html_content=None, + items=[attachment_id], + include_mime_content=False, + body_type=None, + filter_html_content=None, additional_fields=None, ) self.streaming = True @@ -2645,11 +2723,17 @@

            Methods

            def call(self, items, include_mime_content, body_type, filter_html_content, additional_fields):
                 if body_type and body_type not in BODY_TYPE_CHOICES:
            -        raise InvalidEnumValue('body_type', body_type, BODY_TYPE_CHOICES)
            -    return self._elems_to_objs(self._chunked_get_elements(
            -        self.get_payload, items=items, include_mime_content=include_mime_content,
            -        body_type=body_type, filter_html_content=filter_html_content, additional_fields=additional_fields,
            -    ))
            + raise InvalidEnumValue("body_type", body_type, BODY_TYPE_CHOICES) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=items, + include_mime_content=include_mime_content, + body_type=body_type, + filter_html_content=filter_html_content, + additional_fields=additional_fields, + ) + )
            @@ -2662,21 +2746,22 @@

            Methods

            Expand source code
            def get_payload(self, items, include_mime_content, body_type, filter_html_content, additional_fields):
            -    payload = create_element(f'm:{self.SERVICE_NAME}')
            -    shape_elem = create_element('m:AttachmentShape')
            +    payload = create_element(f"m:{self.SERVICE_NAME}")
            +    shape_elem = create_element("m:AttachmentShape")
                 if include_mime_content:
            -        add_xml_child(shape_elem, 't:IncludeMimeContent', 'true')
            +        add_xml_child(shape_elem, "t:IncludeMimeContent", "true")
                 if body_type:
            -        add_xml_child(shape_elem, 't:BodyType', body_type)
            +        add_xml_child(shape_elem, "t:BodyType", body_type)
                 if filter_html_content is not None:
            -        add_xml_child(shape_elem, 't:FilterHtmlContent', 'true' if filter_html_content else 'false')
            +        add_xml_child(shape_elem, "t:FilterHtmlContent", "true" if filter_html_content else "false")
                 if additional_fields:
            -        additional_properties = create_element('t:AdditionalProperties')
            +        additional_properties = create_element("t:AdditionalProperties")
                     expanded_fields = chain(*(f.expand(version=self.account.version) for f in additional_fields))
            -        set_xml_value(additional_properties, sorted(
            -            expanded_fields,
            -            key=lambda f: (getattr(f.field, 'field_uri', ''), f.path)
            -        ), version=self.account.version)
            +        set_xml_value(
            +            additional_properties,
            +            sorted(expanded_fields, key=lambda f: (getattr(f.field, "field_uri", ""), f.path)),
            +            version=self.account.version,
            +        )
                     shape_elem.append(additional_properties)
                 if len(shape_elem):
                     payload.append(shape_elem)
            @@ -2696,7 +2781,10 @@ 

            Methods

            def stream_file_content(self, attachment_id):
                 # The streaming XML parser can only stream content of one attachment
                 payload = self.get_payload(
            -        items=[attachment_id], include_mime_content=False, body_type=None, filter_html_content=None,
            +        items=[attachment_id],
            +        include_mime_content=False,
            +        body_type=None,
            +        filter_html_content=None,
                     additional_fields=None,
                 )
                 self.streaming = True
            @@ -2745,26 +2833,28 @@ 

            Inherited members

            class GetDelegate(EWSAccountService):
                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getdelegate-operation"""
             
            -    SERVICE_NAME = 'GetDelegate'
            +    SERVICE_NAME = "GetDelegate"
                 ERRORS_TO_CATCH_IN_RESPONSE = ()
                 supported_from = EXCHANGE_2007_SP1
             
                 def call(self, user_ids, include_permissions):
            -        return self._elems_to_objs(self._chunked_get_elements(
            -            self.get_payload,
            -            items=user_ids or [None],
            -            mailbox=DLMailbox(email_address=self.account.primary_smtp_address),
            -            include_permissions=include_permissions,
            -        ))
            +        return self._elems_to_objs(
            +            self._chunked_get_elements(
            +                self.get_payload,
            +                items=user_ids or [None],
            +                mailbox=DLMailbox(email_address=self.account.primary_smtp_address),
            +                include_permissions=include_permissions,
            +            )
            +        )
             
                 def _elem_to_obj(self, elem):
                     return DelegateUser.from_xml(elem=elem, account=self.account)
             
                 def get_payload(self, user_ids, mailbox, include_permissions):
            -        payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IncludePermissions=include_permissions))
            +        payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(IncludePermissions=include_permissions))
                     set_xml_value(payload, mailbox, version=self.protocol.version)
                     if user_ids != [None]:
            -            user_ids_elem = create_element('m:UserIds')
            +            user_ids_elem = create_element("m:UserIds")
                         for user_id in user_ids:
                             if isinstance(user_id, str):
                                 user_id = UserId(primary_smtp_address=user_id)
            @@ -2778,7 +2868,7 @@ 

            Inherited members

            @classmethod def _response_message_tag(cls): - return f'{{{MNS}}}DelegateUserResponseMessageType'
            + return f"{{{MNS}}}DelegateUserResponseMessageType"

            Ancestors

              @@ -2812,12 +2902,14 @@

              Methods

              Expand source code
              def call(self, user_ids, include_permissions):
              -    return self._elems_to_objs(self._chunked_get_elements(
              -        self.get_payload,
              -        items=user_ids or [None],
              -        mailbox=DLMailbox(email_address=self.account.primary_smtp_address),
              -        include_permissions=include_permissions,
              -    ))
              + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=user_ids or [None], + mailbox=DLMailbox(email_address=self.account.primary_smtp_address), + include_permissions=include_permissions, + ) + )
            @@ -2830,10 +2922,10 @@

            Methods

            Expand source code
            def get_payload(self, user_ids, mailbox, include_permissions):
            -    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IncludePermissions=include_permissions))
            +    payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(IncludePermissions=include_permissions))
                 set_xml_value(payload, mailbox, version=self.protocol.version)
                 if user_ids != [None]:
            -        user_ids_elem = create_element('m:UserIds')
            +        user_ids_elem = create_element("m:UserIds")
                     for user_id in user_ids:
                         if isinstance(user_id, str):
                             user_id = UserId(primary_smtp_address=user_id)
            @@ -2871,13 +2963,18 @@ 

            Inherited members

            https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getevents-operation """ - SERVICE_NAME = 'GetEvents' + SERVICE_NAME = "GetEvents" prefer_affinity = True def call(self, subscription_id, watermark): - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - subscription_id=subscription_id, watermark=watermark, - ))) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + subscription_id=subscription_id, + watermark=watermark, + ) + ) + ) def _elem_to_obj(self, elem): return Notification.from_xml(elem=elem, account=None) @@ -2887,9 +2984,9 @@

            Inherited members

            return container.findall(Notification.response_tag()) def get_payload(self, subscription_id, watermark): - payload = create_element(f'm:{self.SERVICE_NAME}') - add_xml_child(payload, 'm:SubscriptionId', subscription_id) - add_xml_child(payload, 'm:Watermark', watermark) + payload = create_element(f"m:{self.SERVICE_NAME}") + add_xml_child(payload, "m:SubscriptionId", subscription_id) + add_xml_child(payload, "m:Watermark", watermark) return payload

            Ancestors

            @@ -2920,9 +3017,14 @@

            Methods

            Expand source code
            def call(self, subscription_id, watermark):
            -    return self._elems_to_objs(self._get_elements(payload=self.get_payload(
            -            subscription_id=subscription_id, watermark=watermark,
            -    )))
            + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + subscription_id=subscription_id, + watermark=watermark, + ) + ) + )
            @@ -2935,9 +3037,9 @@

            Methods

            Expand source code
            def get_payload(self, subscription_id, watermark):
            -    payload = create_element(f'm:{self.SERVICE_NAME}')
            -    add_xml_child(payload, 'm:SubscriptionId', subscription_id)
            -    add_xml_child(payload, 'm:Watermark', watermark)
            +    payload = create_element(f"m:{self.SERVICE_NAME}")
            +    add_xml_child(payload, "m:SubscriptionId", subscription_id)
            +    add_xml_child(payload, "m:Watermark", watermark)
                 return payload
            @@ -2967,10 +3069,12 @@

            Inherited members

            class GetFolder(EWSAccountService):
                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getfolder-operation"""
             
            -    SERVICE_NAME = 'GetFolder'
            -    element_container_name = f'{{{MNS}}}Folders'
            +    SERVICE_NAME = "GetFolder"
            +    element_container_name = f"{{{MNS}}}Folders"
                 ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (
            -        ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorInvalidOperation,
            +        ErrorFolderNotFound,
            +        ErrorNoPublicFolderReplicaAvailable,
            +        ErrorInvalidOperation,
                 )
             
                 def __init__(self, *args, **kwargs):
            @@ -2989,12 +3093,14 @@ 

            Inherited members

            # We can't easily find the correct folder class from the returned XML. Instead, return objects with the same # class as the folder instance it was requested with. self.folders = list(folders) # Convert to a list, in case 'folders' is a generator. We're iterating twice. - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, - items=self.folders, - additional_fields=additional_fields, - shape=shape, - )) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=self.folders, + additional_fields=additional_fields, + shape=shape, + ) + ) def _elems_to_objs(self, elems): for folder, elem in zip(self.folders, elems): @@ -3004,10 +3110,12 @@

            Inherited members

            yield parse_folder_elem(elem=elem, folder=folder, account=self.account) def get_payload(self, folders, additional_fields, shape): - payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(shape_element( - tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version - )) + payload = create_element(f"m:{self.SERVICE_NAME}") + payload.append( + shape_element( + tag="m:FolderShape", shape=shape, additional_fields=additional_fields, version=self.account.version + ) + ) payload.append(folder_ids_element(folders=folders, version=self.account.version)) return payload
            @@ -3058,12 +3166,14 @@

            Methods

            # We can't easily find the correct folder class from the returned XML. Instead, return objects with the same # class as the folder instance it was requested with. self.folders = list(folders) # Convert to a list, in case 'folders' is a generator. We're iterating twice. - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, - items=self.folders, - additional_fields=additional_fields, - shape=shape, - ))
            + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=self.folders, + additional_fields=additional_fields, + shape=shape, + ) + )
            @@ -3076,10 +3186,12 @@

            Methods

            Expand source code
            def get_payload(self, folders, additional_fields, shape):
            -    payload = create_element(f'm:{self.SERVICE_NAME}')
            -    payload.append(shape_element(
            -        tag='m:FolderShape', shape=shape, additional_fields=additional_fields, version=self.account.version
            -    ))
            +    payload = create_element(f"m:{self.SERVICE_NAME}")
            +    payload.append(
            +        shape_element(
            +            tag="m:FolderShape", shape=shape, additional_fields=additional_fields, version=self.account.version
            +        )
            +    )
                 payload.append(folder_ids_element(folders=folders, version=self.account.version))
                 return payload
            @@ -3110,8 +3222,8 @@

            Inherited members

            class GetItem(EWSAccountService):
                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getitem-operation"""
             
            -    SERVICE_NAME = 'GetItem'
            -    element_container_name = f'{{{MNS}}}Items'
            +    SERVICE_NAME = "GetItem"
            +    element_container_name = f"{{{MNS}}}Items"
             
                 def call(self, items, additional_fields, shape):
                     """Return all items in an account that correspond to a list of ID's, in stable order.
            @@ -3122,18 +3234,25 @@ 

            Inherited members

            :return: XML elements for the items, in stable order """ - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, items=items, additional_fields=additional_fields, shape=shape, - )) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=items, + additional_fields=additional_fields, + shape=shape, + ) + ) def _elem_to_obj(self, elem): return BaseFolder.item_model_from_tag(elem.tag).from_xml(elem=elem, account=self.account) def get_payload(self, items, additional_fields, shape): - payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(shape_element( - tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version - )) + payload = create_element(f"m:{self.SERVICE_NAME}") + payload.append( + shape_element( + tag="m:ItemShape", shape=shape, additional_fields=additional_fields, version=self.account.version + ) + ) payload.append(item_ids_element(items=items, version=self.account.version)) return payload
            @@ -3177,9 +3296,14 @@

            Methods

            :return: XML elements for the items, in stable order """ - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, items=items, additional_fields=additional_fields, shape=shape, - ))
            + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=items, + additional_fields=additional_fields, + shape=shape, + ) + )
            @@ -3192,10 +3316,12 @@

            Methods

            Expand source code
            def get_payload(self, items, additional_fields, shape):
            -    payload = create_element(f'm:{self.SERVICE_NAME}')
            -    payload.append(shape_element(
            -        tag='m:ItemShape', shape=shape, additional_fields=additional_fields, version=self.account.version
            -    ))
            +    payload = create_element(f"m:{self.SERVICE_NAME}")
            +    payload.append(
            +        shape_element(
            +            tag="m:ItemShape", shape=shape, additional_fields=additional_fields, version=self.account.version
            +        )
            +    )
                 payload.append(item_ids_element(items=items, version=self.account.version))
                 return payload
            @@ -3226,24 +3352,26 @@

            Inherited members

            class GetMailTips(EWSService):
                 """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getmailtips-operation"""
             
            -    SERVICE_NAME = 'GetMailTips'
            +    SERVICE_NAME = "GetMailTips"
             
                 def call(self, sending_as, recipients, mail_tips_requested):
            -        return self._elems_to_objs(self._chunked_get_elements(
            -            self.get_payload,
            -            items=recipients,
            -            sending_as=sending_as,
            -            mail_tips_requested=mail_tips_requested,
            -        ))
            +        return self._elems_to_objs(
            +            self._chunked_get_elements(
            +                self.get_payload,
            +                items=recipients,
            +                sending_as=sending_as,
            +                mail_tips_requested=mail_tips_requested,
            +            )
            +        )
             
                 def _elem_to_obj(self, elem):
                     return MailTips.from_xml(elem=elem, account=None)
             
            -    def get_payload(self, recipients, sending_as,  mail_tips_requested):
            -        payload = create_element(f'm:{self.SERVICE_NAME}')
            +    def get_payload(self, recipients, sending_as, mail_tips_requested):
            +        payload = create_element(f"m:{self.SERVICE_NAME}")
                     set_xml_value(payload, sending_as, version=self.protocol.version)
             
            -        recipients_elem = create_element('m:Recipients')
            +        recipients_elem = create_element("m:Recipients")
                     for recipient in recipients:
                         set_xml_value(recipients_elem, recipient, version=self.protocol.version)
                     payload.append(recipients_elem)
            @@ -3258,7 +3386,7 @@ 

            Inherited members

            @classmethod def _response_message_tag(cls): - return f'{{{MNS}}}MailTipsResponseMessageType'
            + return f"{{{MNS}}}MailTipsResponseMessageType"

            Ancestors

              @@ -3283,12 +3411,14 @@

              Methods

              Expand source code
              def call(self, sending_as, recipients, mail_tips_requested):
              -    return self._elems_to_objs(self._chunked_get_elements(
              -        self.get_payload,
              -        items=recipients,
              -        sending_as=sending_as,
              -        mail_tips_requested=mail_tips_requested,
              -    ))
              + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=recipients, + sending_as=sending_as, + mail_tips_requested=mail_tips_requested, + ) + )
              @@ -3300,11 +3430,11 @@

              Methods

              Expand source code -
              def get_payload(self, recipients, sending_as,  mail_tips_requested):
              -    payload = create_element(f'm:{self.SERVICE_NAME}')
              +
              def get_payload(self, recipients, sending_as, mail_tips_requested):
              +    payload = create_element(f"m:{self.SERVICE_NAME}")
                   set_xml_value(payload, sending_as, version=self.protocol.version)
               
              -    recipients_elem = create_element('m:Recipients')
              +    recipients_elem = create_element("m:Recipients")
                   for recipient in recipients:
                       set_xml_value(recipients_elem, recipient, version=self.protocol.version)
                   payload.append(recipients_elem)
              @@ -3340,7 +3470,7 @@ 

              Inherited members

              class GetPersona(EWSAccountService):
                   """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getpersona-operation"""
               
              -    SERVICE_NAME = 'GetPersona'
              +    SERVICE_NAME = "GetPersona"
               
                   def call(self, personas):
                       # GetPersona only accepts one persona ID per request. Crazy.
              @@ -3352,18 +3482,16 @@ 

              Inherited members

              def get_payload(self, persona): return set_xml_value( - create_element(f'm:{self.SERVICE_NAME}'), - to_item_id(persona, PersonaId), - version=self.protocol.version + create_element(f"m:{self.SERVICE_NAME}"), to_item_id(persona, PersonaId), version=self.protocol.version ) @classmethod def _get_elements_in_container(cls, container): - return container.findall(f'{{{MNS}}}{Persona.ELEMENT_NAME}') + return container.findall(f"{{{MNS}}}{Persona.ELEMENT_NAME}") @classmethod def _response_tag(cls): - return f'{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage'
              + return f"{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage"

              Ancestors

                @@ -3405,9 +3533,7 @@

                Methods

                def get_payload(self, persona):
                     return set_xml_value(
                -        create_element(f'm:{self.SERVICE_NAME}'),
                -        to_item_id(persona, PersonaId),
                -        version=self.protocol.version
                +        create_element(f"m:{self.SERVICE_NAME}"), to_item_id(persona, PersonaId), version=self.protocol.version
                     )
                @@ -3437,8 +3563,8 @@

                Inherited members

                class GetRoomLists(EWSService):
                     """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getroomlists-operation"""
                 
                -    SERVICE_NAME = 'GetRoomLists'
                -    element_container_name = f'{{{MNS}}}RoomLists'
                +    SERVICE_NAME = "GetRoomLists"
                +    element_container_name = f"{{{MNS}}}RoomLists"
                     supported_from = EXCHANGE_2010
                 
                     def call(self):
                @@ -3448,7 +3574,7 @@ 

                Inherited members

                return RoomList.from_xml(elem=elem, account=None) def get_payload(self): - return create_element(f'm:{self.SERVICE_NAME}')
                + return create_element(f"m:{self.SERVICE_NAME}")

              Ancestors

                @@ -3494,7 +3620,7 @@

                Methods

                Expand source code
                def get_payload(self):
                -    return create_element(f'm:{self.SERVICE_NAME}')
                + return create_element(f"m:{self.SERVICE_NAME}")
                @@ -3523,8 +3649,8 @@

                Inherited members

                class GetRooms(EWSService):
                     """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getrooms-operation"""
                 
                -    SERVICE_NAME = 'GetRooms'
                -    element_container_name = f'{{{MNS}}}Rooms'
                +    SERVICE_NAME = "GetRooms"
                +    element_container_name = f"{{{MNS}}}Rooms"
                     supported_from = EXCHANGE_2010
                 
                     def call(self, room_list):
                @@ -3534,7 +3660,7 @@ 

                Inherited members

                return Room.from_xml(elem=elem, account=None) def get_payload(self, room_list): - return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), room_list, version=self.protocol.version)
                + return set_xml_value(create_element(f"m:{self.SERVICE_NAME}"), room_list, version=self.protocol.version)

                Ancestors

                  @@ -3580,7 +3706,7 @@

                  Methods

                  Expand source code
                  def get_payload(self, room_list):
                  -    return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), room_list, version=self.protocol.version)
                  + return set_xml_value(create_element(f"m:{self.SERVICE_NAME}"), room_list, version=self.protocol.version) @@ -3612,27 +3738,31 @@

                  Inherited members

                  https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getsearchablemailboxes-operation """ - SERVICE_NAME = 'GetSearchableMailboxes' - element_container_name = f'{{{MNS}}}SearchableMailboxes' - failed_mailboxes_container_name = f'{{{MNS}}}FailedMailboxes' + SERVICE_NAME = "GetSearchableMailboxes" + element_container_name = f"{{{MNS}}}SearchableMailboxes" + failed_mailboxes_container_name = f"{{{MNS}}}FailedMailboxes" supported_from = EXCHANGE_2013 cls_map = {cls.response_tag(): cls for cls in (SearchableMailbox, FailedMailbox)} def call(self, search_filter, expand_group_membership): - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - search_filter=search_filter, - expand_group_membership=expand_group_membership, - ))) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + search_filter=search_filter, + expand_group_membership=expand_group_membership, + ) + ) + ) def _elem_to_obj(self, elem): return self.cls_map[elem.tag].from_xml(elem=elem, account=None) def get_payload(self, search_filter, expand_group_membership): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") if search_filter: - add_xml_child(payload, 'm:SearchFilter', search_filter) + add_xml_child(payload, "m:SearchFilter", search_filter) if expand_group_membership is not None: - add_xml_child(payload, 'm:ExpandGroupMembership', 'true' if expand_group_membership else 'false') + add_xml_child(payload, "m:ExpandGroupMembership", "true" if expand_group_membership else "false") return payload def _get_elements_in_response(self, response): @@ -3684,10 +3814,14 @@

                  Methods

                  Expand source code
                  def call(self, search_filter, expand_group_membership):
                  -    return self._elems_to_objs(self._get_elements(payload=self.get_payload(
                  -            search_filter=search_filter,
                  -            expand_group_membership=expand_group_membership,
                  -    )))
                  + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + search_filter=search_filter, + expand_group_membership=expand_group_membership, + ) + ) + )
                  @@ -3700,11 +3834,11 @@

                  Methods

                  Expand source code
                  def get_payload(self, search_filter, expand_group_membership):
                  -    payload = create_element(f'm:{self.SERVICE_NAME}')
                  +    payload = create_element(f"m:{self.SERVICE_NAME}")
                       if search_filter:
                  -        add_xml_child(payload, 'm:SearchFilter', search_filter)
                  +        add_xml_child(payload, "m:SearchFilter", search_filter)
                       if expand_group_membership is not None:
                  -        add_xml_child(payload, 'm:ExpandGroupMembership', 'true' if expand_group_membership else 'false')
                  +        add_xml_child(payload, "m:ExpandGroupMembership", "true" if expand_group_membership else "false")
                       return payload
                  @@ -3737,27 +3871,28 @@

                  Inherited members

                  https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getservertimezones-operation """ - SERVICE_NAME = 'GetServerTimeZones' - element_container_name = f'{{{MNS}}}TimeZoneDefinitions' + SERVICE_NAME = "GetServerTimeZones" + element_container_name = f"{{{MNS}}}TimeZoneDefinitions" supported_from = EXCHANGE_2010 def call(self, timezones=None, return_full_timezone_data=False): - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - timezones=timezones, - return_full_timezone_data=return_full_timezone_data - ))) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload(timezones=timezones, return_full_timezone_data=return_full_timezone_data) + ) + ) def get_payload(self, timezones, return_full_timezone_data): payload = create_element( - f'm:{self.SERVICE_NAME}', + f"m:{self.SERVICE_NAME}", attrs=dict(ReturnFullTimeZoneData=return_full_timezone_data), ) if timezones is not None: is_empty, timezones = peek(timezones) if not is_empty: - tz_ids = create_element('m:Ids') + tz_ids = create_element("m:Ids") for timezone in timezones: - tz_id = set_xml_value(create_element('t:Id'), timezone.ms_id) + tz_id = set_xml_value(create_element("t:Id"), timezone.ms_id) tz_ids.append(tz_id) payload.append(tz_ids) return payload @@ -3796,10 +3931,11 @@

                  Methods

                  Expand source code
                  def call(self, timezones=None, return_full_timezone_data=False):
                  -    return self._elems_to_objs(self._get_elements(payload=self.get_payload(
                  -        timezones=timezones,
                  -        return_full_timezone_data=return_full_timezone_data
                  -    )))
                  + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload(timezones=timezones, return_full_timezone_data=return_full_timezone_data) + ) + )
                  @@ -3813,15 +3949,15 @@

                  Methods

                  def get_payload(self, timezones, return_full_timezone_data):
                       payload = create_element(
                  -        f'm:{self.SERVICE_NAME}',
                  +        f"m:{self.SERVICE_NAME}",
                           attrs=dict(ReturnFullTimeZoneData=return_full_timezone_data),
                       )
                       if timezones is not None:
                           is_empty, timezones = peek(timezones)
                           if not is_empty:
                  -            tz_ids = create_element('m:Ids')
                  +            tz_ids = create_element("m:Ids")
                               for timezone in timezones:
                  -                tz_id = set_xml_value(create_element('t:Id'), timezone.ms_id)
                  +                tz_id = set_xml_value(create_element("t:Id"), timezone.ms_id)
                                   tz_ids.append(tz_id)
                               payload.append(tz_ids)
                       return payload
                  @@ -3856,13 +3992,13 @@

                  Inherited members

                  https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getstreamingevents-operation """ - SERVICE_NAME = 'GetStreamingEvents' - element_container_name = f'{{{MNS}}}Notifications' + SERVICE_NAME = "GetStreamingEvents" + element_container_name = f"{{{MNS}}}Notifications" prefer_affinity = True # Connection status values - OK = 'OK' - CLOSED = 'Closed' + OK = "OK" + CLOSED = "Closed" def __init__(self, *args, **kwargs): # These values are set each time call() is consumed @@ -3872,14 +4008,19 @@

                  Inherited members

                  def call(self, subscription_ids, connection_timeout): if not isinstance(connection_timeout, int): - raise InvalidTypeError('connection_timeout', connection_timeout, int) + raise InvalidTypeError("connection_timeout", connection_timeout, int) if connection_timeout < 1: raise ValueError(f"'connection_timeout' {connection_timeout} must be a positive integer") # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed self.timeout = connection_timeout * 60 + 60 - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - subscription_ids=subscription_ids, connection_timeout=connection_timeout, - ))) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + subscription_ids=subscription_ids, + connection_timeout=connection_timeout, + ) + ) + ) def _elem_to_obj(self, elem): return Notification.from_xml(elem=elem, account=None) @@ -3895,7 +4036,7 @@

                  Inherited members

                  # XML response. r = body for i, doc in enumerate(DocumentYielder(r.iter_content()), start=1): - xml_log.debug('''Response XML (docs counter: %(i)s): %(xml_response)s''', dict(i=i, xml_response=doc)) + xml_log.debug("Response XML (docs counter: %(i)s): %(xml_response)s", dict(i=i, xml_response=doc)) response = DummyResponse(content=doc) try: _, body = super()._get_soap_parts(response=response, **parse_opts) @@ -3910,10 +4051,10 @@

                  Inherited members

                  break def _get_element_container(self, message, name=None): - error_ids_elem = message.find(f'{{{MNS}}}ErrorSubscriptionIds') - error_ids = [] if error_ids_elem is None else get_xml_attrs(error_ids_elem, f'{{{MNS}}}SubscriptionId') - self.connection_status = get_xml_attr(message, f'{{{MNS}}}ConnectionStatus') # Either 'OK' or 'Closed' - log.debug('Connection status is: %s', self.connection_status) + error_ids_elem = message.find(f"{{{MNS}}}ErrorSubscriptionIds") + error_ids = [] if error_ids_elem is None else get_xml_attrs(error_ids_elem, f"{{{MNS}}}SubscriptionId") + self.connection_status = get_xml_attr(message, f"{{{MNS}}}ConnectionStatus") # Either 'OK' or 'Closed' + log.debug("Connection status is: %s", self.connection_status) # Upstream normally expects to find a 'name' tag but our response does not always have it. We still want to # call upstream, to have exceptions raised. Return an empty list if there is no 'name' tag and no errors. if message.find(name) is None: @@ -3925,20 +4066,20 @@

                  Inherited members

                  # subscriptions seem to never be returned even though the XML spec allows it. This means there's no point in # trying to collect any notifications here and delivering a combination of errors and return values. if error_ids: - e.value += f' (subscription IDs: {error_ids})' + e.value += f" (subscription IDs: {error_ids})" raise e return [] if name is None else res def get_payload(self, subscription_ids, connection_timeout): - payload = create_element(f'm:{self.SERVICE_NAME}') - subscriptions_elem = create_element('m:SubscriptionIds') + payload = create_element(f"m:{self.SERVICE_NAME}") + subscriptions_elem = create_element("m:SubscriptionIds") for subscription_id in subscription_ids: - add_xml_child(subscriptions_elem, 't:SubscriptionId', subscription_id) + add_xml_child(subscriptions_elem, "t:SubscriptionId", subscription_id) if not len(subscriptions_elem): raise ValueError("'subscription_ids' must not be empty") payload.append(subscriptions_elem) - add_xml_child(payload, 'm:ConnectionTimeout', connection_timeout) + add_xml_child(payload, "m:ConnectionTimeout", connection_timeout) return payload

                  Ancestors

                  @@ -3982,14 +4123,19 @@

                  Methods

                  def call(self, subscription_ids, connection_timeout):
                       if not isinstance(connection_timeout, int):
                  -        raise InvalidTypeError('connection_timeout', connection_timeout, int)
                  +        raise InvalidTypeError("connection_timeout", connection_timeout, int)
                       if connection_timeout < 1:
                           raise ValueError(f"'connection_timeout' {connection_timeout} must be a positive integer")
                       # Add 60 seconds to the timeout, to allow us to always get the final message containing ConnectionStatus=Closed
                       self.timeout = connection_timeout * 60 + 60
                  -    return self._elems_to_objs(self._get_elements(payload=self.get_payload(
                  -            subscription_ids=subscription_ids, connection_timeout=connection_timeout,
                  -    )))
                  + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + subscription_ids=subscription_ids, + connection_timeout=connection_timeout, + ) + ) + )
                  @@ -4002,15 +4148,15 @@

                  Methods

                  Expand source code
                  def get_payload(self, subscription_ids, connection_timeout):
                  -    payload = create_element(f'm:{self.SERVICE_NAME}')
                  -    subscriptions_elem = create_element('m:SubscriptionIds')
                  +    payload = create_element(f"m:{self.SERVICE_NAME}")
                  +    subscriptions_elem = create_element("m:SubscriptionIds")
                       for subscription_id in subscription_ids:
                  -        add_xml_child(subscriptions_elem, 't:SubscriptionId', subscription_id)
                  +        add_xml_child(subscriptions_elem, "t:SubscriptionId", subscription_id)
                       if not len(subscriptions_elem):
                           raise ValueError("'subscription_ids' must not be empty")
                   
                       payload.append(subscriptions_elem)
                  -    add_xml_child(payload, 'm:ConnectionTimeout', connection_timeout)
                  +    add_xml_child(payload, "m:ConnectionTimeout", connection_timeout)
                       return payload
                  @@ -4045,45 +4191,47 @@

                  Inherited members

                  https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getuseravailability-operation """ - SERVICE_NAME = 'GetUserAvailability' + SERVICE_NAME = "GetUserAvailability" def call(self, timezone, mailbox_data, free_busy_view_options): # TODO: Also supports SuggestionsViewOptions, see # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/suggestionsviewoptions - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - timezone=timezone, - mailbox_data=mailbox_data, - free_busy_view_options=free_busy_view_options - ))) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + timezone=timezone, mailbox_data=mailbox_data, free_busy_view_options=free_busy_view_options + ) + ) + ) def _elem_to_obj(self, elem): return FreeBusyView.from_xml(elem=elem, account=None) def get_payload(self, timezone, mailbox_data, free_busy_view_options): - payload = create_element(f'm:{self.SERVICE_NAME}Request') + payload = create_element(f"m:{self.SERVICE_NAME}Request") set_xml_value(payload, timezone, version=self.protocol.version) - mailbox_data_array = create_element('m:MailboxDataArray') + mailbox_data_array = create_element("m:MailboxDataArray") set_xml_value(mailbox_data_array, mailbox_data, version=self.protocol.version) payload.append(mailbox_data_array) return set_xml_value(payload, free_busy_view_options, version=self.protocol.version) @staticmethod def _response_messages_tag(): - return f'{{{MNS}}}FreeBusyResponseArray' + return f"{{{MNS}}}FreeBusyResponseArray" @classmethod def _response_message_tag(cls): - return f'{{{MNS}}}FreeBusyResponse' + return f"{{{MNS}}}FreeBusyResponse" def _get_elements_in_response(self, response): for msg in response: # Just check the response code and raise errors - self._get_element_container(message=msg.find(f'{{{MNS}}}ResponseMessage')) + self._get_element_container(message=msg.find(f"{{{MNS}}}ResponseMessage")) yield from self._get_elements_in_container(container=msg) @classmethod def _get_elements_in_container(cls, container): - return [container.find(f'{{{MNS}}}FreeBusyView')]
                  + return [container.find(f"{{{MNS}}}FreeBusyView")]

                  Ancestors

                    @@ -4110,11 +4258,13 @@

                    Methods

                    def call(self, timezone, mailbox_data, free_busy_view_options):
                         # TODO: Also supports SuggestionsViewOptions, see
                         #  https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/suggestionsviewoptions
                    -    return self._elems_to_objs(self._get_elements(payload=self.get_payload(
                    -        timezone=timezone,
                    -        mailbox_data=mailbox_data,
                    -        free_busy_view_options=free_busy_view_options
                    -    )))
                    + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + timezone=timezone, mailbox_data=mailbox_data, free_busy_view_options=free_busy_view_options + ) + ) + )
                    @@ -4127,9 +4277,9 @@

                    Methods

                    Expand source code
                    def get_payload(self, timezone, mailbox_data, free_busy_view_options):
                    -    payload = create_element(f'm:{self.SERVICE_NAME}Request')
                    +    payload = create_element(f"m:{self.SERVICE_NAME}Request")
                         set_xml_value(payload, timezone, version=self.protocol.version)
                    -    mailbox_data_array = create_element('m:MailboxDataArray')
                    +    mailbox_data_array = create_element("m:MailboxDataArray")
                         set_xml_value(mailbox_data_array, mailbox_data, version=self.protocol.version)
                         payload.append(mailbox_data_array)
                         return set_xml_value(payload, free_busy_view_options, version=self.protocol.version)
                    @@ -4164,14 +4314,16 @@

                    Inherited members

                    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getuserconfiguration-operation """ - SERVICE_NAME = 'GetUserConfiguration' + SERVICE_NAME = "GetUserConfiguration" def call(self, user_configuration_name, properties): if properties not in PROPERTIES_CHOICES: - raise InvalidEnumValue('properties', properties, PROPERTIES_CHOICES) - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - user_configuration_name=user_configuration_name, properties=properties - ))) + raise InvalidEnumValue("properties", properties, PROPERTIES_CHOICES) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload(user_configuration_name=user_configuration_name, properties=properties) + ) + ) def _elem_to_obj(self, elem): return UserConfiguration.from_xml(elem=elem, account=self.account) @@ -4181,10 +4333,10 @@

                    Inherited members

                    return container.findall(UserConfiguration.response_tag()) def get_payload(self, user_configuration_name, properties): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") set_xml_value(payload, user_configuration_name, version=self.account.version) payload.append( - set_xml_value(create_element('m:UserConfigurationProperties'), properties, version=self.account.version) + set_xml_value(create_element("m:UserConfigurationProperties"), properties, version=self.account.version) ) return payload
                    @@ -4213,10 +4365,12 @@

                    Methods

                    def call(self, user_configuration_name, properties):
                         if properties not in PROPERTIES_CHOICES:
                    -        raise InvalidEnumValue('properties', properties, PROPERTIES_CHOICES)
                    -    return self._elems_to_objs(self._get_elements(payload=self.get_payload(
                    -            user_configuration_name=user_configuration_name, properties=properties
                    -    )))
                    + raise InvalidEnumValue("properties", properties, PROPERTIES_CHOICES) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload(user_configuration_name=user_configuration_name, properties=properties) + ) + )
                    @@ -4229,10 +4383,10 @@

                    Methods

                    Expand source code
                    def get_payload(self, user_configuration_name, properties):
                    -    payload = create_element(f'm:{self.SERVICE_NAME}')
                    +    payload = create_element(f"m:{self.SERVICE_NAME}")
                         set_xml_value(payload, user_configuration_name, version=self.account.version)
                         payload.append(
                    -        set_xml_value(create_element('m:UserConfigurationProperties'), properties, version=self.account.version)
                    +        set_xml_value(create_element("m:UserConfigurationProperties"), properties, version=self.account.version)
                         )
                         return payload
                    @@ -4266,8 +4420,8 @@

                    Inherited members

                    MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getuseroofsettings-operation """ - SERVICE_NAME = 'GetUserOofSettings' - element_container_name = f'{{{TNS}}}OofSettings' + SERVICE_NAME = "GetUserOofSettings" + element_container_name = f"{{{TNS}}}OofSettings" def call(self, mailbox): return self._elems_to_objs(self._get_elements(payload=self.get_payload(mailbox=mailbox))) @@ -4277,9 +4431,9 @@

                    Inherited members

                    def get_payload(self, mailbox): return set_xml_value( - create_element(f'm:{self.SERVICE_NAME}Request'), + create_element(f"m:{self.SERVICE_NAME}Request"), AvailabilityMailbox.from_mailbox(mailbox), - version=self.account.version + version=self.account.version, ) @classmethod @@ -4294,7 +4448,7 @@

                    Inherited members

                    @classmethod def _response_message_tag(cls): - return f'{{{MNS}}}ResponseMessage'
                    + return f"{{{MNS}}}ResponseMessage"

                    Ancestors

                      @@ -4338,9 +4492,9 @@

                      Methods

                      def get_payload(self, mailbox):
                           return set_xml_value(
                      -        create_element(f'm:{self.SERVICE_NAME}Request'),
                      +        create_element(f"m:{self.SERVICE_NAME}Request"),
                               AvailabilityMailbox.from_mailbox(mailbox),
                      -        version=self.account.version
                      +        version=self.account.version,
                           )
                      @@ -4370,7 +4524,7 @@

                      Inherited members

                      class MarkAsJunk(EWSAccountService):
                           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/markasjunk-operation"""
                       
                      -    SERVICE_NAME = 'MarkAsJunk'
                      +    SERVICE_NAME = "MarkAsJunk"
                       
                           def call(self, items, is_junk, move_item):
                               return self._elems_to_objs(
                      @@ -4386,7 +4540,7 @@ 

                      Inherited members

                      def get_payload(self, items, is_junk, move_item): # Takes a list of items and returns either success or raises an error message - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IsJunk=is_junk, MoveItem=move_item)) + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(IsJunk=is_junk, MoveItem=move_item)) payload.append(item_ids_element(items=items, version=self.account.version)) return payload
                      @@ -4430,7 +4584,7 @@

                      Methods

                      def get_payload(self, items, is_junk, move_item):
                           # Takes a list of items and returns either success or raises an error message
                      -    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IsJunk=is_junk, MoveItem=move_item))
                      +    payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(IsJunk=is_junk, MoveItem=move_item))
                           payload.append(item_ids_element(items=items, version=self.account.version))
                           return payload
                      @@ -4462,11 +4616,11 @@

                      Inherited members

                      """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/movefolder-operation""" SERVICE_NAME = "MoveFolder" - element_container_name = f'{{{MNS}}}Folders' + element_container_name = f"{{{MNS}}}Folders" def call(self, folders, to_folder): if not isinstance(to_folder, (BaseFolder, FolderId)): - raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId)) + raise InvalidTypeError("to_folder", to_folder, (BaseFolder, FolderId)) return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=folders, to_folder=to_folder)) def _elem_to_obj(self, elem): @@ -4474,8 +4628,8 @@

                      Inherited members

                      def get_payload(self, folders, to_folder): # Takes a list of folders and returns their new folder IDs - payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) + payload = create_element(f"m:{self.SERVICE_NAME}") + payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag="m:ToFolderId")) payload.append(folder_ids_element(folders=folders, version=self.account.version)) return payload @@ -4508,7 +4662,7 @@

                      Methods

                      def call(self, folders, to_folder):
                           if not isinstance(to_folder, (BaseFolder, FolderId)):
                      -        raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId))
                      +        raise InvalidTypeError("to_folder", to_folder, (BaseFolder, FolderId))
                           return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=folders, to_folder=to_folder))
                      @@ -4523,8 +4677,8 @@

                      Methods

                      def get_payload(self, folders, to_folder):
                           # Takes a list of folders and returns their new folder IDs
                      -    payload = create_element(f'm:{self.SERVICE_NAME}')
                      -    payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId'))
                      +    payload = create_element(f"m:{self.SERVICE_NAME}")
                      +    payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag="m:ToFolderId"))
                           payload.append(folder_ids_element(folders=folders, version=self.account.version))
                           return payload
                      @@ -4555,12 +4709,12 @@

                      Inherited members

                      class MoveItem(EWSAccountService):
                           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/moveitem-operation"""
                       
                      -    SERVICE_NAME = 'MoveItem'
                      -    element_container_name = f'{{{MNS}}}Items'
                      +    SERVICE_NAME = "MoveItem"
                      +    element_container_name = f"{{{MNS}}}Items"
                       
                           def call(self, items, to_folder):
                               if not isinstance(to_folder, (BaseFolder, FolderId)):
                      -            raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId))
                      +            raise InvalidTypeError("to_folder", to_folder, (BaseFolder, FolderId))
                               return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, to_folder=to_folder))
                       
                           def _elem_to_obj(self, elem):
                      @@ -4568,8 +4722,8 @@ 

                      Inherited members

                      def get_payload(self, items, to_folder): # Takes a list of items and returns their new item IDs - payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) + payload = create_element(f"m:{self.SERVICE_NAME}") + payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag="m:ToFolderId")) payload.append(item_ids_element(items=items, version=self.account.version)) return payload
                      @@ -4606,7 +4760,7 @@

                      Methods

                      def call(self, items, to_folder):
                           if not isinstance(to_folder, (BaseFolder, FolderId)):
                      -        raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId))
                      +        raise InvalidTypeError("to_folder", to_folder, (BaseFolder, FolderId))
                           return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, to_folder=to_folder))
                      @@ -4621,8 +4775,8 @@

                      Methods

                      def get_payload(self, items, to_folder):
                           # Takes a list of items and returns their new item IDs
                      -    payload = create_element(f'm:{self.SERVICE_NAME}')
                      -    payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId'))
                      +    payload = create_element(f"m:{self.SERVICE_NAME}")
                      +    payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag="m:ToFolderId"))
                           payload.append(item_ids_element(items=items, version=self.account.version))
                           return payload
                      @@ -4653,8 +4807,8 @@

                      Inherited members

                      class ResolveNames(EWSService):
                           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/resolvenames-operation"""
                       
                      -    SERVICE_NAME = 'ResolveNames'
                      -    element_container_name = f'{{{MNS}}}ResolutionSet'
                      +    SERVICE_NAME = "ResolveNames"
                      +    element_container_name = f"{{{MNS}}}ResolutionSet"
                           ERRORS_TO_CATCH_IN_RESPONSE = ErrorNameResolutionNoResults
                           WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults
                           # Note: paging information is returned as attrs on the 'ResolutionSet' element, but this service does not
                      @@ -4666,26 +4820,34 @@ 

                      Inherited members

                      super().__init__(*args, **kwargs) self.return_full_contact_data = False # A hack to communicate parsing args to _elems_to_objs() - def call(self, unresolved_entries, parent_folders=None, return_full_contact_data=False, search_scope=None, - contact_data_shape=None): + def call( + self, + unresolved_entries, + parent_folders=None, + return_full_contact_data=False, + search_scope=None, + contact_data_shape=None, + ): if self.chunk_size > 100: raise ValueError( - f'Chunk size {self.chunk_size} is too high. {self.SERVICE_NAME} supports returning at most 100 ' - f'candidates for a lookup', + f"Chunk size {self.chunk_size} is too high. {self.SERVICE_NAME} supports returning at most 100 " + f"candidates for a lookup", ) if search_scope and search_scope not in SEARCH_SCOPE_CHOICES: - raise InvalidEnumValue('search_scope', search_scope, SEARCH_SCOPE_CHOICES) + raise InvalidEnumValue("search_scope", search_scope, SEARCH_SCOPE_CHOICES) if contact_data_shape and contact_data_shape not in SHAPE_CHOICES: - raise InvalidEnumValue('contact_data_shape', contact_data_shape, SHAPE_CHOICES) + raise InvalidEnumValue("contact_data_shape", contact_data_shape, SHAPE_CHOICES) self.return_full_contact_data = return_full_contact_data - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, - items=unresolved_entries, - parent_folders=parent_folders, - return_full_contact_data=return_full_contact_data, - search_scope=search_scope, - contact_data_shape=contact_data_shape, - )) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=unresolved_entries, + parent_folders=parent_folders, + return_full_contact_data=return_full_contact_data, + search_scope=search_scope, + contact_data_shape=contact_data_shape, + ) + ) def _elem_to_obj(self, elem): if self.return_full_contact_data: @@ -4697,23 +4859,25 @@

                      Inherited members

                      ) return Mailbox.from_xml(elem=elem.find(Mailbox.response_tag()), account=None) - def get_payload(self, unresolved_entries, parent_folders, return_full_contact_data, search_scope, - contact_data_shape): + def get_payload( + self, unresolved_entries, parent_folders, return_full_contact_data, search_scope, contact_data_shape + ): attrs = dict(ReturnFullContactData=return_full_contact_data) if search_scope: - attrs['SearchScope'] = search_scope + attrs["SearchScope"] = search_scope if contact_data_shape: if self.protocol.version.build < EXCHANGE_2010_SP2: raise NotImplementedError( - "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later") - attrs['ContactDataShape'] = contact_data_shape - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later" + ) + attrs["ContactDataShape"] = contact_data_shape + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=attrs) if parent_folders: - payload.append(folder_ids_element( - folders=parent_folders, version=self.protocol.version, tag='m:ParentFolderIds' - )) + payload.append( + folder_ids_element(folders=parent_folders, version=self.protocol.version, tag="m:ParentFolderIds") + ) for entry in unresolved_entries: - add_xml_child(payload, 'm:UnresolvedEntry', entry) + add_xml_child(payload, "m:UnresolvedEntry", entry) return payload

                      Ancestors

                      @@ -4754,26 +4918,34 @@

                      Methods

                      Expand source code -
                      def call(self, unresolved_entries, parent_folders=None, return_full_contact_data=False, search_scope=None,
                      -         contact_data_shape=None):
                      +
                      def call(
                      +    self,
                      +    unresolved_entries,
                      +    parent_folders=None,
                      +    return_full_contact_data=False,
                      +    search_scope=None,
                      +    contact_data_shape=None,
                      +):
                           if self.chunk_size > 100:
                               raise ValueError(
                      -            f'Chunk size {self.chunk_size} is too high. {self.SERVICE_NAME} supports returning at most 100 '
                      -            f'candidates for a lookup',
                      +            f"Chunk size {self.chunk_size} is too high. {self.SERVICE_NAME} supports returning at most 100 "
                      +            f"candidates for a lookup",
                               )
                           if search_scope and search_scope not in SEARCH_SCOPE_CHOICES:
                      -        raise InvalidEnumValue('search_scope', search_scope, SEARCH_SCOPE_CHOICES)
                      +        raise InvalidEnumValue("search_scope", search_scope, SEARCH_SCOPE_CHOICES)
                           if contact_data_shape and contact_data_shape not in SHAPE_CHOICES:
                      -        raise InvalidEnumValue('contact_data_shape', contact_data_shape, SHAPE_CHOICES)
                      +        raise InvalidEnumValue("contact_data_shape", contact_data_shape, SHAPE_CHOICES)
                           self.return_full_contact_data = return_full_contact_data
                      -    return self._elems_to_objs(self._chunked_get_elements(
                      -        self.get_payload,
                      -        items=unresolved_entries,
                      -        parent_folders=parent_folders,
                      -        return_full_contact_data=return_full_contact_data,
                      -        search_scope=search_scope,
                      -        contact_data_shape=contact_data_shape,
                      -    ))
                      + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=unresolved_entries, + parent_folders=parent_folders, + return_full_contact_data=return_full_contact_data, + search_scope=search_scope, + contact_data_shape=contact_data_shape, + ) + )
                      @@ -4785,23 +4957,25 @@

                      Methods

                      Expand source code -
                      def get_payload(self, unresolved_entries, parent_folders, return_full_contact_data, search_scope,
                      -                contact_data_shape):
                      +
                      def get_payload(
                      +    self, unresolved_entries, parent_folders, return_full_contact_data, search_scope, contact_data_shape
                      +):
                           attrs = dict(ReturnFullContactData=return_full_contact_data)
                           if search_scope:
                      -        attrs['SearchScope'] = search_scope
                      +        attrs["SearchScope"] = search_scope
                           if contact_data_shape:
                               if self.protocol.version.build < EXCHANGE_2010_SP2:
                                   raise NotImplementedError(
                      -                "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later")
                      -        attrs['ContactDataShape'] = contact_data_shape
                      -    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs)
                      +                "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later"
                      +            )
                      +        attrs["ContactDataShape"] = contact_data_shape
                      +    payload = create_element(f"m:{self.SERVICE_NAME}", attrs=attrs)
                           if parent_folders:
                      -        payload.append(folder_ids_element(
                      -            folders=parent_folders, version=self.protocol.version, tag='m:ParentFolderIds'
                      -        ))
                      +        payload.append(
                      +            folder_ids_element(folders=parent_folders, version=self.protocol.version, tag="m:ParentFolderIds")
                      +        )
                           for entry in unresolved_entries:
                      -        add_xml_child(payload, 'm:UnresolvedEntry', entry)
                      +        add_xml_child(payload, "m:UnresolvedEntry", entry)
                           return payload
                      @@ -4831,21 +5005,21 @@

                      Inherited members

                      class SendItem(EWSAccountService):
                           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/senditem-operation"""
                       
                      -    SERVICE_NAME = 'SendItem'
                      +    SERVICE_NAME = "SendItem"
                           returns_elements = False
                       
                           def call(self, items, saved_item_folder):
                               if saved_item_folder and not isinstance(saved_item_folder, (BaseFolder, FolderId)):
                      -            raise InvalidTypeError('saved_item_folder', saved_item_folder, (BaseFolder, FolderId))
                      +            raise InvalidTypeError("saved_item_folder", saved_item_folder, (BaseFolder, FolderId))
                               return self._chunked_get_elements(self.get_payload, items=items, saved_item_folder=saved_item_folder)
                       
                           def get_payload(self, items, saved_item_folder):
                      -        payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(SaveItemToFolder=bool(saved_item_folder)))
                      +        payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(SaveItemToFolder=bool(saved_item_folder)))
                               payload.append(item_ids_element(items=items, version=self.account.version))
                               if saved_item_folder:
                      -            payload.append(folder_ids_element(
                      -                folders=[saved_item_folder], version=self.account.version, tag='m:SavedItemFolderId'
                      -            ))
                      +            payload.append(
                      +                folder_ids_element(folders=[saved_item_folder], version=self.account.version, tag="m:SavedItemFolderId")
                      +            )
                               return payload

                      Ancestors

                      @@ -4877,7 +5051,7 @@

                      Methods

                      def call(self, items, saved_item_folder):
                           if saved_item_folder and not isinstance(saved_item_folder, (BaseFolder, FolderId)):
                      -        raise InvalidTypeError('saved_item_folder', saved_item_folder, (BaseFolder, FolderId))
                      +        raise InvalidTypeError("saved_item_folder", saved_item_folder, (BaseFolder, FolderId))
                           return self._chunked_get_elements(self.get_payload, items=items, saved_item_folder=saved_item_folder)
                      @@ -4891,12 +5065,12 @@

                      Methods

                      Expand source code
                      def get_payload(self, items, saved_item_folder):
                      -    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(SaveItemToFolder=bool(saved_item_folder)))
                      +    payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(SaveItemToFolder=bool(saved_item_folder)))
                           payload.append(item_ids_element(items=items, version=self.account.version))
                           if saved_item_folder:
                      -        payload.append(folder_ids_element(
                      -            folders=[saved_item_folder], version=self.account.version, tag='m:SavedItemFolderId'
                      -        ))
                      +        payload.append(
                      +            folder_ids_element(folders=[saved_item_folder], version=self.account.version, tag="m:SavedItemFolderId")
                      +        )
                           return payload
                      @@ -4934,9 +5108,9 @@

                      Inherited members

                      push notifications. """ - SERVICE_NAME = 'SendNotification' - OK = 'OK' - UNSUBSCRIBE = 'Unsubscribe' + SERVICE_NAME = "SendNotification" + OK = "OK" + UNSUBSCRIBE = "Unsubscribe" STATUS_CHOICES = (OK, UNSUBSCRIBE) def ok_payload(self): @@ -4951,7 +5125,7 @@

                      Inherited members

                      @classmethod def _response_tag(cls): """Return the name of the element containing the service response.""" - return f'{{{MNS}}}{cls.SERVICE_NAME}' + return f"{{{MNS}}}{cls.SERVICE_NAME}" @classmethod def _get_elements_in_container(cls, container): @@ -4959,9 +5133,9 @@

                      Inherited members

                      def get_payload(self, status): if status not in self.STATUS_CHOICES: - raise InvalidEnumValue('status', status, self.STATUS_CHOICES) - payload = create_element(f'm:{self.SERVICE_NAME}Result') - add_xml_child(payload, 'm:SubscriptionStatus', status) + raise InvalidEnumValue("status", status, self.STATUS_CHOICES) + payload = create_element(f"m:{self.SERVICE_NAME}Result") + add_xml_child(payload, "m:SubscriptionStatus", status) return payload

                      Ancestors

                      @@ -5000,9 +5174,9 @@

                      Methods

                      def get_payload(self, status):
                           if status not in self.STATUS_CHOICES:
                      -        raise InvalidEnumValue('status', status, self.STATUS_CHOICES)
                      -    payload = create_element(f'm:{self.SERVICE_NAME}Result')
                      -    add_xml_child(payload, 'm:SubscriptionStatus', status)
                      +        raise InvalidEnumValue("status", status, self.STATUS_CHOICES)
                      +    payload = create_element(f"m:{self.SERVICE_NAME}Result")
                      +    add_xml_child(payload, "m:SubscriptionStatus", status)
                           return payload
                      @@ -5061,18 +5235,18 @@

                      Inherited members

                      MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/setuseroofsettings-operation """ - SERVICE_NAME = 'SetUserOofSettings' + SERVICE_NAME = "SetUserOofSettings" returns_elements = False def call(self, oof_settings, mailbox): if not isinstance(oof_settings, OofSettings): - raise InvalidTypeError('oof_settings', oof_settings, OofSettings) + raise InvalidTypeError("oof_settings", oof_settings, OofSettings) if not isinstance(mailbox, Mailbox): - raise InvalidTypeError('mailbox', mailbox, Mailbox) + raise InvalidTypeError("mailbox", mailbox, Mailbox) return self._get_elements(payload=self.get_payload(oof_settings=oof_settings, mailbox=mailbox)) def get_payload(self, oof_settings, mailbox): - payload = create_element(f'm:{self.SERVICE_NAME}Request') + payload = create_element(f"m:{self.SERVICE_NAME}Request") set_xml_value(payload, AvailabilityMailbox.from_mailbox(mailbox), version=self.account.version) return set_xml_value(payload, oof_settings, version=self.account.version) @@ -5082,7 +5256,7 @@

                      Inherited members

                      @classmethod def _response_message_tag(cls): - return f'{{{MNS}}}ResponseMessage'
                      + return f"{{{MNS}}}ResponseMessage"

                      Ancestors

                        @@ -5113,9 +5287,9 @@

                        Methods

                        def call(self, oof_settings, mailbox):
                             if not isinstance(oof_settings, OofSettings):
                        -        raise InvalidTypeError('oof_settings', oof_settings, OofSettings)
                        +        raise InvalidTypeError("oof_settings", oof_settings, OofSettings)
                             if not isinstance(mailbox, Mailbox):
                        -        raise InvalidTypeError('mailbox', mailbox, Mailbox)
                        +        raise InvalidTypeError("mailbox", mailbox, Mailbox)
                             return self._get_elements(payload=self.get_payload(oof_settings=oof_settings, mailbox=mailbox))
                        @@ -5129,7 +5303,7 @@

                        Methods

                        Expand source code
                        def get_payload(self, oof_settings, mailbox):
                        -    payload = create_element(f'm:{self.SERVICE_NAME}Request')
                        +    payload = create_element(f"m:{self.SERVICE_NAME}Request")
                             set_xml_value(payload, AvailabilityMailbox.from_mailbox(mailbox), version=self.account.version)
                             return set_xml_value(payload, oof_settings, version=self.account.version)
                        @@ -5158,21 +5332,24 @@

                        Inherited members

                        Expand source code
                        class SubscribeToPull(Subscribe):
                        -    subscription_request_elem_tag = 'm:PullSubscriptionRequest'
                        +    subscription_request_elem_tag = "m:PullSubscriptionRequest"
                             prefer_affinity = True
                         
                             def call(self, folders, event_types, watermark, timeout):
                                 yield from self._partial_call(
                        -            payload_func=self.get_payload, folders=folders, event_types=event_types, timeout=timeout,
                        +            payload_func=self.get_payload,
                        +            folders=folders,
                        +            event_types=event_types,
                        +            timeout=timeout,
                                     watermark=watermark,
                                 )
                         
                             def get_payload(self, folders, event_types, watermark, timeout):
                        -        payload = create_element(f'm:{self.SERVICE_NAME}')
                        +        payload = create_element(f"m:{self.SERVICE_NAME}")
                                 request_elem = self._partial_payload(folders=folders, event_types=event_types)
                                 if watermark:
                        -            add_xml_child(request_elem, 'm:Watermark', watermark)
                        -        add_xml_child(request_elem, 't:Timeout', timeout)  # In minutes
                        +            add_xml_child(request_elem, "m:Watermark", watermark)
                        +        add_xml_child(request_elem, "t:Timeout", timeout)  # In minutes
                                 payload.append(request_elem)
                                 return payload
                        @@ -5206,7 +5383,10 @@

                        Methods

                        def call(self, folders, event_types, watermark, timeout):
                             yield from self._partial_call(
                        -        payload_func=self.get_payload, folders=folders, event_types=event_types, timeout=timeout,
                        +        payload_func=self.get_payload,
                        +        folders=folders,
                        +        event_types=event_types,
                        +        timeout=timeout,
                                 watermark=watermark,
                             )
                        @@ -5221,11 +5401,11 @@

                        Methods

                        Expand source code
                        def get_payload(self, folders, event_types, watermark, timeout):
                        -    payload = create_element(f'm:{self.SERVICE_NAME}')
                        +    payload = create_element(f"m:{self.SERVICE_NAME}")
                             request_elem = self._partial_payload(folders=folders, event_types=event_types)
                             if watermark:
                        -        add_xml_child(request_elem, 'm:Watermark', watermark)
                        -    add_xml_child(request_elem, 't:Timeout', timeout)  # In minutes
                        +        add_xml_child(request_elem, "m:Watermark", watermark)
                        +    add_xml_child(request_elem, "t:Timeout", timeout)  # In minutes
                             payload.append(request_elem)
                             return payload
                        @@ -5254,21 +5434,25 @@

                        Inherited members

                        Expand source code
                        class SubscribeToPush(Subscribe):
                        -    subscription_request_elem_tag = 'm:PushSubscriptionRequest'
                        +    subscription_request_elem_tag = "m:PushSubscriptionRequest"
                         
                             def call(self, folders, event_types, watermark, status_frequency, url):
                                 yield from self._partial_call(
                        -            payload_func=self.get_payload, folders=folders, event_types=event_types,
                        -            status_frequency=status_frequency, url=url, watermark=watermark,
                        +            payload_func=self.get_payload,
                        +            folders=folders,
                        +            event_types=event_types,
                        +            status_frequency=status_frequency,
                        +            url=url,
                        +            watermark=watermark,
                                 )
                         
                             def get_payload(self, folders, event_types, watermark, status_frequency, url):
                        -        payload = create_element(f'm:{self.SERVICE_NAME}')
                        +        payload = create_element(f"m:{self.SERVICE_NAME}")
                                 request_elem = self._partial_payload(folders=folders, event_types=event_types)
                                 if watermark:
                        -            add_xml_child(request_elem, 'm:Watermark', watermark)
                        -        add_xml_child(request_elem, 't:StatusFrequency', status_frequency)  # In minutes
                        -        add_xml_child(request_elem, 't:URL', url)
                        +            add_xml_child(request_elem, "m:Watermark", watermark)
                        +        add_xml_child(request_elem, "t:StatusFrequency", status_frequency)  # In minutes
                        +        add_xml_child(request_elem, "t:URL", url)
                                 payload.append(request_elem)
                                 return payload
                        @@ -5298,8 +5482,12 @@

                        Methods

                        def call(self, folders, event_types, watermark, status_frequency, url):
                             yield from self._partial_call(
                        -        payload_func=self.get_payload, folders=folders, event_types=event_types,
                        -        status_frequency=status_frequency, url=url, watermark=watermark,
                        +        payload_func=self.get_payload,
                        +        folders=folders,
                        +        event_types=event_types,
                        +        status_frequency=status_frequency,
                        +        url=url,
                        +        watermark=watermark,
                             )
                        @@ -5313,12 +5501,12 @@

                        Methods

                        Expand source code
                        def get_payload(self, folders, event_types, watermark, status_frequency, url):
                        -    payload = create_element(f'm:{self.SERVICE_NAME}')
                        +    payload = create_element(f"m:{self.SERVICE_NAME}")
                             request_elem = self._partial_payload(folders=folders, event_types=event_types)
                             if watermark:
                        -        add_xml_child(request_elem, 'm:Watermark', watermark)
                        -    add_xml_child(request_elem, 't:StatusFrequency', status_frequency)  # In minutes
                        -    add_xml_child(request_elem, 't:URL', url)
                        +        add_xml_child(request_elem, "m:Watermark", watermark)
                        +    add_xml_child(request_elem, "t:StatusFrequency", status_frequency)  # In minutes
                        +    add_xml_child(request_elem, "t:URL", url)
                             payload.append(request_elem)
                             return payload
                        @@ -5347,7 +5535,7 @@

                        Inherited members

                        Expand source code
                        class SubscribeToStreaming(Subscribe):
                        -    subscription_request_elem_tag = 'm:StreamingSubscriptionRequest'
                        +    subscription_request_elem_tag = "m:StreamingSubscriptionRequest"
                             prefer_affinity = True
                         
                             def call(self, folders, event_types):
                        @@ -5358,10 +5546,10 @@ 

                        Inherited members

                        @classmethod def _get_elements_in_container(cls, container): - return [container.find(f'{{{MNS}}}SubscriptionId')] + return [container.find(f"{{{MNS}}}SubscriptionId")] def get_payload(self, folders, event_types): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") payload.append(self._partial_payload(folders=folders, event_types=event_types)) return payload
                        @@ -5407,7 +5595,7 @@

                        Methods

                        Expand source code
                        def get_payload(self, folders, event_types):
                        -    payload = create_element(f'm:{self.SERVICE_NAME}')
                        +    payload = create_element(f"m:{self.SERVICE_NAME}")
                             payload.append(self._partial_payload(folders=folders, event_types=event_types))
                             return payload
                        @@ -5441,9 +5629,9 @@

                        Inherited members

                        https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/syncfolderhierarchy-operation """ - SERVICE_NAME = 'SyncFolderHierarchy' - shape_tag = 'm:FolderShape' - last_in_range_name = f'{{{MNS}}}IncludesLastFolderInRange' + SERVICE_NAME = "SyncFolderHierarchy" + shape_tag = "m:FolderShape" + last_in_range_name = f"{{{MNS}}}IncludesLastFolderInRange" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -5452,12 +5640,16 @@

                        Inherited members

                        def call(self, folder, shape, additional_fields, sync_state): self.sync_state = sync_state self.folder = folder - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - folder=folder, - shape=shape, - additional_fields=additional_fields, - sync_state=sync_state, - ))) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + folder=folder, + shape=shape, + additional_fields=additional_fields, + sync_state=sync_state, + ) + ) + ) def _elem_to_obj(self, elem): change_type = self.change_types_map[elem.tag] @@ -5511,12 +5703,16 @@

                        Methods

                        def call(self, folder, shape, additional_fields, sync_state):
                             self.sync_state = sync_state
                             self.folder = folder
                        -    return self._elems_to_objs(self._get_elements(payload=self.get_payload(
                        -            folder=folder,
                        -            shape=shape,
                        -            additional_fields=additional_fields,
                        -            sync_state=sync_state,
                        -    )))
                        + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + folder=folder, + shape=shape, + additional_fields=additional_fields, + sync_state=sync_state, + ) + ) + )
                        @@ -5563,45 +5759,49 @@

                        Inherited members

                        https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/syncfolderitems-operation """ - SERVICE_NAME = 'SyncFolderItems' + SERVICE_NAME = "SyncFolderItems" SYNC_SCOPES = ( - 'NormalItems', - 'NormalAndAssociatedItems', + "NormalItems", + "NormalAndAssociatedItems", ) # Extra change type - READ_FLAG_CHANGE = 'read_flag_change' + READ_FLAG_CHANGE = "read_flag_change" CHANGE_TYPES = SyncFolder.CHANGE_TYPES + (READ_FLAG_CHANGE,) - shape_tag = 'm:ItemShape' - last_in_range_name = f'{{{MNS}}}IncludesLastItemInRange' + shape_tag = "m:ItemShape" + last_in_range_name = f"{{{MNS}}}IncludesLastItemInRange" change_types_map = SyncFolder.change_types_map - change_types_map[f'{{{TNS}}}ReadFlagChange'] = READ_FLAG_CHANGE + change_types_map[f"{{{TNS}}}ReadFlagChange"] = READ_FLAG_CHANGE def call(self, folder, shape, additional_fields, sync_state, ignore, max_changes_returned, sync_scope): self.sync_state = sync_state if max_changes_returned is None: max_changes_returned = self.page_size if not isinstance(max_changes_returned, int): - raise InvalidTypeError('max_changes_returned', max_changes_returned, int) + raise InvalidTypeError("max_changes_returned", max_changes_returned, int) if max_changes_returned <= 0: raise ValueError(f"'max_changes_returned' {max_changes_returned} must be a positive integer") if sync_scope is not None and sync_scope not in self.SYNC_SCOPES: - raise InvalidEnumValue('sync_scope', sync_scope, self.SYNC_SCOPES) - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - folder=folder, - shape=shape, - additional_fields=additional_fields, - sync_state=sync_state, - ignore=ignore, - max_changes_returned=max_changes_returned, - sync_scope=sync_scope, - ))) + raise InvalidEnumValue("sync_scope", sync_scope, self.SYNC_SCOPES) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + folder=folder, + shape=shape, + additional_fields=additional_fields, + sync_state=sync_state, + ignore=ignore, + max_changes_returned=max_changes_returned, + sync_scope=sync_scope, + ) + ) + ) def _elem_to_obj(self, elem): change_type = self.change_types_map[elem.tag] if change_type == self.READ_FLAG_CHANGE: item = ( ItemId.from_xml(elem=elem.find(ItemId.response_tag()), account=self.account), - xml_text_to_value(elem.find(f'{{{TNS}}}IsRead').text, bool) + xml_text_to_value(elem.find(f"{{{TNS}}}IsRead").text, bool), ) elif change_type == self.DELETE: item = ItemId.from_xml(elem=elem.find(ItemId.response_tag()), account=self.account) @@ -5618,10 +5818,10 @@

                        Inherited members

                        ) is_empty, ignore = (True, None) if ignore is None else peek(ignore) if not is_empty: - sync_folder_items.append(item_ids_element(items=ignore, version=self.account.version, tag='m:Ignore')) - add_xml_child(sync_folder_items, 'm:MaxChangesReturned', max_changes_returned) + sync_folder_items.append(item_ids_element(items=ignore, version=self.account.version, tag="m:Ignore")) + add_xml_child(sync_folder_items, "m:MaxChangesReturned", max_changes_returned) if sync_scope: - add_xml_child(sync_folder_items, 'm:SyncScope', sync_scope) + add_xml_child(sync_folder_items, "m:SyncScope", sync_scope) return sync_folder_items

                        Ancestors

                        @@ -5678,20 +5878,24 @@

                        Methods

                        if max_changes_returned is None: max_changes_returned = self.page_size if not isinstance(max_changes_returned, int): - raise InvalidTypeError('max_changes_returned', max_changes_returned, int) + raise InvalidTypeError("max_changes_returned", max_changes_returned, int) if max_changes_returned <= 0: raise ValueError(f"'max_changes_returned' {max_changes_returned} must be a positive integer") if sync_scope is not None and sync_scope not in self.SYNC_SCOPES: - raise InvalidEnumValue('sync_scope', sync_scope, self.SYNC_SCOPES) - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - folder=folder, - shape=shape, - additional_fields=additional_fields, - sync_state=sync_state, - ignore=ignore, - max_changes_returned=max_changes_returned, - sync_scope=sync_scope, - ))) + raise InvalidEnumValue("sync_scope", sync_scope, self.SYNC_SCOPES) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + folder=folder, + shape=shape, + additional_fields=additional_fields, + sync_state=sync_state, + ignore=ignore, + max_changes_returned=max_changes_returned, + sync_scope=sync_scope, + ) + ) + )
                        @@ -5709,10 +5913,10 @@

                        Methods

                        ) is_empty, ignore = (True, None) if ignore is None else peek(ignore) if not is_empty: - sync_folder_items.append(item_ids_element(items=ignore, version=self.account.version, tag='m:Ignore')) - add_xml_child(sync_folder_items, 'm:MaxChangesReturned', max_changes_returned) + sync_folder_items.append(item_ids_element(items=ignore, version=self.account.version, tag="m:Ignore")) + add_xml_child(sync_folder_items, "m:MaxChangesReturned", max_changes_returned) if sync_scope: - add_xml_child(sync_folder_items, 'm:SyncScope', sync_scope) + add_xml_child(sync_folder_items, "m:SyncScope", sync_scope) return sync_folder_items
                        @@ -5746,7 +5950,7 @@

                        Inherited members

                        MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/unsubscribe-operation """ - SERVICE_NAME = 'Unsubscribe' + SERVICE_NAME = "Unsubscribe" returns_elements = False prefer_affinity = True @@ -5754,8 +5958,8 @@

                        Inherited members

                        return self._get_elements(payload=self.get_payload(subscription_id=subscription_id)) def get_payload(self, subscription_id): - payload = create_element(f'm:{self.SERVICE_NAME}') - add_xml_child(payload, 'm:SubscriptionId', subscription_id) + payload = create_element(f"m:{self.SERVICE_NAME}") + add_xml_child(payload, "m:SubscriptionId", subscription_id) return payload

                        Ancestors

                        @@ -5803,8 +6007,8 @@

                        Methods

                        Expand source code
                        def get_payload(self, subscription_id):
                        -    payload = create_element(f'm:{self.SERVICE_NAME}')
                        -    add_xml_child(payload, 'm:SubscriptionId', subscription_id)
                        +    payload = create_element(f"m:{self.SERVICE_NAME}")
                        +    add_xml_child(payload, "m:SubscriptionId", subscription_id)
                             return payload
                        @@ -5834,12 +6038,12 @@

                        Inherited members

                        class UpdateFolder(BaseUpdateService):
                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updatefolder-operation"""
                         
                        -    SERVICE_NAME = 'UpdateFolder'
                        -    SET_FIELD_ELEMENT_NAME = 't:SetFolderField'
                        -    DELETE_FIELD_ELEMENT_NAME = 't:DeleteFolderField'
                        -    CHANGE_ELEMENT_NAME = 't:FolderChange'
                        -    CHANGES_ELEMENT_NAME = 'm:FolderChanges'
                        -    element_container_name = f'{{{MNS}}}Folders'
                        +    SERVICE_NAME = "UpdateFolder"
                        +    SET_FIELD_ELEMENT_NAME = "t:SetFolderField"
                        +    DELETE_FIELD_ELEMENT_NAME = "t:DeleteFolderField"
                        +    CHANGE_ELEMENT_NAME = "t:FolderChange"
                        +    CHANGES_ELEMENT_NAME = "m:FolderChanges"
                        +    element_container_name = f"{{{MNS}}}Folders"
                         
                             def __init__(self, *args, **kwargs):
                                 super().__init__(*args, **kwargs)
                        @@ -5864,7 +6068,7 @@ 

                        Inherited members

                        def get_payload(self, folders): # Takes a list of (Folder, fieldnames) tuples where 'Folder' is a instance of a subclass of Folder and # 'fieldnames' are the attribute names that were updated. - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") payload.append(self._changes_elem(target_changes=folders)) return payload
                        @@ -5931,7 +6135,7 @@

                        Methods

                        def get_payload(self, folders):
                             # Takes a list of (Folder, fieldnames) tuples where 'Folder' is a instance of a subclass of Folder and
                             # 'fieldnames' are the attribute names that were updated.
                        -    payload = create_element(f'm:{self.SERVICE_NAME}')
                        +    payload = create_element(f"m:{self.SERVICE_NAME}")
                             payload.append(self._changes_elem(target_changes=folders))
                             return payload
                        @@ -5962,34 +6166,43 @@

                        Inherited members

                        class UpdateItem(BaseUpdateService):
                             """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem-operation"""
                         
                        -    SERVICE_NAME = 'UpdateItem'
                        -    SET_FIELD_ELEMENT_NAME = 't:SetItemField'
                        -    DELETE_FIELD_ELEMENT_NAME = 't:DeleteItemField'
                        -    CHANGE_ELEMENT_NAME = 't:ItemChange'
                        -    CHANGES_ELEMENT_NAME = 'm:ItemChanges'
                        -    element_container_name = f'{{{MNS}}}Items'
                        -
                        -    def call(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations,
                        -             suppress_read_receipts):
                        +    SERVICE_NAME = "UpdateItem"
                        +    SET_FIELD_ELEMENT_NAME = "t:SetItemField"
                        +    DELETE_FIELD_ELEMENT_NAME = "t:DeleteItemField"
                        +    CHANGE_ELEMENT_NAME = "t:ItemChange"
                        +    CHANGES_ELEMENT_NAME = "m:ItemChanges"
                        +    element_container_name = f"{{{MNS}}}Items"
                        +
                        +    def call(
                        +        self,
                        +        items,
                        +        conflict_resolution,
                        +        message_disposition,
                        +        send_meeting_invitations_or_cancellations,
                        +        suppress_read_receipts,
                        +    ):
                                 if conflict_resolution not in CONFLICT_RESOLUTION_CHOICES:
                        -            raise InvalidEnumValue('conflict_resolution', conflict_resolution, CONFLICT_RESOLUTION_CHOICES)
                        +            raise InvalidEnumValue("conflict_resolution", conflict_resolution, CONFLICT_RESOLUTION_CHOICES)
                                 if message_disposition not in MESSAGE_DISPOSITION_CHOICES:
                        -            raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES)
                        +            raise InvalidEnumValue("message_disposition", message_disposition, MESSAGE_DISPOSITION_CHOICES)
                                 if send_meeting_invitations_or_cancellations not in SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES:
                                     raise InvalidEnumValue(
                        -                'send_meeting_invitations_or_cancellations', send_meeting_invitations_or_cancellations,
                        -                SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES
                        +                "send_meeting_invitations_or_cancellations",
                        +                send_meeting_invitations_or_cancellations,
                        +                SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES,
                                     )
                                 if message_disposition == SEND_ONLY:
                        -            raise ValueError('Cannot send-only existing objects. Use SendItem service instead')
                        -        return self._elems_to_objs(self._chunked_get_elements(
                        -            self.get_payload,
                        -            items=items,
                        -            conflict_resolution=conflict_resolution,
                        -            message_disposition=message_disposition,
                        -            send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations,
                        -            suppress_read_receipts=suppress_read_receipts,
                        -        ))
                        +            raise ValueError("Cannot send-only existing objects. Use SendItem service instead")
                        +        return self._elems_to_objs(
                        +            self._chunked_get_elements(
                        +                self.get_payload,
                        +                items=items,
                        +                conflict_resolution=conflict_resolution,
                        +                message_disposition=message_disposition,
                        +                send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations,
                        +                suppress_read_receipts=suppress_read_receipts,
                        +            )
                        +        )
                         
                             def _elem_to_obj(self, elem):
                                 return Item.id_from_xml(elem)
                        @@ -6000,7 +6213,7 @@ 

                        Inherited members

                        if target.__class__ == CalendarItem: # For CalendarItem items where we update 'start' or 'end', we want to update internal timezone fields target.clean_timezone_fields(version=self.account.version) # Possibly also sets timezone values - for field_name in ('start', 'end'): + for field_name in ("start", "end"): if field_name in fieldnames_copy: tz_field_name = target.tz_field_for_field_name(field_name).name if tz_field_name not in fieldnames_copy: @@ -6013,7 +6226,7 @@

                        Inherited members

                        if target.__class__ == CalendarItem: # For CalendarItem items where we update 'start' or 'end', we want to send values in the local timezone - if field.name in ('start', 'end'): + if field.name in ("start", "end"): if type(value) is EWSDate: # EWS always expects a datetime return target.date_to_datetime(field_name=field.name) @@ -6025,8 +6238,14 @@

                        Inherited members

                        def _target_elem(self, target): return to_item_id(target, ItemId) - def get_payload(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, - suppress_read_receipts): + def get_payload( + self, + items, + conflict_resolution, + message_disposition, + send_meeting_invitations_or_cancellations, + suppress_read_receipts, + ): # Takes a list of (Item, fieldnames) tuples where 'Item' is a instance of a subclass of Item and 'fieldnames' # are the attribute names that were updated. attrs = dict( @@ -6035,8 +6254,8 @@

                        Inherited members

                        SendMeetingInvitationsOrCancellations=send_meeting_invitations_or_cancellations, ) if self.account.version.build >= EXCHANGE_2013_SP1: - attrs['SuppressReadReceipts'] = suppress_read_receipts - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + attrs["SuppressReadReceipts"] = suppress_read_receipts + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=attrs) payload.append(self._changes_elem(target_changes=items)) return payload
                        @@ -6084,27 +6303,36 @@

                        Methods

                        Expand source code -
                        def call(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations,
                        -         suppress_read_receipts):
                        +
                        def call(
                        +    self,
                        +    items,
                        +    conflict_resolution,
                        +    message_disposition,
                        +    send_meeting_invitations_or_cancellations,
                        +    suppress_read_receipts,
                        +):
                             if conflict_resolution not in CONFLICT_RESOLUTION_CHOICES:
                        -        raise InvalidEnumValue('conflict_resolution', conflict_resolution, CONFLICT_RESOLUTION_CHOICES)
                        +        raise InvalidEnumValue("conflict_resolution", conflict_resolution, CONFLICT_RESOLUTION_CHOICES)
                             if message_disposition not in MESSAGE_DISPOSITION_CHOICES:
                        -        raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES)
                        +        raise InvalidEnumValue("message_disposition", message_disposition, MESSAGE_DISPOSITION_CHOICES)
                             if send_meeting_invitations_or_cancellations not in SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES:
                                 raise InvalidEnumValue(
                        -            'send_meeting_invitations_or_cancellations', send_meeting_invitations_or_cancellations,
                        -            SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES
                        +            "send_meeting_invitations_or_cancellations",
                        +            send_meeting_invitations_or_cancellations,
                        +            SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES,
                                 )
                             if message_disposition == SEND_ONLY:
                        -        raise ValueError('Cannot send-only existing objects. Use SendItem service instead')
                        -    return self._elems_to_objs(self._chunked_get_elements(
                        -        self.get_payload,
                        -        items=items,
                        -        conflict_resolution=conflict_resolution,
                        -        message_disposition=message_disposition,
                        -        send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations,
                        -        suppress_read_receipts=suppress_read_receipts,
                        -    ))
                        + raise ValueError("Cannot send-only existing objects. Use SendItem service instead") + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=items, + conflict_resolution=conflict_resolution, + message_disposition=message_disposition, + send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations, + suppress_read_receipts=suppress_read_receipts, + ) + )
                        @@ -6116,8 +6344,14 @@

                        Methods

                        Expand source code -
                        def get_payload(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations,
                        -                suppress_read_receipts):
                        +
                        def get_payload(
                        +    self,
                        +    items,
                        +    conflict_resolution,
                        +    message_disposition,
                        +    send_meeting_invitations_or_cancellations,
                        +    suppress_read_receipts,
                        +):
                             # Takes a list of (Item, fieldnames) tuples where 'Item' is a instance of a subclass of Item and 'fieldnames'
                             # are the attribute names that were updated.
                             attrs = dict(
                        @@ -6126,8 +6360,8 @@ 

                        Methods

                        SendMeetingInvitationsOrCancellations=send_meeting_invitations_or_cancellations, ) if self.account.version.build >= EXCHANGE_2013_SP1: - attrs['SuppressReadReceipts'] = suppress_read_receipts - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + attrs["SuppressReadReceipts"] = suppress_read_receipts + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=attrs) payload.append(self._changes_elem(target_changes=items)) return payload
                        @@ -6161,14 +6395,14 @@

                        Inherited members

                        https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateuserconfiguration-operation """ - SERVICE_NAME = 'UpdateUserConfiguration' + SERVICE_NAME = "UpdateUserConfiguration" returns_elements = False def call(self, user_configuration): return self._get_elements(payload=self.get_payload(user_configuration=user_configuration)) def get_payload(self, user_configuration): - return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), user_configuration, version=self.account.version)
                        + return set_xml_value(create_element(f"m:{self.SERVICE_NAME}"), user_configuration, version=self.account.version)

                        Ancestors

                          @@ -6211,7 +6445,7 @@

                          Methods

                          Expand source code
                          def get_payload(self, user_configuration):
                          -    return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), user_configuration, version=self.account.version)
                          + return set_xml_value(create_element(f"m:{self.SERVICE_NAME}"), user_configuration, version=self.account.version) @@ -6238,11 +6472,10 @@

                          Inherited members

                          Expand source code
                          class UploadItems(EWSAccountService):
                          -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/uploaditems-operation
                          -    """
                          +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/uploaditems-operation"""
                           
                          -    SERVICE_NAME = 'UploadItems'
                          -    element_container_name = f'{{{MNS}}}ItemId'
                          +    SERVICE_NAME = "UploadItems"
                          +    element_container_name = f"{{{MNS}}}ItemId"
                           
                               def call(self, items):
                                   # _pool_requests expects 'items', not 'data'
                          @@ -6258,19 +6491,19 @@ 

                          Inherited members

                          :param items: """ - payload = create_element(f'm:{self.SERVICE_NAME}') - items_elem = create_element('m:Items') + payload = create_element(f"m:{self.SERVICE_NAME}") + items_elem = create_element("m:Items") payload.append(items_elem) for parent_folder, (item_id, is_associated, data_str) in items: # TODO: The full spec also allows the "UpdateOrCreate" create action. - attrs = dict(CreateAction='Update' if item_id else 'CreateNew') + attrs = dict(CreateAction="Update" if item_id else "CreateNew") if is_associated is not None: - attrs['IsAssociated'] = is_associated - item = create_element('t:Item', attrs=attrs) + attrs["IsAssociated"] = is_associated + item = create_element("t:Item", attrs=attrs) set_xml_value(item, ParentFolderId(parent_folder.id, parent_folder.changekey), version=self.account.version) if item_id: set_xml_value(item, to_item_id(item_id, ItemId), version=self.account.version) - add_xml_child(item, 't:Data', data_str) + add_xml_child(item, "t:Data", data_str) items_elem.append(item) return payload @@ -6337,19 +6570,19 @@

                          Methods

                          :param items: """ - payload = create_element(f'm:{self.SERVICE_NAME}') - items_elem = create_element('m:Items') + payload = create_element(f"m:{self.SERVICE_NAME}") + items_elem = create_element("m:Items") payload.append(items_elem) for parent_folder, (item_id, is_associated, data_str) in items: # TODO: The full spec also allows the "UpdateOrCreate" create action. - attrs = dict(CreateAction='Update' if item_id else 'CreateNew') + attrs = dict(CreateAction="Update" if item_id else "CreateNew") if is_associated is not None: - attrs['IsAssociated'] = is_associated - item = create_element('t:Item', attrs=attrs) + attrs["IsAssociated"] = is_associated + item = create_element("t:Item", attrs=attrs) set_xml_value(item, ParentFolderId(parent_folder.id, parent_folder.changekey), version=self.account.version) if item_id: set_xml_value(item, to_item_id(item_id, ItemId), version=self.account.version) - add_xml_child(item, 't:Data', data_str) + add_xml_child(item, "t:Data", data_str) items_elem.append(item) return payload
                          diff --git a/docs/exchangelib/services/mark_as_junk.html b/docs/exchangelib/services/mark_as_junk.html index 82afdea9..ce00d979 100644 --- a/docs/exchangelib/services/mark_as_junk.html +++ b/docs/exchangelib/services/mark_as_junk.html @@ -26,15 +26,15 @@

                          Module exchangelib.services.mark_as_junk

                          Expand source code -
                          from .common import EWSAccountService, item_ids_element
                          -from ..properties import MovedItemId
                          +
                          from ..properties import MovedItemId
                           from ..util import create_element
                          +from .common import EWSAccountService, item_ids_element
                           
                           
                           class MarkAsJunk(EWSAccountService):
                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/markasjunk-operation"""
                           
                          -    SERVICE_NAME = 'MarkAsJunk'
                          +    SERVICE_NAME = "MarkAsJunk"
                           
                               def call(self, items, is_junk, move_item):
                                   return self._elems_to_objs(
                          @@ -50,7 +50,7 @@ 

                          Module exchangelib.services.mark_as_junk

                          def get_payload(self, items, is_junk, move_item): # Takes a list of items and returns either success or raises an error message - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IsJunk=is_junk, MoveItem=move_item)) + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(IsJunk=is_junk, MoveItem=move_item)) payload.append(item_ids_element(items=items, version=self.account.version)) return payload
                          @@ -77,7 +77,7 @@

                          Classes

                          class MarkAsJunk(EWSAccountService):
                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/markasjunk-operation"""
                           
                          -    SERVICE_NAME = 'MarkAsJunk'
                          +    SERVICE_NAME = "MarkAsJunk"
                           
                               def call(self, items, is_junk, move_item):
                                   return self._elems_to_objs(
                          @@ -93,7 +93,7 @@ 

                          Classes

                          def get_payload(self, items, is_junk, move_item): # Takes a list of items and returns either success or raises an error message - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IsJunk=is_junk, MoveItem=move_item)) + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(IsJunk=is_junk, MoveItem=move_item)) payload.append(item_ids_element(items=items, version=self.account.version)) return payload
                          @@ -137,7 +137,7 @@

                          Methods

                          def get_payload(self, items, is_junk, move_item):
                               # Takes a list of items and returns either success or raises an error message
                          -    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(IsJunk=is_junk, MoveItem=move_item))
                          +    payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(IsJunk=is_junk, MoveItem=move_item))
                               payload.append(item_ids_element(items=items, version=self.account.version))
                               return payload
                          diff --git a/docs/exchangelib/services/move_folder.html b/docs/exchangelib/services/move_folder.html index 1eb2fec8..eb45bd1e 100644 --- a/docs/exchangelib/services/move_folder.html +++ b/docs/exchangelib/services/move_folder.html @@ -26,22 +26,22 @@

                          Module exchangelib.services.move_folder

                          Expand source code -
                          from .common import EWSAccountService, folder_ids_element
                          -from ..errors import InvalidTypeError
                          +
                          from ..errors import InvalidTypeError
                           from ..folders import BaseFolder
                           from ..properties import FolderId
                          -from ..util import create_element, MNS
                          +from ..util import MNS, create_element
                          +from .common import EWSAccountService, folder_ids_element
                           
                           
                           class MoveFolder(EWSAccountService):
                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/movefolder-operation"""
                           
                               SERVICE_NAME = "MoveFolder"
                          -    element_container_name = f'{{{MNS}}}Folders'
                          +    element_container_name = f"{{{MNS}}}Folders"
                           
                               def call(self, folders, to_folder):
                                   if not isinstance(to_folder, (BaseFolder, FolderId)):
                          -            raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId))
                          +            raise InvalidTypeError("to_folder", to_folder, (BaseFolder, FolderId))
                                   return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=folders, to_folder=to_folder))
                           
                               def _elem_to_obj(self, elem):
                          @@ -49,8 +49,8 @@ 

                          Module exchangelib.services.move_folder

                          def get_payload(self, folders, to_folder): # Takes a list of folders and returns their new folder IDs - payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) + payload = create_element(f"m:{self.SERVICE_NAME}") + payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag="m:ToFolderId")) payload.append(folder_ids_element(folders=folders, version=self.account.version)) return payload
                          @@ -78,11 +78,11 @@

                          Classes

                          """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/movefolder-operation""" SERVICE_NAME = "MoveFolder" - element_container_name = f'{{{MNS}}}Folders' + element_container_name = f"{{{MNS}}}Folders" def call(self, folders, to_folder): if not isinstance(to_folder, (BaseFolder, FolderId)): - raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId)) + raise InvalidTypeError("to_folder", to_folder, (BaseFolder, FolderId)) return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=folders, to_folder=to_folder)) def _elem_to_obj(self, elem): @@ -90,8 +90,8 @@

                          Classes

                          def get_payload(self, folders, to_folder): # Takes a list of folders and returns their new folder IDs - payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) + payload = create_element(f"m:{self.SERVICE_NAME}") + payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag="m:ToFolderId")) payload.append(folder_ids_element(folders=folders, version=self.account.version)) return payload
                          @@ -124,7 +124,7 @@

                          Methods

                          def call(self, folders, to_folder):
                               if not isinstance(to_folder, (BaseFolder, FolderId)):
                          -        raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId))
                          +        raise InvalidTypeError("to_folder", to_folder, (BaseFolder, FolderId))
                               return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=folders, to_folder=to_folder))
                          @@ -139,8 +139,8 @@

                          Methods

                          def get_payload(self, folders, to_folder):
                               # Takes a list of folders and returns their new folder IDs
                          -    payload = create_element(f'm:{self.SERVICE_NAME}')
                          -    payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId'))
                          +    payload = create_element(f"m:{self.SERVICE_NAME}")
                          +    payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag="m:ToFolderId"))
                               payload.append(folder_ids_element(folders=folders, version=self.account.version))
                               return payload
                          diff --git a/docs/exchangelib/services/move_item.html b/docs/exchangelib/services/move_item.html index 8fc63570..910da7ac 100644 --- a/docs/exchangelib/services/move_item.html +++ b/docs/exchangelib/services/move_item.html @@ -26,23 +26,23 @@

                          Module exchangelib.services.move_item

                          Expand source code -
                          from .common import EWSAccountService, item_ids_element, folder_ids_element
                          -from ..errors import InvalidTypeError
                          +
                          from ..errors import InvalidTypeError
                           from ..folders import BaseFolder
                           from ..items import Item
                           from ..properties import FolderId
                          -from ..util import create_element, MNS
                          +from ..util import MNS, create_element
                          +from .common import EWSAccountService, folder_ids_element, item_ids_element
                           
                           
                           class MoveItem(EWSAccountService):
                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/moveitem-operation"""
                           
                          -    SERVICE_NAME = 'MoveItem'
                          -    element_container_name = f'{{{MNS}}}Items'
                          +    SERVICE_NAME = "MoveItem"
                          +    element_container_name = f"{{{MNS}}}Items"
                           
                               def call(self, items, to_folder):
                                   if not isinstance(to_folder, (BaseFolder, FolderId)):
                          -            raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId))
                          +            raise InvalidTypeError("to_folder", to_folder, (BaseFolder, FolderId))
                                   return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, to_folder=to_folder))
                           
                               def _elem_to_obj(self, elem):
                          @@ -50,8 +50,8 @@ 

                          Module exchangelib.services.move_item

                          def get_payload(self, items, to_folder): # Takes a list of items and returns their new item IDs - payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) + payload = create_element(f"m:{self.SERVICE_NAME}") + payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag="m:ToFolderId")) payload.append(item_ids_element(items=items, version=self.account.version)) return payload
                          @@ -78,12 +78,12 @@

                          Classes

                          class MoveItem(EWSAccountService):
                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/moveitem-operation"""
                           
                          -    SERVICE_NAME = 'MoveItem'
                          -    element_container_name = f'{{{MNS}}}Items'
                          +    SERVICE_NAME = "MoveItem"
                          +    element_container_name = f"{{{MNS}}}Items"
                           
                               def call(self, items, to_folder):
                                   if not isinstance(to_folder, (BaseFolder, FolderId)):
                          -            raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId))
                          +            raise InvalidTypeError("to_folder", to_folder, (BaseFolder, FolderId))
                                   return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, to_folder=to_folder))
                           
                               def _elem_to_obj(self, elem):
                          @@ -91,8 +91,8 @@ 

                          Classes

                          def get_payload(self, items, to_folder): # Takes a list of items and returns their new item IDs - payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId')) + payload = create_element(f"m:{self.SERVICE_NAME}") + payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag="m:ToFolderId")) payload.append(item_ids_element(items=items, version=self.account.version)) return payload
                          @@ -129,7 +129,7 @@

                          Methods

                          def call(self, items, to_folder):
                               if not isinstance(to_folder, (BaseFolder, FolderId)):
                          -        raise InvalidTypeError('to_folder', to_folder, (BaseFolder, FolderId))
                          +        raise InvalidTypeError("to_folder", to_folder, (BaseFolder, FolderId))
                               return self._elems_to_objs(self._chunked_get_elements(self.get_payload, items=items, to_folder=to_folder))
                          @@ -144,8 +144,8 @@

                          Methods

                          def get_payload(self, items, to_folder):
                               # Takes a list of items and returns their new item IDs
                          -    payload = create_element(f'm:{self.SERVICE_NAME}')
                          -    payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag='m:ToFolderId'))
                          +    payload = create_element(f"m:{self.SERVICE_NAME}")
                          +    payload.append(folder_ids_element(folders=[to_folder], version=self.account.version, tag="m:ToFolderId"))
                               payload.append(item_ids_element(items=items, version=self.account.version))
                               return payload
                          diff --git a/docs/exchangelib/services/resolve_names.html b/docs/exchangelib/services/resolve_names.html index b3ebbfab..8a998907 100644 --- a/docs/exchangelib/services/resolve_names.html +++ b/docs/exchangelib/services/resolve_names.html @@ -28,12 +28,12 @@

                          Module exchangelib.services.resolve_names

                          import logging
                           
                          -from .common import EWSService, folder_ids_element
                          -from ..errors import ErrorNameResolutionNoResults, ErrorNameResolutionMultipleResults, InvalidEnumValue
                          -from ..items import SHAPE_CHOICES, SEARCH_SCOPE_CHOICES, Contact
                          +from ..errors import ErrorNameResolutionMultipleResults, ErrorNameResolutionNoResults, InvalidEnumValue
                          +from ..items import SEARCH_SCOPE_CHOICES, SHAPE_CHOICES, Contact
                           from ..properties import Mailbox
                          -from ..util import create_element, add_xml_child, MNS
                          +from ..util import MNS, add_xml_child, create_element
                           from ..version import EXCHANGE_2010_SP2
                          +from .common import EWSService, folder_ids_element
                           
                           log = logging.getLogger(__name__)
                           
                          @@ -41,8 +41,8 @@ 

                          Module exchangelib.services.resolve_names

                          class ResolveNames(EWSService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/resolvenames-operation""" - SERVICE_NAME = 'ResolveNames' - element_container_name = f'{{{MNS}}}ResolutionSet' + SERVICE_NAME = "ResolveNames" + element_container_name = f"{{{MNS}}}ResolutionSet" ERRORS_TO_CATCH_IN_RESPONSE = ErrorNameResolutionNoResults WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults # Note: paging information is returned as attrs on the 'ResolutionSet' element, but this service does not @@ -54,26 +54,34 @@

                          Module exchangelib.services.resolve_names

                          super().__init__(*args, **kwargs) self.return_full_contact_data = False # A hack to communicate parsing args to _elems_to_objs() - def call(self, unresolved_entries, parent_folders=None, return_full_contact_data=False, search_scope=None, - contact_data_shape=None): + def call( + self, + unresolved_entries, + parent_folders=None, + return_full_contact_data=False, + search_scope=None, + contact_data_shape=None, + ): if self.chunk_size > 100: raise ValueError( - f'Chunk size {self.chunk_size} is too high. {self.SERVICE_NAME} supports returning at most 100 ' - f'candidates for a lookup', + f"Chunk size {self.chunk_size} is too high. {self.SERVICE_NAME} supports returning at most 100 " + f"candidates for a lookup", ) if search_scope and search_scope not in SEARCH_SCOPE_CHOICES: - raise InvalidEnumValue('search_scope', search_scope, SEARCH_SCOPE_CHOICES) + raise InvalidEnumValue("search_scope", search_scope, SEARCH_SCOPE_CHOICES) if contact_data_shape and contact_data_shape not in SHAPE_CHOICES: - raise InvalidEnumValue('contact_data_shape', contact_data_shape, SHAPE_CHOICES) + raise InvalidEnumValue("contact_data_shape", contact_data_shape, SHAPE_CHOICES) self.return_full_contact_data = return_full_contact_data - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, - items=unresolved_entries, - parent_folders=parent_folders, - return_full_contact_data=return_full_contact_data, - search_scope=search_scope, - contact_data_shape=contact_data_shape, - )) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=unresolved_entries, + parent_folders=parent_folders, + return_full_contact_data=return_full_contact_data, + search_scope=search_scope, + contact_data_shape=contact_data_shape, + ) + ) def _elem_to_obj(self, elem): if self.return_full_contact_data: @@ -85,23 +93,25 @@

                          Module exchangelib.services.resolve_names

                          ) return Mailbox.from_xml(elem=elem.find(Mailbox.response_tag()), account=None) - def get_payload(self, unresolved_entries, parent_folders, return_full_contact_data, search_scope, - contact_data_shape): + def get_payload( + self, unresolved_entries, parent_folders, return_full_contact_data, search_scope, contact_data_shape + ): attrs = dict(ReturnFullContactData=return_full_contact_data) if search_scope: - attrs['SearchScope'] = search_scope + attrs["SearchScope"] = search_scope if contact_data_shape: if self.protocol.version.build < EXCHANGE_2010_SP2: raise NotImplementedError( - "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later") - attrs['ContactDataShape'] = contact_data_shape - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later" + ) + attrs["ContactDataShape"] = contact_data_shape + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=attrs) if parent_folders: - payload.append(folder_ids_element( - folders=parent_folders, version=self.protocol.version, tag='m:ParentFolderIds' - )) + payload.append( + folder_ids_element(folders=parent_folders, version=self.protocol.version, tag="m:ParentFolderIds") + ) for entry in unresolved_entries: - add_xml_child(payload, 'm:UnresolvedEntry', entry) + add_xml_child(payload, "m:UnresolvedEntry", entry) return payload
                          @@ -127,8 +137,8 @@

                          Classes

                          class ResolveNames(EWSService):
                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/resolvenames-operation"""
                           
                          -    SERVICE_NAME = 'ResolveNames'
                          -    element_container_name = f'{{{MNS}}}ResolutionSet'
                          +    SERVICE_NAME = "ResolveNames"
                          +    element_container_name = f"{{{MNS}}}ResolutionSet"
                               ERRORS_TO_CATCH_IN_RESPONSE = ErrorNameResolutionNoResults
                               WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults
                               # Note: paging information is returned as attrs on the 'ResolutionSet' element, but this service does not
                          @@ -140,26 +150,34 @@ 

                          Classes

                          super().__init__(*args, **kwargs) self.return_full_contact_data = False # A hack to communicate parsing args to _elems_to_objs() - def call(self, unresolved_entries, parent_folders=None, return_full_contact_data=False, search_scope=None, - contact_data_shape=None): + def call( + self, + unresolved_entries, + parent_folders=None, + return_full_contact_data=False, + search_scope=None, + contact_data_shape=None, + ): if self.chunk_size > 100: raise ValueError( - f'Chunk size {self.chunk_size} is too high. {self.SERVICE_NAME} supports returning at most 100 ' - f'candidates for a lookup', + f"Chunk size {self.chunk_size} is too high. {self.SERVICE_NAME} supports returning at most 100 " + f"candidates for a lookup", ) if search_scope and search_scope not in SEARCH_SCOPE_CHOICES: - raise InvalidEnumValue('search_scope', search_scope, SEARCH_SCOPE_CHOICES) + raise InvalidEnumValue("search_scope", search_scope, SEARCH_SCOPE_CHOICES) if contact_data_shape and contact_data_shape not in SHAPE_CHOICES: - raise InvalidEnumValue('contact_data_shape', contact_data_shape, SHAPE_CHOICES) + raise InvalidEnumValue("contact_data_shape", contact_data_shape, SHAPE_CHOICES) self.return_full_contact_data = return_full_contact_data - return self._elems_to_objs(self._chunked_get_elements( - self.get_payload, - items=unresolved_entries, - parent_folders=parent_folders, - return_full_contact_data=return_full_contact_data, - search_scope=search_scope, - contact_data_shape=contact_data_shape, - )) + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=unresolved_entries, + parent_folders=parent_folders, + return_full_contact_data=return_full_contact_data, + search_scope=search_scope, + contact_data_shape=contact_data_shape, + ) + ) def _elem_to_obj(self, elem): if self.return_full_contact_data: @@ -171,23 +189,25 @@

                          Classes

                          ) return Mailbox.from_xml(elem=elem.find(Mailbox.response_tag()), account=None) - def get_payload(self, unresolved_entries, parent_folders, return_full_contact_data, search_scope, - contact_data_shape): + def get_payload( + self, unresolved_entries, parent_folders, return_full_contact_data, search_scope, contact_data_shape + ): attrs = dict(ReturnFullContactData=return_full_contact_data) if search_scope: - attrs['SearchScope'] = search_scope + attrs["SearchScope"] = search_scope if contact_data_shape: if self.protocol.version.build < EXCHANGE_2010_SP2: raise NotImplementedError( - "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later") - attrs['ContactDataShape'] = contact_data_shape - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later" + ) + attrs["ContactDataShape"] = contact_data_shape + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=attrs) if parent_folders: - payload.append(folder_ids_element( - folders=parent_folders, version=self.protocol.version, tag='m:ParentFolderIds' - )) + payload.append( + folder_ids_element(folders=parent_folders, version=self.protocol.version, tag="m:ParentFolderIds") + ) for entry in unresolved_entries: - add_xml_child(payload, 'm:UnresolvedEntry', entry) + add_xml_child(payload, "m:UnresolvedEntry", entry) return payload

                          Ancestors

                          @@ -228,26 +248,34 @@

                          Methods

                          Expand source code -
                          def call(self, unresolved_entries, parent_folders=None, return_full_contact_data=False, search_scope=None,
                          -         contact_data_shape=None):
                          +
                          def call(
                          +    self,
                          +    unresolved_entries,
                          +    parent_folders=None,
                          +    return_full_contact_data=False,
                          +    search_scope=None,
                          +    contact_data_shape=None,
                          +):
                               if self.chunk_size > 100:
                                   raise ValueError(
                          -            f'Chunk size {self.chunk_size} is too high. {self.SERVICE_NAME} supports returning at most 100 '
                          -            f'candidates for a lookup',
                          +            f"Chunk size {self.chunk_size} is too high. {self.SERVICE_NAME} supports returning at most 100 "
                          +            f"candidates for a lookup",
                                   )
                               if search_scope and search_scope not in SEARCH_SCOPE_CHOICES:
                          -        raise InvalidEnumValue('search_scope', search_scope, SEARCH_SCOPE_CHOICES)
                          +        raise InvalidEnumValue("search_scope", search_scope, SEARCH_SCOPE_CHOICES)
                               if contact_data_shape and contact_data_shape not in SHAPE_CHOICES:
                          -        raise InvalidEnumValue('contact_data_shape', contact_data_shape, SHAPE_CHOICES)
                          +        raise InvalidEnumValue("contact_data_shape", contact_data_shape, SHAPE_CHOICES)
                               self.return_full_contact_data = return_full_contact_data
                          -    return self._elems_to_objs(self._chunked_get_elements(
                          -        self.get_payload,
                          -        items=unresolved_entries,
                          -        parent_folders=parent_folders,
                          -        return_full_contact_data=return_full_contact_data,
                          -        search_scope=search_scope,
                          -        contact_data_shape=contact_data_shape,
                          -    ))
                          + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=unresolved_entries, + parent_folders=parent_folders, + return_full_contact_data=return_full_contact_data, + search_scope=search_scope, + contact_data_shape=contact_data_shape, + ) + )
                          @@ -259,23 +287,25 @@

                          Methods

                          Expand source code -
                          def get_payload(self, unresolved_entries, parent_folders, return_full_contact_data, search_scope,
                          -                contact_data_shape):
                          +
                          def get_payload(
                          +    self, unresolved_entries, parent_folders, return_full_contact_data, search_scope, contact_data_shape
                          +):
                               attrs = dict(ReturnFullContactData=return_full_contact_data)
                               if search_scope:
                          -        attrs['SearchScope'] = search_scope
                          +        attrs["SearchScope"] = search_scope
                               if contact_data_shape:
                                   if self.protocol.version.build < EXCHANGE_2010_SP2:
                                       raise NotImplementedError(
                          -                "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later")
                          -        attrs['ContactDataShape'] = contact_data_shape
                          -    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs)
                          +                "'contact_data_shape' is only supported for Exchange 2010 SP2 servers and later"
                          +            )
                          +        attrs["ContactDataShape"] = contact_data_shape
                          +    payload = create_element(f"m:{self.SERVICE_NAME}", attrs=attrs)
                               if parent_folders:
                          -        payload.append(folder_ids_element(
                          -            folders=parent_folders, version=self.protocol.version, tag='m:ParentFolderIds'
                          -        ))
                          +        payload.append(
                          +            folder_ids_element(folders=parent_folders, version=self.protocol.version, tag="m:ParentFolderIds")
                          +        )
                               for entry in unresolved_entries:
                          -        add_xml_child(payload, 'm:UnresolvedEntry', entry)
                          +        add_xml_child(payload, "m:UnresolvedEntry", entry)
                               return payload
                          diff --git a/docs/exchangelib/services/send_item.html b/docs/exchangelib/services/send_item.html index 590243e4..2d60609c 100644 --- a/docs/exchangelib/services/send_item.html +++ b/docs/exchangelib/services/send_item.html @@ -26,31 +26,31 @@

                          Module exchangelib.services.send_item

                          Expand source code -
                          from .common import EWSAccountService, item_ids_element, folder_ids_element
                          -from ..errors import InvalidTypeError
                          +
                          from ..errors import InvalidTypeError
                           from ..folders import BaseFolder
                           from ..properties import FolderId
                           from ..util import create_element
                          +from .common import EWSAccountService, folder_ids_element, item_ids_element
                           
                           
                           class SendItem(EWSAccountService):
                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/senditem-operation"""
                           
                          -    SERVICE_NAME = 'SendItem'
                          +    SERVICE_NAME = "SendItem"
                               returns_elements = False
                           
                               def call(self, items, saved_item_folder):
                                   if saved_item_folder and not isinstance(saved_item_folder, (BaseFolder, FolderId)):
                          -            raise InvalidTypeError('saved_item_folder', saved_item_folder, (BaseFolder, FolderId))
                          +            raise InvalidTypeError("saved_item_folder", saved_item_folder, (BaseFolder, FolderId))
                                   return self._chunked_get_elements(self.get_payload, items=items, saved_item_folder=saved_item_folder)
                           
                               def get_payload(self, items, saved_item_folder):
                          -        payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(SaveItemToFolder=bool(saved_item_folder)))
                          +        payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(SaveItemToFolder=bool(saved_item_folder)))
                                   payload.append(item_ids_element(items=items, version=self.account.version))
                                   if saved_item_folder:
                          -            payload.append(folder_ids_element(
                          -                folders=[saved_item_folder], version=self.account.version, tag='m:SavedItemFolderId'
                          -            ))
                          +            payload.append(
                          +                folder_ids_element(folders=[saved_item_folder], version=self.account.version, tag="m:SavedItemFolderId")
                          +            )
                                   return payload
                          @@ -76,21 +76,21 @@

                          Classes

                          class SendItem(EWSAccountService):
                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/senditem-operation"""
                           
                          -    SERVICE_NAME = 'SendItem'
                          +    SERVICE_NAME = "SendItem"
                               returns_elements = False
                           
                               def call(self, items, saved_item_folder):
                                   if saved_item_folder and not isinstance(saved_item_folder, (BaseFolder, FolderId)):
                          -            raise InvalidTypeError('saved_item_folder', saved_item_folder, (BaseFolder, FolderId))
                          +            raise InvalidTypeError("saved_item_folder", saved_item_folder, (BaseFolder, FolderId))
                                   return self._chunked_get_elements(self.get_payload, items=items, saved_item_folder=saved_item_folder)
                           
                               def get_payload(self, items, saved_item_folder):
                          -        payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(SaveItemToFolder=bool(saved_item_folder)))
                          +        payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(SaveItemToFolder=bool(saved_item_folder)))
                                   payload.append(item_ids_element(items=items, version=self.account.version))
                                   if saved_item_folder:
                          -            payload.append(folder_ids_element(
                          -                folders=[saved_item_folder], version=self.account.version, tag='m:SavedItemFolderId'
                          -            ))
                          +            payload.append(
                          +                folder_ids_element(folders=[saved_item_folder], version=self.account.version, tag="m:SavedItemFolderId")
                          +            )
                                   return payload

                          Ancestors

                          @@ -122,7 +122,7 @@

                          Methods

                          def call(self, items, saved_item_folder):
                               if saved_item_folder and not isinstance(saved_item_folder, (BaseFolder, FolderId)):
                          -        raise InvalidTypeError('saved_item_folder', saved_item_folder, (BaseFolder, FolderId))
                          +        raise InvalidTypeError("saved_item_folder", saved_item_folder, (BaseFolder, FolderId))
                               return self._chunked_get_elements(self.get_payload, items=items, saved_item_folder=saved_item_folder)
                          @@ -136,12 +136,12 @@

                          Methods

                          Expand source code
                          def get_payload(self, items, saved_item_folder):
                          -    payload = create_element(f'm:{self.SERVICE_NAME}', attrs=dict(SaveItemToFolder=bool(saved_item_folder)))
                          +    payload = create_element(f"m:{self.SERVICE_NAME}", attrs=dict(SaveItemToFolder=bool(saved_item_folder)))
                               payload.append(item_ids_element(items=items, version=self.account.version))
                               if saved_item_folder:
                          -        payload.append(folder_ids_element(
                          -            folders=[saved_item_folder], version=self.account.version, tag='m:SavedItemFolderId'
                          -        ))
                          +        payload.append(
                          +            folder_ids_element(folders=[saved_item_folder], version=self.account.version, tag="m:SavedItemFolderId")
                          +        )
                               return payload
                          diff --git a/docs/exchangelib/services/send_notification.html b/docs/exchangelib/services/send_notification.html index 665754f1..fa0da538 100644 --- a/docs/exchangelib/services/send_notification.html +++ b/docs/exchangelib/services/send_notification.html @@ -26,11 +26,11 @@

                          Module exchangelib.services.send_notification

                          Expand source code -
                          from .common import EWSService, add_xml_child
                          -from ..errors import InvalidEnumValue
                          +
                          from ..errors import InvalidEnumValue
                           from ..properties import Notification
                           from ..transport import wrap
                          -from ..util import create_element, MNS
                          +from ..util import MNS, create_element
                          +from .common import EWSService, add_xml_child
                           
                           
                           class SendNotification(EWSService):
                          @@ -41,9 +41,9 @@ 

                          Module exchangelib.services.send_notification

                          Module exchangelib.services.send_notification

    Module exchangelib.services.send_notification @@ -102,9 +102,9 @@

    Classes

    push notifications. """ - SERVICE_NAME = 'SendNotification' - OK = 'OK' - UNSUBSCRIBE = 'Unsubscribe' + SERVICE_NAME = "SendNotification" + OK = "OK" + UNSUBSCRIBE = "Unsubscribe" STATUS_CHOICES = (OK, UNSUBSCRIBE) def ok_payload(self): @@ -119,7 +119,7 @@

    Classes

    @classmethod def _response_tag(cls): """Return the name of the element containing the service response.""" - return f'{{{MNS}}}{cls.SERVICE_NAME}' + return f"{{{MNS}}}{cls.SERVICE_NAME}" @classmethod def _get_elements_in_container(cls, container): @@ -127,9 +127,9 @@

    Classes

    def get_payload(self, status): if status not in self.STATUS_CHOICES: - raise InvalidEnumValue('status', status, self.STATUS_CHOICES) - payload = create_element(f'm:{self.SERVICE_NAME}Result') - add_xml_child(payload, 'm:SubscriptionStatus', status) + raise InvalidEnumValue("status", status, self.STATUS_CHOICES) + payload = create_element(f"m:{self.SERVICE_NAME}Result") + add_xml_child(payload, "m:SubscriptionStatus", status) return payload

    Ancestors

    @@ -168,9 +168,9 @@

    Methods

    def get_payload(self, status):
         if status not in self.STATUS_CHOICES:
    -        raise InvalidEnumValue('status', status, self.STATUS_CHOICES)
    -    payload = create_element(f'm:{self.SERVICE_NAME}Result')
    -    add_xml_child(payload, 'm:SubscriptionStatus', status)
    +        raise InvalidEnumValue("status", status, self.STATUS_CHOICES)
    +    payload = create_element(f"m:{self.SERVICE_NAME}Result")
    +    add_xml_child(payload, "m:SubscriptionStatus", status)
         return payload
    diff --git a/docs/exchangelib/services/set_user_oof_settings.html b/docs/exchangelib/services/set_user_oof_settings.html index 67d430b3..22a03525 100644 --- a/docs/exchangelib/services/set_user_oof_settings.html +++ b/docs/exchangelib/services/set_user_oof_settings.html @@ -26,11 +26,11 @@

    Module exchangelib.services.set_user_oof_settings Expand source code -
    from .common import EWSAccountService
    -from ..errors import InvalidTypeError
    +
    from ..errors import InvalidTypeError
     from ..properties import AvailabilityMailbox, Mailbox
     from ..settings import OofSettings
    -from ..util import create_element, set_xml_value, MNS
    +from ..util import MNS, create_element, set_xml_value
    +from .common import EWSAccountService
     
     
     class SetUserOofSettings(EWSAccountService):
    @@ -38,18 +38,18 @@ 

    Module exchangelib.services.set_user_oof_settings MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/setuseroofsettings-operation """ - SERVICE_NAME = 'SetUserOofSettings' + SERVICE_NAME = "SetUserOofSettings" returns_elements = False def call(self, oof_settings, mailbox): if not isinstance(oof_settings, OofSettings): - raise InvalidTypeError('oof_settings', oof_settings, OofSettings) + raise InvalidTypeError("oof_settings", oof_settings, OofSettings) if not isinstance(mailbox, Mailbox): - raise InvalidTypeError('mailbox', mailbox, Mailbox) + raise InvalidTypeError("mailbox", mailbox, Mailbox) return self._get_elements(payload=self.get_payload(oof_settings=oof_settings, mailbox=mailbox)) def get_payload(self, oof_settings, mailbox): - payload = create_element(f'm:{self.SERVICE_NAME}Request') + payload = create_element(f"m:{self.SERVICE_NAME}Request") set_xml_value(payload, AvailabilityMailbox.from_mailbox(mailbox), version=self.account.version) return set_xml_value(payload, oof_settings, version=self.account.version) @@ -59,7 +59,7 @@

    Module exchangelib.services.set_user_oof_settings @classmethod def _response_message_tag(cls): - return f'{{{MNS}}}ResponseMessage'

    + return f"{{{MNS}}}ResponseMessage"
    @@ -87,18 +87,18 @@

    Classes

    MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/setuseroofsettings-operation """ - SERVICE_NAME = 'SetUserOofSettings' + SERVICE_NAME = "SetUserOofSettings" returns_elements = False def call(self, oof_settings, mailbox): if not isinstance(oof_settings, OofSettings): - raise InvalidTypeError('oof_settings', oof_settings, OofSettings) + raise InvalidTypeError("oof_settings", oof_settings, OofSettings) if not isinstance(mailbox, Mailbox): - raise InvalidTypeError('mailbox', mailbox, Mailbox) + raise InvalidTypeError("mailbox", mailbox, Mailbox) return self._get_elements(payload=self.get_payload(oof_settings=oof_settings, mailbox=mailbox)) def get_payload(self, oof_settings, mailbox): - payload = create_element(f'm:{self.SERVICE_NAME}Request') + payload = create_element(f"m:{self.SERVICE_NAME}Request") set_xml_value(payload, AvailabilityMailbox.from_mailbox(mailbox), version=self.account.version) return set_xml_value(payload, oof_settings, version=self.account.version) @@ -108,7 +108,7 @@

    Classes

    @classmethod def _response_message_tag(cls): - return f'{{{MNS}}}ResponseMessage'
    + return f"{{{MNS}}}ResponseMessage"

    Ancestors

      @@ -139,9 +139,9 @@

      Methods

      def call(self, oof_settings, mailbox):
           if not isinstance(oof_settings, OofSettings):
      -        raise InvalidTypeError('oof_settings', oof_settings, OofSettings)
      +        raise InvalidTypeError("oof_settings", oof_settings, OofSettings)
           if not isinstance(mailbox, Mailbox):
      -        raise InvalidTypeError('mailbox', mailbox, Mailbox)
      +        raise InvalidTypeError("mailbox", mailbox, Mailbox)
           return self._get_elements(payload=self.get_payload(oof_settings=oof_settings, mailbox=mailbox))
      @@ -155,7 +155,7 @@

      Methods

      Expand source code
      def get_payload(self, oof_settings, mailbox):
      -    payload = create_element(f'm:{self.SERVICE_NAME}Request')
      +    payload = create_element(f"m:{self.SERVICE_NAME}Request")
           set_xml_value(payload, AvailabilityMailbox.from_mailbox(mailbox), version=self.account.version)
           return set_xml_value(payload, oof_settings, version=self.account.version)
      diff --git a/docs/exchangelib/services/subscribe.html b/docs/exchangelib/services/subscribe.html index d896db2f..0a470bca 100644 --- a/docs/exchangelib/services/subscribe.html +++ b/docs/exchangelib/services/subscribe.html @@ -34,31 +34,31 @@

      Module exchangelib.services.subscribe

      """ import abc -from .common import EWSAccountService, folder_ids_element, add_xml_child -from ..util import create_element, MNS +from ..util import MNS, create_element +from .common import EWSAccountService, add_xml_child, folder_ids_element class Subscribe(EWSAccountService, metaclass=abc.ABCMeta): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/subscribe-operation""" - SERVICE_NAME = 'Subscribe' + SERVICE_NAME = "Subscribe" EVENT_TYPES = ( - 'CopiedEvent', - 'CreatedEvent', - 'DeletedEvent', - 'ModifiedEvent', - 'MovedEvent', - 'NewMailEvent', - 'FreeBusyChangedEvent', + "CopiedEvent", + "CreatedEvent", + "DeletedEvent", + "ModifiedEvent", + "MovedEvent", + "NewMailEvent", + "FreeBusyChangedEvent", ) subscription_request_elem_tag = None def _partial_call(self, payload_func, folders, event_types, **kwargs): if set(event_types) - set(self.EVENT_TYPES): raise ValueError(f"'event_types' values must consist of values in {self.EVENT_TYPES}") - return self._elems_to_objs(self._get_elements( - payload=payload_func(folders=folders, event_types=event_types, **kwargs) - )) + return self._elems_to_objs( + self._get_elements(payload=payload_func(folders=folders, event_types=event_types, **kwargs)) + ) def _elem_to_obj(self, elem): subscription_elem, watermark_elem = elem @@ -66,15 +66,15 @@

      Module exchangelib.services.subscribe

      @classmethod def _get_elements_in_container(cls, container): - return [(container.find(f'{{{MNS}}}SubscriptionId'), container.find(f'{{{MNS}}}Watermark'))] + return [(container.find(f"{{{MNS}}}SubscriptionId"), container.find(f"{{{MNS}}}Watermark"))] def _partial_payload(self, folders, event_types): request_elem = create_element(self.subscription_request_elem_tag) - folder_ids = folder_ids_element(folders=folders, version=self.account.version, tag='t:FolderIds') + folder_ids = folder_ids_element(folders=folders, version=self.account.version, tag="t:FolderIds") request_elem.append(folder_ids) - event_types_elem = create_element('t:EventTypes') + event_types_elem = create_element("t:EventTypes") for event_type in event_types: - add_xml_child(event_types_elem, 't:EventType', event_type) + add_xml_child(event_types_elem, "t:EventType", event_type) if not len(event_types_elem): raise ValueError("'event_types' must not be empty") request_elem.append(event_types_elem) @@ -82,47 +82,54 @@

      Module exchangelib.services.subscribe

      class SubscribeToPull(Subscribe): - subscription_request_elem_tag = 'm:PullSubscriptionRequest' + subscription_request_elem_tag = "m:PullSubscriptionRequest" prefer_affinity = True def call(self, folders, event_types, watermark, timeout): yield from self._partial_call( - payload_func=self.get_payload, folders=folders, event_types=event_types, timeout=timeout, + payload_func=self.get_payload, + folders=folders, + event_types=event_types, + timeout=timeout, watermark=watermark, ) def get_payload(self, folders, event_types, watermark, timeout): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") request_elem = self._partial_payload(folders=folders, event_types=event_types) if watermark: - add_xml_child(request_elem, 'm:Watermark', watermark) - add_xml_child(request_elem, 't:Timeout', timeout) # In minutes + add_xml_child(request_elem, "m:Watermark", watermark) + add_xml_child(request_elem, "t:Timeout", timeout) # In minutes payload.append(request_elem) return payload class SubscribeToPush(Subscribe): - subscription_request_elem_tag = 'm:PushSubscriptionRequest' + subscription_request_elem_tag = "m:PushSubscriptionRequest" def call(self, folders, event_types, watermark, status_frequency, url): yield from self._partial_call( - payload_func=self.get_payload, folders=folders, event_types=event_types, - status_frequency=status_frequency, url=url, watermark=watermark, + payload_func=self.get_payload, + folders=folders, + event_types=event_types, + status_frequency=status_frequency, + url=url, + watermark=watermark, ) def get_payload(self, folders, event_types, watermark, status_frequency, url): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") request_elem = self._partial_payload(folders=folders, event_types=event_types) if watermark: - add_xml_child(request_elem, 'm:Watermark', watermark) - add_xml_child(request_elem, 't:StatusFrequency', status_frequency) # In minutes - add_xml_child(request_elem, 't:URL', url) + add_xml_child(request_elem, "m:Watermark", watermark) + add_xml_child(request_elem, "t:StatusFrequency", status_frequency) # In minutes + add_xml_child(request_elem, "t:URL", url) payload.append(request_elem) return payload class SubscribeToStreaming(Subscribe): - subscription_request_elem_tag = 'm:StreamingSubscriptionRequest' + subscription_request_elem_tag = "m:StreamingSubscriptionRequest" prefer_affinity = True def call(self, folders, event_types): @@ -133,10 +140,10 @@

      Module exchangelib.services.subscribe

      @classmethod def _get_elements_in_container(cls, container): - return [container.find(f'{{{MNS}}}SubscriptionId')] + return [container.find(f"{{{MNS}}}SubscriptionId")] def get_payload(self, folders, event_types): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") payload.append(self._partial_payload(folders=folders, event_types=event_types)) return payload @@ -163,24 +170,24 @@

      Classes

      class Subscribe(EWSAccountService, metaclass=abc.ABCMeta):
           """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/subscribe-operation"""
       
      -    SERVICE_NAME = 'Subscribe'
      +    SERVICE_NAME = "Subscribe"
           EVENT_TYPES = (
      -        'CopiedEvent',
      -        'CreatedEvent',
      -        'DeletedEvent',
      -        'ModifiedEvent',
      -        'MovedEvent',
      -        'NewMailEvent',
      -        'FreeBusyChangedEvent',
      +        "CopiedEvent",
      +        "CreatedEvent",
      +        "DeletedEvent",
      +        "ModifiedEvent",
      +        "MovedEvent",
      +        "NewMailEvent",
      +        "FreeBusyChangedEvent",
           )
           subscription_request_elem_tag = None
       
           def _partial_call(self, payload_func, folders, event_types, **kwargs):
               if set(event_types) - set(self.EVENT_TYPES):
                   raise ValueError(f"'event_types' values must consist of values in {self.EVENT_TYPES}")
      -        return self._elems_to_objs(self._get_elements(
      -            payload=payload_func(folders=folders, event_types=event_types, **kwargs)
      -        ))
      +        return self._elems_to_objs(
      +            self._get_elements(payload=payload_func(folders=folders, event_types=event_types, **kwargs))
      +        )
       
           def _elem_to_obj(self, elem):
               subscription_elem, watermark_elem = elem
      @@ -188,15 +195,15 @@ 

      Classes

      @classmethod def _get_elements_in_container(cls, container): - return [(container.find(f'{{{MNS}}}SubscriptionId'), container.find(f'{{{MNS}}}Watermark'))] + return [(container.find(f"{{{MNS}}}SubscriptionId"), container.find(f"{{{MNS}}}Watermark"))] def _partial_payload(self, folders, event_types): request_elem = create_element(self.subscription_request_elem_tag) - folder_ids = folder_ids_element(folders=folders, version=self.account.version, tag='t:FolderIds') + folder_ids = folder_ids_element(folders=folders, version=self.account.version, tag="t:FolderIds") request_elem.append(folder_ids) - event_types_elem = create_element('t:EventTypes') + event_types_elem = create_element("t:EventTypes") for event_type in event_types: - add_xml_child(event_types_elem, 't:EventType', event_type) + add_xml_child(event_types_elem, "t:EventType", event_type) if not len(event_types_elem): raise ValueError("'event_types' must not be empty") request_elem.append(event_types_elem) @@ -251,21 +258,24 @@

      Inherited members

      Expand source code
      class SubscribeToPull(Subscribe):
      -    subscription_request_elem_tag = 'm:PullSubscriptionRequest'
      +    subscription_request_elem_tag = "m:PullSubscriptionRequest"
           prefer_affinity = True
       
           def call(self, folders, event_types, watermark, timeout):
               yield from self._partial_call(
      -            payload_func=self.get_payload, folders=folders, event_types=event_types, timeout=timeout,
      +            payload_func=self.get_payload,
      +            folders=folders,
      +            event_types=event_types,
      +            timeout=timeout,
                   watermark=watermark,
               )
       
           def get_payload(self, folders, event_types, watermark, timeout):
      -        payload = create_element(f'm:{self.SERVICE_NAME}')
      +        payload = create_element(f"m:{self.SERVICE_NAME}")
               request_elem = self._partial_payload(folders=folders, event_types=event_types)
               if watermark:
      -            add_xml_child(request_elem, 'm:Watermark', watermark)
      -        add_xml_child(request_elem, 't:Timeout', timeout)  # In minutes
      +            add_xml_child(request_elem, "m:Watermark", watermark)
      +        add_xml_child(request_elem, "t:Timeout", timeout)  # In minutes
               payload.append(request_elem)
               return payload
      @@ -299,7 +309,10 @@

      Methods

      def call(self, folders, event_types, watermark, timeout):
           yield from self._partial_call(
      -        payload_func=self.get_payload, folders=folders, event_types=event_types, timeout=timeout,
      +        payload_func=self.get_payload,
      +        folders=folders,
      +        event_types=event_types,
      +        timeout=timeout,
               watermark=watermark,
           )
      @@ -314,11 +327,11 @@

      Methods

      Expand source code
      def get_payload(self, folders, event_types, watermark, timeout):
      -    payload = create_element(f'm:{self.SERVICE_NAME}')
      +    payload = create_element(f"m:{self.SERVICE_NAME}")
           request_elem = self._partial_payload(folders=folders, event_types=event_types)
           if watermark:
      -        add_xml_child(request_elem, 'm:Watermark', watermark)
      -    add_xml_child(request_elem, 't:Timeout', timeout)  # In minutes
      +        add_xml_child(request_elem, "m:Watermark", watermark)
      +    add_xml_child(request_elem, "t:Timeout", timeout)  # In minutes
           payload.append(request_elem)
           return payload
      @@ -347,21 +360,25 @@

      Inherited members

      Expand source code
      class SubscribeToPush(Subscribe):
      -    subscription_request_elem_tag = 'm:PushSubscriptionRequest'
      +    subscription_request_elem_tag = "m:PushSubscriptionRequest"
       
           def call(self, folders, event_types, watermark, status_frequency, url):
               yield from self._partial_call(
      -            payload_func=self.get_payload, folders=folders, event_types=event_types,
      -            status_frequency=status_frequency, url=url, watermark=watermark,
      +            payload_func=self.get_payload,
      +            folders=folders,
      +            event_types=event_types,
      +            status_frequency=status_frequency,
      +            url=url,
      +            watermark=watermark,
               )
       
           def get_payload(self, folders, event_types, watermark, status_frequency, url):
      -        payload = create_element(f'm:{self.SERVICE_NAME}')
      +        payload = create_element(f"m:{self.SERVICE_NAME}")
               request_elem = self._partial_payload(folders=folders, event_types=event_types)
               if watermark:
      -            add_xml_child(request_elem, 'm:Watermark', watermark)
      -        add_xml_child(request_elem, 't:StatusFrequency', status_frequency)  # In minutes
      -        add_xml_child(request_elem, 't:URL', url)
      +            add_xml_child(request_elem, "m:Watermark", watermark)
      +        add_xml_child(request_elem, "t:StatusFrequency", status_frequency)  # In minutes
      +        add_xml_child(request_elem, "t:URL", url)
               payload.append(request_elem)
               return payload
      @@ -391,8 +408,12 @@

      Methods

      def call(self, folders, event_types, watermark, status_frequency, url):
           yield from self._partial_call(
      -        payload_func=self.get_payload, folders=folders, event_types=event_types,
      -        status_frequency=status_frequency, url=url, watermark=watermark,
      +        payload_func=self.get_payload,
      +        folders=folders,
      +        event_types=event_types,
      +        status_frequency=status_frequency,
      +        url=url,
      +        watermark=watermark,
           )
      @@ -406,12 +427,12 @@

      Methods

      Expand source code
      def get_payload(self, folders, event_types, watermark, status_frequency, url):
      -    payload = create_element(f'm:{self.SERVICE_NAME}')
      +    payload = create_element(f"m:{self.SERVICE_NAME}")
           request_elem = self._partial_payload(folders=folders, event_types=event_types)
           if watermark:
      -        add_xml_child(request_elem, 'm:Watermark', watermark)
      -    add_xml_child(request_elem, 't:StatusFrequency', status_frequency)  # In minutes
      -    add_xml_child(request_elem, 't:URL', url)
      +        add_xml_child(request_elem, "m:Watermark", watermark)
      +    add_xml_child(request_elem, "t:StatusFrequency", status_frequency)  # In minutes
      +    add_xml_child(request_elem, "t:URL", url)
           payload.append(request_elem)
           return payload
      @@ -440,7 +461,7 @@

      Inherited members

      Expand source code
      class SubscribeToStreaming(Subscribe):
      -    subscription_request_elem_tag = 'm:StreamingSubscriptionRequest'
      +    subscription_request_elem_tag = "m:StreamingSubscriptionRequest"
           prefer_affinity = True
       
           def call(self, folders, event_types):
      @@ -451,10 +472,10 @@ 

      Inherited members

      @classmethod def _get_elements_in_container(cls, container): - return [container.find(f'{{{MNS}}}SubscriptionId')] + return [container.find(f"{{{MNS}}}SubscriptionId")] def get_payload(self, folders, event_types): - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") payload.append(self._partial_payload(folders=folders, event_types=event_types)) return payload
      @@ -500,7 +521,7 @@

      Methods

      Expand source code
      def get_payload(self, folders, event_types):
      -    payload = create_element(f'm:{self.SERVICE_NAME}')
      +    payload = create_element(f"m:{self.SERVICE_NAME}")
           payload.append(self._partial_payload(folders=folders, event_types=event_types))
           return payload
      diff --git a/docs/exchangelib/services/sync_folder_hierarchy.html b/docs/exchangelib/services/sync_folder_hierarchy.html index f1938238..0f3870ef 100644 --- a/docs/exchangelib/services/sync_folder_hierarchy.html +++ b/docs/exchangelib/services/sync_folder_hierarchy.html @@ -29,9 +29,9 @@

      Module exchangelib.services.sync_folder_hierarchy
      import abc
       import logging
       
      -from .common import EWSPagingService, add_xml_child, folder_ids_element, shape_element, parse_folder_elem
       from ..properties import FolderId
      -from ..util import create_element, xml_text_to_value, MNS, TNS
      +from ..util import MNS, TNS, create_element, xml_text_to_value
      +from .common import EWSPagingService, add_xml_child, folder_ids_element, parse_folder_elem, shape_element
       
       log = logging.getLogger(__name__)
       
      @@ -39,18 +39,18 @@ 

      Module exchangelib.services.sync_folder_hierarchy class SyncFolder(EWSPagingService, metaclass=abc.ABCMeta): """Base class for SyncFolderHierarchy and SyncFolderItems.""" - element_container_name = f'{{{MNS}}}Changes' + element_container_name = f"{{{MNS}}}Changes" # Change types - CREATE = 'create' - UPDATE = 'update' - DELETE = 'delete' + CREATE = "create" + UPDATE = "update" + DELETE = "delete" CHANGE_TYPES = (CREATE, UPDATE, DELETE) shape_tag = None last_in_range_name = None change_types_map = { - f'{{{TNS}}}Create': CREATE, - f'{{{TNS}}}Update': UPDATE, - f'{{{TNS}}}Delete': DELETE, + f"{{{TNS}}}Create": CREATE, + f"{{{TNS}}}Update": UPDATE, + f"{{{TNS}}}Delete": DELETE, } def __init__(self, *args, **kwargs): @@ -60,22 +60,22 @@

      Module exchangelib.services.sync_folder_hierarchy super().__init__(*args, **kwargs) def _get_element_container(self, message, name=None): - self.sync_state = message.find(f'{{{MNS}}}SyncState').text - log.debug('Sync state is: %s', self.sync_state) - self.includes_last_item_in_range = xml_text_to_value( - message.find(self.last_in_range_name).text, bool - ) - log.debug('Includes last item in range: %s', self.includes_last_item_in_range) + self.sync_state = message.find(f"{{{MNS}}}SyncState").text + log.debug("Sync state is: %s", self.sync_state) + self.includes_last_item_in_range = xml_text_to_value(message.find(self.last_in_range_name).text, bool) + log.debug("Includes last item in range: %s", self.includes_last_item_in_range) return super()._get_element_container(message=message, name=name) def _partial_get_payload(self, folder, shape, additional_fields, sync_state): - payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(shape_element( - tag=self.shape_tag, shape=shape, additional_fields=additional_fields, version=self.account.version - )) - payload.append(folder_ids_element(folders=[folder], version=self.account.version, tag='m:SyncFolderId')) + payload = create_element(f"m:{self.SERVICE_NAME}") + payload.append( + shape_element( + tag=self.shape_tag, shape=shape, additional_fields=additional_fields, version=self.account.version + ) + ) + payload.append(folder_ids_element(folders=[folder], version=self.account.version, tag="m:SyncFolderId")) if sync_state: - add_xml_child(payload, 'm:SyncState', sync_state) + add_xml_child(payload, "m:SyncState", sync_state) return payload @@ -84,9 +84,9 @@

      Module exchangelib.services.sync_folder_hierarchy https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/syncfolderhierarchy-operation """ - SERVICE_NAME = 'SyncFolderHierarchy' - shape_tag = 'm:FolderShape' - last_in_range_name = f'{{{MNS}}}IncludesLastFolderInRange' + SERVICE_NAME = "SyncFolderHierarchy" + shape_tag = "m:FolderShape" + last_in_range_name = f"{{{MNS}}}IncludesLastFolderInRange" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -95,12 +95,16 @@

      Module exchangelib.services.sync_folder_hierarchy def call(self, folder, shape, additional_fields, sync_state): self.sync_state = sync_state self.folder = folder - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - folder=folder, - shape=shape, - additional_fields=additional_fields, - sync_state=sync_state, - ))) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + folder=folder, + shape=shape, + additional_fields=additional_fields, + sync_state=sync_state, + ) + ) + ) def _elem_to_obj(self, elem): change_type = self.change_types_map[elem.tag] @@ -141,18 +145,18 @@

      Classes

      class SyncFolder(EWSPagingService, metaclass=abc.ABCMeta):
           """Base class for SyncFolderHierarchy and SyncFolderItems."""
       
      -    element_container_name = f'{{{MNS}}}Changes'
      +    element_container_name = f"{{{MNS}}}Changes"
           # Change types
      -    CREATE = 'create'
      -    UPDATE = 'update'
      -    DELETE = 'delete'
      +    CREATE = "create"
      +    UPDATE = "update"
      +    DELETE = "delete"
           CHANGE_TYPES = (CREATE, UPDATE, DELETE)
           shape_tag = None
           last_in_range_name = None
           change_types_map = {
      -        f'{{{TNS}}}Create': CREATE,
      -        f'{{{TNS}}}Update': UPDATE,
      -        f'{{{TNS}}}Delete': DELETE,
      +        f"{{{TNS}}}Create": CREATE,
      +        f"{{{TNS}}}Update": UPDATE,
      +        f"{{{TNS}}}Delete": DELETE,
           }
       
           def __init__(self, *args, **kwargs):
      @@ -162,22 +166,22 @@ 

      Classes

      super().__init__(*args, **kwargs) def _get_element_container(self, message, name=None): - self.sync_state = message.find(f'{{{MNS}}}SyncState').text - log.debug('Sync state is: %s', self.sync_state) - self.includes_last_item_in_range = xml_text_to_value( - message.find(self.last_in_range_name).text, bool - ) - log.debug('Includes last item in range: %s', self.includes_last_item_in_range) + self.sync_state = message.find(f"{{{MNS}}}SyncState").text + log.debug("Sync state is: %s", self.sync_state) + self.includes_last_item_in_range = xml_text_to_value(message.find(self.last_in_range_name).text, bool) + log.debug("Includes last item in range: %s", self.includes_last_item_in_range) return super()._get_element_container(message=message, name=name) def _partial_get_payload(self, folder, shape, additional_fields, sync_state): - payload = create_element(f'm:{self.SERVICE_NAME}') - payload.append(shape_element( - tag=self.shape_tag, shape=shape, additional_fields=additional_fields, version=self.account.version - )) - payload.append(folder_ids_element(folders=[folder], version=self.account.version, tag='m:SyncFolderId')) + payload = create_element(f"m:{self.SERVICE_NAME}") + payload.append( + shape_element( + tag=self.shape_tag, shape=shape, additional_fields=additional_fields, version=self.account.version + ) + ) + payload.append(folder_ids_element(folders=[folder], version=self.account.version, tag="m:SyncFolderId")) if sync_state: - add_xml_child(payload, 'm:SyncState', sync_state) + add_xml_child(payload, "m:SyncState", sync_state) return payload

      Ancestors

      @@ -254,9 +258,9 @@

      Inherited members

      https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/syncfolderhierarchy-operation """ - SERVICE_NAME = 'SyncFolderHierarchy' - shape_tag = 'm:FolderShape' - last_in_range_name = f'{{{MNS}}}IncludesLastFolderInRange' + SERVICE_NAME = "SyncFolderHierarchy" + shape_tag = "m:FolderShape" + last_in_range_name = f"{{{MNS}}}IncludesLastFolderInRange" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -265,12 +269,16 @@

      Inherited members

      def call(self, folder, shape, additional_fields, sync_state): self.sync_state = sync_state self.folder = folder - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - folder=folder, - shape=shape, - additional_fields=additional_fields, - sync_state=sync_state, - ))) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + folder=folder, + shape=shape, + additional_fields=additional_fields, + sync_state=sync_state, + ) + ) + ) def _elem_to_obj(self, elem): change_type = self.change_types_map[elem.tag] @@ -324,12 +332,16 @@

      Methods

      def call(self, folder, shape, additional_fields, sync_state):
           self.sync_state = sync_state
           self.folder = folder
      -    return self._elems_to_objs(self._get_elements(payload=self.get_payload(
      -            folder=folder,
      -            shape=shape,
      -            additional_fields=additional_fields,
      -            sync_state=sync_state,
      -    )))
      + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + folder=folder, + shape=shape, + additional_fields=additional_fields, + sync_state=sync_state, + ) + ) + )
      diff --git a/docs/exchangelib/services/sync_folder_items.html b/docs/exchangelib/services/sync_folder_items.html index 8804be99..ba9c103a 100644 --- a/docs/exchangelib/services/sync_folder_items.html +++ b/docs/exchangelib/services/sync_folder_items.html @@ -26,12 +26,12 @@

      Module exchangelib.services.sync_folder_items

      Expand source code -
      from .common import add_xml_child, item_ids_element
      -from .sync_folder_hierarchy import SyncFolder
      -from ..errors import InvalidEnumValue, InvalidTypeError
      +
      from ..errors import InvalidEnumValue, InvalidTypeError
       from ..folders import BaseFolder
       from ..properties import ItemId
      -from ..util import xml_text_to_value, peek, TNS, MNS
      +from ..util import MNS, TNS, peek, xml_text_to_value
      +from .common import add_xml_child, item_ids_element
      +from .sync_folder_hierarchy import SyncFolder
       
       
       class SyncFolderItems(SyncFolder):
      @@ -39,45 +39,49 @@ 

      Module exchangelib.services.sync_folder_items

      Module exchangelib.services.sync_folder_items

    @@ -126,45 +130,49 @@

    Classes

    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/syncfolderitems-operation """ - SERVICE_NAME = 'SyncFolderItems' + SERVICE_NAME = "SyncFolderItems" SYNC_SCOPES = ( - 'NormalItems', - 'NormalAndAssociatedItems', + "NormalItems", + "NormalAndAssociatedItems", ) # Extra change type - READ_FLAG_CHANGE = 'read_flag_change' + READ_FLAG_CHANGE = "read_flag_change" CHANGE_TYPES = SyncFolder.CHANGE_TYPES + (READ_FLAG_CHANGE,) - shape_tag = 'm:ItemShape' - last_in_range_name = f'{{{MNS}}}IncludesLastItemInRange' + shape_tag = "m:ItemShape" + last_in_range_name = f"{{{MNS}}}IncludesLastItemInRange" change_types_map = SyncFolder.change_types_map - change_types_map[f'{{{TNS}}}ReadFlagChange'] = READ_FLAG_CHANGE + change_types_map[f"{{{TNS}}}ReadFlagChange"] = READ_FLAG_CHANGE def call(self, folder, shape, additional_fields, sync_state, ignore, max_changes_returned, sync_scope): self.sync_state = sync_state if max_changes_returned is None: max_changes_returned = self.page_size if not isinstance(max_changes_returned, int): - raise InvalidTypeError('max_changes_returned', max_changes_returned, int) + raise InvalidTypeError("max_changes_returned", max_changes_returned, int) if max_changes_returned <= 0: raise ValueError(f"'max_changes_returned' {max_changes_returned} must be a positive integer") if sync_scope is not None and sync_scope not in self.SYNC_SCOPES: - raise InvalidEnumValue('sync_scope', sync_scope, self.SYNC_SCOPES) - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - folder=folder, - shape=shape, - additional_fields=additional_fields, - sync_state=sync_state, - ignore=ignore, - max_changes_returned=max_changes_returned, - sync_scope=sync_scope, - ))) + raise InvalidEnumValue("sync_scope", sync_scope, self.SYNC_SCOPES) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + folder=folder, + shape=shape, + additional_fields=additional_fields, + sync_state=sync_state, + ignore=ignore, + max_changes_returned=max_changes_returned, + sync_scope=sync_scope, + ) + ) + ) def _elem_to_obj(self, elem): change_type = self.change_types_map[elem.tag] if change_type == self.READ_FLAG_CHANGE: item = ( ItemId.from_xml(elem=elem.find(ItemId.response_tag()), account=self.account), - xml_text_to_value(elem.find(f'{{{TNS}}}IsRead').text, bool) + xml_text_to_value(elem.find(f"{{{TNS}}}IsRead").text, bool), ) elif change_type == self.DELETE: item = ItemId.from_xml(elem=elem.find(ItemId.response_tag()), account=self.account) @@ -181,10 +189,10 @@

    Classes

    ) is_empty, ignore = (True, None) if ignore is None else peek(ignore) if not is_empty: - sync_folder_items.append(item_ids_element(items=ignore, version=self.account.version, tag='m:Ignore')) - add_xml_child(sync_folder_items, 'm:MaxChangesReturned', max_changes_returned) + sync_folder_items.append(item_ids_element(items=ignore, version=self.account.version, tag="m:Ignore")) + add_xml_child(sync_folder_items, "m:MaxChangesReturned", max_changes_returned) if sync_scope: - add_xml_child(sync_folder_items, 'm:SyncScope', sync_scope) + add_xml_child(sync_folder_items, "m:SyncScope", sync_scope) return sync_folder_items

    Ancestors

    @@ -241,20 +249,24 @@

    Methods

    if max_changes_returned is None: max_changes_returned = self.page_size if not isinstance(max_changes_returned, int): - raise InvalidTypeError('max_changes_returned', max_changes_returned, int) + raise InvalidTypeError("max_changes_returned", max_changes_returned, int) if max_changes_returned <= 0: raise ValueError(f"'max_changes_returned' {max_changes_returned} must be a positive integer") if sync_scope is not None and sync_scope not in self.SYNC_SCOPES: - raise InvalidEnumValue('sync_scope', sync_scope, self.SYNC_SCOPES) - return self._elems_to_objs(self._get_elements(payload=self.get_payload( - folder=folder, - shape=shape, - additional_fields=additional_fields, - sync_state=sync_state, - ignore=ignore, - max_changes_returned=max_changes_returned, - sync_scope=sync_scope, - )))
    + raise InvalidEnumValue("sync_scope", sync_scope, self.SYNC_SCOPES) + return self._elems_to_objs( + self._get_elements( + payload=self.get_payload( + folder=folder, + shape=shape, + additional_fields=additional_fields, + sync_state=sync_state, + ignore=ignore, + max_changes_returned=max_changes_returned, + sync_scope=sync_scope, + ) + ) + )
    @@ -272,10 +284,10 @@

    Methods

    ) is_empty, ignore = (True, None) if ignore is None else peek(ignore) if not is_empty: - sync_folder_items.append(item_ids_element(items=ignore, version=self.account.version, tag='m:Ignore')) - add_xml_child(sync_folder_items, 'm:MaxChangesReturned', max_changes_returned) + sync_folder_items.append(item_ids_element(items=ignore, version=self.account.version, tag="m:Ignore")) + add_xml_child(sync_folder_items, "m:MaxChangesReturned", max_changes_returned) if sync_scope: - add_xml_child(sync_folder_items, 'm:SyncScope', sync_scope) + add_xml_child(sync_folder_items, "m:SyncScope", sync_scope) return sync_folder_items
    diff --git a/docs/exchangelib/services/unsubscribe.html b/docs/exchangelib/services/unsubscribe.html index 707171c5..aa7ebb88 100644 --- a/docs/exchangelib/services/unsubscribe.html +++ b/docs/exchangelib/services/unsubscribe.html @@ -26,8 +26,8 @@

    Module exchangelib.services.unsubscribe

    Expand source code -
    from .common import EWSAccountService, add_xml_child
    -from ..util import create_element
    +
    from ..util import create_element
    +from .common import EWSAccountService, add_xml_child
     
     
     class Unsubscribe(EWSAccountService):
    @@ -36,7 +36,7 @@ 

    Module exchangelib.services.unsubscribe

    MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/unsubscribe-operation """ - SERVICE_NAME = 'Unsubscribe' + SERVICE_NAME = "Unsubscribe" returns_elements = False prefer_affinity = True @@ -44,8 +44,8 @@

    Module exchangelib.services.unsubscribe

    return self._get_elements(payload=self.get_payload(subscription_id=subscription_id)) def get_payload(self, subscription_id): - payload = create_element(f'm:{self.SERVICE_NAME}') - add_xml_child(payload, 'm:SubscriptionId', subscription_id) + payload = create_element(f"m:{self.SERVICE_NAME}") + add_xml_child(payload, "m:SubscriptionId", subscription_id) return payload
    @@ -75,7 +75,7 @@

    Classes

    MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/unsubscribe-operation """ - SERVICE_NAME = 'Unsubscribe' + SERVICE_NAME = "Unsubscribe" returns_elements = False prefer_affinity = True @@ -83,8 +83,8 @@

    Classes

    return self._get_elements(payload=self.get_payload(subscription_id=subscription_id)) def get_payload(self, subscription_id): - payload = create_element(f'm:{self.SERVICE_NAME}') - add_xml_child(payload, 'm:SubscriptionId', subscription_id) + payload = create_element(f"m:{self.SERVICE_NAME}") + add_xml_child(payload, "m:SubscriptionId", subscription_id) return payload

    Ancestors

    @@ -132,8 +132,8 @@

    Methods

    Expand source code
    def get_payload(self, subscription_id):
    -    payload = create_element(f'm:{self.SERVICE_NAME}')
    -    add_xml_child(payload, 'm:SubscriptionId', subscription_id)
    +    payload = create_element(f"m:{self.SERVICE_NAME}")
    +    add_xml_child(payload, "m:SubscriptionId", subscription_id)
         return payload
    diff --git a/docs/exchangelib/services/update_folder.html b/docs/exchangelib/services/update_folder.html index 81183325..82b98424 100644 --- a/docs/exchangelib/services/update_folder.html +++ b/docs/exchangelib/services/update_folder.html @@ -28,14 +28,15 @@

    Module exchangelib.services.update_folder

    import abc
     
    -from .common import EWSAccountService, parse_folder_elem, to_item_id
     from ..fields import FieldPath, IndexedField
     from ..properties import FolderId
    -from ..util import create_element, set_xml_value, MNS
    +from ..util import MNS, create_element, set_xml_value
    +from .common import EWSAccountService, parse_folder_elem, to_item_id
     
     
     class BaseUpdateService(EWSAccountService, metaclass=abc.ABCMeta):
         """Base class for UpdateFolder and UpdateItem"""
    +
         SET_FIELD_ELEMENT_NAME = None
         DELETE_FIELD_ELEMENT_NAME = None
         CHANGE_ELEMENT_NAME = None
    @@ -60,8 +61,9 @@ 

    Module exchangelib.services.update_folder

    def _set_field_elems(self, target_model, field, value): if isinstance(field, IndexedField): # Generate either set or delete elements for all combinations of labels and subfields - supported_labels = field.value_cls.get_field_by_fieldname('label')\ - .supported_choices(version=self.account.version) + supported_labels = field.value_cls.get_field_by_fieldname("label").supported_choices( + version=self.account.version + ) seen_labels = set() subfields = field.value_cls.supported_fields(version=self.account.version) for v in value: @@ -77,7 +79,7 @@

    Module exchangelib.services.update_folder

    yield self._set_field_elem( target_model=target_model, field_path=field_path, - value=field.value_cls(**{'label': v.label, subfield.name: subfield_value}), + value=field.value_cls(**{"label": v.label, subfield.name: subfield_value}), ) # Generate delete elements for all subfields of all labels not mentioned in the list of values for label in (label for label in supported_labels if label not in seen_labels): @@ -108,13 +110,13 @@

    Module exchangelib.services.update_folder

    for field in self._sorted_fields(target_model=target_model, fieldnames=fieldnames): if field.is_read_only: - raise ValueError(f'{field.name!r} is a read-only field') + raise ValueError(f"{field.name!r} is a read-only field") value = self._get_value(target, field) if value is None or (field.is_list and not value): # A value of None or [] means we want to remove this field from the item if field.is_required or field.is_required_after_save: - raise ValueError(f'{field.name!r} is a required field and may not be deleted') + raise ValueError(f"{field.name!r} is a required field and may not be deleted") yield from self._delete_field_elems(field) continue @@ -125,7 +127,7 @@

    Module exchangelib.services.update_folder

    raise ValueError("'fieldnames' must not be empty") change = create_element(self.CHANGE_ELEMENT_NAME) set_xml_value(change, self._target_elem(target), version=self.account.version) - updates = create_element('t:Updates') + updates = create_element("t:Updates") for elem in self._update_elems(target=target, fieldnames=fieldnames): updates.append(elem) change.append(updates) @@ -147,12 +149,12 @@

    Module exchangelib.services.update_folder

    class UpdateFolder(BaseUpdateService): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updatefolder-operation""" - SERVICE_NAME = 'UpdateFolder' - SET_FIELD_ELEMENT_NAME = 't:SetFolderField' - DELETE_FIELD_ELEMENT_NAME = 't:DeleteFolderField' - CHANGE_ELEMENT_NAME = 't:FolderChange' - CHANGES_ELEMENT_NAME = 'm:FolderChanges' - element_container_name = f'{{{MNS}}}Folders' + SERVICE_NAME = "UpdateFolder" + SET_FIELD_ELEMENT_NAME = "t:SetFolderField" + DELETE_FIELD_ELEMENT_NAME = "t:DeleteFolderField" + CHANGE_ELEMENT_NAME = "t:FolderChange" + CHANGES_ELEMENT_NAME = "m:FolderChanges" + element_container_name = f"{{{MNS}}}Folders" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -177,7 +179,7 @@

    Module exchangelib.services.update_folder

    def get_payload(self, folders): # Takes a list of (Folder, fieldnames) tuples where 'Folder' is a instance of a subclass of Folder and # 'fieldnames' are the attribute names that were updated. - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") payload.append(self._changes_elem(target_changes=folders)) return payload
    @@ -203,6 +205,7 @@

    Classes

    class BaseUpdateService(EWSAccountService, metaclass=abc.ABCMeta):
         """Base class for UpdateFolder and UpdateItem"""
    +
         SET_FIELD_ELEMENT_NAME = None
         DELETE_FIELD_ELEMENT_NAME = None
         CHANGE_ELEMENT_NAME = None
    @@ -227,8 +230,9 @@ 

    Classes

    def _set_field_elems(self, target_model, field, value): if isinstance(field, IndexedField): # Generate either set or delete elements for all combinations of labels and subfields - supported_labels = field.value_cls.get_field_by_fieldname('label')\ - .supported_choices(version=self.account.version) + supported_labels = field.value_cls.get_field_by_fieldname("label").supported_choices( + version=self.account.version + ) seen_labels = set() subfields = field.value_cls.supported_fields(version=self.account.version) for v in value: @@ -244,7 +248,7 @@

    Classes

    yield self._set_field_elem( target_model=target_model, field_path=field_path, - value=field.value_cls(**{'label': v.label, subfield.name: subfield_value}), + value=field.value_cls(**{"label": v.label, subfield.name: subfield_value}), ) # Generate delete elements for all subfields of all labels not mentioned in the list of values for label in (label for label in supported_labels if label not in seen_labels): @@ -275,13 +279,13 @@

    Classes

    for field in self._sorted_fields(target_model=target_model, fieldnames=fieldnames): if field.is_read_only: - raise ValueError(f'{field.name!r} is a read-only field') + raise ValueError(f"{field.name!r} is a read-only field") value = self._get_value(target, field) if value is None or (field.is_list and not value): # A value of None or [] means we want to remove this field from the item if field.is_required or field.is_required_after_save: - raise ValueError(f'{field.name!r} is a required field and may not be deleted') + raise ValueError(f"{field.name!r} is a required field and may not be deleted") yield from self._delete_field_elems(field) continue @@ -292,7 +296,7 @@

    Classes

    raise ValueError("'fieldnames' must not be empty") change = create_element(self.CHANGE_ELEMENT_NAME) set_xml_value(change, self._target_elem(target), version=self.account.version) - updates = create_element('t:Updates') + updates = create_element("t:Updates") for elem in self._update_elems(target=target, fieldnames=fieldnames): updates.append(elem) change.append(updates) @@ -364,12 +368,12 @@

    Inherited members

    class UpdateFolder(BaseUpdateService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updatefolder-operation"""
     
    -    SERVICE_NAME = 'UpdateFolder'
    -    SET_FIELD_ELEMENT_NAME = 't:SetFolderField'
    -    DELETE_FIELD_ELEMENT_NAME = 't:DeleteFolderField'
    -    CHANGE_ELEMENT_NAME = 't:FolderChange'
    -    CHANGES_ELEMENT_NAME = 'm:FolderChanges'
    -    element_container_name = f'{{{MNS}}}Folders'
    +    SERVICE_NAME = "UpdateFolder"
    +    SET_FIELD_ELEMENT_NAME = "t:SetFolderField"
    +    DELETE_FIELD_ELEMENT_NAME = "t:DeleteFolderField"
    +    CHANGE_ELEMENT_NAME = "t:FolderChange"
    +    CHANGES_ELEMENT_NAME = "m:FolderChanges"
    +    element_container_name = f"{{{MNS}}}Folders"
     
         def __init__(self, *args, **kwargs):
             super().__init__(*args, **kwargs)
    @@ -394,7 +398,7 @@ 

    Inherited members

    def get_payload(self, folders): # Takes a list of (Folder, fieldnames) tuples where 'Folder' is a instance of a subclass of Folder and # 'fieldnames' are the attribute names that were updated. - payload = create_element(f'm:{self.SERVICE_NAME}') + payload = create_element(f"m:{self.SERVICE_NAME}") payload.append(self._changes_elem(target_changes=folders)) return payload
    @@ -461,7 +465,7 @@

    Methods

    def get_payload(self, folders):
         # Takes a list of (Folder, fieldnames) tuples where 'Folder' is a instance of a subclass of Folder and
         # 'fieldnames' are the attribute names that were updated.
    -    payload = create_element(f'm:{self.SERVICE_NAME}')
    +    payload = create_element(f"m:{self.SERVICE_NAME}")
         payload.append(self._changes_elem(target_changes=folders))
         return payload
    diff --git a/docs/exchangelib/services/update_item.html b/docs/exchangelib/services/update_item.html index 7f22eb3f..53a75279 100644 --- a/docs/exchangelib/services/update_item.html +++ b/docs/exchangelib/services/update_item.html @@ -26,48 +26,63 @@

    Module exchangelib.services.update_item

    Expand source code -
    from .common import to_item_id
    -from ..errors import InvalidEnumValue
    +
    from ..errors import InvalidEnumValue
     from ..ewsdatetime import EWSDate
    -from ..items import CONFLICT_RESOLUTION_CHOICES, MESSAGE_DISPOSITION_CHOICES, \
    -    SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES, SEND_ONLY, Item, CalendarItem
    +from ..items import (
    +    CONFLICT_RESOLUTION_CHOICES,
    +    MESSAGE_DISPOSITION_CHOICES,
    +    SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES,
    +    SEND_ONLY,
    +    CalendarItem,
    +    Item,
    +)
     from ..properties import ItemId
    -from ..util import create_element, MNS
    +from ..util import MNS, create_element
     from ..version import EXCHANGE_2013_SP1
    +from .common import to_item_id
     from .update_folder import BaseUpdateService
     
     
     class UpdateItem(BaseUpdateService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem-operation"""
     
    -    SERVICE_NAME = 'UpdateItem'
    -    SET_FIELD_ELEMENT_NAME = 't:SetItemField'
    -    DELETE_FIELD_ELEMENT_NAME = 't:DeleteItemField'
    -    CHANGE_ELEMENT_NAME = 't:ItemChange'
    -    CHANGES_ELEMENT_NAME = 'm:ItemChanges'
    -    element_container_name = f'{{{MNS}}}Items'
    +    SERVICE_NAME = "UpdateItem"
    +    SET_FIELD_ELEMENT_NAME = "t:SetItemField"
    +    DELETE_FIELD_ELEMENT_NAME = "t:DeleteItemField"
    +    CHANGE_ELEMENT_NAME = "t:ItemChange"
    +    CHANGES_ELEMENT_NAME = "m:ItemChanges"
    +    element_container_name = f"{{{MNS}}}Items"
     
    -    def call(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations,
    -             suppress_read_receipts):
    +    def call(
    +        self,
    +        items,
    +        conflict_resolution,
    +        message_disposition,
    +        send_meeting_invitations_or_cancellations,
    +        suppress_read_receipts,
    +    ):
             if conflict_resolution not in CONFLICT_RESOLUTION_CHOICES:
    -            raise InvalidEnumValue('conflict_resolution', conflict_resolution, CONFLICT_RESOLUTION_CHOICES)
    +            raise InvalidEnumValue("conflict_resolution", conflict_resolution, CONFLICT_RESOLUTION_CHOICES)
             if message_disposition not in MESSAGE_DISPOSITION_CHOICES:
    -            raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES)
    +            raise InvalidEnumValue("message_disposition", message_disposition, MESSAGE_DISPOSITION_CHOICES)
             if send_meeting_invitations_or_cancellations not in SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES:
                 raise InvalidEnumValue(
    -                'send_meeting_invitations_or_cancellations', send_meeting_invitations_or_cancellations,
    -                SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES
    +                "send_meeting_invitations_or_cancellations",
    +                send_meeting_invitations_or_cancellations,
    +                SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES,
                 )
             if message_disposition == SEND_ONLY:
    -            raise ValueError('Cannot send-only existing objects. Use SendItem service instead')
    -        return self._elems_to_objs(self._chunked_get_elements(
    -            self.get_payload,
    -            items=items,
    -            conflict_resolution=conflict_resolution,
    -            message_disposition=message_disposition,
    -            send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations,
    -            suppress_read_receipts=suppress_read_receipts,
    -        ))
    +            raise ValueError("Cannot send-only existing objects. Use SendItem service instead")
    +        return self._elems_to_objs(
    +            self._chunked_get_elements(
    +                self.get_payload,
    +                items=items,
    +                conflict_resolution=conflict_resolution,
    +                message_disposition=message_disposition,
    +                send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations,
    +                suppress_read_receipts=suppress_read_receipts,
    +            )
    +        )
     
         def _elem_to_obj(self, elem):
             return Item.id_from_xml(elem)
    @@ -78,7 +93,7 @@ 

    Module exchangelib.services.update_item

    if target.__class__ == CalendarItem: # For CalendarItem items where we update 'start' or 'end', we want to update internal timezone fields target.clean_timezone_fields(version=self.account.version) # Possibly also sets timezone values - for field_name in ('start', 'end'): + for field_name in ("start", "end"): if field_name in fieldnames_copy: tz_field_name = target.tz_field_for_field_name(field_name).name if tz_field_name not in fieldnames_copy: @@ -91,7 +106,7 @@

    Module exchangelib.services.update_item

    if target.__class__ == CalendarItem: # For CalendarItem items where we update 'start' or 'end', we want to send values in the local timezone - if field.name in ('start', 'end'): + if field.name in ("start", "end"): if type(value) is EWSDate: # EWS always expects a datetime return target.date_to_datetime(field_name=field.name) @@ -103,8 +118,14 @@

    Module exchangelib.services.update_item

    def _target_elem(self, target): return to_item_id(target, ItemId) - def get_payload(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, - suppress_read_receipts): + def get_payload( + self, + items, + conflict_resolution, + message_disposition, + send_meeting_invitations_or_cancellations, + suppress_read_receipts, + ): # Takes a list of (Item, fieldnames) tuples where 'Item' is a instance of a subclass of Item and 'fieldnames' # are the attribute names that were updated. attrs = dict( @@ -113,8 +134,8 @@

    Module exchangelib.services.update_item

    SendMeetingInvitationsOrCancellations=send_meeting_invitations_or_cancellations, ) if self.account.version.build >= EXCHANGE_2013_SP1: - attrs['SuppressReadReceipts'] = suppress_read_receipts - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + attrs["SuppressReadReceipts"] = suppress_read_receipts + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=attrs) payload.append(self._changes_elem(target_changes=items)) return payload
    @@ -141,34 +162,43 @@

    Classes

    class UpdateItem(BaseUpdateService):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem-operation"""
     
    -    SERVICE_NAME = 'UpdateItem'
    -    SET_FIELD_ELEMENT_NAME = 't:SetItemField'
    -    DELETE_FIELD_ELEMENT_NAME = 't:DeleteItemField'
    -    CHANGE_ELEMENT_NAME = 't:ItemChange'
    -    CHANGES_ELEMENT_NAME = 'm:ItemChanges'
    -    element_container_name = f'{{{MNS}}}Items'
    +    SERVICE_NAME = "UpdateItem"
    +    SET_FIELD_ELEMENT_NAME = "t:SetItemField"
    +    DELETE_FIELD_ELEMENT_NAME = "t:DeleteItemField"
    +    CHANGE_ELEMENT_NAME = "t:ItemChange"
    +    CHANGES_ELEMENT_NAME = "m:ItemChanges"
    +    element_container_name = f"{{{MNS}}}Items"
     
    -    def call(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations,
    -             suppress_read_receipts):
    +    def call(
    +        self,
    +        items,
    +        conflict_resolution,
    +        message_disposition,
    +        send_meeting_invitations_or_cancellations,
    +        suppress_read_receipts,
    +    ):
             if conflict_resolution not in CONFLICT_RESOLUTION_CHOICES:
    -            raise InvalidEnumValue('conflict_resolution', conflict_resolution, CONFLICT_RESOLUTION_CHOICES)
    +            raise InvalidEnumValue("conflict_resolution", conflict_resolution, CONFLICT_RESOLUTION_CHOICES)
             if message_disposition not in MESSAGE_DISPOSITION_CHOICES:
    -            raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES)
    +            raise InvalidEnumValue("message_disposition", message_disposition, MESSAGE_DISPOSITION_CHOICES)
             if send_meeting_invitations_or_cancellations not in SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES:
                 raise InvalidEnumValue(
    -                'send_meeting_invitations_or_cancellations', send_meeting_invitations_or_cancellations,
    -                SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES
    +                "send_meeting_invitations_or_cancellations",
    +                send_meeting_invitations_or_cancellations,
    +                SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES,
                 )
             if message_disposition == SEND_ONLY:
    -            raise ValueError('Cannot send-only existing objects. Use SendItem service instead')
    -        return self._elems_to_objs(self._chunked_get_elements(
    -            self.get_payload,
    -            items=items,
    -            conflict_resolution=conflict_resolution,
    -            message_disposition=message_disposition,
    -            send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations,
    -            suppress_read_receipts=suppress_read_receipts,
    -        ))
    +            raise ValueError("Cannot send-only existing objects. Use SendItem service instead")
    +        return self._elems_to_objs(
    +            self._chunked_get_elements(
    +                self.get_payload,
    +                items=items,
    +                conflict_resolution=conflict_resolution,
    +                message_disposition=message_disposition,
    +                send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations,
    +                suppress_read_receipts=suppress_read_receipts,
    +            )
    +        )
     
         def _elem_to_obj(self, elem):
             return Item.id_from_xml(elem)
    @@ -179,7 +209,7 @@ 

    Classes

    if target.__class__ == CalendarItem: # For CalendarItem items where we update 'start' or 'end', we want to update internal timezone fields target.clean_timezone_fields(version=self.account.version) # Possibly also sets timezone values - for field_name in ('start', 'end'): + for field_name in ("start", "end"): if field_name in fieldnames_copy: tz_field_name = target.tz_field_for_field_name(field_name).name if tz_field_name not in fieldnames_copy: @@ -192,7 +222,7 @@

    Classes

    if target.__class__ == CalendarItem: # For CalendarItem items where we update 'start' or 'end', we want to send values in the local timezone - if field.name in ('start', 'end'): + if field.name in ("start", "end"): if type(value) is EWSDate: # EWS always expects a datetime return target.date_to_datetime(field_name=field.name) @@ -204,8 +234,14 @@

    Classes

    def _target_elem(self, target): return to_item_id(target, ItemId) - def get_payload(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations, - suppress_read_receipts): + def get_payload( + self, + items, + conflict_resolution, + message_disposition, + send_meeting_invitations_or_cancellations, + suppress_read_receipts, + ): # Takes a list of (Item, fieldnames) tuples where 'Item' is a instance of a subclass of Item and 'fieldnames' # are the attribute names that were updated. attrs = dict( @@ -214,8 +250,8 @@

    Classes

    SendMeetingInvitationsOrCancellations=send_meeting_invitations_or_cancellations, ) if self.account.version.build >= EXCHANGE_2013_SP1: - attrs['SuppressReadReceipts'] = suppress_read_receipts - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + attrs["SuppressReadReceipts"] = suppress_read_receipts + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=attrs) payload.append(self._changes_elem(target_changes=items)) return payload
    @@ -263,27 +299,36 @@

    Methods

    Expand source code -
    def call(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations,
    -         suppress_read_receipts):
    +
    def call(
    +    self,
    +    items,
    +    conflict_resolution,
    +    message_disposition,
    +    send_meeting_invitations_or_cancellations,
    +    suppress_read_receipts,
    +):
         if conflict_resolution not in CONFLICT_RESOLUTION_CHOICES:
    -        raise InvalidEnumValue('conflict_resolution', conflict_resolution, CONFLICT_RESOLUTION_CHOICES)
    +        raise InvalidEnumValue("conflict_resolution", conflict_resolution, CONFLICT_RESOLUTION_CHOICES)
         if message_disposition not in MESSAGE_DISPOSITION_CHOICES:
    -        raise InvalidEnumValue('message_disposition', message_disposition, MESSAGE_DISPOSITION_CHOICES)
    +        raise InvalidEnumValue("message_disposition", message_disposition, MESSAGE_DISPOSITION_CHOICES)
         if send_meeting_invitations_or_cancellations not in SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES:
             raise InvalidEnumValue(
    -            'send_meeting_invitations_or_cancellations', send_meeting_invitations_or_cancellations,
    -            SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES
    +            "send_meeting_invitations_or_cancellations",
    +            send_meeting_invitations_or_cancellations,
    +            SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES,
             )
         if message_disposition == SEND_ONLY:
    -        raise ValueError('Cannot send-only existing objects. Use SendItem service instead')
    -    return self._elems_to_objs(self._chunked_get_elements(
    -        self.get_payload,
    -        items=items,
    -        conflict_resolution=conflict_resolution,
    -        message_disposition=message_disposition,
    -        send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations,
    -        suppress_read_receipts=suppress_read_receipts,
    -    ))
    + raise ValueError("Cannot send-only existing objects. Use SendItem service instead") + return self._elems_to_objs( + self._chunked_get_elements( + self.get_payload, + items=items, + conflict_resolution=conflict_resolution, + message_disposition=message_disposition, + send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations, + suppress_read_receipts=suppress_read_receipts, + ) + )
    @@ -295,8 +340,14 @@

    Methods

    Expand source code -
    def get_payload(self, items, conflict_resolution, message_disposition, send_meeting_invitations_or_cancellations,
    -                suppress_read_receipts):
    +
    def get_payload(
    +    self,
    +    items,
    +    conflict_resolution,
    +    message_disposition,
    +    send_meeting_invitations_or_cancellations,
    +    suppress_read_receipts,
    +):
         # Takes a list of (Item, fieldnames) tuples where 'Item' is a instance of a subclass of Item and 'fieldnames'
         # are the attribute names that were updated.
         attrs = dict(
    @@ -305,8 +356,8 @@ 

    Methods

    SendMeetingInvitationsOrCancellations=send_meeting_invitations_or_cancellations, ) if self.account.version.build >= EXCHANGE_2013_SP1: - attrs['SuppressReadReceipts'] = suppress_read_receipts - payload = create_element(f'm:{self.SERVICE_NAME}', attrs=attrs) + attrs["SuppressReadReceipts"] = suppress_read_receipts + payload = create_element(f"m:{self.SERVICE_NAME}", attrs=attrs) payload.append(self._changes_elem(target_changes=items)) return payload
    diff --git a/docs/exchangelib/services/update_user_configuration.html b/docs/exchangelib/services/update_user_configuration.html index c7bbfb11..2e2747df 100644 --- a/docs/exchangelib/services/update_user_configuration.html +++ b/docs/exchangelib/services/update_user_configuration.html @@ -26,8 +26,8 @@

    Module exchangelib.services.update_user_configuration Expand source code -
    from .common import EWSAccountService
    -from ..util import create_element, set_xml_value
    +
    from ..util import create_element, set_xml_value
    +from .common import EWSAccountService
     
     
     class UpdateUserConfiguration(EWSAccountService):
    @@ -35,14 +35,14 @@ 

    Module exchangelib.services.update_user_configuration

    + return set_xml_value(create_element(f"m:{self.SERVICE_NAME}"), user_configuration, version=self.account.version)
    @@ -70,14 +70,14 @@

    Classes

    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateuserconfiguration-operation """ - SERVICE_NAME = 'UpdateUserConfiguration' + SERVICE_NAME = "UpdateUserConfiguration" returns_elements = False def call(self, user_configuration): return self._get_elements(payload=self.get_payload(user_configuration=user_configuration)) def get_payload(self, user_configuration): - return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), user_configuration, version=self.account.version)

    + return set_xml_value(create_element(f"m:{self.SERVICE_NAME}"), user_configuration, version=self.account.version)

    Ancestors

      @@ -120,7 +120,7 @@

      Methods

      Expand source code
      def get_payload(self, user_configuration):
      -    return set_xml_value(create_element(f'm:{self.SERVICE_NAME}'), user_configuration, version=self.account.version)
      + return set_xml_value(create_element(f"m:{self.SERVICE_NAME}"), user_configuration, version=self.account.version)
    diff --git a/docs/exchangelib/services/upload_items.html b/docs/exchangelib/services/upload_items.html index 8a527c4d..8a53732e 100644 --- a/docs/exchangelib/services/upload_items.html +++ b/docs/exchangelib/services/upload_items.html @@ -26,17 +26,16 @@

    Module exchangelib.services.upload_items

    Expand source code -
    from .common import EWSAccountService, to_item_id
    -from ..properties import ItemId, ParentFolderId
    -from ..util import create_element, set_xml_value, add_xml_child, MNS
    +
    from ..properties import ItemId, ParentFolderId
    +from ..util import MNS, add_xml_child, create_element, set_xml_value
    +from .common import EWSAccountService, to_item_id
     
     
     class UploadItems(EWSAccountService):
    -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/uploaditems-operation
    -    """
    +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/uploaditems-operation"""
     
    -    SERVICE_NAME = 'UploadItems'
    -    element_container_name = f'{{{MNS}}}ItemId'
    +    SERVICE_NAME = "UploadItems"
    +    element_container_name = f"{{{MNS}}}ItemId"
     
         def call(self, items):
             # _pool_requests expects 'items', not 'data'
    @@ -52,19 +51,19 @@ 

    Module exchangelib.services.upload_items

    :param items: """ - payload = create_element(f'm:{self.SERVICE_NAME}') - items_elem = create_element('m:Items') + payload = create_element(f"m:{self.SERVICE_NAME}") + items_elem = create_element("m:Items") payload.append(items_elem) for parent_folder, (item_id, is_associated, data_str) in items: # TODO: The full spec also allows the "UpdateOrCreate" create action. - attrs = dict(CreateAction='Update' if item_id else 'CreateNew') + attrs = dict(CreateAction="Update" if item_id else "CreateNew") if is_associated is not None: - attrs['IsAssociated'] = is_associated - item = create_element('t:Item', attrs=attrs) + attrs["IsAssociated"] = is_associated + item = create_element("t:Item", attrs=attrs) set_xml_value(item, ParentFolderId(parent_folder.id, parent_folder.changekey), version=self.account.version) if item_id: set_xml_value(item, to_item_id(item_id, ItemId), version=self.account.version) - add_xml_child(item, 't:Data', data_str) + add_xml_child(item, "t:Data", data_str) items_elem.append(item) return payload @@ -96,11 +95,10 @@

    Classes

    Expand source code
    class UploadItems(EWSAccountService):
    -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/uploaditems-operation
    -    """
    +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/uploaditems-operation"""
     
    -    SERVICE_NAME = 'UploadItems'
    -    element_container_name = f'{{{MNS}}}ItemId'
    +    SERVICE_NAME = "UploadItems"
    +    element_container_name = f"{{{MNS}}}ItemId"
     
         def call(self, items):
             # _pool_requests expects 'items', not 'data'
    @@ -116,19 +114,19 @@ 

    Classes

    :param items: """ - payload = create_element(f'm:{self.SERVICE_NAME}') - items_elem = create_element('m:Items') + payload = create_element(f"m:{self.SERVICE_NAME}") + items_elem = create_element("m:Items") payload.append(items_elem) for parent_folder, (item_id, is_associated, data_str) in items: # TODO: The full spec also allows the "UpdateOrCreate" create action. - attrs = dict(CreateAction='Update' if item_id else 'CreateNew') + attrs = dict(CreateAction="Update" if item_id else "CreateNew") if is_associated is not None: - attrs['IsAssociated'] = is_associated - item = create_element('t:Item', attrs=attrs) + attrs["IsAssociated"] = is_associated + item = create_element("t:Item", attrs=attrs) set_xml_value(item, ParentFolderId(parent_folder.id, parent_folder.changekey), version=self.account.version) if item_id: set_xml_value(item, to_item_id(item_id, ItemId), version=self.account.version) - add_xml_child(item, 't:Data', data_str) + add_xml_child(item, "t:Data", data_str) items_elem.append(item) return payload @@ -195,19 +193,19 @@

    Methods

    :param items: """ - payload = create_element(f'm:{self.SERVICE_NAME}') - items_elem = create_element('m:Items') + payload = create_element(f"m:{self.SERVICE_NAME}") + items_elem = create_element("m:Items") payload.append(items_elem) for parent_folder, (item_id, is_associated, data_str) in items: # TODO: The full spec also allows the "UpdateOrCreate" create action. - attrs = dict(CreateAction='Update' if item_id else 'CreateNew') + attrs = dict(CreateAction="Update" if item_id else "CreateNew") if is_associated is not None: - attrs['IsAssociated'] = is_associated - item = create_element('t:Item', attrs=attrs) + attrs["IsAssociated"] = is_associated + item = create_element("t:Item", attrs=attrs) set_xml_value(item, ParentFolderId(parent_folder.id, parent_folder.changekey), version=self.account.version) if item_id: set_xml_value(item, to_item_id(item_id, ItemId), version=self.account.version) - add_xml_child(item, 't:Data', data_str) + add_xml_child(item, "t:Data", data_str) items_elem.append(item) return payload
    diff --git a/docs/exchangelib/settings.html b/docs/exchangelib/settings.html index 66bce215..d4bf01e8 100644 --- a/docs/exchangelib/settings.html +++ b/docs/exchangelib/settings.html @@ -29,7 +29,7 @@

    Module exchangelib.settings

    import datetime
     
     from .ewsdatetime import UTC
    -from .fields import DateTimeField, MessageField, ChoiceField, Choice
    +from .fields import Choice, ChoiceField, DateTimeField, MessageField
     from .properties import EWSElement, OutOfOffice
     from .util import create_element, set_xml_value
     
    @@ -37,21 +37,22 @@ 

    Module exchangelib.settings

    class OofSettings(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/oofsettings""" - ELEMENT_NAME = 'OofSettings' - REQUEST_ELEMENT_NAME = 'UserOofSettings' + ELEMENT_NAME = "OofSettings" + REQUEST_ELEMENT_NAME = "UserOofSettings" - ENABLED = 'Enabled' - SCHEDULED = 'Scheduled' - DISABLED = 'Disabled' + ENABLED = "Enabled" + SCHEDULED = "Scheduled" + DISABLED = "Disabled" STATE_CHOICES = (ENABLED, SCHEDULED, DISABLED) - state = ChoiceField(field_uri='OofState', is_required=True, choices={Choice(c) for c in STATE_CHOICES}) - external_audience = ChoiceField(field_uri='ExternalAudience', - choices={Choice('None'), Choice('Known'), Choice('All')}, default='All') - start = DateTimeField(field_uri='StartTime') - end = DateTimeField(field_uri='EndTime') - internal_reply = MessageField(field_uri='InternalReply') - external_reply = MessageField(field_uri='ExternalReply') + state = ChoiceField(field_uri="OofState", is_required=True, choices={Choice(c) for c in STATE_CHOICES}) + external_audience = ChoiceField( + field_uri="ExternalAudience", choices={Choice("None"), Choice("Known"), Choice("All")}, default="All" + ) + start = DateTimeField(field_uri="StartTime") + end = DateTimeField(field_uri="EndTime") + internal_reply = MessageField(field_uri="InternalReply") + external_reply = MessageField(field_uri="ExternalReply") def clean(self, version=None): super().clean(version=version) @@ -68,7 +69,7 @@

    Module exchangelib.settings

    @classmethod def from_xml(cls, elem, account): kwargs = {} - for attr in ('state', 'external_audience', 'internal_reply', 'external_reply'): + for attr in ("state", "external_audience", "internal_reply", "external_reply"): f = cls.get_field_by_fieldname(attr) kwargs[attr] = f.from_xml(elem=elem, account=account) kwargs.update(OutOfOffice.duration_to_start_end(elem=elem, account=account)) @@ -77,24 +78,24 @@

    Module exchangelib.settings

    def to_xml(self, version): self.clean(version=version) - elem = create_element(f't:{self.REQUEST_ELEMENT_NAME}') - for attr in ('state', 'external_audience'): + elem = create_element(f"t:{self.REQUEST_ELEMENT_NAME}") + for attr in ("state", "external_audience"): value = getattr(self, attr) f = self.get_field_by_fieldname(attr) set_xml_value(elem, f.to_xml(value, version=version)) if self.start or self.end: - duration = create_element('t:Duration') + duration = create_element("t:Duration") if self.start: - f = self.get_field_by_fieldname('start') + f = self.get_field_by_fieldname("start") set_xml_value(duration, f.to_xml(self.start, version=version)) if self.end: - f = self.get_field_by_fieldname('end') + f = self.get_field_by_fieldname("end") set_xml_value(duration, f.to_xml(self.end, version=version)) elem.append(duration) - for attr in ('internal_reply', 'external_reply'): + for attr in ("internal_reply", "external_reply"): value = getattr(self, attr) if value is None: - value = '' # The value can be empty, but the XML element must always be present + value = "" # The value can be empty, but the XML element must always be present f = self.get_field_by_fieldname(attr) set_xml_value(elem, f.to_xml(value, version=version)) return elem @@ -103,10 +104,10 @@

    Module exchangelib.settings

    # Customize comparison if self.state == self.DISABLED: # All values except state are ignored by the server - relevant_attrs = ('state',) + relevant_attrs = ("state",) elif self.state != self.SCHEDULED: # 'start' and 'end' values are ignored by the server, and the server always returns today's date - relevant_attrs = tuple(f.name for f in self.FIELDS if f.name not in ('start', 'end')) + relevant_attrs = tuple(f.name for f in self.FIELDS if f.name not in ("start", "end")) else: relevant_attrs = tuple(f.name for f in self.FIELDS) return hash(tuple(getattr(self, attr) for attr in relevant_attrs))
    @@ -134,21 +135,22 @@

    Classes

    class OofSettings(EWSElement):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/oofsettings"""
     
    -    ELEMENT_NAME = 'OofSettings'
    -    REQUEST_ELEMENT_NAME = 'UserOofSettings'
    +    ELEMENT_NAME = "OofSettings"
    +    REQUEST_ELEMENT_NAME = "UserOofSettings"
     
    -    ENABLED = 'Enabled'
    -    SCHEDULED = 'Scheduled'
    -    DISABLED = 'Disabled'
    +    ENABLED = "Enabled"
    +    SCHEDULED = "Scheduled"
    +    DISABLED = "Disabled"
         STATE_CHOICES = (ENABLED, SCHEDULED, DISABLED)
     
    -    state = ChoiceField(field_uri='OofState', is_required=True, choices={Choice(c) for c in STATE_CHOICES})
    -    external_audience = ChoiceField(field_uri='ExternalAudience',
    -                                    choices={Choice('None'), Choice('Known'), Choice('All')}, default='All')
    -    start = DateTimeField(field_uri='StartTime')
    -    end = DateTimeField(field_uri='EndTime')
    -    internal_reply = MessageField(field_uri='InternalReply')
    -    external_reply = MessageField(field_uri='ExternalReply')
    +    state = ChoiceField(field_uri="OofState", is_required=True, choices={Choice(c) for c in STATE_CHOICES})
    +    external_audience = ChoiceField(
    +        field_uri="ExternalAudience", choices={Choice("None"), Choice("Known"), Choice("All")}, default="All"
    +    )
    +    start = DateTimeField(field_uri="StartTime")
    +    end = DateTimeField(field_uri="EndTime")
    +    internal_reply = MessageField(field_uri="InternalReply")
    +    external_reply = MessageField(field_uri="ExternalReply")
     
         def clean(self, version=None):
             super().clean(version=version)
    @@ -165,7 +167,7 @@ 

    Classes

    @classmethod def from_xml(cls, elem, account): kwargs = {} - for attr in ('state', 'external_audience', 'internal_reply', 'external_reply'): + for attr in ("state", "external_audience", "internal_reply", "external_reply"): f = cls.get_field_by_fieldname(attr) kwargs[attr] = f.from_xml(elem=elem, account=account) kwargs.update(OutOfOffice.duration_to_start_end(elem=elem, account=account)) @@ -174,24 +176,24 @@

    Classes

    def to_xml(self, version): self.clean(version=version) - elem = create_element(f't:{self.REQUEST_ELEMENT_NAME}') - for attr in ('state', 'external_audience'): + elem = create_element(f"t:{self.REQUEST_ELEMENT_NAME}") + for attr in ("state", "external_audience"): value = getattr(self, attr) f = self.get_field_by_fieldname(attr) set_xml_value(elem, f.to_xml(value, version=version)) if self.start or self.end: - duration = create_element('t:Duration') + duration = create_element("t:Duration") if self.start: - f = self.get_field_by_fieldname('start') + f = self.get_field_by_fieldname("start") set_xml_value(duration, f.to_xml(self.start, version=version)) if self.end: - f = self.get_field_by_fieldname('end') + f = self.get_field_by_fieldname("end") set_xml_value(duration, f.to_xml(self.end, version=version)) elem.append(duration) - for attr in ('internal_reply', 'external_reply'): + for attr in ("internal_reply", "external_reply"): value = getattr(self, attr) if value is None: - value = '' # The value can be empty, but the XML element must always be present + value = "" # The value can be empty, but the XML element must always be present f = self.get_field_by_fieldname(attr) set_xml_value(elem, f.to_xml(value, version=version)) return elem @@ -200,10 +202,10 @@

    Classes

    # Customize comparison if self.state == self.DISABLED: # All values except state are ignored by the server - relevant_attrs = ('state',) + relevant_attrs = ("state",) elif self.state != self.SCHEDULED: # 'start' and 'end' values are ignored by the server, and the server always returns today's date - relevant_attrs = tuple(f.name for f in self.FIELDS if f.name not in ('start', 'end')) + relevant_attrs = tuple(f.name for f in self.FIELDS if f.name not in ("start", "end")) else: relevant_attrs = tuple(f.name for f in self.FIELDS) return hash(tuple(getattr(self, attr) for attr in relevant_attrs))
    @@ -257,7 +259,7 @@

    Static methods

    @classmethod
     def from_xml(cls, elem, account):
         kwargs = {}
    -    for attr in ('state', 'external_audience', 'internal_reply', 'external_reply'):
    +    for attr in ("state", "external_audience", "internal_reply", "external_reply"):
             f = cls.get_field_by_fieldname(attr)
             kwargs[attr] = f.from_xml(elem=elem, account=account)
         kwargs.update(OutOfOffice.duration_to_start_end(elem=elem, account=account))
    @@ -328,24 +330,24 @@ 

    Methods

    def to_xml(self, version):
         self.clean(version=version)
    -    elem = create_element(f't:{self.REQUEST_ELEMENT_NAME}')
    -    for attr in ('state', 'external_audience'):
    +    elem = create_element(f"t:{self.REQUEST_ELEMENT_NAME}")
    +    for attr in ("state", "external_audience"):
             value = getattr(self, attr)
             f = self.get_field_by_fieldname(attr)
             set_xml_value(elem, f.to_xml(value, version=version))
         if self.start or self.end:
    -        duration = create_element('t:Duration')
    +        duration = create_element("t:Duration")
             if self.start:
    -            f = self.get_field_by_fieldname('start')
    +            f = self.get_field_by_fieldname("start")
                 set_xml_value(duration, f.to_xml(self.start, version=version))
             if self.end:
    -            f = self.get_field_by_fieldname('end')
    +            f = self.get_field_by_fieldname("end")
                 set_xml_value(duration, f.to_xml(self.end, version=version))
             elem.append(duration)
    -    for attr in ('internal_reply', 'external_reply'):
    +    for attr in ("internal_reply", "external_reply"):
             value = getattr(self, attr)
             if value is None:
    -            value = ''  # The value can be empty, but the XML element must always be present
    +            value = ""  # The value can be empty, but the XML element must always be present
             f = self.get_field_by_fieldname(attr)
             set_xml_value(elem, f.to_xml(value, version=version))
         return elem
    diff --git a/docs/exchangelib/transport.html b/docs/exchangelib/transport.html index a289c55e..15f3c988 100644 --- a/docs/exchangelib/transport.html +++ b/docs/exchangelib/transport.html @@ -33,21 +33,30 @@

    Module exchangelib.transport

    import requests_ntlm import requests_oauthlib -from .errors import UnauthorizedError, TransportError -from .util import create_element, add_xml_child, xml_to_str, ns_translation, _back_off_if_needed, \ - _retry_after, DummyResponse, CONNECTION_ERRORS, RETRY_WAIT +from .errors import TransportError, UnauthorizedError +from .util import ( + CONNECTION_ERRORS, + RETRY_WAIT, + DummyResponse, + _back_off_if_needed, + _retry_after, + add_xml_child, + create_element, + ns_translation, + xml_to_str, +) log = logging.getLogger(__name__) # Authentication method enums -NOAUTH = 'no authentication' -NTLM = 'NTLM' -BASIC = 'basic' -DIGEST = 'digest' -GSSAPI = 'gssapi' -SSPI = 'sspi' -OAUTH2 = 'OAuth 2.0' -CBA = 'CBA' # Certificate Based Authentication +NOAUTH = "no authentication" +NTLM = "NTLM" +BASIC = "basic" +DIGEST = "digest" +GSSAPI = "gssapi" +SSPI = "sspi" +OAUTH2 = "OAuth 2.0" +CBA = "CBA" # Certificate Based Authentication # The auth types that must be accompanied by a credentials object CREDENTIALS_REQUIRED = (NTLM, BASIC, DIGEST, OAUTH2) @@ -62,19 +71,21 @@

    Module exchangelib.transport

    } try: import requests_gssapi + AUTH_TYPE_MAP[GSSAPI] = requests_gssapi.HTTPSPNEGOAuth except ImportError: # Kerberos auth is optional pass try: import requests_negotiate_sspi + AUTH_TYPE_MAP[SSPI] = requests_negotiate_sspi.HttpNegotiateAuth except ImportError: # SSPI auth is optional pass -DEFAULT_ENCODING = 'utf-8' -DEFAULT_HEADERS = {'Content-Type': f'text/xml; charset={DEFAULT_ENCODING}', 'Accept-Encoding': 'gzip, deflate'} +DEFAULT_ENCODING = "utf-8" +DEFAULT_HEADERS = {"Content-Type": f"text/xml; charset={DEFAULT_ENCODING}", "Accept-Encoding": "gzip, deflate"} def wrap(content, api_version=None, account_to_impersonate=None, timezone=None): @@ -95,36 +106,36 @@

    Module exchangelib.transport

    :param account_to_impersonate: (Default value = None) :param timezone: (Default value = None) """ - envelope = create_element('s:Envelope', nsmap=ns_translation) - header = create_element('s:Header') + envelope = create_element("s:Envelope", nsmap=ns_translation) + header = create_element("s:Header") if api_version: - request_server_version = create_element('t:RequestServerVersion', attrs=dict(Version=api_version)) + request_server_version = create_element("t:RequestServerVersion", attrs=dict(Version=api_version)) header.append(request_server_version) if account_to_impersonate: - exchange_impersonation = create_element('t:ExchangeImpersonation') - connecting_sid = create_element('t:ConnectingSID') + exchange_impersonation = create_element("t:ExchangeImpersonation") + connecting_sid = create_element("t:ConnectingSID") # We have multiple options for uniquely identifying the user. Here's a prioritized list in accordance with # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/connectingsid for attr, tag in ( - ('sid', 'SID'), - ('upn', 'PrincipalName'), - ('smtp_address', 'SmtpAddress'), - ('primary_smtp_address', 'PrimarySmtpAddress'), + ("sid", "SID"), + ("upn", "PrincipalName"), + ("smtp_address", "SmtpAddress"), + ("primary_smtp_address", "PrimarySmtpAddress"), ): val = getattr(account_to_impersonate, attr) if val: - add_xml_child(connecting_sid, f't:{tag}', val) + add_xml_child(connecting_sid, f"t:{tag}", val) break exchange_impersonation.append(connecting_sid) header.append(exchange_impersonation) if timezone: - timezone_context = create_element('t:TimeZoneContext') - timezone_definition = create_element('t:TimeZoneDefinition', attrs=dict(Id=timezone.ms_id)) + timezone_context = create_element("t:TimeZoneContext") + timezone_definition = create_element("t:TimeZoneDefinition", attrs=dict(Id=timezone.ms_id)) timezone_context.append(timezone_definition) header.append(timezone_context) if len(header): envelope.append(header) - body = create_element('s:Body') + body = create_element("s:Body") body.append(content) envelope.append(body) return xml_to_str(envelope, encoding=DEFAULT_ENCODING, xml_declaration=True) @@ -155,19 +166,25 @@

    Module exchangelib.transport

    # We don't know the API version yet, but we need it to create a valid request because some Exchange servers only # respond when given a valid request. Try all known versions. Gross. from .protocol import BaseProtocol + retry = 0 t_start = time.monotonic() headers = DEFAULT_HEADERS.copy() for api_version in api_versions: data = dummy_xml(api_version=api_version, name=name) - log.debug('Requesting %s from %s', data, service_endpoint) + log.debug("Requesting %s from %s", data, service_endpoint) while True: _back_off_if_needed(retry_policy.back_off_until) - log.debug('Trying to get service auth type for %s', service_endpoint) + log.debug("Trying to get service auth type for %s", service_endpoint) with BaseProtocol.raw_session(service_endpoint) as s: try: - r = s.post(url=service_endpoint, headers=headers, data=data, allow_redirects=False, - timeout=BaseProtocol.TIMEOUT) + r = s.post( + url=service_endpoint, + headers=headers, + data=data, + allow_redirects=False, + timeout=BaseProtocol.TIMEOUT, + ) r.close() # Release memory break except CONNECTION_ERRORS as e: @@ -176,66 +193,71 @@

    Module exchangelib.transport

    r = DummyResponse(url=service_endpoint, request_headers=headers) if retry_policy.may_retry_on_error(response=r, wait=total_wait): wait = _retry_after(r, RETRY_WAIT) - log.info("Connection error on URL %s (retry %s, error: %s). Cool down %s secs", - service_endpoint, retry, e, wait) + log.info( + "Connection error on URL %s (retry %s, error: %s). Cool down %s secs", + service_endpoint, + retry, + e, + wait, + ) retry_policy.back_off(wait) retry += 1 continue raise TransportError(str(e)) from e if r.status_code not in (200, 401): - log.debug('Unexpected response: %s %s', r.status_code, r.reason) + log.debug("Unexpected response: %s %s", r.status_code, r.reason) continue try: auth_type = get_auth_method_from_response(response=r) - log.debug('Auth type is %s', auth_type) + log.debug("Auth type is %s", auth_type) return auth_type, api_version except UnauthorizedError: continue - raise TransportError('Failed to get auth type from service') + raise TransportError("Failed to get auth type from service") def get_auth_method_from_response(response): # First, get the auth method from headers. Then, test credentials. Don't handle redirects - burden is on caller. - log.debug('Request headers: %s', response.request.headers) - log.debug('Response headers: %s', response.headers) + log.debug("Request headers: %s", response.request.headers) + log.debug("Response headers: %s", response.headers) if response.status_code == 200: return NOAUTH # Get auth type from headers for key, val in response.headers.items(): - if key.lower() == 'www-authenticate': + if key.lower() == "www-authenticate": # Requests will combine multiple HTTP headers into one in 'request.headers' vals = _tokenize(val.lower()) for v in vals: - if v.startswith('realm'): - realm = v.split('=')[1].strip('"') - log.debug('realm: %s', realm) + if v.startswith("realm"): + realm = v.split("=")[1].strip('"') + log.debug("realm: %s", realm) # Prefer most secure auth method if more than one is offered. See discussion at # http://docs.oracle.com/javase/7/docs/technotes/guides/net/http-auth.html - if 'digest' in vals: + if "digest" in vals: return DIGEST - if 'ntlm' in vals: + if "ntlm" in vals: return NTLM - if 'basic' in vals: + if "basic" in vals: return BASIC - raise UnauthorizedError('No compatible auth type was reported by server') + raise UnauthorizedError("No compatible auth type was reported by server") def _tokenize(val): # Splits cookie auth values auth_methods = [] - auth_method = '' + auth_method = "" quote = False for c in val: - if c in (' ', ',') and not quote: - if auth_method not in ('', ','): + if c in (" ", ",") and not quote: + if auth_method not in ("", ","): auth_methods.append(auth_method) - auth_method = '' + auth_method = "" continue if c == '"': auth_method += c if quote: auth_methods.append(auth_method) - auth_method = '' + auth_method = "" quote = not quote continue auth_method += c @@ -247,13 +269,17 @@

    Module exchangelib.transport

    def dummy_xml(api_version, name): # Generate a minimal, valid EWS request from .services import ResolveNames # Avoid circular import - return wrap(content=ResolveNames(protocol=None).get_payload( - unresolved_entries=[name], - parent_folders=None, - return_full_contact_data=False, - search_scope=None, - contact_data_shape=None, - ), api_version=api_version)
    + + return wrap( + content=ResolveNames(protocol=None).get_payload( + unresolved_entries=[name], + parent_folders=None, + return_full_contact_data=False, + search_scope=None, + contact_data_shape=None, + ), + api_version=api_version, + )
    @@ -275,13 +301,17 @@

    Functions

    def dummy_xml(api_version, name):
         # Generate a minimal, valid EWS request
         from .services import ResolveNames  # Avoid circular import
    -    return wrap(content=ResolveNames(protocol=None).get_payload(
    -        unresolved_entries=[name],
    -        parent_folders=None,
    -        return_full_contact_data=False,
    -        search_scope=None,
    -        contact_data_shape=None,
    -    ), api_version=api_version)
    + + return wrap( + content=ResolveNames(protocol=None).get_payload( + unresolved_entries=[name], + parent_folders=None, + return_full_contact_data=False, + search_scope=None, + contact_data_shape=None, + ), + api_version=api_version, + )
    @@ -324,28 +354,28 @@

    Functions

    def get_auth_method_from_response(response):
         # First, get the auth method from headers. Then, test credentials. Don't handle redirects - burden is on caller.
    -    log.debug('Request headers: %s', response.request.headers)
    -    log.debug('Response headers: %s', response.headers)
    +    log.debug("Request headers: %s", response.request.headers)
    +    log.debug("Response headers: %s", response.headers)
         if response.status_code == 200:
             return NOAUTH
         # Get auth type from headers
         for key, val in response.headers.items():
    -        if key.lower() == 'www-authenticate':
    +        if key.lower() == "www-authenticate":
                 # Requests will combine multiple HTTP headers into one in 'request.headers'
                 vals = _tokenize(val.lower())
                 for v in vals:
    -                if v.startswith('realm'):
    -                    realm = v.split('=')[1].strip('"')
    -                    log.debug('realm: %s', realm)
    +                if v.startswith("realm"):
    +                    realm = v.split("=")[1].strip('"')
    +                    log.debug("realm: %s", realm)
                 # Prefer most secure auth method if more than one is offered. See discussion at
                 # http://docs.oracle.com/javase/7/docs/technotes/guides/net/http-auth.html
    -            if 'digest' in vals:
    +            if "digest" in vals:
                     return DIGEST
    -            if 'ntlm' in vals:
    +            if "ntlm" in vals:
                     return NTLM
    -            if 'basic' in vals:
    +            if "basic" in vals:
                     return BASIC
    -    raise UnauthorizedError('No compatible auth type was reported by server')
    + raise UnauthorizedError("No compatible auth type was reported by server")
    @@ -364,19 +394,25 @@

    Functions

    # We don't know the API version yet, but we need it to create a valid request because some Exchange servers only # respond when given a valid request. Try all known versions. Gross. from .protocol import BaseProtocol + retry = 0 t_start = time.monotonic() headers = DEFAULT_HEADERS.copy() for api_version in api_versions: data = dummy_xml(api_version=api_version, name=name) - log.debug('Requesting %s from %s', data, service_endpoint) + log.debug("Requesting %s from %s", data, service_endpoint) while True: _back_off_if_needed(retry_policy.back_off_until) - log.debug('Trying to get service auth type for %s', service_endpoint) + log.debug("Trying to get service auth type for %s", service_endpoint) with BaseProtocol.raw_session(service_endpoint) as s: try: - r = s.post(url=service_endpoint, headers=headers, data=data, allow_redirects=False, - timeout=BaseProtocol.TIMEOUT) + r = s.post( + url=service_endpoint, + headers=headers, + data=data, + allow_redirects=False, + timeout=BaseProtocol.TIMEOUT, + ) r.close() # Release memory break except CONNECTION_ERRORS as e: @@ -385,22 +421,27 @@

    Functions

    r = DummyResponse(url=service_endpoint, request_headers=headers) if retry_policy.may_retry_on_error(response=r, wait=total_wait): wait = _retry_after(r, RETRY_WAIT) - log.info("Connection error on URL %s (retry %s, error: %s). Cool down %s secs", - service_endpoint, retry, e, wait) + log.info( + "Connection error on URL %s (retry %s, error: %s). Cool down %s secs", + service_endpoint, + retry, + e, + wait, + ) retry_policy.back_off(wait) retry += 1 continue raise TransportError(str(e)) from e if r.status_code not in (200, 401): - log.debug('Unexpected response: %s %s', r.status_code, r.reason) + log.debug("Unexpected response: %s %s", r.status_code, r.reason) continue try: auth_type = get_auth_method_from_response(response=r) - log.debug('Auth type is %s', auth_type) + log.debug("Auth type is %s", auth_type) return auth_type, api_version except UnauthorizedError: continue - raise TransportError('Failed to get auth type from service')
    + raise TransportError("Failed to get auth type from service")
    @@ -443,36 +484,36 @@

    Functions

    :param account_to_impersonate: (Default value = None) :param timezone: (Default value = None) """ - envelope = create_element('s:Envelope', nsmap=ns_translation) - header = create_element('s:Header') + envelope = create_element("s:Envelope", nsmap=ns_translation) + header = create_element("s:Header") if api_version: - request_server_version = create_element('t:RequestServerVersion', attrs=dict(Version=api_version)) + request_server_version = create_element("t:RequestServerVersion", attrs=dict(Version=api_version)) header.append(request_server_version) if account_to_impersonate: - exchange_impersonation = create_element('t:ExchangeImpersonation') - connecting_sid = create_element('t:ConnectingSID') + exchange_impersonation = create_element("t:ExchangeImpersonation") + connecting_sid = create_element("t:ConnectingSID") # We have multiple options for uniquely identifying the user. Here's a prioritized list in accordance with # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/connectingsid for attr, tag in ( - ('sid', 'SID'), - ('upn', 'PrincipalName'), - ('smtp_address', 'SmtpAddress'), - ('primary_smtp_address', 'PrimarySmtpAddress'), + ("sid", "SID"), + ("upn", "PrincipalName"), + ("smtp_address", "SmtpAddress"), + ("primary_smtp_address", "PrimarySmtpAddress"), ): val = getattr(account_to_impersonate, attr) if val: - add_xml_child(connecting_sid, f't:{tag}', val) + add_xml_child(connecting_sid, f"t:{tag}", val) break exchange_impersonation.append(connecting_sid) header.append(exchange_impersonation) if timezone: - timezone_context = create_element('t:TimeZoneContext') - timezone_definition = create_element('t:TimeZoneDefinition', attrs=dict(Id=timezone.ms_id)) + timezone_context = create_element("t:TimeZoneContext") + timezone_definition = create_element("t:TimeZoneDefinition", attrs=dict(Id=timezone.ms_id)) timezone_context.append(timezone_definition) header.append(timezone_context) if len(header): envelope.append(header) - body = create_element('s:Body') + body = create_element("s:Body") body.append(content) envelope.append(body) return xml_to_str(envelope, encoding=DEFAULT_ENCODING, xml_declaration=True)
    diff --git a/docs/exchangelib/util.html b/docs/exchangelib/util.html index 53292197..9e0959bc 100644 --- a/docs/exchangelib/util.html +++ b/docs/exchangelib/util.html @@ -51,19 +51,26 @@

    Module exchangelib.util

    from pygments.formatters.terminal import TerminalFormatter from pygments.lexers.html import XmlLexer -from .errors import TransportError, RateLimitError, RedirectError, RelativeRedirect, MalformedResponseError, \ - InvalidTypeError +from .errors import ( + InvalidTypeError, + MalformedResponseError, + RateLimitError, + RedirectError, + RelativeRedirect, + TransportError, +) log = logging.getLogger(__name__) -xml_log = logging.getLogger(f'{__name__}.xml') +xml_log = logging.getLogger(f"{__name__}.xml") def require_account(f): @wraps(f) def wrapper(self, *args, **kwargs): if not self.account: - raise ValueError(f'{self.__class__.__name__} must have an account') + raise ValueError(f"{self.__class__.__name__} must have an account") return f(self, *args, **kwargs) + return wrapper @@ -71,10 +78,11 @@

    Module exchangelib.util

    @wraps(f) def wrapper(self, *args, **kwargs): if not self.account: - raise ValueError(f'{self.__class__.__name__} must have an account') + raise ValueError(f"{self.__class__.__name__} must have an account") if not self.id: - raise ValueError(f'{self.__class__.__name__} must have an ID') + raise ValueError(f"{self.__class__.__name__} must have an ID") return f(self, *args, **kwargs) + return wrapper @@ -91,21 +99,21 @@

    Module exchangelib.util

    # Regex of UTF-8 control characters that are illegal in XML 1.0 (and XML 1.1) -_ILLEGAL_XML_CHARS_RE = re.compile('[\x00-\x08\x0b\x0c\x0e-\x1F\uD800-\uDFFF\uFFFE\uFFFF]') +_ILLEGAL_XML_CHARS_RE = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1F\uD800-\uDFFF\uFFFE\uFFFF]") # XML namespaces -SOAPNS = 'http://schemas.xmlsoap.org/soap/envelope/' -MNS = 'http://schemas.microsoft.com/exchange/services/2006/messages' -TNS = 'http://schemas.microsoft.com/exchange/services/2006/types' -ENS = 'http://schemas.microsoft.com/exchange/services/2006/errors' -AUTODISCOVER_BASE_NS = 'http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006' -AUTODISCOVER_REQUEST_NS = 'http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006' -AUTODISCOVER_RESPONSE_NS = 'http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a' +SOAPNS = "http://schemas.xmlsoap.org/soap/envelope/" +MNS = "http://schemas.microsoft.com/exchange/services/2006/messages" +TNS = "http://schemas.microsoft.com/exchange/services/2006/types" +ENS = "http://schemas.microsoft.com/exchange/services/2006/errors" +AUTODISCOVER_BASE_NS = "http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006" +AUTODISCOVER_REQUEST_NS = "http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006" +AUTODISCOVER_RESPONSE_NS = "http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a" ns_translation = { - 's': SOAPNS, - 'm': MNS, - 't': TNS, + "s": SOAPNS, + "m": MNS, + "t": TNS, } for item in ns_translation.items(): lxml.etree.register_namespace(*item) @@ -122,7 +130,7 @@

    Module exchangelib.util

    :return: True or False """ if generators_allowed: - if not isinstance(value, (bytes, str)) and hasattr(value, '__iter__'): + if not isinstance(value, (bytes, str)) and hasattr(value, "__iter__"): return True else: if isinstance(value, (tuple, list, set)): @@ -138,10 +146,11 @@

    Module exchangelib.util

    :return: """ from .queryset import QuerySet - if hasattr(iterable, '__getitem__') and not isinstance(iterable, QuerySet): + + if hasattr(iterable, "__getitem__") and not isinstance(iterable, QuerySet): # tuple, list. QuerySet has __getitem__ but that evaluates the entire query greedily. We don't want that here. for i in range(0, len(iterable), chunksize): - yield iterable[i:i + chunksize] + yield iterable[i : i + chunksize] else: # generator, set, map, QuerySet chunk = [] @@ -160,7 +169,7 @@

    Module exchangelib.util

    :param iterable: :return: """ - if hasattr(iterable, '__len__'): + if hasattr(iterable, "__len__"): # tuple, list, set return not iterable, iterable # generator @@ -199,16 +208,17 @@

    Module exchangelib.util

    def value_to_xml_text(value): - from .ewsdatetime import EWSTimeZone, EWSDateTime, EWSDate - from .indexed_properties import PhoneNumber, EmailAddress - from .properties import Mailbox, AssociatedCalendarItemId, Attendee, ConversationId + from .ewsdatetime import EWSDate, EWSDateTime, EWSTimeZone + from .indexed_properties import EmailAddress, PhoneNumber + from .properties import AssociatedCalendarItemId, Attendee, ConversationId, Mailbox + # We can't just create a map and look up with type(value) because we want to support subtypes if isinstance(value, str): return safe_xml_value(value) if isinstance(value, bool): - return '1' if value else '0' + return "1" if value else "0" if isinstance(value, bytes): - return b64encode(value).decode('ascii') + return b64encode(value).decode("ascii") if isinstance(value, (int, Decimal)): return str(value) if isinstance(value, datetime.time): @@ -231,20 +241,21 @@

    Module exchangelib.util

    return value.id if isinstance(value, AssociatedCalendarItemId): return value.id - raise TypeError(f'Unsupported type: {type(value)} ({value})') + raise TypeError(f"Unsupported type: {type(value)} ({value})") def xml_text_to_value(value, value_type): from .ewsdatetime import EWSDate, EWSDateTime + if value_type == str: return value if value_type == bool: try: return { - 'true': True, - 'on': True, - 'false': False, - 'off': False, + "true": True, + "on": True, + "false": False, + "off": False, }[value.lower()] except KeyError: return None @@ -259,10 +270,11 @@

    Module exchangelib.util

    def set_xml_value(elem, value, version=None): - from .ewsdatetime import EWSDateTime, EWSDate - from .fields import FieldPath, FieldOrder + from .ewsdatetime import EWSDate, EWSDateTime + from .fields import FieldOrder, FieldPath from .properties import EWSElement from .version import Version + if isinstance(value, (str, bool, bytes, int, Decimal, datetime.time, EWSDate, EWSDateTime)): elem.text = value_to_xml_text(value) elif isinstance(value, _element_class): @@ -271,31 +283,31 @@

    Module exchangelib.util

    elem.append(value.to_xml()) elif isinstance(value, EWSElement): if not isinstance(version, Version): - raise InvalidTypeError('version', version, Version) + raise InvalidTypeError("version", version, Version) elem.append(value.to_xml(version=version)) elif is_iterable(value, generators_allowed=True): for v in value: set_xml_value(elem, v, version=version) else: - raise ValueError(f'Unsupported type {type(value)} for value {value} on elem {elem}') + raise ValueError(f"Unsupported type {type(value)} for value {value} on elem {elem}") return elem -def safe_xml_value(value, replacement='?'): +def safe_xml_value(value, replacement="?"): return _ILLEGAL_XML_CHARS_RE.sub(replacement, value) def create_element(name, attrs=None, nsmap=None): - if ':' in name: - ns, name = name.split(':') - name = f'{{{ns_translation[ns]}}}{name}' + if ":" in name: + ns, name = name.split(":") + name = f"{{{ns_translation[ns]}}}{name}" elem = _forgiving_parser.makeelement(name, nsmap=nsmap) if attrs: # Try hard to keep attribute order, to ensure deterministic output. This simplifies testing. # Dicts in Python 3.6+ have stable ordering. for k, v in attrs.items(): if isinstance(v, bool): - v = 'true' if v else 'false' + v = "true" if v else "false" elif isinstance(v, int): v = str(v) elem.set(k, v) @@ -351,9 +363,9 @@

    Module exchangelib.util

    overflow = len(data) % 4 if overflow: if isinstance(data, str): - padding = '=' * (4 - overflow) + padding = "=" * (4 - overflow) else: - padding = b'=' * (4 - overflow) + padding = b"=" * (4 - overflow) data += padding return b64decode(data) @@ -387,7 +399,7 @@

    Module exchangelib.util

    self.close() if not self.element_found: data = bytes(collected_data) - raise ElementNotFound('The element to be streamed from was not found', data=bytes(data)) + raise ElementNotFound("The element to be streamed from was not found", data=bytes(data)) def feed(self, data, isFinal=0): """Yield the current content of the character buffer.""" @@ -395,13 +407,13 @@

    Module exchangelib.util

    return self._decode_buffer() def _decode_buffer(self): - remainder = '' + remainder = "" for data in self.buffer: available = len(remainder) + len(data) overflow = available % 4 # Make sure we always decode a multiple of 4 if remainder: - data = (remainder + data) - remainder = '' + data = remainder + data + remainder = "" if overflow: remainder, data = data[-overflow:], data[:-overflow] if data: @@ -414,7 +426,7 @@

    Module exchangelib.util

    recover=True, # This setting is non-default huge_tree=True, # This setting enables parsing huge attachments, mime_content and other large data ) -_element_class = _forgiving_parser.makeelement('x').__class__ +_element_class = _forgiving_parser.makeelement("x").__class__ class BytesGeneratorIO(io.RawIOBase): @@ -441,7 +453,7 @@

    Module exchangelib.util

    if self.closed: raise ValueError("read from a closed file") if self._next is None: - return b'' + return b"" if size is None: size = -1 @@ -469,7 +481,7 @@

    Module exchangelib.util

    class DocumentYielder: """Look for XML documents in a streaming HTTP response and yield them as they become available from the stream.""" - def __init__(self, content_iterator, document_tag='Envelope'): + def __init__(self, content_iterator, document_tag="Envelope"): self._iterator = content_iterator self._document_tag = document_tag.encode() @@ -477,16 +489,16 @@

    Module exchangelib.util

    """Iterate over the bytes until we have a full tag in the buffer. If there's a '>' in an attr value, then we'll exit on that, but it's OK becaus wejust need the plain tag name later. """ - tag_buffer = [b'<'] + tag_buffer = [b"<"] while True: try: c = next(self._iterator) except StopIteration: break tag_buffer.append(c) - if c == b'>': + if c == b">": break - return b''.join(tag_buffer) + return b"".join(tag_buffer) @staticmethod def _normalize_tag(tag): @@ -496,7 +508,7 @@

    Module exchangelib.util

    * <ns:tag foo='bar'> * </ns:tag foo='bar'> """ - return tag.strip(b'<>/').split(b' ')[0].split(b':')[-1] + return tag.strip(b"<>/").split(b" ")[0].split(b":")[-1] def __iter__(self): """Consumes the content iterator, looking for start and end tags. Returns each document when we have fully @@ -507,18 +519,18 @@

    Module exchangelib.util

    try: while True: c = next(self._iterator) - if not doc_started and c == b'<': + if not doc_started and c == b"<": tag = self._get_tag() if self._normalize_tag(tag) == self._document_tag: # Start of document. Collect bytes from this point buffer.append(tag) doc_started = True - elif doc_started and c == b'<': + elif doc_started and c == b"<": tag = self._get_tag() buffer.append(tag) if self._normalize_tag(tag) == self._document_tag: # End of document. Yield a valid document and reset the buffer - yield b"<?xml version='1.0' encoding='utf-8'?>\n" + b''.join(buffer) + yield b"<?xml version='1.0' encoding='utf-8'?>\n" + b"".join(buffer) doc_started = False buffer = [] elif doc_started: @@ -537,39 +549,39 @@

    Module exchangelib.util

    try: res = lxml.etree.parse(stream, parser=_forgiving_parser) # nosec except AssertionError as e: - raise ParseError(e.args[0], '<not from file>', -1, 0) + raise ParseError(e.args[0], "<not from file>", -1, 0) except lxml.etree.ParseError as e: - if hasattr(e, 'position'): + if hasattr(e, "position"): e.lineno, e.offset = e.position if not e.lineno: - raise ParseError(str(e), '<not from file>', e.lineno, e.offset) + raise ParseError(str(e), "<not from file>", e.lineno, e.offset) try: stream.seek(0) offending_line = stream.read().splitlines()[e.lineno - 1] except (IndexError, io.UnsupportedOperation): - raise ParseError(str(e), '<not from file>', e.lineno, e.offset) + raise ParseError(str(e), "<not from file>", e.lineno, e.offset) else: - offending_excerpt = offending_line[max(0, e.offset - 20):e.offset + 20] + offending_excerpt = offending_line[max(0, e.offset - 20) : e.offset + 20] msg = f'{e}\nOffending text: [...]{offending_excerpt.decode("utf-8", errors="ignore")}[...]' - raise ParseError(msg, '<not from file>', e.lineno, e.offset) + raise ParseError(msg, "<not from file>", e.lineno, e.offset) except TypeError: try: stream.seek(0) except (IndexError, io.UnsupportedOperation): pass - raise ParseError(f'This is not XML: {stream.read()!r}', '<not from file>', -1, 0) + raise ParseError(f"This is not XML: {stream.read()!r}", "<not from file>", -1, 0) if res.getroot() is None: try: stream.seek(0) - msg = f'No root element found: {stream.read()!r}' + msg = f"No root element found: {stream.read()!r}" except (IndexError, io.UnsupportedOperation): - msg = 'No root element found' - raise ParseError(msg, '<not from file>', -1, 0) + msg = "No root element found" + raise ParseError(msg, "<not from file>", -1, 0) return res -def is_xml(text, expected_prefix=b'<?xml'): +def is_xml(text, expected_prefix=b"<?xml"): """Lightweight test if response is an XML doc. It's better to be fast than correct here. :param text: The string to check @@ -580,7 +592,7 @@

    Module exchangelib.util

    bom_len = len(BOM_UTF8) prefix_len = len(expected_prefix) if text[:bom_len] == BOM_UTF8: - prefix = text[bom_len:bom_len + prefix_len] + prefix = text[bom_len : bom_len + prefix_len] else: prefix = text[:prefix_len] return prefix == expected_prefix @@ -596,12 +608,11 @@

    Module exchangelib.util

    @classmethod def prettify_xml(cls, xml_bytes): """Re-format an XML document to a consistent style.""" - return lxml.etree.tostring( - cls.parse_bytes(xml_bytes), - xml_declaration=True, - encoding='utf-8', - pretty_print=True - ).replace(b'\t', b' ').replace(b' xmlns:', b'\n xmlns:') + return ( + lxml.etree.tostring(cls.parse_bytes(xml_bytes), xml_declaration=True, encoding="utf-8", pretty_print=True) + .replace(b"\t", b" ") + .replace(b" xmlns:", b"\n xmlns:") + ) @staticmethod def highlight_xml(xml_str): @@ -618,7 +629,7 @@

    Module exchangelib.util

    """ if record.levelno == logging.DEBUG and self.is_tty() and isinstance(record.args, dict): for key, value in record.args.items(): - if not key.startswith('xml_'): + if not key.startswith("xml_"): continue if not isinstance(value, bytes): continue @@ -628,7 +639,7 @@

    Module exchangelib.util

    record.args[key] = self.highlight_xml(self.prettify_xml(value)) except Exception as e: # Something bad happened, but we don't want to crash the program just because logging failed - print(f'XML highlighting failed: {e}') + print(f"XML highlighting failed: {e}") return super().emit(record) def is_tty(self): @@ -641,7 +652,8 @@

    Module exchangelib.util

    class AnonymizingXmlHandler(PrettyXmlHandler): """A steaming log handler that prettifies and anonymizes log statements containing XML when output is a terminal.""" - PRIVATE_TAGS = {'RootItemId', 'ItemId', 'Id', 'RootItemChangeKey', 'ChangeKey'} + + PRIVATE_TAGS = {"RootItemId", "ItemId", "Id", "RootItemChangeKey", "ChangeKey"} def __init__(self, forbidden_strings, *args, **kwargs): self.forbidden_strings = forbidden_strings @@ -652,11 +664,11 @@

    Module exchangelib.util

    for elem in root.iter(): # Anonymize element attribute values known to contain private data for attr in set(elem.keys()) & self.PRIVATE_TAGS: - elem.set(attr, 'DEADBEEF=') + elem.set(attr, "DEADBEEF=") # Anonymize anything requested by the caller for s in self.forbidden_strings: if elem.text is not None: - elem.text = elem.text.replace(s, '[REMOVED]') + elem.text = elem.text.replace(s, "[REMOVED]") return root @@ -670,15 +682,16 @@

    Module exchangelib.util

    class DummyResponse: """A class to fake a requests Response object for functions that expect this.""" - def __init__(self, url=None, headers=None, request_headers=None, content=b'', status_code=503, streaming=False, - history=None): + def __init__( + self, url=None, headers=None, request_headers=None, content=b"", status_code=503, streaming=False, history=None + ): self.status_code = status_code self.url = url self.headers = headers or {} self.content = iter((bytes([b]) for b in content)) if streaming else content - self.text = content.decode('utf-8', errors='ignore') + self.text = content.decode("utf-8", errors="ignore") self.request = DummyRequest(headers=request_headers) - self.reason = '' + self.reason = "" self.history = history def iter_content(self): @@ -690,7 +703,7 @@

    Module exchangelib.util

    def get_domain(email): try: - return email.split('@')[1].lower() + return email.split("@")[1].lower() except (IndexError, AttributeError): raise ValueError(f"{email!r} is not a valid email") @@ -698,15 +711,15 @@

    Module exchangelib.util

    def split_url(url): parsed_url = urlparse(url) # Use netloc instead of hostname since hostname is None if URL is relative - return parsed_url.scheme == 'https', parsed_url.netloc.lower(), parsed_url.path + return parsed_url.scheme == "https", parsed_url.netloc.lower(), parsed_url.path def get_redirect_url(response, allow_relative=True, require_relative=False): # allow_relative=False throws RelativeRedirect error if scheme and hostname are equal to the request # require_relative=True throws RelativeRedirect error if scheme and hostname are not equal to the request - redirect_url = response.headers.get('location') + redirect_url = response.headers.get("location") if not redirect_url: - raise TransportError('HTTP redirect but no location header') + raise TransportError("HTTP redirect but no location header") # At least some servers are kind enough to supply a new location. It may be relative redirect_has_ssl, redirect_server, redirect_path = split_url(redirect_url) # The response may have been redirected already. Get the original URL @@ -718,13 +731,13 @@

    Module exchangelib.util

    # Redirect URL is relative. Inherit server and scheme from response URL redirect_server = response_server redirect_has_ssl = response_has_ssl - if not redirect_path.startswith('/'): + if not redirect_path.startswith("/"): # The path is not top-level. Add response path - redirect_path = (response_path or '/') + redirect_path + redirect_path = (response_path or "/") + redirect_path redirect_url = f"{'https' if redirect_has_ssl else 'http'}://{redirect_server}{redirect_path}" if redirect_url == request_url: # And some are mean enough to redirect to the same location - raise TransportError(f'Redirect to same location: {redirect_url}') + raise TransportError(f"Redirect to same location: {redirect_url}") if not allow_relative and (request_has_ssl == response_has_ssl and request_server == redirect_server): raise RelativeRedirect(redirect_url) if require_relative and (request_has_ssl != response_has_ssl or request_server != redirect_server): @@ -736,14 +749,20 @@

    Module exchangelib.util

    MAX_REDIRECTS = 10 # Maximum number of URL redirects before we give up # A collection of error classes we want to handle as general connection errors -CONNECTION_ERRORS = (requests.exceptions.ChunkedEncodingError, requests.exceptions.ConnectionError, - requests.exceptions.Timeout, socket.timeout, ConnectionResetError) +CONNECTION_ERRORS = ( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + socket.timeout, + ConnectionResetError, +) # A collection of error classes we want to handle as TLS verification errors TLS_ERRORS = (requests.exceptions.SSLError,) try: # If pyOpenSSL is installed, requests will use it and throw this class on TLS errors import OpenSSL.SSL + TLS_ERRORS += (OpenSSL.SSL.Error,) except ImportError: pass @@ -791,7 +810,7 @@

    Module exchangelib.util

    wait = RETRY_WAIT # Initial retry wait. We double the value on each retry retry = 0 redirects = 0 - log_msg = '''\ + log_msg = """\ Retry: %(retry)s Waited: %(wait)s Timeout: %(timeout)s @@ -805,10 +824,10 @@

    Module exchangelib.util

    Response time: %(response_time)s Status code: %(status_code)s Request headers: %(request_headers)s -Response headers: %(response_headers)s''' - xml_log_msg = '''\ +Response headers: %(response_headers)s""" + xml_log_msg = """\ Request XML: %(xml_request)s -Response XML: %(xml_response)s''' +Response XML: %(xml_response)s""" log_vals = dict( retry=retry, wait=wait, @@ -836,28 +855,36 @@

    Module exchangelib.util

    if backed_off: # We may have slept for a long time. Renew the session. session = protocol.renew_session(session) - log.debug('Session %s thread %s: retry %s timeout %s POST\'ing to %s after %ss wait', session.session_id, - thread_id, retry, timeout, url, wait) + log.debug( + "Session %s thread %s: retry %s timeout %s POST'ing to %s after %ss wait", + session.session_id, + thread_id, + retry, + timeout, + url, + wait, + ) d_start = time.monotonic() # Always create a dummy response for logging purposes, in case we fail in the following r = DummyResponse(url=url, request_headers=headers) try: - r = session.post(url=url, headers=headers, data=data, allow_redirects=False, timeout=timeout, - stream=stream) + r = session.post( + url=url, headers=headers, data=data, allow_redirects=False, timeout=timeout, stream=stream + ) except TLS_ERRORS as e: # Don't retry on TLS errors. They will most likely be persistent. raise TransportError(str(e)) except CONNECTION_ERRORS as e: - log.debug('Session %s thread %s: connection error POST\'ing to %s', session.session_id, thread_id, url) - r = DummyResponse(url=url, headers={'TimeoutException': e}, request_headers=headers) + log.debug("Session %s thread %s: connection error POST'ing to %s", session.session_id, thread_id, url) + r = DummyResponse(url=url, headers={"TimeoutException": e}, request_headers=headers) except TokenExpiredError as e: - log.debug('Session %s thread %s: OAuth token expired; refreshing', session.session_id, thread_id) - r = DummyResponse(url=url, headers={'TokenExpiredError': e}, request_headers=headers, status_code=401) + log.debug("Session %s thread %s: OAuth token expired; refreshing", session.session_id, thread_id) + r = DummyResponse(url=url, headers={"TokenExpiredError": e}, request_headers=headers, status_code=401) except KeyError as e: - if e.args[0] != 'www-authenticate': + if e.args[0] != "www-authenticate": raise - log.debug('Session %s thread %s: auth headers missing from %s', session.session_id, thread_id, url) - r = DummyResponse(url=url, headers={'KeyError': e}, request_headers=headers) + log.debug("Session %s thread %s: auth headers missing from %s", session.session_id, thread_id, url) + r = DummyResponse(url=url, headers={"KeyError": e}, request_headers=headers) finally: log_vals.update( retry=retry, @@ -871,7 +898,7 @@

    Module exchangelib.util

    ) xml_log_vals.update( xml_request=data, - xml_response='[STREAMING]' if stream else r.content, + xml_response="[STREAMING]" if stream else r.content, ) log.debug(log_msg, log_vals) xml_log.debug(xml_log_msg, xml_log_vals) @@ -882,8 +909,14 @@

    Module exchangelib.util

    total_wait = time.monotonic() - t_start if protocol.retry_policy.may_retry_on_error(response=r, wait=total_wait): r.close() # Release memory - log.info("Session %s thread %s: Connection error on URL %s (code %s). Cool down %s secs", - session.session_id, thread_id, r.url, r.status_code, wait) + log.info( + "Session %s thread %s: Connection error on URL %s (code %s). Cool down %s secs", + session.session_id, + thread_id, + r.url, + r.status_code, + wait, + ) wait = _retry_after(r, wait) protocol.retry_policy.back_off(wait) retry += 1 @@ -900,22 +933,20 @@

    Module exchangelib.util

    raise except Exception as e: # Let higher layers handle this. Add full context for better debugging. - log.error('%s: %s\n%s\n%s', e.__class__.__name__, str(e), log_msg % log_vals, xml_log_msg % xml_log_vals) + log.error("%s: %s\n%s\n%s", e.__class__.__name__, str(e), log_msg % log_vals, xml_log_msg % xml_log_vals) protocol.retire_session(session) raise if r.status_code == 500 and r.content and is_xml(r.content): # Some genius at Microsoft thinks it's OK to send a valid SOAP response as an HTTP 500 - log.debug('Got status code %s but trying to parse content anyway', r.status_code) + log.debug("Got status code %s but trying to parse content anyway", r.status_code) elif r.status_code != 200: protocol.retire_session(session) try: protocol.retry_policy.raise_response_errors(r) # Always raises an exception except MalformedResponseError as e: - log.error('%s: %s\n%s\n%s', e.__class__.__name__, str(e), log_msg % log_vals, xml_log_msg % xml_log_vals) + log.error("%s: %s\n%s\n%s", e.__class__.__name__, str(e), log_msg % log_vals, xml_log_msg % xml_log_vals) raise - except Exception: - raise - log.debug('Session %s thread %s: Useful response from %s', session.session_id, thread_id, url) + log.debug("Session %s thread %s: Useful response from %s", session.session_id, thread_id, url) return r, session @@ -924,15 +955,14 @@

    Module exchangelib.util

    sleep_secs = (back_off_until - datetime.datetime.now()).total_seconds() # The back off value may have expired within the last few milliseconds if sleep_secs > 0: - log.warning('Server requested back off until %s. Sleeping %s seconds', back_off_until, sleep_secs) + log.warning("Server requested back off until %s. Sleeping %s seconds", back_off_until, sleep_secs) time.sleep(sleep_secs) return True return False def _need_new_credentials(response): - return response.status_code == 401 \ - and response.headers.get('TokenExpiredError') + return response.status_code == 401 and response.headers.get("TokenExpiredError") def _redirect_or_fail(response, redirects, allow_redirects): @@ -944,18 +974,18 @@

    Module exchangelib.util

    log.debug("'allow_redirects' only supports relative redirects (%s -> %s)", response.url, e.value) raise RedirectError(url=e.value) if not allow_redirects: - raise TransportError(f'Redirect not allowed but we were redirected ({response.url} -> {redirect_url})') - log.debug('HTTP redirected to %s', redirect_url) + raise TransportError(f"Redirect not allowed but we were redirected ({response.url} -> {redirect_url})") + log.debug("HTTP redirected to %s", redirect_url) redirects += 1 if redirects > MAX_REDIRECTS: - raise TransportError('Max redirect count exceeded') + raise TransportError("Max redirect count exceeded") return redirect_url, redirects def _retry_after(r, wait): """Either return the Retry-After header value or the default wait, whichever is larger.""" try: - retry_after = int(r.headers.get('Retry-After', '0')) + retry_after = int(r.headers.get("Retry-After", "0")) except ValueError: pass else: @@ -1006,10 +1036,11 @@

    Functions

    :return: """ from .queryset import QuerySet - if hasattr(iterable, '__getitem__') and not isinstance(iterable, QuerySet): + + if hasattr(iterable, "__getitem__") and not isinstance(iterable, QuerySet): # tuple, list. QuerySet has __getitem__ but that evaluates the entire query greedily. We don't want that here. for i in range(0, len(iterable), chunksize): - yield iterable[i:i + chunksize] + yield iterable[i : i + chunksize] else: # generator, set, map, QuerySet chunk = [] @@ -1032,16 +1063,16 @@

    Functions

    Expand source code
    def create_element(name, attrs=None, nsmap=None):
    -    if ':' in name:
    -        ns, name = name.split(':')
    -        name = f'{{{ns_translation[ns]}}}{name}'
    +    if ":" in name:
    +        ns, name = name.split(":")
    +        name = f"{{{ns_translation[ns]}}}{name}"
         elem = _forgiving_parser.makeelement(name, nsmap=nsmap)
         if attrs:
             # Try hard to keep attribute order, to ensure deterministic output. This simplifies testing.
             # Dicts in Python 3.6+ have stable ordering.
             for k, v in attrs.items():
                 if isinstance(v, bool):
    -                v = 'true' if v else 'false'
    +                v = "true" if v else "false"
                 elif isinstance(v, int):
                     v = str(v)
                 elem.set(k, v)
    @@ -1059,7 +1090,7 @@ 

    Functions

    def get_domain(email):
         try:
    -        return email.split('@')[1].lower()
    +        return email.split("@")[1].lower()
         except (IndexError, AttributeError):
             raise ValueError(f"{email!r} is not a valid email")
    @@ -1076,9 +1107,9 @@

    Functions

    def get_redirect_url(response, allow_relative=True, require_relative=False):
         # allow_relative=False throws RelativeRedirect error if scheme and hostname are equal to the request
         # require_relative=True throws RelativeRedirect error if scheme and hostname are not equal to the request
    -    redirect_url = response.headers.get('location')
    +    redirect_url = response.headers.get("location")
         if not redirect_url:
    -        raise TransportError('HTTP redirect but no location header')
    +        raise TransportError("HTTP redirect but no location header")
         # At least some servers are kind enough to supply a new location. It may be relative
         redirect_has_ssl, redirect_server, redirect_path = split_url(redirect_url)
         # The response may have been redirected already. Get the original URL
    @@ -1090,13 +1121,13 @@ 

    Functions

    # Redirect URL is relative. Inherit server and scheme from response URL redirect_server = response_server redirect_has_ssl = response_has_ssl - if not redirect_path.startswith('/'): + if not redirect_path.startswith("/"): # The path is not top-level. Add response path - redirect_path = (response_path or '/') + redirect_path + redirect_path = (response_path or "/") + redirect_path redirect_url = f"{'https' if redirect_has_ssl else 'http'}://{redirect_server}{redirect_path}" if redirect_url == request_url: # And some are mean enough to redirect to the same location - raise TransportError(f'Redirect to same location: {redirect_url}') + raise TransportError(f"Redirect to same location: {redirect_url}") if not allow_relative and (request_has_ssl == response_has_ssl and request_server == redirect_server): raise RelativeRedirect(redirect_url) if require_relative and (request_has_ssl != response_has_ssl or request_server != redirect_server): @@ -1158,7 +1189,7 @@

    Functions

    :return: True or False """ if generators_allowed: - if not isinstance(value, (bytes, str)) and hasattr(value, '__iter__'): + if not isinstance(value, (bytes, str)) and hasattr(value, "__iter__"): return True else: if isinstance(value, (tuple, list, set)): @@ -1178,7 +1209,7 @@

    Functions

    Expand source code -
    def is_xml(text, expected_prefix=b'<?xml'):
    +
    def is_xml(text, expected_prefix=b"<?xml"):
         """Lightweight test if response is an XML doc. It's better to be fast than correct here.
     
         :param text: The string to check
    @@ -1189,7 +1220,7 @@ 

    Functions

    bom_len = len(BOM_UTF8) prefix_len = len(expected_prefix) if text[:bom_len] == BOM_UTF8: - prefix = text[bom_len:bom_len + prefix_len] + prefix = text[bom_len : bom_len + prefix_len] else: prefix = text[:prefix_len] return prefix == expected_prefix
    @@ -1212,7 +1243,7 @@

    Functions

    :param iterable: :return: """ - if hasattr(iterable, '__len__'): + if hasattr(iterable, "__len__"): # tuple, list, set return not iterable, iterable # generator @@ -1303,7 +1334,7 @@

    Functions

    wait = RETRY_WAIT # Initial retry wait. We double the value on each retry retry = 0 redirects = 0 - log_msg = '''\ + log_msg = """\ Retry: %(retry)s Waited: %(wait)s Timeout: %(timeout)s @@ -1317,10 +1348,10 @@

    Functions

    Response time: %(response_time)s Status code: %(status_code)s Request headers: %(request_headers)s -Response headers: %(response_headers)s''' - xml_log_msg = '''\ +Response headers: %(response_headers)s""" + xml_log_msg = """\ Request XML: %(xml_request)s -Response XML: %(xml_response)s''' +Response XML: %(xml_response)s""" log_vals = dict( retry=retry, wait=wait, @@ -1348,28 +1379,36 @@

    Functions

    if backed_off: # We may have slept for a long time. Renew the session. session = protocol.renew_session(session) - log.debug('Session %s thread %s: retry %s timeout %s POST\'ing to %s after %ss wait', session.session_id, - thread_id, retry, timeout, url, wait) + log.debug( + "Session %s thread %s: retry %s timeout %s POST'ing to %s after %ss wait", + session.session_id, + thread_id, + retry, + timeout, + url, + wait, + ) d_start = time.monotonic() # Always create a dummy response for logging purposes, in case we fail in the following r = DummyResponse(url=url, request_headers=headers) try: - r = session.post(url=url, headers=headers, data=data, allow_redirects=False, timeout=timeout, - stream=stream) + r = session.post( + url=url, headers=headers, data=data, allow_redirects=False, timeout=timeout, stream=stream + ) except TLS_ERRORS as e: # Don't retry on TLS errors. They will most likely be persistent. raise TransportError(str(e)) except CONNECTION_ERRORS as e: - log.debug('Session %s thread %s: connection error POST\'ing to %s', session.session_id, thread_id, url) - r = DummyResponse(url=url, headers={'TimeoutException': e}, request_headers=headers) + log.debug("Session %s thread %s: connection error POST'ing to %s", session.session_id, thread_id, url) + r = DummyResponse(url=url, headers={"TimeoutException": e}, request_headers=headers) except TokenExpiredError as e: - log.debug('Session %s thread %s: OAuth token expired; refreshing', session.session_id, thread_id) - r = DummyResponse(url=url, headers={'TokenExpiredError': e}, request_headers=headers, status_code=401) + log.debug("Session %s thread %s: OAuth token expired; refreshing", session.session_id, thread_id) + r = DummyResponse(url=url, headers={"TokenExpiredError": e}, request_headers=headers, status_code=401) except KeyError as e: - if e.args[0] != 'www-authenticate': + if e.args[0] != "www-authenticate": raise - log.debug('Session %s thread %s: auth headers missing from %s', session.session_id, thread_id, url) - r = DummyResponse(url=url, headers={'KeyError': e}, request_headers=headers) + log.debug("Session %s thread %s: auth headers missing from %s", session.session_id, thread_id, url) + r = DummyResponse(url=url, headers={"KeyError": e}, request_headers=headers) finally: log_vals.update( retry=retry, @@ -1383,7 +1422,7 @@

    Functions

    ) xml_log_vals.update( xml_request=data, - xml_response='[STREAMING]' if stream else r.content, + xml_response="[STREAMING]" if stream else r.content, ) log.debug(log_msg, log_vals) xml_log.debug(xml_log_msg, xml_log_vals) @@ -1394,8 +1433,14 @@

    Functions

    total_wait = time.monotonic() - t_start if protocol.retry_policy.may_retry_on_error(response=r, wait=total_wait): r.close() # Release memory - log.info("Session %s thread %s: Connection error on URL %s (code %s). Cool down %s secs", - session.session_id, thread_id, r.url, r.status_code, wait) + log.info( + "Session %s thread %s: Connection error on URL %s (code %s). Cool down %s secs", + session.session_id, + thread_id, + r.url, + r.status_code, + wait, + ) wait = _retry_after(r, wait) protocol.retry_policy.back_off(wait) retry += 1 @@ -1412,22 +1457,20 @@

    Functions

    raise except Exception as e: # Let higher layers handle this. Add full context for better debugging. - log.error('%s: %s\n%s\n%s', e.__class__.__name__, str(e), log_msg % log_vals, xml_log_msg % xml_log_vals) + log.error("%s: %s\n%s\n%s", e.__class__.__name__, str(e), log_msg % log_vals, xml_log_msg % xml_log_vals) protocol.retire_session(session) raise if r.status_code == 500 and r.content and is_xml(r.content): # Some genius at Microsoft thinks it's OK to send a valid SOAP response as an HTTP 500 - log.debug('Got status code %s but trying to parse content anyway', r.status_code) + log.debug("Got status code %s but trying to parse content anyway", r.status_code) elif r.status_code != 200: protocol.retire_session(session) try: protocol.retry_policy.raise_response_errors(r) # Always raises an exception except MalformedResponseError as e: - log.error('%s: %s\n%s\n%s', e.__class__.__name__, str(e), log_msg % log_vals, xml_log_msg % xml_log_vals) - raise - except Exception: + log.error("%s: %s\n%s\n%s", e.__class__.__name__, str(e), log_msg % log_vals, xml_log_msg % xml_log_vals) raise - log.debug('Session %s thread %s: Useful response from %s', session.session_id, thread_id, url) + log.debug("Session %s thread %s: Useful response from %s", session.session_id, thread_id, url) return r, session
    @@ -1461,8 +1504,9 @@

    Functions

    @wraps(f) def wrapper(self, *args, **kwargs): if not self.account: - raise ValueError(f'{self.__class__.__name__} must have an account') + raise ValueError(f"{self.__class__.__name__} must have an account") return f(self, *args, **kwargs) + return wrapper
    @@ -1479,10 +1523,11 @@

    Functions

    @wraps(f) def wrapper(self, *args, **kwargs): if not self.account: - raise ValueError(f'{self.__class__.__name__} must have an account') + raise ValueError(f"{self.__class__.__name__} must have an account") if not self.id: - raise ValueError(f'{self.__class__.__name__} must have an ID') + raise ValueError(f"{self.__class__.__name__} must have an ID") return f(self, *args, **kwargs) + return wrapper
    @@ -1501,9 +1546,9 @@

    Functions

    overflow = len(data) % 4 if overflow: if isinstance(data, str): - padding = '=' * (4 - overflow) + padding = "=" * (4 - overflow) else: - padding = b'=' * (4 - overflow) + padding = b"=" * (4 - overflow) data += padding return b64decode(data) @@ -1517,7 +1562,7 @@

    Functions

    Expand source code -
    def safe_xml_value(value, replacement='?'):
    +
    def safe_xml_value(value, replacement="?"):
         return _ILLEGAL_XML_CHARS_RE.sub(replacement, value)
    @@ -1531,10 +1576,11 @@

    Functions

    Expand source code
    def set_xml_value(elem, value, version=None):
    -    from .ewsdatetime import EWSDateTime, EWSDate
    -    from .fields import FieldPath, FieldOrder
    +    from .ewsdatetime import EWSDate, EWSDateTime
    +    from .fields import FieldOrder, FieldPath
         from .properties import EWSElement
         from .version import Version
    +
         if isinstance(value, (str, bool, bytes, int, Decimal, datetime.time, EWSDate, EWSDateTime)):
             elem.text = value_to_xml_text(value)
         elif isinstance(value, _element_class):
    @@ -1543,13 +1589,13 @@ 

    Functions

    elem.append(value.to_xml()) elif isinstance(value, EWSElement): if not isinstance(version, Version): - raise InvalidTypeError('version', version, Version) + raise InvalidTypeError("version", version, Version) elem.append(value.to_xml(version=version)) elif is_iterable(value, generators_allowed=True): for v in value: set_xml_value(elem, v, version=version) else: - raise ValueError(f'Unsupported type {type(value)} for value {value} on elem {elem}') + raise ValueError(f"Unsupported type {type(value)} for value {value} on elem {elem}") return elem
    @@ -1565,7 +1611,7 @@

    Functions

    def split_url(url):
         parsed_url = urlparse(url)
         # Use netloc instead of hostname since hostname is None if URL is relative
    -    return parsed_url.scheme == 'https', parsed_url.netloc.lower(), parsed_url.path
    + return parsed_url.scheme == "https", parsed_url.netloc.lower(), parsed_url.path
    @@ -1587,35 +1633,35 @@

    Functions

    try: res = lxml.etree.parse(stream, parser=_forgiving_parser) # nosec except AssertionError as e: - raise ParseError(e.args[0], '<not from file>', -1, 0) + raise ParseError(e.args[0], "<not from file>", -1, 0) except lxml.etree.ParseError as e: - if hasattr(e, 'position'): + if hasattr(e, "position"): e.lineno, e.offset = e.position if not e.lineno: - raise ParseError(str(e), '<not from file>', e.lineno, e.offset) + raise ParseError(str(e), "<not from file>", e.lineno, e.offset) try: stream.seek(0) offending_line = stream.read().splitlines()[e.lineno - 1] except (IndexError, io.UnsupportedOperation): - raise ParseError(str(e), '<not from file>', e.lineno, e.offset) + raise ParseError(str(e), "<not from file>", e.lineno, e.offset) else: - offending_excerpt = offending_line[max(0, e.offset - 20):e.offset + 20] + offending_excerpt = offending_line[max(0, e.offset - 20) : e.offset + 20] msg = f'{e}\nOffending text: [...]{offending_excerpt.decode("utf-8", errors="ignore")}[...]' - raise ParseError(msg, '<not from file>', e.lineno, e.offset) + raise ParseError(msg, "<not from file>", e.lineno, e.offset) except TypeError: try: stream.seek(0) except (IndexError, io.UnsupportedOperation): pass - raise ParseError(f'This is not XML: {stream.read()!r}', '<not from file>', -1, 0) + raise ParseError(f"This is not XML: {stream.read()!r}", "<not from file>", -1, 0) if res.getroot() is None: try: stream.seek(0) - msg = f'No root element found: {stream.read()!r}' + msg = f"No root element found: {stream.read()!r}" except (IndexError, io.UnsupportedOperation): - msg = 'No root element found' - raise ParseError(msg, '<not from file>', -1, 0) + msg = "No root element found" + raise ParseError(msg, "<not from file>", -1, 0) return res
    @@ -1629,16 +1675,17 @@

    Functions

    Expand source code
    def value_to_xml_text(value):
    -    from .ewsdatetime import EWSTimeZone, EWSDateTime, EWSDate
    -    from .indexed_properties import PhoneNumber, EmailAddress
    -    from .properties import Mailbox, AssociatedCalendarItemId, Attendee, ConversationId
    +    from .ewsdatetime import EWSDate, EWSDateTime, EWSTimeZone
    +    from .indexed_properties import EmailAddress, PhoneNumber
    +    from .properties import AssociatedCalendarItemId, Attendee, ConversationId, Mailbox
    +
         # We can't just create a map and look up with type(value) because we want to support subtypes
         if isinstance(value, str):
             return safe_xml_value(value)
         if isinstance(value, bool):
    -        return '1' if value else '0'
    +        return "1" if value else "0"
         if isinstance(value, bytes):
    -        return b64encode(value).decode('ascii')
    +        return b64encode(value).decode("ascii")
         if isinstance(value, (int, Decimal)):
             return str(value)
         if isinstance(value, datetime.time):
    @@ -1661,7 +1708,7 @@ 

    Functions

    return value.id if isinstance(value, AssociatedCalendarItemId): return value.id - raise TypeError(f'Unsupported type: {type(value)} ({value})')
    + raise TypeError(f"Unsupported type: {type(value)} ({value})")
    @@ -1675,15 +1722,16 @@

    Functions

    def xml_text_to_value(value, value_type):
         from .ewsdatetime import EWSDate, EWSDateTime
    +
         if value_type == str:
             return value
         if value_type == bool:
             try:
                 return {
    -                'true': True,
    -                'on': True,
    -                'false': False,
    -                'off': False,
    +                "true": True,
    +                "on": True,
    +                "false": False,
    +                "off": False,
                 }[value.lower()]
             except KeyError:
                 return None
    @@ -1746,7 +1794,8 @@ 

    Classes

    class AnonymizingXmlHandler(PrettyXmlHandler):
         """A steaming log handler that prettifies and anonymizes log statements containing XML when output is a terminal."""
    -    PRIVATE_TAGS = {'RootItemId', 'ItemId', 'Id', 'RootItemChangeKey', 'ChangeKey'}
    +
    +    PRIVATE_TAGS = {"RootItemId", "ItemId", "Id", "RootItemChangeKey", "ChangeKey"}
     
         def __init__(self, forbidden_strings, *args, **kwargs):
             self.forbidden_strings = forbidden_strings
    @@ -1757,11 +1806,11 @@ 

    Classes

    for elem in root.iter(): # Anonymize element attribute values known to contain private data for attr in set(elem.keys()) & self.PRIVATE_TAGS: - elem.set(attr, 'DEADBEEF=') + elem.set(attr, "DEADBEEF=") # Anonymize anything requested by the caller for s in self.forbidden_strings: if elem.text is not None: - elem.text = elem.text.replace(s, '[REMOVED]') + elem.text = elem.text.replace(s, "[REMOVED]") return root

    Ancestors

    @@ -1794,11 +1843,11 @@

    Methods

    for elem in root.iter(): # Anonymize element attribute values known to contain private data for attr in set(elem.keys()) & self.PRIVATE_TAGS: - elem.set(attr, 'DEADBEEF=') + elem.set(attr, "DEADBEEF=") # Anonymize anything requested by the caller for s in self.forbidden_strings: if elem.text is not None: - elem.text = elem.text.replace(s, '[REMOVED]') + elem.text = elem.text.replace(s, "[REMOVED]") return root
    @@ -1851,7 +1900,7 @@

    Inherited members

    if self.closed: raise ValueError("read from a closed file") if self._next is None: - return b'' + return b"" if size is None: size = -1 @@ -1915,7 +1964,7 @@

    Methods

    if self.closed: raise ValueError("read from a closed file") if self._next is None: - return b'' + return b"" if size is None: size = -1 @@ -1977,7 +2026,7 @@

    Methods

    class DocumentYielder:
         """Look for XML documents in a streaming HTTP response and yield them as they become available from the stream."""
     
    -    def __init__(self, content_iterator, document_tag='Envelope'):
    +    def __init__(self, content_iterator, document_tag="Envelope"):
             self._iterator = content_iterator
             self._document_tag = document_tag.encode()
     
    @@ -1985,16 +2034,16 @@ 

    Methods

    """Iterate over the bytes until we have a full tag in the buffer. If there's a '>' in an attr value, then we'll exit on that, but it's OK becaus wejust need the plain tag name later. """ - tag_buffer = [b'<'] + tag_buffer = [b"<"] while True: try: c = next(self._iterator) except StopIteration: break tag_buffer.append(c) - if c == b'>': + if c == b">": break - return b''.join(tag_buffer) + return b"".join(tag_buffer) @staticmethod def _normalize_tag(tag): @@ -2004,7 +2053,7 @@

    Methods

    * <ns:tag foo='bar'> * </ns:tag foo='bar'> """ - return tag.strip(b'<>/').split(b' ')[0].split(b':')[-1] + return tag.strip(b"<>/").split(b" ")[0].split(b":")[-1] def __iter__(self): """Consumes the content iterator, looking for start and end tags. Returns each document when we have fully @@ -2015,18 +2064,18 @@

    Methods

    try: while True: c = next(self._iterator) - if not doc_started and c == b'<': + if not doc_started and c == b"<": tag = self._get_tag() if self._normalize_tag(tag) == self._document_tag: # Start of document. Collect bytes from this point buffer.append(tag) doc_started = True - elif doc_started and c == b'<': + elif doc_started and c == b"<": tag = self._get_tag() buffer.append(tag) if self._normalize_tag(tag) == self._document_tag: # End of document. Yield a valid document and reset the buffer - yield b"<?xml version='1.0' encoding='utf-8'?>\n" + b''.join(buffer) + yield b"<?xml version='1.0' encoding='utf-8'?>\n" + b"".join(buffer) doc_started = False buffer = [] elif doc_started: @@ -2065,15 +2114,16 @@

    Methods

    class DummyResponse:
         """A class to fake a requests Response object for functions that expect this."""
     
    -    def __init__(self, url=None, headers=None, request_headers=None, content=b'', status_code=503, streaming=False,
    -                 history=None):
    +    def __init__(
    +        self, url=None, headers=None, request_headers=None, content=b"", status_code=503, streaming=False, history=None
    +    ):
             self.status_code = status_code
             self.url = url
             self.headers = headers or {}
             self.content = iter((bytes([b]) for b in content)) if streaming else content
    -        self.text = content.decode('utf-8', errors='ignore')
    +        self.text = content.decode("utf-8", errors="ignore")
             self.request = DummyRequest(headers=request_headers)
    -        self.reason = ''
    +        self.reason = ""
             self.history = history
     
         def iter_content(self):
    @@ -2181,12 +2231,11 @@ 

    Ancestors

    @classmethod def prettify_xml(cls, xml_bytes): """Re-format an XML document to a consistent style.""" - return lxml.etree.tostring( - cls.parse_bytes(xml_bytes), - xml_declaration=True, - encoding='utf-8', - pretty_print=True - ).replace(b'\t', b' ').replace(b' xmlns:', b'\n xmlns:') + return ( + lxml.etree.tostring(cls.parse_bytes(xml_bytes), xml_declaration=True, encoding="utf-8", pretty_print=True) + .replace(b"\t", b" ") + .replace(b" xmlns:", b"\n xmlns:") + ) @staticmethod def highlight_xml(xml_str): @@ -2203,7 +2252,7 @@

    Ancestors

    """ if record.levelno == logging.DEBUG and self.is_tty() and isinstance(record.args, dict): for key, value in record.args.items(): - if not key.startswith('xml_'): + if not key.startswith("xml_"): continue if not isinstance(value, bytes): continue @@ -2213,7 +2262,7 @@

    Ancestors

    record.args[key] = self.highlight_xml(self.prettify_xml(value)) except Exception as e: # Something bad happened, but we don't want to crash the program just because logging failed - print(f'XML highlighting failed: {e}') + print(f"XML highlighting failed: {e}") return super().emit(record) def is_tty(self): @@ -2276,12 +2325,11 @@

    Static methods

    @classmethod
     def prettify_xml(cls, xml_bytes):
         """Re-format an XML document to a consistent style."""
    -    return lxml.etree.tostring(
    -        cls.parse_bytes(xml_bytes),
    -        xml_declaration=True,
    -        encoding='utf-8',
    -        pretty_print=True
    -    ).replace(b'\t', b'    ').replace(b' xmlns:', b'\n    xmlns:')
    + return ( + lxml.etree.tostring(cls.parse_bytes(xml_bytes), xml_declaration=True, encoding="utf-8", pretty_print=True) + .replace(b"\t", b" ") + .replace(b" xmlns:", b"\n xmlns:") + )
    @@ -2310,7 +2358,7 @@

    Methods

    """ if record.levelno == logging.DEBUG and self.is_tty() and isinstance(record.args, dict): for key, value in record.args.items(): - if not key.startswith('xml_'): + if not key.startswith("xml_"): continue if not isinstance(value, bytes): continue @@ -2320,7 +2368,7 @@

    Methods

    record.args[key] = self.highlight_xml(self.prettify_xml(value)) except Exception as e: # Something bad happened, but we don't want to crash the program just because logging failed - print(f'XML highlighting failed: {e}') + print(f"XML highlighting failed: {e}") return super().emit(record)
    @@ -2382,7 +2430,7 @@

    Methods

    self.close() if not self.element_found: data = bytes(collected_data) - raise ElementNotFound('The element to be streamed from was not found', data=bytes(data)) + raise ElementNotFound("The element to be streamed from was not found", data=bytes(data)) def feed(self, data, isFinal=0): """Yield the current content of the character buffer.""" @@ -2390,13 +2438,13 @@

    Methods

    return self._decode_buffer() def _decode_buffer(self): - remainder = '' + remainder = "" for data in self.buffer: available = len(remainder) + len(data) overflow = available % 4 # Make sure we always decode a multiple of 4 if remainder: - data = (remainder + data) - remainder = '' + data = remainder + data + remainder = "" if overflow: remainder, data = data[-overflow:], data[:-overflow] if data: @@ -2457,7 +2505,7 @@

    Methods

    self.close() if not self.element_found: data = bytes(collected_data) - raise ElementNotFound('The element to be streamed from was not found', data=bytes(data))
    + raise ElementNotFound("The element to be streamed from was not found", data=bytes(data)) diff --git a/docs/exchangelib/version.html b/docs/exchangelib/version.html index 1bdae272..f9e15872 100644 --- a/docs/exchangelib/version.html +++ b/docs/exchangelib/version.html @@ -29,8 +29,8 @@

    Module exchangelib.version

    import logging
     import re
     
    -from .errors import TransportError, ResponseMessageError, InvalidTypeError
    -from .util import xml_to_str, TNS
    +from .errors import InvalidTypeError, ResponseMessageError, TransportError
    +from .util import TNS, xml_to_str
     
     log = logging.getLogger(__name__)
     
    @@ -44,20 +44,20 @@ 

    Module exchangelib.version

    # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/requestserverversion VERSIONS = { - 'Exchange2007': ('Exchange2007', 'Microsoft Exchange Server 2007'), - 'Exchange2007_SP1': ('Exchange2007_SP1', 'Microsoft Exchange Server 2007 SP1'), - 'Exchange2007_SP2': ('Exchange2007_SP1', 'Microsoft Exchange Server 2007 SP2'), - 'Exchange2007_SP3': ('Exchange2007_SP1', 'Microsoft Exchange Server 2007 SP3'), - 'Exchange2010': ('Exchange2010', 'Microsoft Exchange Server 2010'), - 'Exchange2010_SP1': ('Exchange2010_SP1', 'Microsoft Exchange Server 2010 SP1'), - 'Exchange2010_SP2': ('Exchange2010_SP2', 'Microsoft Exchange Server 2010 SP2'), - 'Exchange2010_SP3': ('Exchange2010_SP2', 'Microsoft Exchange Server 2010 SP3'), - 'Exchange2013': ('Exchange2013', 'Microsoft Exchange Server 2013'), - 'Exchange2013_SP1': ('Exchange2013_SP1', 'Microsoft Exchange Server 2013 SP1'), - 'Exchange2015': ('Exchange2015', 'Microsoft Exchange Server 2015'), - 'Exchange2015_SP1': ('Exchange2015_SP1', 'Microsoft Exchange Server 2015 SP1'), - 'Exchange2016': ('Exchange2016', 'Microsoft Exchange Server 2016'), - 'Exchange2019': ('Exchange2019', 'Microsoft Exchange Server 2019'), + "Exchange2007": ("Exchange2007", "Microsoft Exchange Server 2007"), + "Exchange2007_SP1": ("Exchange2007_SP1", "Microsoft Exchange Server 2007 SP1"), + "Exchange2007_SP2": ("Exchange2007_SP1", "Microsoft Exchange Server 2007 SP2"), + "Exchange2007_SP3": ("Exchange2007_SP1", "Microsoft Exchange Server 2007 SP3"), + "Exchange2010": ("Exchange2010", "Microsoft Exchange Server 2010"), + "Exchange2010_SP1": ("Exchange2010_SP1", "Microsoft Exchange Server 2010 SP1"), + "Exchange2010_SP2": ("Exchange2010_SP2", "Microsoft Exchange Server 2010 SP2"), + "Exchange2010_SP3": ("Exchange2010_SP2", "Microsoft Exchange Server 2010 SP3"), + "Exchange2013": ("Exchange2013", "Microsoft Exchange Server 2013"), + "Exchange2013_SP1": ("Exchange2013_SP1", "Microsoft Exchange Server 2013 SP1"), + "Exchange2015": ("Exchange2015", "Microsoft Exchange Server 2015"), + "Exchange2015_SP1": ("Exchange2015_SP1", "Microsoft Exchange Server 2015 SP1"), + "Exchange2016": ("Exchange2016", "Microsoft Exchange Server 2016"), + "Exchange2019": ("Exchange2019", "Microsoft Exchange Server 2019"), } # Build a list of unique API versions, used when guessing API version supported by the server. Use reverse order so we @@ -71,36 +71,36 @@

    Module exchangelib.version

    # List of build numbers here: https://docs.microsoft.com/en-us/exchange/new-features/build-numbers-and-release-dates API_VERSION_MAP = { 8: { - 0: 'Exchange2007', - 1: 'Exchange2007_SP1', - 2: 'Exchange2007_SP1', - 3: 'Exchange2007_SP1', + 0: "Exchange2007", + 1: "Exchange2007_SP1", + 2: "Exchange2007_SP1", + 3: "Exchange2007_SP1", }, 14: { - 0: 'Exchange2010', - 1: 'Exchange2010_SP1', - 2: 'Exchange2010_SP2', - 3: 'Exchange2010_SP2', + 0: "Exchange2010", + 1: "Exchange2010_SP1", + 2: "Exchange2010_SP2", + 3: "Exchange2010_SP2", }, 15: { - 0: 'Exchange2013', # Minor builds starting from 847 are Exchange2013_SP1, see api_version() - 1: 'Exchange2016', - 2: 'Exchange2019', - 20: 'Exchange2016', # This is Office365. See issue #221 + 0: "Exchange2013", # Minor builds starting from 847 are Exchange2013_SP1, see api_version() + 1: "Exchange2016", + 2: "Exchange2019", + 20: "Exchange2016", # This is Office365. See issue #221 }, } - __slots__ = 'major_version', 'minor_version', 'major_build', 'minor_build' + __slots__ = "major_version", "minor_version", "major_build", "minor_build" def __init__(self, major_version, minor_version, major_build=0, minor_build=0): if not isinstance(major_version, int): - raise InvalidTypeError('major_version', major_version, int) + raise InvalidTypeError("major_version", major_version, int) if not isinstance(minor_version, int): - raise InvalidTypeError('minor_version', minor_version, int) + raise InvalidTypeError("minor_version", minor_version, int) if not isinstance(major_build, int): - raise InvalidTypeError('major_build', major_build, int) + raise InvalidTypeError("major_build", major_build, int) if not isinstance(minor_build, int): - raise InvalidTypeError('minor_build', minor_build, int) + raise InvalidTypeError("minor_build", minor_build, int) self.major_version = major_version self.minor_version = minor_version self.major_build = major_build @@ -111,10 +111,10 @@

    Module exchangelib.version

    @classmethod def from_xml(cls, elem): xml_elems_map = { - 'major_version': 'MajorVersion', - 'minor_version': 'MinorVersion', - 'major_build': 'MajorBuildNumber', - 'minor_build': 'MinorBuildNumber', + "major_version": "MajorVersion", + "minor_version": "MinorVersion", + "major_build": "MajorBuildNumber", + "minor_build": "MinorBuildNumber", } kwargs = {} for k, xml_elem in xml_elems_map.items(): @@ -138,7 +138,7 @@

    Module exchangelib.version

    :param s: """ - bin_s = f'{int(s, 16):032b}' # Convert string to 32-bit binary string + bin_s = f"{int(s, 16):032b}" # Convert string to 32-bit binary string major_version = int(bin_s[4:10], 2) minor_version = int(bin_s[10:16], 2) build_number = int(bin_s[17:32], 2) @@ -146,11 +146,11 @@

    Module exchangelib.version

    def api_version(self): if EXCHANGE_2013_SP1 <= self < EXCHANGE_2016: - return 'Exchange2013_SP1' + return "Exchange2013_SP1" try: return self.API_VERSION_MAP[self.major_version][self.minor_version] except KeyError: - raise ValueError(f'API version for build {self} is unknown') + raise ValueError(f"API version for build {self} is unknown") def fullname(self): return VERSIONS[self.api_version()][1] @@ -190,11 +190,12 @@

    Module exchangelib.version

    return self.__cmp__(other) >= 0 def __str__(self): - return f'{self.major_version}.{self.minor_version}.{self.major_build}.{self.minor_build}' + return f"{self.major_version}.{self.minor_version}.{self.major_build}.{self.minor_build}" def __repr__(self): - return self.__class__.__name__ \ - + repr((self.major_version, self.minor_version, self.major_build, self.minor_build)) + return self.__class__.__name__ + repr( + (self.major_version, self.minor_version, self.major_build, self.minor_build) + ) # Helpers for comparison operations elsewhere in this package @@ -213,18 +214,18 @@

    Module exchangelib.version

    class Version: """Holds information about the server version.""" - __slots__ = 'build', 'api_version' + __slots__ = "build", "api_version" def __init__(self, build, api_version=None): if api_version is None: if not isinstance(build, Build): - raise InvalidTypeError('build', build, Build) + raise InvalidTypeError("build", build, Build) self.api_version = build.api_version() else: if not isinstance(build, (Build, type(None))): - raise InvalidTypeError('build', build, Build) + raise InvalidTypeError("build", build, Build) if not isinstance(api_version, str): - raise InvalidTypeError('api_version', api_version, str) + raise InvalidTypeError("api_version", api_version, str) self.api_version = api_version self.build = build @@ -245,53 +246,62 @@

    Module exchangelib.version

    :param api_version_hint: (Default value = None) """ from .services import ResolveNames + # The protocol doesn't have a version yet, so default to latest supported version if we don't have a hint. api_version = api_version_hint or API_VERSIONS[0] - log.debug('Asking server for version info using API version %s', api_version) + log.debug("Asking server for version info using API version %s", api_version) # We don't know the build version yet. Hopefully, the server will report it in the SOAP header. Lots of # places expect a version to have a build, so this is a bit dangerous, but passing a fake build around is also # dangerous. Make sure the call to ResolveNames does not require a version build. protocol.config.version = Version(build=None, api_version=api_version) # Use ResolveNames as a minimal request to the server to test if the version is correct. If not, ResolveNames # will try to guess the version automatically. - name = str(protocol.credentials) if protocol.credentials and str(protocol.credentials) else 'DUMMY' + name = str(protocol.credentials) if protocol.credentials and str(protocol.credentials) else "DUMMY" try: list(ResolveNames(protocol=protocol).call(unresolved_entries=[name])) except ResponseMessageError as e: # We may have survived long enough to get a new version if not protocol.config.version.build: - raise TransportError(f'No valid version headers found in response ({e!r})') + raise TransportError(f"No valid version headers found in response ({e!r})") if not protocol.config.version.build: - raise TransportError('No valid version headers found in response') + raise TransportError("No valid version headers found in response") return protocol.config.version @staticmethod def _is_invalid_version_string(version): # Check if a version string is bogus, e.g. V2_, V2015_ or V2018_ - return re.match(r'V[0-9]{1,4}_.*', version) + return re.match(r"V[0-9]{1,4}_.*", version) @classmethod def from_soap_header(cls, requested_api_version, header): - info = header.find(f'{{{TNS}}}ServerVersionInfo') + info = header.find(f"{{{TNS}}}ServerVersionInfo") if info is None: - raise TransportError(f'No ServerVersionInfo in header: {xml_to_str(header)!r}') + raise TransportError(f"No ServerVersionInfo in header: {xml_to_str(header)!r}") try: build = Build.from_xml(elem=info) except ValueError: - raise TransportError(f'Bad ServerVersionInfo in response: {xml_to_str(header)!r}') + raise TransportError(f"Bad ServerVersionInfo in response: {xml_to_str(header)!r}") # Not all Exchange servers send the Version element - api_version_from_server = info.get('Version') or build.api_version() + api_version_from_server = info.get("Version") or build.api_version() if api_version_from_server != requested_api_version: if cls._is_invalid_version_string(api_version_from_server): # For unknown reasons, Office 365 may respond with an API version strings that is invalid in a request. # Detect these so we can fallback to a valid version string. - log.debug('API version "%s" worked but server reports version "%s". Using "%s"', requested_api_version, - api_version_from_server, requested_api_version) + log.debug( + 'API version "%s" worked but server reports version "%s". Using "%s"', + requested_api_version, + api_version_from_server, + requested_api_version, + ) api_version_from_server = requested_api_version else: # Trust API version from server response - log.debug('API version "%s" worked but server reports version "%s". Using "%s"', requested_api_version, - api_version_from_server, api_version_from_server) + log.debug( + 'API version "%s" worked but server reports version "%s". Using "%s"', + requested_api_version, + api_version_from_server, + api_version_from_server, + ) return cls(build=build, api_version=api_version_from_server) def copy(self): @@ -310,7 +320,7 @@

    Module exchangelib.version

    return self.__class__.__name__ + repr((self.build, self.api_version)) def __str__(self): - return f'Build={self.build}, API={self.api_version}, Fullname={self.fullname}'
    + return f"Build={self.build}, API={self.api_version}, Fullname={self.fullname}"
    @@ -338,36 +348,36 @@

    Classes

    # List of build numbers here: https://docs.microsoft.com/en-us/exchange/new-features/build-numbers-and-release-dates API_VERSION_MAP = { 8: { - 0: 'Exchange2007', - 1: 'Exchange2007_SP1', - 2: 'Exchange2007_SP1', - 3: 'Exchange2007_SP1', + 0: "Exchange2007", + 1: "Exchange2007_SP1", + 2: "Exchange2007_SP1", + 3: "Exchange2007_SP1", }, 14: { - 0: 'Exchange2010', - 1: 'Exchange2010_SP1', - 2: 'Exchange2010_SP2', - 3: 'Exchange2010_SP2', + 0: "Exchange2010", + 1: "Exchange2010_SP1", + 2: "Exchange2010_SP2", + 3: "Exchange2010_SP2", }, 15: { - 0: 'Exchange2013', # Minor builds starting from 847 are Exchange2013_SP1, see api_version() - 1: 'Exchange2016', - 2: 'Exchange2019', - 20: 'Exchange2016', # This is Office365. See issue #221 + 0: "Exchange2013", # Minor builds starting from 847 are Exchange2013_SP1, see api_version() + 1: "Exchange2016", + 2: "Exchange2019", + 20: "Exchange2016", # This is Office365. See issue #221 }, } - __slots__ = 'major_version', 'minor_version', 'major_build', 'minor_build' + __slots__ = "major_version", "minor_version", "major_build", "minor_build" def __init__(self, major_version, minor_version, major_build=0, minor_build=0): if not isinstance(major_version, int): - raise InvalidTypeError('major_version', major_version, int) + raise InvalidTypeError("major_version", major_version, int) if not isinstance(minor_version, int): - raise InvalidTypeError('minor_version', minor_version, int) + raise InvalidTypeError("minor_version", minor_version, int) if not isinstance(major_build, int): - raise InvalidTypeError('major_build', major_build, int) + raise InvalidTypeError("major_build", major_build, int) if not isinstance(minor_build, int): - raise InvalidTypeError('minor_build', minor_build, int) + raise InvalidTypeError("minor_build", minor_build, int) self.major_version = major_version self.minor_version = minor_version self.major_build = major_build @@ -378,10 +388,10 @@

    Classes

    @classmethod def from_xml(cls, elem): xml_elems_map = { - 'major_version': 'MajorVersion', - 'minor_version': 'MinorVersion', - 'major_build': 'MajorBuildNumber', - 'minor_build': 'MinorBuildNumber', + "major_version": "MajorVersion", + "minor_version": "MinorVersion", + "major_build": "MajorBuildNumber", + "minor_build": "MinorBuildNumber", } kwargs = {} for k, xml_elem in xml_elems_map.items(): @@ -405,7 +415,7 @@

    Classes

    :param s: """ - bin_s = f'{int(s, 16):032b}' # Convert string to 32-bit binary string + bin_s = f"{int(s, 16):032b}" # Convert string to 32-bit binary string major_version = int(bin_s[4:10], 2) minor_version = int(bin_s[10:16], 2) build_number = int(bin_s[17:32], 2) @@ -413,11 +423,11 @@

    Classes

    def api_version(self): if EXCHANGE_2013_SP1 <= self < EXCHANGE_2016: - return 'Exchange2013_SP1' + return "Exchange2013_SP1" try: return self.API_VERSION_MAP[self.major_version][self.minor_version] except KeyError: - raise ValueError(f'API version for build {self} is unknown') + raise ValueError(f"API version for build {self} is unknown") def fullname(self): return VERSIONS[self.api_version()][1] @@ -457,11 +467,12 @@

    Classes

    return self.__cmp__(other) >= 0 def __str__(self): - return f'{self.major_version}.{self.minor_version}.{self.major_build}.{self.minor_build}' + return f"{self.major_version}.{self.minor_version}.{self.major_build}.{self.minor_build}" def __repr__(self): - return self.__class__.__name__ \ - + repr((self.major_version, self.minor_version, self.major_build, self.minor_build)) + return self.__class__.__name__ + repr( + (self.major_version, self.minor_version, self.major_build, self.minor_build) + )

    Class variables

    @@ -503,7 +514,7 @@

    Static methods

    :param s: """ - bin_s = f'{int(s, 16):032b}' # Convert string to 32-bit binary string + bin_s = f"{int(s, 16):032b}" # Convert string to 32-bit binary string major_version = int(bin_s[4:10], 2) minor_version = int(bin_s[10:16], 2) build_number = int(bin_s[17:32], 2) @@ -522,10 +533,10 @@

    Static methods

    @classmethod
     def from_xml(cls, elem):
         xml_elems_map = {
    -        'major_version': 'MajorVersion',
    -        'minor_version': 'MinorVersion',
    -        'major_build': 'MajorBuildNumber',
    -        'minor_build': 'MinorBuildNumber',
    +        "major_version": "MajorVersion",
    +        "minor_version": "MinorVersion",
    +        "major_build": "MajorBuildNumber",
    +        "minor_build": "MinorBuildNumber",
         }
         kwargs = {}
         for k, xml_elem in xml_elems_map.items():
    @@ -569,11 +580,11 @@ 

    Methods

    def api_version(self):
         if EXCHANGE_2013_SP1 <= self < EXCHANGE_2016:
    -        return 'Exchange2013_SP1'
    +        return "Exchange2013_SP1"
         try:
             return self.API_VERSION_MAP[self.major_version][self.minor_version]
         except KeyError:
    -        raise ValueError(f'API version for build {self} is unknown')
    + raise ValueError(f"API version for build {self} is unknown")
    @@ -604,18 +615,18 @@

    Methods

    class Version:
         """Holds information about the server version."""
     
    -    __slots__ = 'build', 'api_version'
    +    __slots__ = "build", "api_version"
     
         def __init__(self, build, api_version=None):
             if api_version is None:
                 if not isinstance(build, Build):
    -                raise InvalidTypeError('build', build, Build)
    +                raise InvalidTypeError("build", build, Build)
                 self.api_version = build.api_version()
             else:
                 if not isinstance(build, (Build, type(None))):
    -                raise InvalidTypeError('build', build, Build)
    +                raise InvalidTypeError("build", build, Build)
                 if not isinstance(api_version, str):
    -                raise InvalidTypeError('api_version', api_version, str)
    +                raise InvalidTypeError("api_version", api_version, str)
                 self.api_version = api_version
             self.build = build
     
    @@ -636,53 +647,62 @@ 

    Methods

    :param api_version_hint: (Default value = None) """ from .services import ResolveNames + # The protocol doesn't have a version yet, so default to latest supported version if we don't have a hint. api_version = api_version_hint or API_VERSIONS[0] - log.debug('Asking server for version info using API version %s', api_version) + log.debug("Asking server for version info using API version %s", api_version) # We don't know the build version yet. Hopefully, the server will report it in the SOAP header. Lots of # places expect a version to have a build, so this is a bit dangerous, but passing a fake build around is also # dangerous. Make sure the call to ResolveNames does not require a version build. protocol.config.version = Version(build=None, api_version=api_version) # Use ResolveNames as a minimal request to the server to test if the version is correct. If not, ResolveNames # will try to guess the version automatically. - name = str(protocol.credentials) if protocol.credentials and str(protocol.credentials) else 'DUMMY' + name = str(protocol.credentials) if protocol.credentials and str(protocol.credentials) else "DUMMY" try: list(ResolveNames(protocol=protocol).call(unresolved_entries=[name])) except ResponseMessageError as e: # We may have survived long enough to get a new version if not protocol.config.version.build: - raise TransportError(f'No valid version headers found in response ({e!r})') + raise TransportError(f"No valid version headers found in response ({e!r})") if not protocol.config.version.build: - raise TransportError('No valid version headers found in response') + raise TransportError("No valid version headers found in response") return protocol.config.version @staticmethod def _is_invalid_version_string(version): # Check if a version string is bogus, e.g. V2_, V2015_ or V2018_ - return re.match(r'V[0-9]{1,4}_.*', version) + return re.match(r"V[0-9]{1,4}_.*", version) @classmethod def from_soap_header(cls, requested_api_version, header): - info = header.find(f'{{{TNS}}}ServerVersionInfo') + info = header.find(f"{{{TNS}}}ServerVersionInfo") if info is None: - raise TransportError(f'No ServerVersionInfo in header: {xml_to_str(header)!r}') + raise TransportError(f"No ServerVersionInfo in header: {xml_to_str(header)!r}") try: build = Build.from_xml(elem=info) except ValueError: - raise TransportError(f'Bad ServerVersionInfo in response: {xml_to_str(header)!r}') + raise TransportError(f"Bad ServerVersionInfo in response: {xml_to_str(header)!r}") # Not all Exchange servers send the Version element - api_version_from_server = info.get('Version') or build.api_version() + api_version_from_server = info.get("Version") or build.api_version() if api_version_from_server != requested_api_version: if cls._is_invalid_version_string(api_version_from_server): # For unknown reasons, Office 365 may respond with an API version strings that is invalid in a request. # Detect these so we can fallback to a valid version string. - log.debug('API version "%s" worked but server reports version "%s". Using "%s"', requested_api_version, - api_version_from_server, requested_api_version) + log.debug( + 'API version "%s" worked but server reports version "%s". Using "%s"', + requested_api_version, + api_version_from_server, + requested_api_version, + ) api_version_from_server = requested_api_version else: # Trust API version from server response - log.debug('API version "%s" worked but server reports version "%s". Using "%s"', requested_api_version, - api_version_from_server, api_version_from_server) + log.debug( + 'API version "%s" worked but server reports version "%s". Using "%s"', + requested_api_version, + api_version_from_server, + api_version_from_server, + ) return cls(build=build, api_version=api_version_from_server) def copy(self): @@ -701,7 +721,7 @@

    Methods

    return self.__class__.__name__ + repr((self.build, self.api_version)) def __str__(self): - return f'Build={self.build}, API={self.api_version}, Fullname={self.fullname}'
    + return f"Build={self.build}, API={self.api_version}, Fullname={self.fullname}"

    Static methods

    @@ -716,26 +736,34 @@

    Static methods

    @classmethod
     def from_soap_header(cls, requested_api_version, header):
    -    info = header.find(f'{{{TNS}}}ServerVersionInfo')
    +    info = header.find(f"{{{TNS}}}ServerVersionInfo")
         if info is None:
    -        raise TransportError(f'No ServerVersionInfo in header: {xml_to_str(header)!r}')
    +        raise TransportError(f"No ServerVersionInfo in header: {xml_to_str(header)!r}")
         try:
             build = Build.from_xml(elem=info)
         except ValueError:
    -        raise TransportError(f'Bad ServerVersionInfo in response: {xml_to_str(header)!r}')
    +        raise TransportError(f"Bad ServerVersionInfo in response: {xml_to_str(header)!r}")
         # Not all Exchange servers send the Version element
    -    api_version_from_server = info.get('Version') or build.api_version()
    +    api_version_from_server = info.get("Version") or build.api_version()
         if api_version_from_server != requested_api_version:
             if cls._is_invalid_version_string(api_version_from_server):
                 # For unknown reasons, Office 365 may respond with an API version strings that is invalid in a request.
                 # Detect these so we can fallback to a valid version string.
    -            log.debug('API version "%s" worked but server reports version "%s". Using "%s"', requested_api_version,
    -                      api_version_from_server, requested_api_version)
    +            log.debug(
    +                'API version "%s" worked but server reports version "%s". Using "%s"',
    +                requested_api_version,
    +                api_version_from_server,
    +                requested_api_version,
    +            )
                 api_version_from_server = requested_api_version
             else:
                 # Trust API version from server response
    -            log.debug('API version "%s" worked but server reports version "%s". Using "%s"', requested_api_version,
    -                      api_version_from_server, api_version_from_server)
    +            log.debug(
    +                'API version "%s" worked but server reports version "%s". Using "%s"',
    +                requested_api_version,
    +                api_version_from_server,
    +                api_version_from_server,
    +            )
         return cls(build=build, api_version=api_version_from_server)
    @@ -768,24 +796,25 @@

    Static methods

    :param api_version_hint: (Default value = None) """ from .services import ResolveNames + # The protocol doesn't have a version yet, so default to latest supported version if we don't have a hint. api_version = api_version_hint or API_VERSIONS[0] - log.debug('Asking server for version info using API version %s', api_version) + log.debug("Asking server for version info using API version %s", api_version) # We don't know the build version yet. Hopefully, the server will report it in the SOAP header. Lots of # places expect a version to have a build, so this is a bit dangerous, but passing a fake build around is also # dangerous. Make sure the call to ResolveNames does not require a version build. protocol.config.version = Version(build=None, api_version=api_version) # Use ResolveNames as a minimal request to the server to test if the version is correct. If not, ResolveNames # will try to guess the version automatically. - name = str(protocol.credentials) if protocol.credentials and str(protocol.credentials) else 'DUMMY' + name = str(protocol.credentials) if protocol.credentials and str(protocol.credentials) else "DUMMY" try: list(ResolveNames(protocol=protocol).call(unresolved_entries=[name])) except ResponseMessageError as e: # We may have survived long enough to get a new version if not protocol.config.version.build: - raise TransportError(f'No valid version headers found in response ({e!r})') + raise TransportError(f"No valid version headers found in response ({e!r})") if not protocol.config.version.build: - raise TransportError('No valid version headers found in response') + raise TransportError("No valid version headers found in response") return protocol.config.version diff --git a/docs/exchangelib/winzone.html b/docs/exchangelib/winzone.html index 2c6b8def..be1e4a5f 100644 --- a/docs/exchangelib/winzone.html +++ b/docs/exchangelib/winzone.html @@ -35,10 +35,10 @@

    Module exchangelib.winzone

    from .util import to_xml -CLDR_WINZONE_URL = 'https://raw.githubusercontent.com/unicode-org/cldr/master/common/supplemental/windowsZones.xml' -DEFAULT_TERRITORY = '001' -CLDR_WINZONE_TYPE_VERSION = '2021a' -CLDR_WINZONE_OTHER_VERSION = '7e11800' +CLDR_WINZONE_URL = "https://raw.githubusercontent.com/unicode-org/cldr/master/common/supplemental/windowsZones.xml" +DEFAULT_TERRITORY = "001" +CLDR_WINZONE_TYPE_VERSION = "2021a" +CLDR_WINZONE_OTHER_VERSION = "7e11800" def generate_map(timeout=10): @@ -49,17 +49,17 @@

    Module exchangelib.winzone

    """ r = requests.get(CLDR_WINZONE_URL, timeout=timeout) if r.status_code != 200: - raise ValueError(f'Unexpected response: {r}') + raise ValueError(f"Unexpected response: {r}") tz_map = {} - timezones_elem = to_xml(r.content).find('windowsZones').find('mapTimezones') - type_version = timezones_elem.get('typeVersion') - other_version = timezones_elem.get('otherVersion') - for e in timezones_elem.findall('mapZone'): - for location in re.split(r'\s+', e.get('type')): - if e.get('territory') == DEFAULT_TERRITORY or location not in tz_map: + timezones_elem = to_xml(r.content).find("windowsZones").find("mapTimezones") + type_version = timezones_elem.get("typeVersion") + other_version = timezones_elem.get("otherVersion") + for e in timezones_elem.findall("mapZone"): + for location in re.split(r"\s+", e.get("type")): + if e.get("territory") == DEFAULT_TERRITORY or location not in tz_map: # Prefer default territory. This is so MS_TIMEZONE_TO_IANA_MAP maps from MS timezone ID back to the # "preferred" region/location timezone name. - tz_map[location] = e.get('other'), e.get('territory') + tz_map[location] = e.get("other"), e.get("territory") return type_version, other_version, tz_map @@ -68,465 +68,465 @@

    Module exchangelib.winzone

    # # This list was generated from CLDR_WINZONE_URL version CLDR_WINZONE_VERSION. CLDR_TO_MS_TIMEZONE_MAP = { - 'Africa/Abidjan': ('Greenwich Standard Time', 'CI'), - 'Africa/Accra': ('Greenwich Standard Time', 'GH'), - 'Africa/Addis_Ababa': ('E. Africa Standard Time', 'ET'), - 'Africa/Algiers': ('W. Central Africa Standard Time', 'DZ'), - 'Africa/Asmera': ('E. Africa Standard Time', 'ER'), - 'Africa/Bamako': ('Greenwich Standard Time', 'ML'), - 'Africa/Bangui': ('W. Central Africa Standard Time', 'CF'), - 'Africa/Banjul': ('Greenwich Standard Time', 'GM'), - 'Africa/Bissau': ('Greenwich Standard Time', 'GW'), - 'Africa/Blantyre': ('South Africa Standard Time', 'MW'), - 'Africa/Brazzaville': ('W. Central Africa Standard Time', 'CG'), - 'Africa/Bujumbura': ('South Africa Standard Time', 'BI'), - 'Africa/Cairo': ('Egypt Standard Time', '001'), - 'Africa/Casablanca': ('Morocco Standard Time', '001'), - 'Africa/Ceuta': ('Romance Standard Time', 'ES'), - 'Africa/Conakry': ('Greenwich Standard Time', 'GN'), - 'Africa/Dakar': ('Greenwich Standard Time', 'SN'), - 'Africa/Dar_es_Salaam': ('E. Africa Standard Time', 'TZ'), - 'Africa/Djibouti': ('E. Africa Standard Time', 'DJ'), - 'Africa/Douala': ('W. Central Africa Standard Time', 'CM'), - 'Africa/El_Aaiun': ('Morocco Standard Time', 'EH'), - 'Africa/Freetown': ('Greenwich Standard Time', 'SL'), - 'Africa/Gaborone': ('South Africa Standard Time', 'BW'), - 'Africa/Harare': ('South Africa Standard Time', 'ZW'), - 'Africa/Johannesburg': ('South Africa Standard Time', '001'), - 'Africa/Juba': ('South Sudan Standard Time', '001'), - 'Africa/Kampala': ('E. Africa Standard Time', 'UG'), - 'Africa/Khartoum': ('Sudan Standard Time', '001'), - 'Africa/Kigali': ('South Africa Standard Time', 'RW'), - 'Africa/Kinshasa': ('W. Central Africa Standard Time', 'CD'), - 'Africa/Lagos': ('W. Central Africa Standard Time', '001'), - 'Africa/Libreville': ('W. Central Africa Standard Time', 'GA'), - 'Africa/Lome': ('Greenwich Standard Time', 'TG'), - 'Africa/Luanda': ('W. Central Africa Standard Time', 'AO'), - 'Africa/Lubumbashi': ('South Africa Standard Time', 'CD'), - 'Africa/Lusaka': ('South Africa Standard Time', 'ZM'), - 'Africa/Malabo': ('W. Central Africa Standard Time', 'GQ'), - 'Africa/Maputo': ('South Africa Standard Time', 'MZ'), - 'Africa/Maseru': ('South Africa Standard Time', 'LS'), - 'Africa/Mbabane': ('South Africa Standard Time', 'SZ'), - 'Africa/Mogadishu': ('E. Africa Standard Time', 'SO'), - 'Africa/Monrovia': ('Greenwich Standard Time', 'LR'), - 'Africa/Nairobi': ('E. Africa Standard Time', '001'), - 'Africa/Ndjamena': ('W. Central Africa Standard Time', 'TD'), - 'Africa/Niamey': ('W. Central Africa Standard Time', 'NE'), - 'Africa/Nouakchott': ('Greenwich Standard Time', 'MR'), - 'Africa/Ouagadougou': ('Greenwich Standard Time', 'BF'), - 'Africa/Porto-Novo': ('W. Central Africa Standard Time', 'BJ'), - 'Africa/Sao_Tome': ('Sao Tome Standard Time', '001'), - 'Africa/Tripoli': ('Libya Standard Time', '001'), - 'Africa/Tunis': ('W. Central Africa Standard Time', 'TN'), - 'Africa/Windhoek': ('Namibia Standard Time', '001'), - 'America/Adak': ('Aleutian Standard Time', '001'), - 'America/Anchorage': ('Alaskan Standard Time', '001'), - 'America/Anguilla': ('SA Western Standard Time', 'AI'), - 'America/Antigua': ('SA Western Standard Time', 'AG'), - 'America/Araguaina': ('Tocantins Standard Time', '001'), - 'America/Argentina/La_Rioja': ('Argentina Standard Time', 'AR'), - 'America/Argentina/Rio_Gallegos': ('Argentina Standard Time', 'AR'), - 'America/Argentina/Salta': ('Argentina Standard Time', 'AR'), - 'America/Argentina/San_Juan': ('Argentina Standard Time', 'AR'), - 'America/Argentina/San_Luis': ('Argentina Standard Time', 'AR'), - 'America/Argentina/Tucuman': ('Argentina Standard Time', 'AR'), - 'America/Argentina/Ushuaia': ('Argentina Standard Time', 'AR'), - 'America/Aruba': ('SA Western Standard Time', 'AW'), - 'America/Asuncion': ('Paraguay Standard Time', '001'), - 'America/Bahia': ('Bahia Standard Time', '001'), - 'America/Bahia_Banderas': ('Central Standard Time (Mexico)', 'MX'), - 'America/Barbados': ('SA Western Standard Time', 'BB'), - 'America/Belem': ('SA Eastern Standard Time', 'BR'), - 'America/Belize': ('Central America Standard Time', 'BZ'), - 'America/Blanc-Sablon': ('SA Western Standard Time', 'CA'), - 'America/Boa_Vista': ('SA Western Standard Time', 'BR'), - 'America/Bogota': ('SA Pacific Standard Time', '001'), - 'America/Boise': ('Mountain Standard Time', 'US'), - 'America/Buenos_Aires': ('Argentina Standard Time', '001'), - 'America/Cambridge_Bay': ('Mountain Standard Time', 'CA'), - 'America/Campo_Grande': ('Central Brazilian Standard Time', 'BR'), - 'America/Cancun': ('Eastern Standard Time (Mexico)', '001'), - 'America/Caracas': ('Venezuela Standard Time', '001'), - 'America/Catamarca': ('Argentina Standard Time', 'AR'), - 'America/Cayenne': ('SA Eastern Standard Time', '001'), - 'America/Cayman': ('SA Pacific Standard Time', 'KY'), - 'America/Chicago': ('Central Standard Time', '001'), - 'America/Chihuahua': ('Mountain Standard Time (Mexico)', '001'), - 'America/Coral_Harbour': ('SA Pacific Standard Time', 'CA'), - 'America/Cordoba': ('Argentina Standard Time', 'AR'), - 'America/Costa_Rica': ('Central America Standard Time', 'CR'), - 'America/Creston': ('US Mountain Standard Time', 'CA'), - 'America/Cuiaba': ('Central Brazilian Standard Time', '001'), - 'America/Curacao': ('SA Western Standard Time', 'CW'), - 'America/Danmarkshavn': ('Greenwich Standard Time', 'GL'), - 'America/Dawson': ('Yukon Standard Time', 'CA'), - 'America/Dawson_Creek': ('US Mountain Standard Time', 'CA'), - 'America/Denver': ('Mountain Standard Time', '001'), - 'America/Detroit': ('Eastern Standard Time', 'US'), - 'America/Dominica': ('SA Western Standard Time', 'DM'), - 'America/Edmonton': ('Mountain Standard Time', 'CA'), - 'America/Eirunepe': ('SA Pacific Standard Time', 'BR'), - 'America/El_Salvador': ('Central America Standard Time', 'SV'), - 'America/Fort_Nelson': ('US Mountain Standard Time', 'CA'), - 'America/Fortaleza': ('SA Eastern Standard Time', 'BR'), - 'America/Glace_Bay': ('Atlantic Standard Time', 'CA'), - 'America/Godthab': ('Greenland Standard Time', '001'), - 'America/Goose_Bay': ('Atlantic Standard Time', 'CA'), - 'America/Grand_Turk': ('Turks And Caicos Standard Time', '001'), - 'America/Grenada': ('SA Western Standard Time', 'GD'), - 'America/Guadeloupe': ('SA Western Standard Time', 'GP'), - 'America/Guatemala': ('Central America Standard Time', '001'), - 'America/Guayaquil': ('SA Pacific Standard Time', 'EC'), - 'America/Guyana': ('SA Western Standard Time', 'GY'), - 'America/Halifax': ('Atlantic Standard Time', '001'), - 'America/Havana': ('Cuba Standard Time', '001'), - 'America/Hermosillo': ('US Mountain Standard Time', 'MX'), - 'America/Indiana/Knox': ('Central Standard Time', 'US'), - 'America/Indiana/Marengo': ('US Eastern Standard Time', 'US'), - 'America/Indiana/Petersburg': ('Eastern Standard Time', 'US'), - 'America/Indiana/Tell_City': ('Central Standard Time', 'US'), - 'America/Indiana/Vevay': ('US Eastern Standard Time', 'US'), - 'America/Indiana/Vincennes': ('Eastern Standard Time', 'US'), - 'America/Indiana/Winamac': ('Eastern Standard Time', 'US'), - 'America/Indianapolis': ('US Eastern Standard Time', '001'), - 'America/Inuvik': ('Mountain Standard Time', 'CA'), - 'America/Iqaluit': ('Eastern Standard Time', 'CA'), - 'America/Jamaica': ('SA Pacific Standard Time', 'JM'), - 'America/Jujuy': ('Argentina Standard Time', 'AR'), - 'America/Juneau': ('Alaskan Standard Time', 'US'), - 'America/Kentucky/Monticello': ('Eastern Standard Time', 'US'), - 'America/Kralendijk': ('SA Western Standard Time', 'BQ'), - 'America/La_Paz': ('SA Western Standard Time', '001'), - 'America/Lima': ('SA Pacific Standard Time', 'PE'), - 'America/Los_Angeles': ('Pacific Standard Time', '001'), - 'America/Louisville': ('Eastern Standard Time', 'US'), - 'America/Lower_Princes': ('SA Western Standard Time', 'SX'), - 'America/Maceio': ('SA Eastern Standard Time', 'BR'), - 'America/Managua': ('Central America Standard Time', 'NI'), - 'America/Manaus': ('SA Western Standard Time', 'BR'), - 'America/Marigot': ('SA Western Standard Time', 'MF'), - 'America/Martinique': ('SA Western Standard Time', 'MQ'), - 'America/Matamoros': ('Central Standard Time', 'MX'), - 'America/Mazatlan': ('Mountain Standard Time (Mexico)', 'MX'), - 'America/Mendoza': ('Argentina Standard Time', 'AR'), - 'America/Menominee': ('Central Standard Time', 'US'), - 'America/Merida': ('Central Standard Time (Mexico)', 'MX'), - 'America/Metlakatla': ('Alaskan Standard Time', 'US'), - 'America/Mexico_City': ('Central Standard Time (Mexico)', '001'), - 'America/Miquelon': ('Saint Pierre Standard Time', '001'), - 'America/Moncton': ('Atlantic Standard Time', 'CA'), - 'America/Monterrey': ('Central Standard Time (Mexico)', 'MX'), - 'America/Montevideo': ('Montevideo Standard Time', '001'), - 'America/Montreal': ('Eastern Standard Time', 'CA'), - 'America/Montserrat': ('SA Western Standard Time', 'MS'), - 'America/Nassau': ('Eastern Standard Time', 'BS'), - 'America/New_York': ('Eastern Standard Time', '001'), - 'America/Nipigon': ('Eastern Standard Time', 'CA'), - 'America/Nome': ('Alaskan Standard Time', 'US'), - 'America/Noronha': ('UTC-02', 'BR'), - 'America/North_Dakota/Beulah': ('Central Standard Time', 'US'), - 'America/North_Dakota/Center': ('Central Standard Time', 'US'), - 'America/North_Dakota/New_Salem': ('Central Standard Time', 'US'), - 'America/Ojinaga': ('Mountain Standard Time', 'MX'), - 'America/Panama': ('SA Pacific Standard Time', 'PA'), - 'America/Pangnirtung': ('Eastern Standard Time', 'CA'), - 'America/Paramaribo': ('SA Eastern Standard Time', 'SR'), - 'America/Phoenix': ('US Mountain Standard Time', '001'), - 'America/Port-au-Prince': ('Haiti Standard Time', '001'), - 'America/Port_of_Spain': ('SA Western Standard Time', 'TT'), - 'America/Porto_Velho': ('SA Western Standard Time', 'BR'), - 'America/Puerto_Rico': ('SA Western Standard Time', 'PR'), - 'America/Punta_Arenas': ('Magallanes Standard Time', '001'), - 'America/Rainy_River': ('Central Standard Time', 'CA'), - 'America/Rankin_Inlet': ('Central Standard Time', 'CA'), - 'America/Recife': ('SA Eastern Standard Time', 'BR'), - 'America/Regina': ('Canada Central Standard Time', '001'), - 'America/Resolute': ('Central Standard Time', 'CA'), - 'America/Rio_Branco': ('SA Pacific Standard Time', 'BR'), - 'America/Santa_Isabel': ('Pacific Standard Time (Mexico)', 'MX'), - 'America/Santarem': ('SA Eastern Standard Time', 'BR'), - 'America/Santiago': ('Pacific SA Standard Time', '001'), - 'America/Santo_Domingo': ('SA Western Standard Time', 'DO'), - 'America/Sao_Paulo': ('E. South America Standard Time', '001'), - 'America/Scoresbysund': ('Azores Standard Time', 'GL'), - 'America/Sitka': ('Alaskan Standard Time', 'US'), - 'America/St_Barthelemy': ('SA Western Standard Time', 'BL'), - 'America/St_Johns': ('Newfoundland Standard Time', '001'), - 'America/St_Kitts': ('SA Western Standard Time', 'KN'), - 'America/St_Lucia': ('SA Western Standard Time', 'LC'), - 'America/St_Thomas': ('SA Western Standard Time', 'VI'), - 'America/St_Vincent': ('SA Western Standard Time', 'VC'), - 'America/Swift_Current': ('Canada Central Standard Time', 'CA'), - 'America/Tegucigalpa': ('Central America Standard Time', 'HN'), - 'America/Thule': ('Atlantic Standard Time', 'GL'), - 'America/Thunder_Bay': ('Eastern Standard Time', 'CA'), - 'America/Tijuana': ('Pacific Standard Time (Mexico)', '001'), - 'America/Toronto': ('Eastern Standard Time', 'CA'), - 'America/Tortola': ('SA Western Standard Time', 'VG'), - 'America/Vancouver': ('Pacific Standard Time', 'CA'), - 'America/Whitehorse': ('Yukon Standard Time', '001'), - 'America/Winnipeg': ('Central Standard Time', 'CA'), - 'America/Yakutat': ('Alaskan Standard Time', 'US'), - 'America/Yellowknife': ('Mountain Standard Time', 'CA'), - 'Antarctica/Casey': ('Central Pacific Standard Time', 'AQ'), - 'Antarctica/Davis': ('SE Asia Standard Time', 'AQ'), - 'Antarctica/DumontDUrville': ('West Pacific Standard Time', 'AQ'), - 'Antarctica/Macquarie': ('Tasmania Standard Time', 'AU'), - 'Antarctica/Mawson': ('West Asia Standard Time', 'AQ'), - 'Antarctica/McMurdo': ('New Zealand Standard Time', 'AQ'), - 'Antarctica/Palmer': ('SA Eastern Standard Time', 'AQ'), - 'Antarctica/Rothera': ('SA Eastern Standard Time', 'AQ'), - 'Antarctica/Syowa': ('E. Africa Standard Time', 'AQ'), - 'Antarctica/Vostok': ('Central Asia Standard Time', 'AQ'), - 'Arctic/Longyearbyen': ('W. Europe Standard Time', 'SJ'), - 'Asia/Aden': ('Arab Standard Time', 'YE'), - 'Asia/Almaty': ('Central Asia Standard Time', '001'), - 'Asia/Amman': ('Jordan Standard Time', '001'), - 'Asia/Anadyr': ('Russia Time Zone 11', 'RU'), - 'Asia/Aqtau': ('West Asia Standard Time', 'KZ'), - 'Asia/Aqtobe': ('West Asia Standard Time', 'KZ'), - 'Asia/Ashgabat': ('West Asia Standard Time', 'TM'), - 'Asia/Atyrau': ('West Asia Standard Time', 'KZ'), - 'Asia/Baghdad': ('Arabic Standard Time', '001'), - 'Asia/Bahrain': ('Arab Standard Time', 'BH'), - 'Asia/Baku': ('Azerbaijan Standard Time', '001'), - 'Asia/Bangkok': ('SE Asia Standard Time', '001'), - 'Asia/Barnaul': ('Altai Standard Time', '001'), - 'Asia/Beirut': ('Middle East Standard Time', '001'), - 'Asia/Bishkek': ('Central Asia Standard Time', 'KG'), - 'Asia/Brunei': ('Singapore Standard Time', 'BN'), - 'Asia/Calcutta': ('India Standard Time', '001'), - 'Asia/Chita': ('Transbaikal Standard Time', '001'), - 'Asia/Choibalsan': ('Ulaanbaatar Standard Time', 'MN'), - 'Asia/Colombo': ('Sri Lanka Standard Time', '001'), - 'Asia/Damascus': ('Syria Standard Time', '001'), - 'Asia/Dhaka': ('Bangladesh Standard Time', '001'), - 'Asia/Dili': ('Tokyo Standard Time', 'TL'), - 'Asia/Dubai': ('Arabian Standard Time', '001'), - 'Asia/Dushanbe': ('West Asia Standard Time', 'TJ'), - 'Asia/Famagusta': ('GTB Standard Time', 'CY'), - 'Asia/Gaza': ('West Bank Standard Time', 'PS'), - 'Asia/Hebron': ('West Bank Standard Time', '001'), - 'Asia/Hong_Kong': ('China Standard Time', 'HK'), - 'Asia/Hovd': ('W. Mongolia Standard Time', '001'), - 'Asia/Irkutsk': ('North Asia East Standard Time', '001'), - 'Asia/Jakarta': ('SE Asia Standard Time', 'ID'), - 'Asia/Jayapura': ('Tokyo Standard Time', 'ID'), - 'Asia/Jerusalem': ('Israel Standard Time', '001'), - 'Asia/Kabul': ('Afghanistan Standard Time', '001'), - 'Asia/Kamchatka': ('Russia Time Zone 11', '001'), - 'Asia/Karachi': ('Pakistan Standard Time', '001'), - 'Asia/Katmandu': ('Nepal Standard Time', '001'), - 'Asia/Khandyga': ('Yakutsk Standard Time', 'RU'), - 'Asia/Krasnoyarsk': ('North Asia Standard Time', '001'), - 'Asia/Kuala_Lumpur': ('Singapore Standard Time', 'MY'), - 'Asia/Kuching': ('Singapore Standard Time', 'MY'), - 'Asia/Kuwait': ('Arab Standard Time', 'KW'), - 'Asia/Macau': ('China Standard Time', 'MO'), - 'Asia/Magadan': ('Magadan Standard Time', '001'), - 'Asia/Makassar': ('Singapore Standard Time', 'ID'), - 'Asia/Manila': ('Singapore Standard Time', 'PH'), - 'Asia/Muscat': ('Arabian Standard Time', 'OM'), - 'Asia/Nicosia': ('GTB Standard Time', 'CY'), - 'Asia/Novokuznetsk': ('North Asia Standard Time', 'RU'), - 'Asia/Novosibirsk': ('N. Central Asia Standard Time', '001'), - 'Asia/Omsk': ('Omsk Standard Time', '001'), - 'Asia/Oral': ('West Asia Standard Time', 'KZ'), - 'Asia/Phnom_Penh': ('SE Asia Standard Time', 'KH'), - 'Asia/Pontianak': ('SE Asia Standard Time', 'ID'), - 'Asia/Pyongyang': ('North Korea Standard Time', '001'), - 'Asia/Qatar': ('Arab Standard Time', 'QA'), - 'Asia/Qostanay': ('Central Asia Standard Time', 'KZ'), - 'Asia/Qyzylorda': ('Qyzylorda Standard Time', '001'), - 'Asia/Rangoon': ('Myanmar Standard Time', '001'), - 'Asia/Riyadh': ('Arab Standard Time', '001'), - 'Asia/Saigon': ('SE Asia Standard Time', 'VN'), - 'Asia/Sakhalin': ('Sakhalin Standard Time', '001'), - 'Asia/Samarkand': ('West Asia Standard Time', 'UZ'), - 'Asia/Seoul': ('Korea Standard Time', '001'), - 'Asia/Shanghai': ('China Standard Time', '001'), - 'Asia/Singapore': ('Singapore Standard Time', '001'), - 'Asia/Srednekolymsk': ('Russia Time Zone 10', '001'), - 'Asia/Taipei': ('Taipei Standard Time', '001'), - 'Asia/Tashkent': ('West Asia Standard Time', '001'), - 'Asia/Tbilisi': ('Georgian Standard Time', '001'), - 'Asia/Tehran': ('Iran Standard Time', '001'), - 'Asia/Thimphu': ('Bangladesh Standard Time', 'BT'), - 'Asia/Tokyo': ('Tokyo Standard Time', '001'), - 'Asia/Tomsk': ('Tomsk Standard Time', '001'), - 'Asia/Ulaanbaatar': ('Ulaanbaatar Standard Time', '001'), - 'Asia/Urumqi': ('Central Asia Standard Time', 'CN'), - 'Asia/Ust-Nera': ('Vladivostok Standard Time', 'RU'), - 'Asia/Vientiane': ('SE Asia Standard Time', 'LA'), - 'Asia/Vladivostok': ('Vladivostok Standard Time', '001'), - 'Asia/Yakutsk': ('Yakutsk Standard Time', '001'), - 'Asia/Yekaterinburg': ('Ekaterinburg Standard Time', '001'), - 'Asia/Yerevan': ('Caucasus Standard Time', '001'), - 'Atlantic/Azores': ('Azores Standard Time', '001'), - 'Atlantic/Bermuda': ('Atlantic Standard Time', 'BM'), - 'Atlantic/Canary': ('GMT Standard Time', 'ES'), - 'Atlantic/Cape_Verde': ('Cape Verde Standard Time', '001'), - 'Atlantic/Faeroe': ('GMT Standard Time', 'FO'), - 'Atlantic/Madeira': ('GMT Standard Time', 'PT'), - 'Atlantic/Reykjavik': ('Greenwich Standard Time', '001'), - 'Atlantic/South_Georgia': ('UTC-02', 'GS'), - 'Atlantic/St_Helena': ('Greenwich Standard Time', 'SH'), - 'Atlantic/Stanley': ('SA Eastern Standard Time', 'FK'), - 'Australia/Adelaide': ('Cen. Australia Standard Time', '001'), - 'Australia/Brisbane': ('E. Australia Standard Time', '001'), - 'Australia/Broken_Hill': ('Cen. Australia Standard Time', 'AU'), - 'Australia/Currie': ('Tasmania Standard Time', 'AU'), - 'Australia/Darwin': ('AUS Central Standard Time', '001'), - 'Australia/Eucla': ('Aus Central W. Standard Time', '001'), - 'Australia/Hobart': ('Tasmania Standard Time', '001'), - 'Australia/Lindeman': ('E. Australia Standard Time', 'AU'), - 'Australia/Lord_Howe': ('Lord Howe Standard Time', '001'), - 'Australia/Melbourne': ('AUS Eastern Standard Time', 'AU'), - 'Australia/Perth': ('W. Australia Standard Time', '001'), - 'Australia/Sydney': ('AUS Eastern Standard Time', '001'), - 'CST6CDT': ('Central Standard Time', 'ZZ'), - 'EST5EDT': ('Eastern Standard Time', 'ZZ'), - 'Etc/GMT': ('UTC', 'ZZ'), - 'Etc/GMT+1': ('Cape Verde Standard Time', 'ZZ'), - 'Etc/GMT+10': ('Hawaiian Standard Time', 'ZZ'), - 'Etc/GMT+11': ('UTC-11', '001'), - 'Etc/GMT+12': ('Dateline Standard Time', '001'), - 'Etc/GMT+2': ('UTC-02', '001'), - 'Etc/GMT+3': ('SA Eastern Standard Time', 'ZZ'), - 'Etc/GMT+4': ('SA Western Standard Time', 'ZZ'), - 'Etc/GMT+5': ('SA Pacific Standard Time', 'ZZ'), - 'Etc/GMT+6': ('Central America Standard Time', 'ZZ'), - 'Etc/GMT+7': ('US Mountain Standard Time', 'ZZ'), - 'Etc/GMT+8': ('UTC-08', '001'), - 'Etc/GMT+9': ('UTC-09', '001'), - 'Etc/GMT-1': ('W. Central Africa Standard Time', 'ZZ'), - 'Etc/GMT-10': ('West Pacific Standard Time', 'ZZ'), - 'Etc/GMT-11': ('Central Pacific Standard Time', 'ZZ'), - 'Etc/GMT-12': ('UTC+12', '001'), - 'Etc/GMT-13': ('UTC+13', '001'), - 'Etc/GMT-14': ('Line Islands Standard Time', 'ZZ'), - 'Etc/GMT-2': ('South Africa Standard Time', 'ZZ'), - 'Etc/GMT-3': ('E. Africa Standard Time', 'ZZ'), - 'Etc/GMT-4': ('Arabian Standard Time', 'ZZ'), - 'Etc/GMT-5': ('West Asia Standard Time', 'ZZ'), - 'Etc/GMT-6': ('Central Asia Standard Time', 'ZZ'), - 'Etc/GMT-7': ('SE Asia Standard Time', 'ZZ'), - 'Etc/GMT-8': ('Singapore Standard Time', 'ZZ'), - 'Etc/GMT-9': ('Tokyo Standard Time', 'ZZ'), - 'Etc/UTC': ('UTC', '001'), - 'Europe/Amsterdam': ('W. Europe Standard Time', 'NL'), - 'Europe/Andorra': ('W. Europe Standard Time', 'AD'), - 'Europe/Astrakhan': ('Astrakhan Standard Time', '001'), - 'Europe/Athens': ('GTB Standard Time', 'GR'), - 'Europe/Belgrade': ('Central Europe Standard Time', 'RS'), - 'Europe/Berlin': ('W. Europe Standard Time', '001'), - 'Europe/Bratislava': ('Central Europe Standard Time', 'SK'), - 'Europe/Brussels': ('Romance Standard Time', 'BE'), - 'Europe/Bucharest': ('GTB Standard Time', '001'), - 'Europe/Budapest': ('Central Europe Standard Time', '001'), - 'Europe/Busingen': ('W. Europe Standard Time', 'DE'), - 'Europe/Chisinau': ('E. Europe Standard Time', '001'), - 'Europe/Copenhagen': ('Romance Standard Time', 'DK'), - 'Europe/Dublin': ('GMT Standard Time', 'IE'), - 'Europe/Gibraltar': ('W. Europe Standard Time', 'GI'), - 'Europe/Guernsey': ('GMT Standard Time', 'GG'), - 'Europe/Helsinki': ('FLE Standard Time', 'FI'), - 'Europe/Isle_of_Man': ('GMT Standard Time', 'IM'), - 'Europe/Istanbul': ('Turkey Standard Time', '001'), - 'Europe/Jersey': ('GMT Standard Time', 'JE'), - 'Europe/Kaliningrad': ('Kaliningrad Standard Time', '001'), - 'Europe/Kiev': ('FLE Standard Time', '001'), - 'Europe/Kirov': ('Russian Standard Time', 'RU'), - 'Europe/Lisbon': ('GMT Standard Time', 'PT'), - 'Europe/Ljubljana': ('Central Europe Standard Time', 'SI'), - 'Europe/London': ('GMT Standard Time', '001'), - 'Europe/Luxembourg': ('W. Europe Standard Time', 'LU'), - 'Europe/Madrid': ('Romance Standard Time', 'ES'), - 'Europe/Malta': ('W. Europe Standard Time', 'MT'), - 'Europe/Mariehamn': ('FLE Standard Time', 'AX'), - 'Europe/Minsk': ('Belarus Standard Time', '001'), - 'Europe/Monaco': ('W. Europe Standard Time', 'MC'), - 'Europe/Moscow': ('Russian Standard Time', '001'), - 'Europe/Oslo': ('W. Europe Standard Time', 'NO'), - 'Europe/Paris': ('Romance Standard Time', '001'), - 'Europe/Podgorica': ('Central Europe Standard Time', 'ME'), - 'Europe/Prague': ('Central Europe Standard Time', 'CZ'), - 'Europe/Riga': ('FLE Standard Time', 'LV'), - 'Europe/Rome': ('W. Europe Standard Time', 'IT'), - 'Europe/Samara': ('Russia Time Zone 3', '001'), - 'Europe/San_Marino': ('W. Europe Standard Time', 'SM'), - 'Europe/Sarajevo': ('Central European Standard Time', 'BA'), - 'Europe/Saratov': ('Saratov Standard Time', '001'), - 'Europe/Simferopol': ('Russian Standard Time', 'UA'), - 'Europe/Skopje': ('Central European Standard Time', 'MK'), - 'Europe/Sofia': ('FLE Standard Time', 'BG'), - 'Europe/Stockholm': ('W. Europe Standard Time', 'SE'), - 'Europe/Tallinn': ('FLE Standard Time', 'EE'), - 'Europe/Tirane': ('Central Europe Standard Time', 'AL'), - 'Europe/Ulyanovsk': ('Astrakhan Standard Time', 'RU'), - 'Europe/Uzhgorod': ('FLE Standard Time', 'UA'), - 'Europe/Vaduz': ('W. Europe Standard Time', 'LI'), - 'Europe/Vatican': ('W. Europe Standard Time', 'VA'), - 'Europe/Vienna': ('W. Europe Standard Time', 'AT'), - 'Europe/Vilnius': ('FLE Standard Time', 'LT'), - 'Europe/Volgograd': ('Volgograd Standard Time', '001'), - 'Europe/Warsaw': ('Central European Standard Time', '001'), - 'Europe/Zagreb': ('Central European Standard Time', 'HR'), - 'Europe/Zaporozhye': ('FLE Standard Time', 'UA'), - 'Europe/Zurich': ('W. Europe Standard Time', 'CH'), - 'Indian/Antananarivo': ('E. Africa Standard Time', 'MG'), - 'Indian/Chagos': ('Central Asia Standard Time', 'IO'), - 'Indian/Christmas': ('SE Asia Standard Time', 'CX'), - 'Indian/Cocos': ('Myanmar Standard Time', 'CC'), - 'Indian/Comoro': ('E. Africa Standard Time', 'KM'), - 'Indian/Kerguelen': ('West Asia Standard Time', 'TF'), - 'Indian/Mahe': ('Mauritius Standard Time', 'SC'), - 'Indian/Maldives': ('West Asia Standard Time', 'MV'), - 'Indian/Mauritius': ('Mauritius Standard Time', '001'), - 'Indian/Mayotte': ('E. Africa Standard Time', 'YT'), - 'Indian/Reunion': ('Mauritius Standard Time', 'RE'), - 'MST7MDT': ('Mountain Standard Time', 'ZZ'), - 'PST8PDT': ('Pacific Standard Time', 'ZZ'), - 'Pacific/Apia': ('Samoa Standard Time', '001'), - 'Pacific/Auckland': ('New Zealand Standard Time', '001'), - 'Pacific/Bougainville': ('Bougainville Standard Time', '001'), - 'Pacific/Chatham': ('Chatham Islands Standard Time', '001'), - 'Pacific/Easter': ('Easter Island Standard Time', '001'), - 'Pacific/Efate': ('Central Pacific Standard Time', 'VU'), - 'Pacific/Enderbury': ('UTC+13', 'KI'), - 'Pacific/Fakaofo': ('UTC+13', 'TK'), - 'Pacific/Fiji': ('Fiji Standard Time', '001'), - 'Pacific/Funafuti': ('UTC+12', 'TV'), - 'Pacific/Galapagos': ('Central America Standard Time', 'EC'), - 'Pacific/Gambier': ('UTC-09', 'PF'), - 'Pacific/Guadalcanal': ('Central Pacific Standard Time', '001'), - 'Pacific/Guam': ('West Pacific Standard Time', 'GU'), - 'Pacific/Honolulu': ('Hawaiian Standard Time', '001'), - 'Pacific/Johnston': ('Hawaiian Standard Time', 'UM'), - 'Pacific/Kiritimati': ('Line Islands Standard Time', '001'), - 'Pacific/Kosrae': ('Central Pacific Standard Time', 'FM'), - 'Pacific/Kwajalein': ('UTC+12', 'MH'), - 'Pacific/Majuro': ('UTC+12', 'MH'), - 'Pacific/Marquesas': ('Marquesas Standard Time', '001'), - 'Pacific/Midway': ('UTC-11', 'UM'), - 'Pacific/Nauru': ('UTC+12', 'NR'), - 'Pacific/Niue': ('UTC-11', 'NU'), - 'Pacific/Norfolk': ('Norfolk Standard Time', '001'), - 'Pacific/Noumea': ('Central Pacific Standard Time', 'NC'), - 'Pacific/Pago_Pago': ('UTC-11', 'AS'), - 'Pacific/Palau': ('Tokyo Standard Time', 'PW'), - 'Pacific/Pitcairn': ('UTC-08', 'PN'), - 'Pacific/Ponape': ('Central Pacific Standard Time', 'FM'), - 'Pacific/Port_Moresby': ('West Pacific Standard Time', '001'), - 'Pacific/Rarotonga': ('Hawaiian Standard Time', 'CK'), - 'Pacific/Saipan': ('West Pacific Standard Time', 'MP'), - 'Pacific/Tahiti': ('Hawaiian Standard Time', 'PF'), - 'Pacific/Tarawa': ('UTC+12', 'KI'), - 'Pacific/Tongatapu': ('Tonga Standard Time', '001'), - 'Pacific/Truk': ('West Pacific Standard Time', 'FM'), - 'Pacific/Wake': ('UTC+12', 'UM'), - 'Pacific/Wallis': ('UTC+12', 'WF'), + "Africa/Abidjan": ("Greenwich Standard Time", "CI"), + "Africa/Accra": ("Greenwich Standard Time", "GH"), + "Africa/Addis_Ababa": ("E. Africa Standard Time", "ET"), + "Africa/Algiers": ("W. Central Africa Standard Time", "DZ"), + "Africa/Asmera": ("E. Africa Standard Time", "ER"), + "Africa/Bamako": ("Greenwich Standard Time", "ML"), + "Africa/Bangui": ("W. Central Africa Standard Time", "CF"), + "Africa/Banjul": ("Greenwich Standard Time", "GM"), + "Africa/Bissau": ("Greenwich Standard Time", "GW"), + "Africa/Blantyre": ("South Africa Standard Time", "MW"), + "Africa/Brazzaville": ("W. Central Africa Standard Time", "CG"), + "Africa/Bujumbura": ("South Africa Standard Time", "BI"), + "Africa/Cairo": ("Egypt Standard Time", "001"), + "Africa/Casablanca": ("Morocco Standard Time", "001"), + "Africa/Ceuta": ("Romance Standard Time", "ES"), + "Africa/Conakry": ("Greenwich Standard Time", "GN"), + "Africa/Dakar": ("Greenwich Standard Time", "SN"), + "Africa/Dar_es_Salaam": ("E. Africa Standard Time", "TZ"), + "Africa/Djibouti": ("E. Africa Standard Time", "DJ"), + "Africa/Douala": ("W. Central Africa Standard Time", "CM"), + "Africa/El_Aaiun": ("Morocco Standard Time", "EH"), + "Africa/Freetown": ("Greenwich Standard Time", "SL"), + "Africa/Gaborone": ("South Africa Standard Time", "BW"), + "Africa/Harare": ("South Africa Standard Time", "ZW"), + "Africa/Johannesburg": ("South Africa Standard Time", "001"), + "Africa/Juba": ("South Sudan Standard Time", "001"), + "Africa/Kampala": ("E. Africa Standard Time", "UG"), + "Africa/Khartoum": ("Sudan Standard Time", "001"), + "Africa/Kigali": ("South Africa Standard Time", "RW"), + "Africa/Kinshasa": ("W. Central Africa Standard Time", "CD"), + "Africa/Lagos": ("W. Central Africa Standard Time", "001"), + "Africa/Libreville": ("W. Central Africa Standard Time", "GA"), + "Africa/Lome": ("Greenwich Standard Time", "TG"), + "Africa/Luanda": ("W. Central Africa Standard Time", "AO"), + "Africa/Lubumbashi": ("South Africa Standard Time", "CD"), + "Africa/Lusaka": ("South Africa Standard Time", "ZM"), + "Africa/Malabo": ("W. Central Africa Standard Time", "GQ"), + "Africa/Maputo": ("South Africa Standard Time", "MZ"), + "Africa/Maseru": ("South Africa Standard Time", "LS"), + "Africa/Mbabane": ("South Africa Standard Time", "SZ"), + "Africa/Mogadishu": ("E. Africa Standard Time", "SO"), + "Africa/Monrovia": ("Greenwich Standard Time", "LR"), + "Africa/Nairobi": ("E. Africa Standard Time", "001"), + "Africa/Ndjamena": ("W. Central Africa Standard Time", "TD"), + "Africa/Niamey": ("W. Central Africa Standard Time", "NE"), + "Africa/Nouakchott": ("Greenwich Standard Time", "MR"), + "Africa/Ouagadougou": ("Greenwich Standard Time", "BF"), + "Africa/Porto-Novo": ("W. Central Africa Standard Time", "BJ"), + "Africa/Sao_Tome": ("Sao Tome Standard Time", "001"), + "Africa/Tripoli": ("Libya Standard Time", "001"), + "Africa/Tunis": ("W. Central Africa Standard Time", "TN"), + "Africa/Windhoek": ("Namibia Standard Time", "001"), + "America/Adak": ("Aleutian Standard Time", "001"), + "America/Anchorage": ("Alaskan Standard Time", "001"), + "America/Anguilla": ("SA Western Standard Time", "AI"), + "America/Antigua": ("SA Western Standard Time", "AG"), + "America/Araguaina": ("Tocantins Standard Time", "001"), + "America/Argentina/La_Rioja": ("Argentina Standard Time", "AR"), + "America/Argentina/Rio_Gallegos": ("Argentina Standard Time", "AR"), + "America/Argentina/Salta": ("Argentina Standard Time", "AR"), + "America/Argentina/San_Juan": ("Argentina Standard Time", "AR"), + "America/Argentina/San_Luis": ("Argentina Standard Time", "AR"), + "America/Argentina/Tucuman": ("Argentina Standard Time", "AR"), + "America/Argentina/Ushuaia": ("Argentina Standard Time", "AR"), + "America/Aruba": ("SA Western Standard Time", "AW"), + "America/Asuncion": ("Paraguay Standard Time", "001"), + "America/Bahia": ("Bahia Standard Time", "001"), + "America/Bahia_Banderas": ("Central Standard Time (Mexico)", "MX"), + "America/Barbados": ("SA Western Standard Time", "BB"), + "America/Belem": ("SA Eastern Standard Time", "BR"), + "America/Belize": ("Central America Standard Time", "BZ"), + "America/Blanc-Sablon": ("SA Western Standard Time", "CA"), + "America/Boa_Vista": ("SA Western Standard Time", "BR"), + "America/Bogota": ("SA Pacific Standard Time", "001"), + "America/Boise": ("Mountain Standard Time", "US"), + "America/Buenos_Aires": ("Argentina Standard Time", "001"), + "America/Cambridge_Bay": ("Mountain Standard Time", "CA"), + "America/Campo_Grande": ("Central Brazilian Standard Time", "BR"), + "America/Cancun": ("Eastern Standard Time (Mexico)", "001"), + "America/Caracas": ("Venezuela Standard Time", "001"), + "America/Catamarca": ("Argentina Standard Time", "AR"), + "America/Cayenne": ("SA Eastern Standard Time", "001"), + "America/Cayman": ("SA Pacific Standard Time", "KY"), + "America/Chicago": ("Central Standard Time", "001"), + "America/Chihuahua": ("Mountain Standard Time (Mexico)", "001"), + "America/Coral_Harbour": ("SA Pacific Standard Time", "CA"), + "America/Cordoba": ("Argentina Standard Time", "AR"), + "America/Costa_Rica": ("Central America Standard Time", "CR"), + "America/Creston": ("US Mountain Standard Time", "CA"), + "America/Cuiaba": ("Central Brazilian Standard Time", "001"), + "America/Curacao": ("SA Western Standard Time", "CW"), + "America/Danmarkshavn": ("Greenwich Standard Time", "GL"), + "America/Dawson": ("Yukon Standard Time", "CA"), + "America/Dawson_Creek": ("US Mountain Standard Time", "CA"), + "America/Denver": ("Mountain Standard Time", "001"), + "America/Detroit": ("Eastern Standard Time", "US"), + "America/Dominica": ("SA Western Standard Time", "DM"), + "America/Edmonton": ("Mountain Standard Time", "CA"), + "America/Eirunepe": ("SA Pacific Standard Time", "BR"), + "America/El_Salvador": ("Central America Standard Time", "SV"), + "America/Fort_Nelson": ("US Mountain Standard Time", "CA"), + "America/Fortaleza": ("SA Eastern Standard Time", "BR"), + "America/Glace_Bay": ("Atlantic Standard Time", "CA"), + "America/Godthab": ("Greenland Standard Time", "001"), + "America/Goose_Bay": ("Atlantic Standard Time", "CA"), + "America/Grand_Turk": ("Turks And Caicos Standard Time", "001"), + "America/Grenada": ("SA Western Standard Time", "GD"), + "America/Guadeloupe": ("SA Western Standard Time", "GP"), + "America/Guatemala": ("Central America Standard Time", "001"), + "America/Guayaquil": ("SA Pacific Standard Time", "EC"), + "America/Guyana": ("SA Western Standard Time", "GY"), + "America/Halifax": ("Atlantic Standard Time", "001"), + "America/Havana": ("Cuba Standard Time", "001"), + "America/Hermosillo": ("US Mountain Standard Time", "MX"), + "America/Indiana/Knox": ("Central Standard Time", "US"), + "America/Indiana/Marengo": ("US Eastern Standard Time", "US"), + "America/Indiana/Petersburg": ("Eastern Standard Time", "US"), + "America/Indiana/Tell_City": ("Central Standard Time", "US"), + "America/Indiana/Vevay": ("US Eastern Standard Time", "US"), + "America/Indiana/Vincennes": ("Eastern Standard Time", "US"), + "America/Indiana/Winamac": ("Eastern Standard Time", "US"), + "America/Indianapolis": ("US Eastern Standard Time", "001"), + "America/Inuvik": ("Mountain Standard Time", "CA"), + "America/Iqaluit": ("Eastern Standard Time", "CA"), + "America/Jamaica": ("SA Pacific Standard Time", "JM"), + "America/Jujuy": ("Argentina Standard Time", "AR"), + "America/Juneau": ("Alaskan Standard Time", "US"), + "America/Kentucky/Monticello": ("Eastern Standard Time", "US"), + "America/Kralendijk": ("SA Western Standard Time", "BQ"), + "America/La_Paz": ("SA Western Standard Time", "001"), + "America/Lima": ("SA Pacific Standard Time", "PE"), + "America/Los_Angeles": ("Pacific Standard Time", "001"), + "America/Louisville": ("Eastern Standard Time", "US"), + "America/Lower_Princes": ("SA Western Standard Time", "SX"), + "America/Maceio": ("SA Eastern Standard Time", "BR"), + "America/Managua": ("Central America Standard Time", "NI"), + "America/Manaus": ("SA Western Standard Time", "BR"), + "America/Marigot": ("SA Western Standard Time", "MF"), + "America/Martinique": ("SA Western Standard Time", "MQ"), + "America/Matamoros": ("Central Standard Time", "MX"), + "America/Mazatlan": ("Mountain Standard Time (Mexico)", "MX"), + "America/Mendoza": ("Argentina Standard Time", "AR"), + "America/Menominee": ("Central Standard Time", "US"), + "America/Merida": ("Central Standard Time (Mexico)", "MX"), + "America/Metlakatla": ("Alaskan Standard Time", "US"), + "America/Mexico_City": ("Central Standard Time (Mexico)", "001"), + "America/Miquelon": ("Saint Pierre Standard Time", "001"), + "America/Moncton": ("Atlantic Standard Time", "CA"), + "America/Monterrey": ("Central Standard Time (Mexico)", "MX"), + "America/Montevideo": ("Montevideo Standard Time", "001"), + "America/Montreal": ("Eastern Standard Time", "CA"), + "America/Montserrat": ("SA Western Standard Time", "MS"), + "America/Nassau": ("Eastern Standard Time", "BS"), + "America/New_York": ("Eastern Standard Time", "001"), + "America/Nipigon": ("Eastern Standard Time", "CA"), + "America/Nome": ("Alaskan Standard Time", "US"), + "America/Noronha": ("UTC-02", "BR"), + "America/North_Dakota/Beulah": ("Central Standard Time", "US"), + "America/North_Dakota/Center": ("Central Standard Time", "US"), + "America/North_Dakota/New_Salem": ("Central Standard Time", "US"), + "America/Ojinaga": ("Mountain Standard Time", "MX"), + "America/Panama": ("SA Pacific Standard Time", "PA"), + "America/Pangnirtung": ("Eastern Standard Time", "CA"), + "America/Paramaribo": ("SA Eastern Standard Time", "SR"), + "America/Phoenix": ("US Mountain Standard Time", "001"), + "America/Port-au-Prince": ("Haiti Standard Time", "001"), + "America/Port_of_Spain": ("SA Western Standard Time", "TT"), + "America/Porto_Velho": ("SA Western Standard Time", "BR"), + "America/Puerto_Rico": ("SA Western Standard Time", "PR"), + "America/Punta_Arenas": ("Magallanes Standard Time", "001"), + "America/Rainy_River": ("Central Standard Time", "CA"), + "America/Rankin_Inlet": ("Central Standard Time", "CA"), + "America/Recife": ("SA Eastern Standard Time", "BR"), + "America/Regina": ("Canada Central Standard Time", "001"), + "America/Resolute": ("Central Standard Time", "CA"), + "America/Rio_Branco": ("SA Pacific Standard Time", "BR"), + "America/Santa_Isabel": ("Pacific Standard Time (Mexico)", "MX"), + "America/Santarem": ("SA Eastern Standard Time", "BR"), + "America/Santiago": ("Pacific SA Standard Time", "001"), + "America/Santo_Domingo": ("SA Western Standard Time", "DO"), + "America/Sao_Paulo": ("E. South America Standard Time", "001"), + "America/Scoresbysund": ("Azores Standard Time", "GL"), + "America/Sitka": ("Alaskan Standard Time", "US"), + "America/St_Barthelemy": ("SA Western Standard Time", "BL"), + "America/St_Johns": ("Newfoundland Standard Time", "001"), + "America/St_Kitts": ("SA Western Standard Time", "KN"), + "America/St_Lucia": ("SA Western Standard Time", "LC"), + "America/St_Thomas": ("SA Western Standard Time", "VI"), + "America/St_Vincent": ("SA Western Standard Time", "VC"), + "America/Swift_Current": ("Canada Central Standard Time", "CA"), + "America/Tegucigalpa": ("Central America Standard Time", "HN"), + "America/Thule": ("Atlantic Standard Time", "GL"), + "America/Thunder_Bay": ("Eastern Standard Time", "CA"), + "America/Tijuana": ("Pacific Standard Time (Mexico)", "001"), + "America/Toronto": ("Eastern Standard Time", "CA"), + "America/Tortola": ("SA Western Standard Time", "VG"), + "America/Vancouver": ("Pacific Standard Time", "CA"), + "America/Whitehorse": ("Yukon Standard Time", "001"), + "America/Winnipeg": ("Central Standard Time", "CA"), + "America/Yakutat": ("Alaskan Standard Time", "US"), + "America/Yellowknife": ("Mountain Standard Time", "CA"), + "Antarctica/Casey": ("Central Pacific Standard Time", "AQ"), + "Antarctica/Davis": ("SE Asia Standard Time", "AQ"), + "Antarctica/DumontDUrville": ("West Pacific Standard Time", "AQ"), + "Antarctica/Macquarie": ("Tasmania Standard Time", "AU"), + "Antarctica/Mawson": ("West Asia Standard Time", "AQ"), + "Antarctica/McMurdo": ("New Zealand Standard Time", "AQ"), + "Antarctica/Palmer": ("SA Eastern Standard Time", "AQ"), + "Antarctica/Rothera": ("SA Eastern Standard Time", "AQ"), + "Antarctica/Syowa": ("E. Africa Standard Time", "AQ"), + "Antarctica/Vostok": ("Central Asia Standard Time", "AQ"), + "Arctic/Longyearbyen": ("W. Europe Standard Time", "SJ"), + "Asia/Aden": ("Arab Standard Time", "YE"), + "Asia/Almaty": ("Central Asia Standard Time", "001"), + "Asia/Amman": ("Jordan Standard Time", "001"), + "Asia/Anadyr": ("Russia Time Zone 11", "RU"), + "Asia/Aqtau": ("West Asia Standard Time", "KZ"), + "Asia/Aqtobe": ("West Asia Standard Time", "KZ"), + "Asia/Ashgabat": ("West Asia Standard Time", "TM"), + "Asia/Atyrau": ("West Asia Standard Time", "KZ"), + "Asia/Baghdad": ("Arabic Standard Time", "001"), + "Asia/Bahrain": ("Arab Standard Time", "BH"), + "Asia/Baku": ("Azerbaijan Standard Time", "001"), + "Asia/Bangkok": ("SE Asia Standard Time", "001"), + "Asia/Barnaul": ("Altai Standard Time", "001"), + "Asia/Beirut": ("Middle East Standard Time", "001"), + "Asia/Bishkek": ("Central Asia Standard Time", "KG"), + "Asia/Brunei": ("Singapore Standard Time", "BN"), + "Asia/Calcutta": ("India Standard Time", "001"), + "Asia/Chita": ("Transbaikal Standard Time", "001"), + "Asia/Choibalsan": ("Ulaanbaatar Standard Time", "MN"), + "Asia/Colombo": ("Sri Lanka Standard Time", "001"), + "Asia/Damascus": ("Syria Standard Time", "001"), + "Asia/Dhaka": ("Bangladesh Standard Time", "001"), + "Asia/Dili": ("Tokyo Standard Time", "TL"), + "Asia/Dubai": ("Arabian Standard Time", "001"), + "Asia/Dushanbe": ("West Asia Standard Time", "TJ"), + "Asia/Famagusta": ("GTB Standard Time", "CY"), + "Asia/Gaza": ("West Bank Standard Time", "PS"), + "Asia/Hebron": ("West Bank Standard Time", "001"), + "Asia/Hong_Kong": ("China Standard Time", "HK"), + "Asia/Hovd": ("W. Mongolia Standard Time", "001"), + "Asia/Irkutsk": ("North Asia East Standard Time", "001"), + "Asia/Jakarta": ("SE Asia Standard Time", "ID"), + "Asia/Jayapura": ("Tokyo Standard Time", "ID"), + "Asia/Jerusalem": ("Israel Standard Time", "001"), + "Asia/Kabul": ("Afghanistan Standard Time", "001"), + "Asia/Kamchatka": ("Russia Time Zone 11", "001"), + "Asia/Karachi": ("Pakistan Standard Time", "001"), + "Asia/Katmandu": ("Nepal Standard Time", "001"), + "Asia/Khandyga": ("Yakutsk Standard Time", "RU"), + "Asia/Krasnoyarsk": ("North Asia Standard Time", "001"), + "Asia/Kuala_Lumpur": ("Singapore Standard Time", "MY"), + "Asia/Kuching": ("Singapore Standard Time", "MY"), + "Asia/Kuwait": ("Arab Standard Time", "KW"), + "Asia/Macau": ("China Standard Time", "MO"), + "Asia/Magadan": ("Magadan Standard Time", "001"), + "Asia/Makassar": ("Singapore Standard Time", "ID"), + "Asia/Manila": ("Singapore Standard Time", "PH"), + "Asia/Muscat": ("Arabian Standard Time", "OM"), + "Asia/Nicosia": ("GTB Standard Time", "CY"), + "Asia/Novokuznetsk": ("North Asia Standard Time", "RU"), + "Asia/Novosibirsk": ("N. Central Asia Standard Time", "001"), + "Asia/Omsk": ("Omsk Standard Time", "001"), + "Asia/Oral": ("West Asia Standard Time", "KZ"), + "Asia/Phnom_Penh": ("SE Asia Standard Time", "KH"), + "Asia/Pontianak": ("SE Asia Standard Time", "ID"), + "Asia/Pyongyang": ("North Korea Standard Time", "001"), + "Asia/Qatar": ("Arab Standard Time", "QA"), + "Asia/Qostanay": ("Central Asia Standard Time", "KZ"), + "Asia/Qyzylorda": ("Qyzylorda Standard Time", "001"), + "Asia/Rangoon": ("Myanmar Standard Time", "001"), + "Asia/Riyadh": ("Arab Standard Time", "001"), + "Asia/Saigon": ("SE Asia Standard Time", "VN"), + "Asia/Sakhalin": ("Sakhalin Standard Time", "001"), + "Asia/Samarkand": ("West Asia Standard Time", "UZ"), + "Asia/Seoul": ("Korea Standard Time", "001"), + "Asia/Shanghai": ("China Standard Time", "001"), + "Asia/Singapore": ("Singapore Standard Time", "001"), + "Asia/Srednekolymsk": ("Russia Time Zone 10", "001"), + "Asia/Taipei": ("Taipei Standard Time", "001"), + "Asia/Tashkent": ("West Asia Standard Time", "001"), + "Asia/Tbilisi": ("Georgian Standard Time", "001"), + "Asia/Tehran": ("Iran Standard Time", "001"), + "Asia/Thimphu": ("Bangladesh Standard Time", "BT"), + "Asia/Tokyo": ("Tokyo Standard Time", "001"), + "Asia/Tomsk": ("Tomsk Standard Time", "001"), + "Asia/Ulaanbaatar": ("Ulaanbaatar Standard Time", "001"), + "Asia/Urumqi": ("Central Asia Standard Time", "CN"), + "Asia/Ust-Nera": ("Vladivostok Standard Time", "RU"), + "Asia/Vientiane": ("SE Asia Standard Time", "LA"), + "Asia/Vladivostok": ("Vladivostok Standard Time", "001"), + "Asia/Yakutsk": ("Yakutsk Standard Time", "001"), + "Asia/Yekaterinburg": ("Ekaterinburg Standard Time", "001"), + "Asia/Yerevan": ("Caucasus Standard Time", "001"), + "Atlantic/Azores": ("Azores Standard Time", "001"), + "Atlantic/Bermuda": ("Atlantic Standard Time", "BM"), + "Atlantic/Canary": ("GMT Standard Time", "ES"), + "Atlantic/Cape_Verde": ("Cape Verde Standard Time", "001"), + "Atlantic/Faeroe": ("GMT Standard Time", "FO"), + "Atlantic/Madeira": ("GMT Standard Time", "PT"), + "Atlantic/Reykjavik": ("Greenwich Standard Time", "001"), + "Atlantic/South_Georgia": ("UTC-02", "GS"), + "Atlantic/St_Helena": ("Greenwich Standard Time", "SH"), + "Atlantic/Stanley": ("SA Eastern Standard Time", "FK"), + "Australia/Adelaide": ("Cen. Australia Standard Time", "001"), + "Australia/Brisbane": ("E. Australia Standard Time", "001"), + "Australia/Broken_Hill": ("Cen. Australia Standard Time", "AU"), + "Australia/Currie": ("Tasmania Standard Time", "AU"), + "Australia/Darwin": ("AUS Central Standard Time", "001"), + "Australia/Eucla": ("Aus Central W. Standard Time", "001"), + "Australia/Hobart": ("Tasmania Standard Time", "001"), + "Australia/Lindeman": ("E. Australia Standard Time", "AU"), + "Australia/Lord_Howe": ("Lord Howe Standard Time", "001"), + "Australia/Melbourne": ("AUS Eastern Standard Time", "AU"), + "Australia/Perth": ("W. Australia Standard Time", "001"), + "Australia/Sydney": ("AUS Eastern Standard Time", "001"), + "CST6CDT": ("Central Standard Time", "ZZ"), + "EST5EDT": ("Eastern Standard Time", "ZZ"), + "Etc/GMT": ("UTC", "ZZ"), + "Etc/GMT+1": ("Cape Verde Standard Time", "ZZ"), + "Etc/GMT+10": ("Hawaiian Standard Time", "ZZ"), + "Etc/GMT+11": ("UTC-11", "001"), + "Etc/GMT+12": ("Dateline Standard Time", "001"), + "Etc/GMT+2": ("UTC-02", "001"), + "Etc/GMT+3": ("SA Eastern Standard Time", "ZZ"), + "Etc/GMT+4": ("SA Western Standard Time", "ZZ"), + "Etc/GMT+5": ("SA Pacific Standard Time", "ZZ"), + "Etc/GMT+6": ("Central America Standard Time", "ZZ"), + "Etc/GMT+7": ("US Mountain Standard Time", "ZZ"), + "Etc/GMT+8": ("UTC-08", "001"), + "Etc/GMT+9": ("UTC-09", "001"), + "Etc/GMT-1": ("W. Central Africa Standard Time", "ZZ"), + "Etc/GMT-10": ("West Pacific Standard Time", "ZZ"), + "Etc/GMT-11": ("Central Pacific Standard Time", "ZZ"), + "Etc/GMT-12": ("UTC+12", "001"), + "Etc/GMT-13": ("UTC+13", "001"), + "Etc/GMT-14": ("Line Islands Standard Time", "ZZ"), + "Etc/GMT-2": ("South Africa Standard Time", "ZZ"), + "Etc/GMT-3": ("E. Africa Standard Time", "ZZ"), + "Etc/GMT-4": ("Arabian Standard Time", "ZZ"), + "Etc/GMT-5": ("West Asia Standard Time", "ZZ"), + "Etc/GMT-6": ("Central Asia Standard Time", "ZZ"), + "Etc/GMT-7": ("SE Asia Standard Time", "ZZ"), + "Etc/GMT-8": ("Singapore Standard Time", "ZZ"), + "Etc/GMT-9": ("Tokyo Standard Time", "ZZ"), + "Etc/UTC": ("UTC", "001"), + "Europe/Amsterdam": ("W. Europe Standard Time", "NL"), + "Europe/Andorra": ("W. Europe Standard Time", "AD"), + "Europe/Astrakhan": ("Astrakhan Standard Time", "001"), + "Europe/Athens": ("GTB Standard Time", "GR"), + "Europe/Belgrade": ("Central Europe Standard Time", "RS"), + "Europe/Berlin": ("W. Europe Standard Time", "001"), + "Europe/Bratislava": ("Central Europe Standard Time", "SK"), + "Europe/Brussels": ("Romance Standard Time", "BE"), + "Europe/Bucharest": ("GTB Standard Time", "001"), + "Europe/Budapest": ("Central Europe Standard Time", "001"), + "Europe/Busingen": ("W. Europe Standard Time", "DE"), + "Europe/Chisinau": ("E. Europe Standard Time", "001"), + "Europe/Copenhagen": ("Romance Standard Time", "DK"), + "Europe/Dublin": ("GMT Standard Time", "IE"), + "Europe/Gibraltar": ("W. Europe Standard Time", "GI"), + "Europe/Guernsey": ("GMT Standard Time", "GG"), + "Europe/Helsinki": ("FLE Standard Time", "FI"), + "Europe/Isle_of_Man": ("GMT Standard Time", "IM"), + "Europe/Istanbul": ("Turkey Standard Time", "001"), + "Europe/Jersey": ("GMT Standard Time", "JE"), + "Europe/Kaliningrad": ("Kaliningrad Standard Time", "001"), + "Europe/Kiev": ("FLE Standard Time", "001"), + "Europe/Kirov": ("Russian Standard Time", "RU"), + "Europe/Lisbon": ("GMT Standard Time", "PT"), + "Europe/Ljubljana": ("Central Europe Standard Time", "SI"), + "Europe/London": ("GMT Standard Time", "001"), + "Europe/Luxembourg": ("W. Europe Standard Time", "LU"), + "Europe/Madrid": ("Romance Standard Time", "ES"), + "Europe/Malta": ("W. Europe Standard Time", "MT"), + "Europe/Mariehamn": ("FLE Standard Time", "AX"), + "Europe/Minsk": ("Belarus Standard Time", "001"), + "Europe/Monaco": ("W. Europe Standard Time", "MC"), + "Europe/Moscow": ("Russian Standard Time", "001"), + "Europe/Oslo": ("W. Europe Standard Time", "NO"), + "Europe/Paris": ("Romance Standard Time", "001"), + "Europe/Podgorica": ("Central Europe Standard Time", "ME"), + "Europe/Prague": ("Central Europe Standard Time", "CZ"), + "Europe/Riga": ("FLE Standard Time", "LV"), + "Europe/Rome": ("W. Europe Standard Time", "IT"), + "Europe/Samara": ("Russia Time Zone 3", "001"), + "Europe/San_Marino": ("W. Europe Standard Time", "SM"), + "Europe/Sarajevo": ("Central European Standard Time", "BA"), + "Europe/Saratov": ("Saratov Standard Time", "001"), + "Europe/Simferopol": ("Russian Standard Time", "UA"), + "Europe/Skopje": ("Central European Standard Time", "MK"), + "Europe/Sofia": ("FLE Standard Time", "BG"), + "Europe/Stockholm": ("W. Europe Standard Time", "SE"), + "Europe/Tallinn": ("FLE Standard Time", "EE"), + "Europe/Tirane": ("Central Europe Standard Time", "AL"), + "Europe/Ulyanovsk": ("Astrakhan Standard Time", "RU"), + "Europe/Uzhgorod": ("FLE Standard Time", "UA"), + "Europe/Vaduz": ("W. Europe Standard Time", "LI"), + "Europe/Vatican": ("W. Europe Standard Time", "VA"), + "Europe/Vienna": ("W. Europe Standard Time", "AT"), + "Europe/Vilnius": ("FLE Standard Time", "LT"), + "Europe/Volgograd": ("Volgograd Standard Time", "001"), + "Europe/Warsaw": ("Central European Standard Time", "001"), + "Europe/Zagreb": ("Central European Standard Time", "HR"), + "Europe/Zaporozhye": ("FLE Standard Time", "UA"), + "Europe/Zurich": ("W. Europe Standard Time", "CH"), + "Indian/Antananarivo": ("E. Africa Standard Time", "MG"), + "Indian/Chagos": ("Central Asia Standard Time", "IO"), + "Indian/Christmas": ("SE Asia Standard Time", "CX"), + "Indian/Cocos": ("Myanmar Standard Time", "CC"), + "Indian/Comoro": ("E. Africa Standard Time", "KM"), + "Indian/Kerguelen": ("West Asia Standard Time", "TF"), + "Indian/Mahe": ("Mauritius Standard Time", "SC"), + "Indian/Maldives": ("West Asia Standard Time", "MV"), + "Indian/Mauritius": ("Mauritius Standard Time", "001"), + "Indian/Mayotte": ("E. Africa Standard Time", "YT"), + "Indian/Reunion": ("Mauritius Standard Time", "RE"), + "MST7MDT": ("Mountain Standard Time", "ZZ"), + "PST8PDT": ("Pacific Standard Time", "ZZ"), + "Pacific/Apia": ("Samoa Standard Time", "001"), + "Pacific/Auckland": ("New Zealand Standard Time", "001"), + "Pacific/Bougainville": ("Bougainville Standard Time", "001"), + "Pacific/Chatham": ("Chatham Islands Standard Time", "001"), + "Pacific/Easter": ("Easter Island Standard Time", "001"), + "Pacific/Efate": ("Central Pacific Standard Time", "VU"), + "Pacific/Enderbury": ("UTC+13", "KI"), + "Pacific/Fakaofo": ("UTC+13", "TK"), + "Pacific/Fiji": ("Fiji Standard Time", "001"), + "Pacific/Funafuti": ("UTC+12", "TV"), + "Pacific/Galapagos": ("Central America Standard Time", "EC"), + "Pacific/Gambier": ("UTC-09", "PF"), + "Pacific/Guadalcanal": ("Central Pacific Standard Time", "001"), + "Pacific/Guam": ("West Pacific Standard Time", "GU"), + "Pacific/Honolulu": ("Hawaiian Standard Time", "001"), + "Pacific/Johnston": ("Hawaiian Standard Time", "UM"), + "Pacific/Kiritimati": ("Line Islands Standard Time", "001"), + "Pacific/Kosrae": ("Central Pacific Standard Time", "FM"), + "Pacific/Kwajalein": ("UTC+12", "MH"), + "Pacific/Majuro": ("UTC+12", "MH"), + "Pacific/Marquesas": ("Marquesas Standard Time", "001"), + "Pacific/Midway": ("UTC-11", "UM"), + "Pacific/Nauru": ("UTC+12", "NR"), + "Pacific/Niue": ("UTC-11", "NU"), + "Pacific/Norfolk": ("Norfolk Standard Time", "001"), + "Pacific/Noumea": ("Central Pacific Standard Time", "NC"), + "Pacific/Pago_Pago": ("UTC-11", "AS"), + "Pacific/Palau": ("Tokyo Standard Time", "PW"), + "Pacific/Pitcairn": ("UTC-08", "PN"), + "Pacific/Ponape": ("Central Pacific Standard Time", "FM"), + "Pacific/Port_Moresby": ("West Pacific Standard Time", "001"), + "Pacific/Rarotonga": ("Hawaiian Standard Time", "CK"), + "Pacific/Saipan": ("West Pacific Standard Time", "MP"), + "Pacific/Tahiti": ("Hawaiian Standard Time", "PF"), + "Pacific/Tarawa": ("UTC+12", "KI"), + "Pacific/Tongatapu": ("Tonga Standard Time", "001"), + "Pacific/Truk": ("West Pacific Standard Time", "FM"), + "Pacific/Wake": ("UTC+12", "UM"), + "Pacific/Wallis": ("UTC+12", "WF"), } # Timezone names used by IANA but not mentioned in the CLDR. All of them have an alias in CLDR. This is essentially @@ -535,143 +535,143 @@

    Module exchangelib.winzone

    IANA_TO_MS_TIMEZONE_MAP = dict( CLDR_TO_MS_TIMEZONE_MAP, **{ - 'Africa/Asmara': CLDR_TO_MS_TIMEZONE_MAP['Africa/Nairobi'], - 'Africa/Timbuktu': CLDR_TO_MS_TIMEZONE_MAP['Africa/Abidjan'], - 'America/Argentina/Buenos_Aires': CLDR_TO_MS_TIMEZONE_MAP['America/Buenos_Aires'], - 'America/Argentina/Catamarca': CLDR_TO_MS_TIMEZONE_MAP['America/Catamarca'], - 'America/Argentina/ComodRivadavia': CLDR_TO_MS_TIMEZONE_MAP['America/Catamarca'], - 'America/Argentina/Cordoba': CLDR_TO_MS_TIMEZONE_MAP['America/Cordoba'], - 'America/Argentina/Jujuy': CLDR_TO_MS_TIMEZONE_MAP['America/Jujuy'], - 'America/Argentina/Mendoza': CLDR_TO_MS_TIMEZONE_MAP['America/Mendoza'], - 'America/Atikokan': CLDR_TO_MS_TIMEZONE_MAP['America/Coral_Harbour'], - 'America/Atka': CLDR_TO_MS_TIMEZONE_MAP['America/Adak'], - 'America/Ensenada': CLDR_TO_MS_TIMEZONE_MAP['America/Tijuana'], - 'America/Fort_Wayne': CLDR_TO_MS_TIMEZONE_MAP['America/Indianapolis'], - 'America/Indiana/Indianapolis': CLDR_TO_MS_TIMEZONE_MAP['America/Indianapolis'], - 'America/Kentucky/Louisville': CLDR_TO_MS_TIMEZONE_MAP['America/Louisville'], - 'America/Knox_IN': CLDR_TO_MS_TIMEZONE_MAP['America/Indiana/Knox'], - 'America/Nuuk': CLDR_TO_MS_TIMEZONE_MAP['America/Godthab'], - 'America/Porto_Acre': CLDR_TO_MS_TIMEZONE_MAP['America/Rio_Branco'], - 'America/Rosario': CLDR_TO_MS_TIMEZONE_MAP['America/Cordoba'], - 'America/Shiprock': CLDR_TO_MS_TIMEZONE_MAP['America/Denver'], - 'America/Virgin': CLDR_TO_MS_TIMEZONE_MAP['America/Port_of_Spain'], - 'Antarctica/South_Pole': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Auckland'], - 'Antarctica/Troll': CLDR_TO_MS_TIMEZONE_MAP['Europe/Oslo'], - 'Asia/Ashkhabad': CLDR_TO_MS_TIMEZONE_MAP['Asia/Ashgabat'], - 'Asia/Chongqing': CLDR_TO_MS_TIMEZONE_MAP['Asia/Shanghai'], - 'Asia/Chungking': CLDR_TO_MS_TIMEZONE_MAP['Asia/Shanghai'], - 'Asia/Dacca': CLDR_TO_MS_TIMEZONE_MAP['Asia/Dhaka'], - 'Asia/Harbin': CLDR_TO_MS_TIMEZONE_MAP['Asia/Shanghai'], - 'Asia/Ho_Chi_Minh': CLDR_TO_MS_TIMEZONE_MAP['Asia/Saigon'], - 'Asia/Istanbul': CLDR_TO_MS_TIMEZONE_MAP['Europe/Istanbul'], - 'Asia/Kashgar': CLDR_TO_MS_TIMEZONE_MAP['Asia/Urumqi'], - 'Asia/Kathmandu': CLDR_TO_MS_TIMEZONE_MAP['Asia/Katmandu'], - 'Asia/Kolkata': CLDR_TO_MS_TIMEZONE_MAP['Asia/Calcutta'], - 'Asia/Macao': CLDR_TO_MS_TIMEZONE_MAP['Asia/Macau'], - 'Asia/Tel_Aviv': CLDR_TO_MS_TIMEZONE_MAP['Asia/Jerusalem'], - 'Asia/Thimbu': CLDR_TO_MS_TIMEZONE_MAP['Asia/Thimphu'], - 'Asia/Ujung_Pandang': CLDR_TO_MS_TIMEZONE_MAP['Asia/Makassar'], - 'Asia/Ulan_Bator': CLDR_TO_MS_TIMEZONE_MAP['Asia/Ulaanbaatar'], - 'Asia/Yangon': CLDR_TO_MS_TIMEZONE_MAP['Asia/Rangoon'], - 'Atlantic/Faroe': CLDR_TO_MS_TIMEZONE_MAP['Atlantic/Faeroe'], - 'Atlantic/Jan_Mayen': CLDR_TO_MS_TIMEZONE_MAP['Europe/Oslo'], - 'Australia/ACT': CLDR_TO_MS_TIMEZONE_MAP['Australia/Sydney'], - 'Australia/Canberra': CLDR_TO_MS_TIMEZONE_MAP['Australia/Sydney'], - 'Australia/LHI': CLDR_TO_MS_TIMEZONE_MAP['Australia/Lord_Howe'], - 'Australia/NSW': CLDR_TO_MS_TIMEZONE_MAP['Australia/Sydney'], - 'Australia/North': CLDR_TO_MS_TIMEZONE_MAP['Australia/Darwin'], - 'Australia/Queensland': CLDR_TO_MS_TIMEZONE_MAP['Australia/Brisbane'], - 'Australia/South': CLDR_TO_MS_TIMEZONE_MAP['Australia/Adelaide'], - 'Australia/Tasmania': CLDR_TO_MS_TIMEZONE_MAP['Australia/Hobart'], - 'Australia/Victoria': CLDR_TO_MS_TIMEZONE_MAP['Australia/Melbourne'], - 'Australia/West': CLDR_TO_MS_TIMEZONE_MAP['Australia/Perth'], - 'Australia/Yancowinna': CLDR_TO_MS_TIMEZONE_MAP['Australia/Broken_Hill'], - 'Brazil/Acre': CLDR_TO_MS_TIMEZONE_MAP['America/Rio_Branco'], - 'Brazil/DeNoronha': CLDR_TO_MS_TIMEZONE_MAP['America/Noronha'], - 'Brazil/East': CLDR_TO_MS_TIMEZONE_MAP['America/Sao_Paulo'], - 'Brazil/West': CLDR_TO_MS_TIMEZONE_MAP['America/Manaus'], - 'CET': CLDR_TO_MS_TIMEZONE_MAP['Europe/Paris'], - 'Canada/Atlantic': CLDR_TO_MS_TIMEZONE_MAP['America/Halifax'], - 'Canada/Central': CLDR_TO_MS_TIMEZONE_MAP['America/Winnipeg'], - 'Canada/Eastern': CLDR_TO_MS_TIMEZONE_MAP['America/Toronto'], - 'Canada/Mountain': CLDR_TO_MS_TIMEZONE_MAP['America/Edmonton'], - 'Canada/Newfoundland': CLDR_TO_MS_TIMEZONE_MAP['America/St_Johns'], - 'Canada/Pacific': CLDR_TO_MS_TIMEZONE_MAP['America/Vancouver'], - 'Canada/Saskatchewan': CLDR_TO_MS_TIMEZONE_MAP['America/Regina'], - 'Canada/Yukon': CLDR_TO_MS_TIMEZONE_MAP['America/Whitehorse'], - 'Chile/Continental': CLDR_TO_MS_TIMEZONE_MAP['America/Santiago'], - 'Chile/EasterIsland': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Easter'], - 'Cuba': CLDR_TO_MS_TIMEZONE_MAP['America/Havana'], - 'EET': CLDR_TO_MS_TIMEZONE_MAP['Europe/Sofia'], - 'EST': CLDR_TO_MS_TIMEZONE_MAP['America/Cancun'], - 'Egypt': CLDR_TO_MS_TIMEZONE_MAP['Africa/Cairo'], - 'Eire': CLDR_TO_MS_TIMEZONE_MAP['Europe/Dublin'], - 'Etc/GMT+0': CLDR_TO_MS_TIMEZONE_MAP['Etc/GMT'], - 'Etc/GMT-0': CLDR_TO_MS_TIMEZONE_MAP['Etc/GMT'], - 'Etc/GMT0': CLDR_TO_MS_TIMEZONE_MAP['Etc/GMT'], - 'Etc/Greenwich': CLDR_TO_MS_TIMEZONE_MAP['Etc/GMT'], - 'Etc/UCT': CLDR_TO_MS_TIMEZONE_MAP['Etc/UTC'], - 'Etc/Universal': CLDR_TO_MS_TIMEZONE_MAP['Etc/UTC'], - 'Etc/Zulu': CLDR_TO_MS_TIMEZONE_MAP['Etc/UTC'], - 'Europe/Belfast': CLDR_TO_MS_TIMEZONE_MAP['Europe/London'], - 'Europe/Nicosia': CLDR_TO_MS_TIMEZONE_MAP['Asia/Nicosia'], - 'Europe/Tiraspol': CLDR_TO_MS_TIMEZONE_MAP['Europe/Chisinau'], - 'Factory': CLDR_TO_MS_TIMEZONE_MAP['Etc/UTC'], - 'GB': CLDR_TO_MS_TIMEZONE_MAP['Europe/London'], - 'GB-Eire': CLDR_TO_MS_TIMEZONE_MAP['Europe/London'], - 'GMT': CLDR_TO_MS_TIMEZONE_MAP['Etc/GMT'], - 'GMT+0': CLDR_TO_MS_TIMEZONE_MAP['Etc/GMT'], - 'GMT-0': CLDR_TO_MS_TIMEZONE_MAP['Etc/GMT'], - 'GMT0': CLDR_TO_MS_TIMEZONE_MAP['Etc/GMT'], - 'Greenwich': CLDR_TO_MS_TIMEZONE_MAP['Etc/GMT'], - 'HST': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Honolulu'], - 'Hongkong': CLDR_TO_MS_TIMEZONE_MAP['Asia/Hong_Kong'], - 'Iceland': CLDR_TO_MS_TIMEZONE_MAP['Atlantic/Reykjavik'], - 'Iran': CLDR_TO_MS_TIMEZONE_MAP['Asia/Tehran'], - 'Israel': CLDR_TO_MS_TIMEZONE_MAP['Asia/Jerusalem'], - 'Jamaica': CLDR_TO_MS_TIMEZONE_MAP['America/Jamaica'], - 'Japan': CLDR_TO_MS_TIMEZONE_MAP['Asia/Tokyo'], - 'Kwajalein': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Kwajalein'], - 'Libya': CLDR_TO_MS_TIMEZONE_MAP['Africa/Tripoli'], - 'MET': CLDR_TO_MS_TIMEZONE_MAP['Europe/Paris'], - 'MST': CLDR_TO_MS_TIMEZONE_MAP['America/Phoenix'], - 'Mexico/BajaNorte': CLDR_TO_MS_TIMEZONE_MAP['America/Tijuana'], - 'Mexico/BajaSur': CLDR_TO_MS_TIMEZONE_MAP['America/Mazatlan'], - 'Mexico/General': CLDR_TO_MS_TIMEZONE_MAP['America/Mexico_City'], - 'NZ': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Auckland'], - 'NZ-CHAT': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Chatham'], - 'Navajo': CLDR_TO_MS_TIMEZONE_MAP['America/Denver'], - 'PRC': CLDR_TO_MS_TIMEZONE_MAP['Asia/Shanghai'], - 'Pacific/Chuuk': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Truk'], - 'Pacific/Kanton': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Enderbury'], - 'Pacific/Pohnpei': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Ponape'], - 'Pacific/Samoa': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Pago_Pago'], - 'Pacific/Yap': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Truk'], - 'Poland': CLDR_TO_MS_TIMEZONE_MAP['Europe/Warsaw'], - 'Portugal': CLDR_TO_MS_TIMEZONE_MAP['Europe/Lisbon'], - 'ROC': CLDR_TO_MS_TIMEZONE_MAP['Asia/Taipei'], - 'ROK': CLDR_TO_MS_TIMEZONE_MAP['Asia/Seoul'], - 'Singapore': CLDR_TO_MS_TIMEZONE_MAP['Asia/Singapore'], - 'Turkey': CLDR_TO_MS_TIMEZONE_MAP['Europe/Istanbul'], - 'UCT': CLDR_TO_MS_TIMEZONE_MAP['Etc/UTC'], - 'US/Alaska': CLDR_TO_MS_TIMEZONE_MAP['America/Anchorage'], - 'US/Aleutian': CLDR_TO_MS_TIMEZONE_MAP['America/Adak'], - 'US/Arizona': CLDR_TO_MS_TIMEZONE_MAP['America/Phoenix'], - 'US/Central': CLDR_TO_MS_TIMEZONE_MAP['America/Chicago'], - 'US/East-Indiana': CLDR_TO_MS_TIMEZONE_MAP['America/Indianapolis'], - 'US/Eastern': CLDR_TO_MS_TIMEZONE_MAP['America/New_York'], - 'US/Hawaii': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Honolulu'], - 'US/Indiana-Starke': CLDR_TO_MS_TIMEZONE_MAP['America/Indiana/Knox'], - 'US/Michigan': CLDR_TO_MS_TIMEZONE_MAP['America/Detroit'], - 'US/Mountain': CLDR_TO_MS_TIMEZONE_MAP['America/Denver'], - 'US/Pacific': CLDR_TO_MS_TIMEZONE_MAP['America/Los_Angeles'], - 'US/Samoa': CLDR_TO_MS_TIMEZONE_MAP['Pacific/Pago_Pago'], - 'UTC': CLDR_TO_MS_TIMEZONE_MAP['Etc/UTC'], - 'Universal': CLDR_TO_MS_TIMEZONE_MAP['Etc/UTC'], - 'W-SU': CLDR_TO_MS_TIMEZONE_MAP['Europe/Moscow'], - 'WET': CLDR_TO_MS_TIMEZONE_MAP['Europe/Lisbon'], - 'Zulu': CLDR_TO_MS_TIMEZONE_MAP['Etc/UTC'], - } + "Africa/Asmara": CLDR_TO_MS_TIMEZONE_MAP["Africa/Nairobi"], + "Africa/Timbuktu": CLDR_TO_MS_TIMEZONE_MAP["Africa/Abidjan"], + "America/Argentina/Buenos_Aires": CLDR_TO_MS_TIMEZONE_MAP["America/Buenos_Aires"], + "America/Argentina/Catamarca": CLDR_TO_MS_TIMEZONE_MAP["America/Catamarca"], + "America/Argentina/ComodRivadavia": CLDR_TO_MS_TIMEZONE_MAP["America/Catamarca"], + "America/Argentina/Cordoba": CLDR_TO_MS_TIMEZONE_MAP["America/Cordoba"], + "America/Argentina/Jujuy": CLDR_TO_MS_TIMEZONE_MAP["America/Jujuy"], + "America/Argentina/Mendoza": CLDR_TO_MS_TIMEZONE_MAP["America/Mendoza"], + "America/Atikokan": CLDR_TO_MS_TIMEZONE_MAP["America/Coral_Harbour"], + "America/Atka": CLDR_TO_MS_TIMEZONE_MAP["America/Adak"], + "America/Ensenada": CLDR_TO_MS_TIMEZONE_MAP["America/Tijuana"], + "America/Fort_Wayne": CLDR_TO_MS_TIMEZONE_MAP["America/Indianapolis"], + "America/Indiana/Indianapolis": CLDR_TO_MS_TIMEZONE_MAP["America/Indianapolis"], + "America/Kentucky/Louisville": CLDR_TO_MS_TIMEZONE_MAP["America/Louisville"], + "America/Knox_IN": CLDR_TO_MS_TIMEZONE_MAP["America/Indiana/Knox"], + "America/Nuuk": CLDR_TO_MS_TIMEZONE_MAP["America/Godthab"], + "America/Porto_Acre": CLDR_TO_MS_TIMEZONE_MAP["America/Rio_Branco"], + "America/Rosario": CLDR_TO_MS_TIMEZONE_MAP["America/Cordoba"], + "America/Shiprock": CLDR_TO_MS_TIMEZONE_MAP["America/Denver"], + "America/Virgin": CLDR_TO_MS_TIMEZONE_MAP["America/Port_of_Spain"], + "Antarctica/South_Pole": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Auckland"], + "Antarctica/Troll": CLDR_TO_MS_TIMEZONE_MAP["Europe/Oslo"], + "Asia/Ashkhabad": CLDR_TO_MS_TIMEZONE_MAP["Asia/Ashgabat"], + "Asia/Chongqing": CLDR_TO_MS_TIMEZONE_MAP["Asia/Shanghai"], + "Asia/Chungking": CLDR_TO_MS_TIMEZONE_MAP["Asia/Shanghai"], + "Asia/Dacca": CLDR_TO_MS_TIMEZONE_MAP["Asia/Dhaka"], + "Asia/Harbin": CLDR_TO_MS_TIMEZONE_MAP["Asia/Shanghai"], + "Asia/Ho_Chi_Minh": CLDR_TO_MS_TIMEZONE_MAP["Asia/Saigon"], + "Asia/Istanbul": CLDR_TO_MS_TIMEZONE_MAP["Europe/Istanbul"], + "Asia/Kashgar": CLDR_TO_MS_TIMEZONE_MAP["Asia/Urumqi"], + "Asia/Kathmandu": CLDR_TO_MS_TIMEZONE_MAP["Asia/Katmandu"], + "Asia/Kolkata": CLDR_TO_MS_TIMEZONE_MAP["Asia/Calcutta"], + "Asia/Macao": CLDR_TO_MS_TIMEZONE_MAP["Asia/Macau"], + "Asia/Tel_Aviv": CLDR_TO_MS_TIMEZONE_MAP["Asia/Jerusalem"], + "Asia/Thimbu": CLDR_TO_MS_TIMEZONE_MAP["Asia/Thimphu"], + "Asia/Ujung_Pandang": CLDR_TO_MS_TIMEZONE_MAP["Asia/Makassar"], + "Asia/Ulan_Bator": CLDR_TO_MS_TIMEZONE_MAP["Asia/Ulaanbaatar"], + "Asia/Yangon": CLDR_TO_MS_TIMEZONE_MAP["Asia/Rangoon"], + "Atlantic/Faroe": CLDR_TO_MS_TIMEZONE_MAP["Atlantic/Faeroe"], + "Atlantic/Jan_Mayen": CLDR_TO_MS_TIMEZONE_MAP["Europe/Oslo"], + "Australia/ACT": CLDR_TO_MS_TIMEZONE_MAP["Australia/Sydney"], + "Australia/Canberra": CLDR_TO_MS_TIMEZONE_MAP["Australia/Sydney"], + "Australia/LHI": CLDR_TO_MS_TIMEZONE_MAP["Australia/Lord_Howe"], + "Australia/NSW": CLDR_TO_MS_TIMEZONE_MAP["Australia/Sydney"], + "Australia/North": CLDR_TO_MS_TIMEZONE_MAP["Australia/Darwin"], + "Australia/Queensland": CLDR_TO_MS_TIMEZONE_MAP["Australia/Brisbane"], + "Australia/South": CLDR_TO_MS_TIMEZONE_MAP["Australia/Adelaide"], + "Australia/Tasmania": CLDR_TO_MS_TIMEZONE_MAP["Australia/Hobart"], + "Australia/Victoria": CLDR_TO_MS_TIMEZONE_MAP["Australia/Melbourne"], + "Australia/West": CLDR_TO_MS_TIMEZONE_MAP["Australia/Perth"], + "Australia/Yancowinna": CLDR_TO_MS_TIMEZONE_MAP["Australia/Broken_Hill"], + "Brazil/Acre": CLDR_TO_MS_TIMEZONE_MAP["America/Rio_Branco"], + "Brazil/DeNoronha": CLDR_TO_MS_TIMEZONE_MAP["America/Noronha"], + "Brazil/East": CLDR_TO_MS_TIMEZONE_MAP["America/Sao_Paulo"], + "Brazil/West": CLDR_TO_MS_TIMEZONE_MAP["America/Manaus"], + "CET": CLDR_TO_MS_TIMEZONE_MAP["Europe/Paris"], + "Canada/Atlantic": CLDR_TO_MS_TIMEZONE_MAP["America/Halifax"], + "Canada/Central": CLDR_TO_MS_TIMEZONE_MAP["America/Winnipeg"], + "Canada/Eastern": CLDR_TO_MS_TIMEZONE_MAP["America/Toronto"], + "Canada/Mountain": CLDR_TO_MS_TIMEZONE_MAP["America/Edmonton"], + "Canada/Newfoundland": CLDR_TO_MS_TIMEZONE_MAP["America/St_Johns"], + "Canada/Pacific": CLDR_TO_MS_TIMEZONE_MAP["America/Vancouver"], + "Canada/Saskatchewan": CLDR_TO_MS_TIMEZONE_MAP["America/Regina"], + "Canada/Yukon": CLDR_TO_MS_TIMEZONE_MAP["America/Whitehorse"], + "Chile/Continental": CLDR_TO_MS_TIMEZONE_MAP["America/Santiago"], + "Chile/EasterIsland": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Easter"], + "Cuba": CLDR_TO_MS_TIMEZONE_MAP["America/Havana"], + "EET": CLDR_TO_MS_TIMEZONE_MAP["Europe/Sofia"], + "EST": CLDR_TO_MS_TIMEZONE_MAP["America/Cancun"], + "Egypt": CLDR_TO_MS_TIMEZONE_MAP["Africa/Cairo"], + "Eire": CLDR_TO_MS_TIMEZONE_MAP["Europe/Dublin"], + "Etc/GMT+0": CLDR_TO_MS_TIMEZONE_MAP["Etc/GMT"], + "Etc/GMT-0": CLDR_TO_MS_TIMEZONE_MAP["Etc/GMT"], + "Etc/GMT0": CLDR_TO_MS_TIMEZONE_MAP["Etc/GMT"], + "Etc/Greenwich": CLDR_TO_MS_TIMEZONE_MAP["Etc/GMT"], + "Etc/UCT": CLDR_TO_MS_TIMEZONE_MAP["Etc/UTC"], + "Etc/Universal": CLDR_TO_MS_TIMEZONE_MAP["Etc/UTC"], + "Etc/Zulu": CLDR_TO_MS_TIMEZONE_MAP["Etc/UTC"], + "Europe/Belfast": CLDR_TO_MS_TIMEZONE_MAP["Europe/London"], + "Europe/Nicosia": CLDR_TO_MS_TIMEZONE_MAP["Asia/Nicosia"], + "Europe/Tiraspol": CLDR_TO_MS_TIMEZONE_MAP["Europe/Chisinau"], + "Factory": CLDR_TO_MS_TIMEZONE_MAP["Etc/UTC"], + "GB": CLDR_TO_MS_TIMEZONE_MAP["Europe/London"], + "GB-Eire": CLDR_TO_MS_TIMEZONE_MAP["Europe/London"], + "GMT": CLDR_TO_MS_TIMEZONE_MAP["Etc/GMT"], + "GMT+0": CLDR_TO_MS_TIMEZONE_MAP["Etc/GMT"], + "GMT-0": CLDR_TO_MS_TIMEZONE_MAP["Etc/GMT"], + "GMT0": CLDR_TO_MS_TIMEZONE_MAP["Etc/GMT"], + "Greenwich": CLDR_TO_MS_TIMEZONE_MAP["Etc/GMT"], + "HST": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Honolulu"], + "Hongkong": CLDR_TO_MS_TIMEZONE_MAP["Asia/Hong_Kong"], + "Iceland": CLDR_TO_MS_TIMEZONE_MAP["Atlantic/Reykjavik"], + "Iran": CLDR_TO_MS_TIMEZONE_MAP["Asia/Tehran"], + "Israel": CLDR_TO_MS_TIMEZONE_MAP["Asia/Jerusalem"], + "Jamaica": CLDR_TO_MS_TIMEZONE_MAP["America/Jamaica"], + "Japan": CLDR_TO_MS_TIMEZONE_MAP["Asia/Tokyo"], + "Kwajalein": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Kwajalein"], + "Libya": CLDR_TO_MS_TIMEZONE_MAP["Africa/Tripoli"], + "MET": CLDR_TO_MS_TIMEZONE_MAP["Europe/Paris"], + "MST": CLDR_TO_MS_TIMEZONE_MAP["America/Phoenix"], + "Mexico/BajaNorte": CLDR_TO_MS_TIMEZONE_MAP["America/Tijuana"], + "Mexico/BajaSur": CLDR_TO_MS_TIMEZONE_MAP["America/Mazatlan"], + "Mexico/General": CLDR_TO_MS_TIMEZONE_MAP["America/Mexico_City"], + "NZ": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Auckland"], + "NZ-CHAT": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Chatham"], + "Navajo": CLDR_TO_MS_TIMEZONE_MAP["America/Denver"], + "PRC": CLDR_TO_MS_TIMEZONE_MAP["Asia/Shanghai"], + "Pacific/Chuuk": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Truk"], + "Pacific/Kanton": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Enderbury"], + "Pacific/Pohnpei": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Ponape"], + "Pacific/Samoa": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Pago_Pago"], + "Pacific/Yap": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Truk"], + "Poland": CLDR_TO_MS_TIMEZONE_MAP["Europe/Warsaw"], + "Portugal": CLDR_TO_MS_TIMEZONE_MAP["Europe/Lisbon"], + "ROC": CLDR_TO_MS_TIMEZONE_MAP["Asia/Taipei"], + "ROK": CLDR_TO_MS_TIMEZONE_MAP["Asia/Seoul"], + "Singapore": CLDR_TO_MS_TIMEZONE_MAP["Asia/Singapore"], + "Turkey": CLDR_TO_MS_TIMEZONE_MAP["Europe/Istanbul"], + "UCT": CLDR_TO_MS_TIMEZONE_MAP["Etc/UTC"], + "US/Alaska": CLDR_TO_MS_TIMEZONE_MAP["America/Anchorage"], + "US/Aleutian": CLDR_TO_MS_TIMEZONE_MAP["America/Adak"], + "US/Arizona": CLDR_TO_MS_TIMEZONE_MAP["America/Phoenix"], + "US/Central": CLDR_TO_MS_TIMEZONE_MAP["America/Chicago"], + "US/East-Indiana": CLDR_TO_MS_TIMEZONE_MAP["America/Indianapolis"], + "US/Eastern": CLDR_TO_MS_TIMEZONE_MAP["America/New_York"], + "US/Hawaii": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Honolulu"], + "US/Indiana-Starke": CLDR_TO_MS_TIMEZONE_MAP["America/Indiana/Knox"], + "US/Michigan": CLDR_TO_MS_TIMEZONE_MAP["America/Detroit"], + "US/Mountain": CLDR_TO_MS_TIMEZONE_MAP["America/Denver"], + "US/Pacific": CLDR_TO_MS_TIMEZONE_MAP["America/Los_Angeles"], + "US/Samoa": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Pago_Pago"], + "UTC": CLDR_TO_MS_TIMEZONE_MAP["Etc/UTC"], + "Universal": CLDR_TO_MS_TIMEZONE_MAP["Etc/UTC"], + "W-SU": CLDR_TO_MS_TIMEZONE_MAP["Europe/Moscow"], + "WET": CLDR_TO_MS_TIMEZONE_MAP["Europe/Lisbon"], + "Zulu": CLDR_TO_MS_TIMEZONE_MAP["Etc/UTC"], + }, ) # Reverse map from Microsoft timezone ID to IANA timezone name. Non-IANA timezone ID's can be added here. @@ -679,8 +679,8 @@

    Module exchangelib.winzone

    # Use the CLDR map because the IANA map contains deprecated aliases that not all systems support {v[0]: k for k, v in CLDR_TO_MS_TIMEZONE_MAP.items() if v[1] == DEFAULT_TERRITORY}, **{ - 'tzone://Microsoft/Utc': 'UTC', - } + "tzone://Microsoft/Utc": "UTC", + }, )
    @@ -711,17 +711,17 @@

    Functions

    """ r = requests.get(CLDR_WINZONE_URL, timeout=timeout) if r.status_code != 200: - raise ValueError(f'Unexpected response: {r}') + raise ValueError(f"Unexpected response: {r}") tz_map = {} - timezones_elem = to_xml(r.content).find('windowsZones').find('mapTimezones') - type_version = timezones_elem.get('typeVersion') - other_version = timezones_elem.get('otherVersion') - for e in timezones_elem.findall('mapZone'): - for location in re.split(r'\s+', e.get('type')): - if e.get('territory') == DEFAULT_TERRITORY or location not in tz_map: + timezones_elem = to_xml(r.content).find("windowsZones").find("mapTimezones") + type_version = timezones_elem.get("typeVersion") + other_version = timezones_elem.get("otherVersion") + for e in timezones_elem.findall("mapZone"): + for location in re.split(r"\s+", e.get("type")): + if e.get("territory") == DEFAULT_TERRITORY or location not in tz_map: # Prefer default territory. This is so MS_TIMEZONE_TO_IANA_MAP maps from MS timezone ID back to the # "preferred" region/location timezone name. - tz_map[location] = e.get('other'), e.get('territory') + tz_map[location] = e.get("other"), e.get("territory") return type_version, other_version, tz_map diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index e3726553..5e9f4b9a 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -28,7 +28,7 @@ from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "4.7.1" +__version__ = "4.7.2" __all__ = [ "__version__", diff --git a/setup.py b/setup.py index 49b46e61..23974b8f 100755 --- a/setup.py +++ b/setup.py @@ -60,7 +60,7 @@ def read(file_name): 'complete': ['requests_gssapi', 'requests_negotiate_sspi'], # Only for Win32 environments }, packages=find_packages(exclude=('tests', 'tests.*')), - tests_require=['flake8', 'psutil', 'python-dateutil', 'pytz', 'PyYAML', 'requests_mock'], + tests_require=['psutil', 'python-dateutil', 'pytz', 'PyYAML', 'requests_mock'], python_requires=">=3.7", test_suite='tests', zip_safe=False, From 204c5bf610d2ce1f8ef82776567a67cbca723a6f Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 30 Jan 2022 21:37:45 +0100 Subject: [PATCH 199/509] black formatting --- exchangelib/folders/known_folders.py | 2 +- tests/common.py | 2 +- tests/test_attachments.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/exchangelib/folders/known_folders.py b/exchangelib/folders/known_folders.py index 51222bf1..9764aaa9 100644 --- a/exchangelib/folders/known_folders.py +++ b/exchangelib/folders/known_folders.py @@ -102,7 +102,7 @@ class Outbox(Messages): "de_DE": ("Postausgang",), "en_US": ("Outbox",), "es_ES": ("Bandeja de salida",), - "fr_CA": (u"Boîte d'envoi",), + "fr_CA": ("Boîte d'envoi",), "nl_NL": ("Postvak UIT",), "ru_RU": ("Исходящие",), "sv_SE": ("Utkorgen",), diff --git a/tests/common.py b/tests/common.py index 2bcf4338..dee19ba9 100644 --- a/tests/common.py +++ b/tests/common.py @@ -321,7 +321,7 @@ def get_random_int(min_val=0, max_val=2147483647): def get_random_decimal(min_val=0, max_val=100): precision = 2 - val = get_random_int(min_val, max_val * 10 ** precision) / 10.0 ** precision + val = get_random_int(min_val, max_val * 10**precision) / 10.0**precision return Decimal(f"{val:.2f}") diff --git a/tests/test_attachments.py b/tests/test_attachments.py index 4f17112f..2d4f1cbf 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -215,7 +215,7 @@ def test_file_attachments(self): def test_streaming_file_attachments(self): item = self.get_test_item(folder=self.test_folder) - large_binary_file_content = get_random_string(2 ** 10).encode("utf-8") + large_binary_file_content = get_random_string(2**10).encode("utf-8") large_att = FileAttachment(name="my_large_file.txt", content=large_binary_file_content) item.attach(large_att) item.save() From 00b799923aa068b74d2c8d69cbfe2bc92e5a9fe6 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 2 Feb 2022 13:29:21 +0100 Subject: [PATCH 200/509] Allow OAuth2AuthorizationCodeCredentials with only access token, as promised in the docstring. Fixes #1042 --- exchangelib/credentials.py | 14 +++++++------- exchangelib/protocol.py | 3 +-- tests/test_credentials.py | 10 +++++++--- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/exchangelib/credentials.py b/exchangelib/credentials.py index 869fde88..bdc42141 100644 --- a/exchangelib/credentials.py +++ b/exchangelib/credentials.py @@ -108,21 +108,21 @@ class OAuth2Credentials(BaseCredentials): the associated auth code grant type for multi-tenant applications. """ - def __init__(self, client_id, client_secret, tenant_id=None, identity=None): + def __init__(self, client_id, client_secret, tenant_id=None, identity=None, access_token=None): """ :param client_id: ID of an authorized OAuth application, required for automatic token fetching and refreshing :param client_secret: Secret associated with the OAuth application :param tenant_id: Microsoft tenant ID of the account to access :param identity: An Identity object representing the account that these credentials are connected to. + :param access_token: Previously-obtained access token, as a dict or an oauthlib.oauth2.OAuth2Token """ super().__init__() self.client_id = client_id self.client_secret = client_secret self.tenant_id = tenant_id self.identity = identity - # When set, access_token is a dict (or an oauthlib.oauth2.OAuth2Token, which is also a dict) - self.access_token = None + self.access_token = access_token def refresh(self, session): # Creating a new session gets a new access token, so there's no work here to refresh the credentials. This @@ -174,8 +174,8 @@ class OAuth2AuthorizationCodeCredentials(OAuth2Credentials): several ways: * Given an authorization code, client ID, and client secret, fetch a token ourselves and refresh it as needed if supplied with a refresh token. - * Given an existing access token, refresh token, client ID, and client secret, use the access token until it - expires and then refresh it as needed. + * Given an existing access token, client ID, and client secret, use the access token until it expires and then + refresh it as needed. * Given only an existing access token, use it until it expires. This can be used to let the calling application refresh tokens itself by subclassing and implementing refresh(). @@ -184,7 +184,7 @@ class OAuth2AuthorizationCodeCredentials(OAuth2Credentials): tenant. """ - def __init__(self, authorization_code=None, access_token=None, **kwargs): + def __init__(self, authorization_code=None, access_token=None, client_id=None, client_secret=None, **kwargs): """ :param client_id: ID of an authorized OAuth application, required for automatic token fetching and refreshing @@ -196,7 +196,7 @@ def __init__(self, authorization_code=None, access_token=None, **kwargs): :param access_token: Previously-obtained access token. If a token exists and the application will handle refreshing by itself (or opts not to handle it), this parameter alone is sufficient. """ - super().__init__(**kwargs) + super().__init__(client_id=client_id, client_secret=client_secret, **kwargs) self.authorization_code = authorization_code if access_token is not None and not isinstance(access_token, dict): raise InvalidTypeError("access_token", access_token, OAuth2Token) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 84daa965..b8ec6097 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -309,10 +309,10 @@ def create_session(self): return session def create_oauth2_session(self): - has_token = False scope = ["https://outlook.office365.com/.default"] session_params = {} token_params = {} + has_token = self.credentials.access_token is not None if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials): # Ask for a refresh token @@ -326,7 +326,6 @@ def create_oauth2_session(self): token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token" # nosec client_params = {} - has_token = self.credentials.access_token is not None if has_token: session_params["token"] = self.credentials.access_token elif self.credentials.authorization_code is not None: diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 92e688fb..a3eed2d8 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -28,9 +28,13 @@ def test_pickle(self): for o in ( Identity("XXX", "YYY", "ZZZ", "WWW"), Credentials("XXX", "YYY"), - OAuth2Credentials("XXX", "YYY", "ZZZZ"), - OAuth2Credentials("XXX", "YYY", "ZZZZ", identity=Identity("AAA")), - OAuth2AuthorizationCodeCredentials(client_id="WWW", client_secret="XXX"), + OAuth2Credentials(client_id="XXX", client_secret="YYY", tenant_id="ZZZZ"), + OAuth2Credentials(client_id="XXX", client_secret="YYY", tenant_id="ZZZZ", identity=Identity("AAA")), + OAuth2AuthorizationCodeCredentials(client_id="WWW", client_secret="XXX", authorization_code="YYY"), + OAuth2AuthorizationCodeCredentials( + client_id="WWW", client_secret="XXX", access_token={"access_token": "ZZZ"} + ), + OAuth2AuthorizationCodeCredentials(access_token={"access_token": "ZZZ"}), OAuth2AuthorizationCodeCredentials( client_id="WWW", client_secret="XXX", From 34170e7e76f230fe9654806a0354d3135c1a9d8b Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 2 Feb 2022 13:45:03 +0100 Subject: [PATCH 201/509] Propagate timeout value to resolve() --- exchangelib/autodiscover/discovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exchangelib/autodiscover/discovery.py b/exchangelib/autodiscover/discovery.py index 2752b7a6..651e09a4 100644 --- a/exchangelib/autodiscover/discovery.py +++ b/exchangelib/autodiscover/discovery.py @@ -378,7 +378,7 @@ def _get_srv_records(self, hostname): log.debug("Attempting to get SRV records for %s", hostname) records = [] try: - answers = self.resolver.resolve(f"{hostname}.", "SRV") + answers = self.resolver.resolve(f"{hostname}.", "SRV", lifetime=self.DNS_RESOLVER_ATTRS.get("timeout")) except (dns.resolver.NoNameservers, dns.resolver.NoAnswer, dns.resolver.NXDOMAIN) as e: log.debug("DNS lookup failure: %s", e) return records From bd4e67d8cfe5fce4c22e65607b8dc7a7a8483b80 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 2 Feb 2022 15:38:47 +0100 Subject: [PATCH 202/509] Make calls to resolve() consistent. Fix mock versions. --- exchangelib/autodiscover/discovery.py | 16 ++++++++++++---- exchangelib/errors.py | 2 +- tests/test_autodiscover.py | 4 ++-- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/exchangelib/autodiscover/discovery.py b/exchangelib/autodiscover/discovery.py index 651e09a4..4c0960ee 100644 --- a/exchangelib/autodiscover/discovery.py +++ b/exchangelib/autodiscover/discovery.py @@ -26,6 +26,13 @@ log = logging.getLogger(__name__) +DNS_LOOKUP_ERRORS = ( + dns.name.EmptyLabel, + dns.resolver.NXDOMAIN, + dns.resolver.NoAnswer, + dns.resolver.NoNameservers, +) + def discover(email, credentials=None, auth_type=None, retry_policy=None): ad_response, protocol = Autodiscovery(email=email, credentials=credentials).discover() @@ -357,8 +364,9 @@ def _attempt_response(self, url): def _is_valid_hostname(self, hostname): log.debug("Checking if %s can be looked up in DNS", hostname) try: - self.resolver.resolve(hostname) - except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.name.EmptyLabel): + self.resolver.resolve(f"{hostname}.", "A", lifetime=self.DNS_RESOLVER_ATTRS.get("timeout")) + except DNS_LOOKUP_ERRORS as e: + log.debug("DNS A lookup failure: %s", e) return False return True @@ -379,8 +387,8 @@ def _get_srv_records(self, hostname): records = [] try: answers = self.resolver.resolve(f"{hostname}.", "SRV", lifetime=self.DNS_RESOLVER_ATTRS.get("timeout")) - except (dns.resolver.NoNameservers, dns.resolver.NoAnswer, dns.resolver.NXDOMAIN) as e: - log.debug("DNS lookup failure: %s", e) + except DNS_LOOKUP_ERRORS as e: + log.debug("DNS SRV lookup failure: %s", e) return records for rdata in answers: try: diff --git a/exchangelib/errors.py b/exchangelib/errors.py index 2336b6f8..ecfd74f8 100644 --- a/exchangelib/errors.py +++ b/exchangelib/errors.py @@ -1691,7 +1691,7 @@ class ErrorWrongServerVersionDelegate(ResponseMessageError): pass -# Microsoft recommends to cache the autodiscover data around 24 hours and perform autodiscover +# Microsoft recommends caching the autodiscover data around 24 hours and perform autodiscover # immediately following certain error responses from EWS. See more at # http://blogs.msdn.com/b/mstehle/archive/2010/11/09/ews-best-practices-use-autodiscover.aspx diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index c1e5559c..fe8389e9 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -542,7 +542,7 @@ def test_get_srv_records(self): class _Mock1: @staticmethod - def resolve(hostname, cat): + def resolve(*args, **kwargs): class A: @staticmethod def to_text(): @@ -560,7 +560,7 @@ def to_text(): class _Mock2: @staticmethod - def resolve(hostname, cat): + def resolve(*args, **kwargs): class A: @staticmethod def to_text(): From 6de5a833249cee867010c7edcf0c8015fb704dd9 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 1 Mar 2022 10:55:04 +0100 Subject: [PATCH 203/509] Print diff when formatting checks fail --- .github/workflows/python-package.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index a11d57c2..79afb4d5 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -77,8 +77,8 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - black --check exchangelib tests - isort --check exchangelib tests + black --check --diff exchangelib tests + isort --check --diff exchangelib tests unittest-parallel -j 4 --class-fixtures --coverage --coverage-source exchangelib coveralls --service=github From 8c0ea27d9051da58332c3a0bc21e9d1e5659db4d Mon Sep 17 00:00:00 2001 From: cygnus9 Date: Tue, 1 Mar 2022 11:44:38 +0100 Subject: [PATCH 204/509] Don't interpret Period identifiers. Fixes #1058 (#1059) Instead find the correct period by finding the first Standard Period referenced from the appropriate TransitionsGroup for the requested year. --- exchangelib/errors.py | 4 -- exchangelib/properties.py | 35 +++++------------ tests/test_protocol.py | 80 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 87 insertions(+), 32 deletions(-) diff --git a/exchangelib/errors.py b/exchangelib/errors.py index ecfd74f8..268fa8cc 100644 --- a/exchangelib/errors.py +++ b/exchangelib/errors.py @@ -120,10 +120,6 @@ class UnknownTimeZone(EWSError): pass -class TimezoneDefinitionInvalidForYear(EWSError): - pass - - class SessionPoolMinSizeReached(EWSError): pass diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 6d1d908b..81a966a9 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -7,7 +7,7 @@ from inspect import getmro from threading import Lock -from .errors import InvalidTypeError, TimezoneDefinitionInvalidForYear +from .errors import InvalidTypeError from .fields import ( WEEKDAY_NAMES, AssociatedCalendarItemIdField, @@ -1864,18 +1864,6 @@ class Period(EWSElement): name = CharField(field_uri="Name", is_attribute=True) bias = TimeDeltaField(field_uri="Bias", is_attribute=True) - def _split_id(self): - to_year, to_type = self.id.rsplit("/", 1)[1].split("-") - return int(to_year), to_type - - @property - def year(self): - return self._split_id()[0] - - @property - def type(self): - return self._split_id()[1] - @property def bias_in_minutes(self): return int(self.bias.total_seconds()) // 60 # Convert to minutes @@ -1906,18 +1894,15 @@ class TimeZoneDefinition(EWSElement): def from_xml(cls, elem, account): return super().from_xml(elem, account) - def _get_standard_period(self, for_year): - # Look through periods and pick a relevant period according to the 'for_year' value - valid_period = None - for period in sorted(self.periods, key=lambda p: (p.year, p.type)): - if period.year > for_year: - break - if period.type != "Standard": + def _get_standard_period(self, transitions_group): + # Find the first standard period referenced from transitions_group + standard_periods_map = {p.id: p for p in self.periods if p.name == "Standard"} + for transition in transitions_group.transitions: + try: + return standard_periods_map[transition.to] + except KeyError: continue - valid_period = period - if valid_period is None: - raise TimezoneDefinitionInvalidForYear(f"Year {for_year} not included in periods {self.periods}") - return valid_period + raise ValueError(f"No standard period matching transition reference {transition.to}") def _get_transitions_group(self, for_year): # Look through the transitions, and pick the relevant transition group according to the 'for_year' value @@ -1939,7 +1924,7 @@ def get_std_and_dst(self, for_year): if not 0 <= len(transitions_group.transitions) <= 2: raise ValueError(f"Expected 0-2 transitions in transitions group {transitions_group}") - standard_period = self._get_standard_period(for_year) + standard_period = self._get_standard_period(transitions_group) periods_map = {p.id: p for p in self.periods} standard_time, daylight_time = None, None if len(transitions_group.transitions) == 1: diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 774cb442..34742199 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -24,7 +24,6 @@ RateLimitError, SessionPoolMaxSizeReached, SessionPoolMinSizeReached, - TimezoneDefinitionInvalidForYear, TransportError, ) from exchangelib.items import SEARCH_SCOPE_CHOICES, CalendarItem @@ -33,14 +32,17 @@ ID_FORMATS, AlternateId, DLMailbox, + DaylightTime, FailedMailbox, FreeBusyView, FreeBusyViewOptions, ItemId, Mailbox, MailboxData, + Period, RoomList, SearchableMailbox, + StandardTime, TimeZone, ) from exchangelib.protocol import BaseProtocol, FailFast, FaultTolerance, NoVerifyHTTPAdapter, Protocol @@ -49,6 +51,7 @@ GetRoomLists, GetRooms, GetSearchableMailboxes, + GetServerTimeZones, ResolveNames, SetUserOofSettings, ) @@ -262,9 +265,80 @@ def test_get_timezones(self): for_year=2018, ) self.assertEqual(tz.bias, tz_definition.get_std_and_dst(for_year=2018)[2].bias_in_minutes) - except TimezoneDefinitionInvalidForYear: + except ValueError: pass - + + def test_get_timezones_parsing(self): + # Test static XML since it's non-standard + xml = b"""\ + + +
    + +
    + + + + + NoError + + + + + + + + + + std + PT180M + 10 + Sunday + -1 + + + dlt + PT120M + 3 + Sunday + -1 + + + + + + 0 + + + + + + + + +
    """ + ws = GetServerTimeZones(self.account.protocol) + timezones = list(ws.parse(xml)) + self.assertEqual(1, len(timezones)) + (standard_transition, daylight_transition, standard_period) = timezones[0].get_std_and_dst(2022) + self.assertEqual( + standard_transition, + StandardTime(bias=0, time=datetime.time(hour=3), occurrence=5, iso_month=10, weekday=7), + ) + self.assertEqual( + daylight_transition, + DaylightTime(bias=-60, time=datetime.time(hour=2), occurrence=5, iso_month=3, weekday=7), + ) + self.assertEqual( + standard_period, + Period(id="std", name="Standard", bias=datetime.timedelta(minutes=-60)), + ) + def test_get_free_busy_info(self): tz = self.account.default_timezone server_timezones = list(self.account.protocol.get_timezones(return_full_timezone_data=True)) From 0afb4173b658d70ab212a0a0d913450992d355cf Mon Sep 17 00:00:00 2001 From: cygnus9 Date: Thu, 3 Mar 2022 08:42:37 +0100 Subject: [PATCH 205/509] Fix formatting issue (#1062) * Fix formatting issue * Fix import sort order, addendum for #1062 --- tests/test_protocol.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 34742199..23eda5d9 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -31,8 +31,8 @@ EWS_ID, ID_FORMATS, AlternateId, - DLMailbox, DaylightTime, + DLMailbox, FailedMailbox, FreeBusyView, FreeBusyViewOptions, @@ -267,7 +267,7 @@ def test_get_timezones(self): self.assertEqual(tz.bias, tz_definition.get_std_and_dst(for_year=2018)[2].bias_in_minutes) except ValueError: pass - + def test_get_timezones_parsing(self): # Test static XML since it's non-standard xml = b"""\ @@ -338,7 +338,7 @@ def test_get_timezones_parsing(self): standard_period, Period(id="std", name="Standard", bias=datetime.timedelta(minutes=-60)), ) - + def test_get_free_busy_info(self): tz = self.account.default_timezone server_timezones = list(self.account.protocol.get_timezones(return_full_timezone_data=True)) From e3a850183aac150645c4cd0255f7cbc963afd0fb Mon Sep 17 00:00:00 2001 From: cygnus9 Date: Sun, 6 Mar 2022 16:03:37 +0100 Subject: [PATCH 206/509] Handle unknown addresses in GetUserAvailability (#1064) * Handle unknown addresses in GetUserAvailability Instead of raising an exception, making the remainder of the generator useless, return the exception. This way the caller must decide what to do. But at least all remaining responses are still accessible. --- exchangelib/services/common.py | 2 ++ exchangelib/services/get_user_availability.py | 8 ++++--- tests/test_protocol.py | 23 +++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index ac65f739..99f07c3c 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -40,6 +40,7 @@ ErrorItemSave, ErrorMailboxMoveInProgress, ErrorMailboxStoreUnavailable, + ErrorMailRecipientNotFound, ErrorMessageSizeExceeded, ErrorMimeContentConversionFailed, ErrorNameResolutionMultipleResults, @@ -158,6 +159,7 @@ class EWSService(metaclass=abc.ABCMeta): ErrorRecurrenceHasNoOccurrence, ErrorCorruptData, ErrorItemCorrupt, + ErrorMailRecipientNotFound, ) # Similarly, define the warnings we want to return unraised WARNINGS_TO_CATCH_IN_RESPONSE = ErrorBatchProcessingStopped diff --git a/exchangelib/services/get_user_availability.py b/exchangelib/services/get_user_availability.py index e6f20ad1..83bf617e 100644 --- a/exchangelib/services/get_user_availability.py +++ b/exchangelib/services/get_user_availability.py @@ -43,9 +43,11 @@ def _response_message_tag(cls): def _get_elements_in_response(self, response): for msg in response: - # Just check the response code and raise errors - self._get_element_container(message=msg.find(f"{{{MNS}}}ResponseMessage")) - yield from self._get_elements_in_container(container=msg) + container_or_exc = self._get_element_container(message=msg.find(f"{{{MNS}}}ResponseMessage")) + if isinstance(container_or_exc, Exception): + yield container_or_exc + else: + yield from self._get_elements_in_container(container=msg) @classmethod def _get_elements_in_container(cls, container): diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 23eda5d9..649cf8bc 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -20,6 +20,7 @@ from exchangelib.credentials import Credentials, OAuth2AuthorizationCodeCredentials, OAuth2Credentials from exchangelib.errors import ( ErrorAccessDenied, + ErrorMailRecipientNotFound, ErrorNameResolutionNoResults, RateLimitError, SessionPoolMaxSizeReached, @@ -390,6 +391,28 @@ def test_get_free_busy_info(self): ): self.assertIsInstance(view_info, FreeBusyView) + # Test non-existing address + for view_info in self.account.protocol.get_free_busy_info( + accounts=[(f"unlikely-to-exist-{self.account.primary_smtp_address}", "Organizer", False)], + start=start, + end=end, + ): + self.assertIsInstance(view_info, ErrorMailRecipientNotFound) + + # Test non-existing and existing address + view_infos = list( + self.account.protocol.get_free_busy_info( + accounts=[ + (f"unlikely-to-exist-{self.account.primary_smtp_address}", "Organizer", False), + (self.account.primary_smtp_address, "Organizer", False), + ], + start=start, + end=end, + ) + ) + self.assertIsInstance(view_infos[0], ErrorMailRecipientNotFound) + self.assertIsInstance(view_infos[1], FreeBusyView) + def test_get_roomlists(self): # The test server is not guaranteed to have any room lists which makes this test less useful ws = GetRoomLists(self.account.protocol) From ec7e496c4ff3212ac79d95827bee4b40bcc05e92 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 9 Mar 2022 11:06:54 +0100 Subject: [PATCH 207/509] Don't hardcode name of TOIS folder --- exchangelib/folders/known_folders.py | 2 ++ tests/test_folder.py | 30 ++++++++++++++++------------ 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/exchangelib/folders/known_folders.py b/exchangelib/folders/known_folders.py index 9764aaa9..33b3382f 100644 --- a/exchangelib/folders/known_folders.py +++ b/exchangelib/folders/known_folders.py @@ -267,6 +267,8 @@ class MsgFolderRoot(WellknownFolder): DISTINGUISHED_FOLDER_ID = "msgfolderroot" LOCALIZED_NAMES = { + None: ("Top of Information Store",), + "da_DK": ("Informationslagerets øverste niveau",), "zh_CN": ("信息存储顶部",), } diff --git a/tests/test_folder.py b/tests/test_folder.py index 647bab46..2045b10f 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -217,30 +217,31 @@ def test_find_folders_compat(self): def test_find_folders_with_restriction(self): # Exact match + tois_folder_name = self.account.root.tois.name folders = list( FolderCollection(account=self.account, folders=[self.account.root]).find_folders( - q=Q(name="Top of Information Store") + q=Q(name=tois_folder_name) ) ) self.assertEqual(len(folders), 1, sorted(f.name for f in folders)) # Startswith folders = list( FolderCollection(account=self.account, folders=[self.account.root]).find_folders( - q=Q(name__startswith="Top of ") + q=Q(name__startswith=tois_folder_name[:6]) ) ) self.assertEqual(len(folders), 1, sorted(f.name for f in folders)) # Wrong case folders = list( FolderCollection(account=self.account, folders=[self.account.root]).find_folders( - q=Q(name__startswith="top of ") + q=Q(name__startswith=tois_folder_name[:6].lower()) ) ) self.assertEqual(len(folders), 0, sorted(f.name for f in folders)) # Case insensitive folders = list( FolderCollection(account=self.account, folders=[self.account.root]).find_folders( - q=Q(name__istartswith="top of ") + q=Q(name__istartswith=tois_folder_name[:6].lower()) ) ) self.assertEqual(len(folders), 1, sorted(f.name for f in folders)) @@ -443,7 +444,7 @@ def test_refresh(self): folder.refresh() # Must have an id def test_parent(self): - self.assertEqual(self.account.calendar.parent.name, "Top of Information Store") + self.assertEqual(self.account.calendar.parent.name, self.account.root.tois.name) self.assertEqual(self.account.calendar.parent.parent.name, "root") # Setters parent = self.account.calendar.parent @@ -459,16 +460,19 @@ def test_parent(self): self.assertIsNone(Folder(id=self.account.inbox.id, parent=self.account.inbox).parent) def test_children(self): - self.assertIn("Top of Information Store", [c.name for c in self.account.root.children]) + self.assertIn(self.account.root.tois.name, [c.name for c in self.account.root.children]) def test_parts(self): self.assertEqual( [p.name for p in self.account.calendar.parts], - ["root", "Top of Information Store", self.account.calendar.name], + ["root", self.account.root.tois.name, self.account.calendar.name], ) def test_absolute(self): - self.assertEqual(self.account.calendar.absolute, "/root/Top of Information Store/" + self.account.calendar.name) + self.assertEqual( + self.account.calendar.absolute, + f"/root/{self.account.root.tois.name}/{self.account.calendar.name}" + ) def test_walk(self): self.assertGreaterEqual(len(list(self.account.root.walk())), 20) @@ -484,7 +488,7 @@ def test_glob(self): self.assertGreaterEqual(len(list(self.account.contacts.glob("/"))), 5) self.assertGreaterEqual(len(list(self.account.contacts.glob("../*"))), 5) self.assertEqual(len(list(self.account.root.glob(f"**/{self.account.contacts.name}"))), 1) - self.assertEqual(len(list(self.account.root.glob(f"Top of*/{self.account.contacts.name}"))), 1) + self.assertEqual(len(list(self.account.root.glob(f"{self.account.root.tois.name[:6]}*/{self.account.contacts.name}"))), 1) with self.assertRaises(ValueError) as e: list(self.account.root.glob("../*")) self.assertEqual(e.exception.args[0], "Already at top") @@ -503,9 +507,9 @@ def test_empty_collections(self): def test_div_navigation(self): self.assertEqual( - (self.account.root / "Top of Information Store" / self.account.calendar.name).id, self.account.calendar.id + (self.account.root / self.account.root.tois.name / self.account.calendar.name).id, self.account.calendar.id ) - self.assertEqual((self.account.root / "Top of Information Store" / "..").id, self.account.root.id) + self.assertEqual((self.account.root / self.account.root.tois.name / "..").id, self.account.root.id) self.assertEqual((self.account.root / ".").id, self.account.root.id) with self.assertRaises(ValueError) as e: _ = self.account.root / ".." @@ -520,13 +524,13 @@ def test_double_div_navigation(self): # Test normal navigation self.assertEqual( - (self.account.root // "Top of Information Store" // self.account.calendar.name).id, self.account.calendar.id + (self.account.root // self.account.root.tois.name // self.account.calendar.name).id, self.account.calendar.id ) self.assertIsNone(self.account.root._subfolders) # Test parent ('..') syntax. Should not work with self.assertRaises(ValueError) as e: - _ = self.account.root // "Top of Information Store" // ".." + _ = self.account.root // self.account.root.tois.name // ".." self.assertEqual(e.exception.args[0], "Cannot get parent without a folder cache") self.assertIsNone(self.account.root._subfolders) From 563498165ee0c6f981c3d9791a8393c1a76bb64d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=A1=D1=83?= =?UTF-8?q?=D1=85=D0=B0=D0=BD=D0=BE=D0=B2?= <67975624+suhanoves@users.noreply.github.com> Date: Fri, 1 Apr 2022 11:05:08 +0300 Subject: [PATCH 208/509] Fix misprints (#1074) --- docs/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index cea233ca..92cf13f1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1557,7 +1557,7 @@ for occurrence in a.calendar.view( ): # Delete or update random occurrences. This will affect # 'modified_occurrences' and 'deleted_occurrences' of the master item. - if occurrence.start.milliseconds % 2: + if occurrence.start.microseconds % 2: # We receive timestamps as UTC but want to write back as local timezone occurrence.start = occurrence.start.astimezone(a.default_timezone) occurrence.start += datetime.timedelta(minutes=30) @@ -1578,7 +1578,7 @@ third_occurrence.start += datetime.timedelta(hours=3) third_occurrence.save(update_fields=['start']) # Similarly, you can reach the master recurrence from the occurrence -master = third_occurrence.master_recurrence() +master = third_occurrence.recurring_master() master.subject = 'An update' master.save(update_fields=['subject']) ``` From 1a7ae76fcd2a348ab1f028545ac271f0056ca10c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=A1=D1=83?= =?UTF-8?q?=D1=85=D0=B0=D0=BD=D0=BE=D0=B2?= <67975624+suhanoves@users.noreply.github.com> Date: Mon, 4 Apr 2022 15:50:27 +0300 Subject: [PATCH 209/509] Fix misprint (#1075) --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 92cf13f1..07bd0ee6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1557,7 +1557,7 @@ for occurrence in a.calendar.view( ): # Delete or update random occurrences. This will affect # 'modified_occurrences' and 'deleted_occurrences' of the master item. - if occurrence.start.microseconds % 2: + if occurrence.start.microsecond % 2: # We receive timestamps as UTC but want to write back as local timezone occurrence.start = occurrence.start.astimezone(a.default_timezone) occurrence.start += datetime.timedelta(minutes=30) From a01a1c5bd9d9b9ccabeff28b9118525cc21a0c0e Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 8 May 2022 00:03:58 +0200 Subject: [PATCH 210/509] Group fields together in validation --- exchangelib/configuration.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/exchangelib/configuration.py b/exchangelib/configuration.py index a1336ef3..b79745a2 100644 --- a/exchangelib/configuration.py +++ b/exchangelib/configuration.py @@ -61,12 +61,12 @@ def __init__( if auth_type is None: # Set a default auth type for the credentials where this makes sense auth_type = DEFAULT_AUTH_TYPE.get(type(credentials)) - elif credentials is None and auth_type in CREDENTIALS_REQUIRED: + if auth_type is not None and auth_type not in AUTH_TYPE_MAP: + raise InvalidEnumValue("auth_type", auth_type, AUTH_TYPE_MAP) + if credentials is None and auth_type in CREDENTIALS_REQUIRED: raise ValueError(f"Auth type {auth_type!r} was detected but no credentials were provided") if server and service_endpoint: raise AttributeError("Only one of 'server' or 'service_endpoint' must be provided") - if auth_type is not None and auth_type not in AUTH_TYPE_MAP: - raise InvalidEnumValue("auth_type", auth_type, AUTH_TYPE_MAP) if not retry_policy: retry_policy = FailFast() if not isinstance(version, (Version, type(None))): From 2aead05b8c28663c0e5e2eccd41acb761e07c320 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 8 May 2022 00:04:53 +0200 Subject: [PATCH 211/509] Support more folder types. Sort long lists --- exchangelib/folders/__init__.py | 54 ++++++++++++++++++---------- exchangelib/folders/base.py | 24 ++++++++++--- exchangelib/folders/known_folders.py | 51 ++++++++++++++++++++++++++ exchangelib/folders/roots.py | 3 +- 4 files changed, 107 insertions(+), 25 deletions(-) diff --git a/exchangelib/folders/__init__.py b/exchangelib/folders/__init__.py index 426f7e04..b0726036 100644 --- a/exchangelib/folders/__init__.py +++ b/exchangelib/folders/__init__.py @@ -6,6 +6,7 @@ AdminAuditLogs, AllContacts, AllItems, + ApplicationData, ArchiveDeletedItems, ArchiveInbox, ArchiveMsgFolderRoot, @@ -14,6 +15,7 @@ ArchiveRecoverableItemsRoot, ArchiveRecoverableItemsVersions, Audits, + Birthdays, Calendar, CalendarLogging, CommonViews, @@ -22,14 +24,17 @@ Contacts, ConversationHistory, ConversationSettings, + CrawlerData, DefaultFoldersChangeHistory, DeferredAction, DeletedItems, Directory, + DlpPolicyEvaluation, Drafts, ExchangeSyncData, Favorites, Files, + FreeBusyCache, FreebusyData, Friends, GALContacts, @@ -60,6 +65,7 @@ RecoverableItemsPurges, RecoverableItemsRoot, RecoverableItemsVersions, + RecoveryPoints, Reminders, RSSFeeds, Schedule, @@ -69,8 +75,10 @@ Sharing, Shortcuts, Signal, + SkypeTeamsMessages, SmsAndChatsSync, SpoolerQueue, + SwssItems, SyncIssues, System, Tasks, @@ -85,14 +93,10 @@ from .roots import ArchiveRoot, PublicFoldersRoot, Root, RootOfHierarchy __all__ = [ - "FolderId", - "DistinguishedFolderId", - "FolderCollection", - "BaseFolder", - "Folder", "AdminAuditLogs", "AllContacts", "AllItems", + "ApplicationData", "ArchiveDeletedItems", "ArchiveInbox", "ArchiveMsgFolderRoot", @@ -100,22 +104,36 @@ "ArchiveRecoverableItemsPurges", "ArchiveRecoverableItemsRoot", "ArchiveRecoverableItemsVersions", + "ArchiveRoot", "Audits", + "BaseFolder", + "Birthdays", "Calendar", "CalendarLogging", "CommonViews", + "Companies", "Conflicts", "Contacts", "ConversationHistory", "ConversationSettings", + "CrawlerData", + "DEEP", "DefaultFoldersChangeHistory", "DeferredAction", "DeletedItems", "Directory", + "DistinguishedFolderId", + "DlpPolicyEvaluation", "Drafts", "ExchangeSyncData", + "FOLDER_TRAVERSAL_CHOICES", "Favorites", "Files", + "Folder", + "FolderCollection", + "FolderId", + "FolderQuerySet", + "FreeBusyCache", "FreebusyData", "Friends", "GALContacts", @@ -131,13 +149,17 @@ "MsgFolderRoot", "MyContacts", "MyContactsExtended", + "NON_DELETABLE_FOLDERS", "NonDeletableFolderMixin", "Notes", + "OrganizationalContacts", "Outbox", "ParkedMessages", "PassThroughSearchResults", "PdpProfileV2Secured", + "PeopleCentricConversationBuddies", "PeopleConnect", + "PublicFoldersRoot", "QuickContacts", "RSSFeeds", "RecipientCache", @@ -145,7 +167,12 @@ "RecoverableItemsPurges", "RecoverableItemsRoot", "RecoverableItemsVersions", + "RecoveryPoints", "Reminders", + "Root", + "RootOfHierarchy", + "SHALLOW", + "SOFT_DELETED", "Schedule", "SearchFolders", "SentItems", @@ -153,8 +180,11 @@ "Sharing", "Shortcuts", "Signal", + "SingleFolderQuerySet", + "SkypeTeamsMessages", "SmsAndChatsSync", "SpoolerQueue", + "SwssItems", "SyncIssues", "System", "Tasks", @@ -164,18 +194,4 @@ "VoiceMail", "WellknownFolder", "WorkingSet", - "Companies", - "OrganizationalContacts", - "PeopleCentricConversationBuddies", - "NON_DELETABLE_FOLDERS", - "FolderQuerySet", - "SingleFolderQuerySet", - "FOLDER_TRAVERSAL_CHOICES", - "SHALLOW", - "DEEP", - "SOFT_DELETED", - "Root", - "ArchiveRoot", - "PublicFoldersRoot", - "RootOfHierarchy", ] diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 1300bc78..80badf9c 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -229,27 +229,41 @@ def folder_cls_from_container_class(container_class): :return: """ from .known_folders import ( + ApplicationData, Calendar, Contacts, ConversationSettings, + CrawlerData, + DlpPolicyEvaluation, + FreeBusyCache, GALContacts, Messages, RecipientCache, + RecoveryPoints, Reminders, RSSFeeds, + Signal, + SwssItems, Tasks, ) for folder_cls in ( - Messages, - Tasks, + ApplicationData, Calendar, - ConversationSettings, Contacts, + ConversationSettings, + CrawlerData, + DlpPolicyEvaluation, + FreeBusyCache, GALContacts, - Reminders, - RecipientCache, + Messages, RSSFeeds, + RecipientCache, + RecoveryPoints, + Reminders, + Signal, + SwssItems, + Tasks, ): if folder_cls.CONTAINER_CLASS == container_class: return folder_cls diff --git a/exchangelib/folders/known_folders.py b/exchangelib/folders/known_folders.py index 33b3382f..f58cfeeb 100644 --- a/exchangelib/folders/known_folders.py +++ b/exchangelib/folders/known_folders.py @@ -62,6 +62,45 @@ class Messages(Folder): supported_item_models = (Message, MeetingRequest, MeetingResponse, MeetingCancellation) +class ApplicationData(Folder): + CONTAINER_CLASS = "IPM.ApplicationData" + + +class CrawlerData(Folder): + CONTAINER_CLASS = "IPF.StoreItem.CrawlerData" + + +class DlpPolicyEvaluation(Folder): + CONTAINER_CLASS = "IPF.StoreItem.DlpPolicyEvaluation" + + +class FreeBusyCache(Folder): + CONTAINER_CLASS = "IPF.StoreItem.FreeBusyCache" + + +class RecoveryPoints(Folder): + CONTAINER_CLASS = "IPF.StoreItem.RecoveryPoints" + + +class SwssItems(Folder): + CONTAINER_CLASS = "IPF.StoreItem.SwssItems" + + +class SkypeTeamsMessages(Folder): + CONTAINER_CLASS = "IPF.SkypeTeams.Message" + LOCALIZED_NAMES = { + None: ("Team-chat",), + } + + +class Birthdays(Folder): + CONTAINER_CLASS = "IPF.Appointment.Birthday" + LOCALIZED_NAMES = { + None: ("Birthdays",), + "da_DK": ("Fødselsdage",), + } + + class Drafts(Messages): DISTINGUISHED_FOLDER_ID = "drafts" @@ -408,6 +447,7 @@ class Companies(NonDeletableFolderMixin, Contacts): CONTAINTER_CLASS = "IPF.Contact.Company" LOCALIZED_NAMES = { None: ("Companies",), + "da_DK": ("Firmaer",), } @@ -701,3 +741,14 @@ class WorkingSet(NonDeletableFolderMixin, Folder): ArchiveRecoverableItemsRoot, ArchiveRecoverableItemsVersions, ] + +MISC_FOLDERS = [ + ApplicationData, + CrawlerData, + DlpPolicyEvaluation, + FreeBusyCache, + RecoveryPoints, + SwssItems, + SkypeTeamsMessages, + Birthdays, +] diff --git a/exchangelib/folders/roots.py b/exchangelib/folders/roots.py index 2218be6b..0f8d70c8 100644 --- a/exchangelib/folders/roots.py +++ b/exchangelib/folders/roots.py @@ -8,6 +8,7 @@ from .base import BaseFolder from .collections import FolderCollection from .known_folders import ( + MISC_FOLDERS, NON_DELETABLE_FOLDERS, WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT, WELLKNOWN_FOLDERS_IN_ROOT, @@ -205,7 +206,7 @@ def folder_cls_from_folder_name(cls, folder_name, locale): :param folder_name: :param locale: a string, e.g. 'da_DK' """ - for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS: + for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS + MISC_FOLDERS: if folder_name.lower() in folder_cls.localized_names(locale): return folder_cls raise KeyError() From 28c974f8b7811b1fec1dfe4f245fbfb210f0124e Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 8 May 2022 00:05:39 +0200 Subject: [PATCH 212/509] Two small fixes for Exchange Online account --- exchangelib/items/message.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/exchangelib/items/message.py b/exchangelib/items/message.py index e31956a2..8bbc0407 100644 --- a/exchangelib/items/message.py +++ b/exchangelib/items/message.py @@ -37,7 +37,7 @@ class Message(Item): conversation_topic = CharField(field_uri="message:ConversationTopic", is_read_only=True) # Rename 'From' to 'author'. We can't use fieldname 'from' since it's a Python keyword. author = MailboxField(field_uri="message:From", is_read_only_after_send=True) - message_id = CharField(field_uri="message:InternetMessageId", is_read_only_after_send=True) + message_id = TextField(field_uri="message:InternetMessageId", is_read_only_after_send=True) is_read = BooleanField(field_uri="message:IsRead", is_required=True, default=False) is_response_requested = BooleanField(field_uri="message:IsResponseRequested", default=False, is_required=True) references = TextField(field_uri="message:References") @@ -169,7 +169,7 @@ def mark_as_junk(self, is_junk=True, move_item=True): from ..services import MarkAsJunk res = MarkAsJunk(account=self.account).get( - items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item + items=[self], is_junk=is_junk, move_item=move_item, expect_result=None ) if res is None: return From 244441316cd53e72c3ec15bf96ba5686f0f85851 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 8 May 2022 00:06:10 +0200 Subject: [PATCH 213/509] Use full line length --- exchangelib/protocol.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index b8ec6097..d9fcc685 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -318,9 +318,8 @@ def create_oauth2_session(self): # Ask for a refresh token scope.append("offline_access") - # We don't know (or need) the Microsoft tenant ID. Use - # common/ to let Microsoft select the appropriate tenant - # for the provided authorization code or refresh token. + # We don't know (or need) the Microsoft tenant ID. Use common/ to let Microsoft select the appropriate + # tenant for the provided authorization code or refresh token. # # Suppress looks-like-password warning from Bandit. token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token" # nosec @@ -333,13 +332,10 @@ def create_oauth2_session(self): self.credentials.authorization_code = None if self.credentials.client_id is not None and self.credentials.client_secret is not None: - # If we're given a client ID and secret, we have enough - # to refresh access tokens ourselves. In other cases the - # session will raise TokenExpiredError and we'll need to - # ask the calling application to refresh the token (that - # covers cases where the caller doesn't have access to - # the client secret but is working with a service that - # can provide it refreshed tokens on a limited basis). + # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other + # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to + # refresh the token (that covers cases where the caller doesn't have access to the client secret but + # is working with a service that can provide it refreshed tokens on a limited basis). session_params.update( { "auto_refresh_kwargs": { @@ -366,8 +362,8 @@ def create_oauth2_session(self): timeout=self.TIMEOUT, **token_params, ) - # Allow the credentials object to update its copy of the new - # token, and give the application an opportunity to cache it + # Allow the credentials object to update its copy of the new token, and give the application an opportunity + # to cache it. self.credentials.on_token_auto_refreshed(token) session.auth = get_auth_instance(auth_type=OAUTH2, client=client) From adc4a7cab316c8176e4d5cf3925d25858f29c794 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 8 May 2022 00:09:48 +0200 Subject: [PATCH 214/509] Also use existing token for OAuth2Credentials type credentials --- exchangelib/protocol.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index d9fcc685..800145d9 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -310,9 +310,8 @@ def create_session(self): def create_oauth2_session(self): scope = ["https://outlook.office365.com/.default"] - session_params = {} + session_params = {"token": self.credentials.access_token} # Token may be None token_params = {} - has_token = self.credentials.access_token is not None if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials): # Ask for a refresh token @@ -325,11 +324,8 @@ def create_oauth2_session(self): token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token" # nosec client_params = {} - if has_token: - session_params["token"] = self.credentials.access_token - elif self.credentials.authorization_code is not None: - token_params["code"] = self.credentials.authorization_code - self.credentials.authorization_code = None + token_params["code"] = self.credentials.authorization_code # Auth code may be None + self.credentials.authorization_code = None # We can only use the code once if self.credentials.client_id is not None and self.credentials.client_secret is not None: # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other @@ -352,7 +348,7 @@ def create_oauth2_session(self): client = BackendApplicationClient(client_id=self.credentials.client_id) session = self.raw_session(self.service_endpoint, oauth2_client=client, oauth2_session_params=session_params) - if not has_token: + if not session.token: # Fetch the token explicitly -- it doesn't occur implicitly token = session.fetch_token( token_url=token_url, From 4c03f3428ac799bf63e7fd07fee17100c22d5c8e Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 8 May 2022 00:10:54 +0200 Subject: [PATCH 215/509] Take logic frm parent method into account --- exchangelib/services/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 99f07c3c..e5d0c782 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -783,7 +783,7 @@ def _extra_headers(self, session): def _account_to_impersonate(self): if self.account.access_type == IMPERSONATION: return self.account.identity - return None + return super()._account_to_impersonate @property def _timezone(self): From 3590fde28ccc73418746df257596e5c1b105d21d Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 8 May 2022 00:58:25 +0200 Subject: [PATCH 216/509] Various fixes for test account on O365 --- tests/common.py | 34 +++++---- tests/test_account.py | 26 +++---- tests/test_attachments.py | 25 +++--- tests/test_autodiscover.py | 51 +++++++------ tests/test_extended_properties.py | 5 +- tests/test_folder.py | 101 +++++++++++++++++-------- tests/test_items/test_basics.py | 6 +- tests/test_items/test_calendaritems.py | 22 ++++-- tests/test_items/test_generic.py | 3 +- tests/test_items/test_messages.py | 7 +- tests/test_items/test_queryset.py | 5 +- tests/test_items/test_sync.py | 35 +++++++-- tests/test_protocol.py | 8 +- tests/test_source.py | 6 +- 14 files changed, 212 insertions(+), 122 deletions(-) diff --git a/tests/common.py b/tests/common.py index dee19ba9..92cdebcb 100644 --- a/tests/common.py +++ b/tests/common.py @@ -16,10 +16,10 @@ except ImportError: from backports import zoneinfo -from exchangelib.account import Account +from exchangelib.account import Account, Identity from exchangelib.attachments import FileAttachment from exchangelib.configuration import Configuration -from exchangelib.credentials import DELEGATE, Credentials +from exchangelib.credentials import IMPERSONATION, Credentials, OAuth2Credentials from exchangelib.errors import UnknownTimeZone from exchangelib.ewsdatetime import EWSTimeZone from exchangelib.fields import ( @@ -124,11 +124,22 @@ def setUpClass(cls): cls.retry_policy = FaultTolerance(max_wait=600) cls.config = Configuration( server=settings["server"], - credentials=Credentials(settings["username"], settings["password"]), + credentials=cls.credentials(), retry_policy=cls.retry_policy, ) cls.account = cls.get_account() + @classmethod + def credentials(cls): + if cls.settings.get("client_id"): + return OAuth2Credentials( + client_id=cls.settings["client_id"], + client_secret=cls.settings["client_secret"], + tenant_id=cls.settings["tenant_id"], + identity=Identity(primary_smtp_address=cls.settings["account"]), + ) + return Credentials(username=cls.settings["username"], password=cls.settings["password"]) + @classmethod def get_account(cls): return Account( @@ -209,21 +220,14 @@ def random_val(self, field): if isinstance(field, MailboxListField): # email_address must be a real account on the server(?) # TODO: Mailbox has multiple optional args but vals must match server account, so we can't easily test - if get_random_bool(): - return [Mailbox(email_address=self.account.primary_smtp_address)] - return [self.account.primary_smtp_address] + return [Mailbox(email_address=self.account.primary_smtp_address)] if isinstance(field, MailboxField): # email_address must be a real account on the server(?) # TODO: Mailbox has multiple optional args but vals must match server account, so we can't easily test - if get_random_bool(): - return Mailbox(email_address=self.account.primary_smtp_address) - return self.account.primary_smtp_address + return Mailbox(email_address=self.account.primary_smtp_address) if isinstance(field, AttendeesField): # Attendee must refer to a real mailbox on the server(?). We're only sure to have one - if get_random_bool(): - mbx = Mailbox(email_address=self.account.primary_smtp_address) - else: - mbx = self.account.primary_smtp_address + mbx = Mailbox(email_address=self.account.primary_smtp_address) with_last_response_time = get_random_bool() if with_last_response_time: return [ @@ -233,9 +237,7 @@ def random_val(self, field): last_response_time=get_random_datetime(tz=self.account.default_timezone), ) ] - if get_random_bool(): - return [Attendee(mailbox=mbx, response_type="Accept")] - return [self.account.primary_smtp_address] + return [Attendee(mailbox=mbx, response_type="Accept")] if isinstance(field, EmailAddressesField): addrs = [] for label in EmailAddress.get_field_by_fieldname("label").supported_choices(version=self.account.version): diff --git a/tests/test_account.py b/tests/test_account.py index b3287e7a..5040a8bf 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -5,7 +5,7 @@ from exchangelib.account import Account from exchangelib.attachments import FileAttachment from exchangelib.configuration import Configuration -from exchangelib.credentials import DELEGATE, Credentials +from exchangelib.credentials import DELEGATE, Credentials, OAuth2Credentials from exchangelib.errors import ( ErrorAccessDenied, ErrorDelegateNoUser, @@ -78,9 +78,8 @@ def test_getlocale_failure(self, m): access_type=DELEGATE, config=Configuration( service_endpoint=self.account.protocol.service_endpoint, - credentials=Credentials(self.account.protocol.credentials.username, "WRONG_PASSWORD"), + credentials=Credentials("john@example.com", "WRONG_PASSWORD"), version=self.account.version, - auth_type=self.account.protocol.auth_type, retry_policy=self.retry_policy, ), autodiscover=False, @@ -94,9 +93,8 @@ def test_tzlocal_failure(self, m): access_type=DELEGATE, config=Configuration( service_endpoint=self.account.protocol.service_endpoint, - credentials=Credentials(self.account.protocol.credentials.username, "WRONG_PASSWORD"), + credentials=Credentials("john@example.com", "WRONG_PASSWORD"), version=self.account.version, - auth_type=self.account.protocol.auth_type, retry_policy=self.retry_policy, ), autodiscover=False, @@ -164,7 +162,7 @@ def test_pickle(self): def test_mail_tips(self): # Test that mail tips work - self.assertEqual(self.account.mail_tips.recipient_address, self.account.primary_smtp_address) + self.assertEqual(self.account.mail_tips.recipient_address.email_address, self.account.primary_smtp_address) # recipients must not be empty list( GetMailTips(protocol=self.account.protocol).call( @@ -292,9 +290,8 @@ def test_login_failure_and_credentials_update(self): access_type=DELEGATE, config=Configuration( service_endpoint=self.account.protocol.service_endpoint, - credentials=Credentials(self.account.protocol.credentials.username, "WRONG_PASSWORD"), + credentials=Credentials("john@example.com", "WRONG_PASSWORD"), version=self.account.version, - auth_type=self.account.protocol.auth_type, retry_policy=self.retry_policy, ), autodiscover=False, @@ -324,34 +321,37 @@ def raise_response_errors(self, response): with self.assertRaises(AttributeError): account.protocol.config.credentials = self.account.protocol.credentials # Should succeed after credentials update - account.protocol.credentials = self.account.protocol.credentials + account.protocol.config.auth_type = self.account.protocol.config.auth_type + account.protocol.credentials = self.credentials() account.root.refresh() def test_protocol_default_values(self): # Test that retry_policy and auth_type always get a value regardless of how we create an Account - c = Credentials(self.settings["username"], self.settings["password"]) a = Account( self.account.primary_smtp_address, autodiscover=False, config=Configuration( server=self.settings["server"], - credentials=c, + credentials=self.credentials(), ), ) self.assertIsNotNone(a.protocol.auth_type) self.assertIsNotNone(a.protocol.retry_policy) + if isinstance(self.account.protocol.credentials, OAuth2Credentials): + self.skipTest("OAuth authentication does not work with POX autodiscover") + a = Account( self.account.primary_smtp_address, autodiscover=True, config=Configuration( server=self.settings["server"], - credentials=c, + credentials=self.credentials(), ), ) self.assertIsNotNone(a.protocol.auth_type) self.assertIsNotNone(a.protocol.retry_policy) - a = Account(self.account.primary_smtp_address, autodiscover=True, credentials=c) + a = Account(self.account.primary_smtp_address, autodiscover=True, credentials=self.credentials()) self.assertIsNotNone(a.protocol.auth_type) self.assertIsNotNone(a.protocol.retry_policy) diff --git a/tests/test_attachments.py b/tests/test_attachments.py index 2d4f1cbf..0077b9ce 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -1,5 +1,5 @@ from exchangelib.attachments import AttachmentId, FileAttachment, ItemAttachment -from exchangelib.errors import ErrorInvalidIdMalformed, ErrorItemNotFound +from exchangelib.errors import ErrorInvalidAttachmentId, ErrorInvalidIdMalformed from exchangelib.fields import FieldPath from exchangelib.folders import Inbox from exchangelib.items import Item, Message @@ -169,9 +169,9 @@ def test_raw_service_call(self): additional_fields=[FieldPath(field=self.ITEM_CLASS.get_field_by_fieldname("body"))], ) self.assertEqual( - attachment.item.body, - '\r\n\r\n\r\n' - "\r\n\r\nHello HTML\r\n\r\n\r\n", + attachment.item.body.replace("\r\n", ""), + '' + "Hello HTML ", ) def test_file_attachments(self): @@ -238,9 +238,10 @@ def test_streaming_file_attachments(self): def test_streaming_file_attachment_error(self): # Test that we can parse XML error responses in streaming mode. - # Try to stram an attachment with malformed ID + # Try to stream an attachment with malformed ID + item = self.get_test_item(folder=self.test_folder).save() att = FileAttachment( - parent_item=self.get_test_item(folder=self.test_folder), + parent_item=item, attachment_id=AttachmentId(id="AAMk="), name="dummy.txt", content=b"", @@ -250,11 +251,13 @@ def test_streaming_file_attachment_error(self): fp.read() # Try to stream a non-existent attachment - att.attachment_id.id = ( - "AAMkADQyYzZmYmUxLTJiYjItNDg2Ny1iMzNjLTIzYWE1NDgxNmZhNABGAAAAAADUebQDarW2Q7G2Ji8hKofPBwAl9iKCsfCfS" - "a9cmjh+JCrCAAPJcuhjAABioKiOUTCQRI6Q5sRzi0pJAAHnDV3CAAABEgAQAN0zlxDrzlxAteU+kt84qOM=" - ) - with self.assertRaises(ErrorItemNotFound): + att.attachment_id = None + att.attach() + att_id = att.attachment_id + att.detach() + att.parent_item = item + att.attachment_id = att_id + with self.assertRaises(ErrorInvalidAttachmentId): with att.fp as fp: fp.read() diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index fe8389e9..68835577 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -23,7 +23,7 @@ from exchangelib.autodiscover.properties import Account as ADAccount from exchangelib.autodiscover.properties import Autodiscover, Error, ErrorResponse, Response from exchangelib.configuration import Configuration -from exchangelib.credentials import DELEGATE, Credentials +from exchangelib.credentials import DELEGATE, Credentials, OAuth2Credentials from exchangelib.errors import AutoDiscoverCircularRedirect, AutoDiscoverFailed, ErrorNonExistentMailbox from exchangelib.protocol import FailFast, FaultTolerance from exchangelib.transport import NOAUTH, NTLM @@ -35,6 +35,9 @@ class AutodiscoverTest(EWSTest): def setUp(self): + if isinstance(self.account.protocol.credentials, OAuth2Credentials): + self.skipTest("OAuth authentication does not work with POX autodiscover") + super().setUp() # Enable retries, to make tests more robust @@ -50,6 +53,8 @@ def setUp(self): self.dummy_ews_endpoint = "https://expr.example.com/EWS/Exchange.asmx" self.dummy_ad_response = self.settings_xml(self.account.primary_smtp_address, self.dummy_ews_endpoint) + self.pox_credentials = Credentials(username=self.settings["username"], password=self.settings["password"]) + @staticmethod def settings_xml(address, ews_url): return f"""\ @@ -132,7 +137,7 @@ def test_autodiscover_empty_cache(self): # A live test of the entire process with an empty cache ad_response, protocol = discover( email=self.account.primary_smtp_address, - credentials=self.account.protocol.credentials, + credentials=self.pox_credentials, retry_policy=self.retry_policy, ) self.assertEqual(ad_response.autodiscover_smtp_address, self.account.primary_smtp_address) @@ -143,21 +148,21 @@ def test_autodiscover_empty_cache(self): self.assertEqual(protocol.version.build, self.account.protocol.version.build) def test_autodiscover_failure(self): - # A live test that errors can be raised. Here, we try to aútodiscover a non-existing email address + # A live test that errors can be raised. Here, we try to autodiscover a non-existing email address if not self.settings.get("autodiscover_server"): self.skipTest(f"Skipping {self.__class__.__name__} - no 'autodiscover_server' entry in settings.yml") # Autodiscovery may take a long time. Prime the cache with the autodiscover server from the config file ad_endpoint = f"https://{self.settings['autodiscover_server']}/Autodiscover/Autodiscover.xml" - cache_key = (self.domain, self.account.protocol.credentials) + cache_key = (self.domain, self.pox_credentials) autodiscover_cache[cache_key] = self.get_test_protocol( service_endpoint=ad_endpoint, - credentials=self.account.protocol.credentials, + credentials=self.pox_credentials, retry_policy=self.retry_policy, ) with self.assertRaises(ErrorNonExistentMailbox): discover( email="XXX." + self.account.primary_smtp_address, - credentials=self.account.protocol.credentials, + credentials=self.pox_credentials, retry_policy=self.retry_policy, ) @@ -166,7 +171,7 @@ def test_failed_login_via_account(self): Account( primary_smtp_address=self.account.primary_smtp_address, access_type=DELEGATE, - credentials=Credentials(self.account.protocol.credentials.username, "WRONG_PASSWORD"), + credentials=Credentials("john@example.com", "WRONG_PASSWORD"), autodiscover=True, locale="da_DK", ) @@ -193,7 +198,7 @@ def test_autodiscover_cache(self, m): m.post(self.dummy_ad_endpoint, status_code=200, content=self.dummy_ad_response) discovery = Autodiscovery( email=self.account.primary_smtp_address, - credentials=self.account.protocol.credentials, + credentials=self.pox_credentials, ) # Not cached self.assertNotIn(discovery._cache_key, autodiscover_cache) @@ -204,7 +209,7 @@ def test_autodiscover_cache(self, m): self.assertIn( ( self.account.primary_smtp_address.split("@")[1], - Credentials(self.account.protocol.credentials.username, self.account.protocol.credentials.password), + self.pox_credentials, True, ), autodiscover_cache, @@ -266,7 +271,7 @@ def test_autodiscover_from_account(self, m): account = Account( primary_smtp_address=self.account.primary_smtp_address, config=Configuration( - credentials=self.account.protocol.credentials, + credentials=self.pox_credentials, retry_policy=self.retry_policy, version=Version(build=EXCHANGE_2013), ), @@ -277,12 +282,12 @@ def test_autodiscover_from_account(self, m): self.assertEqual(account.protocol.service_endpoint.lower(), self.dummy_ews_endpoint.lower()) # Make sure cache is full self.assertEqual(len(autodiscover_cache), 1) - self.assertTrue((account.domain, self.account.protocol.credentials, True) in autodiscover_cache) + self.assertTrue((account.domain, self.pox_credentials, True) in autodiscover_cache) # Test that autodiscover works with a full cache account = Account( primary_smtp_address=self.account.primary_smtp_address, config=Configuration( - credentials=self.account.protocol.credentials, + credentials=self.pox_credentials, retry_policy=self.retry_policy, ), autodiscover=True, @@ -290,7 +295,7 @@ def test_autodiscover_from_account(self, m): ) self.assertEqual(account.primary_smtp_address, self.account.primary_smtp_address) # Test cache manipulation - key = (account.domain, self.account.protocol.credentials, True) + key = (account.domain, self.pox_credentials, True) self.assertTrue(key in autodiscover_cache) del autodiscover_cache[key] self.assertFalse(key in autodiscover_cache) @@ -303,7 +308,7 @@ def test_autodiscover_redirect(self, m): m.post(self.dummy_ad_endpoint, status_code=200, content=self.dummy_ad_response) discovery = Autodiscovery( email=self.account.primary_smtp_address, - credentials=self.account.protocol.credentials, + credentials=self.pox_credentials, ) discovery.discover() @@ -380,7 +385,7 @@ def test_autodiscover_redirect(self, m): def test_autodiscover_path_1_2_5(self, m): # Test steps 1 -> 2 -> 5 clear_cache() - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.pox_credentials) ews_url = f"https://xxx.{self.domain}/EWS/Exchange.asmx" email = f"xxxd@{self.domain}" m.post(self.dummy_ad_endpoint, status_code=501) @@ -397,7 +402,7 @@ def test_autodiscover_path_1_2_5(self, m): def test_autodiscover_path_1_2_3_invalid301_4(self, m): # Test steps 1 -> 2 -> 3 -> invalid 301 URL -> 4 clear_cache() - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.pox_credentials) m.post(self.dummy_ad_endpoint, status_code=501) m.post(f"https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=501) m.get( @@ -413,7 +418,7 @@ def test_autodiscover_path_1_2_3_invalid301_4(self, m): @requests_mock.mock(real_http=False) def test_autodiscover_path_1_2_3_no301_4(self, m): # Test steps 1 -> 2 -> 3 -> no 301 response -> 4 - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.pox_credentials) m.post(self.dummy_ad_endpoint, status_code=501) m.post(f"https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=501) m.get(f"http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=200) @@ -425,7 +430,7 @@ def test_autodiscover_path_1_2_3_no301_4(self, m): @requests_mock.mock(real_http=False) def test_autodiscover_path_1_2_3_4_valid_srv_invalid_response(self, m): # Test steps 1 -> 2 -> 3 -> 4 -> invalid response from SRV URL - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.pox_credentials) redirect_srv = "httpbin.org" m.post(self.dummy_ad_endpoint, status_code=501) m.post(f"https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=501) @@ -445,7 +450,7 @@ def test_autodiscover_path_1_2_3_4_valid_srv_invalid_response(self, m): @requests_mock.mock(real_http=False) def test_autodiscover_path_1_2_3_4_valid_srv_valid_response(self, m): # Test steps 1 -> 2 -> 3 -> 4 -> 5 - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.pox_credentials) redirect_srv = "httpbin.org" ews_url = f"https://{redirect_srv}/EWS/Exchange.asmx" redirect_email = f"john@redirected.{redirect_srv}" @@ -471,7 +476,7 @@ def test_autodiscover_path_1_2_3_4_valid_srv_valid_response(self, m): @requests_mock.mock(real_http=False) def test_autodiscover_path_1_2_3_4_invalid_srv(self, m): # Test steps 1 -> 2 -> 3 -> 4 -> invalid SRV URL - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.pox_credentials) m.post(self.dummy_ad_endpoint, status_code=501) m.post(f"https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=501) m.get(f"http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=200) @@ -489,7 +494,7 @@ def test_autodiscover_path_1_2_3_4_invalid_srv(self, m): def test_autodiscover_path_1_5_invalid_redirect_url(self, m): # Test steps 1 -> -> 5 -> Invalid redirect URL clear_cache() - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.pox_credentials) m.post( self.dummy_ad_endpoint, status_code=200, @@ -504,7 +509,7 @@ def test_autodiscover_path_1_5_invalid_redirect_url(self, m): def test_autodiscover_path_1_5_valid_redirect_url_invalid_response(self, m): # Test steps 1 -> -> 5 -> Invalid response from redirect URL clear_cache() - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.pox_credentials) redirect_url = "https://httpbin.org/Autodiscover/Autodiscover.xml" m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_url_xml(redirect_url)) m.head(redirect_url, status_code=501) @@ -518,7 +523,7 @@ def test_autodiscover_path_1_5_valid_redirect_url_invalid_response(self, m): def test_autodiscover_path_1_5_valid_redirect_url_valid_response(self, m): # Test steps 1 -> -> 5 -> Valid response from redirect URL -> 5 clear_cache() - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.pox_credentials) redirect_hostname = "httpbin.org" redirect_url = f"https://{redirect_hostname}/Autodiscover/Autodiscover.xml" ews_url = f"https://{redirect_hostname}/EWS/Exchange.asmx" diff --git a/tests/test_extended_properties.py b/tests/test_extended_properties.py index 1fd89edb..dbb1249f 100644 --- a/tests/test_extended_properties.py +++ b/tests/test_extended_properties.py @@ -342,7 +342,10 @@ class TestArayProp(ExtendedProperty): item = self.get_test_item(folder=self.test_folder).save() self.assertEqual(self.test_folder.filter(**{attr_name: getattr(item, attr_name)}).count(), 1) self.assertEqual( - self.test_folder.filter(**{f"{array_attr_name}__contains": getattr(item, array_attr_name)}).count(), 1 + # Does not work in O365 + # self.test_folder.filter(**{f"{array_attr_name}__contains": getattr(item, array_attr_name)}).count(), 1 + self.test_folder.filter(**{f"{array_attr_name}__in": getattr(item, array_attr_name)}).count(), + 1, ) finally: self.ITEM_CLASS.deregister(attr_name=attr_name) diff --git a/tests/test_folder.py b/tests/test_folder.py index 2045b10f..6ff64e07 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -8,6 +8,7 @@ ErrorFolderNotFound, ErrorItemNotFound, ErrorItemSave, + ErrorNoPublicFolderReplicaAvailable, ErrorObjectTypeChanged, MultipleObjectsReturned, ) @@ -17,19 +18,24 @@ SHALLOW, AllContacts, AllItems, + ApplicationData, + Birthdays, Calendar, Companies, Contacts, ConversationSettings, + CrawlerData, DefaultFoldersChangeHistory, DeletedItems, DistinguishedFolderId, + DlpPolicyEvaluation, Drafts, Favorites, Files, Folder, FolderCollection, FolderQuerySet, + FreeBusyCache, Friends, GALContacts, GraphAnalytics, @@ -49,6 +55,7 @@ PublicFoldersRoot, QuickContacts, RecipientCache, + RecoveryPoints, Reminders, RootOfHierarchy, RSSFeeds, @@ -56,9 +63,10 @@ Sharing, Signal, SingleFolderQuerySet, + SkypeTeamsMessages, SmsAndChatsSync, + SwssItems, SyncIssues, - System, Tasks, ToDoSearch, VoiceMail, @@ -124,9 +132,12 @@ def test_folder_failure(self): with self.assertRaises(ValueError): self.account.root.get_default_folder(Folder) - with self.assertRaises(ValueError) as e: - Folder(root=self.account.public_folders_root, parent=self.account.inbox) - self.assertEqual(e.exception.args[0], "'parent.root' must match 'root'") + try: + with self.assertRaises(ValueError) as e: + Folder(root=self.account.public_folders_root, parent=self.account.inbox) + self.assertEqual(e.exception.args[0], "'parent.root' must match 'root'") + except ErrorFolderNotFound: + pass with self.assertRaises(ValueError) as e: Folder(parent=self.account.inbox, parent_folder_id="XXX") self.assertEqual(e.exception.args[0], "'parent_folder_id' must match 'parent' ID") @@ -147,10 +158,17 @@ def test_folder_failure(self): def test_public_folders_root(self): # Test account does not have a public folders root. Make a dummy query just to hit .get_children() - self.assertGreaterEqual( - len(list(PublicFoldersRoot(account=self.account, is_distinguished=True).get_children(self.account.inbox))), - 0, - ) + try: + self.assertGreaterEqual( + len( + list( + PublicFoldersRoot(account=self.account, is_distinguished=True).get_children(self.account.inbox) + ) + ), + 0, + ) + except ErrorNoPublicFolderReplicaAvailable: + pass def test_invalid_deletefolder_args(self): with self.assertRaises(ValueError) as e: @@ -202,7 +220,10 @@ def test_find_folders(self): self.assertGreater(len(folders), 40, sorted(f.name for f in folders)) def test_find_folders_multiple_roots(self): - coll = FolderCollection(account=self.account, folders=[self.account.root, self.account.public_folders_root]) + try: + coll = FolderCollection(account=self.account, folders=[self.account.root, self.account.public_folders_root]) + except ErrorFolderNotFound as e: + self.skipTest(str(e)) with self.assertRaises(ValueError) as e: list(coll.find_folders(depth="Shallow")) self.assertIn("All folders in 'roots' must have the same root hierarchy", e.exception.args[0]) @@ -219,9 +240,7 @@ def test_find_folders_with_restriction(self): # Exact match tois_folder_name = self.account.root.tois.name folders = list( - FolderCollection(account=self.account, folders=[self.account.root]).find_folders( - q=Q(name=tois_folder_name) - ) + FolderCollection(account=self.account, folders=[self.account.root]).find_folders(q=Q(name=tois_folder_name)) ) self.assertEqual(len(folders), 1, sorted(f.name for f in folders)) # Startswith @@ -301,6 +320,26 @@ def test_folder_grouping(self): ), ): self.assertEqual(f.folder_class, "IPF.Note") + elif isinstance(f, ApplicationData): + self.assertEqual(f.folder_class, "IPM.ApplicationData") + elif isinstance(f, CrawlerData): + self.assertEqual(f.folder_class, "IPF.StoreItem.CrawlerData") + elif isinstance(f, DlpPolicyEvaluation): + self.assertEqual(f.folder_class, "IPF.StoreItem.DlpPolicyEvaluation") + elif isinstance(f, FreeBusyCache): + self.assertEqual(f.folder_class, "IPF.StoreItem.FreeBusyCache") + elif isinstance(f, RecoveryPoints): + self.assertEqual(f.folder_class, "IPF.StoreItem.RecoveryPoints") + elif isinstance(f, SwssItems): + self.assertEqual(f.folder_class, "IPF.StoreItem.SwssItems") + elif isinstance(f, PassThroughSearchResults): + self.assertEqual(f.folder_class, "IPF.StoreItem.PassThroughSearchResults") + elif isinstance(f, GraphAnalytics): + self.assertEqual(f.folder_class, "IPF.StoreItem.GraphAnalytics") + elif isinstance(f, Signal): + self.assertEqual(f.folder_class, "IPF.StoreItem.Signal") + elif isinstance(f, PdpProfileV2Secured): + self.assertEqual(f.folder_class, "IPF.StoreItem.PdpProfileSecured") elif isinstance(f, Companies): self.assertEqual(f.folder_class, "IPF.Contact.Company") elif isinstance(f, OrganizationalContacts): @@ -311,8 +350,14 @@ def test_folder_grouping(self): self.assertEqual(f.folder_class, "IPF.Contact.GalContacts") elif isinstance(f, RecipientCache): self.assertEqual(f.folder_class, "IPF.Contact.RecipientCache") + elif isinstance(f, IMContactList): + self.assertEqual(f.folder_class, "IPF.Contact.MOC.ImContactList") + elif isinstance(f, QuickContacts): + self.assertEqual(f.folder_class, "IPF.Contact.MOC.QuickContacts") elif isinstance(f, Contacts): self.assertEqual(f.folder_class, "IPF.Contact") + elif isinstance(f, Birthdays): + self.assertEqual(f.folder_class, "IPF.Appointment.Birthday") elif isinstance(f, Calendar): self.assertEqual(f.folder_class, "IPF.Appointment") elif isinstance(f, (Tasks, ToDoSearch)): @@ -325,32 +370,22 @@ def test_folder_grouping(self): self.assertEqual(f.folder_class, "IPF.Configuration") elif isinstance(f, Files): self.assertEqual(f.folder_class, "IPF.Files") - elif isinstance(f, Friends): - self.assertEqual(f.folder_class, "IPF.Note") + elif isinstance(f, VoiceMail): + self.assertEqual(f.folder_class, "IPF.Note.Microsoft.Voicemail") elif isinstance(f, RSSFeeds): self.assertEqual(f.folder_class, "IPF.Note.OutlookHomepage") - elif isinstance(f, IMContactList): - self.assertEqual(f.folder_class, "IPF.Contact.MOC.ImContactList") - elif isinstance(f, QuickContacts): - self.assertEqual(f.folder_class, "IPF.Contact.MOC.QuickContacts") + elif isinstance(f, Friends): + self.assertEqual(f.folder_class, "IPF.Note") elif isinstance(f, Journal): self.assertEqual(f.folder_class, "IPF.Journal") elif isinstance(f, Notes): self.assertEqual(f.folder_class, "IPF.StickyNote") elif isinstance(f, DefaultFoldersChangeHistory): self.assertEqual(f.folder_class, "IPM.DefaultFolderHistoryItem") - elif isinstance(f, PassThroughSearchResults): - self.assertEqual(f.folder_class, "IPF.StoreItem.PassThroughSearchResults") + elif isinstance(f, SkypeTeamsMessages): + self.assertEqual(f.folder_class, "IPF.SkypeTeams.Message") elif isinstance(f, SmsAndChatsSync): self.assertEqual(f.folder_class, "IPF.SmsAndChatsSync") - elif isinstance(f, GraphAnalytics): - self.assertEqual(f.folder_class, "IPF.StoreItem.GraphAnalytics") - elif isinstance(f, Signal): - self.assertEqual(f.folder_class, "IPF.StoreItem.Signal") - elif isinstance(f, PdpProfileV2Secured): - self.assertEqual(f.folder_class, "IPF.StoreItem.PdpProfileSecured") - elif isinstance(f, VoiceMail): - self.assertEqual(f.folder_class, "IPF.Note.Microsoft.Voicemail") else: self.assertIn(f.folder_class, (None, "IPF"), (f.name, f.__class__.__name__, f.folder_class)) self.assertIsInstance(f, Folder) @@ -470,8 +505,7 @@ def test_parts(self): def test_absolute(self): self.assertEqual( - self.account.calendar.absolute, - f"/root/{self.account.root.tois.name}/{self.account.calendar.name}" + self.account.calendar.absolute, f"/root/{self.account.root.tois.name}/{self.account.calendar.name}" ) def test_walk(self): @@ -488,7 +522,9 @@ def test_glob(self): self.assertGreaterEqual(len(list(self.account.contacts.glob("/"))), 5) self.assertGreaterEqual(len(list(self.account.contacts.glob("../*"))), 5) self.assertEqual(len(list(self.account.root.glob(f"**/{self.account.contacts.name}"))), 1) - self.assertEqual(len(list(self.account.root.glob(f"{self.account.root.tois.name[:6]}*/{self.account.contacts.name}"))), 1) + self.assertEqual( + len(list(self.account.root.glob(f"{self.account.root.tois.name[:6]}*/{self.account.contacts.name}"))), 1 + ) with self.assertRaises(ValueError) as e: list(self.account.root.glob("../*")) self.assertEqual(e.exception.args[0], "Already at top") @@ -524,7 +560,8 @@ def test_double_div_navigation(self): # Test normal navigation self.assertEqual( - (self.account.root // self.account.root.tois.name // self.account.calendar.name).id, self.account.calendar.id + (self.account.root // self.account.root.tois.name // self.account.calendar.name).id, + self.account.calendar.id, ) self.assertIsNone(self.account.root._subfolders) diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index 754c98af..8aac119e 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -379,7 +379,9 @@ def test_filter_on_list_fields(self): for f in fields: val = getattr(item, f.name) # Filter multi-value fields with =, __in and __contains - filter_kwargs = [{f"{f.name}__in": val}, {f"{f.name}__contains": val}] + # Does not work in O365 + # filter_kwargs = [{f"{f.name}__in": val}, {f"{f.name}__contains": val}] + filter_kwargs = [{f"{f.name}__in": val}] self._run_filter_tests(common_qs, f, filter_kwargs, val) def test_filter_on_single_field_index_fields(self): @@ -626,7 +628,7 @@ def test_item(self): if f.name == "mime_content": # This will change depending on other contents fields continue - old, new = getattr(item, f.name), insert_kwargs[f.name] + old, new = insert_kwargs[f.name], getattr(item, f.name) if f.is_list: old, new = set(old or ()), set(new or ()) self.assertEqual(old, new, (f.name, old, new)) diff --git a/tests/test_items/test_calendaritems.py b/tests/test_items/test_calendaritems.py index 669edd0b..33f36484 100644 --- a/tests/test_items/test_calendaritems.py +++ b/tests/test_items/test_calendaritems.py @@ -1,6 +1,11 @@ import datetime -from exchangelib.errors import ErrorInvalidOperation, ErrorItemNotFound, ErrorMissingInformationReferenceItemId +from exchangelib.errors import ( + ErrorInvalidOperation, + ErrorInvalidRecipients, + ErrorItemNotFound, + ErrorMissingInformationReferenceItemId, +) from exchangelib.ewsdatetime import UTC from exchangelib.fields import MONDAY, NOVEMBER, THIRD, WEDNESDAY, WEEK_DAY, WEEKEND_DAY from exchangelib.folders import Calendar @@ -37,11 +42,16 @@ def match_cat(self, i): def test_cancel(self): item = self.get_test_item().save() - res = item.cancel() # Returns (id, changekey) of cancelled item - self.assertIsInstance(res, BulkCreateResult) - with self.assertRaises(ErrorItemNotFound): - # Item is already cancelled - item.cancel() + try: + res = item.cancel() # Returns (id, changekey) of cancelled item + except ErrorInvalidRecipients: + # Does not always work in a single-account setup + pass + else: + self.assertIsInstance(res, BulkCreateResult) + with self.assertRaises(ErrorItemNotFound): + # Item is already cancelled + item.cancel() def test_updating_timestamps(self): # Test that we can update an item without changing anything, and maintain the hidden timezone fields as local diff --git a/tests/test_items/test_generic.py b/tests/test_items/test_generic.py index 004bec70..aeffa073 100644 --- a/tests/test_items/test_generic.py +++ b/tests/test_items/test_generic.py @@ -532,7 +532,8 @@ def test_finditems(self): self.assertEqual(common_qs.filter(categories__contains=["TESTA"]).count(), 1) # Test case insensitivity self.assertEqual(common_qs.filter(categories__contains=["testa"]).count(), 1) # Test case insensitivity self.assertEqual(common_qs.filter(categories__contains=["TestA"]).count(), 1) # Partial - self.assertEqual(common_qs.filter(categories__contains=item.categories).count(), 1) # Exact match + # Does not work in O365 + # self.assertEqual(common_qs.filter(categories__contains=item.categories).count(), 1) # Exact match with self.assertRaises(TypeError): common_qs.filter(categories__in="ci6xahH1").count() # Plain string is not supported self.assertEqual(common_qs.filter(categories__in=["ci6xahH1"]).count(), 0) # Same, but as list diff --git a/tests/test_items/test_messages.py b/tests/test_items/test_messages.py index 6e14bfa0..4e4f261f 100644 --- a/tests/test_items/test_messages.py +++ b/tests/test_items/test_messages.py @@ -187,9 +187,10 @@ def test_mark_as_junk(self): item.mark_as_junk(is_junk=True, move_item=False) self.assertEqual(item.folder, self.test_folder) self.assertEqual(self.test_folder.get(categories__contains=self.categories).id, item.id) - item.mark_as_junk(is_junk=True, move_item=True) - self.assertEqual(item.folder, self.account.junk) - self.assertEqual(self.account.junk.get(categories__contains=self.categories).id, item.id) + # Does not work in O365 + # item.mark_as_junk(is_junk=True, move_item=True) + # self.assertEqual(item.folder, self.account.junk) + # self.assertEqual(self.account.junk.get(categories__contains=self.categories).id, item.id) item.mark_as_junk(is_junk=False, move_item=True) self.assertEqual(item.folder, self.account.inbox) self.assertEqual(self.account.inbox.get(categories__contains=self.categories).id, item.id) diff --git a/tests/test_items/test_queryset.py b/tests/test_items/test_queryset.py index 96c13afb..f871a578 100644 --- a/tests/test_items/test_queryset.py +++ b/tests/test_items/test_queryset.py @@ -318,8 +318,9 @@ def test_mark_as_junk_via_queryset(self): qs.mark_as_junk(is_junk=True, move_item=False) self.assertEqual(self.test_folder.filter(categories__contains=self.categories).count(), 1) qs.mark_as_junk(is_junk=True, move_item=True) - self.assertEqual(self.account.junk.filter(categories__contains=self.categories).count(), 1) - self.account.junk.filter(categories__contains=self.categories).mark_as_junk(is_junk=False, move_item=True) + # Does not work in O365 + # self.assertEqual(self.account.junk.filter(categories__contains=self.categories).count(), 1) + # self.account.junk.filter(categories__contains=self.categories).mark_as_junk(is_junk=False, move_item=True) self.assertEqual(self.account.inbox.filter(categories__contains=self.categories).count(), 1) def test_archive_via_queryset(self): diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index 96d1a6a1..09dbc332 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -3,7 +3,15 @@ from exchangelib.errors import ErrorInvalidSubscription, ErrorSubscriptionNotFound, MalformedResponseError from exchangelib.folders import FolderCollection, Inbox from exchangelib.items import Message -from exchangelib.properties import CreatedEvent, DeletedEvent, ItemId, ModifiedEvent, Notification, StatusEvent +from exchangelib.properties import ( + CreatedEvent, + DeletedEvent, + ItemId, + ModifiedEvent, + MovedEvent, + Notification, + StatusEvent, +) from exchangelib.services import GetStreamingEvents, SendNotification, SubscribeToPull from exchangelib.util import PrettyXmlHandler @@ -201,8 +209,11 @@ def _filter_events(self, notifications, event_cls, item_id): if item_id is None: events.append(e) continue - if e.event_type == event_cls.ITEM and e.item_id.id == item_id: - events.append(e) + if e.event_type == event_cls.ITEM: + if isinstance(e, MovedEvent) and e.old_item_id.id == item_id: + events.append(e) + elif e.item_id.id == item_id: + events.append(e) self.assertEqual(len(events), 1) event = events[0] self.assertIsInstance(event, event_cls) @@ -235,8 +246,13 @@ def test_pull_notifications(self): i1.delete() time.sleep(5) # For some reason, events do not trigger instantly notifications = list(test_folder.get_events(subscription_id, watermark)) - deleted_event, watermark = self._filter_events(notifications, DeletedEvent, i1_id) - self.assertEqual(deleted_event.item_id.id, i1_id) + try: + # On some servers, items are moved to the Recoverable Items on delete + moved_event, watermark = self._filter_events(notifications, MovedEvent, i1_id) + self.assertEqual(moved_event.old_item_id.id, i1_id) + except AssertionError: + deleted_event, watermark = self._filter_events(notifications, DeletedEvent, i1_id) + self.assertEqual(deleted_event.item_id.id, i1_id) def test_streaming_notifications(self): # Test that we can create a streaming subscription, make changes and see the events by calling @@ -272,8 +288,13 @@ def test_streaming_notifications(self): notifications = list( test_folder.get_streaming_events(subscription_id, connection_timeout=1, max_notifications_returned=1) ) - deleted_event, _ = self._filter_events(notifications, DeletedEvent, i1_id) - self.assertEqual(deleted_event.item_id.id, i1_id) + try: + # On some servers, items are moved to the Recoverable Items on delete + moved_event, _ = self._filter_events(notifications, MovedEvent, i1_id) + self.assertEqual(moved_event.old_item_id.id, i1_id) + except AssertionError: + deleted_event, _ = self._filter_events(notifications, DeletedEvent, i1_id) + self.assertEqual(deleted_event.item_id.id, i1_id) def test_streaming_with_other_calls(self): # Test that we can call other EWS operations while we have a streaming subscription open diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 649cf8bc..57252d0b 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -778,10 +778,7 @@ def test_oof_settings_validation(self): ).clean(version=None) def test_convert_id(self): - i = ( - "AAMkADQyYzZmYmUxLTJiYjItNDg2Ny1iMzNjLTIzYWE1NDgxNmZhNABGAAAAAADUebQDarW2Q7G2Ji8hKofPBwAl9iKCsfCfSa9cmjh" - "+JCrCAAPJcuhjAAB0l+JSKvzBRYP+FXGewReXAABj6DrMAAA=" - ) + i = self.account.root.id for fmt in ID_FORMATS: res = list( self.account.protocol.convert_ids( @@ -825,6 +822,9 @@ def test_sessionpool(self): self.assertEqual(ids.count(), len(items)) def test_disable_ssl_verification(self): + if isinstance(self.account.protocol.credentials, OAuth2Credentials): + self.skipTest("OAuth authentication ony works with SSL verification enabled") + # Test that we can make requests when SSL verification is turned off. I don't know how to mock TLS responses if not self.verify_ssl: # We can only run this test if we haven't already disabled TLS diff --git a/tests/test_source.py b/tests/test_source.py index 1fdadaa7..9820238f 100644 --- a/tests/test_source.py +++ b/tests/test_source.py @@ -1,3 +1,4 @@ +from exchangelib.credentials import OAuth2Credentials from exchangelib.errors import ( ErrorAccessDenied, ErrorFolderNotFound, @@ -13,7 +14,10 @@ class CommonTest(EWSTest): def test_magic(self): self.assertIn(self.account.protocol.version.api_version, str(self.account.protocol)) - self.assertIn(self.account.protocol.credentials.username, str(self.account.protocol.credentials)) + if isinstance(self.account.protocol.credentials, OAuth2Credentials): + self.assertIn(self.account.protocol.credentials.client_id, str(self.account.protocol.credentials)) + else: + self.assertIn(self.account.protocol.credentials.username, str(self.account.protocol.credentials)) self.assertIn(self.account.primary_smtp_address, str(self.account)) self.assertIn(str(self.account.version.build.major_version), repr(self.account.version)) for item in ( From 8f94336d568e1f2eb47ef6e8b8f33c45235daf88 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 8 May 2022 11:04:46 +0200 Subject: [PATCH 217/509] Ignore local virtualenv --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a8b47e6b..bce3dca7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ build dist __pycache__ +venv settings.yml scratch*.py From bffa5d02b0e12473acab98fd4c14b9735f0e4cf4 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 8 May 2022 11:05:42 +0200 Subject: [PATCH 218/509] Blacken scripts --- scripts/notifier.py | 48 ++++++++++++++++++++++++++------------------- scripts/optimize.py | 38 +++++++++++++++++++---------------- 2 files changed, 49 insertions(+), 37 deletions(-) diff --git a/scripts/notifier.py b/scripts/notifier.py index 8e6a10bc..6725e576 100644 --- a/scripts/notifier.py +++ b/scripts/notifier.py @@ -37,24 +37,26 @@ done """ -from datetime import timedelta, datetime -from netrc import netrc import sys import warnings +from datetime import datetime, timedelta +from netrc import netrc -from exchangelib import DELEGATE, Credentials, Account, EWSTimeZone import sh -if '--insecure' in sys.argv: +from exchangelib import DELEGATE, Account, Credentials, EWSTimeZone + +if "--insecure" in sys.argv: # Disable TLS when Office365 can't get their certificate act together from exchangelib.protocol import BaseProtocol, NoVerifyHTTPAdapter + BaseProtocol.HTTP_ADAPTER_CLS = NoVerifyHTTPAdapter # Disable insecure TLS warnings warnings.filterwarnings("ignore") # Use notify-send for email notifications and zenity for calendar notifications -notify = sh.Command('/usr/bin/notify-send') -zenity = sh.Command('/usr/bin/zenity') +notify = sh.Command("/usr/bin/notify-send") +zenity = sh.Command("/usr/bin/zenity") # Get the local timezone tz = EWSTimeZone.localzone() @@ -63,24 +65,30 @@ now = datetime.now(tz=tz) emails_since = now - timedelta(seconds=sleep) cal_items_before = now + timedelta(seconds=sleep * 4) # Longer notice of upcoming appointments than new emails -username, _, password = netrc().authenticators('office365') +username, _, password = netrc().authenticators("office365") c = Credentials(username, password) a = Account(primary_smtp_address=c.username, credentials=c, access_type=DELEGATE, autodiscover=True) -for msg in a.calendar.view(start=now, end=cal_items_before)\ - .only('start', 'end', 'subject', 'location')\ - .order_by('start', 'end'): +for msg in ( + a.calendar.view(start=now, end=cal_items_before) + .only("start", "end", "subject", "location") + .order_by("start", "end") +): if msg.start < now: continue minutes_to_appointment = int((msg.start - now).total_seconds() / 60) - subj = f'You have a meeting in {minutes_to_appointment} minutes' - body = f"{msg.start.astimezone(tz).strftime('%H:%M')}-{msg.end.astimezone(tz).strftime('%H:%M')}: " \ - f"{msg.subject[:150]}\n{msg.location}" - zenity(**{'info': None, 'no-markup': None, 'title': subj, 'text': body}) - -for msg in a.inbox.filter(datetime_received__gt=emails_since, is_read=False)\ - .only('datetime_received', 'subject', 'text_body')\ - .order_by('datetime_received')[:10]: - subj = f'New mail: {msg.subject}' - clean_body = '\n'.join(line for line in msg.text_body.split('\n') if line) + subj = f"You have a meeting in {minutes_to_appointment} minutes" + body = ( + f"{msg.start.astimezone(tz).strftime('%H:%M')}-{msg.end.astimezone(tz).strftime('%H:%M')}: " + f"{msg.subject[:150]}\n{msg.location}" + ) + zenity(**{"info": None, "no-markup": None, "title": subj, "text": body}) + +for msg in ( + a.inbox.filter(datetime_received__gt=emails_since, is_read=False) + .only("datetime_received", "subject", "text_body") + .order_by("datetime_received")[:10] +): + subj = f"New mail: {msg.subject}" + clean_body = "\n".join(line for line in msg.text_body.split("\n") if line) notify(subj, clean_body[:200]) diff --git a/scripts/optimize.py b/scripts/optimize.py index b60e7cb7..5d2a6a3a 100755 --- a/scripts/optimize.py +++ b/scripts/optimize.py @@ -6,6 +6,7 @@ import logging import os import time + try: import zoneinfo except ImportError: @@ -13,33 +14,34 @@ from yaml import safe_load -from exchangelib import DELEGATE, Configuration, Account, CalendarItem, Credentials, FaultTolerance +from exchangelib import DELEGATE, Account, CalendarItem, Configuration, Credentials, FaultTolerance logging.basicConfig(level=logging.WARNING) try: - with open(os.path.join(os.path.dirname(__file__), '../settings.yml')) as f: + with open(os.path.join(os.path.dirname(__file__), "../settings.yml")) as f: settings = safe_load(f) except FileNotFoundError: - print('Copy settings.yml.sample to settings.yml and enter values for your test server') + print("Copy settings.yml.sample to settings.yml and enter values for your test server") raise -categories = ['perftest'] -tz = zoneinfo.ZoneInfo('America/New_York') +categories = ["perftest"] +tz = zoneinfo.ZoneInfo("America/New_York") -verify_ssl = settings.get('verify_ssl', True) +verify_ssl = settings.get("verify_ssl", True) if not verify_ssl: from exchangelib.protocol import BaseProtocol, NoVerifyHTTPAdapter + BaseProtocol.HTTP_ADAPTER_CLS = NoVerifyHTTPAdapter config = Configuration( - server=settings['server'], - credentials=Credentials(settings['username'], settings['password']), + server=settings["server"], + credentials=Credentials(settings["username"], settings["password"]), retry_policy=FaultTolerance(), ) -print(f'Exchange server: {config.service_endpoint}') +print(f"Exchange server: {config.service_endpoint}") -account = Account(config=config, primary_smtp_address=settings['account'], access_type=DELEGATE) +account = Account(config=config, primary_smtp_address=settings["account"], access_type=DELEGATE) # Remove leftovers from earlier tests account.calendar.filter(categories__contains=categories).delete() @@ -52,14 +54,14 @@ def generate_items(count): tpl_item = CalendarItem( start=start, end=end, - body=f'This is a performance optimization test of server {account.protocol.server} intended to find the ' - f'optimal batch size and concurrent connection pool size of this server.', + body=f"This is a performance optimization test of server {account.protocol.server} intended to find the " + f"optimal batch size and concurrent connection pool size of this server.", location="It's safe to delete this", categories=categories, ) for j in range(count): item = copy.copy(tpl_item) - item.subject = f'Performance optimization test {j} by exchangelib', + item.subject = (f"Performance optimization test {j} by exchangelib",) yield item @@ -75,21 +77,23 @@ def test(items, chunk_size): rate1 = len(ids) / delta1 delta2 = t3 - t2 rate2 = len(ids) / delta2 - print(f'Time to process {len(ids)} items (batchsize {chunk_size}, poolsize {account.protocol.poolsize}): ' - f'{delta1} / {delta2} ({rate1} / {rate2} per sec)') + print( + f"Time to process {len(ids)} items (batchsize {chunk_size}, poolsize {account.protocol.poolsize}): " + f"{delta1} / {delta2} ({rate1} / {rate2} per sec)" + ) # Generate items calitems = list(generate_items(500)) -print('\nTesting batch size') +print("\nTesting batch size") for i in range(1, 11): chunk_size = 25 * i account.protocol.poolsize = 5 test(calitems, chunk_size) time.sleep(60) # Sleep 1 minute. Performance will deteriorate over time if we give the server tie to recover -print('\nTesting pool size') +print("\nTesting pool size") for i in range(1, 11): chunk_size = 10 account.protocol.poolsize = i From 2491d753ce4a7f81f945f7de3a9bc4c5357626eb Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 8 May 2022 11:05:52 +0200 Subject: [PATCH 219/509] Use impersonation with OAuth credentials --- tests/common.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/common.py b/tests/common.py index 92cdebcb..cd38da23 100644 --- a/tests/common.py +++ b/tests/common.py @@ -19,7 +19,7 @@ from exchangelib.account import Account, Identity from exchangelib.attachments import FileAttachment from exchangelib.configuration import Configuration -from exchangelib.credentials import IMPERSONATION, Credentials, OAuth2Credentials +from exchangelib.credentials import DELEGATE, IMPERSONATION, Credentials, OAuth2Credentials from exchangelib.errors import UnknownTimeZone from exchangelib.ewsdatetime import EWSTimeZone from exchangelib.fields import ( @@ -144,7 +144,7 @@ def credentials(cls): def get_account(cls): return Account( primary_smtp_address=cls.settings["account"], - access_type=DELEGATE, + access_type=IMPERSONATION if cls.settings.get("client_id") else DELEGATE, config=cls.config, locale="da_DK", default_timezone=cls.tz, From ba0cdbd21e80b42c7c423e5263baca8e716729ad Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 8 May 2022 11:35:55 +0200 Subject: [PATCH 220/509] Small fixes for test credentials --- tests/common.py | 2 +- tests/test_account.py | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/common.py b/tests/common.py index cd38da23..d3394010 100644 --- a/tests/common.py +++ b/tests/common.py @@ -144,7 +144,7 @@ def credentials(cls): def get_account(cls): return Account( primary_smtp_address=cls.settings["account"], - access_type=IMPERSONATION if cls.settings.get("client_id") else DELEGATE, + access_type=IMPERSONATION if isinstance(cls.config.credentials, OAuth2Credentials) else DELEGATE, config=cls.config, locale="da_DK", default_timezone=cls.tz, diff --git a/tests/test_account.py b/tests/test_account.py index 5040a8bf..8f42f29d 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -322,7 +322,7 @@ def raise_response_errors(self, response): account.protocol.config.credentials = self.account.protocol.credentials # Should succeed after credentials update account.protocol.config.auth_type = self.account.protocol.config.auth_type - account.protocol.credentials = self.credentials() + account.protocol.credentials = self.account.protocol.credentials account.root.refresh() def test_protocol_default_values(self): @@ -332,7 +332,7 @@ def test_protocol_default_values(self): autodiscover=False, config=Configuration( server=self.settings["server"], - credentials=self.credentials(), + credentials=self.account.protocol.credentials, ), ) self.assertIsNotNone(a.protocol.auth_type) @@ -341,17 +341,19 @@ def test_protocol_default_values(self): if isinstance(self.account.protocol.credentials, OAuth2Credentials): self.skipTest("OAuth authentication does not work with POX autodiscover") + pox_credentials = Credentials(username=self.settings["username"], password=self.settings["password"]) + a = Account( - self.account.primary_smtp_address, + self.settings["alias"], autodiscover=True, config=Configuration( server=self.settings["server"], - credentials=self.credentials(), + credentials=pox_credentials, ), ) self.assertIsNotNone(a.protocol.auth_type) self.assertIsNotNone(a.protocol.retry_policy) - a = Account(self.account.primary_smtp_address, autodiscover=True, credentials=self.credentials()) + a = Account(self.settings["alias"], autodiscover=True, credentials=pox_credentials) self.assertIsNotNone(a.protocol.auth_type) self.assertIsNotNone(a.protocol.retry_policy) From dce1fa9b2830c4202608456cfebd15acb1851d9a Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 8 May 2022 12:00:28 +0200 Subject: [PATCH 221/509] Switch test suite to OAuth. Update sample file with new settings options. --- settings.yml.enc | Bin 272 -> 0 bytes settings.yml.ghenc | Bin 288 -> 544 bytes settings.yml.sample | 10 +++++++++- 3 files changed, 9 insertions(+), 1 deletion(-) delete mode 100644 settings.yml.enc diff --git a/settings.yml.enc b/settings.yml.enc deleted file mode 100644 index 661425dccd572b7a410592646f87d9e69f314093..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 272 zcmV+r0q_1kWgqVh;RF@=U{{4$lt|AXH=^y9s)xW6e3v#BYj)l5l;Yw5 z#-W*W>;?bzUH?w1AF$}VUrFs zW^2i(FcxVYaHWGF^?BvR#(7)ZKnV+n~H%60ms6jsa7AR7z3tQy(3 zPAm#;=6Jcd8x`n1D&XkQ73272{Y)RIAk88)dgbS=DE54Q!|LVcb}=K!R1zjox*zBbr*~NPt9-$mp4VpT`PJaYB=~>=z|QhZN!H$NZ-V zo~WvKu4h-UDX0ZWdJ9p2+<2<9#v7E-oRT{^o+AMPSGUNS93Tk3ZQVH7HT;=Dzp;`% zCV}mmeJ6t0p`_emXTV!MVKeHQ^_czCPehh!w2A207biyzxrB-ldbZ1X+8Konn4X~soG!VXuq7}(x$&}seETh! id9XPg3_ys@&5rcS7()MkhY5|;280}`MB literal 288 zcmV+*0pI>pVQh3|WM5y_v_Q#5G5v?U_qbmyq0o}oIFsE(YzlWkB^K1 diff --git a/settings.yml.sample b/settings.yml.sample index 1a821271..464ccadb 100644 --- a/settings.yml.sample +++ b/settings.yml.sample @@ -1,6 +1,14 @@ server: 'example.com' autodiscover_server: 'example.com' + username: 'MYWINDOMAIN\myusername' password: 'topsecret' + +# Or, for OAuth: +tenant_id: +client_id: +client_secret: + account: 'john.doe@example.com' # Don't use an account containing valuable data! We're polite, but things may go wrong. -verify_ssl: True +alias: 'john.doe@example.com' # For autodiscover lookups with an alias. Can be the same as 'account'. +verify_ssl: True # Must be True for OAuth From 25cbd60cfe357c674cd6f18a60332daf356aeff7 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 8 May 2022 12:07:55 +0200 Subject: [PATCH 222/509] Set digest type explicitly --- .github/workflows/python-package.yml | 4 ++-- settings.yml.ghenc | Bin 544 -> 544 bytes 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 79afb4d5..3c3ec4d1 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -50,7 +50,7 @@ jobs: # Only repo owners have access to the secret. PRs will run only the unit tests if: env.AES_256_CBC_PASS != '' run: | - openssl aes-256-cbc -d -in settings.yml.ghenc -out settings.yml -pass env:AES_256_CBC_PASS + openssl aes-256-cbc -d -md sha256 -in settings.yml.ghenc -out settings.yml -pass env:AES_256_CBC_PASS - name: Upgrade pip run: | @@ -102,7 +102,7 @@ jobs: # Only repo owners have access to the secret. PRs will run only the unit tests if: env.AES_256_CBC_PASS != '' run: | - openssl aes-256-cbc -d -in settings.yml.ghenc -out settings.yml -pass env:AES_256_CBC_PASS + openssl aes-256-cbc -d -md sha256 -in settings.yml.ghenc -out settings.yml -pass env:AES_256_CBC_PASS - name: Upgrade pip run: | diff --git a/settings.yml.ghenc b/settings.yml.ghenc index 07073c618c4c0c6891111aaf171c541b0a4bc8b8..ceac1a42b3fdcacdb80dd150a88533250743ea3c 100644 GIT binary patch literal 544 zcmV+*0^j{pVQh3|WM5xLrzo6^`mGJ#Pm5%-TL(=rJnB~L)eoP@u@b|9Dcg<7{l3S} ziMeB)PA8jDlu&$KqI98?^N?03;g)H8^ELYf*Gl%NJSLa#PJ_7uLW$xMXP0BFS)X>d z<>uT}lTCZ@@hsTKmJWtzl{2 zqE_xpNq!^dtbL8Eh)oNGOUWcsO_^K$`e+QQQVdKMbv$~euRyg<`X7F&&BSIyit{tnm0PMB0`0RYa&xtKE=9x2gm_MUDh{FCg>!#ySQHaobW2)Ql4lUJ*; iS8Ul!_|YkBhi{Q+OctT$t_g2SN)^f?Yb8kj-vK{nwGOWU literal 544 zcmV+*0^j{pVQh3|WM5x&o&pzsqjsa7AR7z3tQy(3 zPAm#;=6Jcd8x`n1D&XkQ73272{Y)RIAk88)dgbS=DE54Q!|LVcb}=K!R1zjox*zBbr*~NPt9-$mp4VpT`PJaYB=~>=z|QhZN!H$NZ-V zo~WvKu4h-UDX0ZWdJ9p2+<2<9#v7E-oRT{^o+AMPSGUNS93Tk3ZQVH7HT;=Dzp;`% zCV}mmeJ6t0p`_emXTV!MVKeHQ^_czCPehh!w2A207biyzxrB-ldbZ1X+8Konn4X~soG!VXuq7}(x$&}seETh! id9XPg3_ys@&5rcS7()MkhY5|;280}`MB From 31d8c634e7e86499ea74b1099d3d536d74b5df84 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 8 May 2022 23:08:37 +0200 Subject: [PATCH 223/509] Fixes to unbreak wipe script --- exchangelib/errors.py | 4 ++++ exchangelib/folders/base.py | 25 ++++++++++++++++++++++--- exchangelib/folders/known_folders.py | 10 +++++----- exchangelib/services/common.py | 2 ++ 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/exchangelib/errors.py b/exchangelib/errors.py index 268fa8cc..a633666a 100644 --- a/exchangelib/errors.py +++ b/exchangelib/errors.py @@ -1413,6 +1413,10 @@ class ErrorReadReceiptNotPending(ResponseMessageError): pass +class ErrorRecoverableItemsAccessDenied(ResponseMessageError): + pass + + class ErrorRecurrenceEndDateTooBig(ResponseMessageError): pass diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 80badf9c..149dea84 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -10,6 +10,7 @@ ErrorDeleteDistinguishedFolder, ErrorFolderNotFound, ErrorItemNotFound, + ErrorRecoverableItemsAccessDenied, InvalidTypeError, ) from ..fields import ( @@ -47,6 +48,13 @@ log = logging.getLogger(__name__) +DELETE_FOLDER_ERRORS = ( + ErrorAccessDenied, + ErrorCannotDeleteObject, + ErrorCannotEmptyFolder, + ErrorItemNotFound, +) + class BaseFolder(RegisterMixIn, SearchableMixIn, metaclass=EWSMeta): """Base class for all classes that implement a folder.""" @@ -416,12 +424,20 @@ def empty(self, delete_type=HARD_DELETE, delete_sub_folders=False): def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0): # Recursively deletes all items in this folder, and all subfolders and their content. Attempts to protect # distinguished folders from being deleted. Use with caution! + from .known_folders import Audits _seen = _seen or set() if self.id in _seen: raise RecursionError(f"We already tried to wipe {self}") if _level > 16: raise RecursionError(f"Max recursion level reached: {_level}") _seen.add(self.id) + if isinstance(self, Audits): + # Shortcircuit because this folder can have many items that are all non-deletable + log.warning("Cannot wipe audits folder %s", self) + return + if self.is_distinguished and "recoverableitems" in self.DISTINGUISHED_FOLDER_ID: + log.warning("Cannot wipe recoverable items folder %s", self) + return log.warning("Wiping %s", self) has_distinguished_subfolders = any(f.is_distinguished for f in self.children) try: @@ -429,12 +445,15 @@ def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0): self.empty(delete_sub_folders=False) else: self.empty(delete_sub_folders=True) - except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): + except ErrorRecoverableItemsAccessDenied: + log.warning("Access denied to %s. Skipping", self) + return + except DELETE_FOLDER_ERRORS: try: if has_distinguished_subfolders: raise # We already tried this self.empty(delete_sub_folders=False) - except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): + except DELETE_FOLDER_ERRORS as e: log.warning("Not allowed to empty %s. Trying to delete items instead", self) kwargs = {} if page_size is not None: @@ -443,7 +462,7 @@ def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0): kwargs["chunk_size"] = chunk_size try: self.all().delete(**kwargs) - except (ErrorAccessDenied, ErrorCannotDeleteObject, ErrorItemNotFound): + except DELETE_FOLDER_ERRORS: log.warning("Not allowed to delete items in %s", self) _level += 1 for f in self.children: diff --git a/exchangelib/folders/known_folders.py b/exchangelib/folders/known_folders.py index f58cfeeb..530af138 100644 --- a/exchangelib/folders/known_folders.py +++ b/exchangelib/folders/known_folders.py @@ -62,10 +62,6 @@ class Messages(Folder): supported_item_models = (Message, MeetingRequest, MeetingResponse, MeetingCancellation) -class ApplicationData(Folder): - CONTAINER_CLASS = "IPM.ApplicationData" - - class CrawlerData(Folder): CONTAINER_CLASS = "IPF.StoreItem.CrawlerData" @@ -422,6 +418,10 @@ class AllItems(NonDeletableFolderMixin, Folder): } +class ApplicationData(NonDeletableFolderMixin, Folder): + CONTAINER_CLASS = "IPM.ApplicationData" + + class Audits(NonDeletableFolderMixin, Folder): LOCALIZED_NAMES = { None: ("Audits",), @@ -661,6 +661,7 @@ class WorkingSet(NonDeletableFolderMixin, Folder): NON_DELETABLE_FOLDERS = [ AllContacts, AllItems, + ApplicationData, Audits, CalendarLogging, CommonViews, @@ -743,7 +744,6 @@ class WorkingSet(NonDeletableFolderMixin, Folder): ] MISC_FOLDERS = [ - ApplicationData, CrawlerData, DlpPolicyEvaluation, FreeBusyCache, diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index e5d0c782..8d9c0045 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -50,6 +50,7 @@ ErrorNoRespondingCASInDestinationSite, ErrorNotDelegate, ErrorQuotaExceeded, + ErrorRecoverableItemsAccessDenied, ErrorRecurrenceHasNoOccurrence, ErrorServerBusy, ErrorTimeoutExpired, @@ -131,6 +132,7 @@ ErrorNoPublicFolderReplicaAvailable, ErrorNoRespondingCASInDestinationSite, ErrorNotDelegate, + ErrorRecoverableItemsAccessDenied, ErrorQuotaExceeded, ErrorTimeoutExpired, RateLimitError, From 5c8db936a19c568c833bd4f65303eb374e1dd464 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 8 May 2022 23:10:29 +0200 Subject: [PATCH 224/509] Be less verbose in debug mode. Add larger timeout in wipe script for slow Exchange servers --- scripts/wipe_test_account.py | 2 ++ tests/__init__.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/scripts/wipe_test_account.py b/scripts/wipe_test_account.py index 11e086df..49289a16 100644 --- a/scripts/wipe_test_account.py +++ b/scripts/wipe_test_account.py @@ -1,5 +1,7 @@ from tests.common import EWSTest +from exchangelib.protocol import BaseProtocol +BaseProtocol.TIMEOUT = 300 # Seconds t = EWSTest() t.setUpClass() t.wipe_test_account() diff --git a/tests/__init__.py b/tests/__init__.py index 88dd5a5b..555f40c8 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -26,5 +26,7 @@ def __iter__(self): if os.environ.get("DEBUG", "").lower() in ("1", "yes", "true"): logging.basicConfig(level=logging.DEBUG, handlers=[PrettyXmlHandler()]) + logging.getLogger('requests').setLevel(level=logging.INFO) + logging.getLogger('requests_oauthlib').setLevel(level=logging.INFO) else: logging.basicConfig(level=logging.CRITICAL) From 4b9610501a7018f9be4e829cfff25d08169bc7d8 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 8 May 2022 23:22:43 +0200 Subject: [PATCH 225/509] Blacken --- exchangelib/folders/base.py | 1 + scripts/wipe_test_account.py | 2 +- tests/__init__.py | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 149dea84..6df7056e 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -425,6 +425,7 @@ def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0): # Recursively deletes all items in this folder, and all subfolders and their content. Attempts to protect # distinguished folders from being deleted. Use with caution! from .known_folders import Audits + _seen = _seen or set() if self.id in _seen: raise RecursionError(f"We already tried to wipe {self}") diff --git a/scripts/wipe_test_account.py b/scripts/wipe_test_account.py index 49289a16..e5a29879 100644 --- a/scripts/wipe_test_account.py +++ b/scripts/wipe_test_account.py @@ -1,6 +1,6 @@ +from exchangelib.protocol import BaseProtocol from tests.common import EWSTest -from exchangelib.protocol import BaseProtocol BaseProtocol.TIMEOUT = 300 # Seconds t = EWSTest() t.setUpClass() diff --git a/tests/__init__.py b/tests/__init__.py index 555f40c8..bc9f76b6 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -26,7 +26,7 @@ def __iter__(self): if os.environ.get("DEBUG", "").lower() in ("1", "yes", "true"): logging.basicConfig(level=logging.DEBUG, handlers=[PrettyXmlHandler()]) - logging.getLogger('requests').setLevel(level=logging.INFO) - logging.getLogger('requests_oauthlib').setLevel(level=logging.INFO) + logging.getLogger("requests").setLevel(level=logging.INFO) + logging.getLogger("requests_oauthlib").setLevel(level=logging.INFO) else: logging.basicConfig(level=logging.CRITICAL) From 15c7e76c17d1a4cb5e48ea333f527adf262069bf Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 9 May 2022 08:09:37 +0200 Subject: [PATCH 226/509] Remove field_uri attr. It creates an extraneous *ItemId element when converting to XML. Fixes #1078 --- exchangelib/properties.py | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 81a966a9..2256785e 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -582,6 +582,24 @@ def id_from_xml(cls, elem): return item.id, item.changekey +class OldItemId(ItemId): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/oldfolderid""" + + ELEMENT_NAME = "OldItemId" + + +class OldFolderId(FolderId): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/olditemid""" + + ELEMENT_NAME = "OldFolderId" + + +class OldParentFolderId(ParentFolderId): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/oldparentfolderid""" + + ELEMENT_NAME = "OldParentFolderId" + + class Mailbox(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailbox""" @@ -1705,9 +1723,9 @@ class TimestampEvent(Event, metaclass=EWSMeta): ITEM = "item" timestamp = DateTimeField(field_uri="TimeStamp") - item_id = EWSElementField(field_uri="ItemId", value_cls=ItemId) - folder_id = EWSElementField(field_uri="FolderId", value_cls=FolderId) - parent_folder_id = EWSElementField(field_uri="ParentFolderId", value_cls=ParentFolderId) + item_id = EWSElementField(value_cls=ItemId) + folder_id = EWSElementField(value_cls=FolderId) + parent_folder_id = EWSElementField(value_cls=ParentFolderId) @property def event_type(self): @@ -1721,9 +1739,9 @@ def event_type(self): class OldTimestampEvent(TimestampEvent, metaclass=EWSMeta): """Base class for both item and folder copy/move events.""" - old_item_id = EWSElementField(field_uri="OldItemId", value_cls=ItemId) - old_folder_id = EWSElementField(field_uri="OldFolderId", value_cls=FolderId) - old_parent_folder_id = EWSElementField(field_uri="OldParentFolderId", value_cls=ParentFolderId) + old_item_id = EWSElementField(value_cls=OldItemId) + old_folder_id = EWSElementField(value_cls=OldFolderId) + old_parent_folder_id = EWSElementField(value_cls=OldParentFolderId) class CopiedEvent(OldTimestampEvent): From ba452f7dd636b5c2a8ba85444e8467f68e568c56 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 9 May 2022 08:26:37 +0200 Subject: [PATCH 227/509] Remove stack-trace printing. Newer Python versions print the stack trace just fine within threads. --- exchangelib/services/common.py | 77 ---------------------------------- 1 file changed, 77 deletions(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 8d9c0045..cafe0b0f 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -1,56 +1,28 @@ import abc import logging -import traceback from itertools import chain from .. import errors from ..attachments import AttachmentId from ..credentials import IMPERSONATION, OAuth2Credentials from ..errors import ( - ErrorAccessDenied, - ErrorADUnavailable, ErrorBatchProcessingStopped, ErrorCannotDeleteObject, ErrorCannotDeleteTaskOccurrence, - ErrorCannotEmptyFolder, - ErrorConnectionFailed, - ErrorConnectionFailedTransientError, ErrorCorruptData, - ErrorCreateItemAccessDenied, - ErrorDelegateNoUser, - ErrorDeleteDistinguishedFolder, ErrorExceededConnectionCount, - ErrorFolderNotFound, - ErrorImpersonateUserDenied, - ErrorImpersonationFailed, ErrorIncorrectSchemaVersion, - ErrorInternalServerError, - ErrorInternalServerTransientError, ErrorInvalidChangeKey, ErrorInvalidIdMalformed, - ErrorInvalidLicense, ErrorInvalidRequest, ErrorInvalidSchemaVersionForMailboxVersion, ErrorInvalidServerVersion, - ErrorInvalidSubscription, - ErrorInvalidSyncStateData, - ErrorInvalidWatermark, ErrorItemCorrupt, ErrorItemNotFound, ErrorItemSave, - ErrorMailboxMoveInProgress, - ErrorMailboxStoreUnavailable, ErrorMailRecipientNotFound, ErrorMessageSizeExceeded, ErrorMimeContentConversionFailed, - ErrorNameResolutionMultipleResults, - ErrorNameResolutionNoResults, - ErrorNonExistentMailbox, - ErrorNoPublicFolderReplicaAvailable, - ErrorNoRespondingCASInDestinationSite, - ErrorNotDelegate, - ErrorQuotaExceeded, - ErrorRecoverableItemsAccessDenied, ErrorRecurrenceHasNoOccurrence, ErrorServerBusy, ErrorTimeoutExpired, @@ -58,11 +30,9 @@ EWSWarning, InvalidTypeError, MalformedResponseError, - RateLimitError, SessionPoolMinSizeReached, SOAPError, TransportError, - UnauthorizedError, ) from ..folders import BaseFolder, Folder, RootOfHierarchy from ..items import BaseItem @@ -100,45 +70,6 @@ PAGE_SIZE = 100 # A default page size for all paging services. This is the number of items we request per page CHUNK_SIZE = 100 # A default chunk size for all services. This is the number of items we send in a single request -KNOWN_EXCEPTIONS = ( - ErrorAccessDenied, - ErrorADUnavailable, - ErrorBatchProcessingStopped, - ErrorCannotDeleteObject, - ErrorCannotEmptyFolder, - ErrorConnectionFailed, - ErrorConnectionFailedTransientError, - ErrorCreateItemAccessDenied, - ErrorDelegateNoUser, - ErrorDeleteDistinguishedFolder, - ErrorExceededConnectionCount, - ErrorFolderNotFound, - ErrorImpersonateUserDenied, - ErrorImpersonationFailed, - ErrorInternalServerError, - ErrorInternalServerTransientError, - ErrorInvalidChangeKey, - ErrorInvalidLicense, - ErrorInvalidSubscription, - ErrorInvalidSyncStateData, - ErrorInvalidWatermark, - ErrorItemCorrupt, - ErrorItemNotFound, - ErrorMailboxMoveInProgress, - ErrorMailboxStoreUnavailable, - ErrorNameResolutionMultipleResults, - ErrorNameResolutionNoResults, - ErrorNonExistentMailbox, - ErrorNoPublicFolderReplicaAvailable, - ErrorNoRespondingCASInDestinationSite, - ErrorNotDelegate, - ErrorRecoverableItemsAccessDenied, - ErrorQuotaExceeded, - ErrorTimeoutExpired, - RateLimitError, - UnauthorizedError, -) - class EWSService(metaclass=abc.ABCMeta): """Base class for all EWS services.""" @@ -336,9 +267,6 @@ def _get_elements(self, payload): except ErrorServerBusy as e: self._handle_backoff(e) continue - except KNOWN_EXCEPTIONS: - # These are known and understood, and don't require a backtrace. - raise except (ErrorTooManyObjectsOpened, ErrorTimeoutExpired) as e: # ErrorTooManyObjectsOpened means there are too many connections to the Exchange database. This is very # often a symptom of sending too many requests. @@ -353,11 +281,6 @@ def _get_elements(self, payload): # Re-raise as an ErrorServerBusy with a default delay of 5 minutes raise ErrorServerBusy(f"Reraised from {e.__class__.__name__}({e})") - except Exception: - # This may run in a thread, which obfuscates the stack trace. Print trace immediately. - account = self.account if isinstance(self, EWSAccountService) else None - log.warning("Account %s: Exception in _get_elements: %s", account, traceback.format_exc(20)) - raise finally: if self.streaming: self.stop_streaming() From 6781432a5038d82f4631cb171d7176d8ba365563 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 9 May 2022 08:27:34 +0200 Subject: [PATCH 228/509] Re-introduce flake8 --- .github/workflows/python-package.yml | 5 ++-- exchangelib/folders/base.py | 2 +- pyproject.toml | 1 - setup.cfg | 4 +++ test-requirements.txt | 1 + tests/test_protocol.py | 37 ++++++++++++++++++---------- 6 files changed, 33 insertions(+), 17 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 3c3ec4d1..0573ce79 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -77,8 +77,9 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - black --check --diff exchangelib tests - isort --check --diff exchangelib tests + black --check --diff exchangelib tests scripts + isort --check --diff exchangelib tests scripts + flake8 exchangelib tests scripts unittest-parallel -j 4 --class-fixtures --coverage --coverage-source exchangelib coveralls --service=github diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 6df7056e..8e6c3795 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -454,7 +454,7 @@ def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0): if has_distinguished_subfolders: raise # We already tried this self.empty(delete_sub_folders=False) - except DELETE_FOLDER_ERRORS as e: + except DELETE_FOLDER_ERRORS: log.warning("Not allowed to empty %s. Trying to delete items instead", self) kwargs = {} if page_size is not None: diff --git a/pyproject.toml b/pyproject.toml index 101ff259..5f2f74d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,4 +4,3 @@ line-length = 120 [tool.isort] line_length = 120 profile = "black" - diff --git a/setup.cfg b/setup.cfg index ed8a958e..ed3536de 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,3 +3,7 @@ universal = 1 [metadata] license_file = LICENSE + +[flake8] +ignore = E203, W503 +max-line-length = 120 \ No newline at end of file diff --git a/test-requirements.txt b/test-requirements.txt index e123b455..e51b7bdd 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,6 +1,7 @@ black coverage coveralls +flake8 isort psutil python-dateutil diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 57252d0b..1e565022 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -278,7 +278,13 @@ def test_get_timezones_parsing(self): xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
    - +
    NoError - + @@ -596,8 +604,11 @@ def test_get_searchable_mailboxes(self): self.account.protocol.get_searchable_mailboxes(search_filter="non_existent_distro@example.com") with self.assertRaises(ErrorAccessDenied): self.account.protocol.get_searchable_mailboxes(expand_group_membership=True) - - xml = b"""\ + guid = "33a408fe-2574-4e3b-49f5-5e1e000a3035" + email = "LOLgroup@example.com" + display_name = "LOLgroup" + reference_id = "/o=First/ou=Exchange(FYLT)/cn=Recipients/cn=81213b958a0b5295b13b3f02b812bf1bc-LOLgroup" + xml = f"""\ NoError - 33a408fe-2574-4e3b-49f5-5e1e000a3035 - LOLgroup@example.com + {guid} + {email} false - LOLgroup + {display_name} true - /o=First/ou=Exchange(FYLT)/cn=Recipients/cn=81213b958a0b5295b13b3f02b812bf1bc-LOLgroup + {reference_id} FAILgroup@example.com @@ -623,19 +634,19 @@ def test_get_searchable_mailboxes(self): -""" +""".encode() ws = GetSearchableMailboxes(protocol=self.account.protocol) self.assertListEqual( list(ws.parse(xml)), [ SearchableMailbox( - guid="33a408fe-2574-4e3b-49f5-5e1e000a3035", - primary_smtp_address="LOLgroup@example.com", + guid=guid, + primary_smtp_address=email, is_external=False, external_email=None, - display_name="LOLgroup", + display_name=display_name, is_membership_group=True, - reference_id="/o=First/ou=Exchange(FYLT)/cn=Recipients/cn=81213b958a0b5295b13b3f02b812bf1bc-LOLgroup", + reference_id=reference_id, ), FailedMailbox( mailbox="FAILgroup@example.com", From 32f801392c9f1650188ac307df398f106bc004f1 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 9 May 2022 11:32:53 +0200 Subject: [PATCH 229/509] Fix linter warnings --- exchangelib/properties.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 2256785e..b496d19b 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -219,15 +219,15 @@ def __new__(mcs, name, bases, kwargs): # Folder class, making the custom field available for subclasses). if local_fields: kwargs["FIELDS"] = fields - cls = super().__new__(mcs, name, bases, kwargs) - cls._slots_keys = mcs._get_slots_keys(cls) - return cls + klass = super().__new__(mcs, name, bases, kwargs) + klass._slots_keys = mcs._get_slots_keys(klass) + return klass @staticmethod - def _get_slots_keys(cls): + def _get_slots_keys(klass): seen = set() keys = [] - for c in reversed(getmro(cls)): + for c in reversed(getmro(klass)): if not hasattr(c, "__slots__"): continue for k in c.__slots__: @@ -1920,7 +1920,7 @@ def _get_standard_period(self, transitions_group): return standard_periods_map[transition.to] except KeyError: continue - raise ValueError(f"No standard period matching transition reference {transition.to}") + raise ValueError(f"No standard period matching any transition in {transitions_group}") def _get_transitions_group(self, for_year): # Look through the transitions, and pick the relevant transition group according to the 'for_year' value From dfaad278cab989f6594b42fac8a809ea2afb03a3 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 9 May 2022 11:45:28 +0200 Subject: [PATCH 230/509] Bump version --- CHANGELOG.md | 5 +++++ exchangelib/__init__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3842602..2f45fdca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ HEAD ---- +4.7.3 +----- +- Bugfix release + + 4.7.2 ----- - Fixed field name to match API: `BaseReplyItem.received_by_representing` to diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index 5e9f4b9a..028c731b 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -28,7 +28,7 @@ from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "4.7.2" +__version__ = "4.7.3" __all__ = [ "__version__", From 95c3906a45a249a004bad5e223364e0330bfb35d Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 9 May 2022 11:45:41 +0200 Subject: [PATCH 231/509] Re-generate docs --- docs/exchangelib/autodiscover/discovery.html | 29 +- docs/exchangelib/autodiscover/index.html | 11 +- docs/exchangelib/configuration.html | 12 +- docs/exchangelib/credentials.html | 39 +- docs/exchangelib/errors.html | 60 +- docs/exchangelib/ewsdatetime.html | 2 +- docs/exchangelib/fields.html | 5 +- docs/exchangelib/folders/base.html | 142 +++- docs/exchangelib/folders/index.html | 767 +++++++++++++++++- docs/exchangelib/folders/known_folders.html | 701 +++++++++++++++- docs/exchangelib/folders/roots.html | 7 +- docs/exchangelib/index.html | 120 ++- docs/exchangelib/items/contact.html | 4 +- docs/exchangelib/items/index.html | 10 +- docs/exchangelib/items/message.html | 10 +- docs/exchangelib/properties.html | 313 ++++--- docs/exchangelib/protocol.html | 99 +-- docs/exchangelib/services/common.html | 90 +- .../services/get_user_availability.html | 16 +- docs/exchangelib/services/index.html | 8 +- 20 files changed, 1959 insertions(+), 486 deletions(-) diff --git a/docs/exchangelib/autodiscover/discovery.html b/docs/exchangelib/autodiscover/discovery.html index 91649b6d..9054f269 100644 --- a/docs/exchangelib/autodiscover/discovery.html +++ b/docs/exchangelib/autodiscover/discovery.html @@ -54,6 +54,13 @@

    Module exchangelib.autodiscover.discovery

    log = logging.getLogger(__name__) +DNS_LOOKUP_ERRORS = ( + dns.name.EmptyLabel, + dns.resolver.NXDOMAIN, + dns.resolver.NoAnswer, + dns.resolver.NoNameservers, +) + def discover(email, credentials=None, auth_type=None, retry_policy=None): ad_response, protocol = Autodiscovery(email=email, credentials=credentials).discover() @@ -385,8 +392,9 @@

    Module exchangelib.autodiscover.discovery

    def _is_valid_hostname(self, hostname): log.debug("Checking if %s can be looked up in DNS", hostname) try: - self.resolver.resolve(hostname) - except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.name.EmptyLabel): + self.resolver.resolve(f"{hostname}.", "A", lifetime=self.DNS_RESOLVER_ATTRS.get("timeout")) + except DNS_LOOKUP_ERRORS as e: + log.debug("DNS A lookup failure: %s", e) return False return True @@ -406,9 +414,9 @@

    Module exchangelib.autodiscover.discovery

    log.debug("Attempting to get SRV records for %s", hostname) records = [] try: - answers = self.resolver.resolve(f"{hostname}.", "SRV") - except (dns.resolver.NoNameservers, dns.resolver.NoAnswer, dns.resolver.NXDOMAIN) as e: - log.debug("DNS lookup failure: %s", e) + answers = self.resolver.resolve(f"{hostname}.", "SRV", lifetime=self.DNS_RESOLVER_ATTRS.get("timeout")) + except DNS_LOOKUP_ERRORS as e: + log.debug("DNS SRV lookup failure: %s", e) return records for rdata in answers: try: @@ -959,8 +967,9 @@

    Classes

    def _is_valid_hostname(self, hostname): log.debug("Checking if %s can be looked up in DNS", hostname) try: - self.resolver.resolve(hostname) - except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.name.EmptyLabel): + self.resolver.resolve(f"{hostname}.", "A", lifetime=self.DNS_RESOLVER_ATTRS.get("timeout")) + except DNS_LOOKUP_ERRORS as e: + log.debug("DNS A lookup failure: %s", e) return False return True @@ -980,9 +989,9 @@

    Classes

    log.debug("Attempting to get SRV records for %s", hostname) records = [] try: - answers = self.resolver.resolve(f"{hostname}.", "SRV") - except (dns.resolver.NoNameservers, dns.resolver.NoAnswer, dns.resolver.NXDOMAIN) as e: - log.debug("DNS lookup failure: %s", e) + answers = self.resolver.resolve(f"{hostname}.", "SRV", lifetime=self.DNS_RESOLVER_ATTRS.get("timeout")) + except DNS_LOOKUP_ERRORS as e: + log.debug("DNS SRV lookup failure: %s", e) return records for rdata in answers: try: diff --git a/docs/exchangelib/autodiscover/index.html b/docs/exchangelib/autodiscover/index.html index cc63d723..abc9faa0 100644 --- a/docs/exchangelib/autodiscover/index.html +++ b/docs/exchangelib/autodiscover/index.html @@ -665,8 +665,9 @@

    Inherited members

    def _is_valid_hostname(self, hostname): log.debug("Checking if %s can be looked up in DNS", hostname) try: - self.resolver.resolve(hostname) - except (dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.name.EmptyLabel): + self.resolver.resolve(f"{hostname}.", "A", lifetime=self.DNS_RESOLVER_ATTRS.get("timeout")) + except DNS_LOOKUP_ERRORS as e: + log.debug("DNS A lookup failure: %s", e) return False return True @@ -686,9 +687,9 @@

    Inherited members

    log.debug("Attempting to get SRV records for %s", hostname) records = [] try: - answers = self.resolver.resolve(f"{hostname}.", "SRV") - except (dns.resolver.NoNameservers, dns.resolver.NoAnswer, dns.resolver.NXDOMAIN) as e: - log.debug("DNS lookup failure: %s", e) + answers = self.resolver.resolve(f"{hostname}.", "SRV", lifetime=self.DNS_RESOLVER_ATTRS.get("timeout")) + except DNS_LOOKUP_ERRORS as e: + log.debug("DNS SRV lookup failure: %s", e) return records for rdata in answers: try: diff --git a/docs/exchangelib/configuration.html b/docs/exchangelib/configuration.html index 1cf59d9b..5feb396b 100644 --- a/docs/exchangelib/configuration.html +++ b/docs/exchangelib/configuration.html @@ -89,12 +89,12 @@

    Module exchangelib.configuration

    if auth_type is None: # Set a default auth type for the credentials where this makes sense auth_type = DEFAULT_AUTH_TYPE.get(type(credentials)) - elif credentials is None and auth_type in CREDENTIALS_REQUIRED: + if auth_type is not None and auth_type not in AUTH_TYPE_MAP: + raise InvalidEnumValue("auth_type", auth_type, AUTH_TYPE_MAP) + if credentials is None and auth_type in CREDENTIALS_REQUIRED: raise ValueError(f"Auth type {auth_type!r} was detected but no credentials were provided") if server and service_endpoint: raise AttributeError("Only one of 'server' or 'service_endpoint' must be provided") - if auth_type is not None and auth_type not in AUTH_TYPE_MAP: - raise InvalidEnumValue("auth_type", auth_type, AUTH_TYPE_MAP) if not retry_policy: retry_policy = FailFast() if not isinstance(version, (Version, type(None))): @@ -212,12 +212,12 @@

    Classes

    if auth_type is None: # Set a default auth type for the credentials where this makes sense auth_type = DEFAULT_AUTH_TYPE.get(type(credentials)) - elif credentials is None and auth_type in CREDENTIALS_REQUIRED: + if auth_type is not None and auth_type not in AUTH_TYPE_MAP: + raise InvalidEnumValue("auth_type", auth_type, AUTH_TYPE_MAP) + if credentials is None and auth_type in CREDENTIALS_REQUIRED: raise ValueError(f"Auth type {auth_type!r} was detected but no credentials were provided") if server and service_endpoint: raise AttributeError("Only one of 'server' or 'service_endpoint' must be provided") - if auth_type is not None and auth_type not in AUTH_TYPE_MAP: - raise InvalidEnumValue("auth_type", auth_type, AUTH_TYPE_MAP) if not retry_policy: retry_policy = FailFast() if not isinstance(version, (Version, type(None))): diff --git a/docs/exchangelib/credentials.html b/docs/exchangelib/credentials.html index 9543bca5..f3d4c078 100644 --- a/docs/exchangelib/credentials.html +++ b/docs/exchangelib/credentials.html @@ -141,21 +141,21 @@

    Module exchangelib.credentials

    the associated auth code grant type for multi-tenant applications. """ - def __init__(self, client_id, client_secret, tenant_id=None, identity=None): + def __init__(self, client_id, client_secret, tenant_id=None, identity=None, access_token=None): """ :param client_id: ID of an authorized OAuth application, required for automatic token fetching and refreshing :param client_secret: Secret associated with the OAuth application :param tenant_id: Microsoft tenant ID of the account to access :param identity: An Identity object representing the account that these credentials are connected to. + :param access_token: Previously-obtained access token, as a dict or an oauthlib.oauth2.OAuth2Token """ super().__init__() self.client_id = client_id self.client_secret = client_secret self.tenant_id = tenant_id self.identity = identity - # When set, access_token is a dict (or an oauthlib.oauth2.OAuth2Token, which is also a dict) - self.access_token = None + self.access_token = access_token def refresh(self, session): # Creating a new session gets a new access token, so there's no work here to refresh the credentials. This @@ -207,8 +207,8 @@

    Module exchangelib.credentials

    several ways: * Given an authorization code, client ID, and client secret, fetch a token ourselves and refresh it as needed if supplied with a refresh token. - * Given an existing access token, refresh token, client ID, and client secret, use the access token until it - expires and then refresh it as needed. + * Given an existing access token, client ID, and client secret, use the access token until it expires and then + refresh it as needed. * Given only an existing access token, use it until it expires. This can be used to let the calling application refresh tokens itself by subclassing and implementing refresh(). @@ -217,7 +217,7 @@

    Module exchangelib.credentials

    tenant. """ - def __init__(self, authorization_code=None, access_token=None, **kwargs): + def __init__(self, authorization_code=None, access_token=None, client_id=None, client_secret=None, **kwargs): """ :param client_id: ID of an authorized OAuth application, required for automatic token fetching and refreshing @@ -229,7 +229,7 @@

    Module exchangelib.credentials

    :param access_token: Previously-obtained access token. If a token exists and the application will handle refreshing by itself (or opts not to handle it), this parameter alone is sufficient. """ - super().__init__(**kwargs) + super().__init__(client_id=client_id, client_secret=client_secret, **kwargs) self.authorization_code = authorization_code if access_token is not None and not isinstance(access_token, dict): raise InvalidTypeError("access_token", access_token, OAuth2Token) @@ -442,15 +442,15 @@

    Inherited members

    class OAuth2AuthorizationCodeCredentials -(authorization_code=None, access_token=None, **kwargs) +(authorization_code=None, access_token=None, client_id=None, client_secret=None, **kwargs)

    Login info for OAuth 2.0 authentication using the authorization code grant type. This can be used in one of several ways: * Given an authorization code, client ID, and client secret, fetch a token ourselves and refresh it as needed if supplied with a refresh token. -* Given an existing access token, refresh token, client ID, and client secret, use the access token until it -expires and then refresh it as needed. +* Given an existing access token, client ID, and client secret, use the access token until it expires and then +refresh it as needed. * Given only an existing access token, use it until it expires. This can be used to let the calling application refresh tokens itself by subclassing and implementing refresh().

    Unlike the base (client credentials) grant, authorization code credentials don't require a Microsoft tenant ID @@ -473,8 +473,8 @@

    Inherited members

    several ways: * Given an authorization code, client ID, and client secret, fetch a token ourselves and refresh it as needed if supplied with a refresh token. - * Given an existing access token, refresh token, client ID, and client secret, use the access token until it - expires and then refresh it as needed. + * Given an existing access token, client ID, and client secret, use the access token until it expires and then + refresh it as needed. * Given only an existing access token, use it until it expires. This can be used to let the calling application refresh tokens itself by subclassing and implementing refresh(). @@ -483,7 +483,7 @@

    Inherited members

    tenant. """ - def __init__(self, authorization_code=None, access_token=None, **kwargs): + def __init__(self, authorization_code=None, access_token=None, client_id=None, client_secret=None, **kwargs): """ :param client_id: ID of an authorized OAuth application, required for automatic token fetching and refreshing @@ -495,7 +495,7 @@

    Inherited members

    :param access_token: Previously-obtained access token. If a token exists and the application will handle refreshing by itself (or opts not to handle it), this parameter alone is sufficient. """ - super().__init__(**kwargs) + super().__init__(client_id=client_id, client_secret=client_secret, **kwargs) self.authorization_code = authorization_code if access_token is not None and not isinstance(access_token, dict): raise InvalidTypeError("access_token", access_token, OAuth2Token) @@ -533,7 +533,7 @@

    Inherited members

    class OAuth2Credentials -(client_id, client_secret, tenant_id=None, identity=None) +(client_id, client_secret, tenant_id=None, identity=None, access_token=None)

    Login info for OAuth 2.0 client credentials authentication, as well as a base for other OAuth 2.0 grant types.

    @@ -544,7 +544,8 @@

    Inherited members

    :param client_id: ID of an authorized OAuth application, required for automatic token fetching and refreshing :param client_secret: Secret associated with the OAuth application :param tenant_id: Microsoft tenant ID of the account to access -:param identity: An Identity object representing the account that these credentials are connected to.

    +:param identity: An Identity object representing the account that these credentials are connected to. +:param access_token: Previously-obtained access token, as a dict or an oauthlib.oauth2.OAuth2Token

    Expand source code @@ -558,21 +559,21 @@

    Inherited members

    the associated auth code grant type for multi-tenant applications. """ - def __init__(self, client_id, client_secret, tenant_id=None, identity=None): + def __init__(self, client_id, client_secret, tenant_id=None, identity=None, access_token=None): """ :param client_id: ID of an authorized OAuth application, required for automatic token fetching and refreshing :param client_secret: Secret associated with the OAuth application :param tenant_id: Microsoft tenant ID of the account to access :param identity: An Identity object representing the account that these credentials are connected to. + :param access_token: Previously-obtained access token, as a dict or an oauthlib.oauth2.OAuth2Token """ super().__init__() self.client_id = client_id self.client_secret = client_secret self.tenant_id = tenant_id self.identity = identity - # When set, access_token is a dict (or an oauthlib.oauth2.OAuth2Token, which is also a dict) - self.access_token = None + self.access_token = access_token def refresh(self, session): # Creating a new session gets a new access token, so there's no work here to refresh the credentials. This diff --git a/docs/exchangelib/errors.html b/docs/exchangelib/errors.html index 704a5af7..dc7c9c6b 100644 --- a/docs/exchangelib/errors.html +++ b/docs/exchangelib/errors.html @@ -149,10 +149,6 @@

    Module exchangelib.errors

    pass -class TimezoneDefinitionInvalidForYear(EWSError): - pass - - class SessionPoolMinSizeReached(EWSError): pass @@ -1446,6 +1442,10 @@

    Module exchangelib.errors

    pass +class ErrorRecoverableItemsAccessDenied(ResponseMessageError): + pass + + class ErrorRecurrenceEndDateTooBig(ResponseMessageError): pass @@ -1720,7 +1720,7 @@

    Module exchangelib.errors

    pass -# Microsoft recommends to cache the autodiscover data around 24 hours and perform autodiscover +# Microsoft recommends caching the autodiscover data around 24 hours and perform autodiscover # immediately following certain error responses from EWS. See more at # http://blogs.msdn.com/b/mstehle/archive/2010/11/09/ews-best-practices-use-autodiscover.aspx @@ -1903,7 +1903,6 @@

    Subclasses

  • EWSWarning
  • SessionPoolMaxSizeReached
  • SessionPoolMinSizeReached
  • -
  • TimezoneDefinitionInvalidForYear
  • TransportError
  • UnauthorizedError
  • UnknownTimeZone
  • @@ -8881,6 +8880,28 @@

    Ancestors

  • builtins.BaseException
  • +
    +class ErrorRecoverableItemsAccessDenied +(value) +
    +
    +

    Global error type within this module.

    +
    + +Expand source code + +
    class ErrorRecoverableItemsAccessDenied(ResponseMessageError):
    +    pass
    +
    +

    Ancestors

    + +
    class ErrorRecurrenceEndDateTooBig (value) @@ -10914,6 +10935,7 @@

    Subclasses

  • ErrorQuotaExceeded
  • ErrorReadEventsFailed
  • ErrorReadReceiptNotPending
  • +
  • ErrorRecoverableItemsAccessDenied
  • ErrorRecurrenceEndDateTooBig
  • ErrorRecurrenceHasNoOccurrence
  • ErrorRemoveDelegatesFailed
  • @@ -11045,26 +11067,6 @@

    Ancestors

  • builtins.BaseException
  • -
    -class TimezoneDefinitionInvalidForYear -(value) -
    -
    -

    Global error type within this module.

    -
    - -Expand source code - -
    class TimezoneDefinitionInvalidForYear(EWSError):
    -    pass
    -
    -

    Ancestors

    -
      -
    • EWSError
    • -
    • builtins.Exception
    • -
    • builtins.BaseException
    • -
    -
    class TransportError (value) @@ -12121,6 +12123,9 @@

    ErrorReadReceiptNotPending

  • +

    ErrorRecoverableItemsAccessDenied

    +
  • +
  • ErrorRecurrenceEndDateTooBig

  • @@ -12361,9 +12366,6 @@

    SessionPoolMinSizeReached

  • -

    TimezoneDefinitionInvalidForYear

    -
  • -
  • TransportError

  • diff --git a/docs/exchangelib/ewsdatetime.html b/docs/exchangelib/ewsdatetime.html index f10a28fe..36fce92e 100644 --- a/docs/exchangelib/ewsdatetime.html +++ b/docs/exchangelib/ewsdatetime.html @@ -953,7 +953,7 @@

    Methods

    Ancestors

      -
    • backports.zoneinfo.ZoneInfo
    • +
    • zoneinfo.ZoneInfo
    • datetime.tzinfo

    Class variables

    diff --git a/docs/exchangelib/fields.html b/docs/exchangelib/fields.html index e67f562f..a52f863d 100644 --- a/docs/exchangelib/fields.html +++ b/docs/exchangelib/fields.html @@ -6611,7 +6611,10 @@

    Class variables

    var value_cls
    -

    Difference between two datetime values.

    +

    Difference between two datetime values.

    +

    timedelta(days=0, seconds=0, microseconds=0, milliseconds=0, minutes=0, hours=0, weeks=0)

    +

    All arguments are optional and default to 0. +Arguments may be integers or floats, and may be positive or negative.

    Methods

    diff --git a/docs/exchangelib/folders/base.html b/docs/exchangelib/folders/base.html index df412a1b..7d1b336a 100644 --- a/docs/exchangelib/folders/base.html +++ b/docs/exchangelib/folders/base.html @@ -38,6 +38,7 @@

    Module exchangelib.folders.base

    ErrorDeleteDistinguishedFolder, ErrorFolderNotFound, ErrorItemNotFound, + ErrorRecoverableItemsAccessDenied, InvalidTypeError, ) from ..fields import ( @@ -75,6 +76,13 @@

    Module exchangelib.folders.base

    log = logging.getLogger(__name__) +DELETE_FOLDER_ERRORS = ( + ErrorAccessDenied, + ErrorCannotDeleteObject, + ErrorCannotEmptyFolder, + ErrorItemNotFound, +) + class BaseFolder(RegisterMixIn, SearchableMixIn, metaclass=EWSMeta): """Base class for all classes that implement a folder.""" @@ -257,27 +265,41 @@

    Module exchangelib.folders.base

    :return: """ from .known_folders import ( + ApplicationData, Calendar, Contacts, ConversationSettings, + CrawlerData, + DlpPolicyEvaluation, + FreeBusyCache, GALContacts, Messages, RecipientCache, + RecoveryPoints, Reminders, RSSFeeds, + Signal, + SwssItems, Tasks, ) for folder_cls in ( - Messages, - Tasks, + ApplicationData, Calendar, - ConversationSettings, Contacts, + ConversationSettings, + CrawlerData, + DlpPolicyEvaluation, + FreeBusyCache, GALContacts, - Reminders, - RecipientCache, + Messages, RSSFeeds, + RecipientCache, + RecoveryPoints, + Reminders, + Signal, + SwssItems, + Tasks, ): if folder_cls.CONTAINER_CLASS == container_class: return folder_cls @@ -430,12 +452,21 @@

    Module exchangelib.folders.base

    def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0): # Recursively deletes all items in this folder, and all subfolders and their content. Attempts to protect # distinguished folders from being deleted. Use with caution! + from .known_folders import Audits + _seen = _seen or set() if self.id in _seen: raise RecursionError(f"We already tried to wipe {self}") if _level > 16: raise RecursionError(f"Max recursion level reached: {_level}") _seen.add(self.id) + if isinstance(self, Audits): + # Shortcircuit because this folder can have many items that are all non-deletable + log.warning("Cannot wipe audits folder %s", self) + return + if self.is_distinguished and "recoverableitems" in self.DISTINGUISHED_FOLDER_ID: + log.warning("Cannot wipe recoverable items folder %s", self) + return log.warning("Wiping %s", self) has_distinguished_subfolders = any(f.is_distinguished for f in self.children) try: @@ -443,12 +474,15 @@

    Module exchangelib.folders.base

    self.empty(delete_sub_folders=False) else: self.empty(delete_sub_folders=True) - except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): + except ErrorRecoverableItemsAccessDenied: + log.warning("Access denied to %s. Skipping", self) + return + except DELETE_FOLDER_ERRORS: try: if has_distinguished_subfolders: raise # We already tried this self.empty(delete_sub_folders=False) - except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): + except DELETE_FOLDER_ERRORS: log.warning("Not allowed to empty %s. Trying to delete items instead", self) kwargs = {} if page_size is not None: @@ -457,7 +491,7 @@

    Module exchangelib.folders.base

    kwargs["chunk_size"] = chunk_size try: self.all().delete(**kwargs) - except (ErrorAccessDenied, ErrorCannotDeleteObject, ErrorItemNotFound): + except DELETE_FOLDER_ERRORS: log.warning("Not allowed to delete items in %s", self) _level += 1 for f in self.children: @@ -1127,27 +1161,41 @@

    Classes

    :return: """ from .known_folders import ( + ApplicationData, Calendar, Contacts, ConversationSettings, + CrawlerData, + DlpPolicyEvaluation, + FreeBusyCache, GALContacts, Messages, RecipientCache, + RecoveryPoints, Reminders, RSSFeeds, + Signal, + SwssItems, Tasks, ) for folder_cls in ( - Messages, - Tasks, + ApplicationData, Calendar, - ConversationSettings, Contacts, + ConversationSettings, + CrawlerData, + DlpPolicyEvaluation, + FreeBusyCache, GALContacts, - Reminders, - RecipientCache, + Messages, RSSFeeds, + RecipientCache, + RecoveryPoints, + Reminders, + Signal, + SwssItems, + Tasks, ): if folder_cls.CONTAINER_CLASS == container_class: return folder_cls @@ -1300,12 +1348,21 @@

    Classes

    def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0): # Recursively deletes all items in this folder, and all subfolders and their content. Attempts to protect # distinguished folders from being deleted. Use with caution! + from .known_folders import Audits + _seen = _seen or set() if self.id in _seen: raise RecursionError(f"We already tried to wipe {self}") if _level > 16: raise RecursionError(f"Max recursion level reached: {_level}") _seen.add(self.id) + if isinstance(self, Audits): + # Shortcircuit because this folder can have many items that are all non-deletable + log.warning("Cannot wipe audits folder %s", self) + return + if self.is_distinguished and "recoverableitems" in self.DISTINGUISHED_FOLDER_ID: + log.warning("Cannot wipe recoverable items folder %s", self) + return log.warning("Wiping %s", self) has_distinguished_subfolders = any(f.is_distinguished for f in self.children) try: @@ -1313,12 +1370,15 @@

    Classes

    self.empty(delete_sub_folders=False) else: self.empty(delete_sub_folders=True) - except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): + except ErrorRecoverableItemsAccessDenied: + log.warning("Access denied to %s. Skipping", self) + return + except DELETE_FOLDER_ERRORS: try: if has_distinguished_subfolders: raise # We already tried this self.empty(delete_sub_folders=False) - except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): + except DELETE_FOLDER_ERRORS: log.warning("Not allowed to empty %s. Trying to delete items instead", self) kwargs = {} if page_size is not None: @@ -1327,7 +1387,7 @@

    Classes

    kwargs["chunk_size"] = chunk_size try: self.all().delete(**kwargs) - except (ErrorAccessDenied, ErrorCannotDeleteObject, ErrorItemNotFound): + except DELETE_FOLDER_ERRORS: log.warning("Not allowed to delete items in %s", self) _level += 1 for f in self.children: @@ -1777,27 +1837,41 @@

    Static methods

    :return: """ from .known_folders import ( + ApplicationData, Calendar, Contacts, ConversationSettings, + CrawlerData, + DlpPolicyEvaluation, + FreeBusyCache, GALContacts, Messages, RecipientCache, + RecoveryPoints, Reminders, RSSFeeds, + Signal, + SwssItems, Tasks, ) for folder_cls in ( - Messages, - Tasks, + ApplicationData, Calendar, - ConversationSettings, Contacts, + ConversationSettings, + CrawlerData, + DlpPolicyEvaluation, + FreeBusyCache, GALContacts, - Reminders, - RecipientCache, + Messages, RSSFeeds, + RecipientCache, + RecoveryPoints, + Reminders, + Signal, + SwssItems, + Tasks, ): if folder_cls.CONTAINER_CLASS == container_class: return folder_cls @@ -2788,12 +2862,21 @@

    Methods

    def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0):
         # Recursively deletes all items in this folder, and all subfolders and their content. Attempts to protect
         # distinguished folders from being deleted. Use with caution!
    +    from .known_folders import Audits
    +
         _seen = _seen or set()
         if self.id in _seen:
             raise RecursionError(f"We already tried to wipe {self}")
         if _level > 16:
             raise RecursionError(f"Max recursion level reached: {_level}")
         _seen.add(self.id)
    +    if isinstance(self, Audits):
    +        # Shortcircuit because this folder can have many items that are all non-deletable
    +        log.warning("Cannot wipe audits folder %s", self)
    +        return
    +    if self.is_distinguished and "recoverableitems" in self.DISTINGUISHED_FOLDER_ID:
    +        log.warning("Cannot wipe recoverable items folder %s", self)
    +        return
         log.warning("Wiping %s", self)
         has_distinguished_subfolders = any(f.is_distinguished for f in self.children)
         try:
    @@ -2801,12 +2884,15 @@ 

    Methods

    self.empty(delete_sub_folders=False) else: self.empty(delete_sub_folders=True) - except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): + except ErrorRecoverableItemsAccessDenied: + log.warning("Access denied to %s. Skipping", self) + return + except DELETE_FOLDER_ERRORS: try: if has_distinguished_subfolders: raise # We already tried this self.empty(delete_sub_folders=False) - except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): + except DELETE_FOLDER_ERRORS: log.warning("Not allowed to empty %s. Trying to delete items instead", self) kwargs = {} if page_size is not None: @@ -2815,7 +2901,7 @@

    Methods

    kwargs["chunk_size"] = chunk_size try: self.all().delete(**kwargs) - except (ErrorAccessDenied, ErrorCannotDeleteObject, ErrorItemNotFound): + except DELETE_FOLDER_ERRORS: log.warning("Not allowed to delete items in %s", self) _level += 1 for f in self.children: @@ -3005,17 +3091,22 @@

    Ancestors

    Subclasses

    • AllItems
    • +
    • ApplicationData
    • Audits
    • +
    • Birthdays
    • Calendar
    • CalendarLogging
    • CommonViews
    • Contacts
    • ConversationSettings
    • +
    • CrawlerData
    • DefaultFoldersChangeHistory
    • DeferredAction
    • DeletedItems
    • +
    • DlpPolicyEvaluation
    • ExchangeSyncData
    • Files
    • +
    • FreeBusyCache
    • FreebusyData
    • GraphAnalytics
    • Location
    • @@ -3025,13 +3116,16 @@

      Subclasses

    • PassThroughSearchResults
    • PdpProfileV2Secured
    • RSSFeeds
    • +
    • RecoveryPoints
    • Reminders
    • Schedule
    • Sharing
    • Shortcuts
    • Signal
    • +
    • SkypeTeamsMessages
    • SmsAndChatsSync
    • SpoolerQueue
    • +
    • SwssItems
    • System
    • System1
    • Tasks
    • diff --git a/docs/exchangelib/folders/index.html b/docs/exchangelib/folders/index.html index d29c8485..af9ef9c2 100644 --- a/docs/exchangelib/folders/index.html +++ b/docs/exchangelib/folders/index.html @@ -34,6 +34,7 @@

      Module exchangelib.folders

      AdminAuditLogs, AllContacts, AllItems, + ApplicationData, ArchiveDeletedItems, ArchiveInbox, ArchiveMsgFolderRoot, @@ -42,6 +43,7 @@

      Module exchangelib.folders

      ArchiveRecoverableItemsRoot, ArchiveRecoverableItemsVersions, Audits, + Birthdays, Calendar, CalendarLogging, CommonViews, @@ -50,14 +52,17 @@

      Module exchangelib.folders

      Contacts, ConversationHistory, ConversationSettings, + CrawlerData, DefaultFoldersChangeHistory, DeferredAction, DeletedItems, Directory, + DlpPolicyEvaluation, Drafts, ExchangeSyncData, Favorites, Files, + FreeBusyCache, FreebusyData, Friends, GALContacts, @@ -88,6 +93,7 @@

      Module exchangelib.folders

      RecoverableItemsPurges, RecoverableItemsRoot, RecoverableItemsVersions, + RecoveryPoints, Reminders, RSSFeeds, Schedule, @@ -97,8 +103,10 @@

      Module exchangelib.folders

      Sharing, Shortcuts, Signal, + SkypeTeamsMessages, SmsAndChatsSync, SpoolerQueue, + SwssItems, SyncIssues, System, Tasks, @@ -113,14 +121,10 @@

      Module exchangelib.folders

      from .roots import ArchiveRoot, PublicFoldersRoot, Root, RootOfHierarchy __all__ = [ - "FolderId", - "DistinguishedFolderId", - "FolderCollection", - "BaseFolder", - "Folder", "AdminAuditLogs", "AllContacts", "AllItems", + "ApplicationData", "ArchiveDeletedItems", "ArchiveInbox", "ArchiveMsgFolderRoot", @@ -128,22 +132,36 @@

      Module exchangelib.folders

      "ArchiveRecoverableItemsPurges", "ArchiveRecoverableItemsRoot", "ArchiveRecoverableItemsVersions", + "ArchiveRoot", "Audits", + "BaseFolder", + "Birthdays", "Calendar", "CalendarLogging", "CommonViews", + "Companies", "Conflicts", "Contacts", "ConversationHistory", "ConversationSettings", + "CrawlerData", + "DEEP", "DefaultFoldersChangeHistory", "DeferredAction", "DeletedItems", "Directory", + "DistinguishedFolderId", + "DlpPolicyEvaluation", "Drafts", "ExchangeSyncData", + "FOLDER_TRAVERSAL_CHOICES", "Favorites", "Files", + "Folder", + "FolderCollection", + "FolderId", + "FolderQuerySet", + "FreeBusyCache", "FreebusyData", "Friends", "GALContacts", @@ -159,13 +177,17 @@

      Module exchangelib.folders

      "MsgFolderRoot", "MyContacts", "MyContactsExtended", + "NON_DELETABLE_FOLDERS", "NonDeletableFolderMixin", "Notes", + "OrganizationalContacts", "Outbox", "ParkedMessages", "PassThroughSearchResults", "PdpProfileV2Secured", + "PeopleCentricConversationBuddies", "PeopleConnect", + "PublicFoldersRoot", "QuickContacts", "RSSFeeds", "RecipientCache", @@ -173,7 +195,12 @@

      Module exchangelib.folders

      "RecoverableItemsPurges", "RecoverableItemsRoot", "RecoverableItemsVersions", + "RecoveryPoints", "Reminders", + "Root", + "RootOfHierarchy", + "SHALLOW", + "SOFT_DELETED", "Schedule", "SearchFolders", "SentItems", @@ -181,8 +208,11 @@

      Module exchangelib.folders

      "Sharing", "Shortcuts", "Signal", + "SingleFolderQuerySet", + "SkypeTeamsMessages", "SmsAndChatsSync", "SpoolerQueue", + "SwssItems", "SyncIssues", "System", "Tasks", @@ -192,20 +222,6 @@

      Module exchangelib.folders

      "VoiceMail", "WellknownFolder", "WorkingSet", - "Companies", - "OrganizationalContacts", - "PeopleCentricConversationBuddies", - "NON_DELETABLE_FOLDERS", - "FolderQuerySet", - "SingleFolderQuerySet", - "FOLDER_TRAVERSAL_CHOICES", - "SHALLOW", - "DEEP", - "SOFT_DELETED", - "Root", - "ArchiveRoot", - "PublicFoldersRoot", - "RootOfHierarchy", ]
    @@ -475,6 +491,75 @@

    Inherited members

  • +
    +class ApplicationData +(**kwargs) +
    +
    +

    A mixin for non-wellknown folders than that are not deletable.

    +
    + +Expand source code + +
    class ApplicationData(NonDeletableFolderMixin, Folder):
    +    CONTAINER_CLASS = "IPM.ApplicationData"
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var CONTAINER_CLASS
    +
    +
    +
    +
    +

    Inherited members

    + +
    class ArchiveDeletedItems (**kwargs) @@ -1342,27 +1427,41 @@

    Inherited members

    :return: """ from .known_folders import ( + ApplicationData, Calendar, Contacts, ConversationSettings, + CrawlerData, + DlpPolicyEvaluation, + FreeBusyCache, GALContacts, Messages, RecipientCache, + RecoveryPoints, Reminders, RSSFeeds, + Signal, + SwssItems, Tasks, ) for folder_cls in ( - Messages, - Tasks, + ApplicationData, Calendar, - ConversationSettings, Contacts, + ConversationSettings, + CrawlerData, + DlpPolicyEvaluation, + FreeBusyCache, GALContacts, - Reminders, - RecipientCache, + Messages, RSSFeeds, + RecipientCache, + RecoveryPoints, + Reminders, + Signal, + SwssItems, + Tasks, ): if folder_cls.CONTAINER_CLASS == container_class: return folder_cls @@ -1515,12 +1614,21 @@

    Inherited members

    def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0): # Recursively deletes all items in this folder, and all subfolders and their content. Attempts to protect # distinguished folders from being deleted. Use with caution! + from .known_folders import Audits + _seen = _seen or set() if self.id in _seen: raise RecursionError(f"We already tried to wipe {self}") if _level > 16: raise RecursionError(f"Max recursion level reached: {_level}") _seen.add(self.id) + if isinstance(self, Audits): + # Shortcircuit because this folder can have many items that are all non-deletable + log.warning("Cannot wipe audits folder %s", self) + return + if self.is_distinguished and "recoverableitems" in self.DISTINGUISHED_FOLDER_ID: + log.warning("Cannot wipe recoverable items folder %s", self) + return log.warning("Wiping %s", self) has_distinguished_subfolders = any(f.is_distinguished for f in self.children) try: @@ -1528,12 +1636,15 @@

    Inherited members

    self.empty(delete_sub_folders=False) else: self.empty(delete_sub_folders=True) - except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): + except ErrorRecoverableItemsAccessDenied: + log.warning("Access denied to %s. Skipping", self) + return + except DELETE_FOLDER_ERRORS: try: if has_distinguished_subfolders: raise # We already tried this self.empty(delete_sub_folders=False) - except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): + except DELETE_FOLDER_ERRORS: log.warning("Not allowed to empty %s. Trying to delete items instead", self) kwargs = {} if page_size is not None: @@ -1542,7 +1653,7 @@

    Inherited members

    kwargs["chunk_size"] = chunk_size try: self.all().delete(**kwargs) - except (ErrorAccessDenied, ErrorCannotDeleteObject, ErrorItemNotFound): + except DELETE_FOLDER_ERRORS: log.warning("Not allowed to delete items in %s", self) _level += 1 for f in self.children: @@ -1992,27 +2103,41 @@

    Static methods

    :return: """ from .known_folders import ( + ApplicationData, Calendar, Contacts, ConversationSettings, + CrawlerData, + DlpPolicyEvaluation, + FreeBusyCache, GALContacts, Messages, RecipientCache, + RecoveryPoints, Reminders, RSSFeeds, + Signal, + SwssItems, Tasks, ) for folder_cls in ( - Messages, - Tasks, + ApplicationData, Calendar, - ConversationSettings, Contacts, + ConversationSettings, + CrawlerData, + DlpPolicyEvaluation, + FreeBusyCache, GALContacts, - Reminders, - RecipientCache, + Messages, RSSFeeds, + RecipientCache, + RecoveryPoints, + Reminders, + Signal, + SwssItems, + Tasks, ): if folder_cls.CONTAINER_CLASS == container_class: return folder_cls @@ -3003,12 +3128,21 @@

    Methods

    def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0):
         # Recursively deletes all items in this folder, and all subfolders and their content. Attempts to protect
         # distinguished folders from being deleted. Use with caution!
    +    from .known_folders import Audits
    +
         _seen = _seen or set()
         if self.id in _seen:
             raise RecursionError(f"We already tried to wipe {self}")
         if _level > 16:
             raise RecursionError(f"Max recursion level reached: {_level}")
         _seen.add(self.id)
    +    if isinstance(self, Audits):
    +        # Shortcircuit because this folder can have many items that are all non-deletable
    +        log.warning("Cannot wipe audits folder %s", self)
    +        return
    +    if self.is_distinguished and "recoverableitems" in self.DISTINGUISHED_FOLDER_ID:
    +        log.warning("Cannot wipe recoverable items folder %s", self)
    +        return
         log.warning("Wiping %s", self)
         has_distinguished_subfolders = any(f.is_distinguished for f in self.children)
         try:
    @@ -3016,12 +3150,15 @@ 

    Methods

    self.empty(delete_sub_folders=False) else: self.empty(delete_sub_folders=True) - except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): + except ErrorRecoverableItemsAccessDenied: + log.warning("Access denied to %s. Skipping", self) + return + except DELETE_FOLDER_ERRORS: try: if has_distinguished_subfolders: raise # We already tried this self.empty(delete_sub_folders=False) - except (ErrorAccessDenied, ErrorCannotEmptyFolder, ErrorItemNotFound): + except DELETE_FOLDER_ERRORS: log.warning("Not allowed to empty %s. Trying to delete items instead", self) kwargs = {} if page_size is not None: @@ -3030,7 +3167,7 @@

    Methods

    kwargs["chunk_size"] = chunk_size try: self.all().delete(**kwargs) - except (ErrorAccessDenied, ErrorCannotDeleteObject, ErrorItemNotFound): + except DELETE_FOLDER_ERRORS: log.warning("Not allowed to delete items in %s", self) _level += 1 for f in self.children: @@ -3069,6 +3206,82 @@

    Inherited members

    +
    +class Birthdays +(**kwargs) +
    +
    + +
    + +Expand source code + +
    class Birthdays(Folder):
    +    CONTAINER_CLASS = "IPF.Appointment.Birthday"
    +    LOCALIZED_NAMES = {
    +        None: ("Birthdays",),
    +        "da_DK": ("Fødselsdage",),
    +    }
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var CONTAINER_CLASS
    +
    +
    +
    +
    var LOCALIZED_NAMES
    +
    +
    +
    +
    +

    Inherited members

    + +
    class Calendar (**kwargs) @@ -3346,6 +3559,7 @@

    Inherited members

    CONTAINTER_CLASS = "IPF.Contact.Company" LOCALIZED_NAMES = { None: ("Companies",), + "da_DK": ("Firmaer",), }

    Ancestors

    @@ -3742,6 +3956,74 @@

    Inherited members

    +
    +class CrawlerData +(**kwargs) +
    +
    + +
    + +Expand source code + +
    class CrawlerData(Folder):
    +    CONTAINER_CLASS = "IPF.StoreItem.CrawlerData"
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var CONTAINER_CLASS
    +
    +
    +
    +
    +

    Inherited members

    + +
    class DefaultFoldersChangeHistory (**kwargs) @@ -4140,6 +4422,74 @@

    Inherited members

    +
    +class DlpPolicyEvaluation +(**kwargs) +
    +
    + +
    + +Expand source code + +
    class DlpPolicyEvaluation(Folder):
    +    CONTAINER_CLASS = "IPF.StoreItem.DlpPolicyEvaluation"
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var CONTAINER_CLASS
    +
    +
    +
    +
    +

    Inherited members

    + +
    class Drafts (**kwargs) @@ -4603,17 +4953,22 @@

    Ancestors

    Subclasses

    +
    +class SkypeTeamsMessages +(**kwargs) +
    +
    + +
    + +Expand source code + +
    class SkypeTeamsMessages(Folder):
    +    CONTAINER_CLASS = "IPF.SkypeTeams.Message"
    +    LOCALIZED_NAMES = {
    +        None: ("Team-chat",),
    +    }
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var CONTAINER_CLASS
    +
    +
    +
    +
    var LOCALIZED_NAMES
    +
    +
    +
    +
    +

    Inherited members

    + +
    class SmsAndChatsSync (**kwargs) @@ -10373,6 +10946,74 @@

    Inherited members

    +
    +class SwssItems +(**kwargs) +
    +
    + +
    + +Expand source code + +
    class SwssItems(Folder):
    +    CONTAINER_CLASS = "IPF.StoreItem.SwssItems"
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var CONTAINER_CLASS
    +
    +
    +
    +
    +

    Inherited members

    + +
    class SyncIssues (**kwargs) @@ -11153,6 +11794,12 @@

    ApplicationData

    + + +
  • ArchiveDeletedItems

    • DISTINGUISHED_FOLDER_ID
    • @@ -11290,6 +11937,13 @@

      Birthdays

      + + +
    • Calendar

      +
      +class SwssItems +(**kwargs) +
      +
      + +
      + +Expand source code + +
      class SwssItems(Folder):
      +    CONTAINER_CLASS = "IPF.StoreItem.SwssItems"
      +
      +

      Ancestors

      + +

      Class variables

      +
      +
      var CONTAINER_CLASS
      +
      +
      +
      +
      +

      Inherited members

      + +
      class SyncIssues (**kwargs) @@ -6859,6 +7476,12 @@

      ApplicationData

      + +
    • +
    • ArchiveDeletedItems

      • DISTINGUISHED_FOLDER_ID
      • @@ -6915,6 +7538,13 @@

        Birthdays

        + + +
      • Calendar

        • CONTAINER_CLASS
        • @@ -6976,6 +7606,12 @@

        • +

          CrawlerData

          + +
        • +
        • DefaultFoldersChangeHistory

          • CONTAINER_CLASS
          • @@ -7005,6 +7641,12 @@

            DlpPolicyEvaluation

            + + +
          • Drafts

            • DISTINGUISHED_FOLDER_ID
            • @@ -7033,6 +7675,12 @@

              FreeBusyCache

              + + +
            • FreebusyData

              • LOCALIZED_NAMES
              • @@ -7255,6 +7903,12 @@

              • +

                RecoveryPoints

                + +
              • +
              • Reminders

                • CONTAINER_CLASS
                • @@ -7308,6 +7962,13 @@

                  SkypeTeamsMessages

                  + + +
                • SmsAndChatsSync

                  • CONTAINER_CLASS
                  • @@ -7321,6 +7982,12 @@

                    SwssItems

                    + + +
                  • SyncIssues

                    • CONTAINER_CLASS
                    • diff --git a/docs/exchangelib/folders/roots.html b/docs/exchangelib/folders/roots.html index 557f7715..713a1971 100644 --- a/docs/exchangelib/folders/roots.html +++ b/docs/exchangelib/folders/roots.html @@ -36,6 +36,7 @@

                      Module exchangelib.folders.roots

                      from .base import BaseFolder from .collections import FolderCollection from .known_folders import ( + MISC_FOLDERS, NON_DELETABLE_FOLDERS, WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT, WELLKNOWN_FOLDERS_IN_ROOT, @@ -233,7 +234,7 @@

                      Module exchangelib.folders.roots

                      :param folder_name: :param locale: a string, e.g. 'da_DK' """ - for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS: + for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS + MISC_FOLDERS: if folder_name.lower() in folder_cls.localized_names(locale): return folder_cls raise KeyError() @@ -984,7 +985,7 @@

                      Inherited members

                      :param folder_name: :param locale: a string, e.g. 'da_DK' """ - for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS: + for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS + MISC_FOLDERS: if folder_name.lower() in folder_cls.localized_names(locale): return folder_cls raise KeyError() @@ -1050,7 +1051,7 @@

                      Static methods

                      :param folder_name: :param locale: a string, e.g. 'da_DK' """ - for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS: + for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS + MISC_FOLDERS: if folder_name.lower() in folder_cls.localized_names(locale): return folder_cls raise KeyError()
                      diff --git a/docs/exchangelib/index.html b/docs/exchangelib/index.html index 53a8450a..0cdb0afa 100644 --- a/docs/exchangelib/index.html +++ b/docs/exchangelib/index.html @@ -56,7 +56,7 @@

                      Package exchangelib

                      from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "4.7.2" +__version__ = "4.7.3" __all__ = [ "__version__", @@ -2932,38 +2932,29 @@

                      Inherited members

                      return session def create_oauth2_session(self): - has_token = False scope = ["https://outlook.office365.com/.default"] - session_params = {} + session_params = {"token": self.credentials.access_token} # Token may be None token_params = {} if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials): # Ask for a refresh token scope.append("offline_access") - # We don't know (or need) the Microsoft tenant ID. Use - # common/ to let Microsoft select the appropriate tenant - # for the provided authorization code or refresh token. + # We don't know (or need) the Microsoft tenant ID. Use common/ to let Microsoft select the appropriate + # tenant for the provided authorization code or refresh token. # # Suppress looks-like-password warning from Bandit. token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token" # nosec client_params = {} - has_token = self.credentials.access_token is not None - if has_token: - session_params["token"] = self.credentials.access_token - elif self.credentials.authorization_code is not None: - token_params["code"] = self.credentials.authorization_code - self.credentials.authorization_code = None + token_params["code"] = self.credentials.authorization_code # Auth code may be None + self.credentials.authorization_code = None # We can only use the code once if self.credentials.client_id is not None and self.credentials.client_secret is not None: - # If we're given a client ID and secret, we have enough - # to refresh access tokens ourselves. In other cases the - # session will raise TokenExpiredError and we'll need to - # ask the calling application to refresh the token (that - # covers cases where the caller doesn't have access to - # the client secret but is working with a service that - # can provide it refreshed tokens on a limited basis). + # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other + # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to + # refresh the token (that covers cases where the caller doesn't have access to the client secret but + # is working with a service that can provide it refreshed tokens on a limited basis). session_params.update( { "auto_refresh_kwargs": { @@ -2980,7 +2971,7 @@

                      Inherited members

                      client = BackendApplicationClient(client_id=self.credentials.client_id) session = self.raw_session(self.service_endpoint, oauth2_client=client, oauth2_session_params=session_params) - if not has_token: + if not session.token: # Fetch the token explicitly -- it doesn't occur implicitly token = session.fetch_token( token_url=token_url, @@ -2990,8 +2981,8 @@

                      Inherited members

                      timeout=self.TIMEOUT, **token_params, ) - # Allow the credentials object to update its copy of the new - # token, and give the application an opportunity to cache it + # Allow the credentials object to update its copy of the new token, and give the application an opportunity + # to cache it. self.credentials.on_token_auto_refreshed(token) session.auth = get_auth_instance(auth_type=OAUTH2, client=client) @@ -3237,38 +3228,29 @@

                      Methods

                      Expand source code
                      def create_oauth2_session(self):
                      -    has_token = False
                           scope = ["https://outlook.office365.com/.default"]
                      -    session_params = {}
                      +    session_params = {"token": self.credentials.access_token}  # Token may be None
                           token_params = {}
                       
                           if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials):
                               # Ask for a refresh token
                               scope.append("offline_access")
                       
                      -        # We don't know (or need) the Microsoft tenant ID. Use
                      -        # common/ to let Microsoft select the appropriate tenant
                      -        # for the provided authorization code or refresh token.
                      +        # We don't know (or need) the Microsoft tenant ID. Use common/ to let Microsoft select the appropriate
                      +        # tenant for the provided authorization code or refresh token.
                               #
                               # Suppress looks-like-password warning from Bandit.
                               token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token"  # nosec
                       
                               client_params = {}
                      -        has_token = self.credentials.access_token is not None
                      -        if has_token:
                      -            session_params["token"] = self.credentials.access_token
                      -        elif self.credentials.authorization_code is not None:
                      -            token_params["code"] = self.credentials.authorization_code
                      -            self.credentials.authorization_code = None
                      +        token_params["code"] = self.credentials.authorization_code  # Auth code may be None
                      +        self.credentials.authorization_code = None  # We can only use the code once
                       
                               if self.credentials.client_id is not None and self.credentials.client_secret is not None:
                      -            # If we're given a client ID and secret, we have enough
                      -            # to refresh access tokens ourselves. In other cases the
                      -            # session will raise TokenExpiredError and we'll need to
                      -            # ask the calling application to refresh the token (that
                      -            # covers cases where the caller doesn't have access to
                      -            # the client secret but is working with a service that
                      -            # can provide it refreshed tokens on a limited basis).
                      +            # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other
                      +            # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to
                      +            # refresh the token (that covers cases where the caller doesn't have access to the client secret but
                      +            # is working with a service that can provide it refreshed tokens on a limited basis).
                                   session_params.update(
                                       {
                                           "auto_refresh_kwargs": {
                      @@ -3285,7 +3267,7 @@ 

                      Methods

                      client = BackendApplicationClient(client_id=self.credentials.client_id) session = self.raw_session(self.service_endpoint, oauth2_client=client, oauth2_session_params=session_params) - if not has_token: + if not session.token: # Fetch the token explicitly -- it doesn't occur implicitly token = session.fetch_token( token_url=token_url, @@ -3295,8 +3277,8 @@

                      Methods

                      timeout=self.TIMEOUT, **token_params, ) - # Allow the credentials object to update its copy of the new - # token, and give the application an opportunity to cache it + # Allow the credentials object to update its copy of the new token, and give the application an opportunity + # to cache it. self.credentials.on_token_auto_refreshed(token) session.auth = get_auth_instance(auth_type=OAUTH2, client=client) @@ -4668,12 +4650,12 @@

                      Inherited members

                      if auth_type is None: # Set a default auth type for the credentials where this makes sense auth_type = DEFAULT_AUTH_TYPE.get(type(credentials)) - elif credentials is None and auth_type in CREDENTIALS_REQUIRED: + if auth_type is not None and auth_type not in AUTH_TYPE_MAP: + raise InvalidEnumValue("auth_type", auth_type, AUTH_TYPE_MAP) + if credentials is None and auth_type in CREDENTIALS_REQUIRED: raise ValueError(f"Auth type {auth_type!r} was detected but no credentials were provided") if server and service_endpoint: raise AttributeError("Only one of 'server' or 'service_endpoint' must be provided") - if auth_type is not None and auth_type not in AUTH_TYPE_MAP: - raise InvalidEnumValue("auth_type", auth_type, AUTH_TYPE_MAP) if not retry_policy: retry_policy = FailFast() if not isinstance(version, (Version, type(None))): @@ -5918,7 +5900,7 @@

                      Methods

                      Ancestors

                        -
                      • backports.zoneinfo.ZoneInfo
                      • +
                      • zoneinfo.ZoneInfo
                      • datetime.tzinfo

                      Class variables

                      @@ -7193,17 +7175,22 @@

                      Ancestors

                      Subclasses

                      • AllItems
                      • +
                      • ApplicationData
                      • Audits
                      • +
                      • Birthdays
                      • Calendar
                      • CalendarLogging
                      • CommonViews
                      • Contacts
                      • ConversationSettings
                      • +
                      • CrawlerData
                      • DefaultFoldersChangeHistory
                      • DeferredAction
                      • DeletedItems
                      • +
                      • DlpPolicyEvaluation
                      • ExchangeSyncData
                      • Files
                      • +
                      • FreeBusyCache
                      • FreebusyData
                      • GraphAnalytics
                      • Location
                      • @@ -7213,13 +7200,16 @@

                        Subclasses

                      • PassThroughSearchResults
                      • PdpProfileV2Secured
                      • RSSFeeds
                      • +
                      • RecoveryPoints
                      • Reminders
                      • Schedule
                      • Sharing
                      • Shortcuts
                      • Signal
                      • +
                      • SkypeTeamsMessages
                      • SmsAndChatsSync
                      • SpoolerQueue
                      • +
                      • SwssItems
                      • System
                      • System1
                      • Tasks
                      • @@ -8931,6 +8921,7 @@

                        Subclasses

                      • ConversationId
                      • FolderId
                      • MovedItemId
                      • +
                      • OldItemId
                      • ParentFolderId
                      • ParentItemId
                      • PersonaId
                      • @@ -9170,7 +9161,7 @@

                        Inherited members

                        conversation_topic = CharField(field_uri="message:ConversationTopic", is_read_only=True) # Rename 'From' to 'author'. We can't use fieldname 'from' since it's a Python keyword. author = MailboxField(field_uri="message:From", is_read_only_after_send=True) - message_id = CharField(field_uri="message:InternetMessageId", is_read_only_after_send=True) + message_id = TextField(field_uri="message:InternetMessageId", is_read_only_after_send=True) is_read = BooleanField(field_uri="message:IsRead", is_required=True, default=False) is_response_requested = BooleanField(field_uri="message:IsResponseRequested", default=False, is_required=True) references = TextField(field_uri="message:References") @@ -9302,7 +9293,7 @@

                        Inherited members

                        from ..services import MarkAsJunk res = MarkAsJunk(account=self.account).get( - items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item + items=[self], is_junk=is_junk, move_item=move_item, expect_result=None ) if res is None: return @@ -9476,7 +9467,7 @@

                        Methods

                        from ..services import MarkAsJunk res = MarkAsJunk(account=self.account).get( - items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item + items=[self], is_junk=is_junk, move_item=move_item, expect_result=None ) if res is None: return @@ -9672,15 +9663,15 @@

                        Methods

                        class OAuth2AuthorizationCodeCredentials -(authorization_code=None, access_token=None, **kwargs) +(authorization_code=None, access_token=None, client_id=None, client_secret=None, **kwargs)

                        Login info for OAuth 2.0 authentication using the authorization code grant type. This can be used in one of several ways: * Given an authorization code, client ID, and client secret, fetch a token ourselves and refresh it as needed if supplied with a refresh token. -* Given an existing access token, refresh token, client ID, and client secret, use the access token until it -expires and then refresh it as needed. +* Given an existing access token, client ID, and client secret, use the access token until it expires and then +refresh it as needed. * Given only an existing access token, use it until it expires. This can be used to let the calling application refresh tokens itself by subclassing and implementing refresh().

                        Unlike the base (client credentials) grant, authorization code credentials don't require a Microsoft tenant ID @@ -9703,8 +9694,8 @@

                        Methods

                        several ways: * Given an authorization code, client ID, and client secret, fetch a token ourselves and refresh it as needed if supplied with a refresh token. - * Given an existing access token, refresh token, client ID, and client secret, use the access token until it - expires and then refresh it as needed. + * Given an existing access token, client ID, and client secret, use the access token until it expires and then + refresh it as needed. * Given only an existing access token, use it until it expires. This can be used to let the calling application refresh tokens itself by subclassing and implementing refresh(). @@ -9713,7 +9704,7 @@

                        Methods

                        tenant. """ - def __init__(self, authorization_code=None, access_token=None, **kwargs): + def __init__(self, authorization_code=None, access_token=None, client_id=None, client_secret=None, **kwargs): """ :param client_id: ID of an authorized OAuth application, required for automatic token fetching and refreshing @@ -9725,7 +9716,7 @@

                        Methods

                        :param access_token: Previously-obtained access token. If a token exists and the application will handle refreshing by itself (or opts not to handle it), this parameter alone is sufficient. """ - super().__init__(**kwargs) + super().__init__(client_id=client_id, client_secret=client_secret, **kwargs) self.authorization_code = authorization_code if access_token is not None and not isinstance(access_token, dict): raise InvalidTypeError("access_token", access_token, OAuth2Token) @@ -9763,7 +9754,7 @@

                        Inherited members

                        class OAuth2Credentials -(client_id, client_secret, tenant_id=None, identity=None) +(client_id, client_secret, tenant_id=None, identity=None, access_token=None)

                        Login info for OAuth 2.0 client credentials authentication, as well as a base for other OAuth 2.0 grant types.

                        @@ -9774,7 +9765,8 @@

                        Inherited members

                        :param client_id: ID of an authorized OAuth application, required for automatic token fetching and refreshing :param client_secret: Secret associated with the OAuth application :param tenant_id: Microsoft tenant ID of the account to access -:param identity: An Identity object representing the account that these credentials are connected to.

                        +:param identity: An Identity object representing the account that these credentials are connected to. +:param access_token: Previously-obtained access token, as a dict or an oauthlib.oauth2.OAuth2Token

                        Expand source code @@ -9788,21 +9780,21 @@

                        Inherited members

                        the associated auth code grant type for multi-tenant applications. """ - def __init__(self, client_id, client_secret, tenant_id=None, identity=None): + def __init__(self, client_id, client_secret, tenant_id=None, identity=None, access_token=None): """ :param client_id: ID of an authorized OAuth application, required for automatic token fetching and refreshing :param client_secret: Secret associated with the OAuth application :param tenant_id: Microsoft tenant ID of the account to access :param identity: An Identity object representing the account that these credentials are connected to. + :param access_token: Previously-obtained access token, as a dict or an oauthlib.oauth2.OAuth2Token """ super().__init__() self.client_id = client_id self.client_secret = client_secret self.tenant_id = tenant_id self.identity = identity - # When set, access_token is a dict (or an oauthlib.oauth2.OAuth2Token, which is also a dict) - self.access_token = None + self.access_token = access_token def refresh(self, session): # Creating a new session gets a new access token, so there's no work here to refresh the credentials. This @@ -11597,7 +11589,7 @@

                        Inherited members

                        :param folder_name: :param locale: a string, e.g. 'da_DK' """ - for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS: + for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS + MISC_FOLDERS: if folder_name.lower() in folder_cls.localized_names(locale): return folder_cls raise KeyError() @@ -11663,7 +11655,7 @@

                        Static methods

                        :param folder_name: :param locale: a string, e.g. 'da_DK' """ - for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS: + for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS + MISC_FOLDERS: if folder_name.lower() in folder_cls.localized_names(locale): return folder_cls raise KeyError()
                      diff --git a/docs/exchangelib/items/contact.html b/docs/exchangelib/items/contact.html index cd576c2f..a479a399 100644 --- a/docs/exchangelib/items/contact.html +++ b/docs/exchangelib/items/contact.html @@ -809,7 +809,9 @@

                      Inherited members

                      hobbies = StringAttributedValueField(field_uri="persona:Hobbies") wedding_anniversaries = StringAttributedValueField(field_uri="persona:WeddingAnniversaries") birthdays = StringAttributedValueField(field_uri="persona:Birthdays") - locations = StringAttributedValueField(field_uri="persona:Locations") + locations = StringAttributedValueField(field_uri="persona:Locations") + # This class has an additional field of type "ExtendedPropertyAttributedValueField" and + # field_uri 'persona:ExtendedProperties'

                      Ancestors

                        diff --git a/docs/exchangelib/items/index.html b/docs/exchangelib/items/index.html index ca08feaa..b7463e18 100644 --- a/docs/exchangelib/items/index.html +++ b/docs/exchangelib/items/index.html @@ -3015,7 +3015,7 @@

                        Inherited members

                        conversation_topic = CharField(field_uri="message:ConversationTopic", is_read_only=True) # Rename 'From' to 'author'. We can't use fieldname 'from' since it's a Python keyword. author = MailboxField(field_uri="message:From", is_read_only_after_send=True) - message_id = CharField(field_uri="message:InternetMessageId", is_read_only_after_send=True) + message_id = TextField(field_uri="message:InternetMessageId", is_read_only_after_send=True) is_read = BooleanField(field_uri="message:IsRead", is_required=True, default=False) is_response_requested = BooleanField(field_uri="message:IsResponseRequested", default=False, is_required=True) references = TextField(field_uri="message:References") @@ -3147,7 +3147,7 @@

                        Inherited members

                        from ..services import MarkAsJunk res = MarkAsJunk(account=self.account).get( - items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item + items=[self], is_junk=is_junk, move_item=move_item, expect_result=None ) if res is None: return @@ -3321,7 +3321,7 @@

                        Methods

                        from ..services import MarkAsJunk res = MarkAsJunk(account=self.account).get( - items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item + items=[self], is_junk=is_junk, move_item=move_item, expect_result=None ) if res is None: return @@ -3576,7 +3576,9 @@

                        Inherited members

                        hobbies = StringAttributedValueField(field_uri="persona:Hobbies") wedding_anniversaries = StringAttributedValueField(field_uri="persona:WeddingAnniversaries") birthdays = StringAttributedValueField(field_uri="persona:Birthdays") - locations = StringAttributedValueField(field_uri="persona:Locations") + locations = StringAttributedValueField(field_uri="persona:Locations") + # This class has an additional field of type "ExtendedPropertyAttributedValueField" and + # field_uri 'persona:ExtendedProperties'

                        Ancestors

                          diff --git a/docs/exchangelib/items/message.html b/docs/exchangelib/items/message.html index a5aafdb6..e724dee9 100644 --- a/docs/exchangelib/items/message.html +++ b/docs/exchangelib/items/message.html @@ -65,7 +65,7 @@

                          Module exchangelib.items.message

                          conversation_topic = CharField(field_uri="message:ConversationTopic", is_read_only=True) # Rename 'From' to 'author'. We can't use fieldname 'from' since it's a Python keyword. author = MailboxField(field_uri="message:From", is_read_only_after_send=True) - message_id = CharField(field_uri="message:InternetMessageId", is_read_only_after_send=True) + message_id = TextField(field_uri="message:InternetMessageId", is_read_only_after_send=True) is_read = BooleanField(field_uri="message:IsRead", is_required=True, default=False) is_response_requested = BooleanField(field_uri="message:IsResponseRequested", default=False, is_required=True) references = TextField(field_uri="message:References") @@ -197,7 +197,7 @@

                          Module exchangelib.items.message

                          from ..services import MarkAsJunk res = MarkAsJunk(account=self.account).get( - items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item + items=[self], is_junk=is_junk, move_item=move_item, expect_result=None ) if res is None: return @@ -316,7 +316,7 @@

                          Inherited members

                          conversation_topic = CharField(field_uri="message:ConversationTopic", is_read_only=True) # Rename 'From' to 'author'. We can't use fieldname 'from' since it's a Python keyword. author = MailboxField(field_uri="message:From", is_read_only_after_send=True) - message_id = CharField(field_uri="message:InternetMessageId", is_read_only_after_send=True) + message_id = TextField(field_uri="message:InternetMessageId", is_read_only_after_send=True) is_read = BooleanField(field_uri="message:IsRead", is_required=True, default=False) is_response_requested = BooleanField(field_uri="message:IsResponseRequested", default=False, is_required=True) references = TextField(field_uri="message:References") @@ -448,7 +448,7 @@

                          Inherited members

                          from ..services import MarkAsJunk res = MarkAsJunk(account=self.account).get( - items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item + items=[self], is_junk=is_junk, move_item=move_item, expect_result=None ) if res is None: return @@ -622,7 +622,7 @@

                          Methods

                          from ..services import MarkAsJunk res = MarkAsJunk(account=self.account).get( - items=[self], is_junk=is_junk, move_item=move_item, expect_result=move_item + items=[self], is_junk=is_junk, move_item=move_item, expect_result=None ) if res is None: return diff --git a/docs/exchangelib/properties.html b/docs/exchangelib/properties.html index 8684a767..68480f6d 100644 --- a/docs/exchangelib/properties.html +++ b/docs/exchangelib/properties.html @@ -35,7 +35,7 @@

                          Module exchangelib.properties

                          from inspect import getmro from threading import Lock -from .errors import InvalidTypeError, TimezoneDefinitionInvalidForYear +from .errors import InvalidTypeError from .fields import ( WEEKDAY_NAMES, AssociatedCalendarItemIdField, @@ -247,15 +247,15 @@

                          Module exchangelib.properties

                          # Folder class, making the custom field available for subclasses). if local_fields: kwargs["FIELDS"] = fields - cls = super().__new__(mcs, name, bases, kwargs) - cls._slots_keys = mcs._get_slots_keys(cls) - return cls + klass = super().__new__(mcs, name, bases, kwargs) + klass._slots_keys = mcs._get_slots_keys(klass) + return klass @staticmethod - def _get_slots_keys(cls): + def _get_slots_keys(klass): seen = set() keys = [] - for c in reversed(getmro(cls)): + for c in reversed(getmro(klass)): if not hasattr(c, "__slots__"): continue for k in c.__slots__: @@ -610,6 +610,24 @@

                          Module exchangelib.properties

                          return item.id, item.changekey +class OldItemId(ItemId): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/oldfolderid""" + + ELEMENT_NAME = "OldItemId" + + +class OldFolderId(FolderId): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/olditemid""" + + ELEMENT_NAME = "OldFolderId" + + +class OldParentFolderId(ParentFolderId): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/oldparentfolderid""" + + ELEMENT_NAME = "OldParentFolderId" + + class Mailbox(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailbox""" @@ -1733,9 +1751,9 @@

                          Module exchangelib.properties

                          ITEM = "item" timestamp = DateTimeField(field_uri="TimeStamp") - item_id = EWSElementField(field_uri="ItemId", value_cls=ItemId) - folder_id = EWSElementField(field_uri="FolderId", value_cls=FolderId) - parent_folder_id = EWSElementField(field_uri="ParentFolderId", value_cls=ParentFolderId) + item_id = EWSElementField(value_cls=ItemId) + folder_id = EWSElementField(value_cls=FolderId) + parent_folder_id = EWSElementField(value_cls=ParentFolderId) @property def event_type(self): @@ -1749,9 +1767,9 @@

                          Module exchangelib.properties

                          class OldTimestampEvent(TimestampEvent, metaclass=EWSMeta): """Base class for both item and folder copy/move events.""" - old_item_id = EWSElementField(field_uri="OldItemId", value_cls=ItemId) - old_folder_id = EWSElementField(field_uri="OldFolderId", value_cls=FolderId) - old_parent_folder_id = EWSElementField(field_uri="OldParentFolderId", value_cls=ParentFolderId) + old_item_id = EWSElementField(value_cls=OldItemId) + old_folder_id = EWSElementField(value_cls=OldFolderId) + old_parent_folder_id = EWSElementField(value_cls=OldParentFolderId) class CopiedEvent(OldTimestampEvent): @@ -1892,18 +1910,6 @@

                          Module exchangelib.properties

                          name = CharField(field_uri="Name", is_attribute=True) bias = TimeDeltaField(field_uri="Bias", is_attribute=True) - def _split_id(self): - to_year, to_type = self.id.rsplit("/", 1)[1].split("-") - return int(to_year), to_type - - @property - def year(self): - return self._split_id()[0] - - @property - def type(self): - return self._split_id()[1] - @property def bias_in_minutes(self): return int(self.bias.total_seconds()) // 60 # Convert to minutes @@ -1934,18 +1940,15 @@

                          Module exchangelib.properties

                          def from_xml(cls, elem, account): return super().from_xml(elem, account) - def _get_standard_period(self, for_year): - # Look through periods and pick a relevant period according to the 'for_year' value - valid_period = None - for period in sorted(self.periods, key=lambda p: (p.year, p.type)): - if period.year > for_year: - break - if period.type != "Standard": + def _get_standard_period(self, transitions_group): + # Find the first standard period referenced from transitions_group + standard_periods_map = {p.id: p for p in self.periods if p.name == "Standard"} + for transition in transitions_group.transitions: + try: + return standard_periods_map[transition.to] + except KeyError: continue - valid_period = period - if valid_period is None: - raise TimezoneDefinitionInvalidForYear(f"Year {for_year} not included in periods {self.periods}") - return valid_period + raise ValueError(f"No standard period matching any transition in {transitions_group}") def _get_transitions_group(self, for_year): # Look through the transitions, and pick the relevant transition group according to the 'for_year' value @@ -1967,7 +1970,7 @@

                          Module exchangelib.properties

                          if not 0 <= len(transitions_group.transitions) <= 2: raise ValueError(f"Expected 0-2 transitions in transitions group {transitions_group}") - standard_period = self._get_standard_period(for_year) + standard_period = self._get_standard_period(transitions_group) periods_map = {p.id: p for p in self.periods} standard_time, daylight_time = None, None if len(transitions_group.transitions) == 1: @@ -3535,7 +3538,9 @@

                          Inherited members

                          class ConversationId(ItemId):
                               """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/conversationid"""
                           
                          -    ELEMENT_NAME = "ConversationId"
                          + ELEMENT_NAME = "ConversationId" + + # ChangeKey attribute is sometimes required, see MSDN link

                          Ancestors

                            @@ -4615,9 +4620,8 @@

                            Methods

                            (*args, **kwargs)
  • -

    type(object_or_name, bases, dict) -type(object) -> the object's type -type(name, bases, dict) -> a new type

    +

    type(object) -> the object's type +type(name, bases, dict, **kwds) -> a new type

    Expand source code @@ -4653,15 +4657,15 @@

    Methods

    # Folder class, making the custom field available for subclasses). if local_fields: kwargs["FIELDS"] = fields - cls = super().__new__(mcs, name, bases, kwargs) - cls._slots_keys = mcs._get_slots_keys(cls) - return cls + klass = super().__new__(mcs, name, bases, kwargs) + klass._slots_keys = mcs._get_slots_keys(klass) + return klass @staticmethod - def _get_slots_keys(cls): + def _get_slots_keys(klass): seen = set() keys = [] - for c in reversed(getmro(cls)): + for c in reversed(getmro(klass)): if not hasattr(c, "__slots__"): continue for k in c.__slots__: @@ -5435,6 +5439,7 @@

    Ancestors

    Subclasses

    Class variables

    @@ -6023,6 +6028,7 @@

    Subclasses

  • ConversationId
  • FolderId
  • MovedItemId
  • +
  • OldItemId
  • ParentFolderId
  • ParentItemId
  • PersonaId
  • @@ -6859,6 +6865,128 @@

    Inherited members

    +
    +class OldFolderId +(*args, **kwargs) +
    +
    + +
    + +Expand source code + +
    class OldFolderId(FolderId):
    +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/olditemid"""
    +
    +    ELEMENT_NAME = "OldFolderId"
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var ELEMENT_NAME
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class OldItemId +(*args, **kwargs) +
    +
    + +
    + +Expand source code + +
    class OldItemId(ItemId):
    +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/oldfolderid"""
    +
    +    ELEMENT_NAME = "OldItemId"
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var ELEMENT_NAME
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class OldParentFolderId +(*args, **kwargs) +
    +
    + +
    + +Expand source code + +
    class OldParentFolderId(ParentFolderId):
    +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/oldparentfolderid"""
    +
    +    ELEMENT_NAME = "OldParentFolderId"
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var ELEMENT_NAME
    +
    +
    +
    +
    +

    Inherited members

    + +
    class OldTimestampEvent (**kwargs) @@ -6872,9 +7000,9 @@

    Inherited members

    class OldTimestampEvent(TimestampEvent, metaclass=EWSMeta):
         """Base class for both item and folder copy/move events."""
     
    -    old_item_id = EWSElementField(field_uri="OldItemId", value_cls=ItemId)
    -    old_folder_id = EWSElementField(field_uri="OldFolderId", value_cls=FolderId)
    -    old_parent_folder_id = EWSElementField(field_uri="OldParentFolderId", value_cls=ParentFolderId)
    + old_item_id = EWSElementField(value_cls=OldItemId) + old_folder_id = EWSElementField(value_cls=OldFolderId) + old_parent_folder_id = EWSElementField(value_cls=OldParentFolderId)

    Ancestors

    +

    Subclasses

    +

    Class variables

    var ELEMENT_NAME
    @@ -7149,18 +7281,6 @@

    Inherited members

    name = CharField(field_uri="Name", is_attribute=True) bias = TimeDeltaField(field_uri="Bias", is_attribute=True) - def _split_id(self): - to_year, to_type = self.id.rsplit("/", 1)[1].split("-") - return int(to_year), to_type - - @property - def year(self): - return self._split_id()[0] - - @property - def type(self): - return self._split_id()[1] - @property def bias_in_minutes(self): return int(self.bias.total_seconds()) // 60 # Convert to minutes
    @@ -7206,30 +7326,6 @@

    Instance variables

    -
    var type
    -
    -
    -
    - -Expand source code - -
    @property
    -def type(self):
    -    return self._split_id()[1]
    -
    -
    -
    var year
    -
    -
    -
    - -Expand source code - -
    @property
    -def year(self):
    -    return self._split_id()[0]
    -
    -

    Inherited members

  • +

    PostReplyItem

    + +
  • +
  • Q

    • AND
    • @@ -13427,4 +13665,4 @@

      Version

      Generated by pdoc 0.10.0.

      - \ No newline at end of file + diff --git a/docs/exchangelib/items/calendar_item.html b/docs/exchangelib/items/calendar_item.html index e11ddafb..bdac4734 100644 --- a/docs/exchangelib/items/calendar_item.html +++ b/docs/exchangelib/items/calendar_item.html @@ -438,16 +438,24 @@

      Module exchangelib.items.calendar_item

      @require_account def send(self, message_disposition=SEND_AND_SAVE_COPY): - # Some responses contain multiple response IDs, e.g. MeetingRequest.accept(). Return either the single ID or - # the list of IDs. from ..services import CreateItem - return CreateItem(account=self.account).get( - items=[self], - folder=self.folder, - message_disposition=message_disposition, - send_meeting_invitations=SEND_TO_NONE, + res = list( + CreateItem(account=self.account).call( + items=[self], + folder=self.folder, + message_disposition=message_disposition, + send_meeting_invitations=SEND_TO_NONE, + ) ) + for r in res: + if isinstance(r, Exception): + raise r + # CreateItem may return multiple item IDs when given a meeting reply item. See issue#886. In lack of a better + # idea, return either the single ID or the list of IDs here. + if len(res) == 1: + return res[0] + return res class AcceptItem(BaseMeetingReplyItem): @@ -859,16 +867,24 @@

      Inherited members

      @require_account def send(self, message_disposition=SEND_AND_SAVE_COPY): - # Some responses contain multiple response IDs, e.g. MeetingRequest.accept(). Return either the single ID or - # the list of IDs. from ..services import CreateItem - return CreateItem(account=self.account).get( - items=[self], - folder=self.folder, - message_disposition=message_disposition, - send_meeting_invitations=SEND_TO_NONE, - )
      + res = list( + CreateItem(account=self.account).call( + items=[self], + folder=self.folder, + message_disposition=message_disposition, + send_meeting_invitations=SEND_TO_NONE, + ) + ) + for r in res: + if isinstance(r, Exception): + raise r + # CreateItem may return multiple item IDs when given a meeting reply item. See issue#886. In lack of a better + # idea, return either the single ID or the list of IDs here. + if len(res) == 1: + return res[0] + return res

      Ancestors

        @@ -970,16 +986,24 @@

        Methods

        @require_account
         def send(self, message_disposition=SEND_AND_SAVE_COPY):
        -    # Some responses contain multiple response IDs, e.g. MeetingRequest.accept(). Return either the single ID or
        -    # the list of IDs.
             from ..services import CreateItem
         
        -    return CreateItem(account=self.account).get(
        -        items=[self],
        -        folder=self.folder,
        -        message_disposition=message_disposition,
        -        send_meeting_invitations=SEND_TO_NONE,
        -    )
        + res = list( + CreateItem(account=self.account).call( + items=[self], + folder=self.folder, + message_disposition=message_disposition, + send_meeting_invitations=SEND_TO_NONE, + ) + ) + for r in res: + if isinstance(r, Exception): + raise r + # CreateItem may return multiple item IDs when given a meeting reply item. See issue#886. In lack of a better + # idea, return either the single ID or the list of IDs here. + if len(res) == 1: + return res[0] + return res
        @@ -2494,4 +2518,4 @@

        Generated by pdoc 0.10.0.

        - \ No newline at end of file + diff --git a/docs/exchangelib/properties.html b/docs/exchangelib/properties.html index 68480f6d..e08d1a58 100644 --- a/docs/exchangelib/properties.html +++ b/docs/exchangelib/properties.html @@ -1455,23 +1455,20 @@

        Module exchangelib.properties

        """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/responseobjects""" ELEMENT_NAME = "ResponseObjects" - NAMESPACE = EWSElement.NAMESPACE - accept_item = EWSElementField(field_uri="AcceptItem", value_cls="AcceptItem", namespace=NAMESPACE) + accept_item = EWSElementField(field_uri="AcceptItem", value_cls="AcceptItem", namespace=TNS) tentatively_accept_item = EWSElementField( - field_uri="TentativelyAcceptItem", value_cls="TentativelyAcceptItem", namespace=NAMESPACE + field_uri="TentativelyAcceptItem", value_cls="TentativelyAcceptItem", namespace=TNS ) - decline_item = EWSElementField(field_uri="DeclineItem", value_cls="DeclineItem", namespace=NAMESPACE) - reply_to_item = EWSElementField(field_uri="ReplyToItem", value_cls="ReplyToItem", namespace=NAMESPACE) - forward_item = EWSElementField(field_uri="ForwardItem", value_cls="ForwardItem", namespace=NAMESPACE) - reply_all_to_item = EWSElementField(field_uri="ReplyAllToItem", value_cls="ReplyAllToItem", namespace=NAMESPACE) + decline_item = EWSElementField(field_uri="DeclineItem", value_cls="DeclineItem", namespace=TNS) + reply_to_item = EWSElementField(field_uri="ReplyToItem", value_cls="ReplyToItem", namespace=TNS) + forward_item = EWSElementField(field_uri="ForwardItem", value_cls="ForwardItem", namespace=TNS) + reply_all_to_item = EWSElementField(field_uri="ReplyAllToItem", value_cls="ReplyAllToItem", namespace=TNS) cancel_calendar_item = EWSElementField( - field_uri="CancelCalendarItem", value_cls="CancelCalendarItem", namespace=NAMESPACE + field_uri="CancelCalendarItem", value_cls="CancelCalendarItem", namespace=TNS ) remove_item = EWSElementField(field_uri="RemoveItem", value_cls=RemoveItem) - post_reply_item = EWSElementField( - field_uri="PostReplyItem", value_cls="PostReplyItem", namespace=EWSElement.NAMESPACE - ) + post_reply_item = EWSElementField(field_uri="PostReplyItem", value_cls="PostReplyItem", namespace=TNS) success_read_receipt = EWSElementField(field_uri="SuppressReadReceipt", value_cls=SuppressReadReceipt) accept_sharing_invitation = EWSElementField(field_uri="AcceptSharingInvitation", value_cls=AcceptSharingInvitation) @@ -8351,23 +8348,20 @@

        Inherited members

        """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/responseobjects""" ELEMENT_NAME = "ResponseObjects" - NAMESPACE = EWSElement.NAMESPACE - accept_item = EWSElementField(field_uri="AcceptItem", value_cls="AcceptItem", namespace=NAMESPACE) + accept_item = EWSElementField(field_uri="AcceptItem", value_cls="AcceptItem", namespace=TNS) tentatively_accept_item = EWSElementField( - field_uri="TentativelyAcceptItem", value_cls="TentativelyAcceptItem", namespace=NAMESPACE + field_uri="TentativelyAcceptItem", value_cls="TentativelyAcceptItem", namespace=TNS ) - decline_item = EWSElementField(field_uri="DeclineItem", value_cls="DeclineItem", namespace=NAMESPACE) - reply_to_item = EWSElementField(field_uri="ReplyToItem", value_cls="ReplyToItem", namespace=NAMESPACE) - forward_item = EWSElementField(field_uri="ForwardItem", value_cls="ForwardItem", namespace=NAMESPACE) - reply_all_to_item = EWSElementField(field_uri="ReplyAllToItem", value_cls="ReplyAllToItem", namespace=NAMESPACE) + decline_item = EWSElementField(field_uri="DeclineItem", value_cls="DeclineItem", namespace=TNS) + reply_to_item = EWSElementField(field_uri="ReplyToItem", value_cls="ReplyToItem", namespace=TNS) + forward_item = EWSElementField(field_uri="ForwardItem", value_cls="ForwardItem", namespace=TNS) + reply_all_to_item = EWSElementField(field_uri="ReplyAllToItem", value_cls="ReplyAllToItem", namespace=TNS) cancel_calendar_item = EWSElementField( - field_uri="CancelCalendarItem", value_cls="CancelCalendarItem", namespace=NAMESPACE + field_uri="CancelCalendarItem", value_cls="CancelCalendarItem", namespace=TNS ) remove_item = EWSElementField(field_uri="RemoveItem", value_cls=RemoveItem) - post_reply_item = EWSElementField( - field_uri="PostReplyItem", value_cls="PostReplyItem", namespace=EWSElement.NAMESPACE - ) + post_reply_item = EWSElementField(field_uri="PostReplyItem", value_cls="PostReplyItem", namespace=TNS) success_read_receipt = EWSElementField(field_uri="SuppressReadReceipt", value_cls=SuppressReadReceipt) accept_sharing_invitation = EWSElementField(field_uri="AcceptSharingInvitation", value_cls=AcceptSharingInvitation)
        @@ -8385,10 +8379,6 @@

        Class variables

        -
        var NAMESPACE
        -
        -
        -

        Instance variables

        @@ -11140,7 +11130,6 @@

      • ELEMENT_NAME
      • FIELDS
      • -
      • NAMESPACE
      • accept_item
      • accept_sharing_invitation
      • cancel_calendar_item
      • @@ -11382,4 +11371,4 @@

        pdoc 0.10.0.

        - \ No newline at end of file + diff --git a/docs/exchangelib/protocol.html b/docs/exchangelib/protocol.html index ce1a0410..21306507 100644 --- a/docs/exchangelib/protocol.html +++ b/docs/exchangelib/protocol.html @@ -340,25 +340,14 @@

        Module exchangelib.protocol

        return session def create_oauth2_session(self): - scope = ["https://outlook.office365.com/.default"] session_params = {"token": self.credentials.access_token} # Token may be None token_params = {} if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials): - # Ask for a refresh token - scope.append("offline_access") - - # We don't know (or need) the Microsoft tenant ID. Use common/ to let Microsoft select the appropriate - # tenant for the provided authorization code or refresh token. - # - # Suppress looks-like-password warning from Bandit. - token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token" # nosec - - client_params = {} token_params["code"] = self.credentials.authorization_code # Auth code may be None self.credentials.authorization_code = None # We can only use the code once - if self.credentials.client_id is not None and self.credentials.client_secret is not None: + if self.credentials.client_id and self.credentials.client_secret: # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to # refresh the token (that covers cases where the caller doesn't have access to the client secret but @@ -369,23 +358,22 @@

        Module exchangelib.protocol

        "client_id": self.credentials.client_id, "client_secret": self.credentials.client_secret, }, - "auto_refresh_url": token_url, + "auto_refresh_url": self.credentials.token_url, "token_updater": self.credentials.on_token_auto_refreshed, } ) - client = WebApplicationClient(self.credentials.client_id, **client_params) + client = WebApplicationClient(client_id=self.credentials.client_id) else: - token_url = f"https://login.microsoftonline.com/{self.credentials.tenant_id}/oauth2/v2.0/token" client = BackendApplicationClient(client_id=self.credentials.client_id) session = self.raw_session(self.service_endpoint, oauth2_client=client, oauth2_session_params=session_params) if not session.token: # Fetch the token explicitly -- it doesn't occur implicitly token = session.fetch_token( - token_url=token_url, + token_url=self.credentials.token_url, client_id=self.credentials.client_id, client_secret=self.credentials.client_secret, - scope=scope, + scope=self.credentials.scope, timeout=self.TIMEOUT, **token_params, ) @@ -1130,25 +1118,14 @@

        Classes

        return session def create_oauth2_session(self): - scope = ["https://outlook.office365.com/.default"] session_params = {"token": self.credentials.access_token} # Token may be None token_params = {} if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials): - # Ask for a refresh token - scope.append("offline_access") - - # We don't know (or need) the Microsoft tenant ID. Use common/ to let Microsoft select the appropriate - # tenant for the provided authorization code or refresh token. - # - # Suppress looks-like-password warning from Bandit. - token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token" # nosec - - client_params = {} token_params["code"] = self.credentials.authorization_code # Auth code may be None self.credentials.authorization_code = None # We can only use the code once - if self.credentials.client_id is not None and self.credentials.client_secret is not None: + if self.credentials.client_id and self.credentials.client_secret: # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to # refresh the token (that covers cases where the caller doesn't have access to the client secret but @@ -1159,23 +1136,22 @@

        Classes

        "client_id": self.credentials.client_id, "client_secret": self.credentials.client_secret, }, - "auto_refresh_url": token_url, + "auto_refresh_url": self.credentials.token_url, "token_updater": self.credentials.on_token_auto_refreshed, } ) - client = WebApplicationClient(self.credentials.client_id, **client_params) + client = WebApplicationClient(client_id=self.credentials.client_id) else: - token_url = f"https://login.microsoftonline.com/{self.credentials.tenant_id}/oauth2/v2.0/token" client = BackendApplicationClient(client_id=self.credentials.client_id) session = self.raw_session(self.service_endpoint, oauth2_client=client, oauth2_session_params=session_params) if not session.token: # Fetch the token explicitly -- it doesn't occur implicitly token = session.fetch_token( - token_url=token_url, + token_url=self.credentials.token_url, client_id=self.credentials.client_id, client_secret=self.credentials.client_secret, - scope=scope, + scope=self.credentials.scope, timeout=self.TIMEOUT, **token_params, ) @@ -1426,25 +1402,14 @@

        Methods

        Expand source code
        def create_oauth2_session(self):
        -    scope = ["https://outlook.office365.com/.default"]
             session_params = {"token": self.credentials.access_token}  # Token may be None
             token_params = {}
         
             if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials):
        -        # Ask for a refresh token
        -        scope.append("offline_access")
        -
        -        # We don't know (or need) the Microsoft tenant ID. Use common/ to let Microsoft select the appropriate
        -        # tenant for the provided authorization code or refresh token.
        -        #
        -        # Suppress looks-like-password warning from Bandit.
        -        token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token"  # nosec
        -
        -        client_params = {}
                 token_params["code"] = self.credentials.authorization_code  # Auth code may be None
                 self.credentials.authorization_code = None  # We can only use the code once
         
        -        if self.credentials.client_id is not None and self.credentials.client_secret is not None:
        +        if self.credentials.client_id and self.credentials.client_secret:
                     # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other
                     # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to
                     # refresh the token (that covers cases where the caller doesn't have access to the client secret but
        @@ -1455,23 +1420,22 @@ 

        Methods

        "client_id": self.credentials.client_id, "client_secret": self.credentials.client_secret, }, - "auto_refresh_url": token_url, + "auto_refresh_url": self.credentials.token_url, "token_updater": self.credentials.on_token_auto_refreshed, } ) - client = WebApplicationClient(self.credentials.client_id, **client_params) + client = WebApplicationClient(client_id=self.credentials.client_id) else: - token_url = f"https://login.microsoftonline.com/{self.credentials.tenant_id}/oauth2/v2.0/token" client = BackendApplicationClient(client_id=self.credentials.client_id) session = self.raw_session(self.service_endpoint, oauth2_client=client, oauth2_session_params=session_params) if not session.token: # Fetch the token explicitly -- it doesn't occur implicitly token = session.fetch_token( - token_url=token_url, + token_url=self.credentials.token_url, client_id=self.credentials.client_id, client_secret=self.credentials.client_secret, - scope=scope, + scope=self.credentials.scope, timeout=self.TIMEOUT, **token_params, ) @@ -2845,4 +2809,4 @@

        pdoc 0.10.0.

        - \ No newline at end of file + diff --git a/docs/exchangelib/services/index.html b/docs/exchangelib/services/index.html index dac091e4..59812f89 100644 --- a/docs/exchangelib/services/index.html +++ b/docs/exchangelib/services/index.html @@ -4814,9 +4814,11 @@

        Inherited members

        ERRORS_TO_CATCH_IN_RESPONSE = ErrorNameResolutionNoResults WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults # Note: paging information is returned as attrs on the 'ResolutionSet' element, but this service does not - # support the 'IndexedPageItemView' element, so it's not really a paging service. According to docs, at most - # 100 candidates are returned for a lookup. + # support the 'IndexedPageItemView' element, so it's not really a paging service. supports_paging = False + # According to the 'Remarks' section of the MSDN documentation referenced above, at most 100 candidates are + # returned for a lookup. + candidates_limit = 100 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -4851,6 +4853,20 @@

        Inherited members

        ) ) + def _get_element_container(self, message, name=None): + container_or_exc = super()._get_element_container(message=message, name=name) + if isinstance(container_or_exc, Exception): + return container_or_exc + is_last_page = container_or_exc.get("IncludesLastItemInRange").lower() in ("true", "0") + log.debug("Includes last item in range: %s", is_last_page) + if not is_last_page: + warnings.warn( + f"The {self.__class__.__name__} service returns at most {self.candidates_limit} candidates and does " + f"not support paging. You have reached this limit and have not received the exhaustive list of " + f"candidates." + ) + return container_or_exc + def _elem_to_obj(self, elem): if self.return_full_contact_data: mailbox_elem = elem.find(Mailbox.response_tag()) @@ -4900,6 +4916,10 @@

        Class variables

        Global error type within this module.

        +
        var candidates_limit
        +
        +
        +
        var element_container_name
        @@ -7008,6 +7028,7 @@

        SERVICE_NAME
      • WARNINGS_TO_IGNORE_IN_RESPONSE
      • call
      • +
      • candidates_limit
      • element_container_name
      • get_payload
      • supports_paging
      • @@ -7156,4 +7177,4 @@

        pdoc 0.10.0.

        - \ No newline at end of file + diff --git a/docs/exchangelib/services/resolve_names.html b/docs/exchangelib/services/resolve_names.html index 8a998907..0fe3ad02 100644 --- a/docs/exchangelib/services/resolve_names.html +++ b/docs/exchangelib/services/resolve_names.html @@ -27,6 +27,7 @@

        Module exchangelib.services.resolve_names

        Expand source code
        import logging
        +import warnings
         
         from ..errors import ErrorNameResolutionMultipleResults, ErrorNameResolutionNoResults, InvalidEnumValue
         from ..items import SEARCH_SCOPE_CHOICES, SHAPE_CHOICES, Contact
        @@ -46,9 +47,11 @@ 

        Module exchangelib.services.resolve_names

        ERRORS_TO_CATCH_IN_RESPONSE = ErrorNameResolutionNoResults WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults # Note: paging information is returned as attrs on the 'ResolutionSet' element, but this service does not - # support the 'IndexedPageItemView' element, so it's not really a paging service. According to docs, at most - # 100 candidates are returned for a lookup. + # support the 'IndexedPageItemView' element, so it's not really a paging service. supports_paging = False + # According to the 'Remarks' section of the MSDN documentation referenced above, at most 100 candidates are + # returned for a lookup. + candidates_limit = 100 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -83,6 +86,20 @@

        Module exchangelib.services.resolve_names

        ) ) + def _get_element_container(self, message, name=None): + container_or_exc = super()._get_element_container(message=message, name=name) + if isinstance(container_or_exc, Exception): + return container_or_exc + is_last_page = container_or_exc.get("IncludesLastItemInRange").lower() in ("true", "0") + log.debug("Includes last item in range: %s", is_last_page) + if not is_last_page: + warnings.warn( + f"The {self.__class__.__name__} service returns at most {self.candidates_limit} candidates and does " + f"not support paging. You have reached this limit and have not received the exhaustive list of " + f"candidates." + ) + return container_or_exc + def _elem_to_obj(self, elem): if self.return_full_contact_data: mailbox_elem = elem.find(Mailbox.response_tag()) @@ -142,9 +159,11 @@

        Classes

        ERRORS_TO_CATCH_IN_RESPONSE = ErrorNameResolutionNoResults WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults # Note: paging information is returned as attrs on the 'ResolutionSet' element, but this service does not - # support the 'IndexedPageItemView' element, so it's not really a paging service. According to docs, at most - # 100 candidates are returned for a lookup. + # support the 'IndexedPageItemView' element, so it's not really a paging service. supports_paging = False + # According to the 'Remarks' section of the MSDN documentation referenced above, at most 100 candidates are + # returned for a lookup. + candidates_limit = 100 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -179,6 +198,20 @@

        Classes

        ) ) + def _get_element_container(self, message, name=None): + container_or_exc = super()._get_element_container(message=message, name=name) + if isinstance(container_or_exc, Exception): + return container_or_exc + is_last_page = container_or_exc.get("IncludesLastItemInRange").lower() in ("true", "0") + log.debug("Includes last item in range: %s", is_last_page) + if not is_last_page: + warnings.warn( + f"The {self.__class__.__name__} service returns at most {self.candidates_limit} candidates and does " + f"not support paging. You have reached this limit and have not received the exhaustive list of " + f"candidates." + ) + return container_or_exc + def _elem_to_obj(self, elem): if self.return_full_contact_data: mailbox_elem = elem.find(Mailbox.response_tag()) @@ -228,6 +261,10 @@

        Class variables

        Global error type within this module.

        +
        var candidates_limit
        +
        +
        +
        var element_container_name
        @@ -345,6 +382,7 @@

        SERVICE_NAME
      • WARNINGS_TO_IGNORE_IN_RESPONSE
      • call
      • +
      • candidates_limit
      • element_container_name
      • get_payload
      • supports_paging
      • @@ -359,4 +397,4 @@

        pdoc 0.10.0.

        - \ No newline at end of file + From 6c4a0fe1667b8a115fc26e8c46151c7473efc9a3 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 21 Jul 2022 10:58:23 +0200 Subject: [PATCH 249/509] chore: Bump version --- CHANGELOG.md | 6 +++++- exchangelib/__init__.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f45fdca..07a277df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ HEAD ---- +4.7.4 +----- +- Bugfix release + + 4.7.3 ----- - Bugfix release @@ -841,4 +846,3 @@ shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFold --- - Initial import - diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index 072b77f6..49a722eb 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -29,7 +29,7 @@ from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "4.7.3" +__version__ = "4.7.4" __all__ = [ "__version__", From cc07d08ac5f97a8545bf640063e0b8a688f35c64 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 3 Aug 2022 07:13:09 +0200 Subject: [PATCH 250/509] fix: Chunk requests when get_free_busy_info() is called with +100 accounts. Fixes #1101 --- CHANGELOG.md | 1 + exchangelib/protocol.py | 2 +- exchangelib/services/get_user_availability.py | 13 ++++---- tests/test_protocol.py | 32 +++++++++++++++---- 4 files changed, 34 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07a277df..462f2f1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Change Log HEAD ---- +- Fixed `Protocol.get_free_busy_info()` when called with +100 accounts. 4.7.4 diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 9ffe30d5..61da3977 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -502,7 +502,6 @@ def get_free_busy_info(self, accounts, start, end, merged_free_busy_interval=30, tz_definition = list(self.get_timezones(timezones=[start.tzinfo], return_full_timezone_data=True))[0] return GetUserAvailability(self).call( - timezone=TimeZone.from_server_timezone(tz_definition=tz_definition, for_year=start.year), mailbox_data=[ MailboxData( email=account.primary_smtp_address if isinstance(account, Account) else account, @@ -511,6 +510,7 @@ def get_free_busy_info(self, accounts, start, end, merged_free_busy_interval=30, ) for account, attendee_type, exclude_conflicts in accounts ], + timezone=TimeZone.from_server_timezone(tz_definition=tz_definition, for_year=start.year), free_busy_view_options=FreeBusyViewOptions( time_window=TimeWindow(start=start, end=end), merged_free_busy_interval=merged_free_busy_interval, diff --git a/exchangelib/services/get_user_availability.py b/exchangelib/services/get_user_availability.py index 83bf617e..975e3bab 100644 --- a/exchangelib/services/get_user_availability.py +++ b/exchangelib/services/get_user_availability.py @@ -11,21 +11,22 @@ class GetUserAvailability(EWSService): SERVICE_NAME = "GetUserAvailability" - def call(self, timezone, mailbox_data, free_busy_view_options): + def call(self, mailbox_data, timezone, free_busy_view_options): # TODO: Also supports SuggestionsViewOptions, see # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/suggestionsviewoptions return self._elems_to_objs( - self._get_elements( - payload=self.get_payload( - timezone=timezone, mailbox_data=mailbox_data, free_busy_view_options=free_busy_view_options - ) + self._chunked_get_elements( + self.get_payload, + items=mailbox_data, + timezone=timezone, + free_busy_view_options=free_busy_view_options, ) ) def _elem_to_obj(self, elem): return FreeBusyView.from_xml(elem=elem, account=None) - def get_payload(self, timezone, mailbox_data, free_busy_view_options): + def get_payload(self, mailbox_data, timezone, free_busy_view_options): payload = create_element(f"m:{self.SERVICE_NAME}Request") set_xml_value(payload, timezone, version=self.protocol.version) mailbox_data_array = create_element("m:MailboxDataArray") diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 77f78604..14cd09be 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -356,31 +356,41 @@ def test_get_free_busy_info(self): accounts = [(self.account, "Organizer", False)] with self.assertRaises(TypeError) as e: - self.account.protocol.get_free_busy_info(accounts=[(123, "XXX", "XXX")], start=start, end=end) + list(self.account.protocol.get_free_busy_info(accounts=[(123, "XXX", "XXX")], start=start, end=end)) self.assertEqual( e.exception.args[0], "Field 'email' value 123 must be of type " ) with self.assertRaises(ValueError) as e: - self.account.protocol.get_free_busy_info(accounts=[(self.account, "XXX", "XXX")], start=start, end=end) + list( + self.account.protocol.get_free_busy_info(accounts=[(self.account, "XXX", "XXX")], start=start, end=end) + ) self.assertEqual( e.exception.args[0], f"Invalid choice 'XXX' for field 'attendee_type'. Valid choices are {sorted(MailboxData.ATTENDEE_TYPES)}", ) with self.assertRaises(TypeError) as e: - self.account.protocol.get_free_busy_info(accounts=[(self.account, "Organizer", "X")], start=start, end=end) + list( + self.account.protocol.get_free_busy_info( + accounts=[(self.account, "Organizer", "X")], start=start, end=end + ) + ) self.assertEqual(e.exception.args[0], "Field 'exclude_conflicts' value 'X' must be of type ") with self.assertRaises(ValueError) as e: - self.account.protocol.get_free_busy_info(accounts=accounts, start=end, end=start) + list(self.account.protocol.get_free_busy_info(accounts=accounts, start=end, end=start)) self.assertIn("'start' must be less than 'end'", e.exception.args[0]) with self.assertRaises(TypeError) as e: - self.account.protocol.get_free_busy_info( - accounts=accounts, start=start, end=end, merged_free_busy_interval="XXX" + list( + self.account.protocol.get_free_busy_info( + accounts=accounts, start=start, end=end, merged_free_busy_interval="XXX" + ) ) self.assertEqual( e.exception.args[0], "Field 'merged_free_busy_interval' value 'XXX' must be of type " ) with self.assertRaises(ValueError) as e: - self.account.protocol.get_free_busy_info(accounts=accounts, start=start, end=end, requested_view="XXX") + list( + self.account.protocol.get_free_busy_info(accounts=accounts, start=start, end=end, requested_view="XXX") + ) self.assertEqual( e.exception.args[0], f"Invalid choice 'XXX' for field 'requested_view'. Valid choices are " @@ -407,6 +417,14 @@ def test_get_free_busy_info(self): ): self.assertIsInstance(view_info, ErrorMailRecipientNotFound) + # Test +100 addresses + for view_info in self.account.protocol.get_free_busy_info( + accounts=[(f"unknown-{i}-{self.account.primary_smtp_address}", "Organizer", False) for i in range(101)], + start=start, + end=end, + ): + self.assertIsInstance(view_info, ErrorMailRecipientNotFound) + # Test non-existing and existing address view_infos = list( self.account.protocol.get_free_busy_info( From 5b6656de7f01de289bff9293ddfb0efa2e0cf110 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 4 Aug 2022 08:13:48 +0200 Subject: [PATCH 251/509] feat: make timeout and lifetime settings of DNS resolver individually configurable. Fixes #1102 --- CHANGELOG.md | 3 +++ exchangelib/autodiscover/discovery.py | 7 ++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 462f2f1a..e58b41f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Change Log HEAD ---- - Fixed `Protocol.get_free_busy_info()` when called with +100 accounts. +- Allowed configuring DNS timeout for a single nameserver + (`Autodiscovery.DNS_RESOLVER_ATTRS["timeout""]`) and the total query lifetime + (`Autodiscovery.DNS_RESOLVER_LIFETIME`) separately. 4.7.4 diff --git a/exchangelib/autodiscover/discovery.py b/exchangelib/autodiscover/discovery.py index 4c0960ee..eb3ba661 100644 --- a/exchangelib/autodiscover/discovery.py +++ b/exchangelib/autodiscover/discovery.py @@ -88,8 +88,9 @@ class Autodiscovery: MAX_REDIRECTS = 10 # Maximum number of URL redirects before we give up DNS_RESOLVER_KWARGS = {} DNS_RESOLVER_ATTRS = { - "timeout": AutodiscoverProtocol.TIMEOUT, + "timeout": AutodiscoverProtocol.TIMEOUT / 2.5, # Timeout for query to a single nameserver } + DNS_RESOLVER_LIFETIME = AutodiscoverProtocol.TIMEOUT # Total timeout for a query in case of multiple nameservers def __init__(self, email, credentials=None): """ @@ -364,7 +365,7 @@ def _attempt_response(self, url): def _is_valid_hostname(self, hostname): log.debug("Checking if %s can be looked up in DNS", hostname) try: - self.resolver.resolve(f"{hostname}.", "A", lifetime=self.DNS_RESOLVER_ATTRS.get("timeout")) + self.resolver.resolve(f"{hostname}.", "A", lifetime=self.DNS_RESOLVER_LIFETIME) except DNS_LOOKUP_ERRORS as e: log.debug("DNS A lookup failure: %s", e) return False @@ -386,7 +387,7 @@ def _get_srv_records(self, hostname): log.debug("Attempting to get SRV records for %s", hostname) records = [] try: - answers = self.resolver.resolve(f"{hostname}.", "SRV", lifetime=self.DNS_RESOLVER_ATTRS.get("timeout")) + answers = self.resolver.resolve(f"{hostname}.", "SRV", lifetime=self.DNS_RESOLVER_LIFETIME) except DNS_LOOKUP_ERRORS as e: log.debug("DNS SRV lookup failure: %s", e) return records From f73143cd3b0741a009216bb489e52b21dd70925b Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 9 Aug 2022 10:00:09 +0200 Subject: [PATCH 252/509] When a session is retired, make sure to also clear access token (#1105) Refs #1090 --- exchangelib/protocol.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 61da3977..3377704c 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -245,8 +245,12 @@ def release_session(self, session): session = self.renew_session(session) self._session_pool.put(session, block=False) - @staticmethod - def close_session(session): + def close_session(self, session): + if isinstance(self.credentials, OAuth2Credentials) and not isinstance( + self.credentials, OAuth2AuthorizationCodeCredentials + ): + # Reset token if client is of type BackendApplicationClient + self.credentials.access_token = None session.close() del session From 2a1cb326f5211b45ec0948a24ee8f516acbdae3d Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 9 Aug 2022 10:10:48 +0200 Subject: [PATCH 253/509] chore: fix linter warning --- exchangelib/util.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/exchangelib/util.py b/exchangelib/util.py index 908dbee1..057bd513 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -573,8 +573,7 @@ def is_xml(text, expected_prefix=b" Date: Tue, 9 Aug 2022 10:13:47 +0200 Subject: [PATCH 254/509] docs: regenerate --- docs/exchangelib/autodiscover/discovery.html | 21 ++++--- docs/exchangelib/autodiscover/index.html | 14 +++-- docs/exchangelib/index.html | 44 +++++++++------ docs/exchangelib/protocol.html | 56 +++++++++++-------- .../services/get_user_availability.html | 45 ++++++++------- docs/exchangelib/services/index.html | 30 +++++----- docs/exchangelib/util.html | 35 ++++++------ 7 files changed, 140 insertions(+), 105 deletions(-) diff --git a/docs/exchangelib/autodiscover/discovery.html b/docs/exchangelib/autodiscover/discovery.html index 9054f269..e7a5801b 100644 --- a/docs/exchangelib/autodiscover/discovery.html +++ b/docs/exchangelib/autodiscover/discovery.html @@ -116,8 +116,9 @@

        Module exchangelib.autodiscover.discovery

        MAX_REDIRECTS = 10 # Maximum number of URL redirects before we give up DNS_RESOLVER_KWARGS = {} DNS_RESOLVER_ATTRS = { - "timeout": AutodiscoverProtocol.TIMEOUT, + "timeout": AutodiscoverProtocol.TIMEOUT / 2.5, # Timeout for query to a single nameserver } + DNS_RESOLVER_LIFETIME = AutodiscoverProtocol.TIMEOUT # Total timeout for a query in case of multiple nameservers def __init__(self, email, credentials=None): """ @@ -392,7 +393,7 @@

        Module exchangelib.autodiscover.discovery

        def _is_valid_hostname(self, hostname): log.debug("Checking if %s can be looked up in DNS", hostname) try: - self.resolver.resolve(f"{hostname}.", "A", lifetime=self.DNS_RESOLVER_ATTRS.get("timeout")) + self.resolver.resolve(f"{hostname}.", "A", lifetime=self.DNS_RESOLVER_LIFETIME) except DNS_LOOKUP_ERRORS as e: log.debug("DNS A lookup failure: %s", e) return False @@ -414,7 +415,7 @@

        Module exchangelib.autodiscover.discovery

        log.debug("Attempting to get SRV records for %s", hostname) records = [] try: - answers = self.resolver.resolve(f"{hostname}.", "SRV", lifetime=self.DNS_RESOLVER_ATTRS.get("timeout")) + answers = self.resolver.resolve(f"{hostname}.", "SRV", lifetime=self.DNS_RESOLVER_LIFETIME) except DNS_LOOKUP_ERRORS as e: log.debug("DNS SRV lookup failure: %s", e) return records @@ -691,8 +692,9 @@

        Classes

        MAX_REDIRECTS = 10 # Maximum number of URL redirects before we give up DNS_RESOLVER_KWARGS = {} DNS_RESOLVER_ATTRS = { - "timeout": AutodiscoverProtocol.TIMEOUT, + "timeout": AutodiscoverProtocol.TIMEOUT / 2.5, # Timeout for query to a single nameserver } + DNS_RESOLVER_LIFETIME = AutodiscoverProtocol.TIMEOUT # Total timeout for a query in case of multiple nameservers def __init__(self, email, credentials=None): """ @@ -967,7 +969,7 @@

        Classes

        def _is_valid_hostname(self, hostname): log.debug("Checking if %s can be looked up in DNS", hostname) try: - self.resolver.resolve(f"{hostname}.", "A", lifetime=self.DNS_RESOLVER_ATTRS.get("timeout")) + self.resolver.resolve(f"{hostname}.", "A", lifetime=self.DNS_RESOLVER_LIFETIME) except DNS_LOOKUP_ERRORS as e: log.debug("DNS A lookup failure: %s", e) return False @@ -989,7 +991,7 @@

        Classes

        log.debug("Attempting to get SRV records for %s", hostname) records = [] try: - answers = self.resolver.resolve(f"{hostname}.", "SRV", lifetime=self.DNS_RESOLVER_ATTRS.get("timeout")) + answers = self.resolver.resolve(f"{hostname}.", "SRV", lifetime=self.DNS_RESOLVER_LIFETIME) except DNS_LOOKUP_ERRORS as e: log.debug("DNS SRV lookup failure: %s", e) return records @@ -1166,6 +1168,10 @@

        Class variables

        +
        var DNS_RESOLVER_LIFETIME
        +
        +
        +
        var INITIAL_RETRY_POLICY
        @@ -1323,6 +1329,7 @@

      • DNS_RESOLVER_ATTRS
      • DNS_RESOLVER_KWARGS
      • +
      • DNS_RESOLVER_LIFETIME
      • INITIAL_RETRY_POLICY
      • MAX_REDIRECTS
      • RETRY_WAIT
      • @@ -1343,4 +1350,4 @@

        pdoc 0.10.0.

        - \ No newline at end of file + diff --git a/docs/exchangelib/autodiscover/index.html b/docs/exchangelib/autodiscover/index.html index abc9faa0..3c8c9c9d 100644 --- a/docs/exchangelib/autodiscover/index.html +++ b/docs/exchangelib/autodiscover/index.html @@ -389,8 +389,9 @@

        Inherited members

        MAX_REDIRECTS = 10 # Maximum number of URL redirects before we give up DNS_RESOLVER_KWARGS = {} DNS_RESOLVER_ATTRS = { - "timeout": AutodiscoverProtocol.TIMEOUT, + "timeout": AutodiscoverProtocol.TIMEOUT / 2.5, # Timeout for query to a single nameserver } + DNS_RESOLVER_LIFETIME = AutodiscoverProtocol.TIMEOUT # Total timeout for a query in case of multiple nameservers def __init__(self, email, credentials=None): """ @@ -665,7 +666,7 @@

        Inherited members

        def _is_valid_hostname(self, hostname): log.debug("Checking if %s can be looked up in DNS", hostname) try: - self.resolver.resolve(f"{hostname}.", "A", lifetime=self.DNS_RESOLVER_ATTRS.get("timeout")) + self.resolver.resolve(f"{hostname}.", "A", lifetime=self.DNS_RESOLVER_LIFETIME) except DNS_LOOKUP_ERRORS as e: log.debug("DNS A lookup failure: %s", e) return False @@ -687,7 +688,7 @@

        Inherited members

        log.debug("Attempting to get SRV records for %s", hostname) records = [] try: - answers = self.resolver.resolve(f"{hostname}.", "SRV", lifetime=self.DNS_RESOLVER_ATTRS.get("timeout")) + answers = self.resolver.resolve(f"{hostname}.", "SRV", lifetime=self.DNS_RESOLVER_LIFETIME) except DNS_LOOKUP_ERRORS as e: log.debug("DNS SRV lookup failure: %s", e) return records @@ -864,6 +865,10 @@

        Class variables

        +
        var DNS_RESOLVER_LIFETIME
        +
        +
        +
        var INITIAL_RETRY_POLICY
        @@ -1021,6 +1026,7 @@

      • DNS_RESOLVER_ATTRS
      • DNS_RESOLVER_KWARGS
      • +
      • DNS_RESOLVER_LIFETIME
      • INITIAL_RETRY_POLICY
      • MAX_REDIRECTS
      • RETRY_WAIT
      • @@ -1038,4 +1044,4 @@

        pdoc 0.10.0.

        - \ No newline at end of file + diff --git a/docs/exchangelib/index.html b/docs/exchangelib/index.html index 77d1859a..4302b105 100644 --- a/docs/exchangelib/index.html +++ b/docs/exchangelib/index.html @@ -57,7 +57,7 @@

        Package exchangelib

        from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "4.7.4" +__version__ = "4.7.5" __all__ = [ "__version__", @@ -2870,8 +2870,12 @@

        Inherited members

        session = self.renew_session(session) self._session_pool.put(session, block=False) - @staticmethod - def close_session(session): + def close_session(self, session): + if isinstance(self.credentials, OAuth2Credentials) and not isinstance( + self.credentials, OAuth2AuthorizationCodeCredentials + ): + # Reset token if client is of type BackendApplicationClient + self.credentials.access_token = None session.close() del session @@ -3051,21 +3055,6 @@

        Class variables

        Static methods

        -
        -def close_session(session) -
        -
        -
        -
        - -Expand source code - -
        @staticmethod
        -def close_session(session):
        -    session.close()
        -    del session
        -
        -
        def get_adapter()
        @@ -3208,6 +3197,25 @@

        Methods

        break
        +
        +def close_session(self, session) +
        +
        +
        +
        + +Expand source code + +
        def close_session(self, session):
        +    if isinstance(self.credentials, OAuth2Credentials) and not isinstance(
        +        self.credentials, OAuth2AuthorizationCodeCredentials
        +    ):
        +        # Reset token if client is of type BackendApplicationClient
        +        self.credentials.access_token = None
        +    session.close()
        +    del session
        +
        +
        def create_oauth2_session(self)
        diff --git a/docs/exchangelib/protocol.html b/docs/exchangelib/protocol.html index 21306507..cece96d0 100644 --- a/docs/exchangelib/protocol.html +++ b/docs/exchangelib/protocol.html @@ -276,8 +276,12 @@

        Module exchangelib.protocol

        session = self.renew_session(session) self._session_pool.put(session, block=False) - @staticmethod - def close_session(session): + def close_session(self, session): + if isinstance(self.credentials, OAuth2Credentials) and not isinstance( + self.credentials, OAuth2AuthorizationCodeCredentials + ): + # Reset token if client is of type BackendApplicationClient + self.credentials.access_token = None session.close() del session @@ -533,7 +537,6 @@

        Module exchangelib.protocol

        tz_definition = list(self.get_timezones(timezones=[start.tzinfo], return_full_timezone_data=True))[0] return GetUserAvailability(self).call( - timezone=TimeZone.from_server_timezone(tz_definition=tz_definition, for_year=start.year), mailbox_data=[ MailboxData( email=account.primary_smtp_address if isinstance(account, Account) else account, @@ -542,6 +545,7 @@

        Module exchangelib.protocol

        ) for account, attendee_type, exclude_conflicts in accounts ], + timezone=TimeZone.from_server_timezone(tz_definition=tz_definition, for_year=start.year), free_busy_view_options=FreeBusyViewOptions( time_window=TimeWindow(start=start, end=end), merged_free_busy_interval=merged_free_busy_interval, @@ -1054,8 +1058,12 @@

        Classes

        session = self.renew_session(session) self._session_pool.put(session, block=False) - @staticmethod - def close_session(session): + def close_session(self, session): + if isinstance(self.credentials, OAuth2Credentials) and not isinstance( + self.credentials, OAuth2AuthorizationCodeCredentials + ): + # Reset token if client is of type BackendApplicationClient + self.credentials.access_token = None session.close() del session @@ -1235,21 +1243,6 @@

        Class variables

        Static methods

        -
        -def close_session(session) -
        -
        -
        -
        - -Expand source code - -
        @staticmethod
        -def close_session(session):
        -    session.close()
        -    del session
        -
        -
        def get_adapter()
        @@ -1392,6 +1385,25 @@

        Methods

        break
        +
        +def close_session(self, session) +
        +
        +
        +
        + +Expand source code + +
        def close_session(self, session):
        +    if isinstance(self.credentials, OAuth2Credentials) and not isinstance(
        +        self.credentials, OAuth2AuthorizationCodeCredentials
        +    ):
        +        # Reset token if client is of type BackendApplicationClient
        +        self.credentials.access_token = None
        +    session.close()
        +    del session
        +
        +
        def create_oauth2_session(self)
        @@ -2103,7 +2115,6 @@

        Methods

        tz_definition = list(self.get_timezones(timezones=[start.tzinfo], return_full_timezone_data=True))[0] return GetUserAvailability(self).call( - timezone=TimeZone.from_server_timezone(tz_definition=tz_definition, for_year=start.year), mailbox_data=[ MailboxData( email=account.primary_smtp_address if isinstance(account, Account) else account, @@ -2112,6 +2123,7 @@

        Methods

        ) for account, attendee_type, exclude_conflicts in accounts ], + timezone=TimeZone.from_server_timezone(tz_definition=tz_definition, for_year=start.year), free_busy_view_options=FreeBusyViewOptions( time_window=TimeWindow(start=start, end=end), merged_free_busy_interval=merged_free_busy_interval, @@ -2319,7 +2331,6 @@

        Methods

        tz_definition = list(self.get_timezones(timezones=[start.tzinfo], return_full_timezone_data=True))[0] return GetUserAvailability(self).call( - timezone=TimeZone.from_server_timezone(tz_definition=tz_definition, for_year=start.year), mailbox_data=[ MailboxData( email=account.primary_smtp_address if isinstance(account, Account) else account, @@ -2328,6 +2339,7 @@

        Methods

        ) for account, attendee_type, exclude_conflicts in accounts ], + timezone=TimeZone.from_server_timezone(tz_definition=tz_definition, for_year=start.year), free_busy_view_options=FreeBusyViewOptions( time_window=TimeWindow(start=start, end=end), merged_free_busy_interval=merged_free_busy_interval, diff --git a/docs/exchangelib/services/get_user_availability.html b/docs/exchangelib/services/get_user_availability.html index 6e1a0d84..43242e03 100644 --- a/docs/exchangelib/services/get_user_availability.html +++ b/docs/exchangelib/services/get_user_availability.html @@ -39,21 +39,22 @@

        Module exchangelib.services.get_user_availability SERVICE_NAME = "GetUserAvailability" - def call(self, timezone, mailbox_data, free_busy_view_options): + def call(self, mailbox_data, timezone, free_busy_view_options): # TODO: Also supports SuggestionsViewOptions, see # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/suggestionsviewoptions return self._elems_to_objs( - self._get_elements( - payload=self.get_payload( - timezone=timezone, mailbox_data=mailbox_data, free_busy_view_options=free_busy_view_options - ) + self._chunked_get_elements( + self.get_payload, + items=mailbox_data, + timezone=timezone, + free_busy_view_options=free_busy_view_options, ) ) def _elem_to_obj(self, elem): return FreeBusyView.from_xml(elem=elem, account=None) - def get_payload(self, timezone, mailbox_data, free_busy_view_options): + def get_payload(self, mailbox_data, timezone, free_busy_view_options): payload = create_element(f"m:{self.SERVICE_NAME}Request") set_xml_value(payload, timezone, version=self.protocol.version) mailbox_data_array = create_element("m:MailboxDataArray") @@ -111,21 +112,22 @@

        Classes

        SERVICE_NAME = "GetUserAvailability" - def call(self, timezone, mailbox_data, free_busy_view_options): + def call(self, mailbox_data, timezone, free_busy_view_options): # TODO: Also supports SuggestionsViewOptions, see # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/suggestionsviewoptions return self._elems_to_objs( - self._get_elements( - payload=self.get_payload( - timezone=timezone, mailbox_data=mailbox_data, free_busy_view_options=free_busy_view_options - ) + self._chunked_get_elements( + self.get_payload, + items=mailbox_data, + timezone=timezone, + free_busy_view_options=free_busy_view_options, ) ) def _elem_to_obj(self, elem): return FreeBusyView.from_xml(elem=elem, account=None) - def get_payload(self, timezone, mailbox_data, free_busy_view_options): + def get_payload(self, mailbox_data, timezone, free_busy_view_options): payload = create_element(f"m:{self.SERVICE_NAME}Request") set_xml_value(payload, timezone, version=self.protocol.version) mailbox_data_array = create_element("m:MailboxDataArray") @@ -167,7 +169,7 @@

        Class variables

        Methods

        -def call(self, timezone, mailbox_data, free_busy_view_options) +def call(self, mailbox_data, timezone, free_busy_view_options)
        @@ -175,20 +177,21 @@

        Methods

        Expand source code -
        def call(self, timezone, mailbox_data, free_busy_view_options):
        +
        def call(self, mailbox_data, timezone, free_busy_view_options):
             # TODO: Also supports SuggestionsViewOptions, see
             #  https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/suggestionsviewoptions
             return self._elems_to_objs(
        -        self._get_elements(
        -            payload=self.get_payload(
        -                timezone=timezone, mailbox_data=mailbox_data, free_busy_view_options=free_busy_view_options
        -            )
        +        self._chunked_get_elements(
        +            self.get_payload,
        +            items=mailbox_data,
        +            timezone=timezone,
        +            free_busy_view_options=free_busy_view_options,
                 )
             )
        -def get_payload(self, timezone, mailbox_data, free_busy_view_options) +def get_payload(self, mailbox_data, timezone, free_busy_view_options)
        @@ -196,7 +199,7 @@

        Methods

        Expand source code -
        def get_payload(self, timezone, mailbox_data, free_busy_view_options):
        +
        def get_payload(self, mailbox_data, timezone, free_busy_view_options):
             payload = create_element(f"m:{self.SERVICE_NAME}Request")
             set_xml_value(payload, timezone, version=self.protocol.version)
             mailbox_data_array = create_element("m:MailboxDataArray")
        @@ -251,4 +254,4 @@ 

        pdoc 0.10.0.

        - \ No newline at end of file + diff --git a/docs/exchangelib/services/index.html b/docs/exchangelib/services/index.html index 59812f89..e9ba41b8 100644 --- a/docs/exchangelib/services/index.html +++ b/docs/exchangelib/services/index.html @@ -4193,21 +4193,22 @@

        Inherited members

        SERVICE_NAME = "GetUserAvailability" - def call(self, timezone, mailbox_data, free_busy_view_options): + def call(self, mailbox_data, timezone, free_busy_view_options): # TODO: Also supports SuggestionsViewOptions, see # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/suggestionsviewoptions return self._elems_to_objs( - self._get_elements( - payload=self.get_payload( - timezone=timezone, mailbox_data=mailbox_data, free_busy_view_options=free_busy_view_options - ) + self._chunked_get_elements( + self.get_payload, + items=mailbox_data, + timezone=timezone, + free_busy_view_options=free_busy_view_options, ) ) def _elem_to_obj(self, elem): return FreeBusyView.from_xml(elem=elem, account=None) - def get_payload(self, timezone, mailbox_data, free_busy_view_options): + def get_payload(self, mailbox_data, timezone, free_busy_view_options): payload = create_element(f"m:{self.SERVICE_NAME}Request") set_xml_value(payload, timezone, version=self.protocol.version) mailbox_data_array = create_element("m:MailboxDataArray") @@ -4249,7 +4250,7 @@

        Class variables

        Methods

        -def call(self, timezone, mailbox_data, free_busy_view_options) +def call(self, mailbox_data, timezone, free_busy_view_options)
        @@ -4257,20 +4258,21 @@

        Methods

        Expand source code -
        def call(self, timezone, mailbox_data, free_busy_view_options):
        +
        def call(self, mailbox_data, timezone, free_busy_view_options):
             # TODO: Also supports SuggestionsViewOptions, see
             #  https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/suggestionsviewoptions
             return self._elems_to_objs(
        -        self._get_elements(
        -            payload=self.get_payload(
        -                timezone=timezone, mailbox_data=mailbox_data, free_busy_view_options=free_busy_view_options
        -            )
        +        self._chunked_get_elements(
        +            self.get_payload,
        +            items=mailbox_data,
        +            timezone=timezone,
        +            free_busy_view_options=free_busy_view_options,
                 )
             )
        -def get_payload(self, timezone, mailbox_data, free_busy_view_options) +def get_payload(self, mailbox_data, timezone, free_busy_view_options)
        @@ -4278,7 +4280,7 @@

        Methods

        Expand source code -
        def get_payload(self, timezone, mailbox_data, free_busy_view_options):
        +
        def get_payload(self, mailbox_data, timezone, free_busy_view_options):
             payload = create_element(f"m:{self.SERVICE_NAME}Request")
             set_xml_value(payload, timezone, version=self.protocol.version)
             mailbox_data_array = create_element("m:MailboxDataArray")
        diff --git a/docs/exchangelib/util.html b/docs/exchangelib/util.html
        index 9e0959bc..b482deee 100644
        --- a/docs/exchangelib/util.html
        +++ b/docs/exchangelib/util.html
        @@ -601,8 +601,7 @@ 

        Module exchangelib.util

        class PrettyXmlHandler(logging.StreamHandler): """A steaming log handler that prettifies log statements containing XML when output is a terminal.""" - @staticmethod - def parse_bytes(xml_bytes): + def parse_bytes(self, xml_bytes): return to_xml(xml_bytes) @classmethod @@ -2224,8 +2223,7 @@

        Ancestors

        class PrettyXmlHandler(logging.StreamHandler):
             """A steaming log handler that prettifies log statements containing XML when output is a terminal."""
         
        -    @staticmethod
        -    def parse_bytes(xml_bytes):
        +    def parse_bytes(self, xml_bytes):
                 return to_xml(xml_bytes)
         
             @classmethod
        @@ -2299,20 +2297,6 @@ 

        Static methods

        return highlight(xml_str, XmlLexer(), TerminalFormatter()).rstrip()
        -
        -def parse_bytes(xml_bytes) -
        -
        -
        -
        - -Expand source code - -
        @staticmethod
        -def parse_bytes(xml_bytes):
        -    return to_xml(xml_bytes)
        -
        -
        def prettify_xml(xml_bytes)
        @@ -2389,6 +2373,19 @@

        Methods

        return False

        +
        +def parse_bytes(self, xml_bytes) +
        +
        +
        +
        + +Expand source code + +
        def parse_bytes(self, xml_bytes):
        +    return to_xml(xml_bytes)
        +
        +
        @@ -2729,4 +2726,4 @@

        pdoc 0.10.0.

        - \ No newline at end of file + From ba44ed26c35aa2adee2ea151dd1bd52fdd27f220 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 9 Aug 2022 10:14:18 +0200 Subject: [PATCH 255/509] Bump version --- CHANGELOG.md | 5 +++++ exchangelib/__init__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e58b41f3..cd44656e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,15 @@ Change Log HEAD ---- + + +4.7.5 +----- - Fixed `Protocol.get_free_busy_info()` when called with +100 accounts. - Allowed configuring DNS timeout for a single nameserver (`Autodiscovery.DNS_RESOLVER_ATTRS["timeout""]`) and the total query lifetime (`Autodiscovery.DNS_RESOLVER_LIFETIME`) separately. +- Fixed token refresh bug with OAuth2 authentication 4.7.4 diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index 49a722eb..d2c352d1 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -29,7 +29,7 @@ from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "4.7.4" +__version__ = "4.7.5" __all__ = [ "__version__", From 35834b8026537e49a28080e801adf32d8423aa58 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 9 Aug 2022 14:15:32 +0200 Subject: [PATCH 256/509] Revert "chore: fix linter warning" This reverts commit 2a1cb326f5211b45ec0948a24ee8f516acbdae3d. --- exchangelib/util.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/exchangelib/util.py b/exchangelib/util.py index 057bd513..908dbee1 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -573,7 +573,8 @@ def is_xml(text, expected_prefix=b" Date: Tue, 9 Aug 2022 14:31:55 +0200 Subject: [PATCH 257/509] fix: linter warning --- exchangelib/util.py | 8 +++----- tests/test_items/test_sync.py | 4 ++-- tests/test_transport.py | 4 ++-- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/exchangelib/util.py b/exchangelib/util.py index 908dbee1..91bbcfa9 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -573,15 +573,13 @@ def is_xml(text, expected_prefix=b" Date: Tue, 9 Aug 2022 14:45:23 +0200 Subject: [PATCH 258/509] Add client_id and client_secret explicitly to session.post() to work around token refresh bug (#1106) Fixes #1090 --- exchangelib/util.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/exchangelib/util.py b/exchangelib/util.py index 91bbcfa9..36803fda 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -22,6 +22,7 @@ from pygments import highlight from pygments.formatters.terminal import TerminalFormatter from pygments.lexers.html import XmlLexer +from requests_oauthlib import OAuth2Session from .errors import ( InvalidTypeError, @@ -837,10 +838,12 @@ def post_ratelimited(protocol, session, url, headers, data, allow_redirects=Fals d_start = time.monotonic() # Always create a dummy response for logging purposes, in case we fail in the following r = DummyResponse(url=url, request_headers=headers) + kwargs = dict(url=url, headers=headers, data=data, allow_redirects=False, timeout=timeout, stream=stream) + if isinstance(session, OAuth2Session): + # Fix token refreshing bug. Reported as https://github.com/requests/requests-oauthlib/issues/498 + kwargs.update(session.auto_refresh_kwargs) try: - r = session.post( - url=url, headers=headers, data=data, allow_redirects=False, timeout=timeout, stream=stream - ) + r = session.post(**kwargs) except TLS_ERRORS as e: # Don't retry on TLS errors. They will most likely be persistent. raise TransportError(str(e)) From 0f90a9ad8cd1ed526fbdd7fda19240c4644a30fd Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 9 Aug 2022 21:19:41 +0200 Subject: [PATCH 259/509] ci: ignore errors in flaky test --- tests/test_items/test_basics.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index 4b3641a3..e0e03a10 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -333,6 +333,7 @@ def _run_filter_tests(self, qs, f, filter_kwargs, val): matches = qs.filter(**kw).count() if matches == expected: break + self.skipTest(f"Filter expression {kw} on complex field still failing after multiple retries") self.assertEqual(matches, expected, (f.name, val, kw, retries)) def test_filter_on_simple_fields(self): From 4b44aab705f8bbe63479c354ffc4295031e936f5 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 10 Aug 2022 07:33:06 +0200 Subject: [PATCH 260/509] docs: regenerate --- docs/exchangelib/autodiscover/discovery.html | 2 +- docs/exchangelib/autodiscover/index.html | 2 +- docs/exchangelib/credentials.html | 2 +- docs/exchangelib/errors.html | 2 +- docs/exchangelib/index.html | 4 +- docs/exchangelib/items/calendar_item.html | 2 +- docs/exchangelib/properties.html | 2 +- docs/exchangelib/protocol.html | 2 +- .../services/get_user_availability.html | 2 +- docs/exchangelib/services/index.html | 2 +- docs/exchangelib/services/resolve_names.html | 2 +- docs/exchangelib/util.html | 66 ++++++++++--------- 12 files changed, 46 insertions(+), 44 deletions(-) diff --git a/docs/exchangelib/autodiscover/discovery.html b/docs/exchangelib/autodiscover/discovery.html index e7a5801b..81e752e7 100644 --- a/docs/exchangelib/autodiscover/discovery.html +++ b/docs/exchangelib/autodiscover/discovery.html @@ -1350,4 +1350,4 @@

        pdoc 0.10.0.

        - + \ No newline at end of file diff --git a/docs/exchangelib/autodiscover/index.html b/docs/exchangelib/autodiscover/index.html index 3c8c9c9d..3edb0d9e 100644 --- a/docs/exchangelib/autodiscover/index.html +++ b/docs/exchangelib/autodiscover/index.html @@ -1044,4 +1044,4 @@

        pdoc 0.10.0.

        - + \ No newline at end of file diff --git a/docs/exchangelib/credentials.html b/docs/exchangelib/credentials.html index 8709a45e..7f58c846 100644 --- a/docs/exchangelib/credentials.html +++ b/docs/exchangelib/credentials.html @@ -845,4 +845,4 @@

        pdoc 0.10.0.

        - + \ No newline at end of file diff --git a/docs/exchangelib/errors.html b/docs/exchangelib/errors.html index 33bb9094..4c54e5df 100644 --- a/docs/exchangelib/errors.html +++ b/docs/exchangelib/errors.html @@ -12389,4 +12389,4 @@

        pdoc 0.10.0.

        - + \ No newline at end of file diff --git a/docs/exchangelib/index.html b/docs/exchangelib/index.html index 4302b105..f51a132b 100644 --- a/docs/exchangelib/index.html +++ b/docs/exchangelib/index.html @@ -57,7 +57,7 @@

        Package exchangelib

        from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "4.7.5" +__version__ = "4.7.6" __all__ = [ "__version__", @@ -13673,4 +13673,4 @@

        Version

        Generated by pdoc 0.10.0.

        - + \ No newline at end of file diff --git a/docs/exchangelib/items/calendar_item.html b/docs/exchangelib/items/calendar_item.html index bdac4734..3bd7d6b6 100644 --- a/docs/exchangelib/items/calendar_item.html +++ b/docs/exchangelib/items/calendar_item.html @@ -2518,4 +2518,4 @@

        Generated by pdoc 0.10.0.

        - + \ No newline at end of file diff --git a/docs/exchangelib/properties.html b/docs/exchangelib/properties.html index e08d1a58..db670b8c 100644 --- a/docs/exchangelib/properties.html +++ b/docs/exchangelib/properties.html @@ -11371,4 +11371,4 @@

        pdoc 0.10.0.

        - + \ No newline at end of file diff --git a/docs/exchangelib/protocol.html b/docs/exchangelib/protocol.html index cece96d0..121f7bfb 100644 --- a/docs/exchangelib/protocol.html +++ b/docs/exchangelib/protocol.html @@ -2821,4 +2821,4 @@

        pdoc 0.10.0.

        - + \ No newline at end of file diff --git a/docs/exchangelib/services/get_user_availability.html b/docs/exchangelib/services/get_user_availability.html index 43242e03..009a793b 100644 --- a/docs/exchangelib/services/get_user_availability.html +++ b/docs/exchangelib/services/get_user_availability.html @@ -254,4 +254,4 @@

        pdoc 0.10.0.

        - + \ No newline at end of file diff --git a/docs/exchangelib/services/index.html b/docs/exchangelib/services/index.html index e9ba41b8..ab79b647 100644 --- a/docs/exchangelib/services/index.html +++ b/docs/exchangelib/services/index.html @@ -7179,4 +7179,4 @@

        pdoc 0.10.0.

        - + \ No newline at end of file diff --git a/docs/exchangelib/services/resolve_names.html b/docs/exchangelib/services/resolve_names.html index 0fe3ad02..9635962c 100644 --- a/docs/exchangelib/services/resolve_names.html +++ b/docs/exchangelib/services/resolve_names.html @@ -397,4 +397,4 @@

        pdoc 0.10.0.

        - + \ No newline at end of file diff --git a/docs/exchangelib/util.html b/docs/exchangelib/util.html index b482deee..6ae89aa7 100644 --- a/docs/exchangelib/util.html +++ b/docs/exchangelib/util.html @@ -50,6 +50,7 @@

        Module exchangelib.util

        from pygments import highlight from pygments.formatters.terminal import TerminalFormatter from pygments.lexers.html import XmlLexer +from requests_oauthlib import OAuth2Session from .errors import ( InvalidTypeError, @@ -604,11 +605,10 @@

        Module exchangelib.util

        def parse_bytes(self, xml_bytes): return to_xml(xml_bytes) - @classmethod - def prettify_xml(cls, xml_bytes): + def prettify_xml(self, xml_bytes): """Re-format an XML document to a consistent style.""" return ( - lxml.etree.tostring(cls.parse_bytes(xml_bytes), xml_declaration=True, encoding="utf-8", pretty_print=True) + lxml.etree.tostring(self.parse_bytes(xml_bytes), xml_declaration=True, encoding="utf-8", pretty_print=True) .replace(b"\t", b" ") .replace(b" xmlns:", b"\n xmlns:") ) @@ -866,10 +866,12 @@

        Module exchangelib.util

        d_start = time.monotonic() # Always create a dummy response for logging purposes, in case we fail in the following r = DummyResponse(url=url, request_headers=headers) + kwargs = dict(url=url, headers=headers, data=data, allow_redirects=False, timeout=timeout, stream=stream) + if isinstance(session, OAuth2Session): + # Fix token refreshing bug. Reported as https://github.com/requests/requests-oauthlib/issues/498 + kwargs.update(session.auto_refresh_kwargs) try: - r = session.post( - url=url, headers=headers, data=data, allow_redirects=False, timeout=timeout, stream=stream - ) + r = session.post(**kwargs) except TLS_ERRORS as e: # Don't retry on TLS errors. They will most likely be persistent. raise TransportError(str(e)) @@ -1390,10 +1392,12 @@

        Functions

        d_start = time.monotonic() # Always create a dummy response for logging purposes, in case we fail in the following r = DummyResponse(url=url, request_headers=headers) + kwargs = dict(url=url, headers=headers, data=data, allow_redirects=False, timeout=timeout, stream=stream) + if isinstance(session, OAuth2Session): + # Fix token refreshing bug. Reported as https://github.com/requests/requests-oauthlib/issues/498 + kwargs.update(session.auto_refresh_kwargs) try: - r = session.post( - url=url, headers=headers, data=data, allow_redirects=False, timeout=timeout, stream=stream - ) + r = session.post(**kwargs) except TLS_ERRORS as e: # Don't retry on TLS errors. They will most likely be persistent. raise TransportError(str(e)) @@ -2226,11 +2230,10 @@

        Ancestors

        def parse_bytes(self, xml_bytes): return to_xml(xml_bytes) - @classmethod - def prettify_xml(cls, xml_bytes): + def prettify_xml(self, xml_bytes): """Re-format an XML document to a consistent style.""" return ( - lxml.etree.tostring(cls.parse_bytes(xml_bytes), xml_declaration=True, encoding="utf-8", pretty_print=True) + lxml.etree.tostring(self.parse_bytes(xml_bytes), xml_declaration=True, encoding="utf-8", pretty_print=True) .replace(b"\t", b" ") .replace(b" xmlns:", b"\n xmlns:") ) @@ -2297,25 +2300,6 @@

        Static methods

        return highlight(xml_str, XmlLexer(), TerminalFormatter()).rstrip()
        -
        -def prettify_xml(xml_bytes) -
        -
        -

        Re-format an XML document to a consistent style.

        -
        - -Expand source code - -
        @classmethod
        -def prettify_xml(cls, xml_bytes):
        -    """Re-format an XML document to a consistent style."""
        -    return (
        -        lxml.etree.tostring(cls.parse_bytes(xml_bytes), xml_declaration=True, encoding="utf-8", pretty_print=True)
        -        .replace(b"\t", b"    ")
        -        .replace(b" xmlns:", b"\n    xmlns:")
        -    )
        -
        -

        Methods

        @@ -2386,6 +2370,24 @@

        Methods

        return to_xml(xml_bytes)
        +
        +def prettify_xml(self, xml_bytes) +
        +
        +

        Re-format an XML document to a consistent style.

        +
        + +Expand source code + +
        def prettify_xml(self, xml_bytes):
        +    """Re-format an XML document to a consistent style."""
        +    return (
        +        lxml.etree.tostring(self.parse_bytes(xml_bytes), xml_declaration=True, encoding="utf-8", pretty_print=True)
        +        .replace(b"\t", b"    ")
        +        .replace(b" xmlns:", b"\n    xmlns:")
        +    )
        +
        +
        @@ -2726,4 +2728,4 @@

        pdoc 0.10.0.

        - + \ No newline at end of file From fdadcc61461c962d9b35b6da42d945ef6a58daf2 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 10 Aug 2022 07:33:21 +0200 Subject: [PATCH 261/509] Bump version --- CHANGELOG.md | 5 +++++ exchangelib/__init__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd44656e..4aaf3277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ HEAD ---- +4.7.6 +----- +- Fixed token refresh bug with OAuth2 authentication, again + + 4.7.5 ----- - Fixed `Protocol.get_free_busy_info()` when called with +100 accounts. diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index d2c352d1..d702dc95 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -29,7 +29,7 @@ from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "4.7.5" +__version__ = "4.7.6" __all__ = [ "__version__", From 53840786ed3dcfa96be233e1496c491de99fb945 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 18 Aug 2022 16:31:22 +0200 Subject: [PATCH 262/509] fix: Fix docs on setting CHUNK_SIZE and PAGE_SIZE globally The previous docs would just result in exchangelib.services.CHUNK_SIZE changing but exchangelib.services.common.CHUNK_SIZE staying the same, the latter being the value actually picked up by the service. While here, allow setting these values per-service. Fixes #1109 --- docs/index.md | 4 ++-- exchangelib/queryset.py | 4 ++-- exchangelib/services/__init__.py | 5 ++--- exchangelib/services/common.py | 10 +++++----- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/docs/index.md b/docs/index.md index 115d4845..2dc12353 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1142,7 +1142,7 @@ number of items fetched per page when paging is requested. You can change this v ```python import exchangelib.services -exchangelib.services.PAGE_SIZE = 25 +exchangelib.services.EWSService.PAGE_SIZE = 25 ``` Other EWS services like `GetItem` and `GetFolder`, have a default chunk size of 100. This value is @@ -1151,7 +1151,7 @@ requests. You can change this value globally: ```python import exchangelib.services -exchangelib.services.CHUNK_SIZE = 25 +exchangelib.services.EWSService.CHUNK_SIZE = 25 ``` If you are working with very small or very large items, these may not be a reasonable diff --git a/exchangelib/queryset.py b/exchangelib/queryset.py index 7ae0e9dd..4fa47860 100644 --- a/exchangelib/queryset.py +++ b/exchangelib/queryset.py @@ -305,7 +305,7 @@ def _getitem_idx(self, idx): raise IndexError() def _getitem_slice(self, s): - from .services import PAGE_SIZE + from .services import FindItem if ((s.start or 0) < 0) or ((s.stop or 0) < 0) or ((s.step or 0) < 0): # islice() does not support negative start, stop and step. Make sure cache is full by iterating the full @@ -320,7 +320,7 @@ def _getitem_slice(self, s): new_qs.offset = s.start elif s.stop is not None: new_qs.max_items = s.stop - if new_qs.page_size is None and new_qs.max_items is not None and new_qs.max_items < PAGE_SIZE: + if new_qs.page_size is None and new_qs.max_items is not None and new_qs.max_items < FindItem.PAGE_SIZE: new_qs.page_size = new_qs.max_items return islice(new_qs.__iter__(), None, None, s.step) diff --git a/exchangelib/services/__init__.py b/exchangelib/services/__init__.py index 4247d8af..09f8fd7e 100644 --- a/exchangelib/services/__init__.py +++ b/exchangelib/services/__init__.py @@ -8,7 +8,7 @@ """ from .archive_item import ArchiveItem -from .common import CHUNK_SIZE, PAGE_SIZE +from .common import EWSService from .convert_id import ConvertId from .copy_item import CopyItem from .create_attachment import CreateAttachment @@ -57,8 +57,6 @@ from .upload_items import UploadItems __all__ = [ - "CHUNK_SIZE", - "PAGE_SIZE", "ArchiveItem", "ConvertId", "CopyItem", @@ -71,6 +69,7 @@ "DeleteUserConfiguration", "DeleteItem", "EmptyFolder", + "EWSService", "ExpandDL", "ExportItems", "FindFolder", diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index cafe0b0f..cdefdbad 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -67,13 +67,13 @@ log = logging.getLogger(__name__) -PAGE_SIZE = 100 # A default page size for all paging services. This is the number of items we request per page -CHUNK_SIZE = 100 # A default chunk size for all services. This is the number of items we send in a single request - class EWSService(metaclass=abc.ABCMeta): """Base class for all EWS services.""" + PAGE_SIZE = 100 # A default page size for all paging services. This is the number of items we request per page + CHUNK_SIZE = 100 # A default chunk size for all services. This is the number of items we send in a single request + SERVICE_NAME = None # The name of the SOAP service element_container_name = None # The name of the XML element wrapping the collection of returned items paging_container_name = None # The name of the element that contains paging information and the paged results @@ -106,7 +106,7 @@ class EWSService(metaclass=abc.ABCMeta): supports_paging = False def __init__(self, protocol, chunk_size=None, timeout=None): - self.chunk_size = chunk_size or CHUNK_SIZE + self.chunk_size = chunk_size or self.CHUNK_SIZE if not isinstance(self.chunk_size, int): raise InvalidTypeError("chunk_size", chunk_size, int) if self.chunk_size < 1: @@ -717,7 +717,7 @@ def _timezone(self): class EWSPagingService(EWSAccountService): def __init__(self, *args, **kwargs): - self.page_size = kwargs.pop("page_size", None) or PAGE_SIZE + self.page_size = kwargs.pop("page_size", None) or self.PAGE_SIZE if not isinstance(self.page_size, int): raise InvalidTypeError("page_size", self.page_size, int) if self.page_size < 1: From d5d386f54adec8ab02f871332b89e1176c214ba2 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Fri, 19 Aug 2022 09:19:43 +0200 Subject: [PATCH 263/509] chore: add IANA timezone alias added in 2022.2 --- exchangelib/winzone.py | 1 + 1 file changed, 1 insertion(+) diff --git a/exchangelib/winzone.py b/exchangelib/winzone.py index 78d3631c..a236cd08 100644 --- a/exchangelib/winzone.py +++ b/exchangelib/winzone.py @@ -585,6 +585,7 @@ def generate_map(timeout=10): "Etc/Universal": CLDR_TO_MS_TIMEZONE_MAP["Etc/UTC"], "Etc/Zulu": CLDR_TO_MS_TIMEZONE_MAP["Etc/UTC"], "Europe/Belfast": CLDR_TO_MS_TIMEZONE_MAP["Europe/London"], + "Europe/Kyiv": CLDR_TO_MS_TIMEZONE_MAP["Europe/Kiev"], "Europe/Nicosia": CLDR_TO_MS_TIMEZONE_MAP["Asia/Nicosia"], "Europe/Tiraspol": CLDR_TO_MS_TIMEZONE_MAP["Europe/Chisinau"], "Factory": CLDR_TO_MS_TIMEZONE_MAP["Etc/UTC"], From 7533ac640097f8111779681d7bd8dab3592fd60c Mon Sep 17 00:00:00 2001 From: jpays <40646104+jpays@users.noreply.github.com> Date: Mon, 22 Aug 2022 15:55:10 +0200 Subject: [PATCH 264/509] Update protocol.py (#1110) * Update protocol.py Use the configured protocol adapter for oauth2 token endpoint * Blacken Co-authored-by: Erik Cederstrand --- exchangelib/protocol.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 3377704c..7712c8dc 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -339,7 +339,12 @@ def create_oauth2_session(self): else: client = BackendApplicationClient(client_id=self.credentials.client_id) - session = self.raw_session(self.service_endpoint, oauth2_client=client, oauth2_session_params=session_params) + session = self.raw_session( + self.service_endpoint, + oauth2_client=client, + oauth2_session_params=session_params, + oauth2_token_endpoint=self.credentials.token_url, + ) if not session.token: # Fetch the token explicitly -- it doesn't occur implicitly token = session.fetch_token( @@ -358,7 +363,7 @@ def create_oauth2_session(self): return session @classmethod - def raw_session(cls, prefix, oauth2_client=None, oauth2_session_params=None): + def raw_session(cls, prefix, oauth2_client=None, oauth2_session_params=None, oauth2_token_endpoint=None): if oauth2_client: session = OAuth2Session(client=oauth2_client, **(oauth2_session_params or {})) else: @@ -366,6 +371,8 @@ def raw_session(cls, prefix, oauth2_client=None, oauth2_session_params=None): session.headers.update(DEFAULT_HEADERS) session.headers["User-Agent"] = cls.USERAGENT session.mount(prefix, adapter=cls.get_adapter()) + if oauth2_token_endpoint: + session.mount(oauth2_token_endpoint, adapter=cls.get_adapter()) return session def __repr__(self): From 18dcaefc95ee081f4d9315ca2fe80a17f2d1e24b Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 29 Aug 2022 09:32:56 +0200 Subject: [PATCH 265/509] fix: support fromisoformat() on EWSDateTime. Fixes #1114 --- exchangelib/ewsdatetime.py | 4 ++++ tests/test_ewsdatetime.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/exchangelib/ewsdatetime.py b/exchangelib/ewsdatetime.py index 834cd738..cd517b28 100644 --- a/exchangelib/ewsdatetime.py +++ b/exchangelib/ewsdatetime.py @@ -130,6 +130,10 @@ def astimezone(self, tz=None): return t return self.from_datetime(t) # We want to return EWSDateTime objects + @classmethod + def fromisoformat(cls, date_string): + return cls.from_string(date_string) + def __add__(self, other): t = super().__add__(other) if isinstance(t, self.__class__): diff --git a/tests/test_ewsdatetime.py b/tests/test_ewsdatetime.py index ff550721..2508e036 100644 --- a/tests/test_ewsdatetime.py +++ b/tests/test_ewsdatetime.py @@ -191,6 +191,13 @@ def test_ewsdatetime(self): with self.assertRaises(TypeError): EWSDateTime.from_datetime(EWSDateTime(2000, 1, 2, 3, 4, 5)) + # Test isoformat + dt = EWSDateTime(2000, 1, 2, 3, 4, 5, tzinfo=tz) + fmt = dt.isoformat() + self.assertEqual(fmt, "2000-01-02T03:04:05+01:00") + dt = EWSDateTime.fromisoformat(fmt) + self.assertEqual(dt, EWSDateTime(2000, 1, 2, 3, 4, 5, tzinfo=tz)) + def test_generate(self): try: type_version, other_version, tz_map = generate_map() @@ -232,3 +239,10 @@ def test_ewsdate(self): EWSDate.from_date(EWSDate(2000, 1, 2)) self.assertEqual(EWSDate.fromordinal(730120), EWSDate(2000, 1, 1)) + + # Test isoformat + dt = EWSDate(2000, 1, 2) + fmt = dt.isoformat() + self.assertEqual(fmt, "2000-01-02") + dt = EWSDate.fromisoformat(fmt) + self.assertEqual(dt, EWSDate(2000, 1, 2)) From 05b7edf8996e8f2e92e3988dab4c63f96e9f6743 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 29 Aug 2022 09:33:57 +0200 Subject: [PATCH 266/509] ci: split EWS* tests into one test class per EWS* class --- tests/test_ewsdatetime.py | 136 ++++++++++++++++++++------------------ 1 file changed, 70 insertions(+), 66 deletions(-) diff --git a/tests/test_ewsdatetime.py b/tests/test_ewsdatetime.py index 2508e036..f28c9bf1 100644 --- a/tests/test_ewsdatetime.py +++ b/tests/test_ewsdatetime.py @@ -33,72 +33,6 @@ def test_super_methods(self): self.assertIsInstance(EWSDateTime.fromtimestamp(123456789, tz=tz), EWSDateTime) self.assertIsInstance(EWSDateTime.utcfromtimestamp(123456789), EWSDateTime) - def test_ewstimezone(self): - # Test autogenerated translations - tz = EWSTimeZone("Europe/Copenhagen") - self.assertIsInstance(tz, EWSTimeZone) - self.assertEqual(tz.key, "Europe/Copenhagen") - self.assertEqual(tz.ms_id, "Romance Standard Time") - # self.assertEqual(EWSTimeZone('Europe/Copenhagen').ms_name, '') # EWS works fine without the ms_name - - # Test localzone() - tz = EWSTimeZone.localzone() - self.assertIsInstance(tz, EWSTimeZone) - - # Test common helpers - tz = EWSTimeZone("UTC") - self.assertIsInstance(tz, EWSTimeZone) - self.assertEqual(tz.key, "UTC") - self.assertEqual(tz.ms_id, "UTC") - tz = EWSTimeZone("GMT") - self.assertIsInstance(tz, EWSTimeZone) - self.assertEqual(tz.key, "GMT") - self.assertEqual(tz.ms_id, "UTC") - - # Test mapper contents. Latest map from unicode.org has 394 entries - self.assertGreater(len(EWSTimeZone.IANA_TO_MS_MAP), 300) - for k, v in EWSTimeZone.IANA_TO_MS_MAP.items(): - self.assertIsInstance(k, str) - self.assertIsInstance(v, tuple) - self.assertEqual(len(v), 2) - self.assertIsInstance(v[0], str) - - # Test IANA exceptions - sanitized = list(t for t in zoneinfo.available_timezones() if not t.startswith("SystemV/") and t != "localtime") - self.assertEqual(set(sanitized) - set(EWSTimeZone.IANA_TO_MS_MAP), set()) - - # Test timezone unknown by ZoneInfo - with self.assertRaises(UnknownTimeZone) as e: - EWSTimeZone("UNKNOWN") - self.assertEqual(e.exception.args[0], "No time zone found with key UNKNOWN") - - # Test timezone known by IANA but with no Winzone mapping - with self.assertRaises(UnknownTimeZone) as e: - del EWSTimeZone.IANA_TO_MS_MAP["Africa/Tripoli"] - EWSTimeZone("Africa/Tripoli") - self.assertEqual(e.exception.args[0], "No Windows timezone name found for timezone 'Africa/Tripoli'") - - # Test __eq__ with non-EWSTimeZone compare - self.assertFalse(EWSTimeZone("GMT") == zoneinfo.ZoneInfo("UTC")) - - # Test from_ms_id() with non-standard MS ID - self.assertEqual(EWSTimeZone("Europe/Copenhagen"), EWSTimeZone.from_ms_id("Europe/Copenhagen")) - - def test_from_timezone(self): - self.assertEqual(EWSTimeZone("Europe/Copenhagen"), EWSTimeZone.from_timezone(EWSTimeZone("Europe/Copenhagen"))) - self.assertEqual( - EWSTimeZone("Europe/Copenhagen"), EWSTimeZone.from_timezone(zoneinfo.ZoneInfo("Europe/Copenhagen")) - ) - self.assertEqual( - EWSTimeZone("Europe/Copenhagen"), EWSTimeZone.from_timezone(dateutil.tz.gettz("Europe/Copenhagen")) - ) - self.assertEqual( - EWSTimeZone("Europe/Copenhagen"), EWSTimeZone.from_timezone(pytz.timezone("Europe/Copenhagen")) - ) - - self.assertEqual(EWSTimeZone("UTC"), EWSTimeZone.from_timezone(dateutil.tz.UTC)) - self.assertEqual(EWSTimeZone("UTC"), EWSTimeZone.from_timezone(datetime.timezone.utc)) - def test_ewsdatetime(self): # Test a static timezone tz = EWSTimeZone("Etc/GMT-5") @@ -198,6 +132,74 @@ def test_ewsdatetime(self): dt = EWSDateTime.fromisoformat(fmt) self.assertEqual(dt, EWSDateTime(2000, 1, 2, 3, 4, 5, tzinfo=tz)) + +class EWSTimeZoneTest(TimedTestCase): + def test_ewstimezone(self): + # Test autogenerated translations + tz = EWSTimeZone("Europe/Copenhagen") + self.assertIsInstance(tz, EWSTimeZone) + self.assertEqual(tz.key, "Europe/Copenhagen") + self.assertEqual(tz.ms_id, "Romance Standard Time") + # self.assertEqual(EWSTimeZone('Europe/Copenhagen').ms_name, '') # EWS works fine without the ms_name + + # Test localzone() + tz = EWSTimeZone.localzone() + self.assertIsInstance(tz, EWSTimeZone) + + # Test common helpers + tz = EWSTimeZone("UTC") + self.assertIsInstance(tz, EWSTimeZone) + self.assertEqual(tz.key, "UTC") + self.assertEqual(tz.ms_id, "UTC") + tz = EWSTimeZone("GMT") + self.assertIsInstance(tz, EWSTimeZone) + self.assertEqual(tz.key, "GMT") + self.assertEqual(tz.ms_id, "UTC") + + # Test mapper contents. Latest map from unicode.org has 394 entries + self.assertGreater(len(EWSTimeZone.IANA_TO_MS_MAP), 300) + for k, v in EWSTimeZone.IANA_TO_MS_MAP.items(): + self.assertIsInstance(k, str) + self.assertIsInstance(v, tuple) + self.assertEqual(len(v), 2) + self.assertIsInstance(v[0], str) + + # Test IANA exceptions + sanitized = list(t for t in zoneinfo.available_timezones() if not t.startswith("SystemV/") and t != "localtime") + self.assertEqual(set(sanitized) - set(EWSTimeZone.IANA_TO_MS_MAP), set()) + + # Test timezone unknown by ZoneInfo + with self.assertRaises(UnknownTimeZone) as e: + EWSTimeZone("UNKNOWN") + self.assertEqual(e.exception.args[0], "No time zone found with key UNKNOWN") + + # Test timezone known by IANA but with no Winzone mapping + with self.assertRaises(UnknownTimeZone) as e: + del EWSTimeZone.IANA_TO_MS_MAP["Africa/Tripoli"] + EWSTimeZone("Africa/Tripoli") + self.assertEqual(e.exception.args[0], "No Windows timezone name found for timezone 'Africa/Tripoli'") + + # Test __eq__ with non-EWSTimeZone compare + self.assertFalse(EWSTimeZone("GMT") == zoneinfo.ZoneInfo("UTC")) + + # Test from_ms_id() with non-standard MS ID + self.assertEqual(EWSTimeZone("Europe/Copenhagen"), EWSTimeZone.from_ms_id("Europe/Copenhagen")) + + def test_from_timezone(self): + self.assertEqual(EWSTimeZone("Europe/Copenhagen"), EWSTimeZone.from_timezone(EWSTimeZone("Europe/Copenhagen"))) + self.assertEqual( + EWSTimeZone("Europe/Copenhagen"), EWSTimeZone.from_timezone(zoneinfo.ZoneInfo("Europe/Copenhagen")) + ) + self.assertEqual( + EWSTimeZone("Europe/Copenhagen"), EWSTimeZone.from_timezone(dateutil.tz.gettz("Europe/Copenhagen")) + ) + self.assertEqual( + EWSTimeZone("Europe/Copenhagen"), EWSTimeZone.from_timezone(pytz.timezone("Europe/Copenhagen")) + ) + + self.assertEqual(EWSTimeZone("UTC"), EWSTimeZone.from_timezone(dateutil.tz.UTC)) + self.assertEqual(EWSTimeZone("UTC"), EWSTimeZone.from_timezone(datetime.timezone.utc)) + def test_generate(self): try: type_version, other_version, tz_map = generate_map() @@ -215,6 +217,8 @@ def test_generate_failure(self, m): with self.assertRaises(ValueError): generate_map() + +class EWSDateTest(TimedTestCase): def test_ewsdate(self): self.assertEqual(EWSDate(2000, 1, 1).ewsformat(), "2000-01-01") self.assertEqual(EWSDate.from_string("2000-01-01"), EWSDate(2000, 1, 1)) From 6733a5b1e2a21d3b4c7661a3aa59adb32496715a Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 12 Sep 2022 07:37:29 +0200 Subject: [PATCH 267/509] fix: explicitly insert client ID in request, to support older versions of requests_oauthlib. Refs #1115 --- exchangelib/protocol.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 7712c8dc..eac9dcdb 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -314,7 +314,7 @@ def create_session(self): def create_oauth2_session(self): session_params = {"token": self.credentials.access_token} # Token may be None - token_params = {} + token_params = {"include_client_id": True} if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials): token_params["code"] = self.credentials.authorization_code # Auth code may be None From 3b400deeb8a770374df806b275f7bc4d760b38ed Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 26 Sep 2022 08:45:55 +0200 Subject: [PATCH 268/509] Document creating Contacts with indexed property fields --- docs/index.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/index.md b/docs/index.md index 2dc12353..57c00507 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1291,6 +1291,43 @@ all_addresses = [ ] ``` +Contacts have some special "indexed property" fields that require extra attention. Here's an +example of creating a new contact with multiple phone numbers and addresses. Each indexed property +class has a `LABEL_CHOICES` class attribute listing all valid label values: + +```python +from exchangelib import Contact +from exchangelib.indexed_properties import PhoneNumber, EmailAddress, PhysicalAddress + +contact = Contact( + account=a, + folder=a.contacts, + given_name="Test", + surname="Lockt", + display_name="Test Lockt", + phone_numbers=[ + PhoneNumber(label="MobilePhone", phone_number="123456"), + PhoneNumber(label="BusinessPhone", phone_number="123456"), + PhoneNumber(label="OtherTelephone", phone_number="123456"), + ], + email_addresses=[ + EmailAddress(label="EmailAddress1", email="test@test.si"), + EmailAddress(label="EmailAddress2", email="test2@test.si"), + ], + physical_addresses=[ + PhysicalAddress( + label="Home", + street="Test 30", + city="TestCity", + country="TestCountry", + zipcode="8237", + ) + ], + company_name="Blue Anon Airlines", +) +contact.save() +``` + Contact items have `photo` and `notes` fields, but they are apparently unused. Instead, you can add a contact photo and notes like this: From 947150620183bb0850c4fe0f109d58a1445a1efc Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 28 Sep 2022 10:55:51 +0200 Subject: [PATCH 269/509] ci: Move inherently flaky timezone test cases under integration testing, to aid automated systems running the unit tests. Refs #1116 --- README.md | 1 - tests/common.py | 15 +++++++++------ tests/test_ewsdatetime.py | 31 ++++++++++++++++++++++--------- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index f08b5608..913603e5 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,6 @@ exporting and uploading calendar, mailbox, task, contact and distribution list i [![image](https://img.shields.io/pypi/v/exchangelib.svg)](https://pypi.org/project/exchangelib/) [![image](https://img.shields.io/pypi/pyversions/exchangelib.svg)](https://pypi.org/project/exchangelib/) [![image](https://api.codacy.com/project/badge/Grade/5f805ad901054a889f4b99a82d6c1cb7)](https://app.codacy.com/gh/ecederstrand/exchangelib) -[![image](https://api.travis-ci.com/ecederstrand/exchangelib.png)](http://travis-ci.com/ecederstrand/exchangelib) [![image](https://coveralls.io/repos/github/ecederstrand/exchangelib/badge.svg?branch=master)](https://coveralls.io/github/ecederstrand/exchangelib?branch=master) [![xscode](https://img.shields.io/badge/Available%20on-xs%3Acode-blue)](https://xscode.com/ecederstrand/exchangelib) diff --git a/tests/common.py b/tests/common.py index d3394010..8b031135 100644 --- a/tests/common.py +++ b/tests/common.py @@ -71,6 +71,11 @@ mock_version = namedtuple("mock_version", ("build",)) +def get_settings(): + with open(os.path.join(os.path.dirname(os.path.dirname(__file__)), "settings.yml")) as f: + return safe_load(f) + + def mock_post(url, status_code, headers, text=""): return lambda **kwargs: DummyResponse( url=url, headers=headers, request_headers={}, content=text.encode("utf-8"), status_code=status_code @@ -101,20 +106,18 @@ class EWSTest(TimedTestCase, metaclass=abc.ABCMeta): @classmethod def setUpClass(cls): # There's no official Exchange server we can test against, and we can't really provide credentials for our - # own test server to everyone on the Internet. Travis-CI uses the encrypted settings.yml.enc for testing. + # own test server to everyone on the Internet. GitHub Actions use the encrypted settings.yml.ghenc for testing. # # If you want to test against your own server and account, create your own settings.yml with credentials for # that server. 'settings.yml.sample' is provided as a template. try: - with open(os.path.join(os.path.dirname(os.path.dirname(__file__)), "settings.yml")) as f: - settings = safe_load(f) + cls.settings = get_settings() except FileNotFoundError: print(f"Skipping {cls.__name__} - no settings.yml file found") print("Copy settings.yml.sample to settings.yml and enter values for your test server") raise unittest.SkipTest(f"Skipping {cls.__name__} - no settings.yml file found") - cls.settings = settings - cls.verify_ssl = settings.get("verify_ssl", True) + cls.verify_ssl = cls.settings.get("verify_ssl", True) if not cls.verify_ssl: # Allow unverified TLS if requested in settings file BaseProtocol.HTTP_ADAPTER_CLS = NoVerifyHTTPAdapter @@ -123,7 +126,7 @@ def setUpClass(cls): cls.tz = zoneinfo.ZoneInfo("Europe/Copenhagen") cls.retry_policy = FaultTolerance(max_wait=600) cls.config = Configuration( - server=settings["server"], + server=cls.settings["server"], credentials=cls.credentials(), retry_policy=cls.retry_policy, ) diff --git a/tests/test_ewsdatetime.py b/tests/test_ewsdatetime.py index f28c9bf1..b0ce06f3 100644 --- a/tests/test_ewsdatetime.py +++ b/tests/test_ewsdatetime.py @@ -1,4 +1,5 @@ import datetime +import unittest import dateutil.tz import pytz @@ -20,7 +21,7 @@ generate_map, ) -from .common import TimedTestCase +from .common import TimedTestCase, get_settings class EWSDateTimeTest(TimedTestCase): @@ -164,20 +165,20 @@ def test_ewstimezone(self): self.assertEqual(len(v), 2) self.assertIsInstance(v[0], str) - # Test IANA exceptions - sanitized = list(t for t in zoneinfo.available_timezones() if not t.startswith("SystemV/") and t != "localtime") - self.assertEqual(set(sanitized) - set(EWSTimeZone.IANA_TO_MS_MAP), set()) - # Test timezone unknown by ZoneInfo with self.assertRaises(UnknownTimeZone) as e: EWSTimeZone("UNKNOWN") self.assertEqual(e.exception.args[0], "No time zone found with key UNKNOWN") # Test timezone known by IANA but with no Winzone mapping - with self.assertRaises(UnknownTimeZone) as e: - del EWSTimeZone.IANA_TO_MS_MAP["Africa/Tripoli"] - EWSTimeZone("Africa/Tripoli") - self.assertEqual(e.exception.args[0], "No Windows timezone name found for timezone 'Africa/Tripoli'") + orig = EWSTimeZone.IANA_TO_MS_MAP["Africa/Tripoli"] + try: + with self.assertRaises(UnknownTimeZone) as e: + del EWSTimeZone.IANA_TO_MS_MAP["Africa/Tripoli"] + EWSTimeZone("Africa/Tripoli") + self.assertEqual(e.exception.args[0], "No Windows timezone name found for timezone 'Africa/Tripoli'") + finally: + EWSTimeZone.IANA_TO_MS_MAP["Africa/Tripoli"] = orig # Test __eq__ with non-EWSTimeZone compare self.assertFalse(EWSTimeZone("GMT") == zoneinfo.ZoneInfo("UTC")) @@ -201,6 +202,14 @@ def test_from_timezone(self): self.assertEqual(EWSTimeZone("UTC"), EWSTimeZone.from_timezone(datetime.timezone.utc)) def test_generate(self): + try: + get_settings() + except FileNotFoundError: + # We don't actually need settings here, but it's a convenient way to separate unit and integration tests. + # This test pulls in timezone maps from the Internet, which may cause the test case to break in the future. + # Let's leave the unit test suite as stable as possible. Unit tests are what is run if you don't create a + # settings.yml file. + raise unittest.SkipTest(f"Skipping {self.__class__.__name__} - this is an integration test") try: type_version, other_version, tz_map = generate_map() except CONNECTION_ERRORS: @@ -211,6 +220,10 @@ def test_generate(self): self.assertEqual(other_version, CLDR_WINZONE_OTHER_VERSION) self.assertDictEqual(tz_map, CLDR_TO_MS_TIMEZONE_MAP) + # Test IANA exceptions. This fails if available_timezones() returns timezones that we have not yet implemented. + sanitized = list(t for t in zoneinfo.available_timezones() if not t.startswith("SystemV/") and t != "localtime") + self.assertEqual(set(sanitized) - set(EWSTimeZone.IANA_TO_MS_MAP), set()) + @requests_mock.mock() def test_generate_failure(self, m): m.get(CLDR_WINZONE_URL, status_code=500) From f3052bd0553dc12697bd6dbc62c2ee539ec9971c Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 28 Sep 2022 15:11:34 +0200 Subject: [PATCH 270/509] chore: implement suggestions from refurb. Mainly using suppress() context manager --- exchangelib/autodiscover/cache.py | 23 +++++++---------------- exchangelib/autodiscover/discovery.py | 2 -- exchangelib/extended_properties.py | 5 ++--- exchangelib/fields.py | 5 ++--- exchangelib/folders/base.py | 17 ++++++----------- exchangelib/folders/collections.py | 2 +- exchangelib/folders/roots.py | 20 ++++++-------------- exchangelib/properties.py | 2 +- exchangelib/protocol.py | 11 ++++------- exchangelib/queryset.py | 9 +++------ exchangelib/restriction.py | 7 +++---- exchangelib/services/common.py | 20 ++++++-------------- exchangelib/transport.py | 13 +++++-------- exchangelib/util.py | 11 ++++------- 14 files changed, 50 insertions(+), 97 deletions(-) diff --git a/exchangelib/autodiscover/cache.py b/exchangelib/autodiscover/cache.py index a4b18b1a..2095775c 100644 --- a/exchangelib/autodiscover/cache.py +++ b/exchangelib/autodiscover/cache.py @@ -5,7 +5,7 @@ import shelve import sys import tempfile -from contextlib import contextmanager +from contextlib import contextmanager, suppress from threading import RLock from ..configuration import Configuration @@ -39,13 +39,10 @@ def shelve_open_with_failover(filename): # We don't know which file caused the error, so just delete them all. try: shelve_handle = shelve.open(filename) - # Try to actually use the shelve. Some implementations may allow opening the file but then throw + # Try to actually use the file. Some implementations may allow opening the file but then throw # errors on access. - try: + with suppress(KeyError): _ = shelve_handle[""] - except KeyError: - # The entry doesn't exist. This is expected. - pass except Exception as e: for f in glob.glob(filename + "*"): log.warning("Deleting invalid cache file %s (%r)", f, e) @@ -121,14 +118,10 @@ def __delitem__(self, key): # multiple times due to race conditions. domain = key[0] with shelve_open_with_failover(self._storage_file) as db: - try: + with suppress(KeyError): del db[str(domain)] - except KeyError: - pass - try: + with suppress(KeyError): del self._protocols[key] - except KeyError: - pass def close(self): # Close all open connections @@ -146,11 +139,9 @@ def __exit__(self, *args, **kwargs): def __del__(self): # pylint: disable=bare-except - try: - self.close() - except Exception: # nosec + with suppress(Exception): # __del__ should never fail - pass + self.close() def __str__(self): return str(self._protocols) diff --git a/exchangelib/autodiscover/discovery.py b/exchangelib/autodiscover/discovery.py index eb3ba661..2570da0c 100644 --- a/exchangelib/autodiscover/discovery.py +++ b/exchangelib/autodiscover/discovery.py @@ -307,8 +307,6 @@ def _get_authenticated_response(self, protocol): url=protocol.service_endpoint, headers=headers, data=data, - allow_redirects=False, - stream=False, ) protocol.release_session(session) except UnauthorizedError as e: diff --git a/exchangelib/extended_properties.py b/exchangelib/extended_properties.py index f62b1735..320be26e 100644 --- a/exchangelib/extended_properties.py +++ b/exchangelib/extended_properties.py @@ -1,4 +1,5 @@ import logging +from contextlib import suppress from decimal import Decimal from .errors import InvalidEnumValue @@ -199,10 +200,8 @@ def _normalize_obj(cls, obj): try: obj.property_set_id = cls.DISTINGUISHED_SET_NAME_TO_ID_MAP[obj.distinguished_property_set_id] except KeyError: - try: + with suppress(KeyError): obj.distinguished_property_set_id = cls.DISTINGUISHED_SET_ID_TO_NAME_MAP[obj.property_set_id] - except KeyError: - pass return obj @classmethod diff --git a/exchangelib/fields.py b/exchangelib/fields.py index 2775f683..ba7085ca 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -1,6 +1,7 @@ import abc import datetime import logging +from contextlib import suppress from decimal import Decimal, InvalidOperation from importlib import import_module @@ -653,14 +654,12 @@ class TimeField(FieldURIField): def from_xml(self, elem, account): val = self._get_val_from_elem(elem) if val is not None: - try: + with suppress(ValueError): if ":" in val: # Assume a string of the form HH:MM:SS return datetime.datetime.strptime(val, "%H:%M:%S").time() # Assume an integer in minutes since midnight return (datetime.datetime(2000, 1, 1) + datetime.timedelta(minutes=int(val))).time() - except ValueError: - pass return self.default diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 8e6c3795..cf3da55c 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -1,5 +1,6 @@ import abc import logging +from contextlib import suppress from fnmatch import fnmatch from operator import attrgetter @@ -329,10 +330,8 @@ def normalize_fields(self, fields): @classmethod def get_item_field_by_fieldname(cls, fieldname): for item_model in cls.supported_item_models: - try: + with suppress(InvalidField): return item_model.get_field_by_fieldname(fieldname) - except InvalidField: - pass raise InvalidField(f"{fieldname!r} is not a valid field name on {cls.supported_item_models}") def get(self, *args, **kwargs): @@ -443,7 +442,7 @@ def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0): has_distinguished_subfolders = any(f.is_distinguished for f in self.children) try: if has_distinguished_subfolders: - self.empty(delete_sub_folders=False) + self.empty() else: self.empty(delete_sub_folders=True) except ErrorRecoverableItemsAccessDenied: @@ -453,7 +452,7 @@ def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0): try: if has_distinguished_subfolders: raise # We already tried this - self.empty(delete_sub_folders=False) + self.empty() except DELETE_FOLDER_ERRORS: log.warning("Not allowed to empty %s. Trying to delete items instead", self) kwargs = {} @@ -914,20 +913,16 @@ def from_xml_with_root(cls, elem, root): # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. if folder.name: - try: + with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) - except KeyError: - pass if folder.folder_class and folder_cls == Folder: - try: + with suppress(KeyError): folder_cls = cls.folder_cls_from_container_class(container_class=folder.folder_class) log.debug( "Folder class %s matches container class %s (%s)", folder_cls, folder.folder_class, folder.name ) - except KeyError: - pass if folder_cls == Folder: log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS}) diff --git a/exchangelib/folders/collections.py b/exchangelib/folders/collections.py index 905bbc3a..03ce00fa 100644 --- a/exchangelib/folders/collections.py +++ b/exchangelib/folders/collections.py @@ -331,7 +331,7 @@ def resolve(self): else: resolveable_folders.append(f) # Fetch all properties for the remaining folders of folder IDs - additional_fields = self.get_folder_fields(target_cls=self._get_target_cls(), is_complex=None) + additional_fields = self.get_folder_fields(target_cls=self._get_target_cls()) yield from self.__class__(account=self.account, folders=resolveable_folders).get_folders( additional_fields=additional_fields ) diff --git a/exchangelib/folders/roots.py b/exchangelib/folders/roots.py index 0f8d70c8..11c02803 100644 --- a/exchangelib/folders/roots.py +++ b/exchangelib/folders/roots.py @@ -1,4 +1,5 @@ import logging +from contextlib import suppress from threading import Lock from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorInvalidOperation @@ -87,10 +88,8 @@ def update_folder(self, folder): def remove_folder(self, folder): if not folder.id: raise ValueError("'folder' must have an ID") - try: + with suppress(KeyError): del self._folders_map[folder.id] - except KeyError: - pass def clear_cache(self): with self._subfolders_lock: @@ -241,10 +240,8 @@ def tois(self): return self.get_default_folder(MsgFolderRoot) def get_default_folder(self, folder_cls): - try: + with suppress(MISSING_FOLDER_ERRORS): return super().get_default_folder(folder_cls) - except MISSING_FOLDER_ERRORS: - pass # Try to pick a suitable default folder. we do this by: # 1. Searching the full folder list for a folder with the distinguished folder name @@ -262,11 +259,9 @@ def get_default_folder(self, folder_cls): # Try direct children of TOIS first, unless we're trying to get the TOIS folder if folder_cls != MsgFolderRoot: - try: + with suppress(MISSING_FOLDER_ERRORS): return self._get_candidate(folder_cls=folder_cls, folder_coll=self.tois.children) - except MISSING_FOLDER_ERRORS: - # No candidates, or TOIS does not exist, or we don't have access to TOIS - pass + # No candidates, or TOIS does not exist, or we don't have access to TOIS # Finally, try direct children of root return self._get_candidate(folder_cls=folder_cls, folder_coll=self.children) @@ -314,7 +309,7 @@ def get_children(self, folder): return children_map = {} - try: + with suppress(ErrorAccessDenied): for f in ( SingleFolderQuerySet(account=self.account, folder=folder) .depth(self.DEFAULT_FOLDER_TRAVERSAL_DEPTH) @@ -326,9 +321,6 @@ def get_children(self, folder): if isinstance(f, Exception): raise f children_map[f.id] = f - except ErrorAccessDenied: - # No access to this folder - pass # Let's update the cache atomically, to avoid partial reads of the cache. with self._subfolders_lock: diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 4380f696..16044cc7 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -980,7 +980,7 @@ def from_xml(cls, elem, account): kwargs = {} working_hours_elem = elem.find(f"{{{TNS}}}WorkingHours") for f in cls.FIELDS: - if f.name in ["working_hours", "working_hours_timezone"]: + if f.name in ("working_hours", "working_hours_timezone"): if working_hours_elem is None: continue kwargs[f.name] = f.from_xml(elem=working_hours_elem, account=account) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index eac9dcdb..2d750541 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -8,6 +8,7 @@ import datetime import logging import random +from contextlib import suppress from queue import Empty, LifoQueue from threading import Lock @@ -142,11 +143,9 @@ def __setstate__(self, state): def __del__(self): # pylint: disable=bare-except - try: - self.close() - except Exception: # nosec + with suppress(Exception): # __del__ should never fail - pass + self.close() def close(self): log.debug("Server %s: Closing sessions", self.server) @@ -221,10 +220,8 @@ def get_session(self): session = self._session_pool.get(block=False) log.debug("Server %s: Got session immediately", self.server) except Empty: - try: + with suppress(SessionPoolMaxSizeReached): self.increase_poolsize() - except SessionPoolMaxSizeReached: - pass while True: try: log.debug("Server %s: Waiting for session", self.server) diff --git a/exchangelib/queryset.py b/exchangelib/queryset.py index 4fa47860..0602f1a2 100644 --- a/exchangelib/queryset.py +++ b/exchangelib/queryset.py @@ -1,5 +1,6 @@ import abc import logging +from contextlib import suppress from copy import deepcopy from itertools import islice @@ -110,10 +111,8 @@ def _get_field_path(self, field_path): if self.request_type == self.PERSONA: return FieldPath(field=Persona.get_field_by_fieldname(field_path)) for folder in self.folder_collection: - try: + with suppress(InvalidField): return FieldPath.from_string(field_path=field_path, folder=folder) - except InvalidField: - pass raise InvalidField(f"Unknown field path {field_path!r} on folders {self.folder_collection.folders}") def _get_field_order(self, field_path): @@ -125,10 +124,8 @@ def _get_field_order(self, field_path): reverse=field_path.startswith("-"), ) for folder in self.folder_collection: - try: + with suppress(InvalidField): return FieldOrder.from_string(field_path=field_path, folder=folder) - except InvalidField: - pass raise InvalidField(f"Unknown field path {field_path!r} on folders {self.folder_collection.folders}") @property diff --git a/exchangelib/restriction.py b/exchangelib/restriction.py index e189507f..207ce9f4 100644 --- a/exchangelib/restriction.py +++ b/exchangelib/restriction.py @@ -1,4 +1,5 @@ import logging +from contextlib import suppress from copy import copy from .errors import InvalidEnumValue @@ -93,7 +94,7 @@ def __init__(self, *args, **kwargs): self.children.extend(args) # Parse keyword args and extract the filter - is_single_kwarg = len(args) == 0 and len(kwargs) == 1 + is_single_kwarg = not args and len(kwargs) == 1 for key, value in kwargs.items(): self.children.extend(self._get_children_from_kwarg(key=key, value=value, is_single_kwarg=is_single_kwarg)) @@ -505,13 +506,11 @@ def __invert__(self): self.LT: self.GTE, self.LTE: self.GT, } - try: + with suppress(KeyError): new = copy(self) new.op = inverse_ops[self.op] new.reduce() return new - except KeyError: - pass return self.__class__(self, conn_type=self.NOT) def __eq__(self, other): diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index cdefdbad..bee8ea92 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -1,5 +1,6 @@ import abc import logging +from contextlib import suppress from itertools import chain from .. import errors @@ -127,13 +128,11 @@ def __init__(self, protocol, chunk_size=None, timeout=None): def __del__(self): # pylint: disable=bare-except - try: + with suppress(Exception): + # __del__ should never fail if self.streaming: # Make sure to clean up lingering resources self.stop_streaming() - except Exception: # nosec - # __del__ should never fail - pass # The following two methods are the minimum required to be implemented by subclasses, but the name and number of # kwargs differs between services. Therefore, we cannot make these methods abstract. @@ -305,7 +304,6 @@ def _get_response(self, payload, api_version): account_to_impersonate=self._account_to_impersonate, timezone=self._timezone, ), - allow_redirects=False, stream=self.streaming, timeout=self.timeout or self.protocol.TIMEOUT, ) @@ -388,10 +386,8 @@ def _handle_backoff(self, e): """ log.debug("Got ErrorServerBusy (back off %s seconds)", e.back_off) # ErrorServerBusy is very often a symptom of sending too many requests. Scale back connections if possible. - try: + with suppress(SessionPoolMinSizeReached): self.protocol.decrease_poolsize() - except SessionPoolMinSizeReached: - pass if self.protocol.retry_policy.fail_fast: raise e self.protocol.retry_policy.back_off(e.back_off) @@ -477,12 +473,10 @@ def _raise_soap_errors(cls, fault): msg_xml = detail.find(f"{{{TNS}}}MessageXml") # Crazy. Here, it's in the TNS namespace if code == "ErrorServerBusy": back_off = None - try: + with suppress(TypeError, AttributeError): value = msg_xml.find(f"{{{TNS}}}Value") if value.get("Name") == "BackOffMilliseconds": back_off = int(value.text) / 1000.0 # Convert to seconds - except (TypeError, AttributeError): - pass raise ErrorServerBusy(msg, back_off=back_off) if code == "ErrorSchemaValidation" and msg_xml is not None: line_number = get_xml_attr(msg_xml, f"{{{TNS}}}LineNumber") @@ -496,10 +490,8 @@ def _raise_soap_errors(cls, fault): raise vars(errors)[code](msg) except KeyError: detail = f"{cls.SERVICE_NAME}: code: {code} msg: {msg} ({xml_to_str(detail)})" - try: + with suppress(KeyError): raise vars(errors)[fault_code](fault_string) - except KeyError: - pass raise SOAPError(f"SOAP error code: {fault_code} string: {fault_string} actor: {fault_actor} detail: {detail}") def _get_element_container(self, message, name=None): diff --git a/exchangelib/transport.py b/exchangelib/transport.py index c0931397..677353f5 100644 --- a/exchangelib/transport.py +++ b/exchangelib/transport.py @@ -1,5 +1,6 @@ import logging import time +from contextlib import suppress import requests.auth import requests_ntlm @@ -41,20 +42,16 @@ CBA: None, NOAUTH: None, } -try: +with suppress(ImportError): + # Kerberos auth is optional import requests_gssapi AUTH_TYPE_MAP[GSSAPI] = requests_gssapi.HTTPSPNEGOAuth -except ImportError: - # Kerberos auth is optional - pass -try: +with suppress(ImportError): + # SSPI auth is optional import requests_negotiate_sspi AUTH_TYPE_MAP[SSPI] = requests_negotiate_sspi.HttpNegotiateAuth -except ImportError: - # SSPI auth is optional - pass DEFAULT_ENCODING = "utf-8" DEFAULT_HEADERS = {"Content-Type": f"text/xml; charset={DEFAULT_ENCODING}", "Accept-Encoding": "gzip, deflate"} diff --git a/exchangelib/util.py b/exchangelib/util.py index 36803fda..cb191436 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -8,6 +8,7 @@ import xml.sax.handler # nosec from base64 import b64decode, b64encode from codecs import BOM_UTF8 +from contextlib import suppress from decimal import Decimal from functools import wraps from threading import get_ident @@ -538,10 +539,8 @@ def to_xml(bytes_content): msg = f'{e}\nOffending text: [...]{offending_excerpt.decode("utf-8", errors="ignore")}[...]' raise ParseError(msg, "", e.lineno, e.offset) except TypeError: - try: + with suppress(IndexError, io.UnsupportedOperation): stream.seek(0) - except (IndexError, io.UnsupportedOperation): - pass raise ParseError(f"This is not XML: {stream.read()!r}", "", -1, 0) if res.getroot() is None: @@ -581,8 +580,8 @@ def prettify_xml(self, xml_bytes): """Re-format an XML document to a consistent style.""" return ( lxml.etree.tostring(self.parse_bytes(xml_bytes), xml_declaration=True, encoding="utf-8", pretty_print=True) - .replace(b"\t", b" ") .replace(b" xmlns:", b"\n xmlns:") + .expandtabs() ) @staticmethod @@ -730,13 +729,11 @@ def get_redirect_url(response, allow_relative=True, require_relative=False): # A collection of error classes we want to handle as TLS verification errors TLS_ERRORS = (requests.exceptions.SSLError,) -try: +with suppress(ImportError): # If pyOpenSSL is installed, requests will use it and throw this class on TLS errors import OpenSSL.SSL TLS_ERRORS += (OpenSSL.SSL.Error,) -except ImportError: - pass def post_ratelimited(protocol, session, url, headers, data, allow_redirects=False, stream=False, timeout=None): From cee88df28ef0190ec308e25b7aa97e893bbc45e6 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 3 Oct 2022 20:45:03 +0200 Subject: [PATCH 271/509] Support delegate access with OAuth (#1121) --- docs/index.md | 20 ++++++++++++++++++-- exchangelib/__init__.py | 10 +++++++++- exchangelib/configuration.py | 3 ++- exchangelib/credentials.py | 21 +++++++++++++++++++++ exchangelib/protocol.py | 8 ++++++-- tests/test_credentials.py | 10 +++++++++- 6 files changed, 65 insertions(+), 7 deletions(-) diff --git a/docs/index.md b/docs/index.md index 57c00507..217f37fe 100644 --- a/docs/index.md +++ b/docs/index.md @@ -121,6 +121,8 @@ supported, if your server expects that. from exchangelib import Credentials credentials = Credentials(username='MYWINDOMAIN\\myuser', password='topsecret') +# For Office365 +credentials = Credentials(username='myuser@example.com', password='topsecret') ``` If you're running long-running jobs, you may want to enable fault-tolerance. Fault-tolerance means that requests to the server do an exponential backoff @@ -305,8 +307,8 @@ config = Configuration(auth_type=CBA) ### OAuth authentication OAuth is supported via the OAUTH2 auth type and the OAuth2Credentials class. -Use OAuth2AuthorizationCodeCredentials for the authorization code flow (useful -for applications that access multiple accounts). +Use OAuth2AuthorizationCodeCredentials instead for the authorization code flow +(useful for applications that access multiple accounts). ```python from exchangelib import OAuth2Credentials @@ -316,6 +318,20 @@ credentials = OAuth2Credentials( ) ``` +If you need to support legacy password-based authentication using OAuth and +delegated permissions, use the OAuth2LegacyCredentials class instead: + +```python +from exchangelib import OAuth2LegacyCredentials + +credentials = OAuth2LegacyCredentials( + client_id='MY_ID', client_secret='MY_SECRET', tenant_id='TENANT_ID', + username='myuser@example.com', password='topsecret' +) +config = Configuration(credentials=credentials, ...) +account = Account('myuser@example.com', config=config, access_type=DELEGATE) +``` + The OAuth2 flow may need to have impersonation headers set. If you get impersonation errors, add information about the account that the OAuth2 credentials was created for: diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index d702dc95..c8ef33bc 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -2,7 +2,14 @@ from .attachments import FileAttachment, ItemAttachment from .autodiscover import discover from .configuration import Configuration -from .credentials import DELEGATE, IMPERSONATION, Credentials, OAuth2AuthorizationCodeCredentials, OAuth2Credentials +from .credentials import ( + DELEGATE, + IMPERSONATION, + Credentials, + OAuth2AuthorizationCodeCredentials, + OAuth2Credentials, + OAuth2LegacyCredentials, +) from .ewsdatetime import UTC, UTC_NOW, EWSDate, EWSDateTime, EWSTimeZone from .extended_properties import ExtendedProperty from .folders import DEEP, SHALLOW, Folder, FolderCollection, RootOfHierarchy @@ -75,6 +82,7 @@ "OAUTH2", "OAuth2AuthorizationCodeCredentials", "OAuth2Credentials", + "OAuth2LegacyCredentials", "OofSettings", "PostItem", "PostReplyItem", diff --git a/exchangelib/configuration.py b/exchangelib/configuration.py index b79745a2..17ba150f 100644 --- a/exchangelib/configuration.py +++ b/exchangelib/configuration.py @@ -2,7 +2,7 @@ from cached_property import threaded_cached_property -from .credentials import BaseCredentials, OAuth2AuthorizationCodeCredentials, OAuth2Credentials +from .credentials import BaseCredentials, OAuth2AuthorizationCodeCredentials, OAuth2Credentials, OAuth2LegacyCredentials from .errors import InvalidEnumValue, InvalidTypeError from .protocol import FailFast, RetryPolicy from .transport import AUTH_TYPE_MAP, CREDENTIALS_REQUIRED, OAUTH2 @@ -14,6 +14,7 @@ DEFAULT_AUTH_TYPE = { # This type of credentials *must* use the OAuth auth type OAuth2Credentials: OAUTH2, + OAuth2LegacyCredentials: OAUTH2, OAuth2AuthorizationCodeCredentials: OAUTH2, } diff --git a/exchangelib/credentials.py b/exchangelib/credentials.py index 41b1792e..95811b6c 100644 --- a/exchangelib/credentials.py +++ b/exchangelib/credentials.py @@ -177,6 +177,27 @@ def __str__(self): return self.client_id +class OAuth2LegacyCredentials(OAuth2Credentials): + """Login info for OAuth 2.0 authentication using delegated permissions and application permissions. + + This requires the app to acquire username and password from the user and pass that when requesting authentication + tokens for the given user. This allows the app to act as the signed-in user. + """ + + def __init__(self, username, password, **kwargs): + """ + :param username: The username of the user to act as + :poram password: The password of the user to act as + """ + super().__init__(**kwargs) + self.username = username + self.password = password + + @property + def scope(self): + return ["https://outlook.office365.com/EWS.AccessAsUser.All"] + + class OAuth2AuthorizationCodeCredentials(OAuth2Credentials): """Login info for OAuth 2.0 authentication using the authorization code grant type. This can be used in one of several ways: diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 2d750541..80d83aa9 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -14,10 +14,10 @@ import requests.adapters import requests.sessions -from oauthlib.oauth2 import BackendApplicationClient, WebApplicationClient +from oauthlib.oauth2 import BackendApplicationClient, LegacyApplicationClient, WebApplicationClient from requests_oauthlib import OAuth2Session -from .credentials import OAuth2AuthorizationCodeCredentials, OAuth2Credentials +from .credentials import OAuth2AuthorizationCodeCredentials, OAuth2Credentials, OAuth2LegacyCredentials from .errors import ( CASError, ErrorInvalidSchemaVersionForMailboxVersion, @@ -333,6 +333,10 @@ def create_oauth2_session(self): } ) client = WebApplicationClient(client_id=self.credentials.client_id) + elif isinstance(self.credentials, OAuth2LegacyCredentials): + client = LegacyApplicationClient(client_id=self.credentials.client_id) + token_params["username"] = self.credentials.username + token_params["password"] = self.credentials.password else: client = BackendApplicationClient(client_id=self.credentials.client_id) diff --git a/tests/test_credentials.py b/tests/test_credentials.py index a3eed2d8..564b71f8 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -1,7 +1,12 @@ import pickle from exchangelib.account import Identity -from exchangelib.credentials import Credentials, OAuth2AuthorizationCodeCredentials, OAuth2Credentials +from exchangelib.credentials import ( + Credentials, + OAuth2AuthorizationCodeCredentials, + OAuth2Credentials, + OAuth2LegacyCredentials, +) from .common import TimedTestCase @@ -30,6 +35,9 @@ def test_pickle(self): Credentials("XXX", "YYY"), OAuth2Credentials(client_id="XXX", client_secret="YYY", tenant_id="ZZZZ"), OAuth2Credentials(client_id="XXX", client_secret="YYY", tenant_id="ZZZZ", identity=Identity("AAA")), + OAuth2LegacyCredentials( + client_id="XXX", client_secret="YYY", tenant_id="ZZZZ", username="AAA", password="BBB" + ), OAuth2AuthorizationCodeCredentials(client_id="WWW", client_secret="XXX", authorization_code="YYY"), OAuth2AuthorizationCodeCredentials( client_id="WWW", client_secret="XXX", access_token={"access_token": "ZZZ"} From 737c912007ead10a5fac4819aef57905a7c042f4 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 4 Oct 2022 09:55:26 +0200 Subject: [PATCH 272/509] docs: regenerate. Remove suppress from __del__ where the function may no longer be available --- docs/exchangelib/autodiscover/cache.html | 34 +- docs/exchangelib/autodiscover/discovery.html | 6 +- docs/exchangelib/autodiscover/index.html | 12 +- docs/exchangelib/configuration.html | 5 +- docs/exchangelib/credentials.html | 94 ++- docs/exchangelib/ewsdatetime.html | 25 +- docs/exchangelib/extended_properties.html | 11 +- docs/exchangelib/fields.html | 11 +- docs/exchangelib/folders/base.html | 51 +- docs/exchangelib/folders/collections.html | 8 +- docs/exchangelib/folders/index.html | 68 +- docs/exchangelib/folders/roots.html | 52 +- docs/exchangelib/index.html | 188 +++-- docs/exchangelib/properties.html | 8 +- docs/exchangelib/protocol.html | 72 +- docs/exchangelib/queryset.html | 27 +- docs/exchangelib/restriction.html | 15 +- docs/exchangelib/services/common.html | 56 +- docs/exchangelib/services/index.html | 764 ++++++++++++++++++- docs/exchangelib/transport.html | 15 +- docs/exchangelib/util.html | 21 +- docs/exchangelib/winzone.html | 3 +- exchangelib/autodiscover/cache.py | 6 +- exchangelib/protocol.py | 6 +- exchangelib/services/common.py | 6 +- 25 files changed, 1229 insertions(+), 335 deletions(-) diff --git a/docs/exchangelib/autodiscover/cache.html b/docs/exchangelib/autodiscover/cache.html index e7ddaec1..d1bdfd91 100644 --- a/docs/exchangelib/autodiscover/cache.html +++ b/docs/exchangelib/autodiscover/cache.html @@ -33,7 +33,7 @@

        Module exchangelib.autodiscover.cache

        import shelve import sys import tempfile -from contextlib import contextmanager +from contextlib import contextmanager, suppress from threading import RLock from ..configuration import Configuration @@ -67,13 +67,10 @@

        Module exchangelib.autodiscover.cache

        # We don't know which file caused the error, so just delete them all. try: shelve_handle = shelve.open(filename) - # Try to actually use the shelve. Some implementations may allow opening the file but then throw + # Try to actually use the file. Some implementations may allow opening the file but then throw # errors on access. - try: + with suppress(KeyError): _ = shelve_handle[""] - except KeyError: - # The entry doesn't exist. This is expected. - pass except Exception as e: for f in glob.glob(filename + "*"): log.warning("Deleting invalid cache file %s (%r)", f, e) @@ -149,14 +146,10 @@

        Module exchangelib.autodiscover.cache

        # multiple times due to race conditions. domain = key[0] with shelve_open_with_failover(self._storage_file) as db: - try: + with suppress(KeyError): del db[str(domain)] - except KeyError: - pass - try: + with suppress(KeyError): del self._protocols[key] - except KeyError: - pass def close(self): # Close all open connections @@ -234,13 +227,10 @@

        Functions

        # We don't know which file caused the error, so just delete them all. try: shelve_handle = shelve.open(filename) - # Try to actually use the shelve. Some implementations may allow opening the file but then throw + # Try to actually use the file. Some implementations may allow opening the file but then throw # errors on access. - try: + with suppress(KeyError): _ = shelve_handle[""] - except KeyError: - # The entry doesn't exist. This is expected. - pass except Exception as e: for f in glob.glob(filename + "*"): log.warning("Deleting invalid cache file %s (%r)", f, e) @@ -340,14 +330,10 @@

        Classes

        # multiple times due to race conditions. domain = key[0] with shelve_open_with_failover(self._storage_file) as db: - try: + with suppress(KeyError): del db[str(domain)] - except KeyError: - pass - try: + with suppress(KeyError): del self._protocols[key] - except KeyError: - pass def close(self): # Close all open connections @@ -450,4 +436,4 @@

        pdoc 0.10.0.

        - \ No newline at end of file + diff --git a/docs/exchangelib/autodiscover/discovery.html b/docs/exchangelib/autodiscover/discovery.html index 81e752e7..9e114fa6 100644 --- a/docs/exchangelib/autodiscover/discovery.html +++ b/docs/exchangelib/autodiscover/discovery.html @@ -335,8 +335,6 @@

        Module exchangelib.autodiscover.discovery

        url=protocol.service_endpoint, headers=headers, data=data, - allow_redirects=False, - stream=False, ) protocol.release_session(session) except UnauthorizedError as e: @@ -911,8 +909,6 @@

        Classes

        url=protocol.service_endpoint, headers=headers, data=data, - allow_redirects=False, - stream=False, ) protocol.release_session(session) except UnauthorizedError as e: @@ -1350,4 +1346,4 @@

        pdoc 0.10.0.

        - \ No newline at end of file + diff --git a/docs/exchangelib/autodiscover/index.html b/docs/exchangelib/autodiscover/index.html index 3edb0d9e..e264901c 100644 --- a/docs/exchangelib/autodiscover/index.html +++ b/docs/exchangelib/autodiscover/index.html @@ -213,14 +213,10 @@

        Classes

        # multiple times due to race conditions. domain = key[0] with shelve_open_with_failover(self._storage_file) as db: - try: + with suppress(KeyError): del db[str(domain)] - except KeyError: - pass - try: + with suppress(KeyError): del self._protocols[key] - except KeyError: - pass def close(self): # Close all open connections @@ -608,8 +604,6 @@

        Inherited members

        url=protocol.service_endpoint, headers=headers, data=data, - allow_redirects=False, - stream=False, ) protocol.release_session(session) except UnauthorizedError as e: @@ -1044,4 +1038,4 @@

        pdoc 0.10.0.

        - \ No newline at end of file + diff --git a/docs/exchangelib/configuration.html b/docs/exchangelib/configuration.html index 5feb396b..3b671ff3 100644 --- a/docs/exchangelib/configuration.html +++ b/docs/exchangelib/configuration.html @@ -30,7 +30,7 @@

        Module exchangelib.configuration

        from cached_property import threaded_cached_property -from .credentials import BaseCredentials, OAuth2AuthorizationCodeCredentials, OAuth2Credentials +from .credentials import BaseCredentials, OAuth2AuthorizationCodeCredentials, OAuth2Credentials, OAuth2LegacyCredentials from .errors import InvalidEnumValue, InvalidTypeError from .protocol import FailFast, RetryPolicy from .transport import AUTH_TYPE_MAP, CREDENTIALS_REQUIRED, OAUTH2 @@ -42,6 +42,7 @@

        Module exchangelib.configuration

        DEFAULT_AUTH_TYPE = { # This type of credentials *must* use the OAuth auth type OAuth2Credentials: OAUTH2, + OAuth2LegacyCredentials: OAUTH2, OAuth2AuthorizationCodeCredentials: OAUTH2, } @@ -326,4 +327,4 @@

        pdoc 0.10.0.

        - \ No newline at end of file + diff --git a/docs/exchangelib/credentials.html b/docs/exchangelib/credentials.html index 7f58c846..7019c581 100644 --- a/docs/exchangelib/credentials.html +++ b/docs/exchangelib/credentials.html @@ -210,6 +210,27 @@

        Module exchangelib.credentials

        return self.client_id +class OAuth2LegacyCredentials(OAuth2Credentials): + """Login info for OAuth 2.0 authentication using delegated permissions and application permissions. + + This requires the app to acquire username and password from the user and pass that when requesting authentication + tokens for the given user. This allows the app to act as the signed-in user. + """ + + def __init__(self, username, password, **kwargs): + """ + :param username: The username of the user to act as + :poram password: The password of the user to act as + """ + super().__init__(**kwargs) + self.username = username + self.password = password + + @property + def scope(self): + return ["https://outlook.office365.com/EWS.AccessAsUser.All"] + + class OAuth2AuthorizationCodeCredentials(OAuth2Credentials): """Login info for OAuth 2.0 authentication using the authorization code grant type. This can be used in one of several ways: @@ -697,6 +718,7 @@

        Ancestors

        Subclasses

        Instance variables

        @@ -789,6 +811,70 @@

        Inherited members

      +
      +class OAuth2LegacyCredentials +(username, password, **kwargs) +
      +
      +

      Login info for OAuth 2.0 authentication using delegated permissions and application permissions.

      +

      This requires the app to acquire username and password from the user and pass that when requesting authentication +tokens for the given user. This allows the app to act as the signed-in user.

      +

      :param username: The username of the user to act as +:poram password: The password of the user to act as

      +
      + +Expand source code + +
      class OAuth2LegacyCredentials(OAuth2Credentials):
      +    """Login info for OAuth 2.0 authentication using delegated permissions and application permissions.
      +
      +    This requires the app to acquire username and password from the user and pass that when requesting authentication
      +    tokens for the given user. This allows the app to act as the signed-in user.
      +    """
      +
      +    def __init__(self, username, password, **kwargs):
      +        """
      +        :param username: The username of the user to act as
      +        :poram password: The password of the user to act as
      +        """
      +        super().__init__(**kwargs)
      +        self.username = username
      +        self.password = password
      +
      +    @property
      +    def scope(self):
      +        return ["https://outlook.office365.com/EWS.AccessAsUser.All"]
      +
      +

      Ancestors

      + +

      Instance variables

      +
      +
      var scope
      +
      +
      +
      + +Expand source code + +
      @property
      +def scope(self):
      +    return ["https://outlook.office365.com/EWS.AccessAsUser.All"]
      +
      +
      +
      +

      Inherited members

      + +
      @@ -836,6 +922,12 @@

      token_url

  • +
  • +

    OAuth2LegacyCredentials

    + +
  • @@ -845,4 +937,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/ewsdatetime.html b/docs/exchangelib/ewsdatetime.html index 36fce92e..6a3846ab 100644 --- a/docs/exchangelib/ewsdatetime.html +++ b/docs/exchangelib/ewsdatetime.html @@ -158,6 +158,10 @@

    Module exchangelib.ewsdatetime

    return t return self.from_datetime(t) # We want to return EWSDateTime objects + @classmethod + def fromisoformat(cls, date_string): + return cls.from_string(date_string) + def __add__(self, other): t = super().__add__(other) if isinstance(t, self.__class__): @@ -579,6 +583,10 @@

    Methods

    return t return self.from_datetime(t) # We want to return EWSDateTime objects + @classmethod + def fromisoformat(cls, date_string): + return cls.from_string(date_string) + def __add__(self, other): t = super().__add__(other) if isinstance(t, self.__class__): @@ -704,6 +712,20 @@

    Static methods

    return cls.from_datetime(aware_dt)
    +
    +def fromisoformat(date_string) +
    +
    +

    string -> datetime from datetime.isoformat() output

    +
    + +Expand source code + +
    @classmethod
    +def fromisoformat(cls, date_string):
    +    return cls.from_string(date_string)
    +
    +
    def fromtimestamp(t, tz=None)
    @@ -1161,6 +1183,7 @@

    ewsformat
  • from_datetime
  • from_string
  • +
  • fromisoformat
  • fromtimestamp
  • now
  • utcfromtimestamp
  • @@ -1191,4 +1214,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/extended_properties.html b/docs/exchangelib/extended_properties.html index 0a9595d8..fe238892 100644 --- a/docs/exchangelib/extended_properties.html +++ b/docs/exchangelib/extended_properties.html @@ -27,6 +27,7 @@

    Module exchangelib.extended_properties

    Expand source code
    import logging
    +from contextlib import suppress
     from decimal import Decimal
     
     from .errors import InvalidEnumValue
    @@ -227,10 +228,8 @@ 

    Module exchangelib.extended_properties

    try: obj.property_set_id = cls.DISTINGUISHED_SET_NAME_TO_ID_MAP[obj.distinguished_property_set_id] except KeyError: - try: + with suppress(KeyError): obj.distinguished_property_set_id = cls.DISTINGUISHED_SET_ID_TO_NAME_MAP[obj.property_set_id] - except KeyError: - pass return obj @classmethod @@ -539,10 +538,8 @@

    Classes

    try: obj.property_set_id = cls.DISTINGUISHED_SET_NAME_TO_ID_MAP[obj.distinguished_property_set_id] except KeyError: - try: + with suppress(KeyError): obj.distinguished_property_set_id = cls.DISTINGUISHED_SET_ID_TO_NAME_MAP[obj.property_set_id] - except KeyError: - pass return obj @classmethod @@ -1094,4 +1091,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/fields.html b/docs/exchangelib/fields.html index a52f863d..73ca36e0 100644 --- a/docs/exchangelib/fields.html +++ b/docs/exchangelib/fields.html @@ -29,6 +29,7 @@

    Module exchangelib.fields

    import abc
     import datetime
     import logging
    +from contextlib import suppress
     from decimal import Decimal, InvalidOperation
     from importlib import import_module
     
    @@ -681,14 +682,12 @@ 

    Module exchangelib.fields

    def from_xml(self, elem, account): val = self._get_val_from_elem(elem) if val is not None: - try: + with suppress(ValueError): if ":" in val: # Assume a string of the form HH:MM:SS return datetime.datetime.strptime(val, "%H:%M:%S").time() # Assume an integer in minutes since midnight return (datetime.datetime(2000, 1, 1) + datetime.timedelta(minutes=int(val))).time() - except ValueError: - pass return self.default @@ -6665,14 +6664,12 @@

    Inherited members

    def from_xml(self, elem, account): val = self._get_val_from_elem(elem) if val is not None: - try: + with suppress(ValueError): if ":" in val: # Assume a string of the form HH:MM:SS return datetime.datetime.strptime(val, "%H:%M:%S").time() # Assume an integer in minutes since midnight return (datetime.datetime(2000, 1, 1) + datetime.timedelta(minutes=int(val))).time() - except ValueError: - pass return self.default

    Ancestors

    @@ -7641,4 +7638,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/folders/base.html b/docs/exchangelib/folders/base.html index 7d1b336a..bf3e5102 100644 --- a/docs/exchangelib/folders/base.html +++ b/docs/exchangelib/folders/base.html @@ -28,6 +28,7 @@

    Module exchangelib.folders.base

    import abc
     import logging
    +from contextlib import suppress
     from fnmatch import fnmatch
     from operator import attrgetter
     
    @@ -357,10 +358,8 @@ 

    Module exchangelib.folders.base

    @classmethod def get_item_field_by_fieldname(cls, fieldname): for item_model in cls.supported_item_models: - try: + with suppress(InvalidField): return item_model.get_field_by_fieldname(fieldname) - except InvalidField: - pass raise InvalidField(f"{fieldname!r} is not a valid field name on {cls.supported_item_models}") def get(self, *args, **kwargs): @@ -471,7 +470,7 @@

    Module exchangelib.folders.base

    has_distinguished_subfolders = any(f.is_distinguished for f in self.children) try: if has_distinguished_subfolders: - self.empty(delete_sub_folders=False) + self.empty() else: self.empty(delete_sub_folders=True) except ErrorRecoverableItemsAccessDenied: @@ -481,7 +480,7 @@

    Module exchangelib.folders.base

    try: if has_distinguished_subfolders: raise # We already tried this - self.empty(delete_sub_folders=False) + self.empty() except DELETE_FOLDER_ERRORS: log.warning("Not allowed to empty %s. Trying to delete items instead", self) kwargs = {} @@ -942,20 +941,16 @@

    Module exchangelib.folders.base

    # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. if folder.name: - try: + with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) - except KeyError: - pass if folder.folder_class and folder_cls == Folder: - try: + with suppress(KeyError): folder_cls = cls.folder_cls_from_container_class(container_class=folder.folder_class) log.debug( "Folder class %s matches container class %s (%s)", folder_cls, folder.folder_class, folder.name ) - except KeyError: - pass if folder_cls == Folder: log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS})
    @@ -1253,10 +1248,8 @@

    Classes

    @classmethod def get_item_field_by_fieldname(cls, fieldname): for item_model in cls.supported_item_models: - try: + with suppress(InvalidField): return item_model.get_field_by_fieldname(fieldname) - except InvalidField: - pass raise InvalidField(f"{fieldname!r} is not a valid field name on {cls.supported_item_models}") def get(self, *args, **kwargs): @@ -1367,7 +1360,7 @@

    Classes

    has_distinguished_subfolders = any(f.is_distinguished for f in self.children) try: if has_distinguished_subfolders: - self.empty(delete_sub_folders=False) + self.empty() else: self.empty(delete_sub_folders=True) except ErrorRecoverableItemsAccessDenied: @@ -1377,7 +1370,7 @@

    Classes

    try: if has_distinguished_subfolders: raise # We already tried this - self.empty(delete_sub_folders=False) + self.empty() except DELETE_FOLDER_ERRORS: log.warning("Not allowed to empty %s. Trying to delete items instead", self) kwargs = {} @@ -1890,10 +1883,8 @@

    Static methods

    @classmethod
     def get_item_field_by_fieldname(cls, fieldname):
         for item_model in cls.supported_item_models:
    -        try:
    +        with suppress(InvalidField):
                 return item_model.get_field_by_fieldname(fieldname)
    -        except InvalidField:
    -            pass
         raise InvalidField(f"{fieldname!r} is not a valid field name on {cls.supported_item_models}")
    @@ -2881,7 +2872,7 @@

    Methods

    has_distinguished_subfolders = any(f.is_distinguished for f in self.children) try: if has_distinguished_subfolders: - self.empty(delete_sub_folders=False) + self.empty() else: self.empty(delete_sub_folders=True) except ErrorRecoverableItemsAccessDenied: @@ -2891,7 +2882,7 @@

    Methods

    try: if has_distinguished_subfolders: raise # We already tried this - self.empty(delete_sub_folders=False) + self.empty() except DELETE_FOLDER_ERRORS: log.warning("Not allowed to empty %s. Trying to delete items instead", self) kwargs = {} @@ -3062,20 +3053,16 @@

    Inherited members

    # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. if folder.name: - try: + with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) - except KeyError: - pass if folder.folder_class and folder_cls == Folder: - try: + with suppress(KeyError): folder_cls = cls.folder_cls_from_container_class(container_class=folder.folder_class) log.debug( "Folder class %s matches container class %s (%s)", folder_cls, folder.folder_class, folder.name ) - except KeyError: - pass if folder_cls == Folder: log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS})

    @@ -3174,20 +3161,16 @@

    Static methods

    # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. if folder.name: - try: + with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) - except KeyError: - pass if folder.folder_class and folder_cls == Folder: - try: + with suppress(KeyError): folder_cls = cls.folder_cls_from_container_class(container_class=folder.folder_class) log.debug( "Folder class %s matches container class %s (%s)", folder_cls, folder.folder_class, folder.name ) - except KeyError: - pass if folder_cls == Folder: log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS})
    @@ -3398,4 +3381,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/folders/collections.html b/docs/exchangelib/folders/collections.html index a1d05210..8ce2a04b 100644 --- a/docs/exchangelib/folders/collections.html +++ b/docs/exchangelib/folders/collections.html @@ -359,7 +359,7 @@

    Module exchangelib.folders.collections

    else: resolveable_folders.append(f) # Fetch all properties for the remaining folders of folder IDs - additional_fields = self.get_folder_fields(target_cls=self._get_target_cls(), is_complex=None) + additional_fields = self.get_folder_fields(target_cls=self._get_target_cls()) yield from self.__class__(account=self.account, folders=resolveable_folders).get_folders( additional_fields=additional_fields ) @@ -966,7 +966,7 @@

    Subclasses

    else: resolveable_folders.append(f) # Fetch all properties for the remaining folders of folder IDs - additional_fields = self.get_folder_fields(target_cls=self._get_target_cls(), is_complex=None) + additional_fields = self.get_folder_fields(target_cls=self._get_target_cls()) yield from self.__class__(account=self.account, folders=resolveable_folders).get_folders( additional_fields=additional_fields ) @@ -1602,7 +1602,7 @@

    Examples

    else: resolveable_folders.append(f) # Fetch all properties for the remaining folders of folder IDs - additional_fields = self.get_folder_fields(target_cls=self._get_target_cls(), is_complex=None) + additional_fields = self.get_folder_fields(target_cls=self._get_target_cls()) yield from self.__class__(account=self.account, folders=resolveable_folders).get_folders( additional_fields=additional_fields )
    @@ -2042,4 +2042,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/folders/index.html b/docs/exchangelib/folders/index.html index af9ef9c2..6ff9dc54 100644 --- a/docs/exchangelib/folders/index.html +++ b/docs/exchangelib/folders/index.html @@ -1519,10 +1519,8 @@

    Inherited members

    @classmethod def get_item_field_by_fieldname(cls, fieldname): for item_model in cls.supported_item_models: - try: + with suppress(InvalidField): return item_model.get_field_by_fieldname(fieldname) - except InvalidField: - pass raise InvalidField(f"{fieldname!r} is not a valid field name on {cls.supported_item_models}") def get(self, *args, **kwargs): @@ -1633,7 +1631,7 @@

    Inherited members

    has_distinguished_subfolders = any(f.is_distinguished for f in self.children) try: if has_distinguished_subfolders: - self.empty(delete_sub_folders=False) + self.empty() else: self.empty(delete_sub_folders=True) except ErrorRecoverableItemsAccessDenied: @@ -1643,7 +1641,7 @@

    Inherited members

    try: if has_distinguished_subfolders: raise # We already tried this - self.empty(delete_sub_folders=False) + self.empty() except DELETE_FOLDER_ERRORS: log.warning("Not allowed to empty %s. Trying to delete items instead", self) kwargs = {} @@ -2156,10 +2154,8 @@

    Static methods

    @classmethod
     def get_item_field_by_fieldname(cls, fieldname):
         for item_model in cls.supported_item_models:
    -        try:
    +        with suppress(InvalidField):
                 return item_model.get_field_by_fieldname(fieldname)
    -        except InvalidField:
    -            pass
         raise InvalidField(f"{fieldname!r} is not a valid field name on {cls.supported_item_models}")
    @@ -3147,7 +3143,7 @@

    Methods

    has_distinguished_subfolders = any(f.is_distinguished for f in self.children) try: if has_distinguished_subfolders: - self.empty(delete_sub_folders=False) + self.empty() else: self.empty(delete_sub_folders=True) except ErrorRecoverableItemsAccessDenied: @@ -3157,7 +3153,7 @@

    Methods

    try: if has_distinguished_subfolders: raise # We already tried this - self.empty(delete_sub_folders=False) + self.empty() except DELETE_FOLDER_ERRORS: log.warning("Not allowed to empty %s. Trying to delete items instead", self) kwargs = {} @@ -4924,20 +4920,16 @@

    Inherited members

    # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. if folder.name: - try: + with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) - except KeyError: - pass if folder.folder_class and folder_cls == Folder: - try: + with suppress(KeyError): folder_cls = cls.folder_cls_from_container_class(container_class=folder.folder_class) log.debug( "Folder class %s matches container class %s (%s)", folder_cls, folder.folder_class, folder.name ) - except KeyError: - pass if folder_cls == Folder: log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS})
    @@ -5036,20 +5028,16 @@

    Static methods

    # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. if folder.name: - try: + with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) - except KeyError: - pass if folder.folder_class and folder_cls == Folder: - try: + with suppress(KeyError): folder_cls = cls.folder_cls_from_container_class(container_class=folder.folder_class) log.debug( "Folder class %s matches container class %s (%s)", folder_cls, folder.folder_class, folder.name ) - except KeyError: - pass if folder_cls == Folder: log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS})
    @@ -5473,7 +5461,7 @@

    Inherited members

    else: resolveable_folders.append(f) # Fetch all properties for the remaining folders of folder IDs - additional_fields = self.get_folder_fields(target_cls=self._get_target_cls(), is_complex=None) + additional_fields = self.get_folder_fields(target_cls=self._get_target_cls()) yield from self.__class__(account=self.account, folders=resolveable_folders).get_folders( additional_fields=additional_fields ) @@ -6109,7 +6097,7 @@

    Examples

    else: resolveable_folders.append(f) # Fetch all properties for the remaining folders of folder IDs - additional_fields = self.get_folder_fields(target_cls=self._get_target_cls(), is_complex=None) + additional_fields = self.get_folder_fields(target_cls=self._get_target_cls()) yield from self.__class__(account=self.account, folders=resolveable_folders).get_folders( additional_fields=additional_fields )
    @@ -8682,7 +8670,7 @@

    Inherited members

    return children_map = {} - try: + with suppress(ErrorAccessDenied): for f in ( SingleFolderQuerySet(account=self.account, folder=folder) .depth(self.DEFAULT_FOLDER_TRAVERSAL_DEPTH) @@ -8694,9 +8682,6 @@

    Inherited members

    if isinstance(f, Exception): raise f children_map[f.id] = f - except ErrorAccessDenied: - # No access to this folder - pass # Let's update the cache atomically, to avoid partial reads of the cache. with self._subfolders_lock: @@ -8756,7 +8741,7 @@

    Methods

    return children_map = {} - try: + with suppress(ErrorAccessDenied): for f in ( SingleFolderQuerySet(account=self.account, folder=folder) .depth(self.DEFAULT_FOLDER_TRAVERSAL_DEPTH) @@ -8768,9 +8753,6 @@

    Methods

    if isinstance(f, Exception): raise f children_map[f.id] = f - except ErrorAccessDenied: - # No access to this folder - pass # Let's update the cache atomically, to avoid partial reads of the cache. with self._subfolders_lock: @@ -9525,10 +9507,8 @@

    Inherited members

    return self.get_default_folder(MsgFolderRoot) def get_default_folder(self, folder_cls): - try: + with suppress(MISSING_FOLDER_ERRORS): return super().get_default_folder(folder_cls) - except MISSING_FOLDER_ERRORS: - pass # Try to pick a suitable default folder. we do this by: # 1. Searching the full folder list for a folder with the distinguished folder name @@ -9546,11 +9526,9 @@

    Inherited members

    # Try direct children of TOIS first, unless we're trying to get the TOIS folder if folder_cls != MsgFolderRoot: - try: + with suppress(MISSING_FOLDER_ERRORS): return self._get_candidate(folder_cls=folder_cls, folder_coll=self.tois.children) - except MISSING_FOLDER_ERRORS: - # No candidates, or TOIS does not exist, or we don't have access to TOIS - pass + # No candidates, or TOIS does not exist, or we don't have access to TOIS # Finally, try direct children of root return self._get_candidate(folder_cls=folder_cls, folder_coll=self.children) @@ -9730,10 +9708,8 @@

    Inherited members

    def remove_folder(self, folder): if not folder.id: raise ValueError("'folder' must have an ID") - try: + with suppress(KeyError): del self._folders_map[folder.id] - except KeyError: - pass def clear_cache(self): with self._subfolders_lock: @@ -10085,10 +10061,8 @@

    Methods

    def remove_folder(self, folder):
         if not folder.id:
             raise ValueError("'folder' must have an ID")
    -    try:
    -        del self._folders_map[folder.id]
    -    except KeyError:
    -        pass
    + with suppress(KeyError): + del self._folders_map[folder.id]
    @@ -12564,4 +12538,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/folders/roots.html b/docs/exchangelib/folders/roots.html index 713a1971..c5233626 100644 --- a/docs/exchangelib/folders/roots.html +++ b/docs/exchangelib/folders/roots.html @@ -27,6 +27,7 @@

    Module exchangelib.folders.roots

    Expand source code
    import logging
    +from contextlib import suppress
     from threading import Lock
     
     from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorInvalidOperation
    @@ -115,10 +116,8 @@ 

    Module exchangelib.folders.roots

    def remove_folder(self, folder): if not folder.id: raise ValueError("'folder' must have an ID") - try: + with suppress(KeyError): del self._folders_map[folder.id] - except KeyError: - pass def clear_cache(self): with self._subfolders_lock: @@ -269,10 +268,8 @@

    Module exchangelib.folders.roots

    return self.get_default_folder(MsgFolderRoot) def get_default_folder(self, folder_cls): - try: + with suppress(MISSING_FOLDER_ERRORS): return super().get_default_folder(folder_cls) - except MISSING_FOLDER_ERRORS: - pass # Try to pick a suitable default folder. we do this by: # 1. Searching the full folder list for a folder with the distinguished folder name @@ -290,11 +287,9 @@

    Module exchangelib.folders.roots

    # Try direct children of TOIS first, unless we're trying to get the TOIS folder if folder_cls != MsgFolderRoot: - try: + with suppress(MISSING_FOLDER_ERRORS): return self._get_candidate(folder_cls=folder_cls, folder_coll=self.tois.children) - except MISSING_FOLDER_ERRORS: - # No candidates, or TOIS does not exist, or we don't have access to TOIS - pass + # No candidates, or TOIS does not exist, or we don't have access to TOIS # Finally, try direct children of root return self._get_candidate(folder_cls=folder_cls, folder_coll=self.children) @@ -342,7 +337,7 @@

    Module exchangelib.folders.roots

    return children_map = {} - try: + with suppress(ErrorAccessDenied): for f in ( SingleFolderQuerySet(account=self.account, folder=folder) .depth(self.DEFAULT_FOLDER_TRAVERSAL_DEPTH) @@ -354,9 +349,6 @@

    Module exchangelib.folders.roots

    if isinstance(f, Exception): raise f children_map[f.id] = f - except ErrorAccessDenied: - # No access to this folder - pass # Let's update the cache atomically, to avoid partial reads of the cache. with self._subfolders_lock: @@ -498,7 +490,7 @@

    Inherited members

    return children_map = {} - try: + with suppress(ErrorAccessDenied): for f in ( SingleFolderQuerySet(account=self.account, folder=folder) .depth(self.DEFAULT_FOLDER_TRAVERSAL_DEPTH) @@ -510,9 +502,6 @@

    Inherited members

    if isinstance(f, Exception): raise f children_map[f.id] = f - except ErrorAccessDenied: - # No access to this folder - pass # Let's update the cache atomically, to avoid partial reads of the cache. with self._subfolders_lock: @@ -572,7 +561,7 @@

    Methods

    return children_map = {} - try: + with suppress(ErrorAccessDenied): for f in ( SingleFolderQuerySet(account=self.account, folder=folder) .depth(self.DEFAULT_FOLDER_TRAVERSAL_DEPTH) @@ -584,9 +573,6 @@

    Methods

    if isinstance(f, Exception): raise f children_map[f.id] = f - except ErrorAccessDenied: - # No access to this folder - pass # Let's update the cache atomically, to avoid partial reads of the cache. with self._subfolders_lock: @@ -661,10 +647,8 @@

    Inherited members

    return self.get_default_folder(MsgFolderRoot) def get_default_folder(self, folder_cls): - try: + with suppress(MISSING_FOLDER_ERRORS): return super().get_default_folder(folder_cls) - except MISSING_FOLDER_ERRORS: - pass # Try to pick a suitable default folder. we do this by: # 1. Searching the full folder list for a folder with the distinguished folder name @@ -682,11 +666,9 @@

    Inherited members

    # Try direct children of TOIS first, unless we're trying to get the TOIS folder if folder_cls != MsgFolderRoot: - try: + with suppress(MISSING_FOLDER_ERRORS): return self._get_candidate(folder_cls=folder_cls, folder_coll=self.tois.children) - except MISSING_FOLDER_ERRORS: - # No candidates, or TOIS does not exist, or we don't have access to TOIS - pass + # No candidates, or TOIS does not exist, or we don't have access to TOIS # Finally, try direct children of root return self._get_candidate(folder_cls=folder_cls, folder_coll=self.children) @@ -866,10 +848,8 @@

    Inherited members

    def remove_folder(self, folder): if not folder.id: raise ValueError("'folder' must have an ID") - try: + with suppress(KeyError): del self._folders_map[folder.id] - except KeyError: - pass def clear_cache(self): with self._subfolders_lock: @@ -1221,10 +1201,8 @@

    Methods

    def remove_folder(self, folder):
         if not folder.id:
             raise ValueError("'folder' must have an ID")
    -    try:
    -        del self._folders_map[folder.id]
    -    except KeyError:
    -        pass
    + with suppress(KeyError): + del self._folders_map[folder.id]
    @@ -1349,4 +1327,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/index.html b/docs/exchangelib/index.html index f51a132b..8be1a343 100644 --- a/docs/exchangelib/index.html +++ b/docs/exchangelib/index.html @@ -30,7 +30,14 @@

    Package exchangelib

    from .attachments import FileAttachment, ItemAttachment from .autodiscover import discover from .configuration import Configuration -from .credentials import DELEGATE, IMPERSONATION, Credentials, OAuth2AuthorizationCodeCredentials, OAuth2Credentials +from .credentials import ( + DELEGATE, + IMPERSONATION, + Credentials, + OAuth2AuthorizationCodeCredentials, + OAuth2Credentials, + OAuth2LegacyCredentials, +) from .ewsdatetime import UTC, UTC_NOW, EWSDate, EWSDateTime, EWSTimeZone from .extended_properties import ExtendedProperty from .folders import DEEP, SHALLOW, Folder, FolderCollection, RootOfHierarchy @@ -57,7 +64,7 @@

    Package exchangelib

    from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "4.7.6" +__version__ = "4.8.0" __all__ = [ "__version__", @@ -103,6 +110,7 @@

    Package exchangelib

    "OAUTH2", "OAuth2AuthorizationCodeCredentials", "OAuth2Credentials", + "OAuth2LegacyCredentials", "OofSettings", "PostItem", "PostReplyItem", @@ -2846,10 +2854,8 @@

    Inherited members

    session = self._session_pool.get(block=False) log.debug("Server %s: Got session immediately", self.server) except Empty: - try: + with suppress(SessionPoolMaxSizeReached): self.increase_poolsize() - except SessionPoolMaxSizeReached: - pass while True: try: log.debug("Server %s: Waiting for session", self.server) @@ -2939,7 +2945,7 @@

    Inherited members

    def create_oauth2_session(self): session_params = {"token": self.credentials.access_token} # Token may be None - token_params = {} + token_params = {"include_client_id": True} if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials): token_params["code"] = self.credentials.authorization_code # Auth code may be None @@ -2961,10 +2967,19 @@

    Inherited members

    } ) client = WebApplicationClient(client_id=self.credentials.client_id) + elif isinstance(self.credentials, OAuth2LegacyCredentials): + client = LegacyApplicationClient(client_id=self.credentials.client_id) + token_params["username"] = self.credentials.username + token_params["password"] = self.credentials.password else: client = BackendApplicationClient(client_id=self.credentials.client_id) - session = self.raw_session(self.service_endpoint, oauth2_client=client, oauth2_session_params=session_params) + session = self.raw_session( + self.service_endpoint, + oauth2_client=client, + oauth2_session_params=session_params, + oauth2_token_endpoint=self.credentials.token_url, + ) if not session.token: # Fetch the token explicitly -- it doesn't occur implicitly token = session.fetch_token( @@ -2983,7 +2998,7 @@

    Inherited members

    return session @classmethod - def raw_session(cls, prefix, oauth2_client=None, oauth2_session_params=None): + def raw_session(cls, prefix, oauth2_client=None, oauth2_session_params=None, oauth2_token_endpoint=None): if oauth2_client: session = OAuth2Session(client=oauth2_client, **(oauth2_session_params or {})) else: @@ -2991,6 +3006,8 @@

    Inherited members

    session.headers.update(DEFAULT_HEADERS) session.headers["User-Agent"] = cls.USERAGENT session.mount(prefix, adapter=cls.get_adapter()) + if oauth2_token_endpoint: + session.mount(oauth2_token_endpoint, adapter=cls.get_adapter()) return session def __repr__(self): @@ -3076,7 +3093,7 @@

    Static methods

    -def raw_session(prefix, oauth2_client=None, oauth2_session_params=None) +def raw_session(prefix, oauth2_client=None, oauth2_session_params=None, oauth2_token_endpoint=None)
    @@ -3085,7 +3102,7 @@

    Static methods

    Expand source code
    @classmethod
    -def raw_session(cls, prefix, oauth2_client=None, oauth2_session_params=None):
    +def raw_session(cls, prefix, oauth2_client=None, oauth2_session_params=None, oauth2_token_endpoint=None):
         if oauth2_client:
             session = OAuth2Session(client=oauth2_client, **(oauth2_session_params or {}))
         else:
    @@ -3093,6 +3110,8 @@ 

    Static methods

    session.headers.update(DEFAULT_HEADERS) session.headers["User-Agent"] = cls.USERAGENT session.mount(prefix, adapter=cls.get_adapter()) + if oauth2_token_endpoint: + session.mount(oauth2_token_endpoint, adapter=cls.get_adapter()) return session
    @@ -3227,7 +3246,7 @@

    Methods

    def create_oauth2_session(self):
         session_params = {"token": self.credentials.access_token}  # Token may be None
    -    token_params = {}
    +    token_params = {"include_client_id": True}
     
         if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials):
             token_params["code"] = self.credentials.authorization_code  # Auth code may be None
    @@ -3249,10 +3268,19 @@ 

    Methods

    } ) client = WebApplicationClient(client_id=self.credentials.client_id) + elif isinstance(self.credentials, OAuth2LegacyCredentials): + client = LegacyApplicationClient(client_id=self.credentials.client_id) + token_params["username"] = self.credentials.username + token_params["password"] = self.credentials.password else: client = BackendApplicationClient(client_id=self.credentials.client_id) - session = self.raw_session(self.service_endpoint, oauth2_client=client, oauth2_session_params=session_params) + session = self.raw_session( + self.service_endpoint, + oauth2_client=client, + oauth2_session_params=session_params, + oauth2_token_endpoint=self.credentials.token_url, + ) if not session.token: # Fetch the token explicitly -- it doesn't occur implicitly token = session.fetch_token( @@ -3382,10 +3410,8 @@

    Methods

    session = self._session_pool.get(block=False) log.debug("Server %s: Got session immediately", self.server) except Empty: - try: + with suppress(SessionPoolMaxSizeReached): self.increase_poolsize() - except SessionPoolMaxSizeReached: - pass while True: try: log.debug("Server %s: Waiting for session", self.server) @@ -5512,6 +5538,10 @@

    Methods

    return t return self.from_datetime(t) # We want to return EWSDateTime objects + @classmethod + def fromisoformat(cls, date_string): + return cls.from_string(date_string) + def __add__(self, other): t = super().__add__(other) if isinstance(t, self.__class__): @@ -5637,6 +5667,20 @@

    Static methods

    return cls.from_datetime(aware_dt)
    +
    +def fromisoformat(date_string) +
    +
    +

    string -> datetime from datetime.isoformat() output

    +
    + +Expand source code + +
    @classmethod
    +def fromisoformat(cls, date_string):
    +    return cls.from_string(date_string)
    +
    +
    def fromtimestamp(t, tz=None)
    @@ -6246,10 +6290,8 @@

    Methods

    try: obj.property_set_id = cls.DISTINGUISHED_SET_NAME_TO_ID_MAP[obj.distinguished_property_set_id] except KeyError: - try: + with suppress(KeyError): obj.distinguished_property_set_id = cls.DISTINGUISHED_SET_ID_TO_NAME_MAP[obj.property_set_id] - except KeyError: - pass return obj @classmethod @@ -7132,20 +7174,16 @@

    Inherited members

    # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. if folder.name: - try: + with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) - except KeyError: - pass if folder.folder_class and folder_cls == Folder: - try: + with suppress(KeyError): folder_cls = cls.folder_cls_from_container_class(container_class=folder.folder_class) log.debug( "Folder class %s matches container class %s (%s)", folder_cls, folder.folder_class, folder.name ) - except KeyError: - pass if folder_cls == Folder: log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS})
    @@ -7244,20 +7282,16 @@

    Static methods

    # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. if folder.name: - try: + with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) - except KeyError: - pass if folder.folder_class and folder_cls == Folder: - try: + with suppress(KeyError): folder_cls = cls.folder_cls_from_container_class(container_class=folder.folder_class) log.debug( "Folder class %s matches container class %s (%s)", folder_cls, folder.folder_class, folder.name ) - except KeyError: - pass if folder_cls == Folder: log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS})
    @@ -7681,7 +7715,7 @@

    Inherited members

    else: resolveable_folders.append(f) # Fetch all properties for the remaining folders of folder IDs - additional_fields = self.get_folder_fields(target_cls=self._get_target_cls(), is_complex=None) + additional_fields = self.get_folder_fields(target_cls=self._get_target_cls()) yield from self.__class__(account=self.account, folders=resolveable_folders).get_folders( additional_fields=additional_fields ) @@ -8317,7 +8351,7 @@

    Examples

    else: resolveable_folders.append(f) # Fetch all properties for the remaining folders of folder IDs - additional_fields = self.get_folder_fields(target_cls=self._get_target_cls(), is_complex=None) + additional_fields = self.get_folder_fields(target_cls=self._get_target_cls()) yield from self.__class__(account=self.account, folders=resolveable_folders).get_folders( additional_fields=additional_fields )
    @@ -9884,6 +9918,7 @@

    Ancestors

    Subclasses

    Instance variables

    @@ -9976,6 +10011,70 @@

    Inherited members

    +
    +class OAuth2LegacyCredentials +(username, password, **kwargs) +
    +
    +

    Login info for OAuth 2.0 authentication using delegated permissions and application permissions.

    +

    This requires the app to acquire username and password from the user and pass that when requesting authentication +tokens for the given user. This allows the app to act as the signed-in user.

    +

    :param username: The username of the user to act as +:poram password: The password of the user to act as

    +
    + +Expand source code + +
    class OAuth2LegacyCredentials(OAuth2Credentials):
    +    """Login info for OAuth 2.0 authentication using delegated permissions and application permissions.
    +
    +    This requires the app to acquire username and password from the user and pass that when requesting authentication
    +    tokens for the given user. This allows the app to act as the signed-in user.
    +    """
    +
    +    def __init__(self, username, password, **kwargs):
    +        """
    +        :param username: The username of the user to act as
    +        :poram password: The password of the user to act as
    +        """
    +        super().__init__(**kwargs)
    +        self.username = username
    +        self.password = password
    +
    +    @property
    +    def scope(self):
    +        return ["https://outlook.office365.com/EWS.AccessAsUser.All"]
    +
    +

    Ancestors

    + +

    Instance variables

    +
    +
    var scope
    +
    +
    +
    + +Expand source code + +
    @property
    +def scope(self):
    +    return ["https://outlook.office365.com/EWS.AccessAsUser.All"]
    +
    +
    +
    +

    Inherited members

    + +
    class OofSettings (**kwargs) @@ -10567,7 +10666,7 @@

    Inherited members

    self.children.extend(args) # Parse keyword args and extract the filter - is_single_kwarg = len(args) == 0 and len(kwargs) == 1 + is_single_kwarg = not args and len(kwargs) == 1 for key, value in kwargs.items(): self.children.extend(self._get_children_from_kwarg(key=key, value=value, is_single_kwarg=is_single_kwarg)) @@ -10979,13 +11078,11 @@

    Inherited members

    self.LT: self.GTE, self.LTE: self.GT, } - try: + with suppress(KeyError): new = copy(self) new.op = inverse_ops[self.op] new.reduce() return new - except KeyError: - pass return self.__class__(self, conn_type=self.NOT) def __eq__(self, other): @@ -11683,10 +11780,8 @@

    Inherited members

    def remove_folder(self, folder): if not folder.id: raise ValueError("'folder' must have an ID") - try: + with suppress(KeyError): del self._folders_map[folder.id] - except KeyError: - pass def clear_cache(self): with self._subfolders_lock: @@ -12038,10 +12133,8 @@

    Methods

    def remove_folder(self, folder):
         if not folder.id:
             raise ValueError("'folder' must have an ID")
    -    try:
    -        del self._folders_map[folder.id]
    -    except KeyError:
    -        pass
    + with suppress(KeyError): + del self._folders_map[folder.id]
    @@ -13227,6 +13320,7 @@

    EWS
  • ewsformat
  • from_datetime
  • from_string
  • +
  • fromisoformat
  • fromtimestamp
  • now
  • utcfromtimestamp
  • @@ -13440,6 +13534,12 @@

    OAuth2LegacyCredentials

    + + +
  • OofSettings

    • DISABLED
    • @@ -13673,4 +13773,4 @@

      Version

      Generated by pdoc 0.10.0.

      - \ No newline at end of file + diff --git a/docs/exchangelib/properties.html b/docs/exchangelib/properties.html index db670b8c..04849960 100644 --- a/docs/exchangelib/properties.html +++ b/docs/exchangelib/properties.html @@ -1008,7 +1008,7 @@

      Module exchangelib.properties

      kwargs = {} working_hours_elem = elem.find(f"{{{TNS}}}WorkingHours") for f in cls.FIELDS: - if f.name in ["working_hours", "working_hours_timezone"]: + if f.name in ("working_hours", "working_hours_timezone"): if working_hours_elem is None: continue kwargs[f.name] = f.from_xml(elem=working_hours_elem, account=account) @@ -5538,7 +5538,7 @@

      Inherited members

      kwargs = {} working_hours_elem = elem.find(f"{{{TNS}}}WorkingHours") for f in cls.FIELDS: - if f.name in ["working_hours", "working_hours_timezone"]: + if f.name in ("working_hours", "working_hours_timezone"): if working_hours_elem is None: continue kwargs[f.name] = f.from_xml(elem=working_hours_elem, account=account) @@ -5582,7 +5582,7 @@

      Static methods

      kwargs = {} working_hours_elem = elem.find(f"{{{TNS}}}WorkingHours") for f in cls.FIELDS: - if f.name in ["working_hours", "working_hours_timezone"]: + if f.name in ("working_hours", "working_hours_timezone"): if working_hours_elem is None: continue kwargs[f.name] = f.from_xml(elem=working_hours_elem, account=account) @@ -11371,4 +11371,4 @@

      pdoc 0.10.0.

      - \ No newline at end of file + diff --git a/docs/exchangelib/protocol.html b/docs/exchangelib/protocol.html index 121f7bfb..e1fd2f1a 100644 --- a/docs/exchangelib/protocol.html +++ b/docs/exchangelib/protocol.html @@ -39,15 +39,16 @@

      Module exchangelib.protocol

      import datetime import logging import random +from contextlib import suppress from queue import Empty, LifoQueue from threading import Lock import requests.adapters import requests.sessions -from oauthlib.oauth2 import BackendApplicationClient, WebApplicationClient +from oauthlib.oauth2 import BackendApplicationClient, LegacyApplicationClient, WebApplicationClient from requests_oauthlib import OAuth2Session -from .credentials import OAuth2AuthorizationCodeCredentials, OAuth2Credentials +from .credentials import OAuth2AuthorizationCodeCredentials, OAuth2Credentials, OAuth2LegacyCredentials from .errors import ( CASError, ErrorInvalidSchemaVersionForMailboxVersion, @@ -252,10 +253,8 @@

      Module exchangelib.protocol

      session = self._session_pool.get(block=False) log.debug("Server %s: Got session immediately", self.server) except Empty: - try: + with suppress(SessionPoolMaxSizeReached): self.increase_poolsize() - except SessionPoolMaxSizeReached: - pass while True: try: log.debug("Server %s: Waiting for session", self.server) @@ -345,7 +344,7 @@

      Module exchangelib.protocol

      def create_oauth2_session(self): session_params = {"token": self.credentials.access_token} # Token may be None - token_params = {} + token_params = {"include_client_id": True} if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials): token_params["code"] = self.credentials.authorization_code # Auth code may be None @@ -367,10 +366,19 @@

      Module exchangelib.protocol

      } ) client = WebApplicationClient(client_id=self.credentials.client_id) + elif isinstance(self.credentials, OAuth2LegacyCredentials): + client = LegacyApplicationClient(client_id=self.credentials.client_id) + token_params["username"] = self.credentials.username + token_params["password"] = self.credentials.password else: client = BackendApplicationClient(client_id=self.credentials.client_id) - session = self.raw_session(self.service_endpoint, oauth2_client=client, oauth2_session_params=session_params) + session = self.raw_session( + self.service_endpoint, + oauth2_client=client, + oauth2_session_params=session_params, + oauth2_token_endpoint=self.credentials.token_url, + ) if not session.token: # Fetch the token explicitly -- it doesn't occur implicitly token = session.fetch_token( @@ -389,7 +397,7 @@

      Module exchangelib.protocol

      return session @classmethod - def raw_session(cls, prefix, oauth2_client=None, oauth2_session_params=None): + def raw_session(cls, prefix, oauth2_client=None, oauth2_session_params=None, oauth2_token_endpoint=None): if oauth2_client: session = OAuth2Session(client=oauth2_client, **(oauth2_session_params or {})) else: @@ -397,6 +405,8 @@

      Module exchangelib.protocol

      session.headers.update(DEFAULT_HEADERS) session.headers["User-Agent"] = cls.USERAGENT session.mount(prefix, adapter=cls.get_adapter()) + if oauth2_token_endpoint: + session.mount(oauth2_token_endpoint, adapter=cls.get_adapter()) return session def __repr__(self): @@ -1034,10 +1044,8 @@

      Classes

      session = self._session_pool.get(block=False) log.debug("Server %s: Got session immediately", self.server) except Empty: - try: + with suppress(SessionPoolMaxSizeReached): self.increase_poolsize() - except SessionPoolMaxSizeReached: - pass while True: try: log.debug("Server %s: Waiting for session", self.server) @@ -1127,7 +1135,7 @@

      Classes

      def create_oauth2_session(self): session_params = {"token": self.credentials.access_token} # Token may be None - token_params = {} + token_params = {"include_client_id": True} if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials): token_params["code"] = self.credentials.authorization_code # Auth code may be None @@ -1149,10 +1157,19 @@

      Classes

      } ) client = WebApplicationClient(client_id=self.credentials.client_id) + elif isinstance(self.credentials, OAuth2LegacyCredentials): + client = LegacyApplicationClient(client_id=self.credentials.client_id) + token_params["username"] = self.credentials.username + token_params["password"] = self.credentials.password else: client = BackendApplicationClient(client_id=self.credentials.client_id) - session = self.raw_session(self.service_endpoint, oauth2_client=client, oauth2_session_params=session_params) + session = self.raw_session( + self.service_endpoint, + oauth2_client=client, + oauth2_session_params=session_params, + oauth2_token_endpoint=self.credentials.token_url, + ) if not session.token: # Fetch the token explicitly -- it doesn't occur implicitly token = session.fetch_token( @@ -1171,7 +1188,7 @@

      Classes

      return session @classmethod - def raw_session(cls, prefix, oauth2_client=None, oauth2_session_params=None): + def raw_session(cls, prefix, oauth2_client=None, oauth2_session_params=None, oauth2_token_endpoint=None): if oauth2_client: session = OAuth2Session(client=oauth2_client, **(oauth2_session_params or {})) else: @@ -1179,6 +1196,8 @@

      Classes

      session.headers.update(DEFAULT_HEADERS) session.headers["User-Agent"] = cls.USERAGENT session.mount(prefix, adapter=cls.get_adapter()) + if oauth2_token_endpoint: + session.mount(oauth2_token_endpoint, adapter=cls.get_adapter()) return session def __repr__(self): @@ -1264,7 +1283,7 @@

      Static methods

      -def raw_session(prefix, oauth2_client=None, oauth2_session_params=None) +def raw_session(prefix, oauth2_client=None, oauth2_session_params=None, oauth2_token_endpoint=None)
      @@ -1273,7 +1292,7 @@

      Static methods

      Expand source code
      @classmethod
      -def raw_session(cls, prefix, oauth2_client=None, oauth2_session_params=None):
      +def raw_session(cls, prefix, oauth2_client=None, oauth2_session_params=None, oauth2_token_endpoint=None):
           if oauth2_client:
               session = OAuth2Session(client=oauth2_client, **(oauth2_session_params or {}))
           else:
      @@ -1281,6 +1300,8 @@ 

      Static methods

      session.headers.update(DEFAULT_HEADERS) session.headers["User-Agent"] = cls.USERAGENT session.mount(prefix, adapter=cls.get_adapter()) + if oauth2_token_endpoint: + session.mount(oauth2_token_endpoint, adapter=cls.get_adapter()) return session
      @@ -1415,7 +1436,7 @@

      Methods

      def create_oauth2_session(self):
           session_params = {"token": self.credentials.access_token}  # Token may be None
      -    token_params = {}
      +    token_params = {"include_client_id": True}
       
           if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials):
               token_params["code"] = self.credentials.authorization_code  # Auth code may be None
      @@ -1437,10 +1458,19 @@ 

      Methods

      } ) client = WebApplicationClient(client_id=self.credentials.client_id) + elif isinstance(self.credentials, OAuth2LegacyCredentials): + client = LegacyApplicationClient(client_id=self.credentials.client_id) + token_params["username"] = self.credentials.username + token_params["password"] = self.credentials.password else: client = BackendApplicationClient(client_id=self.credentials.client_id) - session = self.raw_session(self.service_endpoint, oauth2_client=client, oauth2_session_params=session_params) + session = self.raw_session( + self.service_endpoint, + oauth2_client=client, + oauth2_session_params=session_params, + oauth2_token_endpoint=self.credentials.token_url, + ) if not session.token: # Fetch the token explicitly -- it doesn't occur implicitly token = session.fetch_token( @@ -1570,10 +1600,8 @@

      Methods

      session = self._session_pool.get(block=False) log.debug("Server %s: Got session immediately", self.server) except Empty: - try: + with suppress(SessionPoolMaxSizeReached): self.increase_poolsize() - except SessionPoolMaxSizeReached: - pass while True: try: log.debug("Server %s: Waiting for session", self.server) @@ -2821,4 +2849,4 @@

      pdoc 0.10.0.

      - \ No newline at end of file + diff --git a/docs/exchangelib/queryset.html b/docs/exchangelib/queryset.html index cf3f0e0b..fd552ca0 100644 --- a/docs/exchangelib/queryset.html +++ b/docs/exchangelib/queryset.html @@ -28,6 +28,7 @@

      Module exchangelib.queryset

      import abc
       import logging
      +from contextlib import suppress
       from copy import deepcopy
       from itertools import islice
       
      @@ -138,10 +139,8 @@ 

      Module exchangelib.queryset

      if self.request_type == self.PERSONA: return FieldPath(field=Persona.get_field_by_fieldname(field_path)) for folder in self.folder_collection: - try: + with suppress(InvalidField): return FieldPath.from_string(field_path=field_path, folder=folder) - except InvalidField: - pass raise InvalidField(f"Unknown field path {field_path!r} on folders {self.folder_collection.folders}") def _get_field_order(self, field_path): @@ -153,10 +152,8 @@

      Module exchangelib.queryset

      reverse=field_path.startswith("-"), ) for folder in self.folder_collection: - try: + with suppress(InvalidField): return FieldOrder.from_string(field_path=field_path, folder=folder) - except InvalidField: - pass raise InvalidField(f"Unknown field path {field_path!r} on folders {self.folder_collection.folders}") @property @@ -333,7 +330,7 @@

      Module exchangelib.queryset

      raise IndexError() def _getitem_slice(self, s): - from .services import PAGE_SIZE + from .services import FindItem if ((s.start or 0) < 0) or ((s.stop or 0) < 0) or ((s.step or 0) < 0): # islice() does not support negative start, stop and step. Make sure cache is full by iterating the full @@ -348,7 +345,7 @@

      Module exchangelib.queryset

      new_qs.offset = s.start elif s.stop is not None: new_qs.max_items = s.stop - if new_qs.page_size is None and new_qs.max_items is not None and new_qs.max_items < PAGE_SIZE: + if new_qs.page_size is None and new_qs.max_items is not None and new_qs.max_items < FindItem.PAGE_SIZE: new_qs.page_size = new_qs.max_items return islice(new_qs.__iter__(), None, None, s.step) @@ -802,10 +799,8 @@

      Classes

      if self.request_type == self.PERSONA: return FieldPath(field=Persona.get_field_by_fieldname(field_path)) for folder in self.folder_collection: - try: + with suppress(InvalidField): return FieldPath.from_string(field_path=field_path, folder=folder) - except InvalidField: - pass raise InvalidField(f"Unknown field path {field_path!r} on folders {self.folder_collection.folders}") def _get_field_order(self, field_path): @@ -817,10 +812,8 @@

      Classes

      reverse=field_path.startswith("-"), ) for folder in self.folder_collection: - try: + with suppress(InvalidField): return FieldOrder.from_string(field_path=field_path, folder=folder) - except InvalidField: - pass raise InvalidField(f"Unknown field path {field_path!r} on folders {self.folder_collection.folders}") @property @@ -997,7 +990,7 @@

      Classes

      raise IndexError() def _getitem_slice(self, s): - from .services import PAGE_SIZE + from .services import FindItem if ((s.start or 0) < 0) or ((s.stop or 0) < 0) or ((s.step or 0) < 0): # islice() does not support negative start, stop and step. Make sure cache is full by iterating the full @@ -1012,7 +1005,7 @@

      Classes

      new_qs.offset = s.start elif s.stop is not None: new_qs.max_items = s.stop - if new_qs.page_size is None and new_qs.max_items is not None and new_qs.max_items < PAGE_SIZE: + if new_qs.page_size is None and new_qs.max_items is not None and new_qs.max_items < FindItem.PAGE_SIZE: new_qs.page_size = new_qs.max_items return islice(new_qs.__iter__(), None, None, s.step) @@ -1981,4 +1974,4 @@

      pdoc 0.10.0.

      - \ No newline at end of file + diff --git a/docs/exchangelib/restriction.html b/docs/exchangelib/restriction.html index 8102f320..29773372 100644 --- a/docs/exchangelib/restriction.html +++ b/docs/exchangelib/restriction.html @@ -27,6 +27,7 @@

      Module exchangelib.restriction

      Expand source code
      import logging
      +from contextlib import suppress
       from copy import copy
       
       from .errors import InvalidEnumValue
      @@ -121,7 +122,7 @@ 

      Module exchangelib.restriction

      self.children.extend(args) # Parse keyword args and extract the filter - is_single_kwarg = len(args) == 0 and len(kwargs) == 1 + is_single_kwarg = not args and len(kwargs) == 1 for key, value in kwargs.items(): self.children.extend(self._get_children_from_kwarg(key=key, value=value, is_single_kwarg=is_single_kwarg)) @@ -533,13 +534,11 @@

      Module exchangelib.restriction

      self.LT: self.GTE, self.LTE: self.GT, } - try: + with suppress(KeyError): new = copy(self) new.op = inverse_ops[self.op] new.reduce() return new - except KeyError: - pass return self.__class__(self, conn_type=self.NOT) def __eq__(self, other): @@ -695,7 +694,7 @@

      Classes

      self.children.extend(args) # Parse keyword args and extract the filter - is_single_kwarg = len(args) == 0 and len(kwargs) == 1 + is_single_kwarg = not args and len(kwargs) == 1 for key, value in kwargs.items(): self.children.extend(self._get_children_from_kwarg(key=key, value=value, is_single_kwarg=is_single_kwarg)) @@ -1107,13 +1106,11 @@

      Classes

      self.LT: self.GTE, self.LTE: self.GT, } - try: + with suppress(KeyError): new = copy(self) new.op = inverse_ops[self.op] new.reduce() return new - except KeyError: - pass return self.__class__(self, conn_type=self.NOT) def __eq__(self, other): @@ -1664,4 +1661,4 @@

      pdoc 0.10.0.

      - \ No newline at end of file + diff --git a/docs/exchangelib/services/common.html b/docs/exchangelib/services/common.html index 866fef8e..67211789 100644 --- a/docs/exchangelib/services/common.html +++ b/docs/exchangelib/services/common.html @@ -28,6 +28,7 @@

      Module exchangelib.services.common

      import abc
       import logging
      +from contextlib import suppress
       from itertools import chain
       
       from .. import errors
      @@ -95,13 +96,13 @@ 

      Module exchangelib.services.common

      log = logging.getLogger(__name__) -PAGE_SIZE = 100 # A default page size for all paging services. This is the number of items we request per page -CHUNK_SIZE = 100 # A default chunk size for all services. This is the number of items we send in a single request - class EWSService(metaclass=abc.ABCMeta): """Base class for all EWS services.""" + PAGE_SIZE = 100 # A default page size for all paging services. This is the number of items we request per page + CHUNK_SIZE = 100 # A default chunk size for all services. This is the number of items we send in a single request + SERVICE_NAME = None # The name of the SOAP service element_container_name = None # The name of the XML element wrapping the collection of returned items paging_container_name = None # The name of the element that contains paging information and the paged results @@ -134,7 +135,7 @@

      Module exchangelib.services.common

      supports_paging = False def __init__(self, protocol, chunk_size=None, timeout=None): - self.chunk_size = chunk_size or CHUNK_SIZE + self.chunk_size = chunk_size or self.CHUNK_SIZE if not isinstance(self.chunk_size, int): raise InvalidTypeError("chunk_size", chunk_size, int) if self.chunk_size < 1: @@ -333,7 +334,6 @@

      Module exchangelib.services.common

      account_to_impersonate=self._account_to_impersonate, timezone=self._timezone, ), - allow_redirects=False, stream=self.streaming, timeout=self.timeout or self.protocol.TIMEOUT, ) @@ -416,10 +416,8 @@

      Module exchangelib.services.common

      """ log.debug("Got ErrorServerBusy (back off %s seconds)", e.back_off) # ErrorServerBusy is very often a symptom of sending too many requests. Scale back connections if possible. - try: + with suppress(SessionPoolMinSizeReached): self.protocol.decrease_poolsize() - except SessionPoolMinSizeReached: - pass if self.protocol.retry_policy.fail_fast: raise e self.protocol.retry_policy.back_off(e.back_off) @@ -505,12 +503,10 @@

      Module exchangelib.services.common

      msg_xml = detail.find(f"{{{TNS}}}MessageXml") # Crazy. Here, it's in the TNS namespace if code == "ErrorServerBusy": back_off = None - try: + with suppress(TypeError, AttributeError): value = msg_xml.find(f"{{{TNS}}}Value") if value.get("Name") == "BackOffMilliseconds": back_off = int(value.text) / 1000.0 # Convert to seconds - except (TypeError, AttributeError): - pass raise ErrorServerBusy(msg, back_off=back_off) if code == "ErrorSchemaValidation" and msg_xml is not None: line_number = get_xml_attr(msg_xml, f"{{{TNS}}}LineNumber") @@ -524,10 +520,8 @@

      Module exchangelib.services.common

      raise vars(errors)[code](msg) except KeyError: detail = f"{cls.SERVICE_NAME}: code: {code} msg: {msg} ({xml_to_str(detail)})" - try: + with suppress(KeyError): raise vars(errors)[fault_code](fault_string) - except KeyError: - pass raise SOAPError(f"SOAP error code: {fault_code} string: {fault_string} actor: {fault_actor} detail: {detail}") def _get_element_container(self, message, name=None): @@ -745,7 +739,7 @@

      Module exchangelib.services.common

      class EWSPagingService(EWSAccountService): def __init__(self, *args, **kwargs): - self.page_size = kwargs.pop("page_size", None) or PAGE_SIZE + self.page_size = kwargs.pop("page_size", None) or self.PAGE_SIZE if not isinstance(self.page_size, int): raise InvalidTypeError("page_size", self.page_size, int) if self.page_size < 1: @@ -1234,7 +1228,7 @@

      Inherited members

      class EWSPagingService(EWSAccountService):
           def __init__(self, *args, **kwargs):
      -        self.page_size = kwargs.pop("page_size", None) or PAGE_SIZE
      +        self.page_size = kwargs.pop("page_size", None) or self.PAGE_SIZE
               if not isinstance(self.page_size, int):
                   raise InvalidTypeError("page_size", self.page_size, int)
               if self.page_size < 1:
      @@ -1413,6 +1407,9 @@ 

      Inherited members

      class EWSService(metaclass=abc.ABCMeta):
           """Base class for all EWS services."""
       
      +    PAGE_SIZE = 100  # A default page size for all paging services. This is the number of items we request per page
      +    CHUNK_SIZE = 100  # A default chunk size for all services. This is the number of items we send in a single request
      +
           SERVICE_NAME = None  # The name of the SOAP service
           element_container_name = None  # The name of the XML element wrapping the collection of returned items
           paging_container_name = None  # The name of the element that contains paging information and the paged results
      @@ -1445,7 +1442,7 @@ 

      Inherited members

      supports_paging = False def __init__(self, protocol, chunk_size=None, timeout=None): - self.chunk_size = chunk_size or CHUNK_SIZE + self.chunk_size = chunk_size or self.CHUNK_SIZE if not isinstance(self.chunk_size, int): raise InvalidTypeError("chunk_size", chunk_size, int) if self.chunk_size < 1: @@ -1644,7 +1641,6 @@

      Inherited members

      account_to_impersonate=self._account_to_impersonate, timezone=self._timezone, ), - allow_redirects=False, stream=self.streaming, timeout=self.timeout or self.protocol.TIMEOUT, ) @@ -1727,10 +1723,8 @@

      Inherited members

      """ log.debug("Got ErrorServerBusy (back off %s seconds)", e.back_off) # ErrorServerBusy is very often a symptom of sending too many requests. Scale back connections if possible. - try: + with suppress(SessionPoolMinSizeReached): self.protocol.decrease_poolsize() - except SessionPoolMinSizeReached: - pass if self.protocol.retry_policy.fail_fast: raise e self.protocol.retry_policy.back_off(e.back_off) @@ -1816,12 +1810,10 @@

      Inherited members

      msg_xml = detail.find(f"{{{TNS}}}MessageXml") # Crazy. Here, it's in the TNS namespace if code == "ErrorServerBusy": back_off = None - try: + with suppress(TypeError, AttributeError): value = msg_xml.find(f"{{{TNS}}}Value") if value.get("Name") == "BackOffMilliseconds": back_off = int(value.text) / 1000.0 # Convert to seconds - except (TypeError, AttributeError): - pass raise ErrorServerBusy(msg, back_off=back_off) if code == "ErrorSchemaValidation" and msg_xml is not None: line_number = get_xml_attr(msg_xml, f"{{{TNS}}}LineNumber") @@ -1835,10 +1827,8 @@

      Inherited members

      raise vars(errors)[code](msg) except KeyError: detail = f"{cls.SERVICE_NAME}: code: {code} msg: {msg} ({xml_to_str(detail)})" - try: + with suppress(KeyError): raise vars(errors)[fault_code](fault_string) - except KeyError: - pass raise SOAPError(f"SOAP error code: {fault_code} string: {fault_string} actor: {fault_actor} detail: {detail}") def _get_element_container(self, message, name=None): @@ -2014,6 +2004,10 @@

      Subclasses

    Class variables

    +
    var CHUNK_SIZE
    +
    +
    +
    var ERRORS_TO_CATCH_IN_RESPONSE
    @@ -2022,6 +2016,10 @@

    Class variables

    Global error type within this module.

    +
    var PAGE_SIZE
    +
    +
    +
    var SERVICE_NAME
    @@ -2171,8 +2169,10 @@

    EWSService

      +
    • CHUNK_SIZE
    • ERRORS_TO_CATCH_IN_RESPONSE
    • NO_VALID_SERVER_VERSIONS
    • +
    • PAGE_SIZE
    • SERVICE_NAME
    • WARNINGS_TO_CATCH_IN_RESPONSE
    • WARNINGS_TO_IGNORE_IN_RESPONSE
    • @@ -2195,4 +2195,4 @@

      pdoc 0.10.0.

      - \ No newline at end of file + diff --git a/docs/exchangelib/services/index.html b/docs/exchangelib/services/index.html index ab79b647..328c18d9 100644 --- a/docs/exchangelib/services/index.html +++ b/docs/exchangelib/services/index.html @@ -41,7 +41,7 @@

      Module exchangelib.services

      """ from .archive_item import ArchiveItem -from .common import CHUNK_SIZE, PAGE_SIZE +from .common import EWSService from .convert_id import ConvertId from .copy_item import CopyItem from .create_attachment import CreateAttachment @@ -90,8 +90,6 @@

      Module exchangelib.services

      from .upload_items import UploadItems __all__ = [ - "CHUNK_SIZE", - "PAGE_SIZE", "ArchiveItem", "ConvertId", "CopyItem", @@ -104,6 +102,7 @@

      Module exchangelib.services

      "DeleteUserConfiguration", "DeleteItem", "EmptyFolder", + "EWSService", "ExpandDL", "ExportItems", "FindFolder", @@ -1577,6 +1576,743 @@

      Inherited members

    +
    +class EWSService +(protocol, chunk_size=None, timeout=None) +
    +
    +

    Base class for all EWS services.

    +
    + +Expand source code + +
    class EWSService(metaclass=abc.ABCMeta):
    +    """Base class for all EWS services."""
    +
    +    PAGE_SIZE = 100  # A default page size for all paging services. This is the number of items we request per page
    +    CHUNK_SIZE = 100  # A default chunk size for all services. This is the number of items we send in a single request
    +
    +    SERVICE_NAME = None  # The name of the SOAP service
    +    element_container_name = None  # The name of the XML element wrapping the collection of returned items
    +    paging_container_name = None  # The name of the element that contains paging information and the paged results
    +    returns_elements = True  # If False, the service does not return response elements, just the ResponseCode status
    +    # Return exception instance instead of raising exceptions for the following errors when contained in an element
    +    ERRORS_TO_CATCH_IN_RESPONSE = (
    +        EWSWarning,
    +        ErrorCannotDeleteObject,
    +        ErrorInvalidChangeKey,
    +        ErrorItemNotFound,
    +        ErrorItemSave,
    +        ErrorInvalidIdMalformed,
    +        ErrorMessageSizeExceeded,
    +        ErrorCannotDeleteTaskOccurrence,
    +        ErrorMimeContentConversionFailed,
    +        ErrorRecurrenceHasNoOccurrence,
    +        ErrorCorruptData,
    +        ErrorItemCorrupt,
    +        ErrorMailRecipientNotFound,
    +    )
    +    # Similarly, define the warnings we want to return unraised
    +    WARNINGS_TO_CATCH_IN_RESPONSE = ErrorBatchProcessingStopped
    +    # Define the warnings we want to ignore, to let response processing proceed
    +    WARNINGS_TO_IGNORE_IN_RESPONSE = ()
    +    # The exception type to raise when all attempted API versions failed
    +    NO_VALID_SERVER_VERSIONS = ErrorInvalidServerVersion
    +    # Marks the version from which the service was introduced
    +    supported_from = None
    +    # Marks services that support paging of requested items
    +    supports_paging = False
    +
    +    def __init__(self, protocol, chunk_size=None, timeout=None):
    +        self.chunk_size = chunk_size or self.CHUNK_SIZE
    +        if not isinstance(self.chunk_size, int):
    +            raise InvalidTypeError("chunk_size", chunk_size, int)
    +        if self.chunk_size < 1:
    +            raise ValueError(f"'chunk_size' {self.chunk_size} must be a positive number")
    +        if self.supported_from and protocol.version.build < self.supported_from:
    +            raise NotImplementedError(
    +                f"{self.SERVICE_NAME!r} is only supported on {self.supported_from.fullname()!r} and later. "
    +                f"Your current version is {protocol.version.build.fullname()!r}."
    +            )
    +        self.protocol = protocol
    +        # Allow a service to override the default protocol timeout. Useful for streaming services
    +        self.timeout = timeout
    +        # Controls whether the HTTP request should be streaming or fetch everything at once
    +        self.streaming = False
    +        # Streaming connection variables
    +        self._streaming_session = None
    +        self._streaming_response = None
    +
    +    def __del__(self):
    +        # pylint: disable=bare-except
    +        try:
    +            if self.streaming:
    +                # Make sure to clean up lingering resources
    +                self.stop_streaming()
    +        except Exception:  # nosec
    +            # __del__ should never fail
    +            pass
    +
    +    # The following two methods are the minimum required to be implemented by subclasses, but the name and number of
    +    # kwargs differs between services. Therefore, we cannot make these methods abstract.
    +
    +    # @abc.abstractmethod
    +    # def call(self, **kwargs):
    +    #     """Defines the arguments required by the service. Arguments are basic Python types or EWSElement objects.
    +    #     Returns either XML objects or EWSElement objects.
    +    #     """"
    +    #     pass
    +
    +    # @abc.abstractmethod
    +    # def get_payload(self, **kwargs):
    +    #     """Using the arguments from .call(), return the payload expected by the service, as an XML object. The XML
    +    #     object should consist of a SERVICE_NAME element and everything within that.
    +    #     """
    +    #     pass
    +
    +    def get(self, expect_result=True, **kwargs):
    +        """Like .call(), but expects exactly one result from the server, or zero when expect_result=False, or either
    +        zero or one when expect_result=None. Returns either one object or None.
    +
    +        :param expect_result: None, True, or False
    +        :param kwargs: Same as arguments for .call()
    +        :return: Same as .call(), but returns either None or exactly one item
    +        """
    +        res = list(self.call(**kwargs))
    +        # Raise any errors
    +        for r in res:
    +            if isinstance(r, Exception):
    +                raise r
    +        if expect_result is None and not res:
    +            # Allow empty result
    +            return None
    +        if expect_result is False:
    +            if res:
    +                raise ValueError(f"Expected result length 0, but got {res}")
    +            return None
    +        if len(res) != 1:
    +            raise ValueError(f"Expected result length 1, but got {res}")
    +        return res[0]
    +
    +    def parse(self, xml):
    +        """Used mostly for testing, when we want to parse static XML data."""
    +        resp = DummyResponse(content=xml, streaming=self.streaming)
    +        _, body = self._get_soap_parts(response=resp)
    +        return self._elems_to_objs(self._get_elements_in_response(response=self._get_soap_messages(body=body)))
    +
    +    def _elems_to_objs(self, elems):
    +        """Takes a generator of XML elements and exceptions. Returns the equivalent Python objects (or exceptions)."""
    +        for elem in elems:
    +            # Allow None here. Some services don't return an ID if the target folder is outside the mailbox.
    +            if isinstance(elem, (Exception, type(None))):
    +                yield elem
    +                continue
    +            yield self._elem_to_obj(elem)
    +
    +    def _elem_to_obj(self, elem):
    +        if not self.returns_elements:
    +            raise RuntimeError("Incorrect call to method when 'returns_elements' is False")
    +        raise NotImplementedError()
    +
    +    @property
    +    def _version_hint(self):
    +        # We may be here due to version guessing in Protocol.version, so we can't use the self.protocol.version property
    +        return self.protocol.config.version
    +
    +    @_version_hint.setter
    +    def _version_hint(self, value):
    +        self.protocol.config.version = value
    +
    +    def _extra_headers(self, session):
    +        return {}
    +
    +    @property
    +    def _account_to_impersonate(self):
    +        if isinstance(self.protocol.credentials, OAuth2Credentials):
    +            return self.protocol.credentials.identity
    +        return None
    +
    +    @property
    +    def _timezone(self):
    +        return None
    +
    +    def _response_generator(self, payload):
    +        """Send the payload to the server, and return the response.
    +
    +        :param payload: payload as an XML object
    +        :return: the response, as XML objects
    +        """
    +        response = self._get_response_xml(payload=payload)
    +        if self.supports_paging:
    +            return (self._get_page(message) for message in response)
    +        return self._get_elements_in_response(response=response)
    +
    +    def _chunked_get_elements(self, payload_func, items, **kwargs):
    +        """Yield elements in a response. Like ._get_elements(), but chop items into suitable chunks and send multiple
    +        requests.
    +
    +        :param payload_func: A reference to .payload()
    +        :param items: An iterable of items (messages, folders, etc.) to process
    +        :param kwargs: Same as arguments for .call(), except for the 'items' argument
    +        :return: Same as ._get_elements()
    +        """
    +        # If the input for a service is a QuerySet, it can be difficult to remove exceptions before now
    +        filtered_items = filter(lambda i: not isinstance(i, Exception), items)
    +        for i, chunk in enumerate(chunkify(filtered_items, self.chunk_size), start=1):
    +            log.debug("Processing chunk %s containing %s items", i, len(chunk))
    +            yield from self._get_elements(payload=payload_func(chunk, **kwargs))
    +
    +    def stop_streaming(self):
    +        if not self.streaming:
    +            raise RuntimeError("Attempt to stop a non-streaming service")
    +        if self._streaming_response:
    +            self._streaming_response.close()  # Release memory
    +            self._streaming_response = None
    +        if self._streaming_session:
    +            self.protocol.release_session(self._streaming_session)
    +            self._streaming_session = None
    +
    +    def _get_elements(self, payload):
    +        """Send the payload to be sent and parsed. Handles and re-raise exceptions that are not meant to be returned
    +        to the caller as exception objects. Retry the request according to the retry policy.
    +        """
    +        while True:
    +            try:
    +                # Create a generator over the response elements so exceptions in response elements are also raised
    +                # here and can be handled.
    +                yield from self._response_generator(payload=payload)
    +                return
    +            except ErrorServerBusy as e:
    +                self._handle_backoff(e)
    +                continue
    +            except (ErrorTooManyObjectsOpened, ErrorTimeoutExpired) as e:
    +                # ErrorTooManyObjectsOpened means there are too many connections to the Exchange database. This is very
    +                # often a symptom of sending too many requests.
    +                #
    +                # ErrorTimeoutExpired can be caused by a busy server, or by overly large requests. Start by lowering the
    +                # session count. This is done by downstream code.
    +                if isinstance(e, ErrorTimeoutExpired) and self.protocol.session_pool_size <= 1:
    +                    # We're already as low as we can go, so downstream cannot limit the session count to put less load
    +                    # on the server. We don't have a way of lowering the page size of requests from
    +                    # this part of the code yet. Let the user handle this.
    +                    raise e
    +
    +                # Re-raise as an ErrorServerBusy with a default delay of 5 minutes
    +                raise ErrorServerBusy(f"Reraised from {e.__class__.__name__}({e})")
    +            finally:
    +                if self.streaming:
    +                    self.stop_streaming()
    +
    +    def _handle_response_cookies(self, session):
    +        pass
    +
    +    def _get_response(self, payload, api_version):
    +        """Send the actual HTTP request and get the response."""
    +        if self.streaming:
    +            # Make sure to clean up lingering resources
    +            self.stop_streaming()
    +        session = self.protocol.get_session()
    +        r, session = post_ratelimited(
    +            protocol=self.protocol,
    +            session=session,
    +            url=self.protocol.service_endpoint,
    +            headers=self._extra_headers(session),
    +            data=wrap(
    +                content=payload,
    +                api_version=api_version,
    +                account_to_impersonate=self._account_to_impersonate,
    +                timezone=self._timezone,
    +            ),
    +            stream=self.streaming,
    +            timeout=self.timeout or self.protocol.TIMEOUT,
    +        )
    +        self._handle_response_cookies(session)
    +        if self.streaming:
    +            # We con only release the session when we have fully consumed the response. Save session and response
    +            # objects for later.
    +            self._streaming_session, self._streaming_response = session, r
    +        else:
    +            self.protocol.release_session(session)
    +        return r
    +
    +    @property
    +    def _api_versions_to_try(self):
    +        # Put the hint first in the list, and then all other versions except the hint, from newest to oldest
    +        return (self._version_hint.api_version,) + tuple(v for v in API_VERSIONS if v != self._version_hint.api_version)
    +
    +    def _get_response_xml(self, payload, **parse_opts):
    +        """Send the payload to the server and return relevant elements from the result. Several things happen here:
    +          * The payload is wrapped in SOAP headers and sent to the server
    +          * The Exchange API version is negotiated and stored in the protocol object
    +          * Connection errors are handled and possibly reraised as ErrorServerBusy
    +          * SOAP errors are raised
    +          * EWS errors are raised, or passed on to the caller
    +
    +        :param payload: The request payload, as an XML object
    +        :return: A generator of XML objects or None if the service does not return a result
    +        """
    +        # Microsoft really doesn't want to make our lives easy. The server may report one version in our initial version
    +        # guessing tango, but then the server may decide that any arbitrary legacy backend server may actually process
    +        # the request for an account. Prepare to handle version-related errors and set the server version per-account.
    +        log.debug("Calling service %s", self.SERVICE_NAME)
    +        for api_version in self._api_versions_to_try:
    +            log.debug("Trying API version %s", api_version)
    +            r = self._get_response(payload=payload, api_version=api_version)
    +            if self.streaming:
    +                # Let 'requests' decode raw data automatically
    +                r.raw.decode_content = True
    +            try:
    +                header, body = self._get_soap_parts(response=r, **parse_opts)
    +            except Exception:
    +                r.close()  # Release memory
    +                raise
    +            # The body may contain error messages from Exchange, but we still want to collect version info
    +            if header is not None:
    +                self._update_api_version(api_version=api_version, header=header, **parse_opts)
    +            try:
    +                return self._get_soap_messages(body=body, **parse_opts)
    +            except (
    +                ErrorInvalidServerVersion,
    +                ErrorIncorrectSchemaVersion,
    +                ErrorInvalidRequest,
    +                ErrorInvalidSchemaVersionForMailboxVersion,
    +            ):
    +                # The guessed server version is wrong. Try the next version
    +                log.debug("API version %s was invalid", api_version)
    +                continue
    +            except ErrorExceededConnectionCount as e:
    +                # This indicates that the connecting user has too many open TCP connections to the server. Decrease
    +                # our session pool size.
    +                try:
    +                    self.protocol.decrease_poolsize()
    +                    continue
    +                except SessionPoolMinSizeReached:
    +                    # We're already as low as we can go. Let the user handle this.
    +                    raise e
    +            finally:
    +                if not self.streaming:
    +                    # In streaming mode, we may not have accessed the raw stream yet. Caller must handle this.
    +                    r.close()  # Release memory
    +
    +        raise self.NO_VALID_SERVER_VERSIONS(f"Tried versions {self._api_versions_to_try} but all were invalid")
    +
    +    def _handle_backoff(self, e):
    +        """Take a request from the server to back off and checks the retry policy for what to do. Re-raise the
    +        exception if conditions are not met.
    +
    +        :param e: An ErrorServerBusy instance
    +        :return:
    +        """
    +        log.debug("Got ErrorServerBusy (back off %s seconds)", e.back_off)
    +        # ErrorServerBusy is very often a symptom of sending too many requests. Scale back connections if possible.
    +        with suppress(SessionPoolMinSizeReached):
    +            self.protocol.decrease_poolsize()
    +        if self.protocol.retry_policy.fail_fast:
    +            raise e
    +        self.protocol.retry_policy.back_off(e.back_off)
    +        # We'll warn about this later if we actually need to sleep
    +
    +    def _update_api_version(self, api_version, header, **parse_opts):
    +        """Parse the server version contained in SOAP headers and update the version hint stored by the caller, if
    +        necessary.
    +        """
    +        try:
    +            head_version = Version.from_soap_header(requested_api_version=api_version, header=header)
    +        except TransportError as te:
    +            log.debug("Failed to update version info (%s)", te)
    +            return
    +        if self._version_hint == head_version:
    +            # Nothing to do
    +            return
    +        log.debug("Found new version (%s -> %s)", self._version_hint, head_version)
    +        # The api_version that worked was different than our hint, or we never got a build version. Store the working
    +        # version.
    +        self._version_hint = head_version
    +
    +    @classmethod
    +    def _response_tag(cls):
    +        """Return the name of the element containing the service response."""
    +        return f"{{{MNS}}}{cls.SERVICE_NAME}Response"
    +
    +    @staticmethod
    +    def _response_messages_tag():
    +        """Return the name of the element containing service response messages."""
    +        return f"{{{MNS}}}ResponseMessages"
    +
    +    @classmethod
    +    def _response_message_tag(cls):
    +        """Return the name of the element of a single response message."""
    +        return f"{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage"
    +
    +    @classmethod
    +    def _get_soap_parts(cls, response, **parse_opts):
    +        """Split the SOAP response into its headers an body elements."""
    +        try:
    +            root = to_xml(response.iter_content())
    +        except ParseError as e:
    +            raise SOAPError(f"Bad SOAP response: {e}")
    +        header = root.find(f"{{{SOAPNS}}}Header")
    +        if header is None:
    +            # This is normal when the response contains SOAP-level errors
    +            log.debug("No header in XML response")
    +        body = root.find(f"{{{SOAPNS}}}Body")
    +        if body is None:
    +            raise MalformedResponseError("No Body element in SOAP response")
    +        return header, body
    +
    +    def _get_soap_messages(self, body, **parse_opts):
    +        """Return the elements in the response containing the response messages. Raises any SOAP exceptions."""
    +        response = body.find(self._response_tag())
    +        if response is None:
    +            fault = body.find(f"{{{SOAPNS}}}Fault")
    +            if fault is None:
    +                raise SOAPError(f"Unknown SOAP response (expected {self._response_tag()} or Fault): {xml_to_str(body)}")
    +            self._raise_soap_errors(fault=fault)  # Will throw SOAPError or custom EWS error
    +        response_messages = response.find(self._response_messages_tag())
    +        if response_messages is None:
    +            # Result isn't delivered in a list of FooResponseMessages, but directly in the FooResponse. Consumers expect
    +            # a list, so return a list
    +            return [response]
    +        return response_messages.findall(self._response_message_tag())
    +
    +    @classmethod
    +    def _raise_soap_errors(cls, fault):
    +        """Parse error messages contained in SOAP headers and raise as exceptions defined in this package."""
    +        # Fault: See http://www.w3.org/TR/2000/NOTE-SOAP-20000508/#_Toc478383507
    +        fault_code = get_xml_attr(fault, "faultcode")
    +        fault_string = get_xml_attr(fault, "faultstring")
    +        fault_actor = get_xml_attr(fault, "faultactor")
    +        detail = fault.find("detail")
    +        if detail is not None:
    +            code, msg = None, ""
    +            if detail.find(f"{{{ENS}}}ResponseCode") is not None:
    +                code = get_xml_attr(detail, f"{{{ENS}}}ResponseCode").strip()
    +            if detail.find(f"{{{ENS}}}Message") is not None:
    +                msg = get_xml_attr(detail, f"{{{ENS}}}Message").strip()
    +            msg_xml = detail.find(f"{{{TNS}}}MessageXml")  # Crazy. Here, it's in the TNS namespace
    +            if code == "ErrorServerBusy":
    +                back_off = None
    +                with suppress(TypeError, AttributeError):
    +                    value = msg_xml.find(f"{{{TNS}}}Value")
    +                    if value.get("Name") == "BackOffMilliseconds":
    +                        back_off = int(value.text) / 1000.0  # Convert to seconds
    +                raise ErrorServerBusy(msg, back_off=back_off)
    +            if code == "ErrorSchemaValidation" and msg_xml is not None:
    +                line_number = get_xml_attr(msg_xml, f"{{{TNS}}}LineNumber")
    +                line_position = get_xml_attr(msg_xml, f"{{{TNS}}}LinePosition")
    +                violation = get_xml_attr(msg_xml, f"{{{TNS}}}Violation")
    +                if violation:
    +                    msg = f"{msg} {violation}"
    +                if line_number or line_position:
    +                    msg = f"{msg} (line: {line_number} position: {line_position})"
    +            try:
    +                raise vars(errors)[code](msg)
    +            except KeyError:
    +                detail = f"{cls.SERVICE_NAME}: code: {code} msg: {msg} ({xml_to_str(detail)})"
    +        with suppress(KeyError):
    +            raise vars(errors)[fault_code](fault_string)
    +        raise SOAPError(f"SOAP error code: {fault_code} string: {fault_string} actor: {fault_actor} detail: {detail}")
    +
    +    def _get_element_container(self, message, name=None):
    +        """Return the XML element in a response element that contains the elements we want the service to return. For
    +        example, in a GetFolder response, 'message' is the GetFolderResponseMessage element, and we return the 'Folders'
    +        element:
    +
    +        <m:GetFolderResponseMessage ResponseClass="Success">
    +          <m:ResponseCode>NoError</m:ResponseCode>
    +          <m:Folders>
    +            <t:Folder>
    +              <t:FolderId Id="AQApA=" ChangeKey="AQAAAB" />
    +              [...]
    +            </t:Folder>
    +          </m:Folders>
    +        </m:GetFolderResponseMessage>
    +
    +        Some service responses don't have a containing element for the returned elements ('name' is None). In
    +        that case, we return the 'SomeServiceResponseMessage' element.
    +
    +        If the response contains a warning or an error message, we raise the relevant exception, unless the error class
    +        is contained in WARNINGS_TO_CATCH_IN_RESPONSE or ERRORS_TO_CATCH_IN_RESPONSE, in which case we return the
    +        exception instance.
    +        """
    +        # ResponseClass is an XML attribute of various SomeServiceResponseMessage elements: Possible values are:
    +        # Success, Warning, Error. See e.g.
    +        # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/finditemresponsemessage
    +        response_class = message.get("ResponseClass")
    +        # ResponseCode, MessageText: See
    +        # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/responsecode
    +        response_code = get_xml_attr(message, f"{{{MNS}}}ResponseCode")
    +        if response_class == "Success" and response_code == "NoError":
    +            if not name:
    +                return message
    +            container = message.find(name)
    +            if container is None:
    +                raise MalformedResponseError(f"No {name} elements in ResponseMessage ({xml_to_str(message)})")
    +            return container
    +        if response_code == "NoError":
    +            return True
    +        # Raise any non-acceptable errors in the container, or return the container or the acceptable exception instance
    +        msg_text = get_xml_attr(message, f"{{{MNS}}}MessageText")
    +        msg_xml = message.find(f"{{{MNS}}}MessageXml")
    +        if response_class == "Warning":
    +            try:
    +                raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml)
    +            except self.WARNINGS_TO_CATCH_IN_RESPONSE as e:
    +                return e
    +            except self.WARNINGS_TO_IGNORE_IN_RESPONSE as e:
    +                log.warning(str(e))
    +                container = message.find(name)
    +                if container is None:
    +                    raise MalformedResponseError(f"No {name} elements in ResponseMessage ({xml_to_str(message)})")
    +                return container
    +        # rspclass == 'Error', or 'Success' and not 'NoError'
    +        try:
    +            raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml)
    +        except self.ERRORS_TO_CATCH_IN_RESPONSE as e:
    +            return e
    +
    +    @staticmethod
    +    def _get_exception(code, text, msg_xml):
    +        """Parse error messages contained in EWS responses and raise as exceptions defined in this package."""
    +        if not code:
    +            return TransportError(f"Empty ResponseCode in ResponseMessage (MessageText: {text}, MessageXml: {msg_xml})")
    +        if msg_xml is not None:
    +            # If this is an ErrorInvalidPropertyRequest error, the xml may contain a specific FieldURI
    +            for elem_cls in (FieldURI, IndexedFieldURI, ExtendedFieldURI, ExceptionFieldURI):
    +                elem = msg_xml.find(elem_cls.response_tag())
    +                if elem is not None:
    +                    field_uri = elem_cls.from_xml(elem, account=None)
    +                    text += f" (field: {field_uri})"
    +                    break
    +
    +            # If this is an ErrorInvalidValueForProperty error, the xml may contain the name and value of the property
    +            if code == "ErrorInvalidValueForProperty":
    +                msg_parts = {}
    +                for elem in msg_xml.findall(f"{{{TNS}}}Value"):
    +                    key, val = elem.get("Name"), elem.text
    +                    if key:
    +                        msg_parts[key] = val
    +                if msg_parts:
    +                    text += f" ({', '.join(f'{k}: {v}' for k, v in msg_parts.items())})"
    +
    +            # If this is an ErrorInternalServerError error, the xml may contain a more specific error code
    +            inner_code, inner_text = None, None
    +            for value_elem in msg_xml.findall(f"{{{TNS}}}Value"):
    +                name = value_elem.get("Name")
    +                if name == "InnerErrorResponseCode":
    +                    inner_code = value_elem.text
    +                elif name == "InnerErrorMessageText":
    +                    inner_text = value_elem.text
    +            if inner_code:
    +                try:
    +                    # Raise the error as the inner error code
    +                    return vars(errors)[inner_code](f"{inner_text} (raised from: {code}({text!r}))")
    +                except KeyError:
    +                    # Inner code is unknown to us. Just append to the original text
    +                    text += f" (inner error: {inner_code}({inner_text!r}))"
    +        try:
    +            # Raise the error corresponding to the ResponseCode
    +            return vars(errors)[code](text)
    +        except KeyError:
    +            # Should not happen
    +            return TransportError(
    +                f"Unknown ResponseCode in ResponseMessage: {code} (MessageText: {text}, MessageXml: {msg_xml})"
    +            )
    +
    +    def _get_elements_in_response(self, response):
    +        """Take a list of 'SomeServiceResponseMessage' elements and return the elements in each response message that
    +        we want the service to return. With e.g. 'CreateItem', we get a list of 'CreateItemResponseMessage' elements
    +        and return the 'Message' elements.
    +
    +        <m:CreateItemResponseMessage ResponseClass="Success">
    +          <m:ResponseCode>NoError</m:ResponseCode>
    +          <m:Items>
    +            <t:Message>
    +              <t:ItemId Id="AQApA=" ChangeKey="AQAAAB"/>
    +            </t:Message>
    +          </m:Items>
    +        </m:CreateItemResponseMessage>
    +        <m:CreateItemResponseMessage ResponseClass="Success">
    +          <m:ResponseCode>NoError</m:ResponseCode>
    +          <m:Items>
    +            <t:Message>
    +              <t:ItemId Id="AQApB=" ChangeKey="AQAAAC"/>
    +            </t:Message>
    +          </m:Items>
    +        </m:CreateItemResponseMessage>
    +
    +        :param response: a list of 'SomeServiceResponseMessage' XML objects
    +        :return: a generator of items as returned by '_get_elements_in_container()
    +        """
    +        for msg in response:
    +            container_or_exc = self._get_element_container(message=msg, name=self.element_container_name)
    +            if isinstance(container_or_exc, (bool, Exception)):
    +                yield container_or_exc
    +            else:
    +                for c in self._get_elements_in_container(container=container_or_exc):
    +                    yield c
    +
    +    @classmethod
    +    def _get_elements_in_container(cls, container):
    +        """Return a list of response elements from an XML response element container. With e.g.
    +        'CreateItem', 'Items' is the container element and we return the 'Message' child elements:
    +
    +          <m:Items>
    +            <t:Message>
    +              <t:ItemId Id="AQApA=" ChangeKey="AQAAAB"/>
    +            </t:Message>
    +          </m:Items>
    +
    +        If the service does not return response elements, return True to indicate the status. Errors have already been
    +        raised.
    +        """
    +        if cls.returns_elements:
    +            return list(container)
    +        return [True]
    +
    +

    Subclasses

    + +

    Class variables

    +
    +
    var CHUNK_SIZE
    +
    +
    +
    +
    var ERRORS_TO_CATCH_IN_RESPONSE
    +
    +
    +
    +
    var NO_VALID_SERVER_VERSIONS
    +
    +

    Global error type within this module.

    +
    +
    var PAGE_SIZE
    +
    +
    +
    +
    var SERVICE_NAME
    +
    +
    +
    +
    var WARNINGS_TO_CATCH_IN_RESPONSE
    +
    +

    Global error type within this module.

    +
    +
    var WARNINGS_TO_IGNORE_IN_RESPONSE
    +
    +
    +
    +
    var element_container_name
    +
    +
    +
    +
    var paging_container_name
    +
    +
    +
    +
    var returns_elements
    +
    +
    +
    +
    var supported_from
    +
    +
    +
    +
    var supports_paging
    +
    +
    +
    +
    +

    Methods

    +
    +
    +def get(self, expect_result=True, **kwargs) +
    +
    +

    Like .call(), but expects exactly one result from the server, or zero when expect_result=False, or either +zero or one when expect_result=None. Returns either one object or None.

    +

    :param expect_result: None, True, or False +:param kwargs: Same as arguments for .call() +:return: Same as .call(), but returns either None or exactly one item

    +
    + +Expand source code + +
    def get(self, expect_result=True, **kwargs):
    +    """Like .call(), but expects exactly one result from the server, or zero when expect_result=False, or either
    +    zero or one when expect_result=None. Returns either one object or None.
    +
    +    :param expect_result: None, True, or False
    +    :param kwargs: Same as arguments for .call()
    +    :return: Same as .call(), but returns either None or exactly one item
    +    """
    +    res = list(self.call(**kwargs))
    +    # Raise any errors
    +    for r in res:
    +        if isinstance(r, Exception):
    +            raise r
    +    if expect_result is None and not res:
    +        # Allow empty result
    +        return None
    +    if expect_result is False:
    +        if res:
    +            raise ValueError(f"Expected result length 0, but got {res}")
    +        return None
    +    if len(res) != 1:
    +        raise ValueError(f"Expected result length 1, but got {res}")
    +    return res[0]
    +
    +
    +
    +def parse(self, xml) +
    +
    +

    Used mostly for testing, when we want to parse static XML data.

    +
    + +Expand source code + +
    def parse(self, xml):
    +    """Used mostly for testing, when we want to parse static XML data."""
    +    resp = DummyResponse(content=xml, streaming=self.streaming)
    +    _, body = self._get_soap_parts(response=resp)
    +    return self._elems_to_objs(self._get_elements_in_response(response=self._get_soap_messages(body=body)))
    +
    +
    +
    +def stop_streaming(self) +
    +
    +
    +
    + +Expand source code + +
    def stop_streaming(self):
    +    if not self.streaming:
    +        raise RuntimeError("Attempt to stop a non-streaming service")
    +    if self._streaming_response:
    +        self._streaming_response.close()  # Release memory
    +        self._streaming_response = None
    +    if self._streaming_session:
    +        self.protocol.release_session(self._streaming_session)
    +        self._streaming_session = None
    +
    +
    +
    +
    class EmptyFolder (*args, **kwargs) @@ -6792,6 +7528,26 @@

    EWSService

    + +
  • +
  • EmptyFolder

    • SERVICE_NAME
    • @@ -7179,4 +7935,4 @@

      pdoc 0.10.0.

      - \ No newline at end of file + diff --git a/docs/exchangelib/transport.html b/docs/exchangelib/transport.html index 15f3c988..c5f549f7 100644 --- a/docs/exchangelib/transport.html +++ b/docs/exchangelib/transport.html @@ -28,6 +28,7 @@

      Module exchangelib.transport

      import logging
       import time
      +from contextlib import suppress
       
       import requests.auth
       import requests_ntlm
      @@ -69,20 +70,16 @@ 

      Module exchangelib.transport

      CBA: None, NOAUTH: None, } -try: +with suppress(ImportError): + # Kerberos auth is optional import requests_gssapi AUTH_TYPE_MAP[GSSAPI] = requests_gssapi.HTTPSPNEGOAuth -except ImportError: - # Kerberos auth is optional - pass -try: +with suppress(ImportError): + # SSPI auth is optional import requests_negotiate_sspi AUTH_TYPE_MAP[SSPI] = requests_negotiate_sspi.HttpNegotiateAuth -except ImportError: - # SSPI auth is optional - pass DEFAULT_ENCODING = "utf-8" DEFAULT_HEADERS = {"Content-Type": f"text/xml; charset={DEFAULT_ENCODING}", "Accept-Encoding": "gzip, deflate"} @@ -551,4 +548,4 @@

      Index

      Generated by pdoc 0.10.0.

      - \ No newline at end of file + diff --git a/docs/exchangelib/util.html b/docs/exchangelib/util.html index 6ae89aa7..83db40c0 100644 --- a/docs/exchangelib/util.html +++ b/docs/exchangelib/util.html @@ -36,6 +36,7 @@

      Module exchangelib.util

      import xml.sax.handler # nosec from base64 import b64decode, b64encode from codecs import BOM_UTF8 +from contextlib import suppress from decimal import Decimal from functools import wraps from threading import get_ident @@ -566,10 +567,8 @@

      Module exchangelib.util

      msg = f'{e}\nOffending text: [...]{offending_excerpt.decode("utf-8", errors="ignore")}[...]' raise ParseError(msg, "<not from file>", e.lineno, e.offset) except TypeError: - try: + with suppress(IndexError, io.UnsupportedOperation): stream.seek(0) - except (IndexError, io.UnsupportedOperation): - pass raise ParseError(f"This is not XML: {stream.read()!r}", "<not from file>", -1, 0) if res.getroot() is None: @@ -609,8 +608,8 @@

      Module exchangelib.util

      """Re-format an XML document to a consistent style.""" return ( lxml.etree.tostring(self.parse_bytes(xml_bytes), xml_declaration=True, encoding="utf-8", pretty_print=True) - .replace(b"\t", b" ") .replace(b" xmlns:", b"\n xmlns:") + .expandtabs() ) @staticmethod @@ -758,13 +757,11 @@

      Module exchangelib.util

      # A collection of error classes we want to handle as TLS verification errors TLS_ERRORS = (requests.exceptions.SSLError,) -try: +with suppress(ImportError): # If pyOpenSSL is installed, requests will use it and throw this class on TLS errors import OpenSSL.SSL TLS_ERRORS += (OpenSSL.SSL.Error,) -except ImportError: - pass def post_ratelimited(protocol, session, url, headers, data, allow_redirects=False, stream=False, timeout=None): @@ -1652,10 +1649,8 @@

      Functions

      msg = f'{e}\nOffending text: [...]{offending_excerpt.decode("utf-8", errors="ignore")}[...]' raise ParseError(msg, "<not from file>", e.lineno, e.offset) except TypeError: - try: + with suppress(IndexError, io.UnsupportedOperation): stream.seek(0) - except (IndexError, io.UnsupportedOperation): - pass raise ParseError(f"This is not XML: {stream.read()!r}", "<not from file>", -1, 0) if res.getroot() is None: @@ -2234,8 +2229,8 @@

      Ancestors

      """Re-format an XML document to a consistent style.""" return ( lxml.etree.tostring(self.parse_bytes(xml_bytes), xml_declaration=True, encoding="utf-8", pretty_print=True) - .replace(b"\t", b" ") .replace(b" xmlns:", b"\n xmlns:") + .expandtabs() ) @staticmethod @@ -2383,8 +2378,8 @@

      Methods

      """Re-format an XML document to a consistent style.""" return ( lxml.etree.tostring(self.parse_bytes(xml_bytes), xml_declaration=True, encoding="utf-8", pretty_print=True) - .replace(b"\t", b" ") .replace(b" xmlns:", b"\n xmlns:") + .expandtabs() )
      @@ -2728,4 +2723,4 @@

      pdoc 0.10.0.

      - \ No newline at end of file + diff --git a/docs/exchangelib/winzone.html b/docs/exchangelib/winzone.html index be1e4a5f..bb464e9b 100644 --- a/docs/exchangelib/winzone.html +++ b/docs/exchangelib/winzone.html @@ -614,6 +614,7 @@

      Module exchangelib.winzone

      "Etc/Universal": CLDR_TO_MS_TIMEZONE_MAP["Etc/UTC"], "Etc/Zulu": CLDR_TO_MS_TIMEZONE_MAP["Etc/UTC"], "Europe/Belfast": CLDR_TO_MS_TIMEZONE_MAP["Europe/London"], + "Europe/Kyiv": CLDR_TO_MS_TIMEZONE_MAP["Europe/Kiev"], "Europe/Nicosia": CLDR_TO_MS_TIMEZONE_MAP["Asia/Nicosia"], "Europe/Tiraspol": CLDR_TO_MS_TIMEZONE_MAP["Europe/Chisinau"], "Factory": CLDR_TO_MS_TIMEZONE_MAP["Etc/UTC"], @@ -753,4 +754,4 @@

      Index

      Generated by pdoc 0.10.0.

      - \ No newline at end of file + diff --git a/exchangelib/autodiscover/cache.py b/exchangelib/autodiscover/cache.py index 2095775c..0443509a 100644 --- a/exchangelib/autodiscover/cache.py +++ b/exchangelib/autodiscover/cache.py @@ -139,9 +139,11 @@ def __exit__(self, *args, **kwargs): def __del__(self): # pylint: disable=bare-except - with suppress(Exception): - # __del__ should never fail + try: self.close() + except Exception: # nosec + # __del__ should never fail + pass def __str__(self): return str(self._protocols) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 80d83aa9..3c5a6d7c 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -143,9 +143,11 @@ def __setstate__(self, state): def __del__(self): # pylint: disable=bare-except - with suppress(Exception): - # __del__ should never fail + try: self.close() + except Exception: # nosec + # __del__ should never fail + pass def close(self): log.debug("Server %s: Closing sessions", self.server) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index bee8ea92..4f1b57bd 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -128,11 +128,13 @@ def __init__(self, protocol, chunk_size=None, timeout=None): def __del__(self): # pylint: disable=bare-except - with suppress(Exception): - # __del__ should never fail + try: if self.streaming: # Make sure to clean up lingering resources self.stop_streaming() + except Exception: # nosec + # __del__ should never fail + pass # The following two methods are the minimum required to be implemented by subclasses, but the name and number of # kwargs differs between services. Therefore, we cannot make these methods abstract. From fc230f6a0d152bf3471de747d1f638b373115df2 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 4 Oct 2022 09:55:50 +0200 Subject: [PATCH 273/509] Bump version --- CHANGELOG.md | 6 ++++++ exchangelib/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aaf3277..9bb188d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ HEAD ---- +4.8.0 +----- +- Added new `OAuth2LegacyCredentials` class to support username/password auth + over OAuth. + + 4.7.6 ----- - Fixed token refresh bug with OAuth2 authentication, again diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index c8ef33bc..ff68ae69 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -36,7 +36,7 @@ from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "4.7.6" +__version__ = "4.8.0" __all__ = [ "__version__", From c32374e023f87f63e7fe6eddeac58cfe6bf7f2ac Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Fri, 7 Oct 2022 10:11:02 +0200 Subject: [PATCH 274/509] docs: Spelling check run --- exchangelib/attachments.py | 2 +- exchangelib/autodiscover/cache.py | 4 ++-- exchangelib/autodiscover/discovery.py | 4 ++-- exchangelib/autodiscover/properties.py | 8 ++++---- exchangelib/credentials.py | 2 +- exchangelib/ewsdatetime.py | 24 ++++++++++++------------ exchangelib/extended_properties.py | 4 ++-- exchangelib/fields.py | 2 +- exchangelib/folders/base.py | 14 +++++++------- exchangelib/folders/collections.py | 6 +++--- exchangelib/folders/queryset.py | 8 ++++---- exchangelib/folders/roots.py | 8 ++++---- exchangelib/items/calendar_item.py | 6 +++--- exchangelib/items/message.py | 4 ++-- exchangelib/properties.py | 8 ++++---- exchangelib/protocol.py | 6 +++--- exchangelib/queryset.py | 2 +- exchangelib/recurrence.py | 8 ++++---- exchangelib/restriction.py | 12 ++++++------ exchangelib/services/common.py | 10 +++++----- exchangelib/services/create_item.py | 2 +- exchangelib/services/export_items.py | 3 +-- exchangelib/services/find_folder.py | 2 +- exchangelib/services/update_folder.py | 2 +- exchangelib/services/update_item.py | 2 +- exchangelib/transport.py | 2 +- exchangelib/util.py | 6 +++--- exchangelib/version.py | 6 +++--- 28 files changed, 83 insertions(+), 84 deletions(-) diff --git a/exchangelib/attachments.py b/exchangelib/attachments.py index c44634d3..8d29d9d2 100644 --- a/exchangelib/attachments.py +++ b/exchangelib/attachments.py @@ -134,7 +134,7 @@ def fp(self): return self._fp def _init_fp(self): - # Create a file-like object for the attachment content. We try hard to reduce memory consumption so we never + # Create a file-like object for the attachment content. We try hard to reduce memory consumption, so we never # store the full attachment content in-memory. if not self.parent_item or not self.parent_item.account: raise ValueError(f"{self.__class__.__name__} must have an account") diff --git a/exchangelib/autodiscover/cache.py b/exchangelib/autodiscover/cache.py index 0443509a..81ff63f0 100644 --- a/exchangelib/autodiscover/cache.py +++ b/exchangelib/autodiscover/cache.py @@ -52,7 +52,7 @@ def shelve_open_with_failover(filename): class AutodiscoverCache: - """Stores the translation from (email domain, credentials) -> AutodiscoverProtocol object so we can re-use TCP + """Stores the translation from (email domain, credentials) -> AutodiscoverProtocol object, so we can re-use TCP connections to an autodiscover server within the same process. Also persists the email domain -> (autodiscover endpoint URL, auth_type) translation to the filesystem so the cache can be shared between multiple processes. @@ -61,7 +61,7 @@ class AutodiscoverCache: advice. But it could save some valuable seconds every time we start a new connection to a known server. In any case, the persistent storage must not contain any sensitive information since the cache could be readable by unprivileged users. Domain, endpoint and auth_type are OK to cache since this info is make publicly available on - HTTP and DNS servers via the autodiscover protocol. Just don't persist any credentials info. + HTTP and DNS servers via the autodiscover protocol. Just don't persist any credential info. If an autodiscover lookup fails for any reason, the corresponding cache entry must be purged. diff --git a/exchangelib/autodiscover/discovery.py b/exchangelib/autodiscover/discovery.py index 2570da0c..d978512a 100644 --- a/exchangelib/autodiscover/discovery.py +++ b/exchangelib/autodiscover/discovery.py @@ -230,7 +230,7 @@ def _redirect_url_is_valid(self, url): return True def _get_unauthenticated_response(self, url, method="post"): - """Get auth type by tasting headers from the server. Do POST requests be default. HEAD is too error prone, and + """Get auth type by tasting headers from the server. Do POST requests be default. HEAD is too error-prone, and some servers are set up to redirect to OWA on all requests except POST to the autodiscover endpoint. :param url: @@ -434,7 +434,7 @@ def _step_2(self, hostname): return self._step_3(hostname=hostname) def _step_3(self, hostname): - """Perform step 3, where the client sends an unauth'ed GET method request to + """Perform step 3, where the client sends an unauthenticated GET method request to http://autodiscover.example.com/autodiscover/autodiscover.xml (Note that this is a non-HTTPS endpoint). The client then does one of the following: * If the GET request returns a 302 redirect response, it gets the redirection URL from the 'Location' HTTP diff --git a/exchangelib/autodiscover/properties.py b/exchangelib/autodiscover/properties.py index bd61fe31..e2b92659 100644 --- a/exchangelib/autodiscover/properties.py +++ b/exchangelib/autodiscover/properties.py @@ -163,7 +163,7 @@ def auth_type(self): # Missing in list are DIGEST and OAUTH2 "basic": BASIC, "kerb": GSSAPI, - "kerbntlm": NTLM, # Means client can chose between NTLM and GSSAPI + "kerbntlm": NTLM, # Means client can choose between NTLM and GSSAPI "ntlm": NTLM, "certificate": CBA, "negotiate": SSPI, # Unsure about this one @@ -258,7 +258,7 @@ def autodiscover_smtp_address(self): @property def version(self): - # Get the server version. Not all protocol entries have a server version so we cheat a bit and also look at the + # Get the server version. Not all protocol entries have a server version, so we cheat a bit and also look at the # other ones that point to the same endpoint. ews_url = self.protocol.ews_url for protocol in self.account.protocols: @@ -331,11 +331,11 @@ def from_bytes(cls, bytes_content): def raise_errors(self): # Find an error message in the response and raise the relevant exception try: - errorcode = self.error_response.error.code + error_code = self.error_response.error.code message = self.error_response.error.message if message in ("The e-mail address cannot be found.", "The email address can't be found."): raise ErrorNonExistentMailbox("The SMTP address has no mailbox associated with it") - raise AutoDiscoverFailed(f"Unknown error {errorcode}: {message}") + raise AutoDiscoverFailed(f"Unknown error {error_code}: {message}") except AttributeError: raise AutoDiscoverFailed(f"Unknown autodiscover error response: {self.error_response}") diff --git a/exchangelib/credentials.py b/exchangelib/credentials.py index 95811b6c..2269293f 100644 --- a/exchangelib/credentials.py +++ b/exchangelib/credentials.py @@ -36,7 +36,7 @@ def lock(self): @abc.abstractmethod def refresh(self, session): """Obtain a new set of valid credentials. This is mostly intended to support OAuth token refreshing, which can - happen in long- running applications or those that cache access tokens and so might start with a token close to + happen in long-running applications or those that cache access tokens and so might start with a token close to expiration. :param session: requests session asking for refreshed credentials diff --git a/exchangelib/ewsdatetime.py b/exchangelib/ewsdatetime.py index cd517b28..de0b7e20 100644 --- a/exchangelib/ewsdatetime.py +++ b/exchangelib/ewsdatetime.py @@ -58,7 +58,7 @@ def from_date(cls, d): @classmethod def from_string(cls, date_string): - # Sometimes, we'll receive a date string with timezone information. Not very useful. + # Sometimes, we'll receive a date string with time zone information. Not very useful. if date_string.endswith("Z"): date_fmt = "%Y-%m-%dZ" elif ":" in date_string: @@ -156,7 +156,7 @@ def __isub__(self, other): @classmethod def from_string(cls, date_string): - # Parses several common datetime formats and returns timezone-aware EWSDateTime objects + # Parses several common datetime formats and returns time zone aware EWSDateTime objects if date_string.endswith("Z"): # UTC datetime return super().strptime(date_string, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) @@ -164,7 +164,7 @@ def from_string(cls, date_string): # This is probably a naive datetime. Don't allow this, but signal caller with an appropriate error local_dt = super().strptime(date_string, "%Y-%m-%dT%H:%M:%S") raise NaiveDateTimeNotAllowed(local_dt) - # This is probably a datetime value with timezone information. This comes in the form '+/-HH:MM'. + # This is probably a datetime value with time zone information. This comes in the form '+/-HH:MM'. aware_dt = datetime.datetime.fromisoformat(date_string).astimezone(UTC).replace(tzinfo=UTC) if isinstance(aware_dt, cls): return aware_dt @@ -206,7 +206,7 @@ def date(self): class EWSTimeZone(zoneinfo.ZoneInfo): - """Represents a timezone as expected by the EWS TimezoneContext / TimezoneDefinition XML element, and returned by + """Represents a time zone as expected by the EWS TimezoneContext / TimezoneDefinition XML element, and returned by services.GetServerTimeZones. """ @@ -223,24 +223,24 @@ def __new__(cls, *args, **kwargs): except KeyError: raise UnknownTimeZone(f"No Windows timezone name found for timezone {instance.key!r}") - # We don't need the Windows long-format timezone name in long format. It's used in timezone XML elements, but - # EWS happily accepts empty strings. For a full list of timezones supported by the target server, including + # We don't need the Windows long-format time zone name in long format. It's used in time zone XML elements, but + # EWS happily accepts empty strings. For a full list of time zones supported by the target server, including # long-format names, see output of services.GetServerTimeZones(account.protocol).call() instance.ms_name = "" return instance def __eq__(self, other): - # Microsoft timezones are less granular than IANA, so an EWSTimeZone created from 'Europe/Copenhagen' may return - # from the server as 'Europe/Copenhagen'. We're catering for Microsoft here, so base equality on the Microsoft - # timezone ID. + # Microsoft time zones are less granular than IANA, so an EWSTimeZone created from 'Europe/Copenhagen' may + # return from the server as 'Europe/Copenhagen'. We're catering for Microsoft here, so base equality on the + # Microsoft time zone ID. if not isinstance(other, self.__class__): return NotImplemented return self.ms_id == other.ms_id @classmethod def from_ms_id(cls, ms_id): - # Create a timezone instance from a Microsoft timezone ID. This is lossy because there is not a 1:1 translation - # from MS timezone ID to IANA timezone. + # Create a time zone instance from a Microsoft time zone ID. This is lossy because there is not a 1:1 + # translation from MS time zone ID to IANA time zone. try: return cls(cls.MS_TO_IANA_MAP[ms_id]) except KeyError: @@ -262,7 +262,7 @@ def from_datetime(cls, tz): @classmethod def from_dateutil(cls, tz): # Objects returned by dateutil.tz.tzlocal() and dateutil.tz.gettz() are not supported. They - # don't contain enough information to reliably match them with a CLDR timezone. + # don't contain enough information to reliably match them with a CLDR time zone. if hasattr(tz, "_filename"): key = "/".join(tz._filename.split("/")[-2:]) return cls(key) diff --git a/exchangelib/extended_properties.py b/exchangelib/extended_properties.py index 320be26e..847f7182 100644 --- a/exchangelib/extended_properties.py +++ b/exchangelib/extended_properties.py @@ -111,7 +111,7 @@ def __init__(self, *args, **kwargs): @classmethod def validate_cls(cls): - # Validate values of class attributes and their inter-dependencies + # Validate values of class attributes and their interdependencies cls._validate_distinguished_property_set_id() cls._validate_property_set_id() cls._validate_property_tag() @@ -210,7 +210,7 @@ def is_property_instance(cls, elem): do not have a name, so we must match on the cls.property_* attributes to match a field in the request with a field in the response. """ - # We can't use ExtendedFieldURI.from_xml(). It clears the XML element but we may not want to consume it here. + # We can't use ExtendedFieldURI.from_xml(). It clears the XML element, but we may not want to consume it here. kwargs = { f.name: f.from_xml(elem=elem.find(ExtendedFieldURI.response_tag()), account=None) for f in ExtendedFieldURI.FIELDS diff --git a/exchangelib/fields.py b/exchangelib/fields.py index ba7085ca..d7c124df 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -1553,7 +1553,7 @@ def __init__(self, *args, **kwargs): class TypeValueField(FieldURIField): - """This field type has no value_cls because values may have many different types.""" + """This field type has no value_cls because values may have many types.""" TYPES_MAP = { "Boolean": bool, diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index cf3da55c..34a65075 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -162,7 +162,7 @@ def _glob(self, pattern): raise ValueError("Already at top") yield from self.parent.glob(tail or "*") elif head == "**": - # Match anything here or in any subfolder at arbitrary depth + # Match anything here or in any sub-folder at arbitrary depth for c in self.walk(): # fnmatch() may be case-sensitive depending on operating system: # force a case-insensitive match since case appears not to @@ -208,7 +208,7 @@ def tree(self): elif i == len(children) and j == 1: # Not the last child, but the first node, which is the name of the child tree += f"└── {node}\n" - else: # Last child, and not name of child + else: # Last child and not name of child tree += f" {node}\n" return tree.strip() @@ -421,7 +421,7 @@ def empty(self, delete_type=HARD_DELETE, delete_sub_folders=False): self.root.clear_cache() def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0): - # Recursively deletes all items in this folder, and all subfolders and their content. Attempts to protect + # Recursively deletes all items in this folder, and all sub-folders and their content. Attempts to protect # distinguished folders from being deleted. Use with caution! from .known_folders import Audits @@ -467,7 +467,7 @@ def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0): _level += 1 for f in self.children: f.wipe(page_size=page_size, chunk_size=chunk_size, _seen=_seen, _level=_level) - # Remove non-distinguished children that are empty and have no subfolders + # Remove non-distinguished children that are empty and have no sub-folders if f.is_deletable and not f.children: log.warning("Deleting folder %s", f) try: @@ -484,7 +484,7 @@ def test_access(self): @classmethod def _kwargs_from_elem(cls, elem, account): - # Check for 'DisplayName' element before collecting kwargs because because that clears the elements + # Check for 'DisplayName' element before collecting kwargs because that clears the elements has_name_elem = elem.find(cls.get_field_by_fieldname("name").response_tag()) is not None kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS} if has_name_elem and not kwargs["name"]: @@ -704,7 +704,7 @@ def get_events(self, subscription_id, watermark): """Get events since the given watermark. Non-blocking. :param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|push]() - :param watermark: Either the watermark from the subscription, or as returned in the last .get_events() call. + :param watermark: Either the watermark from the subscription, or as returned by the last .get_events() call. :return: A Notification object containing a list of events This method doesn't need the current folder instance, but it makes sense to keep the method along the other @@ -753,7 +753,7 @@ def __floordiv__(self, other): Works like as __truediv__ but does not touch the folder cache. - This is useful if the folder hierarchy contains a huge number of folders and you don't want to fetch them all + This is useful if the folder hierarchy contains a huge number of folders, and you don't want to fetch them all :param other: :return: diff --git a/exchangelib/folders/collections.py b/exchangelib/folders/collections.py index 03ce00fa..81595a43 100644 --- a/exchangelib/folders/collections.py +++ b/exchangelib/folders/collections.py @@ -15,7 +15,7 @@ class SyncCompleted(Exception): - """This is a really ugly way of returning the sync state.""" + """This is really misusing an exception to return the sync state.""" def __init__(self, sync_state): super().__init__(sync_state) @@ -285,7 +285,7 @@ def get_folder_fields(self, target_cls, is_complex=None): def _get_target_cls(self): # We may have root folders that don't support the same set of fields as normal folders. If there is a mix of - # both folder types in self.folders, raise an error so we don't risk losing some fields in the query. + # both folder types in self.folders, raise an error, so we don't risk losing some fields in the query. from .base import Folder from .roots import RootOfHierarchy @@ -360,7 +360,7 @@ def find_folders( if depth is None: depth = self._get_default_folder_traversal_depth() if additional_fields is None: - # Default to all non-complex properties. Subfolders will always be of class Folder + # Default to all non-complex properties. Sub-folders will always be of class Folder additional_fields = self.get_folder_fields(target_cls=Folder, is_complex=False) else: for f in additional_fields: diff --git a/exchangelib/folders/queryset.py b/exchangelib/folders/queryset.py index 083b74c5..9593e1e5 100644 --- a/exchangelib/folders/queryset.py +++ b/exchangelib/folders/queryset.py @@ -19,7 +19,7 @@ class FolderQuerySet: - """A QuerySet-like class for finding subfolders of a folder collection.""" + """A QuerySet-like class for finding sub-folders of a folder collection.""" def __init__(self, folder_collection): from .collections import FolderCollection @@ -46,7 +46,7 @@ def only(self, *args): """Restrict the fields returned. 'name' and 'folder_class' are always returned.""" from .base import Folder - # Subfolders will always be of class Folder + # Sub-folders will always be of class Folder all_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=None) all_fields.update(Folder.attribute_fields()) only_fields = [] @@ -113,7 +113,7 @@ def _query(self): from .collections import FolderCollection if self.only_fields is None: - # Subfolders will always be of class Folder + # Sub-folders will always be of class Folder non_complex_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=False) complex_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=True) else: @@ -121,7 +121,7 @@ def _query(self): complex_fields = {f for f in self.only_fields if f.field.is_complex} # First, fetch all non-complex fields using FindFolder. We do this because some folders do not support - # GetFolder but we still want to get as much information as possible. + # GetFolder, but we still want to get as much information as possible. folders = self.folder_collection.find_folders(q=self.q, depth=self._depth, additional_fields=non_complex_fields) if not complex_fields: yield from folders diff --git a/exchangelib/folders/roots.py b/exchangelib/folders/roots.py index 11c02803..a7eb0dfb 100644 --- a/exchangelib/folders/roots.py +++ b/exchangelib/folders/roots.py @@ -40,7 +40,7 @@ class RootOfHierarchy(BaseFolder, metaclass=EWSMeta): __slots__ = "_account", "_subfolders" - # A special folder that acts as the top of a folder hierarchy. Finds and caches subfolders at arbitrary depth. + # A special folder that acts as the top of a folder hierarchy. Finds and caches sub-folders at arbitrary depth. def __init__(self, **kwargs): self._account = kwargs.pop("account", None) # A pointer back to the account holding the folder hierarchy super().__init__(**kwargs) @@ -151,8 +151,8 @@ def _folders_map(self): return self._subfolders with self._subfolders_lock: - # Map root, and all subfolders of root, at arbitrary depth by folder ID. First get distinguished folders, - # so we are sure to apply the correct Folder class, then fetch all subfolders of this root. + # Map root, and all sub-folders of root, at arbitrary depth by folder ID. First get distinguished folders, + # so we are sure to apply the correct Folder class, then fetch all sub-folders of this root. folders_map = {self.id: self} distinguished_folders = [ cls(root=self, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) @@ -287,7 +287,7 @@ def _get_candidate(self, folder_cls, folder_coll): class PublicFoldersRoot(RootOfHierarchy): - """The root of the public folders hierarchy. Not available on all mailboxes.""" + """The root of the public folder hierarchy. Not available on all mailboxes.""" DISTINGUISHED_FOLDER_ID = "publicfoldersroot" DEFAULT_FOLDER_TRAVERSAL_DEPTH = SHALLOW diff --git a/exchangelib/items/calendar_item.py b/exchangelib/items/calendar_item.py index a9d042cc..1f00e113 100644 --- a/exchangelib/items/calendar_item.py +++ b/exchangelib/items/calendar_item.py @@ -144,7 +144,7 @@ class CalendarItem(Item, AcceptDeclineMixIn): def occurrence(self, index): """Get an occurrence of a recurring master by index. No query is sent to the server to actually fetch the item. - Call refresh() on the item do do so. + Call refresh() on the item to do so. Only call this method on a recurring master. @@ -160,7 +160,7 @@ def occurrence(self, index): def recurring_master(self): """Get the recurring master of an occurrence. No query is sent to the server to actually fetch the item. - Call refresh() on the item do do so. + Call refresh() on the item to do so. Only call this method on an occurrence of a recurring master. @@ -294,7 +294,7 @@ class BaseMeetingItem(Item, metaclass=EWSMeta): Certain types are created as a side effect of doing something else. Meeting messages, for example, are created when you send a calendar item to attendees; they are not explicitly created. - Therefore BaseMeetingItem inherits from EWSElement has no save() or send() method + Therefore, BaseMeetingItem inherits from EWSElement has no save() or send() method """ associated_calendar_item_id = AssociatedCalendarItemIdField(field_uri="meeting:AssociatedCalendarItemId") diff --git a/exchangelib/items/message.py b/exchangelib/items/message.py index 8bbc0407..dade5cfe 100644 --- a/exchangelib/items/message.py +++ b/exchangelib/items/message.py @@ -35,7 +35,7 @@ class Message(Item): ) conversation_index = Base64Field(field_uri="message:ConversationIndex", is_read_only=True) conversation_topic = CharField(field_uri="message:ConversationTopic", is_read_only=True) - # Rename 'From' to 'author'. We can't use fieldname 'from' since it's a Python keyword. + # Rename 'From' to 'author'. We can't use field name 'from' since it's a Python keyword. author = MailboxField(field_uri="message:From", is_read_only_after_send=True) message_id = TextField(field_uri="message:InternetMessageId", is_read_only_after_send=True) is_read = BooleanField(field_uri="message:IsRead", is_required=True, default=False) @@ -76,7 +76,7 @@ def send( # New message if copy_to_folder: - # This would better be done via send_and_save() but lets just support it here + # This would better be done via send_and_save() but let's just support it here self.folder = copy_to_folder return self.send_and_save( conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 16044cc7..79839bdd 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -208,7 +208,7 @@ def __new__(mcs, name, bases, kwargs): # FIELDS defined on a model overrides the base class fields fields = kwargs.get("FIELDS", base_fields) + local_fields - # Include all fields as class attributes so we can use them as instance attributes + # Include all fields as class attributes, so we can use them as instance attributes kwargs.update({_mangle(f.name): f for f in fields}) # Calculate __slots__ so we don't have to hard-code it on the model @@ -404,7 +404,7 @@ def add_field(cls, field, insert_after): @classmethod def remove_field(cls, field): - """Remove the given field and and update the slots cache. + """Remove the given field and update the slots cache. :param field: """ @@ -1456,7 +1456,7 @@ class PhoneNumber(EWSElement): class IdChangeKeyMixIn(EWSElement, metaclass=EWSMeta): """Base class for classes that have a concept of 'id' and 'changekey' values. The values are actually stored on - a separate element but we add convenience methods to hide that fact. + a separate element, but we add convenience methods to hide that fact. """ ID_ELEMENT_CLS = None @@ -1944,7 +1944,7 @@ def get_std_and_dst(self, for_year): standard_time, daylight_time = None, None if len(transitions_group.transitions) == 1: # This is a simple transition group representing a timezone with no DST. Some servers don't accept - # TimeZone elements without a STD and DST element (see issue #488). Return StandardTime and DaylightTime + # TimeZone elements without an STD and DST element (see issue #488). Return StandardTime and DaylightTime # objects with dummy values and 0 bias - this satisfies the broken servers and hopefully doesn't break # the well-behaving servers. standard_time = StandardTime(bias=0, time=datetime.time(0), occurrence=1, iso_month=1, weekday=1) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 3c5a6d7c..91c5858a 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -415,7 +415,7 @@ def __call__(cls, *args, **kwargs): return protocol # Acquire lock to guard against multiple threads competing to cache information. Having a per-server lock is - # probably overkill although it would reduce lock contention. + # probably overkill, although it would reduce lock contention. log.debug("Waiting for _protocol_cache_lock") with cls._protocol_cache_lock: try: @@ -560,7 +560,7 @@ def resolve_names(self, names, parent_folders=None, return_full_contact_data=Fal ) def expand_dl(self, distribution_list): - """Expand distribution list into it's members. + """Expand distribution list into its members. :param distribution_list: SMTP address of the distribution list to expand, or a DLMailbox representing the list @@ -629,7 +629,7 @@ class NoVerifyHTTPAdapter(requests.adapters.HTTPAdapter): def cert_verify(self, conn, url, verify, cert): # pylint: disable=unused-argument - # We're overriding a method so we have to keep the signature + # We're overriding a method, so we have to keep the signature super().cert_verify(conn=conn, url=url, verify=False, cert=cert) diff --git a/exchangelib/queryset.py b/exchangelib/queryset.py index 0602f1a2..e331584d 100644 --- a/exchangelib/queryset.py +++ b/exchangelib/queryset.py @@ -45,7 +45,7 @@ def people(self): class QuerySet(SearchableMixIn): - """A Django QuerySet-like class for querying items. Defers queries until the QuerySet is consumed. Supports + """A Django QuerySet-like class for querying items. Defers query until the QuerySet is consumed. Supports chaining to build up complex queries. Django QuerySet documentation: https://docs.djangoproject.com/en/dev/ref/models/querysets/ diff --git a/exchangelib/recurrence.py b/exchangelib/recurrence.py index 3fdc5f20..9cdebf67 100644 --- a/exchangelib/recurrence.py +++ b/exchangelib/recurrence.py @@ -45,10 +45,10 @@ class AbsoluteYearlyPattern(Pattern): ELEMENT_NAME = "AbsoluteYearlyRecurrence" - # The day of month of an occurrence, in range 1 -> 31. If a particular month has less days than the day_of_month + # The day of month of an occurrence, in range 1 -> 31. If a particular month has fewer days than the day_of_month # value, the last day in the month is assumed day_of_month = IntegerField(field_uri="DayOfMonth", min=1, max=31, is_required=True) - # The month of the year, from 1 - 12 + # The month of the year, from 1 to 12 month = EnumField(field_uri="Month", enum=MONTHS, is_required=True) def __str__(self): @@ -69,7 +69,7 @@ class RelativeYearlyPattern(Pattern): # Week number of the month, in range 1 -> 5. If 5 is specified, this assumes the last week of the month for # months that have only 4 weeks week_number = EnumField(field_uri="DayOfWeekIndex", enum=WEEK_NUMBERS, is_required=True) - # The month of the year, from 1 - 12 + # The month of the year, from 1 to 12 month = EnumField(field_uri="Month", enum=MONTHS, is_required=True) def __str__(self): @@ -88,7 +88,7 @@ class AbsoluteMonthlyPattern(Pattern): # Interval, in months, in range 1 -> 99 interval = IntegerField(field_uri="Interval", min=1, max=99, is_required=True) - # The day of month of an occurrence, in range 1 -> 31. If a particular month has less days than the day_of_month + # The day of month of an occurrence, in range 1 -> 31. If a particular month has fewer days than the day_of_month # value, the last day in the month is assumed day_of_month = IntegerField(field_uri="DayOfMonth", min=1, max=31, is_required=True) diff --git a/exchangelib/restriction.py b/exchangelib/restriction.py index 207ce9f4..212d588e 100644 --- a/exchangelib/restriction.py +++ b/exchangelib/restriction.py @@ -156,7 +156,7 @@ def _get_children_from_kwarg(self, key, value, is_single_kwarg=False): return (self.__class__(*children, conn_type=self.OR),) if lookup == self.LOOKUP_CONTAINS and is_iterable(value, generators_allowed=True): - # A '__contains' lookup with an list as the value ony makes sense for list fields, since exact match + # A '__contains' lookup with a list as the value ony makes sense for list fields, since exact match # on multiple distinct values will always fail for single-value fields. # # An empty list as value is allowed. We interpret it to mean "are all values in the empty set contained @@ -223,7 +223,7 @@ def _promote(self): self.children = q.children def clean(self, version): - """Do some basic checks on the attributes, using a generic folder. to_xml() does a really good job of + """Do some basic checks on the attributes, using a generic folder. to_xml() does a good job of validating. There's no reason to replicate much of that here. """ from .folders import Folder @@ -329,7 +329,7 @@ def expr(self): if self.is_leaf(): expr = f"{self.field_path} {self.op} {self.value!r}" else: - # Sort children by field name so we get stable output (for easier testing). Children should never be empty. + # Sort children by field name, so we get stable output (for easier testing). Children should never be empty. expr = f" {self.AND if self.conn_type == self.NOT else self.conn_type} ".join( (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f"({c.expr()})") for c in sorted(self.children, key=lambda i: i.field_path or "") @@ -449,7 +449,7 @@ def xml_elem(self, folders, version, applies_to): clean_value = self._get_clean_value(field_path=field_path, version=version) if issubclass(field_path.field.value_cls, SingleFieldIndexedElement) and not field_path.label: # We allow a filter shortcut of e.g. email_addresses__contains=EmailAddress(label='Foo', ...) instead of - # email_addresses__Foo_email_address=.... Set FieldPath label now so we can generate the field_uri. + # email_addresses__Foo_email_address=.... Set FieldPath label now, so we can generate the field_uri. field_path.label = clean_value.label elif isinstance(field_path.field, DateTimeBackedDateField): # We need to convert to datetime @@ -469,7 +469,7 @@ def xml_elem(self, folders, version, applies_to): else: # We have multiple children. If conn_type is NOT, then group children with AND. We'll add the NOT later elem = self._conn_to_xml(self.AND if self.conn_type == self.NOT else self.conn_type) - # Sort children by field name so we get stable output (for easier testing). Children should never be empty + # Sort children by field name, so we get stable output (for easier testing). Children should never be empty for c in sorted(self.children, key=lambda i: i.field_path or ""): elem.append(c.xml_elem(folders=folders, version=version, applies_to=applies_to)) if elem is None: @@ -492,7 +492,7 @@ def __or__(self, other): def __invert__(self): # ~ operator. If op has an inverse, change op. Else return a new Q with conn_type NOT if self.conn_type == self.NOT: - # This is NOT NOT. Change to AND + # This is 'NOT NOT'. Change to 'AND' new = copy(self) new.conn_type = self.AND new.reduce() diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 4f1b57bd..b909589e 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -95,7 +95,7 @@ class EWSService(metaclass=abc.ABCMeta): ErrorItemCorrupt, ErrorMailRecipientNotFound, ) - # Similarly, define the warnings we want to return unraised + # Similarly, define the warnings we want to return un-raised WARNINGS_TO_CATCH_IN_RESPONSE = ErrorBatchProcessingStopped # Define the warnings we want to ignore, to let response processing proceed WARNINGS_TO_IGNORE_IN_RESPONSE = () @@ -408,7 +408,7 @@ def _update_api_version(self, api_version, header, **parse_opts): # Nothing to do return log.debug("Found new version (%s -> %s)", self._version_hint, head_version) - # The api_version that worked was different than our hint, or we never got a build version. Store the working + # The api_version that worked was different from our hint, or we never got a build version. Store the working # version. self._version_hint = head_version @@ -429,7 +429,7 @@ def _response_message_tag(cls): @classmethod def _get_soap_parts(cls, response, **parse_opts): - """Split the SOAP response into its headers an body elements.""" + """Split the SOAP response into its headers and body elements.""" try: root = to_xml(response.iter_content()) except ParseError as e: @@ -638,7 +638,7 @@ def _get_elements_in_response(self, response): @classmethod def _get_elements_in_container(cls, container): """Return a list of response elements from an XML response element container. With e.g. - 'CreateItem', 'Items' is the container element and we return the 'Message' child elements: + 'CreateItem', 'Items' is the container element, and we return the 'Message' child elements: @@ -843,7 +843,7 @@ def _get_next_offset(paging_infos): # Paging is done for all messages return None # We cannot guarantee that all messages that have a next_offset also have the *same* next_offset. This is - # because the collections that we are iterating may change while iterating. We'll do our best but we cannot + # because the collections that we are iterating may change while iterating. We'll do our best, but we cannot # guarantee 100% consistency when large collections are simultaneously being changed on the server. # # It's not possible to supply a per-folder offset when iterating multiple folders, so we'll just have to diff --git a/exchangelib/services/create_item.py b/exchangelib/services/create_item.py index 6cbbdfde..9e9021bc 100644 --- a/exchangelib/services/create_item.py +++ b/exchangelib/services/create_item.py @@ -62,7 +62,7 @@ def _get_elements_in_container(cls, container): return res or [True] def get_payload(self, items, folder, message_disposition, send_meeting_invitations): - """Take a list of Item objects (CalendarItem, Message etc) and return the XML for a CreateItem request. + """Take a list of Item objects (CalendarItem, Message etc.) and return the XML for a CreateItem request. convert items to XML Elements. MessageDisposition is only applicable to email messages, where it is required. diff --git a/exchangelib/services/export_items.py b/exchangelib/services/export_items.py index 94e977c7..a8480eaa 100644 --- a/exchangelib/services/export_items.py +++ b/exchangelib/services/export_items.py @@ -21,8 +21,7 @@ def get_payload(self, items): payload.append(item_ids_element(items=items, version=self.account.version)) return payload - # We need to override this since ExportItemsResponseMessage is formatted a - # little bit differently. . + # We need to override this since ExportItemsResponseMessage is formatted a bit differently. @classmethod def _get_elements_in_container(cls, container): return [container] diff --git a/exchangelib/services/find_folder.py b/exchangelib/services/find_folder.py index 2aae3328..5c8e46df 100644 --- a/exchangelib/services/find_folder.py +++ b/exchangelib/services/find_folder.py @@ -20,7 +20,7 @@ def __init__(self, *args, **kwargs): self.root = None # A hack to communicate parsing args to _elems_to_objs() def call(self, folders, additional_fields, restriction, shape, depth, max_items, offset): - """Find subfolders of a folder. + """Find sub-folders of a folder. :param folders: the folders to act on :param additional_fields: the extra fields that should be returned with the folder, as FieldPath objects diff --git a/exchangelib/services/update_folder.py b/exchangelib/services/update_folder.py index aa113847..e93aa150 100644 --- a/exchangelib/services/update_folder.py +++ b/exchangelib/services/update_folder.py @@ -149,7 +149,7 @@ def _target_elem(self, target): return to_item_id(target, FolderId) def get_payload(self, folders): - # Takes a list of (Folder, fieldnames) tuples where 'Folder' is a instance of a subclass of Folder and + # Takes a list of (Folder, fieldnames) tuples where 'Folder' is an instance of a subclass of Folder and # 'fieldnames' are the attribute names that were updated. payload = create_element(f"m:{self.SERVICE_NAME}") payload.append(self._changes_elem(target_changes=folders)) diff --git a/exchangelib/services/update_item.py b/exchangelib/services/update_item.py index 6fc828ea..389c5079 100644 --- a/exchangelib/services/update_item.py +++ b/exchangelib/services/update_item.py @@ -98,7 +98,7 @@ def get_payload( send_meeting_invitations_or_cancellations, suppress_read_receipts, ): - # Takes a list of (Item, fieldnames) tuples where 'Item' is a instance of a subclass of Item and 'fieldnames' + # Takes a list of (Item, fieldnames) tuples where 'Item' is an instance of a subclass of Item and 'fieldnames' # are the attribute names that were updated. attrs = dict( ConflictResolution=conflict_resolution, diff --git a/exchangelib/transport.py b/exchangelib/transport.py index 677353f5..94b0ab10 100644 --- a/exchangelib/transport.py +++ b/exchangelib/transport.py @@ -129,7 +129,7 @@ def get_auth_instance(auth_type, **kwargs): def get_service_authtype(service_endpoint, retry_policy, api_versions, name): - # Get auth type by tasting headers from the server. Only do POST requests. HEAD is too error prone, and some servers + # Get auth type by tasting headers from the server. Only do POST requests. HEAD is too error-prone, and some servers # are set up to redirect to OWA on all requests except POST to /EWS/Exchange.asmx # # We don't know the API version yet, but we need it to create a valid request because some Exchange servers only diff --git a/exchangelib/util.py b/exchangelib/util.py index cb191436..166c4364 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -332,7 +332,7 @@ def prepare_input_source(source): def safe_b64decode(data): - # Incoming base64-encoded data is not always padded to a multiple of 4. Python's parser is more strict and requires + # Incoming base64-encoded data is not always padded to a multiple of 4. Python's parser is stricter and requires # padding. Add padding if it's needed. overflow = len(data) % 4 if overflow: @@ -461,7 +461,7 @@ def __init__(self, content_iterator, document_tag="Envelope"): def _get_tag(self): """Iterate over the bytes until we have a full tag in the buffer. If there's a '>' in an attr value, then we'll - exit on that, but it's OK becaus wejust need the plain tag name later. + exit on that, but it's OK because we just need the plain tag name later. """ tag_buffer = [b"<"] while True: @@ -560,7 +560,7 @@ def is_xml(text, expected_prefix=b" Date: Fri, 7 Oct 2022 12:20:17 +0200 Subject: [PATCH 275/509] ci: split up large test cases. Make extended prop register and deregister in test cases safer. (#1126) --- tests/test_items/test_basics.py | 39 ++++++++++++++------- tests/test_items/test_generic.py | 60 +++++++++++++++++--------------- 2 files changed, 59 insertions(+), 40 deletions(-) diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index e0e03a10..6e28e00b 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -601,7 +601,7 @@ def test_save_and_delete(self): items = self.test_folder.filter(categories__contains=item.categories) self.assertEqual(items.count(), 0) - def test_item(self): + def test_item_insert(self): # Test insert insert_kwargs = self.get_random_insert_kwargs() insert_kwargs["categories"] = self.categories @@ -637,7 +637,11 @@ def test_item(self): old, new = set(old or ()), set(new or ()) self.assertEqual(old, new, (f.name, old, new)) + def test_item_update(self): # Test update + insert_kwargs = self.get_random_insert_kwargs() + insert_kwargs["categories"] = self.categories + item = self.ITEM_CLASS(folder=self.test_folder, **insert_kwargs).save() update_kwargs = self.get_random_update_kwargs(item=item, insert_kwargs=insert_kwargs) if self.ITEM_CLASS in (Contact, DistributionList): # Contact and DistributionList don't support mime_type updates at all @@ -649,8 +653,8 @@ def test_item(self): update_ids = self.account.bulk_update(items=(i for i in [(item, update_fieldnames)])) self.assertEqual(len(update_ids), 1) self.assertEqual(len(update_ids[0]), 2, update_ids) - self.assertEqual(insert_ids[0].id, update_ids[0][0]) # ID should be the same - self.assertNotEqual(insert_ids[0].changekey, update_ids[0][1]) # Changekey should change when item is updated + self.assertEqual(item.id, update_ids[0][0]) # ID should be the same + self.assertNotEqual(item.changekey, update_ids[0][1]) # Changekey should change when item is updated item = self.get_item_by_id(update_ids[0]) for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): @@ -694,7 +698,16 @@ def test_item(self): old, new = set(old or ()), set(new or ()) self.assertEqual(old, new, (f.name, old, new)) + def test_item_update_wipe(self): # Test wiping or removing fields + insert_kwargs = self.get_random_insert_kwargs() + insert_kwargs["categories"] = self.categories + item = self.ITEM_CLASS(folder=self.test_folder, **insert_kwargs).save() + update_kwargs = self.get_random_update_kwargs(item=item, insert_kwargs=insert_kwargs) + if self.ITEM_CLASS in (Contact, DistributionList): + # Contact and DistributionList don't support mime_type updates at all + update_kwargs.pop("mime_content", None) + update_fieldnames = [f for f in update_kwargs if f != "attachments"] wipe_kwargs = {} for f in self.ITEM_CLASS.FIELDS: if not f.supports_version(self.account.version): @@ -715,10 +728,8 @@ def test_item(self): wipe_ids = self.account.bulk_update([(item, update_fieldnames)]) self.assertEqual(len(wipe_ids), 1) self.assertEqual(len(wipe_ids[0]), 2, wipe_ids) - self.assertEqual(insert_ids[0].id, wipe_ids[0][0]) # ID should be the same - self.assertNotEqual( - insert_ids[0].changekey, wipe_ids[0][1] - ) # Changekey should not be the same when item is updated + self.assertEqual(item.id, wipe_ids[0][0]) # ID should be the same + self.assertNotEqual(item.changekey, wipe_ids[0][1]) # Changekey should not be the same when item is updated item = self.get_item_by_id(wipe_ids[0]) for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): @@ -737,17 +748,21 @@ def test_item(self): old, new = set(old or ()), set(new or ()) self.assertEqual(old, new, (f.name, old, new)) + def test_item_update_extended_properties(self): + item = self.get_test_item().save() + item = self.get_item_by_id(item) # An Item saved in Inbox becomes a Message + item.__class__.register("extern_id", ExternId) + try: - item.__class__.register("extern_id", ExternId) # Test extern_id = None, which deletes the extended property entirely extern_id = None item.extern_id = extern_id wipe2_ids = self.account.bulk_update([(item, ["extern_id"])]) self.assertEqual(len(wipe2_ids), 1) self.assertEqual(len(wipe2_ids[0]), 2, wipe2_ids) - self.assertEqual(insert_ids[0].id, wipe2_ids[0][0]) # ID must be the same - self.assertNotEqual(insert_ids[0].changekey, wipe2_ids[0][1]) # Changekey must change when item is updated - item = self.get_item_by_id(wipe2_ids[0]) - self.assertEqual(item.extern_id, extern_id) + self.assertEqual(item.id, wipe2_ids[0][0]) # ID must be the same + self.assertNotEqual(item.changekey, wipe2_ids[0][1]) # Changekey must change when item is updated + updated_item = self.get_item_by_id(wipe2_ids[0]) + self.assertEqual(updated_item.extern_id, extern_id) finally: item.__class__.deregister("extern_id") diff --git a/tests/test_items/test_generic.py b/tests/test_items/test_generic.py index aeffa073..b9ba8704 100644 --- a/tests/test_items/test_generic.py +++ b/tests/test_items/test_generic.py @@ -10,7 +10,7 @@ from exchangelib.extended_properties import ExtendedProperty, ExternId from exchangelib.fields import CharField, ExtendedPropertyField from exchangelib.folders import Folder, FolderCollection, Root -from exchangelib.items import CalendarItem, Item, Message +from exchangelib.items import CalendarItem, Item from exchangelib.queryset import QuerySet from exchangelib.restriction import Q, Restriction from exchangelib.services import CreateItem, DeleteItem, FindItem, FindPeople, UpdateItem @@ -355,6 +355,7 @@ class UnsupportedProp(ExtendedProperty): def test_order_by(self): # Test order_by() on normal field + test_items = [] for i in range(4): item = self.get_test_item() @@ -372,17 +373,20 @@ def test_order_by(self): ) self.bulk_delete(qs) + # Register the extended property on the correct item class + item = self.get_item_by_id(self.get_test_item().save()) # An Item saved in Inbox becomes a Message + item.__class__.register("extern_id", ExternId) + item.delete() + try: - self.ITEM_CLASS.register("extern_id", ExternId) - if self.ITEM_CLASS == Item: - # An Item saved in Inbox becomes a Message - Message.register("extern_id", ExternId) # Test order_by() on ExtendedProperty test_items = [] for i in range(4): - item = self.get_test_item() - item.extern_id = f"ID {i}" - test_items.append(item) + item_kwargs = self.get_random_insert_kwargs() + item_kwargs["categories"] = self.categories + test_item = item.__class__(folder=self.test_folder, **item_kwargs) + test_item.extern_id = f"ID {i}" + test_items.append(test_item) self.test_folder.bulk_create(items=test_items) qs = QuerySet(folder_collection=FolderCollection(account=self.account, folders=[self.test_folder])).filter( categories__contains=self.categories @@ -394,26 +398,29 @@ def test_order_by(self): list(qs.order_by("-extern_id").values_list("extern_id", flat=True)), ["ID 3", "ID 2", "ID 1", "ID 0"] ) finally: - self.ITEM_CLASS.deregister("extern_id") - if self.ITEM_CLASS == Item: - # An Item saved in Inbox becomes a Message - Message.deregister("extern_id") + item.__class__.deregister("extern_id") self.bulk_delete(qs) + def test_order_by_on_multiple_fields(self): # Test sorting on multiple fields + + # Register the extended property on the correct item class + item = self.get_item_by_id(self.get_test_item().save()) # An Item saved in Inbox becomes a Message + item.__class__.register("extern_id", ExternId) + item.delete() + + test_items = [] + for i in range(2): + for j in range(2): + item_kwargs = self.get_random_insert_kwargs() + item_kwargs["categories"] = self.categories + test_item = item.__class__(folder=self.test_folder, **item_kwargs) + test_item.subject = f"Subj {i}" + test_item.extern_id = f"ID {j}" + test_items.append(test_item) + self.test_folder.bulk_create(items=test_items) + try: - self.ITEM_CLASS.register("extern_id", ExternId) - if self.ITEM_CLASS == Item: - # An Item saved in Inbox becomes a Message - Message.register("extern_id", ExternId) - test_items = [] - for i in range(2): - for j in range(2): - item = self.get_test_item() - item.subject = f"Subj {i}" - item.extern_id = f"ID {j}" - test_items.append(item) - self.test_folder.bulk_create(items=test_items) qs = QuerySet(folder_collection=FolderCollection(account=self.account, folders=[self.test_folder])).filter( categories__contains=self.categories ) @@ -454,10 +461,7 @@ def test_order_by(self): ], ) finally: - self.ITEM_CLASS.deregister("extern_id") - if self.ITEM_CLASS == Item: - # An Item saved in Inbox becomes a Message - Message.deregister("extern_id") + item.__class__.deregister("extern_id") def test_order_by_with_empty_values(self): # Test order_by() when some values are empty From b18bcd5ea45caa9b467e0657a5e8c3ab4fdabb42 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Oct 2022 09:45:28 +0200 Subject: [PATCH 276/509] chore: Create a base class for OAuth credentials classes. This makes code handling OAuth credentials instances simpler. (#1127) --- exchangelib/configuration.py | 13 +-- exchangelib/credentials.py | 219 +++++++++++++++++++++++------------ exchangelib/protocol.py | 79 ++++--------- tests/test_credentials.py | 2 +- 4 files changed, 170 insertions(+), 143 deletions(-) diff --git a/exchangelib/configuration.py b/exchangelib/configuration.py index 17ba150f..2c32cacc 100644 --- a/exchangelib/configuration.py +++ b/exchangelib/configuration.py @@ -2,7 +2,7 @@ from cached_property import threaded_cached_property -from .credentials import BaseCredentials, OAuth2AuthorizationCodeCredentials, OAuth2Credentials, OAuth2LegacyCredentials +from .credentials import BaseCredentials, BaseOAuth2Credentials from .errors import InvalidEnumValue, InvalidTypeError from .protocol import FailFast, RetryPolicy from .transport import AUTH_TYPE_MAP, CREDENTIALS_REQUIRED, OAUTH2 @@ -11,13 +11,6 @@ log = logging.getLogger(__name__) -DEFAULT_AUTH_TYPE = { - # This type of credentials *must* use the OAuth auth type - OAuth2Credentials: OAUTH2, - OAuth2LegacyCredentials: OAUTH2, - OAuth2AuthorizationCodeCredentials: OAUTH2, -} - class Configuration: """Contains information needed to create an authenticated connection to an EWS endpoint. @@ -59,9 +52,9 @@ def __init__( ): if not isinstance(credentials, (BaseCredentials, type(None))): raise InvalidTypeError("credentials", credentials, BaseCredentials) - if auth_type is None: + if auth_type is None and isinstance(credentials, BaseOAuth2Credentials): # Set a default auth type for the credentials where this makes sense - auth_type = DEFAULT_AUTH_TYPE.get(type(credentials)) + auth_type = OAUTH2 if auth_type is not None and auth_type not in AUTH_TYPE_MAP: raise InvalidEnumValue("auth_type", auth_type, AUTH_TYPE_MAP) if credentials is None and auth_type in CREDENTIALS_REQUIRED: diff --git a/exchangelib/credentials.py b/exchangelib/credentials.py index 2269293f..21519bb2 100644 --- a/exchangelib/credentials.py +++ b/exchangelib/credentials.py @@ -8,7 +8,8 @@ import logging from threading import RLock -from oauthlib.oauth2 import OAuth2Token +from cached_property import threaded_cached_property +from oauthlib.oauth2 import BackendApplicationClient, LegacyApplicationClient, OAuth2Token, WebApplicationClient from .errors import InvalidTypeError @@ -20,48 +21,13 @@ class BaseCredentials(metaclass=abc.ABCMeta): - """Base for credential storage. - - Establishes a method for refreshing credentials (mostly useful with OAuth, which expires tokens relatively - frequently) and provides a lock for synchronizing access to the object around refreshes. - """ - - def __init__(self): - self._lock = RLock() - - @property - def lock(self): - return self._lock - - @abc.abstractmethod - def refresh(self, session): - """Obtain a new set of valid credentials. This is mostly intended to support OAuth token refreshing, which can - happen in long-running applications or those that cache access tokens and so might start with a token close to - expiration. - - :param session: requests session asking for refreshed credentials - :return: - """ - - def _get_hash_values(self): - return (getattr(self, k) for k in self.__dict__ if k != "_lock") + """Base class for credential storage.""" def __eq__(self, other): - return all(getattr(self, k) == getattr(other, k) for k in self.__dict__ if k != "_lock") + return all(getattr(self, k) == getattr(other, k) for k in self.__dict__) def __hash__(self): - return hash(tuple(self._get_hash_values())) - - def __getstate__(self): - # The lock cannot be pickled - state = self.__dict__.copy() - del state["_lock"] - return state - - def __setstate__(self, state): - # Restore the lock - self.__dict__.update(state) - self._lock = RLock() + return hash(tuple((getattr(self, k) for k in self.__dict__))) class Credentials(BaseCredentials): @@ -89,9 +55,6 @@ def __init__(self, username, password): self.username = username self.password = password - def refresh(self, session): - pass - def __repr__(self): return self.__class__.__name__ + repr((self.username, "********")) @@ -99,14 +62,8 @@ def __str__(self): return self.username -class OAuth2Credentials(BaseCredentials): - """Login info for OAuth 2.0 client credentials authentication, as well as a base for other OAuth 2.0 grant types. - - This is primarily useful for in-house applications accessing data from a single Microsoft account. For applications - that will access multiple tenants' data, the client credentials flow does not give the application enough - information to restrict end users' access to the appropriate account. Use OAuth2AuthorizationCodeCredentials and - the associated auth code grant type for multi-tenant applications. - """ +class BaseOAuth2Credentials(BaseCredentials): + """Base class for all classes that implement OAuth 2.0 authentication""" def __init__(self, client_id, client_secret, tenant_id=None, identity=None, access_token=None): """ @@ -124,9 +81,31 @@ def __init__(self, client_id, client_secret, tenant_id=None, identity=None, acce self.identity = identity self.access_token = access_token + self._lock = RLock() + + @property + def lock(self): + return self._lock + + @property + def access_token(self): + return self._access_token + + @access_token.setter + def access_token(self, access_token): + if access_token is not None and not isinstance(access_token, dict): + raise InvalidTypeError("access_token", access_token, OAuth2Token) + self._access_token = access_token + def refresh(self, session): - # Creating a new session gets a new access token, so there's no work here to refresh the credentials. This - # implementation just makes sure we don't raise a NotImplementedError. + """Obtain a new set of valid credentials. This is intended to support OAuth token refreshing, which can + happen in long-running applications or those that cache access tokens and so might start with a token close to + expiration. + + :param session: requests session asking for refreshed credentials + :return: + """ + # Creating a new session gets a new access token, so there's no work here to refresh the credentials. pass def on_token_auto_refreshed(self, access_token): @@ -138,17 +117,10 @@ def on_token_auto_refreshed(self, access_token): :param access_token: New token obtained by refreshing """ # Ensure we don't update the object in the middle of a new session being created, which could cause a race. - if not isinstance(access_token, dict): - raise InvalidTypeError("access_token", access_token, OAuth2Token) with self.lock: log.debug("%s auth token for %s", "Refreshing" if self.access_token else "Setting", self.client_id) self.access_token = access_token - def _get_hash_values(self): - # 'access_token' may be refreshed once in a while. This should not affect the hash signature. - # 'identity' is just informational and should also not affect the hash signature. - return (getattr(self, k) for k in self.__dict__ if k not in ("_lock", "identity", "access_token")) - def sig(self): # Like hash(self), but pulls in the access token. Protocol.refresh_credentials() uses this to find out # if the access_token needs to be refreshed. @@ -156,26 +128,82 @@ def sig(self): for k in self.__dict__: if k in ("_lock", "identity"): continue - if k == "access_token": + if k == "_access_token": res.append(self.access_token["access_token"] if self.access_token else None) continue res.append(getattr(self, k)) return hash(tuple(res)) @property + @abc.abstractmethod def token_url(self): - return f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token" + """The URL to request tokens from""" @property + @abc.abstractmethod def scope(self): - return ["https://outlook.office365.com/.default"] + """The scope we ask for the token to have""" - def __repr__(self): - return self.__class__.__name__ + repr((self.client_id, "********")) + def session_params(self): + """Extra parameters to use when creating the session""" + return {"token": self.access_token} # Token may be None + + def token_params(self): + """Extra parameters when requesting the token""" + return {"include_client_id": True} + + @threaded_cached_property + @abc.abstractmethod + def client(self): + """The client implementation to use for this credential class""" + + def __eq__(self, other): + return all(getattr(self, k) == getattr(other, k) for k in self.__dict__ if k != "_lock") + + def __hash__(self): + # 'access_token' may be refreshed once in a while. This should not affect the hash signature. + # 'identity' is just informational and should also not affect the hash signature. + return hash(tuple(getattr(self, k) for k in self.__dict__ if k not in ("_lock", "identity", "_access_token"))) def __str__(self): return self.client_id + def __repr__(self): + return self.__class__.__name__ + repr((self.client_id, "********")) + + def __getstate__(self): + # The lock cannot be pickled + state = self.__dict__.copy() + del state["_lock"] + return state + + def __setstate__(self, state): + # Restore the lock + self.__dict__.update(state) + self._lock = RLock() + + +class OAuth2Credentials(BaseOAuth2Credentials): + """Login info for OAuth 2.0 client credentials authentication, as well as a base for other OAuth 2.0 grant types. + + This is primarily useful for in-house applications accessing data from a single Microsoft account. For applications + that will access multiple tenants' data, the client credentials flow does not give the application enough + information to restrict end users' access to the appropriate account. Use OAuth2AuthorizationCodeCredentials and + the associated auth code grant type for multi-tenant applications. + """ + + @property + def token_url(self): + return f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token" + + @property + def scope(self): + return ["https://outlook.office365.com/.default"] + + @threaded_cached_property + def client(self): + return BackendApplicationClient(client_id=self.client_id) + class OAuth2LegacyCredentials(OAuth2Credentials): """Login info for OAuth 2.0 authentication using delegated permissions and application permissions. @@ -187,18 +215,32 @@ class OAuth2LegacyCredentials(OAuth2Credentials): def __init__(self, username, password, **kwargs): """ :param username: The username of the user to act as - :poram password: The password of the user to act as + :param password: The password of the user to act as """ super().__init__(**kwargs) self.username = username self.password = password + def token_params(self): + res = super().token_params() + res.update( + { + "username": self.username, + "password": self.password, + } + ) + return res + + @threaded_cached_property + def client(self): + return LegacyApplicationClient(client_id=self.client_id) + @property def scope(self): return ["https://outlook.office365.com/EWS.AccessAsUser.All"] -class OAuth2AuthorizationCodeCredentials(OAuth2Credentials): +class OAuth2AuthorizationCodeCredentials(BaseOAuth2Credentials): """Login info for OAuth 2.0 authentication using the authorization code grant type. This can be used in one of several ways: * Given an authorization code, client ID, and client secret, fetch a token ourselves and refresh it as needed if @@ -213,23 +255,17 @@ class OAuth2AuthorizationCodeCredentials(OAuth2Credentials): tenant. """ - def __init__(self, authorization_code=None, access_token=None, client_id=None, client_secret=None, **kwargs): + def __init__(self, authorization_code=None, **kwargs): """ - :param client_id: ID of an authorized OAuth application, required for automatic token fetching and refreshing - :param client_secret: Secret associated with the OAuth application - :param tenant_id: Microsoft tenant ID of the account to access - :param identity: An Identity object representing the account that these credentials are connected to. :param authorization_code: Code obtained when authorizing the application to access an account. In combination with client_id and client_secret, will be used to obtain an access token. - :param access_token: Previously-obtained access token. If a token exists and the application will handle - refreshing by itself (or opts not to handle it), this parameter alone is sufficient. """ - super().__init__(client_id=client_id, client_secret=client_secret, **kwargs) + for attr in ("client_id", "client_secret"): + # Allow omitting these kwargs + kwargs[attr] = kwargs.pop(attr, None) + super().__init__(**kwargs) self.authorization_code = authorization_code - if access_token is not None and not isinstance(access_token, dict): - raise InvalidTypeError("access_token", access_token, OAuth2Token) - self.access_token = access_token @property def token_url(self): @@ -243,6 +279,35 @@ def scope(self): res.append("offline_access") return res + def session_params(self): + res = super().session_params() + if self.client_id and self.client_secret: + # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other + # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to + # refresh the token (that covers cases where the caller doesn't have access to the client secret but + # is working with a service that can provide it refreshed tokens on a limited basis). + res.update( + { + "auto_refresh_kwargs": { + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + "auto_refresh_url": self.token_url, + "token_updater": self.on_token_auto_refreshed, + } + ) + return res + + def token_params(self): + res = super().token_params() + res["code"] = self.authorization_code # Auth code may be None + self.authorization_code = None # We can only use the code once + return res + + @threaded_cached_property + def client(self): + return WebApplicationClient(client_id=self.client_id) + def __repr__(self): return self.__class__.__name__ + repr( (self.client_id, "[client_secret]", "[authorization_code]", "[access_token]") diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 91c5858a..49db9b08 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -14,10 +14,10 @@ import requests.adapters import requests.sessions -from oauthlib.oauth2 import BackendApplicationClient, LegacyApplicationClient, WebApplicationClient +from oauthlib.oauth2 import BackendApplicationClient from requests_oauthlib import OAuth2Session -from .credentials import OAuth2AuthorizationCodeCredentials, OAuth2Credentials, OAuth2LegacyCredentials +from .credentials import BaseOAuth2Credentials from .errors import ( CASError, ErrorInvalidSchemaVersionForMailboxVersion, @@ -245,11 +245,12 @@ def release_session(self, session): self._session_pool.put(session, block=False) def close_session(self, session): - if isinstance(self.credentials, OAuth2Credentials) and not isinstance( - self.credentials, OAuth2AuthorizationCodeCredentials + if isinstance(self.credentials, BaseOAuth2Credentials) and isinstance( + self.credentials.client, BackendApplicationClient ): - # Reset token if client is of type BackendApplicationClient - self.credentials.access_token = None + # Reset access token + with self.credentials.lock: + self.credentials.access_token = None session.close() del session @@ -286,24 +287,22 @@ def create_session(self): session = self.raw_session(self.service_endpoint) session.auth = get_auth_instance(auth_type=self.auth_type) else: - with self.credentials.lock: - if isinstance(self.credentials, OAuth2Credentials): + if isinstance(self.credentials, BaseOAuth2Credentials): + with self.credentials.lock: session = self.create_oauth2_session() - # Keep track of the credentials used to create this session. If - # and when we need to renew credentials (for example, refreshing - # an OAuth access token), this lets us easily determine whether - # the credentials have already been refreshed in another thread - # by the time this session tries. + # Keep track of the credentials used to create this session. If and when we need to renew + # credentials (for example, refreshing an OAuth access token), this lets us easily determine whether + # the credentials have already been refreshed in another thread by the time this session tries. session.credentials_sig = self.credentials.sig() + else: + if self.auth_type == NTLM and self.credentials.type == self.credentials.EMAIL: + username = "\\" + self.credentials.username else: - if self.auth_type == NTLM and self.credentials.type == self.credentials.EMAIL: - username = "\\" + self.credentials.username - else: - username = self.credentials.username - session = self.raw_session(self.service_endpoint) - session.auth = get_auth_instance( - auth_type=self.auth_type, username=username, password=self.credentials.password - ) + username = self.credentials.username + session = self.raw_session(self.service_endpoint) + session.auth = get_auth_instance( + auth_type=self.auth_type, username=username, password=self.credentials.password + ) # Add some extra info session.session_id = random.randint(10000, 99999) # Used for debugging messages in services @@ -312,40 +311,10 @@ def create_session(self): return session def create_oauth2_session(self): - session_params = {"token": self.credentials.access_token} # Token may be None - token_params = {"include_client_id": True} - - if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials): - token_params["code"] = self.credentials.authorization_code # Auth code may be None - self.credentials.authorization_code = None # We can only use the code once - - if self.credentials.client_id and self.credentials.client_secret: - # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other - # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to - # refresh the token (that covers cases where the caller doesn't have access to the client secret but - # is working with a service that can provide it refreshed tokens on a limited basis). - session_params.update( - { - "auto_refresh_kwargs": { - "client_id": self.credentials.client_id, - "client_secret": self.credentials.client_secret, - }, - "auto_refresh_url": self.credentials.token_url, - "token_updater": self.credentials.on_token_auto_refreshed, - } - ) - client = WebApplicationClient(client_id=self.credentials.client_id) - elif isinstance(self.credentials, OAuth2LegacyCredentials): - client = LegacyApplicationClient(client_id=self.credentials.client_id) - token_params["username"] = self.credentials.username - token_params["password"] = self.credentials.password - else: - client = BackendApplicationClient(client_id=self.credentials.client_id) - session = self.raw_session( self.service_endpoint, - oauth2_client=client, - oauth2_session_params=session_params, + oauth2_client=self.credentials.client, + oauth2_session_params=self.credentials.session_params(), oauth2_token_endpoint=self.credentials.token_url, ) if not session.token: @@ -356,12 +325,12 @@ def create_oauth2_session(self): client_secret=self.credentials.client_secret, scope=self.credentials.scope, timeout=self.TIMEOUT, - **token_params, + **self.credentials.token_params(), ) # Allow the credentials object to update its copy of the new token, and give the application an opportunity # to cache it. self.credentials.on_token_auto_refreshed(token) - session.auth = get_auth_instance(auth_type=OAUTH2, client=client) + session.auth = get_auth_instance(auth_type=OAUTH2, client=self.credentials.client) return session diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 564b71f8..ad8343d6 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -62,7 +62,7 @@ def test_pickle(self): self.assertEqual(str(o), str(unpickled_o)) def test_plain(self): - Credentials("XXX", "YYY").refresh("XXX") # No-op + OAuth2Credentials("XXX", "YYY", "ZZZZ").refresh("XXX") # No-op def test_oauth_validation(self): with self.assertRaises(TypeError) as e: From 5a649af5269deb9862f11ef8e0cf251e4c8e5873 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Oct 2022 20:13:40 +0200 Subject: [PATCH 277/509] chore: migrate file handling code to pathlib (#1129) --- exchangelib/autodiscover/cache.py | 15 +++++++-------- exchangelib/credentials.py | 1 - scripts/optimize.py | 5 ++--- setup.py | 15 ++++++--------- tests/common.py | 5 ++--- tests/test_autodiscover.py | 12 +++++------- 6 files changed, 22 insertions(+), 31 deletions(-) diff --git a/exchangelib/autodiscover/cache.py b/exchangelib/autodiscover/cache.py index 81ff63f0..946fdd7d 100644 --- a/exchangelib/autodiscover/cache.py +++ b/exchangelib/autodiscover/cache.py @@ -1,11 +1,10 @@ import getpass -import glob import logging -import os import shelve import sys import tempfile from contextlib import contextmanager, suppress +from pathlib import Path from threading import RLock from ..configuration import Configuration @@ -29,25 +28,25 @@ def shelve_filename(): return f"exchangelib.{version}.cache.{user}.py{major}{minor}" -AUTODISCOVER_PERSISTENT_STORAGE = os.path.join(tempfile.gettempdir(), shelve_filename()) +AUTODISCOVER_PERSISTENT_STORAGE = Path(tempfile.gettempdir(), shelve_filename()) @contextmanager -def shelve_open_with_failover(filename): +def shelve_open_with_failover(file): # We can expect empty or corrupt files. Whatever happens, just delete the cache file and try again. # 'shelve' may add a backend-specific suffix to the file, so also delete all files with a suffix. # We don't know which file caused the error, so just delete them all. try: - shelve_handle = shelve.open(filename) + shelve_handle = shelve.open(file) # Try to actually use the file. Some implementations may allow opening the file but then throw # errors on access. with suppress(KeyError): _ = shelve_handle[""] except Exception as e: - for f in glob.glob(filename + "*"): + for f in file.parent.glob(f"{file.name}*"): log.warning("Deleting invalid cache file %s (%r)", f, e) - os.unlink(f) - shelve_handle = shelve.open(filename) + f.unlink() + shelve_handle = shelve.open(file) yield shelve_handle diff --git a/exchangelib/credentials.py b/exchangelib/credentials.py index 21519bb2..ffe69044 100644 --- a/exchangelib/credentials.py +++ b/exchangelib/credentials.py @@ -106,7 +106,6 @@ def refresh(self, session): :return: """ # Creating a new session gets a new access token, so there's no work here to refresh the credentials. - pass def on_token_auto_refreshed(self, access_token): """Set the access_token. Called after the access token is refreshed (requests-oauthlib can automatically diff --git a/scripts/optimize.py b/scripts/optimize.py index 5d2a6a3a..45934509 100755 --- a/scripts/optimize.py +++ b/scripts/optimize.py @@ -4,8 +4,8 @@ import copy import datetime import logging -import os import time +from pathlib import Path try: import zoneinfo @@ -19,8 +19,7 @@ logging.basicConfig(level=logging.WARNING) try: - with open(os.path.join(os.path.dirname(__file__), "../settings.yml")) as f: - settings = safe_load(f) + settings = safe_load((Path(__file__).parent.parent / "settings.yml").read_text()) except FileNotFoundError: print("Copy settings.yml.sample to settings.yml and enter values for your test server") raise diff --git a/setup.py b/setup.py index 6416dd0d..2f27217d 100755 --- a/setup.py +++ b/setup.py @@ -10,23 +10,20 @@ * Push to PyPI: twine upload dist/* * Create release on GitHub """ -import io -import os +from pathlib import Path from setuptools import find_packages, setup def version(): - with io.open(os.path.join(os.path.dirname(__file__), "exchangelib/__init__.py"), encoding="utf-8") as f: - for line in f: - if not line.startswith("__version__"): - continue - return line.split("=")[1].strip(" \"'\n") + for line in read("exchangelib/__init__.py").splitlines(): + if not line.startswith("__version__"): + continue + return line.split("=")[1].strip(" \"'\n") def read(file_name): - with io.open(os.path.join(os.path.dirname(__file__), file_name), encoding="utf-8") as f: - return f.read() + return (Path(__file__).parent / file_name).read_text() setup( diff --git a/tests/common.py b/tests/common.py index 8b031135..49f15fef 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,6 +1,5 @@ import abc import datetime -import os import random import string import time @@ -8,6 +7,7 @@ import unittest.util from collections import namedtuple from decimal import Decimal +from pathlib import Path from yaml import safe_load @@ -72,8 +72,7 @@ def get_settings(): - with open(os.path.join(os.path.dirname(os.path.dirname(__file__)), "settings.yml")) as f: - return safe_load(f) + return safe_load((Path(__file__).parent.parent / "settings.yml").read_text()) def mock_post(url, status_code, headers, text=""): diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 68835577..62c67e22 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -1,5 +1,4 @@ import getpass -import glob import sys from collections import namedtuple from types import MethodType @@ -251,15 +250,14 @@ def test_corrupt_autodiscover_cache(self, m): self.assertTrue(key in autodiscover_cache) # Check that we can recover from a destroyed file - for db_file in glob.glob(autodiscover_cache._storage_file + "*"): - with open(db_file, "w") as f: - f.write("XXX") + file = autodiscover_cache._storage_file + for f in file.parent.glob(f"{file.name}*"): + f.write_text("XXX") self.assertFalse(key in autodiscover_cache) # Check that we can recover from an empty file - for db_file in glob.glob(autodiscover_cache._storage_file + "*"): - with open(db_file, "wb") as f: - f.write(b"") + for f in file.parent.glob(f"{file.name}*"): + f.write_bytes(b"") self.assertFalse(key in autodiscover_cache) @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here From 6d4930a2053346f2468da0bea8d357165961d79a Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 11 Oct 2022 20:49:52 +0200 Subject: [PATCH 278/509] Refurb suggestions (#1130) * docs: improve Subscribe docstring * chore: implement suggestions from refurb in the test suite --- exchangelib/services/subscribe.py | 8 +++++--- tests/test_folder.py | 9 +++------ tests/test_items/test_basics.py | 4 ++-- tests/test_protocol.py | 5 ++--- tests/test_util.py | 11 +++++------ 5 files changed, 17 insertions(+), 20 deletions(-) diff --git a/exchangelib/services/subscribe.py b/exchangelib/services/subscribe.py index 47e5d659..23a30320 100644 --- a/exchangelib/services/subscribe.py +++ b/exchangelib/services/subscribe.py @@ -1,5 +1,5 @@ -"""The 'Subscribe' service has two different modes, pull and push, with different signatures. Implement as two distinct -classes. +"""The 'Subscribe' service has three different modes - pull, push and streaming - with different signatures. Implement +as three distinct classes. """ import abc @@ -8,7 +8,9 @@ class Subscribe(EWSAccountService, metaclass=abc.ABCMeta): - """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/subscribe-operation""" + """Base class for subscription classes. + + MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/subscribe-operation""" SERVICE_NAME = "Subscribe" EVENT_TYPES = ( diff --git a/tests/test_folder.py b/tests/test_folder.py index 6ff64e07..8fa2e806 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -1,3 +1,4 @@ +from contextlib import suppress from unittest.mock import Mock from exchangelib.errors import ( @@ -132,12 +133,10 @@ def test_folder_failure(self): with self.assertRaises(ValueError): self.account.root.get_default_folder(Folder) - try: + with suppress(ErrorFolderNotFound): with self.assertRaises(ValueError) as e: Folder(root=self.account.public_folders_root, parent=self.account.inbox) self.assertEqual(e.exception.args[0], "'parent.root' must match 'root'") - except ErrorFolderNotFound: - pass with self.assertRaises(ValueError) as e: Folder(parent=self.account.inbox, parent_folder_id="XXX") self.assertEqual(e.exception.args[0], "'parent_folder_id' must match 'parent' ID") @@ -158,7 +157,7 @@ def test_folder_failure(self): def test_public_folders_root(self): # Test account does not have a public folders root. Make a dummy query just to hit .get_children() - try: + with suppress(ErrorNoPublicFolderReplicaAvailable): self.assertGreaterEqual( len( list( @@ -167,8 +166,6 @@ def test_public_folders_root(self): ), 0, ) - except ErrorNoPublicFolderReplicaAvailable: - pass def test_invalid_deletefolder_args(self): with self.assertRaises(ValueError) as e: diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index 6e28e00b..6bf22dde 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -607,7 +607,7 @@ def test_item_insert(self): insert_kwargs["categories"] = self.categories item = self.ITEM_CLASS(folder=self.test_folder, **insert_kwargs) # Test with generator as argument - insert_ids = self.test_folder.bulk_create(items=(i for i in [item])) + insert_ids = self.test_folder.bulk_create(items=(i for i in (item,))) self.assertEqual(len(insert_ids), 1) self.assertIsInstance(insert_ids[0], BaseItem) find_ids = list(self.test_folder.filter(categories__contains=item.categories).values_list("id", "changekey")) @@ -650,7 +650,7 @@ def test_item_update(self): for k, v in update_kwargs.items(): setattr(item, k, v) # Test with generator as argument - update_ids = self.account.bulk_update(items=(i for i in [(item, update_fieldnames)])) + update_ids = self.account.bulk_update(items=(i for i in ((item, update_fieldnames),))) self.assertEqual(len(update_ids), 1) self.assertEqual(len(update_ids[0]), 2, update_ids) self.assertEqual(item.id, update_ids[0][0]) # ID should be the same diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 14cd09be..4b4d2ed8 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -4,6 +4,7 @@ import socket import tempfile import warnings +from contextlib import suppress from unittest.mock import Mock, patch try: @@ -260,14 +261,12 @@ def test_get_timezones(self): self.assertAlmostEqual(len(list(self.account.protocol.get_timezones())), 130, delta=30, msg=data) # Test translation to TimeZone objects for tz_definition in self.account.protocol.get_timezones(return_full_timezone_data=True): - try: + with suppress(ValueError): tz = TimeZone.from_server_timezone( tz_definition=tz_definition, for_year=2018, ) self.assertEqual(tz.bias, tz_definition.get_std_and_dst(for_year=2018)[2].bias_in_minutes) - except ValueError: - pass def test_get_timezones_parsing(self): # Test static XML since it's non-standard diff --git a/tests/test_util.py b/tests/test_util.py index aed59e6b..cfddd514 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,5 +1,6 @@ import io import logging +from contextlib import suppress from itertools import chain from unittest.mock import patch @@ -91,13 +92,13 @@ def test_peek(self): # map is_empty, seq = peek(map(int, [])) self.assertEqual((is_empty, list(seq)), (True, [])) - is_empty, seq = peek(map(int, [1, 2, 3])) + is_empty, seq = peek(map(int, (1, 2, 3))) self.assertEqual((is_empty, list(seq)), (False, [1, 2, 3])) # generator - is_empty, seq = peek((i for i in [])) + is_empty, seq = peek((i for i in ())) self.assertEqual((is_empty, list(seq)), (True, [])) - is_empty, seq = peek((i for i in [1, 2, 3])) + is_empty, seq = peek((i for i in (1, 2, 3))) self.assertEqual((is_empty, list(seq)), (False, [1, 2, 3])) @requests_mock.mock() @@ -340,10 +341,8 @@ def test_post_ratelimited(self): exchangelib.util.RETRY_WAIT = RETRY_WAIT exchangelib.util.MAX_REDIRECTS = MAX_REDIRECTS - try: + with suppress(AttributeError): delattr(protocol, "renew_session") - except AttributeError: - pass def test_safe_b64decode(self): # Test correctly padded string From 8efeb6dda2b4305af59255ebc9d4aec306ea3cc2 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 25 Oct 2022 13:56:50 +0200 Subject: [PATCH 279/509] ci: Python 3.11 is out --- .github/workflows/python-package.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index bec3bd93..fec2a269 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -29,11 +29,11 @@ jobs: needs: pre_job strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10'] - include: - # Allow failure on Python dev - e.g. Cython install regularly fails - - python-version: "3.11-dev" - allowed_failure: true + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] + #include: + # # Allow failure on Python dev - e.g. Cython install regularly fails + # - python-version: "3.11-dev" + # allowed_failure: true max-parallel: 1 steps: @@ -95,7 +95,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: '3.10' + python-version: '3.11' - name: Unencrypt secret file env: From 6044fb11f5f5b03c143b8f1b25635f67c84318a6 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 7 Nov 2022 15:01:46 +0100 Subject: [PATCH 280/509] chore: align mixin class spelling --- exchangelib/folders/__init__.py | 4 +- exchangelib/folders/known_folders.py | 76 ++++++++++++++-------------- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/exchangelib/folders/__init__.py b/exchangelib/folders/__init__.py index b0726036..71ec91c7 100644 --- a/exchangelib/folders/__init__.py +++ b/exchangelib/folders/__init__.py @@ -50,7 +50,7 @@ MsgFolderRoot, MyContacts, MyContactsExtended, - NonDeletableFolderMixin, + NonDeletableFolderMixIn, Notes, OrganizationalContacts, Outbox, @@ -150,7 +150,7 @@ "MyContacts", "MyContactsExtended", "NON_DELETABLE_FOLDERS", - "NonDeletableFolderMixin", + "NonDeletableFolderMixIn", "Notes", "OrganizationalContacts", "Outbox", diff --git a/exchangelib/folders/known_folders.py b/exchangelib/folders/known_folders.py index 530af138..6fb05db7 100644 --- a/exchangelib/folders/known_folders.py +++ b/exchangelib/folders/known_folders.py @@ -394,7 +394,7 @@ class VoiceMail(WellknownFolder): } -class NonDeletableFolderMixin: +class NonDeletableFolderMixIn: """A mixin for non-wellknown folders than that are not deletable.""" @property @@ -402,7 +402,7 @@ def is_deletable(self): return False -class AllContacts(NonDeletableFolderMixin, Contacts): +class AllContacts(NonDeletableFolderMixIn, Contacts): CONTAINER_CLASS = "IPF.Note" LOCALIZED_NAMES = { @@ -410,7 +410,7 @@ class AllContacts(NonDeletableFolderMixin, Contacts): } -class AllItems(NonDeletableFolderMixin, Folder): +class AllItems(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPF" LOCALIZED_NAMES = { @@ -418,31 +418,31 @@ class AllItems(NonDeletableFolderMixin, Folder): } -class ApplicationData(NonDeletableFolderMixin, Folder): +class ApplicationData(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPM.ApplicationData" -class Audits(NonDeletableFolderMixin, Folder): +class Audits(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("Audits",), } get_folder_allowed = False -class CalendarLogging(NonDeletableFolderMixin, Folder): +class CalendarLogging(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("Calendar Logging",), } -class CommonViews(NonDeletableFolderMixin, Folder): +class CommonViews(NonDeletableFolderMixIn, Folder): DEFAULT_ITEM_TRAVERSAL_DEPTH = ASSOCIATED LOCALIZED_NAMES = { None: ("Common Views",), } -class Companies(NonDeletableFolderMixin, Contacts): +class Companies(NonDeletableFolderMixIn, Contacts): DISTINGUISHED_FOLDER_ID = None CONTAINTER_CLASS = "IPF.Contact.Company" LOCALIZED_NAMES = { @@ -451,33 +451,33 @@ class Companies(NonDeletableFolderMixin, Contacts): } -class ConversationSettings(NonDeletableFolderMixin, Folder): +class ConversationSettings(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPF.Configuration" LOCALIZED_NAMES = { "da_DK": ("Indstillinger for samtalehandlinger",), } -class DefaultFoldersChangeHistory(NonDeletableFolderMixin, Folder): +class DefaultFoldersChangeHistory(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPM.DefaultFolderHistoryItem" LOCALIZED_NAMES = { None: ("DefaultFoldersChangeHistory",), } -class DeferredAction(NonDeletableFolderMixin, Folder): +class DeferredAction(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("Deferred Action",), } -class ExchangeSyncData(NonDeletableFolderMixin, Folder): +class ExchangeSyncData(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("ExchangeSyncData",), } -class Files(NonDeletableFolderMixin, Folder): +class Files(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPF.Files" LOCALIZED_NAMES = { @@ -485,13 +485,13 @@ class Files(NonDeletableFolderMixin, Folder): } -class FreebusyData(NonDeletableFolderMixin, Folder): +class FreebusyData(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("Freebusy Data",), } -class Friends(NonDeletableFolderMixin, Contacts): +class Friends(NonDeletableFolderMixIn, Contacts): CONTAINER_CLASS = "IPF.Note" LOCALIZED_NAMES = { @@ -499,7 +499,7 @@ class Friends(NonDeletableFolderMixin, Contacts): } -class GALContacts(NonDeletableFolderMixin, Contacts): +class GALContacts(NonDeletableFolderMixIn, Contacts): DISTINGUISHED_FOLDER_ID = None CONTAINER_CLASS = "IPF.Contact.GalContacts" @@ -508,33 +508,33 @@ class GALContacts(NonDeletableFolderMixin, Contacts): } -class GraphAnalytics(NonDeletableFolderMixin, Folder): +class GraphAnalytics(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPF.StoreItem.GraphAnalytics" LOCALIZED_NAMES = { None: ("GraphAnalytics",), } -class Location(NonDeletableFolderMixin, Folder): +class Location(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("Location",), } -class MailboxAssociations(NonDeletableFolderMixin, Folder): +class MailboxAssociations(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("MailboxAssociations",), } -class MyContactsExtended(NonDeletableFolderMixin, Contacts): +class MyContactsExtended(NonDeletableFolderMixIn, Contacts): CONTAINER_CLASS = "IPF.Note" LOCALIZED_NAMES = { None: ("MyContactsExtended",), } -class OrganizationalContacts(NonDeletableFolderMixin, Contacts): +class OrganizationalContacts(NonDeletableFolderMixIn, Contacts): DISTINGUISHED_FOLDER_ID = None CONTAINTER_CLASS = "IPF.Contact.OrganizationalContacts" LOCALIZED_NAMES = { @@ -542,21 +542,21 @@ class OrganizationalContacts(NonDeletableFolderMixin, Contacts): } -class ParkedMessages(NonDeletableFolderMixin, Folder): +class ParkedMessages(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = None LOCALIZED_NAMES = { None: ("ParkedMessages",), } -class PassThroughSearchResults(NonDeletableFolderMixin, Folder): +class PassThroughSearchResults(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPF.StoreItem.PassThroughSearchResults" LOCALIZED_NAMES = { None: ("Pass-Through Search Results",), } -class PeopleCentricConversationBuddies(NonDeletableFolderMixin, Contacts): +class PeopleCentricConversationBuddies(NonDeletableFolderMixIn, Contacts): DISTINGUISHED_FOLDER_ID = None CONTAINTER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies" LOCALIZED_NAMES = { @@ -564,93 +564,93 @@ class PeopleCentricConversationBuddies(NonDeletableFolderMixin, Contacts): } -class PdpProfileV2Secured(NonDeletableFolderMixin, Folder): +class PdpProfileV2Secured(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPF.StoreItem.PdpProfileSecured" LOCALIZED_NAMES = { None: ("PdpProfileV2Secured",), } -class Reminders(NonDeletableFolderMixin, Folder): +class Reminders(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "Outlook.Reminder" LOCALIZED_NAMES = { "da_DK": ("Påmindelser",), } -class RSSFeeds(NonDeletableFolderMixin, Folder): +class RSSFeeds(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPF.Note.OutlookHomepage" LOCALIZED_NAMES = { None: ("RSS Feeds",), } -class Schedule(NonDeletableFolderMixin, Folder): +class Schedule(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("Schedule",), } -class Sharing(NonDeletableFolderMixin, Folder): +class Sharing(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPF.Note" LOCALIZED_NAMES = { None: ("Sharing",), } -class Shortcuts(NonDeletableFolderMixin, Folder): +class Shortcuts(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("Shortcuts",), } -class Signal(NonDeletableFolderMixin, Folder): +class Signal(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPF.StoreItem.Signal" LOCALIZED_NAMES = { None: ("Signal",), } -class SmsAndChatsSync(NonDeletableFolderMixin, Folder): +class SmsAndChatsSync(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPF.SmsAndChatsSync" LOCALIZED_NAMES = { None: ("SmsAndChatsSync",), } -class SpoolerQueue(NonDeletableFolderMixin, Folder): +class SpoolerQueue(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("Spooler Queue",), } -class System(NonDeletableFolderMixin, Folder): +class System(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("System",), } get_folder_allowed = False -class System1(NonDeletableFolderMixin, Folder): +class System1(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("System1",), } get_folder_allowed = False -class TemporarySaves(NonDeletableFolderMixin, Folder): +class TemporarySaves(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("TemporarySaves",), } -class Views(NonDeletableFolderMixin, Folder): +class Views(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("Views",), } -class WorkingSet(NonDeletableFolderMixin, Folder): +class WorkingSet(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("Working Set",), } From aa96d5a6017086b2e24edc8a700ab15979932cc0 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 7 Nov 2022 15:38:00 +0100 Subject: [PATCH 281/509] chore: extract supported/deprecated functions into common mixin classes --- exchangelib/fields.py | 41 +++++++---------------------- exchangelib/folders/base.py | 14 ++-------- exchangelib/properties.py | 4 +-- exchangelib/protocol.py | 1 + exchangelib/services/common.py | 10 +++---- exchangelib/version.py | 48 ++++++++++++++++++++++++++++++++++ tests/test_properties.py | 4 +-- tests/test_protocol.py | 16 +++++++----- 8 files changed, 77 insertions(+), 61 deletions(-) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index d7c124df..b88d5ca7 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -17,7 +17,7 @@ value_to_xml_text, xml_text_to_value, ) -from .version import EXCHANGE_2013, Build +from .version import EXCHANGE_2013, Build, SupportedVersionInstanceMixIn log = logging.getLogger(__name__) @@ -269,7 +269,7 @@ def to_xml(self): return field_order -class Field(metaclass=abc.ABCMeta): +class Field(SupportedVersionInstanceMixIn, metaclass=abc.ABCMeta): """Holds information related to an item field.""" value_cls = None @@ -292,8 +292,8 @@ def __init__( is_searchable=True, is_attribute=False, default=None, - supported_from=None, - deprecated_from=None, + *args, + **kwargs, ): self.name = name # Usually set by the EWSMeta metaclass self.default = default # Default value if none is given @@ -309,16 +309,7 @@ def __init__( self.is_searchable = is_searchable # When true, this field is treated as an XML attribute instead of an element self.is_attribute = is_attribute - # The Exchange build when this field was introduced. When talking with versions prior to this version, - # we will ignore this field. - if supported_from is not None and not isinstance(supported_from, Build): - raise InvalidTypeError("supported_from", supported_from, Build) - self.supported_from = supported_from - # The Exchange build when this field was deprecated. When talking with versions at or later than this version, - # we will ignore this field. - if deprecated_from is not None and not isinstance(deprecated_from, Build): - raise InvalidTypeError("deprecated_from", deprecated_from, Build) - self.deprecated_from = deprecated_from + super().__init__(*args, **kwargs) def clean(self, value, version=None): if version and not self.supports_version(version): @@ -352,14 +343,6 @@ def from_xml(self, elem, account): def to_xml(self, value, version): """Convert this field to an XML element""" - def supports_version(self, version): - # 'version' is a Version instance, for convenience by callers - if self.supported_from and version.build < self.supported_from: - return False - if self.deprecated_from and version.build >= self.deprecated_from: - return False - return True - def __eq__(self, other): return hash(self) == hash(other) @@ -902,18 +885,12 @@ class CultureField(CharField): """Helper to mark strings that are # RFC 1766 culture values.""" -class Choice: +class Choice(SupportedVersionInstanceMixIn): """Implement versioned choices for the ChoiceField field.""" - def __init__(self, value, supported_from=None): + def __init__(self, value, *args, **kwargs): self.value = value - self.supported_from = supported_from - - def supports_version(self, version): - # 'version' is a Version instance, for convenience by callers - if not self.supported_from: - return True - return version.build >= self.supported_from + super().__init__(*args, **kwargs) class ChoiceField(CharField): @@ -934,7 +911,7 @@ def clean(self, value, version=None): return value if value in valid_choices: raise InvalidChoiceForVersion( - f"Choice {self.name!r} only supports EWS builds from {self.supported_from or '*'} to " + f"Choice {self.name!r} only supports server versions from {self.supported_from or '*'} to " f"{self.deprecated_from or '*'} (server has {version})" ) else: diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 34a65075..69728464 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -40,7 +40,7 @@ ) from ..queryset import DoesNotExist, SearchableMixIn from ..util import TNS, is_iterable, require_id -from ..version import EXCHANGE_2007_SP1, EXCHANGE_2010 +from ..version import EXCHANGE_2007_SP1, EXCHANGE_2010, SupportedVersionClassMixIn from .collections import FolderCollection, PullSubscription, PushSubscription, StreamingSubscription, SyncCompleted from .queryset import DEEP as DEEP_FOLDERS from .queryset import MISSING_FOLDER_ERRORS @@ -57,7 +57,7 @@ ) -class BaseFolder(RegisterMixIn, SearchableMixIn, metaclass=EWSMeta): +class BaseFolder(RegisterMixIn, SearchableMixIn, SupportedVersionClassMixIn, metaclass=EWSMeta): """Base class for all classes that implement a folder.""" ELEMENT_NAME = "Folder" @@ -68,9 +68,6 @@ class BaseFolder(RegisterMixIn, SearchableMixIn, metaclass=EWSMeta): # https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxosfld/68a85898-84fe-43c4-b166-4711c13cdd61 CONTAINER_CLASS = None supported_item_models = ITEM_CLASSES # The Item types that this folder can contain. Default is all - # Marks the version from which a distinguished folder was introduced. A possibly authoritative source is: - # https://github.com/OfficeDev/ews-managed-api/blob/master/Enumerations/WellKnownFolderName.cs - supported_from = None # Whether this folder type is allowed with the GetFolder service get_folder_allowed = True DEFAULT_FOLDER_TRAVERSAL_DEPTH = DEEP_FOLDERS @@ -212,13 +209,6 @@ def tree(self): tree += f" {node}\n" return tree.strip() - @classmethod - def supports_version(cls, version): - # 'version' is a Version instance, for convenience by callers - if not cls.supported_from: - return True - return version.build >= cls.supported_from - @property def has_distinguished_name(self): return self.name and self.DISTINGUISHED_FOLDER_ID and self.name.lower() == self.DISTINGUISHED_FOLDER_ID.lower() diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 79839bdd..60610e2d 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -384,8 +384,8 @@ def validate_field(cls, field, version): if not field.supports_version(version): # The field exists but is not valid for this version raise InvalidFieldForVersion( - f"Field {field.name!r} is not supported on server version {version} " - f"(supported from: {field.supported_from}, deprecated from: {field.deprecated_from})" + f"Field {field.name!r} only supports server versions from {field.supported_from or '*'} to " + f"{field.deprecated_from or '*'} (server has {version})" ) @classmethod diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 49db9b08..e7e1b533 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -448,6 +448,7 @@ def __init__(self, *args, **kwargs): def version(self): # Make sure only one thread does the guessing. if not self.config.version or not self.config.version.build: + log.debug("Waiting for _version_lock") with self._version_lock: if not self.config.version or not self.config.version.build: # Version.guess() needs auth objects and a working session pool diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index b909589e..df8640d1 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -64,12 +64,12 @@ to_xml, xml_to_str, ) -from ..version import API_VERSIONS, Version +from ..version import API_VERSIONS, SupportedVersionClassMixIn, Version log = logging.getLogger(__name__) -class EWSService(metaclass=abc.ABCMeta): +class EWSService(SupportedVersionClassMixIn, metaclass=abc.ABCMeta): """Base class for all EWS services.""" PAGE_SIZE = 100 # A default page size for all paging services. This is the number of items we request per page @@ -101,8 +101,6 @@ class EWSService(metaclass=abc.ABCMeta): WARNINGS_TO_IGNORE_IN_RESPONSE = () # The exception type to raise when all attempted API versions failed NO_VALID_SERVER_VERSIONS = ErrorInvalidServerVersion - # Marks the version from which the service was introduced - supported_from = None # Marks services that support paging of requested items supports_paging = False @@ -114,8 +112,8 @@ def __init__(self, protocol, chunk_size=None, timeout=None): raise ValueError(f"'chunk_size' {self.chunk_size} must be a positive number") if self.supported_from and protocol.version.build < self.supported_from: raise NotImplementedError( - f"{self.SERVICE_NAME!r} is only supported on {self.supported_from.fullname()!r} and later. " - f"Your current version is {protocol.version.build.fullname()!r}." + f"Service {self.SERVICE_NAME!r} only supports server versions from {self.supported_from or '*'} to " + f"{self.deprecated_from or '*'} (server has {protocol.version})" ) self.protocol = protocol # Allow a service to override the default protocol timeout. Useful for streaming services diff --git a/exchangelib/version.py b/exchangelib/version.py index 0ae0a6e1..c87d4c90 100644 --- a/exchangelib/version.py +++ b/exchangelib/version.py @@ -293,3 +293,51 @@ def __repr__(self): def __str__(self): return f"Build={self.build}, API={self.api_version}, Fullname={self.fullname}" + + +class SupportedVersionClassMixIn: + """Supports specifying the supported versions of services, fields, folders etc. + + For distinguished folders, a possibly authoritative source is: + # https://github.com/OfficeDev/ews-managed-api/blob/master/Enumerations/WellKnownFolderName.cs + """ + + supported_from = None # The Exchange build when this element was introduced + deprecated_from = None # The Exchange build when this element was deprecated + + @classmethod + def __new__(cls, *args, **kwargs): + _check(cls.supported_from, cls.deprecated_from) + return super().__new__(cls) + + @classmethod + def supports_version(cls, version): + return _supports_version(cls.supported_from, cls.deprecated_from, version) + + +class SupportedVersionInstanceMixIn: + """Like SupportedVersionClassMixIn but for class instances""" + + def __init__(self, supported_from=None, deprecated_from=None): + _check(supported_from, deprecated_from) + self.supported_from = supported_from + self.deprecated_from = deprecated_from + + def supports_version(self, version): + return _supports_version(self.supported_from, self.deprecated_from, version) + + +def _check(supported_from, deprecated_from): + if supported_from is not None and not isinstance(supported_from, Build): + raise InvalidTypeError("supported_from", supported_from, Build) + if deprecated_from is not None and not isinstance(deprecated_from, Build): + raise InvalidTypeError("deprecated_from", deprecated_from, Build) + + +def _supports_version(supported_from, deprecated_from, version): + # 'version' is a Version instance, for convenience by callers + if supported_from and version.build < supported_from: + return False + if deprecated_from and version.build >= deprecated_from: + return False + return True diff --git a/tests/test_properties.py b/tests/test_properties.py index 3a3b34a2..1fd44e11 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -156,8 +156,8 @@ def test_invalid_field(self): Item.validate_field(field=test_field, version=Version(build=EXCHANGE_2010)) self.assertEqual( e.exception.args[0], - "Field 'text_body' is not supported on server version Build=14.0.0.0, API=Exchange2010, Fullname=Microsoft " - "Exchange Server 2010 (supported from: 15.0.0.0, deprecated from: None)", + "Field 'text_body' only supports server versions from 15.0.0.0 to * (server has Build=14.0.0.0, " + "API=Exchange2010, Fullname=Microsoft Exchange Server 2010)", ) def test_add_field(self): diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 4b4d2ed8..ce21eabf 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -1022,24 +1022,26 @@ def test_version_guess(self, m): - - - . - ErrorNameResolutionMultipleResults + + The SMTP address format is invalid. + ErrorInvalidSmtpAddress 0 - + - + """, ) with self.assertRaises(TransportError) as e: Version.guess(protocol) self.assertEqual( - e.exception.args[0], "No valid version headers found in response (ErrorNameResolutionMultipleResults('.'))" + e.exception.args[0], + "No valid version headers found in response " + "(ErrorInvalidSmtpAddress('The SMTP address format is invalid.'))", ) @patch("requests.sessions.Session.post", side_effect=ConnectionResetError("XXX")) From 73f1869e078350a0f4a54bd47dc9504f18ab2d1f Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 7 Nov 2022 15:46:36 +0100 Subject: [PATCH 282/509] chore: Add a new exception type to retry on --- exchangelib/services/common.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index df8640d1..c82a4569 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -13,6 +13,7 @@ ErrorCorruptData, ErrorExceededConnectionCount, ErrorIncorrectSchemaVersion, + ErrorInternalServerTransientError, ErrorInvalidChangeKey, ErrorInvalidIdMalformed, ErrorInvalidRequest, @@ -266,7 +267,7 @@ def _get_elements(self, payload): except ErrorServerBusy as e: self._handle_backoff(e) continue - except (ErrorTooManyObjectsOpened, ErrorTimeoutExpired) as e: + except (ErrorTooManyObjectsOpened, ErrorTimeoutExpired, ErrorInternalServerTransientError) as e: # ErrorTooManyObjectsOpened means there are too many connections to the Exchange database. This is very # often a symptom of sending too many requests. # From e0019f8513e1c11de57d132659b00fddae0b936d Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 7 Nov 2022 15:53:39 +0100 Subject: [PATCH 283/509] chore: Replace ResolveNames with the more lightweight ConvertId for version guessing Marks the ConvertId service as supported on Exchange 2007 as I cannot find documentation that it isn't supported there. This allows us to call the service without accessing the version attribute which we are trying to guess. --- exchangelib/services/convert_id.py | 4 +--- exchangelib/util.py | 12 +----------- exchangelib/version.py | 12 ++++++------ tests/test_protocol.py | 12 ++++++------ 4 files changed, 14 insertions(+), 26 deletions(-) diff --git a/exchangelib/services/convert_id.py b/exchangelib/services/convert_id.py index c9b8bef8..deb67db9 100644 --- a/exchangelib/services/convert_id.py +++ b/exchangelib/services/convert_id.py @@ -1,7 +1,6 @@ from ..errors import InvalidEnumValue, InvalidTypeError from ..properties import ID_FORMATS, AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId from ..util import create_element, set_xml_value -from ..version import EXCHANGE_2007_SP1 from .common import EWSService @@ -13,7 +12,6 @@ class ConvertId(EWSService): """ SERVICE_NAME = "ConvertId" - supported_from = EXCHANGE_2007_SP1 cls_map = {cls.response_tag(): cls for cls in (AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId)} def call(self, items, destination_format): @@ -33,7 +31,7 @@ def get_payload(self, items, destination_format): for item in items: if not isinstance(item, supported_item_classes): raise InvalidTypeError("item", item, supported_item_classes) - set_xml_value(item_ids, item, version=self.protocol.version) + set_xml_value(item_ids, item, version=None) payload.append(item_ids) return payload diff --git a/exchangelib/util.py b/exchangelib/util.py index 166c4364..85feefd0 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -25,14 +25,7 @@ from pygments.lexers.html import XmlLexer from requests_oauthlib import OAuth2Session -from .errors import ( - InvalidTypeError, - MalformedResponseError, - RateLimitError, - RedirectError, - RelativeRedirect, - TransportError, -) +from .errors import MalformedResponseError, RateLimitError, RedirectError, RelativeRedirect, TransportError log = logging.getLogger(__name__) xml_log = logging.getLogger(f"{__name__}.xml") @@ -247,7 +240,6 @@ def set_xml_value(elem, value, version=None): from .ewsdatetime import EWSDate, EWSDateTime from .fields import FieldOrder, FieldPath from .properties import EWSElement - from .version import Version if isinstance(value, (str, bool, bytes, int, Decimal, datetime.time, EWSDate, EWSDateTime)): elem.text = value_to_xml_text(value) @@ -256,8 +248,6 @@ def set_xml_value(elem, value, version=None): elif isinstance(value, (FieldPath, FieldOrder)): elem.append(value.to_xml()) elif isinstance(value, EWSElement): - if not isinstance(version, Version): - raise InvalidTypeError("version", version, Version) elem.append(value.to_xml(version=version)) elif is_iterable(value, generators_allowed=True): for v in value: diff --git a/exchangelib/version.py b/exchangelib/version.py index c87d4c90..6f25a4c6 100644 --- a/exchangelib/version.py +++ b/exchangelib/version.py @@ -217,20 +217,20 @@ def guess(cls, protocol, api_version_hint=None): :param protocol: :param api_version_hint: (Default value = None) """ - from .services import ResolveNames + from .properties import ENTRY_ID, EWS_ID, AlternateId + from .services import ConvertId # The protocol doesn't have a version yet, so default to the latest supported version if we don't have a hint. api_version = api_version_hint or API_VERSIONS[0] log.debug("Asking server for version info using API version %s", api_version) # We don't know the build version yet. Hopefully, the server will report it in the SOAP header. Lots of # places expect a version to have a build, so this is a bit dangerous, but passing a fake build around is also - # dangerous. Make sure the call to ResolveNames does not require a version build. + # dangerous. protocol.config.version = Version(build=None, api_version=api_version) - # Use ResolveNames as a minimal request to the server to test if the version is correct. If not, ResolveNames - # will try to guess the version automatically. - name = str(protocol.credentials) if protocol.credentials and str(protocol.credentials) else "DUMMY" + # Use ConvertId as a minimal request to the server to test if the version is correct. If not, ConvertId will + # try to guess the version automatically. Make sure the call to ConvertId does not require a version build. try: - list(ResolveNames(protocol=protocol).call(unresolved_entries=[name])) + list(ConvertId(protocol=protocol).call([AlternateId(id="DUMMY", format=EWS_ID, mailbox="DUMMY")], ENTRY_ID)) except ResponseMessageError as e: # We may have survived long enough to get a new version if not protocol.config.version.build: diff --git a/tests/test_protocol.py b/tests/test_protocol.py index ce21eabf..b0b74af2 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -995,17 +995,17 @@ def test_version_guess(self, m): MajorVersion="15" MinorVersion="1" MajorBuildNumber="2345" MinorBuildNumber="6789" Version="V2017_07_11"/> - - - Multiple results were found. - ErrorNameResolutionMultipleResults + + The SMTP address format is invalid. + ErrorInvalidSmtpAddress 0 - + - + """, ) From 2dc473b22ca3c6b1352a5d71baf38277d303a53c Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 9 Nov 2022 09:32:53 +0100 Subject: [PATCH 284/509] Add GitHub Dependency Review --- .github/workflows/dependency-review.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/dependency-review.yml diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 00000000..fe461b42 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,20 @@ +# Dependency Review Action +# +# This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. +# +# Source repository: https://github.com/actions/dependency-review-action +# Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement +name: 'Dependency Review' +on: [pull_request] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: 'Checkout Repository' + uses: actions/checkout@v3 + - name: 'Dependency Review' + uses: actions/dependency-review-action@v2 From 9f255c5316aebcfcee57dca9e1f5e02d988bffa2 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 9 Nov 2022 09:35:29 +0100 Subject: [PATCH 285/509] Add CodeQL Scanning --- .github/workflows/codeql.yml | 74 ++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..b96d4349 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,74 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ "master" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "master" ] + schedule: + - cron: '35 7 * * 1' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" From 92c9355f8992c2bccfae28530f7033022f945676 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 9 Nov 2022 09:52:11 +0100 Subject: [PATCH 286/509] Bump actions versions --- .github/workflows/python-package.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index fec2a269..113e299b 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -37,10 +37,10 @@ jobs: max-parallel: 1 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} @@ -90,10 +90,10 @@ jobs: if: ${{ always() }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: '3.11' From d71ec0268530642088a4fefe2f961fa86f2c3a34 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 9 Nov 2022 10:35:13 +0100 Subject: [PATCH 287/509] chore: Update regexp and point to solution used --- exchangelib/util.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/exchangelib/util.py b/exchangelib/util.py index 85feefd0..2e1f512d 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -65,8 +65,9 @@ def __init__(self, msg, data): self.data = data -# Regex of UTF-8 control characters that are illegal in XML 1.0 (and XML 1.1) -_ILLEGAL_XML_CHARS_RE = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1F\uD800-\uDFFF\uFFFE\uFFFF]") +# Regex of UTF-8 control characters that are illegal in XML 1.0 (and XML 1.1). +# See https://stackoverflow.com/a/22273639/219640 +_ILLEGAL_XML_CHARS_RE = re.compile("[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x84\x86-\x9F\uFDD0-\uFDDF\uFFFE\uFFFF]") # XML namespaces SOAPNS = "http://schemas.xmlsoap.org/soap/envelope/" From 3a4a53c49646b8309272583aedaf631e8b014def Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 9 Nov 2022 11:03:35 +0100 Subject: [PATCH 288/509] ci: Only run when Python files are committed --- .pre-commit-config.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 13f63c28..d887f13f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,11 +28,9 @@ repos: entry: isort --check --diff types: [ python ] language: system - always_run: true - id: flake8 name: flake8 stages: [ commit ] entry: flake8 types: [ python ] language: system - always_run: true From 59ecfbb9d8b970bcf317ade359d3528b2ae95b3b Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 9 Nov 2022 11:03:56 +0100 Subject: [PATCH 289/509] ci: Only test oldest and newest supported Python versions, to reduce runtime --- .github/workflows/python-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 113e299b..c63cac52 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -29,7 +29,7 @@ jobs: needs: pre_job strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python-version: ['3.7', '3.11'] #include: # # Allow failure on Python dev - e.g. Cython install regularly fails # - python-version: "3.11-dev" From 56211a060dac1762d424a096c88a73e8fa7b1755 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 9 Nov 2022 12:26:43 +0100 Subject: [PATCH 290/509] ci: Python 12-dev is now available See https://github.com/actions/setup-python/issues/514 --- .github/workflows/python-package.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index c63cac52..9b605cd0 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -30,10 +30,10 @@ jobs: strategy: matrix: python-version: ['3.7', '3.11'] - #include: - # # Allow failure on Python dev - e.g. Cython install regularly fails - # - python-version: "3.11-dev" - # allowed_failure: true + include: + # Allow failure on Python dev - e.g. Cython install regularly fails + - python-version: "3.12-dev" + allowed_failure: true max-parallel: 1 steps: @@ -58,7 +58,7 @@ jobs: - name: Install cutting-edge Cython-based packages on Python dev versions continue-on-error: ${{ matrix.allowed_failure || false }} - if: matrix.python-version == '3.11-dev' + if: matrix.python-version == '3.12-dev' run: | sudo apt-get install libxml2-dev libxslt1-dev python -m pip install hg+https://foss.heptapod.net/pypy/cffi From 34154fce0a3968c5a1b824977c61c2ac5101df35 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 10 Nov 2022 11:48:15 +0100 Subject: [PATCH 291/509] chore: Also replace ResolveNames with ConvertId for auth type guessing. --- exchangelib/protocol.py | 3 +-- exchangelib/transport.py | 18 ++++++++---------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index e7e1b533..2a488622 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -121,9 +121,8 @@ def server(self): def get_auth_type(self): # Autodetect authentication type. We also set version hint here. - name = str(self.credentials) if self.credentials and str(self.credentials) else "DUMMY" auth_type, api_version_hint = get_service_authtype( - service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS, name=name + service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS ) self._api_version_hint = api_version_hint return auth_type diff --git a/exchangelib/transport.py b/exchangelib/transport.py index 94b0ab10..d60ad10e 100644 --- a/exchangelib/transport.py +++ b/exchangelib/transport.py @@ -128,7 +128,7 @@ def get_auth_instance(auth_type, **kwargs): return model(**kwargs) -def get_service_authtype(service_endpoint, retry_policy, api_versions, name): +def get_service_authtype(service_endpoint, retry_policy, api_versions): # Get auth type by tasting headers from the server. Only do POST requests. HEAD is too error-prone, and some servers # are set up to redirect to OWA on all requests except POST to /EWS/Exchange.asmx # @@ -140,7 +140,7 @@ def get_service_authtype(service_endpoint, retry_policy, api_versions, name): t_start = time.monotonic() headers = DEFAULT_HEADERS.copy() for api_version in api_versions: - data = dummy_xml(api_version=api_version, name=name) + data = dummy_xml(api_version=api_version) log.debug("Requesting %s from %s", data, service_endpoint) while True: _back_off_if_needed(retry_policy.back_off_until) @@ -235,17 +235,15 @@ def _tokenize(val): return auth_methods -def dummy_xml(api_version, name): +def dummy_xml(api_version): # Generate a minimal, valid EWS request - from .services import ResolveNames # Avoid circular import + from .properties import ENTRY_ID, EWS_ID, AlternateId + from .services import ConvertId # Avoid circular import return wrap( - content=ResolveNames(protocol=None).get_payload( - unresolved_entries=[name], - parent_folders=None, - return_full_contact_data=False, - search_scope=None, - contact_data_shape=None, + content=ConvertId(protocol=None).get_payload( + items=[AlternateId(id="DUMMY", format=EWS_ID, mailbox="DUMMY")], + destination_format=ENTRY_ID, ), api_version=api_version, ) From 9ba7cc147b1ac0222497462edbe5f48412d48c1b Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 10 Nov 2022 21:18:45 +0100 Subject: [PATCH 292/509] chore: Make wrap() a method on EWSService, to allow overriding --- exchangelib/services/common.py | 61 +++++++++++++++++-- exchangelib/services/send_notification.py | 5 +- exchangelib/transport.py | 70 ++------------------- tests/test_services.py | 73 +++++++++++++++++++++- tests/test_transport.py | 74 +---------------------- 5 files changed, 135 insertions(+), 148 deletions(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index c82a4569..3c87d2c2 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -48,7 +48,7 @@ IndexedFieldURI, ItemId, ) -from ..transport import wrap +from ..transport import DEFAULT_ENCODING from ..util import ( ENS, MNS, @@ -60,6 +60,7 @@ chunkify, create_element, get_xml_attr, + ns_translation, post_ratelimited, set_xml_value, to_xml, @@ -182,6 +183,58 @@ def parse(self, xml): _, body = self._get_soap_parts(response=resp) return self._elems_to_objs(self._get_elements_in_response(response=self._get_soap_messages(body=body))) + def wrap(self, content, api_version=None): + """Generate the necessary boilerplate XML for a raw SOAP request. The XML is specific to the server version. + ExchangeImpersonation allows to act as the user we want to impersonate. + + RequestServerVersion element on MSDN: + https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/requestserverversion + + ExchangeImpersonation element on MSDN: + https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exchangeimpersonation + + TimeZoneContent element on MSDN: + https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezonecontext + + :param content: + :param api_version: + """ + envelope = create_element("s:Envelope", nsmap=ns_translation) + header = create_element("s:Header") + if api_version: + request_server_version = create_element("t:RequestServerVersion", attrs=dict(Version=api_version)) + header.append(request_server_version) + account_to_impersonate = self._account_to_impersonate + if account_to_impersonate: + exchange_impersonation = create_element("t:ExchangeImpersonation") + connecting_sid = create_element("t:ConnectingSID") + # We have multiple options for uniquely identifying the user. Here's a prioritized list in accordance with + # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/connectingsid + for attr, tag in ( + ("sid", "SID"), + ("upn", "PrincipalName"), + ("smtp_address", "SmtpAddress"), + ("primary_smtp_address", "PrimarySmtpAddress"), + ): + val = getattr(account_to_impersonate, attr) + if val: + add_xml_child(connecting_sid, f"t:{tag}", val) + break + exchange_impersonation.append(connecting_sid) + header.append(exchange_impersonation) + timezone = self._timezone + if timezone: + timezone_context = create_element("t:TimeZoneContext") + timezone_definition = create_element("t:TimeZoneDefinition", attrs=dict(Id=timezone.ms_id)) + timezone_context.append(timezone_definition) + header.append(timezone_context) + if len(header): + envelope.append(header) + body = create_element("s:Body") + body.append(content) + envelope.append(body) + return xml_to_str(envelope, encoding=DEFAULT_ENCODING, xml_declaration=True) + def _elems_to_objs(self, elems): """Takes a generator of XML elements and exceptions. Returns the equivalent Python objects (or exceptions).""" for elem in elems: @@ -210,7 +263,7 @@ def _extra_headers(self, session): @property def _account_to_impersonate(self): - if isinstance(self.protocol.credentials, OAuth2Credentials): + if self.protocol and isinstance(self.protocol.credentials, OAuth2Credentials): return self.protocol.credentials.identity return None @@ -299,11 +352,9 @@ def _get_response(self, payload, api_version): session=session, url=self.protocol.service_endpoint, headers=self._extra_headers(session), - data=wrap( + data=self.wrap( content=payload, api_version=api_version, - account_to_impersonate=self._account_to_impersonate, - timezone=self._timezone, ), stream=self.streaming, timeout=self.timeout or self.protocol.TIMEOUT, diff --git a/exchangelib/services/send_notification.py b/exchangelib/services/send_notification.py index 387a02d9..29d2ea4e 100644 --- a/exchangelib/services/send_notification.py +++ b/exchangelib/services/send_notification.py @@ -1,6 +1,5 @@ from ..errors import InvalidEnumValue from ..properties import Notification -from ..transport import wrap from ..util import MNS, create_element from .common import EWSService, add_xml_child @@ -19,10 +18,10 @@ class SendNotification(EWSService): STATUS_CHOICES = (OK, UNSUBSCRIBE) def ok_payload(self): - return wrap(content=self.get_payload(status=self.OK)) + return self.wrap(content=self.get_payload(status=self.OK)) def unsubscribe_payload(self): - return wrap(content=self.get_payload(status=self.UNSUBSCRIBE)) + return self.wrap(content=self.get_payload(status=self.UNSUBSCRIBE)) def _elem_to_obj(self, elem): return Notification.from_xml(elem=elem, account=None) diff --git a/exchangelib/transport.py b/exchangelib/transport.py index d60ad10e..56284397 100644 --- a/exchangelib/transport.py +++ b/exchangelib/transport.py @@ -7,17 +7,7 @@ import requests_oauthlib from .errors import TransportError, UnauthorizedError -from .util import ( - CONNECTION_ERRORS, - RETRY_WAIT, - DummyResponse, - _back_off_if_needed, - _retry_after, - add_xml_child, - create_element, - ns_translation, - xml_to_str, -) +from .util import CONNECTION_ERRORS, RETRY_WAIT, DummyResponse, _back_off_if_needed, _retry_after log = logging.getLogger(__name__) @@ -57,59 +47,6 @@ DEFAULT_HEADERS = {"Content-Type": f"text/xml; charset={DEFAULT_ENCODING}", "Accept-Encoding": "gzip, deflate"} -def wrap(content, api_version=None, account_to_impersonate=None, timezone=None): - """Generate the necessary boilerplate XML for a raw SOAP request. The XML is specific to the server version. - ExchangeImpersonation allows to act as the user we want to impersonate. - - RequestServerVersion element on MSDN: - https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/requestserverversion - - ExchangeImpersonation element on MSDN: - https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exchangeimpersonation - - TimeZoneContent element on MSDN: - https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezonecontext - - :param content: - :param api_version: - :param account_to_impersonate: (Default value = None) - :param timezone: (Default value = None) - """ - envelope = create_element("s:Envelope", nsmap=ns_translation) - header = create_element("s:Header") - if api_version: - request_server_version = create_element("t:RequestServerVersion", attrs=dict(Version=api_version)) - header.append(request_server_version) - if account_to_impersonate: - exchange_impersonation = create_element("t:ExchangeImpersonation") - connecting_sid = create_element("t:ConnectingSID") - # We have multiple options for uniquely identifying the user. Here's a prioritized list in accordance with - # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/connectingsid - for attr, tag in ( - ("sid", "SID"), - ("upn", "PrincipalName"), - ("smtp_address", "SmtpAddress"), - ("primary_smtp_address", "PrimarySmtpAddress"), - ): - val = getattr(account_to_impersonate, attr) - if val: - add_xml_child(connecting_sid, f"t:{tag}", val) - break - exchange_impersonation.append(connecting_sid) - header.append(exchange_impersonation) - if timezone: - timezone_context = create_element("t:TimeZoneContext") - timezone_definition = create_element("t:TimeZoneDefinition", attrs=dict(Id=timezone.ms_id)) - timezone_context.append(timezone_definition) - header.append(timezone_context) - if len(header): - envelope.append(header) - body = create_element("s:Body") - body.append(content) - envelope.append(body) - return xml_to_str(envelope, encoding=DEFAULT_ENCODING, xml_declaration=True) - - def get_auth_instance(auth_type, **kwargs): """Return an *Auth instance suitable for the requests package. @@ -240,8 +177,9 @@ def dummy_xml(api_version): from .properties import ENTRY_ID, EWS_ID, AlternateId from .services import ConvertId # Avoid circular import - return wrap( - content=ConvertId(protocol=None).get_payload( + svc = ConvertId(protocol=None) + return svc.wrap( + content=svc.get_payload( items=[AlternateId(id="DUMMY", format=EWS_ID, mailbox="DUMMY")], destination_format=ENTRY_ID, ), diff --git a/tests/test_services.py b/tests/test_services.py index 405da91b..093694a0 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1,7 +1,10 @@ +from collections import namedtuple from unittest.mock import Mock import requests_mock +from exchangelib.account import DELEGATE, Identity +from exchangelib.credentials import OAuth2Credentials from exchangelib.errors import ( ErrorExceededConnectionCount, ErrorInternalServerError, @@ -18,7 +21,8 @@ from exchangelib.folders import FolderCollection from exchangelib.protocol import FailFast, FaultTolerance from exchangelib.services import DeleteItem, FindFolder, GetRoomLists, GetRooms, GetServerTimeZones, ResolveNames -from exchangelib.util import create_element +from exchangelib.services.common import EWSAccountService, EWSService +from exchangelib.util import PrettyXmlHandler, create_element from exchangelib.version import EXCHANGE_2007, EXCHANGE_2010 from .common import EWSTest, get_random_string, mock_account, mock_protocol, mock_version @@ -340,3 +344,70 @@ def test_version_renegotiate(self): self.assertEqual(old_version, self.account.version.api_version) finally: self.account.version.api_version = old_version + + def test_wrap(self): + # Test payload wrapper with both delegation, impersonation and timezones + svc = EWSService(protocol=None) + wrapped = svc.wrap(content=create_element("AAA"), api_version="BBB") + self.assertEqual( + PrettyXmlHandler().prettify_xml(wrapped), + """\ + + + + + + + + + +""".encode(), + ) + + MockTZ = namedtuple("EWSTimeZone", ["ms_id"]) + MockProtocol = namedtuple("Protocol", ["credentials"]) + MockAccount = namedtuple("Account", ["access_type", "default_timezone", "protocol"]) + for attr, tag in ( + ("primary_smtp_address", "PrimarySmtpAddress"), + ("upn", "PrincipalName"), + ("sid", "SID"), + ("smtp_address", "SmtpAddress"), + ): + val = f"{attr}@example.com" + protocol = MockProtocol( + credentials=OAuth2Credentials(identity=Identity(**{attr: val}), client_id=None, client_secret=None) + ) + account = MockAccount(access_type=DELEGATE, default_timezone=MockTZ("XXX"), protocol=protocol) + svc = EWSAccountService(account=account) + wrapped = svc.wrap( + content=create_element("AAA"), + api_version="BBB", + ) + self.assertEqual( + PrettyXmlHandler().prettify_xml(wrapped), + f"""\ + + + + + + + {val} + + + + + + + + + + +""".encode(), + ) diff --git a/tests/test_transport.py b/tests/test_transport.py index 5b48d2b3..7c922038 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -1,12 +1,8 @@ -from collections import namedtuple - import requests import requests_mock -from exchangelib.account import DELEGATE, Identity from exchangelib.errors import UnauthorizedError -from exchangelib.transport import BASIC, DIGEST, NOAUTH, NTLM, get_auth_method_from_response, wrap -from exchangelib.util import PrettyXmlHandler, create_element +from exchangelib.transport import BASIC, DIGEST, NOAUTH, NTLM, get_auth_method_from_response from .common import TimedTestCase @@ -85,71 +81,3 @@ def test_get_auth_method_from_response(self, m): m.get(url, status_code=401, headers={"WWW-Authenticate": 'Basic realm="X1", Digest realm="X2", NTLM'}) r = requests.get(url) self.assertEqual(get_auth_method_from_response(r), DIGEST) - - def test_wrap(self): - # Test payload wrapper with both delegation, impersonation and timezones - MockTZ = namedtuple("EWSTimeZone", ["ms_id"]) - MockAccount = namedtuple("Account", ["access_type", "identity", "default_timezone"]) - content = create_element("AAA") - api_version = "BBB" - account = MockAccount(access_type=DELEGATE, identity=None, default_timezone=MockTZ("XXX")) - wrapped = wrap(content=content, api_version=api_version, timezone=account.default_timezone) - self.assertEqual( - PrettyXmlHandler().prettify_xml(wrapped), - b""" - - - - - - - - - - - -""", - ) - for attr, tag in ( - ("primary_smtp_address", "PrimarySmtpAddress"), - ("upn", "PrincipalName"), - ("sid", "SID"), - ("smtp_address", "SmtpAddress"), - ): - val = f"{attr}@example.com" - account = MockAccount( - access_type=DELEGATE, identity=Identity(**{attr: val}), default_timezone=MockTZ("XXX") - ) - wrapped = wrap( - content=content, - api_version=api_version, - account_to_impersonate=account.identity, - timezone=account.default_timezone, - ) - self.assertEqual( - PrettyXmlHandler().prettify_xml(wrapped), - f""" - - - - - - {val} - - - - - - - - - - -""".encode(), - ) From 2d02ff1c94da9d623b00ff270e5179fd68dc5d2e Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sun, 13 Nov 2022 15:33:21 +0100 Subject: [PATCH 293/509] chore: Push paging parameters to the paging service --- exchangelib/services/common.py | 27 ++++++++++++------- exchangelib/services/find_folder.py | 1 - exchangelib/services/find_item.py | 1 - exchangelib/services/find_people.py | 1 - exchangelib/services/resolve_names.py | 6 ++--- exchangelib/services/sync_folder_hierarchy.py | 4 +-- exchangelib/services/sync_folder_items.py | 2 +- 7 files changed, 24 insertions(+), 18 deletions(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 3c87d2c2..cb153bf9 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -74,12 +74,10 @@ class EWSService(SupportedVersionClassMixIn, metaclass=abc.ABCMeta): """Base class for all EWS services.""" - PAGE_SIZE = 100 # A default page size for all paging services. This is the number of items we request per page CHUNK_SIZE = 100 # A default chunk size for all services. This is the number of items we send in a single request SERVICE_NAME = None # The name of the SOAP service element_container_name = None # The name of the XML element wrapping the collection of returned items - paging_container_name = None # The name of the element that contains paging information and the paged results returns_elements = True # If False, the service does not return response elements, just the ResponseCode status # Return exception instance instead of raising exceptions for the following errors when contained in an element ERRORS_TO_CATCH_IN_RESPONSE = ( @@ -103,8 +101,8 @@ class EWSService(SupportedVersionClassMixIn, metaclass=abc.ABCMeta): WARNINGS_TO_IGNORE_IN_RESPONSE = () # The exception type to raise when all attempted API versions failed NO_VALID_SERVER_VERSIONS = ErrorInvalidServerVersion - # Marks services that support paging of requested items - supports_paging = False + + NS_MAP = {k: v for k, v in ns_translation.items() if k in ("s", "m", "t")} def __init__(self, protocol, chunk_size=None, timeout=None): self.chunk_size = chunk_size or self.CHUNK_SIZE @@ -199,7 +197,7 @@ def wrap(self, content, api_version=None): :param content: :param api_version: """ - envelope = create_element("s:Envelope", nsmap=ns_translation) + envelope = create_element("s:Envelope", nsmap=self.NS_MAP) header = create_element("s:Header") if api_version: request_server_version = create_element("t:RequestServerVersion", attrs=dict(Version=api_version)) @@ -278,8 +276,6 @@ def _response_generator(self, payload): :return: the response, as XML objects """ response = self._get_response_xml(payload=payload) - if self.supports_paging: - return (self._get_page(message) for message in response) return self._get_elements_in_response(response=response) def _chunked_get_elements(self, payload_func, items, **kwargs): @@ -292,7 +288,7 @@ def _chunked_get_elements(self, payload_func, items, **kwargs): :return: Same as ._get_elements() """ # If the input for a service is a QuerySet, it can be difficult to remove exceptions before now - filtered_items = filter(lambda i: not isinstance(i, Exception), items) + filtered_items = filter(lambda item: not isinstance(item, Exception), items) for i, chunk in enumerate(chunkify(filtered_items, self.chunk_size), start=1): log.debug("Processing chunk %s containing %s items", i, len(chunk)) yield from self._get_elements(payload=payload_func(chunk, **kwargs)) @@ -598,7 +594,7 @@ def _get_element_container(self, message, name=None): if container is None: raise MalformedResponseError(f"No {name} elements in ResponseMessage ({xml_to_str(message)})") return container - # rspclass == 'Error', or 'Success' and not 'NoError' + # response_class == 'Error', or 'Success' and not 'NoError' try: raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml) except self.ERRORS_TO_CATCH_IN_RESPONSE as e: @@ -760,6 +756,10 @@ def _timezone(self): class EWSPagingService(EWSAccountService): + PAGE_SIZE = 100 # A default page size for all paging services. This is the number of items we request per page + + paging_container_name = None # The name of the element that contains paging information and the paged results + def __init__(self, *args, **kwargs): self.page_size = kwargs.pop("page_size", None) or self.PAGE_SIZE if not isinstance(self.page_size, int): @@ -768,6 +768,15 @@ def __init__(self, *args, **kwargs): raise ValueError(f"'page_size' {self.page_size} must be a positive number") super().__init__(*args, **kwargs) + def _response_generator(self, payload): + """Send the payload to the server, and return the response. + + :param payload: payload as an XML object + :return: the response, as XML objects + """ + response = self._get_response_xml(payload=payload) + return (self._get_page(message) for message in response) + def _paged_call(self, payload_func, max_items, folders, **kwargs): """Call a service that supports paging requests. Return a generator over all response items. Keeps track of all paging-related counters. diff --git a/exchangelib/services/find_folder.py b/exchangelib/services/find_folder.py index 5c8e46df..d1dfdd69 100644 --- a/exchangelib/services/find_folder.py +++ b/exchangelib/services/find_folder.py @@ -13,7 +13,6 @@ class FindFolder(EWSPagingService): SERVICE_NAME = "FindFolder" element_container_name = f"{{{TNS}}}Folders" paging_container_name = f"{{{MNS}}}RootFolder" - supports_paging = True def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/exchangelib/services/find_item.py b/exchangelib/services/find_item.py index 65bff3b4..3dca1ed6 100644 --- a/exchangelib/services/find_item.py +++ b/exchangelib/services/find_item.py @@ -11,7 +11,6 @@ class FindItem(EWSPagingService): SERVICE_NAME = "FindItem" element_container_name = f"{{{TNS}}}Items" paging_container_name = f"{{{MNS}}}RootFolder" - supports_paging = True def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/exchangelib/services/find_people.py b/exchangelib/services/find_people.py index 0c309837..3cf244fe 100644 --- a/exchangelib/services/find_people.py +++ b/exchangelib/services/find_people.py @@ -15,7 +15,6 @@ class FindPeople(EWSPagingService): SERVICE_NAME = "FindPeople" element_container_name = f"{{{MNS}}}People" supported_from = EXCHANGE_2013 - supports_paging = True def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/exchangelib/services/resolve_names.py b/exchangelib/services/resolve_names.py index 532092f6..838632a2 100644 --- a/exchangelib/services/resolve_names.py +++ b/exchangelib/services/resolve_names.py @@ -18,11 +18,11 @@ class ResolveNames(EWSService): element_container_name = f"{{{MNS}}}ResolutionSet" ERRORS_TO_CATCH_IN_RESPONSE = ErrorNameResolutionNoResults WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults - # Note: paging information is returned as attrs on the 'ResolutionSet' element, but this service does not - # support the 'IndexedPageItemView' element, so it's not really a paging service. - supports_paging = False + # According to the 'Remarks' section of the MSDN documentation referenced above, at most 100 candidates are # returned for a lookup. + # Note: paging information is returned as attrs on the 'ResolutionSet' element, but this service does not + # support the 'IndexedPageItemView' element, so it's not really a paging service. candidates_limit = 100 def __init__(self, *args, **kwargs): diff --git a/exchangelib/services/sync_folder_hierarchy.py b/exchangelib/services/sync_folder_hierarchy.py index 4547177b..81dcae68 100644 --- a/exchangelib/services/sync_folder_hierarchy.py +++ b/exchangelib/services/sync_folder_hierarchy.py @@ -3,12 +3,12 @@ from ..properties import FolderId from ..util import MNS, TNS, create_element, xml_text_to_value -from .common import EWSPagingService, add_xml_child, folder_ids_element, parse_folder_elem, shape_element +from .common import EWSAccountService, add_xml_child, folder_ids_element, parse_folder_elem, shape_element log = logging.getLogger(__name__) -class SyncFolder(EWSPagingService, metaclass=abc.ABCMeta): +class SyncFolder(EWSAccountService, metaclass=abc.ABCMeta): """Base class for SyncFolderHierarchy and SyncFolderItems.""" element_container_name = f"{{{MNS}}}Changes" diff --git a/exchangelib/services/sync_folder_items.py b/exchangelib/services/sync_folder_items.py index 2a51e8fa..101b1a58 100644 --- a/exchangelib/services/sync_folder_items.py +++ b/exchangelib/services/sync_folder_items.py @@ -27,7 +27,7 @@ class SyncFolderItems(SyncFolder): def call(self, folder, shape, additional_fields, sync_state, ignore, max_changes_returned, sync_scope): self.sync_state = sync_state if max_changes_returned is None: - max_changes_returned = self.page_size + max_changes_returned = 100 if not isinstance(max_changes_returned, int): raise InvalidTypeError("max_changes_returned", max_changes_returned, int) if max_changes_returned <= 0: From 845798ea11a27cd17de0997171b2485c38d916ad Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 14 Nov 2022 13:53:48 +0100 Subject: [PATCH 294/509] chore: Move API hints out of base class --- exchangelib/protocol.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 2a488622..71d692b5 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -77,7 +77,6 @@ class BaseProtocol: def __init__(self, config): self.config = config - self._api_version_hint = None self._session_pool_size = 0 self._session_pool_maxsize = config.max_connections or self.SESSION_POOLSIZE @@ -92,6 +91,10 @@ def __init__(self, config): def service_endpoint(self): return self.config.service_endpoint + @abc.abstractmethod + def get_auth_type(self): + """Autodetect authentication type""" + @property def auth_type(self): # Autodetect authentication type if necessary @@ -119,14 +122,6 @@ def retry_policy(self): def server(self): return self.config.server - def get_auth_type(self): - # Autodetect authentication type. We also set version hint here. - auth_type, api_version_hint = get_service_authtype( - service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS - ) - self._api_version_hint = api_version_hint - return auth_type - def __getstate__(self): # The session pool and lock cannot be pickled state = self.__dict__.copy() @@ -442,6 +437,15 @@ class Protocol(BaseProtocol, metaclass=CachingProtocol): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._version_lock = Lock() + self._api_version_hint = None + + def get_auth_type(self): + # Autodetect authentication type. We also set version hint here. + auth_type, api_version_hint = get_service_authtype( + service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS + ) + self._api_version_hint = api_version_hint + return auth_type @property def version(self): From 358081c4c0d70d29862a092542f1019cae3017f9 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 14 Nov 2022 13:59:21 +0100 Subject: [PATCH 295/509] chore: Remove unused allow_redirects option to post_ratelimited() --- exchangelib/util.py | 20 +++++--------------- tests/test_util.py | 9 --------- 2 files changed, 5 insertions(+), 24 deletions(-) diff --git a/exchangelib/util.py b/exchangelib/util.py index 2e1f512d..740603de 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -707,7 +707,6 @@ def get_redirect_url(response, allow_relative=True, require_relative=False): RETRY_WAIT = 10 # Seconds to wait before retry on connection errors -MAX_REDIRECTS = 10 # Maximum number of URL redirects before we give up # A collection of error classes we want to handle as general connection errors CONNECTION_ERRORS = ( @@ -727,7 +726,7 @@ def get_redirect_url(response, allow_relative=True, require_relative=False): TLS_ERRORS += (OpenSSL.SSL.Error,) -def post_ratelimited(protocol, session, url, headers, data, allow_redirects=False, stream=False, timeout=None): +def post_ratelimited(protocol, session, url, headers, data, stream=False, timeout=None): """There are two error-handling policies implemented here: a fail-fast policy intended for stand-alone scripts which fails on all responses except HTTP 200. The other policy is intended for long-running tasks that need to respect rate-limiting errors from the server and paper over outages of up to 1 hour. @@ -757,7 +756,6 @@ def post_ratelimited(protocol, session, url, headers, data, allow_redirects=Fals :param url: :param headers: :param data: - :param allow_redirects: (Default value = False) :param stream: (Default value = False) :param timeout: @@ -768,7 +766,6 @@ def post_ratelimited(protocol, session, url, headers, data, allow_redirects=Fals thread_id = get_ident() wait = RETRY_WAIT # Initial retry wait. We double the value on each retry retry = 0 - redirects = 0 log_msg = """\ Retry: %(retry)s Waited: %(wait)s @@ -778,7 +775,6 @@ def post_ratelimited(protocol, session, url, headers, data, allow_redirects=Fals Auth type: %(auth)s URL: %(url)s HTTP adapter: %(adapter)s -Allow redirects: %(allow_redirects)s Streaming: %(stream)s Response time: %(response_time)s Status code: %(status_code)s @@ -796,7 +792,6 @@ def post_ratelimited(protocol, session, url, headers, data, allow_redirects=Fals auth=session.auth, url=url, adapter=session.get_adapter(url), - allow_redirects=allow_redirects, stream=stream, response_time=None, status_code=None, @@ -885,7 +880,7 @@ def post_ratelimited(protocol, session, url, headers, data, allow_redirects=Fals continue if r.status_code in (301, 302): r.close() # Release memory - url, redirects = _redirect_or_fail(r, redirects, allow_redirects) + url = _fail_on_redirect(r) continue break except (RateLimitError, RedirectError) as e: @@ -926,7 +921,7 @@ def _need_new_credentials(response): return response.status_code == 401 and response.headers.get("TokenExpiredError") -def _redirect_or_fail(response, redirects, allow_redirects): +def _fail_on_redirect(response): # Retry with no delay. If we let requests handle redirects automatically, it would issue a GET to that # URL. We still want to POST. try: @@ -934,13 +929,8 @@ def _redirect_or_fail(response, redirects, allow_redirects): except RelativeRedirect as e: log.debug("'allow_redirects' only supports relative redirects (%s -> %s)", response.url, e.value) raise RedirectError(url=e.value) - if not allow_redirects: - raise TransportError(f"Redirect not allowed but we were redirected ({response.url} -> {redirect_url})") - log.debug("HTTP redirected to %s", redirect_url) - redirects += 1 - if redirects > MAX_REDIRECTS: - raise TransportError("Max redirect count exceeded") - return redirect_url, redirects + log.debug("Redirect not allowed but we were redirected ( (%s -> %s)", response.url, redirect_url) + raise RedirectError(url=redirect_url) def _retry_after(r, wait): diff --git a/tests/test_util.py b/tests/test_util.py index cfddd514..fe3f6607 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -230,7 +230,6 @@ def test_post_ratelimited(self): protocol = self.account.protocol orig_policy = protocol.config.retry_policy RETRY_WAIT = exchangelib.util.RETRY_WAIT - MAX_REDIRECTS = exchangelib.util.MAX_REDIRECTS session = protocol.get_session() try: @@ -282,13 +281,6 @@ def test_post_ratelimited(self): session.post = mock_post(url, 302, {"location": "https://contoso.com"}) with self.assertRaises(TransportError): r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data="") - # Redirect header to other location and allow_redirects=True - exchangelib.util.MAX_REDIRECTS = 0 - session.post = mock_post(url, 302, {"location": "https://contoso.com"}) - with self.assertRaises(TransportError): - r, session = post_ratelimited( - protocol=protocol, session=session, url=url, headers=None, data="", allow_redirects=True - ) # CAS error session.post = mock_post(url, 999, {"X-CasErrorCode": "AAARGH!"}) @@ -339,7 +331,6 @@ def test_post_ratelimited(self): # Restore patched attributes and functions protocol.config.retry_policy = orig_policy exchangelib.util.RETRY_WAIT = RETRY_WAIT - exchangelib.util.MAX_REDIRECTS = MAX_REDIRECTS with suppress(AttributeError): delattr(protocol, "renew_session") From 8eb07603a23436cadcfbbc97479e25e15a8e9248 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 17 Nov 2022 23:33:40 +0100 Subject: [PATCH 296/509] fix: shelve does not handle pathlib objects --- exchangelib/autodiscover/cache.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/exchangelib/autodiscover/cache.py b/exchangelib/autodiscover/cache.py index 946fdd7d..1ada1281 100644 --- a/exchangelib/autodiscover/cache.py +++ b/exchangelib/autodiscover/cache.py @@ -37,7 +37,7 @@ def shelve_open_with_failover(file): # 'shelve' may add a backend-specific suffix to the file, so also delete all files with a suffix. # We don't know which file caused the error, so just delete them all. try: - shelve_handle = shelve.open(file) + shelve_handle = shelve.open(str(file)) # Try to actually use the file. Some implementations may allow opening the file but then throw # errors on access. with suppress(KeyError): @@ -46,7 +46,7 @@ def shelve_open_with_failover(file): for f in file.parent.glob(f"{file.name}*"): log.warning("Deleting invalid cache file %s (%r)", f, e) f.unlink() - shelve_handle = shelve.open(file) + shelve_handle = shelve.open(str(file)) yield shelve_handle From c427d9c49f85420be1fcc90bf54f329a5f8033e3 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Fri, 18 Nov 2022 00:14:51 +0100 Subject: [PATCH 297/509] chore: clean up Version implementation. Only implement version guessing on versions that the used service supports. --- exchangelib/protocol.py | 4 +- exchangelib/services/common.py | 16 +++-- exchangelib/transport.py | 7 +- exchangelib/version.py | 113 ++++++++++++++------------------- 4 files changed, 66 insertions(+), 74 deletions(-) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 71d692b5..2dad3ad2 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -41,7 +41,7 @@ ResolveNames, ) from .transport import CREDENTIALS_REQUIRED, DEFAULT_HEADERS, NTLM, OAUTH2, get_auth_instance, get_service_authtype -from .version import API_VERSIONS, Version +from .version import Version log = logging.getLogger(__name__) @@ -442,7 +442,7 @@ def __init__(self, *args, **kwargs): def get_auth_type(self): # Autodetect authentication type. We also set version hint here. auth_type, api_version_hint = get_service_authtype( - service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS + service_endpoint=self.service_endpoint, retry_policy=self.retry_policy ) self._api_version_hint = api_version_hint return auth_type diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index cb153bf9..0a67886b 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -66,7 +66,7 @@ to_xml, xml_to_str, ) -from ..version import API_VERSIONS, SupportedVersionClassMixIn, Version +from ..version import SupportedVersionClassMixIn, Version log = logging.getLogger(__name__) @@ -364,10 +364,16 @@ def _get_response(self, payload, api_version): self.protocol.release_session(session) return r - @property + @classmethod + def supported_api_versions(cls): + """Return API versions supported by the service, sorted from newest to oldest""" + return sorted({v.api_version for v in Version.all_versions() if cls.supports_version(v)}, reverse=True) + def _api_versions_to_try(self): # Put the hint first in the list, and then all other versions except the hint, from newest to oldest - return (self._version_hint.api_version,) + tuple(v for v in API_VERSIONS if v != self._version_hint.api_version) + return (self._version_hint.api_version,) + tuple( + v for v in self.supported_api_versions() if v != self._version_hint.api_version + ) def _get_response_xml(self, payload, **parse_opts): """Send the payload to the server and return relevant elements from the result. Several things happen here: @@ -384,7 +390,7 @@ def _get_response_xml(self, payload, **parse_opts): # guessing tango, but then the server may decide that any arbitrary legacy backend server may actually process # the request for an account. Prepare to handle version-related errors and set the server version per-account. log.debug("Calling service %s", self.SERVICE_NAME) - for api_version in self._api_versions_to_try: + for api_version in self._api_versions_to_try(): log.debug("Trying API version %s", api_version) r = self._get_response(payload=payload, api_version=api_version) if self.streaming: @@ -423,7 +429,7 @@ def _get_response_xml(self, payload, **parse_opts): # In streaming mode, we may not have accessed the raw stream yet. Caller must handle this. r.close() # Release memory - raise self.NO_VALID_SERVER_VERSIONS(f"Tried versions {self._api_versions_to_try} but all were invalid") + raise self.NO_VALID_SERVER_VERSIONS(f"Tried versions {self._api_versions_to_try()} but all were invalid") def _handle_backoff(self, e): """Take a request from the server to back off and checks the retry policy for what to do. Re-raise the diff --git a/exchangelib/transport.py b/exchangelib/transport.py index 56284397..6593ffb9 100644 --- a/exchangelib/transport.py +++ b/exchangelib/transport.py @@ -65,18 +65,19 @@ def get_auth_instance(auth_type, **kwargs): return model(**kwargs) -def get_service_authtype(service_endpoint, retry_policy, api_versions): +def get_service_authtype(service_endpoint, retry_policy): # Get auth type by tasting headers from the server. Only do POST requests. HEAD is too error-prone, and some servers # are set up to redirect to OWA on all requests except POST to /EWS/Exchange.asmx # # We don't know the API version yet, but we need it to create a valid request because some Exchange servers only # respond when given a valid request. Try all known versions. Gross. from .protocol import BaseProtocol + from .services import ConvertId retry = 0 t_start = time.monotonic() headers = DEFAULT_HEADERS.copy() - for api_version in api_versions: + for api_version in ConvertId.supported_api_versions(): data = dummy_xml(api_version=api_version) log.debug("Requesting %s from %s", data, service_endpoint) while True: @@ -175,7 +176,7 @@ def _tokenize(val): def dummy_xml(api_version): # Generate a minimal, valid EWS request from .properties import ENTRY_ID, EWS_ID, AlternateId - from .services import ConvertId # Avoid circular import + from .services import ConvertId svc = ConvertId(protocol=None) return svc.wrap( diff --git a/exchangelib/version.py b/exchangelib/version.py index 6f25a4c6..13d864eb 100644 --- a/exchangelib/version.py +++ b/exchangelib/version.py @@ -6,62 +6,10 @@ log = logging.getLogger(__name__) -# Legend for dict: -# Key: shortname -# Values: (EWS API version ID, full name) - -# 'shortname' comes from types.xsd and is the official version of the server, corresponding to the version numbers -# supplied in SOAP headers. 'API version' is the version name supplied in the RequestServerVersion element in SOAP -# headers and describes the EWS API version the server implements. Valid values for this element are described here: -# https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/requestserverversion - -VERSIONS = { - "Exchange2007": ("Exchange2007", "Microsoft Exchange Server 2007"), - "Exchange2007_SP1": ("Exchange2007_SP1", "Microsoft Exchange Server 2007 SP1"), - "Exchange2007_SP2": ("Exchange2007_SP1", "Microsoft Exchange Server 2007 SP2"), - "Exchange2007_SP3": ("Exchange2007_SP1", "Microsoft Exchange Server 2007 SP3"), - "Exchange2010": ("Exchange2010", "Microsoft Exchange Server 2010"), - "Exchange2010_SP1": ("Exchange2010_SP1", "Microsoft Exchange Server 2010 SP1"), - "Exchange2010_SP2": ("Exchange2010_SP2", "Microsoft Exchange Server 2010 SP2"), - "Exchange2010_SP3": ("Exchange2010_SP2", "Microsoft Exchange Server 2010 SP3"), - "Exchange2013": ("Exchange2013", "Microsoft Exchange Server 2013"), - "Exchange2013_SP1": ("Exchange2013_SP1", "Microsoft Exchange Server 2013 SP1"), - "Exchange2015": ("Exchange2015", "Microsoft Exchange Server 2015"), - "Exchange2015_SP1": ("Exchange2015_SP1", "Microsoft Exchange Server 2015 SP1"), - "Exchange2016": ("Exchange2016", "Microsoft Exchange Server 2016"), - "Exchange2019": ("Exchange2019", "Microsoft Exchange Server 2019"), -} - -# Build a list of unique API versions, used when guessing API version supported by the server. Use reverse order, so we -# get the newest API version supported by the server. -API_VERSIONS = sorted({v[0] for v in VERSIONS.values()}, reverse=True) - class Build: """Holds methods for working with build numbers.""" - # List of build numbers here: https://docs.microsoft.com/en-us/exchange/new-features/build-numbers-and-release-dates - API_VERSION_MAP = { - 8: { - 0: "Exchange2007", - 1: "Exchange2007_SP1", - 2: "Exchange2007_SP1", - 3: "Exchange2007_SP1", - }, - 14: { - 0: "Exchange2010", - 1: "Exchange2010_SP1", - 2: "Exchange2010_SP2", - 3: "Exchange2010_SP2", - }, - 15: { - 0: "Exchange2013", # Minor builds starting from 847 are Exchange2013_SP1, see api_version() - 1: "Exchange2016", - 2: "Exchange2019", - 20: "Exchange2016", # This is Office365. See issue #221 - }, - } - __slots__ = "major_version", "minor_version", "major_build", "minor_build" def __init__(self, major_version, minor_version, major_build=0, minor_build=0): @@ -117,15 +65,12 @@ def from_hex_string(cls, s): return cls(major_version=major_version, minor_version=minor_version, major_build=build_number) def api_version(self): - if EXCHANGE_2013_SP1 <= self < EXCHANGE_2016: - return "Exchange2013_SP1" - try: - return self.API_VERSION_MAP[self.major_version][self.minor_version] - except KeyError: - raise ValueError(f"API version for build {self} is unknown") - - def fullname(self): - return VERSIONS[self.api_version()][1] + for build, api_version, _ in VERSIONS: + if self.major_version != build.major_version or self.minor_version != build.minor_version: + continue + if self >= build: + return api_version + raise ValueError(f"API version for build {self} is unknown") def __cmp__(self, other): # __cmp__ is not a magic method in Python3. We'll just use it here to implement comparison operators @@ -173,15 +118,45 @@ def __repr__(self): # Helpers for comparison operations elsewhere in this package EXCHANGE_2007 = Build(8, 0) EXCHANGE_2007_SP1 = Build(8, 1) +EXCHANGE_2007_SP2 = Build(8, 2) +EXCHANGE_2007_SP3 = Build(8, 3) EXCHANGE_2010 = Build(14, 0) EXCHANGE_2010_SP1 = Build(14, 1) EXCHANGE_2010_SP2 = Build(14, 2) +EXCHANGE_2010_SP3 = Build(14, 3) EXCHANGE_2013 = Build(15, 0) -EXCHANGE_2013_SP1 = Build(15, 0, 847) +EXCHANGE_2013_SP1 = Build(15, 0, 847) # Major builds starting from 847 are Exchange2013_SP1 EXCHANGE_2016 = Build(15, 1) EXCHANGE_2019 = Build(15, 2) EXCHANGE_O365 = Build(15, 20) +# Legend for VERSIONS: +# (build, API version, full name) +# +# 'API version' is the version name supplied in the RequestServerVersion element in SOAP headers and describes the EWS +# API version the server implements. Valid values for this element are described here: +# https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/requestserverversion +# +# A list of build numbers and full version names is available here: +# https://docs.microsoft.com/en-us/exchange/new-features/build-numbers-and-release-dates +# +# The list is sorted from newest to oldest build +VERSIONS = ( + (EXCHANGE_O365, "Exchange2016", "Microsoft Exchange Server Office365"), # Not mentioned in list of build numbers + (EXCHANGE_2019, "Exchange2019", "Microsoft Exchange Server 2019"), + (EXCHANGE_2016, "Exchange2016", "Microsoft Exchange Server 2016"), + (EXCHANGE_2013_SP1, "Exchange2013_SP1", "Microsoft Exchange Server 2013 SP1"), + (EXCHANGE_2013, "Exchange2013", "Microsoft Exchange Server 2013"), + (EXCHANGE_2010_SP3, "Exchange2010_SP2", "Microsoft Exchange Server 2010 SP3"), + (EXCHANGE_2010_SP2, "Exchange2010_SP2", "Microsoft Exchange Server 2010 SP2"), + (EXCHANGE_2010_SP1, "Exchange2010_SP1", "Microsoft Exchange Server 2010 SP1"), + (EXCHANGE_2010, "Exchange2010", "Microsoft Exchange Server 2010"), + (EXCHANGE_2007_SP3, "Exchange2007_SP1", "Microsoft Exchange Server 2007 SP3"), + (EXCHANGE_2007_SP2, "Exchange2007_SP1", "Microsoft Exchange Server 2007 SP2"), + (EXCHANGE_2007_SP1, "Exchange2007_SP1", "Microsoft Exchange Server 2007 SP1"), + (EXCHANGE_2007, "Exchange2007", "Microsoft Exchange Server 2007"), +) + class Version: """Holds information about the server version.""" @@ -203,7 +178,12 @@ def __init__(self, build, api_version=None): @property def fullname(self): - return VERSIONS[self.api_version][1] + for build, _, full_name in VERSIONS: + if self.build.major_version != build.major_version or self.build.minor_version != build.minor_version: + continue + if self.build >= build: + return full_name + raise ValueError(f"Full name for version {self} is unknown") @classmethod def guess(cls, protocol, api_version_hint=None): @@ -221,7 +201,7 @@ def guess(cls, protocol, api_version_hint=None): from .services import ConvertId # The protocol doesn't have a version yet, so default to the latest supported version if we don't have a hint. - api_version = api_version_hint or API_VERSIONS[0] + api_version = api_version_hint or ConvertId.supported_api_versions()[0] log.debug("Asking server for version info using API version %s", api_version) # We don't know the build version yet. Hopefully, the server will report it in the SOAP header. Lots of # places expect a version to have a build, so this is a bit dangerous, but passing a fake build around is also @@ -279,6 +259,11 @@ def from_soap_header(cls, requested_api_version, header): def copy(self): return self.__class__(build=self.build, api_version=self.api_version) + @classmethod + def all_versions(cls): + # Return all supported versions, sorted newest to oldest + return [cls(build=build, api_version=api_version) for build, api_version, _ in VERSIONS] + def __eq__(self, other): if self.api_version != other.api_version: return False From df5865501db07ce480324a2c8ac3fc73c11ac571 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Fri, 18 Nov 2022 00:16:18 +0100 Subject: [PATCH 298/509] chore: remove unused expected_prefix arg. Support SOAP exceptions with no prefix --- exchangelib/util.py | 15 +++++++++------ tests/test_util.py | 2 ++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/exchangelib/util.py b/exchangelib/util.py index 740603de..539b6c96 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -544,21 +544,24 @@ def to_xml(bytes_content): return res -def is_xml(text, expected_prefix=b"'), True) self.assertEqual(is_xml(BOM_UTF8 + b''), True) + self.assertEqual(is_xml(b""), True) + self.assertEqual(is_xml(BOM_UTF8 + b""), True) self.assertEqual(is_xml(b"XXX"), False) def test_xml_to_str(self): From f4e20f032f1146c653a7805869f627df45805e76 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 21 Nov 2022 23:39:34 +0100 Subject: [PATCH 299/509] chore: minor fixes --- .../autodiscover/{discovery.py => discovery_pox.py} | 0 exchangelib/autodiscover/properties.py | 2 +- exchangelib/credentials.py | 10 +++++----- exchangelib/protocol.py | 2 +- exchangelib/services/common.py | 4 ++-- exchangelib/version.py | 9 +++++---- 6 files changed, 14 insertions(+), 13 deletions(-) rename exchangelib/autodiscover/{discovery.py => discovery_pox.py} (100%) diff --git a/exchangelib/autodiscover/discovery.py b/exchangelib/autodiscover/discovery_pox.py similarity index 100% rename from exchangelib/autodiscover/discovery.py rename to exchangelib/autodiscover/discovery_pox.py diff --git a/exchangelib/autodiscover/properties.py b/exchangelib/autodiscover/properties.py index e2b92659..c3c88ef7 100644 --- a/exchangelib/autodiscover/properties.py +++ b/exchangelib/autodiscover/properties.py @@ -81,7 +81,7 @@ class SimpleProtocol(AutodiscoverBase): class IntExtBase(AutodiscoverBase): # TODO: 'OWAUrl' also has an AuthenticationMethod enum-style XML attribute with values: - # WindowsIntegrated, FBA, NTLM, Digest, Basic + # WindowsIntegrated, FBA, NTLM, Digest, Basic, LiveIdFba, OAuth owa_url = TextField(field_uri="OWAUrl", namespace=RNS) protocol = EWSElementField(value_cls=SimpleProtocol) diff --git a/exchangelib/credentials.py b/exchangelib/credentials.py index ffe69044..3325e1bd 100644 --- a/exchangelib/credentials.py +++ b/exchangelib/credentials.py @@ -8,8 +8,8 @@ import logging from threading import RLock +import oauthlib.oauth2 from cached_property import threaded_cached_property -from oauthlib.oauth2 import BackendApplicationClient, LegacyApplicationClient, OAuth2Token, WebApplicationClient from .errors import InvalidTypeError @@ -94,7 +94,7 @@ def access_token(self): @access_token.setter def access_token(self, access_token): if access_token is not None and not isinstance(access_token, dict): - raise InvalidTypeError("access_token", access_token, OAuth2Token) + raise InvalidTypeError("access_token", access_token, oauthlib.oauth2.OAuth2Token) self._access_token = access_token def refresh(self, session): @@ -201,7 +201,7 @@ def scope(self): @threaded_cached_property def client(self): - return BackendApplicationClient(client_id=self.client_id) + return oauthlib.oauth2.BackendApplicationClient(client_id=self.client_id) class OAuth2LegacyCredentials(OAuth2Credentials): @@ -232,7 +232,7 @@ def token_params(self): @threaded_cached_property def client(self): - return LegacyApplicationClient(client_id=self.client_id) + return oauthlib.oauth2.LegacyApplicationClient(client_id=self.client_id) @property def scope(self): @@ -305,7 +305,7 @@ def token_params(self): @threaded_cached_property def client(self): - return WebApplicationClient(client_id=self.client_id) + return oauthlib.oauth2.WebApplicationClient(client_id=self.client_id) def __repr__(self): return self.__class__.__name__ + repr( diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 2dad3ad2..86ccd023 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -686,7 +686,7 @@ def back_off(self, seconds): raise ValueError("Cannot back off with fail-fast policy") def may_retry_on_error(self, response, wait): - log.debug("No retry: no fail-fast policy") + log.debug("No retry with fail-fast policy") return False diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 0a67886b..d74cde92 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -5,7 +5,7 @@ from .. import errors from ..attachments import AttachmentId -from ..credentials import IMPERSONATION, OAuth2Credentials +from ..credentials import IMPERSONATION, BaseOAuth2Credentials from ..errors import ( ErrorBatchProcessingStopped, ErrorCannotDeleteObject, @@ -261,7 +261,7 @@ def _extra_headers(self, session): @property def _account_to_impersonate(self): - if self.protocol and isinstance(self.protocol.credentials, OAuth2Credentials): + if self.protocol and isinstance(self.protocol.credentials, BaseOAuth2Credentials): return self.protocol.credentials.identity return None diff --git a/exchangelib/version.py b/exchangelib/version.py index 13d864eb..f5f2d57a 100644 --- a/exchangelib/version.py +++ b/exchangelib/version.py @@ -178,10 +178,11 @@ def __init__(self, build, api_version=None): @property def fullname(self): - for build, _, full_name in VERSIONS: - if self.build.major_version != build.major_version or self.build.minor_version != build.minor_version: - continue - if self.build >= build: + for build, api_version, full_name in VERSIONS: + if self.build: + if self.build.major_version != build.major_version or self.build.minor_version != build.minor_version: + continue + if self.api_version == api_version: return full_name raise ValueError(f"Full name for version {self} is unknown") From 24148ae0648023d5563c3e852f652887b7f00705 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 21 Nov 2022 23:41:29 +0100 Subject: [PATCH 300/509] chore: Move dummy_xml() to Protocol --- exchangelib/account.py | 2 +- exchangelib/autodiscover/__init__.py | 2 +- exchangelib/protocol.py | 26 +++++++++++++++++-------- exchangelib/transport.py | 29 ++++++++-------------------- 4 files changed, 28 insertions(+), 31 deletions(-) diff --git a/exchangelib/account.py b/exchangelib/account.py index 53bea5f5..d5c86801 100644 --- a/exchangelib/account.py +++ b/exchangelib/account.py @@ -3,7 +3,7 @@ from cached_property import threaded_cached_property -from .autodiscover import Autodiscovery +from .autodiscover.discovery_pox import Autodiscovery from .configuration import Configuration from .credentials import ACCESS_TYPES, DELEGATE, IMPERSONATION from .errors import InvalidEnumValue, InvalidTypeError, UnknownTimeZone diff --git a/exchangelib/autodiscover/__init__.py b/exchangelib/autodiscover/__init__.py index acc00a8e..5b7ab26f 100644 --- a/exchangelib/autodiscover/__init__.py +++ b/exchangelib/autodiscover/__init__.py @@ -1,5 +1,5 @@ from .cache import AutodiscoverCache, autodiscover_cache -from .discovery import Autodiscovery, discover +from .discovery_pox import Autodiscovery, discover from .protocol import AutodiscoverProtocol diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 86ccd023..16f806ac 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -437,15 +437,11 @@ class Protocol(BaseProtocol, metaclass=CachingProtocol): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._version_lock = Lock() - self._api_version_hint = None + self.api_version_hint = None def get_auth_type(self): - # Autodetect authentication type. We also set version hint here. - auth_type, api_version_hint = get_service_authtype( - service_endpoint=self.service_endpoint, retry_policy=self.retry_policy - ) - self._api_version_hint = api_version_hint - return auth_type + # Autodetect authentication type. We also set 'self.api_version_hint' here. + return get_service_authtype(protocol=self) @property def version(self): @@ -455,7 +451,7 @@ def version(self): with self._version_lock: if not self.config.version or not self.config.version.build: # Version.guess() needs auth objects and a working session pool - self.config.version = Version.guess(self, api_version_hint=self._api_version_hint) + self.config.version = Version.guess(self, api_version_hint=self.api_version_hint) return self.config.version def get_timezones(self, timezones=None, return_full_timezone_data=False): @@ -571,6 +567,20 @@ def convert_ids(self, ids, destination_format): """ return ConvertId(protocol=self).call(items=ids, destination_format=destination_format) + def dummy_xml(self): + # Generate a minimal, valid EWS request + from .properties import ENTRY_ID, EWS_ID, AlternateId + from .services import ConvertId + + svc = ConvertId(protocol=None) + return svc.wrap( + content=svc.get_payload( + items=[AlternateId(id="DUMMY", format=EWS_ID, mailbox="DUMMY")], + destination_format=ENTRY_ID, + ), + api_version=self.api_version_hint, + ) + def __getstate__(self): # The lock cannot be pickled state = super().__getstate__() diff --git a/exchangelib/transport.py b/exchangelib/transport.py index 6593ffb9..26e0cbe1 100644 --- a/exchangelib/transport.py +++ b/exchangelib/transport.py @@ -65,32 +65,34 @@ def get_auth_instance(auth_type, **kwargs): return model(**kwargs) -def get_service_authtype(service_endpoint, retry_policy): +def get_service_authtype(protocol): # Get auth type by tasting headers from the server. Only do POST requests. HEAD is too error-prone, and some servers # are set up to redirect to OWA on all requests except POST to /EWS/Exchange.asmx # # We don't know the API version yet, but we need it to create a valid request because some Exchange servers only # respond when given a valid request. Try all known versions. Gross. - from .protocol import BaseProtocol from .services import ConvertId + service_endpoint = protocol.service_endpoint + retry_policy = protocol.retry_policy retry = 0 t_start = time.monotonic() headers = DEFAULT_HEADERS.copy() for api_version in ConvertId.supported_api_versions(): - data = dummy_xml(api_version=api_version) + protocol.api_version_hint = api_version + data = protocol.dummy_xml() log.debug("Requesting %s from %s", data, service_endpoint) while True: _back_off_if_needed(retry_policy.back_off_until) log.debug("Trying to get service auth type for %s", service_endpoint) - with BaseProtocol.raw_session(service_endpoint) as s: + with protocol.raw_session(service_endpoint) as s: try: r = s.post( url=service_endpoint, headers=headers, data=data, allow_redirects=False, - timeout=BaseProtocol.TIMEOUT, + timeout=protocol.TIMEOUT, ) r.close() # Release memory break @@ -117,7 +119,7 @@ def get_service_authtype(service_endpoint, retry_policy): try: auth_type = get_auth_method_from_response(response=r) log.debug("Auth type is %s", auth_type) - return auth_type, api_version + return auth_type except UnauthorizedError: continue raise TransportError("Failed to get auth type from service") @@ -171,18 +173,3 @@ def _tokenize(val): if auth_method: auth_methods.append(auth_method) return auth_methods - - -def dummy_xml(api_version): - # Generate a minimal, valid EWS request - from .properties import ENTRY_ID, EWS_ID, AlternateId - from .services import ConvertId - - svc = ConvertId(protocol=None) - return svc.wrap( - content=svc.get_payload( - items=[AlternateId(id="DUMMY", format=EWS_ID, mailbox="DUMMY")], - destination_format=ENTRY_ID, - ), - api_version=api_version, - ) From ed7d3a2db915a8c190239487509f0d26db2605cb Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 21 Nov 2022 23:44:27 +0100 Subject: [PATCH 301/509] ci: Fix and re-enable autodiscover tests. They now work with OAuth --- tests/test_account.py | 25 +------ tests/test_autodiscover.py | 143 ++++++++++++++++++++++++------------- tests/test_protocol.py | 9 ++- 3 files changed, 102 insertions(+), 75 deletions(-) diff --git a/tests/test_account.py b/tests/test_account.py index 8f42f29d..70d14fd3 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -5,7 +5,7 @@ from exchangelib.account import Account from exchangelib.attachments import FileAttachment from exchangelib.configuration import Configuration -from exchangelib.credentials import DELEGATE, Credentials, OAuth2Credentials +from exchangelib.credentials import DELEGATE, Credentials from exchangelib.errors import ( ErrorAccessDenied, ErrorDelegateNoUser, @@ -326,7 +326,8 @@ def raise_response_errors(self, response): account.root.refresh() def test_protocol_default_values(self): - # Test that retry_policy and auth_type always get a value regardless of how we create an Account + # Test that retry_policy and auth_type always get a value regardless of how we create an Account. autodiscover + # args are tested in AutodiscoverTest. a = Account( self.account.primary_smtp_address, autodiscover=False, @@ -337,23 +338,3 @@ def test_protocol_default_values(self): ) self.assertIsNotNone(a.protocol.auth_type) self.assertIsNotNone(a.protocol.retry_policy) - - if isinstance(self.account.protocol.credentials, OAuth2Credentials): - self.skipTest("OAuth authentication does not work with POX autodiscover") - - pox_credentials = Credentials(username=self.settings["username"], password=self.settings["password"]) - - a = Account( - self.settings["alias"], - autodiscover=True, - config=Configuration( - server=self.settings["server"], - credentials=pox_credentials, - ), - ) - self.assertIsNotNone(a.protocol.auth_type) - self.assertIsNotNone(a.protocol.retry_policy) - - a = Account(self.settings["alias"], autodiscover=True, credentials=pox_credentials) - self.assertIsNotNone(a.protocol.auth_type) - self.assertIsNotNone(a.protocol.retry_policy) diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 62c67e22..f89d977f 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -8,34 +8,27 @@ import requests_mock from exchangelib.account import Account -from exchangelib.autodiscover import ( - AutodiscoverCache, - AutodiscoverProtocol, - Autodiscovery, - autodiscover_cache, - clear_cache, - close_connections, - discover, -) -from exchangelib.autodiscover.cache import shelve_filename -from exchangelib.autodiscover.discovery import SrvRecord, _select_srv_host +from exchangelib.autodiscover import clear_cache, close_connections +from exchangelib.autodiscover.cache import AutodiscoverCache, autodiscover_cache, shelve_filename +from exchangelib.autodiscover.discovery_pox import Autodiscovery, SrvRecord, _select_srv_host, discover from exchangelib.autodiscover.properties import Account as ADAccount from exchangelib.autodiscover.properties import Autodiscover, Error, ErrorResponse, Response +from exchangelib.autodiscover.protocol import AutodiscoverProtocol from exchangelib.configuration import Configuration -from exchangelib.credentials import DELEGATE, Credentials, OAuth2Credentials +from exchangelib.credentials import DELEGATE, Credentials, OAuth2LegacyCredentials from exchangelib.errors import AutoDiscoverCircularRedirect, AutoDiscoverFailed, ErrorNonExistentMailbox from exchangelib.protocol import FailFast, FaultTolerance -from exchangelib.transport import NOAUTH, NTLM +from exchangelib.transport import NTLM from exchangelib.util import ParseError, get_domain from exchangelib.version import EXCHANGE_2013, Version from .common import EWSTest, get_random_hostname, get_random_string -class AutodiscoverTest(EWSTest): +class AutodiscoverPoxTest(EWSTest): def setUp(self): - if isinstance(self.account.protocol.credentials, OAuth2Credentials): - self.skipTest("OAuth authentication does not work with POX autodiscover") + if not self.account: + self.skipTest("POX autodiscover requires delegate credentials") super().setUp() @@ -52,7 +45,29 @@ def setUp(self): self.dummy_ews_endpoint = "https://expr.example.com/EWS/Exchange.asmx" self.dummy_ad_response = self.settings_xml(self.account.primary_smtp_address, self.dummy_ews_endpoint) - self.pox_credentials = Credentials(username=self.settings["username"], password=self.settings["password"]) + @classmethod + def get_account(cls): + if cls.settings.get("client_id") and cls.settings["username"]: + credentials = OAuth2LegacyCredentials( + client_id=cls.settings["client_id"], + client_secret=cls.settings["client_secret"], + tenant_id=cls.settings["tenant_id"], + username=cls.settings["username"], + password=cls.settings["password"], + ) + config = Configuration( + server=cls.settings["server"], + credentials=credentials, + retry_policy=cls.retry_policy, + ) + return Account( + primary_smtp_address=cls.settings["account"], + access_type=DELEGATE, + config=config, + locale="da_DK", + default_timezone=cls.tz, + ) + return None @staticmethod def settings_xml(address, ews_url): @@ -136,15 +151,11 @@ def test_autodiscover_empty_cache(self): # A live test of the entire process with an empty cache ad_response, protocol = discover( email=self.account.primary_smtp_address, - credentials=self.pox_credentials, + credentials=self.account.protocol.credentials, retry_policy=self.retry_policy, ) self.assertEqual(ad_response.autodiscover_smtp_address, self.account.primary_smtp_address) - self.assertEqual(ad_response.protocol.auth_type, self.account.protocol.auth_type) - ad_response.protocol.auth_required = False - self.assertEqual(ad_response.protocol.auth_type, NOAUTH) self.assertEqual(protocol.service_endpoint.lower(), self.account.protocol.service_endpoint.lower()) - self.assertEqual(protocol.version.build, self.account.protocol.version.build) def test_autodiscover_failure(self): # A live test that errors can be raised. Here, we try to autodiscover a non-existing email address @@ -152,16 +163,16 @@ def test_autodiscover_failure(self): self.skipTest(f"Skipping {self.__class__.__name__} - no 'autodiscover_server' entry in settings.yml") # Autodiscovery may take a long time. Prime the cache with the autodiscover server from the config file ad_endpoint = f"https://{self.settings['autodiscover_server']}/Autodiscover/Autodiscover.xml" - cache_key = (self.domain, self.pox_credentials) + cache_key = (self.domain, self.account.protocol.credentials) autodiscover_cache[cache_key] = self.get_test_protocol( service_endpoint=ad_endpoint, - credentials=self.pox_credentials, + credentials=self.account.protocol.credentials, retry_policy=self.retry_policy, ) with self.assertRaises(ErrorNonExistentMailbox): discover( email="XXX." + self.account.primary_smtp_address, - credentials=self.pox_credentials, + credentials=self.account.protocol.credentials, retry_policy=self.retry_policy, ) @@ -192,12 +203,13 @@ def test_autodiscover_direct_gc(self, m): autodiscover_cache.__del__() # Don't use del() because that would remove the global object @requests_mock.mock(real_http=False) - def test_autodiscover_cache(self, m): + @patch.object(Autodiscovery, "_is_valid_hostname", return_value=True) + def test_autodiscover_cache(self, m, _): # Mock the default endpoint that we test in step 1 of autodiscovery m.post(self.dummy_ad_endpoint, status_code=200, content=self.dummy_ad_response) discovery = Autodiscovery( email=self.account.primary_smtp_address, - credentials=self.pox_credentials, + credentials=self.account.protocol.credentials, ) # Not cached self.assertNotIn(discovery._cache_key, autodiscover_cache) @@ -208,7 +220,7 @@ def test_autodiscover_cache(self, m): self.assertIn( ( self.account.primary_smtp_address.split("@")[1], - self.pox_credentials, + self.account.protocol.credentials, True, ), autodiscover_cache, @@ -261,7 +273,8 @@ def test_corrupt_autodiscover_cache(self, m): self.assertFalse(key in autodiscover_cache) @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here - def test_autodiscover_from_account(self, m): + @patch.object(Autodiscovery, "_is_valid_hostname", return_value=True) + def test_autodiscover_from_account(self, m, _): # Test that autodiscovery via account creation works # Mock the default endpoint that we test in step 1 of autodiscovery m.post(self.dummy_ad_endpoint, status_code=200, content=self.dummy_ad_response) @@ -269,7 +282,7 @@ def test_autodiscover_from_account(self, m): account = Account( primary_smtp_address=self.account.primary_smtp_address, config=Configuration( - credentials=self.pox_credentials, + credentials=self.account.protocol.credentials, retry_policy=self.retry_policy, version=Version(build=EXCHANGE_2013), ), @@ -280,12 +293,12 @@ def test_autodiscover_from_account(self, m): self.assertEqual(account.protocol.service_endpoint.lower(), self.dummy_ews_endpoint.lower()) # Make sure cache is full self.assertEqual(len(autodiscover_cache), 1) - self.assertTrue((account.domain, self.pox_credentials, True) in autodiscover_cache) + self.assertTrue((account.domain, self.account.protocol.credentials, True) in autodiscover_cache) # Test that autodiscover works with a full cache account = Account( primary_smtp_address=self.account.primary_smtp_address, config=Configuration( - credentials=self.pox_credentials, + credentials=self.account.protocol.credentials, retry_policy=self.retry_policy, ), autodiscover=True, @@ -293,20 +306,21 @@ def test_autodiscover_from_account(self, m): ) self.assertEqual(account.primary_smtp_address, self.account.primary_smtp_address) # Test cache manipulation - key = (account.domain, self.pox_credentials, True) + key = (account.domain, self.account.protocol.credentials, True) self.assertTrue(key in autodiscover_cache) del autodiscover_cache[key] self.assertFalse(key in autodiscover_cache) @requests_mock.mock(real_http=False) - def test_autodiscover_redirect(self, m): + @patch.object(Autodiscovery, "_is_valid_hostname", return_value=True) + def test_autodiscover_redirect(self, m, _): # Test various aspects of autodiscover redirection. Mock all HTTP responses because we can't force a live server # to send us into the correct code paths. # Mock the default endpoint that we test in step 1 of autodiscovery m.post(self.dummy_ad_endpoint, status_code=200, content=self.dummy_ad_response) discovery = Autodiscovery( email=self.account.primary_smtp_address, - credentials=self.pox_credentials, + credentials=self.account.protocol.credentials, ) discovery.discover() @@ -380,10 +394,11 @@ def test_autodiscover_redirect(self, m): self.assertEqual(ad_response.protocol.ews_url, ews_url) @requests_mock.mock(real_http=False) - def test_autodiscover_path_1_2_5(self, m): + @patch.object(Autodiscovery, "_is_valid_hostname", return_value=True) + def test_autodiscover_path_1_2_5(self, m, _): # Test steps 1 -> 2 -> 5 clear_cache() - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.pox_credentials) + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) ews_url = f"https://xxx.{self.domain}/EWS/Exchange.asmx" email = f"xxxd@{self.domain}" m.post(self.dummy_ad_endpoint, status_code=501) @@ -397,10 +412,11 @@ def test_autodiscover_path_1_2_5(self, m): self.assertEqual(ad_response.protocol.ews_url, ews_url) @requests_mock.mock(real_http=False) - def test_autodiscover_path_1_2_3_invalid301_4(self, m): + @patch.object(Autodiscovery, "_is_valid_hostname", return_value=True) + def test_autodiscover_path_1_2_3_invalid301_4(self, m, _): # Test steps 1 -> 2 -> 3 -> invalid 301 URL -> 4 clear_cache() - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.pox_credentials) + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) m.post(self.dummy_ad_endpoint, status_code=501) m.post(f"https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=501) m.get( @@ -414,9 +430,10 @@ def test_autodiscover_path_1_2_3_invalid301_4(self, m): ad_response, _ = d.discover() @requests_mock.mock(real_http=False) - def test_autodiscover_path_1_2_3_no301_4(self, m): + @patch.object(Autodiscovery, "_is_valid_hostname", return_value=True) + def test_autodiscover_path_1_2_3_no301_4(self, m, _): # Test steps 1 -> 2 -> 3 -> no 301 response -> 4 - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.pox_credentials) + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) m.post(self.dummy_ad_endpoint, status_code=501) m.post(f"https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=501) m.get(f"http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=200) @@ -426,9 +443,10 @@ def test_autodiscover_path_1_2_3_no301_4(self, m): ad_response, _ = d.discover() @requests_mock.mock(real_http=False) - def test_autodiscover_path_1_2_3_4_valid_srv_invalid_response(self, m): + @patch.object(Autodiscovery, "_is_valid_hostname", return_value=True) + def test_autodiscover_path_1_2_3_4_valid_srv_invalid_response(self, m, _): # Test steps 1 -> 2 -> 3 -> 4 -> invalid response from SRV URL - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.pox_credentials) + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) redirect_srv = "httpbin.org" m.post(self.dummy_ad_endpoint, status_code=501) m.post(f"https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=501) @@ -446,9 +464,10 @@ def test_autodiscover_path_1_2_3_4_valid_srv_invalid_response(self, m): d._get_srv_records = tmp @requests_mock.mock(real_http=False) - def test_autodiscover_path_1_2_3_4_valid_srv_valid_response(self, m): + @patch.object(Autodiscovery, "_is_valid_hostname", return_value=True) + def test_autodiscover_path_1_2_3_4_valid_srv_valid_response(self, m, _): # Test steps 1 -> 2 -> 3 -> 4 -> 5 - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.pox_credentials) + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) redirect_srv = "httpbin.org" ews_url = f"https://{redirect_srv}/EWS/Exchange.asmx" redirect_email = f"john@redirected.{redirect_srv}" @@ -474,7 +493,7 @@ def test_autodiscover_path_1_2_3_4_valid_srv_valid_response(self, m): @requests_mock.mock(real_http=False) def test_autodiscover_path_1_2_3_4_invalid_srv(self, m): # Test steps 1 -> 2 -> 3 -> 4 -> invalid SRV URL - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.pox_credentials) + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) m.post(self.dummy_ad_endpoint, status_code=501) m.post(f"https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=501) m.get(f"http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=200) @@ -492,22 +511,28 @@ def test_autodiscover_path_1_2_3_4_invalid_srv(self, m): def test_autodiscover_path_1_5_invalid_redirect_url(self, m): # Test steps 1 -> -> 5 -> Invalid redirect URL clear_cache() - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.pox_credentials) + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) m.post( self.dummy_ad_endpoint, status_code=200, content=self.redirect_url_xml(f"https://{get_random_hostname()}/EWS/Exchange.asmx"), ) + m.post( + f"https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", + status_code=200, + content=self.redirect_url_xml(f"https://{get_random_hostname()}/EWS/Exchange.asmx"), + ) with self.assertRaises(AutoDiscoverFailed): # Fails in step 5 with invalid redirect URL ad_response, _ = d.discover() @requests_mock.mock(real_http=False) - def test_autodiscover_path_1_5_valid_redirect_url_invalid_response(self, m): + @patch.object(Autodiscovery, "_is_valid_hostname", return_value=True) + def test_autodiscover_path_1_5_valid_redirect_url_invalid_response(self, m, _): # Test steps 1 -> -> 5 -> Invalid response from redirect URL clear_cache() - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.pox_credentials) + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) redirect_url = "https://httpbin.org/Autodiscover/Autodiscover.xml" m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_url_xml(redirect_url)) m.head(redirect_url, status_code=501) @@ -518,10 +543,11 @@ def test_autodiscover_path_1_5_valid_redirect_url_invalid_response(self, m): ad_response, _ = d.discover() @requests_mock.mock(real_http=False) - def test_autodiscover_path_1_5_valid_redirect_url_valid_response(self, m): + @patch.object(Autodiscovery, "_is_valid_hostname", return_value=True) + def test_autodiscover_path_1_5_valid_redirect_url_valid_response(self, m, _): # Test steps 1 -> -> 5 -> Valid response from redirect URL -> 5 clear_cache() - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.pox_credentials) + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) redirect_hostname = "httpbin.org" redirect_url = f"https://{redirect_hostname}/Autodiscover/Autodiscover.xml" ews_url = f"https://{redirect_hostname}/EWS/Exchange.asmx" @@ -789,3 +815,18 @@ def test_redirect_url_is_valid(self, m): # OK response from URL on valid hostname m.head(self.account.protocol.config.service_endpoint, status_code=200) self.assertTrue(a._redirect_url_is_valid(self.account.protocol.config.service_endpoint)) + + def test_protocol_default_values(self): + # Test that retry_policy and auth_type always get a value regardless of how we create an Account + self.get_account() + a = Account( + self.account.primary_smtp_address, + autodiscover=True, + config=self.account.protocol.config, + ) + self.assertIsNotNone(a.protocol.auth_type) + self.assertIsNotNone(a.protocol.retry_policy) + + a = Account(self.account.primary_smtp_address, autodiscover=True, credentials=self.account.protocol.credentials) + self.assertIsNotNone(a.protocol.auth_type) + self.assertIsNotNone(a.protocol.retry_policy) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index b0b74af2..38e38e46 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -18,7 +18,12 @@ from exchangelib import close_connections from exchangelib.configuration import Configuration -from exchangelib.credentials import Credentials, OAuth2AuthorizationCodeCredentials, OAuth2Credentials +from exchangelib.credentials import ( + BaseOAuth2Credentials, + Credentials, + OAuth2AuthorizationCodeCredentials, + OAuth2Credentials, +) from exchangelib.errors import ( ErrorAccessDenied, ErrorMailRecipientNotFound, @@ -897,7 +902,7 @@ def test_sessionpool(self): self.assertEqual(ids.count(), len(items)) def test_disable_ssl_verification(self): - if isinstance(self.account.protocol.credentials, OAuth2Credentials): + if isinstance(self.account.protocol.credentials, BaseOAuth2Credentials): self.skipTest("OAuth authentication ony works with SSL verification enabled") # Test that we can make requests when SSL verification is turned off. I don't know how to mock TLS responses From c153910a257adcdd772749ef9c75a34f8cddf184 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 21 Nov 2022 23:53:42 +0100 Subject: [PATCH 302/509] chore: Rename test file --- tests/{test_autodiscover.py => test_autodiscover_pox.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_autodiscover.py => test_autodiscover_pox.py} (100%) diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover_pox.py similarity index 100% rename from tests/test_autodiscover.py rename to tests/test_autodiscover_pox.py From eaad12c2bb8036451588d60157a190002eff675c Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 22 Nov 2022 20:04:10 +0100 Subject: [PATCH 303/509] chore: simplify rendering of Identity --- exchangelib/account.py | 34 ++++++++++------------------------ exchangelib/protocol.py | 17 ++++++++++++----- exchangelib/services/common.py | 21 +++------------------ tests/test_credentials.py | 8 +++++--- 4 files changed, 30 insertions(+), 50 deletions(-) diff --git a/exchangelib/account.py b/exchangelib/account.py index d5c86801..f00b0a3e 100644 --- a/exchangelib/account.py +++ b/exchangelib/account.py @@ -8,7 +8,7 @@ from .credentials import ACCESS_TYPES, DELEGATE, IMPERSONATION from .errors import InvalidEnumValue, InvalidTypeError, UnknownTimeZone from .ewsdatetime import UTC, EWSTimeZone -from .fields import FieldPath +from .fields import FieldPath, TextField from .folders import ( AdminAuditLogs, ArchiveDeletedItems, @@ -55,7 +55,7 @@ VoiceMail, ) from .items import ALL_OCCURRENCES, AUTO_RESOLVE, HARD_DELETE, ID_ONLY, SAVE_ONLY, SEND_TO_NONE -from .properties import Mailbox, SendingAs +from .properties import EWSElement, Mailbox, SendingAs from .protocol import Protocol from .queryset import QuerySet from .services import ( @@ -81,31 +81,17 @@ log = getLogger(__name__) -class Identity: +class Identity(EWSElement): """Contains information that uniquely identifies an account. Currently only used for SOAP impersonation headers.""" - def __init__(self, primary_smtp_address=None, smtp_address=None, upn=None, sid=None): - """ - - :param primary_smtp_address: The primary email address associated with the account (Default value = None) - :param smtp_address: The (non-)primary email address associated with the account (Default value = None) - :param upn: (Default value = None) - :param sid: (Default value = None) - :return: - """ - self.primary_smtp_address = primary_smtp_address - self.smtp_address = smtp_address - self.upn = upn - self.sid = sid - - def __eq__(self, other): - return all(getattr(self, k) == getattr(other, k) for k in self.__dict__) - - def __hash__(self): - return hash(repr(self)) + ELEMENT_NAME = "ConnectingSID" - def __repr__(self): - return self.__class__.__name__ + repr((self.primary_smtp_address, self.smtp_address, self.upn, self.sid)) + # We have multiple options for uniquely identifying the user. Here's a prioritized list in accordance with + # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/connectingsid + sid = TextField(field_uri="SID") + upn = TextField(field_uri="PrincipalName") + smtp_address = TextField(field_uri="SmtpAddress") # The (non-)primary email address for the account + primary_smtp_address = TextField(field_uri="PrimarySmtpAddress") # The primary email address for the account class Account: diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 16f806ac..97284f00 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -29,7 +29,17 @@ TransportError, UnauthorizedError, ) -from .properties import DLMailbox, FreeBusyViewOptions, MailboxData, RoomList, TimeWindow, TimeZone +from .properties import ( + ENTRY_ID, + EWS_ID, + AlternateId, + DLMailbox, + FreeBusyViewOptions, + MailboxData, + RoomList, + TimeWindow, + TimeZone, +) from .services import ( ConvertId, ExpandDL, @@ -569,10 +579,7 @@ def convert_ids(self, ids, destination_format): def dummy_xml(self): # Generate a minimal, valid EWS request - from .properties import ENTRY_ID, EWS_ID, AlternateId - from .services import ConvertId - - svc = ConvertId(protocol=None) + svc = ConvertId(protocol=self) return svc.wrap( content=svc.get_payload( items=[AlternateId(id="DUMMY", format=EWS_ID, mailbox="DUMMY")], diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index d74cde92..6b9f3ccd 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -202,24 +202,9 @@ def wrap(self, content, api_version=None): if api_version: request_server_version = create_element("t:RequestServerVersion", attrs=dict(Version=api_version)) header.append(request_server_version) - account_to_impersonate = self._account_to_impersonate - if account_to_impersonate: - exchange_impersonation = create_element("t:ExchangeImpersonation") - connecting_sid = create_element("t:ConnectingSID") - # We have multiple options for uniquely identifying the user. Here's a prioritized list in accordance with - # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/connectingsid - for attr, tag in ( - ("sid", "SID"), - ("upn", "PrincipalName"), - ("smtp_address", "SmtpAddress"), - ("primary_smtp_address", "PrimarySmtpAddress"), - ): - val = getattr(account_to_impersonate, attr) - if val: - add_xml_child(connecting_sid, f"t:{tag}", val) - break - exchange_impersonation.append(connecting_sid) - header.append(exchange_impersonation) + identity = self._account_to_impersonate + if identity: + add_xml_child(header, "t:ExchangeImpersonation", identity) timezone = self._timezone if timezone: timezone_context = create_element("t:TimeZoneContext") diff --git a/tests/test_credentials.py b/tests/test_credentials.py index ad8343d6..755e3af0 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -31,10 +31,12 @@ def test_type(self): def test_pickle(self): # Test that we can pickle, hash, repr, str and compare various credentials types for o in ( - Identity("XXX", "YYY", "ZZZ", "WWW"), + Identity(sid="XXX", upn="YYY", smtp_address="ZZZ", primary_smtp_address="WWW"), Credentials("XXX", "YYY"), OAuth2Credentials(client_id="XXX", client_secret="YYY", tenant_id="ZZZZ"), - OAuth2Credentials(client_id="XXX", client_secret="YYY", tenant_id="ZZZZ", identity=Identity("AAA")), + OAuth2Credentials( + client_id="XXX", client_secret="YYY", tenant_id="ZZZZ", identity=Identity(primary_smtp_address="AAA") + ), OAuth2LegacyCredentials( client_id="XXX", client_secret="YYY", tenant_id="ZZZZ", username="AAA", password="BBB" ), @@ -49,7 +51,7 @@ def test_pickle(self): authorization_code="YYY", access_token={"access_token": "ZZZ"}, tenant_id="ZZZ", - identity=Identity("AAA"), + identity=Identity(primary_smtp_address="AAA"), ), ): with self.subTest(o=o): From 8bf43b04536b499085c00ea0f8fd76c45d3ce085 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 22 Nov 2022 20:06:34 +0100 Subject: [PATCH 304/509] chore: Add X-AnchorMailbox for OAuth even if we don't have the account --- exchangelib/services/common.py | 16 +++++++++++----- exchangelib/transport.py | 4 +++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 6b9f3ccd..a27f7678 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -241,8 +241,14 @@ def _version_hint(self): def _version_hint(self, value): self.protocol.config.version = value - def _extra_headers(self, session): - return {} + def _extra_headers(self): + headers = {} + identity = self._account_to_impersonate + if identity and identity.primary_smtp_address: + # See + # https://blogs.msdn.microsoft.com/webdav_101/2015/05/11/best-practices-ews-authentication-and-access-issues/ + headers["X-AnchorMailbox"] = identity.primary_smtp_address + return headers @property def _account_to_impersonate(self): @@ -332,7 +338,7 @@ def _get_response(self, payload, api_version): protocol=self.protocol, session=session, url=self.protocol.service_endpoint, - headers=self._extra_headers(session), + headers=self._extra_headers(), data=self.wrap( content=payload, api_version=api_version, @@ -721,8 +727,8 @@ def _handle_response_cookies(self, session): self.account.affinity_cookie = cookie.value break - def _extra_headers(self, session): - headers = super()._extra_headers(session=session) + def _extra_headers(self): + headers = super()._extra_headers() # See # https://blogs.msdn.microsoft.com/webdav_101/2015/05/11/best-practices-ews-authentication-and-access-issues/ headers["X-AnchorMailbox"] = self.account.primary_smtp_address diff --git a/exchangelib/transport.py b/exchangelib/transport.py index 26e0cbe1..b6a62ce9 100644 --- a/exchangelib/transport.py +++ b/exchangelib/transport.py @@ -77,10 +77,10 @@ def get_service_authtype(protocol): retry_policy = protocol.retry_policy retry = 0 t_start = time.monotonic() - headers = DEFAULT_HEADERS.copy() for api_version in ConvertId.supported_api_versions(): protocol.api_version_hint = api_version data = protocol.dummy_xml() + headers = {} log.debug("Requesting %s from %s", data, service_endpoint) while True: _back_off_if_needed(retry_policy.back_off_until) @@ -148,6 +148,8 @@ def get_auth_method_from_response(response): return NTLM if "basic" in vals: return BASIC + elif key.lower() == "ms-diagnostics-public" and "Modern Auth" in val: + return OAUTH2 raise UnauthorizedError("No compatible auth type was reported by server") From 741c46230a78b5126ad4ce51cde9e4e283323e7f Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 22 Nov 2022 20:07:38 +0100 Subject: [PATCH 305/509] chore: Support Exchange2015 API version. Fix __str__ --- exchangelib/version.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/exchangelib/version.py b/exchangelib/version.py index f5f2d57a..6e1da93a 100644 --- a/exchangelib/version.py +++ b/exchangelib/version.py @@ -126,6 +126,8 @@ def __repr__(self): EXCHANGE_2010_SP3 = Build(14, 3) EXCHANGE_2013 = Build(15, 0) EXCHANGE_2013_SP1 = Build(15, 0, 847) # Major builds starting from 847 are Exchange2013_SP1 +EXCHANGE_2015 = Build(15, 20) +EXCHANGE_2015_SP1 = Build(15, 20) EXCHANGE_2016 = Build(15, 1) EXCHANGE_2019 = Build(15, 2) EXCHANGE_O365 = Build(15, 20) @@ -145,6 +147,8 @@ def __repr__(self): (EXCHANGE_O365, "Exchange2016", "Microsoft Exchange Server Office365"), # Not mentioned in list of build numbers (EXCHANGE_2019, "Exchange2019", "Microsoft Exchange Server 2019"), (EXCHANGE_2016, "Exchange2016", "Microsoft Exchange Server 2016"), + (EXCHANGE_2015_SP1, "Exchange2015_SP1", "Microsoft Exchange Server 2015 SP1"), + (EXCHANGE_2015, "Exchange2015", "Microsoft Exchange Server 2015"), (EXCHANGE_2013_SP1, "Exchange2013_SP1", "Microsoft Exchange Server 2013 SP1"), (EXCHANGE_2013, "Exchange2013", "Microsoft Exchange Server 2013"), (EXCHANGE_2010_SP3, "Exchange2010_SP2", "Microsoft Exchange Server 2010 SP3"), @@ -184,7 +188,7 @@ def fullname(self): continue if self.api_version == api_version: return full_name - raise ValueError(f"Full name for version {self} is unknown") + raise ValueError(f"Full name for API version {self.api_version} build {self.build} is unknown") @classmethod def guess(cls, protocol, api_version_hint=None): From 3270fea53365c4d1a350464f997b1cb9b7c4587d Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 23 Nov 2022 01:05:10 +0100 Subject: [PATCH 306/509] feat: Add support for SOAP-based autodiscovery. Refs #1089 --- CHANGELOG.md | 5 + exchangelib/account.py | 14 +- exchangelib/autodiscover/__init__.py | 3 +- .../autodiscover/discovery/__init__.py | 0 .../{discovery_pox.py => discovery/base.py} | 252 +---- exchangelib/autodiscover/discovery/pox.py | 189 ++++ exchangelib/autodiscover/discovery/soap.py | 106 +++ exchangelib/autodiscover/properties.py | 3 + exchangelib/autodiscover/protocol.py | 48 + exchangelib/properties.py | 170 +++- exchangelib/services/__init__.py | 2 + exchangelib/services/get_user_settings.py | 96 ++ exchangelib/transport.py | 56 +- exchangelib/util.py | 6 + exchangelib/version.py | 12 +- tests/__init__.py | 1 + tests/test_autodiscover_pox.py | 36 +- tests/test_autodiscover_soap.py | 883 ++++++++++++++++++ 18 files changed, 1649 insertions(+), 233 deletions(-) create mode 100644 exchangelib/autodiscover/discovery/__init__.py rename exchangelib/autodiscover/{discovery_pox.py => discovery/base.py} (63%) create mode 100644 exchangelib/autodiscover/discovery/pox.py create mode 100644 exchangelib/autodiscover/discovery/soap.py create mode 100644 exchangelib/services/get_user_settings.py create mode 100644 tests/test_autodiscover_soap.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bb188d4..f1bd5657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ Change Log HEAD ---- +- Added support for SOAP-based autodiscovery, in addition to the existing POX + (plain old XML) implementation. You can specify the autodiscover + implementation explicitly using the `autodiscover` argument: + `Account(..., autodiscover="soap")` or `Account(..., autodiscover="pox")`. POX + is still the default. 4.8.0 diff --git a/exchangelib/account.py b/exchangelib/account.py index f00b0a3e..a54c7b98 100644 --- a/exchangelib/account.py +++ b/exchangelib/account.py @@ -3,7 +3,8 @@ from cached_property import threaded_cached_property -from .autodiscover.discovery_pox import Autodiscovery +from .autodiscover.discovery.pox import PoxAutodiscovery +from .autodiscover.discovery.soap import SoapAutodiscovery from .configuration import Configuration from .credentials import ACCESS_TYPES, DELEGATE, IMPERSONATION from .errors import InvalidEnumValue, InvalidTypeError, UnknownTimeZone @@ -97,6 +98,8 @@ class Identity(EWSElement): class Account: """Models an Exchange server user account.""" + DEFAULT_DISCOVERY_CLS = PoxAutodiscovery + def __init__( self, primary_smtp_address, @@ -115,7 +118,7 @@ def __init__( :param access_type: The access type granted to 'credentials' for this account. Valid options are 'delegate' and 'impersonation'. 'delegate' is default if 'credentials' is set. Otherwise, 'impersonation' is default. :param autodiscover: Whether to look up the EWS endpoint automatically using the autodiscover protocol. - (Default value = False) + Can also be set to "pox" or "soap" to choose the autodiscover implementation (Default value = False) :param credentials: A Credentials object containing valid credentials for this account. (Default value = None) :param config: A Configuration object containing EWS endpoint information. Required if autodiscover is disabled (Default value = None) @@ -162,7 +165,12 @@ def __init__( credentials = config.credentials else: auth_type, retry_policy, version = None, None, None - self.ad_response, self.protocol = Autodiscovery( + discovery_cls = { + "pox": PoxAutodiscovery, + "soap": SoapAutodiscovery, + True: self.DEFAULT_DISCOVERY_CLS, + }[autodiscover] + self.ad_response, self.protocol = discovery_cls( email=primary_smtp_address, credentials=credentials ).discover() # Let's not use the auth_package hint from the AD response. It could be incorrect and we can just guess. diff --git a/exchangelib/autodiscover/__init__.py b/exchangelib/autodiscover/__init__.py index 5b7ab26f..d67473ca 100644 --- a/exchangelib/autodiscover/__init__.py +++ b/exchangelib/autodiscover/__init__.py @@ -1,5 +1,6 @@ from .cache import AutodiscoverCache, autodiscover_cache -from .discovery_pox import Autodiscovery, discover +from .discovery.pox import PoxAutodiscovery as Autodiscovery +from .discovery.pox import discover from .protocol import AutodiscoverProtocol diff --git a/exchangelib/autodiscover/discovery/__init__.py b/exchangelib/autodiscover/discovery/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/exchangelib/autodiscover/discovery_pox.py b/exchangelib/autodiscover/discovery/base.py similarity index 63% rename from exchangelib/autodiscover/discovery_pox.py rename to exchangelib/autodiscover/discovery/base.py index d978512a..c5435107 100644 --- a/exchangelib/autodiscover/discovery_pox.py +++ b/exchangelib/autodiscover/discovery/base.py @@ -1,28 +1,16 @@ +import abc import logging -import time from urllib.parse import urlparse import dns.name import dns.resolver from cached_property import threaded_cached_property -from ..configuration import Configuration -from ..errors import AutoDiscoverCircularRedirect, AutoDiscoverFailed, RedirectError, TransportError, UnauthorizedError -from ..protocol import FailFast, Protocol -from ..transport import AUTH_TYPE_MAP, DEFAULT_HEADERS, GSSAPI, NOAUTH, get_auth_method_from_response -from ..util import ( - CONNECTION_ERRORS, - TLS_ERRORS, - DummyResponse, - ParseError, - _back_off_if_needed, - get_domain, - get_redirect_url, - post_ratelimited, -) -from .cache import autodiscover_cache -from .properties import Autodiscover -from .protocol import AutodiscoverProtocol +from ...errors import AutoDiscoverCircularRedirect, AutoDiscoverFailed, TransportError +from ...protocol import FailFast +from ...util import DummyResponse, get_domain, get_redirect_url +from ..cache import autodiscover_cache +from ..protocol import AutodiscoverProtocol log = logging.getLogger(__name__) @@ -34,13 +22,6 @@ ) -def discover(email, credentials=None, auth_type=None, retry_policy=None): - ad_response, protocol = Autodiscovery(email=email, credentials=credentials).discover() - protocol.config.auth_typ = auth_type - protocol.config.retry_policy = retry_policy - return ad_response, protocol - - class SrvRecord: """A container for autodiscover-related SRV records in DNS.""" @@ -54,7 +35,7 @@ def __eq__(self, other): return all(getattr(self, k) == getattr(other, k) for k in self.__dict__) -class Autodiscovery: +class BaseAutodiscovery(metaclass=abc.ABCMeta): """Autodiscover is a Microsoft protocol for automatically getting the endpoint of the Exchange server and other connection-related settings holding the email address using only the email address, and username and password of the user. @@ -91,6 +72,7 @@ class Autodiscovery: "timeout": AutodiscoverProtocol.TIMEOUT / 2.5, # Timeout for query to a single nameserver } DNS_RESOLVER_LIFETIME = AutodiscoverProtocol.TIMEOUT # Total timeout for a query in case of multiple nameservers + URL_PATH = None def __init__(self, email, credentials=None): """ @@ -119,30 +101,30 @@ def discover(self): ad_protocol = autodiscover_cache[cache_key] log.debug("Cache hit for key %s: %s", cache_key, ad_protocol.service_endpoint) try: - ad_response = self._quick(protocol=ad_protocol) + ad = self._quick(protocol=ad_protocol) except AutoDiscoverFailed: # Autodiscover no longer works with this domain. Clear cache and try again after releasing the lock log.debug("AD request failure. Removing cache for key %s", cache_key) del autodiscover_cache[cache_key] - ad_response = self._step_1(hostname=domain) + ad = self._step_1(hostname=domain) else: # This will cache the result log.debug("Cache miss for key %s", cache_key) - ad_response = self._step_1(hostname=domain) + ad = self._step_1(hostname=domain) log.debug("Released autodiscover_cache_lock") - if ad_response.redirect_address: - log.debug("Got a redirect address: %s", ad_response.redirect_address) - if ad_response.redirect_address.lower() in self._emails_visited: + if ad.redirect_address: + log.debug("Got a redirect address: %s", ad.redirect_address) + if ad.redirect_address.lower() in self._emails_visited: raise AutoDiscoverCircularRedirect("We were redirected to an email address we have already seen") # Start over, but with the new email address - self.email = ad_response.redirect_address + self.email = ad.redirect_address return self.discover() # We successfully received a response. Clear the cache of seen emails etc. self.clear() - return self._build_response(ad_response=ad_response) + return self._build_response(ad_response=ad) def clear(self): # This resets cached variables @@ -164,34 +146,13 @@ def resolver(self): setattr(resolver, k, v) return resolver + @abc.abstractmethod def _build_response(self, ad_response): - if not ad_response.autodiscover_smtp_address: - # Autodiscover does not always return an email address. In that case, the requesting email should be used - ad_response.user.autodiscover_smtp_address = self.email - - protocol = Protocol( - config=Configuration( - service_endpoint=ad_response.protocol.ews_url, - credentials=self.credentials, - version=ad_response.version, - auth_type=ad_response.protocol.auth_type, - ) - ) - return ad_response, protocol + pass + @abc.abstractmethod def _quick(self, protocol): - try: - r = self._get_authenticated_response(protocol=protocol) - except TransportError as e: - raise AutoDiscoverFailed(f"Response error: {e}") - if r.status_code == 200: - try: - ad = Autodiscover.from_bytes(bytes_content=r.content) - except ParseError as e: - raise AutoDiscoverFailed(f"Invalid response: {e}") - else: - return self._step_5(ad=ad) - raise AutoDiscoverFailed(f"Invalid response code: {r.status_code}") + pass def _redirect_url_is_valid(self, url): """Three separate responses can be “Redirect responses”: @@ -229,145 +190,24 @@ def _redirect_url_is_valid(self, url): self._redirect_count += 1 return True + @abc.abstractmethod def _get_unauthenticated_response(self, url, method="post"): - """Get auth type by tasting headers from the server. Do POST requests be default. HEAD is too error-prone, and - some servers are set up to redirect to OWA on all requests except POST to the autodiscover endpoint. - - :param url: - :param method: (Default value = 'post') - :return: - """ - # We are connecting to untrusted servers here, so take necessary precautions. - hostname = urlparse(url).netloc - if not self._is_valid_hostname(hostname): - # 'requests' is really bad at reporting that a hostname cannot be resolved. Let's check this separately. - # Don't retry on DNS errors. They will most likely be persistent. - raise TransportError(f"{hostname!r} has no DNS entry") - - kwargs = dict( - url=url, headers=DEFAULT_HEADERS.copy(), allow_redirects=False, timeout=AutodiscoverProtocol.TIMEOUT - ) - if method == "post": - kwargs["data"] = Autodiscover.payload(email=self.email) - retry = 0 - t_start = time.monotonic() - while True: - _back_off_if_needed(self.INITIAL_RETRY_POLICY.back_off_until) - log.debug("Trying to get response from %s", url) - with AutodiscoverProtocol.raw_session(url) as s: - try: - r = getattr(s, method)(**kwargs) - r.close() # Release memory - break - except TLS_ERRORS as e: - # Don't retry on TLS errors. They will most likely be persistent. - raise TransportError(str(e)) - except CONNECTION_ERRORS as e: - r = DummyResponse(url=url, request_headers=kwargs["headers"]) - total_wait = time.monotonic() - t_start - if self.INITIAL_RETRY_POLICY.may_retry_on_error(response=r, wait=total_wait): - log.debug("Connection error on URL %s (retry %s, error: %s). Cool down", url, retry, e) - # Don't respect the 'Retry-After' header. We don't know if this is a useful endpoint, and we - # want autodiscover to be reasonably fast. - self.INITIAL_RETRY_POLICY.back_off(self.RETRY_WAIT) - retry += 1 - continue - log.debug("Connection error on URL %s: %s", url, e) - raise TransportError(str(e)) - try: - auth_type = get_auth_method_from_response(response=r) - except UnauthorizedError: - # Failed to guess the auth type - auth_type = NOAUTH - if r.status_code in (301, 302) and "location" in r.headers: - # Make the redirect URL absolute - try: - r.headers["location"] = get_redirect_url(r) - except TransportError: - del r.headers["location"] - return auth_type, r - - def _get_authenticated_response(self, protocol): - """Get a response by using the credentials provided. We guess the auth type along the way. - - :param protocol: - :return: - """ - # Redo the request with the correct auth - data = Autodiscover.payload(email=self.email) - headers = DEFAULT_HEADERS.copy() - session = protocol.get_session() - if GSSAPI in AUTH_TYPE_MAP and isinstance(session.auth, AUTH_TYPE_MAP[GSSAPI]): - # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/pox-autodiscover-request-for-exchange - headers["X-ClientCanHandle"] = "Negotiate" - try: - r, session = post_ratelimited( - protocol=protocol, - session=session, - url=protocol.service_endpoint, - headers=headers, - data=data, - ) - protocol.release_session(session) - except UnauthorizedError as e: - # It's entirely possible for the endpoint to ask for login. We should continue if login fails because this - # isn't necessarily the right endpoint to use. - raise TransportError(str(e)) - except RedirectError as e: - r = DummyResponse(url=protocol.service_endpoint, headers={"location": e.url}, status_code=302) - return r + pass + @abc.abstractmethod def _attempt_response(self, url): - """Return an (is_valid_response, response) tuple. + pass - :param url: - :return: - """ - self._urls_visited.append(url.lower()) - log.debug("Attempting to get a valid response from %s", url) - try: - auth_type, r = self._get_unauthenticated_response(url=url) - ad_protocol = AutodiscoverProtocol( - config=Configuration( - service_endpoint=url, - credentials=self.credentials, - auth_type=auth_type, - retry_policy=self.INITIAL_RETRY_POLICY, - ) - ) - if auth_type != NOAUTH: - r = self._get_authenticated_response(protocol=ad_protocol) - except TransportError as e: - log.debug("Failed to get a response: %s", e) - return False, None - if r.status_code in (301, 302) and "location" in r.headers: - redirect_url = get_redirect_url(r) - if self._redirect_url_is_valid(url=redirect_url): - # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com - # works, it seems that we should follow this URL now and try to get a valid response. - return self._attempt_response(url=redirect_url) - if r.status_code == 200: - try: - ad = Autodiscover.from_bytes(bytes_content=r.content) - except ParseError as e: - log.debug("Invalid response: %s", e) - else: - # We got a valid response. Unless this is a URL redirect response, we cache the result - if ad.response is None or not ad.response.redirect_url: - cache_key = self._cache_key - log.debug("Adding cache entry for key %s: %s", cache_key, ad_protocol.service_endpoint) - autodiscover_cache[cache_key] = ad_protocol - return True, ad - return False, None - - def _is_valid_hostname(self, hostname): + def _ensure_valid_hostname(self, url): + hostname = urlparse(url).netloc log.debug("Checking if %s can be looked up in DNS", hostname) try: self.resolver.resolve(f"{hostname}.", "A", lifetime=self.DNS_RESOLVER_LIFETIME) except DNS_LOOKUP_ERRORS as e: log.debug("DNS A lookup failure: %s", e) - return False - return True + # 'requests' is bad at reporting that a hostname cannot be resolved. Let's check this separately. + # Don't retry on DNS errors. They will most likely be persistent. + raise TransportError(f"{hostname!r} has no DNS entry") def _get_srv_records(self, hostname): """Send a DNS query for SRV entries for the hostname. @@ -403,14 +243,14 @@ def _get_srv_records(self, hostname): def _step_1(self, hostname): """Perform step 1, where the client sends an Autodiscover request to - https://example.com/autodiscover/autodiscover.xml and then does one of the following: + https://example.com/ and then does one of the following: * If the Autodiscover attempt succeeds, the client proceeds to step 5. * If the Autodiscover attempt fails, the client proceeds to step 2. :param hostname: :return: """ - url = f"https://{hostname}/Autodiscover/Autodiscover.xml" + url = f"https://{hostname}/{self.URL_PATH}" log.info("Step 1: Trying autodiscover on %r with email %r", url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: @@ -419,14 +259,14 @@ def _step_1(self, hostname): def _step_2(self, hostname): """Perform step 2, where the client sends an Autodiscover request to - https://autodiscover.example.com/autodiscover/autodiscover.xml and then does one of the following: + https://autodiscover.example.com/ and then does one of the following: * If the Autodiscover attempt succeeds, the client proceeds to step 5. * If the Autodiscover attempt fails, the client proceeds to step 3. :param hostname: :return: """ - url = f"https://autodiscover.{hostname}/Autodiscover/Autodiscover.xml" + url = f"https://autodiscover.{hostname}/{self.URL_PATH}" log.info("Step 2: Trying autodiscover on %r with email %r", url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: @@ -435,7 +275,7 @@ def _step_2(self, hostname): def _step_3(self, hostname): """Perform step 3, where the client sends an unauthenticated GET method request to - http://autodiscover.example.com/autodiscover/autodiscover.xml (Note that this is a non-HTTPS endpoint). The + http://autodiscover.example.com/ (Note that this is a non-HTTPS endpoint). The client then does one of the following: * If the GET request returns a 302 redirect response, it gets the redirection URL from the 'Location' HTTP header and validates it as described in the "Redirect responses" section. The client then does one of the @@ -449,7 +289,7 @@ def _step_3(self, hostname): :param hostname: :return: """ - url = f"http://autodiscover.{hostname}/Autodiscover/Autodiscover.xml" + url = f"http://autodiscover.{hostname}/{self.URL_PATH}" log.info("Step 3: Trying autodiscover on %r with email %r", url, self.email) try: _, r = self._get_unauthenticated_response(url=url, method="get") @@ -494,7 +334,7 @@ def _step_4(self, hostname): srv_host = None if not srv_host: return self._step_6() - redirect_url = f"https://{srv_host}/Autodiscover/Autodiscover.xml" + redirect_url = f"https://{srv_host}/{self.URL_PATH}" if self._redirect_url_is_valid(url=redirect_url): is_valid_response, ad = self._attempt_response(url=redirect_url) if is_valid_response: @@ -522,17 +362,19 @@ def _step_5(self, ad): :return: """ log.info("Step 5: Checking response") - if ad.response is None: - # This is not explicit in the protocol, but let's raise errors here - ad.raise_errors() + # This is not explicit in the protocol, but let's raise any errors here + ad.raise_errors() + + if hasattr(ad, "response"): + # Hack for PoxAutodiscover + ad = ad.response - ad_response = ad.response - if ad_response.redirect_url: - log.debug("Got a redirect URL: %s", ad_response.redirect_url) + if ad.redirect_url: + log.debug("Got a redirect URL: %s", ad.redirect_url) # We are diverging a bit from the protocol here. We will never get an HTTP 302 since earlier steps already # followed the redirects where possible. Instead, we handle redirect responses here. - if self._redirect_url_is_valid(url=ad_response.redirect_url): - is_valid_response, ad = self._attempt_response(url=ad_response.redirect_url) + if self._redirect_url_is_valid(url=ad.redirect_url): + is_valid_response, ad = self._attempt_response(url=ad.redirect_url) if is_valid_response: return self._step_5(ad=ad) log.debug("Got invalid response") @@ -540,7 +382,7 @@ def _step_5(self, ad): log.debug("Invalid redirect URL") return self._step_6() # This could be an email redirect. Let outer layer handle this - return ad_response + return ad def _step_6(self): """Perform step 6. If the client cannot contact the Autodiscover service, the client should ask the user for diff --git a/exchangelib/autodiscover/discovery/pox.py b/exchangelib/autodiscover/discovery/pox.py new file mode 100644 index 00000000..83f17bcc --- /dev/null +++ b/exchangelib/autodiscover/discovery/pox.py @@ -0,0 +1,189 @@ +import logging +import time + +from ...configuration import Configuration +from ...errors import AutoDiscoverFailed, RedirectError, TransportError, UnauthorizedError +from ...protocol import Protocol +from ...transport import AUTH_TYPE_MAP, DEFAULT_HEADERS, GSSAPI, NOAUTH, get_auth_method_from_response +from ...util import ( + CONNECTION_ERRORS, + TLS_ERRORS, + DummyResponse, + ParseError, + _back_off_if_needed, + get_redirect_url, + post_ratelimited, +) +from ..cache import autodiscover_cache +from ..properties import Autodiscover +from ..protocol import AutodiscoverProtocol +from .base import BaseAutodiscovery + +log = logging.getLogger(__name__) + + +def discover(email, credentials=None, auth_type=None, retry_policy=None): + ad_response, protocol = PoxAutodiscovery(email=email, credentials=credentials).discover() + protocol.config.auth_typ = auth_type + protocol.config.retry_policy = retry_policy + return ad_response, protocol + + +class PoxAutodiscovery(BaseAutodiscovery): + URL_PATH = "Autodiscover/Autodiscover.xml" + + def _build_response(self, ad_response): + if not ad_response.autodiscover_smtp_address: + # Autodiscover does not always return an email address. In that case, the requesting email should be used + ad_response.user.autodiscover_smtp_address = self.email + + protocol = Protocol( + config=Configuration( + service_endpoint=ad_response.protocol.ews_url, + credentials=self.credentials, + version=ad_response.version, + auth_type=ad_response.protocol.auth_type, + ) + ) + return ad_response, protocol + + def _quick(self, protocol): + try: + r = self._get_authenticated_response(protocol=protocol) + except TransportError as e: + raise AutoDiscoverFailed(f"Response error: {e}") + if r.status_code == 200: + try: + ad = Autodiscover.from_bytes(bytes_content=r.content) + except ParseError as e: + raise AutoDiscoverFailed(f"Invalid response: {e}") + else: + return self._step_5(ad=ad) + raise AutoDiscoverFailed(f"Invalid response code: {r.status_code}") + + def _get_unauthenticated_response(self, url, method="post"): + """Get auth type by tasting headers from the server. Do POST requests be default. HEAD is too error-prone, and + some servers are set up to redirect to OWA on all requests except POST to the autodiscover endpoint. + + :param url: + :param method: (Default value = 'post') + :return: + """ + # We are connecting to untrusted servers here, so take necessary precautions. + self._ensure_valid_hostname(url) + + kwargs = dict( + url=url, headers=DEFAULT_HEADERS.copy(), allow_redirects=False, timeout=AutodiscoverProtocol.TIMEOUT + ) + if method == "post": + kwargs["data"] = Autodiscover.payload(email=self.email) + retry = 0 + t_start = time.monotonic() + while True: + _back_off_if_needed(self.INITIAL_RETRY_POLICY.back_off_until) + log.debug("Trying to get response from %s", url) + with AutodiscoverProtocol.raw_session(url) as s: + try: + r = getattr(s, method)(**kwargs) + r.close() # Release memory + break + except TLS_ERRORS as e: + # Don't retry on TLS errors. They will most likely be persistent. + raise TransportError(str(e)) + except CONNECTION_ERRORS as e: + r = DummyResponse(url=url, request_headers=kwargs["headers"]) + total_wait = time.monotonic() - t_start + if self.INITIAL_RETRY_POLICY.may_retry_on_error(response=r, wait=total_wait): + log.debug("Connection error on URL %s (retry %s, error: %s). Cool down", url, retry, e) + # Don't respect the 'Retry-After' header. We don't know if this is a useful endpoint, and we + # want autodiscover to be reasonably fast. + self.INITIAL_RETRY_POLICY.back_off(self.RETRY_WAIT) + retry += 1 + continue + log.debug("Connection error on URL %s: %s", url, e) + raise TransportError(str(e)) + try: + auth_type = get_auth_method_from_response(response=r) + except UnauthorizedError: + # Failed to guess the auth type + auth_type = NOAUTH + if r.status_code in (301, 302) and "location" in r.headers: + # Make the redirect URL absolute + try: + r.headers["location"] = get_redirect_url(r) + except TransportError: + del r.headers["location"] + return auth_type, r + + def _get_authenticated_response(self, protocol): + """Get a response by using the credentials provided. We guess the auth type along the way. + + :param protocol: + :return: + """ + # Redo the request with the correct auth + data = Autodiscover.payload(email=self.email) + headers = DEFAULT_HEADERS.copy() + session = protocol.get_session() + if GSSAPI in AUTH_TYPE_MAP and isinstance(session.auth, AUTH_TYPE_MAP[GSSAPI]): + # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/pox-autodiscover-request-for-exchange + headers["X-ClientCanHandle"] = "Negotiate" + try: + r, session = post_ratelimited( + protocol=protocol, + session=session, + url=protocol.service_endpoint, + headers=headers, + data=data, + ) + protocol.release_session(session) + except UnauthorizedError as e: + # It's entirely possible for the endpoint to ask for login. We should continue if login fails because this + # isn't necessarily the right endpoint to use. + raise TransportError(str(e)) + except RedirectError as e: + r = DummyResponse(url=protocol.service_endpoint, headers={"location": e.url}, status_code=302) + return r + + def _attempt_response(self, url): + """Return an (is_valid_response, response) tuple. + + :param url: + :return: + """ + self._urls_visited.append(url.lower()) + log.debug("Attempting to get a valid response from %s", url) + try: + auth_type, r = self._get_unauthenticated_response(url=url) + ad_protocol = AutodiscoverProtocol( + config=Configuration( + service_endpoint=url, + credentials=self.credentials, + auth_type=auth_type, + retry_policy=self.INITIAL_RETRY_POLICY, + ) + ) + if auth_type != NOAUTH: + r = self._get_authenticated_response(protocol=ad_protocol) + except TransportError as e: + log.debug("Failed to get a response: %s", e) + return False, None + if r.status_code in (301, 302) and "location" in r.headers: + redirect_url = get_redirect_url(r) + if self._redirect_url_is_valid(url=redirect_url): + # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com + # works, it seems that we should follow this URL now and try to get a valid response. + return self._attempt_response(url=redirect_url) + if r.status_code == 200: + try: + ad = Autodiscover.from_bytes(bytes_content=r.content) + except ParseError as e: + log.debug("Invalid response: %s", e) + else: + # We got a valid response. Unless this is a URL redirect response, we cache the result + if ad.response is None or not ad.response.redirect_url: + cache_key = self._cache_key + log.debug("Adding cache entry for key %s: %s", cache_key, ad_protocol.service_endpoint) + autodiscover_cache[cache_key] = ad_protocol + return True, ad + return False, None diff --git a/exchangelib/autodiscover/discovery/soap.py b/exchangelib/autodiscover/discovery/soap.py new file mode 100644 index 00000000..a8d799e3 --- /dev/null +++ b/exchangelib/autodiscover/discovery/soap.py @@ -0,0 +1,106 @@ +import logging + +from ...configuration import Configuration +from ...errors import AutoDiscoverFailed, RedirectError, TransportError +from ...protocol import Protocol +from ...transport import get_unauthenticated_autodiscover_response +from ...util import CONNECTION_ERRORS +from ..cache import autodiscover_cache +from ..protocol import AutodiscoverProtocol +from .base import BaseAutodiscovery + +log = logging.getLogger(__name__) + + +def discover(email, credentials=None, auth_type=None, retry_policy=None): + ad_response, protocol = SoapAutodiscovery(email=email, credentials=credentials).discover() + protocol.config.auth_typ = auth_type + protocol.config.retry_policy = retry_policy + return ad_response, protocol + + +class SoapAutodiscovery(BaseAutodiscovery): + URL_PATH = "autodiscover/autodiscover.svc" + + def _build_response(self, ad_response): + if not ad_response.autodiscover_smtp_address: + # Autodiscover does not always return an email address. In that case, the requesting email should be used + ad_response.autodiscover_smtp_address = self.email + + protocol = Protocol( + config=Configuration( + service_endpoint=ad_response.ews_url, + credentials=self.credentials, + version=ad_response.version, + # TODO: Detect EWS service auth type somehow + ) + ) + return ad_response, protocol + + def _quick(self, protocol): + try: + user_response = protocol.get_user_settings(user=self.email) + except TransportError as e: + raise AutoDiscoverFailed(f"Response error: {e}") + return self._step_5(ad=user_response) + + def _get_unauthenticated_response(self, url, method="post"): + """Get response from server using the given HTTP method + + :param url: + :return: + """ + # We are connecting to untrusted servers here, so take necessary precautions. + self._ensure_valid_hostname(url) + + protocol = AutodiscoverProtocol( + config=Configuration( + service_endpoint=url, + retry_policy=self.INITIAL_RETRY_POLICY, + ) + ) + return None, get_unauthenticated_autodiscover_response(protocol=protocol, method=method) + + def _attempt_response(self, url): + """Return an (is_valid_response, response) tuple. + + :param url: + :return: + """ + self._urls_visited.append(url.lower()) + log.debug("Attempting to get a valid response from %s", url) + + try: + self._ensure_valid_hostname(url) + except TransportError: + return False, None + + protocol = AutodiscoverProtocol( + config=Configuration( + service_endpoint=url, + credentials=self.credentials, + retry_policy=self.INITIAL_RETRY_POLICY, + ) + ) + try: + user_response = protocol.get_user_settings(user=self.email) + except RedirectError as e: + if self._redirect_url_is_valid(url=e.url): + # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com + # works, it seems that we should follow this URL now and try to get a valid response. + return self._attempt_response(url=e.url) + log.debug("Invalid redirect URL: %s", e.url) + return False, None + except TransportError as e: + log.debug("Failed to get a response: %s", e) + return False, None + except CONNECTION_ERRORS as e: + log.debug("Failed to get a response: %s", e) + return False, None + + # We got a valid response. Unless this is a URL redirect response, we cache the result + if not user_response.redirect_url: + cache_key = self._cache_key + log.debug("Adding cache entry for key %s: %s", cache_key, protocol.service_endpoint) + autodiscover_cache[cache_key] = protocol + return True, user_response diff --git a/exchangelib/autodiscover/properties.py b/exchangelib/autodiscover/properties.py index c3c88ef7..8f4665b9 100644 --- a/exchangelib/autodiscover/properties.py +++ b/exchangelib/autodiscover/properties.py @@ -329,6 +329,9 @@ def from_bytes(cls, bytes_content): return cls.from_xml(elem=root, account=None) def raise_errors(self): + if self.response is not None: + return + # Find an error message in the response and raise the relevant exception try: error_code = self.error_response.error.code diff --git a/exchangelib/autodiscover/protocol.py b/exchangelib/autodiscover/protocol.py index a5fc044c..830e2336 100644 --- a/exchangelib/autodiscover/protocol.py +++ b/exchangelib/autodiscover/protocol.py @@ -1,4 +1,7 @@ from ..protocol import BaseProtocol +from ..services import GetUserSettings +from ..transport import get_autodiscover_authtype +from ..version import Version class AutodiscoverProtocol(BaseProtocol): @@ -6,7 +9,52 @@ class AutodiscoverProtocol(BaseProtocol): TIMEOUT = 10 # Seconds + def __init__(self, config): + if not config.version: + # Default to the latest supported version + config.version = Version.all_versions()[0] + super().__init__(config=config) + def __str__(self): return f"""\ Autodiscover endpoint: {self.service_endpoint} Auth type: {self.auth_type}""" + + @property + def version(self): + return self.config.version + + @property + def auth_type(self): + # Autodetect authentication type if necessary + if self.config.auth_type is None: + self.config.auth_type = self.get_auth_type() + return self.config.auth_type + + def get_auth_type(self): + # Autodetect authentication type. + return get_autodiscover_authtype(protocol=self) + + def get_user_settings(self, user): + return GetUserSettings(protocol=self).get( + users=[user], + settings=[ + "user_dn", + "mailbox_dn", + "user_display_name", + "auto_discover_smtp_address", + "external_ews_url", + "ews_supported_schemas", + ], + ) + + def dummy_xml(self): + # Generate a valid EWS request for SOAP autodiscovery + svc = GetUserSettings(protocol=self) + return svc.wrap( + content=svc.get_payload( + users=["DUMMY@example.com"], + settings=["auto_discover_smtp_address"], + ), + api_version=self.config.version.api_version, + ) diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 60610e2d..b55820fc 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -7,7 +7,13 @@ from inspect import getmro from threading import Lock -from .errors import InvalidTypeError +from .errors import ( + AutoDiscoverFailed, + ErrorInternalServerError, + ErrorNonExistentMailbox, + ErrorServerBusy, + InvalidTypeError, +) from .fields import ( WEEKDAY_NAMES, AssociatedCalendarItemIdField, @@ -49,8 +55,8 @@ TypeValueField, UnknownEntriesField, ) -from .util import MNS, TNS, create_element, get_xml_attr, set_xml_value, value_to_xml_text -from .version import EXCHANGE_2013, Build +from .util import ANS, MNS, TNS, create_element, get_xml_attr, set_xml_value, value_to_xml_text +from .version import EXCHANGE_2013, Build, Version log = logging.getLogger(__name__) @@ -1970,3 +1976,161 @@ def get_std_and_dst(self, for_year): continue raise ValueError(f"Unknown transition: {transition}") return standard_time, daylight_time, standard_period + + +class UserResponse(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/userresponse-soap""" + + ELEMENT_NAME = "UserResponse" + + # See https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/setting-soap + SETTINGS_MAP = { + "user_display_name": "UserDisplayName", + "user_dn": "UserDN", + "user_deployment_id": "UserDeploymentId", + "internal_mailbox_server": "InternalMailboxServer", + "internal_rpc_client_server": "InternalRpcClientServer", + "internal_mailbox_server_dn": "InternalMailboxServerDN", + "internal_ecp_url": "InternalEcpUrl", + "internal_ecp_voicemail_url": "InternalEcpVoicemailUrl", + "internal_ecp_email_subscriptions_url": "InternalEcpEmailSubscriptionsUrl", + "internal_ecp_text_messaging_url": "InternalEcpTextMessagingUrl", + "internal_ecp_delivery_report_url": "InternalEcpDeliveryReportUrl", + "internal_ecp_retention_policy_tags_url": "InternalEcpRetentionPolicyTagsUrl", + "internal_ecp_publishing_url": "InternalEcpPublishingUrl", + "internal_ews_url": "InternalEwsUrl", + "internal_oab_url": "InternalOABUrl", + "internal_um_url": "InternalUMUrl", + "internal_web_client_urls": "InternalWebClientUrls", + "mailbox_dn": "MailboxDN", + "public_folder_server": "PublicFolderServer", + "active_directory_server": "ActiveDirectoryServer", + "external_mailbox_server": "ExternalMailboxServer", + "external_mailbox_server_requires_ssl": "ExternalMailboxServerRequiresSSL", + "external_mailbox_server_authentication_methods": "ExternalMailboxServerAuthenticationMethods", + "ecp_voicemail_url_fragment,": "EcpVoicemailUrlFragment,", + "ecp_email_subscriptions_url_fragment": "EcpEmailSubscriptionsUrlFragment", + "ecp_text_messaging_url_fragment": "EcpTextMessagingUrlFragment", + "ecp_delivery_report_url_fragment": "EcpDeliveryReportUrlFragment", + "ecp_retention_policy_tags_url_fragment": "EcpRetentionPolicyTagsUrlFragment", + "ecp_publishing_url_fragment": "EcpPublishingUrlFragment", + "external_ecp_url": "ExternalEcpUrl", + "external_ecp_voicemail_url": "ExternalEcpVoicemailUrl", + "external_ecp_email_subscriptions_url": "ExternalEcpEmailSubscriptionsUrl", + "external_ecp_text_messaging_url": "ExternalEcpTextMessagingUrl", + "external_ecp_delivery_report_url": "ExternalEcpDeliveryReportUrl", + "external_ecp_retention_policy_tags_url": "ExternalEcpRetentionPolicyTagsUrl", + "external_ecp_publishing_url": "ExternalEcpPublishingUrl", + "external_ews_url": "ExternalEwsUrl", + "external_oab_url": "ExternalOABUrl", + "external_um_url": "ExternalUMUrl", + "external_web_client_urls": "ExternalWebClientUrls", + "cross_organization_sharing_enabled": "CrossOrganizationSharingEnabled", + "alternate_mailboxes": "AlternateMailboxes", + "cas_version": "CasVersion", + "ews_supported_schemas": "EwsSupportedSchemas", + "internal_pop3_connections": "InternalPop3Connections", + "external_pop3_connections": "ExternalPop3Connections", + "internal_imap4_connections": "InternalImap4Connections", + "external_imap4_connections": "ExternalImap4Connections", + "internal_smtp_connections": "InternalSmtpConnections", + "external_smtp_connections": "ExternalSmtpConnections", + "internal_server_exclusive_connect": "InternalServerExclusiveConnect", + "external_server_exclusive_connect": "ExternalServerExclusiveConnect", + "exchange_rpc_url": "ExchangeRpcUrl", + "show_gal_as_default_view": "ShowGalAsDefaultView", + "auto_discover_smtp_address": "AutoDiscoverSMTPAddress", + "interop_external_ews_url": "InteropExternalEwsUrl", + "external_ews_version": "ExternalEwsVersion", + "interop_external_ews_version": "InteropExternalEwsVersion", + "mobile_mailbox_policy_interop": "MobileMailboxPolicyInterop", + "grouping_information": "GroupingInformation", + "user_ms_online": "UserMSOnline", + "mapi_http_enabled": "MapiHttpEnabled", + } + REVERSE_SETTINGS_MAP = {v: k for k, v in SETTINGS_MAP.items()} + + error_code = CharField() + error_message = CharField() + redirect_address = CharField() + redirect_url = CharField() + user_settings_errors = DictionaryField() + user_settings = DictionaryField() + + @property + def autodiscover_smtp_address(self): + return self.user_settings.get("auto_discover_smtp_address") + + @property + def ews_url(self): + return self.user_settings.get("external_ews_url") + + @property + def version(self): + if not self.user_settings.get("ews_supported_schemas"): + return None + supported_schemas = [s.strip() for s in self.user_settings.get("ews_supported_schemas").split(",")] + newest_supported_schema = sorted(supported_schemas, reverse=True)[0] + + for version in Version.all_versions(): + if newest_supported_schema == version.api_version: + return version + raise ValueError(f"Unknown supported schemas: {supported_schemas}") + + @staticmethod + def _is_url(s): + if not s: + return False + return s.startswith("http://") or s.startswith("https://") + + def raise_errors(self): + if self.error_code == "InvalidUser": + raise ErrorNonExistentMailbox(self.error_message) + if self.error_code in ( + "InvalidRequest", + "InvalidSetting", + "SettingIsNotAvailable", + "InvalidDomain", + "NotFederated", + ): + raise AutoDiscoverFailed(f"{self.error_code}: {self.error_message}") + if self.user_settings_errors: + raise AutoDiscoverFailed(f"User settings errors: {self.user_settings_errors}") + + @classmethod + def from_xml(cls, elem, account): + # Possible ErrorCode values: + # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/errorcode-soap + error_code = get_xml_attr(elem, f"{{{ANS}}}ErrorCode") + error_message = get_xml_attr(elem, f"{{{ANS}}}ErrorMessage") + if error_code == "InternalServerError": + raise ErrorInternalServerError(error_message) + if error_code == "ServerBusy": + raise ErrorServerBusy(error_message) + if error_code not in ("NoError", "RedirectAddress", "RedirectUrl"): + return cls(error_code=error_code, error_message=error_message) + + redirect_target = get_xml_attr(elem, f"{{{ANS}}}RedirectTarget") + redirect_address = redirect_target if error_code == "RedirectAddress" else None + redirect_url = redirect_target if error_code == "RedirectUrl" else None + user_settings_errors = {} + settings_errors_elem = elem.find(f"{{{ANS}}}UserSettingErrors") + if settings_errors_elem is not None: + for setting_error in settings_errors_elem: + error_code = get_xml_attr(setting_error, f"{{{ANS}}}ErrorCode") + error_message = get_xml_attr(setting_error, f"{{{ANS}}}ErrorMessage") + name = get_xml_attr(setting_error, f"{{{ANS}}}SettingName") + user_settings_errors[cls.REVERSE_SETTINGS_MAP[name]] = (error_code, error_message) + user_settings = {} + settings_elem = elem.find(f"{{{ANS}}}UserSettings") + if settings_elem is not None: + for setting in settings_elem: + name = get_xml_attr(setting, f"{{{ANS}}}Name") + value = get_xml_attr(setting, f"{{{ANS}}}Value") + user_settings[cls.REVERSE_SETTINGS_MAP[name]] = value + return cls( + redirect_address=redirect_address, + redirect_url=redirect_url, + user_settings_errors=user_settings_errors, + user_settings=user_settings, + ) diff --git a/exchangelib/services/__init__.py b/exchangelib/services/__init__.py index 09f8fd7e..ef3a98cd 100644 --- a/exchangelib/services/__init__.py +++ b/exchangelib/services/__init__.py @@ -40,6 +40,7 @@ from .get_user_availability import GetUserAvailability from .get_user_configuration import GetUserConfiguration from .get_user_oof_settings import GetUserOofSettings +from .get_user_settings import GetUserSettings from .mark_as_junk import MarkAsJunk from .move_folder import MoveFolder from .move_item import MoveItem @@ -90,6 +91,7 @@ "GetUserAvailability", "GetUserConfiguration", "GetUserOofSettings", + "GetUserSettings", "MarkAsJunk", "MoveFolder", "MoveItem", diff --git a/exchangelib/services/get_user_settings.py b/exchangelib/services/get_user_settings.py new file mode 100644 index 00000000..4d210ecb --- /dev/null +++ b/exchangelib/services/get_user_settings.py @@ -0,0 +1,96 @@ +import logging + +from ..errors import ErrorInvalidServerVersion, MalformedResponseError +from ..properties import UserResponse +from ..transport import DEFAULT_ENCODING +from ..util import ANS, add_xml_child, create_element, get_xml_attr, ns_translation, set_xml_value, xml_to_str +from ..version import EXCHANGE_2010 +from .common import EWSService + +log = logging.getLogger(__name__) + + +class GetUserSettings(EWSService): + """Take a list of users and requested Autodiscover settings for these users. Returns the requested settings values. + + MSDN: + https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/getusersettings-operation-soap + """ + + SERVICE_NAME = "GetUserSettings" + NS_MAP = {k: v for k, v in ns_translation.items() if k in ("s", "t", "a", "wsa", "xsi")} + element_container_name = f"{{{ANS}}}UserResponses" + supported_from = EXCHANGE_2010 + + def call(self, users, settings): + return self._elems_to_objs(self._get_elements(self.get_payload(users=users, settings=settings))) + + def wrap(self, content, api_version=None): + envelope = create_element("s:Envelope", nsmap=self.NS_MAP) + header = create_element("s:Header") + if api_version: + add_xml_child(header, "a:RequestedServerVersion", api_version) + action = f"http://schemas.microsoft.com/exchange/2010/Autodiscover/Autodiscover/{self.SERVICE_NAME}" + add_xml_child(header, "wsa:Action", action) + add_xml_child(header, "wsa:To", self.protocol.service_endpoint) + identity = self._account_to_impersonate + if identity: + add_xml_child(header, "t:ExchangeImpersonation", identity) + envelope.append(header) + body = create_element("s:Body") + body.append(content) + envelope.append(body) + return xml_to_str(envelope, encoding=DEFAULT_ENCODING, xml_declaration=True) + + def _elem_to_obj(self, elem): + return UserResponse.from_xml(elem=elem, account=None) + + def get_payload(self, users, settings): + payload = create_element(f"a:{self.SERVICE_NAME}RequestMessage") + request = create_element("a:Request") + users_elem = create_element("a:Users") + for user in users: + mailbox = create_element("a:Mailbox") + set_xml_value(mailbox, user) + add_xml_child(users_elem, "a:User", mailbox) + if not len(users_elem): + raise ValueError("'users' must not be empty") + request.append(users_elem) + requested_settings = create_element("a:RequestedSettings") + for setting in settings: + add_xml_child(requested_settings, "a:Setting", UserResponse.SETTINGS_MAP[setting]) + if not len(requested_settings): + raise ValueError("'requested_settings' must not be empty") + request.append(requested_settings) + payload.append(request) + return payload + + @classmethod + def _response_tag(cls): + """Return the name of the element containing the service response.""" + return f"{{{ANS}}}{cls.SERVICE_NAME}ResponseMessage" + + def _get_element_container(self, message, name=None): + response = message.find(f"{{{ANS}}}Response") + # ErrorCode: See + # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/errorcode-soap + error_code = get_xml_attr(response, f"{{{ANS}}}ErrorCode") + if error_code == "NoError": + container = response.find(name) + if container is None: + raise MalformedResponseError(f"No {name} elements in ResponseMessage ({xml_to_str(response)})") + return container + # Raise any non-acceptable errors in the container, or return the container or the acceptable exception instance + msg_text = get_xml_attr(response, f"{{{ANS}}}ErrorMessage") + try: + raise self._get_exception(code=error_code, text=msg_text, msg_xml=None) + except self.ERRORS_TO_CATCH_IN_RESPONSE as e: + return e + + @classmethod + def _raise_soap_errors(cls, fault): + fault_code = get_xml_attr(fault, "faultcode") + fault_string = get_xml_attr(fault, "faultstring") + if fault_code == "a:ActionNotSupported" and "ContractFilter mismatch" in fault_string: + raise ErrorInvalidServerVersion(f"SOAP error code: {fault_code} string: {fault_string}") + super()._raise_soap_errors(fault=fault) diff --git a/exchangelib/transport.py b/exchangelib/transport.py index b6a62ce9..05218a52 100644 --- a/exchangelib/transport.py +++ b/exchangelib/transport.py @@ -7,7 +7,7 @@ import requests_oauthlib from .errors import TransportError, UnauthorizedError -from .util import CONNECTION_ERRORS, RETRY_WAIT, DummyResponse, _back_off_if_needed, _retry_after +from .util import CONNECTION_ERRORS, RETRY_WAIT, TLS_ERRORS, DummyResponse, _back_off_if_needed, _retry_after log = logging.getLogger(__name__) @@ -125,6 +125,60 @@ def get_service_authtype(protocol): raise TransportError("Failed to get auth type from service") +def get_autodiscover_authtype(protocol): + data = protocol.dummy_xml() + headers = {"X-AnchorMailbox": "DUMMY@example.com"} # Required in case of OAuth + r = get_unauthenticated_autodiscover_response(protocol=protocol, method="post", headers=headers, data=data) + auth_type = get_auth_method_from_response(response=r) + log.debug("Auth type is %s", auth_type) + return auth_type + + +def get_unauthenticated_autodiscover_response(protocol, method, headers=None, data=None): + from .autodiscover import Autodiscovery + + service_endpoint = protocol.service_endpoint + retry_policy = protocol.retry_policy + retry = 0 + t_start = time.monotonic() + while True: + _back_off_if_needed(retry_policy.back_off_until) + log.debug("Trying to get response from %s", service_endpoint) + with protocol.raw_session(service_endpoint) as s: + try: + r = getattr(s, method)( + url=service_endpoint, + headers=headers, + data=data, + allow_redirects=False, + timeout=protocol.TIMEOUT, + ) + r.close() # Release memory + break + except TLS_ERRORS as e: + # Don't retry on TLS errors. But wrap, so we can catch later and continue with the next endpoint. + raise TransportError(str(e)) + except CONNECTION_ERRORS as e: + r = DummyResponse(url=service_endpoint, request_headers=headers) + total_wait = time.monotonic() - t_start + if retry_policy.may_retry_on_error(response=r, wait=total_wait): + log.debug( + "Connection error on URL %s (retry %s, error: %s). Cool down %s secs", + service_endpoint, + retry, + e, + Autodiscovery.RETRY_WAIT, + ) + # Don't respect the 'Retry-After' header. We don't know if this is a useful endpoint, and we + # want autodiscover to be reasonably fast. + retry_policy.back_off(Autodiscovery.RETRY_WAIT) + retry += 1 + continue + log.debug("Connection error on URL %s: %s", service_endpoint, e) + raise TransportError(str(e)) + return r + + def get_auth_method_from_response(response): # First, get the auth method from headers. Then, test credentials. Don't handle redirects - burden is on caller. log.debug("Request headers: %s", response.request.headers) diff --git a/exchangelib/util.py b/exchangelib/util.py index 539b6c96..ed76ec9c 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -74,6 +74,9 @@ def __init__(self, msg, data): MNS = "http://schemas.microsoft.com/exchange/services/2006/messages" TNS = "http://schemas.microsoft.com/exchange/services/2006/types" ENS = "http://schemas.microsoft.com/exchange/services/2006/errors" +ANS = "http://schemas.microsoft.com/exchange/2010/Autodiscover" +INS = "http://www.w3.org/2001/XMLSchema-instance" +WSA = "http://www.w3.org/2005/08/addressing" AUTODISCOVER_BASE_NS = "http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006" AUTODISCOVER_REQUEST_NS = "http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006" AUTODISCOVER_RESPONSE_NS = "http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a" @@ -82,6 +85,9 @@ def __init__(self, msg, data): "s": SOAPNS, "m": MNS, "t": TNS, + "a": ANS, + "wsa": WSA, + "xsi": INS, } for item in ns_translation.items(): lxml.etree.register_namespace(*item) diff --git a/exchangelib/version.py b/exchangelib/version.py index 6e1da93a..87d49731 100644 --- a/exchangelib/version.py +++ b/exchangelib/version.py @@ -2,7 +2,7 @@ import re from .errors import InvalidTypeError, ResponseMessageError, TransportError -from .util import TNS, xml_to_str +from .util import ANS, TNS, get_xml_attr, xml_to_str log = logging.getLogger(__name__) @@ -40,7 +40,9 @@ def from_xml(cls, elem): for k, xml_elem in xml_elems_map.items(): v = elem.get(xml_elem) if v is None: - raise ValueError() + v = get_xml_attr(elem, f"{{{ANS}}}{xml_elem}") + if v is None: + raise ValueError() kwargs[k] = int(v) # Also raises ValueError return cls(**kwargs) @@ -233,13 +235,15 @@ def _is_invalid_version_string(version): def from_soap_header(cls, requested_api_version, header): info = header.find(f"{{{TNS}}}ServerVersionInfo") if info is None: - raise TransportError(f"No ServerVersionInfo in header: {xml_to_str(header)!r}") + info = header.find(f"{{{ANS}}}ServerVersionInfo") + if info is None: + raise TransportError(f"No ServerVersionInfo in header: {xml_to_str(header)!r}") try: build = Build.from_xml(elem=info) except ValueError: raise TransportError(f"Bad ServerVersionInfo in response: {xml_to_str(header)!r}") # Not all Exchange servers send the Version element - api_version_from_server = info.get("Version") or build.api_version() + api_version_from_server = info.get("Version") or get_xml_attr(info, f"{{{ANS}}}Version") or build.api_version() if api_version_from_server != requested_api_version: if cls._is_invalid_version_string(api_version_from_server): # For unknown reasons, Office 365 may respond with an API version strings that is invalid in a request. diff --git a/tests/__init__.py b/tests/__init__.py index bc9f76b6..cb77765c 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -28,5 +28,6 @@ def __iter__(self): logging.basicConfig(level=logging.DEBUG, handlers=[PrettyXmlHandler()]) logging.getLogger("requests").setLevel(level=logging.INFO) logging.getLogger("requests_oauthlib").setLevel(level=logging.INFO) + logging.getLogger("urllib3").setLevel(level=logging.INFO) else: logging.basicConfig(level=logging.CRITICAL) diff --git a/tests/test_autodiscover_pox.py b/tests/test_autodiscover_pox.py index f89d977f..a5b8080d 100644 --- a/tests/test_autodiscover_pox.py +++ b/tests/test_autodiscover_pox.py @@ -10,7 +10,9 @@ from exchangelib.account import Account from exchangelib.autodiscover import clear_cache, close_connections from exchangelib.autodiscover.cache import AutodiscoverCache, autodiscover_cache, shelve_filename -from exchangelib.autodiscover.discovery_pox import Autodiscovery, SrvRecord, _select_srv_host, discover +from exchangelib.autodiscover.discovery.base import SrvRecord, _select_srv_host +from exchangelib.autodiscover.discovery.pox import PoxAutodiscovery as Autodiscovery +from exchangelib.autodiscover.discovery.pox import discover from exchangelib.autodiscover.properties import Account as ADAccount from exchangelib.autodiscover.properties import Autodiscover, Error, ErrorResponse, Response from exchangelib.autodiscover.protocol import AutodiscoverProtocol @@ -182,7 +184,7 @@ def test_failed_login_via_account(self): primary_smtp_address=self.account.primary_smtp_address, access_type=DELEGATE, credentials=Credentials("john@example.com", "WRONG_PASSWORD"), - autodiscover=True, + autodiscover="pox", locale="da_DK", ) @@ -203,7 +205,7 @@ def test_autodiscover_direct_gc(self, m): autodiscover_cache.__del__() # Don't use del() because that would remove the global object @requests_mock.mock(real_http=False) - @patch.object(Autodiscovery, "_is_valid_hostname", return_value=True) + @patch.object(Autodiscovery, "_ensure_valid_hostname") def test_autodiscover_cache(self, m, _): # Mock the default endpoint that we test in step 1 of autodiscovery m.post(self.dummy_ad_endpoint, status_code=200, content=self.dummy_ad_response) @@ -273,7 +275,7 @@ def test_corrupt_autodiscover_cache(self, m): self.assertFalse(key in autodiscover_cache) @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here - @patch.object(Autodiscovery, "_is_valid_hostname", return_value=True) + @patch.object(Autodiscovery, "_ensure_valid_hostname") def test_autodiscover_from_account(self, m, _): # Test that autodiscovery via account creation works # Mock the default endpoint that we test in step 1 of autodiscovery @@ -286,7 +288,7 @@ def test_autodiscover_from_account(self, m, _): retry_policy=self.retry_policy, version=Version(build=EXCHANGE_2013), ), - autodiscover=True, + autodiscover="pox", locale="da_DK", ) self.assertEqual(account.primary_smtp_address, self.account.primary_smtp_address) @@ -301,7 +303,7 @@ def test_autodiscover_from_account(self, m, _): credentials=self.account.protocol.credentials, retry_policy=self.retry_policy, ), - autodiscover=True, + autodiscover="pox", locale="da_DK", ) self.assertEqual(account.primary_smtp_address, self.account.primary_smtp_address) @@ -312,7 +314,7 @@ def test_autodiscover_from_account(self, m, _): self.assertFalse(key in autodiscover_cache) @requests_mock.mock(real_http=False) - @patch.object(Autodiscovery, "_is_valid_hostname", return_value=True) + @patch.object(Autodiscovery, "_ensure_valid_hostname") def test_autodiscover_redirect(self, m, _): # Test various aspects of autodiscover redirection. Mock all HTTP responses because we can't force a live server # to send us into the correct code paths. @@ -394,7 +396,7 @@ def test_autodiscover_redirect(self, m, _): self.assertEqual(ad_response.protocol.ews_url, ews_url) @requests_mock.mock(real_http=False) - @patch.object(Autodiscovery, "_is_valid_hostname", return_value=True) + @patch.object(Autodiscovery, "_ensure_valid_hostname") def test_autodiscover_path_1_2_5(self, m, _): # Test steps 1 -> 2 -> 5 clear_cache() @@ -412,7 +414,7 @@ def test_autodiscover_path_1_2_5(self, m, _): self.assertEqual(ad_response.protocol.ews_url, ews_url) @requests_mock.mock(real_http=False) - @patch.object(Autodiscovery, "_is_valid_hostname", return_value=True) + @patch.object(Autodiscovery, "_ensure_valid_hostname") def test_autodiscover_path_1_2_3_invalid301_4(self, m, _): # Test steps 1 -> 2 -> 3 -> invalid 301 URL -> 4 clear_cache() @@ -430,7 +432,7 @@ def test_autodiscover_path_1_2_3_invalid301_4(self, m, _): ad_response, _ = d.discover() @requests_mock.mock(real_http=False) - @patch.object(Autodiscovery, "_is_valid_hostname", return_value=True) + @patch.object(Autodiscovery, "_ensure_valid_hostname") def test_autodiscover_path_1_2_3_no301_4(self, m, _): # Test steps 1 -> 2 -> 3 -> no 301 response -> 4 d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) @@ -443,7 +445,7 @@ def test_autodiscover_path_1_2_3_no301_4(self, m, _): ad_response, _ = d.discover() @requests_mock.mock(real_http=False) - @patch.object(Autodiscovery, "_is_valid_hostname", return_value=True) + @patch.object(Autodiscovery, "_ensure_valid_hostname") def test_autodiscover_path_1_2_3_4_valid_srv_invalid_response(self, m, _): # Test steps 1 -> 2 -> 3 -> 4 -> invalid response from SRV URL d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) @@ -464,7 +466,7 @@ def test_autodiscover_path_1_2_3_4_valid_srv_invalid_response(self, m, _): d._get_srv_records = tmp @requests_mock.mock(real_http=False) - @patch.object(Autodiscovery, "_is_valid_hostname", return_value=True) + @patch.object(Autodiscovery, "_ensure_valid_hostname") def test_autodiscover_path_1_2_3_4_valid_srv_valid_response(self, m, _): # Test steps 1 -> 2 -> 3 -> 4 -> 5 d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) @@ -528,7 +530,7 @@ def test_autodiscover_path_1_5_invalid_redirect_url(self, m): ad_response, _ = d.discover() @requests_mock.mock(real_http=False) - @patch.object(Autodiscovery, "_is_valid_hostname", return_value=True) + @patch.object(Autodiscovery, "_ensure_valid_hostname") def test_autodiscover_path_1_5_valid_redirect_url_invalid_response(self, m, _): # Test steps 1 -> -> 5 -> Invalid response from redirect URL clear_cache() @@ -543,7 +545,7 @@ def test_autodiscover_path_1_5_valid_redirect_url_invalid_response(self, m, _): ad_response, _ = d.discover() @requests_mock.mock(real_http=False) - @patch.object(Autodiscovery, "_is_valid_hostname", return_value=True) + @patch.object(Autodiscovery, "_ensure_valid_hostname") def test_autodiscover_path_1_5_valid_redirect_url_valid_response(self, m, _): # Test steps 1 -> -> 5 -> Valid response from redirect URL -> 5 clear_cache() @@ -821,12 +823,14 @@ def test_protocol_default_values(self): self.get_account() a = Account( self.account.primary_smtp_address, - autodiscover=True, + autodiscover="pox", config=self.account.protocol.config, ) self.assertIsNotNone(a.protocol.auth_type) self.assertIsNotNone(a.protocol.retry_policy) - a = Account(self.account.primary_smtp_address, autodiscover=True, credentials=self.account.protocol.credentials) + a = Account( + self.account.primary_smtp_address, autodiscover="pox", credentials=self.account.protocol.credentials + ) self.assertIsNotNone(a.protocol.auth_type) self.assertIsNotNone(a.protocol.retry_policy) diff --git a/tests/test_autodiscover_soap.py b/tests/test_autodiscover_soap.py new file mode 100644 index 00000000..fb5f6d96 --- /dev/null +++ b/tests/test_autodiscover_soap.py @@ -0,0 +1,883 @@ +import getpass +import sys +from collections import namedtuple +from types import MethodType +from unittest.mock import Mock, patch + +import dns +import requests_mock + +from exchangelib.account import Account +from exchangelib.autodiscover import clear_cache, close_connections +from exchangelib.autodiscover.cache import AutodiscoverCache, autodiscover_cache, shelve_filename +from exchangelib.autodiscover.discovery.base import SrvRecord, _select_srv_host +from exchangelib.autodiscover.discovery.soap import SoapAutodiscovery as Autodiscovery +from exchangelib.autodiscover.properties import Account as ADAccount +from exchangelib.autodiscover.properties import Autodiscover, Error, ErrorResponse, Response +from exchangelib.autodiscover.protocol import AutodiscoverProtocol +from exchangelib.configuration import Configuration +from exchangelib.credentials import DELEGATE, Credentials +from exchangelib.errors import AutoDiscoverCircularRedirect, AutoDiscoverFailed, ErrorNonExistentMailbox +from exchangelib.protocol import FailFast, FaultTolerance +from exchangelib.transport import NTLM +from exchangelib.util import ParseError, get_domain +from exchangelib.version import EXCHANGE_2013, Version + +from .common import EWSTest, get_random_hostname, get_random_string + + +class AutodiscoverSoapTest(EWSTest): + def setUp(self): + if not self.account: + self.skipTest("SOAP autodiscover requires delegate credentials") + + super().setUp() + + # Enable retries, to make tests more robust + Autodiscovery.INITIAL_RETRY_POLICY = FaultTolerance(max_wait=5) + Autodiscovery.RETRY_WAIT = 5 + + # Each test should start with a clean autodiscover cache + clear_cache() + + # Some mocking helpers + self.domain = get_domain(self.account.primary_smtp_address) + self.dummy_ad_endpoint = f"https://{self.domain}/autodiscover/autodiscover.svc" + self.dummy_ews_endpoint = "https://expr.example.com/EWS/Exchange.asmx" + self.dummy_ad_response = self.settings_xml(self.account.primary_smtp_address, self.dummy_ews_endpoint) + + @staticmethod + def settings_xml(address, ews_url): + return f"""\ + + + + + 15 + 20 + 5834 + 15 + Exchange2015 + + + + + + NoError + + + + NoError + No error. + + + + + AutoDiscoverSMTPAddress + {address} + + + ExternalEwsUrl + {ews_url} + + + + + + + +""".encode() + + @staticmethod + def redirect_address_xml(address): + return f"""\ + + + + + 15 + 20 + 5834 + 15 + Exchange2015 + + + + + + NoError + + + + RedirectAddress + Redirection address. + {address} + + + + + + + +""".encode() + + @staticmethod + def redirect_url_xml(autodiscover_url): + return f"""\ + + + + + 15 + 20 + 5834 + 15 + Exchange2015 + + + + + + NoError + + + + RedirectUrl + Redirection URL. + {autodiscover_url} + + + + + + + +""".encode() + + @staticmethod + def get_test_protocol(**kwargs): + return AutodiscoverProtocol( + config=Configuration( + service_endpoint=kwargs.get("service_endpoint", "https://example.com/autodiscover/autodiscover.svc"), + credentials=kwargs.get("credentials", Credentials(get_random_string(8), get_random_string(8))), + auth_type=kwargs.get("auth_type", NTLM), + retry_policy=kwargs.get("retry_policy", FailFast()), + ) + ) + + @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here + def test_magic(self, m): + # Just test we don't fail when calling repr() and str(). Insert a dummy cache entry for testing + p = self.get_test_protocol() + autodiscover_cache[(p.config.server, p.config.credentials)] = p + self.assertEqual(len(autodiscover_cache), 1) + str(autodiscover_cache) + repr(autodiscover_cache) + for protocol in autodiscover_cache._protocols.values(): + str(protocol) + repr(protocol) + + def test_response_properties(self): + # Test edge cases of Response properties + self.assertEqual(Response().redirect_address, None) + self.assertEqual(Response(account=ADAccount(action=ADAccount.REDIRECT_URL)).redirect_address, None) + self.assertEqual(Response().redirect_url, None) + self.assertEqual(Response(account=ADAccount(action=ADAccount.SETTINGS)).redirect_url, None) + self.assertEqual(Response().autodiscover_smtp_address, None) + self.assertEqual(Response(account=ADAccount(action=ADAccount.REDIRECT_ADDR)).autodiscover_smtp_address, None) + + def test_autodiscover_empty_cache(self): + # A live test of the entire process with an empty cache + ad_response, protocol = Autodiscovery( + email=self.account.primary_smtp_address, + credentials=self.account.protocol.credentials, + ).discover() + self.assertEqual(ad_response.autodiscover_smtp_address, self.account.primary_smtp_address) + self.assertEqual(protocol.service_endpoint.lower(), self.account.protocol.service_endpoint.lower()) + + def test_autodiscover_failure(self): + # A live test that errors can be raised. Here, we try to autodiscover a non-existing email address + if not self.settings.get("autodiscover_server"): + self.skipTest(f"Skipping {self.__class__.__name__} - no 'autodiscover_server' entry in settings.yml") + # Autodiscovery may take a long time. Prime the cache with the autodiscover server from the config file + ad_endpoint = f"https://{self.settings['autodiscover_server']}/autodiscover/autodiscover.svc" + cache_key = (self.domain, self.account.protocol.credentials) + autodiscover_cache[cache_key] = self.get_test_protocol( + service_endpoint=ad_endpoint, + credentials=self.account.protocol.credentials, + retry_policy=self.retry_policy, + ) + with self.assertRaises(ErrorNonExistentMailbox): + Autodiscovery( + email="XXX." + self.account.primary_smtp_address, + credentials=self.account.protocol.credentials, + ).discover() + + def test_failed_login_via_account(self): + with self.assertRaises(AutoDiscoverFailed): + Account( + primary_smtp_address=self.account.primary_smtp_address, + access_type=DELEGATE, + credentials=Credentials("john@example.com", "WRONG_PASSWORD"), + autodiscover="soap", + locale="da_DK", + ) + + @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here + def test_close_autodiscover_connections(self, m): + # A live test that we can close TCP connections + p = self.get_test_protocol() + autodiscover_cache[(p.config.server, p.config.credentials)] = p + self.assertEqual(len(autodiscover_cache), 1) + close_connections() + + @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here + def test_autodiscover_direct_gc(self, m): + # Test garbage collection of the autodiscover cache + p = self.get_test_protocol() + autodiscover_cache[(p.config.server, p.config.credentials)] = p + self.assertEqual(len(autodiscover_cache), 1) + autodiscover_cache.__del__() # Don't use del() because that would remove the global object + + @requests_mock.mock(real_http=False) + @patch.object(Autodiscovery, "_ensure_valid_hostname") + def test_autodiscover_cache(self, m, _): + # Mock the default endpoint that we test in step 1 of autodiscovery + m.post(self.dummy_ad_endpoint, status_code=200, content=self.dummy_ad_response) + discovery = Autodiscovery( + email=self.account.primary_smtp_address, + credentials=self.account.protocol.credentials, + ) + # Not cached + self.assertNotIn(discovery._cache_key, autodiscover_cache) + discovery.discover() + # Now it's cached + self.assertIn(discovery._cache_key, autodiscover_cache) + # Make sure the cache can be looked by value, not by id(). This is important for multi-threading/processing + self.assertIn( + ( + self.account.primary_smtp_address.split("@")[1], + self.account.protocol.credentials, + True, + ), + autodiscover_cache, + ) + # Poison the cache with a failing autodiscover endpoint. discover() must handle this and rebuild the cache + p = self.get_test_protocol() + autodiscover_cache[discovery._cache_key] = p + m.post("https://example.com/autodiscover/autodiscover.svc", status_code=404) + discovery.discover() + self.assertIn(discovery._cache_key, autodiscover_cache) + + # Make sure that the cache is actually used on the second call to discover() + _orig = discovery._step_1 + + def _mock(slf, *args, **kwargs): + raise NotImplementedError() + + discovery._step_1 = MethodType(_mock, discovery) + discovery.discover() + + # Fake that another thread added the cache entry into the persistent storage but we don't have it in our + # in-memory cache. The cache should work anyway. + autodiscover_cache._protocols.clear() + discovery.discover() + discovery._step_1 = _orig + + # Make sure we can delete cache entries even though we don't have it in our in-memory cache + autodiscover_cache._protocols.clear() + del autodiscover_cache[discovery._cache_key] + # This should also work if the cache does not contain the entry anymore + del autodiscover_cache[discovery._cache_key] + + @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here + def test_corrupt_autodiscover_cache(self, m): + # Insert a fake Protocol instance into the cache and test that we can recover + key = (2, "foo", 4) + autodiscover_cache[key] = namedtuple("P", ["service_endpoint", "auth_type", "retry_policy"])(1, "bar", "baz") + # Check that it exists. 'in' goes directly to the file + self.assertTrue(key in autodiscover_cache) + + # Check that we can recover from a destroyed file + file = autodiscover_cache._storage_file + for f in file.parent.glob(f"{file.name}*"): + f.write_text("XXX") + self.assertFalse(key in autodiscover_cache) + + # Check that we can recover from an empty file + for f in file.parent.glob(f"{file.name}*"): + f.write_bytes(b"") + self.assertFalse(key in autodiscover_cache) + + @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here + @patch.object(Autodiscovery, "_ensure_valid_hostname") + def test_autodiscover_from_account(self, m, _): + # Test that autodiscovery via account creation works + # Mock the default endpoint that we test in step 1 of autodiscovery + m.post(self.dummy_ad_endpoint, status_code=200, content=self.dummy_ad_response) + self.assertEqual(len(autodiscover_cache), 0) + account = Account( + primary_smtp_address=self.account.primary_smtp_address, + config=Configuration( + credentials=self.account.protocol.credentials, + retry_policy=self.retry_policy, + version=Version(build=EXCHANGE_2013), + ), + autodiscover="soap", + locale="da_DK", + ) + self.assertEqual(account.primary_smtp_address, self.account.primary_smtp_address) + self.assertEqual(account.protocol.service_endpoint.lower(), self.dummy_ews_endpoint.lower()) + # Make sure cache is full + self.assertEqual(len(autodiscover_cache), 1) + self.assertTrue((account.domain, self.account.protocol.credentials, True) in autodiscover_cache) + # Test that autodiscover works with a full cache + account = Account( + primary_smtp_address=self.account.primary_smtp_address, + config=Configuration( + credentials=self.account.protocol.credentials, + retry_policy=self.retry_policy, + ), + autodiscover="soap", + locale="da_DK", + ) + self.assertEqual(account.primary_smtp_address, self.account.primary_smtp_address) + # Test cache manipulation + key = (account.domain, self.account.protocol.credentials, True) + self.assertTrue(key in autodiscover_cache) + del autodiscover_cache[key] + self.assertFalse(key in autodiscover_cache) + + @requests_mock.mock(real_http=False) + @patch.object(Autodiscovery, "_ensure_valid_hostname") + def test_autodiscover_redirect(self, m, _): + # Test various aspects of autodiscover redirection. Mock all HTTP responses because we can't force a live server + # to send us into the correct code paths. + # Mock the default endpoint that we test in step 1 of autodiscovery + m.post(self.dummy_ad_endpoint, status_code=200, content=self.dummy_ad_response) + discovery = Autodiscovery( + email=self.account.primary_smtp_address, + credentials=self.account.protocol.credentials, + ) + discovery.discover() + + # Make sure we discover a different return address + m.post( + self.dummy_ad_endpoint, + status_code=200, + content=self.settings_xml("john@example.com", "https://expr.example.com/EWS/Exchange.asmx"), + ) + ad_response, _ = discovery.discover() + self.assertEqual(ad_response.autodiscover_smtp_address, "john@example.com") + + # Make sure we discover an address redirect to the same domain. We have to mock the same URL with two different + # responses. We do that with a response list. + m.post( + self.dummy_ad_endpoint, + [ + dict(status_code=200, content=self.redirect_address_xml(f"redirect_me@{self.domain}")), + dict( + status_code=200, + content=self.settings_xml( + f"redirected@{self.domain}", f"https://redirected.{self.domain}/EWS/Exchange.asmx" + ), + ), + ], + ) + ad_response, _ = discovery.discover() + self.assertEqual(ad_response.autodiscover_smtp_address, f"redirected@{self.domain}") + self.assertEqual(ad_response.ews_url, f"https://redirected.{self.domain}/EWS/Exchange.asmx") + + # Test that we catch circular redirects on the same domain with a primed cache. Just mock the endpoint to + # return the same redirect response on every request. + self.assertEqual(len(autodiscover_cache), 1) + m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_address_xml(f"foo@{self.domain}")) + self.assertEqual(len(autodiscover_cache), 1) + with self.assertRaises(AutoDiscoverCircularRedirect): + discovery.discover() + + # Test that we also catch circular redirects when cache is empty + clear_cache() + self.assertEqual(len(autodiscover_cache), 0) + with self.assertRaises(AutoDiscoverCircularRedirect): + discovery.discover() + + # Test that we can handle being asked to redirect to an address on a different domain + # Don't use example.com to redirect - it does not resolve or answer on all ISPs + ews_hostname = "httpbin.org" + redirect_email = f"john@redirected.{ews_hostname}" + ews_url = f"https://{ews_hostname}/EWS/Exchange.asmx" + m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_address_xml(f"john@{ews_hostname}")) + m.post( + f"https://{ews_hostname}/autodiscover/autodiscover.svc", + status_code=200, + content=self.settings_xml(redirect_email, ews_url), + ) + ad_response, _ = discovery.discover() + self.assertEqual(ad_response.autodiscover_smtp_address, redirect_email) + self.assertEqual(ad_response.ews_url, ews_url) + + # Test redirect via HTTP 301 + clear_cache() + redirect_url = f"https://{ews_hostname}/OtherPath/Autodiscover.xml" + redirect_email = f"john@otherpath.{ews_hostname}" + ews_url = f"https://xxx.{ews_hostname}/EWS/Exchange.asmx" + discovery.email = self.account.primary_smtp_address + m.post(self.dummy_ad_endpoint, status_code=301, headers=dict(location=redirect_url)) + m.post(redirect_url, status_code=200, content=self.settings_xml(redirect_email, ews_url)) + m.head(redirect_url, status_code=200) + ad_response, _ = discovery.discover() + self.assertEqual(ad_response.autodiscover_smtp_address, redirect_email) + self.assertEqual(ad_response.ews_url, ews_url) + + @requests_mock.mock(real_http=False) + @patch.object(Autodiscovery, "_ensure_valid_hostname") + def test_autodiscover_path_1_2_5(self, m, _): + # Test steps 1 -> 2 -> 5 + clear_cache() + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + ews_url = f"https://xxx.{self.domain}/EWS/Exchange.asmx" + email = f"xxxd@{self.domain}" + m.post(self.dummy_ad_endpoint, status_code=501) + m.post( + f"https://autodiscover.{self.domain}/autodiscover/autodiscover.svc", + status_code=200, + content=self.settings_xml(email, ews_url), + ) + ad_response, _ = d.discover() + self.assertEqual(ad_response.autodiscover_smtp_address, email) + self.assertEqual(ad_response.ews_url, ews_url) + + @requests_mock.mock(real_http=False) + @patch.object(Autodiscovery, "_ensure_valid_hostname") + def test_autodiscover_path_1_2_3_invalid301_4(self, m, _): + # Test steps 1 -> 2 -> 3 -> invalid 301 URL -> 4 + clear_cache() + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + m.post(self.dummy_ad_endpoint, status_code=501) + m.post(f"https://autodiscover.{self.domain}/autodiscover/autodiscover.svc", status_code=501) + m.get( + f"http://autodiscover.{self.domain}/autodiscover/autodiscover.svc", + status_code=301, + headers=dict(location="XXX"), + ) + + with self.assertRaises(AutoDiscoverFailed): + # Fails in step 4 with invalid SRV entry + ad_response, _ = d.discover() + + @requests_mock.mock(real_http=False) + @patch.object(Autodiscovery, "_ensure_valid_hostname") + def test_autodiscover_path_1_2_3_no301_4(self, m, _): + # Test steps 1 -> 2 -> 3 -> no 301 response -> 4 + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + m.post(self.dummy_ad_endpoint, status_code=501) + m.post(f"https://autodiscover.{self.domain}/autodiscover/autodiscover.svc", status_code=501) + m.get(f"http://autodiscover.{self.domain}/autodiscover/autodiscover.svc", status_code=200) + + with self.assertRaises(AutoDiscoverFailed): + # Fails in step 4 with invalid SRV entry + ad_response, _ = d.discover() + + @requests_mock.mock(real_http=False) + @patch.object(Autodiscovery, "_ensure_valid_hostname") + def test_autodiscover_path_1_2_3_4_valid_srv_invalid_response(self, m, _): + # Test steps 1 -> 2 -> 3 -> 4 -> invalid response from SRV URL + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + redirect_srv = "httpbin.org" + m.post(self.dummy_ad_endpoint, status_code=501) + m.post(f"https://autodiscover.{self.domain}/autodiscover/autodiscover.svc", status_code=501) + m.get(f"http://autodiscover.{self.domain}/autodiscover/autodiscover.svc", status_code=200) + m.head(f"https://{redirect_srv}/autodiscover/autodiscover.svc", status_code=501) + m.post(f"https://{redirect_srv}/autodiscover/autodiscover.svc", status_code=501) + + tmp = d._get_srv_records + d._get_srv_records = Mock(return_value=[SrvRecord(1, 1, 443, redirect_srv)]) + try: + with self.assertRaises(AutoDiscoverFailed): + # Fails in step 4 with invalid response + ad_response, _ = d.discover() + finally: + d._get_srv_records = tmp + + @requests_mock.mock(real_http=False) + @patch.object(Autodiscovery, "_ensure_valid_hostname") + def test_autodiscover_path_1_2_3_4_valid_srv_valid_response(self, m, _): + # Test steps 1 -> 2 -> 3 -> 4 -> 5 + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + redirect_srv = "httpbin.org" + ews_url = f"https://{redirect_srv}/EWS/Exchange.asmx" + redirect_email = f"john@redirected.{redirect_srv}" + m.post(self.dummy_ad_endpoint, status_code=501) + m.post(f"https://autodiscover.{self.domain}/autodiscover/autodiscover.svc", status_code=501) + m.get(f"http://autodiscover.{self.domain}/autodiscover/autodiscover.svc", status_code=200) + m.head(f"https://{redirect_srv}/autodiscover/autodiscover.svc", status_code=200) + m.post( + f"https://{redirect_srv}/autodiscover/autodiscover.svc", + status_code=200, + content=self.settings_xml(redirect_email, ews_url), + ) + + tmp = d._get_srv_records + d._get_srv_records = Mock(return_value=[SrvRecord(1, 1, 443, redirect_srv)]) + try: + ad_response, _ = d.discover() + self.assertEqual(ad_response.autodiscover_smtp_address, redirect_email) + self.assertEqual(ad_response.ews_url, ews_url) + finally: + d._get_srv_records = tmp + + @requests_mock.mock(real_http=False) + def test_autodiscover_path_1_2_3_4_invalid_srv(self, m): + # Test steps 1 -> 2 -> 3 -> 4 -> invalid SRV URL + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + m.post(self.dummy_ad_endpoint, status_code=501) + m.post(f"https://autodiscover.{self.domain}/autodiscover/autodiscover.svc", status_code=501) + m.get(f"http://autodiscover.{self.domain}/autodiscover/autodiscover.svc", status_code=200) + + tmp = d._get_srv_records + d._get_srv_records = Mock(return_value=[SrvRecord(1, 1, 443, get_random_hostname())]) + try: + with self.assertRaises(AutoDiscoverFailed): + # Fails in step 4 with invalid response + ad_response, _ = d.discover() + finally: + d._get_srv_records = tmp + + @requests_mock.mock(real_http=False) + def test_autodiscover_path_1_5_invalid_redirect_url(self, m): + # Test steps 1 -> -> 5 -> Invalid redirect URL + clear_cache() + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + m.post( + self.dummy_ad_endpoint, + status_code=200, + content=self.redirect_url_xml(f"https://{get_random_hostname()}/autodiscover/autodiscover.svc"), + ) + m.post( + f"https://autodiscover.{self.domain}/autodiscover/autodiscover.svc", + status_code=200, + content=self.redirect_url_xml(f"https://{get_random_hostname()}/autodiscover/autodiscover.svc"), + ) + + with self.assertRaises(AutoDiscoverFailed): + # Fails in step 5 with invalid redirect URL + ad_response, _ = d.discover() + + @requests_mock.mock(real_http=False) + @patch.object(Autodiscovery, "_ensure_valid_hostname") + def test_autodiscover_path_1_5_valid_redirect_url_invalid_response(self, m, _): + # Test steps 1 -> -> 5 -> Invalid response from redirect URL + clear_cache() + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + redirect_url = "https://httpbin.org/autodiscover/autodiscover.svc" + m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_url_xml(redirect_url)) + m.head(redirect_url, status_code=501) + m.post(redirect_url, status_code=501) + + with self.assertRaises(AutoDiscoverFailed): + # Fails in step 5 with invalid response + ad_response, _ = d.discover() + + @requests_mock.mock(real_http=False) + @patch.object(Autodiscovery, "_ensure_valid_hostname") + def test_autodiscover_path_1_5_valid_redirect_url_valid_response(self, m, _): + # Test steps 1 -> 5 -> Valid response from redirect URL -> 5 + clear_cache() + d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) + redirect_hostname = "httpbin.org" + redirect_url = f"https://{redirect_hostname}/autodiscover/autodiscover.svc" + ews_url = f"https://{redirect_hostname}/EWS/Exchange.asmx" + email = f"john@redirected.{redirect_hostname}" + m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_url_xml(redirect_url)) + m.head(redirect_url, status_code=200) + m.post(redirect_url, status_code=200, content=self.settings_xml(email, ews_url)) + + ad_response, _ = d.discover() + self.assertEqual(ad_response.autodiscover_smtp_address, email) + self.assertEqual(ad_response.ews_url, ews_url) + + def test_get_srv_records(self): + ad = Autodiscovery("foo@example.com") + # Unknown domain + self.assertEqual(ad._get_srv_records("example.XXXXX"), []) + # No SRV record + self.assertEqual(ad._get_srv_records("example.com"), []) + # Finding a real server that has a correct SRV record is not easy. Mock it + _orig = dns.resolver.Resolver + + class _Mock1: + @staticmethod + def resolve(*args, **kwargs): + class A: + @staticmethod + def to_text(): + # Return a valid record + return "1 2 3 example.com." + + return [A()] + + dns.resolver.Resolver = _Mock1 + del ad.resolver + # Test a valid record + self.assertEqual( + ad._get_srv_records("example.com."), [SrvRecord(priority=1, weight=2, port=3, srv="example.com")] + ) + + class _Mock2: + @staticmethod + def resolve(*args, **kwargs): + class A: + @staticmethod + def to_text(): + # Return malformed data + return "XXXXXXX" + + return [A()] + + dns.resolver.Resolver = _Mock2 + del ad.resolver + # Test an invalid record + self.assertEqual(ad._get_srv_records("example.com"), []) + dns.resolver.Resolver = _orig + del ad.resolver + + def test_select_srv_host(self): + with self.assertRaises(ValueError): + # Empty list + _select_srv_host([]) + with self.assertRaises(ValueError): + # No records with TLS port + _select_srv_host([SrvRecord(priority=1, weight=2, port=3, srv="example.com")]) + # One record + self.assertEqual( + _select_srv_host([SrvRecord(priority=1, weight=2, port=443, srv="example.com")]), "example.com" + ) + # Highest priority record + self.assertEqual( + _select_srv_host( + [ + SrvRecord(priority=10, weight=2, port=443, srv="10.example.com"), + SrvRecord(priority=1, weight=2, port=443, srv="1.example.com"), + ] + ), + "10.example.com", + ) + # Highest priority record no matter how it's sorted + self.assertEqual( + _select_srv_host( + [ + SrvRecord(priority=1, weight=2, port=443, srv="1.example.com"), + SrvRecord(priority=10, weight=2, port=443, srv="10.example.com"), + ] + ), + "10.example.com", + ) + + def test_parse_response(self): + # Test parsing of various XML responses + with self.assertRaises(ParseError) as e: + Autodiscover.from_bytes(b"XXX") # Invalid response + self.assertEqual(e.exception.args[0], "Response is not XML: b'XXX'") + + xml = b"""bar""" + with self.assertRaises(ParseError) as e: + Autodiscover.from_bytes(xml) # Invalid XML response + self.assertEqual( + e.exception.args[0], + 'Unknown root element in XML: b\'bar\'', + ) + + # Redirect to different email address + xml = b"""\ + + + + + john@demo.affect-it.dk + + + redirectAddr + foo@example.com + + +""" + self.assertEqual(Autodiscover.from_bytes(xml).response.redirect_address, "foo@example.com") + + # Redirect to different URL + xml = b"""\ + + + + + john@demo.affect-it.dk + + + redirectUrl + https://example.com/foo.asmx + + +""" + self.assertEqual(Autodiscover.from_bytes(xml).response.redirect_url, "https://example.com/foo.asmx") + + # Select EXPR if it's there, and there are multiple available + xml = b"""\ + + + + + john@demo.affect-it.dk + + + email + settings + + EXCH + https://exch.example.com/EWS/Exchange.asmx + + + EXPR + https://expr.example.com/EWS/Exchange.asmx + + + +""" + self.assertEqual( + Autodiscover.from_bytes(xml).response.protocol.ews_url, "https://expr.example.com/EWS/Exchange.asmx" + ) + + # Select EXPR if EXPR is unavailable + xml = b"""\ + + + + + john@demo.affect-it.dk + + + email + settings + + EXCH + https://exch.example.com/EWS/Exchange.asmx + + + +""" + self.assertEqual( + Autodiscover.from_bytes(xml).response.protocol.ews_url, "https://exch.example.com/EWS/Exchange.asmx" + ) + + # Fail if neither EXPR nor EXPR are unavailable + xml = b"""\ + + + + + john@demo.affect-it.dk + + + email + settings + + XXX + https://xxx.example.com/EWS/Exchange.asmx + + + +""" + with self.assertRaises(ValueError): + _ = Autodiscover.from_bytes(xml).response.protocol.ews_url + + def test_raise_errors(self): + with self.assertRaises(AutoDiscoverFailed) as e: + Autodiscover().raise_errors() + self.assertEqual(e.exception.args[0], "Unknown autodiscover error response: None") + with self.assertRaises(AutoDiscoverFailed) as e: + Autodiscover(error_response=ErrorResponse(error=Error(code="YYY", message="XXX"))).raise_errors() + self.assertEqual(e.exception.args[0], "Unknown error YYY: XXX") + with self.assertRaises(ErrorNonExistentMailbox) as e: + Autodiscover( + error_response=ErrorResponse(error=Error(message="The e-mail address cannot be found.")) + ).raise_errors() + self.assertEqual(e.exception.args[0], "The SMTP address has no mailbox associated with it") + + def test_del_on_error(self): + # Test that __del__ can handle exceptions on close() + tmp = AutodiscoverCache.close + cache = AutodiscoverCache() + AutodiscoverCache.close = Mock(side_effect=Exception("XXX")) + with self.assertRaises(Exception): + cache.close() + del cache + AutodiscoverCache.close = tmp + + def test_shelve_filename(self): + major, minor = sys.version_info[:2] + self.assertEqual(shelve_filename(), f"exchangelib.2.cache.{getpass.getuser()}.py{major}{minor}") + + @patch("getpass.getuser", side_effect=KeyError()) + def test_shelve_filename_getuser_failure(self, m): + # Test that shelve_filename can handle a failing getuser() + major, minor = sys.version_info[:2] + self.assertEqual(shelve_filename(), f"exchangelib.2.cache.exchangelib.py{major}{minor}") + + @requests_mock.mock(real_http=False) + def test_redirect_url_is_valid(self, m): + # This method is private but hard to get to otherwise + a = Autodiscovery("john@example.com") + + # Already visited + a._urls_visited.append("https://example.com") + self.assertFalse(a._redirect_url_is_valid("https://example.com")) + a._urls_visited.clear() + + # Max redirects exceeded + a._redirect_count = 10 + self.assertFalse(a._redirect_url_is_valid("https://example.com")) + a._redirect_count = 0 + + # Must be secure + self.assertFalse(a._redirect_url_is_valid("http://example.com")) + + # Does not resolve with DNS + url = f"https://{get_random_hostname()}" + m.head(url, status_code=200) + self.assertFalse(a._redirect_url_is_valid(url)) + + # Bad response from URL on valid hostname + m.head(self.account.protocol.config.service_endpoint, status_code=501) + self.assertTrue(a._redirect_url_is_valid(self.account.protocol.config.service_endpoint)) + + # OK response from URL on valid hostname + m.head(self.account.protocol.config.service_endpoint, status_code=200) + self.assertTrue(a._redirect_url_is_valid(self.account.protocol.config.service_endpoint)) + + def test_protocol_default_values(self): + # Test that retry_policy and auth_type always get a value regardless of how we create an Account + self.get_account() + a = Account( + self.account.primary_smtp_address, + autodiscover="soap", + config=self.account.protocol.config, + ) + self.assertIsNotNone(a.protocol.auth_type) + self.assertIsNotNone(a.protocol.retry_policy) + + a = Account( + self.account.primary_smtp_address, autodiscover="soap", credentials=self.account.protocol.credentials + ) + self.assertIsNotNone(a.protocol.auth_type) + self.assertIsNotNone(a.protocol.retry_policy) From 1bb0e9c8f8c0482c2bd12ee49cb999e3cfef3c33 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 23 Nov 2022 01:10:49 +0100 Subject: [PATCH 307/509] Re-generate docs --- docs/exchangelib/account.html | 178 ++- docs/exchangelib/attachments.html | 6 +- docs/exchangelib/autodiscover/cache.html | 39 +- .../autodiscover/discovery/base.html | 1038 +++++++++++++++++ .../autodiscover/discovery/index.html | 75 ++ .../autodiscover/discovery/pox.html | 483 ++++++++ .../autodiscover/discovery/soap.html | 327 ++++++ docs/exchangelib/autodiscover/index.html | 629 +++------- docs/exchangelib/autodiscover/properties.html | 39 +- docs/exchangelib/autodiscover/protocol.html | 181 ++- docs/exchangelib/configuration.html | 17 +- docs/exchangelib/credentials.html | 871 ++++++++------ docs/exchangelib/ewsdatetime.html | 62 +- docs/exchangelib/extended_properties.html | 12 +- docs/exchangelib/fields.html | 223 ++-- docs/exchangelib/folders/base.html | 89 +- docs/exchangelib/folders/collections.html | 16 +- docs/exchangelib/folders/index.html | 337 +++--- docs/exchangelib/folders/known_folders.html | 323 +++-- docs/exchangelib/folders/queryset.html | 22 +- docs/exchangelib/folders/roots.html | 22 +- docs/exchangelib/index.html | 921 ++++++--------- docs/exchangelib/items/calendar_item.html | 24 +- docs/exchangelib/items/index.html | 20 +- docs/exchangelib/items/message.html | 12 +- docs/exchangelib/properties.html | 581 ++++++++- docs/exchangelib/protocol.html | 370 +++--- docs/exchangelib/queryset.html | 6 +- docs/exchangelib/recurrence.html | 18 +- docs/exchangelib/restriction.html | 34 +- docs/exchangelib/services/archive_item.html | 5 +- docs/exchangelib/services/common.html | 363 ++++-- docs/exchangelib/services/convert_id.html | 19 +- docs/exchangelib/services/copy_item.html | 5 +- .../services/create_attachment.html | 5 +- docs/exchangelib/services/create_folder.html | 5 +- docs/exchangelib/services/create_item.html | 13 +- .../services/create_user_configuration.html | 5 +- .../services/delete_attachment.html | 5 +- docs/exchangelib/services/delete_folder.html | 5 +- docs/exchangelib/services/delete_item.html | 5 +- .../services/delete_user_configuration.html | 5 +- docs/exchangelib/services/empty_folder.html | 5 +- docs/exchangelib/services/expand_dl.html | 5 +- docs/exchangelib/services/export_items.html | 11 +- docs/exchangelib/services/find_folder.html | 20 +- docs/exchangelib/services/find_item.html | 12 +- docs/exchangelib/services/find_people.html | 12 +- docs/exchangelib/services/get_attachment.html | 5 +- docs/exchangelib/services/get_delegate.html | 5 +- docs/exchangelib/services/get_events.html | 5 +- docs/exchangelib/services/get_folder.html | 5 +- docs/exchangelib/services/get_item.html | 5 +- docs/exchangelib/services/get_mail_tips.html | 5 +- docs/exchangelib/services/get_persona.html | 5 +- docs/exchangelib/services/get_room_lists.html | 5 +- docs/exchangelib/services/get_rooms.html | 5 +- .../services/get_searchable_mailboxes.html | 5 +- .../services/get_server_time_zones.html | 5 +- .../services/get_streaming_events.html | 5 +- .../services/get_user_availability.html | 5 +- .../services/get_user_configuration.html | 5 +- .../services/get_user_oof_settings.html | 5 +- .../services/get_user_settings.html | 353 ++++++ docs/exchangelib/services/index.html | 630 ++++++++-- docs/exchangelib/services/mark_as_junk.html | 5 +- docs/exchangelib/services/move_folder.html | 5 +- docs/exchangelib/services/move_item.html | 5 +- docs/exchangelib/services/resolve_names.html | 22 +- docs/exchangelib/services/send_item.html | 5 +- .../services/send_notification.html | 18 +- .../services/set_user_oof_settings.html | 5 +- docs/exchangelib/services/subscribe.html | 46 +- .../services/sync_folder_hierarchy.html | 26 +- .../services/sync_folder_items.html | 12 +- docs/exchangelib/services/unsubscribe.html | 5 +- docs/exchangelib/services/update_folder.html | 14 +- docs/exchangelib/services/update_item.html | 11 +- .../services/update_user_configuration.html | 5 +- docs/exchangelib/services/upload_items.html | 5 +- docs/exchangelib/transport.html | 341 +++--- docs/exchangelib/util.html | 103 +- docs/exchangelib/version.html | 466 +++++--- 83 files changed, 6631 insertions(+), 3006 deletions(-) create mode 100644 docs/exchangelib/autodiscover/discovery/base.html create mode 100644 docs/exchangelib/autodiscover/discovery/index.html create mode 100644 docs/exchangelib/autodiscover/discovery/pox.html create mode 100644 docs/exchangelib/autodiscover/discovery/soap.html create mode 100644 docs/exchangelib/services/get_user_settings.html diff --git a/docs/exchangelib/account.html b/docs/exchangelib/account.html index 4d547a5b..a64d15ab 100644 --- a/docs/exchangelib/account.html +++ b/docs/exchangelib/account.html @@ -31,12 +31,13 @@

      Module exchangelib.account

      from cached_property import threaded_cached_property -from .autodiscover import Autodiscovery +from .autodiscover.discovery.pox import PoxAutodiscovery +from .autodiscover.discovery.soap import SoapAutodiscovery from .configuration import Configuration from .credentials import ACCESS_TYPES, DELEGATE, IMPERSONATION from .errors import InvalidEnumValue, InvalidTypeError, UnknownTimeZone from .ewsdatetime import UTC, EWSTimeZone -from .fields import FieldPath +from .fields import FieldPath, TextField from .folders import ( AdminAuditLogs, ArchiveDeletedItems, @@ -83,7 +84,7 @@

      Module exchangelib.account

      VoiceMail, ) from .items import ALL_OCCURRENCES, AUTO_RESOLVE, HARD_DELETE, ID_ONLY, SAVE_ONLY, SEND_TO_NONE -from .properties import Mailbox, SendingAs +from .properties import EWSElement, Mailbox, SendingAs from .protocol import Protocol from .queryset import QuerySet from .services import ( @@ -109,36 +110,24 @@

      Module exchangelib.account

      log = getLogger(__name__) -class Identity: +class Identity(EWSElement): """Contains information that uniquely identifies an account. Currently only used for SOAP impersonation headers.""" - def __init__(self, primary_smtp_address=None, smtp_address=None, upn=None, sid=None): - """ - - :param primary_smtp_address: The primary email address associated with the account (Default value = None) - :param smtp_address: The (non-)primary email address associated with the account (Default value = None) - :param upn: (Default value = None) - :param sid: (Default value = None) - :return: - """ - self.primary_smtp_address = primary_smtp_address - self.smtp_address = smtp_address - self.upn = upn - self.sid = sid - - def __eq__(self, other): - return all(getattr(self, k) == getattr(other, k) for k in self.__dict__) - - def __hash__(self): - return hash(repr(self)) + ELEMENT_NAME = "ConnectingSID" - def __repr__(self): - return self.__class__.__name__ + repr((self.primary_smtp_address, self.smtp_address, self.upn, self.sid)) + # We have multiple options for uniquely identifying the user. Here's a prioritized list in accordance with + # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/connectingsid + sid = TextField(field_uri="SID") + upn = TextField(field_uri="PrincipalName") + smtp_address = TextField(field_uri="SmtpAddress") # The (non-)primary email address for the account + primary_smtp_address = TextField(field_uri="PrimarySmtpAddress") # The primary email address for the account class Account: """Models an Exchange server user account.""" + DEFAULT_DISCOVERY_CLS = PoxAutodiscovery + def __init__( self, primary_smtp_address, @@ -157,7 +146,7 @@

      Module exchangelib.account

      :param access_type: The access type granted to 'credentials' for this account. Valid options are 'delegate' and 'impersonation'. 'delegate' is default if 'credentials' is set. Otherwise, 'impersonation' is default. :param autodiscover: Whether to look up the EWS endpoint automatically using the autodiscover protocol. - (Default value = False) + Can also be set to "pox" or "soap" to choose the autodiscover implementation (Default value = False) :param credentials: A Credentials object containing valid credentials for this account. (Default value = None) :param config: A Configuration object containing EWS endpoint information. Required if autodiscover is disabled (Default value = None) @@ -204,7 +193,12 @@

      Module exchangelib.account

      credentials = config.credentials else: auth_type, retry_policy, version = None, None, None - self.ad_response, self.protocol = Autodiscovery( + discovery_cls = { + "pox": PoxAutodiscovery, + "soap": SoapAutodiscovery, + True: self.DEFAULT_DISCOVERY_CLS, + }[autodiscover] + self.ad_response, self.protocol = discovery_cls( email=primary_smtp_address, credentials=credentials ).discover() # Let's not use the auth_package hint from the AD response. It could be incorrect and we can just guess. @@ -810,7 +804,7 @@

      Classes

      :param access_type: The access type granted to 'credentials' for this account. Valid options are 'delegate' and 'impersonation'. 'delegate' is default if 'credentials' is set. Otherwise, 'impersonation' is default. :param autodiscover: Whether to look up the EWS endpoint automatically using the autodiscover protocol. -(Default value = False) +Can also be set to "pox" or "soap" to choose the autodiscover implementation (Default value = False) :param credentials: A Credentials object containing valid credentials for this account. (Default value = None) :param config: A Configuration object containing EWS endpoint information. Required if autodiscover is disabled (Default value = None) @@ -825,6 +819,8 @@

      Classes

      class Account:
           """Models an Exchange server user account."""
       
      +    DEFAULT_DISCOVERY_CLS = PoxAutodiscovery
      +
           def __init__(
               self,
               primary_smtp_address,
      @@ -843,7 +839,7 @@ 

      Classes

      :param access_type: The access type granted to 'credentials' for this account. Valid options are 'delegate' and 'impersonation'. 'delegate' is default if 'credentials' is set. Otherwise, 'impersonation' is default. :param autodiscover: Whether to look up the EWS endpoint automatically using the autodiscover protocol. - (Default value = False) + Can also be set to "pox" or "soap" to choose the autodiscover implementation (Default value = False) :param credentials: A Credentials object containing valid credentials for this account. (Default value = None) :param config: A Configuration object containing EWS endpoint information. Required if autodiscover is disabled (Default value = None) @@ -890,7 +886,12 @@

      Classes

      credentials = config.credentials else: auth_type, retry_policy, version = None, None, None - self.ad_response, self.protocol = Autodiscovery( + discovery_cls = { + "pox": PoxAutodiscovery, + "soap": SoapAutodiscovery, + True: self.DEFAULT_DISCOVERY_CLS, + }[autodiscover] + self.ad_response, self.protocol = discovery_cls( email=primary_smtp_address, credentials=credentials ).discover() # Let's not use the auth_package hint from the AD response. It could be incorrect and we can just guess. @@ -1475,6 +1476,28 @@

      Classes

      return f"{self.primary_smtp_address} ({self.fullname})" return self.primary_smtp_address
      +

      Class variables

      +
      +
      var DEFAULT_DISCOVERY_CLS
      +
      +

      Autodiscover is a Microsoft protocol for automatically getting the endpoint of the Exchange server and other +connection-related settings holding the email address using only the email address, and username and password of the +user.

      +

      For a description of the protocol implemented, see "Autodiscover for Exchange ActiveSync developers":

      +

      https://docs.microsoft.com/en-us/previous-versions/office/developer/exchange-server-interoperability-guidance/hh352638%28v%3dexchg.140%29

      +

      Descriptions of the steps from the article are provided in their respective methods in this class.

      +

      For a description of how to handle autodiscover error messages, see:

      +

      https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/handling-autodiscover-error-messages

      +

      A tip from the article: +The client can perform steps 1 through 4 in any order or in parallel to expedite the process, but it must wait for +responses to finish at each step before proceeding. Given that many organizations prefer to use the URL in step 2 to +set up the Autodiscover service, the client might try this step first.

      +

      Another possibly newer resource which has not yet been attempted is "Outlook 2016 Implementation of Autodiscover": +https://support.microsoft.com/en-us/help/3211279/outlook-2016-implementation-of-autodiscover

      +

      WARNING: The autodiscover protocol is very complicated. If you have problems autodiscovering using this +implementation, start by doing an official test at https://testconnectivity.microsoft.com

      +
      +

      Instance variables

      var admin_audit_logs
      @@ -3050,45 +3073,71 @@

      Methods

      class Identity -(primary_smtp_address=None, smtp_address=None, upn=None, sid=None) +(**kwargs)
      -

      Contains information that uniquely identifies an account. Currently only used for SOAP impersonation headers.

      -

      :param primary_smtp_address: The primary email address associated with the account (Default value = None) -:param smtp_address: The (non-)primary email address associated with the account (Default value = None) -:param upn: (Default value = None) -:param sid: (Default value = None) -:return:

      +

      Contains information that uniquely identifies an account. Currently only used for SOAP impersonation headers.

      Expand source code -
      class Identity:
      +
      class Identity(EWSElement):
           """Contains information that uniquely identifies an account. Currently only used for SOAP impersonation headers."""
       
      -    def __init__(self, primary_smtp_address=None, smtp_address=None, upn=None, sid=None):
      -        """
      -
      -        :param primary_smtp_address: The primary email address associated with the account (Default value = None)
      -        :param smtp_address: The (non-)primary email address associated with the account (Default value = None)
      -        :param upn: (Default value = None)
      -        :param sid: (Default value = None)
      -        :return:
      -        """
      -        self.primary_smtp_address = primary_smtp_address
      -        self.smtp_address = smtp_address
      -        self.upn = upn
      -        self.sid = sid
      -
      -    def __eq__(self, other):
      -        return all(getattr(self, k) == getattr(other, k) for k in self.__dict__)
      +    ELEMENT_NAME = "ConnectingSID"
       
      -    def __hash__(self):
      -        return hash(repr(self))
      -
      -    def __repr__(self):
      -        return self.__class__.__name__ + repr((self.primary_smtp_address, self.smtp_address, self.upn, self.sid))
      + # We have multiple options for uniquely identifying the user. Here's a prioritized list in accordance with + # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/connectingsid + sid = TextField(field_uri="SID") + upn = TextField(field_uri="PrincipalName") + smtp_address = TextField(field_uri="SmtpAddress") # The (non-)primary email address for the account + primary_smtp_address = TextField(field_uri="PrimarySmtpAddress") # The primary email address for the account
      +

      Ancestors

      + +

      Class variables

      +
      +
      var ELEMENT_NAME
      +
      +
      +
      +
      var FIELDS
      +
      +
      +
      +
      +

      Instance variables

      +
      +
      var primary_smtp_address
      +
      +
      +
      +
      var sid
      +
      +
      +
      +
      var smtp_address
      +
      +
      +
      +
      var upn
      +
      +
      +
      +
      +

      Inherited members

      +
      @@ -3109,6 +3158,7 @@

      Index

    • Account

    • @@ -3182,4 +3240,4 @@

      pdoc 0.10.0.

      - \ No newline at end of file + diff --git a/docs/exchangelib/attachments.html b/docs/exchangelib/attachments.html index 4f0fe14d..3dfd3094 100644 --- a/docs/exchangelib/attachments.html +++ b/docs/exchangelib/attachments.html @@ -162,7 +162,7 @@

      Module exchangelib.attachments

      return self._fp def _init_fp(self): - # Create a file-like object for the attachment content. We try hard to reduce memory consumption so we never + # Create a file-like object for the attachment content. We try hard to reduce memory consumption, so we never # store the full attachment content in-memory. if not self.parent_item or not self.parent_item.account: raise ValueError(f"{self.__class__.__name__} must have an account") @@ -648,7 +648,7 @@

      Inherited members

      return self._fp def _init_fp(self): - # Create a file-like object for the attachment content. We try hard to reduce memory consumption so we never + # Create a file-like object for the attachment content. We try hard to reduce memory consumption, so we never # store the full attachment content in-memory. if not self.parent_item or not self.parent_item.account: raise ValueError(f"{self.__class__.__name__} must have an account") @@ -1150,4 +1150,4 @@

      pdoc 0.10.0.

      - \ No newline at end of file + diff --git a/docs/exchangelib/autodiscover/cache.html b/docs/exchangelib/autodiscover/cache.html index d1bdfd91..907e0b57 100644 --- a/docs/exchangelib/autodiscover/cache.html +++ b/docs/exchangelib/autodiscover/cache.html @@ -27,13 +27,12 @@

      Module exchangelib.autodiscover.cache

      Expand source code
      import getpass
      -import glob
       import logging
      -import os
       import shelve
       import sys
       import tempfile
       from contextlib import contextmanager, suppress
      +from pathlib import Path
       from threading import RLock
       
       from ..configuration import Configuration
      @@ -57,30 +56,30 @@ 

      Module exchangelib.autodiscover.cache

      return f"exchangelib.{version}.cache.{user}.py{major}{minor}" -AUTODISCOVER_PERSISTENT_STORAGE = os.path.join(tempfile.gettempdir(), shelve_filename()) +AUTODISCOVER_PERSISTENT_STORAGE = Path(tempfile.gettempdir(), shelve_filename()) @contextmanager -def shelve_open_with_failover(filename): +def shelve_open_with_failover(file): # We can expect empty or corrupt files. Whatever happens, just delete the cache file and try again. # 'shelve' may add a backend-specific suffix to the file, so also delete all files with a suffix. # We don't know which file caused the error, so just delete them all. try: - shelve_handle = shelve.open(filename) + shelve_handle = shelve.open(str(file)) # Try to actually use the file. Some implementations may allow opening the file but then throw # errors on access. with suppress(KeyError): _ = shelve_handle[""] except Exception as e: - for f in glob.glob(filename + "*"): + for f in file.parent.glob(f"{file.name}*"): log.warning("Deleting invalid cache file %s (%r)", f, e) - os.unlink(f) - shelve_handle = shelve.open(filename) + f.unlink() + shelve_handle = shelve.open(str(file)) yield shelve_handle class AutodiscoverCache: - """Stores the translation from (email domain, credentials) -> AutodiscoverProtocol object so we can re-use TCP + """Stores the translation from (email domain, credentials) -> AutodiscoverProtocol object, so we can re-use TCP connections to an autodiscover server within the same process. Also persists the email domain -> (autodiscover endpoint URL, auth_type) translation to the filesystem so the cache can be shared between multiple processes. @@ -89,7 +88,7 @@

      Module exchangelib.autodiscover.cache

      advice. But it could save some valuable seconds every time we start a new connection to a known server. In any case, the persistent storage must not contain any sensitive information since the cache could be readable by unprivileged users. Domain, endpoint and auth_type are OK to cache since this info is make publicly available on - HTTP and DNS servers via the autodiscover protocol. Just don't persist any credentials info. + HTTP and DNS servers via the autodiscover protocol. Just don't persist any credential info. If an autodiscover lookup fails for any reason, the corresponding cache entry must be purged. @@ -212,7 +211,7 @@

      Functions

      -def shelve_open_with_failover(filename) +def shelve_open_with_failover(file)
      @@ -221,21 +220,21 @@

      Functions

      Expand source code
      @contextmanager
      -def shelve_open_with_failover(filename):
      +def shelve_open_with_failover(file):
           # We can expect empty or corrupt files. Whatever happens, just delete the cache file and try again.
           # 'shelve' may add a backend-specific suffix to the file, so also delete all files with a suffix.
           # We don't know which file caused the error, so just delete them all.
           try:
      -        shelve_handle = shelve.open(filename)
      +        shelve_handle = shelve.open(str(file))
               # Try to actually use the file. Some implementations may allow opening the file but then throw
               # errors on access.
               with suppress(KeyError):
                   _ = shelve_handle[""]
           except Exception as e:
      -        for f in glob.glob(filename + "*"):
      +        for f in file.parent.glob(f"{file.name}*"):
                   log.warning("Deleting invalid cache file %s (%r)", f, e)
      -            os.unlink(f)
      -        shelve_handle = shelve.open(filename)
      +            f.unlink()
      +        shelve_handle = shelve.open(str(file))
           yield shelve_handle
      @@ -248,7 +247,7 @@

      Classes

      class AutodiscoverCache

  • -

    Stores the translation from (email domain, credentials) -> AutodiscoverProtocol object so we can re-use TCP +

    Stores the translation from (email domain, credentials) -> AutodiscoverProtocol object, so we can re-use TCP connections to an autodiscover server within the same process. Also persists the email domain -> (autodiscover endpoint URL, auth_type) translation to the filesystem so the cache can be shared between multiple processes.

    According to Microsoft, we may forever cache the (email domain -> autodiscover endpoint URL) mapping, or until @@ -256,7 +255,7 @@

    Classes

    advice. But it could save some valuable seconds every time we start a new connection to a known server. In any case, the persistent storage must not contain any sensitive information since the cache could be readable by unprivileged users. Domain, endpoint and auth_type are OK to cache since this info is make publicly available on -HTTP and DNS servers via the autodiscover protocol. Just don't persist any credentials info.

    +HTTP and DNS servers via the autodiscover protocol. Just don't persist any credential info.

    If an autodiscover lookup fails for any reason, the corresponding cache entry must be purged.

    'shelve' is supposedly thread-safe and process-safe, which suits our needs.

    @@ -264,7 +263,7 @@

    Classes

    Expand source code
    class AutodiscoverCache:
    -    """Stores the translation from (email domain, credentials) -> AutodiscoverProtocol object so we can re-use TCP
    +    """Stores the translation from (email domain, credentials) -> AutodiscoverProtocol object, so we can re-use TCP
         connections to an autodiscover server within the same process. Also persists the email domain -> (autodiscover
         endpoint URL, auth_type) translation to the filesystem so the cache can be shared between multiple processes.
     
    @@ -273,7 +272,7 @@ 

    Classes

    advice. But it could save some valuable seconds every time we start a new connection to a known server. In any case, the persistent storage must not contain any sensitive information since the cache could be readable by unprivileged users. Domain, endpoint and auth_type are OK to cache since this info is make publicly available on - HTTP and DNS servers via the autodiscover protocol. Just don't persist any credentials info. + HTTP and DNS servers via the autodiscover protocol. Just don't persist any credential info. If an autodiscover lookup fails for any reason, the corresponding cache entry must be purged. diff --git a/docs/exchangelib/autodiscover/discovery/base.html b/docs/exchangelib/autodiscover/discovery/base.html new file mode 100644 index 00000000..f129e941 --- /dev/null +++ b/docs/exchangelib/autodiscover/discovery/base.html @@ -0,0 +1,1038 @@ + + + + + + +exchangelib.autodiscover.discovery.base API documentation + + + + + + + + + + + +
    +
    +
    +

    Module exchangelib.autodiscover.discovery.base

    +
    +
    +
    + +Expand source code + +
    import abc
    +import logging
    +from urllib.parse import urlparse
    +
    +import dns.name
    +import dns.resolver
    +from cached_property import threaded_cached_property
    +
    +from ...errors import AutoDiscoverCircularRedirect, AutoDiscoverFailed, TransportError
    +from ...protocol import FailFast
    +from ...util import DummyResponse, get_domain, get_redirect_url
    +from ..cache import autodiscover_cache
    +from ..protocol import AutodiscoverProtocol
    +
    +log = logging.getLogger(__name__)
    +
    +DNS_LOOKUP_ERRORS = (
    +    dns.name.EmptyLabel,
    +    dns.resolver.NXDOMAIN,
    +    dns.resolver.NoAnswer,
    +    dns.resolver.NoNameservers,
    +)
    +
    +
    +class SrvRecord:
    +    """A container for autodiscover-related SRV records in DNS."""
    +
    +    def __init__(self, priority, weight, port, srv):
    +        self.priority = priority
    +        self.weight = weight
    +        self.port = port
    +        self.srv = srv
    +
    +    def __eq__(self, other):
    +        return all(getattr(self, k) == getattr(other, k) for k in self.__dict__)
    +
    +
    +class BaseAutodiscovery(metaclass=abc.ABCMeta):
    +    """Autodiscover is a Microsoft protocol for automatically getting the endpoint of the Exchange server and other
    +    connection-related settings holding the email address using only the email address, and username and password of the
    +    user.
    +
    +    For a description of the protocol implemented, see "Autodiscover for Exchange ActiveSync developers":
    +
    +    https://docs.microsoft.com/en-us/previous-versions/office/developer/exchange-server-interoperability-guidance/hh352638%28v%3dexchg.140%29
    +
    +    Descriptions of the steps from the article are provided in their respective methods in this class.
    +
    +    For a description of how to handle autodiscover error messages, see:
    +
    +    https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/handling-autodiscover-error-messages
    +
    +    A tip from the article:
    +    The client can perform steps 1 through 4 in any order or in parallel to expedite the process, but it must wait for
    +    responses to finish at each step before proceeding. Given that many organizations prefer to use the URL in step 2 to
    +    set up the Autodiscover service, the client might try this step first.
    +
    +    Another possibly newer resource which has not yet been attempted is "Outlook 2016 Implementation of Autodiscover":
    +    https://support.microsoft.com/en-us/help/3211279/outlook-2016-implementation-of-autodiscover
    +
    +    WARNING: The autodiscover protocol is very complicated. If you have problems autodiscovering using this
    +    implementation, start by doing an official test at https://testconnectivity.microsoft.com
    +    """
    +
    +    # When connecting to servers that may not be serving the correct endpoint, we should use a retry policy that does
    +    # not leave us hanging for a long time on each step in the protocol.
    +    INITIAL_RETRY_POLICY = FailFast()
    +    RETRY_WAIT = 10  # Seconds to wait before retry on connection errors
    +    MAX_REDIRECTS = 10  # Maximum number of URL redirects before we give up
    +    DNS_RESOLVER_KWARGS = {}
    +    DNS_RESOLVER_ATTRS = {
    +        "timeout": AutodiscoverProtocol.TIMEOUT / 2.5,  # Timeout for query to a single nameserver
    +    }
    +    DNS_RESOLVER_LIFETIME = AutodiscoverProtocol.TIMEOUT  # Total timeout for a query in case of multiple nameservers
    +    URL_PATH = None
    +
    +    def __init__(self, email, credentials=None):
    +        """
    +
    +        :param email: The email address to autodiscover
    +        :param credentials: Credentials with authorization to make autodiscover lookups for this Account
    +            (Default value = None)
    +        """
    +        self.email = email
    +        self.credentials = credentials
    +        self._urls_visited = []  # Collects HTTP and Autodiscover redirects
    +        self._redirect_count = 0
    +        self._emails_visited = []  # Collects Autodiscover email redirects
    +
    +    def discover(self):
    +        self._emails_visited.append(self.email.lower())
    +
    +        # Check the autodiscover cache to see if we already know the autodiscover service endpoint for this email
    +        # domain. Use a lock to guard against multiple threads competing to cache information.
    +        log.debug("Waiting for autodiscover_cache lock")
    +        with autodiscover_cache:
    +            log.debug("autodiscover_cache lock acquired")
    +            cache_key = self._cache_key
    +            domain = get_domain(self.email)
    +            if cache_key in autodiscover_cache:
    +                ad_protocol = autodiscover_cache[cache_key]
    +                log.debug("Cache hit for key %s: %s", cache_key, ad_protocol.service_endpoint)
    +                try:
    +                    ad = self._quick(protocol=ad_protocol)
    +                except AutoDiscoverFailed:
    +                    # Autodiscover no longer works with this domain. Clear cache and try again after releasing the lock
    +                    log.debug("AD request failure. Removing cache for key %s", cache_key)
    +                    del autodiscover_cache[cache_key]
    +                    ad = self._step_1(hostname=domain)
    +            else:
    +                # This will cache the result
    +                log.debug("Cache miss for key %s", cache_key)
    +                ad = self._step_1(hostname=domain)
    +
    +        log.debug("Released autodiscover_cache_lock")
    +        if ad.redirect_address:
    +            log.debug("Got a redirect address: %s", ad.redirect_address)
    +            if ad.redirect_address.lower() in self._emails_visited:
    +                raise AutoDiscoverCircularRedirect("We were redirected to an email address we have already seen")
    +
    +            # Start over, but with the new email address
    +            self.email = ad.redirect_address
    +            return self.discover()
    +
    +        # We successfully received a response. Clear the cache of seen emails etc.
    +        self.clear()
    +        return self._build_response(ad_response=ad)
    +
    +    def clear(self):
    +        # This resets cached variables
    +        self._urls_visited = []
    +        self._redirect_count = 0
    +        self._emails_visited = []
    +
    +    @property
    +    def _cache_key(self):
    +        # We may be using multiple different credentials and changing our minds on TLS verification. This key
    +        # combination should be safe for caching.
    +        domain = get_domain(self.email)
    +        return domain, self.credentials
    +
    +    @threaded_cached_property
    +    def resolver(self):
    +        resolver = dns.resolver.Resolver(**self.DNS_RESOLVER_KWARGS)
    +        for k, v in self.DNS_RESOLVER_ATTRS.items():
    +            setattr(resolver, k, v)
    +        return resolver
    +
    +    @abc.abstractmethod
    +    def _build_response(self, ad_response):
    +        pass
    +
    +    @abc.abstractmethod
    +    def _quick(self, protocol):
    +        pass
    +
    +    def _redirect_url_is_valid(self, url):
    +        """Three separate responses can be “Redirect responses”:
    +        * An HTTP status code (301, 302) with a new URL
    +        * An HTTP status code of 200, but with a payload XML containing a redirect to a different URL
    +        * An HTTP status code of 200, but with a payload XML containing a different SMTP address as the target address
    +
    +        We only handle the HTTP 302 redirects here. We validate the URL received in the redirect response to ensure that
    +        it does not redirect to non-SSL endpoints or SSL endpoints with invalid certificates, and that the redirect is
    +        not circular. Finally, we should fail after 10 redirects.
    +
    +        :param url:
    +        :return:
    +        """
    +        if url.lower() in self._urls_visited:
    +            log.warning("We have already tried this URL: %s", url)
    +            return False
    +
    +        if self._redirect_count >= self.MAX_REDIRECTS:
    +            log.warning("We reached max redirects at URL: %s", url)
    +            return False
    +
    +        # We require TLS endpoints
    +        if not url.startswith("https://"):
    +            log.debug("Invalid scheme for URL: %s", url)
    +            return False
    +
    +        # Quick test that the endpoint responds and that TLS handshake is OK
    +        try:
    +            self._get_unauthenticated_response(url, method="head")
    +        except TransportError as e:
    +            log.debug("Response error on redirect URL %s: %s", url, e)
    +            return False
    +
    +        self._redirect_count += 1
    +        return True
    +
    +    @abc.abstractmethod
    +    def _get_unauthenticated_response(self, url, method="post"):
    +        pass
    +
    +    @abc.abstractmethod
    +    def _attempt_response(self, url):
    +        pass
    +
    +    def _ensure_valid_hostname(self, url):
    +        hostname = urlparse(url).netloc
    +        log.debug("Checking if %s can be looked up in DNS", hostname)
    +        try:
    +            self.resolver.resolve(f"{hostname}.", "A", lifetime=self.DNS_RESOLVER_LIFETIME)
    +        except DNS_LOOKUP_ERRORS as e:
    +            log.debug("DNS A lookup failure: %s", e)
    +            # 'requests' is bad at reporting that a hostname cannot be resolved. Let's check this separately.
    +            # Don't retry on DNS errors. They will most likely be persistent.
    +            raise TransportError(f"{hostname!r} has no DNS entry")
    +
    +    def _get_srv_records(self, hostname):
    +        """Send a DNS query for SRV entries for the hostname.
    +
    +        An SRV entry that has been formatted for autodiscovery will have the following format:
    +
    +            canonical name = mail.example.com.
    +            service = 8 100 443 webmail.example.com.
    +
    +        The first three numbers in the service line are: priority, weight, port
    +
    +        :param hostname:
    +        :return:
    +        """
    +        log.debug("Attempting to get SRV records for %s", hostname)
    +        records = []
    +        try:
    +            answers = self.resolver.resolve(f"{hostname}.", "SRV", lifetime=self.DNS_RESOLVER_LIFETIME)
    +        except DNS_LOOKUP_ERRORS as e:
    +            log.debug("DNS SRV lookup failure: %s", e)
    +            return records
    +        for rdata in answers:
    +            try:
    +                vals = rdata.to_text().strip().rstrip(".").split(" ")
    +                # Raise ValueError if the first three are not ints, and IndexError if there are less than 4 values
    +                priority, weight, port, srv = int(vals[0]), int(vals[1]), int(vals[2]), vals[3]
    +                record = SrvRecord(priority=priority, weight=weight, port=port, srv=srv)
    +                log.debug("Found SRV record %s ", record)
    +                records.append(record)
    +            except (ValueError, IndexError):
    +                log.debug("Incompatible SRV record for %s (%s)", hostname, rdata.to_text())
    +        return records
    +
    +    def _step_1(self, hostname):
    +        """Perform step 1, where the client sends an Autodiscover request to
    +        https://example.com/ and then does one of the following:
    +            * If the Autodiscover attempt succeeds, the client proceeds to step 5.
    +            * If the Autodiscover attempt fails, the client proceeds to step 2.
    +
    +        :param hostname:
    +        :return:
    +        """
    +        url = f"https://{hostname}/{self.URL_PATH}"
    +        log.info("Step 1: Trying autodiscover on %r with email %r", url, self.email)
    +        is_valid_response, ad = self._attempt_response(url=url)
    +        if is_valid_response:
    +            return self._step_5(ad=ad)
    +        return self._step_2(hostname=hostname)
    +
    +    def _step_2(self, hostname):
    +        """Perform step 2, where the client sends an Autodiscover request to
    +        https://autodiscover.example.com/ and then does one of the following:
    +            * If the Autodiscover attempt succeeds, the client proceeds to step 5.
    +            * If the Autodiscover attempt fails, the client proceeds to step 3.
    +
    +        :param hostname:
    +        :return:
    +        """
    +        url = f"https://autodiscover.{hostname}/{self.URL_PATH}"
    +        log.info("Step 2: Trying autodiscover on %r with email %r", url, self.email)
    +        is_valid_response, ad = self._attempt_response(url=url)
    +        if is_valid_response:
    +            return self._step_5(ad=ad)
    +        return self._step_3(hostname=hostname)
    +
    +    def _step_3(self, hostname):
    +        """Perform step 3, where the client sends an unauthenticated GET method request to
    +        http://autodiscover.example.com/ (Note that this is a non-HTTPS endpoint). The
    +        client then does one of the following:
    +            * If the GET request returns a 302 redirect response, it gets the redirection URL from the 'Location' HTTP
    +            header and validates it as described in the "Redirect responses" section. The client then does one of the
    +            following:
    +                * If the redirection URL is valid, the client tries the URL and then does one of the following:
    +                    * If the attempt succeeds, the client proceeds to step 5.
    +                    * If the attempt fails, the client proceeds to step 4.
    +                * If the redirection URL is not valid, the client proceeds to step 4.
    +            * If the GET request does not return a 302 redirect response, the client proceeds to step 4.
    +
    +        :param hostname:
    +        :return:
    +        """
    +        url = f"http://autodiscover.{hostname}/{self.URL_PATH}"
    +        log.info("Step 3: Trying autodiscover on %r with email %r", url, self.email)
    +        try:
    +            _, r = self._get_unauthenticated_response(url=url, method="get")
    +        except TransportError:
    +            r = DummyResponse(url=url)
    +        if r.status_code in (301, 302) and "location" in r.headers:
    +            redirect_url = get_redirect_url(r)
    +            if self._redirect_url_is_valid(url=redirect_url):
    +                is_valid_response, ad = self._attempt_response(url=redirect_url)
    +                if is_valid_response:
    +                    return self._step_5(ad=ad)
    +                log.debug("Got invalid response")
    +                return self._step_4(hostname=hostname)
    +            log.debug("Got invalid redirect URL")
    +            return self._step_4(hostname=hostname)
    +        log.debug("Got no redirect URL")
    +        return self._step_4(hostname=hostname)
    +
    +    def _step_4(self, hostname):
    +        """Perform step 4, where the client performs a Domain Name System (DNS) query for an SRV record for
    +        _autodiscover._tcp.example.com. The query might return multiple records. The client selects only records that
    +        point to an SSL endpoint and that have the highest priority and weight. One of the following actions then
    +        occurs:
    +            * If no such records are returned, the client proceeds to step 6.
    +            * If records are returned, the application randomly chooses a record in the list and validates the endpoint
    +              that it points to by following the process described in the "Redirect Response" section. The client then
    +              does one of the following:
    +                * If the redirection URL is valid, the client tries the URL and then does one of the following:
    +                    * If the attempt succeeds, the client proceeds to step 5.
    +                    * If the attempt fails, the client proceeds to step 6.
    +                * If the redirection URL is not valid, the client proceeds to step 6.
    +
    +        :param hostname:
    +        :return:
    +        """
    +        dns_hostname = f"_autodiscover._tcp.{hostname}"
    +        log.info("Step 4: Trying autodiscover on %r with email %r", dns_hostname, self.email)
    +        srv_records = self._get_srv_records(dns_hostname)
    +        try:
    +            srv_host = _select_srv_host(srv_records)
    +        except ValueError:
    +            srv_host = None
    +        if not srv_host:
    +            return self._step_6()
    +        redirect_url = f"https://{srv_host}/{self.URL_PATH}"
    +        if self._redirect_url_is_valid(url=redirect_url):
    +            is_valid_response, ad = self._attempt_response(url=redirect_url)
    +            if is_valid_response:
    +                return self._step_5(ad=ad)
    +            log.debug("Got invalid response")
    +            return self._step_6()
    +        log.debug("Got invalid redirect URL")
    +        return self._step_6()
    +
    +    def _step_5(self, ad):
    +        """Perform step 5. When a valid Autodiscover request succeeds, the following sequence occurs:
    +            * If the server responds with an HTTP 302 redirect, the client validates the redirection URL according to
    +              the process defined in the "Redirect responses" and then does one of the following:
    +                * If the redirection URL is valid, the client tries the URL and then does one of the following:
    +                    * If the attempt succeeds, the client repeats step 5 from the beginning.
    +                    * If the attempt fails, the client proceeds to step 6.
    +                * If the redirection URL is not valid, the client proceeds to step 6.
    +            * If the server responds with a valid Autodiscover response, the client does one of the following:
    +                * If the value of the Action element is "Redirect", the client gets the redirection email address from
    +                  the Redirect element and then returns to step 1, using this new email address.
    +                * If the value of the Action element is "Settings", the client has successfully received the requested
    +                  configuration settings for the specified user. The client does not need to proceed to step 6.
    +
    +        :param ad:
    +        :return:
    +        """
    +        log.info("Step 5: Checking response")
    +        # This is not explicit in the protocol, but let's raise any errors here
    +        ad.raise_errors()
    +
    +        if hasattr(ad, "response"):
    +            # Hack for PoxAutodiscover
    +            ad = ad.response
    +
    +        if ad.redirect_url:
    +            log.debug("Got a redirect URL: %s", ad.redirect_url)
    +            # We are diverging a bit from the protocol here. We will never get an HTTP 302 since earlier steps already
    +            # followed the redirects where possible. Instead, we handle redirect responses here.
    +            if self._redirect_url_is_valid(url=ad.redirect_url):
    +                is_valid_response, ad = self._attempt_response(url=ad.redirect_url)
    +                if is_valid_response:
    +                    return self._step_5(ad=ad)
    +                log.debug("Got invalid response")
    +                return self._step_6()
    +            log.debug("Invalid redirect URL")
    +            return self._step_6()
    +        # This could be an email redirect. Let outer layer handle this
    +        return ad
    +
    +    def _step_6(self):
    +        """Perform step 6. If the client cannot contact the Autodiscover service, the client should ask the user for
    +        the Exchange server name and use it to construct an Exchange EWS URL. The client should try to use this URL for
    +        future requests.
    +        """
    +        raise AutoDiscoverFailed(
    +            f"All steps in the autodiscover protocol failed for email {self.email}. If you think this is an error, "
    +            f"consider doing an official test at https://testconnectivity.microsoft.com"
    +        )
    +
    +
    +def _select_srv_host(srv_records):
    +    """Select the record with the highest priority, that also supports TLS.
    +
    +    :param srv_records:
    +    :return:
    +    """
    +    best_record = None
    +    for srv_record in srv_records:
    +        if srv_record.port != 443:
    +            log.debug("Skipping SRV record %r (no TLS)", srv_record)
    +            continue
    +        # Assume port 443 will serve TLS. If not, autodiscover will probably also be broken for others.
    +        if best_record is None or best_record.priority < srv_record.priority:
    +            best_record = srv_record
    +    if not best_record:
    +        raise ValueError("No suitable records")
    +    return best_record.srv
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class BaseAutodiscovery +(email, credentials=None) +
    +
    +

    Autodiscover is a Microsoft protocol for automatically getting the endpoint of the Exchange server and other +connection-related settings holding the email address using only the email address, and username and password of the +user.

    +

    For a description of the protocol implemented, see "Autodiscover for Exchange ActiveSync developers":

    +

    https://docs.microsoft.com/en-us/previous-versions/office/developer/exchange-server-interoperability-guidance/hh352638%28v%3dexchg.140%29

    +

    Descriptions of the steps from the article are provided in their respective methods in this class.

    +

    For a description of how to handle autodiscover error messages, see:

    +

    https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/handling-autodiscover-error-messages

    +

    A tip from the article: +The client can perform steps 1 through 4 in any order or in parallel to expedite the process, but it must wait for +responses to finish at each step before proceeding. Given that many organizations prefer to use the URL in step 2 to +set up the Autodiscover service, the client might try this step first.

    +

    Another possibly newer resource which has not yet been attempted is "Outlook 2016 Implementation of Autodiscover": +https://support.microsoft.com/en-us/help/3211279/outlook-2016-implementation-of-autodiscover

    +

    WARNING: The autodiscover protocol is very complicated. If you have problems autodiscovering using this +implementation, start by doing an official test at https://testconnectivity.microsoft.com

    +

    :param email: The email address to autodiscover +:param credentials: Credentials with authorization to make autodiscover lookups for this Account +(Default value = None)

    +
    + +Expand source code + +
    class BaseAutodiscovery(metaclass=abc.ABCMeta):
    +    """Autodiscover is a Microsoft protocol for automatically getting the endpoint of the Exchange server and other
    +    connection-related settings holding the email address using only the email address, and username and password of the
    +    user.
    +
    +    For a description of the protocol implemented, see "Autodiscover for Exchange ActiveSync developers":
    +
    +    https://docs.microsoft.com/en-us/previous-versions/office/developer/exchange-server-interoperability-guidance/hh352638%28v%3dexchg.140%29
    +
    +    Descriptions of the steps from the article are provided in their respective methods in this class.
    +
    +    For a description of how to handle autodiscover error messages, see:
    +
    +    https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/handling-autodiscover-error-messages
    +
    +    A tip from the article:
    +    The client can perform steps 1 through 4 in any order or in parallel to expedite the process, but it must wait for
    +    responses to finish at each step before proceeding. Given that many organizations prefer to use the URL in step 2 to
    +    set up the Autodiscover service, the client might try this step first.
    +
    +    Another possibly newer resource which has not yet been attempted is "Outlook 2016 Implementation of Autodiscover":
    +    https://support.microsoft.com/en-us/help/3211279/outlook-2016-implementation-of-autodiscover
    +
    +    WARNING: The autodiscover protocol is very complicated. If you have problems autodiscovering using this
    +    implementation, start by doing an official test at https://testconnectivity.microsoft.com
    +    """
    +
    +    # When connecting to servers that may not be serving the correct endpoint, we should use a retry policy that does
    +    # not leave us hanging for a long time on each step in the protocol.
    +    INITIAL_RETRY_POLICY = FailFast()
    +    RETRY_WAIT = 10  # Seconds to wait before retry on connection errors
    +    MAX_REDIRECTS = 10  # Maximum number of URL redirects before we give up
    +    DNS_RESOLVER_KWARGS = {}
    +    DNS_RESOLVER_ATTRS = {
    +        "timeout": AutodiscoverProtocol.TIMEOUT / 2.5,  # Timeout for query to a single nameserver
    +    }
    +    DNS_RESOLVER_LIFETIME = AutodiscoverProtocol.TIMEOUT  # Total timeout for a query in case of multiple nameservers
    +    URL_PATH = None
    +
    +    def __init__(self, email, credentials=None):
    +        """
    +
    +        :param email: The email address to autodiscover
    +        :param credentials: Credentials with authorization to make autodiscover lookups for this Account
    +            (Default value = None)
    +        """
    +        self.email = email
    +        self.credentials = credentials
    +        self._urls_visited = []  # Collects HTTP and Autodiscover redirects
    +        self._redirect_count = 0
    +        self._emails_visited = []  # Collects Autodiscover email redirects
    +
    +    def discover(self):
    +        self._emails_visited.append(self.email.lower())
    +
    +        # Check the autodiscover cache to see if we already know the autodiscover service endpoint for this email
    +        # domain. Use a lock to guard against multiple threads competing to cache information.
    +        log.debug("Waiting for autodiscover_cache lock")
    +        with autodiscover_cache:
    +            log.debug("autodiscover_cache lock acquired")
    +            cache_key = self._cache_key
    +            domain = get_domain(self.email)
    +            if cache_key in autodiscover_cache:
    +                ad_protocol = autodiscover_cache[cache_key]
    +                log.debug("Cache hit for key %s: %s", cache_key, ad_protocol.service_endpoint)
    +                try:
    +                    ad = self._quick(protocol=ad_protocol)
    +                except AutoDiscoverFailed:
    +                    # Autodiscover no longer works with this domain. Clear cache and try again after releasing the lock
    +                    log.debug("AD request failure. Removing cache for key %s", cache_key)
    +                    del autodiscover_cache[cache_key]
    +                    ad = self._step_1(hostname=domain)
    +            else:
    +                # This will cache the result
    +                log.debug("Cache miss for key %s", cache_key)
    +                ad = self._step_1(hostname=domain)
    +
    +        log.debug("Released autodiscover_cache_lock")
    +        if ad.redirect_address:
    +            log.debug("Got a redirect address: %s", ad.redirect_address)
    +            if ad.redirect_address.lower() in self._emails_visited:
    +                raise AutoDiscoverCircularRedirect("We were redirected to an email address we have already seen")
    +
    +            # Start over, but with the new email address
    +            self.email = ad.redirect_address
    +            return self.discover()
    +
    +        # We successfully received a response. Clear the cache of seen emails etc.
    +        self.clear()
    +        return self._build_response(ad_response=ad)
    +
    +    def clear(self):
    +        # This resets cached variables
    +        self._urls_visited = []
    +        self._redirect_count = 0
    +        self._emails_visited = []
    +
    +    @property
    +    def _cache_key(self):
    +        # We may be using multiple different credentials and changing our minds on TLS verification. This key
    +        # combination should be safe for caching.
    +        domain = get_domain(self.email)
    +        return domain, self.credentials
    +
    +    @threaded_cached_property
    +    def resolver(self):
    +        resolver = dns.resolver.Resolver(**self.DNS_RESOLVER_KWARGS)
    +        for k, v in self.DNS_RESOLVER_ATTRS.items():
    +            setattr(resolver, k, v)
    +        return resolver
    +
    +    @abc.abstractmethod
    +    def _build_response(self, ad_response):
    +        pass
    +
    +    @abc.abstractmethod
    +    def _quick(self, protocol):
    +        pass
    +
    +    def _redirect_url_is_valid(self, url):
    +        """Three separate responses can be “Redirect responses”:
    +        * An HTTP status code (301, 302) with a new URL
    +        * An HTTP status code of 200, but with a payload XML containing a redirect to a different URL
    +        * An HTTP status code of 200, but with a payload XML containing a different SMTP address as the target address
    +
    +        We only handle the HTTP 302 redirects here. We validate the URL received in the redirect response to ensure that
    +        it does not redirect to non-SSL endpoints or SSL endpoints with invalid certificates, and that the redirect is
    +        not circular. Finally, we should fail after 10 redirects.
    +
    +        :param url:
    +        :return:
    +        """
    +        if url.lower() in self._urls_visited:
    +            log.warning("We have already tried this URL: %s", url)
    +            return False
    +
    +        if self._redirect_count >= self.MAX_REDIRECTS:
    +            log.warning("We reached max redirects at URL: %s", url)
    +            return False
    +
    +        # We require TLS endpoints
    +        if not url.startswith("https://"):
    +            log.debug("Invalid scheme for URL: %s", url)
    +            return False
    +
    +        # Quick test that the endpoint responds and that TLS handshake is OK
    +        try:
    +            self._get_unauthenticated_response(url, method="head")
    +        except TransportError as e:
    +            log.debug("Response error on redirect URL %s: %s", url, e)
    +            return False
    +
    +        self._redirect_count += 1
    +        return True
    +
    +    @abc.abstractmethod
    +    def _get_unauthenticated_response(self, url, method="post"):
    +        pass
    +
    +    @abc.abstractmethod
    +    def _attempt_response(self, url):
    +        pass
    +
    +    def _ensure_valid_hostname(self, url):
    +        hostname = urlparse(url).netloc
    +        log.debug("Checking if %s can be looked up in DNS", hostname)
    +        try:
    +            self.resolver.resolve(f"{hostname}.", "A", lifetime=self.DNS_RESOLVER_LIFETIME)
    +        except DNS_LOOKUP_ERRORS as e:
    +            log.debug("DNS A lookup failure: %s", e)
    +            # 'requests' is bad at reporting that a hostname cannot be resolved. Let's check this separately.
    +            # Don't retry on DNS errors. They will most likely be persistent.
    +            raise TransportError(f"{hostname!r} has no DNS entry")
    +
    +    def _get_srv_records(self, hostname):
    +        """Send a DNS query for SRV entries for the hostname.
    +
    +        An SRV entry that has been formatted for autodiscovery will have the following format:
    +
    +            canonical name = mail.example.com.
    +            service = 8 100 443 webmail.example.com.
    +
    +        The first three numbers in the service line are: priority, weight, port
    +
    +        :param hostname:
    +        :return:
    +        """
    +        log.debug("Attempting to get SRV records for %s", hostname)
    +        records = []
    +        try:
    +            answers = self.resolver.resolve(f"{hostname}.", "SRV", lifetime=self.DNS_RESOLVER_LIFETIME)
    +        except DNS_LOOKUP_ERRORS as e:
    +            log.debug("DNS SRV lookup failure: %s", e)
    +            return records
    +        for rdata in answers:
    +            try:
    +                vals = rdata.to_text().strip().rstrip(".").split(" ")
    +                # Raise ValueError if the first three are not ints, and IndexError if there are less than 4 values
    +                priority, weight, port, srv = int(vals[0]), int(vals[1]), int(vals[2]), vals[3]
    +                record = SrvRecord(priority=priority, weight=weight, port=port, srv=srv)
    +                log.debug("Found SRV record %s ", record)
    +                records.append(record)
    +            except (ValueError, IndexError):
    +                log.debug("Incompatible SRV record for %s (%s)", hostname, rdata.to_text())
    +        return records
    +
    +    def _step_1(self, hostname):
    +        """Perform step 1, where the client sends an Autodiscover request to
    +        https://example.com/ and then does one of the following:
    +            * If the Autodiscover attempt succeeds, the client proceeds to step 5.
    +            * If the Autodiscover attempt fails, the client proceeds to step 2.
    +
    +        :param hostname:
    +        :return:
    +        """
    +        url = f"https://{hostname}/{self.URL_PATH}"
    +        log.info("Step 1: Trying autodiscover on %r with email %r", url, self.email)
    +        is_valid_response, ad = self._attempt_response(url=url)
    +        if is_valid_response:
    +            return self._step_5(ad=ad)
    +        return self._step_2(hostname=hostname)
    +
    +    def _step_2(self, hostname):
    +        """Perform step 2, where the client sends an Autodiscover request to
    +        https://autodiscover.example.com/ and then does one of the following:
    +            * If the Autodiscover attempt succeeds, the client proceeds to step 5.
    +            * If the Autodiscover attempt fails, the client proceeds to step 3.
    +
    +        :param hostname:
    +        :return:
    +        """
    +        url = f"https://autodiscover.{hostname}/{self.URL_PATH}"
    +        log.info("Step 2: Trying autodiscover on %r with email %r", url, self.email)
    +        is_valid_response, ad = self._attempt_response(url=url)
    +        if is_valid_response:
    +            return self._step_5(ad=ad)
    +        return self._step_3(hostname=hostname)
    +
    +    def _step_3(self, hostname):
    +        """Perform step 3, where the client sends an unauthenticated GET method request to
    +        http://autodiscover.example.com/ (Note that this is a non-HTTPS endpoint). The
    +        client then does one of the following:
    +            * If the GET request returns a 302 redirect response, it gets the redirection URL from the 'Location' HTTP
    +            header and validates it as described in the "Redirect responses" section. The client then does one of the
    +            following:
    +                * If the redirection URL is valid, the client tries the URL and then does one of the following:
    +                    * If the attempt succeeds, the client proceeds to step 5.
    +                    * If the attempt fails, the client proceeds to step 4.
    +                * If the redirection URL is not valid, the client proceeds to step 4.
    +            * If the GET request does not return a 302 redirect response, the client proceeds to step 4.
    +
    +        :param hostname:
    +        :return:
    +        """
    +        url = f"http://autodiscover.{hostname}/{self.URL_PATH}"
    +        log.info("Step 3: Trying autodiscover on %r with email %r", url, self.email)
    +        try:
    +            _, r = self._get_unauthenticated_response(url=url, method="get")
    +        except TransportError:
    +            r = DummyResponse(url=url)
    +        if r.status_code in (301, 302) and "location" in r.headers:
    +            redirect_url = get_redirect_url(r)
    +            if self._redirect_url_is_valid(url=redirect_url):
    +                is_valid_response, ad = self._attempt_response(url=redirect_url)
    +                if is_valid_response:
    +                    return self._step_5(ad=ad)
    +                log.debug("Got invalid response")
    +                return self._step_4(hostname=hostname)
    +            log.debug("Got invalid redirect URL")
    +            return self._step_4(hostname=hostname)
    +        log.debug("Got no redirect URL")
    +        return self._step_4(hostname=hostname)
    +
    +    def _step_4(self, hostname):
    +        """Perform step 4, where the client performs a Domain Name System (DNS) query for an SRV record for
    +        _autodiscover._tcp.example.com. The query might return multiple records. The client selects only records that
    +        point to an SSL endpoint and that have the highest priority and weight. One of the following actions then
    +        occurs:
    +            * If no such records are returned, the client proceeds to step 6.
    +            * If records are returned, the application randomly chooses a record in the list and validates the endpoint
    +              that it points to by following the process described in the "Redirect Response" section. The client then
    +              does one of the following:
    +                * If the redirection URL is valid, the client tries the URL and then does one of the following:
    +                    * If the attempt succeeds, the client proceeds to step 5.
    +                    * If the attempt fails, the client proceeds to step 6.
    +                * If the redirection URL is not valid, the client proceeds to step 6.
    +
    +        :param hostname:
    +        :return:
    +        """
    +        dns_hostname = f"_autodiscover._tcp.{hostname}"
    +        log.info("Step 4: Trying autodiscover on %r with email %r", dns_hostname, self.email)
    +        srv_records = self._get_srv_records(dns_hostname)
    +        try:
    +            srv_host = _select_srv_host(srv_records)
    +        except ValueError:
    +            srv_host = None
    +        if not srv_host:
    +            return self._step_6()
    +        redirect_url = f"https://{srv_host}/{self.URL_PATH}"
    +        if self._redirect_url_is_valid(url=redirect_url):
    +            is_valid_response, ad = self._attempt_response(url=redirect_url)
    +            if is_valid_response:
    +                return self._step_5(ad=ad)
    +            log.debug("Got invalid response")
    +            return self._step_6()
    +        log.debug("Got invalid redirect URL")
    +        return self._step_6()
    +
    +    def _step_5(self, ad):
    +        """Perform step 5. When a valid Autodiscover request succeeds, the following sequence occurs:
    +            * If the server responds with an HTTP 302 redirect, the client validates the redirection URL according to
    +              the process defined in the "Redirect responses" and then does one of the following:
    +                * If the redirection URL is valid, the client tries the URL and then does one of the following:
    +                    * If the attempt succeeds, the client repeats step 5 from the beginning.
    +                    * If the attempt fails, the client proceeds to step 6.
    +                * If the redirection URL is not valid, the client proceeds to step 6.
    +            * If the server responds with a valid Autodiscover response, the client does one of the following:
    +                * If the value of the Action element is "Redirect", the client gets the redirection email address from
    +                  the Redirect element and then returns to step 1, using this new email address.
    +                * If the value of the Action element is "Settings", the client has successfully received the requested
    +                  configuration settings for the specified user. The client does not need to proceed to step 6.
    +
    +        :param ad:
    +        :return:
    +        """
    +        log.info("Step 5: Checking response")
    +        # This is not explicit in the protocol, but let's raise any errors here
    +        ad.raise_errors()
    +
    +        if hasattr(ad, "response"):
    +            # Hack for PoxAutodiscover
    +            ad = ad.response
    +
    +        if ad.redirect_url:
    +            log.debug("Got a redirect URL: %s", ad.redirect_url)
    +            # We are diverging a bit from the protocol here. We will never get an HTTP 302 since earlier steps already
    +            # followed the redirects where possible. Instead, we handle redirect responses here.
    +            if self._redirect_url_is_valid(url=ad.redirect_url):
    +                is_valid_response, ad = self._attempt_response(url=ad.redirect_url)
    +                if is_valid_response:
    +                    return self._step_5(ad=ad)
    +                log.debug("Got invalid response")
    +                return self._step_6()
    +            log.debug("Invalid redirect URL")
    +            return self._step_6()
    +        # This could be an email redirect. Let outer layer handle this
    +        return ad
    +
    +    def _step_6(self):
    +        """Perform step 6. If the client cannot contact the Autodiscover service, the client should ask the user for
    +        the Exchange server name and use it to construct an Exchange EWS URL. The client should try to use this URL for
    +        future requests.
    +        """
    +        raise AutoDiscoverFailed(
    +            f"All steps in the autodiscover protocol failed for email {self.email}. If you think this is an error, "
    +            f"consider doing an official test at https://testconnectivity.microsoft.com"
    +        )
    +
    +

    Subclasses

    + +

    Class variables

    +
    +
    var DNS_RESOLVER_ATTRS
    +
    +
    +
    +
    var DNS_RESOLVER_KWARGS
    +
    +
    +
    +
    var DNS_RESOLVER_LIFETIME
    +
    +
    +
    +
    var INITIAL_RETRY_POLICY
    +
    +
    +
    +
    var MAX_REDIRECTS
    +
    +
    +
    +
    var RETRY_WAIT
    +
    +
    +
    +
    var URL_PATH
    +
    +
    +
    +
    +

    Instance variables

    +
    +
    var resolver
    +
    +
    +
    + +Expand source code + +
    def __get__(self, obj, cls):
    +    if obj is None:
    +        return self
    +
    +    obj_dict = obj.__dict__
    +    name = self.func.__name__
    +    with self.lock:
    +        try:
    +            # check if the value was computed before the lock was acquired
    +            return obj_dict[name]
    +
    +        except KeyError:
    +            # if not, do the calculation and release the lock
    +            return obj_dict.setdefault(name, self.func(obj))
    +
    +
    +
    +

    Methods

    +
    +
    +def clear(self) +
    +
    +
    +
    + +Expand source code + +
    def clear(self):
    +    # This resets cached variables
    +    self._urls_visited = []
    +    self._redirect_count = 0
    +    self._emails_visited = []
    +
    +
    +
    +def discover(self) +
    +
    +
    +
    + +Expand source code + +
    def discover(self):
    +    self._emails_visited.append(self.email.lower())
    +
    +    # Check the autodiscover cache to see if we already know the autodiscover service endpoint for this email
    +    # domain. Use a lock to guard against multiple threads competing to cache information.
    +    log.debug("Waiting for autodiscover_cache lock")
    +    with autodiscover_cache:
    +        log.debug("autodiscover_cache lock acquired")
    +        cache_key = self._cache_key
    +        domain = get_domain(self.email)
    +        if cache_key in autodiscover_cache:
    +            ad_protocol = autodiscover_cache[cache_key]
    +            log.debug("Cache hit for key %s: %s", cache_key, ad_protocol.service_endpoint)
    +            try:
    +                ad = self._quick(protocol=ad_protocol)
    +            except AutoDiscoverFailed:
    +                # Autodiscover no longer works with this domain. Clear cache and try again after releasing the lock
    +                log.debug("AD request failure. Removing cache for key %s", cache_key)
    +                del autodiscover_cache[cache_key]
    +                ad = self._step_1(hostname=domain)
    +        else:
    +            # This will cache the result
    +            log.debug("Cache miss for key %s", cache_key)
    +            ad = self._step_1(hostname=domain)
    +
    +    log.debug("Released autodiscover_cache_lock")
    +    if ad.redirect_address:
    +        log.debug("Got a redirect address: %s", ad.redirect_address)
    +        if ad.redirect_address.lower() in self._emails_visited:
    +            raise AutoDiscoverCircularRedirect("We were redirected to an email address we have already seen")
    +
    +        # Start over, but with the new email address
    +        self.email = ad.redirect_address
    +        return self.discover()
    +
    +    # We successfully received a response. Clear the cache of seen emails etc.
    +    self.clear()
    +    return self._build_response(ad_response=ad)
    +
    +
    +
    +
    +
    +class SrvRecord +(priority, weight, port, srv) +
    +
    +

    A container for autodiscover-related SRV records in DNS.

    +
    + +Expand source code + +
    class SrvRecord:
    +    """A container for autodiscover-related SRV records in DNS."""
    +
    +    def __init__(self, priority, weight, port, srv):
    +        self.priority = priority
    +        self.weight = weight
    +        self.port = port
    +        self.srv = srv
    +
    +    def __eq__(self, other):
    +        return all(getattr(self, k) == getattr(other, k) for k in self.__dict__)
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/exchangelib/autodiscover/discovery/index.html b/docs/exchangelib/autodiscover/discovery/index.html new file mode 100644 index 00000000..2c0d34b8 --- /dev/null +++ b/docs/exchangelib/autodiscover/discovery/index.html @@ -0,0 +1,75 @@ + + + + + + +exchangelib.autodiscover.discovery API documentation + + + + + + + + + + + +
    + + +
    + + + diff --git a/docs/exchangelib/autodiscover/discovery/pox.html b/docs/exchangelib/autodiscover/discovery/pox.html new file mode 100644 index 00000000..7f9a7bbe --- /dev/null +++ b/docs/exchangelib/autodiscover/discovery/pox.html @@ -0,0 +1,483 @@ + + + + + + +exchangelib.autodiscover.discovery.pox API documentation + + + + + + + + + + + +
    +
    +
    +

    Module exchangelib.autodiscover.discovery.pox

    +
    +
    +
    + +Expand source code + +
    import logging
    +import time
    +
    +from ...configuration import Configuration
    +from ...errors import AutoDiscoverFailed, RedirectError, TransportError, UnauthorizedError
    +from ...protocol import Protocol
    +from ...transport import AUTH_TYPE_MAP, DEFAULT_HEADERS, GSSAPI, NOAUTH, get_auth_method_from_response
    +from ...util import (
    +    CONNECTION_ERRORS,
    +    TLS_ERRORS,
    +    DummyResponse,
    +    ParseError,
    +    _back_off_if_needed,
    +    get_redirect_url,
    +    post_ratelimited,
    +)
    +from ..cache import autodiscover_cache
    +from ..properties import Autodiscover
    +from ..protocol import AutodiscoverProtocol
    +from .base import BaseAutodiscovery
    +
    +log = logging.getLogger(__name__)
    +
    +
    +def discover(email, credentials=None, auth_type=None, retry_policy=None):
    +    ad_response, protocol = PoxAutodiscovery(email=email, credentials=credentials).discover()
    +    protocol.config.auth_typ = auth_type
    +    protocol.config.retry_policy = retry_policy
    +    return ad_response, protocol
    +
    +
    +class PoxAutodiscovery(BaseAutodiscovery):
    +    URL_PATH = "Autodiscover/Autodiscover.xml"
    +
    +    def _build_response(self, ad_response):
    +        if not ad_response.autodiscover_smtp_address:
    +            # Autodiscover does not always return an email address. In that case, the requesting email should be used
    +            ad_response.user.autodiscover_smtp_address = self.email
    +
    +        protocol = Protocol(
    +            config=Configuration(
    +                service_endpoint=ad_response.protocol.ews_url,
    +                credentials=self.credentials,
    +                version=ad_response.version,
    +                auth_type=ad_response.protocol.auth_type,
    +            )
    +        )
    +        return ad_response, protocol
    +
    +    def _quick(self, protocol):
    +        try:
    +            r = self._get_authenticated_response(protocol=protocol)
    +        except TransportError as e:
    +            raise AutoDiscoverFailed(f"Response error: {e}")
    +        if r.status_code == 200:
    +            try:
    +                ad = Autodiscover.from_bytes(bytes_content=r.content)
    +            except ParseError as e:
    +                raise AutoDiscoverFailed(f"Invalid response: {e}")
    +            else:
    +                return self._step_5(ad=ad)
    +        raise AutoDiscoverFailed(f"Invalid response code: {r.status_code}")
    +
    +    def _get_unauthenticated_response(self, url, method="post"):
    +        """Get auth type by tasting headers from the server. Do POST requests be default. HEAD is too error-prone, and
    +        some servers are set up to redirect to OWA on all requests except POST to the autodiscover endpoint.
    +
    +        :param url:
    +        :param method:  (Default value = 'post')
    +        :return:
    +        """
    +        # We are connecting to untrusted servers here, so take necessary precautions.
    +        self._ensure_valid_hostname(url)
    +
    +        kwargs = dict(
    +            url=url, headers=DEFAULT_HEADERS.copy(), allow_redirects=False, timeout=AutodiscoverProtocol.TIMEOUT
    +        )
    +        if method == "post":
    +            kwargs["data"] = Autodiscover.payload(email=self.email)
    +        retry = 0
    +        t_start = time.monotonic()
    +        while True:
    +            _back_off_if_needed(self.INITIAL_RETRY_POLICY.back_off_until)
    +            log.debug("Trying to get response from %s", url)
    +            with AutodiscoverProtocol.raw_session(url) as s:
    +                try:
    +                    r = getattr(s, method)(**kwargs)
    +                    r.close()  # Release memory
    +                    break
    +                except TLS_ERRORS as e:
    +                    # Don't retry on TLS errors. They will most likely be persistent.
    +                    raise TransportError(str(e))
    +                except CONNECTION_ERRORS as e:
    +                    r = DummyResponse(url=url, request_headers=kwargs["headers"])
    +                    total_wait = time.monotonic() - t_start
    +                    if self.INITIAL_RETRY_POLICY.may_retry_on_error(response=r, wait=total_wait):
    +                        log.debug("Connection error on URL %s (retry %s, error: %s). Cool down", url, retry, e)
    +                        # Don't respect the 'Retry-After' header. We don't know if this is a useful endpoint, and we
    +                        # want autodiscover to be reasonably fast.
    +                        self.INITIAL_RETRY_POLICY.back_off(self.RETRY_WAIT)
    +                        retry += 1
    +                        continue
    +                    log.debug("Connection error on URL %s: %s", url, e)
    +                    raise TransportError(str(e))
    +        try:
    +            auth_type = get_auth_method_from_response(response=r)
    +        except UnauthorizedError:
    +            # Failed to guess the auth type
    +            auth_type = NOAUTH
    +        if r.status_code in (301, 302) and "location" in r.headers:
    +            # Make the redirect URL absolute
    +            try:
    +                r.headers["location"] = get_redirect_url(r)
    +            except TransportError:
    +                del r.headers["location"]
    +        return auth_type, r
    +
    +    def _get_authenticated_response(self, protocol):
    +        """Get a response by using the credentials provided. We guess the auth type along the way.
    +
    +        :param protocol:
    +        :return:
    +        """
    +        # Redo the request with the correct auth
    +        data = Autodiscover.payload(email=self.email)
    +        headers = DEFAULT_HEADERS.copy()
    +        session = protocol.get_session()
    +        if GSSAPI in AUTH_TYPE_MAP and isinstance(session.auth, AUTH_TYPE_MAP[GSSAPI]):
    +            # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/pox-autodiscover-request-for-exchange
    +            headers["X-ClientCanHandle"] = "Negotiate"
    +        try:
    +            r, session = post_ratelimited(
    +                protocol=protocol,
    +                session=session,
    +                url=protocol.service_endpoint,
    +                headers=headers,
    +                data=data,
    +            )
    +            protocol.release_session(session)
    +        except UnauthorizedError as e:
    +            # It's entirely possible for the endpoint to ask for login. We should continue if login fails because this
    +            # isn't necessarily the right endpoint to use.
    +            raise TransportError(str(e))
    +        except RedirectError as e:
    +            r = DummyResponse(url=protocol.service_endpoint, headers={"location": e.url}, status_code=302)
    +        return r
    +
    +    def _attempt_response(self, url):
    +        """Return an (is_valid_response, response) tuple.
    +
    +        :param url:
    +        :return:
    +        """
    +        self._urls_visited.append(url.lower())
    +        log.debug("Attempting to get a valid response from %s", url)
    +        try:
    +            auth_type, r = self._get_unauthenticated_response(url=url)
    +            ad_protocol = AutodiscoverProtocol(
    +                config=Configuration(
    +                    service_endpoint=url,
    +                    credentials=self.credentials,
    +                    auth_type=auth_type,
    +                    retry_policy=self.INITIAL_RETRY_POLICY,
    +                )
    +            )
    +            if auth_type != NOAUTH:
    +                r = self._get_authenticated_response(protocol=ad_protocol)
    +        except TransportError as e:
    +            log.debug("Failed to get a response: %s", e)
    +            return False, None
    +        if r.status_code in (301, 302) and "location" in r.headers:
    +            redirect_url = get_redirect_url(r)
    +            if self._redirect_url_is_valid(url=redirect_url):
    +                # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com
    +                # works, it seems that we should follow this URL now and try to get a valid response.
    +                return self._attempt_response(url=redirect_url)
    +        if r.status_code == 200:
    +            try:
    +                ad = Autodiscover.from_bytes(bytes_content=r.content)
    +            except ParseError as e:
    +                log.debug("Invalid response: %s", e)
    +            else:
    +                # We got a valid response. Unless this is a URL redirect response, we cache the result
    +                if ad.response is None or not ad.response.redirect_url:
    +                    cache_key = self._cache_key
    +                    log.debug("Adding cache entry for key %s: %s", cache_key, ad_protocol.service_endpoint)
    +                    autodiscover_cache[cache_key] = ad_protocol
    +                return True, ad
    +        return False, None
    +
    +
    +
    +
    +
    +
    +
    +

    Functions

    +
    +
    +def discover(email, credentials=None, auth_type=None, retry_policy=None) +
    +
    +
    +
    + +Expand source code + +
    def discover(email, credentials=None, auth_type=None, retry_policy=None):
    +    ad_response, protocol = PoxAutodiscovery(email=email, credentials=credentials).discover()
    +    protocol.config.auth_typ = auth_type
    +    protocol.config.retry_policy = retry_policy
    +    return ad_response, protocol
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class PoxAutodiscovery +(email, credentials=None) +
    +
    +

    Autodiscover is a Microsoft protocol for automatically getting the endpoint of the Exchange server and other +connection-related settings holding the email address using only the email address, and username and password of the +user.

    +

    For a description of the protocol implemented, see "Autodiscover for Exchange ActiveSync developers":

    +

    https://docs.microsoft.com/en-us/previous-versions/office/developer/exchange-server-interoperability-guidance/hh352638%28v%3dexchg.140%29

    +

    Descriptions of the steps from the article are provided in their respective methods in this class.

    +

    For a description of how to handle autodiscover error messages, see:

    +

    https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/handling-autodiscover-error-messages

    +

    A tip from the article: +The client can perform steps 1 through 4 in any order or in parallel to expedite the process, but it must wait for +responses to finish at each step before proceeding. Given that many organizations prefer to use the URL in step 2 to +set up the Autodiscover service, the client might try this step first.

    +

    Another possibly newer resource which has not yet been attempted is "Outlook 2016 Implementation of Autodiscover": +https://support.microsoft.com/en-us/help/3211279/outlook-2016-implementation-of-autodiscover

    +

    WARNING: The autodiscover protocol is very complicated. If you have problems autodiscovering using this +implementation, start by doing an official test at https://testconnectivity.microsoft.com

    +

    :param email: The email address to autodiscover +:param credentials: Credentials with authorization to make autodiscover lookups for this Account +(Default value = None)

    +
    + +Expand source code + +
    class PoxAutodiscovery(BaseAutodiscovery):
    +    URL_PATH = "Autodiscover/Autodiscover.xml"
    +
    +    def _build_response(self, ad_response):
    +        if not ad_response.autodiscover_smtp_address:
    +            # Autodiscover does not always return an email address. In that case, the requesting email should be used
    +            ad_response.user.autodiscover_smtp_address = self.email
    +
    +        protocol = Protocol(
    +            config=Configuration(
    +                service_endpoint=ad_response.protocol.ews_url,
    +                credentials=self.credentials,
    +                version=ad_response.version,
    +                auth_type=ad_response.protocol.auth_type,
    +            )
    +        )
    +        return ad_response, protocol
    +
    +    def _quick(self, protocol):
    +        try:
    +            r = self._get_authenticated_response(protocol=protocol)
    +        except TransportError as e:
    +            raise AutoDiscoverFailed(f"Response error: {e}")
    +        if r.status_code == 200:
    +            try:
    +                ad = Autodiscover.from_bytes(bytes_content=r.content)
    +            except ParseError as e:
    +                raise AutoDiscoverFailed(f"Invalid response: {e}")
    +            else:
    +                return self._step_5(ad=ad)
    +        raise AutoDiscoverFailed(f"Invalid response code: {r.status_code}")
    +
    +    def _get_unauthenticated_response(self, url, method="post"):
    +        """Get auth type by tasting headers from the server. Do POST requests be default. HEAD is too error-prone, and
    +        some servers are set up to redirect to OWA on all requests except POST to the autodiscover endpoint.
    +
    +        :param url:
    +        :param method:  (Default value = 'post')
    +        :return:
    +        """
    +        # We are connecting to untrusted servers here, so take necessary precautions.
    +        self._ensure_valid_hostname(url)
    +
    +        kwargs = dict(
    +            url=url, headers=DEFAULT_HEADERS.copy(), allow_redirects=False, timeout=AutodiscoverProtocol.TIMEOUT
    +        )
    +        if method == "post":
    +            kwargs["data"] = Autodiscover.payload(email=self.email)
    +        retry = 0
    +        t_start = time.monotonic()
    +        while True:
    +            _back_off_if_needed(self.INITIAL_RETRY_POLICY.back_off_until)
    +            log.debug("Trying to get response from %s", url)
    +            with AutodiscoverProtocol.raw_session(url) as s:
    +                try:
    +                    r = getattr(s, method)(**kwargs)
    +                    r.close()  # Release memory
    +                    break
    +                except TLS_ERRORS as e:
    +                    # Don't retry on TLS errors. They will most likely be persistent.
    +                    raise TransportError(str(e))
    +                except CONNECTION_ERRORS as e:
    +                    r = DummyResponse(url=url, request_headers=kwargs["headers"])
    +                    total_wait = time.monotonic() - t_start
    +                    if self.INITIAL_RETRY_POLICY.may_retry_on_error(response=r, wait=total_wait):
    +                        log.debug("Connection error on URL %s (retry %s, error: %s). Cool down", url, retry, e)
    +                        # Don't respect the 'Retry-After' header. We don't know if this is a useful endpoint, and we
    +                        # want autodiscover to be reasonably fast.
    +                        self.INITIAL_RETRY_POLICY.back_off(self.RETRY_WAIT)
    +                        retry += 1
    +                        continue
    +                    log.debug("Connection error on URL %s: %s", url, e)
    +                    raise TransportError(str(e))
    +        try:
    +            auth_type = get_auth_method_from_response(response=r)
    +        except UnauthorizedError:
    +            # Failed to guess the auth type
    +            auth_type = NOAUTH
    +        if r.status_code in (301, 302) and "location" in r.headers:
    +            # Make the redirect URL absolute
    +            try:
    +                r.headers["location"] = get_redirect_url(r)
    +            except TransportError:
    +                del r.headers["location"]
    +        return auth_type, r
    +
    +    def _get_authenticated_response(self, protocol):
    +        """Get a response by using the credentials provided. We guess the auth type along the way.
    +
    +        :param protocol:
    +        :return:
    +        """
    +        # Redo the request with the correct auth
    +        data = Autodiscover.payload(email=self.email)
    +        headers = DEFAULT_HEADERS.copy()
    +        session = protocol.get_session()
    +        if GSSAPI in AUTH_TYPE_MAP and isinstance(session.auth, AUTH_TYPE_MAP[GSSAPI]):
    +            # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/pox-autodiscover-request-for-exchange
    +            headers["X-ClientCanHandle"] = "Negotiate"
    +        try:
    +            r, session = post_ratelimited(
    +                protocol=protocol,
    +                session=session,
    +                url=protocol.service_endpoint,
    +                headers=headers,
    +                data=data,
    +            )
    +            protocol.release_session(session)
    +        except UnauthorizedError as e:
    +            # It's entirely possible for the endpoint to ask for login. We should continue if login fails because this
    +            # isn't necessarily the right endpoint to use.
    +            raise TransportError(str(e))
    +        except RedirectError as e:
    +            r = DummyResponse(url=protocol.service_endpoint, headers={"location": e.url}, status_code=302)
    +        return r
    +
    +    def _attempt_response(self, url):
    +        """Return an (is_valid_response, response) tuple.
    +
    +        :param url:
    +        :return:
    +        """
    +        self._urls_visited.append(url.lower())
    +        log.debug("Attempting to get a valid response from %s", url)
    +        try:
    +            auth_type, r = self._get_unauthenticated_response(url=url)
    +            ad_protocol = AutodiscoverProtocol(
    +                config=Configuration(
    +                    service_endpoint=url,
    +                    credentials=self.credentials,
    +                    auth_type=auth_type,
    +                    retry_policy=self.INITIAL_RETRY_POLICY,
    +                )
    +            )
    +            if auth_type != NOAUTH:
    +                r = self._get_authenticated_response(protocol=ad_protocol)
    +        except TransportError as e:
    +            log.debug("Failed to get a response: %s", e)
    +            return False, None
    +        if r.status_code in (301, 302) and "location" in r.headers:
    +            redirect_url = get_redirect_url(r)
    +            if self._redirect_url_is_valid(url=redirect_url):
    +                # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com
    +                # works, it seems that we should follow this URL now and try to get a valid response.
    +                return self._attempt_response(url=redirect_url)
    +        if r.status_code == 200:
    +            try:
    +                ad = Autodiscover.from_bytes(bytes_content=r.content)
    +            except ParseError as e:
    +                log.debug("Invalid response: %s", e)
    +            else:
    +                # We got a valid response. Unless this is a URL redirect response, we cache the result
    +                if ad.response is None or not ad.response.redirect_url:
    +                    cache_key = self._cache_key
    +                    log.debug("Adding cache entry for key %s: %s", cache_key, ad_protocol.service_endpoint)
    +                    autodiscover_cache[cache_key] = ad_protocol
    +                return True, ad
    +        return False, None
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var URL_PATH
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/exchangelib/autodiscover/discovery/soap.html b/docs/exchangelib/autodiscover/discovery/soap.html new file mode 100644 index 00000000..131cf7b1 --- /dev/null +++ b/docs/exchangelib/autodiscover/discovery/soap.html @@ -0,0 +1,327 @@ + + + + + + +exchangelib.autodiscover.discovery.soap API documentation + + + + + + + + + + + +
    +
    +
    +

    Module exchangelib.autodiscover.discovery.soap

    +
    +
    +
    + +Expand source code + +
    import logging
    +
    +from ...configuration import Configuration
    +from ...errors import AutoDiscoverFailed, RedirectError, TransportError
    +from ...protocol import Protocol
    +from ...transport import get_unauthenticated_autodiscover_response
    +from ...util import CONNECTION_ERRORS
    +from ..cache import autodiscover_cache
    +from ..protocol import AutodiscoverProtocol
    +from .base import BaseAutodiscovery
    +
    +log = logging.getLogger(__name__)
    +
    +
    +def discover(email, credentials=None, auth_type=None, retry_policy=None):
    +    ad_response, protocol = SoapAutodiscovery(email=email, credentials=credentials).discover()
    +    protocol.config.auth_typ = auth_type
    +    protocol.config.retry_policy = retry_policy
    +    return ad_response, protocol
    +
    +
    +class SoapAutodiscovery(BaseAutodiscovery):
    +    URL_PATH = "autodiscover/autodiscover.svc"
    +
    +    def _build_response(self, ad_response):
    +        if not ad_response.autodiscover_smtp_address:
    +            # Autodiscover does not always return an email address. In that case, the requesting email should be used
    +            ad_response.autodiscover_smtp_address = self.email
    +
    +        protocol = Protocol(
    +            config=Configuration(
    +                service_endpoint=ad_response.ews_url,
    +                credentials=self.credentials,
    +                version=ad_response.version,
    +                # TODO: Detect EWS service auth type somehow
    +            )
    +        )
    +        return ad_response, protocol
    +
    +    def _quick(self, protocol):
    +        try:
    +            user_response = protocol.get_user_settings(user=self.email)
    +        except TransportError as e:
    +            raise AutoDiscoverFailed(f"Response error: {e}")
    +        return self._step_5(ad=user_response)
    +
    +    def _get_unauthenticated_response(self, url, method="post"):
    +        """Get response from server using the given HTTP method
    +
    +        :param url:
    +        :return:
    +        """
    +        # We are connecting to untrusted servers here, so take necessary precautions.
    +        self._ensure_valid_hostname(url)
    +
    +        protocol = AutodiscoverProtocol(
    +            config=Configuration(
    +                service_endpoint=url,
    +                retry_policy=self.INITIAL_RETRY_POLICY,
    +            )
    +        )
    +        return None, get_unauthenticated_autodiscover_response(protocol=protocol, method=method)
    +
    +    def _attempt_response(self, url):
    +        """Return an (is_valid_response, response) tuple.
    +
    +        :param url:
    +        :return:
    +        """
    +        self._urls_visited.append(url.lower())
    +        log.debug("Attempting to get a valid response from %s", url)
    +
    +        try:
    +            self._ensure_valid_hostname(url)
    +        except TransportError:
    +            return False, None
    +
    +        protocol = AutodiscoverProtocol(
    +            config=Configuration(
    +                service_endpoint=url,
    +                credentials=self.credentials,
    +                retry_policy=self.INITIAL_RETRY_POLICY,
    +            )
    +        )
    +        try:
    +            user_response = protocol.get_user_settings(user=self.email)
    +        except RedirectError as e:
    +            if self._redirect_url_is_valid(url=e.url):
    +                # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com
    +                # works, it seems that we should follow this URL now and try to get a valid response.
    +                return self._attempt_response(url=e.url)
    +            log.debug("Invalid redirect URL: %s", e.url)
    +            return False, None
    +        except TransportError as e:
    +            log.debug("Failed to get a response: %s", e)
    +            return False, None
    +        except CONNECTION_ERRORS as e:
    +            log.debug("Failed to get a response: %s", e)
    +            return False, None
    +
    +        # We got a valid response. Unless this is a URL redirect response, we cache the result
    +        if not user_response.redirect_url:
    +            cache_key = self._cache_key
    +            log.debug("Adding cache entry for key %s: %s", cache_key, protocol.service_endpoint)
    +            autodiscover_cache[cache_key] = protocol
    +        return True, user_response
    +
    +
    +
    +
    +
    +
    +
    +

    Functions

    +
    +
    +def discover(email, credentials=None, auth_type=None, retry_policy=None) +
    +
    +
    +
    + +Expand source code + +
    def discover(email, credentials=None, auth_type=None, retry_policy=None):
    +    ad_response, protocol = SoapAutodiscovery(email=email, credentials=credentials).discover()
    +    protocol.config.auth_typ = auth_type
    +    protocol.config.retry_policy = retry_policy
    +    return ad_response, protocol
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class SoapAutodiscovery +(email, credentials=None) +
    +
    +

    Autodiscover is a Microsoft protocol for automatically getting the endpoint of the Exchange server and other +connection-related settings holding the email address using only the email address, and username and password of the +user.

    +

    For a description of the protocol implemented, see "Autodiscover for Exchange ActiveSync developers":

    +

    https://docs.microsoft.com/en-us/previous-versions/office/developer/exchange-server-interoperability-guidance/hh352638%28v%3dexchg.140%29

    +

    Descriptions of the steps from the article are provided in their respective methods in this class.

    +

    For a description of how to handle autodiscover error messages, see:

    +

    https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/handling-autodiscover-error-messages

    +

    A tip from the article: +The client can perform steps 1 through 4 in any order or in parallel to expedite the process, but it must wait for +responses to finish at each step before proceeding. Given that many organizations prefer to use the URL in step 2 to +set up the Autodiscover service, the client might try this step first.

    +

    Another possibly newer resource which has not yet been attempted is "Outlook 2016 Implementation of Autodiscover": +https://support.microsoft.com/en-us/help/3211279/outlook-2016-implementation-of-autodiscover

    +

    WARNING: The autodiscover protocol is very complicated. If you have problems autodiscovering using this +implementation, start by doing an official test at https://testconnectivity.microsoft.com

    +

    :param email: The email address to autodiscover +:param credentials: Credentials with authorization to make autodiscover lookups for this Account +(Default value = None)

    +
    + +Expand source code + +
    class SoapAutodiscovery(BaseAutodiscovery):
    +    URL_PATH = "autodiscover/autodiscover.svc"
    +
    +    def _build_response(self, ad_response):
    +        if not ad_response.autodiscover_smtp_address:
    +            # Autodiscover does not always return an email address. In that case, the requesting email should be used
    +            ad_response.autodiscover_smtp_address = self.email
    +
    +        protocol = Protocol(
    +            config=Configuration(
    +                service_endpoint=ad_response.ews_url,
    +                credentials=self.credentials,
    +                version=ad_response.version,
    +                # TODO: Detect EWS service auth type somehow
    +            )
    +        )
    +        return ad_response, protocol
    +
    +    def _quick(self, protocol):
    +        try:
    +            user_response = protocol.get_user_settings(user=self.email)
    +        except TransportError as e:
    +            raise AutoDiscoverFailed(f"Response error: {e}")
    +        return self._step_5(ad=user_response)
    +
    +    def _get_unauthenticated_response(self, url, method="post"):
    +        """Get response from server using the given HTTP method
    +
    +        :param url:
    +        :return:
    +        """
    +        # We are connecting to untrusted servers here, so take necessary precautions.
    +        self._ensure_valid_hostname(url)
    +
    +        protocol = AutodiscoverProtocol(
    +            config=Configuration(
    +                service_endpoint=url,
    +                retry_policy=self.INITIAL_RETRY_POLICY,
    +            )
    +        )
    +        return None, get_unauthenticated_autodiscover_response(protocol=protocol, method=method)
    +
    +    def _attempt_response(self, url):
    +        """Return an (is_valid_response, response) tuple.
    +
    +        :param url:
    +        :return:
    +        """
    +        self._urls_visited.append(url.lower())
    +        log.debug("Attempting to get a valid response from %s", url)
    +
    +        try:
    +            self._ensure_valid_hostname(url)
    +        except TransportError:
    +            return False, None
    +
    +        protocol = AutodiscoverProtocol(
    +            config=Configuration(
    +                service_endpoint=url,
    +                credentials=self.credentials,
    +                retry_policy=self.INITIAL_RETRY_POLICY,
    +            )
    +        )
    +        try:
    +            user_response = protocol.get_user_settings(user=self.email)
    +        except RedirectError as e:
    +            if self._redirect_url_is_valid(url=e.url):
    +                # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com
    +                # works, it seems that we should follow this URL now and try to get a valid response.
    +                return self._attempt_response(url=e.url)
    +            log.debug("Invalid redirect URL: %s", e.url)
    +            return False, None
    +        except TransportError as e:
    +            log.debug("Failed to get a response: %s", e)
    +            return False, None
    +        except CONNECTION_ERRORS as e:
    +            log.debug("Failed to get a response: %s", e)
    +            return False, None
    +
    +        # We got a valid response. Unless this is a URL redirect response, we cache the result
    +        if not user_response.redirect_url:
    +            cache_key = self._cache_key
    +            log.debug("Adding cache entry for key %s: %s", cache_key, protocol.service_endpoint)
    +            autodiscover_cache[cache_key] = protocol
    +        return True, user_response
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var URL_PATH
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/exchangelib/autodiscover/index.html b/docs/exchangelib/autodiscover/index.html index e264901c..62000eec 100644 --- a/docs/exchangelib/autodiscover/index.html +++ b/docs/exchangelib/autodiscover/index.html @@ -27,7 +27,8 @@

    Module exchangelib.autodiscover

    Expand source code
    from .cache import AutodiscoverCache, autodiscover_cache
    -from .discovery import Autodiscovery, discover
    +from .discovery.pox import PoxAutodiscovery as Autodiscovery
    +from .discovery.pox import discover
     from .protocol import AutodiscoverProtocol
     
     
    @@ -59,7 +60,7 @@ 

    Sub-modules

    -
    exchangelib.autodiscover.discovery
    +
    exchangelib.autodiscover.discovery
    @@ -116,7 +117,7 @@

    Functions

    Expand source code
    def discover(email, credentials=None, auth_type=None, retry_policy=None):
    -    ad_response, protocol = Autodiscovery(email=email, credentials=credentials).discover()
    +    ad_response, protocol = PoxAutodiscovery(email=email, credentials=credentials).discover()
         protocol.config.auth_typ = auth_type
         protocol.config.retry_policy = retry_policy
         return ad_response, protocol
    @@ -131,7 +132,7 @@

    Classes

    class AutodiscoverCache
    -

    Stores the translation from (email domain, credentials) -> AutodiscoverProtocol object so we can re-use TCP +

    Stores the translation from (email domain, credentials) -> AutodiscoverProtocol object, so we can re-use TCP connections to an autodiscover server within the same process. Also persists the email domain -> (autodiscover endpoint URL, auth_type) translation to the filesystem so the cache can be shared between multiple processes.

    According to Microsoft, we may forever cache the (email domain -> autodiscover endpoint URL) mapping, or until @@ -139,7 +140,7 @@

    Classes

    advice. But it could save some valuable seconds every time we start a new connection to a known server. In any case, the persistent storage must not contain any sensitive information since the cache could be readable by unprivileged users. Domain, endpoint and auth_type are OK to cache since this info is make publicly available on -HTTP and DNS servers via the autodiscover protocol. Just don't persist any credentials info.

    +HTTP and DNS servers via the autodiscover protocol. Just don't persist any credential info.

    If an autodiscover lookup fails for any reason, the corresponding cache entry must be purged.

    'shelve' is supposedly thread-safe and process-safe, which suits our needs.

    @@ -147,7 +148,7 @@

    Classes

    Expand source code
    class AutodiscoverCache:
    -    """Stores the translation from (email domain, credentials) -> AutodiscoverProtocol object so we can re-use TCP
    +    """Stores the translation from (email domain, credentials) -> AutodiscoverProtocol object, so we can re-use TCP
         connections to an autodiscover server within the same process. Also persists the email domain -> (autodiscover
         endpoint URL, auth_type) translation to the filesystem so the cache can be shared between multiple processes.
     
    @@ -156,7 +157,7 @@ 

    Classes

    advice. But it could save some valuable seconds every time we start a new connection to a known server. In any case, the persistent storage must not contain any sensitive information since the cache could be readable by unprivileged users. Domain, endpoint and auth_type are OK to cache since this info is make publicly available on - HTTP and DNS servers via the autodiscover protocol. Just don't persist any credentials info. + HTTP and DNS servers via the autodiscover protocol. Just don't persist any credential info. If an autodiscover lookup fails for any reason, the corresponding cache entry must be purged. @@ -296,10 +297,55 @@

    Methods

    TIMEOUT = 10 # Seconds + def __init__(self, config): + if not config.version: + # Default to the latest supported version + config.version = Version.all_versions()[0] + super().__init__(config=config) + def __str__(self): return f"""\ Autodiscover endpoint: {self.service_endpoint} -Auth type: {self.auth_type}"""
    +Auth type: {self.auth_type}""" + + @property + def version(self): + return self.config.version + + @property + def auth_type(self): + # Autodetect authentication type if necessary + if self.config.auth_type is None: + self.config.auth_type = self.get_auth_type() + return self.config.auth_type + + def get_auth_type(self): + # Autodetect authentication type. + return get_autodiscover_authtype(protocol=self) + + def get_user_settings(self, user): + return GetUserSettings(protocol=self).get( + users=[user], + settings=[ + "user_dn", + "mailbox_dn", + "user_display_name", + "auto_discover_smtp_address", + "external_ews_url", + "ews_supported_schemas", + ], + ) + + def dummy_xml(self): + # Generate a valid EWS request for SOAP autodiscovery + svc = GetUserSettings(protocol=self) + return svc.wrap( + content=svc.get_payload( + users=["DUMMY@example.com"], + settings=["auto_discover_smtp_address"], + ), + api_version=self.config.version.api_version, + )

    Ancestors

      @@ -312,18 +358,96 @@

      Class variables

    +

    Instance variables

    +
    +
    var auth_type
    +
    +
    +
    + +Expand source code + +
    @property
    +def auth_type(self):
    +    # Autodetect authentication type if necessary
    +    if self.config.auth_type is None:
    +        self.config.auth_type = self.get_auth_type()
    +    return self.config.auth_type
    +
    +
    +
    var version
    +
    +
    +
    + +Expand source code + +
    @property
    +def version(self):
    +    return self.config.version
    +
    +
    +
    +

    Methods

    +
    +
    +def dummy_xml(self) +
    +
    +
    +
    + +Expand source code + +
    def dummy_xml(self):
    +    # Generate a valid EWS request for SOAP autodiscovery
    +    svc = GetUserSettings(protocol=self)
    +    return svc.wrap(
    +        content=svc.get_payload(
    +            users=["DUMMY@example.com"],
    +            settings=["auto_discover_smtp_address"],
    +        ),
    +        api_version=self.config.version.api_version,
    +    )
    +
    +
    +
    +def get_user_settings(self, user) +
    +
    +
    +
    + +Expand source code + +
    def get_user_settings(self, user):
    +    return GetUserSettings(protocol=self).get(
    +        users=[user],
    +        settings=[
    +            "user_dn",
    +            "mailbox_dn",
    +            "user_display_name",
    +            "auto_discover_smtp_address",
    +            "external_ews_url",
    +            "ews_supported_schemas",
    +        ],
    +    )
    +
    +
    +

    Inherited members

    -
    +
    class Autodiscovery (email, credentials=None)
    @@ -351,115 +475,8 @@

    Inherited members

    Expand source code -
    class Autodiscovery:
    -    """Autodiscover is a Microsoft protocol for automatically getting the endpoint of the Exchange server and other
    -    connection-related settings holding the email address using only the email address, and username and password of the
    -    user.
    -
    -    For a description of the protocol implemented, see "Autodiscover for Exchange ActiveSync developers":
    -
    -    https://docs.microsoft.com/en-us/previous-versions/office/developer/exchange-server-interoperability-guidance/hh352638%28v%3dexchg.140%29
    -
    -    Descriptions of the steps from the article are provided in their respective methods in this class.
    -
    -    For a description of how to handle autodiscover error messages, see:
    -
    -    https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/handling-autodiscover-error-messages
    -
    -    A tip from the article:
    -    The client can perform steps 1 through 4 in any order or in parallel to expedite the process, but it must wait for
    -    responses to finish at each step before proceeding. Given that many organizations prefer to use the URL in step 2 to
    -    set up the Autodiscover service, the client might try this step first.
    -
    -    Another possibly newer resource which has not yet been attempted is "Outlook 2016 Implementation of Autodiscover":
    -    https://support.microsoft.com/en-us/help/3211279/outlook-2016-implementation-of-autodiscover
    -
    -    WARNING: The autodiscover protocol is very complicated. If you have problems autodiscovering using this
    -    implementation, start by doing an official test at https://testconnectivity.microsoft.com
    -    """
    -
    -    # When connecting to servers that may not be serving the correct endpoint, we should use a retry policy that does
    -    # not leave us hanging for a long time on each step in the protocol.
    -    INITIAL_RETRY_POLICY = FailFast()
    -    RETRY_WAIT = 10  # Seconds to wait before retry on connection errors
    -    MAX_REDIRECTS = 10  # Maximum number of URL redirects before we give up
    -    DNS_RESOLVER_KWARGS = {}
    -    DNS_RESOLVER_ATTRS = {
    -        "timeout": AutodiscoverProtocol.TIMEOUT / 2.5,  # Timeout for query to a single nameserver
    -    }
    -    DNS_RESOLVER_LIFETIME = AutodiscoverProtocol.TIMEOUT  # Total timeout for a query in case of multiple nameservers
    -
    -    def __init__(self, email, credentials=None):
    -        """
    -
    -        :param email: The email address to autodiscover
    -        :param credentials: Credentials with authorization to make autodiscover lookups for this Account
    -            (Default value = None)
    -        """
    -        self.email = email
    -        self.credentials = credentials
    -        self._urls_visited = []  # Collects HTTP and Autodiscover redirects
    -        self._redirect_count = 0
    -        self._emails_visited = []  # Collects Autodiscover email redirects
    -
    -    def discover(self):
    -        self._emails_visited.append(self.email.lower())
    -
    -        # Check the autodiscover cache to see if we already know the autodiscover service endpoint for this email
    -        # domain. Use a lock to guard against multiple threads competing to cache information.
    -        log.debug("Waiting for autodiscover_cache lock")
    -        with autodiscover_cache:
    -            log.debug("autodiscover_cache lock acquired")
    -            cache_key = self._cache_key
    -            domain = get_domain(self.email)
    -            if cache_key in autodiscover_cache:
    -                ad_protocol = autodiscover_cache[cache_key]
    -                log.debug("Cache hit for key %s: %s", cache_key, ad_protocol.service_endpoint)
    -                try:
    -                    ad_response = self._quick(protocol=ad_protocol)
    -                except AutoDiscoverFailed:
    -                    # Autodiscover no longer works with this domain. Clear cache and try again after releasing the lock
    -                    log.debug("AD request failure. Removing cache for key %s", cache_key)
    -                    del autodiscover_cache[cache_key]
    -                    ad_response = self._step_1(hostname=domain)
    -            else:
    -                # This will cache the result
    -                log.debug("Cache miss for key %s", cache_key)
    -                ad_response = self._step_1(hostname=domain)
    -
    -        log.debug("Released autodiscover_cache_lock")
    -        if ad_response.redirect_address:
    -            log.debug("Got a redirect address: %s", ad_response.redirect_address)
    -            if ad_response.redirect_address.lower() in self._emails_visited:
    -                raise AutoDiscoverCircularRedirect("We were redirected to an email address we have already seen")
    -
    -            # Start over, but with the new email address
    -            self.email = ad_response.redirect_address
    -            return self.discover()
    -
    -        # We successfully received a response. Clear the cache of seen emails etc.
    -        self.clear()
    -        return self._build_response(ad_response=ad_response)
    -
    -    def clear(self):
    -        # This resets cached variables
    -        self._urls_visited = []
    -        self._redirect_count = 0
    -        self._emails_visited = []
    -
    -    @property
    -    def _cache_key(self):
    -        # We may be using multiple different credentials and changing our minds on TLS verification. This key
    -        # combination should be safe for caching.
    -        domain = get_domain(self.email)
    -        return domain, self.credentials
    -
    -    @threaded_cached_property
    -    def resolver(self):
    -        resolver = dns.resolver.Resolver(**self.DNS_RESOLVER_KWARGS)
    -        for k, v in self.DNS_RESOLVER_ATTRS.items():
    -            setattr(resolver, k, v)
    -        return resolver
    +
    class PoxAutodiscovery(BaseAutodiscovery):
    +    URL_PATH = "Autodiscover/Autodiscover.xml"
     
         def _build_response(self, ad_response):
             if not ad_response.autodiscover_smtp_address:
    @@ -490,44 +507,8 @@ 

    Inherited members

    return self._step_5(ad=ad) raise AutoDiscoverFailed(f"Invalid response code: {r.status_code}") - def _redirect_url_is_valid(self, url): - """Three separate responses can be “Redirect responses”: - * An HTTP status code (301, 302) with a new URL - * An HTTP status code of 200, but with a payload XML containing a redirect to a different URL - * An HTTP status code of 200, but with a payload XML containing a different SMTP address as the target address - - We only handle the HTTP 302 redirects here. We validate the URL received in the redirect response to ensure that - it does not redirect to non-SSL endpoints or SSL endpoints with invalid certificates, and that the redirect is - not circular. Finally, we should fail after 10 redirects. - - :param url: - :return: - """ - if url.lower() in self._urls_visited: - log.warning("We have already tried this URL: %s", url) - return False - - if self._redirect_count >= self.MAX_REDIRECTS: - log.warning("We reached max redirects at URL: %s", url) - return False - - # We require TLS endpoints - if not url.startswith("https://"): - log.debug("Invalid scheme for URL: %s", url) - return False - - # Quick test that the endpoint responds and that TLS handshake is OK - try: - self._get_unauthenticated_response(url, method="head") - except TransportError as e: - log.debug("Response error on redirect URL %s: %s", url, e) - return False - - self._redirect_count += 1 - return True - def _get_unauthenticated_response(self, url, method="post"): - """Get auth type by tasting headers from the server. Do POST requests be default. HEAD is too error prone, and + """Get auth type by tasting headers from the server. Do POST requests be default. HEAD is too error-prone, and some servers are set up to redirect to OWA on all requests except POST to the autodiscover endpoint. :param url: @@ -535,11 +516,7 @@

    Inherited members

    :return: """ # We are connecting to untrusted servers here, so take necessary precautions. - hostname = urlparse(url).netloc - if not self._is_valid_hostname(hostname): - # 'requests' is really bad at reporting that a hostname cannot be resolved. Let's check this separately. - # Don't retry on DNS errors. They will most likely be persistent. - raise TransportError(f"{hostname!r} has no DNS entry") + self._ensure_valid_hostname(url) kwargs = dict( url=url, headers=DEFAULT_HEADERS.copy(), allow_redirects=False, timeout=AutodiscoverProtocol.TIMEOUT @@ -655,319 +632,17 @@

    Inherited members

    log.debug("Adding cache entry for key %s: %s", cache_key, ad_protocol.service_endpoint) autodiscover_cache[cache_key] = ad_protocol return True, ad - return False, None - - def _is_valid_hostname(self, hostname): - log.debug("Checking if %s can be looked up in DNS", hostname) - try: - self.resolver.resolve(f"{hostname}.", "A", lifetime=self.DNS_RESOLVER_LIFETIME) - except DNS_LOOKUP_ERRORS as e: - log.debug("DNS A lookup failure: %s", e) - return False - return True - - def _get_srv_records(self, hostname): - """Send a DNS query for SRV entries for the hostname. - - An SRV entry that has been formatted for autodiscovery will have the following format: - - canonical name = mail.example.com. - service = 8 100 443 webmail.example.com. - - The first three numbers in the service line are: priority, weight, port - - :param hostname: - :return: - """ - log.debug("Attempting to get SRV records for %s", hostname) - records = [] - try: - answers = self.resolver.resolve(f"{hostname}.", "SRV", lifetime=self.DNS_RESOLVER_LIFETIME) - except DNS_LOOKUP_ERRORS as e: - log.debug("DNS SRV lookup failure: %s", e) - return records - for rdata in answers: - try: - vals = rdata.to_text().strip().rstrip(".").split(" ") - # Raise ValueError if the first three are not ints, and IndexError if there are less than 4 values - priority, weight, port, srv = int(vals[0]), int(vals[1]), int(vals[2]), vals[3] - record = SrvRecord(priority=priority, weight=weight, port=port, srv=srv) - log.debug("Found SRV record %s ", record) - records.append(record) - except (ValueError, IndexError): - log.debug("Incompatible SRV record for %s (%s)", hostname, rdata.to_text()) - return records - - def _step_1(self, hostname): - """Perform step 1, where the client sends an Autodiscover request to - https://example.com/autodiscover/autodiscover.xml and then does one of the following: - * If the Autodiscover attempt succeeds, the client proceeds to step 5. - * If the Autodiscover attempt fails, the client proceeds to step 2. - - :param hostname: - :return: - """ - url = f"https://{hostname}/Autodiscover/Autodiscover.xml" - log.info("Step 1: Trying autodiscover on %r with email %r", url, self.email) - is_valid_response, ad = self._attempt_response(url=url) - if is_valid_response: - return self._step_5(ad=ad) - return self._step_2(hostname=hostname) - - def _step_2(self, hostname): - """Perform step 2, where the client sends an Autodiscover request to - https://autodiscover.example.com/autodiscover/autodiscover.xml and then does one of the following: - * If the Autodiscover attempt succeeds, the client proceeds to step 5. - * If the Autodiscover attempt fails, the client proceeds to step 3. - - :param hostname: - :return: - """ - url = f"https://autodiscover.{hostname}/Autodiscover/Autodiscover.xml" - log.info("Step 2: Trying autodiscover on %r with email %r", url, self.email) - is_valid_response, ad = self._attempt_response(url=url) - if is_valid_response: - return self._step_5(ad=ad) - return self._step_3(hostname=hostname) - - def _step_3(self, hostname): - """Perform step 3, where the client sends an unauth'ed GET method request to - http://autodiscover.example.com/autodiscover/autodiscover.xml (Note that this is a non-HTTPS endpoint). The - client then does one of the following: - * If the GET request returns a 302 redirect response, it gets the redirection URL from the 'Location' HTTP - header and validates it as described in the "Redirect responses" section. The client then does one of the - following: - * If the redirection URL is valid, the client tries the URL and then does one of the following: - * If the attempt succeeds, the client proceeds to step 5. - * If the attempt fails, the client proceeds to step 4. - * If the redirection URL is not valid, the client proceeds to step 4. - * If the GET request does not return a 302 redirect response, the client proceeds to step 4. - - :param hostname: - :return: - """ - url = f"http://autodiscover.{hostname}/Autodiscover/Autodiscover.xml" - log.info("Step 3: Trying autodiscover on %r with email %r", url, self.email) - try: - _, r = self._get_unauthenticated_response(url=url, method="get") - except TransportError: - r = DummyResponse(url=url) - if r.status_code in (301, 302) and "location" in r.headers: - redirect_url = get_redirect_url(r) - if self._redirect_url_is_valid(url=redirect_url): - is_valid_response, ad = self._attempt_response(url=redirect_url) - if is_valid_response: - return self._step_5(ad=ad) - log.debug("Got invalid response") - return self._step_4(hostname=hostname) - log.debug("Got invalid redirect URL") - return self._step_4(hostname=hostname) - log.debug("Got no redirect URL") - return self._step_4(hostname=hostname) - - def _step_4(self, hostname): - """Perform step 4, where the client performs a Domain Name System (DNS) query for an SRV record for - _autodiscover._tcp.example.com. The query might return multiple records. The client selects only records that - point to an SSL endpoint and that have the highest priority and weight. One of the following actions then - occurs: - * If no such records are returned, the client proceeds to step 6. - * If records are returned, the application randomly chooses a record in the list and validates the endpoint - that it points to by following the process described in the "Redirect Response" section. The client then - does one of the following: - * If the redirection URL is valid, the client tries the URL and then does one of the following: - * If the attempt succeeds, the client proceeds to step 5. - * If the attempt fails, the client proceeds to step 6. - * If the redirection URL is not valid, the client proceeds to step 6. - - :param hostname: - :return: - """ - dns_hostname = f"_autodiscover._tcp.{hostname}" - log.info("Step 4: Trying autodiscover on %r with email %r", dns_hostname, self.email) - srv_records = self._get_srv_records(dns_hostname) - try: - srv_host = _select_srv_host(srv_records) - except ValueError: - srv_host = None - if not srv_host: - return self._step_6() - redirect_url = f"https://{srv_host}/Autodiscover/Autodiscover.xml" - if self._redirect_url_is_valid(url=redirect_url): - is_valid_response, ad = self._attempt_response(url=redirect_url) - if is_valid_response: - return self._step_5(ad=ad) - log.debug("Got invalid response") - return self._step_6() - log.debug("Got invalid redirect URL") - return self._step_6() - - def _step_5(self, ad): - """Perform step 5. When a valid Autodiscover request succeeds, the following sequence occurs: - * If the server responds with an HTTP 302 redirect, the client validates the redirection URL according to - the process defined in the "Redirect responses" and then does one of the following: - * If the redirection URL is valid, the client tries the URL and then does one of the following: - * If the attempt succeeds, the client repeats step 5 from the beginning. - * If the attempt fails, the client proceeds to step 6. - * If the redirection URL is not valid, the client proceeds to step 6. - * If the server responds with a valid Autodiscover response, the client does one of the following: - * If the value of the Action element is "Redirect", the client gets the redirection email address from - the Redirect element and then returns to step 1, using this new email address. - * If the value of the Action element is "Settings", the client has successfully received the requested - configuration settings for the specified user. The client does not need to proceed to step 6. - - :param ad: - :return: - """ - log.info("Step 5: Checking response") - if ad.response is None: - # This is not explicit in the protocol, but let's raise errors here - ad.raise_errors() - - ad_response = ad.response - if ad_response.redirect_url: - log.debug("Got a redirect URL: %s", ad_response.redirect_url) - # We are diverging a bit from the protocol here. We will never get an HTTP 302 since earlier steps already - # followed the redirects where possible. Instead, we handle redirect responses here. - if self._redirect_url_is_valid(url=ad_response.redirect_url): - is_valid_response, ad = self._attempt_response(url=ad_response.redirect_url) - if is_valid_response: - return self._step_5(ad=ad) - log.debug("Got invalid response") - return self._step_6() - log.debug("Invalid redirect URL") - return self._step_6() - # This could be an email redirect. Let outer layer handle this - return ad_response - - def _step_6(self): - """Perform step 6. If the client cannot contact the Autodiscover service, the client should ask the user for - the Exchange server name and use it to construct an Exchange EWS URL. The client should try to use this URL for - future requests. - """ - raise AutoDiscoverFailed( - f"All steps in the autodiscover protocol failed for email {self.email}. If you think this is an error, " - f"consider doing an official test at https://testconnectivity.microsoft.com" - )
    + return False, None
    +

    Ancestors

    +

    Class variables

    -
    var DNS_RESOLVER_ATTRS
    -
    -
    -
    -
    var DNS_RESOLVER_KWARGS
    -
    -
    -
    -
    var DNS_RESOLVER_LIFETIME
    -
    -
    -
    -
    var INITIAL_RETRY_POLICY
    -
    -
    -
    -
    var MAX_REDIRECTS
    -
    -
    -
    -
    var RETRY_WAIT
    -
    -
    -
    -
    -

    Instance variables

    -
    -
    var resolver
    +
    var URL_PATH
    -
    - -Expand source code - -
    def __get__(self, obj, cls):
    -    if obj is None:
    -        return self
    -
    -    obj_dict = obj.__dict__
    -    name = self.func.__name__
    -    with self.lock:
    -        try:
    -            # check if the value was computed before the lock was acquired
    -            return obj_dict[name]
    -
    -        except KeyError:
    -            # if not, do the calculation and release the lock
    -            return obj_dict.setdefault(name, self.func(obj))
    -
    -
    -
    -

    Methods

    -
    -
    -def clear(self) -
    -
    -
    -
    - -Expand source code - -
    def clear(self):
    -    # This resets cached variables
    -    self._urls_visited = []
    -    self._redirect_count = 0
    -    self._emails_visited = []
    -
    -
    -
    -def discover(self) -
    -
    -
    -
    - -Expand source code - -
    def discover(self):
    -    self._emails_visited.append(self.email.lower())
    -
    -    # Check the autodiscover cache to see if we already know the autodiscover service endpoint for this email
    -    # domain. Use a lock to guard against multiple threads competing to cache information.
    -    log.debug("Waiting for autodiscover_cache lock")
    -    with autodiscover_cache:
    -        log.debug("autodiscover_cache lock acquired")
    -        cache_key = self._cache_key
    -        domain = get_domain(self.email)
    -        if cache_key in autodiscover_cache:
    -            ad_protocol = autodiscover_cache[cache_key]
    -            log.debug("Cache hit for key %s: %s", cache_key, ad_protocol.service_endpoint)
    -            try:
    -                ad_response = self._quick(protocol=ad_protocol)
    -            except AutoDiscoverFailed:
    -                # Autodiscover no longer works with this domain. Clear cache and try again after releasing the lock
    -                log.debug("AD request failure. Removing cache for key %s", cache_key)
    -                del autodiscover_cache[cache_key]
    -                ad_response = self._step_1(hostname=domain)
    -        else:
    -            # This will cache the result
    -            log.debug("Cache miss for key %s", cache_key)
    -            ad_response = self._step_1(hostname=domain)
    -
    -    log.debug("Released autodiscover_cache_lock")
    -    if ad_response.redirect_address:
    -        log.debug("Got a redirect address: %s", ad_response.redirect_address)
    -        if ad_response.redirect_address.lower() in self._emails_visited:
    -            raise AutoDiscoverCircularRedirect("We were redirected to an email address we have already seen")
    -
    -        # Start over, but with the new email address
    -        self.email = ad_response.redirect_address
    -        return self.discover()
    -
    -    # We successfully received a response. Clear the cache of seen emails etc.
    -    self.clear()
    -    return self._build_response(ad_response=ad_response)
    -
    @@ -988,7 +663,7 @@

    Index

  • Sub-modules

    @@ -1013,20 +688,16 @@

    AutodiscoverProtocol

  • -

    Autodiscovery

    +

    PoxAutodiscovery

  • diff --git a/docs/exchangelib/autodiscover/properties.html b/docs/exchangelib/autodiscover/properties.html index 98e003f4..d9472d11 100644 --- a/docs/exchangelib/autodiscover/properties.html +++ b/docs/exchangelib/autodiscover/properties.html @@ -109,7 +109,7 @@

    Module exchangelib.autodiscover.properties

    class IntExtBase(AutodiscoverBase): # TODO: 'OWAUrl' also has an AuthenticationMethod enum-style XML attribute with values: - # WindowsIntegrated, FBA, NTLM, Digest, Basic + # WindowsIntegrated, FBA, NTLM, Digest, Basic, LiveIdFba, OAuth owa_url = TextField(field_uri="OWAUrl", namespace=RNS) protocol = EWSElementField(value_cls=SimpleProtocol) @@ -191,7 +191,7 @@

    Module exchangelib.autodiscover.properties

    # Missing in list are DIGEST and OAUTH2 "basic": BASIC, "kerb": GSSAPI, - "kerbntlm": NTLM, # Means client can chose between NTLM and GSSAPI + "kerbntlm": NTLM, # Means client can choose between NTLM and GSSAPI "ntlm": NTLM, "certificate": CBA, "negotiate": SSPI, # Unsure about this one @@ -286,7 +286,7 @@

    Module exchangelib.autodiscover.properties

    @property def version(self): - # Get the server version. Not all protocol entries have a server version so we cheat a bit and also look at the + # Get the server version. Not all protocol entries have a server version, so we cheat a bit and also look at the # other ones that point to the same endpoint. ews_url = self.protocol.ews_url for protocol in self.account.protocols: @@ -357,13 +357,16 @@

    Module exchangelib.autodiscover.properties

    return cls.from_xml(elem=root, account=None) def raise_errors(self): + if self.response is not None: + return + # Find an error message in the response and raise the relevant exception try: - errorcode = self.error_response.error.code + error_code = self.error_response.error.code message = self.error_response.error.message if message in ("The e-mail address cannot be found.", "The email address can't be found."): raise ErrorNonExistentMailbox("The SMTP address has no mailbox associated with it") - raise AutoDiscoverFailed(f"Unknown error {errorcode}: {message}") + raise AutoDiscoverFailed(f"Unknown error {error_code}: {message}") except AttributeError: raise AutoDiscoverFailed(f"Unknown autodiscover error response: {self.error_response}") @@ -619,13 +622,16 @@

    Inherited members

    return cls.from_xml(elem=root, account=None) def raise_errors(self): + if self.response is not None: + return + # Find an error message in the response and raise the relevant exception try: - errorcode = self.error_response.error.code + error_code = self.error_response.error.code message = self.error_response.error.message if message in ("The e-mail address cannot be found.", "The email address can't be found."): raise ErrorNonExistentMailbox("The SMTP address has no mailbox associated with it") - raise AutoDiscoverFailed(f"Unknown error {errorcode}: {message}") + raise AutoDiscoverFailed(f"Unknown error {error_code}: {message}") except AttributeError: raise AutoDiscoverFailed(f"Unknown autodiscover error response: {self.error_response}") @@ -732,13 +738,16 @@

    Methods

    Expand source code
    def raise_errors(self):
    +    if self.response is not None:
    +        return
    +
         # Find an error message in the response and raise the relevant exception
         try:
    -        errorcode = self.error_response.error.code
    +        error_code = self.error_response.error.code
             message = self.error_response.error.message
             if message in ("The e-mail address cannot be found.", "The email address can't be found."):
                 raise ErrorNonExistentMailbox("The SMTP address has no mailbox associated with it")
    -        raise AutoDiscoverFailed(f"Unknown error {errorcode}: {message}")
    +        raise AutoDiscoverFailed(f"Unknown error {error_code}: {message}")
         except AttributeError:
             raise AutoDiscoverFailed(f"Unknown autodiscover error response: {self.error_response}")
    @@ -990,7 +999,7 @@

    Inherited members

    class IntExtBase(AutodiscoverBase):
         # TODO: 'OWAUrl' also has an AuthenticationMethod enum-style XML attribute with values:
    -    #  WindowsIntegrated, FBA, NTLM, Digest, Basic
    +    #  WindowsIntegrated, FBA, NTLM, Digest, Basic, LiveIdFba, OAuth
         owa_url = TextField(field_uri="OWAUrl", namespace=RNS)
         protocol = EWSElementField(value_cls=SimpleProtocol)
    @@ -1310,7 +1319,7 @@

    Inherited members

    # Missing in list are DIGEST and OAUTH2 "basic": BASIC, "kerb": GSSAPI, - "kerbntlm": NTLM, # Means client can chose between NTLM and GSSAPI + "kerbntlm": NTLM, # Means client can choose between NTLM and GSSAPI "ntlm": NTLM, "certificate": CBA, "negotiate": SSPI, # Unsure about this one @@ -1363,7 +1372,7 @@

    Instance variables

    # Missing in list are DIGEST and OAUTH2 "basic": BASIC, "kerb": GSSAPI, - "kerbntlm": NTLM, # Means client can chose between NTLM and GSSAPI + "kerbntlm": NTLM, # Means client can choose between NTLM and GSSAPI "ntlm": NTLM, "certificate": CBA, "negotiate": SSPI, # Unsure about this one @@ -1601,7 +1610,7 @@

    Inherited members

    @property def version(self): - # Get the server version. Not all protocol entries have a server version so we cheat a bit and also look at the + # Get the server version. Not all protocol entries have a server version, so we cheat a bit and also look at the # other ones that point to the same endpoint. ews_url = self.protocol.ews_url for protocol in self.account.protocols: @@ -1750,7 +1759,7 @@

    Instance variables

    @property
     def version(self):
    -    # Get the server version. Not all protocol entries have a server version so we cheat a bit and also look at the
    +    # Get the server version. Not all protocol entries have a server version, so we cheat a bit and also look at the
         # other ones that point to the same endpoint.
         ews_url = self.protocol.ews_url
         for protocol in self.account.protocols:
    @@ -2160,4 +2169,4 @@ 

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/autodiscover/protocol.html b/docs/exchangelib/autodiscover/protocol.html index 1b046bac..307292cb 100644 --- a/docs/exchangelib/autodiscover/protocol.html +++ b/docs/exchangelib/autodiscover/protocol.html @@ -27,6 +27,9 @@

    Module exchangelib.autodiscover.protocol

    Expand source code
    from ..protocol import BaseProtocol
    +from ..services import GetUserSettings
    +from ..transport import get_autodiscover_authtype
    +from ..version import Version
     
     
     class AutodiscoverProtocol(BaseProtocol):
    @@ -34,10 +37,55 @@ 

    Module exchangelib.autodiscover.protocol

    TIMEOUT = 10 # Seconds + def __init__(self, config): + if not config.version: + # Default to the latest supported version + config.version = Version.all_versions()[0] + super().__init__(config=config) + def __str__(self): return f"""\ Autodiscover endpoint: {self.service_endpoint} -Auth type: {self.auth_type}"""
    +Auth type: {self.auth_type}""" + + @property + def version(self): + return self.config.version + + @property + def auth_type(self): + # Autodetect authentication type if necessary + if self.config.auth_type is None: + self.config.auth_type = self.get_auth_type() + return self.config.auth_type + + def get_auth_type(self): + # Autodetect authentication type. + return get_autodiscover_authtype(protocol=self) + + def get_user_settings(self, user): + return GetUserSettings(protocol=self).get( + users=[user], + settings=[ + "user_dn", + "mailbox_dn", + "user_display_name", + "auto_discover_smtp_address", + "external_ews_url", + "ews_supported_schemas", + ], + ) + + def dummy_xml(self): + # Generate a valid EWS request for SOAP autodiscovery + svc = GetUserSettings(protocol=self) + return svc.wrap( + content=svc.get_payload( + users=["DUMMY@example.com"], + settings=["auto_discover_smtp_address"], + ), + api_version=self.config.version.api_version, + )

    @@ -64,10 +112,55 @@

    Classes

    TIMEOUT = 10 # Seconds + def __init__(self, config): + if not config.version: + # Default to the latest supported version + config.version = Version.all_versions()[0] + super().__init__(config=config) + def __str__(self): return f"""\ Autodiscover endpoint: {self.service_endpoint} -Auth type: {self.auth_type}"""
    +Auth type: {self.auth_type}""" + + @property + def version(self): + return self.config.version + + @property + def auth_type(self): + # Autodetect authentication type if necessary + if self.config.auth_type is None: + self.config.auth_type = self.get_auth_type() + return self.config.auth_type + + def get_auth_type(self): + # Autodetect authentication type. + return get_autodiscover_authtype(protocol=self) + + def get_user_settings(self, user): + return GetUserSettings(protocol=self).get( + users=[user], + settings=[ + "user_dn", + "mailbox_dn", + "user_display_name", + "auto_discover_smtp_address", + "external_ews_url", + "ews_supported_schemas", + ], + ) + + def dummy_xml(self): + # Generate a valid EWS request for SOAP autodiscovery + svc = GetUserSettings(protocol=self) + return svc.wrap( + content=svc.get_payload( + users=["DUMMY@example.com"], + settings=["auto_discover_smtp_address"], + ), + api_version=self.config.version.api_version, + )

    Ancestors

      @@ -80,12 +173,90 @@

      Class variables

      +

      Instance variables

      +
      +
      var auth_type
      +
      +
      +
      + +Expand source code + +
      @property
      +def auth_type(self):
      +    # Autodetect authentication type if necessary
      +    if self.config.auth_type is None:
      +        self.config.auth_type = self.get_auth_type()
      +    return self.config.auth_type
      +
      +
      +
      var version
      +
      +
      +
      + +Expand source code + +
      @property
      +def version(self):
      +    return self.config.version
      +
      +
      +
      +

      Methods

      +
      +
      +def dummy_xml(self) +
      +
      +
      +
      + +Expand source code + +
      def dummy_xml(self):
      +    # Generate a valid EWS request for SOAP autodiscovery
      +    svc = GetUserSettings(protocol=self)
      +    return svc.wrap(
      +        content=svc.get_payload(
      +            users=["DUMMY@example.com"],
      +            settings=["auto_discover_smtp_address"],
      +        ),
      +        api_version=self.config.version.api_version,
      +    )
      +
      +
      +
      +def get_user_settings(self, user) +
      +
      +
      +
      + +Expand source code + +
      def get_user_settings(self, user):
      +    return GetUserSettings(protocol=self).get(
      +        users=[user],
      +        settings=[
      +            "user_dn",
      +            "mailbox_dn",
      +            "user_display_name",
      +            "auto_discover_smtp_address",
      +            "external_ews_url",
      +            "ews_supported_schemas",
      +        ],
      +    )
      +
      +
      +

      Inherited members

      @@ -122,4 +297,4 @@

      Generated by pdoc 0.10.0.

      - \ No newline at end of file + diff --git a/docs/exchangelib/configuration.html b/docs/exchangelib/configuration.html index 3b671ff3..48c91443 100644 --- a/docs/exchangelib/configuration.html +++ b/docs/exchangelib/configuration.html @@ -30,7 +30,7 @@

      Module exchangelib.configuration

      from cached_property import threaded_cached_property -from .credentials import BaseCredentials, OAuth2AuthorizationCodeCredentials, OAuth2Credentials, OAuth2LegacyCredentials +from .credentials import BaseCredentials, BaseOAuth2Credentials from .errors import InvalidEnumValue, InvalidTypeError from .protocol import FailFast, RetryPolicy from .transport import AUTH_TYPE_MAP, CREDENTIALS_REQUIRED, OAUTH2 @@ -39,13 +39,6 @@

      Module exchangelib.configuration

      log = logging.getLogger(__name__) -DEFAULT_AUTH_TYPE = { - # This type of credentials *must* use the OAuth auth type - OAuth2Credentials: OAUTH2, - OAuth2LegacyCredentials: OAUTH2, - OAuth2AuthorizationCodeCredentials: OAUTH2, -} - class Configuration: """Contains information needed to create an authenticated connection to an EWS endpoint. @@ -87,9 +80,9 @@

      Module exchangelib.configuration

      ): if not isinstance(credentials, (BaseCredentials, type(None))): raise InvalidTypeError("credentials", credentials, BaseCredentials) - if auth_type is None: + if auth_type is None and isinstance(credentials, BaseOAuth2Credentials): # Set a default auth type for the credentials where this makes sense - auth_type = DEFAULT_AUTH_TYPE.get(type(credentials)) + auth_type = OAUTH2 if auth_type is not None and auth_type not in AUTH_TYPE_MAP: raise InvalidEnumValue("auth_type", auth_type, AUTH_TYPE_MAP) if credentials is None and auth_type in CREDENTIALS_REQUIRED: @@ -210,9 +203,9 @@

      Classes

      ): if not isinstance(credentials, (BaseCredentials, type(None))): raise InvalidTypeError("credentials", credentials, BaseCredentials) - if auth_type is None: + if auth_type is None and isinstance(credentials, BaseOAuth2Credentials): # Set a default auth type for the credentials where this makes sense - auth_type = DEFAULT_AUTH_TYPE.get(type(credentials)) + auth_type = OAUTH2 if auth_type is not None and auth_type not in AUTH_TYPE_MAP: raise InvalidEnumValue("auth_type", auth_type, AUTH_TYPE_MAP) if credentials is None and auth_type in CREDENTIALS_REQUIRED: diff --git a/docs/exchangelib/credentials.html b/docs/exchangelib/credentials.html index 7019c581..f4177040 100644 --- a/docs/exchangelib/credentials.html +++ b/docs/exchangelib/credentials.html @@ -41,7 +41,8 @@

      Module exchangelib.credentials

      import logging from threading import RLock -from oauthlib.oauth2 import OAuth2Token +import oauthlib.oauth2 +from cached_property import threaded_cached_property from .errors import InvalidTypeError @@ -53,48 +54,13 @@

      Module exchangelib.credentials

      class BaseCredentials(metaclass=abc.ABCMeta): - """Base for credential storage. - - Establishes a method for refreshing credentials (mostly useful with OAuth, which expires tokens relatively - frequently) and provides a lock for synchronizing access to the object around refreshes. - """ - - def __init__(self): - self._lock = RLock() - - @property - def lock(self): - return self._lock - - @abc.abstractmethod - def refresh(self, session): - """Obtain a new set of valid credentials. This is mostly intended to support OAuth token refreshing, which can - happen in long- running applications or those that cache access tokens and so might start with a token close to - expiration. - - :param session: requests session asking for refreshed credentials - :return: - """ - - def _get_hash_values(self): - return (getattr(self, k) for k in self.__dict__ if k != "_lock") + """Base class for credential storage.""" def __eq__(self, other): - return all(getattr(self, k) == getattr(other, k) for k in self.__dict__ if k != "_lock") + return all(getattr(self, k) == getattr(other, k) for k in self.__dict__) def __hash__(self): - return hash(tuple(self._get_hash_values())) - - def __getstate__(self): - # The lock cannot be pickled - state = self.__dict__.copy() - del state["_lock"] - return state - - def __setstate__(self, state): - # Restore the lock - self.__dict__.update(state) - self._lock = RLock() + return hash(tuple((getattr(self, k) for k in self.__dict__))) class Credentials(BaseCredentials): @@ -122,9 +88,6 @@

      Module exchangelib.credentials

      self.username = username self.password = password - def refresh(self, session): - pass - def __repr__(self): return self.__class__.__name__ + repr((self.username, "********")) @@ -132,14 +95,8 @@

      Module exchangelib.credentials

      return self.username -class OAuth2Credentials(BaseCredentials): - """Login info for OAuth 2.0 client credentials authentication, as well as a base for other OAuth 2.0 grant types. - - This is primarily useful for in-house applications accessing data from a single Microsoft account. For applications - that will access multiple tenants' data, the client credentials flow does not give the application enough - information to restrict end users' access to the appropriate account. Use OAuth2AuthorizationCodeCredentials and - the associated auth code grant type for multi-tenant applications. - """ +class BaseOAuth2Credentials(BaseCredentials): + """Base class for all classes that implement OAuth 2.0 authentication""" def __init__(self, client_id, client_secret, tenant_id=None, identity=None, access_token=None): """ @@ -157,10 +114,31 @@

      Module exchangelib.credentials

      self.identity = identity self.access_token = access_token + self._lock = RLock() + + @property + def lock(self): + return self._lock + + @property + def access_token(self): + return self._access_token + + @access_token.setter + def access_token(self, access_token): + if access_token is not None and not isinstance(access_token, dict): + raise InvalidTypeError("access_token", access_token, oauthlib.oauth2.OAuth2Token) + self._access_token = access_token + def refresh(self, session): - # Creating a new session gets a new access token, so there's no work here to refresh the credentials. This - # implementation just makes sure we don't raise a NotImplementedError. - pass + """Obtain a new set of valid credentials. This is intended to support OAuth token refreshing, which can + happen in long-running applications or those that cache access tokens and so might start with a token close to + expiration. + + :param session: requests session asking for refreshed credentials + :return: + """ + # Creating a new session gets a new access token, so there's no work here to refresh the credentials. def on_token_auto_refreshed(self, access_token): """Set the access_token. Called after the access token is refreshed (requests-oauthlib can automatically @@ -171,17 +149,10 @@

      Module exchangelib.credentials

      :param access_token: New token obtained by refreshing """ # Ensure we don't update the object in the middle of a new session being created, which could cause a race. - if not isinstance(access_token, dict): - raise InvalidTypeError("access_token", access_token, OAuth2Token) with self.lock: log.debug("%s auth token for %s", "Refreshing" if self.access_token else "Setting", self.client_id) self.access_token = access_token - def _get_hash_values(self): - # 'access_token' may be refreshed once in a while. This should not affect the hash signature. - # 'identity' is just informational and should also not affect the hash signature. - return (getattr(self, k) for k in self.__dict__ if k not in ("_lock", "identity", "access_token")) - def sig(self): # Like hash(self), but pulls in the access token. Protocol.refresh_credentials() uses this to find out # if the access_token needs to be refreshed. @@ -189,26 +160,82 @@

      Module exchangelib.credentials

      for k in self.__dict__: if k in ("_lock", "identity"): continue - if k == "access_token": + if k == "_access_token": res.append(self.access_token["access_token"] if self.access_token else None) continue res.append(getattr(self, k)) return hash(tuple(res)) @property + @abc.abstractmethod def token_url(self): - return f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token" + """The URL to request tokens from""" @property + @abc.abstractmethod def scope(self): - return ["https://outlook.office365.com/.default"] + """The scope we ask for the token to have""" - def __repr__(self): - return self.__class__.__name__ + repr((self.client_id, "********")) + def session_params(self): + """Extra parameters to use when creating the session""" + return {"token": self.access_token} # Token may be None + + def token_params(self): + """Extra parameters when requesting the token""" + return {"include_client_id": True} + + @threaded_cached_property + @abc.abstractmethod + def client(self): + """The client implementation to use for this credential class""" + + def __eq__(self, other): + return all(getattr(self, k) == getattr(other, k) for k in self.__dict__ if k != "_lock") + + def __hash__(self): + # 'access_token' may be refreshed once in a while. This should not affect the hash signature. + # 'identity' is just informational and should also not affect the hash signature. + return hash(tuple(getattr(self, k) for k in self.__dict__ if k not in ("_lock", "identity", "_access_token"))) def __str__(self): return self.client_id + def __repr__(self): + return self.__class__.__name__ + repr((self.client_id, "********")) + + def __getstate__(self): + # The lock cannot be pickled + state = self.__dict__.copy() + del state["_lock"] + return state + + def __setstate__(self, state): + # Restore the lock + self.__dict__.update(state) + self._lock = RLock() + + +class OAuth2Credentials(BaseOAuth2Credentials): + """Login info for OAuth 2.0 client credentials authentication, as well as a base for other OAuth 2.0 grant types. + + This is primarily useful for in-house applications accessing data from a single Microsoft account. For applications + that will access multiple tenants' data, the client credentials flow does not give the application enough + information to restrict end users' access to the appropriate account. Use OAuth2AuthorizationCodeCredentials and + the associated auth code grant type for multi-tenant applications. + """ + + @property + def token_url(self): + return f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token" + + @property + def scope(self): + return ["https://outlook.office365.com/.default"] + + @threaded_cached_property + def client(self): + return oauthlib.oauth2.BackendApplicationClient(client_id=self.client_id) + class OAuth2LegacyCredentials(OAuth2Credentials): """Login info for OAuth 2.0 authentication using delegated permissions and application permissions. @@ -220,18 +247,32 @@

      Module exchangelib.credentials

      def __init__(self, username, password, **kwargs): """ :param username: The username of the user to act as - :poram password: The password of the user to act as + :param password: The password of the user to act as """ super().__init__(**kwargs) self.username = username self.password = password + def token_params(self): + res = super().token_params() + res.update( + { + "username": self.username, + "password": self.password, + } + ) + return res + + @threaded_cached_property + def client(self): + return oauthlib.oauth2.LegacyApplicationClient(client_id=self.client_id) + @property def scope(self): return ["https://outlook.office365.com/EWS.AccessAsUser.All"] -class OAuth2AuthorizationCodeCredentials(OAuth2Credentials): +class OAuth2AuthorizationCodeCredentials(BaseOAuth2Credentials): """Login info for OAuth 2.0 authentication using the authorization code grant type. This can be used in one of several ways: * Given an authorization code, client ID, and client secret, fetch a token ourselves and refresh it as needed if @@ -246,23 +287,17 @@

      Module exchangelib.credentials

      tenant. """ - def __init__(self, authorization_code=None, access_token=None, client_id=None, client_secret=None, **kwargs): + def __init__(self, authorization_code=None, **kwargs): """ - :param client_id: ID of an authorized OAuth application, required for automatic token fetching and refreshing - :param client_secret: Secret associated with the OAuth application - :param tenant_id: Microsoft tenant ID of the account to access - :param identity: An Identity object representing the account that these credentials are connected to. :param authorization_code: Code obtained when authorizing the application to access an account. In combination with client_id and client_secret, will be used to obtain an access token. - :param access_token: Previously-obtained access token. If a token exists and the application will handle - refreshing by itself (or opts not to handle it), this parameter alone is sufficient. """ - super().__init__(client_id=client_id, client_secret=client_secret, **kwargs) + for attr in ("client_id", "client_secret"): + # Allow omitting these kwargs + kwargs[attr] = kwargs.pop(attr, None) + super().__init__(**kwargs) self.authorization_code = authorization_code - if access_token is not None and not isinstance(access_token, dict): - raise InvalidTypeError("access_token", access_token, OAuth2Token) - self.access_token = access_token @property def token_url(self): @@ -276,6 +311,35 @@

      Module exchangelib.credentials

      res.append("offline_access") return res + def session_params(self): + res = super().session_params() + if self.client_id and self.client_secret: + # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other + # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to + # refresh the token (that covers cases where the caller doesn't have access to the client secret but + # is working with a service that can provide it refreshed tokens on a limited basis). + res.update( + { + "auto_refresh_kwargs": { + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + "auto_refresh_url": self.token_url, + "token_updater": self.on_token_auto_refreshed, + } + ) + return res + + def token_params(self): + res = super().token_params() + res["code"] = self.authorization_code # Auth code may be None + self.authorization_code = None # We can only use the code once + return res + + @threaded_cached_property + def client(self): + return oauthlib.oauth2.WebApplicationClient(client_id=self.client_id) + def __repr__(self): return self.__class__.__name__ + repr( (self.client_id, "[client_secret]", "[authorization_code]", "[access_token]") @@ -305,45 +369,148 @@

      Classes

      class BaseCredentials

    -

    Base for credential storage.

    -

    Establishes a method for refreshing credentials (mostly useful with OAuth, which expires tokens relatively -frequently) and provides a lock for synchronizing access to the object around refreshes.

    +

    Base class for credential storage.

    Expand source code
    class BaseCredentials(metaclass=abc.ABCMeta):
    -    """Base for credential storage.
    +    """Base class for credential storage."""
     
    -    Establishes a method for refreshing credentials (mostly useful with OAuth, which expires tokens relatively
    -    frequently) and provides a lock for synchronizing access to the object around refreshes.
    -    """
    +    def __eq__(self, other):
    +        return all(getattr(self, k) == getattr(other, k) for k in self.__dict__)
    +
    +    def __hash__(self):
    +        return hash(tuple((getattr(self, k) for k in self.__dict__)))
    +
    +

    Subclasses

    + +
    +
    +class BaseOAuth2Credentials +(client_id, client_secret, tenant_id=None, identity=None, access_token=None) +
    +
    +

    Base class for all classes that implement OAuth 2.0 authentication

    +

    :param client_id: ID of an authorized OAuth application, required for automatic token fetching and refreshing +:param client_secret: Secret associated with the OAuth application +:param tenant_id: Microsoft tenant ID of the account to access +:param identity: An Identity object representing the account that these credentials are connected to. +:param access_token: Previously-obtained access token, as a dict or an oauthlib.oauth2.OAuth2Token

    +
    + +Expand source code + +
    class BaseOAuth2Credentials(BaseCredentials):
    +    """Base class for all classes that implement OAuth 2.0 authentication"""
    +
    +    def __init__(self, client_id, client_secret, tenant_id=None, identity=None, access_token=None):
    +        """
    +
    +        :param client_id: ID of an authorized OAuth application, required for automatic token fetching and refreshing
    +        :param client_secret: Secret associated with the OAuth application
    +        :param tenant_id: Microsoft tenant ID of the account to access
    +        :param identity: An Identity object representing the account that these credentials are connected to.
    +        :param access_token: Previously-obtained access token, as a dict or an oauthlib.oauth2.OAuth2Token
    +        """
    +        super().__init__()
    +        self.client_id = client_id
    +        self.client_secret = client_secret
    +        self.tenant_id = tenant_id
    +        self.identity = identity
    +        self.access_token = access_token
     
    -    def __init__(self):
             self._lock = RLock()
     
         @property
         def lock(self):
             return self._lock
     
    -    @abc.abstractmethod
    +    @property
    +    def access_token(self):
    +        return self._access_token
    +
    +    @access_token.setter
    +    def access_token(self, access_token):
    +        if access_token is not None and not isinstance(access_token, dict):
    +            raise InvalidTypeError("access_token", access_token, oauthlib.oauth2.OAuth2Token)
    +        self._access_token = access_token
    +
         def refresh(self, session):
    -        """Obtain a new set of valid credentials. This is mostly intended to support OAuth token refreshing, which can
    -        happen in long- running applications or those that cache access tokens and so might start with a token close to
    +        """Obtain a new set of valid credentials. This is intended to support OAuth token refreshing, which can
    +        happen in long-running applications or those that cache access tokens and so might start with a token close to
             expiration.
     
             :param session: requests session asking for refreshed credentials
             :return:
             """
    +        # Creating a new session gets a new access token, so there's no work here to refresh the credentials.
     
    -    def _get_hash_values(self):
    -        return (getattr(self, k) for k in self.__dict__ if k != "_lock")
    +    def on_token_auto_refreshed(self, access_token):
    +        """Set the access_token. Called after the access token is refreshed (requests-oauthlib can automatically
    +        refresh tokens if given an OAuth client ID and secret, so this is how our copy of the token stays up-to-date).
    +        Applications that cache access tokens can override this to store the new token - just remember to call the
    +        super() method.
    +
    +        :param access_token: New token obtained by refreshing
    +        """
    +        # Ensure we don't update the object in the middle of a new session being created, which could cause a race.
    +        with self.lock:
    +            log.debug("%s auth token for %s", "Refreshing" if self.access_token else "Setting", self.client_id)
    +            self.access_token = access_token
    +
    +    def sig(self):
    +        # Like hash(self), but pulls in the access token. Protocol.refresh_credentials() uses this to find out
    +        # if the access_token needs to be refreshed.
    +        res = []
    +        for k in self.__dict__:
    +            if k in ("_lock", "identity"):
    +                continue
    +            if k == "_access_token":
    +                res.append(self.access_token["access_token"] if self.access_token else None)
    +                continue
    +            res.append(getattr(self, k))
    +        return hash(tuple(res))
    +
    +    @property
    +    @abc.abstractmethod
    +    def token_url(self):
    +        """The URL to request tokens from"""
    +
    +    @property
    +    @abc.abstractmethod
    +    def scope(self):
    +        """The scope we ask for the token to have"""
    +
    +    def session_params(self):
    +        """Extra parameters to use when creating the session"""
    +        return {"token": self.access_token}  # Token may be None
    +
    +    def token_params(self):
    +        """Extra parameters when requesting the token"""
    +        return {"include_client_id": True}
    +
    +    @threaded_cached_property
    +    @abc.abstractmethod
    +    def client(self):
    +        """The client implementation to use for this credential class"""
     
         def __eq__(self, other):
             return all(getattr(self, k) == getattr(other, k) for k in self.__dict__ if k != "_lock")
     
         def __hash__(self):
    -        return hash(tuple(self._get_hash_values()))
    +        # 'access_token' may be refreshed once in a while. This should not affect the hash signature.
    +        # 'identity' is just informational and should also not affect the hash signature.
    +        return hash(tuple(getattr(self, k) for k in self.__dict__ if k not in ("_lock", "identity", "_access_token")))
    +
    +    def __str__(self):
    +        return self.client_id
    +
    +    def __repr__(self):
    +        return self.__class__.__name__ + repr((self.client_id, "********"))
     
         def __getstate__(self):
             # The lock cannot be pickled
    @@ -356,14 +523,53 @@ 

    Classes

    self.__dict__.update(state) self._lock = RLock()
    +

    Ancestors

    +

    Subclasses

    Instance variables

    -
    var lock
    +
    var access_token
    +
    +
    +
    + +Expand source code + +
    @property
    +def access_token(self):
    +    return self._access_token
    +
    +
    +
    var client
    +
    +

    The client implementation to use for this credential class

    +
    + +Expand source code + +
    def __get__(self, obj, cls):
    +    if obj is None:
    +        return self
    +
    +    obj_dict = obj.__dict__
    +    name = self.func.__name__
    +    with self.lock:
    +        try:
    +            # check if the value was computed before the lock was acquired
    +            return obj_dict[name]
    +
    +        except KeyError:
    +            # if not, do the calculation and release the lock
    +            return obj_dict.setdefault(name, self.func(obj))
    +
    +
    +
    var lock
    @@ -375,15 +581,68 @@

    Instance variables

    return self._lock
    -
    -

    Methods

    -
    -
    +
    var scope
    +
    +

    The scope we ask for the token to have

    +
    + +Expand source code + +
    @property
    +@abc.abstractmethod
    +def scope(self):
    +    """The scope we ask for the token to have"""
    +
    +
    +
    var token_url
    +
    +

    The URL to request tokens from

    +
    + +Expand source code + +
    @property
    +@abc.abstractmethod
    +def token_url(self):
    +    """The URL to request tokens from"""
    +
    +
    +
    +

    Methods

    +
    +
    +def on_token_auto_refreshed(self, access_token) +
    +
    +

    Set the access_token. Called after the access token is refreshed (requests-oauthlib can automatically +refresh tokens if given an OAuth client ID and secret, so this is how our copy of the token stays up-to-date). +Applications that cache access tokens can override this to store the new token - just remember to call the +super() method.

    +

    :param access_token: New token obtained by refreshing

    +
    + +Expand source code + +
    def on_token_auto_refreshed(self, access_token):
    +    """Set the access_token. Called after the access token is refreshed (requests-oauthlib can automatically
    +    refresh tokens if given an OAuth client ID and secret, so this is how our copy of the token stays up-to-date).
    +    Applications that cache access tokens can override this to store the new token - just remember to call the
    +    super() method.
    +
    +    :param access_token: New token obtained by refreshing
    +    """
    +    # Ensure we don't update the object in the middle of a new session being created, which could cause a race.
    +    with self.lock:
    +        log.debug("%s auth token for %s", "Refreshing" if self.access_token else "Setting", self.client_id)
    +        self.access_token = access_token
    +
    +
    +
    def refresh(self, session)
    -

    Obtain a new set of valid credentials. This is mostly intended to support OAuth token refreshing, which can -happen in long- running applications or those that cache access tokens and so might start with a token close to +

    Obtain a new set of valid credentials. This is intended to support OAuth token refreshing, which can +happen in long-running applications or those that cache access tokens and so might start with a token close to expiration.

    :param session: requests session asking for refreshed credentials :return:

    @@ -391,15 +650,66 @@

    Methods

    Expand source code -
    @abc.abstractmethod
    -def refresh(self, session):
    -    """Obtain a new set of valid credentials. This is mostly intended to support OAuth token refreshing, which can
    -    happen in long- running applications or those that cache access tokens and so might start with a token close to
    +
    def refresh(self, session):
    +    """Obtain a new set of valid credentials. This is intended to support OAuth token refreshing, which can
    +    happen in long-running applications or those that cache access tokens and so might start with a token close to
         expiration.
     
         :param session: requests session asking for refreshed credentials
         :return:
    -    """
    + """ + # Creating a new session gets a new access token, so there's no work here to refresh the credentials.
    + +
    +
    +def session_params(self) +
    +
    +

    Extra parameters to use when creating the session

    +
    + +Expand source code + +
    def session_params(self):
    +    """Extra parameters to use when creating the session"""
    +    return {"token": self.access_token}  # Token may be None
    +
    +
    +
    +def sig(self) +
    +
    +
    +
    + +Expand source code + +
    def sig(self):
    +    # Like hash(self), but pulls in the access token. Protocol.refresh_credentials() uses this to find out
    +    # if the access_token needs to be refreshed.
    +    res = []
    +    for k in self.__dict__:
    +        if k in ("_lock", "identity"):
    +            continue
    +        if k == "_access_token":
    +            res.append(self.access_token["access_token"] if self.access_token else None)
    +            continue
    +        res.append(getattr(self, k))
    +    return hash(tuple(res))
    +
    +
    +
    +def token_params(self) +
    +
    +

    Extra parameters when requesting the token

    +
    + +Expand source code + +
    def token_params(self):
    +    """Extra parameters when requesting the token"""
    +    return {"include_client_id": True}
    @@ -444,9 +754,6 @@

    Methods

    self.username = username self.password = password - def refresh(self, session): - pass - def __repr__(self): return self.__class__.__name__ + repr((self.username, "********")) @@ -472,18 +779,10 @@

    Class variables

    -

    Inherited members

    -
    class OAuth2AuthorizationCodeCredentials -(authorization_code=None, access_token=None, client_id=None, client_secret=None, **kwargs) +(authorization_code=None, **kwargs)

    Login info for OAuth 2.0 authentication using the authorization code grant type. This can be used in one of @@ -497,19 +796,13 @@

    Inherited members

    Unlike the base (client credentials) grant, authorization code credentials don't require a Microsoft tenant ID because each access token (and the authorization code used to get the access token) is restricted to a single tenant.

    -

    :param client_id: ID of an authorized OAuth application, required for automatic token fetching and refreshing -:param client_secret: Secret associated with the OAuth application -:param tenant_id: Microsoft tenant ID of the account to access -:param identity: An Identity object representing the account that these credentials are connected to. -:param authorization_code: Code obtained when authorizing the application to access an account. In combination -with client_id and client_secret, will be used to obtain an access token. -:param access_token: Previously-obtained access token. If a token exists and the application will handle -refreshing by itself (or opts not to handle it), this parameter alone is sufficient.

    +

    :param authorization_code: Code obtained when authorizing the application to access an account. In combination +with client_id and client_secret, will be used to obtain an access token.

    Expand source code -
    class OAuth2AuthorizationCodeCredentials(OAuth2Credentials):
    +
    class OAuth2AuthorizationCodeCredentials(BaseOAuth2Credentials):
         """Login info for OAuth 2.0 authentication using the authorization code grant type. This can be used in one of
         several ways:
         * Given an authorization code, client ID, and client secret, fetch a token ourselves and refresh it as needed if
    @@ -524,23 +817,17 @@ 

    Inherited members

    tenant. """ - def __init__(self, authorization_code=None, access_token=None, client_id=None, client_secret=None, **kwargs): + def __init__(self, authorization_code=None, **kwargs): """ - :param client_id: ID of an authorized OAuth application, required for automatic token fetching and refreshing - :param client_secret: Secret associated with the OAuth application - :param tenant_id: Microsoft tenant ID of the account to access - :param identity: An Identity object representing the account that these credentials are connected to. :param authorization_code: Code obtained when authorizing the application to access an account. In combination with client_id and client_secret, will be used to obtain an access token. - :param access_token: Previously-obtained access token. If a token exists and the application will handle - refreshing by itself (or opts not to handle it), this parameter alone is sufficient. """ - super().__init__(client_id=client_id, client_secret=client_secret, **kwargs) + for attr in ("client_id", "client_secret"): + # Allow omitting these kwargs + kwargs[attr] = kwargs.pop(attr, None) + super().__init__(**kwargs) self.authorization_code = authorization_code - if access_token is not None and not isinstance(access_token, dict): - raise InvalidTypeError("access_token", access_token, OAuth2Token) - self.access_token = access_token @property def token_url(self): @@ -554,6 +841,35 @@

    Inherited members

    res.append("offline_access") return res + def session_params(self): + res = super().session_params() + if self.client_id and self.client_secret: + # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other + # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to + # refresh the token (that covers cases where the caller doesn't have access to the client secret but + # is working with a service that can provide it refreshed tokens on a limited basis). + res.update( + { + "auto_refresh_kwargs": { + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + "auto_refresh_url": self.token_url, + "token_updater": self.on_token_auto_refreshed, + } + ) + return res + + def token_params(self): + res = super().token_params() + res["code"] = self.authorization_code # Auth code may be None + self.authorization_code = None # We can only use the code once + return res + + @threaded_cached_property + def client(self): + return oauthlib.oauth2.WebApplicationClient(client_id=self.client_id) + def __repr__(self): return self.__class__.__name__ + repr( (self.client_id, "[client_secret]", "[authorization_code]", "[access_token]") @@ -571,46 +887,20 @@

    Inherited members

    Ancestors

    -

    Instance variables

    -
    -
    var scope
    -
    -
    -
    - -Expand source code - -
    @property
    -def scope(self):
    -    res = super().scope
    -    res.append("offline_access")
    -    return res
    -
    -
    -
    var token_url
    -
    -
    -
    - -Expand source code - -
    @property
    -def token_url(self):
    -    # We don't know (or need) the Microsoft tenant ID. Use common/ to let Microsoft select the appropriate
    -    # tenant for the provided authorization code or refresh token.
    -    return "https://login.microsoftonline.com/common/oauth2/v2.0/token"  # nosec
    -
    -
    -

    Inherited members

    @@ -634,7 +924,7 @@

    Inherited members

    Expand source code -
    class OAuth2Credentials(BaseCredentials):
    +
    class OAuth2Credentials(BaseOAuth2Credentials):
         """Login info for OAuth 2.0 client credentials authentication, as well as a base for other OAuth 2.0 grant types.
     
         This is primarily useful for in-house applications accessing data from a single Microsoft account. For applications
    @@ -643,60 +933,6 @@ 

    Inherited members

    the associated auth code grant type for multi-tenant applications. """ - def __init__(self, client_id, client_secret, tenant_id=None, identity=None, access_token=None): - """ - - :param client_id: ID of an authorized OAuth application, required for automatic token fetching and refreshing - :param client_secret: Secret associated with the OAuth application - :param tenant_id: Microsoft tenant ID of the account to access - :param identity: An Identity object representing the account that these credentials are connected to. - :param access_token: Previously-obtained access token, as a dict or an oauthlib.oauth2.OAuth2Token - """ - super().__init__() - self.client_id = client_id - self.client_secret = client_secret - self.tenant_id = tenant_id - self.identity = identity - self.access_token = access_token - - def refresh(self, session): - # Creating a new session gets a new access token, so there's no work here to refresh the credentials. This - # implementation just makes sure we don't raise a NotImplementedError. - pass - - def on_token_auto_refreshed(self, access_token): - """Set the access_token. Called after the access token is refreshed (requests-oauthlib can automatically - refresh tokens if given an OAuth client ID and secret, so this is how our copy of the token stays up-to-date). - Applications that cache access tokens can override this to store the new token - just remember to call the - super() method. - - :param access_token: New token obtained by refreshing - """ - # Ensure we don't update the object in the middle of a new session being created, which could cause a race. - if not isinstance(access_token, dict): - raise InvalidTypeError("access_token", access_token, OAuth2Token) - with self.lock: - log.debug("%s auth token for %s", "Refreshing" if self.access_token else "Setting", self.client_id) - self.access_token = access_token - - def _get_hash_values(self): - # 'access_token' may be refreshed once in a while. This should not affect the hash signature. - # 'identity' is just informational and should also not affect the hash signature. - return (getattr(self, k) for k in self.__dict__ if k not in ("_lock", "identity", "access_token")) - - def sig(self): - # Like hash(self), but pulls in the access token. Protocol.refresh_credentials() uses this to find out - # if the access_token needs to be refreshed. - res = [] - for k in self.__dict__: - if k in ("_lock", "identity"): - continue - if k == "access_token": - res.append(self.access_token["access_token"] if self.access_token else None) - continue - res.append(getattr(self, k)) - return hash(tuple(res)) - @property def token_url(self): return f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token" @@ -705,108 +941,30 @@

    Inherited members

    def scope(self): return ["https://outlook.office365.com/.default"] - def __repr__(self): - return self.__class__.__name__ + repr((self.client_id, "********")) - - def __str__(self): - return self.client_id
    + @threaded_cached_property + def client(self): + return oauthlib.oauth2.BackendApplicationClient(client_id=self.client_id)

    Ancestors

    Subclasses

    -

    Instance variables

    -
    -
    var scope
    -
    -
    -
    - -Expand source code - -
    @property
    -def scope(self):
    -    return ["https://outlook.office365.com/.default"]
    -
    -
    -
    var token_url
    -
    -
    -
    - -Expand source code - -
    @property
    -def token_url(self):
    -    return f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token"
    -
    -
    -
    -

    Methods

    -
    -
    -def on_token_auto_refreshed(self, access_token) -
    -
    -

    Set the access_token. Called after the access token is refreshed (requests-oauthlib can automatically -refresh tokens if given an OAuth client ID and secret, so this is how our copy of the token stays up-to-date). -Applications that cache access tokens can override this to store the new token - just remember to call the -super() method.

    -

    :param access_token: New token obtained by refreshing

    -
    - -Expand source code - -
    def on_token_auto_refreshed(self, access_token):
    -    """Set the access_token. Called after the access token is refreshed (requests-oauthlib can automatically
    -    refresh tokens if given an OAuth client ID and secret, so this is how our copy of the token stays up-to-date).
    -    Applications that cache access tokens can override this to store the new token - just remember to call the
    -    super() method.
    -
    -    :param access_token: New token obtained by refreshing
    -    """
    -    # Ensure we don't update the object in the middle of a new session being created, which could cause a race.
    -    if not isinstance(access_token, dict):
    -        raise InvalidTypeError("access_token", access_token, OAuth2Token)
    -    with self.lock:
    -        log.debug("%s auth token for %s", "Refreshing" if self.access_token else "Setting", self.client_id)
    -        self.access_token = access_token
    -
    -
    -
    -def sig(self) -
    -
    -
    -
    - -Expand source code - -
    def sig(self):
    -    # Like hash(self), but pulls in the access token. Protocol.refresh_credentials() uses this to find out
    -    # if the access_token needs to be refreshed.
    -    res = []
    -    for k in self.__dict__:
    -        if k in ("_lock", "identity"):
    -            continue
    -        if k == "access_token":
    -            res.append(self.access_token["access_token"] if self.access_token else None)
    -            continue
    -        res.append(getattr(self, k))
    -    return hash(tuple(res))
    -
    -
    -

    Inherited members

    @@ -820,7 +978,7 @@

    Inherited members

    This requires the app to acquire username and password from the user and pass that when requesting authentication tokens for the given user. This allows the app to act as the signed-in user.

    :param username: The username of the user to act as -:poram password: The password of the user to act as

    +:param password: The password of the user to act as

    Expand source code @@ -835,12 +993,26 @@

    Inherited members

    def __init__(self, username, password, **kwargs): """ :param username: The username of the user to act as - :poram password: The password of the user to act as + :param password: The password of the user to act as """ super().__init__(**kwargs) self.username = username self.password = password + def token_params(self): + res = super().token_params() + res.update( + { + "username": self.username, + "password": self.password, + } + ) + return res + + @threaded_cached_property + def client(self): + return oauthlib.oauth2.LegacyApplicationClient(client_id=self.client_id) + @property def scope(self): return ["https://outlook.office365.com/EWS.AccessAsUser.All"]
    @@ -848,29 +1020,28 @@

    Inherited members

    Ancestors

    -

    Instance variables

    -
    -
    var scope
    -
    -
    -
    - -Expand source code - -
    @property
    -def scope(self):
    -    return ["https://outlook.office365.com/EWS.AccessAsUser.All"]
    -
    -
    -

    Inherited members

    @@ -893,9 +1064,20 @@

    Index

    diff --git a/docs/exchangelib/ewsdatetime.html b/docs/exchangelib/ewsdatetime.html index 6a3846ab..02a1824d 100644 --- a/docs/exchangelib/ewsdatetime.html +++ b/docs/exchangelib/ewsdatetime.html @@ -86,7 +86,7 @@

    Module exchangelib.ewsdatetime

    @classmethod def from_string(cls, date_string): - # Sometimes, we'll receive a date string with timezone information. Not very useful. + # Sometimes, we'll receive a date string with time zone information. Not very useful. if date_string.endswith("Z"): date_fmt = "%Y-%m-%dZ" elif ":" in date_string: @@ -184,7 +184,7 @@

    Module exchangelib.ewsdatetime

    @classmethod def from_string(cls, date_string): - # Parses several common datetime formats and returns timezone-aware EWSDateTime objects + # Parses several common datetime formats and returns time zone aware EWSDateTime objects if date_string.endswith("Z"): # UTC datetime return super().strptime(date_string, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) @@ -192,7 +192,7 @@

    Module exchangelib.ewsdatetime

    # This is probably a naive datetime. Don't allow this, but signal caller with an appropriate error local_dt = super().strptime(date_string, "%Y-%m-%dT%H:%M:%S") raise NaiveDateTimeNotAllowed(local_dt) - # This is probably a datetime value with timezone information. This comes in the form '+/-HH:MM'. + # This is probably a datetime value with time zone information. This comes in the form '+/-HH:MM'. aware_dt = datetime.datetime.fromisoformat(date_string).astimezone(UTC).replace(tzinfo=UTC) if isinstance(aware_dt, cls): return aware_dt @@ -234,7 +234,7 @@

    Module exchangelib.ewsdatetime

    class EWSTimeZone(zoneinfo.ZoneInfo): - """Represents a timezone as expected by the EWS TimezoneContext / TimezoneDefinition XML element, and returned by + """Represents a time zone as expected by the EWS TimezoneContext / TimezoneDefinition XML element, and returned by services.GetServerTimeZones. """ @@ -251,24 +251,24 @@

    Module exchangelib.ewsdatetime

    except KeyError: raise UnknownTimeZone(f"No Windows timezone name found for timezone {instance.key!r}") - # We don't need the Windows long-format timezone name in long format. It's used in timezone XML elements, but - # EWS happily accepts empty strings. For a full list of timezones supported by the target server, including + # We don't need the Windows long-format time zone name in long format. It's used in time zone XML elements, but + # EWS happily accepts empty strings. For a full list of time zones supported by the target server, including # long-format names, see output of services.GetServerTimeZones(account.protocol).call() instance.ms_name = "" return instance def __eq__(self, other): - # Microsoft timezones are less granular than IANA, so an EWSTimeZone created from 'Europe/Copenhagen' may return - # from the server as 'Europe/Copenhagen'. We're catering for Microsoft here, so base equality on the Microsoft - # timezone ID. + # Microsoft time zones are less granular than IANA, so an EWSTimeZone created from 'Europe/Copenhagen' may + # return from the server as 'Europe/Copenhagen'. We're catering for Microsoft here, so base equality on the + # Microsoft time zone ID. if not isinstance(other, self.__class__): return NotImplemented return self.ms_id == other.ms_id @classmethod def from_ms_id(cls, ms_id): - # Create a timezone instance from a Microsoft timezone ID. This is lossy because there is not a 1:1 translation - # from MS timezone ID to IANA timezone. + # Create a time zone instance from a Microsoft time zone ID. This is lossy because there is not a 1:1 + # translation from MS time zone ID to IANA time zone. try: return cls(cls.MS_TO_IANA_MAP[ms_id]) except KeyError: @@ -290,7 +290,7 @@

    Module exchangelib.ewsdatetime

    @classmethod def from_dateutil(cls, tz): # Objects returned by dateutil.tz.tzlocal() and dateutil.tz.gettz() are not supported. They - # don't contain enough information to reliably match them with a CLDR timezone. + # don't contain enough information to reliably match them with a CLDR time zone. if hasattr(tz, "_filename"): key = "/".join(tz._filename.split("/")[-2:]) return cls(key) @@ -417,7 +417,7 @@

    Classes

    @classmethod def from_string(cls, date_string): - # Sometimes, we'll receive a date string with timezone information. Not very useful. + # Sometimes, we'll receive a date string with time zone information. Not very useful. if date_string.endswith("Z"): date_fmt = "%Y-%m-%dZ" elif ":" in date_string: @@ -465,7 +465,7 @@

    Static methods

    @classmethod
     def from_string(cls, date_string):
    -    # Sometimes, we'll receive a date string with timezone information. Not very useful.
    +    # Sometimes, we'll receive a date string with time zone information. Not very useful.
         if date_string.endswith("Z"):
             date_fmt = "%Y-%m-%dZ"
         elif ":" in date_string:
    @@ -609,7 +609,7 @@ 

    Methods

    @classmethod def from_string(cls, date_string): - # Parses several common datetime formats and returns timezone-aware EWSDateTime objects + # Parses several common datetime formats and returns time zone aware EWSDateTime objects if date_string.endswith("Z"): # UTC datetime return super().strptime(date_string, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) @@ -617,7 +617,7 @@

    Methods

    # This is probably a naive datetime. Don't allow this, but signal caller with an appropriate error local_dt = super().strptime(date_string, "%Y-%m-%dT%H:%M:%S") raise NaiveDateTimeNotAllowed(local_dt) - # This is probably a datetime value with timezone information. This comes in the form '+/-HH:MM'. + # This is probably a datetime value with time zone information. This comes in the form '+/-HH:MM'. aware_dt = datetime.datetime.fromisoformat(date_string).astimezone(UTC).replace(tzinfo=UTC) if isinstance(aware_dt, cls): return aware_dt @@ -697,7 +697,7 @@

    Static methods

    @classmethod
     def from_string(cls, date_string):
    -    # Parses several common datetime formats and returns timezone-aware EWSDateTime objects
    +    # Parses several common datetime formats and returns time zone aware EWSDateTime objects
         if date_string.endswith("Z"):
             # UTC datetime
             return super().strptime(date_string, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
    @@ -705,7 +705,7 @@ 

    Static methods

    # This is probably a naive datetime. Don't allow this, but signal caller with an appropriate error local_dt = super().strptime(date_string, "%Y-%m-%dT%H:%M:%S") raise NaiveDateTimeNotAllowed(local_dt) - # This is probably a datetime value with timezone information. This comes in the form '+/-HH:MM'. + # This is probably a datetime value with time zone information. This comes in the form '+/-HH:MM'. aware_dt = datetime.datetime.fromisoformat(date_string).astimezone(UTC).replace(tzinfo=UTC) if isinstance(aware_dt, cls): return aware_dt @@ -866,14 +866,14 @@

    Methods

    (*args, **kwargs)
    -

    Represents a timezone as expected by the EWS TimezoneContext / TimezoneDefinition XML element, and returned by +

    Represents a time zone as expected by the EWS TimezoneContext / TimezoneDefinition XML element, and returned by services.GetServerTimeZones.

    Expand source code
    class EWSTimeZone(zoneinfo.ZoneInfo):
    -    """Represents a timezone as expected by the EWS TimezoneContext / TimezoneDefinition XML element, and returned by
    +    """Represents a time zone as expected by the EWS TimezoneContext / TimezoneDefinition XML element, and returned by
         services.GetServerTimeZones.
         """
     
    @@ -890,24 +890,24 @@ 

    Methods

    except KeyError: raise UnknownTimeZone(f"No Windows timezone name found for timezone {instance.key!r}") - # We don't need the Windows long-format timezone name in long format. It's used in timezone XML elements, but - # EWS happily accepts empty strings. For a full list of timezones supported by the target server, including + # We don't need the Windows long-format time zone name in long format. It's used in time zone XML elements, but + # EWS happily accepts empty strings. For a full list of time zones supported by the target server, including # long-format names, see output of services.GetServerTimeZones(account.protocol).call() instance.ms_name = "" return instance def __eq__(self, other): - # Microsoft timezones are less granular than IANA, so an EWSTimeZone created from 'Europe/Copenhagen' may return - # from the server as 'Europe/Copenhagen'. We're catering for Microsoft here, so base equality on the Microsoft - # timezone ID. + # Microsoft time zones are less granular than IANA, so an EWSTimeZone created from 'Europe/Copenhagen' may + # return from the server as 'Europe/Copenhagen'. We're catering for Microsoft here, so base equality on the + # Microsoft time zone ID. if not isinstance(other, self.__class__): return NotImplemented return self.ms_id == other.ms_id @classmethod def from_ms_id(cls, ms_id): - # Create a timezone instance from a Microsoft timezone ID. This is lossy because there is not a 1:1 translation - # from MS timezone ID to IANA timezone. + # Create a time zone instance from a Microsoft time zone ID. This is lossy because there is not a 1:1 + # translation from MS time zone ID to IANA time zone. try: return cls(cls.MS_TO_IANA_MAP[ms_id]) except KeyError: @@ -929,7 +929,7 @@

    Methods

    @classmethod def from_dateutil(cls, tz): # Objects returned by dateutil.tz.tzlocal() and dateutil.tz.gettz() are not supported. They - # don't contain enough information to reliably match them with a CLDR timezone. + # don't contain enough information to reliably match them with a CLDR time zone. if hasattr(tz, "_filename"): key = "/".join(tz._filename.split("/")[-2:]) return cls(key) @@ -1018,7 +1018,7 @@

    Static methods

    @classmethod
     def from_dateutil(cls, tz):
         # Objects returned by dateutil.tz.tzlocal() and dateutil.tz.gettz() are not supported. They
    -    # don't contain enough information to reliably match them with a CLDR timezone.
    +    # don't contain enough information to reliably match them with a CLDR time zone.
         if hasattr(tz, "_filename"):
             key = "/".join(tz._filename.split("/")[-2:])
             return cls(key)
    @@ -1036,8 +1036,8 @@ 

    Static methods

    @classmethod
     def from_ms_id(cls, ms_id):
    -    # Create a timezone instance from a Microsoft timezone ID. This is lossy because there is not a 1:1 translation
    -    # from MS timezone ID to IANA timezone.
    +    # Create a time zone instance from a Microsoft time zone ID. This is lossy because there is not a 1:1
    +    # translation from MS time zone ID to IANA time zone.
         try:
             return cls(cls.MS_TO_IANA_MAP[ms_id])
         except KeyError:
    diff --git a/docs/exchangelib/extended_properties.html b/docs/exchangelib/extended_properties.html
    index fe238892..6616e981 100644
    --- a/docs/exchangelib/extended_properties.html
    +++ b/docs/exchangelib/extended_properties.html
    @@ -139,7 +139,7 @@ 

    Module exchangelib.extended_properties

    @classmethod def validate_cls(cls): - # Validate values of class attributes and their inter-dependencies + # Validate values of class attributes and their interdependencies cls._validate_distinguished_property_set_id() cls._validate_property_set_id() cls._validate_property_tag() @@ -238,7 +238,7 @@

    Module exchangelib.extended_properties

    do not have a name, so we must match on the cls.property_* attributes to match a field in the request with a field in the response. """ - # We can't use ExtendedFieldURI.from_xml(). It clears the XML element but we may not want to consume it here. + # We can't use ExtendedFieldURI.from_xml(). It clears the XML element, but we may not want to consume it here. kwargs = { f.name: f.from_xml(elem=elem.find(ExtendedFieldURI.response_tag()), account=None) for f in ExtendedFieldURI.FIELDS @@ -449,7 +449,7 @@

    Classes

    @classmethod def validate_cls(cls): - # Validate values of class attributes and their inter-dependencies + # Validate values of class attributes and their interdependencies cls._validate_distinguished_property_set_id() cls._validate_property_set_id() cls._validate_property_tag() @@ -548,7 +548,7 @@

    Classes

    do not have a name, so we must match on the cls.property_* attributes to match a field in the request with a field in the response. """ - # We can't use ExtendedFieldURI.from_xml(). It clears the XML element but we may not want to consume it here. + # We can't use ExtendedFieldURI.from_xml(). It clears the XML element, but we may not want to consume it here. kwargs = { f.name: f.from_xml(elem=elem.find(ExtendedFieldURI.response_tag()), account=None) for f in ExtendedFieldURI.FIELDS @@ -765,7 +765,7 @@

    Static methods

    do not have a name, so we must match on the cls.property_* attributes to match a field in the request with a field in the response. """ - # We can't use ExtendedFieldURI.from_xml(). It clears the XML element but we may not want to consume it here. + # We can't use ExtendedFieldURI.from_xml(). It clears the XML element, but we may not want to consume it here. kwargs = { f.name: f.from_xml(elem=elem.find(ExtendedFieldURI.response_tag()), account=None) for f in ExtendedFieldURI.FIELDS @@ -845,7 +845,7 @@

    Static methods

    @classmethod
     def validate_cls(cls):
    -    # Validate values of class attributes and their inter-dependencies
    +    # Validate values of class attributes and their interdependencies
         cls._validate_distinguished_property_set_id()
         cls._validate_property_set_id()
         cls._validate_property_tag()
    diff --git a/docs/exchangelib/fields.html b/docs/exchangelib/fields.html
    index 73ca36e0..34ec7be7 100644
    --- a/docs/exchangelib/fields.html
    +++ b/docs/exchangelib/fields.html
    @@ -45,7 +45,7 @@ 

    Module exchangelib.fields

    value_to_xml_text, xml_text_to_value, ) -from .version import EXCHANGE_2013, Build +from .version import EXCHANGE_2013, Build, SupportedVersionInstanceMixIn log = logging.getLogger(__name__) @@ -297,7 +297,7 @@

    Module exchangelib.fields

    return field_order -class Field(metaclass=abc.ABCMeta): +class Field(SupportedVersionInstanceMixIn, metaclass=abc.ABCMeta): """Holds information related to an item field.""" value_cls = None @@ -320,8 +320,8 @@

    Module exchangelib.fields

    is_searchable=True, is_attribute=False, default=None, - supported_from=None, - deprecated_from=None, + *args, + **kwargs, ): self.name = name # Usually set by the EWSMeta metaclass self.default = default # Default value if none is given @@ -337,16 +337,7 @@

    Module exchangelib.fields

    self.is_searchable = is_searchable # When true, this field is treated as an XML attribute instead of an element self.is_attribute = is_attribute - # The Exchange build when this field was introduced. When talking with versions prior to this version, - # we will ignore this field. - if supported_from is not None and not isinstance(supported_from, Build): - raise InvalidTypeError("supported_from", supported_from, Build) - self.supported_from = supported_from - # The Exchange build when this field was deprecated. When talking with versions at or later than this version, - # we will ignore this field. - if deprecated_from is not None and not isinstance(deprecated_from, Build): - raise InvalidTypeError("deprecated_from", deprecated_from, Build) - self.deprecated_from = deprecated_from + super().__init__(*args, **kwargs) def clean(self, value, version=None): if version and not self.supports_version(version): @@ -380,14 +371,6 @@

    Module exchangelib.fields

    def to_xml(self, value, version): """Convert this field to an XML element""" - def supports_version(self, version): - # 'version' is a Version instance, for convenience by callers - if self.supported_from and version.build < self.supported_from: - return False - if self.deprecated_from and version.build >= self.deprecated_from: - return False - return True - def __eq__(self, other): return hash(self) == hash(other) @@ -930,18 +913,12 @@

    Module exchangelib.fields

    """Helper to mark strings that are # RFC 1766 culture values.""" -class Choice: +class Choice(SupportedVersionInstanceMixIn): """Implement versioned choices for the ChoiceField field.""" - def __init__(self, value, supported_from=None): + def __init__(self, value, *args, **kwargs): self.value = value - self.supported_from = supported_from - - def supports_version(self, version): - # 'version' is a Version instance, for convenience by callers - if not self.supported_from: - return True - return version.build >= self.supported_from + super().__init__(*args, **kwargs) class ChoiceField(CharField): @@ -962,7 +939,7 @@

    Module exchangelib.fields

    return value if value in valid_choices: raise InvalidChoiceForVersion( - f"Choice {self.name!r} only supports EWS builds from {self.supported_from or '*'} to " + f"Choice {self.name!r} only supports server versions from {self.supported_from or '*'} to " f"{self.deprecated_from or '*'} (server has {version})" ) else: @@ -1581,7 +1558,7 @@

    Module exchangelib.fields

    class TypeValueField(FieldURIField): - """This field type has no value_cls because values may have many different types.""" + """This field type has no value_cls because values may have many types.""" TYPES_MAP = { "Boolean": bool, @@ -1946,6 +1923,7 @@

    Ancestors

  • IntegerField
  • FieldURIField
  • Field
  • +
  • SupportedVersionInstanceMixIn
  • Class variables

    @@ -2008,6 +1986,7 @@

    Ancestors

  • EWSElementField
  • FieldURIField
  • Field
  • +
  • SupportedVersionInstanceMixIn
  • Class variables

    @@ -2065,6 +2044,7 @@

    Ancestors

  • EWSElementField
  • FieldURIField
  • Field
  • +
  • SupportedVersionInstanceMixIn
  • Inherited members

    Methods

    @@ -2168,6 +2149,7 @@

    Ancestors

    Subclasses

    Subclasses

    Class variables

    @@ -2378,6 +2362,7 @@

    Ancestors

  • TextField
  • FieldURIField
  • Field
  • +
  • SupportedVersionInstanceMixIn
  • Methods

    @@ -2427,6 +2412,7 @@

    Ancestors

    Subclasses

    Inherited members

    Subclasses

    Subclasses

      @@ -2646,7 +2635,7 @@

      Inherited members

    class Choice -(value, supported_from=None) +(value, *args, **kwargs)

    Implement versioned choices for the ChoiceField field.

    @@ -2654,38 +2643,17 @@

    Inherited members

    Expand source code -
    class Choice:
    +
    class Choice(SupportedVersionInstanceMixIn):
         """Implement versioned choices for the ChoiceField field."""
     
    -    def __init__(self, value, supported_from=None):
    +    def __init__(self, value, *args, **kwargs):
             self.value = value
    -        self.supported_from = supported_from
    -
    -    def supports_version(self, version):
    -        # 'version' is a Version instance, for convenience by callers
    -        if not self.supported_from:
    -            return True
    -        return version.build >= self.supported_from
    -
    -

    Methods

    -
    -
    -def supports_version(self, version) -
    -
    -
    -
    - -Expand source code - -
    def supports_version(self, version):
    -    # 'version' is a Version instance, for convenience by callers
    -    if not self.supported_from:
    -        return True
    -    return version.build >= self.supported_from
    + super().__init__(*args, **kwargs)
    -
    -
    +

    Ancestors

    +
    class ChoiceField @@ -2715,7 +2683,7 @@

    Methods

    return value if value in valid_choices: raise InvalidChoiceForVersion( - f"Choice {self.name!r} only supports EWS builds from {self.supported_from or '*'} to " + f"Choice {self.name!r} only supports server versions from {self.supported_from or '*'} to " f"{self.deprecated_from or '*'} (server has {version})" ) else: @@ -2732,6 +2700,7 @@

    Ancestors

  • TextField
  • FieldURIField
  • Field
  • +
  • SupportedVersionInstanceMixIn
  • Subclasses

      @@ -2761,7 +2730,7 @@

      Methods

      return value if value in valid_choices: raise InvalidChoiceForVersion( - f"Choice {self.name!r} only supports EWS builds from {self.supported_from or '*'} to " + f"Choice {self.name!r} only supports server versions from {self.supported_from or '*'} to " f"{self.deprecated_from or '*'} (server has {version})" ) else: @@ -2814,6 +2783,7 @@

      Ancestors

    • TextField
    • FieldURIField
    • Field
    • +
    • SupportedVersionInstanceMixIn

    Inherited members

    Methods

    @@ -3096,6 +3069,7 @@

    Ancestors

    Subclasses

    Inherited members

    Inherited members

      @@ -3661,7 +3644,7 @@

      Inherited members

      class EmailSubField -(name=None, is_required=False, is_required_after_save=False, is_read_only=False, is_read_only_after_send=False, is_searchable=True, is_attribute=False, default=None, supported_from=None, deprecated_from=None) +(name=None, is_required=False, is_required_after_save=False, is_read_only=False, is_read_only_after_send=False, is_searchable=True, is_attribute=False, default=None, *args, **kwargs)

      A field to hold the value on an SingleFieldIndexedElement.

      @@ -3681,6 +3664,7 @@

      Ancestors

      Inherited members

      Inherited members

      Subclasses

      Subclasses

      class Field -(name=None, is_required=False, is_required_after_save=False, is_read_only=False, is_read_only_after_send=False, is_searchable=True, is_attribute=False, default=None, supported_from=None, deprecated_from=None) +(name=None, is_required=False, is_required_after_save=False, is_read_only=False, is_read_only_after_send=False, is_searchable=True, is_attribute=False, default=None, *args, **kwargs)

      Holds information related to an item field.

      @@ -4093,7 +4082,7 @@

      Inherited members

      Expand source code -
      class Field(metaclass=abc.ABCMeta):
      +
      class Field(SupportedVersionInstanceMixIn, metaclass=abc.ABCMeta):
           """Holds information related to an item field."""
       
           value_cls = None
      @@ -4116,8 +4105,8 @@ 

      Inherited members

      is_searchable=True, is_attribute=False, default=None, - supported_from=None, - deprecated_from=None, + *args, + **kwargs, ): self.name = name # Usually set by the EWSMeta metaclass self.default = default # Default value if none is given @@ -4133,16 +4122,7 @@

      Inherited members

      self.is_searchable = is_searchable # When true, this field is treated as an XML attribute instead of an element self.is_attribute = is_attribute - # The Exchange build when this field was introduced. When talking with versions prior to this version, - # we will ignore this field. - if supported_from is not None and not isinstance(supported_from, Build): - raise InvalidTypeError("supported_from", supported_from, Build) - self.supported_from = supported_from - # The Exchange build when this field was deprecated. When talking with versions at or later than this version, - # we will ignore this field. - if deprecated_from is not None and not isinstance(deprecated_from, Build): - raise InvalidTypeError("deprecated_from", deprecated_from, Build) - self.deprecated_from = deprecated_from + super().__init__(*args, **kwargs) def clean(self, value, version=None): if version and not self.supports_version(version): @@ -4176,14 +4156,6 @@

      Inherited members

      def to_xml(self, value, version): """Convert this field to an XML element""" - def supports_version(self, version): - # 'version' is a Version instance, for convenience by callers - if self.supported_from and version.build < self.supported_from: - return False - if self.deprecated_from and version.build >= self.deprecated_from: - return False - return True - def __eq__(self, other): return hash(self) == hash(other) @@ -4197,6 +4169,10 @@

      Inherited members

      ) return f"{self.__class__.__name__}({args_str})"
      +

      Ancestors

      +

      Subclasses

      -
      -def supports_version(self, version) -
      -
      -
      -
      - -Expand source code - -
      def supports_version(self, version):
      -    # 'version' is a Version instance, for convenience by callers
      -    if self.supported_from and version.build < self.supported_from:
      -        return False
      -    if self.deprecated_from and version.build >= self.deprecated_from:
      -        return False
      -    return True
      -
      -
      def to_xml(self, value, version)
      @@ -4678,6 +4636,7 @@

      Methods

      Ancestors

      Subclasses

      Inherited members

      Class variables

      @@ -4891,6 +4852,7 @@

      Ancestors

    • EWSElementField
    • FieldURIField
    • Field
    • +
    • SupportedVersionInstanceMixIn

    Inherited members

    Inherited members

    Subclasses

    Methods

    @@ -5475,6 +5445,7 @@

    Ancestors

  • TextField
  • FieldURIField
  • Field
  • +
  • SupportedVersionInstanceMixIn
  • Class variables

    @@ -5517,6 +5488,7 @@

    Ancestors

  • EWSElementField
  • FieldURIField
  • Field
  • +
  • SupportedVersionInstanceMixIn
  • Inherited members

    Inherited members

    Subclasses

    Class variables

    @@ -5761,6 +5737,7 @@

    Ancestors

  • BooleanField
  • FieldURIField
  • Field
  • +
  • SupportedVersionInstanceMixIn
  • Inherited members

    Class variables

    @@ -5839,6 +5817,7 @@

    Ancestors

  • EWSElementField
  • FieldURIField
  • Field
  • +
  • SupportedVersionInstanceMixIn
  • Class variables

    @@ -5880,6 +5859,7 @@

    Ancestors

  • EWSElementField
  • FieldURIField
  • Field
  • +
  • SupportedVersionInstanceMixIn
  • Inherited members

    Class variables

    @@ -5973,6 +5954,7 @@

    Ancestors

  • EWSElementField
  • FieldURIField
  • Field
  • +
  • SupportedVersionInstanceMixIn
  • Class variables

    @@ -6022,6 +6004,7 @@

    Ancestors

  • EWSElementField
  • FieldURIField
  • Field
  • +
  • SupportedVersionInstanceMixIn
  • Inherited members

    Inherited members

    Inherited members

    Class variables

    @@ -6177,6 +6163,7 @@

    Ancestors

  • EWSElementField
  • FieldURIField
  • Field
  • +
  • SupportedVersionInstanceMixIn
  • Class variables

    @@ -6218,6 +6205,7 @@

    Ancestors

  • TextField
  • FieldURIField
  • Field
  • +
  • SupportedVersionInstanceMixIn
  • Inherited members

    Inherited members

    -

    This field type has no value_cls because values may have many different types.

    +

    This field type has no value_cls because values may have many types.

    Expand source code
    class TypeValueField(FieldURIField):
    -    """This field type has no value_cls because values may have many different types."""
    +    """This field type has no value_cls because values may have many types."""
     
         TYPES_MAP = {
             "Boolean": bool,
    @@ -6921,6 +6918,7 @@ 

    Ancestors

    Class variables

    @@ -7028,6 +7026,7 @@

    Ancestors

  • TextField
  • FieldURIField
  • Field
  • +
  • SupportedVersionInstanceMixIn
  • Inherited members

    Methods

    @@ -7114,6 +7114,7 @@

    Ancestors

  • IntegerField
  • FieldURIField
  • Field
  • +
  • SupportedVersionInstanceMixIn
  • Methods

    @@ -7241,9 +7242,6 @@

    Choice

    -
  • ChoiceField

    @@ -7365,7 +7363,6 @@

    F
  • from_xml
  • is_complex
  • is_list
  • -
  • supports_version
  • to_xml
  • value_cls
  • diff --git a/docs/exchangelib/folders/base.html b/docs/exchangelib/folders/base.html index bf3e5102..983e768f 100644 --- a/docs/exchangelib/folders/base.html +++ b/docs/exchangelib/folders/base.html @@ -68,7 +68,7 @@

    Module exchangelib.folders.base

    ) from ..queryset import DoesNotExist, SearchableMixIn from ..util import TNS, is_iterable, require_id -from ..version import EXCHANGE_2007_SP1, EXCHANGE_2010 +from ..version import EXCHANGE_2007_SP1, EXCHANGE_2010, SupportedVersionClassMixIn from .collections import FolderCollection, PullSubscription, PushSubscription, StreamingSubscription, SyncCompleted from .queryset import DEEP as DEEP_FOLDERS from .queryset import MISSING_FOLDER_ERRORS @@ -85,7 +85,7 @@

    Module exchangelib.folders.base

    ) -class BaseFolder(RegisterMixIn, SearchableMixIn, metaclass=EWSMeta): +class BaseFolder(RegisterMixIn, SearchableMixIn, SupportedVersionClassMixIn, metaclass=EWSMeta): """Base class for all classes that implement a folder.""" ELEMENT_NAME = "Folder" @@ -96,9 +96,6 @@

    Module exchangelib.folders.base

    # https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxosfld/68a85898-84fe-43c4-b166-4711c13cdd61 CONTAINER_CLASS = None supported_item_models = ITEM_CLASSES # The Item types that this folder can contain. Default is all - # Marks the version from which a distinguished folder was introduced. A possibly authoritative source is: - # https://github.com/OfficeDev/ews-managed-api/blob/master/Enumerations/WellKnownFolderName.cs - supported_from = None # Whether this folder type is allowed with the GetFolder service get_folder_allowed = True DEFAULT_FOLDER_TRAVERSAL_DEPTH = DEEP_FOLDERS @@ -190,7 +187,7 @@

    Module exchangelib.folders.base

    raise ValueError("Already at top") yield from self.parent.glob(tail or "*") elif head == "**": - # Match anything here or in any subfolder at arbitrary depth + # Match anything here or in any sub-folder at arbitrary depth for c in self.walk(): # fnmatch() may be case-sensitive depending on operating system: # force a case-insensitive match since case appears not to @@ -236,17 +233,10 @@

    Module exchangelib.folders.base

    elif i == len(children) and j == 1: # Not the last child, but the first node, which is the name of the child tree += f"└── {node}\n" - else: # Last child, and not name of child + else: # Last child and not name of child tree += f" {node}\n" return tree.strip() - @classmethod - def supports_version(cls, version): - # 'version' is a Version instance, for convenience by callers - if not cls.supported_from: - return True - return version.build >= cls.supported_from - @property def has_distinguished_name(self): return self.name and self.DISTINGUISHED_FOLDER_ID and self.name.lower() == self.DISTINGUISHED_FOLDER_ID.lower() @@ -449,7 +439,7 @@

    Module exchangelib.folders.base

    self.root.clear_cache() def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0): - # Recursively deletes all items in this folder, and all subfolders and their content. Attempts to protect + # Recursively deletes all items in this folder, and all sub-folders and their content. Attempts to protect # distinguished folders from being deleted. Use with caution! from .known_folders import Audits @@ -495,7 +485,7 @@

    Module exchangelib.folders.base

    _level += 1 for f in self.children: f.wipe(page_size=page_size, chunk_size=chunk_size, _seen=_seen, _level=_level) - # Remove non-distinguished children that are empty and have no subfolders + # Remove non-distinguished children that are empty and have no sub-folders if f.is_deletable and not f.children: log.warning("Deleting folder %s", f) try: @@ -512,7 +502,7 @@

    Module exchangelib.folders.base

    @classmethod def _kwargs_from_elem(cls, elem, account): - # Check for 'DisplayName' element before collecting kwargs because because that clears the elements + # Check for 'DisplayName' element before collecting kwargs because that clears the elements has_name_elem = elem.find(cls.get_field_by_fieldname("name").response_tag()) is not None kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS} if has_name_elem and not kwargs["name"]: @@ -732,7 +722,7 @@

    Module exchangelib.folders.base

    """Get events since the given watermark. Non-blocking. :param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|push]() - :param watermark: Either the watermark from the subscription, or as returned in the last .get_events() call. + :param watermark: Either the watermark from the subscription, or as returned by the last .get_events() call. :return: A Notification object containing a list of events This method doesn't need the current folder instance, but it makes sense to keep the method along the other @@ -781,7 +771,7 @@

    Module exchangelib.folders.base

    Works like as __truediv__ but does not touch the folder cache. - This is useful if the folder hierarchy contains a huge number of folders and you don't want to fetch them all + This is useful if the folder hierarchy contains a huge number of folders, and you don't want to fetch them all :param other: :return: @@ -975,7 +965,7 @@

    Classes

    Expand source code -
    class BaseFolder(RegisterMixIn, SearchableMixIn, metaclass=EWSMeta):
    +
    class BaseFolder(RegisterMixIn, SearchableMixIn, SupportedVersionClassMixIn, metaclass=EWSMeta):
         """Base class for all classes that implement a folder."""
     
         ELEMENT_NAME = "Folder"
    @@ -986,9 +976,6 @@ 

    Classes

    # https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxosfld/68a85898-84fe-43c4-b166-4711c13cdd61 CONTAINER_CLASS = None supported_item_models = ITEM_CLASSES # The Item types that this folder can contain. Default is all - # Marks the version from which a distinguished folder was introduced. A possibly authoritative source is: - # https://github.com/OfficeDev/ews-managed-api/blob/master/Enumerations/WellKnownFolderName.cs - supported_from = None # Whether this folder type is allowed with the GetFolder service get_folder_allowed = True DEFAULT_FOLDER_TRAVERSAL_DEPTH = DEEP_FOLDERS @@ -1080,7 +1067,7 @@

    Classes

    raise ValueError("Already at top") yield from self.parent.glob(tail or "*") elif head == "**": - # Match anything here or in any subfolder at arbitrary depth + # Match anything here or in any sub-folder at arbitrary depth for c in self.walk(): # fnmatch() may be case-sensitive depending on operating system: # force a case-insensitive match since case appears not to @@ -1126,17 +1113,10 @@

    Classes

    elif i == len(children) and j == 1: # Not the last child, but the first node, which is the name of the child tree += f"└── {node}\n" - else: # Last child, and not name of child + else: # Last child and not name of child tree += f" {node}\n" return tree.strip() - @classmethod - def supports_version(cls, version): - # 'version' is a Version instance, for convenience by callers - if not cls.supported_from: - return True - return version.build >= cls.supported_from - @property def has_distinguished_name(self): return self.name and self.DISTINGUISHED_FOLDER_ID and self.name.lower() == self.DISTINGUISHED_FOLDER_ID.lower() @@ -1339,7 +1319,7 @@

    Classes

    self.root.clear_cache() def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0): - # Recursively deletes all items in this folder, and all subfolders and their content. Attempts to protect + # Recursively deletes all items in this folder, and all sub-folders and their content. Attempts to protect # distinguished folders from being deleted. Use with caution! from .known_folders import Audits @@ -1385,7 +1365,7 @@

    Classes

    _level += 1 for f in self.children: f.wipe(page_size=page_size, chunk_size=chunk_size, _seen=_seen, _level=_level) - # Remove non-distinguished children that are empty and have no subfolders + # Remove non-distinguished children that are empty and have no sub-folders if f.is_deletable and not f.children: log.warning("Deleting folder %s", f) try: @@ -1402,7 +1382,7 @@

    Classes

    @classmethod def _kwargs_from_elem(cls, elem, account): - # Check for 'DisplayName' element before collecting kwargs because because that clears the elements + # Check for 'DisplayName' element before collecting kwargs because that clears the elements has_name_elem = elem.find(cls.get_field_by_fieldname("name").response_tag()) is not None kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS} if has_name_elem and not kwargs["name"]: @@ -1622,7 +1602,7 @@

    Classes

    """Get events since the given watermark. Non-blocking. :param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|push]() - :param watermark: Either the watermark from the subscription, or as returned in the last .get_events() call. + :param watermark: Either the watermark from the subscription, or as returned by the last .get_events() call. :return: A Notification object containing a list of events This method doesn't need the current folder instance, but it makes sense to keep the method along the other @@ -1671,7 +1651,7 @@

    Classes

    Works like as __truediv__ but does not touch the folder cache. - This is useful if the folder hierarchy contains a huge number of folders and you don't want to fetch them all + This is useful if the folder hierarchy contains a huge number of folders, and you don't want to fetch them all :param other: :return: @@ -1724,6 +1704,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Subclasses

      @@ -1780,10 +1761,6 @@

      Class variables

      -
      var supported_from
      -
      -
      -
      var supported_item_models
      @@ -1946,23 +1923,6 @@

      Static methods

      return f

  • -
    -def supports_version(version) -
    -
    -
    -
    - -Expand source code - -
    @classmethod
    -def supports_version(cls, version):
    -    # 'version' is a Version instance, for convenience by callers
    -    if not cls.supported_from:
    -        return True
    -    return version.build >= cls.supported_from
    -
    -

    Instance variables

    @@ -2223,7 +2183,7 @@

    Methods

    Get events since the given watermark. Non-blocking.

    :param subscription_id: A subscription ID as acquired by .subscribe_to_pull|push -:param watermark: Either the watermark from the subscription, or as returned in the last .get_events() call. +:param watermark: Either the watermark from the subscription, or as returned by the last .get_events() call. :return: A Notification object containing a list of events

    This method doesn't need the current folder instance, but it makes sense to keep the method along the other sync methods.

    @@ -2235,7 +2195,7 @@

    Methods

    """Get events since the given watermark. Non-blocking. :param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|push]() - :param watermark: Either the watermark from the subscription, or as returned in the last .get_events() call. + :param watermark: Either the watermark from the subscription, or as returned by the last .get_events() call. :return: A Notification object containing a list of events This method doesn't need the current folder instance, but it makes sense to keep the method along the other @@ -2761,7 +2721,7 @@

    Methods

    elif i == len(children) and j == 1: # Not the last child, but the first node, which is the name of the child tree += f"└── {node}\n" - else: # Last child, and not name of child + else: # Last child and not name of child tree += f" {node}\n" return tree.strip()
    @@ -2851,7 +2811,7 @@

    Methods

    Expand source code
    def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0):
    -    # Recursively deletes all items in this folder, and all subfolders and their content. Attempts to protect
    +    # Recursively deletes all items in this folder, and all sub-folders and their content. Attempts to protect
         # distinguished folders from being deleted. Use with caution!
         from .known_folders import Audits
     
    @@ -2897,7 +2857,7 @@ 

    Methods

    _level += 1 for f in self.children: f.wipe(page_size=page_size, chunk_size=chunk_size, _seen=_seen, _level=_level) - # Remove non-distinguished children that are empty and have no subfolders + # Remove non-distinguished children that are empty and have no sub-folders if f.is_deletable and not f.children: log.warning("Deleting folder %s", f) try: @@ -3074,6 +3034,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Subclasses

      @@ -3344,9 +3305,7 @@

      subscribe_to_pull
    • subscribe_to_push
    • subscribe_to_streaming
    • -
    • supported_from
    • supported_item_models
    • -
    • supports_version
    • sync_hierarchy
    • sync_items
    • test_access
    • diff --git a/docs/exchangelib/folders/collections.html b/docs/exchangelib/folders/collections.html index 8ce2a04b..ce30d115 100644 --- a/docs/exchangelib/folders/collections.html +++ b/docs/exchangelib/folders/collections.html @@ -43,7 +43,7 @@

      Module exchangelib.folders.collections

      class SyncCompleted(Exception): - """This is a really ugly way of returning the sync state.""" + """This is really misusing an exception to return the sync state.""" def __init__(self, sync_state): super().__init__(sync_state) @@ -313,7 +313,7 @@

      Module exchangelib.folders.collections

      def _get_target_cls(self): # We may have root folders that don't support the same set of fields as normal folders. If there is a mix of - # both folder types in self.folders, raise an error so we don't risk losing some fields in the query. + # both folder types in self.folders, raise an error, so we don't risk losing some fields in the query. from .base import Folder from .roots import RootOfHierarchy @@ -388,7 +388,7 @@

      Module exchangelib.folders.collections

      if depth is None: depth = self._get_default_folder_traversal_depth() if additional_fields is None: - # Default to all non-complex properties. Subfolders will always be of class Folder + # Default to all non-complex properties. Sub-folders will always be of class Folder additional_fields = self.get_folder_fields(target_cls=Folder, is_complex=False) else: for f in additional_fields: @@ -920,7 +920,7 @@

      Subclasses

      def _get_target_cls(self): # We may have root folders that don't support the same set of fields as normal folders. If there is a mix of - # both folder types in self.folders, raise an error so we don't risk losing some fields in the query. + # both folder types in self.folders, raise an error, so we don't risk losing some fields in the query. from .base import Folder from .roots import RootOfHierarchy @@ -995,7 +995,7 @@

      Subclasses

      if depth is None: depth = self._get_default_folder_traversal_depth() if additional_fields is None: - # Default to all non-complex properties. Subfolders will always be of class Folder + # Default to all non-complex properties. Sub-folders will always be of class Folder additional_fields = self.get_folder_fields(target_cls=Folder, is_complex=False) else: for f in additional_fields: @@ -1328,7 +1328,7 @@

      Examples

      if depth is None: depth = self._get_default_folder_traversal_depth() if additional_fields is None: - # Default to all non-complex properties. Subfolders will always be of class Folder + # Default to all non-complex properties. Sub-folders will always be of class Folder additional_fields = self.get_folder_fields(target_cls=Folder, is_complex=False) else: for f in additional_fields: @@ -1957,13 +1957,13 @@

      Ancestors

      (sync_state)
      -

      This is a really ugly way of returning the sync state.

      +

      This is really misusing an exception to return the sync state.

      Expand source code
      class SyncCompleted(Exception):
      -    """This is a really ugly way of returning the sync state."""
      +    """This is really misusing an exception to return the sync state."""
       
           def __init__(self, sync_state):
               super().__init__(sync_state)
      diff --git a/docs/exchangelib/folders/index.html b/docs/exchangelib/folders/index.html
      index 6ff9dc54..a5940f7b 100644
      --- a/docs/exchangelib/folders/index.html
      +++ b/docs/exchangelib/folders/index.html
      @@ -78,7 +78,7 @@ 

      Module exchangelib.folders

      MsgFolderRoot, MyContacts, MyContactsExtended, - NonDeletableFolderMixin, + NonDeletableFolderMixIn, Notes, OrganizationalContacts, Outbox, @@ -178,7 +178,7 @@

      Module exchangelib.folders

      "MyContacts", "MyContactsExtended", "NON_DELETABLE_FOLDERS", - "NonDeletableFolderMixin", + "NonDeletableFolderMixIn", "Notes", "OrganizationalContacts", "Outbox", @@ -281,6 +281,7 @@

      Ancestors

    • IdChangeKeyMixIn
    • EWSElement
    • SearchableMixIn
    • +
    • SupportedVersionClassMixIn

    Class variables

    @@ -346,7 +347,7 @@

    Inherited members

    Expand source code -
    class AllContacts(NonDeletableFolderMixin, Contacts):
    +
    class AllContacts(NonDeletableFolderMixIn, Contacts):
         CONTAINER_CLASS = "IPF.Note"
     
         LOCALIZED_NAMES = {
    @@ -355,7 +356,7 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -424,7 +426,7 @@

    Inherited members

    Expand source code -
    class AllItems(NonDeletableFolderMixin, Folder):
    +
    class AllItems(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPF"
     
         LOCALIZED_NAMES = {
    @@ -433,13 +435,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -501,18 +504,19 @@

    Inherited members

    Expand source code -
    class ApplicationData(NonDeletableFolderMixin, Folder):
    +
    class ApplicationData(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPM.ApplicationData"

    Ancestors

    Class variables

    @@ -583,6 +587,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -657,6 +662,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -731,6 +737,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -805,6 +812,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -879,6 +887,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -953,6 +962,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -1027,6 +1037,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -1103,6 +1114,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -1170,7 +1182,7 @@

    Inherited members

    Expand source code -
    class Audits(NonDeletableFolderMixin, Folder):
    +
    class Audits(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("Audits",),
         }
    @@ -1178,13 +1190,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -1246,7 +1259,7 @@

    Inherited members

    Expand source code -
    class BaseFolder(RegisterMixIn, SearchableMixIn, metaclass=EWSMeta):
    +
    class BaseFolder(RegisterMixIn, SearchableMixIn, SupportedVersionClassMixIn, metaclass=EWSMeta):
         """Base class for all classes that implement a folder."""
     
         ELEMENT_NAME = "Folder"
    @@ -1257,9 +1270,6 @@ 

    Inherited members

    # https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxosfld/68a85898-84fe-43c4-b166-4711c13cdd61 CONTAINER_CLASS = None supported_item_models = ITEM_CLASSES # The Item types that this folder can contain. Default is all - # Marks the version from which a distinguished folder was introduced. A possibly authoritative source is: - # https://github.com/OfficeDev/ews-managed-api/blob/master/Enumerations/WellKnownFolderName.cs - supported_from = None # Whether this folder type is allowed with the GetFolder service get_folder_allowed = True DEFAULT_FOLDER_TRAVERSAL_DEPTH = DEEP_FOLDERS @@ -1351,7 +1361,7 @@

    Inherited members

    raise ValueError("Already at top") yield from self.parent.glob(tail or "*") elif head == "**": - # Match anything here or in any subfolder at arbitrary depth + # Match anything here or in any sub-folder at arbitrary depth for c in self.walk(): # fnmatch() may be case-sensitive depending on operating system: # force a case-insensitive match since case appears not to @@ -1397,17 +1407,10 @@

    Inherited members

    elif i == len(children) and j == 1: # Not the last child, but the first node, which is the name of the child tree += f"└── {node}\n" - else: # Last child, and not name of child + else: # Last child and not name of child tree += f" {node}\n" return tree.strip() - @classmethod - def supports_version(cls, version): - # 'version' is a Version instance, for convenience by callers - if not cls.supported_from: - return True - return version.build >= cls.supported_from - @property def has_distinguished_name(self): return self.name and self.DISTINGUISHED_FOLDER_ID and self.name.lower() == self.DISTINGUISHED_FOLDER_ID.lower() @@ -1610,7 +1613,7 @@

    Inherited members

    self.root.clear_cache() def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0): - # Recursively deletes all items in this folder, and all subfolders and their content. Attempts to protect + # Recursively deletes all items in this folder, and all sub-folders and their content. Attempts to protect # distinguished folders from being deleted. Use with caution! from .known_folders import Audits @@ -1656,7 +1659,7 @@

    Inherited members

    _level += 1 for f in self.children: f.wipe(page_size=page_size, chunk_size=chunk_size, _seen=_seen, _level=_level) - # Remove non-distinguished children that are empty and have no subfolders + # Remove non-distinguished children that are empty and have no sub-folders if f.is_deletable and not f.children: log.warning("Deleting folder %s", f) try: @@ -1673,7 +1676,7 @@

    Inherited members

    @classmethod def _kwargs_from_elem(cls, elem, account): - # Check for 'DisplayName' element before collecting kwargs because because that clears the elements + # Check for 'DisplayName' element before collecting kwargs because that clears the elements has_name_elem = elem.find(cls.get_field_by_fieldname("name").response_tag()) is not None kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS} if has_name_elem and not kwargs["name"]: @@ -1893,7 +1896,7 @@

    Inherited members

    """Get events since the given watermark. Non-blocking. :param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|push]() - :param watermark: Either the watermark from the subscription, or as returned in the last .get_events() call. + :param watermark: Either the watermark from the subscription, or as returned by the last .get_events() call. :return: A Notification object containing a list of events This method doesn't need the current folder instance, but it makes sense to keep the method along the other @@ -1942,7 +1945,7 @@

    Inherited members

    Works like as __truediv__ but does not touch the folder cache. - This is useful if the folder hierarchy contains a huge number of folders and you don't want to fetch them all + This is useful if the folder hierarchy contains a huge number of folders, and you don't want to fetch them all :param other: :return: @@ -1995,6 +1998,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Subclasses

      @@ -2051,10 +2055,6 @@

      Class variables

      -
      var supported_from
      -
      -
      -
      var supported_item_models
      @@ -2217,23 +2217,6 @@

      Static methods

      return f
    -
    -def supports_version(version) -
    -
    -
    -
    - -Expand source code - -
    @classmethod
    -def supports_version(cls, version):
    -    # 'version' is a Version instance, for convenience by callers
    -    if not cls.supported_from:
    -        return True
    -    return version.build >= cls.supported_from
    -
    -

    Instance variables

    @@ -2494,7 +2477,7 @@

    Methods

    Get events since the given watermark. Non-blocking.

    :param subscription_id: A subscription ID as acquired by .subscribe_to_pull|push -:param watermark: Either the watermark from the subscription, or as returned in the last .get_events() call. +:param watermark: Either the watermark from the subscription, or as returned by the last .get_events() call. :return: A Notification object containing a list of events

    This method doesn't need the current folder instance, but it makes sense to keep the method along the other sync methods.

    @@ -2506,7 +2489,7 @@

    Methods

    """Get events since the given watermark. Non-blocking. :param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|push]() - :param watermark: Either the watermark from the subscription, or as returned in the last .get_events() call. + :param watermark: Either the watermark from the subscription, or as returned by the last .get_events() call. :return: A Notification object containing a list of events This method doesn't need the current folder instance, but it makes sense to keep the method along the other @@ -3032,7 +3015,7 @@

    Methods

    elif i == len(children) and j == 1: # Not the last child, but the first node, which is the name of the child tree += f"└── {node}\n" - else: # Last child, and not name of child + else: # Last child and not name of child tree += f" {node}\n" return tree.strip()
    @@ -3122,7 +3105,7 @@

    Methods

    Expand source code
    def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0):
    -    # Recursively deletes all items in this folder, and all subfolders and their content. Attempts to protect
    +    # Recursively deletes all items in this folder, and all sub-folders and their content. Attempts to protect
         # distinguished folders from being deleted. Use with caution!
         from .known_folders import Audits
     
    @@ -3168,7 +3151,7 @@ 

    Methods

    _level += 1 for f in self.children: f.wipe(page_size=page_size, chunk_size=chunk_size, _seen=_seen, _level=_level) - # Remove non-distinguished children that are empty and have no subfolders + # Remove non-distinguished children that are empty and have no sub-folders if f.is_deletable and not f.children: log.warning("Deleting folder %s", f) try: @@ -3227,6 +3210,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -3318,6 +3302,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -3403,20 +3388,21 @@

    Inherited members

    Expand source code -
    class CalendarLogging(NonDeletableFolderMixin, Folder):
    +
    class CalendarLogging(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("Calendar Logging",),
         }

    Ancestors

    Class variables

    @@ -3474,7 +3460,7 @@

    Inherited members

    Expand source code -
    class CommonViews(NonDeletableFolderMixin, Folder):
    +
    class CommonViews(NonDeletableFolderMixIn, Folder):
         DEFAULT_ITEM_TRAVERSAL_DEPTH = ASSOCIATED
         LOCALIZED_NAMES = {
             None: ("Common Views",),
    @@ -3482,13 +3468,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -3550,7 +3537,7 @@

    Inherited members

    Expand source code -
    class Companies(NonDeletableFolderMixin, Contacts):
    +
    class Companies(NonDeletableFolderMixIn, Contacts):
         DISTINGUISHED_FOLDER_ID = None
         CONTAINTER_CLASS = "IPF.Contact.Company"
         LOCALIZED_NAMES = {
    @@ -3560,7 +3547,7 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -3646,6 +3634,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -3732,6 +3721,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Subclasses

    Class variables

    @@ -3886,7 +3877,7 @@

    Inherited members

    Expand source code -
    class ConversationSettings(NonDeletableFolderMixin, Folder):
    +
    class ConversationSettings(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPF.Configuration"
         LOCALIZED_NAMES = {
             "da_DK": ("Indstillinger for samtalehandlinger",),
    @@ -3894,13 +3885,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -3973,6 +3965,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -4030,7 +4023,7 @@

    Inherited members

    Expand source code -
    class DefaultFoldersChangeHistory(NonDeletableFolderMixin, Folder):
    +
    class DefaultFoldersChangeHistory(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPM.DefaultFolderHistoryItem"
         LOCALIZED_NAMES = {
             None: ("DefaultFoldersChangeHistory",),
    @@ -4038,13 +4031,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -4106,20 +4100,21 @@

    Inherited members

    Expand source code -
    class DeferredAction(NonDeletableFolderMixin, Folder):
    +
    class DeferredAction(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("Deferred Action",),
         }

    Ancestors

    Class variables

    @@ -4202,6 +4197,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -4284,6 +4280,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -4439,6 +4436,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -4520,6 +4518,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -4581,20 +4580,21 @@

    Inherited members

    Expand source code -
    class ExchangeSyncData(NonDeletableFolderMixin, Folder):
    +
    class ExchangeSyncData(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("ExchangeSyncData",),
         }

    Ancestors

    Class variables

    @@ -4666,6 +4666,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -4731,7 +4732,7 @@

    Inherited members

    Expand source code -
    class Files(NonDeletableFolderMixin, Folder):
    +
    class Files(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPF.Files"
     
         LOCALIZED_NAMES = {
    @@ -4740,13 +4741,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -4941,6 +4943,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Subclasses

      @@ -5415,7 +5418,7 @@

      Inherited members

      def _get_target_cls(self): # We may have root folders that don't support the same set of fields as normal folders. If there is a mix of - # both folder types in self.folders, raise an error so we don't risk losing some fields in the query. + # both folder types in self.folders, raise an error, so we don't risk losing some fields in the query. from .base import Folder from .roots import RootOfHierarchy @@ -5490,7 +5493,7 @@

      Inherited members

      if depth is None: depth = self._get_default_folder_traversal_depth() if additional_fields is None: - # Default to all non-complex properties. Subfolders will always be of class Folder + # Default to all non-complex properties. Sub-folders will always be of class Folder additional_fields = self.get_folder_fields(target_cls=Folder, is_complex=False) else: for f in additional_fields: @@ -5823,7 +5826,7 @@

      Examples

      if depth is None: depth = self._get_default_folder_traversal_depth() if additional_fields is None: - # Default to all non-complex properties. Subfolders will always be of class Folder + # Default to all non-complex properties. Sub-folders will always be of class Folder additional_fields = self.get_folder_fields(target_cls=Folder, is_complex=False) else: for f in additional_fields: @@ -6433,13 +6436,13 @@

      Inherited members

      (folder_collection)
      -

      A QuerySet-like class for finding subfolders of a folder collection.

      +

      A QuerySet-like class for finding sub-folders of a folder collection.

      Expand source code
      class FolderQuerySet:
      -    """A QuerySet-like class for finding subfolders of a folder collection."""
      +    """A QuerySet-like class for finding sub-folders of a folder collection."""
       
           def __init__(self, folder_collection):
               from .collections import FolderCollection
      @@ -6466,7 +6469,7 @@ 

      Inherited members

      """Restrict the fields returned. 'name' and 'folder_class' are always returned.""" from .base import Folder - # Subfolders will always be of class Folder + # Sub-folders will always be of class Folder all_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=None) all_fields.update(Folder.attribute_fields()) only_fields = [] @@ -6533,7 +6536,7 @@

      Inherited members

      from .collections import FolderCollection if self.only_fields is None: - # Subfolders will always be of class Folder + # Sub-folders will always be of class Folder non_complex_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=False) complex_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=True) else: @@ -6541,7 +6544,7 @@

      Inherited members

      complex_fields = {f for f in self.only_fields if f.field.is_complex} # First, fetch all non-complex fields using FindFolder. We do this because some folders do not support - # GetFolder but we still want to get as much information as possible. + # GetFolder, but we still want to get as much information as possible. folders = self.folder_collection.find_folders(q=self.q, depth=self._depth, additional_fields=non_complex_fields) if not complex_fields: yield from folders @@ -6683,7 +6686,7 @@

      Methods

      """Restrict the fields returned. 'name' and 'folder_class' are always returned.""" from .base import Folder - # Subfolders will always be of class Folder + # Sub-folders will always be of class Folder all_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=None) all_fields.update(Folder.attribute_fields()) only_fields = [] @@ -6722,6 +6725,7 @@

      Ancestors

    • IdChangeKeyMixIn
    • EWSElement
    • SearchableMixIn
    • +
    • SupportedVersionClassMixIn

    Class variables

    @@ -6779,20 +6783,21 @@

    Inherited members

    Expand source code -
    class FreebusyData(NonDeletableFolderMixin, Folder):
    +
    class FreebusyData(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("Freebusy Data",),
         }

    Ancestors

    Class variables

    @@ -6850,7 +6855,7 @@

    Inherited members

    Expand source code -
    class Friends(NonDeletableFolderMixin, Contacts):
    +
    class Friends(NonDeletableFolderMixIn, Contacts):
         CONTAINER_CLASS = "IPF.Note"
     
         LOCALIZED_NAMES = {
    @@ -6859,7 +6864,7 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -6928,7 +6934,7 @@

    Inherited members

    Expand source code -
    class GALContacts(NonDeletableFolderMixin, Contacts):
    +
    class GALContacts(NonDeletableFolderMixIn, Contacts):
         DISTINGUISHED_FOLDER_ID = None
         CONTAINER_CLASS = "IPF.Contact.GalContacts"
     
    @@ -6938,7 +6944,7 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -7011,7 +7018,7 @@

    Inherited members

    Expand source code -
    class GraphAnalytics(NonDeletableFolderMixin, Folder):
    +
    class GraphAnalytics(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPF.StoreItem.GraphAnalytics"
         LOCALIZED_NAMES = {
             None: ("GraphAnalytics",),
    @@ -7019,13 +7026,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -7101,6 +7109,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -7190,6 +7199,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -7264,6 +7274,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -7349,6 +7360,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -7423,6 +7435,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -7484,20 +7497,21 @@

    Inherited members

    Expand source code -
    class Location(NonDeletableFolderMixin, Folder):
    +
    class Location(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("Location",),
         }

    Ancestors

    Class variables

    @@ -7555,20 +7569,21 @@

    Inherited members

    Expand source code -
    class MailboxAssociations(NonDeletableFolderMixin, Folder):
    +
    class MailboxAssociations(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("MailboxAssociations",),
         }

    Ancestors

    Class variables

    @@ -7638,6 +7653,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Subclasses

    Class variables

    @@ -7801,6 +7818,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -7866,7 +7884,7 @@

    Inherited members

    Expand source code -
    class MyContactsExtended(NonDeletableFolderMixin, Contacts):
    +
    class MyContactsExtended(NonDeletableFolderMixIn, Contacts):
         CONTAINER_CLASS = "IPF.Note"
         LOCALIZED_NAMES = {
             None: ("MyContactsExtended",),
    @@ -7874,7 +7892,7 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -7933,8 +7952,8 @@

    Inherited members

    -
    -class NonDeletableFolderMixin +
    +class NonDeletableFolderMixIn

    A mixin for non-wellknown folders than that are not deletable.

    @@ -7942,7 +7961,7 @@

    Inherited members

    Expand source code -
    class NonDeletableFolderMixin:
    +
    class NonDeletableFolderMixIn:
         """A mixin for non-wellknown folders than that are not deletable."""
     
         @property
    @@ -7991,7 +8010,7 @@ 

    Subclasses

    Instance variables

    -
    var is_deletable
    +
    var is_deletable
    @@ -8031,6 +8050,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -8096,7 +8116,7 @@

    Inherited members

    Expand source code -
    class OrganizationalContacts(NonDeletableFolderMixin, Contacts):
    +
    class OrganizationalContacts(NonDeletableFolderMixIn, Contacts):
         DISTINGUISHED_FOLDER_ID = None
         CONTAINTER_CLASS = "IPF.Contact.OrganizationalContacts"
         LOCALIZED_NAMES = {
    @@ -8105,7 +8125,7 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -8202,6 +8223,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -8263,7 +8285,7 @@

    Inherited members

    Expand source code -
    class ParkedMessages(NonDeletableFolderMixin, Folder):
    +
    class ParkedMessages(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = None
         LOCALIZED_NAMES = {
             None: ("ParkedMessages",),
    @@ -8271,13 +8293,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -8339,7 +8362,7 @@

    Inherited members

    Expand source code -
    class PassThroughSearchResults(NonDeletableFolderMixin, Folder):
    +
    class PassThroughSearchResults(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPF.StoreItem.PassThroughSearchResults"
         LOCALIZED_NAMES = {
             None: ("Pass-Through Search Results",),
    @@ -8347,13 +8370,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -8415,7 +8439,7 @@

    Inherited members

    Expand source code -
    class PdpProfileV2Secured(NonDeletableFolderMixin, Folder):
    +
    class PdpProfileV2Secured(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPF.StoreItem.PdpProfileSecured"
         LOCALIZED_NAMES = {
             None: ("PdpProfileV2Secured",),
    @@ -8423,13 +8447,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -8491,7 +8516,7 @@

    Inherited members

    Expand source code -
    class PeopleCentricConversationBuddies(NonDeletableFolderMixin, Contacts):
    +
    class PeopleCentricConversationBuddies(NonDeletableFolderMixIn, Contacts):
         DISTINGUISHED_FOLDER_ID = None
         CONTAINTER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies"
         LOCALIZED_NAMES = {
    @@ -8500,7 +8525,7 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -8586,6 +8612,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -8642,13 +8669,13 @@

    Inherited members

    (**kwargs)
    -

    The root of the public folders hierarchy. Not available on all mailboxes.

    +

    The root of the public folder hierarchy. Not available on all mailboxes.

    Expand source code
    class PublicFoldersRoot(RootOfHierarchy):
    -    """The root of the public folders hierarchy. Not available on all mailboxes."""
    +    """The root of the public folder hierarchy. Not available on all mailboxes."""
     
         DISTINGUISHED_FOLDER_ID = "publicfoldersroot"
         DEFAULT_FOLDER_TRAVERSAL_DEPTH = SHALLOW
    @@ -8698,6 +8725,7 @@ 

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -8828,6 +8856,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -8893,7 +8922,7 @@

    Inherited members

    Expand source code -
    class RSSFeeds(NonDeletableFolderMixin, Folder):
    +
    class RSSFeeds(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPF.Note.OutlookHomepage"
         LOCALIZED_NAMES = {
             None: ("RSS Feeds",),
    @@ -8901,13 +8930,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -8985,6 +9015,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -9067,6 +9098,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -9141,6 +9173,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -9215,6 +9248,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -9289,6 +9323,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -9361,6 +9396,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -9418,7 +9454,7 @@

    Inherited members

    Expand source code -
    class Reminders(NonDeletableFolderMixin, Folder):
    +
    class Reminders(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "Outlook.Reminder"
         LOCALIZED_NAMES = {
             "da_DK": ("Påmindelser",),
    @@ -9426,13 +9462,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -9560,6 +9597,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -9660,7 +9698,7 @@

    Inherited members

    __slots__ = "_account", "_subfolders" - # A special folder that acts as the top of a folder hierarchy. Finds and caches subfolders at arbitrary depth. + # A special folder that acts as the top of a folder hierarchy. Finds and caches sub-folders at arbitrary depth. def __init__(self, **kwargs): self._account = kwargs.pop("account", None) # A pointer back to the account holding the folder hierarchy super().__init__(**kwargs) @@ -9771,8 +9809,8 @@

    Inherited members

    return self._subfolders with self._subfolders_lock: - # Map root, and all subfolders of root, at arbitrary depth by folder ID. First get distinguished folders, - # so we are sure to apply the correct Folder class, then fetch all subfolders of this root. + # Map root, and all sub-folders of root, at arbitrary depth by folder ID. First get distinguished folders, + # so we are sure to apply the correct Folder class, then fetch all sub-folders of this root. folders_map = {self.id: self} distinguished_folders = [ cls(root=self, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) @@ -9853,6 +9891,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Subclasses

    Class variables

    @@ -10293,6 +10334,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -10367,6 +10409,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -10428,7 +10471,7 @@

    Inherited members

    Expand source code -
    class Sharing(NonDeletableFolderMixin, Folder):
    +
    class Sharing(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPF.Note"
         LOCALIZED_NAMES = {
             None: ("Sharing",),
    @@ -10436,13 +10479,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -10504,20 +10548,21 @@

    Inherited members

    Expand source code -
    class Shortcuts(NonDeletableFolderMixin, Folder):
    +
    class Shortcuts(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("Shortcuts",),
         }

    Ancestors

    Class variables

    @@ -10575,7 +10620,7 @@

    Inherited members

    Expand source code -
    class Signal(NonDeletableFolderMixin, Folder):
    +
    class Signal(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPF.StoreItem.Signal"
         LOCALIZED_NAMES = {
             None: ("Signal",),
    @@ -10583,13 +10628,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -10722,6 +10768,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -10783,7 +10830,7 @@

    Inherited members

    Expand source code -
    class SmsAndChatsSync(NonDeletableFolderMixin, Folder):
    +
    class SmsAndChatsSync(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPF.SmsAndChatsSync"
         LOCALIZED_NAMES = {
             None: ("SmsAndChatsSync",),
    @@ -10791,13 +10838,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -10859,20 +10907,21 @@

    Inherited members

    Expand source code -
    class SpoolerQueue(NonDeletableFolderMixin, Folder):
    +
    class SpoolerQueue(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("Spooler Queue",),
         }

    Ancestors

    Class variables

    @@ -10941,6 +10990,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -11012,6 +11062,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -11077,7 +11128,7 @@

    Inherited members

    Expand source code -
    class System(NonDeletableFolderMixin, Folder):
    +
    class System(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("System",),
         }
    @@ -11085,13 +11136,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -11178,6 +11230,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -11247,20 +11300,21 @@

    Inherited members

    Expand source code -
    class TemporarySaves(NonDeletableFolderMixin, Folder):
    +
    class TemporarySaves(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("TemporarySaves",),
         }

    Ancestors

    Class variables

    @@ -11336,6 +11390,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -11405,20 +11460,21 @@

    Inherited members

    Expand source code -
    class Views(NonDeletableFolderMixin, Folder):
    +
    class Views(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("Views",),
         }

    Ancestors

    Class variables

    @@ -11492,6 +11548,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -11570,6 +11627,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Subclasses

      @@ -11659,20 +11717,21 @@

      Inherited members

      Expand source code -
      class WorkingSet(NonDeletableFolderMixin, Folder):
      +
      class WorkingSet(NonDeletableFolderMixIn, Folder):
           LOCALIZED_NAMES = {
               None: ("Working Set",),
           }

      Ancestors

      Class variables

      @@ -11893,9 +11952,7 @@

      subscribe_to_pull
    • subscribe_to_push
    • subscribe_to_streaming
    • -
    • supported_from
    • supported_item_models
    • -
    • supports_version
    • sync_hierarchy
    • sync_items
    • test_access
    • @@ -12222,9 +12279,9 @@

      NonDeletableFolderMixin

      +

      NonDeletableFolderMixIn

    • diff --git a/docs/exchangelib/folders/known_folders.html b/docs/exchangelib/folders/known_folders.html index 0605b174..8d7b1411 100644 --- a/docs/exchangelib/folders/known_folders.html +++ b/docs/exchangelib/folders/known_folders.html @@ -422,7 +422,7 @@

      Module exchangelib.folders.known_folders

      } -class NonDeletableFolderMixin: +class NonDeletableFolderMixIn: """A mixin for non-wellknown folders than that are not deletable.""" @property @@ -430,7 +430,7 @@

      Module exchangelib.folders.known_folders

      return False -class AllContacts(NonDeletableFolderMixin, Contacts): +class AllContacts(NonDeletableFolderMixIn, Contacts): CONTAINER_CLASS = "IPF.Note" LOCALIZED_NAMES = { @@ -438,7 +438,7 @@

      Module exchangelib.folders.known_folders

      } -class AllItems(NonDeletableFolderMixin, Folder): +class AllItems(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPF" LOCALIZED_NAMES = { @@ -446,31 +446,31 @@

      Module exchangelib.folders.known_folders

      } -class ApplicationData(NonDeletableFolderMixin, Folder): +class ApplicationData(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPM.ApplicationData" -class Audits(NonDeletableFolderMixin, Folder): +class Audits(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("Audits",), } get_folder_allowed = False -class CalendarLogging(NonDeletableFolderMixin, Folder): +class CalendarLogging(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("Calendar Logging",), } -class CommonViews(NonDeletableFolderMixin, Folder): +class CommonViews(NonDeletableFolderMixIn, Folder): DEFAULT_ITEM_TRAVERSAL_DEPTH = ASSOCIATED LOCALIZED_NAMES = { None: ("Common Views",), } -class Companies(NonDeletableFolderMixin, Contacts): +class Companies(NonDeletableFolderMixIn, Contacts): DISTINGUISHED_FOLDER_ID = None CONTAINTER_CLASS = "IPF.Contact.Company" LOCALIZED_NAMES = { @@ -479,33 +479,33 @@

      Module exchangelib.folders.known_folders

      } -class ConversationSettings(NonDeletableFolderMixin, Folder): +class ConversationSettings(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPF.Configuration" LOCALIZED_NAMES = { "da_DK": ("Indstillinger for samtalehandlinger",), } -class DefaultFoldersChangeHistory(NonDeletableFolderMixin, Folder): +class DefaultFoldersChangeHistory(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPM.DefaultFolderHistoryItem" LOCALIZED_NAMES = { None: ("DefaultFoldersChangeHistory",), } -class DeferredAction(NonDeletableFolderMixin, Folder): +class DeferredAction(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("Deferred Action",), } -class ExchangeSyncData(NonDeletableFolderMixin, Folder): +class ExchangeSyncData(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("ExchangeSyncData",), } -class Files(NonDeletableFolderMixin, Folder): +class Files(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPF.Files" LOCALIZED_NAMES = { @@ -513,13 +513,13 @@

      Module exchangelib.folders.known_folders

      } -class FreebusyData(NonDeletableFolderMixin, Folder): +class FreebusyData(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("Freebusy Data",), } -class Friends(NonDeletableFolderMixin, Contacts): +class Friends(NonDeletableFolderMixIn, Contacts): CONTAINER_CLASS = "IPF.Note" LOCALIZED_NAMES = { @@ -527,7 +527,7 @@

      Module exchangelib.folders.known_folders

      } -class GALContacts(NonDeletableFolderMixin, Contacts): +class GALContacts(NonDeletableFolderMixIn, Contacts): DISTINGUISHED_FOLDER_ID = None CONTAINER_CLASS = "IPF.Contact.GalContacts" @@ -536,33 +536,33 @@

      Module exchangelib.folders.known_folders

      } -class GraphAnalytics(NonDeletableFolderMixin, Folder): +class GraphAnalytics(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPF.StoreItem.GraphAnalytics" LOCALIZED_NAMES = { None: ("GraphAnalytics",), } -class Location(NonDeletableFolderMixin, Folder): +class Location(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("Location",), } -class MailboxAssociations(NonDeletableFolderMixin, Folder): +class MailboxAssociations(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("MailboxAssociations",), } -class MyContactsExtended(NonDeletableFolderMixin, Contacts): +class MyContactsExtended(NonDeletableFolderMixIn, Contacts): CONTAINER_CLASS = "IPF.Note" LOCALIZED_NAMES = { None: ("MyContactsExtended",), } -class OrganizationalContacts(NonDeletableFolderMixin, Contacts): +class OrganizationalContacts(NonDeletableFolderMixIn, Contacts): DISTINGUISHED_FOLDER_ID = None CONTAINTER_CLASS = "IPF.Contact.OrganizationalContacts" LOCALIZED_NAMES = { @@ -570,21 +570,21 @@

      Module exchangelib.folders.known_folders

      } -class ParkedMessages(NonDeletableFolderMixin, Folder): +class ParkedMessages(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = None LOCALIZED_NAMES = { None: ("ParkedMessages",), } -class PassThroughSearchResults(NonDeletableFolderMixin, Folder): +class PassThroughSearchResults(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPF.StoreItem.PassThroughSearchResults" LOCALIZED_NAMES = { None: ("Pass-Through Search Results",), } -class PeopleCentricConversationBuddies(NonDeletableFolderMixin, Contacts): +class PeopleCentricConversationBuddies(NonDeletableFolderMixIn, Contacts): DISTINGUISHED_FOLDER_ID = None CONTAINTER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies" LOCALIZED_NAMES = { @@ -592,93 +592,93 @@

      Module exchangelib.folders.known_folders

      } -class PdpProfileV2Secured(NonDeletableFolderMixin, Folder): +class PdpProfileV2Secured(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPF.StoreItem.PdpProfileSecured" LOCALIZED_NAMES = { None: ("PdpProfileV2Secured",), } -class Reminders(NonDeletableFolderMixin, Folder): +class Reminders(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "Outlook.Reminder" LOCALIZED_NAMES = { "da_DK": ("Påmindelser",), } -class RSSFeeds(NonDeletableFolderMixin, Folder): +class RSSFeeds(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPF.Note.OutlookHomepage" LOCALIZED_NAMES = { None: ("RSS Feeds",), } -class Schedule(NonDeletableFolderMixin, Folder): +class Schedule(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("Schedule",), } -class Sharing(NonDeletableFolderMixin, Folder): +class Sharing(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPF.Note" LOCALIZED_NAMES = { None: ("Sharing",), } -class Shortcuts(NonDeletableFolderMixin, Folder): +class Shortcuts(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("Shortcuts",), } -class Signal(NonDeletableFolderMixin, Folder): +class Signal(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPF.StoreItem.Signal" LOCALIZED_NAMES = { None: ("Signal",), } -class SmsAndChatsSync(NonDeletableFolderMixin, Folder): +class SmsAndChatsSync(NonDeletableFolderMixIn, Folder): CONTAINER_CLASS = "IPF.SmsAndChatsSync" LOCALIZED_NAMES = { None: ("SmsAndChatsSync",), } -class SpoolerQueue(NonDeletableFolderMixin, Folder): +class SpoolerQueue(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("Spooler Queue",), } -class System(NonDeletableFolderMixin, Folder): +class System(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("System",), } get_folder_allowed = False -class System1(NonDeletableFolderMixin, Folder): +class System1(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("System1",), } get_folder_allowed = False -class TemporarySaves(NonDeletableFolderMixin, Folder): +class TemporarySaves(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("TemporarySaves",), } -class Views(NonDeletableFolderMixin, Folder): +class Views(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("Views",), } -class WorkingSet(NonDeletableFolderMixin, Folder): +class WorkingSet(NonDeletableFolderMixIn, Folder): LOCALIZED_NAMES = { None: ("Working Set",), } @@ -815,6 +815,7 @@

      Ancestors

    • IdChangeKeyMixIn
    • EWSElement
    • SearchableMixIn
    • +
    • SupportedVersionClassMixIn

    Class variables

    @@ -880,7 +881,7 @@

    Inherited members

    Expand source code -
    class AllContacts(NonDeletableFolderMixin, Contacts):
    +
    class AllContacts(NonDeletableFolderMixIn, Contacts):
         CONTAINER_CLASS = "IPF.Note"
     
         LOCALIZED_NAMES = {
    @@ -889,7 +890,7 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -958,7 +960,7 @@

    Inherited members

    Expand source code -
    class AllItems(NonDeletableFolderMixin, Folder):
    +
    class AllItems(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPF"
     
         LOCALIZED_NAMES = {
    @@ -967,13 +969,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -1035,18 +1038,19 @@

    Inherited members

    Expand source code -
    class ApplicationData(NonDeletableFolderMixin, Folder):
    +
    class ApplicationData(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPM.ApplicationData"

    Ancestors

    Class variables

    @@ -1117,6 +1121,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -1191,6 +1196,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -1265,6 +1271,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -1339,6 +1346,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -1413,6 +1421,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -1487,6 +1496,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -1561,6 +1571,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -1622,7 +1633,7 @@

    Inherited members

    Expand source code -
    class Audits(NonDeletableFolderMixin, Folder):
    +
    class Audits(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("Audits",),
         }
    @@ -1630,13 +1641,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -1713,6 +1725,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -1804,6 +1817,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -1889,20 +1903,21 @@

    Inherited members

    Expand source code -
    class CalendarLogging(NonDeletableFolderMixin, Folder):
    +
    class CalendarLogging(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("Calendar Logging",),
         }

    Ancestors

    Class variables

    @@ -1960,7 +1975,7 @@

    Inherited members

    Expand source code -
    class CommonViews(NonDeletableFolderMixin, Folder):
    +
    class CommonViews(NonDeletableFolderMixIn, Folder):
         DEFAULT_ITEM_TRAVERSAL_DEPTH = ASSOCIATED
         LOCALIZED_NAMES = {
             None: ("Common Views",),
    @@ -1968,13 +1983,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -2036,7 +2052,7 @@

    Inherited members

    Expand source code -
    class Companies(NonDeletableFolderMixin, Contacts):
    +
    class Companies(NonDeletableFolderMixIn, Contacts):
         DISTINGUISHED_FOLDER_ID = None
         CONTAINTER_CLASS = "IPF.Contact.Company"
         LOCALIZED_NAMES = {
    @@ -2046,7 +2062,7 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -2132,6 +2149,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -2218,6 +2236,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Subclasses

    Class variables

    @@ -2372,7 +2392,7 @@

    Inherited members

    Expand source code -
    class ConversationSettings(NonDeletableFolderMixin, Folder):
    +
    class ConversationSettings(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPF.Configuration"
         LOCALIZED_NAMES = {
             "da_DK": ("Indstillinger for samtalehandlinger",),
    @@ -2380,13 +2400,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -2459,6 +2480,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -2516,7 +2538,7 @@

    Inherited members

    Expand source code -
    class DefaultFoldersChangeHistory(NonDeletableFolderMixin, Folder):
    +
    class DefaultFoldersChangeHistory(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPM.DefaultFolderHistoryItem"
         LOCALIZED_NAMES = {
             None: ("DefaultFoldersChangeHistory",),
    @@ -2524,13 +2546,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -2592,20 +2615,21 @@

    Inherited members

    Expand source code -
    class DeferredAction(NonDeletableFolderMixin, Folder):
    +
    class DeferredAction(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("Deferred Action",),
         }

    Ancestors

    Class variables

    @@ -2688,6 +2712,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -2770,6 +2795,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -2842,6 +2868,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -2923,6 +2950,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -2984,20 +3012,21 @@

    Inherited members

    Expand source code -
    class ExchangeSyncData(NonDeletableFolderMixin, Folder):
    +
    class ExchangeSyncData(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("ExchangeSyncData",),
         }

    Ancestors

    Class variables

    @@ -3069,6 +3098,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -3134,7 +3164,7 @@

    Inherited members

    Expand source code -
    class Files(NonDeletableFolderMixin, Folder):
    +
    class Files(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPF.Files"
     
         LOCALIZED_NAMES = {
    @@ -3143,13 +3173,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -3222,6 +3253,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -3279,20 +3311,21 @@

    Inherited members

    Expand source code -
    class FreebusyData(NonDeletableFolderMixin, Folder):
    +
    class FreebusyData(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("Freebusy Data",),
         }

    Ancestors

    Class variables

    @@ -3350,7 +3383,7 @@

    Inherited members

    Expand source code -
    class Friends(NonDeletableFolderMixin, Contacts):
    +
    class Friends(NonDeletableFolderMixIn, Contacts):
         CONTAINER_CLASS = "IPF.Note"
     
         LOCALIZED_NAMES = {
    @@ -3359,7 +3392,7 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -3428,7 +3462,7 @@

    Inherited members

    Expand source code -
    class GALContacts(NonDeletableFolderMixin, Contacts):
    +
    class GALContacts(NonDeletableFolderMixIn, Contacts):
         DISTINGUISHED_FOLDER_ID = None
         CONTAINER_CLASS = "IPF.Contact.GalContacts"
     
    @@ -3438,7 +3472,7 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -3511,7 +3546,7 @@

    Inherited members

    Expand source code -
    class GraphAnalytics(NonDeletableFolderMixin, Folder):
    +
    class GraphAnalytics(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPF.StoreItem.GraphAnalytics"
         LOCALIZED_NAMES = {
             None: ("GraphAnalytics",),
    @@ -3519,13 +3554,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -3601,6 +3637,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -3690,6 +3727,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -3764,6 +3802,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -3849,6 +3888,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -3923,6 +3963,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -3984,20 +4025,21 @@

    Inherited members

    Expand source code -
    class Location(NonDeletableFolderMixin, Folder):
    +
    class Location(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("Location",),
         }

    Ancestors

    Class variables

    @@ -4055,20 +4097,21 @@

    Inherited members

    Expand source code -
    class MailboxAssociations(NonDeletableFolderMixin, Folder):
    +
    class MailboxAssociations(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("MailboxAssociations",),
         }

    Ancestors

    Class variables

    @@ -4138,6 +4181,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Subclasses

    Class variables

    @@ -4301,6 +4346,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -4366,7 +4412,7 @@

    Inherited members

    Expand source code -
    class MyContactsExtended(NonDeletableFolderMixin, Contacts):
    +
    class MyContactsExtended(NonDeletableFolderMixIn, Contacts):
         CONTAINER_CLASS = "IPF.Note"
         LOCALIZED_NAMES = {
             None: ("MyContactsExtended",),
    @@ -4374,7 +4420,7 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -4433,8 +4480,8 @@

    Inherited members

    -
    -class NonDeletableFolderMixin +
    +class NonDeletableFolderMixIn

    A mixin for non-wellknown folders than that are not deletable.

    @@ -4442,7 +4489,7 @@

    Inherited members

    Expand source code -
    class NonDeletableFolderMixin:
    +
    class NonDeletableFolderMixIn:
         """A mixin for non-wellknown folders than that are not deletable."""
     
         @property
    @@ -4491,7 +4538,7 @@ 

    Subclasses

    Instance variables

    -
    var is_deletable
    +
    var is_deletable
    @@ -4531,6 +4578,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -4596,7 +4644,7 @@

    Inherited members

    Expand source code -
    class OrganizationalContacts(NonDeletableFolderMixin, Contacts):
    +
    class OrganizationalContacts(NonDeletableFolderMixIn, Contacts):
         DISTINGUISHED_FOLDER_ID = None
         CONTAINTER_CLASS = "IPF.Contact.OrganizationalContacts"
         LOCALIZED_NAMES = {
    @@ -4605,7 +4653,7 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -4702,6 +4751,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -4763,7 +4813,7 @@

    Inherited members

    Expand source code -
    class ParkedMessages(NonDeletableFolderMixin, Folder):
    +
    class ParkedMessages(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = None
         LOCALIZED_NAMES = {
             None: ("ParkedMessages",),
    @@ -4771,13 +4821,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -4839,7 +4890,7 @@

    Inherited members

    Expand source code -
    class PassThroughSearchResults(NonDeletableFolderMixin, Folder):
    +
    class PassThroughSearchResults(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPF.StoreItem.PassThroughSearchResults"
         LOCALIZED_NAMES = {
             None: ("Pass-Through Search Results",),
    @@ -4847,13 +4898,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -4915,7 +4967,7 @@

    Inherited members

    Expand source code -
    class PdpProfileV2Secured(NonDeletableFolderMixin, Folder):
    +
    class PdpProfileV2Secured(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPF.StoreItem.PdpProfileSecured"
         LOCALIZED_NAMES = {
             None: ("PdpProfileV2Secured",),
    @@ -4923,13 +4975,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -4991,7 +5044,7 @@

    Inherited members

    Expand source code -
    class PeopleCentricConversationBuddies(NonDeletableFolderMixin, Contacts):
    +
    class PeopleCentricConversationBuddies(NonDeletableFolderMixIn, Contacts):
         DISTINGUISHED_FOLDER_ID = None
         CONTAINTER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies"
         LOCALIZED_NAMES = {
    @@ -5000,7 +5053,7 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -5086,6 +5140,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -5161,6 +5216,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -5226,7 +5282,7 @@

    Inherited members

    Expand source code -
    class RSSFeeds(NonDeletableFolderMixin, Folder):
    +
    class RSSFeeds(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPF.Note.OutlookHomepage"
         LOCALIZED_NAMES = {
             None: ("RSS Feeds",),
    @@ -5234,13 +5290,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -5318,6 +5375,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -5400,6 +5458,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -5474,6 +5533,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -5548,6 +5608,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -5622,6 +5683,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -5694,6 +5756,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -5751,7 +5814,7 @@

    Inherited members

    Expand source code -
    class Reminders(NonDeletableFolderMixin, Folder):
    +
    class Reminders(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "Outlook.Reminder"
         LOCALIZED_NAMES = {
             "da_DK": ("Påmindelser",),
    @@ -5759,13 +5822,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -5827,20 +5891,21 @@

    Inherited members

    Expand source code -
    class Schedule(NonDeletableFolderMixin, Folder):
    +
    class Schedule(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("Schedule",),
         }

    Ancestors

    Class variables

    @@ -5910,6 +5975,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -5991,6 +6057,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -6065,6 +6132,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -6126,7 +6194,7 @@

    Inherited members

    Expand source code -
    class Sharing(NonDeletableFolderMixin, Folder):
    +
    class Sharing(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPF.Note"
         LOCALIZED_NAMES = {
             None: ("Sharing",),
    @@ -6134,13 +6202,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -6202,20 +6271,21 @@

    Inherited members

    Expand source code -
    class Shortcuts(NonDeletableFolderMixin, Folder):
    +
    class Shortcuts(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("Shortcuts",),
         }

    Ancestors

    Class variables

    @@ -6273,7 +6343,7 @@

    Inherited members

    Expand source code -
    class Signal(NonDeletableFolderMixin, Folder):
    +
    class Signal(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPF.StoreItem.Signal"
         LOCALIZED_NAMES = {
             None: ("Signal",),
    @@ -6281,13 +6351,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -6363,6 +6434,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -6424,7 +6496,7 @@

    Inherited members

    Expand source code -
    class SmsAndChatsSync(NonDeletableFolderMixin, Folder):
    +
    class SmsAndChatsSync(NonDeletableFolderMixIn, Folder):
         CONTAINER_CLASS = "IPF.SmsAndChatsSync"
         LOCALIZED_NAMES = {
             None: ("SmsAndChatsSync",),
    @@ -6432,13 +6504,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -6500,20 +6573,21 @@

    Inherited members

    Expand source code -
    class SpoolerQueue(NonDeletableFolderMixin, Folder):
    +
    class SpoolerQueue(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("Spooler Queue",),
         }

    Ancestors

    Class variables

    @@ -6582,6 +6656,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -6653,6 +6728,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -6718,7 +6794,7 @@

    Inherited members

    Expand source code -
    class System(NonDeletableFolderMixin, Folder):
    +
    class System(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("System",),
         }
    @@ -6726,13 +6802,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -6794,7 +6871,7 @@

    Inherited members

    Expand source code -
    class System1(NonDeletableFolderMixin, Folder):
    +
    class System1(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("System1",),
         }
    @@ -6802,13 +6879,14 @@ 

    Inherited members

    Ancestors

    Class variables

    @@ -6895,6 +6973,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -6964,20 +7043,21 @@

    Inherited members

    Expand source code -
    class TemporarySaves(NonDeletableFolderMixin, Folder):
    +
    class TemporarySaves(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("TemporarySaves",),
         }

    Ancestors

    Class variables

    @@ -7053,6 +7133,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -7122,20 +7203,21 @@

    Inherited members

    Expand source code -
    class Views(NonDeletableFolderMixin, Folder):
    +
    class Views(NonDeletableFolderMixIn, Folder):
         LOCALIZED_NAMES = {
             None: ("Views",),
         }

    Ancestors

    Class variables

    @@ -7209,6 +7291,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -7287,6 +7370,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Subclasses

      @@ -7376,20 +7460,21 @@

      Inherited members

      Expand source code -
      class WorkingSet(NonDeletableFolderMixin, Folder):
      +
      class WorkingSet(NonDeletableFolderMixIn, Folder):
           LOCALIZED_NAMES = {
               None: ("Working Set",),
           }

      Ancestors

      Class variables

      @@ -7786,9 +7871,9 @@

      NonDeletableFolderMixin

      +

      NonDeletableFolderMixIn

    • @@ -8068,4 +8153,4 @@

      pdoc 0.10.0.

      - \ No newline at end of file + diff --git a/docs/exchangelib/folders/queryset.html b/docs/exchangelib/folders/queryset.html index b2b11337..4098679d 100644 --- a/docs/exchangelib/folders/queryset.html +++ b/docs/exchangelib/folders/queryset.html @@ -47,7 +47,7 @@

      Module exchangelib.folders.queryset

      class FolderQuerySet: - """A QuerySet-like class for finding subfolders of a folder collection.""" + """A QuerySet-like class for finding sub-folders of a folder collection.""" def __init__(self, folder_collection): from .collections import FolderCollection @@ -74,7 +74,7 @@

      Module exchangelib.folders.queryset

      """Restrict the fields returned. 'name' and 'folder_class' are always returned.""" from .base import Folder - # Subfolders will always be of class Folder + # Sub-folders will always be of class Folder all_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=None) all_fields.update(Folder.attribute_fields()) only_fields = [] @@ -141,7 +141,7 @@

      Module exchangelib.folders.queryset

      from .collections import FolderCollection if self.only_fields is None: - # Subfolders will always be of class Folder + # Sub-folders will always be of class Folder non_complex_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=False) complex_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=True) else: @@ -149,7 +149,7 @@

      Module exchangelib.folders.queryset

      complex_fields = {f for f in self.only_fields if f.field.is_complex} # First, fetch all non-complex fields using FindFolder. We do this because some folders do not support - # GetFolder but we still want to get as much information as possible. + # GetFolder, but we still want to get as much information as possible. folders = self.folder_collection.find_folders(q=self.q, depth=self._depth, additional_fields=non_complex_fields) if not complex_fields: yield from folders @@ -217,13 +217,13 @@

      Classes

      (folder_collection)
      -

      A QuerySet-like class for finding subfolders of a folder collection.

      +

      A QuerySet-like class for finding sub-folders of a folder collection.

      Expand source code
      class FolderQuerySet:
      -    """A QuerySet-like class for finding subfolders of a folder collection."""
      +    """A QuerySet-like class for finding sub-folders of a folder collection."""
       
           def __init__(self, folder_collection):
               from .collections import FolderCollection
      @@ -250,7 +250,7 @@ 

      Classes

      """Restrict the fields returned. 'name' and 'folder_class' are always returned.""" from .base import Folder - # Subfolders will always be of class Folder + # Sub-folders will always be of class Folder all_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=None) all_fields.update(Folder.attribute_fields()) only_fields = [] @@ -317,7 +317,7 @@

      Classes

      from .collections import FolderCollection if self.only_fields is None: - # Subfolders will always be of class Folder + # Sub-folders will always be of class Folder non_complex_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=False) complex_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=True) else: @@ -325,7 +325,7 @@

      Classes

      complex_fields = {f for f in self.only_fields if f.field.is_complex} # First, fetch all non-complex fields using FindFolder. We do this because some folders do not support - # GetFolder but we still want to get as much information as possible. + # GetFolder, but we still want to get as much information as possible. folders = self.folder_collection.find_folders(q=self.q, depth=self._depth, additional_fields=non_complex_fields) if not complex_fields: yield from folders @@ -467,7 +467,7 @@

      Methods

      """Restrict the fields returned. 'name' and 'folder_class' are always returned.""" from .base import Folder - # Subfolders will always be of class Folder + # Sub-folders will always be of class Folder all_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=None) all_fields.update(Folder.attribute_fields()) only_fields = [] @@ -583,4 +583,4 @@

      pdoc 0.10.0.

      - \ No newline at end of file + diff --git a/docs/exchangelib/folders/roots.html b/docs/exchangelib/folders/roots.html index c5233626..74242713 100644 --- a/docs/exchangelib/folders/roots.html +++ b/docs/exchangelib/folders/roots.html @@ -68,7 +68,7 @@

      Module exchangelib.folders.roots

      __slots__ = "_account", "_subfolders" - # A special folder that acts as the top of a folder hierarchy. Finds and caches subfolders at arbitrary depth. + # A special folder that acts as the top of a folder hierarchy. Finds and caches sub-folders at arbitrary depth. def __init__(self, **kwargs): self._account = kwargs.pop("account", None) # A pointer back to the account holding the folder hierarchy super().__init__(**kwargs) @@ -179,8 +179,8 @@

      Module exchangelib.folders.roots

      return self._subfolders with self._subfolders_lock: - # Map root, and all subfolders of root, at arbitrary depth by folder ID. First get distinguished folders, - # so we are sure to apply the correct Folder class, then fetch all subfolders of this root. + # Map root, and all sub-folders of root, at arbitrary depth by folder ID. First get distinguished folders, + # so we are sure to apply the correct Folder class, then fetch all sub-folders of this root. folders_map = {self.id: self} distinguished_folders = [ cls(root=self, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) @@ -315,7 +315,7 @@

      Module exchangelib.folders.roots

      class PublicFoldersRoot(RootOfHierarchy): - """The root of the public folders hierarchy. Not available on all mailboxes.""" + """The root of the public folder hierarchy. Not available on all mailboxes.""" DISTINGUISHED_FOLDER_ID = "publicfoldersroot" DEFAULT_FOLDER_TRAVERSAL_DEPTH = SHALLOW @@ -400,6 +400,7 @@

      Ancestors

    • IdChangeKeyMixIn
    • EWSElement
    • SearchableMixIn
    • +
    • SupportedVersionClassMixIn

    Class variables

    @@ -462,13 +463,13 @@

    Inherited members

    (**kwargs)
    -

    The root of the public folders hierarchy. Not available on all mailboxes.

    +

    The root of the public folder hierarchy. Not available on all mailboxes.

    Expand source code
    class PublicFoldersRoot(RootOfHierarchy):
    -    """The root of the public folders hierarchy. Not available on all mailboxes."""
    +    """The root of the public folder hierarchy. Not available on all mailboxes."""
     
         DISTINGUISHED_FOLDER_ID = "publicfoldersroot"
         DEFAULT_FOLDER_TRAVERSAL_DEPTH = SHALLOW
    @@ -518,6 +519,7 @@ 

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -700,6 +702,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -800,7 +803,7 @@

    Inherited members

    __slots__ = "_account", "_subfolders" - # A special folder that acts as the top of a folder hierarchy. Finds and caches subfolders at arbitrary depth. + # A special folder that acts as the top of a folder hierarchy. Finds and caches sub-folders at arbitrary depth. def __init__(self, **kwargs): self._account = kwargs.pop("account", None) # A pointer back to the account holding the folder hierarchy super().__init__(**kwargs) @@ -911,8 +914,8 @@

    Inherited members

    return self._subfolders with self._subfolders_lock: - # Map root, and all subfolders of root, at arbitrary depth by folder ID. First get distinguished folders, - # so we are sure to apply the correct Folder class, then fetch all subfolders of this root. + # Map root, and all sub-folders of root, at arbitrary depth by folder ID. First get distinguished folders, + # so we are sure to apply the correct Folder class, then fetch all sub-folders of this root. folders_map = {self.id: self} distinguished_folders = [ cls(root=self, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) @@ -993,6 +996,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Subclasses

      diff --git a/docs/exchangelib/index.html b/docs/exchangelib/index.html index 8be1a343..235c891c 100644 --- a/docs/exchangelib/index.html +++ b/docs/exchangelib/index.html @@ -64,7 +64,7 @@

      Package exchangelib

      from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "4.8.0" +__version__ = "4.9.0" __all__ = [ "__version__", @@ -289,7 +289,7 @@

      Functions

      Expand source code
      def discover(email, credentials=None, auth_type=None, retry_policy=None):
      -    ad_response, protocol = Autodiscovery(email=email, credentials=credentials).discover()
      +    ad_response, protocol = PoxAutodiscovery(email=email, credentials=credentials).discover()
           protocol.config.auth_typ = auth_type
           protocol.config.retry_policy = retry_policy
           return ad_response, protocol
      @@ -363,7 +363,7 @@

      Inherited members

      :param access_type: The access type granted to 'credentials' for this account. Valid options are 'delegate' and 'impersonation'. 'delegate' is default if 'credentials' is set. Otherwise, 'impersonation' is default. :param autodiscover: Whether to look up the EWS endpoint automatically using the autodiscover protocol. -(Default value = False) +Can also be set to "pox" or "soap" to choose the autodiscover implementation (Default value = False) :param credentials: A Credentials object containing valid credentials for this account. (Default value = None) :param config: A Configuration object containing EWS endpoint information. Required if autodiscover is disabled (Default value = None) @@ -378,6 +378,8 @@

      Inherited members

      class Account:
           """Models an Exchange server user account."""
       
      +    DEFAULT_DISCOVERY_CLS = PoxAutodiscovery
      +
           def __init__(
               self,
               primary_smtp_address,
      @@ -396,7 +398,7 @@ 

      Inherited members

      :param access_type: The access type granted to 'credentials' for this account. Valid options are 'delegate' and 'impersonation'. 'delegate' is default if 'credentials' is set. Otherwise, 'impersonation' is default. :param autodiscover: Whether to look up the EWS endpoint automatically using the autodiscover protocol. - (Default value = False) + Can also be set to "pox" or "soap" to choose the autodiscover implementation (Default value = False) :param credentials: A Credentials object containing valid credentials for this account. (Default value = None) :param config: A Configuration object containing EWS endpoint information. Required if autodiscover is disabled (Default value = None) @@ -443,7 +445,12 @@

      Inherited members

      credentials = config.credentials else: auth_type, retry_policy, version = None, None, None - self.ad_response, self.protocol = Autodiscovery( + discovery_cls = { + "pox": PoxAutodiscovery, + "soap": SoapAutodiscovery, + True: self.DEFAULT_DISCOVERY_CLS, + }[autodiscover] + self.ad_response, self.protocol = discovery_cls( email=primary_smtp_address, credentials=credentials ).discover() # Let's not use the auth_package hint from the AD response. It could be incorrect and we can just guess. @@ -1028,6 +1035,28 @@

      Inherited members

      return f"{self.primary_smtp_address} ({self.fullname})" return self.primary_smtp_address
    +

    Class variables

    +
    +
    var DEFAULT_DISCOVERY_CLS
    +
    +

    Autodiscover is a Microsoft protocol for automatically getting the endpoint of the Exchange server and other +connection-related settings holding the email address using only the email address, and username and password of the +user.

    +

    For a description of the protocol implemented, see "Autodiscover for Exchange ActiveSync developers":

    +

    https://docs.microsoft.com/en-us/previous-versions/office/developer/exchange-server-interoperability-guidance/hh352638%28v%3dexchg.140%29

    +

    Descriptions of the steps from the article are provided in their respective methods in this class.

    +

    For a description of how to handle autodiscover error messages, see:

    +

    https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/handling-autodiscover-error-messages

    +

    A tip from the article: +The client can perform steps 1 through 4 in any order or in parallel to expedite the process, but it must wait for +responses to finish at each step before proceeding. Given that many organizations prefer to use the URL in step 2 to +set up the Autodiscover service, the client might try this step first.

    +

    Another possibly newer resource which has not yet been attempted is "Outlook 2016 Implementation of Autodiscover": +https://support.microsoft.com/en-us/help/3211279/outlook-2016-implementation-of-autodiscover

    +

    WARNING: The autodiscover protocol is very complicated. If you have problems autodiscovering using this +implementation, start by doing an official test at https://testconnectivity.microsoft.com

    +
    +

    Instance variables

    var admin_audit_logs
    @@ -2709,7 +2738,6 @@

    Inherited members

    def __init__(self, config): self.config = config - self._api_version_hint = None self._session_pool_size = 0 self._session_pool_maxsize = config.max_connections or self.SESSION_POOLSIZE @@ -2724,6 +2752,10 @@

    Inherited members

    def service_endpoint(self): return self.config.service_endpoint + @abc.abstractmethod + def get_auth_type(self): + """Autodetect authentication type""" + @property def auth_type(self): # Autodetect authentication type if necessary @@ -2751,15 +2783,6 @@

    Inherited members

    def server(self): return self.config.server - def get_auth_type(self): - # Autodetect authentication type. We also set version hint here. - name = str(self.credentials) if self.credentials and str(self.credentials) else "DUMMY" - auth_type, api_version_hint = get_service_authtype( - service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS, name=name - ) - self._api_version_hint = api_version_hint - return auth_type - def __getstate__(self): # The session pool and lock cannot be pickled state = self.__dict__.copy() @@ -2877,11 +2900,12 @@

    Inherited members

    self._session_pool.put(session, block=False) def close_session(self, session): - if isinstance(self.credentials, OAuth2Credentials) and not isinstance( - self.credentials, OAuth2AuthorizationCodeCredentials + if isinstance(self.credentials, BaseOAuth2Credentials) and isinstance( + self.credentials.client, BackendApplicationClient ): - # Reset token if client is of type BackendApplicationClient - self.credentials.access_token = None + # Reset access token + with self.credentials.lock: + self.credentials.access_token = None session.close() del session @@ -2918,24 +2942,22 @@

    Inherited members

    session = self.raw_session(self.service_endpoint) session.auth = get_auth_instance(auth_type=self.auth_type) else: - with self.credentials.lock: - if isinstance(self.credentials, OAuth2Credentials): + if isinstance(self.credentials, BaseOAuth2Credentials): + with self.credentials.lock: session = self.create_oauth2_session() - # Keep track of the credentials used to create this session. If - # and when we need to renew credentials (for example, refreshing - # an OAuth access token), this lets us easily determine whether - # the credentials have already been refreshed in another thread - # by the time this session tries. + # Keep track of the credentials used to create this session. If and when we need to renew + # credentials (for example, refreshing an OAuth access token), this lets us easily determine whether + # the credentials have already been refreshed in another thread by the time this session tries. session.credentials_sig = self.credentials.sig() + else: + if self.auth_type == NTLM and self.credentials.type == self.credentials.EMAIL: + username = "\\" + self.credentials.username else: - if self.auth_type == NTLM and self.credentials.type == self.credentials.EMAIL: - username = "\\" + self.credentials.username - else: - username = self.credentials.username - session = self.raw_session(self.service_endpoint) - session.auth = get_auth_instance( - auth_type=self.auth_type, username=username, password=self.credentials.password - ) + username = self.credentials.username + session = self.raw_session(self.service_endpoint) + session.auth = get_auth_instance( + auth_type=self.auth_type, username=username, password=self.credentials.password + ) # Add some extra info session.session_id = random.randint(10000, 99999) # Used for debugging messages in services @@ -2944,40 +2966,10 @@

    Inherited members

    return session def create_oauth2_session(self): - session_params = {"token": self.credentials.access_token} # Token may be None - token_params = {"include_client_id": True} - - if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials): - token_params["code"] = self.credentials.authorization_code # Auth code may be None - self.credentials.authorization_code = None # We can only use the code once - - if self.credentials.client_id and self.credentials.client_secret: - # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other - # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to - # refresh the token (that covers cases where the caller doesn't have access to the client secret but - # is working with a service that can provide it refreshed tokens on a limited basis). - session_params.update( - { - "auto_refresh_kwargs": { - "client_id": self.credentials.client_id, - "client_secret": self.credentials.client_secret, - }, - "auto_refresh_url": self.credentials.token_url, - "token_updater": self.credentials.on_token_auto_refreshed, - } - ) - client = WebApplicationClient(client_id=self.credentials.client_id) - elif isinstance(self.credentials, OAuth2LegacyCredentials): - client = LegacyApplicationClient(client_id=self.credentials.client_id) - token_params["username"] = self.credentials.username - token_params["password"] = self.credentials.password - else: - client = BackendApplicationClient(client_id=self.credentials.client_id) - session = self.raw_session( self.service_endpoint, - oauth2_client=client, - oauth2_session_params=session_params, + oauth2_client=self.credentials.client, + oauth2_session_params=self.credentials.session_params(), oauth2_token_endpoint=self.credentials.token_url, ) if not session.token: @@ -2988,12 +2980,12 @@

    Inherited members

    client_secret=self.credentials.client_secret, scope=self.credentials.scope, timeout=self.TIMEOUT, - **token_params, + **self.credentials.token_params(), ) # Allow the credentials object to update its copy of the new token, and give the application an opportunity # to cache it. self.credentials.on_token_auto_refreshed(token) - session.auth = get_auth_instance(auth_type=OAUTH2, client=client) + session.auth = get_auth_instance(auth_type=OAUTH2, client=self.credentials.client) return session @@ -3226,11 +3218,12 @@

    Methods

    Expand source code
    def close_session(self, session):
    -    if isinstance(self.credentials, OAuth2Credentials) and not isinstance(
    -        self.credentials, OAuth2AuthorizationCodeCredentials
    +    if isinstance(self.credentials, BaseOAuth2Credentials) and isinstance(
    +        self.credentials.client, BackendApplicationClient
         ):
    -        # Reset token if client is of type BackendApplicationClient
    -        self.credentials.access_token = None
    +        # Reset access token
    +        with self.credentials.lock:
    +            self.credentials.access_token = None
         session.close()
         del session
    @@ -3245,40 +3238,10 @@

    Methods

    Expand source code
    def create_oauth2_session(self):
    -    session_params = {"token": self.credentials.access_token}  # Token may be None
    -    token_params = {"include_client_id": True}
    -
    -    if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials):
    -        token_params["code"] = self.credentials.authorization_code  # Auth code may be None
    -        self.credentials.authorization_code = None  # We can only use the code once
    -
    -        if self.credentials.client_id and self.credentials.client_secret:
    -            # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other
    -            # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to
    -            # refresh the token (that covers cases where the caller doesn't have access to the client secret but
    -            # is working with a service that can provide it refreshed tokens on a limited basis).
    -            session_params.update(
    -                {
    -                    "auto_refresh_kwargs": {
    -                        "client_id": self.credentials.client_id,
    -                        "client_secret": self.credentials.client_secret,
    -                    },
    -                    "auto_refresh_url": self.credentials.token_url,
    -                    "token_updater": self.credentials.on_token_auto_refreshed,
    -                }
    -            )
    -        client = WebApplicationClient(client_id=self.credentials.client_id)
    -    elif isinstance(self.credentials, OAuth2LegacyCredentials):
    -        client = LegacyApplicationClient(client_id=self.credentials.client_id)
    -        token_params["username"] = self.credentials.username
    -        token_params["password"] = self.credentials.password
    -    else:
    -        client = BackendApplicationClient(client_id=self.credentials.client_id)
    -
         session = self.raw_session(
             self.service_endpoint,
    -        oauth2_client=client,
    -        oauth2_session_params=session_params,
    +        oauth2_client=self.credentials.client,
    +        oauth2_session_params=self.credentials.session_params(),
             oauth2_token_endpoint=self.credentials.token_url,
         )
         if not session.token:
    @@ -3289,12 +3252,12 @@ 

    Methods

    client_secret=self.credentials.client_secret, scope=self.credentials.scope, timeout=self.TIMEOUT, - **token_params, + **self.credentials.token_params(), ) # Allow the credentials object to update its copy of the new token, and give the application an opportunity # to cache it. self.credentials.on_token_auto_refreshed(token) - session.auth = get_auth_instance(auth_type=OAUTH2, client=client) + session.auth = get_auth_instance(auth_type=OAUTH2, client=self.credentials.client) return session
    @@ -3315,24 +3278,22 @@

    Methods

    session = self.raw_session(self.service_endpoint) session.auth = get_auth_instance(auth_type=self.auth_type) else: - with self.credentials.lock: - if isinstance(self.credentials, OAuth2Credentials): + if isinstance(self.credentials, BaseOAuth2Credentials): + with self.credentials.lock: session = self.create_oauth2_session() - # Keep track of the credentials used to create this session. If - # and when we need to renew credentials (for example, refreshing - # an OAuth access token), this lets us easily determine whether - # the credentials have already been refreshed in another thread - # by the time this session tries. + # Keep track of the credentials used to create this session. If and when we need to renew + # credentials (for example, refreshing an OAuth access token), this lets us easily determine whether + # the credentials have already been refreshed in another thread by the time this session tries. session.credentials_sig = self.credentials.sig() + else: + if self.auth_type == NTLM and self.credentials.type == self.credentials.EMAIL: + username = "\\" + self.credentials.username else: - if self.auth_type == NTLM and self.credentials.type == self.credentials.EMAIL: - username = "\\" + self.credentials.username - else: - username = self.credentials.username - session = self.raw_session(self.service_endpoint) - session.auth = get_auth_instance( - auth_type=self.auth_type, username=username, password=self.credentials.password - ) + username = self.credentials.username + session = self.raw_session(self.service_endpoint) + session.auth = get_auth_instance( + auth_type=self.auth_type, username=username, password=self.credentials.password + ) # Add some extra info session.session_id = random.randint(10000, 99999) # Used for debugging messages in services @@ -3378,19 +3339,14 @@

    Methods

    def get_auth_type(self)
    -
    +

    Autodetect authentication type

    Expand source code -
    def get_auth_type(self):
    -    # Autodetect authentication type. We also set version hint here.
    -    name = str(self.credentials) if self.credentials and str(self.credentials) else "DUMMY"
    -    auth_type, api_version_hint = get_service_authtype(
    -        service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS, name=name
    -    )
    -    self._api_version_hint = api_version_hint
    -    return auth_type
    +
    @abc.abstractmethod
    +def get_auth_type(self):
    +    """Autodetect authentication type"""
    @@ -3609,28 +3565,6 @@

    Methods

    class Build:
         """Holds methods for working with build numbers."""
     
    -    # List of build numbers here: https://docs.microsoft.com/en-us/exchange/new-features/build-numbers-and-release-dates
    -    API_VERSION_MAP = {
    -        8: {
    -            0: "Exchange2007",
    -            1: "Exchange2007_SP1",
    -            2: "Exchange2007_SP1",
    -            3: "Exchange2007_SP1",
    -        },
    -        14: {
    -            0: "Exchange2010",
    -            1: "Exchange2010_SP1",
    -            2: "Exchange2010_SP2",
    -            3: "Exchange2010_SP2",
    -        },
    -        15: {
    -            0: "Exchange2013",  # Minor builds starting from 847 are Exchange2013_SP1, see api_version()
    -            1: "Exchange2016",
    -            2: "Exchange2019",
    -            20: "Exchange2016",  # This is Office365. See issue #221
    -        },
    -    }
    -
         __slots__ = "major_version", "minor_version", "major_build", "minor_build"
     
         def __init__(self, major_version, minor_version, major_build=0, minor_build=0):
    @@ -3661,7 +3595,9 @@ 

    Methods

    for k, xml_elem in xml_elems_map.items(): v = elem.get(xml_elem) if v is None: - raise ValueError() + v = get_xml_attr(elem, f"{{{ANS}}}{xml_elem}") + if v is None: + raise ValueError() kwargs[k] = int(v) # Also raises ValueError return cls(**kwargs) @@ -3686,15 +3622,12 @@

    Methods

    return cls(major_version=major_version, minor_version=minor_version, major_build=build_number) def api_version(self): - if EXCHANGE_2013_SP1 <= self < EXCHANGE_2016: - return "Exchange2013_SP1" - try: - return self.API_VERSION_MAP[self.major_version][self.minor_version] - except KeyError: - raise ValueError(f"API version for build {self} is unknown") - - def fullname(self): - return VERSIONS[self.api_version()][1] + for build, api_version, _ in VERSIONS: + if self.major_version != build.major_version or self.minor_version != build.minor_version: + continue + if self >= build: + return api_version + raise ValueError(f"API version for build {self} is unknown") def __cmp__(self, other): # __cmp__ is not a magic method in Python3. We'll just use it here to implement comparison operators @@ -3738,13 +3671,6 @@

    Methods

    (self.major_version, self.minor_version, self.major_build, self.minor_build) )
    -

    Class variables

    -
    -
    var API_VERSION_MAP
    -
    -
    -
    -

    Static methods

    @@ -3806,7 +3732,9 @@

    Static methods

    for k, xml_elem in xml_elems_map.items(): v = elem.get(xml_elem) if v is None: - raise ValueError() + v = get_xml_attr(elem, f"{{{ANS}}}{xml_elem}") + if v is None: + raise ValueError() kwargs[k] = int(v) # Also raises ValueError return cls(**kwargs)
    @@ -3843,25 +3771,12 @@

    Methods

    Expand source code
    def api_version(self):
    -    if EXCHANGE_2013_SP1 <= self < EXCHANGE_2016:
    -        return "Exchange2013_SP1"
    -    try:
    -        return self.API_VERSION_MAP[self.major_version][self.minor_version]
    -    except KeyError:
    -        raise ValueError(f"API version for build {self} is unknown")
    - -
    -
    -def fullname(self) -
    -
    -
    -
    - -Expand source code - -
    def fullname(self):
    -    return VERSIONS[self.api_version()][1]
    + for build, api_version, _ in VERSIONS: + if self.major_version != build.major_version or self.minor_version != build.minor_version: + continue + if self >= build: + return api_version + raise ValueError(f"API version for build {self} is unknown")
    @@ -3956,7 +3871,7 @@

    Methods

    def occurrence(self, index): """Get an occurrence of a recurring master by index. No query is sent to the server to actually fetch the item. - Call refresh() on the item do do so. + Call refresh() on the item to do so. Only call this method on a recurring master. @@ -3972,7 +3887,7 @@

    Methods

    def recurring_master(self): """Get the recurring master of an occurrence. No query is sent to the server to actually fetch the item. - Call refresh() on the item do do so. + Call refresh() on the item to do so. Only call this method on an occurrence of a recurring master. @@ -4415,7 +4330,7 @@

    Methods

    Get an occurrence of a recurring master by index. No query is sent to the server to actually fetch the item. -Call refresh() on the item do do so.

    +Call refresh() on the item to do so.

    Only call this method on a recurring master.

    :param index: The index, which is 1-based

    :return The occurrence

    @@ -4425,7 +4340,7 @@

    Methods

    def occurrence(self, index):
         """Get an occurrence of a recurring master by index. No query is sent to the server to actually fetch the item.
    -    Call refresh() on the item do do so.
    +    Call refresh() on the item to do so.
     
         Only call this method on a recurring master.
     
    @@ -4445,7 +4360,7 @@ 

    Methods

    Get the recurring master of an occurrence. No query is sent to the server to actually fetch the item. -Call refresh() on the item do do so.

    +Call refresh() on the item to do so.

    Only call this method on an occurrence of a recurring master.

    :return: The master occurrence

    @@ -4454,7 +4369,7 @@

    Methods

    def recurring_master(self):
         """Get the recurring master of an occurrence. No query is sent to the server to actually fetch the item.
    -    Call refresh() on the item do do so.
    +    Call refresh() on the item to do so.
     
         Only call this method on an occurrence of a recurring master.
     
    @@ -4659,9 +4574,9 @@ 

    Inherited members

    ): if not isinstance(credentials, (BaseCredentials, type(None))): raise InvalidTypeError("credentials", credentials, BaseCredentials) - if auth_type is None: + if auth_type is None and isinstance(credentials, BaseOAuth2Credentials): # Set a default auth type for the credentials where this makes sense - auth_type = DEFAULT_AUTH_TYPE.get(type(credentials)) + auth_type = OAUTH2 if auth_type is not None and auth_type not in AUTH_TYPE_MAP: raise InvalidEnumValue("auth_type", auth_type, AUTH_TYPE_MAP) if credentials is None and auth_type in CREDENTIALS_REQUIRED: @@ -5106,9 +5021,6 @@

    Inherited members

    self.username = username self.password = password - def refresh(self, session): - pass - def __repr__(self): return self.__class__.__name__ + repr((self.username, "********")) @@ -5134,14 +5046,6 @@

    Class variables

    -

    Inherited members

    -
    class DLMailbox @@ -5372,7 +5276,7 @@

    Inherited members

    @classmethod def from_string(cls, date_string): - # Sometimes, we'll receive a date string with timezone information. Not very useful. + # Sometimes, we'll receive a date string with time zone information. Not very useful. if date_string.endswith("Z"): date_fmt = "%Y-%m-%dZ" elif ":" in date_string: @@ -5420,7 +5324,7 @@

    Static methods

    @classmethod
     def from_string(cls, date_string):
    -    # Sometimes, we'll receive a date string with timezone information. Not very useful.
    +    # Sometimes, we'll receive a date string with time zone information. Not very useful.
         if date_string.endswith("Z"):
             date_fmt = "%Y-%m-%dZ"
         elif ":" in date_string:
    @@ -5564,7 +5468,7 @@ 

    Methods

    @classmethod def from_string(cls, date_string): - # Parses several common datetime formats and returns timezone-aware EWSDateTime objects + # Parses several common datetime formats and returns time zone aware EWSDateTime objects if date_string.endswith("Z"): # UTC datetime return super().strptime(date_string, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) @@ -5572,7 +5476,7 @@

    Methods

    # This is probably a naive datetime. Don't allow this, but signal caller with an appropriate error local_dt = super().strptime(date_string, "%Y-%m-%dT%H:%M:%S") raise NaiveDateTimeNotAllowed(local_dt) - # This is probably a datetime value with timezone information. This comes in the form '+/-HH:MM'. + # This is probably a datetime value with time zone information. This comes in the form '+/-HH:MM'. aware_dt = datetime.datetime.fromisoformat(date_string).astimezone(UTC).replace(tzinfo=UTC) if isinstance(aware_dt, cls): return aware_dt @@ -5652,7 +5556,7 @@

    Static methods

    @classmethod
     def from_string(cls, date_string):
    -    # Parses several common datetime formats and returns timezone-aware EWSDateTime objects
    +    # Parses several common datetime formats and returns time zone aware EWSDateTime objects
         if date_string.endswith("Z"):
             # UTC datetime
             return super().strptime(date_string, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
    @@ -5660,7 +5564,7 @@ 

    Static methods

    # This is probably a naive datetime. Don't allow this, but signal caller with an appropriate error local_dt = super().strptime(date_string, "%Y-%m-%dT%H:%M:%S") raise NaiveDateTimeNotAllowed(local_dt) - # This is probably a datetime value with timezone information. This comes in the form '+/-HH:MM'. + # This is probably a datetime value with time zone information. This comes in the form '+/-HH:MM'. aware_dt = datetime.datetime.fromisoformat(date_string).astimezone(UTC).replace(tzinfo=UTC) if isinstance(aware_dt, cls): return aware_dt @@ -5821,14 +5725,14 @@

    Methods

    (*args, **kwargs)
    -

    Represents a timezone as expected by the EWS TimezoneContext / TimezoneDefinition XML element, and returned by +

    Represents a time zone as expected by the EWS TimezoneContext / TimezoneDefinition XML element, and returned by services.GetServerTimeZones.

    Expand source code
    class EWSTimeZone(zoneinfo.ZoneInfo):
    -    """Represents a timezone as expected by the EWS TimezoneContext / TimezoneDefinition XML element, and returned by
    +    """Represents a time zone as expected by the EWS TimezoneContext / TimezoneDefinition XML element, and returned by
         services.GetServerTimeZones.
         """
     
    @@ -5845,24 +5749,24 @@ 

    Methods

    except KeyError: raise UnknownTimeZone(f"No Windows timezone name found for timezone {instance.key!r}") - # We don't need the Windows long-format timezone name in long format. It's used in timezone XML elements, but - # EWS happily accepts empty strings. For a full list of timezones supported by the target server, including + # We don't need the Windows long-format time zone name in long format. It's used in time zone XML elements, but + # EWS happily accepts empty strings. For a full list of time zones supported by the target server, including # long-format names, see output of services.GetServerTimeZones(account.protocol).call() instance.ms_name = "" return instance def __eq__(self, other): - # Microsoft timezones are less granular than IANA, so an EWSTimeZone created from 'Europe/Copenhagen' may return - # from the server as 'Europe/Copenhagen'. We're catering for Microsoft here, so base equality on the Microsoft - # timezone ID. + # Microsoft time zones are less granular than IANA, so an EWSTimeZone created from 'Europe/Copenhagen' may + # return from the server as 'Europe/Copenhagen'. We're catering for Microsoft here, so base equality on the + # Microsoft time zone ID. if not isinstance(other, self.__class__): return NotImplemented return self.ms_id == other.ms_id @classmethod def from_ms_id(cls, ms_id): - # Create a timezone instance from a Microsoft timezone ID. This is lossy because there is not a 1:1 translation - # from MS timezone ID to IANA timezone. + # Create a time zone instance from a Microsoft time zone ID. This is lossy because there is not a 1:1 + # translation from MS time zone ID to IANA time zone. try: return cls(cls.MS_TO_IANA_MAP[ms_id]) except KeyError: @@ -5884,7 +5788,7 @@

    Methods

    @classmethod def from_dateutil(cls, tz): # Objects returned by dateutil.tz.tzlocal() and dateutil.tz.gettz() are not supported. They - # don't contain enough information to reliably match them with a CLDR timezone. + # don't contain enough information to reliably match them with a CLDR time zone. if hasattr(tz, "_filename"): key = "/".join(tz._filename.split("/")[-2:]) return cls(key) @@ -5973,7 +5877,7 @@

    Static methods

    @classmethod
     def from_dateutil(cls, tz):
         # Objects returned by dateutil.tz.tzlocal() and dateutil.tz.gettz() are not supported. They
    -    # don't contain enough information to reliably match them with a CLDR timezone.
    +    # don't contain enough information to reliably match them with a CLDR time zone.
         if hasattr(tz, "_filename"):
             key = "/".join(tz._filename.split("/")[-2:])
             return cls(key)
    @@ -5991,8 +5895,8 @@ 

    Static methods

    @classmethod
     def from_ms_id(cls, ms_id):
    -    # Create a timezone instance from a Microsoft timezone ID. This is lossy because there is not a 1:1 translation
    -    # from MS timezone ID to IANA timezone.
    +    # Create a time zone instance from a Microsoft time zone ID. This is lossy because there is not a 1:1
    +    # translation from MS time zone ID to IANA time zone.
         try:
             return cls(cls.MS_TO_IANA_MAP[ms_id])
         except KeyError:
    @@ -6201,7 +6105,7 @@ 

    Methods

    @classmethod def validate_cls(cls): - # Validate values of class attributes and their inter-dependencies + # Validate values of class attributes and their interdependencies cls._validate_distinguished_property_set_id() cls._validate_property_set_id() cls._validate_property_tag() @@ -6300,7 +6204,7 @@

    Methods

    do not have a name, so we must match on the cls.property_* attributes to match a field in the request with a field in the response. """ - # We can't use ExtendedFieldURI.from_xml(). It clears the XML element but we may not want to consume it here. + # We can't use ExtendedFieldURI.from_xml(). It clears the XML element, but we may not want to consume it here. kwargs = { f.name: f.from_xml(elem=elem.find(ExtendedFieldURI.response_tag()), account=None) for f in ExtendedFieldURI.FIELDS @@ -6517,7 +6421,7 @@

    Static methods

    do not have a name, so we must match on the cls.property_* attributes to match a field in the request with a field in the response. """ - # We can't use ExtendedFieldURI.from_xml(). It clears the XML element but we may not want to consume it here. + # We can't use ExtendedFieldURI.from_xml(). It clears the XML element, but we may not want to consume it here. kwargs = { f.name: f.from_xml(elem=elem.find(ExtendedFieldURI.response_tag()), account=None) for f in ExtendedFieldURI.FIELDS @@ -6597,7 +6501,7 @@

    Static methods

    @classmethod
     def validate_cls(cls):
    -    # Validate values of class attributes and their inter-dependencies
    +    # Validate values of class attributes and their interdependencies
         cls._validate_distinguished_property_set_id()
         cls._validate_property_set_id()
         cls._validate_property_tag()
    @@ -6694,7 +6598,7 @@ 

    Inherited members

    raise ValueError("Cannot back off with fail-fast policy") def may_retry_on_error(self, response, wait): - log.debug("No retry: no fail-fast policy") + log.debug("No retry with fail-fast policy") return False

    Ancestors

    @@ -6891,7 +6795,7 @@

    Inherited members

    return self._fp def _init_fp(self): - # Create a file-like object for the attachment content. We try hard to reduce memory consumption so we never + # Create a file-like object for the attachment content. We try hard to reduce memory consumption, so we never # store the full attachment content in-memory. if not self.parent_item or not self.parent_item.account: raise ValueError(f"{self.__class__.__name__} must have an account") @@ -7195,6 +7099,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Subclasses

      @@ -7669,7 +7574,7 @@

      Inherited members

      def _get_target_cls(self): # We may have root folders that don't support the same set of fields as normal folders. If there is a mix of - # both folder types in self.folders, raise an error so we don't risk losing some fields in the query. + # both folder types in self.folders, raise an error, so we don't risk losing some fields in the query. from .base import Folder from .roots import RootOfHierarchy @@ -7744,7 +7649,7 @@

      Inherited members

      if depth is None: depth = self._get_default_folder_traversal_depth() if additional_fields is None: - # Default to all non-complex properties. Subfolders will always be of class Folder + # Default to all non-complex properties. Sub-folders will always be of class Folder additional_fields = self.get_folder_fields(target_cls=Folder, is_complex=False) else: for f in additional_fields: @@ -8077,7 +7982,7 @@

      Examples

      if depth is None: depth = self._get_default_folder_traversal_depth() if additional_fields is None: - # Default to all non-complex properties. Subfolders will always be of class Folder + # Default to all non-complex properties. Sub-folders will always be of class Folder additional_fields = self.get_folder_fields(target_cls=Folder, is_complex=False) else: for f in additional_fields: @@ -8720,45 +8625,71 @@

      Inherited members

    class Identity -(primary_smtp_address=None, smtp_address=None, upn=None, sid=None) +(**kwargs)
    -

    Contains information that uniquely identifies an account. Currently only used for SOAP impersonation headers.

    -

    :param primary_smtp_address: The primary email address associated with the account (Default value = None) -:param smtp_address: The (non-)primary email address associated with the account (Default value = None) -:param upn: (Default value = None) -:param sid: (Default value = None) -:return:

    +

    Contains information that uniquely identifies an account. Currently only used for SOAP impersonation headers.

    Expand source code -
    class Identity:
    +
    class Identity(EWSElement):
         """Contains information that uniquely identifies an account. Currently only used for SOAP impersonation headers."""
     
    -    def __init__(self, primary_smtp_address=None, smtp_address=None, upn=None, sid=None):
    -        """
    -
    -        :param primary_smtp_address: The primary email address associated with the account (Default value = None)
    -        :param smtp_address: The (non-)primary email address associated with the account (Default value = None)
    -        :param upn: (Default value = None)
    -        :param sid: (Default value = None)
    -        :return:
    -        """
    -        self.primary_smtp_address = primary_smtp_address
    -        self.smtp_address = smtp_address
    -        self.upn = upn
    -        self.sid = sid
    -
    -    def __eq__(self, other):
    -        return all(getattr(self, k) == getattr(other, k) for k in self.__dict__)
    +    ELEMENT_NAME = "ConnectingSID"
     
    -    def __hash__(self):
    -        return hash(repr(self))
    -
    -    def __repr__(self):
    -        return self.__class__.__name__ + repr((self.primary_smtp_address, self.smtp_address, self.upn, self.sid))
    + # We have multiple options for uniquely identifying the user. Here's a prioritized list in accordance with + # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/connectingsid + sid = TextField(field_uri="SID") + upn = TextField(field_uri="PrincipalName") + smtp_address = TextField(field_uri="SmtpAddress") # The (non-)primary email address for the account + primary_smtp_address = TextField(field_uri="PrimarySmtpAddress") # The primary email address for the account
    +

    Ancestors

    + +

    Class variables

    +
    +
    var ELEMENT_NAME
    +
    +
    +
    +
    var FIELDS
    +
    +
    +
    +
    +

    Instance variables

    +
    +
    var primary_smtp_address
    +
    +
    +
    +
    var sid
    +
    +
    +
    +
    var smtp_address
    +
    +
    +
    +
    var upn
    +
    +
    +
    +
    +

    Inherited members

    +
    class ItemAttachment @@ -9179,7 +9110,7 @@

    Inherited members

    ) conversation_index = Base64Field(field_uri="message:ConversationIndex", is_read_only=True) conversation_topic = CharField(field_uri="message:ConversationTopic", is_read_only=True) - # Rename 'From' to 'author'. We can't use fieldname 'from' since it's a Python keyword. + # Rename 'From' to 'author'. We can't use field name 'from' since it's a Python keyword. author = MailboxField(field_uri="message:From", is_read_only_after_send=True) message_id = TextField(field_uri="message:InternetMessageId", is_read_only_after_send=True) is_read = BooleanField(field_uri="message:IsRead", is_required=True, default=False) @@ -9220,7 +9151,7 @@

    Inherited members

    # New message if copy_to_folder: - # This would better be done via send_and_save() but lets just support it here + # This would better be done via send_and_save() but let's just support it here self.folder = copy_to_folder return self.send_and_save( conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations @@ -9555,7 +9486,7 @@

    Methods

    # New message if copy_to_folder: - # This would better be done via send_and_save() but lets just support it here + # This would better be done via send_and_save() but let's just support it here self.folder = copy_to_folder return self.send_and_save( conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations @@ -9646,7 +9577,7 @@

    Inherited members

    def cert_verify(self, conn, url, verify, cert): # pylint: disable=unused-argument - # We're overriding a method so we have to keep the signature + # We're overriding a method, so we have to keep the signature super().cert_verify(conn=conn, url=url, verify=False, cert=cert)

    Ancestors

    @@ -9675,7 +9606,7 @@

    Methods

    def cert_verify(self, conn, url, verify, cert):
         # pylint: disable=unused-argument
    -    # We're overriding a method so we have to keep the signature
    +    # We're overriding a method, so we have to keep the signature
         super().cert_verify(conn=conn, url=url, verify=False, cert=cert)
    @@ -9683,7 +9614,7 @@

    Methods

    class OAuth2AuthorizationCodeCredentials -(authorization_code=None, access_token=None, client_id=None, client_secret=None, **kwargs) +(authorization_code=None, **kwargs)

    Login info for OAuth 2.0 authentication using the authorization code grant type. This can be used in one of @@ -9697,19 +9628,13 @@

    Methods

    Unlike the base (client credentials) grant, authorization code credentials don't require a Microsoft tenant ID because each access token (and the authorization code used to get the access token) is restricted to a single tenant.

    -

    :param client_id: ID of an authorized OAuth application, required for automatic token fetching and refreshing -:param client_secret: Secret associated with the OAuth application -:param tenant_id: Microsoft tenant ID of the account to access -:param identity: An Identity object representing the account that these credentials are connected to. -:param authorization_code: Code obtained when authorizing the application to access an account. In combination -with client_id and client_secret, will be used to obtain an access token. -:param access_token: Previously-obtained access token. If a token exists and the application will handle -refreshing by itself (or opts not to handle it), this parameter alone is sufficient.

    +

    :param authorization_code: Code obtained when authorizing the application to access an account. In combination +with client_id and client_secret, will be used to obtain an access token.

    Expand source code -
    class OAuth2AuthorizationCodeCredentials(OAuth2Credentials):
    +
    class OAuth2AuthorizationCodeCredentials(BaseOAuth2Credentials):
         """Login info for OAuth 2.0 authentication using the authorization code grant type. This can be used in one of
         several ways:
         * Given an authorization code, client ID, and client secret, fetch a token ourselves and refresh it as needed if
    @@ -9724,23 +9649,17 @@ 

    Methods

    tenant. """ - def __init__(self, authorization_code=None, access_token=None, client_id=None, client_secret=None, **kwargs): + def __init__(self, authorization_code=None, **kwargs): """ - :param client_id: ID of an authorized OAuth application, required for automatic token fetching and refreshing - :param client_secret: Secret associated with the OAuth application - :param tenant_id: Microsoft tenant ID of the account to access - :param identity: An Identity object representing the account that these credentials are connected to. :param authorization_code: Code obtained when authorizing the application to access an account. In combination with client_id and client_secret, will be used to obtain an access token. - :param access_token: Previously-obtained access token. If a token exists and the application will handle - refreshing by itself (or opts not to handle it), this parameter alone is sufficient. """ - super().__init__(client_id=client_id, client_secret=client_secret, **kwargs) + for attr in ("client_id", "client_secret"): + # Allow omitting these kwargs + kwargs[attr] = kwargs.pop(attr, None) + super().__init__(**kwargs) self.authorization_code = authorization_code - if access_token is not None and not isinstance(access_token, dict): - raise InvalidTypeError("access_token", access_token, OAuth2Token) - self.access_token = access_token @property def token_url(self): @@ -9754,6 +9673,35 @@

    Methods

    res.append("offline_access") return res + def session_params(self): + res = super().session_params() + if self.client_id and self.client_secret: + # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other + # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to + # refresh the token (that covers cases where the caller doesn't have access to the client secret but + # is working with a service that can provide it refreshed tokens on a limited basis). + res.update( + { + "auto_refresh_kwargs": { + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + "auto_refresh_url": self.token_url, + "token_updater": self.on_token_auto_refreshed, + } + ) + return res + + def token_params(self): + res = super().token_params() + res["code"] = self.authorization_code # Auth code may be None + self.authorization_code = None # We can only use the code once + return res + + @threaded_cached_property + def client(self): + return oauthlib.oauth2.WebApplicationClient(client_id=self.client_id) + def __repr__(self): return self.__class__.__name__ + repr( (self.client_id, "[client_secret]", "[authorization_code]", "[access_token]") @@ -9771,46 +9719,20 @@

    Methods

    Ancestors

    -

    Instance variables

    -
    -
    var scope
    -
    -
    -
    - -Expand source code - -
    @property
    -def scope(self):
    -    res = super().scope
    -    res.append("offline_access")
    -    return res
    -
    -
    -
    var token_url
    -
    -
    -
    - -Expand source code - -
    @property
    -def token_url(self):
    -    # We don't know (or need) the Microsoft tenant ID. Use common/ to let Microsoft select the appropriate
    -    # tenant for the provided authorization code or refresh token.
    -    return "https://login.microsoftonline.com/common/oauth2/v2.0/token"  # nosec
    -
    -
    -

    Inherited members

    @@ -9834,7 +9756,7 @@

    Inherited members

    Expand source code -
    class OAuth2Credentials(BaseCredentials):
    +
    class OAuth2Credentials(BaseOAuth2Credentials):
         """Login info for OAuth 2.0 client credentials authentication, as well as a base for other OAuth 2.0 grant types.
     
         This is primarily useful for in-house applications accessing data from a single Microsoft account. For applications
    @@ -9843,60 +9765,6 @@ 

    Inherited members

    the associated auth code grant type for multi-tenant applications. """ - def __init__(self, client_id, client_secret, tenant_id=None, identity=None, access_token=None): - """ - - :param client_id: ID of an authorized OAuth application, required for automatic token fetching and refreshing - :param client_secret: Secret associated with the OAuth application - :param tenant_id: Microsoft tenant ID of the account to access - :param identity: An Identity object representing the account that these credentials are connected to. - :param access_token: Previously-obtained access token, as a dict or an oauthlib.oauth2.OAuth2Token - """ - super().__init__() - self.client_id = client_id - self.client_secret = client_secret - self.tenant_id = tenant_id - self.identity = identity - self.access_token = access_token - - def refresh(self, session): - # Creating a new session gets a new access token, so there's no work here to refresh the credentials. This - # implementation just makes sure we don't raise a NotImplementedError. - pass - - def on_token_auto_refreshed(self, access_token): - """Set the access_token. Called after the access token is refreshed (requests-oauthlib can automatically - refresh tokens if given an OAuth client ID and secret, so this is how our copy of the token stays up-to-date). - Applications that cache access tokens can override this to store the new token - just remember to call the - super() method. - - :param access_token: New token obtained by refreshing - """ - # Ensure we don't update the object in the middle of a new session being created, which could cause a race. - if not isinstance(access_token, dict): - raise InvalidTypeError("access_token", access_token, OAuth2Token) - with self.lock: - log.debug("%s auth token for %s", "Refreshing" if self.access_token else "Setting", self.client_id) - self.access_token = access_token - - def _get_hash_values(self): - # 'access_token' may be refreshed once in a while. This should not affect the hash signature. - # 'identity' is just informational and should also not affect the hash signature. - return (getattr(self, k) for k in self.__dict__ if k not in ("_lock", "identity", "access_token")) - - def sig(self): - # Like hash(self), but pulls in the access token. Protocol.refresh_credentials() uses this to find out - # if the access_token needs to be refreshed. - res = [] - for k in self.__dict__: - if k in ("_lock", "identity"): - continue - if k == "access_token": - res.append(self.access_token["access_token"] if self.access_token else None) - continue - res.append(getattr(self, k)) - return hash(tuple(res)) - @property def token_url(self): return f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token" @@ -9905,108 +9773,30 @@

    Inherited members

    def scope(self): return ["https://outlook.office365.com/.default"] - def __repr__(self): - return self.__class__.__name__ + repr((self.client_id, "********")) - - def __str__(self): - return self.client_id
    + @threaded_cached_property + def client(self): + return oauthlib.oauth2.BackendApplicationClient(client_id=self.client_id)

    Ancestors

    Subclasses

    -

    Instance variables

    -
    -
    var scope
    -
    -
    -
    - -Expand source code - -
    @property
    -def scope(self):
    -    return ["https://outlook.office365.com/.default"]
    -
    -
    -
    var token_url
    -
    -
    -
    - -Expand source code - -
    @property
    -def token_url(self):
    -    return f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token"
    -
    -
    -
    -

    Methods

    -
    -
    -def on_token_auto_refreshed(self, access_token) -
    -
    -

    Set the access_token. Called after the access token is refreshed (requests-oauthlib can automatically -refresh tokens if given an OAuth client ID and secret, so this is how our copy of the token stays up-to-date). -Applications that cache access tokens can override this to store the new token - just remember to call the -super() method.

    -

    :param access_token: New token obtained by refreshing

    -
    - -Expand source code - -
    def on_token_auto_refreshed(self, access_token):
    -    """Set the access_token. Called after the access token is refreshed (requests-oauthlib can automatically
    -    refresh tokens if given an OAuth client ID and secret, so this is how our copy of the token stays up-to-date).
    -    Applications that cache access tokens can override this to store the new token - just remember to call the
    -    super() method.
    -
    -    :param access_token: New token obtained by refreshing
    -    """
    -    # Ensure we don't update the object in the middle of a new session being created, which could cause a race.
    -    if not isinstance(access_token, dict):
    -        raise InvalidTypeError("access_token", access_token, OAuth2Token)
    -    with self.lock:
    -        log.debug("%s auth token for %s", "Refreshing" if self.access_token else "Setting", self.client_id)
    -        self.access_token = access_token
    -
    -
    -
    -def sig(self) -
    -
    -
    -
    - -Expand source code - -
    def sig(self):
    -    # Like hash(self), but pulls in the access token. Protocol.refresh_credentials() uses this to find out
    -    # if the access_token needs to be refreshed.
    -    res = []
    -    for k in self.__dict__:
    -        if k in ("_lock", "identity"):
    -            continue
    -        if k == "access_token":
    -            res.append(self.access_token["access_token"] if self.access_token else None)
    -            continue
    -        res.append(getattr(self, k))
    -    return hash(tuple(res))
    -
    -
    -

    Inherited members

    @@ -10020,7 +9810,7 @@

    Inherited members

    This requires the app to acquire username and password from the user and pass that when requesting authentication tokens for the given user. This allows the app to act as the signed-in user.

    :param username: The username of the user to act as -:poram password: The password of the user to act as

    +:param password: The password of the user to act as

    Expand source code @@ -10035,12 +9825,26 @@

    Inherited members

    def __init__(self, username, password, **kwargs): """ :param username: The username of the user to act as - :poram password: The password of the user to act as + :param password: The password of the user to act as """ super().__init__(**kwargs) self.username = username self.password = password + def token_params(self): + res = super().token_params() + res.update( + { + "username": self.username, + "password": self.password, + } + ) + return res + + @threaded_cached_property + def client(self): + return oauthlib.oauth2.LegacyApplicationClient(client_id=self.client_id) + @property def scope(self): return ["https://outlook.office365.com/EWS.AccessAsUser.All"]
    @@ -10048,29 +9852,28 @@

    Inherited members

    Ancestors

    -

    Instance variables

    -
    -
    var scope
    -
    -
    -
    - -Expand source code - -
    @property
    -def scope(self):
    -    return ["https://outlook.office365.com/EWS.AccessAsUser.All"]
    -
    -
    -

    Inherited members

    @@ -10728,7 +10531,7 @@

    Inherited members

    return (self.__class__(*children, conn_type=self.OR),) if lookup == self.LOOKUP_CONTAINS and is_iterable(value, generators_allowed=True): - # A '__contains' lookup with an list as the value ony makes sense for list fields, since exact match + # A '__contains' lookup with a list as the value ony makes sense for list fields, since exact match # on multiple distinct values will always fail for single-value fields. # # An empty list as value is allowed. We interpret it to mean "are all values in the empty set contained @@ -10795,7 +10598,7 @@

    Inherited members

    self.children = q.children def clean(self, version): - """Do some basic checks on the attributes, using a generic folder. to_xml() does a really good job of + """Do some basic checks on the attributes, using a generic folder. to_xml() does a good job of validating. There's no reason to replicate much of that here. """ from .folders import Folder @@ -10901,7 +10704,7 @@

    Inherited members

    if self.is_leaf(): expr = f"{self.field_path} {self.op} {self.value!r}" else: - # Sort children by field name so we get stable output (for easier testing). Children should never be empty. + # Sort children by field name, so we get stable output (for easier testing). Children should never be empty. expr = f" {self.AND if self.conn_type == self.NOT else self.conn_type} ".join( (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f"({c.expr()})") for c in sorted(self.children, key=lambda i: i.field_path or "") @@ -11021,7 +10824,7 @@

    Inherited members

    clean_value = self._get_clean_value(field_path=field_path, version=version) if issubclass(field_path.field.value_cls, SingleFieldIndexedElement) and not field_path.label: # We allow a filter shortcut of e.g. email_addresses__contains=EmailAddress(label='Foo', ...) instead of - # email_addresses__Foo_email_address=.... Set FieldPath label now so we can generate the field_uri. + # email_addresses__Foo_email_address=.... Set FieldPath label now, so we can generate the field_uri. field_path.label = clean_value.label elif isinstance(field_path.field, DateTimeBackedDateField): # We need to convert to datetime @@ -11041,7 +10844,7 @@

    Inherited members

    else: # We have multiple children. If conn_type is NOT, then group children with AND. We'll add the NOT later elem = self._conn_to_xml(self.AND if self.conn_type == self.NOT else self.conn_type) - # Sort children by field name so we get stable output (for easier testing). Children should never be empty + # Sort children by field name, so we get stable output (for easier testing). Children should never be empty for c in sorted(self.children, key=lambda i: i.field_path or ""): elem.append(c.xml_elem(folders=folders, version=version, applies_to=applies_to)) if elem is None: @@ -11064,7 +10867,7 @@

    Inherited members

    def __invert__(self): # ~ operator. If op has an inverse, change op. Else return a new Q with conn_type NOT if self.conn_type == self.NOT: - # This is NOT NOT. Change to AND + # This is 'NOT NOT'. Change to 'AND' new = copy(self) new.conn_type = self.AND new.reduce() @@ -11282,14 +11085,14 @@

    Methods

    def clean(self, version)
    -

    Do some basic checks on the attributes, using a generic folder. to_xml() does a really good job of +

    Do some basic checks on the attributes, using a generic folder. to_xml() does a good job of validating. There's no reason to replicate much of that here.

    Expand source code
    def clean(self, version):
    -    """Do some basic checks on the attributes, using a generic folder. to_xml() does a really good job of
    +    """Do some basic checks on the attributes, using a generic folder. to_xml() does a good job of
         validating. There's no reason to replicate much of that here.
         """
         from .folders import Folder
    @@ -11316,7 +11119,7 @@ 

    Methods

    if self.is_leaf(): expr = f"{self.field_path} {self.op} {self.value!r}" else: - # Sort children by field name so we get stable output (for easier testing). Children should never be empty. + # Sort children by field name, so we get stable output (for easier testing). Children should never be empty. expr = f" {self.AND if self.conn_type == self.NOT else self.conn_type} ".join( (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f"({c.expr()})") for c in sorted(self.children, key=lambda i: i.field_path or "") @@ -11438,7 +11241,7 @@

    Methods

    clean_value = self._get_clean_value(field_path=field_path, version=version) if issubclass(field_path.field.value_cls, SingleFieldIndexedElement) and not field_path.label: # We allow a filter shortcut of e.g. email_addresses__contains=EmailAddress(label='Foo', ...) instead of - # email_addresses__Foo_email_address=.... Set FieldPath label now so we can generate the field_uri. + # email_addresses__Foo_email_address=.... Set FieldPath label now, so we can generate the field_uri. field_path.label = clean_value.label elif isinstance(field_path.field, DateTimeBackedDateField): # We need to convert to datetime @@ -11458,7 +11261,7 @@

    Methods

    else: # We have multiple children. If conn_type is NOT, then group children with AND. We'll add the NOT later elem = self._conn_to_xml(self.AND if self.conn_type == self.NOT else self.conn_type) - # Sort children by field name so we get stable output (for easier testing). Children should never be empty + # Sort children by field name, so we get stable output (for easier testing). Children should never be empty for c in sorted(self.children, key=lambda i: i.field_path or ""): elem.append(c.xml_elem(folders=folders, version=version, applies_to=applies_to)) if elem is None: @@ -11732,7 +11535,7 @@

    Inherited members

    __slots__ = "_account", "_subfolders" - # A special folder that acts as the top of a folder hierarchy. Finds and caches subfolders at arbitrary depth. + # A special folder that acts as the top of a folder hierarchy. Finds and caches sub-folders at arbitrary depth. def __init__(self, **kwargs): self._account = kwargs.pop("account", None) # A pointer back to the account holding the folder hierarchy super().__init__(**kwargs) @@ -11843,8 +11646,8 @@

    Inherited members

    return self._subfolders with self._subfolders_lock: - # Map root, and all subfolders of root, at arbitrary depth by folder ID. First get distinguished folders, - # so we are sure to apply the correct Folder class, then fetch all subfolders of this root. + # Map root, and all sub-folders of root, at arbitrary depth by folder ID. First get distinguished folders, + # so we are sure to apply the correct Folder class, then fetch all sub-folders of this root. folders_map = {self.id: self} distinguished_folders = [ cls(root=self, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) @@ -11925,6 +11728,7 @@

    Ancestors

  • IdChangeKeyMixIn
  • EWSElement
  • SearchableMixIn
  • +
  • SupportedVersionClassMixIn
  • Subclasses

      @@ -12748,7 +12552,13 @@

      Static methods

      @property def fullname(self): - return VERSIONS[self.api_version][1] + for build, api_version, full_name in VERSIONS: + if self.build: + if self.build.major_version != build.major_version or self.build.minor_version != build.minor_version: + continue + if self.api_version == api_version: + return full_name + raise ValueError(f"Full name for API version {self.api_version} build {self.build} is unknown") @classmethod def guess(cls, protocol, api_version_hint=None): @@ -12762,20 +12572,20 @@

      Static methods

      :param protocol: :param api_version_hint: (Default value = None) """ - from .services import ResolveNames + from .properties import ENTRY_ID, EWS_ID, AlternateId + from .services import ConvertId - # The protocol doesn't have a version yet, so default to latest supported version if we don't have a hint. - api_version = api_version_hint or API_VERSIONS[0] + # The protocol doesn't have a version yet, so default to the latest supported version if we don't have a hint. + api_version = api_version_hint or ConvertId.supported_api_versions()[0] log.debug("Asking server for version info using API version %s", api_version) # We don't know the build version yet. Hopefully, the server will report it in the SOAP header. Lots of # places expect a version to have a build, so this is a bit dangerous, but passing a fake build around is also - # dangerous. Make sure the call to ResolveNames does not require a version build. + # dangerous. protocol.config.version = Version(build=None, api_version=api_version) - # Use ResolveNames as a minimal request to the server to test if the version is correct. If not, ResolveNames - # will try to guess the version automatically. - name = str(protocol.credentials) if protocol.credentials and str(protocol.credentials) else "DUMMY" + # Use ConvertId as a minimal request to the server to test if the version is correct. If not, ConvertId will + # try to guess the version automatically. Make sure the call to ConvertId does not require a version build. try: - list(ResolveNames(protocol=protocol).call(unresolved_entries=[name])) + list(ConvertId(protocol=protocol).call([AlternateId(id="DUMMY", format=EWS_ID, mailbox="DUMMY")], ENTRY_ID)) except ResponseMessageError as e: # We may have survived long enough to get a new version if not protocol.config.version.build: @@ -12793,17 +12603,19 @@

      Static methods

      def from_soap_header(cls, requested_api_version, header): info = header.find(f"{{{TNS}}}ServerVersionInfo") if info is None: - raise TransportError(f"No ServerVersionInfo in header: {xml_to_str(header)!r}") + info = header.find(f"{{{ANS}}}ServerVersionInfo") + if info is None: + raise TransportError(f"No ServerVersionInfo in header: {xml_to_str(header)!r}") try: build = Build.from_xml(elem=info) except ValueError: raise TransportError(f"Bad ServerVersionInfo in response: {xml_to_str(header)!r}") # Not all Exchange servers send the Version element - api_version_from_server = info.get("Version") or build.api_version() + api_version_from_server = info.get("Version") or get_xml_attr(info, f"{{{ANS}}}Version") or build.api_version() if api_version_from_server != requested_api_version: if cls._is_invalid_version_string(api_version_from_server): # For unknown reasons, Office 365 may respond with an API version strings that is invalid in a request. - # Detect these so we can fallback to a valid version string. + # Detect these, so we can fall back to a valid version string. log.debug( 'API version "%s" worked but server reports version "%s". Using "%s"', requested_api_version, @@ -12824,6 +12636,11 @@

      Static methods

      def copy(self): return self.__class__(build=self.build, api_version=self.api_version) + @classmethod + def all_versions(cls): + # Return all supported versions, sorted newest to oldest + return [cls(build=build, api_version=api_version) for build, api_version, _ in VERSIONS] + def __eq__(self, other): if self.api_version != other.api_version: return False @@ -12841,6 +12658,21 @@

      Static methods

    Static methods

    +
    +def all_versions() +
    +
    +
    +
    + +Expand source code + +
    @classmethod
    +def all_versions(cls):
    +    # Return all supported versions, sorted newest to oldest
    +    return [cls(build=build, api_version=api_version) for build, api_version, _ in VERSIONS]
    +
    +
    def from_soap_header(requested_api_version, header)
    @@ -12854,17 +12686,19 @@

    Static methods

    def from_soap_header(cls, requested_api_version, header): info = header.find(f"{{{TNS}}}ServerVersionInfo") if info is None: - raise TransportError(f"No ServerVersionInfo in header: {xml_to_str(header)!r}") + info = header.find(f"{{{ANS}}}ServerVersionInfo") + if info is None: + raise TransportError(f"No ServerVersionInfo in header: {xml_to_str(header)!r}") try: build = Build.from_xml(elem=info) except ValueError: raise TransportError(f"Bad ServerVersionInfo in response: {xml_to_str(header)!r}") # Not all Exchange servers send the Version element - api_version_from_server = info.get("Version") or build.api_version() + api_version_from_server = info.get("Version") or get_xml_attr(info, f"{{{ANS}}}Version") or build.api_version() if api_version_from_server != requested_api_version: if cls._is_invalid_version_string(api_version_from_server): # For unknown reasons, Office 365 may respond with an API version strings that is invalid in a request. - # Detect these so we can fallback to a valid version string. + # Detect these, so we can fall back to a valid version string. log.debug( 'API version "%s" worked but server reports version "%s". Using "%s"', requested_api_version, @@ -12911,20 +12745,20 @@

    Static methods

    :param protocol: :param api_version_hint: (Default value = None) """ - from .services import ResolveNames + from .properties import ENTRY_ID, EWS_ID, AlternateId + from .services import ConvertId - # The protocol doesn't have a version yet, so default to latest supported version if we don't have a hint. - api_version = api_version_hint or API_VERSIONS[0] + # The protocol doesn't have a version yet, so default to the latest supported version if we don't have a hint. + api_version = api_version_hint or ConvertId.supported_api_versions()[0] log.debug("Asking server for version info using API version %s", api_version) # We don't know the build version yet. Hopefully, the server will report it in the SOAP header. Lots of # places expect a version to have a build, so this is a bit dangerous, but passing a fake build around is also - # dangerous. Make sure the call to ResolveNames does not require a version build. + # dangerous. protocol.config.version = Version(build=None, api_version=api_version) - # Use ResolveNames as a minimal request to the server to test if the version is correct. If not, ResolveNames - # will try to guess the version automatically. - name = str(protocol.credentials) if protocol.credentials and str(protocol.credentials) else "DUMMY" + # Use ConvertId as a minimal request to the server to test if the version is correct. If not, ConvertId will + # try to guess the version automatically. Make sure the call to ConvertId does not require a version build. try: - list(ResolveNames(protocol=protocol).call(unresolved_entries=[name])) + list(ConvertId(protocol=protocol).call([AlternateId(id="DUMMY", format=EWS_ID, mailbox="DUMMY")], ENTRY_ID)) except ResponseMessageError as e: # We may have survived long enough to get a new version if not protocol.config.version.build: @@ -12954,7 +12788,13 @@

    Instance variables

    @property
     def fullname(self):
    -    return VERSIONS[self.api_version][1]
    + for build, api_version, full_name in VERSIONS: + if self.build: + if self.build.major_version != build.major_version or self.build.minor_version != build.minor_version: + continue + if self.api_version == api_version: + return full_name + raise ValueError(f"Full name for API version {self.api_version} build {self.build} is unknown")
    @@ -13029,6 +12869,7 @@

    Accep
  • Account

      +
    • DEFAULT_DISCOVERY_CLS
    • admin_audit_logs
    • archive_deleted_items
    • archive_inbox
    • @@ -13142,11 +12983,9 @@

      Body

      Build

        -
      • API_VERSION_MAP
      • api_version
      • from_hex_string
      • from_xml
      • -
      • fullname
      • major_build
      • major_version
      • minor_build
      • @@ -13443,6 +13282,14 @@

        HTMLBody<
      • Identity

        +
      • ItemAttachment

        @@ -13519,25 +13366,12 @@

        OAuth2AuthorizationCodeCredentials

        -
      • OAuth2Credentials

        -
      • OAuth2LegacyCredentials

        -
      • OofSettings

        @@ -13756,6 +13590,7 @@

        UID

      • Version

          +
        • all_versions
        • api_version
        • build
        • copy
        • diff --git a/docs/exchangelib/items/calendar_item.html b/docs/exchangelib/items/calendar_item.html index 3bd7d6b6..43e5be3e 100644 --- a/docs/exchangelib/items/calendar_item.html +++ b/docs/exchangelib/items/calendar_item.html @@ -172,7 +172,7 @@

          Module exchangelib.items.calendar_item

          def occurrence(self, index): """Get an occurrence of a recurring master by index. No query is sent to the server to actually fetch the item. - Call refresh() on the item do do so. + Call refresh() on the item to do so. Only call this method on a recurring master. @@ -188,7 +188,7 @@

          Module exchangelib.items.calendar_item

          def recurring_master(self): """Get the recurring master of an occurrence. No query is sent to the server to actually fetch the item. - Call refresh() on the item do do so. + Call refresh() on the item to do so. Only call this method on an occurrence of a recurring master. @@ -322,7 +322,7 @@

          Module exchangelib.items.calendar_item

          Certain types are created as a side effect of doing something else. Meeting messages, for example, are created when you send a calendar item to attendees; they are not explicitly created. - Therefore BaseMeetingItem inherits from EWSElement has no save() or send() method + Therefore, BaseMeetingItem inherits from EWSElement has no save() or send() method """ associated_calendar_item_id = AssociatedCalendarItemIdField(field_uri="meeting:AssociatedCalendarItemId") @@ -635,7 +635,7 @@

          Inherited members

          MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/responsecode Certain types are created as a side effect of doing something else. Meeting messages, for example, are created when you send a calendar item to attendees; they are not explicitly created.

          -

          Therefore BaseMeetingItem inherits from +

          Therefore, BaseMeetingItem inherits from EWSElement has no save() or send() method

          Pick out optional 'account' and 'folder' kwargs, and pass the rest to the parent class.

          :param kwargs: @@ -653,7 +653,7 @@

          Inherited members

          Certain types are created as a side effect of doing something else. Meeting messages, for example, are created when you send a calendar item to attendees; they are not explicitly created. - Therefore BaseMeetingItem inherits from EWSElement has no save() or send() method + Therefore, BaseMeetingItem inherits from EWSElement has no save() or send() method """ associated_calendar_item_id = AssociatedCalendarItemIdField(field_uri="meeting:AssociatedCalendarItemId") @@ -1114,7 +1114,7 @@

          Inherited members

          def occurrence(self, index): """Get an occurrence of a recurring master by index. No query is sent to the server to actually fetch the item. - Call refresh() on the item do do so. + Call refresh() on the item to do so. Only call this method on a recurring master. @@ -1130,7 +1130,7 @@

          Inherited members

          def recurring_master(self): """Get the recurring master of an occurrence. No query is sent to the server to actually fetch the item. - Call refresh() on the item do do so. + Call refresh() on the item to do so. Only call this method on an occurrence of a recurring master. @@ -1573,7 +1573,7 @@

          Methods

          Get an occurrence of a recurring master by index. No query is sent to the server to actually fetch the item. -Call refresh() on the item do do so.

          +Call refresh() on the item to do so.

          Only call this method on a recurring master.

          :param index: The index, which is 1-based

          :return The occurrence

          @@ -1583,7 +1583,7 @@

          Methods

          def occurrence(self, index):
               """Get an occurrence of a recurring master by index. No query is sent to the server to actually fetch the item.
          -    Call refresh() on the item do do so.
          +    Call refresh() on the item to do so.
           
               Only call this method on a recurring master.
           
          @@ -1603,7 +1603,7 @@ 

          Methods

          Get the recurring master of an occurrence. No query is sent to the server to actually fetch the item. -Call refresh() on the item do do so.

          +Call refresh() on the item to do so.

          Only call this method on an occurrence of a recurring master.

          :return: The master occurrence

          @@ -1612,7 +1612,7 @@

          Methods

          def recurring_master(self):
               """Get the recurring master of an occurrence. No query is sent to the server to actually fetch the item.
          -    Call refresh() on the item do do so.
          +    Call refresh() on the item to do so.
           
               Only call this method on an occurrence of a recurring master.
           
          @@ -2518,4 +2518,4 @@ 

          Generated by pdoc 0.10.0.

          - \ No newline at end of file + diff --git a/docs/exchangelib/items/index.html b/docs/exchangelib/items/index.html index b7463e18..1ffbcb5b 100644 --- a/docs/exchangelib/items/index.html +++ b/docs/exchangelib/items/index.html @@ -535,7 +535,7 @@

          Inherited members

          def occurrence(self, index): """Get an occurrence of a recurring master by index. No query is sent to the server to actually fetch the item. - Call refresh() on the item do do so. + Call refresh() on the item to do so. Only call this method on a recurring master. @@ -551,7 +551,7 @@

          Inherited members

          def recurring_master(self): """Get the recurring master of an occurrence. No query is sent to the server to actually fetch the item. - Call refresh() on the item do do so. + Call refresh() on the item to do so. Only call this method on an occurrence of a recurring master. @@ -994,7 +994,7 @@

          Methods

          Get an occurrence of a recurring master by index. No query is sent to the server to actually fetch the item. -Call refresh() on the item do do so.

          +Call refresh() on the item to do so.

          Only call this method on a recurring master.

          :param index: The index, which is 1-based

          :return The occurrence

          @@ -1004,7 +1004,7 @@

          Methods

          def occurrence(self, index):
               """Get an occurrence of a recurring master by index. No query is sent to the server to actually fetch the item.
          -    Call refresh() on the item do do so.
          +    Call refresh() on the item to do so.
           
               Only call this method on a recurring master.
           
          @@ -1024,7 +1024,7 @@ 

          Methods

          Get the recurring master of an occurrence. No query is sent to the server to actually fetch the item. -Call refresh() on the item do do so.

          +Call refresh() on the item to do so.

          Only call this method on an occurrence of a recurring master.

          :return: The master occurrence

          @@ -1033,7 +1033,7 @@

          Methods

          def recurring_master(self):
               """Get the recurring master of an occurrence. No query is sent to the server to actually fetch the item.
          -    Call refresh() on the item do do so.
          +    Call refresh() on the item to do so.
           
               Only call this method on an occurrence of a recurring master.
           
          @@ -3013,7 +3013,7 @@ 

          Inherited members

          ) conversation_index = Base64Field(field_uri="message:ConversationIndex", is_read_only=True) conversation_topic = CharField(field_uri="message:ConversationTopic", is_read_only=True) - # Rename 'From' to 'author'. We can't use fieldname 'from' since it's a Python keyword. + # Rename 'From' to 'author'. We can't use field name 'from' since it's a Python keyword. author = MailboxField(field_uri="message:From", is_read_only_after_send=True) message_id = TextField(field_uri="message:InternetMessageId", is_read_only_after_send=True) is_read = BooleanField(field_uri="message:IsRead", is_required=True, default=False) @@ -3054,7 +3054,7 @@

          Inherited members

          # New message if copy_to_folder: - # This would better be done via send_and_save() but lets just support it here + # This would better be done via send_and_save() but let's just support it here self.folder = copy_to_folder return self.send_and_save( conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations @@ -3389,7 +3389,7 @@

          Methods

          # New message if copy_to_folder: - # This would better be done via send_and_save() but lets just support it here + # This would better be done via send_and_save() but let's just support it here self.folder = copy_to_folder return self.send_and_save( conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations @@ -5434,4 +5434,4 @@

          pdoc 0.10.0.

          - \ No newline at end of file + diff --git a/docs/exchangelib/items/message.html b/docs/exchangelib/items/message.html index e724dee9..90ce0949 100644 --- a/docs/exchangelib/items/message.html +++ b/docs/exchangelib/items/message.html @@ -63,7 +63,7 @@

          Module exchangelib.items.message

          ) conversation_index = Base64Field(field_uri="message:ConversationIndex", is_read_only=True) conversation_topic = CharField(field_uri="message:ConversationTopic", is_read_only=True) - # Rename 'From' to 'author'. We can't use fieldname 'from' since it's a Python keyword. + # Rename 'From' to 'author'. We can't use field name 'from' since it's a Python keyword. author = MailboxField(field_uri="message:From", is_read_only_after_send=True) message_id = TextField(field_uri="message:InternetMessageId", is_read_only_after_send=True) is_read = BooleanField(field_uri="message:IsRead", is_required=True, default=False) @@ -104,7 +104,7 @@

          Module exchangelib.items.message

          # New message if copy_to_folder: - # This would better be done via send_and_save() but lets just support it here + # This would better be done via send_and_save() but let's just support it here self.folder = copy_to_folder return self.send_and_save( conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations @@ -314,7 +314,7 @@

          Inherited members

          ) conversation_index = Base64Field(field_uri="message:ConversationIndex", is_read_only=True) conversation_topic = CharField(field_uri="message:ConversationTopic", is_read_only=True) - # Rename 'From' to 'author'. We can't use fieldname 'from' since it's a Python keyword. + # Rename 'From' to 'author'. We can't use field name 'from' since it's a Python keyword. author = MailboxField(field_uri="message:From", is_read_only_after_send=True) message_id = TextField(field_uri="message:InternetMessageId", is_read_only_after_send=True) is_read = BooleanField(field_uri="message:IsRead", is_required=True, default=False) @@ -355,7 +355,7 @@

          Inherited members

          # New message if copy_to_folder: - # This would better be done via send_and_save() but lets just support it here + # This would better be done via send_and_save() but let's just support it here self.folder = copy_to_folder return self.send_and_save( conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations @@ -690,7 +690,7 @@

          Methods

          # New message if copy_to_folder: - # This would better be done via send_and_save() but lets just support it here + # This would better be done via send_and_save() but let's just support it here self.folder = copy_to_folder return self.send_and_save( conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations @@ -922,4 +922,4 @@

          pdoc 0.10.0.

          - \ No newline at end of file + diff --git a/docs/exchangelib/properties.html b/docs/exchangelib/properties.html index 04849960..7f8e4efb 100644 --- a/docs/exchangelib/properties.html +++ b/docs/exchangelib/properties.html @@ -35,7 +35,13 @@

          Module exchangelib.properties

          from inspect import getmro from threading import Lock -from .errors import InvalidTypeError +from .errors import ( + AutoDiscoverFailed, + ErrorInternalServerError, + ErrorNonExistentMailbox, + ErrorServerBusy, + InvalidTypeError, +) from .fields import ( WEEKDAY_NAMES, AssociatedCalendarItemIdField, @@ -77,8 +83,8 @@

          Module exchangelib.properties

          TypeValueField, UnknownEntriesField, ) -from .util import MNS, TNS, create_element, get_xml_attr, set_xml_value, value_to_xml_text -from .version import EXCHANGE_2013, Build +from .util import ANS, MNS, TNS, create_element, get_xml_attr, set_xml_value, value_to_xml_text +from .version import EXCHANGE_2013, Build, Version log = logging.getLogger(__name__) @@ -236,7 +242,7 @@

          Module exchangelib.properties

          # FIELDS defined on a model overrides the base class fields fields = kwargs.get("FIELDS", base_fields) + local_fields - # Include all fields as class attributes so we can use them as instance attributes + # Include all fields as class attributes, so we can use them as instance attributes kwargs.update({_mangle(f.name): f for f in fields}) # Calculate __slots__ so we don't have to hard-code it on the model @@ -412,8 +418,8 @@

          Module exchangelib.properties

          if not field.supports_version(version): # The field exists but is not valid for this version raise InvalidFieldForVersion( - f"Field {field.name!r} is not supported on server version {version} " - f"(supported from: {field.supported_from}, deprecated from: {field.deprecated_from})" + f"Field {field.name!r} only supports server versions from {field.supported_from or '*'} to " + f"{field.deprecated_from or '*'} (server has {version})" ) @classmethod @@ -432,7 +438,7 @@

          Module exchangelib.properties

          @classmethod def remove_field(cls, field): - """Remove the given field and and update the slots cache. + """Remove the given field and update the slots cache. :param field: """ @@ -1484,7 +1490,7 @@

          Module exchangelib.properties

          class IdChangeKeyMixIn(EWSElement, metaclass=EWSMeta): """Base class for classes that have a concept of 'id' and 'changekey' values. The values are actually stored on - a separate element but we add convenience methods to hide that fact. + a separate element, but we add convenience methods to hide that fact. """ ID_ELEMENT_CLS = None @@ -1972,7 +1978,7 @@

          Module exchangelib.properties

          standard_time, daylight_time = None, None if len(transitions_group.transitions) == 1: # This is a simple transition group representing a timezone with no DST. Some servers don't accept - # TimeZone elements without a STD and DST element (see issue #488). Return StandardTime and DaylightTime + # TimeZone elements without an STD and DST element (see issue #488). Return StandardTime and DaylightTime # objects with dummy values and 0 bias - this satisfies the broken servers and hopefully doesn't break # the well-behaving servers. standard_time = StandardTime(bias=0, time=datetime.time(0), occurrence=1, iso_month=1, weekday=1) @@ -1997,7 +2003,165 @@

          Module exchangelib.properties

          daylight_time = DaylightTime(**transition_kwargs) continue raise ValueError(f"Unknown transition: {transition}") - return standard_time, daylight_time, standard_period

          + return standard_time, daylight_time, standard_period + + +class UserResponse(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/userresponse-soap""" + + ELEMENT_NAME = "UserResponse" + + # See https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/setting-soap + SETTINGS_MAP = { + "user_display_name": "UserDisplayName", + "user_dn": "UserDN", + "user_deployment_id": "UserDeploymentId", + "internal_mailbox_server": "InternalMailboxServer", + "internal_rpc_client_server": "InternalRpcClientServer", + "internal_mailbox_server_dn": "InternalMailboxServerDN", + "internal_ecp_url": "InternalEcpUrl", + "internal_ecp_voicemail_url": "InternalEcpVoicemailUrl", + "internal_ecp_email_subscriptions_url": "InternalEcpEmailSubscriptionsUrl", + "internal_ecp_text_messaging_url": "InternalEcpTextMessagingUrl", + "internal_ecp_delivery_report_url": "InternalEcpDeliveryReportUrl", + "internal_ecp_retention_policy_tags_url": "InternalEcpRetentionPolicyTagsUrl", + "internal_ecp_publishing_url": "InternalEcpPublishingUrl", + "internal_ews_url": "InternalEwsUrl", + "internal_oab_url": "InternalOABUrl", + "internal_um_url": "InternalUMUrl", + "internal_web_client_urls": "InternalWebClientUrls", + "mailbox_dn": "MailboxDN", + "public_folder_server": "PublicFolderServer", + "active_directory_server": "ActiveDirectoryServer", + "external_mailbox_server": "ExternalMailboxServer", + "external_mailbox_server_requires_ssl": "ExternalMailboxServerRequiresSSL", + "external_mailbox_server_authentication_methods": "ExternalMailboxServerAuthenticationMethods", + "ecp_voicemail_url_fragment,": "EcpVoicemailUrlFragment,", + "ecp_email_subscriptions_url_fragment": "EcpEmailSubscriptionsUrlFragment", + "ecp_text_messaging_url_fragment": "EcpTextMessagingUrlFragment", + "ecp_delivery_report_url_fragment": "EcpDeliveryReportUrlFragment", + "ecp_retention_policy_tags_url_fragment": "EcpRetentionPolicyTagsUrlFragment", + "ecp_publishing_url_fragment": "EcpPublishingUrlFragment", + "external_ecp_url": "ExternalEcpUrl", + "external_ecp_voicemail_url": "ExternalEcpVoicemailUrl", + "external_ecp_email_subscriptions_url": "ExternalEcpEmailSubscriptionsUrl", + "external_ecp_text_messaging_url": "ExternalEcpTextMessagingUrl", + "external_ecp_delivery_report_url": "ExternalEcpDeliveryReportUrl", + "external_ecp_retention_policy_tags_url": "ExternalEcpRetentionPolicyTagsUrl", + "external_ecp_publishing_url": "ExternalEcpPublishingUrl", + "external_ews_url": "ExternalEwsUrl", + "external_oab_url": "ExternalOABUrl", + "external_um_url": "ExternalUMUrl", + "external_web_client_urls": "ExternalWebClientUrls", + "cross_organization_sharing_enabled": "CrossOrganizationSharingEnabled", + "alternate_mailboxes": "AlternateMailboxes", + "cas_version": "CasVersion", + "ews_supported_schemas": "EwsSupportedSchemas", + "internal_pop3_connections": "InternalPop3Connections", + "external_pop3_connections": "ExternalPop3Connections", + "internal_imap4_connections": "InternalImap4Connections", + "external_imap4_connections": "ExternalImap4Connections", + "internal_smtp_connections": "InternalSmtpConnections", + "external_smtp_connections": "ExternalSmtpConnections", + "internal_server_exclusive_connect": "InternalServerExclusiveConnect", + "external_server_exclusive_connect": "ExternalServerExclusiveConnect", + "exchange_rpc_url": "ExchangeRpcUrl", + "show_gal_as_default_view": "ShowGalAsDefaultView", + "auto_discover_smtp_address": "AutoDiscoverSMTPAddress", + "interop_external_ews_url": "InteropExternalEwsUrl", + "external_ews_version": "ExternalEwsVersion", + "interop_external_ews_version": "InteropExternalEwsVersion", + "mobile_mailbox_policy_interop": "MobileMailboxPolicyInterop", + "grouping_information": "GroupingInformation", + "user_ms_online": "UserMSOnline", + "mapi_http_enabled": "MapiHttpEnabled", + } + REVERSE_SETTINGS_MAP = {v: k for k, v in SETTINGS_MAP.items()} + + error_code = CharField() + error_message = CharField() + redirect_address = CharField() + redirect_url = CharField() + user_settings_errors = DictionaryField() + user_settings = DictionaryField() + + @property + def autodiscover_smtp_address(self): + return self.user_settings.get("auto_discover_smtp_address") + + @property + def ews_url(self): + return self.user_settings.get("external_ews_url") + + @property + def version(self): + if not self.user_settings.get("ews_supported_schemas"): + return None + supported_schemas = [s.strip() for s in self.user_settings.get("ews_supported_schemas").split(",")] + newest_supported_schema = sorted(supported_schemas, reverse=True)[0] + + for version in Version.all_versions(): + if newest_supported_schema == version.api_version: + return version + raise ValueError(f"Unknown supported schemas: {supported_schemas}") + + @staticmethod + def _is_url(s): + if not s: + return False + return s.startswith("http://") or s.startswith("https://") + + def raise_errors(self): + if self.error_code == "InvalidUser": + raise ErrorNonExistentMailbox(self.error_message) + if self.error_code in ( + "InvalidRequest", + "InvalidSetting", + "SettingIsNotAvailable", + "InvalidDomain", + "NotFederated", + ): + raise AutoDiscoverFailed(f"{self.error_code}: {self.error_message}") + if self.user_settings_errors: + raise AutoDiscoverFailed(f"User settings errors: {self.user_settings_errors}") + + @classmethod + def from_xml(cls, elem, account): + # Possible ErrorCode values: + # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/errorcode-soap + error_code = get_xml_attr(elem, f"{{{ANS}}}ErrorCode") + error_message = get_xml_attr(elem, f"{{{ANS}}}ErrorMessage") + if error_code == "InternalServerError": + raise ErrorInternalServerError(error_message) + if error_code == "ServerBusy": + raise ErrorServerBusy(error_message) + if error_code not in ("NoError", "RedirectAddress", "RedirectUrl"): + return cls(error_code=error_code, error_message=error_message) + + redirect_target = get_xml_attr(elem, f"{{{ANS}}}RedirectTarget") + redirect_address = redirect_target if error_code == "RedirectAddress" else None + redirect_url = redirect_target if error_code == "RedirectUrl" else None + user_settings_errors = {} + settings_errors_elem = elem.find(f"{{{ANS}}}UserSettingErrors") + if settings_errors_elem is not None: + for setting_error in settings_errors_elem: + error_code = get_xml_attr(setting_error, f"{{{ANS}}}ErrorCode") + error_message = get_xml_attr(setting_error, f"{{{ANS}}}ErrorMessage") + name = get_xml_attr(setting_error, f"{{{ANS}}}SettingName") + user_settings_errors[cls.REVERSE_SETTINGS_MAP[name]] = (error_code, error_message) + user_settings = {} + settings_elem = elem.find(f"{{{ANS}}}UserSettings") + if settings_elem is not None: + for setting in settings_elem: + name = get_xml_attr(setting, f"{{{ANS}}}Name") + value = get_xml_attr(setting, f"{{{ANS}}}Value") + user_settings[cls.REVERSE_SETTINGS_MAP[name]] = value + return cls( + redirect_address=redirect_address, + redirect_url=redirect_url, + user_settings_errors=user_settings_errors, + user_settings=user_settings, + )

          @@ -4219,8 +4383,8 @@

          Inherited members

          if not field.supports_version(version): # The field exists but is not valid for this version raise InvalidFieldForVersion( - f"Field {field.name!r} is not supported on server version {version} " - f"(supported from: {field.supported_from}, deprecated from: {field.deprecated_from})" + f"Field {field.name!r} only supports server versions from {field.supported_from or '*'} to " + f"{field.deprecated_from or '*'} (server has {version})" ) @classmethod @@ -4239,7 +4403,7 @@

          Inherited members

          @classmethod def remove_field(cls, field): - """Remove the given field and and update the slots cache. + """Remove the given field and update the slots cache. :param field: """ @@ -4276,6 +4440,7 @@

          Inherited members

          Subclasses

            +
          • Identity
          • Attachment
          • AttachmentId
          • Autodiscover
          • @@ -4340,6 +4505,7 @@

            Subclasses

          • TransitionsGroup
          • UserConfigurationName
          • UserId
          • +
          • UserResponse
          • WorkingPeriod
          • Boundary
          • DeletedOccurrence
          • @@ -4441,7 +4607,7 @@

            Static methods

            def remove_field(field)
            -

            Remove the given field and and update the slots cache.

            +

            Remove the given field and update the slots cache.

            :param field:

            @@ -4449,7 +4615,7 @@

            Static methods

            @classmethod
             def remove_field(cls, field):
            -    """Remove the given field and and update the slots cache.
            +    """Remove the given field and update the slots cache.
             
                 :param field:
                 """
            @@ -4542,8 +4708,8 @@ 

            Static methods

            if not field.supports_version(version): # The field exists but is not valid for this version raise InvalidFieldForVersion( - f"Field {field.name!r} is not supported on server version {version} " - f"(supported from: {field.supported_from}, deprecated from: {field.deprecated_from})" + f"Field {field.name!r} only supports server versions from {field.supported_from or '*'} to " + f"{field.deprecated_from or '*'} (server has {version})" )
            @@ -4643,7 +4809,7 @@

            Methods

            # FIELDS defined on a model overrides the base class fields fields = kwargs.get("FIELDS", base_fields) + local_fields - # Include all fields as class attributes so we can use them as instance attributes + # Include all fields as class attributes, so we can use them as instance attributes kwargs.update({_mangle(f.name): f for f in fields}) # Calculate __slots__ so we don't have to hard-code it on the model @@ -5745,14 +5911,14 @@

            Inherited members

            Base class for classes that have a concept of 'id' and 'changekey' values. The values are actually stored on -a separate element but we add convenience methods to hide that fact.

            +a separate element, but we add convenience methods to hide that fact.

            Expand source code
            class IdChangeKeyMixIn(EWSElement, metaclass=EWSMeta):
                 """Base class for classes that have a concept of 'id' and 'changekey' values. The values are actually stored on
            -    a separate element but we add convenience methods to hide that fact.
            +    a separate element, but we add convenience methods to hide that fact.
                 """
             
                 ID_ELEMENT_CLS = None
            @@ -9357,7 +9523,7 @@ 

            Inherited members

            standard_time, daylight_time = None, None if len(transitions_group.transitions) == 1: # This is a simple transition group representing a timezone with no DST. Some servers don't accept - # TimeZone elements without a STD and DST element (see issue #488). Return StandardTime and DaylightTime + # TimeZone elements without an STD and DST element (see issue #488). Return StandardTime and DaylightTime # objects with dummy values and 0 bias - this satisfies the broken servers and hopefully doesn't break # the well-behaving servers. standard_time = StandardTime(bias=0, time=datetime.time(0), occurrence=1, iso_month=1, weekday=1) @@ -9461,7 +9627,7 @@

            Methods

            standard_time, daylight_time = None, None if len(transitions_group.transitions) == 1: # This is a simple transition group representing a timezone with no DST. Some servers don't accept - # TimeZone elements without a STD and DST element (see issue #488). Return StandardTime and DaylightTime + # TimeZone elements without an STD and DST element (see issue #488). Return StandardTime and DaylightTime # objects with dummy values and 0 bias - this satisfies the broken servers and hopefully doesn't break # the well-behaving servers. standard_time = StandardTime(bias=0, time=datetime.time(0), occurrence=1, iso_month=1, weekday=1) @@ -10226,6 +10392,357 @@

            Inherited members

          +
          +class UserResponse +(**kwargs) +
          +
          + +
          + +Expand source code + +
          class UserResponse(EWSElement):
          +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/userresponse-soap"""
          +
          +    ELEMENT_NAME = "UserResponse"
          +
          +    # See https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/setting-soap
          +    SETTINGS_MAP = {
          +        "user_display_name": "UserDisplayName",
          +        "user_dn": "UserDN",
          +        "user_deployment_id": "UserDeploymentId",
          +        "internal_mailbox_server": "InternalMailboxServer",
          +        "internal_rpc_client_server": "InternalRpcClientServer",
          +        "internal_mailbox_server_dn": "InternalMailboxServerDN",
          +        "internal_ecp_url": "InternalEcpUrl",
          +        "internal_ecp_voicemail_url": "InternalEcpVoicemailUrl",
          +        "internal_ecp_email_subscriptions_url": "InternalEcpEmailSubscriptionsUrl",
          +        "internal_ecp_text_messaging_url": "InternalEcpTextMessagingUrl",
          +        "internal_ecp_delivery_report_url": "InternalEcpDeliveryReportUrl",
          +        "internal_ecp_retention_policy_tags_url": "InternalEcpRetentionPolicyTagsUrl",
          +        "internal_ecp_publishing_url": "InternalEcpPublishingUrl",
          +        "internal_ews_url": "InternalEwsUrl",
          +        "internal_oab_url": "InternalOABUrl",
          +        "internal_um_url": "InternalUMUrl",
          +        "internal_web_client_urls": "InternalWebClientUrls",
          +        "mailbox_dn": "MailboxDN",
          +        "public_folder_server": "PublicFolderServer",
          +        "active_directory_server": "ActiveDirectoryServer",
          +        "external_mailbox_server": "ExternalMailboxServer",
          +        "external_mailbox_server_requires_ssl": "ExternalMailboxServerRequiresSSL",
          +        "external_mailbox_server_authentication_methods": "ExternalMailboxServerAuthenticationMethods",
          +        "ecp_voicemail_url_fragment,": "EcpVoicemailUrlFragment,",
          +        "ecp_email_subscriptions_url_fragment": "EcpEmailSubscriptionsUrlFragment",
          +        "ecp_text_messaging_url_fragment": "EcpTextMessagingUrlFragment",
          +        "ecp_delivery_report_url_fragment": "EcpDeliveryReportUrlFragment",
          +        "ecp_retention_policy_tags_url_fragment": "EcpRetentionPolicyTagsUrlFragment",
          +        "ecp_publishing_url_fragment": "EcpPublishingUrlFragment",
          +        "external_ecp_url": "ExternalEcpUrl",
          +        "external_ecp_voicemail_url": "ExternalEcpVoicemailUrl",
          +        "external_ecp_email_subscriptions_url": "ExternalEcpEmailSubscriptionsUrl",
          +        "external_ecp_text_messaging_url": "ExternalEcpTextMessagingUrl",
          +        "external_ecp_delivery_report_url": "ExternalEcpDeliveryReportUrl",
          +        "external_ecp_retention_policy_tags_url": "ExternalEcpRetentionPolicyTagsUrl",
          +        "external_ecp_publishing_url": "ExternalEcpPublishingUrl",
          +        "external_ews_url": "ExternalEwsUrl",
          +        "external_oab_url": "ExternalOABUrl",
          +        "external_um_url": "ExternalUMUrl",
          +        "external_web_client_urls": "ExternalWebClientUrls",
          +        "cross_organization_sharing_enabled": "CrossOrganizationSharingEnabled",
          +        "alternate_mailboxes": "AlternateMailboxes",
          +        "cas_version": "CasVersion",
          +        "ews_supported_schemas": "EwsSupportedSchemas",
          +        "internal_pop3_connections": "InternalPop3Connections",
          +        "external_pop3_connections": "ExternalPop3Connections",
          +        "internal_imap4_connections": "InternalImap4Connections",
          +        "external_imap4_connections": "ExternalImap4Connections",
          +        "internal_smtp_connections": "InternalSmtpConnections",
          +        "external_smtp_connections": "ExternalSmtpConnections",
          +        "internal_server_exclusive_connect": "InternalServerExclusiveConnect",
          +        "external_server_exclusive_connect": "ExternalServerExclusiveConnect",
          +        "exchange_rpc_url": "ExchangeRpcUrl",
          +        "show_gal_as_default_view": "ShowGalAsDefaultView",
          +        "auto_discover_smtp_address": "AutoDiscoverSMTPAddress",
          +        "interop_external_ews_url": "InteropExternalEwsUrl",
          +        "external_ews_version": "ExternalEwsVersion",
          +        "interop_external_ews_version": "InteropExternalEwsVersion",
          +        "mobile_mailbox_policy_interop": "MobileMailboxPolicyInterop",
          +        "grouping_information": "GroupingInformation",
          +        "user_ms_online": "UserMSOnline",
          +        "mapi_http_enabled": "MapiHttpEnabled",
          +    }
          +    REVERSE_SETTINGS_MAP = {v: k for k, v in SETTINGS_MAP.items()}
          +
          +    error_code = CharField()
          +    error_message = CharField()
          +    redirect_address = CharField()
          +    redirect_url = CharField()
          +    user_settings_errors = DictionaryField()
          +    user_settings = DictionaryField()
          +
          +    @property
          +    def autodiscover_smtp_address(self):
          +        return self.user_settings.get("auto_discover_smtp_address")
          +
          +    @property
          +    def ews_url(self):
          +        return self.user_settings.get("external_ews_url")
          +
          +    @property
          +    def version(self):
          +        if not self.user_settings.get("ews_supported_schemas"):
          +            return None
          +        supported_schemas = [s.strip() for s in self.user_settings.get("ews_supported_schemas").split(",")]
          +        newest_supported_schema = sorted(supported_schemas, reverse=True)[0]
          +
          +        for version in Version.all_versions():
          +            if newest_supported_schema == version.api_version:
          +                return version
          +        raise ValueError(f"Unknown supported schemas: {supported_schemas}")
          +
          +    @staticmethod
          +    def _is_url(s):
          +        if not s:
          +            return False
          +        return s.startswith("http://") or s.startswith("https://")
          +
          +    def raise_errors(self):
          +        if self.error_code == "InvalidUser":
          +            raise ErrorNonExistentMailbox(self.error_message)
          +        if self.error_code in (
          +            "InvalidRequest",
          +            "InvalidSetting",
          +            "SettingIsNotAvailable",
          +            "InvalidDomain",
          +            "NotFederated",
          +        ):
          +            raise AutoDiscoverFailed(f"{self.error_code}: {self.error_message}")
          +        if self.user_settings_errors:
          +            raise AutoDiscoverFailed(f"User settings errors: {self.user_settings_errors}")
          +
          +    @classmethod
          +    def from_xml(cls, elem, account):
          +        # Possible ErrorCode values:
          +        #   https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/errorcode-soap
          +        error_code = get_xml_attr(elem, f"{{{ANS}}}ErrorCode")
          +        error_message = get_xml_attr(elem, f"{{{ANS}}}ErrorMessage")
          +        if error_code == "InternalServerError":
          +            raise ErrorInternalServerError(error_message)
          +        if error_code == "ServerBusy":
          +            raise ErrorServerBusy(error_message)
          +        if error_code not in ("NoError", "RedirectAddress", "RedirectUrl"):
          +            return cls(error_code=error_code, error_message=error_message)
          +
          +        redirect_target = get_xml_attr(elem, f"{{{ANS}}}RedirectTarget")
          +        redirect_address = redirect_target if error_code == "RedirectAddress" else None
          +        redirect_url = redirect_target if error_code == "RedirectUrl" else None
          +        user_settings_errors = {}
          +        settings_errors_elem = elem.find(f"{{{ANS}}}UserSettingErrors")
          +        if settings_errors_elem is not None:
          +            for setting_error in settings_errors_elem:
          +                error_code = get_xml_attr(setting_error, f"{{{ANS}}}ErrorCode")
          +                error_message = get_xml_attr(setting_error, f"{{{ANS}}}ErrorMessage")
          +                name = get_xml_attr(setting_error, f"{{{ANS}}}SettingName")
          +                user_settings_errors[cls.REVERSE_SETTINGS_MAP[name]] = (error_code, error_message)
          +        user_settings = {}
          +        settings_elem = elem.find(f"{{{ANS}}}UserSettings")
          +        if settings_elem is not None:
          +            for setting in settings_elem:
          +                name = get_xml_attr(setting, f"{{{ANS}}}Name")
          +                value = get_xml_attr(setting, f"{{{ANS}}}Value")
          +                user_settings[cls.REVERSE_SETTINGS_MAP[name]] = value
          +        return cls(
          +            redirect_address=redirect_address,
          +            redirect_url=redirect_url,
          +            user_settings_errors=user_settings_errors,
          +            user_settings=user_settings,
          +        )
          +
          +

          Ancestors

          + +

          Class variables

          +
          +
          var ELEMENT_NAME
          +
          +
          +
          +
          var FIELDS
          +
          +
          +
          +
          var REVERSE_SETTINGS_MAP
          +
          +
          +
          +
          var SETTINGS_MAP
          +
          +
          +
          +
          +

          Static methods

          +
          +
          +def from_xml(elem, account) +
          +
          +
          +
          + +Expand source code + +
          @classmethod
          +def from_xml(cls, elem, account):
          +    # Possible ErrorCode values:
          +    #   https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/errorcode-soap
          +    error_code = get_xml_attr(elem, f"{{{ANS}}}ErrorCode")
          +    error_message = get_xml_attr(elem, f"{{{ANS}}}ErrorMessage")
          +    if error_code == "InternalServerError":
          +        raise ErrorInternalServerError(error_message)
          +    if error_code == "ServerBusy":
          +        raise ErrorServerBusy(error_message)
          +    if error_code not in ("NoError", "RedirectAddress", "RedirectUrl"):
          +        return cls(error_code=error_code, error_message=error_message)
          +
          +    redirect_target = get_xml_attr(elem, f"{{{ANS}}}RedirectTarget")
          +    redirect_address = redirect_target if error_code == "RedirectAddress" else None
          +    redirect_url = redirect_target if error_code == "RedirectUrl" else None
          +    user_settings_errors = {}
          +    settings_errors_elem = elem.find(f"{{{ANS}}}UserSettingErrors")
          +    if settings_errors_elem is not None:
          +        for setting_error in settings_errors_elem:
          +            error_code = get_xml_attr(setting_error, f"{{{ANS}}}ErrorCode")
          +            error_message = get_xml_attr(setting_error, f"{{{ANS}}}ErrorMessage")
          +            name = get_xml_attr(setting_error, f"{{{ANS}}}SettingName")
          +            user_settings_errors[cls.REVERSE_SETTINGS_MAP[name]] = (error_code, error_message)
          +    user_settings = {}
          +    settings_elem = elem.find(f"{{{ANS}}}UserSettings")
          +    if settings_elem is not None:
          +        for setting in settings_elem:
          +            name = get_xml_attr(setting, f"{{{ANS}}}Name")
          +            value = get_xml_attr(setting, f"{{{ANS}}}Value")
          +            user_settings[cls.REVERSE_SETTINGS_MAP[name]] = value
          +    return cls(
          +        redirect_address=redirect_address,
          +        redirect_url=redirect_url,
          +        user_settings_errors=user_settings_errors,
          +        user_settings=user_settings,
          +    )
          +
          +
          +
          +

          Instance variables

          +
          +
          var autodiscover_smtp_address
          +
          +
          +
          + +Expand source code + +
          @property
          +def autodiscover_smtp_address(self):
          +    return self.user_settings.get("auto_discover_smtp_address")
          +
          +
          +
          var error_code
          +
          +
          +
          +
          var error_message
          +
          +
          +
          +
          var ews_url
          +
          +
          +
          + +Expand source code + +
          @property
          +def ews_url(self):
          +    return self.user_settings.get("external_ews_url")
          +
          +
          +
          var redirect_address
          +
          +
          +
          +
          var redirect_url
          +
          +
          +
          +
          var user_settings
          +
          +
          +
          +
          var user_settings_errors
          +
          +
          +
          +
          var version
          +
          +
          +
          + +Expand source code + +
          @property
          +def version(self):
          +    if not self.user_settings.get("ews_supported_schemas"):
          +        return None
          +    supported_schemas = [s.strip() for s in self.user_settings.get("ews_supported_schemas").split(",")]
          +    newest_supported_schema = sorted(supported_schemas, reverse=True)[0]
          +
          +    for version in Version.all_versions():
          +        if newest_supported_schema == version.api_version:
          +            return version
          +    raise ValueError(f"Unknown supported schemas: {supported_schemas}")
          +
          +
          +
          +

          Methods

          +
          +
          +def raise_errors(self) +
          +
          +
          +
          + +Expand source code + +
          def raise_errors(self):
          +    if self.error_code == "InvalidUser":
          +        raise ErrorNonExistentMailbox(self.error_message)
          +    if self.error_code in (
          +        "InvalidRequest",
          +        "InvalidSetting",
          +        "SettingIsNotAvailable",
          +        "InvalidDomain",
          +        "NotFederated",
          +    ):
          +        raise AutoDiscoverFailed(f"{self.error_code}: {self.error_message}")
          +    if self.user_settings_errors:
          +        raise AutoDiscoverFailed(f"User settings errors: {self.user_settings_errors}")
          +
          +
          +
          +

          Inherited members

          + +
          class WorkingPeriod (**kwargs) @@ -11353,6 +11870,26 @@

          UserResponse

          + + +
        • WorkingPeriod

          • ELEMENT_NAME
          • diff --git a/docs/exchangelib/protocol.html b/docs/exchangelib/protocol.html index e1fd2f1a..77f0066e 100644 --- a/docs/exchangelib/protocol.html +++ b/docs/exchangelib/protocol.html @@ -45,10 +45,10 @@

            Module exchangelib.protocol

            import requests.adapters import requests.sessions -from oauthlib.oauth2 import BackendApplicationClient, LegacyApplicationClient, WebApplicationClient +from oauthlib.oauth2 import BackendApplicationClient from requests_oauthlib import OAuth2Session -from .credentials import OAuth2AuthorizationCodeCredentials, OAuth2Credentials, OAuth2LegacyCredentials +from .credentials import BaseOAuth2Credentials from .errors import ( CASError, ErrorInvalidSchemaVersionForMailboxVersion, @@ -60,7 +60,17 @@

            Module exchangelib.protocol

            TransportError, UnauthorizedError, ) -from .properties import DLMailbox, FreeBusyViewOptions, MailboxData, RoomList, TimeWindow, TimeZone +from .properties import ( + ENTRY_ID, + EWS_ID, + AlternateId, + DLMailbox, + FreeBusyViewOptions, + MailboxData, + RoomList, + TimeWindow, + TimeZone, +) from .services import ( ConvertId, ExpandDL, @@ -72,7 +82,7 @@

            Module exchangelib.protocol

            ResolveNames, ) from .transport import CREDENTIALS_REQUIRED, DEFAULT_HEADERS, NTLM, OAUTH2, get_auth_instance, get_service_authtype -from .version import API_VERSIONS, Version +from .version import Version log = logging.getLogger(__name__) @@ -108,7 +118,6 @@

            Module exchangelib.protocol

            def __init__(self, config): self.config = config - self._api_version_hint = None self._session_pool_size = 0 self._session_pool_maxsize = config.max_connections or self.SESSION_POOLSIZE @@ -123,6 +132,10 @@

            Module exchangelib.protocol

            def service_endpoint(self): return self.config.service_endpoint + @abc.abstractmethod + def get_auth_type(self): + """Autodetect authentication type""" + @property def auth_type(self): # Autodetect authentication type if necessary @@ -150,15 +163,6 @@

            Module exchangelib.protocol

            def server(self): return self.config.server - def get_auth_type(self): - # Autodetect authentication type. We also set version hint here. - name = str(self.credentials) if self.credentials and str(self.credentials) else "DUMMY" - auth_type, api_version_hint = get_service_authtype( - service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS, name=name - ) - self._api_version_hint = api_version_hint - return auth_type - def __getstate__(self): # The session pool and lock cannot be pickled state = self.__dict__.copy() @@ -276,11 +280,12 @@

            Module exchangelib.protocol

            self._session_pool.put(session, block=False) def close_session(self, session): - if isinstance(self.credentials, OAuth2Credentials) and not isinstance( - self.credentials, OAuth2AuthorizationCodeCredentials + if isinstance(self.credentials, BaseOAuth2Credentials) and isinstance( + self.credentials.client, BackendApplicationClient ): - # Reset token if client is of type BackendApplicationClient - self.credentials.access_token = None + # Reset access token + with self.credentials.lock: + self.credentials.access_token = None session.close() del session @@ -317,24 +322,22 @@

            Module exchangelib.protocol

            session = self.raw_session(self.service_endpoint) session.auth = get_auth_instance(auth_type=self.auth_type) else: - with self.credentials.lock: - if isinstance(self.credentials, OAuth2Credentials): + if isinstance(self.credentials, BaseOAuth2Credentials): + with self.credentials.lock: session = self.create_oauth2_session() - # Keep track of the credentials used to create this session. If - # and when we need to renew credentials (for example, refreshing - # an OAuth access token), this lets us easily determine whether - # the credentials have already been refreshed in another thread - # by the time this session tries. + # Keep track of the credentials used to create this session. If and when we need to renew + # credentials (for example, refreshing an OAuth access token), this lets us easily determine whether + # the credentials have already been refreshed in another thread by the time this session tries. session.credentials_sig = self.credentials.sig() + else: + if self.auth_type == NTLM and self.credentials.type == self.credentials.EMAIL: + username = "\\" + self.credentials.username else: - if self.auth_type == NTLM and self.credentials.type == self.credentials.EMAIL: - username = "\\" + self.credentials.username - else: - username = self.credentials.username - session = self.raw_session(self.service_endpoint) - session.auth = get_auth_instance( - auth_type=self.auth_type, username=username, password=self.credentials.password - ) + username = self.credentials.username + session = self.raw_session(self.service_endpoint) + session.auth = get_auth_instance( + auth_type=self.auth_type, username=username, password=self.credentials.password + ) # Add some extra info session.session_id = random.randint(10000, 99999) # Used for debugging messages in services @@ -343,40 +346,10 @@

            Module exchangelib.protocol

            return session def create_oauth2_session(self): - session_params = {"token": self.credentials.access_token} # Token may be None - token_params = {"include_client_id": True} - - if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials): - token_params["code"] = self.credentials.authorization_code # Auth code may be None - self.credentials.authorization_code = None # We can only use the code once - - if self.credentials.client_id and self.credentials.client_secret: - # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other - # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to - # refresh the token (that covers cases where the caller doesn't have access to the client secret but - # is working with a service that can provide it refreshed tokens on a limited basis). - session_params.update( - { - "auto_refresh_kwargs": { - "client_id": self.credentials.client_id, - "client_secret": self.credentials.client_secret, - }, - "auto_refresh_url": self.credentials.token_url, - "token_updater": self.credentials.on_token_auto_refreshed, - } - ) - client = WebApplicationClient(client_id=self.credentials.client_id) - elif isinstance(self.credentials, OAuth2LegacyCredentials): - client = LegacyApplicationClient(client_id=self.credentials.client_id) - token_params["username"] = self.credentials.username - token_params["password"] = self.credentials.password - else: - client = BackendApplicationClient(client_id=self.credentials.client_id) - session = self.raw_session( self.service_endpoint, - oauth2_client=client, - oauth2_session_params=session_params, + oauth2_client=self.credentials.client, + oauth2_session_params=self.credentials.session_params(), oauth2_token_endpoint=self.credentials.token_url, ) if not session.token: @@ -387,12 +360,12 @@

            Module exchangelib.protocol

            client_secret=self.credentials.client_secret, scope=self.credentials.scope, timeout=self.TIMEOUT, - **token_params, + **self.credentials.token_params(), ) # Allow the credentials object to update its copy of the new token, and give the application an opportunity # to cache it. self.credentials.on_token_auto_refreshed(token) - session.auth = get_auth_instance(auth_type=OAUTH2, client=client) + session.auth = get_auth_instance(auth_type=OAUTH2, client=self.credentials.client) return session @@ -446,7 +419,7 @@

            Module exchangelib.protocol

            return protocol # Acquire lock to guard against multiple threads competing to cache information. Having a per-server lock is - # probably overkill although it would reduce lock contention. + # probably overkill, although it would reduce lock contention. log.debug("Waiting for _protocol_cache_lock") with cls._protocol_cache_lock: try: @@ -505,15 +478,21 @@

            Module exchangelib.protocol

            def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._version_lock = Lock() + self.api_version_hint = None + + def get_auth_type(self): + # Autodetect authentication type. We also set 'self.api_version_hint' here. + return get_service_authtype(protocol=self) @property def version(self): # Make sure only one thread does the guessing. if not self.config.version or not self.config.version.build: + log.debug("Waiting for _version_lock") with self._version_lock: if not self.config.version or not self.config.version.build: # Version.guess() needs auth objects and a working session pool - self.config.version = Version.guess(self, api_version_hint=self._api_version_hint) + self.config.version = Version.guess(self, api_version_hint=self.api_version_hint) return self.config.version def get_timezones(self, timezones=None, return_full_timezone_data=False): @@ -591,7 +570,7 @@

            Module exchangelib.protocol

            ) def expand_dl(self, distribution_list): - """Expand distribution list into it's members. + """Expand distribution list into its members. :param distribution_list: SMTP address of the distribution list to expand, or a DLMailbox representing the list @@ -629,6 +608,17 @@

            Module exchangelib.protocol

            """ return ConvertId(protocol=self).call(items=ids, destination_format=destination_format) + def dummy_xml(self): + # Generate a minimal, valid EWS request + svc = ConvertId(protocol=self) + return svc.wrap( + content=svc.get_payload( + items=[AlternateId(id="DUMMY", format=EWS_ID, mailbox="DUMMY")], + destination_format=ENTRY_ID, + ), + api_version=self.api_version_hint, + ) + def __getstate__(self): # The lock cannot be pickled state = super().__getstate__() @@ -660,7 +650,7 @@

            Module exchangelib.protocol

            def cert_verify(self, conn, url, verify, cert): # pylint: disable=unused-argument - # We're overriding a method so we have to keep the signature + # We're overriding a method, so we have to keep the signature super().cert_verify(conn=conn, url=url, verify=False, cert=cert) @@ -744,7 +734,7 @@

            Module exchangelib.protocol

            raise ValueError("Cannot back off with fail-fast policy") def may_retry_on_error(self, response, wait): - log.debug("No retry: no fail-fast policy") + log.debug("No retry with fail-fast policy") return False @@ -899,7 +889,6 @@

            Classes

            def __init__(self, config): self.config = config - self._api_version_hint = None self._session_pool_size = 0 self._session_pool_maxsize = config.max_connections or self.SESSION_POOLSIZE @@ -914,6 +903,10 @@

            Classes

            def service_endpoint(self): return self.config.service_endpoint + @abc.abstractmethod + def get_auth_type(self): + """Autodetect authentication type""" + @property def auth_type(self): # Autodetect authentication type if necessary @@ -941,15 +934,6 @@

            Classes

            def server(self): return self.config.server - def get_auth_type(self): - # Autodetect authentication type. We also set version hint here. - name = str(self.credentials) if self.credentials and str(self.credentials) else "DUMMY" - auth_type, api_version_hint = get_service_authtype( - service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS, name=name - ) - self._api_version_hint = api_version_hint - return auth_type - def __getstate__(self): # The session pool and lock cannot be pickled state = self.__dict__.copy() @@ -1067,11 +1051,12 @@

            Classes

            self._session_pool.put(session, block=False) def close_session(self, session): - if isinstance(self.credentials, OAuth2Credentials) and not isinstance( - self.credentials, OAuth2AuthorizationCodeCredentials + if isinstance(self.credentials, BaseOAuth2Credentials) and isinstance( + self.credentials.client, BackendApplicationClient ): - # Reset token if client is of type BackendApplicationClient - self.credentials.access_token = None + # Reset access token + with self.credentials.lock: + self.credentials.access_token = None session.close() del session @@ -1108,24 +1093,22 @@

            Classes

            session = self.raw_session(self.service_endpoint) session.auth = get_auth_instance(auth_type=self.auth_type) else: - with self.credentials.lock: - if isinstance(self.credentials, OAuth2Credentials): + if isinstance(self.credentials, BaseOAuth2Credentials): + with self.credentials.lock: session = self.create_oauth2_session() - # Keep track of the credentials used to create this session. If - # and when we need to renew credentials (for example, refreshing - # an OAuth access token), this lets us easily determine whether - # the credentials have already been refreshed in another thread - # by the time this session tries. + # Keep track of the credentials used to create this session. If and when we need to renew + # credentials (for example, refreshing an OAuth access token), this lets us easily determine whether + # the credentials have already been refreshed in another thread by the time this session tries. session.credentials_sig = self.credentials.sig() + else: + if self.auth_type == NTLM and self.credentials.type == self.credentials.EMAIL: + username = "\\" + self.credentials.username else: - if self.auth_type == NTLM and self.credentials.type == self.credentials.EMAIL: - username = "\\" + self.credentials.username - else: - username = self.credentials.username - session = self.raw_session(self.service_endpoint) - session.auth = get_auth_instance( - auth_type=self.auth_type, username=username, password=self.credentials.password - ) + username = self.credentials.username + session = self.raw_session(self.service_endpoint) + session.auth = get_auth_instance( + auth_type=self.auth_type, username=username, password=self.credentials.password + ) # Add some extra info session.session_id = random.randint(10000, 99999) # Used for debugging messages in services @@ -1134,40 +1117,10 @@

            Classes

            return session def create_oauth2_session(self): - session_params = {"token": self.credentials.access_token} # Token may be None - token_params = {"include_client_id": True} - - if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials): - token_params["code"] = self.credentials.authorization_code # Auth code may be None - self.credentials.authorization_code = None # We can only use the code once - - if self.credentials.client_id and self.credentials.client_secret: - # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other - # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to - # refresh the token (that covers cases where the caller doesn't have access to the client secret but - # is working with a service that can provide it refreshed tokens on a limited basis). - session_params.update( - { - "auto_refresh_kwargs": { - "client_id": self.credentials.client_id, - "client_secret": self.credentials.client_secret, - }, - "auto_refresh_url": self.credentials.token_url, - "token_updater": self.credentials.on_token_auto_refreshed, - } - ) - client = WebApplicationClient(client_id=self.credentials.client_id) - elif isinstance(self.credentials, OAuth2LegacyCredentials): - client = LegacyApplicationClient(client_id=self.credentials.client_id) - token_params["username"] = self.credentials.username - token_params["password"] = self.credentials.password - else: - client = BackendApplicationClient(client_id=self.credentials.client_id) - session = self.raw_session( self.service_endpoint, - oauth2_client=client, - oauth2_session_params=session_params, + oauth2_client=self.credentials.client, + oauth2_session_params=self.credentials.session_params(), oauth2_token_endpoint=self.credentials.token_url, ) if not session.token: @@ -1178,12 +1131,12 @@

            Classes

            client_secret=self.credentials.client_secret, scope=self.credentials.scope, timeout=self.TIMEOUT, - **token_params, + **self.credentials.token_params(), ) # Allow the credentials object to update its copy of the new token, and give the application an opportunity # to cache it. self.credentials.on_token_auto_refreshed(token) - session.auth = get_auth_instance(auth_type=OAUTH2, client=client) + session.auth = get_auth_instance(auth_type=OAUTH2, client=self.credentials.client) return session @@ -1416,11 +1369,12 @@

            Methods

            Expand source code
            def close_session(self, session):
            -    if isinstance(self.credentials, OAuth2Credentials) and not isinstance(
            -        self.credentials, OAuth2AuthorizationCodeCredentials
            +    if isinstance(self.credentials, BaseOAuth2Credentials) and isinstance(
            +        self.credentials.client, BackendApplicationClient
                 ):
            -        # Reset token if client is of type BackendApplicationClient
            -        self.credentials.access_token = None
            +        # Reset access token
            +        with self.credentials.lock:
            +            self.credentials.access_token = None
                 session.close()
                 del session
            @@ -1435,40 +1389,10 @@

            Methods

            Expand source code
            def create_oauth2_session(self):
            -    session_params = {"token": self.credentials.access_token}  # Token may be None
            -    token_params = {"include_client_id": True}
            -
            -    if isinstance(self.credentials, OAuth2AuthorizationCodeCredentials):
            -        token_params["code"] = self.credentials.authorization_code  # Auth code may be None
            -        self.credentials.authorization_code = None  # We can only use the code once
            -
            -        if self.credentials.client_id and self.credentials.client_secret:
            -            # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other
            -            # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to
            -            # refresh the token (that covers cases where the caller doesn't have access to the client secret but
            -            # is working with a service that can provide it refreshed tokens on a limited basis).
            -            session_params.update(
            -                {
            -                    "auto_refresh_kwargs": {
            -                        "client_id": self.credentials.client_id,
            -                        "client_secret": self.credentials.client_secret,
            -                    },
            -                    "auto_refresh_url": self.credentials.token_url,
            -                    "token_updater": self.credentials.on_token_auto_refreshed,
            -                }
            -            )
            -        client = WebApplicationClient(client_id=self.credentials.client_id)
            -    elif isinstance(self.credentials, OAuth2LegacyCredentials):
            -        client = LegacyApplicationClient(client_id=self.credentials.client_id)
            -        token_params["username"] = self.credentials.username
            -        token_params["password"] = self.credentials.password
            -    else:
            -        client = BackendApplicationClient(client_id=self.credentials.client_id)
            -
                 session = self.raw_session(
                     self.service_endpoint,
            -        oauth2_client=client,
            -        oauth2_session_params=session_params,
            +        oauth2_client=self.credentials.client,
            +        oauth2_session_params=self.credentials.session_params(),
                     oauth2_token_endpoint=self.credentials.token_url,
                 )
                 if not session.token:
            @@ -1479,12 +1403,12 @@ 

            Methods

            client_secret=self.credentials.client_secret, scope=self.credentials.scope, timeout=self.TIMEOUT, - **token_params, + **self.credentials.token_params(), ) # Allow the credentials object to update its copy of the new token, and give the application an opportunity # to cache it. self.credentials.on_token_auto_refreshed(token) - session.auth = get_auth_instance(auth_type=OAUTH2, client=client) + session.auth = get_auth_instance(auth_type=OAUTH2, client=self.credentials.client) return session
            @@ -1505,24 +1429,22 @@

            Methods

            session = self.raw_session(self.service_endpoint) session.auth = get_auth_instance(auth_type=self.auth_type) else: - with self.credentials.lock: - if isinstance(self.credentials, OAuth2Credentials): + if isinstance(self.credentials, BaseOAuth2Credentials): + with self.credentials.lock: session = self.create_oauth2_session() - # Keep track of the credentials used to create this session. If - # and when we need to renew credentials (for example, refreshing - # an OAuth access token), this lets us easily determine whether - # the credentials have already been refreshed in another thread - # by the time this session tries. + # Keep track of the credentials used to create this session. If and when we need to renew + # credentials (for example, refreshing an OAuth access token), this lets us easily determine whether + # the credentials have already been refreshed in another thread by the time this session tries. session.credentials_sig = self.credentials.sig() + else: + if self.auth_type == NTLM and self.credentials.type == self.credentials.EMAIL: + username = "\\" + self.credentials.username else: - if self.auth_type == NTLM and self.credentials.type == self.credentials.EMAIL: - username = "\\" + self.credentials.username - else: - username = self.credentials.username - session = self.raw_session(self.service_endpoint) - session.auth = get_auth_instance( - auth_type=self.auth_type, username=username, password=self.credentials.password - ) + username = self.credentials.username + session = self.raw_session(self.service_endpoint) + session.auth = get_auth_instance( + auth_type=self.auth_type, username=username, password=self.credentials.password + ) # Add some extra info session.session_id = random.randint(10000, 99999) # Used for debugging messages in services @@ -1568,19 +1490,14 @@

            Methods

            def get_auth_type(self)
        • -
          +

          Autodetect authentication type

          Expand source code -
          def get_auth_type(self):
          -    # Autodetect authentication type. We also set version hint here.
          -    name = str(self.credentials) if self.credentials and str(self.credentials) else "DUMMY"
          -    auth_type, api_version_hint = get_service_authtype(
          -        service_endpoint=self.service_endpoint, retry_policy=self.retry_policy, api_versions=API_VERSIONS, name=name
          -    )
          -    self._api_version_hint = api_version_hint
          -    return auth_type
          +
          @abc.abstractmethod
          +def get_auth_type(self):
          +    """Autodetect authentication type"""
          @@ -1763,7 +1680,7 @@

          Methods

          return protocol # Acquire lock to guard against multiple threads competing to cache information. Having a per-server lock is - # probably overkill although it would reduce lock contention. + # probably overkill, although it would reduce lock contention. log.debug("Waiting for _protocol_cache_lock") with cls._protocol_cache_lock: try: @@ -1866,7 +1783,7 @@

          Static methods

          raise ValueError("Cannot back off with fail-fast policy") def may_retry_on_error(self, response, wait): - log.debug("No retry: no fail-fast policy") + log.debug("No retry with fail-fast policy") return False

          Ancestors

          @@ -2045,7 +1962,7 @@

          Inherited members

          def cert_verify(self, conn, url, verify, cert): # pylint: disable=unused-argument - # We're overriding a method so we have to keep the signature + # We're overriding a method, so we have to keep the signature super().cert_verify(conn=conn, url=url, verify=False, cert=cert)

          Ancestors

          @@ -2074,7 +1991,7 @@

          Methods

          def cert_verify(self, conn, url, verify, cert):
               # pylint: disable=unused-argument
          -    # We're overriding a method so we have to keep the signature
          +    # We're overriding a method, so we have to keep the signature
               super().cert_verify(conn=conn, url=url, verify=False, cert=cert)
          @@ -2101,15 +2018,21 @@

          Methods

          def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._version_lock = Lock() + self.api_version_hint = None + + def get_auth_type(self): + # Autodetect authentication type. We also set 'self.api_version_hint' here. + return get_service_authtype(protocol=self) @property def version(self): # Make sure only one thread does the guessing. if not self.config.version or not self.config.version.build: + log.debug("Waiting for _version_lock") with self._version_lock: if not self.config.version or not self.config.version.build: # Version.guess() needs auth objects and a working session pool - self.config.version = Version.guess(self, api_version_hint=self._api_version_hint) + self.config.version = Version.guess(self, api_version_hint=self.api_version_hint) return self.config.version def get_timezones(self, timezones=None, return_full_timezone_data=False): @@ -2187,7 +2110,7 @@

          Methods

          ) def expand_dl(self, distribution_list): - """Expand distribution list into it's members. + """Expand distribution list into its members. :param distribution_list: SMTP address of the distribution list to expand, or a DLMailbox representing the list @@ -2225,6 +2148,17 @@

          Methods

          """ return ConvertId(protocol=self).call(items=ids, destination_format=destination_format) + def dummy_xml(self): + # Generate a minimal, valid EWS request + svc = ConvertId(protocol=self) + return svc.wrap( + content=svc.get_payload( + items=[AlternateId(id="DUMMY", format=EWS_ID, mailbox="DUMMY")], + destination_format=ENTRY_ID, + ), + api_version=self.api_version_hint, + ) + def __getstate__(self): # The lock cannot be pickled state = super().__getstate__() @@ -2267,10 +2201,11 @@

          Instance variables

          def version(self): # Make sure only one thread does the guessing. if not self.config.version or not self.config.version.build: + log.debug("Waiting for _version_lock") with self._version_lock: if not self.config.version or not self.config.version.build: # Version.guess() needs auth objects and a working session pool - self.config.version = Version.guess(self, api_version_hint=self._api_version_hint) + self.config.version = Version.guess(self, api_version_hint=self.api_version_hint) return self.config.version
          @@ -2300,11 +2235,32 @@

          Methods

          return ConvertId(protocol=self).call(items=ids, destination_format=destination_format)
          +
          +def dummy_xml(self) +
          +
          +
          +
          + +Expand source code + +
          def dummy_xml(self):
          +    # Generate a minimal, valid EWS request
          +    svc = ConvertId(protocol=self)
          +    return svc.wrap(
          +        content=svc.get_payload(
          +            items=[AlternateId(id="DUMMY", format=EWS_ID, mailbox="DUMMY")],
          +            destination_format=ENTRY_ID,
          +        ),
          +        api_version=self.api_version_hint,
          +    )
          +
          +
          def expand_dl(self, distribution_list)
          -

          Expand distribution list into it's members.

          +

          Expand distribution list into its members.

          :param distribution_list: SMTP address of the distribution list to expand, or a DLMailbox representing the list

          :return: List of Mailbox items that are members of the distribution list

          @@ -2312,7 +2268,7 @@

          Methods

          Expand source code
          def expand_dl(self, distribution_list):
          -    """Expand distribution list into it's members.
          +    """Expand distribution list into its members.
           
               :param distribution_list: SMTP address of the distribution list to expand, or a DLMailbox representing the list
           
          @@ -2506,6 +2462,7 @@ 

          Inherited members

          @@ -2813,6 +2770,7 @@

          Protocol

          • convert_ids
          • +
          • dummy_xml
          • expand_dl
          • get_free_busy_info
          • get_roomlists
          • diff --git a/docs/exchangelib/queryset.html b/docs/exchangelib/queryset.html index fd552ca0..cbc067a0 100644 --- a/docs/exchangelib/queryset.html +++ b/docs/exchangelib/queryset.html @@ -73,7 +73,7 @@

            Module exchangelib.queryset

            class QuerySet(SearchableMixIn): - """A Django QuerySet-like class for querying items. Defers queries until the QuerySet is consumed. Supports + """A Django QuerySet-like class for querying items. Defers query until the QuerySet is consumed. Supports chaining to build up complex queries. Django QuerySet documentation: https://docs.djangoproject.com/en/dev/ref/models/querysets/ @@ -725,7 +725,7 @@

            Classes

            (folder_collection, request_type='item')
            -

            A Django QuerySet-like class for querying items. Defers queries until the QuerySet is consumed. Supports +

            A Django QuerySet-like class for querying items. Defers query until the QuerySet is consumed. Supports chaining to build up complex queries.

            Django QuerySet documentation: https://docs.djangoproject.com/en/dev/ref/models/querysets/

            @@ -733,7 +733,7 @@

            Classes

            Expand source code
            class QuerySet(SearchableMixIn):
            -    """A Django QuerySet-like class for querying items. Defers queries until the QuerySet is consumed. Supports
            +    """A Django QuerySet-like class for querying items. Defers query until the QuerySet is consumed. Supports
                 chaining to build up complex queries.
             
                 Django QuerySet documentation: https://docs.djangoproject.com/en/dev/ref/models/querysets/
            diff --git a/docs/exchangelib/recurrence.html b/docs/exchangelib/recurrence.html
            index 1f9717d6..ee2404e9 100644
            --- a/docs/exchangelib/recurrence.html
            +++ b/docs/exchangelib/recurrence.html
            @@ -73,10 +73,10 @@ 

            Module exchangelib.recurrence

            ELEMENT_NAME = "AbsoluteYearlyRecurrence" - # The day of month of an occurrence, in range 1 -> 31. If a particular month has less days than the day_of_month + # The day of month of an occurrence, in range 1 -> 31. If a particular month has fewer days than the day_of_month # value, the last day in the month is assumed day_of_month = IntegerField(field_uri="DayOfMonth", min=1, max=31, is_required=True) - # The month of the year, from 1 - 12 + # The month of the year, from 1 to 12 month = EnumField(field_uri="Month", enum=MONTHS, is_required=True) def __str__(self): @@ -97,7 +97,7 @@

            Module exchangelib.recurrence

            # Week number of the month, in range 1 -> 5. If 5 is specified, this assumes the last week of the month for # months that have only 4 weeks week_number = EnumField(field_uri="DayOfWeekIndex", enum=WEEK_NUMBERS, is_required=True) - # The month of the year, from 1 - 12 + # The month of the year, from 1 to 12 month = EnumField(field_uri="Month", enum=MONTHS, is_required=True) def __str__(self): @@ -116,7 +116,7 @@

            Module exchangelib.recurrence

            # Interval, in months, in range 1 -> 99 interval = IntegerField(field_uri="Interval", min=1, max=99, is_required=True) - # The day of month of an occurrence, in range 1 -> 31. If a particular month has less days than the day_of_month + # The day of month of an occurrence, in range 1 -> 31. If a particular month has fewer days than the day_of_month # value, the last day in the month is assumed day_of_month = IntegerField(field_uri="DayOfMonth", min=1, max=31, is_required=True) @@ -406,7 +406,7 @@

            Classes

            # Interval, in months, in range 1 -> 99 interval = IntegerField(field_uri="Interval", min=1, max=99, is_required=True) - # The day of month of an occurrence, in range 1 -> 31. If a particular month has less days than the day_of_month + # The day of month of an occurrence, in range 1 -> 31. If a particular month has fewer days than the day_of_month # value, the last day in the month is assumed day_of_month = IntegerField(field_uri="DayOfMonth", min=1, max=31, is_required=True) @@ -470,10 +470,10 @@

            Inherited members

            ELEMENT_NAME = "AbsoluteYearlyRecurrence" - # The day of month of an occurrence, in range 1 -> 31. If a particular month has less days than the day_of_month + # The day of month of an occurrence, in range 1 -> 31. If a particular month has fewer days than the day_of_month # value, the last day in the month is assumed day_of_month = IntegerField(field_uri="DayOfMonth", min=1, max=31, is_required=True) - # The month of the year, from 1 - 12 + # The month of the year, from 1 to 12 month = EnumField(field_uri="Month", enum=MONTHS, is_required=True) def __str__(self): @@ -1419,7 +1419,7 @@

            Inherited members

            # Week number of the month, in range 1 -> 5. If 5 is specified, this assumes the last week of the month for # months that have only 4 weeks week_number = EnumField(field_uri="DayOfWeekIndex", enum=WEEK_NUMBERS, is_required=True) - # The month of the year, from 1 - 12 + # The month of the year, from 1 to 12 month = EnumField(field_uri="Month", enum=MONTHS, is_required=True) def __str__(self): @@ -1896,4 +1896,4 @@

            pdoc 0.10.0.

            - \ No newline at end of file + diff --git a/docs/exchangelib/restriction.html b/docs/exchangelib/restriction.html index 29773372..9029e838 100644 --- a/docs/exchangelib/restriction.html +++ b/docs/exchangelib/restriction.html @@ -184,7 +184,7 @@

            Module exchangelib.restriction

            return (self.__class__(*children, conn_type=self.OR),) if lookup == self.LOOKUP_CONTAINS and is_iterable(value, generators_allowed=True): - # A '__contains' lookup with an list as the value ony makes sense for list fields, since exact match + # A '__contains' lookup with a list as the value ony makes sense for list fields, since exact match # on multiple distinct values will always fail for single-value fields. # # An empty list as value is allowed. We interpret it to mean "are all values in the empty set contained @@ -251,7 +251,7 @@

            Module exchangelib.restriction

            self.children = q.children def clean(self, version): - """Do some basic checks on the attributes, using a generic folder. to_xml() does a really good job of + """Do some basic checks on the attributes, using a generic folder. to_xml() does a good job of validating. There's no reason to replicate much of that here. """ from .folders import Folder @@ -357,7 +357,7 @@

            Module exchangelib.restriction

            if self.is_leaf(): expr = f"{self.field_path} {self.op} {self.value!r}" else: - # Sort children by field name so we get stable output (for easier testing). Children should never be empty. + # Sort children by field name, so we get stable output (for easier testing). Children should never be empty. expr = f" {self.AND if self.conn_type == self.NOT else self.conn_type} ".join( (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f"({c.expr()})") for c in sorted(self.children, key=lambda i: i.field_path or "") @@ -477,7 +477,7 @@

            Module exchangelib.restriction

            clean_value = self._get_clean_value(field_path=field_path, version=version) if issubclass(field_path.field.value_cls, SingleFieldIndexedElement) and not field_path.label: # We allow a filter shortcut of e.g. email_addresses__contains=EmailAddress(label='Foo', ...) instead of - # email_addresses__Foo_email_address=.... Set FieldPath label now so we can generate the field_uri. + # email_addresses__Foo_email_address=.... Set FieldPath label now, so we can generate the field_uri. field_path.label = clean_value.label elif isinstance(field_path.field, DateTimeBackedDateField): # We need to convert to datetime @@ -497,7 +497,7 @@

            Module exchangelib.restriction

            else: # We have multiple children. If conn_type is NOT, then group children with AND. We'll add the NOT later elem = self._conn_to_xml(self.AND if self.conn_type == self.NOT else self.conn_type) - # Sort children by field name so we get stable output (for easier testing). Children should never be empty + # Sort children by field name, so we get stable output (for easier testing). Children should never be empty for c in sorted(self.children, key=lambda i: i.field_path or ""): elem.append(c.xml_elem(folders=folders, version=version, applies_to=applies_to)) if elem is None: @@ -520,7 +520,7 @@

            Module exchangelib.restriction

            def __invert__(self): # ~ operator. If op has an inverse, change op. Else return a new Q with conn_type NOT if self.conn_type == self.NOT: - # This is NOT NOT. Change to AND + # This is 'NOT NOT'. Change to 'AND' new = copy(self) new.conn_type = self.AND new.reduce() @@ -756,7 +756,7 @@

            Classes

            return (self.__class__(*children, conn_type=self.OR),) if lookup == self.LOOKUP_CONTAINS and is_iterable(value, generators_allowed=True): - # A '__contains' lookup with an list as the value ony makes sense for list fields, since exact match + # A '__contains' lookup with a list as the value ony makes sense for list fields, since exact match # on multiple distinct values will always fail for single-value fields. # # An empty list as value is allowed. We interpret it to mean "are all values in the empty set contained @@ -823,7 +823,7 @@

            Classes

            self.children = q.children def clean(self, version): - """Do some basic checks on the attributes, using a generic folder. to_xml() does a really good job of + """Do some basic checks on the attributes, using a generic folder. to_xml() does a good job of validating. There's no reason to replicate much of that here. """ from .folders import Folder @@ -929,7 +929,7 @@

            Classes

            if self.is_leaf(): expr = f"{self.field_path} {self.op} {self.value!r}" else: - # Sort children by field name so we get stable output (for easier testing). Children should never be empty. + # Sort children by field name, so we get stable output (for easier testing). Children should never be empty. expr = f" {self.AND if self.conn_type == self.NOT else self.conn_type} ".join( (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f"({c.expr()})") for c in sorted(self.children, key=lambda i: i.field_path or "") @@ -1049,7 +1049,7 @@

            Classes

            clean_value = self._get_clean_value(field_path=field_path, version=version) if issubclass(field_path.field.value_cls, SingleFieldIndexedElement) and not field_path.label: # We allow a filter shortcut of e.g. email_addresses__contains=EmailAddress(label='Foo', ...) instead of - # email_addresses__Foo_email_address=.... Set FieldPath label now so we can generate the field_uri. + # email_addresses__Foo_email_address=.... Set FieldPath label now, so we can generate the field_uri. field_path.label = clean_value.label elif isinstance(field_path.field, DateTimeBackedDateField): # We need to convert to datetime @@ -1069,7 +1069,7 @@

            Classes

            else: # We have multiple children. If conn_type is NOT, then group children with AND. We'll add the NOT later elem = self._conn_to_xml(self.AND if self.conn_type == self.NOT else self.conn_type) - # Sort children by field name so we get stable output (for easier testing). Children should never be empty + # Sort children by field name, so we get stable output (for easier testing). Children should never be empty for c in sorted(self.children, key=lambda i: i.field_path or ""): elem.append(c.xml_elem(folders=folders, version=version, applies_to=applies_to)) if elem is None: @@ -1092,7 +1092,7 @@

            Classes

            def __invert__(self): # ~ operator. If op has an inverse, change op. Else return a new Q with conn_type NOT if self.conn_type == self.NOT: - # This is NOT NOT. Change to AND + # This is 'NOT NOT'. Change to 'AND' new = copy(self) new.conn_type = self.AND new.reduce() @@ -1310,14 +1310,14 @@

            Methods

            def clean(self, version)
            -

            Do some basic checks on the attributes, using a generic folder. to_xml() does a really good job of +

            Do some basic checks on the attributes, using a generic folder. to_xml() does a good job of validating. There's no reason to replicate much of that here.

            Expand source code
            def clean(self, version):
            -    """Do some basic checks on the attributes, using a generic folder. to_xml() does a really good job of
            +    """Do some basic checks on the attributes, using a generic folder. to_xml() does a good job of
                 validating. There's no reason to replicate much of that here.
                 """
                 from .folders import Folder
            @@ -1344,7 +1344,7 @@ 

            Methods

            if self.is_leaf(): expr = f"{self.field_path} {self.op} {self.value!r}" else: - # Sort children by field name so we get stable output (for easier testing). Children should never be empty. + # Sort children by field name, so we get stable output (for easier testing). Children should never be empty. expr = f" {self.AND if self.conn_type == self.NOT else self.conn_type} ".join( (c.expr() if c.is_leaf() or c.conn_type == self.NOT else f"({c.expr()})") for c in sorted(self.children, key=lambda i: i.field_path or "") @@ -1466,7 +1466,7 @@

            Methods

            clean_value = self._get_clean_value(field_path=field_path, version=version) if issubclass(field_path.field.value_cls, SingleFieldIndexedElement) and not field_path.label: # We allow a filter shortcut of e.g. email_addresses__contains=EmailAddress(label='Foo', ...) instead of - # email_addresses__Foo_email_address=.... Set FieldPath label now so we can generate the field_uri. + # email_addresses__Foo_email_address=.... Set FieldPath label now, so we can generate the field_uri. field_path.label = clean_value.label elif isinstance(field_path.field, DateTimeBackedDateField): # We need to convert to datetime @@ -1486,7 +1486,7 @@

            Methods

            else: # We have multiple children. If conn_type is NOT, then group children with AND. We'll add the NOT later elem = self._conn_to_xml(self.AND if self.conn_type == self.NOT else self.conn_type) - # Sort children by field name so we get stable output (for easier testing). Children should never be empty + # Sort children by field name, so we get stable output (for easier testing). Children should never be empty for c in sorted(self.children, key=lambda i: i.field_path or ""): elem.append(c.xml_elem(folders=folders, version=version, applies_to=applies_to)) if elem is None: diff --git a/docs/exchangelib/services/archive_item.html b/docs/exchangelib/services/archive_item.html index 29068116..c5836454 100644 --- a/docs/exchangelib/services/archive_item.html +++ b/docs/exchangelib/services/archive_item.html @@ -112,6 +112,7 @@

            Ancestors

            Class variables

            @@ -180,6 +181,8 @@

            Inherited members

          • WARNINGS_TO_CATCH_IN_RESPONSE
          • get
          • parse
          • +
          • supported_api_versions
          • +
          • wrap
        @@ -219,4 +222,4 @@

        pdoc 0.10.0.

        - \ No newline at end of file + diff --git a/docs/exchangelib/services/common.html b/docs/exchangelib/services/common.html index 67211789..d72b320e 100644 --- a/docs/exchangelib/services/common.html +++ b/docs/exchangelib/services/common.html @@ -33,7 +33,7 @@

        Module exchangelib.services.common

        from .. import errors from ..attachments import AttachmentId -from ..credentials import IMPERSONATION, OAuth2Credentials +from ..credentials import IMPERSONATION, BaseOAuth2Credentials from ..errors import ( ErrorBatchProcessingStopped, ErrorCannotDeleteObject, @@ -41,6 +41,7 @@

        Module exchangelib.services.common

        ErrorCorruptData, ErrorExceededConnectionCount, ErrorIncorrectSchemaVersion, + ErrorInternalServerTransientError, ErrorInvalidChangeKey, ErrorInvalidIdMalformed, ErrorInvalidRequest, @@ -75,7 +76,7 @@

        Module exchangelib.services.common

        IndexedFieldURI, ItemId, ) -from ..transport import wrap +from ..transport import DEFAULT_ENCODING from ..util import ( ENS, MNS, @@ -87,25 +88,24 @@

        Module exchangelib.services.common

        chunkify, create_element, get_xml_attr, + ns_translation, post_ratelimited, set_xml_value, to_xml, xml_to_str, ) -from ..version import API_VERSIONS, Version +from ..version import SupportedVersionClassMixIn, Version log = logging.getLogger(__name__) -class EWSService(metaclass=abc.ABCMeta): +class EWSService(SupportedVersionClassMixIn, metaclass=abc.ABCMeta): """Base class for all EWS services.""" - PAGE_SIZE = 100 # A default page size for all paging services. This is the number of items we request per page CHUNK_SIZE = 100 # A default chunk size for all services. This is the number of items we send in a single request SERVICE_NAME = None # The name of the SOAP service element_container_name = None # The name of the XML element wrapping the collection of returned items - paging_container_name = None # The name of the element that contains paging information and the paged results returns_elements = True # If False, the service does not return response elements, just the ResponseCode status # Return exception instance instead of raising exceptions for the following errors when contained in an element ERRORS_TO_CATCH_IN_RESPONSE = ( @@ -123,16 +123,14 @@

        Module exchangelib.services.common

        ErrorItemCorrupt, ErrorMailRecipientNotFound, ) - # Similarly, define the warnings we want to return unraised + # Similarly, define the warnings we want to return un-raised WARNINGS_TO_CATCH_IN_RESPONSE = ErrorBatchProcessingStopped # Define the warnings we want to ignore, to let response processing proceed WARNINGS_TO_IGNORE_IN_RESPONSE = () # The exception type to raise when all attempted API versions failed NO_VALID_SERVER_VERSIONS = ErrorInvalidServerVersion - # Marks the version from which the service was introduced - supported_from = None - # Marks services that support paging of requested items - supports_paging = False + + NS_MAP = {k: v for k, v in ns_translation.items() if k in ("s", "m", "t")} def __init__(self, protocol, chunk_size=None, timeout=None): self.chunk_size = chunk_size or self.CHUNK_SIZE @@ -142,8 +140,8 @@

        Module exchangelib.services.common

        raise ValueError(f"'chunk_size' {self.chunk_size} must be a positive number") if self.supported_from and protocol.version.build < self.supported_from: raise NotImplementedError( - f"{self.SERVICE_NAME!r} is only supported on {self.supported_from.fullname()!r} and later. " - f"Your current version is {protocol.version.build.fullname()!r}." + f"Service {self.SERVICE_NAME!r} only supports server versions from {self.supported_from or '*'} to " + f"{self.deprecated_from or '*'} (server has {protocol.version})" ) self.protocol = protocol # Allow a service to override the default protocol timeout. Useful for streaming services @@ -211,6 +209,43 @@

        Module exchangelib.services.common

        _, body = self._get_soap_parts(response=resp) return self._elems_to_objs(self._get_elements_in_response(response=self._get_soap_messages(body=body))) + def wrap(self, content, api_version=None): + """Generate the necessary boilerplate XML for a raw SOAP request. The XML is specific to the server version. + ExchangeImpersonation allows to act as the user we want to impersonate. + + RequestServerVersion element on MSDN: + https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/requestserverversion + + ExchangeImpersonation element on MSDN: + https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exchangeimpersonation + + TimeZoneContent element on MSDN: + https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezonecontext + + :param content: + :param api_version: + """ + envelope = create_element("s:Envelope", nsmap=self.NS_MAP) + header = create_element("s:Header") + if api_version: + request_server_version = create_element("t:RequestServerVersion", attrs=dict(Version=api_version)) + header.append(request_server_version) + identity = self._account_to_impersonate + if identity: + add_xml_child(header, "t:ExchangeImpersonation", identity) + timezone = self._timezone + if timezone: + timezone_context = create_element("t:TimeZoneContext") + timezone_definition = create_element("t:TimeZoneDefinition", attrs=dict(Id=timezone.ms_id)) + timezone_context.append(timezone_definition) + header.append(timezone_context) + if len(header): + envelope.append(header) + body = create_element("s:Body") + body.append(content) + envelope.append(body) + return xml_to_str(envelope, encoding=DEFAULT_ENCODING, xml_declaration=True) + def _elems_to_objs(self, elems): """Takes a generator of XML elements and exceptions. Returns the equivalent Python objects (or exceptions).""" for elem in elems: @@ -234,12 +269,18 @@

        Module exchangelib.services.common

        def _version_hint(self, value): self.protocol.config.version = value - def _extra_headers(self, session): - return {} + def _extra_headers(self): + headers = {} + identity = self._account_to_impersonate + if identity and identity.primary_smtp_address: + # See + # https://blogs.msdn.microsoft.com/webdav_101/2015/05/11/best-practices-ews-authentication-and-access-issues/ + headers["X-AnchorMailbox"] = identity.primary_smtp_address + return headers @property def _account_to_impersonate(self): - if isinstance(self.protocol.credentials, OAuth2Credentials): + if self.protocol and isinstance(self.protocol.credentials, BaseOAuth2Credentials): return self.protocol.credentials.identity return None @@ -254,8 +295,6 @@

        Module exchangelib.services.common

        :return: the response, as XML objects """ response = self._get_response_xml(payload=payload) - if self.supports_paging: - return (self._get_page(message) for message in response) return self._get_elements_in_response(response=response) def _chunked_get_elements(self, payload_func, items, **kwargs): @@ -268,7 +307,7 @@

        Module exchangelib.services.common

        :return: Same as ._get_elements() """ # If the input for a service is a QuerySet, it can be difficult to remove exceptions before now - filtered_items = filter(lambda i: not isinstance(i, Exception), items) + filtered_items = filter(lambda item: not isinstance(item, Exception), items) for i, chunk in enumerate(chunkify(filtered_items, self.chunk_size), start=1): log.debug("Processing chunk %s containing %s items", i, len(chunk)) yield from self._get_elements(payload=payload_func(chunk, **kwargs)) @@ -296,7 +335,7 @@

        Module exchangelib.services.common

        except ErrorServerBusy as e: self._handle_backoff(e) continue - except (ErrorTooManyObjectsOpened, ErrorTimeoutExpired) as e: + except (ErrorTooManyObjectsOpened, ErrorTimeoutExpired, ErrorInternalServerTransientError) as e: # ErrorTooManyObjectsOpened means there are too many connections to the Exchange database. This is very # often a symptom of sending too many requests. # @@ -327,12 +366,10 @@

        Module exchangelib.services.common

        protocol=self.protocol, session=session, url=self.protocol.service_endpoint, - headers=self._extra_headers(session), - data=wrap( + headers=self._extra_headers(), + data=self.wrap( content=payload, api_version=api_version, - account_to_impersonate=self._account_to_impersonate, - timezone=self._timezone, ), stream=self.streaming, timeout=self.timeout or self.protocol.TIMEOUT, @@ -346,10 +383,16 @@

        Module exchangelib.services.common

        self.protocol.release_session(session) return r - @property + @classmethod + def supported_api_versions(cls): + """Return API versions supported by the service, sorted from newest to oldest""" + return sorted({v.api_version for v in Version.all_versions() if cls.supports_version(v)}, reverse=True) + def _api_versions_to_try(self): # Put the hint first in the list, and then all other versions except the hint, from newest to oldest - return (self._version_hint.api_version,) + tuple(v for v in API_VERSIONS if v != self._version_hint.api_version) + return (self._version_hint.api_version,) + tuple( + v for v in self.supported_api_versions() if v != self._version_hint.api_version + ) def _get_response_xml(self, payload, **parse_opts): """Send the payload to the server and return relevant elements from the result. Several things happen here: @@ -366,7 +409,7 @@

        Module exchangelib.services.common

        # guessing tango, but then the server may decide that any arbitrary legacy backend server may actually process # the request for an account. Prepare to handle version-related errors and set the server version per-account. log.debug("Calling service %s", self.SERVICE_NAME) - for api_version in self._api_versions_to_try: + for api_version in self._api_versions_to_try(): log.debug("Trying API version %s", api_version) r = self._get_response(payload=payload, api_version=api_version) if self.streaming: @@ -405,7 +448,7 @@

        Module exchangelib.services.common

        # In streaming mode, we may not have accessed the raw stream yet. Caller must handle this. r.close() # Release memory - raise self.NO_VALID_SERVER_VERSIONS(f"Tried versions {self._api_versions_to_try} but all were invalid") + raise self.NO_VALID_SERVER_VERSIONS(f"Tried versions {self._api_versions_to_try()} but all were invalid") def _handle_backoff(self, e): """Take a request from the server to back off and checks the retry policy for what to do. Re-raise the @@ -436,7 +479,7 @@

        Module exchangelib.services.common

        # Nothing to do return log.debug("Found new version (%s -> %s)", self._version_hint, head_version) - # The api_version that worked was different than our hint, or we never got a build version. Store the working + # The api_version that worked was different from our hint, or we never got a build version. Store the working # version. self._version_hint = head_version @@ -457,7 +500,7 @@

        Module exchangelib.services.common

        @classmethod def _get_soap_parts(cls, response, **parse_opts): - """Split the SOAP response into its headers an body elements.""" + """Split the SOAP response into its headers and body elements.""" try: root = to_xml(response.iter_content()) except ParseError as e: @@ -576,7 +619,7 @@

        Module exchangelib.services.common

        if container is None: raise MalformedResponseError(f"No {name} elements in ResponseMessage ({xml_to_str(message)})") return container - # rspclass == 'Error', or 'Success' and not 'NoError' + # response_class == 'Error', or 'Success' and not 'NoError' try: raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml) except self.ERRORS_TO_CATCH_IN_RESPONSE as e: @@ -666,7 +709,7 @@

        Module exchangelib.services.common

        @classmethod def _get_elements_in_container(cls, container): """Return a list of response elements from an XML response element container. With e.g. - 'CreateItem', 'Items' is the container element and we return the 'Message' child elements: + 'CreateItem', 'Items' is the container element, and we return the 'Message' child elements: <m:Items> <t:Message> @@ -712,8 +755,8 @@

        Module exchangelib.services.common

        self.account.affinity_cookie = cookie.value break - def _extra_headers(self, session): - headers = super()._extra_headers(session=session) + def _extra_headers(self): + headers = super()._extra_headers() # See # https://blogs.msdn.microsoft.com/webdav_101/2015/05/11/best-practices-ews-authentication-and-access-issues/ headers["X-AnchorMailbox"] = self.account.primary_smtp_address @@ -738,6 +781,10 @@

        Module exchangelib.services.common

        class EWSPagingService(EWSAccountService): + PAGE_SIZE = 100 # A default page size for all paging services. This is the number of items we request per page + + paging_container_name = None # The name of the element that contains paging information and the paged results + def __init__(self, *args, **kwargs): self.page_size = kwargs.pop("page_size", None) or self.PAGE_SIZE if not isinstance(self.page_size, int): @@ -746,6 +793,15 @@

        Module exchangelib.services.common

        raise ValueError(f"'page_size' {self.page_size} must be a positive number") super().__init__(*args, **kwargs) + def _response_generator(self, payload): + """Send the payload to the server, and return the response. + + :param payload: payload as an XML object + :return: the response, as XML objects + """ + response = self._get_response_xml(payload=payload) + return (self._get_page(message) for message in response) + def _paged_call(self, payload_func, max_items, folders, **kwargs): """Call a service that supports paging requests. Return a generator over all response items. Keeps track of all paging-related counters. @@ -871,7 +927,7 @@

        Module exchangelib.services.common

        # Paging is done for all messages return None # We cannot guarantee that all messages that have a next_offset also have the *same* next_offset. This is - # because the collections that we are iterating may change while iterating. We'll do our best but we cannot + # because the collections that we are iterating may change while iterating. We'll do our best, but we cannot # guarantee 100% consistency when large collections are simultaneously being changed on the server. # # It's not possible to supply a per-folder offset when iterating multiple folders, so we'll just have to @@ -1135,8 +1191,8 @@

        Classes

        self.account.affinity_cookie = cookie.value break - def _extra_headers(self, session): - headers = super()._extra_headers(session=session) + def _extra_headers(self): + headers = super()._extra_headers() # See # https://blogs.msdn.microsoft.com/webdav_101/2015/05/11/best-practices-ews-authentication-and-access-issues/ headers["X-AnchorMailbox"] = self.account.primary_smtp_address @@ -1162,6 +1218,7 @@

        Classes

        Ancestors

        Subclasses

      @@ -1227,6 +1287,10 @@

      Inherited members

      Expand source code
      class EWSPagingService(EWSAccountService):
      +    PAGE_SIZE = 100  # A default page size for all paging services. This is the number of items we request per page
      +
      +    paging_container_name = None  # The name of the element that contains paging information and the paged results
      +
           def __init__(self, *args, **kwargs):
               self.page_size = kwargs.pop("page_size", None) or self.PAGE_SIZE
               if not isinstance(self.page_size, int):
      @@ -1235,6 +1299,15 @@ 

      Inherited members

      raise ValueError(f"'page_size' {self.page_size} must be a positive number") super().__init__(*args, **kwargs) + def _response_generator(self, payload): + """Send the payload to the server, and return the response. + + :param payload: payload as an XML object + :return: the response, as XML objects + """ + response = self._get_response_xml(payload=payload) + return (self._get_page(message) for message in response) + def _paged_call(self, payload_func, max_items, folders, **kwargs): """Call a service that supports paging requests. Return a generator over all response items. Keeps track of all paging-related counters. @@ -1360,7 +1433,7 @@

      Inherited members

      # Paging is done for all messages return None # We cannot guarantee that all messages that have a next_offset also have the *same* next_offset. This is - # because the collections that we are iterating may change while iterating. We'll do our best but we cannot + # because the collections that we are iterating may change while iterating. We'll do our best, but we cannot # guarantee 100% consistency when large collections are simultaneously being changed on the server. # # It's not possible to supply a per-folder offset when iterating multiple folders, so we'll just have to @@ -1374,14 +1447,25 @@

      Ancestors

      Subclasses

      +

      Class variables

      +
      +
      var PAGE_SIZE
      +
      +
      +
      +
      var paging_container_name
      +
      +
      +
      +

      Inherited members

    @@ -1404,15 +1490,13 @@

    Inherited members

    Expand source code -
    class EWSService(metaclass=abc.ABCMeta):
    +
    class EWSService(SupportedVersionClassMixIn, metaclass=abc.ABCMeta):
         """Base class for all EWS services."""
     
    -    PAGE_SIZE = 100  # A default page size for all paging services. This is the number of items we request per page
         CHUNK_SIZE = 100  # A default chunk size for all services. This is the number of items we send in a single request
     
         SERVICE_NAME = None  # The name of the SOAP service
         element_container_name = None  # The name of the XML element wrapping the collection of returned items
    -    paging_container_name = None  # The name of the element that contains paging information and the paged results
         returns_elements = True  # If False, the service does not return response elements, just the ResponseCode status
         # Return exception instance instead of raising exceptions for the following errors when contained in an element
         ERRORS_TO_CATCH_IN_RESPONSE = (
    @@ -1430,16 +1514,14 @@ 

    Inherited members

    ErrorItemCorrupt, ErrorMailRecipientNotFound, ) - # Similarly, define the warnings we want to return unraised + # Similarly, define the warnings we want to return un-raised WARNINGS_TO_CATCH_IN_RESPONSE = ErrorBatchProcessingStopped # Define the warnings we want to ignore, to let response processing proceed WARNINGS_TO_IGNORE_IN_RESPONSE = () # The exception type to raise when all attempted API versions failed NO_VALID_SERVER_VERSIONS = ErrorInvalidServerVersion - # Marks the version from which the service was introduced - supported_from = None - # Marks services that support paging of requested items - supports_paging = False + + NS_MAP = {k: v for k, v in ns_translation.items() if k in ("s", "m", "t")} def __init__(self, protocol, chunk_size=None, timeout=None): self.chunk_size = chunk_size or self.CHUNK_SIZE @@ -1449,8 +1531,8 @@

    Inherited members

    raise ValueError(f"'chunk_size' {self.chunk_size} must be a positive number") if self.supported_from and protocol.version.build < self.supported_from: raise NotImplementedError( - f"{self.SERVICE_NAME!r} is only supported on {self.supported_from.fullname()!r} and later. " - f"Your current version is {protocol.version.build.fullname()!r}." + f"Service {self.SERVICE_NAME!r} only supports server versions from {self.supported_from or '*'} to " + f"{self.deprecated_from or '*'} (server has {protocol.version})" ) self.protocol = protocol # Allow a service to override the default protocol timeout. Useful for streaming services @@ -1518,6 +1600,43 @@

    Inherited members

    _, body = self._get_soap_parts(response=resp) return self._elems_to_objs(self._get_elements_in_response(response=self._get_soap_messages(body=body))) + def wrap(self, content, api_version=None): + """Generate the necessary boilerplate XML for a raw SOAP request. The XML is specific to the server version. + ExchangeImpersonation allows to act as the user we want to impersonate. + + RequestServerVersion element on MSDN: + https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/requestserverversion + + ExchangeImpersonation element on MSDN: + https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exchangeimpersonation + + TimeZoneContent element on MSDN: + https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezonecontext + + :param content: + :param api_version: + """ + envelope = create_element("s:Envelope", nsmap=self.NS_MAP) + header = create_element("s:Header") + if api_version: + request_server_version = create_element("t:RequestServerVersion", attrs=dict(Version=api_version)) + header.append(request_server_version) + identity = self._account_to_impersonate + if identity: + add_xml_child(header, "t:ExchangeImpersonation", identity) + timezone = self._timezone + if timezone: + timezone_context = create_element("t:TimeZoneContext") + timezone_definition = create_element("t:TimeZoneDefinition", attrs=dict(Id=timezone.ms_id)) + timezone_context.append(timezone_definition) + header.append(timezone_context) + if len(header): + envelope.append(header) + body = create_element("s:Body") + body.append(content) + envelope.append(body) + return xml_to_str(envelope, encoding=DEFAULT_ENCODING, xml_declaration=True) + def _elems_to_objs(self, elems): """Takes a generator of XML elements and exceptions. Returns the equivalent Python objects (or exceptions).""" for elem in elems: @@ -1541,12 +1660,18 @@

    Inherited members

    def _version_hint(self, value): self.protocol.config.version = value - def _extra_headers(self, session): - return {} + def _extra_headers(self): + headers = {} + identity = self._account_to_impersonate + if identity and identity.primary_smtp_address: + # See + # https://blogs.msdn.microsoft.com/webdav_101/2015/05/11/best-practices-ews-authentication-and-access-issues/ + headers["X-AnchorMailbox"] = identity.primary_smtp_address + return headers @property def _account_to_impersonate(self): - if isinstance(self.protocol.credentials, OAuth2Credentials): + if self.protocol and isinstance(self.protocol.credentials, BaseOAuth2Credentials): return self.protocol.credentials.identity return None @@ -1561,8 +1686,6 @@

    Inherited members

    :return: the response, as XML objects """ response = self._get_response_xml(payload=payload) - if self.supports_paging: - return (self._get_page(message) for message in response) return self._get_elements_in_response(response=response) def _chunked_get_elements(self, payload_func, items, **kwargs): @@ -1575,7 +1698,7 @@

    Inherited members

    :return: Same as ._get_elements() """ # If the input for a service is a QuerySet, it can be difficult to remove exceptions before now - filtered_items = filter(lambda i: not isinstance(i, Exception), items) + filtered_items = filter(lambda item: not isinstance(item, Exception), items) for i, chunk in enumerate(chunkify(filtered_items, self.chunk_size), start=1): log.debug("Processing chunk %s containing %s items", i, len(chunk)) yield from self._get_elements(payload=payload_func(chunk, **kwargs)) @@ -1603,7 +1726,7 @@

    Inherited members

    except ErrorServerBusy as e: self._handle_backoff(e) continue - except (ErrorTooManyObjectsOpened, ErrorTimeoutExpired) as e: + except (ErrorTooManyObjectsOpened, ErrorTimeoutExpired, ErrorInternalServerTransientError) as e: # ErrorTooManyObjectsOpened means there are too many connections to the Exchange database. This is very # often a symptom of sending too many requests. # @@ -1634,12 +1757,10 @@

    Inherited members

    protocol=self.protocol, session=session, url=self.protocol.service_endpoint, - headers=self._extra_headers(session), - data=wrap( + headers=self._extra_headers(), + data=self.wrap( content=payload, api_version=api_version, - account_to_impersonate=self._account_to_impersonate, - timezone=self._timezone, ), stream=self.streaming, timeout=self.timeout or self.protocol.TIMEOUT, @@ -1653,10 +1774,16 @@

    Inherited members

    self.protocol.release_session(session) return r - @property + @classmethod + def supported_api_versions(cls): + """Return API versions supported by the service, sorted from newest to oldest""" + return sorted({v.api_version for v in Version.all_versions() if cls.supports_version(v)}, reverse=True) + def _api_versions_to_try(self): # Put the hint first in the list, and then all other versions except the hint, from newest to oldest - return (self._version_hint.api_version,) + tuple(v for v in API_VERSIONS if v != self._version_hint.api_version) + return (self._version_hint.api_version,) + tuple( + v for v in self.supported_api_versions() if v != self._version_hint.api_version + ) def _get_response_xml(self, payload, **parse_opts): """Send the payload to the server and return relevant elements from the result. Several things happen here: @@ -1673,7 +1800,7 @@

    Inherited members

    # guessing tango, but then the server may decide that any arbitrary legacy backend server may actually process # the request for an account. Prepare to handle version-related errors and set the server version per-account. log.debug("Calling service %s", self.SERVICE_NAME) - for api_version in self._api_versions_to_try: + for api_version in self._api_versions_to_try(): log.debug("Trying API version %s", api_version) r = self._get_response(payload=payload, api_version=api_version) if self.streaming: @@ -1712,7 +1839,7 @@

    Inherited members

    # In streaming mode, we may not have accessed the raw stream yet. Caller must handle this. r.close() # Release memory - raise self.NO_VALID_SERVER_VERSIONS(f"Tried versions {self._api_versions_to_try} but all were invalid") + raise self.NO_VALID_SERVER_VERSIONS(f"Tried versions {self._api_versions_to_try()} but all were invalid") def _handle_backoff(self, e): """Take a request from the server to back off and checks the retry policy for what to do. Re-raise the @@ -1743,7 +1870,7 @@

    Inherited members

    # Nothing to do return log.debug("Found new version (%s -> %s)", self._version_hint, head_version) - # The api_version that worked was different than our hint, or we never got a build version. Store the working + # The api_version that worked was different from our hint, or we never got a build version. Store the working # version. self._version_hint = head_version @@ -1764,7 +1891,7 @@

    Inherited members

    @classmethod def _get_soap_parts(cls, response, **parse_opts): - """Split the SOAP response into its headers an body elements.""" + """Split the SOAP response into its headers and body elements.""" try: root = to_xml(response.iter_content()) except ParseError as e: @@ -1883,7 +2010,7 @@

    Inherited members

    if container is None: raise MalformedResponseError(f"No {name} elements in ResponseMessage ({xml_to_str(message)})") return container - # rspclass == 'Error', or 'Success' and not 'NoError' + # response_class == 'Error', or 'Success' and not 'NoError' try: raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml) except self.ERRORS_TO_CATCH_IN_RESPONSE as e: @@ -1973,7 +2100,7 @@

    Inherited members

    @classmethod def _get_elements_in_container(cls, container): """Return a list of response elements from an XML response element container. With e.g. - 'CreateItem', 'Items' is the container element and we return the 'Message' child elements: + 'CreateItem', 'Items' is the container element, and we return the 'Message' child elements: <m:Items> <t:Message> @@ -1988,6 +2115,10 @@

    Inherited members

    return list(container) return [True]
    +

    Ancestors

    +

    Subclasses

    @@ -2016,7 +2148,7 @@

    Class variables

    Global error type within this module.

    -
    var PAGE_SIZE
    +
    var NS_MAP
    @@ -2036,21 +2168,27 @@

    Class variables

    -
    var paging_container_name
    -
    -
    -
    var returns_elements
    -
    var supported_from
    -
    -
    -
    -
    var supports_paging
    + +

    Static methods

    +
    +
    +def supported_api_versions() +
    -
    +

    Return API versions supported by the service, sorted from newest to oldest

    +
    + +Expand source code + +
    @classmethod
    +def supported_api_versions(cls):
    +    """Return API versions supported by the service, sorted from newest to oldest"""
    +    return sorted({v.api_version for v in Version.all_versions() if cls.supports_version(v)}, reverse=True)
    +

    Methods

    @@ -2129,6 +2267,62 @@

    Methods

    self._streaming_session = None
    +
    +def wrap(self, content, api_version=None) +
    +
    +

    Generate the necessary boilerplate XML for a raw SOAP request. The XML is specific to the server version. +ExchangeImpersonation allows to act as the user we want to impersonate.

    +

    RequestServerVersion element on MSDN: +https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/requestserverversion

    +

    ExchangeImpersonation element on MSDN: +https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exchangeimpersonation

    +

    TimeZoneContent element on MSDN: +https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezonecontext

    +

    :param content: +:param api_version:

    +
    + +Expand source code + +
    def wrap(self, content, api_version=None):
    +    """Generate the necessary boilerplate XML for a raw SOAP request. The XML is specific to the server version.
    +    ExchangeImpersonation allows to act as the user we want to impersonate.
    +
    +    RequestServerVersion element on MSDN:
    +    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/requestserverversion
    +
    +    ExchangeImpersonation element on MSDN:
    +    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exchangeimpersonation
    +
    +    TimeZoneContent element on MSDN:
    +    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezonecontext
    +
    +    :param content:
    +    :param api_version:
    +    """
    +    envelope = create_element("s:Envelope", nsmap=self.NS_MAP)
    +    header = create_element("s:Header")
    +    if api_version:
    +        request_server_version = create_element("t:RequestServerVersion", attrs=dict(Version=api_version))
    +        header.append(request_server_version)
    +    identity = self._account_to_impersonate
    +    if identity:
    +        add_xml_child(header, "t:ExchangeImpersonation", identity)
    +    timezone = self._timezone
    +    if timezone:
    +        timezone_context = create_element("t:TimeZoneContext")
    +        timezone_definition = create_element("t:TimeZoneDefinition", attrs=dict(Id=timezone.ms_id))
    +        timezone_context.append(timezone_definition)
    +        header.append(timezone_context)
    +    if len(header):
    +        envelope.append(header)
    +    body = create_element("s:Body")
    +    body.append(content)
    +    envelope.append(body)
    +    return xml_to_str(envelope, encoding=DEFAULT_ENCODING, xml_declaration=True)
    +
    +
    @@ -2165,6 +2359,10 @@

    EWSPagingService

    +
  • EWSService

    @@ -2172,18 +2370,17 @@

    CHUNK_SIZE

  • ERRORS_TO_CATCH_IN_RESPONSE
  • NO_VALID_SERVER_VERSIONS
  • -
  • PAGE_SIZE
  • +
  • NS_MAP
  • SERVICE_NAME
  • WARNINGS_TO_CATCH_IN_RESPONSE
  • WARNINGS_TO_IGNORE_IN_RESPONSE
  • element_container_name
  • get
  • -
  • paging_container_name
  • parse
  • returns_elements
  • stop_streaming
  • -
  • supported_from
  • -
  • supports_paging
  • +
  • supported_api_versions
  • +
  • wrap
  • diff --git a/docs/exchangelib/services/convert_id.html b/docs/exchangelib/services/convert_id.html index 12f2f4bd..44d28b1f 100644 --- a/docs/exchangelib/services/convert_id.html +++ b/docs/exchangelib/services/convert_id.html @@ -29,7 +29,6 @@

    Module exchangelib.services.convert_id

    from ..errors import InvalidEnumValue, InvalidTypeError
     from ..properties import ID_FORMATS, AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId
     from ..util import create_element, set_xml_value
    -from ..version import EXCHANGE_2007_SP1
     from .common import EWSService
     
     
    @@ -41,7 +40,6 @@ 

    Module exchangelib.services.convert_id

    """ SERVICE_NAME = "ConvertId" - supported_from = EXCHANGE_2007_SP1 cls_map = {cls.response_tag(): cls for cls in (AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId)} def call(self, items, destination_format): @@ -61,7 +59,7 @@

    Module exchangelib.services.convert_id

    for item in items: if not isinstance(item, supported_item_classes): raise InvalidTypeError("item", item, supported_item_classes) - set_xml_value(item_ids, item, version=self.protocol.version) + set_xml_value(item_ids, item, version=None) payload.append(item_ids) return payload @@ -104,7 +102,6 @@

    Classes

    """ SERVICE_NAME = "ConvertId" - supported_from = EXCHANGE_2007_SP1 cls_map = {cls.response_tag(): cls for cls in (AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId)} def call(self, items, destination_format): @@ -124,7 +121,7 @@

    Classes

    for item in items: if not isinstance(item, supported_item_classes): raise InvalidTypeError("item", item, supported_item_classes) - set_xml_value(item_ids, item, version=self.protocol.version) + set_xml_value(item_ids, item, version=None) payload.append(item_ids) return payload @@ -140,6 +137,7 @@

    Classes

    Ancestors

    Class variables

    @@ -151,10 +149,6 @@

    Class variables

    -
    var supported_from
    -
    -
    -

    Methods

    @@ -191,7 +185,7 @@

    Methods

    for item in items: if not isinstance(item, supported_item_classes): raise InvalidTypeError("item", item, supported_item_classes) - set_xml_value(item_ids, item, version=self.protocol.version) + set_xml_value(item_ids, item, version=None) payload.append(item_ids) return payload
    @@ -205,6 +199,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -232,7 +228,6 @@

    call
  • cls_map
  • get_payload
  • -
  • supported_from
  • @@ -244,4 +239,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/copy_item.html b/docs/exchangelib/services/copy_item.html index 8dcad32e..d208b9d9 100644 --- a/docs/exchangelib/services/copy_item.html +++ b/docs/exchangelib/services/copy_item.html @@ -64,6 +64,7 @@

    Ancestors

  • MoveItem
  • EWSAccountService
  • EWSService
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -80,6 +81,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -115,4 +118,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/create_attachment.html b/docs/exchangelib/services/create_attachment.html index 62cb3ce4..45520b98 100644 --- a/docs/exchangelib/services/create_attachment.html +++ b/docs/exchangelib/services/create_attachment.html @@ -114,6 +114,7 @@

    Ancestors

    Class variables

    @@ -177,6 +178,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -216,4 +219,4 @@

    Generated by pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/create_folder.html b/docs/exchangelib/services/create_folder.html index 23eb3d65..4baaac7a 100644 --- a/docs/exchangelib/services/create_folder.html +++ b/docs/exchangelib/services/create_folder.html @@ -131,6 +131,7 @@

    Ancestors

    Class variables

    @@ -198,6 +199,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -237,4 +240,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/create_item.html b/docs/exchangelib/services/create_item.html index 09529f6e..0447c5ca 100644 --- a/docs/exchangelib/services/create_item.html +++ b/docs/exchangelib/services/create_item.html @@ -90,7 +90,7 @@

    Module exchangelib.services.create_item

    return res or [True] def get_payload(self, items, folder, message_disposition, send_meeting_invitations): - """Take a list of Item objects (CalendarItem, Message etc) and return the XML for a CreateItem request. + """Take a list of Item objects (CalendarItem, Message etc.) and return the XML for a CreateItem request. convert items to XML Elements. MessageDisposition is only applicable to email messages, where it is required. @@ -195,7 +195,7 @@

    Classes

    return res or [True] def get_payload(self, items, folder, message_disposition, send_meeting_invitations): - """Take a list of Item objects (CalendarItem, Message etc) and return the XML for a CreateItem request. + """Take a list of Item objects (CalendarItem, Message etc.) and return the XML for a CreateItem request. convert items to XML Elements. MessageDisposition is only applicable to email messages, where it is required. @@ -233,6 +233,7 @@

    Ancestors

    Class variables

    @@ -289,7 +290,7 @@

    Methods

    def get_payload(self, items, folder, message_disposition, send_meeting_invitations)
    -

    Take a list of Item objects (CalendarItem, Message etc) and return the XML for a CreateItem request. +

    Take a list of Item objects (CalendarItem, Message etc.) and return the XML for a CreateItem request. convert items to XML Elements.

    MessageDisposition is only applicable to email messages, where it is required.

    SendMeetingInvitations is required for calendar items. It is also applicable to tasks, meeting request @@ -308,7 +309,7 @@

    Methods

    Expand source code
    def get_payload(self, items, folder, message_disposition, send_meeting_invitations):
    -    """Take a list of Item objects (CalendarItem, Message etc) and return the XML for a CreateItem request.
    +    """Take a list of Item objects (CalendarItem, Message etc.) and return the XML for a CreateItem request.
         convert items to XML Elements.
     
         MessageDisposition is only applicable to email messages, where it is required.
    @@ -352,6 +353,8 @@ 

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -390,4 +393,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/create_user_configuration.html b/docs/exchangelib/services/create_user_configuration.html index 5db8b917..6b1af3dd 100644 --- a/docs/exchangelib/services/create_user_configuration.html +++ b/docs/exchangelib/services/create_user_configuration.html @@ -87,6 +87,7 @@

    Ancestors

    Class variables

    @@ -138,6 +139,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -176,4 +179,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/delete_attachment.html b/docs/exchangelib/services/delete_attachment.html index 9585face..adcbfe7b 100644 --- a/docs/exchangelib/services/delete_attachment.html +++ b/docs/exchangelib/services/delete_attachment.html @@ -104,6 +104,7 @@

    Ancestors

    Class variables

    @@ -153,6 +154,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -190,4 +193,4 @@

    Generated by pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/delete_folder.html b/docs/exchangelib/services/delete_folder.html index 1df70f09..a84a76e8 100644 --- a/docs/exchangelib/services/delete_folder.html +++ b/docs/exchangelib/services/delete_folder.html @@ -88,6 +88,7 @@

    Ancestors

    Class variables

    @@ -141,6 +142,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -179,4 +182,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/delete_item.html b/docs/exchangelib/services/delete_item.html index 6cc6ebb3..a9521231 100644 --- a/docs/exchangelib/services/delete_item.html +++ b/docs/exchangelib/services/delete_item.html @@ -149,6 +149,7 @@

    Ancestors

    Class variables

    @@ -227,6 +228,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -265,4 +268,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/delete_user_configuration.html b/docs/exchangelib/services/delete_user_configuration.html index 176ffd43..c876d8b6 100644 --- a/docs/exchangelib/services/delete_user_configuration.html +++ b/docs/exchangelib/services/delete_user_configuration.html @@ -87,6 +87,7 @@

    Ancestors

    Class variables

    @@ -138,6 +139,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -176,4 +179,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/empty_folder.html b/docs/exchangelib/services/empty_folder.html index caf06dc0..b8366f4e 100644 --- a/docs/exchangelib/services/empty_folder.html +++ b/docs/exchangelib/services/empty_folder.html @@ -96,6 +96,7 @@

    Ancestors

    Class variables

    @@ -153,6 +154,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -191,4 +194,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/expand_dl.html b/docs/exchangelib/services/expand_dl.html index 9aea3d8b..9dbe5f36 100644 --- a/docs/exchangelib/services/expand_dl.html +++ b/docs/exchangelib/services/expand_dl.html @@ -87,6 +87,7 @@

    Classes

    Ancestors

    Class variables

    @@ -140,6 +141,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -179,4 +182,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/export_items.html b/docs/exchangelib/services/export_items.html index 1c36ae71..481aed55 100644 --- a/docs/exchangelib/services/export_items.html +++ b/docs/exchangelib/services/export_items.html @@ -49,8 +49,7 @@

    Module exchangelib.services.export_items

    payload.append(item_ids_element(items=items, version=self.account.version)) return payload - # We need to override this since ExportItemsResponseMessage is formatted a - # little bit differently. . + # We need to override this since ExportItemsResponseMessage is formatted a bit differently. @classmethod def _get_elements_in_container(cls, container): return [container]

    @@ -93,8 +92,7 @@

    Classes

    payload.append(item_ids_element(items=items, version=self.account.version)) return payload - # We need to override this since ExportItemsResponseMessage is formatted a - # little bit differently. . + # We need to override this since ExportItemsResponseMessage is formatted a bit differently. @classmethod def _get_elements_in_container(cls, container): return [container]
    @@ -103,6 +101,7 @@

    Ancestors

    Class variables

    @@ -158,6 +157,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -197,4 +198,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/find_folder.html b/docs/exchangelib/services/find_folder.html index 0f728f8e..df07a5b7 100644 --- a/docs/exchangelib/services/find_folder.html +++ b/docs/exchangelib/services/find_folder.html @@ -41,14 +41,13 @@

    Module exchangelib.services.find_folder

    SERVICE_NAME = "FindFolder" element_container_name = f"{{{TNS}}}Folders" paging_container_name = f"{{{MNS}}}RootFolder" - supports_paging = True def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.root = None # A hack to communicate parsing args to _elems_to_objs() def call(self, folders, additional_fields, restriction, shape, depth, max_items, offset): - """Find subfolders of a folder. + """Find sub-folders of a folder. :param folders: the folders to act on :param additional_fields: the extra fields that should be returned with the folder, as FieldPath objects @@ -134,14 +133,13 @@

    Classes

    SERVICE_NAME = "FindFolder" element_container_name = f"{{{TNS}}}Folders" paging_container_name = f"{{{MNS}}}RootFolder" - supports_paging = True def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.root = None # A hack to communicate parsing args to _elems_to_objs() def call(self, folders, additional_fields, restriction, shape, depth, max_items, offset): - """Find subfolders of a folder. + """Find sub-folders of a folder. :param folders: the folders to act on :param additional_fields: the extra fields that should be returned with the folder, as FieldPath objects @@ -206,6 +204,7 @@

    Ancestors

  • EWSPagingService
  • EWSAccountService
  • EWSService
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -221,10 +220,6 @@

    Class variables

    -
    var supports_paging
    -
    -
    -

    Methods

    @@ -232,7 +227,7 @@

    Methods

    def call(self, folders, additional_fields, restriction, shape, depth, max_items, offset)
    -

    Find subfolders of a folder.

    +

    Find sub-folders of a folder.

    :param folders: the folders to act on :param additional_fields: the extra fields that should be returned with the folder, as FieldPath objects :param restriction: Restriction object that defines the filters for the query @@ -246,7 +241,7 @@

    Methods

    Expand source code
    def call(self, folders, additional_fields, restriction, shape, depth, max_items, offset):
    -    """Find subfolders of a folder.
    +    """Find sub-folders of a folder.
     
         :param folders: the folders to act on
         :param additional_fields: the extra fields that should be returned with the folder, as FieldPath objects
    @@ -323,6 +318,8 @@ 

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -351,7 +348,6 @@

    element_container_name
  • get_payload
  • paging_container_name
  • -
  • supports_paging
  • @@ -363,4 +359,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/find_item.html b/docs/exchangelib/services/find_item.html index ea5dd93a..73a943f5 100644 --- a/docs/exchangelib/services/find_item.html +++ b/docs/exchangelib/services/find_item.html @@ -39,7 +39,6 @@

    Module exchangelib.services.find_item

    SERVICE_NAME = "FindItem" element_container_name = f"{{{TNS}}}Items" paging_container_name = f"{{{MNS}}}RootFolder" - supports_paging = True def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -166,7 +165,6 @@

    Classes

    SERVICE_NAME = "FindItem" element_container_name = f"{{{TNS}}}Items" paging_container_name = f"{{{MNS}}}RootFolder" - supports_paging = True def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -272,6 +270,7 @@

    Ancestors

  • EWSPagingService
  • EWSAccountService
  • EWSService
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -287,10 +286,6 @@

    Class variables

    -
    var supports_paging
    -
    -
    -

    Methods

    @@ -422,6 +417,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -450,7 +447,6 @@

    element_container_name
  • get_payload
  • paging_container_name
  • -
  • supports_paging
  • @@ -462,4 +458,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/find_people.html b/docs/exchangelib/services/find_people.html index acd0c50c..eee4cd7b 100644 --- a/docs/exchangelib/services/find_people.html +++ b/docs/exchangelib/services/find_people.html @@ -43,7 +43,6 @@

    Module exchangelib.services.find_people

    SERVICE_NAME = "FindPeople" element_container_name = f"{{{MNS}}}People" supported_from = EXCHANGE_2013 - supports_paging = True def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -160,7 +159,6 @@

    Classes

    SERVICE_NAME = "FindPeople" element_container_name = f"{{{MNS}}}People" supported_from = EXCHANGE_2013 - supports_paging = True def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -256,6 +254,7 @@

    Ancestors

  • EWSPagingService
  • EWSAccountService
  • EWSService
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -271,10 +270,6 @@

    Class variables

    -
    var supports_paging
    -
    -
    -

    Methods

    @@ -380,6 +375,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -408,7 +405,6 @@

    element_container_name
  • get_payload
  • supported_from
  • -
  • supports_paging
  • @@ -420,4 +416,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/get_attachment.html b/docs/exchangelib/services/get_attachment.html index 39c2401c..5f7c379c 100644 --- a/docs/exchangelib/services/get_attachment.html +++ b/docs/exchangelib/services/get_attachment.html @@ -270,6 +270,7 @@

    Ancestors

    Class variables

    @@ -392,6 +393,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -432,4 +435,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/get_delegate.html b/docs/exchangelib/services/get_delegate.html index 93085f09..a0ff73e4 100644 --- a/docs/exchangelib/services/get_delegate.html +++ b/docs/exchangelib/services/get_delegate.html @@ -136,6 +136,7 @@

    Ancestors

    Class variables

    @@ -205,6 +206,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -244,4 +247,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/get_events.html b/docs/exchangelib/services/get_events.html index d60e781d..ae8cdd86 100644 --- a/docs/exchangelib/services/get_events.html +++ b/docs/exchangelib/services/get_events.html @@ -122,6 +122,7 @@

    Ancestors

    Class variables

    @@ -181,6 +182,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -219,4 +222,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/get_folder.html b/docs/exchangelib/services/get_folder.html index 2f47a12f..ba473bda 100644 --- a/docs/exchangelib/services/get_folder.html +++ b/docs/exchangelib/services/get_folder.html @@ -161,6 +161,7 @@

    Ancestors

    Class variables

    @@ -243,6 +244,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -282,4 +285,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/get_item.html b/docs/exchangelib/services/get_item.html index 92d00151..a83afb06 100644 --- a/docs/exchangelib/services/get_item.html +++ b/docs/exchangelib/services/get_item.html @@ -129,6 +129,7 @@

    Ancestors

    Class variables

    @@ -204,6 +205,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -242,4 +245,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/get_mail_tips.html b/docs/exchangelib/services/get_mail_tips.html index 4254efe0..89a881a0 100644 --- a/docs/exchangelib/services/get_mail_tips.html +++ b/docs/exchangelib/services/get_mail_tips.html @@ -132,6 +132,7 @@

    Classes

    Ancestors

    Class variables

    @@ -194,6 +195,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -231,4 +234,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/get_persona.html b/docs/exchangelib/services/get_persona.html index 303897e6..f13fa848 100644 --- a/docs/exchangelib/services/get_persona.html +++ b/docs/exchangelib/services/get_persona.html @@ -108,6 +108,7 @@

    Ancestors

    Class variables

    @@ -157,6 +158,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -194,4 +197,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/get_room_lists.html b/docs/exchangelib/services/get_room_lists.html index eb084e32..81959d35 100644 --- a/docs/exchangelib/services/get_room_lists.html +++ b/docs/exchangelib/services/get_room_lists.html @@ -87,6 +87,7 @@

    Classes

    Ancestors

    Class variables

    @@ -140,6 +141,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -179,4 +182,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/get_rooms.html b/docs/exchangelib/services/get_rooms.html index ce91ce40..fb200f5a 100644 --- a/docs/exchangelib/services/get_rooms.html +++ b/docs/exchangelib/services/get_rooms.html @@ -87,6 +87,7 @@

    Classes

    Ancestors

    Class variables

    @@ -140,6 +141,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -179,4 +182,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/get_searchable_mailboxes.html b/docs/exchangelib/services/get_searchable_mailboxes.html index f9ba8faa..5b6ebb1f 100644 --- a/docs/exchangelib/services/get_searchable_mailboxes.html +++ b/docs/exchangelib/services/get_searchable_mailboxes.html @@ -141,6 +141,7 @@

    Classes

    Ancestors

    Class variables

    @@ -214,6 +215,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -255,4 +258,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/get_server_time_zones.html b/docs/exchangelib/services/get_server_time_zones.html index 0c81a838..e2fe9213 100644 --- a/docs/exchangelib/services/get_server_time_zones.html +++ b/docs/exchangelib/services/get_server_time_zones.html @@ -124,6 +124,7 @@

    Classes

    Ancestors

    Class variables

    @@ -193,6 +194,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -232,4 +235,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/get_streaming_events.html b/docs/exchangelib/services/get_streaming_events.html index 7306c649..e5cda087 100644 --- a/docs/exchangelib/services/get_streaming_events.html +++ b/docs/exchangelib/services/get_streaming_events.html @@ -252,6 +252,7 @@

    Ancestors

    Class variables

    @@ -335,6 +336,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -376,4 +379,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/get_user_availability.html b/docs/exchangelib/services/get_user_availability.html index 009a793b..9e966f85 100644 --- a/docs/exchangelib/services/get_user_availability.html +++ b/docs/exchangelib/services/get_user_availability.html @@ -158,6 +158,7 @@

    Classes

    Ancestors

    Class variables

    @@ -217,6 +218,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -254,4 +257,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/get_user_configuration.html b/docs/exchangelib/services/get_user_configuration.html index 746b6d1e..e7bf73de 100644 --- a/docs/exchangelib/services/get_user_configuration.html +++ b/docs/exchangelib/services/get_user_configuration.html @@ -126,6 +126,7 @@

    Ancestors

    Class variables

    @@ -182,6 +183,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -219,4 +222,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/get_user_oof_settings.html b/docs/exchangelib/services/get_user_oof_settings.html index 84aa1ad4..194df720 100644 --- a/docs/exchangelib/services/get_user_oof_settings.html +++ b/docs/exchangelib/services/get_user_oof_settings.html @@ -127,6 +127,7 @@

    Ancestors

    Class variables

    @@ -180,6 +181,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -218,4 +221,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/get_user_settings.html b/docs/exchangelib/services/get_user_settings.html new file mode 100644 index 00000000..c121d565 --- /dev/null +++ b/docs/exchangelib/services/get_user_settings.html @@ -0,0 +1,353 @@ + + + + + + +exchangelib.services.get_user_settings API documentation + + + + + + + + + + + +
    +
    +
    +

    Module exchangelib.services.get_user_settings

    +
    +
    +
    + +Expand source code + +
    import logging
    +
    +from ..errors import ErrorInvalidServerVersion, MalformedResponseError
    +from ..properties import UserResponse
    +from ..transport import DEFAULT_ENCODING
    +from ..util import ANS, add_xml_child, create_element, get_xml_attr, ns_translation, set_xml_value, xml_to_str
    +from ..version import EXCHANGE_2010
    +from .common import EWSService
    +
    +log = logging.getLogger(__name__)
    +
    +
    +class GetUserSettings(EWSService):
    +    """Take a list of users and requested Autodiscover settings for these users. Returns the requested settings values.
    +
    +    MSDN:
    +    https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/getusersettings-operation-soap
    +    """
    +
    +    SERVICE_NAME = "GetUserSettings"
    +    NS_MAP = {k: v for k, v in ns_translation.items() if k in ("s", "t", "a", "wsa", "xsi")}
    +    element_container_name = f"{{{ANS}}}UserResponses"
    +    supported_from = EXCHANGE_2010
    +
    +    def call(self, users, settings):
    +        return self._elems_to_objs(self._get_elements(self.get_payload(users=users, settings=settings)))
    +
    +    def wrap(self, content, api_version=None):
    +        envelope = create_element("s:Envelope", nsmap=self.NS_MAP)
    +        header = create_element("s:Header")
    +        if api_version:
    +            add_xml_child(header, "a:RequestedServerVersion", api_version)
    +        action = f"http://schemas.microsoft.com/exchange/2010/Autodiscover/Autodiscover/{self.SERVICE_NAME}"
    +        add_xml_child(header, "wsa:Action", action)
    +        add_xml_child(header, "wsa:To", self.protocol.service_endpoint)
    +        identity = self._account_to_impersonate
    +        if identity:
    +            add_xml_child(header, "t:ExchangeImpersonation", identity)
    +        envelope.append(header)
    +        body = create_element("s:Body")
    +        body.append(content)
    +        envelope.append(body)
    +        return xml_to_str(envelope, encoding=DEFAULT_ENCODING, xml_declaration=True)
    +
    +    def _elem_to_obj(self, elem):
    +        return UserResponse.from_xml(elem=elem, account=None)
    +
    +    def get_payload(self, users, settings):
    +        payload = create_element(f"a:{self.SERVICE_NAME}RequestMessage")
    +        request = create_element("a:Request")
    +        users_elem = create_element("a:Users")
    +        for user in users:
    +            mailbox = create_element("a:Mailbox")
    +            set_xml_value(mailbox, user)
    +            add_xml_child(users_elem, "a:User", mailbox)
    +        if not len(users_elem):
    +            raise ValueError("'users' must not be empty")
    +        request.append(users_elem)
    +        requested_settings = create_element("a:RequestedSettings")
    +        for setting in settings:
    +            add_xml_child(requested_settings, "a:Setting", UserResponse.SETTINGS_MAP[setting])
    +        if not len(requested_settings):
    +            raise ValueError("'requested_settings' must not be empty")
    +        request.append(requested_settings)
    +        payload.append(request)
    +        return payload
    +
    +    @classmethod
    +    def _response_tag(cls):
    +        """Return the name of the element containing the service response."""
    +        return f"{{{ANS}}}{cls.SERVICE_NAME}ResponseMessage"
    +
    +    def _get_element_container(self, message, name=None):
    +        response = message.find(f"{{{ANS}}}Response")
    +        # ErrorCode: See
    +        # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/errorcode-soap
    +        error_code = get_xml_attr(response, f"{{{ANS}}}ErrorCode")
    +        if error_code == "NoError":
    +            container = response.find(name)
    +            if container is None:
    +                raise MalformedResponseError(f"No {name} elements in ResponseMessage ({xml_to_str(response)})")
    +            return container
    +        # Raise any non-acceptable errors in the container, or return the container or the acceptable exception instance
    +        msg_text = get_xml_attr(response, f"{{{ANS}}}ErrorMessage")
    +        try:
    +            raise self._get_exception(code=error_code, text=msg_text, msg_xml=None)
    +        except self.ERRORS_TO_CATCH_IN_RESPONSE as e:
    +            return e
    +
    +    @classmethod
    +    def _raise_soap_errors(cls, fault):
    +        fault_code = get_xml_attr(fault, "faultcode")
    +        fault_string = get_xml_attr(fault, "faultstring")
    +        if fault_code == "a:ActionNotSupported" and "ContractFilter mismatch" in fault_string:
    +            raise ErrorInvalidServerVersion(f"SOAP error code: {fault_code} string: {fault_string}")
    +        super()._raise_soap_errors(fault=fault)
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class GetUserSettings +(protocol, chunk_size=None, timeout=None) +
    +
    +

    Take a list of users and requested Autodiscover settings for these users. Returns the requested settings values.

    +

    MSDN: +https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/getusersettings-operation-soap

    +
    + +Expand source code + +
    class GetUserSettings(EWSService):
    +    """Take a list of users and requested Autodiscover settings for these users. Returns the requested settings values.
    +
    +    MSDN:
    +    https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/getusersettings-operation-soap
    +    """
    +
    +    SERVICE_NAME = "GetUserSettings"
    +    NS_MAP = {k: v for k, v in ns_translation.items() if k in ("s", "t", "a", "wsa", "xsi")}
    +    element_container_name = f"{{{ANS}}}UserResponses"
    +    supported_from = EXCHANGE_2010
    +
    +    def call(self, users, settings):
    +        return self._elems_to_objs(self._get_elements(self.get_payload(users=users, settings=settings)))
    +
    +    def wrap(self, content, api_version=None):
    +        envelope = create_element("s:Envelope", nsmap=self.NS_MAP)
    +        header = create_element("s:Header")
    +        if api_version:
    +            add_xml_child(header, "a:RequestedServerVersion", api_version)
    +        action = f"http://schemas.microsoft.com/exchange/2010/Autodiscover/Autodiscover/{self.SERVICE_NAME}"
    +        add_xml_child(header, "wsa:Action", action)
    +        add_xml_child(header, "wsa:To", self.protocol.service_endpoint)
    +        identity = self._account_to_impersonate
    +        if identity:
    +            add_xml_child(header, "t:ExchangeImpersonation", identity)
    +        envelope.append(header)
    +        body = create_element("s:Body")
    +        body.append(content)
    +        envelope.append(body)
    +        return xml_to_str(envelope, encoding=DEFAULT_ENCODING, xml_declaration=True)
    +
    +    def _elem_to_obj(self, elem):
    +        return UserResponse.from_xml(elem=elem, account=None)
    +
    +    def get_payload(self, users, settings):
    +        payload = create_element(f"a:{self.SERVICE_NAME}RequestMessage")
    +        request = create_element("a:Request")
    +        users_elem = create_element("a:Users")
    +        for user in users:
    +            mailbox = create_element("a:Mailbox")
    +            set_xml_value(mailbox, user)
    +            add_xml_child(users_elem, "a:User", mailbox)
    +        if not len(users_elem):
    +            raise ValueError("'users' must not be empty")
    +        request.append(users_elem)
    +        requested_settings = create_element("a:RequestedSettings")
    +        for setting in settings:
    +            add_xml_child(requested_settings, "a:Setting", UserResponse.SETTINGS_MAP[setting])
    +        if not len(requested_settings):
    +            raise ValueError("'requested_settings' must not be empty")
    +        request.append(requested_settings)
    +        payload.append(request)
    +        return payload
    +
    +    @classmethod
    +    def _response_tag(cls):
    +        """Return the name of the element containing the service response."""
    +        return f"{{{ANS}}}{cls.SERVICE_NAME}ResponseMessage"
    +
    +    def _get_element_container(self, message, name=None):
    +        response = message.find(f"{{{ANS}}}Response")
    +        # ErrorCode: See
    +        # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/errorcode-soap
    +        error_code = get_xml_attr(response, f"{{{ANS}}}ErrorCode")
    +        if error_code == "NoError":
    +            container = response.find(name)
    +            if container is None:
    +                raise MalformedResponseError(f"No {name} elements in ResponseMessage ({xml_to_str(response)})")
    +            return container
    +        # Raise any non-acceptable errors in the container, or return the container or the acceptable exception instance
    +        msg_text = get_xml_attr(response, f"{{{ANS}}}ErrorMessage")
    +        try:
    +            raise self._get_exception(code=error_code, text=msg_text, msg_xml=None)
    +        except self.ERRORS_TO_CATCH_IN_RESPONSE as e:
    +            return e
    +
    +    @classmethod
    +    def _raise_soap_errors(cls, fault):
    +        fault_code = get_xml_attr(fault, "faultcode")
    +        fault_string = get_xml_attr(fault, "faultstring")
    +        if fault_code == "a:ActionNotSupported" and "ContractFilter mismatch" in fault_string:
    +            raise ErrorInvalidServerVersion(f"SOAP error code: {fault_code} string: {fault_string}")
    +        super()._raise_soap_errors(fault=fault)
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var NS_MAP
    +
    +
    +
    +
    var SERVICE_NAME
    +
    +
    +
    +
    var element_container_name
    +
    +
    +
    +
    var supported_from
    +
    +
    +
    +
    +

    Methods

    +
    +
    +def call(self, users, settings) +
    +
    +
    +
    + +Expand source code + +
    def call(self, users, settings):
    +    return self._elems_to_objs(self._get_elements(self.get_payload(users=users, settings=settings)))
    +
    +
    +
    +def get_payload(self, users, settings) +
    +
    +
    +
    + +Expand source code + +
    def get_payload(self, users, settings):
    +    payload = create_element(f"a:{self.SERVICE_NAME}RequestMessage")
    +    request = create_element("a:Request")
    +    users_elem = create_element("a:Users")
    +    for user in users:
    +        mailbox = create_element("a:Mailbox")
    +        set_xml_value(mailbox, user)
    +        add_xml_child(users_elem, "a:User", mailbox)
    +    if not len(users_elem):
    +        raise ValueError("'users' must not be empty")
    +    request.append(users_elem)
    +    requested_settings = create_element("a:RequestedSettings")
    +    for setting in settings:
    +        add_xml_child(requested_settings, "a:Setting", UserResponse.SETTINGS_MAP[setting])
    +    if not len(requested_settings):
    +        raise ValueError("'requested_settings' must not be empty")
    +    request.append(requested_settings)
    +    payload.append(request)
    +    return payload
    +
    +
    +
    +

    Inherited members

    + +
    +
    +
    +
    + +
    + + + diff --git a/docs/exchangelib/services/index.html b/docs/exchangelib/services/index.html index 328c18d9..0d8e998b 100644 --- a/docs/exchangelib/services/index.html +++ b/docs/exchangelib/services/index.html @@ -73,6 +73,7 @@

    Module exchangelib.services

    from .get_user_availability import GetUserAvailability from .get_user_configuration import GetUserConfiguration from .get_user_oof_settings import GetUserOofSettings +from .get_user_settings import GetUserSettings from .mark_as_junk import MarkAsJunk from .move_folder import MoveFolder from .move_item import MoveItem @@ -123,6 +124,7 @@

    Module exchangelib.services

    "GetUserAvailability", "GetUserConfiguration", "GetUserOofSettings", + "GetUserSettings", "MarkAsJunk", "MoveFolder", "MoveItem", @@ -278,6 +280,10 @@

    Sub-modules

    +
    exchangelib.services.get_user_settings
    +
    +
    +
    exchangelib.services.mark_as_junk
    @@ -308,8 +314,8 @@

    Sub-modules

    exchangelib.services.subscribe
    -

    The 'Subscribe' service has two different modes, pull and push, with different signatures. Implement as two distinct -classes.

    +

    The 'Subscribe' service has three different modes - pull, push and streaming - with different signatures. Implement +as three distinct classes.

    exchangelib.services.sync_folder_hierarchy
    @@ -390,6 +396,7 @@

    Ancestors

    Class variables

    @@ -458,6 +465,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -482,7 +491,6 @@

    Inherited members

    """ SERVICE_NAME = "ConvertId" - supported_from = EXCHANGE_2007_SP1 cls_map = {cls.response_tag(): cls for cls in (AlternateId, AlternatePublicFolderId, AlternatePublicFolderItemId)} def call(self, items, destination_format): @@ -502,7 +510,7 @@

    Inherited members

    for item in items: if not isinstance(item, supported_item_classes): raise InvalidTypeError("item", item, supported_item_classes) - set_xml_value(item_ids, item, version=self.protocol.version) + set_xml_value(item_ids, item, version=None) payload.append(item_ids) return payload @@ -518,6 +526,7 @@

    Inherited members

    Ancestors

    Class variables

    @@ -529,10 +538,6 @@

    Class variables

    -
    var supported_from
    -
    -
    -

    Methods

    @@ -569,7 +574,7 @@

    Methods

    for item in items: if not isinstance(item, supported_item_classes): raise InvalidTypeError("item", item, supported_item_classes) - set_xml_value(item_ids, item, version=self.protocol.version) + set_xml_value(item_ids, item, version=None) payload.append(item_ids) return payload

    @@ -583,6 +588,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -607,6 +614,7 @@

    Ancestors

  • MoveItem
  • EWSAccountService
  • EWSService
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -623,6 +631,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -670,6 +680,7 @@

    Ancestors

    Class variables

    @@ -733,6 +744,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -789,6 +802,7 @@

    Ancestors

    Class variables

    @@ -856,6 +870,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -921,7 +937,7 @@

    Inherited members

    return res or [True] def get_payload(self, items, folder, message_disposition, send_meeting_invitations): - """Take a list of Item objects (CalendarItem, Message etc) and return the XML for a CreateItem request. + """Take a list of Item objects (CalendarItem, Message etc.) and return the XML for a CreateItem request. convert items to XML Elements. MessageDisposition is only applicable to email messages, where it is required. @@ -959,6 +975,7 @@

    Ancestors

    Class variables

    @@ -1015,7 +1032,7 @@

    Methods

    def get_payload(self, items, folder, message_disposition, send_meeting_invitations)
    -

    Take a list of Item objects (CalendarItem, Message etc) and return the XML for a CreateItem request. +

    Take a list of Item objects (CalendarItem, Message etc.) and return the XML for a CreateItem request. convert items to XML Elements.

    MessageDisposition is only applicable to email messages, where it is required.

    SendMeetingInvitations is required for calendar items. It is also applicable to tasks, meeting request @@ -1034,7 +1051,7 @@

    Methods

    Expand source code
    def get_payload(self, items, folder, message_disposition, send_meeting_invitations):
    -    """Take a list of Item objects (CalendarItem, Message etc) and return the XML for a CreateItem request.
    +    """Take a list of Item objects (CalendarItem, Message etc.) and return the XML for a CreateItem request.
         convert items to XML Elements.
     
         MessageDisposition is only applicable to email messages, where it is required.
    @@ -1078,6 +1095,8 @@ 

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -1113,6 +1132,7 @@

    Ancestors

    Class variables

    @@ -1164,6 +1184,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -1207,6 +1229,7 @@

    Ancestors

    Class variables

    @@ -1256,6 +1279,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -1290,6 +1315,7 @@

    Ancestors

    Class variables

    @@ -1343,6 +1369,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -1408,6 +1436,7 @@

    Ancestors

    Class variables

    @@ -1486,6 +1515,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -1521,6 +1552,7 @@

    Ancestors

    Class variables

    @@ -1572,6 +1604,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -1586,15 +1620,13 @@

    Inherited members

    Expand source code -
    class EWSService(metaclass=abc.ABCMeta):
    +
    class EWSService(SupportedVersionClassMixIn, metaclass=abc.ABCMeta):
         """Base class for all EWS services."""
     
    -    PAGE_SIZE = 100  # A default page size for all paging services. This is the number of items we request per page
         CHUNK_SIZE = 100  # A default chunk size for all services. This is the number of items we send in a single request
     
         SERVICE_NAME = None  # The name of the SOAP service
         element_container_name = None  # The name of the XML element wrapping the collection of returned items
    -    paging_container_name = None  # The name of the element that contains paging information and the paged results
         returns_elements = True  # If False, the service does not return response elements, just the ResponseCode status
         # Return exception instance instead of raising exceptions for the following errors when contained in an element
         ERRORS_TO_CATCH_IN_RESPONSE = (
    @@ -1612,16 +1644,14 @@ 

    Inherited members

    ErrorItemCorrupt, ErrorMailRecipientNotFound, ) - # Similarly, define the warnings we want to return unraised + # Similarly, define the warnings we want to return un-raised WARNINGS_TO_CATCH_IN_RESPONSE = ErrorBatchProcessingStopped # Define the warnings we want to ignore, to let response processing proceed WARNINGS_TO_IGNORE_IN_RESPONSE = () # The exception type to raise when all attempted API versions failed NO_VALID_SERVER_VERSIONS = ErrorInvalidServerVersion - # Marks the version from which the service was introduced - supported_from = None - # Marks services that support paging of requested items - supports_paging = False + + NS_MAP = {k: v for k, v in ns_translation.items() if k in ("s", "m", "t")} def __init__(self, protocol, chunk_size=None, timeout=None): self.chunk_size = chunk_size or self.CHUNK_SIZE @@ -1631,8 +1661,8 @@

    Inherited members

    raise ValueError(f"'chunk_size' {self.chunk_size} must be a positive number") if self.supported_from and protocol.version.build < self.supported_from: raise NotImplementedError( - f"{self.SERVICE_NAME!r} is only supported on {self.supported_from.fullname()!r} and later. " - f"Your current version is {protocol.version.build.fullname()!r}." + f"Service {self.SERVICE_NAME!r} only supports server versions from {self.supported_from or '*'} to " + f"{self.deprecated_from or '*'} (server has {protocol.version})" ) self.protocol = protocol # Allow a service to override the default protocol timeout. Useful for streaming services @@ -1700,6 +1730,43 @@

    Inherited members

    _, body = self._get_soap_parts(response=resp) return self._elems_to_objs(self._get_elements_in_response(response=self._get_soap_messages(body=body))) + def wrap(self, content, api_version=None): + """Generate the necessary boilerplate XML for a raw SOAP request. The XML is specific to the server version. + ExchangeImpersonation allows to act as the user we want to impersonate. + + RequestServerVersion element on MSDN: + https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/requestserverversion + + ExchangeImpersonation element on MSDN: + https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exchangeimpersonation + + TimeZoneContent element on MSDN: + https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezonecontext + + :param content: + :param api_version: + """ + envelope = create_element("s:Envelope", nsmap=self.NS_MAP) + header = create_element("s:Header") + if api_version: + request_server_version = create_element("t:RequestServerVersion", attrs=dict(Version=api_version)) + header.append(request_server_version) + identity = self._account_to_impersonate + if identity: + add_xml_child(header, "t:ExchangeImpersonation", identity) + timezone = self._timezone + if timezone: + timezone_context = create_element("t:TimeZoneContext") + timezone_definition = create_element("t:TimeZoneDefinition", attrs=dict(Id=timezone.ms_id)) + timezone_context.append(timezone_definition) + header.append(timezone_context) + if len(header): + envelope.append(header) + body = create_element("s:Body") + body.append(content) + envelope.append(body) + return xml_to_str(envelope, encoding=DEFAULT_ENCODING, xml_declaration=True) + def _elems_to_objs(self, elems): """Takes a generator of XML elements and exceptions. Returns the equivalent Python objects (or exceptions).""" for elem in elems: @@ -1723,12 +1790,18 @@

    Inherited members

    def _version_hint(self, value): self.protocol.config.version = value - def _extra_headers(self, session): - return {} + def _extra_headers(self): + headers = {} + identity = self._account_to_impersonate + if identity and identity.primary_smtp_address: + # See + # https://blogs.msdn.microsoft.com/webdav_101/2015/05/11/best-practices-ews-authentication-and-access-issues/ + headers["X-AnchorMailbox"] = identity.primary_smtp_address + return headers @property def _account_to_impersonate(self): - if isinstance(self.protocol.credentials, OAuth2Credentials): + if self.protocol and isinstance(self.protocol.credentials, BaseOAuth2Credentials): return self.protocol.credentials.identity return None @@ -1743,8 +1816,6 @@

    Inherited members

    :return: the response, as XML objects """ response = self._get_response_xml(payload=payload) - if self.supports_paging: - return (self._get_page(message) for message in response) return self._get_elements_in_response(response=response) def _chunked_get_elements(self, payload_func, items, **kwargs): @@ -1757,7 +1828,7 @@

    Inherited members

    :return: Same as ._get_elements() """ # If the input for a service is a QuerySet, it can be difficult to remove exceptions before now - filtered_items = filter(lambda i: not isinstance(i, Exception), items) + filtered_items = filter(lambda item: not isinstance(item, Exception), items) for i, chunk in enumerate(chunkify(filtered_items, self.chunk_size), start=1): log.debug("Processing chunk %s containing %s items", i, len(chunk)) yield from self._get_elements(payload=payload_func(chunk, **kwargs)) @@ -1785,7 +1856,7 @@

    Inherited members

    except ErrorServerBusy as e: self._handle_backoff(e) continue - except (ErrorTooManyObjectsOpened, ErrorTimeoutExpired) as e: + except (ErrorTooManyObjectsOpened, ErrorTimeoutExpired, ErrorInternalServerTransientError) as e: # ErrorTooManyObjectsOpened means there are too many connections to the Exchange database. This is very # often a symptom of sending too many requests. # @@ -1816,12 +1887,10 @@

    Inherited members

    protocol=self.protocol, session=session, url=self.protocol.service_endpoint, - headers=self._extra_headers(session), - data=wrap( + headers=self._extra_headers(), + data=self.wrap( content=payload, api_version=api_version, - account_to_impersonate=self._account_to_impersonate, - timezone=self._timezone, ), stream=self.streaming, timeout=self.timeout or self.protocol.TIMEOUT, @@ -1835,10 +1904,16 @@

    Inherited members

    self.protocol.release_session(session) return r - @property + @classmethod + def supported_api_versions(cls): + """Return API versions supported by the service, sorted from newest to oldest""" + return sorted({v.api_version for v in Version.all_versions() if cls.supports_version(v)}, reverse=True) + def _api_versions_to_try(self): # Put the hint first in the list, and then all other versions except the hint, from newest to oldest - return (self._version_hint.api_version,) + tuple(v for v in API_VERSIONS if v != self._version_hint.api_version) + return (self._version_hint.api_version,) + tuple( + v for v in self.supported_api_versions() if v != self._version_hint.api_version + ) def _get_response_xml(self, payload, **parse_opts): """Send the payload to the server and return relevant elements from the result. Several things happen here: @@ -1855,7 +1930,7 @@

    Inherited members

    # guessing tango, but then the server may decide that any arbitrary legacy backend server may actually process # the request for an account. Prepare to handle version-related errors and set the server version per-account. log.debug("Calling service %s", self.SERVICE_NAME) - for api_version in self._api_versions_to_try: + for api_version in self._api_versions_to_try(): log.debug("Trying API version %s", api_version) r = self._get_response(payload=payload, api_version=api_version) if self.streaming: @@ -1894,7 +1969,7 @@

    Inherited members

    # In streaming mode, we may not have accessed the raw stream yet. Caller must handle this. r.close() # Release memory - raise self.NO_VALID_SERVER_VERSIONS(f"Tried versions {self._api_versions_to_try} but all were invalid") + raise self.NO_VALID_SERVER_VERSIONS(f"Tried versions {self._api_versions_to_try()} but all were invalid") def _handle_backoff(self, e): """Take a request from the server to back off and checks the retry policy for what to do. Re-raise the @@ -1925,7 +2000,7 @@

    Inherited members

    # Nothing to do return log.debug("Found new version (%s -> %s)", self._version_hint, head_version) - # The api_version that worked was different than our hint, or we never got a build version. Store the working + # The api_version that worked was different from our hint, or we never got a build version. Store the working # version. self._version_hint = head_version @@ -1946,7 +2021,7 @@

    Inherited members

    @classmethod def _get_soap_parts(cls, response, **parse_opts): - """Split the SOAP response into its headers an body elements.""" + """Split the SOAP response into its headers and body elements.""" try: root = to_xml(response.iter_content()) except ParseError as e: @@ -2065,7 +2140,7 @@

    Inherited members

    if container is None: raise MalformedResponseError(f"No {name} elements in ResponseMessage ({xml_to_str(message)})") return container - # rspclass == 'Error', or 'Success' and not 'NoError' + # response_class == 'Error', or 'Success' and not 'NoError' try: raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml) except self.ERRORS_TO_CATCH_IN_RESPONSE as e: @@ -2155,7 +2230,7 @@

    Inherited members

    @classmethod def _get_elements_in_container(cls, container): """Return a list of response elements from an XML response element container. With e.g. - 'CreateItem', 'Items' is the container element and we return the 'Message' child elements: + 'CreateItem', 'Items' is the container element, and we return the 'Message' child elements: <m:Items> <t:Message> @@ -2170,6 +2245,10 @@

    Inherited members

    return list(container) return [True]
    +

    Ancestors

    +

    Subclasses

    @@ -2198,7 +2278,7 @@

    Class variables

    Global error type within this module.

    -
    var PAGE_SIZE
    +
    var NS_MAP
    @@ -2218,21 +2298,27 @@

    Class variables

    -
    var paging_container_name
    -
    -
    -
    var returns_elements
    -
    var supported_from
    -
    -
    -
    -
    var supports_paging
    +
    +

    Static methods

    +
    +
    +def supported_api_versions() +
    -
    +

    Return API versions supported by the service, sorted from newest to oldest

    +
    + +Expand source code + +
    @classmethod
    +def supported_api_versions(cls):
    +    """Return API versions supported by the service, sorted from newest to oldest"""
    +    return sorted({v.api_version for v in Version.all_versions() if cls.supports_version(v)}, reverse=True)
    +

    Methods

    @@ -2311,6 +2397,62 @@

    Methods

    self._streaming_session = None
    +
    +def wrap(self, content, api_version=None) +
    +
    +

    Generate the necessary boilerplate XML for a raw SOAP request. The XML is specific to the server version. +ExchangeImpersonation allows to act as the user we want to impersonate.

    +

    RequestServerVersion element on MSDN: +https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/requestserverversion

    +

    ExchangeImpersonation element on MSDN: +https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exchangeimpersonation

    +

    TimeZoneContent element on MSDN: +https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezonecontext

    +

    :param content: +:param api_version:

    +
    + +Expand source code + +
    def wrap(self, content, api_version=None):
    +    """Generate the necessary boilerplate XML for a raw SOAP request. The XML is specific to the server version.
    +    ExchangeImpersonation allows to act as the user we want to impersonate.
    +
    +    RequestServerVersion element on MSDN:
    +    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/requestserverversion
    +
    +    ExchangeImpersonation element on MSDN:
    +    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exchangeimpersonation
    +
    +    TimeZoneContent element on MSDN:
    +    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezonecontext
    +
    +    :param content:
    +    :param api_version:
    +    """
    +    envelope = create_element("s:Envelope", nsmap=self.NS_MAP)
    +    header = create_element("s:Header")
    +    if api_version:
    +        request_server_version = create_element("t:RequestServerVersion", attrs=dict(Version=api_version))
    +        header.append(request_server_version)
    +    identity = self._account_to_impersonate
    +    if identity:
    +        add_xml_child(header, "t:ExchangeImpersonation", identity)
    +    timezone = self._timezone
    +    if timezone:
    +        timezone_context = create_element("t:TimeZoneContext")
    +        timezone_definition = create_element("t:TimeZoneDefinition", attrs=dict(Id=timezone.ms_id))
    +        timezone_context.append(timezone_definition)
    +        header.append(timezone_context)
    +    if len(header):
    +        envelope.append(header)
    +    body = create_element("s:Body")
    +    body.append(content)
    +    envelope.append(body)
    +    return xml_to_str(envelope, encoding=DEFAULT_ENCODING, xml_declaration=True)
    +
    +
    @@ -2347,6 +2489,7 @@

    Ancestors

    Class variables

    @@ -2404,6 +2547,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -2437,6 +2582,7 @@

    Inherited members

    Ancestors

    Class variables

    @@ -2490,6 +2636,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -2522,8 +2670,7 @@

    Inherited members

    payload.append(item_ids_element(items=items, version=self.account.version)) return payload - # We need to override this since ExportItemsResponseMessage is formatted a - # little bit differently. . + # We need to override this since ExportItemsResponseMessage is formatted a bit differently. @classmethod def _get_elements_in_container(cls, container): return [container]
    @@ -2532,6 +2679,7 @@

    Ancestors

    Class variables

    @@ -2587,6 +2735,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -2607,14 +2757,13 @@

    Inherited members

    SERVICE_NAME = "FindFolder" element_container_name = f"{{{TNS}}}Folders" paging_container_name = f"{{{MNS}}}RootFolder" - supports_paging = True def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.root = None # A hack to communicate parsing args to _elems_to_objs() def call(self, folders, additional_fields, restriction, shape, depth, max_items, offset): - """Find subfolders of a folder. + """Find sub-folders of a folder. :param folders: the folders to act on :param additional_fields: the extra fields that should be returned with the folder, as FieldPath objects @@ -2679,6 +2828,7 @@

    Ancestors

  • EWSPagingService
  • EWSAccountService
  • EWSService
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -2694,10 +2844,6 @@

    Class variables

    -
    var supports_paging
    -
    -
    -

    Methods

    @@ -2705,7 +2851,7 @@

    Methods

    def call(self, folders, additional_fields, restriction, shape, depth, max_items, offset)
    -

    Find subfolders of a folder.

    +

    Find sub-folders of a folder.

    :param folders: the folders to act on :param additional_fields: the extra fields that should be returned with the folder, as FieldPath objects :param restriction: Restriction object that defines the filters for the query @@ -2719,7 +2865,7 @@

    Methods

    Expand source code
    def call(self, folders, additional_fields, restriction, shape, depth, max_items, offset):
    -    """Find subfolders of a folder.
    +    """Find sub-folders of a folder.
     
         :param folders: the folders to act on
         :param additional_fields: the extra fields that should be returned with the folder, as FieldPath objects
    @@ -2796,6 +2942,8 @@ 

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -2816,7 +2964,6 @@

    Inherited members

    SERVICE_NAME = "FindItem" element_container_name = f"{{{TNS}}}Items" paging_container_name = f"{{{MNS}}}RootFolder" - supports_paging = True def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -2922,6 +3069,7 @@

    Ancestors

  • EWSPagingService
  • EWSAccountService
  • EWSService
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -2937,10 +3085,6 @@

    Class variables

    -
    var supports_paging
    -
    -
    -

    Methods

    @@ -3072,6 +3216,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -3092,7 +3238,6 @@

    Inherited members

    SERVICE_NAME = "FindPeople" element_container_name = f"{{{MNS}}}People" supported_from = EXCHANGE_2013 - supports_paging = True def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -3188,6 +3333,7 @@

    Ancestors

  • EWSPagingService
  • EWSAccountService
  • EWSService
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -3203,10 +3349,6 @@

    Class variables

    -
    var supports_paging
    -
    -
    -

    Methods

    @@ -3312,6 +3454,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -3430,6 +3574,7 @@

    Ancestors

    Class variables

    @@ -3552,6 +3697,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -3610,6 +3757,7 @@

    Ancestors

    Class variables

    @@ -3679,6 +3827,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -3729,6 +3879,7 @@

    Ancestors

    Class variables

    @@ -3788,6 +3939,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -3859,6 +4012,7 @@

    Ancestors

    Class variables

    @@ -3941,6 +4095,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -3996,6 +4152,7 @@

    Ancestors

    Class variables

    @@ -4071,6 +4228,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -4127,6 +4286,7 @@

    Inherited members

    Ancestors

    Class variables

    @@ -4189,6 +4349,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -4233,6 +4395,7 @@

    Ancestors

    Class variables

    @@ -4282,6 +4445,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -4315,6 +4480,7 @@

    Inherited members

    Ancestors

    Class variables

    @@ -4368,6 +4534,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -4401,6 +4569,7 @@

    Inherited members

    Ancestors

    Class variables

    @@ -4454,6 +4623,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -4514,6 +4685,7 @@

    Inherited members

    Ancestors

    Class variables

    @@ -4587,6 +4759,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -4639,6 +4813,7 @@

    Inherited members

    Ancestors

    Class variables

    @@ -4708,6 +4883,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -4822,6 +4999,7 @@

    Ancestors

    Class variables

    @@ -4905,6 +5083,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -4975,6 +5155,7 @@

    Inherited members

    Ancestors

    Class variables

    @@ -5034,6 +5215,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -5084,6 +5267,7 @@

    Ancestors

    Class variables

    @@ -5140,6 +5324,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -5194,6 +5380,7 @@

    Ancestors

    Class variables

    @@ -5247,6 +5434,189 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • + + + +
    +
    +class GetUserSettings +(protocol, chunk_size=None, timeout=None) +
    +
    +

    Take a list of users and requested Autodiscover settings for these users. Returns the requested settings values.

    +

    MSDN: +https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/getusersettings-operation-soap

    +
    + +Expand source code + +
    class GetUserSettings(EWSService):
    +    """Take a list of users and requested Autodiscover settings for these users. Returns the requested settings values.
    +
    +    MSDN:
    +    https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/getusersettings-operation-soap
    +    """
    +
    +    SERVICE_NAME = "GetUserSettings"
    +    NS_MAP = {k: v for k, v in ns_translation.items() if k in ("s", "t", "a", "wsa", "xsi")}
    +    element_container_name = f"{{{ANS}}}UserResponses"
    +    supported_from = EXCHANGE_2010
    +
    +    def call(self, users, settings):
    +        return self._elems_to_objs(self._get_elements(self.get_payload(users=users, settings=settings)))
    +
    +    def wrap(self, content, api_version=None):
    +        envelope = create_element("s:Envelope", nsmap=self.NS_MAP)
    +        header = create_element("s:Header")
    +        if api_version:
    +            add_xml_child(header, "a:RequestedServerVersion", api_version)
    +        action = f"http://schemas.microsoft.com/exchange/2010/Autodiscover/Autodiscover/{self.SERVICE_NAME}"
    +        add_xml_child(header, "wsa:Action", action)
    +        add_xml_child(header, "wsa:To", self.protocol.service_endpoint)
    +        identity = self._account_to_impersonate
    +        if identity:
    +            add_xml_child(header, "t:ExchangeImpersonation", identity)
    +        envelope.append(header)
    +        body = create_element("s:Body")
    +        body.append(content)
    +        envelope.append(body)
    +        return xml_to_str(envelope, encoding=DEFAULT_ENCODING, xml_declaration=True)
    +
    +    def _elem_to_obj(self, elem):
    +        return UserResponse.from_xml(elem=elem, account=None)
    +
    +    def get_payload(self, users, settings):
    +        payload = create_element(f"a:{self.SERVICE_NAME}RequestMessage")
    +        request = create_element("a:Request")
    +        users_elem = create_element("a:Users")
    +        for user in users:
    +            mailbox = create_element("a:Mailbox")
    +            set_xml_value(mailbox, user)
    +            add_xml_child(users_elem, "a:User", mailbox)
    +        if not len(users_elem):
    +            raise ValueError("'users' must not be empty")
    +        request.append(users_elem)
    +        requested_settings = create_element("a:RequestedSettings")
    +        for setting in settings:
    +            add_xml_child(requested_settings, "a:Setting", UserResponse.SETTINGS_MAP[setting])
    +        if not len(requested_settings):
    +            raise ValueError("'requested_settings' must not be empty")
    +        request.append(requested_settings)
    +        payload.append(request)
    +        return payload
    +
    +    @classmethod
    +    def _response_tag(cls):
    +        """Return the name of the element containing the service response."""
    +        return f"{{{ANS}}}{cls.SERVICE_NAME}ResponseMessage"
    +
    +    def _get_element_container(self, message, name=None):
    +        response = message.find(f"{{{ANS}}}Response")
    +        # ErrorCode: See
    +        # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/errorcode-soap
    +        error_code = get_xml_attr(response, f"{{{ANS}}}ErrorCode")
    +        if error_code == "NoError":
    +            container = response.find(name)
    +            if container is None:
    +                raise MalformedResponseError(f"No {name} elements in ResponseMessage ({xml_to_str(response)})")
    +            return container
    +        # Raise any non-acceptable errors in the container, or return the container or the acceptable exception instance
    +        msg_text = get_xml_attr(response, f"{{{ANS}}}ErrorMessage")
    +        try:
    +            raise self._get_exception(code=error_code, text=msg_text, msg_xml=None)
    +        except self.ERRORS_TO_CATCH_IN_RESPONSE as e:
    +            return e
    +
    +    @classmethod
    +    def _raise_soap_errors(cls, fault):
    +        fault_code = get_xml_attr(fault, "faultcode")
    +        fault_string = get_xml_attr(fault, "faultstring")
    +        if fault_code == "a:ActionNotSupported" and "ContractFilter mismatch" in fault_string:
    +            raise ErrorInvalidServerVersion(f"SOAP error code: {fault_code} string: {fault_string}")
    +        super()._raise_soap_errors(fault=fault)
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var NS_MAP
    +
    +
    +
    +
    var SERVICE_NAME
    +
    +
    +
    +
    var element_container_name
    +
    +
    +
    +
    var supported_from
    +
    +
    +
    +
    +

    Methods

    +
    +
    +def call(self, users, settings) +
    +
    +
    +
    + +Expand source code + +
    def call(self, users, settings):
    +    return self._elems_to_objs(self._get_elements(self.get_payload(users=users, settings=settings)))
    +
    +
    +
    +def get_payload(self, users, settings) +
    +
    +
    +
    + +Expand source code + +
    def get_payload(self, users, settings):
    +    payload = create_element(f"a:{self.SERVICE_NAME}RequestMessage")
    +    request = create_element("a:Request")
    +    users_elem = create_element("a:Users")
    +    for user in users:
    +        mailbox = create_element("a:Mailbox")
    +        set_xml_value(mailbox, user)
    +        add_xml_child(users_elem, "a:User", mailbox)
    +    if not len(users_elem):
    +        raise ValueError("'users' must not be empty")
    +    request.append(users_elem)
    +    requested_settings = create_element("a:RequestedSettings")
    +    for setting in settings:
    +        add_xml_child(requested_settings, "a:Setting", UserResponse.SETTINGS_MAP[setting])
    +    if not len(requested_settings):
    +        raise ValueError("'requested_settings' must not be empty")
    +    request.append(requested_settings)
    +    payload.append(request)
    +    return payload
    +
    +
    +
    +

    Inherited members

    + @@ -5288,6 +5658,7 @@

    Ancestors

    Class variables

    @@ -5338,6 +5709,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -5377,6 +5750,7 @@

    Ancestors

    Class variables

    @@ -5432,6 +5806,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -5471,6 +5847,7 @@

    Ancestors

    Subclasses

    @@ -5551,11 +5930,11 @@

    Inherited members

    element_container_name = f"{{{MNS}}}ResolutionSet" ERRORS_TO_CATCH_IN_RESPONSE = ErrorNameResolutionNoResults WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults - # Note: paging information is returned as attrs on the 'ResolutionSet' element, but this service does not - # support the 'IndexedPageItemView' element, so it's not really a paging service. - supports_paging = False + # According to the 'Remarks' section of the MSDN documentation referenced above, at most 100 candidates are # returned for a lookup. + # Note: paging information is returned as attrs on the 'ResolutionSet' element, but this service does not + # support the 'IndexedPageItemView' element, so it's not really a paging service. candidates_limit = 100 def __init__(self, *args, **kwargs): @@ -5639,6 +6018,7 @@

    Inherited members

    Ancestors

    Class variables

    @@ -5662,10 +6042,6 @@

    Class variables

    -
    var supports_paging
    -
    -
    -

    Methods

    @@ -5748,6 +6124,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -5786,6 +6164,7 @@

    Ancestors

    Class variables

    @@ -5843,6 +6222,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -5874,10 +6255,10 @@

    Inherited members

    STATUS_CHOICES = (OK, UNSUBSCRIBE) def ok_payload(self): - return wrap(content=self.get_payload(status=self.OK)) + return self.wrap(content=self.get_payload(status=self.OK)) def unsubscribe_payload(self): - return wrap(content=self.get_payload(status=self.UNSUBSCRIBE)) + return self.wrap(content=self.get_payload(status=self.UNSUBSCRIBE)) def _elem_to_obj(self, elem): return Notification.from_xml(elem=elem, account=None) @@ -5901,6 +6282,7 @@

    Inherited members

    Ancestors

    Class variables

    @@ -5950,7 +6332,7 @@

    Methods

    Expand source code
    def ok_payload(self):
    -    return wrap(content=self.get_payload(status=self.OK))
    + return self.wrap(content=self.get_payload(status=self.OK))
    @@ -5963,7 +6345,7 @@

    Methods

    Expand source code
    def unsubscribe_payload(self):
    -    return wrap(content=self.get_payload(status=self.UNSUBSCRIBE))
    + return self.wrap(content=self.get_payload(status=self.UNSUBSCRIBE))

    @@ -5975,6 +6357,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -6022,6 +6406,7 @@

    Ancestors

    Class variables

    @@ -6077,6 +6462,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -6086,7 +6473,8 @@

    Inherited members

    (*args, **kwargs)
    - +
    Expand source code @@ -6118,6 +6506,7 @@

    Ancestors

  • Subscribe
  • EWSAccountService
  • EWSService
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -6179,6 +6568,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -6188,7 +6579,8 @@

    Inherited members

    (*args, **kwargs)
    - +
    Expand source code @@ -6221,6 +6613,7 @@

    Ancestors

  • Subscribe
  • EWSAccountService
  • EWSService
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -6280,6 +6673,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -6289,7 +6684,8 @@

    Inherited members

    (*args, **kwargs)
    - +
    Expand source code @@ -6318,6 +6714,7 @@

    Ancestors

  • Subscribe
  • EWSAccountService
  • EWSService
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -6369,6 +6766,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -6430,9 +6829,9 @@

    Inherited members

    Ancestors

    Class variables

    @@ -6499,6 +6898,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -6535,7 +6936,7 @@

    Inherited members

    def call(self, folder, shape, additional_fields, sync_state, ignore, max_changes_returned, sync_scope): self.sync_state = sync_state if max_changes_returned is None: - max_changes_returned = self.page_size + max_changes_returned = 100 if not isinstance(max_changes_returned, int): raise InvalidTypeError("max_changes_returned", max_changes_returned, int) if max_changes_returned <= 0: @@ -6587,9 +6988,9 @@

    Inherited members

    Ancestors

    Class variables

    @@ -6636,7 +7037,7 @@

    Methods

    def call(self, folder, shape, additional_fields, sync_state, ignore, max_changes_returned, sync_scope):
         self.sync_state = sync_state
         if max_changes_returned is None:
    -        max_changes_returned = self.page_size
    +        max_changes_returned = 100
         if not isinstance(max_changes_returned, int):
             raise InvalidTypeError("max_changes_returned", max_changes_returned, int)
         if max_changes_returned <= 0:
    @@ -6689,6 +7090,8 @@ 

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -6726,6 +7129,7 @@

    Ancestors

    Class variables

    @@ -6781,6 +7185,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -6826,7 +7232,7 @@

    Inherited members

    return to_item_id(target, FolderId) def get_payload(self, folders): - # Takes a list of (Folder, fieldnames) tuples where 'Folder' is a instance of a subclass of Folder and + # Takes a list of (Folder, fieldnames) tuples where 'Folder' is an instance of a subclass of Folder and # 'fieldnames' are the attribute names that were updated. payload = create_element(f"m:{self.SERVICE_NAME}") payload.append(self._changes_elem(target_changes=folders)) @@ -6837,6 +7243,7 @@

    Ancestors

  • BaseUpdateService
  • EWSAccountService
  • EWSService
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -6893,7 +7300,7 @@

    Methods

    Expand source code
    def get_payload(self, folders):
    -    # Takes a list of (Folder, fieldnames) tuples where 'Folder' is a instance of a subclass of Folder and
    +    # Takes a list of (Folder, fieldnames) tuples where 'Folder' is an instance of a subclass of Folder and
         # 'fieldnames' are the attribute names that were updated.
         payload = create_element(f"m:{self.SERVICE_NAME}")
         payload.append(self._changes_elem(target_changes=folders))
    @@ -6909,6 +7316,8 @@ 

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -7006,7 +7415,7 @@

    Inherited members

    send_meeting_invitations_or_cancellations, suppress_read_receipts, ): - # Takes a list of (Item, fieldnames) tuples where 'Item' is a instance of a subclass of Item and 'fieldnames' + # Takes a list of (Item, fieldnames) tuples where 'Item' is an instance of a subclass of Item and 'fieldnames' # are the attribute names that were updated. attrs = dict( ConflictResolution=conflict_resolution, @@ -7024,6 +7433,7 @@

    Ancestors

  • BaseUpdateService
  • EWSAccountService
  • EWSService
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -7112,7 +7522,7 @@

    Methods

    send_meeting_invitations_or_cancellations, suppress_read_receipts, ): - # Takes a list of (Item, fieldnames) tuples where 'Item' is a instance of a subclass of Item and 'fieldnames' + # Takes a list of (Item, fieldnames) tuples where 'Item' is an instance of a subclass of Item and 'fieldnames' # are the attribute names that were updated. attrs = dict( ConflictResolution=conflict_resolution, @@ -7135,6 +7545,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -7168,6 +7580,7 @@

    Ancestors

    Class variables

    @@ -7217,6 +7630,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -7278,6 +7693,7 @@

    Ancestors

    Class variables

    @@ -7356,6 +7772,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -7409,6 +7827,7 @@

    Index

  • exchangelib.services.get_user_availability
  • exchangelib.services.get_user_configuration
  • exchangelib.services.get_user_oof_settings
  • +
  • exchangelib.services.get_user_settings
  • exchangelib.services.mark_as_junk
  • exchangelib.services.move_folder
  • exchangelib.services.move_item
  • @@ -7445,7 +7864,6 @@

    call
  • cls_map
  • get_payload
  • -
  • supported_from
  • @@ -7533,18 +7951,17 @@

    CHUNK_SIZE

  • ERRORS_TO_CATCH_IN_RESPONSE
  • NO_VALID_SERVER_VERSIONS
  • -
  • PAGE_SIZE
  • +
  • NS_MAP
  • SERVICE_NAME
  • WARNINGS_TO_CATCH_IN_RESPONSE
  • WARNINGS_TO_IGNORE_IN_RESPONSE
  • element_container_name
  • get
  • -
  • paging_container_name
  • parse
  • returns_elements
  • stop_streaming
  • -
  • supported_from
  • -
  • supports_paging
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -7584,7 +8001,6 @@

    element_container_name

  • get_payload
  • paging_container_name
  • -
  • supports_paging
  • @@ -7595,7 +8011,6 @@

    element_container_name

  • get_payload
  • paging_container_name
  • -
  • supports_paging
  • @@ -7606,7 +8021,6 @@

    element_container_name

  • get_payload
  • supported_from
  • -
  • supports_paging
  • @@ -7754,6 +8168,17 @@

    GetUserSettings

    + +
  • +
  • MarkAsJunk

  • diff --git a/docs/exchangelib/services/mark_as_junk.html b/docs/exchangelib/services/mark_as_junk.html index ce00d979..596a9d3c 100644 --- a/docs/exchangelib/services/mark_as_junk.html +++ b/docs/exchangelib/services/mark_as_junk.html @@ -101,6 +101,7 @@

    Ancestors

    Class variables

    @@ -151,6 +152,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -188,4 +191,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/move_folder.html b/docs/exchangelib/services/move_folder.html index eb45bd1e..e3f0ac1b 100644 --- a/docs/exchangelib/services/move_folder.html +++ b/docs/exchangelib/services/move_folder.html @@ -99,6 +99,7 @@

    Ancestors

    Class variables

    @@ -154,6 +155,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -192,4 +195,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/move_item.html b/docs/exchangelib/services/move_item.html index 910da7ac..fef304c2 100644 --- a/docs/exchangelib/services/move_item.html +++ b/docs/exchangelib/services/move_item.html @@ -100,6 +100,7 @@

    Ancestors

    Subclasses

    @@ -197,4 +200,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/resolve_names.html b/docs/exchangelib/services/resolve_names.html index 9635962c..5b828641 100644 --- a/docs/exchangelib/services/resolve_names.html +++ b/docs/exchangelib/services/resolve_names.html @@ -46,11 +46,11 @@

    Module exchangelib.services.resolve_names

    element_container_name = f"{{{MNS}}}ResolutionSet" ERRORS_TO_CATCH_IN_RESPONSE = ErrorNameResolutionNoResults WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults - # Note: paging information is returned as attrs on the 'ResolutionSet' element, but this service does not - # support the 'IndexedPageItemView' element, so it's not really a paging service. - supports_paging = False + # According to the 'Remarks' section of the MSDN documentation referenced above, at most 100 candidates are # returned for a lookup. + # Note: paging information is returned as attrs on the 'ResolutionSet' element, but this service does not + # support the 'IndexedPageItemView' element, so it's not really a paging service. candidates_limit = 100 def __init__(self, *args, **kwargs): @@ -158,11 +158,11 @@

    Classes

    element_container_name = f"{{{MNS}}}ResolutionSet" ERRORS_TO_CATCH_IN_RESPONSE = ErrorNameResolutionNoResults WARNINGS_TO_IGNORE_IN_RESPONSE = ErrorNameResolutionMultipleResults - # Note: paging information is returned as attrs on the 'ResolutionSet' element, but this service does not - # support the 'IndexedPageItemView' element, so it's not really a paging service. - supports_paging = False + # According to the 'Remarks' section of the MSDN documentation referenced above, at most 100 candidates are # returned for a lookup. + # Note: paging information is returned as attrs on the 'ResolutionSet' element, but this service does not + # support the 'IndexedPageItemView' element, so it's not really a paging service. candidates_limit = 100 def __init__(self, *args, **kwargs): @@ -246,6 +246,7 @@

    Classes

    Ancestors

    Class variables

    @@ -269,10 +270,6 @@

    Class variables

    -
    var supports_paging
    -
    -
    -

    Methods

    @@ -355,6 +352,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -385,7 +384,6 @@

    candidates_limit
  • element_container_name
  • get_payload
  • -
  • supports_paging
  • @@ -397,4 +395,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/send_item.html b/docs/exchangelib/services/send_item.html index 2d60609c..62461c2b 100644 --- a/docs/exchangelib/services/send_item.html +++ b/docs/exchangelib/services/send_item.html @@ -97,6 +97,7 @@

    Ancestors

    Class variables

    @@ -154,6 +155,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -192,4 +195,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/send_notification.html b/docs/exchangelib/services/send_notification.html index fa0da538..22c8e9ec 100644 --- a/docs/exchangelib/services/send_notification.html +++ b/docs/exchangelib/services/send_notification.html @@ -28,7 +28,6 @@

    Module exchangelib.services.send_notification

    from ..errors import InvalidEnumValue
     from ..properties import Notification
    -from ..transport import wrap
     from ..util import MNS, create_element
     from .common import EWSService, add_xml_child
     
    @@ -47,10 +46,10 @@ 

    Module exchangelib.services.send_notification

    Classes

    STATUS_CHOICES = (OK, UNSUBSCRIBE) def ok_payload(self): - return wrap(content=self.get_payload(status=self.OK)) + return self.wrap(content=self.get_payload(status=self.OK)) def unsubscribe_payload(self): - return wrap(content=self.get_payload(status=self.UNSUBSCRIBE)) + return self.wrap(content=self.get_payload(status=self.UNSUBSCRIBE)) def _elem_to_obj(self, elem): return Notification.from_xml(elem=elem, account=None) @@ -135,6 +134,7 @@

    Classes

    Ancestors

    Class variables

    @@ -184,7 +184,7 @@

    Methods

    Expand source code

    def ok_payload(self):
    -    return wrap(content=self.get_payload(status=self.OK))
    + return self.wrap(content=self.get_payload(status=self.OK))
    @@ -197,7 +197,7 @@

    Methods

    Expand source code
    def unsubscribe_payload(self):
    -    return wrap(content=self.get_payload(status=self.UNSUBSCRIBE))
    + return self.wrap(content=self.get_payload(status=self.UNSUBSCRIBE))
    @@ -209,6 +209,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -250,4 +252,4 @@

    Generated by pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/set_user_oof_settings.html b/docs/exchangelib/services/set_user_oof_settings.html index 22a03525..de01d8b9 100644 --- a/docs/exchangelib/services/set_user_oof_settings.html +++ b/docs/exchangelib/services/set_user_oof_settings.html @@ -114,6 +114,7 @@

    Ancestors

    Class variables

    @@ -169,6 +170,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -207,4 +210,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/subscribe.html b/docs/exchangelib/services/subscribe.html index 0a470bca..3b75d7f1 100644 --- a/docs/exchangelib/services/subscribe.html +++ b/docs/exchangelib/services/subscribe.html @@ -5,8 +5,8 @@ exchangelib.services.subscribe API documentation - + @@ -23,14 +23,14 @@

    Module exchangelib.services.subscribe

    -

    The 'Subscribe' service has two different modes, pull and push, with different signatures. Implement as two distinct -classes.

    +

    The 'Subscribe' service has three different modes - pull, push and streaming - with different signatures. Implement +as three distinct classes.

    Expand source code -
    """The 'Subscribe' service has two different modes, pull and push, with different signatures. Implement as two distinct
    -classes.
    +
    """The 'Subscribe' service has three different modes - pull, push and streaming - with different signatures. Implement
    +as three distinct classes.
     """
     import abc
     
    @@ -39,7 +39,9 @@ 

    Module exchangelib.services.subscribe

    class Subscribe(EWSAccountService, metaclass=abc.ABCMeta): - """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/subscribe-operation""" + """Base class for subscription classes. + + MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/subscribe-operation""" SERVICE_NAME = "Subscribe" EVENT_TYPES = ( @@ -162,13 +164,16 @@

    Classes

    (*args, **kwargs)
    - +
    Expand source code
    class Subscribe(EWSAccountService, metaclass=abc.ABCMeta):
    -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/subscribe-operation"""
    +    """Base class for subscription classes.
    +
    +    MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/subscribe-operation"""
     
         SERVICE_NAME = "Subscribe"
         EVENT_TYPES = (
    @@ -213,6 +218,7 @@ 

    Ancestors

    Subclasses

    @@ -252,7 +260,8 @@

    Inherited members

    (*args, **kwargs)
    - +
    Expand source code @@ -284,6 +293,7 @@

    Ancestors

  • Subscribe
  • EWSAccountService
  • EWSService
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -345,6 +355,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -354,7 +366,8 @@

    Inherited members

    (*args, **kwargs)
    - +
    Expand source code @@ -387,6 +400,7 @@

    Ancestors

  • Subscribe
  • EWSAccountService
  • EWSService
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -446,6 +460,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -455,7 +471,8 @@

    Inherited members

    (*args, **kwargs)
    - +
    Expand source code @@ -484,6 +501,7 @@

    Ancestors

  • Subscribe
  • EWSAccountService
  • EWSService
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -535,6 +553,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -598,4 +618,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/sync_folder_hierarchy.html b/docs/exchangelib/services/sync_folder_hierarchy.html index 0f3870ef..d487a96b 100644 --- a/docs/exchangelib/services/sync_folder_hierarchy.html +++ b/docs/exchangelib/services/sync_folder_hierarchy.html @@ -31,12 +31,12 @@

    Module exchangelib.services.sync_folder_hierarchy from ..properties import FolderId from ..util import MNS, TNS, create_element, xml_text_to_value -from .common import EWSPagingService, add_xml_child, folder_ids_element, parse_folder_elem, shape_element +from .common import EWSAccountService, add_xml_child, folder_ids_element, parse_folder_elem, shape_element log = logging.getLogger(__name__) -class SyncFolder(EWSPagingService, metaclass=abc.ABCMeta): +class SyncFolder(EWSAccountService, metaclass=abc.ABCMeta): """Base class for SyncFolderHierarchy and SyncFolderItems.""" element_container_name = f"{{{MNS}}}Changes" @@ -142,7 +142,7 @@

    Classes

    Expand source code -
    class SyncFolder(EWSPagingService, metaclass=abc.ABCMeta):
    +
    class SyncFolder(EWSAccountService, metaclass=abc.ABCMeta):
         """Base class for SyncFolderHierarchy and SyncFolderItems."""
     
         element_container_name = f"{{{MNS}}}Changes"
    @@ -186,9 +186,9 @@ 

    Classes

    Ancestors

    Subclasses

      @@ -232,12 +232,14 @@

      Class variables

    Inherited members

    @@ -299,9 +301,9 @@

    Inherited members

    Ancestors

    Class variables

    @@ -368,6 +370,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -420,4 +424,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/sync_folder_items.html b/docs/exchangelib/services/sync_folder_items.html index ba9c103a..38854af8 100644 --- a/docs/exchangelib/services/sync_folder_items.html +++ b/docs/exchangelib/services/sync_folder_items.html @@ -55,7 +55,7 @@

    Module exchangelib.services.sync_folder_items

    Classes

    def call(self, folder, shape, additional_fields, sync_state, ignore, max_changes_returned, sync_scope): self.sync_state = sync_state if max_changes_returned is None: - max_changes_returned = self.page_size + max_changes_returned = 100 if not isinstance(max_changes_returned, int): raise InvalidTypeError("max_changes_returned", max_changes_returned, int) if max_changes_returned <= 0: @@ -198,9 +198,9 @@

    Classes

    Ancestors

    Class variables

    @@ -247,7 +247,7 @@

    Methods

    def call(self, folder, shape, additional_fields, sync_state, ignore, max_changes_returned, sync_scope):
         self.sync_state = sync_state
         if max_changes_returned is None:
    -        max_changes_returned = self.page_size
    +        max_changes_returned = 100
         if not isinstance(max_changes_returned, int):
             raise InvalidTypeError("max_changes_returned", max_changes_returned, int)
         if max_changes_returned <= 0:
    @@ -300,6 +300,8 @@ 

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -343,4 +345,4 @@

    Generated by pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/unsubscribe.html b/docs/exchangelib/services/unsubscribe.html index aa7ebb88..0f26f1d9 100644 --- a/docs/exchangelib/services/unsubscribe.html +++ b/docs/exchangelib/services/unsubscribe.html @@ -91,6 +91,7 @@

    Ancestors

    Class variables

    @@ -146,6 +147,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -185,4 +188,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/update_folder.html b/docs/exchangelib/services/update_folder.html index 82b98424..c9c52dac 100644 --- a/docs/exchangelib/services/update_folder.html +++ b/docs/exchangelib/services/update_folder.html @@ -177,7 +177,7 @@

    Module exchangelib.services.update_folder

    return to_item_id(target, FolderId) def get_payload(self, folders): - # Takes a list of (Folder, fieldnames) tuples where 'Folder' is a instance of a subclass of Folder and + # Takes a list of (Folder, fieldnames) tuples where 'Folder' is an instance of a subclass of Folder and # 'fieldnames' are the attribute names that were updated. payload = create_element(f"m:{self.SERVICE_NAME}") payload.append(self._changes_elem(target_changes=folders)) @@ -318,6 +318,7 @@

    Ancestors

    Subclasses

    @@ -396,7 +399,7 @@

    Inherited members

    return to_item_id(target, FolderId) def get_payload(self, folders): - # Takes a list of (Folder, fieldnames) tuples where 'Folder' is a instance of a subclass of Folder and + # Takes a list of (Folder, fieldnames) tuples where 'Folder' is an instance of a subclass of Folder and # 'fieldnames' are the attribute names that were updated. payload = create_element(f"m:{self.SERVICE_NAME}") payload.append(self._changes_elem(target_changes=folders)) @@ -407,6 +410,7 @@

    Ancestors

  • BaseUpdateService
  • EWSAccountService
  • EWSService
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -463,7 +467,7 @@

    Methods

    Expand source code

    def get_payload(self, folders):
    -    # Takes a list of (Folder, fieldnames) tuples where 'Folder' is a instance of a subclass of Folder and
    +    # Takes a list of (Folder, fieldnames) tuples where 'Folder' is an instance of a subclass of Folder and
         # 'fieldnames' are the attribute names that were updated.
         payload = create_element(f"m:{self.SERVICE_NAME}")
         payload.append(self._changes_elem(target_changes=folders))
    @@ -479,6 +483,8 @@ 

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -530,4 +536,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/update_item.html b/docs/exchangelib/services/update_item.html index 53a75279..7917a423 100644 --- a/docs/exchangelib/services/update_item.html +++ b/docs/exchangelib/services/update_item.html @@ -126,7 +126,7 @@

    Module exchangelib.services.update_item

    send_meeting_invitations_or_cancellations, suppress_read_receipts, ): - # Takes a list of (Item, fieldnames) tuples where 'Item' is a instance of a subclass of Item and 'fieldnames' + # Takes a list of (Item, fieldnames) tuples where 'Item' is an instance of a subclass of Item and 'fieldnames' # are the attribute names that were updated. attrs = dict( ConflictResolution=conflict_resolution, @@ -242,7 +242,7 @@

    Classes

    send_meeting_invitations_or_cancellations, suppress_read_receipts, ): - # Takes a list of (Item, fieldnames) tuples where 'Item' is a instance of a subclass of Item and 'fieldnames' + # Takes a list of (Item, fieldnames) tuples where 'Item' is an instance of a subclass of Item and 'fieldnames' # are the attribute names that were updated. attrs = dict( ConflictResolution=conflict_resolution, @@ -260,6 +260,7 @@

    Ancestors

  • BaseUpdateService
  • EWSAccountService
  • EWSService
  • +
  • SupportedVersionClassMixIn
  • Class variables

    @@ -348,7 +349,7 @@

    Methods

    send_meeting_invitations_or_cancellations, suppress_read_receipts, ): - # Takes a list of (Item, fieldnames) tuples where 'Item' is a instance of a subclass of Item and 'fieldnames' + # Takes a list of (Item, fieldnames) tuples where 'Item' is an instance of a subclass of Item and 'fieldnames' # are the attribute names that were updated. attrs = dict( ConflictResolution=conflict_resolution, @@ -371,6 +372,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -413,4 +416,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/update_user_configuration.html b/docs/exchangelib/services/update_user_configuration.html index 2e2747df..e0e2f9e5 100644 --- a/docs/exchangelib/services/update_user_configuration.html +++ b/docs/exchangelib/services/update_user_configuration.html @@ -83,6 +83,7 @@

    Ancestors

    Class variables

    @@ -132,6 +133,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -170,4 +173,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/services/upload_items.html b/docs/exchangelib/services/upload_items.html index 8a53732e..78216ae5 100644 --- a/docs/exchangelib/services/upload_items.html +++ b/docs/exchangelib/services/upload_items.html @@ -141,6 +141,7 @@

    Ancestors

    Class variables

    @@ -219,6 +220,8 @@

    Inherited members

  • WARNINGS_TO_CATCH_IN_RESPONSE
  • get
  • parse
  • +
  • supported_api_versions
  • +
  • wrap
  • @@ -257,4 +260,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/transport.html b/docs/exchangelib/transport.html index c5f549f7..83503e63 100644 --- a/docs/exchangelib/transport.html +++ b/docs/exchangelib/transport.html @@ -35,17 +35,7 @@

    Module exchangelib.transport

    import requests_oauthlib from .errors import TransportError, UnauthorizedError -from .util import ( - CONNECTION_ERRORS, - RETRY_WAIT, - DummyResponse, - _back_off_if_needed, - _retry_after, - add_xml_child, - create_element, - ns_translation, - xml_to_str, -) +from .util import CONNECTION_ERRORS, RETRY_WAIT, TLS_ERRORS, DummyResponse, _back_off_if_needed, _retry_after log = logging.getLogger(__name__) @@ -85,59 +75,6 @@

    Module exchangelib.transport

    DEFAULT_HEADERS = {"Content-Type": f"text/xml; charset={DEFAULT_ENCODING}", "Accept-Encoding": "gzip, deflate"} -def wrap(content, api_version=None, account_to_impersonate=None, timezone=None): - """Generate the necessary boilerplate XML for a raw SOAP request. The XML is specific to the server version. - ExchangeImpersonation allows to act as the user we want to impersonate. - - RequestServerVersion element on MSDN: - https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/requestserverversion - - ExchangeImpersonation element on MSDN: - https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exchangeimpersonation - - TimeZoneContent element on MSDN: - https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezonecontext - - :param content: - :param api_version: - :param account_to_impersonate: (Default value = None) - :param timezone: (Default value = None) - """ - envelope = create_element("s:Envelope", nsmap=ns_translation) - header = create_element("s:Header") - if api_version: - request_server_version = create_element("t:RequestServerVersion", attrs=dict(Version=api_version)) - header.append(request_server_version) - if account_to_impersonate: - exchange_impersonation = create_element("t:ExchangeImpersonation") - connecting_sid = create_element("t:ConnectingSID") - # We have multiple options for uniquely identifying the user. Here's a prioritized list in accordance with - # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/connectingsid - for attr, tag in ( - ("sid", "SID"), - ("upn", "PrincipalName"), - ("smtp_address", "SmtpAddress"), - ("primary_smtp_address", "PrimarySmtpAddress"), - ): - val = getattr(account_to_impersonate, attr) - if val: - add_xml_child(connecting_sid, f"t:{tag}", val) - break - exchange_impersonation.append(connecting_sid) - header.append(exchange_impersonation) - if timezone: - timezone_context = create_element("t:TimeZoneContext") - timezone_definition = create_element("t:TimeZoneDefinition", attrs=dict(Id=timezone.ms_id)) - timezone_context.append(timezone_definition) - header.append(timezone_context) - if len(header): - envelope.append(header) - body = create_element("s:Body") - body.append(content) - envelope.append(body) - return xml_to_str(envelope, encoding=DEFAULT_ENCODING, xml_declaration=True) - - def get_auth_instance(auth_type, **kwargs): """Return an *Auth instance suitable for the requests package. @@ -156,31 +93,34 @@

    Module exchangelib.transport

    return model(**kwargs) -def get_service_authtype(service_endpoint, retry_policy, api_versions, name): - # Get auth type by tasting headers from the server. Only do POST requests. HEAD is too error prone, and some servers +def get_service_authtype(protocol): + # Get auth type by tasting headers from the server. Only do POST requests. HEAD is too error-prone, and some servers # are set up to redirect to OWA on all requests except POST to /EWS/Exchange.asmx # # We don't know the API version yet, but we need it to create a valid request because some Exchange servers only # respond when given a valid request. Try all known versions. Gross. - from .protocol import BaseProtocol + from .services import ConvertId + service_endpoint = protocol.service_endpoint + retry_policy = protocol.retry_policy retry = 0 t_start = time.monotonic() - headers = DEFAULT_HEADERS.copy() - for api_version in api_versions: - data = dummy_xml(api_version=api_version, name=name) + for api_version in ConvertId.supported_api_versions(): + protocol.api_version_hint = api_version + data = protocol.dummy_xml() + headers = {} log.debug("Requesting %s from %s", data, service_endpoint) while True: _back_off_if_needed(retry_policy.back_off_until) log.debug("Trying to get service auth type for %s", service_endpoint) - with BaseProtocol.raw_session(service_endpoint) as s: + with protocol.raw_session(service_endpoint) as s: try: r = s.post( url=service_endpoint, headers=headers, data=data, allow_redirects=False, - timeout=BaseProtocol.TIMEOUT, + timeout=protocol.TIMEOUT, ) r.close() # Release memory break @@ -207,12 +147,66 @@

    Module exchangelib.transport

    try: auth_type = get_auth_method_from_response(response=r) log.debug("Auth type is %s", auth_type) - return auth_type, api_version + return auth_type except UnauthorizedError: continue raise TransportError("Failed to get auth type from service") +def get_autodiscover_authtype(protocol): + data = protocol.dummy_xml() + headers = {"X-AnchorMailbox": "DUMMY@example.com"} # Required in case of OAuth + r = get_unauthenticated_autodiscover_response(protocol=protocol, method="post", headers=headers, data=data) + auth_type = get_auth_method_from_response(response=r) + log.debug("Auth type is %s", auth_type) + return auth_type + + +def get_unauthenticated_autodiscover_response(protocol, method, headers=None, data=None): + from .autodiscover import Autodiscovery + + service_endpoint = protocol.service_endpoint + retry_policy = protocol.retry_policy + retry = 0 + t_start = time.monotonic() + while True: + _back_off_if_needed(retry_policy.back_off_until) + log.debug("Trying to get response from %s", service_endpoint) + with protocol.raw_session(service_endpoint) as s: + try: + r = getattr(s, method)( + url=service_endpoint, + headers=headers, + data=data, + allow_redirects=False, + timeout=protocol.TIMEOUT, + ) + r.close() # Release memory + break + except TLS_ERRORS as e: + # Don't retry on TLS errors. But wrap, so we can catch later and continue with the next endpoint. + raise TransportError(str(e)) + except CONNECTION_ERRORS as e: + r = DummyResponse(url=service_endpoint, request_headers=headers) + total_wait = time.monotonic() - t_start + if retry_policy.may_retry_on_error(response=r, wait=total_wait): + log.debug( + "Connection error on URL %s (retry %s, error: %s). Cool down %s secs", + service_endpoint, + retry, + e, + Autodiscovery.RETRY_WAIT, + ) + # Don't respect the 'Retry-After' header. We don't know if this is a useful endpoint, and we + # want autodiscover to be reasonably fast. + retry_policy.back_off(Autodiscovery.RETRY_WAIT) + retry += 1 + continue + log.debug("Connection error on URL %s: %s", service_endpoint, e) + raise TransportError(str(e)) + return r + + def get_auth_method_from_response(response): # First, get the auth method from headers. Then, test credentials. Don't handle redirects - burden is on caller. log.debug("Request headers: %s", response.request.headers) @@ -236,6 +230,8 @@

    Module exchangelib.transport

    return NTLM if "basic" in vals: return BASIC + elif key.lower() == "ms-diagnostics-public" and "Modern Auth" in val: + return OAUTH2 raise UnauthorizedError("No compatible auth type was reported by server") @@ -260,23 +256,7 @@

    Module exchangelib.transport

    auth_method += c if auth_method: auth_methods.append(auth_method) - return auth_methods - - -def dummy_xml(api_version, name): - # Generate a minimal, valid EWS request - from .services import ResolveNames # Avoid circular import - - return wrap( - content=ResolveNames(protocol=None).get_payload( - unresolved_entries=[name], - parent_folders=None, - return_full_contact_data=False, - search_scope=None, - contact_data_shape=None, - ), - api_version=api_version, - )

    + return auth_methods
    @@ -286,31 +266,6 @@

    Module exchangelib.transport

    Functions

    -
    -def dummy_xml(api_version, name) -
    -
    -
    -
    - -Expand source code - -
    def dummy_xml(api_version, name):
    -    # Generate a minimal, valid EWS request
    -    from .services import ResolveNames  # Avoid circular import
    -
    -    return wrap(
    -        content=ResolveNames(protocol=None).get_payload(
    -            unresolved_entries=[name],
    -            parent_folders=None,
    -            return_full_contact_data=False,
    -            search_scope=None,
    -            contact_data_shape=None,
    -        ),
    -        api_version=api_version,
    -    )
    -
    -
    def get_auth_instance(auth_type, **kwargs)
    @@ -372,11 +327,31 @@

    Functions

    return NTLM if "basic" in vals: return BASIC + elif key.lower() == "ms-diagnostics-public" and "Modern Auth" in val: + return OAUTH2 raise UnauthorizedError("No compatible auth type was reported by server")
    +
    +def get_autodiscover_authtype(protocol) +
    +
    +
    +
    + +Expand source code + +
    def get_autodiscover_authtype(protocol):
    +    data = protocol.dummy_xml()
    +    headers = {"X-AnchorMailbox": "DUMMY@example.com"}  # Required in case of OAuth
    +    r = get_unauthenticated_autodiscover_response(protocol=protocol, method="post", headers=headers, data=data)
    +    auth_type = get_auth_method_from_response(response=r)
    +    log.debug("Auth type is %s", auth_type)
    +    return auth_type
    +
    +
    -def get_service_authtype(service_endpoint, retry_policy, api_versions, name) +def get_service_authtype(protocol)
    @@ -384,31 +359,34 @@

    Functions

    Expand source code -
    def get_service_authtype(service_endpoint, retry_policy, api_versions, name):
    -    # Get auth type by tasting headers from the server. Only do POST requests. HEAD is too error prone, and some servers
    +
    def get_service_authtype(protocol):
    +    # Get auth type by tasting headers from the server. Only do POST requests. HEAD is too error-prone, and some servers
         # are set up to redirect to OWA on all requests except POST to /EWS/Exchange.asmx
         #
         # We don't know the API version yet, but we need it to create a valid request because some Exchange servers only
         # respond when given a valid request. Try all known versions. Gross.
    -    from .protocol import BaseProtocol
    +    from .services import ConvertId
     
    +    service_endpoint = protocol.service_endpoint
    +    retry_policy = protocol.retry_policy
         retry = 0
         t_start = time.monotonic()
    -    headers = DEFAULT_HEADERS.copy()
    -    for api_version in api_versions:
    -        data = dummy_xml(api_version=api_version, name=name)
    +    for api_version in ConvertId.supported_api_versions():
    +        protocol.api_version_hint = api_version
    +        data = protocol.dummy_xml()
    +        headers = {}
             log.debug("Requesting %s from %s", data, service_endpoint)
             while True:
                 _back_off_if_needed(retry_policy.back_off_until)
                 log.debug("Trying to get service auth type for %s", service_endpoint)
    -            with BaseProtocol.raw_session(service_endpoint) as s:
    +            with protocol.raw_session(service_endpoint) as s:
                     try:
                         r = s.post(
                             url=service_endpoint,
                             headers=headers,
                             data=data,
                             allow_redirects=False,
    -                        timeout=BaseProtocol.TIMEOUT,
    +                        timeout=protocol.TIMEOUT,
                         )
                         r.close()  # Release memory
                         break
    @@ -435,85 +413,64 @@ 

    Functions

    try: auth_type = get_auth_method_from_response(response=r) log.debug("Auth type is %s", auth_type) - return auth_type, api_version + return auth_type except UnauthorizedError: continue raise TransportError("Failed to get auth type from service")
    -
    -def wrap(content, api_version=None, account_to_impersonate=None, timezone=None) +
    +def get_unauthenticated_autodiscover_response(protocol, method, headers=None, data=None)
    -

    Generate the necessary boilerplate XML for a raw SOAP request. The XML is specific to the server version. -ExchangeImpersonation allows to act as the user we want to impersonate.

    -

    RequestServerVersion element on MSDN: -https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/requestserverversion

    -

    ExchangeImpersonation element on MSDN: -https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exchangeimpersonation

    -

    TimeZoneContent element on MSDN: -https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezonecontext

    -

    :param content: -:param api_version: -:param account_to_impersonate: -(Default value = None) -:param timezone: -(Default value = None)

    +
    Expand source code -
    def wrap(content, api_version=None, account_to_impersonate=None, timezone=None):
    -    """Generate the necessary boilerplate XML for a raw SOAP request. The XML is specific to the server version.
    -    ExchangeImpersonation allows to act as the user we want to impersonate.
    -
    -    RequestServerVersion element on MSDN:
    -    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/requestserverversion
    -
    -    ExchangeImpersonation element on MSDN:
    -    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exchangeimpersonation
    +
    def get_unauthenticated_autodiscover_response(protocol, method, headers=None, data=None):
    +    from .autodiscover import Autodiscovery
     
    -    TimeZoneContent element on MSDN:
    -    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/timezonecontext
    -
    -    :param content:
    -    :param api_version:
    -    :param account_to_impersonate:  (Default value = None)
    -    :param timezone:  (Default value = None)
    -    """
    -    envelope = create_element("s:Envelope", nsmap=ns_translation)
    -    header = create_element("s:Header")
    -    if api_version:
    -        request_server_version = create_element("t:RequestServerVersion", attrs=dict(Version=api_version))
    -        header.append(request_server_version)
    -    if account_to_impersonate:
    -        exchange_impersonation = create_element("t:ExchangeImpersonation")
    -        connecting_sid = create_element("t:ConnectingSID")
    -        # We have multiple options for uniquely identifying the user. Here's a prioritized list in accordance with
    -        # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/connectingsid
    -        for attr, tag in (
    -            ("sid", "SID"),
    -            ("upn", "PrincipalName"),
    -            ("smtp_address", "SmtpAddress"),
    -            ("primary_smtp_address", "PrimarySmtpAddress"),
    -        ):
    -            val = getattr(account_to_impersonate, attr)
    -            if val:
    -                add_xml_child(connecting_sid, f"t:{tag}", val)
    +    service_endpoint = protocol.service_endpoint
    +    retry_policy = protocol.retry_policy
    +    retry = 0
    +    t_start = time.monotonic()
    +    while True:
    +        _back_off_if_needed(retry_policy.back_off_until)
    +        log.debug("Trying to get response from %s", service_endpoint)
    +        with protocol.raw_session(service_endpoint) as s:
    +            try:
    +                r = getattr(s, method)(
    +                    url=service_endpoint,
    +                    headers=headers,
    +                    data=data,
    +                    allow_redirects=False,
    +                    timeout=protocol.TIMEOUT,
    +                )
    +                r.close()  # Release memory
                     break
    -        exchange_impersonation.append(connecting_sid)
    -        header.append(exchange_impersonation)
    -    if timezone:
    -        timezone_context = create_element("t:TimeZoneContext")
    -        timezone_definition = create_element("t:TimeZoneDefinition", attrs=dict(Id=timezone.ms_id))
    -        timezone_context.append(timezone_definition)
    -        header.append(timezone_context)
    -    if len(header):
    -        envelope.append(header)
    -    body = create_element("s:Body")
    -    body.append(content)
    -    envelope.append(body)
    -    return xml_to_str(envelope, encoding=DEFAULT_ENCODING, xml_declaration=True)
    + except TLS_ERRORS as e: + # Don't retry on TLS errors. But wrap, so we can catch later and continue with the next endpoint. + raise TransportError(str(e)) + except CONNECTION_ERRORS as e: + r = DummyResponse(url=service_endpoint, request_headers=headers) + total_wait = time.monotonic() - t_start + if retry_policy.may_retry_on_error(response=r, wait=total_wait): + log.debug( + "Connection error on URL %s (retry %s, error: %s). Cool down %s secs", + service_endpoint, + retry, + e, + Autodiscovery.RETRY_WAIT, + ) + # Don't respect the 'Retry-After' header. We don't know if this is a useful endpoint, and we + # want autodiscover to be reasonably fast. + retry_policy.back_off(Autodiscovery.RETRY_WAIT) + retry += 1 + continue + log.debug("Connection error on URL %s: %s", service_endpoint, e) + raise TransportError(str(e)) + return r
    @@ -534,11 +491,11 @@

    Index

  • Functions

  • diff --git a/docs/exchangelib/util.html b/docs/exchangelib/util.html index 83db40c0..b6fcdfe1 100644 --- a/docs/exchangelib/util.html +++ b/docs/exchangelib/util.html @@ -53,14 +53,7 @@

    Module exchangelib.util

    from pygments.lexers.html import XmlLexer from requests_oauthlib import OAuth2Session -from .errors import ( - InvalidTypeError, - MalformedResponseError, - RateLimitError, - RedirectError, - RelativeRedirect, - TransportError, -) +from .errors import MalformedResponseError, RateLimitError, RedirectError, RelativeRedirect, TransportError log = logging.getLogger(__name__) xml_log = logging.getLogger(f"{__name__}.xml") @@ -100,14 +93,18 @@

    Module exchangelib.util

    self.data = data -# Regex of UTF-8 control characters that are illegal in XML 1.0 (and XML 1.1) -_ILLEGAL_XML_CHARS_RE = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1F\uD800-\uDFFF\uFFFE\uFFFF]") +# Regex of UTF-8 control characters that are illegal in XML 1.0 (and XML 1.1). +# See https://stackoverflow.com/a/22273639/219640 +_ILLEGAL_XML_CHARS_RE = re.compile("[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x84\x86-\x9F\uFDD0-\uFDDF\uFFFE\uFFFF]") # XML namespaces SOAPNS = "http://schemas.xmlsoap.org/soap/envelope/" MNS = "http://schemas.microsoft.com/exchange/services/2006/messages" TNS = "http://schemas.microsoft.com/exchange/services/2006/types" ENS = "http://schemas.microsoft.com/exchange/services/2006/errors" +ANS = "http://schemas.microsoft.com/exchange/2010/Autodiscover" +INS = "http://www.w3.org/2001/XMLSchema-instance" +WSA = "http://www.w3.org/2005/08/addressing" AUTODISCOVER_BASE_NS = "http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006" AUTODISCOVER_REQUEST_NS = "http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006" AUTODISCOVER_RESPONSE_NS = "http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a" @@ -116,6 +113,9 @@

    Module exchangelib.util

    "s": SOAPNS, "m": MNS, "t": TNS, + "a": ANS, + "wsa": WSA, + "xsi": INS, } for item in ns_translation.items(): lxml.etree.register_namespace(*item) @@ -275,7 +275,6 @@

    Module exchangelib.util

    from .ewsdatetime import EWSDate, EWSDateTime from .fields import FieldOrder, FieldPath from .properties import EWSElement - from .version import Version if isinstance(value, (str, bool, bytes, int, Decimal, datetime.time, EWSDate, EWSDateTime)): elem.text = value_to_xml_text(value) @@ -284,8 +283,6 @@

    Module exchangelib.util

    elif isinstance(value, (FieldPath, FieldOrder)): elem.append(value.to_xml()) elif isinstance(value, EWSElement): - if not isinstance(version, Version): - raise InvalidTypeError("version", version, Version) elem.append(value.to_xml(version=version)) elif is_iterable(value, generators_allowed=True): for v in value: @@ -360,7 +357,7 @@

    Module exchangelib.util

    def safe_b64decode(data): - # Incoming base64-encoded data is not always padded to a multiple of 4. Python's parser is more strict and requires + # Incoming base64-encoded data is not always padded to a multiple of 4. Python's parser is stricter and requires # padding. Add padding if it's needed. overflow = len(data) % 4 if overflow: @@ -489,7 +486,7 @@

    Module exchangelib.util

    def _get_tag(self): """Iterate over the bytes until we have a full tag in the buffer. If there's a '>' in an attr value, then we'll - exit on that, but it's OK becaus wejust need the plain tag name later. + exit on that, but it's OK because we just need the plain tag name later. """ tag_buffer = [b"<"] while True: @@ -581,21 +578,24 @@

    Module exchangelib.util

    return res -def is_xml(text, expected_prefix=b"<?xml"): +def is_xml(text): """Lightweight test if response is an XML doc. It's better to be fast than correct here. :param text: The string to check - :param expected_prefix: What to search for in the start if the string :return: """ - # BOM_UTF8 is an UTF-8 byte order mark which may precede the XML from an Exchange server + # BOM_UTF8 is a UTF-8 byte order mark which may precede the XML from an Exchange server bom_len = len(BOM_UTF8) - prefix_len = len(expected_prefix) + expected_prefixes = (b"<?xml", b"<s:Envelope") + max_prefix_len = len(expected_prefixes[1]) if text[:bom_len] == BOM_UTF8: - prefix = text[bom_len : bom_len + prefix_len] + prefix = text[bom_len : bom_len + max_prefix_len] else: - prefix = text[:prefix_len] - return prefix == expected_prefix + prefix = text[:max_prefix_len] + for expected_prefix in expected_prefixes: + if prefix[: len(expected_prefix)] == expected_prefix: + return True + return False class PrettyXmlHandler(logging.StreamHandler): @@ -744,7 +744,6 @@

    Module exchangelib.util

    RETRY_WAIT = 10 # Seconds to wait before retry on connection errors -MAX_REDIRECTS = 10 # Maximum number of URL redirects before we give up # A collection of error classes we want to handle as general connection errors CONNECTION_ERRORS = ( @@ -764,7 +763,7 @@

    Module exchangelib.util

    TLS_ERRORS += (OpenSSL.SSL.Error,) -def post_ratelimited(protocol, session, url, headers, data, allow_redirects=False, stream=False, timeout=None): +def post_ratelimited(protocol, session, url, headers, data, stream=False, timeout=None): """There are two error-handling policies implemented here: a fail-fast policy intended for stand-alone scripts which fails on all responses except HTTP 200. The other policy is intended for long-running tasks that need to respect rate-limiting errors from the server and paper over outages of up to 1 hour. @@ -794,7 +793,6 @@

    Module exchangelib.util

    :param url: :param headers: :param data: - :param allow_redirects: (Default value = False) :param stream: (Default value = False) :param timeout: @@ -805,7 +803,6 @@

    Module exchangelib.util

    thread_id = get_ident() wait = RETRY_WAIT # Initial retry wait. We double the value on each retry retry = 0 - redirects = 0 log_msg = """\ Retry: %(retry)s Waited: %(wait)s @@ -815,7 +812,6 @@

    Module exchangelib.util

    Auth type: %(auth)s URL: %(url)s HTTP adapter: %(adapter)s -Allow redirects: %(allow_redirects)s Streaming: %(stream)s Response time: %(response_time)s Status code: %(status_code)s @@ -833,7 +829,6 @@

    Module exchangelib.util

    auth=session.auth, url=url, adapter=session.get_adapter(url), - allow_redirects=allow_redirects, stream=stream, response_time=None, status_code=None, @@ -922,7 +917,7 @@

    Module exchangelib.util

    continue if r.status_code in (301, 302): r.close() # Release memory - url, redirects = _redirect_or_fail(r, redirects, allow_redirects) + url = _fail_on_redirect(r) continue break except (RateLimitError, RedirectError) as e: @@ -963,7 +958,7 @@

    Module exchangelib.util

    return response.status_code == 401 and response.headers.get("TokenExpiredError") -def _redirect_or_fail(response, redirects, allow_redirects): +def _fail_on_redirect(response): # Retry with no delay. If we let requests handle redirects automatically, it would issue a GET to that # URL. We still want to POST. try: @@ -971,13 +966,8 @@

    Module exchangelib.util

    except RelativeRedirect as e: log.debug("'allow_redirects' only supports relative redirects (%s -> %s)", response.url, e.value) raise RedirectError(url=e.value) - if not allow_redirects: - raise TransportError(f"Redirect not allowed but we were redirected ({response.url} -> {redirect_url})") - log.debug("HTTP redirected to %s", redirect_url) - redirects += 1 - if redirects > MAX_REDIRECTS: - raise TransportError("Max redirect count exceeded") - return redirect_url, redirects + log.debug("Redirect not allowed but we were redirected ( (%s -> %s)", response.url, redirect_url) + raise RedirectError(url=redirect_url) def _retry_after(r, wait): @@ -1196,32 +1186,34 @@

    Functions

    -def is_xml(text, expected_prefix=b'<?xml') +def is_xml(text)

    Lightweight test if response is an XML doc. It's better to be fast than correct here.

    :param text: The string to check -:param expected_prefix: What to search for in the start if the string :return:

    Expand source code -
    def is_xml(text, expected_prefix=b"<?xml"):
    +
    def is_xml(text):
         """Lightweight test if response is an XML doc. It's better to be fast than correct here.
     
         :param text: The string to check
    -    :param expected_prefix: What to search for in the start if the string
         :return:
         """
    -    # BOM_UTF8 is an UTF-8 byte order mark which may precede the XML from an Exchange server
    +    # BOM_UTF8 is a UTF-8 byte order mark which may precede the XML from an Exchange server
         bom_len = len(BOM_UTF8)
    -    prefix_len = len(expected_prefix)
    +    expected_prefixes = (b"<?xml", b"<s:Envelope")
    +    max_prefix_len = len(expected_prefixes[1])
         if text[:bom_len] == BOM_UTF8:
    -        prefix = text[bom_len : bom_len + prefix_len]
    +        prefix = text[bom_len : bom_len + max_prefix_len]
         else:
    -        prefix = text[:prefix_len]
    -    return prefix == expected_prefix
    + prefix = text[:max_prefix_len] + for expected_prefix in expected_prefixes: + if prefix[: len(expected_prefix)] == expected_prefix: + return True + return False
    @@ -1254,7 +1246,7 @@

    Functions

    -def post_ratelimited(protocol, session, url, headers, data, allow_redirects=False, stream=False, timeout=None) +def post_ratelimited(protocol, session, url, headers, data, stream=False, timeout=None)

    There are two error-handling policies implemented here: a fail-fast policy intended for stand-alone scripts which @@ -1280,8 +1272,6 @@

    Functions

    :param url: :param headers: :param data: -:param allow_redirects: -(Default value = False) :param stream: (Default value = False) :param timeout:

    @@ -1290,7 +1280,7 @@

    Functions

    Expand source code -
    def post_ratelimited(protocol, session, url, headers, data, allow_redirects=False, stream=False, timeout=None):
    +
    def post_ratelimited(protocol, session, url, headers, data, stream=False, timeout=None):
         """There are two error-handling policies implemented here: a fail-fast policy intended for stand-alone scripts which
         fails on all responses except HTTP 200. The other policy is intended for long-running tasks that need to respect
         rate-limiting errors from the server and paper over outages of up to 1 hour.
    @@ -1320,7 +1310,6 @@ 

    Functions

    :param url: :param headers: :param data: - :param allow_redirects: (Default value = False) :param stream: (Default value = False) :param timeout: @@ -1331,7 +1320,6 @@

    Functions

    thread_id = get_ident() wait = RETRY_WAIT # Initial retry wait. We double the value on each retry retry = 0 - redirects = 0 log_msg = """\ Retry: %(retry)s Waited: %(wait)s @@ -1341,7 +1329,6 @@

    Functions

    Auth type: %(auth)s URL: %(url)s HTTP adapter: %(adapter)s -Allow redirects: %(allow_redirects)s Streaming: %(stream)s Response time: %(response_time)s Status code: %(status_code)s @@ -1359,7 +1346,6 @@

    Functions

    auth=session.auth, url=url, adapter=session.get_adapter(url), - allow_redirects=allow_redirects, stream=stream, response_time=None, status_code=None, @@ -1448,7 +1434,7 @@

    Functions

    continue if r.status_code in (301, 302): r.close() # Release memory - url, redirects = _redirect_or_fail(r, redirects, allow_redirects) + url = _fail_on_redirect(r) continue break except (RateLimitError, RedirectError) as e: @@ -1541,7 +1527,7 @@

    Functions

    Expand source code
    def safe_b64decode(data):
    -    # Incoming base64-encoded data is not always padded to a multiple of 4. Python's parser is more strict and requires
    +    # Incoming base64-encoded data is not always padded to a multiple of 4. Python's parser is stricter and requires
         # padding. Add padding if it's needed.
         overflow = len(data) % 4
         if overflow:
    @@ -1579,7 +1565,6 @@ 

    Functions

    from .ewsdatetime import EWSDate, EWSDateTime from .fields import FieldOrder, FieldPath from .properties import EWSElement - from .version import Version if isinstance(value, (str, bool, bytes, int, Decimal, datetime.time, EWSDate, EWSDateTime)): elem.text = value_to_xml_text(value) @@ -1588,8 +1573,6 @@

    Functions

    elif isinstance(value, (FieldPath, FieldOrder)): elem.append(value.to_xml()) elif isinstance(value, EWSElement): - if not isinstance(version, Version): - raise InvalidTypeError("version", version, Version) elem.append(value.to_xml(version=version)) elif is_iterable(value, generators_allowed=True): for v in value: @@ -2030,7 +2013,7 @@

    Methods

    def _get_tag(self): """Iterate over the bytes until we have a full tag in the buffer. If there's a '>' in an attr value, then we'll - exit on that, but it's OK becaus wejust need the plain tag name later. + exit on that, but it's OK because we just need the plain tag name later. """ tag_buffer = [b"<"] while True: diff --git a/docs/exchangelib/version.html b/docs/exchangelib/version.html index f9e15872..05d44faf 100644 --- a/docs/exchangelib/version.html +++ b/docs/exchangelib/version.html @@ -30,66 +30,14 @@

    Module exchangelib.version

    import re from .errors import InvalidTypeError, ResponseMessageError, TransportError -from .util import TNS, xml_to_str +from .util import ANS, TNS, get_xml_attr, xml_to_str log = logging.getLogger(__name__) -# Legend for dict: -# Key: shortname -# Values: (EWS API version ID, full name) - -# 'shortname' comes from types.xsd and is the official version of the server, corresponding to the version numbers -# supplied in SOAP headers. 'API version' is the version name supplied in the RequestServerVersion element in SOAP -# headers and describes the EWS API version the server implements. Valid values for this element are described here: -# https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/requestserverversion - -VERSIONS = { - "Exchange2007": ("Exchange2007", "Microsoft Exchange Server 2007"), - "Exchange2007_SP1": ("Exchange2007_SP1", "Microsoft Exchange Server 2007 SP1"), - "Exchange2007_SP2": ("Exchange2007_SP1", "Microsoft Exchange Server 2007 SP2"), - "Exchange2007_SP3": ("Exchange2007_SP1", "Microsoft Exchange Server 2007 SP3"), - "Exchange2010": ("Exchange2010", "Microsoft Exchange Server 2010"), - "Exchange2010_SP1": ("Exchange2010_SP1", "Microsoft Exchange Server 2010 SP1"), - "Exchange2010_SP2": ("Exchange2010_SP2", "Microsoft Exchange Server 2010 SP2"), - "Exchange2010_SP3": ("Exchange2010_SP2", "Microsoft Exchange Server 2010 SP3"), - "Exchange2013": ("Exchange2013", "Microsoft Exchange Server 2013"), - "Exchange2013_SP1": ("Exchange2013_SP1", "Microsoft Exchange Server 2013 SP1"), - "Exchange2015": ("Exchange2015", "Microsoft Exchange Server 2015"), - "Exchange2015_SP1": ("Exchange2015_SP1", "Microsoft Exchange Server 2015 SP1"), - "Exchange2016": ("Exchange2016", "Microsoft Exchange Server 2016"), - "Exchange2019": ("Exchange2019", "Microsoft Exchange Server 2019"), -} - -# Build a list of unique API versions, used when guessing API version supported by the server. Use reverse order so we -# get the newest API version supported by the server. -API_VERSIONS = sorted({v[0] for v in VERSIONS.values()}, reverse=True) - class Build: """Holds methods for working with build numbers.""" - # List of build numbers here: https://docs.microsoft.com/en-us/exchange/new-features/build-numbers-and-release-dates - API_VERSION_MAP = { - 8: { - 0: "Exchange2007", - 1: "Exchange2007_SP1", - 2: "Exchange2007_SP1", - 3: "Exchange2007_SP1", - }, - 14: { - 0: "Exchange2010", - 1: "Exchange2010_SP1", - 2: "Exchange2010_SP2", - 3: "Exchange2010_SP2", - }, - 15: { - 0: "Exchange2013", # Minor builds starting from 847 are Exchange2013_SP1, see api_version() - 1: "Exchange2016", - 2: "Exchange2019", - 20: "Exchange2016", # This is Office365. See issue #221 - }, - } - __slots__ = "major_version", "minor_version", "major_build", "minor_build" def __init__(self, major_version, minor_version, major_build=0, minor_build=0): @@ -120,7 +68,9 @@

    Module exchangelib.version

    for k, xml_elem in xml_elems_map.items(): v = elem.get(xml_elem) if v is None: - raise ValueError() + v = get_xml_attr(elem, f"{{{ANS}}}{xml_elem}") + if v is None: + raise ValueError() kwargs[k] = int(v) # Also raises ValueError return cls(**kwargs) @@ -145,15 +95,12 @@

    Module exchangelib.version

    return cls(major_version=major_version, minor_version=minor_version, major_build=build_number) def api_version(self): - if EXCHANGE_2013_SP1 <= self < EXCHANGE_2016: - return "Exchange2013_SP1" - try: - return self.API_VERSION_MAP[self.major_version][self.minor_version] - except KeyError: - raise ValueError(f"API version for build {self} is unknown") - - def fullname(self): - return VERSIONS[self.api_version()][1] + for build, api_version, _ in VERSIONS: + if self.major_version != build.major_version or self.minor_version != build.minor_version: + continue + if self >= build: + return api_version + raise ValueError(f"API version for build {self} is unknown") def __cmp__(self, other): # __cmp__ is not a magic method in Python3. We'll just use it here to implement comparison operators @@ -201,15 +148,49 @@

    Module exchangelib.version

    # Helpers for comparison operations elsewhere in this package EXCHANGE_2007 = Build(8, 0) EXCHANGE_2007_SP1 = Build(8, 1) +EXCHANGE_2007_SP2 = Build(8, 2) +EXCHANGE_2007_SP3 = Build(8, 3) EXCHANGE_2010 = Build(14, 0) EXCHANGE_2010_SP1 = Build(14, 1) EXCHANGE_2010_SP2 = Build(14, 2) +EXCHANGE_2010_SP3 = Build(14, 3) EXCHANGE_2013 = Build(15, 0) -EXCHANGE_2013_SP1 = Build(15, 0, 847) +EXCHANGE_2013_SP1 = Build(15, 0, 847) # Major builds starting from 847 are Exchange2013_SP1 +EXCHANGE_2015 = Build(15, 20) +EXCHANGE_2015_SP1 = Build(15, 20) EXCHANGE_2016 = Build(15, 1) EXCHANGE_2019 = Build(15, 2) EXCHANGE_O365 = Build(15, 20) +# Legend for VERSIONS: +# (build, API version, full name) +# +# 'API version' is the version name supplied in the RequestServerVersion element in SOAP headers and describes the EWS +# API version the server implements. Valid values for this element are described here: +# https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/requestserverversion +# +# A list of build numbers and full version names is available here: +# https://docs.microsoft.com/en-us/exchange/new-features/build-numbers-and-release-dates +# +# The list is sorted from newest to oldest build +VERSIONS = ( + (EXCHANGE_O365, "Exchange2016", "Microsoft Exchange Server Office365"), # Not mentioned in list of build numbers + (EXCHANGE_2019, "Exchange2019", "Microsoft Exchange Server 2019"), + (EXCHANGE_2016, "Exchange2016", "Microsoft Exchange Server 2016"), + (EXCHANGE_2015_SP1, "Exchange2015_SP1", "Microsoft Exchange Server 2015 SP1"), + (EXCHANGE_2015, "Exchange2015", "Microsoft Exchange Server 2015"), + (EXCHANGE_2013_SP1, "Exchange2013_SP1", "Microsoft Exchange Server 2013 SP1"), + (EXCHANGE_2013, "Exchange2013", "Microsoft Exchange Server 2013"), + (EXCHANGE_2010_SP3, "Exchange2010_SP2", "Microsoft Exchange Server 2010 SP3"), + (EXCHANGE_2010_SP2, "Exchange2010_SP2", "Microsoft Exchange Server 2010 SP2"), + (EXCHANGE_2010_SP1, "Exchange2010_SP1", "Microsoft Exchange Server 2010 SP1"), + (EXCHANGE_2010, "Exchange2010", "Microsoft Exchange Server 2010"), + (EXCHANGE_2007_SP3, "Exchange2007_SP1", "Microsoft Exchange Server 2007 SP3"), + (EXCHANGE_2007_SP2, "Exchange2007_SP1", "Microsoft Exchange Server 2007 SP2"), + (EXCHANGE_2007_SP1, "Exchange2007_SP1", "Microsoft Exchange Server 2007 SP1"), + (EXCHANGE_2007, "Exchange2007", "Microsoft Exchange Server 2007"), +) + class Version: """Holds information about the server version.""" @@ -231,7 +212,13 @@

    Module exchangelib.version

    @property def fullname(self): - return VERSIONS[self.api_version][1] + for build, api_version, full_name in VERSIONS: + if self.build: + if self.build.major_version != build.major_version or self.build.minor_version != build.minor_version: + continue + if self.api_version == api_version: + return full_name + raise ValueError(f"Full name for API version {self.api_version} build {self.build} is unknown") @classmethod def guess(cls, protocol, api_version_hint=None): @@ -245,20 +232,20 @@

    Module exchangelib.version

    :param protocol: :param api_version_hint: (Default value = None) """ - from .services import ResolveNames + from .properties import ENTRY_ID, EWS_ID, AlternateId + from .services import ConvertId - # The protocol doesn't have a version yet, so default to latest supported version if we don't have a hint. - api_version = api_version_hint or API_VERSIONS[0] + # The protocol doesn't have a version yet, so default to the latest supported version if we don't have a hint. + api_version = api_version_hint or ConvertId.supported_api_versions()[0] log.debug("Asking server for version info using API version %s", api_version) # We don't know the build version yet. Hopefully, the server will report it in the SOAP header. Lots of # places expect a version to have a build, so this is a bit dangerous, but passing a fake build around is also - # dangerous. Make sure the call to ResolveNames does not require a version build. + # dangerous. protocol.config.version = Version(build=None, api_version=api_version) - # Use ResolveNames as a minimal request to the server to test if the version is correct. If not, ResolveNames - # will try to guess the version automatically. - name = str(protocol.credentials) if protocol.credentials and str(protocol.credentials) else "DUMMY" + # Use ConvertId as a minimal request to the server to test if the version is correct. If not, ConvertId will + # try to guess the version automatically. Make sure the call to ConvertId does not require a version build. try: - list(ResolveNames(protocol=protocol).call(unresolved_entries=[name])) + list(ConvertId(protocol=protocol).call([AlternateId(id="DUMMY", format=EWS_ID, mailbox="DUMMY")], ENTRY_ID)) except ResponseMessageError as e: # We may have survived long enough to get a new version if not protocol.config.version.build: @@ -276,17 +263,19 @@

    Module exchangelib.version

    def from_soap_header(cls, requested_api_version, header): info = header.find(f"{{{TNS}}}ServerVersionInfo") if info is None: - raise TransportError(f"No ServerVersionInfo in header: {xml_to_str(header)!r}") + info = header.find(f"{{{ANS}}}ServerVersionInfo") + if info is None: + raise TransportError(f"No ServerVersionInfo in header: {xml_to_str(header)!r}") try: build = Build.from_xml(elem=info) except ValueError: raise TransportError(f"Bad ServerVersionInfo in response: {xml_to_str(header)!r}") # Not all Exchange servers send the Version element - api_version_from_server = info.get("Version") or build.api_version() + api_version_from_server = info.get("Version") or get_xml_attr(info, f"{{{ANS}}}Version") or build.api_version() if api_version_from_server != requested_api_version: if cls._is_invalid_version_string(api_version_from_server): # For unknown reasons, Office 365 may respond with an API version strings that is invalid in a request. - # Detect these so we can fallback to a valid version string. + # Detect these, so we can fall back to a valid version string. log.debug( 'API version "%s" worked but server reports version "%s". Using "%s"', requested_api_version, @@ -307,6 +296,11 @@

    Module exchangelib.version

    def copy(self): return self.__class__(build=self.build, api_version=self.api_version) + @classmethod + def all_versions(cls): + # Return all supported versions, sorted newest to oldest + return [cls(build=build, api_version=api_version) for build, api_version, _ in VERSIONS] + def __eq__(self, other): if self.api_version != other.api_version: return False @@ -320,7 +314,55 @@

    Module exchangelib.version

    return self.__class__.__name__ + repr((self.build, self.api_version)) def __str__(self): - return f"Build={self.build}, API={self.api_version}, Fullname={self.fullname}"
    + return f"Build={self.build}, API={self.api_version}, Fullname={self.fullname}" + + +class SupportedVersionClassMixIn: + """Supports specifying the supported versions of services, fields, folders etc. + + For distinguished folders, a possibly authoritative source is: + # https://github.com/OfficeDev/ews-managed-api/blob/master/Enumerations/WellKnownFolderName.cs + """ + + supported_from = None # The Exchange build when this element was introduced + deprecated_from = None # The Exchange build when this element was deprecated + + @classmethod + def __new__(cls, *args, **kwargs): + _check(cls.supported_from, cls.deprecated_from) + return super().__new__(cls) + + @classmethod + def supports_version(cls, version): + return _supports_version(cls.supported_from, cls.deprecated_from, version) + + +class SupportedVersionInstanceMixIn: + """Like SupportedVersionClassMixIn but for class instances""" + + def __init__(self, supported_from=None, deprecated_from=None): + _check(supported_from, deprecated_from) + self.supported_from = supported_from + self.deprecated_from = deprecated_from + + def supports_version(self, version): + return _supports_version(self.supported_from, self.deprecated_from, version) + + +def _check(supported_from, deprecated_from): + if supported_from is not None and not isinstance(supported_from, Build): + raise InvalidTypeError("supported_from", supported_from, Build) + if deprecated_from is not None and not isinstance(deprecated_from, Build): + raise InvalidTypeError("deprecated_from", deprecated_from, Build) + + +def _supports_version(supported_from, deprecated_from, version): + # 'version' is a Version instance, for convenience by callers + if supported_from and version.build < supported_from: + return False + if deprecated_from and version.build >= deprecated_from: + return False + return True
    @@ -345,28 +387,6 @@

    Classes

    class Build:
         """Holds methods for working with build numbers."""
     
    -    # List of build numbers here: https://docs.microsoft.com/en-us/exchange/new-features/build-numbers-and-release-dates
    -    API_VERSION_MAP = {
    -        8: {
    -            0: "Exchange2007",
    -            1: "Exchange2007_SP1",
    -            2: "Exchange2007_SP1",
    -            3: "Exchange2007_SP1",
    -        },
    -        14: {
    -            0: "Exchange2010",
    -            1: "Exchange2010_SP1",
    -            2: "Exchange2010_SP2",
    -            3: "Exchange2010_SP2",
    -        },
    -        15: {
    -            0: "Exchange2013",  # Minor builds starting from 847 are Exchange2013_SP1, see api_version()
    -            1: "Exchange2016",
    -            2: "Exchange2019",
    -            20: "Exchange2016",  # This is Office365. See issue #221
    -        },
    -    }
    -
         __slots__ = "major_version", "minor_version", "major_build", "minor_build"
     
         def __init__(self, major_version, minor_version, major_build=0, minor_build=0):
    @@ -397,7 +417,9 @@ 

    Classes

    for k, xml_elem in xml_elems_map.items(): v = elem.get(xml_elem) if v is None: - raise ValueError() + v = get_xml_attr(elem, f"{{{ANS}}}{xml_elem}") + if v is None: + raise ValueError() kwargs[k] = int(v) # Also raises ValueError return cls(**kwargs) @@ -422,15 +444,12 @@

    Classes

    return cls(major_version=major_version, minor_version=minor_version, major_build=build_number) def api_version(self): - if EXCHANGE_2013_SP1 <= self < EXCHANGE_2016: - return "Exchange2013_SP1" - try: - return self.API_VERSION_MAP[self.major_version][self.minor_version] - except KeyError: - raise ValueError(f"API version for build {self} is unknown") - - def fullname(self): - return VERSIONS[self.api_version()][1] + for build, api_version, _ in VERSIONS: + if self.major_version != build.major_version or self.minor_version != build.minor_version: + continue + if self >= build: + return api_version + raise ValueError(f"API version for build {self} is unknown") def __cmp__(self, other): # __cmp__ is not a magic method in Python3. We'll just use it here to implement comparison operators @@ -474,13 +493,6 @@

    Classes

    (self.major_version, self.minor_version, self.major_build, self.minor_build) )
    -

    Class variables

    -
    -
    var API_VERSION_MAP
    -
    -
    -
    -

    Static methods

    @@ -542,7 +554,9 @@

    Static methods

    for k, xml_elem in xml_elems_map.items(): v = elem.get(xml_elem) if v is None: - raise ValueError() + v = get_xml_attr(elem, f"{{{ANS}}}{xml_elem}") + if v is None: + raise ValueError() kwargs[k] = int(v) # Also raises ValueError return cls(**kwargs)
    @@ -579,16 +593,111 @@

    Methods

    Expand source code
    def api_version(self):
    -    if EXCHANGE_2013_SP1 <= self < EXCHANGE_2016:
    -        return "Exchange2013_SP1"
    -    try:
    -        return self.API_VERSION_MAP[self.major_version][self.minor_version]
    -    except KeyError:
    -        raise ValueError(f"API version for build {self} is unknown")
    + for build, api_version, _ in VERSIONS: + if self.major_version != build.major_version or self.minor_version != build.minor_version: + continue + if self >= build: + return api_version + raise ValueError(f"API version for build {self} is unknown")
    -
    -def fullname(self) +
    + +
    +class SupportedVersionClassMixIn +(*args, **kwargs) +
    +
    +

    Supports specifying the supported versions of services, fields, folders etc.

    +

    For distinguished folders, a possibly authoritative source is:

    +

    https://github.com/OfficeDev/ews-managed-api/blob/master/Enumerations/WellKnownFolderName.cs

    +
    + +Expand source code + +
    class SupportedVersionClassMixIn:
    +    """Supports specifying the supported versions of services, fields, folders etc.
    +
    +    For distinguished folders, a possibly authoritative source is:
    +    # https://github.com/OfficeDev/ews-managed-api/blob/master/Enumerations/WellKnownFolderName.cs
    +    """
    +
    +    supported_from = None  # The Exchange build when this element was introduced
    +    deprecated_from = None  # The Exchange build when this element was deprecated
    +
    +    @classmethod
    +    def __new__(cls, *args, **kwargs):
    +        _check(cls.supported_from, cls.deprecated_from)
    +        return super().__new__(cls)
    +
    +    @classmethod
    +    def supports_version(cls, version):
    +        return _supports_version(cls.supported_from, cls.deprecated_from, version)
    +
    +

    Subclasses

    + +

    Class variables

    +
    +
    var deprecated_from
    +
    +
    +
    +
    var supported_from
    +
    +
    +
    +
    +

    Static methods

    +
    +
    +def supports_version(version) +
    +
    +
    +
    + +Expand source code + +
    @classmethod
    +def supports_version(cls, version):
    +    return _supports_version(cls.supported_from, cls.deprecated_from, version)
    +
    +
    +
    +
    +
    +class SupportedVersionInstanceMixIn +(supported_from=None, deprecated_from=None) +
    +
    +

    Like SupportedVersionClassMixIn but for class instances

    +
    + +Expand source code + +
    class SupportedVersionInstanceMixIn:
    +    """Like SupportedVersionClassMixIn but for class instances"""
    +
    +    def __init__(self, supported_from=None, deprecated_from=None):
    +        _check(supported_from, deprecated_from)
    +        self.supported_from = supported_from
    +        self.deprecated_from = deprecated_from
    +
    +    def supports_version(self, version):
    +        return _supports_version(self.supported_from, self.deprecated_from, version)
    +
    +

    Subclasses

    + +

    Methods

    +
    +
    +def supports_version(self, version)
    @@ -596,8 +705,8 @@

    Methods

    Expand source code -
    def fullname(self):
    -    return VERSIONS[self.api_version()][1]
    +
    def supports_version(self, version):
    +    return _supports_version(self.supported_from, self.deprecated_from, version)
    @@ -632,7 +741,13 @@

    Methods

    @property def fullname(self): - return VERSIONS[self.api_version][1] + for build, api_version, full_name in VERSIONS: + if self.build: + if self.build.major_version != build.major_version or self.build.minor_version != build.minor_version: + continue + if self.api_version == api_version: + return full_name + raise ValueError(f"Full name for API version {self.api_version} build {self.build} is unknown") @classmethod def guess(cls, protocol, api_version_hint=None): @@ -646,20 +761,20 @@

    Methods

    :param protocol: :param api_version_hint: (Default value = None) """ - from .services import ResolveNames + from .properties import ENTRY_ID, EWS_ID, AlternateId + from .services import ConvertId - # The protocol doesn't have a version yet, so default to latest supported version if we don't have a hint. - api_version = api_version_hint or API_VERSIONS[0] + # The protocol doesn't have a version yet, so default to the latest supported version if we don't have a hint. + api_version = api_version_hint or ConvertId.supported_api_versions()[0] log.debug("Asking server for version info using API version %s", api_version) # We don't know the build version yet. Hopefully, the server will report it in the SOAP header. Lots of # places expect a version to have a build, so this is a bit dangerous, but passing a fake build around is also - # dangerous. Make sure the call to ResolveNames does not require a version build. + # dangerous. protocol.config.version = Version(build=None, api_version=api_version) - # Use ResolveNames as a minimal request to the server to test if the version is correct. If not, ResolveNames - # will try to guess the version automatically. - name = str(protocol.credentials) if protocol.credentials and str(protocol.credentials) else "DUMMY" + # Use ConvertId as a minimal request to the server to test if the version is correct. If not, ConvertId will + # try to guess the version automatically. Make sure the call to ConvertId does not require a version build. try: - list(ResolveNames(protocol=protocol).call(unresolved_entries=[name])) + list(ConvertId(protocol=protocol).call([AlternateId(id="DUMMY", format=EWS_ID, mailbox="DUMMY")], ENTRY_ID)) except ResponseMessageError as e: # We may have survived long enough to get a new version if not protocol.config.version.build: @@ -677,17 +792,19 @@

    Methods

    def from_soap_header(cls, requested_api_version, header): info = header.find(f"{{{TNS}}}ServerVersionInfo") if info is None: - raise TransportError(f"No ServerVersionInfo in header: {xml_to_str(header)!r}") + info = header.find(f"{{{ANS}}}ServerVersionInfo") + if info is None: + raise TransportError(f"No ServerVersionInfo in header: {xml_to_str(header)!r}") try: build = Build.from_xml(elem=info) except ValueError: raise TransportError(f"Bad ServerVersionInfo in response: {xml_to_str(header)!r}") # Not all Exchange servers send the Version element - api_version_from_server = info.get("Version") or build.api_version() + api_version_from_server = info.get("Version") or get_xml_attr(info, f"{{{ANS}}}Version") or build.api_version() if api_version_from_server != requested_api_version: if cls._is_invalid_version_string(api_version_from_server): # For unknown reasons, Office 365 may respond with an API version strings that is invalid in a request. - # Detect these so we can fallback to a valid version string. + # Detect these, so we can fall back to a valid version string. log.debug( 'API version "%s" worked but server reports version "%s". Using "%s"', requested_api_version, @@ -708,6 +825,11 @@

    Methods

    def copy(self): return self.__class__(build=self.build, api_version=self.api_version) + @classmethod + def all_versions(cls): + # Return all supported versions, sorted newest to oldest + return [cls(build=build, api_version=api_version) for build, api_version, _ in VERSIONS] + def __eq__(self, other): if self.api_version != other.api_version: return False @@ -725,6 +847,21 @@

    Methods

    Static methods

    +
    +def all_versions() +
    +
    +
    +
    + +Expand source code + +
    @classmethod
    +def all_versions(cls):
    +    # Return all supported versions, sorted newest to oldest
    +    return [cls(build=build, api_version=api_version) for build, api_version, _ in VERSIONS]
    +
    +
    def from_soap_header(requested_api_version, header)
    @@ -738,17 +875,19 @@

    Static methods

    def from_soap_header(cls, requested_api_version, header): info = header.find(f"{{{TNS}}}ServerVersionInfo") if info is None: - raise TransportError(f"No ServerVersionInfo in header: {xml_to_str(header)!r}") + info = header.find(f"{{{ANS}}}ServerVersionInfo") + if info is None: + raise TransportError(f"No ServerVersionInfo in header: {xml_to_str(header)!r}") try: build = Build.from_xml(elem=info) except ValueError: raise TransportError(f"Bad ServerVersionInfo in response: {xml_to_str(header)!r}") # Not all Exchange servers send the Version element - api_version_from_server = info.get("Version") or build.api_version() + api_version_from_server = info.get("Version") or get_xml_attr(info, f"{{{ANS}}}Version") or build.api_version() if api_version_from_server != requested_api_version: if cls._is_invalid_version_string(api_version_from_server): # For unknown reasons, Office 365 may respond with an API version strings that is invalid in a request. - # Detect these so we can fallback to a valid version string. + # Detect these, so we can fall back to a valid version string. log.debug( 'API version "%s" worked but server reports version "%s". Using "%s"', requested_api_version, @@ -795,20 +934,20 @@

    Static methods

    :param protocol: :param api_version_hint: (Default value = None) """ - from .services import ResolveNames + from .properties import ENTRY_ID, EWS_ID, AlternateId + from .services import ConvertId - # The protocol doesn't have a version yet, so default to latest supported version if we don't have a hint. - api_version = api_version_hint or API_VERSIONS[0] + # The protocol doesn't have a version yet, so default to the latest supported version if we don't have a hint. + api_version = api_version_hint or ConvertId.supported_api_versions()[0] log.debug("Asking server for version info using API version %s", api_version) # We don't know the build version yet. Hopefully, the server will report it in the SOAP header. Lots of # places expect a version to have a build, so this is a bit dangerous, but passing a fake build around is also - # dangerous. Make sure the call to ResolveNames does not require a version build. + # dangerous. protocol.config.version = Version(build=None, api_version=api_version) - # Use ResolveNames as a minimal request to the server to test if the version is correct. If not, ResolveNames - # will try to guess the version automatically. - name = str(protocol.credentials) if protocol.credentials and str(protocol.credentials) else "DUMMY" + # Use ConvertId as a minimal request to the server to test if the version is correct. If not, ConvertId will + # try to guess the version automatically. Make sure the call to ConvertId does not require a version build. try: - list(ResolveNames(protocol=protocol).call(unresolved_entries=[name])) + list(ConvertId(protocol=protocol).call([AlternateId(id="DUMMY", format=EWS_ID, mailbox="DUMMY")], ENTRY_ID)) except ResponseMessageError as e: # We may have survived long enough to get a new version if not protocol.config.version.build: @@ -838,7 +977,13 @@

    Instance variables

    @property
     def fullname(self):
    -    return VERSIONS[self.api_version][1]
    + for build, api_version, full_name in VERSIONS: + if self.build: + if self.build.major_version != build.major_version or self.build.minor_version != build.minor_version: + continue + if self.api_version == api_version: + return full_name + raise ValueError(f"Full name for API version {self.api_version} build {self.build} is unknown")

    @@ -878,11 +1023,9 @@

    Index

  • Build

      -
    • API_VERSION_MAP
    • api_version
    • from_hex_string
    • from_xml
    • -
    • fullname
    • major_build
    • major_version
    • minor_build
    • @@ -890,8 +1033,23 @@

    • +

      SupportedVersionClassMixIn

      + +
    • +
    • +

      SupportedVersionInstanceMixIn

      + +
    • +
    • Version

        +
      • all_versions
      • api_version
      • build
      • copy
      • @@ -909,4 +1067,4 @@

        pdoc 0.10.0.

        - \ No newline at end of file + From af8ca8a074fa7c19928e0998183907a4a4e42a25 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 23 Nov 2022 01:11:40 +0100 Subject: [PATCH 308/509] Bump version --- CHANGELOG.md | 4 ++++ exchangelib/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1bd5657..c8b40af5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ Change Log HEAD ---- + + +4.9.0 +----- - Added support for SOAP-based autodiscovery, in addition to the existing POX (plain old XML) implementation. You can specify the autodiscover implementation explicitly using the `autodiscover` argument: diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index ff68ae69..42530357 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -36,7 +36,7 @@ from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "4.8.0" +__version__ = "4.9.0" __all__ = [ "__version__", From 85e2b152bd600d133e04d399c90ee39167cbd959 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 23 Nov 2022 01:50:27 +0100 Subject: [PATCH 309/509] ci: Update secrets file --- .github/workflows/python-package.yml | 4 +++- settings.yml.ghenc | Bin 544 -> 544 bytes 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 9b605cd0..79138807 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -100,7 +100,9 @@ jobs: - name: Unencrypt secret file env: AES_256_CBC_PASS: ${{ secrets.AES_256_CBC_PASS }} - # Only repo owners have access to the secret. PRs will run only the unit tests + # Only repo owners have access to the secret. PRs will run only the unit tests. + # The encrypted file was created as: + # openssl aes-256-cbc -e -md sha256 -in settings.yml -out settings.yml.ghenc if: env.AES_256_CBC_PASS != '' run: | openssl aes-256-cbc -d -md sha256 -in settings.yml.ghenc -out settings.yml -pass env:AES_256_CBC_PASS diff --git a/settings.yml.ghenc b/settings.yml.ghenc index ceac1a42b3fdcacdb80dd150a88533250743ea3c..dd20eed71567f87f8e6154604b0f1b63b2ef3fb0 100644 GIT binary patch literal 544 zcmV+*0^j{pVQh3|WM5w;%Q_{~Go!qBbyLe6k1>l1gP=vh)|pBALAyxRueR1Ar1$>g zb>oWbG&f4kJ1D7Ouw}HcrYy-T6}cK?l{Db!NH3Wrak--g7Nch>q+!U16L|jEuQW2& z94nJ|JpKKH(&DxO4tH4&QM&K(c&OR(t>pBIB>@V9+zAD@r9R3j6n+vq_(kDl?2kD% zOz}7THsvS;YF3lDhsU+folADzw7|}k7&m7=yT*)E2PT|oZ(nr!(6}sd)yd*U;@y+w zmA9?xHAyahSQV8B8-Gsdno=locn9T%2hUhQn>rl+f_V|rV@pJA{oG-+<1&4sLiu&3 zx~95gHj4VYlfa@Q9B<(TtLp{9(LG-dfUYcZD?-h)^I>woq(#Ke+^W6MBr)<0m5u;! znxN%z7pEfVHX~Q@d33y&$$OOX%VMp@sb&0$J?_}@v9w7NlC=pQKFE7MG@}Uj5v0YI zzNdr>Xc7=Es4`7}1&25DKswS1qnt|SMoEa)vGu3Bs=tS&Y+}evho~meo!z`tAL1cJ iFT~YozTf@9V=pzFrDovL{^+5IiDMJnnSh5L5=gm$=@kwD literal 544 zcmV+*0^j{pVQh3|WM5xLrzo6^`mGJ#Pm5%-TL(=rJnB~L)eoP@u@b|9Dcg<7{l3S} ziMeB)PA8jDlu&$KqI98?^N?03;g)H8^ELYf*Gl%NJSLa#PJ_7uLW$xMXP0BFS)X>d z<>uT}lTCZ@@hsTKmJWtzl{2 zqE_xpNq!^dtbL8Eh)oNGOUWcsO_^K$`e+QQQVdKMbv$~euRyg<`X7F&&BSIyit{tnm0PMB0`0RYa&xtKE=9x2gm_MUDh{FCg>!#ySQHaobW2)Ql4lUJ*; iS8Ul!_|YkBhi{Q+OctT$t_g2SN)^f?Yb8kj-vK{nwGOWU From 48a0e790183b056421fa12e86529e657ca7e0f18 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 23 Nov 2022 02:44:30 +0100 Subject: [PATCH 310/509] feat: Remove POX autodiscovery support BREAKING CHANGE: This should be transparent to most users, but deprecates support for autodiscovery in Exchange 2007 which does not support SOAP autodiscovery --- CHANGELOG.md | 3 + exchangelib/account.py | 14 +- exchangelib/autodiscover/__init__.py | 3 +- .../{discovery/base.py => discovery.py} | 111 ++- .../autodiscover/discovery/__init__.py | 0 exchangelib/autodiscover/discovery/pox.py | 189 ---- exchangelib/autodiscover/discovery/soap.py | 106 --- exchangelib/autodiscover/properties.py | 353 -------- ...odiscover_soap.py => test_autodiscover.py} | 164 +--- tests/test_autodiscover_pox.py | 836 ------------------ 10 files changed, 115 insertions(+), 1664 deletions(-) rename exchangelib/autodiscover/{discovery/base.py => discovery.py} (82%) delete mode 100644 exchangelib/autodiscover/discovery/__init__.py delete mode 100644 exchangelib/autodiscover/discovery/pox.py delete mode 100644 exchangelib/autodiscover/discovery/soap.py delete mode 100644 exchangelib/autodiscover/properties.py rename tests/{test_autodiscover_soap.py => test_autodiscover.py} (83%) delete mode 100644 tests/test_autodiscover_pox.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c8b40af5..b350e566 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ Change Log HEAD ---- +- Make SOAP-based autodiscovery the default, and remove support for POX-based + discovery. This also removes support for autodiscovery on Exchange 2007. + Only `Account(..., autodiscover=True)` is supported again. 4.9.0 diff --git a/exchangelib/account.py b/exchangelib/account.py index a54c7b98..5dbdea7a 100644 --- a/exchangelib/account.py +++ b/exchangelib/account.py @@ -3,8 +3,7 @@ from cached_property import threaded_cached_property -from .autodiscover.discovery.pox import PoxAutodiscovery -from .autodiscover.discovery.soap import SoapAutodiscovery +from .autodiscover import Autodiscovery from .configuration import Configuration from .credentials import ACCESS_TYPES, DELEGATE, IMPERSONATION from .errors import InvalidEnumValue, InvalidTypeError, UnknownTimeZone @@ -98,8 +97,6 @@ class Identity(EWSElement): class Account: """Models an Exchange server user account.""" - DEFAULT_DISCOVERY_CLS = PoxAutodiscovery - def __init__( self, primary_smtp_address, @@ -118,7 +115,7 @@ def __init__( :param access_type: The access type granted to 'credentials' for this account. Valid options are 'delegate' and 'impersonation'. 'delegate' is default if 'credentials' is set. Otherwise, 'impersonation' is default. :param autodiscover: Whether to look up the EWS endpoint automatically using the autodiscover protocol. - Can also be set to "pox" or "soap" to choose the autodiscover implementation (Default value = False) + (Default value = False) :param credentials: A Credentials object containing valid credentials for this account. (Default value = None) :param config: A Configuration object containing EWS endpoint information. Required if autodiscover is disabled (Default value = None) @@ -165,12 +162,7 @@ def __init__( credentials = config.credentials else: auth_type, retry_policy, version = None, None, None - discovery_cls = { - "pox": PoxAutodiscovery, - "soap": SoapAutodiscovery, - True: self.DEFAULT_DISCOVERY_CLS, - }[autodiscover] - self.ad_response, self.protocol = discovery_cls( + self.ad_response, self.protocol = Autodiscovery( email=primary_smtp_address, credentials=credentials ).discover() # Let's not use the auth_package hint from the AD response. It could be incorrect and we can just guess. diff --git a/exchangelib/autodiscover/__init__.py b/exchangelib/autodiscover/__init__.py index d67473ca..acc00a8e 100644 --- a/exchangelib/autodiscover/__init__.py +++ b/exchangelib/autodiscover/__init__.py @@ -1,6 +1,5 @@ from .cache import AutodiscoverCache, autodiscover_cache -from .discovery.pox import PoxAutodiscovery as Autodiscovery -from .discovery.pox import discover +from .discovery import Autodiscovery, discover from .protocol import AutodiscoverProtocol diff --git a/exchangelib/autodiscover/discovery/base.py b/exchangelib/autodiscover/discovery.py similarity index 82% rename from exchangelib/autodiscover/discovery/base.py rename to exchangelib/autodiscover/discovery.py index c5435107..6860af92 100644 --- a/exchangelib/autodiscover/discovery/base.py +++ b/exchangelib/autodiscover/discovery.py @@ -1,4 +1,3 @@ -import abc import logging from urllib.parse import urlparse @@ -6,11 +5,13 @@ import dns.resolver from cached_property import threaded_cached_property -from ...errors import AutoDiscoverCircularRedirect, AutoDiscoverFailed, TransportError -from ...protocol import FailFast -from ...util import DummyResponse, get_domain, get_redirect_url -from ..cache import autodiscover_cache -from ..protocol import AutodiscoverProtocol +from ..configuration import Configuration +from ..errors import AutoDiscoverCircularRedirect, AutoDiscoverFailed, RedirectError, TransportError +from ..protocol import FailFast, Protocol +from ..transport import get_unauthenticated_autodiscover_response +from ..util import CONNECTION_ERRORS, DummyResponse, get_domain, get_redirect_url +from .cache import autodiscover_cache +from .protocol import AutodiscoverProtocol log = logging.getLogger(__name__) @@ -35,7 +36,14 @@ def __eq__(self, other): return all(getattr(self, k) == getattr(other, k) for k in self.__dict__) -class BaseAutodiscovery(metaclass=abc.ABCMeta): +def discover(email, credentials=None, auth_type=None, retry_policy=None): + ad_response, protocol = Autodiscovery(email=email, credentials=credentials).discover() + protocol.config.auth_typ = auth_type + protocol.config.retry_policy = retry_policy + return ad_response, protocol + + +class Autodiscovery: """Autodiscover is a Microsoft protocol for automatically getting the endpoint of the Exchange server and other connection-related settings holding the email address using only the email address, and username and password of the user. @@ -72,7 +80,7 @@ class BaseAutodiscovery(metaclass=abc.ABCMeta): "timeout": AutodiscoverProtocol.TIMEOUT / 2.5, # Timeout for query to a single nameserver } DNS_RESOLVER_LIFETIME = AutodiscoverProtocol.TIMEOUT # Total timeout for a query in case of multiple nameservers - URL_PATH = None + URL_PATH = "autodiscover/autodiscover.svc" def __init__(self, email, credentials=None): """ @@ -146,13 +154,27 @@ def resolver(self): setattr(resolver, k, v) return resolver - @abc.abstractmethod def _build_response(self, ad_response): - pass + if not ad_response.autodiscover_smtp_address: + # Autodiscover does not always return an email address. In that case, the requesting email should be used + ad_response.autodiscover_smtp_address = self.email + + protocol = Protocol( + config=Configuration( + service_endpoint=ad_response.ews_url, + credentials=self.credentials, + version=ad_response.version, + # TODO: Detect EWS service auth type somehow + ) + ) + return ad_response, protocol - @abc.abstractmethod def _quick(self, protocol): - pass + try: + user_response = protocol.get_user_settings(user=self.email) + except TransportError as e: + raise AutoDiscoverFailed(f"Response error: {e}") + return self._step_5(ad=user_response) def _redirect_url_is_valid(self, url): """Three separate responses can be “Redirect responses”: @@ -190,13 +212,66 @@ def _redirect_url_is_valid(self, url): self._redirect_count += 1 return True - @abc.abstractmethod def _get_unauthenticated_response(self, url, method="post"): - pass + """Get response from server using the given HTTP method + + :param url: + :return: + """ + # We are connecting to untrusted servers here, so take necessary precautions. + self._ensure_valid_hostname(url) + + protocol = AutodiscoverProtocol( + config=Configuration( + service_endpoint=url, + retry_policy=self.INITIAL_RETRY_POLICY, + ) + ) + return None, get_unauthenticated_autodiscover_response(protocol=protocol, method=method) - @abc.abstractmethod def _attempt_response(self, url): - pass + """Return an (is_valid_response, response) tuple. + + :param url: + :return: + """ + self._urls_visited.append(url.lower()) + log.debug("Attempting to get a valid response from %s", url) + + try: + self._ensure_valid_hostname(url) + except TransportError: + return False, None + + protocol = AutodiscoverProtocol( + config=Configuration( + service_endpoint=url, + credentials=self.credentials, + retry_policy=self.INITIAL_RETRY_POLICY, + ) + ) + try: + user_response = protocol.get_user_settings(user=self.email) + except RedirectError as e: + if self._redirect_url_is_valid(url=e.url): + # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com + # works, it seems that we should follow this URL now and try to get a valid response. + return self._attempt_response(url=e.url) + log.debug("Invalid redirect URL: %s", e.url) + return False, None + except TransportError as e: + log.debug("Failed to get a response: %s", e) + return False, None + except CONNECTION_ERRORS as e: + log.debug("Failed to get a response: %s", e) + return False, None + + # We got a valid response. Unless this is a URL redirect response, we cache the result + if not user_response.redirect_url: + cache_key = self._cache_key + log.debug("Adding cache entry for key %s: %s", cache_key, protocol.service_endpoint) + autodiscover_cache[cache_key] = protocol + return True, user_response def _ensure_valid_hostname(self, url): hostname = urlparse(url).netloc @@ -365,10 +440,6 @@ def _step_5(self, ad): # This is not explicit in the protocol, but let's raise any errors here ad.raise_errors() - if hasattr(ad, "response"): - # Hack for PoxAutodiscover - ad = ad.response - if ad.redirect_url: log.debug("Got a redirect URL: %s", ad.redirect_url) # We are diverging a bit from the protocol here. We will never get an HTTP 302 since earlier steps already diff --git a/exchangelib/autodiscover/discovery/__init__.py b/exchangelib/autodiscover/discovery/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/exchangelib/autodiscover/discovery/pox.py b/exchangelib/autodiscover/discovery/pox.py deleted file mode 100644 index 83f17bcc..00000000 --- a/exchangelib/autodiscover/discovery/pox.py +++ /dev/null @@ -1,189 +0,0 @@ -import logging -import time - -from ...configuration import Configuration -from ...errors import AutoDiscoverFailed, RedirectError, TransportError, UnauthorizedError -from ...protocol import Protocol -from ...transport import AUTH_TYPE_MAP, DEFAULT_HEADERS, GSSAPI, NOAUTH, get_auth_method_from_response -from ...util import ( - CONNECTION_ERRORS, - TLS_ERRORS, - DummyResponse, - ParseError, - _back_off_if_needed, - get_redirect_url, - post_ratelimited, -) -from ..cache import autodiscover_cache -from ..properties import Autodiscover -from ..protocol import AutodiscoverProtocol -from .base import BaseAutodiscovery - -log = logging.getLogger(__name__) - - -def discover(email, credentials=None, auth_type=None, retry_policy=None): - ad_response, protocol = PoxAutodiscovery(email=email, credentials=credentials).discover() - protocol.config.auth_typ = auth_type - protocol.config.retry_policy = retry_policy - return ad_response, protocol - - -class PoxAutodiscovery(BaseAutodiscovery): - URL_PATH = "Autodiscover/Autodiscover.xml" - - def _build_response(self, ad_response): - if not ad_response.autodiscover_smtp_address: - # Autodiscover does not always return an email address. In that case, the requesting email should be used - ad_response.user.autodiscover_smtp_address = self.email - - protocol = Protocol( - config=Configuration( - service_endpoint=ad_response.protocol.ews_url, - credentials=self.credentials, - version=ad_response.version, - auth_type=ad_response.protocol.auth_type, - ) - ) - return ad_response, protocol - - def _quick(self, protocol): - try: - r = self._get_authenticated_response(protocol=protocol) - except TransportError as e: - raise AutoDiscoverFailed(f"Response error: {e}") - if r.status_code == 200: - try: - ad = Autodiscover.from_bytes(bytes_content=r.content) - except ParseError as e: - raise AutoDiscoverFailed(f"Invalid response: {e}") - else: - return self._step_5(ad=ad) - raise AutoDiscoverFailed(f"Invalid response code: {r.status_code}") - - def _get_unauthenticated_response(self, url, method="post"): - """Get auth type by tasting headers from the server. Do POST requests be default. HEAD is too error-prone, and - some servers are set up to redirect to OWA on all requests except POST to the autodiscover endpoint. - - :param url: - :param method: (Default value = 'post') - :return: - """ - # We are connecting to untrusted servers here, so take necessary precautions. - self._ensure_valid_hostname(url) - - kwargs = dict( - url=url, headers=DEFAULT_HEADERS.copy(), allow_redirects=False, timeout=AutodiscoverProtocol.TIMEOUT - ) - if method == "post": - kwargs["data"] = Autodiscover.payload(email=self.email) - retry = 0 - t_start = time.monotonic() - while True: - _back_off_if_needed(self.INITIAL_RETRY_POLICY.back_off_until) - log.debug("Trying to get response from %s", url) - with AutodiscoverProtocol.raw_session(url) as s: - try: - r = getattr(s, method)(**kwargs) - r.close() # Release memory - break - except TLS_ERRORS as e: - # Don't retry on TLS errors. They will most likely be persistent. - raise TransportError(str(e)) - except CONNECTION_ERRORS as e: - r = DummyResponse(url=url, request_headers=kwargs["headers"]) - total_wait = time.monotonic() - t_start - if self.INITIAL_RETRY_POLICY.may_retry_on_error(response=r, wait=total_wait): - log.debug("Connection error on URL %s (retry %s, error: %s). Cool down", url, retry, e) - # Don't respect the 'Retry-After' header. We don't know if this is a useful endpoint, and we - # want autodiscover to be reasonably fast. - self.INITIAL_RETRY_POLICY.back_off(self.RETRY_WAIT) - retry += 1 - continue - log.debug("Connection error on URL %s: %s", url, e) - raise TransportError(str(e)) - try: - auth_type = get_auth_method_from_response(response=r) - except UnauthorizedError: - # Failed to guess the auth type - auth_type = NOAUTH - if r.status_code in (301, 302) and "location" in r.headers: - # Make the redirect URL absolute - try: - r.headers["location"] = get_redirect_url(r) - except TransportError: - del r.headers["location"] - return auth_type, r - - def _get_authenticated_response(self, protocol): - """Get a response by using the credentials provided. We guess the auth type along the way. - - :param protocol: - :return: - """ - # Redo the request with the correct auth - data = Autodiscover.payload(email=self.email) - headers = DEFAULT_HEADERS.copy() - session = protocol.get_session() - if GSSAPI in AUTH_TYPE_MAP and isinstance(session.auth, AUTH_TYPE_MAP[GSSAPI]): - # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/pox-autodiscover-request-for-exchange - headers["X-ClientCanHandle"] = "Negotiate" - try: - r, session = post_ratelimited( - protocol=protocol, - session=session, - url=protocol.service_endpoint, - headers=headers, - data=data, - ) - protocol.release_session(session) - except UnauthorizedError as e: - # It's entirely possible for the endpoint to ask for login. We should continue if login fails because this - # isn't necessarily the right endpoint to use. - raise TransportError(str(e)) - except RedirectError as e: - r = DummyResponse(url=protocol.service_endpoint, headers={"location": e.url}, status_code=302) - return r - - def _attempt_response(self, url): - """Return an (is_valid_response, response) tuple. - - :param url: - :return: - """ - self._urls_visited.append(url.lower()) - log.debug("Attempting to get a valid response from %s", url) - try: - auth_type, r = self._get_unauthenticated_response(url=url) - ad_protocol = AutodiscoverProtocol( - config=Configuration( - service_endpoint=url, - credentials=self.credentials, - auth_type=auth_type, - retry_policy=self.INITIAL_RETRY_POLICY, - ) - ) - if auth_type != NOAUTH: - r = self._get_authenticated_response(protocol=ad_protocol) - except TransportError as e: - log.debug("Failed to get a response: %s", e) - return False, None - if r.status_code in (301, 302) and "location" in r.headers: - redirect_url = get_redirect_url(r) - if self._redirect_url_is_valid(url=redirect_url): - # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com - # works, it seems that we should follow this URL now and try to get a valid response. - return self._attempt_response(url=redirect_url) - if r.status_code == 200: - try: - ad = Autodiscover.from_bytes(bytes_content=r.content) - except ParseError as e: - log.debug("Invalid response: %s", e) - else: - # We got a valid response. Unless this is a URL redirect response, we cache the result - if ad.response is None or not ad.response.redirect_url: - cache_key = self._cache_key - log.debug("Adding cache entry for key %s: %s", cache_key, ad_protocol.service_endpoint) - autodiscover_cache[cache_key] = ad_protocol - return True, ad - return False, None diff --git a/exchangelib/autodiscover/discovery/soap.py b/exchangelib/autodiscover/discovery/soap.py deleted file mode 100644 index a8d799e3..00000000 --- a/exchangelib/autodiscover/discovery/soap.py +++ /dev/null @@ -1,106 +0,0 @@ -import logging - -from ...configuration import Configuration -from ...errors import AutoDiscoverFailed, RedirectError, TransportError -from ...protocol import Protocol -from ...transport import get_unauthenticated_autodiscover_response -from ...util import CONNECTION_ERRORS -from ..cache import autodiscover_cache -from ..protocol import AutodiscoverProtocol -from .base import BaseAutodiscovery - -log = logging.getLogger(__name__) - - -def discover(email, credentials=None, auth_type=None, retry_policy=None): - ad_response, protocol = SoapAutodiscovery(email=email, credentials=credentials).discover() - protocol.config.auth_typ = auth_type - protocol.config.retry_policy = retry_policy - return ad_response, protocol - - -class SoapAutodiscovery(BaseAutodiscovery): - URL_PATH = "autodiscover/autodiscover.svc" - - def _build_response(self, ad_response): - if not ad_response.autodiscover_smtp_address: - # Autodiscover does not always return an email address. In that case, the requesting email should be used - ad_response.autodiscover_smtp_address = self.email - - protocol = Protocol( - config=Configuration( - service_endpoint=ad_response.ews_url, - credentials=self.credentials, - version=ad_response.version, - # TODO: Detect EWS service auth type somehow - ) - ) - return ad_response, protocol - - def _quick(self, protocol): - try: - user_response = protocol.get_user_settings(user=self.email) - except TransportError as e: - raise AutoDiscoverFailed(f"Response error: {e}") - return self._step_5(ad=user_response) - - def _get_unauthenticated_response(self, url, method="post"): - """Get response from server using the given HTTP method - - :param url: - :return: - """ - # We are connecting to untrusted servers here, so take necessary precautions. - self._ensure_valid_hostname(url) - - protocol = AutodiscoverProtocol( - config=Configuration( - service_endpoint=url, - retry_policy=self.INITIAL_RETRY_POLICY, - ) - ) - return None, get_unauthenticated_autodiscover_response(protocol=protocol, method=method) - - def _attempt_response(self, url): - """Return an (is_valid_response, response) tuple. - - :param url: - :return: - """ - self._urls_visited.append(url.lower()) - log.debug("Attempting to get a valid response from %s", url) - - try: - self._ensure_valid_hostname(url) - except TransportError: - return False, None - - protocol = AutodiscoverProtocol( - config=Configuration( - service_endpoint=url, - credentials=self.credentials, - retry_policy=self.INITIAL_RETRY_POLICY, - ) - ) - try: - user_response = protocol.get_user_settings(user=self.email) - except RedirectError as e: - if self._redirect_url_is_valid(url=e.url): - # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com - # works, it seems that we should follow this URL now and try to get a valid response. - return self._attempt_response(url=e.url) - log.debug("Invalid redirect URL: %s", e.url) - return False, None - except TransportError as e: - log.debug("Failed to get a response: %s", e) - return False, None - except CONNECTION_ERRORS as e: - log.debug("Failed to get a response: %s", e) - return False, None - - # We got a valid response. Unless this is a URL redirect response, we cache the result - if not user_response.redirect_url: - cache_key = self._cache_key - log.debug("Adding cache entry for key %s: %s", cache_key, protocol.service_endpoint) - autodiscover_cache[cache_key] = protocol - return True, user_response diff --git a/exchangelib/autodiscover/properties.py b/exchangelib/autodiscover/properties.py deleted file mode 100644 index 8f4665b9..00000000 --- a/exchangelib/autodiscover/properties.py +++ /dev/null @@ -1,353 +0,0 @@ -from ..errors import AutoDiscoverFailed, ErrorNonExistentMailbox -from ..fields import ( - BooleanField, - BuildField, - Choice, - ChoiceField, - EmailAddressField, - EWSElementField, - IntegerField, - OnOffField, - ProtocolListField, - TextField, -) -from ..properties import EWSElement -from ..transport import BASIC, CBA, DEFAULT_ENCODING, GSSAPI, NOAUTH, NTLM, SSPI -from ..util import AUTODISCOVER_BASE_NS, AUTODISCOVER_REQUEST_NS -from ..util import AUTODISCOVER_RESPONSE_NS as RNS -from ..util import ParseError, add_xml_child, create_element, is_xml, to_xml, xml_to_str -from ..version import Version - - -class AutodiscoverBase(EWSElement): - NAMESPACE = RNS - - -class User(AutodiscoverBase): - """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/user-pox""" - - ELEMENT_NAME = "User" - - display_name = TextField(field_uri="DisplayName", namespace=RNS) - legacy_dn = TextField(field_uri="LegacyDN", namespace=RNS) - deployment_id = TextField(field_uri="DeploymentId", namespace=RNS) # GUID format - autodiscover_smtp_address = EmailAddressField(field_uri="AutoDiscoverSMTPAddress", namespace=RNS) - - -class IntExtUrlBase(AutodiscoverBase): - external_url = TextField(field_uri="ExternalUrl", namespace=RNS) - internal_url = TextField(field_uri="InternalUrl", namespace=RNS) - - -class AddressBook(IntExtUrlBase): - """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/addressbook-pox""" - - ELEMENT_NAME = "AddressBook" - - -class MailStore(IntExtUrlBase): - """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailstore-pox""" - - ELEMENT_NAME = "MailStore" - - -class NetworkRequirements(AutodiscoverBase): - """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/networkrequirements-pox""" - - ELEMENT_NAME = "NetworkRequirements" - - ipv4_start = TextField(field_uri="IPv4Start", namespace=RNS) - ipv4_end = TextField(field_uri="IPv4End", namespace=RNS) - ipv6_start = TextField(field_uri="IPv6Start", namespace=RNS) - ipv6_end = TextField(field_uri="IPv6End", namespace=RNS) - - -class SimpleProtocol(AutodiscoverBase): - """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/protocol-pox - - Used for the 'Internal' and 'External' elements that may contain a stripped-down version of the Protocol element. - """ - - ELEMENT_NAME = "Protocol" - WEB = "WEB" - EXCH = "EXCH" - EXPR = "EXPR" - EXHTTP = "EXHTTP" - TYPES = (WEB, EXCH, EXPR, EXHTTP) - - type = ChoiceField(field_uri="Type", choices={Choice(c) for c in TYPES}, namespace=RNS) - as_url = TextField(field_uri="ASUrl", namespace=RNS) - - -class IntExtBase(AutodiscoverBase): - # TODO: 'OWAUrl' also has an AuthenticationMethod enum-style XML attribute with values: - # WindowsIntegrated, FBA, NTLM, Digest, Basic, LiveIdFba, OAuth - owa_url = TextField(field_uri="OWAUrl", namespace=RNS) - protocol = EWSElementField(value_cls=SimpleProtocol) - - -class Internal(IntExtBase): - """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/internal-pox""" - - ELEMENT_NAME = "Internal" - - -class External(IntExtBase): - """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/external-pox""" - - ELEMENT_NAME = "External" - - -class Protocol(SimpleProtocol): - """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/protocol-pox""" - - # Attribute 'Type' is ignored here. Has a name conflict with the child element and does not seem useful. - version = TextField(field_uri="Version", is_attribute=True, namespace=RNS) - internal = EWSElementField(value_cls=Internal) - external = EWSElementField(value_cls=External) - ttl = IntegerField(field_uri="TTL", namespace=RNS, default=1) # TTL for this autodiscover response, in hours - server = TextField(field_uri="Server", namespace=RNS) - server_dn = TextField(field_uri="ServerDN", namespace=RNS) - server_version = BuildField(field_uri="ServerVersion", namespace=RNS) - mdb_dn = TextField(field_uri="MdbDN", namespace=RNS) - public_folder_server = TextField(field_uri="PublicFolderServer", namespace=RNS) - port = IntegerField(field_uri="Port", namespace=RNS, min=1, max=65535) - directory_port = IntegerField(field_uri="DirectoryPort", namespace=RNS, min=1, max=65535) - referral_port = IntegerField(field_uri="ReferralPort", namespace=RNS, min=1, max=65535) - ews_url = TextField(field_uri="EwsUrl", namespace=RNS) - emws_url = TextField(field_uri="EmwsUrl", namespace=RNS) - sharing_url = TextField(field_uri="SharingUrl", namespace=RNS) - ecp_url = TextField(field_uri="EcpUrl", namespace=RNS) - ecp_url_um = TextField(field_uri="EcpUrl-um", namespace=RNS) - ecp_url_aggr = TextField(field_uri="EcpUrl-aggr", namespace=RNS) - ecp_url_mt = TextField(field_uri="EcpUrl-mt", namespace=RNS) - ecp_url_ret = TextField(field_uri="EcpUrl-ret", namespace=RNS) - ecp_url_sms = TextField(field_uri="EcpUrl-sms", namespace=RNS) - ecp_url_publish = TextField(field_uri="EcpUrl-publish", namespace=RNS) - ecp_url_photo = TextField(field_uri="EcpUrl-photo", namespace=RNS) - ecp_url_tm = TextField(field_uri="EcpUrl-tm", namespace=RNS) - ecp_url_tm_creating = TextField(field_uri="EcpUrl-tmCreating", namespace=RNS) - ecp_url_tm_hiding = TextField(field_uri="EcpUrl-tmHiding", namespace=RNS) - ecp_url_tm_editing = TextField(field_uri="EcpUrl-tmEditing", namespace=RNS) - ecp_url_extinstall = TextField(field_uri="EcpUrl-extinstall", namespace=RNS) - oof_url = TextField(field_uri="OOFUrl", namespace=RNS) - oab_url = TextField(field_uri="OABUrl", namespace=RNS) - um_url = TextField(field_uri="UMUrl", namespace=RNS) - ews_partner_url = TextField(field_uri="EwsPartnerUrl", namespace=RNS) - login_name = TextField(field_uri="LoginName", namespace=RNS) - domain_required = OnOffField(field_uri="DomainRequired", namespace=RNS) - domain_name = TextField(field_uri="DomainName", namespace=RNS) - spa = OnOffField(field_uri="SPA", namespace=RNS, default=True) - auth_package = ChoiceField( - field_uri="AuthPackage", - namespace=RNS, - choices={Choice(c) for c in ("basic", "kerb", "kerbntlm", "ntlm", "certificate", "negotiate", "nego2")}, - ) - cert_principal_name = TextField(field_uri="CertPrincipalName", namespace=RNS) - ssl = OnOffField(field_uri="SSL", namespace=RNS, default=True) - auth_required = OnOffField(field_uri="AuthRequired", namespace=RNS, default=True) - use_pop_path = OnOffField(field_uri="UsePOPAuth", namespace=RNS) - smtp_last = OnOffField(field_uri="SMTPLast", namespace=RNS, default=False) - network_requirements = EWSElementField(value_cls=NetworkRequirements) - address_book = EWSElementField(value_cls=AddressBook) - mail_store = EWSElementField(value_cls=MailStore) - - @property - def auth_type(self): - # Translates 'auth_package' value to our own 'auth_type' enum vals - if not self.auth_required: - return NOAUTH - if not self.auth_package: - return None - return { - # Missing in list are DIGEST and OAUTH2 - "basic": BASIC, - "kerb": GSSAPI, - "kerbntlm": NTLM, # Means client can choose between NTLM and GSSAPI - "ntlm": NTLM, - "certificate": CBA, - "negotiate": SSPI, # Unsure about this one - "nego2": GSSAPI, - "anonymous": NOAUTH, # Seen in some docs even though it's not mentioned in MSDN - }.get(self.auth_package.lower()) - - -class Error(EWSElement): - """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/error-pox""" - - ELEMENT_NAME = "Error" - NAMESPACE = AUTODISCOVER_BASE_NS - - id = TextField(field_uri="Id", namespace=AUTODISCOVER_BASE_NS, is_attribute=True) - time = TextField(field_uri="Time", namespace=AUTODISCOVER_BASE_NS, is_attribute=True) - code = TextField(field_uri="ErrorCode", namespace=AUTODISCOVER_BASE_NS) - message = TextField(field_uri="Message", namespace=AUTODISCOVER_BASE_NS) - debug_data = TextField(field_uri="DebugData", namespace=AUTODISCOVER_BASE_NS) - - -class Account(AutodiscoverBase): - """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/account-pox""" - - ELEMENT_NAME = "Account" - REDIRECT_URL = "redirectUrl" - REDIRECT_ADDR = "redirectAddr" - SETTINGS = "settings" - ACTIONS = (REDIRECT_URL, REDIRECT_ADDR, SETTINGS) - - type = ChoiceField(field_uri="AccountType", namespace=RNS, choices={Choice("email")}) - action = ChoiceField(field_uri="Action", namespace=RNS, choices={Choice(p) for p in ACTIONS}) - microsoft_online = BooleanField(field_uri="MicrosoftOnline", namespace=RNS) - redirect_url = TextField(field_uri="RedirectURL", namespace=RNS) - redirect_address = EmailAddressField(field_uri="RedirectAddr", namespace=RNS) - image = TextField(field_uri="Image", namespace=RNS) # Path to image used for branding - service_home = TextField(field_uri="ServiceHome", namespace=RNS) # URL to website of ISP - protocols = ProtocolListField() - # 'SmtpAddress' is inside the 'PublicFolderInformation' element - public_folder_smtp_address = TextField(field_uri="SmtpAddress", namespace=RNS) - - @classmethod - def from_xml(cls, elem, account): - kwargs = {} - public_folder_information = elem.find(f"{{{cls.NAMESPACE}}}PublicFolderInformation") - for f in cls.FIELDS: - if f.name == "public_folder_smtp_address": - if public_folder_information is None: - continue - kwargs[f.name] = f.from_xml(elem=public_folder_information, account=account) - continue - kwargs[f.name] = f.from_xml(elem=elem, account=account) - cls._clear(elem) - return cls(**kwargs) - - -class Response(AutodiscoverBase): - """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/response-pox""" - - ELEMENT_NAME = "Response" - - user = EWSElementField(value_cls=User) - account = EWSElementField(value_cls=Account) - - @property - def redirect_address(self): - try: - if self.account.action != Account.REDIRECT_ADDR: - return None - return self.account.redirect_address - except AttributeError: - return None - - @property - def redirect_url(self): - try: - if self.account.action != Account.REDIRECT_URL: - return None - return self.account.redirect_url - except AttributeError: - return None - - @property - def autodiscover_smtp_address(self): - # AutoDiscoverSMTPAddress might not be present in the XML. In this case, use the original email address. - try: - if self.account.action != Account.SETTINGS: - return None - return self.user.autodiscover_smtp_address - except AttributeError: - return None - - @property - def version(self): - # Get the server version. Not all protocol entries have a server version, so we cheat a bit and also look at the - # other ones that point to the same endpoint. - ews_url = self.protocol.ews_url - for protocol in self.account.protocols: - if not protocol.ews_url or not protocol.server_version: - continue - if protocol.ews_url.lower() == ews_url.lower(): - return Version(build=protocol.server_version) - return None - - @property - def protocol(self): - """Return the protocol containing an EWS URL. - - A response may contain a number of possible protocol types. EXPR is meant for EWS. See - https://techcommunity.microsoft.com/t5/blogs/blogarticleprintpage/blog-id/Exchange/article-id/16 - - We allow fallback to EXCH if EXPR is not available, to support installations where EXPR is not available. - - Additionally, some responses may contain an EXPR with no EWS URL. In that case, return EXCH, if available. - """ - protocols = {p.type: p for p in self.account.protocols if p.ews_url} - if Protocol.EXPR in protocols: - return protocols[Protocol.EXPR] - if Protocol.EXCH in protocols: - return protocols[Protocol.EXCH] - raise ValueError( - f"No EWS URL found in any of the available protocols: {[str(p) for p in self.account.protocols]}" - ) - - -class ErrorResponse(EWSElement): - """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/response-pox - - Like 'Response', but with a different namespace. - """ - - ELEMENT_NAME = "Response" - NAMESPACE = AUTODISCOVER_BASE_NS - - error = EWSElementField(value_cls=Error) - - -class Autodiscover(EWSElement): - ELEMENT_NAME = "Autodiscover" - NAMESPACE = AUTODISCOVER_BASE_NS - - response = EWSElementField(value_cls=Response) - error_response = EWSElementField(value_cls=ErrorResponse) - - @staticmethod - def _clear(elem): - # Parent implementation also clears the parent, but this element doesn't have one. - elem.clear() - - @classmethod - def from_bytes(cls, bytes_content): - """Create an instance from response bytes. An Autodiscover request and response example is available at: - https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/pox-autodiscover-response-for-exchange - - :param bytes_content: - :return: - """ - if not is_xml(bytes_content): - raise ParseError(f"Response is not XML: {bytes_content}", "", -1, 0) - root = to_xml(bytes_content).getroot() # May raise ParseError - if root.tag != cls.response_tag(): - raise ParseError(f"Unknown root element in XML: {bytes_content}", "", -1, 0) - return cls.from_xml(elem=root, account=None) - - def raise_errors(self): - if self.response is not None: - return - - # Find an error message in the response and raise the relevant exception - try: - error_code = self.error_response.error.code - message = self.error_response.error.message - if message in ("The e-mail address cannot be found.", "The email address can't be found."): - raise ErrorNonExistentMailbox("The SMTP address has no mailbox associated with it") - raise AutoDiscoverFailed(f"Unknown error {error_code}: {message}") - except AttributeError: - raise AutoDiscoverFailed(f"Unknown autodiscover error response: {self.error_response}") - - @staticmethod - def payload(email): - # Builds a full Autodiscover XML request - payload = create_element("Autodiscover", attrs=dict(xmlns=AUTODISCOVER_REQUEST_NS)) - request = create_element("Request") - add_xml_child(request, "EMailAddress", email) - add_xml_child(request, "AcceptableResponseSchema", RNS) - payload.append(request) - return xml_to_str(payload, encoding=DEFAULT_ENCODING, xml_declaration=True) diff --git a/tests/test_autodiscover_soap.py b/tests/test_autodiscover.py similarity index 83% rename from tests/test_autodiscover_soap.py rename to tests/test_autodiscover.py index fb5f6d96..959d2e8e 100644 --- a/tests/test_autodiscover_soap.py +++ b/tests/test_autodiscover.py @@ -10,23 +10,21 @@ from exchangelib.account import Account from exchangelib.autodiscover import clear_cache, close_connections from exchangelib.autodiscover.cache import AutodiscoverCache, autodiscover_cache, shelve_filename -from exchangelib.autodiscover.discovery.base import SrvRecord, _select_srv_host -from exchangelib.autodiscover.discovery.soap import SoapAutodiscovery as Autodiscovery -from exchangelib.autodiscover.properties import Account as ADAccount -from exchangelib.autodiscover.properties import Autodiscover, Error, ErrorResponse, Response +from exchangelib.autodiscover.discovery import Autodiscovery, SrvRecord, _select_srv_host from exchangelib.autodiscover.protocol import AutodiscoverProtocol from exchangelib.configuration import Configuration from exchangelib.credentials import DELEGATE, Credentials from exchangelib.errors import AutoDiscoverCircularRedirect, AutoDiscoverFailed, ErrorNonExistentMailbox +from exchangelib.properties import UserResponse from exchangelib.protocol import FailFast, FaultTolerance from exchangelib.transport import NTLM -from exchangelib.util import ParseError, get_domain +from exchangelib.util import get_domain from exchangelib.version import EXCHANGE_2013, Version from .common import EWSTest, get_random_hostname, get_random_string -class AutodiscoverSoapTest(EWSTest): +class AutodiscoverTest(EWSTest): def setUp(self): if not self.account: self.skipTest("SOAP autodiscover requires delegate credentials") @@ -189,15 +187,6 @@ def test_magic(self, m): str(protocol) repr(protocol) - def test_response_properties(self): - # Test edge cases of Response properties - self.assertEqual(Response().redirect_address, None) - self.assertEqual(Response(account=ADAccount(action=ADAccount.REDIRECT_URL)).redirect_address, None) - self.assertEqual(Response().redirect_url, None) - self.assertEqual(Response(account=ADAccount(action=ADAccount.SETTINGS)).redirect_url, None) - self.assertEqual(Response().autodiscover_smtp_address, None) - self.assertEqual(Response(account=ADAccount(action=ADAccount.REDIRECT_ADDR)).autodiscover_smtp_address, None) - def test_autodiscover_empty_cache(self): # A live test of the entire process with an empty cache ad_response, protocol = Autodiscovery( @@ -231,7 +220,7 @@ def test_failed_login_via_account(self): primary_smtp_address=self.account.primary_smtp_address, access_type=DELEGATE, credentials=Credentials("john@example.com", "WRONG_PASSWORD"), - autodiscover="soap", + autodiscover=True, locale="da_DK", ) @@ -290,7 +279,7 @@ def _mock(slf, *args, **kwargs): discovery._step_1 = MethodType(_mock, discovery) discovery.discover() - # Fake that another thread added the cache entry into the persistent storage but we don't have it in our + # Fake that another thread added the cache entry into the persistent storage, but we don't have it in our # in-memory cache. The cache should work anyway. autodiscover_cache._protocols.clear() discovery.discover() @@ -335,7 +324,7 @@ def test_autodiscover_from_account(self, m, _): retry_policy=self.retry_policy, version=Version(build=EXCHANGE_2013), ), - autodiscover="soap", + autodiscover=True, locale="da_DK", ) self.assertEqual(account.primary_smtp_address, self.account.primary_smtp_address) @@ -350,7 +339,7 @@ def test_autodiscover_from_account(self, m, _): credentials=self.account.protocol.credentials, retry_policy=self.retry_policy, ), - autodiscover="soap", + autodiscover=True, locale="da_DK", ) self.assertEqual(account.primary_smtp_address, self.account.primary_smtp_address) @@ -686,133 +675,16 @@ def test_select_srv_host(self): "10.example.com", ) - def test_parse_response(self): - # Test parsing of various XML responses - with self.assertRaises(ParseError) as e: - Autodiscover.from_bytes(b"XXX") # Invalid response - self.assertEqual(e.exception.args[0], "Response is not XML: b'XXX'") - - xml = b"""bar""" - with self.assertRaises(ParseError) as e: - Autodiscover.from_bytes(xml) # Invalid XML response - self.assertEqual( - e.exception.args[0], - 'Unknown root element in XML: b\'bar\'', - ) - - # Redirect to different email address - xml = b"""\ - - - - - john@demo.affect-it.dk - - - redirectAddr - foo@example.com - - -""" - self.assertEqual(Autodiscover.from_bytes(xml).response.redirect_address, "foo@example.com") - - # Redirect to different URL - xml = b"""\ - - - - - john@demo.affect-it.dk - - - redirectUrl - https://example.com/foo.asmx - - -""" - self.assertEqual(Autodiscover.from_bytes(xml).response.redirect_url, "https://example.com/foo.asmx") - - # Select EXPR if it's there, and there are multiple available - xml = b"""\ - - - - - john@demo.affect-it.dk - - - email - settings - - EXCH - https://exch.example.com/EWS/Exchange.asmx - - - EXPR - https://expr.example.com/EWS/Exchange.asmx - - - -""" - self.assertEqual( - Autodiscover.from_bytes(xml).response.protocol.ews_url, "https://expr.example.com/EWS/Exchange.asmx" - ) - - # Select EXPR if EXPR is unavailable - xml = b"""\ - - - - - john@demo.affect-it.dk - - - email - settings - - EXCH - https://exch.example.com/EWS/Exchange.asmx - - - -""" - self.assertEqual( - Autodiscover.from_bytes(xml).response.protocol.ews_url, "https://exch.example.com/EWS/Exchange.asmx" - ) - - # Fail if neither EXPR nor EXPR are unavailable - xml = b"""\ - - - - - john@demo.affect-it.dk - - - email - settings - - XXX - https://xxx.example.com/EWS/Exchange.asmx - - - -""" - with self.assertRaises(ValueError): - _ = Autodiscover.from_bytes(xml).response.protocol.ews_url - def test_raise_errors(self): + UserResponse().raise_errors() + with self.assertRaises(ErrorNonExistentMailbox) as e: + UserResponse(error_code="InvalidUser", error_message="Foo").raise_errors() with self.assertRaises(AutoDiscoverFailed) as e: - Autodiscover().raise_errors() - self.assertEqual(e.exception.args[0], "Unknown autodiscover error response: None") + UserResponse(error_code="InvalidRequest", error_message="FOO").raise_errors() + self.assertEqual(e.exception.args[0], "InvalidRequest: FOO") with self.assertRaises(AutoDiscoverFailed) as e: - Autodiscover(error_response=ErrorResponse(error=Error(code="YYY", message="XXX"))).raise_errors() - self.assertEqual(e.exception.args[0], "Unknown error YYY: XXX") - with self.assertRaises(ErrorNonExistentMailbox) as e: - Autodiscover( - error_response=ErrorResponse(error=Error(message="The e-mail address cannot be found.")) - ).raise_errors() - self.assertEqual(e.exception.args[0], "The SMTP address has no mailbox associated with it") + UserResponse(user_settings_errors={"FOO": "BAR"}).raise_errors() + self.assertEqual(e.exception.args[0], "User settings errors: {'FOO': 'BAR'}") def test_del_on_error(self): # Test that __del__ can handle exceptions on close() @@ -870,14 +742,12 @@ def test_protocol_default_values(self): self.get_account() a = Account( self.account.primary_smtp_address, - autodiscover="soap", + autodiscover=True, config=self.account.protocol.config, ) self.assertIsNotNone(a.protocol.auth_type) self.assertIsNotNone(a.protocol.retry_policy) - a = Account( - self.account.primary_smtp_address, autodiscover="soap", credentials=self.account.protocol.credentials - ) + a = Account(self.account.primary_smtp_address, autodiscover=True, credentials=self.account.protocol.credentials) self.assertIsNotNone(a.protocol.auth_type) self.assertIsNotNone(a.protocol.retry_policy) diff --git a/tests/test_autodiscover_pox.py b/tests/test_autodiscover_pox.py deleted file mode 100644 index a5b8080d..00000000 --- a/tests/test_autodiscover_pox.py +++ /dev/null @@ -1,836 +0,0 @@ -import getpass -import sys -from collections import namedtuple -from types import MethodType -from unittest.mock import Mock, patch - -import dns -import requests_mock - -from exchangelib.account import Account -from exchangelib.autodiscover import clear_cache, close_connections -from exchangelib.autodiscover.cache import AutodiscoverCache, autodiscover_cache, shelve_filename -from exchangelib.autodiscover.discovery.base import SrvRecord, _select_srv_host -from exchangelib.autodiscover.discovery.pox import PoxAutodiscovery as Autodiscovery -from exchangelib.autodiscover.discovery.pox import discover -from exchangelib.autodiscover.properties import Account as ADAccount -from exchangelib.autodiscover.properties import Autodiscover, Error, ErrorResponse, Response -from exchangelib.autodiscover.protocol import AutodiscoverProtocol -from exchangelib.configuration import Configuration -from exchangelib.credentials import DELEGATE, Credentials, OAuth2LegacyCredentials -from exchangelib.errors import AutoDiscoverCircularRedirect, AutoDiscoverFailed, ErrorNonExistentMailbox -from exchangelib.protocol import FailFast, FaultTolerance -from exchangelib.transport import NTLM -from exchangelib.util import ParseError, get_domain -from exchangelib.version import EXCHANGE_2013, Version - -from .common import EWSTest, get_random_hostname, get_random_string - - -class AutodiscoverPoxTest(EWSTest): - def setUp(self): - if not self.account: - self.skipTest("POX autodiscover requires delegate credentials") - - super().setUp() - - # Enable retries, to make tests more robust - Autodiscovery.INITIAL_RETRY_POLICY = FaultTolerance(max_wait=5) - Autodiscovery.RETRY_WAIT = 5 - - # Each test should start with a clean autodiscover cache - clear_cache() - - # Some mocking helpers - self.domain = get_domain(self.account.primary_smtp_address) - self.dummy_ad_endpoint = f"https://{self.domain}/Autodiscover/Autodiscover.xml" - self.dummy_ews_endpoint = "https://expr.example.com/EWS/Exchange.asmx" - self.dummy_ad_response = self.settings_xml(self.account.primary_smtp_address, self.dummy_ews_endpoint) - - @classmethod - def get_account(cls): - if cls.settings.get("client_id") and cls.settings["username"]: - credentials = OAuth2LegacyCredentials( - client_id=cls.settings["client_id"], - client_secret=cls.settings["client_secret"], - tenant_id=cls.settings["tenant_id"], - username=cls.settings["username"], - password=cls.settings["password"], - ) - config = Configuration( - server=cls.settings["server"], - credentials=credentials, - retry_policy=cls.retry_policy, - ) - return Account( - primary_smtp_address=cls.settings["account"], - access_type=DELEGATE, - config=config, - locale="da_DK", - default_timezone=cls.tz, - ) - return None - - @staticmethod - def settings_xml(address, ews_url): - return f"""\ - - - - - {address} - - - email - settings - - EXPR - {ews_url} - - - -""".encode() - - @staticmethod - def redirect_address_xml(address): - return f"""\ - - - - - redirectAddr - {address} - - -""".encode() - - @staticmethod - def redirect_url_xml(ews_url): - return f"""\ - - - - - redirectUrl - {ews_url} - - -""".encode() - - @staticmethod - def get_test_protocol(**kwargs): - return AutodiscoverProtocol( - config=Configuration( - service_endpoint=kwargs.get("service_endpoint", "https://example.com/Autodiscover/Autodiscover.xml"), - credentials=kwargs.get("credentials", Credentials(get_random_string(8), get_random_string(8))), - auth_type=kwargs.get("auth_type", NTLM), - retry_policy=kwargs.get("retry_policy", FailFast()), - ) - ) - - @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here - def test_magic(self, m): - # Just test we don't fail when calling repr() and str(). Insert a dummy cache entry for testing - p = self.get_test_protocol() - autodiscover_cache[(p.config.server, p.config.credentials)] = p - self.assertEqual(len(autodiscover_cache), 1) - str(autodiscover_cache) - repr(autodiscover_cache) - for protocol in autodiscover_cache._protocols.values(): - str(protocol) - repr(protocol) - - def test_response_properties(self): - # Test edge cases of Response properties - self.assertEqual(Response().redirect_address, None) - self.assertEqual(Response(account=ADAccount(action=ADAccount.REDIRECT_URL)).redirect_address, None) - self.assertEqual(Response().redirect_url, None) - self.assertEqual(Response(account=ADAccount(action=ADAccount.SETTINGS)).redirect_url, None) - self.assertEqual(Response().autodiscover_smtp_address, None) - self.assertEqual(Response(account=ADAccount(action=ADAccount.REDIRECT_ADDR)).autodiscover_smtp_address, None) - - def test_autodiscover_empty_cache(self): - # A live test of the entire process with an empty cache - ad_response, protocol = discover( - email=self.account.primary_smtp_address, - credentials=self.account.protocol.credentials, - retry_policy=self.retry_policy, - ) - self.assertEqual(ad_response.autodiscover_smtp_address, self.account.primary_smtp_address) - self.assertEqual(protocol.service_endpoint.lower(), self.account.protocol.service_endpoint.lower()) - - def test_autodiscover_failure(self): - # A live test that errors can be raised. Here, we try to autodiscover a non-existing email address - if not self.settings.get("autodiscover_server"): - self.skipTest(f"Skipping {self.__class__.__name__} - no 'autodiscover_server' entry in settings.yml") - # Autodiscovery may take a long time. Prime the cache with the autodiscover server from the config file - ad_endpoint = f"https://{self.settings['autodiscover_server']}/Autodiscover/Autodiscover.xml" - cache_key = (self.domain, self.account.protocol.credentials) - autodiscover_cache[cache_key] = self.get_test_protocol( - service_endpoint=ad_endpoint, - credentials=self.account.protocol.credentials, - retry_policy=self.retry_policy, - ) - with self.assertRaises(ErrorNonExistentMailbox): - discover( - email="XXX." + self.account.primary_smtp_address, - credentials=self.account.protocol.credentials, - retry_policy=self.retry_policy, - ) - - def test_failed_login_via_account(self): - with self.assertRaises(AutoDiscoverFailed): - Account( - primary_smtp_address=self.account.primary_smtp_address, - access_type=DELEGATE, - credentials=Credentials("john@example.com", "WRONG_PASSWORD"), - autodiscover="pox", - locale="da_DK", - ) - - @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here - def test_close_autodiscover_connections(self, m): - # A live test that we can close TCP connections - p = self.get_test_protocol() - autodiscover_cache[(p.config.server, p.config.credentials)] = p - self.assertEqual(len(autodiscover_cache), 1) - close_connections() - - @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here - def test_autodiscover_direct_gc(self, m): - # Test garbage collection of the autodiscover cache - p = self.get_test_protocol() - autodiscover_cache[(p.config.server, p.config.credentials)] = p - self.assertEqual(len(autodiscover_cache), 1) - autodiscover_cache.__del__() # Don't use del() because that would remove the global object - - @requests_mock.mock(real_http=False) - @patch.object(Autodiscovery, "_ensure_valid_hostname") - def test_autodiscover_cache(self, m, _): - # Mock the default endpoint that we test in step 1 of autodiscovery - m.post(self.dummy_ad_endpoint, status_code=200, content=self.dummy_ad_response) - discovery = Autodiscovery( - email=self.account.primary_smtp_address, - credentials=self.account.protocol.credentials, - ) - # Not cached - self.assertNotIn(discovery._cache_key, autodiscover_cache) - discovery.discover() - # Now it's cached - self.assertIn(discovery._cache_key, autodiscover_cache) - # Make sure the cache can be looked by value, not by id(). This is important for multi-threading/processing - self.assertIn( - ( - self.account.primary_smtp_address.split("@")[1], - self.account.protocol.credentials, - True, - ), - autodiscover_cache, - ) - # Poison the cache with a failing autodiscover endpoint. discover() must handle this and rebuild the cache - p = self.get_test_protocol() - autodiscover_cache[discovery._cache_key] = p - m.post("https://example.com/Autodiscover/Autodiscover.xml", status_code=404) - discovery.discover() - self.assertIn(discovery._cache_key, autodiscover_cache) - - # Make sure that the cache is actually used on the second call to discover() - _orig = discovery._step_1 - - def _mock(slf, *args, **kwargs): - raise NotImplementedError() - - discovery._step_1 = MethodType(_mock, discovery) - discovery.discover() - - # Fake that another thread added the cache entry into the persistent storage but we don't have it in our - # in-memory cache. The cache should work anyway. - autodiscover_cache._protocols.clear() - discovery.discover() - discovery._step_1 = _orig - - # Make sure we can delete cache entries even though we don't have it in our in-memory cache - autodiscover_cache._protocols.clear() - del autodiscover_cache[discovery._cache_key] - # This should also work if the cache does not contain the entry anymore - del autodiscover_cache[discovery._cache_key] - - @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here - def test_corrupt_autodiscover_cache(self, m): - # Insert a fake Protocol instance into the cache and test that we can recover - key = (2, "foo", 4) - autodiscover_cache[key] = namedtuple("P", ["service_endpoint", "auth_type", "retry_policy"])(1, "bar", "baz") - # Check that it exists. 'in' goes directly to the file - self.assertTrue(key in autodiscover_cache) - - # Check that we can recover from a destroyed file - file = autodiscover_cache._storage_file - for f in file.parent.glob(f"{file.name}*"): - f.write_text("XXX") - self.assertFalse(key in autodiscover_cache) - - # Check that we can recover from an empty file - for f in file.parent.glob(f"{file.name}*"): - f.write_bytes(b"") - self.assertFalse(key in autodiscover_cache) - - @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here - @patch.object(Autodiscovery, "_ensure_valid_hostname") - def test_autodiscover_from_account(self, m, _): - # Test that autodiscovery via account creation works - # Mock the default endpoint that we test in step 1 of autodiscovery - m.post(self.dummy_ad_endpoint, status_code=200, content=self.dummy_ad_response) - self.assertEqual(len(autodiscover_cache), 0) - account = Account( - primary_smtp_address=self.account.primary_smtp_address, - config=Configuration( - credentials=self.account.protocol.credentials, - retry_policy=self.retry_policy, - version=Version(build=EXCHANGE_2013), - ), - autodiscover="pox", - locale="da_DK", - ) - self.assertEqual(account.primary_smtp_address, self.account.primary_smtp_address) - self.assertEqual(account.protocol.service_endpoint.lower(), self.dummy_ews_endpoint.lower()) - # Make sure cache is full - self.assertEqual(len(autodiscover_cache), 1) - self.assertTrue((account.domain, self.account.protocol.credentials, True) in autodiscover_cache) - # Test that autodiscover works with a full cache - account = Account( - primary_smtp_address=self.account.primary_smtp_address, - config=Configuration( - credentials=self.account.protocol.credentials, - retry_policy=self.retry_policy, - ), - autodiscover="pox", - locale="da_DK", - ) - self.assertEqual(account.primary_smtp_address, self.account.primary_smtp_address) - # Test cache manipulation - key = (account.domain, self.account.protocol.credentials, True) - self.assertTrue(key in autodiscover_cache) - del autodiscover_cache[key] - self.assertFalse(key in autodiscover_cache) - - @requests_mock.mock(real_http=False) - @patch.object(Autodiscovery, "_ensure_valid_hostname") - def test_autodiscover_redirect(self, m, _): - # Test various aspects of autodiscover redirection. Mock all HTTP responses because we can't force a live server - # to send us into the correct code paths. - # Mock the default endpoint that we test in step 1 of autodiscovery - m.post(self.dummy_ad_endpoint, status_code=200, content=self.dummy_ad_response) - discovery = Autodiscovery( - email=self.account.primary_smtp_address, - credentials=self.account.protocol.credentials, - ) - discovery.discover() - - # Make sure we discover a different return address - m.post( - self.dummy_ad_endpoint, - status_code=200, - content=self.settings_xml("john@example.com", "https://expr.example.com/EWS/Exchange.asmx"), - ) - ad_response, _ = discovery.discover() - self.assertEqual(ad_response.autodiscover_smtp_address, "john@example.com") - - # Make sure we discover an address redirect to the same domain. We have to mock the same URL with two different - # responses. We do that with a response list. - m.post( - self.dummy_ad_endpoint, - [ - dict(status_code=200, content=self.redirect_address_xml(f"redirect_me@{self.domain}")), - dict( - status_code=200, - content=self.settings_xml( - f"redirected@{self.domain}", f"https://redirected.{self.domain}/EWS/Exchange.asmx" - ), - ), - ], - ) - ad_response, _ = discovery.discover() - self.assertEqual(ad_response.autodiscover_smtp_address, f"redirected@{self.domain}") - self.assertEqual(ad_response.protocol.ews_url, f"https://redirected.{self.domain}/EWS/Exchange.asmx") - - # Test that we catch circular redirects on the same domain with a primed cache. Just mock the endpoint to - # return the same redirect response on every request. - self.assertEqual(len(autodiscover_cache), 1) - m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_address_xml(f"foo@{self.domain}")) - self.assertEqual(len(autodiscover_cache), 1) - with self.assertRaises(AutoDiscoverCircularRedirect): - discovery.discover() - - # Test that we also catch circular redirects when cache is empty - clear_cache() - self.assertEqual(len(autodiscover_cache), 0) - with self.assertRaises(AutoDiscoverCircularRedirect): - discovery.discover() - - # Test that we can handle being asked to redirect to an address on a different domain - # Don't use example.com to redirect - it does not resolve or answer on all ISPs - ews_hostname = "httpbin.org" - redirect_email = f"john@redirected.{ews_hostname}" - ews_url = f"https://{ews_hostname}/EWS/Exchange.asmx" - m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_address_xml(f"john@{ews_hostname}")) - m.post( - f"https://{ews_hostname}/Autodiscover/Autodiscover.xml", - status_code=200, - content=self.settings_xml(redirect_email, ews_url), - ) - ad_response, _ = discovery.discover() - self.assertEqual(ad_response.autodiscover_smtp_address, redirect_email) - self.assertEqual(ad_response.protocol.ews_url, ews_url) - - # Test redirect via HTTP 301 - clear_cache() - redirect_url = f"https://{ews_hostname}/OtherPath/Autodiscover.xml" - redirect_email = f"john@otherpath.{ews_hostname}" - ews_url = f"https://xxx.{ews_hostname}/EWS/Exchange.asmx" - discovery.email = self.account.primary_smtp_address - m.post(self.dummy_ad_endpoint, status_code=301, headers=dict(location=redirect_url)) - m.post(redirect_url, status_code=200, content=self.settings_xml(redirect_email, ews_url)) - m.head(redirect_url, status_code=200) - ad_response, _ = discovery.discover() - self.assertEqual(ad_response.autodiscover_smtp_address, redirect_email) - self.assertEqual(ad_response.protocol.ews_url, ews_url) - - @requests_mock.mock(real_http=False) - @patch.object(Autodiscovery, "_ensure_valid_hostname") - def test_autodiscover_path_1_2_5(self, m, _): - # Test steps 1 -> 2 -> 5 - clear_cache() - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) - ews_url = f"https://xxx.{self.domain}/EWS/Exchange.asmx" - email = f"xxxd@{self.domain}" - m.post(self.dummy_ad_endpoint, status_code=501) - m.post( - f"https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", - status_code=200, - content=self.settings_xml(email, ews_url), - ) - ad_response, _ = d.discover() - self.assertEqual(ad_response.autodiscover_smtp_address, email) - self.assertEqual(ad_response.protocol.ews_url, ews_url) - - @requests_mock.mock(real_http=False) - @patch.object(Autodiscovery, "_ensure_valid_hostname") - def test_autodiscover_path_1_2_3_invalid301_4(self, m, _): - # Test steps 1 -> 2 -> 3 -> invalid 301 URL -> 4 - clear_cache() - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) - m.post(self.dummy_ad_endpoint, status_code=501) - m.post(f"https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=501) - m.get( - f"http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", - status_code=301, - headers=dict(location="XXX"), - ) - - with self.assertRaises(AutoDiscoverFailed): - # Fails in step 4 with invalid SRV entry - ad_response, _ = d.discover() - - @requests_mock.mock(real_http=False) - @patch.object(Autodiscovery, "_ensure_valid_hostname") - def test_autodiscover_path_1_2_3_no301_4(self, m, _): - # Test steps 1 -> 2 -> 3 -> no 301 response -> 4 - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) - m.post(self.dummy_ad_endpoint, status_code=501) - m.post(f"https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=501) - m.get(f"http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=200) - - with self.assertRaises(AutoDiscoverFailed): - # Fails in step 4 with invalid SRV entry - ad_response, _ = d.discover() - - @requests_mock.mock(real_http=False) - @patch.object(Autodiscovery, "_ensure_valid_hostname") - def test_autodiscover_path_1_2_3_4_valid_srv_invalid_response(self, m, _): - # Test steps 1 -> 2 -> 3 -> 4 -> invalid response from SRV URL - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) - redirect_srv = "httpbin.org" - m.post(self.dummy_ad_endpoint, status_code=501) - m.post(f"https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=501) - m.get(f"http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=200) - m.head(f"https://{redirect_srv}/Autodiscover/Autodiscover.xml", status_code=501) - m.post(f"https://{redirect_srv}/Autodiscover/Autodiscover.xml", status_code=501) - - tmp = d._get_srv_records - d._get_srv_records = Mock(return_value=[SrvRecord(1, 1, 443, redirect_srv)]) - try: - with self.assertRaises(AutoDiscoverFailed): - # Fails in step 4 with invalid response - ad_response, _ = d.discover() - finally: - d._get_srv_records = tmp - - @requests_mock.mock(real_http=False) - @patch.object(Autodiscovery, "_ensure_valid_hostname") - def test_autodiscover_path_1_2_3_4_valid_srv_valid_response(self, m, _): - # Test steps 1 -> 2 -> 3 -> 4 -> 5 - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) - redirect_srv = "httpbin.org" - ews_url = f"https://{redirect_srv}/EWS/Exchange.asmx" - redirect_email = f"john@redirected.{redirect_srv}" - m.post(self.dummy_ad_endpoint, status_code=501) - m.post(f"https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=501) - m.get(f"http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=200) - m.head(f"https://{redirect_srv}/Autodiscover/Autodiscover.xml", status_code=200) - m.post( - f"https://{redirect_srv}/Autodiscover/Autodiscover.xml", - status_code=200, - content=self.settings_xml(redirect_email, ews_url), - ) - - tmp = d._get_srv_records - d._get_srv_records = Mock(return_value=[SrvRecord(1, 1, 443, redirect_srv)]) - try: - ad_response, _ = d.discover() - self.assertEqual(ad_response.autodiscover_smtp_address, redirect_email) - self.assertEqual(ad_response.protocol.ews_url, ews_url) - finally: - d._get_srv_records = tmp - - @requests_mock.mock(real_http=False) - def test_autodiscover_path_1_2_3_4_invalid_srv(self, m): - # Test steps 1 -> 2 -> 3 -> 4 -> invalid SRV URL - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) - m.post(self.dummy_ad_endpoint, status_code=501) - m.post(f"https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=501) - m.get(f"http://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", status_code=200) - - tmp = d._get_srv_records - d._get_srv_records = Mock(return_value=[SrvRecord(1, 1, 443, get_random_hostname())]) - try: - with self.assertRaises(AutoDiscoverFailed): - # Fails in step 4 with invalid response - ad_response, _ = d.discover() - finally: - d._get_srv_records = tmp - - @requests_mock.mock(real_http=False) - def test_autodiscover_path_1_5_invalid_redirect_url(self, m): - # Test steps 1 -> -> 5 -> Invalid redirect URL - clear_cache() - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) - m.post( - self.dummy_ad_endpoint, - status_code=200, - content=self.redirect_url_xml(f"https://{get_random_hostname()}/EWS/Exchange.asmx"), - ) - m.post( - f"https://autodiscover.{self.domain}/Autodiscover/Autodiscover.xml", - status_code=200, - content=self.redirect_url_xml(f"https://{get_random_hostname()}/EWS/Exchange.asmx"), - ) - - with self.assertRaises(AutoDiscoverFailed): - # Fails in step 5 with invalid redirect URL - ad_response, _ = d.discover() - - @requests_mock.mock(real_http=False) - @patch.object(Autodiscovery, "_ensure_valid_hostname") - def test_autodiscover_path_1_5_valid_redirect_url_invalid_response(self, m, _): - # Test steps 1 -> -> 5 -> Invalid response from redirect URL - clear_cache() - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) - redirect_url = "https://httpbin.org/Autodiscover/Autodiscover.xml" - m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_url_xml(redirect_url)) - m.head(redirect_url, status_code=501) - m.post(redirect_url, status_code=501) - - with self.assertRaises(AutoDiscoverFailed): - # Fails in step 5 with invalid response - ad_response, _ = d.discover() - - @requests_mock.mock(real_http=False) - @patch.object(Autodiscovery, "_ensure_valid_hostname") - def test_autodiscover_path_1_5_valid_redirect_url_valid_response(self, m, _): - # Test steps 1 -> -> 5 -> Valid response from redirect URL -> 5 - clear_cache() - d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) - redirect_hostname = "httpbin.org" - redirect_url = f"https://{redirect_hostname}/Autodiscover/Autodiscover.xml" - ews_url = f"https://{redirect_hostname}/EWS/Exchange.asmx" - email = f"john@redirected.{redirect_hostname}" - m.post(self.dummy_ad_endpoint, status_code=200, content=self.redirect_url_xml(redirect_url)) - m.head(redirect_url, status_code=200) - m.post(redirect_url, status_code=200, content=self.settings_xml(email, ews_url)) - - ad_response, _ = d.discover() - self.assertEqual(ad_response.autodiscover_smtp_address, email) - self.assertEqual(ad_response.protocol.ews_url, ews_url) - - def test_get_srv_records(self): - ad = Autodiscovery("foo@example.com") - # Unknown domain - self.assertEqual(ad._get_srv_records("example.XXXXX"), []) - # No SRV record - self.assertEqual(ad._get_srv_records("example.com"), []) - # Finding a real server that has a correct SRV record is not easy. Mock it - _orig = dns.resolver.Resolver - - class _Mock1: - @staticmethod - def resolve(*args, **kwargs): - class A: - @staticmethod - def to_text(): - # Return a valid record - return "1 2 3 example.com." - - return [A()] - - dns.resolver.Resolver = _Mock1 - del ad.resolver - # Test a valid record - self.assertEqual( - ad._get_srv_records("example.com."), [SrvRecord(priority=1, weight=2, port=3, srv="example.com")] - ) - - class _Mock2: - @staticmethod - def resolve(*args, **kwargs): - class A: - @staticmethod - def to_text(): - # Return malformed data - return "XXXXXXX" - - return [A()] - - dns.resolver.Resolver = _Mock2 - del ad.resolver - # Test an invalid record - self.assertEqual(ad._get_srv_records("example.com"), []) - dns.resolver.Resolver = _orig - del ad.resolver - - def test_select_srv_host(self): - with self.assertRaises(ValueError): - # Empty list - _select_srv_host([]) - with self.assertRaises(ValueError): - # No records with TLS port - _select_srv_host([SrvRecord(priority=1, weight=2, port=3, srv="example.com")]) - # One record - self.assertEqual( - _select_srv_host([SrvRecord(priority=1, weight=2, port=443, srv="example.com")]), "example.com" - ) - # Highest priority record - self.assertEqual( - _select_srv_host( - [ - SrvRecord(priority=10, weight=2, port=443, srv="10.example.com"), - SrvRecord(priority=1, weight=2, port=443, srv="1.example.com"), - ] - ), - "10.example.com", - ) - # Highest priority record no matter how it's sorted - self.assertEqual( - _select_srv_host( - [ - SrvRecord(priority=1, weight=2, port=443, srv="1.example.com"), - SrvRecord(priority=10, weight=2, port=443, srv="10.example.com"), - ] - ), - "10.example.com", - ) - - def test_parse_response(self): - # Test parsing of various XML responses - with self.assertRaises(ParseError) as e: - Autodiscover.from_bytes(b"XXX") # Invalid response - self.assertEqual(e.exception.args[0], "Response is not XML: b'XXX'") - - xml = b"""bar""" - with self.assertRaises(ParseError) as e: - Autodiscover.from_bytes(xml) # Invalid XML response - self.assertEqual( - e.exception.args[0], - 'Unknown root element in XML: b\'bar\'', - ) - - # Redirect to different email address - xml = b"""\ - - - - - john@demo.affect-it.dk - - - redirectAddr - foo@example.com - - -""" - self.assertEqual(Autodiscover.from_bytes(xml).response.redirect_address, "foo@example.com") - - # Redirect to different URL - xml = b"""\ - - - - - john@demo.affect-it.dk - - - redirectUrl - https://example.com/foo.asmx - - -""" - self.assertEqual(Autodiscover.from_bytes(xml).response.redirect_url, "https://example.com/foo.asmx") - - # Select EXPR if it's there, and there are multiple available - xml = b"""\ - - - - - john@demo.affect-it.dk - - - email - settings - - EXCH - https://exch.example.com/EWS/Exchange.asmx - - - EXPR - https://expr.example.com/EWS/Exchange.asmx - - - -""" - self.assertEqual( - Autodiscover.from_bytes(xml).response.protocol.ews_url, "https://expr.example.com/EWS/Exchange.asmx" - ) - - # Select EXPR if EXPR is unavailable - xml = b"""\ - - - - - john@demo.affect-it.dk - - - email - settings - - EXCH - https://exch.example.com/EWS/Exchange.asmx - - - -""" - self.assertEqual( - Autodiscover.from_bytes(xml).response.protocol.ews_url, "https://exch.example.com/EWS/Exchange.asmx" - ) - - # Fail if neither EXPR nor EXPR are unavailable - xml = b"""\ - - - - - john@demo.affect-it.dk - - - email - settings - - XXX - https://xxx.example.com/EWS/Exchange.asmx - - - -""" - with self.assertRaises(ValueError): - _ = Autodiscover.from_bytes(xml).response.protocol.ews_url - - def test_raise_errors(self): - with self.assertRaises(AutoDiscoverFailed) as e: - Autodiscover().raise_errors() - self.assertEqual(e.exception.args[0], "Unknown autodiscover error response: None") - with self.assertRaises(AutoDiscoverFailed) as e: - Autodiscover(error_response=ErrorResponse(error=Error(code="YYY", message="XXX"))).raise_errors() - self.assertEqual(e.exception.args[0], "Unknown error YYY: XXX") - with self.assertRaises(ErrorNonExistentMailbox) as e: - Autodiscover( - error_response=ErrorResponse(error=Error(message="The e-mail address cannot be found.")) - ).raise_errors() - self.assertEqual(e.exception.args[0], "The SMTP address has no mailbox associated with it") - - def test_del_on_error(self): - # Test that __del__ can handle exceptions on close() - tmp = AutodiscoverCache.close - cache = AutodiscoverCache() - AutodiscoverCache.close = Mock(side_effect=Exception("XXX")) - with self.assertRaises(Exception): - cache.close() - del cache - AutodiscoverCache.close = tmp - - def test_shelve_filename(self): - major, minor = sys.version_info[:2] - self.assertEqual(shelve_filename(), f"exchangelib.2.cache.{getpass.getuser()}.py{major}{minor}") - - @patch("getpass.getuser", side_effect=KeyError()) - def test_shelve_filename_getuser_failure(self, m): - # Test that shelve_filename can handle a failing getuser() - major, minor = sys.version_info[:2] - self.assertEqual(shelve_filename(), f"exchangelib.2.cache.exchangelib.py{major}{minor}") - - @requests_mock.mock(real_http=False) - def test_redirect_url_is_valid(self, m): - # This method is private but hard to get to otherwise - a = Autodiscovery("john@example.com") - - # Already visited - a._urls_visited.append("https://example.com") - self.assertFalse(a._redirect_url_is_valid("https://example.com")) - a._urls_visited.clear() - - # Max redirects exceeded - a._redirect_count = 10 - self.assertFalse(a._redirect_url_is_valid("https://example.com")) - a._redirect_count = 0 - - # Must be secure - self.assertFalse(a._redirect_url_is_valid("http://example.com")) - - # Does not resolve with DNS - url = f"https://{get_random_hostname()}" - m.head(url, status_code=200) - self.assertFalse(a._redirect_url_is_valid(url)) - - # Bad response from URL on valid hostname - m.head(self.account.protocol.config.service_endpoint, status_code=501) - self.assertTrue(a._redirect_url_is_valid(self.account.protocol.config.service_endpoint)) - - # OK response from URL on valid hostname - m.head(self.account.protocol.config.service_endpoint, status_code=200) - self.assertTrue(a._redirect_url_is_valid(self.account.protocol.config.service_endpoint)) - - def test_protocol_default_values(self): - # Test that retry_policy and auth_type always get a value regardless of how we create an Account - self.get_account() - a = Account( - self.account.primary_smtp_address, - autodiscover="pox", - config=self.account.protocol.config, - ) - self.assertIsNotNone(a.protocol.auth_type) - self.assertIsNotNone(a.protocol.retry_policy) - - a = Account( - self.account.primary_smtp_address, autodiscover="pox", credentials=self.account.protocol.credentials - ) - self.assertIsNotNone(a.protocol.auth_type) - self.assertIsNotNone(a.protocol.retry_policy) From 3bd256fa9fbc99f45a1d64ecd658bc92ce8da352 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 23 Nov 2022 03:11:45 +0100 Subject: [PATCH 311/509] ci: Mock out token refresh HTTP calls --- tests/test_autodiscover.py | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 959d2e8e..29ed5240 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -242,7 +242,8 @@ def test_autodiscover_direct_gc(self, m): @requests_mock.mock(real_http=False) @patch.object(Autodiscovery, "_ensure_valid_hostname") - def test_autodiscover_cache(self, m, _): + @patch("requests_oauthlib.OAuth2Session.fetch_token") + def test_autodiscover_cache(self, m, *args): # Mock the default endpoint that we test in step 1 of autodiscovery m.post(self.dummy_ad_endpoint, status_code=200, content=self.dummy_ad_response) discovery = Autodiscovery( @@ -312,7 +313,8 @@ def test_corrupt_autodiscover_cache(self, m): @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here @patch.object(Autodiscovery, "_ensure_valid_hostname") - def test_autodiscover_from_account(self, m, _): + @patch("requests_oauthlib.OAuth2Session.fetch_token") + def test_autodiscover_from_account(self, m, *args): # Test that autodiscovery via account creation works # Mock the default endpoint that we test in step 1 of autodiscovery m.post(self.dummy_ad_endpoint, status_code=200, content=self.dummy_ad_response) @@ -351,7 +353,8 @@ def test_autodiscover_from_account(self, m, _): @requests_mock.mock(real_http=False) @patch.object(Autodiscovery, "_ensure_valid_hostname") - def test_autodiscover_redirect(self, m, _): + @patch("requests_oauthlib.OAuth2Session.fetch_token") + def test_autodiscover_redirect(self, m, *args): # Test various aspects of autodiscover redirection. Mock all HTTP responses because we can't force a live server # to send us into the correct code paths. # Mock the default endpoint that we test in step 1 of autodiscovery @@ -433,7 +436,8 @@ def test_autodiscover_redirect(self, m, _): @requests_mock.mock(real_http=False) @patch.object(Autodiscovery, "_ensure_valid_hostname") - def test_autodiscover_path_1_2_5(self, m, _): + @patch("requests_oauthlib.OAuth2Session.fetch_token") + def test_autodiscover_path_1_2_5(self, m, *args): # Test steps 1 -> 2 -> 5 clear_cache() d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) @@ -451,7 +455,8 @@ def test_autodiscover_path_1_2_5(self, m, _): @requests_mock.mock(real_http=False) @patch.object(Autodiscovery, "_ensure_valid_hostname") - def test_autodiscover_path_1_2_3_invalid301_4(self, m, _): + @patch("requests_oauthlib.OAuth2Session.fetch_token") + def test_autodiscover_path_1_2_3_invalid301_4(self, m, *args): # Test steps 1 -> 2 -> 3 -> invalid 301 URL -> 4 clear_cache() d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) @@ -469,7 +474,8 @@ def test_autodiscover_path_1_2_3_invalid301_4(self, m, _): @requests_mock.mock(real_http=False) @patch.object(Autodiscovery, "_ensure_valid_hostname") - def test_autodiscover_path_1_2_3_no301_4(self, m, _): + @patch("requests_oauthlib.OAuth2Session.fetch_token") + def test_autodiscover_path_1_2_3_no301_4(self, m, *args): # Test steps 1 -> 2 -> 3 -> no 301 response -> 4 d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) m.post(self.dummy_ad_endpoint, status_code=501) @@ -482,7 +488,8 @@ def test_autodiscover_path_1_2_3_no301_4(self, m, _): @requests_mock.mock(real_http=False) @patch.object(Autodiscovery, "_ensure_valid_hostname") - def test_autodiscover_path_1_2_3_4_valid_srv_invalid_response(self, m, _): + @patch("requests_oauthlib.OAuth2Session.fetch_token") + def test_autodiscover_path_1_2_3_4_valid_srv_invalid_response(self, m, *args): # Test steps 1 -> 2 -> 3 -> 4 -> invalid response from SRV URL d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) redirect_srv = "httpbin.org" @@ -503,7 +510,8 @@ def test_autodiscover_path_1_2_3_4_valid_srv_invalid_response(self, m, _): @requests_mock.mock(real_http=False) @patch.object(Autodiscovery, "_ensure_valid_hostname") - def test_autodiscover_path_1_2_3_4_valid_srv_valid_response(self, m, _): + @patch("requests_oauthlib.OAuth2Session.fetch_token") + def test_autodiscover_path_1_2_3_4_valid_srv_valid_response(self, m, *args): # Test steps 1 -> 2 -> 3 -> 4 -> 5 d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) redirect_srv = "httpbin.org" @@ -567,7 +575,8 @@ def test_autodiscover_path_1_5_invalid_redirect_url(self, m): @requests_mock.mock(real_http=False) @patch.object(Autodiscovery, "_ensure_valid_hostname") - def test_autodiscover_path_1_5_valid_redirect_url_invalid_response(self, m, _): + @patch("requests_oauthlib.OAuth2Session.fetch_token") + def test_autodiscover_path_1_5_valid_redirect_url_invalid_response(self, m, *args): # Test steps 1 -> -> 5 -> Invalid response from redirect URL clear_cache() d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) @@ -582,7 +591,8 @@ def test_autodiscover_path_1_5_valid_redirect_url_invalid_response(self, m, _): @requests_mock.mock(real_http=False) @patch.object(Autodiscovery, "_ensure_valid_hostname") - def test_autodiscover_path_1_5_valid_redirect_url_valid_response(self, m, _): + @patch("requests_oauthlib.OAuth2Session.fetch_token") + def test_autodiscover_path_1_5_valid_redirect_url_valid_response(self, m, *args): # Test steps 1 -> 5 -> Valid response from redirect URL -> 5 clear_cache() d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) From e1c75ae8aae9a2a3a0e923664ef7ae866e3b4547 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 23 Nov 2022 18:58:33 +0100 Subject: [PATCH 312/509] ci: Fix assert in credentials access_token setter --- tests/test_autodiscover.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 29ed5240..b66527e2 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -242,7 +242,7 @@ def test_autodiscover_direct_gc(self, m): @requests_mock.mock(real_http=False) @patch.object(Autodiscovery, "_ensure_valid_hostname") - @patch("requests_oauthlib.OAuth2Session.fetch_token") + @patch("requests_oauthlib.OAuth2Session.fetch_token", return_value=None) def test_autodiscover_cache(self, m, *args): # Mock the default endpoint that we test in step 1 of autodiscovery m.post(self.dummy_ad_endpoint, status_code=200, content=self.dummy_ad_response) @@ -313,7 +313,7 @@ def test_corrupt_autodiscover_cache(self, m): @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here @patch.object(Autodiscovery, "_ensure_valid_hostname") - @patch("requests_oauthlib.OAuth2Session.fetch_token") + @patch("requests_oauthlib.OAuth2Session.fetch_token", return_value=None) def test_autodiscover_from_account(self, m, *args): # Test that autodiscovery via account creation works # Mock the default endpoint that we test in step 1 of autodiscovery @@ -353,7 +353,7 @@ def test_autodiscover_from_account(self, m, *args): @requests_mock.mock(real_http=False) @patch.object(Autodiscovery, "_ensure_valid_hostname") - @patch("requests_oauthlib.OAuth2Session.fetch_token") + @patch("requests_oauthlib.OAuth2Session.fetch_token", return_value=None) def test_autodiscover_redirect(self, m, *args): # Test various aspects of autodiscover redirection. Mock all HTTP responses because we can't force a live server # to send us into the correct code paths. @@ -436,7 +436,7 @@ def test_autodiscover_redirect(self, m, *args): @requests_mock.mock(real_http=False) @patch.object(Autodiscovery, "_ensure_valid_hostname") - @patch("requests_oauthlib.OAuth2Session.fetch_token") + @patch("requests_oauthlib.OAuth2Session.fetch_token", return_value=None) def test_autodiscover_path_1_2_5(self, m, *args): # Test steps 1 -> 2 -> 5 clear_cache() @@ -455,7 +455,7 @@ def test_autodiscover_path_1_2_5(self, m, *args): @requests_mock.mock(real_http=False) @patch.object(Autodiscovery, "_ensure_valid_hostname") - @patch("requests_oauthlib.OAuth2Session.fetch_token") + @patch("requests_oauthlib.OAuth2Session.fetch_token", return_value=None) def test_autodiscover_path_1_2_3_invalid301_4(self, m, *args): # Test steps 1 -> 2 -> 3 -> invalid 301 URL -> 4 clear_cache() @@ -474,7 +474,7 @@ def test_autodiscover_path_1_2_3_invalid301_4(self, m, *args): @requests_mock.mock(real_http=False) @patch.object(Autodiscovery, "_ensure_valid_hostname") - @patch("requests_oauthlib.OAuth2Session.fetch_token") + @patch("requests_oauthlib.OAuth2Session.fetch_token", return_value=None) def test_autodiscover_path_1_2_3_no301_4(self, m, *args): # Test steps 1 -> 2 -> 3 -> no 301 response -> 4 d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) @@ -488,7 +488,7 @@ def test_autodiscover_path_1_2_3_no301_4(self, m, *args): @requests_mock.mock(real_http=False) @patch.object(Autodiscovery, "_ensure_valid_hostname") - @patch("requests_oauthlib.OAuth2Session.fetch_token") + @patch("requests_oauthlib.OAuth2Session.fetch_token", return_value=None) def test_autodiscover_path_1_2_3_4_valid_srv_invalid_response(self, m, *args): # Test steps 1 -> 2 -> 3 -> 4 -> invalid response from SRV URL d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) @@ -510,7 +510,7 @@ def test_autodiscover_path_1_2_3_4_valid_srv_invalid_response(self, m, *args): @requests_mock.mock(real_http=False) @patch.object(Autodiscovery, "_ensure_valid_hostname") - @patch("requests_oauthlib.OAuth2Session.fetch_token") + @patch("requests_oauthlib.OAuth2Session.fetch_token", return_value=None) def test_autodiscover_path_1_2_3_4_valid_srv_valid_response(self, m, *args): # Test steps 1 -> 2 -> 3 -> 4 -> 5 d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) @@ -575,7 +575,7 @@ def test_autodiscover_path_1_5_invalid_redirect_url(self, m): @requests_mock.mock(real_http=False) @patch.object(Autodiscovery, "_ensure_valid_hostname") - @patch("requests_oauthlib.OAuth2Session.fetch_token") + @patch("requests_oauthlib.OAuth2Session.fetch_token", return_value=None) def test_autodiscover_path_1_5_valid_redirect_url_invalid_response(self, m, *args): # Test steps 1 -> -> 5 -> Invalid response from redirect URL clear_cache() @@ -591,7 +591,7 @@ def test_autodiscover_path_1_5_valid_redirect_url_invalid_response(self, m, *arg @requests_mock.mock(real_http=False) @patch.object(Autodiscovery, "_ensure_valid_hostname") - @patch("requests_oauthlib.OAuth2Session.fetch_token") + @patch("requests_oauthlib.OAuth2Session.fetch_token", return_value=None) def test_autodiscover_path_1_5_valid_redirect_url_valid_response(self, m, *args): # Test steps 1 -> 5 -> Valid response from redirect URL -> 5 clear_cache() From 0edcae09d058eccf03221b09e45b13ea61b5e2bb Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 23 Nov 2022 19:18:14 +0100 Subject: [PATCH 313/509] ci: mock oauth token method for two remaining tests --- tests/test_autodiscover.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index b66527e2..5f40966d 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -537,7 +537,8 @@ def test_autodiscover_path_1_2_3_4_valid_srv_valid_response(self, m, *args): d._get_srv_records = tmp @requests_mock.mock(real_http=False) - def test_autodiscover_path_1_2_3_4_invalid_srv(self, m): + @patch("requests_oauthlib.OAuth2Session.fetch_token", return_value=None) + def test_autodiscover_path_1_2_3_4_invalid_srv(self, m, *args): # Test steps 1 -> 2 -> 3 -> 4 -> invalid SRV URL d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) m.post(self.dummy_ad_endpoint, status_code=501) @@ -554,7 +555,8 @@ def test_autodiscover_path_1_2_3_4_invalid_srv(self, m): d._get_srv_records = tmp @requests_mock.mock(real_http=False) - def test_autodiscover_path_1_5_invalid_redirect_url(self, m): + @patch("requests_oauthlib.OAuth2Session.fetch_token", return_value=None) + def test_autodiscover_path_1_5_invalid_redirect_url(self, m, *args): # Test steps 1 -> -> 5 -> Invalid redirect URL clear_cache() d = Autodiscovery(email=self.account.primary_smtp_address, credentials=self.account.protocol.credentials) From 2b1e039a59dcf6520c07a56b576e3b3ccfd99764 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 24 Nov 2022 13:00:42 +0100 Subject: [PATCH 314/509] chore: remove dead code and improve test coverage --- exchangelib/attachments.py | 15 +-- exchangelib/fields.py | 33 +----- exchangelib/properties.py | 6 -- exchangelib/services/get_user_settings.py | 10 +- exchangelib/version.py | 20 ---- tests/test_autodiscover.py | 26 ++++- tests/test_extended_properties.py | 117 +++++++++++++++++++--- tests/test_folder.py | 5 + 8 files changed, 135 insertions(+), 97 deletions(-) diff --git a/exchangelib/attachments.py b/exchangelib/attachments.py index 8d29d9d2..9feef6be 100644 --- a/exchangelib/attachments.py +++ b/exchangelib/attachments.py @@ -15,16 +15,13 @@ TextField, URIField, ) -from .properties import EWSElement, EWSMeta +from .properties import BaseItemId, EWSElement, EWSMeta log = logging.getLogger(__name__) -class AttachmentId(EWSElement): - """'id' and 'changekey' are UUIDs generated by Exchange. - - MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/attachmentid - """ +class AttachmentId(BaseItemId): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/attachmentid""" ELEMENT_NAME = "AttachmentId" @@ -36,12 +33,6 @@ class AttachmentId(EWSElement): root_id = IdField(field_uri=ROOT_ID_ATTR) root_changekey = IdField(field_uri=ROOT_CHANGEKEY_ATTR) - def __init__(self, *args, **kwargs): - if not kwargs: - # Allow to set attributes without keyword - kwargs = dict(zip(self._slots_keys, args)) - super().__init__(**kwargs) - class Attachment(EWSElement, metaclass=EWSMeta): """Base class for FileAttachment and ItemAttachment.""" diff --git a/exchangelib/fields.py b/exchangelib/fields.py index b88d5ca7..360aaed7 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -17,7 +17,7 @@ value_to_xml_text, xml_text_to_value, ) -from .version import EXCHANGE_2013, Build, SupportedVersionInstanceMixIn +from .version import EXCHANGE_2013, SupportedVersionInstanceMixIn log = logging.getLogger(__name__) @@ -423,10 +423,6 @@ class BooleanField(FieldURIField): value_cls = bool -class OnOffField(BooleanField): - """A field that handles boolean values that are On/Off instead of True/False.""" - - class IntegerField(FieldURIField): """A field that handles integer values.""" @@ -1488,33 +1484,6 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) -class BuildField(CharField): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.value_cls = Build - - def from_xml(self, elem, account): - val = self._get_val_from_elem(elem) - if val: - try: - return self.value_cls.from_hex_string(val) - except (TypeError, ValueError): - log.warning("Invalid server version string: %r", val) - return val - - -class ProtocolListField(EWSElementListField): - # There is not containing element for this field. Just multiple 'Protocol' elements on the 'Account' element. - def __init__(self, *args, **kwargs): - from .autodiscover.properties import Protocol - - kwargs["value_cls"] = Protocol - super().__init__(*args, **kwargs) - - def from_xml(self, elem, account): - return [self.value_cls.from_xml(elem=e, account=account) for e in elem.findall(self.value_cls.response_tag())] - - class RoutingTypeField(ChoiceField): def __init__(self, *args, **kwargs): kwargs["choices"] = {Choice("SMTP"), Choice("EX")} diff --git a/exchangelib/properties.py b/exchangelib/properties.py index b55820fc..8d242ec1 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -2077,12 +2077,6 @@ def version(self): return version raise ValueError(f"Unknown supported schemas: {supported_schemas}") - @staticmethod - def _is_url(s): - if not s: - return False - return s.startswith("http://") or s.startswith("https://") - def raise_errors(self): if self.error_code == "InvalidUser": raise ErrorNonExistentMailbox(self.error_message) diff --git a/exchangelib/services/get_user_settings.py b/exchangelib/services/get_user_settings.py index 4d210ecb..95a7bf3b 100644 --- a/exchangelib/services/get_user_settings.py +++ b/exchangelib/services/get_user_settings.py @@ -1,6 +1,6 @@ import logging -from ..errors import ErrorInvalidServerVersion, MalformedResponseError +from ..errors import MalformedResponseError from ..properties import UserResponse from ..transport import DEFAULT_ENCODING from ..util import ANS, add_xml_child, create_element, get_xml_attr, ns_translation, set_xml_value, xml_to_str @@ -86,11 +86,3 @@ def _get_element_container(self, message, name=None): raise self._get_exception(code=error_code, text=msg_text, msg_xml=None) except self.ERRORS_TO_CATCH_IN_RESPONSE as e: return e - - @classmethod - def _raise_soap_errors(cls, fault): - fault_code = get_xml_attr(fault, "faultcode") - fault_string = get_xml_attr(fault, "faultstring") - if fault_code == "a:ActionNotSupported" and "ContractFilter mismatch" in fault_string: - raise ErrorInvalidServerVersion(f"SOAP error code: {fault_code} string: {fault_string}") - super()._raise_soap_errors(fault=fault) diff --git a/exchangelib/version.py b/exchangelib/version.py index 87d49731..408c6af7 100644 --- a/exchangelib/version.py +++ b/exchangelib/version.py @@ -46,26 +46,6 @@ def from_xml(cls, elem): kwargs[k] = int(v) # Also raises ValueError return cls(**kwargs) - @classmethod - def from_hex_string(cls, s): - """Parse a server version string as returned in an autodiscover response. The process is described here: - https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/serverversion-pox#example - - The string is a hex string that, converted to a 32-bit binary, encodes the server version. The rules are: - * The first 4 bits contain the version number structure version. Can be ignored - * The next 6 bits contain the major version number - * The next 6 bits contain the minor version number - * The next bit contains a flag. Can be ignored - * The next 15 bits contain the major build number - - :param s: - """ - bin_s = f"{int(s, 16):032b}" # Convert string to 32-bit binary string - major_version = int(bin_s[4:10], 2) - minor_version = int(bin_s[10:16], 2) - build_number = int(bin_s[17:32], 2) - return cls(major_version=major_version, minor_version=minor_version, major_build=build_number) - def api_version(self): for build, api_version, _ in VERSIONS: if self.major_version != build.major_version or self.minor_version != build.minor_version: diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 5f40966d..14ca01f5 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -7,13 +7,13 @@ import dns import requests_mock -from exchangelib.account import Account +from exchangelib.account import Account, Identity from exchangelib.autodiscover import clear_cache, close_connections from exchangelib.autodiscover.cache import AutodiscoverCache, autodiscover_cache, shelve_filename from exchangelib.autodiscover.discovery import Autodiscovery, SrvRecord, _select_srv_host from exchangelib.autodiscover.protocol import AutodiscoverProtocol from exchangelib.configuration import Configuration -from exchangelib.credentials import DELEGATE, Credentials +from exchangelib.credentials import DELEGATE, Credentials, OAuth2LegacyCredentials from exchangelib.errors import AutoDiscoverCircularRedirect, AutoDiscoverFailed, ErrorNonExistentMailbox from exchangelib.properties import UserResponse from exchangelib.protocol import FailFast, FaultTolerance @@ -26,9 +26,6 @@ class AutodiscoverTest(EWSTest): def setUp(self): - if not self.account: - self.skipTest("SOAP autodiscover requires delegate credentials") - super().setUp() # Enable retries, to make tests more robust @@ -224,6 +221,25 @@ def test_failed_login_via_account(self): locale="da_DK", ) + def test_autodiscover_with_delegate(self): + if not self.settings.get("client_id") or not self.settings.get("username"): + self.skipTest("This test requires delegate OAuth setup") + + credentials = OAuth2LegacyCredentials( + client_id=self.settings["client_id"], + client_secret=self.settings["client_secret"], + tenant_id=self.settings["tenant_id"], + username=self.settings["username"], + password=self.settings["password"], + identity=Identity(smtp_address=self.settings["account"]), + ) + ad_response, protocol = Autodiscovery( + email=self.account.primary_smtp_address, + credentials=credentials, + ).discover() + self.assertEqual(ad_response.autodiscover_smtp_address, self.account.primary_smtp_address) + self.assertEqual(protocol.service_endpoint.lower(), self.account.protocol.service_endpoint.lower()) + @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here def test_close_autodiscover_connections(self, m): # A live test that we can close TCP connections diff --git a/tests/test_extended_properties.py b/tests/test_extended_properties.py index dbb1249f..ab38a5c5 100644 --- a/tests/test_extended_properties.py +++ b/tests/test_extended_properties.py @@ -2,6 +2,7 @@ from exchangelib.folders import Inbox from exchangelib.items import BaseItem, CalendarItem, Message from exchangelib.properties import Mailbox +from exchangelib.util import to_xml from .common import get_random_int, get_random_url from .test_items.test_basics import BaseItemTest @@ -202,90 +203,129 @@ class TestProp1(ExtendedProperty): distinguished_property_set_id = "XXX" property_set_id = "YYY" - with self.assertRaises(ValueError): + with self.assertRaises(ValueError) as e: TestProp1.validate_cls() + self.assertEqual( + e.exception.args[0], + "When 'distinguished_property_set_id' is set, 'property_set_id' and 'property_tag' must be None", + ) # Must have property_id or property_name class TestProp2(ExtendedProperty): distinguished_property_set_id = "XXX" - with self.assertRaises(ValueError): + with self.assertRaises(ValueError) as e: TestProp2.validate_cls() + self.assertEqual( + e.exception.args[0], + "When 'distinguished_property_set_id' is set, 'property_id' or 'property_name' must also be set", + ) # distinguished_property_set_id must have a valid value class TestProp3(ExtendedProperty): distinguished_property_set_id = "XXX" property_id = "YYY" - with self.assertRaises(ValueError): + with self.assertRaises(ValueError) as e: TestProp3.validate_cls() + self.assertEqual( + e.exception.args[0], + f"'distinguished_property_set_id' 'XXX' must be one of {sorted(ExtendedProperty.DISTINGUISHED_SETS)}", + ) # Must not have distinguished_property_set_id or property_tag class TestProp4(ExtendedProperty): property_set_id = "XXX" property_tag = "YYY" - with self.assertRaises(ValueError): + with self.assertRaises(ValueError) as e: TestProp4.validate_cls() + self.assertEqual( + e.exception.args[0], + "When 'property_set_id' is set, 'distinguished_property_set_id' and 'property_tag' must be None", + ) # Must have property_id or property_name class TestProp5(ExtendedProperty): property_set_id = "XXX" - with self.assertRaises(ValueError): + with self.assertRaises(ValueError) as e: TestProp5.validate_cls() + self.assertEqual( + e.exception.args[0], "When 'property_set_id' is set, 'property_id' or 'property_name' must also be set" + ) # property_tag is only compatible with property_type class TestProp6(ExtendedProperty): property_tag = "XXX" property_set_id = "YYY" - with self.assertRaises(ValueError): + with self.assertRaises(ValueError) as e: TestProp6.validate_cls() + self.assertEqual( + e.exception.args[0], + "When 'property_set_id' is set, 'distinguished_property_set_id' and 'property_tag' must be None", + ) # property_tag must be an integer or string that can be converted to int class TestProp7(ExtendedProperty): property_tag = "XXX" - with self.assertRaises(ValueError): + with self.assertRaises(ValueError) as e: TestProp7.validate_cls() + self.assertEqual(e.exception.args[0], "invalid literal for int() with base 16: 'XXX'") # property_tag must not be in the reserved range class TestProp8(ExtendedProperty): property_tag = 0x8001 - with self.assertRaises(ValueError): + with self.assertRaises(ValueError) as e: TestProp8.validate_cls() + self.assertEqual(e.exception.args[0], "'property_tag' value '0x8001' is reserved for custom properties") # Must not have property_id or property_tag class TestProp9(ExtendedProperty): property_name = "XXX" property_id = "YYY" - with self.assertRaises(ValueError): + with self.assertRaises(ValueError) as e: TestProp9.validate_cls() + self.assertEqual( + e.exception.args[0], "When 'property_name' is set, 'property_id' and 'property_tag' must be None" + ) # Must have distinguished_property_set_id or property_set_id class TestProp10(ExtendedProperty): property_name = "XXX" - with self.assertRaises(ValueError): + with self.assertRaises(ValueError) as e: TestProp10.validate_cls() + self.assertEqual( + e.exception.args[0], + "When 'property_name' is set, 'distinguished_property_set_id' or 'property_set_id' must also be set", + ) # Must not have property_name or property_tag class TestProp11(ExtendedProperty): property_id = "XXX" property_name = "YYY" - with self.assertRaises(ValueError): + with self.assertRaises(ValueError) as e: TestProp11.validate_cls() # This actually hits the check on property_name values + self.assertEqual( + e.exception.args[0], "When 'property_name' is set, 'property_id' and 'property_tag' must be None" + ) # Must have distinguished_property_set_id or property_set_id class TestProp12(ExtendedProperty): property_id = "XXX" - with self.assertRaises(ValueError): + with self.assertRaises(ValueError) as e: TestProp12.validate_cls() + self.assertEqual( + e.exception.args[0], + "When 'property_id' is set, 'distinguished_property_set_id' or 'property_set_id' must also be set", + ) # property_type must be a valid value class TestProp13(ExtendedProperty): @@ -293,8 +333,27 @@ class TestProp13(ExtendedProperty): property_set_id = "YYY" property_type = "ZZZ" - with self.assertRaises(ValueError): + with self.assertRaises(ValueError) as e: TestProp13.validate_cls() + self.assertEqual( + e.exception.args[0], f"'property_type' 'ZZZ' must be one of {sorted(ExtendedProperty.PROPERTY_TYPES)}" + ) + + # property_tag and property_id are mutually exclusive + class TestProp14(ExtendedProperty): + property_tag = "XXX" + property_id = "YYY" + + with self.assertRaises(ValueError) as e: + # We cannot reach this exception directly with validate_cls() + TestProp14._validate_property_tag() + self.assertEqual(e.exception.args[0], "When 'property_tag' is set, only 'property_type' must be set") + with self.assertRaises(ValueError) as e: + # We cannot reach this exception directly with validate_cls() + TestProp14._validate_property_id() + self.assertEqual( + e.exception.args[0], "When 'property_id' is set, 'property_name' and 'property_tag' must be None" + ) def test_multiple_extended_properties(self): class ExternalSharingUrl(ExtendedProperty): @@ -350,3 +409,35 @@ class TestArayProp(ExtendedProperty): finally: self.ITEM_CLASS.deregister(attr_name=attr_name) self.ITEM_CLASS.deregister(attr_name=array_attr_name) + + def test_from_xml(self): + # Test that empty and no-op XML Value elements for string props both return empty strings + class TestProp(ExtendedProperty): + property_set_id = "deadbeaf-cafe-cafe-cafe-deadbeefcafe" + property_name = "Test Property" + property_type = "String" + + elem = to_xml( + b"""\ + + + XXX +""" + ) + self.assertEqual(TestProp.from_xml(elem, account=None), "XXX") + elem = to_xml( + b"""\ + + + +""" + ) + self.assertEqual(TestProp.from_xml(elem, account=None), "") + elem = to_xml( + b"""\ + + + +""" + ) + self.assertEqual(TestProp.from_xml(elem, account=None), "") diff --git a/tests/test_folder.py b/tests/test_folder.py index 8fa2e806..046bb950 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -216,6 +216,11 @@ def test_find_folders(self): folders = list(FolderCollection(account=self.account, folders=[self.account.root]).find_folders()) self.assertGreater(len(folders), 40, sorted(f.name for f in folders)) + # Test failure on different roots + with self.assertRaises(ValueError) as e: + list(FolderCollection(account=self.account, folders=[Folder(root="A"), Folder(root="B")]).find_folders()) + self.assertIn("All folders in 'roots' must have the same root hierarchy", e.exception.args[0]) + def test_find_folders_multiple_roots(self): try: coll = FolderCollection(account=self.account, folders=[self.account.root, self.account.public_folders_root]) From 50153c0bec22ce49442fb9b7cc0cc8e71723d6e5 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 24 Nov 2022 13:39:30 +0100 Subject: [PATCH 315/509] ci: add coverage of a failure case --- tests/test_folder.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/tests/test_folder.py b/tests/test_folder.py index 046bb950..51a225dd 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -7,6 +7,7 @@ ErrorDeleteDistinguishedFolder, ErrorFolderExists, ErrorFolderNotFound, + ErrorInvalidIdMalformed, ErrorItemNotFound, ErrorItemSave, ErrorNoPublicFolderReplicaAvailable, @@ -216,20 +217,12 @@ def test_find_folders(self): folders = list(FolderCollection(account=self.account, folders=[self.account.root]).find_folders()) self.assertGreater(len(folders), 40, sorted(f.name for f in folders)) + def test_find_folders_multiple_roots(self): # Test failure on different roots with self.assertRaises(ValueError) as e: list(FolderCollection(account=self.account, folders=[Folder(root="A"), Folder(root="B")]).find_folders()) self.assertIn("All folders in 'roots' must have the same root hierarchy", e.exception.args[0]) - def test_find_folders_multiple_roots(self): - try: - coll = FolderCollection(account=self.account, folders=[self.account.root, self.account.public_folders_root]) - except ErrorFolderNotFound as e: - self.skipTest(str(e)) - with self.assertRaises(ValueError) as e: - list(coll.find_folders(depth="Shallow")) - self.assertIn("All folders in 'roots' must have the same root hierarchy", e.exception.args[0]) - def test_find_folders_compat(self): account = self.get_account() coll = FolderCollection(account=account, folders=[account.root]) @@ -781,6 +774,9 @@ def test_folder_query_set_failures(self): with self.assertRaises(InvalidField) as e: list(fld_qs.filter(XXX="XXX")) self.assertIn("Unknown field path 'XXX' on folders", e.exception.args[0]) + # Test some exception paths + with self.assertRaises(ErrorInvalidIdMalformed): + SingleFolderQuerySet(account=self.account, folder=Folder(root=self.account.root, id="XXX")).get() def test_user_configuration(self): """Test that we can do CRUD operations on user configuration data.""" From 9891174b90bcddd42287c4a94cab03691183c7cd Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 24 Nov 2022 13:59:50 +0100 Subject: [PATCH 316/509] fix: properly handle exceptions that we want to retry --- exchangelib/services/common.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index a27f7678..f1ecf2c8 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -306,21 +306,21 @@ def _get_elements(self, payload): return except ErrorServerBusy as e: self._handle_backoff(e) - continue - except (ErrorTooManyObjectsOpened, ErrorTimeoutExpired, ErrorInternalServerTransientError) as e: - # ErrorTooManyObjectsOpened means there are too many connections to the Exchange database. This is very - # often a symptom of sending too many requests. - # - # ErrorTimeoutExpired can be caused by a busy server, or by overly large requests. Start by lowering the - # session count. This is done by downstream code. - if isinstance(e, ErrorTimeoutExpired) and self.protocol.session_pool_size <= 1: + except ErrorTimeoutExpired as e: + # ErrorTimeoutExpired can be caused by a busy server, or by overly large requests + if self.protocol.session_pool_size <= 1: # We're already as low as we can go, so downstream cannot limit the session count to put less load # on the server. We don't have a way of lowering the page size of requests from # this part of the code yet. Let the user handle this. raise e - - # Re-raise as an ErrorServerBusy with a default delay of 5 minutes - raise ErrorServerBusy(f"Reraised from {e.__class__.__name__}({e})") + # Re-raise as an ErrorServerBusy with a default delay + self._handle_backoff(ErrorServerBusy(f"Reraised from {e.__class__.__name__}({e})")) + except (ErrorTooManyObjectsOpened, ErrorInternalServerTransientError) as e: + # ErrorTooManyObjectsOpened means there are too many connections to the Exchange database. This is very + # often a symptom of sending too many requests. + # + # Re-raise as an ErrorServerBusy with a default delay + self._handle_backoff(ErrorServerBusy(f"Reraised from {e.__class__.__name__}({e})")) finally: if self.streaming: self.stop_streaming() From 1ee86e4933e027f6aa67c438adceb1c2a7e031e4 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 30 Nov 2022 22:53:06 +0100 Subject: [PATCH 317/509] chore: Move actual HTTP request retries out of post_ratelimited() so we can retry based on exceptions in the XML response as well --- CHANGELOG.md | 3 + exchangelib/autodiscover/discovery.py | 24 +-- exchangelib/errors.py | 11 +- exchangelib/properties.py | 3 + exchangelib/protocol.py | 100 ++++++----- exchangelib/services/common.py | 45 +++-- exchangelib/services/get_user_settings.py | 8 +- exchangelib/transport.py | 56 ++----- exchangelib/util.py | 193 ++++++++-------------- tests/test_account.py | 5 - tests/test_autodiscover.py | 2 +- tests/test_errors.py | 9 + tests/test_items/test_generic.py | 4 +- tests/test_services.py | 11 +- tests/test_util.py | 38 +---- 15 files changed, 220 insertions(+), 292 deletions(-) create mode 100644 tests/test_errors.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b350e566..19dccf61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ HEAD - Make SOAP-based autodiscovery the default, and remove support for POX-based discovery. This also removes support for autodiscovery on Exchange 2007. Only `Account(..., autodiscover=True)` is supported again. +- Deprecated `RetryPolicy.may_retry_on_error`. Instead, ad custom retry logic + in `RetryPolicy.raise_response_errors`. +- Moved `exchangelib.util.RETRY_WAIT` to `BaseProtocol.RETRY_WAIT`. 4.9.0 diff --git a/exchangelib/autodiscover/discovery.py b/exchangelib/autodiscover/discovery.py index 6860af92..3235931c 100644 --- a/exchangelib/autodiscover/discovery.py +++ b/exchangelib/autodiscover/discovery.py @@ -9,7 +9,7 @@ from ..errors import AutoDiscoverCircularRedirect, AutoDiscoverFailed, RedirectError, TransportError from ..protocol import FailFast, Protocol from ..transport import get_unauthenticated_autodiscover_response -from ..util import CONNECTION_ERRORS, DummyResponse, get_domain, get_redirect_url +from ..util import CONNECTION_ERRORS, get_domain, get_redirect_url from .cache import autodiscover_cache from .protocol import AutodiscoverProtocol @@ -73,7 +73,6 @@ class Autodiscovery: # When connecting to servers that may not be serving the correct endpoint, we should use a retry policy that does # not leave us hanging for a long time on each step in the protocol. INITIAL_RETRY_POLICY = FailFast() - RETRY_WAIT = 10 # Seconds to wait before retry on connection errors MAX_REDIRECTS = 10 # Maximum number of URL redirects before we give up DNS_RESOLVER_KWARGS = {} DNS_RESOLVER_ATTRS = { @@ -369,17 +368,18 @@ def _step_3(self, hostname): try: _, r = self._get_unauthenticated_response(url=url, method="get") except TransportError: - r = DummyResponse(url=url) - if r.status_code in (301, 302) and "location" in r.headers: - redirect_url = get_redirect_url(r) - if self._redirect_url_is_valid(url=redirect_url): - is_valid_response, ad = self._attempt_response(url=redirect_url) - if is_valid_response: - return self._step_5(ad=ad) - log.debug("Got invalid response") + pass + else: + if r.status_code in (301, 302) and "location" in r.headers: + redirect_url = get_redirect_url(r) + if self._redirect_url_is_valid(url=redirect_url): + is_valid_response, ad = self._attempt_response(url=redirect_url) + if is_valid_response: + return self._step_5(ad=ad) + log.debug("Got invalid response") + return self._step_4(hostname=hostname) + log.debug("Got invalid redirect URL") return self._step_4(hostname=hostname) - log.debug("Got invalid redirect URL") - return self._step_4(hostname=hostname) log.debug("Got no redirect URL") return self._step_4(hostname=hostname) diff --git a/exchangelib/errors.py b/exchangelib/errors.py index 89557ba2..41937ddc 100644 --- a/exchangelib/errors.py +++ b/exchangelib/errors.py @@ -60,17 +60,12 @@ class TransportError(EWSError): class RateLimitError(TransportError): - def __init__(self, value, url, status_code, total_wait): + def __init__(self, value, wait): super().__init__(value) - self.url = url - self.status_code = status_code - self.total_wait = total_wait + self.wait = wait def __str__(self): - return ( - f"{self.value} (gave up after {self.total_wait:.3f} seconds. " - f"URL {self.url} returned status code {self.status_code})" - ) + return f"{self.value} (gave up when asked to back off {self.wait:.3f} seconds)" class SOAPError(TransportError): diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 8d242ec1..112b8914 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -11,6 +11,7 @@ AutoDiscoverFailed, ErrorInternalServerError, ErrorNonExistentMailbox, + ErrorOrganizationNotFederated, ErrorServerBusy, InvalidTypeError, ) @@ -2101,6 +2102,8 @@ def from_xml(cls, elem, account): raise ErrorInternalServerError(error_message) if error_code == "ServerBusy": raise ErrorServerBusy(error_message) + if error_code == "NotFederated": + raise ErrorOrganizationNotFederated(error_message) if error_code not in ("NoError", "RedirectAddress", "RedirectUrl"): return cls(error_code=error_code, error_message=error_message) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 97284f00..339c8f85 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -20,10 +20,14 @@ from .credentials import BaseOAuth2Credentials from .errors import ( CASError, + ErrorInternalServerTransientError, ErrorInvalidSchemaVersionForMailboxVersion, + ErrorServerBusy, InvalidTypeError, MalformedResponseError, RateLimitError, + RedirectError, + RelativeRedirect, SessionPoolMaxSizeReached, SessionPoolMinSizeReached, TransportError, @@ -51,6 +55,7 @@ ResolveNames, ) from .transport import CREDENTIALS_REQUIRED, DEFAULT_HEADERS, NTLM, OAUTH2, get_auth_instance, get_service_authtype +from .util import _get_retry_after, get_redirect_url, is_xml from .version import Version log = logging.getLogger(__name__) @@ -78,6 +83,7 @@ class BaseProtocol: MAX_SESSION_USAGE_COUNT = None # Timeout for HTTP requests TIMEOUT = 120 + RETRY_WAIT = 10 # Seconds to wait before retry on connection errors # The adapter class to use for HTTP requests. Override this if you need e.g. proxy support or specific TLS versions HTTP_ADAPTER_CLS = requests.adapters.HTTPAdapter @@ -656,11 +662,14 @@ def back_off_until(self, value): def back_off(self, seconds): """Set a new back off until value""" - @abc.abstractmethod - def may_retry_on_error(self, response, wait): - """Return whether retries should still be attempted""" - def raise_response_errors(self, response): + if response.status_code == 200: + # Response is OK + return + if response.status_code == 500 and response.content and is_xml(response.content): + # Some genius at Microsoft thinks it's OK to send a valid SOAP response as an HTTP 500 + log.debug("Got status code %s but trying to parse content anyway", response.status_code) + return cas_error = response.headers.get("X-CasErrorCode") if cas_error: if cas_error.startswith("CAS error:"): @@ -673,14 +682,41 @@ def raise_response_errors(self, response): ): # Another way of communicating invalid schema versions raise ErrorInvalidSchemaVersionForMailboxVersion("Invalid server version") + if response.headers.get("connection") == "close": + # Connection closed. OK to retry. + raise ErrorServerBusy("Caused by closed connection") + if ( + response.status_code == 302 + and response.headers.get("location", "").lower() + == "/ews/genericerrorpage.htm?aspxerrorpath=/ews/exchange.asmx" + ): + # Redirect to genericerrorpage.htm is ridiculous behaviour for random outages. OK to retry. + # + # Redirect to '/internalsite/internalerror.asp' or '/internalsite/initparams.aspx' is caused by e.g. TLS + # certificate f*ckups on the Exchange server. We should not retry those. + raise ErrorInternalServerTransientError(f"Caused by HTTP 302 redirect to {response.headers['location']}") + if response.status_code in (301, 302): + try: + redirect_url = get_redirect_url(response=response, allow_relative=False) + except RelativeRedirect as e: + log.debug("Redirect not allowed but we were relative redirected (%s -> %s)", response.url, e.value) + raise RedirectError(url=e.value) + log.debug("Redirect not allowed but we were redirected ( (%s -> %s)", response.url, redirect_url) + raise RedirectError(url=redirect_url) if b"The referenced account is currently locked out" in response.content: raise UnauthorizedError("The referenced account is currently locked out") if response.status_code == 401 and self.fail_fast: # This is a login failure raise UnauthorizedError(f"Invalid credentials for {response.url}") - if "TimeoutException" in response.headers: - # A header set by us on CONNECTION_ERRORS - raise response.headers["TimeoutException"] + if response.status_code == 401: + # EWS sometimes throws 401's when it wants us to throttle connections. OK to retry. + raise ErrorServerBusy("Caused by HTTP 401 response") + if response.status_code == 500 and b"Server Error in '/EWS' Application" in response.content: + # "Server Error in '/EWS' Application" has been seen in highly concurrent settings. OK to retry. + raise ErrorInternalServerTransientError("Caused by \"Server Error in 'EWS' Application\"") + if response.status_code == 503: + # Internal server error. OK to retry. + raise ErrorInternalServerTransientError("Caused by HTTP 503 response") # This could be anything. Let higher layers handle this raise MalformedResponseError( f"Unknown failure in response. Code: {response.status_code} headers: {response.headers} " @@ -702,10 +738,6 @@ def back_off_until(self): def back_off(self, seconds): raise ValueError("Cannot back off with fail-fast policy") - def may_retry_on_error(self, response, wait): - log.debug("No retry with fail-fast policy") - return False - class FaultTolerance(RetryPolicy): """Enables fault-tolerant error handling. Tells internal methods to do an exponential back off when requests start @@ -756,41 +788,19 @@ def back_off_until(self, value): def back_off(self, seconds): if seconds is None: seconds = self.DEFAULT_BACKOFF + if seconds > self.max_wait: + # We lost patience. Session is cleaned up in outer loop + raise RateLimitError("Max timeout reached", wait=seconds) value = datetime.datetime.now() + datetime.timedelta(seconds=seconds) with self._back_off_lock: self._back_off_until = value - def may_retry_on_error(self, response, wait): - if response.status_code not in (301, 302, 401, 500, 503): - # Don't retry if we didn't get a status code that we can hope to recover from - log.debug("No retry: wrong status code %s", response.status_code) - return False - if wait > self.max_wait: - # We lost patience. Session is cleaned up in outer loop - raise RateLimitError( - "Max timeout reached", url=response.url, status_code=response.status_code, total_wait=wait - ) - if response.status_code == 401: - # EWS sometimes throws 401's when it wants us to throttle connections. OK to retry. - return True - if response.headers.get("connection") == "close": - # Connection closed. OK to retry. - return True - if ( - response.status_code == 302 - and response.headers.get("location", "").lower() - == "/ews/genericerrorpage.htm?aspxerrorpath=/ews/exchange.asmx" - ): - # The genericerrorpage.htm/internalerror.asp is ridiculous behaviour for random outages. OK to retry. - # - # Redirect to '/internalsite/internalerror.asp' or '/internalsite/initparams.aspx' is caused by e.g. TLS - # certificate f*ckups on the Exchange server. We should not retry those. - return True - if response.status_code == 503: - # Internal server error. OK to retry. - return True - if response.status_code == 500 and b"Server Error in '/EWS' Application" in response.content: - # "Server Error in '/EWS' Application" has been seen in highly concurrent settings. OK to retry. - log.debug("Retry allowed: conditions met") - return True - return False + def raise_response_errors(self, response): + try: + return super().raise_response_errors(response) + except (ErrorInternalServerTransientError, ErrorServerBusy) as e: + # Pass on the retry header value + retry_after = _get_retry_after(response) + if retry_after: + raise ErrorServerBusy(e.args[0], back_off=retry_after) + raise diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index f1ecf2c8..55d73a42 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -3,6 +3,8 @@ from contextlib import suppress from itertools import chain +from oauthlib.oauth2 import TokenExpiredError + from .. import errors from ..attachments import AttachmentId from ..credentials import IMPERSONATION, BaseOAuth2Credentials @@ -298,30 +300,46 @@ def _get_elements(self, payload): """Send the payload to be sent and parsed. Handles and re-raise exceptions that are not meant to be returned to the caller as exception objects. Retry the request according to the retry policy. """ + wait = self.protocol.RETRY_WAIT while True: try: # Create a generator over the response elements so exceptions in response elements are also raised # here and can be handled. yield from self._response_generator(payload=payload) + # TODO: Restore session pool size on succeeding request? return + except TokenExpiredError: + # Retry immediately + continue except ErrorServerBusy as e: + if not e.back_off: + e.back_off = wait self._handle_backoff(e) + except ErrorExceededConnectionCount as e: + # ErrorExceededConnectionCount indicates that the connecting user has too many open TCP connections to + # the server. Decrease our session pool size and retry immediately. + try: + self.protocol.decrease_poolsize() + continue + except SessionPoolMinSizeReached: + # We're already as low as we can go. Let the user handle this. + raise e except ErrorTimeoutExpired as e: - # ErrorTimeoutExpired can be caused by a busy server, or by overly large requests + # ErrorTimeoutExpired can be caused by a busy server, or by an overly large request. If it's the latter, + # we don't want to continue hammering the server with this request indefinitely. Instead, lower the + # connection count, if possible, and retry the request. if self.protocol.session_pool_size <= 1: - # We're already as low as we can go, so downstream cannot limit the session count to put less load - # on the server. We don't have a way of lowering the page size of requests from - # this part of the code yet. Let the user handle this. + # We're already as low as we can go. We can no longer use the session count to put less load + # on the server. If this is a chunked request we could lower the chunk size, but we don't have a + # way of doing that from this part of the code yet. Let the user handle this. raise e - # Re-raise as an ErrorServerBusy with a default delay - self._handle_backoff(ErrorServerBusy(f"Reraised from {e.__class__.__name__}({e})")) + self._handle_backoff(ErrorServerBusy(f"Reraised from {e.__class__.__name__}({e})", back_off=wait)) except (ErrorTooManyObjectsOpened, ErrorInternalServerTransientError) as e: # ErrorTooManyObjectsOpened means there are too many connections to the Exchange database. This is very # often a symptom of sending too many requests. - # - # Re-raise as an ErrorServerBusy with a default delay - self._handle_backoff(ErrorServerBusy(f"Reraised from {e.__class__.__name__}({e})")) + self._handle_backoff(ErrorServerBusy(f"Reraised from {e.__class__.__name__}({e})", back_off=wait)) finally: + wait *= 2 # Increase delay for every retry if self.streaming: self.stop_streaming() @@ -406,15 +424,6 @@ def _get_response_xml(self, payload, **parse_opts): # The guessed server version is wrong. Try the next version log.debug("API version %s was invalid", api_version) continue - except ErrorExceededConnectionCount as e: - # This indicates that the connecting user has too many open TCP connections to the server. Decrease - # our session pool size. - try: - self.protocol.decrease_poolsize() - continue - except SessionPoolMinSizeReached: - # We're already as low as we can go. Let the user handle this. - raise e finally: if not self.streaming: # In streaming mode, we may not have accessed the raw stream yet. Caller must handle this. diff --git a/exchangelib/services/get_user_settings.py b/exchangelib/services/get_user_settings.py index 95a7bf3b..d0188a57 100644 --- a/exchangelib/services/get_user_settings.py +++ b/exchangelib/services/get_user_settings.py @@ -1,6 +1,6 @@ import logging -from ..errors import MalformedResponseError +from ..errors import ErrorInternalServerError, ErrorOrganizationNotFederated, ErrorServerBusy, MalformedResponseError from ..properties import UserResponse from ..transport import DEFAULT_ENCODING from ..util import ANS, add_xml_child, create_element, get_xml_attr, ns_translation, set_xml_value, xml_to_str @@ -82,6 +82,12 @@ def _get_element_container(self, message, name=None): return container # Raise any non-acceptable errors in the container, or return the container or the acceptable exception instance msg_text = get_xml_attr(response, f"{{{ANS}}}ErrorMessage") + if error_code == "InternalServerError": + raise ErrorInternalServerError(msg_text) + if error_code == "ServerBusy": + raise ErrorServerBusy(msg_text) + if error_code == "NotFederated": + raise ErrorOrganizationNotFederated(msg_text) try: raise self._get_exception(code=error_code, text=msg_text, msg_xml=None) except self.ERRORS_TO_CATCH_IN_RESPONSE as e: diff --git a/exchangelib/transport.py b/exchangelib/transport.py index 05218a52..50bf28a0 100644 --- a/exchangelib/transport.py +++ b/exchangelib/transport.py @@ -1,13 +1,12 @@ import logging -import time from contextlib import suppress import requests.auth import requests_ntlm import requests_oauthlib -from .errors import TransportError, UnauthorizedError -from .util import CONNECTION_ERRORS, RETRY_WAIT, TLS_ERRORS, DummyResponse, _back_off_if_needed, _retry_after +from .errors import RateLimitError, TransportError, UnauthorizedError +from .util import CONNECTION_ERRORS, TLS_ERRORS, _back_off_if_needed log = logging.getLogger(__name__) @@ -75,12 +74,10 @@ def get_service_authtype(protocol): service_endpoint = protocol.service_endpoint retry_policy = protocol.retry_policy - retry = 0 - t_start = time.monotonic() + wait = protocol.RETRY_WAIT for api_version in ConvertId.supported_api_versions(): protocol.api_version_hint = api_version data = protocol.dummy_xml() - headers = {} log.debug("Requesting %s from %s", data, service_endpoint) while True: _back_off_if_needed(retry_policy.back_off_until) @@ -89,7 +86,6 @@ def get_service_authtype(protocol): try: r = s.post( url=service_endpoint, - headers=headers, data=data, allow_redirects=False, timeout=protocol.TIMEOUT, @@ -98,21 +94,13 @@ def get_service_authtype(protocol): break except CONNECTION_ERRORS as e: # Don't retry on TLS errors. They will most likely be persistent. - total_wait = time.monotonic() - t_start - r = DummyResponse(url=service_endpoint, request_headers=headers) - if retry_policy.may_retry_on_error(response=r, wait=total_wait): - wait = _retry_after(r, RETRY_WAIT) - log.info( - "Connection error on URL %s (retry %s, error: %s). Cool down %s secs", - service_endpoint, - retry, - e, - wait, - ) + log.info("Connection error on URL %s.", service_endpoint) + if not retry_policy.fail_fast: retry_policy.back_off(wait) - retry += 1 continue raise TransportError(str(e)) from e + finally: + wait *= 2 if r.status_code not in (200, 401): log.debug("Unexpected response: %s %s", r.status_code, r.reason) continue @@ -135,12 +123,9 @@ def get_autodiscover_authtype(protocol): def get_unauthenticated_autodiscover_response(protocol, method, headers=None, data=None): - from .autodiscover import Autodiscovery - service_endpoint = protocol.service_endpoint retry_policy = protocol.retry_policy - retry = 0 - t_start = time.monotonic() + wait = protocol.RETRY_WAIT while True: _back_off_if_needed(retry_policy.back_off_until) log.debug("Trying to get response from %s", service_endpoint) @@ -159,23 +144,16 @@ def get_unauthenticated_autodiscover_response(protocol, method, headers=None, da # Don't retry on TLS errors. But wrap, so we can catch later and continue with the next endpoint. raise TransportError(str(e)) except CONNECTION_ERRORS as e: - r = DummyResponse(url=service_endpoint, request_headers=headers) - total_wait = time.monotonic() - t_start - if retry_policy.may_retry_on_error(response=r, wait=total_wait): - log.debug( - "Connection error on URL %s (retry %s, error: %s). Cool down %s secs", - service_endpoint, - retry, - e, - Autodiscovery.RETRY_WAIT, - ) - # Don't respect the 'Retry-After' header. We don't know if this is a useful endpoint, and we - # want autodiscover to be reasonably fast. - retry_policy.back_off(Autodiscovery.RETRY_WAIT) - retry += 1 - continue log.debug("Connection error on URL %s: %s", service_endpoint, e) - raise TransportError(str(e)) + if not retry_policy.fail_fast: + try: + retry_policy.back_off(wait) + continue + except RateLimitError: + pass + raise TransportError(str(e)) from e + finally: + wait *= 2 return r diff --git a/exchangelib/util.py b/exchangelib/util.py index ed76ec9c..4f3605a6 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -25,7 +25,7 @@ from pygments.lexers.html import XmlLexer from requests_oauthlib import OAuth2Session -from .errors import MalformedResponseError, RateLimitError, RedirectError, RelativeRedirect, TransportError +from .errors import ErrorInternalServerTransientError, ErrorTimeoutExpired, RelativeRedirect, TransportError log = logging.getLogger(__name__) xml_log = logging.getLogger(f"{__name__}.xml") @@ -715,8 +715,6 @@ def get_redirect_url(response, allow_relative=True, require_relative=False): return redirect_url -RETRY_WAIT = 10 # Seconds to wait before retry on connection errors - # A collection of error classes we want to handle as general connection errors CONNECTION_ERRORS = ( requests.exceptions.ChunkedEncodingError, @@ -773,11 +771,7 @@ def post_ratelimited(protocol, session, url, headers, data, stream=False, timeou if not timeout: timeout = protocol.TIMEOUT thread_id = get_ident() - wait = RETRY_WAIT # Initial retry wait. We double the value on each retry - retry = 0 log_msg = """\ -Retry: %(retry)s -Waited: %(wait)s Timeout: %(timeout)s Session: %(session_id)s Thread: %(thread_id)s @@ -793,8 +787,6 @@ def post_ratelimited(protocol, session, url, headers, data, stream=False, timeou Request XML: %(xml_request)s Response XML: %(xml_response)s""" log_vals = dict( - retry=retry, - wait=wait, timeout=timeout, session_id=session.session_id, thread_id=thread_id, @@ -808,147 +800,94 @@ def post_ratelimited(protocol, session, url, headers, data, stream=False, timeou response_headers=None, ) xml_log_vals = dict( - xml_request=None, + xml_request=data, xml_response=None, ) - t_start = time.monotonic() + sleep_secs = _back_off_if_needed(protocol.retry_policy.back_off_until) + if sleep_secs: + # We may have slept for a long time. Renew the session. + session = protocol.renew_session(session) + log.debug( + "Session %s thread %s timeout %s: POST'ing to %s after %ss sleep", + session.session_id, + thread_id, + timeout, + url, + sleep_secs, + ) + kwargs = dict(url=url, headers=headers, data=data, allow_redirects=False, timeout=timeout, stream=stream) + if isinstance(session, OAuth2Session): + # Fix token refreshing bug. Reported as https://github.com/requests/requests-oauthlib/issues/498 + kwargs.update(session.auto_refresh_kwargs) + d_start = time.monotonic() try: - while True: - backed_off = _back_off_if_needed(protocol.retry_policy.back_off_until) - if backed_off: - # We may have slept for a long time. Renew the session. - session = protocol.renew_session(session) - log.debug( - "Session %s thread %s: retry %s timeout %s POST'ing to %s after %ss wait", - session.session_id, - thread_id, - retry, - timeout, - url, - wait, - ) - d_start = time.monotonic() - # Always create a dummy response for logging purposes, in case we fail in the following - r = DummyResponse(url=url, request_headers=headers) - kwargs = dict(url=url, headers=headers, data=data, allow_redirects=False, timeout=timeout, stream=stream) - if isinstance(session, OAuth2Session): - # Fix token refreshing bug. Reported as https://github.com/requests/requests-oauthlib/issues/498 - kwargs.update(session.auto_refresh_kwargs) - try: - r = session.post(**kwargs) - except TLS_ERRORS as e: - # Don't retry on TLS errors. They will most likely be persistent. - raise TransportError(str(e)) - except CONNECTION_ERRORS as e: - log.debug("Session %s thread %s: connection error POST'ing to %s", session.session_id, thread_id, url) - r = DummyResponse(url=url, headers={"TimeoutException": e}, request_headers=headers) - except TokenExpiredError as e: - log.debug("Session %s thread %s: OAuth token expired; refreshing", session.session_id, thread_id) - r = DummyResponse(url=url, headers={"TokenExpiredError": e}, request_headers=headers, status_code=401) - except KeyError as e: - if e.args[0] != "www-authenticate": - raise - log.debug("Session %s thread %s: auth headers missing from %s", session.session_id, thread_id, url) - r = DummyResponse(url=url, headers={"KeyError": e}, request_headers=headers) - finally: - log_vals.update( - retry=retry, - wait=wait, - session_id=session.session_id, - url=str(r.url), - response_time=time.monotonic() - d_start, - status_code=r.status_code, - request_headers=r.request.headers, - response_headers=r.headers, - ) - xml_log_vals.update( - xml_request=data, - xml_response="[STREAMING]" if stream else r.content, - ) - log.debug(log_msg, log_vals) - xml_log.debug(xml_log_msg, xml_log_vals) - if _need_new_credentials(response=r): - r.close() # Release memory - session = protocol.refresh_credentials(session) - continue - total_wait = time.monotonic() - t_start - if protocol.retry_policy.may_retry_on_error(response=r, wait=total_wait): - r.close() # Release memory - log.info( - "Session %s thread %s: Connection error on URL %s (code %s). Cool down %s secs", - session.session_id, - thread_id, - r.url, - r.status_code, - wait, - ) - wait = _retry_after(r, wait) - protocol.retry_policy.back_off(wait) - retry += 1 - wait *= 2 # Increase delay for every retry - continue - if r.status_code in (301, 302): - r.close() # Release memory - url = _fail_on_redirect(r) - continue - break - except (RateLimitError, RedirectError) as e: - log.warning(e.value) + r = session.post(**kwargs) + except TLS_ERRORS as e: protocol.retire_session(session) + # Don't retry on TLS errors. They will most likely be persistent. + raise TransportError(str(e)) + except CONNECTION_ERRORS as e: + protocol.retire_session(session) + log.debug("Session %s thread %s: connection error POST'ing to %s", session.session_id, thread_id, url) + raise ErrorTimeoutExpired(f"Reraised from {e.__class__.__name__}({e})") + except TokenExpiredError: + log.debug("Session %s thread %s: OAuth token expired; refreshing", session.session_id, thread_id) + protocol.release_session(protocol.refresh_credentials(session)) raise + except KeyError as e: + protocol.retire_session(session) + if e.args[0] != "www-authenticate": + raise + # Server returned an HTTP error code during the NTLM handshake. Re-raise as internal server error + log.debug("Session %s thread %s: auth headers missing from %s", session.session_id, thread_id, url) + raise ErrorInternalServerTransientError(f"Reraised from {e.__class__.__name__}({e})") except Exception as e: # Let higher layers handle this. Add full context for better debugging. log.error("%s: %s\n%s\n%s", e.__class__.__name__, str(e), log_msg % log_vals, xml_log_msg % xml_log_vals) protocol.retire_session(session) raise - if r.status_code == 500 and r.content and is_xml(r.content): - # Some genius at Microsoft thinks it's OK to send a valid SOAP response as an HTTP 500 - log.debug("Got status code %s but trying to parse content anyway", r.status_code) - elif r.status_code != 200: + log_vals.update( + session_id=session.session_id, + url=r.url, + response_time=time.monotonic() - d_start, + status_code=r.status_code, + request_headers=r.request.headers, + response_headers=r.headers, + ) + xml_log_vals.update( + xml_request=data, + xml_response="[STREAMING]" if stream else r.content, + ) + log.debug(log_msg, log_vals) + xml_log.debug(xml_log_msg, xml_log_vals) + + try: + protocol.retry_policy.raise_response_errors(r) + except Exception: + r.close() # Release memory protocol.retire_session(session) - try: - protocol.retry_policy.raise_response_errors(r) # Always raises an exception - except MalformedResponseError as e: - log.error("%s: %s\n%s\n%s", e.__class__.__name__, str(e), log_msg % log_vals, xml_log_msg % xml_log_vals) - raise + raise + log.debug("Session %s thread %s: Useful response from %s", session.session_id, thread_id, url) return r, session def _back_off_if_needed(back_off_until): + # Returns the number of seconds we slept if back_off_until: sleep_secs = (back_off_until - datetime.datetime.now()).total_seconds() # The back off value may have expired within the last few milliseconds if sleep_secs > 0: log.warning("Server requested back off until %s. Sleeping %s seconds", back_off_until, sleep_secs) time.sleep(sleep_secs) - return True - return False - - -def _need_new_credentials(response): - return response.status_code == 401 and response.headers.get("TokenExpiredError") + return sleep_secs + return 0 -def _fail_on_redirect(response): - # Retry with no delay. If we let requests handle redirects automatically, it would issue a GET to that - # URL. We still want to POST. +def _get_retry_after(r): + """Get Retry-After header, if it exists. We interpret the value as seconds to wait before sending next request.""" try: - redirect_url = get_redirect_url(response=response, allow_relative=False) - except RelativeRedirect as e: - log.debug("'allow_redirects' only supports relative redirects (%s -> %s)", response.url, e.value) - raise RedirectError(url=e.value) - log.debug("Redirect not allowed but we were redirected ( (%s -> %s)", response.url, redirect_url) - raise RedirectError(url=redirect_url) - - -def _retry_after(r, wait): - """Either return the Retry-After header value or the default wait, whichever is larger.""" - try: - retry_after = int(r.headers.get("Retry-After", "0")) + val = int(r.headers.get("Retry-After", "0")) except ValueError: - pass - else: - if retry_after > wait: - return retry_after - return wait + return None + return val if val > 0 else None diff --git a/tests/test_account.py b/tests/test_account.py index 70d14fd3..762c5601 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -300,11 +300,6 @@ def test_login_failure_and_credentials_update(self): # Should fail when credentials are wrong, but UnauthorizedError is caught and retried. Mock the needed methods class Mock1(FaultTolerance): - def may_retry_on_error(self, response, wait): - if response.status_code == 401: - return False - return super().may_retry_on_error(response, wait) - def raise_response_errors(self, response): if response.status_code == 401: raise UnauthorizedError(f"Invalid credentials for {response.url}") diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 14ca01f5..9786fd17 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -30,7 +30,7 @@ def setUp(self): # Enable retries, to make tests more robust Autodiscovery.INITIAL_RETRY_POLICY = FaultTolerance(max_wait=5) - Autodiscovery.RETRY_WAIT = 5 + AutodiscoverProtocol.RETRY_WAIT = 5 # Each test should start with a clean autodiscover cache clear_cache() diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 00000000..cd8671df --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,9 @@ +from exchangelib.errors import EWSError + +from .common import TimedTestCase + + +class ErrorTest(TimedTestCase): + def test_hash(self): + e = EWSError("foo") + self.assertEqual(hash(e), hash("foo")) diff --git a/tests/test_items/test_generic.py b/tests/test_items/test_generic.py index b9ba8704..b9e73a83 100644 --- a/tests/test_items/test_generic.py +++ b/tests/test_items/test_generic.py @@ -696,8 +696,8 @@ def test_filter_with_querystring(self): item.subject = get_random_string(length=8, spaces=False, special=False) item.save() # For some reason, the querystring search doesn't work instantly. We may have to wait for up to 60 seconds. - # I'm too impatient for that, so also allow empty results. This makes the test almost worthless but I blame EWS. - # Also, some servers are misconfigured and don't support querystrings at all. Don't fail on that. + # I'm too impatient for that, so also allow empty results. This makes the test almost worthless, but I blame + # EWS. Also, some servers are misconfigured and don't support querystrings at all. Don't fail on that. try: self.assertIn(self.test_folder.filter(f"Subject:{item.subject}").count(), (0, 1)) except ErrorInternalServerError as e: diff --git a/tests/test_services.py b/tests/test_services.py index 093694a0..b468794c 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -15,6 +15,7 @@ ErrorServerBusy, ErrorTooManyObjectsOpened, MalformedResponseError, + RateLimitError, SOAPError, TransportError, ) @@ -190,16 +191,16 @@ def test_error_too_many_objects_opened(self, m): with self.assertRaises(ErrorTooManyObjectsOpened): list(ws.parse(xml)) - # Test that it gets converted to an ErrorServerBusy exception. This happens deep inside EWSService methods - # so it's easier to only mock the response. + # Test that it eventually gets converted to an RateLimitError exception. This happens deep inside EWSService + # methods, so it's easier to only mock the response. self.account.root # Needed to get past the GetFolder request m.post(self.account.protocol.service_endpoint, content=xml) orig_policy = self.account.protocol.config.retry_policy try: - self.account.protocol.config.retry_policy = FaultTolerance(max_wait=0) - with self.assertRaises(ErrorServerBusy) as e: + self.account.protocol.config.retry_policy = FaultTolerance(max_wait=1) # Set max_wait < RETRY_WAIT + with self.assertRaises(RateLimitError) as e: list(FolderCollection(account=self.account, folders=[self.account.root]).find_folders()) - self.assertEqual(e.exception.back_off, None) # ErrorTooManyObjectsOpened has no BackOffMilliseconds value + self.assertEqual(e.exception.wait, self.account.protocol.RETRY_WAIT) finally: self.account.protocol.config.retry_policy = orig_policy diff --git a/tests/test_util.py b/tests/test_util.py index 9cfe331b..4d201811 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -7,10 +7,10 @@ import requests import requests_mock -import exchangelib.util from exchangelib.errors import ( CASError, - RateLimitError, + ErrorServerBusy, + ErrorTimeoutExpired, RedirectError, RelativeRedirect, TransportError, @@ -231,7 +231,7 @@ def test_post_ratelimited(self): protocol = self.account.protocol orig_policy = protocol.config.retry_policy - RETRY_WAIT = exchangelib.util.RETRY_WAIT + orig_wait = protocol.RETRY_WAIT session = protocol.get_session() try: @@ -246,7 +246,7 @@ def test_post_ratelimited(self): # Test exceptions raises by the POST request for err_cls in CONNECTION_ERRORS: session.post = mock_session_exception(err_cls) - with self.assertRaises(err_cls): + with self.assertRaises(ErrorTimeoutExpired): r, session = post_ratelimited( protocol=protocol, session=session, url="http://", headers=None, data="" ) @@ -300,39 +300,19 @@ def test_post_ratelimited(self): r, session = post_ratelimited(protocol=protocol, session=session, url=url, headers=None, data="") # Test rate limit exceeded - exchangelib.util.RETRY_WAIT = 1 - protocol.config.retry_policy = FaultTolerance(max_wait=0.5) # Fail after first RETRY_WAIT + protocol.RETRY_WAIT = 1 + protocol.config.retry_policy = FaultTolerance(max_wait=0.5) session.post = mock_post(url, 503, {"connection": "close"}) # Mock renew_session to return the same session so the session object's 'post' method is still mocked protocol.renew_session = lambda s: s - with self.assertRaises(RateLimitError) as rle: + with self.assertRaises(ErrorServerBusy) as rle: r, session = post_ratelimited(protocol=protocol, session=session, url="http://", headers=None, data="") - self.assertEqual(rle.exception.status_code, 503) - self.assertEqual(rle.exception.url, url) - self.assertRegex( - str(rle.exception), - r"Max timeout reached \(gave up after .* seconds. URL https://example.com returned status code 503\)", - ) - self.assertTrue(1 <= rle.exception.total_wait < 2) # One RETRY_WAIT plus some overhead - - # Test something larger than the default wait, so we retry at least once - protocol.retry_policy.max_wait = 3 # Fail after second RETRY_WAIT - session.post = mock_post(url, 503, {"connection": "close"}) - with self.assertRaises(RateLimitError) as rle: - r, session = post_ratelimited(protocol=protocol, session=session, url="http://", headers=None, data="") - self.assertEqual(rle.exception.status_code, 503) - self.assertEqual(rle.exception.url, url) - self.assertRegex( - str(rle.exception), - r"Max timeout reached \(gave up after .* seconds. URL https://example.com returned status code 503\)", - ) - # We double the wait for each retry, so this is RETRY_WAIT + 2*RETRY_WAIT plus some overhead - self.assertTrue(3 <= rle.exception.total_wait < 4, rle.exception.total_wait) + self.assertEqual(str(rle.exception), "Caused by closed connection") finally: protocol.retire_session(session) # We have patched the session, so discard it # Restore patched attributes and functions protocol.config.retry_policy = orig_policy - exchangelib.util.RETRY_WAIT = RETRY_WAIT + protocol.RETRY_WAIT = orig_wait with suppress(AttributeError): delattr(protocol, "renew_session") From e7d7d842edb067b84e4017d95f961bd1f4b3afca Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 30 Nov 2022 23:18:50 +0100 Subject: [PATCH 318/509] fix: Support updating the Folder.permission_set field. Closes #1145 --- exchangelib/fields.py | 3 +++ tests/test_folder.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index 360aaed7..07e9556e 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -1475,6 +1475,9 @@ def __init__(self, *args, **kwargs): kwargs["value_cls"] = PermissionSet super().__init__(*args, **kwargs) + def to_xml(self, value, version): + return value.to_xml(version=version) + class EffectiveRightsField(EWSElementField): def __init__(self, *args, **kwargs): diff --git a/tests/test_folder.py b/tests/test_folder.py index 51a225dd..c12e24d3 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -437,6 +437,24 @@ def test_counts(self): self.assertEqual(f.child_folder_count, 0) f.delete() + def test_update(self): + # Test that we can update folder attributes + f = Folder(parent=self.account.inbox, name=get_random_string(16)).save() + old_values = {} + for field in f.FIELDS: + if field.name in ("account", "id", "changekey", "folder_class", "parent_folder_id"): + # These are needed for a successful refresh() + continue + if field.is_read_only: + continue + old_values[field.name] = getattr(f, field.name) + new_val = self.random_val(field) + setattr(f, field.name, new_val) + f.save() + f.refresh() + for field_name, value in old_values.items(): + self.assertNotEqual(value, getattr(f, field_name)) + def test_refresh(self): # Test that we can refresh folders f = Folder(parent=self.account.inbox, name=get_random_string(16)).save() From 419eafcd9261bfd0617823ee437204d5556a8271 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 1 Dec 2022 23:41:52 +0100 Subject: [PATCH 319/509] chore: Add timezone in latest tzdata release --- exchangelib/winzone.py | 1 + tests/test_ewsdatetime.py | 1 + 2 files changed, 2 insertions(+) diff --git a/exchangelib/winzone.py b/exchangelib/winzone.py index a236cd08..0d9d0e4a 100644 --- a/exchangelib/winzone.py +++ b/exchangelib/winzone.py @@ -516,6 +516,7 @@ def generate_map(timeout=10): "America/Argentina/Mendoza": CLDR_TO_MS_TIMEZONE_MAP["America/Mendoza"], "America/Atikokan": CLDR_TO_MS_TIMEZONE_MAP["America/Coral_Harbour"], "America/Atka": CLDR_TO_MS_TIMEZONE_MAP["America/Adak"], + "America/Ciudad_Juarez": CLDR_TO_MS_TIMEZONE_MAP["America/Chihuahua"], "America/Ensenada": CLDR_TO_MS_TIMEZONE_MAP["America/Tijuana"], "America/Fort_Wayne": CLDR_TO_MS_TIMEZONE_MAP["America/Indianapolis"], "America/Indiana/Indianapolis": CLDR_TO_MS_TIMEZONE_MAP["America/Indianapolis"], diff --git a/tests/test_ewsdatetime.py b/tests/test_ewsdatetime.py index b0ce06f3..0a3f4d6f 100644 --- a/tests/test_ewsdatetime.py +++ b/tests/test_ewsdatetime.py @@ -221,6 +221,7 @@ def test_generate(self): self.assertDictEqual(tz_map, CLDR_TO_MS_TIMEZONE_MAP) # Test IANA exceptions. This fails if available_timezones() returns timezones that we have not yet implemented. + # If this fails in CI but not locally, you need to update the 'tzdata' package to the latest version. sanitized = list(t for t in zoneinfo.available_timezones() if not t.startswith("SystemV/") and t != "localtime") self.assertEqual(set(sanitized) - set(EWSTimeZone.IANA_TO_MS_MAP), set()) From 05b7fde45d0a680269a5109c149e749af63f509b Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Fri, 2 Dec 2022 21:30:09 +0100 Subject: [PATCH 320/509] fix: Return the ID that send() returns back to the caller --- exchangelib/items/item.py | 2 +- exchangelib/items/message.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/exchangelib/items/item.py b/exchangelib/items/item.py index a9c75666..50fe3987 100644 --- a/exchangelib/items/item.py +++ b/exchangelib/items/item.py @@ -407,7 +407,7 @@ def create_forward(self, subject, body, to_recipients, cc_recipients=None, bcc_r ) def forward(self, subject, body, to_recipients, cc_recipients=None, bcc_recipients=None): - self.create_forward( + return self.create_forward( subject, body, to_recipients, diff --git a/exchangelib/items/message.py b/exchangelib/items/message.py index dade5cfe..77f3a016 100644 --- a/exchangelib/items/message.py +++ b/exchangelib/items/message.py @@ -138,7 +138,7 @@ def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bc ) def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): - self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients).send() + return self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients).send() @require_id def create_reply_all(self, subject, body): @@ -156,7 +156,7 @@ def create_reply_all(self, subject, body): ) def reply_all(self, subject, body): - self.create_reply_all(subject, body).send() + return self.create_reply_all(subject, body).send() def mark_as_junk(self, is_junk=True, move_item=True): """Mark or un-marks items as junk email. From 642a3b87cc974631898f213bdd3a9a68ff73e20b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s?= Date: Wed, 1 Mar 2023 21:54:31 +0200 Subject: [PATCH 321/509] added option to change from_address when creating reply (#1167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * added option to change author when creating reply --------- Co-authored-by: Tamás Bakos --- exchangelib/items/message.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/exchangelib/items/message.py b/exchangelib/items/message.py index 77f3a016..89f729ba 100644 --- a/exchangelib/items/message.py +++ b/exchangelib/items/message.py @@ -122,7 +122,7 @@ def send_and_save( self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations) @require_id - def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): + def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None): if to_recipients is None: if not self.author: raise ValueError("'to_recipients' must be set when message has no 'author'") @@ -135,13 +135,14 @@ def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bc to_recipients=to_recipients, cc_recipients=cc_recipients, bcc_recipients=bcc_recipients, + author=author, ) - def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): - return self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients).send() + def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None): + return self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients, author).send() @require_id - def create_reply_all(self, subject, body): + def create_reply_all(self, subject, body, author=None): to_recipients = list(self.to_recipients) if self.to_recipients else [] if self.author: to_recipients.append(self.author) @@ -153,10 +154,11 @@ def create_reply_all(self, subject, body): to_recipients=to_recipients, cc_recipients=self.cc_recipients, bcc_recipients=self.bcc_recipients, + author=author, ) - def reply_all(self, subject, body): - return self.create_reply_all(subject, body).send() + def reply_all(self, subject, body, author=None): + return self.create_reply_all(subject, body, author).send() def mark_as_junk(self, is_junk=True, move_item=True): """Mark or un-marks items as junk email. From 0887fdb4f5a838465900266fbeab69de35ea1cc0 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 1 Mar 2023 21:37:59 +0100 Subject: [PATCH 322/509] ci: Catch up with timezone updates --- exchangelib/winzone.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/exchangelib/winzone.py b/exchangelib/winzone.py index 0d9d0e4a..0e5f5945 100644 --- a/exchangelib/winzone.py +++ b/exchangelib/winzone.py @@ -26,7 +26,7 @@ def generate_map(timeout=10): type_version = timezones_elem.get("typeVersion") other_version = timezones_elem.get("otherVersion") for e in timezones_elem.findall("mapZone"): - for location in re.split(r"\s+", e.get("type")): + for location in re.split(r"\s+", e.get("type").strip()): if e.get("territory") == DEFAULT_TERRITORY or location not in tz_map: # Prefer default territory. This is so MS_TIMEZONE_TO_IANA_MAP maps from MS timezone ID back to the # "preferred" region/location timezone name. @@ -123,7 +123,8 @@ def generate_map(timeout=10): "America/Cayenne": ("SA Eastern Standard Time", "001"), "America/Cayman": ("SA Pacific Standard Time", "KY"), "America/Chicago": ("Central Standard Time", "001"), - "America/Chihuahua": ("Mountain Standard Time (Mexico)", "001"), + "America/Chihuahua": ("Central Standard Time (Mexico)", "MX"), + "America/Ciudad_Juarez": ("Mountain Standard Time", "MX"), "America/Coral_Harbour": ("SA Pacific Standard Time", "CA"), "America/Cordoba": ("Argentina Standard Time", "AR"), "America/Costa_Rica": ("Central America Standard Time", "CR"), @@ -179,7 +180,7 @@ def generate_map(timeout=10): "America/Marigot": ("SA Western Standard Time", "MF"), "America/Martinique": ("SA Western Standard Time", "MQ"), "America/Matamoros": ("Central Standard Time", "MX"), - "America/Mazatlan": ("Mountain Standard Time (Mexico)", "MX"), + "America/Mazatlan": ("Mountain Standard Time (Mexico)", "001"), "America/Mendoza": ("Argentina Standard Time", "AR"), "America/Menominee": ("Central Standard Time", "US"), "America/Merida": ("Central Standard Time (Mexico)", "MX"), @@ -199,7 +200,7 @@ def generate_map(timeout=10): "America/North_Dakota/Beulah": ("Central Standard Time", "US"), "America/North_Dakota/Center": ("Central Standard Time", "US"), "America/North_Dakota/New_Salem": ("Central Standard Time", "US"), - "America/Ojinaga": ("Mountain Standard Time", "MX"), + "America/Ojinaga": ("Central Standard Time", "MX"), "America/Panama": ("SA Pacific Standard Time", "PA"), "America/Pangnirtung": ("Eastern Standard Time", "CA"), "America/Paramaribo": ("SA Eastern Standard Time", "SR"), @@ -516,7 +517,6 @@ def generate_map(timeout=10): "America/Argentina/Mendoza": CLDR_TO_MS_TIMEZONE_MAP["America/Mendoza"], "America/Atikokan": CLDR_TO_MS_TIMEZONE_MAP["America/Coral_Harbour"], "America/Atka": CLDR_TO_MS_TIMEZONE_MAP["America/Adak"], - "America/Ciudad_Juarez": CLDR_TO_MS_TIMEZONE_MAP["America/Chihuahua"], "America/Ensenada": CLDR_TO_MS_TIMEZONE_MAP["America/Tijuana"], "America/Fort_Wayne": CLDR_TO_MS_TIMEZONE_MAP["America/Indianapolis"], "America/Indiana/Indianapolis": CLDR_TO_MS_TIMEZONE_MAP["America/Indianapolis"], From 416107969ef6709fd327a07560219d5e42044ad1 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 1 Mar 2023 21:39:19 +0100 Subject: [PATCH 323/509] ci: Support newer Pygments --- tests/test_util.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_util.py b/tests/test_util.py index 4d201811..46f99301 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -222,8 +222,8 @@ def test_pretty_xml_handler(self): h.stream.seek(0) self.assertEqual( h.stream.read(), - "hello \x1b[36m\x1b[39;49;00m\n\x1b[94m" - "\x1b[39;49;00mbar\x1b[94m\x1b[39;49;00m\n", + "hello \x1b[36m\x1b[39;49;00m\x1b[37m\x1b[39;49;00m\n\x1b[94m" + "\x1b[39;49;00mbar\x1b[94m\x1b[39;49;00m\x1b[37m\x1b[39;49;00m\n", ) def test_post_ratelimited(self): From 32992f087ee95d6efdc292580c4b2630abb9c6bb Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 2 Mar 2023 09:58:55 +0100 Subject: [PATCH 324/509] fix: Make subfolders lock an instance variable. Fixes #1161 --- exchangelib/folders/roots.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/exchangelib/folders/roots.py b/exchangelib/folders/roots.py index a7eb0dfb..7433acf4 100644 --- a/exchangelib/folders/roots.py +++ b/exchangelib/folders/roots.py @@ -29,8 +29,6 @@ class RootOfHierarchy(BaseFolder, metaclass=EWSMeta): # 'RootOfHierarchy' subclasses must not be in this list. WELLKNOWN_FOLDERS = [] - _subfolders_lock = Lock() - # This folder type also has 'folder:PermissionSet' on some server versions, but requesting it sometimes causes # 'ErrorAccessDenied', as reported by some users. Ignore it entirely for root folders - it's usefulness is # deemed minimal at best. @@ -38,13 +36,14 @@ class RootOfHierarchy(BaseFolder, metaclass=EWSMeta): field_uri="folder:EffectiveRights", is_read_only=True, supported_from=EXCHANGE_2007_SP1 ) - __slots__ = "_account", "_subfolders" + __slots__ = "_account", "_subfolders", "_subfolders_lock" # A special folder that acts as the top of a folder hierarchy. Finds and caches sub-folders at arbitrary depth. def __init__(self, **kwargs): self._account = kwargs.pop("account", None) # A pointer back to the account holding the folder hierarchy super().__init__(**kwargs) self._subfolders = None # See self._folders_map() + self._subfolders_lock = Lock() @property def account(self): @@ -210,6 +209,18 @@ def folder_cls_from_folder_name(cls, folder_name, locale): return folder_cls raise KeyError() + def __getstate__(self): + # The lock cannot be pickled + state = {k: getattr(self, k) for k in self._slots_keys} + del state["_subfolders_lock"] + return state + + def __setstate__(self, state): + # Restore the lock + for k in self._slots_keys: + setattr(self, k, state.get(k)) + self._subfolders_lock = Lock() + def __repr__(self): # Let's not create an infinite loop when printing self.root return self.__class__.__name__ + repr( From fcd5b492e1a1e9a6cca46930669cc1cafeb8af01 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 28 Mar 2023 14:41:25 +0200 Subject: [PATCH 325/509] fix: get_xml_attr() may return None. Fixes #1178 --- exchangelib/services/common.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 55d73a42..0ac93c88 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -519,11 +519,12 @@ def _raise_soap_errors(cls, fault): fault_actor = get_xml_attr(fault, "faultactor") detail = fault.find("detail") if detail is not None: - code, msg = None, "" - if detail.find(f"{{{ENS}}}ResponseCode") is not None: - code = get_xml_attr(detail, f"{{{ENS}}}ResponseCode").strip() - if detail.find(f"{{{ENS}}}Message") is not None: - msg = get_xml_attr(detail, f"{{{ENS}}}Message").strip() + code = get_xml_attr(detail, f"{{{ENS}}}ResponseCode") + if code: + code = code.strip() + msg = get_xml_attr(detail, f"{{{ENS}}}Message") + if msg: + msg = msg.strip() msg_xml = detail.find(f"{{{TNS}}}MessageXml") # Crazy. Here, it's in the TNS namespace if code == "ErrorServerBusy": back_off = None From f7fe6302d6fcfa7bd704b53247b5254d3df48143 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 29 Mar 2023 20:18:12 +0200 Subject: [PATCH 326/509] fix: Take folder_class into account when guessing folder type by folder name. While here, fix spelling error 'CONTAINTER_CLASS'. Fixes #1156 --- exchangelib/folders/base.py | 14 ++++++++++++-- exchangelib/folders/known_folders.py | 6 +++--- exchangelib/folders/roots.py | 9 ++++++--- tests/test_folder.py | 18 ++++++++++++++++++ 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 69728464..65ab2f8f 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -230,6 +230,7 @@ def folder_cls_from_container_class(container_class): from .known_folders import ( ApplicationData, Calendar, + Companies, Contacts, ConversationSettings, CrawlerData, @@ -237,6 +238,8 @@ def folder_cls_from_container_class(container_class): FreeBusyCache, GALContacts, Messages, + OrganizationalContacts, + PeopleCentricConversationBuddies, RecipientCache, RecoveryPoints, Reminders, @@ -249,6 +252,7 @@ def folder_cls_from_container_class(container_class): for folder_cls in ( ApplicationData, Calendar, + Companies, Contacts, ConversationSettings, CrawlerData, @@ -256,6 +260,8 @@ def folder_cls_from_container_class(container_class): FreeBusyCache, GALContacts, Messages, + OrganizationalContacts, + PeopleCentricConversationBuddies, RSSFeeds, RecipientCache, RecoveryPoints, @@ -902,10 +908,14 @@ def from_xml_with_root(cls, elem, root): # # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. - if folder.name: + if folder.name and folder.folder_class: with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative - folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) + folder_cls = root.folder_cls_from_folder_name( + folder_name=folder.name, + folder_class=folder.folder_class, + locale=root.account.locale, + ) log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) if folder.folder_class and folder_cls == Folder: with suppress(KeyError): diff --git a/exchangelib/folders/known_folders.py b/exchangelib/folders/known_folders.py index 6fb05db7..e6c0ba72 100644 --- a/exchangelib/folders/known_folders.py +++ b/exchangelib/folders/known_folders.py @@ -444,7 +444,7 @@ class CommonViews(NonDeletableFolderMixIn, Folder): class Companies(NonDeletableFolderMixIn, Contacts): DISTINGUISHED_FOLDER_ID = None - CONTAINTER_CLASS = "IPF.Contact.Company" + CONTAINER_CLASS = "IPF.Contact.Company" LOCALIZED_NAMES = { None: ("Companies",), "da_DK": ("Firmaer",), @@ -536,7 +536,7 @@ class MyContactsExtended(NonDeletableFolderMixIn, Contacts): class OrganizationalContacts(NonDeletableFolderMixIn, Contacts): DISTINGUISHED_FOLDER_ID = None - CONTAINTER_CLASS = "IPF.Contact.OrganizationalContacts" + CONTAINER_CLASS = "IPF.Contact.OrganizationalContacts" LOCALIZED_NAMES = { None: ("Organizational Contacts",), } @@ -558,7 +558,7 @@ class PassThroughSearchResults(NonDeletableFolderMixIn, Folder): class PeopleCentricConversationBuddies(NonDeletableFolderMixIn, Contacts): DISTINGUISHED_FOLDER_ID = None - CONTAINTER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies" + CONTAINER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies" LOCALIZED_NAMES = { None: ("PeopleCentricConversation Buddies",), } diff --git a/exchangelib/folders/roots.py b/exchangelib/folders/roots.py index 7433acf4..b0ecd40e 100644 --- a/exchangelib/folders/roots.py +++ b/exchangelib/folders/roots.py @@ -198,14 +198,17 @@ def from_xml(cls, elem, account): return cls(account=account, **kwargs) @classmethod - def folder_cls_from_folder_name(cls, folder_name, locale): - """Return the folder class that matches a localized folder name. + def folder_cls_from_folder_name(cls, folder_name, folder_class, locale): + """Return the folder class that matches a localized folder name. Take into account the 'folder_class' of the + folder, to not identify an 'IPF.Note' folder as a 'Calendar' class just because it's called e.g. 'Kalender' and + the locale is 'da_DK'. :param folder_name: + :param folder_class: :param locale: a string, e.g. 'da_DK' """ for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS + MISC_FOLDERS: - if folder_name.lower() in folder_cls.localized_names(locale): + if folder_cls.CONTAINER_CLASS == folder_class and folder_name.lower() in folder_cls.localized_names(locale): return folder_cls raise KeyError() diff --git a/tests/test_folder.py b/tests/test_folder.py index c12e24d3..21fde75b 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -671,6 +671,24 @@ def test_create_update_empty_delete(self): with self.assertRaises(ErrorDeleteDistinguishedFolder): self.account.inbox.delete() + def test_folder_type_guessing(self): + old_locale = self.account.locale + dk_locale = "da_DK" + try: + self.account.locale = dk_locale + # Create a folder to contain the test + f = Messages(parent=self.account.inbox, name=get_random_string(16)).save() + # Create a subfolder with a misleading name + misleading_name = Calendar.LOCALIZED_NAMES[dk_locale][0] + Messages(parent=f, name=misleading_name).save() + # Check that it's still detected as a Messages folder + self.account.root.clear_cache() + test_folder = f / misleading_name + self.assertEqual(type(test_folder), Messages) + self.assertEqual(test_folder.folder_class, Messages.CONTAINER_CLASS) + finally: + self.account.locale = old_locale + def test_wipe_without_empty(self): name = get_random_string(16) f = Messages(parent=self.account.inbox, name=name).save() From 6be4f563e249e5720904b5430e23c3bd1102e0b3 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 29 Mar 2023 20:20:18 +0200 Subject: [PATCH 327/509] chore: Let send_and_save() return whatever upstream returns. --- exchangelib/items/message.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/exchangelib/items/message.py b/exchangelib/items/message.py index 89f729ba..8abeff83 100644 --- a/exchangelib/items/message.py +++ b/exchangelib/items/message.py @@ -98,7 +98,7 @@ def send_and_save( ): # Sends Message and saves a copy in the parent folder. Does not return an ItemId. if self.id: - self._update( + return self._update( update_fieldnames=update_fields, message_disposition=SEND_AND_SAVE_COPY, conflict_resolution=conflict_resolution, @@ -113,13 +113,15 @@ def send_and_save( conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) - self.send( + return self.send( save_copy=False, conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) else: - self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations) + return self._create( + message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations + ) @require_id def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None): From be1cfeeac9b26fae2ff4c513d5a12b0c583277b6 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 29 Mar 2023 20:38:42 +0200 Subject: [PATCH 328/509] docs: Re-generate docs --- docs/exchangelib/account.html | 50 +- docs/exchangelib/attachments.html | 44 +- docs/exchangelib/autodiscover/discovery.html | 539 ++++++--------- docs/exchangelib/autodiscover/index.html | 639 ++++++++++++++---- docs/exchangelib/errors.html | 26 +- docs/exchangelib/fields.html | 169 +---- docs/exchangelib/folders/base.html | 42 +- docs/exchangelib/folders/index.html | 88 ++- docs/exchangelib/folders/known_folders.html | 24 +- docs/exchangelib/folders/roots.html | 68 +- docs/exchangelib/index.html | 293 ++++---- docs/exchangelib/items/index.html | 56 +- docs/exchangelib/items/item.html | 8 +- docs/exchangelib/items/message.html | 74 +- docs/exchangelib/properties.html | 25 +- docs/exchangelib/protocol.html | 283 +++++--- docs/exchangelib/services/common.html | 126 ++-- .../services/get_user_settings.html | 34 +- docs/exchangelib/services/index.html | 78 ++- docs/exchangelib/transport.html | 107 +-- docs/exchangelib/util.html | 348 ++++------ docs/exchangelib/version.html | 79 --- docs/exchangelib/winzone.html | 11 +- 23 files changed, 1563 insertions(+), 1648 deletions(-) diff --git a/docs/exchangelib/account.html b/docs/exchangelib/account.html index a64d15ab..2e08e6d8 100644 --- a/docs/exchangelib/account.html +++ b/docs/exchangelib/account.html @@ -31,8 +31,7 @@

        Module exchangelib.account

        from cached_property import threaded_cached_property -from .autodiscover.discovery.pox import PoxAutodiscovery -from .autodiscover.discovery.soap import SoapAutodiscovery +from .autodiscover import Autodiscovery from .configuration import Configuration from .credentials import ACCESS_TYPES, DELEGATE, IMPERSONATION from .errors import InvalidEnumValue, InvalidTypeError, UnknownTimeZone @@ -126,8 +125,6 @@

        Module exchangelib.account

        class Account: """Models an Exchange server user account.""" - DEFAULT_DISCOVERY_CLS = PoxAutodiscovery - def __init__( self, primary_smtp_address, @@ -146,7 +143,7 @@

        Module exchangelib.account

        :param access_type: The access type granted to 'credentials' for this account. Valid options are 'delegate' and 'impersonation'. 'delegate' is default if 'credentials' is set. Otherwise, 'impersonation' is default. :param autodiscover: Whether to look up the EWS endpoint automatically using the autodiscover protocol. - Can also be set to "pox" or "soap" to choose the autodiscover implementation (Default value = False) + (Default value = False) :param credentials: A Credentials object containing valid credentials for this account. (Default value = None) :param config: A Configuration object containing EWS endpoint information. Required if autodiscover is disabled (Default value = None) @@ -193,12 +190,7 @@

        Module exchangelib.account

        credentials = config.credentials else: auth_type, retry_policy, version = None, None, None - discovery_cls = { - "pox": PoxAutodiscovery, - "soap": SoapAutodiscovery, - True: self.DEFAULT_DISCOVERY_CLS, - }[autodiscover] - self.ad_response, self.protocol = discovery_cls( + self.ad_response, self.protocol = Autodiscovery( email=primary_smtp_address, credentials=credentials ).discover() # Let's not use the auth_package hint from the AD response. It could be incorrect and we can just guess. @@ -804,7 +796,7 @@

        Classes

        :param access_type: The access type granted to 'credentials' for this account. Valid options are 'delegate' and 'impersonation'. 'delegate' is default if 'credentials' is set. Otherwise, 'impersonation' is default. :param autodiscover: Whether to look up the EWS endpoint automatically using the autodiscover protocol. -Can also be set to "pox" or "soap" to choose the autodiscover implementation (Default value = False) +(Default value = False) :param credentials: A Credentials object containing valid credentials for this account. (Default value = None) :param config: A Configuration object containing EWS endpoint information. Required if autodiscover is disabled (Default value = None) @@ -819,8 +811,6 @@

        Classes

        class Account:
             """Models an Exchange server user account."""
         
        -    DEFAULT_DISCOVERY_CLS = PoxAutodiscovery
        -
             def __init__(
                 self,
                 primary_smtp_address,
        @@ -839,7 +829,7 @@ 

        Classes

        :param access_type: The access type granted to 'credentials' for this account. Valid options are 'delegate' and 'impersonation'. 'delegate' is default if 'credentials' is set. Otherwise, 'impersonation' is default. :param autodiscover: Whether to look up the EWS endpoint automatically using the autodiscover protocol. - Can also be set to "pox" or "soap" to choose the autodiscover implementation (Default value = False) + (Default value = False) :param credentials: A Credentials object containing valid credentials for this account. (Default value = None) :param config: A Configuration object containing EWS endpoint information. Required if autodiscover is disabled (Default value = None) @@ -886,12 +876,7 @@

        Classes

        credentials = config.credentials else: auth_type, retry_policy, version = None, None, None - discovery_cls = { - "pox": PoxAutodiscovery, - "soap": SoapAutodiscovery, - True: self.DEFAULT_DISCOVERY_CLS, - }[autodiscover] - self.ad_response, self.protocol = discovery_cls( + self.ad_response, self.protocol = Autodiscovery( email=primary_smtp_address, credentials=credentials ).discover() # Let's not use the auth_package hint from the AD response. It could be incorrect and we can just guess. @@ -1476,28 +1461,6 @@

        Classes

        return f"{self.primary_smtp_address} ({self.fullname})" return self.primary_smtp_address
        -

        Class variables

        -
        -
        var DEFAULT_DISCOVERY_CLS
        -
        -

        Autodiscover is a Microsoft protocol for automatically getting the endpoint of the Exchange server and other -connection-related settings holding the email address using only the email address, and username and password of the -user.

        -

        For a description of the protocol implemented, see "Autodiscover for Exchange ActiveSync developers":

        -

        https://docs.microsoft.com/en-us/previous-versions/office/developer/exchange-server-interoperability-guidance/hh352638%28v%3dexchg.140%29

        -

        Descriptions of the steps from the article are provided in their respective methods in this class.

        -

        For a description of how to handle autodiscover error messages, see:

        -

        https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/handling-autodiscover-error-messages

        -

        A tip from the article: -The client can perform steps 1 through 4 in any order or in parallel to expedite the process, but it must wait for -responses to finish at each step before proceeding. Given that many organizations prefer to use the URL in step 2 to -set up the Autodiscover service, the client might try this step first.

        -

        Another possibly newer resource which has not yet been attempted is "Outlook 2016 Implementation of Autodiscover": -https://support.microsoft.com/en-us/help/3211279/outlook-2016-implementation-of-autodiscover

        -

        WARNING: The autodiscover protocol is very complicated. If you have problems autodiscovering using this -implementation, start by doing an official test at https://testconnectivity.microsoft.com

        -
        -

        Instance variables

        var admin_audit_logs
        @@ -3158,7 +3121,6 @@

        Index

      • Account

          -
        • DEFAULT_DISCOVERY_CLS
        • admin_audit_logs
        • archive_deleted_items
        • archive_inbox
        • diff --git a/docs/exchangelib/attachments.html b/docs/exchangelib/attachments.html index 3dfd3094..4436d8e6 100644 --- a/docs/exchangelib/attachments.html +++ b/docs/exchangelib/attachments.html @@ -43,16 +43,13 @@

          Module exchangelib.attachments

          TextField, URIField, ) -from .properties import EWSElement, EWSMeta +from .properties import BaseItemId, EWSElement, EWSMeta log = logging.getLogger(__name__) -class AttachmentId(EWSElement): - """'id' and 'changekey' are UUIDs generated by Exchange. - - MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/attachmentid - """ +class AttachmentId(BaseItemId): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/attachmentid""" ELEMENT_NAME = "AttachmentId" @@ -64,12 +61,6 @@

          Module exchangelib.attachments

          root_id = IdField(field_uri=ROOT_ID_ATTR) root_changekey = IdField(field_uri=ROOT_CHANGEKEY_ATTR) - def __init__(self, *args, **kwargs): - if not kwargs: - # Allow to set attributes without keyword - kwargs = dict(zip(self._slots_keys, args)) - super().__init__(**kwargs) - class Attachment(EWSElement, metaclass=EWSMeta): """Base class for FileAttachment and ItemAttachment.""" @@ -533,17 +524,13 @@

          Inherited members

          (*args, **kwargs)
          - +
          Expand source code -
          class AttachmentId(EWSElement):
          -    """'id' and 'changekey' are UUIDs generated by Exchange.
          -
          -    MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/attachmentid
          -    """
          +
          class AttachmentId(BaseItemId):
          +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/attachmentid"""
           
               ELEMENT_NAME = "AttachmentId"
           
          @@ -553,16 +540,11 @@ 

          Inherited members

          id = IdField(field_uri=ID_ATTR, is_required=True) root_id = IdField(field_uri=ROOT_ID_ATTR) - root_changekey = IdField(field_uri=ROOT_CHANGEKEY_ATTR) - - def __init__(self, *args, **kwargs): - if not kwargs: - # Allow to set attributes without keyword - kwargs = dict(zip(self._slots_keys, args)) - super().__init__(**kwargs)
          + root_changekey = IdField(field_uri=ROOT_CHANGEKEY_ATTR)

          Ancestors

          Class variables

          @@ -605,12 +587,12 @@

          Instance variables

      • Inherited members

        diff --git a/docs/exchangelib/autodiscover/discovery.html b/docs/exchangelib/autodiscover/discovery.html index 9e114fa6..1b93f3bf 100644 --- a/docs/exchangelib/autodiscover/discovery.html +++ b/docs/exchangelib/autodiscover/discovery.html @@ -27,7 +27,6 @@

        Module exchangelib.autodiscover.discovery

        Expand source code
        import logging
        -import time
         from urllib.parse import urlparse
         
         import dns.name
        @@ -35,21 +34,11 @@ 

        Module exchangelib.autodiscover.discovery

        from cached_property import threaded_cached_property from ..configuration import Configuration -from ..errors import AutoDiscoverCircularRedirect, AutoDiscoverFailed, RedirectError, TransportError, UnauthorizedError +from ..errors import AutoDiscoverCircularRedirect, AutoDiscoverFailed, RedirectError, TransportError from ..protocol import FailFast, Protocol -from ..transport import AUTH_TYPE_MAP, DEFAULT_HEADERS, GSSAPI, NOAUTH, get_auth_method_from_response -from ..util import ( - CONNECTION_ERRORS, - TLS_ERRORS, - DummyResponse, - ParseError, - _back_off_if_needed, - get_domain, - get_redirect_url, - post_ratelimited, -) +from ..transport import get_unauthenticated_autodiscover_response +from ..util import CONNECTION_ERRORS, get_domain, get_redirect_url from .cache import autodiscover_cache -from .properties import Autodiscover from .protocol import AutodiscoverProtocol log = logging.getLogger(__name__) @@ -62,13 +51,6 @@

        Module exchangelib.autodiscover.discovery

        ) -def discover(email, credentials=None, auth_type=None, retry_policy=None): - ad_response, protocol = Autodiscovery(email=email, credentials=credentials).discover() - protocol.config.auth_typ = auth_type - protocol.config.retry_policy = retry_policy - return ad_response, protocol - - class SrvRecord: """A container for autodiscover-related SRV records in DNS.""" @@ -82,6 +64,13 @@

        Module exchangelib.autodiscover.discovery

        return all(getattr(self, k) == getattr(other, k) for k in self.__dict__) +def discover(email, credentials=None, auth_type=None, retry_policy=None): + ad_response, protocol = Autodiscovery(email=email, credentials=credentials).discover() + protocol.config.auth_typ = auth_type + protocol.config.retry_policy = retry_policy + return ad_response, protocol + + class Autodiscovery: """Autodiscover is a Microsoft protocol for automatically getting the endpoint of the Exchange server and other connection-related settings holding the email address using only the email address, and username and password of the @@ -112,13 +101,13 @@

        Module exchangelib.autodiscover.discovery

        # When connecting to servers that may not be serving the correct endpoint, we should use a retry policy that does # not leave us hanging for a long time on each step in the protocol. INITIAL_RETRY_POLICY = FailFast() - RETRY_WAIT = 10 # Seconds to wait before retry on connection errors MAX_REDIRECTS = 10 # Maximum number of URL redirects before we give up DNS_RESOLVER_KWARGS = {} DNS_RESOLVER_ATTRS = { "timeout": AutodiscoverProtocol.TIMEOUT / 2.5, # Timeout for query to a single nameserver } DNS_RESOLVER_LIFETIME = AutodiscoverProtocol.TIMEOUT # Total timeout for a query in case of multiple nameservers + URL_PATH = "autodiscover/autodiscover.svc" def __init__(self, email, credentials=None): """ @@ -147,30 +136,30 @@

        Module exchangelib.autodiscover.discovery

        ad_protocol = autodiscover_cache[cache_key] log.debug("Cache hit for key %s: %s", cache_key, ad_protocol.service_endpoint) try: - ad_response = self._quick(protocol=ad_protocol) + ad = self._quick(protocol=ad_protocol) except AutoDiscoverFailed: # Autodiscover no longer works with this domain. Clear cache and try again after releasing the lock log.debug("AD request failure. Removing cache for key %s", cache_key) del autodiscover_cache[cache_key] - ad_response = self._step_1(hostname=domain) + ad = self._step_1(hostname=domain) else: # This will cache the result log.debug("Cache miss for key %s", cache_key) - ad_response = self._step_1(hostname=domain) + ad = self._step_1(hostname=domain) log.debug("Released autodiscover_cache_lock") - if ad_response.redirect_address: - log.debug("Got a redirect address: %s", ad_response.redirect_address) - if ad_response.redirect_address.lower() in self._emails_visited: + if ad.redirect_address: + log.debug("Got a redirect address: %s", ad.redirect_address) + if ad.redirect_address.lower() in self._emails_visited: raise AutoDiscoverCircularRedirect("We were redirected to an email address we have already seen") # Start over, but with the new email address - self.email = ad_response.redirect_address + self.email = ad.redirect_address return self.discover() # We successfully received a response. Clear the cache of seen emails etc. self.clear() - return self._build_response(ad_response=ad_response) + return self._build_response(ad_response=ad) def clear(self): # This resets cached variables @@ -195,31 +184,24 @@

        Module exchangelib.autodiscover.discovery

        def _build_response(self, ad_response): if not ad_response.autodiscover_smtp_address: # Autodiscover does not always return an email address. In that case, the requesting email should be used - ad_response.user.autodiscover_smtp_address = self.email + ad_response.autodiscover_smtp_address = self.email protocol = Protocol( config=Configuration( - service_endpoint=ad_response.protocol.ews_url, + service_endpoint=ad_response.ews_url, credentials=self.credentials, version=ad_response.version, - auth_type=ad_response.protocol.auth_type, + # TODO: Detect EWS service auth type somehow ) ) return ad_response, protocol def _quick(self, protocol): try: - r = self._get_authenticated_response(protocol=protocol) + user_response = protocol.get_user_settings(user=self.email) except TransportError as e: raise AutoDiscoverFailed(f"Response error: {e}") - if r.status_code == 200: - try: - ad = Autodiscover.from_bytes(bytes_content=r.content) - except ParseError as e: - raise AutoDiscoverFailed(f"Invalid response: {e}") - else: - return self._step_5(ad=ad) - raise AutoDiscoverFailed(f"Invalid response code: {r.status_code}") + return self._step_5(ad=user_response) def _redirect_url_is_valid(self, url): """Three separate responses can be “Redirect responses”: @@ -258,92 +240,21 @@

        Module exchangelib.autodiscover.discovery

        return True def _get_unauthenticated_response(self, url, method="post"): - """Get auth type by tasting headers from the server. Do POST requests be default. HEAD is too error prone, and - some servers are set up to redirect to OWA on all requests except POST to the autodiscover endpoint. + """Get response from server using the given HTTP method :param url: - :param method: (Default value = 'post') :return: """ # We are connecting to untrusted servers here, so take necessary precautions. - hostname = urlparse(url).netloc - if not self._is_valid_hostname(hostname): - # 'requests' is really bad at reporting that a hostname cannot be resolved. Let's check this separately. - # Don't retry on DNS errors. They will most likely be persistent. - raise TransportError(f"{hostname!r} has no DNS entry") + self._ensure_valid_hostname(url) - kwargs = dict( - url=url, headers=DEFAULT_HEADERS.copy(), allow_redirects=False, timeout=AutodiscoverProtocol.TIMEOUT - ) - if method == "post": - kwargs["data"] = Autodiscover.payload(email=self.email) - retry = 0 - t_start = time.monotonic() - while True: - _back_off_if_needed(self.INITIAL_RETRY_POLICY.back_off_until) - log.debug("Trying to get response from %s", url) - with AutodiscoverProtocol.raw_session(url) as s: - try: - r = getattr(s, method)(**kwargs) - r.close() # Release memory - break - except TLS_ERRORS as e: - # Don't retry on TLS errors. They will most likely be persistent. - raise TransportError(str(e)) - except CONNECTION_ERRORS as e: - r = DummyResponse(url=url, request_headers=kwargs["headers"]) - total_wait = time.monotonic() - t_start - if self.INITIAL_RETRY_POLICY.may_retry_on_error(response=r, wait=total_wait): - log.debug("Connection error on URL %s (retry %s, error: %s). Cool down", url, retry, e) - # Don't respect the 'Retry-After' header. We don't know if this is a useful endpoint, and we - # want autodiscover to be reasonably fast. - self.INITIAL_RETRY_POLICY.back_off(self.RETRY_WAIT) - retry += 1 - continue - log.debug("Connection error on URL %s: %s", url, e) - raise TransportError(str(e)) - try: - auth_type = get_auth_method_from_response(response=r) - except UnauthorizedError: - # Failed to guess the auth type - auth_type = NOAUTH - if r.status_code in (301, 302) and "location" in r.headers: - # Make the redirect URL absolute - try: - r.headers["location"] = get_redirect_url(r) - except TransportError: - del r.headers["location"] - return auth_type, r - - def _get_authenticated_response(self, protocol): - """Get a response by using the credentials provided. We guess the auth type along the way. - - :param protocol: - :return: - """ - # Redo the request with the correct auth - data = Autodiscover.payload(email=self.email) - headers = DEFAULT_HEADERS.copy() - session = protocol.get_session() - if GSSAPI in AUTH_TYPE_MAP and isinstance(session.auth, AUTH_TYPE_MAP[GSSAPI]): - # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/pox-autodiscover-request-for-exchange - headers["X-ClientCanHandle"] = "Negotiate" - try: - r, session = post_ratelimited( - protocol=protocol, - session=session, - url=protocol.service_endpoint, - headers=headers, - data=data, + protocol = AutodiscoverProtocol( + config=Configuration( + service_endpoint=url, + retry_policy=self.INITIAL_RETRY_POLICY, ) - protocol.release_session(session) - except UnauthorizedError as e: - # It's entirely possible for the endpoint to ask for login. We should continue if login fails because this - # isn't necessarily the right endpoint to use. - raise TransportError(str(e)) - except RedirectError as e: - r = DummyResponse(url=protocol.service_endpoint, headers={"location": e.url}, status_code=302) - return r + ) + return None, get_unauthenticated_autodiscover_response(protocol=protocol, method=method) def _attempt_response(self, url): """Return an (is_valid_response, response) tuple. @@ -353,49 +264,52 @@

        Module exchangelib.autodiscover.discovery

        """ self._urls_visited.append(url.lower()) log.debug("Attempting to get a valid response from %s", url) + try: - auth_type, r = self._get_unauthenticated_response(url=url) - ad_protocol = AutodiscoverProtocol( - config=Configuration( - service_endpoint=url, - credentials=self.credentials, - auth_type=auth_type, - retry_policy=self.INITIAL_RETRY_POLICY, - ) + self._ensure_valid_hostname(url) + except TransportError: + return False, None + + protocol = AutodiscoverProtocol( + config=Configuration( + service_endpoint=url, + credentials=self.credentials, + retry_policy=self.INITIAL_RETRY_POLICY, ) - if auth_type != NOAUTH: - r = self._get_authenticated_response(protocol=ad_protocol) + ) + try: + user_response = protocol.get_user_settings(user=self.email) + except RedirectError as e: + if self._redirect_url_is_valid(url=e.url): + # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com + # works, it seems that we should follow this URL now and try to get a valid response. + return self._attempt_response(url=e.url) + log.debug("Invalid redirect URL: %s", e.url) + return False, None except TransportError as e: log.debug("Failed to get a response: %s", e) return False, None - if r.status_code in (301, 302) and "location" in r.headers: - redirect_url = get_redirect_url(r) - if self._redirect_url_is_valid(url=redirect_url): - # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com - # works, it seems that we should follow this URL now and try to get a valid response. - return self._attempt_response(url=redirect_url) - if r.status_code == 200: - try: - ad = Autodiscover.from_bytes(bytes_content=r.content) - except ParseError as e: - log.debug("Invalid response: %s", e) - else: - # We got a valid response. Unless this is a URL redirect response, we cache the result - if ad.response is None or not ad.response.redirect_url: - cache_key = self._cache_key - log.debug("Adding cache entry for key %s: %s", cache_key, ad_protocol.service_endpoint) - autodiscover_cache[cache_key] = ad_protocol - return True, ad - return False, None - - def _is_valid_hostname(self, hostname): + except CONNECTION_ERRORS as e: + log.debug("Failed to get a response: %s", e) + return False, None + + # We got a valid response. Unless this is a URL redirect response, we cache the result + if not user_response.redirect_url: + cache_key = self._cache_key + log.debug("Adding cache entry for key %s: %s", cache_key, protocol.service_endpoint) + autodiscover_cache[cache_key] = protocol + return True, user_response + + def _ensure_valid_hostname(self, url): + hostname = urlparse(url).netloc log.debug("Checking if %s can be looked up in DNS", hostname) try: self.resolver.resolve(f"{hostname}.", "A", lifetime=self.DNS_RESOLVER_LIFETIME) except DNS_LOOKUP_ERRORS as e: log.debug("DNS A lookup failure: %s", e) - return False - return True + # 'requests' is bad at reporting that a hostname cannot be resolved. Let's check this separately. + # Don't retry on DNS errors. They will most likely be persistent. + raise TransportError(f"{hostname!r} has no DNS entry") def _get_srv_records(self, hostname): """Send a DNS query for SRV entries for the hostname. @@ -431,14 +345,14 @@

        Module exchangelib.autodiscover.discovery

        def _step_1(self, hostname): """Perform step 1, where the client sends an Autodiscover request to - https://example.com/autodiscover/autodiscover.xml and then does one of the following: + https://example.com/ and then does one of the following: * If the Autodiscover attempt succeeds, the client proceeds to step 5. * If the Autodiscover attempt fails, the client proceeds to step 2. :param hostname: :return: """ - url = f"https://{hostname}/Autodiscover/Autodiscover.xml" + url = f"https://{hostname}/{self.URL_PATH}" log.info("Step 1: Trying autodiscover on %r with email %r", url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: @@ -447,14 +361,14 @@

        Module exchangelib.autodiscover.discovery

        def _step_2(self, hostname): """Perform step 2, where the client sends an Autodiscover request to - https://autodiscover.example.com/autodiscover/autodiscover.xml and then does one of the following: + https://autodiscover.example.com/ and then does one of the following: * If the Autodiscover attempt succeeds, the client proceeds to step 5. * If the Autodiscover attempt fails, the client proceeds to step 3. :param hostname: :return: """ - url = f"https://autodiscover.{hostname}/Autodiscover/Autodiscover.xml" + url = f"https://autodiscover.{hostname}/{self.URL_PATH}" log.info("Step 2: Trying autodiscover on %r with email %r", url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: @@ -462,8 +376,8 @@

        Module exchangelib.autodiscover.discovery

        return self._step_3(hostname=hostname) def _step_3(self, hostname): - """Perform step 3, where the client sends an unauth'ed GET method request to - http://autodiscover.example.com/autodiscover/autodiscover.xml (Note that this is a non-HTTPS endpoint). The + """Perform step 3, where the client sends an unauthenticated GET method request to + http://autodiscover.example.com/ (Note that this is a non-HTTPS endpoint). The client then does one of the following: * If the GET request returns a 302 redirect response, it gets the redirection URL from the 'Location' HTTP header and validates it as described in the "Redirect responses" section. The client then does one of the @@ -477,22 +391,23 @@

        Module exchangelib.autodiscover.discovery

        :param hostname: :return: """ - url = f"http://autodiscover.{hostname}/Autodiscover/Autodiscover.xml" + url = f"http://autodiscover.{hostname}/{self.URL_PATH}" log.info("Step 3: Trying autodiscover on %r with email %r", url, self.email) try: _, r = self._get_unauthenticated_response(url=url, method="get") except TransportError: - r = DummyResponse(url=url) - if r.status_code in (301, 302) and "location" in r.headers: - redirect_url = get_redirect_url(r) - if self._redirect_url_is_valid(url=redirect_url): - is_valid_response, ad = self._attempt_response(url=redirect_url) - if is_valid_response: - return self._step_5(ad=ad) - log.debug("Got invalid response") + pass + else: + if r.status_code in (301, 302) and "location" in r.headers: + redirect_url = get_redirect_url(r) + if self._redirect_url_is_valid(url=redirect_url): + is_valid_response, ad = self._attempt_response(url=redirect_url) + if is_valid_response: + return self._step_5(ad=ad) + log.debug("Got invalid response") + return self._step_4(hostname=hostname) + log.debug("Got invalid redirect URL") return self._step_4(hostname=hostname) - log.debug("Got invalid redirect URL") - return self._step_4(hostname=hostname) log.debug("Got no redirect URL") return self._step_4(hostname=hostname) @@ -522,7 +437,7 @@

        Module exchangelib.autodiscover.discovery

        srv_host = None if not srv_host: return self._step_6() - redirect_url = f"https://{srv_host}/Autodiscover/Autodiscover.xml" + redirect_url = f"https://{srv_host}/{self.URL_PATH}" if self._redirect_url_is_valid(url=redirect_url): is_valid_response, ad = self._attempt_response(url=redirect_url) if is_valid_response: @@ -550,17 +465,15 @@

        Module exchangelib.autodiscover.discovery

        :return: """ log.info("Step 5: Checking response") - if ad.response is None: - # This is not explicit in the protocol, but let's raise errors here - ad.raise_errors() + # This is not explicit in the protocol, but let's raise any errors here + ad.raise_errors() - ad_response = ad.response - if ad_response.redirect_url: - log.debug("Got a redirect URL: %s", ad_response.redirect_url) + if ad.redirect_url: + log.debug("Got a redirect URL: %s", ad.redirect_url) # We are diverging a bit from the protocol here. We will never get an HTTP 302 since earlier steps already # followed the redirects where possible. Instead, we handle redirect responses here. - if self._redirect_url_is_valid(url=ad_response.redirect_url): - is_valid_response, ad = self._attempt_response(url=ad_response.redirect_url) + if self._redirect_url_is_valid(url=ad.redirect_url): + is_valid_response, ad = self._attempt_response(url=ad.redirect_url) if is_valid_response: return self._step_5(ad=ad) log.debug("Got invalid response") @@ -568,7 +481,7 @@

        Module exchangelib.autodiscover.discovery

        log.debug("Invalid redirect URL") return self._step_6() # This could be an email redirect. Let outer layer handle this - return ad_response + return ad def _step_6(self): """Perform step 6. If the client cannot contact the Autodiscover service, the client should ask the user for @@ -686,13 +599,13 @@

        Classes

        # When connecting to servers that may not be serving the correct endpoint, we should use a retry policy that does # not leave us hanging for a long time on each step in the protocol. INITIAL_RETRY_POLICY = FailFast() - RETRY_WAIT = 10 # Seconds to wait before retry on connection errors MAX_REDIRECTS = 10 # Maximum number of URL redirects before we give up DNS_RESOLVER_KWARGS = {} DNS_RESOLVER_ATTRS = { "timeout": AutodiscoverProtocol.TIMEOUT / 2.5, # Timeout for query to a single nameserver } DNS_RESOLVER_LIFETIME = AutodiscoverProtocol.TIMEOUT # Total timeout for a query in case of multiple nameservers + URL_PATH = "autodiscover/autodiscover.svc" def __init__(self, email, credentials=None): """ @@ -721,30 +634,30 @@

        Classes

        ad_protocol = autodiscover_cache[cache_key] log.debug("Cache hit for key %s: %s", cache_key, ad_protocol.service_endpoint) try: - ad_response = self._quick(protocol=ad_protocol) + ad = self._quick(protocol=ad_protocol) except AutoDiscoverFailed: # Autodiscover no longer works with this domain. Clear cache and try again after releasing the lock log.debug("AD request failure. Removing cache for key %s", cache_key) del autodiscover_cache[cache_key] - ad_response = self._step_1(hostname=domain) + ad = self._step_1(hostname=domain) else: # This will cache the result log.debug("Cache miss for key %s", cache_key) - ad_response = self._step_1(hostname=domain) + ad = self._step_1(hostname=domain) log.debug("Released autodiscover_cache_lock") - if ad_response.redirect_address: - log.debug("Got a redirect address: %s", ad_response.redirect_address) - if ad_response.redirect_address.lower() in self._emails_visited: + if ad.redirect_address: + log.debug("Got a redirect address: %s", ad.redirect_address) + if ad.redirect_address.lower() in self._emails_visited: raise AutoDiscoverCircularRedirect("We were redirected to an email address we have already seen") # Start over, but with the new email address - self.email = ad_response.redirect_address + self.email = ad.redirect_address return self.discover() # We successfully received a response. Clear the cache of seen emails etc. self.clear() - return self._build_response(ad_response=ad_response) + return self._build_response(ad_response=ad) def clear(self): # This resets cached variables @@ -769,31 +682,24 @@

        Classes

        def _build_response(self, ad_response): if not ad_response.autodiscover_smtp_address: # Autodiscover does not always return an email address. In that case, the requesting email should be used - ad_response.user.autodiscover_smtp_address = self.email + ad_response.autodiscover_smtp_address = self.email protocol = Protocol( config=Configuration( - service_endpoint=ad_response.protocol.ews_url, + service_endpoint=ad_response.ews_url, credentials=self.credentials, version=ad_response.version, - auth_type=ad_response.protocol.auth_type, + # TODO: Detect EWS service auth type somehow ) ) return ad_response, protocol def _quick(self, protocol): try: - r = self._get_authenticated_response(protocol=protocol) + user_response = protocol.get_user_settings(user=self.email) except TransportError as e: raise AutoDiscoverFailed(f"Response error: {e}") - if r.status_code == 200: - try: - ad = Autodiscover.from_bytes(bytes_content=r.content) - except ParseError as e: - raise AutoDiscoverFailed(f"Invalid response: {e}") - else: - return self._step_5(ad=ad) - raise AutoDiscoverFailed(f"Invalid response code: {r.status_code}") + return self._step_5(ad=user_response) def _redirect_url_is_valid(self, url): """Three separate responses can be “Redirect responses”: @@ -832,92 +738,21 @@

        Classes

        return True def _get_unauthenticated_response(self, url, method="post"): - """Get auth type by tasting headers from the server. Do POST requests be default. HEAD is too error prone, and - some servers are set up to redirect to OWA on all requests except POST to the autodiscover endpoint. + """Get response from server using the given HTTP method :param url: - :param method: (Default value = 'post') :return: """ # We are connecting to untrusted servers here, so take necessary precautions. - hostname = urlparse(url).netloc - if not self._is_valid_hostname(hostname): - # 'requests' is really bad at reporting that a hostname cannot be resolved. Let's check this separately. - # Don't retry on DNS errors. They will most likely be persistent. - raise TransportError(f"{hostname!r} has no DNS entry") - - kwargs = dict( - url=url, headers=DEFAULT_HEADERS.copy(), allow_redirects=False, timeout=AutodiscoverProtocol.TIMEOUT - ) - if method == "post": - kwargs["data"] = Autodiscover.payload(email=self.email) - retry = 0 - t_start = time.monotonic() - while True: - _back_off_if_needed(self.INITIAL_RETRY_POLICY.back_off_until) - log.debug("Trying to get response from %s", url) - with AutodiscoverProtocol.raw_session(url) as s: - try: - r = getattr(s, method)(**kwargs) - r.close() # Release memory - break - except TLS_ERRORS as e: - # Don't retry on TLS errors. They will most likely be persistent. - raise TransportError(str(e)) - except CONNECTION_ERRORS as e: - r = DummyResponse(url=url, request_headers=kwargs["headers"]) - total_wait = time.monotonic() - t_start - if self.INITIAL_RETRY_POLICY.may_retry_on_error(response=r, wait=total_wait): - log.debug("Connection error on URL %s (retry %s, error: %s). Cool down", url, retry, e) - # Don't respect the 'Retry-After' header. We don't know if this is a useful endpoint, and we - # want autodiscover to be reasonably fast. - self.INITIAL_RETRY_POLICY.back_off(self.RETRY_WAIT) - retry += 1 - continue - log.debug("Connection error on URL %s: %s", url, e) - raise TransportError(str(e)) - try: - auth_type = get_auth_method_from_response(response=r) - except UnauthorizedError: - # Failed to guess the auth type - auth_type = NOAUTH - if r.status_code in (301, 302) and "location" in r.headers: - # Make the redirect URL absolute - try: - r.headers["location"] = get_redirect_url(r) - except TransportError: - del r.headers["location"] - return auth_type, r + self._ensure_valid_hostname(url) - def _get_authenticated_response(self, protocol): - """Get a response by using the credentials provided. We guess the auth type along the way. - - :param protocol: - :return: - """ - # Redo the request with the correct auth - data = Autodiscover.payload(email=self.email) - headers = DEFAULT_HEADERS.copy() - session = protocol.get_session() - if GSSAPI in AUTH_TYPE_MAP and isinstance(session.auth, AUTH_TYPE_MAP[GSSAPI]): - # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/pox-autodiscover-request-for-exchange - headers["X-ClientCanHandle"] = "Negotiate" - try: - r, session = post_ratelimited( - protocol=protocol, - session=session, - url=protocol.service_endpoint, - headers=headers, - data=data, + protocol = AutodiscoverProtocol( + config=Configuration( + service_endpoint=url, + retry_policy=self.INITIAL_RETRY_POLICY, ) - protocol.release_session(session) - except UnauthorizedError as e: - # It's entirely possible for the endpoint to ask for login. We should continue if login fails because this - # isn't necessarily the right endpoint to use. - raise TransportError(str(e)) - except RedirectError as e: - r = DummyResponse(url=protocol.service_endpoint, headers={"location": e.url}, status_code=302) - return r + ) + return None, get_unauthenticated_autodiscover_response(protocol=protocol, method=method) def _attempt_response(self, url): """Return an (is_valid_response, response) tuple. @@ -927,49 +762,52 @@

        Classes

        """ self._urls_visited.append(url.lower()) log.debug("Attempting to get a valid response from %s", url) + try: - auth_type, r = self._get_unauthenticated_response(url=url) - ad_protocol = AutodiscoverProtocol( - config=Configuration( - service_endpoint=url, - credentials=self.credentials, - auth_type=auth_type, - retry_policy=self.INITIAL_RETRY_POLICY, - ) + self._ensure_valid_hostname(url) + except TransportError: + return False, None + + protocol = AutodiscoverProtocol( + config=Configuration( + service_endpoint=url, + credentials=self.credentials, + retry_policy=self.INITIAL_RETRY_POLICY, ) - if auth_type != NOAUTH: - r = self._get_authenticated_response(protocol=ad_protocol) + ) + try: + user_response = protocol.get_user_settings(user=self.email) + except RedirectError as e: + if self._redirect_url_is_valid(url=e.url): + # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com + # works, it seems that we should follow this URL now and try to get a valid response. + return self._attempt_response(url=e.url) + log.debug("Invalid redirect URL: %s", e.url) + return False, None except TransportError as e: log.debug("Failed to get a response: %s", e) return False, None - if r.status_code in (301, 302) and "location" in r.headers: - redirect_url = get_redirect_url(r) - if self._redirect_url_is_valid(url=redirect_url): - # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com - # works, it seems that we should follow this URL now and try to get a valid response. - return self._attempt_response(url=redirect_url) - if r.status_code == 200: - try: - ad = Autodiscover.from_bytes(bytes_content=r.content) - except ParseError as e: - log.debug("Invalid response: %s", e) - else: - # We got a valid response. Unless this is a URL redirect response, we cache the result - if ad.response is None or not ad.response.redirect_url: - cache_key = self._cache_key - log.debug("Adding cache entry for key %s: %s", cache_key, ad_protocol.service_endpoint) - autodiscover_cache[cache_key] = ad_protocol - return True, ad - return False, None - - def _is_valid_hostname(self, hostname): + except CONNECTION_ERRORS as e: + log.debug("Failed to get a response: %s", e) + return False, None + + # We got a valid response. Unless this is a URL redirect response, we cache the result + if not user_response.redirect_url: + cache_key = self._cache_key + log.debug("Adding cache entry for key %s: %s", cache_key, protocol.service_endpoint) + autodiscover_cache[cache_key] = protocol + return True, user_response + + def _ensure_valid_hostname(self, url): + hostname = urlparse(url).netloc log.debug("Checking if %s can be looked up in DNS", hostname) try: self.resolver.resolve(f"{hostname}.", "A", lifetime=self.DNS_RESOLVER_LIFETIME) except DNS_LOOKUP_ERRORS as e: log.debug("DNS A lookup failure: %s", e) - return False - return True + # 'requests' is bad at reporting that a hostname cannot be resolved. Let's check this separately. + # Don't retry on DNS errors. They will most likely be persistent. + raise TransportError(f"{hostname!r} has no DNS entry") def _get_srv_records(self, hostname): """Send a DNS query for SRV entries for the hostname. @@ -1005,14 +843,14 @@

        Classes

        def _step_1(self, hostname): """Perform step 1, where the client sends an Autodiscover request to - https://example.com/autodiscover/autodiscover.xml and then does one of the following: + https://example.com/ and then does one of the following: * If the Autodiscover attempt succeeds, the client proceeds to step 5. * If the Autodiscover attempt fails, the client proceeds to step 2. :param hostname: :return: """ - url = f"https://{hostname}/Autodiscover/Autodiscover.xml" + url = f"https://{hostname}/{self.URL_PATH}" log.info("Step 1: Trying autodiscover on %r with email %r", url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: @@ -1021,14 +859,14 @@

        Classes

        def _step_2(self, hostname): """Perform step 2, where the client sends an Autodiscover request to - https://autodiscover.example.com/autodiscover/autodiscover.xml and then does one of the following: + https://autodiscover.example.com/ and then does one of the following: * If the Autodiscover attempt succeeds, the client proceeds to step 5. * If the Autodiscover attempt fails, the client proceeds to step 3. :param hostname: :return: """ - url = f"https://autodiscover.{hostname}/Autodiscover/Autodiscover.xml" + url = f"https://autodiscover.{hostname}/{self.URL_PATH}" log.info("Step 2: Trying autodiscover on %r with email %r", url, self.email) is_valid_response, ad = self._attempt_response(url=url) if is_valid_response: @@ -1036,8 +874,8 @@

        Classes

        return self._step_3(hostname=hostname) def _step_3(self, hostname): - """Perform step 3, where the client sends an unauth'ed GET method request to - http://autodiscover.example.com/autodiscover/autodiscover.xml (Note that this is a non-HTTPS endpoint). The + """Perform step 3, where the client sends an unauthenticated GET method request to + http://autodiscover.example.com/ (Note that this is a non-HTTPS endpoint). The client then does one of the following: * If the GET request returns a 302 redirect response, it gets the redirection URL from the 'Location' HTTP header and validates it as described in the "Redirect responses" section. The client then does one of the @@ -1051,22 +889,23 @@

        Classes

        :param hostname: :return: """ - url = f"http://autodiscover.{hostname}/Autodiscover/Autodiscover.xml" + url = f"http://autodiscover.{hostname}/{self.URL_PATH}" log.info("Step 3: Trying autodiscover on %r with email %r", url, self.email) try: _, r = self._get_unauthenticated_response(url=url, method="get") except TransportError: - r = DummyResponse(url=url) - if r.status_code in (301, 302) and "location" in r.headers: - redirect_url = get_redirect_url(r) - if self._redirect_url_is_valid(url=redirect_url): - is_valid_response, ad = self._attempt_response(url=redirect_url) - if is_valid_response: - return self._step_5(ad=ad) - log.debug("Got invalid response") + pass + else: + if r.status_code in (301, 302) and "location" in r.headers: + redirect_url = get_redirect_url(r) + if self._redirect_url_is_valid(url=redirect_url): + is_valid_response, ad = self._attempt_response(url=redirect_url) + if is_valid_response: + return self._step_5(ad=ad) + log.debug("Got invalid response") + return self._step_4(hostname=hostname) + log.debug("Got invalid redirect URL") return self._step_4(hostname=hostname) - log.debug("Got invalid redirect URL") - return self._step_4(hostname=hostname) log.debug("Got no redirect URL") return self._step_4(hostname=hostname) @@ -1096,7 +935,7 @@

        Classes

        srv_host = None if not srv_host: return self._step_6() - redirect_url = f"https://{srv_host}/Autodiscover/Autodiscover.xml" + redirect_url = f"https://{srv_host}/{self.URL_PATH}" if self._redirect_url_is_valid(url=redirect_url): is_valid_response, ad = self._attempt_response(url=redirect_url) if is_valid_response: @@ -1124,17 +963,15 @@

        Classes

        :return: """ log.info("Step 5: Checking response") - if ad.response is None: - # This is not explicit in the protocol, but let's raise errors here - ad.raise_errors() + # This is not explicit in the protocol, but let's raise any errors here + ad.raise_errors() - ad_response = ad.response - if ad_response.redirect_url: - log.debug("Got a redirect URL: %s", ad_response.redirect_url) + if ad.redirect_url: + log.debug("Got a redirect URL: %s", ad.redirect_url) # We are diverging a bit from the protocol here. We will never get an HTTP 302 since earlier steps already # followed the redirects where possible. Instead, we handle redirect responses here. - if self._redirect_url_is_valid(url=ad_response.redirect_url): - is_valid_response, ad = self._attempt_response(url=ad_response.redirect_url) + if self._redirect_url_is_valid(url=ad.redirect_url): + is_valid_response, ad = self._attempt_response(url=ad.redirect_url) if is_valid_response: return self._step_5(ad=ad) log.debug("Got invalid response") @@ -1142,7 +979,7 @@

        Classes

        log.debug("Invalid redirect URL") return self._step_6() # This could be an email redirect. Let outer layer handle this - return ad_response + return ad def _step_6(self): """Perform step 6. If the client cannot contact the Autodiscover service, the client should ask the user for @@ -1176,7 +1013,7 @@

        Class variables

        -
        var RETRY_WAIT
        +
        var URL_PATH
        @@ -1248,30 +1085,30 @@

        Methods

        ad_protocol = autodiscover_cache[cache_key] log.debug("Cache hit for key %s: %s", cache_key, ad_protocol.service_endpoint) try: - ad_response = self._quick(protocol=ad_protocol) + ad = self._quick(protocol=ad_protocol) except AutoDiscoverFailed: # Autodiscover no longer works with this domain. Clear cache and try again after releasing the lock log.debug("AD request failure. Removing cache for key %s", cache_key) del autodiscover_cache[cache_key] - ad_response = self._step_1(hostname=domain) + ad = self._step_1(hostname=domain) else: # This will cache the result log.debug("Cache miss for key %s", cache_key) - ad_response = self._step_1(hostname=domain) + ad = self._step_1(hostname=domain) log.debug("Released autodiscover_cache_lock") - if ad_response.redirect_address: - log.debug("Got a redirect address: %s", ad_response.redirect_address) - if ad_response.redirect_address.lower() in self._emails_visited: + if ad.redirect_address: + log.debug("Got a redirect address: %s", ad.redirect_address) + if ad.redirect_address.lower() in self._emails_visited: raise AutoDiscoverCircularRedirect("We were redirected to an email address we have already seen") # Start over, but with the new email address - self.email = ad_response.redirect_address + self.email = ad.redirect_address return self.discover() # We successfully received a response. Clear the cache of seen emails etc. self.clear() - return self._build_response(ad_response=ad_response)
        + return self._build_response(ad_response=ad)
  • @@ -1328,7 +1165,7 @@

    DNS_RESOLVER_LIFETIME
  • INITIAL_RETRY_POLICY
  • MAX_REDIRECTS
  • -
  • RETRY_WAIT
  • +
  • URL_PATH
  • clear
  • discover
  • resolver
  • diff --git a/docs/exchangelib/autodiscover/index.html b/docs/exchangelib/autodiscover/index.html index 62000eec..d81da24b 100644 --- a/docs/exchangelib/autodiscover/index.html +++ b/docs/exchangelib/autodiscover/index.html @@ -27,8 +27,7 @@

    Module exchangelib.autodiscover

    Expand source code
    from .cache import AutodiscoverCache, autodiscover_cache
    -from .discovery.pox import PoxAutodiscovery as Autodiscovery
    -from .discovery.pox import discover
    +from .discovery import Autodiscovery, discover
     from .protocol import AutodiscoverProtocol
     
     
    @@ -60,11 +59,7 @@ 

    Sub-modules

    -
    exchangelib.autodiscover.discovery
    -
    -
    -
    -
    exchangelib.autodiscover.properties
    +
    exchangelib.autodiscover.discovery
    @@ -117,7 +112,7 @@

    Functions

    Expand source code
    def discover(email, credentials=None, auth_type=None, retry_policy=None):
    -    ad_response, protocol = PoxAutodiscovery(email=email, credentials=credentials).discover()
    +    ad_response, protocol = Autodiscovery(email=email, credentials=credentials).discover()
         protocol.config.auth_typ = auth_type
         protocol.config.retry_policy = retry_policy
         return ad_response, protocol
    @@ -447,7 +442,7 @@

    Inherited members

    -
    +
    class Autodiscovery (email, credentials=None)
    @@ -475,121 +470,190 @@

    Inherited members

    Expand source code -
    class PoxAutodiscovery(BaseAutodiscovery):
    -    URL_PATH = "Autodiscover/Autodiscover.xml"
    +
    class Autodiscovery:
    +    """Autodiscover is a Microsoft protocol for automatically getting the endpoint of the Exchange server and other
    +    connection-related settings holding the email address using only the email address, and username and password of the
    +    user.
    +
    +    For a description of the protocol implemented, see "Autodiscover for Exchange ActiveSync developers":
    +
    +    https://docs.microsoft.com/en-us/previous-versions/office/developer/exchange-server-interoperability-guidance/hh352638%28v%3dexchg.140%29
    +
    +    Descriptions of the steps from the article are provided in their respective methods in this class.
    +
    +    For a description of how to handle autodiscover error messages, see:
    +
    +    https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/handling-autodiscover-error-messages
    +
    +    A tip from the article:
    +    The client can perform steps 1 through 4 in any order or in parallel to expedite the process, but it must wait for
    +    responses to finish at each step before proceeding. Given that many organizations prefer to use the URL in step 2 to
    +    set up the Autodiscover service, the client might try this step first.
    +
    +    Another possibly newer resource which has not yet been attempted is "Outlook 2016 Implementation of Autodiscover":
    +    https://support.microsoft.com/en-us/help/3211279/outlook-2016-implementation-of-autodiscover
    +
    +    WARNING: The autodiscover protocol is very complicated. If you have problems autodiscovering using this
    +    implementation, start by doing an official test at https://testconnectivity.microsoft.com
    +    """
    +
    +    # When connecting to servers that may not be serving the correct endpoint, we should use a retry policy that does
    +    # not leave us hanging for a long time on each step in the protocol.
    +    INITIAL_RETRY_POLICY = FailFast()
    +    MAX_REDIRECTS = 10  # Maximum number of URL redirects before we give up
    +    DNS_RESOLVER_KWARGS = {}
    +    DNS_RESOLVER_ATTRS = {
    +        "timeout": AutodiscoverProtocol.TIMEOUT / 2.5,  # Timeout for query to a single nameserver
    +    }
    +    DNS_RESOLVER_LIFETIME = AutodiscoverProtocol.TIMEOUT  # Total timeout for a query in case of multiple nameservers
    +    URL_PATH = "autodiscover/autodiscover.svc"
    +
    +    def __init__(self, email, credentials=None):
    +        """
    +
    +        :param email: The email address to autodiscover
    +        :param credentials: Credentials with authorization to make autodiscover lookups for this Account
    +            (Default value = None)
    +        """
    +        self.email = email
    +        self.credentials = credentials
    +        self._urls_visited = []  # Collects HTTP and Autodiscover redirects
    +        self._redirect_count = 0
    +        self._emails_visited = []  # Collects Autodiscover email redirects
    +
    +    def discover(self):
    +        self._emails_visited.append(self.email.lower())
    +
    +        # Check the autodiscover cache to see if we already know the autodiscover service endpoint for this email
    +        # domain. Use a lock to guard against multiple threads competing to cache information.
    +        log.debug("Waiting for autodiscover_cache lock")
    +        with autodiscover_cache:
    +            log.debug("autodiscover_cache lock acquired")
    +            cache_key = self._cache_key
    +            domain = get_domain(self.email)
    +            if cache_key in autodiscover_cache:
    +                ad_protocol = autodiscover_cache[cache_key]
    +                log.debug("Cache hit for key %s: %s", cache_key, ad_protocol.service_endpoint)
    +                try:
    +                    ad = self._quick(protocol=ad_protocol)
    +                except AutoDiscoverFailed:
    +                    # Autodiscover no longer works with this domain. Clear cache and try again after releasing the lock
    +                    log.debug("AD request failure. Removing cache for key %s", cache_key)
    +                    del autodiscover_cache[cache_key]
    +                    ad = self._step_1(hostname=domain)
    +            else:
    +                # This will cache the result
    +                log.debug("Cache miss for key %s", cache_key)
    +                ad = self._step_1(hostname=domain)
    +
    +        log.debug("Released autodiscover_cache_lock")
    +        if ad.redirect_address:
    +            log.debug("Got a redirect address: %s", ad.redirect_address)
    +            if ad.redirect_address.lower() in self._emails_visited:
    +                raise AutoDiscoverCircularRedirect("We were redirected to an email address we have already seen")
    +
    +            # Start over, but with the new email address
    +            self.email = ad.redirect_address
    +            return self.discover()
    +
    +        # We successfully received a response. Clear the cache of seen emails etc.
    +        self.clear()
    +        return self._build_response(ad_response=ad)
    +
    +    def clear(self):
    +        # This resets cached variables
    +        self._urls_visited = []
    +        self._redirect_count = 0
    +        self._emails_visited = []
    +
    +    @property
    +    def _cache_key(self):
    +        # We may be using multiple different credentials and changing our minds on TLS verification. This key
    +        # combination should be safe for caching.
    +        domain = get_domain(self.email)
    +        return domain, self.credentials
    +
    +    @threaded_cached_property
    +    def resolver(self):
    +        resolver = dns.resolver.Resolver(**self.DNS_RESOLVER_KWARGS)
    +        for k, v in self.DNS_RESOLVER_ATTRS.items():
    +            setattr(resolver, k, v)
    +        return resolver
     
         def _build_response(self, ad_response):
             if not ad_response.autodiscover_smtp_address:
                 # Autodiscover does not always return an email address. In that case, the requesting email should be used
    -            ad_response.user.autodiscover_smtp_address = self.email
    +            ad_response.autodiscover_smtp_address = self.email
     
             protocol = Protocol(
                 config=Configuration(
    -                service_endpoint=ad_response.protocol.ews_url,
    +                service_endpoint=ad_response.ews_url,
                     credentials=self.credentials,
                     version=ad_response.version,
    -                auth_type=ad_response.protocol.auth_type,
    +                # TODO: Detect EWS service auth type somehow
                 )
             )
             return ad_response, protocol
     
         def _quick(self, protocol):
             try:
    -            r = self._get_authenticated_response(protocol=protocol)
    +            user_response = protocol.get_user_settings(user=self.email)
             except TransportError as e:
                 raise AutoDiscoverFailed(f"Response error: {e}")
    -        if r.status_code == 200:
    -            try:
    -                ad = Autodiscover.from_bytes(bytes_content=r.content)
    -            except ParseError as e:
    -                raise AutoDiscoverFailed(f"Invalid response: {e}")
    -            else:
    -                return self._step_5(ad=ad)
    -        raise AutoDiscoverFailed(f"Invalid response code: {r.status_code}")
    +        return self._step_5(ad=user_response)
     
    -    def _get_unauthenticated_response(self, url, method="post"):
    -        """Get auth type by tasting headers from the server. Do POST requests be default. HEAD is too error-prone, and
    -        some servers are set up to redirect to OWA on all requests except POST to the autodiscover endpoint.
    +    def _redirect_url_is_valid(self, url):
    +        """Three separate responses can be “Redirect responses”:
    +        * An HTTP status code (301, 302) with a new URL
    +        * An HTTP status code of 200, but with a payload XML containing a redirect to a different URL
    +        * An HTTP status code of 200, but with a payload XML containing a different SMTP address as the target address
    +
    +        We only handle the HTTP 302 redirects here. We validate the URL received in the redirect response to ensure that
    +        it does not redirect to non-SSL endpoints or SSL endpoints with invalid certificates, and that the redirect is
    +        not circular. Finally, we should fail after 10 redirects.
     
             :param url:
    -        :param method:  (Default value = 'post')
             :return:
             """
    -        # We are connecting to untrusted servers here, so take necessary precautions.
    -        self._ensure_valid_hostname(url)
    +        if url.lower() in self._urls_visited:
    +            log.warning("We have already tried this URL: %s", url)
    +            return False
     
    -        kwargs = dict(
    -            url=url, headers=DEFAULT_HEADERS.copy(), allow_redirects=False, timeout=AutodiscoverProtocol.TIMEOUT
    -        )
    -        if method == "post":
    -            kwargs["data"] = Autodiscover.payload(email=self.email)
    -        retry = 0
    -        t_start = time.monotonic()
    -        while True:
    -            _back_off_if_needed(self.INITIAL_RETRY_POLICY.back_off_until)
    -            log.debug("Trying to get response from %s", url)
    -            with AutodiscoverProtocol.raw_session(url) as s:
    -                try:
    -                    r = getattr(s, method)(**kwargs)
    -                    r.close()  # Release memory
    -                    break
    -                except TLS_ERRORS as e:
    -                    # Don't retry on TLS errors. They will most likely be persistent.
    -                    raise TransportError(str(e))
    -                except CONNECTION_ERRORS as e:
    -                    r = DummyResponse(url=url, request_headers=kwargs["headers"])
    -                    total_wait = time.monotonic() - t_start
    -                    if self.INITIAL_RETRY_POLICY.may_retry_on_error(response=r, wait=total_wait):
    -                        log.debug("Connection error on URL %s (retry %s, error: %s). Cool down", url, retry, e)
    -                        # Don't respect the 'Retry-After' header. We don't know if this is a useful endpoint, and we
    -                        # want autodiscover to be reasonably fast.
    -                        self.INITIAL_RETRY_POLICY.back_off(self.RETRY_WAIT)
    -                        retry += 1
    -                        continue
    -                    log.debug("Connection error on URL %s: %s", url, e)
    -                    raise TransportError(str(e))
    +        if self._redirect_count >= self.MAX_REDIRECTS:
    +            log.warning("We reached max redirects at URL: %s", url)
    +            return False
    +
    +        # We require TLS endpoints
    +        if not url.startswith("https://"):
    +            log.debug("Invalid scheme for URL: %s", url)
    +            return False
    +
    +        # Quick test that the endpoint responds and that TLS handshake is OK
             try:
    -            auth_type = get_auth_method_from_response(response=r)
    -        except UnauthorizedError:
    -            # Failed to guess the auth type
    -            auth_type = NOAUTH
    -        if r.status_code in (301, 302) and "location" in r.headers:
    -            # Make the redirect URL absolute
    -            try:
    -                r.headers["location"] = get_redirect_url(r)
    -            except TransportError:
    -                del r.headers["location"]
    -        return auth_type, r
    +            self._get_unauthenticated_response(url, method="head")
    +        except TransportError as e:
    +            log.debug("Response error on redirect URL %s: %s", url, e)
    +            return False
     
    -    def _get_authenticated_response(self, protocol):
    -        """Get a response by using the credentials provided. We guess the auth type along the way.
    +        self._redirect_count += 1
    +        return True
     
    -        :param protocol:
    +    def _get_unauthenticated_response(self, url, method="post"):
    +        """Get response from server using the given HTTP method
    +
    +        :param url:
             :return:
             """
    -        # Redo the request with the correct auth
    -        data = Autodiscover.payload(email=self.email)
    -        headers = DEFAULT_HEADERS.copy()
    -        session = protocol.get_session()
    -        if GSSAPI in AUTH_TYPE_MAP and isinstance(session.auth, AUTH_TYPE_MAP[GSSAPI]):
    -            # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/pox-autodiscover-request-for-exchange
    -            headers["X-ClientCanHandle"] = "Negotiate"
    -        try:
    -            r, session = post_ratelimited(
    -                protocol=protocol,
    -                session=session,
    -                url=protocol.service_endpoint,
    -                headers=headers,
    -                data=data,
    +        # We are connecting to untrusted servers here, so take necessary precautions.
    +        self._ensure_valid_hostname(url)
    +
    +        protocol = AutodiscoverProtocol(
    +            config=Configuration(
    +                service_endpoint=url,
    +                retry_policy=self.INITIAL_RETRY_POLICY,
                 )
    -            protocol.release_session(session)
    -        except UnauthorizedError as e:
    -            # It's entirely possible for the endpoint to ask for login. We should continue if login fails because this
    -            # isn't necessarily the right endpoint to use.
    -            raise TransportError(str(e))
    -        except RedirectError as e:
    -            r = DummyResponse(url=protocol.service_endpoint, headers={"location": e.url}, status_code=302)
    -        return r
    +        )
    +        return None, get_unauthenticated_autodiscover_response(protocol=protocol, method=method)
     
         def _attempt_response(self, url):
             """Return an (is_valid_response, response) tuple.
    @@ -599,51 +663,355 @@ 

    Inherited members

    """ self._urls_visited.append(url.lower()) log.debug("Attempting to get a valid response from %s", url) + try: - auth_type, r = self._get_unauthenticated_response(url=url) - ad_protocol = AutodiscoverProtocol( - config=Configuration( - service_endpoint=url, - credentials=self.credentials, - auth_type=auth_type, - retry_policy=self.INITIAL_RETRY_POLICY, - ) + self._ensure_valid_hostname(url) + except TransportError: + return False, None + + protocol = AutodiscoverProtocol( + config=Configuration( + service_endpoint=url, + credentials=self.credentials, + retry_policy=self.INITIAL_RETRY_POLICY, ) - if auth_type != NOAUTH: - r = self._get_authenticated_response(protocol=ad_protocol) + ) + try: + user_response = protocol.get_user_settings(user=self.email) + except RedirectError as e: + if self._redirect_url_is_valid(url=e.url): + # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com + # works, it seems that we should follow this URL now and try to get a valid response. + return self._attempt_response(url=e.url) + log.debug("Invalid redirect URL: %s", e.url) + return False, None except TransportError as e: log.debug("Failed to get a response: %s", e) return False, None - if r.status_code in (301, 302) and "location" in r.headers: - redirect_url = get_redirect_url(r) - if self._redirect_url_is_valid(url=redirect_url): - # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com - # works, it seems that we should follow this URL now and try to get a valid response. - return self._attempt_response(url=redirect_url) - if r.status_code == 200: + except CONNECTION_ERRORS as e: + log.debug("Failed to get a response: %s", e) + return False, None + + # We got a valid response. Unless this is a URL redirect response, we cache the result + if not user_response.redirect_url: + cache_key = self._cache_key + log.debug("Adding cache entry for key %s: %s", cache_key, protocol.service_endpoint) + autodiscover_cache[cache_key] = protocol + return True, user_response + + def _ensure_valid_hostname(self, url): + hostname = urlparse(url).netloc + log.debug("Checking if %s can be looked up in DNS", hostname) + try: + self.resolver.resolve(f"{hostname}.", "A", lifetime=self.DNS_RESOLVER_LIFETIME) + except DNS_LOOKUP_ERRORS as e: + log.debug("DNS A lookup failure: %s", e) + # 'requests' is bad at reporting that a hostname cannot be resolved. Let's check this separately. + # Don't retry on DNS errors. They will most likely be persistent. + raise TransportError(f"{hostname!r} has no DNS entry") + + def _get_srv_records(self, hostname): + """Send a DNS query for SRV entries for the hostname. + + An SRV entry that has been formatted for autodiscovery will have the following format: + + canonical name = mail.example.com. + service = 8 100 443 webmail.example.com. + + The first three numbers in the service line are: priority, weight, port + + :param hostname: + :return: + """ + log.debug("Attempting to get SRV records for %s", hostname) + records = [] + try: + answers = self.resolver.resolve(f"{hostname}.", "SRV", lifetime=self.DNS_RESOLVER_LIFETIME) + except DNS_LOOKUP_ERRORS as e: + log.debug("DNS SRV lookup failure: %s", e) + return records + for rdata in answers: try: - ad = Autodiscover.from_bytes(bytes_content=r.content) - except ParseError as e: - log.debug("Invalid response: %s", e) - else: - # We got a valid response. Unless this is a URL redirect response, we cache the result - if ad.response is None or not ad.response.redirect_url: - cache_key = self._cache_key - log.debug("Adding cache entry for key %s: %s", cache_key, ad_protocol.service_endpoint) - autodiscover_cache[cache_key] = ad_protocol - return True, ad - return False, None
    + vals = rdata.to_text().strip().rstrip(".").split(" ") + # Raise ValueError if the first three are not ints, and IndexError if there are less than 4 values + priority, weight, port, srv = int(vals[0]), int(vals[1]), int(vals[2]), vals[3] + record = SrvRecord(priority=priority, weight=weight, port=port, srv=srv) + log.debug("Found SRV record %s ", record) + records.append(record) + except (ValueError, IndexError): + log.debug("Incompatible SRV record for %s (%s)", hostname, rdata.to_text()) + return records + + def _step_1(self, hostname): + """Perform step 1, where the client sends an Autodiscover request to + https://example.com/ and then does one of the following: + * If the Autodiscover attempt succeeds, the client proceeds to step 5. + * If the Autodiscover attempt fails, the client proceeds to step 2. + + :param hostname: + :return: + """ + url = f"https://{hostname}/{self.URL_PATH}" + log.info("Step 1: Trying autodiscover on %r with email %r", url, self.email) + is_valid_response, ad = self._attempt_response(url=url) + if is_valid_response: + return self._step_5(ad=ad) + return self._step_2(hostname=hostname) + + def _step_2(self, hostname): + """Perform step 2, where the client sends an Autodiscover request to + https://autodiscover.example.com/ and then does one of the following: + * If the Autodiscover attempt succeeds, the client proceeds to step 5. + * If the Autodiscover attempt fails, the client proceeds to step 3. + + :param hostname: + :return: + """ + url = f"https://autodiscover.{hostname}/{self.URL_PATH}" + log.info("Step 2: Trying autodiscover on %r with email %r", url, self.email) + is_valid_response, ad = self._attempt_response(url=url) + if is_valid_response: + return self._step_5(ad=ad) + return self._step_3(hostname=hostname) + + def _step_3(self, hostname): + """Perform step 3, where the client sends an unauthenticated GET method request to + http://autodiscover.example.com/ (Note that this is a non-HTTPS endpoint). The + client then does one of the following: + * If the GET request returns a 302 redirect response, it gets the redirection URL from the 'Location' HTTP + header and validates it as described in the "Redirect responses" section. The client then does one of the + following: + * If the redirection URL is valid, the client tries the URL and then does one of the following: + * If the attempt succeeds, the client proceeds to step 5. + * If the attempt fails, the client proceeds to step 4. + * If the redirection URL is not valid, the client proceeds to step 4. + * If the GET request does not return a 302 redirect response, the client proceeds to step 4. + + :param hostname: + :return: + """ + url = f"http://autodiscover.{hostname}/{self.URL_PATH}" + log.info("Step 3: Trying autodiscover on %r with email %r", url, self.email) + try: + _, r = self._get_unauthenticated_response(url=url, method="get") + except TransportError: + pass + else: + if r.status_code in (301, 302) and "location" in r.headers: + redirect_url = get_redirect_url(r) + if self._redirect_url_is_valid(url=redirect_url): + is_valid_response, ad = self._attempt_response(url=redirect_url) + if is_valid_response: + return self._step_5(ad=ad) + log.debug("Got invalid response") + return self._step_4(hostname=hostname) + log.debug("Got invalid redirect URL") + return self._step_4(hostname=hostname) + log.debug("Got no redirect URL") + return self._step_4(hostname=hostname) + + def _step_4(self, hostname): + """Perform step 4, where the client performs a Domain Name System (DNS) query for an SRV record for + _autodiscover._tcp.example.com. The query might return multiple records. The client selects only records that + point to an SSL endpoint and that have the highest priority and weight. One of the following actions then + occurs: + * If no such records are returned, the client proceeds to step 6. + * If records are returned, the application randomly chooses a record in the list and validates the endpoint + that it points to by following the process described in the "Redirect Response" section. The client then + does one of the following: + * If the redirection URL is valid, the client tries the URL and then does one of the following: + * If the attempt succeeds, the client proceeds to step 5. + * If the attempt fails, the client proceeds to step 6. + * If the redirection URL is not valid, the client proceeds to step 6. + + :param hostname: + :return: + """ + dns_hostname = f"_autodiscover._tcp.{hostname}" + log.info("Step 4: Trying autodiscover on %r with email %r", dns_hostname, self.email) + srv_records = self._get_srv_records(dns_hostname) + try: + srv_host = _select_srv_host(srv_records) + except ValueError: + srv_host = None + if not srv_host: + return self._step_6() + redirect_url = f"https://{srv_host}/{self.URL_PATH}" + if self._redirect_url_is_valid(url=redirect_url): + is_valid_response, ad = self._attempt_response(url=redirect_url) + if is_valid_response: + return self._step_5(ad=ad) + log.debug("Got invalid response") + return self._step_6() + log.debug("Got invalid redirect URL") + return self._step_6() + + def _step_5(self, ad): + """Perform step 5. When a valid Autodiscover request succeeds, the following sequence occurs: + * If the server responds with an HTTP 302 redirect, the client validates the redirection URL according to + the process defined in the "Redirect responses" and then does one of the following: + * If the redirection URL is valid, the client tries the URL and then does one of the following: + * If the attempt succeeds, the client repeats step 5 from the beginning. + * If the attempt fails, the client proceeds to step 6. + * If the redirection URL is not valid, the client proceeds to step 6. + * If the server responds with a valid Autodiscover response, the client does one of the following: + * If the value of the Action element is "Redirect", the client gets the redirection email address from + the Redirect element and then returns to step 1, using this new email address. + * If the value of the Action element is "Settings", the client has successfully received the requested + configuration settings for the specified user. The client does not need to proceed to step 6. + + :param ad: + :return: + """ + log.info("Step 5: Checking response") + # This is not explicit in the protocol, but let's raise any errors here + ad.raise_errors() + + if ad.redirect_url: + log.debug("Got a redirect URL: %s", ad.redirect_url) + # We are diverging a bit from the protocol here. We will never get an HTTP 302 since earlier steps already + # followed the redirects where possible. Instead, we handle redirect responses here. + if self._redirect_url_is_valid(url=ad.redirect_url): + is_valid_response, ad = self._attempt_response(url=ad.redirect_url) + if is_valid_response: + return self._step_5(ad=ad) + log.debug("Got invalid response") + return self._step_6() + log.debug("Invalid redirect URL") + return self._step_6() + # This could be an email redirect. Let outer layer handle this + return ad + + def _step_6(self): + """Perform step 6. If the client cannot contact the Autodiscover service, the client should ask the user for + the Exchange server name and use it to construct an Exchange EWS URL. The client should try to use this URL for + future requests. + """ + raise AutoDiscoverFailed( + f"All steps in the autodiscover protocol failed for email {self.email}. If you think this is an error, " + f"consider doing an official test at https://testconnectivity.microsoft.com" + )
    -

    Ancestors

    -

    Class variables

    -
    var URL_PATH
    +
    var DNS_RESOLVER_ATTRS
    +
    +
    +
    +
    var DNS_RESOLVER_KWARGS
    +
    +
    +
    +
    var DNS_RESOLVER_LIFETIME
    +
    var INITIAL_RETRY_POLICY
    +
    +
    +
    +
    var MAX_REDIRECTS
    +
    +
    +
    +
    var URL_PATH
    +
    +
    +
    +
    +

    Instance variables

    +
    +
    var resolver
    +
    +
    +
    + +Expand source code + +
    def __get__(self, obj, cls):
    +    if obj is None:
    +        return self
    +
    +    obj_dict = obj.__dict__
    +    name = self.func.__name__
    +    with self.lock:
    +        try:
    +            # check if the value was computed before the lock was acquired
    +            return obj_dict[name]
    +
    +        except KeyError:
    +            # if not, do the calculation and release the lock
    +            return obj_dict.setdefault(name, self.func(obj))
    +
    +
    +
    +

    Methods

    +
    +
    +def clear(self) +
    +
    +
    +
    + +Expand source code + +
    def clear(self):
    +    # This resets cached variables
    +    self._urls_visited = []
    +    self._redirect_count = 0
    +    self._emails_visited = []
    +
    +
    +
    +def discover(self) +
    +
    +
    +
    + +Expand source code + +
    def discover(self):
    +    self._emails_visited.append(self.email.lower())
    +
    +    # Check the autodiscover cache to see if we already know the autodiscover service endpoint for this email
    +    # domain. Use a lock to guard against multiple threads competing to cache information.
    +    log.debug("Waiting for autodiscover_cache lock")
    +    with autodiscover_cache:
    +        log.debug("autodiscover_cache lock acquired")
    +        cache_key = self._cache_key
    +        domain = get_domain(self.email)
    +        if cache_key in autodiscover_cache:
    +            ad_protocol = autodiscover_cache[cache_key]
    +            log.debug("Cache hit for key %s: %s", cache_key, ad_protocol.service_endpoint)
    +            try:
    +                ad = self._quick(protocol=ad_protocol)
    +            except AutoDiscoverFailed:
    +                # Autodiscover no longer works with this domain. Clear cache and try again after releasing the lock
    +                log.debug("AD request failure. Removing cache for key %s", cache_key)
    +                del autodiscover_cache[cache_key]
    +                ad = self._step_1(hostname=domain)
    +        else:
    +            # This will cache the result
    +            log.debug("Cache miss for key %s", cache_key)
    +            ad = self._step_1(hostname=domain)
    +
    +    log.debug("Released autodiscover_cache_lock")
    +    if ad.redirect_address:
    +        log.debug("Got a redirect address: %s", ad.redirect_address)
    +        if ad.redirect_address.lower() in self._emails_visited:
    +            raise AutoDiscoverCircularRedirect("We were redirected to an email address we have already seen")
    +
    +        # Start over, but with the new email address
    +        self.email = ad.redirect_address
    +        return self.discover()
    +
    +    # We successfully received a response. Clear the cache of seen emails etc.
    +    self.clear()
    +    return self._build_response(ad_response=ad)
    +
    +

    @@ -663,8 +1031,7 @@

    Index

  • Sub-modules

  • @@ -695,9 +1062,17 @@

    PoxAutodiscovery

    +

    Autodiscovery

    diff --git a/docs/exchangelib/errors.html b/docs/exchangelib/errors.html index 4c54e5df..b4dabf8f 100644 --- a/docs/exchangelib/errors.html +++ b/docs/exchangelib/errors.html @@ -89,17 +89,12 @@

    Module exchangelib.errors

    class RateLimitError(TransportError): - def __init__(self, value, url, status_code, total_wait): + def __init__(self, value, wait): super().__init__(value) - self.url = url - self.status_code = status_code - self.total_wait = total_wait + self.wait = wait def __str__(self): - return ( - f"{self.value} (gave up after {self.total_wait:.3f} seconds. " - f"URL {self.url} returned status code {self.status_code})" - ) + return f"{self.value} (gave up when asked to back off {self.wait:.3f} seconds)" class SOAPError(TransportError): @@ -10524,7 +10519,7 @@

    Ancestors

    class RateLimitError -(value, url, status_code, total_wait) +(value, wait)

    Global error type within this module.

    @@ -10533,17 +10528,12 @@

    Ancestors

    Expand source code
    class RateLimitError(TransportError):
    -    def __init__(self, value, url, status_code, total_wait):
    +    def __init__(self, value, wait):
             super().__init__(value)
    -        self.url = url
    -        self.status_code = status_code
    -        self.total_wait = total_wait
    +        self.wait = wait
     
         def __str__(self):
    -        return (
    -            f"{self.value} (gave up after {self.total_wait:.3f} seconds. "
    -            f"URL {self.url} returned status code {self.status_code})"
    -        )
    + return f"{self.value} (gave up when asked to back off {self.wait:.3f} seconds)"

    Ancestors

      @@ -12389,4 +12379,4 @@

      pdoc 0.10.0.

      - \ No newline at end of file + diff --git a/docs/exchangelib/fields.html b/docs/exchangelib/fields.html index 34ec7be7..76f3a7d1 100644 --- a/docs/exchangelib/fields.html +++ b/docs/exchangelib/fields.html @@ -45,7 +45,7 @@

      Module exchangelib.fields

      value_to_xml_text, xml_text_to_value, ) -from .version import EXCHANGE_2013, Build, SupportedVersionInstanceMixIn +from .version import EXCHANGE_2013, SupportedVersionInstanceMixIn log = logging.getLogger(__name__) @@ -451,10 +451,6 @@

      Module exchangelib.fields

      value_cls = bool -class OnOffField(BooleanField): - """A field that handles boolean values that are On/Off instead of True/False.""" - - class IntegerField(FieldURIField): """A field that handles integer values.""" @@ -1507,6 +1503,9 @@

      Module exchangelib.fields

      kwargs["value_cls"] = PermissionSet super().__init__(*args, **kwargs) + def to_xml(self, value, version): + return value.to_xml(version=version) + class EffectiveRightsField(EWSElementField): def __init__(self, *args, **kwargs): @@ -1516,33 +1515,6 @@

      Module exchangelib.fields

      super().__init__(*args, **kwargs) -class BuildField(CharField): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.value_cls = Build - - def from_xml(self, elem, account): - val = self._get_val_from_elem(elem) - if val: - try: - return self.value_cls.from_hex_string(val) - except (TypeError, ValueError): - log.warning("Invalid server version string: %r", val) - return val - - -class ProtocolListField(EWSElementListField): - # There is not containing element for this field. Just multiple 'Protocol' elements on the 'Account' element. - def __init__(self, *args, **kwargs): - from .autodiscover.properties import Protocol - - kwargs["value_cls"] = Protocol - super().__init__(*args, **kwargs) - - def from_xml(self, elem, account): - return [self.value_cls.from_xml(elem=e, account=account) for e in elem.findall(self.value_cls.response_tag())] - - class RoutingTypeField(ChoiceField): def __init__(self, *args, **kwargs): kwargs["choices"] = {Choice("SMTP"), Choice("EX")} @@ -2414,10 +2386,6 @@

      Ancestors

    • Field
    • SupportedVersionInstanceMixIn
    -

    Subclasses

    -

    Class variables

    var value_cls
    @@ -2438,49 +2406,6 @@

    Inherited members

    -
    -class BuildField -(*args, **kwargs) -
    -
    -

    A field that stores a string value with a limited length.

    -
    - -Expand source code - -
    class BuildField(CharField):
    -    def __init__(self, *args, **kwargs):
    -        super().__init__(*args, **kwargs)
    -        self.value_cls = Build
    -
    -    def from_xml(self, elem, account):
    -        val = self._get_val_from_elem(elem)
    -        if val:
    -            try:
    -                return self.value_cls.from_hex_string(val)
    -            except (TypeError, ValueError):
    -                log.warning("Invalid server version string: %r", val)
    -        return val
    -
    -

    Ancestors

    - -

    Inherited members

    - -
    class CharField (*args, **kwargs) @@ -2518,7 +2443,6 @@

    Ancestors

    Subclasses

    @@ -5719,37 +5642,6 @@

    Inherited members

    -
    -class OnOffField -(*args, **kwargs) -
    -
    -

    A field that handles boolean values that are On/Off instead of True/False.

    -
    - -Expand source code - -
    class OnOffField(BooleanField):
    -    """A field that handles boolean values that are On/Off instead of True/False."""
    -
    -

    Ancestors

    - -

    Inherited members

    - -
    class PermissionSetField (*args, **kwargs) @@ -5767,7 +5659,10 @@

    Inherited members

    from .properties import PermissionSet kwargs["value_cls"] = PermissionSet - super().__init__(*args, **kwargs)
    + super().__init__(*args, **kwargs) + + def to_xml(self, value, version): + return value.to_xml(version=version)

    Ancestors

      @@ -6016,45 +5911,6 @@

      Inherited members

    -
    -class ProtocolListField -(*args, **kwargs) -
    -
    -

    Like EWSElementField, but for lists of EWSElement objects.

    -
    - -Expand source code - -
    class ProtocolListField(EWSElementListField):
    -    # There is not containing element for this field. Just multiple 'Protocol' elements on the 'Account' element.
    -    def __init__(self, *args, **kwargs):
    -        from .autodiscover.properties import Protocol
    -
    -        kwargs["value_cls"] = Protocol
    -        super().__init__(*args, **kwargs)
    -
    -    def from_xml(self, elem, account):
    -        return [self.value_cls.from_xml(elem=e, account=account) for e in elem.findall(self.value_cls.response_tag())]
    -
    -

    Ancestors

    - -

    Inherited members

    - -
    class RecipientAddressField (*args, **kwargs) @@ -7225,9 +7081,6 @@

    BuildField

    - -
  • CharField

    • clean
    • @@ -7488,9 +7341,6 @@

      OnOffField

      - -
    • PermissionSetField

      • is_complex
      • @@ -7525,9 +7375,6 @@

        PostalAddressAttributedValueField

      • -

        ProtocolListField

        -
      • -
      • RecipientAddressField

      • diff --git a/docs/exchangelib/folders/base.html b/docs/exchangelib/folders/base.html index 983e768f..3c510914 100644 --- a/docs/exchangelib/folders/base.html +++ b/docs/exchangelib/folders/base.html @@ -258,6 +258,7 @@

        Module exchangelib.folders.base

        from .known_folders import ( ApplicationData, Calendar, + Companies, Contacts, ConversationSettings, CrawlerData, @@ -265,6 +266,8 @@

        Module exchangelib.folders.base

        FreeBusyCache, GALContacts, Messages, + OrganizationalContacts, + PeopleCentricConversationBuddies, RecipientCache, RecoveryPoints, Reminders, @@ -277,6 +280,7 @@

        Module exchangelib.folders.base

        for folder_cls in ( ApplicationData, Calendar, + Companies, Contacts, ConversationSettings, CrawlerData, @@ -284,6 +288,8 @@

        Module exchangelib.folders.base

        FreeBusyCache, GALContacts, Messages, + OrganizationalContacts, + PeopleCentricConversationBuddies, RSSFeeds, RecipientCache, RecoveryPoints, @@ -930,10 +936,14 @@

        Module exchangelib.folders.base

        # # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. - if folder.name: + if folder.name and folder.folder_class: with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative - folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) + folder_cls = root.folder_cls_from_folder_name( + folder_name=folder.name, + folder_class=folder.folder_class, + locale=root.account.locale, + ) log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) if folder.folder_class and folder_cls == Folder: with suppress(KeyError): @@ -1138,6 +1148,7 @@

        Classes

        from .known_folders import ( ApplicationData, Calendar, + Companies, Contacts, ConversationSettings, CrawlerData, @@ -1145,6 +1156,8 @@

        Classes

        FreeBusyCache, GALContacts, Messages, + OrganizationalContacts, + PeopleCentricConversationBuddies, RecipientCache, RecoveryPoints, Reminders, @@ -1157,6 +1170,7 @@

        Classes

        for folder_cls in ( ApplicationData, Calendar, + Companies, Contacts, ConversationSettings, CrawlerData, @@ -1164,6 +1178,8 @@

        Classes

        FreeBusyCache, GALContacts, Messages, + OrganizationalContacts, + PeopleCentricConversationBuddies, RSSFeeds, RecipientCache, RecoveryPoints, @@ -1809,6 +1825,7 @@

        Static methods

        from .known_folders import ( ApplicationData, Calendar, + Companies, Contacts, ConversationSettings, CrawlerData, @@ -1816,6 +1833,8 @@

        Static methods

        FreeBusyCache, GALContacts, Messages, + OrganizationalContacts, + PeopleCentricConversationBuddies, RecipientCache, RecoveryPoints, Reminders, @@ -1828,6 +1847,7 @@

        Static methods

        for folder_cls in ( ApplicationData, Calendar, + Companies, Contacts, ConversationSettings, CrawlerData, @@ -1835,6 +1855,8 @@

        Static methods

        FreeBusyCache, GALContacts, Messages, + OrganizationalContacts, + PeopleCentricConversationBuddies, RSSFeeds, RecipientCache, RecoveryPoints, @@ -3012,10 +3034,14 @@

        Inherited members

        # # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. - if folder.name: + if folder.name and folder.folder_class: with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative - folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) + folder_cls = root.folder_cls_from_folder_name( + folder_name=folder.name, + folder_class=folder.folder_class, + locale=root.account.locale, + ) log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) if folder.folder_class and folder_cls == Folder: with suppress(KeyError): @@ -3121,10 +3147,14 @@

        Static methods

        # # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. - if folder.name: + if folder.name and folder.folder_class: with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative - folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) + folder_cls = root.folder_cls_from_folder_name( + folder_name=folder.name, + folder_class=folder.folder_class, + locale=root.account.locale, + ) log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) if folder.folder_class and folder_cls == Folder: with suppress(KeyError): diff --git a/docs/exchangelib/folders/index.html b/docs/exchangelib/folders/index.html index a5940f7b..0273ab4f 100644 --- a/docs/exchangelib/folders/index.html +++ b/docs/exchangelib/folders/index.html @@ -1432,6 +1432,7 @@

        Inherited members

        from .known_folders import ( ApplicationData, Calendar, + Companies, Contacts, ConversationSettings, CrawlerData, @@ -1439,6 +1440,8 @@

        Inherited members

        FreeBusyCache, GALContacts, Messages, + OrganizationalContacts, + PeopleCentricConversationBuddies, RecipientCache, RecoveryPoints, Reminders, @@ -1451,6 +1454,7 @@

        Inherited members

        for folder_cls in ( ApplicationData, Calendar, + Companies, Contacts, ConversationSettings, CrawlerData, @@ -1458,6 +1462,8 @@

        Inherited members

        FreeBusyCache, GALContacts, Messages, + OrganizationalContacts, + PeopleCentricConversationBuddies, RSSFeeds, RecipientCache, RecoveryPoints, @@ -2103,6 +2109,7 @@

        Static methods

        from .known_folders import ( ApplicationData, Calendar, + Companies, Contacts, ConversationSettings, CrawlerData, @@ -2110,6 +2117,8 @@

        Static methods

        FreeBusyCache, GALContacts, Messages, + OrganizationalContacts, + PeopleCentricConversationBuddies, RecipientCache, RecoveryPoints, Reminders, @@ -2122,6 +2131,7 @@

        Static methods

        for folder_cls in ( ApplicationData, Calendar, + Companies, Contacts, ConversationSettings, CrawlerData, @@ -2129,6 +2139,8 @@

        Static methods

        FreeBusyCache, GALContacts, Messages, + OrganizationalContacts, + PeopleCentricConversationBuddies, RSSFeeds, RecipientCache, RecoveryPoints, @@ -3539,7 +3551,7 @@

        Inherited members

        class Companies(NonDeletableFolderMixIn, Contacts):
             DISTINGUISHED_FOLDER_ID = None
        -    CONTAINTER_CLASS = "IPF.Contact.Company"
        +    CONTAINER_CLASS = "IPF.Contact.Company"
             LOCALIZED_NAMES = {
                 None: ("Companies",),
                 "da_DK": ("Firmaer",),
        @@ -3559,7 +3571,7 @@ 

        Ancestors

      Class variables

      -
      var CONTAINTER_CLASS
      +
      var CONTAINER_CLASS
      @@ -4921,10 +4933,14 @@

      Inherited members

      # # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. - if folder.name: + if folder.name and folder.folder_class: with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative - folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) + folder_cls = root.folder_cls_from_folder_name( + folder_name=folder.name, + folder_class=folder.folder_class, + locale=root.account.locale, + ) log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) if folder.folder_class and folder_cls == Folder: with suppress(KeyError): @@ -5030,10 +5046,14 @@

      Static methods

      # # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. - if folder.name: + if folder.name and folder.folder_class: with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative - folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) + folder_cls = root.folder_cls_from_folder_name( + folder_name=folder.name, + folder_class=folder.folder_class, + locale=root.account.locale, + ) log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) if folder.folder_class and folder_cls == Folder: with suppress(KeyError): @@ -8118,7 +8138,7 @@

      Inherited members

      class OrganizationalContacts(NonDeletableFolderMixIn, Contacts):
           DISTINGUISHED_FOLDER_ID = None
      -    CONTAINTER_CLASS = "IPF.Contact.OrganizationalContacts"
      +    CONTAINER_CLASS = "IPF.Contact.OrganizationalContacts"
           LOCALIZED_NAMES = {
               None: ("Organizational Contacts",),
           }
      @@ -8137,7 +8157,7 @@

      Ancestors

    Class variables

    -
    var CONTAINTER_CLASS
    +
    var CONTAINER_CLASS
    @@ -8518,7 +8538,7 @@

    Inherited members

    class PeopleCentricConversationBuddies(NonDeletableFolderMixIn, Contacts):
         DISTINGUISHED_FOLDER_ID = None
    -    CONTAINTER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies"
    +    CONTAINER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies"
         LOCALIZED_NAMES = {
             None: ("PeopleCentricConversation Buddies",),
         }
    @@ -8537,7 +8557,7 @@

    Ancestors

    Class variables

    -
    var CONTAINTER_CLASS
    +
    var CONTAINER_CLASS
    @@ -9687,8 +9707,6 @@

    Inherited members

    # 'RootOfHierarchy' subclasses must not be in this list. WELLKNOWN_FOLDERS = [] - _subfolders_lock = Lock() - # This folder type also has 'folder:PermissionSet' on some server versions, but requesting it sometimes causes # 'ErrorAccessDenied', as reported by some users. Ignore it entirely for root folders - it's usefulness is # deemed minimal at best. @@ -9696,13 +9714,14 @@

    Inherited members

    field_uri="folder:EffectiveRights", is_read_only=True, supported_from=EXCHANGE_2007_SP1 ) - __slots__ = "_account", "_subfolders" + __slots__ = "_account", "_subfolders", "_subfolders_lock" # A special folder that acts as the top of a folder hierarchy. Finds and caches sub-folders at arbitrary depth. def __init__(self, **kwargs): self._account = kwargs.pop("account", None) # A pointer back to the account holding the folder hierarchy super().__init__(**kwargs) self._subfolders = None # See self._folders_map() + self._subfolders_lock = Lock() @property def account(self): @@ -9857,17 +9876,32 @@

    Inherited members

    return cls(account=account, **kwargs) @classmethod - def folder_cls_from_folder_name(cls, folder_name, locale): - """Return the folder class that matches a localized folder name. + def folder_cls_from_folder_name(cls, folder_name, folder_class, locale): + """Return the folder class that matches a localized folder name. Take into account the 'folder_class' of the + folder, to not identify an 'IPF.Note' folder as a 'Calendar' class just because it's called e.g. 'Kalender' and + the locale is 'da_DK'. :param folder_name: + :param folder_class: :param locale: a string, e.g. 'da_DK' """ for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS + MISC_FOLDERS: - if folder_name.lower() in folder_cls.localized_names(locale): + if folder_cls.CONTAINER_CLASS == folder_class and folder_name.lower() in folder_cls.localized_names(locale): return folder_cls raise KeyError() + def __getstate__(self): + # The lock cannot be pickled + state = {k: getattr(self, k) for k in self._slots_keys} + del state["_subfolders_lock"] + return state + + def __setstate__(self, state): + # Restore the lock + for k in self._slots_keys: + setattr(self, k, state.get(k)) + self._subfolders_lock = Lock() + def __repr__(self): # Let's not create an infinite loop when printing self.root return self.__class__.__name__ + repr( @@ -9913,25 +9947,31 @@

    Class variables

    Static methods

    -def folder_cls_from_folder_name(folder_name, locale) +def folder_cls_from_folder_name(folder_name, folder_class, locale)
    -

    Return the folder class that matches a localized folder name.

    +

    Return the folder class that matches a localized folder name. Take into account the 'folder_class' of the +folder, to not identify an 'IPF.Note' folder as a 'Calendar' class just because it's called e.g. 'Kalender' and +the locale is 'da_DK'.

    :param folder_name: +:param folder_class: :param locale: a string, e.g. 'da_DK'

    Expand source code
    @classmethod
    -def folder_cls_from_folder_name(cls, folder_name, locale):
    -    """Return the folder class that matches a localized folder name.
    +def folder_cls_from_folder_name(cls, folder_name, folder_class, locale):
    +    """Return the folder class that matches a localized folder name. Take into account the 'folder_class' of the
    +    folder, to not identify an 'IPF.Note' folder as a 'Calendar' class just because it's called e.g. 'Kalender' and
    +    the locale is 'da_DK'.
     
         :param folder_name:
    +    :param folder_class:
         :param locale: a string, e.g. 'da_DK'
         """
         for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS + MISC_FOLDERS:
    -        if folder_name.lower() in folder_cls.localized_names(locale):
    +        if folder_cls.CONTAINER_CLASS == folder_class and folder_name.lower() in folder_cls.localized_names(locale):
                 return folder_cls
         raise KeyError()
    @@ -12000,7 +12040,7 @@

    Companies

    @@ -12295,7 +12335,7 @@

    OrganizationalContacts

    @@ -12331,7 +12371,7 @@

    PeopleCentricConversationBuddies

    diff --git a/docs/exchangelib/folders/known_folders.html b/docs/exchangelib/folders/known_folders.html index 8d7b1411..5dd3c001 100644 --- a/docs/exchangelib/folders/known_folders.html +++ b/docs/exchangelib/folders/known_folders.html @@ -472,7 +472,7 @@

    Module exchangelib.folders.known_folders

    class Companies(NonDeletableFolderMixIn, Contacts): DISTINGUISHED_FOLDER_ID = None - CONTAINTER_CLASS = "IPF.Contact.Company" + CONTAINER_CLASS = "IPF.Contact.Company" LOCALIZED_NAMES = { None: ("Companies",), "da_DK": ("Firmaer",), @@ -564,7 +564,7 @@

    Module exchangelib.folders.known_folders

    class OrganizationalContacts(NonDeletableFolderMixIn, Contacts): DISTINGUISHED_FOLDER_ID = None - CONTAINTER_CLASS = "IPF.Contact.OrganizationalContacts" + CONTAINER_CLASS = "IPF.Contact.OrganizationalContacts" LOCALIZED_NAMES = { None: ("Organizational Contacts",), } @@ -586,7 +586,7 @@

    Module exchangelib.folders.known_folders

    class PeopleCentricConversationBuddies(NonDeletableFolderMixIn, Contacts): DISTINGUISHED_FOLDER_ID = None - CONTAINTER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies" + CONTAINER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies" LOCALIZED_NAMES = { None: ("PeopleCentricConversation Buddies",), } @@ -2054,7 +2054,7 @@

    Inherited members

    class Companies(NonDeletableFolderMixIn, Contacts):
         DISTINGUISHED_FOLDER_ID = None
    -    CONTAINTER_CLASS = "IPF.Contact.Company"
    +    CONTAINER_CLASS = "IPF.Contact.Company"
         LOCALIZED_NAMES = {
             None: ("Companies",),
             "da_DK": ("Firmaer",),
    @@ -2074,7 +2074,7 @@ 

    Ancestors

    Class variables

    -
    var CONTAINTER_CLASS
    +
    var CONTAINER_CLASS
    @@ -4646,7 +4646,7 @@

    Inherited members

    class OrganizationalContacts(NonDeletableFolderMixIn, Contacts):
         DISTINGUISHED_FOLDER_ID = None
    -    CONTAINTER_CLASS = "IPF.Contact.OrganizationalContacts"
    +    CONTAINER_CLASS = "IPF.Contact.OrganizationalContacts"
         LOCALIZED_NAMES = {
             None: ("Organizational Contacts",),
         }
    @@ -4665,7 +4665,7 @@

    Ancestors

    Class variables

    -
    var CONTAINTER_CLASS
    +
    var CONTAINER_CLASS
    @@ -5046,7 +5046,7 @@

    Inherited members

    class PeopleCentricConversationBuddies(NonDeletableFolderMixIn, Contacts):
         DISTINGUISHED_FOLDER_ID = None
    -    CONTAINTER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies"
    +    CONTAINER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies"
         LOCALIZED_NAMES = {
             None: ("PeopleCentricConversation Buddies",),
         }
    @@ -5065,7 +5065,7 @@

    Ancestors

    Class variables

    -
    var CONTAINTER_CLASS
    +
    var CONTAINER_CLASS
    @@ -7655,7 +7655,7 @@

    Companies

    @@ -7887,7 +7887,7 @@

    OrganizationalContacts

    @@ -7923,7 +7923,7 @@

    PeopleCentricConversationBuddies

    diff --git a/docs/exchangelib/folders/roots.html b/docs/exchangelib/folders/roots.html index 74242713..d723541e 100644 --- a/docs/exchangelib/folders/roots.html +++ b/docs/exchangelib/folders/roots.html @@ -57,8 +57,6 @@

    Module exchangelib.folders.roots

    # 'RootOfHierarchy' subclasses must not be in this list. WELLKNOWN_FOLDERS = [] - _subfolders_lock = Lock() - # This folder type also has 'folder:PermissionSet' on some server versions, but requesting it sometimes causes # 'ErrorAccessDenied', as reported by some users. Ignore it entirely for root folders - it's usefulness is # deemed minimal at best. @@ -66,13 +64,14 @@

    Module exchangelib.folders.roots

    field_uri="folder:EffectiveRights", is_read_only=True, supported_from=EXCHANGE_2007_SP1 ) - __slots__ = "_account", "_subfolders" + __slots__ = "_account", "_subfolders", "_subfolders_lock" # A special folder that acts as the top of a folder hierarchy. Finds and caches sub-folders at arbitrary depth. def __init__(self, **kwargs): self._account = kwargs.pop("account", None) # A pointer back to the account holding the folder hierarchy super().__init__(**kwargs) self._subfolders = None # See self._folders_map() + self._subfolders_lock = Lock() @property def account(self): @@ -227,17 +226,32 @@

    Module exchangelib.folders.roots

    return cls(account=account, **kwargs) @classmethod - def folder_cls_from_folder_name(cls, folder_name, locale): - """Return the folder class that matches a localized folder name. + def folder_cls_from_folder_name(cls, folder_name, folder_class, locale): + """Return the folder class that matches a localized folder name. Take into account the 'folder_class' of the + folder, to not identify an 'IPF.Note' folder as a 'Calendar' class just because it's called e.g. 'Kalender' and + the locale is 'da_DK'. :param folder_name: + :param folder_class: :param locale: a string, e.g. 'da_DK' """ for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS + MISC_FOLDERS: - if folder_name.lower() in folder_cls.localized_names(locale): + if folder_cls.CONTAINER_CLASS == folder_class and folder_name.lower() in folder_cls.localized_names(locale): return folder_cls raise KeyError() + def __getstate__(self): + # The lock cannot be pickled + state = {k: getattr(self, k) for k in self._slots_keys} + del state["_subfolders_lock"] + return state + + def __setstate__(self, state): + # Restore the lock + for k in self._slots_keys: + setattr(self, k, state.get(k)) + self._subfolders_lock = Lock() + def __repr__(self): # Let's not create an infinite loop when printing self.root return self.__class__.__name__ + repr( @@ -792,8 +806,6 @@

    Inherited members

    # 'RootOfHierarchy' subclasses must not be in this list. WELLKNOWN_FOLDERS = [] - _subfolders_lock = Lock() - # This folder type also has 'folder:PermissionSet' on some server versions, but requesting it sometimes causes # 'ErrorAccessDenied', as reported by some users. Ignore it entirely for root folders - it's usefulness is # deemed minimal at best. @@ -801,13 +813,14 @@

    Inherited members

    field_uri="folder:EffectiveRights", is_read_only=True, supported_from=EXCHANGE_2007_SP1 ) - __slots__ = "_account", "_subfolders" + __slots__ = "_account", "_subfolders", "_subfolders_lock" # A special folder that acts as the top of a folder hierarchy. Finds and caches sub-folders at arbitrary depth. def __init__(self, **kwargs): self._account = kwargs.pop("account", None) # A pointer back to the account holding the folder hierarchy super().__init__(**kwargs) self._subfolders = None # See self._folders_map() + self._subfolders_lock = Lock() @property def account(self): @@ -962,17 +975,32 @@

    Inherited members

    return cls(account=account, **kwargs) @classmethod - def folder_cls_from_folder_name(cls, folder_name, locale): - """Return the folder class that matches a localized folder name. + def folder_cls_from_folder_name(cls, folder_name, folder_class, locale): + """Return the folder class that matches a localized folder name. Take into account the 'folder_class' of the + folder, to not identify an 'IPF.Note' folder as a 'Calendar' class just because it's called e.g. 'Kalender' and + the locale is 'da_DK'. :param folder_name: + :param folder_class: :param locale: a string, e.g. 'da_DK' """ for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS + MISC_FOLDERS: - if folder_name.lower() in folder_cls.localized_names(locale): + if folder_cls.CONTAINER_CLASS == folder_class and folder_name.lower() in folder_cls.localized_names(locale): return folder_cls raise KeyError() + def __getstate__(self): + # The lock cannot be pickled + state = {k: getattr(self, k) for k in self._slots_keys} + del state["_subfolders_lock"] + return state + + def __setstate__(self, state): + # Restore the lock + for k in self._slots_keys: + setattr(self, k, state.get(k)) + self._subfolders_lock = Lock() + def __repr__(self): # Let's not create an infinite loop when printing self.root return self.__class__.__name__ + repr( @@ -1018,25 +1046,31 @@

    Class variables

    Static methods

    -def folder_cls_from_folder_name(folder_name, locale) +def folder_cls_from_folder_name(folder_name, folder_class, locale)
    -

    Return the folder class that matches a localized folder name.

    +

    Return the folder class that matches a localized folder name. Take into account the 'folder_class' of the +folder, to not identify an 'IPF.Note' folder as a 'Calendar' class just because it's called e.g. 'Kalender' and +the locale is 'da_DK'.

    :param folder_name: +:param folder_class: :param locale: a string, e.g. 'da_DK'

    Expand source code
    @classmethod
    -def folder_cls_from_folder_name(cls, folder_name, locale):
    -    """Return the folder class that matches a localized folder name.
    +def folder_cls_from_folder_name(cls, folder_name, folder_class, locale):
    +    """Return the folder class that matches a localized folder name. Take into account the 'folder_class' of the
    +    folder, to not identify an 'IPF.Note' folder as a 'Calendar' class just because it's called e.g. 'Kalender' and
    +    the locale is 'da_DK'.
     
         :param folder_name:
    +    :param folder_class:
         :param locale: a string, e.g. 'da_DK'
         """
         for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS + MISC_FOLDERS:
    -        if folder_name.lower() in folder_cls.localized_names(locale):
    +        if folder_cls.CONTAINER_CLASS == folder_class and folder_name.lower() in folder_cls.localized_names(locale):
                 return folder_cls
         raise KeyError()
    diff --git a/docs/exchangelib/index.html b/docs/exchangelib/index.html index 235c891c..10be932f 100644 --- a/docs/exchangelib/index.html +++ b/docs/exchangelib/index.html @@ -64,7 +64,7 @@

    Package exchangelib

    from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "4.9.0" +__version__ = "5.0.0" __all__ = [ "__version__", @@ -289,7 +289,7 @@

    Functions

    Expand source code
    def discover(email, credentials=None, auth_type=None, retry_policy=None):
    -    ad_response, protocol = PoxAutodiscovery(email=email, credentials=credentials).discover()
    +    ad_response, protocol = Autodiscovery(email=email, credentials=credentials).discover()
         protocol.config.auth_typ = auth_type
         protocol.config.retry_policy = retry_policy
         return ad_response, protocol
    @@ -363,7 +363,7 @@

    Inherited members

    :param access_type: The access type granted to 'credentials' for this account. Valid options are 'delegate' and 'impersonation'. 'delegate' is default if 'credentials' is set. Otherwise, 'impersonation' is default. :param autodiscover: Whether to look up the EWS endpoint automatically using the autodiscover protocol. -Can also be set to "pox" or "soap" to choose the autodiscover implementation (Default value = False) +(Default value = False) :param credentials: A Credentials object containing valid credentials for this account. (Default value = None) :param config: A Configuration object containing EWS endpoint information. Required if autodiscover is disabled (Default value = None) @@ -378,8 +378,6 @@

    Inherited members

    class Account:
         """Models an Exchange server user account."""
     
    -    DEFAULT_DISCOVERY_CLS = PoxAutodiscovery
    -
         def __init__(
             self,
             primary_smtp_address,
    @@ -398,7 +396,7 @@ 

    Inherited members

    :param access_type: The access type granted to 'credentials' for this account. Valid options are 'delegate' and 'impersonation'. 'delegate' is default if 'credentials' is set. Otherwise, 'impersonation' is default. :param autodiscover: Whether to look up the EWS endpoint automatically using the autodiscover protocol. - Can also be set to "pox" or "soap" to choose the autodiscover implementation (Default value = False) + (Default value = False) :param credentials: A Credentials object containing valid credentials for this account. (Default value = None) :param config: A Configuration object containing EWS endpoint information. Required if autodiscover is disabled (Default value = None) @@ -445,12 +443,7 @@

    Inherited members

    credentials = config.credentials else: auth_type, retry_policy, version = None, None, None - discovery_cls = { - "pox": PoxAutodiscovery, - "soap": SoapAutodiscovery, - True: self.DEFAULT_DISCOVERY_CLS, - }[autodiscover] - self.ad_response, self.protocol = discovery_cls( + self.ad_response, self.protocol = Autodiscovery( email=primary_smtp_address, credentials=credentials ).discover() # Let's not use the auth_package hint from the AD response. It could be incorrect and we can just guess. @@ -1035,28 +1028,6 @@

    Inherited members

    return f"{self.primary_smtp_address} ({self.fullname})" return self.primary_smtp_address
    -

    Class variables

    -
    -
    var DEFAULT_DISCOVERY_CLS
    -
    -

    Autodiscover is a Microsoft protocol for automatically getting the endpoint of the Exchange server and other -connection-related settings holding the email address using only the email address, and username and password of the -user.

    -

    For a description of the protocol implemented, see "Autodiscover for Exchange ActiveSync developers":

    -

    https://docs.microsoft.com/en-us/previous-versions/office/developer/exchange-server-interoperability-guidance/hh352638%28v%3dexchg.140%29

    -

    Descriptions of the steps from the article are provided in their respective methods in this class.

    -

    For a description of how to handle autodiscover error messages, see:

    -

    https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/handling-autodiscover-error-messages

    -

    A tip from the article: -The client can perform steps 1 through 4 in any order or in parallel to expedite the process, but it must wait for -responses to finish at each step before proceeding. Given that many organizations prefer to use the URL in step 2 to -set up the Autodiscover service, the client might try this step first.

    -

    Another possibly newer resource which has not yet been attempted is "Outlook 2016 Implementation of Autodiscover": -https://support.microsoft.com/en-us/help/3211279/outlook-2016-implementation-of-autodiscover

    -

    WARNING: The autodiscover protocol is very complicated. If you have problems autodiscovering using this -implementation, start by doing an official test at https://testconnectivity.microsoft.com

    -
    -

    Instance variables

    var admin_audit_logs
    @@ -2729,6 +2700,7 @@

    Inherited members

    MAX_SESSION_USAGE_COUNT = None # Timeout for HTTP requests TIMEOUT = 120 + RETRY_WAIT = 10 # Seconds to wait before retry on connection errors # The adapter class to use for HTTP requests. Override this if you need e.g. proxy support or specific TLS versions HTTP_ADAPTER_CLS = requests.adapters.HTTPAdapter @@ -3049,6 +3021,10 @@

    Class variables

    +
    var RETRY_WAIT
    +
    +
    +
    var SESSION_POOLSIZE
    @@ -3601,26 +3577,6 @@

    Methods

    kwargs[k] = int(v) # Also raises ValueError return cls(**kwargs) - @classmethod - def from_hex_string(cls, s): - """Parse a server version string as returned in an autodiscover response. The process is described here: - https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/serverversion-pox#example - - The string is a hex string that, converted to a 32-bit binary, encodes the server version. The rules are: - * The first 4 bits contain the version number structure version. Can be ignored - * The next 6 bits contain the major version number - * The next 6 bits contain the minor version number - * The next bit contains a flag. Can be ignored - * The next 15 bits contain the major build number - - :param s: - """ - bin_s = f"{int(s, 16):032b}" # Convert string to 32-bit binary string - major_version = int(bin_s[4:10], 2) - minor_version = int(bin_s[10:16], 2) - build_number = int(bin_s[17:32], 2) - return cls(major_version=major_version, minor_version=minor_version, major_build=build_number) - def api_version(self): for build, api_version, _ in VERSIONS: if self.major_version != build.major_version or self.minor_version != build.minor_version: @@ -3673,44 +3629,6 @@

    Methods

    Static methods

    -
    -def from_hex_string(s) -
    -
    -

    Parse a server version string as returned in an autodiscover response. The process is described here: -https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/serverversion-pox#example

    -

    The string is a hex string that, converted to a 32-bit binary, encodes the server version. The rules are: -* The first 4 bits contain the version number structure version. Can be ignored -* The next 6 bits contain the major version number -* The next 6 bits contain the minor version number -* The next bit contains a flag. Can be ignored -* The next 15 bits contain the major build number

    -

    :param s:

    -
    - -Expand source code - -
    @classmethod
    -def from_hex_string(cls, s):
    -    """Parse a server version string as returned in an autodiscover response. The process is described here:
    -    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/serverversion-pox#example
    -
    -    The string is a hex string that, converted to a 32-bit binary, encodes the server version. The rules are:
    -        * The first 4 bits contain the version number structure version. Can be ignored
    -        * The next 6 bits contain the major version number
    -        * The next 6 bits contain the minor version number
    -        * The next bit contains a flag. Can be ignored
    -        * The next 15 bits contain the major build number
    -
    -    :param s:
    -    """
    -    bin_s = f"{int(s, 16):032b}"  # Convert string to 32-bit binary string
    -    major_version = int(bin_s[4:10], 2)
    -    minor_version = int(bin_s[10:16], 2)
    -    build_number = int(bin_s[17:32], 2)
    -    return cls(major_version=major_version, minor_version=minor_version, major_build=build_number)
    -
    -
    def from_xml(elem)
    @@ -6595,11 +6513,7 @@

    Inherited members

    return None def back_off(self, seconds): - raise ValueError("Cannot back off with fail-fast policy") - - def may_retry_on_error(self, response, wait): - log.debug("No retry with fail-fast policy") - return False

    + raise ValueError("Cannot back off with fail-fast policy")

    Ancestors

  • @@ -6677,44 +6590,22 @@

    Inherited members

    def back_off(self, seconds): if seconds is None: seconds = self.DEFAULT_BACKOFF + if seconds > self.max_wait: + # We lost patience. Session is cleaned up in outer loop + raise RateLimitError("Max timeout reached", wait=seconds) value = datetime.datetime.now() + datetime.timedelta(seconds=seconds) with self._back_off_lock: self._back_off_until = value - def may_retry_on_error(self, response, wait): - if response.status_code not in (301, 302, 401, 500, 503): - # Don't retry if we didn't get a status code that we can hope to recover from - log.debug("No retry: wrong status code %s", response.status_code) - return False - if wait > self.max_wait: - # We lost patience. Session is cleaned up in outer loop - raise RateLimitError( - "Max timeout reached", url=response.url, status_code=response.status_code, total_wait=wait - ) - if response.status_code == 401: - # EWS sometimes throws 401's when it wants us to throttle connections. OK to retry. - return True - if response.headers.get("connection") == "close": - # Connection closed. OK to retry. - return True - if ( - response.status_code == 302 - and response.headers.get("location", "").lower() - == "/ews/genericerrorpage.htm?aspxerrorpath=/ews/exchange.asmx" - ): - # The genericerrorpage.htm/internalerror.asp is ridiculous behaviour for random outages. OK to retry. - # - # Redirect to '/internalsite/internalerror.asp' or '/internalsite/initparams.aspx' is caused by e.g. TLS - # certificate f*ckups on the Exchange server. We should not retry those. - return True - if response.status_code == 503: - # Internal server error. OK to retry. - return True - if response.status_code == 500 and b"Server Error in '/EWS' Application" in response.content: - # "Server Error in '/EWS' Application" has been seen in highly concurrent settings. OK to retry. - log.debug("Retry allowed: conditions met") - return True - return False
    + def raise_response_errors(self, response): + try: + return super().raise_response_errors(response) + except (ErrorInternalServerTransientError, ErrorServerBusy) as e: + # Pass on the retry header value + retry_after = _get_retry_after(response) + if retry_after: + raise ErrorServerBusy(e.args[0], back_off=retry_after) + raise

    Ancestors

      @@ -6751,13 +6642,35 @@

      Instance variables

    +

    Methods

    +
    +
    +def raise_response_errors(self, response) +
    +
    +
    +
    + +Expand source code + +
    def raise_response_errors(self, response):
    +    try:
    +        return super().raise_response_errors(response)
    +    except (ErrorInternalServerTransientError, ErrorServerBusy) as e:
    +        # Pass on the retry header value
    +        retry_after = _get_retry_after(response)
    +        if retry_after:
    +            raise ErrorServerBusy(e.args[0], back_off=retry_after)
    +        raise
    +
    +
    +

    Inherited members

    @@ -7077,10 +6990,14 @@

    Inherited members

    # # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. - if folder.name: + if folder.name and folder.folder_class: with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative - folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) + folder_cls = root.folder_cls_from_folder_name( + folder_name=folder.name, + folder_class=folder.folder_class, + locale=root.account.locale, + ) log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) if folder.folder_class and folder_cls == Folder: with suppress(KeyError): @@ -7186,10 +7103,14 @@

    Static methods

    # # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. - if folder.name: + if folder.name and folder.folder_class: with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative - folder_cls = root.folder_cls_from_folder_name(folder_name=folder.name, locale=root.account.locale) + folder_cls = root.folder_cls_from_folder_name( + folder_name=folder.name, + folder_class=folder.folder_class, + locale=root.account.locale, + ) log.debug("Folder class %s matches localized folder name %s", folder_cls, folder.name) if folder.folder_class and folder_cls == Folder: with suppress(KeyError): @@ -9173,7 +9094,7 @@

    Inherited members

    ): # Sends Message and saves a copy in the parent folder. Does not return an ItemId. if self.id: - self._update( + return self._update( update_fieldnames=update_fields, message_disposition=SEND_AND_SAVE_COPY, conflict_resolution=conflict_resolution, @@ -9188,16 +9109,18 @@

    Inherited members

    conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) - self.send( + return self.send( save_copy=False, conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) else: - self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations) + return self._create( + message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations + ) @require_id - def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): + def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None): if to_recipients is None: if not self.author: raise ValueError("'to_recipients' must be set when message has no 'author'") @@ -9210,13 +9133,14 @@

    Inherited members

    to_recipients=to_recipients, cc_recipients=cc_recipients, bcc_recipients=bcc_recipients, + author=author, ) - def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): - self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients).send() + def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None): + return self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients, author).send() @require_id - def create_reply_all(self, subject, body): + def create_reply_all(self, subject, body, author=None): to_recipients = list(self.to_recipients) if self.to_recipients else [] if self.author: to_recipients.append(self.author) @@ -9228,10 +9152,11 @@

    Inherited members

    to_recipients=to_recipients, cc_recipients=self.cc_recipients, bcc_recipients=self.bcc_recipients, + author=author, ) - def reply_all(self, subject, body): - self.create_reply_all(subject, body).send() + def reply_all(self, subject, body, author=None): + return self.create_reply_all(subject, body, author).send() def mark_as_junk(self, is_junk=True, move_item=True): """Mark or un-marks items as junk email. @@ -9344,7 +9269,7 @@

    Instance variables

    Methods

    -def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None) +def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None)
    @@ -9353,7 +9278,7 @@

    Methods

    Expand source code
    @require_id
    -def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None):
    +def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None):
         if to_recipients is None:
             if not self.author:
                 raise ValueError("'to_recipients' must be set when message has no 'author'")
    @@ -9366,11 +9291,12 @@ 

    Methods

    to_recipients=to_recipients, cc_recipients=cc_recipients, bcc_recipients=bcc_recipients, + author=author, )
    -def create_reply_all(self, subject, body) +def create_reply_all(self, subject, body, author=None)
    @@ -9379,7 +9305,7 @@

    Methods

    Expand source code
    @require_id
    -def create_reply_all(self, subject, body):
    +def create_reply_all(self, subject, body, author=None):
         to_recipients = list(self.to_recipients) if self.to_recipients else []
         if self.author:
             to_recipients.append(self.author)
    @@ -9391,6 +9317,7 @@ 

    Methods

    to_recipients=to_recipients, cc_recipients=self.cc_recipients, bcc_recipients=self.bcc_recipients, + author=author, )
    @@ -9427,7 +9354,7 @@

    Methods

    -def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None) +def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None)
    @@ -9435,12 +9362,12 @@

    Methods

    Expand source code -
    def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None):
    -    self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients).send()
    +
    def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None):
    +    return self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients, author).send()
    -def reply_all(self, subject, body) +def reply_all(self, subject, body, author=None)
    @@ -9448,8 +9375,8 @@

    Methods

    Expand source code -
    def reply_all(self, subject, body):
    -    self.create_reply_all(subject, body).send()
    +
    def reply_all(self, subject, body, author=None):
    +    return self.create_reply_all(subject, body, author).send()
    @@ -9518,7 +9445,7 @@

    Methods

    ): # Sends Message and saves a copy in the parent folder. Does not return an ItemId. if self.id: - self._update( + return self._update( update_fieldnames=update_fields, message_disposition=SEND_AND_SAVE_COPY, conflict_resolution=conflict_resolution, @@ -9533,13 +9460,15 @@

    Methods

    conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) - self.send( + return self.send( save_copy=False, conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) else: - self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations)
    + return self._create( + message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations + )
    @@ -11524,8 +11453,6 @@

    Inherited members

    # 'RootOfHierarchy' subclasses must not be in this list. WELLKNOWN_FOLDERS = [] - _subfolders_lock = Lock() - # This folder type also has 'folder:PermissionSet' on some server versions, but requesting it sometimes causes # 'ErrorAccessDenied', as reported by some users. Ignore it entirely for root folders - it's usefulness is # deemed minimal at best. @@ -11533,13 +11460,14 @@

    Inherited members

    field_uri="folder:EffectiveRights", is_read_only=True, supported_from=EXCHANGE_2007_SP1 ) - __slots__ = "_account", "_subfolders" + __slots__ = "_account", "_subfolders", "_subfolders_lock" # A special folder that acts as the top of a folder hierarchy. Finds and caches sub-folders at arbitrary depth. def __init__(self, **kwargs): self._account = kwargs.pop("account", None) # A pointer back to the account holding the folder hierarchy super().__init__(**kwargs) self._subfolders = None # See self._folders_map() + self._subfolders_lock = Lock() @property def account(self): @@ -11694,17 +11622,32 @@

    Inherited members

    return cls(account=account, **kwargs) @classmethod - def folder_cls_from_folder_name(cls, folder_name, locale): - """Return the folder class that matches a localized folder name. + def folder_cls_from_folder_name(cls, folder_name, folder_class, locale): + """Return the folder class that matches a localized folder name. Take into account the 'folder_class' of the + folder, to not identify an 'IPF.Note' folder as a 'Calendar' class just because it's called e.g. 'Kalender' and + the locale is 'da_DK'. :param folder_name: + :param folder_class: :param locale: a string, e.g. 'da_DK' """ for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS + MISC_FOLDERS: - if folder_name.lower() in folder_cls.localized_names(locale): + if folder_cls.CONTAINER_CLASS == folder_class and folder_name.lower() in folder_cls.localized_names(locale): return folder_cls raise KeyError() + def __getstate__(self): + # The lock cannot be pickled + state = {k: getattr(self, k) for k in self._slots_keys} + del state["_subfolders_lock"] + return state + + def __setstate__(self, state): + # Restore the lock + for k in self._slots_keys: + setattr(self, k, state.get(k)) + self._subfolders_lock = Lock() + def __repr__(self): # Let's not create an infinite loop when printing self.root return self.__class__.__name__ + repr( @@ -11750,25 +11693,31 @@

    Class variables

    Static methods

    -def folder_cls_from_folder_name(folder_name, locale) +def folder_cls_from_folder_name(folder_name, folder_class, locale)
    -

    Return the folder class that matches a localized folder name.

    +

    Return the folder class that matches a localized folder name. Take into account the 'folder_class' of the +folder, to not identify an 'IPF.Note' folder as a 'Calendar' class just because it's called e.g. 'Kalender' and +the locale is 'da_DK'.

    :param folder_name: +:param folder_class: :param locale: a string, e.g. 'da_DK'

    Expand source code
    @classmethod
    -def folder_cls_from_folder_name(cls, folder_name, locale):
    -    """Return the folder class that matches a localized folder name.
    +def folder_cls_from_folder_name(cls, folder_name, folder_class, locale):
    +    """Return the folder class that matches a localized folder name. Take into account the 'folder_class' of the
    +    folder, to not identify an 'IPF.Note' folder as a 'Calendar' class just because it's called e.g. 'Kalender' and
    +    the locale is 'da_DK'.
     
         :param folder_name:
    +    :param folder_class:
         :param locale: a string, e.g. 'da_DK'
         """
         for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS + MISC_FOLDERS:
    -        if folder_name.lower() in folder_cls.localized_names(locale):
    +        if folder_cls.CONTAINER_CLASS == folder_class and folder_name.lower() in folder_cls.localized_names(locale):
                 return folder_cls
         raise KeyError()
    @@ -12869,7 +12818,6 @@

    Accep
  • Account

      -
    • DEFAULT_DISCOVERY_CLS
    • admin_audit_logs
    • archive_deleted_items
    • archive_inbox
    • @@ -12948,6 +12896,7 @@

      B
    • CONNECTIONS_PER_SESSION
    • HTTP_ADAPTER_CLS
    • MAX_SESSION_USAGE_COUNT
    • +
    • RETRY_WAIT
    • SESSION_POOLSIZE
    • TIMEOUT
    • USERAGENT
    • @@ -12984,7 +12933,6 @@

      BodyBuild

    • diff --git a/docs/exchangelib/items/index.html b/docs/exchangelib/items/index.html index 1ffbcb5b..d140a52d 100644 --- a/docs/exchangelib/items/index.html +++ b/docs/exchangelib/items/index.html @@ -2045,7 +2045,7 @@

      Inherited members

      ) def forward(self, subject, body, to_recipients, cc_recipients=None, bcc_recipients=None): - self.create_forward( + return self.create_forward( subject, body, to_recipients, @@ -2406,7 +2406,7 @@

      Methods

      Expand source code
      def forward(self, subject, body, to_recipients, cc_recipients=None, bcc_recipients=None):
      -    self.create_forward(
      +    return self.create_forward(
               subject,
               body,
               to_recipients,
      @@ -3076,7 +3076,7 @@ 

      Inherited members

      ): # Sends Message and saves a copy in the parent folder. Does not return an ItemId. if self.id: - self._update( + return self._update( update_fieldnames=update_fields, message_disposition=SEND_AND_SAVE_COPY, conflict_resolution=conflict_resolution, @@ -3091,16 +3091,18 @@

      Inherited members

      conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) - self.send( + return self.send( save_copy=False, conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) else: - self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations) + return self._create( + message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations + ) @require_id - def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): + def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None): if to_recipients is None: if not self.author: raise ValueError("'to_recipients' must be set when message has no 'author'") @@ -3113,13 +3115,14 @@

      Inherited members

      to_recipients=to_recipients, cc_recipients=cc_recipients, bcc_recipients=bcc_recipients, + author=author, ) - def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): - self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients).send() + def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None): + return self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients, author).send() @require_id - def create_reply_all(self, subject, body): + def create_reply_all(self, subject, body, author=None): to_recipients = list(self.to_recipients) if self.to_recipients else [] if self.author: to_recipients.append(self.author) @@ -3131,10 +3134,11 @@

      Inherited members

      to_recipients=to_recipients, cc_recipients=self.cc_recipients, bcc_recipients=self.bcc_recipients, + author=author, ) - def reply_all(self, subject, body): - self.create_reply_all(subject, body).send() + def reply_all(self, subject, body, author=None): + return self.create_reply_all(subject, body, author).send() def mark_as_junk(self, is_junk=True, move_item=True): """Mark or un-marks items as junk email. @@ -3247,7 +3251,7 @@

      Instance variables

      Methods

      -def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None) +def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None)
      @@ -3256,7 +3260,7 @@

      Methods

      Expand source code
      @require_id
      -def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None):
      +def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None):
           if to_recipients is None:
               if not self.author:
                   raise ValueError("'to_recipients' must be set when message has no 'author'")
      @@ -3269,11 +3273,12 @@ 

      Methods

      to_recipients=to_recipients, cc_recipients=cc_recipients, bcc_recipients=bcc_recipients, + author=author, )
      -def create_reply_all(self, subject, body) +def create_reply_all(self, subject, body, author=None)
      @@ -3282,7 +3287,7 @@

      Methods

      Expand source code
      @require_id
      -def create_reply_all(self, subject, body):
      +def create_reply_all(self, subject, body, author=None):
           to_recipients = list(self.to_recipients) if self.to_recipients else []
           if self.author:
               to_recipients.append(self.author)
      @@ -3294,6 +3299,7 @@ 

      Methods

      to_recipients=to_recipients, cc_recipients=self.cc_recipients, bcc_recipients=self.bcc_recipients, + author=author, )
      @@ -3330,7 +3336,7 @@

      Methods

  • -def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None) +def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None)
    @@ -3338,12 +3344,12 @@

    Methods

    Expand source code -
    def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None):
    -    self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients).send()
    +
    def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None):
    +    return self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients, author).send()
    -def reply_all(self, subject, body) +def reply_all(self, subject, body, author=None)
    @@ -3351,8 +3357,8 @@

    Methods

    Expand source code -
    def reply_all(self, subject, body):
    -    self.create_reply_all(subject, body).send()
    +
    def reply_all(self, subject, body, author=None):
    +    return self.create_reply_all(subject, body, author).send()
    @@ -3421,7 +3427,7 @@

    Methods

    ): # Sends Message and saves a copy in the parent folder. Does not return an ItemId. if self.id: - self._update( + return self._update( update_fieldnames=update_fields, message_disposition=SEND_AND_SAVE_COPY, conflict_resolution=conflict_resolution, @@ -3436,13 +3442,15 @@

    Methods

    conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) - self.send( + return self.send( save_copy=False, conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) else: - self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations)
    + return self._create( + message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations + )
    diff --git a/docs/exchangelib/items/item.html b/docs/exchangelib/items/item.html index 8bc52663..a3f914a9 100644 --- a/docs/exchangelib/items/item.html +++ b/docs/exchangelib/items/item.html @@ -435,7 +435,7 @@

    Module exchangelib.items.item

    ) def forward(self, subject, body, to_recipients, cc_recipients=None, bcc_recipients=None): - self.create_forward( + return self.create_forward( subject, body, to_recipients, @@ -829,7 +829,7 @@

    Classes

    ) def forward(self, subject, body, to_recipients, cc_recipients=None, bcc_recipients=None): - self.create_forward( + return self.create_forward( subject, body, to_recipients, @@ -1190,7 +1190,7 @@

    Methods

    Expand source code
    def forward(self, subject, body, to_recipients, cc_recipients=None, bcc_recipients=None):
    -    self.create_forward(
    +    return self.create_forward(
             subject,
             body,
             to_recipients,
    @@ -1462,4 +1462,4 @@ 

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/items/message.html b/docs/exchangelib/items/message.html index 90ce0949..e9785010 100644 --- a/docs/exchangelib/items/message.html +++ b/docs/exchangelib/items/message.html @@ -126,7 +126,7 @@

    Module exchangelib.items.message

    ): # Sends Message and saves a copy in the parent folder. Does not return an ItemId. if self.id: - self._update( + return self._update( update_fieldnames=update_fields, message_disposition=SEND_AND_SAVE_COPY, conflict_resolution=conflict_resolution, @@ -141,16 +141,18 @@

    Module exchangelib.items.message

    conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) - self.send( + return self.send( save_copy=False, conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) else: - self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations) + return self._create( + message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations + ) @require_id - def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): + def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None): if to_recipients is None: if not self.author: raise ValueError("'to_recipients' must be set when message has no 'author'") @@ -163,13 +165,14 @@

    Module exchangelib.items.message

    to_recipients=to_recipients, cc_recipients=cc_recipients, bcc_recipients=bcc_recipients, + author=author, ) - def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): - self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients).send() + def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None): + return self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients, author).send() @require_id - def create_reply_all(self, subject, body): + def create_reply_all(self, subject, body, author=None): to_recipients = list(self.to_recipients) if self.to_recipients else [] if self.author: to_recipients.append(self.author) @@ -181,10 +184,11 @@

    Module exchangelib.items.message

    to_recipients=to_recipients, cc_recipients=self.cc_recipients, bcc_recipients=self.bcc_recipients, + author=author, ) - def reply_all(self, subject, body): - self.create_reply_all(subject, body).send() + def reply_all(self, subject, body, author=None): + return self.create_reply_all(subject, body, author).send() def mark_as_junk(self, is_junk=True, move_item=True): """Mark or un-marks items as junk email. @@ -377,7 +381,7 @@

    Inherited members

    ): # Sends Message and saves a copy in the parent folder. Does not return an ItemId. if self.id: - self._update( + return self._update( update_fieldnames=update_fields, message_disposition=SEND_AND_SAVE_COPY, conflict_resolution=conflict_resolution, @@ -392,16 +396,18 @@

    Inherited members

    conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) - self.send( + return self.send( save_copy=False, conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) else: - self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations) + return self._create( + message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations + ) @require_id - def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): + def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None): if to_recipients is None: if not self.author: raise ValueError("'to_recipients' must be set when message has no 'author'") @@ -414,13 +420,14 @@

    Inherited members

    to_recipients=to_recipients, cc_recipients=cc_recipients, bcc_recipients=bcc_recipients, + author=author, ) - def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None): - self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients).send() + def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None): + return self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients, author).send() @require_id - def create_reply_all(self, subject, body): + def create_reply_all(self, subject, body, author=None): to_recipients = list(self.to_recipients) if self.to_recipients else [] if self.author: to_recipients.append(self.author) @@ -432,10 +439,11 @@

    Inherited members

    to_recipients=to_recipients, cc_recipients=self.cc_recipients, bcc_recipients=self.bcc_recipients, + author=author, ) - def reply_all(self, subject, body): - self.create_reply_all(subject, body).send() + def reply_all(self, subject, body, author=None): + return self.create_reply_all(subject, body, author).send() def mark_as_junk(self, is_junk=True, move_item=True): """Mark or un-marks items as junk email. @@ -548,7 +556,7 @@

    Instance variables

    Methods

    -def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None) +def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None)
    @@ -557,7 +565,7 @@

    Methods

    Expand source code
    @require_id
    -def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None):
    +def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None):
         if to_recipients is None:
             if not self.author:
                 raise ValueError("'to_recipients' must be set when message has no 'author'")
    @@ -570,11 +578,12 @@ 

    Methods

    to_recipients=to_recipients, cc_recipients=cc_recipients, bcc_recipients=bcc_recipients, + author=author, )
    -def create_reply_all(self, subject, body) +def create_reply_all(self, subject, body, author=None)
    @@ -583,7 +592,7 @@

    Methods

    Expand source code
    @require_id
    -def create_reply_all(self, subject, body):
    +def create_reply_all(self, subject, body, author=None):
         to_recipients = list(self.to_recipients) if self.to_recipients else []
         if self.author:
             to_recipients.append(self.author)
    @@ -595,6 +604,7 @@ 

    Methods

    to_recipients=to_recipients, cc_recipients=self.cc_recipients, bcc_recipients=self.bcc_recipients, + author=author, )
    @@ -631,7 +641,7 @@

    Methods

    -def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None) +def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None)
    @@ -639,12 +649,12 @@

    Methods

    Expand source code -
    def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None):
    -    self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients).send()
    +
    def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None):
    +    return self.create_reply(subject, body, to_recipients, cc_recipients, bcc_recipients, author).send()
    -def reply_all(self, subject, body) +def reply_all(self, subject, body, author=None)
    @@ -652,8 +662,8 @@

    Methods

    Expand source code -
    def reply_all(self, subject, body):
    -    self.create_reply_all(subject, body).send()
    +
    def reply_all(self, subject, body, author=None):
    +    return self.create_reply_all(subject, body, author).send()
    @@ -722,7 +732,7 @@

    Methods

    ): # Sends Message and saves a copy in the parent folder. Does not return an ItemId. if self.id: - self._update( + return self._update( update_fieldnames=update_fields, message_disposition=SEND_AND_SAVE_COPY, conflict_resolution=conflict_resolution, @@ -737,13 +747,15 @@

    Methods

    conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) - self.send( + return self.send( save_copy=False, conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) else: - self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations)

    + return self._create( + message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations + ) diff --git a/docs/exchangelib/properties.html b/docs/exchangelib/properties.html index 7f8e4efb..647a6129 100644 --- a/docs/exchangelib/properties.html +++ b/docs/exchangelib/properties.html @@ -39,6 +39,7 @@

    Module exchangelib.properties

    AutoDiscoverFailed, ErrorInternalServerError, ErrorNonExistentMailbox, + ErrorOrganizationNotFederated, ErrorServerBusy, InvalidTypeError, ) @@ -2105,12 +2106,6 @@

    Module exchangelib.properties

    return version raise ValueError(f"Unknown supported schemas: {supported_schemas}") - @staticmethod - def _is_url(s): - if not s: - return False - return s.startswith("http://") or s.startswith("https://") - def raise_errors(self): if self.error_code == "InvalidUser": raise ErrorNonExistentMailbox(self.error_message) @@ -2135,6 +2130,8 @@

    Module exchangelib.properties

    raise ErrorInternalServerError(error_message) if error_code == "ServerBusy": raise ErrorServerBusy(error_message) + if error_code == "NotFederated": + raise ErrorOrganizationNotFederated(error_message) if error_code not in ("NoError", "RedirectAddress", "RedirectUrl"): return cls(error_code=error_code, error_message=error_message) @@ -2861,6 +2858,7 @@

    Ancestors

    Subclasses

      +
    • AttachmentId
    • ItemId
    • OccurrenceItemId
    • RecurringMasterItemId
    • @@ -4442,11 +4440,6 @@

      Subclasses

      • Identity
      • Attachment
      • -
      • AttachmentId
      • -
      • Autodiscover
      • -
      • AutodiscoverBase
      • -
      • Error
      • -
      • ErrorResponse
      • ExtendedProperty
      • IndexedElement
      • BaseReplyItem
      • @@ -10501,12 +10494,6 @@

        Inherited members

        return version raise ValueError(f"Unknown supported schemas: {supported_schemas}") - @staticmethod - def _is_url(s): - if not s: - return False - return s.startswith("http://") or s.startswith("https://") - def raise_errors(self): if self.error_code == "InvalidUser": raise ErrorNonExistentMailbox(self.error_message) @@ -10531,6 +10518,8 @@

        Inherited members

        raise ErrorInternalServerError(error_message) if error_code == "ServerBusy": raise ErrorServerBusy(error_message) + if error_code == "NotFederated": + raise ErrorOrganizationNotFederated(error_message) if error_code not in ("NoError", "RedirectAddress", "RedirectUrl"): return cls(error_code=error_code, error_message=error_message) @@ -10603,6 +10592,8 @@

        Static methods

        raise ErrorInternalServerError(error_message) if error_code == "ServerBusy": raise ErrorServerBusy(error_message) + if error_code == "NotFederated": + raise ErrorOrganizationNotFederated(error_message) if error_code not in ("NoError", "RedirectAddress", "RedirectUrl"): return cls(error_code=error_code, error_message=error_message) diff --git a/docs/exchangelib/protocol.html b/docs/exchangelib/protocol.html index 77f0066e..989eb8aa 100644 --- a/docs/exchangelib/protocol.html +++ b/docs/exchangelib/protocol.html @@ -51,10 +51,14 @@

        Module exchangelib.protocol

        from .credentials import BaseOAuth2Credentials from .errors import ( CASError, + ErrorInternalServerTransientError, ErrorInvalidSchemaVersionForMailboxVersion, + ErrorServerBusy, InvalidTypeError, MalformedResponseError, RateLimitError, + RedirectError, + RelativeRedirect, SessionPoolMaxSizeReached, SessionPoolMinSizeReached, TransportError, @@ -82,6 +86,7 @@

        Module exchangelib.protocol

        ResolveNames, ) from .transport import CREDENTIALS_REQUIRED, DEFAULT_HEADERS, NTLM, OAUTH2, get_auth_instance, get_service_authtype +from .util import _get_retry_after, get_redirect_url, is_xml from .version import Version log = logging.getLogger(__name__) @@ -109,6 +114,7 @@

        Module exchangelib.protocol

        MAX_SESSION_USAGE_COUNT = None # Timeout for HTTP requests TIMEOUT = 120 + RETRY_WAIT = 10 # Seconds to wait before retry on connection errors # The adapter class to use for HTTP requests. Override this if you need e.g. proxy support or specific TLS versions HTTP_ADAPTER_CLS = requests.adapters.HTTPAdapter @@ -687,11 +693,14 @@

        Module exchangelib.protocol

        def back_off(self, seconds): """Set a new back off until value""" - @abc.abstractmethod - def may_retry_on_error(self, response, wait): - """Return whether retries should still be attempted""" - def raise_response_errors(self, response): + if response.status_code == 200: + # Response is OK + return + if response.status_code == 500 and response.content and is_xml(response.content): + # Some genius at Microsoft thinks it's OK to send a valid SOAP response as an HTTP 500 + log.debug("Got status code %s but trying to parse content anyway", response.status_code) + return cas_error = response.headers.get("X-CasErrorCode") if cas_error: if cas_error.startswith("CAS error:"): @@ -704,14 +713,41 @@

        Module exchangelib.protocol

        ): # Another way of communicating invalid schema versions raise ErrorInvalidSchemaVersionForMailboxVersion("Invalid server version") + if response.headers.get("connection") == "close": + # Connection closed. OK to retry. + raise ErrorServerBusy("Caused by closed connection") + if ( + response.status_code == 302 + and response.headers.get("location", "").lower() + == "/ews/genericerrorpage.htm?aspxerrorpath=/ews/exchange.asmx" + ): + # Redirect to genericerrorpage.htm is ridiculous behaviour for random outages. OK to retry. + # + # Redirect to '/internalsite/internalerror.asp' or '/internalsite/initparams.aspx' is caused by e.g. TLS + # certificate f*ckups on the Exchange server. We should not retry those. + raise ErrorInternalServerTransientError(f"Caused by HTTP 302 redirect to {response.headers['location']}") + if response.status_code in (301, 302): + try: + redirect_url = get_redirect_url(response=response, allow_relative=False) + except RelativeRedirect as e: + log.debug("Redirect not allowed but we were relative redirected (%s -> %s)", response.url, e.value) + raise RedirectError(url=e.value) + log.debug("Redirect not allowed but we were redirected ( (%s -> %s)", response.url, redirect_url) + raise RedirectError(url=redirect_url) if b"The referenced account is currently locked out" in response.content: raise UnauthorizedError("The referenced account is currently locked out") if response.status_code == 401 and self.fail_fast: # This is a login failure raise UnauthorizedError(f"Invalid credentials for {response.url}") - if "TimeoutException" in response.headers: - # A header set by us on CONNECTION_ERRORS - raise response.headers["TimeoutException"] + if response.status_code == 401: + # EWS sometimes throws 401's when it wants us to throttle connections. OK to retry. + raise ErrorServerBusy("Caused by HTTP 401 response") + if response.status_code == 500 and b"Server Error in '/EWS' Application" in response.content: + # "Server Error in '/EWS' Application" has been seen in highly concurrent settings. OK to retry. + raise ErrorInternalServerTransientError("Caused by \"Server Error in 'EWS' Application\"") + if response.status_code == 503: + # Internal server error. OK to retry. + raise ErrorInternalServerTransientError("Caused by HTTP 503 response") # This could be anything. Let higher layers handle this raise MalformedResponseError( f"Unknown failure in response. Code: {response.status_code} headers: {response.headers} " @@ -733,10 +769,6 @@

        Module exchangelib.protocol

        def back_off(self, seconds): raise ValueError("Cannot back off with fail-fast policy") - def may_retry_on_error(self, response, wait): - log.debug("No retry with fail-fast policy") - return False - class FaultTolerance(RetryPolicy): """Enables fault-tolerant error handling. Tells internal methods to do an exponential back off when requests start @@ -787,44 +819,22 @@

        Module exchangelib.protocol

        def back_off(self, seconds): if seconds is None: seconds = self.DEFAULT_BACKOFF + if seconds > self.max_wait: + # We lost patience. Session is cleaned up in outer loop + raise RateLimitError("Max timeout reached", wait=seconds) value = datetime.datetime.now() + datetime.timedelta(seconds=seconds) with self._back_off_lock: self._back_off_until = value - def may_retry_on_error(self, response, wait): - if response.status_code not in (301, 302, 401, 500, 503): - # Don't retry if we didn't get a status code that we can hope to recover from - log.debug("No retry: wrong status code %s", response.status_code) - return False - if wait > self.max_wait: - # We lost patience. Session is cleaned up in outer loop - raise RateLimitError( - "Max timeout reached", url=response.url, status_code=response.status_code, total_wait=wait - ) - if response.status_code == 401: - # EWS sometimes throws 401's when it wants us to throttle connections. OK to retry. - return True - if response.headers.get("connection") == "close": - # Connection closed. OK to retry. - return True - if ( - response.status_code == 302 - and response.headers.get("location", "").lower() - == "/ews/genericerrorpage.htm?aspxerrorpath=/ews/exchange.asmx" - ): - # The genericerrorpage.htm/internalerror.asp is ridiculous behaviour for random outages. OK to retry. - # - # Redirect to '/internalsite/internalerror.asp' or '/internalsite/initparams.aspx' is caused by e.g. TLS - # certificate f*ckups on the Exchange server. We should not retry those. - return True - if response.status_code == 503: - # Internal server error. OK to retry. - return True - if response.status_code == 500 and b"Server Error in '/EWS' Application" in response.content: - # "Server Error in '/EWS' Application" has been seen in highly concurrent settings. OK to retry. - log.debug("Retry allowed: conditions met") - return True - return False
        + def raise_response_errors(self, response): + try: + return super().raise_response_errors(response) + except (ErrorInternalServerTransientError, ErrorServerBusy) as e: + # Pass on the retry header value + retry_after = _get_retry_after(response) + if retry_after: + raise ErrorServerBusy(e.args[0], back_off=retry_after) + raise
        @@ -880,6 +890,7 @@

        Classes

        MAX_SESSION_USAGE_COUNT = None # Timeout for HTTP requests TIMEOUT = 120 + RETRY_WAIT = 10 # Seconds to wait before retry on connection errors # The adapter class to use for HTTP requests. Override this if you need e.g. proxy support or specific TLS versions HTTP_ADAPTER_CLS = requests.adapters.HTTPAdapter @@ -1200,6 +1211,10 @@

        Class variables

        +
        var RETRY_WAIT
        +
        +
        +
        var SESSION_POOLSIZE
        @@ -1780,11 +1795,7 @@

        Static methods

        return None def back_off(self, seconds): - raise ValueError("Cannot back off with fail-fast policy") - - def may_retry_on_error(self, response, wait): - log.debug("No retry with fail-fast policy") - return False
        + raise ValueError("Cannot back off with fail-fast policy")

        Ancestors

      @@ -1862,44 +1872,22 @@

      Inherited members

      def back_off(self, seconds): if seconds is None: seconds = self.DEFAULT_BACKOFF + if seconds > self.max_wait: + # We lost patience. Session is cleaned up in outer loop + raise RateLimitError("Max timeout reached", wait=seconds) value = datetime.datetime.now() + datetime.timedelta(seconds=seconds) with self._back_off_lock: self._back_off_until = value - def may_retry_on_error(self, response, wait): - if response.status_code not in (301, 302, 401, 500, 503): - # Don't retry if we didn't get a status code that we can hope to recover from - log.debug("No retry: wrong status code %s", response.status_code) - return False - if wait > self.max_wait: - # We lost patience. Session is cleaned up in outer loop - raise RateLimitError( - "Max timeout reached", url=response.url, status_code=response.status_code, total_wait=wait - ) - if response.status_code == 401: - # EWS sometimes throws 401's when it wants us to throttle connections. OK to retry. - return True - if response.headers.get("connection") == "close": - # Connection closed. OK to retry. - return True - if ( - response.status_code == 302 - and response.headers.get("location", "").lower() - == "/ews/genericerrorpage.htm?aspxerrorpath=/ews/exchange.asmx" - ): - # The genericerrorpage.htm/internalerror.asp is ridiculous behaviour for random outages. OK to retry. - # - # Redirect to '/internalsite/internalerror.asp' or '/internalsite/initparams.aspx' is caused by e.g. TLS - # certificate f*ckups on the Exchange server. We should not retry those. - return True - if response.status_code == 503: - # Internal server error. OK to retry. - return True - if response.status_code == 500 and b"Server Error in '/EWS' Application" in response.content: - # "Server Error in '/EWS' Application" has been seen in highly concurrent settings. OK to retry. - log.debug("Retry allowed: conditions met") - return True - return False + def raise_response_errors(self, response): + try: + return super().raise_response_errors(response) + except (ErrorInternalServerTransientError, ErrorServerBusy) as e: + # Pass on the retry header value + retry_after = _get_retry_after(response) + if retry_after: + raise ErrorServerBusy(e.args[0], back_off=retry_after) + raise

      Ancestors

        @@ -1936,13 +1924,35 @@

        Instance variables

        +

        Methods

        +
        +
        +def raise_response_errors(self, response) +
        +
        +
        +
        + +Expand source code + +
        def raise_response_errors(self, response):
        +    try:
        +        return super().raise_response_errors(response)
        +    except (ErrorInternalServerTransientError, ErrorServerBusy) as e:
        +        # Pass on the retry header value
        +        retry_after = _get_retry_after(response)
        +        if retry_after:
        +            raise ErrorServerBusy(e.args[0], back_off=retry_after)
        +        raise
        +
        +
        +

        Inherited members

        @@ -2500,11 +2510,14 @@

        Inherited members

        def back_off(self, seconds): """Set a new back off until value""" - @abc.abstractmethod - def may_retry_on_error(self, response, wait): - """Return whether retries should still be attempted""" - def raise_response_errors(self, response): + if response.status_code == 200: + # Response is OK + return + if response.status_code == 500 and response.content and is_xml(response.content): + # Some genius at Microsoft thinks it's OK to send a valid SOAP response as an HTTP 500 + log.debug("Got status code %s but trying to parse content anyway", response.status_code) + return cas_error = response.headers.get("X-CasErrorCode") if cas_error: if cas_error.startswith("CAS error:"): @@ -2517,14 +2530,41 @@

        Inherited members

        ): # Another way of communicating invalid schema versions raise ErrorInvalidSchemaVersionForMailboxVersion("Invalid server version") + if response.headers.get("connection") == "close": + # Connection closed. OK to retry. + raise ErrorServerBusy("Caused by closed connection") + if ( + response.status_code == 302 + and response.headers.get("location", "").lower() + == "/ews/genericerrorpage.htm?aspxerrorpath=/ews/exchange.asmx" + ): + # Redirect to genericerrorpage.htm is ridiculous behaviour for random outages. OK to retry. + # + # Redirect to '/internalsite/internalerror.asp' or '/internalsite/initparams.aspx' is caused by e.g. TLS + # certificate f*ckups on the Exchange server. We should not retry those. + raise ErrorInternalServerTransientError(f"Caused by HTTP 302 redirect to {response.headers['location']}") + if response.status_code in (301, 302): + try: + redirect_url = get_redirect_url(response=response, allow_relative=False) + except RelativeRedirect as e: + log.debug("Redirect not allowed but we were relative redirected (%s -> %s)", response.url, e.value) + raise RedirectError(url=e.value) + log.debug("Redirect not allowed but we were redirected ( (%s -> %s)", response.url, redirect_url) + raise RedirectError(url=redirect_url) if b"The referenced account is currently locked out" in response.content: raise UnauthorizedError("The referenced account is currently locked out") if response.status_code == 401 and self.fail_fast: # This is a login failure raise UnauthorizedError(f"Invalid credentials for {response.url}") - if "TimeoutException" in response.headers: - # A header set by us on CONNECTION_ERRORS - raise response.headers["TimeoutException"] + if response.status_code == 401: + # EWS sometimes throws 401's when it wants us to throttle connections. OK to retry. + raise ErrorServerBusy("Caused by HTTP 401 response") + if response.status_code == 500 and b"Server Error in '/EWS' Application" in response.content: + # "Server Error in '/EWS' Application" has been seen in highly concurrent settings. OK to retry. + raise ErrorInternalServerTransientError("Caused by \"Server Error in 'EWS' Application\"") + if response.status_code == 503: + # Internal server error. OK to retry. + raise ErrorInternalServerTransientError("Caused by HTTP 503 response") # This could be anything. Let higher layers handle this raise MalformedResponseError( f"Unknown failure in response. Code: {response.status_code} headers: {response.headers} " @@ -2583,20 +2623,6 @@

        Methods

        """Set a new back off until value""" -
        -def may_retry_on_error(self, response, wait) -
        -
        -

        Return whether retries should still be attempted

        -
        - -Expand source code - -
        @abc.abstractmethod
        -def may_retry_on_error(self, response, wait):
        -    """Return whether retries should still be attempted"""
        -
        -
        def raise_response_errors(self, response)
        @@ -2607,6 +2633,13 @@

        Methods

        Expand source code
        def raise_response_errors(self, response):
        +    if response.status_code == 200:
        +        # Response is OK
        +        return
        +    if response.status_code == 500 and response.content and is_xml(response.content):
        +        # Some genius at Microsoft thinks it's OK to send a valid SOAP response as an HTTP 500
        +        log.debug("Got status code %s but trying to parse content anyway", response.status_code)
        +        return
             cas_error = response.headers.get("X-CasErrorCode")
             if cas_error:
                 if cas_error.startswith("CAS error:"):
        @@ -2619,14 +2652,41 @@ 

        Methods

        ): # Another way of communicating invalid schema versions raise ErrorInvalidSchemaVersionForMailboxVersion("Invalid server version") + if response.headers.get("connection") == "close": + # Connection closed. OK to retry. + raise ErrorServerBusy("Caused by closed connection") + if ( + response.status_code == 302 + and response.headers.get("location", "").lower() + == "/ews/genericerrorpage.htm?aspxerrorpath=/ews/exchange.asmx" + ): + # Redirect to genericerrorpage.htm is ridiculous behaviour for random outages. OK to retry. + # + # Redirect to '/internalsite/internalerror.asp' or '/internalsite/initparams.aspx' is caused by e.g. TLS + # certificate f*ckups on the Exchange server. We should not retry those. + raise ErrorInternalServerTransientError(f"Caused by HTTP 302 redirect to {response.headers['location']}") + if response.status_code in (301, 302): + try: + redirect_url = get_redirect_url(response=response, allow_relative=False) + except RelativeRedirect as e: + log.debug("Redirect not allowed but we were relative redirected (%s -> %s)", response.url, e.value) + raise RedirectError(url=e.value) + log.debug("Redirect not allowed but we were redirected ( (%s -> %s)", response.url, redirect_url) + raise RedirectError(url=redirect_url) if b"The referenced account is currently locked out" in response.content: raise UnauthorizedError("The referenced account is currently locked out") if response.status_code == 401 and self.fail_fast: # This is a login failure raise UnauthorizedError(f"Invalid credentials for {response.url}") - if "TimeoutException" in response.headers: - # A header set by us on CONNECTION_ERRORS - raise response.headers["TimeoutException"] + if response.status_code == 401: + # EWS sometimes throws 401's when it wants us to throttle connections. OK to retry. + raise ErrorServerBusy("Caused by HTTP 401 response") + if response.status_code == 500 and b"Server Error in '/EWS' Application" in response.content: + # "Server Error in '/EWS' Application" has been seen in highly concurrent settings. OK to retry. + raise ErrorInternalServerTransientError("Caused by \"Server Error in 'EWS' Application\"") + if response.status_code == 503: + # Internal server error. OK to retry. + raise ErrorInternalServerTransientError("Caused by HTTP 503 response") # This could be anything. Let higher layers handle this raise MalformedResponseError( f"Unknown failure in response. Code: {response.status_code} headers: {response.headers} " @@ -2719,6 +2779,7 @@

        CONNECTIONS_PER_SESSION
      • HTTP_ADAPTER_CLS
      • MAX_SESSION_USAGE_COUNT
      • +
      • RETRY_WAIT
      • SESSION_POOLSIZE
      • TIMEOUT
      • USERAGENT
      • @@ -2758,6 +2819,7 @@

      • DEFAULT_BACKOFF
      • back_off_until
      • +
      • raise_response_errors
    • @@ -2787,7 +2849,6 @@

      back_off

    • back_off_until
    • fail_fast
    • -
    • may_retry_on_error
    • raise_response_errors
    diff --git a/docs/exchangelib/services/common.html b/docs/exchangelib/services/common.html index d72b320e..65389118 100644 --- a/docs/exchangelib/services/common.html +++ b/docs/exchangelib/services/common.html @@ -31,6 +31,8 @@

    Module exchangelib.services.common

    from contextlib import suppress from itertools import chain +from oauthlib.oauth2 import TokenExpiredError + from .. import errors from ..attachments import AttachmentId from ..credentials import IMPERSONATION, BaseOAuth2Credentials @@ -326,30 +328,46 @@

    Module exchangelib.services.common

    """Send the payload to be sent and parsed. Handles and re-raise exceptions that are not meant to be returned to the caller as exception objects. Retry the request according to the retry policy. """ + wait = self.protocol.RETRY_WAIT while True: try: # Create a generator over the response elements so exceptions in response elements are also raised # here and can be handled. yield from self._response_generator(payload=payload) + # TODO: Restore session pool size on succeeding request? return + except TokenExpiredError: + # Retry immediately + continue except ErrorServerBusy as e: + if not e.back_off: + e.back_off = wait self._handle_backoff(e) - continue - except (ErrorTooManyObjectsOpened, ErrorTimeoutExpired, ErrorInternalServerTransientError) as e: + except ErrorExceededConnectionCount as e: + # ErrorExceededConnectionCount indicates that the connecting user has too many open TCP connections to + # the server. Decrease our session pool size and retry immediately. + try: + self.protocol.decrease_poolsize() + continue + except SessionPoolMinSizeReached: + # We're already as low as we can go. Let the user handle this. + raise e + except ErrorTimeoutExpired as e: + # ErrorTimeoutExpired can be caused by a busy server, or by an overly large request. If it's the latter, + # we don't want to continue hammering the server with this request indefinitely. Instead, lower the + # connection count, if possible, and retry the request. + if self.protocol.session_pool_size <= 1: + # We're already as low as we can go. We can no longer use the session count to put less load + # on the server. If this is a chunked request we could lower the chunk size, but we don't have a + # way of doing that from this part of the code yet. Let the user handle this. + raise e + self._handle_backoff(ErrorServerBusy(f"Reraised from {e.__class__.__name__}({e})", back_off=wait)) + except (ErrorTooManyObjectsOpened, ErrorInternalServerTransientError) as e: # ErrorTooManyObjectsOpened means there are too many connections to the Exchange database. This is very # often a symptom of sending too many requests. - # - # ErrorTimeoutExpired can be caused by a busy server, or by overly large requests. Start by lowering the - # session count. This is done by downstream code. - if isinstance(e, ErrorTimeoutExpired) and self.protocol.session_pool_size <= 1: - # We're already as low as we can go, so downstream cannot limit the session count to put less load - # on the server. We don't have a way of lowering the page size of requests from - # this part of the code yet. Let the user handle this. - raise e - - # Re-raise as an ErrorServerBusy with a default delay of 5 minutes - raise ErrorServerBusy(f"Reraised from {e.__class__.__name__}({e})") + self._handle_backoff(ErrorServerBusy(f"Reraised from {e.__class__.__name__}({e})", back_off=wait)) finally: + wait *= 2 # Increase delay for every retry if self.streaming: self.stop_streaming() @@ -434,15 +452,6 @@

    Module exchangelib.services.common

    # The guessed server version is wrong. Try the next version log.debug("API version %s was invalid", api_version) continue - except ErrorExceededConnectionCount as e: - # This indicates that the connecting user has too many open TCP connections to the server. Decrease - # our session pool size. - try: - self.protocol.decrease_poolsize() - continue - except SessionPoolMinSizeReached: - # We're already as low as we can go. Let the user handle this. - raise e finally: if not self.streaming: # In streaming mode, we may not have accessed the raw stream yet. Caller must handle this. @@ -538,11 +547,12 @@

    Module exchangelib.services.common

    fault_actor = get_xml_attr(fault, "faultactor") detail = fault.find("detail") if detail is not None: - code, msg = None, "" - if detail.find(f"{{{ENS}}}ResponseCode") is not None: - code = get_xml_attr(detail, f"{{{ENS}}}ResponseCode").strip() - if detail.find(f"{{{ENS}}}Message") is not None: - msg = get_xml_attr(detail, f"{{{ENS}}}Message").strip() + code = get_xml_attr(detail, f"{{{ENS}}}ResponseCode") + if code: + code = code.strip() + msg = get_xml_attr(detail, f"{{{ENS}}}Message") + if msg: + msg = msg.strip() msg_xml = detail.find(f"{{{TNS}}}MessageXml") # Crazy. Here, it's in the TNS namespace if code == "ErrorServerBusy": back_off = None @@ -1717,30 +1727,46 @@

    Inherited members

    """Send the payload to be sent and parsed. Handles and re-raise exceptions that are not meant to be returned to the caller as exception objects. Retry the request according to the retry policy. """ + wait = self.protocol.RETRY_WAIT while True: try: # Create a generator over the response elements so exceptions in response elements are also raised # here and can be handled. yield from self._response_generator(payload=payload) + # TODO: Restore session pool size on succeeding request? return + except TokenExpiredError: + # Retry immediately + continue except ErrorServerBusy as e: + if not e.back_off: + e.back_off = wait self._handle_backoff(e) - continue - except (ErrorTooManyObjectsOpened, ErrorTimeoutExpired, ErrorInternalServerTransientError) as e: + except ErrorExceededConnectionCount as e: + # ErrorExceededConnectionCount indicates that the connecting user has too many open TCP connections to + # the server. Decrease our session pool size and retry immediately. + try: + self.protocol.decrease_poolsize() + continue + except SessionPoolMinSizeReached: + # We're already as low as we can go. Let the user handle this. + raise e + except ErrorTimeoutExpired as e: + # ErrorTimeoutExpired can be caused by a busy server, or by an overly large request. If it's the latter, + # we don't want to continue hammering the server with this request indefinitely. Instead, lower the + # connection count, if possible, and retry the request. + if self.protocol.session_pool_size <= 1: + # We're already as low as we can go. We can no longer use the session count to put less load + # on the server. If this is a chunked request we could lower the chunk size, but we don't have a + # way of doing that from this part of the code yet. Let the user handle this. + raise e + self._handle_backoff(ErrorServerBusy(f"Reraised from {e.__class__.__name__}({e})", back_off=wait)) + except (ErrorTooManyObjectsOpened, ErrorInternalServerTransientError) as e: # ErrorTooManyObjectsOpened means there are too many connections to the Exchange database. This is very # often a symptom of sending too many requests. - # - # ErrorTimeoutExpired can be caused by a busy server, or by overly large requests. Start by lowering the - # session count. This is done by downstream code. - if isinstance(e, ErrorTimeoutExpired) and self.protocol.session_pool_size <= 1: - # We're already as low as we can go, so downstream cannot limit the session count to put less load - # on the server. We don't have a way of lowering the page size of requests from - # this part of the code yet. Let the user handle this. - raise e - - # Re-raise as an ErrorServerBusy with a default delay of 5 minutes - raise ErrorServerBusy(f"Reraised from {e.__class__.__name__}({e})") + self._handle_backoff(ErrorServerBusy(f"Reraised from {e.__class__.__name__}({e})", back_off=wait)) finally: + wait *= 2 # Increase delay for every retry if self.streaming: self.stop_streaming() @@ -1825,15 +1851,6 @@

    Inherited members

    # The guessed server version is wrong. Try the next version log.debug("API version %s was invalid", api_version) continue - except ErrorExceededConnectionCount as e: - # This indicates that the connecting user has too many open TCP connections to the server. Decrease - # our session pool size. - try: - self.protocol.decrease_poolsize() - continue - except SessionPoolMinSizeReached: - # We're already as low as we can go. Let the user handle this. - raise e finally: if not self.streaming: # In streaming mode, we may not have accessed the raw stream yet. Caller must handle this. @@ -1929,11 +1946,12 @@

    Inherited members

    fault_actor = get_xml_attr(fault, "faultactor") detail = fault.find("detail") if detail is not None: - code, msg = None, "" - if detail.find(f"{{{ENS}}}ResponseCode") is not None: - code = get_xml_attr(detail, f"{{{ENS}}}ResponseCode").strip() - if detail.find(f"{{{ENS}}}Message") is not None: - msg = get_xml_attr(detail, f"{{{ENS}}}Message").strip() + code = get_xml_attr(detail, f"{{{ENS}}}ResponseCode") + if code: + code = code.strip() + msg = get_xml_attr(detail, f"{{{ENS}}}Message") + if msg: + msg = msg.strip() msg_xml = detail.find(f"{{{TNS}}}MessageXml") # Crazy. Here, it's in the TNS namespace if code == "ErrorServerBusy": back_off = None diff --git a/docs/exchangelib/services/get_user_settings.html b/docs/exchangelib/services/get_user_settings.html index c121d565..0c2f933c 100644 --- a/docs/exchangelib/services/get_user_settings.html +++ b/docs/exchangelib/services/get_user_settings.html @@ -28,7 +28,7 @@

    Module exchangelib.services.get_user_settings

    import logging
     
    -from ..errors import ErrorInvalidServerVersion, MalformedResponseError
    +from ..errors import ErrorInternalServerError, ErrorOrganizationNotFederated, ErrorServerBusy, MalformedResponseError
     from ..properties import UserResponse
     from ..transport import DEFAULT_ENCODING
     from ..util import ANS, add_xml_child, create_element, get_xml_attr, ns_translation, set_xml_value, xml_to_str
    @@ -110,18 +110,16 @@ 

    Module exchangelib.services.get_user_settings

    + return e
    @@ -217,18 +215,16 @@

    Classes

    return container # Raise any non-acceptable errors in the container, or return the container or the acceptable exception instance msg_text = get_xml_attr(response, f"{{{ANS}}}ErrorMessage") + if error_code == "InternalServerError": + raise ErrorInternalServerError(msg_text) + if error_code == "ServerBusy": + raise ErrorServerBusy(msg_text) + if error_code == "NotFederated": + raise ErrorOrganizationNotFederated(msg_text) try: raise self._get_exception(code=error_code, text=msg_text, msg_xml=None) except self.ERRORS_TO_CATCH_IN_RESPONSE as e: - return e - - @classmethod - def _raise_soap_errors(cls, fault): - fault_code = get_xml_attr(fault, "faultcode") - fault_string = get_xml_attr(fault, "faultstring") - if fault_code == "a:ActionNotSupported" and "ContractFilter mismatch" in fault_string: - raise ErrorInvalidServerVersion(f"SOAP error code: {fault_code} string: {fault_string}") - super()._raise_soap_errors(fault=fault)
    + return e

    Ancestors

      diff --git a/docs/exchangelib/services/index.html b/docs/exchangelib/services/index.html index 0d8e998b..4b7cde19 100644 --- a/docs/exchangelib/services/index.html +++ b/docs/exchangelib/services/index.html @@ -1847,30 +1847,46 @@

      Inherited members

      """Send the payload to be sent and parsed. Handles and re-raise exceptions that are not meant to be returned to the caller as exception objects. Retry the request according to the retry policy. """ + wait = self.protocol.RETRY_WAIT while True: try: # Create a generator over the response elements so exceptions in response elements are also raised # here and can be handled. yield from self._response_generator(payload=payload) + # TODO: Restore session pool size on succeeding request? return + except TokenExpiredError: + # Retry immediately + continue except ErrorServerBusy as e: + if not e.back_off: + e.back_off = wait self._handle_backoff(e) - continue - except (ErrorTooManyObjectsOpened, ErrorTimeoutExpired, ErrorInternalServerTransientError) as e: + except ErrorExceededConnectionCount as e: + # ErrorExceededConnectionCount indicates that the connecting user has too many open TCP connections to + # the server. Decrease our session pool size and retry immediately. + try: + self.protocol.decrease_poolsize() + continue + except SessionPoolMinSizeReached: + # We're already as low as we can go. Let the user handle this. + raise e + except ErrorTimeoutExpired as e: + # ErrorTimeoutExpired can be caused by a busy server, or by an overly large request. If it's the latter, + # we don't want to continue hammering the server with this request indefinitely. Instead, lower the + # connection count, if possible, and retry the request. + if self.protocol.session_pool_size <= 1: + # We're already as low as we can go. We can no longer use the session count to put less load + # on the server. If this is a chunked request we could lower the chunk size, but we don't have a + # way of doing that from this part of the code yet. Let the user handle this. + raise e + self._handle_backoff(ErrorServerBusy(f"Reraised from {e.__class__.__name__}({e})", back_off=wait)) + except (ErrorTooManyObjectsOpened, ErrorInternalServerTransientError) as e: # ErrorTooManyObjectsOpened means there are too many connections to the Exchange database. This is very # often a symptom of sending too many requests. - # - # ErrorTimeoutExpired can be caused by a busy server, or by overly large requests. Start by lowering the - # session count. This is done by downstream code. - if isinstance(e, ErrorTimeoutExpired) and self.protocol.session_pool_size <= 1: - # We're already as low as we can go, so downstream cannot limit the session count to put less load - # on the server. We don't have a way of lowering the page size of requests from - # this part of the code yet. Let the user handle this. - raise e - - # Re-raise as an ErrorServerBusy with a default delay of 5 minutes - raise ErrorServerBusy(f"Reraised from {e.__class__.__name__}({e})") + self._handle_backoff(ErrorServerBusy(f"Reraised from {e.__class__.__name__}({e})", back_off=wait)) finally: + wait *= 2 # Increase delay for every retry if self.streaming: self.stop_streaming() @@ -1955,15 +1971,6 @@

      Inherited members

      # The guessed server version is wrong. Try the next version log.debug("API version %s was invalid", api_version) continue - except ErrorExceededConnectionCount as e: - # This indicates that the connecting user has too many open TCP connections to the server. Decrease - # our session pool size. - try: - self.protocol.decrease_poolsize() - continue - except SessionPoolMinSizeReached: - # We're already as low as we can go. Let the user handle this. - raise e finally: if not self.streaming: # In streaming mode, we may not have accessed the raw stream yet. Caller must handle this. @@ -2059,11 +2066,12 @@

      Inherited members

      fault_actor = get_xml_attr(fault, "faultactor") detail = fault.find("detail") if detail is not None: - code, msg = None, "" - if detail.find(f"{{{ENS}}}ResponseCode") is not None: - code = get_xml_attr(detail, f"{{{ENS}}}ResponseCode").strip() - if detail.find(f"{{{ENS}}}Message") is not None: - msg = get_xml_attr(detail, f"{{{ENS}}}Message").strip() + code = get_xml_attr(detail, f"{{{ENS}}}ResponseCode") + if code: + code = code.strip() + msg = get_xml_attr(detail, f"{{{ENS}}}Message") + if msg: + msg = msg.strip() msg_xml = detail.find(f"{{{TNS}}}MessageXml") # Crazy. Here, it's in the TNS namespace if code == "ErrorServerBusy": back_off = None @@ -5524,18 +5532,16 @@

      Inherited members

      return container # Raise any non-acceptable errors in the container, or return the container or the acceptable exception instance msg_text = get_xml_attr(response, f"{{{ANS}}}ErrorMessage") + if error_code == "InternalServerError": + raise ErrorInternalServerError(msg_text) + if error_code == "ServerBusy": + raise ErrorServerBusy(msg_text) + if error_code == "NotFederated": + raise ErrorOrganizationNotFederated(msg_text) try: raise self._get_exception(code=error_code, text=msg_text, msg_xml=None) except self.ERRORS_TO_CATCH_IN_RESPONSE as e: - return e - - @classmethod - def _raise_soap_errors(cls, fault): - fault_code = get_xml_attr(fault, "faultcode") - fault_string = get_xml_attr(fault, "faultstring") - if fault_code == "a:ActionNotSupported" and "ContractFilter mismatch" in fault_string: - raise ErrorInvalidServerVersion(f"SOAP error code: {fault_code} string: {fault_string}") - super()._raise_soap_errors(fault=fault) + return e

      Ancestors

        diff --git a/docs/exchangelib/transport.html b/docs/exchangelib/transport.html index 83503e63..318fdbad 100644 --- a/docs/exchangelib/transport.html +++ b/docs/exchangelib/transport.html @@ -27,15 +27,14 @@

        Module exchangelib.transport

        Expand source code
        import logging
        -import time
         from contextlib import suppress
         
         import requests.auth
         import requests_ntlm
         import requests_oauthlib
         
        -from .errors import TransportError, UnauthorizedError
        -from .util import CONNECTION_ERRORS, RETRY_WAIT, TLS_ERRORS, DummyResponse, _back_off_if_needed, _retry_after
        +from .errors import RateLimitError, TransportError, UnauthorizedError
        +from .util import CONNECTION_ERRORS, TLS_ERRORS, _back_off_if_needed
         
         log = logging.getLogger(__name__)
         
        @@ -103,12 +102,10 @@ 

        Module exchangelib.transport

        service_endpoint = protocol.service_endpoint retry_policy = protocol.retry_policy - retry = 0 - t_start = time.monotonic() + wait = protocol.RETRY_WAIT for api_version in ConvertId.supported_api_versions(): protocol.api_version_hint = api_version data = protocol.dummy_xml() - headers = {} log.debug("Requesting %s from %s", data, service_endpoint) while True: _back_off_if_needed(retry_policy.back_off_until) @@ -117,7 +114,6 @@

        Module exchangelib.transport

        try: r = s.post( url=service_endpoint, - headers=headers, data=data, allow_redirects=False, timeout=protocol.TIMEOUT, @@ -126,21 +122,13 @@

        Module exchangelib.transport

        break except CONNECTION_ERRORS as e: # Don't retry on TLS errors. They will most likely be persistent. - total_wait = time.monotonic() - t_start - r = DummyResponse(url=service_endpoint, request_headers=headers) - if retry_policy.may_retry_on_error(response=r, wait=total_wait): - wait = _retry_after(r, RETRY_WAIT) - log.info( - "Connection error on URL %s (retry %s, error: %s). Cool down %s secs", - service_endpoint, - retry, - e, - wait, - ) + log.info("Connection error on URL %s.", service_endpoint) + if not retry_policy.fail_fast: retry_policy.back_off(wait) - retry += 1 continue raise TransportError(str(e)) from e + finally: + wait *= 2 if r.status_code not in (200, 401): log.debug("Unexpected response: %s %s", r.status_code, r.reason) continue @@ -163,12 +151,9 @@

        Module exchangelib.transport

        def get_unauthenticated_autodiscover_response(protocol, method, headers=None, data=None): - from .autodiscover import Autodiscovery - service_endpoint = protocol.service_endpoint retry_policy = protocol.retry_policy - retry = 0 - t_start = time.monotonic() + wait = protocol.RETRY_WAIT while True: _back_off_if_needed(retry_policy.back_off_until) log.debug("Trying to get response from %s", service_endpoint) @@ -187,23 +172,16 @@

        Module exchangelib.transport

        # Don't retry on TLS errors. But wrap, so we can catch later and continue with the next endpoint. raise TransportError(str(e)) except CONNECTION_ERRORS as e: - r = DummyResponse(url=service_endpoint, request_headers=headers) - total_wait = time.monotonic() - t_start - if retry_policy.may_retry_on_error(response=r, wait=total_wait): - log.debug( - "Connection error on URL %s (retry %s, error: %s). Cool down %s secs", - service_endpoint, - retry, - e, - Autodiscovery.RETRY_WAIT, - ) - # Don't respect the 'Retry-After' header. We don't know if this is a useful endpoint, and we - # want autodiscover to be reasonably fast. - retry_policy.back_off(Autodiscovery.RETRY_WAIT) - retry += 1 - continue log.debug("Connection error on URL %s: %s", service_endpoint, e) - raise TransportError(str(e)) + if not retry_policy.fail_fast: + try: + retry_policy.back_off(wait) + continue + except RateLimitError: + pass + raise TransportError(str(e)) from e + finally: + wait *= 2 return r @@ -369,12 +347,10 @@

        Functions

        service_endpoint = protocol.service_endpoint retry_policy = protocol.retry_policy - retry = 0 - t_start = time.monotonic() + wait = protocol.RETRY_WAIT for api_version in ConvertId.supported_api_versions(): protocol.api_version_hint = api_version data = protocol.dummy_xml() - headers = {} log.debug("Requesting %s from %s", data, service_endpoint) while True: _back_off_if_needed(retry_policy.back_off_until) @@ -383,7 +359,6 @@

        Functions

        try: r = s.post( url=service_endpoint, - headers=headers, data=data, allow_redirects=False, timeout=protocol.TIMEOUT, @@ -392,21 +367,13 @@

        Functions

        break except CONNECTION_ERRORS as e: # Don't retry on TLS errors. They will most likely be persistent. - total_wait = time.monotonic() - t_start - r = DummyResponse(url=service_endpoint, request_headers=headers) - if retry_policy.may_retry_on_error(response=r, wait=total_wait): - wait = _retry_after(r, RETRY_WAIT) - log.info( - "Connection error on URL %s (retry %s, error: %s). Cool down %s secs", - service_endpoint, - retry, - e, - wait, - ) + log.info("Connection error on URL %s.", service_endpoint) + if not retry_policy.fail_fast: retry_policy.back_off(wait) - retry += 1 continue raise TransportError(str(e)) from e + finally: + wait *= 2 if r.status_code not in (200, 401): log.debug("Unexpected response: %s %s", r.status_code, r.reason) continue @@ -429,12 +396,9 @@

        Functions

        Expand source code
        def get_unauthenticated_autodiscover_response(protocol, method, headers=None, data=None):
        -    from .autodiscover import Autodiscovery
        -
             service_endpoint = protocol.service_endpoint
             retry_policy = protocol.retry_policy
        -    retry = 0
        -    t_start = time.monotonic()
        +    wait = protocol.RETRY_WAIT
             while True:
                 _back_off_if_needed(retry_policy.back_off_until)
                 log.debug("Trying to get response from %s", service_endpoint)
        @@ -453,23 +417,16 @@ 

        Functions

        # Don't retry on TLS errors. But wrap, so we can catch later and continue with the next endpoint. raise TransportError(str(e)) except CONNECTION_ERRORS as e: - r = DummyResponse(url=service_endpoint, request_headers=headers) - total_wait = time.monotonic() - t_start - if retry_policy.may_retry_on_error(response=r, wait=total_wait): - log.debug( - "Connection error on URL %s (retry %s, error: %s). Cool down %s secs", - service_endpoint, - retry, - e, - Autodiscovery.RETRY_WAIT, - ) - # Don't respect the 'Retry-After' header. We don't know if this is a useful endpoint, and we - # want autodiscover to be reasonably fast. - retry_policy.back_off(Autodiscovery.RETRY_WAIT) - retry += 1 - continue log.debug("Connection error on URL %s: %s", service_endpoint, e) - raise TransportError(str(e)) + if not retry_policy.fail_fast: + try: + retry_policy.back_off(wait) + continue + except RateLimitError: + pass + raise TransportError(str(e)) from e + finally: + wait *= 2 return r
        diff --git a/docs/exchangelib/util.html b/docs/exchangelib/util.html index b6fcdfe1..b4118117 100644 --- a/docs/exchangelib/util.html +++ b/docs/exchangelib/util.html @@ -53,7 +53,7 @@

        Module exchangelib.util

        from pygments.lexers.html import XmlLexer from requests_oauthlib import OAuth2Session -from .errors import MalformedResponseError, RateLimitError, RedirectError, RelativeRedirect, TransportError +from .errors import ErrorInternalServerTransientError, ErrorTimeoutExpired, RelativeRedirect, TransportError log = logging.getLogger(__name__) xml_log = logging.getLogger(f"{__name__}.xml") @@ -743,8 +743,6 @@

        Module exchangelib.util

        return redirect_url -RETRY_WAIT = 10 # Seconds to wait before retry on connection errors - # A collection of error classes we want to handle as general connection errors CONNECTION_ERRORS = ( requests.exceptions.ChunkedEncodingError, @@ -801,11 +799,7 @@

        Module exchangelib.util

        if not timeout: timeout = protocol.TIMEOUT thread_id = get_ident() - wait = RETRY_WAIT # Initial retry wait. We double the value on each retry - retry = 0 log_msg = """\ -Retry: %(retry)s -Waited: %(wait)s Timeout: %(timeout)s Session: %(session_id)s Thread: %(thread_id)s @@ -821,8 +815,6 @@

        Module exchangelib.util

        Request XML: %(xml_request)s Response XML: %(xml_response)s""" log_vals = dict( - retry=retry, - wait=wait, timeout=timeout, session_id=session.session_id, thread_id=thread_id, @@ -836,150 +828,97 @@

        Module exchangelib.util

        response_headers=None, ) xml_log_vals = dict( - xml_request=None, + xml_request=data, xml_response=None, ) - t_start = time.monotonic() + sleep_secs = _back_off_if_needed(protocol.retry_policy.back_off_until) + if sleep_secs: + # We may have slept for a long time. Renew the session. + session = protocol.renew_session(session) + log.debug( + "Session %s thread %s timeout %s: POST'ing to %s after %ss sleep", + session.session_id, + thread_id, + timeout, + url, + sleep_secs, + ) + kwargs = dict(url=url, headers=headers, data=data, allow_redirects=False, timeout=timeout, stream=stream) + if isinstance(session, OAuth2Session): + # Fix token refreshing bug. Reported as https://github.com/requests/requests-oauthlib/issues/498 + kwargs.update(session.auto_refresh_kwargs) + d_start = time.monotonic() try: - while True: - backed_off = _back_off_if_needed(protocol.retry_policy.back_off_until) - if backed_off: - # We may have slept for a long time. Renew the session. - session = protocol.renew_session(session) - log.debug( - "Session %s thread %s: retry %s timeout %s POST'ing to %s after %ss wait", - session.session_id, - thread_id, - retry, - timeout, - url, - wait, - ) - d_start = time.monotonic() - # Always create a dummy response for logging purposes, in case we fail in the following - r = DummyResponse(url=url, request_headers=headers) - kwargs = dict(url=url, headers=headers, data=data, allow_redirects=False, timeout=timeout, stream=stream) - if isinstance(session, OAuth2Session): - # Fix token refreshing bug. Reported as https://github.com/requests/requests-oauthlib/issues/498 - kwargs.update(session.auto_refresh_kwargs) - try: - r = session.post(**kwargs) - except TLS_ERRORS as e: - # Don't retry on TLS errors. They will most likely be persistent. - raise TransportError(str(e)) - except CONNECTION_ERRORS as e: - log.debug("Session %s thread %s: connection error POST'ing to %s", session.session_id, thread_id, url) - r = DummyResponse(url=url, headers={"TimeoutException": e}, request_headers=headers) - except TokenExpiredError as e: - log.debug("Session %s thread %s: OAuth token expired; refreshing", session.session_id, thread_id) - r = DummyResponse(url=url, headers={"TokenExpiredError": e}, request_headers=headers, status_code=401) - except KeyError as e: - if e.args[0] != "www-authenticate": - raise - log.debug("Session %s thread %s: auth headers missing from %s", session.session_id, thread_id, url) - r = DummyResponse(url=url, headers={"KeyError": e}, request_headers=headers) - finally: - log_vals.update( - retry=retry, - wait=wait, - session_id=session.session_id, - url=str(r.url), - response_time=time.monotonic() - d_start, - status_code=r.status_code, - request_headers=r.request.headers, - response_headers=r.headers, - ) - xml_log_vals.update( - xml_request=data, - xml_response="[STREAMING]" if stream else r.content, - ) - log.debug(log_msg, log_vals) - xml_log.debug(xml_log_msg, xml_log_vals) - if _need_new_credentials(response=r): - r.close() # Release memory - session = protocol.refresh_credentials(session) - continue - total_wait = time.monotonic() - t_start - if protocol.retry_policy.may_retry_on_error(response=r, wait=total_wait): - r.close() # Release memory - log.info( - "Session %s thread %s: Connection error on URL %s (code %s). Cool down %s secs", - session.session_id, - thread_id, - r.url, - r.status_code, - wait, - ) - wait = _retry_after(r, wait) - protocol.retry_policy.back_off(wait) - retry += 1 - wait *= 2 # Increase delay for every retry - continue - if r.status_code in (301, 302): - r.close() # Release memory - url = _fail_on_redirect(r) - continue - break - except (RateLimitError, RedirectError) as e: - log.warning(e.value) + r = session.post(**kwargs) + except TLS_ERRORS as e: + protocol.retire_session(session) + # Don't retry on TLS errors. They will most likely be persistent. + raise TransportError(str(e)) + except CONNECTION_ERRORS as e: protocol.retire_session(session) + log.debug("Session %s thread %s: connection error POST'ing to %s", session.session_id, thread_id, url) + raise ErrorTimeoutExpired(f"Reraised from {e.__class__.__name__}({e})") + except TokenExpiredError: + log.debug("Session %s thread %s: OAuth token expired; refreshing", session.session_id, thread_id) + protocol.release_session(protocol.refresh_credentials(session)) raise + except KeyError as e: + protocol.retire_session(session) + if e.args[0] != "www-authenticate": + raise + # Server returned an HTTP error code during the NTLM handshake. Re-raise as internal server error + log.debug("Session %s thread %s: auth headers missing from %s", session.session_id, thread_id, url) + raise ErrorInternalServerTransientError(f"Reraised from {e.__class__.__name__}({e})") except Exception as e: # Let higher layers handle this. Add full context for better debugging. log.error("%s: %s\n%s\n%s", e.__class__.__name__, str(e), log_msg % log_vals, xml_log_msg % xml_log_vals) protocol.retire_session(session) raise - if r.status_code == 500 and r.content and is_xml(r.content): - # Some genius at Microsoft thinks it's OK to send a valid SOAP response as an HTTP 500 - log.debug("Got status code %s but trying to parse content anyway", r.status_code) - elif r.status_code != 200: + log_vals.update( + session_id=session.session_id, + url=r.url, + response_time=time.monotonic() - d_start, + status_code=r.status_code, + request_headers=r.request.headers, + response_headers=r.headers, + ) + xml_log_vals.update( + xml_request=data, + xml_response="[STREAMING]" if stream else r.content, + ) + log.debug(log_msg, log_vals) + xml_log.debug(xml_log_msg, xml_log_vals) + + try: + protocol.retry_policy.raise_response_errors(r) + except Exception: + r.close() # Release memory protocol.retire_session(session) - try: - protocol.retry_policy.raise_response_errors(r) # Always raises an exception - except MalformedResponseError as e: - log.error("%s: %s\n%s\n%s", e.__class__.__name__, str(e), log_msg % log_vals, xml_log_msg % xml_log_vals) - raise + raise + log.debug("Session %s thread %s: Useful response from %s", session.session_id, thread_id, url) return r, session def _back_off_if_needed(back_off_until): + # Returns the number of seconds we slept if back_off_until: sleep_secs = (back_off_until - datetime.datetime.now()).total_seconds() # The back off value may have expired within the last few milliseconds if sleep_secs > 0: log.warning("Server requested back off until %s. Sleeping %s seconds", back_off_until, sleep_secs) time.sleep(sleep_secs) - return True - return False + return sleep_secs + return 0 -def _need_new_credentials(response): - return response.status_code == 401 and response.headers.get("TokenExpiredError") - - -def _fail_on_redirect(response): - # Retry with no delay. If we let requests handle redirects automatically, it would issue a GET to that - # URL. We still want to POST. +def _get_retry_after(r): + """Get Retry-After header, if it exists. We interpret the value as seconds to wait before sending next request.""" try: - redirect_url = get_redirect_url(response=response, allow_relative=False) - except RelativeRedirect as e: - log.debug("'allow_redirects' only supports relative redirects (%s -> %s)", response.url, e.value) - raise RedirectError(url=e.value) - log.debug("Redirect not allowed but we were redirected ( (%s -> %s)", response.url, redirect_url) - raise RedirectError(url=redirect_url) - - -def _retry_after(r, wait): - """Either return the Retry-After header value or the default wait, whichever is larger.""" - try: - retry_after = int(r.headers.get("Retry-After", "0")) + val = int(r.headers.get("Retry-After", "0")) except ValueError: - pass - else: - if retry_after > wait: - return retry_after - return wait
        + return None + return val if val > 0 else None
    @@ -1318,11 +1257,7 @@

    Functions

    if not timeout: timeout = protocol.TIMEOUT thread_id = get_ident() - wait = RETRY_WAIT # Initial retry wait. We double the value on each retry - retry = 0 log_msg = """\ -Retry: %(retry)s -Waited: %(wait)s Timeout: %(timeout)s Session: %(session_id)s Thread: %(thread_id)s @@ -1338,8 +1273,6 @@

    Functions

    Request XML: %(xml_request)s Response XML: %(xml_response)s""" log_vals = dict( - retry=retry, - wait=wait, timeout=timeout, session_id=session.session_id, thread_id=thread_id, @@ -1353,109 +1286,74 @@

    Functions

    response_headers=None, ) xml_log_vals = dict( - xml_request=None, + xml_request=data, xml_response=None, ) - t_start = time.monotonic() + sleep_secs = _back_off_if_needed(protocol.retry_policy.back_off_until) + if sleep_secs: + # We may have slept for a long time. Renew the session. + session = protocol.renew_session(session) + log.debug( + "Session %s thread %s timeout %s: POST'ing to %s after %ss sleep", + session.session_id, + thread_id, + timeout, + url, + sleep_secs, + ) + kwargs = dict(url=url, headers=headers, data=data, allow_redirects=False, timeout=timeout, stream=stream) + if isinstance(session, OAuth2Session): + # Fix token refreshing bug. Reported as https://github.com/requests/requests-oauthlib/issues/498 + kwargs.update(session.auto_refresh_kwargs) + d_start = time.monotonic() try: - while True: - backed_off = _back_off_if_needed(protocol.retry_policy.back_off_until) - if backed_off: - # We may have slept for a long time. Renew the session. - session = protocol.renew_session(session) - log.debug( - "Session %s thread %s: retry %s timeout %s POST'ing to %s after %ss wait", - session.session_id, - thread_id, - retry, - timeout, - url, - wait, - ) - d_start = time.monotonic() - # Always create a dummy response for logging purposes, in case we fail in the following - r = DummyResponse(url=url, request_headers=headers) - kwargs = dict(url=url, headers=headers, data=data, allow_redirects=False, timeout=timeout, stream=stream) - if isinstance(session, OAuth2Session): - # Fix token refreshing bug. Reported as https://github.com/requests/requests-oauthlib/issues/498 - kwargs.update(session.auto_refresh_kwargs) - try: - r = session.post(**kwargs) - except TLS_ERRORS as e: - # Don't retry on TLS errors. They will most likely be persistent. - raise TransportError(str(e)) - except CONNECTION_ERRORS as e: - log.debug("Session %s thread %s: connection error POST'ing to %s", session.session_id, thread_id, url) - r = DummyResponse(url=url, headers={"TimeoutException": e}, request_headers=headers) - except TokenExpiredError as e: - log.debug("Session %s thread %s: OAuth token expired; refreshing", session.session_id, thread_id) - r = DummyResponse(url=url, headers={"TokenExpiredError": e}, request_headers=headers, status_code=401) - except KeyError as e: - if e.args[0] != "www-authenticate": - raise - log.debug("Session %s thread %s: auth headers missing from %s", session.session_id, thread_id, url) - r = DummyResponse(url=url, headers={"KeyError": e}, request_headers=headers) - finally: - log_vals.update( - retry=retry, - wait=wait, - session_id=session.session_id, - url=str(r.url), - response_time=time.monotonic() - d_start, - status_code=r.status_code, - request_headers=r.request.headers, - response_headers=r.headers, - ) - xml_log_vals.update( - xml_request=data, - xml_response="[STREAMING]" if stream else r.content, - ) - log.debug(log_msg, log_vals) - xml_log.debug(xml_log_msg, xml_log_vals) - if _need_new_credentials(response=r): - r.close() # Release memory - session = protocol.refresh_credentials(session) - continue - total_wait = time.monotonic() - t_start - if protocol.retry_policy.may_retry_on_error(response=r, wait=total_wait): - r.close() # Release memory - log.info( - "Session %s thread %s: Connection error on URL %s (code %s). Cool down %s secs", - session.session_id, - thread_id, - r.url, - r.status_code, - wait, - ) - wait = _retry_after(r, wait) - protocol.retry_policy.back_off(wait) - retry += 1 - wait *= 2 # Increase delay for every retry - continue - if r.status_code in (301, 302): - r.close() # Release memory - url = _fail_on_redirect(r) - continue - break - except (RateLimitError, RedirectError) as e: - log.warning(e.value) + r = session.post(**kwargs) + except TLS_ERRORS as e: protocol.retire_session(session) + # Don't retry on TLS errors. They will most likely be persistent. + raise TransportError(str(e)) + except CONNECTION_ERRORS as e: + protocol.retire_session(session) + log.debug("Session %s thread %s: connection error POST'ing to %s", session.session_id, thread_id, url) + raise ErrorTimeoutExpired(f"Reraised from {e.__class__.__name__}({e})") + except TokenExpiredError: + log.debug("Session %s thread %s: OAuth token expired; refreshing", session.session_id, thread_id) + protocol.release_session(protocol.refresh_credentials(session)) raise + except KeyError as e: + protocol.retire_session(session) + if e.args[0] != "www-authenticate": + raise + # Server returned an HTTP error code during the NTLM handshake. Re-raise as internal server error + log.debug("Session %s thread %s: auth headers missing from %s", session.session_id, thread_id, url) + raise ErrorInternalServerTransientError(f"Reraised from {e.__class__.__name__}({e})") except Exception as e: # Let higher layers handle this. Add full context for better debugging. log.error("%s: %s\n%s\n%s", e.__class__.__name__, str(e), log_msg % log_vals, xml_log_msg % xml_log_vals) protocol.retire_session(session) raise - if r.status_code == 500 and r.content and is_xml(r.content): - # Some genius at Microsoft thinks it's OK to send a valid SOAP response as an HTTP 500 - log.debug("Got status code %s but trying to parse content anyway", r.status_code) - elif r.status_code != 200: + log_vals.update( + session_id=session.session_id, + url=r.url, + response_time=time.monotonic() - d_start, + status_code=r.status_code, + request_headers=r.request.headers, + response_headers=r.headers, + ) + xml_log_vals.update( + xml_request=data, + xml_response="[STREAMING]" if stream else r.content, + ) + log.debug(log_msg, log_vals) + xml_log.debug(xml_log_msg, xml_log_vals) + + try: + protocol.retry_policy.raise_response_errors(r) + except Exception: + r.close() # Release memory protocol.retire_session(session) - try: - protocol.retry_policy.raise_response_errors(r) # Always raises an exception - except MalformedResponseError as e: - log.error("%s: %s\n%s\n%s", e.__class__.__name__, str(e), log_msg % log_vals, xml_log_msg % xml_log_vals) - raise + raise + log.debug("Session %s thread %s: Useful response from %s", session.session_id, thread_id, url) return r, session diff --git a/docs/exchangelib/version.html b/docs/exchangelib/version.html index 05d44faf..067d2170 100644 --- a/docs/exchangelib/version.html +++ b/docs/exchangelib/version.html @@ -74,26 +74,6 @@

    Module exchangelib.version

    kwargs[k] = int(v) # Also raises ValueError return cls(**kwargs) - @classmethod - def from_hex_string(cls, s): - """Parse a server version string as returned in an autodiscover response. The process is described here: - https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/serverversion-pox#example - - The string is a hex string that, converted to a 32-bit binary, encodes the server version. The rules are: - * The first 4 bits contain the version number structure version. Can be ignored - * The next 6 bits contain the major version number - * The next 6 bits contain the minor version number - * The next bit contains a flag. Can be ignored - * The next 15 bits contain the major build number - - :param s: - """ - bin_s = f"{int(s, 16):032b}" # Convert string to 32-bit binary string - major_version = int(bin_s[4:10], 2) - minor_version = int(bin_s[10:16], 2) - build_number = int(bin_s[17:32], 2) - return cls(major_version=major_version, minor_version=minor_version, major_build=build_number) - def api_version(self): for build, api_version, _ in VERSIONS: if self.major_version != build.major_version or self.minor_version != build.minor_version: @@ -423,26 +403,6 @@

    Classes

    kwargs[k] = int(v) # Also raises ValueError return cls(**kwargs) - @classmethod - def from_hex_string(cls, s): - """Parse a server version string as returned in an autodiscover response. The process is described here: - https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/serverversion-pox#example - - The string is a hex string that, converted to a 32-bit binary, encodes the server version. The rules are: - * The first 4 bits contain the version number structure version. Can be ignored - * The next 6 bits contain the major version number - * The next 6 bits contain the minor version number - * The next bit contains a flag. Can be ignored - * The next 15 bits contain the major build number - - :param s: - """ - bin_s = f"{int(s, 16):032b}" # Convert string to 32-bit binary string - major_version = int(bin_s[4:10], 2) - minor_version = int(bin_s[10:16], 2) - build_number = int(bin_s[17:32], 2) - return cls(major_version=major_version, minor_version=minor_version, major_build=build_number) - def api_version(self): for build, api_version, _ in VERSIONS: if self.major_version != build.major_version or self.minor_version != build.minor_version: @@ -495,44 +455,6 @@

    Classes

    Static methods

    -
    -def from_hex_string(s) -
    -
    -

    Parse a server version string as returned in an autodiscover response. The process is described here: -https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/serverversion-pox#example

    -

    The string is a hex string that, converted to a 32-bit binary, encodes the server version. The rules are: -* The first 4 bits contain the version number structure version. Can be ignored -* The next 6 bits contain the major version number -* The next 6 bits contain the minor version number -* The next bit contains a flag. Can be ignored -* The next 15 bits contain the major build number

    -

    :param s:

    -
    - -Expand source code - -
    @classmethod
    -def from_hex_string(cls, s):
    -    """Parse a server version string as returned in an autodiscover response. The process is described here:
    -    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/serverversion-pox#example
    -
    -    The string is a hex string that, converted to a 32-bit binary, encodes the server version. The rules are:
    -        * The first 4 bits contain the version number structure version. Can be ignored
    -        * The next 6 bits contain the major version number
    -        * The next 6 bits contain the minor version number
    -        * The next bit contains a flag. Can be ignored
    -        * The next 15 bits contain the major build number
    -
    -    :param s:
    -    """
    -    bin_s = f"{int(s, 16):032b}"  # Convert string to 32-bit binary string
    -    major_version = int(bin_s[4:10], 2)
    -    minor_version = int(bin_s[10:16], 2)
    -    build_number = int(bin_s[17:32], 2)
    -    return cls(major_version=major_version, minor_version=minor_version, major_build=build_number)
    -
    -
    def from_xml(elem)
    @@ -1024,7 +946,6 @@

    Index

    Build

    • api_version
    • -
    • from_hex_string
    • from_xml
    • major_build
    • major_version
    • diff --git a/docs/exchangelib/winzone.html b/docs/exchangelib/winzone.html index bb464e9b..1cb53684 100644 --- a/docs/exchangelib/winzone.html +++ b/docs/exchangelib/winzone.html @@ -55,7 +55,7 @@

      Module exchangelib.winzone

      type_version = timezones_elem.get("typeVersion") other_version = timezones_elem.get("otherVersion") for e in timezones_elem.findall("mapZone"): - for location in re.split(r"\s+", e.get("type")): + for location in re.split(r"\s+", e.get("type").strip()): if e.get("territory") == DEFAULT_TERRITORY or location not in tz_map: # Prefer default territory. This is so MS_TIMEZONE_TO_IANA_MAP maps from MS timezone ID back to the # "preferred" region/location timezone name. @@ -152,7 +152,8 @@

      Module exchangelib.winzone

      "America/Cayenne": ("SA Eastern Standard Time", "001"), "America/Cayman": ("SA Pacific Standard Time", "KY"), "America/Chicago": ("Central Standard Time", "001"), - "America/Chihuahua": ("Mountain Standard Time (Mexico)", "001"), + "America/Chihuahua": ("Central Standard Time (Mexico)", "MX"), + "America/Ciudad_Juarez": ("Mountain Standard Time", "MX"), "America/Coral_Harbour": ("SA Pacific Standard Time", "CA"), "America/Cordoba": ("Argentina Standard Time", "AR"), "America/Costa_Rica": ("Central America Standard Time", "CR"), @@ -208,7 +209,7 @@

      Module exchangelib.winzone

      "America/Marigot": ("SA Western Standard Time", "MF"), "America/Martinique": ("SA Western Standard Time", "MQ"), "America/Matamoros": ("Central Standard Time", "MX"), - "America/Mazatlan": ("Mountain Standard Time (Mexico)", "MX"), + "America/Mazatlan": ("Mountain Standard Time (Mexico)", "001"), "America/Mendoza": ("Argentina Standard Time", "AR"), "America/Menominee": ("Central Standard Time", "US"), "America/Merida": ("Central Standard Time (Mexico)", "MX"), @@ -228,7 +229,7 @@

      Module exchangelib.winzone

      "America/North_Dakota/Beulah": ("Central Standard Time", "US"), "America/North_Dakota/Center": ("Central Standard Time", "US"), "America/North_Dakota/New_Salem": ("Central Standard Time", "US"), - "America/Ojinaga": ("Mountain Standard Time", "MX"), + "America/Ojinaga": ("Central Standard Time", "MX"), "America/Panama": ("SA Pacific Standard Time", "PA"), "America/Pangnirtung": ("Eastern Standard Time", "CA"), "America/Paramaribo": ("SA Eastern Standard Time", "SR"), @@ -718,7 +719,7 @@

      Functions

      type_version = timezones_elem.get("typeVersion") other_version = timezones_elem.get("otherVersion") for e in timezones_elem.findall("mapZone"): - for location in re.split(r"\s+", e.get("type")): + for location in re.split(r"\s+", e.get("type").strip()): if e.get("territory") == DEFAULT_TERRITORY or location not in tz_map: # Prefer default territory. This is so MS_TIMEZONE_TO_IANA_MAP maps from MS timezone ID back to the # "preferred" region/location timezone name. From fa8a5194c968b81c1f61f8134987fef345d16a62 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 29 Mar 2023 20:38:56 +0200 Subject: [PATCH 329/509] Bump version --- CHANGELOG.md | 6 +++++- exchangelib/__init__.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19dccf61..013f4c45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,14 @@ Change Log HEAD ---- + + +5.0.0 +----- - Make SOAP-based autodiscovery the default, and remove support for POX-based discovery. This also removes support for autodiscovery on Exchange 2007. Only `Account(..., autodiscover=True)` is supported again. -- Deprecated `RetryPolicy.may_retry_on_error`. Instead, ad custom retry logic +- Deprecated `RetryPolicy.may_retry_on_error`. Instead, add custom retry logic in `RetryPolicy.raise_response_errors`. - Moved `exchangelib.util.RETRY_WAIT` to `BaseProtocol.RETRY_WAIT`. diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index 42530357..151c55ce 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -36,7 +36,7 @@ from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "4.9.0" +__version__ = "5.0.0" __all__ = [ "__version__", From 32b435c38e396e472bfc6f0367e6445f1e220689 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Fri, 31 Mar 2023 07:46:54 +0200 Subject: [PATCH 330/509] fix: Build package from fresh build folder. Fixes #1181 --- CHANGELOG.md | 5 +++++ exchangelib/__init__.py | 2 +- setup.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 013f4c45..231d5a75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ HEAD ---- +5.0.1 +----- +- Fix PyPI package. No source code changes. + + 5.0.0 ----- - Make SOAP-based autodiscovery the default, and remove support for POX-based diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index 151c55ce..5ae7d8c5 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -36,7 +36,7 @@ from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "5.0.0" +__version__ = "5.0.1" __all__ = [ "__version__", diff --git a/setup.py b/setup.py index 2f27217d..98a047be 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ * Bump version in CHANGELOG.md * Generate documentation: pdoc3 --html exchangelib -o docs --force * Commit and push changes -* Build package: rm -rf dist/* && python setup.py sdist bdist_wheel +* Build package: rm -rf build dist && python setup.py sdist bdist_wheel * Push to PyPI: twine upload dist/* * Create release on GitHub """ From 7a736dfc19e60cc29da68cb165d23917eef927f7 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 13 Apr 2023 19:25:26 +0200 Subject: [PATCH 331/509] chore: Simplify logic --- exchangelib/folders/roots.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/exchangelib/folders/roots.py b/exchangelib/folders/roots.py index b0ecd40e..1a416d64 100644 --- a/exchangelib/folders/roots.py +++ b/exchangelib/folders/roots.py @@ -208,8 +208,11 @@ def folder_cls_from_folder_name(cls, folder_name, folder_class, locale): :param locale: a string, e.g. 'da_DK' """ for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS + MISC_FOLDERS: - if folder_cls.CONTAINER_CLASS == folder_class and folder_name.lower() in folder_cls.localized_names(locale): - return folder_cls + if folder_cls.CONTAINER_CLASS != folder_class: + continue + if folder_name.lower() not in folder_cls.localized_names(locale): + continue + return folder_cls raise KeyError() def __getstate__(self): From 8cfa12d56e02891133b1c4601ac80a90c4087650 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 13 Apr 2023 19:55:33 +0200 Subject: [PATCH 332/509] fix: support folder guessing also for folders that don't define a CONTAINER_CLASS value. Fixes #1182 --- exchangelib/folders/base.py | 2 +- exchangelib/folders/roots.py | 3 +++ tests/test_folder.py | 5 +++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 65ab2f8f..79a9cffe 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -908,7 +908,7 @@ def from_xml_with_root(cls, elem, root): # # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. - if folder.name and folder.folder_class: + if folder.name: with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative folder_cls = root.folder_cls_from_folder_name( diff --git a/exchangelib/folders/roots.py b/exchangelib/folders/roots.py index 1a416d64..0fbb1d2b 100644 --- a/exchangelib/folders/roots.py +++ b/exchangelib/folders/roots.py @@ -203,6 +203,9 @@ def folder_cls_from_folder_name(cls, folder_name, folder_class, locale): folder, to not identify an 'IPF.Note' folder as a 'Calendar' class just because it's called e.g. 'Kalender' and the locale is 'da_DK'. + Some folders, e.g. `System`, don't define a `folder_class`. For these folders, we match on localized folder name + if the folder class does not have its 'CONTAINER_CLASS' set. + :param folder_name: :param folder_class: :param locale: a string, e.g. 'da_DK' diff --git a/tests/test_folder.py b/tests/test_folder.py index 21fde75b..a636b38e 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -23,6 +23,7 @@ ApplicationData, Birthdays, Calendar, + CommonViews, Companies, Contacts, ConversationSettings, @@ -689,6 +690,10 @@ def test_folder_type_guessing(self): finally: self.account.locale = old_locale + # Also test folders that don't have a CONTAINER_CLASS + f = self.account.root / "Common Views" + self.assertIsInstance(f, CommonViews) + def test_wipe_without_empty(self): name = get_random_string(16) f = Messages(parent=self.account.inbox, name=name).save() From dc79362e5f1a8c940e5966a75202b6a75f4c1867 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 13 Apr 2023 20:02:41 +0200 Subject: [PATCH 333/509] chore: regenerate docs --- docs/exchangelib/folders/base.html | 6 +++--- docs/exchangelib/folders/index.html | 26 +++++++++++++++++------ docs/exchangelib/folders/roots.html | 32 +++++++++++++++++++++++------ docs/exchangelib/index.html | 28 ++++++++++++++++++------- setup.py | 2 +- 5 files changed, 71 insertions(+), 23 deletions(-) diff --git a/docs/exchangelib/folders/base.html b/docs/exchangelib/folders/base.html index 3c510914..bc4924a9 100644 --- a/docs/exchangelib/folders/base.html +++ b/docs/exchangelib/folders/base.html @@ -936,7 +936,7 @@

      Module exchangelib.folders.base

      # # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. - if folder.name and folder.folder_class: + if folder.name: with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative folder_cls = root.folder_cls_from_folder_name( @@ -3034,7 +3034,7 @@

      Inherited members

      # # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. - if folder.name and folder.folder_class: + if folder.name: with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative folder_cls = root.folder_cls_from_folder_name( @@ -3147,7 +3147,7 @@

      Static methods

      # # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. - if folder.name and folder.folder_class: + if folder.name: with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative folder_cls = root.folder_cls_from_folder_name( diff --git a/docs/exchangelib/folders/index.html b/docs/exchangelib/folders/index.html index 0273ab4f..9511b59c 100644 --- a/docs/exchangelib/folders/index.html +++ b/docs/exchangelib/folders/index.html @@ -4933,7 +4933,7 @@

      Inherited members

      # # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. - if folder.name and folder.folder_class: + if folder.name: with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative folder_cls = root.folder_cls_from_folder_name( @@ -5046,7 +5046,7 @@

      Static methods

      # # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. - if folder.name and folder.folder_class: + if folder.name: with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative folder_cls = root.folder_cls_from_folder_name( @@ -9881,13 +9881,19 @@

      Inherited members

      folder, to not identify an 'IPF.Note' folder as a 'Calendar' class just because it's called e.g. 'Kalender' and the locale is 'da_DK'. + Some folders, e.g. `System`, don't define a `folder_class`. For these folders, we match on localized folder name + if the folder class does not have its 'CONTAINER_CLASS' set. + :param folder_name: :param folder_class: :param locale: a string, e.g. 'da_DK' """ for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS + MISC_FOLDERS: - if folder_cls.CONTAINER_CLASS == folder_class and folder_name.lower() in folder_cls.localized_names(locale): - return folder_cls + if folder_cls.CONTAINER_CLASS != folder_class: + continue + if folder_name.lower() not in folder_cls.localized_names(locale): + continue + return folder_cls raise KeyError() def __getstate__(self): @@ -9953,6 +9959,8 @@

      Static methods

      Return the folder class that matches a localized folder name. Take into account the 'folder_class' of the folder, to not identify an 'IPF.Note' folder as a 'Calendar' class just because it's called e.g. 'Kalender' and the locale is 'da_DK'.

      +

      Some folders, e.g. System, don't define a folder_class. For these folders, we match on localized folder name +if the folder class does not have its 'CONTAINER_CLASS' set.

      :param folder_name: :param folder_class: :param locale: a string, e.g. 'da_DK'

      @@ -9966,13 +9974,19 @@

      Static methods

      folder, to not identify an 'IPF.Note' folder as a 'Calendar' class just because it's called e.g. 'Kalender' and the locale is 'da_DK'. + Some folders, e.g. `System`, don't define a `folder_class`. For these folders, we match on localized folder name + if the folder class does not have its 'CONTAINER_CLASS' set. + :param folder_name: :param folder_class: :param locale: a string, e.g. 'da_DK' """ for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS + MISC_FOLDERS: - if folder_cls.CONTAINER_CLASS == folder_class and folder_name.lower() in folder_cls.localized_names(locale): - return folder_cls + if folder_cls.CONTAINER_CLASS != folder_class: + continue + if folder_name.lower() not in folder_cls.localized_names(locale): + continue + return folder_cls raise KeyError() diff --git a/docs/exchangelib/folders/roots.html b/docs/exchangelib/folders/roots.html index d723541e..c71428c8 100644 --- a/docs/exchangelib/folders/roots.html +++ b/docs/exchangelib/folders/roots.html @@ -231,13 +231,19 @@

      Module exchangelib.folders.roots

      folder, to not identify an 'IPF.Note' folder as a 'Calendar' class just because it's called e.g. 'Kalender' and the locale is 'da_DK'. + Some folders, e.g. `System`, don't define a `folder_class`. For these folders, we match on localized folder name + if the folder class does not have its 'CONTAINER_CLASS' set. + :param folder_name: :param folder_class: :param locale: a string, e.g. 'da_DK' """ for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS + MISC_FOLDERS: - if folder_cls.CONTAINER_CLASS == folder_class and folder_name.lower() in folder_cls.localized_names(locale): - return folder_cls + if folder_cls.CONTAINER_CLASS != folder_class: + continue + if folder_name.lower() not in folder_cls.localized_names(locale): + continue + return folder_cls raise KeyError() def __getstate__(self): @@ -980,13 +986,19 @@

      Inherited members

      folder, to not identify an 'IPF.Note' folder as a 'Calendar' class just because it's called e.g. 'Kalender' and the locale is 'da_DK'. + Some folders, e.g. `System`, don't define a `folder_class`. For these folders, we match on localized folder name + if the folder class does not have its 'CONTAINER_CLASS' set. + :param folder_name: :param folder_class: :param locale: a string, e.g. 'da_DK' """ for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS + MISC_FOLDERS: - if folder_cls.CONTAINER_CLASS == folder_class and folder_name.lower() in folder_cls.localized_names(locale): - return folder_cls + if folder_cls.CONTAINER_CLASS != folder_class: + continue + if folder_name.lower() not in folder_cls.localized_names(locale): + continue + return folder_cls raise KeyError() def __getstate__(self): @@ -1052,6 +1064,8 @@

      Static methods

      Return the folder class that matches a localized folder name. Take into account the 'folder_class' of the folder, to not identify an 'IPF.Note' folder as a 'Calendar' class just because it's called e.g. 'Kalender' and the locale is 'da_DK'.

      +

      Some folders, e.g. System, don't define a folder_class. For these folders, we match on localized folder name +if the folder class does not have its 'CONTAINER_CLASS' set.

      :param folder_name: :param folder_class: :param locale: a string, e.g. 'da_DK'

      @@ -1065,13 +1079,19 @@

      Static methods

      folder, to not identify an 'IPF.Note' folder as a 'Calendar' class just because it's called e.g. 'Kalender' and the locale is 'da_DK'. + Some folders, e.g. `System`, don't define a `folder_class`. For these folders, we match on localized folder name + if the folder class does not have its 'CONTAINER_CLASS' set. + :param folder_name: :param folder_class: :param locale: a string, e.g. 'da_DK' """ for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS + MISC_FOLDERS: - if folder_cls.CONTAINER_CLASS == folder_class and folder_name.lower() in folder_cls.localized_names(locale): - return folder_cls + if folder_cls.CONTAINER_CLASS != folder_class: + continue + if folder_name.lower() not in folder_cls.localized_names(locale): + continue + return folder_cls raise KeyError() diff --git a/docs/exchangelib/index.html b/docs/exchangelib/index.html index 10be932f..b4cc63ec 100644 --- a/docs/exchangelib/index.html +++ b/docs/exchangelib/index.html @@ -64,7 +64,7 @@

      Package exchangelib

      from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "5.0.0" +__version__ = "5.0.2" __all__ = [ "__version__", @@ -6990,7 +6990,7 @@

      Inherited members

      # # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. - if folder.name and folder.folder_class: + if folder.name: with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative folder_cls = root.folder_cls_from_folder_name( @@ -7103,7 +7103,7 @@

      Static methods

      # # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. - if folder.name and folder.folder_class: + if folder.name: with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative folder_cls = root.folder_cls_from_folder_name( @@ -11627,13 +11627,19 @@

      Inherited members

      folder, to not identify an 'IPF.Note' folder as a 'Calendar' class just because it's called e.g. 'Kalender' and the locale is 'da_DK'. + Some folders, e.g. `System`, don't define a `folder_class`. For these folders, we match on localized folder name + if the folder class does not have its 'CONTAINER_CLASS' set. + :param folder_name: :param folder_class: :param locale: a string, e.g. 'da_DK' """ for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS + MISC_FOLDERS: - if folder_cls.CONTAINER_CLASS == folder_class and folder_name.lower() in folder_cls.localized_names(locale): - return folder_cls + if folder_cls.CONTAINER_CLASS != folder_class: + continue + if folder_name.lower() not in folder_cls.localized_names(locale): + continue + return folder_cls raise KeyError() def __getstate__(self): @@ -11699,6 +11705,8 @@

      Static methods

      Return the folder class that matches a localized folder name. Take into account the 'folder_class' of the folder, to not identify an 'IPF.Note' folder as a 'Calendar' class just because it's called e.g. 'Kalender' and the locale is 'da_DK'.

      +

      Some folders, e.g. System, don't define a folder_class. For these folders, we match on localized folder name +if the folder class does not have its 'CONTAINER_CLASS' set.

      :param folder_name: :param folder_class: :param locale: a string, e.g. 'da_DK'

      @@ -11712,13 +11720,19 @@

      Static methods

      folder, to not identify an 'IPF.Note' folder as a 'Calendar' class just because it's called e.g. 'Kalender' and the locale is 'da_DK'. + Some folders, e.g. `System`, don't define a `folder_class`. For these folders, we match on localized folder name + if the folder class does not have its 'CONTAINER_CLASS' set. + :param folder_name: :param folder_class: :param locale: a string, e.g. 'da_DK' """ for folder_cls in cls.WELLKNOWN_FOLDERS + NON_DELETABLE_FOLDERS + MISC_FOLDERS: - if folder_cls.CONTAINER_CLASS == folder_class and folder_name.lower() in folder_cls.localized_names(locale): - return folder_cls + if folder_cls.CONTAINER_CLASS != folder_class: + continue + if folder_name.lower() not in folder_cls.localized_names(locale): + continue + return folder_cls raise KeyError() diff --git a/setup.py b/setup.py index 98a047be..58b1d821 100755 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ * Install pdoc3, wheel, twine * Bump version in exchangelib/__init__.py * Bump version in CHANGELOG.md -* Generate documentation: pdoc3 --html exchangelib -o docs --force +* Generate documentation: pdoc3 --html exchangelib -o docs --force && pre-commit run end-of-file-fixer * Commit and push changes * Build package: rm -rf build dist && python setup.py sdist bdist_wheel * Push to PyPI: twine upload dist/* From 9fadf7751df5c7af93cd7c41319dd3fc413a7089 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 13 Apr 2023 20:03:10 +0200 Subject: [PATCH 334/509] Bump version --- CHANGELOG.md | 5 +++++ exchangelib/__init__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 231d5a75..0f79f11a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ HEAD ---- +5.0.2 +----- +- Fix bug where certain folders were being assigned the wrong Python class. + + 5.0.1 ----- - Fix PyPI package. No source code changes. diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index 5ae7d8c5..f755e7cb 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -36,7 +36,7 @@ from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "5.0.1" +__version__ = "5.0.2" __all__ = [ "__version__", From 8f6ced514384d612aa9d14250dff4bade443a34b Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 13 Apr 2023 20:05:09 +0200 Subject: [PATCH 335/509] docs: clean up more --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 58b1d821..2c4846f1 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ * Bump version in CHANGELOG.md * Generate documentation: pdoc3 --html exchangelib -o docs --force && pre-commit run end-of-file-fixer * Commit and push changes -* Build package: rm -rf build dist && python setup.py sdist bdist_wheel +* Build package: rm -rf build dist exchangelib.egg-info && python setup.py sdist bdist_wheel * Push to PyPI: twine upload dist/* * Create release on GitHub """ From 23f10d5a67d91e4c2379df7a150bfa3d21e153d4 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 4 May 2023 21:47:15 +0200 Subject: [PATCH 336/509] fix: Let autodiscover survive not finding an auth type for the server it's attempting to cnnect to. Refs #1187 --- exchangelib/transport.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/exchangelib/transport.py b/exchangelib/transport.py index 50bf28a0..d7dd3bc5 100644 --- a/exchangelib/transport.py +++ b/exchangelib/transport.py @@ -117,9 +117,12 @@ def get_autodiscover_authtype(protocol): data = protocol.dummy_xml() headers = {"X-AnchorMailbox": "DUMMY@example.com"} # Required in case of OAuth r = get_unauthenticated_autodiscover_response(protocol=protocol, method="post", headers=headers, data=data) - auth_type = get_auth_method_from_response(response=r) - log.debug("Auth type is %s", auth_type) - return auth_type + try: + auth_type = get_auth_method_from_response(response=r) + log.debug("Auth type is %s", auth_type) + return auth_type + except UnauthorizedError: + raise TransportError("Failed to get autodiscover auth type from service") def get_unauthenticated_autodiscover_response(protocol, method, headers=None, data=None): From f1f3dbebfa3506e07ef06c6392a462bf51cb6d4a Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 4 May 2023 23:52:49 +0200 Subject: [PATCH 337/509] fix: Remove UniqueBody from Contact, Task and Persona classes. O365 throws errors on it. --- exchangelib/items/contact.py | 7 +++++++ exchangelib/items/task.py | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/exchangelib/items/contact.py b/exchangelib/items/contact.py index 2cfdedc4..5a182f1e 100644 --- a/exchangelib/items/contact.py +++ b/exchangelib/items/contact.py @@ -134,6 +134,9 @@ class Contact(Item): direct_reports = MailboxListField( field_uri="contacts:DirectReports", supported_from=EXCHANGE_2010_SP2, is_read_only=True ) + # O365 throws ErrorInternalServerError "[0x004f0102] MapiReplyToBlob" if UniqueBody is requested + unique_body_idx = Item.FIELDS.index_by_name("unique_body") + FIELDS = Item.FIELDS[:unique_body_idx] + Item.FIELDS[unique_body_idx + 1 :] class Persona(IdChangeKeyMixIn): @@ -253,3 +256,7 @@ class DistributionList(Item): field_uri="contacts:ContactSource", choices={Choice("Store"), Choice("ActiveDirectory")}, is_read_only=True ) members = MemberListField(field_uri="distributionlist:Members") + + # O365 throws ErrorInternalServerError "[0x004f0102] MapiReplyToBlob" if UniqueBody is requested + unique_body_idx = Item.FIELDS.index_by_name("unique_body") + FIELDS = Item.FIELDS[:unique_body_idx] + Item.FIELDS[unique_body_idx + 1 :] diff --git a/exchangelib/items/task.py b/exchangelib/items/task.py index f0d0efd2..335d10bc 100644 --- a/exchangelib/items/task.py +++ b/exchangelib/items/task.py @@ -82,6 +82,10 @@ class Task(Item): status_description = CharField(field_uri="task:StatusDescription", is_read_only=True) total_work = IntegerField(field_uri="task:TotalWork", min=0) + # O365 throws ErrorInternalServerError "[0x004f0102] MapiReplyToBlob" if UniqueBody is requested + unique_body_idx = Item.FIELDS.index_by_name("unique_body") + FIELDS = Item.FIELDS[:unique_body_idx] + Item.FIELDS[unique_body_idx + 1 :] + def clean(self, version=None): super().clean(version=version) if self.due_date and self.start_date and self.due_date < self.start_date: From 3685ff816dd3102e3e797fe2214c4d8f067f1999 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 4 May 2023 23:54:20 +0200 Subject: [PATCH 338/509] fix: Fix places where we didn't respect the folder or item type when requesting an item with all fields --- exchangelib/items/item.py | 5 +---- exchangelib/queryset.py | 7 ++++++- tests/test_items/test_basics.py | 20 ++++++++++---------- tests/test_items/test_tasks.py | 10 +++++----- 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/exchangelib/items/item.py b/exchangelib/items/item.py index 50fe3987..f439b746 100644 --- a/exchangelib/items/item.py +++ b/exchangelib/items/item.py @@ -239,12 +239,9 @@ def _update(self, update_fieldnames, message_disposition, conflict_resolution, s @require_id def refresh(self): # Updates the item based on fresh data from EWS - from ..folders import Folder from ..services import GetItem - additional_fields = { - FieldPath(field=f) for f in Folder(root=self.account.root).allowed_item_fields(version=self.account.version) - } + additional_fields = {FieldPath(field=f) for f in self.supported_fields(version=self.account.version)} res = GetItem(account=self.account).get(items=[self], additional_fields=additional_fields, shape=ID_ONLY) if self.id != res.id and not isinstance(self._id, (OccurrenceItemId, RecurringMasterItemId)): # When we refresh an item with an OccurrenceItemId as ID, EWS returns the ID of the occurrence, so diff --git a/exchangelib/queryset.py b/exchangelib/queryset.py index e331584d..18047966 100644 --- a/exchangelib/queryset.py +++ b/exchangelib/queryset.py @@ -516,7 +516,12 @@ def get(self, *args, **kwargs): account = self.folder_collection.account item_id = self._id_field.field.clean(kwargs["id"], version=account.version) changekey = self._changekey_field.field.clean(kwargs.get("changekey"), version=account.version) - items = list(account.fetch(ids=[(item_id, changekey)], only_fields=self.only_fields)) + # The folders we're querying may not support all fields + if self.only_fields is None: + only_fields = {FieldPath(field=f) for f in self.folder_collection.allowed_item_fields()} + else: + only_fields = self.only_fields + items = list(account.fetch(ids=[(item_id, changekey)], only_fields=only_fields)) else: new_qs = self.filter(*args, **kwargs) items = list(new_qs.__iter__()) diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index 6bf22dde..6b24bc6d 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -242,9 +242,9 @@ def get_test_item(self, folder=None, categories=None): def get_test_folder(self, folder=None): return self.FOLDER_CLASS(parent=folder or self.test_folder, name=get_random_string(8)) - def get_item_by_id(self, item): + def get_item_by_id(self, item, folder=None): _id, changekey = item if isinstance(item, tuple) else (item.id, item.changekey) - return self.account.root.get(id=_id, changekey=changekey) + return (folder or self.account.root).get(id=_id, changekey=changekey) class CommonItemTest(BaseItemTest): @@ -530,7 +530,7 @@ def test_save_and_delete(self): for k, v in insert_kwargs.items(): self.assertEqual(getattr(item, k), v, (k, getattr(item, k), v)) # Test that whatever we have locally also matches whatever is in the DB - fresh_item = self.get_item_by_id(item) + fresh_item = self.get_item_by_id(item, folder=self.test_folder) for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): old, new = getattr(item, f.name), getattr(fresh_item, f.name) @@ -555,7 +555,7 @@ def test_save_and_delete(self): for k, v in update_kwargs.items(): self.assertEqual(getattr(item, k), v, (k, getattr(item, k), v)) # Test that whatever we have locally also matches whatever is in the DB - fresh_item = self.get_item_by_id(item) + fresh_item = self.get_item_by_id(item, folder=self.test_folder) for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): old, new = getattr(item, f.name), getattr(fresh_item, f.name) @@ -594,7 +594,7 @@ def test_save_and_delete(self): # Hard delete item_id = (item.id, item.changekey) item.delete() - for e in self.account.fetch(ids=[item_id]): + for e in self.account.fetch(ids=[item_id], folder=self.test_folder): # It's gone from the account self.assertIsInstance(e, ErrorItemNotFound) # Really gone, not just changed ItemId @@ -615,7 +615,7 @@ def test_item_insert(self): self.assertEqual(len(find_ids[0]), 2, find_ids[0]) self.assertEqual(insert_ids, find_ids) # Test with generator as argument - item = list(self.account.fetch(ids=(i for i in find_ids)))[0] + item = list(self.account.fetch(ids=(i for i in find_ids), folder=self.test_folder))[0] for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): if not f.supports_version(self.account.version): @@ -655,7 +655,7 @@ def test_item_update(self): self.assertEqual(len(update_ids[0]), 2, update_ids) self.assertEqual(item.id, update_ids[0][0]) # ID should be the same self.assertNotEqual(item.changekey, update_ids[0][1]) # Changekey should change when item is updated - item = self.get_item_by_id(update_ids[0]) + item = self.get_item_by_id(update_ids[0], folder=self.test_folder) for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): if not f.supports_version(self.account.version): @@ -730,7 +730,7 @@ def test_item_update_wipe(self): self.assertEqual(len(wipe_ids[0]), 2, wipe_ids) self.assertEqual(item.id, wipe_ids[0][0]) # ID should be the same self.assertNotEqual(item.changekey, wipe_ids[0][1]) # Changekey should not be the same when item is updated - item = self.get_item_by_id(wipe_ids[0]) + item = self.get_item_by_id(wipe_ids[0], folder=self.test_folder) for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): if not f.supports_version(self.account.version): @@ -750,7 +750,7 @@ def test_item_update_wipe(self): def test_item_update_extended_properties(self): item = self.get_test_item().save() - item = self.get_item_by_id(item) # An Item saved in Inbox becomes a Message + item = self.get_item_by_id(item, folder=self.test_folder) # An Item saved in Inbox becomes a Message item.__class__.register("extern_id", ExternId) try: @@ -762,7 +762,7 @@ def test_item_update_extended_properties(self): self.assertEqual(len(wipe2_ids[0]), 2, wipe2_ids) self.assertEqual(item.id, wipe2_ids[0][0]) # ID must be the same self.assertNotEqual(item.changekey, wipe2_ids[0][1]) # Changekey must change when item is updated - updated_item = self.get_item_by_id(wipe2_ids[0]) + updated_item = self.get_item_by_id(wipe2_ids[0], folder=self.test_folder) self.assertEqual(updated_item.extern_id, extern_id) finally: item.__class__.deregister("extern_id") diff --git a/tests/test_items/test_tasks.py b/tests/test_items/test_tasks.py index 898db066..d4ba3720 100644 --- a/tests/test_items/test_tasks.py +++ b/tests/test_items/test_tasks.py @@ -76,7 +76,7 @@ def test_recurring_item(self): self.assertEqual(self.test_folder.filter(categories__contains=self.categories).count(), 1) # Change the start date. We should see a new task appear. - master_item = self.get_item_by_id((master_item_id, None)) + master_item = self.get_item_by_id((master_item_id, None), folder=self.test_folder) master_item.recurrence.boundary.start = datetime.date(2016, 2, 1) occurrence_item = master_item.save() occurrence_item.refresh() @@ -84,7 +84,7 @@ def test_recurring_item(self): self.assertEqual(self.test_folder.filter(categories__contains=self.categories).count(), 2) # Check fields on the recurring item - master_item = self.get_item_by_id((master_item_id, None)) + master_item = self.get_item_by_id((master_item_id, None), folder=self.test_folder) self.assertEqual(master_item.change_count, 2) self.assertEqual(master_item.due_date, datetime.date(2016, 1, 2)) # This is the next occurrence self.assertEqual(master_item.recurrence.boundary.number, 3) # One less @@ -97,7 +97,7 @@ def test_recurring_item(self): self.assertEqual(self.test_folder.filter(categories__contains=self.categories).count(), 3) # Check fields on the recurring item - master_item = self.get_item_by_id((master_item_id, None)) + master_item = self.get_item_by_id((master_item_id, None), folder=self.test_folder) self.assertEqual(master_item.change_count, 3) self.assertEqual(master_item.due_date, datetime.date(2016, 2, 1)) # This is the next occurrence self.assertEqual(master_item.recurrence.boundary.number, 2) # One less @@ -118,7 +118,7 @@ def test_recurring_item(self): self.assertEqual(self.test_folder.filter(categories__contains=self.categories).count(), 1) # Change the start date. We should *not* see a new task appear. - master_item = self.get_item_by_id((master_item_id, None)) + master_item = self.get_item_by_id((master_item_id, None), folder=self.test_folder) master_item.recurrence.boundary.start = datetime.date(2016, 1, 2) occurrence_item = master_item.save() occurrence_item.refresh() @@ -133,7 +133,7 @@ def test_recurring_item(self): self.assertEqual(self.test_folder.filter(categories__contains=self.categories).count(), 2) # Check fields on the recurring item - master_item = self.get_item_by_id((master_item_id, None)) + master_item = self.get_item_by_id((master_item_id, None), folder=self.test_folder) self.assertEqual(master_item.change_count, 2) # The due date is the next occurrence after today tz = self.account.default_timezone From d2815e69b83bbbf7aa455b5dd87fe3a0a9a7d209 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Fri, 5 May 2023 00:25:32 +0200 Subject: [PATCH 339/509] chore: Add 'Asia/Hanoi' to satisfy test on GitHub --- exchangelib/winzone.py | 1 + 1 file changed, 1 insertion(+) diff --git a/exchangelib/winzone.py b/exchangelib/winzone.py index 0e5f5945..4b2d3789 100644 --- a/exchangelib/winzone.py +++ b/exchangelib/winzone.py @@ -533,6 +533,7 @@ def generate_map(timeout=10): "Asia/Chongqing": CLDR_TO_MS_TIMEZONE_MAP["Asia/Shanghai"], "Asia/Chungking": CLDR_TO_MS_TIMEZONE_MAP["Asia/Shanghai"], "Asia/Dacca": CLDR_TO_MS_TIMEZONE_MAP["Asia/Dhaka"], + "Asia/Hanoi": CLDR_TO_MS_TIMEZONE_MAP["Asia/Saigon"], "Asia/Harbin": CLDR_TO_MS_TIMEZONE_MAP["Asia/Shanghai"], "Asia/Ho_Chi_Minh": CLDR_TO_MS_TIMEZONE_MAP["Asia/Saigon"], "Asia/Istanbul": CLDR_TO_MS_TIMEZONE_MAP["Europe/Istanbul"], From cb3622d5704e9505411bbdaedec3acc1f7bec3c0 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 11 May 2023 19:56:03 +0200 Subject: [PATCH 340/509] docs: Rebuild docs --- .../autodiscover/discovery/base.html | 1038 -------- .../autodiscover/discovery/index.html | 75 - .../autodiscover/discovery/pox.html | 483 ---- .../autodiscover/discovery/soap.html | 327 --- docs/exchangelib/autodiscover/properties.html | 2172 ----------------- docs/exchangelib/index.html | 32 +- docs/exchangelib/items/contact.html | 32 +- docs/exchangelib/items/index.html | 40 +- docs/exchangelib/items/item.html | 15 +- docs/exchangelib/items/task.html | 15 +- docs/exchangelib/queryset.html | 21 +- docs/exchangelib/transport.html | 18 +- docs/exchangelib/winzone.html | 1 + 13 files changed, 135 insertions(+), 4134 deletions(-) delete mode 100644 docs/exchangelib/autodiscover/discovery/base.html delete mode 100644 docs/exchangelib/autodiscover/discovery/index.html delete mode 100644 docs/exchangelib/autodiscover/discovery/pox.html delete mode 100644 docs/exchangelib/autodiscover/discovery/soap.html delete mode 100644 docs/exchangelib/autodiscover/properties.html diff --git a/docs/exchangelib/autodiscover/discovery/base.html b/docs/exchangelib/autodiscover/discovery/base.html deleted file mode 100644 index f129e941..00000000 --- a/docs/exchangelib/autodiscover/discovery/base.html +++ /dev/null @@ -1,1038 +0,0 @@ - - - - - - -exchangelib.autodiscover.discovery.base API documentation - - - - - - - - - - - -
      -
      -
      -

      Module exchangelib.autodiscover.discovery.base

      -
      -
      -
      - -Expand source code - -
      import abc
      -import logging
      -from urllib.parse import urlparse
      -
      -import dns.name
      -import dns.resolver
      -from cached_property import threaded_cached_property
      -
      -from ...errors import AutoDiscoverCircularRedirect, AutoDiscoverFailed, TransportError
      -from ...protocol import FailFast
      -from ...util import DummyResponse, get_domain, get_redirect_url
      -from ..cache import autodiscover_cache
      -from ..protocol import AutodiscoverProtocol
      -
      -log = logging.getLogger(__name__)
      -
      -DNS_LOOKUP_ERRORS = (
      -    dns.name.EmptyLabel,
      -    dns.resolver.NXDOMAIN,
      -    dns.resolver.NoAnswer,
      -    dns.resolver.NoNameservers,
      -)
      -
      -
      -class SrvRecord:
      -    """A container for autodiscover-related SRV records in DNS."""
      -
      -    def __init__(self, priority, weight, port, srv):
      -        self.priority = priority
      -        self.weight = weight
      -        self.port = port
      -        self.srv = srv
      -
      -    def __eq__(self, other):
      -        return all(getattr(self, k) == getattr(other, k) for k in self.__dict__)
      -
      -
      -class BaseAutodiscovery(metaclass=abc.ABCMeta):
      -    """Autodiscover is a Microsoft protocol for automatically getting the endpoint of the Exchange server and other
      -    connection-related settings holding the email address using only the email address, and username and password of the
      -    user.
      -
      -    For a description of the protocol implemented, see "Autodiscover for Exchange ActiveSync developers":
      -
      -    https://docs.microsoft.com/en-us/previous-versions/office/developer/exchange-server-interoperability-guidance/hh352638%28v%3dexchg.140%29
      -
      -    Descriptions of the steps from the article are provided in their respective methods in this class.
      -
      -    For a description of how to handle autodiscover error messages, see:
      -
      -    https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/handling-autodiscover-error-messages
      -
      -    A tip from the article:
      -    The client can perform steps 1 through 4 in any order or in parallel to expedite the process, but it must wait for
      -    responses to finish at each step before proceeding. Given that many organizations prefer to use the URL in step 2 to
      -    set up the Autodiscover service, the client might try this step first.
      -
      -    Another possibly newer resource which has not yet been attempted is "Outlook 2016 Implementation of Autodiscover":
      -    https://support.microsoft.com/en-us/help/3211279/outlook-2016-implementation-of-autodiscover
      -
      -    WARNING: The autodiscover protocol is very complicated. If you have problems autodiscovering using this
      -    implementation, start by doing an official test at https://testconnectivity.microsoft.com
      -    """
      -
      -    # When connecting to servers that may not be serving the correct endpoint, we should use a retry policy that does
      -    # not leave us hanging for a long time on each step in the protocol.
      -    INITIAL_RETRY_POLICY = FailFast()
      -    RETRY_WAIT = 10  # Seconds to wait before retry on connection errors
      -    MAX_REDIRECTS = 10  # Maximum number of URL redirects before we give up
      -    DNS_RESOLVER_KWARGS = {}
      -    DNS_RESOLVER_ATTRS = {
      -        "timeout": AutodiscoverProtocol.TIMEOUT / 2.5,  # Timeout for query to a single nameserver
      -    }
      -    DNS_RESOLVER_LIFETIME = AutodiscoverProtocol.TIMEOUT  # Total timeout for a query in case of multiple nameservers
      -    URL_PATH = None
      -
      -    def __init__(self, email, credentials=None):
      -        """
      -
      -        :param email: The email address to autodiscover
      -        :param credentials: Credentials with authorization to make autodiscover lookups for this Account
      -            (Default value = None)
      -        """
      -        self.email = email
      -        self.credentials = credentials
      -        self._urls_visited = []  # Collects HTTP and Autodiscover redirects
      -        self._redirect_count = 0
      -        self._emails_visited = []  # Collects Autodiscover email redirects
      -
      -    def discover(self):
      -        self._emails_visited.append(self.email.lower())
      -
      -        # Check the autodiscover cache to see if we already know the autodiscover service endpoint for this email
      -        # domain. Use a lock to guard against multiple threads competing to cache information.
      -        log.debug("Waiting for autodiscover_cache lock")
      -        with autodiscover_cache:
      -            log.debug("autodiscover_cache lock acquired")
      -            cache_key = self._cache_key
      -            domain = get_domain(self.email)
      -            if cache_key in autodiscover_cache:
      -                ad_protocol = autodiscover_cache[cache_key]
      -                log.debug("Cache hit for key %s: %s", cache_key, ad_protocol.service_endpoint)
      -                try:
      -                    ad = self._quick(protocol=ad_protocol)
      -                except AutoDiscoverFailed:
      -                    # Autodiscover no longer works with this domain. Clear cache and try again after releasing the lock
      -                    log.debug("AD request failure. Removing cache for key %s", cache_key)
      -                    del autodiscover_cache[cache_key]
      -                    ad = self._step_1(hostname=domain)
      -            else:
      -                # This will cache the result
      -                log.debug("Cache miss for key %s", cache_key)
      -                ad = self._step_1(hostname=domain)
      -
      -        log.debug("Released autodiscover_cache_lock")
      -        if ad.redirect_address:
      -            log.debug("Got a redirect address: %s", ad.redirect_address)
      -            if ad.redirect_address.lower() in self._emails_visited:
      -                raise AutoDiscoverCircularRedirect("We were redirected to an email address we have already seen")
      -
      -            # Start over, but with the new email address
      -            self.email = ad.redirect_address
      -            return self.discover()
      -
      -        # We successfully received a response. Clear the cache of seen emails etc.
      -        self.clear()
      -        return self._build_response(ad_response=ad)
      -
      -    def clear(self):
      -        # This resets cached variables
      -        self._urls_visited = []
      -        self._redirect_count = 0
      -        self._emails_visited = []
      -
      -    @property
      -    def _cache_key(self):
      -        # We may be using multiple different credentials and changing our minds on TLS verification. This key
      -        # combination should be safe for caching.
      -        domain = get_domain(self.email)
      -        return domain, self.credentials
      -
      -    @threaded_cached_property
      -    def resolver(self):
      -        resolver = dns.resolver.Resolver(**self.DNS_RESOLVER_KWARGS)
      -        for k, v in self.DNS_RESOLVER_ATTRS.items():
      -            setattr(resolver, k, v)
      -        return resolver
      -
      -    @abc.abstractmethod
      -    def _build_response(self, ad_response):
      -        pass
      -
      -    @abc.abstractmethod
      -    def _quick(self, protocol):
      -        pass
      -
      -    def _redirect_url_is_valid(self, url):
      -        """Three separate responses can be “Redirect responses”:
      -        * An HTTP status code (301, 302) with a new URL
      -        * An HTTP status code of 200, but with a payload XML containing a redirect to a different URL
      -        * An HTTP status code of 200, but with a payload XML containing a different SMTP address as the target address
      -
      -        We only handle the HTTP 302 redirects here. We validate the URL received in the redirect response to ensure that
      -        it does not redirect to non-SSL endpoints or SSL endpoints with invalid certificates, and that the redirect is
      -        not circular. Finally, we should fail after 10 redirects.
      -
      -        :param url:
      -        :return:
      -        """
      -        if url.lower() in self._urls_visited:
      -            log.warning("We have already tried this URL: %s", url)
      -            return False
      -
      -        if self._redirect_count >= self.MAX_REDIRECTS:
      -            log.warning("We reached max redirects at URL: %s", url)
      -            return False
      -
      -        # We require TLS endpoints
      -        if not url.startswith("https://"):
      -            log.debug("Invalid scheme for URL: %s", url)
      -            return False
      -
      -        # Quick test that the endpoint responds and that TLS handshake is OK
      -        try:
      -            self._get_unauthenticated_response(url, method="head")
      -        except TransportError as e:
      -            log.debug("Response error on redirect URL %s: %s", url, e)
      -            return False
      -
      -        self._redirect_count += 1
      -        return True
      -
      -    @abc.abstractmethod
      -    def _get_unauthenticated_response(self, url, method="post"):
      -        pass
      -
      -    @abc.abstractmethod
      -    def _attempt_response(self, url):
      -        pass
      -
      -    def _ensure_valid_hostname(self, url):
      -        hostname = urlparse(url).netloc
      -        log.debug("Checking if %s can be looked up in DNS", hostname)
      -        try:
      -            self.resolver.resolve(f"{hostname}.", "A", lifetime=self.DNS_RESOLVER_LIFETIME)
      -        except DNS_LOOKUP_ERRORS as e:
      -            log.debug("DNS A lookup failure: %s", e)
      -            # 'requests' is bad at reporting that a hostname cannot be resolved. Let's check this separately.
      -            # Don't retry on DNS errors. They will most likely be persistent.
      -            raise TransportError(f"{hostname!r} has no DNS entry")
      -
      -    def _get_srv_records(self, hostname):
      -        """Send a DNS query for SRV entries for the hostname.
      -
      -        An SRV entry that has been formatted for autodiscovery will have the following format:
      -
      -            canonical name = mail.example.com.
      -            service = 8 100 443 webmail.example.com.
      -
      -        The first three numbers in the service line are: priority, weight, port
      -
      -        :param hostname:
      -        :return:
      -        """
      -        log.debug("Attempting to get SRV records for %s", hostname)
      -        records = []
      -        try:
      -            answers = self.resolver.resolve(f"{hostname}.", "SRV", lifetime=self.DNS_RESOLVER_LIFETIME)
      -        except DNS_LOOKUP_ERRORS as e:
      -            log.debug("DNS SRV lookup failure: %s", e)
      -            return records
      -        for rdata in answers:
      -            try:
      -                vals = rdata.to_text().strip().rstrip(".").split(" ")
      -                # Raise ValueError if the first three are not ints, and IndexError if there are less than 4 values
      -                priority, weight, port, srv = int(vals[0]), int(vals[1]), int(vals[2]), vals[3]
      -                record = SrvRecord(priority=priority, weight=weight, port=port, srv=srv)
      -                log.debug("Found SRV record %s ", record)
      -                records.append(record)
      -            except (ValueError, IndexError):
      -                log.debug("Incompatible SRV record for %s (%s)", hostname, rdata.to_text())
      -        return records
      -
      -    def _step_1(self, hostname):
      -        """Perform step 1, where the client sends an Autodiscover request to
      -        https://example.com/ and then does one of the following:
      -            * If the Autodiscover attempt succeeds, the client proceeds to step 5.
      -            * If the Autodiscover attempt fails, the client proceeds to step 2.
      -
      -        :param hostname:
      -        :return:
      -        """
      -        url = f"https://{hostname}/{self.URL_PATH}"
      -        log.info("Step 1: Trying autodiscover on %r with email %r", url, self.email)
      -        is_valid_response, ad = self._attempt_response(url=url)
      -        if is_valid_response:
      -            return self._step_5(ad=ad)
      -        return self._step_2(hostname=hostname)
      -
      -    def _step_2(self, hostname):
      -        """Perform step 2, where the client sends an Autodiscover request to
      -        https://autodiscover.example.com/ and then does one of the following:
      -            * If the Autodiscover attempt succeeds, the client proceeds to step 5.
      -            * If the Autodiscover attempt fails, the client proceeds to step 3.
      -
      -        :param hostname:
      -        :return:
      -        """
      -        url = f"https://autodiscover.{hostname}/{self.URL_PATH}"
      -        log.info("Step 2: Trying autodiscover on %r with email %r", url, self.email)
      -        is_valid_response, ad = self._attempt_response(url=url)
      -        if is_valid_response:
      -            return self._step_5(ad=ad)
      -        return self._step_3(hostname=hostname)
      -
      -    def _step_3(self, hostname):
      -        """Perform step 3, where the client sends an unauthenticated GET method request to
      -        http://autodiscover.example.com/ (Note that this is a non-HTTPS endpoint). The
      -        client then does one of the following:
      -            * If the GET request returns a 302 redirect response, it gets the redirection URL from the 'Location' HTTP
      -            header and validates it as described in the "Redirect responses" section. The client then does one of the
      -            following:
      -                * If the redirection URL is valid, the client tries the URL and then does one of the following:
      -                    * If the attempt succeeds, the client proceeds to step 5.
      -                    * If the attempt fails, the client proceeds to step 4.
      -                * If the redirection URL is not valid, the client proceeds to step 4.
      -            * If the GET request does not return a 302 redirect response, the client proceeds to step 4.
      -
      -        :param hostname:
      -        :return:
      -        """
      -        url = f"http://autodiscover.{hostname}/{self.URL_PATH}"
      -        log.info("Step 3: Trying autodiscover on %r with email %r", url, self.email)
      -        try:
      -            _, r = self._get_unauthenticated_response(url=url, method="get")
      -        except TransportError:
      -            r = DummyResponse(url=url)
      -        if r.status_code in (301, 302) and "location" in r.headers:
      -            redirect_url = get_redirect_url(r)
      -            if self._redirect_url_is_valid(url=redirect_url):
      -                is_valid_response, ad = self._attempt_response(url=redirect_url)
      -                if is_valid_response:
      -                    return self._step_5(ad=ad)
      -                log.debug("Got invalid response")
      -                return self._step_4(hostname=hostname)
      -            log.debug("Got invalid redirect URL")
      -            return self._step_4(hostname=hostname)
      -        log.debug("Got no redirect URL")
      -        return self._step_4(hostname=hostname)
      -
      -    def _step_4(self, hostname):
      -        """Perform step 4, where the client performs a Domain Name System (DNS) query for an SRV record for
      -        _autodiscover._tcp.example.com. The query might return multiple records. The client selects only records that
      -        point to an SSL endpoint and that have the highest priority and weight. One of the following actions then
      -        occurs:
      -            * If no such records are returned, the client proceeds to step 6.
      -            * If records are returned, the application randomly chooses a record in the list and validates the endpoint
      -              that it points to by following the process described in the "Redirect Response" section. The client then
      -              does one of the following:
      -                * If the redirection URL is valid, the client tries the URL and then does one of the following:
      -                    * If the attempt succeeds, the client proceeds to step 5.
      -                    * If the attempt fails, the client proceeds to step 6.
      -                * If the redirection URL is not valid, the client proceeds to step 6.
      -
      -        :param hostname:
      -        :return:
      -        """
      -        dns_hostname = f"_autodiscover._tcp.{hostname}"
      -        log.info("Step 4: Trying autodiscover on %r with email %r", dns_hostname, self.email)
      -        srv_records = self._get_srv_records(dns_hostname)
      -        try:
      -            srv_host = _select_srv_host(srv_records)
      -        except ValueError:
      -            srv_host = None
      -        if not srv_host:
      -            return self._step_6()
      -        redirect_url = f"https://{srv_host}/{self.URL_PATH}"
      -        if self._redirect_url_is_valid(url=redirect_url):
      -            is_valid_response, ad = self._attempt_response(url=redirect_url)
      -            if is_valid_response:
      -                return self._step_5(ad=ad)
      -            log.debug("Got invalid response")
      -            return self._step_6()
      -        log.debug("Got invalid redirect URL")
      -        return self._step_6()
      -
      -    def _step_5(self, ad):
      -        """Perform step 5. When a valid Autodiscover request succeeds, the following sequence occurs:
      -            * If the server responds with an HTTP 302 redirect, the client validates the redirection URL according to
      -              the process defined in the "Redirect responses" and then does one of the following:
      -                * If the redirection URL is valid, the client tries the URL and then does one of the following:
      -                    * If the attempt succeeds, the client repeats step 5 from the beginning.
      -                    * If the attempt fails, the client proceeds to step 6.
      -                * If the redirection URL is not valid, the client proceeds to step 6.
      -            * If the server responds with a valid Autodiscover response, the client does one of the following:
      -                * If the value of the Action element is "Redirect", the client gets the redirection email address from
      -                  the Redirect element and then returns to step 1, using this new email address.
      -                * If the value of the Action element is "Settings", the client has successfully received the requested
      -                  configuration settings for the specified user. The client does not need to proceed to step 6.
      -
      -        :param ad:
      -        :return:
      -        """
      -        log.info("Step 5: Checking response")
      -        # This is not explicit in the protocol, but let's raise any errors here
      -        ad.raise_errors()
      -
      -        if hasattr(ad, "response"):
      -            # Hack for PoxAutodiscover
      -            ad = ad.response
      -
      -        if ad.redirect_url:
      -            log.debug("Got a redirect URL: %s", ad.redirect_url)
      -            # We are diverging a bit from the protocol here. We will never get an HTTP 302 since earlier steps already
      -            # followed the redirects where possible. Instead, we handle redirect responses here.
      -            if self._redirect_url_is_valid(url=ad.redirect_url):
      -                is_valid_response, ad = self._attempt_response(url=ad.redirect_url)
      -                if is_valid_response:
      -                    return self._step_5(ad=ad)
      -                log.debug("Got invalid response")
      -                return self._step_6()
      -            log.debug("Invalid redirect URL")
      -            return self._step_6()
      -        # This could be an email redirect. Let outer layer handle this
      -        return ad
      -
      -    def _step_6(self):
      -        """Perform step 6. If the client cannot contact the Autodiscover service, the client should ask the user for
      -        the Exchange server name and use it to construct an Exchange EWS URL. The client should try to use this URL for
      -        future requests.
      -        """
      -        raise AutoDiscoverFailed(
      -            f"All steps in the autodiscover protocol failed for email {self.email}. If you think this is an error, "
      -            f"consider doing an official test at https://testconnectivity.microsoft.com"
      -        )
      -
      -
      -def _select_srv_host(srv_records):
      -    """Select the record with the highest priority, that also supports TLS.
      -
      -    :param srv_records:
      -    :return:
      -    """
      -    best_record = None
      -    for srv_record in srv_records:
      -        if srv_record.port != 443:
      -            log.debug("Skipping SRV record %r (no TLS)", srv_record)
      -            continue
      -        # Assume port 443 will serve TLS. If not, autodiscover will probably also be broken for others.
      -        if best_record is None or best_record.priority < srv_record.priority:
      -            best_record = srv_record
      -    if not best_record:
      -        raise ValueError("No suitable records")
      -    return best_record.srv
      -
      -
      -
      -
      -
      -
      -
      -
      -
      -

      Classes

      -
      -
      -class BaseAutodiscovery -(email, credentials=None) -
      -
      -

      Autodiscover is a Microsoft protocol for automatically getting the endpoint of the Exchange server and other -connection-related settings holding the email address using only the email address, and username and password of the -user.

      -

      For a description of the protocol implemented, see "Autodiscover for Exchange ActiveSync developers":

      -

      https://docs.microsoft.com/en-us/previous-versions/office/developer/exchange-server-interoperability-guidance/hh352638%28v%3dexchg.140%29

      -

      Descriptions of the steps from the article are provided in their respective methods in this class.

      -

      For a description of how to handle autodiscover error messages, see:

      -

      https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/handling-autodiscover-error-messages

      -

      A tip from the article: -The client can perform steps 1 through 4 in any order or in parallel to expedite the process, but it must wait for -responses to finish at each step before proceeding. Given that many organizations prefer to use the URL in step 2 to -set up the Autodiscover service, the client might try this step first.

      -

      Another possibly newer resource which has not yet been attempted is "Outlook 2016 Implementation of Autodiscover": -https://support.microsoft.com/en-us/help/3211279/outlook-2016-implementation-of-autodiscover

      -

      WARNING: The autodiscover protocol is very complicated. If you have problems autodiscovering using this -implementation, start by doing an official test at https://testconnectivity.microsoft.com

      -

      :param email: The email address to autodiscover -:param credentials: Credentials with authorization to make autodiscover lookups for this Account -(Default value = None)

      -
      - -Expand source code - -
      class BaseAutodiscovery(metaclass=abc.ABCMeta):
      -    """Autodiscover is a Microsoft protocol for automatically getting the endpoint of the Exchange server and other
      -    connection-related settings holding the email address using only the email address, and username and password of the
      -    user.
      -
      -    For a description of the protocol implemented, see "Autodiscover for Exchange ActiveSync developers":
      -
      -    https://docs.microsoft.com/en-us/previous-versions/office/developer/exchange-server-interoperability-guidance/hh352638%28v%3dexchg.140%29
      -
      -    Descriptions of the steps from the article are provided in their respective methods in this class.
      -
      -    For a description of how to handle autodiscover error messages, see:
      -
      -    https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/handling-autodiscover-error-messages
      -
      -    A tip from the article:
      -    The client can perform steps 1 through 4 in any order or in parallel to expedite the process, but it must wait for
      -    responses to finish at each step before proceeding. Given that many organizations prefer to use the URL in step 2 to
      -    set up the Autodiscover service, the client might try this step first.
      -
      -    Another possibly newer resource which has not yet been attempted is "Outlook 2016 Implementation of Autodiscover":
      -    https://support.microsoft.com/en-us/help/3211279/outlook-2016-implementation-of-autodiscover
      -
      -    WARNING: The autodiscover protocol is very complicated. If you have problems autodiscovering using this
      -    implementation, start by doing an official test at https://testconnectivity.microsoft.com
      -    """
      -
      -    # When connecting to servers that may not be serving the correct endpoint, we should use a retry policy that does
      -    # not leave us hanging for a long time on each step in the protocol.
      -    INITIAL_RETRY_POLICY = FailFast()
      -    RETRY_WAIT = 10  # Seconds to wait before retry on connection errors
      -    MAX_REDIRECTS = 10  # Maximum number of URL redirects before we give up
      -    DNS_RESOLVER_KWARGS = {}
      -    DNS_RESOLVER_ATTRS = {
      -        "timeout": AutodiscoverProtocol.TIMEOUT / 2.5,  # Timeout for query to a single nameserver
      -    }
      -    DNS_RESOLVER_LIFETIME = AutodiscoverProtocol.TIMEOUT  # Total timeout for a query in case of multiple nameservers
      -    URL_PATH = None
      -
      -    def __init__(self, email, credentials=None):
      -        """
      -
      -        :param email: The email address to autodiscover
      -        :param credentials: Credentials with authorization to make autodiscover lookups for this Account
      -            (Default value = None)
      -        """
      -        self.email = email
      -        self.credentials = credentials
      -        self._urls_visited = []  # Collects HTTP and Autodiscover redirects
      -        self._redirect_count = 0
      -        self._emails_visited = []  # Collects Autodiscover email redirects
      -
      -    def discover(self):
      -        self._emails_visited.append(self.email.lower())
      -
      -        # Check the autodiscover cache to see if we already know the autodiscover service endpoint for this email
      -        # domain. Use a lock to guard against multiple threads competing to cache information.
      -        log.debug("Waiting for autodiscover_cache lock")
      -        with autodiscover_cache:
      -            log.debug("autodiscover_cache lock acquired")
      -            cache_key = self._cache_key
      -            domain = get_domain(self.email)
      -            if cache_key in autodiscover_cache:
      -                ad_protocol = autodiscover_cache[cache_key]
      -                log.debug("Cache hit for key %s: %s", cache_key, ad_protocol.service_endpoint)
      -                try:
      -                    ad = self._quick(protocol=ad_protocol)
      -                except AutoDiscoverFailed:
      -                    # Autodiscover no longer works with this domain. Clear cache and try again after releasing the lock
      -                    log.debug("AD request failure. Removing cache for key %s", cache_key)
      -                    del autodiscover_cache[cache_key]
      -                    ad = self._step_1(hostname=domain)
      -            else:
      -                # This will cache the result
      -                log.debug("Cache miss for key %s", cache_key)
      -                ad = self._step_1(hostname=domain)
      -
      -        log.debug("Released autodiscover_cache_lock")
      -        if ad.redirect_address:
      -            log.debug("Got a redirect address: %s", ad.redirect_address)
      -            if ad.redirect_address.lower() in self._emails_visited:
      -                raise AutoDiscoverCircularRedirect("We were redirected to an email address we have already seen")
      -
      -            # Start over, but with the new email address
      -            self.email = ad.redirect_address
      -            return self.discover()
      -
      -        # We successfully received a response. Clear the cache of seen emails etc.
      -        self.clear()
      -        return self._build_response(ad_response=ad)
      -
      -    def clear(self):
      -        # This resets cached variables
      -        self._urls_visited = []
      -        self._redirect_count = 0
      -        self._emails_visited = []
      -
      -    @property
      -    def _cache_key(self):
      -        # We may be using multiple different credentials and changing our minds on TLS verification. This key
      -        # combination should be safe for caching.
      -        domain = get_domain(self.email)
      -        return domain, self.credentials
      -
      -    @threaded_cached_property
      -    def resolver(self):
      -        resolver = dns.resolver.Resolver(**self.DNS_RESOLVER_KWARGS)
      -        for k, v in self.DNS_RESOLVER_ATTRS.items():
      -            setattr(resolver, k, v)
      -        return resolver
      -
      -    @abc.abstractmethod
      -    def _build_response(self, ad_response):
      -        pass
      -
      -    @abc.abstractmethod
      -    def _quick(self, protocol):
      -        pass
      -
      -    def _redirect_url_is_valid(self, url):
      -        """Three separate responses can be “Redirect responses”:
      -        * An HTTP status code (301, 302) with a new URL
      -        * An HTTP status code of 200, but with a payload XML containing a redirect to a different URL
      -        * An HTTP status code of 200, but with a payload XML containing a different SMTP address as the target address
      -
      -        We only handle the HTTP 302 redirects here. We validate the URL received in the redirect response to ensure that
      -        it does not redirect to non-SSL endpoints or SSL endpoints with invalid certificates, and that the redirect is
      -        not circular. Finally, we should fail after 10 redirects.
      -
      -        :param url:
      -        :return:
      -        """
      -        if url.lower() in self._urls_visited:
      -            log.warning("We have already tried this URL: %s", url)
      -            return False
      -
      -        if self._redirect_count >= self.MAX_REDIRECTS:
      -            log.warning("We reached max redirects at URL: %s", url)
      -            return False
      -
      -        # We require TLS endpoints
      -        if not url.startswith("https://"):
      -            log.debug("Invalid scheme for URL: %s", url)
      -            return False
      -
      -        # Quick test that the endpoint responds and that TLS handshake is OK
      -        try:
      -            self._get_unauthenticated_response(url, method="head")
      -        except TransportError as e:
      -            log.debug("Response error on redirect URL %s: %s", url, e)
      -            return False
      -
      -        self._redirect_count += 1
      -        return True
      -
      -    @abc.abstractmethod
      -    def _get_unauthenticated_response(self, url, method="post"):
      -        pass
      -
      -    @abc.abstractmethod
      -    def _attempt_response(self, url):
      -        pass
      -
      -    def _ensure_valid_hostname(self, url):
      -        hostname = urlparse(url).netloc
      -        log.debug("Checking if %s can be looked up in DNS", hostname)
      -        try:
      -            self.resolver.resolve(f"{hostname}.", "A", lifetime=self.DNS_RESOLVER_LIFETIME)
      -        except DNS_LOOKUP_ERRORS as e:
      -            log.debug("DNS A lookup failure: %s", e)
      -            # 'requests' is bad at reporting that a hostname cannot be resolved. Let's check this separately.
      -            # Don't retry on DNS errors. They will most likely be persistent.
      -            raise TransportError(f"{hostname!r} has no DNS entry")
      -
      -    def _get_srv_records(self, hostname):
      -        """Send a DNS query for SRV entries for the hostname.
      -
      -        An SRV entry that has been formatted for autodiscovery will have the following format:
      -
      -            canonical name = mail.example.com.
      -            service = 8 100 443 webmail.example.com.
      -
      -        The first three numbers in the service line are: priority, weight, port
      -
      -        :param hostname:
      -        :return:
      -        """
      -        log.debug("Attempting to get SRV records for %s", hostname)
      -        records = []
      -        try:
      -            answers = self.resolver.resolve(f"{hostname}.", "SRV", lifetime=self.DNS_RESOLVER_LIFETIME)
      -        except DNS_LOOKUP_ERRORS as e:
      -            log.debug("DNS SRV lookup failure: %s", e)
      -            return records
      -        for rdata in answers:
      -            try:
      -                vals = rdata.to_text().strip().rstrip(".").split(" ")
      -                # Raise ValueError if the first three are not ints, and IndexError if there are less than 4 values
      -                priority, weight, port, srv = int(vals[0]), int(vals[1]), int(vals[2]), vals[3]
      -                record = SrvRecord(priority=priority, weight=weight, port=port, srv=srv)
      -                log.debug("Found SRV record %s ", record)
      -                records.append(record)
      -            except (ValueError, IndexError):
      -                log.debug("Incompatible SRV record for %s (%s)", hostname, rdata.to_text())
      -        return records
      -
      -    def _step_1(self, hostname):
      -        """Perform step 1, where the client sends an Autodiscover request to
      -        https://example.com/ and then does one of the following:
      -            * If the Autodiscover attempt succeeds, the client proceeds to step 5.
      -            * If the Autodiscover attempt fails, the client proceeds to step 2.
      -
      -        :param hostname:
      -        :return:
      -        """
      -        url = f"https://{hostname}/{self.URL_PATH}"
      -        log.info("Step 1: Trying autodiscover on %r with email %r", url, self.email)
      -        is_valid_response, ad = self._attempt_response(url=url)
      -        if is_valid_response:
      -            return self._step_5(ad=ad)
      -        return self._step_2(hostname=hostname)
      -
      -    def _step_2(self, hostname):
      -        """Perform step 2, where the client sends an Autodiscover request to
      -        https://autodiscover.example.com/ and then does one of the following:
      -            * If the Autodiscover attempt succeeds, the client proceeds to step 5.
      -            * If the Autodiscover attempt fails, the client proceeds to step 3.
      -
      -        :param hostname:
      -        :return:
      -        """
      -        url = f"https://autodiscover.{hostname}/{self.URL_PATH}"
      -        log.info("Step 2: Trying autodiscover on %r with email %r", url, self.email)
      -        is_valid_response, ad = self._attempt_response(url=url)
      -        if is_valid_response:
      -            return self._step_5(ad=ad)
      -        return self._step_3(hostname=hostname)
      -
      -    def _step_3(self, hostname):
      -        """Perform step 3, where the client sends an unauthenticated GET method request to
      -        http://autodiscover.example.com/ (Note that this is a non-HTTPS endpoint). The
      -        client then does one of the following:
      -            * If the GET request returns a 302 redirect response, it gets the redirection URL from the 'Location' HTTP
      -            header and validates it as described in the "Redirect responses" section. The client then does one of the
      -            following:
      -                * If the redirection URL is valid, the client tries the URL and then does one of the following:
      -                    * If the attempt succeeds, the client proceeds to step 5.
      -                    * If the attempt fails, the client proceeds to step 4.
      -                * If the redirection URL is not valid, the client proceeds to step 4.
      -            * If the GET request does not return a 302 redirect response, the client proceeds to step 4.
      -
      -        :param hostname:
      -        :return:
      -        """
      -        url = f"http://autodiscover.{hostname}/{self.URL_PATH}"
      -        log.info("Step 3: Trying autodiscover on %r with email %r", url, self.email)
      -        try:
      -            _, r = self._get_unauthenticated_response(url=url, method="get")
      -        except TransportError:
      -            r = DummyResponse(url=url)
      -        if r.status_code in (301, 302) and "location" in r.headers:
      -            redirect_url = get_redirect_url(r)
      -            if self._redirect_url_is_valid(url=redirect_url):
      -                is_valid_response, ad = self._attempt_response(url=redirect_url)
      -                if is_valid_response:
      -                    return self._step_5(ad=ad)
      -                log.debug("Got invalid response")
      -                return self._step_4(hostname=hostname)
      -            log.debug("Got invalid redirect URL")
      -            return self._step_4(hostname=hostname)
      -        log.debug("Got no redirect URL")
      -        return self._step_4(hostname=hostname)
      -
      -    def _step_4(self, hostname):
      -        """Perform step 4, where the client performs a Domain Name System (DNS) query for an SRV record for
      -        _autodiscover._tcp.example.com. The query might return multiple records. The client selects only records that
      -        point to an SSL endpoint and that have the highest priority and weight. One of the following actions then
      -        occurs:
      -            * If no such records are returned, the client proceeds to step 6.
      -            * If records are returned, the application randomly chooses a record in the list and validates the endpoint
      -              that it points to by following the process described in the "Redirect Response" section. The client then
      -              does one of the following:
      -                * If the redirection URL is valid, the client tries the URL and then does one of the following:
      -                    * If the attempt succeeds, the client proceeds to step 5.
      -                    * If the attempt fails, the client proceeds to step 6.
      -                * If the redirection URL is not valid, the client proceeds to step 6.
      -
      -        :param hostname:
      -        :return:
      -        """
      -        dns_hostname = f"_autodiscover._tcp.{hostname}"
      -        log.info("Step 4: Trying autodiscover on %r with email %r", dns_hostname, self.email)
      -        srv_records = self._get_srv_records(dns_hostname)
      -        try:
      -            srv_host = _select_srv_host(srv_records)
      -        except ValueError:
      -            srv_host = None
      -        if not srv_host:
      -            return self._step_6()
      -        redirect_url = f"https://{srv_host}/{self.URL_PATH}"
      -        if self._redirect_url_is_valid(url=redirect_url):
      -            is_valid_response, ad = self._attempt_response(url=redirect_url)
      -            if is_valid_response:
      -                return self._step_5(ad=ad)
      -            log.debug("Got invalid response")
      -            return self._step_6()
      -        log.debug("Got invalid redirect URL")
      -        return self._step_6()
      -
      -    def _step_5(self, ad):
      -        """Perform step 5. When a valid Autodiscover request succeeds, the following sequence occurs:
      -            * If the server responds with an HTTP 302 redirect, the client validates the redirection URL according to
      -              the process defined in the "Redirect responses" and then does one of the following:
      -                * If the redirection URL is valid, the client tries the URL and then does one of the following:
      -                    * If the attempt succeeds, the client repeats step 5 from the beginning.
      -                    * If the attempt fails, the client proceeds to step 6.
      -                * If the redirection URL is not valid, the client proceeds to step 6.
      -            * If the server responds with a valid Autodiscover response, the client does one of the following:
      -                * If the value of the Action element is "Redirect", the client gets the redirection email address from
      -                  the Redirect element and then returns to step 1, using this new email address.
      -                * If the value of the Action element is "Settings", the client has successfully received the requested
      -                  configuration settings for the specified user. The client does not need to proceed to step 6.
      -
      -        :param ad:
      -        :return:
      -        """
      -        log.info("Step 5: Checking response")
      -        # This is not explicit in the protocol, but let's raise any errors here
      -        ad.raise_errors()
      -
      -        if hasattr(ad, "response"):
      -            # Hack for PoxAutodiscover
      -            ad = ad.response
      -
      -        if ad.redirect_url:
      -            log.debug("Got a redirect URL: %s", ad.redirect_url)
      -            # We are diverging a bit from the protocol here. We will never get an HTTP 302 since earlier steps already
      -            # followed the redirects where possible. Instead, we handle redirect responses here.
      -            if self._redirect_url_is_valid(url=ad.redirect_url):
      -                is_valid_response, ad = self._attempt_response(url=ad.redirect_url)
      -                if is_valid_response:
      -                    return self._step_5(ad=ad)
      -                log.debug("Got invalid response")
      -                return self._step_6()
      -            log.debug("Invalid redirect URL")
      -            return self._step_6()
      -        # This could be an email redirect. Let outer layer handle this
      -        return ad
      -
      -    def _step_6(self):
      -        """Perform step 6. If the client cannot contact the Autodiscover service, the client should ask the user for
      -        the Exchange server name and use it to construct an Exchange EWS URL. The client should try to use this URL for
      -        future requests.
      -        """
      -        raise AutoDiscoverFailed(
      -            f"All steps in the autodiscover protocol failed for email {self.email}. If you think this is an error, "
      -            f"consider doing an official test at https://testconnectivity.microsoft.com"
      -        )
      -
      -

      Subclasses

      - -

      Class variables

      -
      -
      var DNS_RESOLVER_ATTRS
      -
      -
      -
      -
      var DNS_RESOLVER_KWARGS
      -
      -
      -
      -
      var DNS_RESOLVER_LIFETIME
      -
      -
      -
      -
      var INITIAL_RETRY_POLICY
      -
      -
      -
      -
      var MAX_REDIRECTS
      -
      -
      -
      -
      var RETRY_WAIT
      -
      -
      -
      -
      var URL_PATH
      -
      -
      -
      -
      -

      Instance variables

      -
      -
      var resolver
      -
      -
      -
      - -Expand source code - -
      def __get__(self, obj, cls):
      -    if obj is None:
      -        return self
      -
      -    obj_dict = obj.__dict__
      -    name = self.func.__name__
      -    with self.lock:
      -        try:
      -            # check if the value was computed before the lock was acquired
      -            return obj_dict[name]
      -
      -        except KeyError:
      -            # if not, do the calculation and release the lock
      -            return obj_dict.setdefault(name, self.func(obj))
      -
      -
      -
      -

      Methods

      -
      -
      -def clear(self) -
      -
      -
      -
      - -Expand source code - -
      def clear(self):
      -    # This resets cached variables
      -    self._urls_visited = []
      -    self._redirect_count = 0
      -    self._emails_visited = []
      -
      -
      -
      -def discover(self) -
      -
      -
      -
      - -Expand source code - -
      def discover(self):
      -    self._emails_visited.append(self.email.lower())
      -
      -    # Check the autodiscover cache to see if we already know the autodiscover service endpoint for this email
      -    # domain. Use a lock to guard against multiple threads competing to cache information.
      -    log.debug("Waiting for autodiscover_cache lock")
      -    with autodiscover_cache:
      -        log.debug("autodiscover_cache lock acquired")
      -        cache_key = self._cache_key
      -        domain = get_domain(self.email)
      -        if cache_key in autodiscover_cache:
      -            ad_protocol = autodiscover_cache[cache_key]
      -            log.debug("Cache hit for key %s: %s", cache_key, ad_protocol.service_endpoint)
      -            try:
      -                ad = self._quick(protocol=ad_protocol)
      -            except AutoDiscoverFailed:
      -                # Autodiscover no longer works with this domain. Clear cache and try again after releasing the lock
      -                log.debug("AD request failure. Removing cache for key %s", cache_key)
      -                del autodiscover_cache[cache_key]
      -                ad = self._step_1(hostname=domain)
      -        else:
      -            # This will cache the result
      -            log.debug("Cache miss for key %s", cache_key)
      -            ad = self._step_1(hostname=domain)
      -
      -    log.debug("Released autodiscover_cache_lock")
      -    if ad.redirect_address:
      -        log.debug("Got a redirect address: %s", ad.redirect_address)
      -        if ad.redirect_address.lower() in self._emails_visited:
      -            raise AutoDiscoverCircularRedirect("We were redirected to an email address we have already seen")
      -
      -        # Start over, but with the new email address
      -        self.email = ad.redirect_address
      -        return self.discover()
      -
      -    # We successfully received a response. Clear the cache of seen emails etc.
      -    self.clear()
      -    return self._build_response(ad_response=ad)
      -
      -
      -
      -
      -
      -class SrvRecord -(priority, weight, port, srv) -
      -
      -

      A container for autodiscover-related SRV records in DNS.

      -
      - -Expand source code - -
      class SrvRecord:
      -    """A container for autodiscover-related SRV records in DNS."""
      -
      -    def __init__(self, priority, weight, port, srv):
      -        self.priority = priority
      -        self.weight = weight
      -        self.port = port
      -        self.srv = srv
      -
      -    def __eq__(self, other):
      -        return all(getattr(self, k) == getattr(other, k) for k in self.__dict__)
      -
      -
      -
      -
      -
      - -
      - - - diff --git a/docs/exchangelib/autodiscover/discovery/index.html b/docs/exchangelib/autodiscover/discovery/index.html deleted file mode 100644 index 2c0d34b8..00000000 --- a/docs/exchangelib/autodiscover/discovery/index.html +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - -exchangelib.autodiscover.discovery API documentation - - - - - - - - - - - -
      - - -
      - - - diff --git a/docs/exchangelib/autodiscover/discovery/pox.html b/docs/exchangelib/autodiscover/discovery/pox.html deleted file mode 100644 index 7f9a7bbe..00000000 --- a/docs/exchangelib/autodiscover/discovery/pox.html +++ /dev/null @@ -1,483 +0,0 @@ - - - - - - -exchangelib.autodiscover.discovery.pox API documentation - - - - - - - - - - - -
      -
      -
      -

      Module exchangelib.autodiscover.discovery.pox

      -
      -
      -
      - -Expand source code - -
      import logging
      -import time
      -
      -from ...configuration import Configuration
      -from ...errors import AutoDiscoverFailed, RedirectError, TransportError, UnauthorizedError
      -from ...protocol import Protocol
      -from ...transport import AUTH_TYPE_MAP, DEFAULT_HEADERS, GSSAPI, NOAUTH, get_auth_method_from_response
      -from ...util import (
      -    CONNECTION_ERRORS,
      -    TLS_ERRORS,
      -    DummyResponse,
      -    ParseError,
      -    _back_off_if_needed,
      -    get_redirect_url,
      -    post_ratelimited,
      -)
      -from ..cache import autodiscover_cache
      -from ..properties import Autodiscover
      -from ..protocol import AutodiscoverProtocol
      -from .base import BaseAutodiscovery
      -
      -log = logging.getLogger(__name__)
      -
      -
      -def discover(email, credentials=None, auth_type=None, retry_policy=None):
      -    ad_response, protocol = PoxAutodiscovery(email=email, credentials=credentials).discover()
      -    protocol.config.auth_typ = auth_type
      -    protocol.config.retry_policy = retry_policy
      -    return ad_response, protocol
      -
      -
      -class PoxAutodiscovery(BaseAutodiscovery):
      -    URL_PATH = "Autodiscover/Autodiscover.xml"
      -
      -    def _build_response(self, ad_response):
      -        if not ad_response.autodiscover_smtp_address:
      -            # Autodiscover does not always return an email address. In that case, the requesting email should be used
      -            ad_response.user.autodiscover_smtp_address = self.email
      -
      -        protocol = Protocol(
      -            config=Configuration(
      -                service_endpoint=ad_response.protocol.ews_url,
      -                credentials=self.credentials,
      -                version=ad_response.version,
      -                auth_type=ad_response.protocol.auth_type,
      -            )
      -        )
      -        return ad_response, protocol
      -
      -    def _quick(self, protocol):
      -        try:
      -            r = self._get_authenticated_response(protocol=protocol)
      -        except TransportError as e:
      -            raise AutoDiscoverFailed(f"Response error: {e}")
      -        if r.status_code == 200:
      -            try:
      -                ad = Autodiscover.from_bytes(bytes_content=r.content)
      -            except ParseError as e:
      -                raise AutoDiscoverFailed(f"Invalid response: {e}")
      -            else:
      -                return self._step_5(ad=ad)
      -        raise AutoDiscoverFailed(f"Invalid response code: {r.status_code}")
      -
      -    def _get_unauthenticated_response(self, url, method="post"):
      -        """Get auth type by tasting headers from the server. Do POST requests be default. HEAD is too error-prone, and
      -        some servers are set up to redirect to OWA on all requests except POST to the autodiscover endpoint.
      -
      -        :param url:
      -        :param method:  (Default value = 'post')
      -        :return:
      -        """
      -        # We are connecting to untrusted servers here, so take necessary precautions.
      -        self._ensure_valid_hostname(url)
      -
      -        kwargs = dict(
      -            url=url, headers=DEFAULT_HEADERS.copy(), allow_redirects=False, timeout=AutodiscoverProtocol.TIMEOUT
      -        )
      -        if method == "post":
      -            kwargs["data"] = Autodiscover.payload(email=self.email)
      -        retry = 0
      -        t_start = time.monotonic()
      -        while True:
      -            _back_off_if_needed(self.INITIAL_RETRY_POLICY.back_off_until)
      -            log.debug("Trying to get response from %s", url)
      -            with AutodiscoverProtocol.raw_session(url) as s:
      -                try:
      -                    r = getattr(s, method)(**kwargs)
      -                    r.close()  # Release memory
      -                    break
      -                except TLS_ERRORS as e:
      -                    # Don't retry on TLS errors. They will most likely be persistent.
      -                    raise TransportError(str(e))
      -                except CONNECTION_ERRORS as e:
      -                    r = DummyResponse(url=url, request_headers=kwargs["headers"])
      -                    total_wait = time.monotonic() - t_start
      -                    if self.INITIAL_RETRY_POLICY.may_retry_on_error(response=r, wait=total_wait):
      -                        log.debug("Connection error on URL %s (retry %s, error: %s). Cool down", url, retry, e)
      -                        # Don't respect the 'Retry-After' header. We don't know if this is a useful endpoint, and we
      -                        # want autodiscover to be reasonably fast.
      -                        self.INITIAL_RETRY_POLICY.back_off(self.RETRY_WAIT)
      -                        retry += 1
      -                        continue
      -                    log.debug("Connection error on URL %s: %s", url, e)
      -                    raise TransportError(str(e))
      -        try:
      -            auth_type = get_auth_method_from_response(response=r)
      -        except UnauthorizedError:
      -            # Failed to guess the auth type
      -            auth_type = NOAUTH
      -        if r.status_code in (301, 302) and "location" in r.headers:
      -            # Make the redirect URL absolute
      -            try:
      -                r.headers["location"] = get_redirect_url(r)
      -            except TransportError:
      -                del r.headers["location"]
      -        return auth_type, r
      -
      -    def _get_authenticated_response(self, protocol):
      -        """Get a response by using the credentials provided. We guess the auth type along the way.
      -
      -        :param protocol:
      -        :return:
      -        """
      -        # Redo the request with the correct auth
      -        data = Autodiscover.payload(email=self.email)
      -        headers = DEFAULT_HEADERS.copy()
      -        session = protocol.get_session()
      -        if GSSAPI in AUTH_TYPE_MAP and isinstance(session.auth, AUTH_TYPE_MAP[GSSAPI]):
      -            # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/pox-autodiscover-request-for-exchange
      -            headers["X-ClientCanHandle"] = "Negotiate"
      -        try:
      -            r, session = post_ratelimited(
      -                protocol=protocol,
      -                session=session,
      -                url=protocol.service_endpoint,
      -                headers=headers,
      -                data=data,
      -            )
      -            protocol.release_session(session)
      -        except UnauthorizedError as e:
      -            # It's entirely possible for the endpoint to ask for login. We should continue if login fails because this
      -            # isn't necessarily the right endpoint to use.
      -            raise TransportError(str(e))
      -        except RedirectError as e:
      -            r = DummyResponse(url=protocol.service_endpoint, headers={"location": e.url}, status_code=302)
      -        return r
      -
      -    def _attempt_response(self, url):
      -        """Return an (is_valid_response, response) tuple.
      -
      -        :param url:
      -        :return:
      -        """
      -        self._urls_visited.append(url.lower())
      -        log.debug("Attempting to get a valid response from %s", url)
      -        try:
      -            auth_type, r = self._get_unauthenticated_response(url=url)
      -            ad_protocol = AutodiscoverProtocol(
      -                config=Configuration(
      -                    service_endpoint=url,
      -                    credentials=self.credentials,
      -                    auth_type=auth_type,
      -                    retry_policy=self.INITIAL_RETRY_POLICY,
      -                )
      -            )
      -            if auth_type != NOAUTH:
      -                r = self._get_authenticated_response(protocol=ad_protocol)
      -        except TransportError as e:
      -            log.debug("Failed to get a response: %s", e)
      -            return False, None
      -        if r.status_code in (301, 302) and "location" in r.headers:
      -            redirect_url = get_redirect_url(r)
      -            if self._redirect_url_is_valid(url=redirect_url):
      -                # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com
      -                # works, it seems that we should follow this URL now and try to get a valid response.
      -                return self._attempt_response(url=redirect_url)
      -        if r.status_code == 200:
      -            try:
      -                ad = Autodiscover.from_bytes(bytes_content=r.content)
      -            except ParseError as e:
      -                log.debug("Invalid response: %s", e)
      -            else:
      -                # We got a valid response. Unless this is a URL redirect response, we cache the result
      -                if ad.response is None or not ad.response.redirect_url:
      -                    cache_key = self._cache_key
      -                    log.debug("Adding cache entry for key %s: %s", cache_key, ad_protocol.service_endpoint)
      -                    autodiscover_cache[cache_key] = ad_protocol
      -                return True, ad
      -        return False, None
      -
      -
      -
      -
      -
      -
      -
      -

      Functions

      -
      -
      -def discover(email, credentials=None, auth_type=None, retry_policy=None) -
      -
      -
      -
      - -Expand source code - -
      def discover(email, credentials=None, auth_type=None, retry_policy=None):
      -    ad_response, protocol = PoxAutodiscovery(email=email, credentials=credentials).discover()
      -    protocol.config.auth_typ = auth_type
      -    protocol.config.retry_policy = retry_policy
      -    return ad_response, protocol
      -
      -
      -
      -
      -
      -

      Classes

      -
      -
      -class PoxAutodiscovery -(email, credentials=None) -
      -
      -

      Autodiscover is a Microsoft protocol for automatically getting the endpoint of the Exchange server and other -connection-related settings holding the email address using only the email address, and username and password of the -user.

      -

      For a description of the protocol implemented, see "Autodiscover for Exchange ActiveSync developers":

      -

      https://docs.microsoft.com/en-us/previous-versions/office/developer/exchange-server-interoperability-guidance/hh352638%28v%3dexchg.140%29

      -

      Descriptions of the steps from the article are provided in their respective methods in this class.

      -

      For a description of how to handle autodiscover error messages, see:

      -

      https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/handling-autodiscover-error-messages

      -

      A tip from the article: -The client can perform steps 1 through 4 in any order or in parallel to expedite the process, but it must wait for -responses to finish at each step before proceeding. Given that many organizations prefer to use the URL in step 2 to -set up the Autodiscover service, the client might try this step first.

      -

      Another possibly newer resource which has not yet been attempted is "Outlook 2016 Implementation of Autodiscover": -https://support.microsoft.com/en-us/help/3211279/outlook-2016-implementation-of-autodiscover

      -

      WARNING: The autodiscover protocol is very complicated. If you have problems autodiscovering using this -implementation, start by doing an official test at https://testconnectivity.microsoft.com

      -

      :param email: The email address to autodiscover -:param credentials: Credentials with authorization to make autodiscover lookups for this Account -(Default value = None)

      -
      - -Expand source code - -
      class PoxAutodiscovery(BaseAutodiscovery):
      -    URL_PATH = "Autodiscover/Autodiscover.xml"
      -
      -    def _build_response(self, ad_response):
      -        if not ad_response.autodiscover_smtp_address:
      -            # Autodiscover does not always return an email address. In that case, the requesting email should be used
      -            ad_response.user.autodiscover_smtp_address = self.email
      -
      -        protocol = Protocol(
      -            config=Configuration(
      -                service_endpoint=ad_response.protocol.ews_url,
      -                credentials=self.credentials,
      -                version=ad_response.version,
      -                auth_type=ad_response.protocol.auth_type,
      -            )
      -        )
      -        return ad_response, protocol
      -
      -    def _quick(self, protocol):
      -        try:
      -            r = self._get_authenticated_response(protocol=protocol)
      -        except TransportError as e:
      -            raise AutoDiscoverFailed(f"Response error: {e}")
      -        if r.status_code == 200:
      -            try:
      -                ad = Autodiscover.from_bytes(bytes_content=r.content)
      -            except ParseError as e:
      -                raise AutoDiscoverFailed(f"Invalid response: {e}")
      -            else:
      -                return self._step_5(ad=ad)
      -        raise AutoDiscoverFailed(f"Invalid response code: {r.status_code}")
      -
      -    def _get_unauthenticated_response(self, url, method="post"):
      -        """Get auth type by tasting headers from the server. Do POST requests be default. HEAD is too error-prone, and
      -        some servers are set up to redirect to OWA on all requests except POST to the autodiscover endpoint.
      -
      -        :param url:
      -        :param method:  (Default value = 'post')
      -        :return:
      -        """
      -        # We are connecting to untrusted servers here, so take necessary precautions.
      -        self._ensure_valid_hostname(url)
      -
      -        kwargs = dict(
      -            url=url, headers=DEFAULT_HEADERS.copy(), allow_redirects=False, timeout=AutodiscoverProtocol.TIMEOUT
      -        )
      -        if method == "post":
      -            kwargs["data"] = Autodiscover.payload(email=self.email)
      -        retry = 0
      -        t_start = time.monotonic()
      -        while True:
      -            _back_off_if_needed(self.INITIAL_RETRY_POLICY.back_off_until)
      -            log.debug("Trying to get response from %s", url)
      -            with AutodiscoverProtocol.raw_session(url) as s:
      -                try:
      -                    r = getattr(s, method)(**kwargs)
      -                    r.close()  # Release memory
      -                    break
      -                except TLS_ERRORS as e:
      -                    # Don't retry on TLS errors. They will most likely be persistent.
      -                    raise TransportError(str(e))
      -                except CONNECTION_ERRORS as e:
      -                    r = DummyResponse(url=url, request_headers=kwargs["headers"])
      -                    total_wait = time.monotonic() - t_start
      -                    if self.INITIAL_RETRY_POLICY.may_retry_on_error(response=r, wait=total_wait):
      -                        log.debug("Connection error on URL %s (retry %s, error: %s). Cool down", url, retry, e)
      -                        # Don't respect the 'Retry-After' header. We don't know if this is a useful endpoint, and we
      -                        # want autodiscover to be reasonably fast.
      -                        self.INITIAL_RETRY_POLICY.back_off(self.RETRY_WAIT)
      -                        retry += 1
      -                        continue
      -                    log.debug("Connection error on URL %s: %s", url, e)
      -                    raise TransportError(str(e))
      -        try:
      -            auth_type = get_auth_method_from_response(response=r)
      -        except UnauthorizedError:
      -            # Failed to guess the auth type
      -            auth_type = NOAUTH
      -        if r.status_code in (301, 302) and "location" in r.headers:
      -            # Make the redirect URL absolute
      -            try:
      -                r.headers["location"] = get_redirect_url(r)
      -            except TransportError:
      -                del r.headers["location"]
      -        return auth_type, r
      -
      -    def _get_authenticated_response(self, protocol):
      -        """Get a response by using the credentials provided. We guess the auth type along the way.
      -
      -        :param protocol:
      -        :return:
      -        """
      -        # Redo the request with the correct auth
      -        data = Autodiscover.payload(email=self.email)
      -        headers = DEFAULT_HEADERS.copy()
      -        session = protocol.get_session()
      -        if GSSAPI in AUTH_TYPE_MAP and isinstance(session.auth, AUTH_TYPE_MAP[GSSAPI]):
      -            # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/pox-autodiscover-request-for-exchange
      -            headers["X-ClientCanHandle"] = "Negotiate"
      -        try:
      -            r, session = post_ratelimited(
      -                protocol=protocol,
      -                session=session,
      -                url=protocol.service_endpoint,
      -                headers=headers,
      -                data=data,
      -            )
      -            protocol.release_session(session)
      -        except UnauthorizedError as e:
      -            # It's entirely possible for the endpoint to ask for login. We should continue if login fails because this
      -            # isn't necessarily the right endpoint to use.
      -            raise TransportError(str(e))
      -        except RedirectError as e:
      -            r = DummyResponse(url=protocol.service_endpoint, headers={"location": e.url}, status_code=302)
      -        return r
      -
      -    def _attempt_response(self, url):
      -        """Return an (is_valid_response, response) tuple.
      -
      -        :param url:
      -        :return:
      -        """
      -        self._urls_visited.append(url.lower())
      -        log.debug("Attempting to get a valid response from %s", url)
      -        try:
      -            auth_type, r = self._get_unauthenticated_response(url=url)
      -            ad_protocol = AutodiscoverProtocol(
      -                config=Configuration(
      -                    service_endpoint=url,
      -                    credentials=self.credentials,
      -                    auth_type=auth_type,
      -                    retry_policy=self.INITIAL_RETRY_POLICY,
      -                )
      -            )
      -            if auth_type != NOAUTH:
      -                r = self._get_authenticated_response(protocol=ad_protocol)
      -        except TransportError as e:
      -            log.debug("Failed to get a response: %s", e)
      -            return False, None
      -        if r.status_code in (301, 302) and "location" in r.headers:
      -            redirect_url = get_redirect_url(r)
      -            if self._redirect_url_is_valid(url=redirect_url):
      -                # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com
      -                # works, it seems that we should follow this URL now and try to get a valid response.
      -                return self._attempt_response(url=redirect_url)
      -        if r.status_code == 200:
      -            try:
      -                ad = Autodiscover.from_bytes(bytes_content=r.content)
      -            except ParseError as e:
      -                log.debug("Invalid response: %s", e)
      -            else:
      -                # We got a valid response. Unless this is a URL redirect response, we cache the result
      -                if ad.response is None or not ad.response.redirect_url:
      -                    cache_key = self._cache_key
      -                    log.debug("Adding cache entry for key %s: %s", cache_key, ad_protocol.service_endpoint)
      -                    autodiscover_cache[cache_key] = ad_protocol
      -                return True, ad
      -        return False, None
      -
      -

      Ancestors

      - -

      Class variables

      -
      -
      var URL_PATH
      -
      -
      -
      -
      -
      -
      -
      -
      - -
      - - - diff --git a/docs/exchangelib/autodiscover/discovery/soap.html b/docs/exchangelib/autodiscover/discovery/soap.html deleted file mode 100644 index 131cf7b1..00000000 --- a/docs/exchangelib/autodiscover/discovery/soap.html +++ /dev/null @@ -1,327 +0,0 @@ - - - - - - -exchangelib.autodiscover.discovery.soap API documentation - - - - - - - - - - - -
      -
      -
      -

      Module exchangelib.autodiscover.discovery.soap

      -
      -
      -
      - -Expand source code - -
      import logging
      -
      -from ...configuration import Configuration
      -from ...errors import AutoDiscoverFailed, RedirectError, TransportError
      -from ...protocol import Protocol
      -from ...transport import get_unauthenticated_autodiscover_response
      -from ...util import CONNECTION_ERRORS
      -from ..cache import autodiscover_cache
      -from ..protocol import AutodiscoverProtocol
      -from .base import BaseAutodiscovery
      -
      -log = logging.getLogger(__name__)
      -
      -
      -def discover(email, credentials=None, auth_type=None, retry_policy=None):
      -    ad_response, protocol = SoapAutodiscovery(email=email, credentials=credentials).discover()
      -    protocol.config.auth_typ = auth_type
      -    protocol.config.retry_policy = retry_policy
      -    return ad_response, protocol
      -
      -
      -class SoapAutodiscovery(BaseAutodiscovery):
      -    URL_PATH = "autodiscover/autodiscover.svc"
      -
      -    def _build_response(self, ad_response):
      -        if not ad_response.autodiscover_smtp_address:
      -            # Autodiscover does not always return an email address. In that case, the requesting email should be used
      -            ad_response.autodiscover_smtp_address = self.email
      -
      -        protocol = Protocol(
      -            config=Configuration(
      -                service_endpoint=ad_response.ews_url,
      -                credentials=self.credentials,
      -                version=ad_response.version,
      -                # TODO: Detect EWS service auth type somehow
      -            )
      -        )
      -        return ad_response, protocol
      -
      -    def _quick(self, protocol):
      -        try:
      -            user_response = protocol.get_user_settings(user=self.email)
      -        except TransportError as e:
      -            raise AutoDiscoverFailed(f"Response error: {e}")
      -        return self._step_5(ad=user_response)
      -
      -    def _get_unauthenticated_response(self, url, method="post"):
      -        """Get response from server using the given HTTP method
      -
      -        :param url:
      -        :return:
      -        """
      -        # We are connecting to untrusted servers here, so take necessary precautions.
      -        self._ensure_valid_hostname(url)
      -
      -        protocol = AutodiscoverProtocol(
      -            config=Configuration(
      -                service_endpoint=url,
      -                retry_policy=self.INITIAL_RETRY_POLICY,
      -            )
      -        )
      -        return None, get_unauthenticated_autodiscover_response(protocol=protocol, method=method)
      -
      -    def _attempt_response(self, url):
      -        """Return an (is_valid_response, response) tuple.
      -
      -        :param url:
      -        :return:
      -        """
      -        self._urls_visited.append(url.lower())
      -        log.debug("Attempting to get a valid response from %s", url)
      -
      -        try:
      -            self._ensure_valid_hostname(url)
      -        except TransportError:
      -            return False, None
      -
      -        protocol = AutodiscoverProtocol(
      -            config=Configuration(
      -                service_endpoint=url,
      -                credentials=self.credentials,
      -                retry_policy=self.INITIAL_RETRY_POLICY,
      -            )
      -        )
      -        try:
      -            user_response = protocol.get_user_settings(user=self.email)
      -        except RedirectError as e:
      -            if self._redirect_url_is_valid(url=e.url):
      -                # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com
      -                # works, it seems that we should follow this URL now and try to get a valid response.
      -                return self._attempt_response(url=e.url)
      -            log.debug("Invalid redirect URL: %s", e.url)
      -            return False, None
      -        except TransportError as e:
      -            log.debug("Failed to get a response: %s", e)
      -            return False, None
      -        except CONNECTION_ERRORS as e:
      -            log.debug("Failed to get a response: %s", e)
      -            return False, None
      -
      -        # We got a valid response. Unless this is a URL redirect response, we cache the result
      -        if not user_response.redirect_url:
      -            cache_key = self._cache_key
      -            log.debug("Adding cache entry for key %s: %s", cache_key, protocol.service_endpoint)
      -            autodiscover_cache[cache_key] = protocol
      -        return True, user_response
      -
      -
      -
      -
      -
      -
      -
      -

      Functions

      -
      -
      -def discover(email, credentials=None, auth_type=None, retry_policy=None) -
      -
      -
      -
      - -Expand source code - -
      def discover(email, credentials=None, auth_type=None, retry_policy=None):
      -    ad_response, protocol = SoapAutodiscovery(email=email, credentials=credentials).discover()
      -    protocol.config.auth_typ = auth_type
      -    protocol.config.retry_policy = retry_policy
      -    return ad_response, protocol
      -
      -
      -
      -
      -
      -

      Classes

      -
      -
      -class SoapAutodiscovery -(email, credentials=None) -
      -
      -

      Autodiscover is a Microsoft protocol for automatically getting the endpoint of the Exchange server and other -connection-related settings holding the email address using only the email address, and username and password of the -user.

      -

      For a description of the protocol implemented, see "Autodiscover for Exchange ActiveSync developers":

      -

      https://docs.microsoft.com/en-us/previous-versions/office/developer/exchange-server-interoperability-guidance/hh352638%28v%3dexchg.140%29

      -

      Descriptions of the steps from the article are provided in their respective methods in this class.

      -

      For a description of how to handle autodiscover error messages, see:

      -

      https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/handling-autodiscover-error-messages

      -

      A tip from the article: -The client can perform steps 1 through 4 in any order or in parallel to expedite the process, but it must wait for -responses to finish at each step before proceeding. Given that many organizations prefer to use the URL in step 2 to -set up the Autodiscover service, the client might try this step first.

      -

      Another possibly newer resource which has not yet been attempted is "Outlook 2016 Implementation of Autodiscover": -https://support.microsoft.com/en-us/help/3211279/outlook-2016-implementation-of-autodiscover

      -

      WARNING: The autodiscover protocol is very complicated. If you have problems autodiscovering using this -implementation, start by doing an official test at https://testconnectivity.microsoft.com

      -

      :param email: The email address to autodiscover -:param credentials: Credentials with authorization to make autodiscover lookups for this Account -(Default value = None)

      -
      - -Expand source code - -
      class SoapAutodiscovery(BaseAutodiscovery):
      -    URL_PATH = "autodiscover/autodiscover.svc"
      -
      -    def _build_response(self, ad_response):
      -        if not ad_response.autodiscover_smtp_address:
      -            # Autodiscover does not always return an email address. In that case, the requesting email should be used
      -            ad_response.autodiscover_smtp_address = self.email
      -
      -        protocol = Protocol(
      -            config=Configuration(
      -                service_endpoint=ad_response.ews_url,
      -                credentials=self.credentials,
      -                version=ad_response.version,
      -                # TODO: Detect EWS service auth type somehow
      -            )
      -        )
      -        return ad_response, protocol
      -
      -    def _quick(self, protocol):
      -        try:
      -            user_response = protocol.get_user_settings(user=self.email)
      -        except TransportError as e:
      -            raise AutoDiscoverFailed(f"Response error: {e}")
      -        return self._step_5(ad=user_response)
      -
      -    def _get_unauthenticated_response(self, url, method="post"):
      -        """Get response from server using the given HTTP method
      -
      -        :param url:
      -        :return:
      -        """
      -        # We are connecting to untrusted servers here, so take necessary precautions.
      -        self._ensure_valid_hostname(url)
      -
      -        protocol = AutodiscoverProtocol(
      -            config=Configuration(
      -                service_endpoint=url,
      -                retry_policy=self.INITIAL_RETRY_POLICY,
      -            )
      -        )
      -        return None, get_unauthenticated_autodiscover_response(protocol=protocol, method=method)
      -
      -    def _attempt_response(self, url):
      -        """Return an (is_valid_response, response) tuple.
      -
      -        :param url:
      -        :return:
      -        """
      -        self._urls_visited.append(url.lower())
      -        log.debug("Attempting to get a valid response from %s", url)
      -
      -        try:
      -            self._ensure_valid_hostname(url)
      -        except TransportError:
      -            return False, None
      -
      -        protocol = AutodiscoverProtocol(
      -            config=Configuration(
      -                service_endpoint=url,
      -                credentials=self.credentials,
      -                retry_policy=self.INITIAL_RETRY_POLICY,
      -            )
      -        )
      -        try:
      -            user_response = protocol.get_user_settings(user=self.email)
      -        except RedirectError as e:
      -            if self._redirect_url_is_valid(url=e.url):
      -                # The protocol does not specify this explicitly, but by looking at how testconnectivity.microsoft.com
      -                # works, it seems that we should follow this URL now and try to get a valid response.
      -                return self._attempt_response(url=e.url)
      -            log.debug("Invalid redirect URL: %s", e.url)
      -            return False, None
      -        except TransportError as e:
      -            log.debug("Failed to get a response: %s", e)
      -            return False, None
      -        except CONNECTION_ERRORS as e:
      -            log.debug("Failed to get a response: %s", e)
      -            return False, None
      -
      -        # We got a valid response. Unless this is a URL redirect response, we cache the result
      -        if not user_response.redirect_url:
      -            cache_key = self._cache_key
      -            log.debug("Adding cache entry for key %s: %s", cache_key, protocol.service_endpoint)
      -            autodiscover_cache[cache_key] = protocol
      -        return True, user_response
      -
      -

      Ancestors

      - -

      Class variables

      -
      -
      var URL_PATH
      -
      -
      -
      -
      -
      -
      -
      -
      - -
      - - - diff --git a/docs/exchangelib/autodiscover/properties.html b/docs/exchangelib/autodiscover/properties.html deleted file mode 100644 index d9472d11..00000000 --- a/docs/exchangelib/autodiscover/properties.html +++ /dev/null @@ -1,2172 +0,0 @@ - - - - - - -exchangelib.autodiscover.properties API documentation - - - - - - - - - - - -
      -
      -
      -

      Module exchangelib.autodiscover.properties

      -
      -
      -
      - -Expand source code - -
      from ..errors import AutoDiscoverFailed, ErrorNonExistentMailbox
      -from ..fields import (
      -    BooleanField,
      -    BuildField,
      -    Choice,
      -    ChoiceField,
      -    EmailAddressField,
      -    EWSElementField,
      -    IntegerField,
      -    OnOffField,
      -    ProtocolListField,
      -    TextField,
      -)
      -from ..properties import EWSElement
      -from ..transport import BASIC, CBA, DEFAULT_ENCODING, GSSAPI, NOAUTH, NTLM, SSPI
      -from ..util import AUTODISCOVER_BASE_NS, AUTODISCOVER_REQUEST_NS
      -from ..util import AUTODISCOVER_RESPONSE_NS as RNS
      -from ..util import ParseError, add_xml_child, create_element, is_xml, to_xml, xml_to_str
      -from ..version import Version
      -
      -
      -class AutodiscoverBase(EWSElement):
      -    NAMESPACE = RNS
      -
      -
      -class User(AutodiscoverBase):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/user-pox"""
      -
      -    ELEMENT_NAME = "User"
      -
      -    display_name = TextField(field_uri="DisplayName", namespace=RNS)
      -    legacy_dn = TextField(field_uri="LegacyDN", namespace=RNS)
      -    deployment_id = TextField(field_uri="DeploymentId", namespace=RNS)  # GUID format
      -    autodiscover_smtp_address = EmailAddressField(field_uri="AutoDiscoverSMTPAddress", namespace=RNS)
      -
      -
      -class IntExtUrlBase(AutodiscoverBase):
      -    external_url = TextField(field_uri="ExternalUrl", namespace=RNS)
      -    internal_url = TextField(field_uri="InternalUrl", namespace=RNS)
      -
      -
      -class AddressBook(IntExtUrlBase):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/addressbook-pox"""
      -
      -    ELEMENT_NAME = "AddressBook"
      -
      -
      -class MailStore(IntExtUrlBase):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailstore-pox"""
      -
      -    ELEMENT_NAME = "MailStore"
      -
      -
      -class NetworkRequirements(AutodiscoverBase):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/networkrequirements-pox"""
      -
      -    ELEMENT_NAME = "NetworkRequirements"
      -
      -    ipv4_start = TextField(field_uri="IPv4Start", namespace=RNS)
      -    ipv4_end = TextField(field_uri="IPv4End", namespace=RNS)
      -    ipv6_start = TextField(field_uri="IPv6Start", namespace=RNS)
      -    ipv6_end = TextField(field_uri="IPv6End", namespace=RNS)
      -
      -
      -class SimpleProtocol(AutodiscoverBase):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/protocol-pox
      -
      -    Used for the 'Internal' and 'External' elements that may contain a stripped-down version of the Protocol element.
      -    """
      -
      -    ELEMENT_NAME = "Protocol"
      -    WEB = "WEB"
      -    EXCH = "EXCH"
      -    EXPR = "EXPR"
      -    EXHTTP = "EXHTTP"
      -    TYPES = (WEB, EXCH, EXPR, EXHTTP)
      -
      -    type = ChoiceField(field_uri="Type", choices={Choice(c) for c in TYPES}, namespace=RNS)
      -    as_url = TextField(field_uri="ASUrl", namespace=RNS)
      -
      -
      -class IntExtBase(AutodiscoverBase):
      -    # TODO: 'OWAUrl' also has an AuthenticationMethod enum-style XML attribute with values:
      -    #  WindowsIntegrated, FBA, NTLM, Digest, Basic, LiveIdFba, OAuth
      -    owa_url = TextField(field_uri="OWAUrl", namespace=RNS)
      -    protocol = EWSElementField(value_cls=SimpleProtocol)
      -
      -
      -class Internal(IntExtBase):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/internal-pox"""
      -
      -    ELEMENT_NAME = "Internal"
      -
      -
      -class External(IntExtBase):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/external-pox"""
      -
      -    ELEMENT_NAME = "External"
      -
      -
      -class Protocol(SimpleProtocol):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/protocol-pox"""
      -
      -    # Attribute 'Type' is ignored here. Has a name conflict with the child element and does not seem useful.
      -    version = TextField(field_uri="Version", is_attribute=True, namespace=RNS)
      -    internal = EWSElementField(value_cls=Internal)
      -    external = EWSElementField(value_cls=External)
      -    ttl = IntegerField(field_uri="TTL", namespace=RNS, default=1)  # TTL for this autodiscover response, in hours
      -    server = TextField(field_uri="Server", namespace=RNS)
      -    server_dn = TextField(field_uri="ServerDN", namespace=RNS)
      -    server_version = BuildField(field_uri="ServerVersion", namespace=RNS)
      -    mdb_dn = TextField(field_uri="MdbDN", namespace=RNS)
      -    public_folder_server = TextField(field_uri="PublicFolderServer", namespace=RNS)
      -    port = IntegerField(field_uri="Port", namespace=RNS, min=1, max=65535)
      -    directory_port = IntegerField(field_uri="DirectoryPort", namespace=RNS, min=1, max=65535)
      -    referral_port = IntegerField(field_uri="ReferralPort", namespace=RNS, min=1, max=65535)
      -    ews_url = TextField(field_uri="EwsUrl", namespace=RNS)
      -    emws_url = TextField(field_uri="EmwsUrl", namespace=RNS)
      -    sharing_url = TextField(field_uri="SharingUrl", namespace=RNS)
      -    ecp_url = TextField(field_uri="EcpUrl", namespace=RNS)
      -    ecp_url_um = TextField(field_uri="EcpUrl-um", namespace=RNS)
      -    ecp_url_aggr = TextField(field_uri="EcpUrl-aggr", namespace=RNS)
      -    ecp_url_mt = TextField(field_uri="EcpUrl-mt", namespace=RNS)
      -    ecp_url_ret = TextField(field_uri="EcpUrl-ret", namespace=RNS)
      -    ecp_url_sms = TextField(field_uri="EcpUrl-sms", namespace=RNS)
      -    ecp_url_publish = TextField(field_uri="EcpUrl-publish", namespace=RNS)
      -    ecp_url_photo = TextField(field_uri="EcpUrl-photo", namespace=RNS)
      -    ecp_url_tm = TextField(field_uri="EcpUrl-tm", namespace=RNS)
      -    ecp_url_tm_creating = TextField(field_uri="EcpUrl-tmCreating", namespace=RNS)
      -    ecp_url_tm_hiding = TextField(field_uri="EcpUrl-tmHiding", namespace=RNS)
      -    ecp_url_tm_editing = TextField(field_uri="EcpUrl-tmEditing", namespace=RNS)
      -    ecp_url_extinstall = TextField(field_uri="EcpUrl-extinstall", namespace=RNS)
      -    oof_url = TextField(field_uri="OOFUrl", namespace=RNS)
      -    oab_url = TextField(field_uri="OABUrl", namespace=RNS)
      -    um_url = TextField(field_uri="UMUrl", namespace=RNS)
      -    ews_partner_url = TextField(field_uri="EwsPartnerUrl", namespace=RNS)
      -    login_name = TextField(field_uri="LoginName", namespace=RNS)
      -    domain_required = OnOffField(field_uri="DomainRequired", namespace=RNS)
      -    domain_name = TextField(field_uri="DomainName", namespace=RNS)
      -    spa = OnOffField(field_uri="SPA", namespace=RNS, default=True)
      -    auth_package = ChoiceField(
      -        field_uri="AuthPackage",
      -        namespace=RNS,
      -        choices={Choice(c) for c in ("basic", "kerb", "kerbntlm", "ntlm", "certificate", "negotiate", "nego2")},
      -    )
      -    cert_principal_name = TextField(field_uri="CertPrincipalName", namespace=RNS)
      -    ssl = OnOffField(field_uri="SSL", namespace=RNS, default=True)
      -    auth_required = OnOffField(field_uri="AuthRequired", namespace=RNS, default=True)
      -    use_pop_path = OnOffField(field_uri="UsePOPAuth", namespace=RNS)
      -    smtp_last = OnOffField(field_uri="SMTPLast", namespace=RNS, default=False)
      -    network_requirements = EWSElementField(value_cls=NetworkRequirements)
      -    address_book = EWSElementField(value_cls=AddressBook)
      -    mail_store = EWSElementField(value_cls=MailStore)
      -
      -    @property
      -    def auth_type(self):
      -        # Translates 'auth_package' value to our own 'auth_type' enum vals
      -        if not self.auth_required:
      -            return NOAUTH
      -        if not self.auth_package:
      -            return None
      -        return {
      -            # Missing in list are DIGEST and OAUTH2
      -            "basic": BASIC,
      -            "kerb": GSSAPI,
      -            "kerbntlm": NTLM,  # Means client can choose between NTLM and GSSAPI
      -            "ntlm": NTLM,
      -            "certificate": CBA,
      -            "negotiate": SSPI,  # Unsure about this one
      -            "nego2": GSSAPI,
      -            "anonymous": NOAUTH,  # Seen in some docs even though it's not mentioned in MSDN
      -        }.get(self.auth_package.lower())
      -
      -
      -class Error(EWSElement):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/error-pox"""
      -
      -    ELEMENT_NAME = "Error"
      -    NAMESPACE = AUTODISCOVER_BASE_NS
      -
      -    id = TextField(field_uri="Id", namespace=AUTODISCOVER_BASE_NS, is_attribute=True)
      -    time = TextField(field_uri="Time", namespace=AUTODISCOVER_BASE_NS, is_attribute=True)
      -    code = TextField(field_uri="ErrorCode", namespace=AUTODISCOVER_BASE_NS)
      -    message = TextField(field_uri="Message", namespace=AUTODISCOVER_BASE_NS)
      -    debug_data = TextField(field_uri="DebugData", namespace=AUTODISCOVER_BASE_NS)
      -
      -
      -class Account(AutodiscoverBase):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/account-pox"""
      -
      -    ELEMENT_NAME = "Account"
      -    REDIRECT_URL = "redirectUrl"
      -    REDIRECT_ADDR = "redirectAddr"
      -    SETTINGS = "settings"
      -    ACTIONS = (REDIRECT_URL, REDIRECT_ADDR, SETTINGS)
      -
      -    type = ChoiceField(field_uri="AccountType", namespace=RNS, choices={Choice("email")})
      -    action = ChoiceField(field_uri="Action", namespace=RNS, choices={Choice(p) for p in ACTIONS})
      -    microsoft_online = BooleanField(field_uri="MicrosoftOnline", namespace=RNS)
      -    redirect_url = TextField(field_uri="RedirectURL", namespace=RNS)
      -    redirect_address = EmailAddressField(field_uri="RedirectAddr", namespace=RNS)
      -    image = TextField(field_uri="Image", namespace=RNS)  # Path to image used for branding
      -    service_home = TextField(field_uri="ServiceHome", namespace=RNS)  # URL to website of ISP
      -    protocols = ProtocolListField()
      -    # 'SmtpAddress' is inside the 'PublicFolderInformation' element
      -    public_folder_smtp_address = TextField(field_uri="SmtpAddress", namespace=RNS)
      -
      -    @classmethod
      -    def from_xml(cls, elem, account):
      -        kwargs = {}
      -        public_folder_information = elem.find(f"{{{cls.NAMESPACE}}}PublicFolderInformation")
      -        for f in cls.FIELDS:
      -            if f.name == "public_folder_smtp_address":
      -                if public_folder_information is None:
      -                    continue
      -                kwargs[f.name] = f.from_xml(elem=public_folder_information, account=account)
      -                continue
      -            kwargs[f.name] = f.from_xml(elem=elem, account=account)
      -        cls._clear(elem)
      -        return cls(**kwargs)
      -
      -
      -class Response(AutodiscoverBase):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/response-pox"""
      -
      -    ELEMENT_NAME = "Response"
      -
      -    user = EWSElementField(value_cls=User)
      -    account = EWSElementField(value_cls=Account)
      -
      -    @property
      -    def redirect_address(self):
      -        try:
      -            if self.account.action != Account.REDIRECT_ADDR:
      -                return None
      -            return self.account.redirect_address
      -        except AttributeError:
      -            return None
      -
      -    @property
      -    def redirect_url(self):
      -        try:
      -            if self.account.action != Account.REDIRECT_URL:
      -                return None
      -            return self.account.redirect_url
      -        except AttributeError:
      -            return None
      -
      -    @property
      -    def autodiscover_smtp_address(self):
      -        # AutoDiscoverSMTPAddress might not be present in the XML. In this case, use the original email address.
      -        try:
      -            if self.account.action != Account.SETTINGS:
      -                return None
      -            return self.user.autodiscover_smtp_address
      -        except AttributeError:
      -            return None
      -
      -    @property
      -    def version(self):
      -        # Get the server version. Not all protocol entries have a server version, so we cheat a bit and also look at the
      -        # other ones that point to the same endpoint.
      -        ews_url = self.protocol.ews_url
      -        for protocol in self.account.protocols:
      -            if not protocol.ews_url or not protocol.server_version:
      -                continue
      -            if protocol.ews_url.lower() == ews_url.lower():
      -                return Version(build=protocol.server_version)
      -        return None
      -
      -    @property
      -    def protocol(self):
      -        """Return the protocol containing an EWS URL.
      -
      -        A response may contain a number of possible protocol types. EXPR is meant for EWS. See
      -        https://techcommunity.microsoft.com/t5/blogs/blogarticleprintpage/blog-id/Exchange/article-id/16
      -
      -        We allow fallback to EXCH if EXPR is not available, to support installations where EXPR is not available.
      -
      -        Additionally, some responses may contain an EXPR with no EWS URL. In that case, return EXCH, if available.
      -        """
      -        protocols = {p.type: p for p in self.account.protocols if p.ews_url}
      -        if Protocol.EXPR in protocols:
      -            return protocols[Protocol.EXPR]
      -        if Protocol.EXCH in protocols:
      -            return protocols[Protocol.EXCH]
      -        raise ValueError(
      -            f"No EWS URL found in any of the available protocols: {[str(p) for p in self.account.protocols]}"
      -        )
      -
      -
      -class ErrorResponse(EWSElement):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/response-pox
      -
      -    Like 'Response', but with a different namespace.
      -    """
      -
      -    ELEMENT_NAME = "Response"
      -    NAMESPACE = AUTODISCOVER_BASE_NS
      -
      -    error = EWSElementField(value_cls=Error)
      -
      -
      -class Autodiscover(EWSElement):
      -    ELEMENT_NAME = "Autodiscover"
      -    NAMESPACE = AUTODISCOVER_BASE_NS
      -
      -    response = EWSElementField(value_cls=Response)
      -    error_response = EWSElementField(value_cls=ErrorResponse)
      -
      -    @staticmethod
      -    def _clear(elem):
      -        # Parent implementation also clears the parent, but this element doesn't have one.
      -        elem.clear()
      -
      -    @classmethod
      -    def from_bytes(cls, bytes_content):
      -        """Create an instance from response bytes. An Autodiscover request and response example is available at:
      -        https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/pox-autodiscover-response-for-exchange
      -
      -        :param bytes_content:
      -        :return:
      -        """
      -        if not is_xml(bytes_content):
      -            raise ParseError(f"Response is not XML: {bytes_content}", "<not from file>", -1, 0)
      -        root = to_xml(bytes_content).getroot()  # May raise ParseError
      -        if root.tag != cls.response_tag():
      -            raise ParseError(f"Unknown root element in XML: {bytes_content}", "<not from file>", -1, 0)
      -        return cls.from_xml(elem=root, account=None)
      -
      -    def raise_errors(self):
      -        if self.response is not None:
      -            return
      -
      -        # Find an error message in the response and raise the relevant exception
      -        try:
      -            error_code = self.error_response.error.code
      -            message = self.error_response.error.message
      -            if message in ("The e-mail address cannot be found.", "The email address can't be found."):
      -                raise ErrorNonExistentMailbox("The SMTP address has no mailbox associated with it")
      -            raise AutoDiscoverFailed(f"Unknown error {error_code}: {message}")
      -        except AttributeError:
      -            raise AutoDiscoverFailed(f"Unknown autodiscover error response: {self.error_response}")
      -
      -    @staticmethod
      -    def payload(email):
      -        # Builds a full Autodiscover XML request
      -        payload = create_element("Autodiscover", attrs=dict(xmlns=AUTODISCOVER_REQUEST_NS))
      -        request = create_element("Request")
      -        add_xml_child(request, "EMailAddress", email)
      -        add_xml_child(request, "AcceptableResponseSchema", RNS)
      -        payload.append(request)
      -        return xml_to_str(payload, encoding=DEFAULT_ENCODING, xml_declaration=True)
      -
      -
      -
      -
      -
      -
      -
      -
      -
      -

      Classes

      -
      -
      -class Account -(**kwargs) -
      -
      - -
      - -Expand source code - -
      class Account(AutodiscoverBase):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/account-pox"""
      -
      -    ELEMENT_NAME = "Account"
      -    REDIRECT_URL = "redirectUrl"
      -    REDIRECT_ADDR = "redirectAddr"
      -    SETTINGS = "settings"
      -    ACTIONS = (REDIRECT_URL, REDIRECT_ADDR, SETTINGS)
      -
      -    type = ChoiceField(field_uri="AccountType", namespace=RNS, choices={Choice("email")})
      -    action = ChoiceField(field_uri="Action", namespace=RNS, choices={Choice(p) for p in ACTIONS})
      -    microsoft_online = BooleanField(field_uri="MicrosoftOnline", namespace=RNS)
      -    redirect_url = TextField(field_uri="RedirectURL", namespace=RNS)
      -    redirect_address = EmailAddressField(field_uri="RedirectAddr", namespace=RNS)
      -    image = TextField(field_uri="Image", namespace=RNS)  # Path to image used for branding
      -    service_home = TextField(field_uri="ServiceHome", namespace=RNS)  # URL to website of ISP
      -    protocols = ProtocolListField()
      -    # 'SmtpAddress' is inside the 'PublicFolderInformation' element
      -    public_folder_smtp_address = TextField(field_uri="SmtpAddress", namespace=RNS)
      -
      -    @classmethod
      -    def from_xml(cls, elem, account):
      -        kwargs = {}
      -        public_folder_information = elem.find(f"{{{cls.NAMESPACE}}}PublicFolderInformation")
      -        for f in cls.FIELDS:
      -            if f.name == "public_folder_smtp_address":
      -                if public_folder_information is None:
      -                    continue
      -                kwargs[f.name] = f.from_xml(elem=public_folder_information, account=account)
      -                continue
      -            kwargs[f.name] = f.from_xml(elem=elem, account=account)
      -        cls._clear(elem)
      -        return cls(**kwargs)
      -
      -

      Ancestors

      - -

      Class variables

      -
      -
      var ACTIONS
      -
      -
      -
      -
      var ELEMENT_NAME
      -
      -
      -
      -
      var FIELDS
      -
      -
      -
      -
      var REDIRECT_ADDR
      -
      -
      -
      -
      var REDIRECT_URL
      -
      -
      -
      -
      var SETTINGS
      -
      -
      -
      -
      -

      Static methods

      -
      -
      -def from_xml(elem, account) -
      -
      -
      -
      - -Expand source code - -
      @classmethod
      -def from_xml(cls, elem, account):
      -    kwargs = {}
      -    public_folder_information = elem.find(f"{{{cls.NAMESPACE}}}PublicFolderInformation")
      -    for f in cls.FIELDS:
      -        if f.name == "public_folder_smtp_address":
      -            if public_folder_information is None:
      -                continue
      -            kwargs[f.name] = f.from_xml(elem=public_folder_information, account=account)
      -            continue
      -        kwargs[f.name] = f.from_xml(elem=elem, account=account)
      -    cls._clear(elem)
      -    return cls(**kwargs)
      -
      -
      -
      -

      Instance variables

      -
      -
      var action
      -
      -
      -
      -
      var image
      -
      -
      -
      -
      var microsoft_online
      -
      -
      -
      -
      var protocols
      -
      -
      -
      -
      var public_folder_smtp_address
      -
      -
      -
      -
      var redirect_address
      -
      -
      -
      -
      var redirect_url
      -
      -
      -
      -
      var service_home
      -
      -
      -
      -
      var type
      -
      -
      -
      -
      -

      Inherited members

      - -
      -
      -class AddressBook -(**kwargs) -
      -
      - -
      - -Expand source code - -
      class AddressBook(IntExtUrlBase):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/addressbook-pox"""
      -
      -    ELEMENT_NAME = "AddressBook"
      -
      -

      Ancestors

      - -

      Class variables

      -
      -
      var ELEMENT_NAME
      -
      -
      -
      -
      -

      Inherited members

      - -
      -
      -class Autodiscover -(**kwargs) -
      -
      -

      Base class for all XML element implementations.

      -
      - -Expand source code - -
      class Autodiscover(EWSElement):
      -    ELEMENT_NAME = "Autodiscover"
      -    NAMESPACE = AUTODISCOVER_BASE_NS
      -
      -    response = EWSElementField(value_cls=Response)
      -    error_response = EWSElementField(value_cls=ErrorResponse)
      -
      -    @staticmethod
      -    def _clear(elem):
      -        # Parent implementation also clears the parent, but this element doesn't have one.
      -        elem.clear()
      -
      -    @classmethod
      -    def from_bytes(cls, bytes_content):
      -        """Create an instance from response bytes. An Autodiscover request and response example is available at:
      -        https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/pox-autodiscover-response-for-exchange
      -
      -        :param bytes_content:
      -        :return:
      -        """
      -        if not is_xml(bytes_content):
      -            raise ParseError(f"Response is not XML: {bytes_content}", "<not from file>", -1, 0)
      -        root = to_xml(bytes_content).getroot()  # May raise ParseError
      -        if root.tag != cls.response_tag():
      -            raise ParseError(f"Unknown root element in XML: {bytes_content}", "<not from file>", -1, 0)
      -        return cls.from_xml(elem=root, account=None)
      -
      -    def raise_errors(self):
      -        if self.response is not None:
      -            return
      -
      -        # Find an error message in the response and raise the relevant exception
      -        try:
      -            error_code = self.error_response.error.code
      -            message = self.error_response.error.message
      -            if message in ("The e-mail address cannot be found.", "The email address can't be found."):
      -                raise ErrorNonExistentMailbox("The SMTP address has no mailbox associated with it")
      -            raise AutoDiscoverFailed(f"Unknown error {error_code}: {message}")
      -        except AttributeError:
      -            raise AutoDiscoverFailed(f"Unknown autodiscover error response: {self.error_response}")
      -
      -    @staticmethod
      -    def payload(email):
      -        # Builds a full Autodiscover XML request
      -        payload = create_element("Autodiscover", attrs=dict(xmlns=AUTODISCOVER_REQUEST_NS))
      -        request = create_element("Request")
      -        add_xml_child(request, "EMailAddress", email)
      -        add_xml_child(request, "AcceptableResponseSchema", RNS)
      -        payload.append(request)
      -        return xml_to_str(payload, encoding=DEFAULT_ENCODING, xml_declaration=True)
      -
      -

      Ancestors

      - -

      Class variables

      -
      -
      var ELEMENT_NAME
      -
      -
      -
      -
      var FIELDS
      -
      -
      -
      -
      var NAMESPACE
      -
      -
      -
      -
      -

      Static methods

      -
      -
      -def from_bytes(bytes_content) -
      -
      -

      Create an instance from response bytes. An Autodiscover request and response example is available at: -https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/pox-autodiscover-response-for-exchange

      -

      :param bytes_content: -:return:

      -
      - -Expand source code - -
      @classmethod
      -def from_bytes(cls, bytes_content):
      -    """Create an instance from response bytes. An Autodiscover request and response example is available at:
      -    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/pox-autodiscover-response-for-exchange
      -
      -    :param bytes_content:
      -    :return:
      -    """
      -    if not is_xml(bytes_content):
      -        raise ParseError(f"Response is not XML: {bytes_content}", "<not from file>", -1, 0)
      -    root = to_xml(bytes_content).getroot()  # May raise ParseError
      -    if root.tag != cls.response_tag():
      -        raise ParseError(f"Unknown root element in XML: {bytes_content}", "<not from file>", -1, 0)
      -    return cls.from_xml(elem=root, account=None)
      -
      -
      -
      -def payload(email) -
      -
      -
      -
      - -Expand source code - -
      @staticmethod
      -def payload(email):
      -    # Builds a full Autodiscover XML request
      -    payload = create_element("Autodiscover", attrs=dict(xmlns=AUTODISCOVER_REQUEST_NS))
      -    request = create_element("Request")
      -    add_xml_child(request, "EMailAddress", email)
      -    add_xml_child(request, "AcceptableResponseSchema", RNS)
      -    payload.append(request)
      -    return xml_to_str(payload, encoding=DEFAULT_ENCODING, xml_declaration=True)
      -
      -
      -
      -

      Instance variables

      -
      -
      var error_response
      -
      -
      -
      -
      var response
      -
      -
      -
      -
      -

      Methods

      -
      -
      -def raise_errors(self) -
      -
      -
      -
      - -Expand source code - -
      def raise_errors(self):
      -    if self.response is not None:
      -        return
      -
      -    # Find an error message in the response and raise the relevant exception
      -    try:
      -        error_code = self.error_response.error.code
      -        message = self.error_response.error.message
      -        if message in ("The e-mail address cannot be found.", "The email address can't be found."):
      -            raise ErrorNonExistentMailbox("The SMTP address has no mailbox associated with it")
      -        raise AutoDiscoverFailed(f"Unknown error {error_code}: {message}")
      -    except AttributeError:
      -        raise AutoDiscoverFailed(f"Unknown autodiscover error response: {self.error_response}")
      -
      -
      -
      -

      Inherited members

      - -
      -
      -class AutodiscoverBase -(**kwargs) -
      -
      -

      Base class for all XML element implementations.

      -
      - -Expand source code - -
      class AutodiscoverBase(EWSElement):
      -    NAMESPACE = RNS
      -
      -

      Ancestors

      - -

      Subclasses

      - -

      Class variables

      -
      -
      var NAMESPACE
      -
      -
      -
      -
      -

      Inherited members

      - -
      -
      -class Error -(**kwargs) -
      -
      - -
      - -Expand source code - -
      class Error(EWSElement):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/error-pox"""
      -
      -    ELEMENT_NAME = "Error"
      -    NAMESPACE = AUTODISCOVER_BASE_NS
      -
      -    id = TextField(field_uri="Id", namespace=AUTODISCOVER_BASE_NS, is_attribute=True)
      -    time = TextField(field_uri="Time", namespace=AUTODISCOVER_BASE_NS, is_attribute=True)
      -    code = TextField(field_uri="ErrorCode", namespace=AUTODISCOVER_BASE_NS)
      -    message = TextField(field_uri="Message", namespace=AUTODISCOVER_BASE_NS)
      -    debug_data = TextField(field_uri="DebugData", namespace=AUTODISCOVER_BASE_NS)
      -
      -

      Ancestors

      - -

      Class variables

      -
      -
      var ELEMENT_NAME
      -
      -
      -
      -
      var FIELDS
      -
      -
      -
      -
      var NAMESPACE
      -
      -
      -
      -
      -

      Instance variables

      -
      -
      var code
      -
      -
      -
      -
      var debug_data
      -
      -
      -
      -
      var id
      -
      -
      -
      -
      var message
      -
      -
      -
      -
      var time
      -
      -
      -
      -
      -

      Inherited members

      - -
      -
      -class ErrorResponse -(**kwargs) -
      -
      - -
      - -Expand source code - -
      class ErrorResponse(EWSElement):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/response-pox
      -
      -    Like 'Response', but with a different namespace.
      -    """
      -
      -    ELEMENT_NAME = "Response"
      -    NAMESPACE = AUTODISCOVER_BASE_NS
      -
      -    error = EWSElementField(value_cls=Error)
      -
      -

      Ancestors

      - -

      Class variables

      -
      -
      var ELEMENT_NAME
      -
      -
      -
      -
      var FIELDS
      -
      -
      -
      -
      var NAMESPACE
      -
      -
      -
      -
      -

      Instance variables

      -
      -
      var error
      -
      -
      -
      -
      -

      Inherited members

      - -
      -
      -class External -(**kwargs) -
      -
      - -
      - -Expand source code - -
      class External(IntExtBase):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/external-pox"""
      -
      -    ELEMENT_NAME = "External"
      -
      -

      Ancestors

      - -

      Class variables

      -
      -
      var ELEMENT_NAME
      -
      -
      -
      -
      -

      Inherited members

      - -
      -
      -class IntExtBase -(**kwargs) -
      -
      -

      Base class for all XML element implementations.

      -
      - -Expand source code - -
      class IntExtBase(AutodiscoverBase):
      -    # TODO: 'OWAUrl' also has an AuthenticationMethod enum-style XML attribute with values:
      -    #  WindowsIntegrated, FBA, NTLM, Digest, Basic, LiveIdFba, OAuth
      -    owa_url = TextField(field_uri="OWAUrl", namespace=RNS)
      -    protocol = EWSElementField(value_cls=SimpleProtocol)
      -
      -

      Ancestors

      - -

      Subclasses

      - -

      Class variables

      -
      -
      var FIELDS
      -
      -
      -
      -
      -

      Instance variables

      -
      -
      var owa_url
      -
      -
      -
      -
      var protocol
      -
      -
      -
      -
      -

      Inherited members

      - -
      -
      -class IntExtUrlBase -(**kwargs) -
      -
      -

      Base class for all XML element implementations.

      -
      - -Expand source code - -
      class IntExtUrlBase(AutodiscoverBase):
      -    external_url = TextField(field_uri="ExternalUrl", namespace=RNS)
      -    internal_url = TextField(field_uri="InternalUrl", namespace=RNS)
      -
      -

      Ancestors

      - -

      Subclasses

      - -

      Class variables

      -
      -
      var FIELDS
      -
      -
      -
      -
      -

      Instance variables

      -
      -
      var external_url
      -
      -
      -
      -
      var internal_url
      -
      -
      -
      -
      -

      Inherited members

      - -
      -
      -class Internal -(**kwargs) -
      -
      - -
      - -Expand source code - -
      class Internal(IntExtBase):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/internal-pox"""
      -
      -    ELEMENT_NAME = "Internal"
      -
      -

      Ancestors

      - -

      Class variables

      -
      -
      var ELEMENT_NAME
      -
      -
      -
      -
      -

      Inherited members

      - -
      -
      -class MailStore -(**kwargs) -
      -
      - -
      - -Expand source code - -
      class MailStore(IntExtUrlBase):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/mailstore-pox"""
      -
      -    ELEMENT_NAME = "MailStore"
      -
      -

      Ancestors

      - -

      Class variables

      -
      -
      var ELEMENT_NAME
      -
      -
      -
      -
      -

      Inherited members

      - -
      -
      -class NetworkRequirements -(**kwargs) -
      -
      - -
      - -Expand source code - -
      class NetworkRequirements(AutodiscoverBase):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/networkrequirements-pox"""
      -
      -    ELEMENT_NAME = "NetworkRequirements"
      -
      -    ipv4_start = TextField(field_uri="IPv4Start", namespace=RNS)
      -    ipv4_end = TextField(field_uri="IPv4End", namespace=RNS)
      -    ipv6_start = TextField(field_uri="IPv6Start", namespace=RNS)
      -    ipv6_end = TextField(field_uri="IPv6End", namespace=RNS)
      -
      -

      Ancestors

      - -

      Class variables

      -
      -
      var ELEMENT_NAME
      -
      -
      -
      -
      var FIELDS
      -
      -
      -
      -
      -

      Instance variables

      -
      -
      var ipv4_end
      -
      -
      -
      -
      var ipv4_start
      -
      -
      -
      -
      var ipv6_end
      -
      -
      -
      -
      var ipv6_start
      -
      -
      -
      -
      -

      Inherited members

      - -
      -
      -class Protocol -(**kwargs) -
      -
      - -
      - -Expand source code - -
      class Protocol(SimpleProtocol):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/protocol-pox"""
      -
      -    # Attribute 'Type' is ignored here. Has a name conflict with the child element and does not seem useful.
      -    version = TextField(field_uri="Version", is_attribute=True, namespace=RNS)
      -    internal = EWSElementField(value_cls=Internal)
      -    external = EWSElementField(value_cls=External)
      -    ttl = IntegerField(field_uri="TTL", namespace=RNS, default=1)  # TTL for this autodiscover response, in hours
      -    server = TextField(field_uri="Server", namespace=RNS)
      -    server_dn = TextField(field_uri="ServerDN", namespace=RNS)
      -    server_version = BuildField(field_uri="ServerVersion", namespace=RNS)
      -    mdb_dn = TextField(field_uri="MdbDN", namespace=RNS)
      -    public_folder_server = TextField(field_uri="PublicFolderServer", namespace=RNS)
      -    port = IntegerField(field_uri="Port", namespace=RNS, min=1, max=65535)
      -    directory_port = IntegerField(field_uri="DirectoryPort", namespace=RNS, min=1, max=65535)
      -    referral_port = IntegerField(field_uri="ReferralPort", namespace=RNS, min=1, max=65535)
      -    ews_url = TextField(field_uri="EwsUrl", namespace=RNS)
      -    emws_url = TextField(field_uri="EmwsUrl", namespace=RNS)
      -    sharing_url = TextField(field_uri="SharingUrl", namespace=RNS)
      -    ecp_url = TextField(field_uri="EcpUrl", namespace=RNS)
      -    ecp_url_um = TextField(field_uri="EcpUrl-um", namespace=RNS)
      -    ecp_url_aggr = TextField(field_uri="EcpUrl-aggr", namespace=RNS)
      -    ecp_url_mt = TextField(field_uri="EcpUrl-mt", namespace=RNS)
      -    ecp_url_ret = TextField(field_uri="EcpUrl-ret", namespace=RNS)
      -    ecp_url_sms = TextField(field_uri="EcpUrl-sms", namespace=RNS)
      -    ecp_url_publish = TextField(field_uri="EcpUrl-publish", namespace=RNS)
      -    ecp_url_photo = TextField(field_uri="EcpUrl-photo", namespace=RNS)
      -    ecp_url_tm = TextField(field_uri="EcpUrl-tm", namespace=RNS)
      -    ecp_url_tm_creating = TextField(field_uri="EcpUrl-tmCreating", namespace=RNS)
      -    ecp_url_tm_hiding = TextField(field_uri="EcpUrl-tmHiding", namespace=RNS)
      -    ecp_url_tm_editing = TextField(field_uri="EcpUrl-tmEditing", namespace=RNS)
      -    ecp_url_extinstall = TextField(field_uri="EcpUrl-extinstall", namespace=RNS)
      -    oof_url = TextField(field_uri="OOFUrl", namespace=RNS)
      -    oab_url = TextField(field_uri="OABUrl", namespace=RNS)
      -    um_url = TextField(field_uri="UMUrl", namespace=RNS)
      -    ews_partner_url = TextField(field_uri="EwsPartnerUrl", namespace=RNS)
      -    login_name = TextField(field_uri="LoginName", namespace=RNS)
      -    domain_required = OnOffField(field_uri="DomainRequired", namespace=RNS)
      -    domain_name = TextField(field_uri="DomainName", namespace=RNS)
      -    spa = OnOffField(field_uri="SPA", namespace=RNS, default=True)
      -    auth_package = ChoiceField(
      -        field_uri="AuthPackage",
      -        namespace=RNS,
      -        choices={Choice(c) for c in ("basic", "kerb", "kerbntlm", "ntlm", "certificate", "negotiate", "nego2")},
      -    )
      -    cert_principal_name = TextField(field_uri="CertPrincipalName", namespace=RNS)
      -    ssl = OnOffField(field_uri="SSL", namespace=RNS, default=True)
      -    auth_required = OnOffField(field_uri="AuthRequired", namespace=RNS, default=True)
      -    use_pop_path = OnOffField(field_uri="UsePOPAuth", namespace=RNS)
      -    smtp_last = OnOffField(field_uri="SMTPLast", namespace=RNS, default=False)
      -    network_requirements = EWSElementField(value_cls=NetworkRequirements)
      -    address_book = EWSElementField(value_cls=AddressBook)
      -    mail_store = EWSElementField(value_cls=MailStore)
      -
      -    @property
      -    def auth_type(self):
      -        # Translates 'auth_package' value to our own 'auth_type' enum vals
      -        if not self.auth_required:
      -            return NOAUTH
      -        if not self.auth_package:
      -            return None
      -        return {
      -            # Missing in list are DIGEST and OAUTH2
      -            "basic": BASIC,
      -            "kerb": GSSAPI,
      -            "kerbntlm": NTLM,  # Means client can choose between NTLM and GSSAPI
      -            "ntlm": NTLM,
      -            "certificate": CBA,
      -            "negotiate": SSPI,  # Unsure about this one
      -            "nego2": GSSAPI,
      -            "anonymous": NOAUTH,  # Seen in some docs even though it's not mentioned in MSDN
      -        }.get(self.auth_package.lower())
      -
      -

      Ancestors

      - -

      Class variables

      -
      -
      var FIELDS
      -
      -
      -
      -
      -

      Instance variables

      -
      -
      var address_book
      -
      -
      -
      -
      var auth_package
      -
      -
      -
      -
      var auth_required
      -
      -
      -
      -
      var auth_type
      -
      -
      -
      - -Expand source code - -
      @property
      -def auth_type(self):
      -    # Translates 'auth_package' value to our own 'auth_type' enum vals
      -    if not self.auth_required:
      -        return NOAUTH
      -    if not self.auth_package:
      -        return None
      -    return {
      -        # Missing in list are DIGEST and OAUTH2
      -        "basic": BASIC,
      -        "kerb": GSSAPI,
      -        "kerbntlm": NTLM,  # Means client can choose between NTLM and GSSAPI
      -        "ntlm": NTLM,
      -        "certificate": CBA,
      -        "negotiate": SSPI,  # Unsure about this one
      -        "nego2": GSSAPI,
      -        "anonymous": NOAUTH,  # Seen in some docs even though it's not mentioned in MSDN
      -    }.get(self.auth_package.lower())
      -
      -
      -
      var cert_principal_name
      -
      -
      -
      -
      var directory_port
      -
      -
      -
      -
      var domain_name
      -
      -
      -
      -
      var domain_required
      -
      -
      -
      -
      var ecp_url
      -
      -
      -
      -
      var ecp_url_aggr
      -
      -
      -
      -
      var ecp_url_extinstall
      -
      -
      -
      -
      var ecp_url_mt
      -
      -
      -
      -
      var ecp_url_photo
      -
      -
      -
      -
      var ecp_url_publish
      -
      -
      -
      -
      var ecp_url_ret
      -
      -
      -
      -
      var ecp_url_sms
      -
      -
      -
      -
      var ecp_url_tm
      -
      -
      -
      -
      var ecp_url_tm_creating
      -
      -
      -
      -
      var ecp_url_tm_editing
      -
      -
      -
      -
      var ecp_url_tm_hiding
      -
      -
      -
      -
      var ecp_url_um
      -
      -
      -
      -
      var emws_url
      -
      -
      -
      -
      var ews_partner_url
      -
      -
      -
      -
      var ews_url
      -
      -
      -
      -
      var external
      -
      -
      -
      -
      var internal
      -
      -
      -
      -
      var login_name
      -
      -
      -
      -
      var mail_store
      -
      -
      -
      -
      var mdb_dn
      -
      -
      -
      -
      var network_requirements
      -
      -
      -
      -
      var oab_url
      -
      -
      -
      -
      var oof_url
      -
      -
      -
      -
      var port
      -
      -
      -
      -
      var public_folder_server
      -
      -
      -
      -
      var referral_port
      -
      -
      -
      -
      var server
      -
      -
      -
      -
      var server_dn
      -
      -
      -
      -
      var server_version
      -
      -
      -
      -
      var sharing_url
      -
      -
      -
      -
      var smtp_last
      -
      -
      -
      -
      var spa
      -
      -
      -
      -
      var ssl
      -
      -
      -
      -
      var ttl
      -
      -
      -
      -
      var um_url
      -
      -
      -
      -
      var use_pop_path
      -
      -
      -
      -
      var version
      -
      -
      -
      -
      -

      Inherited members

      - -
      -
      -class Response -(**kwargs) -
      -
      - -
      - -Expand source code - -
      class Response(AutodiscoverBase):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/response-pox"""
      -
      -    ELEMENT_NAME = "Response"
      -
      -    user = EWSElementField(value_cls=User)
      -    account = EWSElementField(value_cls=Account)
      -
      -    @property
      -    def redirect_address(self):
      -        try:
      -            if self.account.action != Account.REDIRECT_ADDR:
      -                return None
      -            return self.account.redirect_address
      -        except AttributeError:
      -            return None
      -
      -    @property
      -    def redirect_url(self):
      -        try:
      -            if self.account.action != Account.REDIRECT_URL:
      -                return None
      -            return self.account.redirect_url
      -        except AttributeError:
      -            return None
      -
      -    @property
      -    def autodiscover_smtp_address(self):
      -        # AutoDiscoverSMTPAddress might not be present in the XML. In this case, use the original email address.
      -        try:
      -            if self.account.action != Account.SETTINGS:
      -                return None
      -            return self.user.autodiscover_smtp_address
      -        except AttributeError:
      -            return None
      -
      -    @property
      -    def version(self):
      -        # Get the server version. Not all protocol entries have a server version, so we cheat a bit and also look at the
      -        # other ones that point to the same endpoint.
      -        ews_url = self.protocol.ews_url
      -        for protocol in self.account.protocols:
      -            if not protocol.ews_url or not protocol.server_version:
      -                continue
      -            if protocol.ews_url.lower() == ews_url.lower():
      -                return Version(build=protocol.server_version)
      -        return None
      -
      -    @property
      -    def protocol(self):
      -        """Return the protocol containing an EWS URL.
      -
      -        A response may contain a number of possible protocol types. EXPR is meant for EWS. See
      -        https://techcommunity.microsoft.com/t5/blogs/blogarticleprintpage/blog-id/Exchange/article-id/16
      -
      -        We allow fallback to EXCH if EXPR is not available, to support installations where EXPR is not available.
      -
      -        Additionally, some responses may contain an EXPR with no EWS URL. In that case, return EXCH, if available.
      -        """
      -        protocols = {p.type: p for p in self.account.protocols if p.ews_url}
      -        if Protocol.EXPR in protocols:
      -            return protocols[Protocol.EXPR]
      -        if Protocol.EXCH in protocols:
      -            return protocols[Protocol.EXCH]
      -        raise ValueError(
      -            f"No EWS URL found in any of the available protocols: {[str(p) for p in self.account.protocols]}"
      -        )
      -
      -

      Ancestors

      - -

      Class variables

      -
      -
      var ELEMENT_NAME
      -
      -
      -
      -
      var FIELDS
      -
      -
      -
      -
      -

      Instance variables

      -
      -
      var account
      -
      -
      -
      -
      var autodiscover_smtp_address
      -
      -
      -
      - -Expand source code - -
      @property
      -def autodiscover_smtp_address(self):
      -    # AutoDiscoverSMTPAddress might not be present in the XML. In this case, use the original email address.
      -    try:
      -        if self.account.action != Account.SETTINGS:
      -            return None
      -        return self.user.autodiscover_smtp_address
      -    except AttributeError:
      -        return None
      -
      -
      -
      var protocol
      -
      -

      Return the protocol containing an EWS URL.

      -

      A response may contain a number of possible protocol types. EXPR is meant for EWS. See -https://techcommunity.microsoft.com/t5/blogs/blogarticleprintpage/blog-id/Exchange/article-id/16

      -

      We allow fallback to EXCH if EXPR is not available, to support installations where EXPR is not available.

      -

      Additionally, some responses may contain an EXPR with no EWS URL. In that case, return EXCH, if available.

      -
      - -Expand source code - -
      @property
      -def protocol(self):
      -    """Return the protocol containing an EWS URL.
      -
      -    A response may contain a number of possible protocol types. EXPR is meant for EWS. See
      -    https://techcommunity.microsoft.com/t5/blogs/blogarticleprintpage/blog-id/Exchange/article-id/16
      -
      -    We allow fallback to EXCH if EXPR is not available, to support installations where EXPR is not available.
      -
      -    Additionally, some responses may contain an EXPR with no EWS URL. In that case, return EXCH, if available.
      -    """
      -    protocols = {p.type: p for p in self.account.protocols if p.ews_url}
      -    if Protocol.EXPR in protocols:
      -        return protocols[Protocol.EXPR]
      -    if Protocol.EXCH in protocols:
      -        return protocols[Protocol.EXCH]
      -    raise ValueError(
      -        f"No EWS URL found in any of the available protocols: {[str(p) for p in self.account.protocols]}"
      -    )
      -
      -
      -
      var redirect_address
      -
      -
      -
      - -Expand source code - -
      @property
      -def redirect_address(self):
      -    try:
      -        if self.account.action != Account.REDIRECT_ADDR:
      -            return None
      -        return self.account.redirect_address
      -    except AttributeError:
      -        return None
      -
      -
      -
      var redirect_url
      -
      -
      -
      - -Expand source code - -
      @property
      -def redirect_url(self):
      -    try:
      -        if self.account.action != Account.REDIRECT_URL:
      -            return None
      -        return self.account.redirect_url
      -    except AttributeError:
      -        return None
      -
      -
      -
      var user
      -
      -
      -
      -
      var version
      -
      -
      -
      - -Expand source code - -
      @property
      -def version(self):
      -    # Get the server version. Not all protocol entries have a server version, so we cheat a bit and also look at the
      -    # other ones that point to the same endpoint.
      -    ews_url = self.protocol.ews_url
      -    for protocol in self.account.protocols:
      -        if not protocol.ews_url or not protocol.server_version:
      -            continue
      -        if protocol.ews_url.lower() == ews_url.lower():
      -            return Version(build=protocol.server_version)
      -    return None
      -
      -
      -
      -

      Inherited members

      - -
      -
      -class SimpleProtocol -(**kwargs) -
      -
      -

      MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/protocol-pox

      -

      Used for the 'Internal' and 'External' elements that may contain a stripped-down version of the Protocol element.

      -
      - -Expand source code - -
      class SimpleProtocol(AutodiscoverBase):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/protocol-pox
      -
      -    Used for the 'Internal' and 'External' elements that may contain a stripped-down version of the Protocol element.
      -    """
      -
      -    ELEMENT_NAME = "Protocol"
      -    WEB = "WEB"
      -    EXCH = "EXCH"
      -    EXPR = "EXPR"
      -    EXHTTP = "EXHTTP"
      -    TYPES = (WEB, EXCH, EXPR, EXHTTP)
      -
      -    type = ChoiceField(field_uri="Type", choices={Choice(c) for c in TYPES}, namespace=RNS)
      -    as_url = TextField(field_uri="ASUrl", namespace=RNS)
      -
      -

      Ancestors

      - -

      Subclasses

      - -

      Class variables

      -
      -
      var ELEMENT_NAME
      -
      -
      -
      -
      var EXCH
      -
      -
      -
      -
      var EXHTTP
      -
      -
      -
      -
      var EXPR
      -
      -
      -
      -
      var FIELDS
      -
      -
      -
      -
      var TYPES
      -
      -
      -
      -
      var WEB
      -
      -
      -
      -
      -

      Instance variables

      -
      -
      var as_url
      -
      -
      -
      -
      var type
      -
      -
      -
      -
      -

      Inherited members

      - -
      -
      -class User -(**kwargs) -
      -
      - -
      - -Expand source code - -
      class User(AutodiscoverBase):
      -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/user-pox"""
      -
      -    ELEMENT_NAME = "User"
      -
      -    display_name = TextField(field_uri="DisplayName", namespace=RNS)
      -    legacy_dn = TextField(field_uri="LegacyDN", namespace=RNS)
      -    deployment_id = TextField(field_uri="DeploymentId", namespace=RNS)  # GUID format
      -    autodiscover_smtp_address = EmailAddressField(field_uri="AutoDiscoverSMTPAddress", namespace=RNS)
      -
      -

      Ancestors

      - -

      Class variables

      -
      -
      var ELEMENT_NAME
      -
      -
      -
      -
      var FIELDS
      -
      -
      -
      -
      -

      Instance variables

      -
      -
      var autodiscover_smtp_address
      -
      -
      -
      -
      var deployment_id
      -
      -
      -
      -
      var display_name
      -
      -
      -
      -
      var legacy_dn
      -
      -
      -
      -
      -

      Inherited members

      - -
      -
      -
      -
      - -
      - - - diff --git a/docs/exchangelib/index.html b/docs/exchangelib/index.html index b4cc63ec..09031a30 100644 --- a/docs/exchangelib/index.html +++ b/docs/exchangelib/index.html @@ -64,7 +64,7 @@

      Package exchangelib

      from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "5.0.2" +__version__ = "5.0.3" __all__ = [ "__version__", @@ -4688,7 +4688,10 @@

      Instance variables

      ) direct_reports = MailboxListField( field_uri="contacts:DirectReports", supported_from=EXCHANGE_2010_SP2, is_read_only=True - ) + ) + # O365 throws ErrorInternalServerError "[0x004f0102] MapiReplyToBlob" if UniqueBody is requested + unique_body_idx = Item.FIELDS.index_by_name("unique_body") + FIELDS = Item.FIELDS[:unique_body_idx] + Item.FIELDS[unique_body_idx + 1 :]

      Ancestors

        @@ -4708,6 +4711,10 @@

        Class variables

        +
        var unique_body_idx
        +
        +
        +

    Instance variables

    @@ -5081,7 +5088,11 @@

    Inherited members

    contact_source = ChoiceField( field_uri="contacts:ContactSource", choices={Choice("Store"), Choice("ActiveDirectory")}, is_read_only=True ) - members = MemberListField(field_uri="distributionlist:Members") + members = MemberListField(field_uri="distributionlist:Members") + + # O365 throws ErrorInternalServerError "[0x004f0102] MapiReplyToBlob" if UniqueBody is requested + unique_body_idx = Item.FIELDS.index_by_name("unique_body") + FIELDS = Item.FIELDS[:unique_body_idx] + Item.FIELDS[unique_body_idx + 1 :]

    Ancestors

      @@ -5101,6 +5112,10 @@

      Class variables

      +
      var unique_body_idx
      +
      +
      +

    Instance variables

    @@ -12090,6 +12105,10 @@

    Methods

    status_description = CharField(field_uri="task:StatusDescription", is_read_only=True) total_work = IntegerField(field_uri="task:TotalWork", min=0) + # O365 throws ErrorInternalServerError "[0x004f0102] MapiReplyToBlob" if UniqueBody is requested + unique_body_idx = Item.FIELDS.index_by_name("unique_body") + FIELDS = Item.FIELDS[:unique_body_idx] + Item.FIELDS[unique_body_idx + 1 :] + def clean(self, version=None): super().clean(version=version) if self.due_date and self.start_date and self.due_date < self.start_date: @@ -12168,6 +12187,10 @@

    Class variables

    +
    var unique_body_idx
    +
    +
    +

    Instance variables

    @@ -13069,6 +13092,7 @@

    Contact
  • profession
  • spouse_name
  • surname
  • +
  • unique_body_idx
  • user_smime_certificate
  • wedding_anniversary
  • @@ -13102,6 +13126,7 @@

    display_name
  • file_as
  • members
  • +
  • unique_body_idx
  • @@ -13536,6 +13561,7 @@

    Taskstatus

  • status_description
  • total_work
  • +
  • unique_body_idx
  • diff --git a/docs/exchangelib/items/contact.html b/docs/exchangelib/items/contact.html index a479a399..5af9f3b9 100644 --- a/docs/exchangelib/items/contact.html +++ b/docs/exchangelib/items/contact.html @@ -162,6 +162,9 @@

    Module exchangelib.items.contact

    direct_reports = MailboxListField( field_uri="contacts:DirectReports", supported_from=EXCHANGE_2010_SP2, is_read_only=True ) + # O365 throws ErrorInternalServerError "[0x004f0102] MapiReplyToBlob" if UniqueBody is requested + unique_body_idx = Item.FIELDS.index_by_name("unique_body") + FIELDS = Item.FIELDS[:unique_body_idx] + Item.FIELDS[unique_body_idx + 1 :] class Persona(IdChangeKeyMixIn): @@ -280,7 +283,11 @@

    Module exchangelib.items.contact

    contact_source = ChoiceField( field_uri="contacts:ContactSource", choices={Choice("Store"), Choice("ActiveDirectory")}, is_read_only=True ) - members = MemberListField(field_uri="distributionlist:Members")
    + members = MemberListField(field_uri="distributionlist:Members") + + # O365 throws ErrorInternalServerError "[0x004f0102] MapiReplyToBlob" if UniqueBody is requested + unique_body_idx = Item.FIELDS.index_by_name("unique_body") + FIELDS = Item.FIELDS[:unique_body_idx] + Item.FIELDS[unique_body_idx + 1 :]
  • @@ -403,7 +410,10 @@

    Classes

    ) direct_reports = MailboxListField( field_uri="contacts:DirectReports", supported_from=EXCHANGE_2010_SP2, is_read_only=True - ) + ) + # O365 throws ErrorInternalServerError "[0x004f0102] MapiReplyToBlob" if UniqueBody is requested + unique_body_idx = Item.FIELDS.index_by_name("unique_body") + FIELDS = Item.FIELDS[:unique_body_idx] + Item.FIELDS[unique_body_idx + 1 :]

    Ancestors

      @@ -423,6 +433,10 @@

      Class variables

      +
      var unique_body_idx
      +
      +
      +

      Instance variables

      @@ -639,7 +653,11 @@

      Inherited members

      contact_source = ChoiceField( field_uri="contacts:ContactSource", choices={Choice("Store"), Choice("ActiveDirectory")}, is_read_only=True ) - members = MemberListField(field_uri="distributionlist:Members") + members = MemberListField(field_uri="distributionlist:Members") + + # O365 throws ErrorInternalServerError "[0x004f0102] MapiReplyToBlob" if UniqueBody is requested + unique_body_idx = Item.FIELDS.index_by_name("unique_body") + FIELDS = Item.FIELDS[:unique_body_idx] + Item.FIELDS[unique_body_idx + 1 :]

      Ancestors

        @@ -659,6 +677,10 @@

        Class variables

        +
        var unique_body_idx
        +
        +
        +

      Instance variables

      @@ -1289,6 +1311,7 @@

      profession
    • spouse_name
    • surname
    • +
    • unique_body_idx
    • user_smime_certificate
    • wedding_anniversary
    @@ -1302,6 +1325,7 @@

    display_name
  • file_as
  • members
  • +
  • unique_body_idx
  • @@ -1416,4 +1440,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/items/index.html b/docs/exchangelib/items/index.html index d140a52d..dc75b1f8 100644 --- a/docs/exchangelib/items/index.html +++ b/docs/exchangelib/items/index.html @@ -1281,7 +1281,10 @@

    Inherited members

    ) direct_reports = MailboxListField( field_uri="contacts:DirectReports", supported_from=EXCHANGE_2010_SP2, is_read_only=True - )
    + ) + # O365 throws ErrorInternalServerError "[0x004f0102] MapiReplyToBlob" if UniqueBody is requested + unique_body_idx = Item.FIELDS.index_by_name("unique_body") + FIELDS = Item.FIELDS[:unique_body_idx] + Item.FIELDS[unique_body_idx + 1 :]

    Ancestors

      @@ -1301,6 +1304,10 @@

      Class variables

      +
      var unique_body_idx
      +
      +
      +

      Instance variables

      @@ -1569,7 +1576,11 @@

      Inherited members

      contact_source = ChoiceField( field_uri="contacts:ContactSource", choices={Choice("Store"), Choice("ActiveDirectory")}, is_read_only=True ) - members = MemberListField(field_uri="distributionlist:Members") + members = MemberListField(field_uri="distributionlist:Members") + + # O365 throws ErrorInternalServerError "[0x004f0102] MapiReplyToBlob" if UniqueBody is requested + unique_body_idx = Item.FIELDS.index_by_name("unique_body") + FIELDS = Item.FIELDS[:unique_body_idx] + Item.FIELDS[unique_body_idx + 1 :]

      Ancestors

        @@ -1589,6 +1600,10 @@

        Class variables

        +
        var unique_body_idx
        +
        +
        +

      Instance variables

      @@ -1877,12 +1892,9 @@

      Inherited members

      @require_id def refresh(self): # Updates the item based on fresh data from EWS - from ..folders import Folder from ..services import GetItem - additional_fields = { - FieldPath(field=f) for f in Folder(root=self.account.root).allowed_item_fields(version=self.account.version) - } + additional_fields = {FieldPath(field=f) for f in self.supported_fields(version=self.account.version)} res = GetItem(account=self.account).get(items=[self], additional_fields=additional_fields, shape=ID_ONLY) if self.id != res.id and not isinstance(self._id, (OccurrenceItemId, RecurringMasterItemId)): # When we refresh an item with an OccurrenceItemId as ID, EWS returns the ID of the occurrence, so @@ -2479,12 +2491,9 @@

      Methods

      @require_id
       def refresh(self):
           # Updates the item based on fresh data from EWS
      -    from ..folders import Folder
           from ..services import GetItem
       
      -    additional_fields = {
      -        FieldPath(field=f) for f in Folder(root=self.account.root).allowed_item_fields(version=self.account.version)
      -    }
      +    additional_fields = {FieldPath(field=f) for f in self.supported_fields(version=self.account.version)}
           res = GetItem(account=self.account).get(items=[self], additional_fields=additional_fields, shape=ID_ONLY)
           if self.id != res.id and not isinstance(self._id, (OccurrenceItemId, RecurringMasterItemId)):
               # When we refresh an item with an OccurrenceItemId as ID, EWS returns the ID of the occurrence, so
      @@ -4579,6 +4588,10 @@ 

      Inherited members

      status_description = CharField(field_uri="task:StatusDescription", is_read_only=True) total_work = IntegerField(field_uri="task:TotalWork", min=0) + # O365 throws ErrorInternalServerError "[0x004f0102] MapiReplyToBlob" if UniqueBody is requested + unique_body_idx = Item.FIELDS.index_by_name("unique_body") + FIELDS = Item.FIELDS[:unique_body_idx] + Item.FIELDS[unique_body_idx + 1 :] + def clean(self, version=None): super().clean(version=version) if self.due_date and self.start_date and self.due_date < self.start_date: @@ -4657,6 +4670,10 @@

      Class variables

      +
      var unique_body_idx
      +
      +
      +

      Instance variables

      @@ -5053,6 +5070,7 @@

      profession
    • spouse_name
    • surname
    • +
    • unique_body_idx
    • user_smime_certificate
    • wedding_anniversary
    @@ -5072,6 +5090,7 @@

    display_name

  • file_as
  • members
  • +
  • unique_body_idx
  • @@ -5425,6 +5444,7 @@

    Task<
  • status
  • status_description
  • total_work
  • +
  • unique_body_idx
  • diff --git a/docs/exchangelib/items/item.html b/docs/exchangelib/items/item.html index a3f914a9..c88c58ec 100644 --- a/docs/exchangelib/items/item.html +++ b/docs/exchangelib/items/item.html @@ -267,12 +267,9 @@

    Module exchangelib.items.item

    @require_id def refresh(self): # Updates the item based on fresh data from EWS - from ..folders import Folder from ..services import GetItem - additional_fields = { - FieldPath(field=f) for f in Folder(root=self.account.root).allowed_item_fields(version=self.account.version) - } + additional_fields = {FieldPath(field=f) for f in self.supported_fields(version=self.account.version)} res = GetItem(account=self.account).get(items=[self], additional_fields=additional_fields, shape=ID_ONLY) if self.id != res.id and not isinstance(self._id, (OccurrenceItemId, RecurringMasterItemId)): # When we refresh an item with an OccurrenceItemId as ID, EWS returns the ID of the occurrence, so @@ -661,12 +658,9 @@

    Classes

    @require_id def refresh(self): # Updates the item based on fresh data from EWS - from ..folders import Folder from ..services import GetItem - additional_fields = { - FieldPath(field=f) for f in Folder(root=self.account.root).allowed_item_fields(version=self.account.version) - } + additional_fields = {FieldPath(field=f) for f in self.supported_fields(version=self.account.version)} res = GetItem(account=self.account).get(items=[self], additional_fields=additional_fields, shape=ID_ONLY) if self.id != res.id and not isinstance(self._id, (OccurrenceItemId, RecurringMasterItemId)): # When we refresh an item with an OccurrenceItemId as ID, EWS returns the ID of the occurrence, so @@ -1263,12 +1257,9 @@

    Methods

    @require_id
     def refresh(self):
         # Updates the item based on fresh data from EWS
    -    from ..folders import Folder
         from ..services import GetItem
     
    -    additional_fields = {
    -        FieldPath(field=f) for f in Folder(root=self.account.root).allowed_item_fields(version=self.account.version)
    -    }
    +    additional_fields = {FieldPath(field=f) for f in self.supported_fields(version=self.account.version)}
         res = GetItem(account=self.account).get(items=[self], additional_fields=additional_fields, shape=ID_ONLY)
         if self.id != res.id and not isinstance(self._id, (OccurrenceItemId, RecurringMasterItemId)):
             # When we refresh an item with an OccurrenceItemId as ID, EWS returns the ID of the occurrence, so
    diff --git a/docs/exchangelib/items/task.html b/docs/exchangelib/items/task.html
    index 3dd931a9..bba5b93f 100644
    --- a/docs/exchangelib/items/task.html
    +++ b/docs/exchangelib/items/task.html
    @@ -110,6 +110,10 @@ 

    Module exchangelib.items.task

    status_description = CharField(field_uri="task:StatusDescription", is_read_only=True) total_work = IntegerField(field_uri="task:TotalWork", min=0) + # O365 throws ErrorInternalServerError "[0x004f0102] MapiReplyToBlob" if UniqueBody is requested + unique_body_idx = Item.FIELDS.index_by_name("unique_body") + FIELDS = Item.FIELDS[:unique_body_idx] + Item.FIELDS[unique_body_idx + 1 :] + def clean(self, version=None): super().clean(version=version) if self.due_date and self.start_date and self.due_date < self.start_date: @@ -248,6 +252,10 @@

    Classes

    status_description = CharField(field_uri="task:StatusDescription", is_read_only=True) total_work = IntegerField(field_uri="task:TotalWork", min=0) + # O365 throws ErrorInternalServerError "[0x004f0102] MapiReplyToBlob" if UniqueBody is requested + unique_body_idx = Item.FIELDS.index_by_name("unique_body") + FIELDS = Item.FIELDS[:unique_body_idx] + Item.FIELDS[unique_body_idx + 1 :] + def clean(self, version=None): super().clean(version=version) if self.due_date and self.start_date and self.due_date < self.start_date: @@ -326,6 +334,10 @@

    Class variables

    +
    var unique_body_idx
    +
    +
    +

    Instance variables

    @@ -559,6 +571,7 @@

    status

  • status_description
  • total_work
  • +
  • unique_body_idx
  • @@ -570,4 +583,4 @@

    pdoc 0.10.0.

    - \ No newline at end of file + diff --git a/docs/exchangelib/queryset.html b/docs/exchangelib/queryset.html index cbc067a0..ce90ed66 100644 --- a/docs/exchangelib/queryset.html +++ b/docs/exchangelib/queryset.html @@ -544,7 +544,12 @@

    Module exchangelib.queryset

    account = self.folder_collection.account item_id = self._id_field.field.clean(kwargs["id"], version=account.version) changekey = self._changekey_field.field.clean(kwargs.get("changekey"), version=account.version) - items = list(account.fetch(ids=[(item_id, changekey)], only_fields=self.only_fields)) + # The folders we're querying may not support all fields + if self.only_fields is None: + only_fields = {FieldPath(field=f) for f in self.folder_collection.allowed_item_fields()} + else: + only_fields = self.only_fields + items = list(account.fetch(ids=[(item_id, changekey)], only_fields=only_fields)) else: new_qs = self.filter(*args, **kwargs) items = list(new_qs.__iter__()) @@ -1204,7 +1209,12 @@

    Classes

    account = self.folder_collection.account item_id = self._id_field.field.clean(kwargs["id"], version=account.version) changekey = self._changekey_field.field.clean(kwargs.get("changekey"), version=account.version) - items = list(account.fetch(ids=[(item_id, changekey)], only_fields=self.only_fields)) + # The folders we're querying may not support all fields + if self.only_fields is None: + only_fields = {FieldPath(field=f) for f in self.folder_collection.allowed_item_fields()} + else: + only_fields = self.only_fields + items = list(account.fetch(ids=[(item_id, changekey)], only_fields=only_fields)) else: new_qs = self.filter(*args, **kwargs) items = list(new_qs.__iter__()) @@ -1540,7 +1550,12 @@

    Methods

    account = self.folder_collection.account item_id = self._id_field.field.clean(kwargs["id"], version=account.version) changekey = self._changekey_field.field.clean(kwargs.get("changekey"), version=account.version) - items = list(account.fetch(ids=[(item_id, changekey)], only_fields=self.only_fields)) + # The folders we're querying may not support all fields + if self.only_fields is None: + only_fields = {FieldPath(field=f) for f in self.folder_collection.allowed_item_fields()} + else: + only_fields = self.only_fields + items = list(account.fetch(ids=[(item_id, changekey)], only_fields=only_fields)) else: new_qs = self.filter(*args, **kwargs) items = list(new_qs.__iter__()) diff --git a/docs/exchangelib/transport.html b/docs/exchangelib/transport.html index 318fdbad..ba18a1bf 100644 --- a/docs/exchangelib/transport.html +++ b/docs/exchangelib/transport.html @@ -145,9 +145,12 @@

    Module exchangelib.transport

    data = protocol.dummy_xml() headers = {"X-AnchorMailbox": "DUMMY@example.com"} # Required in case of OAuth r = get_unauthenticated_autodiscover_response(protocol=protocol, method="post", headers=headers, data=data) - auth_type = get_auth_method_from_response(response=r) - log.debug("Auth type is %s", auth_type) - return auth_type + try: + auth_type = get_auth_method_from_response(response=r) + log.debug("Auth type is %s", auth_type) + return auth_type + except UnauthorizedError: + raise TransportError("Failed to get autodiscover auth type from service") def get_unauthenticated_autodiscover_response(protocol, method, headers=None, data=None): @@ -323,9 +326,12 @@

    Functions

    data = protocol.dummy_xml() headers = {"X-AnchorMailbox": "DUMMY@example.com"} # Required in case of OAuth r = get_unauthenticated_autodiscover_response(protocol=protocol, method="post", headers=headers, data=data) - auth_type = get_auth_method_from_response(response=r) - log.debug("Auth type is %s", auth_type) - return auth_type
    + try: + auth_type = get_auth_method_from_response(response=r) + log.debug("Auth type is %s", auth_type) + return auth_type + except UnauthorizedError: + raise TransportError("Failed to get autodiscover auth type from service")
    diff --git a/docs/exchangelib/winzone.html b/docs/exchangelib/winzone.html index 1cb53684..2789d289 100644 --- a/docs/exchangelib/winzone.html +++ b/docs/exchangelib/winzone.html @@ -562,6 +562,7 @@

    Module exchangelib.winzone

    "Asia/Chongqing": CLDR_TO_MS_TIMEZONE_MAP["Asia/Shanghai"], "Asia/Chungking": CLDR_TO_MS_TIMEZONE_MAP["Asia/Shanghai"], "Asia/Dacca": CLDR_TO_MS_TIMEZONE_MAP["Asia/Dhaka"], + "Asia/Hanoi": CLDR_TO_MS_TIMEZONE_MAP["Asia/Saigon"], "Asia/Harbin": CLDR_TO_MS_TIMEZONE_MAP["Asia/Shanghai"], "Asia/Ho_Chi_Minh": CLDR_TO_MS_TIMEZONE_MAP["Asia/Saigon"], "Asia/Istanbul": CLDR_TO_MS_TIMEZONE_MAP["Europe/Istanbul"], From ee65628b458eb1c11bbab9d3074bcbb1365c1e38 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 11 May 2023 19:57:46 +0200 Subject: [PATCH 341/509] Bump version --- CHANGELOG.md | 5 +++++ exchangelib/__init__.py | 2 +- setup.py | 9 ++++++--- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f79f11a..97a83046 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ HEAD ---- +5.0.3 +----- +- Bugfix release + + 5.0.2 ----- - Fix bug where certain folders were being assigned the wrong Python class. diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index f755e7cb..0007c8ae 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -36,7 +36,7 @@ from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "5.0.2" +__version__ = "5.0.3" __all__ = [ "__version__", diff --git a/setup.py b/setup.py index 2c4846f1..a8bc6b60 100755 --- a/setup.py +++ b/setup.py @@ -4,10 +4,13 @@ * Install pdoc3, wheel, twine * Bump version in exchangelib/__init__.py * Bump version in CHANGELOG.md -* Generate documentation: pdoc3 --html exchangelib -o docs --force && pre-commit run end-of-file-fixer +* Generate documentation: + rm -r docs/exchangelib && pdoc3 --html exchangelib -o docs --force && pre-commit run end-of-file-fixer * Commit and push changes -* Build package: rm -rf build dist exchangelib.egg-info && python setup.py sdist bdist_wheel -* Push to PyPI: twine upload dist/* +* Build package: + rm -rf build dist exchangelib.egg-info && python setup.py sdist bdist_wheel +* Push to PyPI: + twine upload dist/* * Create release on GitHub """ from pathlib import Path From 7bc815980577ff6ef364f8acb12c75ec09202169 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 22 May 2023 14:38:29 +0200 Subject: [PATCH 342/509] docs: Add documentation on creating an app in Azure with delegate permissions. Refs #1119 --- .../img/delegate_app_api_permissions.png | Bin 0 -> 815521 bytes docs/assets/img/delegate_app_registration.png | Bin 0 -> 242456 bytes .../img/delegate_app_request_permissions.png | Bin 0 -> 325134 bytes docs/index.md | 58 +++++++++++++++--- 4 files changed, 49 insertions(+), 9 deletions(-) create mode 100644 docs/assets/img/delegate_app_api_permissions.png create mode 100644 docs/assets/img/delegate_app_registration.png create mode 100644 docs/assets/img/delegate_app_request_permissions.png diff --git a/docs/assets/img/delegate_app_api_permissions.png b/docs/assets/img/delegate_app_api_permissions.png new file mode 100644 index 0000000000000000000000000000000000000000..b7f8fe95cd05129ac119b119b0199ae4b51b06b9 GIT binary patch literal 815521 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

    V;& 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 Nni{&-Fjs8D{tr#-xTF98 literal 0 HcmV?d00001 diff --git a/docs/assets/img/delegate_app_request_permissions.png b/docs/assets/img/delegate_app_request_permissions.png new file mode 100644 index 0000000000000000000000000000000000000000..01b3b9540a36550458ab738d7381b8c6942cf3d0 GIT binary patch literal 325134 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

    LO%nDeWP|cOkcQ8ewy(b56TjArP z7+5l)=~IhlH~CO`fcE_E&fAs?QpVYy^cK$51d{a{T{$Xa zMK$;$!OW*_;SuYznH$%gZSqwtqfP|%H}{d22ARn`Z`Oyvmn5up`__pzOC1`_4?lTS zSt=s)3*0E;?RZ#uhf1I%iaB5slQ9Q-%?@{8_yIf6t0aAWQ}Z&sZA` zAo$bB;9HA7S^3k-UlC2Oe|JZRueykTRB=RI)h^eC;-nLH-@!1LKWUMN+b5T&a2E9t zXOyFxp130Yu_7`iOJKhUsMO)LJ8L{FCt9h8aF{PT z%(GCLlMjseTkzis=Qo6}La!$NTk(V6MO?|;L`OAONY~dup^E`qUXx)U32D2`b0Ka1 ztmARNS9^RNkI_#qsFxhYCmRt8q}3Lr;NJMZT$y)MWhU+47iQi=a(;lVBAJfKW4-N* z6vu421&}kQbk%YV(6n%_JYJD~Z{{WH={wZ>;?>^@%vmd-Sl|6VWUtramMQLFspVOR zF}rNbBX=#7(3>`exnqb^HqH#mApc12_X%Qz`Fu=RJX(-QW^#eQMj<09%0Ztv0;mLCT4lC9K5r!M7Llr>!_=jW+t(lu^xcK_TlQK#IyDS@C|)| zMeu!9LgpRl-{K`ZuDwotF~#=m5%H!j$HNc>5w4Y|9g+H6{x{#qB^+f@v@eW7E>W~a z_z3U7eXIVdMR?|L#!IYokL8t< z;WI2sw@Y4ZKRk>BqMI?FeUy8D=A}e|Qm*+&gv?3RW#s?v2$;{BFZu>5jHey*yQSLt z|G1ZMS9{hxS9Q;bxlL)|jOq+rx3|KhLPjJI;Ji zr4Q9{TYp+r!aX<5MW`V6vgsswmIqo%b&*7i(nbX=uMlRZUI_9IDo0s7bFfC1syd_E zvu|5#{CRNA^*i4Q-+0i>$;2@Km7y)X)m4+Ng-R4;iz^Q!^1Q7+{|o# z=%Gv4`llb+#j;({+$6r&iwuEGlK@!TwGiKFF7E9F85G&IuHWov_SRb6V}39>5k!x? zV>qo#Pr5eqI_J1(DUVcnZX1~sxo(_r^S#DKsh?Ypd}SCRD!#WPnTWokzPJZ25e z#=lmpIf0@}2IQl5FXh}*as2?Vdsl<+@FKT9c8kW=aY9G^HXivICkCFu4)ViZvhTyk zM7ibsLR^7JADv|ESCv55pohvPDM@hO%u~{{#pFNnkw@ys7CMhBw^`IbDCAdQTL>>i zC%Qt@``jL;x6405Nx|Gh^N7*A}J2to&?@%4XO)iY%)rFEHh;cV4)1e40hb`r;L@cQ=K#62F({bx_$y~t^GO=KpE1MC5FUwOXT8T2O(jTA zBcg*JhKVxjb`_(USmaj~E2n^sUGWXA6}P0z({I8VHRi$3y04=74b-MpS^8T49(N2=%Jl_{6@()eGqj z%Fm2bq!s3_jZ}`5sUQo)HTEyYUIHK1JD@gKL-(Q){^h-_*E_k>kbpnH?~>W{gbu~S zlEAZ=Tj;@pN{hyxy>h+-&E+oeb=}wq0Y)FhQm+p@sg2Y^qpC)d||t z3oQEk3$W$I*wD4r&fShXTM}N0W=)Q#<|$*JfImkvXO_-kf66_~#VzSuJQDN!LIFqr zAlfFM8HhMqobvmr5ODWen(?HqND&Gd?mo=$ho`i{AT>h`kg{d(CzsOMrkr`P`EY|f z*{Qfbk*PKwO7D2Jb(bwa9F+JNv#3uK%E|6sAR!<`WB)I9w7DD6@?5B`S3B4#YOHfx zQ7H*?{xzBoWM)2~xCcd4x498c5>71hgW>z<;FwU+>l@A6eeBp(cS5lduakZkZZj^1G4{lg7ZA!8vTamj;7kDioAu9m0;l2{v?T971 zF|TpXiFag5P`fqxGr(vo7>TaT3tj30uup=$(8EWJ_Z*6q$zjgfboVJNXUR$PRk2A5 z`(&(NDN6@5?5$4o@lx&+WHk@HNz&a|($k3dt>o|I7?0tD`H3d9k}?u`5*Mm{WI3ZG?&|hZk5)P;7UL!^7&mY zt7LBc`E}h$^&*$eVMXc{F=J$Vf*M;8KM1l@rSZM_YwX|~ZsJbkT(LJ$2q1&jPV=T- z#cn&E*NZjxyl@T8{}jC`)3j)qM|)#v?Nm%|lQS@X5$S~95izFczfz^$+o-I;E#DQ* zN^zacttNo`mc8T@6b_SC#O2C};{`Sp(16kOI&YhT&5E8C*=YXW7Z}ng$2EI&;;QV^ z{im~m>zdQVYG-E#jwv@H)@&gzDxat)iAL>o+O;yUzF}#iXmz#ouV5y%+hM^s%EZ58 z8|MT{S`(I9lA2g>2EFU5&DMzGfI$r5X{j#{;wPLlRUcQbMrO58gfP3B#u$b|AXQ&_ zgOH> z83{eDYg$(8xx!PhbVsv-{lV6u>YazHUDCkIDdu%da`xG&+{Z^UrLq$HpKc3ql3VG1 zU*PbRbs|W`21w2P1y@rtXOVOzc|?kt1aGig)jx0c+!ao4jvohifb7^fUjFzVJA`cMx@G;gu>mXB=V4&jpxp+Q3-gjrp*zuX*;`xpD<$Fc2K=X7n6)G+ANY@E}G2_BJ!=g2exr%e` z;;AGdLg$J*%GLuoFVjOe&&c~B0udT3cR;R(*>muI^9e4q?xiX>IcO>sx0Cov`Z;)K zSCTj?gpw+*`9kqCsMz6kD;+(#+3$5CIzR?Qb0MR-m1=aiXGb5qvcm^g4k^u>4U%O zQINYkF`2ySzk*+{9;QEdWoSJ?)Qc^afM=-88?$~jd2@*9UP*Ep7VJ5hX}w@W(7SxE zE``SMq)>|bpM3V6;?=yK5ntb4^c;A@tb>qKU4UhiLhR!K<&?{A)NuEC+lw>n7So$N z)TQFaXO^l29#v=mLrbd{sGHIU{g{4UV~B7huf^0PPh}pMP~J1OI4J2)O2*nC!^s(f z;xT)KP$h*U-O9BH!}erTfL;IG^&;=O^Y(# zt^oOae>KV#o_GP4HIgzO#HQ1k zPx%kCf%JKdII)~!cSM5`U+A1nQKrqGK%9N|btG1wS zcMkbC$__ccH{oLC)YHiWU?1(Kovd}=t2I%Hknf)V`-JiT^qBd48jlt zy@f-fCodhRES|Nams=>A3!AG|Aj&&N8p=x@3A_rI0_n2Bz+gdjGDuzCfoJDLmpI>L z94yYxHd$!hN@b1;00CL~4i=tI`-XTSxbSw8Uw{E5=k|=%?WKUCU;ja^oS5e&pe3@0 zg!aLP=uAefLaZAx=>+4val!VQcqV-yK|a&g!r$=*xCVRpD?!qVp{*@U9aDr}7lVhy zC=R54oHth1KeRA6T>Du}HbR2PtVoj1Z_Ot^ZeLAUjxAJvO(QT@V z@Q%QNdmj9(xmc^|Fz0gFDPOu*i}u~2`5}U}aw@+MCnmQobNix!uZ*pOh0ccinWEfH z627}&&V875JvwSmzHIn5dvif*#mULte8$q%X-h0IV&gj~mBw+s*340pZC1}D8Y=Jg zG~HB?d*&K4oqcxG+@C%LSg+R;c<0C%dHD&Ssmb z;~Wc26^kAEeVQU%P6-(zZKDJH9zeyUL&ZwGi_;;MA)a$Rh}jy<+KXfW=xF;~Y@eRC zB{FLA;s=1=wE;({y|gA9-?F7;2uIt^nOo(7c)7w+FWaYtS{3$P*7~)&DP>9 zsL|fNf7nr=|P?r2oh*CsVu{#3|-c<3Q4prqBZQe0P*&r@KPof6XIvKa_Lm$MA zq=^i)Yow%6Iv<$J z0tIYih!LYX1(%qr4rB=|)y`iy@;2`HS&Sp%Lj=e2coPO2aCm2FgBmwd36eM`Bd?I^ zT!(`_=TR*2d}^M-wLK@DC(s2QDJKrV-pNVY))LNicOvq6t8s0LW2a(jP`G9*OcGjh zI?09x?a?B3>-Ra%No5*KYcuiBY_Yz-;Qs1cA|5Xkv%$em$9lH)<-Hf)Ae6fJ4at)M zZXk>4%gwrr-i948wZ!~bR7g`fv0oR!To1bdSFa=F!#R;Y@eD%dg-6C0GhF%uS)}BH zoQXh1J*X}xyWRROw$yn@8u@+ir-5Rt1*UZ~2YyN=fLQ2<+R9(O=t>>XQAWutL-BT- z<}B*FAFOD=CBFoo1^xAb=-uC#y}IZp`|e9_cPzirT(L}8oLcv-?&90Hj=LrOB0^m%2>3nr2?}RA-yRy8idCD?gAra@m5; ziOFB0Ncx3B5c-1(PuI=nqNrObZOIr>n$Z_RPQH!92lon(-4FyV=B#5euPrfAAScPd z-Q0>4E@JmW@dKKY8X-7IyqPSU&ucrzW2clK3BX&c+c?P>Rc_vZGhHM#*?t5>i^^eZ zmf{csnIuG1sY?>jgNLHV?)v=;5)y+DPXv0fK}el<4hZrTPYu3HTTk1%&ZMq<#Kxu>VIy&znC>9`)NG&A#Y9?y(5@Bcu#~$;9(LT-M(~R$)BoH~qC)M>dg28%JcV(FG^oN_z1YKu zKf!kl{D9ZZiP)Zd!Kh9rdH4jJ>DU|=_Q;_~Mz!edz=Qdh{D!LNN||JvWu>t>5CFk% zy!JGRUR*QgFF1Pe5o=AmU_PN=sG(KLcyt>RiMe&~J#1Ae&~E{BPg()%JE)ZemSIHe!zAJ~I-JWqyM9kypNBqhUDKaF+m$S>X(bZ_@lX5` zmq;C-g7MEsU7&qlurcEjlE~NarabP30)cnXc_QTo}lPRJUE7qmn-ph`l(&{ zSz8o1j=v&jN7ob5>%2WqzA-Ozc7|0lQ^)*Z%=pl7mKf@p^sz&k()#AY_Irj7r__%5 zy@oR!g}%VQo(&w#_hvZ01q^_st%w$Ci6zq(Hh!Vrko*<^A|#g}t_ z@xkrMJ)V5NAW9!*27>y1jzb|3LfCOsl|5ks3j1NuQ3yq?Z)|cSXfF+OS9<0ZhlF)Q z+gD0py7U4OcFCq}IUU*>IsD*KaDSrtQ9NH#>?0*Ii7&aqh=FQeW^Wh5jU;O-uRoSwuPp(Lm z|MQ;v6ZiX?C&w?mu&_OU?*{iL-YaJQmdB|X{m$1<=TW4v_I(?Z`%Nh}>oS)W@cN!c z^af^Q%56$Ivkx`%yTPl`YY4uX1@qS-odfR-Dbqbyzv^eMn(fEkvkQtoOTTRYL|%)^ z-$bZgwAry2P^8yYf+1Z33OiYocHZ~ZV#g!ofmDg4vsnA!r{+N=`yT>KS$~Y@T;-As z6ZM0fGVFIxX|rS~;n1c6!_m;5(Yu6uT5C^&O=DLeNp@uNj<@{p*?5B|6c6&|;io?~IB&BTaBO zx!~l=Z9HvUZi-5J;?m9Rzh}qPl$&}hqy7VidL9|{^B$_!h#;{|?f)0Q^5jc3kBhy~ zjuiSlz|g)~GD9!?TWS0~vC@3ZQ*zfpO_#&>=%t9KU`x@i5c|T?B>(J#cu__i+)lm` z5{`ydQ5@7e#aHjj=QR?@rt7f?yv9n7sDE+?iDnWc12}y^v2CA92f|ob*`J z?^oedzm^0{eVOm!*TAyJ@DD~~r(5!ooLE9pE+~;inx@tW7Yp&2zd&0=MFWd3s z`tU3j1a@H{h(XuVMtkG!=(CR-F{9w<6Q%K|o3=foHOmzcSih^<>b1kF^EO`BDRcKS zf%GACqI09^#0_+^^OI9kHi(9uVJc%j)_8B~9h@~A211oEhpiF$-G`fKCAUdcxbv0$ zUjoM4{$k89;KJa^@fbs5XDvaE5<>SPBpHvj?m~SA3In*2>T%UqOorkv!xjxQA!8c% zLO!27!5w;6g8$TU?(R$6R##;W^h7M8P#C-E<&ZIcOA%TdC*`^@LJ7(9?d`c=I z7-w&4BL6;+<*jdOM7*yTK)gUO1~uMqnio*%~bKWQoFO4xs*|Iiorq$?*#r>P;L^CIb< z!Zxr=LYw!I&}^PYNM8!!aKZoj&1yr}T)vyqLw6|1CJ(K*f3BBLX)SndFAXJC zWb!vM*n#iHlY(5#0-6O07)ZGin{bkdCUg-O#%a5bY zwnCZ|(lsAhpJ&Xj`*NUXW@L54m{ULS7Bdz^8B0AWZCt88-7|x7W2gDWV_n7gHZ^ov zPq#f7l@3dMw;=x1Hs5aS@RUZWOtkaX`~3Kq#)dsxBi-dco<1X8t%fR;CcdNGmetj-+w?!fMpIXdct`4no7Nw}_L_EP~mC`*AZ3+nZZe-WUv&%gWixgxa z-mG?kntJ~8B)je}>~^d6jBfJ6X-Ur4C~txLUi7r;OX25yrQ{p+W`#E9OrKuuDO`}0 z2uT_Pdvz!IM%t_-oY7q5`{X25-56gezK6+7ehYReiQir<&r4H(o6NW>U2VJDD$@ut z=Fr{0(K%0l-mzZ1GvVJiuC3H&W!L;-y?fCx{V$bqM@fu+3tx&_P}ka2{X6xYKc<73 z0c#G?-Eue6+jzcBg-@P1BE0q_QA{wO5^$cv$jottAono1=ZDldISgamP03gl+@ zzGO8VNB-t_)%3={ZyU1JZzl9C>*sIkW-iAqk_p;hbgJ|lpKRY94Ly6Sd*5tZyRJ z?x(T3J+{;~hIX1af1ynuqtoQQuof>${-`oi0`ZfQ>Y@3I;;9-jBayvBoNs?qRV!Ll=TGzq?7*&UJSa9KHo*euol}QVPcHl46|CPRfn) zDA~uac)C_!2(&qbJe?YTLM^aa37{)g5mxZZbPfL)NOi5jGduW0GV=K+x!IJ7?_OgD z2ck#p>As1CJ|ETm?#tFQLlV+W3N?bdL2j>tfEd50=%-bmES&NWpQJ1-HJYV%&)YEn zB#3JQt|MYST|B2;U&w3Wzm$-_F?1f58~S#?CTw|?nl(W%ijRB4T!7@3gb}h0o?L!=S+?b~z3s!1R~D4oBMXrcH9R8A$b!XD7SkCAI5ZyI@9xlO0c# z90|-`dgY+e7JU5H$zVGjU*~TN7B@Rjj>#7jnD)}&bX;AqM`ji~SHoCjhdIk()A4r3 zoW=HoaItH)4=??nDs3yuC8()RuaFHmtou4DNVCxEe)cJOE8)Ia({Q3E0NM@he{I91k;LQ>%HUIe%to(wzS$Rs#mrL=-q>5HVU-EG9m_M$~i=g~y0D$TQ5~ zVgG!UT?eWqNnlaAEvq}MQ1751ZzyZL!c5)E%J`b{JSX;$HS=vU5*UA5YMoPitK-Yz zCq_%UUo6J)rS@|X4^VNE>pu8=20*d<()x20)%;e^rE3U%eAHl#;tqur)`G>df@E>O z_fEA6X7mRnw^tL_WCV>#k7oh(>2m6?P7yOA3b|{efcQ;QT!RFJ$6zazDqgJBv(_UD zRh!0pglpVJ*%Bcn?Fz=}5t;S}-al~UZ2R^`5lidyBfrziZZ)Rg*PU|aOC0J=O3&#Z zK@V6R6mTPTjKxscktukV^qSJn1l=p+tzM@-c`C)LkXNEsx2e9lB3vAy&Df7tYAqYm9%hx%i_=oTJlB}X zvCBW^_$(roRueQ>hXs8H3zgCs_= zfY315@|r>Q}G!T7YURDTq61c`k0>-M0@`XR@J&I;W1q8ph-Qm z#TX^k|2zf~cXsSAD^lUnOIZ>}92ZJLV;wI|?f7P-v4Kar)`398dy*G{ z+>!g-pQ{eIn*dxlf^w(j23NpxB7k#6*mcKu*LWiS7Cf^b+rzZDDy_hZ`-=ZXD>7C$ z8(&|nkdt;O13E_P-4h4iUc^ZolcJb4F2{8PqIfpnr2W(m?}rm<)N>!kvKVi1ZGA}s zCU%b0A5OkI{M=~qED1s?>9t}%z}x@BjbZL}%CJ&y1ccNq%fG}j8&BC_GIp8XKUrWL zGs4Jm!;WInNX$53{kZ&CE1&116uZMsK9qEH81Nt=GZW454rKYO5VDf(o3k0LflEqS z_ti-Px;$!ix7pPabNiW)KRIxo3+DTU&iJeW4FaBtx|ay_Wfe7%jH{)eV5v;{;z^BF zNNc1k;vjSFV*XifKK;&IiQLt<)8b@6NT2#ci|qY4lGQ0;|M&a*TX__$7O4U6lJq}IloB<*p%l zT?*XHU93^MjFlNpEp3RW6Qi5MO-?FhMf<~pK*bIvcO2tRv?uo7u`;fPAg zG^C^El*3aU!*tGw8}AFbtzk1A%roj{){D--&kx2wMrJ)1%sOX>Ow2j!`G|7^6{umH4jWOi=-vBc3wkLq7XC;sP^JtyyN18*ww;nmJyeImpzO#<@k7r(V`W0|0Ymy}7wwB<}UBH~jPjW;&|ufz_J-8jL(AQFGu znLg>W0c|AV*QkrmE*T4K)K(UT zs$$xH@UIBIGSz?e-5>YUW9P7@#RyM};-j`0wV;5phD@Os{~l_5*nWQc(4w2GiafyC zvF0%I{l}-@S?=!REY45kuq|2`Oml7t%MSML<^|Gaat8~2LivyI7lqwjCb$U4nF@R% zt?(Y6_lXco2F%YP^f@H=N0P<`DgG!jj@T$hy)!X3PTK$o<(YVR6nGhX%zHPR*+vXa ztJxQ6h0(w_<2;EUA#>~5NK&{-6eHg1Oma*)XmM%a$*twhzN93BuV49IJed=|ey`m` zoUKKyzqNtM)i@u|=6jnPY~TMo-h-Zd}^E~2X^QQrjB8TYPP0i=+A`=8L>WYFpS-JJo%&(?sNQVmM=5>r`hO#!a z^4Xw3AGcI-OD*iUdZrrDt{9M{tUZi{?(Xil)}+7RaP$|aPf)o%)@h+k%gKr^(tnPhCGTfU#tIXDU1o({$uIfy zCV_6Q447j_p%)xc24{Jzp{%y*`z3c%^Rwr{4KbXu^l? znBE?#jK7!s*RK3KZd~HG=+9d!Sb$vO(JFbZDsf|x?V9%w? zkY;5+%ypo5A#;dw#*S5V`x`gc+V&Z%dV||^A`3V16R|ILW3Rs)z4n>UV1tS&V~;Yg z7wDaOqG*#s$QVK3Z;8^qtp|B&6EUu3%1_~uwPJXolo;6DOGdGls52lcZHiK;inRGq7s!LiFy6EHH z_TlF4^Tr_nJjd8`uIqCL9}*sIV?78E)R8yt9%!P#Ew)VKEm@_CxZ2w}h#j{IgRC z&-N7G@`dud@Xtg35uPd!X5}SHT5(awTYeMT+G;VA%_U+a;O0Pa?}c8Wwp)KZx%%x= zdhS3x>}t?ax+*9{wwI!pRJoGx)|~Ztjk$adwq0_f&7@Lw*C@e8y-?Vr?^h0`M3?j| zJ!Hpug;BluZa1k4W!zcE;a6>SSN26bd+>V<7lUkg6B_bpm9$>@dQA>drNF#CA-3CA zA1l1&(Q+Nf@Tr6|JGk#jp8i!@JO8~{S@iIc2NnBb5CPG*t=>2N(}$k>NQPK_#m7+U15+m{^GFukI@EgeWb`MG!zqql{+8sKI*vq z53mSQmwja~CtG2{4VLlD`t|V1zi=}(e!3_)H=U;n_8jCAJ(<=^_n{1_EqxN;^ZR?% z%qCBy?fX;_WY=3^`w*X#lPdFA6t&IH-nU0X{91TiNna$~|-t)l_j0JMrUD88ePP@)&E%>)23+Xd~XP z*&BV)(+->?r^*NH{{-&dhoi99-!`5M{W!mT4)Q*{RGoY;F@zKx3ZK29V-U=3djWFn z9N2jack}Uqdz4%SY2Z41>~Z)o0iR&{j(`FJJ5w3SnHKw<`qY<^Nl}$yL4U&!> zIg%~P>lHB-U1L|u;zv!&xKH$^rJiOiIq0UV3b%|}h}lJLGhpNFr_>Q}YGj~IYld@= z(^j{-uyFavc4ri)hGYF)?*48(zv<8~yRs+mc+_c#D=I4LN(G3x?O{xK*fP))-mNoJ z4dnixv-|te!(OQv1TsCJ>G|K;hW`QLq8E4lQ}^DPlgw8uC|oTn;UROUVWTSHJIH=t_y#S$z94~-4y8)=KS9^13!D7;088`QzPLkJnw5qqH9q0c4g$uLm6a(%p65*+R6vnYf&31z0nE z$N9MXOLZ81_QPU6i(||2E=7!IN^i(AE4ZoR_LV=r2^y0WUjoT(TjKXOG7-Ok%cz!6f6s7=4+v=zkRRnxjfCxaCJrPHU@d{^6I5*uCL$#8l9LO=> zh{N)nMgjW0g=y$Q3@(hxr~X$I)+<Qk$TzQ)# z3UNDg4~9DwnhYrLYXH1VF0ZUpTA1e(*l)Gc@)JNz)tBI}RrVxg3w)e7qyLtcvbx1V zmJ}JgeHw9gR6yQaJ_W@Te1?AcviX&S#w%=0GVZkU?CllDBs5EzH{taVt%ryI0Quw; zxhZTk^x=qLH?hxo-PV@l-HU%=)_fS2%BSHogHLpZQcV%9p3X$HUrtc~c_uBbSgNO~{{Da1S0D z$3y;3BLN&^{YKVuU<6&JV%5!bDx1m;d|Rwg=lQ`DLH2b2!@l7TD^ibTGr|18>nqz# z=n01yfz6sau--p0?j7fdK{ChrG^k_;9X5r6vzjaX>Qx-kSdx(_Lis|t8{r;4^8?P6STJ*g2Rb>(f~ zw^KdQ7DNCYy48ws{liecv64i^Tep0xw06eCFBdayxJLn~V$avApO&k5(r7{Z6c%`O z9)SNYe)%3V#xo;2CQ5%+dktYb&Lbu(32U$Eiz7a#A=-LoUobLq?kS^Icfn>i*kY&G zr0~_PDr??2%naCcr$pTNHoL>LWGSczfPv_iL-eZ%fCGD6^X*Ud;?KSHm;|7>*x+%LE$H#7XjtLHL7WuDQ-xH%hIvTQks;%w z|0IY1z8E(}ZxQ=Oxi^`({7d(?Wj%Z2UgD8Ie;W~NKKx6k4n>*Lw7~q+V64y?kT(a} zVYjj$3a_l zZM{>6VoNgYun&|cmi&Z;MOrej$3^N1e|&)?3@u-F?w)Y<{%9EcsNV3oDf%i=I|5(> zy4;spaCjIvmOXGPWdv1H>e8#}lY}WQGb3i&3F{n= z9OX&s5Zo`KXFZgPuR}lVJDxR<$xRWo&ckA{vKC8bogs-ZCtEI}lV zZD2ICxPvjWE!DFb55l`&1@QF#t@FvrmgQJ-#PiVgBoki_NqiO!HD{fvX4-d$3n~+c z#QpS*`}_60ewG=?;Dd-eZd}3*BgyGJm^NrOD*ht3$QEZP+7ML;#%(=H0}CYy>aMnZ z>3oiK<9?rEn0j)rM^5SJ#HHjMda^xtj|h&;3X3olXNO*?3 zn2{61c!qCDD5Z!mo{B1!*loFRO40x|P=>fLG$P_v{OLv95Q<)t#>v>?g9@h(N{6gg zrs2LGv1I-}G5a4YouPhn9rJ%ucR%-mjdq*|?i6#ZR~35(C-k@7Uo{ZjGO!#Ty?xCc zel;a1JMy5c>1;KEo@Dyi-}7O~DvI+>pC|U4HRD?lWy6=^fh_tCDmcn-mn!OZZbYSV zL#UD^K_*9eur&|Bp>8E4pqC-{SL2Hl)cJ?u33Uk`WBf$$D}f%|=@K~AoJuZW(zxmF zOUmQH_rTGZSd$a|rg<6P|Hj~d@jzq7o6sc6=mz7~(vFUQAk7ofppV+GAQ6tEI{NN! zA-{_`y%+Lo|D;=tIid&4YnY0gu^%SkR)-BCURrBJ8aY0dOB##5nCh~|jU4+lzi|B) z+Yq0yVf7&%k^-|~B1$DMWxHSc93)2O7hkcC>K*{T^ntC@J;w1XDP2BwqQHkzLf_?m1S#FD2Eb@I|TjZ_S?-M%n7kD&~-?b2}VX^2>=;jA1aPfz6+8I{GibAR53G zS+BOP07pAbK)~gh`{hB8;nG@;y*hKvm`9goQvMRYDdO&PzwVe}MTKPTN5jpdC5^kO zPR*EiUSVekF@dn|C|czCa*}gu!1cwF7g8S<J`YKjk_o$NR zZ}Id(;aTRz`p@5&xA?DJRAHiHGvtV)Tj3jTa@PeUj%Y5*g1!c0B29p=$skMPRd{;w ztHzd|#kvVy(l|T=zI=3(@I=!0c;AD}{>8L}=+2{?`fm%a+wiRemppq)3$*jG*pErR zy`hMotkmlT+bottA*5TJt+49}BXb!Unc!LtTom0LzFO(!8;13CTY^K*IQLlIk`vwW zLSVJ-+Q)-76}RQ*`P|+feDB$Tkt9=zMJ1{41$$Pvz-1pl5sf@N6#B*?BjvNoRLT|W zpbLiV@lz_IkcsaM`gV5j*YHLFK9pijASxNPb?}^nZ>NGMqpiNf9W1n6Gy0xNRBPxc zkw5$8zlpP(F0;U`C~jjW>w{;nmd0WRe++6GRf0y7^??R5cK`Qg7@)kh^0UzaqpSCi zYMC-RCR2FZG|g3Nr9y%6Z8Gf=XyxD~QtTQ ztUG$%ZI+NKKG#!H|NC#L83%>{{=b9vofNZ@731YRkvkcZa7of>5cg#mj`_3Ph` zvFOtLPBJOrqWgrJZEru9A8z`}0sq17f{LsOGn|x0U^ct0JmoDIykt;)`*7VfLc2dE z;Fx7;6l@+PJnq!Evr+TtX42hP`(};vvWP#W*F9qaLmHqxe8Rf`Pmr!C#$XhS_KNRO@xc$nPb!GXo*FQbYBomV3wFDKx;Y&nICAPjfhm5IPm=Az1wzaq3 znhjW$QKPVmu1#I#r}(exCE=RYUhkEt^Uf8yDzVozagjR*d)xLv{A@W`a-1Q}Rdy=d z`3&WZy`#X%%y6~i9H_#T7Ww^e!X!*sMfu%Goe`EWB0Z_ZjB2^Qy0C3(Ubq!QN3deJ z@H-Xpk(l?zP<;=*7p!nH)!G(b`(kIt_w!d_0=_MnTR%s5Z!IS+|NBp^A3EnhP<=Il zVsrvml+bi%vg^1WjF$rxa=xDACq+sx?jBT~u~q#|8KD04A^PdultKFEzQBzX>&x4$ z)1H^&H`Hgx`<|{f#SgOzUs(*S+65|{9Xh(<5O*>UarRTOqP6E-{Z>NClQ$I#U#^apGGk_r_ zhBnA|AwF4y080+bY!a>hdce6$FAk*Mm%@zm=^d-(|-gWVJ~0_MeIx@Dn`yJh-4 zc~ZxVdjTT!ZoNz|T=KQ(++82DZVfvJ9HkxO5;Qzprmr6MQPppaPD+V%sJVnMe*U9Q6mKAj{Yb=fcZ@*j$2_F{^&6vRJ z)4=GRJG2hV>P1a2a;j6bOd9?&Q9i=QM!ThaxuZ*{M zxI0i!k=hnn1h0v9;s~f6DGNwAwXasysdO*km4>ALMK`Ibwyd{oxj)eR<7*?oT^q<* zpJ@1E`0cev~sfT*`<)8*V>T%4plsq!DGUwe0(iu zSyE(AWoV?t!A9nmnhwzo|KTYAyJ)z}O+rYCOFYdr^Vs}5L-b9AP#JVboN3&tJ*fR; z{jzAZ0+CPz0Oq(!GWHP~V&GOzxpkcCQK7623V!V?Mi0=YR$^H2h6 zhuvjDnNLgr!qC!6b@9#b!3#<=>jr;((Zxwu>-Yh}9?P5ayeSmYQI3(7Owoh$t5)M3 z4j;xDqVZ4ZnIZv%!WWd1RI?UWN3)_%?Mv#}nysU&i~`qamtKgXD5JUecIB)MEzG2Vy$Y@O zTtRd;&jPHDn~sK6xKe^kB~V&UJs)lojOS4bCy9%mtv-(&!fX^pb_#u39Bn|Cq>+j) z->6O4Y#3kp{O>NCsrtB4QS}W-;)bSPU#OnO@^as0wzx^z)9dfFe#r@AQI$Vix3$`R zAhxqJ)M~o4_^sE9jgT;^?0!$unR@5ZpCXxOCLJI1o3FBASL0ZMx#hd>=$W=&Pv-OD zKBx{$1jlAdJmKFRjibPhmIhZ0k7uF_+*|74U?uB}{Hsn<7bcyI?pt~YpAK6kv#;-#KP-N2{uhYx~|frS<#qP&j%7jI5X12bBy9F4wVhdz)L-E;84e?8o^2oAVCzzMMPx@m@fB;thwi&0!b9m}UMe+YuMPh%oaUyz%U!ot z<}ge5?r#y5{_zHFqys~21%D1nk2?9E=!X-Vbhl4uR=c^vojc~`VRJg>!*v0UpohCH zraJNuXus|@*~{D6?0#bloQwr!eA8M=ypvPUBcmmQN$qZxPn{MO^hkq`3=gGrFry8^ zjE^lzy*fr+|tI13i3~n0nG|%b zqF*}@zk71Yy#2d!Bfo1v`Cgi^Scy21b^o-UBgXw4PpskBS6PW>NH{mA=LG<}M9NO#UQ%A%xqDSizu6AtX&n(WvQz1faRt5xU-j=o zzb0X4univ|KL$l&d$U}5js|v{kqj{*1M&$lK&*mmG;70xc%^~3UFr&fbx<2!d2kL&>JIIrHraMiEo zwRkveh)m@~QzuD`z>-4q$5<53HOQ|q4d{t>qLj*_T2`O8X-bU#$;(SiW!bs2Y(&JE z&~4mk){R!YIF1%}L`^l;4(^{kT+!8A&AGt4+rCvs$u7NJwgugX>)xZIqp@Kf3bc9Z z>8V!3uoT}?3(^(of1p+b$;~0!#K(h<$!lXT!$0*7u*o`tdrUr_cTlw~^I6$|_L0p) zYNv)dZ%sCrS!qosO*2@xUTE%KobK%%@$tpF*NWj?=_99VvFzq|un}Llk$_di5OlF? zB%31W05303H-F)+Z#SYh4jZG`oBFVU6&c6(q|A?`P+{aO3rcT&l~&B10#Uh1m)6Nj zt{>nQ32NhaCEn?G1<1p_VXECmX|;HtBItPDzVsX>Z*g5cVa9~K)9-6R;`sa+V)b{QwSL;xXtkR5zII4XEpN_ptp#8fan7 zTf3ws+B^#CO{83d)qb@wLyf;htM@+MqB&1qUN6U2o8}eQ%=d$9Qbl4!4)2RHg}Qb& z57wEh6@p%uP7mR2xze@%y4{KVn57YlrS#+sC^jYjO2WCd)6vSf51R~5@WwCSiK7t^ z(%u{K2Wq{7@0Bw`UPdUh&9u&d%pXSyVm}^eAS}1@TXE~9k(s_;i$3{iIrdWq@$E%1T-l{B3Kix9hAxcEnTB;DEj4o~s zP{I#g1!hIK_A)ioWczx4zkagslk4FhdwS5r$evMd+JJW%FOKNb=!m40Pz-*c_5rXv!jfwI237^CBgKDMYY;5;{}`I?%l*9v%0pQpQL zVyn_fB|AG(8Ri%57n3BvSH<7;6d_16tH?fLvYQlsl7h_cW2`S8WWQ<>fj=8cioCs{ zfC#z?Vavp~LfDtSh5;OAUnUJLtk)SDZ|&Q&RoPBQrUW=Qzu#FtdI{kh=~wG<*zldl zW*__p9_)9LT@3ut&R12#pQCwb1na=N zp6^yFtzpmUZNHMkLIL0rfZu`XbB-psCY>>50w z*m=M5)U=VYrpJs{*)v+>Rg@)3Idtc$*=~H>3SwkiqH=nDgsD0t#o)=)Y&4w(tzeCh z(EEM)cT<44)WPlPzk8N$38;+XF6b1*LmT>+cTs z-C-xU=Br1 z0_QT8BoZ$zKzFQ1KSzE}^Y!r9Nv_+8;Gd!%SN z`CYbNLcim!eUNm<`pVl60WZ7SrD?x>Z7)@`ED8TP&@DQ{L#h3~$+P#+%chafgQ(P`6mX3A z*3Vo6-S4rpTfKs8I^vHQ; z)lnwYSZEN2AKF%#nadI_+A%}XD+9u~)?MPw4QDU^Y{@quc48Jjn{_fby_sd3spb+x z?5BezgE*tjfx-unX)jSe6K8v=yXpAKh50S1{_}Ohm!q=@0%mu~NR`%l zVoou%xU;mS%kH_~32Qg5*OR~*B`sJx~+!0?M@nDx~ z8d~J~Q^t6kM#KqL`eadhD93I`7M$+4t1)hwWNdX=GyPuDK~|EkF*cOo*>hS;LC*?U z61VqgVc2l%#p0ea+PpYCE}|2Cj?B#5A`!%r`9$6ec*kJfB||1TITKSw@tVxhtht+p ziu#yJJ+rm-_%uMnUX6(Flvx(qE!4W*#wTX85Z56ib{Pq^C=dDVO3~B27kGV`~EoUOF z80!z)KO%!$R{waa7VlgYKK@{e$rM8R>|T>eU5@U&u%OE538i=S#(ta2F8^pW_r>Vo zK9|D{#$9Q;BbRswVnz$sr9(7rN4ot%suqpE;vB3n$Htg;R&qAelP+QR- z*Z5q=ucHIlh9J+;EzHkO!5g?x>fI&4B~ymaV9>se>dVsoiW|SJMKc6<#L`$o{qFhU z>P^*)H)Yv+{;HMu9}8jaBy~~!+p4JVxDtCVwcz$=Q?v?9s(ud@7%+8b+m^Ow!sM>C zMO5`)<>gW}m~%{kJ6(#J#i92E*8Se(zh zhDd6?w8Y{=ixyb9DN*cvMhEStw$2&ZR~5N5ipCJ3Ij1Fh&${>mR!D1iK&?d}&yfmi zMh~BjpxZbVP{rs4{ywE&MgS({B*W^I2pU_P4l%J=7OM#wB3Eb2m-HW#k-)E!efOr~ zO4-OKyBvs*)c9ddQp;VSX;uk;Glg=6f4g_{UnW4?{is)>TP355j8SC&D2MUA`&gu# zlctRqcsr*i$e!HQb=;DxVAyJxKP^fz5V~v$f3lm%;C;DXb)JlUEA_oruA;JIqn<vk=hFRrVr2%@v^IXb)wP~et10djZWE^!t?CtwjK@qGEM%yYJ^(G~9YkS%EF6^ElRa|LrGTH1kipn z{zb5?2fjA$qr@c>Q7E1E7Uapvv*7yMNLJW&cQifv=tUfx>xU6VxG#|uEmP)1= zG9iNu8*C0{CT1-NgQjQ7kDKsTUme1z60gu2JGn5DTHoc)kA0@M_(RKwm)BEt@Cpa4 zZWe&drJ>77u53MR-V-AecQrb^`^AcC_~qbm*ku~j5J z24%lFqNyyygu=`j(4i`P&6D(1lcD=Erss*J{$2FpE>2leVclUYjO6jIA&vN{#|xmc zm50&1-=n1VSM-8;X35@3?Ai+_ zOZ(>3bRfS+HT^;M%R<+*)t#0V>u|A@F=sN#V=^#B6pipa^PUq*+_~l4YVY)5t>>aS zYyvp@YWCw$&g|N`+(OlE7dg4Z(}+#WA~$eFMmPT<_!YplNHj6q#NFdhcyUu5Adc!^Y?Q7A+Rz&KNqLV*jF02}N zYL2iAezFl3^wAV_r_V|27b>G_tmH{OY>4>w_;+2ublyyE5KWz66unI`^_bn>9tMX` zGe!})m@e!`P@5`S=rcP~%(UFJ*J;69if~IQnY2{7*T+IEswaGJ%PZhqGG04?D$3m_lY`h(|M?YGIe63RI`2@ z0&nSFJ7^jv>{bK&+ey-y=emSzjuwJKt;KP&*9WgdKs}>QVlUSSzf9!NHHDA&>!pfA zQ_YpHSN40GFaE-Ip^dT6CUM>Q8Rb=GO^bq}uwd#_v9&n2`95j-V`b8j`-%eI(aWQe z3A}ttFBwNOBl)sm{rc3QrzA5=ZF@Y}^n(!<#sxB>d3-DWB8!GHf<{H^wB^oOgjfs( zxB7N(cU;POI6v9D-u8F9`o~8Po{4jte+uh-_u{F6nhyj2^yrY0u>Y($2vklL=`*}< zB+-tSNAu?eHf5R+W?_+`g-FQlD;pL-Qy+ zerUGMC%IgfL2uNmn_cgNRKq#x2PCEVGp+d&B!zg`78kAJz`>N@)09XS=I%pP7tU3r zn=0>fV^}s(sQ0wuAbn9BsB!(>A4=@Q2RKO4wjgyVom*HbR~bc97(zuNe6{FqKakx; zl)({)_AR0Z)|L~fx*l7pvv&w!;JyLvBREcLFS;%?8r@8arp-`kK#s~?c*iBx2OW$+ zdw@Nu5%pjAi|T`@f{s8=B;aXPQ5(U4@+51PwIDE)>EKvtuO;^wTiMET%HzlX%9Q`t zx$x@Qn^({1Jj|c}UBzU4|0ZB%dp|S3tG@Cqp+?vPvFe*vbG}{Xe#*j^huW}^D6R3r zF6xlwv5@@{FP_i$+tQqnoa!Z;?0Ty&30JK=>fU1LGcum|q`Q%WnZf$g2(@Je<<8|1 z%U{?ctsuL_{4+*Nj4WAnwB9y6@jgV!5_u(u@md+`Rn+|+E%&;G(_Y0MuCT_Cs0Z9n ziaMkBoNf1+MRaH;MJWT`&J9`-6t}dryvf%~8Y*$BLCJmM0ffo*ZhSXN3+J@wW9@kS zJYR2V^tWe1Psrm>Lz`>XY#vVX33gcrom#Jj~bCpshT_MzTZc zY26z{D(Q+0gTUz8tfm#MNo*OSDap|(nqFdB#4J2S?ky+4Vh(1tmXIkl;+S7Tijct1 zXX$$AUGCZ|FFtG#Yu;XeP)}k-OzYh7&2#N%j_24N0BB66U*1?pVrsAq5Q z?l(;dCLh7PN!Q+ntj(MqtnJ*+WXKgG4W(pmJy>YPXR*hJH%#v^MOceO^NB7lt9D*i z#}7SmQ|+LR?yVVg7R`CF#SOVZ1bINJEGkLf>{&FVle*?Y*bzi!%>W~DeKQ68q#b!2 z!DuK{`b_uesXu}ug^c~th9sS-clc+qF9H?|fqPJf6qBPD{<2Vsg{;oRfUNm>H9}dF zwsvVEYcb~QV0z8H@98@cZyeokFg^b&lJ|^eR_XrxQw&XFr(RE)h>UuuT@Ij z<444B8i;oRpKs&~IGnFBUfb9k8-~K`VVRqTcjrWj1WYt=?PT|3_24d#dJe)tt0pcA z#^0j5U869pr1Qsuq-OYnp0MlpcM@G&#Fsx1`0w=MK9M}cML|1w3`3H-g#r9|-Sf~~ zzX>jQW(0Q!kEUdMa8hrJs{<3ZxcjkxufiS2kN8qMv9%ik!}_#qux@b*+V)Jp*Jp1S zkwph5{z0Lh9gxJR3z(h;^B$g)pO$|Rh-(OOrV$4Cs% z{D$mM-{ZsROB?|^+u<{#R#c}L5|OK+J;My2qD<<*Wzs7<5x}de%on+lav-UFflh0{ zeBE!|dyI7P?UY1jurAl%Jmj;<4jFq5jNS(w7k`qy+1#Q9SpmyMN{lFm6`0J4>xfI~ zN>RVw_eJ1R9bW-~8S(kT%xr`pi)O!ydc=uO5>gna%6L??PQ8h<#1s(oiuHMRvuf)4 zNC!b);x_n~6u-P5f}Be%oktJI-tim=m=AZj+vC667s1yuOHbAp9OLgXQ zU9`@G-iJiYskO}1j-VXLNxv`1$lmO>^)-R4%?J{z>O4fgd1?3~A==^~cis8b$^< zzBfE@EQu!0Xa-6#^|3O7N}zPJ51q)slEDQknGM-aS zE33fZi-6zWMdc_Kh*PcOO^H+pNPGr-PpUqW`{d3o96_+U{-0f};=3)zL3rZa+oNV5&DDD8}tNCJnI+yusX zESVhzctXYy+5{Kl({!*OdDo3x0R*~Tc1^S9!y)wPgFdsLRZ)qK|EfUy|3H6P7W49w zWXaE{7|*|NKd8{(Jc&PI5$Oeda?tH%K}{Qo8p92$Hfw6a?G!ZNr3E(>F#KqOuj`|wxFEOp}jWK{I?k2pREhEH^%3$%M>lq{5faEU>Xj=nda% zf-7NRfJm;ciJx%u%N04VyWjt0`0XXW+p<>Iv10LeYVC_3)PfiJ<48nYK;Dd4WGAb` zr_b&H1NgE&WT%>r_P7O$wa+~yeP{B zV6R+WcLd#TmUxt&c$np%Rkot~nSq&R)ux~3X8hVSf!`Ya!k4qd)%`9N?c|R-P@ioe zo4H1$FGUlN*;Y%*hvhG~k7kBY%A|@_p}{ZQCvQ{0-u;Nr4o_r>!91v_-q2*wTTu*N zqY!ZGXpcD|PmlYV%DnZAIG=~k=I3c7?k$j90YyQ2{DS~0iYvAse%o+|3sw-@UVr-I zUZ^g>j-D;uF?Ta>x6G__qqed5K0S9Qr9-zoOrbtR(e=!}^g4`E<+s9_$6X1!J{NCh zW~M_|X6BAemtaPdl{R7jhmN1iqAr`3TwW(0C7i_u39_kMMY6CbNF!9}=uqac)s6Gi zf$r3UwXiS#0mUB8|>%>kxDkj^&y_OBa)A|fq-`~R#ybZlQ%oVW_0i-W`lt@?S@8j|p)^CJQWM^>L zf$cfm#y;=JUUkhevK!CXoJCWgyMj%~6qh5}w3r z!+1n2e$@jTU{z@_lVe9f9XJ4oKsnwxXpCT}tr$w+#3v~3K*qv+t2Y2)(?lF-!rT5s z+cTSpHf%VeQ}8qv-Q_fp-|8~?+2zmUfb&^d$}#hlrDxD9{iO|M44L}ZSh!;a3cS@P z&sQadC~P_8%$@D4Ygf`e>s0V_HOePxdd#A~xoY{4IXqa{+`VpyD|UB^hsTaneRS|M z5K=beTG4PEwD$f|mT05vi8JxqZ&H_a{f^{(P+Y`H4}--^g|$zl(bD!2=Cm8?XSA0@JRshZI0VKn1Wh%o|*GW+UrXpP7hIknE}ECGDg~AhXuVGdK6XH}meiHS-Uv z7wd57bNY1EuDz@F{=Tp5yL<~G<*na~?R54di4kmWY`PY)oX7}6uV&SNM+|MNfusUV z&s}DD)X6WYHlk4&BdxM@(X4>&4!}=cvVvGPww;P5!qzVx$&=40XjBh!>NP= zdX@OmqNHZ}Excr@ZwYeNW}yTfJYN-{X0||g!y$ga3Oxu`&35)h35a>>#nVj{^(2n7dTnZ*V?T!KHlLv3qd#(R&CL zlnvyB;7bX0(apm=R@z07@~wE&>h`FI)QD{PD7Y`5asyS*5mf0o;!u!t9~2`b=M-xE z*3#6Lj4}TZXN<$aEubsXn8et1 z+55spu()IlMV%yjqi}h(gO#jPLeCZ^-ibrn9^@MHUbMdhJSdkJ>>2J~(IrZ&*7IoH zwU~FBmt8@g012Ql+brSB;`kva1^6BF=(U-9AM+wugK}GbZ?&3LPRE|q$AZvtHQ(oE zZKIzHm}o=IX6m)_Q|2u%R{3Q2X_PsMvQJ25C?;kg{{@SJ?@k8A1+B!}2Xugn8@@Yf zkQcA3k+}Ay8)brAmgO0<2n>!dJy;L)fTKL?E*KRGCFlb$b+(L~^XBw1GBCaF*Ag*y zg&yH;I2h0%0U!cFypS3~XzxJqqo33vvN_yZfAF1;h(WMi|GB_KsP%O$Z_GA% z#N~`^TNdS-*3-;%b0#o;TJEIa0<~-?IPoB|G$bsPy!y?>W2}}9<#=-h@Ke<0c~1}~ zDR9VrH?(Sm@0FlNScbM^L|)q$pz%6_J_DtM$tqv-) zU%5dJCd9KqIj8FFwf46lO^AVvo$fzhSNu0+*GCAS?UFTo1Eui(f_bur`W`-hUfW@k z$;;g+UVK>dj+WKSWR@<;99OUCJ2T0$&<>y`b8W6oH5|Z{&$<0mWGkN_xKgx<0f0oH0E}z_H)W8>P}^B zjFo>J;9n`^0fsqI9&;R)3+9NqzOXz4B&+dNGxlt~RL;3%l*y|1T1D4Ck$HVm;n#)C zf!n)6|9&h^+jf_Ohdv2Ax+J*%2NCu2a!En+soDp+n#_QVoL-C}N_{~o? zV1fhPr%328bexY6SrC?_kg-$baOVO$TFYjD_sQBf^guW^yKr9SH7{_jAR8o-egLq0 zU7DYotY7O6b7Z04&5pXByb6|WcHJSi?F zBju6UiS>qSZc@!M1&JEIA%;eaGj&#=1qr2u;VZ{*>=}|q_>Sk-7O?`J`XO2|xs^Y; z>BvsebQw_H!^!=I>RT+majQ0RVOd)78ECQa0@1794AXDe=3?2aq%)mQLRYOjURJeZtdBU9M+GabxxhrM1r_C0&$BCux{^wLoc1?_ zyksjo7hmtLA6YR)B#}7f*d!kF1lk630d6I1a#=Y{g^d;O{O@+*byD{>fZeo9)izfuz+i~tI zC&^tn=pcG=WtYcM*f&Vm2{5XVhRs8?)|`B?_zS0hEqf>(xGF`fx7fC`+A5}}odY!F zzwkZydiX?A{a%a$*rBLjVs6upi|sA&l%DFQU|MA%b|^#eKLMth!e*4+da{Hj0(pitJP!U+=53s7L;x@h|(&078wszs|6Lb z0uIk|^gHBmO|FWQA}OyUPZ2Z4T^)ycyDqmm(E~u&qU*@1US>4!&*2=v z?>fcFonPR2PDS!$Ht7uFbp+wPb{P#~4)%Pmvv4{OWjynIVqJCpb>jVix7dc~wPR29 zu24Q)PKoJJukD;-El}qgD|9wG6h-lOoBFqtd#RRko1mp zQCq_37jF!n2s`R|?B=R8-914+;B29*?~24YdTedTYTmMG;W$$Cu$Hr{7u~h zRAP4LtIDVR-QB?uE8`s=s!vD-H7b~Hjcw@Zw;kNkv0rVR9 z=f8L6P}LGSOOLVG4cCjU_$6zm<`&Asbp|U~nqRwyKDI33`X~cgh9ebM58n_}SekuE z3#ziIn;bPsmV}kn5(MGR_f-$C)@ba;_Z)u|ne*`f#!R8m|02HRty*SbxkP!)(}3kt zckHu#vE5r{8=NirFNtMR@M%NC3x5$XXv9xd zM~!&K!x{HpC?+dyT=f_`;0?xBL#5B(yZ~nMK#-|9c&UK3m%G*= zgsRror9(HkLPbj$>{%j8CxJ-{=z4c|e)+!kY_)!1tBlNXMn{sBbfhy}jkL8BE^jMs zt03{YlcFXz-Rybf0ip2KiRf1pY|_hHq%-(-6q^y85)GZ~!7@!ek1C?yP`Gv|R#$1; zwM?aY1P^^i1ST)>NRD#%<>{Wp%~NUrDPprjhV)wa^x$*78S9G=b9E7dBiqWuPCU}M zW2H-501yIc5Xtb=P*G|n;Gn@1Ni&r@)DAK$H9kSjZ8;!6S=ETzmWE|?V zgn+6VIKnvUzdb-8~QJ(I0iCI@2d2PNUJMtSHmIM9e2m z`5KtGP6JqA=oGZcNuzX)C?Tulsw34p$}wWxu1q~g@S2-mlO}m-Z2ly9!51PWO3%g9iGYFVLGq4+#-5aD zqU{+;Zx!kSm9kN*C$i=7s|6sy2EiN(Am=`Ye4}A8t}`2WGKd_NVi0TeaU^I_#~80N zI+T3Ci8wAu=NkDFHi~Oq2h=iU?<>7$0Pcm*_D+UeVM?uB)Szj}P-ALM?m?~y4`0k( z$Enr@UOJ`4t9_+K?j_d)j;~}U-|A`%%$E@LO#Or_Vum(Dwr8qbT_TEr5X!LNQyn~YE=uUPk$2*=p7FX83T=5E};dI4&5*trMSyPZK zfi4Z)mf-3Ri8mK?q;G!xqQwqCv^I1F&o4KXLVJNTrh+jP#eZXyzt|{7WL)LG#R{r<*s**HuoD+oAfSKTBboZpT6PoPSyG-U=(Ar zpeo(@(Tu-3&iy%xhc_MrZcWPb5*Q?N%*#(}`CzkNV)x#2m}`6+NSP>AIx&$w;B3BZ zcYhyNlxQx7u4O$$NQNYIst_r;!SbzQcKq5b6WBVFV#p~2-wIQqOB0wr`_3qvoPu{J zL1|e)=of`zHU^W;=C;Dn1rB=7`^DqgT?+-k*ui-@2F2%zwqDJ3Kygx$G zXd&u}&X!iucH`EXRawh4IiHg2f`w!{8@8bzz$qK&QOeFn(ac+g&)5O0 z?+_zX*YUG>pY+lkOenQvhCo7R9+*PeZW4mgh9k z0L)dp_Ndq?OLWgXzkl6VW=xWQCum`pCmz6i(g%!c`IyB zC|ww(eQasm8%p>HvCY`0-_snStPX`5e$Q`B64p;XJ~)8mV8DNG$A zuH%XdaVUkQjVXdVF6ZPv5htL?K+;J@J&O zC|ODw*6nW-lqcEgPIFI%u8Y^-hG8E}N?8w_o)+Y;SOiqD@QwDOM=X%`ExFFysB-Ve zq_ueFVsbC_r8+TcnwVa9X9v7ryj_ZJ>6<2^#-86Ly+&*@6*gr7A`{)fVeK1UL(KZ` zwxC=nN{S<28(2qPobv-dJmpS{=b^IlYEU)BeFfS?5{kC&RahKka!L-p%-+5|17CoX zn??G|f~?zgAFK9^hr^l;M{dsJ4cD6si)pN_m3Od+$Ns=-YVx&W&O{a?W zz30G*38;jPKreGWb&;lZyY6kZu4Vj*?|XbCZog7uX)e0nB26$d!2U5O+uD0*0gZKP zesK1>RbJ6>U$oaqhzELfZ%g<@ePqnlk3g|r6rq%)Ts-M*OyI&OeAk`?YCye{PgZ;b znb(URa;H}p^8I-CBAqgudWL7)1b%C;H~igHKX=BLw#0C zCYJ{W-PZ;FoGAV|kNgXV;J1nErwB5x6sYBx`^(ER7olDvD;uijPa-u{+bG{$eRx%^ z%kXigMz9rg7+`i;x}T4Dkb&&^64cp1-c@v(4l;3~1(bXB)v8%flMz$JxX5D$C_nnECSDi?4MZ`(L(3%j5uU3~({g@}B~Gsen<{mN~<08|9~N zzfiV0n!vIUi62|>(jO{4<`~2%lSgh-a`$9e9cM78zXKS9tf!ILnr))@yv~xBorCgs zC-R$EtHP zmbEP$eYJ7&#R*loi8V{eiCjUXYWM}tHk*tS1EZsZj4^0@Aw)2VJ3*FeSoyu865+fF zN;FnFTslRi%K)zD%XZ?vG|g4{0GqeFXdtF9E}Na}?B7dhe+@}FcxRjuBkqFFh@-XB zLk!*|*cLO1io6>N-RaglXcsDtmNt%_0;}R8$0p+2Z>Q^15&RlYBb(qDaVrR$p}B*`x5-W9$(=LY@vvh9w`q69%oO_k)glC@MOH-gSoNFV zGHy4E-^H)W@xMziPn(6KI>+G*8gD^l5D$UAL-%u`v`Lg1dO4mliJ7o8XGW`)dk#}?KYRuG$ z84ouoM6iN-9+~F#;aMAdUk-7Cc_`8f9Z$Ff6?z`v5aI^C>9j8&LcFfiD>>O9QLe2%OUQEi9f*e01?UgibFW^^prZq(vu`&rwGpJ1wY24lRK$A3M`4K zkv_Bq_L>TU84zEcWU}O}VD;pXCY@0N9z%Hpw=vciHmg5l8;_xSBEQQ~OgoPhfCdYv z@0wc#k$+@Z4W3{H%jvycal*Huw7Q7yN$SbzX+|#>4tu<)ne!8IW{hwKD;iQ#s!h$u z9TD)e2Er-SA473@B_}4t^C_s@o_Ct^yFbNlu3_ASiA;muxr;8rD)dN9kjp5&Hst8`^y^|xJ$RUZvOKdUPe5+(LTFqXDR|*3V2n9 z-I|3$O_E#SAmP9ttoEjc+VxG(&ugpePS;p}bmRzs)HD82Sx`^LVq)>q;EG_a7Yoni ziF!X!8;*c+36V3Nw|1RDw&d^GPG+$_5wV`MI1BnV>H_Y9Q-$A*XyBU-MyCXuH2uPw zW|wY^+V*SkkG`Q0$d-UEcp5QIjF=G49+ARh^FpW4&%laA@#eZF){Kfy?Y&2K+5 zDogv7h2H_y^vKulVCKR6SU%%*(LmLquKMEeB*(;%ASwgZC+dO>YEen#Y#yZUg3yGH zjX`sb%KO-aD^90d zQt9_nio&DDj~DJ>UQahzRap!8B+Ur|QXDqf%VCBP4HJB~GJbFgLYNWzCPmUov}dI4 zsvZb4OU}kvV;^Am%vsx*pof!Ee)6X73gJ+W(ajB?lO$iedw8j5klSy0NZ(oG;Ye(P zLwjL2OBb#9>NOXrm8X#pmyue5p44*^?SS!Bu@c z`Ji=f5Y3%P{q@Ml)~bbZv>HYl?F~5NwOX^*lF*w~IqP6hdVCYn^qqphX;)Zs=|f)- z=2d$Op=&|1KCRnD({gGp&i=jE?f1u=k}84M?|}H_pTYJUn$J{*q;8CaBNMkkfxuiD z?l6@ggn8yL#Dj|PchR1VJsfRM{Z?fCY8l#-1ZMJ@4=U5I3Q&@j1D&7r>@%5lg+&QT z3f<;{FB?1uI46n_YU5b6r}2_HQX2d6&ryUgJLJrVYFs*SrMetSqcRBRXL5%RPi2>@ z#f(B0ckHYkFit};lpQvoZBA&gwQ*ki3bRpB8d*O%6={B(%IN4XPiP>*UqGsV%6Owf zoTn8NUojXqA1MTtf!bCrQ?z|Lg;RF~Z5&&(1t)u%^Dg_#OxXNi9`-vSABNcU;DBg4 z>DhV;LOF`x7|RXHsHtKhw26lo%Ig+|0@w}e4)s$nq&oy;$PyIK) z9TBn@NLL*}gAh?0cUA920KFu{)P0I>mF1COq+8Q5cK;ns%dS=QUJnYVk*e@ACF-|K)&mP2<+fVhJRa$<+xl?oJ5w8jxRU-pp<-78bh7{6WSf5A zYmoDtnC=XmG4SKf+fsY_i2WCSLPqJeDgYSqLzk&{s$z~Y_I$DBE1Su$J!|62PjEGj zLcNN$%B3^H5Nc746kBvAP}fc*ZpYD#Re)+~r(J;d_5|U+4L*v*N@mI&nuv#dpX+?R z)bEiM;es$NJhgPB^MiHlUd9wPJF_Dp23b-|ZRnS~!s{UDhTYWwo$0A5Uu&h}l(l(V zHxOcWhs$KM8E-gAmwLmP&C|N=7@lJbdV}oe9C%XlF{}r;{*~iaIiPeCH~O$!vwj6} z8?dLG0KJ_$7j8A*nHI+5Jm|z>y$T{+CA@HNI&SqeyMg%JK`Kj|%CgJmYbOBAmkkYG zg&4v-wI`p~Ns_D8z2zvz@h4F>9M~S?{msj-LCoNGcS*;xV%`#g&fwQ;p7U`FKWT9M z+2TQ+IwvV0L|lxtj9grb6p$OV_LAlpx}FMFVs0KVPfXuTCVU|$_?n0qS&Q4CXU3Ac z52AVqyaJZ@%Tf}wD2)$tmN1sSu04OI2v~Hp6n95(7%1Xdo1G4Q342~Hx$-1xPt_g9 zm@hEtAFh!?%dc9(2$rS)WL>efiGN2~t&^PGqP^ZicbJ;MaNqJBG7oLWr?`-zxkIdF!K zFr-wxUpW^>KAWEc+kEA^!Fm4dey{BQdo!%2On!_`NI618sJ24hK-9BQj&vG# z^C~9kfiMV^<{$&wYsYdmY!?DvK0&D6Jw7wGrk+lZl51T@a*Zy$CqY6A0YyYOVhF{4ZlQ#CW{ zwyP0$eFIT!qq+Wsw`PP?&=}b>Qi+35m6wj&!ojK;M8)&@!q7k6HeTjL@Yr23g2nAK zVgsA%%lnvgeKUi&QQ#=+-Ve%DyKf^xyO)aYw+U}DKywl+guod zJq$bbdCXUZ$uQiLn_)YU6NPH98ZS3ns|EkyO9z3w4$526xnYOrrW^b~I(}ipcn{QI zZcDr?9J{<7;c=b^d)qwW3Ryl`BCun%zr!0cP!T|8=&m5yH9Cmv>QwsN(OMbMOUYUJ zb>tnb;0+L3OEyD?xyoVH1AvspzS7)s=(-9OuFml3t~gZf;aoGgy}Mg}xPwmN><^fa zylNxc#tez8D67=L=0YwXe1=8mkCrtN4B-l?#hJuzz@wabsc<@Rt|+WOz+@c^fW^|^ z9qt8k`y_~w6}#AscxLR-n3=Uw-CqV($WkrC^#qT85|-Mz4{8rYHYHXUuJ_lo-o06v zw`b`U9>GKPTN*4wq2ytvnoaU{XYLk7jQ7OTLk9J2?`FG z{+Th^Ns(}u!>yB-eVJH0(1j*K7%tj4_=~N1>Sc|kKNriH{&79&OvpGu7->~NVx@Vc z;jj+0U+3XdoO2pYd~5jIdH&&Z{`&3YE217h^v?fz#{ zR0B1m9;Ye0SwlEP8!mvB9=5xNJJqw*zEWS~rcU&*H(2afkL{)dZJA@j@|0+}wBuUy;4JnfB;8+*4bgC6qfn$%6#=`$I?^|w+&m7M6H_9Vw| z^uq3_UAQxL>p$0IYpWIYW=)bs1h0&0j<)Cl+?(cjg1dAsec0B4cY(o{OPsvG&a*yj3gZN72m_HSXS;r~8su?1QtNr$Q*1 zk!oIj{M{Vo>Zm&@C1-50_DfP0S7~jRgZLn-FOtCl?<}Uit6y>MV|Rt61goi0toDfn zGcydHN8bj2Pe_bO50omG&_dY0SiV18E=ABNbH_Y<OdefD_Kn zxv6v;Jz;mhWf#}-Bi?C*m);yv@}U2q2zAB+1%Vlqm$XkNmfIZh*=7!(zg`Kx5iW>H zi)u*>Bgi65AK9QsW?)xO1Y&#DSDX<%h%?Q0wxwmB8oOp<*4iX!mYxktNP%-jH*awm zpG`{X+P#5ry|bKyxWu^~kq7r4wcX9PZ2~_Dn+;j}TPta767gF<^!nUNbKK4J3dLbO zz^TrS+t{E2?@PMJRIZbH(;GB>(y)pWw*i&FpO`TH%g-y)-k-^zSR3HsGT%9h26tNW z3nMD<@6bTy2;t&c31mM|35ogPX_F4-vkSQD)8C2E$fp#)(4r^e-K(ImCa$ z7ki1EjBE5p_TxM-oXkz@5#5^*ANgC8>t96De7F5^S-^0jBCkfm8zP69H}t7$I9WyM zYQpzq-ap3}-%4U!!im+#n|SG$5)s%DHfENeF@1=9M^glXe*&6MIystMb-yesMCf;A z(BmLIj05geV1&l#K0I|)4C2jlEAhnlP{2dw0R{C4FqqqAvtpLE65yKSR4Oy3JE1QR zTcUI4)nqHZ+QdToLY95fO*s->E>3yK| z?)RBKxJ8)5>5$|@e14L)nQZX89~3@lGa~o{?pMe6N5=yX7mA!K6XDnc6UZa7NvXd; z9WVK2EmD5zuJXn(tw)}U&lCF>?^@jk$nFDxJbG`W<%VX~n)@-bhPsAka=`kRw}cyE z#r7+=Si*CeXVC9K6hpO!k;X~;h}&X4WG=PFlo|(kihMU=mRag7GZ@#_3Mn zkVMWAo@}<(Q+?Q6O;6p3V&%hlf_nlqtXt9NdSWx{5;3AXh%CuvOfzLLF*hL!*vWk` z{&7w6JwH$#&>Dq3@;x3_4zBr39KmZ#;od`L~fXEZ;|lgBBgNb$tq0o)+z?RU&8mF44+ zHL>DnHwjLHYSj|oK+|Cb@`sIwlnz!`M%tpA-^$XzjmNO2`$6cbcTtUt8?6b%F4rEB$|UG|hc*ei|;W0ljhCi21% zRcsYp?$_#niN2pjCe_og-wrfiR3BG85^J*uf zV!M~H!g@U||N4*Fv)`;tMk{qkm0M!$-c0bWUdqo~8|Nn*l;Pec*fhqc~f4c2Yy;Ra$L#xateD?+1Whu0QtyNwKtBQ=Qq`5hW32J^?(w0-C z`wb#%3jNN))Jwwu&G3b=`@0#}Kh^~^jYvHD`+V*1A-YJR!L2x!RQIAQehCPVv9p+v z=u+}sFii<~bxGfGsxqRliA&^tdwE=GI;fIz-jncxz)wMsljq^%HmDR0_0fafkW4Q| z66$Kg;kSLhRyI;{v9}VeND@kodIca{)%ki>Y6}|5bKC-y$1grHbyaoY1=*vm64=0% zB&$>%;D>)Dg!_y-Dm+o`vzCmr7}8n=j*azWS)O zpSHaa`Zm$<_LCS=%A}v5#C0*+dm4;fy8^-y8=tD-EKq)!o00$P5kxZ16z~qA7cJ%d zGYpEU)Og%s4lCAeH?yk%#$Ghgi>MsXfvN^f%UBWpCP$U~lZu)1R2lvKb%D^_dDfTxg zfX_Mf4_1WFqk?z{V5jNAS(%C(@d4`_>QOd_Re`U>uGS;?Sr1ncw;xA{-+Ir_FHMsg zU*@(NrZjVnTfBmxT?fuqBr?XGp;7yonW;xnUNSGw^n4^Y=A+o8w}P(Vi2|K%XcfFi zU}|?U0zWhc32mQFiY9z1gMMYzS3^$c(P!U3Tk8&;z(Q~*>I@KmEoQ;A%sX;_@^G7; zVw33kiWmiqOal8|K zRX)&)8??iHS zLabbNEvhr&{@?jPECmB9ilMMmvA5WS?mSs2>ill=ivs_VOYkEPdju&D*A;0WMAWkt ze4gpPNifyR_3r6h3R8T%{P}0qkVM;l?Rd9DLQ=Un(lT8d>E?{LFIe5zO&v6g=Z*rh zub?Q1TtI!~?xXs}(zI&Lx_x>;TO-Fzsp=(cp|i^*vnAaZ003S8FgdkJsnyn=85Oen zTzI7Gc9l&iJ}}=F*K&V}c~}IRu8wRx@wd@aIqOUqoy(WsNF30II>B|WxN6dQYPEvX z-zcSWGUi{G7dgh*-~}y1LtWpmwlSkUe@1J#yZ7 zOWh3h7a9Yd)A-r9k=g`{lIokIHh?5xW}7~}A%dwIBG+xnqL1sS=QPa4%lRS<=b{>7 z1f9@0G7UB#^TwQ?G1Q0Q=Qp|t`@x-R^tB4`rlG7i_GYjYaQeAP*-R_omCIPr5MRhl zDJC?C$Hrs{jtXpY7vvVTB3O*M_EsKMkD?8b2!%H+oB$pso{X+8^gi)75b9Z@Hn)Grm2Xiv#i_Kf^H6 z&Z?`PQjd2T*`RS-ZwqJ)cD`7_e#+-#iI-B@8a(LmXgQdWbfoI1%{{HxPyzpJefM)~ zWxZbJs~O0QVar{uE>FM1%$)!a7iMjpQt7~aI zSJo53XneJ+hOG2=I_|Hf@$ZB3Y`m2#RPdo&P>w-nsf?S=e=MZS7x6|zKZk2gSm-eg zQDoXeFs~wlixxllF+9$+%LDo%eV4RUEt0_qt(8@n@iAopCog?%4_01_{h7~?N$sIFIuw{PI#Gbh<3Oe80)p4sC4)KRpxB^ zyS!_3Y=ndNfh_bkiYGJ!IW0t-s?goN(yKk5&$?;r;kjn6IPK`QlgLr5mqEYxgLi;cewIIm!pwvQ%CDpRnZBe2*I2c}4Os$IE7Im^MOd zn{6|uOBO1dg33z8JSCo>0Xy}{!~NSVu1(YuszmR#7+w0?L-MAv``T1%^Hzg}Wvl}n z{IdHO3czbf4r4*e^nk2KQ^{=?E}i}Uvzwb_3wBgv-az_;7}4!qR^2O^_W)-XMFw%Y z)VetriHp6jFQziROKuJoIPSm2*3`?@*q(hUnr6xtm^jVBNYR>etb_}NCjb4qCPK{D zoa#<&F(pnOTiIgA1Ut_a|B-Fb?={d65MljTuBUFicfn?T?X%Q+bFF8E&Ar=dZid%7 zFgEKvfDCW1fLG=?E=0@JVBp;30-rM^nKd$?PF3SeUGwONX=|&zyjm|GA+$IBZJ8OB z1CTkJx;Mw*A`TnxISsv%*g7Bm(}@ywDXUGJJFESvTBv2oZ3%gIbpE1Q+c3A5hYtw) zXH@1zICQTSq`#;=s}uq$Q$BsH32=stj=8z91t%CW1O%91Mr)TEC-Z!oc31;@do!e+ zuQcO4hDwWu%am?QY6@$r(Y)CyPG#s5GGEsmqstyl`Y1%y)P;Sg4+$a^GGQBA^L&!wK_*1 zdnxA+-E{3z%mVwyw<38edP-|=_79iK)Cx z1Df!r`6%cmdK5^@Y)Wp<6e@%>1$C<+XOh60xDqoTqeNQ_u3eTNaWS>@m!cDm$8PWu zVO^x>tvJc6=UNkYH;vsrM)psm!5cFq8SEx1U#X&1=5+}5+sZ269+!Bc4NLu?HJKV)x$3zm}o0=n`Zjqnp_r6Kiq*N*ZwyzR|y0U3XXdTFKO%C zd6VDC+WF^?Tc(rdcKe-PR}61WY5?g>LrE7B22vq^-ob$MS=3D7b9oCX(P`4BOo~Dj zd?!vD#kl-$3~FAGDscbK3V;wA32?zKn{f1zPXl~^m_hgLR80jqz^>Bmbgj~CILUFp zq|R&(uHL5s&kp80!lm54Wk>LRUrxD@|GTIC{S^$7aaZn$JH!@k0vDbq{_9X~KX{qZ zwj-%FSPRpBd?eQ#pYbK}q`afmdN`-~>~}sxL^h)(&|ZG++7X`hi@j*QXt@fa>S9-| zE9JiR>x0AFm41~gOg$bB>Pu|w{@!2w@yYGev>DsU1bepf1GRrd>HjDf{uR*Yas5_T zSMfzwIXL&Jy^LDmWG%b$=N_XXppZNq>0jzh@N1*eIRAJsIZ5CBrx5y5M(cF=j_ao9 zdsCZm4W$q8vXrMv#vt=iC;d%x7i^L<*z8+S!;bI3*I9!zg+G+L=6EV1;7itTQvq-=NO|b^^#eb7&vQ|W*9XKT1cm}S_%y1ki@_yL34D91w{Er7P;Daw| zr7~nK${4L_y23*kpnSQ)Jj$R_ahv#j zu0n7EN?LjB{KxvT;f+YA&NQ=*HmY@|4;K92qNr6qZYGF>S9O!Ti5bmdh1{R zG&uWZOIH{+|4lsncYf=i?lw&WZzvBLvj0mv86YG}8(3Qx!=J|;LwD}a(E3lG@;`hS z_}l1kL~>-|t&Tu&{P$h^uR-!}&$P$=vVgc%i&y`nuKpL3{(V&x5;8KfqfKThO2GfY z9DUKO4H1G}R102BvBG+LdTQ;tfPZ>`s+dO*fl|NsUsy3>4M5;Yst@#(TX-}bx z;@r!cQ70lg(Uh(@`EyLx0w0&Qw-lATau_kM^$u9v4w7|+_ekee@Fwlg<)=U`87yoEOAw*pT8e|?pkS@=XE_eQk`=4k7>;BcpM>?@!%N^ z>z#*y2-mN6FE7eZ1(rDer8g3akVe-l-J}*9_z)Is|Cny%$30SXUbi!ThwI@(m+jV7 zg!{&e@Hs!%r@IQjj!LsdJW~^k3=GVXNH61PGS$r>-#Ur8#&_7#3l;>#47a8u23(2U zkBo7J6P|~~W|s4lMMm}4unu5Q_hNFQH(qD$1dN`UiyibF%Xsx-q9L?*besh9(M0vs zPUm1_V#dNJ&m4A3({0`zgkt-ItyEN9*g>=MzMw2@NQt)Q7C3S}n^f~lujhG5j|M+Q z`rdTEA~|U>7u{w)tbeexUX7o7!-qd^mcYNZxwb}wqJ&$mcPnQCxc8z!_e0(7EZGgK z)!$DmNNL-}M^P7za~F=X9GgIs3D*)%w_0{F9m)Ud9Gy~pmt5?;a|g*#4!3@<7TUid zt?{yAd(aE49H#A2OBu5wi;eXA+H-fJeqE5!=I7I~tv{TuG_%eL8zyD43KEavd0iG( z8tN@kdS={I<=c-sz#Q_4M{;l&O)5t^TVakkQqXOY22(G8!G${&MAXRb_Ved66( z!25yxvyg&e%#xEhn_GqDZ0~ByOo~r}Ax!QR(Z@-z`_+7cO;MeIR}2d{Q!44B8IU@!GFi8oU}EY7L94?KG(dpXz|!4Ji) z?Ph07Bucp6H0E2w#$)qCL#h`83pjHlaWa&%YnU61X#LlSWpMMNT@D4QRjhlf{ShAd zPn7OSJJY#+@TT{_xH`WES&`#8mdVs6<93!mCPWF6NKkixq9bHt#Cbd!XAD|pImF(7 z-9MvGPTewP%x-7r0K*kFySU&>gzfiYt*OQe5|;{Jgnq(N{3H^B^Rkt; z&DI162`U`8n2(n!*Hu)XKCSZiy;fTP>_GdtV4N1AcYSuU?vlApJDL^rtjriOeUtli zL!GprUtNhJ#J(S1RH47=vP>*IEZkd2xU2=;Dn~`mUBG>cfht-UrKY7}i)SQj7GOSa zddu;@8dUimZZJT?i+*i?EZ|tm1_`Y~4wlL^D92EL_oBS_zrNc5LHip#glOaEhyNb- z?Q&>4Xh)OS)?EC07Mn%{i#Y3nyC@Xes?&jeurYc{fE8gtp9>r7X0WcY)FM3%CFcrt zp1i1bFboG$A&ir?gz;jM998tFIWxiGbs9Bxk5JVihuu*l&R`Exlj&=m_hiUYLk2^@slpMXBgCQyUG$6M=h}^)2~h-uI-e5h59W)`BUF?Z;4WkyzL8w zpLk+AJwwpV!9mc?VZ70a%L_4xbz|_yUH$81hiTXifunKEkVWw-b|$$KyeK8jkIJX# zX#OSgUI2BIkolbwg~v`NbR$MFPBN6>9I1Vy1+IoO_)E0mHUiEHGU5BY%R*98yGN7* zFqfsY84H|CK(kgyX!KQmv)tVzpR^(#Z&&$K2oLXzd+(o7<>x?$JSd$#no-i!hxU>Tmv-j4#C|?f(3#F zcZUFh;Dlg}LvT-UcXw%Mq;Yq5cZY9t&Uw$h-}~Hu^yoc$4EEk@tvRb|R++%N#oPTo z)CM8iHva2GX)7Wd79M-syx<}mrHh*rCVAgUKB`l0Y2s=Ptn|SUX$!FzdoJ%V?rVuwm7a|!Z4m?S--xIiqFvu`n3#24}id=95k&=WopqQOy$&zH** zyw$=6Q7V43p6?o1_>F;SzZ{Iq*jJ!h*ynk*p#B^xoMLnJ#}|#k{jlDQ@3`%yqr4~9 zfsO6LSgZ^g*zO@i6qTYjR^Nr6G?kkRE)3JJZs~rLr~g@6UI9Q8@wLqdHbPM)r+KW@ z?|>Age5;uT`qPlFv*mP8v%K9Vt=G={N5&FPWZGWWD}nvbGYF@ERegMX(>nDAkbJa< z>Tm=^o}D%yd9orl4Y>|F=5RJkjjpH9(uuIMoE+li_ejg>4`L*I%VjtFdJyoR{`)<3IE9W0#g>b0AOHV-!~gv^%m&^KYV-GS9)xV${CwMCAKTmYLYjtZ0DHqYk3k*O<{gWQ;E7aVm<5=+BS?`-a<^Fjgp7!Yasz zsY4CfC@DB*g$Upm5_sO5j)Xz`m@uJl8vM#Jw!+Z@QFv=MWF{dBvldu}j-L?xVy1L{ z=l}{a#{MKOt5GUp@+9McA>e+~*5b2hCs2Gkt<7j=UggS~i13@a+x7wG>_}RY}j5=&gro6mcmGrNIT|7PL08*C@(*! zRlv$ZTK%FHyaz9$#x3tAc`i>%Ng2uVA(f5 zho|#Z9s-i%RB<7MkZ9C5JT;4n>+!>9T1&TTHzN^VU!Em^4jrhpS+Xu#H*DAs z{{WIiaH>dwRki_aL4#{Q-=lu-y#rz%8t1=tzTL>n3};|yo)aX%4;>RsnriV5Ia?!@65G}SLODO|F6nU z0O4GBQGB6(Y|A$PUptkkj3|D)7a3OI1ysc3jy#PrD~^Rbk%VWnpQ)2P=dHgDBqFYw ze6`T=y<`iaMkv9PK4E(G0h@4m^6Sm*?<>Quf4Pla9aZ}!Vl$})3KM~3Q8su%*mE4w1859$MRpB4IlsQySDZpH z&!-d_re@a`PR|Mq!W`hp{uuN3Y)pBRRCLA(fd`n zV3FHfD}7WIL>d(RHdaOWAX;xO=^(ZuI9ezyw)t|luxS;ybo)lC;nsSiFLndi;XXtp zdf3@SwO%B9B6fYkDY|wRN_=9-^%xISJPqMMxU_%*d1wihj||N^{pzP|YPiM{&KiZf zH(Bqy(p^@ABu&WB1!awhMDjxZ+*|jn5y=ROwL}GywF1RGcuLsf71;0DFV8xDS1oDR zM9%~_m&@Y7*;_a^+v{d>)2>!EjsuORtQlijSj@=%{UN49z+*z9H{Ij6+_x7f8!jun zsvqqnFzeF9w74pYk_Z`R#9UUlYsNw?^(q zf(kcej&fSYiDfwCqqLVb0Fye{r?(ZPj?TS_6qU6KqD{QP_Mtj~#kc~|Q89?G@v`#J z9I+nAapnOop&uA#juIC`r7(#|euwaVdg3jb#YdU-5FPj(gwZNyZ{u3|c#_i!I`orb z^9Y*>8DX1#yq#BAzZlZ}svk~1s%zf-A{o`7%WPOjr_hO=`05HlA%a9Hr1W8xl6gY zM&?Z6KG=yE-%b9>*r4rPjtWw8mXV560r3u``l9+SMwJZog!{hcMCt&dF77SRC2;|` zzIOGCbQy)b-^YS-yK3~~Ph@kOx7r{(8>=|ciJZiYwA}s~$Zb%iy<_SttQeOd3nSB; z^*H@xUm@kyRbK32-QUU`?t+Y+P>c|;mXL}+9xPngF#;!^Q;r*1-@El1 zbdIK&a_G%tjB};eYetlw$lCq0O_U{Td9iWzSL1BYh%eK&s`7C2M{WWmx$_$Su=FbW zma7^EKk-uGs;27wt0dolw9r3M&#?*AdCqfPmj0)>(%-|}_A~y%b~3^o@gr!>;G6e_ zBm|2g>F+QB1&AmFxm8F-c}241h8#kOBDNrlq6+M;e=GE_geaYh<{#QTP1+R%Vw>>~ zH7C38o2=AJdlSDSZw;_Y2%4i2y(B;F@`^nr8_d>inms3LC&mK-vTBDeT=lF3Ti}pp zW_6$GchLGJd-{=A_EAkRaQOrX=VJx@#eT$T)>vz@vSv`mYE@gRx*oS9zpl&&;=Iy? zFv%`ao=*YI77y1lSE?~IzV%30BamE~OLheWFn??*NcI3s^2~zyF%d!Ehs6@ZdlV_! zxvVB}`iga%`R8O8fNSlZOD}gydyD4|~lj83lccn1ii8A$X;7uYYW__or4qY_1G zKy&#a7xo+#jwW$J-+ z=dxBvp$zjoJl#m1sI-%Yvci3B;1=3pT1!%kv5O;;AM;T)dC|AqoYink=EyCbvHY6b zAS;*D^y5i{_q+faFA~923a7q|7ashjs+tW9!8@Uin^tM)WK16X*&zpwCr`vYS5 zlZQb1x2L^yLkNa?-q0R(O3xoalO`%?L=Y5&y~~OI$kycLx~e`z{J&da;5S4On8tBY zOaFsX0UYY~RNXkV_HX(J(gKZm`L3;(`V`{tyCn=jNR(9u9LO7kX053!Q7Z|(e_QOvuou&ny`Y4_6DNg7?=(Ci38e1i(5<;3RrFmG>=r8EYaM z3yg{kZ5O}t!+8LK5=GCLf&lXXd5wfe@Hd4F(N&DG523U^q=u8gxEb%+vx@^g9R%eD z$LrXaH0C)F*}tgN^T0}_Nb&(p*m~^Gnni~9l;?Zf6JdTl<#jN{F(ier6LX1yLNRTG zq>u0?MvUy5Q{;?O$cM~H7l@tP0E_|^o@Ny7zf5N`QH(w-NxJf{>Ck}qEtQ+}r<-&f z6iT(neKV0cY^~o%r~Ry~v?<@jT>mB8@*iw?R-u&!+D?qg4`jWip^&w;}gnwbsXr(%j`Q7OQeCW`b*k z8yl?NuCoD4&%coN)_PR8`>hvwj?fv5lQUxWQ|dXZ5w6GzqFFb4Xps9vp>C|99zrWP z%RuI??52LmljXs)i&6J;+6~N1!5n?Z@}&{37#rN&`0I2a1+FCps6d(>vM9^4Nve)W zj$CByHuraK-kuhq+*Nrkr~BuuN7wtP`0tb7p2G9W-JIiql7iGnZv?La`VbbH*UbXq5|DX%X_lFB`w%t zp}X$txa-|2jXgw58Az*yAH7~?zF#W%e=My{4!C9`kh&er>v>O8v5nrPsw%(e=(}E~EJ}q`16dvlq&y5r7Aa zxGqmYbX>|kNTe8*N%x>Y^sr#HeD^n8W6Cq-L*ati*6WcmgavcDPmSIH`)x9pfQwyD zZh_T7-UDcEAl|dK{gf%udHDA9uI5&Zp)qxeHT0d_@Oq~6S@VGSPlc}UO&wSY6Q>Ui zUJ;(NgyI?yAuK2>g}g;b?~_RbUdJb1OMhiu2b2u#yn(@xZbuL2DH} zP&)8VDbodLyb)uU>wSq`*YWdDOO(=Y-t1l)3+qmTyS{$zUup!v9--I&{Wg%$xA)U< zg1FL0o$0^K=h(lLuA>8I)IZRYee*}JMDE-GEKLSngnK@Tt_Od^X$&Owa_K(tIT{yJ znscBPUWUn)LsX!^5t;h%kpxx|BBVj$2xI31P3IKDOhe_n$&EstDbY+}!)TXW0&JCM z6-vlfeT6zjn5inH2bqcl=~ZIlR@Om@-Ske_v?pvWESeQm2hq-F13a|o-4rHNlU*!3 z@f zG~w+Fa={Ky(~<0_lMGJZu`^X?Ej+HYbjXO7%Z|j zNLzK^zSbrx2z|41_+qKuO;PpX!NQ#s`SKs9QVkWrLe)~q&;19@_hAe}CWi zSf?cIb%5P|&^H|idNoE*<4G+@APUV1++P+OrDPK#OiG*4Uw^Qi!PrZeHMQ9~r^=U7 ztZtu!nJ0w(!u29%^iZ#X+HEV_dp-9+?_pQHKf?QdEoB6coY8gs$0=bW?AC4CAkajM zUd9#DfEA^T^rTVyz>uDw&H+}|oX&|ri7`lS@fWB(_*nOGp|7*%7O@dV4Eyzj5*3s= zv=S&E2aHCP00ZSqJu{&zZ#aL=egn!-lP9I85gF1{uYBQZnVT8fGHF1{0|BHwyFunV zt*=>|*v#flF7>*J9#^qfBuH9a~OH#y}4ui$>lm7KyC!D1zUYSX>^klJ@Lw!9@_TDLy`Sj&++BxU()Ue zu{x`Idp|kox-9JJ#@l*ccakcUGiNy5zHvwJ=)yBf;xd-g5qHSZQXf7uFaFnhtsC|1ON45sxbhycX0Gk~}ZU0?vH~ z8(!{BDRGa7;H|eOMVu|)i$gBp)3QU0iifm*2)?(C`bXE%CXKcHKmrKD!VtFh|AUT~ zQKO9af4hoQSNySuz2t!+_oa5OFkI77Gho%{sA*+{L&T38HX`bCzfYJqxOB`DM+BMkswWSy558sHQtfA)bB=+yx!NlD|++P_VE+p68xkv$B&}%+wqP z1fE6+-pk`S7_U2^OdK)jNGViE!E(_ua!J+2H|h$v{H*7cXXn{65+0y!X?CW3uOJ`NU<= zX(f$fqhibC(7);#zysI$G?#Q+27UuYUC-0Wb}Sw2k{@zf<@3j;I`KQC9vp;Q)+Mfc ztpk*a&1=5AHwu~_CdJMMzJPd~Ya=FyJ4%|bo61l}vLP$qwgxlrw~P~mh)IwUDJcdV zXUPVsFd3PQhsTH%es&M;2~&n4yi*^IQ7n5vDtFn^B`Z)@y>lu&V4!^}sap-U#)iUx zF!6c0oHrYM(BX)4T+i#@@l8}V7I-G6JJjj3-S487jdAQthC2~v z{roSg5>*NIlH4czKg9Lp1Dl65DvFrE>0b&B zYhw0t=xB{=slBXQ=F3-0aK2Fp61^FjDUbmL&5$7Vm5`fi0a+->AWtetqd=@rDqhSA zEop=%C}3<-1H6lbOchfckvZlgEJDM})r2o4WEO<3d~ z>12cvepV@N#0H)QM~g;!xF0qDh$BA$#-5pT)4kkY-Ne-5ZuWZjMTdhw*T_;H0+ltB z)nw6!C^Cw{$M5&#td$`Y*I92UHq>0erR#iv2kG>FHMTdVlP+K*2u!F~6*{a_lGtZS zMxt0^?pCm(*|=A|eQEl_lv_ix8;g7O*sL6H5Gvy4XdvRd!`JE24FuVi)gV)k#p)39*kQdcx4x4Vu^~i>)of zot70P()#6 z2dZgCtJ_cZ)3F1Ok6mUrSE$B^1(}Y~yU(c7p`p|ZSO@25MTh5hXl22<=4d5JWOOfd#?p+ekX!7tr zrdaPN84nGuB{?nqTbr33)p=vmAKwKlrUIl((r-+cB}qi_#DGL6ydg;+7*UpUw+=y7MIPXU7`;F?2h{yD) zm`+2l7p)$tX{9RvTUnydf_40(tA2XVw({RlI#wis97T~@UiAQGn8?G%i5-jl7bjX> zgz$hr0W)?ohMRLE@OKVXcCEra3A_4OuqrZr%q+T*_g%!sng^lzqDq?lN?a&^6^PA z@M<<>bA=#LEF#osCg~MehYX~gXB>CT@G^c4&;9eDi#uSkyHQj&t=gvE*Lmc5ekcQ(Q>b!%&WoP$??!;Pgc%u z@oF|s=$;ygg@p>&#H!njwDe)IDr6l1z@BEydIT=%@q{-hwp_Ir|tQI@UvvM|G*yyEDhhKHsY8z%?B zQUryQ`TN7qzN8((Y&oLbQ)?Gtcl;op=;GPU+YdJM<31Mmq@jHd3eE39S5|+gSMCx# z$b{}<>k_|g3TmZ@k~poUSCcj@g1rpx#|QK6=PN5z<4dlrPQZm09+PW=E$PMz^Wo)D z6-*pZEF{C29P03NJ|c!-Q7oo4UjG4EVp=11feq1nkW!z#%-#daFw*KYh#=&s{j=@+ z@S}3xoItmoInpj-8P|J9mOs^@8T>2_BeC{XZ)lwMkNa}a-U;(PZSfL;w*)`OIk>X) zo#3?wb9Bq4N8xzayzLzE`RYaI#qP7sC-m613J5S|f3B&^tZJmh@7|=H;UIkoGQ%l< z7iMI$Fg$3&7KghBVkE6!-#i7-+$Ko6-W8Kl$3JzW`nFiZ0uGaSyMqJl%WD6oNvs1A z&WU+-8)E=`w0Iw9^_}iqZXHs)ZWX;=Cq!lShP>@7kmAc6wfB zngYa}b{w2$U^iFI0Hr|az&a~tU{6^5P!s>EPh}dTQ95sjoC!SEDS3>FsQyPm3Vu{p zCr1kV6o^Y!<-k|&p3I*leq{(XsOvIHQ*oJZ`a^)ga-43jErF#E?d#8yi)WUULe+HI z2C+QGjJ$_??lf^mNmb<6z@*G7egC`G6HGB08?|>eHENMdUpwOw#rBAnuDcy>HsW)Y z&dZ@7Mv?m!^nSn}_scWf6c?>1%=h4rYpBbfTQnQ^LfL~_`^(b^C@3mFgyXs`!)vU@ z!{(rH?sR_pv&x^$1fo3WyTvVZ|?C{OTD?0 z8o@bY-|5)tL|*1=DZ#sOe<2U@Cy@L8XJ8H`B~3r63G4(U*zBg5+#<5Nh;7=Y7G**p_yL=%k= zPvaVMiIl5|{H3jxZD4(BWG4O(A&>EKY z7uFTByfb0E4yr2HZ=C&`NBREteR*jYXu-w}KyHw0Qi^duoIND&@Vx^afO))AQ{kZCOLCYF z{?1GCVS{Gd?^#7*flw<_EuBruP~xCaL9K8ey{3Ed$U?#bWtXa)lzdBN568LEk;Ny;EhLddHmUFh2BHewYPz-Lsyv)qW~fEk(UL9(B5vp#^Jj5kz^jcSWlT?o5?Y!VL<O>Nw-oVtQ2&I-c>HXBSn^`)e=DJ^V zx4xvL^i+_t+YvXLcN@;T3dZ4Fa#;)8$ck3@XuwlRAFc4tt4y8`k)1+*XFNzY{u1=9 z4UVsnQCxq+M34drkMRa9zgo4NIa-xtd#mA;PqL4#5t2Ncgi3VX#$uwGM@sZ+AA8*S zludV=5duZ%>NR^lM@b(Z3g6Gl2#x=3*ub_pVDBjpiA-|Ywd!YYnTC=ghIere30D3f z{-)63Z945wo#C}lm*=fT^zjU6!LZ@F%|Wy)*n_Hx+t0c~LM%~H z)zS-9eY`1|@RB-vnwFd>U4Qip+NgT(aGb-j-j9!OxV#rstYZei_A6=+7A{Ovd!Hw# zG^e+5(T+uum8`mvCWGEx>BVsx?JwisWM0lwwdAO3mCHCB3DfS|NkN+(V&ff-+POnvt89-67RyO~yf+k%N}rbegwXs_7M8u2Z{ zZy@D{^OhEtlMtE51XpT1Gu@Zs9fGZNG-3aT{SVi~M(yJMMiOmCMZ|}~Y{H|M<+>u* zRdfRmmcAT~raJ}6s3gwHt_5q5fJS1Y&!po%!j3|WK-)>u{GW~=W!J)8LUQ`zRj-*% zGt=-nkHa@Vgqw-oj>$K6KEB= zXIc{FqhN=CWCX&jKStd5Q4FeT3X#(uyj6kIf5~^|9fz26Qye~Sc1;%7E`FTEK{6!u zAzc-!(fqoy_^eE{pMDVI4ILEhw5CS_kj-SW z2TWn?5p1FXu+RXBbRC*D+sQ6YYJlxtkpA%OPN?zM6UB7pqhN;4eMelMGMBR^_~=Nl z53@{hA!f|FC2=_09`sl6hSX)*NAA~4lX2bq&qC{}UN)SKhatM>8K-fzP1!%vp510R zNRi5mn`VcC0h_zZgcnBKNVaag@1UeUB9^_^^Jl;qb(=J{sO$tY_7QmQZpV_#!MP)^ zPP(hSM+XU>ky#beU+ZJ(s4d|C?KP&lHYN4y$7p*&nT2GMQR!+*Qp|7u@hhyqN~c%e z!G0@5+$dpno3R(U}$Ga}qf*Ep+CZ_Wjm8>$$x;$LP*U{R3im%cWFnNm0fYBP>rx*$HZO$&qnzL7uHVvzyps?GKZc1U z`lw4nWWqXW;PtyHG8V2|aL+CCjN*uKR&Zjr8^1imr8P`Gm#%O_6fTc#XRU5AwlfXZ z|1*R6f524zJDN6kGk^#<60W-XHwHS#=;L9;QGJFD+IF^6Js*vnQZ1Y;VnO$JvAJki zIh{T>ceOTtUkikf2FYrvfIJ-l!B@-#xvrhidka!)YZHMWbEP&90)#3dpDA z1cmr1aOcTyGb%h(rxWt{y!3eSCO=ae0^LmcW7bO^8nO}rK-Z|^68Pc>AVohIFn92S z%s=7uaKDBtFY@~Vs|&sk{|1m<*<_J6AV2IwK)|Qn(>V~1*HGfqP(T z+~b!%fJbeE*jOp^Fls zOJvsX1r`#*eYRpxpi=yMgoLD>SV6-c?u~MP&n`|aCb*SzAZKf0kI@<%5_NA)+0-pl zV^_Zs2g~$;04J}G6E_wFr1P{Q;N!_m$li%QOAANqr1bP$G-uDm*{w^+hSJ5`m^L>9 zAiqO$;-6S`uY9-Iz_)YjT7XvM)bFLCDZEj@?Fy721FerNkCM~^VN{bKJsA7tXuj|F z*_cXGEJ8TU52B@$2&dC(vHX=Tbs?=Q?NTb% zC~L{t&Jx6lH~ncea8S})`1IV`FBOM1M@ZRRSk98(wVZyBztVKRGJBQ%Fa&AsD~X<5 zTBshTra;x7B9c(|+`2S$-xNE>5t$i9@)yJ;p+~$?t%H6I^wlznqU!x-g`={sz8lqE zlJ4|j+heA~4<(Q|3I;mZW}vgw5F%{T9lop&+`nZLC<0|putBxgKiJb{$b=oZz1iXo zXIUpm!|&*E&D`s|*MR1Q@LET_cB@WOvcHqNmMNROIVrE*`*CY2iR-uUF~xV52V z7N_)2RzUKtNkqJHPPlm1!j2|TzPk!{7Ig|{8Zb>>TcU4P@^(&_I%P3XD^ zpR+|Tw`8ha{A%0!5bgj~^!oj)ZO)GNu;vtdmBRDF7abaM;L=>l;+HEy&H~2Y20Li( z<^24Lye7pTA~?*SIxMpl?VS6yzhqR)ejX4;rHd;egrZo9oJK{&kUky<(sxHsqy43| zKym$`zPxpaxn)3V4{~*38x)?y*YmJ{^`hMOTa?=eiSau?hET#b#)v+Q3OS3ug5d-w z94{v%*1ZJ>V;-`Pm6+}uJ8Q>^oItSMDLNFrPNSOzvnbCc^4x$`mrZtdGvxi~N2oF9 zmNUEfwz&u_MtW}90|?H@<;7f~{3X>#>NNhDUYh3USug6a7RJ-3U4rT7 z1+dSI>9^y#%HZJAsvAIq&WQ4R=JD`Ht5(ZJv7O1&Fg^mW*W+3G5o$7~AmwB>`pMgs zoO_9RAT}}Hl+PmrE$Dp^6=?`>zBXRM=1VPv*?w+rLQ(|D85HyK?5bymT@%VRr5#;K z$%*u=m%2G=EKaw%LH$wF+o@qFS20xg2ZWG^k_pI7hBn{%qI4S2>R$gcf2Rcw;_56n zjm~+)&8240z27<>WHamYwvV&By!kJg@HAN&P51?11Ru5L&p*N44M$;m+V)B~>oY6o z?kM!SomXPJ$ZJ;VNtwq}vbpL35NFXa+K3JW8TE%Zo(C%ejBB;gJbuk!p zE9UmN9ZOfx`-)dKS`PQAnw2pW-MWG|0nmlFO;51X=+(dbmbMq6-Y^B64n!ZwY$NMw z&GIWv)YvU?cLLbnBf6`Cx@a9G(Zc;jc@Vt#?hJyRf z^LV&>$`aw)6T~kf6N)rPJol1jaYD{5Go1nnW_q2fYy>6c2G~H=s9N;=oz|ja?**_j zKKc#7+BEMnl6^Q=clR4F1ms^?(^sN8UE;S(Q+GFK#U+Kj`yyj=c>SXw&b?Ls$nGWl z2swR@6!;VCh?tK4zHzHVS$0wIo$M*ST~4MOT)#9;OKvLqnrHPNT;aR%diAP;>tv-r^Rfp(;O~P9#j(yKOEe>oX{MpewG3`HiOzT7eFGVa%0zSb=UZwo?0n+*@o@D@e zua|2TuwKH0-=0%hzb=pPWa^3rj3^adpXuX`w3AS?tmEgRR$hw~P)VtL500=hG&S0V z;yGiM*%l5DrFy~}N+wjc;fOq=%fb{Tf-8R5ZibS)X%2n#8~^g{Ui+7|#ePNqZz77% zvofwObh6rRfDuSGLHwiWeNR!xF(7M{RMK0S1B`QHGW7hS!DwinPhn{CB`agV6 z5jYX!2ZoYP+JDTV;!D<{d? z-O8b3JY4w@*8jPvS3|y$dx*8A=zKh!bNK))2fmk}gv>BP*?U$}-zL*rj~Zpqv$dEp3!Cyhw(b9+~&LxR}0t z3AN9{8<$bW%XH#x`;|Tuxc#Ox(t}@e@qK$W9n(6jYdxO;$_wXQ{iURS?-_nzmi=jl zD98g&j^;&Ln@V0yd~N?fo*@7CK>RS)c5|UEW%GYWH_B{Kk7u*I^X{%$-W zME7*f_cDjwyY;jq`?J|TVVp4cT>y^L8fb#sZ4OFVBp7>Ai8bDQWRl^?{`yC48mvO= zkrnI963USQ=CpPwE*~QF<9XsJPx{Pxbt$^IkK9qJ4;xIiKGE1NOYjh5)mfwmPNJ;p z-j2rSY+nFVVIO@8M==S>lO$4V0ig(vgP|`6m@M(&!k^W{;1GL=sTeH!yrxeJ)SDU1 z$hx>@GFybM<~h6KQI5-e*wEeAT{k(?HGv2Rp6tjVPwNV=%gXfB44B6@=#t7AQUhwD zG?khT`u3X_p$Lp`@zVZTf87T-flpw62twpG#TpMtTvr8E`*>FSA9SJ#FiC}92Su>y z6O(ClDe^{k|9bl5`>+SZPU7?#2J-f`y$c7tZs>DUo!{kfMk?qdX}Bnp)+~3+m8}Nm z;c%b7Y*A)4g0rW#M=|UjvMzWQt5$kh8@(2}CA4LtJT;2A)jbJTKkt6mLK%|{`ZXDf z?GX^$O}aDvd4buiFDgQ6mDidL4TA(*RwAq4x&tLP9T1adE3T^um?RVCVZL4?+SnG5 z1hV(aqy<9>5`MC-V{=vq83hBbwx>!w&gIl-;9|l(vLst1-`6P85&o{~hMLv&10U6(CxSIo znEk*sMXIKo1+sI(!&NBfpsaT!>2fd{0N$*03zFdBxh{M`qqRRra`iW_CZV5oWD4ki9f|A$e#Pn)S z1#F<@A#Ki;;_6&WjLPl)=_51)mWef16oiJolM_z(5#IEou=KnLcaBMj)2G><(b#t0 z*V)#${LBGbJPtQq!3ej%vPlj$g+% zCccJ`&zJCRt(&D;Ehp$>!$GOc64xc<;wLlXpuC3HZcWj4?r-^Q9qu7&Qy8;}0zp~f zMAfC?B$1G-@)2{95G*5Ni4qoa@9?`u?FQb#h2m<^sr`AyIxz0vh6iLsF$oGR6Cw?L zJiQ~h$bO%swX~_BwRuH?<;f4Y;Z0S~`Bt(j z2}od%0;dy$XTdX-S^1_0leEUE2Y&(mGZ3iW$S5q&qlQuTJD%hE*Hb0?GVT1bjJv)} zn_YF|MY4-2dA1E~n!yxI))jKkFgN=b%`Xy6+pFkVgge{RG~CAHmp49gJ29(1GoV>} zFe%gIE4bn`Gv@n9`8gO9%?3g={pz{-U88(pHAC#uylAm&=wF+)ob^ z7zY@i%9 zK%LSo9#b3b6xy155@o44M1LVT{xz2QF;so^@`ks5d{2ehCs~;ZM-YW=uVlYZkFfAU>=YyN z2$;B5eeG%b%5Y*UYO}0l1>0{Ykx#^F;R@a#8_P8VM~MJVV~ZLe@m9u3SJcYWCZ-1; z%|{Q_Gh$hl$XoQI?h1oP4b8?tl)8lUsSZWZl&z-%V|?Y;DRvBl5!dnXoxjE&MNs0 zrqQ$$+X5PupF17XW&>+gg_J(!OIqW&^4S!0h#IrkMUNo{qD7b66*k4(Z+<6*PAiwsLui(}D+0nO!#|t#6 z76yxZ{N5fVbyap1JOi(J0<*9jk;7cxj}&YaY`o%QRPTeCwI;c6Y3fBXaS5lo(5y;v z?uF&(SAm*`1M*^v6sr`Oe=Lc-7v@5{NX+;)IcOf5UyWeSXOLU5OH8NRUnneCC26Hn z2Or(nzvPqw<8?aZcie|dkZwM>$5p@JHsp1mGLwt#`A);^q0IB3b;! zUR|0E&+1qSS&AG9i{FlDZeZ;s^{A)sDfSllcAwCAv)V44{e$PG7o+tr_5pO>UZ-<81=$& zMAMI5ZT*~Oagw{5E-Mum59}&hyRu(Fo++#XX|}87ECC*AK^Ks-JEIwD@X!<{E30km zvMBn48(7(7o2xv{g|PIZe1E*ZR`Ftwx8=sMT9Xwn!Y#i!`0PL>EvVtzFwz z)359z7$$NFQqFd2x_2QtX|i|M>pg*RAZ%I^Tbw;)z4yX~H2yf!#jiE)#Uk}sbhr4+ zj%$}$I^}Z$6Dkd=Ro$|`p39Ah5b?Ko%(L@s3O%j~Vc@)Q{>Y`Ic4WLzg zxa*4`{ujE!(QA$}Q)qww{LG}k+;Hep{^_yvod)a8c7gJWSe=}jh~eN+{OehEd=G3!SgZolyC8F~>JS_BUoP8F{7>>P=wD!3ByH@zB=(qv=A~nK|JNC>M+ko*f zs(Lq4ne!`x_{5j*2d9g;2}m*h3T}=zhxb@J_e|kbpgTIqUnlh_h}Q=NogEXbt{d(c z{m&hnF)ShmJN`^y-|{SSmTD0V)*1>GwY}Qt4w_wV@@hmB7yI0paYPAJBr5mvIqjV# z?>O`Qyby!5i>%yGsQ$9UIW+AlZbkWj^QL5MCov%ruxo5+=hT&==nAs6R{3>DuVK3hOW_#! zTF*sOB%+(DD2||iczVzcNux<`jHgK>v=TIJ%z3`2kpdM zYHKxtVsT45rwTn5xzXR=^6Et_-SMw(+?Kh#(@TqR7rL}8ouzCvj?iuKTuck%X4E#o z9!o705{AY9Z5Fw7g6^H{;L=QUv~;cE9QFt0x^h7mK}#&DRv~j9Kzk|B_?^;wFomx) zr=imIf%o&>`RT>ow{#_EI^E=OPkvI76G7Wr#mq9&jm)CMAKq`pU0M{XF6r8YLe3Zy#M{V~|%1UmEqk%n)wP zP_+8%GaHl4v5?p~tGyku#$52ZTH49-;sluEyr%Oj+i>}uIh`A*y&UX)#taGKr9Fro#m-5GuvbHqO5D+QCzb)#AF&uH`nZz^*On35MJP#)tL^ zeuKVDAv}G#@)`%93{49)Z-^C)vOFbGKJex&&FY7C2Y1rFaQM$ld)_lH+gn9Zr|lQA ztOkyRO@^87+>}FWQdDr0v4?A*Kx~K9PyDW76CM^AB^K*y1c*)KJjLO+F-lrh(mUEl z!DNe7g%#mB)q@pNE|#N*Rg+%_N&Xaa;mI?IE+=ZIS+{Moek$U@D9!Q1#_w#NelJ~hTj>cc+ zu*Gyxkxmw_(z?-{qTtDAY^!gWIWpvRcCClYOkQswEFy|R8=VLPAx2Fdsj5X29m==; ze8Yq1*mNFJ4F6p<+*YcA<4Z}O%Aa3!jO3%sGG(r^3R1303Q*zs2?0><@%3hVbC77- z5Vqo-GPLDQmtv8M0d{{r^M3dJ-hVJN zpV|An*4lfm&B3^2vGj_M6P0&VJzo#`;CDGwaEOh9@q#~??z{8!Os>XqcH^9}Ltckg zJU6d=FVFY(DkFWjiVDD|JF|n@2TdLrfNr<+N~Y85N@T3;G);XEJ*FFBalZ#gN zw1+GP9UPef14doweoWytSk*?1KSd(*96 zF|ps_BFrWbE!$@f*3?fU3F@m0Y|*Qd49d+0`fC?X5(bk7MI&Y`%R_X3+|v!_#dXzf zY)?Qg;PIiMQJ{@p`1iW0!o2t?B02CT{`@x-z_aRaM^&+A<43Ap{_TnB$Cdn<3pj26 zFRkS-Ewuwh9oWyB(*P3doH`!AF^i~I`Jq@3J-IgGoZYIU+i8qhl{SdUU_sZ?Ps!Y6 z^9_l*{q?s&52h>5sq|L*pR*QoULMDQ%b1S9n<-A>-hunTneG723x}%Y+JI`)_8l6#5R7N!mDFrTZVR!P|YxSIrScoa4Z9 zhc9UlZgdVrIsUCy3m4@M?vhK6Gr!o!^Pm_A9TPJGn zc4p88_0pC~PdBxe9C$tMH1>@bIBC4glH>67BM+YVVWaliE@+ycci)9hF9$Y0a&MdL zpQZ3mu=ZaCGmtL_@R$cnv!EFNQCG5$$zF+l{21jpb4Gt^J;PxUW&CCNMN?5?Z5Eu; zA+u))E4eG!6us>DxQS63-`%6LXP#-Yj19v7XV!ToMa4bjb>rS=#m(jO#2Cf{BK^oA zMbyKDl3I0d2Q6W=p>OV({^j*MfmteoUQ)rRkQiFt)8X0QduQHEIy#v^-3lNt3#+W> zTOuNy0QSlSA(MA#)zxWUvFtW2QN=eWdV5G6wK}FYbHnEEZ`!-wcZ`Fk0QEPAdMIHx zCcd{%P$W#Bhpzb;X?)JT9D$QC!7J1*(5TB(P0p(8T$h5iOmdnP#E6tHS2}>qju9EJ zf%FBge%DY*dMJz29DgDma3~yi;g`98_1iL&w`GVCxNBnP?}M%LW~@CuzNq9<)M>q~ zqQM@Vcp%HkziK3m^`q}Rm|LHf|a7EC707@JtdU9&WrqB zvfAEi6IGu0l|FULSb z(pPlXbzRyaMLI~f+9aKk3*)VmU`GC$NFa}aQpuxEYQLH7c-Tc2^P}%)5EeYZo(N2Appd|B31JW0r~iwTy23#TlmFPdgMS z)3bkYRRvU9Z*BeRA`^YG-|!U@y?)vgpgugMl`_Oy0n9?O;v|O>2a^}a)cTh@Z$*WF zQp+ZMjhEY`^d&m--3Q+7Y9Q8wRGmKxuM%Gex$`3Dzt!|1pVyz)G(#+^r{#cIN8dDI zbH9GZz(jq)hbhtr3s{ZlrzI*1+)-?PhcMj+PWz53~aex8VXF_qib!%csXZY#~!0 z)!eMd%UT{viF2xr>Br=EKnQ}&+x|N1EXN6A31=RyMxClVD$a>)ejY1 z_5%*=5(f+Pr&K@p^c{NpwL%+d$F2m{W!i2v3I))O>Sx}8#w8syll5D!RREmt@n-aE z0HSXZpGWCY#32rP+4yT|e^LA&B{bJh<_saIEygS-SiH?v_eS+oBX4gjpRA;q*7>lo zP$wI(QWumjy*~lq&ia>MB@dEec0|*?y(5pk%x!&Ty$UFjz9;eRjjbGS9Da@E5;cw0 zPL`>evD0aftaxyLztgE24Y{b!R^bBsiJ66~^kBH=Ue~lodIbIU(~!-SA!bUdWO82~ zRg8q3X8C(=kH__;uPvtXOj_9+9GQ0b%RdrRIaaLL*K^K4JcNAi<(K-R&Jx9V!LQ|T zV7VJcrQyDo81yqiY*uojok5!s%3jXH2yhmpfgwV>AkWU|z^Q88DC6q#z|9Z!d}*X? z&r?!c-$@-upo-u=;^L#8Al0cBO0q)pBme2>iH{R$6ZbH`bvF~P`7t}0A7f=}qGdiJ zg!fTtfWlP+^9B*?qft8vWu}l4iUl#_M|D)Qu8jkPqo1WcEX!Yd#m138n2(Sv_-Zo8 zz}~JiXj~(Kuyu$+NQlV~P3t^xT0RIHm=}gB(+_kbnGS1yh{Wt#JXawl?L~rLAHo-{ z&lydnP!n&O>7JyupP`W0$XQLN|J2*}wHn#>^Vz1RB72Ja1lxh$s8+L!Anoc7L~wcK3a$ zH}&?Ac@Yg9+g2KDw?kF zKf}Uz@WZVoD^*|#lw!0CJyq9d`N=XJL}z3=w8$dNu06#=fg*#|g(jw7JfyX&23=5?{k)5;eYCK~$!KLAFC_v!@?R3a!Y^`rRT zgMyypW)#8(n>GdvrObk2auo~(CdHnUFG7az}fHawogHqv8#{839fmo($%eQD3#oeWf3}lzSb~Y9ZlqE4;WSm^9{O^p;^i3o&!U7}XNib7s?}j!2o^ z8S->LeoT6IcDJzm{ZF0yteG&rAZ@3%?>wN;Vxk-af<6TEYd<^@|LFY+3*3)Nu! z5nm6uqg8)qmKYc1sC#BoNcw(ZA+(oy^{v{#l9PdTU@K)7x@zRRek>hE02Y%CToq%r zJm}5k_Y^ptvHj&y4t6JNuDT!XH4y8bW6jII;T+e1P){zxwOyT?!tK_TOX@V?RYnZE zEh30_+SUZa`fK>`66PI)f@Km7O<>(eC(gKodyg>TT!YHg%_rtD zJn`4fL*3u}A;cWrW@0u?SH?70zVBBq*&=}%g13L(P-lO2RrE~Dl5m!%#i_tp$eV4{ zK*~VUMC+mk-@MN>iOWAhKmS$ThRJYo?|K{`>ilBd@$>MneZqjN$P3uJZuIK%5Ngx@ z%fXg2;;iQQ)dOmkMy-tJ5!=IBIxKvgquhmxFS$*YxWdWbI?{`?tX*jP|2+DJTxM_Z zDC=k#8DloOVUK_Pl=63`*#1wPqy0&HKC|b<0TEJVgwR+}p`;pSP)kK;@Rkr)1?2lcv(oJZRf3be{zpV^sN22ca^TwwNw*Ry6Pe_!3 zPdPrpioAN@_|}*G*MC7cSu>(bnDbS@CBL#xk%?1PqqtW*FIhm*X^mAB=NDK{tZ6OP zf(_jj#~Zoy(>n zj|`Toc-h2zsnX(7vM>4EXm{Ddm1!>OG`M~&ybA)-ztud-_rAVgmxMihP~q^QQI-q) z8zH;xjhOlB&jjo&xP2>UN+efDs{-_2zk!(sUWe-KMLsv??efDnGhF&*(J=7Mc{)FwLaylm z%yk*T+ESmJYdw#({1et97Vuh?68&IrU4C0SSg^-U_K(nEJ6MF?fR>W6(;>rm4U5JYANZjFl>f12J zpEZU@*$+0C*gjsmy>p6o=MF8*apTtpB$X?*GkZidCY~Jp`a`}QCX;C0A#-fw zHT84Ob43~Tb?m8LHNJmnckTS5$Hy@*p5QJ9UW!(?i6ZlpiC|HeB;`gs1i={6wX53Q zLP0ue_5uSA`Mx^Q(0#Uak7H}FA>iGOYW-c$$%nwv0{Kk!8Jm2&_^t90N@-iYBHf%5 zYL4u;?Y7ZP5{bHw6;%qi$cl<~?KjDNEe zy`-gw6stmFQG9IG^0@e;&%An8MJ5+NRc7max&&)$ib!6Yn!Bk56n)Ey5hUPN_EX3u zM6W9Az2f7ysuy|uv}<35B2|~De}6eI$A=&;a&59 z#oNAeE#V)c7gbwp-$06DGtiMzA>JRFl9_IjqLqz*XL2Ml7(q_$#M6qS`d^fPhGWBJp$itu6L^+!h)`eSvP>O`IL z0Bih1LgV3gS81c5U(5EN;IAgzylaO+EAjv_&vE|O`VlW;!?&%ge#8p{E$#k+jMNdHtD2cm0lqp+^(=}u3h#VyOY{+;_-Q6U2_-YZ zC3Z6Mj(@2||290`8!MJO5^LUYijXaX+{OM&t83Rp;NNU%!f$vM5=9nut#3LKukP>; zM|*l58i|dIdy2u_Cu0W83ok7~0tB_8IB7PRV_F8%gS`tD{L9SNZth zCms#0Tmi)w!}Z5Ve|_q^O2omQpB#eirQCu#kF``qc=f1Fdg^R4VV^3 z((O9jxU_ib%S2*ZChiSaF$l0{o7BBu^$FW!h*8OlUHX)QBN;2iFfLuXu(J=9+Q4t*>TmK3wDr_tiFAA`lpiv)Ap!O%y>_gW34(cU25$%&lXz`6dI_^ilQ)LK>fV+U)Q)H;a;sBF*QAPWDJXQzN4;J|aoy1| zt2*(cY@j)deZejVH{l&4spTAzf8@RXLR{X2hFJ(2H6Zrd)PR|)hMPwfV7oOk81@ES z`}Xkpw_M`myKDjsE03H84k}mQ;j=z!9#8&Z&R7fNHHd>#(N7cb6qn&M3E?}87_qsf zT||pUko9YOhMa3pJ{&oHZS=&srUPMnZ|d(9_($s#|J)OfVKLHLaC_~i6bFyR)v`eu z3bW`RI*{K7qV}0zUzBdW3S3{9Fk%Ri>v&{w^z2Hbx9Q@w#nTqKs|yWw^6K&QB1)t3 zfNnt@#VSY2-PtzS(Lymp&1V)l-HvFQLVG^7EESi~69<_$Bw&)Mek9SWQK?NmUh1ov z{C{Qv6t#^A6BzI&h>`vzB~zU}a!|K?)mw6-%Y9WOk-mX1Fv;~}2ntN+I~2{o&|`EJKw101@7nGBSb3vNTT-6@}OTj-K2u;)o83P>VT|Ypi=! zK!+Do!)HXUROOH$BGrWe{vd%U7#F2kwEh`=W~uSm3OQn*C>Ug@ZHE-QW*-S3#d~i^ z8K&0|t;27O_-J^@fAZ7y+@Sn> z+PvWZx%pAw-$Q)4=6U$vDe5Quib#ByFXIY%6_Jl+yH)&~XDG}Z1=}8^R|ruO@~QzL zWT({ozh(CJ`QJnT4;3k>3NS~Qh}*jaNZtxt3h(m{K%)Io+E z>v+YU*Cduam{KNdJ?=Y2w<>U!0up@Bw^BX#70F9Wq;14E>2j>XHPavx(D7E6Ar2`S zQ{`7`4mc*Vbeo6 zW#G^h*0c23w{Mjk`|CU&g3^&QyX$R@`y!1yqD6jVcFAV8{>H#@k5@bBi+A?A7s8`O zeTNWPngBkXTZTjWYCKoj`yiV{H4s$L^ohSmv(up`x^~qYVU};}^sC%R2(1Uqu*52?^OQlTez{rf#BD94gKNg7CB`OSOqf?dVTM1t?Zrrr8mPdDU+dq0K7Ob>{ai)GZ| z{}CxVO_2VYi9rt3L_Qd*Yt%N1&H=A~)50xbsiuPCp`-)&9xsu3HGFcOwfK}m?6)ba zM8wO0alD^@tw{~`YCY-`un~0R;A^@1fwUQjHnE_m>oB+{So>t=hgP%ivYTy3_S;lB zL}T}(W@o){h(QYzRV7w5kht<0VlTM!DSUlAd-n9VhOaAUNC9BG2Wt17(~z|`-Kk;R z+5@lcL%Szxzkf_~k>Ud1xD^^Rkdo%+u_Flrn>Rb;LZl?Eza|B=zr1KQBYeVAJ?(Gk z+m~1Z**Rjtd?x6=SHvs+{l-gm?tahgFS;w=+KQ+xU5+)Mju!mnbcqRXiUhH zILYrOSm-2r*Rk3r7Vi_>eJn|%qQng zPBq%>0AH<)C`<3MGVNFphvwEB6mZ5lwJw3uu!P{TDJ}STDfLfsUDc~th~G}3^g%!W z#bc5n(n2Gd_q&K|nskw2o3RTwtZR0C0oa`LiY< zljUY)M-(!Dr*OUcZPso!6jSUNq}J;a=_<(Z^~p&=u!k`pZ0Hq>8J;20kshylwy}j; zcYqE2J@aPHRlf&4`Y4z(U4o*btdtW+MTHW*IEFXF0Qa-DyHRq+V{`svx=Hw-=ra61 zHrSctb~;Nf@kfFsf}zR46fom$jF0qhm}e7u=RHDt|5AC)v_mlQUeIN+X_HUuKo+VC zgsCdJ(lXzk5&I~$fy1%*h3-={^h1YDtjP@FCh%bQzeuy{@g&oRKlvvd!4;{w8;8fc zkZK6YK264M?k-VQUybW@6RH>PW7k$@3`gdB)EjC_iSuP_2Io?s-7XWZ4k1)iMXtoo z2=r?eIb35{%S~@2_#LBzIA%0v)^~f_rhY2VK|kmCS-NcV?CZTxtyYtda2NjRkRj6S zE8$qKJ2X9q{4yPek;IVw3>o8^wI}IP;VuKh=sMqiPgY60)7e7+gE~W7czUe;uc={(nv!zGVFPt6)V*^`#T~1j3;z zO1|gnQ(>7C^rt_DK<;WFc?WM{J4A*y^UJB8sqv!xI~MwQ==)L{J)+;^nlQ0FpVZa$ zZnW_`&*LudbmVuhy@hdPlm7TYm2b3{CW98p#B=d`$#&b##A{!&Ag2d%Yq}0brJaE8H{rQ&EMC?~XdecpX@f%#7uT44 z@3XxKRx1*3Gj-Js zKadYSw1%Ur>GaWUH}NFcg;YV2A}ihIwI#=N4b+p>+S)plKjSH0EjysGL~a}+njSEw zdm<$5&9-bPY8kP;u)glyk&R;N9_S@^s@1&tn+wMRvXX?DDdh&<$by2Js_@u&q(xr4 zUMOE|Ju(K>Sg;1(9K)g2i*%wq+SRAUYC%)SNJ}c|h_H=Gi>MR2;*(%66Q{`vyg}v) zb8PbIIyV*qXWDJn2d7w@w_s}h&j)+JwJ#;_LewX)VX9JK%H@~X6p5mVInS==+bp{z z#H;r&c0O%xxQ*1Em|ris`0fA%gjkHoi>h02VRa{27*455W23&Rk}cTu*ay{Q_*93= zgTk{%FZb9Y3nu4Y4#=EjF=8XEMxf)+zR}8nEpuVF_VQ-?_Ui+4IGQ;YYke*6x2$}+ zrRpYH0=PrYa^>#S5wLdPE$U$ZlT`a|A$t3z#Ym!RMV;o=DlPnK*bdBvR#f$cbegPD z)d}%ElGRG*Hb=9ev8`)&z$Eg7E)D zBpX@ugJ>bBpQ6p1)s$k^!h_X$x}G9o4ouvExk%;t%7q1w2Av#*y*Q)z$qyj;Wf)z- z?_A_=Z?wE+3b?J9X}dXr@Eq5;m=qo3PrOPt{IH?Z{W1-7i-;3Nvoo=MXg zOCf`#_dyVic_v?sRU||duEJpVi8`+D%G8#*f!&=(DV@7+5< zK2fkQ=IuE8U=Uk(OmA)pyE@><&E=blcCRZD*dm@J*Q@lX8X@(1@xWqCOK`*AXhpzZLBWhUwM(jGx%;vfG1E3p6Hy}UvN@5ZY}U-!))rhjZc{3j7u zNaSJ@*O4p4rH+yUih{z#E;!hb^_?LVpFGS<1_&CfI|zOi8?XD2evfgIiLMu6p^y2u z^$Nr$U6A(ZP;#;=G^zivW((?-@xteScWDhQX@0uLy)BV`z5&`!Stzo7+MQnqc;lZ`I?z z=fssTqCDwb(T6dtyI9VD(`1-oPy=f!R#s#39TrN0Do(;sZVhOE>20$$OCKN_)%6KB zzz4toVrquhyxVA<=dnPf^Ru}8L09HqOrF$_yQcY*;`-2N%&av}NWCEz@F5yp4L!wHjr!>*$#;P`L!uS#9t_R2|7?2LHC_6`jTJwiOJ9}nBUxmXZ65oI%- z=6+)^1w+Y(a)@l%oEeC%vo@5Y9ZiG=4xrcp(g)pjg@}IZKLAEY-nD1@#A;@vCA&Hj z9py847JpA?Dtje|3&cdM`>Z{ua24s`Q9ZdzaB+^E7BFUlMA>5dehAewv{4Fc8?Fko zP0Tj?VALUlD}K}tT%75id3E6ntlX3~ytvPp`W(NQh}XfSA_G=W6x4{i0~bCYhd*}_ zht(O+Re!J66q?@(Dy_Bjd;b3Wt@T%!{u1hh+LBxLHd(BX+MtN90^Aa|=0R1Tt8iH%1i!}P2=)w*qeKeDVW z=vVt(mp$E%-dpI;aFmO5T={`-df&6Mk)cmt9z9-BHGVwV>t}O!ZJ1lDR{7^)&Ei%?p56kl_KyYXz{%X}^Sr0}Aucg`VCnqxt_DdHMRyZ#Dt&!pMe zEb>48-wa_ew5s+1t#ra(Rz1JXb>>QNC{4EG1jC zvjnetXknxQY{=2l9kCXSs`JyB<8RliQ(QLk=wlf`2jI^KE;DTL_Wl({MWa?7)~1K> zHdW}$6pIx+FnNOX_?tY4=sRT+b|%CQECqMKWia^s<>wNt51Z+V8|M2#^oNqQO3@5` zbVZv@IbU3>6-a0KZxef8Q}G)b2iT+$X2C@USZVOS+8i!i7xt_!U@wU94gDPchaM@sSdy?kDqN@q6j)&LRmPGD@+fHeZSARblA=W7?ik zfuA)tJn#PNKZM_Zf!YNTUACRB308*+@&woe10Be{4C7@RFwryp#?7?98+s^*TMRW@ zdN^Eu6y5nNVsfq1PZrv2&d964_iuv99DqztY$Chwdz|sA%Cw~GBT$}(Yux?qcml#x z7FGfV2aip`qT=KGG@i4om>`;9S3J0VP%`%n^8vxJsNUbH%=vjoOG{FaEq-+jfe!vS zVKnIqDxlI4eVS3dbFD1ruGmYL)H{?%@WKB)@)Rv3ML0155#iU9Ju7!8eQJ(3*gJiZ zM~ZtMH*G-sHsuKakE`H!^|+kuzipS{;n?;c^2WjIGqJ5$e31B(2n~HQ=7CMnz&p6t z&|_TxydK0}^{>qSTphF~3e~W``PB22c4+!dF*-|1jd>WVOy<GTUn|d^%z1&G_ZM7>k(0d}V9o`gj-G?ww57#b4l0r- zu&Zg=S*H-2Fl_E_6Y7BmaN$Y>e>UtdFbK=*g{>VJ0>eGxT6Pfu5b!yt0q~E7Zb3;IxDh%S9a(sx7aeGFTT4&W-DwG zd8SnW0Ri3W55)r!uIv%_C~FBuX;&tHqo2>Sl}ejd!o5itv|4^_RK>iC9O z+e=(Wk)?(XeUqN;`h76_zvU+Nzmd;h63~##uvp!EM%PaZp*;y)*S2lSjh!6m>7N*X z!R-yGf|qN0uxD?lo3f+dKjDi#)tWA9W&LRi!)*z{eRm%l%LKN|9L1~#`FmtfCzR!7 zWtnt_b>FT-u42j()js`9Jg<-qOrG7}U~%|~1|yG@J*Ouor+!%4Y_QQRh@2mIkV96P zww81(c9NtOPS)$^962?qrrCMd(3~=U6bmngpZK3;)tf^CFB&V}0{u(N+Z{W0ij^d9 zt|mXu%QxG01o`i+(Uq4RO{hw`zck2VDnZk?p8WK0s{`*gxTf-@YPI{i5ZYhw+q5~X zek_R=ZGJd5^(n>!tR@$uB75(ro%>wPZpzi-ykm9w8Yyl?$?-N4=hxIsYv6FJ%dVx~ zpt`kL!i?Wd*Qu!+(zs;p2J-AY%mPCK_?qZ$rZ_M8O9Abwb+;4rq?Rr zrE4dB?=uvQhRAQGtX%B1hHC=4>ZeH6KOo4HgWtMMdxF&$UFz^lBgIVL48YyHWGD;O zYo0z#6ue%8iJ=G>#f+sAuX~5)Z=2B)Ug{aIJ_lCH%L9GbWAAyYpgASNivyqcM-i0K ztFs53Klk;)i2B}0B{l5JX?eO2Z^PPrI2A<0iJzYApUy5I?)TYmdbt1D#&|)h%7%7x z`P#=FfK{vXUWQ*q)aD~a=8t9yuQg+qb=jp>OhdCAZnz2v`+f zF)$PW0h*1EH#E1-M)g~rNGM^Ii$XovcQmcLYb6HhRl$BGNu!>C0~4NgfV+HsT19b% za$wB68`*$!Qs01eGlUNie%fO7gE4$`Wr=(YItnq!>%Lqr3h|l~o;;2Z`@|vuU21%c z>t&`gCIZ$cYmcbw54g__j<}Mt3y-JCxm>lFQijs~dA7H`@J$GsIDijcB&7pJ2}x?7{J?x3JZW-&2@91c zE&YV8F>RIvXe*WJu(+HnDw6(*UmsC|-$mbCV4RGrM&56l1PhxKu%otzTA|%6{%2<4 z#d$&vsx}x|#t>%H{VDT*m{kBlWEEW;a+MYgK_4l_6;| zLMm2cP?EVa#|uu2+d$40!j zQ=^ZS;Bud=7T!^Rw3wH~JD@}_7}X%bRZ=X|mQ-MS)ZjX7{fCHm+WEQ0>Gui{qb{rBaYZ>F`yZ_&gJZgRd__L6GX)y!~{hs0s- z{0}MavOcToBb;KxHImPuc191MySLGqF;D{1)x7!;Q0BnMP^{*ysQ%8Z)BDE?14H7% zv)Qm`L8I0;^XAgFvAYc2efD!F%=30vLiAG;yxEjrY2SeS--R>J%6{O!Xdr!vvIvsM zMt>W57%sM^C?pwNvB^l5*fTG^m5ps z!l#uEmb2Hmesk%Mr%uwJ*0m7SHt{FpHf?roxRH8P7&-dM+cft+eiPeM$jnP`y3UcB zkS>w()!kBJkOImT2^i{w&1 ze0+K)0j@p4&-z;#h#o=yXW!hBe&_LRzQZh2J`sf8Xn*(cffVWaa?_Eu1JaXi%m$%j z8OjY8gWl=3Gxk~ZGX@nb1ZjM%RmzgGu-yDB`b%6R|KZpUo>I`eEoy!+!L#0dEPZ#l3u&oJO%%ftPbOJN3*;C5{MP z(z0jhAWtE^$B?u-<;}Za8Vwn%bma!eE=)Y@ku$h6mgd7no3d}4M0yod$*sq6e7IxY z*$n#U&FnDK74>{(|0!@ zu6O(Q53!)H$_9?+Up9kfR^v6YT{V5vnH58xWbc0aFe`I7#EISS$$CY81jriVB=@|r z9GcEZ71~A}A2O|!6irQ{KiKTuH|e0QVR=V=>!&k0z8-4x9v5|#?)8quHXa(L%Lq)D z|L%k%L`qDxIlRyt9ki5}EbSB(7Ydk@{8{UpWv89`Z)9#sRcDt~1!WQIt6nCk^={W_ z&wT?TDi46y7^_pW^)~{SI%ER(sisU|0aiMG+4r{B_<@^E-Tv|zi<3hNEvCuVdgoa` z+^sbzA1>JOqN^#TsURLU1JRIoCW_u)-&Uzw<1mn|el{NRh^C>p3c1~OHp+ji)1wV= zLQ&Bf#=!Yd(7@`{dA3u)R!X>k+mKj z!zzPXW2cr0@9~zSQB}zC)&ymke;J4v7b$IrJ(ls3iiM* zP^~)j!@r9YcywRn*yStmJ&5Xh$;;4R-huH_grc4PFx+0C_g)Q}TM>CDmj)2?9vo*e zQDI-e(#1x$=aqf-Biy1XpgA5p3OrriMLn$iz&8o$Or*&Y45kZiy}k2(4`cgxI)_LI zl3mebedIM=s_bnn@U%L(v^*{=MilMkbGL!q`GK?Qc2cj*-M+K&m}Sv~j8L05rP=bD zV01X=xQs){#XB6?XMD)R8Xq{i_iIkb*0|g%aIvu_ZL|6bp!`p@q)z_PAM5>JWD|wL z7v8x0aVfse(UO#`k{^mtDFf-!v(|XW6l(TWMbh5svPA=SA+MlcRya3}gG}w7LMzl^ zOm^(eVyptbu9M1)$|r_TCmq>?rWI^7(3YAoI7Z)7s&G)g(YD-2s39|Sf+(%q8k|r+ z*aMRSuiigTeaft3s{f^(mFXiOG6p8GnH0k3RWn-C^$Lro|n z>@%}h%)Cm(pGiMJ1gTe+wdZ*A*e~JifCWM#mM#o7eu%YZkudsijveHWC`1ZySA1T+ z=^_NI&nU-|CDoIGWq4$(0-M;=OYhFK&(W$Qatli0*4$%zo_W;=k;3kMSxHR^-Q^BQpX49mp}8|Ca7 z{>BXEdH3=8i4RYs9HKYL7C6*OnZs@aOsB8(`ZT=upz$v%?a%?u_`sr1B1A$b9*fmU z7k#16_I9Mmo)y726<+{SBp6GB`qr8=lg9~t$P7y5r8qZ_o(nsE`rbRiA>dFSy;#+x z#&~<@dD*84kTUD|_a)~oSf%(gHlfZiv7d+Dt?BRyrxS|DTS-Soh|QSxP)&716q{+N z4K07f{*nwXj7^Hz(Kqq z))yZwY)1=Cjw*lGx833of&JMi%ts!}e0O$MA+5?Q^N*rvuT#G_-4xuxXx zHw@L$@lmO&Nn=hw7u!7G^%kmK)|$JXo^Lem&L^Zq(VO-K6MLZpT<<|PBN!K7T%K4% zD-!xvJ037cBnenwq|BN?6$hmEJcLo+&5Bdn;t={CPJ8S6!rRc^dl+yzen%7#PMg2K z-2G6A6j?(^u9-=?&<$|WLOx?~kOoZY0)Tb_Q6iXDB`<;9^XDCfYAjIZsdFi+o%&c4 zu}*&uyC^lRQ$0a5k#vbpRCY{gR+hzB+Wm8+uF2H@e>TCOKHiImsH4-A|H9V`>Ulvo z)EQG704@!kl?hl|LjI>WbY2H5%FVX{v1ilsFMMw^U2zeQP0d^7f|3sjF=OWs&fQ=` zFkYDezjCN~*a?shXiV7E>s!{#$nYH~;OHCeTXhV-xa9v{51nPn)=d$$6MfP0YkB{A z27@_mZH=J>)k~jdP*74z4v2xLoF5G9nn8O1&G7IaUxWTWbz8!N_TM4}h;EMH<`wyR z&&L9j(CTSGVe?65ZPop^9A>X#vy@RGno8{k#B2^b9~Z`pK8*taiQ$-+EyW4kdvB== zExN9HEXYWLp?Kzu(Hv$VyuS=qpdQL;x?9yMe9j{n#|^!b*VKo6nmZb;DrZ2XY6eyE%HYqyLM|bX$>`}`2J&r z+_Cq4{U^+32335Y*H{|jIg#sB?-h!>?6`36piD9^Kx^Q4siX4naXRj_^y0cr+{;W@ zqvmVEe0BVb1h&yT8YmX;3-1_TJUlyA?{t92fJ>#$3Dpq+u=#rq$oM3y+ZfUu0;Wv` zStalNPOHldr;>~&j4Rm+4#x?@$aazLPq7WZ-%e! z1)b*oH_oJJ5_fVZmb`=e1%Uu`&>HAC(HgV=0&EQjX?iC2Yo|dFykT{KN5P|cZtrm`!9O;T?~UdP4%Xmt=iaxc z6AuZvIm~Ed5uZQ)T~ot+J?aERcQWH*Ej3yF)2vx00Y~YfHW0CdeIGef?{T^f$-0V3 z4W@SXvXYP!E%<>Gs+Ec@U7`!2S~R4Um;WM!o$_|tfa2P1RR&EBri^2H)dX1GY_F13 zlc(x_BJ+JL6lPV;e$=hGE0C5j(_J(wOPDvhJvD>Rf2i5-ErI$oPc*p_?;X0C2egO1 ziyvv5rAZt^%BEIrsKh+;-rmG+JO(cHzd@4W<%q1s`mn|Fkv3*1x<#8)WGNh(rw*1D z$r3r+xZV1?*p{7($Ksxq&sH0|dy-$$Bu6H9}k?{&Xi=_hyRiC0Rk$!bDWlUuEOoKNmi4d zhm=jYc4%ZUc{(&KSR9iDwD9#A2^Te^$#lx#_RM;XZP#;Fj{J?1;*$-Y(trJe$KZ2G z{ERZDaE{uxUn09l#ndFC8jt)~1Y+nsMYCR1G(69ICT*}b9Ca-je61GBvD^^sqL{^` z{7IKpTy$v-(In?Ke|*zwSqt+Z`C=;I{5@IjBgyW&2NOz5wygiYr}qEBo%%oJGVoji zZM+p56_);+>*^D}Y$!epn}5;Ih_>5n=wwWva<~fc{_L1v{>%A36EIJd70R6<>F^srb{!*gGzRV0-5`q$LzKse6PZaC! z4e@PAQnK708%2o_Y!JCFCqj&?D>T3K#ed};gxM#J5AOvNCEV8`vt z-^xuY4Z@J!5?q)j`Tn%wxdgxb_;k4$?Ld0z%8DL&>`^B&0{rfEoG5Y2?Loq|@w;&J z4|5dxYE2y>!&ZTU@SF*2J_M$)=2DjM{XWrGg|Ho6Oj03Morf6mkQ2F%ndl16D|afE zLW_tSJi1U%o9^S!ZrNAF{gT#)B+l)z`s%w=^;LEGf8~JM=^Ed9Q>baeKU#=LlKw=Y zesAj5_o>FdS4ttq&BHU8FcH=P7IS^@%0z_JuOG#?A7)uw-pPxg^`&IBe3%!jrsMpC zh+Bm}*&N>Rl17n|6_CUjgbPa6JmfAZ%j9L>)eZ9j@BOlMN-ZL}peeYgH1Nc+M(Y}j zfgXP2zsGnRn0#G(>*V3WBu&}arf-k;{kG3!U3!mD!-I;?3%K?KDM0g zYvdpa14y3dNA$G#+Zs%?8TKzF%&?vP@)9;qQe%h)TkRdt0R;P1rIA7SR`rIrX z`%r;K7eOqJ z@DNhZV~B>jj6D2^Qqiw`cf1p_K3W}{B6M{tGuMQEYko0?)>>q)NTxjA@SH9DgWz|` zx9iS|kIhfbMcy%ZHF>`MLKathSDXSqIn;z0Ro?^6^Wdjs1Nzw4@XMivm&e4k^S9z( zmdCmj_x9#KN>a>UxZtdtw*-T@f4?0{>aWpkxO@LR&Yt!5Y+2U(%pK8U@P-tmUwU!h z%=&m$O6Ze=<;Kqt&9Q-4y+i)X8X$R4}Y15y)o_2!o{SY%cJKYv!@Rq~Z@7q-izxz~w zo;K){j8qE%%$z@topuZ3_3O|Ic`+$JKp>ur}%Ii{}piOIY{AN&ZFPC<9w#j*`qZ$P8C68+TzE@6q7eVMbXO*8G)n+&981A8_|B^#;Vdaz@XgccpMHE&8X zZ<25U{EkVN@EPA_-VaI1c3*bysj)U~ag4rPf{Q3T<24=!Dg0jgKLaHha``?MNJC9+L+l+m!eA9k8=E;y^w_<{M$pu=3ug@Owe}o&+rL zb>9@*uJBI}@RC6l@2s9NY3gnh_$rIQZgq(_KILIpi8Sn|^DA5@Ne+=)rv^PGclAEF z<-#)O)5f!4)ZA%PJEMJCC4(7dicH|hlS2JU#!6PI?4Nr5B7hL=OusO(s?r-v$$6Xe zv-QQpSDGcIp=nEib#LM3g9jn*@wBETeGf*x$;$Xu&y%DC!CJ^|UDoLgi5$K<2T%6i@Z*fXCJl#+VHGVSlk}b+?)_L&03Jv9sg?9bCX>MhME6puQCrK@i-Nb9m0MzD4 zuEn!k#}r2SBt?kMWhc=x=-LLqczxJNR&JhA9?j5)fyW2!9JVPN zE5%c`rEjn`fJnH*`?u-m8(Xey{M#WCczo;}{*l z_v}2>A$3cDMMtHX_{Y0zbOUebntile%GX9jd`Sz~#&z$<7=#Q4_Ighj2g(>MyExTg{4GW_SwNT0i z#>9JN81Ysb}1y|aEtrQJTS+`4^VhWWcpz|Dqe z_(iPze!^Um8o&_J(!u&lX@YL;ODSO9foky5YszH%RYYaFR`ejO;kCU{wo#m*RA2m` z&qeR)t%rKhBC*L6Zm})4oDt?64RGnaHyvzd%pNOO<$lI_24DVv;KivIl$TSRQ%RJ^ z|Duilxm)RH&Km|XjCOMk^>S9ov8sP_5XPB!f!hMNy5c0&MsbD;sGgZYtpS;sU-CD5 z=7^H#1;tM-RusGWpA0Mttz}VTOEmT_V~s8SLJkJr1JyQ9xMF{u>imQ$G=mWjUK z0T7XQrx*PoQ0ZT@CWxxw_)B}x@Toilrc8B@`q77(m2*wbch9QC>xe6e5!#(8uDB)X zxOpIjtb4Ksr!1#sb<+v0@KEA@q>` zIz8brm|FY;!&8gxfz0e4j&kj~S+{3gz0vzNhbXoBL!O zZbrhVQZ?y?f(T0`@bHOyLT)06knBF&7M7ba6K_1=L~vv_FT4VH3|_LE2+D7Nvh^B` zcCOLw?)JNHn0NUqjhlz;=if;|q$}H)Lk~j0h1kd4plmBL;dk;!Zu*A$20@Vq+&S87 zOd|~8Qqu}6+IkdTB4goX9!zMO@B7$DySi<@H{N5^K}6dQl;ZlOtd_5`!1`tuJ7!$} zC!Bx>)n%M$xHq$*>HFq->Z65lw|8d>9&~Tj-4kb(}PrToBiu*XKlmCM&MjY!YCk&%&d#gXMmL7m* zTj%{k&XJS%R4n+AS>4KvCFK@lX2AmyD8DVzGYJL8nHEQ~L5T26<7(GD4O!6#@L$tk z=zERp(gjVF(=@m-X|B4Y!mWy(j(ZFp_`)g$7H*%vDf;tmrI z!|X|7zQW&xmZ=6G2I>{fZYkyx3@tJkGJ97{y;)O2DLwz#Sz$}}OkUoO_+)^xatK^$ z`~kMy@wKM&9Nzfi1KpQOFPCh=pT|V=#W&Ce5%>Y|?`buetF@Y@DW)*z^yG1%U&>q%^<_y1@1AZ?kZYK?2Gq1X-CNVll5z?@+i%EMj%z+8 zP}a&2vrZQQ)Dkm?j6&TOoz?Q+iPlIfn3j6L;{eO;z@lSirNi;xLrprR>#cB^8}A%) zBmF?8lBrNQh>^qbFMj#DoyXL-DLrTnTgE_9k0`GcVTRbgt)5?JH1cJZ@adbYtepZa zw)lH@w+4u0j+2&Q>@p>M5m5JKw>TL@tdeW|o?89|)3|;r{_MbC;%_ssZTy`Gw*~*r z8sX5X+`qPPkho;4+p&$5ZSRBP4j+>LJFez*4sbm~@DNesOIp;KF9*_(GquKqAd zs>WB{6=X{lo!g}d!z%@k6axL;!k?z6NjQ^As`*{+7cw;jTN+?EpY;7Y%%x4VaEYSphgNUAD~zio79$~6lbW52#v#1q6wd7oA} zwRIhuSUZuF(9`$;9C=?=J1~~u5aRgwO&whpJ?g2B=lPvn3lFO&au!w3QMps$F6*=& zjT@hyP8-O6Xq$WVF_!bl%owUhV%ZW9pF}(VrB#D~+WxfV&@2+W>NwF5d*4co&+wM( z9l~8riaAK=VdXHCMqe|Jdr33#O|jjrRcU2`+qR9au+l~Qj=$b6w}sA3{jaKh+5G1q zA?w^OU#%Yfjz}ecrp^(~5cfyB{=;F$tbD4o=+U^gVc+A^@;CWYqsn_f0OJ~!6{H&Z zafu(lkO+Z|=glNk6mS2iiqp%o_#OV$Ymdx~)0a@Emdi59kYdG7oQ8xaR-ChKhE;8s z*8@YkK;vrCg)Ea2bxGXZ*z{-g28^+{ht#(NfaSoIbH(W~DI4>C-4y=)hK3V~z3ab+ z`YC07C(cXI110)#hbQTYj>{c596i9^p(kj(INaw9866ndf{GxTE~<&DVNGhW8+lv* zurr$2_}Ap!nKPlvw2df{0{UaQfuC>h5o4@5dL9`NAQ@_Ir1tji<{iK1=|39@+}|~Z zSdE`^GtR&PJif~EGB054L=pz*#M`Aw(Sb!Qy!{miGD_RB2V>`ZGH&b;o3 z^ohjA-u90tB*+zb$FL&jMm6362Cv7y{o((pccrLl`woejl84F))jf_Uwrf@l3j`ui z&xKC{`+UZ+?B(udl9;QoeL<}~i8AhFy_5vKJ>oayx(r zF`y|?e}M?Ltjz!LS!Pph9pW9%>e`ZdXPaET(V`!iM^aE z*61KlpbEsU^ww6}>WZ)ZY(K`%@Qq)UUoaddHGDRS7@hM=Qvzm??Jx+Joa8E&m`gM^ z`?`5t%eeoA-0zmrDv49hnq0p7p_j${U?3Y`x`r@@V;yCy_=uAmQ2NC~eBky_)xRk3 zSf7?c_089!bPW41W&gIri`tWYFA_3QcMe)h2A>+6+xr8!yjx&kBAesY{Eeh*^d>*o zeV=ldK@JQ=oU*+%_s6CqSmELbL*Vws_59t>+G9;*bo&C`Lx5r(?%w`tX=QHSHXWB@{nVO$7xPNeAdFXoE4!72$ zUP=c2dGJ|lyE*Q-A zpOXvMCRn8MG||`=dPX$EOG0MIqqgk;2`afbmeeABUjEgLv>@=j(=;VHdt!iv8xWDl z?GBMsDX##zDQoWOO`4mHa#+i`sonH$lU?M#C}3F5CT5q7C+VuXMT+(L z23K^!PR$Vb*@4#8A#F_9)n#M;CYY67M=#!L3i8Dl2+XugpY&aAkJ?-H3r!xAYojxyOJO8jO<$)h3x*36f8-;7t7*n{eD+Je@-CzN{U(vtTS> zFBm+dm1*=cWayh+Oek#Wir>WS8`Cwwz<;h5P;h}ZbbslP?H8U0H8?pa!(6vD zR0L_pPA%5N)XUcI;F|OycB-Kl`B!_hD48%DDQN1ScsVJVmE-&jInH!fw zwnRrphfioAoCut(oVCO6y?L7awN444s%#oz+z@IaBh}(-ls6rSiZKMjGES~rbrKBt z6!e6q4Kqzr_$2k^*8bE2@NdMiTXQHeMRTaf@qp+lH+LQA?U0tFeK2-}0CBbVBgA=E z-%Dzgd(N&SVrZOTYu}j8($cstv|OR2Zne>~qvI%iCM7!|#VFDT*RXu;H&^yz+S_5o z;KOqG_|}*JHE}`y_Z{yaD?UNW<(c>zik)YE&XEeW_X*5WWLuid!jQek6Pt|G-U|t_ zVW?Vg60GeHyMX_O^SwybsU7)_I%P|>WEN~0x}Ehl&WzFjQb5*q=Hz!j>v7FWQ>jt) zP^*z6$W-WNNoG$^v)U7+aMh1;BvA!|@TG=~ zCi_=^ZfrvE%=`m0DPEPm=OVFFc4=OyOj%!N@#}`-ly`LC%^x!3y)vyj+y=_*OV8KD zq0jBUs2IxWO?+=FUhB%QP}(VMM9+p8mP`ULmMMG+@4rI9fK%EPPD&4PlWHGCTzzLp&cR(Ls{`Ltz1$f3>FGG|zb_jdo_~uch6b)8cjJE+h?~WpH=Jc!M$iJ$}5nK*f1YH)Yt_WM{CFOg-A0cVnwA zIe#sP&V+ThH>*~LKP%Y8T^u9t&Sg3k-oC6&K7pH9o1Em4tUo@OS>k+n9@3D!f@JLX z@EvSP`5{@h)GGJs|IGq731L>A;HfL$^=@6Hz2n_~=Limtcu@un9SulAuA=8ACj`4v z8#6fPRwOcKwSe+RLs2}I--03;{fzG22qJxxj7j|tZtO3klF-m4zhij;FcM1CL9;VF zEkDh!PK{E+n$KVv8>XS6fO+aXix`6u33LTzP4nfAzpeRxGrFpJ8%jgg(niz^P3RAm z5Wf$}pAJIG6m3l*EwplLW-?vZTrBY>frnkh^3-S7=6IJB>SiWqc!?RZ zdAY3UHZkx}L)3kYcC?v|!BvkyfYf90T~q;^3Hk__O6_#Tg)h#UGhzy^rLs2uX!mx( z37+JIRn4TD)hBq3^uzy^&;CwkdQqBPcVHNiZ`d5-5%8X_>iM_+yN?C@-)nz-Y^vk@ z_ObGP`q!&lwe2lT@ssE*=B?u8hAPwb@#o>k)>Ore)-@G(*#eE`1R7OYAfex$v2*P1 z@th0$x3OV&EsWf*uoUt}4)_hvg8BB1Q=Fp2BBlMjC1=dQs$aW{#oWSAL$`~iL{vL` zvV(pHr9}cSA1S2LVJbT^Qu({U5kr$N*1I6i+vM8K4xG}Oav72nCg$BsayRG~SE~m2 za!m-E?{|soxWc~lnAeU86p&LpzZNT-1%4rGfPI|N4jSsNpoH{)Zy7qLwJ>y%d>@B+#ckv6fS$l)S^Dw`3Njku72uRlMKP?MR zn-4&)8g9QDsFaPrhoxTz2aFr&t9tip8}U`w5B2(eD^6yXup`kU9(uEc3^Sx4r}Kw^ zNJe}~KgK1x)3y~rT36R~G^NY(d*-uU+YcL*2e58H&uVne@vJUAq5R}M`P zfwf}mtBw=-)6F%LKaRm;Yt^$G#Xu!e@mtVizM{yC-R9pX?NOc?GjGn&$qaRjz`VcmjyczD z)b-V`JE+vn57pl7z4Npz{v$2ZW+xyN^lDsiAWvN04r!%@Tb()JK7*7fEK3SKms-l-aB_35$}AHv zo^o9C9?F?40BKY{?77-DxU>NHxLAnW>_+KfV?}cJtA`~&Bf`9>;x-kjB(mB5)Q+$Y7`i`FkNIAHN!Q zsx)1lU%3y8-n|SPds>H3bigJRrmgLd?2C_|#t%Ot%hYZ5AwRt8*8@T+`fE3o*_CEI z^0m;Hzz0J0*O!U}BeNUNJCdRpL-Umqb5jIc%+@td_X92|2W%#a zvx3SpJbsa&ZamGy0%uy5^kx2bQ78#nkm@pqPXp&cd*%UuzM@)BhItM`&syYpT}(kc z=qmueenOMy^77IScZ_=O`tD)H<>^nRu#2N=2kmOSj~7t6uc2gm;QR@5z)E=3mP;5T z3d?v9pnMv3)I%EyIXQj5M6iH#YvD8cnRr=UnapF@8+3knI-wP*)jzc6r`ENXs3r3h zeX=0id}R)v3pCrgcMIguJ3rpMzkg#8 zJU~a~a@FnN#_NAs@|SqCJF5pqukW`he~HqPy~0%Un_DQT=!a;08#_e_ePf@ruESH-f;6mQCgw<)5n@O4}) zR`t^_>u=PN5*v?+X1^b$70(c7Fsn(E{$cx-hOH&7(UF<*Ys8{vH^jbdfK^umE5GA0J3Up5m1jb81dXc}5jRS~_< zt)Z@Dm|?! zvT56XsFi9(0FixtAUUviCadiyWS@MyhF6+6IShA3KVW$|0Ys6MMo1gt^_TfczX9-0dk5uJ zmKFvT7Ue)r5$X93?!n7yi(4VBUkH)-E_htQpnVeFy&Ob5^K|S9gZOF9bDFzo*i~2cxP%h7YLObzFfL)hAw!G6djwra>7FBKw^M) znDnS+RnwKlQ2tWw?oIPcL`9XJ4y^0@TgAK+Q_@C?5E(+6?gy4hHMY`0;9l{BaDH(# ziptYD)a~FUGp?l^Q49%vy|HP~^alu$FQ1RA^`Bt;oJQ)sHj5R1c{W5&;veagMdztt zNY-sh^<-4w56~TobuhK9`rTGHAVQKSdu5y{%2c&cyy-Z+d2s2WjH}bSfd4$n90ADC zlA*;;1*kUfX_fc@NUZ{qG)yiYTu#;tkUT* z!lcb$Sl(LQeN+N^&|%20ED+Z%XPI*deoEhooOI=hY!ZOv7h4&wZl&t5UT+Wc(KCn&y( z-juz@Q9?|lOtsv+@p~{@Qja#bhv*wnXT0RcFWmz{<=<^LHM!j2d7>;X(QbRZ@VOG^ za*$Kz=$oUY|Na8ycC$IO;T@@mdmCn&vcu{5=~ zSfy27UmOAMdwX`Qi(vaP-oqCsve({ot$}r+WxmGZ*B2-GvN6tkF+@;E@FMybe776R zw=TUNFONVjUtyzM)3L1=)3A2^{g#9FzUv;#i=IPtG*8uwa)U~4&P*Sjz%}~iCxM~( zUeR{#8%u2FFZX7~yeVWa4v&ZZ+XWFrYiTZ3Bo+?YK)6O{rhAgpxr($`Z~aI`mar|$FX&rR z^lY+Hk~h{ige1CD{I#Xt^3*=}xb>)d>laHbrQKh#@yoAwy$;`IUN;K{45pb9s0uTev_U&;^hh%pwDG%lVe8g-8*9=BlrONou7r^uODcX z3}lJ;x2?7Tu7;p}W{NCBa?_BK_6iX*gDMkq5x`QBWoW#!hM()m!+^!m_kQad+b*v9 zEnU^p6&JTLXbH^}pl`V)-c43cgWKBplwUS;MzWT5hu1<3#!&Mi;Y)WnYCG9w;2aR$ zJj-?T^J^K6U__5vE0x#d#Ryv3qFqVBdL zXfMTMLQaTeErPA&yY>SfOWQ2b;uZ#O5HY`0eMNwMvY6Uf>D5JH-9zQl<)Ozd%z&bv}|49`+ZpRq=X47(I&T=sIm&6cB$HKktl#Uihu>j%P{uE=vf>@OYT$pKPW-tn z^E{31qpe}k3GKGaj8-X&8d{V`A};2n?T&u**mZrvov*H?)CLXB*V?6{xmRm<=M3&; z3V9undZV8#8IW7N675Ct%eu`$3#D58%;(B1jXmopZujf}R4Pnj%VjUT9jD)ZlZ|BC z^+`=EXss>2&lKFGw7f6Mv&?m;-4-~k zy|R5KsN7s$HkNUe^6ngtHv(F^@!CtL!mZ4VPLm5jGgStD&32I%%MPiRs?)ZCqZe~_ zxs#P`RGpCJXGgmcLsWNNig?QWc12^ypi>Zhn@IwY7X3~}y|T!p2c z|18^Rgzf42G9`|hl^VhuHyz?O+=XQ+NXCwX7$GjxfcQRx6LTSgQs5)`;y;u*CZlf8 zYwh}ElpX`jP6jqPvrRuiur;#9eq@Gg#vy$dwd>XLYl00Uq>@rk*iXu_A2(fnnS_*? zVKQ+h)CZnqTV}Rllj-NWq?zvL)bE-e0Kf*OtJc}bbzS+*C&g}+{#@gGHeZ!xueS0L z8yl(+0D{}QhG|w}-=`6;Okc8fyCLVeg_R@roG1+-P^Q|BPd zd&bA1XP(-C9tiJWS`rDoFKGNI_XK8Zi#Mb!ON*_|smU|L(aKh;>@{nB^ERFGvmdjq z|98vqFX(@zgX{cYmYiwx13(pH=sf#xN+5hQ^-Z*qx6?`cQ%?oc<@mq1D`2_|J3*m^ z0rM2AnzEOtDIP*hpt1!VXI4NSAxY2+UhC2hkM$@R<^!HHJH)FTG%#Mp9MM!sR8P}p zGDs);cqj7m7>=dgvfhgzqnFD`M=CP~e(*e?Yihp;_ z@NSmH{A~MZF7Cq*0J*dt1?IzuFBJW!-k9;Gy?BVjraEMiWShHL>@Tm?l;99N5Z)2h zdp5y9;FP>>Ot5+ApaA^)pFdd8DOzSRGV$bJ-NFG?^?RFB0rv(G5y9a$G`LshUz&QlO*C^~*8eHNXHhB4K|sUCAh*V`m#T zP$nDG#nz&ur*$cU6YFm?nueP1NwTReaAPhf(xJcG_WJsy6u)OMpLw(wmyd55{~C5Z z(EB-PkDzN}BzTKkI$-vOhxQ!#(uMM#Z=a(x z=AUHgLosnlWN5Z3)02$66bZQvss;cw!K4?RZwvVn_FTt}S<8Er24g}EleeXlHGWds z*B%rL=51%>H!d@m3t2Rn|FSP&%(l1HCRrmA6%c3*_f8R?IhItM(W9QxR7CjBm&L=h z)N{;x=zT$0Ph^0!1Meq>(bk>#pWZRQ%KW}{=;l9%W}b9IzlLz%i@>sMCEW z!jHXN`fb`lOZCar-2p}hEZRxg@~L)dS4i!)8@m6k!!Rg&py-_5^UIdO-o~qR85+#Y z9Pqty4Y08yz_~?X;oZXqmN&GD-FrT!$`fQXDPbd^7mdQ+e@rkNYc>5%DaiL_Dc#zxVcZ8r_OD0bZ}mM`fsX|dR^JyE}p*JNZx1!SBe({AFhG>JU8{m$_R_8 zeyYVwJ-Z$XtCJIjEO6X*k0c!5L5`UuT6pw}^KsC~1Xq0soYL|JeV=nD#Fh%Y>v?14L70B}D`6pKUg}|LV41G67}I`6aV@!`m0J=gdq5TF z`g7&;Y$4-5x>8+9p}SV@u5)>|Bp>nQ+@_4}t--k)HD3^SOw}O6@64&zaID9p@$I8I zpU9Arh)fEZqH}LsnW3qsgCX~zcTvrZrL${cL8zdk87cjM%{kB({U@(5TwboqdbDH9 zfxiI%Qe@9xnkK9OZrIYhLBYXSWL`S3h(L+?7 z^5yJk3W)jo#Fqn9K7Zu1qHFWzzvEN2Bq8O_J~`ix+`j@=HJtF`;{<<<-sQ@KoQ|xK zdRHmg4D%yzHi+4P|8kVjs=G;c?=m`YV-!RY)b(cIHd=27WJ|iPjEh@xo zhB*zUvQq7yzd%o7tGlZj7{hegjTvL6TK2Q@Q@yz+^2h+!x8g-Ea* zeN${+s*(e#ZE_Efs|=62Y+$vZeIpp->l-XQ$S2f zJ1ZCOk3~jKY7*#@d{`)kZwGI*j!<+#zT+*hZ{9H(3lASDHnI9!-}5?g(phGQMGdd#Lmjso^Tn`70IdR|$ZIgu;4x#t}xCmE3P z303=71x>C<8QB20mV+^%!;xV!_tnrNvYEO{6b~2V>ygb^1P=^aJYkp1PMkEr|T|5>0+yef?;( zCQs!LYx4>&`=BcX$C362Q2s-=G$JrqvsGQRh9SD{l72Ut8S8KlbL@W zef%T;&)bH*8y?~zbG|YbR?c*}RzGw35y5q`*o(&1MhPhv`7)pUX$6>CrRAB-coLex z{8PVH<5o)dBZ&x>{3Ib~*qvgp2927klf4+`oMsCU>dPZdN0fNkzW8RwBNh|oIbTN7 zz|8S$Gj~f9NA!7UYB(PA8t8j@-i)3r`U)qdefd2b9&%&5D_7zoyPFR@Io`^T1F3Z0 zTri_`=^Msp3|o>9qO+XJc{4n&ef6 zk8ovl^5d~PYsHg%-dubkuQ|J@$;p9jTkw5q8PqXP}iUtY-N7ElY3hj!+Qw7ft3qPO5qJTj8Prb2U~Fu$h0%k&f3jV6=8<$#XBs^&qw zS1aye@I3K%oog%Mj%MClhR2}|qp*2c8fo-viA+7lxHOCQ7l@}ZRT;cSP*SY;c|F9Q z^<@^)LWa42kWXj?NOLk>1hBkT7BYauc{x|vojJ>93YDxCNx1($e7x5{)fVw;4mf-D z^U8lQ{j6+JYeY&`GA$I8nY|P?p?a>L6~1E_D0P<4Yh zcaljdwDKm?hepxY$wqJXmu@kf%BHQPsX9^z4EYE&8{Vo>Z(AL;L)?LyJJX@0h zeQ~j_>M3^*nn-8i2b_0xe62fL(GxidPu(*f4Zb*n)5h2`2W{n2?#SFs-aMzNX1=Mb zZ3g1DjNIL3vKY8#{Vs#fT9(aec#U4!Uu+j6an`x`hGnSlO_8fWXie&Ci9;Bs+nkFR%i>wiQjU7nbl3K`a^bKOSgyVm#`-j86k1=9%AMLCLfl8617!{g-P9Hq@?A+`Z1)L zAOoJ^cX?s}WX07_ znH)`!pq|JCDSn%FBy2Vxfz?P2xH6qZum0JP@qafG*Ebp55XR{S;ZEcjN&Z{0wWe_W zIWvTTO4PKW8nLQa997_Q)DkkCzT?a!vT3F_I|Q?$mny?C0n@>M)3ZkypK3QQ*7uB7 zeSn<_HH96}BM*hXjil4X!JRB4Cc`k|^GmxH2pTtco0@E!m(7Nf7JKmR%xmhejoTgF z1`_vSaLP?m-#_h~l2_8Aduqw8;~_*7&e_PZC5vkW2-|SJwZA>?;Q0-XQRIv{4C&n- zt2KobQf&tzyBSW^>F*bq%3L=%3`obhy}V>R@eQ58>1;w9*J__EJ2L@ZCY+qq zhh9RoZ%T|AZ573=@*boX57{lubQxL@BiqN{>5>1B66&Ay)GV6t z!e8u~>~8M9h4MS%e`c8QROM&Prva&=jH=ed8RE~~8dQXsfA)4G?1>49g2+nzJ(3}X9sSi zqHd%JF(uFu=`*u?e&%o-c7kiK>ALxyI}((?YC`FXM^9IhUe{#}NViwGXxAme7B?n! zwfN4Kg58Z<`~Xp&GZ0H=EV+RSGg9ohkM;9sI5Y%zT;lNd3sE~X)6aW5$ZZq&BXI2b zKXYZZ`y)qgj`^>TLZfzD`{Fne;t$O4_%I&oZn)b1=~v)lQqa;rzW)MvT@oI_TqBdf zYBN^pSXJeQvX4{nkfFExt+#2~m@W%Ac;3@iUKnQMO!6HmIdWEo3EuqHEH!#84f#l>JH6T4>ShLjp|N)vlke&eeYnB>%f#9PLC4<^U^ zz9vlsqrd_aBR_2=I9lpgWy(TrRvTvR&BJ5{Jl`!1C=}8NaE66(RBZS&X*Kb4x=CyI z>z$OB)b)$a`31=IJsm0DF3QNx6W?gn8`6pk3!u*pJ3`uyt-n*_12c>{cRmOi}ja#pe*FUFb} z3|7~x1mM>?1b$yENiy0#CLn$gzN3+AE2~6pDIXtY39qwEFhwZ_*42(Yz9RVS693;C z$Xf)ew{qQn{3Gi94-V34!MPIo_GWCGRJ)w{zVU9e<5*sR5vo=r)M!1bg+l7^jq%N- zBomuH1@_|A0QzjfTPy0>#mD_9R!|hNubWFcR5xrk2{3`Z5iBxkd-C}K|Ne;_7%x=M zzYck`gSCLgi}k>U>ll^)_!2Hxn!-|U1o+hzCJo$!?SFgtLBxQnHN$z2T)-5JkJ?Yq zk~HDqhI|#coMFx~xwY%Zl%GfhqhXh1fy^z0;`g-u##K2R&iyR^Ahh_#1+s8U&Ooq!B1xG@2G#E@$UuZAG6;s*L>1^kiokxBD}xc=0iBER#5I+6Fn2f z>Lt=qy>XIw(6LxUXs0Rvd1~{PzaYyrsBO!KF-C~1>bU2=>mz22nU4vXbt4vY#kbp~1qI~}lO$cs zw{WKHF&unv&aND@nCq`wWh~TW;XKGpDdmHyF)CoN1{3To4!9k?=6Q^(D{tr3sN5)y z(~9M54AS&@=M=h+Xy=rV%bS$=RT+ni(5szlFCC^x9Ja)5@bmOoUYt!gx$-YuzbUwX^(Q)5tb$Xmc=TF67N#%SK9< zlHoSP4sRWXXo>OM^jAE`V$?>`p5MUPL&2kn^All~Je6Si{z0r8-8Z0{{hv2JhC@d$ z7g^J|ZaUA|)Q>0_YbIz5!U~^J=TApxvYyvQf(kVmJ7fxc`lH6lQT{x;Ro6de{@6;A zp9FXl{YMx4FX`~$dxEKDyHjzm2vD~5zq*BI)nJnK?ksGiCWs1suX@)EOA8{7$dC!#-Xql zCVJ?5wfmdB^zPqF8mqupCU5XV?xA&NfvDt4k-)X}pyKS%puZtEO5dJwZC5^@hPIG5 z`=PDo?qJ|7ylz;6CuFhOTE1zgtQ-LaAWdxjk&#S6B_UTQ*>NR}&^2%((LI_~332Xf zpv->bTHXSBf8l3u@Jb0BFmzb8VW6i!__hQ;c z@NCb5o+j#i$#9M0X*6*acqZ}N%wmzLw)f&Nnua(CS!w1zdxk&XJko(&qpmfcLiD+_Y)!6j=^RdQEcnJ1O^OaXqdh6V=JATKGSZy+OG`m2<%W3 zk`|-B>Z>dwzS5u;^#3lqHs_E3Gk<{)H!z1T*o^1~hcR_n?&rhD+%=sZyj9q^k5@LqtW8D$;9I1eD%8 z2}&mvkrE&j1wsoQk^mv$Ka4uxy>q|$zqzwkmP_MFa?bnich_e>+n8c)6!yfy<@_Dw ztei-x%u14VfxhL?wk$EX=KcT!&M1+M#&mnygFN_ZFJN2sY~%3+?Ax9;=0&3q#=bN0 zUAQ+T#ZyHkp56}(kuVLzaR|zOK{6+g)x_Ypol{^6kJ}|;8ppfQz)u0?3DD3^Y^D>N zLG3uhAwQdmM#Rio!sud)71&QQEp`Lk^Jy8=8o*f@Ls$|yO=}}~kmJBf*fSSJ=;;vA zV2z8+6D_;~;1uitYd=&xN6x9s!0ZJ3zZYK*&|bdu4dtW~B3@{<4ywUplX_17xWf4j zW1F4XaiA?~jZ)PbGx^nI1X0)OY-JRt2=ZGjcAZJGebF%R^d6>sXh0QH{)RY{;v`f# z=4mWB_vTp#KvgmsSVtOueMz-rKWuJvH$@5F_Bdatx#ENpjIH0A3`1j3R6POX-jaJ@V+b#3-=)%qvjj3o3yz6*3QS7#=s*T|7;`7Z<8N>-#^}Z{IE0+=z%Gr&?mhX8I zAsY*IEo=!6)3#wn3CgY4K!w&b<>#%G9SpnnhBWgZrwepNBpw}yW{?Fvt;U`%=q}}o zOF~N03#|x)YwdGv$=NDwBVtQM9>3qE@PPC0} z2C~IAEKSubZHns3{(x_Hb?tudu-=|@f^BUtvX454+&I(bJ5UTU@>{Tbz(qt~32d_6 zh-lG89(@}s-v2m}qLL+^34HT zIoP)U=-gKpR{4J0wL+v|u3I{8m`8j0zNoi5zI4VV;p!f}?J^tB!4 z3(?zv*+cKWU2>43R3=$4(U8vu-e8-1^71xSF4^PMe7`WK>e-#4)nn$j;iBw=J{AVt zyU4AxhaK$RN9K*?e;D{r$lA@*Kx+42+$uWdhRH#{m3j&8mI5dWdG>*XQcb|CD(CN? z*JmV9)g}O24%ZhLaoAQt@w(G^oofeQcI-yCH2B@Jk%84(KGwiImYXNcPBin2cyl51 zaj5TnCNAFC@fav|Tg|^}xhFZ0^4`;&x;c}!ew$h0{x`0x70Mq+aQ)aE{1HR76?ej5SMcxX_Xh*U) zz2x*O5jb_X!~;LLI)If7AV*u6s?d6t6iHAvJO>FCpgGFYm4R1uzK zBOQ4NOCLvc>SdSVW%NlyQ_FZtWz-uOmJ*H_vAbVB;--R)y1Mqr(Y|}DUM;aUdy)70 z77g>_Aa$Y~>eu3ulfOX7AuPz+n0HnC)*BINb6+c|`%!CJd$;2_)R$E%eB^d|I7wEL z`P8uo4&SDtymx^a&0}3VeUj8H$-TzDMJU_V9tZtx<*7Q8`?&ZCy>~JygI&{C%HDQ_ zF%Q%@T)FJ6JHN{Eu8!G<>wDP4xr`XE{`=PxC$VKjRI6ljz~vnAd@8TVT{&7J6OD_| zms`_d@tvdez4a&=Exyf5(-`5)_fV+a8}CrOr%u_&80cHTA_{X$LoHB<6K5sq z?eK*!Z;w9aiPW%VytC_amwvu(-!^ zvMQFa-szUMD2m4<4BK=ii{SuhO2|7>{R*r%n^Ky#RyF8Qbn5re0>Vjy48_HDMg=Gw zx4?~rq@SKu@z($1RCMYTskJ9XIudJBPsM^vjMF&`49@!+EPilothv9#i>f8KO)x`G z%%A@d)_(&|cMgk>vYp;M9teU2-e_{aG5uEjY?QyfUb`wxqxgd*ec!{q+LCBkeOvac802jhBl;FZmfBg>6L zUW+Rfl)BYdjaCe#F4%cb_P9|Le12 z9#-rJesr=S@pI~4No6s5dUjL>qbsJ|D%=8*bdt-++_6rcQBHu&wth=}TPu8wlv7+o zeV9^gyzEexw;(-%yn98QI#>T`xQY8~MZU#nK91mYoIggB@`KXr3q;ZL_sd{aAq9HaP7}8j(O~ zW_igTHz;v!-+sm#+b3Im%}en?RzG5y8%%Tak*$U~IXOx0t~MH1l-4`gz{vRBx+GWJ znI@@{{Ed3|yTW-Go!w;d(g<{_D|_S{yuPe8+w3qBO5}xgtoy^r?sQ#rPTT7K&k2)i!PbB6GauTcMKY?)RX$Wny6tLDcBZjmZKdfYh;iwb~q_x z_?}Y@6@V6lFo#+-%(LZQKbE_!%a;72n9m(6w`V8qZ;wwKy$w>ym28#oNK;kDt5wW+ z5~gg+&}E5e!H@Atc35U>>v=1fx^Dc_g5LFy#(n`&6N?fF8Jw~ZyR+p|(pV0j=VQ+z zKaX?Pq#~QNoifPp_&@ryoqd{888aI`TZ~q@*y*;=FRLpsk(tAen(DjGY0Wj`R=Z%Q z=cqq$-s!*;uDbc5FO`ULSbm3^8B+19T+z#Q30)X42yQrd=fX^Vi(g2A z-`!sJ(95Rnj9w~s)VD0^T(*xzM&{!#WwYz;8ed|UI5J&kuJxxfOP11sx65U#z2|;< zkb$yX=C|g6M3!;9kf{;CHT>3f%mWiwi6rE&y%9M4A=2G0+VS}9QiLGMQ|6pq=)-yk zuDqBHiff7a00rkc_;I$H9%YY}*TWP)!F&`dD33TJTn(!q-Cjn^RnFQ@IK~jQZ{urN zBQWA6Q9EDpj~WT3=?<6Zv8%p2IKrX;R%!)+z<~jCrwCX#g^ul6#6(F7C39~%7$}ec zi4Zb>p#LHspB{Iy%i*Qtl&wJ=9`R^E@9mb|#f19#&h=-(2*KRnj_8_V?kn{##t;$t=+_uvVh4A}V}XQ6 znK}0m6T?~Jsd+LuYXYA~neT~gYwzpu&*cxVJ(!<9ykNVyZO6}1>2)JcJf^DG(eZW8 z)LEPyd!c=fPaNHj2}etvUc@8bw~puBW$Sf$sq>iQyFIg?*zX<$%|;H$UI*2x*Ye@7@{c`pm@pl8u1w4!U`VSno4(N0Ag9v99pz4**i z3{dG8g8c)wf@#d|a$gMJ8pDtD&*r2@Q$_5&{S1fd%j;&B3Y|D9&vNi6Mvd7E=`W{Q zMSs;NekNKEB^;0+JLEMXBJwkXC8yr-`Kzi>_4_=t`WitfQik?D*r#39i1CrAyu+z? zLRI-;COXfJdfeE;bWP*s?}r&v?sDa#(@c1w-KuXNkK|%Vz9Y98^Bmqgi(?1TUr~86 z#5#A`1Wao(!L{zuges;hq|y+K?o*!0rhn9m1vqd7D#v~8t_3u*jWgT}FEtzKA&y`E z-B33ml^e9_AamtF!O;5>jwv$SSSsgsO?Oypn~~)D8*S>Qf)hW0>aJKH^Mk(!+sk~s zQlqCk)>1Um>LgVOxv7y`9d1SvyRdaAkR{|vRP~JTX*v)~Mr%uxMj3~`bnctWj#60{ zm!@6S-im)sFsVqu;#;q&fh#aR@dalpCG5{adTBkmuibTWf-nxAKUJlU8VSDxm_ zWt&6$+FgjrSqQsy{biIs`(=}BX5YO%lf^Q2wj93Z5(h0SfLqAw(3P-Yeo>!h>x@$! z7}m(_TYXTMrE}iv)MJ@Lh81XQKTRFs8FAn0$Ja?6x+Ch}IJ$NX!I$MJ`-CR;NB@%E+36rhx_lg+>m_> zl7^;_7CbiQp!1OD2VlXM8A7INcQ$dBL!V89Xuh;bt9mu6a$p-zPlWDZn zxKIlJ%C%|tZh9`giD=+y%jbXU^a~uj@TJ&$Z892gV}s@zC+VA5Yi~v6{4PXAnvAPr z_FhoVCqbb{I4&Nhn{SmjH#dK?I8@9-TfXn@><6dSfIp6Y8_a1Gusjau?1j z(k5+f`BCa@?BsBnPEQZfF#f5j69zr9&(ZZ>o$3?QWJO$!+6+CiDN7G zv@azu#$r4#MOB4Q+%C-5wkvsHy=y7;Yhm8c5Sn<2hB^BERt8_qL+Y6gXHlCNxP@Mh z@v%GnF3m*+#j>(6#>Orqlh#g-vUo*&LG)`naIibyT=F-=61)_yWMmSEEQ4=y3iNTT zrE@Bf<@bnOls1<#aH)*sK&6{3wo0R5Rs=u&)uV2C-CxovD^n_p;<8w~4k7Iow1^M* zG7aB>xX$|$Je)A(HiAiwzos@Z8eLXg>*-Y)KQ;Uc#|u1O~rJW>;I zW-~P#riDV)9!XAVCEbeF1KAFGsmdL4o*2bQnEuk`oCL~@tG?%z;D$iOH_ z|42yGY0>(-eZ^wgKKGud- zJ^gHEgkWxEJJr>&a)mJ3z3%B(F}!SC(OxpDv6s3M8!*tCl*&GgAy7KjhJukuq<3|F zrCU-(T^d#KS7}6(amesuv0EhAmLqih`sf$UqJ{IhOn&qIp4suTzP>V85hj)35+h99 zRj^ALFXTb`H9bt*hm>5V!OR0RM=k`W(TkmQ)j!gBBf@jQiralEG5N#7`|Z9tRQ>!1 zRi=b~Si2EZWiE#3tLLUxJNmHD_l;AOJeH2fAy{gldjwlvE3PNKv~;;dz>n!{dCJ3y zQ~D)q1}G7Ul<=z_j)RGgd&7;__?TE^a^mA)6-E=2-$U(v{Vrx z8}eL{U6C!5GuZ^Tvp)KP&)n@{9Hu+G-J|V{40!vP|df zL|4$MkP554w?WxJ(?B<^zkT+5NYu*9NEeKrp<8k3Ro6dm=%?B+&tfQYY(Ln?eosQI z8gPC2{17g`@L0c!=M4cGZzzGzUC1_bvtWz;A6zLVxIuR%APZVp za!pT~On*xzs6mbQTZV7WI(dfm3DYMv#Px8{AF}+T4s(u%c}a?^pW|83$v>Xz*AM?m zwf!vJ{Kva$Q8$(Qmr~PNjedFU|K%3|cQVCz;oY*gZrkq&lfU|*Kc4hIFKFOA{qXwv z-qUZFIceUr{=4b^&#V9A5q^Gi3(uF{UdG|Hsk5(-{P@(3SI4H6R&R3hSpJuH7T`~t zcW*r`qy~b7-R$`n_442R6*#-m(b4D5GJ4QAq@jNl)y!RKd>HKu#qI_8$yWS1lK+Y1 z`^)uLQ+!jPApb#{@zVN#`_W&Ef9)jAQIG6{QCa`ZgZ%5Yi6J*hiifBFxvbP`zRAeo zInQ{{i!|Iz^Y8NfS3CRXb9aEI_To*&F;nh$H2+jsvNX)CtS<61-Sb-L{P3%;`S*YS z$C3o{G2Q$iZLO>OXFuZa3T;FGKCnzm;PLNTqv4=3ur?Vtn*3otPfkXE|FvmjD z0r=WMd~|A^;s-KKW481^(+7b`w}2q5bEqf$XO{kN{`Mat^6l{DCt9S#ziD{=@z(z( z*8lz#N96WT%~LH>!$eOw!#|UcZ-+0MM;9>3kFhXFnf}?B{LM}7WCu=)Z8y^9{XcvV z@PWX{$jG_FZ-3GwP8LO=ArN*CC8K=Ze?XaNRc@W4XYx{ z+hy7(Z7=@jCjR@501j6=Lt98qn%~z%@qf50z;{g3hJ28g1l|3o=CR0__I!kLzsiSN zHUIuJOccp`WLnU|2cXP7-6Q0Hq1hZ<+?MJQW_+f}FGs_j9>vJmvh zM$0*yL;uxE^f3K$T4ZiDpZFo*-?lGbyMA4_rlzJv%&zy5*%#i^Z=@!cOnYcB_AwS1 zmy;_zNyK69sqXG>gDm6mf+!53v;AW?4<-#USWO4vlpiB@|nyw7{eAn|eW<4`pn?_{jmPTV~=*;AD+W)J?)BZ^CUou8iv*rR*_OlewOr)#O&WsfyS-T!t)0&gxDD91_ry=7ASCCWv% zwAyMZpS7LHlW9-;^m6aV#vzDbGuDONzO7`t-FF45yjX@I2oxe%IHzd%7dpm_Jblz71H0*0rE}u)% zvzZ))KM>%5M|Ae}c2Om8_QtT!i|K!E;vpK3=#MG06^E-q)YV|L7{-KYa`eoy&8A71 zHfm>}Fn)Y|+;!MGL1}YT5Q|XE;Vg`SRUE}kt>XcNOQe6{gnOSlP9ILt=Ya&Z@-2#G z19_zo{zVatW(u>QU&?=^y#9YGIIv#n0zvSDE|K}c;?D)mx^p2YQ+&#UP7RK)49NB0 zMng}nJ&yN#^!?FsmwLxfk3)6R5Y~O}^M~!0+2Ytsd2llZ6U2(j<|-2^u@Y5(mUYNd z3?WcZ^=@|^yyq$jpgYkX7*m!h&mB$$7ztsF+8n%DAz~c?G(sBUlkL0Y3o@uaw3#kebe3!N#0mr3ib8}O1pc9=bD*8De zgWq0)^;eqqP~UHfJ5+Upc0Yk)Nk~$?@HhYxQjwh{<96WWO1QDO{Sd=;B>(8R)6Of( zK*Bvkr{&sog#ChLSB>B=Ph&`$cHOAXD-S@|{{QylcM$=z2Qn-moyU5aei?L~OIj>1 zq_~&!E62}U=vE>`Z8f%cXCY7k0nYMSn@&qp5*O{pOjweq$OO^#gyelvElTN1w@dL)i?paoyH0J8 z^OG%{BKMtJ{TmN?)FbK8kCh07#okoTDR1ll+>1je1mAAv?zN1RyxdgNUFqDciUL?q z?{a+%%Hn&xwnJRPqW7up_BkqI%k41n8BuaIsR29lIBT$I~-#Pw-N=hw|Cf%ySV5K*BiOVEwQt~{fcs}eM>$h(wt2LhlOBTE3n zY=(X#1OFyQmFE-2m5x`v#ys@x*jY@RZyj|&5%Qhl$SWvRDxA8FTP*Dn`W+Bo>1ev? zwcgH~U>PMVZg1FRkGk6F0VNJEXfA+gQygpf1|51V z4XT_U458Qm$1nuWrrjKWHdIuIBlIIp!IpobD*4}hXR-+3DUsL5KS}&>+}3CX4es>j zH)k@xH>x79Hqy851hJS6oo>u}gR$s4GwYkwYqgJuUVlpuyYjq(xz?)8kmLezN+m#c zG9UoW2KbcUZ!4b6_<&_MgaDj^fsdUYstMRB2SD6F7iVQtKn-!k4&5q8EN19H0;IEG z#CE5x5jPw8f$*Y=+W4z(h7BXrNaxkk+zC|2HG{>j+oer_%P)W)$T#w*|D%k>`wD$q{Ap_6WeL)L16#4 z25O7K!zeTuC37RQ2(z?@M*%FvBZU;BX=5$_sR)S|b3@{(3m!brek=u`T+$_ILR=6M zvFH9zzj`S6sQa=4^4sxWXHK1lIn{_g`DxszPoEh2Y|@)+nLkq3KzlC3RW6oCb<%8a z*_-MCJFJeeDNc1CcfW#n-v#I!5v1MyzMSJ5 zGaZX2*}~Vet+Da>_axS(Ywsp3RE_10Bl#zm?yBTQ;!o0_QsTg#sO4YzQiCP7RY1Ez z=g$e2l5Ltxj4K>wHTSkOJ8En(FF*yeo&=-=7E2tke$?Zzq(5_#NUvC5T{;`6{E=^qR$=9FGa-_@hSBV8;E5B=Tq+ z%Sw^kF!*I&|1!$HX%6RlqU8l2*4g@z9}^9IqEyNEwfshAc6N5E-*PoBZN=Z#x#$&U za%^9WuI^)s6zld+r($fEC*KHcxq7w(vD=O)TQqfTzl0-V9l>7X9{Ssx*$~s29t0E^ zR^PFY3MtrKPiPDXZmQS!UkhWbAN+g{D+Qd^4k#4|jW2{#@VK1Z0)7<17^Mf`hxDXy z_|-330_O|Jl|81RCU(+7x#9P>h1{+~QK>az;-nSNZa28ZsI!7g**J772zn*K%=eKb zz|K!$8_E|iV2gfXjsK!Re-%WTQ$bJcc1^_kY|JQ45J|Ie>MXhyLS0*@-&>`}4HO1= z_-v$1uunNBk)7Qq8LtA^0w`w++JS5vFk{Pavmnr1&3wblpiS+U_+mx!Y-&bjpq2C;vvocYS> zHa}V@2~RreSE--(f2WqdZS#J!HGBTvhgxmH_`~R4bD&{H7$r_ldt`@P$i)>>7Ydof z)OrAX@CPOq_krQEM>y4OmBy{-Pq zKGexoMrZ|(r{gNYO6M#eky{Ncv1)()Fn;OkYju|ratKkq*lFvX%q$kE)YThBc2BdZ z-`Aook1M?R-mL=GK7&5$`-okk!}jvSn)-D<#hP;BJs({tdrI9ew|;e(X|;TWkwgi4 z&mryEL?Cak91iXL_c^ReuHDX5KV>H#~Zn3#2lgG1jV@avyg$ zviCmvuT3Dey5R#p*4__`-0$98NZ8n;sOo`L7( z)4axgaEX?Y_aKd#!XhRGdp%B9Ds#Lj1dPfak+M&th(iS$CFbf|DvFoo zVawHjHfoq(X8Zs>;wD>AicA&Hv3qPbRc}A=$SeT{e&y5Ng(cK<17kh>B*}}VQ$WkL zts9b-L=n^5T^>RFWVgtmeU=SBirXX)|-yqA5*R! zBQE)l?f4Gb4RZ`qRATJv(_0awknSGO_Dc!FS@kkY*%1I4qM)48M zq0iB;wg%|%#tvWyD|t)5LxY?j7xp;{>^z=Ylv-N)JQT1VZ<)8Di7Cy4NpIk%x@DH0 zd`QPutFf8cnAOATWj5TB2&MUzc%N>sF=8EYDYcP+ylm{`1TC~Pn}^CyIFy+B`OH*e zNS=cLR&xZ80U8|A@isY?YrZhQ5+c)Z?kvW^8g6I?9VEAVB-oPk?AQ#KOMC-HZ2!7E z0xV|{vbm7|;<(y*Nz0%gI&Sd@?IibApcOcyP0FxgAvn!UO5Q$L@x5)jk8<@cLI@1s zBY|}KM;&F)aA;Esh8!Ql&@<|x%9ujW$h#PjwW(=EAJ99syl~GY&#NOI?O=%pni}S_ z5wC{^dmWCT8CBRh3HCj(7tRBx#B@T^l@W2KO*laXs z@^%d&mfjD~=#eq1mRx~PHHq(qr|lJZ)#tj4v+mBq%|KHw?qfhyc^JR%Knj}Hl(~my zkk_BtRH|aD^T~VGj0{kcX%Dz^ zgV2w0Dl|4pun=;>tT3ANkj|Pds8@0hKtw%L@<6x_Rj5fiUTC8xp#ZUz%0gl&$cwc$ zE5rgV$>a_wWdItt0@*U(?udi(A$D3d%NJ_Y2g~zKDo9Q8Nz2~M@fs+AKTwM)o&xv5MNOUO(Q&?zQ zUlV2Km9{xh5%XXqN7OAHnzVqGY%Q7CY{j_CzeFVOi@D#T0DEu&TSYe|Ivi4FlHlcpe8mCw2t2bQZ69y4k zj2*3T`|^f5h3q3Yn!s!hxPHdCHq1M0i~b>X`I|` zP+tYw1hXxv4DKsjZ1t#1CydHr92)V4%OY53&i{R=!+vbvpawLil-NZ~#_=~v)dKe*GiZkyqWq(2gYEu=ZD@R?6YE{6B*$w_4oixS^Mqj9u;H=sC zyq8X@``sy8Yv`x<0^HllPmhR}>~ze6SHhUDhO!-oSee%QdfX9qI;0SajXBMf}{?8%`HGz>T*=rijzemW$bRy0VB8IN8J7C3!b z{y@9RDaJ=;aF{w}X@ZhgC6p3}C#N^!kj%5PD&wLTdpf;L!`$mPIpl3sF0H#xXjCM4 zYy^C+5cR1?p&$kLZnTu+tY6S(-*$i`xK+2Z7%-SsdvnX{EN@?+ftH+>e)U@9gfn@x zfULa&anP(N1JCP>xKX%@=a^#-yj!xG-Z3N9n_Vt#7f#C1sWpw_CASyz>NsUn%I1mV z&_}%Z8kwd3>JjDcxWK@`Aw)MCXa+r*UA9E9w@i_wo;f>MT@wLKnyv9blz?WlyYf!ehJx7KyKYBWh(^xSps_{xog^$ch-8s z$6p#kUag67u&T!MN+jSsBhi*cFsw5hMjcw3mY`99Ck{h*mX=bSkkzT)lP_`mWFABh zGNJf6*N^tP|1}fUd4XnIVy+jp)BWa?sP50)9a5u#UUcJAHjGelm?xk}CV98P#Q1cP zySrx`;Ah_@)La}a+VWH+T#Ao-H#HrT(DD^OHYSUE7Y62IG4a0VGlqx1tD7ljY1&a@ zF=bbs#kL&dO+BYtX;?Zl>sHLrUGoy zIYnw=jPua857TRP0l)lwgO|b0juUFz&O@rDs{-p~OIBo7AB@A8O2~2DbPEf}O;0KN z_6J<{p0cQ2t!+GX&o_i|rZZ_{uhfh4iKp(s&ID(E2WWn6ORTL~}Is%vr7Xd`eVq4#k`E>W`1szqon&^(YaF5+g|`vCH2?G^j6E zKIraTtOo+yJ)4=-_Oz8e9UyO6Th?c=Xs+^BsI^NLmH6b?{_Ex*_l3ZC8yFBU&)U{6 z8*hbAhso+bhJuJ~z ze88ve2zWd_fnJrG71N)5e#q^~q0}4W7B{6~L)q#TCJL#LYp=n>U!Wd& z_{HMvYs^F+2q#&prN3ACEl5djDBBddrUW@o2)!!s7|bp}_^5|^6hlppYH4z+@1K>I z^*o>8awclT3JhAb3y=yfE0i?Pm1HMlZC_9%AE8M|dRjQ0aiGa>jC-r&#gs&Z660m41&F zs{aO9t3|L{w<5n};c=z_{A*=?Dy+L27kGs8gwyVG;ivSRsjY|R44@tZ18dGVnu@>s zl@DO9CgdRF_QYe@sK+%UdJX;b$^j-)Jb0vuBt4w%586VMB7AkROy;dY!+AFs= zNr&5Eb87P~>9ZhDF~PN^YNS+e3*XgTEY&Jp@|DFpEUmqP>{|4FZApx~>d-H-)4cUv zc+Zl~cx1VbT)-X{(3D8ENEz}1vdBXpIx10=B@ye6SpO^3=qUg97X~xu0wr7DP+!$7 z;Pij4(j0>J;ui}di<$E+E59W*mn1m}Mr+gcVAa#75O0TcT5HDjX@p)@8R)di|2DjJ z+v(`EVzTf8(Ss|oswbgFx3>f_B)qX$=5X8XJ8PK>eX^xB4%sz zqmuyhg4m0hjN*qWdY{ZDYd0|h<5QS}Tk_iVrbscN;wzjZR|smg$%u0nXz>bXZkq~g z75Io43sH<2dp^t|w58vO=2mlN`AE5qOmiTpMN2Tx@XC+US8mu;wMb z%4~5F65}_|J*MtQ;@`T|@r?Ua;Vn&{@1o zb5q~{ZAf0azcL1rK&XzY-!00gp7>B~WemBz!>-Rhq_t2nCR`yxh)F_dlMrXs!J$e4 z113?FCI2rf0SgtO2cczl0fge~VE>0gU7qXhMlS8~QWb^^$W>my=F2ET@lXNAQeYyF z?>x#Uye6&XRsBwzL~%P(vYY)qNlAP?Lwws3G1a-9vsk>Px;J{H#q&bDJi^yR&tbxE zV6^G3uv>bA0wFXN#{5#Und$P7&T3M?JSfcH)pXtIx8rhF**nui_-AX6oK_=zBR;y6 zK_S^GQziTcLmQKt$m@UlymX+)!%RKy0ZSr;6%)19gXIX|HA-r)30p~TTNrx&X zm1MC5D#2x1!EQS`VsgM}X{fY6saJ)(&|~BoTJIy++?o8&V0h3wuUx%8>~|~pW7+@X z&rMsJ9ceoXsgx{A-!t~R2)_X_ep7gLBp6oR4@y1_roZX)?XL3WL9bC46&vrqMtj+C zT;Mh^#YP8c+i}8edVi6g@ZX3l^v4O{q$jpDLbd1JfHdLE=f+Jgg%{VK=ak($7lSyc z{M-%DZI=q$V|Wwx^**0XL!@Uz!;!DlN2`GOXaj$=>*$df?o_YZjvbi^w%HFxZT3KegXo9B<}3pUUIU z50~YWq7L^2Fso&?4CPyQCJI|eh+*1_u3wgx(l@Y^j@Y%Jxop1wqJ$qn44jb*-VVo= zCi)#iUfE~{2M8ao>0e{@-^L{*Xf6`lf8&BxETsPi+VTTh#n+KDNb<74Glj%jWh(*{ z=-)+~xOK%DY7~E`2KQ|5P#6&;O$fz3vZww8wBtKk!fOKN1r+3{uSskv_9b@=f$9Vr z7-{FX!nP&fyH5>41?nENvY$%OLdF$C7Gb-uPaEngbjMeix}ZFO?kF6~+mJZ$?W<+0 zZQ)6GgykUH-Sr38gqcZ}`<0CEba(d$%^BJxS-bA)-8YjALYzG3RQz^(p2|D*(yux) zk$i|=MvK#T`ujSN{=%^UysIORb8+W7q5ku9qJu}XA1FdCrp$u<-T_rj(e1}FV=AYa zTb~U{YWTkm1H*+?mSxvk6Dn5~WQ8xdWj&v4;n=T)_^KQ zf*taVa`gb=CgFIA>faEW{Xxm30*hjV$a?;;6jBoC-gZYw`al) zryQ$s@vP6bRu7lY%0E2C)~(<2ZfU>z?0+nhqQnBt6GZK<&l_9LYJgyY$6P``DwZyW zvF2rIq$RgT)a(dnKq`icy10w|Z-ju#=JbSGcdN4l_EXe*WcVJqr}!lk?~Kb+mexQ# z%LKvlg!s@nz#bEXhkbs2Yw>b254;Z!AhWDLri{5rR)~$|w6M_&MG~g*T zT^xVZE7q|#iw@l-+vJxI@(Z+zb!?xA@#VTqRadA>jWGd*2VZq@I~nSbCTri;a?|{WKn-UaTz!ob>dG$b>ps#KJY@cf zj2_yW#uwCf0L7scoypPfu4oOxfff`?^d_mlkSQ__oCo&foX|2Z{??CncBf;7QEjeYO# z!IvZ6ZQYH&oZr6E_tFLo?2o}h2c!3yLghlNWfmVn%>xg2cw!wA=&k3U6@ua0=xVv8 zvOd>{n+Kwg&^N3V11XZ3L(tth8^z*&jJ?jcHwK$pNifcmzLqK@o)0&^Jvr1d6$LS) z0)SwxHIjD#)(6rxo3J$cwY9vY1 zKATMih&iO1Y*2fYmEUP9!VS5VcM(AJ$4bZj02mDEa|v$x#E<#8jS2|nK8$0k6<>~F z;70&iPszcjbV7+p2xVR~`<&+I>;61s?20HEC#$WB%{v^_8CCt6m)t_7a3xyWE5bLk z&4eFB%`r1S0<}J!3S3sO;$*N z|Mmz58E>&7!}2b2+a=u@T#YgF!x@}E0s*6TU0s_aDSOnFz3v&OfQ^)s%KcM&`-Ee= zB8TO*7j8h{6f_j*-4$?|40b!{XYV=clWnCbs|p1-brlavHr2V;g`{~BtK+X&D9F+& z%@)jrhg1jn&o_Q%Ik3;ck@bWVZlc+588#P>Pll!WZK>#X72i7AA-5FX=nL?Fs3B5Q z&P={HkGc?$;!x42T}9IzI)4hRDm%re_8l@z@n=kdKB zxai)ws%n2`twG|j*|q!kWIxXX!MDiawx;&-Rx;2EWK!igo9^f6f!=APhsYfM1ln#> zS(lB1y8((aH)5)ho#DJCsa-Qbg#7IZ2~tnG#_N4;5D3U)VcTApCaJ^UM;;wmd-4CX z({*&Tn-Zf3SDrf6UjK2r1Lf3jf}VEW((X~{MCvZA*#px+lZ4}MFlKOT+^oCVV#0Z9 z<64+_glT=i4jVA z!=TaM`t8@}Ts+LeVl2ZYHe836J3|*kjxhc< zd7iUnLn}#8E6?`IcRPD0V)iLo!9yeHNa>~PjV4y$i4jtxP4w!>4`riqNo&vEDV>LO zRNur!>G&tBU@ZwbMV;%dwql4qEB;Gl0Yu}O;dRLlacIFUtq#g>q|5!y7&qHTgQ@ZfH+4*&Jowhj?@@Ru9{x;LdHSB=Qz#OukS5%IxB9s3ER8$Qm!CeM;Es%eT(tE|da8*Z%Nx zTo#+7AbGLeixcF}DA6hz0z#CJ@^b^>j;vS>5!P#6(6^<1{b~tsZi3<2^ z2v*-}vu%k(!w>Wx&ia1Wm#<1+?IL$2wq2C<-JIL}BvZZIE`?@cVa#54;!oqvdwCMt zq#pmUWqLx!YVFd7Aclpt3I&i!;84*11i7PcA9RZE4Jufn{o5X@d)f(K7Pr5$uPk*Q zYOrl^RQ#vb6QjR*bf61j1Bx5f5uhf&ex7yCAy)Yemb?lsDE4!PTA&^fMl?GE* zyI3S1@$i7d4PKn$NN%-C_nEol8LDaNoPkb4eFddojU0}kkpJJNNAvyHrCa^ z+Z!9qjF&#w!sIVhEb!;fBsg>fUVDpXr|2mX>*?eLv)>^6;lWQL!e2?ZvN)x)ldKfB zUT|25B5(H(kHuM71Oz?-gVxXox6(zd?;e+Ie_H+NH?}4g@0{?8O`P1V&+08qha$R? z&oNeW834EsxqB~8aK45{)&LW9){!seLZ}Q23+XTAx=2dl*`AYRz|BP&p}$u5O-;tCJ9sC*uI5?okh&g=v+YoYjz(J#m}n?C@nw8`7* z6i)U2&85=SsLZ^8C?@!GorDYIm$*)OPd_RBMCYR8E^|hN zd`(xHkZM!92PrO*C>uY5(s@xI?>-aM2` zWn$N{_%^x&kOw6RZ=@4`k5(3jCXayhO+EC?EOQh#_hJ~B`w(z_2A83UYkQO|Mw!ztNg(Ytiux>qRLr){j4&sYQ>DqeFEe@_2Hdb-{CRYnLs$vwG(>EtLv@DSj`A1X+F(QzN=?fCjgo@?s|T;d#5RR|w&)qg4gN zyv*YXL&~o-Us(FTUQ65vQyWKwkZx{UE&U3){&ci{{!@#w=ysNGQ2ns3 z=0HN8K0E~RHZxyL@lc4t6&dr2+oUlmexlv)C;SWM*y{G3j}vr7n)PxzjIX%2rb{^v z8rbyZhj)|!Va#V!MO*)6?jps`X4_oEqKB}&C8Lt3EN(zsa7J#ba>zBT^VLg8%Pi(7;6oRQT(g(r zu@j(aj1V8cvGg(~D4?l9_C%0F=yIBF56klaMu^hHX;yPSwqV3k`}Ik%BXr;RQN zaL7A7X0wM@!{&`AMRgtzHX8st3(_fzpr2H<3VU)--c0dns*@GS+7T!{*>L4L+BQYj zM+WckESmDt_E}jfUFb~cWT3xH4qWola5()`wL9UYT-mK^V9KKAn#in(@c!3Q{>@IWxF-MJ=IrS%EK{10ZIoeq4oCj zT;g)SJqK9xRa?qg&(4?17MzUi8PJECwuMI|o_Q-o-0G`3`iy?7r)PHUyKigZ^L!8E zh52lJ`c&jY4wZ9} z5ND@wAX1C7ST1Pe*hFeVNKAVt8hIRb<1?8_E+mPR-kYH^E53rT`Ve$=iaTBJyZ5VV zeGAv-6P7b~fgzna5hMhF^WkRYF`7Y#0%c%7GwGpkI+$yQ!4fa=&*a%QvQN2)w^9HW zE{#*+N|2FqbjP`3`~nR0^a1O`7@o$k_&Iw9PNvO9fy5;alqI8C=FlHw=FHJ;FC(E zdjM(X!xlm2E$W;KWQ1WZ+Ay}5uQOsXqeQ97dp6DMzQgxQ7k_TYVJMtJ;5*j>HdOEe zgyqcqOf`dYQ^bu#vDOPgpIqclggB(JoIbuzcPzx6?>*={_=yj>lU7If?e?QYG!5 zWBwmyZvhq6y8jOwC@3flC5?axNP~1qDF1 zEV8xxigc_$G*y%houOTF@4JLIFFq-A?``MS@3>;?3EHi>cB2f3N~Dzub(>W6%k@B& zeQ>&%Dm@<#{qByM({=F4T6GVoq6t3WtnK4Aj5H>i`=B4h?7Hk(U4qnJRBTQNcGfPY zy4!}446Gki>Rdnp8D3QQtxrSMp+D=v7xq9xUpu_xvm@LFH^m~mNh}OSn=4wub%zi= zL577^yN0Iq68rz&MlDg&C^R9VbbzKTN@ONF0-#p_fOnZO-!LiGG4+kVTTVS@^F4>1n%>R z)pb^cHr!VGSN9|)9PP(}+j)Xhm-i#MT&cZMawbqrS37GKLC->q>L0~UE>O29Q{p+U z-iaRVVCXj7wdfPb&Tm3E?Wsz`hbwW_7PzGIt7hMCNJTutqwA|%LHqot7QlwtBF$Wl zeQ5E6IRSwY7ikmvv=^1ObAyWSG&$JzB`^{bN$D0g{g3nlnANlhudPObKJ0Yt8cu3n zV?H}~tI0uNOm-l-klxixvf~w1m_JB`cyW}~%WZvf zEa$zY?B$e=$zj#S^zEufsoqu?4d1lyXyKNcP`btso=Myf0!oEDfw_E8db_qP1e(f3 zR47QEY_nLh!&gYpXF!s3F8>m!v4r%F?ysLJcX=tdM}xu~qbh~NY%6K>GMQufl{IwF z5)?@iaNk&M!UMI^%6)eU;kWOWNBb2u;d7#|p2nM(TJl^S4q0C;M{6$Yo6Cf;`!h<@O)sm0>u#^ItALGkKI-b~D|(Oz^7cKj4s9cMu&7lFF|Y!@7j^cI#$;^(pF@?#jQXO^!P zROBWDORnbctofU8T?%k)cMh`Jsu`nC*^X(^^yAGcPcHCNyn`=IFA=GNgE)v}K+Ou- z2YxXy%UDR%D`%7rGyTip64desv;lz{)fh0Bu5M;{G|E1d9x|&_-zh;HZBkg1#edRM z2H9Y&l7b`aV}t?*GL!T4gc8YyNlvvgv6m9TmaAL#5mXQP_~*E=&~Y$<4eYl5S5h{k z53&332!1Cq(l8KjwOaHHjREzS1TMYw55wTnQeQIO2ZOICu&Z_UG(!fO&uLx^G%&$@euh%Ud5g%%(;!JrQHh3 zEOD%sSWO+Z$AJUDDU>SfAy0Lpc zS2fi2Ww{7s@9XGk*N@UNKf|(@Och#Cg0eiE0=Em!Vi{P!w%#s%JI3Ww+YR(60S7s$ zZ+$gwrMoQ&Wkc-h>@;n%ZVSq!CUAXdseKvT;1D}q(X`Lj|0UO*8IUOcN|$9Y$Ljr6 zCe|P_%_HH^J|3#9L*2_%3jWnOJpC^QuKs>@rm8E+Ziv9tOyws%`>WeX0 z&aIY*1Cr52E?Xwc0EXSf5g;&p+C!EMd;Fch38Qil;4q8W6|&D-1HXROEWb-HZjw{)P6Pflth_cLbHIez-ajipECa5vhFtaX3BPjuEv!0d>U zeEfZUg*k?PfRF%7mh2W$`mDA~j$#8@=wkfVN$qa2YDw|R7giy^hKudG6G50nAe&|i zLn^wZf44JfSv&i$a$}>&$Ns(*k8eTAwX5uSpj_9H<>`UFtNnGpNjXx=p-5EpED>dI1vC!wpLBH@U<_FCn>l!jy-;3yW8= zN~o4g3w6i4ndN04C_c?_+MGmv0)CBg*x%S7NT6JTfl;D%f4U`#o0d)=y{U%OcKkil z;fNj4uoi)$9@pl$+?3YbW*+U+_3^?fs_k?ZOn9L~n?_62V!NOzElgi2C;W$;?Lyqe zK!}LU!rNqY7dgJIawK0bQc+HT!@Ih??87FAkn+W}bFvHD-35u_4%X(o?j;L(7m)m( z6ymRA;uSr&_a4VuO^ibQ3{LPEPXwow)(&h zwyV16>jXCglP-Ruh_?2!D-1}`HvQ^r?QiY90kgwf#|>ZkC~zPas%`ss#9XD}|7y@5rFwgHTdr_D5iVR4Xy!2$2-& z`d2Xj48Btk*Z&{FU!CMJkZA|?&b&KqfOOBK1 z#8LKua83c*B8faP%*n#`>{_>zS{|ljKV$bh_(uH0V7hQ&H30LiBz`Mu+g zt~$V>4|08&Xn8Z2x|A|R+Vxaosj4GKJe<*WUTQ^-ZP_4typLj{GYG304ppsolPY|b zALne+6A7u?s${+{`5L@D3Xm_67ST2Pa>`jZKB>rY&wF8+?|hng*&ax!xLtoB>%&BB zS}csLcT8d(zpCaIShNC!i32R7V7>%sCW9HBjtNxF{HWY=uz-V)TkG6n^{kxTHf|%V zkued1ACiS_X7~&fsYbrL97LXOtb*j%`6<$$Z^0zAUDq<%K5ek9Nb$9ct%gDQfrg%O zlZt-*JL3<2_gkZhE$S^N9Kv6B-9DL1P6IrW*W~C*1=n-ZyHEkVwn)}S8@@V2`%8*N!*r~=_3|vO163f+ElaEgx^BVV2}83NPodjWm*h7ixb2hEuFvY!d}d*j;uLKQXU3NQbh4S zB5<<_U}Otm+6f2PL?PWkM=#bx#jBRstXw{ph9EpL0sIQ%gAmZN+&>+5S-!C+LYnX- z338QnlBEXa@hzUO*Nf+9(ULk3anU5sA5t!8n$*!sw&vaa@8}HnTMcsD=M(t?n!oZE zv|Ic}!?!gHF7C&y_-BqZfDR~lXdVh*LYmEZi*K+(G=WB|W3o@jgFR*Vkf`&tg_i@aHq7uIkK4NjF;XWkQ>WBF4$HYq7VC4HvDCODJ4z`Y9;L z7fTEF-f392P4A>Ji6AD7g+i(cX)oIoVnw_Bz(H?fYnP)nnO4X}nrE-HlN{ecBm)mS zW27CygT%|F(#-uae0;TCF$2;*q$kd>J(1!GLa+~yOgO!!Dw@|>tH#}O@w@Y|kB++1 zjhm7X}^7UAmAM-e1j zPU(^0FLW$6#{o{_D!4Qz!Gx=Ua4@t_^tdf)540}_7rR*ffB|Pr*n|hHK_9n*S*;Q-X2=6$x`9SJ2k%L6G&EF--9K_(+vfHisFn zeGJ!BITD?bNhMZ6=(hRQ^HY0#(5$1C?0%zY-T8;Y-5nqJ5o%Ek;`(9{FH^v4tys-G z77|WQqN)=Iod~(MMuTrIXPY#VRkgMNnA6s#w=iEfz~Lm4)P9moMCI8}trQJ^D2}{t+s<13G<7<%8r8UwClSdzZob*%seR zPuRtqB7D8y%|wsv_Mg8skls{uz-N8zB>p5I2Ww6hYhnZ%8U$#Z(>+7~LU2V#H{8wG z;&&#NpHE05ZgA=+zQ8VHZMxEN=_Fo6l+5#)_N)H=;WUMkp0tp(zb(qf1#Ti!|&e0gD(uS!HH3cff63|g7%cSWcNH*BB5 z(eCN?jDPic&^?L-3#DQiQ)Ub#r6l9d&tzg*FrK)1Ft}wE+3O zwg}S$_;6x){Tzb=+pp0CU!M+p3V@8+*0~YCjAdU0Y|= zer|D3f``jCSm2WGa!RYdpnr8fJ-*EueUj z6zkLZw97sF}IS8|B%U@)19L(5vI+fKP_eo{|J(_TxFx1kQ zgwqIiNL)#f!~+4GA@Aa6Woqt_J-Ok9FPK2R`6o){x0unCCY%_ASZ-7J?&Yr<@85qcg&Zd|1|*EH$9Oy;;ot~V zAR(lBMF6lWHPAml9Cs3=BQ>1q3LxRl075;2>*Dd_I)>IN04Vo?kSMJXu$rJL6CI3p zl_a%&TLVB@x)r1um68CMij){ZBcaLe_b-tBrOpRm2*8wxG-h#+=4ro2_qMTAKiQTt zz1+aq)8P}E3VzJ!1%~xv)q&WpU#g=(INXWM{1w5EONUNdX<5|^)0>V<#yIV;mvV#c zEb}ZDfsu-^n{ZrBEK4LnVBKnOns!iBweIEUzJ<;ZN@%>(1Te9?p5;aKZ&s1dlwnTq zby@*ds0I?>ka#F@D9Kk`0A6~~_K1-8E4SlIf|$Mq@jZjOXW$11=d5yqAoc^^#X$Bh zB)$mOC-|N`ryW{QQSJKY!;f14utp_Hor&#+20up7sT+w>u z(m~-HA#{D#?-)$_Xu*0mI_4nehv$pvGqy*CH1KBER{n$MB3?qQ9|}G&dCQNCIu{K1 zp3=hNt?R~oHXs0$ExFI(@W;MHJ*pK{R_N-m-1}b3CBp#bO+iZQOA^=9`A(Z?O*=aD z_Osrjn+Ik60yJ}yeL(%Q6i#HaH0_52ZeQ}6H zE<)gQM?9GSb^bJ?d}=;yDkOsa3jY69i$a#8ny5ei_^g`m&AqV@z+Bj>5r&8VWfTdG zhByZ%OGg_?x3EJWBjSJrUhxzzS3)I56;G|oKlWhYhk4!BJ7jL;@j_N)Y|5fZpsg7I z4+0K&v~w4;Ckpx!ql)kccxv}&VfTnzy%e)$d&i%6iDpcAXyTsh2B-uhZnuzG8fbRp zo`T9USt{%?7SbND=IAMCFPR30SZorNuo_vonXZM%nW}pzonzv#n&W>RNaWlEl~SU3 z-ShyjoUeN+cLO>1l+M$;hv4Zpd~N=%wRR`~WKc_TTF>2I3YW{$hEqM&(=yJC>;gtx zJb&a24pWccS+RjJuHR{k@A{sv8AsDIFss?Fgh~&mb<~5#98oWsxg{)<1p&Z>iS3;p z)#55JQ=>~3h{+GZ@t@k6&siS@3z_1gQ<|H8b*%6bFk6pL>zK8^A#~8krL77iuLsj$ z60yjH=tE&Ism{&RA&x-RF=cHV^Adb6J&1Er!^tzGf8c!F(&b|ljs-P`S5QV-Sar=^ z9kUO{qICN z8kcWkZa2zLJf|oJr4jFwDLzE9u0%= zfS2J1KjBe-U+VvMj0i&6-zm73aI^a}aQ{aeoh2UoMW}alGG2>?i z75TCtLQuuAL450Vvz`0%sv93Wo)FqHc=}+-s-qZ-`aF1jeVG10eYCvOJ{YG2OU{Mj zuBox1d23Kg0R?yP?)SS?`1hwI&mj}C<>B(oT0?NUg5*VPN`h$Mu#^a!(=(&WGDYm&=2Zq|0)T_mic<(a< zp_T4Sv~cRmk;1d?U`LCFEl!9ZYx4NOvdGTLRt6Ic(XWYtL4OUH2otD z8{G-rY|?N(S5!>WPu3#@8--0;JP0rHr87~Xm9~<0E}3x}pyD6^ZeyzC@kyXpEdq~B zN%UMhZ=Q6=nG{7{@o00~oz6o$2)<^yCZT!@s&X50C*$U!q@Z*kYgqSd%AT)vE^kP> zQWdc~G-KE~x}e0%2QaDyUlyfz#WwNh^$7ICOnrSFtoqD$=N}wY%O>qh9OJPEU)Up7 z>z1uiH?!l8W~k-xcu%*6Tva6#(t4O z`y9SJj=lOFE1^+y02(XA@V2_J194Gz(9gW#^5KfVogki@n>prsa-%TZtW{c|MQ5e6 zqI_*7o}-WAe89IlTEi)K$>xWy_G_{PX$|t$?wWK*Jxs%`4X00umzI63hz@alrj7h6 zbFIam>!4i4L1sH?<~kXk^QrcT{_X1;{;D=V*9$kqM=Q9hp@9Z(Ar6gPmxIH#F!T!G zg0YC7W3RRNof!4S>xOMY zh4=*fkNsl?{DJ2G*O_l_XwMNVL_%(32ZPegkQJ1xo0Zo(yT6*|D^G4gtFPG6t;ak~ z*$}7JA7pun`Txox+}*ryoJgQey^TpR>x_b?<(4wNXU2F2|_q!mC9Ur8GAnmP~Q?O3j@u6T^IoId!4g`LbmFq3X~#QMy8O@!-7}iPqiv^ncdzSYE*9MiR$U|vN zM3Sr842Tn-l=2$*hX@u^eqF|qcV^Nerus;qyZ_@NfJJ3#^DT7T zl|Yqn1tH0CpPT4YrwcnUeG`M;vr~6WXbJ-vVIo~C#l<;mY7j!-0cp3l4`8ugtyIIt z*F;Tw+ocxi%`fYfzEryAk7tuN(rx09x5t`zo)8yO_p$r;c~7so3_!V=wH@ZY^~K;O zkgPT#@aW<&iNOl0TQ6&AQHgOoO}jTYjZ92vpky$Kszeac$cJ3kj_te?3R06oA%Z5L z$>V71Q1r&fQYFXYiuX$iH^*JSpq1)%vq?BPx=ed8Y5YXaBUSydgmyk2^Zmt&-vthT zwzzueP)_6N+%A@PcFyBX1H5akyXKGhf1=J0Kkg{d3Bg=x5<&T!Z_t5AZY9x#^y5kNa*Mwl(!_WD7WaskT{vh)6V}`RdF{} zWyIHMQq{l~TD%b;e(;g{j%%+s=Srb?`vX4K!Q&ooqd=#o*a@zS79Uo+1+A)7EkUIs z4l@g7+-PX`qpR^1qLI^DvgLyi^7@h|hb?ZXN%Gk|qu}rdCZ@Z+wcEhS4xbo< zr-NqO>Ek2XjeB3AIfl6xks(eE@KLqneR}nE&~3J0Q>*l2%4=Kg1rcIO%~D&1V)>qN zqk1Q8u}bWRgG|KdL&}w>qnthMS@4wwoDyD7p7n;p9WWCe4wWsk;-X&;{9 z@j-Kg8*zh_w_b+R{!M?ta6Mo4WeIF6ZTP9R}jQTh!{g2;j-hBN6ljJ&< zgMGz)2?nA%pI(X=3a==#O-3KT8T)g-H8;6`^b$F*H_^H#_9;gHU76=+d+MM6Iw+&< zLc=recI5<+jSHoP~>@eT-v+)y2wjUljv2JJn*B0_` zJIcR(%My=8z!KlO#_}sgPniQbzx^wM#&?rykmoi8EjcfU7>|;MptuZzJHZE9h}VVHi$42+lRs|KmNd zpkjrxyo$Y3S6gfK4rZGuZ~u_HIq4_-vsoQG$Bp8SW#RGB#Ay`i%q{|ylV`s%wWBRY zHu2xUaeqC;a}4*JE>E#h|AL(EKD`CA02hXS8T(XjnabMh91Y!{*WwHM7OgZn zIoTyeU+{KvYHGEZ(24i|I1sFPZzehOrf(;>MhFP~eCZRPZV;kS!^Km)UPkj)Myh_O zOo;LPMagnk#p-a&ICiD*?iaupyv;b6c&FS=bV!}%OJ*hwV$s$q*TT?Dx_&fzEvsx> zx$Ge8-XY$t-c@I9zi!LF9_D{K=xIb@OE2_#A>)af%JkRu?+Ce{8zMQsR|wQ4w7;H_ zfyu4NCH&WBM?0?f%qz?XB>LFw(mXzI7aR6+xg0(6B$OFKBsN^;UCqYYtH(C0<8)Zt zNoS@1^MGQugtjT#j&sJ~*JB;6{|P0l(>sGS&LehN(R?K>+?)~%j3`Cjz)GY3 z3OmoSDnkSGs_-pppN~+uPnyeMdAw&VLE*#Z@6B(!g6R-OvC4cy#GDj zE=^Dp|krEc?mgy8&U z*^aSB^IBkusA6OsA-~%}6nATO_MQ&|7Xm$gR;+d=Msh09@#QKBQPL~7$KTZW+Vc(? zlLYK*U)n!qc;}#G^J-mJE^{PDw*y{(oSF+KDXSL!DIL~gh7WJJDPH9Q$FC@BF=#&46!20`c^KZw^ z>Y$vBP=;X_dZ|LLe{S2z$L7uxd_zIq%ddb!BQP*6S3EE}YJ1$2QM26Z6PKDuGC^gk zc>;EATW(1Jl(lhGEFj=sPZY{5^q5k+XaxS$N~18qu61*dTv=RQt&KymDv|LXx6%K4 zL+&S@AH8dLP;Enu|Mg)0nmjwK?oy#S45>=y(-E|lc4AKiC?riolu_zSrU<1oh7*Za zoJpJvV#j&i*6!c5z+BLJgcH+?mcidpL0|f^{cJ{nrS*qd$z!n{|4jBMsfbzZRR?K9 zIYQ2qGK@`V?&YOde}u55<_F<&)hxbvQd{4jJcWOb<-aZlIB@X=F4kpLg&X@f_vN5` z@2%bpAv;WBeVU}qDd=^1eS_0coqVZHM?UT8O@&3F~G0pR<2O;}&Bq zMYhu{%WB^s`!Dp+Ki~7qEynXHY*k@(MH_+%OK=Fa6>NpbmI-^-Cy)Y6_pMC(%MY9mrC?O6Nkxx8f>%f#|UWs-GmLXvcz!p zU%SA6JZR>l`5suilMu=Hn;X%oe8Q66%5qF;ZIoVk=bpKoKX$Cfb8kD=jDUg;d}+c- zfyTpDJ88L-LqUStm}XOv6q?rR8B2tMTAk>}_*FOR;jQi9N~|?I-zQagmga``gtMbc zcO&mXbjA;S*$boKM;nIi^k-(%M*weXPDZZ4rV)mde{{Kef=NH)EhM`y{|K6%{pIJHm8p`aS*5 zM{pT+>BhQNY!q2U?Z1MYzt4|<9Phe4eqn|6=R^Myjna|SU8SuQaGQtKkqiFr_EDX z--?{d>?CHY+?Nf0tppAhXpe8lyb|7tZHXEGf%nkH7p=UafWxM)!KVEAg zlkX0vSxI3AXFlhdG-z2cdv*b6YPH2v1|xQmOsEfePt@c04G27?^Y zjAz96%+%Evt4G%7{L{~K6J2rPI(0Rgb}kUjWUx#8Y23+$R8R0%WO#l~?S7eG)L*Zh zpkB0Jd9TME#eo0FaM~nkJ6}D%py6+O&p+PO-xfTJr#h+I@v+tLU(x<7$CfJHkT z7E#k%Vd@(Cvx|xc#8hU}c(JFB^k}7)ZncyLk*`0PH;l#Z-k_L(#1hQ47>8W11oOJ) zI2oy61ltBGC=$LE+9MWBe|W9GVP@RwWsS$XoD&IHAzwiOYWef2(a|WA55=0yFyoTc zoCjg+O$tvA+YGfIt+5H6(ys7_q|4SLBG2kV{&xiS_tmGT5SjUCh9oThOaWqg@4YNO zTS*>+=)s8T-OcqO)K_Mgb9mRb?H(PKG?}$S3$|8{x(kcTB{bW#i`s5khvhP_d;DpgYEL(6Ar8l%G;=r-4?VFjCc2dQ1(`0DgC@W>tf(5L|QY_go~!}n%=3SFkO;o6rKO!);C{r6Kg zcRM-J#TWZNTWA~M++HXQu;D0b#7YPMTV(b>zIC?`|7e4azup)hu{HJQVElB$V_fA) zk6GyuD#wTbBkA6`gyVW7YWId>G99y=L#a$0Z==kXQ$=~D9MhJy9Qv27CAI>by3xUb z8Kuc7Do^v%Bo;Hhnho;)NYqJ$mJg2?AflLAO2U6+22R&&*PpI7NNID6pkW-MQcr!(8e zkmlw}HrvUi&54;&02vj`R^zM}dpN1Me0I+)v%qAQ&2}Ip0xz0G{w~~&N22JZ{mQK_ z2_0i~am}K<_vcf2Dz-K@UM#f@tS+rNy?Sc*9M{ragC4R^G)uTV85A!L_+e`Y+F(Of z!Q$dqnO(ay^l1HE-u3rU{BOrB>Yz}GR*{*B^V^(a7;=jzvK2n|;E*4Nn;wj^;2b-zZezA79}G zN9v;^Y2V#IEv*X>x;a6htjPZ+%K5is?eE`y7kKh()mj;~>)-*!wy~l*y3^qU4E^ zM`@EuNrkwRcJ|-!ubf*N>p04`D#e<%JB%j1KR?ozpTl05{cKcE017jYAcGqfRMN-0 zT`55$??t)zZ}Y9k>Vk@A0R^QR{=;vg5Wtri@-0OeDUrsA#K5VV6jXHS1u#ZgD_2fB zT{}e(NrFeVaM8{DL8IC$Z>q>{BM4W4UVL9F(27t(ED9mgK^891 z3IEK6oUz4#%rk;@lAN+oi_MLrqtjuZ0=S}c!|TTKBvoSDXK|j#@nfY0?IWg(F29NH z|KoDye&YKvf_JC?2ic$W(R0R|Vh#*2D?04uTry!jhM?9;{iXu16f`?&dzi7P&D78g zA>~YAv3Lftk|vBHS29KY9&;UgnfXI(PR+~;da;%Q2Xx){OceZ^L5o``?vanq6^Onl z)m4nu?!F;3TqeFrCBD#s6wD@ZIUHCivL+ySZ6 z97HeTLt4pr6zP<|_g3u#aF^^Y>CtgoDhAHpTYXOCXNu5qlMA5X)NQs-=61OUAK!n) zkLFP#nIa=U2S8Pp1-I6vh5X_A$!3236Il<*{k0Lpk|rbIqO+XW_3JWkX`DyP^8|0_ zwfXb|7LEK_tsYx^bp8Q|Kw+8F8UX9l^@=7j!a5OPN_9yJoe!|t{P<>*dsdc?I4W5} z)h>moA1IO+K$yvUFNWDIdUMI>r`Q+S*vW_GVcur==k&p~ZX>!y)oYTmu{);!9kToH zt7-EEU;{97@}L3r=l=hr2?c9p57fi!RXf;FIxwiDYpfe@cPy$x?4h<nt8J- zA;h+37g2~cm&Ni(*!;dNXw&;+G()Za%ma0{WgpWrn@lEfP3jYYRpr12T!Vo-{tIrH*|7RhP|j1lSYF z-c0vO$)|o9@rISOU0So1mUwhBN>f0v&HKT^Yq6I7^i%3MF{96owL-z$^a?WYU&+P=@m1OudYIa6I;Km!wZ za@4FOSv9vm1cAH0dw`TQI+;k~ceJY6EbZZjKZQj;RsM{!-NWXLj?S z=9&XJvX~?I;w1EE_G0i z;S&#+ooKQ0Jw^IdbCb1J&ij>#6zgVRa^yqTrQS@a`(d$)d9vfS7`^Q+X7^M*2uAV5 zCLC?^_9b_9s9DR3?>=Stvz&Zhn9md6fLsfgZwlM6G>DM_ghu~#w96pifjFy@^N=?6{z(0EPW;o9#PQ)-4LOGLZp^Js zMI&N>z+|4sWIwwg&e@v6trBi~1{9jjSe6_#0mc)=m(;EUtZeTq;HokfP+jJO8BncD zW4E4HvBKy!4DN1RDRc>9)K*(ecfBkQX9SP6zafW*xs4KuCX8rXekMo!D@Xi&o$K#g zS2N12+byN{btgg(CI8&puL7HG+>O8ss%_}|)9(lDUY?3^t!$;142=|;=)<#;@CxlbMNpb5N?jb zFLFF!p_CL0nbBHm$R#e&qH4yD{t;l7q6!G64c%p>6yE?yaYd$pED&97gps_S_)50r zZqTWMhd9(o{dlsylF2nAiE@ByYIq>T+mhB+NRu=ti>gmnY-rDog^W8z;Mu~DHdl4& zLv-RwWAbnKb0KMsk>H7;al`mb@Ot^C=L2+C^{E}t28HJd$@Ql!vO$mezd zfl-h}o3N1SczYGJ^WEY=FzLm9d=DWNx6+t)D`DV%wRPa)wLXza%omOc39}?jR@BS+ z_%x0wqG+=}6Y~Ew$^LQPy-^sbrC-1;Kj8j39MKhnD8ULtNr@sv$2});%nKycC5JB#E$0dfa#}z070dMu%nk zcK!)Hecm@T#E|$ckx?|Xih6+Sz6>lf=I81F<^pL=0Af`Zk$FZfI8w_bD7N`hKpcUT zY>WmVwZ%x<${!&KK+C5GR#_`mVAv1cRfj}E5&*UU(}uDtB+>&d#|{&SS11B&O1WZetwF$?H-vgr=n1Qg5v>6a>g+Y7u#S~z_l%UWK4ysZVoqXC08*! z3W#kAdYYPVS@LHa9^xe@(}iZ@r22qYNb^Omxa(iZDvWNj}2WcdJoP#o@INm!0(J4spH_E$6bXxQyH?;7(ueJc{`GkdMLa zN1k=MAZL593&4-)9xnk8C4o`L?s(8&*|-kCn7O~;IZyIfa5qT=lf+)(<*gv)7M2P0 zUy`vK$f!~U&$D$iuzPA3Hbt#RJUf}SmP^HKx0rQY?LAQ5`*A5ejbX3Xv>c`0HPjDK zu!Ud{V8!bhH&*UB;?d94{=Y0JfBnoX7tn}pwD=)V{44iIare0q#ZVea07^Cgag*Ks z88Jm3!|jEpO3r6g0p>HBQQ?1T0Vo@;1#VW)2%r&wg<(^@aSSDqIjrpKKGh`vH1LS> zv^T$wRm@&apOf@C?d3bl?_A`lKs!47{+qDQN>Jr9gVHD!EX(x!dYym)SuOxCqNn3W z#=_m-zj#{LnNtl=EJ&W4FcKewM#%tdEfTZSZMTk9N7)70>~}pbt9dMznPk_RgWRgk z$pHyhx^Lo3ugKwIHm@cC-EwxYiHqrEvG9+JimxFZJdZD1nY~$M`S)6Qosq!w#6?JWF`P5NpvV3np64^##NavKC{1~>9}OZIpCK@ z6HjV4PT~ET*t}X7N$F;%Isnv+KY??KrWZY{V~JOZ(^N)`gSG%{q?SYoS>jBGVBojo zJngIZ_rZI))+o1w$B?@r5Uxiy=8AsrIXeX|%3uZ{p@pq-wwnQ57Di+$P)c5@KeU{H z)TJyXx5Z7A`yeJGv*w3r{mS!^Hm-y!+JX!fYCSvSywebA#=JVc_y3f3H($s;=D^!= z#(ntbwsy{OQ!I%AO$=0iY!DFid33x$O%y}zZsc(#MP?>DN-ag-hD3xk{tQ_q=%B*e z!Tx3JD38V3Iw7uY;skze!{C5leuyi@bkMb$IzEHu?P$jEZVy&14m~O3MQ@H<7@If`ZIF&)FIy z^8r@xXx%pe&Mk>hK6XgcekSs&L#qx@K3lRg&<#Q)QZNDdtrARv@uaQR=4oK9>F@E9 zz5ozw?CD#f6ryZpOcToFA^BBEy`!_49arUdYQ~v|p102jePs$SwyLy+Rcd=Vry>Bi zJ#NBJn$dxENM+(~0RKGJAtjS+DHkmLg{|bPF;YM{sRKLEAyN<)lsiYI*NyFhRl8z| zk$N_Tw<0l6b3+JacENgA z|FzE@exzC|?oAV!QIdY(#yGrZ#*wPJvEtF3)!#!J{XE!nuA*3 zH%HQzO)mgVPM^BLNiEVUAZ6uqX(p9_zF^j;4G-}QuiRZZ#iB`@1~@(SNY<#a^gF{s znn->jkFfuzq4;8sB2;dlTIMlB>y3XE2Y1+R+Q81jx+P#-)-+z>VHC=l)F{=AsZm=U zN4urX(Dd$8Lxg;mosEQx`r7&?+Kiz-*vyPMJli(1Z{K}?`V_BgxKO+WqCdE`&U9N1 zRf%$jAd9{=*yvO%@p-X^jV+A7j$DL`DEaEP)x7IRjl|ovbF>E!6V#5Ujf2?u;j0D| zA}|rY>jsyBE3a+V@o1H({2VkoMHrEuyr~LSu#`@0I!NwN6yp^i}z_A_4H5ne(PkE})_-I%zv+(ai>yz2E?Gv!Q9y-urX;86`H zvwUS)Qg7;3$?CFSuxHdSKgz!1_o^kA1ymDt=dU~$L(mHoA%{O$moviUn2>7uaEXK} zAKK#sRH)#c+}2JgF3y&|gxN4^>labq2qY{yU$i)o+Zqs*R(mLT`aORLeZo3SEq%H$ zk*w1vuntXfzO0?hc}b%qN+xw15G1D6k4xmX)Zs)o?{) z-H0ll0Rsf0!WPe$B6gXtfUB8A{UJ$OG^{A-_TB9d&s4fmh`BTLeZK^GF9k`)R=yTN zx)v{G`evqE^%L2R?NY#eHBxx%HV%MB z3==cp5EjGcxRAldAl4{JZf!I1exm?bRKXNE%Nhz4>MR;}ACjmvUd-%deT?PoJy>bF z%!KN=W7W13=-0Kmt(FIu0;yUQK(?<4B;omlo);=ln0kP;(%r9{OHw|baZWCMQ!c=2 zXDUk4qf)ok=*(fmELnQ+IS@#9SJ;l_B}tV4xsPam^8L&ERC>zuM7i3cpHBe5e9AS} zw~O*w^zwHNFMnL+BV4CUsnq|3F8#TT{ zTCQx_HCnTR+f?b>->i0ovLc+xOzG?ezNwKB@+q5EMLO$U%=%7tiGoohoe={H+XzX+ zn&acuE?av6P4jAdjhgj7-!3J4{OMrmu`@)}#2kGlHd3q$RXa>Ng=? z?5#7zu0x+K(?NPlm7Rxly7PdAY1EWH)<{seSnc>{S;2MN@+-jAm$9G!O6L4>w5m~Q zol>*481S>8NAgs<0IFUz<=l1uZ0wEqN=jR9Gax2*Ho99LI*;$udQ+z9ai?TOsja-> z$zD-*4eA4gM{*}p48q@KjYfMIUxl-Q3~?>---Z(6h7vN_U+&B~?KtshF{kI8w3Kbt zUnDdD62V(@JP9W@{nld*KoVrV(EEf*{7^j+rEa)xYqY)btk)t#MMX*NZI|q{#fd9` zlh=n7R1mVy9@eH5UI+6b64;LVtoK%yl{MxmHKSh3n2IVyjRG2BkI98xh@Vls@OgdO zQD`d)UZWCaobVuI*J-{Xy6gAyksI!0=W{3^%j`_7*AGa4JCV0AiY1P}L4Kl>gz~ZR zA{6NY%OpY_K)+OMYG z6=`i<05g+8_*@VJ@I6?*>?pk)^|*5;7^BCvlH{ym)Wf>4NEO08IMz;*AMW7ufOv&4 z*pk<7Q-x}Xv82V@w5s)sk6E9qTmNRjHIm0(`M9_HypMYkV~Q2>Bun7VGJ?7G2W68% zm!iQE5T-RRCUIFLp}WU6$t4TUu85e!Gq-S(PrX92Z>zU9=i35GWXG;>kdVowMbi~O z=doXyARY+oCJ5#%JS|ycU((;;!MJT0y5Nd^!X1Wl6Zv2NVx& z3nT_~GV4=MPV-(aOhsTk_p^Ogz;Z^%Bt;c*||4neGjw8A^3 z`wOqh5Lg6AX5uVdl5^YhA#aB(K$}~-DCNt?cB&ciV6WmFcYqq3o&JA}T?bTC>9$rx ziK38z)F8cxbdV+xlq%9hMg#$YB=pckdJ}0unjli7DJV8tXwqv0ktW3udQ%VxO{&!Q z$C*2K-h1=j+*vHw83sm3&iVJ=-~I}+7A_Q>A!Qreid_V;l+)98trpuY18st)3!nj) z+ETLqbQXT;-s@pW0otOXOWf8eod}{N%~AVzM)_vE4~-LLTF}$h&avaZ3nEO32@ENu z>O_$2`IA^&EEyM3CJ|_O3Xvmj16mMKUe(u^yjLdn%C6?3y4FC~@NFN&v}a{J32iew zwr&6h$gi_CAlj0Vdpw*FEP|AtvON$ruOhyAH6uyUDo;~DtUA0%s{_-0tnP)!!i?ez zoGMy;VfuYr00+DzfCP?I=H@l+{s2j&AxnFHojXs4MG=o&Bp zupTgTk6Dzk)Q^joI}aPrHB+dB?VLRdBN9%!T1<@@9FAqy>bvUa;FXehb;^TGZC`Bs zaU1=9GyNZbUOYpw${yuqQgisJuG)npgxzwXo0r*?dbzE3nG$@In^P2woHnp2&^vk8 zJ;8iHkl%m@S_f`9xe@^@Sx}uJH#$rxZ zgcxNUHV*jUFfq;Ae2C%dLYo!dsr9d85HjM`bIh}*%iuP$-{42dxTf zl(Ce{Y?t{BWeF~HzMjfx>C$f?6?f4FUHEc>xjMag<@oe$v=C}y|lfM~Dg82^UgRSA7G#nM=9bqrj# zYr3l?Qp6ohL8G1^!eBaL{7I6ZCEC0L$vYc(TQ z=V!ei@tL`ld(LWWWwJTrrahgPJ7W6jtMFs9(Y<^py~q;NVc-{FC1umGB8-Q;hTY@< zPTK52x~$XXTH^kn1Ydd2+`+^tPkMKc`2*J#-}3Pa&!QNQ?aKk+M8YIfRtQ|LDAb%p zB-K)tWYv0wnvc*=8<+h$uKtfZjC_*)`nbplZZ+X`z8Jm&-56DsQC&Vhg+GqT?aL5e zoW&1#Q)7BfZo!9=dG0CUYsEc@8-&}&+6xXLRBV`KMTK$3LNjM|YY``Fkn4Bf@X^`w zuy0!V1Kieao%Rme3Czi-tF^@Jt>5Hdio9pazevN{@S;x(@z`gmwLs>i=R3kz(ka8- z(L_JOZe%Whq6EoM+iWJM)Qt$mVNWg!>7wqk9P5EICBK-y z6z|%v#g|IRY-cyUZk$NJ#-FvuRAc=_t75odpGNd`=fh`H!H?tyWsHg)Pn)*%)CmIJ zTzBSTZV}I#llbZJMn)SD2wVoxK!xOYL0x@u~MR`6O*()iZmV}b0vJyoOWkG2fwGjT03D@NUQ z=QpzN8g!rCmadSA3}$JSHi|!J{K-0Bpp1O?`mGM~=Q^!;nSp5Sp5*Y$Tl%0g4YUO@ z>E*4N-Xo}pNzmmwfg%0<@T%MD%Vgs%b6+Ca1-JenD`3+ERSmiprDjoXbb%%zhns@C zD1d$WV(3=wtqcpcXCtr`a7n7tglC5cF~mFsjSQ21P8awVkE|} zk#d6a3~Hrs8UOXY+pZ^{&2`Le`j4gK=~L}4Xz{XUK+umdy$|#9Jsis-0+0<{oBh-_6gQor>~Kp>JHsP0H{bY6~lM94PR3 zJ`0?YCl+W=z?1`jRY0eWrBMYj%*%}YnwpV8oJEk*nKE1#0CpOU@^4!oqoOGV9R2|S zPjgyO`5%H1_aTtDK+0QX+C$dqnsZ>7IaEdo`KECA8dGx8c4f?3lndnu@$t>AX^z8+ zR#k-u!yox|g7P9O9t4%bOp_BElh&e(QdKxi^Fn0yu4BW5Kbb`VZ-hj109!244>L!c z+l0J=Aq?F+e#&q38^&~0kW23VMqyAH$TDs`P82^UWfR>RD^`Kjwi(@Bt{-=<-}g*Y z^eL_dsHf-7+^b#hsa*8H)R`U7h7k)Ot}bn3hYRD?#amYz@cYsG%&m0~|Fbje1Vv5q ztP*3S12U`rZM#L4(<#2OR;|~=XSgCliH^sF`Ehow7Q%q#9Uw$kZC?h1KD`OYhHkl^W_LABok}BR= zq_xuG`r8t3moJZsB!-|D$+0c(p9tOZ4gcKjKO;rJp)|ed>W_xqm&C{3bM2a{u&58* z{3KLJlyc&G4Vwm@N(;VR&gKSZTOwg`c3Twu}|S+F-|cn<>7JO z5ge@P`ICl--y1dX=NQw`M1kjsD<{+FKipUgGdD{KjeJv0{B*-+bhF&+eTdUxa1JK9 zYd^2(ZR(T4ysN2qqYRV!j(UE#il~BkP28!)WIgvIe%|+bKS{M@)x5dvs2UO2v3amv zP=0vyO+dX8W_u;LJZOGg`AZ1ZmM%r}2>0FMbD;{A9d(Km&^x_*&Ua5XavxJ6U7Zy5whxAd__tF|F^h_UwaybR*T2i9;-p=>>{@01-A1vqTdi@cByjSzmsm z&8=Y<<*iQR(5D^2$_}K|sF;8mwGKG$29ISnKfSnTU#@NmIettz9PwV8HoX4lqRWrf z?H6YP(CbCfs5|(a4kf=$QcRpaf~Bv-RzC=8{m|68@`KcLFMC+P)ILZjn{h;%7v+3z z-I)wN!)^933pL(8h#x9CS%-arYu1DWsR{!w0NEmNnFC&aetR%o5)74DQI)XQHyrv# z*rub_uq#itVg7w}z8en8TU;r?r03Dr<3+iGWMifS*j6WGyag|A7NoeHQbX~cD<%07p zTzbFZG^^@q4&u~TV{-5Q|9_NAQKYRbMQoYWFwKgcTpI`=n;ZEe<*u$~zQZMZdnFrE zT->x{$ok`6#i&y((3gb0wiePW(<-=I)yex->+sv=&kiLW-Wc5iHC=t*>-I?p@7_A0 z0u}L;};!Bfve;#mv4_S=Y%;lS$y1-+L(Ums2oAydR;Un&u z%*pEm{2oFSoxBJgdIsaNGUuB}s^#~u+QU-sj!GFk>{PvGz>h;VYTTITrpZ=Yq?+Sl zPe5!{b`PdVphy5pE&^PKFC~$g;H}dVuc_bo-qU=$%;6iY3EFTU1me!Kpg0274ZPfe z(W)Pu#l5h0^EMClVybQC9P+oW$**mX%^@9TfYyjR+#f&eY=y09o?HD;Ihzup7^+L^ zV=u>^hdC#l+)juR)64#@0{8jI)rD&7x4iybl1@29GnDSpqnrym1|nHzGF{hXL(s?iIBCAOI-Q{%<-gonx;ZP5;U z0C#c%0okq`kcO3J?`woYSdZMB0IoV! z|M7G6KmjN@;tN%4T?;zB0UxwS_kGP<{k9x#+zwa@Gng9dTHTq+&7d2n+K8Jo9XH+? zyEz{eS@+J6ikp+-A3tM5G-NSzLPlI*nyr2CJ+$~4P&!0%nmrESRR;^hY3 z{(Xqv{oLP+Jv7*h{;~%hx4G|KeXi1(EX{->nO7@1BJ%D+qIQ{T+3JKh)*K4XUs+@V zKmGQu;M$Fm8EZne`n>kEwsza4+m=tXJ`cRL7J7GHf2t!f^?en;^=~2~<-IdnE2cN& zXSAkzc@;&jUYOv{q{I<0^2 zA%Hw?CQgl9WndCBT;55|A%C#sRlm@ruJ#B*CySs2b^!pPsIS~MY!@;0aukbXK(q0g=nCqKiy z(EC#R(xHgrJMjm4Ens74wfRykK(L;XpeAEJ)hvM1h}@XUYN}d+ku7x`G8DWvjR67# zj4KY84+p!nR>EFCl|V0oe%LnPG)geK(`IF(<%~ac0OTO1^VsBQ#N7Shj&D=sBE(er ztj&1#8)#O(W;iOhO13nqYavI8g+tj^m&w+_-It>|(@E8k?8s#hMc=kIxuY@>^D$}R zhiv`N7?0(#QT1w~p(T9a6QG#Pb6cy`jvpRuEzZ9y^lX$j$%1+EJY`q(E|Ri3y^tbq z_24jzO05elhaOXxwLx__kP9I5@$**X;wrFq9zU(tK;u}vHAPPYUOLlH50yqxQ#=Kg z{vn>=P%amu7*U-7}NF&exMrqXU?r`q)Yq^R-D;W)nt4fX6 z{iSa8!;S^O=y%V?3KG?#t>^=sKYylD^=UAG{cDu@f51k&9m>0fF)|gg-pl>@rn0K~ z!7>l(hiZDZuEE!LCcUn`-kp>hi2mbIdBih8%l2CL#->s7Cud3>qde`0BZ;4BdN8;r zFU=}CG0TBUq{`!8WV{Q?jm;a~=}2a7wyYsr4+i<>mC$4eh4p zexszc|VtheRT&gYfhZc=qUzwQ{*O#V)S*>A$~I>pSQrR^OzaUaFgM=}TA=+CXU` zy+mn2)ZIX(G;}3tBlLBm2eP>)@v+{ew#>jCd%u-Ns(?Gi#Tr(pxo;s~p{LP`EKacQ z1QEug0zpeh;e=qjEk}#pr?$eetEf;dM1(1BRiG#a2I9Hu>S1+>Ow_QPCtWDF;&_wsjE4>Vdw$m-trMZm$hQWDq>c2W01RDiJ zNydvuvJMa92*3WdIa0>v%IipDS%D;zWZnY3umo0r+W3NY>plazj-4dQQG)Re0E+8c zCoB|PWz_F3EWc$VR%G2iu7zwqx=E<9*1BqvYID9Q0iFC~{w1jj5P4FHJ96{9%e1CU zP1}uN^+EnnH1xVsT~ET9f>xmsEt;4 zQyGoouiR|m+F6KzXiPpyN_Nu<-e(gR0&~LP2$mK>2>%X%mzG>i`1T~aB{5Gww%H|* zFLut<0vPqnZ(i*7APANxQp*SFqMb?(>;z_vWm{c2X+l5jnVgm!1A9EpD3^6Hq*#V3n#<~VcY8FK;Fxi`0g|(N&eOpC45w&I zrd&lAY*SM9WnGX<|F1q)lXHn>WBCIa%pNx-FNtQBR~s5L>9%~8i4B^YflM|d?aUmA zZPBR%wMaRcbtT3??oBx(k$*1c;0U|IIc6B|8s)V@o)*`ZUp3|8`fGTm&mk_|_l`)? z-Xa)t|Hk&mQCBny`*>2`JT}dfI)PJmpvjgFpWWucujTsjsHPTvHb%=h?d}BGpP=6e z{mt0|?NWniA4@r?T1+j*#%8TxJ`pQJo!{jZ=%|b~t&3ky>Vw8H@&-H5_4B$}>TcY2 z?5nEG@cHCslpwjzeXc-v=Nv}<{54b2@Oyg*ZHSk*_ot+FYBkXtFGYkx@8N7RoFH_# z$bIq%ZMuDC7kVPB5PI~3n-MfMhHuYiH#JVh!s&*H*EQtrsm_eJ5tD3X_F`h3Pqqkq z%Xd_kH&WDrG6iFR^f&6z;aF4*y)mso2>bY$gk#UilDjujnO9D<A)!2F7W!`8koCto$2gA`9IbITmCWiR-MyO(S`XPDI1|!kuI>Mvl4~hGaY}-U z-a)MZl8S2$&wpNFmqR;T7qGh%n3shkMJ%Vlt$sec65@XcQNf5>+?buL;htIi@-BI} zFRp$?7ljX_4Xpt;D`b3$6UEW`7CgS?`V?-;lYt)tc>P=={UGQm_U6E7tDt}|KN`=$ zg#80_9dxdxmU%cO0*BN08(GE*4(nz8XMoLR&fT%dt!p(AE)L&D=TmYSYo{1~uad$1 z`bTiubmwNI#k96(f2!DXPCMy6`e;kqO?Yg_=!CxVY2x;c>*+tH2)u31@eD?{hyoF` zWydzpVfX}lX4+cMkqX{D4q4Arp&RA>sEah*dnsrqq<7Lu=mv7^MeXX1>tzIoJ|nb< z`_u?J;`C$Y^U`fstUjIJ4ZMa8&2G5`3ALl1q>-jIgRl;2;^`^4NJQu~H{C;&f0*5Q zwdnOQIfybTf_OgkibPOH2*)XuU}-B`PZDcFihS9tit}91f@L9+y=|0-+Ue*sX~4j& zTCRnE6U9EH65SD)mmDzlO%Btn94N@9Y0>e-T)~N`X4>Dw?(Wus*q5L>DA^>T=vVvd zpVPU8(W+H_f!u*PzWQl=)Lq?QLd>khUCnPB)rD2|*MxhNi!%-za|HBgbTTe9g;%07 znKez#qTDi(cUtJd3Ks)k6OWZS@O z$ehKNr#pC0>!WG=!B zVtgL2v^^DkAvA_->UpdLG4%ZT;!Ycm2Bb23N0DvpN}JohiZWk|E8Cj&hb(6`4wF;# zQcQDqRc^quJMx_HUCcwcS5S-n4YiKO;Gg!>a#N5Xt^+*N0xjcYT4hnB=ic=d`UCs+ zmb9{$Vsh7QE3GR-&GEC3U(jNR=Lh#wh81L1;>|5mxO($d5;F)<3&!;O9h3fsc@mm3 z{Cid$t1T%j=ceDFMoG4d%`)xcU79O4nJLMuXLqoI1%@qZrqQo#*igl5y&9(*c^1+< zG1E2b7NwdszaCa)iZi2BQwJ*Kc9pK&O?l|-Z3&qc*Tcx&{&abF(qsfjIY4iQYJAo( z8}nZd1Oh%Xju%=e!x8=!WlqBc6xMGtw?h#ApH%ZvuH`g*>1E5}Kz>HP zZlqikby}r%rmk6GyYQPBwv4OlESZ_N60YZlEf?x~s=twFQ^@DF?iJB8aZlq*n}5q2 zE&R#)UH%*Ot0`$R9<{P)R@t1;hKPt<=zb{i(WT<=y{(J^s|`;Apr|1zTs>$rrLxI3 zl=)yjF2>l-mMw5>%`#a|ZEs2J!>t8>!bx@25!SWs=4`ltr*jtlZGv>|tB2cjSh-?c zn8i+rdZ}A_j6#dgX%7nh7Hg;Cg~)yEWA|VYcmRwRalX_rMS12Su76UD)YL*Hy%KEh zX|{yaThMXChcV&sif*w;OozJ8StMkbSvy=ya>(Wba!^{$Zm@jk^c?kp1g%F$oRUmq zG;=W`JI+()TDeWuHZunsN~gozB5y%&oeL~jM(MD-oLkt6ELV=%J1UU}JG*W!ZaEwN zcw$k^uC0$xB~Y7~%LMLu*Uw5j5;`tF4bG3<{n*$7o&uerADnV9{Fq^Y?T9X9bQr2bXF2m8D*+_Br-C?hJ3cmhqRn!f_=+~I$d+96_BBlp0Zf?a;C@wH#v3!SKZQ-Xdt)TH7z;R-c7_&)rDvu|tjO9k`=14OSl4c| zYGb%);=Brphz1z$)d? zxCyjgfD0UXWob12)bZPXQeGU?#I{ho%Q0PZ>KWd`^0T~u{krs>f2hH%vPZ!?x%JcF z-XE>=Uy-%X=OO6hFCO#j7PmbXFs%>VS2PuE8iaC$pKFEC=h(nVS%Euq00EM+jqoUdXn9Ub-DQ!GAfb(vD`K8MV%-$!qVx z0QT3P@_%0!Ez(WYHty=P6fgeh`Sj<__t%Sg*k!xz&#JVQ*Vh4SV|@(zG2HGf%pSvB zWMOLP6sBcq`gD`*^$C~SyuB5Bzd8MTn#Xk8bH*0tw5g=%s3)=@QzD_ds;5c4an{lsK%ZKZ{ zjL9%_PL&k}FiDT1wwPgkt}xbHUaxnQmqpAPif&I;o{|hc)l_uZB z8*>J(e{*AemV<;s-NtHsMEw^B3bUwbOIWTn^W$^bO1_2G2)5e|Y4TPAmJgxK@mBKy zKp~8bjD|o08FiYpV-hV7&peoZFhF+aI{6Lc0HQ#!WO{d7O(L{m=L*lkM+pi8(IsJ3 zd=_{jMevz8w#a)S_#MaXOwS>E8RrqX1u!Zt0v)sS>FRv97*}UG&G^RD>0gA7SGNkx z>?7FjUOm|RAkTk4Ut(+LRzCYb71-dogv$q`U79SRGk99<~36o<=F(5s!3J6B=So+NU{N`#NJZ+VQvFjr?)rz(Z>vl7rQ<>loG zD?iu<3{9tQAc^-ywZmnbGV<)``Oh*Jw?Gp$i1G2#3j902$?8}F)?_(YGfR!1RM-x` zdTAUNrV#&4uD~`n4%qD09LnA5X#07(S|nYs`Tu&?{`~yYBS(XnuB4OHBAKqZ<=y_{ z#45i@v5Hijck;-%o&4{WD&`CYmxHjNpxfjmxOaXyPure6v+o~V9zHaXXM)V5Amfv`g;bTI;$&qlv*_hvh@vouGn#c z*yW>egeho^BRJ$;3t~_{YC_QeKp!@m))qyU&hx_ciDu@(zjJT^ydJVYHiqgLP(6 zJ||2zR~ZhnX?Ay7i6!i(0<31w%!N#o7#I5|g@%xr{mpO;F8JoY{PfUii^*y(4%jW? z#fxGAgYF3oM_bJ#*n`{xdKt|uIabv&GK5IXk7EQe*|F}-h}SuBxWuB!&AjFGFvRb3 z(4Q~X|GpR#3!d6qxIF#0UsxVM!G$1wejWl&n9ir)xRoh$xMW^vtT0Wfsoh^Ue0xDC zzk+uQg`MZ^_Xqk7Cy*rI_S}l-g5|Cw-=Anfz8taRlU$2~K0FR=oE&1~QIM&0rZM)|CppFd57lSJv1>5)B;k-Z+lyFeXT2J;Dq;{i?^9v>w$8ti*=VA)y^)v+A@+y z&hB&X>P(Y;4`6G7Ag2}&ymZ@0x|B?p%huB9zS)GV9P$ukh7SVPzBO3styEO-`)01Hw3~63n0^kLNbItPXpEc70E}!K|n)QvoyCQ{~~P zylf^bR=1t~9(M=HE`v#tdgz0U>1f;5ddxq#_j7H3Tzufn0Na}9e1F^Yhu9dm$!0I| zVDP_diAHOHGnZ{*o?*)EcWC?C>?B1BU;JJ*Fo~~iOEz_6T-6kM2QAB znZTz_+NwXSYz$P$Rq#dX9%}A1vLiUZvlC zXuBLE|2lFRl)`&@_4vx(P?@Mqd}PsO^Wfuu2~gh!clO?WZX^$-QP+ zVTYlS(F}!23#Y?=Z*tvNw`nP69_E8Zm!Jg|)J&uYnQzGqc13+=pV?~KngB=VaBx~_ z6;DrYSKFi1t#;q&2Yi~1Q1}KCMZ7X0cHm~_5+_GPF8*j2dtdG}JVd4dr<)S!;le8( zAk<{1vV{o+Gi!z$Yy#1h-6lmAkhr!3ON^kORr0cVM^}Y#am*4;dY9&S5xJg&`$;*Eq;s5^nhRti7LIedsYc&7O8?a%&g+0O^oQE9A z;S@%*l`-M5T14!#c$FDLd`8-BV2~pR%R+Ohzxde#clkQ|u`8xK1p8x}nM6zI_AA#`iv+gT_?Ssm?GgH$>giZ+kLg`2Nvjie@uAAU($hFz!03&(A%} z5H7^tfK3%6B*)5K#>WD}I>952>MnoCnJCRg*P^VW0%cp^vXLn+ByX@pi+I_=?ln0w zULKZKtlyZqDsCBTH=UvaFUP9F63GNDuhn~?Qyvkc(5Gx)xgz(lL26Gt6St!I?2{R~ z>Rdc!K6>*5O)i-T2+j&x_qY)6i7NriwOdtJnGRRDXebMDp4-iF!X=QZ%9)Q#Q7${! zWcO<}rlIfCyRU?tsB~q3tG$Q#M~ch{%&c_RtWQ!;3N@Lk6@vHgxapojQH50>8W;ZIK;fh? z)w|5%@jg96eB zOSlQZF|tQftB+_k%J>)3p09&d2ZH5yxy9ZTlPEe}!K=U=gdZ&}00CeLIPzx#tn|6q zi^sXQ(OJUz5Tt5RBr{W^KvOFY)#{rxhGl7-mS|<0rnm|`CUVIu$MCBx5?=vLWD&#; zbnU8kM2bAnCENpB>FSTkxU-ax&=>hUXM!6+4xbB}zlB|avU1(MgArF8x9k$QmPUAY zOYA+LBMDr?y92cq(T3;1f!wVsoHzlOc3No^LmDEHB(jo$#TA!B8^1_aG4QxAfy4nT zP(D+nY=pVL0Wy09cH?zU?A=CNygc_wPgqKV6JsuunQL%ih>wYsijg;uo)S0>%{bd7 zW^F-PMaW9H>UiG9zhEobpTAZAqwFriD){3?NulX1onlCmAn3zhXSg<ng@|%P{!-pMN^&8*K98D^o;+A<~85a}_bD?vu_FPnqI^U$)VgP0NB~v*B zITw}K$AS2C2olyLnIt_ykK;?#N3p9VpS3_0H;TD&-BWAOo-rNzPRj_Svif(JeJ8`1 zF41}=Ad0v`#8_^J(4qwUvq1Qi*^73G)gms%T&O9YUPXHdbqA9Z>OyGK@Vtrq5WXQb zpr^3-b%EB1>xOjz+V&--a@qo zMEGkwy)VU+>hwYERoCNy@;*HUk?xb#U`VXkeu6M=pgin|h?8R?-uSM{-0m-OyL82y zXxp6R-UFxt9C1o2$QI5bzGJR=kusR-dvjAxY$cNs55&}}&Z;emzeooOWME$gsmBaeQrH@t#v^5S$#R&;YT7Y)85n$HU&qdl&AXrc-RgF3+>9@_OY!*1rRyhK~nBNnzN8=x2}}=pp%l$C)~GQ#t?33&5rJ zi&RmsM%$_VkGFThk2T_~t#TV*JbtkG-ux^}IrfyS5tKPxsi_U*a^XQHmf0}C$<`Lh z%&h=Gb$&1JK2xEv*9H0+l$9d)I2lF~lC#ZLz)veh>V*cgDxk?lcM1eQ$S;hR0JDoZZP0507qZq#7^n1hFj2bwP&*T{eU~ldi;# zMkcQFYjhl>^CV3(^u9XrlLSIl{NeUlnSOM84Ba%eX)2<)U9`HQA#oKZ>o`ZmPpD!DF)E9yYo>~^2Wie-;`Nn3%G=Zc zJ?;##$DS=|x}p}V8h(1!2W0M|gl-kwn_L01`ze(>#Zla^Ps64FPs1MMwNykgH|s-q zBaa&JAv*zisqsTHS_zB;ev4r1v5DA;q2$P4m@^63Rwj3(ihq=4PBIZ-7L$c^KW=??@&)A>G_e@g% zHd0tI%X{jiGEKY)`}ZiHN*Xfmeqh;p8;UXH4mXxnTx~X{EOmV$26tZ|-25GYHIrNtONQ|G+Zr zd@09FYCc+E7W|6lG8EK>{}@9!_>X2XsMIhXT2!gF>T%dWIEZE&10U1|H|};kC385_ zE_uAAW+r2WRhL)ZzAGq>VE6rayu_tuHN2LbKoif;q6<69e(&=Y8P|!+-L9-t+Wz5k z@%u3h$QNp6*i(ZX@x_e^_KZg*xNk_#$8so-NfEfmtj(|*sM8WqxFh$8`MlS60Wy?% zoH-c1{#ch`l{dQ&C`LK!8Iedu93G+4pE3YnKtL%_5lRmXuSLI;T1B<=vU?D%eLkcQ zvIbv3ohODb8>>dgd6HB}ZGq)Vn) z3&=F-pg)k)Oy}5>lqC|&vURIx$m%(>m8&{vMm|RWi0+gS z;VNn-(e(qMW*Gs77tZGtJlAp2sX)LWVqSHzzMNU}oXV#Z+4hUYwa8}g(0UUthrE(H z<)O$_)*#&RTd{M^x1u-Htr5j;pQA zxZ631G+ZW4^zNl?MW81X1NW+XBDo;^AKR zA@RX+YO{NKxt0~2P5VGlH5-t3raoP&;3Z%I4~Nhq>;MQCJSLjbN&yG#ee4`62nifggk(% z6?e$xl9feLa|zO=K!4&1yof3w^wm(_;<)=zE(g#Z=7R$~{3@e_)jHR9dlK~Ui%a(q zK9I<*63yWtF1rhJ#IoQ_z);ev0pITkE~0Bh<3k@MrpU+2%rqZ>jijp^cXkIreY!rR z5+yT*tI_j(xljp7{+3>vlJh;SZa@tRbiW)k&(M{vmr$o2+qyzT;KwBdBn&L$JJAa< zb3E-)Dvk$VX+`qN3=T0(RxraZ}ypsA&3tpE^%ds8p z!_B`}lzUzx{3)X4Ix|!Gc9YAtB}cG`aFH!0noCv0ldIQSxq9+(UdgLW-?#e{RLUzJ zk_cus#ch;@lb+kuOBF$uqQh+i&#)Q-&^n?pUAZ zHWu~eZ|mq^KbfWh-op~D*VXnl=-;2ve?d9Ff7S5Cqk&hU+=$ToTm(~aL`2;qlkxmW zxCmQD^%Z7{tCV3}bf+(`t1COXv8kw$?4#sm)FIe?05BleW;)m0wlAAuX=QdIVOO{h zeAmBE?qjb-CQCYxRQNL*#-ai@+cMj4uWHw#+vt`BcwC8LMe9JKsvoFJ?CVY^@z8~7 zVQuE3Ry#tjK8`Mh__W4xxVY4AB<-pwwy~Z!IVhb@ep(hLHuN9>qQIi5fCKU665*cL z_E}B$#sK$xjKlHk5e^_7vk3G*JlG#Su_`%ba7y^&>1aT&NePj+kKhXnocf>)cf+Z3 zpons>h?5b4m}c1k;%5%G>X3DqnOv^2u@zIDrJe%IGketO)(vWR?mK+DcRO@Fhe_!; zjTX(Ih*RC^u3IHLvk956@p=_ z-H6>hV1{p8#=adlm)V1P~hz#L14=*$`lkaRDt{gfMkg zM^#>5Ajp`Nsspwp%B5g~=*MrkCw9B^Be|~a?k@)|uTmL0bZKu#v+{(db!3U))t@UN zr{#u90@js*A+P5`{`(;S@_#QrPR&RJLyjSNB@@R2hHK#2M;pHc(5NX#-s%-VWti28 z%xYr&#ad8jGEeMg)lr}@nwd>k+v^`>uAEsp=11rlI#0Y_^eXG=+S$c^Vk>j(^a#F; ziQ94tE0Yp9t1ylO3D2EUqEeYSh;>`6wjx1(ntJ!vHNvg z6U`p$9U0;svQHMb4f>|ek~h<2HPZ3n?T|yzw2}ZubLw zA+q&&Ul-0J^ml8cV5h{(*d3L`_x?Zck`3b)fr-(2M>dv4wE-?;ASJWe7;@whsvJtM zp}^go=Y(aF4QEhBw|v+FPQWAzReX3U&o#AlEF@{4>@=5ImMAl$02C+Y*oQ^QI+fTs zd)sCz>2gx*ZJJ+uQlQ!N1xukJpg^B=hlxFC0iFxBQB-b#+;qGiuYE4?*R(|LXE41l zv@+cogC%r~IaG8B62NZ6i$3o)EbK&~u{%UEM~NhFqX+CntW7VR~P;zd0P352YS}s2F>5-nQ8{fD$Cxvl^o$60uS8xM207 zXnA?%ZQEuC98h_9EDn7^S=}+VZ1?THv>*&9Cg{L1%QiNcU+E%HmUseRL$Y*!dU`eNYFcIL zD{@-{CezuU5~74oguk)!n&AWb08)vOTnjVCSir5f>r%5OD4wJjkM(|lQG9~u8bP!4 zKX``ajs`j}o9#-Z|Wmz`Sk@9b76R=MF{16tB@?^PsI8UW1^Vk$B6-UxI+Kh}py*m$Bz!<$q5 zgQlm5Rria*eSfHj!eo7*LeqC3;i;TkszYYrRYhpLU`oByRPaz&Gs=`?3zc z4&H2o`gckXc*9xDZFz$|L6WFaEQu}JDnvI)$O*uov7M<>t=-2QC;Y*_c8_?XUM{QW zeH{^{L;Ns8oaR{&a7-(UT43NH7WFW0ZckM`1Za4Pq`oPY5dO~{zIG%bcV$Vyw z;&>6aGhKXmrzz=aRwoz<`iIJ$93i$L=KHISki#A(ck6{B>NHHPO(!dmH|BO<8R=)R4*^k(1um#qe- znQ~j@x4({C-m<~aO|n{ECSd0t&$Iyd=uWEs0yWP<)V2yPgq(joWYgD)eI!Mh*^^skPd|~~;^OxB<}LMqpT>XwbpP{WB82Dqc4K9d z?cdhuT$THIl_mzxH*Y?Pl8>}NcIDn>9N-XWGAox$j=w28Tz=3q|JR%2uPwk|uZd<9 zHGZha*S*}^+WwC7Reps6-P!q2HYzG=pNdNOiB9ze-L%Vload___V(UD2?+_c%Qymd z;(s3l|K?vUh3L+YJ*@kB?eD(!StK22p9ADB%p<{oX;pL=;{E34Q<!>C2$$ z?0FBhlU@8G==*ms#(#hP?aTmiDf-6L#e%hLyTAECCV05+UzH6szI=|w19tD)i{1G2 zgnWcOS~qsBosxlp;ZnV6Mk(9hteyS`LfLSM;?wKu*s#q%{*DP%p6l0#SAHu?d-9q_ z;Ph3KrJAlqlS^+cM)GA{2$b^r3nzA0XMA3BM8^Jk3Lq|0_}jOl8u|T$dB#p(5%rMS zosVEF;orG@65Z{FzAh>#XwdSNHnA%GpRrPDib1bCgZue1ja1WDSv-`P3w~Vqg|B?3 zgm7LyiBsw4&>$|*&q;2D?-b*{5R6_m^X3X(<7Pc8cbw12{BiLF1EaC-j@TPa9)F~h z@W!r0Q`*yeX6BCSbM+5~n)ZxRHcQgBzMQ;IJx|#H33lu~Mx%mscyfjw5^sp+Jk6z3 z>f*h=yZp^J_j<{PED3?w?|z3RA47gYyq8e|4NCR7p6`)TtJ@v>_Hx;tmx7podYglt9$l_!efaWu*c-j&df_<~t6$`nSpTQj z!^G~SsXtiZHYfXo&>yvgWb4dYz#D6-t6O&SmQn8hjd8IHf+aVlwXn?+m&VpbEFP^( zGu&h#6qjC%bbI`<{DRUu-Z^Lc#%n@v^NnQ({rH3Qn=hFzIwc;VHMx27rbc*`_sUzH z*@wzpU8?9(r~V#BR&^TI^Ut;=oYvYQ*gUWC~F=wH8@C?r%kDY^_U(gtaCy7DR7 zG==^0!(NwD3@>Y{+S|1kZ=o`aXLH4LH9PYzYm5758A4la$_f&t>-P@7px3oIhWhx0V8Wp2(PEQqRm~rw0*CY|O>x#mGkB)PR zx{KRlAE?%7>-211Y2h6IeYWI499`O2Jr+rvtpKfixx<$Wrd9Xelz~0kv8ZKUnkj|p z<~U=ie}16A^VJZ362Du>r=3(m1K2%`jBUL#ip8tzj53+m&qn^SEWrF1XJ;K%<+i{3 ztstP1qI7q6O9)6yBS<$$cPvsQ6_9RNfV6an2uOD(wEUlL$2m+fpkwzXlisgpx0JtS(a8j*Z)I~J`Yxg5TaG<0qi)Pos#V>k!>T^4*tUpm(Uc)8b7nZfzGG_R`a#K`D87vri#ANko8BP9Ubj2epI43 zK=o@8>%5vPy@00ibrGA~S#yHLww_-mFZ;pH`}y`<{78_yr|pcqMe6p6OFGPrpMGm_ z^uQzuRg!C3O}*EV7%PfAgy7>9wo$M^QlqJX$;aD+HM6&Wjuk3MaaQig2p6yNFj}w# z$biUNF^z91Updph+G5C=%17DQ@b@p1=k0lU|IP#FalYR?u$F<}_owz%yV}CNmon6I z;+U<3lb!NUq)zfktjXW6bN<61Ja+};^4F6IjNk0CpFMr1t7;tcIth^7pDf|=+CV$& zUr#Z`*nCGe>vbu88{lRgGne{I`?b{T{*SuJolR11J}xoQV)fZD6`Rhb=x=iZyn@+m z_qFQ)-;bG{-Gc`$|Lre|$1X=Nh_Lb~r`7~IJIFU~;D7BGkH)Z9P@mmo`u_a&v<2(3 zS`5+V0|>&vpWGbK`Pw2j+-a9BCa-RyS8_h+RsTEx|FAaihew#M_qXBv8)vQ;X(>t>S@e`Pan`a++=Pxb{)a@1$al)Pc=eYNS} zpT5^`lmC#=d#NVOK@}^JtDyKHv^PY?K11`R_6;B5dsdU|Pfw`I{;|WUIKWVjRA7J<4#ar?8$i{rQf!@hKY(AUEw-OwerF$*C5Fb53iP)LP^*O4>WcJodNYqSB?Qo3tWm zUwq`Q=5){Mi;Z4$qmtKGHoDw~{W;U=JSf4YyJKF}z58gHYy1r$YK3;*JW0OfL2-!vQgOdxw^O7*pjUl*FrEnGoRwKlj6{yH}(?kh&V@Y$B4Yb+%0 z5W@D9MrpRyV=I&+bbD+K3zY32ZL0gs^}$skj-qD4`jL9wLcC%UdXw7-BUBDQhAoP9 z=yIyOw5orvRx_isZt_9ntKuCu8|%2{`k(xfo()B%Me1ky0Xxq6ooV>H`5`KYRbL-$ z0{3guP{#A=F3FN8+v>q(AX}by>LD!!Ju287SKkC`q8UIeisVxQjdATF9vfWt7Z}{fBxxYR`=7c!>X6cE|(b^@_fq>mAy8PL(0O1_Ye2-+<>e^Tq){4}X9uAmU}Z zcvgvxe|{N8a7wYIY`;NVZgw+>de zH6fi?jdGtFB6_dbiFbaA3Vw#gj)WUe*CeXti1<}S7u_XH`Qfr(7S^BVVZenH#m_@+ zdMAgaH@bC(zy2RX&wrjXVt#=Vc++qWy39dFr|L?881?|D%ATrh2y%gZdGh zv*mdNjgaCk8d-R3TV2y<;fLhvA&aM~+Ld&S&hvAF9)m*l9J*;JQ#3;~O?{2wE^8C9 zG^2`r9;I1I>ESzkLmPauUN(C#i)zZ5``%0bZeyY^)0=LEcwa30w<6Y1SK=kSos|=2 z??u44(r%(qmF*8Hj*k=xM0`J}H>7?Ln&Pymt%;+%bC{sJp_u|ZC&Nr|@6&86H0h$< zoYIM|(Mh)CF7lQ_K!)+Pz2!v9B^clXeK&cJ4-l_9sw&@&6Aqix@KLp zWZKi@aHCrBqfRrcE zdyP5l*fr{)#u>BVyc%wlN`{tQQ?V^tX132W*!@)Y<_2{ozjq1_0QRgN4DqMIc+4h4 z$PMnfV~Ku$0SIR}wbC7|=VssFqZ16I&JZYjhlT6{E5Jz!kPz~L*(LsNcWS$335G6T zcZcIpD(`S`Bf|`*OeeOSo~WteGyoEU#J;Q7Nu|J1Ul%kw||!asfZ zl;*oEs|mqNd^9?V=&`5xImv%@AaxG+DGe6ViA>NQ6vsK%(mm9=VzWgL=6y+-fBC6Q z?m%j{p@zbot(K@*{`35MnzmBK_Y0F!&u22p)2+(~4J=+y)@nlMG@tP(|9V{~8BWt! z`o`ScYH;*!@u1Mxy$-LId9O2e^6oY>Eee8*v>lDRM>RYoB}w|kpfi2nB;pN!ZOh90 z$3K8Rt{yR9e9%~ePf5S;vk@cHY&ZGkHQOG@HF;j@een|#!0JM=^>XHe$Os48^FTto zckx9BWNyy zbvwRVaEe$y>hc>5ZY3b!H?sw5nzftJfJjOj9?6B`$D5BRiUeHl)xxwWo#{CwLzw-)DAnvXcc&kJ1%3cm~b%GGTM~zOotE)>6uMQ zR$V(&oT{B7JmpU7g*PSDup5;Y@>jpk=H2W7+poIn{++4V@2HT?$z$l-3syTqzb2mm zVX+6!1d#pe7b=Lk&&0{$jWO|J@kiufjWNI1c=Y*A=jWPGjDv{nWOwA;h%UVE&qPz` z>Mp}gyQ`vDx1&5~dhp#Tjm-V#Oz+-EqP6byBtA)bdo1HWmFV~5 zD1$T{11OM=M|<#2I==WlZy^ZPKkC3FpS6jZsans8N)Y zo+jg!3(a@4ex~&JIJQ5OtowzG?Ya8HU1w#=6uxaf2w-S`^)lrtaxt2asXx#uyK&On zfUsc;1HF%h+_shg?5YjfrJuJ2>W)9j;^F?sq%JEL02bsy4jcvbr_;!`*QdLD#_h=4 z5P;!rcykKahezv+?(;*4sUtAqR08AGa3YJgNoO@sAZ-UXcLHB7i#H%K*^UA56u9LP zh2;UzeEC5oCcZA1m3Q)g=h@Sm;*lo7!X{EQ@}P1oL&f1U>v?8$@YK$x@* z&^^2c>q*B^BIn6b2;Nr)*1kXBZ)R))E&1C(6b!EG3Jgwr&13~lqhyyQK)hS6>qft< zn|#fvYi;*LJ>TJMZY^G)-@ISZ`IWAEEb_P7Fb@U0THflsK4Wd|$HlViHnE=dbmrrV zbNcW-9b>n=XEb88a`Ce)wDD3mY4FcfF>f zOb*8;R>~ci-|L(=`#nDJ40uczwu z^uj3;W^k&hB5px4iG>V_(@Rli7S2dQ?(Vzm=~f-Ug84DsdRH56s7;;X4on~>2sr9U zcp)!xcc&D3%_0D#^aZDGf(J`7r@O%#(sZh-l0{5{apwuB3-hY{P$%K z+x2F$dz}C|LP{Np(OQFxRLLdgWC+)vb`Ew=8@cR0{*=5 z+AYR!_DOg{B?`rOQ^G|qR@=q_f5du7$bA)bvwt)~`&IW5`FBg-82TPGo>>5lUnSJU z$&BU1%YJL7Bl7#^L}Z>-V$|>UdKM%!m;oIGJ8P34nCc8f;*YFd6G@}%kg$Ac{ zur~81*EYf_4UtVCN<*M`5oH@d+qMGV=#U-uIAV)=o^PSw&|iwN}977&%D znDjjvc+*{LGtJh+cF#nl6X*R}Sg2d`4SawNP72!g18}#Bvb$8?^_mS_IsnY$`C2%8 zQ#1lc4{c0()e=EJz*ZOL)4^{#V=4JmA>a^P3XAv$M>LNe5gR-phjIFS^b6GfZxQ^> zu^gH0>L(o`F5= z>jGpm!D`F$Z|=p73kMt{O(%m~^|htkyifNQkEU%pNF5>xe@>-Ms~6|@_17nlZKT>v z6y$3b&qsJX7t)RWz~?&{UPib(Rf|8^QI_BmT42s?s$po~E`ZzqHvTLW-9qjchz9$C zl15yt_c1XEBT8#BQ3 z1&mU$0>pv!Q{)^WRPaumOqJhfAslJ{jmSCO&h%fx&_IO$)_^EdZ~Y@9m0uvA*GpuF z&kB}%I>nJ1aFe?8x8q>zzd51=o?KWWV7e*U7EB0_n&*ih-UOz zm&d)76RS>&yteD)Q|z%B21>)0FMf`6gom!pKI}%qPRD5X0r%RE@lsKYw_lWzy!rEM z58f0M1TP-!%u=6O{jh+#4Y}CKDD2HoM5A2%grc0kFYj9q^tyP3y{%JWf3a3aTiZ?>xZ6mK2By$P=#&@8C&`o^Pr^;zAM+=x?@D!1WTUa2<$h z6rmF%^Q$nliF1&)1u)bgXjma7JE6wMZ2?FbQ7&kH5q2?wcj=FHJ>Qa}Rsm(wbx@C< zCkJLXF`KYZlgA+xzz)(U3Y5vqy}_|)E@`o+mf?9P0wNz5(TF51PZx~>F<`*2iIR3f zOS!vp3p6yIx{RT#Jh^u}z(1-3cA^GW@|Bp|H ziXxh8ldAm~@s||ZCVYMFU|u9C?iQXLJ^Jt|*W|H$$k+jo!&lO+vAX=JPZBa;_@f#{ zYzAM{SBl!ry-D(#u1O8Bt0uIm%lBf|DzuY55waP7x}I{rBh@!WXh!XgO+y0o8zGQ92qRE z7(swl)q1?XjM%tyvQ|+<$REef&&<5xL!nhhDLlgNk;UsUMAzx-dG{1ayI*xVQP)0Y zO3y`cn=;C4Ro^nh?a7kGCM6yuoO;8UKtaT_va5qVq`OxuBfhH_FUM@|l{Y9m)+WAs z9M;TQq$SM%@o7i;7sG%cO`z7W;L%_>`W_*#wMHxschkTMUWPj;nO%W{RZz^AioG88 z*rB~MCODh5N{}2-ghII_H2q=p1jb^(|GEq68aE+$FZH*P!ps&)(FVZW+Pq!G_#pP5 zzRiDqKr|@tS1qDRE;9XSRuTN`G{~C6;b70_l`@s*E^tMfF$o`ZDI+v9X}0mRsU%X& zI3QS}JIQ)ZUzg85Ul1(G=SO%JRXZ%V7?5QMMt^7q*;;&vR>I&8ATMS4mppTb zkq{{*R;9I5OvF#AiGACMuf+emr(J@H7L@&SZ!~-?)8q=83BFOS?Gg%4mn|AR%Jp`eVm zCzu21lg@w`hLAiiuC5u}z)`SBY2;)?$>JiRB`q(DMi-XLiSW!N=i;n;-2!{Kn&8CX z^CeE@EE|i=fm5DV3EEGA)R^1R%F|A9reFxd<+;pciJrULrh-=03smtp6bB&CQ4x`Q zI(_eu&@z1$rHY`b1Jaf)RF{OTke{5pSzE)KIAOy9aZP(Ib33ceer7!khRQ#Rt6Zc>o&LRLN%GjrIIT zg)1U?#$P@2Is^V+K3NU!`&E_TJ+}VYH7fG=du)x<_OgCQaay9D#5bM&G)Jl5 zQk=Y_>?olh@|rN4NOj7~a8ItJb*rmQqbw#|Qu23j_WD`+u&B{5k?=TmHJ{Wa!b#d& z|BKCj;?!Za)t+FM|CjwAcIKtcgnMdB-)^aXBVnVY5O|tA{pg_`!IPGnx=?{jt%xqx z2fTRFnVzUd606Y3C|fs7p4MgUK=ZN*Q}TJK?r-$lXVh1gC1B zT>IN3y@Lsz(8&6u%i!sOq*esHrZtbV=fS_TLl(d}`VGN~EiY)T)n+$OO!H?oJV&4gbP0r<;-@Xh_()siW zle?;v(?xeJc({gQNcrUX5^v68iiO#)GQwpgZso7)YTgGEHCd*qkiHCu?nsMK>+?vr zT8o{xDE0OpyHLpUp?A~PT*kpld)(_<+-UaPV(HkE~;6y?{j){zsb8t#?9d*F!MI~46eO| zK|KR=lE$L|B+BllBt2)jcB6zE;Qc57R19x2lv-Gd`vEBNcYz{Pkv`L>s$e9Ie2E`@tyCu2N6r#q5gySGoP> z0J|d7_=lqc>Ydv$#h#zzEjyW;oE3jZFCTZCog!;dsYL+c#xS>Ow_s7Nc^#xX!!p16 zMBV#)gq`|WbX_Tj7;9lhiVkspzQlT3QPJJzM5((8ldCs(?WF0iP3T zp4Qoonjv0?#*>5OB%OK7f~~0w*n9Vrs(DTjkyuBZnG&B`S@zmgzW%GFez^Z1?Jv~3 zn+TlxFQ3Jv$AtzkbypHNnQSzIAzCA<0lFYi8SYDG#7p9Z6D4$UJXNm8b~e+K7iwn8 zag~5|MX_wE@`seiQwFUha56P%5+$p-Ylk^ADL?1T@9JQ)+BpLIGB4uIY)v7#WsqxE zsOPsem{P`l_C5h%U!M8t+SMiu0H}#h(`pE@p448p{=~gQ;Al?)z5p_Z@j}&{=F*)H zuTI|wVL8D#%7V>GB1k5g7gn;#@-7aS#Sjz;HvHL|XZLmY9zXie`x@|~ftsfcHgxIgo#Ftm~b;vq7Z{sVnM67rteHOxBWHpBScE?++2c+Na#Pr|s;N zNbM3^+9G7|dGfCgYOSCd_4T^55;oTM=_z#0uHH@C!OH~?M}7;lCf|lmaQ)gly`^reR*c%uq!*nYF9M5u)+40AIYE?-&7Ah$Y#&B=$(|4 zjg0jU&tc70Wc~Q1Xdxr5@4I`9A!&P9goW4lyU_VKf5xjaP+kEaYWl?mK)j?53+}k~dHXf7LSb!(A2k!Ixyn4VrW5;)x zUn`;(PAKipM-WK3+r(*}L3lJ(n|m==kUw>8Ra9;7Ze79)A(%_;b5O{xf5@EdvD9dR zWeZg(_0iQ0CHzSgMO;o*%sn}se9z8160%Txipd>aTB@A9Q)jGUbHmH)S&>i|#4OFV z(bQx7TMb%3lc%snn(O zxHxDP-J*L0;T$T#sLJIN_l*B|%%1Cf6iwHmn}MtLN%)kGCNi+f`f*injwqVohH^3o zr3m;W6ahuN6^N(U1(xAsSJwqZZwUq+`!_c@4q);H)3GT{Fz~;1OA1YoQg{4Q|z&sCeY@eBewnveGCJrTg8Apa>wTaTu&C}xM;eYsa|EhS5 zcwbNm@0Fos5GekAP#5wcS2*)37niwnE?ISdvS`u03GLD0j_$gwG?K32t(EFndQUiv zH~6mZ9p&2rlJF6Q+AVYb+S=5B97x;2TLlYA8P&J!tkP3t1!pq!f`)yqrui>pxfkgujZP|9fC-CM(6u6eDT;d5&Tl=zz)vULim*88$9J?{zJJUn3w*7` z>+&tGZsPylIO=}D{CwZm~XTou|kP_ zz-?RWzlF;;S#zqCd2=Lgb#N!C9u67%=H0GXl($-{F>VL#HL8p{e5$6DpS7g#;a`JxfaHF1kdp{J?sp`I1%}Yum%PxuTv^ zW}_P$jR}0^Z_G-)ajGm%*O(nj5yHE;8QUmj8yy4UKEPeDi-2d9*La3>5wl2V#V3hd zl~H!*5mZa8sf(J+uD@X43k@?ZQ4kD1>fON;J&hTLMY-guV+LwUY!qRdA>0U7!2AA_ z&=ce{LFq*%Q6$hmuKKP=@~7h?E#>Fa@yAM`?jaR}c}N1MhM-{x) zCi%kUIXffA^f6qq)eq^T4x5l$nMrD>&_7Dqf2*)M8j*fy88#z!6#N#-C}DAj`+pSE zztxb1u5ij&A)~zO(%kP4N;o@4iMW5%2;2HR8V=Ob;0`I-g`%9z&NOfHr4%eAPN+CQ z4d(G1RPDarHcY#^D%+gC;h)4yjqvdM!fiS?h367!<2I%76YFd;U1gae>y36uIlYcunaCeXHFu& z*xObShm@m1_#sv%-BqH$pP8LYDxTiZ{8?xy5-A*hq%O7_jcz3uhn_%*;kQTEIfECd z5q#vb@&RuVjk6|C)GRs_J!2dXew?VlAfdbwo9{e9|CvOHBFeRIUC(^%p(x>Cd(Jgu zxNY;j;)C>J)?9VX?#`eus8`m3r>bIwqMmuEAuR;EUs#!NjsH=U{PhL=cYib)_6Hxq zsDw>|wXM_+$v-+24chx}R!xv~c>KFm@Ew{|Fr;G>4LFdr`I}34kp?tRs&wAUV$>>H zys$yZ+G3e*jI1}p`-Wk`FgpuNX_`z8ofbN=)=^K=8H%mT8A5j(+O>KPFICCT8HNuQ zKBZp3Wae(Ab1Obfh#0yY@-Bfa+7kvhE#}Qps9s_rI!Kiu{lw53* z@cmLLfYQ|0{A0?9!3MP3uCrBbBhTk`)k}{j-FfozC0Zw7!UHUfLvKMVFlAI|v*h;p zAhIaCA_TH?U*sA~{k`Y>0TzEO}Xk zL=vQEpy~Ay3zGZbzboS_geX+FjoRJ~14?ER2ES&gv=s5*{lJ|FPFsTm1XHE`9kw-Z z;PhvIU9nsI+wTOGZ&|ETy$1)$6)|A)yA9K{BN+JMyHNf9dX(_-Z*cdgb80f*n(C^V2=C&}4N2O{1QMR6Ug)0;+k6-{wMaLhC-W+bx>g{1dJ;|#wfB!`kFE-n zyutXZRK4_&e3yiAdSBwW>wW4(`SLp|^v&e~e|`AOimhDU+XiE$#*&4+TKto@ArX& zHPGaB&+qEU_EMJs_N%GYMxUF`Q)f z>r-v&AH(lgCq3G!r`guJi7{d!vn)Pnj}ZYSke)DE9-} z;M7@_(!JlCvnpSw^xz*OOw*^}?|<&QK}`-ZG+!I(VSE*aZB7UisAq_nL>2BK?S8%w|n5;rW@OV@OFZdz;3h)_L(KyGIOQa z+O}}iHdhz1F+~Lz&PwXWiWijARQh^hTQms)UCE2`JI(Pshz)fi)bD8u5e#LJA##aJ zzlo-M(mG5lv(5ZGUNn5K(NW~1AL;o6ty*=L52wT1W_?ub%G-MIt{fNm%l>t}V|meG zL1$l$B#Hjw^bL)Wel6LQGKP_)8{yJouG}KgW2PJMpG!k_bwayK z7bqy#fV$*u-t0(uk;yATl~T*ZYXf+8X$&%om+IRr78;y?AtH0*&$7^E{1i9e$kCl` zI1S^==?RJZr3wI&>Ns#$Z+rW#y-wcGsSzGDcYB792{AD6Gw%!z+#4RLy<^7wP8!rk{j@jQ^ zmNC!R*uShZV;tO9&nzUJS%c7ged{4)Cx+8k} zLZM!JjK57H%wiIIVoKp3x37*Q`_zqmd3kufV{*J-z8-r*iZyz6!oU87Ai&7mneReJxYv zZ1_&f!XNm7`q!cURB=0F7kS#w-#t$%>8_ZHRfNAJ@qu=p(RYhGQ;wCD)e0E6UwJIL zX@Q{@Q0_MHwzUH7Z!7 zB;m<}za`w+m!p4bhbUZp9Fcj>qvNA=BfTXeT!A|Bu=6@1={`sWKd%9kx?g&IB%NBj z&VpA0i6k3wm{-D9Cx*PyQ>R8QU~ZEzKiZVlHC=nnL=Lp?^?}Reo|OIeVAf-`aX!xt zd6rh}o|Kxojugm2RmMv%Od>xpu+i{5@OGC;kqf@eKbbI{!y95IMSxcF3b{d=*RbNJ z$WB%u3=lpRl0W`x#`3@HD1(+=^>a*%kR#=WYYAU9sLl0b=Bbx4pD29)SuX^U8{A57 ztfRjM`k1SxBd|z0;^n*n11<%d(%Qsj4?ty`Gc1!ZXld!+zG*In_?9#l7C1w(quS+t zV(4!_XqCyy1+L(@@&4Z6>cqdwgE#Q6A*ysX2VKT= zDUIk|F1A-xY;MlVet^_Z`}|}-6@U2>#vH%jOw$-!&ZquKKxoS?RvPL$vZ)}gW!1Pq zGB2!uMk%u!E0;Vl4weQ= z2P+Tml_6ahgIVV;z*?VeV_Ye4uDyl~CxrN{MbE{bu$K89-xN#kDt=3t{=A*=)1?tr z#^d}t*?Wf@>VHJV=$$htwh##3gSuVZ%*&Tft!UZBEAS|HPEBXYMX5s#Rbhtzf2+VG zNwJJPDUC%eg5X$5*!q4#!*Zq{~S&JKN*B+vg6-7m?I`0G7Tl)DM2KoP*Jx1)``v8?Fq;>pe{2d z*B~=@)eVD~ujFl^jU^ZCtbWi2|Lihc>5H=68VS`1e-F!3tDLwCRvX^L3QoIHekv3dO;l|J0X(0=s*U|u0#ND5n4*6VKR=`)`%&Q( z6-pk)nNyip86Tz49o3yK5$RyY^s`-aOsqzsf#D8tr3LjJzdv}G^PsS?ah6=-dnnE> z2LHl}-B%zUUI@b^bEL`)HHqdJ;iSSKlei3Kz3Kd};sOKPcCGGAFD2pQmos={2`Bwt zvT=ItHv!n&rw>E-0{rD@u8vgL3WSwUzQA86d7`;t$SKic9fV?`j}eo;CToOvhe^h( zdZW4cSyqVc8Nye#vxGza240~V39;|?s*%Rxu70G<5>*wWpxw$0ezV4S0mRL+Nd{g; zhOH}|y!~e*0tk^{CN31jUI2A}z<#T%$iA1<=L$`|6W%JgjgId`I#cwbHrz*gp-0u~m6btX&*yI9W@~(x?;wYDW&drzi-<7bz*qsxO&PrxrKAhW zrhts76I`$8?NkXW7Vh&d{ag2@R#2TB5VH7?)Inmz=0MMMk(` zHV5U#_lDsFazt-hLU2EVR`w)3_wA=-5$`(UUFYBEt9`!UlsQQne{;Ac-9(f`)FU*l zm*qNH$rRcy#r}Z9{|+EYof522UHnD21MpqMavKx409Q$oTILf=Sq#gv$RW{|rv{Co z3;AXcuG6G)Z;WX{OW!yHl3a*ubb2$iAAS_uoeh_=I_AfI13`Ps8jSLz@%S_L3API+ zd(hg!DNx&aAaQiR?|wL49mb=-e6+FtLYRm-yqYt>Pp$2X;-gqLqA{^3UkV?_ zHQS$mj0uNMFfV=x*GS~q&BBH@w>10hX2D_fh8^%HC~nR#VvAj1D>9ZFS%G_|OWT`* z8DC}GY#s~iP4m|MejL&~bFauxtWvBuSfe0whWuDH(^YHn2rWmLl|4s*vZ+lbUBzwF zsy9eE+p|cf)xTeBSx#vR~=flrph4L2)!0Tk<80sj~~H@Rs@Mn1o)iMZj(f> z^==ydjEP~pZNJ5`$pTBW+19yS>y?DzM|s4;ov%^1+ZBY{Rn1qmQ)q5oS*-`Zwz!kq zV8G9Y5DRPeo;Y^lNwJ!wb3Nxi+Fft}Dq`vwBzET#cQkN>~Zc%#>n zFNoEG<_2HN5aO;Tghq~D!?q4U7MjmEA(`EI#rHT|s7(S278Gq}foixLksAi`$QKpX zL9Mti)i=Q3wxWxtCSog(lT<*$SAK+iQv;Dc8q(sshzRvuvRI4H`zAk4pL|C6>NGgI z@1AN%!(LL05c>d=9aPig!PJpsv`pBm;JKaC{vTMoE0So8G)fuqQu%AKa(|s9Sxzr{ zc}LFJsUMKxLEWabMfon#=})l9z&apUBeAl$f$uBtFz*%*KVwiPr1h|Ry8VWeB%e^| z1_{ICm9^o_+j6c?qx#txUw*>u-?Dps8&sovAY(8r14R>AUB^c0mt+rfb$ma82|f<` z`u=MPqV4awa%0My3h@aif(`C;QF8Kh&7qiBSFtkgqaP_>{1y*7MN00zIq_p@TxyUO zY!DS}`xwnLl{_ei$U_Q1`#qqNM1&P?r=naF{r!_45k+}wD<3vGKe~#a=tk{;-+Mu_*B>k}} z{R!Veha@Zvhgv01SosnDUM{-ZBL&MEG06P0B(|XVXN+Y&nJy))H-578$I=8~1Tql% z$NERflc(#dxs9@|`~;pEK&UM`b(M;~uDq`w>CqosRz*}VPfrtdYE8X6MACDEW|3P- z|H8ZNU7#ym4prIHb<~0%R;$twmk;^Wu*KVtZ8=Q+v@Cv{@@kL2|HaN9jQU)1af8B{ z*Q>Pb(~qH9FO1A7+s;aApGr3_Os`fRgKO%GVSx{0@ctQyFH)S_ALkuBOE>&Ygj*BL zMRm-O0T(R4)YN3SMH|D2^&`N7pAi_m+`LQkIqr~5uY1DPT{|6y4qF+Up~s*iU3jf& z6%;E)Vgemc1tWQS(G44=&tx#lOP=x=Wnub+ub@Tt5xd8p-b^Fiv`6`pt_m(RN~|U; zT+YufRD?2`cSevBhJ{-z5ZT z;uC!#qM6+tDv6QYgo97Eh(p6|f==`O_|nZhyYq3=iV>!eCs3x-qapXQelKd=#UhzB z_1xE9loQ?f@Y*XWU+_U?R}RFVm0u_E9m7cxk_mB4THu1_wFKakB~WUla=iEi>ypv= zv--$#%Bc+4UI2Je0fdG1=XV?j69RwV9Z7QM5hB~UlhLEr-@jVNdqmGULxU?HTa{R& zdwsqgYwRZ8{h}pYY@J;HkXkeVTvVsMp%dBt zkGYxfLK$bK%?|2}3Hv-%6g5cK!!gP0XytTx`%~c$ab@sWWumGWaj3+GL@f2U9j&z^ z96001jnuQ|5Gr+ZkthC|1ow=K{61b;sxl1NFe+?aZ+e+8UF|CS?@jY!y@7;zjVtmu zPLr1{|GxNrXV|nfW$XE}C+FET*JcoBcA3YfYH_z+*}dkgMl_lL?pIA#aWOXY#N(@X?`5UV{93IiWcr$)oUsuu_A#{I^dsKmu21V1pVyAEd?vU zT?9&cgb5&vyEg@q3?^gtl?6e{8Q_W~pPTY6P*TGcZ>zrFZ#wX`Yy zuCl`5(l5iug`2|nM7;SmavmQSgmQIkGwZ|sAA z2S0EY$rFRF^BAtGCh=7-nTeXV2yH}5`V19ceY^psqT{N9qSOn#V}=5+Aaa(89zhqf zU#N3mvCEC34ZwuWlsyHOf}MhWJKX5UEsLAE79<|f*8k##={t55$K@d?rD5k96b?dI zoaVqb{l+ggiF1}4;q4sXN^>x13298Ej6~gFG?*pZd_5eT;PZRWWiE@8W$~e)r|RY1 zuS1uF(fL@IstoBSL4|0}0!Um~Y#{8pUlKYg8mwcw+w5!?<~Uhg*TLM(lay6phUSr> zPUe?KEwc%~?h946=|F&(M3T5Jb{~txQl|8NMyJv4?8A>SoMlT0i29~y@Isw%GT^RL zj-D-pHc7=f9)CCs+JzyO-W=!fge6;@0m;O)`pr0 z?pozk%+*&0OYRAIY+Vm&)8PF%B@ELzewdrMYM9@~_($lKpb~wQ80DQnB+rs06{?3K zOky2=N@ZsiKaEswwqbsp9!{@6^`aF_+;G=FHd=2wjhv#v19T+Km*2$$5Ro{Uq;c6=+YGlUIS|LD=+JVNzwB}H zj9__ry{Y#nvzF$sM9uO!Fp5!-WP&68KaV9arf0%7sz!pLB=UK2SeUwmy7Da;dhO;- z*oTOMOuJ}@;j+qZq87nUlo-D1N5P*%+4v@+tAUI+k5u$Z?pM6yn9)w5=_e(iaQNi^ zD9YIuyRtD#28AzfCYsi4T$3va`7@t&r-C5g>scOY%q0;vurd#_#5$HG+diu9)60K? zCzRw9Jck~~0uvOljEfSYJa&R6(L#xJ-ZG$EG^^nU$7 z_)<)K|7vE15?}hE^6VB5)?Qij)3Js5MJX<1E zAozf#Z5_JZlesBPaTt~Czl24O^=r-(b^>b57z~ZI+&Uf?iB9J5 z6`^NuVPDd!Hx;vMV1hz}0{e|0GJ#EWcVjn!>0@er;Ws8(?OsW|tQTJihB)Z74}n~+ zRJ3(XJ1L3s&7pO(ynER*xHfn7lGGAHa$#8>>og~+D==zkXmfO)AbNWf>EP3Gvbtzp zTnaPrKGe4jKh_QD(H)wpTk^}iq*5VmrR`38)Fpg0;yG{58J?r{yR*ge(`Q zfc)g9%&4W70-d1QGoBHmvT^9@xIYmZxhK{-)CWW7`3&vH8C+kQ%R<|N5v8&BYY_Kh(W~gd2uUEvhz-RWdomF zVm}(vs+`Cd1^5ar+fr9XvB4^3ihMyjybx)POvY*N-GqC8G1drQCiC(o&MhGP2Dr6s@9R||2uMJyS`~XUAOhv;F>=hw zJmG)5_p)Mtxu`6B97N3N@+nDP6cVAe22zp+&sHf{F)ec2^HVA^xynILSJR`aU(3Oe#mwl=o7Hoz;qx%r0b%LbRmCW&V?hnH8Mz%36EGM{d zO+L<6A8^;R??>QcG+!8Tpj!P@mz_t&Akxs(#gdUlRj7U5^C7g>#~qs*OW1Xk9t@m4 zY&i+W^j;F~eZM3WHCr1(SB}PB(oSaH<=?x_{kFmBuq4diatHFRw%oY=VNrXiX59GB zMl2ti0nWBh?75+R=M$=(V}xJtf6;Z;QBBA3-WNeYiGir}NI^vyog)M#L_tMGq)Skm z0d7(vEuEv02I&-VG>&dzfOKp$gAt>jANM}@+;h+K+&?+|1Ltg??lwAblbT}bp#LV_)1R7X!Pn4NWS^61gTf-A|3cIX-zC($ig&e zf$2PnZ5(b2j^ENN;Jr(S%&nZ-Fs~WyIdyLoeu@x$j*|+R%_>&2T|CC^*Z&|^wo(p4 zL@r!tIBXP3^!i#BhL4{HEVXTza;4*zG9D}@QRGO8Vw*V6{4|VEf zq8s7}WrXhcR~-GYd=wJ##E3fKgE{-6gpu+b2_%3dl!2LE>>!y@{P$3opBf+Wy(0i5 zc3ZqLnc)=l@<#e`&M9D)^Gq{C{j_(%`taMR?V$&+Owd8A==DMufo`qmpM+IE+hObP zF1}?`^{>M+XTFZRBmbas~ZrQ2r*apOcINtm<1A< zJq)|Id2HT{MgG-JZDntXwG8(F5Z0%^V(Zw?0iowVOvzl!WL)Do5Kh+9#L)R4QWQW$ zWF9HwcSK?KaG{Q!R5Em;wrM#>d{~CKiAnN3n)g1(d3)GfHuCf{A=#FAkWN2kg@>0l zrFwNoP4YF96ES~tpJ3Ur96bYs2ZZJQRM*>ZB^5g@oUjR-&aj`Naq^oT;VKj@te8j)0(R6|j!HuG8Lo)wESz5LibI9!ogQEyF#Ut*B`a zN|+@%Qy2v8(QvIlt4?+mC*0hvg;>f{4q0Wl6nefeBKx4VRNri^e^xpxC=8Gp9cKp` zBrGGT6bamxE;K(_UBCLQ@!k^h!<%>nKikns^CaD|uixQYcE9fOvAndCE9P9;8-LCH zfVuW?Zqh>W+D(O`lZgTUce<6}+)Lqb2WBdE{?}LW>f{rz1Lq0F`NOM;8xRNk&E-i9 z5#8w}#Z;Wcp8+tkrI}77$o^_H;Bc1az{*9l8?7$JJU==R*x0*bjJuhE=OCB>URkq&VwSZ?P{vo{CE8PRBOuwn}RWAPD z6m9R^$i`O&KLFeKN$WrA$Ua#~__)OfSnTs9fzrlqLCqFk)*bM$fyE~Wn@GJlTvIC@pGqNTN zde9BBw`yceqy`tj^H9X#T;D6^Yu%2JBo--iMDjkC1~B;LYJ|edeLnrv|JS zNdlCGmLBr}So_&ZuK^rLkULR%G@s?R1k&l$ktMK8#|%KTjsnD2C=`i4?)+$_IB{V= zMzfMOeLh!iZG>V~U0k3i9>@p^UIp~fhtdIK9zP?TJDV%90OAWrUbLdEIM@MQsRx{Z zBQD9wmnsGCxux5a92UEgbfkBfbMBQE(#bah$Jb+(r;{CQecSBLgLGdW24;-KR$^8rW%!u%)AkNh=Vo?2Ke)ae(6@~M7Up@}E$%bmtbzD~hk%GF zZH%`gd-7gd#)h;gf-Vvr@_lziyiVa4;v$Pv`$dAaY~a^ov-mke5KlP%MZHcqBWr%d zQ9%7rR9$Fb){+X7Qe>b(`0Ux%=f?9zQ(5!$y0A#6FiajWrH>uSSbQe#d~Yx!X_x!5@3B-)1sQcZlEKPy8JkD6@Xs z{rc5jVJ}wQ_wHN~qMdU#?3do3bREdL&e#v*<6)#nqd%j$I93g-xAsBnP5O)YS+ef$ zk-uwarHJs1@L8#2T6tm^r-fIT%v-=fCQ3IaJ0QQBNs56|n2R+jh$JjFviizw{T8A7 z%lK(75LvEdz-7aMzVgxFFcJ-c7T(MdkR7I_j?%NrbXQYzx`?XDl!N(D8Vy^Aw6cj& z->cgOGpXJ4rl>|JHmJVc=dEiEL;eL+860&(nQK;do!gZAnNl{!W35bC zKSBAv&Tv~REnap~ld+NE81V%r&0TlDhoNi{vTOHnY8+b{J6pjsbAI#>xpomGNsPf0 zzC);>XIsV}<-RDdbV)qX8_R3g&|!w}!o&HU$}$`1j-Psu&9e>GS*0(YT<`^pxR~5iuR#wq%fk&ztIfjFIlGDd~Ccq>cH(zItBVcy1oa{2-?@P?XsN| zRc2cUWGXt_z9vC*&T!u6d3l7JypX&|ZT5vMYRfus?Zxu@+gyPs*S02VKfg+iCZTQB zw5-38gH)~#ij=4K5Wys^(s}2)%5j41YvX=3 z)*qHbv&HwUK=;xlLuHN6?{i$zs4Sol`rEs+6(U9zJ0x;fvDwn&V2SiB!JC7eXbzp^9y?ssKtNkS*AdBdTh zJH%6WJ>wBpuhG0c4^k@gr9`n0xdHXZf5(QClIkVCbRGdxkl|5Xqd5D9qj(Gb#)8V} zN&5@g$w?j0V!NfuBaV`KSwpu1=GFIXz6JH)9gzK**R6vF&U547tgx8$?-)Q%97KbbJTQhR^YnL#o~r#}yF za8$SaQ~PH(W2VJz;RPUgAuPK*GcYXfi8eVN^NN1%7 zR(tJ!LgQt88tX^~kLLql3g{R<@;Z+(sx=?-H;_piFRTsYw|iv-wxa!W zEL7Pt6()yKcN8WHfKF|GZiG6?xlSSwQG!739{l(sG$@!~YD)Z^~XgbDq=qvM&_1)+>X zgJ6Zp}RpRDlb?up$K%{ zSg@{vY(on7M{+v6^fmXDX;8;Qwnz4Ci zcz_q64y=cJRl&Y88mKgTTsMYF+ayS;WwmA|=HdJfKv?Kn#=E0+9fZHC@KtaTg;&fX zd|fQcx0E^h>(u7`X?Ju!XaIfkxubDJ`5lN;oGYYnI`*>g(2@IxUuegGVRJI`7K(xxc`&#UY`}doAVyFiO|&fzlKe>WhtZZCXJtO9Y9k z&vu~Lv!`Q;Q>C98o~F3+9TsMnlaI>FAL6G5Dgu^gE{Nt4nhp&z6Wg2fA8l8#>u<)| zK~oncG@}Q`s2$)AoiN#s33cteQjyWU@UUK6G^)g$c7qO zuvh^0){Tp+C*+jE%8aYJ&+eZ;2^Z#koR=bP#f~?=*f(Cb@e#MkVAwo8_|>#o?+ALN zXw%fulwjpqaz=HG=pE|F)_^o@c!Ekxs2V@CQcYG}%d~r;E91wX8XHgFX^;hk~d`9v;Kpd0_t-t6N`otJI?8LB`|*`Bm?OQ(D)nq#%3J&?LM1gX z5)iMOf0sx$6H{Wl%Qf0_>9Sw2y8J93l?;ck_)%OXPOxP|!6a2(?W!4)$k!3wko$g& z%zmA7^GrrminHL;sX}=WX1HZLc>E0GV z1EWq1forrD_}v%HRujnzX?iO~kTB{LZf%PZN=F7JFo=9ZqkNYjf>4y?yez0$0vMeK=*q-N?CS3|3uC3B=lOzx<@!ygbSJ` zc0lFgljlY0dRFl#B=*K+k0(EjY?0j|zL$N;m#RG^w&*UxC-ekHI~Ebc_k7pgX#T!u zqFiJLOQOW;4z??yv>v}uEX=v>-(C(m*Q|Q!Pkf zGcv35zRPiz0E%iuLdF@Ez0#3N)fja{z!G1cr!#g-OpQX!BTPfS2&DG!I1Xt$!D9k$ zH@Vrc^SUQwWFw~n(|hc6{BML^YR|%f0!LregSf9vd&6%aT6sxjgT2fMd;SPgIt*dD zHv(>a=i{|P^Rl#T@!Z$b(d7!q*pvxL`JE3A39uy}q~qNS1z2qvkV$%?`E@VRNZDfZ z*@aHP`LdU15p%<$1T))&O`x^F;r2z@`QqpA3Z@YDSE=n$q)`F1pQJns*TX#yUN?ii z;I(PZevFTZeP#ptqEkvphu zDfUhBP|P~FcFeOi7<&=5({Z#j>Ks&JII{(RW$Pd+ck-kZsjV8omu%r8~P6DCxDZZot-ixr8bwO zl^-;#jDG%_DE#+q7ukgBB>f^O6P42^MvWZ5^@nhUwdL28TOlj;3r327Dv#V0n!EiI zuX!y9jOL>`^eAr0-0?`?aZWX>J@YL5rT-7%v2glI44%XGIpt3~jp=te!SfWsUS6~NVM z?cTghFuCs;A%D1c6cT7`(y4L!w-jJwWa)bYENZ&) zgW~h$tl9$+#Iv@NQh}*e)-)}GgJQy?$U*ai6ezgKPl``-?Rk7%z89AhZ=^KrVL;sq zS_ajQSp63GmVoWuU#CIHz0FDNB!DM++b;WEAvoho9l{@F>!D*g@ zF%&;chO*p21Gvf@=-=Zuj?$f3K57C{P#0nKT~WaAiYF-+k)98tS8sI8P4CP~A3cmi z#d4)*Rm^r3KB%ZsVUeo}te$u-DH%lILhgGd<4o^aaFkL&O}NE!J0<5ECcmQ_$BP4V-OQS&HQRC zDsUWr2NzT8(cr^)XK(~l%7=6l9uRe)-_7*v9tpT)sM3Jv7N3X<`#>(Gr*ElAu%Iwg zv}8N+Tci2sYoV z&g*MvPqb=@@vTdh;DY4`&C(x8!yl2E87&#H4`4MITS5(?iZ)Grz@G< z<@~X(IO{&*S|H-{J`To&2u=RPU;V}gjyLgaua8Fs1bi{(8R|Dn4uMG$NFiR=6?gBN z1M);oMk9Cz&WCmUrs(Ix)e-))d5||AnXRtEw_G#rrZ0uJDDfHeVSK8`0Wcz#ku%HyGs4 zzG8;*L?|qsUsJnDhY^L}=(rnn@Q|3Vf+?7c`lTbSql6LnU`rWc#1|Iz`;QE}yc;FB zPSby5u(O<{N;#u?9*;J-QV?vk?`#_oW8bY+BHQEN{N`NNsxAUXPY$qs2CVNnliD+{d4 zJk6sibUJg5s9A)qzl~c&v+c^@-n|{>i;+x;9LRmG{g2mDg^}lm3>+|YXhAJ~*n;SKUed(5AY5=oRAm=- zM920+p`mT$PFa6fndmiWdf)Tz6I{Z&II85J{Lj`W8lhFP>MOsYoBc67 z%#>PWd(#3!%^4vLy2?=|6OV`fN%eEIx!TgZ&=fn7lGjHGSFfX z#lP?+5tV((7|_WS5jAl7-So)}GJD;?sbBG+Jfdvh&G5fi0ClEd(E^HXQ95YvAr$^Z z0W>`&+`l_#?vPEfVB*D!GEPtc{7&E z7J7f|J=E{95aH@fsvkdy$7cQeA~R3Xh_>Ri>(G0eBmAp)Usue*KL>4RCwbj`jjTRG zNzxrMp)s>!QPGtHLUZxaOBrD0X^dxZ_%sk^+e{{SI4;7Ig2n=dDYZItMig`c`|bzxdlT3+D3|;i94XSkb-+ z7EBMPS+(W7SGvpuPOI$*0D3s3w1l|M{>J*VEu=6ia~_)m@$Sx04GLqd682rtEc37* z1isk9Uh0N#3o&JM#%F5C^d(ezYd2T$H2PR4*gvg<}^=+QjyQcjJ-W8 zP@sFh^y0+#rrO5C97`Tsuyoj+&D6t6_hOIIM;NyH!2!B=jt|3}!7ht0^s1v^7Al>( zvL9C{3Q~yHCu>TGf1}_@bPL;KN^q#$ag2glv5HI?*HY38=B+%9k%nxGk~PzvpYKPj zsn7mvt@!`yj^2@zji=1R0o#)&ERF_ZcX0X(LCV=5K)d?3gq@k7rqfr=5~Cq6{!??!Y7=)lT=oE?k~^>Xuda^TPONrK`3b!X`~ms&Gc` zwIsJ$U1v|u{Xd?(>`Pgm77R- zIBqpy^o(Zz$zrfun^P&UM`^smBZ(P*cJN%Xu%Ukqu{o;SyWTo1rHs5bJ9nMzttFXw z8k0)s^L`0NbqAnPOc7A`-4%^id$;-c_fGWg_Xg|csW4r(AVfl4;my39YDSbFGpe^7 z>ar-7aC!&tsPve$C zAac6R!}`q>Nf`TDl1sn_h#(DXx~LgH1sT1t3Y5o79=TQr2mtAa4go=@3x5-YdXG-+ zxemwl?X{$4U9t*l@%5jX(VFf+9_rec4P;T?=Ugw%p~N>i zAv-#G94ykK7$x7WWfyvvRgLPs{;;}!Wt5@0IdyCEuNf>k_WQTuHpv-Bp7aQ&m|m$~ z`E_aox#yle;J^P;zEbckiGiXhhTeKxzfbzC-X`!1YQ4kB3YmwqwiPmi4o(u(Z0U{s zM6fxH_C|u**n9T-_u1i}<6A@%IFEOFQ@~2ZK)PD)o%M*T&)u zxU_BWz-FB2J~36(f(IQTxvkr>kw$I`3+h?WO}6^;Naqv3w)bZg@DosoTu}^jtRl9{S1sl<`u|X{_Utxn*Icb}kozLOg6zm>l zXXMp+?%R!dRUfJtC26Gxac{B8v{)#0sW5m^suibs^8CJb^c_}N3(uwqtA>MPo6;{c znwm$RmHw%Ra-L7T7Yht9BH?c+>BRtdx*P_^kNpeh>LWV4 zz)9@3sCUv9G}Jhh-j6*_W_*(FzNMFJY**THpy_~G4RC1-!~BNs`A~K_d^C*AZRnF< z{kDf)l7fbPnGgU=JN_Vu&aNiSe-3pqg1=)a2rZE(24_L`H5rkl2l2V6Yg9=zS@l&d zfdQ=Hxs|5M3gV8%Bwmmi`@<;$u6)dLw(FP_EEn0M$(f}(No9Kw;qH4Zn_OA^9hdIe zzb!DBwasF4EKjyNWmPt-nKGLTh|X$JO+5}b6GYusbb{8^j&*DNVOzoDpbwCQ#=H&I z(xwlS@M|CarWs!*N_Dy92B$cnqCB5O)RqedHwPo*s((G=TG*pOY{aavAp5gJvVZsg zzA@?@O92UTX8D|=6L??{WPVDmXn8#@?|9e7XLt13TtUsxRG9%knt&Bp?7xMGiPQRCS^4i+(=EH01y^tseQioQ$4peS^MXHVWRSfJJALH27LhpRy!4Bsrq%qB4 z;9a$3%qIca#nf`^X9m4i4DkaBS)hF*~w=!B&EA;wsr#ai|*FbSvIOndk)rL}B1+@I>1vRT=FUOtTADe#| zjrS-DY-cWvt9D+xHwWpuPe!h6?F^HEKX75?*GP?pA^mkB3M=b3jKD+t z^ye--)u$WEmyU=#&euHK5Hnq6!C%cS#Ht;GTlM+U{gvY4hRWlW{4m-}gt^WFmp(4drW_-04T{N}8pK7U(HH5JrkCe( zI$z2@IC=SQ6zC_3xyKJ2u^*U(b9cV}z4CdD$97|8N!9mM)r`8=L1XYcPxJmY`nTbE z3EtER!>)bRLapW_0DF5e&j8tG>$SkOz#?fJ%R!8`Nf7md6KNXX>yp%AZ=&HlvXK7i zN;@`TFq42n{KIrv_+07ESL;3%1dHg9dB(G~`(M6ic`fa%n)1u>hatX-JZ26P*;5rd z8z4z9op=fg>yiUN;^_=TR9Wb*)g%?je6zChlJe_){4SjlJ;Iz0#^#8CYhQsN;XA7q zW9uH#$Wle6PmFe5Gy+%_#odDBI(m??WszVXr$Cy1imNO+9sUTzQh`8vSqP~Fit3#_ zD_d9Bm*JF}6BK*G3ry+pj3mVZ;<>v=PZ2%bI)q5z;uD$Jqkl)HP!2?3u|cJe=b>6Q z0FUJ!BlkFN&i@AXaVTE$p9f+0^m@9HYo*H6sR3z3DLV)se!|zu!^bV)}DPXbNe{EDkPq`pjElkWFL@? zf8W*}&{|*9{05r3;1fTG-S6lj^$K9YBTv?ODD>2`$1|+VT(Rz%OF+-P8U3=Rmg*dLY9sP_u%Fk# z0pqViR4pD;t^WIa?QPf00bF0IIr;pQPI;42%<+NY$Z_R3uB2q2KcLls)ufUxsv;od zv9<54eq4##+K4=TXv%N4NlV@-V6M1WIy11<9wMRi9YeG{xJd(S{s z;;lvvil#N7>bcbP-$MkvJbK-yRKGY7XR)q>cJQ5UJ3}@wb<5PwO;M z|BzKM5~c*$HBZ~E{?6eDOP|DWKWFuAaJoi_9M<<-Ry~5hTtLkpTfYk25BK7kb1u&# z%aX6ayV}<8|Bch-vDG7zgZ1lKKgUf9$=@f1AZ)KzYG75_$7s7OeHpb;+|+ZzbXnk~ zK{t!J>M*w#{j+JhK9sS~x43o|mcacG;WiDbo>i-X*a zX)I$PN)7yRX1+$uylx?nb68i!o?huI&(ugYd!v4ZvoA9kv>;T(h@|O!bWbB!WT?tI(>TmQY z-I^6@^%%p%YfNzzz5X$mv0e-i92FSG_(NUZ=(+4w#A~^~RuttTdYqR_6DCa4&x+jN z{Kh8Pc*q}LYH$(g!wIYORQuZbfQrwiM%>M{zNK=ZtWy0rr@aNS4yzQWfYKI3zBbGL zE5>>{<&}O?S5!?@1lQI79Z7PqRN8=dw-QuAKe)e{#& zJZU|M^MTXS2S5#jO*Nm-MF&oM7-|@EhRDRY+DlPSJq#a;haNNnSDF5{?c2s9^S`d} zl>E79lpvmKE^q741P))!7SCoj6w+R;2M?(`_30Nq5;t}#{$zIrvKmsvqh2IBeEePJ zja}Yz;FKsZKmk{70I&{r#lO<4KWAtpVjUcRK;Lz#(=s(vB_`?4F_zwq$Hg%k!&@-^ zOFX!$5iLSBw`zlj#0HAcIB%oT@T?j27y*|Vm$Eo}CO@pP{ukxhJ%Xa}p7CCAoqo<} zu>d#ePni%xLR8Asu4vqGn#?B{p$B9+loiN}m$;G%DdV4k?oqpTD_fw#Bh~0ZZ`~Ra zI{Jipw+B`)QY&=ld*D%`$NWa`s_7x7q1z>pwYsOKw9=S)q`TH_b3NA*WSp<#v&#$!nj1cOBML z!nZ!H8D{@=@Wu~3`I_!_hPr^++*tkZ;r>N2rFBDOF>{4=bwx zxUem&sPo5Eo*&@T2XtHVXoM9C+`1s%tLUrQmDfGTGXf{IGPul~#~*&<{q6GVJf%z$ z37tM)VPk*acO?O~&{NUwr=6@L6u92KcW>>*)WavrO$paY60}mIwGHsFVDnkF;{z)~xPUy;yz_nPdKfT1? znUqU#^r?M)^+83n-M9%e@W zF^&IkxpA4#^JAVA{r9+!R;kVD^|TFRh~d%N06ai9V%E+=+lKpl_CW^rrDq8}+X!`WE)_k^>F$YWDOWmU5=^s4uJrQdp4z&T4_ylq!R~W^oGdIZ?(E= zNEy1|P;2$m$8vL%OXxw}4&1p*8g75zW6#>d_X}WO+w` z=VTS%Y9oV3J_QW>kN#xFj4ZTSOpSA(vSzC;ylPbbWqD18>y5K7XP%koS@mIe^ARUv z8_;(>=FbDN3mKtrL#!786P;$=N@j?&yrm&FM{rDh>(Jw+_lpL9V4b%9|)dq zgHDw{p;k%Lh+Vl>*Lo#zOx~qS9*f)d+$XP)>u?5SKi>okCXr!NquGApyAbQa0wj%1 zg?H@qL=nH#_`X9CGGO?5v)Zz!xaQNDVWd{amCG0u*0U^7)YO{+*j4nh*Qw75hxJ>j zegQ1C$cz3^4d zt3Geu+MzaUcx@*FfS*;HDVazvf7x6C6!WQNA23J=aQwD81DRSiYzwAz0}A7Cu(R&z zLn5+qFUJ;#>)Q^>zp_qNz{&CbQGXE5eI;tQC_?Rn#r}(6NqJ4KH=Z}IexqdLxmLQ; z#ep?d1LX~C#d6*Jj2`fy84d^==VsN=J!<#%s5o6NMe=jiaol{}e;@o4E7pJ)?*81L zpoi@<9^?*j`Md+O+5;&ra*bgckM|#fXqBMBfoAm8FZ}N9$;vH+1doMgUEf$BVFB47 zltP=Jw=|=fDRW^zM(9k35?G^pWV>^d?Z9WO^inH_s{Z1`kzD5Ra5V8|z1(rWfWb$5JtHMK?K)2LrpCp2W5Q(L(zZC6HHTEp_U8 zI6lu#Z}H6Bt+F;+X8*z(-BO1YApz5V8;bjL)7u`p=Pt4~eSm1S`~0M#5w&i({lI}H z`dx_9KbKw5qoCqL!7yZBRw zYwyINJ)d)k&CIQ-f?SVptjcdrk8Zahn09(8ZXwXHw;u(R9Kx{?)o2w2E!Ho&|y z^ZiJ+DUJb3pLzV!_(F=w1-5Re3WX|Cl$plL^dPzrJu(FZYx*Cg{O4QL0hS3};;Y+c!rAYQwv-Pq+C#3a!m`v}zl~u8ynGGdMmeot<8>K&G#Ev* z&$H}>Y!aNs2S`=ziHdzwtQ}io8D$iB1!G^_hV^N$!%MdN5NvofY0n>|0h@Q;Vf=`e zq69{7@6cluYm2Bc8wN-Qj0LLeM+kt`GnI0oneC?N3A=b`}l& zMp1+x|E5>i;+L>7ySOXS(I&Q6am*^RHy z>Sh~H;vzWqcTl+QxkXnyP0yT{|g`Ne4eS0PN%(XP{ktk2yxjqw#$#@fPMcx&R$j4zJq`R~bJQ*C3s z3TVs4G{Pija^u>Y(oe+O=8NlqiOv`pl+YeCh#7CMUmJu;Ok5aOil|AIFDVgUXj=;Z zBalfqAS*TTaLb(nCPemJdWXo+y_%Pv)+DW}=rz4S)xnl5Np!#JQIAi>)y*59(?k(I zZiF36x9WBmA3aXQVM;xg1z_L_o-LcYJJf!Lxh-NA^Qy+Yo9*h)NI5<_(5136eKUWZ zD>Tqda0tM^saPJm1hM(dYrhNA%$Ydnqnzw{Z@x>=!5~_xv^0vxe4L zDqz=WYzf#C`$dkD=d&4_5)^U;g`;OpKWBwWKHU|ofnWExIRgGkOwxt-wVjj0rVCHv zpa&OZuWH~(KNC&M%J%taBjk+v8*K+m<_W!NN!?5GHvX7-m&x@t&u^Zo0z|zCo+joP zDA5VjuDNc)Y4~cAIq)?hD=q8_-|6l!>muVz)o*iI9vvK~3yngJK;JdqfJ()$fhInM$$V9Y&Dq&Qlk43G}yxQ#~+`9M} zN9f*Jr0L_1b~CfYveZR)O|J43^wP{7_^pcP9O#gzrC^i(;=(kXyUJb zvnw#v;@Tv^!Z*)|z+~G4(K~u|rIj4i9c&LIW>QE6)rT-Sw@^Oxrd-L8zn=+vV?&MO z#xd|xG?pEW{c4Pp-A4wz*R6`TE}rjcb?{hZJz9ny|FmN3hoOh-w5CO)-l-lf_*MH- zmU|QgDs3!02@xBBR^nfgD<*4MsB#z6g*~xhtejYovmmlO&?$KTsk+3of;Hj>uUyN5AevFzPTJGWc3H_a!9-_|@l-Bri% zhpBb53?R`SkI)G~tgm7PrpM@@;*@!y9%LLpMDY*!(sFcLDG(A3K0DA{C0zPlPBTlz zCYcn^!o*{BION9F#u<-vx}s)*U{QSxcv69m{spc4-eARUw*XQT(jYE{&3Cf-y$$b> zyP-JLJK%2?V}8?Fq@4x5sL3)p(J}tb&kkWv|854uk?NkplM=y3ko8Kxu^aj1(yNcW z%oVFsH3sl?{)7?(m&%Tiv z%ZfQPR$1A-P~39w_%pC%rnCNFF{8(W4jbADdbMd>o%2l*=)2x6P&$TxWH*~$m|Aqu zoz}Nj>fV=;Yxnhb`DB>y7i@j8)!~r(+^@3jjiw-Nxb;2WK zsbJF1<`>o7s#Sn(k)Ml&zZG8hIazdA53JZ8FJD}!XK4D_+(T&!G3E~O;8`jmSu?er%Urr1E%wY5PgP_86CKCcj*)E6Rp_VOzlK z{s&$6hNaK!OKu_5do@ zOgoTP+k_+Hj;^P^12UQ;)eq`eS*l|)=Y;09XvwQ}PnSb<%-B|i zA>pjYnk)Ql`0J@w0@XAdVtgHUyhiOp`CBL^kNSO^1c&%vHSTF7y7vo~{eaXQB~!Mp za|NJ7C?)*21t{D#V-%X`My-her{IF1>5<>Drmo9(sf&JB>D`Z zRTBfFBX0)1`$3Wa_@fDQqpaa{88Sf83El9YI;b1)6k0DY2So78$P~axzDEy!(M`^j zdy&DBXp2!(xDujpr1UHj8kD(KR= z=B7OspHpFWnHV(;cyd6JJ9;Ot$1-7tv&u?&)Te=8+h$Xv6%{MS5lpfxpIK zOh#A^6}B)=2b9h-v>i}$5&JMp+2sfv8Oecf_{)Qwv7lwkvw3M1X>64b$$sqm;^%R8 zx-WEtmvOGd>$CbdHK!vow7tvPbNjWqpVFtYkho8(`zxCFK-S7z0u(DAHMr!fS+}W9 zt2SV>zVwB_Xjrq0?yf9DRLO$VzGVJ$20fr2Y*RS9++&}Lrnx(3u4htZT+2w^zis+# zYgEd6hH$THtFC+?VdbOUyycZG^Vy5@L;ge#Z#Wl;GX(~Hneo>+ex%iZ@OiBb@+6sO~l#jfeb)ZL8S zD;N~|E2G=r2q*-&_8k}GrLovJEVA5{=U!;U$!~X6C!IJi1(!G!HO&nq^}IJaDXhz( z_h$03{Q9-zNcP;-!@0MDi!Et+> zu@g(M;ibfR{2$7`IxMPn-(LX%MPLA>yBh)NhM|#GB!?Plkd~GP>6VrT>F!XJ?q(>J z9Gao~x7d4o?z!i8?z#K^!=n!mGi$B)UGKL(!DdKBy8l$2(`T5IVeJb?-oCL2zv>&4 z$RnE7y@tYp^`IKH;aAIrC$Bvbdy_yp!{_{Z=`8h$vDgJ3{fhyJ;IejB*H;K4cf&AM zSd?2-Qsgp(7d}?T6K0Q--GcZ9yZ{^_T)ggp{-O7fZ%=wOiJNk7f6f^iQ);{X==e-- ze52jEcLGnW8Jc0BO$OV(BzfBp5?|VPCra-hqClK8`$b$B>Al&S)sjwV@@@gtf6A~* z(efVr&SKsIt^JGNa>BJV`CRTytRZq1R^~{aGISH9Zls`F+G#)JB?5-7^_2U8t|Z;v zB2fnPT+fbc(llmFDN2Yro*_s{Vwp_D1J>vqGfc)a*AJ_I(E-}GZv0+Jw?BrD;h3B! zr@!1lO;_n1-crFXkzyG>nLaneW3HRDpzq1p&S%{%&b9^0GvemsdVw_FT&US~tioZ~ z+lF!1A&_HH@}ZJ0Ge8dr*i?J(>9#3FzUtrGu)++l`bib?J5gzPy1l=jI&sq7=>J#rIj!kH!k>0ZYli36B7)IuC@Eb zYWN}HT?C{XoEd8``Z^)J;Nz;j33OIFbdU`yS3G%#mBsN+2SB zS%IjUN>PwNBF7@7CT>jKwea%Qnx~Ced(wlGbH`QAXOLYG*} zqIUUfJc~7s z9-7&nI3lmTpJCPA`JsjPIRk#V--h?PlHf)V!CY&B+clk@lxsgSmmVw9)4CFSbkTL< zC)aN0kvymrl%b=Um@zMocp{$}kq>;i>pPE0i*J#v|2l$?AJVCZ+~AgS=5A~Vuadua zb_TQ$2Wt}uB22@c=<~)nQI$#3jxF`+5Ii4}Q%ZI$^W~)Ysd`z&lPZV^SsOYI5ItP` z&aw4v_~Tyn@Rsq5pdS3`I9mfmSUbkA^)D7AA({GnTmB(hej`^AXGDmk=jclIm|w|H zzs>lAPRo68}DP#9YaF%-D-2~BBF{nve zjO$R5sq;~lu;YRwD=B0%7ea$l4{)i}Z zZ5PU<+vlT=VI7sAFv6P0cEG;u;%noaUdBeDQhjG6`}$M)<@miw;~O$jHXup9{{=_? z#wDmiz~-XHQ}oM{Uz03!r>uL;PVFiyb4m=Rdmf@GPI5`Lcb;zV?T@;cLE-BgU-mp3&%BB6ez+`8 z2$9V28sO}9KfGEO6RTX|&%^#G$;uG57Y#IxLI@Hl;bWqLriIpZ=WC5^2wc9Iw^&qH zt)cIc9Z`@CR$BNCJ`ApVVEj~Qjky}d!G;ID8BCcJbd#y7{zp36fk)Lb*gd{b(fwVI zZ(PN6$>txOgu8>5hT#4#{9VH&yZc67uvPq4u*S}HLnb)M9VK;wYGSMZx+xPeGu27$ zGz+RVGvcHmuD6|PEnVYDOF!`*vrq~31XxJnM^HeMu?H<{Q}IJ|-Eflotb4YwO>H4I zcH0&QmmP^svxMAQZ?=*9WD1bUJu z{DFOJND#=;&2V_Q@(_x%UTDqoJ+Uih0h(axt>a@QwjYxj*-n#fRZ%9cxyC?sUZt=l zD-z}IJriStm}vxzlo6|F-c#z-WD$_LI4oz}((ADT1F!hLe7VB-MsG^XxUw;8>pE_k z*2pn>k>i50A&o@UD0G^(5hJoTvFv$C_n#Q!CvEXOwjMsS(4h@;c2d_X)}H_oD}`fd zV%Wg6eUagqa0ZDYJK6!$f1|tY>3LI^*0PY-E@;HtQf#P(f``lfR$&YKa4d~SHt#EV zl|eCZ5>U!W7xU^Tn43R*+Xh#(g->+zIfo31A;vUPhoG>{w)oY83)N}%0UQg6R-skB zY}O~2S-;O<@Do<;W-9*| zfF2vw7wYiO6enBfYKd&C@?R=;rP9Bn6Z)>>-=BIk)as5o9wIp1NMNWhp~*m&jW6l_ zOIml=uJLAni~sge>^82QSmKF*oGCb%)MnlFQgAYI^zF_MfAZ7IU~+f!GUt*a(CgQk zIHiX_?%Q68e5yUWQza(b20e`38lNcAa9l`KX0Gw-Njd>Kr2HZt7E7)+^m@;c6v^(B z#T~;`*C!)Q`dIR?^aCzQ3>xlig8H=0(qB@NBfhe7z!0wdY9<21@3akbL)SV5!rx|C zMSOa(&S7(fgyEBPa7uP+UxInjtYmf1J!E@w>(yl|mAhdN@$ga>%8MD(xEEvWHX$dn z%_8TeUgAQh5o6_-pRvdWqg?u$_*RKK9mnw12m(P)ACWJQC~nVWWtLX$)Tek`pM}53 z@G$mox$q?zHa@#dax;>>d1L}NE7+83%Z8XR&dvKQd0+0FlLr5I0#d1bz&@5_GYI*} z^gR*QQ@n?FPA_apV%T}?ckZb4{>8$gsF`&|Ie+BLo-o5b4$2WIs-MK{>Ur2!ND9I-<33+T z%gR-%Mah%#W_$qdS?#o_JI6rKedI;5eNFEx8|3s0-!vyniUh4x^;xy%K82U%nm0x1 znsvZQtyo%oE=SPn;bug(xt zral>`04ahoF0(*XYw9NjbsXoM8LQ#^BZ4KIQ2(3Fxr}Uo>|>rF!9l5u396mdpPM5R zL5Cgy!d7N*SUyh7Jz3noUpkz1!kYlsU7xd2E9;gd z7}-9342*{taDN^vUXN|SkunI+HQV`UeP@9tL&4yEI}siZu9JUbH- z`5(=+=jg3172jJ|?nt*foui$JJ!(2;P&AUx(gNpZPfHcJ6(>?Z@qM!KS_;zQovp{+ z&*y&a%5F%hf~QgeXQKRV02)L@-UbU46b(J9WAehp5jPk%Ao)v5u=$NZfeOnQlk4 z449k+GfbtRuxJ=s-v(#omARd4L0vgl(Z$409|wD0CRmWU-z-6XRpnARcYF~o75BWL zjaa3~dYX3v;#El}g4lWYefZ`cSmrw&^8t;7&gwMc&2WZ(&@axbyXMZcJteAenw(3FeTm-fDUxWd5XI%j3wOZ&7KWrZeEuD8*GukpKUi!fOzG+a z{sp?1jNimz(REBFPusEg=fk6Az>z3L%frLQLwh}$#3yqzkLik2%~gBvX_Vbx!{o>J z*_~9NCgQd(PNNKV*}J%Nn7pQc{_ZXe`$8k{ow^Foj3Nu{UcP%P^%+fcWfU*@(*pb_ z`TnPJH1V-=8+ENDE3w>LP4(H4cg)4*6tsoSVH7*+2a+G9`BmnJ1RN9E)`yx~Ph7tQ75mOi6maBw7OVk`Jj^Q;!jNPmIbCdFYtDjB2~G z*rU-MqbogTqS;(xWYZ}-rPw(pbZ*PgwZZoJ5*XobYVX~f0r0A#TqrclHC^O+ltob8 zYvff3Ke5c!aIzD|$)YXEZwVe1<#0$Qw^*yoE><~#M%u>AVVQzKn)&Hh?O!i)8e_Rs zBKgQ1civ4j_2u{%s@UWV6Yb?}AR1fv z-qe!y;SNSsb|;McSg2l)$|S^XHGypb8H=CN=S_y3|BQswP`~%9$Pw)GLQTh4 zMD?}1MG;MRGo97KoJh;kq&sH?Zk?>D1!61eJw)7$;kQ;g9G5_?h^Hn~(~r8cuwuwv zdaGj!(y#Lm;>QY3lB&L=vnrzSzj6-mk2+aSy|`#dG)Tu?bz1T|WdrBSBsuyXZUOI` z?n+zHZZ+%SfZBTbTA0}9;$aQg?56W4+e|SZZ<(CZTbarE=aF5}UbDF+g2xsrwcd<* z`fjs!dxxgSmJ~zEB7EDHr8dLGb|Spa$7z;i5ut5}+P1E|2=^(~$w~L)cU98Nq+6Ut z6lH+$X3rKeRr`d@XThJ8m+=QN_IWg3S5r$EDabaPLVD>i`BiMU*_(+1(S-GhJ7=pM z|LX&5%C7eH%v;tw*hU;-ri({YKVTT7Rk!GF)l`3BmUYp=PGZu_5%IEMtjMcuKzO;N zEBgNVCq!J{k=e4H?G$;oyCRn8*^gIXqsbW21Z(;G_ z1bE>@1YY&$Ff3dL#p%GiD*mCvPZsGfk^-+#K9RdCmMH=7vK*|V<$nWiE>iv!I;#bkJ@PH zkvuf1DHl5yPR51uPK}(@xi5CET3uh9y=omJwwjzg*>u?)mFQ@1BPiLjD8H|0>Z5!%+b#0MP{w>G9JxkQ^+ux&l9iBZxmLm`|ec2xj!sk zpDty_1cpZUlb!;JqQlpR46Krp>JWA6eH>@im8RnIwkGM4SQ~h8iRN*xvwlP^LSfXT zczOXAc_?FOL8VGj-4{o#L!l`)-x!hs+ln{ONONtW?e9kdSI3uaM+GcH9KSN0m%9~X zwr_bV(~3UFKH74bEz-F7H0|y4xzaRFv(EC;xz&2&WNv4I*YXy&wPg=2*AM3MBa5IR z%d4^SifcP5-)5F3$O<=fY~YOujD#gv>LmpBzHQo|tQ9#P4l)v*V82u->c%2DghsC9 zacJy7H(zR9<_N-gg48!H)x^p)=4|TqVROKAufN7(9B=|I)qFGbF?pKHU`Aqp>)39l z-Seb6bI_q#KVj9skCCO^$==!GOxx4kdY0k4T|&a}hcz_ONy$dhsUttu=>vZ(!NN4} z3wS9(l2iE9;b$AR;+D1N^PY2FpE)&B*QDJVT;LG_@r@q}S$vcxp5iM1nWq^}&~yau z!flmOF!;gx7orIyernk941YnI{#(KKZ~pV_GYq|(Bu7p z_^!@Ua_B_osZr0J+=E!eokZSM>>|= z?r-CK|3e$_pR>{5-}zVW8Y25CG8bL`A(EV(o!w0dS4`m2L8L3Kd=jsmaxN~8Jsep+ ziOX#^CPU*N5HFJZ4>UFU&h#=x&wEu8@8m*O0Wtu~ojY68_v5gQCP1S2mB3X+XJ44X zQMTl^w*I<$FVJE^q5culMDw;_hVIXgdiMr>W9M^092YL%yU4=JfoHB9zrW+(Jm!B| z4d1+A@fTC+JXWt{ENFB{mRu#&)jy;A#Xp_I0)j12N3_E=+#BKh_(#s))_L?oo9QwQ)u-o3iue}WAB&rk2a z{iY*UQal%XTS6}dQ0h)0CnS_}z}ty-mM>an=%8XQQr>y?fD`sx3jiTY%Q)-!T#{-T z2%z_Yi%q0Q(krHfJETiGVbYXu5$;%(RCZC*>g(T*gij!u$)|3&}d;(qjkKwt1ha6a}( zfh8#!C3FDyZrT|8mx<&>JxU5i)xSf~Ykc&#*S`ewty0R&5Yp4U33&xpCSGhBBu&n( z7lJ%Uig@=I{F4itpR1j|{tsXG8+-WQmI;_CCZdZQ;+~@$<9*YA7yyFF70K2o-rU?+ z`_I#oPzz4F5Bjjf%JofL;t>yQWygTtn(GcEg?ajBUb%|i#pj3uAJK4)a{SI1*$Cl9LgmuhNdog6~T%6 z>7oeD>rKJ+h>kHy@oswfzU;{u;@k2+_S6uLd9<-mkOY;EpSckK6R}y)ZHm&Y97)rG z6j=NFcTaQ5#n|0#g=`zUXO=>WD%PXqw`j|=k5KeVj=+NX7LQVFR8BvhZL>)Ex2U}+ zF5yOPjjqVdJm8$DkS~2savpQa^p);xpT1%7vC44HP?aZ5$3e`xa_%w3n+b{e(yc_h zTw#yj*Z!}!`uigP_0;(>OKq6FTP;0u{kx+eOoIZ(*Y`Aq4MiGGR#8bXhTpdACP**Z zC}ZI86D-0z#KGN|zjjnd>2cYb?%QC?iS&1` z`}dpI5i8vRUZ4vrDE-IYv_v=ksMC{+@i4D6Qce1>Llu1T_$$-x1vj&`3b@Ngj^Po= zN}g&GRo>N`*80|Km1ec-wy|=a^YDhNU5t9o<#BxImyrd-g`(95x^_A)3r2%nyhntM z%|Z?{;)T(JSSY4A9}t(f+Kr^= z7X(Rg4Mif7>v4mb?T54vXM~>od?!?>^r7Mhof8M^qKub6nUc5ZBk+JPB_9pQxe+e$ zM1lw7=5-m)55k`fKltIhyF)EeQL7ix`zyb9&HuE&ey`JCCnT#Hicb4kGe8=|98BS1 zk$*(bz^WE*Gw@f4;zH7!&ln2w0FQuQy*3KWQ~YHulV_vqAjHLJ%Vp24XA_M^Q^Qtk zeSPgO!HcPGbS40rpK0p>e_E)bd{^k`uGsi32y| za)lJzsUW0Wy<9Qa`UA*`UzjZ;fx*cs;@#56u_d9`T`C_{-RZ-b;C?Mv$>XLh7WB@4P#Sn^v8`6|?Qm03E) zgx2O>O}pb#K)@-w$a6M;DVuupyoES9lUmn7c!GmEIai45bh_%~bA5e%I1zWgvwus; z^}*Ty`3Y=*KhbYm0xBy12+aV21LTV@Uk{g}>0)qNpq$|~uad8#+qTuxYbQE z9)Rz$FD$=e#dD{Re_C*4nps9@_9f?*b$ZFc70!_gHPxT@UFp9?l}f5nBZccN3=M^d z&(p#sVXa4Fiv2#fr#?x5)nozS%=0B8^vtnQS{!V9uK@tJ^#PDOUBI6#^_x`ppaVuE zgYCb`jsD|vVdX?Yf0hJZ5FAVEeW>^tLh|RZ-4`eFeVJSI$)?rMF(;3MfN2@px$t}v z@N6U{oY#0K$Q9?QtK{h!SGnt_$B}Rj&^t7mG4|X=&UA?h{kL}7t%ny?KR-t$PSOuD zE#N5Mw$0-8m138@-o-lJjpU)Ou{9&=2BWQ;Jad<6-}N1voTgY=d`@)o^{s#2+%GuR zD=e+q5fTGSNm^n6AGFs8P>thb!oQ@c-dB4aL@MN%2&q-&k?6u`P5s*+kuj*gEL{=jMj;(_K)wkOj!Ng@eb=VL+TxaTyt zd5Rx;f+`A>n|9p#{)qD(+`!lv`+Dn&yLR3CKd#b0y}yPQG^#$CWtA@rG>>Tn^n2up zdJSNFzeDe>CA#p_L>5Pbp4)zF|B|_vs28Yx4WS}=KA*%G>w3r7$fUgTOn&_2YCQBs zTwyl%GcO$Wf8->C-n2AAb0)juds$B6yLTJfSqVcJTcqOpRb^EwNd0q3Rt%`uUBDUl z?QL*Q@tJ;1#9;(b^2R`<>#nZ%d?0t!>gV^eaL8cp?(VUy0)Y1jt?K5R4>xzH3|!|? zt=69CXmY5o!6X#teUTKSmEMIhwh`&7#dN-)le9y2QUc1!oNA@~SKNDuyi~PZ6e!1zkiGEYxlC*k1C-Tg_Q&kzIe$byQ<+ADPzM&{*f; z6@&#_7L?f*gx5@SMtWw7c(^1ByE#a4iXH?u0)4ZES?pFK?l-J{QCIQ$0H8OaWzAo0 zFxgYTyltYN+^x&Ra|iqABSV1E(1WnMr1wWMbwz`7fk8n=k;?_gg#@R)C5_;TY(u!7uJJDJIbC4 z3F2*RKbz^1d9KRkjH)lhUQo|zlhx64lfH!8k08DI$f})`!~TE+$3A*1CHBJ`H#;{| zb0zo=A%W=vB?wEJb|LpZnszNW^?VoMhz=1RjGc+oN`4lU7cq8w6A;g6OwA|XC)MP2 z5C%yjqr{5<4$yg^-@gg~SQXO&kD?;!>|7O~Tm~wBXC2ys3wp%>IrK$gq`CV+(z}2_%ZtS9*rn69L}vq#^JNGpmyT0wwmf*>`j=> zc$pxgO4`6rf3s*@R82R%56cYZ5p8(OKd8*ZwC1lU~t_W5Sw#*}NJI zii^qxpmVb7n0cvp=N{1qTw`$Z6Hf`(I}4yYOmrVGch$iHY(?-Z)7sE2L|*MAS%+*7 zoNZ`CslFk>vfISjp7i49eV(bgANTUT!~*!<;SkS4qK?CqN4575orMXs{jn6b%5nP?~fZ9>zfFNRxKpi#$X zAwm8U);3t}BLof+I<;(zoid6-v(Y4Uw$WGuE76F=`j$LrTbGh@Z3jkxmAkKt6QDZy8&+K1U7qYhKFSXF zukN`|NJ>OXq;(21LiJDoc9O10a^I`opuiIw72D{!*C{l6cTCjbzbaYrPZAc^W0032 zPlj?nVEC%*&B>6U%zfH;lNFXL*25=d(mxZqJI1~L4h|=V?0VN!3k%k~Np3YX{s?CT znyhTqYY|LwUB?OXP!~6IL+=W#OTQXC@Yj$PlGA4rW6=GKnlK7gj2t@mYPB8}hZo%> zvp+?x5T-AlNY=AUO$1Q(V#fD`TTZ`tWdq^UPWB^Md_aWz)t-SDiNqA7lpPK#oP4~-(I9QeOp@DD`D)SY+! zdl2-$R)xTH^Cil!trAoXlY<<@weXS`+v|!?$NFMR{-rfz3Z;9U_)8uuH6IIw98Lm7 zes2g6y7*@ckQIKQziyn2NPK(Smz%Qy(>co5xZ19Tt}aB=JjF_n9E^zwRtBL-#g!=!^~qeS3FTVK%LBmll?RRbS6bA-=+_(K7sHV#0~ z+rSMl)_dLLNBdA74~YZG4KH;@Ja&ZWvDvmpFt*Rdc&3iY&0PDnZe8dG*d1Uey)m7d zvu$RxI{^$h$E%hjh29)N27RA2uEZ)jw3ODtK9^MWF+8Y>h0m&-p;UJ&N<#JaRBW&100isGR^51Ls(1{l{Wxz00d z6|O&)8G*6;`-uOS67rw&u)ltj0rC}mGynF({=+kN{UulVU@plya}b`hY^On0)SnT* zLXQgMG?3ek`;eF0bAk_l^x4QL0UuRsp5uG;2IM@S{Yoy|?M`S;(KU23L#QvHA#T>P zRJMOvqAPFfk$zNyvsQ{$lh9g{^E9c(ZsT6%*eqZ95hjQ(QSLL28^;Z%)8pq|k+2XP zm=opW=XP(O1?j4|q|#(F&{83oWv5%*e#wOL%0*C*NWKnyCLgc8V49_3fHKuh;nU)I zIk&_Pxfx5Azsb`_k?nd7uygu*88Y=KB_&5}Vw_W#^F%gHL}&7bF9FQB!>BaD(~x2` zGAC7B#?c&1zg1v+>3RbzQ{SaXGU^H>{6MB)#0C_)tgsCI7gHCH@*R#wW$Q0~ z-QMv75b1lK3|=AtMz23Bm^|g%n}rU)KadNEI|G%_HUV=-y`MJ#o31>~X-r=J(UkWm zv6DMAJOZ$w7Ca?7yrUy!xQGHCa`aeH!U(xd3DlcGJMTDeK;=A>6^;CzVZX`_MwjSv zICtM2l#q7iX4kd7Ux3_gCB}lHWUp~O<0ufl_l(_d4?uWg9O{ape=}-Gy94~YK10@0 zf2NgL-F7lLKfKMc&7Fj&%%8OlXsi2CcIRK0R1Yyj0GjL};mW%XR8fC{l?^-<;Yw?u zW}8*HN%OfmU)`=ARz~oj-EpYk&0PgzqM2@M$@1=~Ry&7@$U}eZxC|;Jonq}d+7|xU zt;bYdY`-(f|Ea9+##*sO9P z3)HD0ujV^4F5*AR7&MeM1KEV4H&Y^Q46%$)rE3~A9J@om`fd5i5#~-70ic;965>aa z)*yQiLH{(M6ppknPO_qahc$iSYUjw_4jlYMmRL4I(=8{UGtampu9i=Zw4(9UHXbaz zN-xhRM-7ertPIw$#`D#@m=VXOQn?1|wi5@yd=w9jKPLrO9v^q@4jG4|5o+w-aUU)I z_G|;w7p=Vw0wfn>>Z>2WXx2&?Q05RKzP5vi@E;1AFRzR<5ak)% z-WqQr&mI;LUV`ldH6Iy(x9DJOE1E}l#Io9b+@dFXSYlWEM?ZPW-CiUMI$NIHDQ9yd zwg8vz1B5@t)u-Cw3S?1`j}I{2%fkj{neMYj!DO+Xj~35pjCn+}RK$8pqV1D8VqE2? z0rd?@AL?-ffTgzznD?6OYOTpY)*=D9wngBCb{LxAM0@7{DWbhNc#mWDoQ^2s1kljv z5u~1x)C_toERcOHIkN~*R{H_rzj&O8N_ntRS7-N2{|Xs$V>WM^3B?iFnRf6Ox+c!i z=*s&i;jVYMFd^!%ck4LRD5O zKby2FuZI0GAL|&Gcz+yrtiYIG$Rcsl3_OON2qi>sO#>ef}{K(=~js+2af%HcwO;m zW@u{l+|)%bSOIS!aMCo5E~g=uJP&0!vm=d;Z8W{VkDTd3q~S4^^HJi>;yIL3EcR9C?yWcmWr|{l8kOu;A-7LQgqW}dD zBKM0KGhG4+`gnW2VWZn88*%f)V`;dl)u2}EeBMlyWC$gOdtC>p`z^CQg!Pj zS&T**b!>N2o;{{(R&5PICplMy5r8L`Bd;a5JKL%NPgT>JsRS*9MyCgyHDc&lw>Zmq z-;X`kz3N+zD?0lpD8IY#VzXXIIYGs)Rv5%Az;7dPf{n{4u705P-?2^{7I>x;i_03S ztm_wC1h6BUZ`)R|SJ&5XSq~Og={S0ODx(eW+dZ=$N*CgL_?17(X2(wM3rk6bq)hf7 z_2rCINcwXNb(uXn=KY1N>J!-3%4>al(rPhpub<_8;t(HrBm$6o5Z&sC#WX&t)q-fa z0FHp+g+iRx@@=@IwxRi#8#z&l02ime<@EMu$>B_QDP`_l?AjtW;>$g1?r%Ff6)cx- z1HlyN$6(x>t<2ll9XsJ49kk+BksEpry>x-gNWV@vGcP@5_9JZFggMc+kB1;{;sotyU2%?>Tv}8&ro0`TocncGc$ke6Bwz^p zm+f9B^zAM(^zDs*UJj5Qh&*qPzd4t$x$ z(9pe$Z)6()e|KaAg;P#{FvJ**MX!wq9CI&eyKq~dKj3l%V|X3_S*ZY`hel%^*bBN; zFy>sHkd~$c364jrx=RAd1O_vN4BLHXwzz;U+zDd+5eWQG95l9SUBgPJN?BpEue9vN{!* znNs$*eDCluQpW+#*}EJyK|@)9uD2e{u=cJ4o4Oy@2tSL$8Ij6Z743wx8H#?@G~LR+ zj?7DNgZOG)99j(~d2CazV6(jjj}3Z}yb7smnS7<2Clb;V z0^5kP8{k;SFM_Gtb5d+Lwos*ZiwXe*IO1-!ndkNTE(<&~$lRzX4%Dk-L0%`w#{+2D zCqUjkLHuxT&-=6)kHE8OXKGHz#?+~VT^;WC*L3wCJK}$f?$0(s?`b+vFS`Kd$?wqy z6a}ARfZd`>{*1=mN)^jLVD4=sZP@~})p?p3<6<4$Rvx~y`|y_y6ULe>cz!o>3@^9m z=BAAc^sVQnCn=;vh+ixlj#`;yo(Epr!BV%?0uPf>2v4`&boYzPLbONtG}i{wA%LdE zBdM-4YtJJvCwdmnDxdk6h>Jpf;#fXrPPCAWXgLZWVNc9o%epi=%t*a>*<93Pn-XYb z@!wvCqysB&;(tVV_+DIr3<$^9C3l)KvoZm%aNbu}p4*=;xF*e3n=yQeM{hV>ALcmp ze@9GPOT-GuTE&>3dk7Kn}-x4OYq34c@oX&Gs_BQQXP|9_X zp!&uGriG3Gf1Q{5R%WjC=N!-})WSzGBBI_7pj`EHC660C|G{!S!>~=$m)oPeh)6t2 z5L!_dYI;BTb7qpc>c@J5XSHlh0E3Y9BulIvcettGA7G%3JCIn^Zz zQp~<7U=sL+&m!t(Z%7%ag<>V&1UNmkh?A?O3N(rrH;a4-VGj54vzR2NeYK*`K2SK}*|yBMb|$n>xh#C0Hx zqKfmLsutHM@%lk8{@5xYT?wO1kr5ysBY;s&P;ZD=1rE$6$ApoPhsZIHv8=Y=Uhm2K zc>($q{Xjo4n$P~M<2AYqrT4G7o%*H4XHPPlewrvb5kXv<1)JhZFwT=U+M=UzoG4|M zDcWqR2YHF;@I5b$(=sYe)UkzlV(?iDQ-Q9tw*B{~8fYXFMtDmnKqiX_7Q4!t_dFb2 zouaJ6EhVJjX!zkrY6mE1*h&9-+W1szAi=x?6iC%fZ}bt5dfV6oV3iAyJDReCq6Lgr z@C0Yfj}>>m`-o3`$8T_Gp-UEA8zoLHGja@E)0@5JHXE`gGA%O%^qsfj$+Iu|*7H$M zDA^s;27$RAA zG2Ociwa!3~h?k19Yi=Ym@$<8JcIFkV=&|c*i@qTtB&LIuki_n__(twMX0j+@b$9Ab z+vyQ;cqU&v>pN>(qHRodE3;_b;I8;{WPO~se2Xxk;5a02F1N0og+Vv_A~@d$Y&3(M zNxXZLfRh52X@%1mXHNP%*;`PE8(1eFY zp)dbh2l%}@4!6<^!#+2Mv|3U%^ikWuNK3xy4FzRwJM}GZPu+6|&#p$Cn<_{$a>II; z+FV-L$wQ`Yv}2gg#h~}+cAqI^dt#A{OQ-J%T78_R9ED%<>dW~~nVeZl_yz3Kjz|2p zy9v)a5AM~)AI_lb=S4?<3A$#ncJlO(1BgF=JKX%SUkI{G zRQK#uCxq4eFdlxu>|;;UG^KnOv=HMnSvD76+>RI@YaKhorBYU0`tgm+tfG1TKn^5D z(NKTnRF15{mGk54LSXJk6BO=)pEoc1kum6#ZpuP&;MLr=sQB>@qLwsH=AD-$OD2Z$ z@eoYcy@gF`-j9ZKNn+bTMbkf4cYAZm*A&41&BEGBY3;qK)7;uaU!SutqTQkZ0<$(fEzLSg~7{ zto}k|+y8yZ7tlM+ zloB8AxlO**N&u$9HIsn3_zw4*0Y)G+nBy`OsAOgcf+(qKvSm>g?&cwXlsS`H5d9%Q zD26tw4VWemZIs=A9ZL_SkBPL3+24Rnu@u^g$;V2Kh;`vPO_j^c1-+0SpdJ2*zDzSg z1zy)r+dzH6M5}Bq#oiSKbY@FU!L#}IkM_|J%*V($gIZw23o2?q-e%fJ>cCiLhi3t= z9xmgwy3RM)uL2@8RU4iy1%ewbr8tr-&hYuiH zmG#O6;3iiAmje|QToHCpx!&*qCS81TO)=#=+&6&pXuP*lx**Rfr_6}jqm#q6L6+ri zw90EH^lp+vwA6l%cHsjv+Zj{gJRlaZVv8K9?&_j{h1u0qGxa@_+B~CE0qaLfqlkoF z6mS29%KV)@{_kf2VJmtFS_LDxbjNkadvn0uT>X4Ch;uW{6?Y*IUN8D?S~aByf2cu5uNdggI!7ov3RrNL z0i5Yws6I2BaFf%%?x_H|;cU)QE)OMhGdxVGQW461DVMVGBNg}&vi$)PA@JO3^M+Uo z{z}&_!9-%}`uhU{)d)N`#ItY1g^Z4CLpHoT)?A`(&&+;)=Z{!sd8vE6v5AUPLdC)@ zwl++9KtcvCLc|kE9al;D3})KLz$HH2?Y6jmcZq?(knbB5Z#(YK4z&bue>1!48oKjb zRB#;iKtAC7Ii+V1t&u4+AFWvP8qKK7UFiR3o*zCe^Tpd2l<`{ zi_1qCw(W~0HB7vYg9Lo*iB@Bv!v1-%OmX{e^AF5cdh{!tHTjwelSl!2`OJyd9UD6q z081=mOg}fFNUxj{4^Y{Y!MY;54ab8ON`SdpVOZxMG9^fl17D8+PROX z_F?kPkECEb@-t-PNQKK-LM)`@q+|10S$o>4*8G81i&y=Wlue~TmLFreSPdvDA_+Wzzm06q$e8+7tAf0_Jp>1R?qA*AWdCm_@hFK zM64nbRdmF5vaybrX#T>mr+qj*j-NQt10;^pM>xv?xXHuVU8{b2s|WIYFhRrBF8uiK zBM-CTs*k_EeLPGb!@?}){{)vNMuv)0axH-HQF6RHm{t}?gh^@z5K{K%asc>`$z+`ZM&o?~=s667y z_$)x)R5!S@wH$IS5g|>Ok`;1IBf6n+3Lg}QUs3W)ZUk(j$Ebl{#>iJ<7gf>=#NGle zQ0+EAE-MK>USI~v&H-k}yYr3mV^?1Z+Ej;x4+m&PCEtT7{Up0gA5S_zkig~Ia39Z4 z0E#Y_-ug4i_z6Y!M%uZqa57y=*EJZpxU%rUB-G{CYh4Yx)`WAOjJybk4AqIW<`33mmb^|8~VSWI^HOP)Lio*F)GX=@!Wt>F}C5q zCa(Z_m9kM}0W*zq7>6#dO>sx3u(0OESn&s#owmLf?hbF~T%ubk%vgxh+ zmg@Ofy^sEz(-(}$WXp=O!vR&4{25<_SVg2-B_?(_O#EwvTz+IBVq9CYt%X|S0l#)P z6Lh3q@wmu2eQFerr(_XlO%nE)WHN(Pg2w-qm zW{x>&sOg=pk2xPO;Z>Qjo%N@U=5d(5$lI8_#-n+LVa(*n@cCJQTK1#z`bGCJ(2Gj7 zOYvlTn0<7VrP*WN7^_vF`LuIq|u02W43&ko|w8y>(cWZPz`l zhzcsLfYKl#4bm}mNehC~AtfLp0@6|f($d`_-Q6JFIe>t44LRgc-#O16&;7e!pLpNz zIOdOGfSI|@>x{kET6^t5Feh}F0d2nTK~R_M%%3`5arf-j@D8AkqN0TfKj#I^G|oB7 zB1(alv|M`hV4`Yivs!e}TTia$JHF;)wG;P-Al~&JNyNi5u`>)&4pjXOiraXvUJ;sG zJl{}NODutLJZcp>atW*IMfaYW(%Ja$)gFIr7KAU*eEqK_iPhZ!?D-gTS?O;f%dR+5 zi(Er=MBd3{p88hlUYv?~$hn4uJWsBl?eri~0JK8nX5TOhnoj}_8`lSue){F`!VC4& z4p{&G$xo=_4_Ayo=H;=+6IKl*#$F%b{XVF z-+5-s#-P<=elmE6Q-LvPVaocKeTH4ReE3UyfRiRz`gj#L1GHV(`) z&CvHMPawO2c3h{g&Qj}!Ym#;gHU}50%qufWSVa|Epye06n`wL7HjA8twOrF6PrE-Lhc?UwyPQYM^D4d1(u!NoI!`Jk<(OJU6r_xo}*q%x} zgJd|GPh*+KAEp&QsZ4)MEFS;3o9?lc`0I|?QE5RV^b2y1FX?yv?x*NDtcy+Em-)&U zC^kchYxyN7&d|qY@{>U9V{Km3?e!BM)vmJ6wW01 zi$p}2B80aGY5O(_M2aJ~4xbzZ?%^Qx{H3c(Cg%WfO)3C1dT(bz={g^i3 z=4A1QM)3pHVEY40xm)ikF`f}5P?lrc4!OUVX6|z76R;IiuHJw?hkO{MSsrf8f7Jj_ zn~Pu*bXk_Sa+a91-t-e$!(e}M;gRki(XWulL5=2V2qJsoU3Aa5$&i!+a=u%BQQ5VC zvVy&|HRlkhS9~i%jLbVg1V7GfFvq@GByj z;INuA=c!{sYK`a*Z{Zk2j$-`=&sg0nQiyWOARS# z4vDAylGl2$0-3aizS9PV!F{dCljysP_RGwiI}0|b+{U6DI?47A^u?ymlxS3P&!JbE@Pri$ucJ^_IIvL_q`$67k^w(5PXkXbW#ci)#%qFjkk{&$xmuN>Q3KDG z)6i1a(hJvC61(JDv7G(#X*|Xk2kmiiO~Z#C+$tL(!#NG-+TV}kTJPkJ*vlmicC4sT z;K^k<${vBl1LFZ?S#PQC5!OBXY1_*8K)=DUnOZ6Jpb;n{9!y!EPT zChBv6(0{Aq{X?JaJ{0ijgf`6q^JwOG__OJ$Xfn+LlamLrc=Maw64hL2@&RX7?z0S$ zb0B$V6EkEmlC#J~<+mTR7I|*5@xhiuW5~kP9I7NNb)z$L*uuA6jrT*6x9=L=ep+Wl zPR9qQgsJdUmDSDn+KM`h$yDo~$YX4Ru=@NN4@0aY3a6ec-lB}qKFwjLx7E3%%c$>H zktBR}mRTx;9g+C_;LUV+=n>z+n3S2A%{+8J4xy3NKcY6Rrm3xdoRxs_JnsW-G#1GwF8KwL|szq<#hJWA5=7al2L$>bSt@akU)59>Xof)g(XGwc0Nw z7>azH*Po};rg-7<`I1ZD7BR^7_(bmE;c@e3#I4tKVni|aB9lb@95>f(QAw;~6^ACr}Mr=uU-rOSk|rE{l46;S!u=!r)* z8eU&*s*ST#ZOYZPIFjC$lnLf@+%g{D{=jdWf*$?p-#s|+uA5yGH5@EApXj2J42XUv zHzap4mG}FtE|x0_`m8=;W$eCj zvbU&mcCzsp%)5o~l`lR|sYpUZ;CNh`aK+a5*(^QY5i<*Lp*IvHSJ$#G$p!UxX7Dp_3d=kjfia#t4BQ&(+fKFdBJPS{9ScZ{d5$^Z6jnHk(pO4Ok@%1 zhVH?o;gnw7Rhqfq-SEU7T*1G4xL3=3vY%Nasrj&u`*s(?@8-0iheGq0hrHCac`vVr z8J{RgA)7sPQt7fG*f*NLiar@;vX>7R9J8_b$DgyVqLF{5lJ=6)I5wR*>q;X%-rP@= zCyJ1uyc!hR5N_)*xpsK!tR&=&pPewZ2G1y?=3i0{BC+VkDo>a$Zl`rCpB7kbv%++_ zH2cxEl#5{&@kDWv2H(advqhMTh^VnjMG^ii7E-JkX=sCM6WjA%f4ONOzEEkL#pQ*A zO`Aa)+akZKudK}hZzI$Tr=!YxG4r0^=@{{<*2x>@Z#~roocr_Iw>Fe?;&jKAV;zRI z)+d$BgZVTR4<2r^OwbmMFRQ#h@%eW%4$Q|gVHEt0iYA$2*?*1@D+`{musf?*D!Mp+ zdKYa-X5GEfP&&`^=X$Dl1dPSkl&5T`Q01CX<&xLY-d#yD`baB?D$JzuPShtjmLHp= z)Aq1K2zY3BA0`iA!KA~b9%*g_f5>Or7$5J*MC2TLmqcQB@3AZFwQ{#-7k9fYd=*+8 zG0C0!687r;fE=Cy1Juzp-@w%PWyJs zB)Lqe${$_;v19J1O+4{@AJ&veWZsPsPpp2SX0z$lm8P5UUvb}XOdNTX^XM(PI|gPS z8Qa#H&r6p<+D)C!TxVB{ro2+*#r&IxUIE@uZTn>_hJIUh*k9Lh)4AxidDMkF=sb_1 zN{~hIcgkP5u<;{A?+%xR(TUkU7+}d0!2HLGi$Z%>IKCb8#WrcYdi&x>A$3t$w!*P( z#9p;8e2{yGzAJyhDiKRF_%+ ze`ONx{_4BS!h56C*tr8Gb#hW}rmi?DIb;h6xW!|;$+76}ioS@*0nk}U>=FBG={)Z~ z?l}Q#Uqzck!A9t^h7rd18h!62-Z8DO>b0|k+-?-^?Ajd$B8iDxEwiedishMY8Vp}z z22#X)GJzP8&*G^MQW}}ca!!tfoc2sw$6p#^4D8(xSBUxR82*pvW#lN1Qg2h<-i^Iq zYw*v73%`5a&xjDq;EtWDSYERjoe^%Yv|Kv-{$VmbQI6-wbPN8*qJ19ZZPe4DNIoku znCW@Y2-+N)4JL~KIik*;P$rLBoq9)aJv%^#EBdaZ;V#HD^{k>EyS4z47C6H9ECVf!?>P6b^C{8zzM4$ zV$Eb+Yr+|WfY5b*o8E%=PSRV^s~eu}Fz0XeZ!6 zgZOAO^5ukeSsz#QUGz5*1npUSVzx^+#y(?_B^K1}cW*r6U+|^M;lCuc9aUZjq8$u# zAz)@Q3@`u)Mo<55N6o+7sNJg-n?z@-g>E1HW#O$bprAc{yfEB^y%6bv;TMc;D1AbO z6X^BGh@|`RQHXrUZ<~}&BPULvm>`2HSfFGBufe(wGzeW^iL(kSrceU{v%oA|c{OLBi6!ir z%N6ddRXdfF^`~$kR8v5Tg`r-4RYg58n*j*k7K7nss?u?Y7^Y0z+Zwa`o24;$=b3Jk z!*$w;-ZfNGK>;MrFWR+=TG`+}N>S&?G}}%v|6saKXkGC#bUfbtUf=Xtouzi(L2xd6 z-2s?0=|KHWVXZwTpLl~jL%&PMr@`7O0wL>#yI_5#<^_1 zaVn55pNNy?y|5cES0Z4?{O4x>mviyY7nuS-%{Hz!>aYI4zm%6szTYLy=xHnX>8s?D zGSMjJ-c1o(Y`wuqX!AezPrYqiVZx631ShWR^A<@E7t!>d`kFf3Fg5M%Eg(UbhY_BE zX(G|E4aZ$A#-rz-X9h$1O(jbK-FhAAZu+)*IkHH04wfwA0>O&}!paX|rDg#`FCk?g z2sE@p%m*Se0%!|N-9mi6n6F##B>gGISFk~>@JdUeUkV%_;iH% z^h6@LKmHCY_FV*12WysdwECI*1Cfd48?VsLfSUg)kl46T^m;QHjT&yhqGF}FLRO~u z+D=kynBUga;7%vHo5z&@$^g^CF!<#r27mY#K)qk++RfNbwD;Ej__xPQ6Y9#N>$B!Q zq8?tNrv_RMLGr&X4L#S#$pdvUrzj4tOF!9Sgl(_~;TZ-9x)Gx!G}imsDw>MRUtUZU z|Kp@@iluHs0mozPdy)vs0XX!UK5=~9{W;24 z=>!j?SYH+5rP^l(} zLwr<{(3`ZEqN3%&?SbdWI*<{z4&pF9uk3Jb*#STi09_rogd3UbiGB^>N=7O{dm(Je z?jwMA5Y(jR9(2v7Av|Xx~R{*WtXi4R^$SSh%$EdxkJ%EVF+#n-D?Td4HR8>?%gnDLA!&u>d zwr(abOUEZST-)JDIQw8Ed2RZ48AyowbdZ1tq9BkVL&ajf?jXOHij1tygZ;3up{HV_ zus2P9GSOyCB@aML*Tfjok49sRM$bjIn*2Y|; znT%%1`Cf<5I|WLvwz#>|etXKRW_PUgV#AJ15zkxS z6{_YY-L5#Qb_IZh=Pv0WqxAAPw@n8+-6v_yf zQ6W3?dM+*<pkx|? z?2!rY+lVk9k_~LbxN0a;s{=a4Ykj+AvQn@W&XyzS8c|z9uM1Ca@aqv4+Ly9@0eU+H zb2K!|)groqrT)UR{cjA@B~REY4a(I8VZOx(mPtA$r!PiQu7j1LytgLPbg0k2q%ACX+RAJ5UCL_-tcO%nTLBxvN3&$*Vq_XA4VMI|}S45Q#L2EEZmJ=GiE zSG15sw2S2a3f2gl?mKu!Qqyu z06AUai9%EO%x4FS20G%oj6$nMF<6T~G!PCV2_~i*sKI*kZBn%2thV$rsceLBf7Ui= zu@{dFSX86ze_ntYokJ2Lu>7Kuy2>1F)W4;2D1&WfT||hD^LnH44PBjUr_XF(*^o%T>jJNkTm^yZ-Rn5 zS8aYr2i52MJ-5kFJz;2!NxUZ9Goe&cD42kUCIMAW6vIZ0`OwFafrd7MQ_@=W zNk5+XEx`wGOrh+5`vJC4z{LB zuy%Se71=Jyct8XjW#o4^yGk9)^08s0HvXq`u9bnu%3UI@Y0J_OqG@FG!ME3j2lr+* zvNt%aAAK(C{mB{ZL&1<&ib!?p2(lXpe$NC~MYS<@a@cl^z2$bO_d&c6Vl9nr_JO(< zo85W_*;G&PJNhfV-n&d?N=t@fxAUrKcuexNgwpo{IrqULYYPHFilgU~O@H1LMe!{p z7ab4^G0km=x!ZmT0sv||%+F6e5P(0Y&Icq@u_pH==ZE?(tKXx$f9^^DZ}>r@gj=Lk znLc0ktCUvQ6ZJ8gcrUW6tPdKd2+=%nL%owiWacL%BRklURCG0u-6nn~TmvFMu{`Nn zByE1+ue+pevU#HdXD0z@%PF8?bV>&N79a#{l&Rfma4JiNqz-_Va8D7|i1j`bR+`R~H=0oH9}7kV^Bp!cNw{+u3Zwe35jM_?@S~v2)YQ=w zWs%rENt;2cF%CeTtPgoaf|6DJ;rOnU<<&u!PzF-HHO2lJOoJ!wa+-)L(4T%7#LYxD z9&{0$-w}orTBvDA8u{dt)d7~~R7qqsZ6F3FbQ)ZML4$q^@7BvO*!@6Oe4?e|TfXRM z0+W_%`~+b>rY9h@{WdP5*H29GILi&ig^Euy`4ZF(SJzCl7?`y0A^u73xzzvFG5VJ&i-8@zZz+4j zyPy5ncomv-(dVYX895x8uRi3#2{hENsT6OcaHDwCC)m$c(;Ghyo}s6XO^^SCG4(8+ zu9$qGwkvGEAnFz98>#0~5N$tck>nk_SUPBBaQXCzx6n3s9A`p4I(lk}gWuyh3&YFB zlK~aQEGt-*iX);cE7cjcYS=RCWxcpa%n!{9gtAHT;|ajU>Jz zYgN-hwM`*C>FawV_Z+mgrYi30uH9|iJq-0BRq(l5E8=j4RqYy%MX1a0TU5G)D;D%D zG-+Xl(p*1+^*g$4<2A2|%$?~Vx`N%}nSu5kO7YU>Q!wgDX&j>*I*i_N50pqFb8Rp3 zFbCC{5+<`gk}aMIVhTA`QcD~)1F}7q6!{~Z<^mL(-n}@+NTb9llUuE`E-c!Il?-@` zhO?g=E)?Qh$!HpR7FNHVeVMV@5(tVvUTnow%t|_nYl~;&j@4Xo=ru`F@3#?zDdYdx z7nuEEcy$n`QYy_2SA!hw88$IhW@^5z;e}P|=Pw-%T&~8n@eT3mF_rarti05%?$Z>T zm6N(WCs0-(%b7^gK8xnxZz$byT_20m_2bfzg`Vts;;ci{g3+(0R}ta1o#rPO`mH0| zwcGfXyLeZ6>4zWNtrQfI(Q`Y;+g|9dPsK^kiBv%E3<>gss9PK{=z4!v5kA&9>$_@v#HJDq>0O7KivD9>qJQm1u{N_X*P z-_fh5T9vUX`+FG1;q!M7*J|AwiAJ8#CJ>wLn~Fg;eNRnDR6D|YWOjMzfVE?yMm6wObX4J;U7o3@Cs7MEC~k6j#V9|i~t0)!se&G`+8 zjTaQV$R1v{YxbbTUPjZZ-u46;K;#r2d{@Y5tM<%0j=O&}>8Hy7k@Dk3GnQ)zhy{G0 z^LV<@KF;$2%PC{Fjhha%N_G;|j6-IA9}qDn>Ho;Mvyp<5gl$0M?e4_e^vfVEXfqmf z2rwRF<0|CbwT6MRavX>&fOaU7({)kZ(5R5zI6n_QXhctPJ9_o#bf}Z@Q{VfxJgL_v zlRzCxb(e%;;Z^&4pcT(#xO9}@W8bl;14)e(zgPLYM^BCA>euYvrwYM>`%-_7zZyhO z17Q!p9iI;HM@WMNnq|Ed6<<*`>&AIALYi4b#HuH}WVXFoGWZ@8g2*CU)~8(sa{ZuN z$#c89)JKL&@<`)w36q)z9aETBN(>vu6J3rbq4JS@EcGYP zYrw0|L0XWOxDsk-^aw-|dy=+qCr@9BIP+%dI5fPQjrdsn9sh1Yp42wh3Ft0A^9~2r zvOZ()lL=rQKD%OVWEccE^M_^yciTS>Ni-_D*ssSqbrQ&KQ(cj*M-Ox_E6qq~*xbW| z;)s&#usk!-LiH+#EjR&z^mm0Zq!sT11jS~&8+bWH+RYgTXZsImk^R6jovXH4{aFj* z%*=s~Aavd8ZL^N;{-vinw|_n5|AfFy13t~sa7%VB|1!dPAwt%oi0Xsuan#!)gVMs) zW@8kB#=4LfrwME?Mu2Rp5xcS;=)1mYWoUE zAK@^m@6e@#%d?%1f>N|=W;S}Wq4z_X^;5j@pV7v@6ZNGl_Rih`(?S6m%!Mn$r3(B* zc1s!SB<{&*`rY%x@ur06JoYD*#aROS1k%NCajKKegw52V(-QbP96ZmQ1@_>6>+u52 zsCmBH#WIAv?_W!FMthg~-oa5A<`%#qhJa*~f?ncUz{@Cs4YtOjaBO888At{1THHRs zTe!zj9VmL!cj_L{8>W)+upc3-Xw7~$xfhqR5JxMD=b}Bwd4r2>ar^iO<{6qcb=zxh zeeu$RE<)^;k~nzt+?8wDvQN9jBVA-O#4?cJzKcDYa@xVxy#fXM0XE{5xZz*jl@Lhi zPS&K6^S0nV0-1u2AYli?R)V&wH?)v>8&WEiIiw)?yhmslxK=$#wJ9KKe`-3IT-G|# zNP2{mc?|G-o{=kLD=^pAlhV0eKG<$6K(`WxADYDDz$sUt8N@g{YeQ* z9(rP|xFZhIAX62VcrDOGhWz`6u(%2S36xYz=(`ySGM=wD93t3CsjM2*Y=kT~E}EHL zlod33b1sd9jBczvmP|)b9*)wi>$a=K7q&v(DlPgjRqJDsFKTNG* ztP*o3vfa4plH>0iBJ@;I_?@(HxU`daGVVMjswyv~D5jTli5S0^vllXVI&X?X(h@F) zs|j$-@(8zD;)=b>SrdcSyAsYhO+jMJ3w;ksSkljd66}Soo$=qr4zPV<&SvFUGz@i5fw2&bMRV=>MjS zBCmG4X8o8z2OFg=w*cWZ%XXl+o?Qpu2AQE|0__Sm4&`;X$L$eTv0}?q7G8G^32dgG z@yO_GY87Co_Bod7lem_ivBC@3 zLXAyy+UCRYMzM@wvAMiZV6Y?hk&oVAzPkUVSa1CbEP*kAHoW0^W_2?rzQEUQ{_CoeE=Z2`=R+dqqExnxu5>}iTgT^uNBULLBf~4eP>4| zE#qH>=|xRAKV6!a7_%1#poLZEcb?c$^-p zP-6Z<$~TLGZO;z*wQ@+0(y;D39g27eCs2{5=g%6-z0jG%oF^64^>VQ+U1L>OfIXY$ z+u7|dD4Vuyv~)dHUKev&w4+wt_o+LTjAgZUMtfXbIl^rxu0eQdJ=-95#!;Zd0Z!!` z1JX@95v5JX?Uxt1&a*vo(5?GybQU^}>lq*RyJ)lEELG$iT;^vxur2D{jE^H(Fp!uxT zMi<7@ip^Z zlkzr;I?`ABHG!t{UfE#FUG=lv1Ci-)d%qce=kKds4x}3!G@0OQP!&de3BhfAIKfaz z9@&Sne}yyU)2_7(wDm?4m?9v4ENLXDFJ-O;|7?~1UC#Vdg2Q+0Gn{NqmiUOB(Ppl^ zG96$?7&bw&i8aJA@xZ+FMdAQ@G5%Dg6@l5*APc0lhxvh_$*V`BS*@#D{k$z|r@IIQ zhT06f{v+|!kzeMVc4mHd@%{jGGSTg0ycNQrA^Kwpjaomc%>N>B`zS@aGeAUV6ZtEM z?Gw73#IV-NK_j;{p>GxFY`=-zh_|DU?ZhA$aaS4&ZU*XS`pDi$_R8ty=qkl0Q=+8vM!dbLk zAEr)l+%R*1JqTrHQOORgj0hxPNJlmfv*q5Za*ZE~JUU#(gT`?CKSVl-d;qD74>>j! zAP41h^BWwYnStW5MXkE1U(slIrS5e>cVl?k{3a!BncvskpD`ed>oE>xEnY#LrxP&@ zXJzZ&tANF=cB8{@#GM z2gDM-Y;e!wX2|E)DfVXqQ8@2iwj%G{Uhc9AygjYr4ZCg8GN&c%;vEH7Xb0cXDZuB@ z<716t66A#>k|C=BxkIXe^FYNe&PsC}#W)mn^z#`v>6P7f+G8{x=R4bN#bSVmba=W! zF@pfLw4t(!V?tIPw9LXUn*mWqOWFt!DnxgtdEe#`}Ej<-$d5&l+K+VrBEOwkdJ)UGN zi4WOsbkpPeS$q3)2x~4(R8`~4gNKXsxp}eQ?H`D2uMLr91#(T%`BU<%wS`p+z}mM9 zANZI^Yejq4hta5^RxGpPtm9%Es)AF{44~y-$hJ`OPrJauRKle|g2S%u{gyI`wCtjU=7Ordv#xAF9>LZD?QI?-L z*0#=mp0#1`LbISs*mMDk)-?El^GD7miODQ5hrnKMQ4n~^z-EW&le+L7>r*XuP7!ox zBR?4BZGnLq%Wu0gMAc2b;A&$+sLk~UfrZ;a>v(D38w|Cu=T#jUrYc}A4{vNWb7-=(E*2~_LZ8t^(fgn1m z*1?3lGxu}2(rRwBT(fcm(Q0+Gc$n8ZhE~$ZAuK%?FAcRA(^1%hpLd6*?ma8YK(=z) zh4q7nPqq?9!(bbD8_Ema^j%VFj&%|7IO=Z86Nh0`emx(Au}!fUa&%hv`zR}B0q!+dwKakR(p(8dffrhIqSFb(w{ z@q(S52>7t7LyU@<-)l)h;NqPPg5W!^m-G~N8us(I80)WFcO-=b@g7xiQ1}Pz9qUeL z#epV5q1inj3GcFXcro$_ZXL{}+n4ISfk>1yI`<8Lau}w2(F{6i0+W;y$*JApm*7|d=XQY;s>yb^53|3#+ zJr08R0=Q!!$TpIed>;ayeprbyk#s}LpI5~(DQ$iK3`DB@PKm4ZQ5T0+>Y0F-n>Bs9z7DF1zHJ|!0rN~2ZRfr3^H*E;KSFOcLU_8NuRcJF^w%@g zbriVgoNWLqQ$K>(ES$^sL1RZ^UReNeVzUGs);ADId;`bbI`Em3sr>O?7N*d<9S>W( zqiOwVrn2EM{{tXYGXZ-g)a(ERck;lnTMV{@bgt;AGObmbbvY(z?4(@l{d81jq$LDQ zI+1b!VwdM8fQ0&{+vti;L6-;AqajFbiD_#Odi#D!&2}W z*(*;qkq9Ae6xNaujs3OD-IquMfHJ^l`vl5!O+MbafBy2Mg}BLds4mP5Ft{T?r%3|$ zP&}yz1+Z-|p@H@XYeVZn0^E6=b{3gJnBMI9b?nR?Hnr>jFBRz)ZsW#7m z3uE?sy})KU151Qygm*R_#Qe-EyYPK}At>tm1_Fv=uC7L47GfaCRv8e*YtVb*r}S`c zO$$M%I_c%nSDscM9X>6}?!8`XW4Z@#!kKQG1vi*2O zVM@;Rrht=5NJ^JC(~V!AgBYeN~vhN9~7_o@qh-f zS#kD7KkStVK-)%wD!>KU=!K~-6RLF%xAyHXqKH9%3?suENku+%YLwcDrpggTAwlsZ zAh!3tO!~OscQC4=C^3ioWLRzS49Ogjg0ZNRUB4UZvqpIJ%@Zvm;q`jhLBK84NLZf# zwJ^xKtc&4|X_vDjj3LI=g%Us#X9083N}#5?a_}|BQJ@USU?MJRg(B^jGdag!QnfSJ zqJ2b1W8s9r-oL}lMan6}$M>a{OGKPbMPngP(wG*>05(cY?^%y%QEzwI_?DFkmR9Y? zCXf>lQEE?-?}}r~kRcVLFh(bU38;i)C$X!eCBbXH6RIcs(!h_}gT#W90MN{H1`63yN>^XH zM#@DrHr1VdyJ~@edE_R6R>&pb)}J$>))qCZPqtvX+HH(6h?ERJVljn=;-o2el(aUWxL7!hFFOI99sE21OFpD=PtZTns+laf6I&i_T!2e zN}Vi?YK3$R*6lTlU+RlFNUpb8pc-UMDj#~`DM&|Yy_Z1>ZdIa~wM7SMt?5L$?I1J2 z0@Bb#QM{%k<)ud@o#~Q~jxmR7AiIS%d9Oq-9R-DvFIr*aB?9Va2dD>sw(NMqtI1`x z#WX-VpAKL@FWF=^{3?vA{CGjWEdx!)Z0_Y6Ah7uWT4H!x7dSecd*JMW8H<W?k6ZX+$-hr9|s_S}%~=J&jDtDRsnG(*@f}F@LTdo$)2h{e?p7_#6H9DwD`-VqcRNTU z`)(@+sY7e)#p$ii!YN8C$9?!8S^1mSXP?@XZA_JKzP1aS|6Z;0oZvA2j}6D+^C!p; zV;bXk&c$EeQ9=ke8($`x+_IGVU5E*`t7LEN!O~HXQ;qQN)ZvMalL|UMw)0$0@N9c) zGopR!yB0a44RY{$?8W?|mOI6ZI|t50h$DWpwZRl+Z-jP@ZD4|WSIEqIxjIQ(B1q)k zLOpr_c}KFK#KVdIIQ;xzL=lhwe4q4oJ1mbcU$~JNcB|)%tX&}qng9EWWQ`SMtdzoG zArxY`wIl37Mv<>*P>V`83!&^nNP?_LG+maA&E6tGHanP}_UWF@m!O!No-OdZd$fRT zgL|sawVmKooq$HJZ}zA78~uEW zmA{R&sXppSa@?dR#qP~K?;0ix_N|~lV)rHiDdAL31Jvv(mx>HbR3UUnk>Ttl)hrB@ z9%34!`Ntys0bIsX&Z)|mY^4_}Nn)uzQ#O`gyLIvu3&s`aT^6>_ITD-jme(Y34A94uM(Y3wRYbGxasw3=V&nx5VM(~H z6a5UAD(Z(O5lM(ATcI5eve@$j8&#tU=O1*4qBfH$+H082r%(UAmRGNts>x|dBjxP~ zFxB~4eBk@fDfH)|*Mxcp1s4TZHr@XuFpc__AA!Rwb}9&Eavlcmsu`?&dD+1-A!dtA z6Zhug(=A>pVV?SAR}RU3GZvyU%ii__*uXdTzyxa!pT-4|-h!HUZ-$ku3Yn*A25;*l zW3DN%t*}Kzr4DSKhnHi!9sT={$&td-Q}>eMeyKWb>ctiQ`PsDaUdG3w-Xu*k zXK_{aJDbu2zDY30N7tJT>V-q$pWC&+L3b6=uP=ApJ5D-#+-Z;MnQk7#J!)=A7T<$; zXwoJrX1Dq}u|2nXm>1FuBbFPms**NUsQ)^0FvY2snP9W*Hf^_++V!*9bA;@2*XIMU z;7Uj4_e=l_Hv`gze*}ympv~#Wu=%jyN2@kql(i&Wa}Q(eNskFlK4&VV&HJUpQ^UfH zu`xo9#P`n2i06<)ULJKFS)#Cue~)cMpP?RLP9n<~2>x2e@aHJ4IG~`NVi(Bx((5Pb z2$y+X@6LK2?xTzQLw-U^Jmgh4fzNQYyM;ofr7~3M;^8`3!DyOGK2OnoTzqZL*mUju zd`Y6d?{}za&sX9Hu}5NT@tl^Y9$5Kln~dvNUgQkMafW+()h>1fD{hsT+Jma0b)d8g zWbZ@Y1Eqh@!NE4%m6iB463lZtmW$UDT3+9&iRhKHo>YnZocg?6#1nCjEeU27AaFCa zc$CzhS@l8QMsQ z9<`3{l-k~O>@QIL$Ka`T6!vet!9Qbd(dt|&TsjzUYcr$ISQxc;yUH$4-nue~4 z*K`0vt0(#Ovqt%$iFC#s%b861s{1W77L_3CHG@Hmk^HaO$?iNwZ-yw~Sf3-0FL1Vo zAx+cEUUq!Xg+HFlq#n+w|A@owy;DM~uI^7e}_z zZuo01>GwF)Ax8M{;{8N{Hf=B=8|B{nM(0TZ7Oeh;-=;ldH0lBMdT(3E!rfO5XjutD zcL{pS7nT296TJ}k{Z~2n>-L;qME1EVmcA8n?faUInLuh#FOZ1F+oF@7T*$VgCo*V5 zy9zbyrDe+_5zDVPI}~DQQ0N)|(NA#qbm+ce7{(+`B(~Z`ubNiOQ4ac6c5Xn8AD&8F z-P7&tP%0{OKkBIS7DpBB@5{9U;}j5^aP#NjJG=@kPD?KDaoBQHO>hvzmu;UK6wLcp z)xQ}JYy4x!{q@=T~s-`2W-bn7D)f9 zT8J(#Nq6Bx+?kXCiCka3Vp|DkMkibc1uLx$F_#?X)j+=wFQQ($j8Ba?ysGR~;djm$ z7!EGcnQWlW*b7xcFgJ2mOzu^VOY*WjWp4~T(r$FcUc-edSN_l^2xT_U1u0;Jw*5mZ ziaS|>gxHHLo09kLN5P-PW!}k{d5wZ5n`z;+z%3a436-O2s z2Vc-l_xipp&SN2ZiZ@YV1tXp=2b!?XiO(C#mDJv!oh|6B>|B^F>1VJ@Uv1E`B&k_y zsK-^joz9F662H7ix7|e4#TQ`gOR+6KV@Onsn>wH5wPGhNT@=TwOnB&8ZVSYz@&OYo ze;xF{Ki6}AoP6BXrqV$2l3e-Y;Gb*t|MFx|01SpDX+g!Czg+fTbdCSjdlr4dD^jw* zu6vEN;or`)UrxS1Ulbm}iv||AB}$d+)l;eeKi;SbMebTCbF=8o&5t-A*Z!+3_!n2) zCk4vZRt$lW*uVIL|K|0(-Mzyce&WZhpQn`N%ubb+i_}YqPGmJ6M$R`R^ zJ2;iw{+=wGQlOsnEqX*&NL={o%-)fQn5>@?uae!axC6X(t zs}s2HyAZhVv_P|Og}&W7Hc(FSzKli?)!}cX&-_xP8x6TGUQO)Cmf|Ka$Ds3)=WpNr z*FQGZi6_g)alNoF_hep}aga&xxkk^vxSGY&=-u&iIKdk#@y7P@Yd4~He9 zZg;YA@R+k5n8UU{g3>2kG3BjJMsAe76wkP*+VO3}$wSJTv8 zBv0hAHV`ny;s38b@~<~jSeEva!soP-F$LM<@ykC~zq9)%yXc6M7cFY&c8!C{=ubzb zYVEdiYJY6Z8)(dQ3Z3%jJ9cN0wrDL>SWZiFUGV;&K3MH@tLRK5MCEAUcO)=VkdS0qI)jMnW1BtP1Y80~|ZnBMWMhSSKK9p^s=>VNKt zA^a0}|L_9%pTGUbE2ikE^wT5dsERPpKW0?ZJv`x}DR2)}@4Pnat@g@)YGHv-KP+@X z-XDQwlqlDq!&0``M02~jcKu6^;lKX$Uw)!{q{20MjAs6gii4OEx8GOg;I-@MdJjrV znS8uTF*wYI84qRiIIsvDo3gj$45p{f%@TSrw+QiWr`+FG$jyiUVh{We8{J#Hkb-uQ zkqF{#{}!O@1>uDheF(Du5l&JvVsWuujohJUY$x@j$d%1i?9|O=MW@)^mO4X`gxoVA zfV1}fY@!ygIb{$e89<101OZh^Z*aI){ZDg)I{ym9{_>*#_M-b~=qIll%hir%2f5!z zfb|NmlYeqW1e1)5Q5(qI(2JQ-W{|Z&$P&puh_j#jdZp!b1+rxS830o39|^fSYXHb` z7~q0bx(o9$4?w>N#O61vxrSoTZ3BO%$nD0fOJsq)M*1}D=_ckccBFqhX0aZhxT;H) zDxa86eg4P9XcCYRCi5s)s4Sb&ST+%(oPf%6-?b?`o#0$w@Mj51SrW8U=>V!uq^lj! zSrAaF-x*EReuo2%T#Q$XwWDX4rs$<6JA+E3%bo)LaSZ6GfNhlIyf5Rp6e=|g3ZMdG zI{n2ju%^0bUOh9666O`v?SEn@HItR(%4Z22c~!RX}wcMG=*&x&T> z3vu!D`=$bpv0A*`hX3ts1ghBJ?klu|A<%FElKhgNcF*x6W`W|h z*q@11K9Perkd%`iiH8=qGJB}{J(mqOhhw)!V%_$AVOx;kc(oVomX56Hv8!o>cAh8#;?M^j>A<;22U?N^v zk#Yeli4&0h-LjC~EY{sYX<;bq=^bc`J&KEguDeaU9EsnDh>mJ!RhR!%A@)B*GS*zw zy0q=QuQvR@U$dz(3YrW$ZqP>E*O*~|!iFI$`JpqB0$Y(zDp!|gz}_KGwI@{-7}+?X z=H$p~oQB4@h4{RDA*yEAORWX46f>Zgf1%Id0F6)-bUg6Ye&=)V;a4?Xm%|*ElC+uw zDN^@}65!BF%Lo7nWI_(gsE;%>%=620jX*M zu5?BIAMPm$%B-@z-CA}2?^_x$5kh~46N~!+l2O}*}9NO9(9lIFq&Bcdd2KzRo$<-XHyhOy+!^yNvN0gdX?u{zp3x`h_Z1 z;`NN*4&H$f3i4a;2KjQ6qCD3eG_#Yo{a1SZJt>V-0L8-4CJV{Rg2<}&%oI#lSb4!3 ztZ`wim5&Zi$on6c;{W49|9}0b*E^mswtkJWE8P#3N=5(k%)cSCN8KMXS9OO3Z(!JO{t*6w%@ytQobr5j9ebzqN= zSmpg)pI4m-08*mF09`iuU=foJywQ?5yAF)t8#`Pp*WELt^1CG;iW#&p{E-*P_nT5K}1bIHzyaw~u-$791a(eDY9@a=mq zMt;eAkH$bf`A=aV;TuCa`YV%_uD2vVS;Lpw__B=01WP7FC>(CnM`A&ao)!^j<@P!Y zGnFfM@3b14MZM_*=2iDJYJ*J(=osPDlAQ6^l&(q9V}OOqNAX=ejn4$^x2z><*X{rR z89x8(b=-N^>n$KtOX$QD3^BMATC{Zi>rezpHa*>cM49Hr=7u#BWHgG^Rt>n-U6TF@ z{=xgAXnfOuwI8s*v7VbSE700K)x$5MVVV`Bc7YU5*|n*WT2v7~>a}l;O`pD9O_ZiQ zVSo%!Q2YaPV)-Z|28sM0v`UWV4G_rcQ?As26&#&zyT9^`cchx0(qhkmj+lE zKVaiqf0OHGaVC0~2$|=`!b*jFUyfB4FlJ0JJl2zGQu9-FcyKXwVED zE3z6Engmq#nI3x^Z%&$j^|dPeZ{Mp)4(BZNda|7WxvUeY=qJfFgWYcP)_;}Su&;{z zsR=+`b}H?9^7ETkF>pO)+Azo40*PjWjQhh-l>8AW$c#?z%(aJiCx?~+W(frNW1GWg zylCg@lm3}{Ngk^~9EyGF{AtnH=;Qmhi98DUh5xdZz_=tV)YvS~oii9z-j@Nm4XrUR zB-u6oW7qk4lo+#>V~3y*XuK%9{qk{;A@xIyK_6KymXf;hgkJEU1~cq)!}h;`?PxQg zYboit1t6So5sH*MgI=(3|IGn+P&EI)CnNvsyY;g!`iRN z?>~{2^a@e=g}D?R!~cU@NcQv=TI+%7|Hm!l8pR(UqGRU;V2Q>xCk7Vmk zZ=zYrgVRmVdy_740bBF4rxcaDArE2!)@A;}SdOjgpQhMBB3^Qm&kEi8>VAxT5BK+Z1Zm`^#5D+75c__zx;ouMKzn|7N z^V!6hw95y=skRMGpQ96X_B)3cYe=s%i+Z)*uV+BVqvfJ>hP0%eFHwDxr8(P)J+5s^ zHR=SGFb9X%iHq&T$P%$nqpuAXI%-I1D<0YIR+yz>AWp@WAsjCxj=!?R$>eoO&+f6r zf%=7;>GH|f^8`nW|{ zwBowwV7G=c!7Za4T{wlzmqg?aP%>f7W*z>dc9OerPHhw4^kOki>tIq(F2`8~sl5G% zVir53Zi0v+jD!mo54&6H+uUl`8AS&ZQYZuE)c*4J4mY@&Sv9urH)r2UrL|-ZwIH;g zx90|3Hu1rnN(M1f3YCb$6Y^R}?%sm?*SjNW0y5OR@jS?ZjWmSnf}q{~o7%9r&K%?t?FV*Lx#7bgDZf-s#qI9k(eJb7=6r z_KVb004$_c*p^?VJpTmPf%2?rQbTDU_Z3>~9RfO{t`3{3s|nR!;otSBAl$NWCy{qvgt?hUKd6fp#% zf%b`BJ@lI5`F0o$j$e*usoY=8ePGeuTanF@VQrZ$T|Fx|(c`c3Z75DHG7~Mb5(yP) z*B$+(38UR<`}tWq!!EJ#t|vi) zjAk+c`Q06IeH?KhS^T!EmKk%Y5gM@#!9YN0gUxWf>NW$UMMu(MqsN7VL9eZX3fQws$CU1uRrQif6Y(BR^2PC$LkUP+j|>mMiRXy1Vv~(rj@7nGyK`9VJ{N?zP|O$R zXkA!>kH}>l#Kh;jMWJ6<$u$}CD0Eb`#}BeHUtTFi2Ni(2xOY0FUwgEiei9aw1w-YM zkyc2Tqrsi=@0_^_qgM;^Jl?y@f99yrjWoNpy|%l#Td0y$g|d>j&~yfS$d@^*@zyQg zAO5w#0aeA>4KEJV`W4~X^6marGW;$5Hx{u|0N^Eu)fHE35BW_CHQBXOszcS}oQBa(OtwVMcSfTQ zf=dwnD4+Izx~&3n*LB}8n2g1X&bb(B2TK2A+fM89S!Si)p42j;5dUxsITM_9+ztPUlGk*6G7KQfw zj&f`!xNR{|G5g-(i<*^rSh3OX-A70GgKH;Z7Y~F?H0rqz&yV(PdyM8$VAN;l>`jdH zlpiy-6!5MFh~&@>4xX->`fmw#SoL2WYysO4VmysG9Ry*ToP^;qvU~rHo+ITdt2`5U zzj*Fy_(5{Q*fr5%4l|?WOv)XIbEHaVagOI`xZzk&I#o-aL}0Rdx$0kQpd6=_tJH0o zg_Mrb=sVw@p|ksn|J`HMHy>l-YdP81rV-Kuo*%4%RVlnSa3;O>#?uEiG z9)#jJcxb2YI9zMW+}#cn*2a!}b%rb?3|2ixVtFs7(iwyqCtF+$!Ofk8NZf->**M68-gfYH z`4}uzIU6ca@Mhig79Fu!;^+@!@$gPkak^vo8}nOfi+U2^Sw3+7^FZl;B8MowKgE~_ zLS2tL-2S;*-%k)Th-unb4w%jvtcB=ag;JkQOAN`Qao+*mXKk##)eyEM5!t3gMG z#Z_wwKk+K>pq4GLfHN>`^$(9s@b*tWhOYSuVrO+X-MKgzrEjNgI~1(4=*tNE6-Keq zUU;gz@!zlCbGoHj<=Ur9LBW9U7|f;qknxXAKX4UrG5#4CR?b2upZX4`UhU0_!3UNG zEP*M~h_FeY3U^4bg6arwZQjyQ4*Rr(uUfe~;leq_T6f9F{bn9|mwZ1PijItD$3c0L z3wZnO zZ|1Mw7kmEgg)YspNSd|zsUj6n*tk@os{kopAD25IKKX|Wkorw!+c=&!j_*xE#D8UC zu~I~7te*Ia?|)4ws}#TB?7;n5Q1zAaFXny**u|avJm1V~G7$IR zF#b9_y)kE*cv0GiWbv2p)xX}XdA#?qmDt${I@;p7j(mW9odb=7ti;sZoRk^8a$amN zP6vPxeSt0b?NXbelr$P|bRyY|jGD#PbxK_|9;3a%b3`*hnz=6Lm0NK_!A{F|=0-}NA+HNLc zC|_=nGFY)_fDt##rhLvT{^be0jb9dGro;m(K9#?9p8jOy4AFL1`>A{oaPmH=B5#J9 z_JHG$erSZtw&R0E$evzEI#vDER28e$RM8ZxsJjfP zK7N=KGyO-lbnZulr!Uh1PfOo)LDw23L~w*@CKb8$4E78BI;M;t@g10Q0UI?Z1>lRs z^IPPsdVVq~UX-y6J(NZ=l`(hJyy)i}PQs?FzFPdFP-xwoEf#cx zN_yfRUlJAb?Go4fd?wpKtL=`nMmO-&@z_vyjBVvnXc0j1!dI;K{R$FH#D;Lj^9EzS z11wOkO5Ae=8v*Z2TyEnFP@{jyS-RAQ*C2_Xtsv00Y#M$tE#jl#;Sy=c^dk z`N59xn;uROtDHg?IAHYo)m^$LOtOtg}! ztsmh2i;z}VrKAY;eGQ!4nN}$C1Jq0WtT^O7%xTS^w1pIGnO_r?C|K zgSG1)ggSPTJ-!ZoF`8O6N}4LV*j^Hu@88-l^D0i{_t&U2@M`L#7|1}TVMOFBs2Msd zl=MdBpu)8;fTF|ZK45P1hsy=%ULFN1!BTO&QVwEoz9S~Nix!%Uw%f`nJZXvD#AlXW z@oN%Mm@YHgT6bLIKHh^IHH@f)hv!n#gq-)O^B`H`!LR`8*fP7S|8y{<1ITLn_kdXY z&oyJE1FR>tkB`@~hrVpc#+fv~7Zr3i*v%rTcgVg;?00`VKWenC;t9MmSh>CBe!d*Q zF7^rwpF!N)n>&?KCvH01SS0kk-Mb7+^f+H8u@iPamc8q1?j*otRXCR6tj*$doADa- z;2>1kb;)lE$akQISm$SJo=}eVro&7UoQJ+TzYEpCDBN0{;A+g?#krMt@2lS-ua8US zmcA<XgpSHlcc9Ivb$8hiceiwt>O8=S+#mSIq>ig(8G_h4oIizlAlCqzfDQs&S4vKn z9vvy6>l8Yh^Jb{DuR^6W*JkXL*76?!-)TCAPE4&q#6(NnpD+?w5jC=~jjshd9XTAa zN@Zq@r^ilbhx6i*Ny(+hpty*!!!>I*mw9*ZGeFs=RI9cMPlV>an$!^-NZ^Z3N%&7K zre4pbC7%k8Q+zCYWj#r&L9GIie#^Kt4}yg8VeFDZcZ`n!;0wn83fm1v5r6}z6$29S zATNcVazqDs>ER~`oV+EbYBjrwh5Vbv4PKr^%WZtr*8mcG7=w~~{E-{Fm5?NZ-kyt# znF1Id+ub8?ba!`w!T4uG>(yDc683MFYN-9gQFBZ)D=(2p4~$EAs*oV#{PHwnG0<$r z&%(dPGDz_gfOpJ_`{a35IPa}}!@mpAq-Lfb*cAu4fUU5fB$!g50*vZ7HHwKTxMU3l zeDkb~4YD6H13VnA6AnPjY5PqK z$0ElLoF^*FKh%U&PboO>KWF})<7N;nXuV7P@iQU*FZVHtqd!;)Creg&fT?t|#piKW z0kZd55Wy;ER2IO_yj>Q69=wz@=?x_Y((cZjXD9t7;v*pbXBGk9rh1TIi5~TW&ne%) zvP*pU%BSE67wUW_U_82$c*wheQp9cx$Tb=r*MuV(# zG0sDf&D`r^u2kj1Qw8uk;119s3blxGZi~LY4}N_N9E#h}+OkL3Vn7?9moa`x$&luK zdQ<0BFH};?=3}ZeL+tJ2W;@H1B4s~pFv`_0m>k81IndxH+@^0qFYQI1JWo7&0nQ?csn0>sJ?GkUU!1Ox>?z*{0qL z1rU7qkCQL1Mjzgu<6gTYVdC`iaU@ zvhaS8f*EI5{K9)PLE#1N4Jtrt3`Hqc0~i$SQP}EpD;7*Km+IzuIfVGm*t!xmnzFC;E*xIe#S4+75z zi_QVmI*C08u%uqUr_k0x9h&s05=fQyZOeD3IZv|e*t~|fveEKd#b_PIGQ}PXtI*kV zMq|!)M&!OS-cXG(f?LW#xPS!DLHE?`-{-6C%;lGBykEEiBhWzeUVuz3LR9}H{vzSP zY+aZ!c#+fr3S)gGa&MsOO0e;y8M@ zom}K**%c^i^J1H`8!%SI!ttBo06DN$Pv4AlFapWs#Ce(o&k(D-1>)KlCfj;*j=na(30bcp_QayzL?bIHyn z@8*%Mwo$zGuew9di3^C z^CRsCgP347)G<|%dw5zHYilIXLqt%1|s&teJfQjI3g z$l{)zZw8hS`V;{7!?w1iJdXrcUVQ+|^E&igz+?WC3m4lj{bAxepuH>N!mI-}nL7wg zaC@Nd&fae3eFfmKo3GLC z2FZ)+p5L&Vu)9`xH)BvfcA5qFOqMfJDmr;~VsWr;X}%2w;CUd&0K{BYZOotCA)b>Z z@55Uq$o)p{x0^iF3o%R(IbmYhKV>JkUsF-L8#EsZe^qdwTo}sDFTK5=%lS+ryZ#BY z*td)ij1qOvag)u1Oy0z+>vC``sl_z??lC+zdn=d|0I5|Ks9)Jyu zrYE1}uLe&2@dTJb{7t5NY(B z6SE&!LNDVBb)&XHkOdHtMp?Oqr_wHc6hrr$)lDC78X-Ptezi{J?h7ClvP)Iy>5l@2 zEM~+vO^T_>SyET{x^6lZUe?4TB)oPip`anF*rgxMMu>SjGHyLn=uLP+NVN<8;v({! z(;6eSY1J`(G&$|8FNt+A)UxCasPRsQLICD(J-r)QDz-~6DgePI9V02@AIz7ie&(a8 zkyJe`{|D_%DAEM87j=A+ks(1H#$xgn37vjkzKQqp9s*m}~wCdh?3HUB21=iiV{}&JBzP zS&Ur6EBCGXj($z29%U-U_s+81QpWV)g7m8;9xsZ)GNPwk20GdhOqu2+5T9@cHZFX z=!19Q>+%jC=IWZfzS1z!cFWSrrJi{nNb9P38?8XvY9NZ!gG?I5E@nr_Jz5&cC`4`=sKJ!MjPNRkeQ!xj-gPWEX#b*;_B=Uy_zFKQSl*8coQo8o?XIy(o%fpH? z=cp_rGpXg&h&bA%R}2j4mO1?JhrfavH`o81nj)%agr@({=iEZiRaWKp{LO`@7oGkR zybS6bKcyZ!d}v(wqLDSC-1x3g=SLzrm2W3gEPn9oALbpYUKz76LBeZY1$HS-tg)R# zgTuMG5#EoI9B(nt%|P}A&X;{IOgeD6ggV97*|~SzKZVfn|4z_-_$h&;pwk$2M z-vE4%jr!A+p)^)IJCE^k22F$<5I5luLK1@zzf8w|e#*D$Ef{>Oe|6ko4{oSnKynx` zgB5Tq+pWAjDE1rkL-GmQytTdxY4#A#nO{WQkfyU~tCW~n=;u_A9LcOU4GK#QQA$2fZYY2a z6TUYy+iCgnr%!snQ~&1T&ArIY(nUqexxAgc{w68DFXS+*qwZI_V34^?h&mK-y3ejx zt;)>nAP-W%ru5eW;jeqJG$yUzzi|Yf2#zK~b+e#teh;KwZsk?j%Ax_T5#NXtXM44< z3O#}MUxZlb3B|9d!B=j!NWVL@p;%%1QLUA79S&mN^fTOYT|Zkuo-oF2J2!^~syFz| z6B)}#-1FrRcb2$a{2wb=E-!{;o-BPVY4~%?V=T0V!)MD-wlBBUWjfw<6|+m>xtbTL zT2<92VS1^0DHxqRt){nd=fk5uNhhT-QK8a1rFOq;Sh@DfT8a_fp*xv-OGCjQk2qBr zEb)5p@zMuGkpixA%>H3I;R{Q7HoM2WyP;&Z6oFUKMda<5?#DkJdZU)wPYv0u$RI9@tg@|n##f8n^nB9ntI_1x9*BO9{{F+TU0Kyp zHSgWG)J`RarytqDfNy(4K6-?It!L*f70+|cFl}Q*f{i}*MEm)Ey8jkEXEGm|?Qo4n zB5ARN;MpxDtze_2>n5=Pp)fG&ggqvHl~r(tnDH@CXQj1Bi09S8uUBEHj70&PR<~Bx zZaoU32xRhjM-Oyr**tugU=&jGJ&_V(Ib42%=n`w~amR zEaWaB7A)zHZx=ZZ7}6QnIl}ELg8H4BN72Sr_L{`6VJ-3t`Gq<*t4gau&ZH6Z)z?Xn zY=hU&IVbsSXI-^-2R;I55$xOkV3uyF((Ku9Ue?=>#e8duOn}|3jf$aEQ=57I{T(`3 zPC}nx)mM6(diN|DwtaR2b(RTROWn!yrlA` zCcfR}3bc#hl;co{;deh{zVX&grcxvSbE-_3{xT_x{2*;3FuOI*y&G0h<6KvN-!{-eDbHZxRtJxWG+KqRlE=FMWrFxiP)Rn#tlWqkgcQB!Jsq zl|D4Y%WY?msw$corK-;{$1bv{y2f9fEpGlA*7eP3omqvsfyPbS=n`H7eMl^J>>D0N zno*6-!yH111epULS>g(JBWZsk#mXQ?#3Z)EnM5D;?iih9Kq|XvnWgjKG$FwS4Pko! zbMb|c{JwOsbnGdLVEux=<~)8BVwhYQ-g%OmJ?=1>>1yzNYCsa3ysoKpwT{Lc;B$&0Ci=MT#y3$Tw)))z@Pl zldDDr3G|T;Pp)0EOYp7$_yR@2>5RUbU8$9wcZ^=VK-8F;G}c)fCdv+08YET~B>^7_ zqN7Et??$`HF^SD(hi_`^jEdnImD|gfDsxO}f*XVjOX{8tntHo@mUKEX)|ntn{?EJ7%l;}Iiq_)YAfp{a zk6*_2EP-nsFoH2>-L&oOg&2KglgS1GS=~_ z@4I{mcd3n_)-*?wAMc)*gZuC@%d#r_{5zK8nWrh94bHs+wd8>at*TYUEkB7eSb|7L zW8_sXDeU@-3VU~|qrp6-PsqiGhW^~rD_eElzCJ9Tj$zrPqE9m!dYg<#+I%F1ENd7! z+$Qrt+F#tWGKsxO+jAG1d-&_v*RDR?WBIF^R4A+(`~pGB$vK{Rp+6C}ss_huZTKY* z#yO#h+CxBI0c+JfQcK9)AYDP3d&(L8W{2srtrIn?yveP4~ zuE7FTQEkqYyKa^CM-B=?G1R$8wdp_z8hBC_+|WwyMQ*4XO&dnbfJCRX0--uq-=2)o z?d+JfBBfb3Jl8`F@Y%Y1x{0QldkWSbwx+|ewKMdbCKR2$e>6EpZcA$wRJkutMj(%a zW_!Yr*%wu-i(Xgk6~$S}Lqnjxo%=6;?gqEK#QNpWjE&sNi~7Cx62|Mv7F3@=6=?2= z$d7^J_vUJ1Zl#tp-p@Pii79emH|UV<#&}xMw-5+)lV2e}_kFDzW5@(T^dh9jcb?qD z6Ui)JfyR7=AV?2sw?4Wbr}7{Sgvu(mmLT@Q%i-vPQ__FlaFEWT6qIy7(%d@)L&Ii5 zkpW%)&b`~^T6;K&96gqB-k&I&9_EFzi%53A>l*)_iMEJ8EtLMV{A1B#X!In~cF4ZKh;p+W=tLpmkU&^`uqK*mB z7;UiACWIZh)LWZ;8FK{vTvAP zo&3u4c2PYaMk{t;Q^xsW1iWRq4RoHaza;WHX4N4>$T_UP_|5d$;ScJVvLXSSO9}Rk zQ64xUY+l*#-@lQT#J8mtgLffY`l$r1gHYG16c+1Q_YD}PGKnoA(@NSVlRm*x--4^9 zKDow0|F=(M1oIbNq^0SnM>x`~l3R=j>6=<*UkqJT1oK4uSG93(a~%dEEoSR!VBW0x zm>^dx{nBCl1fI7`HngJt4%7|U-`F=Uo9?6r!33#56vHGgb7PxB4JGx8MZQL?(M!rH z=`48W*(;Dc-iQN^-3u>t;NZIwiZz`KDz%rD?@}xB8c63rho=BWsc!(h##hl|lHq|oucD4{G@^#JM$N&D;f<1T&yN(=v^y3D`H zdG}VrJjnOKCW1B^HQP)LA=rV48akA>tXwoS5g!4rap8s=($45YZku7<8};4$unuhQ zKoatmxp*R8ti4Neyvi)`N+>a{n*kSFgS%NRJngG8D}1ncCa~o+0Au{e{w$CCwW$Y- zG9Rt!+?#R+09RNVXy$f&!-!z`8!2zv?$mTmH*5j3oC&p^7UO2dcpDHAf`CuU7m=1^6 z)$N<~>0#y|-N#hT`be2I9T09YP{To)5NKp5EOR!6L3vgG>B zn0;a7vMST&tp|(~d69fOJc0+!R!kL5zt>e+D1LdWo9z-L1-?mF0=mq&NF@@CjOyxjI?E446>7PB1p8C)rf?}q2A_(3-A9+8~= z!1#`yVVUGLz}JnF*UM#j62G6_Tc?CowqwawSn*XuJb8=_VVQ?3$CovwtM6whcxPSL zL;+=8Fh_{Cb>OddKo3wfThC;*Lqj8w#C5YOH{`HItPD`U2+YQ+#`xm-h1k68=r zV~NEau3Nz^_o;2PwN}oJJc8s)E#D7&1Zy4VT|yngI)rNd1$HjS%rk&)tb=V{ zJP_IusFg!4smn${!L_Nh0c6E}U(5>qbiYEf?$}Q(S&gLD0YN0|$hWb>wRsV&RbL^M zy|=IC4q%FUq8^v1kBR%S^U;z)(q|L%CbwTX+$gcKs%vNswsO2NI;!`+nq+0pPQSyH z@IM+`ro}VLKH;-E^1)Xwng=ube=ExO5)d@AX5K&RuG4+=dsQbHlo^3#&1H79_J=uE zzfX|p_;@Y@w2x-Mn-*Y>$?1)!UHVjN`EGIEWO`dBFxTq$I2&ionlUh}1Z01?Tp~W7+2&BhH^eZm<3!;w4-NMfzxSU;~+tr~8Q z?Wh9}{$K6)>8+v>Mzeq_Y50$>m>_6pIbP5I?o=S$V&$+FUj*I*d#nyfG4O-j>gmUp zza7mwJ-fE3%{S5%9uomNY`ys1Nuv97i>6unpsl20!#Qsp$YjM=5BwGqpP(*CLPwtD z&(G+xczm;I6lT_xh|aq|5*#FW-*ni1iqdHg?l;qvSxznSvjuh%j9IIGxl&hCZMG`8 z0`hl^MOD=}6G};iw#WoGaaD0b&+Gjg#nG-hyDHT58 z9aRqp(o$BI0pVdnOyf=JZO68S(+nnBGilG&X(`nJoAex{too<{X8u=>2k~WqNam$m*7;X(FoM-X}8WY6ZFqJvX;zqM3@H#KbPM5Dn zV6y+{oVYn?>4O2#&z@d?t!bUqU>I`cYR*b?3gMd3p}I>VrY9wDYqId>RCpzBa9C|% zaZGdo0I82KVC;W^NV7(U*cv81n=eN|s?eHn4n%q|MFaWKEgy~Avi_W;K<$&vRdSmJ z4^yrMHd=+2VZ$6gpxXmn?Po=~=4Yp=UDHL`t)R(5k;!Y|2phzu0OAGm@K?UXk%QK^ z6l3XxubWSHRAgknSS@F&pgm@qG!LZCc$A<dmwv_RsHC(yXW6V$Zp(|2 z!)WJ^?wj_tEC>XsHO$8GA9<#J>INgIyV?<8mm5_~RL@wi$@AK8F{#td^Mdg>1A+S{ zz1wRvZaNZf!6paNs%kON>6Q)F)kpIk)Dz$#6|d{S(8Bjo*!xvy$v+R5pYC3#jN;mD zfTT)|psM_s8A-+;&*sex=b3EDmj@Gwid?fgVeYRFPbU?-toKv$#alBRYqn;dT{ihA zY$*Gks5P*UpY-kd&G5mx_apQVC@|k}LZH!~)c2HfS5Cr++BwM}c`a;V#wOM3fJXAx zvOZPx`Lc4AzuI)#QOD}DyxpE;a;8I$hLcymM$E4ZxUDm|-X`Y$lUbV=q6@@V31jUp zx~1ITpYsm5{uVIIJd{kwsj#%B3+*{EBxo>JN^B;D2r`Xf36X{jv-!w6_Vf?+%_Sy$ z%VAs66>>X;K3lJ$pVjuCEHvtTq|hsD{j4$Zpc~`?hSm#PVS<82qf7cl9k|e);NMl{ zGrOg=i1Dz#exsvMTzkh}^9;=W_Zv^C@E)IvVKpKHCPvBPM$fF2y|ZW4^m zZIL{>8bibfu;9gZ`h^pncR*aR%F8#7X5tLkF>EdEmdcd+VzHe*8@vr-SBZiv^r0?^ z^ZjrTYg*7|nfMZHDz~h%Q{lQ!+hnu$B@a3F%dlLtato{Wt)|L?TD2gax14vW$1I%5 ztnTGJCef_+VuPr2JuMkR(T6XH%IH83mQCg(9))KH6Cxqdd{I$JHc2uX{C0}v{+WaO zIK|1#e%4PZe}<)Y(0{5V?t{=+p%261Osh9FD+B0_=FP5*`AcG}d$?HRTDiggD7PO- zttt3LICVw>THsZTL0&p(an=}SqLtufuCtU(@jt8BPL#GMPb|Ybf22D={w2F z*2$TAx-*=0dgi1-_I2B3NMamGU5g$GbS-P-tf(pzI!s2{c)(Mi5j5h6ct zj)s>gmC7Y*bbZw=Yq)t!($M=8rO7RAx)b5_#7RlFDpa9fFi6DdBH}9}Ikr6ED%!kY zOA9F{&XR`%2Wx7-Wgqb(bD*7ODi$`ko+4s(ieoh!A#Ob{a0R0cYIn5K&2N;HRCKA3 zWC@WRb)fTG+w@!64PdjqEM?3O##QtJBaUfIlR2`N>!=}3iy6FH6uVgrt6~n|t&l8o zA@pNHe$Y_*aKk@>tt3i)##@^KBj3pUq-aKQsBka=&QY6e1PQHR|BO?2()h1&SR9Qo zZ{fZdo3N&VsGbtn2Rww$^2!fFGF7<3 z_ns?Qr*A>R2~JYy_%SjOd_>Z3PA*pKY@eR+zxP%d`8d}}_Kq=?6Ti93en|NJk9uIP zF0IjPgvmMpuy-^DX>f)j*U!s{sxiBSL>KZKo7kI@+geda!>ILUeflzVoYdCXPhVY5 zW-DpO{1hpd(bomv1-h`IA@Adr))D|RXgP#LxKFMauFjRzlP0_{@i3e1OWWxO>IQ7r z#mh~+^Vz1v8%d1|g)XBLwD~AJs#TdwMDCavW_QT9JHTo_#(wz7-54s~aKOEj6yEL1E2xXroN%Bi6=sbESh=oaZOdwG=fDu6hWA zb)0@HSHCxJe1;w1nqfiQltKJJ)E@21#u%&&o6F6!(Fa^{n+=HQT5m37soC;Fgc+5! zE;RzX1Y1FKutVWrOl5^6k{NRyk$ zJ#VA3L0oKu-fV`*hN!dS2Bif>ZSkwhf>e2$#I{!xQ*2#oDhO9|mYnl69m2&OA-pyd zj^CQ^SxUS`pZnU)QoZ!7I(+6p@nDMp)KjWq?4D=0D9L=z&J9$?MX%GJ8UlUCz_HRG z%Ki>pp>a9gEhUHvH70;3H;s0|SXqbbV2zaDsDQo-Z<)4eWTaC)Sbls~#dJU$5G>;0vGH$#vb){p8K}(NxtUH(6z1 zmTQJ)ksTJpqTRq}xxkM(C%#KnB!3~^M=j}w+KMe`mx}FV=f*sf8XwoY8MAxv^iPb< zvSh(k+G}7+Za*t*4sX0C)@y|hA+Y3K z?frmv8g*@MB-qM`xE``s61*}jE5(3Hnc(r|5k(d>^d3hEYBDP+H^JeI*= z^VciUC3W5U8lflm3iZQpVk}u$q3E8=&-@nKJ^GO^gtBKWJ%|MZ_U-U@Wu3Kad==~* zq4MniW&z}^5QDBZbG7KW|4y&w81sAyE=t!}Nv$2syrP9fe4y3(87;U2ds7!jD4Sh)3##-iP|p2BoovAA_GL#t3W-xKIUeT-^oIUB~n zHGeJ4wcTAvhj;y+m9>?`(>XE=(!N4kVHX7VVS;WU9SA&OC>T@0{3nNG)0uQ+dd4>_ zoW&uxizV@Bnf}Pu(;CnlOzQRxOCHN}=hK~A(P7x^L-jSFM4kq~6B|(O=Q!q7-Q(IT~AGGeox zXnCkKl^>^TWG|*$NFVIAytqGZy}8q)p6B-8qy*&x%Av1jZw7co%9$!L|o zogLQRP(M|YXq*$2Q)b?!vv#56vgZn^s^fX8+Aph_?(OS`ldAP+s^>->fd!9Ae0Ytsz7gqWg5VD|G}q8) z@66nK#+M_85`ehEUACSW*#F`w$#lPvs*%{cY=}7Hy7c-vr;`j%UUdV~@2bPp#+x+!jMjZ$NOLp#Mgd}GCZ^6_ z6efdYkd!Y{5is=Gv^o@hY4N#WGVRP2SW!nd|MwpKvM24Sai+*CLL)>^V851>91iL)t(8bWW z?pkGNXH>hrzRAjEUwgRcEmD<^fsbufEp8}Szg@3x?y?h}*sR&pf4x_n=UFVdW#he4 z8$W5fEA0_~PJ;H)2Mpqqjo#aM9Bccl6#Jy*gF}Tph)++R5xkLxfti_w5xJ_==u%3} zn$;u>gp>}>%vMfIf857^bpHH)(80$hcbeUI%t_D2IAUEHyq`S#e0<7&Bt?Q}OJ&-r z+WFhxW6%2S#A$}+m3#fn-8QLuTB!9bsOSV4WG6!1i4zY3%kK=QU)d`kt{zwV81zFp zzF2~1c1}!fq0Ud&&_ar!Tg%n1Z6`BW`E2mu>#vT5hf~lEkrWW+-YkD$8NN8?p15jk#jJc3LB6a7l5eyqM8Tx2)lX;zb_RwySo>g|UD3nbD5wT1LT17TVYtfa6v=fY6{T>Ct7_5b{oYhLM@x6F z2TuU3T5Q9+h|j^^(r4Eqy-PyzqOE>>EOvhqNPpXJy~E8l@JwY*PqPuP9mm2XlDmss z=^q>I6s2GWsWX0;L-olpOr81Kpn^@7ccaenbcAeeMFYI0bHN&6i3DSx=fxR z_f~cMm(Ia^+FnSvE$OQu3(YV(z6Ye>ALa~S4&`h%H%eCxR>$PK2b`^U=*uNP!Pow& zKH14^3_xIxgEw^2g-&hXhM_b!X`j0<2PcL(5vppZkz3<%1EV5|PbZX;ao0qT$@#>_ z00pNPt5J}XVH6`EP&~5MjN4X=2OsAv$4ki7Qr_y9HU`wpW{BtJ$)+4Xa(X+QFairm zFi#xb@%x6Flyvg+k~rxv#Pn0nDoCNSm}+yNk5=k-egr8iQ1Ykb#D|w4q5d30QZ>>C zfuV0&6ngUC_<qx-xv-~}$wH#wo$Re?!YjWSy zrlhk1DfMgNDf{BuCHtWG$*M(zGBn(*+ce<&S?8lo-P$3HWU1H2@zD6>AL#jB1$1e` zV>9(4&4+BqZaZ{*FPE*G5?sbIISsp7jLv_&7uSK&0wJ0bJq!?7y~za$O{&CDh`B=x zhlhFxujk6*qYmjdas8jL+pYVZ=tyx%xyR4DB9imXim)G6BQB2`pu>CHrK9fsDaofz zd>iV)0`l;l|H4#_F5v+MsDZlNKGt14+85)2hgSPuln@WNyy|82)KMg+zzGfRV^G}f z5;O^TZ9~ku-sk@5V;9I7gYlKDnccC;gL#r2FCEh6h2ZQE+pQljFc5-8!;qsV$GCkDIZ9#OGa|5VN8Ef}PapP(3 zpQMv#a)eJ7Xr{$DTs!mS)8##C&AY6l`ZoKxidqsiBEVC-6iEwkpClSOvfU?LZ;-1g z)jt7=3e_I|zsNfGaHiwG|5uVqs1(Vu6y>x~bKZ&~WE66qgpjk%c}PO#l$@DU4s(|C zc}|(LIgiO<&dgzKb3Xjm{kwkm_r9;+-}__Nwd?cQ`~7+z9xo276mdHftRnRKK-~N} z-cDiZxKQ^V#}jedvH`;BNYWZ~rl0K~ewhe+hW;x$1yBRJWbGE-v;67zqOnZzELw~ z%*&JDH=b;C1B6p3`Yup;hFj4sRNV%bzjK?>Lf%cmKo2m1IeOk$#(*DvnDD-RW219uXDd>z`pkq}gn)#W5b~y=9`@ z_a^tyuT~`Y`rFPTMO(&$>=hl2tkUU1%i51j6jVmzhl>|;#wzeC?1OjkZbPL~4OdjL zgQj*BEa+1@`=p^T_6Db9h$$9k@rCQ>LpS?ThaT~gn6L!}_vwsO?r|-tyeCYs0GS|= z&ZZLY(T}2F2eHf!jwx-(GFLDmyL)t)e48`L;k^`SXCNY>myqmzAnE*$fAMK2B86TW z?G=6w`W61;le42wt9ti96{e1Na=~-!t0UI{Ie;o?t3BAGw5W+K#kdmV6Q{>s(q=bn zL}G+Ipk&vfsx{o6zf_~J?s%HTcMKb{R)_W5f!Ws$3fny;LPx81CUz5A?HzrR62uaj zrU`KsDV#@zjE{5RNa`!sq|LBH(~(*ZK~sR@Z-r%f)Qp$ebO;E4Es5@XOq%DL>eQWR zlvql4vDZR=c}j_cWjJs%scb-TGl9IDLp{$R(7#J8Ws#|!${Ahz9`B3Rs3QgORE|~` z?bq3-8PERN)Yv`|D6rMU&!@(&sk9Hn3%JIy?{OIEjs#(Dma1UDzz#0Yq0k+88MXgjx$!1 zWQ+e}0G^uR)ISfQi9$*MjmQ7VNX621v%eE*@%VJ<(erjpjz^F>cwQ&|aK0ohCXmZ! z&f9XB9XUAJ8ix>fQHs0zB|BQP{zZs1Y3JNPhtpe4YXjN^)Y-EmBr=&c`E1NMiB1~5 z<=K;er5}Awu#pMG*fMw2_3FR9{D0z*oh;YaNwbVE3h#&NL%+kG{4lu_R(NgVE{9#| z%iS`*&Vga0BfINK`oLjPEpQ!#+ZcJyD9|428I=TUPQ({@LXu;(I_exCYV3vd*#6ks z;V|ZEyUP>H&Rw5-ovzk}NWPT&J^Fnpcj2N4c%o-kti3@PH=A`ZE(Z*=`yz95@fnMk zmfr;t#;JZ5%<{(y^dwaNL3I@3KH?l4_!{q*mqVIyu;^s~Xm)5`Bi#TdzYHC#-TCrry z8(`(aFFt>hs@%$2UwX#j*|^PBhG82G{KBxleaYEs8j(Il+lPShrW`)cVU?ARd7K=` zf~Ys_h{<_nNG`b*t*?9sYj@$A`;CI2qD|1qC2c^;KJxuj4$eyJVV=ED zy`OsZdfDdKJ>xnr7>1fc)G>aTxU0SMS4?+AQe+Z--=07?uilZl&ujfR9VkEw$xu3A z(X5*cWNbiH`<@C zn0vZ#*m(Pp%ZU`hPrR9bMMe1ln?0J|OST(X}P-k)*hWn-YjF8*viuMQ$y$#S+&PkPfdDe@pW>btm4?%+*- zUK|&a-ThC(ZYmkM1fS?RghbAs33GRn&b_QTF0}R|Kbq&B%7MzkCED~+J$7GgD)sAp zwRul}37Xf)zV~HCx%SdYGka~#lK)5EnmZY8DvS1>zfi+z0#oDEZan z7w7Of1R`5#?eg=!S8QzThtfAbS$WFmZ4 ztucKu6o8W+E78na7@k`A9=vyiJw0k6u!B$ANSM+RD*$bs;^zH3u&~-tqk5bQA&Nb1 z5LCG|p|*JrG7Px~8@w&&M3Z4`8Jq#{Wu8q`KVdl0P0r>pq1g!ER1bSJy+_Gswc_@P zj@^;Bc$4|6G-W_TqFfstdv7|g8TJZx(E$rV%5o{iEPX{`Vj zwH{80ai=wyM7evz%vROzq`g3^Zk+S2r4K&9B3=b+isVvBVcR-9Sgum(>&YmC!mbFv zp~)Js5^O}eu%H#nf}BdeVCQRQS~}?kE#CI%b>MH$#|9j$J9dRQT=kg+Z3%}LU+3^y z(2+lucaW6tY8io1>cR31E@$peK-lje+Ux>seSwaD2!?V`Blf#e_n-2%GU8lU@1IZG z?D%1IPNFS0n45F_2Tna6|7UaudA)Y@18;m|m_0RNj#+x<6SFx#V(awq&4l^<>y*%O zabAp1gfIAUvL44e#y-uD^d{-q{PT&Slk!MNtX`c0Lk%X_eV8$qc>8qm956{cI`xg$ zgL>AyVlz;YP|1=Hvn}Rs!@_>0(7K)XTfe$sO)#h-#XPIT{BK4tth9E_BT^NMMeqGe z-d-kB8n-Dd6G@QiY`9mG$Byx<;2#QPnlH};Njc3%GOP%jpO#QZ;zo=4liS2HgSUS6 zZpi5RG(G%l6EgX(Um-#;bIOdC+wWSal@hb7Pvz0n6N zSl?O?wnuLM{nBQ_er3?9%n)Qa(aPu2QLP>GLF-?k2C9A_{EA$fYZDjmgv)fPU!moT zX;0M<`?XZ{lD})$0Es5O!o)WBc20G$SbN3JXU5qPslp1AL{??ky6~8qaSgk=a(^003y4^~$?M`0o z)~+d`NQSm=#DKh$=1`Sg&xFR2!^zT+!-bHR*2NX}@zvY?Ny|2qUl-5STd}OzqH2Z? zd$F~UGSQgO)teq2sANURT-(AS=PfOme#aM7J|6cG7B^kc`5vL`iJG}ehcFw z9z%LgUF->PaIAY@a03xRMFAX(80iy zKYl=Fq0>LF67y33BZ&FgG5?9bApz&$5AEFwIG=b+$n&>T>Wdp&z8f)73}PN%#NP!- zd19mk*qQgA&MN9{M+^-!&h)(h>Alq@{;U0Q#}bO!HLG6Y>Fqf=*IE@7>qr&DE*In9 z>GMqLuAPpr#}w!H(x28``rm<&V7kSlGws?=g|;U7?4o5(G`aAaH~)^R;=W0l`Lmk4 z-a`oi1@I(oE)ReDhn#QN0QM3_@7%7xV#^FHNxq)V1JpsHMZP${EPF`5;Z{{gy<`gf z@C>OWt!5cDb#ConzQm2MOv@J5g<75&0fh`aO*$@Dm9=^faRyqBTMycav_uZ!-#GlO z3bP9YchNS`mh08e-V8})dd&F|nK;2wv3({SvO25(O)pirU4EUk|JdyzjoMZ5P5%q^ z+!K|noXM*t302z3))z^@?Kc0;3uYTz@vod{4Y3pwKc~aki-#J-JgA0K6CuF3R9{n>mo9H z^7ZhEGGOX)-Xn8k zt_H#Od4TstI%wszSawu>{Nh!+`I^wp_qdCvS0|BhMuM29n zaC}bJ49rj`h;Qm-Lk0yQCc2oA=Ui@pi%%W4ZaHbS0m4a(%DBPK+Z-)c+Y#*q(G%zX zjVN1TmUbD09s2El3M1ogp{=>$>5aiY>;@m$?q@QQf*&&z1;i}9U8u(@!?Df%-74Ej z5uJ|RhxMD%eNi`IHCtyo8bNO{T$jCibT3+QU6a7|TWx=o5}fR~W@3pLWL#}xb;h%J z6|@*^;e#&VNO=KK4wz3@X-9noip!I|I`7KiC8pWgnKYi`nc=tLsrwW!H~>AU*@VcS z9BAG?|0bf`88H|a3@_|`Un_b1HJC*-*s#yCiECPm;&Rz6ZZ@O!{Epa? zK#K=Jb+VA$$bz|<>CHB<3-=4p|5*vVzjMxbc3^Jd$xPcBNNxHxv4gM-F|_mNGbFF@ z7T!G)Q_)79+U~Eb1_SQjh@WluVMDU%+i9yTJ8Vsyk3Al?99@t~zV$97$_8F8X_}>S z(yt1|xOOuHSNA`>k&0Np2TO>$Yr7iFl6qE!YsUp%aD++mBD@m>4!f=aU6Fc+aq^ME zd{<6q&IpGUcMTsq$OA@<6C_&7Wn8W>SSh+i?#OaS2i)~-qLYq-Elah3*PKk&_rvTd z=wVd#QdjES+=7amR? z)f>kGng#7$JKyipLNU8PS$ujf46!fPoskPFOylCDCXO0)AY#Z9MbPV#eLW>zd1Yre zJ~uw#oIJv)N}1~|*TiQO9ff$NkzTYf`7&rK3JaaGY?reDEv&93d&o#Q8TRV`?9zQ6UeK3&mv!64lb&BbEDWKes12( zlrmQAVM7VsO9{wF%WG#tJtTHTDJn5#m7@p0;TMef%7M+chuRATfrH(YGS|!45jef! ziUxMU&%lenmSKA4Lw6AtR346ea#?o7*k9ScoW&Qq=qPXIJ(IsZdX1oHL|N=zeSR|b z0{_0KU66dHPNG&f!9KsS;rWGvl9nWy=SY{)`JUa?0qm!+4uYYd%k2$x!5&G*C;eoD zsAgM}`)Jpx=B52Wosn+Ivo z{it1hf%@;V=YN@0&!vCJr1ZAzZ}*gaK(SEI&Cq-tfwGcwYf)pj6j2*zactr3m7R|y zUwi->j&rcx?S5xq_qe%E`q+Q+jm@zz!l)q8zRAerx#{ZsCw~XjjP!j)gk73I=E+M< zU&oMOAS7-TF7Z>-NG_q}LiUSyo+4_ypB?!V$HT+|x+`kS2LxD`v^mouS~Ccj(QzxP zKsl2XbG=Q7i&HR!Gapzf)48tOM~4hTQsOn8Tt*f0Am-;+|7n$)zntGY-){AuK1I8I zEc@CpGHT=s+5%<7$qrT}SI2_4I~-rrpbyM)8~x1okhJ+TVWxwab*Gka&E{2Bl+&2} zmpP29>@XeC;_Jp-9(K0ug_EmOxK>d*-u$c_qrWIXcS=DXPmGoj+@NNgEzmdHp5^%= zS30J(!1nm9r>EbWzBgQ_Ck=Zawd)TXxFlwZ)Y$g5AET~}54c?r;p;lfFtMd|S#GY$yZ!Xd7F4#8bAu{Ysw?4@! zjTiglTk(4f0IBi;t;iCRO#D$*&U~9{U*!>z*du@uSUM>Vl**(mMxJ+Xf zkfSapN4qx4h!}?`NNO|xy*daDsl@k{_1ff1h<6Wm;hqa&z8Z;FI_Ws4;8sj4;O#a2 zJ$umjRUfO0VuW4`JA^QE{)?G>~P$76y@Rmp1Tt8~Y|5YO^34MnNv?TNcvNie?2 zGt$qcG*=TtDbf$NlRGkx@d+jOLPZ30ShScm$3=i`i6|IUy-`I5b4I#zmHsRVvzNxc zeEvwX%IawL&S41X=ORS}py9tUaT#pVcg4a#gWst87V$*LH{uZS*19ZKmFjX=V&mCb z@USqmFPJPYTmPqR@lLnJ7Z9J6MIcQ0Hu8XR2GR%%a=rc*^OEF-+2;kMKG=j8{>v2= z()BA&Nt|}xKIk9XfdyK#07%zUJFXgT-LdJCTaTOLFgmGRs1J49Kl`@}c9$OM5S0yr zU#w7vdiCuse*I8Hzs3mE=^{kdKjhb}LXmnt@#ybY(K>jstv-cP4sUIfjBXKQibL#y zYlb$Y(z0dsbqm|HOVTA3>9g$XdRE8Mveu|=E-NFbP?>XX@h7Y}EPm!<@QxL3rEZPO za#d5#w0|6b&UD@%>Kb+muPpVLP53P0GW;XR5$$URu|w*j`W@5F6Mwp%4(z5+=MqY6 zCO9&_uQIv@bY!9l7k3vD8PD9-wz9sO3W~mBjX1t>zx3bT-MZPiIY+`%&q=zNA-N8%d49=nZy!;gxxov>oZb*MELYcwWPM zg@ey@aMByv150$X?CEAroN<(UJN}uJ5S3N(P{BNkj6=R~~UVmEgXMoQubc3-X zFJPF91oUrYCjv1NLKx zY;Q4~tKmWMjy0o|1Yatg%DL;IuHy12Na_cXo49|TrT_7NPuIT~A>efiVG`O`U<>wY zEz8+j#&>_lSVru>S8h=sm5+|Q7$*1bubPxt8;8o;#~&`31gpEmh9>p$6!OUAr|_;a z+AC9Kx05orxPozXvg-MzX|jCt!-f*W)poW=?s@I5nhr@lFR7TL4o}R zYSf3YKnFd)d4Z!VeP5d8{#7olo%F<|xi4Wk_=1=3fy;Z6HBf#(AzbGsJJroOgorHQRlT%{ecQ4O-Pe%0%=3rAGgK z4=0#G0-HpXDI9xr)aS0btl_)!!z0AAD9YVVaONqpMC7DUY>E3v;9uzKcs<)Cnd8yM zUOjqEOQFA@Ug08WdfuS{a@#=Kq6+D|YSX${5jmauKJPH2(S&o;)sGf`Fzx#iSPaQp z4%Cl{nY;^tvt^s`cpE3XX+1gG`@=he(sM*xM^)ykcuR9y&$&aSef3?r={R;>`7_)kfo(WAeM>149v{+<-8%C09|A zbD^!9%PP_`y4GLiI?6c}Lftx4L}=z=fS z_buSaXPxNkqU0aAz65eS-B3E_`0Z3w&(tyg9yXwBuTaL7sJDXrT=so*(X8dWwmULM z#3O)Nm?fCwOmW~kv>t$L5?n?)bSCtxMw)B2yP*GI0=re;GB!10Ja?P|g-29FUjU>t z=HHXc)FB^3qtk9Gbv>J@#jOyhqD4LLbsP0`a1>RJ#J19GRcLw01J5H^AemTdfR54IL&f%)Y&LMI@Qj#S7Dk_FA z2=7!6hM`j2x_T1yCiFOn+wl8kzs0tgv4*Ie>Y?L@OVusk9P{;xH{9SR*DZuo(!NE- zM(NvqUor?|m4rIs9cYZZ@S@dONeZ{1Be~d`m#m4_xH$-wP=re3T=LG(m85CTU+Pdd zb$%%xi@a6DE3ybf3tF-5ujtPWJ{)B`vG0--9bSoT>CJ1z7RdWGv?gwBW}{|XPW!Dw z*U7X(Hp>K&&`QI>fe|zUje`|EY8|?DS#K@MMoQS=V)lEcL?Y~+V~OCv$ykIufaun&z^zBeS+%Hn&Q1N&ir+*7gR1Q6Hd-7 zU6e>AD{I3WJtUg8T7t@0^V?_OYL?Pv4YZEbx;)a`2NRV6Cj$`$pYagW)`>@IW!L^v zbPu0dL>`r=ERUIaJl~q@Zv{<3G>W}?^Mk8+5L&5}6?m;12ze6){5S=!T=FBI^g241 z75QarTcP?E5*>h>af8C|vX)26@af-Q5m_H+fRA`gv8u!-%(fLV+OyjJ6{p-&nY6!w zdc6?-W31n4gjsR00^&|Ua{5V*aOscgFqs}CdE;~s`_Xu-YOO-|iKg%T4;FX1t>;Mq zpIk7}bIA(4ze4J41q~z}4;;%F;K$aauOxJOO!=)xZqX5A15oh#jZl6_F`h z+4Uz<(2f9r@ZS5g46g-myIiDjkV=qYL}YJ*B)(!?zRO`WMYO6Gzvd;jJBj&4E{$va z>onLYSTXm3sJ4#$#%IP=q$3*xQXo@Y(8%^~_fZb&XYJlB=ozw$GF0p-%VgS>iKl3c z(eA;U#_ud*Bh)8~hlY6hhE{{4!4W;|tW`~7aT$nLt+AIYoyspaxblJWxSxg5TjTVW zsbb?fhzRoaa`jdS#7SXEgQn1{HbLl0-iOIFtPe#Z3Pl8plxcn(SmdM+1!$(82 zPH@^P7U|@r9L2udW4g#;n&|XB^I+NTxIn>|@8I4+7XOl;Y>`J-bBuXa)Ec#2ALuf9 zQ0hTi%7S4eJf~?f4?tpM=fr96Iy()>{suZN#$Yq>f--&=iv)Y*LOtu3OI`w=mx1Q_ zB+FPEkJnSH!m2&TgFL(_;wmH4WuvyIY@dAvnCzD^b>fZ2`AO#CTY)MxMcQhT$g{h) zWeHM@@@1MNoj0B37sj_;N_uJa>KhUG_OKW+k`J#hsg5UB6l{kW85$<8B=3)Xthtv# zpJLkK+!*>OVlFfqSi)3)<{PlFBk6?1Il=IdUFFUm%*A8(Sy*>IbYIARI<-x!*n4ge z;r$2KC@#ORda4;&q!KS%U%o)!@YGroNLCi8Y(OtjWz+YU&4rHZ!%oY-7pEK-8%Zd|ma{z0f6#k(es7Zt zhxNw{l2d5-uX%p$*-d<~B z*;w+L2RY(kW>~Njyn*XT_-%<^zTz|AgcQkQUO=|f@N0Y!scvaT?$=is9gk_UW1dc=s-mHjE4F~U3*QTuekglg; za&Sb@9!82asl5O}asjZ_Lh}hXi#=};&Z|7qu??v9C z#f;3XwM^kpF@;wZpf`ZKHe}DUUdkq`YfIbX!`jN$COyTyHHC_K%-G)5NI!8wwJXWV zTU>|z>eI&skE;9zFTmi)&q;kVIZ3eIX&7R5%mjU-Daa|Yx|n?peMlEPF?r%vX60XI z%OmurbVo%GJ(HRWu3v5g7#DyeJmpj--5&U;vJe^TO&YwY^#e9JsTMZj)5bGU?Sm6vBTef|F2&DY?EXA*^qH@w`dDeSQvVmc_P~Tf8}9sN&l!x zVR~m474WfJrGB!!fpz7$IpNzJ z&(&1=k0I*7BD)W+C-{4Z%`9kBN-ta9Ojk?h)nrt%+eg5MWL?gJ?VF`SobqL?8JKk@ z(=Q|g%HZ3hXHiisG9Qs|7_&f!W8t+i5&V%7P7B(kWu`RT`*MvpNk{)0ft-E^FVW)M z2Kp60SW>Z>59u<)4)ghO+k$&BTV=nO;`T_UTgJ91psdX`>5~2rcP@r?MKQt4S#`5k zw2$e)FHJK=yY_09j_N?m^qpNSNX4E`?23YuQ&(A2LoHi{j2wbi`%B#=+eN-f`Q(;m z|Bd*2cJ2oO%Hh*t(EcHy(twMBQ1ytm?b?gra^qu7-e{HG+kIBeBXK*jY+%?gyb1Fo zSJ-MUN~b0ynyb$#5S)IE=`7V*==Y<|#4Gb<)LZCVoR_U(LP@5ao6EgHKY**PMfuB# zmf&E$aq~cj!p0vpKd)JRxhq61p?Msr&11vQL##$39bLiL!%k6XYwj3{Oy79p)i_6 z@+iVG>c)oM+ssLe6UGIno)Ts#!~pO3N+sgSuLD-ER@SYqIl%*&v-FO8OnP)2`3Ol= zvB3?mj(Z$|O3BOW26NJ!qI&rnn)3gkpNBfHIfaU8fQZ#!wM6_1svWu5q3i(OOi%nZiyx{OA#H>!$9#jd+-A>o= z0ebfDWJ@*Va} zq#!AH!zyLl*<(LLw&J3uZia%Q*XmvN8tW?WY(SqKwCdznt5B4!XXmeNzT*(~WfF|Z zO9|emCr>|!Ja<@wMO0e8G3i={Q6kSKb)lJOEL_%?AWrizPUOOf9ce26W-!SU9 z)uMgyEGPM0kLDHwFM{NRN_O9PTrV;8bP+%1F`JohPFOQ>ZjUt=bgC8k>vV9|EG`$+ z74MdqRWcLkIv2S<;6uM@?Dz9*zkU4fv9=FS6@&rqBE;})u0xZHgW7V%KugfmMPeQO zBz6vNBf=@k;@P3ZCI|zd3WZI&Q)i$lf9*#W52~)e+#QO2uhrdvSSAO4L0b2)E$}V2 z`{UyIGa3Oi7aM_1!TsZNsEuBwRh$&WOxb(aaZB1uV9({}Z!|q)Laqemj>5U;K4C0|-4|w7dWj5o=4H9iF2QefSymX&JfA+ZXVY~bJt`IiH zNv(1z)0Ic{LO&KQ#ydPAoeO`S3_VM>ZQE+>QkQzt?)lbsiqMOU*qhfObFg5G0RBh6 zJ*2!L5&|FUG4o|1;4*BX)l~D)5CmMfR4#Ap(iPG$xh;!oSVEU_WUvn)ce>n3&loDA z9N@AI*2&LKVZ#|iRI6@S%sQyR+eI+kXQ)2U_9-}#aX4T~-kp|OU(|mr3v*gFlRrftQ9_N!u)1;ax zmJ$+;jq23>Kno8wZH(;xPCrx;kfv_Pp9*0vjQ8LCItU>hdfzok|M~Fa|2#0`rAern zr=jHGbze{8!EC4h`PtZG>38X3oHjDeI^EKZYc%Rk!&j@90bfBX zCX$(@)qH>SMF>UXZ24=`k5Vbm9qy*$ZzZ^J^JHDIGX8|}{MLGF`wc@f-Atyiqi$;H zLZ~~9$nBo$q^}(j6&}UHRKpY8z2N;{v^%44H6T&i0q+qNff!6m+v>Fph{|)5z5gY^ zfKpIt5grAB{&605sl8hRq5mc6?83OeXC-yv?2T8`=XXbgcd&69E zA#3uZoY3CHQHH?OzHIkURvIrs!>FxJg?EYw11L&I${alB9BEhG8gmKN;Wc^+g}hhn z8{HnNTKwEIF{0JR{u{xQ8AbX+zG)5Z+h5IMv8S|zvxbuX9Gx8C8qu0Oi(^dvZnF`7 z!+=O>QM9Mw`hYURjb%8aK|{MO@+|cm!#3eH-*NPzn32@>TyCjd`r^upWH75`CdxtL zBD0WS;<4@2xr2zJ+f@~;z*#UwQT#VJ7s5k&*7zlO&kUKU9`-u*2pBvu53007(Ev%K zqFqi%3-Z|o&dcsak{iQYbNOC3A9_S(f9z9sUp6chts&)&#yQRIlAE&7)(m@8z7lLP%@twuc4BW zUS%xUW@6id_OuLMwtBH+-}Pct#^-msG&_-{Zw?ZaHB`Z&*$Hgd`h<2XN+g%?`vrsX zQ)g0347RV=BiV45x#-6Qo z7N4AjQ2x6K^H(?8SjgO8xhTDt{bs_ZzK3H>x9^6AK1r#uOJ>xFL18I+tP3BNa{7D{9N0fd6{Xd2!nSFFIO?aj>&XhV7z#|!f4>=#of^#&Z~k|V zMXs}(%bLs@A;@9dRZy!n&gE*HX{BC$09%Q1H}lgySu}53IV~@9UTOabK#a-ZHtPJ;3TY5<6NJ zsOd9ML$V6jbNJWxlw}B|YZU)|ZQ1z%VS?4OK-P20*@gP@h(7o9zr;gB4}2<7UE&lr z((%D)z`b!`)SWqY1x9@A0J|ak%C}pA3L7fB-GG(NX|_?_X|?M2sCc=tZCuTiEBiQ! zd7AX{E6={I&3u6%vM9Q`8ZvPFi(7X|kykJKa1U8MKP^YO*!om316YJk$cC>e*tmm13# zj^+A_8#<@$Q%~%EbM-s&`G`^EXs|haP|QY_vdbJp$0zao7M#B$3vpD*s--NUQ_k&? z`%(p9o5Q~!Cx3rU%1QW7bnTyEj^t(jlfxR`tf1uc{{i9xRcFWrNcEmWt^hlo&mznE zV`ZP>+pYmWYnuA4sGF|Rrz!0IB^%pxiB$>zN!D8~t+1)qK@p&aF8EM9Jh9j^vTQB-1X<-J#I_1m(RW_>PFa8*JVk zd1caq+qd-1m%=YzY=U98Zdkl#bpSt}Ez?kY>~i^{{tQdwA0OJ)I;ZzGJ}}2TMaD6p5d8f=AG?Kd(22N8dlXd+Ep?*i#Hg$CB8P~VdSOKef?SJ2N+Tgp)y2i2t@MgbO z<8Te~3fYXiiz9BXIRICe$E9cKQls!IcU0dfHm-~Z+P@7xK8zZ`@+HVMnXej>wu>cGjyj-nH zUMHUn0wGn~vg@R{1;nV=%f6NovZ;yM=`)#U+_VK3BhyI$WOEyUq3;!!zx^k%n_!Y! z@>!qD;f2_aN=Pzx6P{lKU3QOjsV1*{gh;(mI~CJ`NaRYI;XiNI1Wi|@vLd&1e3|1s zc5_dKYJ^jK3osCJ)hy|vW@jR+Bc#Z)Tfq6|8agLxiQU z@=pD{iCa5QooRH^O%6(R<_Ol^jj5r&?zT4xsp~e`7(}VUTJD7iCL`Wd`9sgF^2RXIh^T~ncXz_ij$|)<$}V4mr5+kjuxv~Fz=IFoDCTu}@i^V3dqdZqk+#t_A1m=gEo9V%E-x~{U zFIUM z2=C4qw{zhG19JpD!GeVdKuAY$Wl6yt3WzGY>D=2ECZ@0ut5WL zU+Fh6LcTx6p4N`tlhQ-)`fPH?=A8~K(F04T%JphUsi9wd!_lN>?>~_$BGo@B0SEd1h?+Y^V?5+0TdNMMIzmCsyIw*{;($caL8{#xRlBwgrn0 zU81XQ!yv{31T+``_59UXEYuW?qc#Z zp$_wY#-|t5HW;^GQaSY&+nMD~ z_`ZIV`>tc0k_}dKaj6BxrkaS z^9?sr;da%DYcTWKNp9t<{lYSK?5`x6%ffrItP;eE-kmI3qYoZgK74RFNL5a${_O3(wU9=b(PvDLDqP`(#SY zB!i78JSFh0RPc0ro?vpiwYd(5DKAi2i|db?@4qtf3ClV6I78!bAXIF-@9Yg)9=&*3 zD@D4u#x0Q}K`t!Hq4SLeK$zSHrwW%${V5%<$dR?ExAzJ0uxlx^>re) zG58;wKkTwIBn~d*c5Qk+DJ=LS?0DiAsI2ST_BYLgcF-16G0*W>`xW*EkL0WDJU1e+ zIXitvxa%^E>ZN&=Ep;u9p7OP^O863a9!IjA9=_hoC*~wE>e1hspotw1nS(L&AeLzD z_jsJSgILw-TsbRrC8=V-smM#ppVOY2F%ihoni%W6XefX39IXw(+gCQ)6N>?URAD;M zke=LMhxXBC3l-CuL*0|3yw)d0uDyMSeSUs=-4Wu73{8wytB)JU;?Ix6uXhPXcz%gG zd+hg3-K11=@U;r2?nJlP8@K7Z>21i|u?dlzwt1$#Oguu4uAWYAIPdb%HG-(ogn(c~ z&Rl;3b#SCk1K_)&9;6M)#~zE(mD9g1hEAe*zGaZ5?|d%Dx-J36wFRe)32J&=zPhP3 zh!I<;0`jt7+hM{`|3mmWIE9t#3}pLug6p|{lT9yNxD<=?pEIhPN&BhYdQ)|4E;!6# z*=_Qw9;#%JdPCC<-JQ&im`4toTY*f|Y5OxbB#uhCFc7&piWsgob33qVfP>Q2Cg!|_ zu|gldkP$k_3Y(Zbc&p&Mb!>UMy}d!_$}!ZL;d=zG03G(M{M+dBar}yD=ha8-n9<+Y zT&wCP3lE=y`x89;GE%&Wmfr?THa#~F^O(sM+me`}l3E(D_t?PRdgOzY#78>aJ)(;U zYuwf^&{3e&u|M&WNdSuA@8QwZe2w|vJPxWg;q93E3>7{niFMi z+RcCBZIABTJ7RC__aZ>tr#uCuU^2E=k0xL5S_Ii!asAA#Y9w<@$`uWstiGLaJ#a^R zw~%71P9_wUCEf5F`G~z>oS$=4;~J9#>Z6ROSAjPr&ii=Ykk@pZqN_eWb;3YcABz$#^OYNcCrR4YLCN*`({oM%syO zgj{X##eadW6rJvJnr{9;K*@NVA&Cb(t_2Tz?8Ue6S(s2=M5AlR62EME;N*;|{($Z< zIPvcH@&_w{-jJ0hr_E5bKI_3)2)`bBRrfYl*z`DWbjX6w{v|iOpfrWiVN0)SO?)9m z1hh$y-IU!pZDP1mw^`Hvdv4tkw?4Hwj=k->U6%URw)@&IC$U1PLVqfyKg4wVXDsV+ zEZTM@2OYDE%hHPpK|c1&W93(Z48hgp4Kj*Xa!Pnq{gUYT0jB$1dY8?g4Ky4i;+BDK z++@(B_K&vf$PJ19_WKcQ{ur$Ks#K= zqX;nM;2dIVGMMFy)Eo<;2y@r>QXTtoOSqE}o}-Tj#c1bsl>MIf!fn$<+Cad%v>)i3 zJk*;K8Sa2(X0Oqg>3n+#-5!i{^J{Y$c6ai6z1YDYu~sgQ9p=1c8q9mp=V}3-!^YRH z#`=Mb7hU4kPwv&tKV0}V_hnmo_hsV-5NC^MPbDQ2XaNc66C{7YVCNroW)GXV*QvEe z5TPE#Jzwz?A!tfU*>d>j9{#7zjfwAwse&MkeK5Dl)+z!Bab zYSNBP^O;Zk0-5c1ml@pz& z!)~h_vfzCEMWTFg&s>*R^r(yMb2~7+RtB!J_}k7PL|a=se55`jY(S+y{e&Ok-HXgq zjpyx($zxXrCblm4R?_HoNMAzrF!^z*vtjw+OM?VEvKSfc_SoDkm^o`Zs(i?kGGejQ zfNk8Dj}=uV3ou#AJu`XY7pbs}rDHN<{IM5i{9Rk;7NtC7Fx~Rwpf+docWamV6`9A% znc-vFr?=i#lHI52T>{FNCdO#hQ<&Y0#!g?WABJ+K2_W)3+7<)-BRLcFRiR%$G2b~& z!DLVq`d=6GC@%h3DNOP$s-VAyEt`|UBn}i@EAbET(T1uMKHr`CJ3KF0u2c(Z=@QWq z4Xn|U@o^gcucrnXGEBdgeQllR?@s`z`E`b6y?*-w|Ciq zAzx1n>cjwGp?rjL*I z$B5m?i+$1p2Kndh%aT6gq$cx~*BsZ9BlM_&)e(2hEz7XIi%wdtu*6=%t)!}rpa3az7B_aPYlafkNSRB7kQq3)_`XB;iOYAy?;8}QouA}77> zu3Wzgtsv~d$6cn-?@n=-8id!WweV{%>KGQu=aVP9=)j{>Zn`eFe%4po^vd!kS zIy+TGFFN-R*8Etqc9H8x18W1r3LVEwHiB(1mohhAz;hlGDxbU>mt{$MMtAkZI9-ga z!2Me#V(I5wBrf?=_IA5e{mExtAaDDs=JwR;=M6I+~va%0I!yP zO8eau+nZ|a!tKwW!#nAc-UYh86}?yK^)@m0>BL8Ek;{&1=&~GECY~^o21mHz6#7toQ;}UoZan|{m^|zlhOyHr%(1&9&%WX?eCXby^3z# z?t}IB$ALB@tN?g9j{)EDik0mF0Nri4@tEkOv^rItGlCz!`VIgsh|2J0*Uq^#V8YM6 zbJ0H}sWv_1wglJ09uIdtl5DWZhMBM7p1*J7W=(urd~U227;K{yjP$bO%3-2pmvo+x z9DJQMm3J`qpsPCNpv+56`(M$EF;$HrJ4zNE?GYU>)*6#rv^wVCZFmDa7>i;vcS(m!k#z|)o1YjD;R<#v2n zA*f}ya)_kdUU*p>44BM$`u!y^o4{m8tXQ1oeQmDZ&zd`alR?2TfK5rs?x>MzU9N=` zGl>Qp2a|qN`ciEHDEnr_ok=Zl_Zt8)hmRr8Ljbk?iV_o*(*I^4$pIfG*Q{NU^Ti&**cW#(bX(&vW0!*ub@uR`YJ zFyCe#&b%dzg6Uz`UZ@3wn&=^LP7+)C-5UH#oeL|pdCh9&B^|xZi>9|fenu#alE+r+ z#|N4`zb!l&m*bCw@mj>c%ZlL#SGCFElI0&94BY|XeK++0T#q4j%W}UJM41PA=s6KRc#(Hc; zu`?xIB~BN17_PJu$gpNF5HQZt-g+0y>Nk+Ymf{In0&=Yg=pc~(*69OwOTCA$B;M_F zEUyOk>3xx_i5B~$4eeIs(p1@c!%PKe21v?68Y3P2T8PSCd43q+bZ~U2OcpvuB zy-ON%HA(*Ewn$b*4R`7H9AzYX#CHjx=Nb`SVZ=e&bKu;j(RDWWzEzITcirMsK|$^( z*OIpJXw0IODE?|*R7bevv{2UJs)KO`Ot%7M+Ta68Xw^GJo(|>3n>@Rw8G@^1mCqUa zR7CY?-*gFr{e~)QWwV(zw1}q9_oe>MhP&tn+HAi~A01UKZvH;B_H^d-Gt;hElz-*R zZI>BpLw7EhiUS1`SR3PTKSC%{o?zE0G;mT%v_s$Da4-#Qsr#fVKUHs0b9+26L^jT*izJ1IRT=q{=* zm!(~NAj}tM~S8xbMOWV+O%@Yu$dCO1BA^20VJl9bpm7wy;MZPsng7gbsg zUsK}mWNu}zK3(N!$dU)JyNCv=TZ~Jzm*w3}1W0Nn^@n{@y?wSIYh%oNIu4<77l6b6 z^tP=TbVgAMq^}Z{&^#FfmywAnBB<{kl}4f!_nb+N$8T8v+(y*iAPllj$FNAWof+&} zOJ@lb-dp{HQkAwRu+VY!dz27^B0sN!TC_RMWj13kW($tfDGjy}=Wim;+MH<%C;wE~ zq(H}FAvyQXhdf(>R$ z?SUfZX~Rqz^07?T$4j5J6&TNbIrW@APqK58#tdqv+G^_T+PJ&OtISLmt6O@vXPp>| zZm?BnHcx$v4OKmur&Ks8GAB7Lr=_5-=Nw!1u+ua*?_NH|WBRlN5Td`-gF-h*EuP{L zn=;H~O=S~l$*Q)x{L-9_;lNX3u;a~38EcP&+5x{}6vyPrfG^FP8Q+S;Hg?ZGgtMeH zd)%2hr?#EV0F$0luZJ9@D<8ImBO)&P==TIWnhOs%{j}PV8gvpsNLxZRK`IwV;;r>+ z4Sc~`wWTNqWTp|(Ixz+^RbKJ)dCkB&`D(<(9w4sbD0|qi4evY^V&8zof2G?uKo|Eq z-Col-fkwYJ_pqEM$E{c#t{4{v(TNQq-nMCvE#j7l^G5K4z+1D4L$b-X#1j|hkKX+P_{PGz^*o)oo`k1V9|%{E-Uav@5ofBvH{ zTj6Mzk~+YXN#pVhd~`0ku?Vy5kPhj5B|04^9?@3AmTd`7#OGexD$ACXg zDTv$e9zS)usJoRXlp4W$6$|pzjqGaSXnRBpL933V&yZldt7=QWcJ2f?!Wc0hGEa|? zxNA9kM}XiBph}#!O>&)#AS+pX|C(QIrG=opk!@m1&Vr4bj@7)VeEBr68c`dI#H^`?5Yfq16W5uC{A*^o%d= zXNwXf)3>_bq{-9aM37Ad>hgrAd!c*DHjvREB{m0v@vv+axR9p%)*Eh#@;u!wez8W6 z?i&P-dJpSOahJSvwEu2x+m_{rmAAwsI{B@IBOiSOq|*5CUNY$w?21){ zpLmmiDoMw@$Wu2ORVKQrgyRYZ`iyifeE;ckCSb3|NMc-z9&X*sOb{|QQrFx1>R(hw z31UXRL0u{4TghgG)<)?^ZQ&ILOIC40s^Sam)J0AvI z87X66Wb@GVPBUDOG0m}^^fXBWup9v!YrXoLjonteNoH->_8F|AuT>ywhyyE6j33)| z8|S6=`7DP6vxWnvqfpAznibTo6SZke7Ndjc|Ot&q{%i3ExloX zbVr3MMb8`MoLI{$k34Oe*(zaCp<>sl7f=qjWqibXdd}FKy5^hp^^{DAgPD7yE_iOv zf1jejGI-8(F_a${iAWyMFB)MNi(sgcNP2fkkJ|B~1{3#8U?fb=#kuC?kViuUit6%-Q8snT_AsA$h z5EOmjc#O=&uEt}yz4yfdFl9-%nkMn~(Zi@R9lt(OxLn_6C7oZ6z>e1V_H>)6s;8e# zdp|&J4jQ?Ec>atP3Ryw>K04wlT$U92tPmfyO&2m=r1djA0_$6A99gYmiLuea_$3dN zZ7fy@bk;8cIgxrebaf@W(N&@03p0@RI15)^;VuUk#!m5Toq-V$U4McDqeKd`#B=`}b$65ESfYvzu%Bdw_^7!+^JZhrNeU1DGO6O?BlIx_77=tgNaUiNzxZ7gX&7rZ6Iy4rAwVcr<*FH5HZ!!N#vJg&b_IW7M&X{yPXn7 zR|uZxPLo0OI4ZKEF`am~C@zIHF=rb?T`3a+2S2{$6wt6Q-&tPTYl{9o)T3 z^J1A|UpR&L+OnHgx7`jsb{G~1%XBR8z4JVD!UW25J@R+mmXm)dZF^NZu3?WXA}_!5 zvCj=&1pF1TYG(Px=shUEs`z57q?q%>^2;PoHt76gbE^Zg(kh1#^Y(lHE!@4!r@1G~ zUdm0j>It(kL;6oZBOD-=YXy{dXZfTetfITLZ$;SnkROgo4+V91$}xxn1NKHV%~NB5&_R5-o#q{LU*1c&<6CdtBHEs^hH%R*x3tMD zL1|4>oD5OF4qFV)R+$-%v#3kUqWh5hDc~nc84bvJ(@=dnw4RJu5Y((7>M+C`d`M#M-Z&*>R zNlM5NLCKglb)-3Cd0S_XKo)4~dd3*5^>quwD#d(805b1H!&l#kBA@qxi(udH|2JC^92gqDr6?&nHaUH>h5VTL2?p1Husgl*C~R>V;%- zYr(O$-zdYXs|D}NjtgzyNmONRv%Z%GNhelc${Voa%ksxZXEWkn%2CeW(DTbLdp>}^ z+o?pA^Z5##F}-0|D=P7-bvGV~kq5{j&2N(5U9=Hidv#x>>HOz|Ao6b-o^p)9p{&{@ z`SaApKE1tn^pvV#DMYd&UVI&gKG|?Ghf%DGO&gSk4tvYnY0$0D$;Lr`$Ix~KPqe${ z+2Yo5CbBlg2i)8MXgg-Od0fm&Vo)uBaHeVTn8k8bkZ|Xd^thxG{U9vcb@KCADYmxM z{gZnV^foznXF#lM^u27SPAE}YqMhkO{a1iveYFcUV)N0VW%T%ztV(CWkUq&_yQX}V z-e~T8a+mVM9oGnhX!QVb&4*$*x;DN&&J!%4@nGkaYQ7|e`BrIWJ~$m-MKnWD+n+I_ zR&3Cm@pi4TO^$Zjd^lMYwKt@>nTuRljE?y6?4Kb;XNBiOUnC7z0zK*DBNbQupe}*t794En$((tys!%A1qibhZt!Bh*n>Ng zLpki0v=?!ex7n?RP52~@*iT^2$uTaN@Tn+wy#hm6tXR}h`#x5 z0F5}1=aM!T{J4YL!leDVZr0fv?1v0C8*x1dD2oj=I8dY|v6}~8y7LV3q6-?Ut72=X z`9yC35wB7I7_}{V8o^dIn6ZJ8}*lI31( zElV{f0V#1@lO23%qpRSmx+^2QmQCNufL_T^@u)`8Sq&TSuMyJR-ygmh;;?zX%|#u$K#edokxV^&} za6pDdVj9Agwm8w`@Kcg9zYD13^Jx3>sn3_$E~oNyTM>GeO@2HOolYULk9Z7=!um)I z<4eLvl#XCh()RVW8{9?7^flgttVL@%h3q@n#QTg77+h#6!x(E|br161Rmtui!%E z(+9p+YP$xVQ}WUk&lmU3RxGb!9%f-=rY^8MTTVtYDqc$NImtGHNSExFc;%qC$VS>s zApDjYMDxV>z}u7~_N8_-$4lynuMsZ0L+i}(wUJ^EBCAv|o9r+s3b_~W zJ0)KgEghT0T(4iJ;+1$+7-Oh$-69u8U&JAg!ryPp98&K51 z#ifB0i}1BRS8i)&22<;1v4ri5Ki4vwBNWE z0v^8^Y+#{vTH}hxTcyL^VBgMmFFEto`!ZNju}Q;kUfpwFB%dzxc+9G>Oo@pux78eE%3O;xHx<*7#r2iLN+0T_u8HrqEz z1i^*D@V(pJGL5J>m{tU)%bl@;6V?=a#b4z`FsgfDqIw!Fa-g)=^eQHRR{JV(2L2z;Lo1eKU$!V)xw21(ZYDA*$nt>H88kIyU$A3ev~38UP(nlVESg z;}#ztMZ)|Z48WVb(2h%I%}j>e29W=4t4aONf96FlT#dTiLZu63kdoSigyjlDU2s3c%q!2nX^w{% z{i0bU!?wuw`L4PPZIC_Vkf*Co7zf=N>!*B#*gHy_p@j2(R<=rYo^y)~mqGing-3D7 zy+m>llAtm9+0V$+Cf|2AUC%5rVIa5i9u)6=i|BLd%i}X1k!6X44iB3mJ|kPx>jmZe zq`%L`(2 z?&I3*%7Krl_RlDY`h#1a%KVm7w!7C{DOpd=%7s@h%6`sP1`O=!Tu--1l|f5Sh0$@; zL#^bxfS=1Z^+luB<(AX&F`Ywz68Qn2&!+hpr^>o6KJPqykE^Ycu_PV5FJ=L7v9$HfXVxJAP_KmD=4cc@`I+<n z6(+_-!99d{_<_~grCTuOjON&eXSFlDGB zVbnguDI9t74Ih=w;6>N4*bcK*`s95!c4)2p8En}YxM{=q^Vmc0BJ@pkZ2GDLr{YV` zqt_u77*L`Qs%$G;rzzyRI>z&feF}-6%Y20)R~m6Cs%;|_%w&qK*k2ROPcQEW+{py7 z60_yUG`W^_eUwNimebLAacJI3P8QtWtv6WWu>EbrVh|niD(S^omb`_*EJgvlE&Y94 z)wJ<8!)Lj@1aVEmq$8+o>bhbL&Yv7^5rLVA&4&Dr_zcLfHRQE9a9k0j#<4%6UNx+W zi^(qku4^fJVGx#DBHz)M3$Jz#C#dy_QKTI_qDDo@DQ9UpnYFRm`^*Tf0mS5(su8GO z!BbV5SeAaHaqz9dfyp-Tx-#j*hRL4p-KNddmJptns?VvC94l@IYt}M%(csYk=%zn3 z)L;9S8@g)Dl4QFaw7Lo^KH`o?w}G}jA# zQJ8zCkiQhSsr3UBVr|5vAT_=4++x;yQPOMFR@9cO%Jobgj_5Jl-vNhV2k#Xm2rrN6 zVUJnC)$NPs2YI7d7y9Li>2>M=yPqg<(xO($_d$SHuIQr2`MLN|%#{`#E{nYJ7k%YDh_B_M@HY&)usC{EwpWfz-1vbCG zM}Tv&&_xdosTCGugP~lQ01$ww?4q-akolgy-r_tDN-`yZ;^3FZS5%$tX)nb!QD>aZ zVb!w$KtN-)mFTJQp`~9g2;`TVToj|eFbvu7;RIEP;zWqZIDzP{pgk@}#Pmo>t-Gfy z0$K2*Mng&kDxZ5v(*!n11l)?lI|H<2Bbj>=7<*kJNViFojItB4qKzqvQuG(|tA&a- z^DwV79SEk}%Myu7+YELcDrok?gQv^66CEEj400~1_@#j3Liaeq-6iE=?u3Ez%Xz|^ z*VeA-u=lhQmAV9Vo@DGxb0>O&2(fJ+N|v>`$S;PnC)Vv#UZ-oTNRD|sVSTo;MakEd zQbmK+XlAUvH9~bX$B(Tm&F#*iyB}%Z>%d!3&LJ91JB8#}dP}@g0Lc3yz z1i=y8zw<=#nWN18sd&i3Yk|%Snt+D^iOFT2Bf7F3KZy!!zE3(Aeqp8Mp_b7sg=3{$ zvuG?^LUC=M{Ipgxxt%tfuHcqql{5FS-dqxo6~|;6kD1SHn;ngw8MLTTY8KRsl^;0N z&LX8g%v|1(Hs;mFK420O-Ty6{FSNg+#Ao4*GuJ#wQ{a$ab8SqX%1%sVdes|?aJqX35k|BVRDR?T~xj#NVJ?|jrLXKEEH%Gbtl$( z72JH!EUFk<5Xo=koD3{3gE^{_QOocXj;bQ#`Pu^%5fcga#KMryq@ifvtg4*YS^ zh|HzX*`-@di~NzB_GyznDLUyji@sN6%@M=8uKKmpOdYzTnx~Y8o@Gv$i3tiBZt};|=`-;EL&@cQA zVEwu=Vo{rk%AXIt={hElsEA|A@kAbNW{G^;W2b{W zTbv|Ug~}*@zE4%Ce5zbew12Pah@Y47{uvTLwIa(!F*>+y0E=k)bU|fnP#s4d2=E7Z zPeaPE=Fd73&D>18a8WeEV1*kJi~Q@H-+a2xkc#3~9s&+yNczZ;e2|;!@`@S0uROs5 z8&C0~45?E(42Za)k~mOTS_|k}bV)5BQ`>cQG;!K|MzgyR@%wUWsFx_>mU_eI9KmLa z?zls78?PDVz4m6xqRjR+bI36NK-vQXYR~R)X!(a=4+Gf+4|$+aXMnCH;o_iA)!TIp zjib;0m(%SJ*W0hXxb8`x^Y7?mUTkkfmvY7G7#PN}#BE2T&Y+nE&yFxH&*=!5>oH4s zn&(RUQ3|;5S!X!Rk&nzJDZx)xde?ej+H=$2(^U*U2fqa3A6{PQ;8-hCFrLNp+v$p# zt6L>F-~G|yND!m;N^!bKeIHY;60uvw6}VF=ct|I^!zlQZIDsGR`)2BPCON`qr+yCH z#U;sRwpXjNoVhPaN^f%-d5qlZmGtzv1(k)H*1)ahQr+THgcvoPi8?^%?{}IAqa{wk zdpg+c1p$%+b@+q@S$?{hX*v7Aw1faEZs>jt4vTZ?nFk>xnHPH6Vo29ANoG5uy#<-+ zVqFII3IIxO+~Rw;3~!2|o`U`18KF#y6NgF4+Wy zht_ZJdl~I}rcUNorUUH+RB#giug3LL3{p3_5Gj}D^Q9893Zl%r-I$MusvWPtCQlQB3tWP2BFCg?^8al~p{>kzN+ys1Rv{WfV59CHq zTm!jTFcBL};rU9715_v#Fdhz6-bg^zx>TUj6YcuiJrGJDf4Q3NW67-UOS4eC9e}w2 zt?mZ$*HD`HE^V=@Yow>$Totf;isra!F|0c!a264IueC zp_X4u#v?mlcQQ&|1}6LTq0`aQo1Q4z68ovFdqL3wh#5i!AZpz{V-zvmi#VnIlv89= z7MidjD+sujGqKC{^p;T{FR#0I zUj~0AX;WUw@1&$fgO6KDl!9;3Lh`rr$!ZO~(Li!*Q~F+D*sZ|DSPE^yqut`6uC6Xzn1&DO=USwE1R32 zl`~Dju8}{g#{R&BcuO%=OnFph`9?-TusE5tq&AmOx!N7-ypphazQ0^P2{d+R+Dmav zo~zdBc2kYMp07Q7toBts;)~ZKZe?9u&s@M*3)q+;>He?g(&FUHoL8oWx$Q9nTGd_`Z)Hcch|0Di!#{~-~^AvsiOK6 zAh8ys!x+wA*J@q2%`z|zmoT2e3~}!bhAt@1QT3*MaFH~Qtf+DSH^TMG6@{CQ`57!z zuAjhHf>AjSxstk06LWBnfy(SSX1i2ZW6o~g^bCqD&Xeo&a1e4heAgb_K9SOrcR$ei zF81u&zo!^gmWOkX&NntGbVC1f=jd3TK0k=Q_Lvt_7ek+^w8S@!{QBvw zVItF$0YYe*ysodEpCdC-o;KANpi_oT%Y`TaP#!eSpHD31%DIT`fBM{KEN1hfD%WB6 zCkaj|#20f79YdQJ-!`bP+}hwLotz6a4Cdp_Oty~gd>i>7+j2;UB^ShZIo$}JD;xO*!XGODb#83Ul#3&D+xd|(yC1hJ+@=I zR{?Gw5vwg3Gj12mEXzGI02x%|0dk4E@bZKx1-Y$9Ao6Z3uW}BD3dA%YaN{F5tzvbV zhTBB3ViUuvSrsTt@1bRnEq6Bo1t}8XV`e(5Y0WZdu3)b73D8!#yr~^6m_Ail3HRqZ zk+Joa_39S>To2ojo$HwNdyFlg%hYyNFFFaZLg>bs(W=9;Hwy+U3_2^-y;tcu&z;bf zq=fH-CTM`n(L{>|HJRcG3k0XwL^qn6lQcX$jhMQoeK6;Er{wzkTv_pto|V|Hz`ZB9SDw2)#&cm6$YF?lN$+`}32hpY zt~bWz_F2;5vP@n2HD4XZ#t?x&^pkF8~ouf_Kv^X2!sh^8Z_%$mj5D|z(ul_jRth}lt%<=q-gL5lY~qA5Hv z%_3g!F(IVCgnsRm%SVMzHcu_T+_&4!Cykm*#-?qq&$9+qZQKlin%ZSsXg;$nM%qwg zMVbX=vqof=RmYrfSR_%eJ~V+DBrTl5J+5Y-djQ_Q_nYn4$-aAQy%$5#u5nzwV$Jr# z0kpVC`?>|P7Vc?2uwQo8qoSBVeF{xQY*Kxu?y`dj3HS(sX|1ljRRSU(in35WsX*oy z@vnpgCCY=bQsi!oW4m3~?)Ao${0vbXC%QXdsmlwTWlFR+f#Km&+eDBnW z$V9#GF4i^<=2GrRV`k9yDcZ6>Le3Vu6sv}ktm{}raUXV9(8BIEA(}P{=8dUJLgJ3hU113Gw&#kYoEJMp07#Jkke z3qKZL;(vOVKX8YGxZI?;?OpFmgR6cGD;*9xzo%o3sC~|zm0)HSpUIU{o_}7I{) zt6^esa;x8yrZ7GlZuD0?^E0%s{R^VgOc9Y@5=ILGEwA{LGUo25HH3)%6+8!23#r|m z-jS=Jr));T|N74Vr6JC7cB@|__hZGcS9iH}^2@#GwT+#*fFJDp6^>~SENU|TxCcjw z+NMLKS7vj2xs?<|$eD{jfuNiX!A;UGHI&xroJ9t*0~eABd<{;Urg{wq|_^82M0wokR5Z{U~XTZdQO zbItyI7t+PAv!QWWk)BsRxt-1W<+Axp7W{XO7R>cG-sz3E4V0bjel42PWauIw5}3Zz z4}O!y$+|vrYy405(N=43p3SthsFkKQ^b;R(%&`2+-=G^Fcn&7}@d)nxhqkr9K*ui7 zle9AX(D?f5AKDoseSA{6>67=GrXj~#f0a4>*{`4bXSBsXf3zIo;YrI{*UheV%l-8i zV>pjbvIMMGc8f^-5yBC~LbrBvAZ6Wq?|E2%U+>@c?0>%HpAWQWpo@SNzAY|-Kb#g* zb3o#LDjAK4nh851d5~2FfuWnrq@#m=|4O?PbvCHJ@>+KHav8x^+l6z1S6Npmwz>YTU@RUj*Q%XR}3HQ@bQ*mUmgxjOd;Pl0Q~Ro zM>|4WAK6AsZIv`1?V0YBTFX#A_5Z_*{M8eGHb#JfeU2UtCq|x?{tUn!u^94=r+H0i zQF!nEhL3-a8y!pa3xlY-U1Yt9;>txrS_hS*i{pU+*5;oq5`RA7ueXTfZ`l`x4|J#{ z?SSPLwix2#66)WgC-lA5FM0d@b=BXEQm!+L?icvXL+a=`z4ZNjT5B_Y&4a&Dz(37= z49oF`AH#}b?8FshH7%6RwLsC#7wwz+$F*v4Y9r%$h3t2Nij4$gA9xfTIM*5-cvM1T ztgf5hxfYVkC@}eVfYkewTY=ZUy(tX6vM2Z^{neew1VO49o+qjqsnqF;Zy2) zPZQW8X5X&U3IjjTq4pe059_)AY46e980q{Ue{})o^6`T$=HmQ(ScyQ9+~BHf+irlFID)jey=D}qD8LtvT7G>day^bh*i*>0Y0QpRV2Vq^fT8jqw`T@QJ= z8WopSK$y&bHLCvVa9YFV-O0-AVw&6^qWfz9hmd#CIiB1I?S@JEa4ItMqi@tnm_)em zYy0TRzak0Ud$t1q(b!@6DIq`q`d0jIU7Fj#hD<|8^e2~A4)DGE1NT9K`e15ppmmYr zM6+y!#cnJkA56F^F`lVNftHgWpX zU2n~R3srS4A@zB8XExQ^gPHHq91W>H$ZZ;vcppm_6VT-Rl$1aM#vyTW#HSx2DN zr_ZdP7F^#-%r;ppUN+-Q6In%_*zvbka?P5-Ewy>S?yxhMIhC8Dey_g)!C)4&{?l$%$3H3sZPUN0gR0Cjg%0+F41+suURC%dB!qX zA@f8Zwex|rEhm#0xX$Z@%$Z=U%T}+h>5a0rvF=(!U)>(x5%2|z!|`p%jJtdITdMZE z<`R=RCOGTunM^joRpHj7a;n?>^WC>W7Zvs43!JY$vTU`V72BKgS+~-**(|^0PUQ}^ z^PVlvEU$Mv>y7pE)_!w!i79^b+NYJ9x}D=G!~aeo{8wRJ`u=vM?&MgloTknF|_FGoHEh>Der;T8=3j^Ep=**=cFEZ@WbI&XVvb(D!l9TeTn9 z-O6^>M9s+6{bAFhmzRs&MN|$ng*W><9vENsXA;VQ3uVgLer*pf9mW&oKI_UpQOWHw z_DdHMM)YQHlJ_!tt8l>>ICZKc(x{n&bAaE<_NsbFq$HHY0afq`jMQ443RNwGTH)Yb zIR5pgzjW3ABvAi5Efq9zSFQTO(kds+!C=7j59Ai48>Z$xLHFs)@8h~I5-}ZaNxJSO z)QK6$7_1pJ#sH6K8ViQ8&ICz!@O*OMsoOX15LI-$LazjC{sLbnl$GYoav_%nAp2qT z3&x*{UeV%Drwsi%wyN27zI%&Bbk(zVDGU7oI3Nima*TJ@`Fgi2Wu>Fik0ZUbkJV2* zWtn*i3LGA)4^1h$beqL?LM!P)>Ff)Y@AYmJH|#!%UlgDE5vNLQ`x3YNGtuj(VX&Q+ zz*nX>78^&3Wn8?^vizGv1{67NfY{&P*RFr2*&7=mERfNox!cL%9ziQJb~{nnRMI+n zax}7GI87W?U4_OzaQ(5fQdolGK3vi)YhzMupF5nHGlVZbPV?k+!rdi3lwZ6hOpSdB zY@W9a?XdqrtN%v%fQ=4eAt9HF+NLx6H**HlbW`2C#wT~>;y!eVql3F1#;Og4Bk_C zo{MV|II) zMrsWDUh(a&yRkcl11X#+3V1bQ{$oYX_S?8yP2)rOcw_?J1RUW-A717N#C)5)9qI?NL3pPI}!ModG^w}j2n?5dMmcmbJVABWt^b0~s(`@d(R5}soX;HXZha@ZC z6g2)v$2fEvCDVQ1U@eC3#_57@e&3$q!i0(oG^!Tc1}~k!A~p#fG}7`yx^F-IpI3o< zKOI8&k58&@ary7gC#wb=y?QA0z|O|l_Tdd|);6!IV?3{zV{Jq4JkHh!T|23NN#UT? z@(N-x7vmnVKjqZEN2?t{Q}`-uH0K4bo2g<{ETz`m>zb~fRgWQ3(E^bxYZsjle<0Rx zC_7J9pw$IRj6u`V@wAzdw)!&IDJ5h1{l8KAf7KlSU0ZzWO!tk?A$1iWiA5!UthaCc zuBMUaEMg-O+u^nqeFFXpm*x5NV3F7ymcLpGOZS)#cn$vKoC||(cNt>AS3gZ7bR~S& zqKca`<5Fz%{qHZ!fB1_Ms07lxa+IEdK#5j93qW$R{pMx9KJP{n7(&G-8QV;)RZoec zRQ8s97A|G6F)MHB3tKcP6*qpLOaBviD!O)Xz6O>b|BS09&G zC7)0dvcKdeav<(boR>|Ew+V*|i)uCUXHL&Z=)+sVCa7HU3TvY@d=j3uG~;qdNy-O^ z{7&MIcxE)Ce_aJI1NIvlQDv(ajg+I-@L6xZrGNKrsO!POI}>FoRevbXy^z;~`=SI! z1l7#848MW{qY~Y6BGyfi-BvGpsM}Zz3-95M!0oLOV2Kqw`eNwNjgS$xwm^LC5{WK}fWfO9+NV3|ZJytC5wo~ieO5mFa$6s*$CjmjH{E6^xl#0Av|B?MumEZM_jD>LwLtWjmnOKssl3Z-`?|E z<7V!WSA>k(WBIigz6LWFn?@HZZ2YXswu=`FU~i`Oxi8QFPxuLDC2=NN`qdX-(nlN( zw-lGt!jVe8(8Q2B?2uL-g!Y+c^!)-wi{RGlzoDFqYj3#K;uCK+E1eHG#0GHg(>$lI z-ipQtx0G4{$#H=sd6%$_GmjdmYlNDw7<^rvw((^ zd|C8t_VS?A!4&~&cO9+lDK0O~EiBEwNuJB+YtR>(i}d(uT7sUpOZ9|(_UkB3F2r@W z26E@_Sc(6YDAgmMvCWM(W?G&;tMQC+i8*+}y=u(JkZJ9n`${ebh6S@8ygP9S%m5(c zggI7G9|AA^gVE{#fV3J zS>*aFAmE$5X*DW*;6CrOOHsP%c0i1yg#qoz9q5XkbSK{3)k=R84);POuwh~IQ&!$AgShni{$_z4 z?h+tnDJ%m*)}hsb*sh$7j2)QILF~h0-%W@tQkMt2E*$_wITIppQr2NZx}fM8&oTNV z!wyM`#E?Al+u+?C!RT6t>+8j;q%*FGrafiy4-1pRAL(;T!&1zVpSfy`An}nnwZ`nj z!l-=g(D^H8%`cn}w0OIp;_!hFS>j&LSop?T*3^G%$gm86;C;wvqkkfnr+HGhuCY{e zq#_V)?F$PiDPPf!VYbEg&04dC^@&C9LrYQdow;h_eWiXU?Z;*Pe|`VlK5YwaJNBse zVJ$@lSDyf)Laf35MB%Pw@6VQfnTVu{8rIr-e#~=-{7UX$pd5FLg!Y!XEK(spY)wU* zfVuMD{124%D9o~ou?H`jD;{&nSJlc79(bcrYrXg^b_Dzc6497mL=?xHh#~K%ba2z1 zUiv+Np!=4Vs#_r#l*ke{oD_t8tse$0ZzLVoa|B+`EhHySEVN!SUPAfh%C3Nviq# zeIGucu1{IE&Ci@V{@j(D+)zgc2C}kRNJ&{|J63YJxs~6GO^9jar zHvS41lEteWLJt3w5u)kY!T~B!-RnanLR(Uq{-Zr+ulCa$V<59!iN;SKBH__I5J`wQ zJnQIIL76LIuI!BWK+d`6)<^!UL-@b&=6~ORS^!2gVabYqxIZ@XE55E$!Co#XEBx}* zDW=}@fl_!E6~*(#_VMSC$~K{HBl9>>)q2-dfBU^^1}X|UShe$Sqd!MX`J||3zldi> zP%xZfC5|_ip=o61C0|WkfE|PN1+htMii#ZNz@{Z?0)OkG5d~1OZe6H-0zoSDvQG*G zGQp?&sda+$&|a8+#Ss|K-m2*GjUDLGpL3T0(-25(CZ40#^P&Xrk15jPn+5FkR>e!( z|0sbz2Gr41alX#AO0->Sx^Db2d~}suAheEV3SJ?eD9P|7c2S)mlQ>^PF&_Q=z>-CU zTy>|Ky@*s5lSx9bhenp?YR>JYy-xUG)Q<7h9H2 zDyhrDH*dSsHa76J%V1gmMw>5>+~l!P`ia}-8X}HHVn}{W$6n91BC&9~RRJgry`i(O!9PWT$e0$REdvq)$8a4TbyCv8oE6LUr;^z0< z$XGb%Jt4ypUrPC0s!ak|&RT+$)?Yi=DtRL?o+)$gMf)h5&$0b1?vL8XTqj?%<35~Z z-H8LHBc!lg&B)4egRf36Vi+2+E)p3A+AoP$O2U(H(2-EG|0GH;U`l~WzpO$AGAj%+ za4u{)6@L=iP+(^?0Wcc@UGoA9WZ0nQG)~H=cWH;ZVNG?V)vxLUossHWmBrL)o0^Ui z5h(0=G8h_(CIU4VDTva z;^sDQ;%|mGN|bHQOotx0ePg>l$XEjK0%l<3KRF0Ns>&^6XJpmf_cevqw;qJ_;QwPkWP8+!-;hMwH*H@!|%7L-cgj zTKm;R*^=hSlR}oQ48681#qka4=a9%;-0($aFsp(EJ=k1-16?a3={p}VoZWvhy!hks zJg)$zUPErrf{FCK!D3Zi7xiJKPI|e67RJLu2wd!%)g_?m@cLL9I5#BJ8C`u&rU9(& zO|pdUhH07UIonE?r>3T290KAVt=u>RNvk6mrq@BPpfvn{$f>1+j!wmfgimTQs*khy zi!o+Ztx>Z5&Qp7{D`(4?5e?(%35&@b|4>wrA5(7n{@c@K2s~p+W%taWQjeM5ZY^DJ zA~Bh8$*KyYR4)Q`MK2<8r{yiT7g5*m8-0xg5X=+fwE|8i{1lePKeY+eEId8+7loTOC@h>6f15Udl>4%4{?=X^cz;Lf~1i3>hW zzO%>iJ8c&iB7cjrb1x4!Cf16h;6P=(=?sA};uGCP)EJ;l#3djBjdGJyAO1Ffr9S@s?UCUQt{ncB5i+c zFZ7C|oy+Gd1^sOyrSA0QS4+MYEn7;E+q$pLQF;X7>MU6_7VVre$^qkM-gGe7eaC{8piGAKE#BkewYRss zNWO>9b{MkPsB;r*dH(* zbiHP&dPs7MFzD;gx0$?P|I>w8ccP|~AM2q4PW^2j-nE?1*j#Qi--h36xK_(?J%n9K zPL#w&=KQS%V92kR7)%VD*}0@%Iv{aaXZ<{1@>LN7-ONuASOGrrg zxfNo{i??y;A94A08%9CxE2fDr;`N0y?hcoTA(ZV~?FTXMp@RVaC!|7-lE`mN?u33+ zMBkXAs2(SiA-16ES`Rl`(A)oJv0sAyD-no+qdyf(M8@ zVqF61GANq}({B;6v1xt?Uwy#wh5cQG$OGk{bY#!(N9sPLSCoBv>(+fa5bJ*AQ*&bV zAiL)_QO#X!OGM5$*It0xC?lDdfO}-^uYsD3z~?0cQIDuCzW%a$CdST$6Z9+JfbWgBg^2)Au;^Rm&@egE$GRjOZ6MPeT~Y18QL{eB)Y7*` zx_n2sI2T-C0pj17?gewsKl6SnNp2FVvoN(w(nQhvUh3QFjsp=_TjX1{o%@d0%*c{H zJpOR|xu_}kFVj(SP{#mF5Sd{?sSc+Y6h^pgPj zJkcQ$K}ma|lHf7vMrE%8Rqa|$%L296eCm8UbEUp-nsow;O>^4D=52b1@`psSon*62 zV&I~$1YQZKLCRNd_Cb>Ss}%IL^=0(EOOEwZs#R?yGR<5dVkyY1{w#;D>6vb})YT=` zqLUMo7L$!t;#H?ERW63Ew_S7BHP`Nqt_`)jl(>jC)Hf)(V%LmO=YqXfcWo2jj%S)4 z{s2&Vl?og7o2X^z6xJGSC(m;FeDaO-4Zm};xo(DC%U1A^2AwTgO)Z{3^rWA8S_cglZoC*p0?s~yV?l}NU-Sevmf4Z9z`DM zAA{QOhS1Zx($?~J$15Xv5Wsj#o;d?O19m;$GMg$-P>E$#0T{1uP| zH~U~#ZkDx(h@yRnYrs6)8$%qrMynzpXG!N(Qrm{5mm#JcOcXM1RRO~17f$Vv)HvXmf8!%wS} zh;a8kaK>`&Orj4HY6IQEu{XCZvr1l)%RA7YcXjrD##DCaArgM2tTYbRBKk)nFglCa zfw}4Q?bgxD$B6j2zR7M+`uRJoWFmKW(l*=Ex+R&v5Pu?yCxH@K6SEU(k<3sEkt1)w z$+yUz$dj0zpT$x!FgH9qAnE+_V{yd!xa8zi+SNv!fkJ#Z+*z39_4iA+){gx&7QNVaQZ!LVa)M#SafZ4&F7hr1I0G+TzYHp z+=HE4#Z=GwcDi+=c6o+5Ar>IDcoS_8?b1lz;yW!pH~VHI&m}pzEDey-VD+u7XK_tc zO|tiS--PGeNAG4A#E7*YE!=iOnU{7Kabgv}vm5M3o$Vg05Kr#BY%#KVa4vU}F;H7p zxbq|S=i}2n8&w(=`*e`7{a{s|T$^00N@w1FZY}ajD!*GtU{Y~X3W%+8$g&zKlrG^1 z#lj**r_kyvE4}B<;|1<=yHg&D`^jj=zI1`hK|Z$Dk+^|Z57~uj-TB7Ls7xLij+b{? z3{J|;y*kc8BVau}h!&m|Udc;Lz6rN4lWuf*wpqdH&>`Xr;spZEhc#&N`M46CNfV(R zt~XH@TwdrhIyFkIlg{nNJ6HCpLcSrl=zeCVbxHfAg>9=QNXxkBdj6!5y>4^<$+_4? z0|Il7LODE{d|mTvSpHW>O3HH~;E3*MtOExaFnwmOv=A@Q%I{Y+V^dabhe6qA+8a%o z)z*%Bj*M+RnJ%xoSrRjLxS>4bVXZR{&-MI#qSENyl$6lHt7kB^!0ii&4VOQqs;vrOF;lupm}TWN$s^1dqlN~D*$kB~5_pXU&A z^FsJM`pCop?5!}X?P-fUQp8*6DuPs0@VId0Yk0)?xA6#ZC4Af;9zFve@juFVc(3po z|8H3x|IuH42=MSeTjLS^)khz9|MQ8&T{!5!?u5~w@JMmLZsV>G83g~;n`9w_@W0B$ zp13wV84X!QMciG(%-O;M46$->3C)k%z*UerD(FG*@MzipT=;b0c4(}`g?)Hv<#=(2$E`}@GTez6Ay4%}od0_!DbGCMLv33Bn{u$Twor9~3 z#Qpn!5dGiJKj&%TZv8JzV8~y~!Yz>RPYWME;0fPVU@q36{{iez%Rj;Xv95ojd-i8A zvDen_7IwNXtnG26#!XFo4GcZ~X6o z|G=sDZ#V^?{C{x%ThqUR{#k^Wx&_3+&h-xwwZPUck~pxds{cEb-oL;k`2_|40{Zvb z{|=#hCB(ni{&xs9XKNfGO#e(xlK(FZ|6cc3|7U!EHviw4;h&uLk6Ik7NnU@(_kY<| z@_MpHc@G|*G@jxM8BKTmjTzF!5lz&$UD=tOgdE;xGTk>+G#Jb6JH#8rKJD+X3zY>l z&vWOG6K9XNA7$#|i?d#PpW&~t-Hbh&iD7R{N;2oraDyJ3q+5W%;7PuupPZJ+X1SxJ z`*(<_r19|o=1U=8dhi1v>?4~$9>F!%|M|oc#Z0d5&>7pi@(Jw z?Js=~{rBkb2o$MUsS=1ZX@mZkjsYUbzn>I7Q8cSGD}XGY=l1{7k-!N?_;)nG&E%ns zzqH})*Ln~CmRNWML<=IM{~P7s;|0_`%&1WKo89@dch`P8-Ta%4`9q#aB76cTMQMwu z|7G@6Ul#BEJv&IVlHuka?ca0%*57x0bW>)5NXUNfff8z~jCMTV^YLTjVg*g|2)B!T z?f8x`x9tf|h_+p2O_}!cjmIf^JYlsp^A3||T2mIC@MiUL7pDT_a`U#iuXi?Nib3T_ zv|9q$)2xPvAC*C_59$uH1f8aaLI20~DBf0>lAcD+oSL5^o7ozTMwWIg;vX@%Msl0W zDCYk@s2v@;M6i!V&(#$KRaw0jDy;q~?x)GEwAi7s;y_FB$ZE~k*S>yl$;P^=n%Hj` z?rxe(^F_Sjji^TnLWbDyvw9inuU_U0$AHjF%d9H#+~8>!?YB>OroDs4H`;BXU3-V( z7NZ?TLAtQj2*RT^i}ADE4DZJT3uH2f<&lo)$cPDY^B6^lFk(0z5IaQQzVu7=Fk7a& zm!O`-PVh?JX(O;^{NiQ)o8TDKGdaK6cWzkvD)*jtY3RK-wV_p3y78Z(^-r5*@B+?m z|MnWxK7KdEV0-8gt_Mg+d#%M4uQ-LU)8(L8ufE)#t8kc;ESl+bW4V7=c>5k%NW6d0 z$F+OGpUlQ1xXovKHbv2szekz9DT^`_3;@jl$?(r3usTN2e^ZJ?772 zkBNQqd(AmFc|Ik zGl^mRpiz8lMS%U7AV~6aRnc5|rBILm8$@*LuIX5r-HfUS79IE0^@0|qeOLrq8bg&t zV!})nM6OiJQ)oXm#YNQrj$n>CHkk1 zwO(Rg(OWVLNxE7PT1OkL`-^?qD3%Hc?N!#Rg{G2qNU%$6H>+`J7q2?V>$A;kWJK4i zHV>C1^%^#4=_kXjvzR!C&^FPt%6_D~?b+W#&Ck6N6TlqZ!PSly<&zHD3Yv!}_ZlFp z_qeHaD%{`H{|fyESJ#jew*N)DcDBc3>sp&;pp=Z_qpq-#sMQzbu_Ty~TdE7}`KT=Q znyXHY>CwyN^ZyW%0fdUI_5uqq(qGpFdo)E zV@m`Exrg|-wC%>}%f^8z(C$(JVUm$uHc^70XP==-p>=0~PkpTX@;@c|^)%^#Yd<~8$B;ddk{pEaOV_X*t0)*x*aV`c+md?ic^_g! zx)jZZ4)7%@yYwZ3&Ags<(e9hptE*X2-ysReCUZ=G#lTB0|DQwe$z@2psL(6Q`^orq zlW8qCn`rB+n4E)>4BcJgN895dv#oRvaV(emd`e?mu@)G&SGmW;z7hg}J~9>VF0#HJ z@f4D#^qp-EdI9@p9-~^>8oN1e3gr%^;pCj9FP;{?wrl=1NXjX<&A2&`L$o49sM`cI zL!(5O&;fk9oM~3{yC;4SES#6P>*8|pcwgAS=sU1TOFtZ}sTv+O;FkC5%k?NGCJlq`h?V3OraB57dESrSX=%NGDK4XNo<4;DPmVh<;9rQ%s@|34GLK^ge z67b|+swgG3d|GD!H!_b}8_`kX#giAlQWuN77Fu)(N6%!I>}~JaYwN&0&nQbY^luhv z$lok7=pZVc2|_0=^7|giCkeV$+Kt=PRNCaaB2#_LjgPE%VC66YmT)8Xsor{RlsBoG zL{+A1+Qo{l!;kfxY}c5*J-^SNq&NjNFnO)QU7_sZkZu=5W6qm< zt2JZW!Hr_USA$09dfR2FC@IdhCgML6O3%|YVjZxo5`sOLth zcy)ufAULkL=IMga7f;Ki?IHuRmYbSSP{H%Ix*E-&fIQrtNoE-B_vi1!T+W^}-zTRURYFo0)}~Nhr9gCfYC; z+YEj#`~kLIhsB=yh9qHEgh3(UVT(Lc7rZO@Yc?K}=6g(L%pW{yHhYRX4fc!QCRc`$ zG?MtW8j3NQXl>PH)Y$riaLQzFb%&S@GiHAby8J%Lq9M0cS5^}%MENjPE>Qfu)sQPi zd8bcrP4TW|B zE!%~PtZ(g)pS&8DD5?wvF0x7P?mjOn4DY@%DqUqHn6WdG7s8{~sA^KOD8+6$UCK`h zbfd`kwQZ4Yo=tZMYqsh??QH7P9&m^imGpsIQ4QG}7NdK~O#o3$9J(a|%c+e=dYBo^ z&fuZ%GvBx>0rYwx9jaQGb6$&37n=gH?pra!KlB7NmtPus9%FLG1evCIM5yRC%gJik}&q5=X{UMrQ-odn`p4%&`KZns~ToUw+>h;bxVikx%Ba&6fh zv}XhN#v-_jF{{7cFo-6!;`Hkp_IP1>6mvKGNW|t3&l_Wa12+k#xCbC7sp)bDzFC+t zTj{mv_U8mnNPCwwS>J8N2A1% z%eB>XqYY`;{?@a!kbUm72Ca5^r)_~^GcKf}721-6W-A2Gap zpU3cF_-!Of+QRm zQ=S$#Q!uOz9%oNDNQq+nFnp*tA=}4ofR-Us(K>i^;y8WtJCohhaHPon9oR7Gs7W%b zienHKHh6e6R?ZX_K~g)K3tu$6?1dFcZS)T~!wm?fQo_m7EhL)iM)DimlREvp z6yK}|{b5EMYe$x)Cdb3`jqi5@&?MD!3eHWFfm+t9@IIVwy zg!#@u^f`~_G-vW|H;(d9TyZA+_L%DAAyTy3zZnJQ*sQQpF=kc89Q>MV@6@`*w4Lu= z&;+NcE&_-v%|D3oF>`CJ9dlt486*m;8WBix4Qv#mNK|9Ffi#Xs&BTvBS!MD0hW*f8 zQ5mQ^>1vF^ztj&9nQ1Wrfn#GRowlJ4CEYuN@Z&TgdIcy)62LdHo4{kOC`WtXxnFZ> zk~)wpzG|#^25QX_3zf;0LtU=2ANI-iH~l!vTKm2oMM61v*)|M3Lhys)s0cy64_7O(w~CT4QdMxenm*s#3|ux?ISu&1gl;xALV2|# zaY*p;bw?WCEJVIB-)kwF#Cg(Gl-|6-xSHPjX?m5_QA}mXD~Uj3!K4&pbTnvt)q@!p zX2ENBDOJwQh<^IcrxecAU}=um%d zm|&1(v;29bT@g@;9AZ3yitkuCEtZlVz|49N_hpcDC#L{tf6z#w%GCS}%q&n77$-n~ zLR9@2#l2?bt=29Hv!VK?d2BMUq^(1|&rMYz6e#ef^i+RARM_{)oLiswusV{c@qjr) zN~kP^B#z~2faUq1j39fmmwNSe_N5f#&jul_6GTOa#<^{|4p@{&i7+e(NxkW#DZqTF zCINQeOowgqPhe2;QHQYn(_~0`wfK5xuT%M@iF6~&rr3@Nw|go2H_>oT?#wwva0}`B zaL=a#Cupa&<3C<0Xd|%#hXdBc@Z1`DLImIh;oE4QvfC%wjDF1>X)5JjnP=pr4#c32 zPxM&cPsw$TR)mmDTu^GgGO5I9gJ14h6Oev4 z@ExiT!`+kYFwnB~An9njY8RR2swKth_>8?ZRcUL|9Q}u3p}fRz>HBRLwsRz)^qF_e z(Y}u8Hhju(S4k%7o~`G~QsN~Cm6fZ6a|xHjgBcEaVBhMSr zRe_HBI)KW=HRdWA>6P>`&c;718%2-W-iH;-`S_oIm=G4fmUo)1ci)p|1F}Ru%#>5< zB9R9Iv%Uy~PXbd&hGqrno%IFlJTUb@h-H%1Q-u6^QcQX71hYf(e7V52JPIJ8TtqoD zW$hjd_1%gKiJkaIYweq=HWvi`Vy@*A^}%b^(ibM^bEl z8Uvij5?WHJVt^Ub=kN8Q)N`e~voUAzF2uw5)BHKtPqqnPa4d7|$>-JM-3i0GYzC|Q z<(tu=4>`29jvAVcV?5IL^f?c&a<~NDdZZa3vKfp7Ky@@u^wOgsOKx|DGq;X-1sQGf z#+}BqdRB*LVie&bEIU*72ZuMPyDB^ThkKjs#rHe%hon_@5>+N?76>Yt`z9+8!H2Um zZK)7V_JViJP?Cf>rcbE!G<)K;_fuB>`7`BuZKH~pu+Y!2WH~ERc$Ik1gWkHAZF2p$ zQgbbSjLl@~1waS4w6|x{DRPobl2v}gj)xa5TootSN|j!vS{8SgRU!IhlG33*bvm0h zdwbM`oteW&46N0izlK~fP%)(@bf~Wmp9Gsr^Rg1UM5lF1l@Wbc1CFja-G2=JUN#Rk(_r%G<>J*;Xb`hnvidT6yUMJbdCa9uBG>8zHCPo7FmexTa4M zKV9+gN8Sx+k|d|DZ1Oe=2Hlv*(d%p49jHN0+ayt9 ziLe3nnC5y(_dOW~`~-WSv)G;$gqIZc1g)^uKM0buq0eGbe(CX`Hu2HY`n-C7rjR%c9K*ZU!3t9eY z%iGbLdfGA%!&7VX8c+DZfnFH}Uu)`DJFjL>z<7hwJBW|^YWmstfjAMpSR-rERgA5i) z0I%xC@%h{R^|!o@8;HNS`smMnyJxm}d(a^Wo3>;rVa##VG&uWm^LW8SWyGg%mISR^ z08MQnF|@rW^+f@<`QrDR)ed~PhE5X0uh@L5R)1{F{O^R+J@#H#qJ^9ARJz9lq#iS* z{pAFzzQV5v?I)|3nzwrqn_EuPQjBt!2W>DYqx;G&QKJ_Pyp_(PZj$Q<^gW|G;H3oN}}B{b}yY1_9O3GLSo29+G` zmZ8fVm#d^2o5d={HrL#ec29hNdo=@dVPjfc8qdMv!d157;$?+3ZAQ_Pi0fDeFmvsV z+eX^=kx7pD1y2EbISt0*cqQ~7!AW_O@*$}D*nG2?%J|hwSHxXcfL!YX=tLPipVAOq zsiR2ZW(L5Z)7sAGlEqYKzctAAp&=`B8>rm558)oXYd^i-a-(+nk2P0-6TX(L$RtIJ z(&6LeUxO0Gm0|lnP1uL%il$kwB&$>1tt?|qQ0}ZGbNMXlJt}ABsDY-`R)uSNrlzw5 zK#zS~d-(eE6EfBDn55cM`dabm?imh>}?Y5=443<+4l(<)=g)X&JEFS zbB0j?qi>@~uE%P=6$-wB0patRlt&=!(yPDwPukU(O*#1&PS!d7jbZE7pmCNjJ`E^Lg zFPi1oF%mkR?lzJ6`-n=H_%ilkmx5L816`}2J4;hkKV)5N506WxDl9^t*}iwgph{!B zRH)$cVRo&RpX|{$2nupQ^m1J5_Y83kNbA&2=(%>x*Ls^p`1Ee^hNrlfVx_jG*z|5K zItUAVgI2DZ`bwf-#og>^(fpE%`%Ndubi)r72s9NlrrP+jN(mSvk+4xQLbS+v*7&$^ zRN1I(qNlLBv7RDLG~PB5Lfsfb9e(GND0^Hpwh+Ua&kHg8&Ai~ z%O+&%Rt^z8N+{v@`OjfROq9)=#~V6KmYW+7xb^(R(xz?a0CDA&&K(O3CqJ%>&V{CA z8-=nrSh+DoMYK{?&!T|TCo6@;u93tnEEvJveY)QIb2~Mb6`&2 z4grt2+~cMId(y-mi3vBalw}Nl$@+!iLf_+b7?vhn@IS zXVjqr0o6E{@zGi1A*|`!R9Ckx;rpM+!=L}KB~0XT{vKqg_U0@Ey#<@kg|r@=i$J`8 zgxzA8p2a`}u@A*H#%%R|t#5AE*92iDZ7k(K8>B<@o3KE1#qC2$2CO?OY2#G0X{z(= zD$6j6__`e{xYWb*EXU)T_paY1t(9%zobx6ew3ES^1ORi$jPFldlPZ3I{snP!A@QBL<* z^^}ZXu254;kN7A_QhLBi=QWwG}r>&+OXrR;?rm3cfPIx9msRTWY6g~SHO8Br z3zLVFGe1^()+ywS3bpBm}M zMKvj;Ke)|@F6_jauq#q|sIHkIdEhI(Hbx)3i^#Q;ogwPNU=n|qbf8Ga1(aVU6EJD9 zI>4f%I}ze}uhiQ#*F_*8M-~&yZ=|&q&!+;4nHe=5A&+?XiGoomk#%~w!WOM+t6W#i z^|h_*5iMO!0SWSAe_cvD#S}D$E*=Nyz%=_9AP&tF=m`+4&COTO+QQ;Ucp~0YxoY*t z+{;gVWewybP6J!5`EVL5xJ?BO(4b(BE(Fq$f3M{=$<}t}-Xm(*(6{37shVLI6OU%S z?drU-vX0#0aw58J%9xz8sFb^YTrem{@`%FPnr6z+&76Ri}#QzU!iy0sOoS zO{;d{CKZTFWvX!i66~`nR*NDJhx72I*hbN&UfzMIPyosef4onKGLbJ&W1RH)#fvsa z{BfQ_?+vC&IU$R+FPuJ9TK75ODD5 z4beJn>sqakBki6n)pX{hoQL~ve(;!%731OYX#>GwcvgMHNABHO)OX&g{wm#>a~^TS z6wTSZ@Ajm_SFz~=-6N`ix_*u&*Y>+j2!?DchxclKBCb6N*O?$2%N6X(*wFT3(i3Rvc_Dtmo0YF$FY4pCRSVM?MVwNY)r@M$?%KrngqZM_ zU%XGqSrjqazLq&LJSsyNck;xf()K{6%z#7t@H+c)H-b@N<-(3E^>XsE$n<>#q*zV` zIN^SR=HXS5Pht8A89VhUr7ZBODSF^`Gu14NI!{j-7hqs{(uhlzd_oD5`VHhe1!Ty; zSjI|C94x+XPBeF3|E`7XK|BhD48ki}tUK77PGZ-!{pA>~-pX9wkIR!Xdc@u5?x}xp zCh6@5O7(5|Nu*HC-R7r~P&jF{DlSzTS)_`jpHd~f=6QBFZcWmgF^DLZO68+xpFoGA zgfR6k4yO(H=pn-GK5{EK&P^6eA)Mp{W*@`pmtrf-s{|g7_F$Uw@Ht*W(C1$G9OARs zSVE6Qj91W@|2AbVxg4)2Z*`+2#Y9F%@VEU|x zo4{UUt)v@f=HXw{77K)e(bKPgZ0EugST2!H=W{W(Q*^wSv zXY%iBuoG5ntB!CibqX=a&WbL)Pg&?2;u815m5=q*!b)46HJFCUK}n!`IyjtgB+H&D zCLl&wJGzG3bfs2FvC|6|OLFzv*m{t<>6*O1Pn`fPdrEv@T2;B42u@x;S~v{Lu>;Ku z+UMOYg^DU{fi*an+UitM%WHR~Sf)K36^`aFne=6t>+0Y@Fs!CJ1g=3SfvqVu(^1^n zbpkh{S}?u5jPY@`U|Z0{dZ1rpz9KGuy}ZWF&^6J=Ju%9L=}x8kk>ZLOQ%uiG6P&@{ zQcUL-&BrOS!L+5JQoj1llA1WYelJa z&2H{9MTtC@_C^lYJ z@m3l4=WtA{7&cS{+%x9e7T=&92|gNM-K+jE1(g~Ow|gybEHr1jd3{LlV4PXKG4w`# zbpgzj;88aqM){lX=kBx-=MUwD*1mZ2c~+ltd|GV2xt&RUHPb)Z((N}N)sA^8gDSuU z54_HkXMXokgvnI`Qago>vm84+jcZ#vJi)8AixajC`g1b17_2DA>pZhC@I-m1z7c!E z+oCC<4rjF?8mrfDO4*RxC1aM!p-PQ)CH-l+r8dk6pON~|Q)TVaSxBlJ;SiL}jWP(` zl8d0Mwevb}hyY_(bZ0h~zfs} zSV;`NPkjdIPw-UXz7=S4lDDz=n@-J|SXn&T&*hKzr&nGXriQZt5usXnMxr*~I<^Cy zy~xMxC+r*EPlK7wL*knAlAuvFMuB4F$~eoT7OvJv6(ShX++t-coC24$OoG$9B}w~} zXiO7?4D#@)Pc0+jy9d|;mpf9x@xgI>?|q_Hl$H*{46wRMb`9I3eeUWLGy=tTQ7_;> z>Uks_RuX$fH`}#Pc{1iq22TdrwQ#}#inHqpl}Ur_YM=s}_jE`f+wpVe< z1&OSlPPX1cmhpu%9oraUHI>>12|G*Q{GAN4!n_76RI(UJPQ0+Pblu0}!4JkiT$)sv zUJmpFH$IT&=>{xkF~-H9dLyn^6${G`Q=6OfKI!p$Cn4MmPXWi)rhs3%>CQEitdKH$ z@@mD5Q7V^LXagJd{HPXce|oA7ZFC;za_elqt4lf_3Q`Z|fmw>YZ~-PC)z7Dz+4kmQ|B*Aq>M!j=K&r_K5M$7P;4dvhJ799x z@N|fWyDR5(*S_>Y^z5jauUC#4D?p#RR@$%%p@2P->K|uyyV1Qy@_S*iu*WCdZqa29 zT0?n7#0Zzcl1Z)LZKLdh$phkWQ^oj)2i%2+!m5OXuc5kJ@YlwG7Inu-pQL#Rk`Hs} z9gHpVy1RT$%~i)TYOF1uUOu{t&91S&b|-#L8pYLg+LOlSFU@va6}z;Z*Of;)e`!-1Rw^Cc2%giA(rk1h#j4iyQeH6|Q?+VG$x;-)B1$+d zzJyz3x9Cf4sP;Z{K=$D@`zZr~+gj)VGQ#=JyM;1+j$&_ykCx~eYklrqSS ziF!b6ZT`CA?7|X{vA~?KFnakcZpvab*Qmm^les|evFy5bUnB`GamPmqnXJck1>53| zzS$|bOi>r{`*xI9xBrzWBK?)>k^ZBj_o;uMxhc)shs!C=nZP%NTxDAQtM2+|9NO;M0a^D^9%?I~0Kk<$}N6ktD9(>+#VARBL3?`-i{JzGIRg63*1gY&oUlHZxI ze03ILN=7UrO?haU?#q=W9}#_sK`8~2P-H{UnCv%M^2Db56Y@k~%}}9P+p*8lg3KUDPnVhF{uMr4R>yB-**>h`*(Q$)w{2q+(p=s0cj{U4 z6y3eO2cs9#wj=1ZJe+HQ4ZqPS^97j)dIq~x_TwgbKAMo=+8sauGPN{rt|NkSqClAB zMl7}3GyZI2?mAFi#fOrgJUo=tSDitqMCOTx8f<%VloEQ5(W#7wGTl9e_p5DD@sqAL zN;q%fa(0YruqXXT`#gn>@xCdvtcktQ4a0J_w=cty>#}W3* zcc(|2d&3b@J!(>y4_nX9DvJ&4hxCmwW1UKQs;SZSHt}J21d_xy}Yn>g@`~m#{JihKKpoKU(L1j zGvm5eSRi`Nu~Ki(CzzZ5@V7C6p+J*)6NZ1k#`OA?Y(bhS{gwU!Tp{YddbN#~DBRVS zUSw6pV`H~dA_6T1Qm#1OM|-JS5?l%VuV;1l`kiEH!zVx6>D(gs`RZ8f#IXAt6P1v; zeUiUbm3RR!$nQ)_!=AgY4h#<4juytH4c}K8Z*VQ*F=;V`ZqG6mSoaRQZBDXtYZpi2 z`2S~9s5!C@<+59EGs=Zyr)nG)&ZMGX>xlvpd47A{_n?Y%VeoWf2D`j*Xfv`eMffEc zmPvaglrtpbWSP z4)+3r^-y+DmHm|VWR*3y7|Gy!V)CVP>@2z7(wmn|?3SrMP@E|9KANz6im>x@T=SlB zRWH%6o?rLxXP~+#c_vFQ42ALNRlJDjHQ+jVViW=;*M}Q=6c{zej{;YBJJVS^ncQG^ zc$2BoqZ!KsY_i2QC;ihN5XhYPTrW&i_T<@lRb`K--;pS~E9xaPz4~^sLI?le;^AiP zT+d4uidw@%Hdks00Oo_7aNev=O--Rgoo-ImA`%|F?Acyv(rs`F;?Zl+)D|IVqv6tw zY&cw3G=glUI-Ssi&e6a1(@_^jkWC;?uGUoFkm#5EJ?{fC4v$`5@p?;|j^hD#l@wqM z?aS)OqSAW#U^=ynoQI<~k=q~BF4KLE3cMxXeK?%K1re3tPC2jQvRyHY%hUVFxZ$wY z^aYFbUe}We_`HyBjet9IZGQ~MIY8?Cpt9cg%*K^`Gw$uqxJ- zNoA=%auuVI*?Vvz20E~!7xslI!=AKxV-tdBIX5y)m(XiDG&ONT+blvl#2)%iy|+%Zt;}s``Ou zuO5`KpOLEMacmi4;PYl_^HE_F&@SvQC$*ccHTDJAo5br$>fCXfB4K8u&fD6Dg|D*NhI14Cxv=W_#El}RY>t#V(> z+qZK2f}L|KJy>;$pkWl|lCDTX60 zsmduryt&oe0!GBp?J*T&kK>##xJ==;bU#%f#+DEFUzvieeSU#@)JSLGbjI$+TlJ>^ z6B!YEDW`=O^^@L~f-z6VqFFGZt_IC3!|)rx#zW5-dq#UIq;1l%5AGdlo%@*TqB$hW zm|IP9*IiRaWQL?!o{~xIC()u{tw^9CO^F5;#)|_zGU7}9x zYdWyYwGXIc5YYMoARDH8E2xU@@(;|t-S`MA;`eI3!8mUA_Y3q^xNMnYX_^j_#3I7DTQ!Hz??V7-9 zy&IxW*g)p%HI=MnSUUQ-CL@EHnS_H_vXhG6rO&%irLH8EN)kR{srx||-h(({-}&)d z@4pn=zm>A1q`y-9l(;;DJRN>utK4ZiwCjrV`63`Mv0Isxax=l9>z9{iLvIM3 zSax1e{+5#bh zSt=k28%4Wj7c-bAb)8%4?MS`w!PDVRWkI8ZV_8_8X8wax8>>snwqErotvo_EyVb^R zY%B1?_u_MOsQCh334UFw24@u9|I+EC^_Q5n19{+i>G~fwawO?A9pQuF8_BRE+RcX< z?2hof3SIBnIL*yZGOv7vzR>onSfborDvootYp}CZS`)?E+okJIRB-cjGyvC8#JNRK zx*Sg-5R(TV7BxpET+;o=4M2&n2%uQ6Y_75L-YHwZzOp*I(I$aR(3Qq^oPp_eaXGCl zk-VrsBZJ#l7a8#Wfi+~q`aQXaXLNhGHq~Jx62T*kcSb|xQQ>gC6f6tVE`Ps_f@_SR zB#CvLc4_@AQ^^6P>q!<(5dkHD7DmX&FlZlU2| zft!ULagbX-a$hChjuX1%1+!xNJ9ge`V4_IWnt`%1H_N$S-*t8~fNUq3S9AIE)IMU@ zqGUYndJfXyXuQ>#y3JKL8D^IzIDgz29OA=OJKn3CcQ;WqhnUIC7ibILOmiS38-!(> zAPqmiM(Q@l!pFOB@Iv512pMw2+D}xn%l~6*{BLrUi`9C1=$i}CNpW~Kdk2X}bpHoC zW1!BHd_S^s?t-k5Q0T(7Rym}njxd7p&dO6qc60{I+I)NW%-dkQ&suJJh*89q>ZtPU z@J(}l8Gbu_j@?R~txz52Wa#~kj_~YcBT!RXC9sEdF#h!O&BEbz{@2!KPWk(w;%FPa zdAi_;&{glH@2S>H3#wCvAIIg7xAcwX%HA(v(<1_~C*tcw;NSwJd@=FuKv`UX>5Do8 zV2RDkb#_t27tS8_s0U zSARmV$LAE6NAn%BT}eO%WaU{)btFz(GS7SG(wi2(D0u9eh*>zNIl%9dv93qda^K>! zo^=m4jyly_&?TQf1UO&((+fb>5DR5KQ8@FXk98k8Qcs<7L&SG!tjW30%Jk5#pGO+( zRGnZ)SQF}=rKY9j<#KND-Ua@_u9SSv)z0Y9R;xI!!)HahG`H#|mp$=0*}|}e2SKb2 z&ILzr6`C%Z)!;`8jQedFqTYA+C9y{STQvp@3 z8B{yLBLx)Ysj93RS)dH4-1ip-g1f+}WOx3s35SGLjkqafI@Z*l zWiPtS44LK67$D0ZNu|)P&!R-9*tdn#*6AJUhRp0~fWIYA)TJ1?eClGdLwDAzi!wK@@ zbP>3ezzB@yeHgi1DU``#U@z(1{VZ7Vg z3+_`j127YXYlC;0LugLAd%v4mkf%;_ey=C{n8`fhLuqR;c4Nu&Xn;;LU6r{&0F!D% zx4;53NZ=59Ny>80j}cNIs;-T7TI&AFi`_@Ga+)pd=<;i{OFU+%4t1Z5llO0|)|+?_ zm82I8+s{0(9&74^t9EsrFIAu~kU8}|mpu!~3Y-~o`zm|ti}C{x!#cOp!@tQtMDFg| zTj6#OcWMYe#&-diG*4~6ovG7&IbCgqmk1Th^v-b$^>%=(RNs)feoFcv&$rrsdIf?J z0%t|`14U9okx{`Ouj|(}+|aP}xx7K+MryU0P4ZpbmK3<-G_(v9kk~kuyccaSPBRwz z=uHBbKh}|`5kP=s3d+nYElzdVh;W~hN_xS~*YahrGE&wF?+<3Clv!Fa>c!(!mug-$ zS0h?~5V*ErAjNV(oTSiS&XWjEV0t6?DV<)>0;r%;a9`nDuysYC&mC~hoArlIHO$wJ zL(4v6;0&9v6>8RCGD7y-{j_Pnb1y;j@=>voib1^>iKsJHglh}yNf-#Dm0$F9>6p&G z)D`vy*RO+Z0h)orE78B*uqxcku(nh@?1&?&YOQ&OjA?C0)}2r1=R<7cA@=h%ani(z zFD>S-7rSw&Kw}BR5sjZxpnSBH+##oWwV(pPG)XNMroTfE+C*!ntPf|pOQUcP2W}mF+flAhiSX1P4|_eZ}MN2PL?f8A#lz z&UXi;#+u_2r4)xnc60F@RBadpR^XXa=f?D@NA2*_KVvlP``u$oOT|swL5_zoF#?J; z_>&;BNTBZZ2H(mN#{$=sP3U4?2Y{ye#Y?0}r)ux!YJ1xlL0xA{Up4nt6qP?NZqnT1 z7s)XwG$}F76+MuK&>ptt)et!@s&}VleqAcp5rup2N-e!Y)XJnRE_wed&wZeC;{hV3i#@ZEvj9) z%*?ZCUD+%PmT8(NB^2$^!BS!FQ!?c1eeIe^S!-MU9YlHT!hMnER}zkxZ`jc<+-V_ul zb|0*$WzMg~IL;mtSW2OX%`d;osA%>#PoF2lCf`j)H8ZR=3a5^gIo`!x$MeQ zrA2ol3e*?6My{BIIZ@G3+XX@X7K_qb5miclr+^}QtR2&>oAk{#xsUM$FfkNeZMW=?;inF zi?^(EZ3%p;kRk)Sn?Aa5^>?#P!xM+0fOND$Rnsl(3C2@-Z$J+e3{h z0aUoTqCOzvp_GyOU~}-9V97hpvBj3itjUKS5XL)PFoc>Ir@u@?dp0F) z`aU}6X^gWBn4I#{1ekz@p%g(?XpMdacGeGmTE{_Kg)iraWLN$W+@9ZXC|axyhe2eXMYby+H6C_9^>b){YDoE&OSM3BY}IGyJxi z^Zm`eYS*B&gC@n}{tx)o#xT`xou?tBOR}=Efe8#65XYDSF^pmJF6`7)3AMLfDzWQ! z^xFVRsMzCAx3N`fo6pCnzoJwx`uiC|qR)F&IogX>s;$r7Kxiwe|krxHVJ|5mWSk*lYab!g>0f z`$Vx;=9Dw4HfC_H#9+a z?C}RGe#@4rwq$8(VVlpGaMKW$lM=2m{?cp1UD{)R9 zNB&E5zy+zAMf4^%2Q`UiX<4aca?kyEo`HV+PGk@O3;mi;@t_aEh799aICYI8a;({J z2svP=cgmjN8s8gYK|aW`(6uGsj`C)Vl+*gM&pd7`bV8*p>-Jl_rf7Zy)2^s*=48E4 z^ycyyZ^Nk~ay=+ZZqSTmFW39%X9Vw30zkaL_KEvXUkH671e%jYS&7F!kRxSIq5z=6 z2Rz;{Dsj!~h`v+r4j0?Y^wFzs16OP^gGa(v(q!!HiRzxW|J`W?0POO+UdS9N z@IUj{{M7aP*g`9`C-{{&88bsRYFKTPNGL3i_0NN-3*m9f&TpMl?c>{k%JGWd#CUkO zPV-vgUB?9=b$5miWN@;XM_l z=&Rszgmt~jc{-3(U#xWMf_f0&YwsooBiq&=77$T4jgWSyP_B?@#+4xxW{r;>xL~D7 zFVasNP3T%|?b=i#ow#iHjeYxqFpscQm~8f9oq3h>2fvsL}^Xx6E_Y-zG*XS{*V~JSa7D+Mbo>jg6_+ z!99?4aIG)(UeM1KzUjETLF8J$f{u4{XrzOaz5Sh!w_?DgV2EWAZ#MZ2w5$fk@kHOP zg%qjWs+G{2%ZL%8b-X#*u8zJL@lEBWsf-L%%`AQV0Dbg5xldzd{J1U0zl@($Zi5R$ zc-%`Czr+XjFK5X8Yc?#ySNRKI`1#2Tam#IQ`vEB-z?ePMFYbB~h%neGFvI?8?gXa} z8A<|}(KWZ}`^0@~62wyT3&@7mH*SgP@^1H{PR(%SiTs8CdHVvC!aZ_tb%}AAMgK{U zZ?Cpq*t_^o`M>Tfp=F&UB3YHDvv`1*L)v$|>*;L>Rjs8r2~d%A0(0sK(M>fV1vugM z_6-WZd^g9KVGbGrOpC1U!6YXf?}6qkyF7 zBl#A32`G9Jr`^|`7&T%e(6M65urCy1#~J;UjA)OFY-2g*NfEy;K2$9Dp0M* zXQ{(#4r;Dr~5OkCNI;S2yA_4ZQRnDz<6a#|qrpDGUKNmuN%;Kmq_U>@C zTWWvk54GcO)7P8ryCUcDd#7#?qd@t3-rcR9AGN={J}%bMQ)U6+4;k9iAGW&{m!e=)(Dqs^>mpa$ z*vFk?np>ITK!U5#Y@1P1X$?bQXH|Oa(Yc^9cb_!^ zq&P>E$KT!ZAx>p^W7mqr{leWo_h26mq=y0^ zeoor%MxExqpcQH=6_1^1gZobv6|~JY3rnYmPM8j)dw!O4lN{+?9I$lwM!WE>WUa{o z&<@f>@4Tbv><&-=@G-n631GO}dA#>^nN`@|Ei%HQoKy6?d!`l1tgcI56!{D6Wlbk`P?A82%QZt@g%|`Qf@HuxR z9@hARuT407{T$#}h@j;x+~!`Tp>tQQUU62(18|v4eRZasM2p6w&jR?VVNN=K>7Y+o z^AN#Dbq%+i;Ow3BhdIFJSGfpJ_qSI{v}~ho=Gx$Pb$b~qfQdSEWhD`TA6qv#6fcKV z9qcZiz6F`IOLPkaX4RGc7&CFm%oC$5$GB>>HtGUUjExMv?@r8HGd;J3qk>K3UG3YI zX;}h`A#{}+DoU9FBP-YDG96^@c-772znkccgEUoruyw?ASrHewj=UCM@saul3w`52 z!9rM4sbFZ(mCAh`M)kLN_?4XBPA+@@2Nt*`{ORuVM~GD}M(ihS88Z*&Knb8us37)G z?cf*)b%R&q?I}r141TPjVz4ovyL>YG1N3%usi$50 zX#bL;6NHj5yoe&?yrMrYtE9)W09c)4g;LjUPIM)&2CmulA%S&#;Pr5qqY`~QQAf6T zJq4g;+IGmkw5sEO7)DGjh5|TBxse#%6JycG9x?#b8?X3fXJqIkw2j_QDEKyzm~}Pi z5N_>u3SCvZz!@fdm6cE!o*!=N+OFJKiVRM zFZ8VK+54N)AJEVA-uO&8J`plx$~@n}JQrAGn3pq>L-JyY0$`jKEn|t6?Wl)(y^~N; zQQSfj9nB-d1jD`^&EXxqa8l3>0bW}&J;F*f&`1U%Qz!j<^cvy1#^(5OWdP8)G#ut~ zYFHBNl^oFd?ooA_ryvb0g$;Qzk65%Q8oO4TY?!x;!7Fda4M?fM+n^PC;vB>k0-T#v z03ojwYKWAZEezankMr?Vz*0g9#L#HElOeY=?~fDxL26Sa3*4#`uha z>v(N$M~7ifQ1A{(GJ#bi=r^>8lhuc^43wU*=_vai|nYkY`xdZOG{24$_8ox48 z*X?HqoKf4VhZ08Lfa8^~zP8Wa9T<)du`!kr{2vlkSpG=xbb0f{Kv%Y=jbmpf;JGfH z2v>#PGflq2cz70K990;1Bdk<5;P(8_m=QL=S^(D!@9r&tpkjSO~xJI zUfYH6vCc^A@B`ua{m|0|J~2Vae&}+U31?9k4B)EG_Q+bfW?2b z8(rZ~#X}tbLpuI1-P{^X?e2NuTU$Ay1KO=8Lhfl|~&Hv9|992vxtcKSh~$w)xMVdjaB471NF`mt)sG1gAIGGMCx4BdW1airOXnoKTv9J zw`tlwLx$ystjrJCn3|?6ra$Tbw13YL@BP4bzYS##Q43pxGUC(t4O0uZQPmt2ocg-} z{>Sev^NAdxQQoBdY4!j>b@|j^Q`k#9zb`WXCn&W(qjx-2q8Q8lz8v|Vi|jM7b(FSD ztN!2q+J8Uof2QmobNt_V``=Le=Y;$>LjD^e|2_l%jgY_1z~6rP-!tUz6Y~F~GepaW zBFX;nvw=yn->4R}=in;m2dt|KjRS4pCb@?iT7yrpMD;WOB3gwe%Qs(ZK6~ND%fpEr z3d{fys~jys04)GA>mTC)IwAs`8vI~wNycyDklRo}c9_5&w_L(CHh`QvmDChQ_~>L% zyEKHa@E|E{XT=U3lRZnhPl$ln>tcQIVoFCx$CKc-uLv+UmsQ!-1}FuY#ZS!0BWs-s z{*&F;-e>*4ilQ~??dgH3ToS3Q`6)pOI>MN@O^u|aLj>W5vyCbUK%59GtW{91{(3UH^>pd%RAWof`*H^eX;?BWm=UNrnw>_m9Nz80a->1#bADJi(k)%HF4FIQ}y=(cYoKF19- zF4;9~S%?FVe)}t+n(>MxaFK^;jN7I^>*oby3k2Ox?vetA2e)ja;?y5Hn~7aQ_C%&;{PgHIn}a!tCQWO zDHaaO_6#k)|KXXM8;Z`Mh_wcMyp^H#^CsQD^-^u+p)+##O%YaUYiWvqPDz2a6nI5) zlc_E>ta_KkT>13wPNIEg=!YaFaj}{zFWTXFK~g>EO50_gGjeY;yy)qw#Hfw6Eln6_ zr?5%6g7-4dfjdWYxqFiiAKd?!mCgz7SJvN0j^4atyy1T0yPNd;Cnu!~_dmRBt*xzf zbNb*x@q>Q>LOA-XlP!v@i!^Vjp8_cX^kX9q_o)D%7NkE2p}_gg(Ne3X0o1nSRA4%^ zN8lPaq^^=5nid)McUIo&OrM{8%^o!U5pqLRC9zfz-Rhjino2VA!QhhkYFZJl=E z(DwQauT9O`84M#2*lkcSpp~Y!7GyzbXfKMC?kQ;~hwvJp?pnURKF*XOdrjA*Ql~4o zB!|=s@(uIuwbpY+#02qb1pL+_EnCKQ{Elj;=AY-WcDw7qsz_k9&DH>SF>*xXj?9$s zX(TAZH_x-qyschFr}tiJYd!PhceA~;=+X979PJcN~ zPNd*27H5SEt7kzmpIiuzzHOHa#vcOYN2tq4&I_!SB?Uqs>W2r20@5W34^q~2N88I7 z0kRAHw7hGseL6xz9vuFaCue0f4*=F{qiE9aSo*R+PtPAlaKz$$_r70ME#LK>B`!(T ztGr*}hX~whWvtz^C@=NAxZAWz`J-f`P~fcgA-Q(7Mt+2fsjELprr+GA4?qAS~ZJxz&``f80AFY#~BKS!~VAPRJp5@xqz-gDRBux z@6DT82aw!YRYA1}0IAm6(();aSOylTOAq>E5xGeIR4(Q3=}?4AIID~DN)Oa zErsN6e|^LCpn#t2e7Cz|OfuGNAl+BFsJ$N!ze_PATN8kpyos%iZu+Wti0*75dx_A@-mQEvGvF`$Lws`Zf%uY<8>mX0tPQa<6Pf zlMi7rGDSnK&(ZhJtCR@b`h1j9qdZv|of6cTwuOQzKCO9lV&YGg<5Lj;%g`24DIu$k zY<=F{%S=b)NDkyTuwwzDDl5~!_pXMe@WWh5JVHUTUBPR}o-hC8-%3cry}DoT{)U#jk~jeJ3V$@3o}Am9(48Uv*; z->-wtK{h-$OYg7rbz?CcVAA*Qd|PgslsB9zg)Cx6EqZwQcd?SrSV| zvhARX2_VFLb4pTtYn>feMo{MrO8~p{%H(68kUvF(h|8Wg7eS!;59iSopGapdP$HDN z+?Qx@?^aKDdHV~Bq`z)YrI{B+B-8WLd0tGJKktdLo58^t0BPx*DJ$Cq`0=xL`qRJj zto#?=vR`BrKwR(!mtf{V%u+SCYMaTwsAkqF$r7eD?8f663Qmx_MePzM#V6{9@LlQh zy3m{m7b5O?}3L0=P$pZ?L~SshN%0OZvME$YRl zWm5>))`s~^m2A0V=b0dYe90W#6<%L{+W=kC8RSQ6tL)Oau*P9=B#8xpXc@q<(x%B+ zVt_jLpF(Emo?Uydb1F~QfbVtjobgA8tbFIQwXBeL8`8@M!ClKlaf>OXiez)>bYO}x z^1+RUt*{Su%U`(X*H}2dGws01)mlq~CZH); zq4&Zm!70BL<^0duo;`A>7IB$CAdrU&3T_KUssZ5#h>{d$8dTyvcNvWsS2;i6UtH(* zT^ih4l0p%vZK>CpZ98*D_@QBhuipE)O*!eU~Fit4Z=zpXu=BO zzv}(oxbS|D`Po?upv4o2o{~DJCNw)m{m})-KkX!7arX4efDS(VqL;OQeU*W%0O<7| zBG9yX53T|^{AR#@+lDkdZ1pZGjrg_1IDR_`0k*Bw$Y-0Z9q|YLbl$%m-+pmveUFj) zpZ3*`r_YXV&kXm=w2yy0^!Deb11PSs>R2D&p8{M<iJ|F{!H-ns&7Wyp*+vib9bJl}qPZNDtnpN$yq$OCIt1Z^sk{_})f2ZnIzd0NDO zL|Xsx2mcMXzs=8o!|mVq_WxJm#(zNh-m}B=tIfL5n=T|bgKg1U*p#Zc{i!<-ME+U z9#H3>#}{86Ry@k5uv1{d(fmhtUJ+DkENCP@-9!$p@5#}UTRG8(;)1Pkw{^mX?`K!m zk@gsj30?W3_U}7X>*B5#Qb#zAT*57YvgiC|EEZJ2#&64}+EV>+N!O(7FW&A)fJE^9 zcZa`)&oo$yg3?cWe+DGimo6Sb16_6W?uL3G7cuk1Zb0cH8<;1)XQO3pIy0P*G@vlp zyXO}m<pQ)s95W@Q{ z-r;(H`j7VP`=v*?xEGZrzD+=VDr({svoC~`>&z{-7&gA^bImkUhk?H(3*%3laV0&;ZVQVKUV>^X>h`&T`um+=op|{wi+C-+%GN z6O{D^P%P&His1uA2IV~zpYrK!$Lift?E&cde4_xwy?j*Gt6*c*zRFxBz6yZ}h^TGscn9e#dc zT~b$9M|Xa-XbwHjZ)O{q@)CRaaFbtAne!^y2RjKEQ4oKa{)Oj1U}ocleQ3>ltK{KS ztH%Db9lLHsYDgtg(yydi1;5_7lJN>-<^A(TPW{`Ctc`|YW$!1^!JB*U>gwu#`=Gq< z-$M9p!mg)B%dTs^x5Ms@V=YZ&B#zH$N4V2(MF;BN19JcU_wF67+Xb}wR8%%AcxBCs zWC}gRsZI7&7D(Qx*di1@xDW(1NG8;$_ep2(0oJRt%mOHIlegJQH0ZJQ_CUb@JCJO< zDxKksG{M&zszPIz>*K2Pcv1@+L@Y^>E&sGl1Jy>JE;pRmgO-R^P56 z>hQa3cGF>iLqzh8!^#(@)P8I~n%d&_bpE7x%Zq%6%zQqZz;nn-G0lJb@Etqjv_7~U zrCqihP7L2%DF|VA?pm2DK~C3fi&7Ge4Af1wyQ5^h1V2(U0Ly?#9nBV~Y*X3{q10T~ zrTe{hOX#|(I|*abU|Dnj&56tBx0|MrjU)iG)B|i-!~kL%-Mrs=tA20`h|Uz?&#hIaJB16mmiM&<&%N#x#5{#W@2K< z?MA_>llQlayqdAt<1EMr?brJj1zL&<;3m`PkMahS-BThmXGwDHX1m2lS`2lPy> z*P7+#1QQ9Y^fwE&6~n+=07KPF8r^notFvE~n=Y2P-K!V7u8ten)A$YoSO_1 zA7dm$lTYC^)&**)HV0ULEcajPA>PT3#B0QlWMD$AGr2l%XtF1>*=YqY=oagzWd|x7 zxF#2i$0CHn*XQ()?zXn8j$QII{jB{l^Yy~efKyU~S>fx3E$3eL&ybe`9JNpuJKuSV z70|Fgl8CkTi>bS|$&;qJ<+dhNN_+PF-;1EPoLzSXURjnuuH}!+D{<2w}_>oUBi+}JC@o4Y78a?P=V7q(z* z(3<8DvSA4W)7v<2!|C7g*t^&B>nTa=W1k-4UctuWBOSgh&+Tl7c(QfZ+5bPl${V}%bHg6nz8E91POMj=mPbsCr%I0 z18UEkFYz75qwtJDr!C8Uy!^jl9XyU9pRqF-qlWcg+)nH#M1EOHrS{WN6*y#Ia0Uv9 zoRU%jk?cpk9k;te0gEDg?19ptL=~W~sxs&4%h6mr=R>EvDSN7+T&3aQZL-R)zY?c5bmMI~MjiUW>zPCF0L#_L&qc#RdW=ED00CM=Pu zvIz9wCur2exQ1mhw!ob;UR01h$J007?{_%?)_=Jw%q55j-|aC=GM}i+PZQ>|B5tU_ z)Lle+?luyW_C;I>MbxQz0)`?=jje4BOH4vDTv*vRsY-ptK42s*aI%q$8TcAs#}1zR zp4w8M-!8dv?s;Mz?ef4{Q>#QDV_9+JYFE5d2==sqBVw*uC|qxI5qW$Ul#}8Hq!zhu zsZte=ol{Rd`s(85CVM_QeLk%*H!a}3qf2i#XhB04-YKg0PUOZuO>v-g2wT|_s!kjB zb7*6X&)ZjOy$!~%-s$@@^qbK)bfW<^FO zVBNA)Gx~K^!^rx_4fo@1;zM2hBQndN+pE@PYqwz8du$Sef;{UWK|f#6LMdE}eoq^C z0k|c8q?74yt)5-af7^~umQJVHR&nx$#75YkNFguJI5(YNd$i>4RnA=CcdA0Gi%0Xb z6E+$<7x-oU+>b{=O0z3{&#Had+havq-3{ENus!4JBY>k$c)HxAX4v{u&Ua^qa7e@T zdNJZv%{Oj6buXBwRzJpR99rIb@#M?3zr7rIBvs4)L0-+__+_|aDMf_bA+5%Fmm;>M z@HAfBQ7;8K5$a$W<)QHOnRWpAlF>+*>Zq;-4{F%{navu=ZI4IjKtm|kWpI8~$lMBF z{)@q5JBZ9UdgeMJ(MsCC!fekFSMzhqP`ev=m+Eo?jl z0z7x>JdSR2Mr{jp){xEZ0H$wsM~GL7ms=Uol-hRID4@)*zxei#0iKcduONnW+9DCB zxw)LkqcJHR2$wE#CE{Hh%RR{!Jp)Rs4!&%=xa8G*$OqK1w2e*owHNH|dSya18}J+v zAN5{s50=Dk?s}N$Nf>`8TA}HO^QbPgK}|`AT>uTK=peZ_<{#p&AgpRDJkoN@R2Iy} zRSy3Y&~EHNj|8_{Ne2v#Ij0$@&@UceF1aM3$Nhm9)Q%gKesEs09k|#om|6SOI!oLF z4f%ee`bb{#!=X&qTe1@GuzgW$->Q7qy8u&&3RkzUhXqyc1jqZYar!; z`A_En3Br#@-HO}|-KEfzhnMU$2!9&n=OR zre%0SZXKVDriR?M4D+&GJkz@)0I-asF33uuBscPLYl!-=?4Tw7HHj_2tl%!@z(lqm zzIG{#z^(vnqF{mz%fF(0b-RB&2wJgvGj83#I;nK69>%HYy1wyKJ3~Qi5Ee{Bz(qwi z^W2APw6ns)l4_x;ZLesXj7#1HAAxs1b(`%vE48yN&zu}U@w|ZsUu*2PZ;rVfcBRG+S&Th6rDJMpZJHWT8qtqvCVK9eE9Nm)6 z-}^};YVi!tZxlX{0K97nGX3NpJ$G`24uT1d2^F0R{m~u0cZD%w;}Gk@Ujrjb^}L6q zuXK4`=?Glf6x=`hFE4<_lyeF6&5gBI)rk!wBV|ImC9{NB=*cLORIck9cV-nlL`00r z(7rFK4zFwJSb8eD_WeMKQ>tO9ll7&<6ewk2YT2~m>Ce#$CHBxRQzHq&b#g{dnb(zU zkGf{I{%yAMS8O)LzlzmAH!Kzu>Y2GZzNAseBCLj$Wq+(=MFg#cK--R%Z(9$)?&f*Um&i!b0%#l9^7nazqsL zK?w_%-k_L$W076Aba>Q%&KhYQsz`e{#hP&!{ZcQ+sJz?k)Gyq-I1@U0`|60=yT82# z{mSU`x`Foxg9$!zTFe=RPvH~9vUBaKKs#vwHgTw`b={}+Sb!y4vnCBq3CqB4MoDV~ z5YwcIG6tGVW9$rocelymPC$ui2s@dzMeJ9E+d$RbiI+SKGNcw%#~FdmlwVICtXilb zy^{_cE(KncV$N`e+>n@d$QS=hX)e!B?rirLXr9PRy~R~(Sz+%pQQoXj$zb7F8UgSF zH*41>8}g}l^Ic4wh#5BB7?O^x4}^wSFeo$fw)pf&dxDw@PI1}gw6mlyFLX_IIt-3i z7kdQHLh4G_e?zzp<^)Xu%KzO1jA(kV7-0{6+Sd!-kvg}N6pV~YBxNPl`qK>q>a!@8 zUbWvB-vRc0SbZcn**az@+b1}GMFtNpI3CO+NGdJDvH)X?-=7hjv|yNxvQ@&>RvD277e$|B+0|2a6}Vdv*<+siva}`R4%~D~M`*(Pm(<`yx|Jo)v@A9tm2opnEme*( zJY&MTMygi5MA)+o;ZvkK}!sulE z>=jVPSvz*kRI$WvXaL?wjRVqpsUa)+#Dyh53z~ADUv)#)E~YE{gG4uasN+iu1eg3%1+~T9;Q0q@Pcw8%S*5~7w| zwnvqFm}yTKzNIX-r@_q4x@Sae#LbXrerk9RO<*Q;wn!}7|8gDC!aCG5dO)UTy+@7L zu2wN3j;}N5s=8JU=c!9*b`b=UFj2zd3-(-;UY0c|9heKj`?gJRrXaH#st}Sh(nBnK zV>lneyIGpYuq(r%Gt&b+S*`h8hhZTbTW49YVj~UfzronQ_>{W6%rbY@e?hG=1BN}r zeHcl)*uSu?{=Br@{*uG~8;byhKq?RM2geD(exw-Uq4azci>=Mw(+~AmqeccRHN8!# z?Tj;oFJJFAl6ShI9}c~MCw6RIiCTHwf}fIR0PXNor4`7bwyF7cDKuf|{8#B@1-nz~ zt{55rriC7x$q7jBH7RRFFp7RUU?f+vLkcr{9_!2;f{d8Vqg;GG*b$vEo&J2qWV3x@ z9`eW3Ho>zBjqHHo9_jaMchzx24j&Uu-${Ysay|aId`W+~4O$k=Loo)g651~{0oYPf z!6$mEvd~+b9A#|T&^l#{e5{+CiAo6?eaht#5OdVwU41;Nr8k$1EG|5mh`vnD z2mDDAn7doF>pr zM^3PFW#G_9$#Cy;3xOj8mchJRYZNX7xdeocz)O!pY}5@>t)Xe1w=C7^HJ19~uV@LJ zcbs=_)|QZ<#1SuE&G}azodxZ;W6#jROntFmwK0OL*ow?!y^ULrfdT#blQ6lVEfS>((h2|q^?-JAk65nI_~8)uJY z^0NO%qm*-BbP&tCTRjc95u(tJGz$1Lp2V#|e z?Z?JIU!-Igq3CUTyFJr8kQwAhNtA0K-2;O_Z!8!xNKGY)yhIPHl##Vud*>cyN(nKa zO^3_fIdZnD$7GfI0jfE{;X`nr^V$p}SnBb)b5C^fF_-4nge#^YdXh{F{M{j)*!*qQ zFE7<`uXrY;ykQFB*Gf+x9V;P?X^AsbHP5CJh8a$u@jM#a%J}UDUfwf< zG_(aIXK{;t7ElYzth}MNrV=#1R7}i=5)1EQrPvH>S@Z@3EXOgbsaqeQ^)JHm57f}( z?G7H%gj)SD$j%>}&PELDOE$8^}r7mS7Q^AAT##%c}b=ZBRb-NZW- zF37MXXk{)W_IvNCruE}2_SGSnY&!dW7mrijgc}Cp_vY+uakjPv+EwsVN80P}DmV=% zWru@{XS%8d#k(F(31^zsaK^vtq387n;avrdL0A7~k#rBt0+H-tOYz;+@AY!R$A)F1Le$p@Vt70OCa- zuB{YACq;9U{yESKc+&f}aA*~aFK2U&*08YOuB7o8E|>U%j=v@nNUiE+#}ba# zA@?2p2pTzc4S)c+YhhJE6lV)Ryi8eJUqD|={QOH7dJw=%w3$o{`81uKQ+-(IriJbw zP}1*}CiqGqE5TJX%w0mh!lSx{f^7SA{>uB!C_=7HU_&Z-m6o6@1*wQzmLFm>?az9w zz&HG()jfg)GTV~XyIl)!#iTcuFqJIvU!H|YZDgwNbEsiYR}J(F@0z68I!531YGbC) zd-8Spms#9iov|)|d6^{KO_&oV;CriIsw?anmJuCpD_wkA7vG;cJPTq3N~ zQWt8^h>IXdz_Ui=&G%ne%_4Kt46{=>2MM#yX{iC2LhlL2#tE0r5$62VYMHqNEws3K z&e}2B<${1!A5b3%37HWcsOYZ zEadugd2WnaelFsBw3)49kLl}0y{HTedAHVBxa#;P*+V$|94 z))e3M$`M$Xwd_!yO@Th`rHXwEwI|D~^RIzn<7 zhpaq#(2k|DRgF{CZ#B%yNo_)u)jTq+W;iqL?dC&n$)wDzJ-sQ`g%S6R5F9|)i#&At=eh+ zLj(QpC<$+@xD3^-N-c+MnQLXVaaPF=ns9Nw|dxC&y)(g>w~J8s}_c||`% zp;PgQ*@wq_drrN{(9^{{yO*ac?ArdxV-59I=hX!FF|ovoEU7kBIs;Fa>NjU?`vCQ) zMK7CR?*kqDM^W-r0k1l#L{a@J@Vy4P;Hqg7wkx*iWagkvxS+G(nL5v}FH7Hlu;%m^ zoA6o-UKM844(I{R$M(qMOuRw!8v=s2Krihvn-%ETY-M?H?}_uVX@PCR&9*mY(uKS7 z-g32De~|QCFt72sF~foT*-RlP;hwPipvYiEAo{W%p?p}ek(%wk%R~PNf(24+1^g5R zx^otQvb|0I2q&O>`BmeJDtW^7zO>T!N-uBg4-<=N)7t{b7xiM?zrxx|7{nqc>pB(5 z$7nC^4UGi8=GT|v?r1nYZ&w`ROa-zMy{WGu!0G$X)t4E-h^~&P^{szux*IHfbL}68 z!+IvQ@6xh74#5O27AurDt99s$8SF*3v4Zn-*J$0lzjepG0)56^zU3YySOx1ZHEB-? z9qi3Rm0ad|_+pg})A(Vi;7LO{dvSW+!n4_z=)?UF8xG(XUj$Lq@ndc!~Oz|y@O z&sL|&I{u(rICiLt0ff&L`!qO4i6lA?nHjE~`Qg9K4+3t(c%CmV%9)67&)Rb(cgagy zEr;G5Q`^hR=3~o)qSQfAe3FOL$N(X89l>4wg{1!T*Y~LF9-_J_&ow+-;1y=hx|vUR zvy$ktj%^PHcMd61GB1WnF8iM?xlfPk!hI?Me9=!p^$H($RSUh>OOyE(jaKaUHz=|@ z5jN*AP!3=}3HQIZ6R2}Zng-knaC4vn{E|%ImuA9bJ@doN1;mx@R0rg`L~uL83pWm~ z%{$LzjABBruIiTs!pNl2R>kKtLqxguR!5;7?~ldHQ{A{8 zwNb@2{(L6|FX4trrO!+)Hh;1)aCgdJD91}Z>&0bT(PiBAMmI9o04e|cczb7$Gim3@ z>U~^Ag$7Jep?dWd6D>p)fj4=&+Dsj+K+BTkMH<)ge9WB!QsT8{JCa|f&b`8$vAtxs zcq1KgX)kiU-c-5o-w%8+?rY-kW#^!xd#|s7{ii0hx?)UYYE$5jwmV3=8(b3JvOLMc zCb`kJ1Wnr(M&$MK#Jg}s=*?Zad9e}4>7Gnk-OJt=HUNu%kwvyG(9H3Ex*zU7QDXB` zj!|fnCt?R7B+Z}a*m)a~QmkWlP<{J51byMR8~qUgQ4!7gvE&3drMk!|ZekpA6{5L0 zM*QCZf_RfS^7cYG4%Kgddppf$vM%NEM(*o|57c*{uFGYJ0gwpDRtw>*rYp&4TjI?F zk&r?uztA<}3U$TJFr~z7R zEGnK==!v_nw(@oD_gicbyYCZZO9#c*A|^itt!oQW&PLAkpSP|)XC-*@y+3-p|tXeMEAQ)Sjm>u}+QAh)CK@b;nTW^BL> zzFv$vHhU4|Y><$Z8~_?Fjxj9%5IJRP*hbFCH3t=bUWo}zW+w0AUiJ9 z%D~Uyf9{J2=Q+{k!I-?8Du^%huIoK;6s?DL$bJ;8Bn~y541X5?$BtaZ+NxQs2Tk|q zDDz;;axgbcvBvh+?sgRN`?f4;!{zJ+35s}i+*!>-@!=f_qDv9PZ2ZHU4My(U-+tev zg(viWP{xd<+IImlbJmh!c+7tIp-0Qwol9b72y!IbO6&;G_xd0qPn6B1>wa=LQ=R=r z&2DSqqdE75BITaFpkg+ZCssZtXKRgkQ3_rC$R2*!;JUmA5HM)5{gp?Xo#glWzbdNK zz56f$R7b6OBbyaTt?OGTi!;9g*;)l9d=)mRS9a5u29)d4C8@0M$ZE zd61$kP>^UAt7U673nSeOth(6UXkBMsk)0^-4Ae-0;sS4OLstpAKEh+^VP?e$9M#nd z)A-iN+@}u@79+G;iKLOU*p>K_KR!3f+_rI@84qgy_NuPM(W0m2la&jmCd)m~ zCh1)9HvRr&DUpv5To_rL-yOk5Wz?NK{p$7Xn6lPcZ~44_+Vrkz#bi5{V5-5ENt<$gd#%RnQa|{_b6aaFPa19SJeC3JPyg~22cSGv&;e}~ z)5j54*I1P5O%zvIk0(hj3RNE9`xF5#POQi4QGioHoY_rQHP4Z`(mzCj7JD zR;TnNJDD)nwI{rI{S(oZBKb$=Bo)nt>Afe^(vwdv+N8>lOVc|2I9L)-IGL%QZIdtx z0-cMLq(QLZr<2QCLwVyGcr1L=;gBn`xUX{NJ8`o1_KI@t-t6v&gDr^$hwiKG`&Y5{ zPdiY#2~ljwO@W1w#npCE;37~y)px&lXX@8I(7*$wPSydV;mq;MCpIkd!q_!wy3UGA zQw^&En$0h;H$eJ?<-pgdqSlA9YTAWUwH)0TaJ}uqQ(TXMvWKL{FVa}uqPC!Rjaq}% zwgy}C5|NDTmi-m^U3Y6kpN$RampfITA;<lSMIo_XQ}cLLr7qen0WrTW+R#|* z$1bpIxqrEdPY`gNy?K>&%_Zc}XOwL|1LB$Z@`8$cx)gY4J@{NrKS!J>z>pZj)d zV@86KDTOH2>&}-!L`#K}kwl|@6zsPmwQVvg_?-*GI9b2OCSz2ArdV6Cf^`Jex-dqO z#LgJ|{v;3x1sYQ`^SapSy|Eq_GY7Bh41WAM=;@lqN7d=HugY7y*+23aM*(vk^YSVt zSo37z1*E;PvFG&0C%wRrpvfVPCnOLV*g^Mo#l5UF7Yo`12LufkVhph<$Xa{9>GBy+ zXAm}hb3!T7Y$v5W0$R;Ir>B{3zvv!E)0Jvez6uN|0u(r*h7Q8m`#0vh)LuHbt(7Bl zt~UfwvPc^`SgYTi(Iin#0Hwe%06jtHXK{HxO0!Rf=Lwi?k#%JtozV0ouCp#k#V;Pt zIfbgRWN(qC%k(ON%?d2BBNOWH9NUUQ54WeGBMNfBsYh?_c4MxYMJk$Xwrzbb??chv zXrOwsOmA-Udb}-8$kM3-mFb{a(ik+YoDQ1qp+vjG?y#?wI)%ilBn;Uv@&@71ueF^j zO2)87`Nc~Fc2LXxH`vh$YFrUO*fA{a`;s_fi>bQ%Ej5W|ta+qtE=;>GTV(FO6MO<* zmFP4+jEhc*MZz(XlD{M#P3=`xxHP}C`LW4h`26qydF(!Wb{Bg!G5Pj zB~+fR^0|)Nf<2QZfsQ*8?{XFJAzKzB1pTqgmBS;r0v+z zn|HcRxfgr#1{p_lWLeekPuIsRvX%^)xLog`rIg$e1^3{z(f!KF&7r!X)rsjVSnwt- z7Yet*Xh7{hp?U#E!=<#6FvS%ldZ!`K1??Iy)Itssqa{jvkrk&`zaqf0wKcT;^kSa& zfKruuRD1N!A!T$SPi)nhtL?ZpmB^pUhfgQF_bQA56?9|&al8EPhK51M`cBmsv4Bpy z`MlNzmc!qm%|F84FM%zkLw z%qZ5oz?a?N&VgAllF$FD`nB(+K6iul1=L}_4s*1%U!iK8dsNu38w;;F1{ z+^q?RMcU?7;+@=jQ!X3A=%{-Ko#NOhV*5s#2lL@cic8P5N`8cLzs_W3<>KsG;7LST zT6MjKHj`^Wvo7$kcC0mXh)6fLTz@R2uvGT3Oo`|EO?kg*ZEW9rNKV^IH(lZ;3Y`~5 zAK^B}SbZE49US|iM!j9pv2sm?rCgJqMl{}*=)Gb zIYrjG@o|vv<3!r_Oo2=LqRaeNQro4wj}~_O^du(eS5FZG{pJ2bhXuo(Q>2wXnV;0G zrK1yN(`gfb6j-LONET>o$q0_e&)PmtXx^F3<@;$X_S1#>YWF%eyPC^Q5ykce>Ik*| z8E&)baWH=WqT`2>s*_Pt56GIQ1FX=GcXX&1+^*nCor-jFqh1W1SsmZLnPHG+A2dA_ zu>PqW3NjoJyyF)Y32qU_JL;AO zPziJ$P#xm+v{=_4S!nyJZ4r*0mskcu`G;l>u|*1zs*6eqwG?9BqsdR-xn5f$PL*=5 zaoqT@cHJzJkD%XvZ5?G#VQ#P!Xl(uUq5Ktsv-;O&pF)@TF&&Jf*C#j)(~VqP_T=(HwbFP|Bk;nXz+OOY5%d;!qNj7*0u$?pf}>D6Cd=~5Ox zy`uDTZH6L6uYs#|tArhmGb_5$Yi{T&yO5)bNRH*Vek0OH$natzfGO5Nnvqe=^OgkB zz%r?QKvle!Dt-ld0XYJ zY5XOa@Yd5Gb|2ROd%dySSY;B3-~%~8k>cxu^-r>+d4L&4stvN0Pac>|o&Vx!P0hv1 zs>nSP9u;L=-hH1+U1xbdR_$&pE@u)zYd>G$eAr6Ps`(?7e)qQ3({nR!pFV!P<#Sc& z?wn0Sfo1m@Js;uA_f%2?W@yiOKEE>4JuAFDzA2`^F~4b0SO1}S9no72_?0%m;46xT zh%Exp<@my7&R67|8Hb?=q?1nkby9B<7*F|cweWAi)K4ov7H>dyywnDqY0B#RL2c!t z?!{*4+x^PPz$U{fj0OGgf%>%em zSOknw%zwi$!}}zUwF=z+>-+KBc*M`+SHUw>;o9*Na)liXVR`E2-^?x{o?%uao9cJ` zguDkF;7CW?z`vPY0zAWPBZH_as;=CaaK)znPFRLclN^ioJ9E z?EbUe{`4B)-@m*6Pj9zhGJCh`XsyQss|}_~1`Kxi2K2Y<#lA1rkRP{Efw_6QR?6Gh zo{EZCCo_Qz<>D8&m?@=Xlf^YtW#0?GEY;i3U$87&c~74|o$EHrYanvmyY@7lP>wo; zEq5P!Cqpg%rS@mhXWP$9AE9_ARTgs!9+nFFL_LwtFu<-)is;**!am z3i(Y!>Azlo|M47ji{Ox$jH4I$jYsv{hN2rePwH#S^yz<|`+xn@|Kp#=xx&U`!#qy_ zLbFIh;wkz49g<|wi14l~6=+JEq~$eBpTRm!$FT!(l3#g>+{Bx1(QxtL~m*v(YOcy=Pe}c-CR?qQLnBPanXhm<{J) zP)Ui+Gg|k^-(BladjnVu(tQhk$8DD@jKOQ3rGU#KADNjO0oXriwzFi?E+aDDpD$=e z3+-TTQRo0!bBDuyz;#;BPlS23D1G8a9QNq(>b)7jn}2#eU>l9x;BRLizFzCN@HTx27G zt!p{fP8VpP=Ua_Btblf*9FPP_dFA!43(vpACl9<_x;Z4bjcB-aF21)KMd%CQJs@o`Wr`wjYBC z^ltTy@zmC6pyt%j2-sWX&x$+a8?2)0LxzjZ2PP1f)!|a9rq8pzb_#`Y{24%y2#~g< zQ)9aS@SJ@PQn`jvpvGc6S6vfd2N80RU8mwn)!6^;{05jbLbg!7BqM~>`AfIe<)`)@u-gi#Zzth;k8A;Ssys-oM%t?Kw{aZsD1bakW@f(PsY!%B0y zsUDP$1L{dM_cI+T5zRngq?)P-;MGB?0p)dj+t6mykS!Go~^Vt_MD(+whgWa7KQD z4KJ;w7bK$QzQ3*xG~c1~pFuIzP%C6M07+$Wc(8Ayh{{<3HUEC|!jw-1!+@0#t!8fu zSS6VTQ!WLAz48gJdwF})IE13`B)bUf1>fojnz(%+pH4F+hvr~qms!6 z-4Z6RGjh)n;D$@uielV3$4{XOrI) zUX6H#DKA=?WlWHXHlFMo)JCi;5=+>B0wf5))-I~p#c>1Or7~@MRTy4sf8PG0PYYbOE23$<|Nq>$E~>E`O=4_x1}a`RNggp_9)3cJNK13Q@o34 z3XEY?4QPWoe2xzX$W=Lc_`iPRMca^L1})@^)(UHY-~3Rm>mK3%IMCp5*?m>@z#k)`;leTRig43AK+ z4h)q2;hR_tA}H45z$w4YuIOtvFAr%N#;$5(=LS4xW1&45s-T`s0kwHQXoqaYxwlE) zx3}JyJZ4`qo{hA4V}*)aDZQyuY5rCD=_3DWn~M*~r47#vh)LkF(;5fqk6#|Ao!u;q)HUUCOvY_-bR-x zuud0Lf4x|ZdGP9uxo5`qkZpVAN#L%#kxQoZz_YX?d&f?p1U2j^Q2~5$pJ@Gr*o{gF z8fzNm6y$z9ATx#BZcSMD(<}JrILK4~DnIGl=3rdfg^vt8O0xAP%4w(1Cfm_jd^ndb zp!X31-=2hL@tf~Y?IUp~c@}{CxBKn_pAbX{k<$BSdv)?X`J%~R;d!+f1gM05yfaCHlo}zV z(56$j$IS@4j{5LMEr&yI+4!q^u1-hv zsR95H5_&Km<+m>EoPYLH0FPK&QM7|=@#}zAg>*sw0rSDqXMoLUQctz_O9u)Np-{bS z`K$)L-$~W~6=i_ft3_weFRO3wgYfo{eOJl83=^cnweO4D+V~C5S3@tkT2#yJOm}^6 zY9XsVW%>g8PI#JmhFM(R1T>wba#Fkd+=@IV-4C+E7@7h7n8DcCINubdgui<1<{eXo zH~L8Nq5xl-_`uT6ja;_K(i@hF-oUNWHn5iL$62rgi~Fn21uY80_IdHr!!2MfR&5D_ z+xncUwtdpQEHW{+@vDmk26IA-eRPB1lwfYvDts_r2YIP=YiEkW@Ku)mxlq>3BMJwe zkJvl*CvGWMH!ZI;Zy2n+Jy*Z5rzf9d(02a22(wgI*JN}kO$2$-?Zwgir~r+qE&?XZ zZQK&G>R_mtQ_}zt_3y`30HuTalWYs`meDZjU%>3>KJd9HSBU2Cgrl~)W;*jN7e(>< zNl@}zky6nKLF9N z%U$?VfY%GG21YZDKDJYDTll*D{%!+{=LL*)trn(DmR;Iu>iPg}B;!Ifw^BcrMThuI zr+@dP)4n`w)8*B*@(ZvK>j+NkUiKB$z0&;be4}X8!)&~2(`*E=vNQPNSv+_!_(6nB zn%X|>!cE6Cg#-?jz^026mD^Ky&xuA9$fr-X?O1Jxv#YnEp99L75qU%!g{T_hOPILe){h< zXEb!|Y^RjjPM7PE4uMm+3lPe!-&mDb61mpVSFe(rLcUY`uOD%x)zs3BoE|SU7@UG+ z8kC-^Hyr&Zlsn!it( zEt`J)YpC)7!hC=a%}@y*MDsUhTGU=wd-{-JnlO4!XPCkF;YpC4$2zbK-8Ju}WJ1%K@OlYW7l5>*o4=&pm;+jZ?z(d zAjBw9;Mt9JQIme22IM>fo{IfeIC~H`^wpO3N(?k<*)>&$FZ+d;r77ZfY!RcV|AcJuIDQ} zkLVQ&Bq#0uIziF8+5VU)kE1TXyO)=U#)}uwFSY6`S6jKjnVIxO;t|CDyU0qeU|dLY z@$;ky=aQpv^PI#go`KkExj-s(*B%InlkK87zhH#fGz(>-`7GJ+#QK6L z{5jppG0**!-@G4zvxz&a{upBj>hzNv0~TcFx6(84J>`2Z>fj$|PY2@jSIYx-J1i?g z&(}l8Y4V!W#Hi2UiG*Uc?&_W?Vf|=wYrM>d^>}@MrH}ANX{>FVCzQd+wBD#askh9) ze~P#IbKSc}4VXcZQ8!`UMA|MP>T~R@Tw#C!qp3ooGPVOKd`yN%LVFc|YWZLg4$V$+ zf{pbbG(1P_kWJa3`Of<-74IRFz-jGFJMR?i1o zv066&A~rbe;3(m*5Sfw1N{@2AdgG!sp&ph4`;x*8hgb0^&fIZoCmnH41z+*55?|!Y zPJj!#O-0(73{k-Ga`8 z{9G3W>L(&X)orI=#H!i#^NcQyJ~<6Mos+B#e0i%^+k~JvkCBrl>)|v|FDok7Z2+T? z8?Toh$Oi#NkHP0Sj>BYFds<8)#Ogd)k%Kj4d+<3j#zqds=surNqWnT{0I=Y`hXz@S zR?bMpow!9j#6gcB9?0qBBS7~~LM>a9zF#pdK4HM38bhP5o|<;1_g2K}iyy)-*Ke7w z66gETi?vE)m$J^S2-^376gx|&vcLE9li{ka%3b>n$DJS~>sKXH;-<@Y9)Ge$y?`wg zrR|2ZYPIQj^!K;PDn-R@XSQ(k=!Dky6q$C9`P>}K3Y?{Ka4;k|DtEhm(z2PK>|JQH z)^Irc!_vFAlbEf~-*xlnZ;(D^#p0o3tXa1ZE@+P}e|R5OS!F^haY~cwt zdy*e{pSSo{kH}U!1ZqUZlNyg7mt)klQYRz6hu`Yo<9?d%$xQNthf^=hSYupqv}tDI z5R{~vK^?=aLmKP$b{pCI&MCampzl1_6#Anyjj<50`OX%6k7ys2q*pY7G^h)l&1 z1!O=JSBhclDTwkMCpGaR1+p@^I1!0^1f+kG{O`!!;Ik-%yim(}aZ9>Ni3psz=!7HO zCdKI}PlM|xWIaK?AX-CYbW%Fb=yD|O*E7&~CNf{4Ev434i0;D0XNR|C`vspNdKJ9a z0Kdrq&{33s*oka|#2npglJV4X?x;4!f62@pthX*fTa-_akzMDvdaV#6$n!;k=wgLR z&W2s(^-d*kyCet#j^-HM^Gky$22#iH)PtWF&VJv%mG>Jq?oSmo&%0MZlSGoS;DKri ze4!s0(XIOW*#Tis=|ae>VHw1EbK9|VT|)sV_^3C9EO7W+;J3o|W`{o!-Ik1Z+A$V- z{OVtgS$FSB!LUn5YzcfWF?JOwvNF;kc8S;K(ei8TJt0Knh6)K5hkJAGKy#`I;zi_r zv#!;W_Bff$np!4tI^2k`Wmq}s`5Ar(e34Co2V&!Xxc>-Tt2JK~1-)1P(<~PXvpK~%W)iTfU%_iru z4Bn%Pm4AHA|MjPN27FgrRQ`!HXiwNpUU~IXf3>Z85|rQ1Yh7l#blg{vs@1%=H=6ef zize{Sks7R?3onL!DJ;9k63!M84fEDKD(?bOOwB{qM2?Fe?2T-6Y0ubu-*Z(UFpgq? zo1O9hFD-zdg)q^DwdUgL$tK2#U-8u&lgP`z$dB1+q%r~G84_KSMTe$$dzz42_c+G9 zvG;(^Seoz+hdoxVY785)8qLtmckrzA$32o`2lhD8BK(St+UknURNiE=oW>igSEV6v z`_C7C&}Z{rllaq{^q&tTL+Sa5@3D5%FO_Him5lwbzwen$4z^HtT4dAly^5=0V4uDT znJzsd`1|*sl4S)=)Ql(W;m1GSy?bEu5Yo3>ACX!8dpseUpuwx0pu_BQ?3R7{6m(I~ z%f*i$-_X2^f7OyjE1w_hhb5kv2%0z7!h{?CMl96?3_;e-IpgMWYxr9HA)qI)+;a;2 z8zI+jFocjD;L?2jYP|t3y!z9Xn|~uRn}P4}uFs~}9KTwG^x||mi4y{B#}7dn-+i7# z%1<5JzTi2rQ3-`;NVe>NOGH2Bla{LcyZ+dD(|-zVH* z`;gmffoFv$Z+groauX6JZ{DV(zQ%O^Jnz$!H_q!yU%qw~;SQIoRk}?0{OPl=XPM6u z+^$rgt>k0sN_Jgs%WKwQYovD&xbod2e!9ibp}57ed9-S??N5{c+gjj%l#~ZYUEu`! zZ={^H!NKW|MaqvKe{g;Q$1t1b%KYCP>N((0{|OHDV+{&V5Ojhof`Zj)GF2h1_wMxmy?;n3r|G;0=rJ|MG$6wTR znqUZD=-b_o4KETO&!v5j-0LM2148f?e z;BS<}OF{5Ofl7<4KR)>SXS@CN_5WwP{g#aVv)%rBU;h7bx=|(->23InCldzJh%leI zL{k_eG97DsSK{4;k3)n5#~whB$oa=P#{zE(+{Rmj0do4U&k{b5Ebd(!hbB5Kwx__e zqc^4zi&g%IM94?S+yK7Do}b!%g{;Tk?(rCh0L!BjcsFRX;VOt-yh^@)D>Khm^+*7b zksD`Fj0Id6Z-Ry(S_dt+?_3LQ`bQP%{@A?y9|$<8eFsXdlL}q7ZfO8VY4MOc1{NGp z%nY%cMUwdvr-#9bxg|Jkr&^3lP8A2dmZl3qY27WE6p&;%`oy;))a4~ji?ZAt8c^%a z)taW6-*#I0^^*d6U%+-EctHsM3*bd0z3lbH$|Tbabb+GkN{>3UaZ&HFJlbY5S*zrd zQnNd{5zRz8iv`_IYfiggq)}_AyOhF4-@@)`tjxbn={fR-FlHky@GJ##k4Td52CdPG zIv(RB#=7P4Ey1@WZeJw<6cW^vi#Pd$jYOecN-xp*DP6m(I`A_o&0(?#>O6zf0piKI zC8BOsVdw%wje+ra3ya#Lk%n|#1RRvUNRAQUQ!`gw)a(LDewLJpSsZ%*UCQs5JbU$g z+)wVmM3Mh_n?0kDp3&Gx0cCl zhMcCOPUyKIj6fX_E`)3?#v9%kC^!TGq3uXP`v}Y^&0d+_Yhayn+;(E zR_3RpC+-C6I3wT?XW zny0%V$ni&NGVpO;#=B8pSLqG>Zpx}zz#`cOla6dQ$e!op*Q@eA4 zKLxwH*j#doh##u5hpzTGlzhl9!X8cU)Fv=pIuOg{Qe3e%MvQY9ejmjJlSAIHl)3sA zd7+$u@cdnqlEwAUwIS0ACpJ0A9;_3M+6_Bjpn7WGbWZlB*b{%#TC}=}7>`hvN<$!+ z0GGg<<_WK4NKypu zEo%`Rz>iil8(_cpLbXU^!F?KjbMd@55E)J05eb-0OVcpUjbUFP2hsMySuqiFg(n464Ctb zqI{Pd;LW@1BYq;OjS{5`hBRXoL>M2Gy6wWZE7aL3AJE+zS3<_|=^G3Ot7e zY#}5(RO>!BRlPSRU$(y?H18H%dz!kga_9~97{;N5moo0$rY=lx&Wz^s0O2$*aZ6e&g)9{6p#VqBY9d3SqDbW9fQTkk5ix+R-aFC(99eB&Np z`oK3FcEh5y(=C3cY}PXV@(`P++dz%OQSqZ^*tsA2k7IeI8eU-s7Fn1kW)Ca@(xR)= z?pX#}#X~9fQ$D%$%j_F5ygZ(@>=@up$eGaCwgcMG6F>^sc5~_dEF0$w-VcTcZkzk! zfH3V1bi4fo$#p=msQ~Xdv6)g8NVWldZ%YSy&QUMUAr`A`r``4%8H-KD7|*7G;Bnd; z80OTnr;VvItQrAPzc%&igc5E`Ht=c})cQ2DAqL^b8Btt<7qv9Fa-xWsV?zc4I8s() zX&3tpqBR`0mR@P8Hq0O`7Ar7M0Em46-kPof)8y8ZJ{3KL$KmviB(xzDQGCN|C3-Ug zdQHZ!m$FNSBZ@bNun^XtNU9fmb687-h|yg`;bJ+QQ)kUbc!Fb(DhbBCoeL7Z69Ke3 zx{1i`s|@)s(g^?1&gDZq9B;kLyoc@)Q(&ZDJyBz$>f=gG61zJ@>qF-p{ zWctNoJAxI$d`V<2anQw5E5?{CkNVxWTMKH7QLuQ$wc7(4iqLwoR|MWWoKSzmz0*__ zijgH8o^hN8O*8r-Ushuf^2=g8{6Zx%l%-x(Ty1C@2wb=~Y_!WpngOcd5<6Md6f9 z=exKh^e=Me-#en(#O$RUUgV?gS6ASTTpo8 z6QwOyzB4PVkd0nGOaV?24g3-eAJx?Jy`IX-!VDX4#?<=p)fw*Zs6;)Yn%98U+jo75 z()G-rs~BL@sVLpGUx+Tk6)sAG>(CXJGPBFfbr7fWI2yNWCtNCSfWl#LUr6HNIU?xC zkQc>f8YN1J?`5bF@E`%lB|4W6*)OUd6*fzHNHu&|jR?l*IW4o-kA!M|EfKv zz_1$?fqIlLbSdu*{Nq=>NkZmrww7r&LC29qA?k&5t$r@6A5aUg_JmhpWnW)OYzA-4 zqwkD29KNbfNMH=&=GHqw#GFxvx?@e-lRGTl6wYGl)#w}m#AS3nuJd=mQ=DS9^-fqi z)DX*LTar!nX;+_N+;Uo{s4_Sf_pNlwnUsc#L#29b9 zNK6=KEcj)|x4V%rCuoT&S2PP7JJOyScycWIAQCh}0-yv<-#R_6pBpxJT`uVvM|jalr0O^spA?qS?>JpGRg2GQ0|+!7LOV09)5S zj`RD}LaRsC@s$~^>h*tY^R7lrwMu#E28pitE?j)-B@E<&BN!BO*0Y)4uSSI2Cr|j1 zntV;z7MyDn+fyDk0EUZuvq!iGtMtx~TQF?lNOL@8+$OwlM*uS2gH*uWnQv>mmK^iT zY+91xUI^1TQ`#84oG*NxLT)**eX2E3E9)0V5QLOFmT-rX`RbSq9ScyS-OLR0 z+QgjK(y6&@z&V}T+=NrkcD9dGJPI2}>Kj}`dBYdE0pc#G399Nj!;^z%o0bGVv1fE_ zUX(W!{SCDjxF9d)9BmKqc5(Zy8$y=tVvR72`A{JmQeriF8&~LXJ8ZsEF=pQ~EwS+; z@_s4c75WlZJq}NGjV?EY>I<3=UftsN^X}VELxhHs3Fagkt{U@&A{-XZ z&$;P9L!UZ21dCQKsVDiR(0!0mbXkw4JC>v#nQz; z@*b{j1x}sDRjEg7yy@ZDG}n`PidBx&1HED(JLLoeXlf}?mYZo?sgiMkMZX8Mvwqq| z;o;dlJr4)iH8$nGxL%4mDG|Lg#W=xM2)U7NTPzy)<;qW_D5-iMnJ+T(fQe~{m;F;U z-3&vaOBcXUpUx&T8{JvQ8f-S^%@rcr8HL~qSAe;ndKN#=#n{CSxo|-q_YpufR7f?j zaWXik)sb_ma@uxhZMG`r;+9Ak>XGIk*U3u<7hWV%Mk^(nH%&C=H=maCxkNif&rjYiY@H^k4Y4yLf;{`1YduOxiRo3Y(x-@&Kg-O`7}_7Y=SQ*B^d3Pz9H zKpI$TYucSPs+6XG8&a+k**a9|vTyMPFko@6)TWB3R7NAN#J7kn!0mwG&q+QGv%Uu! z)fSMN$wt<`;)Umlyp}`6dx67gl%^6s7tWPttW2_$Vp%ov`{3IY-}2iD77n_YAj2NR zmG5GmYDMoju_pf%A)D`$&>(MGCvSqs3eHxdEYFV1@mh9%&-ps-5k`5VtueScPN+oN zZM~<>(91n_O(*Dw+tvA+oYJxL8LEh4ErW%Am2u8;uA|+!W%6n=eC7Q3E^lAs8R^QJ z{m_*OdlKV0W_bxDTP7HU2LmX}T2rWJMmV}AU7?pRZLZqc$-H}#yYzB&xm|v4dLJe& z3?LxCj2|b^AORCGbpA0S+jS!fhR;&q8q}*}5X72-;4HwzauiKceLnp#5^a|1tN@**5+pEc@sLi-srkypSM7134 zTU8n%&e?c}Jq9eB6+FREsmKW3g*ik3Ivp_HvZ;1qEWFW*H})v#FY#!B^L^zi#MS}# zgWg4+R8%GWV|uMgn&gV6jK530KwFsmJDW=H0`tWU^7{&+r{+NkArIm+Cn~zTMh1*c zLoso1l_&w*DFD&`SG)%o+aAcR-f8XV9{CcOI~$fi6?Ep_krB*CGG8>u7TT)X#KnJ7DZf)()p`AOhit-o1#J z$x)vH*+%6Q{Y-TI(@^WwD78^?l6Z{ks$v%?@_b=$ve!~cRl00Rqy_A~f2pcNuGt|$ zWtXR6HJICS=uu9(7Ajyh0%K=dCPTE(FVmYX6dtt#6pR_rN}!B8*(p8H?6PQ|*UT*s zt2+16gf86-Z37=nO9A}k&0)7`qKn){OJn}*^k}tGF1GTS+XLoFG}VE|y{Y!5WwTQ; zlQo{F;3@!UUzG4fWW7+~y4a8**4RpRf6=T*jv3TL`}fB+Lu-p$POrSXNPU}X#1$A! zkWtH(+s!8Hr777AS8n!i0~%H+d8_3|-p#_I+D336G-gE=BzqiMnh|L@bG1bWp7|8= zWxUY%T_{m0XpiXT#Hrbshnl8<;}|~F@k+vAE{v(sqFf~{ZN4ajtvuU@#NND_b;o*_MB9>?$hKC-_wO3j?rpCWZA+XcnSlbCMddI0vYoufPhCbf zhX=5-10T6yN*SQ;ov!$W=Ex)REdGdWepxSW>qj(Hs8#n8a>IlaTe%rxD0%JPQ*m7R zuEp#?S`cOs&us=(9eZALv4ZJex8elhA?nb5=$CEJszJRs3SMU}6+f)u8+OE47R~jT z{yIa;aQgY4+5;}0>ra8Duh38!k-=I+iP6Y>N+h)Sq2RE=PNhAl(xUV$)^V5tqD1b3 zCkUQ3_3kl1N`*HdR@520o$w{~0Sg3#=)#PpdK8~!&MdD@PMk$Yq9?y+sqRm|D1{P- ztZYM48d29i5sHJ2$pFCu(&S?`VlB|8bQa1oFi?{jr=XUNLSvuEx32tyg=nePt1rJbG2z zL}PXcR?XsXp8POc)lAibldRJ&-`cx}updi<*0$`j>cz)-fYz0w=zbO=W}6~Q5w zvTt8W@~%M(6|3!$#SAW)3Fnee z-?*VvlDq4%nq87?kL9&63@sSGU>H^GRs&GtW&*IP#k5|Ry=BG;A(bZsiNkL4!09X* z)FV0f(k}NlJCwP%e;R!*%__7Uc&$*ktL~2=-EhCXToYtN#nmO#`U8GL-nN5ApmE`JABmn%7Pm;ZKgH(7{T%Ziq+{(f3 zLJGXg?VQ>Tu}+b*Wy-_xm7;vLNM_s@@!bwn{h4p0U2{2zyFTYywpjpSFbwb!L^^M; z&kk-=lKJjxHbt;ZZ}y{BGipcPc%P@&ClPSX=IC^8A5`NiFhZN)u^ zIy54BRdPv`TjSOH2Fvc6l7lT4!N*C43WD^pW~DNN*o?BEx!z1hLHEzqUr?>!4Po4G zy&l5TVjFp6ft6Il(~d!JT2h~n5}j?#5FW^yig`vO>{wHEq21w?Xrm=(1^2znCY^b~ z+O560|E8s!C*Dig?kI%75#}0)_xX6fO{>9W6rR-^Dt1(+n;5tH97Cv zlLOdvTb%NypWuep@)dghiC@p)nonx|uCYiqXt5|Ke_PO0E_nT&HCiNH?S>*lIMLOn zPi!QW*$wHtkGtzlz8xH zo0TsLyH0$2&Vvazp>X-g*3Xj?csUIv-D;g-?o8IEt4A^{eTY$Q4Jz~(kN4Pu|Na@$j<%?Cj}cjtA@?6oaGh@M#0shn5RDk_)Paa{hHc&4V3 zCd|kCh-0TW6+qEI_u!MjU%iX}1Y7a!1EN+G zH>N$4fCSt1YmU%ibU*W7 z)j10V_TS?Q98Y|B4sA8dr5`LkAV$?Hp*MWQ)1M%KeFCr~uKBmt0LC3$OrY0rz-C!- z;2aWXha1!Yb%6wZQLZDOf%NQMfPD(Su!`@#-wCESRhwOUamE-^PC|3!7oL4fAB=m; z^eWq$Tulcc2eLHFx{50dvo%B5p;cFzZi+ZR$4e<(mWXFjXUova)$uE?lLOBNC&*OK zjo?Uf%AJ3#Fx^)?60NDd?Py#A{!7PH0TQhuUZdapuV|Iu`tqcz z0EzZ&5;lFr82|mBmzlw8=(OKoIEEksp4#^-7()6=f$dRM`aeeI>39w7&Rkdo{P?cF zBz_3bx6uMe#TS1(oOlI(6;NFAh`(6{{1CqG&EGtJc6IQ;M{HCIyQ#e|M$!T92}iI$xWjVX?wkmq0sQsD`4$BK<=%4!nG z7@NW}R3`opb#EONWw-W^D06^^|!6lFZKgo28W|0Aqb+}AhYJ>8J97k#7F0MDuV#@md32LMumrBH4F5nLP zBQWN@?w$t>jUpcU5-{aQ(wP$j=7~SRzhwgMutb>D_TCeA$+MX%eJYcf2GUT1Nk=9s ztz&;7xX$!V@zw@WmO8}^o5CHb z5{OwdOGu}O-b`f|s6%LYZ-TKfXFQ*EP7**cL?;^?sDxeGxA^1T_OJa4TmXH&?1dxi zr&7yBM#_&vM8#SSjBk;_ZA1W)@8!1!p0hiB67U|F0qx_S8!pri9GOnSy+_HL0^Sx8 zVjlbc!Y>x}6Gh$1Ja@L+s;G~GY{yH(PmL2LiFB&%id?qNw9;B2XMAJ2bjiYE41O>i zr~4L(V%9Ob#vdGJB&ROeXny4SVa?)*^Ya8=>BhD5;QF&kl+!MIHQqGS^*@#YufIrk z2j9{t@q$%Td!O=kB=095?Dl=xEXAnUPITW+I)MVW$g<#* zU11M>^<4LU4LFVI70Jz=rH1iW+M$IhKNcuw=7?^aygK}y8M+LV&Cwe|PVJ7yCy4Wg zN0f4m)PID7eO_PxsrzyYxB_qhtA>d4vtz}xBNXQakUz_>nB?&ICUoH^;Q*orG2coV zIh-uy{3$WoMG8>5Vt8?WiGGiDe9`=9@7opK1Ht!@;uUqAx2~j&@oT?I(+wlb3a>Y{Id# z9)0+`z_FLZz5$ctvbK{_E;wgS%(K5#-Y3QcHjg99LVbFfu^p;WAc95+nRUlK^t*P~ zDVE3QPgR8^J>FXmI)%1R1^StvY>{_Jb%%-t+sh{bdME(^?1Ys@DU(74UWF3{0alw#0C3teD|B zOObY^%$<+Uqxojsv92~U?1=*QPG-Ab{&+<{|GFX_^;s81A=l131BfgG0C!uAy_w9|CeVbgj9 zIziH&p!PVf$Twf#44k})0iPa&wt<0z0ypQQv0^PcaLkmoDHXnRf#-e6b@`Rf>=%oDcCbjIqi4_!{0&BCzkca<(G{A;f}G8@ z#%@>jL0iQn!2$e8@L{pJb>H)AH1%;z1i8mzAU}OM;@aKEuibW4(~Bu6`duTBk?j^QP%GJl%OcHY7bh)y=YuGQdiYn`-D>+HuaRp!(Q6t}c)L|1pI)4} zBq_?{St8r_2+GaA8K_i(>y3txbn|=zt|z#5L25ot8n8lN~|2Ik1KcAx5T&cWv4vks~9y+`v&=6Xz1M=WlTkIt!|RL3mK zck^2TN4$D0jK}`uK%$$6FJ0s%j8W(*oq4U7dr6{UAAJ$Zqi0RjUb<)7qk2(}gd(Y* z-BfSrbSoKJbwL>%-zaY~^IGlg6CC+*1ejUk&arG-HmTXAuMldtwpYK3VT?<8A0HAte4ROMx_x@`A^Mfu;bf3W1LobiwEzz0Ka58A3U*(ass*S2R3n$ z6}7K~A?qgPob_FvxzKoiiNVeJ7v-g=G^Eu3se2GDk_bgZp<;X{H5pwcg zR>ADyQ(%_g!9UZKA%9I4CF**hT{rjlT1pZuFAY~)h>)@CFNh$wu;63^jJpQrgu5A+ z31^+AUB|lMdUgIh#St7>v0^~)lL7Qgm_}t&f2jD`i-5%M#i}ah#pOiasWT`iFi(>H znl%EuweC8HwA-p3_8lp9@!0#t(I9a?wH2q&AQQto_Kti&;_7cQdpMn|gfKGnCl~N+ z(%I>MPSV-f;DI)CP4vMT?)Ad(F!V9%aVG61MI6nGi~OinY|bxe+bsDDxn2zYk}48f zX}_h89zi(|ypO5=xGOh^sUJ(i>IeSZ_6U{DbuEM=&K2Ip@Yznv?S%qM*_y$VffkEH z@cCsKw=JcPG$LIZQ8bN$Qs;4xvVCd<^u4F;Rz!DSJ&M)WMo!SD{;`F8AxF!I>zE*= zlJ#0y3lsoEPY&?kCT-8VbPpXIRR7Zppt_5*gzo-!(@zF!g0#X z+wymxWhlovruyTzo(ozrW#RDL#iVoBi zrN*c={v5?rj_c%XH+zUZ*ILMzk9}|}trB7+Mp| zWm~{tN1A~2yNU-a-vcu;_Btd<=mng1guD?raa5vQycpl*_?a%6jmA>U+*#p|I2EIPSVyA+mFlJKanI z@~6afj&4xMQ5MHUFvxZQtl!qqF4)o|574MF+@Ksriz$s%J8p*=&+ zQcLU^ss`_CSWV&Ie=uTtVQ!efXDvNyqh(sz@n(3NRij9k1(3}P_Go_FW*8Qg7rmYh z4Hsl2s%xm&Yr1-x)I#e1gbejTb9zBH<%GszZv85f`a+D}WfZd6Hw74Fmf^#{K)LvNdItQo9; z+unJ9c?JaFO5w^kQrwt16&X5OIPpIBXaR;nVLJ(jtgs%l9WvWgY{{9cXah0zgIJF( zxFWZC?}qva2a^F$Ti)fT=nqQ1I*rSvmai0cadtn`c)9S*^4=Q(_p&XmqOWYZd`@CW zYGlmvYPbXw-u$(@5rEFl)R{9BJD|WA^1yczfmxv()%OhnZ$*pw_m#OH^^x72(VZVE23dU1?_4<+0d zKV_~x`;X1&mbN`&r&9T=d@0^MRX&Qapzl2>#wR@hHFqXrOOCOi@c+%=lkyc179iD`yK`lMx8@Y!HRHCZEx6LzL6Z>2 zs%rUrK2@DA=ao?P1m4s$$G5NmET+f zal9##2tGbw{pAf<`NE0@k_gN`l3y}!7DNQgN<^?v%EyNEWM)UMK?VRPb(harx_T=L z$Vc8hup8@zHLZ1zy;q5eT>M&Ofar}+UfKoS5g^v;<>61>%~DNzpSvr@E{?fUMTVYK z?#r*%b!fVhp?4(IVtxMmImyL07Pp;s^AKLXNu6l5G49xFb?ek~eJvNbqS|R1T!OwU z^o5hmqFVIk;x)~2s1&v#$9cQFlB%GCGIuOXos>_*hq%}X*(-OgRP$DskCx2N=|x#+ z8~9s~MGKpyRsV8zb!-WMi~_|(ec;sEa3RW$KK_fv6nO`gN=D25OJ@L;K#OABn@l3i zI-^*Wsfm8hn%8|D$+)~}Iiw)gSAQ2gVqfmWU4Ueu_gx&e9-{n>eE{Eb`S<-$T%u-$ zXq)8eunY}ht-+ea=Jd!_M=14*gqBb5z7 zj3UkjA_nksDr{z}rnjmpWMA0A?FYVxk!}oaCk)mJ`czG<)WZ(u3BKUuHy)4L@l-Bx zEza`Ql=+~=-@tFWdI1YBMj$8&FUp({4d<|05#f*9eBL(Pl_Cb8o;Hj0i_>K>DmzRH z${Pc;TcYob)%wg`AV=__m5|FXC9TpYO|s=?6_t^tJbA1hq_)H6NRKfdkua7KuP~IuXc9TRi#PUI zz0ky<^AuRDL*#^dsGQ5@T^d-6U$@5%Sje^P${Zdz#2}KE_SWb6rOtcw{nyIfkDU7tS zd;=SJ?T!O-N#`jXG0ef=FyYte`UrFSFmi(5tot5i_W{s~9L|_f&3NvzXZTz&6(Zd>BP^kqLf>|OJ;)(N zem-T@0hPNnajO@0UtwD5^jSW6K_C4oT?mURDfb{Iln(fdVd;AY%=5(oluB{o?Qa;d zd}j75E<~!EMqC#tamk>*Y~_r-0pk?P}E2}B;@h$?kNEDw)c%?K;J zjEANu*mF&rSW0hR0a4eh6YfW;N;rLQ+`?xo=qHsXZ@_ov6nne#32YbxYkHn8Ibh=};NWd<8vL(JUX|d(}5D zvQ^ItWRt`d1uu3%;mf?`>ZSn{w}z|M-*YQEM%lEp@=nTEX{-}0uB39-IuQ`xL<#CD zNT-S?^Q?L0nCU_3I^>eTjT+x9UG}bk@PjaeV#;o^G$QqW(AcH-;v}B3^jmGOsa0q0 z;Loe<*j&Ske%6u5?hA3)9CBDcN;!}63T-ius51T4|8SG$lUypl{2r5%793J^D8{eK zdws^>upUdUGU%dQ=$)44{W~mTd#)ZS=Ds()g-^t<`sZ<}wa@Pi0!tXLI1&qC$)8pg z)>61%*v~=LAcgCE-(LsJKYDIDwHkf(ka|`fJyVw-KYHUMm?w6|^yKP)KSrkPa_bsA z5T0f@Dp>9AiWCU7$cZga;gRTHeL`yC{J{ato@fQ895UbW#4#pth~k>Rx-x0ixssTk zIpx&uV)V;}Wxbb|R3WWhq%U4QwND&nSKi!sekMWz^4h6%cux zf65n3*>$`Hbyde6uc7bv+56p#b3>x5{~Ax0AWj~g66{s$y|LmSg5|JQU6gbl(-t{q z8bd7!lU9t&nP?iG%WGZJg$+Qj$w=uh;wk8WKMNSOg%R3hY?{npG_6ME%f4OWd3)N} zZeK>3Ts9#}3Odv^Px+mEV?9fjw;!@BwH~?3t##7~W@Tk9P2AaZBL;Go6zch@-1%Yo zFSgnxq+b@t*mjI^taaIezdetZwU4jfecF+WP+#JE{$2K7t5&T>Spjyk@XF!U)gOM> zBTsZ5sT>=L{IPo!#9{FmS}#m)O+A#G%l16H2H~9S8A)Y==k4MDN_yVE$&@@Nn<{QF z+5K2gy;@%^SLTIyGFJ~-+;-=llz!SmP+#`6#UZ6{FI222WRTn2>o-~$6-+78t6;VA z251E6ul-mvII+g+ocqK$5hq89cLWymEGb0ag~5~y zhNJ=*@t(wR>X^xo@bxR!OpC1VWBh)vaZE0+1oR_Y?y)oIh%b zWm4)DFu(aSxs(8>HThWXv2w<{G^<j7s9Ztjzb2W)uMf{1 z=5}tqq2=u4$!}jB1xY^)g=nc&*$5qD+GxB=Qvpx2_zjrf<=+nP(`IU8M3$MB?+F-t z9I1K*5qO*<)*+X zycXnNK zg3aLrq(GgKhV4dG@-T<>$U29B1uDqsj!j~q9RFw4q)Xp+_1dtMW_6!si*HbHzi_dU znk{?Va*YD}>PKxoP;$(+?0Z;^V&G3DcobJ*;Ss30N0IR;lX@$GOdoCuFoH4C$)$J( z2v2)MgY7*5`&`-o-0$?M|Jd&s{mV1Ob|8LaP0vL;SJmmnw4SU=I zrML2Toy*-E^QJFZfs7BBNNcPBj~#fp5ALV%J8g_}bwo0itZ|gWUc(zPqsXiv3S^gE zwT{N&UZmnud${T}zEx}@8Yy8_S2AUZ5~O$lFfFp_+{l*9%U|vE;GZh`UaQsp{^6-I zZgOUMcvRqJ=GoY>nX?Ln>VUd8dAcbw0CdAU0Bxu-K3{VpdPYPeKBYd=T|>pxwq3gz zBC_->k|BS2MB|(Z4EBCi>8yhGKtF|t;vRMuTH-}iE2wv9sgat{ksgzd_v6`&k75g; z{w5gLEAkc$vfLM>Hmu8a!O4r}l=cd$$!Jk)#m@8-W4HqU_Jb&7l)O-AhVax}A>!Pkk z;-Jm8dPVGFO&N`cSnl71fyt(vMkb*irqUxT*&=% z1Mt^inUwS4#JI|TdiVrcHuw(PA;7HQ0wU-D0UPKXUOIVS+_M0)PR!WDRExme&HyMApVsyq zLRGOjEfJ~T;4U)&X4#tdM-c{H*E#DgBmi2&vemd7LdDN%wq@m-Yk~E-|5KT%Cu9r4aB_0zagabXJ_y&o12?CX7aW-_1jtpcfYPalZEZR zx`Le=8b#LgCWfs1>&BP)p9dY$a+XG(!?z3g^i(emF$DAs)Rq zC4y4n`QxhAuYY_L(sSP2rEt98y^TM-XX=)gF|A|;jZ$#rUP{{P&BcG0w7oQ#+c(#( zuopjr_pss}O873X($0{7Dv)NG@jq9@H9W#m_H__`8QbV^zLtCI;T6>++rMOhX!l@w zsD2$a%m>lwBAfG65WRu|5O-t}V_tSPV5?prp;akfW5Nks$_?&BVD473_H`F0nVrIO za@DiXP!~7)2F2}|wXRJi(5VS@SeGE|d^JIRI>dW5_i_<%+W*q1Il6#?_3#B}yQ~dD zCh#K%R`BKlag=FV{Gk6n(vRtUsMLt=aK7!S$^f1PX6nM@1Yd&pYHV-CT7q@)x3+mb zrrI@i=(OqBkb44A?zE8^2G2v)YNY{Ej~@N@doN+sIp0UN2Qo~X1ko7>=;V%Rs`!`U zMaj03_z%4P%!#<~D>#Q@#hmAix%vV=jcp(8G9C#hR)!Y(&Y2W&0XFOt8F zH-MnJ7F~s$vOQ49GMSc)@mWEME4IPXj|m~vc*3!DDKWs{MCvbgF-4v8D)9iv8%&Y~ zS?&y_nEU=6YVR(c50%LL*ob50G5IcIr;wgmbD30m01r*u*I=4q{{s)D#&6XFEm6D# z_dDd-tpJW8i5fw>6#3qJVfnRoCq0I)SDt$ReBS^ zD{r?dY*=yM;`?5J)U>O6bJB*#+m*Pxg69dVj=UHEKVnk@%xh}Bf8Df6 z9%c*N+s<9l9CXqwgYv0s`QD+;X@7`h7m-s_!MICfsH$`Tp34{`2sVyno$9 zZV`R))BL%7z<&<&U62LJoQ`ovwhHmc_O`u>qV^uL$qPkzBSbYiq5_;0`TJ$SQ#y_i4L)byXP z?Beo%243)6g(53(|NENcT&&6P+MMCPHo~9&wjgyua7~h){3BW7f7j`zDp-@Z>n;vY z{>#FF%iDf*!Eo)RNB^})|KlJ2EMQHzT!jVi{r5fj?iN^LPV)%J|HqnKI9A)Q$WY-h#YW0S5;x-#Qg?=s^p z?Db1_DQ;_?MI4so=ezy%X8iJ1VXnc1OI5f<}aWW1THA?Qd#RKhJMcBvmFl%*knUhU77b!U*_{p$iz;>Bg?6?Z7?yE(a;W~;G5_n<_8GpK#N|`d zKoBa8?2GA~M1Rg2zoM1hHO(+p%%qB&hO!!qPDW@kSa(+iCfH(O6@^RPF%K&fO=182 z&MMcO3;OkW`+zXq{J5m-sUY=GQ7B>5nx;ee<2v@~96KFcSY3&6o$8;5lK)z7mOFxU zIF1cHns!4P8$5b1&0;YMd_C`nY*FGa1_nXX5zkcA|D#gsA7_Llee=287gPTh zO5%1_+_4LRpiNq#$(fBMq-NEugO;3J{MF0eNe1V!jacDjCoX;61k2QT%9%RXfO4;pRK_Ph z(Z7hw0hV<{rqN;R@3t!I-8e6}FLkIE-$dj+#t+xtmH0-9I{Vt4+%WK$|4n*tW8%&) zirY5Ha~^>`+1sNjlAS)i=4nTkV&^uFiq@~HLjTXk$a+begX`F)m=u=VsLzhve-J)O zv?E=fkgM;RlQYmX%I=_VCbT!}7+>y=0J~l(VWiX8_kGi-bWa&zy2=7Nq{-xPei@K3 zvglOlTJg8lSu&TJcA3?#tiRl8CXsjz5b$j6N-N8n>VAG0u)VRw{(jC4-PFz3D$j}H z&=an>D_^8(qu5U1jjtZ$x}_=6sHAuZMkG;Qu;b30p2Tdm0`>Ib7@G#rw08pZ$YSe4 z6!;p@{x*cS7h>2h-7S=~N~z6%NF@L%45AWJfuLs?vSs|vk`#NyoAm^=`R5;)SF97b z^I!4!gs078;64WVS^G3yRn%wauVXbFV# zHudJ}tnn%)UMEC7Y^$jg0M`_I`)Lzs}>ElC=DI+^wxf2ND6_ zuAhasP@YA5)^c~@246cUS>l1Nmo@CHMu>6S9*R%YAN%WlZK|Bic78p*Q@b?CEd(2V zeWZrPppvE`vx{V#;8b#AD!P*{6v?C*qbPnhZU?M3Z3aP~AcUQArmMh|Tk~AxB7vtuLSZ}PZ9fjiCU>I`s>B1im*eGUAIxyNYsn_F9GktUNG#?ZiF%}z| z!bv{o={dC#o&g!Z(rqD%ogfp733jw6mYCqBUV)MG_>^>)Y1xDhGwaFOaF7vn;P#P9 zKrV2gc>xTMDVHpey&%&Sn5Lk?P&RcmxJOGJEBq54-@!7aKav^^SX^%-bg*Z+ZQ9Dr&KxNNCJ_ z9VV&2odI%J2=(&J@n&SJe8RzLvrupDYwc4wMy?JYmtqQdU+n{S&EYDI+gYvo^2fDP zzyI^ZG5;*tZT6EL>Dh0VwJgk(Ii+-Zl4kxK*E;pAPP&zSoVsGhn~@nion>3*+UGsk z*de7tZy)m>QzCFYY-{y0<$-KGPI%V~CrBOe@*CL9ssATYbw*2$iLTW1kQ*q(UvgKf zAT--pM3XNIuKIf)V>Bjeoj>pLy-gjkQv7+};gc=%~~Le0<%u`qb*MM3j(Mf+%-~; zF|k7ji0)V~Ogo`5=w9*r=jw7UH1;2cCWNu(zo5>rUc2Fgg=7^#D%;dsSY;TofK_o#J#g*{bG; z5yg%&koPo>Z~`usnWCfVzSBSJ-JDIAJMXegQ2mqTBAIm6;-vz@>(s`Ymy$y^=DWA( z9VYo4imN|^zE@ZdO&PnbOqfT4uqz`V2d?s#@^@|8AqmYH^TMr-IrTimDtGe@u$le6Lg7Po#Qzv2Fpx=@* z2myu&T03#);Sd>_kXfxU;1(v!Z8=DfG!1@G9RrT{4jQxfQ1(d<+f+oE*R*gvKP=&- zVacAwLHRNWCJT5|qE9D1Vu&8-%*X6Z8`X?OmgI+o3jhx4io32+af$nv$r4+w3XAYl zEu-RZz}F|rDu;CdXjCVyUsPu=5&(Mc5Vmv!lRawPg8|-rj2C7oS(@7mIjFQx5j^G# zTnwl2MLaV@itglWz?1b&2}g7{W?tu{sKAv`A<3}rjo7A+SO-?Nn;)|rtXfe}rxYSG zd`#cJEp$Aj*ub&cm&nxBBcxerm0dY#{tUF@S+Bz%&3px}O>Q@qXCNBda0RQJ zN_d`-Sv^fd3UQoA$qqv_`V>%oIS`3ND=Wx-!?993-PF+C)jrampZ(@06J^VCvGB@M zi1!Lr(55#qAmg`|DW3tPZhBzRz$+2WV9@Bf??t#Ol#e9BiXQXyua3kGWHK1%1XWQB zH^xKI>K!K`*WU0xm+XUHq*;L|JfE)&@ibo+i7?^{WzIa$+uTQ-DcAN-x7!2`HGk>m;mTX7|WGa(!j?;gQJ9VRFe>QHsHAhA~HPyqFFdhc@$( zeIc0nA~Ar;jnd(pgLf5Bq51Grw0!C?qM?vA6B9)P|W6plzjvf*$v>dT0 z{{`->Yw3C}h*Vr#-A5BnFQSg-gZfZ?Z9@37vxM`2=Z4N&ad!=Ao;7FgyXB1%+T9bP z+C=Cy*Lt7Se)5=J+hRENzJ7Wh*5I)(lm5d`>fBbX=LFtUowcOvXZ+iHzZX|7;H`Bt z0omOzx39d2pr!D>8%7)OB@O5Hm5vW4Ptr`dd`y(+>6CAF2nBv1e6ja6PD%1f*wf2W z#Tvg$N+8z1lF=#gDQodWo?tcM@VdGm$dM5-*YzYTWN~tR|T7*yr=}HH~6c$8~E>hkq%9J*lR8w}`f?%11ACBjU>SnZ4vT;;A5`)3}+2 zRE0ZsZIm+89LbBwHyQ2x1U%+F5szyZx)yS#(#4q|%jIOa0r@_-N7jC!d{oD4Z{X|W z>6vp{J78+t5SlKIWP}M|c*kCp`KeUcr;6iLH>r2TZCj0SHdqnupa~YtiagTzZ!q+h z8gX`I_+5vsB~b5q0Ek6C<6e{7jWiM}K}m?stX&4vB(QnzP?Pu)*j(FpcH_ysQS%X| zu}qSs#seEG^DSJb>al>XqvLiYgXgVbVtg?g)c8{ZG3?3phpY?r%q5Ad5@~b+Xbv)ufDMGv3Xcglf+wW1mb$q+InU z+>ItZ!!8wi4k7P_d9EfyvkVZ8(v3uE@7!EZtQ(>8kZ zb=z-|y1&X+#O*6|ZcETP+*_=0mW+|ON~ylmcIYL`8xn0_*K=fnIp79>JGo3Boo6LfqgB%L}zIi8=AwEKtCkv1g$sMWB$r&|*BzIw_t5K@G& zMUK9J6?-4Plf$8x{M##x|Ei$1dqk}l;`A`|@l)ZBM6qr68)q#?2cNS7?w$)Ov^5n6 z_lYF`jBR0w{{17CQzwVB`zxCrKy5i1X49VH7Ww;{Qn10(UX(@xpXYX~>_XoqC3RA@ z=!~>E%#~#gzGhM~)i^$@y8_$k_bkSWi&Ex;c@5*0@>NVb-nqU!RPLc~>0UlP-k09} zoY+x+XwO(?RlY*8Db5x#S)i4n^yFE3oPfQ)scnU-&Y6+fX}K?cK1J5H?+7zxVM7Rw z1P5@0F6b?L=9d^%#^OcWsoC66QaFw;aa_3aVWI@Yu0{x&@_qI(I61(mOhrj3H99=~ zRQD9ZTJWm-Lz|5!b2Kf3%v4_Ws5cPfMH)o;(Xu@nU`$KX1|0SikSP4&vftN{uZ$`W zv`Z>CJvzDq_dhoMXtKI=MP``NzFwK7hl9n!NNk|~u({4QgE-Ga%JlTJ`6cW=x>`V| zAjZVIx%B{p?eFb}q+M-wuJAe)E=r>8_kLffdhLoxV27g&MWEIQP*4u2%bE{sbD_qH zhU(|7^2*%pf*uJiy%3z5=*dOa-+8OIyVeU>hXy@EXNn%j9qixYNZRYmGcw5OoJI6UI3c)sUh zi>!zVPUL4QYxlWJ^}|T{(o>PgC2XXeXCBJfStX@(4!cbRIo1u7Tsy@oc7{&UXD3%G zS|HDtKU@zG1UcYPdtm7{WLkBm14;<-k2pUK=8b~Qr?DCJ`l4+>Iyr*v15dhXg8ah5 zN2e{e@LCgI*KcC680$-7BkP%mi!Fgd(^1lJvU^>w$p|Ff}LEiOg`jzJx;g^ zyVJ4zpckeK*gQ8>0&He^ypPeHWddjMvtLu(&O4rr3O{=|C@U}eEY-itYZg{XZ(UVb zcAANHkGRX|EqSy@v^o7fp?K)5gOq7$U>{v^yBOrys(f{^6!O}rsV``}%k>mM-v$G* zkj;p9IH9WU`t^z75W%U_t~=oNm7u9^gU)EFdiySjiEaf8EPbpaz$GA-St4q(O;WPLSPh2~Kcuc7`pa_j8EGVFBZh&t@h zzA@h7CF_sTLcL6a$+Vx^-`<)&k`-g#4vN0J@ba-;Ck->_jg5xdIdw z99b~77!v!G@UzFL_xDRQzCc^X5^E-3*);?nm!GrzcOzoS(S(>E?>oBV%a^JitL-&> ze2cp?s&m~_rl#!yiau0g`(xjFNp;zq_pYk6rn;aT;8U%j+ogJ>SMv(C-9ldRfv~tE ztsXC)ygttH!cB>e}m}dmKVxX zQ6j^u^vd`#xHkq7S(=954<|hjD@^Z2#k`#R;Ual{=6?I~5#`=32!?od!a-G0HN~MS zZ_&c^)c>*@Gm42-ph;j#X)5;4okyj*KVd|#a4P44wBjhr;a-LPSaAbqV3n213n0s? zaDwh+N$+gTH62mm52SjZ2~CqMPQ2UBj>Y3SD#8&6Rhs$ie%?^mr}V?L+<2@cf zEOn>I%WWwgz&>)lEk#vRqD@ia|6I&r*7u9XGG;-j*yH?kGGn0Y$f$o-Q(P@S;>|5` z1vo_(+jJExO1gd*$Z!}I4LszHQ}}JaRuz9-UIk$suq_t>boaTJ->!JTk{i^6X246I zLSPhY`<@WbLBrOhEd)h6KV~pb1$%8;_A7qM{SBqn<{_737ET#R<&2>Z?QQ^`1jzp3gP4utXy zx^_($vkkoS+SqZV{Ipd%(0QLC^2zV+@10N-T<6NH8a0gv8-1^WTOEw%<)D6Y=lkCd zN4IkKo}JoZ77iVen{#DnIf-YAi055VEQ?xbDI~DP_Iu_KlS?oBmC&SrA?%BU44U<`Ke3iVg@R(ER`=vD6~oD#+l?3L6T~Ir75i|ZD!PL)!SU}_c~d(%fEJg#Tel~M)7zHINvke zO4IFsns(>-V3B%sLu|r(C0<;df$H{864jb|o-+Gc2B}QK>lJ=dg!CoaoP@rk>P^~$ z4wY-!@AS@;y6~@Od&xX}d-{;%`Zo~HyiPxtz;@BB%8C82;qo(g+`@v;xk}v!=@oM^B{bQr)@2TD)_7bD0 z(ol%8+K-2Y6=f=UY6bj|7F&@Ka)y({JbbgXn~EiqXL$q0!;@bo3Ue7EoS0;oKl)y| zPWG#E_a>%xHDxTuceLxtyR)5Aqshm3^Qt3hjgd7l{2szmS`3pE z57R1z9I`s~WBrEg(&At*<*j@SKj4fSN_k5FJ{&HWY1Tf5qC?Lr)mG%`)#mJ%a!$nxR>R((#$HWokT+N6Esj zGFcy=XE@l8RU3FGix;*E*o@!g<>Z|0v8zSi<+$G$swY1G(MYBDHXj#pOlR2c>UqSN zK`KWa?G+7_lKN*>yP4176&krae#lV4Y;vm_^1e_cgg|2@PS8T_M|%B%e1DCyvECjy z1zNscv)ha|e#FldkdN_y_>1*Pc*L{6m3g4#3#%f`(v;Fo;+FD#I4MDnqm#MZ=V&?( zWgf3=r!t}qWu&j76urM>O`Ta@5dmhTB`$Yrt`vcJyse99qJzZM3G;95l`p}8 z?5`#UXtnzJe!`Qr@-M_T8;>7YyXtNsE9X~3nsU!LkU5Fv!sGvohpTYZx z7<9Fs0uy;*)9s_unb5sd4eX&0!GqV_^OsGn2eZC#JkquNk)5SxICA5uUeRdGsNmVL ze)L$u>NqHIyq-HP)wCkkJ5@O9A-*5)3)p4c?HX?rE(ZxfBfj6OAICPntJYzNfRObb zGe@jWqhUW^Ji2c(D|rV=*L?lFH;n(tbTI&_sf^z_)CDcq*;H8GIXd()5dq#Q=_Y=% zP;W<%l9Lh=m!#7KYfnUOp8xe10jvMFEU~@W9;Q+dPUnm--1S%ZT^a1Ohn(Vvt-Z_ei$G*pquP_+Y;{NhSwz71B^(Mm)tdP8$yYHdrfd?^#fYy^q4DL3hOR7&rAr33gfL?ngMr@b%R+pYe=vu4CE zq3hjL6im5RXjW*iu6|F6TS4_&NOhE-BFJFbOUu-9?vZ!*S$LUw;DTwE5cIerJkYM@yHozxO|G&od6XGevLu&A8(B*8&mc{m)N>3o??TW~nN{RpC2urh*LH(A2=H6sE^F&|)U*mdYS^{mQ7gn9 z-}C3<@GGOjX}Ntim(lTfUYzn<-l`&@dZ^F(y+Ud&)H-5o_>=dEBZ71NwW-u6gye_H zC$f)C?~bBAf*7MwbqXqGqFiVFX>-kI6NEEbN)<4Kd1#>;s+|H_@~aO(CV8Belf3yq zra9O@x~b`xq!^c=%<)Z6i)I#45A@+pm4H(8dqH+b$@l1kE8fG>I?AuQ@;Y~ou7+kM z`yGp4!(KezgE4{A?ge=wKiA@CQ-4prmH&>6s4F2UBVo_ecrK)vw;yf_YQ1j@ydsC$kp4)RJvJ#WS9n;1pFSg!b38tn5 z8aMrgX1DzU$vuxpscRTeMY`W83P_TCx${d^ z=J?C3 z)|3F#|Du7VP=BT{m?f=>)^|fz6;1A3!VOClu#IqD?X}b>1wn1jb-dCwpu*e{+As5> z;QqNLd>-l0S9IxztpFoY&C>?eB5Anvx6f0KuZUPoGUNQSl51f!Qb+ahhR~JMTfe5~ z_qsC+(>o=|r*x!{*=UJxO%lBbLXO)**(964eiT4?I_F~G-nB(-`r;Cp^|4sG&z!I! z?Om2^-PfO4*Y3_9b!ML=Lr0_9h9^BsH;>-65d&pfhwI}vS*QN0 zbxl7S-g!+`!Tqk2cjkrD?ZNSveIsf=mc8fH%_}|eI)a`ezvoZcZAGgJE6lGnJRtv3 zj*7x>mi~M5^&U@!z~}d?piEuFIctIxWK)b4iIAX!M$4`zbeI?{x7rM5$=GTAZsH=n zGe|ZzH!1m{{vmT9`G=>;cRPu$;qmmBn>tsuHtoY^f90@%UGJc;;pE zSBp5++q*2*ymX&OF1<{f7&ALfc((qk@D|^|4AW|~2je+r+~GEEamT|_@ua+|YetL* zI`-^g41wg2G@W0dx5Fnn9dfW*Y<6M#rx!r8 zy3}MqbU_wyCOnH}6y_g|{)IFOHjZNoxU=G;ffa&X(&)vE%pKgzHvB}XS$LNKngi@Y zh8c=g;R@fOA>Nhsk4wDk!l^v85LAQ#wtKc`x9#9uYruzvd}lv~eMM=j8Iz3A>9?rh zG05=}`Q|wj?g|i+Jsy|!>MT$Bfuxi}Ppby4J#s)Pjv25b>XTfsH@(Mfm*(t!TvYIj zW~^qTl|I67VFWIuDQ?&2ad;9}88jX9aQHweD1_!bVX8s@?aME&&y!k!}P*0qGK@Q@W+3 zOS+^5M3F|iq?>`Ey9ESg5EvRn7#fC-`|$2__t~%K?0fgO#rnXkc-Hf;U+@n$Lu@B8 zUv^`Zs4%WA@}Xpi(N3O}ol?s+HYfg3##`LO7IWiI88oc1Yu>jX+MZwCp@lgpmHhhD zG-O=ei>+ne-NOgACwj+sYzkQ?*=fN@N*E;QaBEVf&cSDNZwHjVZ<>LE`-y+vtdq1e zby$GT3~e&>gCjXQLA)5}*1~fP^-`T?VWG{nug(LJBRU+kaQ1T|gJRvTR}F+w1y`3? z{b%@;`LCt?lDG_W5;CedUp(~^93CNLDO0V82${scTUTIWQLE?VdoV2UVXW;Eb_EyK zUrI1?ikN`d)<2VfTDoPKq1hKADpK{L0YRB>U?};y7`gj?My!eRm7zn8<$?p>0S%g- z@++8bhaV3vLjg29q^~+7G=72;jMm%SHt|p)RjM0iqy?2dxa)x0{tezfvOb!pCbDR_ zyP6cZ6A?i($WL-io|s`dTgEpi%EbJJOnVq~k?`}ATc1|3R&dwc@n|m1)IGrCV!u4U za+<*}4&?iKcC^l*6JF;qdyc{7R5jlg5Ku-7(c+`n`ue%mO+0Z_c%LusUF+%e)#cl# zzX><!4evGo~`tVsrNu4KgNtwvMQyIR~3A#VV>aAU}XLpV1CEYL1xt@JQ9voDiZG}SQ z=vur*%V(n~Ksi=guIIc7`N{LhE3I0ww$fGFZpm5Uk3^my8zl1Z=+lZsUW~^>>#o=R zpHpAC-ltue9T!o}x1ZN_~)b}tVYMDwD| zs0MDYY|Yn;_X-*ha#0z5sw+@vnAwq(12#^3qkAtQN13xGeb4jz!-Be$0aAs7+OGb~ zM2-v|#voJ&8`i&Z(`&*eSVbl731o!3gde13Q6&(;B!pa6D!--*jMlvtXIs4x;wzz2 z&+VnIbB1Gv>8sSA(zZHl))^yd+!5}bZp3HLD)O_`Z&8HejeFR*R?^m#M|;`!=)0*M zih3$;>`Sx%=J0{TXf5=%vjhE>>!Q0PnY`)pdP_z)513~i+JHI9OO zEzUKh9E35}<#%_x5^TQ*PX|FW)m!nsk96fKN_DCpz-=p{1tz}DI8E#3pc_zi?|Z$d zl22sMSEzTKH$r}OR906PIf(n=k(eIOZ7hjpb}L|bW5QkSO>$Cs)1tS_(WY*lmr^>v zX1H{NYVLFVnWwP!*KMg~UJbLy8>h~x6^0jta{jy!X^wrMz0AVQn{4 zf)x67unRp|3~bdmyS(td)V!#FFlfo_l&ql6yUbWwotb#QeNs>?2C5sq5{75_yr&g{WDR5y* zWBN4wX-B+MKf*16B~NL1!L|y6Q>SyF#%i?Ray1f99dL&je|pnYQnXWG#^ft3-S<3E zn+%Y+puLO$-PTVe!$iYB!ytnqe5}IOaHxsXR%gLd=jSy{>%Ur6a zTk0)f?VDgE>a*ciaOc)@CxIugRegQx6o$SI_6o^m`W>aHdys!v&V(>1Ldqlpo@rLDE)r?p~s%aveaV-_K-ae`;pUJnZ8Fl_q4<$ zS9%XLS(+BE)#0*v)#$d8`;iM-F#K7RQ=k}WPixlu;S)|p%a`SiTS&sewFjb&9FsmQ zN-^k7*Hxe^nAOpUrls?|>RX;Qk+GokY=owl^uv!4Ynqq4%IxV`oM)i<#O&3AUq5R% zQ>R2y1c{~nSOQVwsVXoZh#fDs;GpIQC3@hDJi}t`W7o17d(KRc_%i!cezD^zv>zT5 zO*WT0465aCH0A|hJ2b8YGr&Sc)@U{3-hLOQF$|K z{9r6>a9umu?-U@|pRe?98OfdMA{1_XQb$5Ry<=%>usOyPrqzp*2^&P4rzZ#<8L53+)Ay&4~KJ>bP8Jyn~s<%TYBh^W`Dfp!_2x;bubYU zIV+^N80NuKn(=>(*6|Y*q^CTYbuc?^20B!1C*5%>rx~Z)@_U$*PEH?B z;?(mNQ~I6}Dmv_wilmORD|8n49K$kjFiZRt3!z53(9YT7st^vt#`o9xrZin=!c7|= z!w4zA+}}|}e<0%2eynt(1UEtR24Cff3`Z_&ra0*USKCK+A+-kRL??l_9J?^_WUzq*SQ4ibsc zHaJu8^7*t5Eb-p(RyG#u9+bz#cE1l`sNCiO-es!f!VYNO6NQA(*wV=x`+S^yOMv3A&tq=7f`(@;q z3f$1~ugMFsJgO;*jZVUtRlP{@I#?fvk&p(G8-7GApW6SKFO^fb@nwZuh&u`gB~(Ya z!m`wCD_78UUKOnbP}^KRKKxsNk|Tl3QXjc=ugO5l5Ya zC$}1cD2)9#7TsbzJv}wcuifZSgVDKydiQ);l zvj+yYCk=jeU1e6Rr`ZNqMSPMBC~#V$<2EI zJqSDRNVok6K*HfTD|V#VV$Yg_F&gdyGw^u#g>JsIHPP4$RdJC6qKOdd zgRj!MUcia?@~gDqRJQbsU^+QUpi<^zF}B2{UheBPi`6cVOY%mwGmr;%+Fc)`nRXT| zfqXP6F(9DbGMWYN61bJ&CUW3eURD14LVy&TnHc)XX?{3x&~_j2*uIDb&S4peihto; zbfDz7W7RCyLi#Z*oa1dT(V3{0>A78wNC;QU(~4#Pm~S?|Xmew*u24R0+4F>7Ho{li*jgMk1ZZF+&R*4qY_2|u@gh^P>~EV3 zyY?Pm$MIKeeOk7w>Giu;I{IjcNL(wu+uj%Y1K#A3=^px0 zvBBxKTRXA%8$zlRHGEc@PrUgG=Oncr>`A1HPTxZ$pXqeXsy0Ja$X}0#B|IqI<28S$ zVY_I!reXg1fX&YbwBxp5bRd*qmZt&zwO% z_MIe@kX_-&4N^-E7a6j@J?a{O3*+J$jA6X1p(6oZB{)h$FJU$???g`D&FAdA#a~)^ zfpt4LfAN<+I#`boHJ(wlVe&3$EYtE>Lh_Qo2s_@e)*EiqJgMT<;9d@ zfY0wq=~(?)qGqf#_*R(rb*@)tmC@@|7|!+`tI@mMr6@VNM)k+@LuU}%ndgs8W^#HM zZicIMGC1|KKbc8rzbOgh#VEFG4D2=MO1O_+6l8A4!f@mh@hJraTRle8g$rgoq$I<3 z9$j>7u;`&$(6n0Y7e%Pa{Lm|^B#3^QbL!x(&kwfQdas$D@B$oGhxZCjiX;6=+?i%H zwn<@=rZ;Va>Swt$IIB&3TBC$>ciY&Hw<|q|E4Gb!_#_{ZEOlqOt|{+d`RuIYD0jVh z|4v3N8S8I&x)=#wBH8Ae^=b*`6nIgHd6Q6;=F2>)(F-@pM&)x! z(ClPMh_Xt+p;lm3XqMGjMFKF;fZQz-0;jcz4#UzO3=S+@oEVAyd!G9PEa(opi_hI> z&+sMhM-S-h9XI&sibFa4O3knWnKo=J5^$L~nE26+ye!fbQ-w731p}^5NvFr3@CzEx zHF%z07$`GN8EK5p_I?h2NMoISo(N#J^3Hr-F@FxGj&PodMe}36&$>f7SDZ{d5~s#7 z_#hqn;)o{N*l{>&^ApK<$UOotT5k5mX4;`Avb2rpBgM$(M?+I`MrvFJ*&)Ftk4ht? z=}_*;8lg6M>?97S3LvCvt!Ma$*%q72X<$%a*@ph$p;Z7-dX4B?X$iJw@qp61-_wy8 zPI>$yvs|}^Il)OeON0P)sP~|r1D~m8Y^#@!IbC&b9U(|}aLEut>NsoUdd$!MR*AD^ zNWc%|)qUACQ7RhgTi}}fP1TX-(rk-Yv6@ZF>>d&HtGE_0L_?@Xc-!@Mj~O7q%>XLkT|LeaOr@&$)>?P-y zi=g1MVI&lXi}dE3xr4Vx>$I^VCs+XcHa@!Aj)JP7S?YIOtvB=dvxAbEjQBLNL=W=W z4i1%U1yXPWrDqMPjtxv&nhG_i?ZcL<^7oNvjR0poR?b+py(J0ZS}C3=Sy`WJ!9cXq z+2!a7^ZFc}K%Rf+j4?0Osm;tZFa2z#Thrnl#ydqsA@t^*-SuyVmIr&^vZY0xAEs_5 zyh8aS)=SaHup4OT*}81+MDe;KxvO8T;ZUcE_a%(Wm91%2yyCL0%{nhhpm&^}318iH z3_w5$HAfz*OmMKKlq=qU9wdqA!)A06yyFStusQG(Ln+Y%dn~!2P5g~)yopE#BY0v2 zfj2JbAffe*kZ#~nD8u(qpipD&$?;D%andWpNd@*Q1D_beKQeiAGTiEUe(GX@W|YRH zKtlfXbqw%PQ*HMYAtw*i5fT5C@aVL{(nsmaXF(I^22-|{5G(#s&-T%0TRCq{qc75b zd+m%1)PXOp&M;)AvU)@Fwuc4drM9)Vl=nk>uvQ_+@VwV^@6|W;4m==pSnSY@ko$%g zT3a;2+-;Y8;!1aIM?{eeZY~IX-Im;7azoB2>;{p#)~1{XE_a;g1d zln?YWQSyLevC`p>vCiF* zt$^PrBb+SoaKGA@(W&9iiT3FyG3TWaf3C+? zBWb^i(_x6DUk=Gip_L9q@y?(CRCYy>OurP6!Pq->4iNrnH2Zk@{@=igH&bjlx;lh@ z1b63aL%>S#&Pacs&*%KEj$5+Mw^{4+h$t8gLvfI$XBMr&)&)@s^2k&rD*q2@frKJ4x$NnD&1`guer8`b~*82;rv6ppQW*P~(98J6_-zV_dzCI5A= zw9Mc_qHrv4miIq@@82CbHQddj-*#zQF6I6Yp0suVUUo4ue$f? z8vp-qkv1^-P8K~Eb8-sKe{db`tIyv?LWj0K)U^KpZ&3o3hfYGqK=ILIqW|PNhN8iG zN}8dz^7|t@{9`HoTv|@Ey+wlor7=C`R;!E5e85v zy1e4B+!H(uFO$cTM+!3;V_(fbVHU zl5%qi6pFXB(D_Hj`5bR202Izf*_?J?fpYrzWCE9=-q{g?JAp$R)_3vZKUiWCs77~W z-6El2iaD@r+6B5^J&jiwxWq9)R9Y6G1q}9dT76R#(;i6@Pz&W%0H{aNA!6S6#*Uhu z`Z@EzdMiV7LkfHsX-Q{1;Ka--JU|mwq6npqk?@1Dx_!TPv6vk=y zeVpWCWhiQHAlYW8*_qJre`!t1ruPz%JQb>~Mje!0|C9Yd0<|0`4_5}>6GfVD7EVxY z*5}^w84Pts6YZv&HRFK|#q;TU+mgw&67~FI#ehq}!f#xoKMrIu086B03q{TbUJ| znmIX9ciN6K{OgA^M2C<_>&2!Hw|p;@KVM&n9IbTnWX}rkMZ3>lC@K030kyDvPvrgF z4DS8c*S~Xlr5nCHn{#|UY>>J$G9gcJs*+UbQTeWzxVky7^!m&@p(-B8&ayxs6#XuddD=Qv}Vz zKIpajdagIAK6ir?kei>trwdXV9vzWhj< zpgrCJ0BPJb2YP;?^D2YzF+H!#1VlXkG zdSj=!q+I@9-_WfiKBI9(LF>&+v8b8hjAb>Q!y4h0!Sx^aS zdm5LYqdY1n&t_aL5kdR?#}96kN5^f~mlX?!af#@`9iCR&ty}!-b6uzUrklw;`#q$Y zh~n##cz01Bq&$RdT-`9ojR?+rwq6hn_=DYVdph2Fu|c$)qkcWiGtGUDvh3~0%ZKDE zZ2I2RYs*!{)B+d;{Z^U&#ZgW6Gg`XMj78&drTX7Pw3S$=WtCQRdw<~V1r3jYq1}(aDf8UP-^U&36Z3j~xFF&Mg ze6vMTLz55M~3d(Ko=fh?)^v*(g z%$^UWd%)=ce?g(F(0-@|*zc5u48`)5+@|C=0NS@!lwjQ00x+!f?Lru6c|~eB&#gyX z{rP=%IO9Z#b`n@k3c$N(!o#$ApMqr1cx&zzwM95gn(qD}crvYS8U9 zMozOiwHkx6YP)q_>*?AQzIW%6*cZE9$2MDKDnZ?m7f&!fi{3`1Ew6r-vbK=nG4KCo zUgBT5#@H~FWM@ME^(o{Kk5a%c29VsI>CD8t3C7a{n)r`UZ}1iw1N^Nxz5~iT6T65( zWGDUQ(Tt-CaRvp!U(ZNb{ke1)!36G84KW?njHk|0H$Zxg0>v?y5y1Z^fWHQTz%(RE zkVrsp=EwLXy3gdZC>0w8kL|Pu7erZy;WjAs*MizsVoY|TCFb!v%9TExbqkG3+y=%| zEM+sbV-Awxgp|JqrFdaT6aPPw9#Eu!2juAJr9WAz1@yy>KXwr?!EQ;;#XQ8ZsEQ($ z`)LCay9Ij^RKl=KkLOVHC@%G%x#5DMNwmugug%|T1KF?;TR1er?3s>yg7#2-w`a@YS=ywp&zs4BV;*87G^f1AuUZeFD^DhtOreTaHaV?_E5rn$*?KuT=Yl z+ydo{c<$B<_3;7~ZXj6sP5)X7EEhuW1-b%?r!DJWQhf`mlu=2zM~g8jfS@!h&~;3@ zzF2#Fx)P^STV1^p&9qwHw4+>$3DCMet81A7FpDQR_OoZ@g96GlbEShgQSuDn#TMAj zHR}S=0os7HOX*Q}Zo@CN@WS!1)MS$&^1Ypw;|zBzt!ycbfdr1Eomi*Aou;QD>6hon zwhzc^1zpeH69YHY2DyaAM14mk7q_Ptffm@X4}U#yV8E*xTIX1Ai%js zCNfebav1yXgtZ7%mo(#cuxmGi}Qc}@& z?{K0gD(Tx66vPS?1WBRs3*+1FtaxYQ*n_**mUVbBx_G{7VbP(!(}7iMLB+X}g@?5s>|S{gJScFk@`y2SrpMjJ3DrX4_Z=U=C89 z7K?tT&<)C=qBdNYEy(0XGu$^W8(x%OA*tIE+qB)NFz0nleOhrgl78GvIbIPE(3h9} z8>ysNs;TgG2lE=Q@(8~E*y_;3TMg-dNP1D%-gL_)%B2RB7Vg)qt!B+@(teI7^}+&^ zXS^?kv+G_oT`|S`Pkl~Gb&Pb{ckQBeoNL}UY4NT$(6s|nEcy;5>OFcdxBp}o^Zdmu z=I&s>?Vu8?pg8x1(+JBcs7VHEkWhO&8|J-7C&Jgm7_HwG5;;k}o;@s%7p`a(GMcWlXJ}(vjRfpr+*Dx1 zY=c42IiD=7O~P$t9!)RmbGS>bk}jl_wGz_4Z`%Ftt~`nd%wulz@b{Dkn1(qJP}i#x z0MMy!Ce35_(jWh%4^x?}6;>a49!*K^n|7Ks2K+eR36Lmz%)|RhojtAkCW$c;XVWf+ zSG&#BJzGx^;aS+%=_5GXZCEqL*1a3v(F-`dhz{Yn#HlYDkwG5@J8vD|stJldsR=`M z1612zwY7FrgWA0wb6ziiHbUykoRJ@LVt?ag()ZYIrEqK?%&yPIvBS#(8r=P=6+r#) zJ&D=fL~!qQI03NP`ySC*c5JaV?RG{5E8G|tY*k{4v~zu=!A4NLQq1l!M7`nqN?Xj9 zry2Uu!IN$$)UhQ$;}l1XDH0-Lrm0WO=NPU=OGZ+(}7#h1zs)))8;FtlG zVn>cdUyOGN34erDX?+83?GImkq>_urnpU5w+gd4_JK700xjX~sb0@2+nH#N_pLfup z<1UBdaU-)Ak1jhlA_LE6o7!GRI`5p3ieNdJ;&%u?iQ!bziMns=x7b2}!9?s_xMPEC z4fS~xuvei`XfN>Dk8O}l*-)!oCwqSV%I#1*?4HA1^Xc7|q00vF)tx8JTtB6Her1g= z9JW-{bT&uE;|ta5HJyddOrYnfb|YFbz4Xp^vZaj^K><6Uo1Yw*l};HdO#G%RtfiDC zWj!u2;G~xV)Ftf}8Eq1#VQzbX1ic*~Ptzs;=!XYBm#mUsw0$T8_SZzwRFT7P(r1@p zZaIs0yW~)WwEfnuN?M5)-0OR{_^9qXbGM!ZUG>^bwzh0N6H|Qld+wpX{qm&M*O#_5 zz4~vaHPMJtE3dLO)8;5)?%_KW0+aN9^;=_Y0XPt2emCcHrMt@h4&)1$7&35RBZPb= z&=e8><>G%}A{=hMG!I@$oH&4MHfONg=eO9arac+M7$~;PGIQrdM~q9}4WIg5x!HI; zcm4)?-3FTPzdNKux|p;z`R*9*o(1ifR3`@IqgguX^(gtWVgkv|l829DFgY^;ii4B9 zVuV$kT>rBHe+$SYboEa$z87lJCxos3fOc0#SGpB%-{k2 zLHWBu)tB?vmzh-J{-CNrYEx@vcPiP$@TJcQ)O?@U*0KxE^wV+;WYT@eSMpR@cUZEj z79`EolO+LwbSR(EcClaMyL)wx8agptTrR+lAk&Gn_wtDXIlknLu@$`3QlQ>$idW9X zVQ{?GTBGb;m&tG+O@N)SY%s)`R@F)OzMdR!9lD){dBWa=xfH@oon9vHA%~y>twML`W z&>33OCB}lz!vpyPIxc~c++WP$4=Rf*bvHczt5f}tEzpmPyVI3C{)Uea(LC$23tx!W z|0_dzT9s3;47oSom$l3A@H0a9^r?37OKS@?uaZXY%AnaVGnvbTe2}Ptu|RhfQti zc}tIY#hHIoBfs4bS-{-iG!|3a!RU7zw7DCg9OTwAr*U~F&%e{VrjoZ?wAY-B)qa!M z8FmP-oqdhWXH|i^q0oFMC`ER*>iXak(P0u$tpo_=m^Iaz+`C;rn~F%UTjX_-=T`Mh zm?=I{rg=Hfl6~U%&fPfp5UXTBIQh++AHy>Z{p@xJ@T!NtcOJ> zy*4vnAa?Lt!B5Dir3-fmN(`~*bXODj&U*s5HymXY$J>RU{_s$;LD;^@7rnsJs@w?g z!++HCtcu@$nt1>PAuFwhWj)atomcOPB71MD>z2R{{H8eZW1Ld1>hr2@En*nuw8MxN!E4;@ibYv?uV!k1*z43$&gA0-U?`BT$NLyDm zIy($4fj{UyU3ST$iKb%>?_Cq%Vm5?oQH5#|q>ZXAI#q3%06IqJob$V8Eu)V|e|LC5 z)z1`l=Q3|8B8Bj&VbXd za?ZQ1_OarGy~w9Ji-N;*NB32?Oqll+Y!dl1GHny2E&&sJVvv{!%L`wXD<#YKxv7@NH@|A*+6PFcI_6@EWxwlEJRvpq9V6|rZ;UPI>R6>`PFRJ<4j^-kJAxZLL+)>O9?Fotz4vu8^)e z{uNYz$OqYO(ypIkmR~3MaP(0RCmMA%H{0+ zq9Nr*Tcm3CYa^fekRf!?0=tP~w|gGjm2;il51O%3pIGN`esT9q@N0hqZx*;9a&fhP zx;6GWGu3~}fOJ4Wrtq}E^sP&_O0yi>vU{#$+lIIa zk1#@S+n6{PVf18@FL+)DAN#=`eR)o%+OcpSJ@~+B5xXZ{)5)Mnor!vdUksDO*lYE^ zpS|wt=hs=)Vl|C;-8ifyLaf?BcUXIXG~Ib%pjF2DvPLHz4XfZn|XmTW3~w0F8bNy>I)|p42uFyobdhZ2ho&0gr#5*$NfQu0K!@6^4NaIeiifH zAXiB{``z*)TBU>>He%hZc*@F+AdzGEK}5V0cGp{M*i=UrhWSQqkvHO+P%F19L0>KR z-OZ^gmr-09cJoU4dB8-~xo^P+LZ`syw0xf3V$K9O#Z1;;u31tyGHNdjA#nlzz=adjl_;_0ce5jVqVencQ2;T+VcK9oagf zfjk(AXNyBxn4EVLsQ@lIb_irvfg69!;r{Fw`z2wBDaQ;~decd~6O`c6Xufrih_3R> zaN@(A-z)GV#FyKFbw)>*?rzb~uU!-yzJw5S=y9q}_?A;uT_sex^kJwDl4CSh2|3I6 zE6qedH&kkPKhz_;@oHRZe#%%WM8YRXK}I-=ibw!nW~eWrw^)0wI<&x)YvPsT2xVTR z-0WXu=*U6fP>gNcs=oU`O>@9jn9bVVM05%FuD4LQds)*+jCNi~BWr$Y^KVJ$te+fBjy7*5jhTNVVxi_|Nr+ z#;P?r<{Mgkj&)UJnh9Y}U2EJrz7HZP->G4r_Bjjnezf|l<3}95T+5W80hjH{Rr9w^ zS#ZZgzE$X*HQs_1jAULbj7*pMt<`ZRJL3nIFhc2TaZ3s4oT7Cl8?bN7w4MT}J^pr; z4Xo7K2WOiN4wF36|jYd z|JkTve-D8$$r4Vx!jm5@(pXN2HB7jN3(E}bwr@box9}LbSzp{GkcaVIpiyZHWzm zrO89RCU^VxnZI&;hfHS9(fMa?NOm={PLE5o=+O*WuEwOlBuL0Kh zi*GC`?#Ub;z9F>dj{yb3v;n4H#gV@XpjWyId z-Er`#h0WTxy|-mJ{esORZtI1%wFYJOS6|)1nWqn2+Nb*ah(Kb4DTXeeL^Mh7{5IMY zkn7khvm!!N(>r1hE+t4r)EYK?4K{g;1kEMo*e90UU8t`b^eA&Q3Y1$9&X2Eu&u|-O z2RY7L_u2cFrdZ|yc{Cv3&ku@JR0Uq>u(0Q@MC`#QbYj&+|0^v|W>KhEknLmWpesXq zLjHiI#VQ8V@{|09x3-00_>?%rk(OnQ0_zx&l+pXS)E}sYpL6uR&v{}^ex-D8rH=Ep z2HCsdk~)ZuA^-9=VRN*8>ldXsf?cM8r*!SL1rqi+YJf9r5_2b@S4tVHIKXMqoTc~P zx5kl#fMyBX7jzC#J7;?!SXL=V5AXnErevGFuD2B?RWZ=A{LVyjS#H6-ZDrQS^Gmk! zKgG}Rciajzn(cG)GPUoVEKt!#Ty0GnTtuB!FxsDAIiphRKioitOqkfSsXkD)_q_gi zj0@&pgX+Q>~W_qO78JCr$%J@v&of3hM4C zHxcIh8u8*XwSjK)$yuI@#WITtdLtK!pAre5b^EseRr0sVEMJC%P{AuyoHNSO{CAK* zI?$_yqsp@ihe^ zxzn=tV6;smeemAUuG35|)jobLACdg~SK_J$84Q1)Ov<*POVQAM zaHe*IW<#~QO+R^7Brs)bVf;7cHIT9t>#aD8pyzJZ=jdjIitv!9u$21KeM7{X6mGcv zMJ`U{3&t1@ZIe}ocqY~E6mnRrA_KfZij6LvQ96txO&Q~Mo00AVE8nsQv#uP_vU{>qzN1qSQc?7x!iQa=p%8ACf@L83+*Pc?aPf?Ptg>tm3UfC&rGR53^ZWd{=xpj|1;o z7Q74Y2}>vIc?JgBTgr$>tX*l0_v&Q7LkAC4u5n2t1+Hzu+=$kxAxWuWnJORlqq+tf zRSUoJ!W2H*5NliwjUa2u^d7YO6zC#l9p#pSYiKMBJlnTx7Vx#gCDsTpDR#u}J7;Zr z#Nv^1KTXG_*!~fw)~C9fiMIz^bT~(@E`HWD8(_yVdTl)osyRb0an^3>PfFL0Rp9f?h6P6-`DZLHb{NlMYI=IWZ@_g7<+EAFbrjS#9ZJh^@q@gDu9SLxc^j9?1_L|9Um$=v*S;SJe5<=IBw z=SsDxq`pZX$avJnJg@!cKj+&&M4ZUPs>nC-gUD7EKI1Pq?uc7|syjqsH{U#OiX!(1 z%0spZbiC=eVUhL4p#$|pfQv_a+~CB$wpu1NcPG9FFHqei)P)WEM&^z0=XUciH2+8R zVwRr3kWtOp%+2r3O8?k1#PfA*|7&>fXhG@NdnzqNy3S-i@1mtGjE6OkSn9cnv&l*` z1v=9BBmUZZse$gO2@+U15sUJ@uf6_=GK^~Ty;oaT_F&5DvY3fCu2Pl|H;!%_@?LF_ z>`|B9AsEfe1EnutTw16`@T)O}q6*Bk7#0JE4k>g824LJ&R8x3E8s%R!{%~Qlu>E5o z?gN?*b1Qjr@c009@$p)vQs&%vB_~U@!sd;k= zoJ-2+rETo$kjJ{$ld8B}xY6tjjd0a^j`*Bms0?DNB+2MIp1d0aV9O2@$-wyp%X#FA zkKU+fZPvxbu_#YtgcJYz5q41I#EZn8*IKl-7uigvBZ>co8)tcmn zEaob-+9~j{O=|Eg5IJ4Zr~|D!!|fj=oz};77!Y*tp8i<#_F2HkIwAg?{8UZkEh$Qz zD(BhfH|tw74Vs_oUahk8*s7;gpE^b}37kMc=#SO#xsrcwx8igRZ9c# z28(`u%i4*DegeBthMUuXO-i_1XHW#54z6G8caJi;HOWRTi$#1I4xZO0`JlA09>3A=u`E_e-iz*AA*|xXGS|9&R9^MpnZ@ z43is61J~0|A9=D~!!-4C;TlqlHU>qYnE#+YeUV+8k5#t>ZUE*;K3UlpzK_> z_#EeAcyeARm}rZ#1okX&gI4h8SyG30^NR*FYCe}d0-IvkF^_{}nR8_()#`^Mzr%`L zL4PZSX7zgxSAeyYC@UERIaV~bSqg7zG18qRKmC; z0lShHjP34QGofipBj}`Oz4@)zp}o|l0iK0@?mN-4<@oiYVV?^On|1s&ey&o#pDzLn z!&%Kb3A5{uG+JH!CXMFMEsl@p&{pQAaO8A(6+!9MsU4uJ8Kz$Re_5=-RR7IlUAZ`I zGoNjQc+*7UmJVa4JarCotZO5vJYD1W4@8t7=rRHV>%f?|J&>Rm63{_vmg)>7xdtL| zPmbp0DmV8q3aOz}-DhS-3I+z4#<3t@J=nsU?pW9alb4EIlUc#sUEDWHrK5hJ?a;~@ z&D)J`0z1GZ#EMpG{MngL@lVbuhvx08d6yF5p>KmYwno%~E;_(=vEcjR^>Li~M!80! zUknA`_O3YfE}0*?IT&8sl=TeDEpx;e&R4^>y{P2e}R4Z zAb?>`eQ5hy{(<-Z)dCQ*)yV>lv`yPmx2Rq*U>9ct*I^O$*rvK1PB8W=gzmKJ(OFqF z<7}R-5N&C}uRDR>+c{R~8>-(ecR2$kCc|bP1h10+zL?}W`%;-mcL&W@ zUeBdU$OU9v??OES-)zA&OA)TC!#-sOnU4_O+i6qPe&;WQ9$fjIu9A-b3u$%h-t8x^ z-r|t>d>O9Qu8Fkz>S@@N^W^lEpK$I%s09^FO2Jwk+Rs#?P`R6eAtZ zRc60xF_rBZ2$_<}@Bb|7lc?M%_no=C=5=BGfKveGO)_b+J8DCIJY|(BckXVFfm>tH z;KDJPDr&GgR;NUndHx+`qrr1>=?l^JRb$bSwS60`J1(jWa@#WCrYU%Zj~0 z66{Oe*Mzu1vV+u{f;_8x^Uu%4&ep6xL{EHWhCHX6j!aP_2%Y%JW2RX9t{$L;68Xz! ze!n^LJx)K|n9{&E@72GccLKCGUcjbpBXhR&$EkfiCbiFg9DhfLWu4o!L( zKjMZOY6D8XRK$CQr_l2dK041Fnh@Gy*)#vR7p(dfXG!unkyq(9op0OrZ_BeVAU7ri znm*{)I%CJ<)A*fan~Bu3fR#(?7MNqxufV;rq+O<`q_2H`%>6VhRay2Q>(}s6c!1HP zeU&o~zPp&01k87COxwVm>;A036W_wX&6f^}bjk0Y=;(HZvJXs6wtsHbuitJg^qhYK z=5YixVw)UiH+t}uL2Ydg)Th6573LlOLBw^lrqda+>$I)FS)%Ab4+Sh-+xk{!&Tmc3 zAVvFPGzPEJ+MzAcAJYN~KT-*Rdb(bi{F2{ux2AQ+*w@w6Z{?%MCN^KuvpoRVxhGRA zi2cSMx;4g}8Wa2qsbu<4l@^ov0khvWVAY5hpP4^{RJnk>sMwArd@!4t&$Z28p3kW(1SO_7nnIpnHI_N)EZFTXrfg+-5|D=6Xq|nEuAkveV%^5Qs_6VEcL%;c{`zw)tyv`L*3=lxBS&5)knd^BDPs zkI__hJc+!Z|kT0fup*6R_t zz^|LV`v@qsT7Ag%qfrvO(uYb>gQ?)QfIh_cnLe8I06{1UC)Q}1*p@5)T0q&%@`sOZ z@zI0$`zqqpV9Y$1SgbjJA372=PATulI^1nH?Aty>*}{#a344Q#0bAFaiT^2=0onW&{rWl4eXjmM?MpnEvQhC+|CYP9HS&TK5E1Cj{U<;aI$O(Jl2NA1_#WK**{D3|9`76o0q!^p3X{Z_KW z;ccXGB^8n#d-nCF%b0}(~PE_@%h(K8rY7oZBO=X3f@DD^&Dc4!?@`)3gey}Xs{R!*D3G?U{w9t=!S8%5RCkR-Z|iM!}`mZ zu5(L0Gip}$y=r8 zX@2tA&fQm!wM-PFuTXND55J_74jcUGynJ{I_e0fl0yqsP3P6m%2ddT^R(@`;VujxO-3UO zPZg(fylB!rA=clu0K#y?=L63IUj9&7y;eL3B68bsuQz*&4bCPO+t=?uH~zF=q_B(l z48JAd`rFt9KT?cv5D&@~;*#@@!ZR#+e*+CU%BE7_)I;#%Hi~2I1^{1xtyS}V>gvkCMQ)CXN?DgUY*LQYx{nDrk4cJw;z2ElwKn! zmr{php*t=dQ4*sj(5<=nQM(os^U}5S!Kh*|EKbjml%49G*(<| z`?E8Y5OZInQ6k$EzSD7bn?&OzdxOzoxl3jS=tmyf4Zj);&DBg{o$1!ig;@@rD^FxV^*qNOIQ?Nq#sW+c=h*w#Q8 zOPZn%y}*V=*pw09iTOY5y=72bU$^cX2*H8{4^Dyv2<~pdHMnbVm&PTy1b266+}+*X zo#5{7ck$onoPGDZ&#rpE-m3eFqM@2*b%L|#0 zr4YLN2&A7M?4CN*NE7P!=X8$|>uTRfrwcBXOSrnzy1e!3Q^nVdAT{Iyi{x3g&*k^W z)V3Dh$ueNK=&#E~jbtJVkw)KFx7TYQxAZm?TY)0v?-XHl4pP2Vp7}!6zEzgS0VnnN zZFUVe-h<*b@d0<3mGrlsW0JSI^d4|Ti%L;nA6EO;?V{f#2bLf4S0Kft~nq>{@q z{#o+PY@*pO?RZ}?lHRUb=rRDb*#F*nf;&*zK2h5AYq{&KWsUTJq2>{b#ZsOzDIWTs zG$dB6-Olg?7#Ky1k*1)1^N;33fOA@DYZoG4UZqj1mj!xh&)BmhPD)yH%uZn;`~aK> zdv?!AQZE$*RgTFzYwFt<0r4Itulq}-60Hlgdd^r|Wvvt`6?w z5DN&ycQ=Dgbjq}>$Xg8gTs8#6^jtOSu@(wdf~As6=i=d+5qrOSRrCD?dRUNzHL~Ju zXHRy6C3_GZF7&u8mI#mc!7Nzyl8P3eh$Vn^^H3=3YQ82|55N7E*Gc>DBqq&E zY5fMgYKtMY=F5*qe>!(mYa*267Xv^AckyKBQ`01+a%Ko%FPS70$YBne+1l@S_#g}7 z-=()ryPnd3qq?MAeIBm2+g`b8gK1e1qG>8X8iV;zxV1qR_B-43D zlrg@Qm9s2U*DY^+lrZ6x?T)2YX3?pjBjU9lCo&oToB8|PDGExI$OTGoqD@Cfp4yOd zu7J5HYATBx6BN3dsy9dy(Fg|^(yrW|+EQ@O25{w(#cLJJW=I8@C2P^#dR+q?>3=q* zyj0NoQ4SHljvxnDVrjdG5fm%S2qO^Yt%xDv(ed?$5KsdfT0HJFVQr69;F^d>`Ncw= zl@)+@Zr)87$Txmdu`9J|dy#9f-4^}RdniUUgDM)&lS;!m>rJIv8G|4z0Ss=%t%v_Dw1gT0}+P?iO6 z3|6}U89D&sL{LyK$$*;g z8b5)k8&07zGpyJ_#-FI79Z-`3uc(x(Lj|Ro47yyXffx`xo)uq4-2SDB1AM!TDmoL) zW@aD*AV`4E<(nob9>}^M0JQhpfY}TO{CB;Joe?TP^ArI@EDXLmH3BS@I_)mcf1Q5) z53G}uIMK+r4X)hI?*IO$|1Xaa2|(_DZBC2-myPXzqLAhRzHby7-sk`FO#dBYB$5O0 zNFnivxqs`)e`3jYaDBrRt0Dv!z@^C=l-}xBk zApJvXas($;$6(QF#Q=3;f0odnUvmJ^1#oUr!j*9UvR6d$a3j9AprikHKDR`WkOe@- z6Hw`Vrguwldpxddd|pZhY$~BtbpxvV+)`V41gx6OVRDjd4W9m3It$Gajnk} z65E5Z*YvNE|HH!nFYoYgEC5gc0%$<(7~JORRDs~8Qco|)4>_*k1aBFV%3MIIC>hW% z1KMOr`-e$PCX~+)xAFh`R`cJN@_&D%J&dnVt{b1*MWNCDgan}AB8wOdV}N9WC;-y~ z66CZ@l$z&G5M&PA+S*0>(F`Y(5KJ5RnEB_PJ{LkZ&D5A{4^>3`x zAIy>M_HbVDiL9Qjne)w>kAtF=R;7MTgMF^oF@%bXLcy4^z_BC>*ejE#{`J`UU#`vn zxqiRR-l{i80Lk5-&-6K(uh1;dTPj|tB2{Ox!Y}|}{7Ar%?tpo>@QK%T4uY0>qFW?5 zHt+W&RIX^B0LJ$WISiDXxd0;k@AKcXzQE_B$FA~6AZ6J@@eA>_LkE7-G=ZnodG);= z@myPq7?L=N(m26LxPLy0I5jp{{N627I z1G|NRhHdf&EMMgX2 z*c@}UcoGlea$%@I1_%*ptYO>;olCMn>9Na%<)JWyI|y4XROVK@T$^$@{Us_RWSsg- zEcgvj{EyNGh7(yw5}}MdZGZ9VSEQFetp7`Si7JXBnhpC)bmYDl(yILrZK2f64L(PA z3LMP$_Ps8$LlPyaplYMBM8W;Z9%*Y713XsC@2StH-?wQa(t#w!^rWJ#tWn^ii4+K5 zA6x-_&!1)F={YyYoEp)s#4Dx-M>T?SJq7-PSwMu}gX zRu0ifppuEg?3;Q2V7%_GF`oFs@w6FEDG0Ddc}s(Fpj7r^jNWiu@J_l@LD$!CD3*Zv zYUgO2CCl$B8GlL99sIry0LoaB7S11r@>$ z>9ISobN=bLRv#>VFkZQYu$gh07mBNR#=sh%31BfxPKrAd(CA{854^W{Bk8=f-Otkn zQ=Ney`L{M)t(#87%=&*5UqLXY($n5Y?n<;hKTsJMmKZ1-H6;P4t1w`q4L&Sw8vUlk zbcyvf>f>L-4)1&WtNx#gQNYFUJ0@&>cEHkq@`6`y5XWddu3hRst&d0<*UZ1n+v=8` z6xW2!IY@jhjHNOG;#f?pih=I+fIr@(TH~)Eao6iYMTIJ*l7!sneQ^4ux>akzM%zX( z)!wS>X?9U*BcM3Z*8?KC;q~bfS+}?$0yen=yNXJoSahY4a}2{D5no)} z{W_P@g^Tgn&$YoSXC#4?WMpuGP4ox2KoqbCF|wf>d!I*a433zjN-8i=*3mAK!u?@mmlux zCw{6sOjFq*-mABft&~Y$wZAfk6db_e?HxwgpPK4AUQ%zNdDsP}@ShLk-5+l!%5}Re z=pnK%fw@fXsl%y~E5txcSj9+;gh(Q51C+RT?q?`H@&k-_P|@V%qAd0&lgta4Dg7)Y ziV@?*|BCE7&(b=IK0;S)ob=r0(w^jFmxhVV4`Xm?T!K zj^k{J;;2X`T3M(xmje^iyK%v*jo%OyoxXprQgiHICQWO=u^zg6<8%8bO($7!XuV*? ze2dE#`+d0{-=P?GMqoReKli->ZrccCcwfBj;W0-|B586eyWwAr>AfHJDc$6%ZMP{E z0Bk%Bi;S*}_*0wyqr<^c9Wv;6@}HBv3I?%Qvq^4Q5YsFP#k|m7IZz+~cvR70l#G}L za)SqP#_!19Q0q&7(NpM{9tHeRG-wqgB6)dpv!M3$U zo5bs=^>?r(Xx(l`-;CDgMEFycQt})eSP6vY@G3ExA7Qi zLOz0qe?Q7;c_9o5*`pe)_qRP>9wAJ0O-y{3w&!;c!+P~3ZLvm$w{#lUCj%4txkH`c z=9|SLO8Ic^8|>}TV{=CR^-e$H!~jYX(VR7#K78U6llAp~cFZu$Y%kijVr**;&xi~z zn+>Rz+f_XrAW%H&rjtyyNQv46@D&p8NkPIbGE>_(`x44r(Mev?!Z} zBZ%GQLJ8rpc8=hFX-P)i7CT|;(tU@!1z^8FP5EJvakj(+7#fC%_u(Lwpom&3lXEG9 zF{w?j#V@!(gIgq)h5^+KwTYK0c(<}W(2d)(>K4d3Ln=jo#m?KTpWVh2FDp;rIF40% zVHmklM1S|j(d+m9^n=HUA_?e{ko`jygAQ?(Io5Xeh1n%P6II-4J2qZ5$-#@`C|j@# zX}7;_RW4k|9I*EmcFj#J8cv>+R1_1|B#JO4-QZD)&S(dQDGyhFwF`m@j zJn#3X_t?D_`HXgn$-D4t$(N6&Rn}4JqW@W+qAP*H7& z_Pd&EppQOzU2G5S9t**_cYOKluo4~jt~~{xg@hl7%&9c!>_CQz&nOY-CxE~UHpayQ za}kd-c&;`_{PuxC&fdHEd=?N7Ha0GW3I1AJyL;G>Qjgo$RvyDgTIbVM@6 zV`Df=!m4*i69qYFOMiksTd#XV)x;U1^X$LNc>vLxg+=U6kO#d zc1fX3yG<`Hjft1m>I;=SItQyVT11~AmwvLVoVuA<3QGI-pdV!Oc|j_a>+pkh#FlNm z56zS~&$#CnTHRgu2dT;wED96~CS!O#Ur5%E%b}sm7ZQl|p*x8XVtJB0fU!SF56_h2 zWy?i)iE`)|7L;>{uq|+3y!Ao7$*n_p&?wy^&&ttV!r8e(A?z@RX)Y8<9?E*=eK6)u z*Wv&Z;+N%Hm4rcgE$=KHJ8s1A+WC9ui_|&o+fS_W+;5?A^gq83>B&A`w%!aim$}p$ z?1WD7Au|sw@Cbh_MWHal5sd$U!*hVay!4@t=8W~63_;meLu~FeNAOS znk&nug?8u%z-5$+Gr;Vm(Df?(TY7Bs&2!`+5b#IOT>D|A&3Hx>aIA)R)iL6hpfNFN z2mG%KyJ!I?fuE@RKpwh!KU-!+IaPFWE$(6Kw2W%RDWGRR$YR&3y14bFCrzqYMygY( zcI^`=4@m9TdfLW_(vzLbcU_O5Vktbv#P*IWR-1t9k8p3KRUb_j=lV zSxU0TzN4q@`5ZfW_xs{6Zb?52X4k?u;gz>=<^t@;wB6Lt48OKoE6`sD$3Z|W&Ey3G zxnS#*xVBF-v=A^%$*v(R&GmFj&1+h161!Ow=yo?}Z;$%fts`CNaPPvZnVR40Mg z`Zu#<%WK&E|9^@+!P??|q~=qjCQ9?m?Pw8sHAm*UgX$RTB1z*9B{8Wt(x-?NY*^p2JSs?f$@FWz#{DxzEBe+(p0e5bG>g8qpNwvkAg+o3bJp+>A*f)Wch$?6_f>ZWWUwtl^c*wJESrS z5=WMlDx5nZ-Ftp3JJ+!UzFPSt3g`cl7NY*RohB# zR zXDPJa@Vc4?X#cec;xe*{x#m#TNW$XyU1t*po`HujZF4KJQw{>6{u=C|kl8_=Thue$NMSMJWtP5e2Tz#lK z*R;q{?+c*1`t7I#_kP+-b>%mP8J#SB#rSlk0oB)Vv$;}(uBtJT0`-)6x6P5d5DwSt znz?--=4||D1fhA!{bHj5)kxBH*e7yJI$a;)b*+{(1RK`5AwY}}Lujr5I*A$26M$Ue zlHBPr&poPh9j#kB?WXbEF^BpYu5~7`2;dPrRvtMWKj?Rju)_+B6l$v0OQ$?V&7K*)3cnUGNjCHSL{;%?7eBJrf?a zXmt;~x;FM)1yR=y%HShUc%3Y=A*os7a_x%BO>Td7dgXVITTSLX^&w+-Xf(^-kVNzm z70VuK%;VK0tHUb7kYbx3F3_6=xMQsT2n59mHf-o17(zZ-FNPJ zw~2*qlgwhFfE(ZdLjigjQRcN%v2$EMHZp=W)BEL&W8n#*xHN>rPn> zn>Yd?8Awf;q+B#QG>`8NG|K5v;oFg$+m9u2OIW;^@?Ks8l|mNK)%yl!X)aA?x64~t zs;`?3NE11HXW2`LcTnBCsZbU;C!@K}jknsV6_AQZHoV~I5nW#AQ%(ekUCj-=CbIXd zMv~!pQ4%r|84Vm-UNbk#wVtmCIgskE*IAp>8{uqM4-DUSCszwb9M;2`k;cBm1XHQ5 zCpZ5@{T5f=bIg(a|I}kNLM-b&35(jfOpdw#;`9c77cj z!^Zg4GQX|bqeCmG&a65aVJZ_g8)ZMk*hLSvwCWpXo38RR9fMv6D zkz=zXM@yt@s7I5em^3+ThDt>K-pH4D55*zf=dBZu6vCEr;Pnd&nw`^SClNdYh3z3b z0OGXzvD?H$h%e1W?-xyIc!5d~;#FT?E6(uVhVP=l?H=A2jq*`73X)vn=6vA6P%3#m zB=OW&v<7(FUBl7vjyE>YeLf)wrMTQ3RgJ}L5lTbz&Xp047*YtKTtIi_0F@=9t}GP) zFK*37iRl)e)ED~n+P$&&G}aw_+CDNEV50(dx;VgN3fu{Oy-WIPIr#*XX0YU&?d6TJ zLoR54QuWK#y3l-p?(AfxfdrgzDbVdnB;lZS_KH+ak?S@bF-$vJvY2dVL4x&ZPRxuY z(T(nMF+2br&c=?O>%jLi!i&5uHRwpHY5wc_m{-glV!L!haxRk;g5zj-I1Xz3tDFKWuh)r6AHoQ=U z@y1q|a{l@Hk|!P275d!;Sg5bf)hjC3x&6UwmG1~wkcPKATWY!ed{BTwxXMN3+I$Yv z-q&NP#ba&%0P8FG4hWSmR2ewq>Ayq+7y-SNIii;rHzyeIAFIZFvJ>(#LgUU`!aj0c z`dJ@Y_@xcdNJJNQ^%(_2bnSaUAfK$d7)~ zc6;%&^|7}a!W>b2g*v84%EFgO>7^($>-*@QR(#%z@%U8-mRc7Lx5{WFPlRCKxG2bI zC**VRC%+bW^OTJRss?<|;&Tq5yb_XE2Et#Jjz2xy0NTmeoV6e{UaH<@h*f2}=(P$Y zR1qCpkH=$6Lz z-yypkkW96HA-hF&o8d@OVGD1);F1!%5@OK($D2`}JDnPKr|DZ2P6*dw*CadMH6TwY z;{B->8ur=7A852vk7toUgU;JlzP{zw`#uw9K7a0@n1rP~Bn8=;nlL`Zjez|F$#*v!jl$EQ7x$0zX5 z*Z=%lbNUgk)$6@BOE3%ISyUqpoXOwA_`^EgrLumG9110hz(M(o#H=WHt|zo95%Vo= z!ZBAzHn#=J+lSXQfNsqNKHc)GbisLaikbbC~e!N2L=UlHw_=N6DsEV6O`BWyvP zn{ji{`V!|Q)zJ{=yp2hRj|;h5SfhR&apdk|d$5pBDw#?5rGvdIT_zR#^rAqlYsIaL zyigJQqiZqxs@)?x{Pce(lT1Yjw2ORfOJK8n|a z+Z9}^5iCZIjoXt2efOT5#A{02eY*GGNhF!>k%Rg#&hB^9`&i8unbKGr1>9uA4rWf} z?+cV~y-;|0o%t6AV_T$p`oQS|8(r@Y?$b;aV?6?t77S1%R zIpPkdO0+R@%^2QA#>F@?#@RD{u|B8O9NE=7557P`iw`gP>S=i29G~lKHo@n0%No6b z=@ty8w4iIB5@v041lHB%9hFJuR{Me|%8`JjnvY^Ka}uc>HG<2>1Sm$p#7L2={rPHN z{;ewP9g1v413!IGeXFq25`%Dg4X=!|Pu8K6u17hATS_$=7&;Lp;X#|Iuo-NnJ0jRXDS-l->p6+bET2@1>IId{q**{HWiy-ip zJ#%PvvwFLo{g?~vb3>C_BP=LBLvHkRez|`s#UpXeC&&Js$iOSG)DO=Fm-w>36#hxftahgST(NL%X+&l?EUEzCO^% z#?z$EFg_UmH78YaE_|ABOyhCqSbfseJ2B0BxIQQ>7xYJ9;l#bg=Cxm11^UtJVYn*} zM>}Ld$I^I}x$ZsSqNDcuR#g2p-a&64GjoYNo4VJdCMxu}v#)mucDiQ7n!fg{bkZ~v znsW4`3aOD+z2K{HyyQ%PIk+)P>q^)KYwgI+G9C9)aK8%R3#)M^U4A-t=`+LqlPBex zNS@p$!t)MyjB+`YLlwX!8ycs%>84!qiz)aT_B{rhZy}KF+!HRhKu`32J~w*GWfRjg znLaR~4B4*?XuWDY{iY7VthY8=eH=fg8x^Ty)26ssCslBxJ`ee%zjT?oJ0TKEtt9k` z9NrfGIjM#*uwd0{>#%-qo@2?rG<6KEKB2E;tQ@hd=AA%YFQH z*f>uyYJFl%wNt~g(YtKtW6-J>Dj06EbN_MnoWm-d_eG_jXC(jUS43VONsCaLwA|iU z-r0}7~CgJ2`G&~#>)zmd^s2-Z!;H1Iu^x!^O&Mo$o2KFxGB>raaUk1+cl3ajy& zVsUsmdBed0j-X6aMp1Z%S6OoAKQov7x6O&NVF5TiIVm1@>mpUk{Vhw)e1lSTEyyZ} zB+@%caB@MiLJ+3QITZ^-=N_2pogD@0DAHf}vj!w(h2C*;MnFP_;_{9jSu6{h+S3(b z%pAB_+hQ|?&r}KEp!%Zm$Z{Hy<$pf{py&mPEgm&TkR-2JGcFIHR>A6YuArCT_(s!C0$ZEM3=m5^4Y4W zFMz-l-S$EyKx|04?RY}NkoLLKgEooXFa65CF|t%`=JCAShFZ?)PJ|Q7zKMrVZ2)i^ zMf?bQ+=;#sCA{P-mf-)mGp4Nk^npH(EmU4>Hf+c3N7d^2RBx8a6m$fSrFh(TlOxu% zJohMU9e+qyd~DC8I^)RhwNOE(&2ae^VR_{M6;K*kQLsWtjiJdbQ6ERDVT3a8bHLog z84gcUBOE*~@0w11bxU=k^?5t(awB;ly&^cgzPHL5XMBWpO(2~2+*fYD?73pp`93zj zYtesNcSTE=L8m*AHD*MEJH30kO7JltRuz4o!Cny1A7r0zqTWR7C_(1A3_mgr9=qTf7Adfv?mmc80qRxh_>)`gnuB9Ffc*FjA9e{MbmN*+glf zG>*iaF!Ss~gdcSN9`w5*82>(zb)o00r8l2Aal|-UuEK%tWDhhGkEp#4g^(z&Gpri^ zaOVd&Q;JNoPm~_R3hBqfzcccVG;0s_@;3Xz3qEiz>x3{y8CT4m(KQxY1lu3YQ~Y9F zObL%GQTyxxs4|#ch1`*gGyTWJ6V492R2hAXbyL#l3n1NFEpU-9x?+)>!Q~T;eP3R8 zxcHAT2O(lZmK@EMwPe*HxL1`flH!HG;~;sf@F)l-n4E9;*22ytDETL`r`*3Jr*ckz zoz@3BMKebR%h}SIwMgAjK|tNI1B^yxI{Ex~u{((9{$@l?hMnlw5L*s>tPQLpV_Y7N z=1i3?n1oCIFu!**<>|H0PGf_*;Kxdskwt>%JmOeifAr*CD8lQct6Ky&ybg3m&-}O5 zYWNeq9^uEwcuDE>L2PBpSFV7oSCI>K=}K2^nZA5~b9*f!dhbL5Vpl0TtYj-7oI_2A zM!eUp$i{*kPes01EGvrt^7LWhmNS-l?`4~H)*ZRiyCbL-@PHO937!elxH(#ASQ9PZ z_v^tn|HBqJSBq<}8d;Vx=w09L<{GD%+WBUW@t5Bxlpgby;8)Dl>GtcR~A6#h+WtQB9riMBj=k`86K2}*0gopAqgbXA-}U&X%y1TT&)JA zod-NgZPoL0d_wp~#0$??hAo7Wba{J%N>h2#AA=w%*XU~X3FZnxhbV0y*E_6Snc`Q> z+Cm(&|7oVCtXbbG8%bdIc}S%E@%?zXwQE4L2Xc?la93VwycR%8OB{l~)*~7&nQ-*- z#(t~gY5${+Z?Cs(B;HqlCjfz28~7`ERQ%EQjl!WdHZln!lTIhm~;04p2B6 z#N1Lb!txxqw0uA=yIyB@V*;U}6Y$?&YAs6UFznsut^BW*)x}R2{oZ>;m9rCKJs5*> z(OGEQUr>)~VaIwXt^`5%U0ksKwIA{V!{~63ZWwga2oc?+KQdwFa8^Rs)?nrCizuZEYQGU+!W1AC;Jjx}RotrH!ED0;*=O|A8x_tv9_l*Q|LQaR_a-+?g zm5fee3zQ2~bg!W5NJLwo@{WfNn9Nj?Wg8a+{Jk%=h!cRAgN@=&7S!vAEBThZuMj+@6(e7_h(Dy38Y zSyDJ9%Byt?y7=}pf{+E(03J`$qskWHu{Abs*atgFM)Bw!dY+)@x#kQh-LFU=DfdSG zsku?07Zs_{Le5w$Q|x_c#X5tK8oB+xw*Hx{iVtiGpRL9`5 zb#oEo5N-2w%NNKSe_y+;emJX|h0s&%s8UO~Xmh22Y@~NF2AW5yoQ@Ul%t>xb!q~m^ z`7T#~u*}hoUykMLRjP1g#Fko_CF_lRoJ{WBwseP)4O^3`v1pS$k#7j4R zI;^>NC1WU<>w_X@H#3l8s>dvEDrka2t5$a2Am(4+`5%j^D>wVuyIZvH^NGvfc8F{l zN=1hCcf$2&0~2$Neab`VQg-@_t;yAr}j2$d`>=ys|t#ve~wSkIgbNk39t?A386 zF&@@v&z?S{%HK)JfN@U>S(UDZ&FaeydE4yx%MN|E_L^jA!1V7u<}TM2rRKl&YLEt2 zbW=#tnu)BG`6crbpGMAddMqWZv6P%&x%LoC{0*qE#$jBgR4UBL>Rg3BfSshc%B39U8wI%lNlzxAxye;(PJj0}xu44eC> z{6tExN5(*+jiH)!+{!zW)_wBukv?}eVU)HpzO_C~BkEO14db~B+O-CK8fjDk8#wmh zVX8GOazD|vygZmyds}pDo_*L6pa1BqghG{Ba=jm&vHd=&V-<&;GKdBLSsL)hhJz{A zIYqGJ#KVSZd3|v;J%u(=C~^o#BCrJ=D5LaSzTMRX7*N-Br~4xh&KQu8wnpN^Wx-$l zk+WT$rA=m$^sz#h7`!0@Za)3c4&d;+fti;i;4};w#Js_?YHO*~#EeZ|bB@;c-5`b~ zVT3$!42)*if1A;SuwgqRCaG@JeiOy@ZH5B6P}EE;mhIPlV?lt?az8k-?oBkVfO0vo zq|s1n=Q3h_i}T|2W%JXgUt4j_)%CXkr1ihX< z?A6J)6)8khX-ExV#4=`zlnSmg;mftSA45%>xbP$tD)j3pjFHl* z&q?T(kFaeb=F7j1B-Gz5njJUC;*#*De8VyS1XhYQ3FXa(=}o@YoYgK(1{Py57LwkT9tiv42kFJ19%TrvtKBvpUTPP|pV+RuS|Lxy~^{q+#FhNAOp54lvEd=e!WKVoO5y}2mYx^QK>Fb zsaUo?c{X>7#k+LAIPDI+Ty*fEF^e$pD#R+uD!0%`v0<2gN7;@GuKf#M5gAl^t05&G zt=3*@S`|YmH#^qJ*dLPckR%n$qPilC-{Et_5Cys77d60nPwHvQ*J*Lv%5kfO%={D) z1mfcJGTp}E>vgm^e)Sh!0h*v9#4p`TEI-{1m`rkd9iMAO>{Od?h9*WSn)8fC&2n71 zTLtAtsD&7@t{Qnn!&*OXk0w#`E4JW6@2Uuf=GpJx{X&#S(GQwp+cr3M${dSkKAELr zaqcj71+wgopBq7LY_~-G*XJ<<(Td}Pu|?R)%d?IvTq2AwY%^xnpqV-=UdrJ|0sY<( zrAMN1IUmAVa$xFD>@LgEUoncPq}6q|&)Ym3Fn2aF8>)%C}EZnv&`(=(RuIhU~W^kVkV@9HPiC2p`-GgTuv&D^mI|)rkMw3jo_DsazOQ#A8QleFKe`pQz!J zXiZ3c<@VqtSsdO{`8pbpfdY4f&CmUR?rl(B6lXL==uny-_ZTsx5{7-o74>{hDQB}G zY*h!@05Hs1csd^Gf|&kC#>IqV@J4#rp`f~{)sz$ZK9M*lXm=C(c_cYQ$<_Sqmk(Gt zVXL`AhsSi<7-Xsss5qHCSGhoq(0}}@UQ-o03ZfZ#EhoK0BQl3WAPm_;S4&)x3pE-l zJKlM7Fwv_OcgW0hi4}XaCIQU0;q! z(fX7}{lSO21;kr`Z~P*e<#B(FRTO`IR4pn4XC5lazFeNE{tz)k;|*Od#gH#kE+@Xl zneS2^M>@%fOU#;J&Fyldz|t}d9^rZTmf~xiGe7od)Wnf|ZXLBmm9XMq517Yu8Yx>! zTd-j*m2|VpWgANIT(dHnYpCIe&3SN}=LDrq+&u3vr3?ns@!!y@k@nEGk>q1A??T+l zc0jMEq)rEe3L5}1qQcZr5@6jWtLwPbGODDCL;xL%w( zH>T7|GkR_RSzYRv#_mY)U(K4O#{g+D3w9D4+fTL-ObC1t+bib98$W4z?U9gbN31px z*?9D@Y7(H898%?yf^-{(`*w3PZtAxj++{|v_GjuDpVJ0!55-Y5Ww4Pzd*fKopW6rp z5tb-n{BqwN*J-2m(Q}sy1%GYW^&XgT%p3+3h-LZ{ z;RI4_Ou5o`yCdMv|2T??wjVE^3#5~CDgp9^?AuUQ;cv0MRjbYc8K`o}NP&OKBXMZ7 z?u7nXVkZ6@-pZFdOY7$Rx6qk%_{$zijus?6T4OQTeaSdhsHN$ zLJnJ5MGGM2>GQ8oBy1i{9zGpxdF$JjuO*6qU~f8IeN>>yKt-1H+9Jy-m^oibPKYz^C$-J;StN|NWVNHY;aA*n*M5=c7JIXaKAFZ-ME`FHDcKHhR-A?UoddHF zSNFmJYtd#KGxM1TE!i<_!1DD3=-dD8Mt}v=c8W=Vh^?J-VzG1W=%vUv8WV|4Wxf=n zuKHF#SD)IcFZgpwQN0uF+ipEmsDR6Ad32Rj(o|daJDNS=iZikn6rVoBxj(`SE;n=> zfqF6AWi{N5aHXEX+2}{6@G4iWw=dVzlkte~H~Wt!3L zG2Q!7Ds+|V^;&MQD{I(e9y#?>G zO#0~MVwrlC-rO8UaOE4jd4>1uHETv(FVa{Aw-A^*<^ z1H{7bY&A&NBy1#?597LFrWyL&agQdqkq4yTLcRdCAPf*0Z|<~y&#oks-zIZxKIUV;in7js>0k}AK|Gk^y(d{m(a~sP zn;X!G&KDpo>^%}KI;*<0+cadgtanD%JzBk9B3Q{Y!J=u{L3c1&($zt_mDpg*Q>0FO zK01kZrAg3m$2xk)IngV_N?8V|*>EP2DzGJvCc~@$8R|MD7_*9|{T_JdZCa;l{sQ6K zV2T0rxI+2+Pg_PGA<1V2P5x-4oLr#BkeHu>cpkl3R3=;06)&xjCOtYI`_Dz8$880A zZ#ias|A1bq}wQt0d(9rJbF+oE=v}JW}SC7 zOfJ<`TUgUX-eGv`S?1QXT2G}ey)TE?c^6b7Q|WW`l>ILB23_Y4)A>KeNl78!%&+|M zulrOFWRg8T-%U#NzGP|s&>F)2p$!A4N%ylNu9JrvcI>n@Sr%A2R~%NAe*~AIykdS3 z%`j}dX|8j{W97(;tA9w_Li#af7PXP=W@7J>!m5>9dj&%$;{iTn@VEwon17&wfG%yo zBVP^_?)B*-$~e&844rxz?ARGIyW?`c#6cC4ZFnaJ!HE-{--IRS4lpgO-dh97ef8SO z-xl=Q&Jd@~J6}}BV|d)5zYYSYf1KNO%W`&gurxjflpI>pI>t%r*3D9ben4bST8b%5 zii-zpyOcgYZ7Cb``^BdP!z30^q1=bxA26=|x@N)$v6KN=kT|2^aJf5%uz7sNFT%Z1 z&eKZ7&8 zTa$gg*3L-avCT{Ir-qi12X|lo*H#o z?ESk=&D)~|K^u!D>_x;3H5Al$mfuJpliZ54)U7sjJZ=dY-H%`c#)st;)xP@T^sPxp zOjo*&9bsA)i~VsAZ7{E>54eF7RsNkrBbK>qpp?JZbz~x zx@h-JHV)g_a#+VLEY6tXX*)tO>V~nLuQnz)`gh`{3 zKf5~nl(DAyxURPChP+fifqtcuwFzqED8Z+_a;`)pSZpyEQaaEW_cqGgi_lo*NZq@!MxGjv8F8 zwqL39@~V=BiGKAH;CQFB#7cpsWT73<(*CBAy8q1qc#bT}j&!?U@cU@}6o3$87(mk6 zm2$8NOeTtmf@M3E&iz%2It8@8Zw@TKTj3=0-4R+~un%<$p z=5+jeGIfa#ZiP<3U9Oxs8_p-f@JxUKsdiir7o&c?c~J&ib0+zm4Q@JH#1Rxsj_KU{ z1AEdC#Vnlz6fBjv({*J`+R*EKTa#0nn3Ce*p5xr6sMs=nULnMjBoqmDv);8ss`L3Z~lw~+VX8+ zMUnQMRbViaKRJO59mtr}stsP>!BKdX$?bmVB!mm&wQ#oY zj~KSz>;m<3mS_-Cc&0rbI{&Ea3T-Y)3xr{Wz~kJg`0H# zV*C!MuMiOlie=I(aO6=i&!1IsMyYduX}>plDAjB#DAsERauCq~!fv{sV|sxPzE%2I z?P?#qyK52ZP?|c#3H&z`m>!jroY9Ijf?jeu@1{iE1%8-78l8 zO?3z#ENJlj$ZIt0Nx$puz6D@I4WX1I8ud0`Nis$N3IsTtCI8akB%Mp?Ov$vy-m_-A zYH+=+JE#Cm{}eUFT-rCjA}+2V2?YShN+(VH%60w@Gz4`(yoT|52j z$d1^De_h8sSW6@(qan?c_Nic57SG&QH9Z{vuhZGIRPkqEUJw$wqWTcCkIkq{y8RL4cru>SO0XmE;$GRJ494qAJnI5`)ek32rOLvx!67A+k5!Hnn z{ezloEqoLeu#y6J%1i+vBe75_Wm+k#rrsa%GL>M^rPj*6Sl?qQyh1J&>^o)ST>H@H+HY(? zx^HfDqb+>X1|s^t?i6c1KV0=DTjle-k2=ghB>J{k3H=i@U)4E^)bg4i9WSCl?%rW( zLsv491C$g+6tb;yQn#{XzyTX_bkHPoYIGbWZ>dt;b<*i$j@w6-tDx=Ure|5Fw+-)o zaE>@^-Flm5Y8vMMKsRYRnXbo@nDL-aa6rE1|K{a{v zH4gVb3yO}Wij+DvA_QlIxi{G)@h5eHlv!?$gmiikkdb-?WNQ=>CdjIF3HF8In_L&p zWFG3x5{vzHxxF*#>~J0y`TD6kKq`eP zQLWZE^Cr1m#(u#d{-T*uVG%GRNph3jH&7(#4r1q?4)Fh4rjNdHAy2!6DuuFsmV-GA z8&5kta$CN|mXH6jQfF*l{7yK{wUn1xe}~|vKkf7tk6NX^KVoNQ-3EJYh%Myy=>~+R zg7#^V5Oz`R&C>*wPNhuK6XSYZqr9ITK3laPC^VhH|9(4wm-muv)1cuKlpGx7zML43 zd6gGZ_Uq+XkyIu{GttjdV(*pmeu%gLF$vE8Vz2 zx@*z!PIjEAb=D;p zkKYb&)DNXT_PT%*9($ko)f~g!I3n{t0qXGUru;5)^mY-gYzZtUj7y#*r2FGR9YNuC zUKSY1g=fBg1Fi4Au3{*;XK6f9-s(cQVO2_InjLS#-?5fB_&zSp%p0!7@p98j;OzT( zTPdHksYR^X%;FAiXw>O~-bdD;3t?8J&ngHw=qIp2yB>SZ>Dhxp9WyD;3_^mO?+8j0 z9DP5(`b8{(+}U&^5K03eiv(AOTQqw5=x+uL4y@E!j;4h&wWNBihk?kaSoR&Fo6okQ zSe&+LrpjFU4oJ{qWOe4Z0po-M<|C7s7kg8CjmVAD$mUwiNn^W|=P7qO2Diu{@dciQ z|Hon2BbY*zwv;+w?hp~a)=l5rlo3aV$-NDRheEH{2G#S%_R&W=DG*s9oK4|gPSStn5;3md&ncK+e?YZ-cQIZKBh z9)|$cCk$)Q4R-EB)mWJm30bL7Q^J@PHkJDq(~?a- z**$8hX#fGamp6Jz8YhTDQkz_%7R;%44J8ZdkoG!5+N-sRP-9#x@xF8uwni|npv-d$ zN8d7)Vd`+6dO9^adMWEVaV@y&(%s;?D0GoLng23aKKX{r=e`D(%8qnFa;I<}&!EA; z>C55T<&I0uF0QNcyn;l#uSv&WC`mM&PG_@sAe*PD-5d$_39R;hNzw*`m+dGlUxA+9 zvE}AFe~`sbJORQ^_d42EyNB^t@^ovg=W~@Nx15uU@#Vg{KQUi=ei8P{N^j_@CcU3aWI;;tnJI#O~DEBx2q0D`1^Ea zb4>=?5sbgRZp&t&q$+)=eKQ1)jhti-)pzj)As_Q3+ z9AX8Gswz1BqFLd9TQoONwtJ!{rC&7qVUQ@(>qP*K))6oQ36YZqJS|;@s(@ zUqxD?k?pWQ9B*{->A(Ks(@WU~-#Ap*D;sYHD~kUd0>{36|oZf=X87er67 zb*eAdW4mbkp<_cR|Hc?)R`gDU%MhXXMjuTlK7VzTZa&5iqp+AR(j_~Q*rBZpNPe%ewb64NyNIwv-3I+7o5Aas-z7n_TNviq^N@y(A z4GC%3Y=Mm<`fvav4wwyA#CR|8)AtK zi_e7#+l-zntFM=pGq-6IdMKaSv^@xYUO~7u_n~^?>cy;&YY9dfE&#ohm0~5eXf8*x5k@h|?z|nEX#f!| z!Sm+&fR;#muQ}<6xKpr2Sa_LrC?5 zah^b|2O$wvZ<^_+w2M?WoFAH}{kV8#sNfdi3g5$f~wQ zZ!ISor5I}7ft49P?KCK4GgKScqEQa}y&;ciLWG-c{TxSkJjV?g9Kh3cWphJWdQ?r` zzxjw4=@B9#3=Q0mOLkw1+5&W6alR^b3&S=~7@SQ*sz%wNP^|lOmE`2$Asb8yv@KHW z)nD%8j=bCV!_^{wS*Xt4Rzr>dR{!ygqC>bMna?p+^5Di7lPB~HbXtu9Is-OOY#S0R zZ`Lv5<-Z~$(i`8QJ&_|9w5RL5ZuF` z)@gRH?lVlTu~z29K}U6@^?TQ6KAKCnSEyQqj5CDv*d7lq3d=X+5-)!}U}m=d_3Bqz zGM|(%>bEH>Uya|Vd_Hw2{}hpyu#LvybAUew_ZV{S3qm0Q$bP?5v2`KnXD-M^)WX^6s8^Iuw0M##LlEhv z{Nx@}FBJ-mxgXYjD)KY|<31JRFYdQDUd5q}Snml0H8(J!p%0ER!VQSDLs00^Yz?nh z%i#m60^oc@k?u_y@btD0GN#=Q0F*S5MmZj=7Ii^vhWkgUmD!EAw4{0RLg&v5V!F7| zW;sENeU2CgCl$DMUpTt!3$A`BdPt3^y^q-W$;Y>F0q{CLM!h`VAo(aNME&Nyv4-+T zfv8;ah&arMJr<_%iLPYzeDk{|{PCx?ZT`WSW9`UDFu*A8RupjJd~h26(HotwR+7}j z!uKNav+u>Ip7(j6(B(F}pHv@coh!8BGW$y2S)-lZlvgE;Zr%}t#6ja#=Umb!2Vz$= zk7K%2;cTBpuhVaqWf(a#PMg_5+u2D1Uz+H~RRUj8k;6TKdm#VmIjmqC`(ok?|NCek z0k!xyE!$>El!pWAL2E=DX6tkq;J!Xvky4!gdchS7fiHb$p8u^5n|!2{{!{YzkACT` z09*+~_4ki9PclW7$2&W}q--Dz)B(bPqV(SBy1^JaoRgi&;mUb5N*EA>!ujT7g*e@g zWJ}_xLG1a%ShLUYNw+?U=@4N1lH1**Bwt*F*RID?_@;kk0&*kA4deJ6so7jS%rT`0 zzuE;e4_uVd{1GL37%IV1h|Wr%>KUsei}>x>s&B_kbz;3)E`Wt8j^+*y$kBQ0H8`5L z%Rhc_FOfTNi9+6-%$K7p1a?kc*Nqy@b%Q#m$K}iSr&6h}7KOab*0;)(0dqhB=B*u* z_G1X?p2*1^NfUOdXSiv~<}YolbL_*=T%gRHZ+eac$1DcNEa~!=p3ABYmq|11g6(Lt zX4Xb}TtUQ(af}^^n*`MOTyx&FV$-)OD679gGmU4~333NFmMa5^?5gpiPiPUf*Ac12 zZyl~rCi9T-qqB+&K4wHAJtfC|M82p;U;Lm~2^)^t2x0C-KpyuIHEu|U&w*zIjc8C9 z%-dfr#P1CXoMHWLfqI$H^~suEmC4|J$Kxrs96BTo7dNo{%)ZE#Pa zRISs$|8Vjd<+5Krxf8R#`#UH+uc5SRnFydm59{eA;nLdh;>pa-&EBY^f z!T&t+{mmQJCE_<&rvEiv)4iwUgm3{^cxdj2rN6KGfAh!Rzvyl$|DL*Ex7q(gV-P{O zf6B4uxj9Cs+3HPVedS0AD%$~o*Vgm`@GPnDmNo;Gjzo6am7&yNn=()Sc~Fb&19+oTrI=+!gf&Z_`*E}CZm=M^fdqD}%2n0f$cV=?lc@J_=>?U{XYePCKL4}x?~e5U_%{(u#B5%%xpbc!$=?s+g2T1m z=->vYJvqQVbR5w0@db)Gz~%u(DXbG+0O-48Zn&e{IDQZg_wfJVxwbJSl*8%(u3ULd zj>H^jmpD$Z#4GoR^C?}TldO+Ovs5!^J+J+hvP?Ef0z8-Jb90kyL$4XLM~i9IhxCsS zLx-NAp&$;DhguCuGh?CszNiB_kjSQZQX$w zFa$2+!6a3We%1fl7DO=j!$gwwI|1~I(?J(o%sYBm*Ff&V|a;9n54rBPVJNI9k0AP%4Uz5x6P!cYJ#0$^N()b-F+ z$LODdxRQ@agwpN5KSCsO5VPIi){*c#D^%vnKJZk|lg$HZO4oxvz~iK(JgW& z@0jA$7$ht=qNwS203uXzP_r6F+5^zf9iP`&e>hnA66p_^+axgr#~v#nWElA^ zVflUjox3ea>ziR$KEMZdzs^qfWkJi>Yq^wzx17tCM5NpWEOPIV?hnIWV&t_9l06Qv)(1e98uIpER&=T9oYpFdcl!RQTtGbVay#7u|$7tUkm1v!sm8NNQN zn}gqT6f;Y?rY#faj=?Z<6 ztkyFslZjKHv9B$l(qm&{)+Q@WkSu3)P|NOe(-7~+N5@Mbn6$A~)~aM|JInO}D3gf; zOjrnMzLt9ZQ&r>SiIM`tE@ZgatY>uoV9b5rnG61M%)inv?c6}M;v{CDPJc4ax;WXQ zIiZ>N*b`LA zS1_Oqo1+qjN#?dzvcLiP3LzWdRHX?vZ#u3S7CTpl`n25w`4i;RN zrHMy`8kXM>xS$WlQP?hUaCdciT*HfFbF61kQ33wXi61Mk4q$aizd1tuoR*&JdEUvK z(*3OTXQug2hXzPpR2Z;otu6gXs_9jCCD>vjp>w>iCDy2UpD2O*lRK}b)OH`~0sjT) z%!#EJme|yJJ;>rZ(e_R=~=Q0Lzv znMRr*-2h3uuiFyrK=Xg@jhi)~2SJnySzRoXu>_0?)_2jd5_}^09V)$cbcz%O?$F4Iy*r+ zL%lJg7z`yr+ynfe-&}{2;!Ct@Mv67|@NepTRlWnlM`duc9aOh$Ftk0*;qXI(&+YIz zcB{cao!idIWWG|)Z1mZSN;->!sLuOAg-nTBw(~V#p-28HtSs94uS!6yY+e zglyfY`lUW&e=4{MFaclVm?Tx7eN1!quIwkCQ|IfCXEHh|?hswtxSf05{Tldpvz2$a z-Rw5*j>3BPm%HER@$WS-bT00;+0NQ)k@3lb`lP40gyrWqc;mHrlcgkr*T?L9Zp-g% zv$ZDShyrkEWpn%9ZVHv`bnkGVL60{$Y!|dCz2~OJ{U^C_urpWMY#UFs#H0_qm+!bn z6E^kH%d}b-cA5NbT`;>Jx_{x29s{_Z+|6NVVVZWmL(WUi zJ9eiiO3JA*cpP+ZYsvO2G zsGAK2x1y+}*E^~$V;yC{X}!)LR|DfUfc;wETO^8V@nll@Jt5~~L1|(NGLU*J8w~ht zxv@8B)s{MbID*Zk$VRZ$-zZehlV^i9#{rQ{42=xFe8egtkXbxeUH0Z-hxfTU5U1B| z3W)l_@3qR|OXu$(pTrYMA5#_)(Duo1XU(_GZl`+kljGu`Bc*gI@hXpkbKU92d~h6F z^ub|23-`!dD8dOwK^^s8tNibr6!{6+6RxI137Pnc>XnX$0CIvftc#>%m){Dk8I&d& zq@To^Y_z{VSX&Pr7N%vjRuETnC{z#dr+THWt#u-lMp2@iR?&R*uHRf(TI|`3>Ec53 zSg|=y65A0;1CXG^l2HU?Q>t3hJtK+ZMe6D`$nq&?8f>fRcC&!m!jW7xnCmXV8DGFO zRjlc9ePKxQsI}oe8DJ5he%l})zl-g(r$#s=KYMUTaB=^Dgbvg5#_b$oe!@SV3x|2u zdp4R=j>WjDr6?U>dy89OkJ*yBZMhPXX2>Q_7qp5=Gy9-`WQx_JxA`x&Kd8pWW8D|S z$S(T4ElKwb$a)+`5yP3xzQNODjq+=dlm%>FYzcPq!bf3B3htyL@vFPPep>&$B2NS)ATJ(7o&OAE) zCEbgSQ09EN>AA|X4X%@v)zSHDq5lUY7Ph5XpU(uG& zov?uGI4aEzI*m#9g4Mv|#Qj+g|9Y&t*+XfPXESIrIwhKwwu>!uHJbT6`@uDau~A3K za`Wydh{CKqGxkIX?+Mre?INpMQu;_l7HH|jSbq%d$if*5om%8v|sj|2vMV77Bryti;VLYA~drWfrosi@gS~%E$ zm8E?S6X84ZpQ9y%(f>}&?<_ODaIqB1VzxV1fBgB{Y06~Alpg1OGKV6xZvU6nvE+ec zn5TR>vdM6{feznWfe37+iZc5NA`%;a-vHMbLgj_zU?&U?dH|Z^*J>*tR2g!1P<)Ke zXiyj})~HnRKDfF%VhLb{d=HXcS4Tj5VxgU^Y+=6^r<-9{KTFo|}82fg&_4bA;jLX$rY<6w7hR#DLV#5BTt@EyeF8916 z&Ru&o7k+|08X=d8x5p{f)OjUBaW5)2JC59CJiU(8{4?xf%{~2J%-c?#cLXjONj`UTRS5fR^X+=E+u&Q|7A! z#Sv*x0zNrzP;S2YJix&;2&718sW(jU?EBS9P+NhbW+6Ge#!g=MWzIcVRGQ5B%Wv66 zczTR;jvAP?$u-M&JsudF{O=GL?)vVJo9_DV;il94=#4-57Nte{gs+0|J!O4bgn2jjDr5;U> zznfEmV2pJzr+lr88OlkzGhxcab{L-o;Z?gqTk>NuBK!mC>4{u5pHNX0kff?~na|Zp z))~G{|BKT>yAuS5Z|j}~A{SXqS6iyTQw^8XTBH6Frc%F*u2~5cJ-M{%SG!nWmN)B* z$*%bpk*hArB4MFH=K;-zc44kwY?bA`2wk9-fIM|Tw0M~5R{<`fjse8@*C3a&(5NT8 z=+g3$gtu|ObM?W8xF77qXJSP?9E}hm%Gn>4l&r6S`k3tSC+j9bM&tW5#0g%iMXJgc ze3@x%ad6rkS=NHHEu&GP$~5w7y=tY}f#lTd@#rK{3h+pc9=g2+y5Y1>BC+(K+Yo}@ zBt?tTq86`9x|1x>#8c9TO+}OL3L0(i$cT{Y%|;J2bwPYqMe=L!y24_lIwPPI@b+JI zN!^{VA;>$0kiPOj{uK6}kUBqA8`LFJd0&g+8UPA-INT0}_gZDT^LAa>!hcZ^stfs} z7q8G_x*QVypolcJW=J}Mz!zCuBJ%tFf^552CSRKx&jc^rFF%yG0=`kIx1h~NI;ze* zSitQN^hjix_l_8kHOLbA>14ki7=X<(2bWG;`Z4^GijLc6e%ToigZgjD8xbkb=Dv+I z?lC_qxhWGLPP+@$G#jjV-;)XkrGe`=4XTG*!e`{h{c-QfOSrdET^_I_JCBRl+uLt$ zp0ap+fQyYIPa8O3d^1&Sl(R0A2VzsbLJNbzZ4Y>YLbdThIwjO;1vuF zD)~%4qKMVAKKy&kB7RN6W`zGETMRlZd=rM16q)5TYSP{nr8%n%f-OX6PKp8)E&B{0 z2UTPVn*1KWUY#OEP(bSaD~rsQyyGO@ZSmMse)H@EZ<~*rn?kn4d3&L=iOPl4(H;IU zU47*Y6nGe7-xIuaAK1uSJaP|=A+Q={(yNHFdBty#0kRT?Rh->w!FrIn221p{4a?jy zS%jbSE`Bkps&XFG#D!0Is?*@8s^rNhHx+dWF5pg#21?^A$50YbGeGViZc>3PkC{zP zF^`XiD;INfb+NTy=PsG0V0aB7k*MVThGFG{sT7gn2X8Bb%?R2`RpJvdff`zlX_}YX z`Rx$)QwBihb^0_XnhNrU`096~t6KcV`L@uZdQ+Q0HkZfVcoKnu%Ii%WJ&c zgtlyst4_q1yZ0>*G%eJbXSv(V@jTUJ)~@fA|BG0kS@&+%O3f+^RSH$;E`SIzYOnu~ zVj2m4@|~!ZV8fL7m~78tA?%Tt86W||NsxDjTg{A)3Da?<<<6%pR?hoycmp5_>)_qh zPjTs06edAq=tvj=yOo}BXIQGSbUb}4|6Wr+SD1PaD5B=8Z*OM~rtHLgyj4hS&RWt3hY{us2Bp!_%}5|~B;A}BFlPzuKd zF`io^c@R-w89`Aw-Tikj_{99)a&L^Lo38H8H%@MnK&SsOw@VUpx>1lj;#*)3k`JWt zj$YwUHK)6mTe3r*#1QY$XavnWyy?$qh_s598@PCLfa(CG=@sq3l zjvdvOo+p$PhzQX*h-xmsH(->z(_XS$kvQ$l$mTSL6JUWn_#?PiBs_o9z#(BiR-h9q zbbYMcF+V8V=xomcI1PYDYz_Oa|H-V{G9s)$g}3t1@@ob~ETe|fJO*fnFhowW{^A8B z{6M{#sZET;D&A>pVIH&>BwxW}tI(7hCd){`Zc;pzUhqi=A`pbKV`&S6k(Br6Z$HC! z^Q%|BQu-Y|e+T@x8bBlAiUB3AI3?e_W&a1SK%IUapaEv+mT)be0!%?sdz+kQ=<47) z2;kWlM2URdzKi&0PUTqQjX=LolxlSBv=SB@703=(gG7;OU*#8zLE(6 zC_dqk7d7I2s?A9s);WeH+P}J{`X$HWEsxd9Q=c2RcA#j`TLnrs3F-tRBAQd$ljipm z&D@z$9(y$$r+o~R`V2rp40M%1PDQF2C1@JRj^N_lfkTh2_7<9u^rl1bhEKbCPHqe(Iwol-N1-YCiQf|LpVUu z|I7RjY7!$Gw9qv`n5hJ+-ecX|X>AF9&sC;Bt(YDDpo^gtm*WZ6-=q;diCP&B!-4-``R3ccf@Ja1R}0jERCwE71|C!Py=y4bX+&?6!+oqR zz{ljZvv})9$ZNr&lWt~;iK;wrQrf)BN4P&C&0(_rGRHx=6;{jFWzH>63PVXf;tMPe z`X&R&?w>`UU4FGzXkDfyxn^$Rlr_1kp2bzy2Hj!=&(-z3svTfNp~TWJKB7O0Ot10h zX&wvEVsHL~3m`as-@BDE8@?&mK~Dh$paS%(vuT1JQ-Z05>AsA(d6K8xuFj)2DJ@T% z$Vy4Cst7tao_o(Ka~O|#eIj7Dydm{0_l2mWT6derwgqL){l)#`8*=@p-?7z>;xpEU zmm-r$GYc`4p7BmQPdB+8y$oD%V(TT%Zb~XIWYf+>{saWZ*d%FZBovJSja@G6KtO*K zC(0~Le}9zRG+J(Q`dORZG^i7@dyN#Gk;CJ@1uBZ-!mLNT`0Q{LIS5G1?8?+5m%7)H zJ=d4eZ)83&?VD6Lu5e^4y#4ZpSFPEXLNy+0ylh~+p)359g zp5hV6pgI;+K`j*%vT>UC6kQ#|dp7LD>@Hyu^i{kX6*uvi*=R#7GMDit2M~}k%vSHz z`>Uhb0d5425}jzgTmy(Vi-9^9k8C1eY>v|>1Wl)iazC$1oknNH9I4vTfVVi&#>)I( zpGmy>Ju6}p{SIOi0XA~xhF(H3(K~1ho5utLbTiB=j%p~KH-B}2h zBWw|fBvEVX$>FgdP?Q2MRah5^F!C7M@#+>xY^9@VWV3;= zCC;|od?&QEJ3Re!AxjzO{b-NhLTcYoOkX{@G^%x*ZiDxDdah}z-K6AG%i0wVIeYeN zj{+3mG5$xlqdkV0O(#Zdu?b3;*=_y^->%M&RJvCF>2h2p@(+;OrMhc{VpjK^i%Hv- zbCXhlLHX_KESnB42!lurhbD~sFa-okYVQT!+cfUf+*Z1f*{MA^h5ao4&hK45x$IL# z1*<6!TgkD6t1KECi?L4K=qXO-wcO1rj^l!u(U)ll)y#_8YEyL z0JMg}(t50vAmruVEGIB@TLb0W3edY+!h-}0Unz#X{wn$F8)PWxgMu6cv;#_bNU>CX zeHNJf1L=Ri+1-;0EWQs(LKp-(fI$S)rnw3ToNm810GI`Xfdn=PxSE!C(J*{VJ>s_L z4&BD(nzHjRpEgMj1|9&eXrmor0pT8cs-2{=2?^o6&!IE{0BDru)e~-2eDZ zQX=G~G4GuI>0|%)CVdYuLl|%r?EiEYe}6g$4sJ=R;HCJ#tnR;lX@<-_-@3nt^RJWg zkNNt0cK)`VfA)<3Fq6M`*57{T&t~9nul$cK@+Uw1Kb@Tnor2h-i-un1yx`H@Gb&KF zVc-6wSX(~v31xVT$~g6Qp5;B-a1*x>V~yRV-Q2KyylQ;=pA7o9!`wIS(N`;2dvzg! zC!So+n)u~E{0t^#8^$-!4-yI>GveYomsckJq?xw0=+(P=lij_eCIRA$S5M}xuUGJ} zLW)p|KkV3gKj9UCPN-)})B4u&r@*KeN{;e~;*UsMr8?>}T9fXIMQzxb!4 zyo;qih%`DN*Jgi8_he%d;CFrI>T>pj)w}Pw-0BqFASdS`s#ZzEOTdhJk=o0>IEd=f zy#FF0ak~YA92$`GFkQImp!dgq-~H(JFMY(qPhaBhO$>yt@&i$c%I>_}m<&OC17PRI@#)!CSSCO@^ zC9f|%Ta^;K^>9>!xTWni7L{zPfJ}J=o7cUe@65Xa$L2A~b zKK~fNPb!ugMoWw^;ga$*ioY_cw{p6{pAT<1=-A&mo}OGfs1-fCj7|^6@mZj~GCS!y z@~Rxb!)ZlZn>#Ugz1k`Jq&iM&w11eN4n?0WQJnsSM|CRW$Rf~cil9Ev-Doq%MBLJ$ zG(MwfzHAYz^}n)_|FL$>6p{IFc@JHr#)-7?t2E7gYa}u1UUamR%ml4PlWLHK-SBmO zRz!8zDew6-jxtfl zGY8F!*IoXU?TL-h66X*pB9J_=Z(mMQn3pxXX1w65LD_=tJ&2QW|Lg@_;WqauqxK(n{t5BPZ#f{mpSS_8}U|kg!MR7~N=Yu;7gVkEhU0a#fse zC==#{ZfT+E_x(V0Sq1haZ%2Y3v9ynrsa?XMP0b^925w)(M z!hfiYoi63^cK-OsI;)^Y-6kZ`e7NpnOucS6N|LCIPyf8U^O0%?(~YNpsljkRLY`AM zw3~b`r*$J9q1CYdx^{!W+iSxEQfWULr9ZC@2}m^!_)zJmA!}gQ&u(-`u1)-r4dLcf zO*gdD$U6b9SSkm8hSvJl`nG(NaceJ1WLc5h3o}j1FO=;Kfk!hg)T(hQGtv#y=CZmW zVA*?u-_cB%U*S{&J4N7pB79V2A$^Ysi%Z@wej4h~Cv>Bxy4TnhEwVM(N*006pOh+3 z?Dx8_YFh9jshx*EPW;bK{JY~JV+DEOgBi!@>xBIi7xVD)DTdfmptN_5SBOt!&O(u; zU3MaNaXrer7L3qj?_cMuSAU*(Lyafn{?2Kr=zN1u=ZIWeyIx|1XfJQeQL?t@7s$Fs zbI;N{DWc9aH+7nO-3bq6VIuH$>$R(Ex=+|1*V?qovGI$~*lwk{VKUo6 z&3LFCVuQLvT#R2!WoME%D_F)=|Aw34OV!}#7opt2#tnn$s9tHruM?>Y;~1^U2ME2t9I3-L-mqS`oNL%m+FqyskJPsh^U-qX0jAxX`yZi0#TF&a$hH( zJ1*RU7*&S}7Zv8DTbuLvszm>c%G6QmScTDE6=T;g`f>Eum}?UKN~ktmR9$Yz$g_#D z@9)*rdCz6rcDTR`BBIPLl=WJcQKhy{yFf)qs=QbF&Pj!P<@_Op<|Oao@n+pN!lr#0nRr#W;u{`iZ9 zgr+BC+P7@UA+Rmmne%vwo;jMSCzDV**Kd4BdV91c2&z7#|EkT?Q%C9;k#QQT!LTOp zPBsxU6Enr(Y&gCjQ8DmH^3A832Oe_W9BvbH)+;gZPmcGd*{-h3?-^WcJgrUajYuCx zB_%_bg=q=a;b!#Ybb~glnlHP0zWwPy&ta;?xi!zuyXu>Sh`5=G6H%?tftdHCI~Q75 z{7MIVTwztY%e;~fAs3@@!9|~jy19Qlgl9R$%Bvxcw6r3H%hcH8!7*c4ZnGsm;S=aD z1AYe6LMt(YA6gxsG_4tXoT7+EQ^@VP-h}Qi#`olQBnW}1hi9cGyrZkNV6#BOe0lqf z^ymJTN#P@zQom4U`-G_|Pm8d-1>fz#aHJPoMc-k;k0>^+`E%56WVTf)At~Eihs8* z{RF+1q{2{WV4Yo()kf-xm9b1L7qdW(z#rE6zi02O1H7gaxlOFb>xB2|hdOl-pDbzJ zoL8M~RXxhR@2iVF&)%1{2g4ub7&i(X44tupV9L?HGM3M@sI|67iimMC%jjjck*auK za{7gUC!e!sbn5IU$*;B*firn0i-*T+2Ci?7y69F)UCo|1$fJHL9W7H%V#%{A*4KQp zsBN$5Z~)(aab2_;94e}6(xDt@M7-B}ksGwFwaB;g(*guXsz?!f=T43J=~rMqhU&t6 z^xAgA`}^l-g>F4Go`Qm%6bPpco_0Kk-57+7S3Ac9kc^_>pu9yQvZb#f(YbOMF3o2z zcy`Iia-FJW-KTbL{dLhwYe(Set;@e(S+`Tns-;d=?U@8LGZPW-`Z#I3->@k>fPHOx zyA|!Y-t90<4Sg(N)i~WV!_GS)TINHLE0auWLWNa#D(;o4>#r`H5<_ZH`@V5S)HqI= zOReT8a4&m}m5LzliSA;7q`u8p5cZ0K2!oUX>bT8mxjxX1FE2miv(MSlPD9)B@CJIe zO@3fx>xEmxck+I}D0FdfZ#)SqOSJS`Vf9aPhm2|Ds!iUdhZ|E|ckvXYYE~GXEy2E@ zJv~ntrOGmT=pgM89bf70qqat2K1}M|(@tlb_A7&_sZk7JH zku}(_bZQLZVO+_w^(HX1?ZB};e{6kt^>&L}(vCTIP+gb9i}AFTtZbZGa00gMy>z(K zm9Vgk*DMXi4}9|e5zT&Qy<(zG>ZK)`M{!`%NWbi=5aa<;av z#i}REXNG^fLgTbtA-Kjof(4wv84J{{8qD)?J6h>I(DU=Sv?AtN(pg+s(XIlo#_28O zBm35F{0EbIb;Jb2^UbzA8*9?RrJ*WlBm}jlPwPlShxC{*gPDO=o9tb@p6~ z*}bb}c#hP)M2op|%~IhZ4UBU_Xf?VOdbeNMHcHeJ`sf;W2t$L7qs~`9x}YwZJguwS z9sA)uZJb4oj4`gi>?+*tL7R*Jd|-so3s0`{fUvAZMztl^tMbXuJ60pb;`Z_uI&{nWc@5j#MzU6;^zvnQoly$x`?MW+ zaxy^;+L`J-o{tAECU|R4874DSJbGj=n45$zwj7mFS8C7dCw`iyYyMzdp zIaze}N>Qj@e;IF8HZ|(&rjS`A5~A*WV$WuYia4?^?q59{jlHmSu53J^)DwN zbCy)7uwI{VbB?IY^|G`Kw*J|9`uUWBRUUq+FNYa7grQb$Y*bS zwV!?4o&QUE4r=U@FIA=fJ@ftJUs%gf5IYCAT;3f0hhn`Mwy(3Sk|je)703~uUDjB) z5BylX+UaD~+Awz3{OJgM-ysfgvrFO@NK*gwnLBs=1E#@g<-wEx^b9>MIi@FYs_n4#)&Q3t;cwI2H#pq$njP>0bkxe?2OGfU*H^AnMP~k-LKXDQn4q(8sl`1&@X~fzh zla7yjd$BY@@pZ8m?*#=lO~-)8f#oBE$j_HVQK`NsR(Z2mTz zf3=Rk52=4UoBrWT{ywCB8oB&!Hh-JVKlaHVKl~jM|1?DZ|7l3PbC@F21(z$6h@C5+ zPC8R{j$Pc_4yl-qlahES=)}W0_9OLW`JeOlqK@n`r1^MF!85JUWPfmr}P|L z%T{rv^Gn-#r}j84D&72rHSecG?~aKdTRJTDLs1`ExOt@qp~b0wbx91G^> z&8O4+R?R0|?@Nn#aRpAsl-n)Kn&Y_4yFpQ~YE<4Rxa4@MhBiNy-&upRye+q26Ta z;w2Plv94H$+-vNOrd{i2;svF)#Q-2)-})^N6V|>Al`tkwPu~}wP-QqHTbq$gdE5SF zis5ka%x(_AqEaw9Y^1Zu?qwQ%;N?O(cBU(((P1uTSjbw*ol@S;j+RHpp_M`Ox?m*X znlW2;C`z$h*=kq~JiQ1&TG1M;#wptOHMFY*jn6mBt1beU6L=*9}dByZ2u-slIZ~(YB`%*FTH%v`!d-3 z`U2O+#{RsA)Ps@O{R@SpzQO!{+vOhjC3HC6F>hcur^b3#&C(V%46sk!A_bE;n~_cx z)rbb-nNaocLlcZf$hDKRnby)=bVC!A%UdR|_9;K0f|82TOQS)~j(hh>Um7&Wsm>Fa zrV%{Uar}n4S78(`NFyTwQ-=NLZllWE*JPHmG{PHL6B_r`PPgYc*i0S!kd6*Pmqikr zi%VGp%}>N0&q!V$e}nkmCzJ9>|H02)DMS412=pAAnUq*~zAkOtooS-gD#+v^(tAA` z6L$KIZ>u<3jdTwXIdliqE_(A{t+2!g*Dks<=-h5Hlm#%9o>}soeHTs2#j3Pu-YWG8 z#MAR+)0qLL+ip=*GLhX=q_T3-3}}y9+(Lwv5CQlajW*p=>a2szn@lX65clRdFLcD1 z>(6T^UmM^xWy0&)`vHYXa24$|M4T9lx%tNkrU!4=SOkO-Wh*+J8-B?PbSl+iuI)5{ zWg1P>O6&yE6p_ZsKiClNl{G?Vs&)e(YChg>J{6L6AdclC^Ci1k7dJS6CD)&D<29*c zFgH~(m}jr~?9X?V|3r@nBeQFnOg&CbF`FX+W)+8La6A0A8mQSJ3u=cM8Iwm)b&t{ z(W++JWE>jaL4{7j$DvbubFmdn3V7i_8wdBC&n@g7Ja|JuIhX|67}Y}%1sARhSnM4` zy09pPQ2H51xr=UqA?DEvDvdsA#d;~F}wItjiS z2@fkOJ}ddjyPeRTu#KZ{Sx1GW?(1!1oLUtp6I$_BrZvs=f>7C}PY+#*JQU6C<(~=-DrQU%#5OyOK2Xa^ome5Isv-pf+CDooygN zh;rwBO&h`%?eJ)|rG;3FkY?)>GX?yc>HYm_)AYde*8<>8C)FNJiB zp06OYtodUIL@;;PuMbJD+7{8B9RfC!jeo z2!*ANe9m=w@zBd~HPJLpGM?XY9sk4a@`1rxs*kR<2jrR6b95bN~}8d)3(cYT%T*p0~^RE;oJI|hMVg>8Cbk=HLm4AEcmB$@KF%?Ulf|}i$9en%9$wPY6&E<) z_8U9U(EyE_YxT0=;g=2wOT6C0r6XE&OCtY_F1jRL@ADOk7PYaMiI=Yj?S^>Qf&d-Q zWR>*$Eu-On{IEu7l4W}U5>7ifW_8Ox>%YgH`PXvUKb+B^X2$NW-eAKyMHnF`Ju0CJ z?pTPdj===6OxIw`(GY(-=vL5VLYyq|IQ?oK`QlK=XfHASdi%D$&VKpONyB6;KN7Mg zyjZV^$vnh1QE57i>CeUHb#>5VP$qc#d^$S3%6sY5Jty)d4 zZ8aPv%}lVGJl<%#IfgGHz^3gyZQRv0x)L9q@m1dW%pN@BY!fI{5wB<|cZv)5^73>~ zuWTXm{Q-W=an-wf`3cCus!XMpOPxptwK^_nfC*L?d}KSPZfYrXt#Mdlv*DtUp@X>> zRB&@;dM77ic=wuK1B6ABxckYbD$^Uhy%W8?77x-dB?V~?liXeBT?;7+~X$86Ts;}ehr z+BBJ#VxLD%`|Y8S3!TAb`s z&-HxeDWptYn;`C`;ad)TZgGfMX~?~VDau{(SsS9#)|()^9jD1ae{`YjnQ~wIgvQ!v z11_U?_mWVeoV%d{S6{Mv%RN~F^|n7rdmn>fipCT4m7Y5cbbcOG6el5A!c1oC%H$jt z*xJ6D`#9{6A@pELzy!s4J7M=|OK%SgG!ZUt$fLeArh|U(pXljH^7}Y_Gq}M9J-g6) z8s2^loh|E4!VC5v7`fOgNjF||ZThh!JwmE%FNzwrTRGeU>CH<+9~QiQhE3(cC2;UV zS<=q!v`L$|{TR5Xfp}?Sp{wuyb{!{^!5@071+K%`qsXBQkVtVd6c*ZWpNr8h3WQR_ zcg>8$@-WWqCMTU%?<^M{tetMqrUy(NmAIq}LDIPwql8+srhRTbY+pLpuQ)_mmeq5~ zr1Ej&#V)*V}RWa@V$KH28HQ9DsTS4g-dbc6H zNUthZqzTeX1f-YHYXA`e1qG$|fYMv&y(q;<4Lv}BNGPF&03iejf$#Qx&p9(^=6S!r z;QYcc!zA40+Sj%B+H0@n;xXNvj5H0xwaU%Yinr<_x9YF`W5s))mL=88rQXh83K)2% z)ls8(&sSI3bUa&;;Jmukx)|&2-_XcU#iXr59FK2HwT4y8V~sRX0$rV1p+6%WRmtKE zjBHWnpV}5MOkWyiAzlYKFo|I2A(SUO6xOu!J zp!EJ@?-#Em4W+GGeR)77YWK_!Do&SFBW{!r8P`zQ4~)QsUVYXqn1vNaS>;Bs&=h8R zI(=wc;R)RSS!*<5@H}VbJwT>Q7SoAb=gis@`XspjDf(;&TlZZ}kHc|&oqnU9CLBgb zGeTtr1F@wX5&KEbv1!0Gp-cy>btPc^qDq6fMVhQZ7}77Yqqy*2%NynkdecvbTJU*3 z@Yhosy_q)odYK^HikaJxoJBevnf?6l2tlflQ~%D(&)+&udmE25;=s?rFwm_BV^cAu z1=sOO8Q`Y6nYOD~LGnUe$aRc+u@df}rWS4qiJb!!G>V_*!vgfPL0qQ$u}gKi`i0YN z7B);_e1UHcc``)RQ4)64Q!WLjbDekA1pjwdeDgJtI&wNIj|Col3V-9 z%&n1so>BQONM$wYL4fLhYz!gJc5T%rHtXi46jx`_IM&m(Z^x>N2RY}*EI}qrVpK&8 zimQLTvwsvQqhaycnUbPaj~bZ58}mnHkhe&)W%CY_XNzfpUl1Ep>ess)cG__5&MNII4?eQl)1$?^wVEh19T)q#8epS!PNE8@d9&Uw^H z@`Q9>SwQ=tuO({*?j~xeM=|sof#dfqKmKe?AAQSAy_BI!@!n6_tg_cl4k2Wq?WW`7 zINd5A^b$XtG!bP{?|W!h&Iwn1ue1QHwH7ghhn}1HEKy^h9Q}U=(1+mLlrI6E*iIAg z=mHi3ZjI%?KyKfcx`5@6;xV=PQ=@m?#L$??hIin~L zi8X^GK;(AWw*3GTql_Nbocu1G+`knSIcXL8AC}q4IfIcjyZIp?XN|P*L&^LtrayqP z)%J$NtpHO{$5QdOrTEHOPf90H(Z1XjQGK@3-8b4sn$H+uHy~tcF4|puhB@g{D_Pq?e}YqVb^!L~odydb$51M%}vEfc}*Y)pGa*ly79)*S9w>+*-} z@SZ=557m{yTFin0vjFi~O3}>q_3-J`-D6Fy3Z1;a=lGM(VeVHKA5VzISE$&3~nu14Imx=Q;Cz zMX;(Ckg6Vn(Bg0vkVFrr*#2)I|G$gi9O9+g5BJRhrAl=!DX|w%d3;{UZ&s>z(IV7% zt=|e#>7Mu?JJhYt@B{1-Ki%M`aPNeNeLb0D#{!xAoAm@uSC~>JUhxRwT^aHpyUjc}8kl`K3Nh z;9xiZnGFq-Gx-&i^_sTDcQVxU)wy?S0(}p9Hy19{_{gY;jW&5TJN6|C9*W)T8c;qB zeq(*6+|Ls4jFsyl!bT+uWl`)iLAE0Rl+&Z)du9@{Y{`RY>T2 z*M>RSvZhH>qTqZ{u%f^TD3+7eTBoR4FBo44Zcj@#nrKR1FhVszDUjc^*KL2@@}jmY zmiUoUUbVXZP80WWY{~+%VDg^~v8Oc-|ceFE^6n#np+`5cI z4z{Py_OiKG2->PQPu8kb@Jd<>j1K>|7J%oQ_9j34Gy)2-P5T>}zhp&K;{bJ8SisKI zc~T{sA_g^+L~T!hm>!yL{-w-fN$xzvaOkJ}2WHDKg2$N>u3DbQb@GE?R@@0SVyphbgqVIxi z!OI@c9udvNz}j5Nl9Pnsg4?t!cUl(rQR@?l@_10PRGdo_f1E(3zqRUxC2D*JZ$;Zm zdgOKRaC?ksvvKojW0lQ|}87DT|RTq%2FI)on%koMDs?Jn%4&YtEd`V1AAwM`sz|7UI^ADm5PL%jm7 zPa(4Ue=4m1^~*Mews!Tt`4YK{mw_=xtl2?`J&4`H|L4VtSF}-bpS=2d-s-FCTQPp` zw7l0t>^@vSIZElc4s)ws?(`x94{Y|&Vp$_b;}Bvc`Na>R;wRS_D@zz`kGS)R7yV~9 z;^L&GGY+x?jQBHx+jpL{n+p*DmD$MCc`_O@mTcyl1f0Vk+t&7{{m`vbNk*1e(o6ho z;<8mn7rO%}*5dtojuQrkgGQj^!WgHl8j*cZGFI*6E52i2v4)$FbTJKpEUI1?2g*{d z{e_hd`##Ig$Rq62G|UOW>L|;i4*<0NMh>?uTExDpiSH&ow+oaCU9dPkGFm8kf<*^o z=W1Qz-ZfD;EYjZnugCH~#p$riMZ2dj(_ODQgN-vmYozBAVlVvW1D^WZ4(GT??%~mD z*Qp$|RoA+=A4tK>i?5&O_=_Mn0?pTD-5|#_0fkOWR0+vOtMb#_e#`U}4fDKT& z(=MFCC@;at)_TxMy8wuM(%y@=Qhbp=wI$zRK*wU|xR#&7pv1a&SDBRQ)d@)CX1P}E zvtiwWHbmz8jw%1Wwtc6X!4_s?<+8E(zfTx8~oMzORPJH-M}5%Z*#Man4|eX6QL zYP8NzGNkcfXv4hv=scf@HJY725&@jLh0!=*fU_AEM1_6`ZP!=;2Si_;T7)!NywPkETL zp9LEC@QCsT@4cLIghPR<9wka=?(0BSkKBKd-GHVu%5LL+TcEW|DsY0?8}Aw5Ewpy_|ogdMfJPQ4y0kNLg*`(|98 zS8^~A&sL|DeR{RSUY)MyjS5cd9Jle+m(PbclHB3k4N+RM)3v6$_YB!cy?V+g+d5HE z6G-Emtp^y|g&*e`=~M#u24s8PlPL+VN^Z%;to{X`Q%@RjA(xfQQEz^lji(rz!hpnb}qN3!xahZ zM-$2c9$TH)wB;pXEd`o%zkcyywUuyOvh!~e-kdZ=j0KTmQTS(pXNMSW0g}+6cTFDO zuJ~Ia^*%vXRr_6i`&q?#(d!QYR9^-PA+xFdr>Hw;=Lbi9{3zs#V}(~w4Sd`}+W8Ke z=bLNr5sI`kVgI~DfRU}zOWi#Rh1!ZcdE5P`^~LgwZL-OowKZRD z`M*6G0NEYmQA2|KEIzXe520RV4#_VLNZ-G9K0vZ3pq2(Z8oj|{^5(0%s~$d9jcj6` zDZB>AO=w{=0__ZS>H*PLm2RkBF_7Q|FgCjcxex#$8}?Y$_20E}Ai6j6!K98cs0(z4 zC-fu+^d8Wtkd?`jcGNq7tDR(@bW3#{^oY^ZboWRB#g^76c_VJ&TZ|ZuexV`pOc3A< z@bFkLDe#UuIm`Em-4OxTLvc2XQ2_{7^IruCQ4TEktn}qyj|w&N*?zB2wgnwP_Eu}D zt?8KHd~ZBvY!E74@AKd&0Mg2$; z9O!3S-^X_+XlsOf_nyQy=&l?Ve3*j^_7-3jX{_@F9TgUf(FJeuhgjkhrK;eDeebWOT0Z0zA$Oz9)hPFzJJ!(N+=ic7HNf5<9gzN zJsmQ?FK=&3w{BOQ>tnkd%^NB!Y|I(%K|=e1c2>L<>WO|GO-u1>DHM7~bkWP335t=o z+NcF0c;Bgr1fsQp!6_)R^V~Cq=L!MZ zZdn%|r)X6e6sAYlejx4~xzrI2txPfX5Njg-7L%z_G=b^YA-x)>sxtlcU!Fb|DaQ7# z0?R6vSfA0NBi-!eBF(F&U!X^hw&lN&_ zO&bqaLJJB{k#GEm<#|o?^U}|GXnBbEOp5$%YuqLbQ6#$Whiv`~+9+?BXsr?b5BY}B zg>>9-V);YlbEcxld_z{L^o#+fQh4J9-IBWNHN_WcfDzD}$G{N{bThfucY%!1EzCZ* z_dk4_EE6c$64yg zx=GWPCYhH(|Dklf3=pQ%&ln_KSQoBbHZ+R>QJj$8q<@_wYlYb*;Fsh&rZ50wls1R# zKu0G5pCiS;XO|1VC{pvEQH8rkKe#-WX@qogG{XS3-Y8&)5-QIJ#Imh`!$-X4*Lu(3 z6{9&DH*sc5G@ydVcE2MWiL|1roX`r@BBHc zuclr1{>R%IJ_9@@s@Ky=_g`H9&{jF5bo!3TZir4&{GWSOXp`yh<-}k?Et*CapCF_+FVdnqU`sM^ zZ*}`8H9l%aPSD9f$N?5umqb6?T!uA=!2O?l~<{-JuO_uh3YzItzW|U+>b$ z4uA&oSK*8fP_P9VhlUdqkfqvM!Nx>6{V)wQql^m4*%Ym5A~aJr>AN*Zk!h|AW+1d^ zKZeE+{f{$e>;9=mN{z?_>aix;_iU?Ze_;g}XAY`ROzkHEe> zPB%gkzR8$(%?!aLQ8c1vyDB58UPgBFR~(SD+QcCx{H2>uKhe>O*6JP%f?pZJ;m5Q=plBbx58tP&uk0 zobRK0+DTXdrm}-mEMzz9kl-!aJsueA7 z2q>GseO_vm%eD5uB#Z8^Pa^@Gxo}!$w{}dMzF{uV+1PvJahJ_$kg_-Mex~bQhF_Ad zyKe*XTlwV$8O}-R@3M5OEAw~#GUbbnnR7z=ONY8HmbLi|@dj(7ix#4T{4GOT-^3uLnaMb^HP zK^(6d|K93I&5R-~A@aWd?7!cTn)tJZ0xu{hVEI0OtsLi-iuUPiyV)lW8Y2f6K30qX zymIrP^uR*WKM7lTNDsU>3~TYUA0I$d@-zmRZ@Vlh`e4iF_xchE&2uv%EBm%-NQ!CK zVM8*he8!UC6l7T&>1>QTl(92&ZW7?|+UgrnIyWfBM4BmuMX-H}aYW(jU$pND0)`bQ zNy~@Wtc)$W*FU1`s4x|pC+SKdx%K{|H*tT|8J_n`uXOya(2e4GL}+h;QpGba%$a(o zCti@S(aD@l#({oe#(2KjS)lS$v+R54UU~~R z?O2%AWdsUgPriE;-lvCP z9gCq|z_lL*PQlhLtP?^`!+DSSxIDVQ=K9@Z{4Fy<_+Gs3+FZ) zNkmQjV8RzaNQ(valJgZN4WQ1eDjf=z3a&SFv+rl@MjN&76=b+38yyYY)4?8M2K04Q z(qJ6Q2W=8=n^TY`iEABPK@?)iygMhj)&tP4Ii!)3yL$?BWD3aO#)U}HdQu-CM}2Mo zfC&3bqvOPY+-eH|Vk7JOLD@NJ^I#tX*!xLuwT?iK}QIH%JU60f{_ zUFFCV&Io$B*}Edp;|TvK27^%Pt7?Ut9q4xc{TqkvV}O8mw(Q@;j1j1V^``*Tqw{Zt zeIltpJNybMtx|NFo^{`Xq+a9fe$P$UIcRI#S9Nefrnv$gU^;`XY#u6PM;{U1j8&7! zK1d5W`sO-Ul&p{cYHn2LRtrA)_{zM|;7opTHm{ZeSl?=LU(skw(k%UP6V_~Pw|ov( z5d^D)hUVi_R*XTe$l;(6uU6}sM>$gG1}mPL(|Oi`(W(%jxxDa~Z*zLJWxQDRuzT~q zIhWKfkb%>y&09q&_yz-I_HMQ$C%C$o3&mND>0vEB=kd21SC5t{4US^qzWVLCvnHeJ z_I);`{g_FACuW7he?8&uw`3CY+kP=aS6mDkz&xWDKHVD{@FhLV>AkSt%$l+J<9Uyk z&*sWD7x-J~Xrd3idAg%p2G1sxdfm==XrRA{?TJ!{m z@K?tmt}}U|5tdHSqq3Nv?EsNWu=e>$x@(MReUZ);@_%OxPS;n*9lyHw6&J4jY?7OY zeE$`Uos0y>AG+F@Iu+-yO!9LVveP#5rtkEONCQK(pYCV6EoOQlv`&~{PO*pf*%bjx zP!=yej{ljL9p($OL*5jt52<x;NRkTJT>_X>S^K|l zG76|*oPb$cnyK>E-9D?T9v0!UJA-gZKTz;;()J_@93h^dpbNO`@&1MTof2r^+yIrZ zYkRrT!z#;X@-hH_uW}Jzc|KM)Jf>X&e3m2X5^~;TMlFv#<-P0?+YI1y(q&(dlLDw|*@HCvZ6@>`97C2w#hngB)z{8IK6PAUgW(r~+jVJx~B?a4dZS?Uz*^(^$ob zHk6T$eI)Oay?0|Y=@RWo-GKiF(+R2LG=DG}`)V_(jx_crwu03X!rw~T3d*&IWdqrW z5uN|GK1BLwCdfxY_;$8X+Lf8XaTQ6fnguaj~)G@W{U_BRjJ1ltrd zF*?naN2XRk-ZDkm3TqOZ42iCwvWA0W+PI`;n_zJ3Ng!Lga;VzDA4(+vqC^7P*HH$B zCNhcXn{DF(@G3Yc(+4V^2B~WI^#qrvr%K;SDv=9R&ZL8lRRp(GA}u>EPk&LWnva~N zHc^__wDkK+Gh-5H#sj8x#*X<(ozcBI0K>b+VnrgRYond8k6P$?GxbLv?2t(p@_7RJ z3}$0l^k{|b{u!nN&_>-40A#x}?_DpLM%(+FE*CRkuO12m9s!~ zJVyEm9DI>gpilJuA~l+Uh@SbEv?7)F7b;{{IDzNx0Is^5(@1kg0EDS$*}X!Z3mOR;a6JnK|Luk+B9hk<%9 zoRq)WR%_O9JHF%3>9?QwI^rSZD(B1fz{!0M{qlv2w8VgHnU%KMKTH9#^@nqQXnOTX zy36Vbq%S}fEZ}uq9)C%nibP z_5?aL$lk4$#|{@0N8bdFR*Smceloe2*4mEMUstf08WI7mA*`zAk4}d4Atxpo8g*^C z(0prdumDsCgQ#306<7jYLRD%*6WgRcQkDOvypi8=TXn@NG(pfn$0h_>jNkArUSzHi zCJ}R0Vb}<`Fm@ic;fL#7OiTbHD=dy$#sOS7Aq2k-02FyJ=lrdvnfddJgv#~~-ZwBP z&;g;{A%D>moFu*4W#=n6Fljb|S$aWM#0?vVuOUmc!^cODoAS>N=!=3hPaEF7Hp@-r+fM6#El7r z00oshX`~*^uz(h6^jlSRly@E-rHO|`UGHcR(u`P^(<pN%>6qcRF!~GPf(d)vg_Y#{_vpJ_d4{rNr1c^EA8g843jhxI4GVK1fV%2Yt{H&* zF%x4rHDd+K{TQ98gZ@XF8F$W>VWb^~`xk(I1>N_A+#59dT-vq>6%tJpug9a^9u4M~ z$D`zV@)l!OOnak{h*9w|x8E#JMaNe2u-~+87jWz{@1sLIBqR^N-7GlBf5KuM&ATtnptAG@atKiBXfkf``cvJ0(2EAR z`^_iI4`6wqnPsKHzGEC%?tSQ7fc;Et=rLgh;uSn&(TKR6VxBIK|4JoK(1_P`Ap_Y+ zcF3VwWBaQR^Q&Act3S?{xB{GKYW3rff>c~?ZFNThaH4&-$CJNcJWtKUC;;>*)Gy%F97u=XI_&I5tMDP05XSIceh=m;e6d&M*+Q8!HTgEJW-HtjOt{Tw2^Q3H@_5#ij(;R?iRBv9{0lnFT}O5L0j>&4RHb> zOwy63SV&qGUNxw2A`8-2>_dg&*qzU=tCA!Q&HAehppb9T&emz2r17JWJ23p=b+tlT z(D#~s*&b}Av0rc~R#5r06Jgck3SPA@{@5Et@qlx4@&xUTY`!AE52*918g}UA%%N1D z@S!>Dq$Ehx)wH8*9gmU7lZvrnBo4F4ES^F*(nTW)o2m4{gCec_b(D&%2~aQaVM&wd zuy+U4R6QhfS9};fg)3<7c1m7oB}!%OD{0E+RQPcEpm)iK_(i=$HOA71DC5XiFoYayH6zCdGX~dZHXkQGGX0{G7m>Ip zZqirbV*8?Fzwz{Npv`N*3m|9*3@ISI@Ln*4e-l>uo|jz!0`MjATT#+cxxKsGFJKs4 zKeJ;2K9VEY?}qbs1b<+!pv;3a#t<26F~pV$ow<8m4OldIdQBQdzM+Qi;9C^phM}Fm zG-_Htm{A{AMF@lK(!JTKg0V_Hi&%0O-p?kr8Ix!z*OCItzWXGW?duSG%oxxQeO$*j zytV45`8qVa<2m2Rg?K$Cqh@bMg|L?zT9prVN0745ixE*>X<#5U%!;!#zXCgBUs9!U zPyV1#Q>iws!X~y$)T~}}FD0P^z8p(aHsdr?oAw*T*YQ@UADB=nVZ8O;3r#7$M$7bD zp?rVB<2ELmbyZ!@IK0>F5;r4{@|(xu%rbL%IZGQbeIsrISV+Xo$i=XwcNYm44e!s7 z678xScCO0&fUm#aJa|V2Y=sQuncM-HdiA*=oM-io&pW?_=M*oM4dCa?Gp@e7zZzx` zC?C#vUOF%McQ|9T9QTD!M*+b-4D?I1c3BV>^X&)U4V)}Id-G>XeYj*5DS1|AVRMB23~ zGbuV=H6Q+GA(_trPHnJt#lU$@{w5AaVovwr?e48wZoX#p+ZJebw9!$eX(gzmy(IJ@ zTEE4YTjuw{@~XHehIFqAjJA~Yz+9-KwHcW5cxI<~PlJZ9>ib0@_e%BsBIOvTpT~zK z*&VyP{oj5u%Oq~A%!R)cnFsz^mH9q&RW62A%?NjAJnlj|2>K7>|X9u=FpBfkaGyS3-9 zSsF|+a!FC*tx69>O_qPthcjS^j$nBj%%JqaM>93nJoE@+2KHb*QRUO8T9oXm+oQ8KdOgINbo1Y-qN)3sy+S>@lRo1aKp5?fvr zgd1>nzW;p4u(P4|jQ3CB5PV(EGfk{@!&BP+ewFV>U3|nRg-S}FyS^0qhY$|9U*t04#*4oP`rQycn7rQ+t9lj>aq!r2 zezye2H)v0VpupL!$7CF$Yn)=-Zqhc&(M^xPlzML$%Zlyy$er_VJ0JeZ236}?+P+Y^ z&1M>r;|h24J`Kp8ZCV@en4xs2`BnUAxV|pa4w6LZX$;M|B+V#QCh(vo9(;zLJcD1s z`=-34)N6N8I5vW2PBKw=KFx3l=bu*LOOThyO7%Ih4`$7q-IxoFHdaNBh9S**lpAUiu^rgBcDhwTh4rQ0AcsSG9&77xls~VvmqIqp8 z3#rA~0m$XD0=DtEMm7|!XCxVak_T3ZKg_D8{W4V(o;>^Hx z#|w7DIbnS#ed#iKOS0g8EgEw@4mqcy*@~u>_3KUFIz7Yxj;0wkSr|6y^#OA_B61BG z3tdqrR%szi+MKp=wHiO8*OmfcU0q%iC5BbIf}j#X&$@4c`=8|sOssUWl?87o7jNBI z21rZ#TX8B!4)0MwA?&PTFEx_QB5vu@)Nc?tqlghQ3KLeVUW}=XyFFI`4fNT_dzPea z$o32|=Qn5DBd*3G(qQbCF6&s9{nsvRG(fKZoH=jkX4MD9D#yiNiG4{zC0HfDc0tUc zKa%xAI}m2q$IT8US+20A%DK)w%Kz?Dnta90s*mbyz)o0;iL8$kQv2*R*Cpz}zPD#d z#xQ_2b$BV4aFN?9#=6m^7U)ptJ)c5-m z>EQYl%RO+s7xiGBe$?hy3OC$pq&>j<1m`n6&wI%J;(0m0VOEN8qXF-kNr_9W!5MDX z*kA{eBfB;_4N!0vAH}z}mKw)BDUIo`I|w+v$ZnLjlZN(s7L#)MYKjR@$Wdx#aJG@EclxI7 z2m5brgtks1nG#lc93q!)8>P5aSmB>P$5S!xd3*Wn)0;d$NurqU(54NP=RK52>K3B{ z0(^0R^Xi9tVS7iUt71nnqVJA~oOk19+(tv(9D0JhtjS?-Ix=~6$ zlonom+hAcSzD2Y)O*Ob#QTSp<5NW;K*Tzxqpyl+0I#&gG2u0% zjO&a`()yKaR+XKnVPWTQu3s=LdpL1*V2E4k<~OZJ!C`?TYkID1LqDbH*L_;*afuTE z`s1KzyGnT*L|tUis;F1Az8U&7lydLgOXY(%uPnY$3jIA-uL<$W2hllQ-n4&;X$Y`; z`XnW(_b*@dd0{%My_%}vgzxL890LcJ7bhl8s7o&73Jq+M`+Ae22h(MYEW_(z$DX&Z zGGriynX5b*e`)%aQP#zp?ip~BV{P{QWSqu_ht?_p`>t15msAF06UgcL^GVFUS6}dl zFT4ER0j#6M>BgJC1!t^m)*xc#5ks0bWpsnv?`HeMn*Fz$u;bsE^Mn?EddhibD+kW9Xbu`LaSz(vnFcbAI2ZqDC9P z@8KWiWMrXMjQll8msY@dDSn2z1XaR-r)p|h9k2De9D2ZC7vj`ViS+-ZGke4at37nI zw_JGSm2uNgZ*hPnH7L)?tLSSzXW7)a{oWCgH}BuEU56FE7iyD|VMhsj^_h-2upsQo z#UgZ=BRaQjZ7C2sg7-zfZFk9$3zVK7m|Z^gtYr$M!;}^iQ?RYHN@gelFgwVeY~2&*}F2voBHcCRi!Hn zQ9M`M0#D#Gk0TGM1Qz!hqzUWxTrpYa0Q<<7FGWooB_3Xs5n=tEwUbKtL3)VtEk@^P zuZ{WwvK36GS^GEj=q030QmJf6?fkyo{32?Umr`{~YLxokfmWUIpc%$72{aWwr}V37 z1-`lzh;?GlTZNJ-C-)&%?VOa!Bx6EUfC3lNX3M{;W;pPD z04-Y)Y5bQ<4ZBRrx5bsD3&%{q*Wg}Hjv{6*s5o9GCeD;SQ{rXl6jU)WHY!nn`!h=6 zmDrEXsj9A{yD(?qFYAm}?vszgBk9Lq@QQ~}czHEauZM@xbPY&zD|$a;RbkLfDn%v+ z!?m+swfoEvnM)X~a%{{?b@W)+_uztacH_G0$kxZSV;XZdQkFWe*PbVNXFoLMvkcyc zvL~`c17D(>m6A6n^{xGNWwiPr(4e-c+Cq&VU7mQYn~^p`WxME|Og*$U7A1yHD!Ed3 zxnJPsMkfIOCN@9GYXfzlM3Y<;#jDJ(Sr~FohL*;k^{J&dSA8`6B*%&B$ySk`5gEFo z_s5QAp*HBT;F-5!>86m@BbmRh#}jfH2&!UgRt<2Qwqg%Axrn>=f~1J(#@{?nxEq8`+7umkvqWL%*WYk zXjGU6(m(yb;>7#t-*IAAvqHOkalJ(mnDriP+5WHmn7qR&?-G;#r&ISeHlY^P9W#2xFS{AB??(UMqLEZwbB7j<-c7 z-wP7-&2h(&jqdO0SbGcw5Io*c9p%Pl$T-Zq+vU#|3!6SSXp(Q>u&=X;=du}JKq!~8 zj44Lo_8I8z#kywf555fuOA=Q5{K2p^--YkuU1L6$K?uQT-TFGDu(7zxWxU9v_O4Ao`E2|PIRD|9_ftBrKQ5}m}OP$yNB2f3Kk9X#5*!E~7 zEpb^`^{f8=35r~5H;~TP>zeIOkcnUo~b=sk3)@% z8s;MRraaE-w2Teuj{#xG+`O1&sO6^fLTyyad`t6ZsCc+?sy#N~@N-m#S@U2eBB|wT zE#E{fTxH0=Yhj{Uy{;vSVo~zS5zZHV{W;~}M}?qf;`-k0lJ?h0OF+k9{C#}(B^_vG z;-2BfM*JsTb7E#_xzgKb6s<>a*m%%Ji1|6lq_=*i8&kV?&R-&emE! z!SjXd@|+SG6eya#dbf$L-4kWlyH5pjy}QrX^BUXY-n<(>zp2ssJUd(-M?_AfIdU(v zznNN_eM}RaE}LXuoPU{ib0-(%&6)TUA8=cvh!ocJSqNblZCN&w!=?l|Z`=sy z@L6O~YYo3c7Y#XJw@fdy;c|Et22(qSz@Z93tt(0UpCAoE`CVHp4Qh0H-~o9BhI@{W z=E>|Y(rg%DB*FQ!A7%gRq`0RRazv=~QA-9-`Y+MX+|=m1_}OT(B%kFRCX-VRI330p zWnj$_5?SqxPLr*AxJLL5qs#|D*C3c#W-5q)ykWF`wh zsUP?qBzz#%f(n`|_ya7LH}_oMDqP5PjU`C%JB)G2B#T;ra0GSHuLnD@`p<&+4!;ymC z|FWv8Qxk)oz>}h4l~(<1zBGSZ+-q)bW8C=^@Jndkwg_(pzhLQ@%Xzsw;r{5>2lYD} za?iMLSr0dAX@@}g8(=nYRyu}@lT=QGiukDJ&Q;c<66v}`T?Gfw+{NO6vK6$=iQlJg z4qxfe$L5CT4=sMiPWZ7q&W^ek@W*lGHKolq-JJN=bV%zIx?ViwvB7 zHCEnMGRxvg%7kihAJwntm%w)_UjKYJsne!DS^Y)Mt#e$tX)Go8(7ZNi^OmpFgN>hm zelpS&O$nOT7SXUM!Iw>N`P*7pfImYi|b{%M?zQb@GM?yQ2iy`7BZ7&BtJ6ii#vJ{@H2Oz ziml@$eE)37dMj^7)so~*O3vJmGUJ`xe970nn4X8(0=eVm^=@-QD@$+oT2xlvKaUF` zcjW*8$;U+rts9gHgmIsFYcX7UP5nA%YNtCivbpZ63Uv8JQ;$s?#vA_;xec#NvTvYG6Q0?h{ za1&8&P#F$Y0i8{tOLhC5}e(uY)N|1uGR^WJ1Q5hS|JR)D}Guff0%+*H=7K+;xZ)} z#>@wP#`p6==iu)<@*V{m&o{lt&Wmc^nKm+Ah!A5ucvae8rD#oDAHV0aKB>T8Io2zB z2eBTu{>5%EBgP)eomcOdF7pj-Q~3}-QQhC zFLF@F_!B;=$D4=WHD7vP5)(|`585hu$oHVXPv)$o{OiZJj|zi>bS^#1ishFJXgc7lM`Rrr#daHh%SYmvEG56U`XdM!TAY$o{( zIT!M)CR=GFi{$e@pD9HvE+<~`f@8Ahf|i=nwmO9=UnAWC17Oz%b;|&4&R={?iZ-kV z>gqh#fu>Of-l9a?F@+jp=5z(WiU!rXObb~r|9Pr6*g)2HDP*##iGd$ucsA>KM1j#!aX>jS(Kt1FZiG*&2$M~L(gfzI#nSO@mI5FGzJa|ml zoUWO$5lwH{-s~8t3~xtujdR~S@kd7$%k5~tsNJsalov7OnVqd_(a7kEVlY{st;=fX zQT|fWu6H`L>8yO@3Rix=%4-dvf|Ha-AvG)sivgQ%yUw%oVOH=eWP{_)U!OTk+tpHD z$b`v9zOGOvT`$ObMWMB_5TS}m+|Kg6-%dOC3EHdMAYomHD8Y%Do?!N~V_Ck22Tg&) z0g4E1t*C?L;lu2Oz=Pjb9_C_nu(_OiMV51mpduS|qer;0pxKipgA zj%TScC=32z^wtWM$)NPxuQX&5FXJ+O_*sX{s8}(IAUD~)F@I)MO$?~2+$B@KnxH?A z?Cv5#0=4ebl&!gp=DQaNL~NGPLxm{rYg3DVz*nDL51V>o{xMobqY?Wo%%sV4ISFO} zEx*cJSX?B1(OdCS5p?`v&GQ7ym%`3}B5RB`3*HouKkptyhr;f(@)dcK@dX@%Ja$h` z#BLjyIh?OEbaO=R&wxdcH8fdMS8|QcPD8u4y5Gp^in8lm!yi=#5}h_?+X1nm>+~Gb zAEw&glw6k}T!B?T_f@sawrL_I@DlEOl#RyNy-QAFuYm3?D3l{ZvXuL;p4XE5j=1{1 zgtL}7AZvxX{OV_Yd_vhhyMgN@wJ(8hDW^;9ALWhb-S&T5j0XDE&f!|LGP(i4G0Ep6 zL7&?8;47VZ)rU7Z=Jai^7jAewm^l~SfX=op=VIs|9_lVdL>rWHa9-whn>#=FK3hQl}$!&~g;NxHRB)ouHnj}L_q1?+3NuDvgu1NYL**WF} zMDLrc6JWq5;=?4=-ggf1+mlWywnr=%aH%f6Wiuk+y8!TA^#8Qx0s3}5nMv_dT&bTq zez$gN~s8Zgw>-BfXt`9x5EymV8RJlDQ5a~F6&^;YS zTu18rx+Ae&G7eMsr+WxGwLrRFQR%83#l6&GpcYdKZDy_C9Pp>iQga&}epDw9{%&Nl z2bOGlI19H9tX$Hdj@YCTC>IKz-Ug~O0*C0*Gb^_60H}nb#P@^nf73)0?s;Wp5GB6}D`QMkx$$0+$NVK8x zcq-+~`Pb!A&YM$`yT=^%#57|`7f`-S3CnD=k8`sFLIrQ$ug#b)LK{$HH^||m8s(TG zLNaw|c6EjbgkUT{lZC~WXXI3VjM2Tc#J7=SPuM-^&16j?bRHF`gk0!lRGq9XnHl*G z(reLG{>Jp6pT>&nId699S6{c~_TZqNjonRYftnXIk5~HR9M}pYE^8xh&4^{=^1QHO z$?jL|boy<6Q&Jyj8 z;KrD&2DG8!lq2s(i6=MJXaT~5O(oFV{Qa8^WMRpHAkMvDaUkuUIh^m>Te?Q{I@C7ngWd5_oc+HyW9JnU5;xoTQz2IEsli&uu}whA(wUf8 zss<7*Xgw-1eayJPFvR+K;zDpkRI$>t3?tM>8JzYB#sr+%=~qJ=5B$%&lVklfeu*pC1k1qG$ODhS7)+UB9pc3Z7|k zd0nu>`3iGh-Iya$pWT09LcforE-f;W-k=Jr;sb!NneP)=mp#wMM7xUAZA3h2R?2#P z>_vO5c&u3nB@;ZP7%uncWwT-ZmH|Nr_~OvlF>=p8M|VtXfdxw#G;qEhK)Pj zuP$Dc%A8K3*3fi2jGNVqbz%cF#Hks@#s%N$?o+w{xo~MN- zYzSC)v%0r#)xI9y;}e*JAIYyP7De^}d(WlGCXi1m?!0&zO)sgR|j6=fbqQaz@^ES5IUu;eUH11+aosZIWf9*_^G1GY`a266FIz2R%=s2NT z>Hkb?-?7U>6HL6)Il!8JsTIU~?5~7+Q&O=rUK`<*l6tY~bRIgtTc2hPRBL!-_CD-; zZ+b_w!2Gb9*PJ`7!T(YC1V-B(pI9R32d8bblny3#wd zu0B*qHny&k0bTM(sj?-I2j4kwP_pi$zzr0i(_(~fGDT@2L4-m0!%rchqH?lDb)g_$ zJ?Rp+fR*Xj=mv5T2rR1Z0zI$yZH_Rde!6(XHTc1}SL5sAl(``(*jNg{+~AP$;cy^@ zT@0hYA16RwY1*Vav6P7eAOfKeE(?0_d1J5!*uVUcb6RY8eC?bhlt93?@q0HTr0BLY z=yjCA$!>O;S%^ivVU`egPx+p3055LqU;dYE#Q5Az zZ}A$`%7=Ch&Ksod!@L(d)Mn3!N9D&+#QSC~|W`ENU6>vXJ4Bape)0Xp#~b*}v+Khl~KBpfDTL_j5~%0WFa z7+~ip6({xT4Z0avh?a;HIq&73Oe&X&FiY=Zm>{M1d zBx!f}{Y@{hYD9$M_7UJHLJzb406D8WB@Ni`r1X1(s?{ORH(x?#E0|mA=6}+;+ z{?#7; zEN@!~XR8~7osYjX?!#%I3qNuG$4h{xserzsrdICIi&VHot=r<+m4-NKj5Y12u*?OS zHR%TGS(CFly@o009q@UI2k!>e6NwPdD|Mz#olvR!k7V2&ZXDnFH$F5>N%U!^@jnD# zihI>F6f0VfJ?WR4qgfwkh?tKJEDJoxZ`LO{^?`kl?VjGBW-Twz^|aHS^z432%e z;eefn=`|-W{CBMT@Kk544ee0)uM~m2l((@u=6C@jxU0Pp=-F!{LP_GN$%5-MC64Hse6W&GWy+9ja@7;H5W1&fv+thKq8VITczuxb9 zKbzZ;wy*61J_q^2-Tl^mY%{g$RV7Cp71;T5?7BRkcHN(6r&eW?(uQ;CT4pkuqTdgB zcLJ_gq|v_d$y2td$7_vNxTn2=+Z)K!Zmek|fo~=Mb}cU4ieKe@G@eRPaA1&Lh;?Rg zED=R6eUGKOaNlcyhoC1KR#;l&e7q_}*Br?)1tYY-oc!1TRdQ*P@4m;V zEtQ+2skwU=+D_5Cc)DGFjCV)lL9Q>bBUpou{5)$R^=VG@mb1jxt>i;V;!M2(RYoeQ z*c-`*m$#(fmcD=nr_Uk+TxJk|Wr#RTrt;@SFiYq~_QI}xBYdN37L~{7gOsX@7L4ta zwS*dkokl6wiO@gH|9XAfjJj&M*W%M`ogF&kX|Z}n2FBS+Cc)-wJ!~j{TXwA)^-5z% zc`u<$7*z+a>WEVGi~2Q(9D%wkORwU87K?5_K#ANo~?;E7h;y6mg4)CbLP4NYNNQi-u>&(KRA|?5b?}bUFAYzv6iZ z^qU{pA=$ zcq{{)!uW+&HGQ-PC>?sDc~}qrl!XjU#GezGFTKVZ&YjG$8OVQ@XP^kOS1H2@17IzS zY;-r@kQke*@j0OfUVI$OtjMxhjI_6 zSKW<~DetZ`VKE(~K8V0wt?7e%cs1$Ga{KeCTNq!NCiI@KxD;i(gApNEE zkHxB9GPOfquOb&N7~z?Fonj3qMzddr@>zYe1C5Gt9Oqm!Mhj@gQhq?QabSEXYPxyp zX-aF9>x)c2vf7LZ9*c0KBSRak-R~5Z%6lcql`bPr z23>No9jk};)**WilL#eY?#he!M+aP9C|F@TgiEkc-2W8_NVYh2D-*K5fF4ywO z3WIyu(GgVj=vuk&8L_S1BF?>Rym@0PI++D}%Z)v)vNv8^mF2ZtX+frVk1VmThrcjp zs$51KM&a8F;E@Zb=dY$bdtStW0uI_@2X{DCPLHk>bXHt?hgiDk+$TvW27@LjM+Fwg z$w^NaDLu!vvzOMBp=-;QUVeGYT9IKfkyq|iYIDY6cV$&jJaI5qVtsu&ALQDN9>I!z z*2p3SM$m9BKNFETY)xA&P=i#)%Kofd*a^dF(IiCA83kaJOCH&jb6iUVG558phY>_e zU&?{yEww)pwEoAEs;fgk$wV8;~H3?-o)+skx( z=T2?M#ieEqJ-)q3oMu$wOvVA*@Ai;oNFd)GtT z!S>h-bQ~PIO={(io84~NY*Mp^n7lgIY~YR!Si%SmOu{){X9|6&PYDc0n5htI*H%;_?I=c{D*?nPB}RGqzD~W-Y0h4vsvBBsQBC`yL4V z-cMTc0FmtVv>_|Ob^?`;4gqPX+e67*u{)Xa(-}B?h?xN|F4cA+rL6?fQQMl6zJUn9 zSu1Km#w>)zI@iQ+rduQTF7OM}Ecq_g&FejTc3GLJZjH^m9!%uNJQq{V*87Jlw>3n2*V zJ^`}3T80ZXwKK3w;#%!gYlbhzvc(rVfM25cmRqC7{9PcjcaIB3$cQY-%|6k+-_ipf z(W?3-Di)SROf*97-qMQ}1lniFhG*V!pQL`wlFRSYS@LbhN|G#& zgT_DI$adBG;DAUD{D5Bh_R;(XI)Q^^(`%sL&5Q=}wY$qf3PsZMObEIhEscmyMd3^onv5TsEHWP^ ziPw~gMIby0M(i_;7*Qwug448jG?f%w=O(>c+>fX=9HanLV|Zu{FJAIS+r`WM6FiQ$ zXhc3;XrCG-X>zNC_I0YBZM(S^u1Frb{7m*oUk!Gz7je3P} zcJ3~1tsGqJhj*R+pW8}LhG>cwoS8?(tG7@(R^GrQtqKW2lcA(Q&1<^`BKrL60tRqv zBXOp05a1)B{@d3T>1Sr*G*Q9=dP1)?J*NY#5kc$egO}dV#@OJ=;@YyrOT{KSkAQOr* zua7&I^A1?z~s>#jL$5oPx!kw=h*Tl98F7@EAU%fiW?H(4Sb@$ z@PEZHlc|wnZvFi>{`I|I7Alnr76}g}H`zG>kR~3F;%k>8h8KhG{fdtsgiNS%j^z{^ z2*k`W%*+AWXdL6eo(#;>2eWnd{QloY?T=9SS4{l*gBJXnqfi`$1fPY5ro)JUFldYd+{l8&+l1h z0M{FpS`|b!LT6vI1==lU%-%8Fd^i>*40$Sii-cie0;I2q5FDNt6p_lVhfA%xdrPgd zN+q7^M?)!wvKfaujn2h&XTKyxe4t-^C%H6!bOyXR!kCN(u4Ja~u1~wZzmx0&P+~?c zCtTXcDkPS$EGWqgJXpt5_O_YF2X1HJ%A^cdg?0Zvpz)$ansAVZ zhg%!Q?PygD0RBKFkAj`X>vep+37PDV63JJ{*2n5f-do_)ypPv+ZV$j#0RDaC+KY_( zXtCC&)O4yU{|eA?=;nF;GMIqJ`ZRGk>enM67f5A15=RW2HQ&e3KFX~DZMJa^ew5rL z>h(d@BidjO07uSfF{O!meYGB&iP0s3vVx4#_sT0V^}9sRg=G%k?6^rz^kq5fr}&

    6G~y(Z<3rYwih42pEumqz8+7yFM_0h*F+tv}%V%o6XFf5q!1 z*~}+!!d>f~Nsjztg$L9oNz@4+s(0?@hTi)aX}K|2X)$}DkSKiTS(Sxn5Zi2Z>;1%@ zgXxFCuI4M1ytYapsFv9}7x!}w*5x(9%d{D|4GVSHEY67;UdbWHo|cdaYsc?g6-Z<( z4NGk2Q!q%J$%tlGRhEk)Hn@4LkNwYaDEJad$OF06Usv)?2f{PkqZcC*P1; zL-jD}kRo#E(MjVQ;twqbPuXzt?H;|j76qlrRbu9H-0Jr~?0#s*uN#~PZ%eB^==LJh zUTrZ+$nOg1v9nDV;Vh>4F;<`4FXZTD6@FHAkjXar&ZIWaQlTX2jO;k|LE;sCnXSsh ziXl{(ZY;4)qmTJT_2yJDv3HJzA>6QU>DGTsd+06eWcVyhs)K!8^5;%qde&yDDXFOF zCZunv9r0jK`MI^4n%~ria8&Una?vtdM5tKctNmjk68jq?2y+xy3G7b3&*w7Vp;{ z`_ETx!C)hbnXbzG{A%A+Aqck_Z+Yd!_lg2LiDk%bm$f}BS)S`cll1ma$Wx3>?kO3S zQT_<;5{$Vgey-!!voVk1;V_ZLta!ni;^8&Jtu0hJoi_x@*2p`9*Mb$4L}*_h&M{rF zqjc?EOIb<6+H1|v1ow0FqGT?>@HELziXZErYR38COS!#gkS91lZ!u&WBN+>sp9`&I zd)zAO^eNIHcM(dQW0|YrVd6lBF2#%Ey^CqISmR#M@dJm7>%jScn z?RpHsf(xBUM~jkAJk#1}VODcL_RlZ$H*q;C5iHtW!molq{}Ch|gFz_jMkBWhZ_?+% zn-qP~NJe|-O`wrwdM?(*3x8Q{^razYH5FEKI+B$GOXD6bk2knE4u>hokc&)wUr*|& z*m>%{kMcTS%xE5^X)bff`;shPa^%0<>$aKkBMs!xO+E^NA%H>M(@(2Q`NGC-#mB;F zkrfdi?}|wPmB~?@`rO?qLMD$zG%odzFpx8191)x3)lN$sy4g- zM5`|C$koN=(+Q`Dh`~N5E2|caWQ!ilYp3lo#(%IPMx~1MMba1c`r@h z|M%}dW}WY(UJ{%A?Gn?f2-Xg}Vxr|rhVU&A=~whP?40B8868eY{M;?SV$!cyIHCcB zZ|dl@U;MlvwN$KbS=mmGylU~ z{B=LkuRfz`M_&(`blLx}KXRFXI*N(9Sm__@kKca_IOj~s zI1~UOz#$iMk>cj&E{21)>b-@9Ge?uqX9qc{&yTw0Pju^<|Fbf<6maVn*!Gx|7>d6_ z8>G#M8iafR=qb(&1%@AYSTdh{{p7e5pJL$%8CvA_j>D=05)2Pm_+h4{q=5R!F_?`G4Ew_R3A$iG4gM>eiRkhw7c1#b$`1m z45o50<$-f8UWK0{Gb-ITw1vF(-@4bleQOQ<@-7B{&-yze6>UHo4BzUsPe}wU+CGO!I`L?fh`4*W>2F2Z+Jx ziFfj5=q7Gz zvgq5KFy47^Z!xM^w`KYiwX#uW@c}5#P1=?z`uH@BeUIp)5UE7~6PLyD_@c71*v$KjIG9DXuSj!|>AR@5*VH6NX3>XgP zdLwfr;!fAX*NZeQIvl0HrBy&KqjSL}(){0YhT0Z#f>9#gBRISGo z5)qLQPR8HqL3FlgP1=NJM_F_Zycw$7iH~rI90N*+20$ar{2;@7yPn(Uc$m_x`eTMO zzWDCju%|~9A-uV5ysoOwwz5jhJoW?x1PmK$S6R)}?Cgr|=4y@nPS+R#)7+jSL)bku z)ngLsvZySehnl^Jj*Fx7oON!rj(S<^#AC&?SB#!$5_juFeaewKnJdaN5?>%yVLo?5It8<3|xq)fZ_|D%7tNSJFZwFkR z=p^6tLNMZIXnqE$e1M=%?>PetCKq&SYx!`3^o$7z#8v=S1oU8Xust>(3#d|Ewq-4} zfNxxj@gn^XekGPiZ>eV*jI1MFXUE?;2R2XD*a~=0`GOk)EDZ0QvzD?83oCG}PHIv5 zx@CSTF&JCsr}SGQfGsTqF9QTs)v2uC{UN|+26TX>pp7yjUyn&3OTk1Hd8%8eQ}%dE z-L=9 z`}|2Bg^B@(m5w+N^7ZYq3d{xaiT@o^~zm zmLiV@qSk(DMR0TX^k9r=lR67MQA2N80;wFvI41QEn1Lg%LSq$+Io0;-*6HKvwB33S+U7pS6n zzzc!eXE#$U0b|*n#~21@(2IyYtJ1H$GKf^5@iw72+cLuwC0BLB7r=~1|6sQ|OumL$ z1QZN>BU;J&a(h`Gb^bWHFb;Ocxh+LKKBtVOcMtj7KKd=uL*_BhP<%8_V^)^G2NL-J zq%wADwrr&S(Ja7c*RQV^oqG&$>yH_EHiuD&vYdc;ZZ8q$8z7V`t$s;r|xdXt<0b^C3JCxpixD3OO-qx%Q=Od!; z$rf7xCjX?BB|Y8~gYx-tZ@DUABey|mW5?Y4J6F@x5M4kKsI$og*(JPp@m8-Wf!F4X zt{0%^axq+DtII=P0E@#)*q-G?avU9qK+zyy>ca~-8jhc2^s5E7@57KwBN(w5SQ)ohHXuPScU#u|@5Vd3+5ap5V2DLpt3EKJ@jOr$a-;=rz!5fAYd5ZeAcJgJ3RAXBou9swDH;=yfzF5Kqgd>C80JLk-aO)V1yOs*_ z(+Qhpk^HEB@vWI_L@fykCHfPnhBpDMWvz$FK8F*!i5CD}sf$uqALKS)y;Z;4U1S(> zcO(Ma-8wU*R+xb&x-P~n0yV}}H@)kBs*#z{A}}LXCi!`Q)t=wI+uZq`fa9QK%f%^K z8N4#+CW#%^$ZD_knxG@-Q~HVvKI65{&w!O#nUlM?O_Wjj9tSj?b+a-=A;}y2E8e_U zyeAVD)fLMyv3!N1gS^b^EZ{2Dw^O#7v+2c1QA@lMoz|9AfCJraJ3s0I0O=LM8Lx8y zW7xg$zPI$_Vn@4(_f10e-Y6Qt@G$sBG@QrZLum8ZaFq{8EU^Y+i`h_cT4D6~U=d>N zRuO^(&=KC~pZMufXtW>Df%S{5uTvE}mzwq2V`rW3KG!uyZI3x$^s%fXi);sX_GKwy zx-l0hO6TI8d@BEHe&Ft-geK+E)p5s0xc+^~FfWCxHn}Oa?rQ&YqX%eeK&r9@lP})- zq<1}vp3BqIP!WJDk=qZqhez}h?Ix$Cu?JH58*Y=`Z=-)DS5^Flo5Z~Oc2YJ)ycs-+ z9FAhl9ARv=(E zZhBywK+sE@&CbXudtp@7&Z1f8v>?gq9pO%$=)b8vXG?W(r`(+Cfdtvd48L|<%3*}9 zB@>d+CxRYYONqBTsGX}oOtiF28mFCf6u3zG`VL3T#d!o40kliJm+n#FBj%3BXu#35 z|D11@1kJWRzyR-_SY_0MZi$l$z%6cOGWn`DpA9J<`o8O57#}<8xQ8!pca-!6b zp|%cyT{Mu8I@KGqG6cYi6l!L!@2wE-e(HPPYBU14D4&B}5Y^(y9Nx{+zj|v^W0SQb zBs<)%hz60{*c4{J*7^BBeor8h8f~Z_hTL?C-C4gn3z8D^p$m8qACpKJd|X=Cczvdy z!cWC1qzyd5F#2~4*KHT`%mSp)_%^`Pu37N`nxacU!K1vEibq0>qHo>Qegpr|%VBRg z87ZWq$aXT^a=NQ{ayVr+Hu4>rF(;3GlhnHV$w*!I2{|AU#3dy&1M#l(I40)@YLJeW zgV7Z2wonIK(_SJ~F=%Cl;S&q!kcI@%!3gycef*pNB;>Zt0u2d^MA2gfW+{YaLDVm$y(7FWt~yv7-d!1hBsvTH|$ z8}9;X*XnfHxNF-(9zfs|_Sqc6r8+-IoP9YBg8{U+cdm9c0VGUr4Gf$>Ixi93e^hIy zSF|~{I+&4s)MMcC<$-Man9M4F}F0p`2*IZ8vr1M$vzacR`R@ql0Y4GJtWzAJ;S{bIHy26u-+ zLAN2&^PPgvarOZWa6;%NeCBfC)LHn-3?=UagKvAyDE(Fe{8mq9Vs^^iX3|^v?a0v< z{LHlt;zNTGvi_yi%tV5x+zDt1oy2kP{PrIsZ1ixDE`qh+mLm;`H?K)XX>j4169^?R%0=e?s_~~~n?@i!pRq~9o@gpJ#`=wH<;2VN_M0NZv$17>(geS6hUeOlbPIpR2uzm&V)|6C-&lY=wvR6Yd)~t+D#1_%S{1)G`>W=fT+Jt>#YEooazAav`UYmG0 z99klEgy< zvr{SGDbeoLKlJbuC>(q1kdX!FP1~D;XNU}KQC7HUT}8wXi%0)A8xTf zp5{42Pr9^J0<9&KHK=|XZN_6#rr*$`-qLga>D#ya6D{_Qc5^K==TS8~WHID!^EZIxe}W}$ z-8MPSWma5c4J!i@MtW0yKbU$@?_rd^137S;rWz2>2e(I&E3@l=ECwqHapM*5jRN+j zCD!dVu3p@wGoR-0v3m0N;(Z-8w{?JM<5p>jLF}ge%s`KD#L@K2yGC>r!l_#>(-qlz zrMw#&4(xwq#GCpHjPM1-0l%QvV^b}&n5RO?3BlLRSy%UfnO*bQ=?3TM5HIT(h`!72 z@YCkegXg8ATzJ+%5cTDl^Pf<_|2Rb5!o{GMd`wV$`B!cU(3Gd^; z?|B=dOJh3)ftja)#E`gz1csdy4Tog)j(VU@4^gU)cTKD*S)OZ;q++B>wo~yctz`_t zi*?Lbj{U>6`9t;b@lznw7SqqndoR{|IJ11pq4l8{|9fN7KMnxl9IhlKvPGz(ibv%T zpXDlE1|mMP9=K%F0mCV`?aDd;Sa!5{I=E|DP{5q!K{^ib#4#Sp*DQf#1YMGbMFbnS zSKszbqjX@T4^2<5H=Ql^0S+=jVF1FdY0tAePzPNQ$TdA7?Pq8?{l?XFatS$6CeLBd*jM1Z&9cB zPI$S38g#8XUy0#OUT<=JoauG^>z+dPs{?O1fHX-U>7y)oys`flUy&^^Ac}YhYq6mY zOK7&4s!cG^qX;XjE>~L4E9qPlFF_Y%XIXCB2*^A}p4pXVm_B#R zQ-MFhA0Q~PA!!g@<#%tT?u*Fg-l<&P3>7|?i2GZM00KOOS&>xtPoC%r* z6&p7z)FvGzW0S*dBZ?(vBeXz2ajep8q)@v`RmAOERC9?d0|Wg83WzvV)HT~LX|LSZ z{ns9HJ>_r{7;F>A8&_18uc%D0Nb0ww1{my9ANOXmv=&sJY6b_;KXYxx^A-PE&g+h-;PMa7zY~^_;f>;!k^c&hjauwt*;QesSli-3#sxn z33exc{?zWp{$t6Ol3iyB=jIR>>FuwMQ>~5}O9D}yW7El7s|RCqxA16(G@j_MuLJ4x zICQiZ%hf+vv{AZ95}{kU+g5LY%wF?genh}M;RPTz|ucDVR=+MKf7F6X1h1SyNGB$+P{lO$ZE%G zP-)_U%0uoE3;pe%Xa$nR@eQL+wgW})Nf$YE}iuapZijxA4pw+?vw<76&lz|?-Ba{A$ ztfGt1WJtFhT8X`bu(;>`VFVRfvh%0kjz(SOWYe|c(*k0=ApLs8_~5E=oU#w4ep-bx1{1{6v_1~46!pd`PW>A?^vd9=~<=G+lNB~gYbH+ z*j2vv@c5inNF45Q%;&m@_SWxbio@M}EEI=cV{TNraQg}@pE`xEnsyO(DDD)bVP8HS z#*Lxq$$*jk8f*SmWI@z%-d*@_zkVXK@>}cuQh*^A?<(V;YB@LcCTzfQ4-W<^4p-5R z^LaoygI-lwHB?SsGUo3(KLeVJmza8Uvh+NMl-hb)eAoP}=nEa5yYjY424(@_M#eBd2LO*o=tW%Wu4DvyaV*+p^m7@flB?!{T5so)%TsBzS?f}ohLuG zc*lIOGF=@u9`!yF-R5E@eYQLe?vPV@{{1N;hOjzuGN51eFFhPTWdvL5R;bdAr19qhc=wZ^VQ3-v==NOHFO_Ou@MP4cDjW#V6n=hv%%C#>_Q zKMvSy>)wC(t=-8ah5`JPV*JieC$3*Ox8KmK@TqUk7?WwZ=5hk+2=8$U&siGcP{D_h zqA*IbWM=wVOc|lcvy(lF6cC&)X_V;K6g$gCQpN-6wKSwq0QtOO!rRI&mL(k8A=^Sn zJ3EVwUDEqCz9^3jMc^oqNc6a$CLSY)ceh>bAe`fCALM5m53~HB&Rm_=0xc`@?%wsIj{4Sd4^ zazzM+bMqKYD+Y8oOJf@$8dy$9Gplze6s7O}h=UG{Y|+qO^DPE!ON9ZZlw zz9F`_n9Wnqb>U*yk2eIUEjG5cb{@G zR>on^*hW|N9TD+ZnEwy<>v!0g))nvnJ?22-E|_<%E*akXYvl>nV^-q7wnMQ>^8X;G znL-9DPEI-rC6O~+^$gORnymZYfiqF_z%YWc=y2(BIVYDAze;4(OwkAFI{aL65&!;E zC;2l`$rwx7SMs{98hpKHT;`o>sB|EXxhzcdmK!=G)!6U52pV`XVu>aURX@&##d31p zfEwH8IZ*|dX`8RdAtfFgiCwob^nRH4u!{-MIcQE>kcpg|DQl%j;`Rq8ww-2zuT0lh z{90y@YQ_v>%OLb=WYkm9##TY88#TENkiLox@2;xZOHUBavE3M-SDfAK~S5yS1Ki51T2l z(;R-d-tWDCHES_1B~siUuX{J)^V+wCkGq>Z{jZXdqtCzV4Q^y8%_-Nz?tHEQw9zgs zqsB>1(Ss$TO2Cr$o`XFM6bDLZSR|re)vf}6Uk$xTg@i;I5M2KaYQ>Ln)$?UnZRvXlb3OLlAku=haEQC(;M0?qvF~Sl%Ws0I5x) zJw0)t3MK088U7F#HDq^pVS%91?_5|9%xAST0}?I`=jBGAO}FchV-!{1Ndo&?rGnZE zv;>l#E!^Sx%LMqdWFSzbpUewRAq>SPb;bQ5hS7Uj2&GB+o$ zY&S7`UZ>yBg8k_)Er41ZiwxAjLwvHTjFM;f*`4`4Hg)Sq%*S1-L0}+vy}eRX18X^@ z`~rHpw;iwV*;=KKn=0QBf>Lw(GTMU%`65}Ex#%;0aCe% zqfjxIb36gLqJ)y-Fx_hmPRgyV`ATp*(K(TwCe|p#V~4-()Zwy&is#KW_|K`2ARiVl z(cq{MK};HK^8(R96~As}uJ~@s|FI=p7ZZtxfmxnXI@>97 zb{`IO96vQr&5lu1uv75A-*%pP#jBUM-MZtqW+*lBiuVuJveAnko)Q`$8yc`h1$e@g zUaK^TntV8$3$m2ajLL8x`<nx4t`gyh9$Qja~Pvdk?I3nPT5}^1bRL zWqC|MOT12rkaYX>sJmOprTE8hD(HBUJGZ=co6oal%CJwq>l{=?^hg?ybHwk=8W!u2fNhvh&em z-i%~6QLn`o(4*3LSFYO4;0jo*cWU6u)qPeO{*)TG)ZM94@D&>7W|86gP*E_u4 z1MXvZQ6zKzoX}>1xp&`5To5I7lbh5k-YuHF7yDloiI#fnW)q%#4n1)=WGh;f`m;0bsV0h^i*#2qf z+mbGOojrs?s1|z}y!#PE3{MMf=M^fRd=^FYDb2MccEPRqt6q}|avgrTZ{2seZ;W4H zWTk=t7j_*^ z?wS3sgk9|XUlgDBO+OXLpyNwX3+I?62{IfJtxASl9|#P(3_S{GX3;>=^33YtSRC!j zO;ogD;wI7EJUD$(tBTVbWVk#`P$XGORvPM6VmJ9teKhF?gRn`y3PpioeI;W#jbTSu zj|cD-u?e!*x2R%+ep3SZNSep<7+d3b;~~I`tll?i?5&nGOch0IF^w-wpN-(Vr-^j{ zDZD4aJfP{?_^JI4uWupL&j$0xrhLg3;E#7=@Pwsc$MV`tciz;ApA>5_@&z&g=0R7d z?p-~kiW;&Uuhy@znQ(Q#`uW9>w!_=l=na~C$U0y}4_0*eYBdn8EwT@)cExzXJ>=kZ zG&1&-qR{DJoevcFe9$j%qmd2iBAyY3-o1RaYkR_;35iOk1~&9n>8t?}DR+%iFwzX( zjc%AV0ax3-s_v%t+cDi>CY#$Ge}=vEawDnw?~iDLpytNSXR!Y_(g2c9=!D_#quSj( zJJ(dzCprQ`=*)J{7kri z8=G*bqed_;Tpg@jIMKsI;7O4H+F6~YX(HSw*1VB_wqT#h0fmO%x(hPJhkLz0zJAuc zIry9a11Cp$emr35O&4u1ljs|4f;9r(Kdg^-m&_;x>_6SUN&YO(JO&@y7K2S7oiv`! z9Uc=bsA!;Cfac09`2+-B#qYK)YhGDdK}Rq1>SmyaTO?e7io{*6>RlCK^Lo0&OCgYdsgby=_jNLSc0&L$ zSag0s8yFnK5JZaE1n&;>06bcQ;ZT)TpD44}f1zr^^wD40Fa0DIkG9Y_JI`%(K>aI= zcF%BIDmnTAK>BWtgK7g<74%p7%>aYaEJg2wJ`#%TyEsc4m+?*qH8QOyRjBhi*V^6? z1vejwI?I{wtS>$8pEgkVA{R40w9VIpKDO0AobQaj$L;bdo7_t$=Ov6218T$%k@~*rwvnNO57(9X zUum2eWK{jh81Cf0jAuA(nRgxMXSBkEKAmpZaLc-TlDx*&CevHqm z3iWyNE<<6ac_bmcx@+J1a3-p_H4aM&3I=H96-J*t<#FUo0QXn`#~LyiOyy(hk8vQt z!lK|n`ari26hROz-G8mPpPm1oO%C%0NWxV@3UQB~^xG0r@QZPmOMg~NRF{#>9Wc(_ zi5^<8A|)V!euNQHNv0XRo92b-f=^j6x_a(1c?gRD>1<8i9HoalkT;8Y7YTaNE^eML z>@vup+(c<{<1w?SwH7qDh?)`iQ+vBYt6w4lSOZ%Cm!!*$;CT#b6 zhvlz)j{aGtB>Kr$e$5g5h4Y83;9B1rSlA+P`KtCJRq1xfE8MN8eNoo>jgX2jN6c#b zIgg-;Ohv5`weZlxve!}LmY9UO9I}9c(RmF6l)c=$6&*|B_~LA6u|aUK>ITZDOPve% zfRYh$fkTkc@S8q`M9Z`G)NfA*ul{ZIc| zs#YKMNh+-(X>`-Ll3~ zOpVL-=DnhEQFYV2%L{x}Q4CTwAVXF(J^a|~Z&4s%d1-^Dvkz*(_3OXOq9}rZPdTwJ zhz-_Uh+B@ESoVwQ0U;HZxK~|rvGq1{gnTjPV=;2_4_F9W*)$YJRbt*gf3N!Z$0-ik zjhq@coT^v6pD^j+#j^SuA~G)Qe!n^yKQrL3`U@vVmYQ2sqyqo12pDx2b0rK)bGnC& z?X`%yh8Y@qL$uOjXF-Zc@Db#YN#9uShYBaQXVrPF^3^=?kREaRBoy<5Jzs(XW?sLVq z9P9U$@@wG!uUDN90Q{7@KOy1Yy(;RvIGfY{lGx2nlC#IoDM@q>T_lDzXbu{b2CYwy ze~@-9rEDm61ko?8hS0$dQ0y*KWJTsX3ooK3JGbI_S=ASIy8=XoH^(PQ)GMtla(abx z9;VY%5(!m{O4jy<#3{GROjvMzhY;)2DM+~}Mbi%y%M`VSCK$a|3c6Pn*|<4(izdZS zwY2zQDsj& z8zXf5*O&P1`TzQ|=An>z5mC5{AnDI3v`bK1ZF9%6cC4se;H$}$FiFQb?Ky0M+&WKo zn>+FHbzc(mMqYR3iFPa<$`{dHJR{j-jZDPZgI4-f(G`p`tK;ej!np@J%&A#)zvjdh zCerb7Dt#jZGAKFmg(-B=`I6Io4+krJ39`Tt)Gs8NG1mjX%26+f53!g?l>fIi-M^#X z1Y1--H{n`IZpNLh__gbYos$%@78~h{8oc}(EQuUUy}iB7e)b6Y6GE6c4}{-DlbrI; zRTeGXg`emlKV#v_S3i{Jer#pCW!u3r%DTN8o2D7AnmjJ>KYreEoP>kt1&WDg)`jAFzHxnmF5t4hY~(JLG4M+b#0cQdqP z_8HO}?Z(}ejMH1BWqcbX?i7pA-Qy^u9CfVYG;Y$pI9|k`s4xo#VpS=%_i})ro<^3` zHFR+aguGvRq&2G6yZeEz3^xE#cspPcE|1Da{~5$ibWHcQ7k9sBG_tjQ^evjWWNs2J zWTro^1w7S7|B&%y(`HhjT-VeJQZZ!K^q-+IN=b+zUA}%w8D#PI@&D&Vxx_(A@#%!0 z@~Ut9dWXj`k+YZGC?#`Rt+cVY-mR%K78j(2i#Ud+wcGhobaH&H2_z+uD!gf*kzzV*3ZDvyh${jc2>RfRc=vJa+XNQ>J`iHqSs2({8j z_7$r?YT#p~pX-}r*X-ruD}VL3XzX9<9-?yRo61ydtT$<nMGPHQ5%M=erH z&-C+kOvWmnr+UiB!_NJu%xC(t^96Fuwt*PkE)a|Je*TL0iaIFQay)lthiYtfpo3X2 zKYhxX&37YDHu^Kt?q)u-tWi`_+V{46UwbkW5buXYNT@p5mW~q=_4uykgUsA|660f0pWxN%}3ageb3g+?U1PW=O#k=%De5 zjY`w>XtPTv>O|YH)(&nrUjDvxbOGT|zkG*TE%TX94Z0ErreJj3++!Vfu(Z3^|J*YR z!~i~>eS?Bqmy&`@5*%l?MxK`28x!lvY0dY?Up1dwz=Ud)y!yg)H^t^WO#7`tMHFwT zhSVKLVJ81lK~wJA@jceDoByqv`gcHtUD|xIX$CseTXX^Z07Mah1CoBCW(AZz%z+X=y0J6S1gpXmF4V{k<#n7j1g;*) zI`~YZ=hO{Z;ZMroh>0%fUg8q>gd?@l?ZexsV#E3&OJx?rcjeVUvJDM#ud?io0Ul?H z;7@=LPB2DU2jzmfR2L8olZw=;cp{DV&;YJwvfXjtsmgPG^(xInigha_2?+`9#QVfh zb_Iq)=M(8o!k^JX9#O$q(-KmNK()<1?|m&y%bdnMK$`64M1yiK@>wy9)ffkZ<5{^@ zf`iz3Jxx(Wuk-(MQ{-MlN^jB$+f))jnIBPag~a22mI|%%*KeD4FVZeq*E?Iyj$8um z;6$}6p@*9j_sW_NldUr&9ZvwBco}dq;zvuGgKJOb1M!zSsQho^L3Y`Jfk)c@&YTJG zc3G{F4-E1Gprfm%J8>b}SS*be{W zubmnJ#f`5MuH}!sRd_tLUZ!~1rL~0!_IdH*Q!KabXFsTMm31fTxJVZOMM-$Aqnb|F zi}+1t9M8l~mSXuk^Fd&|9DE3VSSA(dRBhEEJ=-P70Gr%-J@_p^HXWg{dMc2+Z+CJX8^sDE;=;r_9mi+nGP83GH@ICcr zg_pV7Cz87h#c457aSsf2nD8g6PZ}+D-e9izw6B@M)eiS`WP{JVfSFA$I4h_3{y5u< z3?moP0F*1*WZzCB-gh!rJgZlo%t-%O0sy}TVfXc?7>>M%XzxF&0J}$)7t%!`m4gDR z)Ow7yo0ByNxH8v>S8|u!Yf2tr;Yn<&H8mUk&^zolSHeLAXfRF$a)NKGm=%C7uP8cv z273(8Gv#A$L}~`33Aj-in9s3rv8EC-!npSg)9ix0Z!%ahboZDE*;5I|kiRoHd&D4Z zC93(qW%gu9X`kF}Xv?I3hjjdM-~Y$mdxkZc?d!t|h@yZ66#*%tQU#QR-leHj1?fcu zLI>&6+XxC$lxFC?H|ZS}l^Q}1p+%&G5ITe&_%CK=&+M6f=A8Y0eXq+8(Zu9=*0aj5 z+=a7nw(7_(+Qg7MXQ9FLh{6Nc!1E;2pva)NQnP>{o39R#E)2?0(CXX$8OCSU7QvW7 z=jVGwbnlz$f}V>S2HFi>SJhsq2a!{mfto!h^%P}%j*Q&e+S;*Yn0_0Da6h3wm8osF z&(2es@yi``9Kc6}sw7xFfLyV{O*Se!G z2Zm-n_*)X4G$bNxdR=dPI%&=evj2~O8ZeE1kzvt3eF?XlI+j)oEjE&4Bi16uUyE=C zHE06m{-dCfqER=w;Fcz>2==5H3-ZCUI(!l(^z8!xEop@=&jwJq(sf*JY9}~x?9z}* zd`-82EPNR_NIC&|M;vhk2nHEapF07XO9E_>5sc9Qeme?-9xr;#1oUN_G2lmRR!&bY zFROtdZzAzYG9Nf?5YK1yA4g`R!=ZVcH*VnhC3m)hj-xhEbkGD6xJDkX8Q{Plaa-v( zY7(EItVU?Ofm2c>JuZHIN5>*~(!i_NH@IEh^u}_dueQ@CdsAoIvm_Dz)DOjQ;X;cU zu-aQu?e8w)Gg33f5~e(d*wi1ls4tuvm1-$MzCr1!E^7GJF$t^v!(3t!2r!VSga>zv zZw#^RwvAsHe3ropsp-l#y*iebLDdlP@cz$-Xm{bqheoc_Oul}}1`_u0>mn()_^1nR z4yh<)baT+l4FDjT$KuDftmofmC?6&WSOT-(trws9$xq>t;3%DW2ii2Car*31#dGB5TgnLI!OK&uX_jr-SpGxvie?wy&%O`m> zj@Jyb+bC$iUr*Lk@N{r_8{hAP8r6+09KZ;D!hr4>5I~Gjw7drnH!_O6E1H!uteFMg} zG3Sp)IEiDi@m{?7tSx+Fe8(9GO^(AyDf^`Rr!uk>!}^^zUd3h^0AK67%Rc=4r7|MN zvc(r~DvZE}i*!wM0b+Oy>a{)Lt^`=rV6MRT~siNd&r)`^%mD$4f|>?TqkASu?gyvT+ub>Joi}(C-Qz3rmV- zyKy(4v^+^?se&|Hfe4x3X7?oL^9T>O4R$xrh+BjE5z-^J^<6;CE!u#N21m7r)*iy+kw;M&0F6EPPmvu$sL28Qei)-nRJaA z&3}wq%GFu4$uHsN4Kt-M9H|F&8oW}uN#k~buPf*8TG-)fjz=rZWi1Xm2QWIRr_FO!k;8H;MXhV0P2V;(}8|&Qey5Ch*leOUrBPeuOzXWEZ7m__f}| zf+tIQ#u_ZrCAz`TjSU+4@Bm)*yyc+Q(jkX&C-<8d;@;Ln8Rw#tjNE3Uj+WN~8d+D- z7O9>0HtK3(I)0!*8JILhD~~`T_=)`QtK#3D{TYs^LjFs)0Rx~-%81Vx@h0A=xU-Yk z)EM-dB*mP~u2(w!7?jf^JWYALw+|721U?I3=G`#(pm5jts``LUWG@s@;b5-%=l@bo7+P3X4%L=tNJIJ*;9iVcv2<1<MIcq*4)%*L5?#>WaJ*v9%1iSdAL*xh^;YBQ|dBU+f<#(Lw1jPE{>5$6$+ z9bH(hlOk?L7i)m<%nCRz7r45N+-+haK+K;!c~2bI2HuC!QyfMxZ>5~nvzQ?*We+wJ5 zjj6$JR#*j)oNBBVJNJ2^*swthkQUiNTc9E}mWGDLyyNG$^%{{}5VVe^_a_M1@K#x) z+!B=TQO$ZkS)8%>20m@xmmv?l5gxt4T;T&=#!;a2{Rm>Z;+_AgX{-_WmZf&IXAw3X z&SElBzY#v7`ZMIu*)##5HX0O%1av2+=x?qS zKB+1&c=7pE2Ax@QhB4(XC=AUqM;i*Vu_@3)ZCR4VQXQBw4YqxlurVKgs}lXaUMVek zpibe%XWtlV8TEr8(iU<2|WNhCvyjFTK;n z5&~3xzfc z4Tsef-d?vNJr6aVMYINc4#7=GPy|IK8k#eQU(WI6=wXA-OI}926tSJZ(13veGEb0T ziq9Nf!{Xsn(66NvI(&8cGY`GABLIS-S04DoW6>*JRQDUWA^JpXvKwd68TC4jqcjK9(qv7JCSGE=JopnPUloJs5oC zxnHydGs8#|`=zU8gvOth^xYJ^umFGr)S5#!#1Lj*4%4=`Ta=tjlP}k9$G4AsRP)lx zuE8wZ*v_=KnXRe_p?yWEq(l}d>Xxd<%;(EY*wj}n>IiZA;(lr(u3(AXoEvdVDzI2DnKC5#m<|40twYaSUMSd`sE zI0ATgB2=Ke#@T`Y=#wu{>zAuIZM7QNU>SFlr)RJU0Y|w7!~NVq61uVu_H}f(Vm*R_ zmCG(TzSrTa*f*;#xF~)?BbOO`HvBh-(<2hVaNlnETtrpP^F!74495k=D>pJrOE^i5 z40vtauD`ocS`-s+Ul2XjEhnbJmN8~*eA~vI2^`!RW|~Lz8EsC1exz=%sdn|GrP}QH z;pR}AM`MC^@)i23iCf{viXzY#=}RJZ*?_n&7#+R=R}LB>R2;((0WOzoxD;4u-56T? z=8H$vC<`J^J-NZFdK?fj{J85k>D{1OF=^oQ)DJE>?UjgYL%Wakrn`A%?G~Z4m1qH9 zI|Afm1LRkWA@G2tjMP&%5f+GB6|AD7-*uCp?;U!rpk0A=^G1?d=p&ds2jQpt(tCyy zW=o)&<_-1^4l(z|m{jHJ6jC-k^4xS=V*0zAwq+sCZ;xl{ibFwaAMkEr*-&}W90$mz zi7ljh4{g(RbZz(*iEC}l<=+n`Ud&#J?-J9%!~!#l0b|IG(t$04XK%~Nq(upp#1yN* zp7$+w7as{`yxkPJxlbsQ)Rp#yPFY3PGM;(2m zHGU3x04$!B8SdoLg-*VlrmDl2G&~XGss5)j3!3!@qw9Mf!!y3UWMICodf{jGUwLKe zSM>8|7`r+{Sa&3KtDc5%4=@uI@F4#&YpJ`JE`MZY4EV5O-MVSb^D41jKnikiYsuzl zw-o*c06EMckLT~g=*Opd=_{Q9_%qQ@&e%3G8^c;^68PFB56UBY3*O=c;ALTlzdYGg z2s@ldaARS=fcgm)m-`-3ee}A7rwd0&*6$<$G@E*+cagYx;2Wk7^~2;ehGM>ON}<*q zH_4lu>Hg!%N1)Q`0pxwb&U=OP+HEz^_E?yGKJoN};-^x7@{5M9LqqGIg3fyewv^V* z(QlwjQ;A6;t_zwB53*7(Ccl+5Y%pL+RF3*;PTcDN?5(LGdk)ueG0O|@$Xg=s(doPB z+|6xVU++3LqU*Djh-Oz?zpfa>;lj*wUSczf?&>y|1uX+KS5`tq9%7-I!Y_aIutFVZ z%`X>iB`jc{S>#P}moBbUA(}2&OAwWP4|XU#tiE(*1RSaMnS3J0xMYHw2>@+w;2eT! zwFWAaA_wAHaHp9YS14Hf+cui_S87^yMOiWwjy5`cF>kz+OiQO3vh_cI# zjYjy--TU7Gb(B||(9#b}@jN1KpSk74bm1R%!>DO8R%e4qZ_zCHYv}7drGY2>>t|`? zVl*;d8xY^Vd2^A`O(BG%Hx<-vy-2(bh7Nib5Tpox$M19&9~_)e)km+X-DG8s@{PJE zQE5k-?t!ll1d{?{JxTKeb`=^AEis_bLT4Fg_*);R-H`-4$j9q2sb{?K;YK_3gyqhd zjahpkxA}5H(2Z@^$x&H;+nR+0s(!$loG^5f<_`>UB+hktqd0m*IsVu#44pDi|) z31eRvmJnHm5$g$XuB?-*rr#q7KrWVaVO2RCVWG*B3fLRT3#C+SENz^iyyya3(v)gj z=v7&E8rJ2R^@M0XH87ChwnjF>4EqB;QgCwRVv_f@u%w*9cZi6L}pay-Q|K=a*MtFb3j)oq|tK zd-GwBZpc!DV=tB42sZHJNB*fJ~^48C@CRfesK`K+CWt6q&G4}Dsj;|(i;2)u%-uzRSW=1aHI`e}{ z6YS%y&=!&cgayAJ9R+ui(=@YnaTxj7eW z%k*n5w+?WUdEty4x7}Jle0UjvYkgw$+)c@pS`mpof3xdEQ#L0T@HhWNaFjdD%y(wX4gj?3<8+w2r z{&UT7Z(6xj5T0|r0o4A*3Muh?_Y_&^HE7u(8@H1Gw_%m4M32pV zXZiyu(5@UXpLeD%-8pH~R#$gk28^43Acj+rSnLJ3Q?>2Rkmm+w+9(1342e_D>SF{ayd3r>ZT!gDZt=!XG%V~=Wnk=%xjW8M%}pP7>SW8~r7QVi>NI@u(t^rO7O-0e?ag)zpIft6Jd}83&O0I8mGP zKYO~-Ee6T%F6&bj@ja-8bp6^=_!pMt-%`-_$0Q<>Q5=d^eRRJks_g?L=3#B^t4f8P z$+S=3wP-EZe3lCny#Ai2A+oFaCWW9|p4^mWzD|KZD6f@O=ILtc4RNxa%D7o{?xdW@ z-g7de=3sA%Nt5l9sZ7jiF|kEC8X z-b*QN=gBho9yM*-?$i5gekVI|iYtA1fT$F6##^$UIFs)j`dU0WNgxxO`B`0+Q)AAh z?DaAB?{EpI7yxrHtWomp#_z-R_?W$yr+y>fs@yO;+jhL{vxWq3NuY&fuX61kBS#+6 zM`gE==8EfH!nr}k;eR~akHO<^QY6=zfA3;CCHa$+S2^h19~D@Y8jlux)-I7ct@;b~ zjSGRFKAHwVGHoa7zg4HTIzBBi_*PiOUDEhuZ*z$o0G8+K!nPd z1z<=<1Rq+#4=hnqhdZq$&Ez z?4N5es+8^4 z_kfpVl_A^(3eiZ*pmW(;QDfr&s8yVhVx6r< ztlvp8mP>of>{A8v?6T3m32UDl(9D7-I0b^SN54yi*1$2|j zxq1mH=)nrR#Si!T8{GMJF-OhYMTwF`F@{D>u!3>ujVDM?3|t+*ySyDrF#`BiLd9YB z12VWqlI$X3T8L}ZMSc_n{tMYP{neDz0Abq{cu zmYay@HC#X|5+^(jCUSRCzVn!-Jb48uh# z35E~%i)7@Qc$YZMJ7Z#hehbrO#_j;D`TGFUQg?SY)ma0dco4F{m1zm8*1Z3lsIwnOUV zlTOMj+TRc6T|EwV9B~u5k=O_DJ_ohw?mxk8e|_a44MfB}e#p#R{&NFxikFs?`(-{d zuDzY3B{>o*Bd#~@njETa>>PeAz;8Bliq_>y&-AB{=N@ zm6P80Z8(nh;ciBXbMZApWeO5*)Kq?LTX}yh8!>GoJzyFs#mm>S9{Tm`*O8?jKB|#~ z=Oi$_8Y@$6tJNrK9@;BctW;9(>b^<3bOlFbPf9^)s7aa!<~~AmwJC39-f~u%U$xU4 zM@6>S@HF`hR}VBelu1sAdDm_(4CyDvU+IeP`te!lO$)wVOPGLv!>-U#ft0Wa8g+?< zp`S>Bqq*%1g{||}Lx+}3RS9h$X42e=_VyWU<^DmEov7=619rmoAJ3w4eU>ldFNNW; z$=9|IDxAj0Eo-gX)j4(fcT*>HJ-j1+MGHjvodURb0Oi;Vy?j?_Y^4_Bu@iGp_!%C; z`d4Dd>@A2{%)TNiZf}pGt7Q>^jRgiqvXw7_08w&vAPFi6z zpHeJRe6;G_S4>LVV%eg5CVlp^Oj?5l)!S=$w#$u>HmIVHZG0BNxc+Gy^)Lq!@0~J4 zCGdGBWfNN9*PZ_ZFWv`Wet?fG@dPwvXF1M}IjOMFmg%;25T%wg5LDHEQ z=*q+i(|pxvC6kulBbyT5Uw}4Yr|nxM{C<4$rRgpot=8|_%s1-*0qjYvKvxX;XV72` zg{k$kWU7Z!o9DC5s9wJ>}T1!)MhXM->#dT(@uZHmZDz=Zj#;?!z;ybm;|2^Yty9^RYC5O*_7#g{Mp~=;Z&wo=Gi<3t>^{ixTOZs zA#*BmrzDNy>uL8v)e>QDhB+~@rnl4_(I~ic$MTaF0QYzglN)s}`kt2L*Z*@B|K(Lc z5@*zGfE=S(vPCmzgTipBKDR?6^Y1sM%Xz^x*t2h&_1C{k51*Tlr+`+xCHDfoMu}&q z?-$dSofQ1*w(*z!UAmsE92ht|(2w7pXVa}W`*=sUT+VH2{9Q>z1@b_0%H0^QH8I`W@M*sZ_ zE~o~)etqW6n>X)31?Q2Ai?VQHPj&UCHz?3&KMM#%51*!f`yXHRuP>0cr+s}_=)CIO zR&?6YAO8qDL$>!}#%RT1QWu-D(C8(?$@y?x3j9Qd6a@tZSdFpW!i=MTdaV2pp(z-s zJn4n$vt$#=;s5&Hf4qu6-T)BxGq*a=MY3Z5-s1SRY#b#TpcfO!MIMtt@Eh9>?CjC~*r%|F8Ff1Go_zp*O|=pP4b!p?F3$3XtM7m-~7K|9BJhW~y-zt7)zXK@sf3?+PtxuG`rc)ZiGQ7~3ja$B{jX!aer&O_ zJMB1k=fA#p@YGozt1}7yF^&Fp!T)q)VE%)>fH@Lk@#ddC^4A#-QYLl4QVCoyyZB#E zlY<-Jst#W!i75To_a1l_Xd%Uf{P!E-%z1EC>uuBBx_`U($e#A(e`x{yIjjEu>VhVi znExM_iySs`VCLVRjZ@*LzD&j2_wGG@{``5CbO1>m0XpwaPIe3|1{H#6+0*AAA@r)U zfB*xXWw_zRcFzFNBt{2)2e@QSP0e9<|C_7;DT^^C_-HA;W0fZvd@k@m%r0r)ODs1Z z`+8Q2{GsT{LVFfS-m3K2d&E&P`#SJ0N*MOM{scg4<}>XV`#;Kh(RmMqI{{VY&SdxT zly__!L(L9NgdEE2!qUw|H@&!&ZBV0In%)G+;Pw;=uky_ik+o+)y0E6yWKVPp)EX4>^@@AenLK5x4gR|&5#;&ocdCCH{o}aE?5~fyx zLT2ppgxAYri+>Zq`+a@LmCs3a@Zt(nlks|@5@U4_MR4PlMF$oO{R7-Sb2*bfap-2{ zVS6>zb1Rbu756WC{9GVke)=o9^S9M&7AW0HPG$am6IVTp@!jXKWa)EgIbs8d%nDb( zLysKBj61z$k|4@{57fLz>#<2*_H3KtODb%)ZjGenKZ@m6;&E=}l(>D}E(=>*=fm7I zg(bU~+?VwR$k!5q(6t6g5R~kts_x+Y09AkKuKa1lix;zTi%~uZo%+<9Tl*dNdX0glZya|fOKHg z`rB$`i84L~B1y}R??JxB3|gFQijnM((w2Hu>E&Yd7;}05gt!K*o^x3oeEdD!Yf`W% zK#r80lJaGgvG3ux3Ka+ykIr4=V_Xg3w8Ms(J9Hyl4EMJf#}muVeSs8je~kmPNSR;D z+iSZz^(1e;^eI#)c~c9|gX)2JQ|(rQP4+ik3-l}~_jwINXaF!h{r-AiTj~fl2}T+Q zB0~d_Zhia4HMV@?mVjf@0GLOu#*O_zej!Bd$|tFSmpxxVNE9p;;Ajld--R3Q~#bAMU&=2j#VE6D7Z%vSwWAj@alxFpl zcevD(DuuBxz@!B*hISZB49_wHHmDr3BaTOZ7=ony*Y0;AR2s-TX^>qM*#~N~U*NG4 zUR$1XzY|aX3}H^3yvTpa@yahMKsVzNP>&OA{&q$1G5v21DM09USsZyRxi>F6_8Eg% zg<(^?>_dA1bZ5N`cC3Of6VJOoQG*Gk)~<5ENFI|Qw0)=)`qSAeD}`F`p5j)Fxc<&# zW3B_^N4&k(FrDqTBnsQf63l?V4~+G2#&&{Qk@>)!O1U4#_gM5`uvlBI{zJ2T%2}6w zqIQV^C(!+h1#lp*1pQ6GV*}8y27`7=)L^shYC7t^Ri@U`XtmU+Wu`)S7U&bEOz-yO zje%iMmby6uO6+B=npWy@)U?_FvMnCIzz3$TZGZrP22VjI_!8hZ(= z;UYM&#+8)k20*$B&~)!k3948FxT1f8%Z#wYU0hll%oUc@S^2?bHNg@-wC@Z<#sTStj;Wg= z#ir~Uilyd$-)fkfT`SLHkUK_#_M;r9&ojl;yDp5{d`z!n0$bX${=}9@%w97#r*(9P z+p0;hlFH8xzd-=nSAr==577MPh^jH^dbg&#^RB7G&f6%zZBd^?TyOXueTDa~Q~tB^ zY5|AgamcOI2KzJ*AMdJgAoAZ)FjRO;lxtM{yK8oeN3!)~#w)DI1Y+y!~%VsmQfYgZhUGpdA*Wn z&k@k1%8<3Srr1o#7~9+fX!TM{_2=hIalFk)8b74AJMq~@dNWekZl@u|eQDI(`1D*4 zocnR#<1N=lKsGvwx#_*1`fi&f*SKe}#?GQ?#RUxO+^F#_ZmpsyD!kBxA?v5v9ds() zctZ`OSFTs8dh&e_KG(@eqHcX8t~(biI^g5OvpG6|WUW9{Re_e{hDSz8rC5z&D1KDL zzAWDX-&0boQUL`1-iV|3{sR1p_Q8F3%}F z!}Z@Tcjbw4Co@xe^{d&k7tXRHAeHKFB&`A;%W!ye+ z=3nT@wPoD)Om^R%23j)`iP7B~t8dtu;v*#wddbr~1d|f_K7B$N`L$Mv78540Ti@c1 z(F}tr-@BDPOR4NyC$@kn&RXRic5OZdYyiYp zV~=rvMQ)31mxh`IXIDHAMCPeQSE57H($hAgIK`$nVe~Wdz8=ORiESKJA*p?=*GgmL z*@s?)FRZ2?-7BT_**BWg#dg>Jwk{>M2Pdlc1-|BI$|#kpByrHNHJTmK{wQ)+i@#$y zr>k73!LgFvv;VO)oBaDu+@_JmXvEm_)u?nQB7szc(y!p8^fu;5I&@nAknOP5~K_lN2izqnc3UCygkm%|JeqR7BstMtyz_4yd9ctdb`?xx7yGj1Y82# zmmOhEIRV37PdmS!YJw9${LF7RD#RI)akPobko6pYIj!O}W>$2y2lJ$C?iqTKL!ke;w%2VW7pmL9R1sq|UDHKfDc{m4~11#RGr}$yca+L$n zl}u^=`t4~YMig~uGslEDNJ9rJz|%r0_*$Ny*Drc`kJB*c?Qw&d#z3Rj>_&gH73Wia zf~|0RW#2HFsg>Xj1T;%%+2v)8j`MX)209^W@j*O1u^pH%89l9r-;p9!c@bN!QKcu4 z_*j8d;P4*ZX%|uVu~h>DkmpR&X7HGBb_#DX6DV1uWE$Mfx>8lHbou?D%&u4UL-d zJ%Y=(Z|l?_3`VPU?yodi2;~)SmRwc%gA69v@& zM^RKwA|*)t%-fsix;-K;VHk#|#A%m;5)56R`k_tv-&^*fB68S-?|~YBf3w|4nGqoU z!5VmaOW&{wFMH5jn2Q-@yQu@3c{B4LGudp~pF?JPm=Yi7qOv9Idg1oY@yDu7SLQ^T zC(K^;Wqejj$X4D?1kG1MuZ^6sm?vRj7A;_K^*D8)c>rBXbT9+{S}CTr7QiUOxgoFg zG}zlWY&++7z34lCDCZ%y{X!n%_usY%vK@Ovm>z5r6-sX)MsilX_Ly>!#E_#6=02N7 zzoWu~C3_=>fg__IHJ>%LtW(LUuRSYuE~clUF@>OUg#1(rs8}4e-vY0d>)F|+!3p28 z-4})yU2(VluJ-wBQ|S!8eNk^;)xnw*i3G@I805f`?c-()u$Ms5$Z2+)T^ZQEpPRK^ zSF&&OqJPYx$vm>1~7nYzbcMdqxVV91Il1$CppjFsswUQO{7K1lr$;XN! z_Po6Y33Ru_dJ;|lBUPdSsnX59!3N~ng{aXj_7eLGE=P+ac}Gc2;HM>yYKI*1@ly5# z1^>ehKltukV@@r~rMFbYduTF>PRh4&_q)cA&2_XR6P-v-&L~h&5R*H);oO=gLuVE0 z^Lt5&CH@SEiaV$k(v-{lB;D@7~JThrOZ)N>=PY6dYzI9ru^qYT2pyJ&|M)27W|B z3XrWc;z@WYr=;RMxworHdaZKaLI6U zA(1|B0EpT-7e|zT)~Yv5MGAeu87V?SaQ zMw-=!D#0#zcEG9QTl~vF`c}uo2w8>PbV*}UMs}VR$l+)&3#*T+qz(T}U4^LCRrupM z0Zrq0_D1Muv$%!nHRqb>NtCENgB2Y0>ADZKwH5H#>PXotqEdDo?M?gao~!D5Rbrr0 z_|=}#f|d9uW#Li?74;x?p^(Nn+ATLQOQ0cM@^nt~Mg-9yL-5FF4t}DEqFE2)o+v1x z@jVeqPOqF(YC`W)bXx-HI-wR+g1P52kO2?>9`5&<+frXDo~mX0$aXco7^vX{N}O&b zz~zn43GC>RItuLA9n`M5?X=s9np6MXjaEA1%Iofh6oD4J#oG49*AQ+q+;>vn8WBQO zskW3D%7M`^l~Eo7K+N>mvDAb|#I{t}X!?oeCimK1>|v0g>t3Zp3yq3mseB7T@@L;g zH_6h2eutU`b=LDz>#r?zq8gp-lTQDB!tT({zu@|ySaZ0w?_d<`yDQ+KQvm=BFYS9R zVXOc@T`+mgxf47K9EGU0dqG46!hO$=ZRab14BPF|&Ga-ZUqTdQn9qh@aa%ad{tQX= z-h%_ssXgk41L7(!#?=NTTA?erG>}`$PU=7Dzs-}7ZMo-;K@TO1CbNSo&_?m|_|g`W zsJeS@P3*59XlvcwnpS=hMg1nzd_8MG_9<>%BQv#JC`Vu)DeQa`5DWV4=sz4+646sC zF1r)%8k3a6gu_mto;_R0*HW1XZHU?WETj=Z{uUQyr3bZC2)f5#VU~-$yFsT*nhh@EJIlG`d#2xy*Tx6Yq`ni*(K=y9`XK-PxUt8bgw0qY z+8mZ7ngnO`_YYI*=AKm9^L7dywY~r+4ZZ4TQ!@QIA>n^jLKJ&d>*u}w!6GxX!K`-OK*_)zXrC|zzzWysKpIzCCY zp2e9OX534@Rg|hV9{Nd>)V<0ws%y|4qp~)t_1z5Hf8wD#MM!i*lRCE5Cz%RxWWZJI zU$8O?SpiwpBKEAIQKPUqOvsS=Q6K(C70S3qMo!O|G)X|;#4{B?`n_UKLMtS0C_{JD z8zQyvB95L6PV*=Y`%GA}!t%s&uZgUA@h+vc)cyzmf@iI`IkNM6|IrO_qcHOT(86R( z7~}RU+V5V?RoMZudy%#nzwh{ughaDwHnopO<3qE?zPq}csh|E}*s|1|)r7QK4gngV zo8K_A-)rBS#$l6ryRCO@tT4PHFCSpX>gEXgSXH>IwGPD4EigREZrQ5i{q0$Ph(Vk~ zpEMo&Gp|It(KcL&hFXQRC|V@RRdgtETRt>}$xRyqGjj#}*^O&BY%A$$)$=A9J^+q& zXi?!k@7>b1kGmz`m);yqN%#JFQ%UbLRxEi7^>hx}u5!NaNW>;C8Ss(MWkt}bcGb+Q z2(p*iWOjU*COf$^ykR(OE2~-P}ICUiWlGLc}tz2>1Sbyt{14pYX|KoWqh_tsN>RkXhVEv_iBH% zcB>w5t3ZJCsjkf`K%Up!(-ZO9!c>s6Z=qO?A>)aFv{71X?`fNgprgLMnYH)|t1)&8(FsfVw zGdk!ejuVO{ULnS<0k-*KO()@dSc7H4{w}EAu>w;rP(A#RDC3FF$56DH6a` zqo6&YOeKKnmKvk}xY~ODsG`*!%Fo%6*)7ytm6gr6r^es38yf@YY*Y5E02@Y-C{p>x z{D>I~-{?(O*kssVSkO;8jK7Qfs-NTkgH%?%+;|yEE!zK) zy<>{;{&sFpL&e2c(+5?E7qRN8r2!G~ecNya8Gdb?mw$69&L+D#VxJr5QM`dt<-kKT zW3M7!57%wr(s%Bog)&vsueTbqkE3*#^r|FK)%(JtD`Y>9t5LqgYs5Q0^)Z-oYMZ&z zc?;Uytl7Gf&oMpwKGvlWSA@+_gl3>pvZUE}Xs`2KgH&BuN@shHOgG^Bb-vX|Q~7&O zbp7qI%1g#@tvHc5{8zmVKku2Jo=%D&h3|})#Apby4YtyHE9h)>T+=J(%x??qw6Yi6Wx^vx= z)F*Lw_;5~xPIep}CN5S7gFa(b=QQ~0$5K6vVp&#X_85r{XIE!HR~o$?GL|bq zzRus((SyFQ7|t(}@!8v=x-NHxR=lA;FWbM&}e2!<;OO$=}a)DUCe79 zaQhY-OR|MV*F4V-?I~&!NudjnyHeiA-ZOr1K70PI*Wsg+#GtmzL<8e-vc7%pS7%Q( zZxM%3u96Fi)ilQEaQ@vD%339Z>R#t?03~IzLM$w=mtG*@iRO823?I*%iZH zim*CPj@!<*?Z1WPozsHSM37(lwi2dSD}WHv#dBS0>;yQ);z>^{%bi?akB z)h4i~gKDW$g6h2?td)Y&x6s2<(ru69*&HySny*>+9)fo-&@C>nAoHZIM@rmG)fKdT zK>bB5^I<1nkvZ1G+4k>q*(pQ1gz^@b*U;?Bgqp49zba89H{FmXkQhgHs*R_fpV>^Y6`uZiqwWn z)9U?1f_hL~rb^ksD_S=y)KovPlr;d32TI4L(#Mo_yA$uoQ~gXOX%{RSA5=_G@UFZ2 zldws1>!7Qg<1L(DY0%j}S2a%?q(Z0+r?FW;*9Iw|8pCE`%1RF(JdkLOhS|LcZAmvs zvZI5;a@9PT^I5EnqobZ-z^r3deMf26W=QhPp2@Fc5g1CHBxq01_XoPhlYhK=l8B3xs5^_LZ~aQad_!@A~6%O z(U*3PNS2F~mwm=Ja6Zk!cprrzyPzxBD&X{K;c0{qWaJyEZk>}5FTQP{Y9&$9;454L zarc7XW9ATsHX#X_xkkB_rH>zfK01|`ig?0J=aX{EM(VeH6ne&Xth(6f^M}uSt%84_ zdFvI@t4FLecj3vWBOcxVZ2BZyFDuvLTbSa9ZsFSXKbZJ#YZ_d!qu8S zXQ5xQsim-b4m=FYDe(n@QZpdSiE+mybD^keRe%0$Y`Iu$wM1a91LmyN=#!hNg?mUV zBKC>i{`2z-SZ&X}k^BLVEq&A|bJLCd);q$pHO78bM{nKYTpPrqEJ0j6qT*R{tY=bi z?{~mgfbykYhE!~lb4kY@MD|d(Rvo&d>sPtcb$5pP>Rv0^ogz%x6^+@jIS617JC4w=C-K8dpbFx$*>1y z&&;QD23mhuuExX_l-wv9rH|HkXyDPIu^QFt^5z}7(N~=?RSkX3qEmgA(MchUTt@T! z-A3&M>g8M~)s3}*Z??MGbS^;Ghh#q!iKgIdvJ4q^ZnjOMQ+M6r&dsW7u1NJt&?pIi zkrb8^e`r_(JQvZ=gC7od=LM8svU8F+-6Y;!_KT|HdF{C(XEoP(DdO0pFj3L$gx&sc zEpt^uUy5qm%<@%uu6;uz;lW+^#|DaY4eQ-vOI!9jVRb(9=|>|!popu5*~TqhOM|;l zxhb(LlE&=Q2?Nbu1+7QsS`?`LZa_V23eIrZ7Lt@SO7f4g&NXVA7;}{bTV%7{FZBk6 zFdkj|U<=Ii^0PTxqAQhjyzIG6>bX<3J@+NFV8co)&J)BVc6L&;G~aqDzFNH5_QoA> zyhBZEe3!W&_xoLy-WGxU+0*|mbCUL@ZLiNu)adsa)4J>@#bX^C^Jetq@~*ONQXPQJ@wc|CH1fi)vAl!N5a z(VVw6&k7sdY3_Pm+?;icdydVI zODW5tlEyc=-IyZR#dR)fy*qPGG~i6pp^iw=Ifp1>{Up*>UHVsfhz<5x$OKRTE#!dd&KTv{w0(f|~8< zrR*Q=uSY5`^73#!jS#-l@^kx&y*!`RIr0k}sxmL0k8-vs<~a1G1wax^F0q{a^-loH zC4aG7XJQiDe74|)Y`OhW;a`K}o_-1Ts%RNLFlPc?Bc=Y_?d8L^xp0IlHiD5&zs_kk zgX7}V=Q+e7=cRN9$JqH!|4S-}go{^sO*%}&^y0=ZqPMD-#)U8vV<+XwYvBa8GTyr@ z*i^*Lw;zkT*xxE@m|Q4+-ljIk#^f8rrP~=3=vd6y>dAf8bkJNW9W3JXykIT=ZZwgQd3Zeqf_kI%%hNiz1VybYt$P zrA#5tER5H_yTc@Mn(bc?l}wK zhl}pN3jKa5NhQR_dlQon^6P%PcNNsXq>G!x!o^d+wfT4xQ+2nIqNxb@J=0*k?mgzy z@aG#fJx?wD1D<3v`eCs>1a5b4$IxZJ{F>meZNPT8j)IYP4C7iT{dP+aRWUi*3@{EK zJDXCFH2d3kn!n%S8L}Q#;%fnZAL7$!`N`-L6jA^2=jDahBND5xDd&oXZnJ)!(Bo>D z>GsyRET6HA2<^&Wy&&SY7HCjM&tx~Cn$&WKnRlGaxOn7>UHy*d2R0S5<_#<#nWmZL zJYjQpeGcOB|0w$ksH)brYXy{0KpI3+xy77qVm@=0etBAr98rt=%-Zv=cBF>u8{?+nNGCk@i!73@ zV@wuJ<)4lo40)7(-cO!=eglojpLE8|@)2+F$jr`cM)%l7Q*XA@-;w2wKx-HldIy2@ zSJgVZAXvmeyYVWW)u;BBj)U!A4gltYNvB3Af-otRRjBnBxShtjF+nh%mQSA@)dGT z6udslT}o$|hUi;74^~k(6bzPJ~SN>@76 z75oG~wn9oAP_&5Qmo{S`PGi^=1F%Su=1RSVMtChD(<6+JO{m-2@LyHRH%Kl$DhHqD z>n}hZCrj~Mk8#$iXgp^}lwke#ZP8ZZ}>RE_d*pe^>9Uw68|>n5Q^iR2X< z`{^okwZwM&u<$#ho+ttBN(}FNo*y|m00-n{B;T;mwSFI3P5`nvex?ftp*)}u*X{Up zk_1jMbw8{^dRN@X%It2!Id=Jgt018@>XrT&^g$naW&E1Nt~&0}N*j5^eOAg|VE93T zQ9_V49$1QGFBWBUql_8m`li3B403B1uJ2VZ*wlL2bMvv5dJe7F_9N#Nkqm3W{fCtp z!FvLQtRDGw_1ZI}dI;m$Szk&9MmlF-6nWuu3mE~pofYl6H*{0`YGnq(q*4>K)X5gK zA!1L zo`ivABc)Ul4CQKAKMZ}A`Md_IH!rb0!tdGZg7AvrLjo#^P9iOdc>Llf_V!9x_`p~VU2 zBfwTbYChx&>vz<#(Q^jETxy#f7j#=SSm&uROCQ+ z>mhSuJIgaQy1RXzi|;-g4j{GI*^8{MR0YHbNv!KTEAPn)g6{Twd6!Ft&U4m3coY*{ zTthl8Hlfyvr#oFB#oaRwZquLd{rvf@)8si-RqMzrs-IP+lM`8Xvz4P6r?MOv#YGWLg#(Nrn$AvraM-gP-n+Ou3OlHkRo4wN z8X51>TMZ#Spjs!Ol&H0P>n#r2Y;UAGo!%#^G68}cv5JY{2)2i9qSt>k@3@$Ye7ZMM zV2m>9)b+&wpGWE6|7L+Op5KpJ8kh}n6df&N>8r=d?>USRJ6dQcEIXGER1*VPIAXM; zjfkf6SS5puv9gUN71-N@yfCrn1F5xf$7kiAr>YlRG*vg6?}2KRG7@ zVU>A%8&Lr{Xy5FnN?FLp#%dO9 z)E2)^ZNjDtDFmu9d=6%dB+^8zfP8M^xPctbB5BuE2#PAMAsX%0r&Z~)j)D04e(E29}Q5XSJYS za=y-+k6}L5V}>kZg|=igY?ABzO{`=xVYFk?r5S6L=Z@JOZ}AY|NDtR<_V>2~^U4HC<ioG`E6a}q(p1KNJ7`IKWz~T7QA*Y7b(o{DJ%db&x+?Bif z9TZG0M#(mOrS;ZjmGay8Tc<_`~i z08fVfyge_N8{F0N7Y4E8eCS#dH>y|pOoW!+keG~ygHdND15YPB@ui=aH(QoW!G-7- zH!}CfaA8uJEnm2Xe8W-Hwb8(wyu+AaJ9S)?`ZSUyHcKI~_~pAzbs0`R4b|SF`R0MQ z#O~gCJNNPC%gKAjs*iD+F4H&rt?pRS8imL*gX*Xqj2A&F9lXX~2NFYg)2IYtjM~+_5rwH=FV|WtY25@y$keB?25P?1o;h z5-@kna~{{H%`zlWJi;HK{{7D!Oy)?350rFmt>$K7W1MEQwJ&ag8t+f9S;m|)a z5-RIADp?90Zc&tS>}*yP^1-?9hFiZncipg23%=tHfG_HomV>_mhP3bZ3{6|zyw91%YqET;5TvlXFB z*o6Oe0D09Q|6~%k>2!TI%eslCv*})Aam3Cu^`;#fu6L^7OFQjjv)*qnpGbbepj4Dk zqgyUsYQ&LuRKGrz z0p}WmvR=x&GG6u@ybrD>%7%VoZD5ZJhp!I8jcAJ25Zm?QAbKEGoCeL-U`{zuc{G?S~KKAe^yJ}f%?&X!q#X`PeiMa z1lkgm=2Ad&+yjW-X1%xb;+&!Be&TXgYsijtoMPCak{`o?SRC%Fjhaj)^Lw<_BxAjA zJsaoep1BeSJ1-{>5{hk57`VSL+8fROWPxZV?Kr`C8#zU>1DK#~%ia&aJHFQ*UeQWc znQIHWbT!0Ao~3_w^^rbG;cMCYq39n5^Y>F=NfN*C+I*yW){2O~>Jxo z*#$Xdm&-g{Hi?TWBLx;DP(g_CGV#YXT-F)EO=N9CUs|47E@WAxgZCp&AJnMhH+~~o zwldvZ_x0W<<@+6LXt3yQ=B^ea7IU#poz2KNT-Bj>`bE!hjby~?Vy-UeD9fGJuVx)Afyv8we zvWnto0A*=>Z}||)YCTdx6R$PQM9K=G^aXh?_Vq=(VA>jtQsiA-$n&SqYCicQBN369 z@~60mn%;vd1LXygfBQr9*LdOiSZVR1U2)~8nx@KrLy^VZ>I>SZcE3-}_hNb`eCo|c z^f9R~h9GY>9IJALBMH5w%!uhdhs+Z7I1Gctn$71MqW&$Z5YZCGi(is4CgLj`uZlb4 z%rI6#B0rx@?2jBN@c0~sZW#W7BK9iTlecLc9zTiSPrwT}sy(6#PhYa1C^%;eIkMSq zgwoscSS{ffleU-AO#t0HxrO#1EiBs&x;U*b?*vdbZ-&3{LEF}$S_4BZKd&fmtHRFv zN>a7)W<;zr4{;wdhIeKqf48OrDMx5ea0)cTiK9&vCKMhFULoxdOcZIDrte!=RlB!S z{t>o4f+eDa_$MxZGLg9&kid>)OE|FK_XM+Gi$M^*xQEgvULPIeW_n^Iv)(>&uH`t1 zd0$z||3{wd4_EeIX)o|kFYOn?vBCVUkC0{=zL+su+-!E%y=WuYmRylI<+@*jL==wc zjmccVsTP|o(pbAbXkt`AC6|>)3nS^9cf3|~s=dEsrWqaS9;?#eC?4ob^O)C?VfvJa5>{o&d@9!2YhP!xWNpB1{de9``I>(BhU3+qFVkkPABLj_hBxR3qXMDowWDwS zQ5UW|)xzBCItrctbZY$T9Krtqg?tl#JcBrg#>46QSLP>7*6J@DY=}wywyJ9W(Q4BX=6niLW)H<-vDOfpb4G()3Bey~KpKf2Z zL05JdGcBs0Y`*jT&wM2XkslUbmliqEWHRsI@xan|(+0%O46YO658TtQI2`)<l}6r zKmGiV2I-&1DIyexsD@A`%`6YKn_>qc`O^op?e{B5Sffu=6_Q3Li{ylUdM(Lc0@`z; zM2qJ-NNBx+UfpphBp1mIAVNI4Js%}{^G4obcS@%K1&vIa7jB!hQI+~A8squ|$?XQs zbH>|++5D{V1noVx`K--aHvu+_MOn3GF*rPaz8n(%wLFElq3ASy37MgKH-QvLQeCe* zSuRWB-anoV!^?Ja@TGw;woIP9F&2kI0UN5`suS_QujxPD5$1-9Y9Jh59e>t0xblU; zbrtFId@c*KCtE5}6+9?OE%MvQ?&%sJi3`gOOI~6p540kD3jk|y6dyL^J}Tp%>AMJC zDp=0r-Ey0&F=H>ZZjj2uAk(_0d`ncol$GOAY1CQ$ zBJ~~r-R=7KccLJ@^linZPkl2bhhNDSYBQ^(_3|0f`e8NY62+_yxqOlzOqGc6|1)a% z{Sf@0U%z1W($|aZE;n6v$-`3&+#Nq=JNRt_6hRd{AsDjx^$Z6Ug1&Ur$j-7GT))iZ zuc`Z+<>!5eBAomkty9Ten$Ua7)GemZ=HDOge|cv~kSM|oEy$uhu7mqOW~~3~)xpj1 zmcU3yk}2z}(ENYg?O$)6=rx2qk*FYTh{T^0`fFwUvFQKvJ6_sIX@oK*-DT?kX;uA? z-xU^w`YIe9s)?TXKNyhzGNrAAyaMUkkp{JYkAwfq=YM?+SR^n^SdCQ7G5;Y9_@5`p zTi^pY(Fpr&el43n4e0;;0w3#Pm>67C%qafPvjY|K2rbB4Aa?!VoQ=sg+g{si5er5i6qNyM5+q-H49N}UO&(`GF)T(>jyUew*H-7{H(&X+=N<4 zan=u?R8uRiewX38LCv-Lw;79~eQarq#6kM=jOz02%^7tLq0H_yJ?U?w^TFo>&ih%i zA=h@-kn%0(zpZ%TOQ_wphGguiYwgpy3BA7a2BC&(*2EO0zdQuKSF}{ipxEwQ%RnOi6YPTo67G7Fop%X$ zzN_UHu>ya1$p2i(5pQ6f2HzsPq(r^Rt*H(U=HKUZ68h~UFYq5*K09JYP{I~09klu| zPRI7!JvP9!BtRz@IW0CIDmg{D{#U3i;wxoKDxV&<-&;zSY32~yW$avHpaX;tyMT; z=RI?%(I9IgGo}*9L5VH0tANH}N#SgE_4(iZW+G3Bcu&@jK?%CMY8_6gug<~c)FSla z0_m9-ic=)NhF+KiYL~G7IGB#xUTZo++@;m=9*EFA^8A}Yf((`C;WA6UV9e=R&E%2ggJsi*yL&?WaOub;N2j!Zr-|BCYo&}eg zB@fn6sg!qQz1;Zw0)7lN7?8x+SYrEh4oWX2YEBw#wAx1cs<>Yh9)=rk|qt^c&BEKyTI{W7MxN{9|p4 zATK4g-d0LSb|*t0{F;LgP`YnhC=jemaGp8!|J9*jdGUCN%_mt53et;uwtP@~tb^h2 zH%j}@PY^BQb`-9I8^z|?Ao(>4*q=Y(s6%?=D08e@yl36z{M#l$g&4eiSF-T}Rz@iL zBfa%4nH4gRuQ9>GGzJ=#P-a?2G)ojbr1BmHAE1BD=@ z3G1xLWmQOV`wV0b#U4SLBUG)%k&FGqeg9(_ctyX%FWq=nRpA~!^^;HqopAHB<4AfZp|M%C0AHGghLKXYhRrB92_uKnXD32+SiF2wu zs{Xbw|0lolpROWr#Dm);tgZ6DQOvE{x?(gpFPSyX8qxRz3?C0 zrihj@<-bnm@1OZEuZ1O{qGAY#*GsHaO3y0_*qoTz=nd8Wwu%4i9{O|8@Ow~%6W7!* zr(5<&X}ZTe5qh>llrqngiPPV3ob)P8dtRkS42pfW0!C;V|X zTq|NoX)lg)d6KBP6JsEf9&S0c8aIyTEIC$k{Fzt`gh~cDMgu`ov@do*O@H>x{DC<$ z(sVKtRK)<#?vq@=)ErHQqyS4%WX@r;R`SMpPPwhEEmLZ-X{2x#rIf~IJ(Y4j9~LE( zzb6V4);(ZPPbB+xwM!kAdfAN)*p%}(Rc<68>v$a9|J&NGlsQAfH;z2 zSw2_pq5mE9;-5EUihSxk-tx1Wn$#DyqtN@AP8yw}8RV+&muYOJFn-XQEcCjQYZ-6{2grIaR7R(yXQB zWuFcY2vkh@0(wdF|tCb{d?I*VcMa&Y0g82dOxEJ4DAs{{LBwe?0;3 z2Poum!r{w4C4)&3N8+>}Gg|q=^3{e)ZN(z149=M%35i+8(}~1tR~0r_D`>Ez7rqhU z+%NZJ3AZPU0b3G1LohPZOeBk?=A})W(nz|2*k}O&gm*^~i;2ho=Q7a$WDxL6VTtkt z(mjVXj_U6dMzSJBFUti*v~qsfuctq3JljZ%?=%{)aa3EQ{33X%NC2!6l`EGWPYA?q zhydIqsOkdnf{QIS-99Kh+gy$iW8Iz2KG2xKQo+5wyW62<%SZnhj6q8S>OO(86F^Bv z|MF;^_r()Yq(_!G zOLUz=w#d!7b}KuP^OI2RJz$qv1I*qmr&*H8qT7?r3l2p7Sy@2eqq{laF6Py=t#F4L z#e$zS)<(xsA#JnL_<9j0UkQ zy_qr@f>FTGD6wNtl^cbNBr=(z16k>8^(HqJP@xA3MElWz-kuLoBU#SuNzGS@YyRJ? z@RA^5#MfYcV+E`Zt=dXy<{+Bejf_o$EC1KoHHjmcvEnRGl!9Q;s8x>g)f-*L$EH17 z9>u+j2>N*l85;zIpvh30Ze-667D<7)U<~NnOdR{>-$*iBZOH;Ba(j&!C<`&?#dR%? zh;o04p_S*|zenOG5)uhN=jbOR`>HHN;TSAWNsq58o)CNuK&Vo+2U77{M5 zg?jk_V144RHeJ+=NqZSjH&=JMec4c;**KHX;roTC()bu1X!1mJj=oM0V;;WI@<4LK z2O1L(ilGa>&57&^ouOE(TKD$SZR0cWYrW%E2{juZ-cY70!Rbp_u6Q5n0Rs!TOH2r$6|HV!J~NqO zWnpA4mK#-zMRqUVcx7YLr8VE2tcH`Qfr@XYMRJ{Njj+z;`O;fGBp5|+-Ka@_1hjnq zvAWaSqwRb?4VAR^3X6H3D0YdL#&b2L(G&`*l1Z!d)2Z)nNj)e7}(7$)?SlRgs- zhA`FDldkRcu(iFcT$6jvQk^g~q={D|BThPm=n1)_{Cmd+T{d5fSga3u&&(09gEw|cdb8RGzMpXaOsYc9gArj zGJ%=8O;x&lV_+Rf!r7>3u>-z18NzpWpz;C8k8m^u-oEWoCHKaz>XFO-Y!rsQsE6u< zYci|oa<;oJY6%H!JM*DNy}j+MTON>v2O}5xI>2D7%m8HlQ4%}sxtx|hJe*ifH#Xj5 z0P$DD_|139LbpS{Iv@Y(pGGr^)9Z!8tEveCb3OWx!f`aojbW2JKp-enY|=Zts$o)D6Xx6EX{p7AM#C?#L*5Jrb;oBeA#F9whQy))}pH zzsA^V73l-$77vmt3_fGCSre9hi}RH_h0vK+q- zrIh~~u`>;rDm9ib5O^au!QQ3oKnH~jK~GlpU^F~3wDE$i+0P@BD5SkLL)?r8bMqx% ztiAk2ieql`YV@-B50RPQ7fq=HiPv=y*RP zCWM)J(H|5xB>403pHawn*b4JWO<-sb6s0I|a^?yY5uc$`kzv|anhn3BP^jymmN2ut zp+!7QjebdOr}E3?%nvRBDv+CvkPA6 z646;L^)W~l=Ia%e3CB1bZPLEbOYs$5DwFqge_@%2d%GN919<$)X_DkogQ9d3k+->s z9k>AYCrS&0mU-YqwKpI|c~U6iHuRRS&->RofR7I`$@C7Q7yY`ud7qs;uB}K75w0RL z__9p+J~C$M*WGwcKa6w`x%)feo#0rmoC*k{!qC~ zUV9>C$&X$ijtZpL{)t46bJhWFKU>K}5+^hWo{XS=MO*-o za+&EQ4WmrLn=Nb+Y-8Q?i_%r@K9kv^-EDG>RG(>SjxreQUXLna1b$e`caoIVl5tklsGIh}ZHQ@$MSdtdm+)HLD_y_ zRw`8^F(1|;uuqmEW>w`}R>wYT&oEY;zM`5_EGqgH8J;;0sP^no22EbLUl*y3lH;bS zy(xZj6*$-E8ond4PNAT#;|bwv_YF43z>4AtdPryJYNi@+^>3MSqed%`NG@Z5ALNqH^lwj#)R3IE%phE1pKPQ7n=l@4Y zi{F9l1mr~42fI-s0pcqP5XxoClNCn14X)XHs$WxG1`_RkYR*aQlB zj&Qi=*(9l?S6la|OA>NdTf>C%%dZ7oc7q4IqMs_jLR5Ac8>hv?sx*APGb+g>v z^}No)Cs)````+F!3%Dfp+b1j^~LGL8Ag&cU*<%g^u%5D|A@%!2@mtj=w~D zY%j=y!SxJL7U$F!)p$_H)&IF?r?s zgH+XXHX)25rI@uIw@;_b9mBC!4F;oCg3)M69Opdh6Pdaj^BO$l3yp9`nBRWH;Ue)Y zQZ;B}F;^Bz(s5}(`24#%rT>qWn*9z>ETCf!nQNc1u(|BU4-RjFj{Btd!PFKD6{GE`pPFc>GzjL$z}@; zpk0Xiwf**3WGEIxyo0tnV#hg?=^eu!l|orG2x0eR)E~P}Z0$l_)!D3zP8Y5kj`VWW9v1TgeBGWbo5;JCz!DqwcdON=F<^cg{81XPDZD*$d z5TV$XEYk7IL25$gobbCNrK*p5?zAVsxWZ00T z(B+*{2E;JfJXU;{zEc!*wa8L#&lK2(dHcbMpe26-qDHNZi9@bKN1Q0SyTP?B2sqWN z+V7`r*8A1dgoo2DM3&Qchj^5FTYc_(AH2$fod!_ZXV|NNr5{w!a_1~wm7|ods$)9> zKfVr3w5F~JO6TT#l+GU|RjIFy1_LjZ$MZBNJGlCMqA-;8a23?M)=$T__kfpRmWaAK zUf#gF_?WdNYM&%IQMce@t^VmYlX`%oIV5K@Y4-y?=l0xBkf|9NtI4#j%8F?6>@V&= z7v4;O$7VwAe_W6>T6xA3a}Iir#1~X6O@$IDT_%i=XDFu%zdMf;*psJdY|5iq=Du$# zhT-T)wHvHjt=}bIsWn3nj+OZYB?%wFnI~-lXv+)DuB{CS}x8MNfZS{Do%%i&UN6-b-RU z633J>r(s=`CrV}ytzT!DaB7$hT>(z{9NfJIzp6hX;VtDuoEgvZ#n#&Vo|RdT3bF_g z)+FI@>Id#l_!;Kt_s7dzo$lp7D-BB?=Kdy~bg_{ras(J6UkYFvy`zdo^e~W)wq8-x z*!2Xbea7=OaYSO1ng{er1I<#bbe|}SM);TSKq=|lz)BS*1H*@orO4Mp!VBFU`<5i` z52Umql+#-5+25dTKpw06yHY^B71-47*9hj<+V-yXQYABd?mWGO|Hxuz-wI~yi8bB6*_uo zd9XnH`5;UrjigEp9do|>R~Dy$9Yzq8c<#G5XF z>Y%wfOlDuDM%&BRH%V?C-O&%9tc3G)4TWT=Y%x}Bb1=MbU!#8R``SR96!(o)#h`Is zT}Sxc`-+=7r&5*r0T8OR+?MCGP>l4^BBB;7cM=UArMTWXSFZu1FTZnA{F%wR8rtP} z+GuqRs5eTSU>7&jjOIw$+V1&78;@qk05s9?ojv{5M_jJ$?dqWKcs@_zE8bo%4juPj zaMUrkGTdw9#JwZvk+azxeEqG0+A+VwHHWpY-tIEW?Xm-7unXcN-VfHf#F03)wr*^& zZ<-+cg&w+GWG<{@!+g5=0-8vuRjcd-IGRZTKwJj*;3PhY%XNY?pzBc$XO;QZLPo$% zelHi#?L`J9&&JD#ULW^F&{MOOf4uNhcnC_^_n_;AdoyTgil7WHACyRZ9liSV#qslo z&4Z@VB?R{U*{{c$7;6t!|Y z&;4845Sh82!n;i;;d@-=lCJy6;K4CVH-Kg%v9$*nX}EUKSKGM2o+t^m5^3M@6ebr{ zB7Uj&PO2}i6sd2a2n9}|V3;+mDAx4mwOf2&m#3JVD=iH^PlONWx(ZBa554^Hx+j9$ zhXTVsq%=?n*I9nS^<2a2Xo#K*_8_{#1m{tiYuvRz&IDt)7Ic$^^mey1! zs;gU-r8)l7R1s-FV9K>#4L;@4snv>6ENE%#Ly_V(MkiQ)T9s92Fc43Wag(RVoY|qv zSO93@*DR2_HykzTpC&Px#sWPE8K7|kwLX-@)<RR3;6hlf zx+W@&QOF~jKcpxxQ1Ij*Aw~x_z*1r8AYq`Jrlm#mcyg;Wx%sIcsF+GFe8jm95_{XN ztT|G8+&PfArp5I*l)H@`8A>s{0BXiTIl0~Yjbu~$-oR&OCId@_j`&msFSnbEA@kwK zT3?bNEdWKK%noOV{qS|f&DW3x%%QU}qtT{BlkJKpUF$d>G1*3vj*5!0aWIPwg;N*M zO^Yx$_0+>1ZMoMP+<44C%TTzOPwiwkv+m8wUwZ%|jIy^<@U-9I36e1H&t`k-yhgtj zQVn4%zuYTaPaS>YNfuHmPnjgWwpeYMTAH*j9^T>TR;{h`OuaXTnleW+m0=<%4QeA? zyWH3%Qw`pXeY{9RY$RKR(#a^q{u``0fUbUET=8@|-VGT682H4eFDOw*j@JiB>+L*_eQ@EddK~J2IoJ*V3nL$9j2Jx-QjmWb7H;h&<$xAgbR?s98$K)1Kw5i zw*QZ11m&W(djIv2=j^HWaM2k*Z-%>YN7?kPSKYNQEt_Ex?htwyls8qkMR?J|XR1t% z$|O=aRX*k!`LHZH$*C~eCAzZpRW2W{c7a&*(}4PuMI&WGcITB$RZ>+NB^+Bc&(Ud{ zMvX78&I_9`gaMaCSO*zd)^f@BhE&hk+A9vf+N1MDDO;Qct5}q7*N@?6>^p|>zaojK z*IrOcL=;I}K67#;;WuTsPP`2twJvDWPMGPpDbnqqitSGZ_3WxJ4j3cXv^)Luf)ecM3hOeB2_ce#dDt(%qZWOM0nhu^rzgyc`O)Aky+5p## zZrScKb^8qeMXTYrK1k?oWB&!im9f%q-x%E5VHmCX*vK5W$8tBILTSbL;!DB+3)5n_2s99v_4(C+!4CGAWqh>|17 zb^Oq`1);dNkuro7@--3S-=8QZo%wQ}eD^u(FCW6fFh~u~i6W66_o@|f||;Si&j88hVVAvWo|LNtQ4`kkVyqo|K))^HHvPS(k=hC#(_j3a^$_smE*&rFsPIB z>CK-UWg;FIhoo_YGWfT_vaqjz<*x9bAbl9eEqJC!HCx`ctn3jH^ z27Ub6_I)gOQH0H&)O6ho8ZSCvwwe)+G<7T983g0IuP4z=3#Iv;?SDv5#vdJa;l(M9 z%l19D&wZMO_{yS#-7F2lFF9N=oOxO`x9`WKKfG;N3=-@Py~9WEYe9JIp4GgQHM`t7 zK18B9&FUf<3*hQ}ksRtwJ7o3;i_KCbQVHA4o;DtQ}O*%H>(ChYwjcQJk_n9iy4=hS2Jf*4KVVc zNI?^?|Ly@o&YZmeo%ze$XQsTq*}NXa4qKpS?{n9&KV-E}_&p0#rs~ z*AC`ciUTh}20%=XX72Ob-h9`9tJ4Dpo$J*>7st;_pbu!E9qKa0^NtM#`rPV860h?~ zMHG@iGvBRwdurM`UJvX)s5LG%$SZ z+;gp2{JySK9xTDH@_|6XAeGJQg*fqHzgbh48RU>d={ajS)Q)n4A&d08E7!tWT=^Yd z(5peWyf4MGp3U#u4T#mhZN58AKHV0X4ZyWgOA|_AH_>^DoZTxT{ryY{!t%Ox$NXSn zULpv_ej-;+?Ip9FBi53oS_A^REmtP^qe_fB9vqG7443xl(-V+1d&lE@m z5`J*&S!UA=B+lIS$C&7TG=l!-E;Zvm-jQf#!MxgN zJ#r;m$e+Y-_g#-~p(bg1Cv*9I8yt7Cg&SQQ<8?Rs6LuvZBlDs z{1|#dQVSs8&Z|@-f2IX4ita z3TH`hF_1gp6!7lTUjW1mbtp9A7fkAwpUPwN~Mo|!sx7WLCb8z`Q7!1hLm>_t2Lo0k9+0pgmLlkxcTU> zwiJ-BZ=rtlk7%Jz2*a#Un@V?{d%EVe$si7dKzN4BOU!3^O;91t5e?3ku_`WC!*XSY z1G)u6OHi~>If4Yr-2rT#mY|K$;Bvfc3;kBd6{l(Re}1*VIL zIknlm8aq1p= z!|F+O*tcxh0(70X3&%vZP2FQ3URk6hL@k7 zO&rTr6PkSAm5ZJKy2!eeQ3JHv&LfCS8A8xk5j5gjzG_pTz3ib}SQp&0e|Foibk^Hp|)ntS}YW8z>+0<;=*yA+Gcaa z#5T$I+4{C}Lz5U7Q3I#01)_-aSCeNh`Ki{ws*Mq#C0L^WDc^8T83oGnC*({Z+1nY; zaODk8YF44&9kFnM*VzWJ5s05u7>xvLDC98oC{IvtX1@v5s7$x|fAfXaN*I79L(-A; z4};_2U1%WQ7Ku6eY&g0>JxtiX^~5c0sX}AklD7oOJIF`Q2hULb@DMOLRhXQ9CJP5C z70c-Ov)$=J(kR4)9=bWZr8ku(?f9?!-RB1H>oV?mc$ zuaX=eD#SDG-C1V7lexROoa@vXbW>r_;;GyX4S07dq}SQ5D$}K+Xf}i3s2u@H7tnJK zM)OT=O{{>jOnTQl3W3V7^Ax9ZZLl@55|6Bp&&)OHwmW*O&Xu7gOj7!s(&y=xx^|6B zQUGN@sU8nKP1{maOKu>dqoMRql}49JiWOSA)I679>;{uK0&MMbyQubWbvrU)(X z+%6@MEzxLTlp3d$rEJe>{5P6*r}vazNhYFu}PfH48cb(cKa>TJboXqCoErf1F#C-bV`gN-G9j;sPP zUGbamo8E1FO*C=~Rb%CPp;5iRS0&m!TFc-Bb+#YoG?a4dNJ6Phw(@gGMB#%sL}GcW zFh;lQ8OW+#{WXBbmq81XeJO6_nfAgrTyDYbm^wGhqSrrnOX<1c@<_Vc?x6-5sH~3QcFkn2;)dmdY63*OXei7}0#R{55$;74KL314`3# zwCV4xnKV2Bcx9EQ@Gki@vnVB-TD2ljM%>clz&%AJy)j&3R>%3N?)7dVgr)g;?4Vjw zv5izd11HoFy`+EpDUJ&bRpL7wo|djS{_}n%+@TA9_=DIyh5YV{tFKHc9UT3NWqWLiu3xiOx1mt9XY)ai?~K-k#l z9>eHWY<)Cym!-pJy zSYEGi;4phWgqk!Jjxm4;11q1uWabj!xOzX_-Kvi-Cog^}TKnmxR_}OH!9b-=paJ^7 ziHqYtwKFqP`B}_X4b*to&kDMz7iQ(QU1^-1NpeMXIqg_B>rw^6{uVqYASs`2rm_!) zm#kb~VOw5Qcpoj)AM^rfi@rZa%(vi%oLAg#Q%pwI+_PVf5nwv|9n^Q-KDq(cvJ|d~ zypWHGkZ7SoA#r3nb5Q~{t0phak$_S+^N?DV8S%GqhGsckQ=}dhe|WVWVQ7S1me}w# z{uT0g+t!yznfgKTh2+i~h$q|;jvx34BIw+Cq&P428$vrE%`MeOSL+`u^<1=|w1El& z4C)U-&o`>7i=NFpfLU#;p)$wUzWFv7oqB`aZl#dQb9H4z({o3g z{WqK03nfds-kCL46(fIKtWjrY?asc_V`GWZ96jTC7V@v`js+?NwA2kLPa!MyfjBo6 z*TO92`RObTe9B@$D)q>A*51(#^!2(=uF#q%1!(QIkNvp**k~lwe{=5jT$w! z(b%@_G`4Nqw%NF0)7Va$MvZOTw!hiEzjv+mo^$p-`}~!e=eqJ_%rWN}wUg>-$y0<>3<*! z6v-KU`kkkHfe3Lp2!W5}_IQ2wcDm7qI|+W<-7bzb5Qi+Zt_v0Sy1q=^ahnz%y z_Mz^fvpmhhUb8A>JaYY;uhw-*o23OXmEFLVw=q%H-| z`#<=z6)G8)Od)LBU;fBWNK!RuMuL%4JMYM%A9*9}66uKYz93mc@8JFX#lKVQ_?6pC zt~wL_<_c(`yWv+r_i zyAGI5RTC5DKR7>wF~ClcxQt`u^%rOdKzp%j3Gc3!hV8c8-w!P(!4ICS%MCv1d~>`L zO4o+#GUYR4X*alscs#skzFJEbp0XS9eRSVr)M)yAly8@KZcog9VR#_Q>Gu|>dCW-; zD5Rx8z@q;SE8tHY;Nq<`ot;mI*f#UL`D5kOtO-L+TYU!lcu>GtyI0kvJ&wpx1iSuO ztkY=7(Q9M3SU43`jb8fRy{JW zY-74WHfq3SrcmkG>?aSH^Ir#&$5xtmutS*VXW5(w3|fLonq`X z7*qk(vAVH?Ux0{N(MxGbbA+fR3mc6w>hvP{+_m;J!KzQGKsTxlU$4bcbZ!3X zTXQfAU<~EL8zoB^@7q`Gz4s5{fG9R)^s%TONN^% z=Jlj_?Mu_TwgM(b*VPc=B4FD1b=`UXF@`;$Avf*gCwrxv0IQXzur8ZKcAGgF+6FO- z46k7V9o!Y=ZWGjGN6*_6i%K?L8hqJYK3RQ9RNK~aoXy<*R~q+^-P%CZ+wG`KYwwBD zoK#C!sw(F8XL?YGoVhPadJbe}zj9zCnW`E3>q|KZ096sK-{?%Q(l8UlaaOent$am;6p^ z{ST={5-ZR!d7T6-X}Q4@lM<30Vsz=M$JN|zvmWEI^Ah7r?1Zq)v}wKUPnDPORlYoH6?#Nf>KlRTd=fkP~(*?j7+Zn{WO5y7J7a~(wT8ihlxeAlH zlSKd;hrFrmz{wjCfzqHZ@kds2XE13%g?B60?~L=X$i>j0*(=Trd`J`!n^7#vw?WPy z5&?kN$+KmjX%H8qZ_mDy19pQU7Hadh@|Yp`$pCuTlyy??7e&xQ{g@RHyN}Sy!8LVu z#bkKW69Wbh?C2SwHrz0tXNzXqHs^pXZYV7qY#t(n=yyO4qKy)aa~WBQ1JD+n0(6*r z0FD7}D@Dg@QToi~W*9^&Z#J^t`Gkh4P%wa+D;}~fF!|LRp{S{IRwSF*Phk=g94w)^ z`o(f_i%*Riy=?Z!BL52^X(UFH5CkmrO8lvc36lf>HY?Ka4y>$S16|NQoGfTlsMKn( zOsTheul6wef*UA=oxHa(cG9w+b92u01SGfP`}9UrX4 z$1758zX|$SHmIlAqG-bDaa3r%TH8l){Zp1j&zU(D~Zc*zbh?D+NF@q$p7H zVK9TV%zCwPmIBI$(;!fQvsf~z_^Jn?&g6~0OX5}Kwoq|*s2Gpj`3(%Vj01O*a>Mo5 zrfD#jT5TwdAT|-eA19#<$p3CSB7+Nq@*a$$ze8$H4oXyc_w_M3888B;8_%n*S4oln zrIG;c)Uno~ccUWG-oO9p0`KN@S^jM&Ejs=w5f${lTCR0*jFffC7d9Y&|J;D*gPq zno-Mcs#f&Y1i<)l*7XrmERifY`v&Rm0)0@Xt#|2QQ&r`N_0M^a__L)?FB|^Nib|vj zNWmE&c8ii9I?ohR_WSJzK|Btx(~Rn%InA7NuK+FZ*sWyS`Doe=>c*(k8@!w4b!K6l zOu{q0*$Mb?Fga|gSzekpYtlVQyPmCB@VSSg)Fbd*_|HVuYX&z1?1xQ5xeuBC+v2%= z^hbui7LU#=?woFS<(bG|0QSKO%^@L9vKQa0ak)YufFA6!UTGdoRt1)r&W5ibMuL1M zQ|&>1J6gTlL0Q$0kc|77D@dA8MU#C~%eEaV@}=->QbG)eXM;t6aU^>UQFtqsh+6)3+BR;ns1^hL4_7o=l@5r0?hl z6kvd!nBTH_!N&EClDs5<4AOSE1saE{rHYGZt6n3J2p6va!s@F)!cb(B=SdwPNsUG;NjjQnNxy8q)Tbc8e2^ zNhA;zE*L9R;^ChWVlkT}J8bv=p_Ys~jbI%ereB>lg-P4e_E4679ylo_-sigeTJB8bFm{}ubX8&&nrwd+qyUe)2tJ2{yoTlz!M+K`bzngBVbmf{*N@`Us-eoS_auk+`Z$pAV+h*J-PRqiES^?rl3ZPSy@s`;X{c2k zoY8%IQI@v+ex2T+AtW@K17fkI5cyz`@Di>%6F@JzOQSuu5?4LAJ3>3FwgL9JK+J~% zK*TZ(5eI#QcOHg!Q>J(aOjE=zdt2e`rFV8erl!FkkoApP;z6HYkzG!@+U<&AGy&kF z!=(xU=9Fpcs$cNI_Q>^ot!PLxgtV#?){lg5{Tu&npE!2=Fy4=!q3d70pE{x{tL%wt zDrMigevl5E<#^0By9(t3-N@oOnI2___HqX!oLn1G4g1|j z;ejiQsbbbhB{}NyMmpxu)h0Cr$el<-otfgx_Fpl}e4{9yM9M?v%V1ddTRsFcEdZ); zU}ni$(8vKD7e#1m8}H{U&I&w`@J~iRGKzYe3+)_dKW2`l z9v{P4wY4?ywQH$oUa90(y}b1gsCm6s#2x;1_sC&CJ@Dp*Dns>Pk9bHOJTaa%EfOg1 zPSTV94qtZSVla$fvW#k1sgNG^6M3aAOTDDgTN-4nsNF07|i$D?i9vz!HG1Eno&Gt=>HiZPqY_KQIQ%E|d9f$ECO%dK1RC`NU}tngv&) z3zRnV@UP%s0$^qNUwQ!*2GBF6Ny~vub7%Stn?rfNJXAaWEvr2o9*W(~2r@=$aYbL>=&(#|8 zI2g2w$`xoPuE)4?@Noq1Ex26H5_!>+g_1^1`~MIF`>)un0s6=Q`IzCb*7{#LasaBV zet)7;|Brb7?DjgTIf}s}l_4q7dS?t`){9;;MGrxNM z-;{7`ToIC(vhz?D! zwn+oPWiWhM{0rao; z!dPsh3dj);4yi#Jp3YCdT{Z1dD_oEPd~4(TZ_<0%Ux;L6{)XE9^UMC1Yr#L#4TQf; zCja`JGU+D}!MaH{?oXH;ASwLyjQ+a|!ofeZ`9v>LYsA0$&WhyK&l7$i?2IZ@3CFzF=!kRcX)qi*KM=cO9T1bQJU*iSfVaA|D-_*d)b2Lfv|E{+D*Q1LUP$UB; zukginf$gscgDULrDMGoYQv3O@3H>iG;tipJW!KlK=^Fj7_kvIzyw+3-r9A)dLim3( z06;_$`;Wi^wx{Y}Pdi>48g)3AuZ1o9^lzx!|8X7u{~zW8>JB?7aQ|R;`@4aX!mjPS z3bNFcDo(a&u(&^j|7(DM=@k6$7lhD=z)OJa4s8wkuSpfSBK}e%(!VpS-}pZhk9Yz0 zU}AnkM(BTi!v|;%Xl43LjjLUm{b<97_Y)oHJh)k$_cdbf#lgGsMYN$rV`x$#p;QqC zS4oN#Hrn5GR;g@lwN=pH{O00WpF5MXt>4ex*0|?aJebQPksb=;e!w%uU@NAK>Ym&0nK8BR-upwgW`&uQmUUaIRmA6U_k@@ z2??Q~{_j6}!9aybA|WS8CI22p^F7k{UXlD{$(|Dok#v!}8syyn>DNJlUIzLjMr=X2 zN-F<-Q)e#VQfM-AE>wjb^HD^P82_W2{m*IPfr3i-4HlC1&s*8}6yS+At++@`X#;^A zBGRajEdT#7zVJWeL-Ll?{Kxogz-tSoMc}AQ3SKx9SC%0XR{rNR2n8Aqd}ATjKSziN z20nx?DOX`>TKKY<^c1rTzuw_+4FBqNeRd9yD+(Uqg&I&cUpdM87V*-A^Z=w$&^DU@ z-ss(iuG1?T=`svs0?% zm1hA$qDGtB)A8{v@8{?5EMa5wReIzzbEg z#4YZZ#IJyjX?@zH1q0gk7S)KU@q_~LnH;|MC5nD3`<(8VO_;eBlzFORSw z*TJ3AUEDX9KtKV~bLckR(H_24W{05u;C^Axe}Bq&sCI6qz4h&@WMZH{P<+sC769l^ zMZ;A^j+*P=uA1b0Msj6D%Cw%isvbOo@4jqD>EQJ3Lp$_Z9ol}i6n$Z zl!ShYUg^j9S2fz4a-V5)yZI{dI`xDh#I=c>Zzo&MddqFL0A!-)%4DEwSwJR>Jq?WM zysZ|ZOrXweT=90jTJTk0l=2cB8l>q|?ddn6ioXbo9aQju)=*;gKUd*B5~xXJVP2L7 ze%|3Q@^tk2*i>KH84DwcJhCgnH)L*6kNs8rdW&hYA%FuZ`)~;f16$W`eRsk+b6-|P z!J|N&!R3K$@;jc$mFb2cdiE#Y71e8G3bh5$sv0`|YW7135Tw3uB_P0nW|Y4S5VkHV zjQ}(#mCO6k`|mZ|a~e0w3c|xceeNDHZUAnLqJ%kxY%W&_lj+C-GX}HiNTFa5qDlN`zvJodpi)8>fKHjjq>{j=$w-JudEYj zT-?%WT^%E94y}X6FEt#0c?7?xlneK0<=5(WYZ!C2R5p`V&}Kic+D z`2Jn!6Fy70TsD_zfmm#zR0`FqyeO=u#*L9P|D4~1Kx9eyN~4~<&e2dz$%}MtpuZM) zyIJQ-Kk??j@2?cnOCmF8Au>08IGo>g`|6V^Wr>%+8%yBHu?`m%;nVXQbC3AV8bzUx zs!Yb=atZ;&ipF4&Hkkpx=Ze;Q?}`i|;#v{PhuF9jDp5eQqJUTwMro2*G%(jc4ik{v zmKF~ERAko`RS81cdA0MoY|9!2;r>|mOo_anT`bJa}pj?zDNR4ib}mk=X;yjM)A zuQ%8?S?YYJI_33@fyeEVORHQ{#%WL_gDW|~BFGDu*-WMvH>ApV1{u21U*K(Hz)DL| z`e7_drQ6;-B?Z1E0T@E&TZHr31;T`3W-UKzm-5r9vc0L+L1Ddw^0hv2dnUHnVwX$&U}u z9PQDXKh&PgIh=<2?g3M{!{HaM0G2Nz&j7Q z-St%S0wp#G^E`~H7ewZLkwoP4q+jDv(Z=6LAL25UvGn_gSx;g(7n`h*P`8M@7N6_s zTB48#wRlsXueqVlV}jXwLo%#Gg~jEE_~EA~D{7Zb-lr-mIlz8b$N9moLPX7D{EgRd7`_Db>ryoK9u&0*_%~vRiCxijLU@@GxmuVzK1}oL?`hrKdhb79b z&K78{m-Jm6k;4CqL0;Gq&DXyLQ5^tvG4rz(PTi1B&s!=0VK3FfYO|C)RU&`xqDS}d ztMfm8lc0bK7t0S;L`g*WJ2d$d_AEmQ;Q2%#lbQo=4>zlB>q-qjRCqNbih;ZfSTmn# zUf3*Felv{$z=g0zyLCYz5iN*Hqf-PZ6_kKBRWiGLj`C2D9z@@v%P~eZinl%TV*9x$=sGKeh`(Ef|0we+$ycS5b0cK$`Cs*)g$+z8!Z^HF1 z%WR-~fCE;Mp=i{lQ&Nu|W5O7KCq7)bG&*|x{ci7T8}&$`@|p>@~FZGvjUD%ej|=Dr#72$+&Q_nIfJYR7ezJqv-gK>gjoUuag9pt7Sy$abs^#N zGospK`F%Bl)fu>7mRn=pL1D^%>OYhvyG*sSk~Q{6=P%E@+>m4<^K95HrqpkD0iIzY z0OC=U^bKoHI0E#~0TQfAS2V;XAc)l((T}o6(?ve97l+5&KYn2{^z;SY#F3P{i@Usc z)G5UMa<39X`SxXA;=3Kc{r@75F7XIFL;x9KYEVKWz7y0AN=H?qlb z(yI?LQcfLPKKg1azBoj_}-Dsz5XBsy(dB%;tyL;)!TvGUZ`IDlKh6ub07aYM1$a<*M`BFNmQea&|vzj4$iM zdmjcKajfpocrCX@z6phlG#&Ovpd9grcUGED=I1C;0gGI(&%&x ztzRl4-e6w+`D?DVA7I|e`(0jqVE+i^sNw0fIEFOH*cICF^o!tl>?Hi=#D7@t_EDBP zBmWkm8I|NaJm`c{?u&ZOdoX`7ir0Nw14x}G6A6FJel-_=SRPql@M=aF&-u(fZkM8L z^k|N6!$tV2@^b2Q^Bd{59F0m_;j1~~G7ITS-Je}1d581UwYNY22=mo5 zs1Ilk?+AiAbpPddv(o|GT+UDZ)c`&~zgVo?35T*<5l~|y_-DQ zM&m)nU~f*e8)yl&QKd`V!61t%k+B5(XJKxZs1{tg^>5(_uhdKYQ=-rGx-Uqq0~T__ z?x*$VQwFOu>_@0hkw-eCF6u2(XtV1_9P}6W&`-H(sU!m)PHW6KFRY>h9iBR)nCm5 z-#WNbHLH@GAnkgS((~h$kuCp(&G8dggjV_Xcs`fh0%qz!;WO`j7Z!6Ud6b$$)ZXYJ zi~Q1P4zJcK*dsAw0#uSkuHR)*M(~vgrVHpVrCh!a$^rw$BSiWMx5KN(D@LomPe&6q zNt%cZp?%;ARr34_Nt;WnHXL|s-Yor1TTQ#VC<^K32kU+xUVpimpnrCi8+v<1;GY8c z*1VGy`*;2XJBKB?#c_Nml5bZ##k`o?nH-zqA-5K~;fzwdLn#^!m(Qs9`0mYUNe|T$ zZ-V1F^=ag7(V&LH^G)k}(7txRu+imlUzZbcZplLPmNHd=ixIt z7Vft&ty#VHk|U#SY}PGK6gRWg?V9K-%;{(h8Y`;_2LB2Zg7H7p}{!FWi^M^_4&)Qxebt3m|SfXg}N*IBQ~Q8Li~Un{#(oWanD3KeGZv zO~idlV*$)v0m8$hdm{OK%vSwKe|6{BzKBKOPht^(@R0H-EW<$$AW_ya)_gjuI7ZGS z6U7qoC*K$zF;R#W-a)_~Rv89{Z?LC?*C$(Vac&fQLUVSEuC$4exnGOO8TmMWMIpMU z<*mhl#>nK}J}Jupqv0ZHRoYa)`lg` zebe@U)AH%}Cv8+@5oMaCC@4^e2!R23ywbxXvPcg8I9bcUET_vY2(~CJ3(#iKD0FTV`w{F{$cSWk?GYA zWzKR?*e9|TtL4V*qx}-OnD(?N3=lA+i0X@1-MD)w5v)3%k>IDz9`WyPm7QUISMyp8 z%4uDf#gTiXdPImQ(TNGUlYqvRSHj1YQ09Is9`^nwmu1%~hoQ&o{T76mU_cbc>Qnw$ z%mOFn^MOKv#a#KN^R7!G9*@E0jLa$j zA{fHje@v3AZht;g0~XM3np(5nFRBH__T1fJhAc1oD5jj4vJf}$JrGwjif{;vp zopghc&U`kD&E^NOFXnbX`@WV=tGzaD^w`nLnG+JNesWJTrP7Q$Zko%{ToHMW`>$=Z zOwF~E8AeSVLj^q34~)JbjK*Y{4D*I1>yFne-~|L7uCLfJyN?6~`k$9R;G?9`DfJ(e z|4x2w$t4&ta;$UqJ*KTw`e~Jnb<6EBWZXJxky^C5oiwR zHbw9LR39AVMnwvcS&mqKHz2#gfe0?eIO^1QzRKc0Z9|-}@##6C>JO|l8@(!wGpl`K z9{roKJ?r z)BUmdx;OLEK_OfB-Px*mLPIqBivE5*zmw8OBYcOmVqn*fKMsx&*!X%Hob5N5{m&9d z;7>nP5Jcqa9;4Hx29~#FI}gwT7}+cq$y2uOLRlbS&nF6kKC+lDp?0I8Nuzt^2nh?h z9-Gd-p_VVEH)Xb1$VL!~_h478|833n(%tr!_e$aS=FL-@(^vO-T<;>g{TedJ%p4h{ z`?J%YOk{E}p3ed)Ycz(0U}8<8oSmyGw!qPqu}-Bn5)X)dGI@Wa{49 zN@(3s&>|do)gG;`3d-U{G$^r304T!rt=0VD{rl^<71FBYViX2_=vU~aS{v%~JKlVB z+B^+N#bj1_@Wk}Su-NQ1www~mvu?+39`vr z73ziw@IVm&DWp-Rq?i)c<*tTF){gIaK(ywey^z%J9}sk4rYjKUZyc;M7!L4heN|D+ za_^2r(-#FC<7BFtM~?tNvFCi_3MSrueiL<%BSRl z2Wk8gvl^5Iw})^2s-^)jMT(jXSBXBH5#qMoe)?Q)b-I4cgDr zurSkfu6Cn7Gsc&ln#6R>@D{tE)8y_@_*i51LrqPWTBZEEgzdb?Jo$)W=g-sWQtL;n zC>T&jQyEz^I-OP-*-Ar4?4{N`55u*WM;LaR-c*CB*c|WZL8p*U+^6({#Gpjtw{|j2 z8vU=!#j=^RW7AkN$SjFjK!Dz9A7{adWLKq|>mgCL@EYq2duq@f7{U@wo&+XoecxyM zG%MiI=tW?B1iLZ@0L3NAB+~Mf$mL7ViFwKhZR9dIy63iPS8t-epd+UXPg8X*TZIOdl{l0E!f=# zN}dG-1tZL9RJZ9R+-|h zms@2M!X4|#)Y`W%JD?Sj(G6;}y4KZv&?Nh*a@4d~)UZ9VSuJHeu6I>P#wgn#d=fFX zs~01TO*~l}@%0cJ1af#%Gn+e@`62Y%$iKnmZ`oB^F6d;k`9k zY?G}$I0|ekCwBP|lH$XaqcIkrf}GoVyER!%t6I$IG@BFI=#PFem!6QC$rHS(siiBK zt=Wep_}&R*?PXM_Qj+c~NS|sl<;_dG^XXU;0`;%9qHZa96gZNm?wBZ_&kWu2csNUY z9weB*yxscfjrT&6Ns04Gcez_&YggL`SRxRDZg{-!=Q^R<8ru`Ac3kM5#VVTxJYTwC9ccyzs>L-$p3r zj@|80;bCA;_xCB$^7jB>@wzw{+VUi(6D=crEQ_yaDMEFMc6#BN8UMV89^4qm{3+Bm z85)Ibf@&^(uEUER`!$B02*$U9p=wNXP=G|U`y9#Ko6+j*E4N$a5%WZe+7D%{$p_SS z0>Q|Tjn%-%d>Jg}%QjPlrU)$E&G_|?w|dG(QteF>7$mZ($~FwoN$e>u$8#zc%Ymu& z0-%b90K`>UJKgO2=y@QZK#Ry08KCYNJjg;QZ5R~zRVKK(!X7%AZ0{PI-BWV3^h4&p z)x7FCWIVdrNP4%(&wx|~j>9%*aNA>?LJq{A8J#aod1-^(QJ!%$icXF)yx|me$Wl} zgJRiyp86icrWdvUGP5EylZ7=kJuXC*#8w8~P6_Wr_p*V5w9dEwe$kQf`C~*SuAh?N zbwX;oVafKTo3rM}vgVa~*)sQD+D&46J(LK!+$H(0%GYd1WAFH(WO{ zV$}u=h&d@}0#w`Fw^dGUho~h+etA18kI0+ESr0rF#$kyyReW-4a>GTny6$kK7u7dx~E2) zf_~%O;3Nw&xN-&bI6T5d7(FWE#Ygk0=8O6CfEy=|jmR>Tf>!kVP!ul?b57BFst5 ze0MM!Xa5+kFpNeBs0t&l>745*#9AJ;z`uW?SG}8?D+zae!b2n<+qjr~X zHn#Xrk!1vaxeJ;ZV)}`#{4O`J-BC%re({WetE%&553-9a%TmP_n2uk@yak8f*vefB zaZxHSM;~F6&SOM~$;U(FgbBWRlxfX39m3y(F@yoS8+uhwAPS%)#O);m)nu&Jk&}uI zmaLZRl@-KGR_xc4YF%j2`~6v|*;eMZfvP2d>X}D-(5I!)t|kHH07+rQP{QBaSroFf ztLi9H*L$?d+;4fl7cn2FCFFL}^oR$Jt2*Zrx!lf9pr5$nz!>+YKu_C}+?B8jf!^O? zkyy+PE!FVOuX#PYBAEgMUpSn*Xu}XriFCcI6?ir*+C5(apB9CBF}CJsFTQ|3i%Df- zKetMjpw5+pXJ0e#v5(_CghfzpJmGW66k86DWsgD<;AoFE4rX}0`V$aWtlM?&0W)@s?AP743(&`zx=8`17}_UYC7=&N;Xie;_LWD z8@|nl4D*q#!gHa8^cnV?@qnO@*L!uAR5&q@J_r?+ggXLtSoM>eI+YD-yaV7XN9_uy z*eLdlW46DywImpFB-_rRQu6u;c)W6hgC~HpEh-s<8jK*8VC)nqarR|L_&K|TC_f2@ zwLYMR6g&aBu?!h#hl#jS4(?=t=TQgG!e;@JfT$S*l&>pBTmwZQp+CBE!TE`om{dm& zTP)W{3a=oufqsML_8N0?+wOPCs58cHID1E#J#Y(sqM;Xnlz+%Acg=|m{u?pyr?V~r z1*MOuP9~+|thXi!$;>=~Y*1ukh+K9(V2n=2uXO=0du{ANim#ta{~k;=mj&P&m)dCQ ze>2;7U?g$!`dxPK$iHIf-DEHXyfc*xLOUk8^=FKZe#d!%1iTVa+S3cm)XpJxO-Rq{ zYml2Z%eRJ%G^pM4!KNOH#B`$+(v8c070(i?(+u=7fRnvXyfOZU{PvR~og!ER=l2twpQo*? zsmo1z?@%s(E{4Sie1?|!zOW+~nSMb!o#@UoJQTFLmNpd~yrN7##udLLL z>BZUkRi?1tYR%9FK@8UmlsVXn#nbb^N~bA`GsvaUUc@EK{NP@9t!4QQM$@x;W3u>m6g0o?I? zjN3GZg!o~=c0nivrZjJYqJ)$#hdLWQw!&QqWrvIQK&zS=5A;_AI{Rw^yp>C|1t;q_ z%yYrd5l|KeVP^A9ZR(`QyXcudHOs!U)Ip5R0$x_u>KsDK>LS5nNZ~c3lwpK#HOr8F z^|Ar+ge3Wds#zkt*dXTov&C6|I~Amla{V3;O^*RNYI)*dQ}M%rt+%9F6k1y17U*~~rQi~Cq?{S-{% z#KX~042HbMoR(`A@IBc>SqdP;Tt2Xaz+(^h5ZelHZQ|mx=(`SY0p&%RKsH5< zXO1QzUJ3fzYq%mW2#?S8J@#SyrsV>B2ZhELAn&3d8c@L&cSQR@j7%NxMYt@-WGzZh_% zg5r}CZBjz?8Zpc&`}q-M1X4h{agL5#Df%og`}0p9A+;hLV- za23Lu0t-ED;IF9Q89OeSg6|=0;2=np>c~h)f)$@|P7&$Z&;w8*0cG)Ot}S_n>T417 zVRE@;QyMez{5S~kUq(b@?h#j0MbkGuD-bu+jVI1`nzr)z&2a@K_;J~*$~huCB7b*$ zf#NOU8lrm)IpW)fj-94vy}4ZVxJ@b;lT>MsUT4Fl<7M8dY^J1^&1y ztW=<7+iEyQ@C9O4IL+GzcQO6Qi<7Ty+{W3doL}B9J{=75({y4(kj++Ce+-<*CBx|$ zTv{&&1n!wM`!a`QbLH|$eJ%WX{SJ^IvTR18Yvi68#N7o;Tv5j8-F*Ot(wvAhdZAd@L) zgUaz-?{ijj%rEe)NjuSZ0GVB3bs4_?bGtH!$Ypy~4~Evw)u<4c8OfedH`WELSM}^R z$~G0A&J|$W7{L~ePt`4N|Gjj0@qsG*F^ z%AUpVd}%Z}n=3kw<#ca-lsQ0F?byS?dm#2S*bH5>SU(ogaq(iL)rStiSBax|6u|9K z>gV|Uj{i2fXL45?$_7>c`zLC8O7cz(i44UfvfisILk^Cf#gmh`Z^hGT){Bj6x}+Dm zeD)1EWcNqKV~_I~-X*Z#x`ZVHL4`V$O0CgT-s~1zA|{^CtYoZTp)3i-D70ZMy%@CeB z_n2$U=5-82W~jVr4w=wl90UF6dZ2X{i1nfs5d9|@;Hc)5pje;I*O+lDi)_cUa8!oC?Qu8T&Zi=yfpmWz+jW{|?7%n+7n)WxRi z^CY>hBXP-=eXjmkV>3I1CPhv1Li6YnakUiAzSrH0`NV6!*2iJr6M8$OtpUUyL9Lu1 z*a$JbQrYvkJXN7@3movUKp^tg%(Y~b>5xJoN$w7{axIe2-9$Ip$f~Cl)*?RoJ9Fc8{DKZBHn|d`ap6 z^Ga$>8v-^yAwW@A;g=piF<17hY@p&bp1?GL4~t-DPJr4Q0Xm80Bk**9+Sn`)J=w`n z7#RcNaUc<}D>(6G)${&GmCfAWQB&=}XwC1O_i1aGKBbuH`+TSceEM9Fr(4C@Y9&82 zooyAfadX*dKc=`;4oAq!rr*&){s>iGEPA^; zkEqQCS2&4ODpErUypX$o99_i59-@0!ps+>Tiz9F&P6ET)gb`8uL+Owmi2BYwg-xly zm$vav;GaM1onzzK$qbibAVz1LIj=mK&)OY&arCR|Mc1~Ssv*If4wUcwg0N#MNg+5* zrSrMDM?X7sn?L1F7>~Il7%5ogqbRl+8g)}+7^ZZ91{DL3yfR!-l2=jQ6S$y#0i|!% z9DI9yPOj?`9S^G&vxEV0S-a?u;)xXVTg=l$et@f zu-^Kx$J-rTmt!4!PR;gpHcwEPY^v)U3)j+MZqk3O{$vo}EFP4HJzPF(OrIXImT!zS z@f<#M&fcx^{>6y~J>u$#LM)OH>msJ3R$Mu#?FxOl4@XUbmn#p_^YgrAY@fOa_XHT; z^F{r(F(t8{a`?6~N1zi9WZBF@FUe#WyRET(i}W>P1~^QH#bQk^+K*#!80^Ax=@}ET z>IwAHg}FI8TjBs%4D8;x+R{<{T-IWONdD7{_s2wDg&ihyR~Qn)GKU*}dMhzi>HqF zx1wc}8@ZP~IOBw2zXN4s%nosGNNA|bqo`(q&5y{xZ2HsmiH2z= zW~PAhE6>+Q?#!t4d@^f&Q@G-9`gDgWrlTW*xO!IQD}3X7;MtA;xt7^oLTZ&OwIc>K z`^=B(Em*wnaA*DedFp~yy5evdSA?UhkH@3qGLI2!?-`nD>#BIZom$qJUVvR?(RY)) z0r~S^h2e=lDkZK7RSmpTY&V|Da+e`u;T=(vrG! zDWj^fbh>sZDFx@HXGeZ8vFHHuEi?n@{aJ<)4vb_1IiXwzu1!HSP>@)iG4L)dy*@ra z*!had=?D%-z$eoHZNBV($PkZP$1I7j4k}#0;XxO@IaMetz3+|!o3meIG*BB+ch|ps zq@DEG(^Fs-KbI4O&DoH7c~C}sMK3W_nez8ZE&K`6)&|o!BWvBqO_B^6@bHhPo-dq^;P|}y2=t%2R7@^g%*Ls8XG6O^U<7@jtS_W z#tsnlW9yaK;TYleKe)7uvHR@(y)oGwwH^{Yq7{2{dsmYR~FS&B8Mb}hjctA>M29b7+gvo~TJ07Vt9u7PJx zf~PZf(}Ac)8cY6X0zjyX_)~5gkj$fErs)g~VxR;Y?B@=?*~wC^vYweSr1`Mc(48D3 z)6C)b){gBQhs_ws`e=MBQpHN^j!Ppacj3CvOFl7$`kF3~a{&In{)ds7l;a(6cjUk0?fG`CNI6PKguH8&@9|ubehs za!U;;T@+Q*ty7o=n-#3*ze8IFa(&-4EJy<*==Ik+wA4;zX(tvh_n{FX$!&+NQySp> z-XxdW*SS~QwCE&=SM15uPgmRGMl(liNRT!9@MM~DeV;T$A*TZf+TU~3W>mQYHi;T@ zsjFlb+U=(aAR?D+IFaa_Qb!cXwzBTE0=Xx zu$OmJ9v}i1#0W7^9Q0km_PX2Q9M@#Aio*#Rt zL(9e5l*y=pk=buOVaa!JH>9SMKG}=B6AjDzWR*_}$!MAVLqwWh}|Ba=CbH4?!)} zTNE5gn~g@Y^gaKArB}b(=faKs?FQcU9$&@#@@BN%+rKFzF{;hgQ>>{Gx0>&F*}@R z{-LLFudW?O9Dk^~;~(Q~% z$VKhvszq!(ltwTnN8hBu) zW%am%o_#_&Qv>B0@+VFNd0%Ww{$pr*J{25#EfZ_C#1Gz0fNA}eW=78=S5Fv^Wi8>2 z$Yn~PaPjqkN(JU0B{)qmkRV6>RfAD)Y;G?^tk0Xzi|Z-IRO^tB_ujo9TI+uy=nT5Q z6uo(R^J_nhzi#%eONm3D2G-*pYkWx&tDB_0s>h!Jc31rPgPUB+U8D`8)^~;SYbz`K zer9GL{dpeeQ7i{MS$%r^yEgkNXGjE_wO)14A=f$E8KOGUz?>jB3w%_Q`^0O;N}IIS zrWFhaq$#ul1h@XgY8{#>`$>>-x~<&U0SXUBa8f)AZ<=OD8>miK?;!P*6oclo3X80t zJXoU%2{l45>51`K-w%4d8w(zttTncY-FE!V0vI52@7Xq)NKqqhfK%Zx`sGlluVJK* zXHpa%5CHd0{g_jAQ@8y{El-tx)pEDyw9b_LyRQOPNeX`qqwX19{ufWu;T5klkC481 z7L&z-x>r0k&V?!l0Pv6!w}@Y}mv|z6x5TnwtI|W5Jtm5}B=sMOIZZCC~=w&G(+HedzT? zLhbh~dy3-(S^HA`2clK@Z1wTI)bV2LxyKyH3D)|l7J_tl zImYp#qNO&!G=Zf`WcxbcY?GUDE5DZUnuMYkM5W8bf4q>GH<8cIm1_%@EvMDF<9wRN zdmUWR_muxCS@sG3$h^SRYN zYT({%W*I)$P_=mmwQu@lJV_>;$e;*F*ZW3vaOE65g8y+{;d3$m=uUNai4K(0CJO!; zTVDb{>$47hQE+1`ai@hR|58!e%@ZV*S@YE+HvOpFWQNRBnV^WhmeAZj@xQD+FGVd#RFlGLXCS z_RTp5>#cy_6I`k7@uG7nbn`mLl!kJr!?$6s8DfmN@Vw=DjIQT_?ESABZa7@>q;bML z6GvUs3Twn-A)sJf8oGXdw^^mZmbG^(#QdI+V<9Q2e3l?+ zSrqVseGoBj?Axo;RoQB_JdKby?dTXg$8;1nw=khUWxqC0;AuzS)QleMGz%G}G`NFm z?Tw(2W~ah%bB^1r#-*Cgu$vz^BR1m&_*0g8RUB9JbeU%A&0AaUr<&7Ykr@g1C+4IT zT85jNOZL(k*y`pnxj0H@LH)_%pU{zh_YO1RN7=^cWzI-+_w^l$aWKQqZKU@ZK0=-> zijW@DjvJ|#OeI{68c%#uJapYxH;)I;-GA(3m}ET3Zc4;8*q`SGsrnA`qaYtUquB@6 zxzIi74a;6P7A;Q|lbmx(#ZlTo`^zl*1omXtc8sc@!Pc*nmhp4tMqTwc21)9gh^li% zu0K+Ig2(VsiLvNO=5vR^@)&6ksqCP(nDOa%)FNVo2d?B?sp}#+d<{jHgz|+ASBp?k zPX8(sv)rEkVtx*l4S5*x zss8=m&Pj)z!4Db@xNPNq+8W2vb&~_AzcVK&v{H#E^@4d_k1jm3DyKa`e0dPZ_^uP+ zcs<)@$Uptv8spV`J&)?%*tT-fAzLTXYhyc_lu`-?mI#Z!X|`k&R_DCOW(A_R#Jec- zp8R;GShlbQ8Vzgk>noWIX3stZ6LP4UX3;2T=MQ@sP$G}wOSo*^PLyP*-7agxu=u(3 zBLNq5buOc;rzFT%dh9JJralSnc1uM{dgW;w8!~I`d8W>Yhtz!!*_>c6*nb2jF`V(~*Slic2m>QmW~F5-K;T zGZSZ7;CvSlIqPkgd6I4x^6Zw6sCC-l_1ZVhYrar%e&9ni{G0mAyQ>A_cJVw z`$VYU@Z@!}K@^|trfZs0wDZoyK5cw6_tG}US9#DdA|^t1YuV zKBGO#{KP2o*OTTml_3o`ONbhcdc2;C?@Lr_H^_9G4-!z1F@tR+3I5PLCH{{n5P;bv z>jQ(L=&+2>>K0gx2){y#>LAeI_3pkx)j19ZbkM+fRiN<&`>3DB)uY}e7%NT|oC z9kX^|!L5vCwJ$;K3;debInyOkTh)ih`|rkuG>epL*>8FD{YY&EYsXsaG||ivF>Ei! zL96L{HEOA21H|&`n|)8uuU52i#h)2<`BrfTo=~*BL1?L?3X&Yets7xd6OjXDC#oO1 znr>ypKJeUK=w~`IxRM*y2sC^;Bs+E)?}db%8_w!c;U+peosd#i1Fi$vnX;KeMOI3W zr17yp49aR<{QP8E6Mh5~6n*<0QilP@ z{qeaT)~5~r@A%Xr-$=nEXitFNIe_4z2$UZ6hfQqmc;emYJoNpIXDtf^P7~wHAN5U8 zTeNs(+LrLqf^6@ggB&dD4!N+5m<6X_|#DprIsY{H(mq=<=Ndp26x zq&q|JPb;L6AgI@QuC6F*(be4#i_P ziU)Eot!N)%zYu&sydvpqP29-#Vjf{ zU1IA`S{p*h5h_6Xq3+f}O_bO-yFg8|Jk?)4!KpHTYIK&MH{<86O6G=kpZnd>;kc%@ zNgoJDU3zS_lM%(Pw7OzI*Am|F2`_wfj_yxdIKm0Za0Ll+$K}5$Lhui38{o1e;yzSb zc{)TaV3<4?YoRA~oOUFUc|XpvR0V8KNp5TIQ>gR-44#Q^71giw5MPQx>}ed-ze*E& z?2da7;DNp0FqU87F_`LI{^lnnm-`m$ZKn}=iC{~}_aFV{o@{GTaljF&>O zzzmi`;m?szTATwgFc0D!sDQ)M1tHY%0MHpE7mk=%LhqRn&VwfML4jmHN5lIdW5R|teBA_V?3|@# z!?TvIzFXbZX}s4cGYW}s`>}L2LlGhZ2Nh}XBJv}=OL&OKuUkj1PDJI^9{x0k24QX_ zvZ$WCEJ*nlgWhn#h}~2OXpt zRh0T#-xikm1}7~E-yWe}6+nP?M63dvcZhnoKX9D+kKA!Ugl1DMWcAo_FOQ&xe)G&c zRCw9MgQ4#(ctWl*WasRaM5`&_ZdoKP^vYBYYV6I*ZESyEp@MrUDo`aq=YNH;P;RPx z725a6TLud0l=!h8)8S5anal^ews>obLFt=%Gwj}&@j^mQ4^F3pNA#;7P3hJgwoWM6 z%+5X?S$I^QRuu6yC;Bz?_aWwk!Vz&$p{KT=FPp$l^OjrU^Pt7Ig`}EZu0%eM^IMcp zBIRG;pCme+|jR?XP zndu-^B=)Ac{q-9tTe;VC$VfqMIY6$^?M8@he)zw%$5+{B#$Vv{UJ_0uP7i*{^qKjf zil3EM2p?+NLdT-x^^JVbg;4M*8kJ6-n@~?n1`kntnfFIelWM3yxS-Wmzp&H}B3GHL z<^8ace>kaD7XoIg7Tk;238tK*T@Kdb-;L$veH~!ZbN^x4n7dvzkyF1BN7dKde&r2H z2p`{QoKgm7V=cajtReEdTgr6i0g#-Ln8B;{7+u5CL>wF0MUCH-<8M zcJrQ%9d771&TaGv5&lAO|vAXWyRfT#~q-98$h(#bqb+E`EGO zbEFu>VSIXX@O<|b;qDvcu-K7YA~sz=zWVMFdDkBqjBYYNerxY<9``5F_%w@0%K z!TS=G$K=ak4u4p8YVTE-5@vyWc3&+@F75G?Gu8YZ5w!KI!W~)kA-%p1mDzTWrMt$> z9>zT8RH6gqA24SITG`k#R!Qu)lppvrE@=FR!isF8*8QJRQoY5T=AUQ#>NA@AOeMa} zOe~I)gD+CF>PcfyMjvhPFzg@w*@Oml?+k|K}ccBdZi1JmGY) z9M`;xS>Fh#q(@O!y|(mj^NucwUWJNk?;~GfC^dt^>2y|jJ!Cdg(iVNAc3mPtrREjL z@~5jm33XjcXLX-eou?{_nYluc2;`AfeL`75E2y@9EZ_ZHcYQ!t!3ehW2-!HCE5FVB zW#0FlnzSSV{I)JCq?hVbCviHnBi1jeYmt4PQ?wSpPV{EmHmDRNec@+6LKOUS`IsQ` z%p^uFj=*11L}o>w@+&{Yor!L)v(l5-xHzK)EoqjJBpWc#j*nX&bxL_dZ*`#@?Cr7E z>0@gIlvC#j{0u&iV3_W~N>=D?jr4Om%O?X^`O4iX(+RExxf{~pxf-E)N2Z?$?k~ze zUbyV-UX5Iy+Lvo`G_+jXR%se}TF=s+3!L%TZ4}23!a9?NLoZhg;W2G8-mVUTR2MAW z7u}{~mU=@aFlPH+z(4Q<`)4nWr>ccqkHr`MJSU8rgb~ZPMe9=a zbPpc6*W+@M$xFW$3Rn|{$Ike8nyBoR(0I>Me?Sjl1rQL=UZB4Z7&IcRvY076`e^mS z{qftBB1?y=pKhzIq1+8=$VXMQtZqiV;Sr@Jn=IyqBL0p_4umk`b^G494MRI7>#Tna z`4Yjv({bj|!xm47an!dJaQMM)i-eHoT(R8VYmaqzJ9IVVwqNiKpTu}B^KuV-J}YV{ zK;AL_te}yi@u@<0d8pr3?___4Ap7f}Ot%#3<|)KqLiaPcCjQ_H$HtdKq62(CanG}Jqmus z9S*~>*2#l5cpbkuw=efsjiz%J35Pt`YyVvz3ZA_5@AB7@}3rtc}2aBe5$=@dVIPA13Q8ymDdIhp8);V;!@j$_=zi@KMj=z59uo}!g2}s0iZ#U>F7{_H}RL=4`z0`$z>Alt{vsN@;81EPpna+P72ZMMw{z6 z5V)Ye2F#Y)2OXtwSn|>I)2Nk9uE@V|^8Tb4+xfl4N7!m1K^C@9Y|E7F7cJ zAmh6gcy7@}9RL)lIEIFr{t<*-QgdVVZfNNhN;UK4knVK-%c| zz}2o+NX>kE{|<+G-=rCKGHxV4J`xAIr1KC?->717F*`JW_;eMTh_K(rkFDLY=~NUdaCSa1eIji1E=I(Jb@0&l1`rjuCV;0Q?6xPY ze(~`vz#;dC#awIStNG$>*xDt>Vr&RHm*}_oO@17oMtE>ryQ@Kc-|RKOa8c=vkzCm< z5AX=43<$DrXP0e1Zb^d-w{B}~p#OBS1u#E+G>@&Hz3@cjFV4Q3VYFUwZ5zKbq zbOT9-k*r&OK&%Cb4A83Tlf8nkqSDM$!mNq-kd!VAyP{=1Q1-`w#vVV_pVxF}$u-r-c zrdx=2N%MVt)2FT{r*3l#n}wsfc&&Od*Qw3IUuCyDc}_O3u}ojNyg7e&?val`U=Z{8 zc-{2_hxxEdu4fXDDLRs2df*rJ%DenC5FTn!$$qbn)o36ujYVDUJSSXV++!BcXU92;7$xRS6$~b zcHsZQ+n4IJwWw4q9j@o)a?jz$P{wGwvu%rkYM(|0TqSmz_2aW}6hbZu5Rv7RjVH(z zgx9boa$Z6EGS+y(H!3FdT!9@pYP_{|=!v21Hzoz+1SR{<^z&2;+Rv1ghks2XGmW~2 zf+p|IyW{n>$myZL?^!nN0Q~`Rv_G0rH+igcEtA*ve6Tc2Is+}P4pLkjp3UQLAND_au3XR^)yPzG$ufLi8r%GI&Mgs_XI_wzScSzqAK z-^5%3ghCFPWF*zAP*C{Ky=!>t$H?|R^?jJo@n`kK(Fo4`vD2R*fQn>0$>bijjj`qH zefn89nu>b{w0+4Em*$a@jdja<%>KdErtO5aE@oZ7{7ulkOZW(k@?Y~K;0rQ zKg3$=NAl%e%I~k1IwPLbz>MZq4w;RLtz_;YsVf3nL!zGpWWs)No`?6V&b|~5`P?jm z<35g)mlL@Zbevrvv%4Dm_u7BRaQNbQqh#PFEjO!^(D;}peHH3~n4-oK*{DcvXzpx1rV_vC}@(Tf3c z(!WZ|4ub^hPy-9!C+|gyh;X)UV#k5aAnlU^bl<(>G;M_%Lvbl#Jx;_n)FKDrm4zfT zW{v@;PN*&`y;5U;yqgkJtxI=QE>WK_aig7~tPt@8V3r567n_!%lS`onaAHu%DU|^8 zubfo1?sX%d{mx_;tx+4iW=U0?NffQ41s;b*9Vt~aMyEJ1UZp{`b~+?;AK%E9G0O8%xZpUWUTN3F>N|4){Pka_0 zpkpx}T;u53f#C=$Y`*IGzn9F{@W5@c30Wen-{tZV=@jHjgaToQzu#TZym2(@4M6k8r;jh`vj0 z9#g9Ml7NVMt?e~3(=+iTe@a<-(@*EM`0|k4+tU>Z{vy8lg6`}xue>Fo0Y$Bf6g?La z-&0`43!6}4n?u7@Lgc$&HC;H<8wXIZ98IUbM3N6UcA}=y-i;{yH=RKu&>XP@K66t5 za|lPUA%Q-cA9RyRGD^-6T`gfGaGodu+X8>fo3R-mV;WB|n;8wkY{3;+dwdz!% z5C*rADQC-oL;Tc&zpS>Hs{z*?fIl4wm^yNUi@qQS z?62{2dmhf`&h@T6Q_BR2)f43sSv3E4So@`?bvN(tEM4LT)XsLifVJLuN#Ned^x-+T zT06mIYi}<+OGM+$C9_q&;umNtn@D`kxSKHle*KsOl%`lU zk`Crg7>{~!pa5J!yj=22Nf2vS#Up!8YNH4!BF@TgYu6PUN({e$cQIL*SSLUv&^Cth zhPbMqFjcxSEPWeRb#S*=57WgzP(+c8lt^dLTUT!AqwqvP2VNAjxDra-h683A6`3Nl zld(dVeU!1$-yh9iOmzMTw^^JYhoHe(fv@%a&$QPg!f zOj^~%1B^cnJ7{l!eW)wy_Rs2)%uTtCWw<*d(yp&gz7&Xv4+thpALWcx`jjt8Dk;LD zjuAnD_K`CCiD5%pxCjcx>!>n=t|h~FJ+VXJ=4snc*~W*LMb7qS^6FAJZR4f*?_w8H zLA#d2*$EA+qt%{$YnBv)zWA{n%v~;0QquT@AwBH-GV7+)p{3kH{kP&Jrw@-;WM~^q5@FUmoRdmo_L<)EX@?{v5B% zMwfmBeOXX^EH36sX231|?=7_Pv zgyY_(4^TB&Et?B7x*{o+`_|tLCQg-MF~Nysi>tf4ByrheVGl7kAuE)D=6Wn& zL0=fDvVo6?>zgx{9V09QDmkF-)F2?s@iZr1oI*1vK|e>-X(dk({pk*nlOGu<*Xk52b7w!NIq7 zXS3=ldUDgm)F1t94Anb>ars9;#no66G9kAT@CaiHin-qIa=1=Am;Vyz=*{1iMC@%f zWzn1NbK+?}b07g#QmkCCw*Tjsyo z+JB1CAG>O^@FGJGnUnQ&=;riUoSpf((70BshSfoeq!8@gMS`S=8uhEQOw2=8Gt#9= z*1!$y1r$Vi!8Gn7^jlYdi)7w6+y$;<&tELH8L`FGHQIUYN&HCBwbg@T0SbR1c zi-I5go=E_B6-5bz3l+0{-+=Cio#VK$D|n1Um#L*_ zn8@djr>?i$X8B@Rm_YRsPHgVFJB83Ok7o*=(&N90%l|@VFET(Lijmd0Z<1->EGT^F zV-9~LK*3eIOQ6kVo1~N}EyMX~UH^QL3Vq1DrtchM$VWtzk-3cO+ar#mIr=Vl2W%qj zY^id`fq4^hT2Sm^14aFKt;{WODtMyxF#=VQ>)B4WuE!<0M)6FQfZf5EyoB??x4LK#XpOgi8IR_0j|l&+N?)G;9Yo=c!~~ z*K|>|$eJ+hI9Y!I=Th7dr$6-g!}avP2IwEJc~e0zg-F;PhW)f#U!aYc4u~bQTTR)v z`-z#r(i84miX$mqRg_4=bxdqVy+r3nkuI{XJd%{OUPr?~Ci$*t`+8F-VfRDs3zH*= zsjZbRiDNAQ^-Pxm$F!rvj%Pz4^iW^k23k-A%)74>zPp^x(+Oua8xIDpSF>jUwj(AQ zPv(7+p0cU=ba{58dJm{Di2saDuOWiZ{{CPE-XQ1h_9o%Yv6_zVrFUf((SCi7MatXA z_aXT8D_=ZO9bHbZqTV^E1qKw*v?ZJKR`<^OzIr~YqLvY@@vc7 z<3;18EY)CQJf2DWN-m@9$gw9mj4gavukq2JW< zzQG9$TCq4EVM=`_gRlS9413}b!vHPI#(Q}aG{CQx-6og#@+V0VCf(~!+L8PHoP)dZ zhX;R#G!CN@H^n$WW%>d1lqhVRo1H%!I-C2Y;nmOU{rZ-vdVmV%JH0!S7G*G<&SV0F zX+bnO1(=oFmdj(is}s|njhinM%mPvRpn7v7D+UjA6v4m>{3^G*P7Q1*t?VefaM}2| z8m!!gt=t?MB~bos%xv5*1h6m4(AmJMR%9Pfgl2wvbmX)-T<+JG61Vzf zd3Zms=&R?iyC^m@)tVpE3BW$fuKVfiv>d|0BlyqVm9n}= zSPi#WX_U&plIm#JySzd(O@*NJJ@ze5al6Vr+v8+kb!s#^f|cMy4R-@}X)5b!`Tn4{ zj%z_y$G#6rDRng!ydiTCsncNvLqbDNSYLd-P{B-j8Z42M0H>fdYWXT_=@r3H+2W#?7?lkN!;p0+=94WRxJjP|B^gedPkKV}JIX=vXxj^zQW=>BK6$ zEV^IF32cZtGitxmSQhFtWzsf-DY~@+hU;D?LRk@PZS)r8A`LW0Na$7S;K-I)`(1a?iB0Vc+%e1w*pmg z1VhFOGm!axm7?8H#sfpUU!We5Y;Z5!&r@wLHK>L_&tpR zJ0uDP`+8WHpNX8|@M%v`G|WyYnB9XV8BQ)lp7kw9y-0TT%an+tq3CL(+5rhL8?DK_ zuLv^4JcU7$d6=RbJhjzWPTpX|b8(Z!)aK-nQitPnm5-G03Cck@p1z=7$bcjE_sQkY z9Z&1I)#lP;tTSxd<{^Dd0v}#E>!fBUgs1D-eHM4NzQE)`mn2;h@*gzUvC@Y@qdkGI z?(_M6&=ytq-c?GYI288tU?xhbL;BLm{@pL?s&LrP%H7>_d#>nc2nn3FMyJPKde)!m z)R~F2u&TZuT&%UIOAY$3VX}ojxJ{a*9w%r`M06`TMv(t5P?59 z?onitOVCU)xHGF2_?|v~NSisEBf0ZIr#mocp1)_|&yS)a!nJWmY%H{TQb!u~5+6yx zo$dMiDlPe^7%3Z>Q_+c14Dh;trjk|^B}0&mic*@m`Oq@5I$pK1@dzRoPx?!@OBIrg zmHYK>6-_0$-%{1YmzG4xQlvV6_uKpjJMH^9YA0wUaC>q*aIVv?&)ETxfX0!QyZW41 zfjWOVBL4U!Bnj%eOhke)@=%hdW8Y`rtbU;`1l25qA6p8q-z^w15_Mj|-6j-3XMLOf za$7wTg#j0QeaqJy%%fOH_o_fOM8_$OXmub(H|~zh?E+O0V`z{>7|tjiIPpa0YD(HE z(f&;<{3$#C;oARt&HL9ANNYJV7h*F-saHRdq@ozOfAa8zSRRF!V51vS3z=>qvvPG+ zBSH&MXuR-jxKe-YZ~EbME)L`=W#UgxQY9%N0N+e|1-7F(rh!Y*duIeS3XA0*d%e2KIQPc*Pz1btSu#xQ$ey9c-rLlK5nSUV|=>)#%-8a@>t) z^W_m)=@tbGH|Gg1v&lY07{Ewn124vl!Z6ge*1l4UIj)bcL+PTt_Sv^@JO~qP9QWr% zaTv8SK`Sh!W}%N%IP1^p=ww`##cRLEZ=?K-X3&-b21CZXv2V&ir`9cfLHw7ABd$aX zum#I~udBfg72f*R4O=gRD7sHJdYvsoKZ`lqEr-}B>&Aph7Bad+Nmo2*yXtS3^f^9+p;}k zq%&ZWMuZaaZdtd&cd~(e?=0Z`qe>qDb^W~OA%}eljb8DM!@+XNJe$>Fk;cjH(N`y% z(m#LBOG5&LMOGD8qon7r+p3sfzyKWrZax_{v#}!^*obW4u3!a6Mn*?1KrSsXv5+Hx zLBW-g_PqTvU1=`9%WZc6%~qpZ>k-l(io0CxA9)Y4hk5c;BCBUo)6rz263ILNt z>}nqe_s|rJvB^rO;)%gWgyb3ERmzehL-c!#S@M1M*@sJEUW>wP1%BO4YvvPxBym{L z;#{RwQ&BuF>!p{G-X5v<9R%iGi(u(rf2YDzqchK^{}i^?c`+ z-}M|qN3`Uz)lH_NFjbl_9vmUHREf{ywj;XNR5aKEKNzqTc0wp7U9+gQv%XF$2ncT0 zOUS2zw+eseFm7QupP%(5^JTp|c8^KA|95LXe>}f3%ff7`^vU10Apbkpdc#EvVtTWN z`Lq^Ef~;6Nspuyz|4CA>N04yD|M)Ll9}*gU^(h;U&?5+FRDu8SAJtb#5I{<(uPB_` zi|BafIa}X%UE>&YapC01U(j8<-=OakU>BFq*oa35mbb+OZJ`M*3jax}$#}+zR6y5D zR-4%>rONl}GrN^DmNDF13l=d9_-4%H&M@ew`wIgwXs`0HsTVxXlQ0=|W|M8Ncav88 zxw(3P3R_94TKG&N>_6qu*LmEU86_i8pX{Nr*H9b8{iSxVL36LMv9_1J$1kZcUEL+w z=|~cEPIjG>X0@CbB6=|~_C)S}iI`Z8Y*K-cR)wHQjmqrALU3_U59h>?j7)M*yy5s5 zu6Pv_(-8C96{=c;cw=t+2V-EPMEru1vAyCyj>fO7rAA7b#AK>U96Sa$K|xIgA}d%Wvw#B;-i2KV7U==#wDHAdm8>i)zW z(K6uo)xMJ|%kEo=N%1^$>@+tUO8=A@xZv(1I@K4GdcJbDWcQ|av$Xj@V zj4p;g8$cSD>x|J^S6b|9%s9-QPD~Z-^D9yeQUXFW z3Z*ReY2NeAo2`8@an(EuYYK^XGiTA&wt+%9C0}9y|g26k@ znRhvWs9yYlI8y%#$#+O8SBV@R4(?!vqg({OaW^>x0N9UbE9{Z>tUr~pPJ;&MNffxO zCaId|YEvcp{zzoQ`H~vnRC1S!63l7!);91ZFflM_S$a$L?>;f8MGr0Q&!5Z|7k_pL z$jrd4NkZ) zAIhtymq7Zg2c*`s|FV(=h!+q|Q2W+(-ADkO_e+{EOwZ0#nIUMbLcuab;Ji~pG<A4Tx}<0$SlcFD)X$BTsi{%?NG33u09nXbPh!3p4nu6pPDJkw zky{4I33b76owW=64bce}HC>xa6|c&Iu|3bz*0aYvO06+Q;CP1VWWXJtkLRn~UbS3QO!>)xeoIz{_W8tVQz z1Ya+h3N42z!!yuJn#TDu3&#*RS&!zg_q&jGrzJ&1d>5|mN^jcQe|&=3y+8~N43z7F zTXFOPRSkKnW69RN(0#ME`}H0%PmT#oZC1NW^;%&9`I6&0&z*!?bnvy7XNb_zNrC^# z0O^P~8vh!gaj=k_rmJnmzx6!haZ+&DpHKQWXFB*Mts})rh{)wa50}%%w@)fly3zgM z?)s=ZuSiN&E2(7aq3%_K9=_WW(1{~C{%^O1DnOh9f1l{jcd;nUy=@SAYXoxPP3Hv- zPPfh`8+@VTcrJ5PZqN1?ELj#Zxx#<=ef=7L*4yfj+yxp1g{LWg18DiGg-{I7o`4WS z7Ixv~6$eUfKDi#?N>h?G+#2OquDCR#Qu!J_YdhV1MRSq z{m5ISI(MMre@sD>8O8Gu427lH!5z-Bq;D^qg)Dr;v-Dd1hKm)S&oHNYdaBph<+`e{ z+@82?NlukKw}&CDQl_8<4$E+9*)=lyTUd~r_UpF8%ooT}w=`n|6~7ZS?wkYII|gbh z%vMh)bR+dULaWzrfSd4WNSsZh%(-%$q`GBh}kIVNy z7I3QCcnh0Vj@SFjuI0jCscOwZjO0Ec;&}~&ghvJZv64AB{tr}qClN$6dk>NLL;3ql z67L_q^m*)Y=Uf_$$1C-2ET8&S-mA#i4HNZRizKY-jr5?*cVkhjZ8vC}N=5@bx4{6^ zCJOR%7^>!`C0W0Jbvj&P|7udyrDFHv6ZA|Y)ISsoeT0L5g7BHFY$d^Xs?Is`WP3u5 z@9yGxu5wkOFEs2-uo$o{v9xP;*EB7xYmLXpc$q>z!O$YPJtuF<6)}O|1ex`wCz-f^ zsTc1ZG~(xqXD^u%9_%d-gofCw372lL#vVWLtAe0v0(tLUEVC$B|IbB(Fwy!}j_(f1 z`=clap^;4C;ZE*O-A0W1Rua&jXNjpq^_`UsLRa?2G6}axMMrVWKcy;QFf|;YMZ-3# z>gP8NStsK6AVg9fj!{nUwFli9sPI-Yn^}GpXaO$U4FjzzUCbK{D%%VV#*ui!T6!8^4%_WHYrI(_@_C$)9VdiD$012hU1L@W;n>UBs zz4>fod6VFLPE1U!n=-|X259<7;E?ZW{B;8$^1Trn6=pqZ6vy&$!&$r3$pbkKyIeM4 zPwE8amxgEg>Sxs^s6*<;N#xJmZvx9rhFa=njI`y?ZfIT=(rp!F)pU`GKG1TDc8Jhq z2{tv1Ef>FsP{09hhc~v&QghfPVP!6vZm*VFxrF6pYWl8{KC5VtG68c#vTH3%(>>0T zn`k6P5=pmFb$UUm54mFpuXWykzUMGzUOc@Tc73d7U&A$5*HyKvP?{O};bg5Sdsjj& z@AT&IXexh@8T@oSyof^C^kXWyo1QC0KCxhbJt}oskS2}1&c|AAHNIipVy04dJV8dp zu%*=;JDrpwma0rN23ylC8g(SAlyy;5I)$&OheOwses_9~%KiGZXD58~jNfh}ygnJG z8}zyEGFZ!flcK0WxoJEB9Jf6~&&9Jmd|=oQs0tdKE3`e?+1r0~t=Cu3@CE zTF#4y5qb(5H(S|TQ$u;BvLriB<<9;^RsrqtpJGXT4Sg18=vzTX zL0+4pcv2?i;3?PDfVr)~WB0vQDbl$Ob{L4fJqZ)8flg{**094Z6AQsNF0VBh zXDjdq6_1hQB-0PHW~T$h{%r0P9}m=nz2Gk!<)4q5x}c-!bm-vW;d5(D*Anl+UQ2KF zchS`*bX>K+@v;8XdjI{YDRN-W?R+*Mcwi1Z&}sL(ODWaCs+w-M?nq7#+r*7LUgDU5 zR15>Xwb5pQuIKHmXa@DS;`Q5wwYxT^>UGdLB)`A#RT>oDoA6n8N1T1AoxgwK6rBLEBD)VW!9!8}`#r^BIo+P}-P1JU z*vz8uarj`DWBih*61_EMCQ)Y8M+Rd1Wx~UMw{|+q!8R2~u?+n`MnX5$dYK}f@$Wh- zTocl}+Fq|P=+C3k-$vl~ihTI0vp6ug>W=AR;s5(x{LAO6DZqUf4%zDc_sjd2c{>UP zH{`so{sQCw`6D*6;JUOj)?5DN$N&4ED})e`fk*viZz%qFj}^MNS0usui~sA!479;d z<VgV}!WZm$dzfbqS{jwDdJgS5p4DWjjtbZ(9?;8e%mt+E+Qc(*jq{rg_ zvM&GrXJ_8M5kE%RTKC6L5q}X#4J4(}Artr%>y=JS`bp_>@c)>+rd&AalYaZbS_MfCY+WWhF{`VF12;#W_F(AwT@9%y1>iyfH9zOB6EBLn;1jI-^VE#qA z{>Pt9aK+`g|Npt1DOyxiRN|}jzblyr$m&yPlB&XO*Izpi&WTxUCue7lp`LNlFJos|&VE6;vmKo|v*BIUhm&?qUYFQc?8ELK%v5s#+n7iBby%(hu z_pfn*BUT`OoH&43Gq&_zzN2qzGM6tWc7R^vjC_ZeFvdXU!FQo{`a^PFa+32Oghx>E z^N_Cv{Jydk42T7enM;Fd8H|TFZX-d=!N;=F&So1B+4FGv1lZvY;+pXzf<@+u9_%j$ ziUJEJt{yXvTZr~#)YM%K<#Xp>e)L%3{T`b8WwK%oI<&NKpEflK48@ccRi&PeXrJIE zrzn4hg1k($sTsn3ux`D1s21I@4`SU0g$YkyVpKo%Z(-!Lw^Q5?*2L5MUoW=`}ba*~evUEKID}?_$gLdlZ3!*hny2$Jv*gb8ao**dFEeRMcHW{Z8`x z4fiYafOgT1XwU~Y_nMFlF~o(QFUJ5PRQZA8ehCH5d^FWoHh?RzWFG{L?rdG`^ZRay z!?7S1bceT}Hh2psyO-J^_5W>#Zpat+arPU`8?L9E{gYD!6SogI$+o zi*D=(iPe?~`whkmENODYLvaUD)#AUekr);_kz+i0;i}KO#|HlQv-eltnE?bD+H*vD zKeq8s-Q2dYbv-H{!G`F;I(#hMbQ!$3UpUOYR^%yY{yh9^Dxdn^pFiJ84zV6mc^u?2 zVEVFyXXfjJdGKnAdmgTt0-v~j#JSpgslF`tK>9nsdL28HWZWXU@IwY7Tw$!;{T?rG zE%e0@M}}rw^@mp~q3kt4Z8~iAKof%NFNUnfmS8pLd@~hOJmCK13$0o-zGY3B*u2v+Aqtw}n#n{o?dp9BXYtWL_ zV5@vn>6;35RiCbCj_c_wHAxwna@X&`X~1l}$K#mtr^y;l2x(d`d-VQX&2gHiO5=Q1 zUP=mmK<(|@^o{Mw5`B=X^JOQNPyeB|{12BGPVL(cW>)Jl7h$t#jf&zkoNQcrL(k;!9BHcDV;-i zeD}P2ucdpxYrkv%zT@DJaTW_6=DD9M&g(qSOIA7c%5S&nw?q9`KQd9BvImo3S)nzU z+_fJ&lvHRX({t%>KZRdEG>))M%|`-|*uudk!W`(e+B@S9)Uq?7iT@APO&FKYOkq+? zgZWS)M4mH122ovNoCS7{d$DZ-@4$9o_PJK#wzrA^en_YLj-)`!L&ITq%2HBNQVO;% zBlR-ea4e_N-wgt&e0jkxmKj1#TA2BWkx`EN;X_kor7gSrFf*k5b$Aq$mO(tH9rI$6 zccXiVy(z%fOHJBejMZC~t&P@*GpgrOx_QSMxaHkpRBx-Qt9x+wE*;nak2b-R?W2~Q zzi-?NMn_|C?-a0N@7q3;X1PWD$Q0Zg&a7A6n+$hGXy)N-h_8$kuqk%k`yJl>;{^YD zG=G1kL>oqj5olMxC548blU92~E|?oqJT>K_=znk>WMr*GS@)8nPq3c6h4aZAL zbuK&r$ZVi(YN9&?(^kf_Wqj{Z{>WGr#$VY3K*a2trq{Qy7~R@ z{QXn-g5O3cpOEC;bQQgCMj%K0{p}815e0{ZMLV$hii%8en1W?7*)ikqo32*f%keJ;U)Z7R{%m7AhVKPNbyHfumf$zTN7tu)fO^Amdl~A-+>mDURPc-wAB) z;=~L3uj7HJ4hGOC)AwS+0ZD5rX;zyMb56JABgs0q^HOh9QI1yMnh2WLbt+_>-I>H= zI0Zcr;(0FHLw3Hm$*Bx{8X}{35D}h-Kcv~`Lq*Vo1J!TLd<#)&;{N;^t_AY3LYrUw zfD(rWNXWuZIgiYjJzB{(w$PIufIm)nHneOSHBX#vZkEzo_a$s2VDR?m9F6cbNjr13 zz|hdx^RweXEGbJ%_xb33gT*dGN+3!I2lI+b{n$4rTkjv(BzgaSSN=7I{@cymq(dK5 zrx!d^uOr{SeLKo$H50^2v=9Y6cYv4MxuvUoEW<-Eto(6b&hf-8MVQ^KQyXkSB49A? zW&mrV%WaXv%tS6h?%n=4WD~XalCdl&;DYB$5p1!5VA6rZQyvMH23AjU+i0Vr=X1cW zRp6OO1D)%W21=lDm$9;yp#u4y%V039vgy?^h1^?ES5K#M2uw3k4){&4{q=h=M;M>YX}(p7GPd-p8- zJUO3R01_p;$Kgvz>FYqGyWvw`K#OggJSf!j*!oUDL^MA}Q^Z>HZBki5Aqx0()tsOQ z*H%N+>*rGq?xuh|;j&z=tSFWFls2H90a=;CYu+C5U8zjZ`wWNsO`GjwBK`D2@%JIg ziU!UN{%Yd}eZx~hW(*Dnit&l-_hUYNx>u$2AK|k9b*Bw|o=^KvOrUy8G8B?eBa^-= zi=5N<2o5042uOKc5(SF(*EuQ+A->oIZYM=mCgACD*qjkN@R*i!a45BpoCFddsr-E~ zeSubx3ww-OdxNI{ObtZ9Og~twZZTwW@8{2-6XND6j?_!7ww%#zZBa8cJ={5sV3e4{ zNNSS>EB}BcM9$|R;dI3HXRiF)MAV1@Wei)IaO@${zpcP=eSI2kNPDpx8TBOYL#54_ z&Eop~g9hyBGw9Aj2VtlPD`0NB;x4P2qU(XpQd86OdZ?YdPBfm#WuESS>+^2t@v;H; zmGH$9kcG#XCBX*lC}92anj+%LwcBkQlXa8a)0B@EMN1EHom3xqnwDE!iZqB6P5=0z z5BEPsx_|wodCM@j)hSg(Mc}~S;q;5;-#guhD zpa}$E@qnxDtVB(apqwYh#v0cp9!CviBZ2xwKJfEn*~f@nOLQP{0CS8nH=T>Oap(Vi zwmQY0%M+zenhEBaqDM9ERO8K6eDh=GDUoH} z{D`sW3hMy_m(KO5_Krc33#T57q0;CHfQ)W5Y7QUPn5AO#_TQ2PA-fVKa?x3Su;H9O z7RTitV;@=f08$jstY160VV#{O&(<&~B=uh(YM;*Qm}c3IO1nezUy3c)8Pa9mn`yEH149iXCD9+$AszjX z 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 w;o{2E1uD>eUW; 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

    -Ka)G6+g6Rr$*Zg-Lo(Lm_8E4 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 LYO;lQO#=TPOj@a= literal 0 HcmV?d00001 diff --git a/docs/index.md b/docs/index.md index 217f37fe..7258402a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,7 +24,8 @@ Apart from this documentation, we also provide online * [Kerberos and SSPI authentication](#kerberos-and-sspi-authentication) * [Certificate Based Authentication (CBA)](#certificate-based-authentication-cba) * [OAuth authentication](#oauth-authentication) - * [OAuth on Office 365](#oauth-on-office-365) + * [Impersonation OAuth on Office 365](#impersonation-oauth-on-office-365) + * [Delegate OAuth on Office 365](#delegate-oauth-on-office-365) * [Caching autodiscover results](#caching-autodiscover-results) * [Proxies and custom TLS validation](#proxies-and-custom-tls-validation) * [User-Agent](#user-agent) @@ -382,28 +383,67 @@ class MyCredentials(OAuth2AuthorizationCodeCredentials): self.access_token = ... ``` -### OAuth on Office 365 +### Impersonation OAuth on Office 365 Office 365 is deprecating Basic authentication and switching to MFA for end -users and OAuth for everything else. Here's one way to set up an app that can -access accounts in your organization. First, log into the +users and OAuth for everything else. Here's one way to set up an app in Azure +that can access accounts in your organization using impersonation - i.e. access +to multiple acounts on behalf of those users. First, log into the [https://admin.microsoft.com](Microsoft 365 Administration) page. Find the link -to `Azure Active Directory`. Register a new app, and note down the Directory -(tenant) ID, Application (client) ID, and the secret: +to `Azure Active Directory`. Find the link +to `Azure Active Directory`. Select `App registrations` in the menu and then +`New registration`. Enter an app name and press `Register`: ![App registration](/exchangelib/assets/img/app_registration.png) - -Continue to the `API permissions` page, and add the `full_access_as_app` +On the next page, note down the Directory (tenant) ID and Application (client) +ID, create a secret using the `Add a certificate or secret` link, and note down +the Value (client secret) as well. + +Continue to the `App registraions` menu item, select your new app, select the +`API permissions` menu item, Select `Add permission` and then +`APIs my organization uses` and search for `Office 365 Exchange Online`. Select +that API, then `Application permissions` and add the `full_access_as_app` permission: ![API permissions](/exchangelib/assets/img/api_permissions.png) -Finally, continue to the `Enterprise applications` page, select your app, +Finally, continue to the `Enterprise applications` page, select your new app, continue to the `Permissions` page, and check that your app has the `full_access_as_app` permission: ![API permissions](/exchangelib/assets/img/permissions.png) +If not, press `Grant admin consent for testuiste_delegate` and grant access. You should now be able to connect to an account using the `OAuth2Credentials` class as shown above. +### Delegate OAuth on Office 365 +If you only want to access a single account on Office 365, delegate access is +a more suitable access level. Here's one way to set up an app in Azure +that can access accounts in your organization using delegation - i.e. access +to the same account that you are logging in as. First, log into the +[https://admin.microsoft.com](Microsoft 365 Administration) page. Find the link +to `Azure Active Directory`. Select `App registrations` in the menu and then +`New registration`. Enter an app name and press `Register`: +![App registration](/exchangelib/assets/img/delegate_app_registration.png) +On the next page, note down the Directory (tenant) ID and Application (client) +ID, create a secret using the `Add a certificate or secret` link, and note down +the Value (client secret) as well. + +Continue to the `App registraions` menu item, select your new app, select the +`API permissions` menu item, Select `Add permission` and then +`APIs my organization uses` and search for `Office 365 Exchange Online`. Select +that API and then `Delegated permissions` and add the `EWS.AccessAsUser.All` +permission under the `EWS` section: +![API permissions](/exchangelib/assets/img/delegate_app_api_permissions.png) + +Finally, continue to the `Enterprise applications` page, select your new app, +continue to the `Permissions` page, and check that your app has the +`full_access_as_app` permission: +![API permissions](/exchangelib/assets/img/delegate_app_permissions.png) +If not, press "Grant admin consent for testuiste_delegate" and grant access. + +You should now be able to connect to an account using the +`OAuth2LegacyCredentials` class as shown above. + + ### Caching autodiscover results If you're connecting to the same account very often, you can cache the autodiscover result for later so you can skip the autodiscover lookup: From 33544117cdf42ecef17c73a43916e5e58abc6156 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 22 May 2023 14:45:10 +0200 Subject: [PATCH 343/509] docs: Fix filename --- ...permissions.png => delegate_app_permissions.png} | Bin 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/assets/img/{delegate_app_request_permissions.png => delegate_app_permissions.png} (100%) diff --git a/docs/assets/img/delegate_app_request_permissions.png b/docs/assets/img/delegate_app_permissions.png similarity index 100% rename from docs/assets/img/delegate_app_request_permissions.png rename to docs/assets/img/delegate_app_permissions.png From 8d67eb247d061d2df815ba95a8803f6d84a0dde7 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 22 May 2023 14:47:05 +0200 Subject: [PATCH 344/509] docs: Fix spelling --- docs/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index 7258402a..624c420d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -408,7 +408,7 @@ Finally, continue to the `Enterprise applications` page, select your new app, continue to the `Permissions` page, and check that your app has the `full_access_as_app` permission: ![API permissions](/exchangelib/assets/img/permissions.png) -If not, press `Grant admin consent for testuiste_delegate` and grant access. +If not, press `Grant admin consent for testsuite` and grant access. You should now be able to connect to an account using the `OAuth2Credentials` class as shown above. @@ -438,7 +438,7 @@ Finally, continue to the `Enterprise applications` page, select your new app, continue to the `Permissions` page, and check that your app has the `full_access_as_app` permission: ![API permissions](/exchangelib/assets/img/delegate_app_permissions.png) -If not, press "Grant admin consent for testuiste_delegate" and grant access. +If not, press `Grant admin consent for testsuite_delegate` and grant access. You should now be able to connect to an account using the `OAuth2LegacyCredentials` class as shown above. From 9c9d6edb4d23ee3bcae6e177a8cde605f5da6597 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 22 May 2023 14:49:59 +0200 Subject: [PATCH 345/509] docs: filenames were swapped --- .../img/delegate_app_api_permissions.png | Bin 815521 -> 325134 bytes docs/assets/img/delegate_app_permissions.png | Bin 325134 -> 815521 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/assets/img/delegate_app_api_permissions.png b/docs/assets/img/delegate_app_api_permissions.png index b7f8fe95cd05129ac119b119b0199ae4b51b06b9..01b3b9540a36550458ab738d7381b8c6942cf3d0 100644 GIT binary patch literal 325134 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 z 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 w;o{2E1uD>eUW; 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

    LO%nDeWP|cOkcQ8ewy(b56TjArP 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

    LO%nDeWP|cOkcQ8ewy(b56TjArP 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

    -Ka)G6+g6Rr$*Zg-Lo(Lm_8E4 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 LYO;lQO#=TPOj@a= literal 815521 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 z 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 w;o{2E1uD>eUW; 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

    Module exchangelib.autodiscover.discovery

    DNS_LOOKUP_ERRORS = ( dns.name.EmptyLabel, + dns.resolver.LifetimeTimeout, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers, @@ -60,6 +61,9 @@

    Module exchangelib.autodiscover.discovery

    self.port = port self.srv = srv + def __hash__(self): + return hash((self.priority, self.weight, self.port, self.srv)) + def __eq__(self, other): return all(getattr(self, k) == getattr(other, k) for k in self.__dict__) @@ -1132,6 +1136,9 @@

    Methods

    self.port = port self.srv = srv + def __hash__(self): + return hash((self.priority, self.weight, self.port, self.srv)) + def __eq__(self, other): return all(getattr(self, k) == getattr(other, k) for k in self.__dict__)

    -Ka)G6+g6Rr$*Zg-Lo(Lm_8E4 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 LYO;lQO#=TPOj@a= From 37d5911e05da7f630d03a23e1db5b437bb2fe967 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 22 May 2023 22:26:01 +0200 Subject: [PATCH 346/509] chore: Improve test coverage a bit --- exchangelib/autodiscover/protocol.py | 11 +++++------ tests/test_autodiscover.py | 21 +++++++++++++++++++-- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/exchangelib/autodiscover/protocol.py b/exchangelib/autodiscover/protocol.py index 830e2336..473d793c 100644 --- a/exchangelib/autodiscover/protocol.py +++ b/exchangelib/autodiscover/protocol.py @@ -35,18 +35,17 @@ def get_auth_type(self): # Autodetect authentication type. return get_autodiscover_authtype(protocol=self) - def get_user_settings(self, user): - return GetUserSettings(protocol=self).get( - users=[user], - settings=[ + def get_user_settings(self, user, settings=None): + if not settings: + settings = [ "user_dn", "mailbox_dn", "user_display_name", "auto_discover_smtp_address", "external_ews_url", "ews_supported_schemas", - ], - ) + ] + return GetUserSettings(protocol=self).get(users=[user], settings=settings) def dummy_xml(self): # Generate a valid EWS request for SOAP autodiscovery diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 9786fd17..9bf2c7ee 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -21,7 +21,7 @@ from exchangelib.util import get_domain from exchangelib.version import EXCHANGE_2013, Version -from .common import EWSTest, get_random_hostname, get_random_string +from .common import EWSTest, get_random_email, get_random_hostname, get_random_string class AutodiscoverTest(EWSTest): @@ -240,9 +240,25 @@ def test_autodiscover_with_delegate(self): self.assertEqual(ad_response.autodiscover_smtp_address, self.account.primary_smtp_address) self.assertEqual(protocol.service_endpoint.lower(), self.account.protocol.service_endpoint.lower()) + def test_get_user_settings(self): + # Create a real Autodiscovery protocol instance + ad = Autodiscovery( + email=self.account.primary_smtp_address, + credentials=self.account.protocol.credentials, + ) + ad.discover() + p = autodiscover_cache[ad._cache_key] + + # Test invalid email + invalid_email = get_random_email() + r = p.get_user_settings(user=invalid_email) + self.assertIsInstance(r, UserResponse) + self.assertEqual(r.error_code, "InvalidUser") + self.assertIn(f"Invalid user '{invalid_email}'", r.error_message) + @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here def test_close_autodiscover_connections(self, m): - # A live test that we can close TCP connections + # Test that we can close TCP connections p = self.get_test_protocol() autodiscover_cache[(p.config.server, p.config.credentials)] = p self.assertEqual(len(autodiscover_cache), 1) @@ -707,6 +723,7 @@ def test_raise_errors(self): UserResponse().raise_errors() with self.assertRaises(ErrorNonExistentMailbox) as e: UserResponse(error_code="InvalidUser", error_message="Foo").raise_errors() + self.assertEqual(e.exception.args[0], "Foo") with self.assertRaises(AutoDiscoverFailed) as e: UserResponse(error_code="InvalidRequest", error_message="FOO").raise_errors() self.assertEqual(e.exception.args[0], "InvalidRequest: FOO") From c752e187f2937a9c2bf9cf6214fcd8e05572a52f Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 23 May 2023 14:51:00 +0200 Subject: [PATCH 347/509] docs: Document MSAL with interactive login to O365 --- docs/index.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/index.md b/docs/index.md index 624c420d..50393520 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,6 +26,7 @@ Apart from this documentation, we also provide online * [OAuth authentication](#oauth-authentication) * [Impersonation OAuth on Office 365](#impersonation-oauth-on-office-365) * [Delegate OAuth on Office 365](#delegate-oauth-on-office-365) + * [MSAL on Office 365](#msal-on-office-365) * [Caching autodiscover results](#caching-autodiscover-results) * [Proxies and custom TLS validation](#proxies-and-custom-tls-validation) * [User-Agent](#user-agent) @@ -444,6 +445,54 @@ You should now be able to connect to an account using the `OAuth2LegacyCredentials` class as shown above. +### MSAL on Office 365 +The [Microsoft Authentication Library](https://github.com/AzureAD/microsoft-authentication-library-for-python) +supports obtaining OAuth tokens via a range of different methods. You can use +MSAL to fetch a token valid for EWS and then only provide the token to this +library. + +In this example, we'll do an interactive login using a browser window, so you +can use the login method supported by your organization, and take advantage of +any SSO available to your browser. There are example scripts for other flows +available at [https://github.com/AzureAD/microsoft-authentication-library-for-python/tree/dev/sample](https://github.com/AzureAD/microsoft-authentication-library-for-python/tree/dev/sample). + +First, create an app in Azure with `EWS.AccessAsUser.All` delegate permissions, +as described in the section above, except you don't need to create a client +secret, and you need to add a “Mobile and Desktop application” +Redirect URI set to `http://localhost`. Note down the client ID. Then connect +using the following code: + +```python +# Script adapted from https://github.com/AzureAD/microsoft-authentication-library-for-python/blob/dev/sample/interactive_sample.py +from exchangelib import Configuration, OAUTH2, Account, DELEGATE, OAuth2AuthorizationCodeCredentials +import msal + +config = { + "authority": "https://login.microsoftonline.com/organizations", + "client_id": "MY_CLIENT_ID", + "scope": ["EWS.AccessAsUser.All"], + "username": "MY_ACCOUNT@example.com", + "endpoint": "https://graph.microsoft.com/v1.0/users", + "account": "MY_ACCOUNT@example.com", + "server": "outlook.office365.com", +} +app = msal.PublicClientApplication(config["client_id"], authority=config["authority"]) +print("A local browser window will be open for you to sign in. CTRL+C to cancel.") +result = app.acquire_token_interactive(config["scope"], login_hint=config.get("username")) +assert "access_token" in result + +creds = OAuth2AuthorizationCodeCredentials(access_token=result) +conf = Configuration(server=config["server"], auth_type=OAUTH2, credentials=creds) +a = Account( + primary_smtp_address=config["account"], + access_type=DELEGATE, + config=conf, + autodiscover=False, +) +print(a.root.tree()) +``` + + ### Caching autodiscover results If you're connecting to the same account very often, you can cache the autodiscover result for later so you can skip the autodiscover lookup: From d3780f6573ed604d5151ee4573cc067e70df8c31 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 23 May 2023 15:25:03 +0200 Subject: [PATCH 348/509] fix: Don't restrict OAuth2 Credentials classes unnecessarily in their scope and refreshability. Fixes #1151 --- exchangelib/credentials.py | 51 +++++++++++++++----------------------- 1 file changed, 20 insertions(+), 31 deletions(-) diff --git a/exchangelib/credentials.py b/exchangelib/credentials.py index 3325e1bd..aa2494dc 100644 --- a/exchangelib/credentials.py +++ b/exchangelib/credentials.py @@ -134,9 +134,11 @@ def sig(self): return hash(tuple(res)) @property - @abc.abstractmethod def token_url(self): """The URL to request tokens from""" + # We may not know (or need) the Microsoft tenant ID. If not, use common/ to let Microsoft select the appropriate + # tenant for the provided authorization code or refresh token. + return f"https://login.microsoftonline.com/{self.tenant_id or 'common'}/oauth2/v2.0/token" # nosec @property @abc.abstractmethod @@ -145,7 +147,23 @@ def scope(self): def session_params(self): """Extra parameters to use when creating the session""" - return {"token": self.access_token} # Token may be None + res = {"token": self.access_token} # Token may be None + if self.client_id and self.client_secret: + # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other + # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to + # refresh the token (that covers cases where the caller doesn't have access to the client secret but + # is working with a service that can provide it refreshed tokens on a limited basis). + res.update( + { + "auto_refresh_kwargs": { + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + "auto_refresh_url": self.token_url, + "token_updater": self.on_token_auto_refreshed, + } + ) + return res def token_params(self): """Extra parameters when requesting the token""" @@ -191,10 +209,6 @@ class OAuth2Credentials(BaseOAuth2Credentials): the associated auth code grant type for multi-tenant applications. """ - @property - def token_url(self): - return f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token" - @property def scope(self): return ["https://outlook.office365.com/.default"] @@ -266,37 +280,12 @@ def __init__(self, authorization_code=None, **kwargs): super().__init__(**kwargs) self.authorization_code = authorization_code - @property - def token_url(self): - # We don't know (or need) the Microsoft tenant ID. Use common/ to let Microsoft select the appropriate - # tenant for the provided authorization code or refresh token. - return "https://login.microsoftonline.com/common/oauth2/v2.0/token" # nosec - @property def scope(self): res = super().scope res.append("offline_access") return res - def session_params(self): - res = super().session_params() - if self.client_id and self.client_secret: - # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other - # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to - # refresh the token (that covers cases where the caller doesn't have access to the client secret but - # is working with a service that can provide it refreshed tokens on a limited basis). - res.update( - { - "auto_refresh_kwargs": { - "client_id": self.client_id, - "client_secret": self.client_secret, - }, - "auto_refresh_url": self.token_url, - "token_updater": self.on_token_auto_refreshed, - } - ) - return res - def token_params(self): res = super().token_params() res["code"] = self.authorization_code # Auth code may be None From f527172abf2d66ad74377b9712999543da3e326f Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 23 May 2023 19:20:41 +0200 Subject: [PATCH 349/509] chore: reuse more code between GetUserSettings and UserResponse classes --- exchangelib/properties.py | 23 +++++++++++++++-------- exchangelib/services/get_user_settings.py | 21 ++++++++------------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 112b8914..50cb8139 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -2093,23 +2093,30 @@ def raise_errors(self): raise AutoDiscoverFailed(f"User settings errors: {self.user_settings_errors}") @classmethod - def from_xml(cls, elem, account): + def parse_elem(cls, elem): # Possible ErrorCode values: # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/errorcode-soap error_code = get_xml_attr(elem, f"{{{ANS}}}ErrorCode") error_message = get_xml_attr(elem, f"{{{ANS}}}ErrorMessage") if error_code == "InternalServerError": - raise ErrorInternalServerError(error_message) + return ErrorInternalServerError(error_message) if error_code == "ServerBusy": - raise ErrorServerBusy(error_message) + return ErrorServerBusy(error_message) if error_code == "NotFederated": - raise ErrorOrganizationNotFederated(error_message) - if error_code not in ("NoError", "RedirectAddress", "RedirectUrl"): - return cls(error_code=error_code, error_message=error_message) + return ErrorOrganizationNotFederated(error_message) + return cls(error_code=error_code, error_message=error_message) + + @classmethod + def from_xml(cls, elem, account): + res = cls.parse_elem(elem) + if isinstance(res, Exception): + raise res + if res.error_code not in ("NoError", "RedirectAddress", "RedirectUrl"): + return cls(error_code=res.error_code, error_message=res.error_message) redirect_target = get_xml_attr(elem, f"{{{ANS}}}RedirectTarget") - redirect_address = redirect_target if error_code == "RedirectAddress" else None - redirect_url = redirect_target if error_code == "RedirectUrl" else None + redirect_address = redirect_target if res.error_code == "RedirectAddress" else None + redirect_url = redirect_target if res.error_code == "RedirectUrl" else None user_settings_errors = {} settings_errors_elem = elem.find(f"{{{ANS}}}UserSettingErrors") if settings_errors_elem is not None: diff --git a/exchangelib/services/get_user_settings.py b/exchangelib/services/get_user_settings.py index d0188a57..e8cee20c 100644 --- a/exchangelib/services/get_user_settings.py +++ b/exchangelib/services/get_user_settings.py @@ -1,9 +1,9 @@ import logging -from ..errors import ErrorInternalServerError, ErrorOrganizationNotFederated, ErrorServerBusy, MalformedResponseError +from ..errors import MalformedResponseError from ..properties import UserResponse from ..transport import DEFAULT_ENCODING -from ..util import ANS, add_xml_child, create_element, get_xml_attr, ns_translation, set_xml_value, xml_to_str +from ..util import ANS, add_xml_child, create_element, ns_translation, set_xml_value, xml_to_str from ..version import EXCHANGE_2010 from .common import EWSService @@ -74,21 +74,16 @@ def _get_element_container(self, message, name=None): response = message.find(f"{{{ANS}}}Response") # ErrorCode: See # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/errorcode-soap - error_code = get_xml_attr(response, f"{{{ANS}}}ErrorCode") - if error_code == "NoError": + # There are two 'ErrorCode' elements in the response; one is a child of the 'Response' element, the other is a + # child of the 'UserResponse' element. Let's handle both with the same code. + res = UserResponse.parse_elem(response) + if res.error_code == "NoError": container = response.find(name) if container is None: raise MalformedResponseError(f"No {name} elements in ResponseMessage ({xml_to_str(response)})") return container - # Raise any non-acceptable errors in the container, or return the container or the acceptable exception instance - msg_text = get_xml_attr(response, f"{{{ANS}}}ErrorMessage") - if error_code == "InternalServerError": - raise ErrorInternalServerError(msg_text) - if error_code == "ServerBusy": - raise ErrorServerBusy(msg_text) - if error_code == "NotFederated": - raise ErrorOrganizationNotFederated(msg_text) + # Raise any non-acceptable errors in the container, or return the acceptable exception instance try: - raise self._get_exception(code=error_code, text=msg_text, msg_xml=None) + raise self._get_exception(code=res.error_code, text=res.error_message, msg_xml=None) except self.ERRORS_TO_CATCH_IN_RESPONSE as e: return e From 24d507c1a15916957e72a91d5b271d710945a423 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 23 May 2023 22:33:07 +0200 Subject: [PATCH 350/509] chore: add better test coverate of public folders root --- tests/test_folder.py | 201 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 200 insertions(+), 1 deletion(-) diff --git a/tests/test_folder.py b/tests/test_folder.py index a636b38e..8f1c25e8 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -1,6 +1,8 @@ from contextlib import suppress from unittest.mock import Mock +import requests_mock + from exchangelib.errors import ( DoesNotExist, ErrorCannotEmptyFolder, @@ -157,7 +159,8 @@ def test_folder_failure(self): Folder.item_model_from_tag("XXX") self.assertEqual(e.exception.args[0], "Item type XXX was unexpected in a Folder folder") - def test_public_folders_root(self): + @requests_mock.mock(real_http=True) + def test_public_folders_root(self, m): # Test account does not have a public folders root. Make a dummy query just to hit .get_children() with suppress(ErrorNoPublicFolderReplicaAvailable): self.assertGreaterEqual( @@ -168,6 +171,202 @@ def test_public_folders_root(self): ), 0, ) + # Test public folders root with mocked responses + get_public_folder_xml = b"""\ + + + + + + + NoError + + + + IPF.Note + publicfoldersroot + + + + + + +""" + find_public_folder_children_xml = b"""\ + + + + + + + NoError + + + + + + IPF.Contact + Sample Contacts + 2 + 0 + 0 + + + + + IPF.Note + Sample Folder + 0 + 0 + 0 + + + + + + + +""" + get_public_folder_children_xml = b"""\ + + + + + + + NoError + + + + IPF.Contact + Sample Contacts + + + + IPF.Note + Sample Folder + + + + + + +""" + m.post( + self.account.protocol.service_endpoint, + [ + dict(status_code=200, content=get_public_folder_xml), + dict(status_code=200, content=find_public_folder_children_xml), + dict(status_code=200, content=get_public_folder_children_xml), + ], + ) + # Test top-level .children + self.assertListEqual( + [f.name for f in self.account.public_folders_root.children], ["Sample Contacts", "Sample Folder"] + ) + + find_public_subfolder1_children_xml = b"""\ + + + + + + + NoError + + + + + + IPF.Contact + Sample Subfolder1 + 0 + 0 + 0 + + + + + IPF.Note + Sample Subfolder2 + 0 + 0 + 0 + + + + + + + +""" + get_public_subfolder1_children_xml = b"""\ + + + + + + + NoError + + + + IPF.Contact + Sample Subfolder1 + + + + IPF.Note + Sample Subfolder2 + + + + + + +""" + find_public_subfolder2_children_xml = b"""\ + + + + + + + NoError + + + + + + + + +""" + m.post( + self.account.protocol.service_endpoint, + [ + dict(status_code=200, content=find_public_subfolder1_children_xml), + dict(status_code=200, content=get_public_subfolder1_children_xml), + dict(status_code=200, content=find_public_subfolder2_children_xml), + ], + ) + # Test .get_children() on subfolders + f_1 = self.account.public_folders_root / "Sample Contacts" + f_2 = self.account.public_folders_root / "Sample Folder" + self.assertListEqual( + [f.name for f in self.account.public_folders_root.get_children(f_1)], + ["Sample Subfolder1", "Sample Subfolder2"], + ) + self.assertListEqual([f.name for f in self.account.public_folders_root.get_children(f_2)], []) def test_invalid_deletefolder_args(self): with self.assertRaises(ValueError) as e: From 3153d1810d42e8a694503370265a3752b0b1bd52 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 23 May 2023 23:19:20 +0200 Subject: [PATCH 351/509] chore: Implement some suggestions by DeepSource --- exchangelib/autodiscover/discovery.py | 3 +++ exchangelib/ewsdatetime.py | 3 +++ exchangelib/items/message.py | 32 ++++++++++++--------------- exchangelib/services/update_folder.py | 6 +++-- exchangelib/services/update_item.py | 3 ++- exchangelib/version.py | 10 ++++++--- tests/test_autodiscover.py | 3 +++ tests/test_ewsdatetime.py | 3 +++ tests/test_version.py | 3 +++ 9 files changed, 42 insertions(+), 24 deletions(-) diff --git a/exchangelib/autodiscover/discovery.py b/exchangelib/autodiscover/discovery.py index 3235931c..7024695d 100644 --- a/exchangelib/autodiscover/discovery.py +++ b/exchangelib/autodiscover/discovery.py @@ -32,6 +32,9 @@ def __init__(self, priority, weight, port, srv): self.port = port self.srv = srv + def __hash__(self): + return hash((self.priority, self.weight, self.port, self.srv)) + def __eq__(self, other): return all(getattr(self, k) == getattr(other, k) for k in self.__dict__) diff --git a/exchangelib/ewsdatetime.py b/exchangelib/ewsdatetime.py index de0b7e20..d2f15b9b 100644 --- a/exchangelib/ewsdatetime.py +++ b/exchangelib/ewsdatetime.py @@ -229,6 +229,9 @@ def __new__(cls, *args, **kwargs): instance.ms_name = "" return instance + def __hash__(self): + return hash(self.key) + def __eq__(self, other): # Microsoft time zones are less granular than IANA, so an EWSTimeZone created from 'Europe/Copenhagen' may # return from the server as 'Europe/Copenhagen'. We're catering for Microsoft here, so base equality on the diff --git a/exchangelib/items/message.py b/exchangelib/items/message.py index 8abeff83..8afaa810 100644 --- a/exchangelib/items/message.py +++ b/exchangelib/items/message.py @@ -104,24 +104,20 @@ def send_and_save( conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) - else: - if self.account.version.build < EXCHANGE_2013 and self.attachments: - # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need - # to first save, then attach, then send. This is done in save(). - self.save( - update_fields=update_fields, - conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations, - ) - return self.send( - save_copy=False, - conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations, - ) - else: - return self._create( - message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations - ) + if self.account.version.build < EXCHANGE_2013 and self.attachments: + # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need + # to first save, then attach, then send. This is done in save(). + self.save( + update_fields=update_fields, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, + ) + return self.send( + save_copy=False, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, + ) + return self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations) @require_id def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None): diff --git a/exchangelib/services/update_folder.py b/exchangelib/services/update_folder.py index e93aa150..a3361811 100644 --- a/exchangelib/services/update_folder.py +++ b/exchangelib/services/update_folder.py @@ -105,8 +105,9 @@ def _change_elem(self, target, fieldnames): change.append(updates) return change + @staticmethod @abc.abstractmethod - def _target_elem(self, target): + def _target_elem(target): """Convert the object to update to an XML element""" def _changes_elem(self, target_changes): @@ -145,7 +146,8 @@ def _elems_to_objs(self, elems): continue yield parse_folder_elem(elem=elem, folder=folder, account=self.account) - def _target_elem(self, target): + @staticmethod + def _target_elem(target): return to_item_id(target, FolderId) def get_payload(self, folders): diff --git a/exchangelib/services/update_item.py b/exchangelib/services/update_item.py index 389c5079..9f264b8f 100644 --- a/exchangelib/services/update_item.py +++ b/exchangelib/services/update_item.py @@ -87,7 +87,8 @@ def _get_value(self, target, field): return value - def _target_elem(self, target): + @staticmethod + def _target_elem(target): return to_item_id(target, ItemId) def get_payload( diff --git a/exchangelib/version.py b/exchangelib/version.py index 408c6af7..637c6ecd 100644 --- a/exchangelib/version.py +++ b/exchangelib/version.py @@ -165,9 +165,10 @@ def __init__(self, build, api_version=None): @property def fullname(self): for build, api_version, full_name in VERSIONS: - if self.build: - if self.build.major_version != build.major_version or self.build.minor_version != build.minor_version: - continue + if self.build and ( + self.build.major_version != build.major_version or self.build.minor_version != build.minor_version + ): + continue if self.api_version == api_version: return full_name raise ValueError(f"Full name for API version {self.api_version} build {self.build} is unknown") @@ -253,6 +254,9 @@ def all_versions(cls): # Return all supported versions, sorted newest to oldest return [cls(build=build, api_version=api_version) for build, api_version, _ in VERSIONS] + def __hash__(self): + return hash((self.build, self.api_version)) + def __eq__(self, other): if self.api_version != other.api_version: return False diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 9bf2c7ee..24e75e3d 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -687,6 +687,9 @@ def to_text(): dns.resolver.Resolver = _orig del ad.resolver + def test_srv_magic(self): + hash(SrvRecord(priority=1, weight=2, port=3, srv="example.com")) + def test_select_srv_host(self): with self.assertRaises(ValueError): # Empty list diff --git a/tests/test_ewsdatetime.py b/tests/test_ewsdatetime.py index 0a3f4d6f..5976868f 100644 --- a/tests/test_ewsdatetime.py +++ b/tests/test_ewsdatetime.py @@ -135,6 +135,9 @@ def test_ewsdatetime(self): class EWSTimeZoneTest(TimedTestCase): + def test_magic(self): + hash(EWSTimeZone("Europe/Copenhagen")) + def test_ewstimezone(self): # Test autogenerated translations tz = EWSTimeZone("Europe/Copenhagen") diff --git a/tests/test_version.py b/tests/test_version.py index 85b10ded..b6658658 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -8,6 +8,9 @@ class VersionTest(TimedTestCase): + def test_magic(self): + hash(Version(Build(15, 1, 2, 3))) + def test_invalid_version_args(self): with self.assertRaises(TypeError) as e: Version(build="XXX") From 2851c3d37313c8d9934e4bdb1c4cd2e557049440 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 23 May 2023 23:53:36 +0200 Subject: [PATCH 352/509] chore: Improve test coverage --- exchangelib/services/get_user_settings.py | 2 + tests/test_autodiscover.py | 52 ++++++++++++++++++++++- tests/test_protocol.py | 1 + 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/exchangelib/services/get_user_settings.py b/exchangelib/services/get_user_settings.py index e8cee20c..3ffe64ad 100644 --- a/exchangelib/services/get_user_settings.py +++ b/exchangelib/services/get_user_settings.py @@ -77,6 +77,8 @@ def _get_element_container(self, message, name=None): # There are two 'ErrorCode' elements in the response; one is a child of the 'Response' element, the other is a # child of the 'UserResponse' element. Let's handle both with the same code. res = UserResponse.parse_elem(response) + if isinstance(res, Exception): + raise res if res.error_code == "NoError": container = response.find(name) if container is None: diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 24e75e3d..b7f47c1f 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -14,7 +14,13 @@ from exchangelib.autodiscover.protocol import AutodiscoverProtocol from exchangelib.configuration import Configuration from exchangelib.credentials import DELEGATE, Credentials, OAuth2LegacyCredentials -from exchangelib.errors import AutoDiscoverCircularRedirect, AutoDiscoverFailed, ErrorNonExistentMailbox +from exchangelib.errors import ( + AutoDiscoverCircularRedirect, + AutoDiscoverFailed, + ErrorInternalServerError, + ErrorNonExistentMailbox, + TransportError, +) from exchangelib.properties import UserResponse from exchangelib.protocol import FailFast, FaultTolerance from exchangelib.transport import NTLM @@ -240,7 +246,8 @@ def test_autodiscover_with_delegate(self): self.assertEqual(ad_response.autodiscover_smtp_address, self.account.primary_smtp_address) self.assertEqual(protocol.service_endpoint.lower(), self.account.protocol.service_endpoint.lower()) - def test_get_user_settings(self): + @requests_mock.mock(real_http=True) + def test_get_user_settings(self, m): # Create a real Autodiscovery protocol instance ad = Autodiscovery( email=self.account.primary_smtp_address, @@ -256,6 +263,47 @@ def test_get_user_settings(self): self.assertEqual(r.error_code, "InvalidUser") self.assertIn(f"Invalid user '{invalid_email}'", r.error_message) + # Test error response + xml = """\ + + + + + + InvalidSetting + An error message + + + +""".encode() + m.post(p.service_endpoint, status_code=200, content=xml) + with self.assertRaises(TransportError) as e: + p.get_user_settings(user="foo") + self.assertEqual( + e.exception.args[0], + "Unknown ResponseCode in ResponseMessage: InvalidSetting (MessageText: An error message, MessageXml: None)", + ) + xml = """\ + + + + + + InternalServerError + An internal error + + + +""".encode() + m.post(p.service_endpoint, status_code=200, content=xml) + with self.assertRaises(ErrorInternalServerError) as e: + p.get_user_settings(user="foo") + self.assertEqual(e.exception.args[0], "An internal error") + @requests_mock.mock(real_http=False) # Just make sure we don't issue any real HTTP here def test_close_autodiscover_connections(self, m): # Test that we can close TCP connections diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 38e38e46..259ea9e4 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -1058,6 +1058,7 @@ def test_get_service_authtype(self, m): with self.assertRaises(RateLimitError) as e: _ = self.get_test_protocol(auth_type=None, retry_policy=FaultTolerance(max_wait=0.5)).auth_type self.assertEqual(e.exception.args[0], "Max timeout reached") + self.assertEqual(str(e.exception), "Max timeout reached (gave up when asked to back off 10.000 seconds)") @patch("requests.sessions.Session.post", return_value=DummyResponse(status_code=401)) def test_get_service_authtype_401(self, m): From 7e72dfa4b26cedde0c80cd23ddcf42086395a15f Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 24 May 2023 00:10:20 +0200 Subject: [PATCH 353/509] chore: Improve test coverage --- exchangelib/credentials.py | 6 +----- tests/test_credentials.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/exchangelib/credentials.py b/exchangelib/credentials.py index aa2494dc..8b65f3e4 100644 --- a/exchangelib/credentials.py +++ b/exchangelib/credentials.py @@ -141,9 +141,9 @@ def token_url(self): return f"https://login.microsoftonline.com/{self.tenant_id or 'common'}/oauth2/v2.0/token" # nosec @property - @abc.abstractmethod def scope(self): """The scope we ask for the token to have""" + return ["https://outlook.office365.com/.default"] def session_params(self): """Extra parameters to use when creating the session""" @@ -209,10 +209,6 @@ class OAuth2Credentials(BaseOAuth2Credentials): the associated auth code grant type for multi-tenant applications. """ - @property - def scope(self): - return ["https://outlook.office365.com/.default"] - @threaded_cached_property def client(self): return oauthlib.oauth2.BackendApplicationClient(client_id=self.client_id) diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 755e3af0..885c21e7 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -73,8 +73,19 @@ def test_oauth_validation(self): e.exception.args[0], "'access_token' 'XXX' must be of type ", ) + c = OAuth2AuthorizationCodeCredentials(client_id="WWW", client_secret="XXX", authorization_code="YYY") + self.assertListEqual(c.scope, ["https://outlook.office365.com/.default", "offline_access"]) + self.assertDictEqual(c.token_params(), {"include_client_id": True, "code": "YYY"}) + + c = OAuth2LegacyCredentials( + client_id="XXX", client_secret="YYY", tenant_id="ZZZZ", username="AAA", password="BBB" + ) + self.assertListEqual(c.scope, ["https://outlook.office365.com/EWS.AccessAsUser.All"]) + self.assertDictEqual(c.token_params(), {"include_client_id": True, "password": "BBB", "username": "AAA"}) c = OAuth2Credentials("XXX", "YYY", "ZZZZ") + self.assertListEqual(c.scope, ["https://outlook.office365.com/.default"]) + self.assertDictEqual(c.token_params(), {"include_client_id": True}) c.refresh("XXX") # No-op with self.assertRaises(TypeError) as e: From 55e1999c24fdc754f6f1c5e3cc4b065cae97055b Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 24 May 2023 01:07:29 +0200 Subject: [PATCH 354/509] ci: Emergency fix fo test soute --- tests/test_items/test_tasks.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_items/test_tasks.py b/tests/test_items/test_tasks.py index d4ba3720..8cd4636e 100644 --- a/tests/test_items/test_tasks.py +++ b/tests/test_items/test_tasks.py @@ -1,6 +1,7 @@ import datetime from decimal import Decimal +from exchangelib.ewsdatetime import UTC_NOW from exchangelib.folders import Tasks from exchangelib.items import Task from exchangelib.recurrence import DailyPattern, DailyRegeneration, TaskRecurrence @@ -135,7 +136,6 @@ def test_recurring_item(self): # Check fields on the recurring item master_item = self.get_item_by_id((master_item_id, None), folder=self.test_folder) self.assertEqual(master_item.change_count, 2) - # The due date is the next occurrence after today - tz = self.account.default_timezone - self.assertEqual(master_item.due_date, datetime.datetime.now(tz).date() + datetime.timedelta(days=1)) + # The due date is the next occurrence after today. This is apparently calculated in UTC, which is incorrect. + self.assertEqual(master_item.due_date, UTC_NOW().date() + datetime.timedelta(days=1)) self.assertEqual(master_item.recurrence.boundary.number, 3) # One less From 4209eb1ea8536f0280d5b7ace1f0e1d17d0cac2b Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 14 Jun 2023 14:44:39 +0200 Subject: [PATCH 355/509] fix: Make div folder navigation case insensitive. Fixes #1206 --- exchangelib/folders/base.py | 3 ++- tests/test_folder.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 79a9cffe..7adb4766 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -775,7 +775,8 @@ def __truediv__(self, other): if other == ".": return self for c in self.children: - if c.name == other: + # Folders are case-insensitive server-side. Let's do that here as well. + if c.name.lower() == other.lower(): return c raise ErrorFolderNotFound(f"No subfolder with name {other!r}") diff --git a/tests/test_folder.py b/tests/test_folder.py index 8f1c25e8..746c7e0f 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -637,6 +637,16 @@ def test_counts(self): self.assertEqual(f.child_folder_count, 0) f.delete() + def test_case_sensitivity(self): + # Test that the server does not case about folder name case + upper_name = get_random_string(16).upper() + lower_name = upper_name.lower() + self.assertNotEqual(upper_name, lower_name) + Folder(parent=self.account.inbox, name=upper_name).save() + with self.assertRaises(ErrorFolderExists) as e: + Folder(parent=self.account.inbox, name=lower_name).save() + self.assertIn(f"Could not create folder '{lower_name}'", e.exception.args[0]) + def test_update(self): # Test that we can update folder attributes f = Folder(parent=self.account.inbox, name=get_random_string(16)).save() @@ -768,6 +778,10 @@ def test_div_navigation(self): with self.assertRaises(ErrorFolderNotFound): _ = self.account.root / "XXX" + # Test that we are case-insensitive + self.assertNotEqual(self.account.root.tois.name, self.account.root.tois.name.upper()) + self.assertEqual((self.account.root / self.account.root.tois.name.upper()).id, self.account.root.tois.id) + def test_double_div_navigation(self): self.account.root.clear_cache() # Clear the cache @@ -794,6 +808,10 @@ def test_double_div_navigation(self): # Check that this didn't trigger caching self.assertIsNone(self.account.root._subfolders) + # Test that we are case-insensitive + self.assertNotEqual(self.account.root.tois.name, self.account.root.tois.name.upper()) + self.assertEqual((self.account.root // self.account.root.tois.name.upper()).id, self.account.root.tois.id) + def test_extended_properties(self): # Test extended properties on folders and folder roots. This extended prop gets the size (in bytes) of a folder class FolderSize(ExtendedProperty): From fc648d111ac047f9b60bb8b842851777dbbce414 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 20 Jun 2023 08:04:10 +0200 Subject: [PATCH 356/509] fix: Exchange 2019 apparently uses Exchange2016 as version header value. Also, don't throw exceptions in informational property. Fixes #1210 --- exchangelib/version.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/exchangelib/version.py b/exchangelib/version.py index 637c6ecd..cc047626 100644 --- a/exchangelib/version.py +++ b/exchangelib/version.py @@ -127,7 +127,7 @@ def __repr__(self): # The list is sorted from newest to oldest build VERSIONS = ( (EXCHANGE_O365, "Exchange2016", "Microsoft Exchange Server Office365"), # Not mentioned in list of build numbers - (EXCHANGE_2019, "Exchange2019", "Microsoft Exchange Server 2019"), + (EXCHANGE_2019, "Exchange2016", "Microsoft Exchange Server 2019"), (EXCHANGE_2016, "Exchange2016", "Microsoft Exchange Server 2016"), (EXCHANGE_2015_SP1, "Exchange2015_SP1", "Microsoft Exchange Server 2015 SP1"), (EXCHANGE_2015, "Exchange2015", "Microsoft Exchange Server 2015"), @@ -171,7 +171,8 @@ def fullname(self): continue if self.api_version == api_version: return full_name - raise ValueError(f"Full name for API version {self.api_version} build {self.build} is unknown") + log.warning("Full name for API version %s build %s is unknown", self.api_version, self.build) + return "UNKNOWN" @classmethod def guess(cls, protocol, api_version_hint=None): From a858cf64506d8a2ec740f4132e064a3a669ba200 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 22 Jun 2023 10:03:32 +0200 Subject: [PATCH 357/509] doc: Small fixes --- docs/index.md | 3 +-- exchangelib/version.py | 5 +++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/index.md b/docs/index.md index 50393520..c409ed7d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -437,7 +437,7 @@ permission under the `EWS` section: Finally, continue to the `Enterprise applications` page, select your new app, continue to the `Permissions` page, and check that your app has the -`full_access_as_app` permission: +`EWS.AccessAsUser.All` permission: ![API permissions](/exchangelib/assets/img/delegate_app_permissions.png) If not, press `Grant admin consent for testsuite_delegate` and grant access. @@ -472,7 +472,6 @@ config = { "client_id": "MY_CLIENT_ID", "scope": ["EWS.AccessAsUser.All"], "username": "MY_ACCOUNT@example.com", - "endpoint": "https://graph.microsoft.com/v1.0/users", "account": "MY_ACCOUNT@example.com", "server": "outlook.office365.com", } diff --git a/exchangelib/version.py b/exchangelib/version.py index 637c6ecd..badc581d 100644 --- a/exchangelib/version.py +++ b/exchangelib/version.py @@ -127,7 +127,7 @@ def __repr__(self): # The list is sorted from newest to oldest build VERSIONS = ( (EXCHANGE_O365, "Exchange2016", "Microsoft Exchange Server Office365"), # Not mentioned in list of build numbers - (EXCHANGE_2019, "Exchange2019", "Microsoft Exchange Server 2019"), + (EXCHANGE_2019, "Exchange2016", "Microsoft Exchange Server 2019"), (EXCHANGE_2016, "Exchange2016", "Microsoft Exchange Server 2016"), (EXCHANGE_2015_SP1, "Exchange2015_SP1", "Microsoft Exchange Server 2015 SP1"), (EXCHANGE_2015, "Exchange2015", "Microsoft Exchange Server 2015"), @@ -171,7 +171,8 @@ def fullname(self): continue if self.api_version == api_version: return full_name - raise ValueError(f"Full name for API version {self.api_version} build {self.build} is unknown") + log.warning(f"Full name for API version {self.api_version} build {self.build} is unknown") + return "UNKNOWN" @classmethod def guess(cls, protocol, api_version_hint=None): From 2115c62138f517335f4c9a9b54ab3397076940b1 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 22 Jun 2023 10:05:54 +0200 Subject: [PATCH 358/509] docs: Minor fixes --- docs/index.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index 50393520..c409ed7d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -437,7 +437,7 @@ permission under the `EWS` section: Finally, continue to the `Enterprise applications` page, select your new app, continue to the `Permissions` page, and check that your app has the -`full_access_as_app` permission: +`EWS.AccessAsUser.All` permission: ![API permissions](/exchangelib/assets/img/delegate_app_permissions.png) If not, press `Grant admin consent for testsuite_delegate` and grant access. @@ -472,7 +472,6 @@ config = { "client_id": "MY_CLIENT_ID", "scope": ["EWS.AccessAsUser.All"], "username": "MY_ACCOUNT@example.com", - "endpoint": "https://graph.microsoft.com/v1.0/users", "account": "MY_ACCOUNT@example.com", "server": "outlook.office365.com", } From 3273cc0471381e396028c4d6891358b3e3c2cccd Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sat, 22 Jul 2023 17:33:37 +0200 Subject: [PATCH 359/509] chore: Better logic for whether we should delete subfolders --- exchangelib/folders/base.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 7adb4766..ed4c5616 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -435,9 +435,9 @@ def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0): log.warning("Cannot wipe recoverable items folder %s", self) return log.warning("Wiping %s", self) - has_distinguished_subfolders = any(f.is_distinguished for f in self.children) + has_non_deletable_subfolders = any(not f.is_deletable for f in self.children) try: - if has_distinguished_subfolders: + if has_non_deletable_subfolders: self.empty() else: self.empty(delete_sub_folders=True) @@ -446,7 +446,7 @@ def wipe(self, page_size=None, chunk_size=None, _seen=None, _level=0): return except DELETE_FOLDER_ERRORS: try: - if has_distinguished_subfolders: + if has_non_deletable_subfolders: raise # We already tried this self.empty() except DELETE_FOLDER_ERRORS: From 54dde604aee06dbfcd48a843b697a515c0f1dd67 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sat, 22 Jul 2023 17:37:08 +0200 Subject: [PATCH 360/509] chore: better wording --- exchangelib/services/find_folder.py | 2 +- tests/test_folder.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/exchangelib/services/find_folder.py b/exchangelib/services/find_folder.py index d1dfdd69..5f98f7df 100644 --- a/exchangelib/services/find_folder.py +++ b/exchangelib/services/find_folder.py @@ -37,7 +37,7 @@ def call(self, folders, additional_fields, restriction, shape, depth, max_items, raise InvalidEnumValue("depth", depth, FOLDER_TRAVERSAL_CHOICES) roots = {f.root for f in folders} if len(roots) != 1: - raise ValueError(f"All folders in 'roots' must have the same root hierarchy ({roots})") + raise ValueError(f"All folders must have the same root hierarchy ({roots})") self.root = roots.pop() return self._elems_to_objs( self._paged_call( diff --git a/tests/test_folder.py b/tests/test_folder.py index 746c7e0f..9a951304 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -421,7 +421,7 @@ def test_find_folders_multiple_roots(self): # Test failure on different roots with self.assertRaises(ValueError) as e: list(FolderCollection(account=self.account, folders=[Folder(root="A"), Folder(root="B")]).find_folders()) - self.assertIn("All folders in 'roots' must have the same root hierarchy", e.exception.args[0]) + self.assertIn("All folders must have the same root hierarchy", e.exception.args[0]) def test_find_folders_compat(self): account = self.get_account() From 357a0b21b167670f839feee4e83e43f051fde90f Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sat, 22 Jul 2023 17:38:57 +0200 Subject: [PATCH 361/509] chore: Don't need a real EWS ID here --- tests/test_protocol.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 259ea9e4..52d0a42c 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -858,11 +858,14 @@ def test_oof_settings_validation(self): ).clean(version=None) def test_convert_id(self): - i = self.account.root.id + ews_id = ( + "AAMkADIxNDdkMTI4LTMxNTgtNGYzYy1iMGFlLWQxOTIzYTVlNTAwMgAuAAAAAAC" + "QxfuhknwST6zPr7QuwK7BAQDtzmFCgwmzTq/6+pzfxPNiAAAAAAEBAAA=" + ) for fmt in ID_FORMATS: res = list( self.account.protocol.convert_ids( - [AlternateId(id=i, format=EWS_ID, mailbox=self.account.primary_smtp_address)], + [AlternateId(id=ews_id, format=EWS_ID, mailbox=self.account.primary_smtp_address)], destination_format=fmt, ) ) @@ -871,7 +874,8 @@ def test_convert_id(self): # Test bad format with self.assertRaises(ValueError) as e: self.account.protocol.convert_ids( - [AlternateId(id=i, format=EWS_ID, mailbox=self.account.primary_smtp_address)], destination_format="XXX" + [AlternateId(id=ews_id, format=EWS_ID, mailbox=self.account.primary_smtp_address)], + destination_format="XXX", ) self.assertEqual(e.exception.args[0], f"'destination_format' 'XXX' must be one of {sorted(ID_FORMATS)}") # Test bad item type From 9d45d79d5354109ec5baaaa32b629d346fde62fd Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sat, 22 Jul 2023 23:09:52 +0200 Subject: [PATCH 362/509] fix: Replare is_distinguished in favor of _distinguished_id Folder attribute. Allows changing logic in parse_folder_elem which fixes #1202 --- exchangelib/folders/base.py | 38 +++++++++++++-------------- exchangelib/folders/roots.py | 12 ++++----- exchangelib/services/common.py | 29 +++++++++++--------- exchangelib/services/create_folder.py | 7 +++-- tests/test_account.py | 8 ++++-- tests/test_folder.py | 12 +++------ 6 files changed, 56 insertions(+), 50 deletions(-) diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index ed4c5616..ab211c64 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -32,7 +32,6 @@ DistinguishedFolderId, EWSMeta, FolderId, - Mailbox, ParentFolderId, UserConfiguration, UserConfigurationName, @@ -77,6 +76,7 @@ class BaseFolder(RegisterMixIn, SearchableMixIn, SupportedVersionClassMixIn, met ID_ELEMENT_CLS = FolderId _id = IdElementField(field_uri="folder:FolderId", value_cls=ID_ELEMENT_CLS) + _distinguished_id = IdElementField(field_uri="folder:FolderId", value_cls=DistinguishedFolderId) parent_folder_id = EWSElementField(field_uri="folder:ParentFolderId", value_cls=ParentFolderId, is_read_only=True) folder_class = CharField(field_uri="folder:FolderClass", is_required_after_save=True) name = CharField(field_uri="folder:DisplayName") @@ -84,13 +84,12 @@ class BaseFolder(RegisterMixIn, SearchableMixIn, SupportedVersionClassMixIn, met child_folder_count = IntegerField(field_uri="folder:ChildFolderCount", is_read_only=True) unread_count = IntegerField(field_uri="folder:UnreadCount", is_read_only=True) - __slots__ = "is_distinguished", "item_sync_state", "folder_sync_state" + __slots__ = "item_sync_state", "folder_sync_state" # Used to register extended properties INSERT_AFTER_FIELD = "child_folder_count" def __init__(self, **kwargs): - self.is_distinguished = kwargs.pop("is_distinguished", False) self.item_sync_state = kwargs.pop("item_sync_state", None) self.folder_sync_state = kwargs.pop("folder_sync_state", None) super().__init__(**kwargs) @@ -110,6 +109,10 @@ def root(self): def parent(self): """Return the parent folder of this folder""" + @property + def is_distinguished(self): + return self._distinguished_id or (self.DISTINGUISHED_FOLDER_ID and not self._id) + @property def is_deletable(self): return not self.is_distinguished @@ -490,17 +493,16 @@ def _kwargs_from_elem(cls, elem, account): return kwargs def to_id(self): - if self.is_distinguished: - # Don't add the changekey here. When modifying folder content, we usually don't care if others have changed - # the folder content since we fetched the changekey. - if self.account: - return DistinguishedFolderId( - id=self.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address) - ) - return DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID) - if self.id: - return FolderId(id=self.id, changekey=self.changekey) - raise ValueError("Must be a distinguished folder or have an ID") + # Use self._distinguished_id as-is if we have it. This could be a DistinguishedFolderId with a mailbox pointing + # to a shared mailbox. + if self._distinguished_id: + return self._distinguished_id + if self._id: + return self._id + if not self.DISTINGUISHED_FOLDER_ID: + raise ValueError(f"{self} must be a distinguished folder or have an ID") + self._distinguished_id = DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID) + return self._distinguished_id @classmethod def resolve(cls, account, folder): @@ -856,9 +858,7 @@ def get_distinguished(cls, root): :return: """ try: - return cls.resolve( - account=root.account, folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) - ) + return cls.resolve(account=root.account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") @@ -890,7 +890,7 @@ def clean(self, version=None): @classmethod def from_xml_with_root(cls, elem, root): - folder = cls.from_xml(elem=elem, account=root.account) + folder = cls.from_xml(elem=elem, account=root.account if root else None) folder_cls = cls if cls == Folder: # We were called on the generic Folder class. Try to find a more specific class to return objects as. @@ -909,7 +909,7 @@ def from_xml_with_root(cls, elem, root): # # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. - if folder.name: + if folder.name and root: with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative folder_cls = root.folder_cls_from_folder_name( diff --git a/exchangelib/folders/roots.py b/exchangelib/folders/roots.py index 0fbb1d2b..c5dfa074 100644 --- a/exchangelib/folders/roots.py +++ b/exchangelib/folders/roots.py @@ -4,7 +4,7 @@ from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorInvalidOperation from ..fields import EffectiveRightsField -from ..properties import EWSMeta +from ..properties import DistinguishedFolderId, EWSMeta from ..version import EXCHANGE_2007_SP1, EXCHANGE_2010_SP1 from .base import BaseFolder from .collections import FolderCollection @@ -110,9 +110,7 @@ def get_distinguished(cls, account): if not cls.DISTINGUISHED_FOLDER_ID: raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: - return cls.resolve( - account=account, folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) - ) + return cls.resolve(account=account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") @@ -134,9 +132,9 @@ def get_default_folder(self, folder_cls): log.debug("Requesting distinguished %s folder explicitly", folder_cls) return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: - # Maybe we just don't have GetFolder access? Try FindItems instead + # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) - fld = folder_cls(root=self, name=folder_cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + fld = folder_cls(root=self, _distinguished_id=DistinguishedFolderId(id=folder_cls.DISTINGUISHED_FOLDER_ID)) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: @@ -154,7 +152,7 @@ def _folders_map(self): # so we are sure to apply the correct Folder class, then fetch all sub-folders of this root. folders_map = {self.id: self} distinguished_folders = [ - cls(root=self, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID) for cls in self.WELLKNOWN_FOLDERS if cls.get_folder_allowed and cls.supports_version(self.account.version) ] diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 0ac93c88..0cb2977b 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -38,7 +38,7 @@ SOAPError, TransportError, ) -from ..folders import BaseFolder, Folder, RootOfHierarchy +from ..folders import ArchiveRoot, BaseFolder, Folder, PublicFoldersRoot, Root, RootOfHierarchy from ..items import BaseItem from ..properties import ( BaseItemId, @@ -927,10 +927,7 @@ def to_item_id(item, item_cls): # Allow any BaseItemId subclass to pass unaltered return item if isinstance(item, (BaseFolder, BaseItem)): - try: - return item.to_id() - except ValueError: - return item + return item.to_id() if isinstance(item, (str, tuple, list)): return item_cls(*item) return item_cls(item.id, item.changekey) @@ -978,20 +975,28 @@ def parse_folder_elem(elem, folder, account): f = folder.from_xml(elem=elem, account=folder.account) elif isinstance(folder, Folder): f = folder.from_xml_with_root(elem=elem, root=folder.root) + f._distinguished_id = folder._distinguished_id elif isinstance(folder, DistinguishedFolderId): - # We don't know the root, so assume account.root. - for cls in account.root.WELLKNOWN_FOLDERS: + # We don't know the root, and we can't assume account.root because this may be a shared folder belonging to a + # different mailbox. + roots = (Root, ArchiveRoot, PublicFoldersRoot) + for cls in roots + tuple(chain(*(r.WELLKNOWN_FOLDERS for r in roots))): if cls.DISTINGUISHED_FOLDER_ID == folder.id: folder_cls = cls break else: raise ValueError(f"Unknown distinguished folder ID: {folder.id}") - f = folder_cls.from_xml_with_root(elem=elem, root=account.root) + if folder.mailbox and folder.mailbox.email_address != account.primary_smtp_address: + # Distinguished folder points to a different account. Don't attach the wrong account to the returned folder. + external_account = True + else: + external_account = False + if cls in roots: + f = folder_cls.from_xml(elem=elem, account=None if external_account else account) + else: + f = folder_cls.from_xml_with_root(elem=elem, root=None if external_account else account.root) + f._distinguished_id = folder else: # 'folder' is a generic FolderId instance. We don't know the root so assume account.root. f = Folder.from_xml_with_root(elem=elem, root=account.root) - if isinstance(folder, DistinguishedFolderId): - f.is_distinguished = True - elif isinstance(folder, BaseFolder) and folder.is_distinguished: - f.is_distinguished = True return f diff --git a/exchangelib/services/create_folder.py b/exchangelib/services/create_folder.py index 738946f1..a340be74 100644 --- a/exchangelib/services/create_folder.py +++ b/exchangelib/services/create_folder.py @@ -1,6 +1,6 @@ from ..errors import ErrorFolderExists from ..util import MNS, create_element -from .common import EWSAccountService, folder_ids_element, parse_folder_elem +from .common import EWSAccountService, folder_ids_element, parse_folder_elem, set_xml_value class CreateFolder(EWSAccountService): @@ -38,5 +38,8 @@ def get_payload(self, folders, parent_folder): payload.append( folder_ids_element(folders=[parent_folder], version=self.account.version, tag="m:ParentFolderId") ) - payload.append(folder_ids_element(folders=folders, version=self.account.version, tag="m:Folders")) + folder_elems = create_element("m:Folders") + for folder in folders: + set_xml_value(folder_elems, folder, version=self.account.version) + payload.append(folder_elems) return payload diff --git a/tests/test_account.py b/tests/test_account.py index 762c5601..8a3bc401 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -106,6 +106,7 @@ def test_get_default_folder(self): folder = self.account.root.get_default_folder(Calendar) self.assertIsInstance(folder, Calendar) self.assertNotEqual(folder.id, None) + self.assertEqual(folder.to_id().id, Calendar.DISTINGUISHED_FOLDER_ID) self.assertEqual(folder.name.lower(), Calendar.localized_names(self.account.locale)[0]) class MockCalendar1(Calendar): @@ -113,11 +114,14 @@ class MockCalendar1(Calendar): def get_distinguished(cls, root): raise ErrorAccessDenied("foo") - # Test an indirect folder lookup with FindItems + # Test an indirect folder lookup with FindItem, when we're not allowed to do a GetFolder. We don't get the + # folder element back from the server, just test for existence indirectly be asking for items in the folder. + # Therefore, we don't expect ID or name values. folder = self.account.root.get_default_folder(MockCalendar1) self.assertIsInstance(folder, MockCalendar1) self.assertEqual(folder.id, None) - self.assertEqual(folder.name, MockCalendar1.DISTINGUISHED_FOLDER_ID) + self.assertEqual(folder.to_id().id, Calendar.DISTINGUISHED_FOLDER_ID) + self.assertEqual(folder.name, None) class MockCalendar2(Calendar): @classmethod diff --git a/tests/test_folder.py b/tests/test_folder.py index 9a951304..9cb3dcc2 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -164,11 +164,7 @@ def test_public_folders_root(self, m): # Test account does not have a public folders root. Make a dummy query just to hit .get_children() with suppress(ErrorNoPublicFolderReplicaAvailable): self.assertGreaterEqual( - len( - list( - PublicFoldersRoot(account=self.account, is_distinguished=True).get_children(self.account.inbox) - ) - ), + len(list(PublicFoldersRoot(account=self.account).get_children(self.account.inbox))), 0, ) # Test public folders root with mocked responses @@ -962,8 +958,8 @@ def test_generic_folder(self): self.assertEqual(Folder().has_distinguished_name, None) self.assertEqual(Inbox(name="XXX").has_distinguished_name, False) self.assertEqual(Inbox(name="Inbox").has_distinguished_name, True) - self.assertEqual(Inbox(is_distinguished=False).is_deletable, True) - self.assertEqual(Inbox(is_distinguished=True).is_deletable, False) + self.assertEqual(Folder().is_deletable, True) + self.assertEqual(Inbox().is_deletable, False) def test_non_deletable_folders(self): for f in self.account.root.walk(): @@ -1245,7 +1241,7 @@ def test_permissionset_effectiverights_parsing(self): def test_get_candidate(self): # _get_candidate is a private method, but it's really difficult to recreate a situation where it's used. - f1 = Inbox(name="XXX", is_distinguished=True) + f1 = Inbox(name="XXX") f2 = Inbox(name=Inbox.LOCALIZED_NAMES[self.account.locale][0]) with self.assertRaises(ErrorFolderNotFound) as e: self.account.root._get_candidate(folder_cls=Inbox, folder_coll=[]) From fcc629660189ac13bcb0728aee4ba4c31cb2cbae Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sat, 22 Jul 2023 23:25:12 +0200 Subject: [PATCH 363/509] chore: fix CI pipeline after unittest-parallel version bump --- .github/workflows/python-package.yml | 2 +- docs/index.md | 2 +- test-requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 79138807..0a8a421a 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -80,7 +80,7 @@ jobs: black --check --diff exchangelib tests scripts setup.py isort --check --diff exchangelib tests scripts setup.py flake8 exchangelib tests scripts setup.py - unittest-parallel -j 4 --class-fixtures --coverage --coverage-source exchangelib + unittest-parallel -j 4 --level=class --coverage --coverage-source exchangelib coveralls --service=github cleanup: diff --git a/docs/index.md b/docs/index.md index c409ed7d..9282854a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2132,5 +2132,5 @@ python -m unittest -k test_folder.FolderTest.test_refresh DEBUG=1 python -m unittest -k test_folder.FolderTest.test_refresh # Running tests in parallel using the 'unittest-parallel' dependency -unittest-parallel -j 4 --class-fixtures +unittest-parallel -j 4 --level=class ``` diff --git a/test-requirements.txt b/test-requirements.txt index e51b7bdd..8a45ddb0 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -8,4 +8,4 @@ python-dateutil pytz PyYAML requests_mock -unittest-parallel>=1.3.0 +unittest-parallel>=1.6.0 From 254feb398b2ea730f4227899f75ffeda2a8419f3 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sat, 22 Jul 2023 23:51:12 +0200 Subject: [PATCH 364/509] docs: Add blackening of code snippets in markdown --- .github/workflows/python-package.yml | 1 + .pre-commit-config.yaml | 6 ++++++ test-requirements.txt | 1 + 3 files changed, 8 insertions(+) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 0a8a421a..32dd95e0 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -80,6 +80,7 @@ jobs: black --check --diff exchangelib tests scripts setup.py isort --check --diff exchangelib tests scripts setup.py flake8 exchangelib tests scripts setup.py + blacken-docs *.md docs/*.md unittest-parallel -j 4 --level=class --coverage --coverage-source exchangelib coveralls --service=github diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d887f13f..17ba68cd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,3 +34,9 @@ repos: entry: flake8 types: [ python ] language: system + - id: blacken-docs + name: blacken-docs + stages: [ commit ] + entry: blacken-docs + types: [ markdown ] + language: system diff --git a/test-requirements.txt b/test-requirements.txt index 8a45ddb0..a88c8036 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,4 +1,5 @@ black +blacken-docs coverage coveralls flake8 From 17a846dd9bfb7d6d4cc4db62ea874d4b03798e07 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sat, 22 Jul 2023 23:56:24 +0200 Subject: [PATCH 365/509] docs: Blacken code snippets --- CHANGELOG.md | 82 +++--- README.md | 6 +- docs/index.md | 703 +++++++++++++++++++++++++++----------------------- 3 files changed, 433 insertions(+), 358 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97a83046..99a3121f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -138,10 +138,13 @@ from exchangelib.folders import Calendar, SingleFolderQuerySet from exchangelib.properties import DistinguishedFolderId, Mailbox account = Account(primary_smtp_address="some_user@example.com", ...) -shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFolderId( - id=Calendar.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address="other_user@example.com") -)).resolve() +shared_calendar = SingleFolderQuerySet( + account=account, + folder=DistinguishedFolderId( + id=Calendar.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address="other_user@example.com"), + ), +).resolve() ``` - Minor bugfixes @@ -358,17 +361,18 @@ shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFold from exchangelib import CalendarItem, Message, Contact, Task from exchangelib.extended_properties import ExternId - CalendarItem.register('extern_id', ExternId) - Message.register('extern_id', ExternId) - Contact.register('extern_id', ExternId) - Task.register('extern_id', ExternId) + CalendarItem.register("extern_id", ExternId) + Message.register("extern_id", ExternId) + Contact.register("extern_id", ExternId) + Task.register("extern_id", ExternId) ``` - The `ServiceAccount` class has been removed. If you want fault tolerance, set it in a `Configuration` object: ```python from exchangelib import Configuration, Credentials, FaultTolerance - c = Credentials('foo', 'bar') + + c = Credentials("foo", "bar") config = Configuration(credentials=c, retry_policy=FaultTolerance()) ``` - It is now possible to use Kerberos and SSPI auth without providing a dummy @@ -536,6 +540,7 @@ shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFold ```python from exchangelib.protocol import BaseProtocol, NoVerifyHTTPAdapter + BaseProtocol.HTTP_ADAPTER_CLS = NoVerifyHTTPAdapter ``` @@ -649,10 +654,10 @@ shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFold - Allow setting `Mailbox` and `Attendee`-type attributes as plain strings, e.g.: ```python - calendar_item.organizer = 'anne@example.com' - calendar_item.required_attendees = ['john@example.com', 'bill@example.com'] + calendar_item.organizer = "anne@example.com" + calendar_item.required_attendees = ["john@example.com", "bill@example.com"] - message.to_recipients = ['john@example.com', 'anne@example.com'] + message.to_recipients = ["john@example.com", "anne@example.com"] ``` 1.7.6 @@ -684,23 +689,25 @@ shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFold # Process attachments on existing items for item in my_folder.all(): for attachment in item.attachments: - local_path = os.path.join('/tmp', attachment.name) - with open(local_path, 'wb') as f: + local_path = os.path.join("/tmp", attachment.name) + with open(local_path, "wb") as f: f.write(attachment.content) - print('Saved attachment to', local_path) + print("Saved attachment to", local_path) # Create a new item with an attachment item = Message(...) - binary_file_content = 'Hello from unicode æøå'.encode('utf-8') # Or read from file, BytesIO etc. - my_file = FileAttachment(name='my_file.txt', content=binary_file_content) + binary_file_content = "Hello from unicode æøå".encode( + "utf-8" + ) # Or read from file, BytesIO etc. + my_file = FileAttachment(name="my_file.txt", content=binary_file_content) item.attach(my_file) my_calendar_item = CalendarItem(...) - my_appointment = ItemAttachment(name='my_appointment', item=my_calendar_item) + my_appointment = ItemAttachment(name="my_appointment", item=my_calendar_item) item.attach(my_appointment) item.save() # Add an attachment on an existing item - my_other_file = FileAttachment(name='my_other_file.txt', content=binary_file_content) + my_other_file = FileAttachment(name="my_other_file.txt", content=binary_file_content) item.attach(my_other_file) # Remove the attachment again @@ -733,14 +740,15 @@ shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFold ```python class LunchMenu(ExtendedProperty): - property_id = '12345678-1234-1234-1234-123456781234' - property_name = 'Catering from the cafeteria' - property_type = 'String' + property_id = "12345678-1234-1234-1234-123456781234" + property_name = "Catering from the cafeteria" + property_type = "String" - CalendarItem.register('lunch_menu', LunchMenu) - item = CalendarItem(..., lunch_menu='Foie gras et consommé de légumes') + + CalendarItem.register("lunch_menu", LunchMenu) + item = CalendarItem(..., lunch_menu="Foie gras et consommé de légumes") item.save() - CalendarItem.deregister('lunch_menu') + CalendarItem.deregister("lunch_menu") ``` - Fixed a bug on folder items where an existing HTML body would be converted to text when calling `save()`. When @@ -750,11 +758,11 @@ shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFold ```python item = CalendarItem(...) # Plain-text body - item.body = Body('Hello UNIX-beard pine user!') + item.body = Body("Hello UNIX-beard pine user!") # Also plain-text body, works as before - item.body = 'Hello UNIX-beard pine user!' + item.body = "Hello UNIX-beard pine user!" # Exchange will see this as an HTML body and display nicely in clients - item.body = HTMLBody('Hello happy OWA user!') + item.body = HTMLBody("Hello happy OWA user!") item.save() ``` @@ -778,14 +786,14 @@ shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFold ```python items = [] for i in range(4): - item = Message(subject='Test %s' % i) + item = Message(subject="Test %s" % i) items.append(item) account.sent.bulk_create(items=items) item_changes = [] for i, item in enumerate(items): - item.subject = 'Changed subject' % i - item_changes.append(item, ['subject']) + item.subject = "Changed subject" % i + item_changes.append(item, ["subject"]) account.bulk_update(items=item_changes) ``` @@ -833,11 +841,13 @@ shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFold ```python ids = account.calendar.find_items( - "start < '2016-01-02T03:04:05T' and end > '2016-01-01T03:04:05T' and categories in ('foo', 'bar')", - shape=IdOnly + "start < '2016-01-02T03:04:05T' and end > '2016-01-01T03:04:05T' and categories in ('foo', 'bar')", + shape=IdOnly, ) - q1, q2 = (Q(subject__iexact='foo') | Q(subject__contains='bar')), ~Q(subject__startswith='baz') + q1, q2 = (Q(subject__iexact="foo") | Q(subject__contains="bar")), ~Q( + subject__startswith="baz" + ) ids = account.calendar.find_items(q1, q2, shape=IdOnly) ``` @@ -859,7 +869,7 @@ shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFold ids = account.calendar.find_items( start=tz.localize(EWSDateTime(year, month, day)), end=tz.localize(EWSDateTime(year, month, day + 1)), - categories=['foo', 'bar'], + categories=["foo", "bar"], ) ``` @@ -869,7 +879,7 @@ shared_calendar = SingleFolderQuerySet(account=account, folder=DistinguishedFold ids = account.calendar.find_items( start__lt=tz.localize(EWSDateTime(year, month, day + 1)), end__gt=tz.localize(EWSDateTime(year, month, day)), - categories__contains=['foo', 'bar'], + categories__contains=["foo", "bar"], ) ``` diff --git a/README.md b/README.md index 913603e5..23f00dfb 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,10 @@ Here's a short example of how `exchangelib` works. Let's print the first ```python from exchangelib import Credentials, Account -credentials = Credentials('john@example.com', 'topsecret') -account = Account('john@example.com', credentials=credentials, autodiscover=True) +credentials = Credentials("john@example.com", "topsecret") +account = Account("john@example.com", credentials=credentials, autodiscover=True) -for item in account.inbox.all().order_by('-datetime_received')[:100]: +for item in account.inbox.all().order_by("-datetime_received")[:100]: print(item.subject, item.sender, item.datetime_received) ``` diff --git a/docs/index.md b/docs/index.md index 9282854a..1637149e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -122,9 +122,9 @@ supported, if your server expects that. ```python from exchangelib import Credentials -credentials = Credentials(username='MYWINDOMAIN\\myuser', password='topsecret') +credentials = Credentials(username="MYWINDOMAIN\\myuser", password="topsecret") # For Office365 -credentials = Credentials(username='myuser@example.com', password='topsecret') +credentials = Credentials(username="myuser@example.com", password="topsecret") ``` If you're running long-running jobs, you may want to enable fault-tolerance. Fault-tolerance means that requests to the server do an exponential backoff @@ -148,20 +148,28 @@ you enable autodiscover, an alias address will work, too. In this case, from exchangelib import DELEGATE, IMPERSONATION, Account my_account = Account( - primary_smtp_address='myusername@example.com', credentials=credentials, - autodiscover=True, access_type=DELEGATE + primary_smtp_address="myusername@example.com", + credentials=credentials, + autodiscover=True, + access_type=DELEGATE, ) johns_account = Account( - primary_smtp_address='john@example.com', credentials=credentials, - autodiscover=True, access_type=DELEGATE + primary_smtp_address="john@example.com", + credentials=credentials, + autodiscover=True, + access_type=DELEGATE, ) marys_account = Account( - primary_smtp_address='mary@example.com', credentials=credentials, - autodiscover=True, access_type=DELEGATE + primary_smtp_address="mary@example.com", + credentials=credentials, + autodiscover=True, + access_type=DELEGATE, ) still_marys_account = Account( - primary_smtp_address='alias_for_mary@example.com', credentials=credentials, - autodiscover=True, access_type=DELEGATE + primary_smtp_address="alias_for_mary@example.com", + credentials=credentials, + autodiscover=True, + access_type=DELEGATE, ) # Full autodiscover data is available on the Account object: @@ -169,15 +177,19 @@ my_account.ad_response # Set up a target account and do an autodiscover lookup to find the EWS endpoint account = Account( - primary_smtp_address='john@example.com', credentials=credentials, - autodiscover=True, access_type=DELEGATE + primary_smtp_address="john@example.com", + credentials=credentials, + autodiscover=True, + access_type=DELEGATE, ) # If your credentials have been given impersonation access to the target # account, set a different 'access_type': johns_account = Account( - primary_smtp_address='john@example.com', credentials=credentials, - autodiscover=True, access_type=IMPERSONATION + primary_smtp_address="john@example.com", + credentials=credentials, + autodiscover=True, + access_type=IMPERSONATION, ) ``` @@ -188,10 +200,13 @@ folder to access the folder: from exchangelib.folders import Calendar, SingleFolderQuerySet from exchangelib.properties import DistinguishedFolderId, Mailbox -shared_calendar = SingleFolderQuerySet(account=johns_account, folder=DistinguishedFolderId( - id=Calendar.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address='mary@example.com') -)).resolve() +shared_calendar = SingleFolderQuerySet( + account=johns_account, + folder=DistinguishedFolderId( + id=Calendar.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address="mary@example.com"), + ), +).resolve() ``` Autodiscover needs to make some DNS queries. We use the dnspython package for @@ -201,7 +216,7 @@ object is created: ```python from exchangelib.autodiscover import Autodiscovery -Autodiscovery.DNS_RESOLVER_ATTRS['edns'] = False # Disable EDNS queries +Autodiscovery.DNS_RESOLVER_ATTRS["edns"] = False # Disable EDNS queries ``` @@ -214,8 +229,8 @@ to fetch them by some other means, e.g. via AD lookup: ```python account = Account(...) -account.identity.sid = 'S-my-sid' -account.identity.upn = 'john@subdomain.example.com' +account.identity.sid = "S-my-sid" +account.identity.upn = "john@subdomain.example.com" ``` If the server doesn't support autodiscover, or you want to avoid the overhead @@ -224,16 +239,20 @@ of autodiscover, use a Configuration object to set the hostname instead: from exchangelib import Configuration, Credentials credentials = Credentials(...) -config = Configuration(server='mail.example.com', credentials=credentials) +config = Configuration(server="mail.example.com", credentials=credentials) ``` For accounts that are known to be hosted on Office365, there's no need to use autodiscover. Here's the server to use for Office365: ```python -config = Configuration(server='outlook.office365.com', credentials=credentials) -account = Account(primary_smtp_address='john@example.com', config=config, - autodiscover=False, access_type=DELEGATE) +config = Configuration(server="outlook.office365.com", credentials=credentials) +account = Account( + primary_smtp_address="john@example.com", + config=config, + autodiscover=False, + access_type=DELEGATE, +) ``` We will attempt to guess the server version and authentication method @@ -246,8 +265,7 @@ from exchangelib import Build, NTLM version = Version(build=Build(15, 0, 12, 34)) config = Configuration( - server='example.com', credentials=credentials, version=version, - auth_type=NTLM + server="example.com", credentials=credentials, version=version, auth_type=NTLM ) ``` @@ -258,7 +276,7 @@ place for the connecting credentials, so make sure to agree with your Exchange admins before increasing this value. ```python -config = Configuration(server='mail.example.com', max_connections=10) +config = Configuration(server="mail.example.com", max_connections=10) ``` ### Fault tolerance @@ -272,9 +290,9 @@ from exchangelib import Account, FaultTolerance, Configuration, Credentials credentials = Credentials(...) config = Configuration( - retry_policy=FaultTolerance(max_wait=3600), credentials=credentials + retry_policy=FaultTolerance(max_wait=3600), credentials=credentials ) -account = Account(primary_smtp_address='john@example.com', config=config) +account = Account(primary_smtp_address="john@example.com", config=config) ``` Autodiscovery will also use this policy, but only for the final autodiscover @@ -302,7 +320,7 @@ config = Configuration(auth_type=SSPI) ```python from exchangelib import Configuration, BaseProtocol, CBA, TLSClientAuth -TLSClientAuth.cert_file = '/path/to/client.pem' +TLSClientAuth.cert_file = "/path/to/client.pem" BaseProtocol.HTTP_ADAPTER_CLS = TLSClientAuth config = Configuration(auth_type=CBA) ``` @@ -316,7 +334,7 @@ Use OAuth2AuthorizationCodeCredentials instead for the authorization code flow from exchangelib import OAuth2Credentials credentials = OAuth2Credentials( - client_id='MY_ID', client_secret='MY_SECRET', tenant_id='TENANT_ID' + client_id="MY_ID", client_secret="MY_SECRET", tenant_id="TENANT_ID" ) ``` @@ -327,11 +345,14 @@ delegated permissions, use the OAuth2LegacyCredentials class instead: from exchangelib import OAuth2LegacyCredentials credentials = OAuth2LegacyCredentials( - client_id='MY_ID', client_secret='MY_SECRET', tenant_id='TENANT_ID', - username='myuser@example.com', password='topsecret' + client_id="MY_ID", + client_secret="MY_SECRET", + tenant_id="TENANT_ID", + username="myuser@example.com", + password="topsecret", ) config = Configuration(credentials=credentials, ...) -account = Account('myuser@example.com', config=config, access_type=DELEGATE) +account = Account("myuser@example.com", config=config, access_type=DELEGATE) ``` The OAuth2 flow may need to have impersonation headers set. If you get @@ -339,23 +360,29 @@ impersonation errors, add information about the account that the OAuth2 credentials was created for: ```python -from exchangelib import Configuration, OAuth2Credentials, \ - OAuth2AuthorizationCodeCredentials, Identity, OAUTH2 +from exchangelib import ( + Configuration, + OAuth2Credentials, + OAuth2AuthorizationCodeCredentials, + Identity, + OAUTH2, +) from oauthlib.oauth2 import OAuth2Token credentials = OAuth2Credentials( - ..., identity=Identity(primary_smtp_address='svc_acct@example.com') + ..., identity=Identity(primary_smtp_address="svc_acct@example.com") ) credentials = OAuth2AuthorizationCodeCredentials( - ..., identity=Identity(upn='svc_acct@subdomain.example.com') + ..., identity=Identity(upn="svc_acct@subdomain.example.com") ) credentials = OAuth2AuthorizationCodeCredentials( - client_id='MY_ID', client_secret='MY_SECRET', authorization_code='AUTH_CODE' + client_id="MY_ID", client_secret="MY_SECRET", authorization_code="AUTH_CODE" ) credentials = OAuth2AuthorizationCodeCredentials( - client_id='MY_ID', client_secret='MY_SECRET', - access_token=OAuth2Token(access_token='EXISTING_TOKEN') + client_id="MY_ID", + client_secret="MY_SECRET", + access_token=OAuth2Token(access_token="EXISTING_TOKEN"), ) config = Configuration(credentials=credentials, auth_type=OAUTH2) ``` @@ -464,7 +491,13 @@ using the following code: ```python # Script adapted from https://github.com/AzureAD/microsoft-authentication-library-for-python/blob/dev/sample/interactive_sample.py -from exchangelib import Configuration, OAUTH2, Account, DELEGATE, OAuth2AuthorizationCodeCredentials +from exchangelib import ( + Configuration, + OAUTH2, + Account, + DELEGATE, + OAuth2AuthorizationCodeCredentials, +) import msal config = { @@ -477,7 +510,9 @@ config = { } app = msal.PublicClientApplication(config["client_id"], authority=config["authority"]) print("A local browser window will be open for you to sign in. CTRL+C to cancel.") -result = app.acquire_token_interactive(config["scope"], login_hint=config.get("username")) +result = app.acquire_token_interactive( + config["scope"], login_hint=config.get("username") +) assert "access_token" in result creds = OAuth2AuthorizationCodeCredentials(access_token=result) @@ -510,12 +545,15 @@ version = account.version # You can now create the Account without autodiscovery, using the cached values: credentials = Credentials(...) config = Configuration( - service_endpoint=ews_url, credentials=credentials, auth_type=ews_auth_type, - version=version + service_endpoint=ews_url, + credentials=credentials, + auth_type=ews_auth_type, + version=version, ) account = Account( primary_smtp_address=primary_smtp_address, - config=config, autodiscover=False, + config=config, + autodiscover=False, access_type=DELEGATE, ) ``` @@ -531,6 +569,7 @@ an email in that domain. It's possible to clear the entire cache completely if you want: ```python from exchangelib.autodiscover import clear_cache + clear_cache() ``` @@ -548,17 +587,20 @@ from urllib.parse import urlparse import requests.adapters from exchangelib.protocol import BaseProtocol + class RootCAAdapter(requests.adapters.HTTPAdapter): """An HTTP adapter that uses a custom root CA certificate at a hard coded location. """ + def cert_verify(self, conn, url, verify, cert): cert_file = { - 'example.com': '/path/to/example.com.crt', - 'mail.internal': '/path/to/mail.internal.crt', + "example.com": "/path/to/example.com.crt", + "mail.internal": "/path/to/mail.internal.crt", }[urlparse(url).hostname] super().cert_verify(conn=conn, url=url, verify=cert_file, cert=cert) + # Use this adapter class instead of the default BaseProtocol.HTTP_ADAPTER_CLS = RootCAAdapter ``` @@ -569,14 +611,16 @@ Here's an example of adding proxy support: import requests.adapters from exchangelib.protocol import BaseProtocol + class ProxyAdapter(requests.adapters.HTTPAdapter): def send(self, *args, **kwargs): - kwargs['proxies'] = { - 'http': 'http://10.0.0.1:1243', - 'https': 'http://10.0.0.1:4321', + kwargs["proxies"] = { + "http": "http://10.0.0.1:1243", + "https": "http://10.0.0.1:4321", } return super().send(*args, **kwargs) + # Use this adapter class instead of the default BaseProtocol.HTTP_ADAPTER_CLS = ProxyAdapter ``` @@ -626,7 +670,7 @@ a.root.refresh() a.public_folders_root.refresh() a.archive_root.refresh() -some_folder = a.root / 'Some Folder' +some_folder = a.root / "Some Folder" some_folder.parent some_folder.parent.parent.parent # Returns the root of the folder structure, at any level. Same as Account.root @@ -636,10 +680,10 @@ some_folder.absolute # Returns the absolute path, as a string # A generator returning all subfolders at arbitrary depth this level some_folder.walk() # Globbing uses the normal UNIX globbing syntax, but case-insensitive -some_folder.glob('foo*') # Return child folders matching the pattern -some_folder.glob('*/foo') # Return subfolders named 'foo' in any child folder -some_folder.glob('**/foo') # Return subfolders named 'foo' at any depth -some_folder / 'sub_folder' / 'even_deeper' / 'leaf' # Works like pathlib.Path +some_folder.glob("foo*") # Return child folders matching the pattern +some_folder.glob("*/foo") # Return subfolders named 'foo' in any child folder +some_folder.glob("**/foo") # Return subfolders named 'foo' at any depth +some_folder / "sub_folder" / "even_deeper" / "leaf" # Works like pathlib.Path ``` You can also drill down into the folder structure without using the cache. @@ -648,7 +692,7 @@ cache the folder hierarchy. This is useful if your account contains a huge number of folders, and you already know where to go. ```python -some_folder // 'sub_folder' // 'even_deeper' // 'leaf' +some_folder // "sub_folder" // "even_deeper" // "leaf" some_folder.parts # returns some_folder and all parents, as Folder instances some_folder.absolute # Returns the full path as a string ``` @@ -656,7 +700,7 @@ some_folder.absolute # Returns the full path as a string tree() returns a string representation of the tree structure at a given level ```python print(a.root.tree()) -''' +""" root ├── inbox │ └── todos @@ -664,7 +708,7 @@ root ├── Last Job ├── GitLab issues └── Mom -''' +""" ``` Folders have some useful counters: @@ -682,10 +726,10 @@ Folders can be created, updated and deleted: ```python from exchangelib import Folder -f = Folder(parent=a.inbox, name='My New Folder') +f = Folder(parent=a.inbox, name="My New Folder") f.save() -f.name = 'My New Subfolder' +f.name = "My New Subfolder" f.save() f.delete() @@ -707,25 +751,25 @@ datetime, EWSDateTime, and the 'Byte' type which we emulate in Python as a ```python f.create_user_configuration( - name='SomeName', - dictionary={'foo': 'bar', 123: 'a', 'b': False}, - xml_data=b'bar', - binary_data=b'XXX', + name="SomeName", + dictionary={"foo": "bar", 123: "a", "b": False}, + xml_data=b"bar", + binary_data=b"XXX", ) -config = f.get_user_configuration(name='SomeName') +config = f.get_user_configuration(name="SomeName") config.dictionary # {'foo': 'bar', 123: 'a', 'b': False} config.xml_data # b'bar' config.binary_data # b'XXX' f.update_user_configuration( - name='SomeName', - dictionary={'bar': 'foo', 456: 'a', 'b': True}, - xml_data=b'baz', - binary_data=b'YYY', + name="SomeName", + dictionary={"bar": "foo", 456: "a", "b": True}, + xml_data=b"baz", + binary_data=b"YYY", ) -f.delete_user_configuration(name='SomeName') +f.delete_user_configuration(name="SomeName") ``` ## Dates, datetimes and timezones @@ -740,6 +784,7 @@ so you should be able to use them as regular date objects. from datetime import datetime, timedelta import dateutil.tz import pytz + try: import zoneinfo except ImportError: @@ -747,7 +792,7 @@ except ImportError: from exchangelib import EWSTimeZone, EWSDateTime, EWSDate, UTC, UTC_NOW # EWSTimeZone works just like zoneinfo.ZoneInfo() -tz = EWSTimeZone('Europe/Copenhagen') +tz = EWSTimeZone("Europe/Copenhagen") # You can also get the local timezone defined in your operating system tz = EWSTimeZone.localzone() @@ -773,11 +818,11 @@ right_now_in_utc = EWSDateTime.now(tz=UTC) right_now_in_utc = UTC_NOW() # 'pytz', 'dateutil' and `zoneinfo` timezones can be converted to EWSTimeZone -pytz_tz = pytz.timezone('Europe/Copenhagen') +pytz_tz = pytz.timezone("Europe/Copenhagen") tz = EWSTimeZone.from_timezone(pytz_tz) -dateutil_tz = dateutil.tz.gettz('Europe/Copenhagen') +dateutil_tz = dateutil.tz.gettz("Europe/Copenhagen") tz = EWSTimeZone.from_timezone(dateutil_tz) -zoneinfo_tz = zoneinfo.ZoneInfo('Europe/Copenhagen') +zoneinfo_tz = zoneinfo.ZoneInfo("Europe/Copenhagen") tz = EWSTimeZone.from_timezone(zoneinfo_tz) # Python datetime objects can be converted using from_datetime(). Make sure @@ -796,15 +841,15 @@ from exchangelib.items import SEND_ONLY_TO_ALL, SEND_ONLY_TO_CHANGED from exchangelib.properties import DistinguishedFolderId a = Account(...) -item = CalendarItem(folder=a.calendar, subject='foo') +item = CalendarItem(folder=a.calendar, subject="foo") item.save() # This gives the item an 'id' and a 'changekey' value # Send a meeting invitation to attendees item.save(send_meeting_invitations=SEND_ONLY_TO_ALL) # Update a field. All fields have a corresponding Python type that must be used. -item.subject = 'bar' +item.subject = "bar" item.save() # When the items has an item_id, this will update the item # Only updates certain fields. Accepts a list of field names. -item.save(update_fields=['subject']) +item.save(update_fields=["subject"]) # Send invites only to attendee changes item.save(send_meeting_invitations=SEND_ONLY_TO_CHANGED) item.delete() # Hard deletion @@ -815,7 +860,7 @@ item.move_to_trash() # Move to the trash folder item.move(a.trash) # Also moves the item to the trash folder item.copy(a.trash) # Creates a copy of the item to the trash folder # Archives the item to inbox of the archive mailbox -item.archive(DistinguishedFolderId('inbox')) +item.archive(DistinguishedFolderId("inbox")) # Block sender and move item to junk folder item.mark_as_junk(is_junk=True, move_item=True) ``` @@ -835,17 +880,17 @@ from exchangelib import Message, Mailbox m = Message( account=a, - subject='Daily motivation', - body='All bodies are beautiful', + subject="Daily motivation", + body="All bodies are beautiful", to_recipients=[ - Mailbox(email_address='anne@example.com'), - Mailbox(email_address='bob@example.com'), + Mailbox(email_address="anne@example.com"), + Mailbox(email_address="bob@example.com"), ], # Simple strings work, too - cc_recipients=['carl@example.com', 'denice@example.com'], + cc_recipients=["carl@example.com", "denice@example.com"], bcc_recipients=[ - Mailbox(email_address='erik@example.com'), - 'felicity@example.com', + Mailbox(email_address="erik@example.com"), + "felicity@example.com", ], # Or a mix of both ) m.send() @@ -857,9 +902,9 @@ Or, if you want a copy in e.g. the 'Sent' folder m = Message( account=a, folder=a.sent, - subject='Daily motivation', - body='All bodies are beautiful', - to_recipients=[Mailbox(email_address='anne@example.com')] + subject="Daily motivation", + body="All bodies are beautiful", + to_recipients=[Mailbox(email_address="anne@example.com")], ) m.send_and_save() ``` @@ -868,17 +913,17 @@ Likewise, you can reply to and forward messages that are stored in your mailbox (i.e. they have an item ID). ```python -m = a.sent.get(subject='Daily motivation') +m = a.sent.get(subject="Daily motivation") m.reply( - subject='Re: Daily motivation', - body='I agree', - to_recipients=['carl@example.com', 'denice@example.com'] + subject="Re: Daily motivation", + body="I agree", + to_recipients=["carl@example.com", "denice@example.com"], ) -m.reply_all(subject='Re: Daily motivation', body='I agree') +m.reply_all(subject="Re: Daily motivation", body="I agree") m.forward( - subject='Fwd: Daily motivation', - body='Hey, look at this!', - to_recipients=['carl@example.com', 'denice@example.com'] + subject="Fwd: Daily motivation", + body="Hey, look at this!", + to_recipients=["carl@example.com", "denice@example.com"], ) ``` @@ -888,14 +933,16 @@ You can also edit a draft of a reply or forward from exchangelib import FileAttachment save_result = m.create_forward( - subject='Fwd: Daily motivation', - body='Hey, look at this!', - to_recipients=['erik@cederstrand.dk'] -).save(a.drafts) # gives you back a BulkCreateResult containing the ID and changekey + subject="Fwd: Daily motivation", + body="Hey, look at this!", + to_recipients=["erik@cederstrand.dk"], +).save( + a.drafts +) # gives you back a BulkCreateResult containing the ID and changekey forward_draft = a.drafts.get(id=save_result.id, changekey=save_result.changekey) -forward_draft.reply_to = ['erik@example.com'] -forward_draft.attach(FileAttachment( - name='my_file.txt', content='hello world'.encode('utf-8')) +forward_draft.reply_to = ["erik@example.com"] +forward_draft.attach( + FileAttachment(name="my_file.txt", content="hello world".encode("utf-8")) ) # Now our forward has an extra reply_to field and an extra attachment. forward_draft.send() @@ -907,9 +954,7 @@ and display the body correctly: ```python from exchangelib import HTMLBody -item.body = HTMLBody( - 'Hello happy OWA user!' -) +item.body = HTMLBody("Hello happy OWA user!") ``` ## Bulk operations @@ -919,6 +964,7 @@ an example of building a list of calendar items: ```python import datetime + try: import zoneinfo except ImportError: @@ -927,22 +973,26 @@ from exchangelib import Account, CalendarItem, Attendee, Mailbox from exchangelib.properties import DistinguishedFolderId a = Account(...) -tz = zoneinfo.ZoneInfo('Europe/Copenhagen') +tz = zoneinfo.ZoneInfo("Europe/Copenhagen") year, month, day = 2016, 3, 20 calendar_items = [] for hour in range(7, 17): - calendar_items.append(CalendarItem( - start=datetime.datetime(year, month, day, hour, 30, tzinfo=tz), - end=datetime.datetime(year, month, day, hour + 1, 15, tzinfo=tz), - subject='Test item', - body='Hello from Python', - location='devnull', - categories=['foo', 'bar'], - required_attendees = [Attendee( - mailbox=Mailbox(email_address='user1@example.com'), - response_type='Accept' - )] - )) + calendar_items.append( + CalendarItem( + start=datetime.datetime(year, month, day, hour, 30, tzinfo=tz), + end=datetime.datetime(year, month, day, hour + 1, 15, tzinfo=tz), + subject="Test item", + body="Hello from Python", + location="devnull", + categories=["foo", "bar"], + required_attendees=[ + Attendee( + mailbox=Mailbox(email_address="user1@example.com"), + response_type="Accept", + ) + ], + ) + ) # Create all items at once return_ids = a.bulk_create(folder=a.calendar, items=calendar_items) @@ -952,19 +1002,17 @@ return_ids = a.bulk_create(folder=a.calendar, items=calendar_items) calendar_ids = [(i.id, i.changekey) for i in calendar_items] items_iter = a.fetch(ids=calendar_ids) # If you only want some fields, use the 'only_fields' attribute -items_iter = a.fetch(ids=calendar_ids, only_fields=['start', 'subject']) +items_iter = a.fetch(ids=calendar_ids, only_fields=["start", "subject"]) # Bulk update items. Each item must be accompanied by a list of attributes to # update. -updated_ids = a.bulk_update( - items=[(i, ('start', 'subject')) for i in calendar_items] -) +updated_ids = a.bulk_update(items=[(i, ("start", "subject")) for i in calendar_items]) # Move many items to a new folder new_ids = a.bulk_move(ids=calendar_ids, to_folder=a.other_calendar) # Send draft messages in bulk -message_ids = a.drafts.all().only('id', 'changekey') +message_ids = a.drafts.all().only("id", "changekey") new_ids = a.bulk_send(ids=message_ids, save_copy=False) # Delete in bulk @@ -972,7 +1020,7 @@ delete_results = a.bulk_delete(ids=calendar_ids) # Archive in bulk delete_results = a.bulk_archive( - ids=calendar_ids, to_folder=DistinguishedFolderId('inbox') + ids=calendar_ids, to_folder=DistinguishedFolderId("inbox") ) ``` @@ -980,20 +1028,18 @@ Bulk operations also work on QuerySet objects. Here's how to bulk delete messages in the inbox: ```python -a.inbox.filter(subject__startswith='Invoice').delete() +a.inbox.filter(subject__startswith="Invoice").delete() # Likewise, you can bulk send, copy, move or archive items found in a QuerySet -a.drafts.filter(subject__startswith='Invoice').send() +a.drafts.filter(subject__startswith="Invoice").send() # All kwargs are passed on to the equivalent bulk methods on the Account -a.drafts.filter(subject__startswith='Invoice').send(save_copy=False) -a.inbox.filter(subject__startswith='Invoice').copy(to_folder=a.trash) -a.inbox.filter(subject__startswith='Invoice').move(to_folder=a.trash) -a.inbox.filter(subject__startswith='Invoice').archive( - to_folder=DistinguishedFolderId('inbox') -) -a.inbox.filter(subject__startswith='Invoice').mark_as_junk( - is_junk=True, move_item=True +a.drafts.filter(subject__startswith="Invoice").send(save_copy=False) +a.inbox.filter(subject__startswith="Invoice").copy(to_folder=a.trash) +a.inbox.filter(subject__startswith="Invoice").move(to_folder=a.trash) +a.inbox.filter(subject__startswith="Invoice").archive( + to_folder=DistinguishedFolderId("inbox") ) +a.inbox.filter(subject__startswith="Invoice").mark_as_junk(is_junk=True, move_item=True) ``` ## Searching @@ -1020,8 +1066,9 @@ print([f.name for f in Message.FIELDS if f.is_searchable]) all_items = a.inbox.all() # Get everything all_items_without_caching = a.inbox.all() # Chain multiple modifiers to refine the query -filtered_items = a.inbox.filter(subject__contains='foo')\ - .exclude(categories__icontains='bar') +filtered_items = a.inbox.filter(subject__contains="foo").exclude( + categories__icontains="bar" +) # Delete all items returned by the QuerySet status_report = a.inbox.all().delete() start = datetime.datetime(2017, 1, 1, tzinfo=a.default_timezone) @@ -1033,12 +1080,12 @@ items_for_2017 = a.calendar.filter(start__range=(start, end)) Same as filter() but throws an error if exactly one item isn't returned ```python -item = a.inbox.get(subject='unique_string') +item = a.inbox.get(subject="unique_string") # If you only have the ID and possibly the changekey of an item, you can get the # full item: -a.inbox.get(id='AAMkADQy=') -a.inbox.get(id='AAMkADQy=', changekey='FwAAABYA') +a.inbox.get(id="AAMkADQy=") +a.inbox.get(id="AAMkADQy=", changekey="FwAAABYA") ``` You can sort by a single or multiple fields. Prefix a field with '-' to @@ -1046,15 +1093,14 @@ reverse the sorting. Sorting is efficient since it is done server-side, except when a calendar view sorting on multiple fields. ```python -ordered_items = a.inbox.all().order_by('subject') -reverse_ordered_items = a.inbox.all().order_by('-subject') - # Indexed properties can be ordered on their individual components +ordered_items = a.inbox.all().order_by("subject") +reverse_ordered_items = a.inbox.all().order_by("-subject") +# Indexed properties can be ordered on their individual components sorted_by_home_street = a.contacts.all().order_by( - 'phone_numbers__CarPhone', - 'physical_addresses__Home__street' + "phone_numbers__CarPhone", "physical_addresses__Home__street" ) # Beware that sorting is done client-side here -a.calendar.view(start=start, end=end).order_by('subject', 'categories') +a.calendar.view(start=start, end=end).order_by("subject", "categories") ``` Counting and exists @@ -1066,20 +1112,18 @@ folder_is_empty = not a.inbox.all().exists() # Efficient tasting Restricting returned attributes: ```python -sparse_items = a.inbox.all().only('subject', 'start') +sparse_items = a.inbox.all().only("subject", "start") # Dig deeper on indexed properties -sparse_items = a.contacts.all().only('phone_numbers') -sparse_items = a.contacts.all().only('phone_numbers__CarPhone') -sparse_items = a.contacts.all().only('physical_addresses__Home__street') +sparse_items = a.contacts.all().only("phone_numbers") +sparse_items = a.contacts.all().only("phone_numbers__CarPhone") +sparse_items = a.contacts.all().only("physical_addresses__Home__street") ``` Return values as dicts, nested or flat lists instead of objects: ```python -ids_as_dict = a.inbox.all().values('id', 'changekey') -values_as_list = a.inbox.all().values_list('subject', 'body') -all_subjects = a.inbox.all().values_list( - 'physical_addresses__Home__street', flat=True -) +ids_as_dict = a.inbox.all().values("id", "changekey") +values_as_list = a.inbox.all().values_list("subject", "body") +all_subjects = a.inbox.all().values_list("physical_addresses__Home__street", flat=True) ``` A QuerySet can be indexed and sliced like a normal Python list. Slicing and @@ -1089,17 +1133,17 @@ you might as well reverse the sorting. ```python # Efficient. We only fetch 10 items -first_ten = a.inbox.all().order_by('-subject')[:10] +first_ten = a.inbox.all().order_by("-subject")[:10] # Efficient, but convoluted -last_ten = a.inbox.all().order_by('-subject')[:-10] +last_ten = a.inbox.all().order_by("-subject")[:-10] # Efficient. We only fetch 10 items -next_ten = a.inbox.all().order_by('-subject')[10:20] +next_ten = a.inbox.all().order_by("-subject")[10:20] # Efficient. We only fetch 1 item -single_item = a.inbox.all().order_by('-subject')[34298] +single_item = a.inbox.all().order_by("-subject")[34298] # Efficient. We only fetch 10 items -ten_items = a.inbox.all().order_by('-subject')[3420:3430] +ten_items = a.inbox.all().order_by("-subject")[3420:3430] # This is just stupid, but works -random_emails = a.inbox.all().order_by('-subject')[::3] +random_emails = a.inbox.all().order_by("-subject")[::3] ``` The syntax for filter() is modeled after Django QuerySet filters. The @@ -1119,13 +1163,13 @@ for f in Message.FIELDS: # No restrictions. Return all items. qs = a.calendar.all() # Returns items where subject is exactly 'foo'. Case-sensitive -qs.filter(subject='foo') +qs.filter(subject="foo") # Returns items within range qs.filter(start__range=(start, end)) # Return items where subject is either 'foo' or 'bar' -qs.filter(subject__in=('foo', 'bar')) +qs.filter(subject__in=("foo", "bar")) # Returns items where subject is not 'foo' -qs.filter(subject__not='foo') +qs.filter(subject__not="foo") # Returns items starting after 'dt' qs.filter(start__gt=start) # Returns items starting on or after 'dt' @@ -1135,17 +1179,17 @@ qs.filter(start__lt=start) # Returns items starting on or before 'dt' qs.filter(start__lte=start) # Same as filter(subject='foo') -qs.filter(subject__exact='foo') +qs.filter(subject__exact="foo") # Returns items where subject is 'foo', 'FOO' or 'Foo' -qs.filter(subject__iexact='foo') +qs.filter(subject__iexact="foo") # Returns items where subject contains 'foo' -qs.filter(subject__contains='foo') +qs.filter(subject__contains="foo") # Returns items where subject contains 'foo', 'FOO' or 'Foo' -qs.filter(subject__icontains='foo') +qs.filter(subject__icontains="foo") # Returns items where subject starts with 'foo' -qs.filter(subject__startswith='foo') +qs.filter(subject__startswith="foo") # Returns items where subject starts with 'foo', 'FOO' or 'Foo' -qs.filter(subject__istartswith='foo') +qs.filter(subject__istartswith="foo") # Returns items that have at least one category assigned, i.e. the field exists # on the item on the server. qs.filter(categories__exists=True) @@ -1155,11 +1199,10 @@ qs.filter(categories__exists=False) # When filtering on indexed properties, you need to specify the full path to the # value you want to filter on. -a.contacts.filter(phone_numbers__CarPhone='123456') -a.contacts.filter(phone_numbers__CarPhone__contains='123') -a.contacts.filter(physical_addresses__Home__street='Elm Street') -a.contacts.filter(physical_addresses__Home__street__contains='Elm') - +a.contacts.filter(phone_numbers__CarPhone="123456") +a.contacts.filter(phone_numbers__CarPhone__contains="123") +a.contacts.filter(physical_addresses__Home__street="Elm Street") +a.contacts.filter(physical_addresses__Home__street__contains="Elm") ``` WARNING: Filtering on the 'body' field is not fully supported by EWS. There @@ -1177,7 +1220,7 @@ Read more about the QueryString syntax here: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/querystring-querystringtype ```python -a.inbox.filter('subject:XXX') +a.inbox.filter("subject:XXX") ``` `filter()` also supports `Q` objects that are modeled after Django Q objects, for @@ -1186,9 +1229,9 @@ building complex boolean logic search expressions. ```python from exchangelib import Q -q = ( - Q(subject__iexact='foo') | Q(subject__contains='bar') - ) & ~Q(subject__startswith='baz') +q = (Q(subject__iexact="foo") | Q(subject__contains="bar")) & ~Q( + subject__startswith="baz" +) a.inbox.filter(q) ``` @@ -1198,7 +1241,7 @@ In this example, we filter by categories so we only get items created by us. a.calendar.filter( start__lt=datetime.datetime(2019, 1, 1, tzinfo=a.default_timezone), end__gt=datetime.datetime(2019, 1, 31, tzinfo=a.default_timezone), - categories__contains=['foo', 'bar'], + categories__contains=["foo", "bar"], ) ``` @@ -1222,7 +1265,7 @@ conflicts before adding a meeting from 8:00 to 10:00: has_conflicts = a.calendar.view( start=datetime.datetime(2019, 1, 31, 8, tzinfo=a.default_timezone), end=datetime.datetime(2019, 1, 31, 10, tzinfo=a.default_timezone), - max_items=1 + max_items=1, ).exists() ``` @@ -1232,11 +1275,11 @@ multiple folders in a single request. ```python from exchangelib import FolderCollection -a.inbox.children.filter(subject='foo') -a.inbox.walk().filter(subject='foo') -a.inbox.glob('foo*').filter(subject='foo') +a.inbox.children.filter(subject="foo") +a.inbox.walk().filter(subject="foo") +a.inbox.glob("foo*").filter(subject="foo") # Or select the folders individually -FolderCollection(account=a, folders=[a.inbox, a.calendar]).filter(subject='foo') +FolderCollection(account=a, folders=[a.inbox, a.calendar]).filter(subject="foo") ``` ## Paging @@ -1246,6 +1289,7 @@ number of items fetched per page when paging is requested. You can change this v ```python import exchangelib.services + exchangelib.services.EWSService.PAGE_SIZE = 25 ``` @@ -1255,6 +1299,7 @@ requests. You can change this value globally: ```python import exchangelib.services + exchangelib.services.EWSService.CHUNK_SIZE = 25 ``` @@ -1266,18 +1311,18 @@ you can change this value on a per-queryset basis: from exchangelib import Account a = Account(...) -qs = a.inbox.all().only('mime_content') +qs = a.inbox.all().only("mime_content") qs.page_size = 200 # Number of IDs for FindItem to get per page qs.chunk_size = 5 # Number of full items for GetItem to request per call for msg in qs: - with open('%s.eml' % msg.item_id, 'w') as f: + with open("%s.eml" % msg.item_id, "w") as f: f.write(msg.mime_content) ``` You can also change the default page and chunk size of bulk operations via QuerySets: ```python -a.inbox.filter(subject__startswith='Invoice').delete(page_size=1000, chunk_size=100) +a.inbox.filter(subject__startswith="Invoice").delete(page_size=1000, chunk_size=100) ``` Finally, the bulk methods defined on the `Account` class have an optional `chunk_size` @@ -1289,9 +1334,7 @@ from exchangelib import Account, Message a = Account(...) huge_list_of_items = [Message(...) for i in range(10000)] -return_ids = a.bulk_create( - folder=a.inbox, items=huge_list_of_items, chunk_size=5 -) +return_ids = a.bulk_create(folder=a.inbox, items=huge_list_of_items, chunk_size=5) ``` ## Meetings @@ -1308,8 +1351,11 @@ from the calendar. ```python import datetime from exchangelib import Account, CalendarItem -from exchangelib.items import MeetingRequest, MeetingCancellation, \ - SEND_TO_ALL_AND_SAVE_COPY +from exchangelib.items import ( + MeetingRequest, + MeetingCancellation, + SEND_TO_ALL_AND_SAVE_COPY, +) a = Account(...) @@ -1321,18 +1367,18 @@ item = CalendarItem( end=datetime.datetime(2019, 1, 31, 8, 45, tzinfo=a.default_timezone), subject="Subject of Meeting", body="Please come to my meeting", - required_attendees=['anne@example.com', 'bob@example.com'] + required_attendees=["anne@example.com", "bob@example.com"], ) item.save(send_meeting_invitations=SEND_TO_ALL_AND_SAVE_COPY) # cancel a meeting that was sent out using the CalendarItem class -for calendar_item in a.calendar.all().order_by('-datetime_received')[:5]: +for calendar_item in a.calendar.all().order_by("-datetime_received")[:5]: # only the organizer of a meeting can cancel it if calendar_item.organizer.email_address == a.primary_smtp_address: calendar_item.cancel() # processing an incoming MeetingRequest -for item in a.inbox.all().order_by('-datetime_received')[:5]: +for item in a.inbox.all().order_by("-datetime_received")[:5]: if isinstance(item, MeetingRequest): item.accept(body="Sure, I'll come") # Or: @@ -1342,16 +1388,16 @@ for item in a.inbox.all().order_by('-datetime_received')[:5]: # meeting requests can also be handled from the calendar - e.g. decline the # meeting that was received last. -for calendar_item in a.calendar.all().order_by('-datetime_received')[:1]: +for calendar_item in a.calendar.all().order_by("-datetime_received")[:1]: calendar_item.decline() # processing an incoming MeetingCancellation (also delete from calendar) -for item in a.inbox.all().order_by('-datetime_received')[:5]: +for item in a.inbox.all().order_by("-datetime_received")[:5]: if isinstance(item, MeetingCancellation): if item.associated_calendar_item_id: calendar_item = a.inbox.get( id=item.associated_calendar_item_id.id, - changekey=item.associated_calendar_item_id.changekey + changekey=item.associated_calendar_item_id.changekey, ) calendar_item.delete() item.move_to_trash() @@ -1368,30 +1414,36 @@ from exchangelib import Account, DistributionList from exchangelib.indexed_properties import EmailAddress a = Account(...) -folder = a.root / 'AllContacts' +folder = a.root / "AllContacts" for p in folder.people(): print(p) -for p in folder.people().only('display_name').filter(display_name='john')\ - .order_by('display_name'): +for p in ( + folder.people() + .only("display_name") + .filter(display_name="john") + .order_by("display_name") +): print(p) # Getting a single contact in the GAL contact list -gal = a.contacts / 'GAL Contacts' -contact = gal.get(email_addresses=EmailAddress(email='lucas@example.com')) +gal = a.contacts / "GAL Contacts" +contact = gal.get(email_addresses=EmailAddress(email="lucas@example.com")) # All contacts with a gmail address -gmail_contacts = list(gal.filter( - email_addresses__contains=EmailAddress(email='gmail.com') -)) +gmail_contacts = list( + gal.filter(email_addresses__contains=EmailAddress(email="gmail.com")) +) # All Gmail email addresses gmail_addresses = [ - e.email for c in gal.filter( - email_addresses__contains=EmailAddress(email='gmail.com') - ) for e in c.email_addresses + e.email + for c in gal.filter(email_addresses__contains=EmailAddress(email="gmail.com")) + for e in c.email_addresses ] # All email addresses all_addresses = [ - e.email for c in gal.all() - for e in c.email_addresses if not isinstance(c, DistributionList) + e.email + for c in gal.all() + for e in c.email_addresses + if not isinstance(c, DistributionList) ] ``` @@ -1439,15 +1491,15 @@ add a contact photo and notes like this: from exchangelib import Account, FileAttachment a = Account(...) -contact = a.contacts.get(given_name='John') -contact.body = 'This is a note' -contact.save(update_fields=['body']) +contact = a.contacts.get(given_name="John") +contact.body = "This is a note" +contact.save(update_fields=["body"]) att = FileAttachment( - name='ContactPicture.jpg', - content_type='image/png', + name="ContactPicture.jpg", + content_type="image/png", is_inline=False, is_contact_photo=True, - content=open('john_profile_picture.png', 'rb').read(), + content=open("john_profile_picture.png", "rb").read(), ) contact.attach(att) ``` @@ -1472,49 +1524,55 @@ from exchangelib import Account, ExtendedProperty, CalendarItem a = Account(...) + class LunchMenu(ExtendedProperty): - property_set_id = '12345678-1234-1234-1234-123456781234' - property_name = 'Catering from the cafeteria' - property_type = 'String' + property_set_id = "12345678-1234-1234-1234-123456781234" + property_name = "Catering from the cafeteria" + property_type = "String" + # Register the property on the item type of your choice -CalendarItem.register('lunch_menu', LunchMenu) +CalendarItem.register("lunch_menu", LunchMenu) # Now your property is available as the attribute 'lunch_menu', just like any # other attribute. -item = CalendarItem(..., lunch_menu='Foie gras et consommé de légumes') +item = CalendarItem(..., lunch_menu="Foie gras et consommé de légumes") item.save() for i in a.calendar.all(): print(i.lunch_menu) # If you change your mind, jsut remove the property again -CalendarItem.deregister('lunch_menu') +CalendarItem.deregister("lunch_menu") + # You can also create named properties (e.g. created from User Defined Fields in # Outlook, see issue #137): class LunchMenu(ExtendedProperty): - distinguished_property_set_id = 'PublicStrings' - property_name = 'Catering from the cafeteria' - property_type = 'String' + distinguished_property_set_id = "PublicStrings" + property_name = "Catering from the cafeteria" + property_type = "String" + # We support extended properties with tags. This is the definition for the # 'completed' and 'followup' flag you can add to items in Outlook (see also # issue #85): class Flag(ExtendedProperty): property_tag = 0x1090 - property_type = 'Integer' + property_type = "Integer" + # Or with property ID: class MyMeetingArray(ExtendedProperty): - property_set_id = '00062004-0000-0000-C000-000000000046' - property_type = 'BinaryArray' + property_set_id = "00062004-0000-0000-C000-000000000046" + property_type = "BinaryArray" property_id = 32852 + # Or using distinguished property sets combined with property ID (here as a hex # value to align with the format usually mentioned in Microsoft docs). This is # the definition for a response to an Outlook Vote request (see issue #198): class VoteResponse(ExtendedProperty): - distinguished_property_set_id = 'Common' + distinguished_property_set_id = "Common" property_id = 0x00008524 - property_type = 'String' + property_type = "String" ``` Extended properties also work with folders. For folders, it's only possible to @@ -1527,11 +1585,13 @@ Here's an example of getting the size (in bytes) of a folder: ```python from exchangelib import ExtendedProperty, Folder + class FolderSize(ExtendedProperty): - property_tag = 0x0e08 - property_type = 'Integer' + property_tag = 0x0E08 + property_type = "Integer" + -Folder.register('size', FolderSize) +Folder.register("size", FolderSize) print(a.inbox.size) ``` @@ -1554,12 +1614,14 @@ In conclusion, the definition for the due date becomes: ```python from exchangelib import ExtendedProperty, Message + class FlagDue(ExtendedProperty): - property_set_id = '00062003-0000-0000-C000-000000000046' + property_set_id = "00062003-0000-0000-C000-000000000046" property_id = 0x8105 - property_type = 'SystemTime' + property_type = "SystemTime" + -Message.register('flag_due', FlagDue) +Message.register("flag_due", FlagDue) ``` ## Attachments @@ -1579,10 +1641,10 @@ a = Account for item in a.inbox.all(): for attachment in item.attachments: if isinstance(attachment, FileAttachment): - local_path = os.path.join('/tmp', attachment.name) - with open(local_path, 'wb') as f: + local_path = os.path.join("/tmp", attachment.name) + with open(local_path, "wb") as f: f.write(attachment.content) - print('Saved attachment to', local_path) + print("Saved attachment to", local_path) elif isinstance(attachment, ItemAttachment): if isinstance(attachment.item, Message): print(attachment.item.subject, attachment.item.body) @@ -1595,13 +1657,13 @@ consumption since we never store the full content of the file in-memory: for item in a.inbox.all(): for attachment in item.attachments: if isinstance(attachment, FileAttachment): - local_path = os.path.join('/tmp', attachment.name) - with open(local_path, 'wb') as f, attachment.fp as fp: + local_path = os.path.join("/tmp", attachment.name) + with open(local_path, "wb") as f, attachment.fp as fp: buffer = fp.read(1024) while buffer: f.write(buffer) buffer = fp.read(1024) - print('Saved attachment to', local_path) + print("Saved attachment to", local_path) ``` Some more examples of working with attachments: @@ -1610,22 +1672,20 @@ Some more examples of working with attachments: # Create a new item with an attachment item = Message(...) # State the bytes directly, or read from file, BytesIO etc. -binary_file_content = 'Hello from unicode æøå'.encode('utf-8') -my_file = FileAttachment(name='my_file.txt', content=binary_file_content) +binary_file_content = "Hello from unicode æøå".encode("utf-8") +my_file = FileAttachment(name="my_file.txt", content=binary_file_content) item.attach(my_file) my_calendar_item = CalendarItem(...) # If you got the item to attach from the server, you probably want to ignore the # 'mime_content' field that contains copies of other field values on the item. # This avoids duplicate attachments etc. my_calendar_item.mime_content = None -my_appointment = ItemAttachment(name='my_appointment', item=my_calendar_item) +my_appointment = ItemAttachment(name="my_appointment", item=my_calendar_item) item.attach(my_appointment) item.save() # Add an attachment on an existing item -my_other_file = FileAttachment( - name='my_other_file.txt', content=binary_file_content -) +my_other_file = FileAttachment(name="my_other_file.txt", content=binary_file_content) item.attach(my_other_file) # Remove the attachment again @@ -1638,22 +1698,23 @@ the HTML. from exchangelib import HTMLBody message = Message(...) -logo_filename = 'logo.png' -with open(logo_filename, 'rb') as f: +logo_filename = "logo.png" +with open(logo_filename, "rb") as f: my_logo = FileAttachment( - name=logo_filename, content=f.read(), is_inline=True, - content_id=logo_filename + name=logo_filename, content=f.read(), is_inline=True, content_id=logo_filename ) message.attach(my_logo) # Most email systems message.body = HTMLBody( - 'Hello logo: ' % logo_filename + 'Hello logo: ' % logo_filename ) # Gmail needs this additional img attribute -message.body = HTMLBody('''\ +message.body = HTMLBody( + """\ Hello logo: -''' % logo_filename +""" + % logo_filename ) ``` @@ -1687,11 +1748,11 @@ master_recurrence = CalendarItem( folder=a.calendar, start=start, end=end, - subject='Hello Recurrence', + subject="Hello Recurrence", recurrence=Recurrence( pattern=WeeklyPattern(interval=3, weekdays=[MONDAY, WEDNESDAY]), start=start.date(), - number=7 + number=7, ), ).save() @@ -1708,16 +1769,14 @@ for o in i.deleted_occurrences: # All occurrences expanded. The recurrence will span over 4 iterations of a # 3-week period. -for i in a.calendar.view( - start=start, end=start + datetime.timedelta(days=4*3*7) -): +for i in a.calendar.view(start=start, end=start + datetime.timedelta(days=4 * 3 * 7)): print(i.subject, i.start, i.end) # 'modified_occurrences' and 'deleted_occurrences' of master items are read-only # fields. To delete or modify an occurrence, you must use 'view()' to fetch the # occurrence and modify or delete it: for occurrence in a.calendar.view( - start=start, end=start + datetime.timedelta(days=4*3*7) + start=start, end=start + datetime.timedelta(days=4 * 3 * 7) ): # Delete or update random occurrences. This will affect # 'modified_occurrences' and 'deleted_occurrences' of the master item. @@ -1727,7 +1786,7 @@ for occurrence in a.calendar.view( occurrence.start += datetime.timedelta(minutes=30) occurrence.end = occurrence.end.astimezone(a.default_timezone) occurrence.end += datetime.timedelta(minutes=30) - occurrence.subject = 'My new subject' + occurrence.subject = "My new subject" occurrence.save() else: occurrence.delete() @@ -1739,12 +1798,12 @@ third_occurrence.refresh() # Change a field on the occurrence third_occurrence.start += datetime.timedelta(hours=3) # Delete occurrence -third_occurrence.save(update_fields=['start']) +third_occurrence.save(update_fields=["start"]) # Similarly, you can reach the master recurrence from the occurrence master = third_occurrence.recurring_master() -master.subject = 'An update' -master.save(update_fields=['subject']) +master.subject = "An update" +master.save(update_fields=["subject"]) ``` ## Message timestamp fields @@ -1778,7 +1837,7 @@ a.oof_settings # Change the OOF settings to something else a.oof_settings = OofSettings( state=OofSettings.SCHEDULED, - external_audience='Known', + external_audience="Known", internal_reply="I'm in the pub. See ya guys!", external_reply="I'm having a business dinner in town", start=datetime.datetime(2017, 11, 1, 11, tzinfo=a.default_timezone), @@ -1787,8 +1846,8 @@ a.oof_settings = OofSettings( # Disable OOF messages a.oof_settings = OofSettings( state=OofSettings.DISABLED, - internal_reply='', - external_reply='', + internal_reply="", + external_reply="", ) ``` @@ -1827,7 +1886,7 @@ export and upload services. They are available on the `Account` model: from exchangelib import Account a = Account(...) -items = a.inbox.all().only('id', 'changekey') +items = a.inbox.all().only("id", "changekey") data = a.export(items) # Pass a list of Items or (item_id, changekey) tuples a.upload((a.inbox, d) for d in data) # Expects a list of (folder, data) tuples ``` @@ -1878,7 +1937,7 @@ for parsing the POST data that the Exchange server sends to the callback URL, an creating proper responses to these URLs. ```python subscription_id, watermark = a.inbox.subscribe_to_push( - callback_url='https://my_app.example.com/callback_url' + callback_url="https://my_app.example.com/callback_url" ) ``` @@ -1892,7 +1951,8 @@ from flask import Flask, request app = Flask(__name__) -@app.route('/callback_url', methods=['POST']) + +@app.route("/callback_url", methods=["POST"]) def notify_me(): ws = SendNotification(protocol=None) for notification in ws.parse(request.data): @@ -1902,7 +1962,7 @@ def notify_me(): # Or, if you want to end the subscription: data = ws.unsubscribe_payload() - return data, 201, {'Content-Type': 'text/xml; charset=utf-8'} + return data, 201, {"Content-Type": "text/xml; charset=utf-8"} ``` Here's how to create a streaming subscription that can be used to stream events from the @@ -1922,15 +1982,15 @@ When creating subscriptions, you can also use one of the three context managers that handle unsubscription automatically: ```python with a.inbox.pull_subscription() as (subscription_id, watermark): - pass + pass with a.inbox.push_subscription( - callback_url='https://my_app.example.com/callback_url' + callback_url="https://my_app.example.com/callback_url" ) as (subscription_id, watermark): - pass + pass with a.inbox.streaming_subscription() as subscription_id: - pass + pass ``` Pull events from the server. This method returns Notification objects that @@ -1938,17 +1998,21 @@ contain events in the `events` attribute and a new watermark in the `watermark` attribute. ```python -from exchangelib.properties import CopiedEvent, CreatedEvent, DeletedEvent, \ - ModifiedEvent +from exchangelib.properties import ( + CopiedEvent, + CreatedEvent, + DeletedEvent, + ModifiedEvent, +) for notification in a.inbox.get_events(subscription_id, watermark): - for event in notification.events: - if isinstance(event, (CreatedEvent, ModifiedEvent)): - # Do something - pass - elif isinstance(event, (CopiedEvent, DeletedEvent)): - # Do something else - pass + for event in notification.events: + if isinstance(event, (CreatedEvent, ModifiedEvent)): + # Do something + pass + elif isinstance(event, (CopiedEvent, DeletedEvent)): + # Do something else + pass ``` Stream events from the server. This method returns Notification objects that @@ -1965,19 +2029,21 @@ The default configuration is to only have 1 connection. See the documentation on `Configuration.max_connections` on how to increase the connection count. ```python -from exchangelib.properties import MovedEvent, NewMailEvent, StatusEvent, \ - FreeBusyChangedEvent +from exchangelib.properties import ( + MovedEvent, + NewMailEvent, + StatusEvent, + FreeBusyChangedEvent, +) -for notification in a.inbox.get_streaming_events( - subscription_id, connection_timeout=1 -): - for event in notification.events: - if isinstance(event, (MovedEvent, NewMailEvent)): - # Do something - pass - elif isinstance(event, (StatusEvent, FreeBusyChangedEvent)): - # Do something else - pass +for notification in a.inbox.get_streaming_events(subscription_id, connection_timeout=1): + for event in notification.events: + if isinstance(event, (MovedEvent, NewMailEvent)): + # Do something + pass + elif isinstance(event, (StatusEvent, FreeBusyChangedEvent)): + # Do something else + pass ``` ## Non-account services @@ -1999,26 +2065,29 @@ for rl in a.protocol.get_roomlists(): a.protocol.get_rooms(rl) # Get account information for a list of names or email addresses -for mailbox in a.protocol.resolve_names(['ann@example.com', 'ben@example.com']): +for mailbox in a.protocol.resolve_names(["ann@example.com", "ben@example.com"]): print(mailbox.email_address) for mailbox, contact in a.protocol.resolve_names( - ['anne', 'bart'], return_full_contact_data=True + ["anne", "bart"], return_full_contact_data=True ): print(mailbox.email_address, contact.display_name) # Get all mailboxes on a distribution list for mailbox in a.protocol.expand_dl( - DLMailbox(email_address='distro@example.com', mailbox_type='PublicDL') + DLMailbox(email_address="distro@example.com", mailbox_type="PublicDL") ): print(mailbox.email_address) # Or just pass a string containing the SMTP address -for mailbox in a.protocol.expand_dl('distro@example.com'): +for mailbox in a.protocol.expand_dl("distro@example.com"): print(mailbox.email_address) # Convert item IDs from one format to another -for converted_id in a.protocol.convert_ids([ - AlternateId(id='AAA=', format=EWS_ID, mailbox=a.primary_smtp_address), -], destination_format=OWA_ID): +for converted_id in a.protocol.convert_ids( + [ + AlternateId(id="AAA=", format=EWS_ID, mailbox=a.primary_smtp_address), + ], + destination_format=OWA_ID, +): print(converted_id) # Get searchable mailboxes. This method is only available to users who have been @@ -2040,10 +2109,8 @@ from exchangelib import Account a = Account(...) start = datetime.datetime.now(a.default_timezone) end = start + datetime.timedelta(hours=6) -accounts = [(a, 'Organizer', False)] -for busy_info in a.protocol.get_free_busy_info( - accounts=accounts, start=start, end=end -): +accounts = [(a, "Organizer", False)] +for busy_info in a.protocol.get_free_busy_info(accounts=accounts, start=start, end=end): print(busy_info) ``` @@ -2063,14 +2130,10 @@ timezones = list(a.protocol.get_timezones(return_full_timezone_data=True)) start = datetime.datetime.now(a.default_timezone) end = start + datetime.timedelta(hours=6) # get_free_busy_info expects (account, attendee_type, exclude_conflicts) tuples -accounts = [(a, 'Organizer', False)] -for busy_info in a.protocol.get_free_busy_info( - accounts=accounts, start=start, end=end -): +accounts = [(a, "Organizer", False)] +for busy_info in a.protocol.get_free_busy_info(accounts=accounts, start=start, end=end): # Convert the TimeZone object to a Microsoft timezone ID - ms_id = busy_info.working_hours_timezone.to_server_timezone( - timezones, start.year - ) + ms_id = busy_info.working_hours_timezone.to_server_timezone(timezones, start.year) account_tz = EWSTimeZone.from_ms_id(ms_id) print(account_tz, busy_info.working_hours) for event in busy_info.calendar_events: @@ -2088,6 +2151,7 @@ being sent and received. ```python import logging + # This handler will pretty-print and syntax highlight the request and response # XML documents from exchangelib.util import PrettyXmlHandler @@ -2101,6 +2165,7 @@ MSDN page for the corresponding XML element. ```python from exchangelib import CalendarItem + print(CalendarItem.__doc__) ``` From 9eccab05445ac6bdd02bd8d05242cbbb857865b2 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sun, 23 Jul 2023 09:28:09 +0200 Subject: [PATCH 366/509] chore: Python 3.7 is now EOL --- .github/workflows/python-package.yml | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 32dd95e0..dee918f8 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -29,7 +29,7 @@ jobs: needs: pre_job strategy: matrix: - python-version: ['3.7', '3.11'] + python-version: ['3.8', '3.11'] include: # Allow failure on Python dev - e.g. Cython install regularly fails - python-version: "3.12-dev" diff --git a/setup.py b/setup.py index a8bc6b60..15bcf2df 100755 --- a/setup.py +++ b/setup.py @@ -60,7 +60,7 @@ def read(file_name): "complete": ["requests_gssapi", "requests_negotiate_sspi"], # Only for Win32 environments }, packages=find_packages(exclude=("tests", "tests.*")), - python_requires=">=3.7", + python_requires=">=3.8", test_suite="tests", zip_safe=False, url="https://github.com/ecederstrand/exchangelib", From 7428a3442372800a86a67bb4e778a3622269ee52 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sun, 23 Jul 2023 11:42:40 +0200 Subject: [PATCH 367/509] fix: Handle invalid XML also in streaming XML parser. Fixes #1200 --- exchangelib/util.py | 16 +++++++++++----- tests/test_util.py | 9 ++++++++- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/exchangelib/util.py b/exchangelib/util.py index 4f3605a6..00c90554 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -68,6 +68,7 @@ def __init__(self, msg, data): # Regex of UTF-8 control characters that are illegal in XML 1.0 (and XML 1.1). # See https://stackoverflow.com/a/22273639/219640 _ILLEGAL_XML_CHARS_RE = re.compile("[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x84\x86-\x9F\uFDD0-\uFDDF\uFFFE\uFFFF]") +_ILLEGAL_XML_ESCAPE_CHARS_RE = re.compile(rb"&(#[0-9]+;?|#[xX][0-9a-fA-F]+;?)") # Could match the above better # XML namespaces SOAPNS = "http://schemas.xmlsoap.org/soap/envelope/" @@ -268,6 +269,10 @@ def safe_xml_value(value, replacement="?"): return _ILLEGAL_XML_CHARS_RE.sub(replacement, value) +def sanitize_xml(data, replacement=b"?"): + return _ILLEGAL_XML_ESCAPE_CHARS_RE.sub(replacement, data) + + def create_element(name, attrs=None, nsmap=None): if ":" in name: ns, name = name.split(":") @@ -362,19 +367,20 @@ def parse(self, r): collected_data = [] while buffer: if not self.element_found: - collected_data += buffer + collected_data.extend(buffer) yield from self.feed(buffer) buffer = file.read(self._bufsize) # Any remaining data in self.buffer should be padding chars now self.buffer = None self.close() if not self.element_found: - data = bytes(collected_data) - raise ElementNotFound("The element to be streamed from was not found", data=bytes(data)) + raise ElementNotFound("The element to be streamed from was not found", data=bytes(collected_data)) def feed(self, data, isFinal=0): - """Yield the current content of the character buffer.""" - DefusedExpatParser.feed(self, data=data, isFinal=isFinal) + """Yield the current content of the character buffer. The input XML may contain illegal characters. The lxml + parser handles this gracefully with the 'recover' option, but ExpatParser doesn't have this option. Remove + illegal characters before parsing.""" + DefusedExpatParser.feed(self, data=sanitize_xml(data), isFinal=isFinal) return self._decode_buffer() def _decode_buffer(self): diff --git a/tests/test_util.py b/tests/test_util.py index 46f99301..5f449f70 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -24,6 +24,7 @@ DocumentYielder, ParseError, PrettyXmlHandler, + StreamingBase64Parser, chunkify, get_domain, get_redirect_url, @@ -129,8 +130,9 @@ def test_get_redirect_url(self, m): def test_to_xml(self): to_xml(b'') + to_xml(b'&broken') + to_xml(b'') to_xml(BOM_UTF8 + b'') - to_xml(BOM_UTF8 + b'&broken') with self.assertRaises(ParseError): to_xml(b"foo") @@ -166,6 +168,11 @@ def test_xml_to_str(self): with self.assertRaises(AttributeError): xml_to_str("XXX", encoding=None, xml_declaration=True) + def test_streaming_parser(self): + StreamingBase64Parser().feed(b"SomeName.png", 1) + # Test that we can handle invalid chars in the streaming parser + StreamingBase64Parser().feed(b"SomeName.png", 1) + def test_anonymizing_handler(self): h = AnonymizingXmlHandler(forbidden_strings=("XXX", "yyy")) self.assertEqual( From 0027d662144f2d85c3d6129703cf57a3d7cc4fc5 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sun, 23 Jul 2023 12:00:22 +0200 Subject: [PATCH 368/509] fix: Support expatreader in at least Python 3.8 --- exchangelib/util.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/exchangelib/util.py b/exchangelib/util.py index 00c90554..2abec794 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -270,6 +270,9 @@ def safe_xml_value(value, replacement="?"): def sanitize_xml(data, replacement=b"?"): + if not isinstance(data, bytes): + # We may get data="" from some expatreader versions + return data return _ILLEGAL_XML_ESCAPE_CHARS_RE.sub(replacement, data) From 909cab4ad1853d2657f1cef9e01d449dd1f4c106 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 17 Aug 2023 13:50:32 +0200 Subject: [PATCH 369/509] fix: Add workaround for weird, undocumented Booking items. Refs #877 --- exchangelib/items/__init__.py | 2 ++ exchangelib/items/calendar_item.py | 10 ++++++++++ tests/test_properties.py | 3 +++ 3 files changed, 15 insertions(+) diff --git a/exchangelib/items/__init__.py b/exchangelib/items/__init__.py index 04bf6a6a..d924fadb 100644 --- a/exchangelib/items/__init__.py +++ b/exchangelib/items/__init__.py @@ -40,6 +40,7 @@ MeetingRequest, MeetingResponse, TentativelyAcceptItem, + _Booking, ) from .contact import Contact, DistributionList, Persona from .item import BaseItem, Item @@ -62,6 +63,7 @@ ITEM_CLASSES = ( + _Booking, CalendarItem, Contact, DistributionList, diff --git a/exchangelib/items/calendar_item.py b/exchangelib/items/calendar_item.py index 1f00e113..52f45b59 100644 --- a/exchangelib/items/calendar_item.py +++ b/exchangelib/items/calendar_item.py @@ -454,3 +454,13 @@ class CancelCalendarItem(BaseReplyItem): ELEMENT_NAME = "CancelCalendarItem" author_idx = BaseReplyItem.FIELDS.index_by_name("author") FIELDS = BaseReplyItem.FIELDS[:author_idx] + BaseReplyItem.FIELDS[author_idx + 1 :] + + +class _Booking(Item): + """Not mentioned anywhere in MSDN docs, but it's common enough that we want to at least not crash when we encounter + an item of this type in a folder. For more information, see https://github.com/ecederstrand/exchangelib/issues/877 + + Not supported in any way except to not crash on calendar folder reads + """ + + ELEMENT_NAME = "Booking" diff --git a/tests/test_properties.py b/tests/test_properties.py index 1fd44e11..de12d70b 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -76,6 +76,9 @@ def test_ews_element_sanity(self): if cls.__doc__.startswith("Base class "): # Base classes don't have an MSDN link continue + if cls.__name__.startswith("_"): + # Non-public item class + continue if issubclass(cls, RootOfHierarchy): # Root folders don't have an MSDN link continue From ae4f77a12f95ffbb7e20ef327fb5cfe01bd95cd2 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sun, 20 Aug 2023 13:20:44 +0200 Subject: [PATCH 370/509] fix: Support globbing with a glob pattern that's more than 2 levels deep. Fixes #1212 --- exchangelib/folders/base.py | 2 +- tests/test_folder.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index ab211c64..819f799f 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -151,7 +151,7 @@ def walk(self): return FolderCollection(account=self.account, folders=self._walk()) def _glob(self, pattern): - split_pattern = pattern.rsplit("/", 1) + split_pattern = pattern.split("/", maxsplit=1) head, tail = (split_pattern[0], None) if len(split_pattern) == 1 else split_pattern if head == "": # We got an absolute path. Restart globbing at root diff --git a/tests/test_folder.py b/tests/test_folder.py index 9cb3dcc2..0bac9036 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -748,6 +748,12 @@ def test_glob(self): list(self.account.root.glob("../*")) self.assertEqual(e.exception.args[0], "Already at top") + # Test globbing with multiple levels of folders ('a/b/c') + f1 = Folder(parent=self.account.inbox, name=get_random_string(16)).save() + f2 = Folder(parent=f1, name=get_random_string(16)).save() + f3 = Folder(parent=f2, name=get_random_string(16)).save() + self.assertEqual(len(list(self.account.inbox.glob(f"{f1.name}/{f2.name}/{f3.name}"))), 1) + def test_collection_filtering(self): self.assertGreaterEqual(self.account.root.tois.children.all().count(), 0) self.assertGreaterEqual(self.account.root.tois.walk().all().count(), 0) From 336d3a69146859892e4ca3b8d49516ab923104bd Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sun, 20 Aug 2023 15:09:22 +0200 Subject: [PATCH 371/509] ci: Attempt to make item update tests more stable --- exchangelib/items/item.py | 19 ++++++++++++++++++- tests/test_items/test_basics.py | 2 ++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/exchangelib/items/item.py b/exchangelib/items/item.py index f439b746..04c2dc6e 100644 --- a/exchangelib/items/item.py +++ b/exchangelib/items/item.py @@ -1,4 +1,5 @@ import logging +import time from ..fields import ( AttachmentField, @@ -153,7 +154,23 @@ def save(self, update_fields=None, conflict_resolution=AUTO_RESOLVE, send_meetin # When we update certain fields on a task, the ID may change. A full description is available at # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem-operation-task raise ValueError("'id' mismatch in returned update response") - # Don't check that changekeys are different. No-op saves will sometimes leave the changekey intact + # Check that changekey is different on update. No-op saves will sometimes leave the changekey intact, so + # don't make this a requirement. The test suite has uncovered that item updates are not atomic WTR changekey + # updates, at least on O365. This results in 'ErrorIrresolvableConflict' when updating an item multiple + # times within a short timeframe. Try a couple of times to get a fresh changekey, to make the test more + # stable. + old_changekey = self._id.changekey + if changekey == old_changekey: + log.warning("'changekey' did not change on update") + for i in range(1, 4): + time.sleep(i / 2) + self._id.changekey = None + self.refresh() + if self._id.changekey != old_changekey: + break + else: + self._id.changekey = old_changekey + log.warning("'changekey' still not updated after 3 refreshes") self._id = self.ID_ELEMENT_CLS(item_id, changekey) else: if update_fields: diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index 6b24bc6d..282f7e88 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -642,6 +642,8 @@ def test_item_update(self): insert_kwargs = self.get_random_insert_kwargs() insert_kwargs["categories"] = self.categories item = self.ITEM_CLASS(folder=self.test_folder, **insert_kwargs).save() + # Test no-op update + item.save() update_kwargs = self.get_random_update_kwargs(item=item, insert_kwargs=insert_kwargs) if self.ITEM_CLASS in (Contact, DistributionList): # Contact and DistributionList don't support mime_type updates at all From 6f4e63c2f4d493f41161c1e03a06a12311baf195 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sun, 20 Aug 2023 15:12:07 +0200 Subject: [PATCH 372/509] ci: Use Long instead of Integer, to avoid overflow. Refs #171 --- tests/test_folder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_folder.py b/tests/test_folder.py index 0bac9036..5a8d3dd8 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -818,7 +818,7 @@ def test_extended_properties(self): # Test extended properties on folders and folder roots. This extended prop gets the size (in bytes) of a folder class FolderSize(ExtendedProperty): property_tag = 0x0E08 - property_type = "Integer" + property_type = "Long" try: Folder.register("size", FolderSize) From 61450187f558e0e9cb3670f200f0842abd4c824b Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sun, 20 Aug 2023 15:32:46 +0200 Subject: [PATCH 373/509] ci: Only use bulk_update when strictly required --- tests/test_items/test_basics.py | 25 ++++++------------------- tests/test_items/test_calendaritems.py | 11 ++++------- 2 files changed, 10 insertions(+), 26 deletions(-) diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index 282f7e88..04c41d05 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -651,13 +651,8 @@ def test_item_update(self): update_fieldnames = [f for f in update_kwargs if f != "attachments"] for k, v in update_kwargs.items(): setattr(item, k, v) - # Test with generator as argument - update_ids = self.account.bulk_update(items=(i for i in ((item, update_fieldnames),))) - self.assertEqual(len(update_ids), 1) - self.assertEqual(len(update_ids[0]), 2, update_ids) - self.assertEqual(item.id, update_ids[0][0]) # ID should be the same - self.assertNotEqual(item.changekey, update_ids[0][1]) # Changekey should change when item is updated - item = self.get_item_by_id(update_ids[0], folder=self.test_folder) + item.save(update_fields=update_fieldnames) + item = self.get_item_by_id(item, folder=self.test_folder) for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): if not f.supports_version(self.account.version): @@ -727,12 +722,8 @@ def test_item_update_wipe(self): wipe_kwargs[f.name] = None for k, v in wipe_kwargs.items(): setattr(item, k, v) - wipe_ids = self.account.bulk_update([(item, update_fieldnames)]) - self.assertEqual(len(wipe_ids), 1) - self.assertEqual(len(wipe_ids[0]), 2, wipe_ids) - self.assertEqual(item.id, wipe_ids[0][0]) # ID should be the same - self.assertNotEqual(item.changekey, wipe_ids[0][1]) # Changekey should not be the same when item is updated - item = self.get_item_by_id(wipe_ids[0], folder=self.test_folder) + item.save(update_fields=update_fieldnames) + item = self.get_item_by_id(item, folder=self.test_folder) for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): if not f.supports_version(self.account.version): @@ -759,12 +750,8 @@ def test_item_update_extended_properties(self): # Test extern_id = None, which deletes the extended property entirely extern_id = None item.extern_id = extern_id - wipe2_ids = self.account.bulk_update([(item, ["extern_id"])]) - self.assertEqual(len(wipe2_ids), 1) - self.assertEqual(len(wipe2_ids[0]), 2, wipe2_ids) - self.assertEqual(item.id, wipe2_ids[0][0]) # ID must be the same - self.assertNotEqual(item.changekey, wipe2_ids[0][1]) # Changekey must change when item is updated - updated_item = self.get_item_by_id(wipe2_ids[0], folder=self.test_folder) + item.save(update_fields=["extern_id"]) + updated_item = self.get_item_by_id(item, folder=self.test_folder) self.assertEqual(updated_item.extern_id, extern_id) finally: item.__class__.deregister("extern_id") diff --git a/tests/test_items/test_calendaritems.py b/tests/test_items/test_calendaritems.py index 33f36484..edbb356d 100644 --- a/tests/test_items/test_calendaritems.py +++ b/tests/test_items/test_calendaritems.py @@ -508,17 +508,14 @@ def test_get_master_recurrence(self): third_occurrence.delete() # Item is gone from the server, so this should fail def test_invalid_updateitem_items(self): - # Test here because CalendarItem is the only item that has a requiref field with no default + # Test here because CalendarItem is the only item that has a required field with no default item = self.get_test_item().save() - with self.assertRaises(ValueError) as e: - self.account.bulk_update([(item, [])]) - self.assertEqual(e.exception.args[0], "'fieldnames' must not be empty") # Test a field that has is_required=True start = item.start item.start = None with self.assertRaises(ValueError) as e: - self.account.bulk_update([(item, ["start"])]) + item.save(update_fields=["start"]) self.assertEqual(e.exception.args[0], "'start' is a required field with no default") item.start = start @@ -526,13 +523,13 @@ def test_invalid_updateitem_items(self): uid = item.uid item.uid = None with self.assertRaises(ValueError) as e: - self.account.bulk_update([(item, ["uid"])]) + item.save(update_fields=["uid"]) self.assertEqual(e.exception.args[0], "'uid' is a required field and may not be deleted") item.uid = uid item.is_meeting = None with self.assertRaises(ValueError) as e: - self.account.bulk_update([(item, ["is_meeting"])]) + item.save(update_fields=["is_meeting"]) self.assertEqual(e.exception.args[0], "'is_meeting' is a read-only field") def test_meeting_request(self): From 3e15e50e3c432299561c734ae095b50b748d00c4 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sun, 20 Aug 2023 15:37:48 +0200 Subject: [PATCH 374/509] ci: Move all bulk tests to test_bulk.py --- tests/test_items/test_bulk.py | 18 ++++++++++++++++++ tests/test_items/test_messages.py | 11 ----------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/tests/test_items/test_bulk.py b/tests/test_items/test_bulk.py index b1203386..13eb1e6d 100644 --- a/tests/test_items/test_bulk.py +++ b/tests/test_items/test_bulk.py @@ -1,4 +1,5 @@ import datetime +import time from exchangelib.errors import ErrorInvalidChangeKey, ErrorInvalidIdMalformed, ErrorItemNotFound from exchangelib.fields import FieldPath @@ -199,3 +200,20 @@ def test_no_account(self): item.id, item.changekey = res.id, res.changekey item.account = None self.account.bulk_update(items=[(item, ("start",))]) + + +class MessagesBulkMethodTest(BaseItemTest): + TEST_FOLDER = "inbox" + FOLDER_CLASS = Inbox + ITEM_CLASS = Message + + def test_bulk_send(self): + with self.assertRaises(AttributeError): + self.account.bulk_send(ids=[], save_copy=False, copy_to_folder=self.account.trash) + item = self.get_test_item() + item.save() + for res in self.account.bulk_send(ids=[item]): + self.assertEqual(res, True) + time.sleep(10) # Requests are supposed to be transactional, but apparently not... + # By default, sent items are placed in the sent folder + self.assertEqual(self.account.sent.filter(categories__contains=item.categories).count(), 1) diff --git a/tests/test_items/test_messages.py b/tests/test_items/test_messages.py index 4e4f261f..ae8688f8 100644 --- a/tests/test_items/test_messages.py +++ b/tests/test_items/test_messages.py @@ -96,17 +96,6 @@ def test_send_and_copy_to_folder(self): time.sleep(5) # Requests are supposed to be transactional, but apparently not... self.assertEqual(self.account.sent.filter(categories__contains=item.categories).count(), 1) - def test_bulk_send(self): - with self.assertRaises(AttributeError): - self.account.bulk_send(ids=[], save_copy=False, copy_to_folder=self.account.trash) - item = self.get_test_item() - item.save() - for res in self.account.bulk_send(ids=[item]): - self.assertEqual(res, True) - time.sleep(10) # Requests are supposed to be transactional, but apparently not... - # By default, sent items are placed in the sent folder - self.assertEqual(self.account.sent.filter(categories__contains=item.categories).count(), 1) - def test_reply(self): # Test that we can reply to a Message item. EWS only allows items that have been sent to receive a reply item = self.get_test_item() From 191d4837ac560a27df77d6b7740d0460391f068d Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sun, 20 Aug 2023 20:04:14 +0200 Subject: [PATCH 375/509] Revert "ci: Attempt to make item update tests more stable" This reverts commit 336d3a69146859892e4ca3b8d49516ab923104bd. --- exchangelib/items/item.py | 19 +------------------ tests/test_items/test_basics.py | 2 -- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/exchangelib/items/item.py b/exchangelib/items/item.py index 04c2dc6e..f439b746 100644 --- a/exchangelib/items/item.py +++ b/exchangelib/items/item.py @@ -1,5 +1,4 @@ import logging -import time from ..fields import ( AttachmentField, @@ -154,23 +153,7 @@ def save(self, update_fields=None, conflict_resolution=AUTO_RESOLVE, send_meetin # When we update certain fields on a task, the ID may change. A full description is available at # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateitem-operation-task raise ValueError("'id' mismatch in returned update response") - # Check that changekey is different on update. No-op saves will sometimes leave the changekey intact, so - # don't make this a requirement. The test suite has uncovered that item updates are not atomic WTR changekey - # updates, at least on O365. This results in 'ErrorIrresolvableConflict' when updating an item multiple - # times within a short timeframe. Try a couple of times to get a fresh changekey, to make the test more - # stable. - old_changekey = self._id.changekey - if changekey == old_changekey: - log.warning("'changekey' did not change on update") - for i in range(1, 4): - time.sleep(i / 2) - self._id.changekey = None - self.refresh() - if self._id.changekey != old_changekey: - break - else: - self._id.changekey = old_changekey - log.warning("'changekey' still not updated after 3 refreshes") + # Don't check that changekeys are different. No-op saves will sometimes leave the changekey intact self._id = self.ID_ELEMENT_CLS(item_id, changekey) else: if update_fields: diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index 04c41d05..a8b7fe4d 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -642,8 +642,6 @@ def test_item_update(self): insert_kwargs = self.get_random_insert_kwargs() insert_kwargs["categories"] = self.categories item = self.ITEM_CLASS(folder=self.test_folder, **insert_kwargs).save() - # Test no-op update - item.save() update_kwargs = self.get_random_update_kwargs(item=item, insert_kwargs=insert_kwargs) if self.ITEM_CLASS in (Contact, DistributionList): # Contact and DistributionList don't support mime_type updates at all From 6e03d05dce7c86b35b28b00bdab1dc449f1ea9b7 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 21 Aug 2023 08:21:52 +0200 Subject: [PATCH 376/509] ci: New attempt at letting tests survive random ErrorIrresolvableConflict errors --- tests/test_items/test_basics.py | 29 ++++++++++++++++++++------ tests/test_items/test_calendaritems.py | 6 +++--- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index a8b7fe4d..13363518 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -11,6 +11,7 @@ from exchangelib.errors import ( ErrorInvalidPropertySet, ErrorInvalidValueForProperty, + ErrorIrresolvableConflict, ErrorItemNotFound, ErrorPropertyUpdate, ErrorUnsupportedPathForQuery, @@ -246,6 +247,18 @@ def get_item_by_id(self, item, folder=None): _id, changekey = item if isinstance(item, tuple) else (item.id, item.changekey) return (folder or self.account.root).get(id=_id, changekey=changekey) + def safe_save(self, item, **kwargs): + # Sometimes, after somewhere between 2 and 20 updates, Exchange will decide to randomly update the changekey on + # its own. I cannot find any pattern to this, but tests that use the same item over and over to test updates on + # various fields become horribly unstable. This helper method hides that bug. + try: + item.save(**kwargs) + except ErrorIrresolvableConflict: + item.changekey = None + refreshed_item = self.get_item_by_id(item, item.folder) + item.changekey = refreshed_item.changekey + item.save(**kwargs) + class CommonItemTest(BaseItemTest): @classmethod @@ -478,7 +491,7 @@ def test_text_field_settings(self): else: setattr(item, f.name, get_random_string(f.max_length)) try: - item.save(update_fields=[f.name]) + self.safe_save(item, update_fields=[f.name]) except ErrorPropertyUpdate: # Some fields throw this error when updated to a huge value self.assertIn(f.name, ["given_name", "middle_name", "surname"]) @@ -551,9 +564,10 @@ def test_save_and_delete(self): update_kwargs = self.get_random_update_kwargs(item=item, insert_kwargs=insert_kwargs) for k, v in update_kwargs.items(): setattr(item, k, v) - item.save() + self.safe_save(item) for k, v in update_kwargs.items(): - self.assertEqual(getattr(item, k), v, (k, getattr(item, k), v)) + with self.subTest(k=k): + self.assertEqual(getattr(item, k), v, (k, getattr(item, k), v)) # Test that whatever we have locally also matches whatever is in the DB fresh_item = self.get_item_by_id(item, folder=self.test_folder) for f in self.ITEM_CLASS.FIELDS: @@ -562,6 +576,9 @@ def test_save_and_delete(self): if f.is_read_only and old is None: # Some fields are automatically updated server-side continue + if f.name == "_id": + # We test this elsewhere, and changekey is not guaranteed to be the same - see self.safe_save() + continue if f.name == "mime_content": # This will change depending on other contents fields continue @@ -649,7 +666,7 @@ def test_item_update(self): update_fieldnames = [f for f in update_kwargs if f != "attachments"] for k, v in update_kwargs.items(): setattr(item, k, v) - item.save(update_fields=update_fieldnames) + self.safe_save(item, update_fields=update_fieldnames) item = self.get_item_by_id(item, folder=self.test_folder) for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): @@ -720,7 +737,7 @@ def test_item_update_wipe(self): wipe_kwargs[f.name] = None for k, v in wipe_kwargs.items(): setattr(item, k, v) - item.save(update_fields=update_fieldnames) + self.safe_save(item, update_fields=update_fieldnames) item = self.get_item_by_id(item, folder=self.test_folder) for f in self.ITEM_CLASS.FIELDS: with self.subTest(f=f): @@ -748,7 +765,7 @@ def test_item_update_extended_properties(self): # Test extern_id = None, which deletes the extended property entirely extern_id = None item.extern_id = extern_id - item.save(update_fields=["extern_id"]) + self.safe_save(item, update_fields=["extern_id"]) updated_item = self.get_item_by_id(item, folder=self.test_folder) self.assertEqual(updated_item.extern_id, extern_id) finally: diff --git a/tests/test_items/test_calendaritems.py b/tests/test_items/test_calendaritems.py index edbb356d..acf227fb 100644 --- a/tests/test_items/test_calendaritems.py +++ b/tests/test_items/test_calendaritems.py @@ -101,7 +101,7 @@ def test_update_to_non_utc_datetime(self): ] item.start, item.end = dt_start, dt_end item.recurrence.boundary.start = dt_start.date() - item.save() + self.safe_save(item) item.refresh() self.assertEqual(item.start, dt_start) self.assertEqual(item.end, dt_end) @@ -122,7 +122,7 @@ def test_all_day_datetimes(self): self.assertEqual(item.is_all_day, True) self.assertEqual(item.start, start_dt.date()) self.assertEqual(item.end, end_dt.date()) - item.save() # Make sure we can update + self.safe_save(item) # Make sure we can update item.delete() # We are also allowed to assign plain dates as values for all-day items @@ -139,7 +139,7 @@ def test_all_day_datetimes(self): self.assertEqual(item.is_all_day, True) self.assertEqual(item.start, start_dt.date()) self.assertEqual(item.end, end_dt.date()) - item.save() # Make sure we can update + self.safe_save(item) # Make sure we can update def test_view(self): item1 = self.ITEM_CLASS( From 454f431251f000a7e43d0078bcfd77baf5d6496a Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 21 Aug 2023 10:49:16 +0200 Subject: [PATCH 377/509] ci: Add fix for cancel() as well --- tests/test_items/test_basics.py | 10 ++++++++++ tests/test_items/test_calendaritems.py | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index 13363518..6964b7a6 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -259,6 +259,16 @@ def safe_save(self, item, **kwargs): item.changekey = refreshed_item.changekey item.save(**kwargs) + def safe_cancel(self, item): + # See self.safe_save() + try: + item.cancel() + except ErrorIrresolvableConflict: + item.changekey = None + refreshed_item = self.get_item_by_id(item, item.folder) + item.changekey = refreshed_item.changekey + item.cancel() + class CommonItemTest(BaseItemTest): @classmethod diff --git a/tests/test_items/test_calendaritems.py b/tests/test_items/test_calendaritems.py index acf227fb..889209d8 100644 --- a/tests/test_items/test_calendaritems.py +++ b/tests/test_items/test_calendaritems.py @@ -43,7 +43,7 @@ def match_cat(self, i): def test_cancel(self): item = self.get_test_item().save() try: - res = item.cancel() # Returns (id, changekey) of cancelled item + res = self.safe_cancel(item) # Returns (id, changekey) of cancelled item except ErrorInvalidRecipients: # Does not always work in a single-account setup pass @@ -51,7 +51,7 @@ def test_cancel(self): self.assertIsInstance(res, BulkCreateResult) with self.assertRaises(ErrorItemNotFound): # Item is already cancelled - item.cancel() + self.safe_cancel(item) def test_updating_timestamps(self): # Test that we can update an item without changing anything, and maintain the hidden timezone fields as local From 63a70df804054d875e25474468513873b7f72605 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 21 Aug 2023 13:27:49 +0200 Subject: [PATCH 378/509] fix: Fix shared-folder queysets based on DistinguishedFolderId. Refs #1202 --- exchangelib/folders/base.py | 4 ++-- exchangelib/services/common.py | 16 ++++++---------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 819f799f..4c2cc92a 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -890,7 +890,7 @@ def clean(self, version=None): @classmethod def from_xml_with_root(cls, elem, root): - folder = cls.from_xml(elem=elem, account=root.account if root else None) + folder = cls.from_xml(elem=elem, account=root.account) folder_cls = cls if cls == Folder: # We were called on the generic Folder class. Try to find a more specific class to return objects as. @@ -909,7 +909,7 @@ def from_xml_with_root(cls, elem, root): # # The returned XML may contain neither folder class nor name. In that case, we default to the generic # Folder class. - if folder.name and root: + if folder.name: with suppress(KeyError): # TODO: fld_class.LOCALIZED_NAMES is most definitely neither complete nor authoritative folder_cls = root.folder_cls_from_folder_name( diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 0cb2977b..0e8c0c3c 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -977,8 +977,9 @@ def parse_folder_elem(elem, folder, account): f = folder.from_xml_with_root(elem=elem, root=folder.root) f._distinguished_id = folder._distinguished_id elif isinstance(folder, DistinguishedFolderId): - # We don't know the root, and we can't assume account.root because this may be a shared folder belonging to a - # different mailbox. + # We don't know the root or even account, but we need to attach the folder to something if we want to make + # future requests with this folder. Use 'account' but make sure to always use the distinguished folder ID going + # forward, instead of referencing anything connected to 'account'. roots = (Root, ArchiveRoot, PublicFoldersRoot) for cls in roots + tuple(chain(*(r.WELLKNOWN_FOLDERS for r in roots))): if cls.DISTINGUISHED_FOLDER_ID == folder.id: @@ -986,15 +987,10 @@ def parse_folder_elem(elem, folder, account): break else: raise ValueError(f"Unknown distinguished folder ID: {folder.id}") - if folder.mailbox and folder.mailbox.email_address != account.primary_smtp_address: - # Distinguished folder points to a different account. Don't attach the wrong account to the returned folder. - external_account = True + if folder_cls in roots: + f = folder_cls.from_xml(elem=elem, account=account) else: - external_account = False - if cls in roots: - f = folder_cls.from_xml(elem=elem, account=None if external_account else account) - else: - f = folder_cls.from_xml_with_root(elem=elem, root=None if external_account else account.root) + f = folder_cls.from_xml_with_root(elem=elem, root=account.root) f._distinguished_id = folder else: # 'folder' is a generic FolderId instance. We don't know the root so assume account.root. From 66ee68f00875f787ea569917eb91bad10d26f6aa Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 21 Aug 2023 14:37:10 +0200 Subject: [PATCH 379/509] fix: Add one more DNS exception type --- exchangelib/autodiscover/discovery.py | 1 + setup.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/exchangelib/autodiscover/discovery.py b/exchangelib/autodiscover/discovery.py index 7024695d..d573fd9f 100644 --- a/exchangelib/autodiscover/discovery.py +++ b/exchangelib/autodiscover/discovery.py @@ -17,6 +17,7 @@ DNS_LOOKUP_ERRORS = ( dns.name.EmptyLabel, + dns.resolver.LifetimeTimeout, dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers, diff --git a/setup.py b/setup.py index 15bcf2df..933e13ff 100755 --- a/setup.py +++ b/setup.py @@ -43,7 +43,7 @@ def read(file_name): 'backports.zoneinfo;python_version<"3.9"', "cached_property", "defusedxml>=0.6.0", - "dnspython>=2.0.0", + "dnspython>=2.2.0", "isodate", "lxml>3.0", "oauthlib", From 12dbdfa7bddb62f5194e5883404d7135e5f68aaa Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 22 Aug 2023 12:05:13 +0200 Subject: [PATCH 380/509] docs: Update docs --- docs/exchangelib/autodiscover/discovery.html | 7 + docs/exchangelib/autodiscover/index.html | 24 +-- docs/exchangelib/autodiscover/protocol.html | 35 ++-- docs/exchangelib/credentials.html | 142 ++++++------- docs/exchangelib/ewsdatetime.html | 6 + docs/exchangelib/folders/base.html | 131 ++++++------ docs/exchangelib/folders/index.html | 192 +++++------------- docs/exchangelib/folders/known_folders.html | 85 -------- docs/exchangelib/folders/roots.html | 34 ++-- docs/exchangelib/index.html | 153 +++++--------- docs/exchangelib/items/calendar_item.html | 12 +- docs/exchangelib/items/index.html | 67 +++--- docs/exchangelib/items/item.html | 1 + docs/exchangelib/items/message.html | 96 ++++----- docs/exchangelib/properties.html | 92 ++++++--- docs/exchangelib/services/common.html | 48 ++--- docs/exchangelib/services/create_folder.html | 17 +- docs/exchangelib/services/find_folder.html | 6 +- .../services/get_user_settings.html | 42 ++-- docs/exchangelib/services/index.html | 39 ++-- docs/exchangelib/services/update_folder.html | 12 +- docs/exchangelib/services/update_item.html | 6 +- docs/exchangelib/util.html | 62 ++++-- docs/exchangelib/version.html | 38 ++-- 24 files changed, 606 insertions(+), 741 deletions(-) diff --git a/docs/exchangelib/autodiscover/discovery.html b/docs/exchangelib/autodiscover/discovery.html index 1b93f3bf..ffcf3659 100644 --- a/docs/exchangelib/autodiscover/discovery.html +++ b/docs/exchangelib/autodiscover/discovery.html @@ -45,6 +45,7 @@

    diff --git a/docs/exchangelib/autodiscover/index.html b/docs/exchangelib/autodiscover/index.html index d81da24b..2caa88ee 100644 --- a/docs/exchangelib/autodiscover/index.html +++ b/docs/exchangelib/autodiscover/index.html @@ -318,18 +318,17 @@

    Methods

    # Autodetect authentication type. return get_autodiscover_authtype(protocol=self) - def get_user_settings(self, user): - return GetUserSettings(protocol=self).get( - users=[user], - settings=[ + def get_user_settings(self, user, settings=None): + if not settings: + settings = [ "user_dn", "mailbox_dn", "user_display_name", "auto_discover_smtp_address", "external_ews_url", "ews_supported_schemas", - ], - ) + ] + return GetUserSettings(protocol=self).get(users=[user], settings=settings) def dummy_xml(self): # Generate a valid EWS request for SOAP autodiscovery @@ -407,7 +406,7 @@

    Methods

    -def get_user_settings(self, user) +def get_user_settings(self, user, settings=None)
    @@ -415,18 +414,17 @@

    Methods

    Expand source code -
    def get_user_settings(self, user):
    -    return GetUserSettings(protocol=self).get(
    -        users=[user],
    -        settings=[
    +
    def get_user_settings(self, user, settings=None):
    +    if not settings:
    +        settings = [
                 "user_dn",
                 "mailbox_dn",
                 "user_display_name",
                 "auto_discover_smtp_address",
                 "external_ews_url",
                 "ews_supported_schemas",
    -        ],
    -    )
    + ] + return GetUserSettings(protocol=self).get(users=[user], settings=settings)
    diff --git a/docs/exchangelib/autodiscover/protocol.html b/docs/exchangelib/autodiscover/protocol.html index 307292cb..0bddba55 100644 --- a/docs/exchangelib/autodiscover/protocol.html +++ b/docs/exchangelib/autodiscover/protocol.html @@ -63,18 +63,17 @@

    Module exchangelib.autodiscover.protocol

    # Autodetect authentication type. return get_autodiscover_authtype(protocol=self) - def get_user_settings(self, user): - return GetUserSettings(protocol=self).get( - users=[user], - settings=[ + def get_user_settings(self, user, settings=None): + if not settings: + settings = [ "user_dn", "mailbox_dn", "user_display_name", "auto_discover_smtp_address", "external_ews_url", "ews_supported_schemas", - ], - ) + ] + return GetUserSettings(protocol=self).get(users=[user], settings=settings) def dummy_xml(self): # Generate a valid EWS request for SOAP autodiscovery @@ -138,18 +137,17 @@

    Classes

    # Autodetect authentication type. return get_autodiscover_authtype(protocol=self) - def get_user_settings(self, user): - return GetUserSettings(protocol=self).get( - users=[user], - settings=[ + def get_user_settings(self, user, settings=None): + if not settings: + settings = [ "user_dn", "mailbox_dn", "user_display_name", "auto_discover_smtp_address", "external_ews_url", "ews_supported_schemas", - ], - ) + ] + return GetUserSettings(protocol=self).get(users=[user], settings=settings) def dummy_xml(self): # Generate a valid EWS request for SOAP autodiscovery @@ -227,7 +225,7 @@

    Methods

    -def get_user_settings(self, user) +def get_user_settings(self, user, settings=None)
    @@ -235,18 +233,17 @@

    Methods

    Expand source code -
    def get_user_settings(self, user):
    -    return GetUserSettings(protocol=self).get(
    -        users=[user],
    -        settings=[
    +
    def get_user_settings(self, user, settings=None):
    +    if not settings:
    +        settings = [
                 "user_dn",
                 "mailbox_dn",
                 "user_display_name",
                 "auto_discover_smtp_address",
                 "external_ews_url",
                 "ews_supported_schemas",
    -        ],
    -    )
    + ] + return GetUserSettings(protocol=self).get(users=[user], settings=settings)
    diff --git a/docs/exchangelib/credentials.html b/docs/exchangelib/credentials.html index f4177040..c890a668 100644 --- a/docs/exchangelib/credentials.html +++ b/docs/exchangelib/credentials.html @@ -167,18 +167,36 @@

    Module exchangelib.credentials

    return hash(tuple(res)) @property - @abc.abstractmethod def token_url(self): """The URL to request tokens from""" + # We may not know (or need) the Microsoft tenant ID. If not, use common/ to let Microsoft select the appropriate + # tenant for the provided authorization code or refresh token. + return f"https://login.microsoftonline.com/{self.tenant_id or 'common'}/oauth2/v2.0/token" # nosec @property - @abc.abstractmethod def scope(self): """The scope we ask for the token to have""" + return ["https://outlook.office365.com/.default"] def session_params(self): """Extra parameters to use when creating the session""" - return {"token": self.access_token} # Token may be None + res = {"token": self.access_token} # Token may be None + if self.client_id and self.client_secret: + # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other + # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to + # refresh the token (that covers cases where the caller doesn't have access to the client secret but + # is working with a service that can provide it refreshed tokens on a limited basis). + res.update( + { + "auto_refresh_kwargs": { + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + "auto_refresh_url": self.token_url, + "token_updater": self.on_token_auto_refreshed, + } + ) + return res def token_params(self): """Extra parameters when requesting the token""" @@ -224,14 +242,6 @@

    Module exchangelib.credentials

    the associated auth code grant type for multi-tenant applications. """ - @property - def token_url(self): - return f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token" - - @property - def scope(self): - return ["https://outlook.office365.com/.default"] - @threaded_cached_property def client(self): return oauthlib.oauth2.BackendApplicationClient(client_id=self.client_id) @@ -299,37 +309,12 @@

    Module exchangelib.credentials

    super().__init__(**kwargs) self.authorization_code = authorization_code - @property - def token_url(self): - # We don't know (or need) the Microsoft tenant ID. Use common/ to let Microsoft select the appropriate - # tenant for the provided authorization code or refresh token. - return "https://login.microsoftonline.com/common/oauth2/v2.0/token" # nosec - @property def scope(self): res = super().scope res.append("offline_access") return res - def session_params(self): - res = super().session_params() - if self.client_id and self.client_secret: - # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other - # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to - # refresh the token (that covers cases where the caller doesn't have access to the client secret but - # is working with a service that can provide it refreshed tokens on a limited basis). - res.update( - { - "auto_refresh_kwargs": { - "client_id": self.client_id, - "client_secret": self.client_secret, - }, - "auto_refresh_url": self.token_url, - "token_updater": self.on_token_auto_refreshed, - } - ) - return res - def token_params(self): res = super().token_params() res["code"] = self.authorization_code # Auth code may be None @@ -476,18 +461,36 @@

    Subclasses

    return hash(tuple(res)) @property - @abc.abstractmethod def token_url(self): """The URL to request tokens from""" + # We may not know (or need) the Microsoft tenant ID. If not, use common/ to let Microsoft select the appropriate + # tenant for the provided authorization code or refresh token. + return f"https://login.microsoftonline.com/{self.tenant_id or 'common'}/oauth2/v2.0/token" # nosec @property - @abc.abstractmethod def scope(self): """The scope we ask for the token to have""" + return ["https://outlook.office365.com/.default"] def session_params(self): """Extra parameters to use when creating the session""" - return {"token": self.access_token} # Token may be None + res = {"token": self.access_token} # Token may be None + if self.client_id and self.client_secret: + # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other + # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to + # refresh the token (that covers cases where the caller doesn't have access to the client secret but + # is working with a service that can provide it refreshed tokens on a limited basis). + res.update( + { + "auto_refresh_kwargs": { + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + "auto_refresh_url": self.token_url, + "token_updater": self.on_token_auto_refreshed, + } + ) + return res def token_params(self): """Extra parameters when requesting the token""" @@ -589,9 +592,9 @@

    Instance variables

    Expand source code
    @property
    -@abc.abstractmethod
     def scope(self):
    -    """The scope we ask for the token to have"""
    + """The scope we ask for the token to have""" + return ["https://outlook.office365.com/.default"]
    var token_url
    @@ -602,9 +605,11 @@

    Instance variables

    Expand source code
    @property
    -@abc.abstractmethod
     def token_url(self):
    -    """The URL to request tokens from"""
    + """The URL to request tokens from""" + # We may not know (or need) the Microsoft tenant ID. If not, use common/ to let Microsoft select the appropriate + # tenant for the provided authorization code or refresh token. + return f"https://login.microsoftonline.com/{self.tenant_id or 'common'}/oauth2/v2.0/token" # nosec
    @@ -672,7 +677,23 @@

    Methods

    def session_params(self):
         """Extra parameters to use when creating the session"""
    -    return {"token": self.access_token}  # Token may be None
    + res = {"token": self.access_token} # Token may be None + if self.client_id and self.client_secret: + # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other + # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to + # refresh the token (that covers cases where the caller doesn't have access to the client secret but + # is working with a service that can provide it refreshed tokens on a limited basis). + res.update( + { + "auto_refresh_kwargs": { + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + "auto_refresh_url": self.token_url, + "token_updater": self.on_token_auto_refreshed, + } + ) + return res
    @@ -829,37 +850,12 @@

    Class variables

    super().__init__(**kwargs) self.authorization_code = authorization_code - @property - def token_url(self): - # We don't know (or need) the Microsoft tenant ID. Use common/ to let Microsoft select the appropriate - # tenant for the provided authorization code or refresh token. - return "https://login.microsoftonline.com/common/oauth2/v2.0/token" # nosec - @property def scope(self): res = super().scope res.append("offline_access") return res - def session_params(self): - res = super().session_params() - if self.client_id and self.client_secret: - # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other - # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to - # refresh the token (that covers cases where the caller doesn't have access to the client secret but - # is working with a service that can provide it refreshed tokens on a limited basis). - res.update( - { - "auto_refresh_kwargs": { - "client_id": self.client_id, - "client_secret": self.client_secret, - }, - "auto_refresh_url": self.token_url, - "token_updater": self.on_token_auto_refreshed, - } - ) - return res - def token_params(self): res = super().token_params() res["code"] = self.authorization_code # Auth code may be None @@ -933,14 +929,6 @@

    Inherited members

    the associated auth code grant type for multi-tenant applications. """ - @property - def token_url(self): - return f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token" - - @property - def scope(self): - return ["https://outlook.office365.com/.default"] - @threaded_cached_property def client(self): return oauthlib.oauth2.BackendApplicationClient(client_id=self.client_id)
    diff --git a/docs/exchangelib/ewsdatetime.html b/docs/exchangelib/ewsdatetime.html index 02a1824d..af383698 100644 --- a/docs/exchangelib/ewsdatetime.html +++ b/docs/exchangelib/ewsdatetime.html @@ -257,6 +257,9 @@

    Module exchangelib.ewsdatetime

    instance.ms_name = "" return instance + def __hash__(self): + return hash(self.key) + def __eq__(self, other): # Microsoft time zones are less granular than IANA, so an EWSTimeZone created from 'Europe/Copenhagen' may # return from the server as 'Europe/Copenhagen'. We're catering for Microsoft here, so base equality on the @@ -896,6 +899,9 @@

    Methods

    instance.ms_name = "" return instance + def __hash__(self): + return hash(self.key) + def __eq__(self, other): # Microsoft time zones are less granular than IANA, so an EWSTimeZone created from 'Europe/Copenhagen' may # return from the server as 'Europe/Copenhagen'. We're catering for Microsoft here, so base equality on the diff --git a/docs/exchangelib/folders/base.html b/docs/exchangelib/folders/base.html index bc4924a9..2067ceb1 100644 --- a/docs/exchangelib/folders/base.html +++ b/docs/exchangelib/folders/base.html @@ -60,7 +60,6 @@

    Module exchangelib.folders.base

    DistinguishedFolderId, EWSMeta, FolderId, - Mailbox, ParentFolderId, UserConfiguration, UserConfigurationName, @@ -105,6 +104,7 @@

    Module exchangelib.folders.base

    ID_ELEMENT_CLS = FolderId _id = IdElementField(field_uri="folder:FolderId", value_cls=ID_ELEMENT_CLS) + _distinguished_id = IdElementField(field_uri="folder:FolderId", value_cls=DistinguishedFolderId) parent_folder_id = EWSElementField(field_uri="folder:ParentFolderId", value_cls=ParentFolderId, is_read_only=True) folder_class = CharField(field_uri="folder:FolderClass", is_required_after_save=True) name = CharField(field_uri="folder:DisplayName") @@ -112,13 +112,12 @@

    Module exchangelib.folders.base

    child_folder_count = IntegerField(field_uri="folder:ChildFolderCount", is_read_only=True) unread_count = IntegerField(field_uri="folder:UnreadCount", is_read_only=True) - __slots__ = "is_distinguished", "item_sync_state", "folder_sync_state" + __slots__ = "item_sync_state", "folder_sync_state" # Used to register extended properties INSERT_AFTER_FIELD = "child_folder_count" def __init__(self, **kwargs): - self.is_distinguished = kwargs.pop("is_distinguished", False) self.item_sync_state = kwargs.pop("item_sync_state", None) self.folder_sync_state = kwargs.pop("folder_sync_state", None) super().__init__(**kwargs) @@ -138,6 +137,10 @@

    Module exchangelib.folders.base

    def parent(self): """Return the parent folder of this folder""" + @property + def is_distinguished(self): + return self._distinguished_id or (self.DISTINGUISHED_FOLDER_ID and not self._id) + @property def is_deletable(self): return not self.is_distinguished @@ -176,7 +179,7 @@

    Module exchangelib.folders.base

    return FolderCollection(account=self.account, folders=self._walk()) def _glob(self, pattern): - split_pattern = pattern.rsplit("/", 1) + split_pattern = pattern.split("/", maxsplit=1) head, tail = (split_pattern[0], None) if len(split_pattern) == 1 else split_pattern if head == "": # We got an absolute path. Restart globbing at root @@ -463,9 +466,9 @@

    Module exchangelib.folders.base

    log.warning("Cannot wipe recoverable items folder %s", self) return log.warning("Wiping %s", self) - has_distinguished_subfolders = any(f.is_distinguished for f in self.children) + has_non_deletable_subfolders = any(not f.is_deletable for f in self.children) try: - if has_distinguished_subfolders: + if has_non_deletable_subfolders: self.empty() else: self.empty(delete_sub_folders=True) @@ -474,7 +477,7 @@

    Module exchangelib.folders.base

    return except DELETE_FOLDER_ERRORS: try: - if has_distinguished_subfolders: + if has_non_deletable_subfolders: raise # We already tried this self.empty() except DELETE_FOLDER_ERRORS: @@ -518,17 +521,16 @@

    Module exchangelib.folders.base

    return kwargs def to_id(self): - if self.is_distinguished: - # Don't add the changekey here. When modifying folder content, we usually don't care if others have changed - # the folder content since we fetched the changekey. - if self.account: - return DistinguishedFolderId( - id=self.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address) - ) - return DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID) - if self.id: - return FolderId(id=self.id, changekey=self.changekey) - raise ValueError("Must be a distinguished folder or have an ID") + # Use self._distinguished_id as-is if we have it. This could be a DistinguishedFolderId with a mailbox pointing + # to a shared mailbox. + if self._distinguished_id: + return self._distinguished_id + if self._id: + return self._id + if not self.DISTINGUISHED_FOLDER_ID: + raise ValueError(f"{self} must be a distinguished folder or have an ID") + self._distinguished_id = DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID) + return self._distinguished_id @classmethod def resolve(cls, account, folder): @@ -803,7 +805,8 @@

    Module exchangelib.folders.base

    if other == ".": return self for c in self.children: - if c.name == other: + # Folders are case-insensitive server-side. Let's do that here as well. + if c.name.lower() == other.lower(): return c raise ErrorFolderNotFound(f"No subfolder with name {other!r}") @@ -883,9 +886,7 @@

    Module exchangelib.folders.base

    :return: """ try: - return cls.resolve( - account=root.account, folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) - ) + return cls.resolve(account=root.account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") @@ -995,6 +996,7 @@

    Classes

    ID_ELEMENT_CLS = FolderId _id = IdElementField(field_uri="folder:FolderId", value_cls=ID_ELEMENT_CLS) + _distinguished_id = IdElementField(field_uri="folder:FolderId", value_cls=DistinguishedFolderId) parent_folder_id = EWSElementField(field_uri="folder:ParentFolderId", value_cls=ParentFolderId, is_read_only=True) folder_class = CharField(field_uri="folder:FolderClass", is_required_after_save=True) name = CharField(field_uri="folder:DisplayName") @@ -1002,13 +1004,12 @@

    Classes

    child_folder_count = IntegerField(field_uri="folder:ChildFolderCount", is_read_only=True) unread_count = IntegerField(field_uri="folder:UnreadCount", is_read_only=True) - __slots__ = "is_distinguished", "item_sync_state", "folder_sync_state" + __slots__ = "item_sync_state", "folder_sync_state" # Used to register extended properties INSERT_AFTER_FIELD = "child_folder_count" def __init__(self, **kwargs): - self.is_distinguished = kwargs.pop("is_distinguished", False) self.item_sync_state = kwargs.pop("item_sync_state", None) self.folder_sync_state = kwargs.pop("folder_sync_state", None) super().__init__(**kwargs) @@ -1028,6 +1029,10 @@

    Classes

    def parent(self): """Return the parent folder of this folder""" + @property + def is_distinguished(self): + return self._distinguished_id or (self.DISTINGUISHED_FOLDER_ID and not self._id) + @property def is_deletable(self): return not self.is_distinguished @@ -1066,7 +1071,7 @@

    Classes

    return FolderCollection(account=self.account, folders=self._walk()) def _glob(self, pattern): - split_pattern = pattern.rsplit("/", 1) + split_pattern = pattern.split("/", maxsplit=1) head, tail = (split_pattern[0], None) if len(split_pattern) == 1 else split_pattern if head == "": # We got an absolute path. Restart globbing at root @@ -1353,9 +1358,9 @@

    Classes

    log.warning("Cannot wipe recoverable items folder %s", self) return log.warning("Wiping %s", self) - has_distinguished_subfolders = any(f.is_distinguished for f in self.children) + has_non_deletable_subfolders = any(not f.is_deletable for f in self.children) try: - if has_distinguished_subfolders: + if has_non_deletable_subfolders: self.empty() else: self.empty(delete_sub_folders=True) @@ -1364,7 +1369,7 @@

    Classes

    return except DELETE_FOLDER_ERRORS: try: - if has_distinguished_subfolders: + if has_non_deletable_subfolders: raise # We already tried this self.empty() except DELETE_FOLDER_ERRORS: @@ -1408,17 +1413,16 @@

    Classes

    return kwargs def to_id(self): - if self.is_distinguished: - # Don't add the changekey here. When modifying folder content, we usually don't care if others have changed - # the folder content since we fetched the changekey. - if self.account: - return DistinguishedFolderId( - id=self.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address) - ) - return DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID) - if self.id: - return FolderId(id=self.id, changekey=self.changekey) - raise ValueError("Must be a distinguished folder or have an ID") + # Use self._distinguished_id as-is if we have it. This could be a DistinguishedFolderId with a mailbox pointing + # to a shared mailbox. + if self._distinguished_id: + return self._distinguished_id + if self._id: + return self._id + if not self.DISTINGUISHED_FOLDER_ID: + raise ValueError(f"{self} must be a distinguished folder or have an ID") + self._distinguished_id = DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID) + return self._distinguished_id @classmethod def resolve(cls, account, folder): @@ -1693,7 +1697,8 @@

    Classes

    if other == ".": return self for c in self.children: - if c.name == other: + # Folders are case-insensitive server-side. Let's do that here as well. + if c.name.lower() == other.lower(): return c raise ErrorFolderNotFound(f"No subfolder with name {other!r}") @@ -2025,7 +2030,15 @@

    Instance variables

    var is_distinguished
    -

    Return an attribute of instance, which is of type owner.

    +
    +
    + +Expand source code + +
    @property
    +def is_distinguished(self):
    +    return self._distinguished_id or (self.DISTINGUISHED_FOLDER_ID and not self._id)
    +
    var item_sync_state
    @@ -2688,17 +2701,16 @@

    Methods

    Expand source code
    def to_id(self):
    -    if self.is_distinguished:
    -        # Don't add the changekey here. When modifying folder content, we usually don't care if others have changed
    -        # the folder content since we fetched the changekey.
    -        if self.account:
    -            return DistinguishedFolderId(
    -                id=self.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address)
    -            )
    -        return DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID)
    -    if self.id:
    -        return FolderId(id=self.id, changekey=self.changekey)
    -    raise ValueError("Must be a distinguished folder or have an ID")
    + # Use self._distinguished_id as-is if we have it. This could be a DistinguishedFolderId with a mailbox pointing + # to a shared mailbox. + if self._distinguished_id: + return self._distinguished_id + if self._id: + return self._id + if not self.DISTINGUISHED_FOLDER_ID: + raise ValueError(f"{self} must be a distinguished folder or have an ID") + self._distinguished_id = DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID) + return self._distinguished_id
    @@ -2851,9 +2863,9 @@

    Methods

    log.warning("Cannot wipe recoverable items folder %s", self) return log.warning("Wiping %s", self) - has_distinguished_subfolders = any(f.is_distinguished for f in self.children) + has_non_deletable_subfolders = any(not f.is_deletable for f in self.children) try: - if has_distinguished_subfolders: + if has_non_deletable_subfolders: self.empty() else: self.empty(delete_sub_folders=True) @@ -2862,7 +2874,7 @@

    Methods

    return except DELETE_FOLDER_ERRORS: try: - if has_distinguished_subfolders: + if has_non_deletable_subfolders: raise # We already tried this self.empty() except DELETE_FOLDER_ERRORS: @@ -2981,9 +2993,7 @@

    Inherited members

    :return: """ try: - return cls.resolve( - account=root.account, folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) - ) + return cls.resolve(account=root.account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") @@ -3186,9 +3196,7 @@

    Static methods

    :return: """ try: - return cls.resolve( - account=root.account, folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) - ) + return cls.resolve(account=root.account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}")
    @@ -3241,7 +3249,6 @@

    Inherited members

  • get
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • diff --git a/docs/exchangelib/folders/index.html b/docs/exchangelib/folders/index.html index 9511b59c..fc2e7e8c 100644 --- a/docs/exchangelib/folders/index.html +++ b/docs/exchangelib/folders/index.html @@ -315,7 +315,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -394,7 +393,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -472,7 +470,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -542,7 +539,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -617,7 +613,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -692,7 +687,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -767,7 +761,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -842,7 +835,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -917,7 +909,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -992,7 +983,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -1067,7 +1057,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -1150,7 +1139,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -1227,7 +1215,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -1279,6 +1266,7 @@

    Inherited members

    ID_ELEMENT_CLS = FolderId _id = IdElementField(field_uri="folder:FolderId", value_cls=ID_ELEMENT_CLS) + _distinguished_id = IdElementField(field_uri="folder:FolderId", value_cls=DistinguishedFolderId) parent_folder_id = EWSElementField(field_uri="folder:ParentFolderId", value_cls=ParentFolderId, is_read_only=True) folder_class = CharField(field_uri="folder:FolderClass", is_required_after_save=True) name = CharField(field_uri="folder:DisplayName") @@ -1286,13 +1274,12 @@

    Inherited members

    child_folder_count = IntegerField(field_uri="folder:ChildFolderCount", is_read_only=True) unread_count = IntegerField(field_uri="folder:UnreadCount", is_read_only=True) - __slots__ = "is_distinguished", "item_sync_state", "folder_sync_state" + __slots__ = "item_sync_state", "folder_sync_state" # Used to register extended properties INSERT_AFTER_FIELD = "child_folder_count" def __init__(self, **kwargs): - self.is_distinguished = kwargs.pop("is_distinguished", False) self.item_sync_state = kwargs.pop("item_sync_state", None) self.folder_sync_state = kwargs.pop("folder_sync_state", None) super().__init__(**kwargs) @@ -1312,6 +1299,10 @@

    Inherited members

    def parent(self): """Return the parent folder of this folder""" + @property + def is_distinguished(self): + return self._distinguished_id or (self.DISTINGUISHED_FOLDER_ID and not self._id) + @property def is_deletable(self): return not self.is_distinguished @@ -1350,7 +1341,7 @@

    Inherited members

    return FolderCollection(account=self.account, folders=self._walk()) def _glob(self, pattern): - split_pattern = pattern.rsplit("/", 1) + split_pattern = pattern.split("/", maxsplit=1) head, tail = (split_pattern[0], None) if len(split_pattern) == 1 else split_pattern if head == "": # We got an absolute path. Restart globbing at root @@ -1637,9 +1628,9 @@

    Inherited members

    log.warning("Cannot wipe recoverable items folder %s", self) return log.warning("Wiping %s", self) - has_distinguished_subfolders = any(f.is_distinguished for f in self.children) + has_non_deletable_subfolders = any(not f.is_deletable for f in self.children) try: - if has_distinguished_subfolders: + if has_non_deletable_subfolders: self.empty() else: self.empty(delete_sub_folders=True) @@ -1648,7 +1639,7 @@

    Inherited members

    return except DELETE_FOLDER_ERRORS: try: - if has_distinguished_subfolders: + if has_non_deletable_subfolders: raise # We already tried this self.empty() except DELETE_FOLDER_ERRORS: @@ -1692,17 +1683,16 @@

    Inherited members

    return kwargs def to_id(self): - if self.is_distinguished: - # Don't add the changekey here. When modifying folder content, we usually don't care if others have changed - # the folder content since we fetched the changekey. - if self.account: - return DistinguishedFolderId( - id=self.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address) - ) - return DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID) - if self.id: - return FolderId(id=self.id, changekey=self.changekey) - raise ValueError("Must be a distinguished folder or have an ID") + # Use self._distinguished_id as-is if we have it. This could be a DistinguishedFolderId with a mailbox pointing + # to a shared mailbox. + if self._distinguished_id: + return self._distinguished_id + if self._id: + return self._id + if not self.DISTINGUISHED_FOLDER_ID: + raise ValueError(f"{self} must be a distinguished folder or have an ID") + self._distinguished_id = DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID) + return self._distinguished_id @classmethod def resolve(cls, account, folder): @@ -1977,7 +1967,8 @@

    Inherited members

    if other == ".": return self for c in self.children: - if c.name == other: + # Folders are case-insensitive server-side. Let's do that here as well. + if c.name.lower() == other.lower(): return c raise ErrorFolderNotFound(f"No subfolder with name {other!r}") @@ -2309,7 +2300,15 @@

    Instance variables

    var is_distinguished
    -

    Return an attribute of instance, which is of type owner.

    +
    +
    + +Expand source code + +
    @property
    +def is_distinguished(self):
    +    return self._distinguished_id or (self.DISTINGUISHED_FOLDER_ID and not self._id)
    +
    var item_sync_state
    @@ -2972,17 +2971,16 @@

    Methods

    Expand source code
    def to_id(self):
    -    if self.is_distinguished:
    -        # Don't add the changekey here. When modifying folder content, we usually don't care if others have changed
    -        # the folder content since we fetched the changekey.
    -        if self.account:
    -            return DistinguishedFolderId(
    -                id=self.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address)
    -            )
    -        return DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID)
    -    if self.id:
    -        return FolderId(id=self.id, changekey=self.changekey)
    -    raise ValueError("Must be a distinguished folder or have an ID")
    + # Use self._distinguished_id as-is if we have it. This could be a DistinguishedFolderId with a mailbox pointing + # to a shared mailbox. + if self._distinguished_id: + return self._distinguished_id + if self._id: + return self._id + if not self.DISTINGUISHED_FOLDER_ID: + raise ValueError(f"{self} must be a distinguished folder or have an ID") + self._distinguished_id = DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID) + return self._distinguished_id
    @@ -3135,9 +3133,9 @@

    Methods

    log.warning("Cannot wipe recoverable items folder %s", self) return log.warning("Wiping %s", self) - has_distinguished_subfolders = any(f.is_distinguished for f in self.children) + has_non_deletable_subfolders = any(not f.is_deletable for f in self.children) try: - if has_distinguished_subfolders: + if has_non_deletable_subfolders: self.empty() else: self.empty(delete_sub_folders=True) @@ -3146,7 +3144,7 @@

    Methods

    return except DELETE_FOLDER_ERRORS: try: - if has_distinguished_subfolders: + if has_non_deletable_subfolders: raise # We already tried this self.empty() except DELETE_FOLDER_ERRORS: @@ -3252,7 +3250,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -3368,7 +3365,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -3440,7 +3436,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -3517,7 +3512,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -3601,7 +3595,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -3676,7 +3669,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -3782,7 +3774,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -3857,7 +3848,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -3934,7 +3924,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -4003,7 +3992,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -4080,7 +4068,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -4152,7 +4139,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -4247,7 +4233,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -4322,7 +4307,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -4474,7 +4458,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -4560,7 +4543,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -4632,7 +4614,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -4712,7 +4693,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -4790,7 +4770,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -4880,9 +4859,7 @@

    Inherited members

    :return: """ try: - return cls.resolve( - account=root.account, folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) - ) + return cls.resolve(account=root.account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") @@ -5085,9 +5062,7 @@

    Static methods

    :return: """ try: - return cls.resolve( - account=root.account, folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) - ) + return cls.resolve(account=root.account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}")
    @@ -5140,7 +5115,6 @@

    Inherited members

  • get
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -6771,7 +6745,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -6843,7 +6816,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -6922,7 +6894,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -7006,7 +6977,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -7083,7 +7053,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -7163,7 +7132,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -7249,7 +7217,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -7324,7 +7291,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -7410,7 +7376,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -7485,7 +7450,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -7557,7 +7521,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -7629,7 +7592,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -7711,7 +7673,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -7792,7 +7753,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -7872,7 +7832,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -7950,7 +7909,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -8104,7 +8062,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -8187,7 +8144,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -8273,7 +8229,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -8350,7 +8305,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -8427,7 +8381,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -8504,7 +8457,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -8587,7 +8539,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -8662,7 +8613,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -8830,7 +8780,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -8910,7 +8859,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -8987,7 +8935,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -9073,7 +9020,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -9148,7 +9094,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -9223,7 +9168,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -9298,7 +9242,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -9373,7 +9316,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -9442,7 +9384,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -9519,7 +9460,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -9666,7 +9606,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -9788,9 +9727,7 @@

    Inherited members

    if not cls.DISTINGUISHED_FOLDER_ID: raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: - return cls.resolve( - account=account, folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) - ) + return cls.resolve(account=account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") @@ -9812,9 +9749,9 @@

    Inherited members

    log.debug("Requesting distinguished %s folder explicitly", folder_cls) return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: - # Maybe we just don't have GetFolder access? Try FindItems instead + # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) - fld = folder_cls(root=self, name=folder_cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + fld = folder_cls(root=self, _distinguished_id=DistinguishedFolderId(id=folder_cls.DISTINGUISHED_FOLDER_ID)) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: @@ -9832,7 +9769,7 @@

    Inherited members

    # so we are sure to apply the correct Folder class, then fetch all sub-folders of this root. folders_map = {self.id: self} distinguished_folders = [ - cls(root=self, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID) for cls in self.WELLKNOWN_FOLDERS if cls.get_folder_allowed and cls.supports_version(self.account.version) ] @@ -10025,9 +9962,7 @@

    Static methods

    if not cls.DISTINGUISHED_FOLDER_ID: raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: - return cls.resolve( - account=account, folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) - ) + return cls.resolve(account=account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") @@ -10116,9 +10051,9 @@

    Methods

    log.debug("Requesting distinguished %s folder explicitly", folder_cls) return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: - # Maybe we just don't have GetFolder access? Try FindItems instead + # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) - fld = folder_cls(root=self, name=folder_cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + fld = folder_cls(root=self, _distinguished_id=DistinguishedFolderId(id=folder_cls.DISTINGUISHED_FOLDER_ID)) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: @@ -10190,7 +10125,6 @@

    Inherited members

  • get
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -10262,7 +10196,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -10332,7 +10265,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -10418,7 +10350,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -10493,7 +10424,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -10570,7 +10500,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -10642,7 +10571,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -10719,7 +10647,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -10852,7 +10779,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -10929,7 +10855,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -11001,7 +10926,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -11070,7 +10994,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -11150,7 +11073,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -11227,7 +11149,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -11322,7 +11243,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -11394,7 +11314,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -11482,7 +11401,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -11554,7 +11472,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -11636,7 +11553,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -11739,7 +11655,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -11811,7 +11726,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • diff --git a/docs/exchangelib/folders/known_folders.html b/docs/exchangelib/folders/known_folders.html index 5dd3c001..8c596821 100644 --- a/docs/exchangelib/folders/known_folders.html +++ b/docs/exchangelib/folders/known_folders.html @@ -849,7 +849,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -928,7 +927,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -1006,7 +1004,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -1076,7 +1073,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -1151,7 +1147,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -1226,7 +1221,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -1301,7 +1295,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -1376,7 +1369,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -1451,7 +1443,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -1526,7 +1517,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -1601,7 +1591,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -1678,7 +1667,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -1755,7 +1743,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -1871,7 +1858,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -1943,7 +1929,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -2020,7 +2005,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -2104,7 +2088,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -2179,7 +2162,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -2285,7 +2267,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -2360,7 +2341,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -2437,7 +2417,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -2506,7 +2485,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -2583,7 +2561,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -2655,7 +2632,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -2750,7 +2726,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -2825,7 +2800,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -2894,7 +2868,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -2980,7 +2953,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -3052,7 +3024,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -3132,7 +3103,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -3210,7 +3180,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -3279,7 +3248,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -3351,7 +3319,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -3430,7 +3397,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -3514,7 +3480,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -3591,7 +3556,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -3671,7 +3635,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -3757,7 +3720,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -3832,7 +3794,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -3918,7 +3879,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -3993,7 +3953,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -4065,7 +4024,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -4137,7 +4095,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -4219,7 +4176,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -4300,7 +4256,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -4380,7 +4335,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -4458,7 +4412,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -4612,7 +4565,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -4695,7 +4647,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -4781,7 +4732,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -4858,7 +4808,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -4935,7 +4884,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -5012,7 +4960,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -5095,7 +5042,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -5170,7 +5116,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -5250,7 +5195,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -5327,7 +5271,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -5413,7 +5356,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -5488,7 +5430,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -5563,7 +5504,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -5638,7 +5578,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -5713,7 +5652,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -5782,7 +5720,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -5859,7 +5796,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -5931,7 +5867,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -6001,7 +5936,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -6087,7 +6021,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -6162,7 +6095,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -6239,7 +6171,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -6311,7 +6242,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -6388,7 +6318,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -6464,7 +6393,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -6541,7 +6469,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -6613,7 +6540,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -6682,7 +6608,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -6762,7 +6687,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -6839,7 +6763,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -6916,7 +6839,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -7011,7 +6933,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -7083,7 +7004,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -7171,7 +7091,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -7243,7 +7162,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -7325,7 +7243,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -7428,7 +7345,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -7500,7 +7416,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • diff --git a/docs/exchangelib/folders/roots.html b/docs/exchangelib/folders/roots.html index c71428c8..703458e5 100644 --- a/docs/exchangelib/folders/roots.html +++ b/docs/exchangelib/folders/roots.html @@ -32,7 +32,7 @@

    Module exchangelib.folders.roots

    from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorInvalidOperation from ..fields import EffectiveRightsField -from ..properties import EWSMeta +from ..properties import DistinguishedFolderId, EWSMeta from ..version import EXCHANGE_2007_SP1, EXCHANGE_2010_SP1 from .base import BaseFolder from .collections import FolderCollection @@ -138,9 +138,7 @@

    Module exchangelib.folders.roots

    if not cls.DISTINGUISHED_FOLDER_ID: raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: - return cls.resolve( - account=account, folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) - ) + return cls.resolve(account=account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") @@ -162,9 +160,9 @@

    Module exchangelib.folders.roots

    log.debug("Requesting distinguished %s folder explicitly", folder_cls) return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: - # Maybe we just don't have GetFolder access? Try FindItems instead + # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) - fld = folder_cls(root=self, name=folder_cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + fld = folder_cls(root=self, _distinguished_id=DistinguishedFolderId(id=folder_cls.DISTINGUISHED_FOLDER_ID)) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: @@ -182,7 +180,7 @@

    Module exchangelib.folders.roots

    # so we are sure to apply the correct Folder class, then fetch all sub-folders of this root. folders_map = {self.id: self} distinguished_folders = [ - cls(root=self, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID) for cls in self.WELLKNOWN_FOLDERS if cls.get_folder_allowed and cls.supports_version(self.account.version) ] @@ -456,7 +454,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -624,7 +621,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -771,7 +767,6 @@

    Inherited members

  • get_distinguished
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -893,9 +888,7 @@

    Inherited members

    if not cls.DISTINGUISHED_FOLDER_ID: raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: - return cls.resolve( - account=account, folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) - ) + return cls.resolve(account=account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") @@ -917,9 +910,9 @@

    Inherited members

    log.debug("Requesting distinguished %s folder explicitly", folder_cls) return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: - # Maybe we just don't have GetFolder access? Try FindItems instead + # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) - fld = folder_cls(root=self, name=folder_cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + fld = folder_cls(root=self, _distinguished_id=DistinguishedFolderId(id=folder_cls.DISTINGUISHED_FOLDER_ID)) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: @@ -937,7 +930,7 @@

    Inherited members

    # so we are sure to apply the correct Folder class, then fetch all sub-folders of this root. folders_map = {self.id: self} distinguished_folders = [ - cls(root=self, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID) for cls in self.WELLKNOWN_FOLDERS if cls.get_folder_allowed and cls.supports_version(self.account.version) ] @@ -1130,9 +1123,7 @@

    Static methods

    if not cls.DISTINGUISHED_FOLDER_ID: raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: - return cls.resolve( - account=account, folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) - ) + return cls.resolve(account=account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") @@ -1221,9 +1212,9 @@

    Methods

    log.debug("Requesting distinguished %s folder explicitly", folder_cls) return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: - # Maybe we just don't have GetFolder access? Try FindItems instead + # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) - fld = folder_cls(root=self, name=folder_cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + fld = folder_cls(root=self, _distinguished_id=DistinguishedFolderId(id=folder_cls.DISTINGUISHED_FOLDER_ID)) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: @@ -1295,7 +1286,6 @@

    Inherited members

  • get
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • diff --git a/docs/exchangelib/index.html b/docs/exchangelib/index.html index 09031a30..faf89e00 100644 --- a/docs/exchangelib/index.html +++ b/docs/exchangelib/index.html @@ -64,7 +64,7 @@

    Package exchangelib

    from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "5.0.3" +__version__ = "5.1.0" __all__ = [ "__version__", @@ -5688,6 +5688,9 @@

    Methods

    instance.ms_name = "" return instance + def __hash__(self): + return hash(self.key) + def __eq__(self, other): # Microsoft time zones are less granular than IANA, so an EWSTimeZone created from 'Europe/Copenhagen' may # return from the server as 'Europe/Copenhagen'. We're catering for Microsoft here, so base equality on the @@ -6952,9 +6955,7 @@

    Inherited members

    :return: """ try: - return cls.resolve( - account=root.account, folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) - ) + return cls.resolve(account=root.account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") @@ -7157,9 +7158,7 @@

    Static methods

    :return: """ try: - return cls.resolve( - account=root.account, folder=cls(root=root, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) - ) + return cls.resolve(account=root.account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") @@ -7212,7 +7211,6 @@

    Inherited members

  • get
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -9115,24 +9113,20 @@

    Inherited members

    conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) - else: - if self.account.version.build < EXCHANGE_2013 and self.attachments: - # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need - # to first save, then attach, then send. This is done in save(). - self.save( - update_fields=update_fields, - conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations, - ) - return self.send( - save_copy=False, - conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations, - ) - else: - return self._create( - message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations - ) + if self.account.version.build < EXCHANGE_2013 and self.attachments: + # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need + # to first save, then attach, then send. This is done in save(). + self.save( + update_fields=update_fields, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, + ) + return self.send( + save_copy=False, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, + ) + return self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations) @require_id def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None): @@ -9466,24 +9460,20 @@

    Methods

    conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) - else: - if self.account.version.build < EXCHANGE_2013 and self.attachments: - # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need - # to first save, then attach, then send. This is done in save(). - self.save( - update_fields=update_fields, - conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations, - ) - return self.send( - save_copy=False, - conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations, - ) - else: - return self._create( - message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations - ) + if self.account.version.build < EXCHANGE_2013 and self.attachments: + # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need + # to first save, then attach, then send. This is done in save(). + self.save( + update_fields=update_fields, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, + ) + return self.send( + save_copy=False, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, + ) + return self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations) @@ -9605,37 +9595,12 @@

    Methods

    super().__init__(**kwargs) self.authorization_code = authorization_code - @property - def token_url(self): - # We don't know (or need) the Microsoft tenant ID. Use common/ to let Microsoft select the appropriate - # tenant for the provided authorization code or refresh token. - return "https://login.microsoftonline.com/common/oauth2/v2.0/token" # nosec - @property def scope(self): res = super().scope res.append("offline_access") return res - def session_params(self): - res = super().session_params() - if self.client_id and self.client_secret: - # If we're given a client ID and secret, we have enough to refresh access tokens ourselves. In other - # cases the session will raise TokenExpiredError, and we'll need to ask the calling application to - # refresh the token (that covers cases where the caller doesn't have access to the client secret but - # is working with a service that can provide it refreshed tokens on a limited basis). - res.update( - { - "auto_refresh_kwargs": { - "client_id": self.client_id, - "client_secret": self.client_secret, - }, - "auto_refresh_url": self.token_url, - "token_updater": self.on_token_auto_refreshed, - } - ) - return res - def token_params(self): res = super().token_params() res["code"] = self.authorization_code # Auth code may be None @@ -9709,14 +9674,6 @@

    Inherited members

    the associated auth code grant type for multi-tenant applications. """ - @property - def token_url(self): - return f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token" - - @property - def scope(self): - return ["https://outlook.office365.com/.default"] - @threaded_cached_property def client(self): return oauthlib.oauth2.BackendApplicationClient(client_id=self.client_id) @@ -11549,9 +11506,7 @@

    Inherited members

    if not cls.DISTINGUISHED_FOLDER_ID: raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: - return cls.resolve( - account=account, folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) - ) + return cls.resolve(account=account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") @@ -11573,9 +11528,9 @@

    Inherited members

    log.debug("Requesting distinguished %s folder explicitly", folder_cls) return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: - # Maybe we just don't have GetFolder access? Try FindItems instead + # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) - fld = folder_cls(root=self, name=folder_cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + fld = folder_cls(root=self, _distinguished_id=DistinguishedFolderId(id=folder_cls.DISTINGUISHED_FOLDER_ID)) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: @@ -11593,7 +11548,7 @@

    Inherited members

    # so we are sure to apply the correct Folder class, then fetch all sub-folders of this root. folders_map = {self.id: self} distinguished_folders = [ - cls(root=self, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID) for cls in self.WELLKNOWN_FOLDERS if cls.get_folder_allowed and cls.supports_version(self.account.version) ] @@ -11786,9 +11741,7 @@

    Static methods

    if not cls.DISTINGUISHED_FOLDER_ID: raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: - return cls.resolve( - account=account, folder=cls(account=account, name=cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) - ) + return cls.resolve(account=account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") @@ -11877,9 +11830,9 @@

    Methods

    log.debug("Requesting distinguished %s folder explicitly", folder_cls) return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: - # Maybe we just don't have GetFolder access? Try FindItems instead + # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) - fld = folder_cls(root=self, name=folder_cls.DISTINGUISHED_FOLDER_ID, is_distinguished=True) + fld = folder_cls(root=self, _distinguished_id=DistinguishedFolderId(id=folder_cls.DISTINGUISHED_FOLDER_ID)) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: @@ -11951,7 +11904,6 @@

    Inherited members

  • get
  • get_events
  • get_streaming_events
  • -
  • is_distinguished
  • item_sync_state
  • none
  • parent
  • @@ -12539,12 +12491,14 @@

    Static methods

    @property def fullname(self): for build, api_version, full_name in VERSIONS: - if self.build: - if self.build.major_version != build.major_version or self.build.minor_version != build.minor_version: - continue + if self.build and ( + self.build.major_version != build.major_version or self.build.minor_version != build.minor_version + ): + continue if self.api_version == api_version: return full_name - raise ValueError(f"Full name for API version {self.api_version} build {self.build} is unknown") + log.warning("Full name for API version %s build %s is unknown", self.api_version, self.build) + return "UNKNOWN" @classmethod def guess(cls, protocol, api_version_hint=None): @@ -12627,6 +12581,9 @@

    Static methods

    # Return all supported versions, sorted newest to oldest return [cls(build=build, api_version=api_version) for build, api_version, _ in VERSIONS] + def __hash__(self): + return hash((self.build, self.api_version)) + def __eq__(self, other): if self.api_version != other.api_version: return False @@ -12775,12 +12732,14 @@

    Instance variables

    @property
     def fullname(self):
         for build, api_version, full_name in VERSIONS:
    -        if self.build:
    -            if self.build.major_version != build.major_version or self.build.minor_version != build.minor_version:
    -                continue
    +        if self.build and (
    +            self.build.major_version != build.major_version or self.build.minor_version != build.minor_version
    +        ):
    +            continue
             if self.api_version == api_version:
                 return full_name
    -    raise ValueError(f"Full name for API version {self.api_version} build {self.build} is unknown")
    + log.warning("Full name for API version %s build %s is unknown", self.api_version, self.build) + return "UNKNOWN" diff --git a/docs/exchangelib/items/calendar_item.html b/docs/exchangelib/items/calendar_item.html index 43e5be3e..78fb9894 100644 --- a/docs/exchangelib/items/calendar_item.html +++ b/docs/exchangelib/items/calendar_item.html @@ -481,7 +481,17 @@

    Module exchangelib.items.calendar_item

    ELEMENT_NAME = "CancelCalendarItem" author_idx = BaseReplyItem.FIELDS.index_by_name("author") - FIELDS = BaseReplyItem.FIELDS[:author_idx] + BaseReplyItem.FIELDS[author_idx + 1 :] + FIELDS = BaseReplyItem.FIELDS[:author_idx] + BaseReplyItem.FIELDS[author_idx + 1 :] + + +class _Booking(Item): + """Not mentioned anywhere in MSDN docs, but it's common enough that we want to at least not crash when we encounter + an item of this type in a folder. For more information, see https://github.com/ecederstrand/exchangelib/issues/877 + + Not supported in any way except to not crash on calendar folder reads + """ + + ELEMENT_NAME = "Booking"

    diff --git a/docs/exchangelib/items/index.html b/docs/exchangelib/items/index.html index dc75b1f8..6993901c 100644 --- a/docs/exchangelib/items/index.html +++ b/docs/exchangelib/items/index.html @@ -68,6 +68,7 @@

    Module exchangelib.items

    MeetingRequest, MeetingResponse, TentativelyAcceptItem, + _Booking, ) from .contact import Contact, DistributionList, Persona from .item import BaseItem, Item @@ -90,6 +91,7 @@

    Module exchangelib.items

    ITEM_CLASSES = ( + _Booking, CalendarItem, Contact, DistributionList, @@ -2076,6 +2078,7 @@

    Subclasses

    • BaseMeetingItem
    • CalendarItem
    • +
    • exchangelib.items.calendar_item._Booking
    • Contact
    • DistributionList
    • Message
    • @@ -3091,24 +3094,20 @@

      Inherited members

      conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) - else: - if self.account.version.build < EXCHANGE_2013 and self.attachments: - # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need - # to first save, then attach, then send. This is done in save(). - self.save( - update_fields=update_fields, - conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations, - ) - return self.send( - save_copy=False, - conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations, - ) - else: - return self._create( - message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations - ) + if self.account.version.build < EXCHANGE_2013 and self.attachments: + # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need + # to first save, then attach, then send. This is done in save(). + self.save( + update_fields=update_fields, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, + ) + return self.send( + save_copy=False, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, + ) + return self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations) @require_id def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None): @@ -3442,24 +3441,20 @@

      Methods

      conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) - else: - if self.account.version.build < EXCHANGE_2013 and self.attachments: - # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need - # to first save, then attach, then send. This is done in save(). - self.save( - update_fields=update_fields, - conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations, - ) - return self.send( - save_copy=False, - conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations, - ) - else: - return self._create( - message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations - ) + if self.account.version.build < EXCHANGE_2013 and self.attachments: + # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need + # to first save, then attach, then send. This is done in save(). + self.save( + update_fields=update_fields, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, + ) + return self.send( + save_copy=False, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, + ) + return self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations) diff --git a/docs/exchangelib/items/item.html b/docs/exchangelib/items/item.html index c88c58ec..ca58c97e 100644 --- a/docs/exchangelib/items/item.html +++ b/docs/exchangelib/items/item.html @@ -842,6 +842,7 @@

      Subclasses

      • BaseMeetingItem
      • CalendarItem
      • +
      • exchangelib.items.calendar_item._Booking
      • Contact
      • DistributionList
      • Message
      • diff --git a/docs/exchangelib/items/message.html b/docs/exchangelib/items/message.html index e9785010..f66b76c7 100644 --- a/docs/exchangelib/items/message.html +++ b/docs/exchangelib/items/message.html @@ -132,24 +132,20 @@

        Module exchangelib.items.message

        conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) - else: - if self.account.version.build < EXCHANGE_2013 and self.attachments: - # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need - # to first save, then attach, then send. This is done in save(). - self.save( - update_fields=update_fields, - conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations, - ) - return self.send( - save_copy=False, - conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations, - ) - else: - return self._create( - message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations - ) + if self.account.version.build < EXCHANGE_2013 and self.attachments: + # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need + # to first save, then attach, then send. This is done in save(). + self.save( + update_fields=update_fields, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, + ) + return self.send( + save_copy=False, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, + ) + return self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations) @require_id def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None): @@ -387,24 +383,20 @@

        Inherited members

        conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) - else: - if self.account.version.build < EXCHANGE_2013 and self.attachments: - # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need - # to first save, then attach, then send. This is done in save(). - self.save( - update_fields=update_fields, - conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations, - ) - return self.send( - save_copy=False, - conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations, - ) - else: - return self._create( - message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations - ) + if self.account.version.build < EXCHANGE_2013 and self.attachments: + # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need + # to first save, then attach, then send. This is done in save(). + self.save( + update_fields=update_fields, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, + ) + return self.send( + save_copy=False, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, + ) + return self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations) @require_id def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None): @@ -738,24 +730,20 @@

        Methods

        conflict_resolution=conflict_resolution, send_meeting_invitations=send_meeting_invitations, ) - else: - if self.account.version.build < EXCHANGE_2013 and self.attachments: - # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need - # to first save, then attach, then send. This is done in save(). - self.save( - update_fields=update_fields, - conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations, - ) - return self.send( - save_copy=False, - conflict_resolution=conflict_resolution, - send_meeting_invitations=send_meeting_invitations, - ) - else: - return self._create( - message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations - ) + if self.account.version.build < EXCHANGE_2013 and self.attachments: + # At least some versions prior to Exchange 2013 can't send-and-save attachments immediately. You need + # to first save, then attach, then send. This is done in save(). + self.save( + update_fields=update_fields, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, + ) + return self.send( + save_copy=False, + conflict_resolution=conflict_resolution, + send_meeting_invitations=send_meeting_invitations, + ) + return self._create(message_disposition=SEND_AND_SAVE_COPY, send_meeting_invitations=send_meeting_invitations) diff --git a/docs/exchangelib/properties.html b/docs/exchangelib/properties.html index 647a6129..996a31c8 100644 --- a/docs/exchangelib/properties.html +++ b/docs/exchangelib/properties.html @@ -2121,23 +2121,30 @@

        Module exchangelib.properties

        raise AutoDiscoverFailed(f"User settings errors: {self.user_settings_errors}") @classmethod - def from_xml(cls, elem, account): + def parse_elem(cls, elem): # Possible ErrorCode values: # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/errorcode-soap error_code = get_xml_attr(elem, f"{{{ANS}}}ErrorCode") error_message = get_xml_attr(elem, f"{{{ANS}}}ErrorMessage") if error_code == "InternalServerError": - raise ErrorInternalServerError(error_message) + return ErrorInternalServerError(error_message) if error_code == "ServerBusy": - raise ErrorServerBusy(error_message) + return ErrorServerBusy(error_message) if error_code == "NotFederated": - raise ErrorOrganizationNotFederated(error_message) - if error_code not in ("NoError", "RedirectAddress", "RedirectUrl"): - return cls(error_code=error_code, error_message=error_message) + return ErrorOrganizationNotFederated(error_message) + return cls(error_code=error_code, error_message=error_message) + + @classmethod + def from_xml(cls, elem, account): + res = cls.parse_elem(elem) + if isinstance(res, Exception): + raise res + if res.error_code not in ("NoError", "RedirectAddress", "RedirectUrl"): + return cls(error_code=res.error_code, error_message=res.error_message) redirect_target = get_xml_attr(elem, f"{{{ANS}}}RedirectTarget") - redirect_address = redirect_target if error_code == "RedirectAddress" else None - redirect_url = redirect_target if error_code == "RedirectUrl" else None + redirect_address = redirect_target if res.error_code == "RedirectAddress" else None + redirect_url = redirect_target if res.error_code == "RedirectUrl" else None user_settings_errors = {} settings_errors_elem = elem.find(f"{{{ANS}}}UserSettingErrors") if settings_errors_elem is not None: @@ -10509,23 +10516,30 @@

        Inherited members

        raise AutoDiscoverFailed(f"User settings errors: {self.user_settings_errors}") @classmethod - def from_xml(cls, elem, account): + def parse_elem(cls, elem): # Possible ErrorCode values: # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/errorcode-soap error_code = get_xml_attr(elem, f"{{{ANS}}}ErrorCode") error_message = get_xml_attr(elem, f"{{{ANS}}}ErrorMessage") if error_code == "InternalServerError": - raise ErrorInternalServerError(error_message) + return ErrorInternalServerError(error_message) if error_code == "ServerBusy": - raise ErrorServerBusy(error_message) + return ErrorServerBusy(error_message) if error_code == "NotFederated": - raise ErrorOrganizationNotFederated(error_message) - if error_code not in ("NoError", "RedirectAddress", "RedirectUrl"): - return cls(error_code=error_code, error_message=error_message) + return ErrorOrganizationNotFederated(error_message) + return cls(error_code=error_code, error_message=error_message) + + @classmethod + def from_xml(cls, elem, account): + res = cls.parse_elem(elem) + if isinstance(res, Exception): + raise res + if res.error_code not in ("NoError", "RedirectAddress", "RedirectUrl"): + return cls(error_code=res.error_code, error_message=res.error_message) redirect_target = get_xml_attr(elem, f"{{{ANS}}}RedirectTarget") - redirect_address = redirect_target if error_code == "RedirectAddress" else None - redirect_url = redirect_target if error_code == "RedirectUrl" else None + redirect_address = redirect_target if res.error_code == "RedirectAddress" else None + redirect_url = redirect_target if res.error_code == "RedirectUrl" else None user_settings_errors = {} settings_errors_elem = elem.find(f"{{{ANS}}}UserSettingErrors") if settings_errors_elem is not None: @@ -10584,22 +10598,15 @@

        Static methods

        @classmethod
         def from_xml(cls, elem, account):
        -    # Possible ErrorCode values:
        -    #   https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/errorcode-soap
        -    error_code = get_xml_attr(elem, f"{{{ANS}}}ErrorCode")
        -    error_message = get_xml_attr(elem, f"{{{ANS}}}ErrorMessage")
        -    if error_code == "InternalServerError":
        -        raise ErrorInternalServerError(error_message)
        -    if error_code == "ServerBusy":
        -        raise ErrorServerBusy(error_message)
        -    if error_code == "NotFederated":
        -        raise ErrorOrganizationNotFederated(error_message)
        -    if error_code not in ("NoError", "RedirectAddress", "RedirectUrl"):
        -        return cls(error_code=error_code, error_message=error_message)
        +    res = cls.parse_elem(elem)
        +    if isinstance(res, Exception):
        +        raise res
        +    if res.error_code not in ("NoError", "RedirectAddress", "RedirectUrl"):
        +        return cls(error_code=res.error_code, error_message=res.error_message)
         
             redirect_target = get_xml_attr(elem, f"{{{ANS}}}RedirectTarget")
        -    redirect_address = redirect_target if error_code == "RedirectAddress" else None
        -    redirect_url = redirect_target if error_code == "RedirectUrl" else None
        +    redirect_address = redirect_target if res.error_code == "RedirectAddress" else None
        +    redirect_url = redirect_target if res.error_code == "RedirectUrl" else None
             user_settings_errors = {}
             settings_errors_elem = elem.find(f"{{{ANS}}}UserSettingErrors")
             if settings_errors_elem is not None:
        @@ -10623,6 +10630,30 @@ 

        Static methods

        )
        +
        +def parse_elem(elem) +
        +
        +
        +
        + +Expand source code + +
        @classmethod
        +def parse_elem(cls, elem):
        +    # Possible ErrorCode values:
        +    #   https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/errorcode-soap
        +    error_code = get_xml_attr(elem, f"{{{ANS}}}ErrorCode")
        +    error_message = get_xml_attr(elem, f"{{{ANS}}}ErrorMessage")
        +    if error_code == "InternalServerError":
        +        return ErrorInternalServerError(error_message)
        +    if error_code == "ServerBusy":
        +        return ErrorServerBusy(error_message)
        +    if error_code == "NotFederated":
        +        return ErrorOrganizationNotFederated(error_message)
        +    return cls(error_code=error_code, error_message=error_message)
        +
        +

        Instance variables

        @@ -11872,6 +11903,7 @@

        error_message
      • ews_url
      • from_xml
      • +
      • parse_elem
      • raise_errors
      • redirect_address
      • redirect_url
      • diff --git a/docs/exchangelib/services/common.html b/docs/exchangelib/services/common.html index 65389118..49e84ba3 100644 --- a/docs/exchangelib/services/common.html +++ b/docs/exchangelib/services/common.html @@ -66,7 +66,7 @@

        Module exchangelib.services.common

        SOAPError, TransportError, ) -from ..folders import BaseFolder, Folder, RootOfHierarchy +from ..folders import ArchiveRoot, BaseFolder, Folder, PublicFoldersRoot, Root, RootOfHierarchy from ..items import BaseItem from ..properties import ( BaseItemId, @@ -955,10 +955,7 @@

        Module exchangelib.services.common

        # Allow any BaseItemId subclass to pass unaltered return item if isinstance(item, (BaseFolder, BaseItem)): - try: - return item.to_id() - except ValueError: - return item + return item.to_id() if isinstance(item, (str, tuple, list)): return item_cls(*item) return item_cls(item.id, item.changekey) @@ -1006,22 +1003,26 @@

        Module exchangelib.services.common

        f = folder.from_xml(elem=elem, account=folder.account) elif isinstance(folder, Folder): f = folder.from_xml_with_root(elem=elem, root=folder.root) + f._distinguished_id = folder._distinguished_id elif isinstance(folder, DistinguishedFolderId): - # We don't know the root, so assume account.root. - for cls in account.root.WELLKNOWN_FOLDERS: + # We don't know the root or even account, but we need to attach the folder to something if we want to make + # future requests with this folder. Use 'account' but make sure to always use the distinguished folder ID going + # forward, instead of referencing anything connected to 'account'. + roots = (Root, ArchiveRoot, PublicFoldersRoot) + for cls in roots + tuple(chain(*(r.WELLKNOWN_FOLDERS for r in roots))): if cls.DISTINGUISHED_FOLDER_ID == folder.id: folder_cls = cls break else: raise ValueError(f"Unknown distinguished folder ID: {folder.id}") - f = folder_cls.from_xml_with_root(elem=elem, root=account.root) + if folder_cls in roots: + f = folder_cls.from_xml(elem=elem, account=account) + else: + f = folder_cls.from_xml_with_root(elem=elem, root=account.root) + f._distinguished_id = folder else: # 'folder' is a generic FolderId instance. We don't know the root so assume account.root. f = Folder.from_xml_with_root(elem=elem, root=account.root) - if isinstance(folder, DistinguishedFolderId): - f.is_distinguished = True - elif isinstance(folder, BaseFolder) and folder.is_distinguished: - f.is_distinguished = True return f
    @@ -1085,22 +1086,26 @@

    Functions

    f = folder.from_xml(elem=elem, account=folder.account) elif isinstance(folder, Folder): f = folder.from_xml_with_root(elem=elem, root=folder.root) + f._distinguished_id = folder._distinguished_id elif isinstance(folder, DistinguishedFolderId): - # We don't know the root, so assume account.root. - for cls in account.root.WELLKNOWN_FOLDERS: + # We don't know the root or even account, but we need to attach the folder to something if we want to make + # future requests with this folder. Use 'account' but make sure to always use the distinguished folder ID going + # forward, instead of referencing anything connected to 'account'. + roots = (Root, ArchiveRoot, PublicFoldersRoot) + for cls in roots + tuple(chain(*(r.WELLKNOWN_FOLDERS for r in roots))): if cls.DISTINGUISHED_FOLDER_ID == folder.id: folder_cls = cls break else: raise ValueError(f"Unknown distinguished folder ID: {folder.id}") - f = folder_cls.from_xml_with_root(elem=elem, root=account.root) + if folder_cls in roots: + f = folder_cls.from_xml(elem=elem, account=account) + else: + f = folder_cls.from_xml_with_root(elem=elem, root=account.root) + f._distinguished_id = folder else: # 'folder' is a generic FolderId instance. We don't know the root so assume account.root. f = Folder.from_xml_with_root(elem=elem, root=account.root) - if isinstance(folder, DistinguishedFolderId): - f.is_distinguished = True - elif isinstance(folder, BaseFolder) and folder.is_distinguished: - f.is_distinguished = True return f @@ -1147,10 +1152,7 @@

    Functions

    # Allow any BaseItemId subclass to pass unaltered return item if isinstance(item, (BaseFolder, BaseItem)): - try: - return item.to_id() - except ValueError: - return item + return item.to_id() if isinstance(item, (str, tuple, list)): return item_cls(*item) return item_cls(item.id, item.changekey) diff --git a/docs/exchangelib/services/create_folder.html b/docs/exchangelib/services/create_folder.html index 4baaac7a..887cfbda 100644 --- a/docs/exchangelib/services/create_folder.html +++ b/docs/exchangelib/services/create_folder.html @@ -28,7 +28,7 @@

    Module exchangelib.services.create_folder

    from ..errors import ErrorFolderExists
     from ..util import MNS, create_element
    -from .common import EWSAccountService, folder_ids_element, parse_folder_elem
    +from .common import EWSAccountService, folder_ids_element, parse_folder_elem, set_xml_value
     
     
     class CreateFolder(EWSAccountService):
    @@ -66,7 +66,10 @@ 

    Module exchangelib.services.create_folder

    payload.append( folder_ids_element(folders=[parent_folder], version=self.account.version, tag="m:ParentFolderId") ) - payload.append(folder_ids_element(folders=folders, version=self.account.version, tag="m:Folders")) + folder_elems = create_element("m:Folders") + for folder in folders: + set_xml_value(folder_elems, folder, version=self.account.version) + payload.append(folder_elems) return payload
    @@ -124,7 +127,10 @@

    Classes

    payload.append( folder_ids_element(folders=[parent_folder], version=self.account.version, tag="m:ParentFolderId") ) - payload.append(folder_ids_element(folders=folders, version=self.account.version, tag="m:Folders")) + folder_elems = create_element("m:Folders") + for folder in folders: + set_xml_value(folder_elems, folder, version=self.account.version) + payload.append(folder_elems) return payload

    Ancestors

    @@ -186,7 +192,10 @@

    Methods

    payload.append( folder_ids_element(folders=[parent_folder], version=self.account.version, tag="m:ParentFolderId") ) - payload.append(folder_ids_element(folders=folders, version=self.account.version, tag="m:Folders")) + folder_elems = create_element("m:Folders") + for folder in folders: + set_xml_value(folder_elems, folder, version=self.account.version) + payload.append(folder_elems) return payload diff --git a/docs/exchangelib/services/find_folder.html b/docs/exchangelib/services/find_folder.html index df07a5b7..481f50c6 100644 --- a/docs/exchangelib/services/find_folder.html +++ b/docs/exchangelib/services/find_folder.html @@ -65,7 +65,7 @@

    Module exchangelib.services.find_folder

    raise InvalidEnumValue("depth", depth, FOLDER_TRAVERSAL_CHOICES) roots = {f.root for f in folders} if len(roots) != 1: - raise ValueError(f"All folders in 'roots' must have the same root hierarchy ({roots})") + raise ValueError(f"All folders must have the same root hierarchy ({roots})") self.root = roots.pop() return self._elems_to_objs( self._paged_call( @@ -157,7 +157,7 @@

    Classes

    raise InvalidEnumValue("depth", depth, FOLDER_TRAVERSAL_CHOICES) roots = {f.root for f in folders} if len(roots) != 1: - raise ValueError(f"All folders in 'roots' must have the same root hierarchy ({roots})") + raise ValueError(f"All folders must have the same root hierarchy ({roots})") self.root = roots.pop() return self._elems_to_objs( self._paged_call( @@ -259,7 +259,7 @@

    Methods

    raise InvalidEnumValue("depth", depth, FOLDER_TRAVERSAL_CHOICES) roots = {f.root for f in folders} if len(roots) != 1: - raise ValueError(f"All folders in 'roots' must have the same root hierarchy ({roots})") + raise ValueError(f"All folders must have the same root hierarchy ({roots})") self.root = roots.pop() return self._elems_to_objs( self._paged_call( diff --git a/docs/exchangelib/services/get_user_settings.html b/docs/exchangelib/services/get_user_settings.html index 0c2f933c..339f8d40 100644 --- a/docs/exchangelib/services/get_user_settings.html +++ b/docs/exchangelib/services/get_user_settings.html @@ -28,10 +28,10 @@

    Module exchangelib.services.get_user_settings

    import logging
     
    -from ..errors import ErrorInternalServerError, ErrorOrganizationNotFederated, ErrorServerBusy, MalformedResponseError
    +from ..errors import MalformedResponseError
     from ..properties import UserResponse
     from ..transport import DEFAULT_ENCODING
    -from ..util import ANS, add_xml_child, create_element, get_xml_attr, ns_translation, set_xml_value, xml_to_str
    +from ..util import ANS, add_xml_child, create_element, ns_translation, set_xml_value, xml_to_str
     from ..version import EXCHANGE_2010
     from .common import EWSService
     
    @@ -102,22 +102,19 @@ 

    Module exchangelib.services.get_user_settings

    @@ -207,22 +204,19 @@

    Classes

    response = message.find(f"{{{ANS}}}Response") # ErrorCode: See # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/errorcode-soap - error_code = get_xml_attr(response, f"{{{ANS}}}ErrorCode") - if error_code == "NoError": + # There are two 'ErrorCode' elements in the response; one is a child of the 'Response' element, the other is a + # child of the 'UserResponse' element. Let's handle both with the same code. + res = UserResponse.parse_elem(response) + if isinstance(res, Exception): + raise res + if res.error_code == "NoError": container = response.find(name) if container is None: raise MalformedResponseError(f"No {name} elements in ResponseMessage ({xml_to_str(response)})") return container - # Raise any non-acceptable errors in the container, or return the container or the acceptable exception instance - msg_text = get_xml_attr(response, f"{{{ANS}}}ErrorMessage") - if error_code == "InternalServerError": - raise ErrorInternalServerError(msg_text) - if error_code == "ServerBusy": - raise ErrorServerBusy(msg_text) - if error_code == "NotFederated": - raise ErrorOrganizationNotFederated(msg_text) + # Raise any non-acceptable errors in the container, or return the acceptable exception instance try: - raise self._get_exception(code=error_code, text=msg_text, msg_xml=None) + raise self._get_exception(code=res.error_code, text=res.error_message, msg_xml=None) except self.ERRORS_TO_CATCH_IN_RESPONSE as e: return e
    diff --git a/docs/exchangelib/services/index.html b/docs/exchangelib/services/index.html index 4b7cde19..428a2589 100644 --- a/docs/exchangelib/services/index.html +++ b/docs/exchangelib/services/index.html @@ -795,7 +795,10 @@

    Inherited members

    payload.append( folder_ids_element(folders=[parent_folder], version=self.account.version, tag="m:ParentFolderId") ) - payload.append(folder_ids_element(folders=folders, version=self.account.version, tag="m:Folders")) + folder_elems = create_element("m:Folders") + for folder in folders: + set_xml_value(folder_elems, folder, version=self.account.version) + payload.append(folder_elems) return payload

    Ancestors

    @@ -857,7 +860,10 @@

    Methods

    payload.append( folder_ids_element(folders=[parent_folder], version=self.account.version, tag="m:ParentFolderId") ) - payload.append(folder_ids_element(folders=folders, version=self.account.version, tag="m:Folders")) + folder_elems = create_element("m:Folders") + for folder in folders: + set_xml_value(folder_elems, folder, version=self.account.version) + payload.append(folder_elems) return payload @@ -2789,7 +2795,7 @@

    Inherited members

    raise InvalidEnumValue("depth", depth, FOLDER_TRAVERSAL_CHOICES) roots = {f.root for f in folders} if len(roots) != 1: - raise ValueError(f"All folders in 'roots' must have the same root hierarchy ({roots})") + raise ValueError(f"All folders must have the same root hierarchy ({roots})") self.root = roots.pop() return self._elems_to_objs( self._paged_call( @@ -2891,7 +2897,7 @@

    Methods

    raise InvalidEnumValue("depth", depth, FOLDER_TRAVERSAL_CHOICES) roots = {f.root for f in folders} if len(roots) != 1: - raise ValueError(f"All folders in 'roots' must have the same root hierarchy ({roots})") + raise ValueError(f"All folders must have the same root hierarchy ({roots})") self.root = roots.pop() return self._elems_to_objs( self._paged_call( @@ -5524,22 +5530,19 @@

    Inherited members

    response = message.find(f"{{{ANS}}}Response") # ErrorCode: See # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/errorcode-soap - error_code = get_xml_attr(response, f"{{{ANS}}}ErrorCode") - if error_code == "NoError": + # There are two 'ErrorCode' elements in the response; one is a child of the 'Response' element, the other is a + # child of the 'UserResponse' element. Let's handle both with the same code. + res = UserResponse.parse_elem(response) + if isinstance(res, Exception): + raise res + if res.error_code == "NoError": container = response.find(name) if container is None: raise MalformedResponseError(f"No {name} elements in ResponseMessage ({xml_to_str(response)})") return container - # Raise any non-acceptable errors in the container, or return the container or the acceptable exception instance - msg_text = get_xml_attr(response, f"{{{ANS}}}ErrorMessage") - if error_code == "InternalServerError": - raise ErrorInternalServerError(msg_text) - if error_code == "ServerBusy": - raise ErrorServerBusy(msg_text) - if error_code == "NotFederated": - raise ErrorOrganizationNotFederated(msg_text) + # Raise any non-acceptable errors in the container, or return the acceptable exception instance try: - raise self._get_exception(code=error_code, text=msg_text, msg_xml=None) + raise self._get_exception(code=res.error_code, text=res.error_message, msg_xml=None) except self.ERRORS_TO_CATCH_IN_RESPONSE as e: return e @@ -7234,7 +7237,8 @@

    Inherited members

    continue yield parse_folder_elem(elem=elem, folder=folder, account=self.account) - def _target_elem(self, target): + @staticmethod + def _target_elem(target): return to_item_id(target, FolderId) def get_payload(self, folders): @@ -7410,7 +7414,8 @@

    Inherited members

    return value - def _target_elem(self, target): + @staticmethod + def _target_elem(target): return to_item_id(target, ItemId) def get_payload( diff --git a/docs/exchangelib/services/update_folder.html b/docs/exchangelib/services/update_folder.html index c9c52dac..8dae3d15 100644 --- a/docs/exchangelib/services/update_folder.html +++ b/docs/exchangelib/services/update_folder.html @@ -133,8 +133,9 @@

    Module exchangelib.services.update_folder

    change.append(updates) return change + @staticmethod @abc.abstractmethod - def _target_elem(self, target): + def _target_elem(target): """Convert the object to update to an XML element""" def _changes_elem(self, target_changes): @@ -173,7 +174,8 @@

    Module exchangelib.services.update_folder

    continue yield parse_folder_elem(elem=elem, folder=folder, account=self.account) - def _target_elem(self, target): + @staticmethod + def _target_elem(target): return to_item_id(target, FolderId) def get_payload(self, folders): @@ -302,8 +304,9 @@

    Classes

    change.append(updates) return change + @staticmethod @abc.abstractmethod - def _target_elem(self, target): + def _target_elem(target): """Convert the object to update to an XML element""" def _changes_elem(self, target_changes): @@ -395,7 +398,8 @@

    Inherited members

    continue yield parse_folder_elem(elem=elem, folder=folder, account=self.account) - def _target_elem(self, target): + @staticmethod + def _target_elem(target): return to_item_id(target, FolderId) def get_payload(self, folders): diff --git a/docs/exchangelib/services/update_item.html b/docs/exchangelib/services/update_item.html index 7917a423..914d4b4f 100644 --- a/docs/exchangelib/services/update_item.html +++ b/docs/exchangelib/services/update_item.html @@ -115,7 +115,8 @@

    Module exchangelib.services.update_item

    return value - def _target_elem(self, target): + @staticmethod + def _target_elem(target): return to_item_id(target, ItemId) def get_payload( @@ -231,7 +232,8 @@

    Classes

    return value - def _target_elem(self, target): + @staticmethod + def _target_elem(target): return to_item_id(target, ItemId) def get_payload( diff --git a/docs/exchangelib/util.html b/docs/exchangelib/util.html index b4118117..f0df38d6 100644 --- a/docs/exchangelib/util.html +++ b/docs/exchangelib/util.html @@ -96,6 +96,7 @@

    Module exchangelib.util

    # Regex of UTF-8 control characters that are illegal in XML 1.0 (and XML 1.1). # See https://stackoverflow.com/a/22273639/219640 _ILLEGAL_XML_CHARS_RE = re.compile("[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x84\x86-\x9F\uFDD0-\uFDDF\uFFFE\uFFFF]") +_ILLEGAL_XML_ESCAPE_CHARS_RE = re.compile(rb"&(#[0-9]+;?|#[xX][0-9a-fA-F]+;?)") # Could match the above better # XML namespaces SOAPNS = "http://schemas.xmlsoap.org/soap/envelope/" @@ -296,6 +297,13 @@

    Module exchangelib.util

    return _ILLEGAL_XML_CHARS_RE.sub(replacement, value) +def sanitize_xml(data, replacement=b"?"): + if not isinstance(data, bytes): + # We may get data="" from some expatreader versions + return data + return _ILLEGAL_XML_ESCAPE_CHARS_RE.sub(replacement, data) + + def create_element(name, attrs=None, nsmap=None): if ":" in name: ns, name = name.split(":") @@ -390,19 +398,20 @@

    Module exchangelib.util

    collected_data = [] while buffer: if not self.element_found: - collected_data += buffer + collected_data.extend(buffer) yield from self.feed(buffer) buffer = file.read(self._bufsize) # Any remaining data in self.buffer should be padding chars now self.buffer = None self.close() if not self.element_found: - data = bytes(collected_data) - raise ElementNotFound("The element to be streamed from was not found", data=bytes(data)) + raise ElementNotFound("The element to be streamed from was not found", data=bytes(collected_data)) def feed(self, data, isFinal=0): - """Yield the current content of the character buffer.""" - DefusedExpatParser.feed(self, data=data, isFinal=isFinal) + """Yield the current content of the character buffer. The input XML may contain illegal characters. The lxml + parser handles this gracefully with the 'recover' option, but ExpatParser doesn't have this option. Remove + illegal characters before parsing.""" + DefusedExpatParser.feed(self, data=sanitize_xml(data), isFinal=isFinal) return self._decode_buffer() def _decode_buffer(self): @@ -1450,6 +1459,22 @@

    Functions

    return _ILLEGAL_XML_CHARS_RE.sub(replacement, value) +
    +def sanitize_xml(data, replacement=b'?') +
    +
    +
    +
    + +Expand source code + +
    def sanitize_xml(data, replacement=b"?"):
    +    if not isinstance(data, bytes):
    +        # We may get data="" from some expatreader versions
    +        return data
    +    return _ILLEGAL_XML_ESCAPE_CHARS_RE.sub(replacement, data)
    +
    +
    def set_xml_value(elem, value, version=None)
    @@ -2297,19 +2322,20 @@

    Methods

    collected_data = [] while buffer: if not self.element_found: - collected_data += buffer + collected_data.extend(buffer) yield from self.feed(buffer) buffer = file.read(self._bufsize) # Any remaining data in self.buffer should be padding chars now self.buffer = None self.close() if not self.element_found: - data = bytes(collected_data) - raise ElementNotFound("The element to be streamed from was not found", data=bytes(data)) + raise ElementNotFound("The element to be streamed from was not found", data=bytes(collected_data)) def feed(self, data, isFinal=0): - """Yield the current content of the character buffer.""" - DefusedExpatParser.feed(self, data=data, isFinal=isFinal) + """Yield the current content of the character buffer. The input XML may contain illegal characters. The lxml + parser handles this gracefully with the 'recover' option, but ExpatParser doesn't have this option. Remove + illegal characters before parsing.""" + DefusedExpatParser.feed(self, data=sanitize_xml(data), isFinal=isFinal) return self._decode_buffer() def _decode_buffer(self): @@ -2340,14 +2366,18 @@

    Methods

    def feed(self, data, isFinal=0)
    -

    Yield the current content of the character buffer.

    +

    Yield the current content of the character buffer. The input XML may contain illegal characters. The lxml +parser handles this gracefully with the 'recover' option, but ExpatParser doesn't have this option. Remove +illegal characters before parsing.

    Expand source code
    def feed(self, data, isFinal=0):
    -    """Yield the current content of the character buffer."""
    -    DefusedExpatParser.feed(self, data=data, isFinal=isFinal)
    +    """Yield the current content of the character buffer. The input XML may contain illegal characters. The lxml
    +    parser handles this gracefully with the 'recover' option, but ExpatParser doesn't have this option. Remove
    +    illegal characters before parsing."""
    +    DefusedExpatParser.feed(self, data=sanitize_xml(data), isFinal=isFinal)
         return self._decode_buffer()
    @@ -2372,15 +2402,14 @@

    Methods

    collected_data = [] while buffer: if not self.element_found: - collected_data += buffer + collected_data.extend(buffer) yield from self.feed(buffer) buffer = file.read(self._bufsize) # Any remaining data in self.buffer should be padding chars now self.buffer = None self.close() if not self.element_found: - data = bytes(collected_data) - raise ElementNotFound("The element to be streamed from was not found", data=bytes(data)) + raise ElementNotFound("The element to be streamed from was not found", data=bytes(collected_data)) @@ -2525,6 +2554,7 @@

    Index

  • require_id
  • safe_b64decode
  • safe_xml_value
  • +
  • sanitize_xml
  • set_xml_value
  • split_url
  • to_xml
  • diff --git a/docs/exchangelib/version.html b/docs/exchangelib/version.html index 067d2170..d9f6a7d6 100644 --- a/docs/exchangelib/version.html +++ b/docs/exchangelib/version.html @@ -155,7 +155,7 @@

    Module exchangelib.version

    # The list is sorted from newest to oldest build VERSIONS = ( (EXCHANGE_O365, "Exchange2016", "Microsoft Exchange Server Office365"), # Not mentioned in list of build numbers - (EXCHANGE_2019, "Exchange2019", "Microsoft Exchange Server 2019"), + (EXCHANGE_2019, "Exchange2016", "Microsoft Exchange Server 2019"), (EXCHANGE_2016, "Exchange2016", "Microsoft Exchange Server 2016"), (EXCHANGE_2015_SP1, "Exchange2015_SP1", "Microsoft Exchange Server 2015 SP1"), (EXCHANGE_2015, "Exchange2015", "Microsoft Exchange Server 2015"), @@ -193,12 +193,14 @@

    Module exchangelib.version

    @property def fullname(self): for build, api_version, full_name in VERSIONS: - if self.build: - if self.build.major_version != build.major_version or self.build.minor_version != build.minor_version: - continue + if self.build and ( + self.build.major_version != build.major_version or self.build.minor_version != build.minor_version + ): + continue if self.api_version == api_version: return full_name - raise ValueError(f"Full name for API version {self.api_version} build {self.build} is unknown") + log.warning("Full name for API version %s build %s is unknown", self.api_version, self.build) + return "UNKNOWN" @classmethod def guess(cls, protocol, api_version_hint=None): @@ -281,6 +283,9 @@

    Module exchangelib.version

    # Return all supported versions, sorted newest to oldest return [cls(build=build, api_version=api_version) for build, api_version, _ in VERSIONS] + def __hash__(self): + return hash((self.build, self.api_version)) + def __eq__(self, other): if self.api_version != other.api_version: return False @@ -664,12 +669,14 @@

    Methods

    @property def fullname(self): for build, api_version, full_name in VERSIONS: - if self.build: - if self.build.major_version != build.major_version or self.build.minor_version != build.minor_version: - continue + if self.build and ( + self.build.major_version != build.major_version or self.build.minor_version != build.minor_version + ): + continue if self.api_version == api_version: return full_name - raise ValueError(f"Full name for API version {self.api_version} build {self.build} is unknown") + log.warning("Full name for API version %s build %s is unknown", self.api_version, self.build) + return "UNKNOWN" @classmethod def guess(cls, protocol, api_version_hint=None): @@ -752,6 +759,9 @@

    Methods

    # Return all supported versions, sorted newest to oldest return [cls(build=build, api_version=api_version) for build, api_version, _ in VERSIONS] + def __hash__(self): + return hash((self.build, self.api_version)) + def __eq__(self, other): if self.api_version != other.api_version: return False @@ -900,12 +910,14 @@

    Instance variables

    @property
     def fullname(self):
         for build, api_version, full_name in VERSIONS:
    -        if self.build:
    -            if self.build.major_version != build.major_version or self.build.minor_version != build.minor_version:
    -                continue
    +        if self.build and (
    +            self.build.major_version != build.major_version or self.build.minor_version != build.minor_version
    +        ):
    +            continue
             if self.api_version == api_version:
                 return full_name
    -    raise ValueError(f"Full name for API version {self.api_version} build {self.build} is unknown")
    + log.warning("Full name for API version %s build %s is unknown", self.api_version, self.build) + return "UNKNOWN" From 490f734b5ba73b4038ad6c78f13fb23572b00228 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 22 Aug 2023 12:05:53 +0200 Subject: [PATCH 381/509] Bump version --- CHANGELOG.md | 8 ++++++++ exchangelib/__init__.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99a3121f..4a3c9bb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ HEAD ---- +5.1.0 +----- +- Fix QuerySet operations on shared folders +- Fix globbing on patterns with more than two folder levels +- Fix case sensitivity of "/" folder navigation +- Multiple improvements related to consistency and graceful error handling + + 5.0.3 ----- - Bugfix release diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index 0007c8ae..ce0a7d71 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -36,7 +36,7 @@ from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "5.0.3" +__version__ = "5.1.0" __all__ = [ "__version__", From 5a92b57cd9286b00f3d4538f54d5ad141565f19d Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 23 Aug 2023 12:45:25 +0200 Subject: [PATCH 382/509] fix: Add new folder type uncovered by tests --- exchangelib/folders/__init__.py | 2 ++ exchangelib/folders/known_folders.py | 4 ++++ tests/test_folder.py | 3 +++ 3 files changed, 9 insertions(+) diff --git a/exchangelib/folders/__init__.py b/exchangelib/folders/__init__.py index 71ec91c7..c406bb2f 100644 --- a/exchangelib/folders/__init__.py +++ b/exchangelib/folders/__init__.py @@ -31,6 +31,7 @@ Directory, DlpPolicyEvaluation, Drafts, + EventCheckPoints, ExchangeSyncData, Favorites, Files, @@ -125,6 +126,7 @@ "DistinguishedFolderId", "DlpPolicyEvaluation", "Drafts", + "EventCheckPoints", "ExchangeSyncData", "FOLDER_TRAVERSAL_CHOICES", "Favorites", diff --git a/exchangelib/folders/known_folders.py b/exchangelib/folders/known_folders.py index e6c0ba72..17d366ed 100644 --- a/exchangelib/folders/known_folders.py +++ b/exchangelib/folders/known_folders.py @@ -82,6 +82,10 @@ class SwssItems(Folder): CONTAINER_CLASS = "IPF.StoreItem.SwssItems" +class EventCheckPoints(Folder): + CONTAINER_CLASS = "IPF.StoreItem.EventCheckPoints" + + class SkypeTeamsMessages(Folder): CONTAINER_CLASS = "IPF.SkypeTeams.Message" LOCALIZED_NAMES = { diff --git a/tests/test_folder.py b/tests/test_folder.py index 5a8d3dd8..fea0417f 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -35,6 +35,7 @@ DistinguishedFolderId, DlpPolicyEvaluation, Drafts, + EventCheckPoints, Favorites, Files, Folder, @@ -523,6 +524,8 @@ def test_folder_grouping(self): self.assertEqual(f.folder_class, "IPF.StoreItem.RecoveryPoints") elif isinstance(f, SwssItems): self.assertEqual(f.folder_class, "IPF.StoreItem.SwssItems") + elif isinstance(f, EventCheckPoints): + self.assertEqual(f.folder_class, "IPF.StoreItem.EventCheckPoints") elif isinstance(f, PassThroughSearchResults): self.assertEqual(f.folder_class, "IPF.StoreItem.PassThroughSearchResults") elif isinstance(f, GraphAnalytics): From b2eeb1bdd1bdf29517a35f3c87a000e76a39ea8c Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 23 Aug 2023 14:18:27 +0200 Subject: [PATCH 383/509] fix: Add new folder to folder_cls_from_container_class, missed in previous commit --- exchangelib/folders/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 4c2cc92a..5c2f3af7 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -238,6 +238,7 @@ def folder_cls_from_container_class(container_class): ConversationSettings, CrawlerData, DlpPolicyEvaluation, + EventCheckPoints, FreeBusyCache, GALContacts, Messages, @@ -260,6 +261,7 @@ def folder_cls_from_container_class(container_class): ConversationSettings, CrawlerData, DlpPolicyEvaluation, + EventCheckPoints, FreeBusyCache, GALContacts, Messages, From 4abaaaaac820709cb65dd5d3439e927c70b7ca45 Mon Sep 17 00:00:00 2001 From: Claudius Ellsel Date: Fri, 25 Aug 2023 23:01:47 +0200 Subject: [PATCH 384/509] Update index.md (#1217) --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 1637149e..ba0f7b84 100644 --- a/docs/index.md +++ b/docs/index.md @@ -114,7 +114,7 @@ fails to install. ## Setup and connecting First, specify your credentials. Username is usually in `WINDOMAIN\username` format, -where `WINDOMAIN is` the name of the Windows Domain your username is connected +where `WINDOMAIN` is the name of the Windows Domain your username is connected to, but some servers also accept usernames in PrimarySMTPAddress ('myusername@example.com') format (Office365 requires it). UPN format is also supported, if your server expects that. From 22af5eec84f9654a25fcfe63c7dd3db622082483 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sat, 26 Aug 2023 13:07:04 +0200 Subject: [PATCH 385/509] docs: Improve warning text to suggest how to fix the issue. While here, convert to warning so users can make it a hard error. Refs #541 --- exchangelib/fields.py | 22 ++++++++++++++-------- tests/test_field.py | 20 +++++++++++++++++++- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index 07e9556e..08562e29 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -1,6 +1,7 @@ import abc import datetime import logging +import warnings from contextlib import suppress from decimal import Decimal, InvalidOperation from importlib import import_module @@ -739,16 +740,21 @@ def clean(self, value, version=None): def from_xml(self, elem, account): field_elem = elem.find(self.response_tag()) if field_elem is not None: - ms_id = field_elem.get("Id") - ms_name = field_elem.get("Name") + tz_id = field_elem.get("Id") or field_elem.get("Name") try: - return self.value_cls.from_ms_id(ms_id or ms_name) + return self.value_cls.from_ms_id(tz_id) except UnknownTimeZone: - log.warning( - "Cannot convert value '%s' on field '%s' to type %s (unknown timezone ID)", - (ms_id or ms_name), - self.name, - self.value_cls, + warnings.warn( + f"""\ +Cannot convert value {tz_id!r} on field {self.name!r} to type {self.value_cls.__name__!r} (unknown timezone ID). +You can fix this by adding a custom entry into the timezone translation map: + +from exchangelib.winzone import MS_TIMEZONE_TO_IANA_MAP, CLDR_TO_MS_TIMEZONE_MAP + +# Replace "Some_Region/Some_Location" with a reasonable value from CLDR_TO_MS_TIMEZONE_MAP.keys() +MS_TIMEZONE_TO_IANA_MAP[{tz_id!r}] = "Some_Region/Some_Location" + +# Your code here""" ) return None return self.default diff --git a/tests/test_field.py b/tests/test_field.py index 88e6d710..ad7128b1 100644 --- a/tests/test_field.py +++ b/tests/test_field.py @@ -1,4 +1,5 @@ import datetime +import warnings from collections import namedtuple from decimal import Decimal @@ -176,7 +177,24 @@ def test_garbage_input(self): """ elem = to_xml(payload).find(f"{{{TNS}}}Item") field = TimeZoneField("foo", field_uri="item:Foo", default="DUMMY") - self.assertEqual(field.from_xml(elem=elem, account=account), None) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + tz = field.from_xml(elem=elem, account=account) + self.assertEqual(tz, None) + self.assertEqual( + str(w[0].message), + """\ +Cannot convert value 'THIS_IS_GARBAGE' on field 'foo' to type 'EWSTimeZone' (unknown timezone ID). +You can fix this by adding a custom entry into the timezone translation map: + +from exchangelib.winzone import MS_TIMEZONE_TO_IANA_MAP, CLDR_TO_MS_TIMEZONE_MAP + +# Replace "Some_Region/Some_Location" with a reasonable value from CLDR_TO_MS_TIMEZONE_MAP.keys() +MS_TIMEZONE_TO_IANA_MAP['THIS_IS_GARBAGE'] = "Some_Region/Some_Location" + +# Your code here""", + ) def test_versioned_field(self): field = TextField("foo", field_uri="bar", supported_from=EXCHANGE_2010) From 0a0c0f4c29acb10badf529b6a3532243f90c342b Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 28 Aug 2023 23:45:45 +0200 Subject: [PATCH 386/509] fix: Remove duplicate recipient in reply when author and recipient is the same person. Also remove recipient of riginal mail from recipients in reply. Fixes #1218 --- exchangelib/items/message.py | 18 ++++++++++++------ tests/test_items/test_messages.py | 9 +++++++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/exchangelib/items/message.py b/exchangelib/items/message.py index 8afaa810..0b9df830 100644 --- a/exchangelib/items/message.py +++ b/exchangelib/items/message.py @@ -121,7 +121,7 @@ def send_and_save( @require_id def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None): - if to_recipients is None: + if not to_recipients: if not self.author: raise ValueError("'to_recipients' must be set when message has no 'author'") to_recipients = [self.author] @@ -141,17 +141,23 @@ def reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recip @require_id def create_reply_all(self, subject, body, author=None): - to_recipients = list(self.to_recipients) if self.to_recipients else [] + me = MailboxField().clean(self.account.primary_smtp_address.lower()) + to_recipients = set(self.to_recipients or []) + to_recipients.discard(me) + cc_recipients = set(self.cc_recipients or []) + cc_recipients.discard(me) + bcc_recipients = set(self.bcc_recipients or []) + bcc_recipients.discard(me) if self.author: - to_recipients.append(self.author) + to_recipients.add(self.author) return ReplyAllToItem( account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), subject=subject, new_body=body, - to_recipients=to_recipients, - cc_recipients=self.cc_recipients, - bcc_recipients=self.bcc_recipients, + to_recipients=list(to_recipients), + cc_recipients=list(cc_recipients), + bcc_recipients=list(bcc_recipients), author=author, ) diff --git a/tests/test_items/test_messages.py b/tests/test_items/test_messages.py index ae8688f8..07cb7575 100644 --- a/tests/test_items/test_messages.py +++ b/tests/test_items/test_messages.py @@ -133,6 +133,15 @@ def test_reply_all(self): with self.assertRaises(TypeError) as e: ReplyToItem(account="XXX") self.assertEqual(e.exception.args[0], "'account' 'XXX' must be of type ") + + # Test that to_recipients only has one entry even when we are both the sender and the receiver + item = self.get_test_item(folder=None) + item.id, item.changekey = 123, 456 + reply_item = item.create_reply_all(subject="", body="") + self.assertEqual(reply_item.to_recipients, item.to_recipients) + self.assertEqual(reply_item.to_recipients, item.to_recipients) + self.assertEqual(reply_item.to_recipients, item.to_recipients) + # Test that we can reply-all a Message item. EWS only allows items that have been sent to receive a reply item = self.get_test_item(folder=None) item.folder = None From b6d9e82f1698ebd2e0234ab72678ecd210876667 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 28 Aug 2023 23:46:20 +0200 Subject: [PATCH 387/509] ci: Make get_test_item(folder=None) work as intended --- tests/test_items/test_basics.py | 5 +++-- tests/test_items/test_messages.py | 15 ++++----------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index 6964b7a6..7f49b51d 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -235,10 +235,11 @@ def get_random_update_kwargs(self, item, insert_kwargs): update_kwargs["end"] = (update_kwargs["end"] + datetime.timedelta(days=1)).date() return update_kwargs - def get_test_item(self, folder=None, categories=None): + def get_test_item(self, categories=None, **kwargs): + folder = kwargs.pop("folder", self.test_folder) item_kwargs = self.get_random_insert_kwargs() item_kwargs["categories"] = categories or self.categories - return self.ITEM_CLASS(folder=folder or self.test_folder, **item_kwargs) + return self.ITEM_CLASS(account=self.account, folder=folder, **item_kwargs) def get_test_folder(self, folder=None): return self.FOLDER_CLASS(parent=folder or self.test_folder, name=get_random_string(8)) diff --git a/tests/test_items/test_messages.py b/tests/test_items/test_messages.py index 07cb7575..060b0920 100644 --- a/tests/test_items/test_messages.py +++ b/tests/test_items/test_messages.py @@ -33,8 +33,7 @@ def get_incoming_message(self, subject): def test_send(self): # Test that we can send (only) Message items - item = self.get_test_item() - item.folder = None + item = self.get_test_item(folder=None) item.send() self.assertIsNone(item.id) self.assertIsNone(item.changekey) @@ -52,8 +51,7 @@ def test_send_pre_2013(self): self.assertIsNone(item.changekey) def test_send_no_copy(self): - item = self.get_test_item() - item.folder = None + item = self.get_test_item(folder=None) item.send(save_copy=False) self.assertIsNone(item.id) self.assertIsNone(item.changekey) @@ -98,8 +96,7 @@ def test_send_and_copy_to_folder(self): def test_reply(self): # Test that we can reply to a Message item. EWS only allows items that have been sent to receive a reply - item = self.get_test_item() - item.folder = None + item = self.get_test_item(folder=None) item.send() # get_test_item() sets the to_recipients to the test account sent_item = self.get_incoming_message(item.subject) new_subject = (f"Re: {sent_item.subject}")[:255] @@ -109,7 +106,6 @@ def test_reply(self): def test_create_reply(self): # Test that we can save a reply without sending it item = self.get_test_item(folder=None) - item.folder = None item.send() sent_item = self.get_incoming_message(item.subject) new_subject = (f"Re: {sent_item.subject}")[:255] @@ -144,17 +140,15 @@ def test_reply_all(self): # Test that we can reply-all a Message item. EWS only allows items that have been sent to receive a reply item = self.get_test_item(folder=None) - item.folder = None item.send() sent_item = self.get_incoming_message(item.subject) - new_subject = (f"Re: {sent_item.subject}")[:255] + new_subject = f"Re: {sent_item.subject}"[:255] sent_item.reply_all(subject=new_subject, body="Hello reply") self.assertEqual(self.account.sent.filter(subject=new_subject).count(), 1) def test_forward(self): # Test that we can forward a Message item. EWS only allows items that have been sent to receive a reply item = self.get_test_item(folder=None) - item.folder = None item.send() sent_item = self.get_incoming_message(item.subject) new_subject = (f"Re: {sent_item.subject}")[:255] @@ -164,7 +158,6 @@ def test_forward(self): def test_create_forward(self): # Test that we can forward a Message item. EWS only allows items that have been sent to receive a reply item = self.get_test_item(folder=None) - item.folder = None item.send() sent_item = self.get_incoming_message(item.subject) new_subject = (f"Re: {sent_item.subject}")[:255] From d3bd51adc5730d5ac162a95c193fc99a66d3720e Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 5 Sep 2023 10:47:40 +0200 Subject: [PATCH 388/509] chore: improve error message on invalid setting names for GetUserSettings --- exchangelib/autodiscover/protocol.py | 6 ++++++ tests/test_autodiscover.py | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/exchangelib/autodiscover/protocol.py b/exchangelib/autodiscover/protocol.py index 473d793c..9d2ec777 100644 --- a/exchangelib/autodiscover/protocol.py +++ b/exchangelib/autodiscover/protocol.py @@ -1,3 +1,4 @@ +from ..properties import UserResponse from ..protocol import BaseProtocol from ..services import GetUserSettings from ..transport import get_autodiscover_authtype @@ -45,6 +46,11 @@ def get_user_settings(self, user, settings=None): "external_ews_url", "ews_supported_schemas", ] + for setting in settings: + if setting not in UserResponse.SETTINGS_MAP: + raise ValueError( + f"Setting {setting!r} is invalid. Valid options are: {sorted(UserResponse.SETTINGS_MAP.keys())}" + ) return GetUserSettings(protocol=self).get(users=[user], settings=settings) def dummy_xml(self): diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index b7f47c1f..670fb5a4 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -256,6 +256,14 @@ def test_get_user_settings(self, m): ad.discover() p = autodiscover_cache[ad._cache_key] + # Test invalid settings + with self.assertRaises(ValueError) as e: + p.get_user_settings(user=None, settings=["XXX"]) + self.assertIn( + "Setting 'XXX' is invalid. Valid options are:", + e.exception.args[0], + ) + # Test invalid email invalid_email = get_random_email() r = p.get_user_settings(user=invalid_email) From 22f32017119a057569641dd663512ee0fdd1e4ea Mon Sep 17 00:00:00 2001 From: ekulakov-express <135971525+ekulakov-express@users.noreply.github.com> Date: Fri, 6 Oct 2023 14:30:20 +0300 Subject: [PATCH 389/509] extended Attendee class - added ProposedStart and ProposedEnd (#1236) --- exchangelib/properties.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 50cb8139..814ff741 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -796,6 +796,8 @@ class Attendee(EWSElement): field_uri="ResponseType", choices={Choice(c) for c in RESPONSE_TYPES}, default="Unknown" ) last_response_time = DateTimeField(field_uri="LastResponseTime") + proposed_start = DateTimeField(field_uri="ProposedStart") + proposed_end = DateTimeField(field_uri="ProposedEnd") def __hash__(self): return hash(self.mailbox) From 6a8289a411b771a34f6e46642fe9b486367c3860 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 10 Oct 2023 15:47:45 +0200 Subject: [PATCH 390/509] Python 3.12 is now available Disable 3.13-dev for now, as it isn't available yet. --- .github/workflows/python-package.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index dee918f8..60c3b3aa 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -29,11 +29,11 @@ jobs: needs: pre_job strategy: matrix: - python-version: ['3.8', '3.11'] - include: - # Allow failure on Python dev - e.g. Cython install regularly fails - - python-version: "3.12-dev" - allowed_failure: true + python-version: ['3.8', '3.12'] + #include: + # # Allow failure on Python dev - e.g. Cython install regularly fails + # - python-version: "3.13-dev" + # allowed_failure: true max-parallel: 1 steps: @@ -58,7 +58,7 @@ jobs: - name: Install cutting-edge Cython-based packages on Python dev versions continue-on-error: ${{ matrix.allowed_failure || false }} - if: matrix.python-version == '3.12-dev' + if: matrix.python-version == '3.13-dev' run: | sudo apt-get install libxml2-dev libxslt1-dev python -m pip install hg+https://foss.heptapod.net/pypy/cffi From 71d9871a0db2d90a9e93a776613f952d0218b8ce Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 31 Oct 2023 19:06:16 +0100 Subject: [PATCH 391/509] docs: Add links to *SubscriptionRequest elements (#1245) --- exchangelib/services/subscribe.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/exchangelib/services/subscribe.py b/exchangelib/services/subscribe.py index 23a30320..d85eb69b 100644 --- a/exchangelib/services/subscribe.py +++ b/exchangelib/services/subscribe.py @@ -53,6 +53,7 @@ def _partial_payload(self, folders, event_types): class SubscribeToPull(Subscribe): + # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/pullsubscriptionrequest subscription_request_elem_tag = "m:PullSubscriptionRequest" prefer_affinity = True @@ -76,6 +77,7 @@ def get_payload(self, folders, event_types, watermark, timeout): class SubscribeToPush(Subscribe): + # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/pushsubscriptionrequest subscription_request_elem_tag = "m:PushSubscriptionRequest" def call(self, folders, event_types, watermark, status_frequency, url): @@ -100,6 +102,7 @@ def get_payload(self, folders, event_types, watermark, status_frequency, url): class SubscribeToStreaming(Subscribe): + # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/streamingsubscriptionrequest subscription_request_elem_tag = "m:StreamingSubscriptionRequest" prefer_affinity = True From e0543881c2034473cfe70ee0ce5201fc8fcd8a03 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 31 Oct 2023 19:06:58 +0100 Subject: [PATCH 392/509] Add SubscribeToAllFolders support to subscriptions (#1244) * feat: add SubscribeToAllFolders support to subscriptions * feat: Add context managers --- exchangelib/account.py | 72 ++++++++++++++++++++++++++++++ exchangelib/folders/base.py | 6 +-- exchangelib/folders/collections.py | 18 ++++---- exchangelib/services/subscribe.py | 10 +++-- tests/test_items/test_sync.py | 63 ++++++++++++++++++++++++++ 5 files changed, 154 insertions(+), 15 deletions(-) diff --git a/exchangelib/account.py b/exchangelib/account.py index 5dbdea7a..b26feb35 100644 --- a/exchangelib/account.py +++ b/exchangelib/account.py @@ -54,6 +54,7 @@ ToDoSearch, VoiceMail, ) +from .folders.collections import PullSubscription, PushSubscription, StreamingSubscription from .items import ALL_OCCURRENCES, AUTO_RESOLVE, HARD_DELETE, ID_ONLY, SAVE_ONLY, SEND_TO_NONE from .properties import EWSElement, Mailbox, SendingAs from .protocol import Protocol @@ -73,6 +74,10 @@ MoveItem, SendItem, SetUserOofSettings, + SubscribeToPull, + SubscribeToPush, + SubscribeToStreaming, + Unsubscribe, UpdateItem, UploadItems, ) @@ -742,6 +747,73 @@ def delegates(self): """Return a list of DelegateUser objects representing the delegates that are set on this account.""" return list(GetDelegate(account=self).call(user_ids=None, include_permissions=True)) + def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): + """Create a pull subscription. + + :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPull.EVENT_TYPES + :param watermark: An event bookmark as returned by some sync services + :param timeout: Timeout of the subscription, in minutes. Timeout is reset when the server receives a + GetEvents request for this subscription. + :return: The subscription ID and a watermark + """ + if event_types is None: + event_types = SubscribeToPull.EVENT_TYPES + return SubscribeToPull(account=self).get( + folders=None, + event_types=event_types, + watermark=watermark, + timeout=timeout, + ) + + def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1): + """Create a push subscription. + + :param callback_url: A client-defined URL that the server will call + :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES + :param watermark: An event bookmark as returned by some sync services + :param status_frequency: The frequency, in minutes, that the callback URL will be called with. + :return: The subscription ID and a watermark + """ + if event_types is None: + event_types = SubscribeToPush.EVENT_TYPES + return SubscribeToPush(account=self).get( + folders=None, + event_types=event_types, + watermark=watermark, + status_frequency=status_frequency, + url=callback_url, + ) + + def subscribe_to_streaming(self, event_types=None): + """Create a streaming subscription. + + :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES + :return: The subscription ID + """ + if event_types is None: + event_types = SubscribeToStreaming.EVENT_TYPES + return SubscribeToStreaming(account=self).get(folders=None, event_types=event_types) + + def pull_subscription(self, **kwargs): + return PullSubscription(target=self, **kwargs) + + def push_subscription(self, **kwargs): + return PushSubscription(target=self, **kwargs) + + def streaming_subscription(self, **kwargs): + return StreamingSubscription(target=self, **kwargs) + + def unsubscribe(self, subscription_id): + """Unsubscribe. Only applies to pull and streaming notifications. + + :param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|streaming]() + :return: True + + This method doesn't need the current collection instance, but it makes sense to keep the method along the other + sync methods. + """ + return Unsubscribe(account=self).get(subscription_id=subscription_id) + def __str__(self): if self.fullname: return f"{self.primary_smtp_address} ({self.fullname})" diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 5c2f3af7..5bccd33d 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -631,15 +631,15 @@ def subscribe_to_streaming(self, event_types=None): @require_id def pull_subscription(self, **kwargs): - return PullSubscription(folder=self, **kwargs) + return PullSubscription(target=self, **kwargs) @require_id def push_subscription(self, **kwargs): - return PushSubscription(folder=self, **kwargs) + return PushSubscription(target=self, **kwargs) @require_id def streaming_subscription(self, **kwargs): - return StreamingSubscription(folder=self, **kwargs) + return StreamingSubscription(target=self, **kwargs) def unsubscribe(self, subscription_id): """Unsubscribe. Only applies to pull and streaming notifications. diff --git a/exchangelib/folders/collections.py b/exchangelib/folders/collections.py index 81595a43..c91efba9 100644 --- a/exchangelib/folders/collections.py +++ b/exchangelib/folders/collections.py @@ -448,13 +448,13 @@ def subscribe_to_streaming(self, event_types=None): return SubscribeToStreaming(account=self.account).get(folders=self.folders, event_types=event_types) def pull_subscription(self, **kwargs): - return PullSubscription(folder=self, **kwargs) + return PullSubscription(target=self, **kwargs) def push_subscription(self, **kwargs): - return PushSubscription(folder=self, **kwargs) + return PushSubscription(target=self, **kwargs) def streaming_subscription(self, **kwargs): - return StreamingSubscription(folder=self, **kwargs) + return StreamingSubscription(target=self, **kwargs) def unsubscribe(self, subscription_id): """Unsubscribe. Only applies to pull and streaming notifications. @@ -540,8 +540,8 @@ def sync_hierarchy(self, sync_state=None, only_fields=None): class BaseSubscription(metaclass=abc.ABCMeta): - def __init__(self, folder, **subscription_kwargs): - self.folder = folder + def __init__(self, target, **subscription_kwargs): + self.target = target self.subscription_kwargs = subscription_kwargs self.subscription_id = None @@ -550,19 +550,19 @@ def __enter__(self): """Create the subscription""" def __exit__(self, *args, **kwargs): - self.folder.unsubscribe(subscription_id=self.subscription_id) + self.target.unsubscribe(subscription_id=self.subscription_id) self.subscription_id = None class PullSubscription(BaseSubscription): def __enter__(self): - self.subscription_id, watermark = self.folder.subscribe_to_pull(**self.subscription_kwargs) + self.subscription_id, watermark = self.target.subscribe_to_pull(**self.subscription_kwargs) return self.subscription_id, watermark class PushSubscription(BaseSubscription): def __enter__(self): - self.subscription_id, watermark = self.folder.subscribe_to_push(**self.subscription_kwargs) + self.subscription_id, watermark = self.target.subscribe_to_push(**self.subscription_kwargs) return self.subscription_id, watermark def __exit__(self, *args, **kwargs): @@ -572,5 +572,5 @@ def __exit__(self, *args, **kwargs): class StreamingSubscription(BaseSubscription): def __enter__(self): - self.subscription_id = self.folder.subscribe_to_streaming(**self.subscription_kwargs) + self.subscription_id = self.target.subscribe_to_streaming(**self.subscription_kwargs) return self.subscription_id diff --git a/exchangelib/services/subscribe.py b/exchangelib/services/subscribe.py index d85eb69b..7ca6883b 100644 --- a/exchangelib/services/subscribe.py +++ b/exchangelib/services/subscribe.py @@ -40,9 +40,13 @@ def _get_elements_in_container(cls, container): return [(container.find(f"{{{MNS}}}SubscriptionId"), container.find(f"{{{MNS}}}Watermark"))] def _partial_payload(self, folders, event_types): - request_elem = create_element(self.subscription_request_elem_tag) - folder_ids = folder_ids_element(folders=folders, version=self.account.version, tag="t:FolderIds") - request_elem.append(folder_ids) + if folders is None: + # Interpret this as "all folders" + request_elem = create_element(self.subscription_request_elem_tag, attrs=dict(SubscribeToAllFolders=True)) + else: + request_elem = create_element(self.subscription_request_elem_tag) + folder_ids = folder_ids_element(folders=folders, version=self.account.version, tag="t:FolderIds") + request_elem.append(folder_ids) event_types_elem = create_element("t:EventTypes") for event_type in event_types: add_xml_child(event_types_elem, "t:EventType", event_type) diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index 1f3f7450..5eece424 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -54,6 +54,26 @@ def test_pull_subscribe(self): self.account.root.tois.children.unsubscribe(subscription_id) # Affinity cookie is not always sent by the server for pull subscriptions + def test_pull_subscribe_from_account(self): + self.account.affinity_cookie = None + with self.account.pull_subscription() as (subscription_id, watermark): + self.assertIsNotNone(subscription_id) + self.assertIsNotNone(watermark) + # Test with watermark + with self.account.pull_subscription(watermark=watermark) as (subscription_id, watermark): + self.assertIsNotNone(subscription_id) + self.assertIsNotNone(watermark) + # Context manager already unsubscribed us + with self.assertRaises(ErrorSubscriptionNotFound): + self.account.unsubscribe(subscription_id) + # Test without watermark + with self.account.pull_subscription() as (subscription_id, watermark): + self.assertIsNotNone(subscription_id) + self.assertIsNotNone(watermark) + with self.assertRaises(ErrorSubscriptionNotFound): + self.account.unsubscribe(subscription_id) + # Affinity cookie is not always sent by the server for pull subscriptions + def test_push_subscribe(self): with self.account.inbox.push_subscription(callback_url="https://example.com/foo") as ( subscription_id, @@ -81,6 +101,33 @@ def test_push_subscribe(self): with self.assertRaises(ErrorInvalidSubscription): self.account.root.tois.children.unsubscribe(subscription_id) + def test_push_subscribe_from_account(self): + with self.account.push_subscription(callback_url="https://example.com/foo") as ( + subscription_id, + watermark, + ): + self.assertIsNotNone(subscription_id) + self.assertIsNotNone(watermark) + # Test with watermark + with self.account.push_subscription( + callback_url="https://example.com/foo", + watermark=watermark, + ) as (subscription_id, watermark): + self.assertIsNotNone(subscription_id) + self.assertIsNotNone(watermark) + # Cannot unsubscribe. Must be done as response to callback URL request + with self.assertRaises(ErrorInvalidSubscription): + self.account.unsubscribe(subscription_id) + # Test via folder collection + with self.account.push_subscription(callback_url="https://example.com/foo") as ( + subscription_id, + watermark, + ): + self.assertIsNotNone(subscription_id) + self.assertIsNotNone(watermark) + with self.assertRaises(ErrorInvalidSubscription): + self.account.unsubscribe(subscription_id) + def test_empty_folder_collection(self): self.assertEqual(FolderCollection(account=None, folders=[]).subscribe_to_pull(), None) self.assertEqual(FolderCollection(account=None, folders=[]).subscribe_to_push("http://example.com"), None) @@ -102,6 +149,22 @@ def test_streaming_subscribe(self): # Test affinity cookie self.assertIsNotNone(self.account.affinity_cookie) + def test_streaming_subscribe_from_account(self): + self.account.affinity_cookie = None + with self.account.streaming_subscription() as subscription_id: + self.assertIsNotNone(subscription_id) + # Context manager already unsubscribed us + with self.assertRaises(ErrorSubscriptionNotFound): + self.account.unsubscribe(subscription_id) + # Test via folder collection + with self.account.streaming_subscription() as subscription_id: + self.assertIsNotNone(subscription_id) + with self.assertRaises(ErrorSubscriptionNotFound): + self.account.unsubscribe(subscription_id) + + # Test affinity cookie + self.assertIsNotNone(self.account.affinity_cookie) + def test_sync_folder_hierarchy(self): test_folder = self.get_test_folder().save() From 29b1142e3962ad90904348c739709c2dc55b522b Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 31 Oct 2023 19:20:32 +0100 Subject: [PATCH 393/509] docs: Expand subscription documentation after #1244 (#1247) --- docs/index.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/index.md b/docs/index.md index ba0f7b84..62fa7e36 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1926,8 +1926,21 @@ for change_type, item in a.inbox.sync_items(): # a.inbox.item_sync_state. ``` +EWS allows subscribing to changes in a mailbox. There are three types of subscriptions; pull, +push and streaming. For each type, you can subscribe to all folders at once, to a single +folder, or to a collection of folders (see description of `FolderCollection` above). In the +following, we show only examples for subscriptions on `Account.inbox`, but the others are +also possible. + Here's how to create a pull subscription that can be used to pull events from the server: ```python +# Subscribe to all folders +subscription_id, watermark = a.subscribe_to_pull() + +# Subscribe to a collection of folders +subscription_id, watermark = a.inbox.glob("foo*").subscribe_to_pull() + +# Subscribe to one folder subscription_id, watermark = a.inbox.subscribe_to_pull() ``` From b6e4b1c07c5dedf1fa419ec9769e2447f7200333 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 31 Oct 2023 20:05:50 +0100 Subject: [PATCH 394/509] Bump requests requirement due to CVE-2023-32681 (#1246) * chore: Bump requests requirement due to CVE-2023-32681 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 933e13ff..a1732cb8 100755 --- a/setup.py +++ b/setup.py @@ -48,7 +48,7 @@ def read(file_name): "lxml>3.0", "oauthlib", "pygments", - "requests>=2.7", + "requests>=2.31.0", "requests_ntlm>=0.2.0", "requests_oauthlib", "tzdata", From c11e7947ef1b6f5595ab2b706cb75f938e4d7f42 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 12 Dec 2023 16:20:23 +0100 Subject: [PATCH 395/509] chore: Switch to pyproject.toml --- MANIFEST.in | 1 + pyproject.toml | 77 +++++++++++++++++++++++++++++++++++++++++++++++++ setup.cfg | 9 ------ setup.py | 78 -------------------------------------------------- 4 files changed, 78 insertions(+), 87 deletions(-) delete mode 100644 setup.cfg delete mode 100755 setup.py diff --git a/MANIFEST.in b/MANIFEST.in index ab277940..fab603fb 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,3 +2,4 @@ include MANIFEST.in include LICENSE include CHANGELOG.md include README.md +exclude tests/* diff --git a/pyproject.toml b/pyproject.toml index 5f2f74d6..e391c9b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,80 @@ +# Release notes: +# * Install pdoc3, wheel, twine +# * Bump version in exchangelib/__init__.py +# * Bump version in CHANGELOG.md +# * Generate documentation: +# rm -r docs/exchangelib && pdoc3 --html exchangelib -o docs --force && pre-commit run end-of-file-fixer +# * Commit and push changes +# * Build package: +# rm -rf build dist exchangelib.egg-info && python -m build +# * Push to PyPI: +# twine upload dist/* +# * Create release on GitHub + +[project] +name = "exchangelib" +dynamic = ["version"] +description = "Client for Microsoft Exchange Web Services (EWS)" +readme = {file = "README.md", content-type = "text/markdown"} +requires-python = ">=3.8" +license = {text = "BSD-2-Clause"} +keywords = [ + "ews", + "exchange", + "autodiscover", + "microsoft", + "outlook", + "exchange-web-services", + "o365", + "office365", +] +authors = [ + {name = "Erik Cederstrand", email = "erik@cederstrand.dk"} +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Topic :: Communications", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python :: 3", +] +dependencies = [ + 'backports.zoneinfo; python_version < "3.9"', + "cached_property", + "defusedxml >= 0.6.0", + "dnspython >= 2.2.0", + "isodate", + "lxml>3.0", + "oauthlib", + "pygments", + "requests >= 2.31.0", + "requests_ntlm >= 0.2.0", + "requests_oauthlib", + "tzdata", + "tzlocal", +] + +[project.urls] +Homepage = "https://github.com/ecederstrand/exchangelib" +Issues = "https://github.com/ecederstrand/exchangelib/issues" +Documentation = "https://ecederstrand.github.io/exchangelib/" +Repository = "https://github.com/ecederstrand/exchangelib.git" +Changelog = "https://github.com/ecederstrand/exchangelib/blob/master/CHANGELOG.md" + +[project.optional-dependencies] +kerberos = ["requests_gssapi"] +sspi = ["requests_negotiate_sspi"] +complete = ["requests_gssapi", "requests_negotiate_sspi"] + +[tool.setuptools.dynamic] +version = {attr = "exchangelib.__version__"} + +[bdist_wheel] +universal = 1 + +[tool.flake8] +ignore = ["E203", "W503"] +max-line-length = 120 + [tool.black] line-length = 120 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index ed3536de..00000000 --- a/setup.cfg +++ /dev/null @@ -1,9 +0,0 @@ -[bdist_wheel] -universal = 1 - -[metadata] -license_file = LICENSE - -[flake8] -ignore = E203, W503 -max-line-length = 120 \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100755 index a1732cb8..00000000 --- a/setup.py +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env python -""" -Release notes: -* Install pdoc3, wheel, twine -* Bump version in exchangelib/__init__.py -* Bump version in CHANGELOG.md -* Generate documentation: - rm -r docs/exchangelib && pdoc3 --html exchangelib -o docs --force && pre-commit run end-of-file-fixer -* Commit and push changes -* Build package: - rm -rf build dist exchangelib.egg-info && python setup.py sdist bdist_wheel -* Push to PyPI: - twine upload dist/* -* Create release on GitHub -""" -from pathlib import Path - -from setuptools import find_packages, setup - - -def version(): - for line in read("exchangelib/__init__.py").splitlines(): - if not line.startswith("__version__"): - continue - return line.split("=")[1].strip(" \"'\n") - - -def read(file_name): - return (Path(__file__).parent / file_name).read_text() - - -setup( - name="exchangelib", - version=version(), - author="Erik Cederstrand", - author_email="erik@cederstrand.dk", - description="Client for Microsoft Exchange Web Services (EWS)", - long_description=read("README.md"), - long_description_content_type="text/markdown", - license="BSD-2-Clause", - keywords="ews exchange autodiscover microsoft outlook exchange-web-services o365 office365", - install_requires=[ - 'backports.zoneinfo;python_version<"3.9"', - "cached_property", - "defusedxml>=0.6.0", - "dnspython>=2.2.0", - "isodate", - "lxml>3.0", - "oauthlib", - "pygments", - "requests>=2.31.0", - "requests_ntlm>=0.2.0", - "requests_oauthlib", - "tzdata", - "tzlocal", - ], - extras_require={ - "kerberos": ["requests_gssapi"], - "sspi": ["requests_negotiate_sspi"], # Only for Win32 environments - "complete": ["requests_gssapi", "requests_negotiate_sspi"], # Only for Win32 environments - }, - packages=find_packages(exclude=("tests", "tests.*")), - python_requires=">=3.8", - test_suite="tests", - zip_safe=False, - url="https://github.com/ecederstrand/exchangelib", - project_urls={ - "Bug Tracker": "https://github.com/ecederstrand/exchangelib/issues", - "Documentation": "https://ecederstrand.github.io/exchangelib/", - "Source Code": "https://github.com/ecederstrand/exchangelib", - }, - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Topic :: Communications", - "License :: OSI Approved :: BSD License", - "Programming Language :: Python :: 3", - ], -) From 7bf720d439bebf65f5e9f2ff2900b5fa9aa6c400 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 12 Dec 2023 16:25:48 +0100 Subject: [PATCH 396/509] ci: setup.py no longer exists --- .github/workflows/python-package.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 60c3b3aa..e25ef7ae 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -77,9 +77,9 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - black --check --diff exchangelib tests scripts setup.py - isort --check --diff exchangelib tests scripts setup.py - flake8 exchangelib tests scripts setup.py + black --check --diff exchangelib tests scripts + isort --check --diff exchangelib tests scripts + flake8 exchangelib tests scripts blacken-docs *.md docs/*.md unittest-parallel -j 4 --level=class --coverage --coverage-source exchangelib coveralls --service=github From f5c454adfc1370699aa3a987797f04928f0d5be4 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sun, 3 Mar 2024 16:17:07 +0100 Subject: [PATCH 397/509] test: Update timezone tests to newst timezone releases --- exchangelib/winzone.py | 17 +++-------------- tests/test_ewsdatetime.py | 20 ++++++++++++++++++-- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/exchangelib/winzone.py b/exchangelib/winzone.py index 4b2d3789..7d82762d 100644 --- a/exchangelib/winzone.py +++ b/exchangelib/winzone.py @@ -190,11 +190,9 @@ def generate_map(timeout=10): "America/Moncton": ("Atlantic Standard Time", "CA"), "America/Monterrey": ("Central Standard Time (Mexico)", "MX"), "America/Montevideo": ("Montevideo Standard Time", "001"), - "America/Montreal": ("Eastern Standard Time", "CA"), "America/Montserrat": ("SA Western Standard Time", "MS"), "America/Nassau": ("Eastern Standard Time", "BS"), "America/New_York": ("Eastern Standard Time", "001"), - "America/Nipigon": ("Eastern Standard Time", "CA"), "America/Nome": ("Alaskan Standard Time", "US"), "America/Noronha": ("UTC-02", "BR"), "America/North_Dakota/Beulah": ("Central Standard Time", "US"), @@ -202,7 +200,6 @@ def generate_map(timeout=10): "America/North_Dakota/New_Salem": ("Central Standard Time", "US"), "America/Ojinaga": ("Central Standard Time", "MX"), "America/Panama": ("SA Pacific Standard Time", "PA"), - "America/Pangnirtung": ("Eastern Standard Time", "CA"), "America/Paramaribo": ("SA Eastern Standard Time", "SR"), "America/Phoenix": ("US Mountain Standard Time", "001"), "America/Port-au-Prince": ("Haiti Standard Time", "001"), @@ -210,13 +207,11 @@ def generate_map(timeout=10): "America/Porto_Velho": ("SA Western Standard Time", "BR"), "America/Puerto_Rico": ("SA Western Standard Time", "PR"), "America/Punta_Arenas": ("Magallanes Standard Time", "001"), - "America/Rainy_River": ("Central Standard Time", "CA"), "America/Rankin_Inlet": ("Central Standard Time", "CA"), "America/Recife": ("SA Eastern Standard Time", "BR"), "America/Regina": ("Canada Central Standard Time", "001"), "America/Resolute": ("Central Standard Time", "CA"), "America/Rio_Branco": ("SA Pacific Standard Time", "BR"), - "America/Santa_Isabel": ("Pacific Standard Time (Mexico)", "MX"), "America/Santarem": ("SA Eastern Standard Time", "BR"), "America/Santiago": ("Pacific SA Standard Time", "001"), "America/Santo_Domingo": ("SA Western Standard Time", "DO"), @@ -232,7 +227,6 @@ def generate_map(timeout=10): "America/Swift_Current": ("Canada Central Standard Time", "CA"), "America/Tegucigalpa": ("Central America Standard Time", "HN"), "America/Thule": ("Atlantic Standard Time", "GL"), - "America/Thunder_Bay": ("Eastern Standard Time", "CA"), "America/Tijuana": ("Pacific Standard Time (Mexico)", "001"), "America/Toronto": ("Eastern Standard Time", "CA"), "America/Tortola": ("SA Western Standard Time", "VG"), @@ -240,7 +234,6 @@ def generate_map(timeout=10): "America/Whitehorse": ("Yukon Standard Time", "001"), "America/Winnipeg": ("Central Standard Time", "CA"), "America/Yakutat": ("Alaskan Standard Time", "US"), - "America/Yellowknife": ("Mountain Standard Time", "CA"), "Antarctica/Casey": ("Central Pacific Standard Time", "AQ"), "Antarctica/Davis": ("SE Asia Standard Time", "AQ"), "Antarctica/DumontDUrville": ("West Pacific Standard Time", "AQ"), @@ -253,7 +246,7 @@ def generate_map(timeout=10): "Antarctica/Vostok": ("Central Asia Standard Time", "AQ"), "Arctic/Longyearbyen": ("W. Europe Standard Time", "SJ"), "Asia/Aden": ("Arab Standard Time", "YE"), - "Asia/Almaty": ("Central Asia Standard Time", "001"), + "Asia/Almaty": ("West Asia Standard Time", "KZ"), "Asia/Amman": ("Jordan Standard Time", "001"), "Asia/Anadyr": ("Russia Time Zone 11", "RU"), "Asia/Aqtau": ("West Asia Standard Time", "KZ"), @@ -266,7 +259,7 @@ def generate_map(timeout=10): "Asia/Bangkok": ("SE Asia Standard Time", "001"), "Asia/Barnaul": ("Altai Standard Time", "001"), "Asia/Beirut": ("Middle East Standard Time", "001"), - "Asia/Bishkek": ("Central Asia Standard Time", "KG"), + "Asia/Bishkek": ("Central Asia Standard Time", "001"), "Asia/Brunei": ("Singapore Standard Time", "BN"), "Asia/Calcutta": ("India Standard Time", "001"), "Asia/Chita": ("Transbaikal Standard Time", "001"), @@ -309,7 +302,7 @@ def generate_map(timeout=10): "Asia/Pontianak": ("SE Asia Standard Time", "ID"), "Asia/Pyongyang": ("North Korea Standard Time", "001"), "Asia/Qatar": ("Arab Standard Time", "QA"), - "Asia/Qostanay": ("Central Asia Standard Time", "KZ"), + "Asia/Qostanay": ("West Asia Standard Time", "KZ"), "Asia/Qyzylorda": ("Qyzylorda Standard Time", "001"), "Asia/Rangoon": ("Myanmar Standard Time", "001"), "Asia/Riyadh": ("Arab Standard Time", "001"), @@ -348,7 +341,6 @@ def generate_map(timeout=10): "Australia/Adelaide": ("Cen. Australia Standard Time", "001"), "Australia/Brisbane": ("E. Australia Standard Time", "001"), "Australia/Broken_Hill": ("Cen. Australia Standard Time", "AU"), - "Australia/Currie": ("Tasmania Standard Time", "AU"), "Australia/Darwin": ("AUS Central Standard Time", "001"), "Australia/Eucla": ("Aus Central W. Standard Time", "001"), "Australia/Hobart": ("Tasmania Standard Time", "001"), @@ -437,7 +429,6 @@ def generate_map(timeout=10): "Europe/Tallinn": ("FLE Standard Time", "EE"), "Europe/Tirane": ("Central Europe Standard Time", "AL"), "Europe/Ulyanovsk": ("Astrakhan Standard Time", "RU"), - "Europe/Uzhgorod": ("FLE Standard Time", "UA"), "Europe/Vaduz": ("W. Europe Standard Time", "LI"), "Europe/Vatican": ("W. Europe Standard Time", "VA"), "Europe/Vienna": ("W. Europe Standard Time", "AT"), @@ -445,7 +436,6 @@ def generate_map(timeout=10): "Europe/Volgograd": ("Volgograd Standard Time", "001"), "Europe/Warsaw": ("Central European Standard Time", "001"), "Europe/Zagreb": ("Central European Standard Time", "HR"), - "Europe/Zaporozhye": ("FLE Standard Time", "UA"), "Europe/Zurich": ("W. Europe Standard Time", "CH"), "Indian/Antananarivo": ("E. Africa Standard Time", "MG"), "Indian/Chagos": ("Central Asia Standard Time", "IO"), @@ -475,7 +465,6 @@ def generate_map(timeout=10): "Pacific/Guadalcanal": ("Central Pacific Standard Time", "001"), "Pacific/Guam": ("West Pacific Standard Time", "GU"), "Pacific/Honolulu": ("Hawaiian Standard Time", "001"), - "Pacific/Johnston": ("Hawaiian Standard Time", "UM"), "Pacific/Kiritimati": ("Line Islands Standard Time", "001"), "Pacific/Kosrae": ("Central Pacific Standard Time", "FM"), "Pacific/Kwajalein": ("UTC+12", "MH"), diff --git a/tests/test_ewsdatetime.py b/tests/test_ewsdatetime.py index 5976868f..c4c05733 100644 --- a/tests/test_ewsdatetime.py +++ b/tests/test_ewsdatetime.py @@ -221,12 +221,28 @@ def test_generate(self): return self.assertEqual(type_version, CLDR_WINZONE_TYPE_VERSION) self.assertEqual(other_version, CLDR_WINZONE_OTHER_VERSION) - self.assertDictEqual(tz_map, CLDR_TO_MS_TIMEZONE_MAP) + self.assertDictEqual(CLDR_TO_MS_TIMEZONE_MAP, tz_map) # Test IANA exceptions. This fails if available_timezones() returns timezones that we have not yet implemented. # If this fails in CI but not locally, you need to update the 'tzdata' package to the latest version. sanitized = list(t for t in zoneinfo.available_timezones() if not t.startswith("SystemV/") and t != "localtime") - self.assertEqual(set(sanitized) - set(EWSTimeZone.IANA_TO_MS_MAP), set()) + # TODO: IANA removed timezones that zoneinfo still has + self.assertEqual( + set(sanitized) - set(EWSTimeZone.IANA_TO_MS_MAP), + { + "Europe/Uzhgorod", + "America/Rainy_River", + "America/Santa_Isabel", + "Australia/Currie", + "Pacific/Johnston", + "America/Pangnirtung", + "America/Yellowknife", + "Europe/Zaporozhye", + "America/Montreal", + "America/Nipigon", + "America/Thunder_Bay", + }, + ) @requests_mock.mock() def test_generate_failure(self, m): From 1ba43a8be3f2008d1bbd812d81efde37e3895226 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sun, 3 Mar 2024 16:17:53 +0100 Subject: [PATCH 398/509] test: Disable autodiscover test that's throwing a server error --- tests/test_autodiscover.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 670fb5a4..eb56f12b 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -231,6 +231,11 @@ def test_autodiscover_with_delegate(self): if not self.settings.get("client_id") or not self.settings.get("username"): self.skipTest("This test requires delegate OAuth setup") + self.skipTest( + "Currently throws this error: Due to a configuration change made by your administrator, or because " + "you moved to a new location, you must use multi-factor authentication to access '0000-aaa-bbb-0000'" + ) + credentials = OAuth2LegacyCredentials( client_id=self.settings["client_id"], client_secret=self.settings["client_secret"], From 3ee351adf74262d04417a4cc0017888a9233d303 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sun, 3 Mar 2024 16:20:08 +0100 Subject: [PATCH 399/509] fix: re-add explicit mailbox to distinguised folders. Needed in delegate mode. Refs #1222 --- exchangelib/folders/base.py | 14 ++++++++++++-- exchangelib/folders/roots.py | 23 +++++++++++++++++++---- tests/test_folder.py | 3 ++- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 5bccd33d..d1a2cd34 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -32,6 +32,7 @@ DistinguishedFolderId, EWSMeta, FolderId, + Mailbox, ParentFolderId, UserConfiguration, UserConfigurationName, @@ -503,7 +504,10 @@ def to_id(self): return self._id if not self.DISTINGUISHED_FOLDER_ID: raise ValueError(f"{self} must be a distinguished folder or have an ID") - self._distinguished_id = DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID) + self._distinguished_id = DistinguishedFolderId( + id=self.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ) return self._distinguished_id @classmethod @@ -860,7 +864,13 @@ def get_distinguished(cls, root): :return: """ try: - return cls.resolve(account=root.account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) + return cls.resolve( + account=root.account, + folder=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=root.account.primary_smtp_address), + ), + ) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") diff --git a/exchangelib/folders/roots.py b/exchangelib/folders/roots.py index c5dfa074..aa2a80b0 100644 --- a/exchangelib/folders/roots.py +++ b/exchangelib/folders/roots.py @@ -4,7 +4,7 @@ from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorInvalidOperation from ..fields import EffectiveRightsField -from ..properties import DistinguishedFolderId, EWSMeta +from ..properties import DistinguishedFolderId, EWSMeta, Mailbox from ..version import EXCHANGE_2007_SP1, EXCHANGE_2010_SP1 from .base import BaseFolder from .collections import FolderCollection @@ -110,7 +110,13 @@ def get_distinguished(cls, account): if not cls.DISTINGUISHED_FOLDER_ID: raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: - return cls.resolve(account=account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) + return cls.resolve( + account=account, + folder=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=account.primary_smtp_address), + ), + ) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") @@ -134,7 +140,13 @@ def get_default_folder(self, folder_cls): except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) - fld = folder_cls(root=self, _distinguished_id=DistinguishedFolderId(id=folder_cls.DISTINGUISHED_FOLDER_ID)) + fld = folder_cls( + root=self, + _distinguished_id=DistinguishedFolderId( + id=folder_cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ), + ) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: @@ -152,7 +164,10 @@ def _folders_map(self): # so we are sure to apply the correct Folder class, then fetch all sub-folders of this root. folders_map = {self.id: self} distinguished_folders = [ - DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID) + DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ) for cls in self.WELLKNOWN_FOLDERS if cls.get_folder_allowed and cls.supports_version(self.account.version) ] diff --git a/tests/test_folder.py b/tests/test_folder.py index fea0417f..b6c483ae 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -479,7 +479,8 @@ def test_get_folders_with_distinguished_id(self): # Test that we return an Inbox instance and not a generic Messages or Folder instance when we call GetFolder # with a DistinguishedFolderId instance with an ID of Inbox.DISTINGUISHED_FOLDER_ID. inbox_folder_id = DistinguishedFolderId( - id=Inbox.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address) + id=Inbox.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), ) inbox = list( GetFolder(account=self.account).call( From 7401f514680775e0d851b5aa5c885b14631cff10 Mon Sep 17 00:00:00 2001 From: a76yyyy <56478790+a76yyyy@users.noreply.github.com> Date: Mon, 4 Mar 2024 05:13:41 +0800 Subject: [PATCH 400/509] =?UTF-8?q?Feature:=20=F0=9F=9A=80=20Add=20InboxRu?= =?UTF-8?q?le=20implement=20=20(#1272)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feature(services): 🚀 Add InboxRule implement Fixes #233 Co-authored-by: ecederstrand --- .flake8 | 3 + docs/index.md | 170 ++++++++++++++++++++------ exchangelib/account.py | 51 +++++++- exchangelib/credentials.py | 1 + exchangelib/errors.py | 1 + exchangelib/fields.py | 62 ++++++++++ exchangelib/properties.py | 179 ++++++++++++++++++++++++++++ exchangelib/protocol.py | 1 + exchangelib/services/__init__.py | 5 + exchangelib/services/inbox_rules.py | 121 +++++++++++++++++++ exchangelib/services/subscribe.py | 1 + exchangelib/winzone.py | 1 + scripts/notifier.py | 1 + tests/test_account.py | 39 +++++- 14 files changed, 595 insertions(+), 41 deletions(-) create mode 100644 .flake8 create mode 100644 exchangelib/services/inbox_rules.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..70e14a8e --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +ignore = E203, W503 +max-line-length = 120 diff --git a/docs/index.md b/docs/index.md index 62fa7e36..a5ed5339 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,8 +3,7 @@ layout: default title: exchangelib --- -Exchange Web Services client library -==================================== +# Exchange Web Services client library This module is an ORM for your Exchange mailbox, providing Django-style access to all your data. It is a platform-independent, well-performing, well-behaving, well-documented, well-tested and simple interface for @@ -15,21 +14,21 @@ exporting and uploading calendar, mailbox, task, contact and distribution list i Apart from this documentation, we also provide online [source code documentation](https://ecederstrand.github.io/exchangelib/exchangelib/). - ## Table of Contents + * [Installation](#installation) * [Setup and connecting](#setup-and-connecting) - * [Optimizing connections](#optimizing-connections) - * [Fault tolerance](#fault-tolerance) - * [Kerberos and SSPI authentication](#kerberos-and-sspi-authentication) - * [Certificate Based Authentication (CBA)](#certificate-based-authentication-cba) - * [OAuth authentication](#oauth-authentication) - * [Impersonation OAuth on Office 365](#impersonation-oauth-on-office-365) - * [Delegate OAuth on Office 365](#delegate-oauth-on-office-365) - * [MSAL on Office 365](#msal-on-office-365) - * [Caching autodiscover results](#caching-autodiscover-results) - * [Proxies and custom TLS validation](#proxies-and-custom-tls-validation) - * [User-Agent](#user-agent) + * [Optimizing connections](#optimizing-connections) + * [Fault tolerance](#fault-tolerance) + * [Kerberos and SSPI authentication](#kerberos-and-sspi-authentication) + * [Certificate Based Authentication (CBA)](#certificate-based-authentication-cba) + * [OAuth authentication](#oauth-authentication) + * [Impersonation OAuth on Office 365](#impersonation-oauth-on-office-365) + * [Delegate OAuth on Office 365](#delegate-oauth-on-office-365) + * [MSAL on Office 365](#msal-on-office-365) + * [Caching autodiscover results](#caching-autodiscover-results) + * [Proxies and custom TLS validation](#proxies-and-custom-tls-validation) + * [User-Agent](#user-agent) * [Folders](#folders) * [Dates, datetimes and timezones](#dates-datetimes-and-timezones) * [Creating, updating, deleting, sending, moving, archiving, marking as junk](#creating-updating-deleting-sending-moving-archiving-marking-as-junk) @@ -45,14 +44,15 @@ Apart from this documentation, we also provide online * [Out of Facility (OOF)](#out-of-facility-oof) * [Mail tips](#mail-tips) * [Delegate information](#delegate-information) +* [InboxRules](#inboxrules) * [Export and upload](#export-and-upload) * [Synchronization, subscriptions and notifications](#synchronization-subscriptions-and-notifications) * [Non-account services](#non-account-services) * [Troubleshooting](#troubleshooting) * [Tests](#tests) - ## Installation + You can install this package from PyPI: ```bash @@ -84,6 +84,7 @@ This package uses the `lxml` package, and `pykerberos` to support Kerberos authe To be able to install these, you may need to install some additional operating system packages. On Ubuntu: + ```bash apt-get install libxml2-dev libxslt1-dev @@ -92,12 +93,14 @@ apt-get install libkrb5-dev build-essential libssl-dev libffi-dev python-dev ``` On CentOS: + ```bash # For Kerberos support, install these: yum install gcc python-devel krb5-devel krb5-workstation python-devel ``` On FreeBSD, `pip` needs a little help: + ```bash pkg install libxml2 libxslt CFLAGS=-I/usr/local/include pip install lxml @@ -110,13 +113,12 @@ CFLAGS=-I/usr/local/include pip install kerberos pykerberos For other operating systems, please consult the documentation for the Python package that fails to install. - ## Setup and connecting First, specify your credentials. Username is usually in `WINDOMAIN\username` format, where `WINDOMAIN` is the name of the Windows Domain your username is connected to, but some servers also accept usernames in PrimarySMTPAddress -('myusername@example.com') format (Office365 requires it). UPN format is also +(`myusername@example.com`) format (Office365 requires it). UPN format is also supported, if your server expects that. ```python @@ -126,6 +128,7 @@ credentials = Credentials(username="MYWINDOMAIN\\myuser", password="topsecret") # For Office365 credentials = Credentials(username="myuser@example.com", password="topsecret") ``` + If you're running long-running jobs, you may want to enable fault-tolerance. Fault-tolerance means that requests to the server do an exponential backoff and sleep for up to a certain threshold before giving up, if the server is @@ -196,6 +199,7 @@ johns_account = Account( If you want to impersonate an account and access a shared folder that this account has access to, you need to specify the email adress of the shared folder to access the folder: + ```python from exchangelib.folders import Calendar, SingleFolderQuerySet from exchangelib.properties import DistinguishedFolderId, Mailbox @@ -219,7 +223,6 @@ from exchangelib.autodiscover import Autodiscovery Autodiscovery.DNS_RESOLVER_ATTRS["edns"] = False # Disable EDNS queries ``` - ### Optimizing connections According to MSDN docs, you can avoid a per-request AD lookup if you specify @@ -235,6 +238,7 @@ account.identity.upn = "john@subdomain.example.com" If the server doesn't support autodiscover, or you want to avoid the overhead of autodiscover, use a Configuration object to set the hostname instead: + ```python from exchangelib import Configuration, Credentials @@ -280,6 +284,7 @@ config = Configuration(server="mail.example.com", max_connections=10) ``` ### Fault tolerance + By default, we fail on all exceptions from the server. If you want to enable fault tolerance, add a retry policy to your configuration. We will then retry on certain transient errors. By default, we back off exponentially and retry @@ -306,6 +311,7 @@ Autodiscovery.INITIAL_RETRY_POLICY = FaultTolerance(max_wait=30) ``` ### Kerberos and SSPI authentication + Kerberos and SSPI authentication are supported via the GSSAPI and SSPI auth types. @@ -317,6 +323,7 @@ config = Configuration(auth_type=SSPI) ``` ### Certificate Based Authentication (CBA) + ```python from exchangelib import Configuration, BaseProtocol, CBA, TLSClientAuth @@ -326,6 +333,7 @@ config = Configuration(auth_type=CBA) ``` ### OAuth authentication + OAuth is supported via the OAUTH2 auth type and the OAuth2Credentials class. Use OAuth2AuthorizationCodeCredentials instead for the authorization code flow (useful for applications that access multiple accounts). @@ -412,15 +420,18 @@ class MyCredentials(OAuth2AuthorizationCodeCredentials): ``` ### Impersonation OAuth on Office 365 + Office 365 is deprecating Basic authentication and switching to MFA for end users and OAuth for everything else. Here's one way to set up an app in Azure that can access accounts in your organization using impersonation - i.e. access to multiple acounts on behalf of those users. First, log into the -[https://admin.microsoft.com](Microsoft 365 Administration) page. Find the link +[Microsoft 365 Administration](https://admin.microsoft.com) page. Find the link to `Azure Active Directory`. Find the link to `Azure Active Directory`. Select `App registrations` in the menu and then `New registration`. Enter an app name and press `Register`: + ![App registration](/exchangelib/assets/img/app_registration.png) + On the next page, note down the Directory (tenant) ID and Application (client) ID, create a secret using the `Add a certificate or secret` link, and note down the Value (client secret) as well. @@ -430,27 +441,32 @@ Continue to the `App registraions` menu item, select your new app, select the `APIs my organization uses` and search for `Office 365 Exchange Online`. Select that API, then `Application permissions` and add the `full_access_as_app` permission: + ![API permissions](/exchangelib/assets/img/api_permissions.png) Finally, continue to the `Enterprise applications` page, select your new app, continue to the `Permissions` page, and check that your app has the `full_access_as_app` permission: + ![API permissions](/exchangelib/assets/img/permissions.png) + If not, press `Grant admin consent for testsuite` and grant access. You should now be able to connect to an account using the `OAuth2Credentials` class as shown above. - ### Delegate OAuth on Office 365 + If you only want to access a single account on Office 365, delegate access is a more suitable access level. Here's one way to set up an app in Azure that can access accounts in your organization using delegation - i.e. access to the same account that you are logging in as. First, log into the -[https://admin.microsoft.com](Microsoft 365 Administration) page. Find the link +[Microsoft 365 Administration](https://admin.microsoft.com) page. Find the link to `Azure Active Directory`. Select `App registrations` in the menu and then `New registration`. Enter an app name and press `Register`: + ![App registration](/exchangelib/assets/img/delegate_app_registration.png) + On the next page, note down the Directory (tenant) ID and Application (client) ID, create a secret using the `Add a certificate or secret` link, and note down the Value (client secret) as well. @@ -460,19 +476,22 @@ Continue to the `App registraions` menu item, select your new app, select the `APIs my organization uses` and search for `Office 365 Exchange Online`. Select that API and then `Delegated permissions` and add the `EWS.AccessAsUser.All` permission under the `EWS` section: + ![API permissions](/exchangelib/assets/img/delegate_app_api_permissions.png) Finally, continue to the `Enterprise applications` page, select your new app, continue to the `Permissions` page, and check that your app has the `EWS.AccessAsUser.All` permission: + ![API permissions](/exchangelib/assets/img/delegate_app_permissions.png) + If not, press `Grant admin consent for testsuite_delegate` and grant access. You should now be able to connect to an account using the `OAuth2LegacyCredentials` class as shown above. - ### MSAL on Office 365 + The [Microsoft Authentication Library](https://github.com/AzureAD/microsoft-authentication-library-for-python) supports obtaining OAuth tokens via a range of different methods. You can use MSAL to fetch a token valid for EWS and then only provide the token to this @@ -526,8 +545,8 @@ a = Account( print(a.root.tree()) ``` - ### Caching autodiscover results + If you're connecting to the same account very often, you can cache the autodiscover result for later so you can skip the autodiscover lookup: @@ -567,6 +586,7 @@ shared between processes and is not deleted when your program exits. A cache entry for a domain is removed automatically if autodiscovery fails for an email in that domain. It's possible to clear the entire cache completely if you want: + ```python from exchangelib.autodiscover import clear_cache @@ -649,6 +669,7 @@ BaseProtocol.USERAGENT = "Auto-Reply/0.1.0" ``` ## Folders + All wellknown folders are available as properties on the account, e.g. as `account.root`, `account.calendar`, `account.trash`, `account.inbox`, `account.outbox`, `account.sent`, `account.junk`, `account.tasks` and `account.contacts`. @@ -698,6 +719,7 @@ some_folder.absolute # Returns the full path as a string ``` tree() returns a string representation of the tree structure at a given level + ```python print(a.root.tree()) """ @@ -951,6 +973,7 @@ forward_draft.send() EWS distinguishes between plain text and HTML body contents. If you want to send HTML body content, use the HTMLBody helper. Clients will see this as HTML and display the body correctly: + ```python from exchangelib import HTMLBody @@ -1111,6 +1134,7 @@ folder_is_empty = not a.inbox.all().exists() # Efficient tasting ``` Restricting returned attributes: + ```python sparse_items = a.inbox.all().only("subject", "start") # Dig deeper on indexed properties @@ -1120,6 +1144,7 @@ sparse_items = a.contacts.all().only("physical_addresses__Home__street") ``` Return values as dicts, nested or flat lists instead of objects: + ```python ids_as_dict = a.inbox.all().values("id", "changekey") values_as_list = a.inbox.all().values_list("subject", "body") @@ -1217,7 +1242,7 @@ validating the syntax of the QueryString - we just pass the string verbatim to EWS. Read more about the QueryString syntax here: -https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/querystring-querystringtype + ```python a.inbox.filter("subject:XXX") @@ -1504,7 +1529,6 @@ att = FileAttachment( contact.attach(att) ``` - ## Extended properties Extended properties makes it possible to attach custom key-value pairs @@ -1582,6 +1606,7 @@ others don't. Custom fields must be registered on the generic Folder or RootOfHierarchy folder classes. Here's an example of getting the size (in bytes) of a folder: + ```python from exchangelib import ExtendedProperty, Folder @@ -1596,18 +1621,18 @@ print(a.inbox.size) ``` In general, here's how to work with any MAPI property as listed in e.g. -https://docs.microsoft.com/en-us/office/client-developer/outlook/mapi/mapi-properties. +. Let's take `PidLidTaskDueDate` as an example. This is the due date for a message maked with the follow-up flag in Microsoft Outlook. `PidLidTaskDueDate` is documented at -https://docs.microsoft.com/en-us/office/client-developer/outlook/mapi/pidlidtaskduedate-canonical-property. +. The property ID is `0x00008105` and the property set is `PSETID_Task`. But EWS wants the UUID for `PSETID_Task`, so we look that up in the MS-OXPROPS pdf: -https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxprops/f6ab1613-aefe-447d-a49c-18217230b148 + The UUID is `00062003-0000-0000-C000-000000000046`. The property type is `PT_SYSTIME` which is also called `SystemTime` (see -https://docs.microsoft.com/en-us/dotnet/api/microsoft.exchange.webservices.data.mapipropertytype ) + ) In conclusion, the definition for the due date becomes: @@ -1632,7 +1657,6 @@ FileAttachments have a 'content' attribute containing the binary content of the file, and ItemAttachments have an 'item' attribute containing the item. The item can be a Message, CalendarItem, Task etc. - ```python import os.path from exchangelib import Account, FileAttachment, ItemAttachment, Message @@ -1694,6 +1718,7 @@ item.detach(my_file) If you want to embed an image in the item body, you can link to the file in the HTML. + ```python from exchangelib import HTMLBody @@ -1810,10 +1835,10 @@ master.save(update_fields=["subject"]) Each `Message` item has four timestamp fields: -- `datetime_created` -- `datetime_sent` -- `datetime_received` -- `last_modified_time` +* `datetime_created` +* `datetime_sent` +* `datetime_received` +* `last_modified_time` The values for these fields are set by the Exchange server and are not modifiable via EWS. All values are timezone-aware `EWSDateTime` @@ -1851,8 +1876,8 @@ a.oof_settings = OofSettings( ) ``` - ## Mail tips + Mail tips for an account contain some extra information about the account, e.g. OOF information, max message size, whether the mailbox is full, messages are moderated etc. Here's how to get mail tips for a single account: @@ -1864,11 +1889,12 @@ a = Account(...) print(a.mail_tips) ``` - ## Delegate information + An account can have delegates, which are other users that are allowed to access the account. Here's how to fetch information about those delegates, including which level of access they have to the account. + ```python from exchangelib import Account @@ -1876,6 +1902,69 @@ a = Account(...) print(a.delegates) ``` +## Inbox Rules + +An account can have several inbox rules, which are used to trigger the rule actions for a rule based on the corresponding +conditions in the mailbox. Here's how to fetch information about those rules: + +```python +from exchangelib import Account + +a = Account(...) +print(a.rules) +``` + +The `InboxRules` element represents an array of rules in the user's mailbox. Each `Rule` is structured as follows: + +* `id`: Specifies the rule identifier. +* `display_name`: Contains the display name of a rule. +* `priority`: Indicates the order in which a rule is to be run. +* `is_enabled`: Indicates whether the rule is enabled. +* `is_not_supported`: Indicates whether the rule cannot be modified with the managed code APIs. +* `is_in_error`: Indicates whether the rule is in an error condition. +* `conditions`: Identifies the conditions that, when fulfilled, will trigger the rule actions for a rule. +* `exceptions`: Identifies the exceptions that represent all the available rule exception conditions for the inbox rule. +* `actions`: Represents the actions to be taken on a message when the conditions are fulfilled. + +Here are examples of operations for adding, deleting, modifying, and querying InboxRules. + +```python +from exchangelib import Account +from exchangelib.properties import Actions, Conditions, Exceptions, Rule + +a = Account(...) + +print("Rules before creation:", a.rules, "\n") + +# Create Rule instance +rule = Rule( + display_name="test_exchangelib_rule", + priority=1, + is_enabled=True, + conditions=Conditions(contains_sender_strings=["sender_example"]), + exceptions=Exceptions(), + actions=Actions(delete=True), +) + +# Create rule +a.create_rule(rule) +print("Rule:", rule) +print("Created rule with ID:", rule.id, "\n") + +# Get rule list +print("Rules after creation:", a.rules, "\n") + +# Modify rule +print("Modifying rule with ID:", rule.id) +rule.display_name = "test_exchangelib_rule(modified)" +a.set_rule(rule) +print("Rules after modification:", a.rules, "\n") + +# Delete rule +print("Deleting rule with ID:", rule.id) +a.delete_rule(rule=rule) +print("Rules after deletion:", a.rules) +``` ## Export and upload @@ -1902,6 +1991,7 @@ using EWS is available at [https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/notification-subscriptions-mailbox-events-and-ews-in-exchange](https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/notification-subscriptions-mailbox-events-and-ews-in-exchange): The following shows how to synchronize folders and items: + ```python from exchangelib import Account @@ -1933,6 +2023,7 @@ following, we show only examples for subscriptions on `Account.inbox`, but the o also possible. Here's how to create a pull subscription that can be used to pull events from the server: + ```python # Subscribe to all folders subscription_id, watermark = a.subscribe_to_pull() @@ -1948,6 +2039,7 @@ Here's how to create a push subscription. The server will regularly send an HTTP request to the callback URL to deliver changes or a status message. There is also support for parsing the POST data that the Exchange server sends to the callback URL, and for creating proper responses to these URLs. + ```python subscription_id, watermark = a.inbox.subscribe_to_push( callback_url="https://my_app.example.com/callback_url" @@ -1958,6 +2050,7 @@ When the server sends a push notification, the POST data contains a `SendNotification` XML document. You can use this package in the callback URL implementation to parse this data. Here's a short example of a Flask app that handles these documents: + ```python from exchangelib.services import SendNotification from flask import Flask, request @@ -1980,6 +2073,7 @@ def notify_me(): Here's how to create a streaming subscription that can be used to stream events from the server. + ```python subscription_id = a.inbox.subscribe_to_streaming() ``` @@ -1987,12 +2081,14 @@ subscription_id = a.inbox.subscribe_to_streaming() Cancel the subscription when you're done synchronizing. This is not supported for push subscriptions. They cancel automatically after a certain amount of failed attempts, or you can let your callback URL send an unsubscribe response as described above. + ```python a.inbox.unsubscribe(subscription_id) ``` When creating subscriptions, you can also use one of the three context managers that handle unsubscription automatically: + ```python with a.inbox.pull_subscription() as (subscription_id, watermark): pass @@ -2153,7 +2249,6 @@ for busy_info in a.protocol.get_free_busy_info(accounts=accounts, start=start, e print(account_tz.localize(event.start), account_tz.localize(event.end)) ``` - ## Troubleshooting If you are having trouble using this library, the first thing to try is @@ -2182,7 +2277,6 @@ from exchangelib import CalendarItem print(CalendarItem.__doc__) ``` - ## Tests The test suite is split into unit tests, and integration tests that require a real Exchange diff --git a/exchangelib/account.py b/exchangelib/account.py index b26feb35..966db5cb 100644 --- a/exchangelib/account.py +++ b/exchangelib/account.py @@ -6,7 +6,7 @@ from .autodiscover import Autodiscovery from .configuration import Configuration from .credentials import ACCESS_TYPES, DELEGATE, IMPERSONATION -from .errors import InvalidEnumValue, InvalidTypeError, UnknownTimeZone +from .errors import ErrorItemNotFound, InvalidEnumValue, InvalidTypeError, ResponseMessageError, UnknownTimeZone from .ewsdatetime import UTC, EWSTimeZone from .fields import FieldPath, TextField from .folders import ( @@ -56,16 +56,19 @@ ) from .folders.collections import PullSubscription, PushSubscription, StreamingSubscription from .items import ALL_OCCURRENCES, AUTO_RESOLVE, HARD_DELETE, ID_ONLY, SAVE_ONLY, SEND_TO_NONE -from .properties import EWSElement, Mailbox, SendingAs +from .properties import EWSElement, Mailbox, Rule, SendingAs from .protocol import Protocol from .queryset import QuerySet from .services import ( ArchiveItem, CopyItem, + CreateInboxRule, CreateItem, + DeleteInboxRule, DeleteItem, ExportItems, GetDelegate, + GetInboxRules, GetItem, GetMailTips, GetPersona, @@ -73,6 +76,7 @@ MarkAsJunk, MoveItem, SendItem, + SetInboxRule, SetUserOofSettings, SubscribeToPull, SubscribeToPush, @@ -747,6 +751,49 @@ def delegates(self): """Return a list of DelegateUser objects representing the delegates that are set on this account.""" return list(GetDelegate(account=self).call(user_ids=None, include_permissions=True)) + @property + def rules(self): + """Return a list of Rule objects representing the rules that are set on this account.""" + return list(GetInboxRules(account=self).call()) + + def create_rule(self, rule: Rule): + """Create an Inbox rule. + + :param rule: The rule to create. Must have at least 'display_name' set. + :return: None if success, else raises an error. + """ + CreateInboxRule(account=self).get(rule=rule, remove_outlook_rule_blob=True) + # After creating the rule, query all rules, + # find the rule that was just created, and return its ID. + try: + rule.id = {i.display_name: i for i in GetInboxRules(account=self).call()}[rule.display_name].id + except KeyError: + raise ResponseMessageError(f"Failed to create rule ({rule.display_name})!") + + def set_rule(self, rule: Rule): + """Modify an Inbox rule. + + :param rule: The rule to set. Must have an ID. + :return: None if success, else raises an error. + """ + SetInboxRule(account=self).get(rule=rule) + + def delete_rule(self, rule: Rule): + """Delete an Inbox rule. + + :param rule: The rule to delete. Must have ID or 'display_name'. + :return: None if success, else raises an error. + """ + if not rule.id: + if not rule.display_name: + raise ValueError("Rule must have ID or display_name") + try: + rule = {i.display_name: i for i in GetInboxRules(account=self).call()}[rule.display_name] + except KeyError: + raise ErrorItemNotFound(f"No rule with name {rule.display_name!r}") + DeleteInboxRule(account=self).get(rule=rule) + rule.id = None + def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): """Create a pull subscription. diff --git a/exchangelib/credentials.py b/exchangelib/credentials.py index 8b65f3e4..c2154b4e 100644 --- a/exchangelib/credentials.py +++ b/exchangelib/credentials.py @@ -4,6 +4,7 @@ for ad-hoc access e.g. granted manually by the user. See https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/impersonation-and-ews-in-exchange """ + import abc import logging from threading import RLock diff --git a/exchangelib/errors.py b/exchangelib/errors.py index 41937ddc..0a6bac51 100644 --- a/exchangelib/errors.py +++ b/exchangelib/errors.py @@ -1,4 +1,5 @@ """Stores errors specific to this package, and mirrors all the possible errors that EWS can return.""" + from urllib.parse import urlparse diff --git a/exchangelib/fields.py b/exchangelib/fields.py index 08562e29..76f51b67 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -1723,3 +1723,65 @@ def from_xml(self, elem, account): continue events.append(value_cls.from_xml(elem=event, account=account)) return events or self.default + + +FLAG_ACTION_CHOICES = [ + Choice("Any"), + Choice("Call"), + Choice("DoNotForward"), + Choice("FollowUp"), + Choice("FYI"), + Choice("Forward"), + Choice("NoResponseNecessary"), + Choice("Read"), + Choice("Reply"), + Choice("ReplyToAll"), + Choice("Review"), +] + + +class FlaggedForActionField(ChoiceField): + """ + A field specifies the flag for action value that + must appear on incoming messages in order for the condition + or exception to apply. + """ + + def __init__(self, *args, **kwargs): + kwargs["choices"] = FLAG_ACTION_CHOICES + super().__init__(*args, **kwargs) + + +IMPORTANCE_CHOICES = [ + Choice("Low"), + Choice("Normal"), + Choice("High"), +] + + +class ImportanceField(ChoiceField): + """ + A field that describes the importance of an item or + the aggregated importance of all items in a conversation + in the current folder. + """ + + def __init__(self, *args, **kwargs): + kwargs["choices"] = IMPORTANCE_CHOICES + super().__init__(*args, **kwargs) + + +SENSITIVITY_CHOICES = [ + Choice("Normal"), + Choice("Personal"), + Choice("Private"), + Choice("Confidential"), +] + + +class SensitivityField(ChoiceField): + """A field that indicates the sensitivity level of an item.""" + + def __init__(self, *args, **kwargs): + kwargs["choices"] = SENSITIVITY_CHOICES + super().__init__(*args, **kwargs) diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 814ff741..bcfd3081 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -36,10 +36,12 @@ ExtendedPropertyField, Field, FieldPath, + FlaggedForActionField, FreeBusyStatusField, GenericEventListField, IdElementField, IdField, + ImportanceField, IntegerField, InvalidField, InvalidFieldForVersion, @@ -48,6 +50,7 @@ RecipientAddressField, ReferenceItemIdField, RoutingTypeField, + SensitivityField, SubField, TextField, TimeDeltaField, @@ -2140,3 +2143,179 @@ def from_xml(cls, elem, account): user_settings_errors=user_settings_errors, user_settings=user_settings, ) + + +class WithinDateRange(EWSElement): + """MSDN: + https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/withindaterange + """ + + ELEMENT_NAME = "DateRange" + NAMESPACE = MNS + + start_date_time = DateTimeField(field_uri="StartDateTime", is_required=True) + end_date_time = DateTimeField(field_uri="EndDateTime", is_required=True) + + +class WithinSizeRange(EWSElement): + """MSDN: + https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/withinsizerange + """ + + ELEMENT_NAME = "SizeRange" + NAMESPACE = MNS + + minimum_size = IntegerField(field_uri="MinimumSize", is_required=True) + maximum_size = IntegerField(field_uri="MaximumSize", is_required=True) + + +class Conditions(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/conditions""" + + ELEMENT_NAME = "Conditions" + NAMESPACE = TNS + + categories = CharListField(field_uri="Categories") + contains_body_strings = CharListField(field_uri="ContainsBodyStrings") + contains_header_strings = CharListField(field_uri="ContainsHeaderStrings") + contains_recipient_strings = CharListField(field_uri="ContainsRecipientStrings") + contains_sender_strings = CharListField(field_uri="ContainsSenderStrings") + contains_subject_or_body_strings = CharListField(field_uri="ContainsSubjectOrBodyStrings") + contains_subject_strings = CharListField(field_uri="ContainsSubjectStrings") + flagged_for_action = FlaggedForActionField(field_uri="FlaggedForAction") + from_addresses = EWSElementField(value_cls=Mailbox, field_uri="FromAddresses") + from_connected_accounts = CharListField(field_uri="FromConnectedAccounts") + has_attachments = BooleanField(field_uri="HasAttachments") + importance = ImportanceField(field_uri="Importance") + is_approval_request = BooleanField(field_uri="IsApprovalRequest") + is_automatic_forward = BooleanField(field_uri="IsAutomaticForward") + is_automatic_reply = BooleanField(field_uri="IsAutomaticReply") + is_encrypted = BooleanField(field_uri="IsEncrypted") + is_meeting_request = BooleanField(field_uri="IsMeetingRequest") + is_meeting_response = BooleanField(field_uri="IsMeetingResponse") + is_ndr = BooleanField(field_uri="IsNDR") + is_permission_controlled = BooleanField(field_uri="IsPermissionControlled") + is_read_receipt = BooleanField(field_uri="IsReadReceipt") + is_signed = BooleanField(field_uri="IsSigned") + is_voicemail = BooleanField(field_uri="IsVoicemail") + item_classes = CharListField(field_uri="ItemClasses") + message_classifications = CharListField(field_uri="MessageClassifications") + not_sent_to_me = BooleanField(field_uri="NotSentToMe") + sent_cc_me = BooleanField(field_uri="SentCcMe") + sent_only_to_me = BooleanField(field_uri="SentOnlyToMe") + sent_to_addresses = EWSElementField(value_cls=Mailbox, field_uri="SentToAddresses") + sent_to_me = BooleanField(field_uri="SentToMe") + sent_to_or_cc_me = BooleanField(field_uri="SentToOrCcMe") + sensitivity = SensitivityField(field_uri="Sensitivity") + within_date_range = EWSElementField(value_cls=WithinDateRange, field_uri="WithinDateRange") + within_size_range = EWSElementField(value_cls=WithinSizeRange, field_uri="WithinSizeRange") + + +class Exceptions(Conditions): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exceptions""" + + ELEMENT_NAME = "Exceptions" + NAMESPACE = TNS + + +class CopyToFolder(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/copytofolder""" + + ELEMENT_NAME = "CopyToFolder" + NAMESPACE = MNS + + folder_id = EWSElementField(value_cls=FolderId, field_uri="FolderId") + distinguished_folder_id = EWSElementField(value_cls=DistinguishedFolderId, field_uri="DistinguishedFolderId") + + +class MoveToFolder(CopyToFolder): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/movetofolder""" + + ELEMENT_NAME = "MoveToFolder" + + +class Actions(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/actions""" + + ELEMENT_NAME = "Actions" + NAMESPACE = TNS + + assign_categories = CharListField(field_uri="AssignCategories") + copy_to_folder = EWSElementField(value_cls=CopyToFolder, field_uri="CopyToFolder") + delete = BooleanField(field_uri="Delete") + forward_as_attachment_to_recipients = EWSElementField( + value_cls=Mailbox, field_uri="ForwardAsAttachmentToRecipients" + ) + forward_to_recipients = EWSElementField(value_cls=Mailbox, field_uri="ForwardToRecipients") + mark_importance = ImportanceField(field_uri="MarkImportance") + mark_as_read = BooleanField(field_uri="MarkAsRead") + move_to_folder = EWSElementField(value_cls=MoveToFolder, field_uri="MoveToFolder") + permanent_delete = BooleanField(field_uri="PermanentDelete") + redirect_to_recipients = EWSElementField(value_cls=Mailbox, field_uri="RedirectToRecipients") + send_sms_alert_to_recipients = EWSElementField(value_cls=Mailbox, field_uri="SendSMSAlertToRecipients") + server_reply_with_message = EWSElementField(value_cls=ItemId, field_uri="ServerReplyWithMessage") + stop_processing_rules = BooleanField(field_uri="StopProcessingRules") + + +class Rule(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/rule-ruletype""" + + ELEMENT_NAME = "Rule" + NAMESPACE = TNS + + id = CharField(field_uri="RuleId") + display_name = CharField(field_uri="DisplayName") + priority = IntegerField(field_uri="Priority") + is_enabled = BooleanField(field_uri="IsEnabled") + is_not_supported = BooleanField(field_uri="IsNotSupported") + is_in_error = BooleanField(field_uri="IsInError") + conditions = EWSElementField(value_cls=Conditions) + exceptions = EWSElementField(value_cls=Exceptions) + actions = EWSElementField(value_cls=Actions) + + +class InboxRules(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/inboxrules""" + + ELEMENT_NAME = "InboxRules" + NAMESPACE = MNS + + rule = EWSElementListField(value_cls=Rule) + + +class CreateRuleOperation(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createruleoperation""" + + ELEMENT_NAME = "CreateRuleOperation" + NAMESPACE = TNS + + rule = EWSElementField(value_cls=Rule) + + +class SetRuleOperation(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/setruleoperation""" + + ELEMENT_NAME = "SetRuleOperation" + NAMESPACE = TNS + + rule = EWSElementField(value_cls=Rule) + + +class DeleteRuleOperation(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteruleoperation""" + + ELEMENT_NAME = "DeleteRuleOperation" + NAMESPACE = TNS + + id = CharField(field_uri="RuleId") + + +class Operations(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/operations""" + + ELEMENT_NAME = "Operations" + NAMESPACE = MNS + + create_rule_operation = EWSElementField(value_cls=CreateRuleOperation) + set_rule_operation = EWSElementField(value_cls=SetRuleOperation) + delete_rule_operation = EWSElementField(value_cls=DeleteRuleOperation) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 339c8f85..2b8dc52b 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -4,6 +4,7 @@ Protocols should be accessed through an Account, and are either created from a default Configuration or autodiscovered when creating an Account. """ + import abc import datetime import logging diff --git a/exchangelib/services/__init__.py b/exchangelib/services/__init__.py index ef3a98cd..77a22f21 100644 --- a/exchangelib/services/__init__.py +++ b/exchangelib/services/__init__.py @@ -41,6 +41,7 @@ from .get_user_configuration import GetUserConfiguration from .get_user_oof_settings import GetUserOofSettings from .get_user_settings import GetUserSettings +from .inbox_rules import CreateInboxRule, DeleteInboxRule, GetInboxRules, SetInboxRule from .mark_as_junk import MarkAsJunk from .move_folder import MoveFolder from .move_item import MoveItem @@ -109,4 +110,8 @@ "UpdateItem", "UpdateUserConfiguration", "UploadItems", + "GetInboxRules", + "CreateInboxRule", + "SetInboxRule", + "DeleteInboxRule", ] diff --git a/exchangelib/services/inbox_rules.py b/exchangelib/services/inbox_rules.py new file mode 100644 index 00000000..3545d7c1 --- /dev/null +++ b/exchangelib/services/inbox_rules.py @@ -0,0 +1,121 @@ +from typing import Any, Generator, Optional, Union + +from ..errors import ErrorInvalidOperation +from ..properties import CreateRuleOperation, DeleteRuleOperation, InboxRules, Operations, Rule, SetRuleOperation +from ..util import MNS, add_xml_child, create_element, get_xml_attr, set_xml_value +from ..version import EXCHANGE_2010 +from .common import EWSAccountService + + +class GetInboxRules(EWSAccountService): + """ + MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getinboxrules-operation + + The GetInboxRules operation uses Exchange Web Services to retrieve Inbox rules in the identified user's mailbox. + """ + + SERVICE_NAME = "GetInboxRules" + supported_from = EXCHANGE_2010 + element_container_name = InboxRules.response_tag() + ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (ErrorInvalidOperation,) + + def call(self, mailbox: Optional[str] = None) -> Generator[Union[Rule, Exception, None], Any, None]: + if not mailbox: + mailbox = self.account.primary_smtp_address + payload = self.get_payload(mailbox=mailbox) + elements = self._get_elements(payload=payload) + return self._elems_to_objs(elements) + + def _elem_to_obj(self, elem): + return Rule.from_xml(elem=elem, account=self.account) + + def get_payload(self, mailbox): + payload = create_element(f"m:{self.SERVICE_NAME}") + add_xml_child(payload, "m:MailboxSmtpAddress", mailbox) + return payload + + def _get_element_container(self, message, name=None): + if name: + response_class = message.get("ResponseClass") + response_code = get_xml_attr(message, f"{{{MNS}}}ResponseCode") + if response_class == "Success" and response_code == "NoError" and message.find(name) is None: + return [] + return super()._get_element_container(message, name) + + +class UpdateInboxRules(EWSAccountService): + """ + MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation + + The UpdateInboxRules operation updates the authenticated user's Inbox rules by applying the specified operations. + UpdateInboxRules is used to create an Inbox rule, to set an Inbox rule, or to delete an Inbox rule. + + When you use the UpdateInboxRules operation, Exchange Web Services deletes client-side send rules. + Client-side send rules are stored on the client in the rule Folder Associated Information (FAI) Message and nowhere + else. EWS deletes this rule FAI message by default, based on the expectation that Outlook will recreate it. + However, Outlook can't recreate rules that don't also exist as an extended rule, and client-side send rules don't + exist as extended rules. As a result, these rules are lost. We suggest you consider this when designing your + solution. + """ + + SERVICE_NAME = "UpdateInboxRules" + supported_from = EXCHANGE_2010 + ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (ErrorInvalidOperation,) + + +class CreateInboxRule(UpdateInboxRules): + """ + MSDN: + https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-create-rule-request-example + """ + + def call(self, rule: Rule, remove_outlook_rule_blob: bool = True): + payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob) + return self._get_elements(payload=payload) + + def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True): + payload = create_element(f"m:{self.SERVICE_NAME}") + add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob) + operations = Operations(create_rule_operation=CreateRuleOperation(rule=rule)) + set_xml_value(payload, operations, version=self.account.version) + return payload + + +class SetInboxRule(UpdateInboxRules): + """ + MSDN: + https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-set-rule-request-example + """ + + def call(self, rule: Rule, remove_outlook_rule_blob: bool = True): + payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob) + return self._get_elements(payload=payload) + + def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True): + if not rule.id: + raise ValueError("Rule must have an ID") + payload = create_element(f"m:{self.SERVICE_NAME}") + add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob) + operations = Operations(set_rule_operation=SetRuleOperation(rule=rule)) + set_xml_value(payload, operations, version=self.account.version) + return payload + + +class DeleteInboxRule(UpdateInboxRules): + """ + MSDN: + https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-delete-rule-request-example + """ + + def call(self, rule: Rule, remove_outlook_rule_blob: bool = True): + payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob) + return self._get_elements(payload=payload) + + def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True): + if not rule.id: + raise ValueError("Rule must have an ID") + payload = create_element(f"m:{self.SERVICE_NAME}") + add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob) + operations = Operations(delete_rule_operation=DeleteRuleOperation(id=rule.id)) + set_xml_value(payload, operations, version=self.account.version) + return payload diff --git a/exchangelib/services/subscribe.py b/exchangelib/services/subscribe.py index 7ca6883b..c87c9b40 100644 --- a/exchangelib/services/subscribe.py +++ b/exchangelib/services/subscribe.py @@ -1,6 +1,7 @@ """The 'Subscribe' service has three different modes - pull, push and streaming - with different signatures. Implement as three distinct classes. """ + import abc from ..util import MNS, create_element diff --git a/exchangelib/winzone.py b/exchangelib/winzone.py index 7d82762d..56936a58 100644 --- a/exchangelib/winzone.py +++ b/exchangelib/winzone.py @@ -1,5 +1,6 @@ """A dict to translate from IANA location name to Windows timezone name. Translations taken from CLDR_WINZONE_URL """ + import re import requests diff --git a/scripts/notifier.py b/scripts/notifier.py index 6725e576..f986cf7b 100644 --- a/scripts/notifier.py +++ b/scripts/notifier.py @@ -37,6 +37,7 @@ done """ + import sys import warnings from datetime import datetime, timedelta diff --git a/tests/test_account.py b/tests/test_account.py index 8a3bc401..090c1fc9 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -19,11 +19,15 @@ from exchangelib.folders import Calendar from exchangelib.items import Message from exchangelib.properties import ( + Actions, + Conditions, DelegatePermissions, DelegateUser, + Exceptions, MailTips, OutOfOffice, RecipientAddress, + Rule, SendingAs, UserId, ) @@ -31,7 +35,7 @@ from exchangelib.services import GetDelegate, GetMailTips from exchangelib.version import EXCHANGE_2007_SP1, Version -from .common import EWSTest +from .common import EWSTest, get_random_string class AccountTest(EWSTest): @@ -337,3 +341,36 @@ def test_protocol_default_values(self): ) self.assertIsNotNone(a.protocol.auth_type) self.assertIsNotNone(a.protocol.retry_policy) + + def test_inbox_rules(self): + # Clean up first + for rule in self.account.rules: + self.account.delete_rule(rule) + + self.assertEqual(len(self.account.rules), 0) + + # Create rule + display_name = get_random_string(16) + rule = Rule( + display_name=display_name, + priority=1, + is_enabled=True, + conditions=Conditions(contains_sender_strings=[get_random_string(8)]), + exceptions=Exceptions(), + actions=Actions(delete=True), + ) + self.assertIsNone(rule.id) + self.account.create_rule(rule=rule) + self.assertIsNotNone(rule.id) + self.assertEqual(len(self.account.rules), 1) + self.assertEqual(self.account.rules[0].display_name, display_name) + + # Update rule + rule.display_name = get_random_string(16) + self.account.set_rule(rule=rule) + self.assertEqual(len(self.account.rules), 1) + self.assertNotEqual(self.account.rules[0].display_name, display_name) + + # Delete rule + self.account.delete_rule(rule=rule) + self.assertEqual(len(self.account.rules), 0) From 568dc452339512675c2ae556b861a6057b8a212f Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 7 Mar 2024 11:59:06 +0100 Subject: [PATCH 401/509] fix: Allow setting max_connections from config when in autodiscover mode --- exchangelib/account.py | 10 ++++++++-- exchangelib/protocol.py | 9 +++++++++ tests/test_autodiscover.py | 24 +++++++++++++++--------- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/exchangelib/account.py b/exchangelib/account.py index 966db5cb..402f5ea1 100644 --- a/exchangelib/account.py +++ b/exchangelib/account.py @@ -166,11 +166,16 @@ def __init__( raise InvalidTypeError("config", config, Configuration) if autodiscover: if config: - auth_type, retry_policy, version = config.auth_type, config.retry_policy, config.version + auth_type, retry_policy, version, max_connections = ( + config.auth_type, + config.retry_policy, + config.version, + config.max_connections, + ) if not credentials: credentials = config.credentials else: - auth_type, retry_policy, version = None, None, None + auth_type, retry_policy, version, max_connections = None, None, None, None self.ad_response, self.protocol = Autodiscovery( email=primary_smtp_address, credentials=credentials ).discover() @@ -180,6 +185,7 @@ def __init__( self.protocol.config.retry_policy = retry_policy if version: self.protocol.config.version = version + self.protocol.max_connections = max_connections primary_smtp_address = self.ad_response.autodiscover_smtp_address else: if not config: diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 2b8dc52b..1d4d50c3 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -131,6 +131,15 @@ def credentials(self, value): self.config._credentials = value self.close() + @property + def max_connections(self): + return self._session_pool_maxsize + + @max_connections.setter + def max_connections(self, value): + with self._session_pool_lock: + self._session_pool_maxsize = value or self.SESSION_POOLSIZE + @property def retry_policy(self): return self.config.retry_policy diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index eb56f12b..2df07f05 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -847,16 +847,22 @@ def test_redirect_url_is_valid(self, m): self.assertTrue(a._redirect_url_is_valid(self.account.protocol.config.service_endpoint)) def test_protocol_default_values(self): - # Test that retry_policy and auth_type always get a value regardless of how we create an Account - self.get_account() - a = Account( - self.account.primary_smtp_address, - autodiscover=True, - config=self.account.protocol.config, - ) - self.assertIsNotNone(a.protocol.auth_type) - self.assertIsNotNone(a.protocol.retry_policy) + # Test that retry_policy, auth_type and max_connections always get values regardless of how we create an Account + _max_conn = self.account.protocol.config.max_connections + try: + self.account.protocol.config.max_connections = 3 + a = Account( + self.account.primary_smtp_address, + autodiscover=True, + config=self.account.protocol.config, + ) + self.assertIsNotNone(a.protocol.auth_type) + self.assertIsNotNone(a.protocol.retry_policy) + self.assertEqual(a.protocol._session_pool_maxsize, 3) + finally: + self.account.protocol.config.max_connections = _max_conn a = Account(self.account.primary_smtp_address, autodiscover=True, credentials=self.account.protocol.credentials) self.assertIsNotNone(a.protocol.auth_type) self.assertIsNotNone(a.protocol.retry_policy) + self.assertEqual(a.protocol._session_pool_maxsize, a.protocol.SESSION_POOLSIZE) From 985e1304c20ce09d69996421bdd6352d454e07af Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Fri, 8 Mar 2024 10:52:10 +0100 Subject: [PATCH 402/509] chore: Update generated docs --- docs/exchangelib/account.html | 504 ++++++- docs/exchangelib/autodiscover/index.html | 10 + docs/exchangelib/autodiscover/protocol.html | 18 +- docs/exchangelib/credentials.html | 1 + docs/exchangelib/errors.html | 1 + docs/exchangelib/fields.html | 242 ++- docs/exchangelib/folders/base.html | 65 +- docs/exchangelib/folders/collections.html | 50 +- docs/exchangelib/folders/index.html | 168 ++- docs/exchangelib/folders/known_folders.html | 78 + docs/exchangelib/folders/roots.html | 60 +- docs/exchangelib/index.html | 509 ++++++- docs/exchangelib/items/index.html | 36 +- docs/exchangelib/items/message.html | 54 +- docs/exchangelib/properties.html | 1497 ++++++++++++++++++- docs/exchangelib/protocol.html | 32 + docs/exchangelib/services/common.html | 2 + docs/exchangelib/services/inbox_rules.html | 681 +++++++++ docs/exchangelib/services/index.html | 422 ++++++ docs/exchangelib/services/subscribe.html | 27 +- docs/exchangelib/winzone.html | 18 +- docs/index.md | 2 +- 22 files changed, 4245 insertions(+), 232 deletions(-) create mode 100644 docs/exchangelib/services/inbox_rules.html diff --git a/docs/exchangelib/account.html b/docs/exchangelib/account.html index 2e08e6d8..17be1cbe 100644 --- a/docs/exchangelib/account.html +++ b/docs/exchangelib/account.html @@ -34,7 +34,7 @@

    Module exchangelib.account

    from .autodiscover import Autodiscovery from .configuration import Configuration from .credentials import ACCESS_TYPES, DELEGATE, IMPERSONATION -from .errors import InvalidEnumValue, InvalidTypeError, UnknownTimeZone +from .errors import ErrorItemNotFound, InvalidEnumValue, InvalidTypeError, ResponseMessageError, UnknownTimeZone from .ewsdatetime import UTC, EWSTimeZone from .fields import FieldPath, TextField from .folders import ( @@ -82,17 +82,21 @@

    Module exchangelib.account

    ToDoSearch, VoiceMail, ) +from .folders.collections import PullSubscription, PushSubscription, StreamingSubscription from .items import ALL_OCCURRENCES, AUTO_RESOLVE, HARD_DELETE, ID_ONLY, SAVE_ONLY, SEND_TO_NONE -from .properties import EWSElement, Mailbox, SendingAs +from .properties import EWSElement, Mailbox, Rule, SendingAs from .protocol import Protocol from .queryset import QuerySet from .services import ( ArchiveItem, CopyItem, + CreateInboxRule, CreateItem, + DeleteInboxRule, DeleteItem, ExportItems, GetDelegate, + GetInboxRules, GetItem, GetMailTips, GetPersona, @@ -100,7 +104,12 @@

    Module exchangelib.account

    MarkAsJunk, MoveItem, SendItem, + SetInboxRule, SetUserOofSettings, + SubscribeToPull, + SubscribeToPush, + SubscribeToStreaming, + Unsubscribe, UpdateItem, UploadItems, ) @@ -185,11 +194,16 @@

    Module exchangelib.account

    raise InvalidTypeError("config", config, Configuration) if autodiscover: if config: - auth_type, retry_policy, version = config.auth_type, config.retry_policy, config.version + auth_type, retry_policy, version, max_connections = ( + config.auth_type, + config.retry_policy, + config.version, + config.max_connections, + ) if not credentials: credentials = config.credentials else: - auth_type, retry_policy, version = None, None, None + auth_type, retry_policy, version, max_connections = None, None, None, None self.ad_response, self.protocol = Autodiscovery( email=primary_smtp_address, credentials=credentials ).discover() @@ -199,6 +213,7 @@

    Module exchangelib.account

    self.protocol.config.retry_policy = retry_policy if version: self.protocol.config.version = version + self.protocol.max_connections = max_connections primary_smtp_address = self.ad_response.autodiscover_smtp_address else: if not config: @@ -770,6 +785,116 @@

    Module exchangelib.account

    """Return a list of DelegateUser objects representing the delegates that are set on this account.""" return list(GetDelegate(account=self).call(user_ids=None, include_permissions=True)) + @property + def rules(self): + """Return a list of Rule objects representing the rules that are set on this account.""" + return list(GetInboxRules(account=self).call()) + + def create_rule(self, rule: Rule): + """Create an Inbox rule. + + :param rule: The rule to create. Must have at least 'display_name' set. + :return: None if success, else raises an error. + """ + CreateInboxRule(account=self).get(rule=rule, remove_outlook_rule_blob=True) + # After creating the rule, query all rules, + # find the rule that was just created, and return its ID. + try: + rule.id = {i.display_name: i for i in GetInboxRules(account=self).call()}[rule.display_name].id + except KeyError: + raise ResponseMessageError(f"Failed to create rule ({rule.display_name})!") + + def set_rule(self, rule: Rule): + """Modify an Inbox rule. + + :param rule: The rule to set. Must have an ID. + :return: None if success, else raises an error. + """ + SetInboxRule(account=self).get(rule=rule) + + def delete_rule(self, rule: Rule): + """Delete an Inbox rule. + + :param rule: The rule to delete. Must have ID or 'display_name'. + :return: None if success, else raises an error. + """ + if not rule.id: + if not rule.display_name: + raise ValueError("Rule must have ID or display_name") + try: + rule = {i.display_name: i for i in GetInboxRules(account=self).call()}[rule.display_name] + except KeyError: + raise ErrorItemNotFound(f"No rule with name {rule.display_name!r}") + DeleteInboxRule(account=self).get(rule=rule) + rule.id = None + + def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): + """Create a pull subscription. + + :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPull.EVENT_TYPES + :param watermark: An event bookmark as returned by some sync services + :param timeout: Timeout of the subscription, in minutes. Timeout is reset when the server receives a + GetEvents request for this subscription. + :return: The subscription ID and a watermark + """ + if event_types is None: + event_types = SubscribeToPull.EVENT_TYPES + return SubscribeToPull(account=self).get( + folders=None, + event_types=event_types, + watermark=watermark, + timeout=timeout, + ) + + def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1): + """Create a push subscription. + + :param callback_url: A client-defined URL that the server will call + :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES + :param watermark: An event bookmark as returned by some sync services + :param status_frequency: The frequency, in minutes, that the callback URL will be called with. + :return: The subscription ID and a watermark + """ + if event_types is None: + event_types = SubscribeToPush.EVENT_TYPES + return SubscribeToPush(account=self).get( + folders=None, + event_types=event_types, + watermark=watermark, + status_frequency=status_frequency, + url=callback_url, + ) + + def subscribe_to_streaming(self, event_types=None): + """Create a streaming subscription. + + :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES + :return: The subscription ID + """ + if event_types is None: + event_types = SubscribeToStreaming.EVENT_TYPES + return SubscribeToStreaming(account=self).get(folders=None, event_types=event_types) + + def pull_subscription(self, **kwargs): + return PullSubscription(target=self, **kwargs) + + def push_subscription(self, **kwargs): + return PushSubscription(target=self, **kwargs) + + def streaming_subscription(self, **kwargs): + return StreamingSubscription(target=self, **kwargs) + + def unsubscribe(self, subscription_id): + """Unsubscribe. Only applies to pull and streaming notifications. + + :param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|streaming]() + :return: True + + This method doesn't need the current collection instance, but it makes sense to keep the method along the other + sync methods. + """ + return Unsubscribe(account=self).get(subscription_id=subscription_id) + def __str__(self): if self.fullname: return f"{self.primary_smtp_address} ({self.fullname})" @@ -871,11 +996,16 @@

    Classes

    raise InvalidTypeError("config", config, Configuration) if autodiscover: if config: - auth_type, retry_policy, version = config.auth_type, config.retry_policy, config.version + auth_type, retry_policy, version, max_connections = ( + config.auth_type, + config.retry_policy, + config.version, + config.max_connections, + ) if not credentials: credentials = config.credentials else: - auth_type, retry_policy, version = None, None, None + auth_type, retry_policy, version, max_connections = None, None, None, None self.ad_response, self.protocol = Autodiscovery( email=primary_smtp_address, credentials=credentials ).discover() @@ -885,6 +1015,7 @@

    Classes

    self.protocol.config.retry_policy = retry_policy if version: self.protocol.config.version = version + self.protocol.max_connections = max_connections primary_smtp_address = self.ad_response.autodiscover_smtp_address else: if not config: @@ -1456,6 +1587,116 @@

    Classes

    """Return a list of DelegateUser objects representing the delegates that are set on this account.""" return list(GetDelegate(account=self).call(user_ids=None, include_permissions=True)) + @property + def rules(self): + """Return a list of Rule objects representing the rules that are set on this account.""" + return list(GetInboxRules(account=self).call()) + + def create_rule(self, rule: Rule): + """Create an Inbox rule. + + :param rule: The rule to create. Must have at least 'display_name' set. + :return: None if success, else raises an error. + """ + CreateInboxRule(account=self).get(rule=rule, remove_outlook_rule_blob=True) + # After creating the rule, query all rules, + # find the rule that was just created, and return its ID. + try: + rule.id = {i.display_name: i for i in GetInboxRules(account=self).call()}[rule.display_name].id + except KeyError: + raise ResponseMessageError(f"Failed to create rule ({rule.display_name})!") + + def set_rule(self, rule: Rule): + """Modify an Inbox rule. + + :param rule: The rule to set. Must have an ID. + :return: None if success, else raises an error. + """ + SetInboxRule(account=self).get(rule=rule) + + def delete_rule(self, rule: Rule): + """Delete an Inbox rule. + + :param rule: The rule to delete. Must have ID or 'display_name'. + :return: None if success, else raises an error. + """ + if not rule.id: + if not rule.display_name: + raise ValueError("Rule must have ID or display_name") + try: + rule = {i.display_name: i for i in GetInboxRules(account=self).call()}[rule.display_name] + except KeyError: + raise ErrorItemNotFound(f"No rule with name {rule.display_name!r}") + DeleteInboxRule(account=self).get(rule=rule) + rule.id = None + + def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): + """Create a pull subscription. + + :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPull.EVENT_TYPES + :param watermark: An event bookmark as returned by some sync services + :param timeout: Timeout of the subscription, in minutes. Timeout is reset when the server receives a + GetEvents request for this subscription. + :return: The subscription ID and a watermark + """ + if event_types is None: + event_types = SubscribeToPull.EVENT_TYPES + return SubscribeToPull(account=self).get( + folders=None, + event_types=event_types, + watermark=watermark, + timeout=timeout, + ) + + def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1): + """Create a push subscription. + + :param callback_url: A client-defined URL that the server will call + :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES + :param watermark: An event bookmark as returned by some sync services + :param status_frequency: The frequency, in minutes, that the callback URL will be called with. + :return: The subscription ID and a watermark + """ + if event_types is None: + event_types = SubscribeToPush.EVENT_TYPES + return SubscribeToPush(account=self).get( + folders=None, + event_types=event_types, + watermark=watermark, + status_frequency=status_frequency, + url=callback_url, + ) + + def subscribe_to_streaming(self, event_types=None): + """Create a streaming subscription. + + :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES + :return: The subscription ID + """ + if event_types is None: + event_types = SubscribeToStreaming.EVENT_TYPES + return SubscribeToStreaming(account=self).get(folders=None, event_types=event_types) + + def pull_subscription(self, **kwargs): + return PullSubscription(target=self, **kwargs) + + def push_subscription(self, **kwargs): + return PushSubscription(target=self, **kwargs) + + def streaming_subscription(self, **kwargs): + return StreamingSubscription(target=self, **kwargs) + + def unsubscribe(self, subscription_id): + """Unsubscribe. Only applies to pull and streaming notifications. + + :param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|streaming]() + :return: True + + This method doesn't need the current collection instance, but it makes sense to keep the method along the other + sync methods. + """ + return Unsubscribe(account=self).get(subscription_id=subscription_id) + def __str__(self): if self.fullname: return f"{self.primary_smtp_address} ({self.fullname})" @@ -2317,6 +2558,19 @@

    Instance variables

    return obj_dict.setdefault(name, self.func(obj)) +
    var rules
    +
    +

    Return a list of Rule objects representing the rules that are set on this account.

    +
    + +Expand source code + +
    @property
    +def rules(self):
    +    """Return a list of Rule objects representing the rules that are set on this account."""
    +    return list(GetInboxRules(account=self).call())
    +
    +
    var search_folders
    @@ -2884,6 +3138,60 @@

    Methods

    )
    +
    +def create_rule(self, rule: Rule) +
    +
    +

    Create an Inbox rule.

    +

    :param rule: The rule to create. Must have at least 'display_name' set. +:return: None if success, else raises an error.

    +
    + +Expand source code + +
    def create_rule(self, rule: Rule):
    +    """Create an Inbox rule.
    +
    +    :param rule: The rule to create. Must have at least 'display_name' set.
    +    :return: None if success, else raises an error.
    +    """
    +    CreateInboxRule(account=self).get(rule=rule, remove_outlook_rule_blob=True)
    +    # After creating the rule, query all rules,
    +    # find the rule that was just created, and return its ID.
    +    try:
    +        rule.id = {i.display_name: i for i in GetInboxRules(account=self).call()}[rule.display_name].id
    +    except KeyError:
    +        raise ResponseMessageError(f"Failed to create rule ({rule.display_name})!")
    +
    +
    +
    +def delete_rule(self, rule: Rule) +
    +
    +

    Delete an Inbox rule.

    +

    :param rule: The rule to delete. Must have ID or 'display_name'. +:return: None if success, else raises an error.

    +
    + +Expand source code + +
    def delete_rule(self, rule: Rule):
    +    """Delete an Inbox rule.
    +
    +    :param rule: The rule to delete. Must have ID or 'display_name'.
    +    :return: None if success, else raises an error.
    +    """
    +    if not rule.id:
    +        if not rule.display_name:
    +            raise ValueError("Rule must have ID or display_name")
    +        try:
    +            rule = {i.display_name: i for i in GetInboxRules(account=self).call()}[rule.display_name]
    +        except KeyError:
    +            raise ErrorItemNotFound(f"No rule with name {rule.display_name!r}")
    +    DeleteInboxRule(account=self).get(rule=rule)
    +    rule.id = None
    +
    +
    def export(self, items, chunk_size=None)
    @@ -2987,6 +3295,179 @@

    Methods

    yield from GetPersona(account=self).call(personas=ids) +
    +def pull_subscription(self, **kwargs) +
    +
    +
    +
    + +Expand source code + +
    def pull_subscription(self, **kwargs):
    +    return PullSubscription(target=self, **kwargs)
    +
    +
    +
    +def push_subscription(self, **kwargs) +
    +
    +
    +
    + +Expand source code + +
    def push_subscription(self, **kwargs):
    +    return PushSubscription(target=self, **kwargs)
    +
    +
    +
    +def set_rule(self, rule: Rule) +
    +
    +

    Modify an Inbox rule.

    +

    :param rule: The rule to set. Must have an ID. +:return: None if success, else raises an error.

    +
    + +Expand source code + +
    def set_rule(self, rule: Rule):
    +    """Modify an Inbox rule.
    +
    +    :param rule: The rule to set. Must have an ID.
    +    :return: None if success, else raises an error.
    +    """
    +    SetInboxRule(account=self).get(rule=rule)
    +
    +
    +
    +def streaming_subscription(self, **kwargs) +
    +
    +
    +
    + +Expand source code + +
    def streaming_subscription(self, **kwargs):
    +    return StreamingSubscription(target=self, **kwargs)
    +
    +
    +
    +def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60) +
    +
    +

    Create a pull subscription.

    +

    :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPull.EVENT_TYPES +:param watermark: An event bookmark as returned by some sync services +:param timeout: Timeout of the subscription, in minutes. Timeout is reset when the server receives a +GetEvents request for this subscription. +:return: The subscription ID and a watermark

    +
    + +Expand source code + +
    def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60):
    +    """Create a pull subscription.
    +
    +    :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPull.EVENT_TYPES
    +    :param watermark: An event bookmark as returned by some sync services
    +    :param timeout: Timeout of the subscription, in minutes. Timeout is reset when the server receives a
    +    GetEvents request for this subscription.
    +    :return: The subscription ID and a watermark
    +    """
    +    if event_types is None:
    +        event_types = SubscribeToPull.EVENT_TYPES
    +    return SubscribeToPull(account=self).get(
    +        folders=None,
    +        event_types=event_types,
    +        watermark=watermark,
    +        timeout=timeout,
    +    )
    +
    +
    +
    +def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1) +
    +
    +

    Create a push subscription.

    +

    :param callback_url: A client-defined URL that the server will call +:param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES +:param watermark: An event bookmark as returned by some sync services +:param status_frequency: The frequency, in minutes, that the callback URL will be called with. +:return: The subscription ID and a watermark

    +
    + +Expand source code + +
    def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1):
    +    """Create a push subscription.
    +
    +    :param callback_url: A client-defined URL that the server will call
    +    :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES
    +    :param watermark: An event bookmark as returned by some sync services
    +    :param status_frequency: The frequency, in minutes, that the callback URL will be called with.
    +    :return: The subscription ID and a watermark
    +    """
    +    if event_types is None:
    +        event_types = SubscribeToPush.EVENT_TYPES
    +    return SubscribeToPush(account=self).get(
    +        folders=None,
    +        event_types=event_types,
    +        watermark=watermark,
    +        status_frequency=status_frequency,
    +        url=callback_url,
    +    )
    +
    +
    +
    +def subscribe_to_streaming(self, event_types=None) +
    +
    +

    Create a streaming subscription.

    +

    :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES +:return: The subscription ID

    +
    + +Expand source code + +
    def subscribe_to_streaming(self, event_types=None):
    +    """Create a streaming subscription.
    +
    +    :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES
    +    :return: The subscription ID
    +    """
    +    if event_types is None:
    +        event_types = SubscribeToStreaming.EVENT_TYPES
    +    return SubscribeToStreaming(account=self).get(folders=None, event_types=event_types)
    +
    +
    +
    +def unsubscribe(self, subscription_id) +
    +
    +

    Unsubscribe. Only applies to pull and streaming notifications.

    +

    :param subscription_id: A subscription ID as acquired by .subscribe_to_pull|streaming +:return: True

    +

    This method doesn't need the current collection instance, but it makes sense to keep the method along the other +sync methods.

    +
    + +Expand source code + +
    def unsubscribe(self, subscription_id):
    +    """Unsubscribe. Only applies to pull and streaming notifications.
    +
    +    :param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|streaming]()
    +    :return: True
    +
    +    This method doesn't need the current collection instance, but it makes sense to keep the method along the other
    +    sync methods.
    +    """
    +    return Unsubscribe(account=self).get(subscription_id=subscription_id)
    +
    +
    def upload(self, data, chunk_size=None)
    @@ -3142,7 +3623,9 @@

    conflicts
  • contacts
  • conversation_history
  • +
  • create_rule
  • delegates
  • +
  • delete_rule
  • directory
  • domain
  • drafts
  • @@ -3164,6 +3647,8 @@

    people_connect
  • primary_smtp_address
  • public_folders_root
  • +
  • pull_subscription
  • +
  • push_subscription
  • quick_contacts
  • recipient_cache
  • recoverable_items_deletions
  • @@ -3171,13 +3656,20 @@

    recoverable_items_root
  • recoverable_items_versions
  • root
  • +
  • rules
  • search_folders
  • sent
  • server_failures
  • +
  • set_rule
  • +
  • streaming_subscription
  • +
  • subscribe_to_pull
  • +
  • subscribe_to_push
  • +
  • subscribe_to_streaming
  • sync_issues
  • tasks
  • todo_search
  • trash
  • +
  • unsubscribe
  • upload
  • voice_mail
  • diff --git a/docs/exchangelib/autodiscover/index.html b/docs/exchangelib/autodiscover/index.html index 2caa88ee..e6e83246 100644 --- a/docs/exchangelib/autodiscover/index.html +++ b/docs/exchangelib/autodiscover/index.html @@ -328,6 +328,11 @@

    Methods

    "external_ews_url", "ews_supported_schemas", ] + for setting in settings: + if setting not in UserResponse.SETTINGS_MAP: + raise ValueError( + f"Setting {setting!r} is invalid. Valid options are: {sorted(UserResponse.SETTINGS_MAP.keys())}" + ) return GetUserSettings(protocol=self).get(users=[user], settings=settings) def dummy_xml(self): @@ -424,6 +429,11 @@

    Methods

    "external_ews_url", "ews_supported_schemas", ] + for setting in settings: + if setting not in UserResponse.SETTINGS_MAP: + raise ValueError( + f"Setting {setting!r} is invalid. Valid options are: {sorted(UserResponse.SETTINGS_MAP.keys())}" + ) return GetUserSettings(protocol=self).get(users=[user], settings=settings) diff --git a/docs/exchangelib/autodiscover/protocol.html b/docs/exchangelib/autodiscover/protocol.html index 0bddba55..99875ef1 100644 --- a/docs/exchangelib/autodiscover/protocol.html +++ b/docs/exchangelib/autodiscover/protocol.html @@ -26,7 +26,8 @@

    Module exchangelib.autodiscover.protocol

    Expand source code -
    from ..protocol import BaseProtocol
    +
    from ..properties import UserResponse
    +from ..protocol import BaseProtocol
     from ..services import GetUserSettings
     from ..transport import get_autodiscover_authtype
     from ..version import Version
    @@ -73,6 +74,11 @@ 

    Module exchangelib.autodiscover.protocol

    "external_ews_url", "ews_supported_schemas", ] + for setting in settings: + if setting not in UserResponse.SETTINGS_MAP: + raise ValueError( + f"Setting {setting!r} is invalid. Valid options are: {sorted(UserResponse.SETTINGS_MAP.keys())}" + ) return GetUserSettings(protocol=self).get(users=[user], settings=settings) def dummy_xml(self): @@ -147,6 +153,11 @@

    Classes

    "external_ews_url", "ews_supported_schemas", ] + for setting in settings: + if setting not in UserResponse.SETTINGS_MAP: + raise ValueError( + f"Setting {setting!r} is invalid. Valid options are: {sorted(UserResponse.SETTINGS_MAP.keys())}" + ) return GetUserSettings(protocol=self).get(users=[user], settings=settings) def dummy_xml(self): @@ -243,6 +254,11 @@

    Methods

    "external_ews_url", "ews_supported_schemas", ] + for setting in settings: + if setting not in UserResponse.SETTINGS_MAP: + raise ValueError( + f"Setting {setting!r} is invalid. Valid options are: {sorted(UserResponse.SETTINGS_MAP.keys())}" + ) return GetUserSettings(protocol=self).get(users=[user], settings=settings)
    diff --git a/docs/exchangelib/credentials.html b/docs/exchangelib/credentials.html index c890a668..71e9e0d9 100644 --- a/docs/exchangelib/credentials.html +++ b/docs/exchangelib/credentials.html @@ -37,6 +37,7 @@

    Module exchangelib.credentials

    for ad-hoc access e.g. granted manually by the user. See https://docs.microsoft.com/en-us/exchange/client-developer/exchange-web-services/impersonation-and-ews-in-exchange """ + import abc import logging from threading import RLock diff --git a/docs/exchangelib/errors.html b/docs/exchangelib/errors.html index b4dabf8f..a34dff3e 100644 --- a/docs/exchangelib/errors.html +++ b/docs/exchangelib/errors.html @@ -28,6 +28,7 @@

    Module exchangelib.errors

    Expand source code
    """Stores errors specific to this package, and mirrors all the possible errors that EWS can return."""
    +
     from urllib.parse import urlparse
     
     
    diff --git a/docs/exchangelib/fields.html b/docs/exchangelib/fields.html
    index 76f3a7d1..e59334b0 100644
    --- a/docs/exchangelib/fields.html
    +++ b/docs/exchangelib/fields.html
    @@ -29,6 +29,7 @@ 

    Module exchangelib.fields

    import abc
     import datetime
     import logging
    +import warnings
     from contextlib import suppress
     from decimal import Decimal, InvalidOperation
     from importlib import import_module
    @@ -767,16 +768,21 @@ 

    Module exchangelib.fields

    def from_xml(self, elem, account): field_elem = elem.find(self.response_tag()) if field_elem is not None: - ms_id = field_elem.get("Id") - ms_name = field_elem.get("Name") + tz_id = field_elem.get("Id") or field_elem.get("Name") try: - return self.value_cls.from_ms_id(ms_id or ms_name) + return self.value_cls.from_ms_id(tz_id) except UnknownTimeZone: - log.warning( - "Cannot convert value '%s' on field '%s' to type %s (unknown timezone ID)", - (ms_id or ms_name), - self.name, - self.value_cls, + warnings.warn( + f"""\ +Cannot convert value {tz_id!r} on field {self.name!r} to type {self.value_cls.__name__!r} (unknown timezone ID). +You can fix this by adding a custom entry into the timezone translation map: + +from exchangelib.winzone import MS_TIMEZONE_TO_IANA_MAP, CLDR_TO_MS_TIMEZONE_MAP + +# Replace "Some_Region/Some_Location" with a reasonable value from CLDR_TO_MS_TIMEZONE_MAP.keys() +MS_TIMEZONE_TO_IANA_MAP[{tz_id!r}] = "Some_Region/Some_Location" + +# Your code here""" ) return None return self.default @@ -1744,7 +1750,69 @@

    Module exchangelib.fields

    except KeyError: continue events.append(value_cls.from_xml(elem=event, account=account)) - return events or self.default
    + return events or self.default + + +FLAG_ACTION_CHOICES = [ + Choice("Any"), + Choice("Call"), + Choice("DoNotForward"), + Choice("FollowUp"), + Choice("FYI"), + Choice("Forward"), + Choice("NoResponseNecessary"), + Choice("Read"), + Choice("Reply"), + Choice("ReplyToAll"), + Choice("Review"), +] + + +class FlaggedForActionField(ChoiceField): + """ + A field specifies the flag for action value that + must appear on incoming messages in order for the condition + or exception to apply. + """ + + def __init__(self, *args, **kwargs): + kwargs["choices"] = FLAG_ACTION_CHOICES + super().__init__(*args, **kwargs) + + +IMPORTANCE_CHOICES = [ + Choice("Low"), + Choice("Normal"), + Choice("High"), +] + + +class ImportanceField(ChoiceField): + """ + A field that describes the importance of an item or + the aggregated importance of all items in a conversation + in the current folder. + """ + + def __init__(self, *args, **kwargs): + kwargs["choices"] = IMPORTANCE_CHOICES + super().__init__(*args, **kwargs) + + +SENSITIVITY_CHOICES = [ + Choice("Normal"), + Choice("Personal"), + Choice("Private"), + Choice("Confidential"), +] + + +class SensitivityField(ChoiceField): + """A field that indicates the sensitivity level of an item.""" + + def __init__(self, *args, **kwargs): + kwargs["choices"] = SENSITIVITY_CHOICES + super().__init__(*args, **kwargs)
    @@ -2628,9 +2696,12 @@

    Ancestors

    Subclasses

    Methods

    @@ -4637,6 +4708,49 @@

    Inherited members

    +
    +class FlaggedForActionField +(*args, **kwargs) +
    +
    +

    A field specifies the flag for action value that +must appear on incoming messages in order for the condition +or exception to apply.

    +
    + +Expand source code + +
    class FlaggedForActionField(ChoiceField):
    +    """
    +    A field specifies the flag for action value that
    +    must appear on incoming messages in order for the condition
    +    or exception to apply.
    +    """
    +
    +    def __init__(self, *args, **kwargs):
    +        kwargs["choices"] = FLAG_ACTION_CHOICES
    +        super().__init__(*args, **kwargs)
    +
    +

    Ancestors

    + +

    Inherited members

    + +
    class FreeBusyStatusField (*args, **kwargs) @@ -4830,6 +4944,49 @@

    Inherited members

    +
    +class ImportanceField +(*args, **kwargs) +
    +
    +

    A field that describes the importance of an item or +the aggregated importance of all items in a conversation +in the current folder.

    +
    + +Expand source code + +
    class ImportanceField(ChoiceField):
    +    """
    +    A field that describes the importance of an item or
    +    the aggregated importance of all items in a conversation
    +    in the current folder.
    +    """
    +
    +    def __init__(self, *args, **kwargs):
    +        kwargs["choices"] = IMPORTANCE_CHOICES
    +        super().__init__(*args, **kwargs)
    +
    +

    Ancestors

    + +

    Inherited members

    + +
    class IndexedField (*args, **kwargs) @@ -6074,6 +6231,43 @@

    Inherited members

    +
    +class SensitivityField +(*args, **kwargs) +
    +
    +

    A field that indicates the sensitivity level of an item.

    +
    + +Expand source code + +
    class SensitivityField(ChoiceField):
    +    """A field that indicates the sensitivity level of an item."""
    +
    +    def __init__(self, *args, **kwargs):
    +        kwargs["choices"] = SENSITIVITY_CHOICES
    +        super().__init__(*args, **kwargs)
    +
    +

    Ancestors

    + +

    Inherited members

    + +
    class StringAttributedValueField (*args, **kwargs) @@ -6571,16 +6765,21 @@

    Inherited members

    def from_xml(self, elem, account): field_elem = elem.find(self.response_tag()) if field_elem is not None: - ms_id = field_elem.get("Id") - ms_name = field_elem.get("Name") + tz_id = field_elem.get("Id") or field_elem.get("Name") try: - return self.value_cls.from_ms_id(ms_id or ms_name) + return self.value_cls.from_ms_id(tz_id) except UnknownTimeZone: - log.warning( - "Cannot convert value '%s' on field '%s' to type %s (unknown timezone ID)", - (ms_id or ms_name), - self.name, - self.value_cls, + warnings.warn( + f"""\ +Cannot convert value {tz_id!r} on field {self.name!r} to type {self.value_cls.__name__!r} (unknown timezone ID). +You can fix this by adding a custom entry into the timezone translation map: + +from exchangelib.winzone import MS_TIMEZONE_TO_IANA_MAP, CLDR_TO_MS_TIMEZONE_MAP + +# Replace "Some_Region/Some_Location" with a reasonable value from CLDR_TO_MS_TIMEZONE_MAP.keys() +MS_TIMEZONE_TO_IANA_MAP[{tz_id!r}] = "Some_Region/Some_Location" + +# Your code here""" ) return None return self.default @@ -7247,6 +7446,9 @@

    FlaggedForActionField

    + +
  • FreeBusyStatusField

  • @@ -7262,6 +7464,9 @@

    IdField

  • +

    ImportanceField

    +
  • +
  • IndexedField

    • PARENT_ELEMENT_NAME
    • @@ -7393,6 +7598,9 @@

      RoutingTypeField

    • +

      SensitivityField

      +
    • +
    • StringAttributedValueField

    • diff --git a/docs/exchangelib/folders/base.html b/docs/exchangelib/folders/base.html index 2067ceb1..778ac491 100644 --- a/docs/exchangelib/folders/base.html +++ b/docs/exchangelib/folders/base.html @@ -60,6 +60,7 @@

      Module exchangelib.folders.base

      DistinguishedFolderId, EWSMeta, FolderId, + Mailbox, ParentFolderId, UserConfiguration, UserConfigurationName, @@ -266,6 +267,7 @@

      Module exchangelib.folders.base

      ConversationSettings, CrawlerData, DlpPolicyEvaluation, + EventCheckPoints, FreeBusyCache, GALContacts, Messages, @@ -288,6 +290,7 @@

      Module exchangelib.folders.base

      ConversationSettings, CrawlerData, DlpPolicyEvaluation, + EventCheckPoints, FreeBusyCache, GALContacts, Messages, @@ -529,7 +532,10 @@

      Module exchangelib.folders.base

      return self._id if not self.DISTINGUISHED_FOLDER_ID: raise ValueError(f"{self} must be a distinguished folder or have an ID") - self._distinguished_id = DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID) + self._distinguished_id = DistinguishedFolderId( + id=self.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ) return self._distinguished_id @classmethod @@ -657,15 +663,15 @@

      Module exchangelib.folders.base

      @require_id def pull_subscription(self, **kwargs): - return PullSubscription(folder=self, **kwargs) + return PullSubscription(target=self, **kwargs) @require_id def push_subscription(self, **kwargs): - return PushSubscription(folder=self, **kwargs) + return PushSubscription(target=self, **kwargs) @require_id def streaming_subscription(self, **kwargs): - return StreamingSubscription(folder=self, **kwargs) + return StreamingSubscription(target=self, **kwargs) def unsubscribe(self, subscription_id): """Unsubscribe. Only applies to pull and streaming notifications. @@ -886,7 +892,13 @@

      Module exchangelib.folders.base

      :return: """ try: - return cls.resolve(account=root.account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) + return cls.resolve( + account=root.account, + folder=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=root.account.primary_smtp_address), + ), + ) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") @@ -1158,6 +1170,7 @@

      Classes

      ConversationSettings, CrawlerData, DlpPolicyEvaluation, + EventCheckPoints, FreeBusyCache, GALContacts, Messages, @@ -1180,6 +1193,7 @@

      Classes

      ConversationSettings, CrawlerData, DlpPolicyEvaluation, + EventCheckPoints, FreeBusyCache, GALContacts, Messages, @@ -1421,7 +1435,10 @@

      Classes

      return self._id if not self.DISTINGUISHED_FOLDER_ID: raise ValueError(f"{self} must be a distinguished folder or have an ID") - self._distinguished_id = DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID) + self._distinguished_id = DistinguishedFolderId( + id=self.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ) return self._distinguished_id @classmethod @@ -1549,15 +1566,15 @@

      Classes

      @require_id def pull_subscription(self, **kwargs): - return PullSubscription(folder=self, **kwargs) + return PullSubscription(target=self, **kwargs) @require_id def push_subscription(self, **kwargs): - return PushSubscription(folder=self, **kwargs) + return PushSubscription(target=self, **kwargs) @require_id def streaming_subscription(self, **kwargs): - return StreamingSubscription(folder=self, **kwargs) + return StreamingSubscription(target=self, **kwargs) def unsubscribe(self, subscription_id): """Unsubscribe. Only applies to pull and streaming notifications. @@ -1835,6 +1852,7 @@

      Static methods

      ConversationSettings, CrawlerData, DlpPolicyEvaluation, + EventCheckPoints, FreeBusyCache, GALContacts, Messages, @@ -1857,6 +1875,7 @@

      Static methods

      ConversationSettings, CrawlerData, DlpPolicyEvaluation, + EventCheckPoints, FreeBusyCache, GALContacts, Messages, @@ -2402,7 +2421,7 @@

      Methods

      @require_id
       def pull_subscription(self, **kwargs):
      -    return PullSubscription(folder=self, **kwargs)
      + return PullSubscription(target=self, **kwargs)
  • @@ -2416,7 +2435,7 @@

    Methods

    @require_id
     def push_subscription(self, **kwargs):
    -    return PushSubscription(folder=self, **kwargs)
    + return PushSubscription(target=self, **kwargs)
    @@ -2495,7 +2514,7 @@

    Methods

    @require_id
     def streaming_subscription(self, **kwargs):
    -    return StreamingSubscription(folder=self, **kwargs)
    + return StreamingSubscription(target=self, **kwargs)
    @@ -2709,7 +2728,10 @@

    Methods

    return self._id if not self.DISTINGUISHED_FOLDER_ID: raise ValueError(f"{self} must be a distinguished folder or have an ID") - self._distinguished_id = DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID) + self._distinguished_id = DistinguishedFolderId( + id=self.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ) return self._distinguished_id
    @@ -2993,7 +3015,13 @@

    Inherited members

    :return: """ try: - return cls.resolve(account=root.account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) + return cls.resolve( + account=root.account, + folder=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=root.account.primary_smtp_address), + ), + ) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") @@ -3088,6 +3116,7 @@

    Subclasses

  • DeferredAction
  • DeletedItems
  • DlpPolicyEvaluation
  • +
  • EventCheckPoints
  • ExchangeSyncData
  • Files
  • FreeBusyCache
  • @@ -3196,7 +3225,13 @@

    Static methods

    :return: """ try: - return cls.resolve(account=root.account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) + return cls.resolve( + account=root.account, + folder=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=root.account.primary_smtp_address), + ), + ) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}")
    diff --git a/docs/exchangelib/folders/collections.html b/docs/exchangelib/folders/collections.html index ce30d115..f6b93b79 100644 --- a/docs/exchangelib/folders/collections.html +++ b/docs/exchangelib/folders/collections.html @@ -476,13 +476,13 @@

    Module exchangelib.folders.collections

    return SubscribeToStreaming(account=self.account).get(folders=self.folders, event_types=event_types) def pull_subscription(self, **kwargs): - return PullSubscription(folder=self, **kwargs) + return PullSubscription(target=self, **kwargs) def push_subscription(self, **kwargs): - return PushSubscription(folder=self, **kwargs) + return PushSubscription(target=self, **kwargs) def streaming_subscription(self, **kwargs): - return StreamingSubscription(folder=self, **kwargs) + return StreamingSubscription(target=self, **kwargs) def unsubscribe(self, subscription_id): """Unsubscribe. Only applies to pull and streaming notifications. @@ -568,8 +568,8 @@

    Module exchangelib.folders.collections

    class BaseSubscription(metaclass=abc.ABCMeta): - def __init__(self, folder, **subscription_kwargs): - self.folder = folder + def __init__(self, target, **subscription_kwargs): + self.target = target self.subscription_kwargs = subscription_kwargs self.subscription_id = None @@ -578,19 +578,19 @@

    Module exchangelib.folders.collections

    """Create the subscription""" def __exit__(self, *args, **kwargs): - self.folder.unsubscribe(subscription_id=self.subscription_id) + self.target.unsubscribe(subscription_id=self.subscription_id) self.subscription_id = None class PullSubscription(BaseSubscription): def __enter__(self): - self.subscription_id, watermark = self.folder.subscribe_to_pull(**self.subscription_kwargs) + self.subscription_id, watermark = self.target.subscribe_to_pull(**self.subscription_kwargs) return self.subscription_id, watermark class PushSubscription(BaseSubscription): def __enter__(self): - self.subscription_id, watermark = self.folder.subscribe_to_push(**self.subscription_kwargs) + self.subscription_id, watermark = self.target.subscribe_to_push(**self.subscription_kwargs) return self.subscription_id, watermark def __exit__(self, *args, **kwargs): @@ -600,7 +600,7 @@

    Module exchangelib.folders.collections

    class StreamingSubscription(BaseSubscription): def __enter__(self): - self.subscription_id = self.folder.subscribe_to_streaming(**self.subscription_kwargs) + self.subscription_id = self.target.subscribe_to_streaming(**self.subscription_kwargs) return self.subscription_id
    @@ -615,7 +615,7 @@

    Classes

    class BaseSubscription -(folder, **subscription_kwargs) +(target, **subscription_kwargs)
    @@ -624,8 +624,8 @@

    Classes

    Expand source code
    class BaseSubscription(metaclass=abc.ABCMeta):
    -    def __init__(self, folder, **subscription_kwargs):
    -        self.folder = folder
    +    def __init__(self, target, **subscription_kwargs):
    +        self.target = target
             self.subscription_kwargs = subscription_kwargs
             self.subscription_id = None
     
    @@ -634,7 +634,7 @@ 

    Classes

    """Create the subscription""" def __exit__(self, *args, **kwargs): - self.folder.unsubscribe(subscription_id=self.subscription_id) + self.target.unsubscribe(subscription_id=self.subscription_id) self.subscription_id = None

    Subclasses

    @@ -1083,13 +1083,13 @@

    Subclasses

    return SubscribeToStreaming(account=self.account).get(folders=self.folders, event_types=event_types) def pull_subscription(self, **kwargs): - return PullSubscription(folder=self, **kwargs) + return PullSubscription(target=self, **kwargs) def push_subscription(self, **kwargs): - return PushSubscription(folder=self, **kwargs) + return PushSubscription(target=self, **kwargs) def streaming_subscription(self, **kwargs): - return StreamingSubscription(folder=self, **kwargs) + return StreamingSubscription(target=self, **kwargs) def unsubscribe(self, subscription_id): """Unsubscribe. Only applies to pull and streaming notifications. @@ -1565,7 +1565,7 @@

    Examples

    Expand source code
    def pull_subscription(self, **kwargs):
    -    return PullSubscription(folder=self, **kwargs)
    + return PullSubscription(target=self, **kwargs)
    @@ -1578,7 +1578,7 @@

    Examples

    Expand source code
    def push_subscription(self, **kwargs):
    -    return PushSubscription(folder=self, **kwargs)
    + return PushSubscription(target=self, **kwargs)
    @@ -1618,7 +1618,7 @@

    Examples

    Expand source code
    def streaming_subscription(self, **kwargs):
    -    return StreamingSubscription(folder=self, **kwargs)
    + return StreamingSubscription(target=self, **kwargs)
    @@ -1890,7 +1890,7 @@

    Inherited members

    class PullSubscription -(folder, **subscription_kwargs) +(target, **subscription_kwargs)
    @@ -1900,7 +1900,7 @@

    Inherited members

    class PullSubscription(BaseSubscription):
         def __enter__(self):
    -        self.subscription_id, watermark = self.folder.subscribe_to_pull(**self.subscription_kwargs)
    +        self.subscription_id, watermark = self.target.subscribe_to_pull(**self.subscription_kwargs)
             return self.subscription_id, watermark

    Ancestors

    @@ -1910,7 +1910,7 @@

    Ancestors

    class PushSubscription -(folder, **subscription_kwargs) +(target, **subscription_kwargs)
    @@ -1920,7 +1920,7 @@

    Ancestors

    class PushSubscription(BaseSubscription):
         def __enter__(self):
    -        self.subscription_id, watermark = self.folder.subscribe_to_push(**self.subscription_kwargs)
    +        self.subscription_id, watermark = self.target.subscribe_to_push(**self.subscription_kwargs)
             return self.subscription_id, watermark
     
         def __exit__(self, *args, **kwargs):
    @@ -1934,7 +1934,7 @@ 

    Ancestors

    class StreamingSubscription -(folder, **subscription_kwargs) +(target, **subscription_kwargs)
    @@ -1944,7 +1944,7 @@

    Ancestors

    class StreamingSubscription(BaseSubscription):
         def __enter__(self):
    -        self.subscription_id = self.folder.subscribe_to_streaming(**self.subscription_kwargs)
    +        self.subscription_id = self.target.subscribe_to_streaming(**self.subscription_kwargs)
             return self.subscription_id

    Ancestors

    diff --git a/docs/exchangelib/folders/index.html b/docs/exchangelib/folders/index.html index fc2e7e8c..cc32001d 100644 --- a/docs/exchangelib/folders/index.html +++ b/docs/exchangelib/folders/index.html @@ -59,6 +59,7 @@

    Module exchangelib.folders

    Directory, DlpPolicyEvaluation, Drafts, + EventCheckPoints, ExchangeSyncData, Favorites, Files, @@ -153,6 +154,7 @@

    Module exchangelib.folders

    "DistinguishedFolderId", "DlpPolicyEvaluation", "Drafts", + "EventCheckPoints", "ExchangeSyncData", "FOLDER_TRAVERSAL_CHOICES", "Favorites", @@ -1428,6 +1430,7 @@

    Inherited members

    ConversationSettings, CrawlerData, DlpPolicyEvaluation, + EventCheckPoints, FreeBusyCache, GALContacts, Messages, @@ -1450,6 +1453,7 @@

    Inherited members

    ConversationSettings, CrawlerData, DlpPolicyEvaluation, + EventCheckPoints, FreeBusyCache, GALContacts, Messages, @@ -1691,7 +1695,10 @@

    Inherited members

    return self._id if not self.DISTINGUISHED_FOLDER_ID: raise ValueError(f"{self} must be a distinguished folder or have an ID") - self._distinguished_id = DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID) + self._distinguished_id = DistinguishedFolderId( + id=self.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ) return self._distinguished_id @classmethod @@ -1819,15 +1826,15 @@

    Inherited members

    @require_id def pull_subscription(self, **kwargs): - return PullSubscription(folder=self, **kwargs) + return PullSubscription(target=self, **kwargs) @require_id def push_subscription(self, **kwargs): - return PushSubscription(folder=self, **kwargs) + return PushSubscription(target=self, **kwargs) @require_id def streaming_subscription(self, **kwargs): - return StreamingSubscription(folder=self, **kwargs) + return StreamingSubscription(target=self, **kwargs) def unsubscribe(self, subscription_id): """Unsubscribe. Only applies to pull and streaming notifications. @@ -2105,6 +2112,7 @@

    Static methods

    ConversationSettings, CrawlerData, DlpPolicyEvaluation, + EventCheckPoints, FreeBusyCache, GALContacts, Messages, @@ -2127,6 +2135,7 @@

    Static methods

    ConversationSettings, CrawlerData, DlpPolicyEvaluation, + EventCheckPoints, FreeBusyCache, GALContacts, Messages, @@ -2672,7 +2681,7 @@

    Methods

    @require_id
     def pull_subscription(self, **kwargs):
    -    return PullSubscription(folder=self, **kwargs)
    + return PullSubscription(target=self, **kwargs)
    @@ -2686,7 +2695,7 @@

    Methods

    @require_id
     def push_subscription(self, **kwargs):
    -    return PushSubscription(folder=self, **kwargs)
    + return PushSubscription(target=self, **kwargs)
    @@ -2765,7 +2774,7 @@

    Methods

    @require_id
     def streaming_subscription(self, **kwargs):
    -    return StreamingSubscription(folder=self, **kwargs)
    + return StreamingSubscription(target=self, **kwargs)
    @@ -2979,7 +2988,10 @@

    Methods

    return self._id if not self.DISTINGUISHED_FOLDER_ID: raise ValueError(f"{self} must be a distinguished folder or have an ID") - self._distinguished_id = DistinguishedFolderId(id=self.DISTINGUISHED_FOLDER_ID) + self._distinguished_id = DistinguishedFolderId( + id=self.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ) return self._distinguished_id
    @@ -4564,6 +4576,74 @@

    Inherited members

    +
    +class EventCheckPoints +(**kwargs) +
    +
    + +
    + +Expand source code + +
    class EventCheckPoints(Folder):
    +    CONTAINER_CLASS = "IPF.StoreItem.EventCheckPoints"
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var CONTAINER_CLASS
    +
    +
    +
    +
    +

    Inherited members

    + +
    class ExchangeSyncData (**kwargs) @@ -4859,7 +4939,13 @@

    Inherited members

    :return: """ try: - return cls.resolve(account=root.account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) + return cls.resolve( + account=root.account, + folder=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=root.account.primary_smtp_address), + ), + ) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") @@ -4954,6 +5040,7 @@

    Subclasses

  • DeferredAction
  • DeletedItems
  • DlpPolicyEvaluation
  • +
  • EventCheckPoints
  • ExchangeSyncData
  • Files
  • FreeBusyCache
  • @@ -5062,7 +5149,13 @@

    Static methods

    :return: """ try: - return cls.resolve(account=root.account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) + return cls.resolve( + account=root.account, + folder=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=root.account.primary_smtp_address), + ), + ) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}")
    @@ -5575,13 +5668,13 @@

    Inherited members

    return SubscribeToStreaming(account=self.account).get(folders=self.folders, event_types=event_types) def pull_subscription(self, **kwargs): - return PullSubscription(folder=self, **kwargs) + return PullSubscription(target=self, **kwargs) def push_subscription(self, **kwargs): - return PushSubscription(folder=self, **kwargs) + return PushSubscription(target=self, **kwargs) def streaming_subscription(self, **kwargs): - return StreamingSubscription(folder=self, **kwargs) + return StreamingSubscription(target=self, **kwargs) def unsubscribe(self, subscription_id): """Unsubscribe. Only applies to pull and streaming notifications. @@ -6057,7 +6150,7 @@

    Examples

    Expand source code
    def pull_subscription(self, **kwargs):
    -    return PullSubscription(folder=self, **kwargs)
    + return PullSubscription(target=self, **kwargs)
    @@ -6070,7 +6163,7 @@

    Examples

    Expand source code
    def push_subscription(self, **kwargs):
    -    return PushSubscription(folder=self, **kwargs)
    + return PushSubscription(target=self, **kwargs)
    @@ -6110,7 +6203,7 @@

    Examples

    Expand source code
    def streaming_subscription(self, **kwargs):
    -    return StreamingSubscription(folder=self, **kwargs)
    + return StreamingSubscription(target=self, **kwargs)
    @@ -9727,7 +9820,13 @@

    Inherited members

    if not cls.DISTINGUISHED_FOLDER_ID: raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: - return cls.resolve(account=account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) + return cls.resolve( + account=account, + folder=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=account.primary_smtp_address), + ), + ) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") @@ -9751,7 +9850,13 @@

    Inherited members

    except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) - fld = folder_cls(root=self, _distinguished_id=DistinguishedFolderId(id=folder_cls.DISTINGUISHED_FOLDER_ID)) + fld = folder_cls( + root=self, + _distinguished_id=DistinguishedFolderId( + id=folder_cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ), + ) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: @@ -9769,7 +9874,10 @@

    Inherited members

    # so we are sure to apply the correct Folder class, then fetch all sub-folders of this root. folders_map = {self.id: self} distinguished_folders = [ - DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID) + DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ) for cls in self.WELLKNOWN_FOLDERS if cls.get_folder_allowed and cls.supports_version(self.account.version) ] @@ -9962,7 +10070,13 @@

    Static methods

    if not cls.DISTINGUISHED_FOLDER_ID: raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: - return cls.resolve(account=account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) + return cls.resolve( + account=account, + folder=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=account.primary_smtp_address), + ), + ) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}")
    @@ -10053,7 +10167,13 @@

    Methods

    except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) - fld = folder_cls(root=self, _distinguished_id=DistinguishedFolderId(id=folder_cls.DISTINGUISHED_FOLDER_ID)) + fld = folder_cls( + root=self, + _distinguished_id=DistinguishedFolderId( + id=folder_cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ), + ) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: @@ -12061,6 +12181,12 @@

    EventCheckPoints

    + + +
  • ExchangeSyncData

    • LOCALIZED_NAMES
    • diff --git a/docs/exchangelib/folders/known_folders.html b/docs/exchangelib/folders/known_folders.html index 8c596821..755548a5 100644 --- a/docs/exchangelib/folders/known_folders.html +++ b/docs/exchangelib/folders/known_folders.html @@ -110,6 +110,10 @@

      Module exchangelib.folders.known_folders

      CONTAINER_CLASS = "IPF.StoreItem.SwssItems" +class EventCheckPoints(Folder): + CONTAINER_CLASS = "IPF.StoreItem.EventCheckPoints" + + class SkypeTeamsMessages(Folder): CONTAINER_CLASS = "IPF.SkypeTeams.Message" LOCALIZED_NAMES = { @@ -2974,6 +2978,74 @@

      Inherited members

    +
    +class EventCheckPoints +(**kwargs) +
    +
    + +
    + +Expand source code + +
    class EventCheckPoints(Folder):
    +    CONTAINER_CLASS = "IPF.StoreItem.EventCheckPoints"
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var CONTAINER_CLASS
    +
    +
    +
    +
    +

    Inherited members

    + +
    class ExchangeSyncData (**kwargs) @@ -7654,6 +7726,12 @@

    EventCheckPoints

    + +
  • +
  • ExchangeSyncData

    • LOCALIZED_NAMES
    • diff --git a/docs/exchangelib/folders/roots.html b/docs/exchangelib/folders/roots.html index 703458e5..cd2ad19d 100644 --- a/docs/exchangelib/folders/roots.html +++ b/docs/exchangelib/folders/roots.html @@ -32,7 +32,7 @@

      Module exchangelib.folders.roots

      from ..errors import ErrorAccessDenied, ErrorFolderNotFound, ErrorInvalidOperation from ..fields import EffectiveRightsField -from ..properties import DistinguishedFolderId, EWSMeta +from ..properties import DistinguishedFolderId, EWSMeta, Mailbox from ..version import EXCHANGE_2007_SP1, EXCHANGE_2010_SP1 from .base import BaseFolder from .collections import FolderCollection @@ -138,7 +138,13 @@

      Module exchangelib.folders.roots

      if not cls.DISTINGUISHED_FOLDER_ID: raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: - return cls.resolve(account=account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) + return cls.resolve( + account=account, + folder=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=account.primary_smtp_address), + ), + ) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") @@ -162,7 +168,13 @@

      Module exchangelib.folders.roots

      except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) - fld = folder_cls(root=self, _distinguished_id=DistinguishedFolderId(id=folder_cls.DISTINGUISHED_FOLDER_ID)) + fld = folder_cls( + root=self, + _distinguished_id=DistinguishedFolderId( + id=folder_cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ), + ) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: @@ -180,7 +192,10 @@

      Module exchangelib.folders.roots

      # so we are sure to apply the correct Folder class, then fetch all sub-folders of this root. folders_map = {self.id: self} distinguished_folders = [ - DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID) + DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ) for cls in self.WELLKNOWN_FOLDERS if cls.get_folder_allowed and cls.supports_version(self.account.version) ] @@ -888,7 +903,13 @@

      Inherited members

      if not cls.DISTINGUISHED_FOLDER_ID: raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: - return cls.resolve(account=account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) + return cls.resolve( + account=account, + folder=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=account.primary_smtp_address), + ), + ) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") @@ -912,7 +933,13 @@

      Inherited members

      except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) - fld = folder_cls(root=self, _distinguished_id=DistinguishedFolderId(id=folder_cls.DISTINGUISHED_FOLDER_ID)) + fld = folder_cls( + root=self, + _distinguished_id=DistinguishedFolderId( + id=folder_cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ), + ) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: @@ -930,7 +957,10 @@

      Inherited members

      # so we are sure to apply the correct Folder class, then fetch all sub-folders of this root. folders_map = {self.id: self} distinguished_folders = [ - DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID) + DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ) for cls in self.WELLKNOWN_FOLDERS if cls.get_folder_allowed and cls.supports_version(self.account.version) ] @@ -1123,7 +1153,13 @@

      Static methods

      if not cls.DISTINGUISHED_FOLDER_ID: raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: - return cls.resolve(account=account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) + return cls.resolve( + account=account, + folder=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=account.primary_smtp_address), + ), + ) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}")
      @@ -1214,7 +1250,13 @@

      Methods

      except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) - fld = folder_cls(root=self, _distinguished_id=DistinguishedFolderId(id=folder_cls.DISTINGUISHED_FOLDER_ID)) + fld = folder_cls( + root=self, + _distinguished_id=DistinguishedFolderId( + id=folder_cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ), + ) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: diff --git a/docs/exchangelib/index.html b/docs/exchangelib/index.html index faf89e00..dd9ab62f 100644 --- a/docs/exchangelib/index.html +++ b/docs/exchangelib/index.html @@ -64,7 +64,7 @@

      Package exchangelib

      from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "5.1.0" +__version__ = "5.2.0" __all__ = [ "__version__", @@ -438,11 +438,16 @@

      Inherited members

      raise InvalidTypeError("config", config, Configuration) if autodiscover: if config: - auth_type, retry_policy, version = config.auth_type, config.retry_policy, config.version + auth_type, retry_policy, version, max_connections = ( + config.auth_type, + config.retry_policy, + config.version, + config.max_connections, + ) if not credentials: credentials = config.credentials else: - auth_type, retry_policy, version = None, None, None + auth_type, retry_policy, version, max_connections = None, None, None, None self.ad_response, self.protocol = Autodiscovery( email=primary_smtp_address, credentials=credentials ).discover() @@ -452,6 +457,7 @@

      Inherited members

      self.protocol.config.retry_policy = retry_policy if version: self.protocol.config.version = version + self.protocol.max_connections = max_connections primary_smtp_address = self.ad_response.autodiscover_smtp_address else: if not config: @@ -1023,6 +1029,116 @@

      Inherited members

      """Return a list of DelegateUser objects representing the delegates that are set on this account.""" return list(GetDelegate(account=self).call(user_ids=None, include_permissions=True)) + @property + def rules(self): + """Return a list of Rule objects representing the rules that are set on this account.""" + return list(GetInboxRules(account=self).call()) + + def create_rule(self, rule: Rule): + """Create an Inbox rule. + + :param rule: The rule to create. Must have at least 'display_name' set. + :return: None if success, else raises an error. + """ + CreateInboxRule(account=self).get(rule=rule, remove_outlook_rule_blob=True) + # After creating the rule, query all rules, + # find the rule that was just created, and return its ID. + try: + rule.id = {i.display_name: i for i in GetInboxRules(account=self).call()}[rule.display_name].id + except KeyError: + raise ResponseMessageError(f"Failed to create rule ({rule.display_name})!") + + def set_rule(self, rule: Rule): + """Modify an Inbox rule. + + :param rule: The rule to set. Must have an ID. + :return: None if success, else raises an error. + """ + SetInboxRule(account=self).get(rule=rule) + + def delete_rule(self, rule: Rule): + """Delete an Inbox rule. + + :param rule: The rule to delete. Must have ID or 'display_name'. + :return: None if success, else raises an error. + """ + if not rule.id: + if not rule.display_name: + raise ValueError("Rule must have ID or display_name") + try: + rule = {i.display_name: i for i in GetInboxRules(account=self).call()}[rule.display_name] + except KeyError: + raise ErrorItemNotFound(f"No rule with name {rule.display_name!r}") + DeleteInboxRule(account=self).get(rule=rule) + rule.id = None + + def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): + """Create a pull subscription. + + :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPull.EVENT_TYPES + :param watermark: An event bookmark as returned by some sync services + :param timeout: Timeout of the subscription, in minutes. Timeout is reset when the server receives a + GetEvents request for this subscription. + :return: The subscription ID and a watermark + """ + if event_types is None: + event_types = SubscribeToPull.EVENT_TYPES + return SubscribeToPull(account=self).get( + folders=None, + event_types=event_types, + watermark=watermark, + timeout=timeout, + ) + + def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1): + """Create a push subscription. + + :param callback_url: A client-defined URL that the server will call + :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES + :param watermark: An event bookmark as returned by some sync services + :param status_frequency: The frequency, in minutes, that the callback URL will be called with. + :return: The subscription ID and a watermark + """ + if event_types is None: + event_types = SubscribeToPush.EVENT_TYPES + return SubscribeToPush(account=self).get( + folders=None, + event_types=event_types, + watermark=watermark, + status_frequency=status_frequency, + url=callback_url, + ) + + def subscribe_to_streaming(self, event_types=None): + """Create a streaming subscription. + + :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES + :return: The subscription ID + """ + if event_types is None: + event_types = SubscribeToStreaming.EVENT_TYPES + return SubscribeToStreaming(account=self).get(folders=None, event_types=event_types) + + def pull_subscription(self, **kwargs): + return PullSubscription(target=self, **kwargs) + + def push_subscription(self, **kwargs): + return PushSubscription(target=self, **kwargs) + + def streaming_subscription(self, **kwargs): + return StreamingSubscription(target=self, **kwargs) + + def unsubscribe(self, subscription_id): + """Unsubscribe. Only applies to pull and streaming notifications. + + :param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|streaming]() + :return: True + + This method doesn't need the current collection instance, but it makes sense to keep the method along the other + sync methods. + """ + return Unsubscribe(account=self).get(subscription_id=subscription_id) + def __str__(self): if self.fullname: return f"{self.primary_smtp_address} ({self.fullname})" @@ -1884,6 +2000,19 @@

      Instance variables

      return obj_dict.setdefault(name, self.func(obj)) +
      var rules
      +
      +

      Return a list of Rule objects representing the rules that are set on this account.

      +
      + +Expand source code + +
      @property
      +def rules(self):
      +    """Return a list of Rule objects representing the rules that are set on this account."""
      +    return list(GetInboxRules(account=self).call())
      +
      +
      var search_folders
      @@ -2451,6 +2580,60 @@

      Methods

      )
      +
      +def create_rule(self, rule: Rule) +
      +
      +

      Create an Inbox rule.

      +

      :param rule: The rule to create. Must have at least 'display_name' set. +:return: None if success, else raises an error.

      +
      + +Expand source code + +
      def create_rule(self, rule: Rule):
      +    """Create an Inbox rule.
      +
      +    :param rule: The rule to create. Must have at least 'display_name' set.
      +    :return: None if success, else raises an error.
      +    """
      +    CreateInboxRule(account=self).get(rule=rule, remove_outlook_rule_blob=True)
      +    # After creating the rule, query all rules,
      +    # find the rule that was just created, and return its ID.
      +    try:
      +        rule.id = {i.display_name: i for i in GetInboxRules(account=self).call()}[rule.display_name].id
      +    except KeyError:
      +        raise ResponseMessageError(f"Failed to create rule ({rule.display_name})!")
      +
      +
      +
      +def delete_rule(self, rule: Rule) +
      +
      +

      Delete an Inbox rule.

      +

      :param rule: The rule to delete. Must have ID or 'display_name'. +:return: None if success, else raises an error.

      +
      + +Expand source code + +
      def delete_rule(self, rule: Rule):
      +    """Delete an Inbox rule.
      +
      +    :param rule: The rule to delete. Must have ID or 'display_name'.
      +    :return: None if success, else raises an error.
      +    """
      +    if not rule.id:
      +        if not rule.display_name:
      +            raise ValueError("Rule must have ID or display_name")
      +        try:
      +            rule = {i.display_name: i for i in GetInboxRules(account=self).call()}[rule.display_name]
      +        except KeyError:
      +            raise ErrorItemNotFound(f"No rule with name {rule.display_name!r}")
      +    DeleteInboxRule(account=self).get(rule=rule)
      +    rule.id = None
      +
      +
      def export(self, items, chunk_size=None)
      @@ -2554,6 +2737,179 @@

      Methods

      yield from GetPersona(account=self).call(personas=ids) +
      +def pull_subscription(self, **kwargs) +
      +
      +
      +
      + +Expand source code + +
      def pull_subscription(self, **kwargs):
      +    return PullSubscription(target=self, **kwargs)
      +
      +
      +
      +def push_subscription(self, **kwargs) +
      +
      +
      +
      + +Expand source code + +
      def push_subscription(self, **kwargs):
      +    return PushSubscription(target=self, **kwargs)
      +
      +
      +
      +def set_rule(self, rule: Rule) +
      +
      +

      Modify an Inbox rule.

      +

      :param rule: The rule to set. Must have an ID. +:return: None if success, else raises an error.

      +
      + +Expand source code + +
      def set_rule(self, rule: Rule):
      +    """Modify an Inbox rule.
      +
      +    :param rule: The rule to set. Must have an ID.
      +    :return: None if success, else raises an error.
      +    """
      +    SetInboxRule(account=self).get(rule=rule)
      +
      +
      +
      +def streaming_subscription(self, **kwargs) +
      +
      +
      +
      + +Expand source code + +
      def streaming_subscription(self, **kwargs):
      +    return StreamingSubscription(target=self, **kwargs)
      +
      +
      +
      +def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60) +
      +
      +

      Create a pull subscription.

      +

      :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPull.EVENT_TYPES +:param watermark: An event bookmark as returned by some sync services +:param timeout: Timeout of the subscription, in minutes. Timeout is reset when the server receives a +GetEvents request for this subscription. +:return: The subscription ID and a watermark

      +
      + +Expand source code + +
      def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60):
      +    """Create a pull subscription.
      +
      +    :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPull.EVENT_TYPES
      +    :param watermark: An event bookmark as returned by some sync services
      +    :param timeout: Timeout of the subscription, in minutes. Timeout is reset when the server receives a
      +    GetEvents request for this subscription.
      +    :return: The subscription ID and a watermark
      +    """
      +    if event_types is None:
      +        event_types = SubscribeToPull.EVENT_TYPES
      +    return SubscribeToPull(account=self).get(
      +        folders=None,
      +        event_types=event_types,
      +        watermark=watermark,
      +        timeout=timeout,
      +    )
      +
      +
      +
      +def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1) +
      +
      +

      Create a push subscription.

      +

      :param callback_url: A client-defined URL that the server will call +:param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES +:param watermark: An event bookmark as returned by some sync services +:param status_frequency: The frequency, in minutes, that the callback URL will be called with. +:return: The subscription ID and a watermark

      +
      + +Expand source code + +
      def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1):
      +    """Create a push subscription.
      +
      +    :param callback_url: A client-defined URL that the server will call
      +    :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES
      +    :param watermark: An event bookmark as returned by some sync services
      +    :param status_frequency: The frequency, in minutes, that the callback URL will be called with.
      +    :return: The subscription ID and a watermark
      +    """
      +    if event_types is None:
      +        event_types = SubscribeToPush.EVENT_TYPES
      +    return SubscribeToPush(account=self).get(
      +        folders=None,
      +        event_types=event_types,
      +        watermark=watermark,
      +        status_frequency=status_frequency,
      +        url=callback_url,
      +    )
      +
      +
      +
      +def subscribe_to_streaming(self, event_types=None) +
      +
      +

      Create a streaming subscription.

      +

      :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES +:return: The subscription ID

      +
      + +Expand source code + +
      def subscribe_to_streaming(self, event_types=None):
      +    """Create a streaming subscription.
      +
      +    :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES
      +    :return: The subscription ID
      +    """
      +    if event_types is None:
      +        event_types = SubscribeToStreaming.EVENT_TYPES
      +    return SubscribeToStreaming(account=self).get(folders=None, event_types=event_types)
      +
      +
      +
      +def unsubscribe(self, subscription_id) +
      +
      +

      Unsubscribe. Only applies to pull and streaming notifications.

      +

      :param subscription_id: A subscription ID as acquired by .subscribe_to_pull|streaming +:return: True

      +

      This method doesn't need the current collection instance, but it makes sense to keep the method along the other +sync methods.

      +
      + +Expand source code + +
      def unsubscribe(self, subscription_id):
      +    """Unsubscribe. Only applies to pull and streaming notifications.
      +
      +    :param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|streaming]()
      +    :return: True
      +
      +    This method doesn't need the current collection instance, but it makes sense to keep the method along the other
      +    sync methods.
      +    """
      +    return Unsubscribe(account=self).get(subscription_id=subscription_id)
      +
      +
      def upload(self, data, chunk_size=None)
      @@ -2622,6 +2978,8 @@

      Methods

      field_uri="ResponseType", choices={Choice(c) for c in RESPONSE_TYPES}, default="Unknown" ) last_response_time = DateTimeField(field_uri="LastResponseTime") + proposed_start = DateTimeField(field_uri="ProposedStart") + proposed_end = DateTimeField(field_uri="ProposedEnd") def __hash__(self): return hash(self.mailbox) @@ -2655,6 +3013,14 @@

      Instance variables

      +
      var proposed_end
      +
      +
      +
      +
      var proposed_start
      +
      +
      +
      var response_type
      @@ -2747,6 +3113,15 @@

      Inherited members

      self.config._credentials = value self.close() + @property + def max_connections(self): + return self._session_pool_maxsize + + @max_connections.setter + def max_connections(self, value): + with self._session_pool_lock: + self._session_pool_maxsize = value or self.SESSION_POOLSIZE + @property def retry_policy(self): return self.config.retry_policy @@ -3113,6 +3488,18 @@

      Instance variables

      return self.config.credentials
      +
      var max_connections
      +
      +
      +
      + +Expand source code + +
      @property
      +def max_connections(self):
      +    return self._session_pool_maxsize
      +
      +
      var retry_policy
      @@ -6955,7 +7342,13 @@

      Inherited members

      :return: """ try: - return cls.resolve(account=root.account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) + return cls.resolve( + account=root.account, + folder=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=root.account.primary_smtp_address), + ), + ) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") @@ -7050,6 +7443,7 @@

      Subclasses

    • DeferredAction
    • DeletedItems
    • DlpPolicyEvaluation
    • +
    • EventCheckPoints
    • ExchangeSyncData
    • Files
    • FreeBusyCache
    • @@ -7158,7 +7552,13 @@

      Static methods

      :return: """ try: - return cls.resolve(account=root.account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) + return cls.resolve( + account=root.account, + folder=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=root.account.primary_smtp_address), + ), + ) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") @@ -7671,13 +8071,13 @@

      Inherited members

      return SubscribeToStreaming(account=self.account).get(folders=self.folders, event_types=event_types) def pull_subscription(self, **kwargs): - return PullSubscription(folder=self, **kwargs) + return PullSubscription(target=self, **kwargs) def push_subscription(self, **kwargs): - return PushSubscription(folder=self, **kwargs) + return PushSubscription(target=self, **kwargs) def streaming_subscription(self, **kwargs): - return StreamingSubscription(folder=self, **kwargs) + return StreamingSubscription(target=self, **kwargs) def unsubscribe(self, subscription_id): """Unsubscribe. Only applies to pull and streaming notifications. @@ -8153,7 +8553,7 @@

      Examples

      Expand source code
      def pull_subscription(self, **kwargs):
      -    return PullSubscription(folder=self, **kwargs)
      + return PullSubscription(target=self, **kwargs)
      @@ -8166,7 +8566,7 @@

      Examples

      Expand source code
      def push_subscription(self, **kwargs):
      -    return PushSubscription(folder=self, **kwargs)
      + return PushSubscription(target=self, **kwargs)
      @@ -8206,7 +8606,7 @@

      Examples

      Expand source code
      def streaming_subscription(self, **kwargs):
      -    return StreamingSubscription(folder=self, **kwargs)
      + return StreamingSubscription(target=self, **kwargs)
      @@ -9130,7 +9530,7 @@

      Inherited members

      @require_id def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None): - if to_recipients is None: + if not to_recipients: if not self.author: raise ValueError("'to_recipients' must be set when message has no 'author'") to_recipients = [self.author] @@ -9150,17 +9550,23 @@

      Inherited members

      @require_id def create_reply_all(self, subject, body, author=None): - to_recipients = list(self.to_recipients) if self.to_recipients else [] + me = MailboxField().clean(self.account.primary_smtp_address.lower()) + to_recipients = set(self.to_recipients or []) + to_recipients.discard(me) + cc_recipients = set(self.cc_recipients or []) + cc_recipients.discard(me) + bcc_recipients = set(self.bcc_recipients or []) + bcc_recipients.discard(me) if self.author: - to_recipients.append(self.author) + to_recipients.add(self.author) return ReplyAllToItem( account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), subject=subject, new_body=body, - to_recipients=to_recipients, - cc_recipients=self.cc_recipients, - bcc_recipients=self.bcc_recipients, + to_recipients=list(to_recipients), + cc_recipients=list(cc_recipients), + bcc_recipients=list(bcc_recipients), author=author, ) @@ -9288,7 +9694,7 @@

      Methods

      @require_id
       def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None):
      -    if to_recipients is None:
      +    if not to_recipients:
               if not self.author:
                   raise ValueError("'to_recipients' must be set when message has no 'author'")
               to_recipients = [self.author]
      @@ -9315,17 +9721,23 @@ 

      Methods

      @require_id
       def create_reply_all(self, subject, body, author=None):
      -    to_recipients = list(self.to_recipients) if self.to_recipients else []
      +    me = MailboxField().clean(self.account.primary_smtp_address.lower())
      +    to_recipients = set(self.to_recipients or [])
      +    to_recipients.discard(me)
      +    cc_recipients = set(self.cc_recipients or [])
      +    cc_recipients.discard(me)
      +    bcc_recipients = set(self.bcc_recipients or [])
      +    bcc_recipients.discard(me)
           if self.author:
      -        to_recipients.append(self.author)
      +        to_recipients.add(self.author)
           return ReplyAllToItem(
               account=self.account,
               reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey),
               subject=subject,
               new_body=body,
      -        to_recipients=to_recipients,
      -        cc_recipients=self.cc_recipients,
      -        bcc_recipients=self.bcc_recipients,
      +        to_recipients=list(to_recipients),
      +        cc_recipients=list(cc_recipients),
      +        bcc_recipients=list(bcc_recipients),
               author=author,
           )
      @@ -11506,7 +11918,13 @@

      Inherited members

      if not cls.DISTINGUISHED_FOLDER_ID: raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: - return cls.resolve(account=account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) + return cls.resolve( + account=account, + folder=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=account.primary_smtp_address), + ), + ) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") @@ -11530,7 +11948,13 @@

      Inherited members

      except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) - fld = folder_cls(root=self, _distinguished_id=DistinguishedFolderId(id=folder_cls.DISTINGUISHED_FOLDER_ID)) + fld = folder_cls( + root=self, + _distinguished_id=DistinguishedFolderId( + id=folder_cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ), + ) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: @@ -11548,7 +11972,10 @@

      Inherited members

      # so we are sure to apply the correct Folder class, then fetch all sub-folders of this root. folders_map = {self.id: self} distinguished_folders = [ - DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID) + DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ) for cls in self.WELLKNOWN_FOLDERS if cls.get_folder_allowed and cls.supports_version(self.account.version) ] @@ -11741,7 +12168,13 @@

      Static methods

      if not cls.DISTINGUISHED_FOLDER_ID: raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: - return cls.resolve(account=account, folder=DistinguishedFolderId(id=cls.DISTINGUISHED_FOLDER_ID)) + return cls.resolve( + account=account, + folder=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=account.primary_smtp_address), + ), + ) except MISSING_FOLDER_ERRORS: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}")
      @@ -11832,7 +12265,13 @@

      Methods

      except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) - fld = folder_cls(root=self, _distinguished_id=DistinguishedFolderId(id=folder_cls.DISTINGUISHED_FOLDER_ID)) + fld = folder_cls( + root=self, + _distinguished_id=DistinguishedFolderId( + id=folder_cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ), + ) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available except MISSING_FOLDER_ERRORS: @@ -12835,7 +13274,9 @@

      Account
    • conflicts
    • contacts
    • conversation_history
    • +
    • create_rule
    • delegates
    • +
    • delete_rule
    • directory
    • domain
    • drafts
    • @@ -12857,6 +13298,8 @@

      Account
    • people_connect
    • primary_smtp_address
    • public_folders_root
    • +
    • pull_subscription
    • +
    • push_subscription
    • quick_contacts
    • recipient_cache
    • recoverable_items_deletions
    • @@ -12864,13 +13307,20 @@

      Account
    • recoverable_items_root
    • recoverable_items_versions
    • root
    • +
    • rules
    • search_folders
    • sent
    • server_failures
    • +
    • set_rule
    • +
    • streaming_subscription
    • +
    • subscribe_to_pull
    • +
    • subscribe_to_push
    • +
    • subscribe_to_streaming
    • sync_issues
    • tasks
    • todo_search
    • trash
    • +
    • unsubscribe
    • upload
    • voice_mail
    @@ -12883,6 +13333,8 @@

    Attendee<
  • RESPONSE_TYPES
  • last_response_time
  • mailbox
  • +
  • proposed_end
  • +
  • proposed_start
  • response_type
  • @@ -12907,6 +13359,7 @@

    B
  • get_auth_type
  • get_session
  • increase_poolsize
  • +
  • max_connections
  • raw_session
  • refresh_credentials
  • release_session
  • diff --git a/docs/exchangelib/items/index.html b/docs/exchangelib/items/index.html index 6993901c..008cc1f0 100644 --- a/docs/exchangelib/items/index.html +++ b/docs/exchangelib/items/index.html @@ -3111,7 +3111,7 @@

    Inherited members

    @require_id def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None): - if to_recipients is None: + if not to_recipients: if not self.author: raise ValueError("'to_recipients' must be set when message has no 'author'") to_recipients = [self.author] @@ -3131,17 +3131,23 @@

    Inherited members

    @require_id def create_reply_all(self, subject, body, author=None): - to_recipients = list(self.to_recipients) if self.to_recipients else [] + me = MailboxField().clean(self.account.primary_smtp_address.lower()) + to_recipients = set(self.to_recipients or []) + to_recipients.discard(me) + cc_recipients = set(self.cc_recipients or []) + cc_recipients.discard(me) + bcc_recipients = set(self.bcc_recipients or []) + bcc_recipients.discard(me) if self.author: - to_recipients.append(self.author) + to_recipients.add(self.author) return ReplyAllToItem( account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), subject=subject, new_body=body, - to_recipients=to_recipients, - cc_recipients=self.cc_recipients, - bcc_recipients=self.bcc_recipients, + to_recipients=list(to_recipients), + cc_recipients=list(cc_recipients), + bcc_recipients=list(bcc_recipients), author=author, ) @@ -3269,7 +3275,7 @@

    Methods

    @require_id
     def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None):
    -    if to_recipients is None:
    +    if not to_recipients:
             if not self.author:
                 raise ValueError("'to_recipients' must be set when message has no 'author'")
             to_recipients = [self.author]
    @@ -3296,17 +3302,23 @@ 

    Methods

    @require_id
     def create_reply_all(self, subject, body, author=None):
    -    to_recipients = list(self.to_recipients) if self.to_recipients else []
    +    me = MailboxField().clean(self.account.primary_smtp_address.lower())
    +    to_recipients = set(self.to_recipients or [])
    +    to_recipients.discard(me)
    +    cc_recipients = set(self.cc_recipients or [])
    +    cc_recipients.discard(me)
    +    bcc_recipients = set(self.bcc_recipients or [])
    +    bcc_recipients.discard(me)
         if self.author:
    -        to_recipients.append(self.author)
    +        to_recipients.add(self.author)
         return ReplyAllToItem(
             account=self.account,
             reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey),
             subject=subject,
             new_body=body,
    -        to_recipients=to_recipients,
    -        cc_recipients=self.cc_recipients,
    -        bcc_recipients=self.bcc_recipients,
    +        to_recipients=list(to_recipients),
    +        cc_recipients=list(cc_recipients),
    +        bcc_recipients=list(bcc_recipients),
             author=author,
         )
    diff --git a/docs/exchangelib/items/message.html b/docs/exchangelib/items/message.html index f66b76c7..ec01054c 100644 --- a/docs/exchangelib/items/message.html +++ b/docs/exchangelib/items/message.html @@ -149,7 +149,7 @@

    Module exchangelib.items.message

    @require_id def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None): - if to_recipients is None: + if not to_recipients: if not self.author: raise ValueError("'to_recipients' must be set when message has no 'author'") to_recipients = [self.author] @@ -169,17 +169,23 @@

    Module exchangelib.items.message

    @require_id def create_reply_all(self, subject, body, author=None): - to_recipients = list(self.to_recipients) if self.to_recipients else [] + me = MailboxField().clean(self.account.primary_smtp_address.lower()) + to_recipients = set(self.to_recipients or []) + to_recipients.discard(me) + cc_recipients = set(self.cc_recipients or []) + cc_recipients.discard(me) + bcc_recipients = set(self.bcc_recipients or []) + bcc_recipients.discard(me) if self.author: - to_recipients.append(self.author) + to_recipients.add(self.author) return ReplyAllToItem( account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), subject=subject, new_body=body, - to_recipients=to_recipients, - cc_recipients=self.cc_recipients, - bcc_recipients=self.bcc_recipients, + to_recipients=list(to_recipients), + cc_recipients=list(cc_recipients), + bcc_recipients=list(bcc_recipients), author=author, ) @@ -400,7 +406,7 @@

    Inherited members

    @require_id def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None): - if to_recipients is None: + if not to_recipients: if not self.author: raise ValueError("'to_recipients' must be set when message has no 'author'") to_recipients = [self.author] @@ -420,17 +426,23 @@

    Inherited members

    @require_id def create_reply_all(self, subject, body, author=None): - to_recipients = list(self.to_recipients) if self.to_recipients else [] + me = MailboxField().clean(self.account.primary_smtp_address.lower()) + to_recipients = set(self.to_recipients or []) + to_recipients.discard(me) + cc_recipients = set(self.cc_recipients or []) + cc_recipients.discard(me) + bcc_recipients = set(self.bcc_recipients or []) + bcc_recipients.discard(me) if self.author: - to_recipients.append(self.author) + to_recipients.add(self.author) return ReplyAllToItem( account=self.account, reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey), subject=subject, new_body=body, - to_recipients=to_recipients, - cc_recipients=self.cc_recipients, - bcc_recipients=self.bcc_recipients, + to_recipients=list(to_recipients), + cc_recipients=list(cc_recipients), + bcc_recipients=list(bcc_recipients), author=author, ) @@ -558,7 +570,7 @@

    Methods

    @require_id
     def create_reply(self, subject, body, to_recipients=None, cc_recipients=None, bcc_recipients=None, author=None):
    -    if to_recipients is None:
    +    if not to_recipients:
             if not self.author:
                 raise ValueError("'to_recipients' must be set when message has no 'author'")
             to_recipients = [self.author]
    @@ -585,17 +597,23 @@ 

    Methods

    @require_id
     def create_reply_all(self, subject, body, author=None):
    -    to_recipients = list(self.to_recipients) if self.to_recipients else []
    +    me = MailboxField().clean(self.account.primary_smtp_address.lower())
    +    to_recipients = set(self.to_recipients or [])
    +    to_recipients.discard(me)
    +    cc_recipients = set(self.cc_recipients or [])
    +    cc_recipients.discard(me)
    +    bcc_recipients = set(self.bcc_recipients or [])
    +    bcc_recipients.discard(me)
         if self.author:
    -        to_recipients.append(self.author)
    +        to_recipients.add(self.author)
         return ReplyAllToItem(
             account=self.account,
             reference_item_id=ReferenceItemId(id=self.id, changekey=self.changekey),
             subject=subject,
             new_body=body,
    -        to_recipients=to_recipients,
    -        cc_recipients=self.cc_recipients,
    -        bcc_recipients=self.bcc_recipients,
    +        to_recipients=list(to_recipients),
    +        cc_recipients=list(cc_recipients),
    +        bcc_recipients=list(bcc_recipients),
             author=author,
         )
    diff --git a/docs/exchangelib/properties.html b/docs/exchangelib/properties.html index 996a31c8..84865a2d 100644 --- a/docs/exchangelib/properties.html +++ b/docs/exchangelib/properties.html @@ -64,10 +64,12 @@

    Module exchangelib.properties

    ExtendedPropertyField, Field, FieldPath, + FlaggedForActionField, FreeBusyStatusField, GenericEventListField, IdElementField, IdField, + ImportanceField, IntegerField, InvalidField, InvalidFieldForVersion, @@ -76,6 +78,7 @@

    Module exchangelib.properties

    RecipientAddressField, ReferenceItemIdField, RoutingTypeField, + SensitivityField, SubField, TextField, TimeDeltaField, @@ -824,6 +827,8 @@

    Module exchangelib.properties

    field_uri="ResponseType", choices={Choice(c) for c in RESPONSE_TYPES}, default="Unknown" ) last_response_time = DateTimeField(field_uri="LastResponseTime") + proposed_start = DateTimeField(field_uri="ProposedStart") + proposed_end = DateTimeField(field_uri="ProposedEnd") def __hash__(self): return hash(self.mailbox) @@ -2165,7 +2170,183 @@

    Module exchangelib.properties

    redirect_url=redirect_url, user_settings_errors=user_settings_errors, user_settings=user_settings, - )
    + ) + + +class WithinDateRange(EWSElement): + """MSDN: + https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/withindaterange + """ + + ELEMENT_NAME = "DateRange" + NAMESPACE = MNS + + start_date_time = DateTimeField(field_uri="StartDateTime", is_required=True) + end_date_time = DateTimeField(field_uri="EndDateTime", is_required=True) + + +class WithinSizeRange(EWSElement): + """MSDN: + https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/withinsizerange + """ + + ELEMENT_NAME = "SizeRange" + NAMESPACE = MNS + + minimum_size = IntegerField(field_uri="MinimumSize", is_required=True) + maximum_size = IntegerField(field_uri="MaximumSize", is_required=True) + + +class Conditions(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/conditions""" + + ELEMENT_NAME = "Conditions" + NAMESPACE = TNS + + categories = CharListField(field_uri="Categories") + contains_body_strings = CharListField(field_uri="ContainsBodyStrings") + contains_header_strings = CharListField(field_uri="ContainsHeaderStrings") + contains_recipient_strings = CharListField(field_uri="ContainsRecipientStrings") + contains_sender_strings = CharListField(field_uri="ContainsSenderStrings") + contains_subject_or_body_strings = CharListField(field_uri="ContainsSubjectOrBodyStrings") + contains_subject_strings = CharListField(field_uri="ContainsSubjectStrings") + flagged_for_action = FlaggedForActionField(field_uri="FlaggedForAction") + from_addresses = EWSElementField(value_cls=Mailbox, field_uri="FromAddresses") + from_connected_accounts = CharListField(field_uri="FromConnectedAccounts") + has_attachments = BooleanField(field_uri="HasAttachments") + importance = ImportanceField(field_uri="Importance") + is_approval_request = BooleanField(field_uri="IsApprovalRequest") + is_automatic_forward = BooleanField(field_uri="IsAutomaticForward") + is_automatic_reply = BooleanField(field_uri="IsAutomaticReply") + is_encrypted = BooleanField(field_uri="IsEncrypted") + is_meeting_request = BooleanField(field_uri="IsMeetingRequest") + is_meeting_response = BooleanField(field_uri="IsMeetingResponse") + is_ndr = BooleanField(field_uri="IsNDR") + is_permission_controlled = BooleanField(field_uri="IsPermissionControlled") + is_read_receipt = BooleanField(field_uri="IsReadReceipt") + is_signed = BooleanField(field_uri="IsSigned") + is_voicemail = BooleanField(field_uri="IsVoicemail") + item_classes = CharListField(field_uri="ItemClasses") + message_classifications = CharListField(field_uri="MessageClassifications") + not_sent_to_me = BooleanField(field_uri="NotSentToMe") + sent_cc_me = BooleanField(field_uri="SentCcMe") + sent_only_to_me = BooleanField(field_uri="SentOnlyToMe") + sent_to_addresses = EWSElementField(value_cls=Mailbox, field_uri="SentToAddresses") + sent_to_me = BooleanField(field_uri="SentToMe") + sent_to_or_cc_me = BooleanField(field_uri="SentToOrCcMe") + sensitivity = SensitivityField(field_uri="Sensitivity") + within_date_range = EWSElementField(value_cls=WithinDateRange, field_uri="WithinDateRange") + within_size_range = EWSElementField(value_cls=WithinSizeRange, field_uri="WithinSizeRange") + + +class Exceptions(Conditions): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exceptions""" + + ELEMENT_NAME = "Exceptions" + NAMESPACE = TNS + + +class CopyToFolder(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/copytofolder""" + + ELEMENT_NAME = "CopyToFolder" + NAMESPACE = MNS + + folder_id = EWSElementField(value_cls=FolderId, field_uri="FolderId") + distinguished_folder_id = EWSElementField(value_cls=DistinguishedFolderId, field_uri="DistinguishedFolderId") + + +class MoveToFolder(CopyToFolder): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/movetofolder""" + + ELEMENT_NAME = "MoveToFolder" + + +class Actions(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/actions""" + + ELEMENT_NAME = "Actions" + NAMESPACE = TNS + + assign_categories = CharListField(field_uri="AssignCategories") + copy_to_folder = EWSElementField(value_cls=CopyToFolder, field_uri="CopyToFolder") + delete = BooleanField(field_uri="Delete") + forward_as_attachment_to_recipients = EWSElementField( + value_cls=Mailbox, field_uri="ForwardAsAttachmentToRecipients" + ) + forward_to_recipients = EWSElementField(value_cls=Mailbox, field_uri="ForwardToRecipients") + mark_importance = ImportanceField(field_uri="MarkImportance") + mark_as_read = BooleanField(field_uri="MarkAsRead") + move_to_folder = EWSElementField(value_cls=MoveToFolder, field_uri="MoveToFolder") + permanent_delete = BooleanField(field_uri="PermanentDelete") + redirect_to_recipients = EWSElementField(value_cls=Mailbox, field_uri="RedirectToRecipients") + send_sms_alert_to_recipients = EWSElementField(value_cls=Mailbox, field_uri="SendSMSAlertToRecipients") + server_reply_with_message = EWSElementField(value_cls=ItemId, field_uri="ServerReplyWithMessage") + stop_processing_rules = BooleanField(field_uri="StopProcessingRules") + + +class Rule(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/rule-ruletype""" + + ELEMENT_NAME = "Rule" + NAMESPACE = TNS + + id = CharField(field_uri="RuleId") + display_name = CharField(field_uri="DisplayName") + priority = IntegerField(field_uri="Priority") + is_enabled = BooleanField(field_uri="IsEnabled") + is_not_supported = BooleanField(field_uri="IsNotSupported") + is_in_error = BooleanField(field_uri="IsInError") + conditions = EWSElementField(value_cls=Conditions) + exceptions = EWSElementField(value_cls=Exceptions) + actions = EWSElementField(value_cls=Actions) + + +class InboxRules(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/inboxrules""" + + ELEMENT_NAME = "InboxRules" + NAMESPACE = MNS + + rule = EWSElementListField(value_cls=Rule) + + +class CreateRuleOperation(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createruleoperation""" + + ELEMENT_NAME = "CreateRuleOperation" + NAMESPACE = TNS + + rule = EWSElementField(value_cls=Rule) + + +class SetRuleOperation(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/setruleoperation""" + + ELEMENT_NAME = "SetRuleOperation" + NAMESPACE = TNS + + rule = EWSElementField(value_cls=Rule) + + +class DeleteRuleOperation(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteruleoperation""" + + ELEMENT_NAME = "DeleteRuleOperation" + NAMESPACE = TNS + + id = CharField(field_uri="RuleId") + + +class Operations(EWSElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/operations""" + + ELEMENT_NAME = "Operations" + NAMESPACE = MNS + + create_rule_operation = EWSElementField(value_cls=CreateRuleOperation) + set_rule_operation = EWSElementField(value_cls=SetRuleOperation) + delete_rule_operation = EWSElementField(value_cls=DeleteRuleOperation)
    @@ -2280,6 +2461,124 @@

    Inherited members

    +
    +class Actions +(**kwargs) +
    +
    + +
    + +Expand source code + +
    class Actions(EWSElement):
    +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/actions"""
    +
    +    ELEMENT_NAME = "Actions"
    +    NAMESPACE = TNS
    +
    +    assign_categories = CharListField(field_uri="AssignCategories")
    +    copy_to_folder = EWSElementField(value_cls=CopyToFolder, field_uri="CopyToFolder")
    +    delete = BooleanField(field_uri="Delete")
    +    forward_as_attachment_to_recipients = EWSElementField(
    +        value_cls=Mailbox, field_uri="ForwardAsAttachmentToRecipients"
    +    )
    +    forward_to_recipients = EWSElementField(value_cls=Mailbox, field_uri="ForwardToRecipients")
    +    mark_importance = ImportanceField(field_uri="MarkImportance")
    +    mark_as_read = BooleanField(field_uri="MarkAsRead")
    +    move_to_folder = EWSElementField(value_cls=MoveToFolder, field_uri="MoveToFolder")
    +    permanent_delete = BooleanField(field_uri="PermanentDelete")
    +    redirect_to_recipients = EWSElementField(value_cls=Mailbox, field_uri="RedirectToRecipients")
    +    send_sms_alert_to_recipients = EWSElementField(value_cls=Mailbox, field_uri="SendSMSAlertToRecipients")
    +    server_reply_with_message = EWSElementField(value_cls=ItemId, field_uri="ServerReplyWithMessage")
    +    stop_processing_rules = BooleanField(field_uri="StopProcessingRules")
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var ELEMENT_NAME
    +
    +
    +
    +
    var FIELDS
    +
    +
    +
    +
    var NAMESPACE
    +
    +
    +
    +
    +

    Instance variables

    +
    +
    var assign_categories
    +
    +
    +
    +
    var copy_to_folder
    +
    +
    +
    +
    var delete
    +
    +
    +
    +
    var forward_as_attachment_to_recipients
    +
    +
    +
    +
    var forward_to_recipients
    +
    +
    +
    +
    var mark_as_read
    +
    +
    +
    +
    var mark_importance
    +
    +
    +
    +
    var move_to_folder
    +
    +
    +
    +
    var permanent_delete
    +
    +
    +
    +
    var redirect_to_recipients
    +
    +
    +
    +
    var send_sms_alert_to_recipients
    +
    +
    +
    +
    var server_reply_with_message
    +
    +
    +
    +
    var stop_processing_rules
    +
    +
    +
    +
    +

    Inherited members

    + +
    class Address (**kwargs) @@ -2602,6 +2901,8 @@

    Inherited members

    field_uri="ResponseType", choices={Choice(c) for c in RESPONSE_TYPES}, default="Unknown" ) last_response_time = DateTimeField(field_uri="LastResponseTime") + proposed_start = DateTimeField(field_uri="ProposedStart") + proposed_end = DateTimeField(field_uri="ProposedEnd") def __hash__(self): return hash(self.mailbox)
    @@ -2635,6 +2936,14 @@

    Instance variables

    +
    var proposed_end
    +
    +
    +
    +
    var proposed_start
    +
    +
    +
    var response_type
    @@ -3691,6 +4000,231 @@

    Inherited members

    +
    +class Conditions +(**kwargs) +
    +
    + +
    + +Expand source code + +
    class Conditions(EWSElement):
    +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/conditions"""
    +
    +    ELEMENT_NAME = "Conditions"
    +    NAMESPACE = TNS
    +
    +    categories = CharListField(field_uri="Categories")
    +    contains_body_strings = CharListField(field_uri="ContainsBodyStrings")
    +    contains_header_strings = CharListField(field_uri="ContainsHeaderStrings")
    +    contains_recipient_strings = CharListField(field_uri="ContainsRecipientStrings")
    +    contains_sender_strings = CharListField(field_uri="ContainsSenderStrings")
    +    contains_subject_or_body_strings = CharListField(field_uri="ContainsSubjectOrBodyStrings")
    +    contains_subject_strings = CharListField(field_uri="ContainsSubjectStrings")
    +    flagged_for_action = FlaggedForActionField(field_uri="FlaggedForAction")
    +    from_addresses = EWSElementField(value_cls=Mailbox, field_uri="FromAddresses")
    +    from_connected_accounts = CharListField(field_uri="FromConnectedAccounts")
    +    has_attachments = BooleanField(field_uri="HasAttachments")
    +    importance = ImportanceField(field_uri="Importance")
    +    is_approval_request = BooleanField(field_uri="IsApprovalRequest")
    +    is_automatic_forward = BooleanField(field_uri="IsAutomaticForward")
    +    is_automatic_reply = BooleanField(field_uri="IsAutomaticReply")
    +    is_encrypted = BooleanField(field_uri="IsEncrypted")
    +    is_meeting_request = BooleanField(field_uri="IsMeetingRequest")
    +    is_meeting_response = BooleanField(field_uri="IsMeetingResponse")
    +    is_ndr = BooleanField(field_uri="IsNDR")
    +    is_permission_controlled = BooleanField(field_uri="IsPermissionControlled")
    +    is_read_receipt = BooleanField(field_uri="IsReadReceipt")
    +    is_signed = BooleanField(field_uri="IsSigned")
    +    is_voicemail = BooleanField(field_uri="IsVoicemail")
    +    item_classes = CharListField(field_uri="ItemClasses")
    +    message_classifications = CharListField(field_uri="MessageClassifications")
    +    not_sent_to_me = BooleanField(field_uri="NotSentToMe")
    +    sent_cc_me = BooleanField(field_uri="SentCcMe")
    +    sent_only_to_me = BooleanField(field_uri="SentOnlyToMe")
    +    sent_to_addresses = EWSElementField(value_cls=Mailbox, field_uri="SentToAddresses")
    +    sent_to_me = BooleanField(field_uri="SentToMe")
    +    sent_to_or_cc_me = BooleanField(field_uri="SentToOrCcMe")
    +    sensitivity = SensitivityField(field_uri="Sensitivity")
    +    within_date_range = EWSElementField(value_cls=WithinDateRange, field_uri="WithinDateRange")
    +    within_size_range = EWSElementField(value_cls=WithinSizeRange, field_uri="WithinSizeRange")
    +
    +

    Ancestors

    + +

    Subclasses

    + +

    Class variables

    +
    +
    var ELEMENT_NAME
    +
    +
    +
    +
    var FIELDS
    +
    +
    +
    +
    var NAMESPACE
    +
    +
    +
    +
    +

    Instance variables

    +
    +
    var categories
    +
    +
    +
    +
    var contains_body_strings
    +
    +
    +
    +
    var contains_header_strings
    +
    +
    +
    +
    var contains_recipient_strings
    +
    +
    +
    +
    var contains_sender_strings
    +
    +
    +
    +
    var contains_subject_or_body_strings
    +
    +
    +
    +
    var contains_subject_strings
    +
    +
    +
    +
    var flagged_for_action
    +
    +
    +
    +
    var from_addresses
    +
    +
    +
    +
    var from_connected_accounts
    +
    +
    +
    +
    var has_attachments
    +
    +
    +
    +
    var importance
    +
    +
    +
    +
    var is_approval_request
    +
    +
    +
    +
    var is_automatic_forward
    +
    +
    +
    +
    var is_automatic_reply
    +
    +
    +
    +
    var is_encrypted
    +
    +
    +
    +
    var is_meeting_request
    +
    +
    +
    +
    var is_meeting_response
    +
    +
    +
    +
    var is_ndr
    +
    +
    +
    +
    var is_permission_controlled
    +
    +
    +
    +
    var is_read_receipt
    +
    +
    +
    +
    var is_signed
    +
    +
    +
    +
    var is_voicemail
    +
    +
    +
    +
    var item_classes
    +
    +
    +
    +
    var message_classifications
    +
    +
    +
    +
    var not_sent_to_me
    +
    +
    +
    +
    var sensitivity
    +
    +
    +
    +
    var sent_cc_me
    +
    +
    +
    +
    var sent_only_to_me
    +
    +
    +
    +
    var sent_to_addresses
    +
    +
    +
    +
    var sent_to_me
    +
    +
    +
    +
    var sent_to_or_cc_me
    +
    +
    +
    +
    var within_date_range
    +
    +
    +
    +
    var within_size_range
    +
    +
    +
    +
    +

    Inherited members

    + +
    class ConversationId (*args, **kwargs) @@ -3774,6 +4308,127 @@

    Inherited members

    +
    +class CopyToFolder +(**kwargs) +
    +
    + +
    + +Expand source code + +
    class CopyToFolder(EWSElement):
    +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/copytofolder"""
    +
    +    ELEMENT_NAME = "CopyToFolder"
    +    NAMESPACE = MNS
    +
    +    folder_id = EWSElementField(value_cls=FolderId, field_uri="FolderId")
    +    distinguished_folder_id = EWSElementField(value_cls=DistinguishedFolderId, field_uri="DistinguishedFolderId")
    +
    +

    Ancestors

    + +

    Subclasses

    + +

    Class variables

    +
    +
    var ELEMENT_NAME
    +
    +
    +
    +
    var FIELDS
    +
    +
    +
    +
    var NAMESPACE
    +
    +
    +
    +
    +

    Instance variables

    +
    +
    var distinguished_folder_id
    +
    +
    +
    +
    var folder_id
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class CreateRuleOperation +(**kwargs) +
    +
    + +
    + +Expand source code + +
    class CreateRuleOperation(EWSElement):
    +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createruleoperation"""
    +
    +    ELEMENT_NAME = "CreateRuleOperation"
    +    NAMESPACE = TNS
    +
    +    rule = EWSElementField(value_cls=Rule)
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var ELEMENT_NAME
    +
    +
    +
    +
    var FIELDS
    +
    +
    +
    +
    var NAMESPACE
    +
    +
    +
    +
    +

    Instance variables

    +
    +
    var rule
    +
    +
    +
    +
    +

    Inherited members

    + +
    class CreatedEvent (**kwargs) @@ -3966,15 +4621,86 @@

    Instance variables

    -
    var journal_folder_permission_level
    +
    var journal_folder_permission_level
    +
    +
    +
    +
    var notes_folder_permission_level
    +
    +
    +
    +
    var tasks_folder_permission_level
    +
    +
    +
    +

    +

    Inherited members

    + + +
    +class DelegateUser +(**kwargs) +
    +
    + +
    + +Expand source code + +
    class DelegateUser(EWSElement):
    +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/delegateuser"""
    +
    +    ELEMENT_NAME = "DelegateUser"
    +    NAMESPACE = MNS
    +
    +    user_id = EWSElementField(value_cls=UserId)
    +    delegate_permissions = EWSElementField(value_cls=DelegatePermissions)
    +    receive_copies_of_meeting_messages = BooleanField(field_uri="ReceiveCopiesOfMeetingMessages", default=False)
    +    view_private_items = BooleanField(field_uri="ViewPrivateItems", default=False)
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var ELEMENT_NAME
    +
    +
    +
    +
    var FIELDS
    +
    +
    +
    +
    var NAMESPACE
    +
    +
    +
    +
    +

    Instance variables

    +
    +
    var delegate_permissions
    +
    +
    +
    +
    var receive_copies_of_meeting_messages
    -
    var notes_folder_permission_level
    +
    var user_id
    -
    var tasks_folder_permission_level
    +
    var view_private_items
    @@ -3991,26 +4717,23 @@

    Inherited members

    -
    -class DelegateUser +
    +class DeleteRuleOperation (**kwargs)
    - +
    Expand source code -
    class DelegateUser(EWSElement):
    -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/delegateuser"""
    +
    class DeleteRuleOperation(EWSElement):
    +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteruleoperation"""
     
    -    ELEMENT_NAME = "DelegateUser"
    -    NAMESPACE = MNS
    +    ELEMENT_NAME = "DeleteRuleOperation"
    +    NAMESPACE = TNS
     
    -    user_id = EWSElementField(value_cls=UserId)
    -    delegate_permissions = EWSElementField(value_cls=DelegatePermissions)
    -    receive_copies_of_meeting_messages = BooleanField(field_uri="ReceiveCopiesOfMeetingMessages", default=False)
    -    view_private_items = BooleanField(field_uri="ViewPrivateItems", default=False)
    + id = CharField(field_uri="RuleId")

    Ancestors

      @@ -4018,34 +4741,22 @@

      Ancestors

    Class variables

    -
    var ELEMENT_NAME
    +
    var ELEMENT_NAME
    -
    var FIELDS
    +
    var FIELDS
    -
    var NAMESPACE
    +
    var NAMESPACE

    Instance variables

    -
    var delegate_permissions
    -
    -
    -
    -
    var receive_copies_of_meeting_messages
    -
    -
    -
    -
    var user_id
    -
    -
    -
    -
    var view_private_items
    +
    var id
    @@ -4451,6 +5162,7 @@

    Subclasses

  • IndexedElement
  • BaseReplyItem
  • AcceptSharingInvitation
  • +
  • Actions
  • AlternateId
  • AlternatePublicFolderId
  • AlternatePublicFolderItemId
  • @@ -4465,8 +5177,12 @@

    Subclasses

  • CalendarEventDetails
  • CalendarView
  • CompleteName
  • +
  • Conditions
  • +
  • CopyToFolder
  • +
  • CreateRuleOperation
  • DelegatePermissions
  • DelegateUser
  • +
  • DeleteRuleOperation
  • DictionaryEntry
  • EffectiveRights
  • EmailAddressAttributedValue
  • @@ -4478,6 +5194,7 @@

    Subclasses

  • FreeBusyView
  • FreeBusyViewOptions
  • IdChangeKeyMixIn
  • +
  • InboxRules
  • IndexedFieldURI
  • MailTips
  • Mailbox
  • @@ -4485,6 +5202,7 @@

    Subclasses

  • Member
  • MessageHeader
  • Notification
  • +
  • Operations
  • OutOfOffice
  • Period
  • PermissionSet
  • @@ -4495,7 +5213,9 @@

    Subclasses

  • ReminderMessageData
  • RemoveItem
  • ResponseObjects
  • +
  • Rule
  • SearchableMailbox
  • +
  • SetRuleOperation
  • StringAttributedValue
  • SuppressReadReceipt
  • TimeWindow
  • @@ -4506,6 +5226,8 @@

    Subclasses

  • UserConfigurationName
  • UserId
  • UserResponse
  • +
  • WithinDateRange
  • +
  • WithinSizeRange
  • WorkingPeriod
  • Boundary
  • DeletedOccurrence
  • @@ -5236,6 +5958,50 @@

    Inherited members

    +
    +class Exceptions +(**kwargs) +
    +
    + +
    + +Expand source code + +
    class Exceptions(Conditions):
    +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/exceptions"""
    +
    +    ELEMENT_NAME = "Exceptions"
    +    NAMESPACE = TNS
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var ELEMENT_NAME
    +
    +
    +
    +
    var NAMESPACE
    +
    +
    +
    +
    +

    Inherited members

    + +
    class ExtendedFieldURI (**kwargs) @@ -6100,6 +6866,62 @@

    Inherited members

    +
    +class InboxRules +(**kwargs) +
    +
    + +
    + +Expand source code + +
    class InboxRules(EWSElement):
    +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/inboxrules"""
    +
    +    ELEMENT_NAME = "InboxRules"
    +    NAMESPACE = MNS
    +
    +    rule = EWSElementListField(value_cls=Rule)
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var ELEMENT_NAME
    +
    +
    +
    +
    var FIELDS
    +
    +
    +
    +
    var NAMESPACE
    +
    +
    +
    +
    +

    Instance variables

    +
    +
    var rule
    +
    +
    +
    +
    +

    Inherited members

    + +
    class IndexedFieldURI (**kwargs) @@ -6733,6 +7555,45 @@

    Inherited members

    +
    +class MoveToFolder +(**kwargs) +
    +
    + +
    + +Expand source code + +
    class MoveToFolder(CopyToFolder):
    +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/movetofolder"""
    +
    +    ELEMENT_NAME = "MoveToFolder"
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var ELEMENT_NAME
    +
    +
    +
    +
    +

    Inherited members

    + +
    class MovedEvent (**kwargs) @@ -7212,6 +8073,72 @@

    Inherited members

    +
    +class Operations +(**kwargs) +
    +
    + +
    + +Expand source code + +
    class Operations(EWSElement):
    +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/operations"""
    +
    +    ELEMENT_NAME = "Operations"
    +    NAMESPACE = MNS
    +
    +    create_rule_operation = EWSElementField(value_cls=CreateRuleOperation)
    +    set_rule_operation = EWSElementField(value_cls=SetRuleOperation)
    +    delete_rule_operation = EWSElementField(value_cls=DeleteRuleOperation)
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var ELEMENT_NAME
    +
    +
    +
    +
    var FIELDS
    +
    +
    +
    +
    var NAMESPACE
    +
    +
    +
    +
    +

    Instance variables

    +
    +
    var create_rule_operation
    +
    +
    +
    +
    var delete_rule_operation
    +
    +
    +
    +
    var set_rule_operation
    +
    +
    +
    +
    +

    Inherited members

    + +
    class OutOfOffice (**kwargs) @@ -8752,74 +9679,170 @@

    Inherited members

    -
    -class RootItemId -(*args, **kwargs) +
    +class RootItemId +(*args, **kwargs) +
    +
    + +
    + +Expand source code + +
    class RootItemId(BaseItemId):
    +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/rootitemid"""
    +
    +    ELEMENT_NAME = "RootItemId"
    +    NAMESPACE = MNS
    +    ID_ATTR = "RootItemId"
    +    CHANGEKEY_ATTR = "RootItemChangeKey"
    +
    +    id = IdField(field_uri=ID_ATTR, is_required=True)
    +    changekey = IdField(field_uri=CHANGEKEY_ATTR, is_required=True)
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var CHANGEKEY_ATTR
    +
    +
    +
    +
    var ELEMENT_NAME
    +
    +
    +
    +
    var FIELDS
    +
    +
    +
    +
    var ID_ATTR
    +
    +
    +
    +
    var NAMESPACE
    +
    +
    +
    +
    +

    Instance variables

    +
    +
    var changekey
    +
    +
    +
    +
    var id
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class Rule +(**kwargs)
    - +
    Expand source code -
    class RootItemId(BaseItemId):
    -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/rootitemid"""
    +
    class Rule(EWSElement):
    +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/rule-ruletype"""
     
    -    ELEMENT_NAME = "RootItemId"
    -    NAMESPACE = MNS
    -    ID_ATTR = "RootItemId"
    -    CHANGEKEY_ATTR = "RootItemChangeKey"
    +    ELEMENT_NAME = "Rule"
    +    NAMESPACE = TNS
     
    -    id = IdField(field_uri=ID_ATTR, is_required=True)
    -    changekey = IdField(field_uri=CHANGEKEY_ATTR, is_required=True)
    + id = CharField(field_uri="RuleId") + display_name = CharField(field_uri="DisplayName") + priority = IntegerField(field_uri="Priority") + is_enabled = BooleanField(field_uri="IsEnabled") + is_not_supported = BooleanField(field_uri="IsNotSupported") + is_in_error = BooleanField(field_uri="IsInError") + conditions = EWSElementField(value_cls=Conditions) + exceptions = EWSElementField(value_cls=Exceptions) + actions = EWSElementField(value_cls=Actions)

    Ancestors

    Class variables

    -
    var CHANGEKEY_ATTR
    +
    var ELEMENT_NAME
    -
    var ELEMENT_NAME
    +
    var FIELDS
    -
    var FIELDS
    +
    var NAMESPACE
    -
    var ID_ATTR
    +
    +

    Instance variables

    +
    +
    var actions
    -
    var NAMESPACE
    +
    var conditions
    -
    -

    Instance variables

    -
    -
    var changekey
    +
    var display_name
    -
    var id
    +
    var exceptions
    +
    +
    +
    +
    var id
    +
    +
    +
    +
    var is_enabled
    +
    +
    +
    +
    var is_in_error
    +
    +
    +
    +
    var is_not_supported
    +
    +
    +
    +
    var priority

    Inherited members

    @@ -8953,6 +9976,62 @@

    Inherited members

    +
    +class SetRuleOperation +(**kwargs) +
    +
    + +
    + +Expand source code + +
    class SetRuleOperation(EWSElement):
    +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/setruleoperation"""
    +
    +    ELEMENT_NAME = "SetRuleOperation"
    +    NAMESPACE = TNS
    +
    +    rule = EWSElementField(value_cls=Rule)
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var ELEMENT_NAME
    +
    +
    +
    +
    var FIELDS
    +
    +
    +
    +
    var NAMESPACE
    +
    +
    +
    +
    +

    Instance variables

    +
    +
    var rule
    +
    +
    +
    +
    +

    Inherited members

    + +
    class SourceId (*args, **kwargs) @@ -10765,6 +11844,134 @@

    Inherited members

    +
    +class WithinDateRange +(**kwargs) +
    +
    + +
    + +Expand source code + +
    class WithinDateRange(EWSElement):
    +    """MSDN:
    +    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/withindaterange
    +    """
    +
    +    ELEMENT_NAME = "DateRange"
    +    NAMESPACE = MNS
    +
    +    start_date_time = DateTimeField(field_uri="StartDateTime", is_required=True)
    +    end_date_time = DateTimeField(field_uri="EndDateTime", is_required=True)
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var ELEMENT_NAME
    +
    +
    +
    +
    var FIELDS
    +
    +
    +
    +
    var NAMESPACE
    +
    +
    +
    +
    +

    Instance variables

    +
    +
    var end_date_time
    +
    +
    +
    +
    var start_date_time
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class WithinSizeRange +(**kwargs) +
    +
    + +
    + +Expand source code + +
    class WithinSizeRange(EWSElement):
    +    """MSDN:
    +    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/withinsizerange
    +    """
    +
    +    ELEMENT_NAME = "SizeRange"
    +    NAMESPACE = MNS
    +
    +    minimum_size = IntegerField(field_uri="MinimumSize", is_required=True)
    +    maximum_size = IntegerField(field_uri="MaximumSize", is_required=True)
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var ELEMENT_NAME
    +
    +
    +
    +
    var FIELDS
    +
    +
    +
    +
    var NAMESPACE
    +
    +
    +
    +
    +

    Instance variables

    +
    +
    var maximum_size
    +
    +
    +
    +
    var minimum_size
    +
    +
    +
    +
    +

    Inherited members

    + +
    class WorkingPeriod (**kwargs) @@ -10859,6 +12066,27 @@

    Actions

    + + +
  • Address

  • @@ -11060,6 +12290,48 @@

    Conditions

    + + +
  • ConversationId

    • ELEMENT_NAME
    • @@ -11072,6 +12344,25 @@

      CopyToFolder

      + + +
    • +

      CreateRuleOperation

      + +
    • +
    • CreatedEvent

      • ELEMENT_NAME
      • @@ -11116,6 +12407,15 @@

        DeleteRuleOperation

        + + +
      • DeletedEvent

        • ELEMENT_NAME
        • @@ -11220,6 +12520,13 @@

          Exceptions

          + + +
        • ExtendedFieldURI

          • ELEMENT_NAME
          • @@ -11316,6 +12623,15 @@

            InboxRules

            + + +
          • IndexedFieldURI

            • ELEMENT_NAME
            • @@ -11408,6 +12724,12 @@

              MoveToFolder

              + + +
            • MovedEvent

              • ELEMENT_NAME
              • @@ -11479,6 +12801,17 @@

                Operations

                + + +
              • OutOfOffice

                • ELEMENT_NAME
                • @@ -11710,6 +13043,23 @@

                  Rule

                  + + +
                • SearchableMailbox

                  • ELEMENT_NAME
                  • @@ -11731,6 +13081,15 @@

                    SetRuleOperation

                    + + +
                  • SourceId

                    • ELEMENT_NAME
                    • @@ -11913,6 +13272,26 @@

                      WithinDateRange

                      + + +
                    • +

                      WithinSizeRange

                      + +
                    • +
                    • WorkingPeriod

                      • ELEMENT_NAME
                      • diff --git a/docs/exchangelib/protocol.html b/docs/exchangelib/protocol.html index 989eb8aa..ed6062db 100644 --- a/docs/exchangelib/protocol.html +++ b/docs/exchangelib/protocol.html @@ -35,6 +35,7 @@

                        Module exchangelib.protocol

                        Protocols should be accessed through an Account, and are either created from a default Configuration or autodiscovered when creating an Account. """ + import abc import datetime import logging @@ -161,6 +162,15 @@

                        Module exchangelib.protocol

                        self.config._credentials = value self.close() + @property + def max_connections(self): + return self._session_pool_maxsize + + @max_connections.setter + def max_connections(self, value): + with self._session_pool_lock: + self._session_pool_maxsize = value or self.SESSION_POOLSIZE + @property def retry_policy(self): return self.config.retry_policy @@ -937,6 +947,15 @@

                        Classes

                        self.config._credentials = value self.close() + @property + def max_connections(self): + return self._session_pool_maxsize + + @max_connections.setter + def max_connections(self, value): + with self._session_pool_lock: + self._session_pool_maxsize = value or self.SESSION_POOLSIZE + @property def retry_policy(self): return self.config.retry_policy @@ -1303,6 +1322,18 @@

                        Instance variables

                        return self.config.credentials +
                        var max_connections
                        +
                        +
                        +
                        + +Expand source code + +
                        @property
                        +def max_connections(self):
                        +    return self._session_pool_maxsize
                        +
                        +
                        var retry_policy
                        @@ -2794,6 +2825,7 @@

                        get_auth_type
                      • get_session
                      • increase_poolsize
                      • +
                      • max_connections
                      • raw_session
                      • refresh_credentials
                      • release_session
                      • diff --git a/docs/exchangelib/services/common.html b/docs/exchangelib/services/common.html index 49e84ba3..1de32399 100644 --- a/docs/exchangelib/services/common.html +++ b/docs/exchangelib/services/common.html @@ -1255,6 +1255,8 @@

                        Subclasses

                      • GetStreamingEvents
                      • GetUserConfiguration
                      • GetUserOofSettings
                      • +
                      • GetInboxRules
                      • +
                      • UpdateInboxRules
                      • MarkAsJunk
                      • MoveFolder
                      • MoveItem
                      • diff --git a/docs/exchangelib/services/inbox_rules.html b/docs/exchangelib/services/inbox_rules.html new file mode 100644 index 00000000..60122aaf --- /dev/null +++ b/docs/exchangelib/services/inbox_rules.html @@ -0,0 +1,681 @@ + + + + + + +exchangelib.services.inbox_rules API documentation + + + + + + + + + + + +
                        +
                        +
                        +

                        Module exchangelib.services.inbox_rules

                        +
                        +
                        +
                        + +Expand source code + +
                        from typing import Any, Generator, Optional, Union
                        +
                        +from ..errors import ErrorInvalidOperation
                        +from ..properties import CreateRuleOperation, DeleteRuleOperation, InboxRules, Operations, Rule, SetRuleOperation
                        +from ..util import MNS, add_xml_child, create_element, get_xml_attr, set_xml_value
                        +from ..version import EXCHANGE_2010
                        +from .common import EWSAccountService
                        +
                        +
                        +class GetInboxRules(EWSAccountService):
                        +    """
                        +    MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getinboxrules-operation
                        +
                        +    The GetInboxRules operation uses Exchange Web Services to retrieve Inbox rules in the identified user's mailbox.
                        +    """
                        +
                        +    SERVICE_NAME = "GetInboxRules"
                        +    supported_from = EXCHANGE_2010
                        +    element_container_name = InboxRules.response_tag()
                        +    ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (ErrorInvalidOperation,)
                        +
                        +    def call(self, mailbox: Optional[str] = None) -> Generator[Union[Rule, Exception, None], Any, None]:
                        +        if not mailbox:
                        +            mailbox = self.account.primary_smtp_address
                        +        payload = self.get_payload(mailbox=mailbox)
                        +        elements = self._get_elements(payload=payload)
                        +        return self._elems_to_objs(elements)
                        +
                        +    def _elem_to_obj(self, elem):
                        +        return Rule.from_xml(elem=elem, account=self.account)
                        +
                        +    def get_payload(self, mailbox):
                        +        payload = create_element(f"m:{self.SERVICE_NAME}")
                        +        add_xml_child(payload, "m:MailboxSmtpAddress", mailbox)
                        +        return payload
                        +
                        +    def _get_element_container(self, message, name=None):
                        +        if name:
                        +            response_class = message.get("ResponseClass")
                        +            response_code = get_xml_attr(message, f"{{{MNS}}}ResponseCode")
                        +            if response_class == "Success" and response_code == "NoError" and message.find(name) is None:
                        +                return []
                        +        return super()._get_element_container(message, name)
                        +
                        +
                        +class UpdateInboxRules(EWSAccountService):
                        +    """
                        +    MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation
                        +
                        +    The UpdateInboxRules operation updates the authenticated user's Inbox rules by applying the specified operations.
                        +    UpdateInboxRules is used to create an Inbox rule, to set an Inbox rule, or to delete an Inbox rule.
                        +
                        +    When you use the UpdateInboxRules operation, Exchange Web Services deletes client-side send rules.
                        +    Client-side send rules are stored on the client in the rule Folder Associated Information (FAI) Message and nowhere
                        +    else. EWS deletes this rule FAI message by default, based on the expectation that Outlook will recreate it.
                        +    However, Outlook can't recreate rules that don't also exist as an extended rule, and client-side send rules don't
                        +    exist as extended rules. As a result, these rules are lost. We suggest you consider this when designing your
                        +    solution.
                        +    """
                        +
                        +    SERVICE_NAME = "UpdateInboxRules"
                        +    supported_from = EXCHANGE_2010
                        +    ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (ErrorInvalidOperation,)
                        +
                        +
                        +class CreateInboxRule(UpdateInboxRules):
                        +    """
                        +    MSDN:
                        +    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-create-rule-request-example
                        +    """
                        +
                        +    def call(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                        +        payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob)
                        +        return self._get_elements(payload=payload)
                        +
                        +    def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                        +        payload = create_element(f"m:{self.SERVICE_NAME}")
                        +        add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob)
                        +        operations = Operations(create_rule_operation=CreateRuleOperation(rule=rule))
                        +        set_xml_value(payload, operations, version=self.account.version)
                        +        return payload
                        +
                        +
                        +class SetInboxRule(UpdateInboxRules):
                        +    """
                        +    MSDN:
                        +    https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-set-rule-request-example
                        +    """
                        +
                        +    def call(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                        +        payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob)
                        +        return self._get_elements(payload=payload)
                        +
                        +    def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                        +        if not rule.id:
                        +            raise ValueError("Rule must have an ID")
                        +        payload = create_element(f"m:{self.SERVICE_NAME}")
                        +        add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob)
                        +        operations = Operations(set_rule_operation=SetRuleOperation(rule=rule))
                        +        set_xml_value(payload, operations, version=self.account.version)
                        +        return payload
                        +
                        +
                        +class DeleteInboxRule(UpdateInboxRules):
                        +    """
                        +    MSDN:
                        +    https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-delete-rule-request-example
                        +    """
                        +
                        +    def call(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                        +        payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob)
                        +        return self._get_elements(payload=payload)
                        +
                        +    def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                        +        if not rule.id:
                        +            raise ValueError("Rule must have an ID")
                        +        payload = create_element(f"m:{self.SERVICE_NAME}")
                        +        add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob)
                        +        operations = Operations(delete_rule_operation=DeleteRuleOperation(id=rule.id))
                        +        set_xml_value(payload, operations, version=self.account.version)
                        +        return payload
                        +
                        +
                        +
                        +
                        +
                        +
                        +
                        +
                        +
                        +

                        Classes

                        +
                        +
                        +class CreateInboxRule +(*args, **kwargs) +
                        +
                        + +
                        + +Expand source code + +
                        class CreateInboxRule(UpdateInboxRules):
                        +    """
                        +    MSDN:
                        +    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-create-rule-request-example
                        +    """
                        +
                        +    def call(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                        +        payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob)
                        +        return self._get_elements(payload=payload)
                        +
                        +    def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                        +        payload = create_element(f"m:{self.SERVICE_NAME}")
                        +        add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob)
                        +        operations = Operations(create_rule_operation=CreateRuleOperation(rule=rule))
                        +        set_xml_value(payload, operations, version=self.account.version)
                        +        return payload
                        +
                        +

                        Ancestors

                        + +

                        Methods

                        +
                        +
                        +def call(self, rule: Rule, remove_outlook_rule_blob: bool = True) +
                        +
                        +
                        +
                        + +Expand source code + +
                        def call(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                        +    payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob)
                        +    return self._get_elements(payload=payload)
                        +
                        +
                        +
                        +def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True) +
                        +
                        +
                        +
                        + +Expand source code + +
                        def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                        +    payload = create_element(f"m:{self.SERVICE_NAME}")
                        +    add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob)
                        +    operations = Operations(create_rule_operation=CreateRuleOperation(rule=rule))
                        +    set_xml_value(payload, operations, version=self.account.version)
                        +    return payload
                        +
                        +
                        +
                        +

                        Inherited members

                        + +
                        +
                        +class DeleteInboxRule +(*args, **kwargs) +
                        +
                        + +
                        + +Expand source code + +
                        class DeleteInboxRule(UpdateInboxRules):
                        +    """
                        +    MSDN:
                        +    https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-delete-rule-request-example
                        +    """
                        +
                        +    def call(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                        +        payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob)
                        +        return self._get_elements(payload=payload)
                        +
                        +    def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                        +        if not rule.id:
                        +            raise ValueError("Rule must have an ID")
                        +        payload = create_element(f"m:{self.SERVICE_NAME}")
                        +        add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob)
                        +        operations = Operations(delete_rule_operation=DeleteRuleOperation(id=rule.id))
                        +        set_xml_value(payload, operations, version=self.account.version)
                        +        return payload
                        +
                        +

                        Ancestors

                        + +

                        Methods

                        +
                        +
                        +def call(self, rule: Rule, remove_outlook_rule_blob: bool = True) +
                        +
                        +
                        +
                        + +Expand source code + +
                        def call(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                        +    payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob)
                        +    return self._get_elements(payload=payload)
                        +
                        +
                        +
                        +def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True) +
                        +
                        +
                        +
                        + +Expand source code + +
                        def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                        +    if not rule.id:
                        +        raise ValueError("Rule must have an ID")
                        +    payload = create_element(f"m:{self.SERVICE_NAME}")
                        +    add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob)
                        +    operations = Operations(delete_rule_operation=DeleteRuleOperation(id=rule.id))
                        +    set_xml_value(payload, operations, version=self.account.version)
                        +    return payload
                        +
                        +
                        +
                        +

                        Inherited members

                        + +
                        +
                        +class GetInboxRules +(*args, **kwargs) +
                        +
                        +

                        MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getinboxrules-operation

                        +

                        The GetInboxRules operation uses Exchange Web Services to retrieve Inbox rules in the identified user's mailbox.

                        +
                        + +Expand source code + +
                        class GetInboxRules(EWSAccountService):
                        +    """
                        +    MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getinboxrules-operation
                        +
                        +    The GetInboxRules operation uses Exchange Web Services to retrieve Inbox rules in the identified user's mailbox.
                        +    """
                        +
                        +    SERVICE_NAME = "GetInboxRules"
                        +    supported_from = EXCHANGE_2010
                        +    element_container_name = InboxRules.response_tag()
                        +    ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (ErrorInvalidOperation,)
                        +
                        +    def call(self, mailbox: Optional[str] = None) -> Generator[Union[Rule, Exception, None], Any, None]:
                        +        if not mailbox:
                        +            mailbox = self.account.primary_smtp_address
                        +        payload = self.get_payload(mailbox=mailbox)
                        +        elements = self._get_elements(payload=payload)
                        +        return self._elems_to_objs(elements)
                        +
                        +    def _elem_to_obj(self, elem):
                        +        return Rule.from_xml(elem=elem, account=self.account)
                        +
                        +    def get_payload(self, mailbox):
                        +        payload = create_element(f"m:{self.SERVICE_NAME}")
                        +        add_xml_child(payload, "m:MailboxSmtpAddress", mailbox)
                        +        return payload
                        +
                        +    def _get_element_container(self, message, name=None):
                        +        if name:
                        +            response_class = message.get("ResponseClass")
                        +            response_code = get_xml_attr(message, f"{{{MNS}}}ResponseCode")
                        +            if response_class == "Success" and response_code == "NoError" and message.find(name) is None:
                        +                return []
                        +        return super()._get_element_container(message, name)
                        +
                        +

                        Ancestors

                        + +

                        Class variables

                        +
                        +
                        var ERRORS_TO_CATCH_IN_RESPONSE
                        +
                        +
                        +
                        +
                        var SERVICE_NAME
                        +
                        +
                        +
                        +
                        var element_container_name
                        +
                        +
                        +
                        +
                        var supported_from
                        +
                        +
                        +
                        +
                        +

                        Methods

                        +
                        +
                        +def call(self, mailbox: Optional[str] = None) ‑> Generator[Union[Rule, Exception, ForwardRef(None)], Any, None] +
                        +
                        +
                        +
                        + +Expand source code + +
                        def call(self, mailbox: Optional[str] = None) -> Generator[Union[Rule, Exception, None], Any, None]:
                        +    if not mailbox:
                        +        mailbox = self.account.primary_smtp_address
                        +    payload = self.get_payload(mailbox=mailbox)
                        +    elements = self._get_elements(payload=payload)
                        +    return self._elems_to_objs(elements)
                        +
                        +
                        +
                        +def get_payload(self, mailbox) +
                        +
                        +
                        +
                        + +Expand source code + +
                        def get_payload(self, mailbox):
                        +    payload = create_element(f"m:{self.SERVICE_NAME}")
                        +    add_xml_child(payload, "m:MailboxSmtpAddress", mailbox)
                        +    return payload
                        +
                        +
                        +
                        +

                        Inherited members

                        + +
                        +
                        +class SetInboxRule +(*args, **kwargs) +
                        +
                        + +
                        + +Expand source code + +
                        class SetInboxRule(UpdateInboxRules):
                        +    """
                        +    MSDN:
                        +    https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-set-rule-request-example
                        +    """
                        +
                        +    def call(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                        +        payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob)
                        +        return self._get_elements(payload=payload)
                        +
                        +    def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                        +        if not rule.id:
                        +            raise ValueError("Rule must have an ID")
                        +        payload = create_element(f"m:{self.SERVICE_NAME}")
                        +        add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob)
                        +        operations = Operations(set_rule_operation=SetRuleOperation(rule=rule))
                        +        set_xml_value(payload, operations, version=self.account.version)
                        +        return payload
                        +
                        +

                        Ancestors

                        + +

                        Methods

                        +
                        +
                        +def call(self, rule: Rule, remove_outlook_rule_blob: bool = True) +
                        +
                        +
                        +
                        + +Expand source code + +
                        def call(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                        +    payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob)
                        +    return self._get_elements(payload=payload)
                        +
                        +
                        +
                        +def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True) +
                        +
                        +
                        +
                        + +Expand source code + +
                        def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                        +    if not rule.id:
                        +        raise ValueError("Rule must have an ID")
                        +    payload = create_element(f"m:{self.SERVICE_NAME}")
                        +    add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob)
                        +    operations = Operations(set_rule_operation=SetRuleOperation(rule=rule))
                        +    set_xml_value(payload, operations, version=self.account.version)
                        +    return payload
                        +
                        +
                        +
                        +

                        Inherited members

                        + +
                        +
                        +class UpdateInboxRules +(*args, **kwargs) +
                        +
                        +

                        MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation

                        +

                        The UpdateInboxRules operation updates the authenticated user's Inbox rules by applying the specified operations. +UpdateInboxRules is used to create an Inbox rule, to set an Inbox rule, or to delete an Inbox rule.

                        +

                        When you use the UpdateInboxRules operation, Exchange Web Services deletes client-side send rules. +Client-side send rules are stored on the client in the rule Folder Associated Information (FAI) Message and nowhere +else. EWS deletes this rule FAI message by default, based on the expectation that Outlook will recreate it. +However, Outlook can't recreate rules that don't also exist as an extended rule, and client-side send rules don't +exist as extended rules. As a result, these rules are lost. We suggest you consider this when designing your +solution.

                        +
                        + +Expand source code + +
                        class UpdateInboxRules(EWSAccountService):
                        +    """
                        +    MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation
                        +
                        +    The UpdateInboxRules operation updates the authenticated user's Inbox rules by applying the specified operations.
                        +    UpdateInboxRules is used to create an Inbox rule, to set an Inbox rule, or to delete an Inbox rule.
                        +
                        +    When you use the UpdateInboxRules operation, Exchange Web Services deletes client-side send rules.
                        +    Client-side send rules are stored on the client in the rule Folder Associated Information (FAI) Message and nowhere
                        +    else. EWS deletes this rule FAI message by default, based on the expectation that Outlook will recreate it.
                        +    However, Outlook can't recreate rules that don't also exist as an extended rule, and client-side send rules don't
                        +    exist as extended rules. As a result, these rules are lost. We suggest you consider this when designing your
                        +    solution.
                        +    """
                        +
                        +    SERVICE_NAME = "UpdateInboxRules"
                        +    supported_from = EXCHANGE_2010
                        +    ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (ErrorInvalidOperation,)
                        +
                        +

                        Ancestors

                        + +

                        Subclasses

                        + +

                        Class variables

                        +
                        +
                        var ERRORS_TO_CATCH_IN_RESPONSE
                        +
                        +
                        +
                        +
                        var SERVICE_NAME
                        +
                        +
                        +
                        +
                        var supported_from
                        +
                        +
                        +
                        +
                        +

                        Inherited members

                        + +
                        +
                        +
                        +
                        + +
                        + + + diff --git a/docs/exchangelib/services/index.html b/docs/exchangelib/services/index.html index 428a2589..092b6f46 100644 --- a/docs/exchangelib/services/index.html +++ b/docs/exchangelib/services/index.html @@ -74,6 +74,7 @@

                        Module exchangelib.services

                        from .get_user_configuration import GetUserConfiguration from .get_user_oof_settings import GetUserOofSettings from .get_user_settings import GetUserSettings +from .inbox_rules import CreateInboxRule, DeleteInboxRule, GetInboxRules, SetInboxRule from .mark_as_junk import MarkAsJunk from .move_folder import MoveFolder from .move_item import MoveItem @@ -142,6 +143,10 @@

                        Module exchangelib.services

                        "UpdateItem", "UpdateUserConfiguration", "UploadItems", + "GetInboxRules", + "CreateInboxRule", + "SetInboxRule", + "DeleteInboxRule", ] @@ -284,6 +289,10 @@

                        Sub-modules

                        +
                        exchangelib.services.inbox_rules
                        +
                        +
                        +
                        exchangelib.services.mark_as_junk
                        @@ -882,6 +891,89 @@

                        Inherited members

                      +
                      +class CreateInboxRule +(*args, **kwargs) +
                      +
                      + +
                      + +Expand source code + +
                      class CreateInboxRule(UpdateInboxRules):
                      +    """
                      +    MSDN:
                      +    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-create-rule-request-example
                      +    """
                      +
                      +    def call(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                      +        payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob)
                      +        return self._get_elements(payload=payload)
                      +
                      +    def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                      +        payload = create_element(f"m:{self.SERVICE_NAME}")
                      +        add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob)
                      +        operations = Operations(create_rule_operation=CreateRuleOperation(rule=rule))
                      +        set_xml_value(payload, operations, version=self.account.version)
                      +        return payload
                      +
                      +

                      Ancestors

                      + +

                      Methods

                      +
                      +
                      +def call(self, rule: Rule, remove_outlook_rule_blob: bool = True) +
                      +
                      +
                      +
                      + +Expand source code + +
                      def call(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                      +    payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob)
                      +    return self._get_elements(payload=payload)
                      +
                      +
                      +
                      +def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True) +
                      +
                      +
                      +
                      + +Expand source code + +
                      def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                      +    payload = create_element(f"m:{self.SERVICE_NAME}")
                      +    add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob)
                      +    operations = Operations(create_rule_operation=CreateRuleOperation(rule=rule))
                      +    set_xml_value(payload, operations, version=self.account.version)
                      +    return payload
                      +
                      +
                      +
                      +

                      Inherited members

                      + +
                      class CreateItem (*args, **kwargs) @@ -1381,6 +1473,93 @@

                      Inherited members

                    +
                    +class DeleteInboxRule +(*args, **kwargs) +
                    +
                    + +
                    + +Expand source code + +
                    class DeleteInboxRule(UpdateInboxRules):
                    +    """
                    +    MSDN:
                    +    https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-delete-rule-request-example
                    +    """
                    +
                    +    def call(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                    +        payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob)
                    +        return self._get_elements(payload=payload)
                    +
                    +    def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                    +        if not rule.id:
                    +            raise ValueError("Rule must have an ID")
                    +        payload = create_element(f"m:{self.SERVICE_NAME}")
                    +        add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob)
                    +        operations = Operations(delete_rule_operation=DeleteRuleOperation(id=rule.id))
                    +        set_xml_value(payload, operations, version=self.account.version)
                    +        return payload
                    +
                    +

                    Ancestors

                    + +

                    Methods

                    +
                    +
                    +def call(self, rule: Rule, remove_outlook_rule_blob: bool = True) +
                    +
                    +
                    +
                    + +Expand source code + +
                    def call(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                    +    payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob)
                    +    return self._get_elements(payload=payload)
                    +
                    +
                    +
                    +def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True) +
                    +
                    +
                    +
                    + +Expand source code + +
                    def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                    +    if not rule.id:
                    +        raise ValueError("Rule must have an ID")
                    +    payload = create_element(f"m:{self.SERVICE_NAME}")
                    +    add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob)
                    +    operations = Operations(delete_rule_operation=DeleteRuleOperation(id=rule.id))
                    +    set_xml_value(payload, operations, version=self.account.version)
                    +    return payload
                    +
                    +
                    +
                    +

                    Inherited members

                    + +
                    class DeleteItem (*args, **kwargs) @@ -4115,6 +4294,126 @@

                    Inherited members

                  +
                  +class GetInboxRules +(*args, **kwargs) +
                  +
                  +

                  MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getinboxrules-operation

                  +

                  The GetInboxRules operation uses Exchange Web Services to retrieve Inbox rules in the identified user's mailbox.

                  +
                  + +Expand source code + +
                  class GetInboxRules(EWSAccountService):
                  +    """
                  +    MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/getinboxrules-operation
                  +
                  +    The GetInboxRules operation uses Exchange Web Services to retrieve Inbox rules in the identified user's mailbox.
                  +    """
                  +
                  +    SERVICE_NAME = "GetInboxRules"
                  +    supported_from = EXCHANGE_2010
                  +    element_container_name = InboxRules.response_tag()
                  +    ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (ErrorInvalidOperation,)
                  +
                  +    def call(self, mailbox: Optional[str] = None) -> Generator[Union[Rule, Exception, None], Any, None]:
                  +        if not mailbox:
                  +            mailbox = self.account.primary_smtp_address
                  +        payload = self.get_payload(mailbox=mailbox)
                  +        elements = self._get_elements(payload=payload)
                  +        return self._elems_to_objs(elements)
                  +
                  +    def _elem_to_obj(self, elem):
                  +        return Rule.from_xml(elem=elem, account=self.account)
                  +
                  +    def get_payload(self, mailbox):
                  +        payload = create_element(f"m:{self.SERVICE_NAME}")
                  +        add_xml_child(payload, "m:MailboxSmtpAddress", mailbox)
                  +        return payload
                  +
                  +    def _get_element_container(self, message, name=None):
                  +        if name:
                  +            response_class = message.get("ResponseClass")
                  +            response_code = get_xml_attr(message, f"{{{MNS}}}ResponseCode")
                  +            if response_class == "Success" and response_code == "NoError" and message.find(name) is None:
                  +                return []
                  +        return super()._get_element_container(message, name)
                  +
                  +

                  Ancestors

                  + +

                  Class variables

                  +
                  +
                  var ERRORS_TO_CATCH_IN_RESPONSE
                  +
                  +
                  +
                  +
                  var SERVICE_NAME
                  +
                  +
                  +
                  +
                  var element_container_name
                  +
                  +
                  +
                  +
                  var supported_from
                  +
                  +
                  +
                  +
                  +

                  Methods

                  +
                  +
                  +def call(self, mailbox: Optional[str] = None) ‑> Generator[Union[Rule, Exception, ForwardRef(None)], Any, None] +
                  +
                  +
                  +
                  + +Expand source code + +
                  def call(self, mailbox: Optional[str] = None) -> Generator[Union[Rule, Exception, None], Any, None]:
                  +    if not mailbox:
                  +        mailbox = self.account.primary_smtp_address
                  +    payload = self.get_payload(mailbox=mailbox)
                  +    elements = self._get_elements(payload=payload)
                  +    return self._elems_to_objs(elements)
                  +
                  +
                  +
                  +def get_payload(self, mailbox) +
                  +
                  +
                  +
                  + +Expand source code + +
                  def get_payload(self, mailbox):
                  +    payload = create_element(f"m:{self.SERVICE_NAME}")
                  +    add_xml_child(payload, "m:MailboxSmtpAddress", mailbox)
                  +    return payload
                  +
                  +
                  +
                  +

                  Inherited members

                  + +
                  class GetItem (*args, **kwargs) @@ -6372,6 +6671,93 @@

                  Inherited members

                +
                +class SetInboxRule +(*args, **kwargs) +
                +
                + +
                + +Expand source code + +
                class SetInboxRule(UpdateInboxRules):
                +    """
                +    MSDN:
                +    https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-set-rule-request-example
                +    """
                +
                +    def call(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                +        payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob)
                +        return self._get_elements(payload=payload)
                +
                +    def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                +        if not rule.id:
                +            raise ValueError("Rule must have an ID")
                +        payload = create_element(f"m:{self.SERVICE_NAME}")
                +        add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob)
                +        operations = Operations(set_rule_operation=SetRuleOperation(rule=rule))
                +        set_xml_value(payload, operations, version=self.account.version)
                +        return payload
                +
                +

                Ancestors

                + +

                Methods

                +
                +
                +def call(self, rule: Rule, remove_outlook_rule_blob: bool = True) +
                +
                +
                +
                + +Expand source code + +
                def call(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                +    payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob)
                +    return self._get_elements(payload=payload)
                +
                +
                +
                +def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True) +
                +
                +
                +
                + +Expand source code + +
                def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True):
                +    if not rule.id:
                +        raise ValueError("Rule must have an ID")
                +    payload = create_element(f"m:{self.SERVICE_NAME}")
                +    add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob)
                +    operations = Operations(set_rule_operation=SetRuleOperation(rule=rule))
                +    set_xml_value(payload, operations, version=self.account.version)
                +    return payload
                +
                +
                +
                +

                Inherited members

                + +
                class SetUserOofSettings (*args, **kwargs) @@ -6489,6 +6875,7 @@

                Inherited members

                Expand source code
                class SubscribeToPull(Subscribe):
                +    # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/pullsubscriptionrequest
                     subscription_request_elem_tag = "m:PullSubscriptionRequest"
                     prefer_affinity = True
                 
                @@ -6595,6 +6982,7 @@ 

                Inherited members

                Expand source code
                class SubscribeToPush(Subscribe):
                +    # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/pushsubscriptionrequest
                     subscription_request_elem_tag = "m:PushSubscriptionRequest"
                 
                     def call(self, folders, event_types, watermark, status_frequency, url):
                @@ -6700,6 +7088,7 @@ 

                Inherited members

                Expand source code
                class SubscribeToStreaming(Subscribe):
                +    # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/streamingsubscriptionrequest
                     subscription_request_elem_tag = "m:StreamingSubscriptionRequest"
                     prefer_affinity = True
                 
                @@ -7839,6 +8228,7 @@ 

                Index

              • exchangelib.services.get_user_configuration
              • exchangelib.services.get_user_oof_settings
              • exchangelib.services.get_user_settings
              • +
              • exchangelib.services.inbox_rules
              • exchangelib.services.mark_as_junk
              • exchangelib.services.move_folder
              • exchangelib.services.move_item
              • @@ -7904,6 +8294,13 @@

                CreateInboxRule

                + +
              • +
              • CreateItem

                • SERVICE_NAME
                • @@ -7939,6 +8336,13 @@

                  DeleteInboxRule

                  + + +
                • DeleteItem

                  • SERVICE_NAME
                  • @@ -8075,6 +8479,17 @@

                    GetInboxRules

                    + + +
                  • GetItem

                    • SERVICE_NAME
                    • @@ -8249,6 +8664,13 @@

                      SetInboxRule

                      + + +
                    • SetUserOofSettings

                      • SERVICE_NAME
                      • diff --git a/docs/exchangelib/services/subscribe.html b/docs/exchangelib/services/subscribe.html index 3b75d7f1..1b9ae971 100644 --- a/docs/exchangelib/services/subscribe.html +++ b/docs/exchangelib/services/subscribe.html @@ -32,6 +32,7 @@

                        Module exchangelib.services.subscribe

                        """The 'Subscribe' service has three different modes - pull, push and streaming - with different signatures. Implement
                         as three distinct classes.
                         """
                        +
                         import abc
                         
                         from ..util import MNS, create_element
                        @@ -71,9 +72,13 @@ 

                        Module exchangelib.services.subscribe

                        return [(container.find(f"{{{MNS}}}SubscriptionId"), container.find(f"{{{MNS}}}Watermark"))] def _partial_payload(self, folders, event_types): - request_elem = create_element(self.subscription_request_elem_tag) - folder_ids = folder_ids_element(folders=folders, version=self.account.version, tag="t:FolderIds") - request_elem.append(folder_ids) + if folders is None: + # Interpret this as "all folders" + request_elem = create_element(self.subscription_request_elem_tag, attrs=dict(SubscribeToAllFolders=True)) + else: + request_elem = create_element(self.subscription_request_elem_tag) + folder_ids = folder_ids_element(folders=folders, version=self.account.version, tag="t:FolderIds") + request_elem.append(folder_ids) event_types_elem = create_element("t:EventTypes") for event_type in event_types: add_xml_child(event_types_elem, "t:EventType", event_type) @@ -84,6 +89,7 @@

                        Module exchangelib.services.subscribe

                        class SubscribeToPull(Subscribe): + # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/pullsubscriptionrequest subscription_request_elem_tag = "m:PullSubscriptionRequest" prefer_affinity = True @@ -107,6 +113,7 @@

                        Module exchangelib.services.subscribe

                        class SubscribeToPush(Subscribe): + # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/pushsubscriptionrequest subscription_request_elem_tag = "m:PushSubscriptionRequest" def call(self, folders, event_types, watermark, status_frequency, url): @@ -131,6 +138,7 @@

                        Module exchangelib.services.subscribe

                        class SubscribeToStreaming(Subscribe): + # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/streamingsubscriptionrequest subscription_request_elem_tag = "m:StreamingSubscriptionRequest" prefer_affinity = True @@ -203,9 +211,13 @@

                        Classes

                        return [(container.find(f"{{{MNS}}}SubscriptionId"), container.find(f"{{{MNS}}}Watermark"))] def _partial_payload(self, folders, event_types): - request_elem = create_element(self.subscription_request_elem_tag) - folder_ids = folder_ids_element(folders=folders, version=self.account.version, tag="t:FolderIds") - request_elem.append(folder_ids) + if folders is None: + # Interpret this as "all folders" + request_elem = create_element(self.subscription_request_elem_tag, attrs=dict(SubscribeToAllFolders=True)) + else: + request_elem = create_element(self.subscription_request_elem_tag) + folder_ids = folder_ids_element(folders=folders, version=self.account.version, tag="t:FolderIds") + request_elem.append(folder_ids) event_types_elem = create_element("t:EventTypes") for event_type in event_types: add_xml_child(event_types_elem, "t:EventType", event_type) @@ -267,6 +279,7 @@

                        Inherited members

                        Expand source code
                        class SubscribeToPull(Subscribe):
                        +    # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/pullsubscriptionrequest
                             subscription_request_elem_tag = "m:PullSubscriptionRequest"
                             prefer_affinity = True
                         
                        @@ -373,6 +386,7 @@ 

                        Inherited members

                        Expand source code
                        class SubscribeToPush(Subscribe):
                        +    # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/pushsubscriptionrequest
                             subscription_request_elem_tag = "m:PushSubscriptionRequest"
                         
                             def call(self, folders, event_types, watermark, status_frequency, url):
                        @@ -478,6 +492,7 @@ 

                        Inherited members

                        Expand source code
                        class SubscribeToStreaming(Subscribe):
                        +    # https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/streamingsubscriptionrequest
                             subscription_request_elem_tag = "m:StreamingSubscriptionRequest"
                             prefer_affinity = True
                         
                        diff --git a/docs/exchangelib/winzone.html b/docs/exchangelib/winzone.html
                        index 2789d289..013ebaaf 100644
                        --- a/docs/exchangelib/winzone.html
                        +++ b/docs/exchangelib/winzone.html
                        @@ -29,6 +29,7 @@ 

                        Module exchangelib.winzone

                        """A dict to translate from IANA location name to Windows timezone name. Translations taken from CLDR_WINZONE_URL
                         """
                        +
                         import re
                         
                         import requests
                        @@ -219,11 +220,9 @@ 

                        Module exchangelib.winzone

                        "America/Moncton": ("Atlantic Standard Time", "CA"), "America/Monterrey": ("Central Standard Time (Mexico)", "MX"), "America/Montevideo": ("Montevideo Standard Time", "001"), - "America/Montreal": ("Eastern Standard Time", "CA"), "America/Montserrat": ("SA Western Standard Time", "MS"), "America/Nassau": ("Eastern Standard Time", "BS"), "America/New_York": ("Eastern Standard Time", "001"), - "America/Nipigon": ("Eastern Standard Time", "CA"), "America/Nome": ("Alaskan Standard Time", "US"), "America/Noronha": ("UTC-02", "BR"), "America/North_Dakota/Beulah": ("Central Standard Time", "US"), @@ -231,7 +230,6 @@

                        Module exchangelib.winzone

                        "America/North_Dakota/New_Salem": ("Central Standard Time", "US"), "America/Ojinaga": ("Central Standard Time", "MX"), "America/Panama": ("SA Pacific Standard Time", "PA"), - "America/Pangnirtung": ("Eastern Standard Time", "CA"), "America/Paramaribo": ("SA Eastern Standard Time", "SR"), "America/Phoenix": ("US Mountain Standard Time", "001"), "America/Port-au-Prince": ("Haiti Standard Time", "001"), @@ -239,13 +237,11 @@

                        Module exchangelib.winzone

                        "America/Porto_Velho": ("SA Western Standard Time", "BR"), "America/Puerto_Rico": ("SA Western Standard Time", "PR"), "America/Punta_Arenas": ("Magallanes Standard Time", "001"), - "America/Rainy_River": ("Central Standard Time", "CA"), "America/Rankin_Inlet": ("Central Standard Time", "CA"), "America/Recife": ("SA Eastern Standard Time", "BR"), "America/Regina": ("Canada Central Standard Time", "001"), "America/Resolute": ("Central Standard Time", "CA"), "America/Rio_Branco": ("SA Pacific Standard Time", "BR"), - "America/Santa_Isabel": ("Pacific Standard Time (Mexico)", "MX"), "America/Santarem": ("SA Eastern Standard Time", "BR"), "America/Santiago": ("Pacific SA Standard Time", "001"), "America/Santo_Domingo": ("SA Western Standard Time", "DO"), @@ -261,7 +257,6 @@

                        Module exchangelib.winzone

                        "America/Swift_Current": ("Canada Central Standard Time", "CA"), "America/Tegucigalpa": ("Central America Standard Time", "HN"), "America/Thule": ("Atlantic Standard Time", "GL"), - "America/Thunder_Bay": ("Eastern Standard Time", "CA"), "America/Tijuana": ("Pacific Standard Time (Mexico)", "001"), "America/Toronto": ("Eastern Standard Time", "CA"), "America/Tortola": ("SA Western Standard Time", "VG"), @@ -269,7 +264,6 @@

                        Module exchangelib.winzone

                        "America/Whitehorse": ("Yukon Standard Time", "001"), "America/Winnipeg": ("Central Standard Time", "CA"), "America/Yakutat": ("Alaskan Standard Time", "US"), - "America/Yellowknife": ("Mountain Standard Time", "CA"), "Antarctica/Casey": ("Central Pacific Standard Time", "AQ"), "Antarctica/Davis": ("SE Asia Standard Time", "AQ"), "Antarctica/DumontDUrville": ("West Pacific Standard Time", "AQ"), @@ -282,7 +276,7 @@

                        Module exchangelib.winzone

                        "Antarctica/Vostok": ("Central Asia Standard Time", "AQ"), "Arctic/Longyearbyen": ("W. Europe Standard Time", "SJ"), "Asia/Aden": ("Arab Standard Time", "YE"), - "Asia/Almaty": ("Central Asia Standard Time", "001"), + "Asia/Almaty": ("West Asia Standard Time", "KZ"), "Asia/Amman": ("Jordan Standard Time", "001"), "Asia/Anadyr": ("Russia Time Zone 11", "RU"), "Asia/Aqtau": ("West Asia Standard Time", "KZ"), @@ -295,7 +289,7 @@

                        Module exchangelib.winzone

                        "Asia/Bangkok": ("SE Asia Standard Time", "001"), "Asia/Barnaul": ("Altai Standard Time", "001"), "Asia/Beirut": ("Middle East Standard Time", "001"), - "Asia/Bishkek": ("Central Asia Standard Time", "KG"), + "Asia/Bishkek": ("Central Asia Standard Time", "001"), "Asia/Brunei": ("Singapore Standard Time", "BN"), "Asia/Calcutta": ("India Standard Time", "001"), "Asia/Chita": ("Transbaikal Standard Time", "001"), @@ -338,7 +332,7 @@

                        Module exchangelib.winzone

                        "Asia/Pontianak": ("SE Asia Standard Time", "ID"), "Asia/Pyongyang": ("North Korea Standard Time", "001"), "Asia/Qatar": ("Arab Standard Time", "QA"), - "Asia/Qostanay": ("Central Asia Standard Time", "KZ"), + "Asia/Qostanay": ("West Asia Standard Time", "KZ"), "Asia/Qyzylorda": ("Qyzylorda Standard Time", "001"), "Asia/Rangoon": ("Myanmar Standard Time", "001"), "Asia/Riyadh": ("Arab Standard Time", "001"), @@ -377,7 +371,6 @@

                        Module exchangelib.winzone

                        "Australia/Adelaide": ("Cen. Australia Standard Time", "001"), "Australia/Brisbane": ("E. Australia Standard Time", "001"), "Australia/Broken_Hill": ("Cen. Australia Standard Time", "AU"), - "Australia/Currie": ("Tasmania Standard Time", "AU"), "Australia/Darwin": ("AUS Central Standard Time", "001"), "Australia/Eucla": ("Aus Central W. Standard Time", "001"), "Australia/Hobart": ("Tasmania Standard Time", "001"), @@ -466,7 +459,6 @@

                        Module exchangelib.winzone

                        "Europe/Tallinn": ("FLE Standard Time", "EE"), "Europe/Tirane": ("Central Europe Standard Time", "AL"), "Europe/Ulyanovsk": ("Astrakhan Standard Time", "RU"), - "Europe/Uzhgorod": ("FLE Standard Time", "UA"), "Europe/Vaduz": ("W. Europe Standard Time", "LI"), "Europe/Vatican": ("W. Europe Standard Time", "VA"), "Europe/Vienna": ("W. Europe Standard Time", "AT"), @@ -474,7 +466,6 @@

                        Module exchangelib.winzone

                        "Europe/Volgograd": ("Volgograd Standard Time", "001"), "Europe/Warsaw": ("Central European Standard Time", "001"), "Europe/Zagreb": ("Central European Standard Time", "HR"), - "Europe/Zaporozhye": ("FLE Standard Time", "UA"), "Europe/Zurich": ("W. Europe Standard Time", "CH"), "Indian/Antananarivo": ("E. Africa Standard Time", "MG"), "Indian/Chagos": ("Central Asia Standard Time", "IO"), @@ -504,7 +495,6 @@

                        Module exchangelib.winzone

                        "Pacific/Guadalcanal": ("Central Pacific Standard Time", "001"), "Pacific/Guam": ("West Pacific Standard Time", "GU"), "Pacific/Honolulu": ("Hawaiian Standard Time", "001"), - "Pacific/Johnston": ("Hawaiian Standard Time", "UM"), "Pacific/Kiritimati": ("Line Islands Standard Time", "001"), "Pacific/Kosrae": ("Central Pacific Standard Time", "FM"), "Pacific/Kwajalein": ("UTC+12", "MH"), diff --git a/docs/index.md b/docs/index.md index a5ed5339..e547dbf0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -44,7 +44,7 @@ Apart from this documentation, we also provide online * [Out of Facility (OOF)](#out-of-facility-oof) * [Mail tips](#mail-tips) * [Delegate information](#delegate-information) -* [InboxRules](#inboxrules) +* [Inbox Rules](#inboxrules) * [Export and upload](#export-and-upload) * [Synchronization, subscriptions and notifications](#synchronization-subscriptions-and-notifications) * [Non-account services](#non-account-services) From 014f36eee2df9306aff9518fcc22f6f05a16457f Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Fri, 8 Mar 2024 10:53:16 +0100 Subject: [PATCH 403/509] Bump version --- CHANGELOG.md | 9 +++++++++ exchangelib/__init__.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a3c9bb0..c475834b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ HEAD ---- +5.2.0 +----- +- Allow setting a custom `Configuration.max_conections` in autodiscover mode +- Add support for inbox rules. See documentation for examples. +- Fix shared folder access in delegate mode +- Support subscribing to all folders instead of specific folders + + + 5.1.0 ----- - Fix QuerySet operations on shared folders diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index ce0a7d71..1c988d3a 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -36,7 +36,7 @@ from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "5.1.0" +__version__ = "5.2.0" __all__ = [ "__version__", From 7b93cc74dc7f6c6aa4cf171466643e32bda50cdd Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sun, 17 Mar 2024 11:46:07 +0100 Subject: [PATCH 404/509] chore: ask for and use DistinguishedFolderId directly. Fixes #1278 --- exchangelib/folders/base.py | 2 +- exchangelib/properties.py | 4 ++++ exchangelib/services/common.py | 2 -- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index d1a2cd34..9ec5b488 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -77,7 +77,7 @@ class BaseFolder(RegisterMixIn, SearchableMixIn, SupportedVersionClassMixIn, met ID_ELEMENT_CLS = FolderId _id = IdElementField(field_uri="folder:FolderId", value_cls=ID_ELEMENT_CLS) - _distinguished_id = IdElementField(field_uri="folder:FolderId", value_cls=DistinguishedFolderId) + _distinguished_id = IdElementField(field_uri="folder:DistinguishedFolderId", value_cls=DistinguishedFolderId) parent_folder_id = EWSElementField(field_uri="folder:ParentFolderId", value_cls=ParentFolderId, is_read_only=True) folder_class = CharField(field_uri="folder:FolderClass", is_required_after_save=True) name = CharField(field_uri="folder:DisplayName") diff --git a/exchangelib/properties.py b/exchangelib/properties.py index bcfd3081..96947dcc 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -749,6 +749,10 @@ class DistinguishedFolderId(FolderId): mailbox = MailboxField() + @classmethod + def from_xml(cls, elem, account): + return cls(id=elem.text or None) + def clean(self, version=None): from .folders import PublicFoldersRoot diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 0e8c0c3c..405f3501 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -975,7 +975,6 @@ def parse_folder_elem(elem, folder, account): f = folder.from_xml(elem=elem, account=folder.account) elif isinstance(folder, Folder): f = folder.from_xml_with_root(elem=elem, root=folder.root) - f._distinguished_id = folder._distinguished_id elif isinstance(folder, DistinguishedFolderId): # We don't know the root or even account, but we need to attach the folder to something if we want to make # future requests with this folder. Use 'account' but make sure to always use the distinguished folder ID going @@ -991,7 +990,6 @@ def parse_folder_elem(elem, folder, account): f = folder_cls.from_xml(elem=elem, account=account) else: f = folder_cls.from_xml_with_root(elem=elem, root=account.root) - f._distinguished_id = folder else: # 'folder' is a generic FolderId instance. We don't know the root so assume account.root. f = Folder.from_xml_with_root(elem=elem, root=account.root) From a3db309055103b21babf9e5b73b5e75b1b3e07ab Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sun, 17 Mar 2024 11:46:29 +0100 Subject: [PATCH 405/509] docs: Remove repeated sentence --- docs/index.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index e547dbf0..86fa19e4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -426,7 +426,6 @@ users and OAuth for everything else. Here's one way to set up an app in Azure that can access accounts in your organization using impersonation - i.e. access to multiple acounts on behalf of those users. First, log into the [Microsoft 365 Administration](https://admin.microsoft.com) page. Find the link -to `Azure Active Directory`. Find the link to `Azure Active Directory`. Select `App registrations` in the menu and then `New registration`. Enter an app name and press `Register`: From 04d8cb4db6161cfdcff742587b392cdee269d937 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Sun, 17 Mar 2024 11:48:16 +0100 Subject: [PATCH 406/509] test: Refresh client_id of test account --- settings.yml.ghenc | Bin 544 -> 544 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/settings.yml.ghenc b/settings.yml.ghenc index dd20eed71567f87f8e6154604b0f1b63b2ef3fb0..983f8407ea217b279146e2be30125ef117916335 100644 GIT binary patch literal 544 zcmV+*0^j{pVQh3|WM5w>vBt6eNse$wr^6XV(LD^r8f3S6kU#Dx|I6$~&h1JHI}bg z!D>CX_%$ypK4bw;bqoNXD)1~9NZ^b0Wq9tG&9r1eE%20yLEZIpolsurdPy|J)3kexX~MrTa-^_@T~o6z&5IvXE1A#1v`( literal 544 zcmV+*0^j{pVQh3|WM5w;%Q_{~Go!qBbyLe6k1>l1gP=vh)|pBALAyxRueR1Ar1$>g zb>oWbG&f4kJ1D7Ouw}HcrYy-T6}cK?l{Db!NH3Wrak--g7Nch>q+!U16L|jEuQW2& z94nJ|JpKKH(&DxO4tH4&QM&K(c&OR(t>pBIB>@V9+zAD@r9R3j6n+vq_(kDl?2kD% zOz}7THsvS;YF3lDhsU+folADzw7|}k7&m7=yT*)E2PT|oZ(nr!(6}sd)yd*U;@y+w zmA9?xHAyahSQV8B8-Gsdno=locn9T%2hUhQn>rl+f_V|rV@pJA{oG-+<1&4sLiu&3 zx~95gHj4VYlfa@Q9B<(TtLp{9(LG-dfUYcZD?-h)^I>woq(#Ke+^W6MBr)<0m5u;! znxN%z7pEfVHX~Q@d33y&$$OOX%VMp@sb&0$J?_}@v9w7NlC=pQKFE7MG@}Uj5v0YI zzNdr>Xc7=Es4`7}1&25DKswS1qnt|SMoEa)vGu3Bs=tS&Y+}evho~meo!z`tAL1cJ iFT~YozTf@9V=pzFrDovL{^+5IiDMJnnSh5L5=gm$=@kwD From 140b8831078ddca00aaa5f6c9334731d91ba74fa Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 18 Mar 2024 12:35:20 +0100 Subject: [PATCH 407/509] chore: Clean up known folders, add more distinguished folders, and improve test coverage --- exchangelib/folders/__init__.py | 44 ++- exchangelib/folders/base.py | 2 +- exchangelib/folders/known_folders.py | 512 +++++++++++++++------------ tests/test_folder.py | 94 ++++- 4 files changed, 420 insertions(+), 232 deletions(-) diff --git a/exchangelib/folders/__init__.py b/exchangelib/folders/__init__.py index c406bb2f..928f219c 100644 --- a/exchangelib/folders/__init__.py +++ b/exchangelib/folders/__init__.py @@ -4,8 +4,11 @@ from .known_folders import ( NON_DELETABLE_FOLDERS, AdminAuditLogs, + AllCategorizedItems, AllContacts, AllItems, + AllPersonMetadata, + AllTodoTasks, ApplicationData, ArchiveDeletedItems, ArchiveInbox, @@ -33,15 +36,19 @@ Drafts, EventCheckPoints, ExchangeSyncData, + ExternalContacts, Favorites, Files, + FolderMemberships, FreeBusyCache, FreebusyData, Friends, + FromFavoriteSenders, GALContacts, GraphAnalytics, IMContactList, Inbox, + Inference, Journal, JunkEmail, LocalFailures, @@ -51,8 +58,9 @@ MsgFolderRoot, MyContacts, MyContactsExtended, - NonDeletableFolderMixIn, + NonDeletableFolder, Notes, + OneNotePagePreviews, OrganizationalContacts, Outbox, ParkedMessages, @@ -60,21 +68,31 @@ PdpProfileV2Secured, PeopleCentricConversationBuddies, PeopleConnect, + QedcDefaultRetention, + QedcLongRetention, + QedcMediumRetention, + QedcShortRetention, + QuarantinedEmail, + QuarantinedEmailDefaultCategory, QuickContacts, RecipientCache, RecoverableItemsDeletions, RecoverableItemsPurges, RecoverableItemsRoot, + RecoverableItemsSubstrateHolds, RecoverableItemsVersions, RecoveryPoints, + RelevantContacts, Reminders, RSSFeeds, Schedule, SearchFolders, SentItems, ServerFailures, + SharePointNotifications, Sharing, Shortcuts, + ShortNotes, Signal, SkypeTeamsMessages, SmsAndChatsSync, @@ -82,9 +100,11 @@ SwssItems, SyncIssues, System, + System1, Tasks, TemporarySaves, ToDoSearch, + UserCuratedContacts, Views, VoiceMail, WellknownFolder, @@ -95,8 +115,11 @@ __all__ = [ "AdminAuditLogs", + "AllCategorizedItems", "AllContacts", "AllItems", + "AllPersonMetadata", + "AllTodoTasks", "ApplicationData", "ArchiveDeletedItems", "ArchiveInbox", @@ -128,20 +151,24 @@ "Drafts", "EventCheckPoints", "ExchangeSyncData", + "ExternalContacts", "FOLDER_TRAVERSAL_CHOICES", "Favorites", "Files", "Folder", "FolderCollection", "FolderId", + "FolderMemberships", "FolderQuerySet", "FreeBusyCache", "FreebusyData", "Friends", + "FromFavoriteSenders", "GALContacts", "GraphAnalytics", "IMContactList", "Inbox", + "Inference", "Journal", "JunkEmail", "LocalFailures", @@ -152,8 +179,9 @@ "MyContacts", "MyContactsExtended", "NON_DELETABLE_FOLDERS", - "NonDeletableFolderMixIn", + "NonDeletableFolder", "Notes", + "OneNotePagePreviews", "OrganizationalContacts", "Outbox", "ParkedMessages", @@ -161,6 +189,12 @@ "PdpProfileV2Secured", "PeopleCentricConversationBuddies", "PeopleConnect", + "QedcDefaultRetention", + "QedcMediumRetention", + "QedcLongRetention", + "QedcShortRetention", + "QuarantinedEmail", + "QuarantinedEmailDefaultCategory", "PublicFoldersRoot", "QuickContacts", "RSSFeeds", @@ -168,8 +202,10 @@ "RecoverableItemsDeletions", "RecoverableItemsPurges", "RecoverableItemsRoot", + "RecoverableItemsSubstrateHolds", "RecoverableItemsVersions", "RecoveryPoints", + "RelevantContacts", "Reminders", "Root", "RootOfHierarchy", @@ -179,8 +215,10 @@ "SearchFolders", "SentItems", "ServerFailures", + "SharePointNotifications", "Sharing", "Shortcuts", + "ShortNotes", "Signal", "SingleFolderQuerySet", "SkypeTeamsMessages", @@ -189,9 +227,11 @@ "SwssItems", "SyncIssues", "System", + "System1", "Tasks", "TemporarySaves", "ToDoSearch", + "UserCuratedContacts", "Views", "VoiceMail", "WellknownFolder", diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 9ec5b488..0db73e88 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -221,7 +221,7 @@ def has_distinguished_name(self): def localized_names(cls, locale): # Return localized names for a specific locale. If no locale-specific names exist, return the default names, # if any. - return tuple(s.lower() for s in cls.LOCALIZED_NAMES.get(locale, cls.LOCALIZED_NAMES.get(None, []))) + return tuple(s.lower() for s in cls.LOCALIZED_NAMES.get(locale, cls.LOCALIZED_NAMES.get(None, [cls.__name__]))) @staticmethod def folder_cls_from_container_class(container_class): diff --git a/exchangelib/folders/known_folders.py b/exchangelib/folders/known_folders.py index 17d366ed..29c36ac1 100644 --- a/exchangelib/folders/known_folders.py +++ b/exchangelib/folders/known_folders.py @@ -16,13 +16,57 @@ from .collections import FolderCollection -class Calendar(Folder): +class Birthdays(Folder): + CONTAINER_CLASS = "IPF.Appointment.Birthday" + LOCALIZED_NAMES = { + "da_DK": ("Fødselsdage",), + } + + +class CrawlerData(Folder): + CONTAINER_CLASS = "IPF.StoreItem.CrawlerData" + + +class EventCheckPoints(Folder): + CONTAINER_CLASS = "IPF.StoreItem.EventCheckPoints" + + +class FreeBusyCache(Folder): + CONTAINER_CLASS = "IPF.StoreItem.FreeBusyCache" + + +class RecoveryPoints(Folder): + CONTAINER_CLASS = "IPF.StoreItem.RecoveryPoints" + + +class SkypeTeamsMessages(Folder): + CONTAINER_CLASS = "IPF.SkypeTeams.Message" + LOCALIZED_NAMES = { + None: ("Team-chat",), + } + + +class SwssItems(Folder): + CONTAINER_CLASS = "IPF.StoreItem.SwssItems" + + +class WellknownFolder(Folder, metaclass=EWSMeta): + """Base class to use until we have a more specific folder implementation for this folder.""" + + supported_item_models = ITEM_CLASSES + + +class Messages(WellknownFolder): + CONTAINER_CLASS = "IPF.Note" + supported_item_models = (Message, MeetingRequest, MeetingResponse, MeetingCancellation) + + +class Calendar(WellknownFolder): """An interface for the Exchange calendar.""" DISTINGUISHED_FOLDER_ID = "calendar" CONTAINER_CLASS = "IPF.Appointment" supported_item_models = (CalendarItem,) - LOCALIZED_NAMES = { "da_DK": ("Kalender",), "de_DE": ("Kalender",), @@ -39,11 +83,27 @@ def view(self, *args, **kwargs): return FolderCollection(account=self.account, folders=[self]).view(*args, **kwargs) -class DeletedItems(Folder): +class Contacts(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "contacts" + CONTAINER_CLASS = "IPF.Contact" + supported_item_models = (Contact, DistributionList) + LOCALIZED_NAMES = { + "da_DK": ("Kontaktpersoner",), + "de_DE": ("Kontakte",), + "en_US": ("Contacts",), + "es_ES": ("Contactos",), + "fr_CA": ("Contacts",), + "nl_NL": ("Contactpersonen",), + "ru_RU": ("Контакты",), + "sv_SE": ("Kontakter",), + "zh_CN": ("联系人",), + } + + +class DeletedItems(WellknownFolder): DISTINGUISHED_FOLDER_ID = "deleteditems" CONTAINER_CLASS = "IPF.Note" supported_item_models = ITEM_CLASSES - LOCALIZED_NAMES = { "da_DK": ("Slettet post",), "de_DE": ("Gelöschte Elemente",), @@ -57,53 +117,8 @@ class DeletedItems(Folder): } -class Messages(Folder): - CONTAINER_CLASS = "IPF.Note" - supported_item_models = (Message, MeetingRequest, MeetingResponse, MeetingCancellation) - - -class CrawlerData(Folder): - CONTAINER_CLASS = "IPF.StoreItem.CrawlerData" - - -class DlpPolicyEvaluation(Folder): - CONTAINER_CLASS = "IPF.StoreItem.DlpPolicyEvaluation" - - -class FreeBusyCache(Folder): - CONTAINER_CLASS = "IPF.StoreItem.FreeBusyCache" - - -class RecoveryPoints(Folder): - CONTAINER_CLASS = "IPF.StoreItem.RecoveryPoints" - - -class SwssItems(Folder): - CONTAINER_CLASS = "IPF.StoreItem.SwssItems" - - -class EventCheckPoints(Folder): - CONTAINER_CLASS = "IPF.StoreItem.EventCheckPoints" - - -class SkypeTeamsMessages(Folder): - CONTAINER_CLASS = "IPF.SkypeTeams.Message" - LOCALIZED_NAMES = { - None: ("Team-chat",), - } - - -class Birthdays(Folder): - CONTAINER_CLASS = "IPF.Appointment.Birthday" - LOCALIZED_NAMES = { - None: ("Birthdays",), - "da_DK": ("Fødselsdage",), - } - - class Drafts(Messages): DISTINGUISHED_FOLDER_ID = "drafts" - LOCALIZED_NAMES = { "da_DK": ("Kladder",), "de_DE": ("Entwürfe",), @@ -119,7 +134,6 @@ class Drafts(Messages): class Inbox(Messages): DISTINGUISHED_FOLDER_ID = "inbox" - LOCALIZED_NAMES = { "da_DK": ("Indbakke",), "de_DE": ("Posteingang",), @@ -133,9 +147,23 @@ class Inbox(Messages): } +class JunkEmail(Messages): + DISTINGUISHED_FOLDER_ID = "junkemail" + LOCALIZED_NAMES = { + "da_DK": ("Uønsket e-mail",), + "de_DE": ("Junk-E-Mail",), + "en_US": ("Junk E-mail",), + "es_ES": ("Correo no deseado",), + "fr_CA": ("Courrier indésirables",), + "nl_NL": ("Ongewenste e-mail",), + "ru_RU": ("Нежелательная почта",), + "sv_SE": ("Skräppost",), + "zh_CN": ("垃圾邮件",), + } + + class Outbox(Messages): DISTINGUISHED_FOLDER_ID = "outbox" - LOCALIZED_NAMES = { "da_DK": ("Udbakke",), "de_DE": ("Postausgang",), @@ -151,7 +179,6 @@ class Outbox(Messages): class SentItems(Messages): DISTINGUISHED_FOLDER_ID = "sentitems" - LOCALIZED_NAMES = { "da_DK": ("Sendt post",), "de_DE": ("Gesendete Elemente",), @@ -165,27 +192,10 @@ class SentItems(Messages): } -class JunkEmail(Messages): - DISTINGUISHED_FOLDER_ID = "junkemail" - - LOCALIZED_NAMES = { - "da_DK": ("Uønsket e-mail",), - "de_DE": ("Junk-E-Mail",), - "en_US": ("Junk E-mail",), - "es_ES": ("Correo no deseado",), - "fr_CA": ("Courrier indésirables",), - "nl_NL": ("Ongewenste e-mail",), - "ru_RU": ("Нежелательная почта",), - "sv_SE": ("Skräppost",), - "zh_CN": ("垃圾邮件",), - } - - -class Tasks(Folder): +class Tasks(WellknownFolder): DISTINGUISHED_FOLDER_ID = "tasks" CONTAINER_CLASS = "IPF.Task" supported_item_models = (Task,) - LOCALIZED_NAMES = { "da_DK": ("Opgaver",), "de_DE": ("Aufgaben",), @@ -199,34 +209,30 @@ class Tasks(Folder): } -class Contacts(Folder): - DISTINGUISHED_FOLDER_ID = "contacts" - CONTAINER_CLASS = "IPF.Contact" - supported_item_models = (Contact, DistributionList) +class AdminAuditLogs(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "adminauditlogs" + supported_from = EXCHANGE_2013 + get_folder_allowed = False - LOCALIZED_NAMES = { - "da_DK": ("Kontaktpersoner",), - "de_DE": ("Kontakte",), - "en_US": ("Contacts",), - "es_ES": ("Contactos",), - "fr_CA": ("Contacts",), - "nl_NL": ("Contactpersonen",), - "ru_RU": ("Контакты",), - "sv_SE": ("Kontakter",), - "zh_CN": ("联系人",), - } +class AllContacts(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "allcontacts" + CONTAINER_CLASS = "IPF.Note" -class WellknownFolder(Folder, metaclass=EWSMeta): - """Base class to use until we have a more specific folder implementation for this folder.""" - supported_item_models = ITEM_CLASSES +class AllItems(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "allitems" + CONTAINER_CLASS = "IPF" -class AdminAuditLogs(WellknownFolder): - DISTINGUISHED_FOLDER_ID = "adminauditlogs" - supported_from = EXCHANGE_2013 - get_folder_allowed = False +class AllCategorizedItems(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "allcategorizeditems" + CONTAINER_CLASS = "IPF.Note" + + +class AllPersonMetadata(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "allpersonmetadata" + CONTAINER_CLASS = "IPF.Note" class ArchiveDeletedItems(WellknownFolder): @@ -264,6 +270,15 @@ class ArchiveRecoverableItemsVersions(WellknownFolder): supported_from = EXCHANGE_2010_SP1 +class Companies(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "companycontacts" + CONTAINER_CLASS = "IPF.Contact.Company" + supported_item_models = (Contact, DistributionList) + LOCALIZED_NAMES = { + "da_DK": ("Firmaer",), + } + + class Conflicts(WellknownFolder): DISTINGUISHED_FOLDER_ID = "conflicts" supported_from = EXCHANGE_2013 @@ -279,18 +294,42 @@ class Directory(WellknownFolder): supported_from = EXCHANGE_2013_SP1 +class DlpPolicyEvaluation(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "dlppolicyevaluation" + CONTAINER_CLASS = "IPF.StoreItem.DlpPolicyEvaluation" + + class Favorites(WellknownFolder): CONTAINER_CLASS = "IPF.Note" DISTINGUISHED_FOLDER_ID = "favorites" supported_from = EXCHANGE_2013 +class FolderMemberships(Folder): + CONTAINER_CLASS = "IPF.Task" + LOCALIZED_NAMES = { + None: ("Folder Memberships",), + } + + +class FromFavoriteSenders(WellknownFolder): + CONTAINER_CLASS = "IPF.Note" + DISTINGUISHED_FOLDER_ID = "fromfavoritesenders" + LOCALIZED_NAMES = { + "da_DK": ("Personer jeg kender",), + } + + class IMContactList(WellknownFolder): CONTAINER_CLASS = "IPF.Contact.MOC.ImContactList" DISTINGUISHED_FOLDER_ID = "imcontactlist" supported_from = EXCHANGE_2013 +class Inference(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "inference" + + class Journal(WellknownFolder): CONTAINER_CLASS = "IPF.Journal" DISTINGUISHED_FOLDER_ID = "journal" @@ -326,23 +365,68 @@ class Notes(WellknownFolder): } +class OneNotePagePreviews(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "onenotepagepreviews" + + +class PeopleCentricConversationBuddies(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "peoplecentricconversationbuddies" + CONTAINER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies" + LOCALIZED_NAMES = { + None: ("PeopleCentricConversation Buddies",), + } + + class PeopleConnect(WellknownFolder): DISTINGUISHED_FOLDER_ID = "peopleconnect" supported_from = EXCHANGE_2013 +class QedcDefaultRetention(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "qedcdefaultretention" + + +class QedcLongRetention(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "qedclongretention" + + +class QedcMediumRetention(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "qedcmediumretention" + + +class QedcShortRetention(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "qedcshortretention" + + +class QuarantinedEmail(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "quarantinedemail" + + +class QuarantinedEmailDefaultCategory(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "quarantinedemaildefaultcategory" + + class QuickContacts(WellknownFolder): CONTAINER_CLASS = "IPF.Contact.MOC.QuickContacts" DISTINGUISHED_FOLDER_ID = "quickcontacts" supported_from = EXCHANGE_2013 -class RecipientCache(Contacts): +class RecipientCache(WellknownFolder): DISTINGUISHED_FOLDER_ID = "recipientcache" CONTAINER_CLASS = "IPF.Contact.RecipientCache" supported_from = EXCHANGE_2013 + LOCALIZED_NAMES = { + None: ("RecipientCache",), + } - LOCALIZED_NAMES = {} + +class RelevantContacts(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "relevantcontacts" + CONTAINER_CLASS = "IPF.Note" + LOCALIZED_NAMES = { + None: ("RelevantContacts",), + } class RecoverableItemsDeletions(WellknownFolder): @@ -360,6 +444,14 @@ class RecoverableItemsRoot(WellknownFolder): supported_from = EXCHANGE_2010_SP1 +class RecoverableItemsSubstrateHolds(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "recoverableitemssubstrateholds" + supported_from = EXCHANGE_2010_SP1 + LOCALIZED_NAMES = { + None: ("SubstrateHolds",), + } + + class RecoverableItemsVersions(WellknownFolder): DISTINGUISHED_FOLDER_ID = "recoverableitemsversions" supported_from = EXCHANGE_2010_SP1 @@ -374,22 +466,38 @@ class ServerFailures(WellknownFolder): supported_from = EXCHANGE_2013 +class SharePointNotifications(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "sharepointnotifications" + + +class ShortNotes(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "shortnotes" + + class SyncIssues(WellknownFolder): CONTAINER_CLASS = "IPF.Note" DISTINGUISHED_FOLDER_ID = "syncissues" supported_from = EXCHANGE_2013 +class TemporarySaves(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "temporarysaves" + + class ToDoSearch(WellknownFolder): CONTAINER_CLASS = "IPF.Task" DISTINGUISHED_FOLDER_ID = "todosearch" supported_from = EXCHANGE_2013 - LOCALIZED_NAMES = { None: ("To-Do Search",), } +class UserCuratedContacts(WellknownFolder): + CONTAINER_CLASS = "IPF.Note" + DISTINGUISHED_FOLDER_ID = "usercuratedcontacts" + + class VoiceMail(WellknownFolder): DISTINGUISHED_FOLDER_ID = "voicemail" CONTAINER_CLASS = "IPF.Note.Microsoft.Voicemail" @@ -398,7 +506,7 @@ class VoiceMail(WellknownFolder): } -class NonDeletableFolderMixIn: +class NonDeletableFolder(Folder): """A mixin for non-wellknown folders than that are not deletable.""" @property @@ -406,274 +514,205 @@ def is_deletable(self): return False -class AllContacts(NonDeletableFolderMixIn, Contacts): - CONTAINER_CLASS = "IPF.Note" - +class ExternalContacts(NonDeletableFolder): + DISTINGUISHED_FOLDER_ID = None + CONTAINER_CLASS = "IPF.Contact" + supported_item_models = (Contact, DistributionList) LOCALIZED_NAMES = { - None: ("AllContacts",), + None: ("ExternalContacts",), } -class AllItems(NonDeletableFolderMixIn, Folder): - CONTAINER_CLASS = "IPF" - +class AllTodoTasks(NonDeletableFolder): + DISTINGUISHED_FOLDER_ID = None + CONTAINER_CLASS = "IPF.Task" + supported_item_models = (Task,) LOCALIZED_NAMES = { - None: ("AllItems",), + None: ("AllTodoTasks",), } -class ApplicationData(NonDeletableFolderMixIn, Folder): +class ApplicationData(Folder): CONTAINER_CLASS = "IPM.ApplicationData" -class Audits(NonDeletableFolderMixIn, Folder): - LOCALIZED_NAMES = { - None: ("Audits",), - } +class Audits(NonDeletableFolder): get_folder_allowed = False -class CalendarLogging(NonDeletableFolderMixIn, Folder): +class CalendarLogging(NonDeletableFolder): LOCALIZED_NAMES = { None: ("Calendar Logging",), } -class CommonViews(NonDeletableFolderMixIn, Folder): +class CommonViews(NonDeletableFolder): DEFAULT_ITEM_TRAVERSAL_DEPTH = ASSOCIATED LOCALIZED_NAMES = { None: ("Common Views",), } -class Companies(NonDeletableFolderMixIn, Contacts): - DISTINGUISHED_FOLDER_ID = None - CONTAINER_CLASS = "IPF.Contact.Company" - LOCALIZED_NAMES = { - None: ("Companies",), - "da_DK": ("Firmaer",), - } - - -class ConversationSettings(NonDeletableFolderMixIn, Folder): +class ConversationSettings(NonDeletableFolder): CONTAINER_CLASS = "IPF.Configuration" LOCALIZED_NAMES = { "da_DK": ("Indstillinger for samtalehandlinger",), } -class DefaultFoldersChangeHistory(NonDeletableFolderMixIn, Folder): +class DefaultFoldersChangeHistory(NonDeletableFolder): CONTAINER_CLASS = "IPM.DefaultFolderHistoryItem" - LOCALIZED_NAMES = { - None: ("DefaultFoldersChangeHistory",), - } -class DeferredAction(NonDeletableFolderMixIn, Folder): +class DeferredAction(NonDeletableFolder): LOCALIZED_NAMES = { None: ("Deferred Action",), } -class ExchangeSyncData(NonDeletableFolderMixIn, Folder): - LOCALIZED_NAMES = { - None: ("ExchangeSyncData",), - } +class ExchangeSyncData(NonDeletableFolder): + pass -class Files(NonDeletableFolderMixIn, Folder): +class Files(NonDeletableFolder): CONTAINER_CLASS = "IPF.Files" - LOCALIZED_NAMES = { "da_DK": ("Filer",), } -class FreebusyData(NonDeletableFolderMixIn, Folder): +class FreebusyData(NonDeletableFolder): LOCALIZED_NAMES = { None: ("Freebusy Data",), } -class Friends(NonDeletableFolderMixIn, Contacts): +class Friends(NonDeletableFolder): CONTAINER_CLASS = "IPF.Note" - + supported_item_models = (Contact, DistributionList) LOCALIZED_NAMES = { "de_DE": ("Bekannte",), } -class GALContacts(NonDeletableFolderMixIn, Contacts): - DISTINGUISHED_FOLDER_ID = None +class GALContacts(NonDeletableFolder): CONTAINER_CLASS = "IPF.Contact.GalContacts" - + supported_item_models = (Contact, DistributionList) LOCALIZED_NAMES = { None: ("GAL Contacts",), } -class GraphAnalytics(NonDeletableFolderMixIn, Folder): +class GraphAnalytics(NonDeletableFolder): CONTAINER_CLASS = "IPF.StoreItem.GraphAnalytics" - LOCALIZED_NAMES = { - None: ("GraphAnalytics",), - } -class Location(NonDeletableFolderMixIn, Folder): - LOCALIZED_NAMES = { - None: ("Location",), - } +class Location(NonDeletableFolder): + pass -class MailboxAssociations(NonDeletableFolderMixIn, Folder): - LOCALIZED_NAMES = { - None: ("MailboxAssociations",), - } +class MailboxAssociations(NonDeletableFolder): + pass -class MyContactsExtended(NonDeletableFolderMixIn, Contacts): +class MyContactsExtended(NonDeletableFolder): CONTAINER_CLASS = "IPF.Note" - LOCALIZED_NAMES = { - None: ("MyContactsExtended",), - } + supported_item_models = (Contact, DistributionList) -class OrganizationalContacts(NonDeletableFolderMixIn, Contacts): - DISTINGUISHED_FOLDER_ID = None +class OrganizationalContacts(NonDeletableFolder): CONTAINER_CLASS = "IPF.Contact.OrganizationalContacts" + supported_item_models = (Contact, DistributionList) LOCALIZED_NAMES = { None: ("Organizational Contacts",), } -class ParkedMessages(NonDeletableFolderMixIn, Folder): +class ParkedMessages(NonDeletableFolder): CONTAINER_CLASS = None - LOCALIZED_NAMES = { - None: ("ParkedMessages",), - } -class PassThroughSearchResults(NonDeletableFolderMixIn, Folder): +class PassThroughSearchResults(NonDeletableFolder): CONTAINER_CLASS = "IPF.StoreItem.PassThroughSearchResults" LOCALIZED_NAMES = { None: ("Pass-Through Search Results",), } -class PeopleCentricConversationBuddies(NonDeletableFolderMixIn, Contacts): - DISTINGUISHED_FOLDER_ID = None - CONTAINER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies" - LOCALIZED_NAMES = { - None: ("PeopleCentricConversation Buddies",), - } - - -class PdpProfileV2Secured(NonDeletableFolderMixIn, Folder): +class PdpProfileV2Secured(NonDeletableFolder): CONTAINER_CLASS = "IPF.StoreItem.PdpProfileSecured" - LOCALIZED_NAMES = { - None: ("PdpProfileV2Secured",), - } -class Reminders(NonDeletableFolderMixIn, Folder): +class Reminders(NonDeletableFolder): CONTAINER_CLASS = "Outlook.Reminder" LOCALIZED_NAMES = { "da_DK": ("Påmindelser",), } -class RSSFeeds(NonDeletableFolderMixIn, Folder): +class RSSFeeds(NonDeletableFolder): CONTAINER_CLASS = "IPF.Note.OutlookHomepage" LOCALIZED_NAMES = { None: ("RSS Feeds",), } -class Schedule(NonDeletableFolderMixIn, Folder): - LOCALIZED_NAMES = { - None: ("Schedule",), - } +class Schedule(NonDeletableFolder): + pass -class Sharing(NonDeletableFolderMixIn, Folder): +class Sharing(NonDeletableFolder): CONTAINER_CLASS = "IPF.Note" - LOCALIZED_NAMES = { - None: ("Sharing",), - } -class Shortcuts(NonDeletableFolderMixIn, Folder): - LOCALIZED_NAMES = { - None: ("Shortcuts",), - } +class Shortcuts(NonDeletableFolder): + pass -class Signal(NonDeletableFolderMixIn, Folder): +class Signal(NonDeletableFolder): CONTAINER_CLASS = "IPF.StoreItem.Signal" - LOCALIZED_NAMES = { - None: ("Signal",), - } -class SmsAndChatsSync(NonDeletableFolderMixIn, Folder): +class SmsAndChatsSync(NonDeletableFolder): CONTAINER_CLASS = "IPF.SmsAndChatsSync" - LOCALIZED_NAMES = { - None: ("SmsAndChatsSync",), - } -class SpoolerQueue(NonDeletableFolderMixIn, Folder): +class SpoolerQueue(NonDeletableFolder): LOCALIZED_NAMES = { None: ("Spooler Queue",), } -class System(NonDeletableFolderMixIn, Folder): - LOCALIZED_NAMES = { - None: ("System",), - } +class System(NonDeletableFolder): get_folder_allowed = False -class System1(NonDeletableFolderMixIn, Folder): - LOCALIZED_NAMES = { - None: ("System1",), - } +class System1(NonDeletableFolder): get_folder_allowed = False -class TemporarySaves(NonDeletableFolderMixIn, Folder): - LOCALIZED_NAMES = { - None: ("TemporarySaves",), - } - - -class Views(NonDeletableFolderMixIn, Folder): - LOCALIZED_NAMES = { - None: ("Views",), - } +class Views(NonDeletableFolder): + pass -class WorkingSet(NonDeletableFolderMixIn, Folder): +class WorkingSet(NonDeletableFolder): LOCALIZED_NAMES = { None: ("Working Set",), } -# Folders that return 'ErrorDeleteDistinguishedFolder' when we try to delete them. I can't find any official docs -# listing these folders. +# Folders that do not have a distinguished folder ID but return 'ErrorDeleteDistinguishedFolder' when we try to delete +# them. I can't find any official docs listing these folders. NON_DELETABLE_FOLDERS = [ - AllContacts, - AllItems, - ApplicationData, + AllTodoTasks, Audits, CalendarLogging, CommonViews, - Companies, ConversationSettings, DefaultFoldersChangeHistory, DeferredAction, ExchangeSyncData, + ExternalContacts, FreebusyData, Files, Friends, @@ -685,7 +724,6 @@ class WorkingSet(NonDeletableFolderMixIn, Folder): OrganizationalContacts, ParkedMessages, PassThroughSearchResults, - PeopleCentricConversationBuddies, PdpProfileV2Secured, Reminders, RSSFeeds, @@ -697,22 +735,30 @@ class WorkingSet(NonDeletableFolderMixIn, Folder): SpoolerQueue, System, System1, - TemporarySaves, Views, WorkingSet, ] +# Folders that have a distinguished ID and are located in the root folder hierarchy WELLKNOWN_FOLDERS_IN_ROOT = [ AdminAuditLogs, + AllCategorizedItems, + AllContacts, + AllItems, + AllPersonMetadata, Calendar, + Companies, Conflicts, Contacts, ConversationHistory, DeletedItems, Directory, + DlpPolicyEvaluation, Drafts, Favorites, + FromFavoriteSenders, IMContactList, + Inference, Inbox, Journal, JunkEmail, @@ -720,23 +766,38 @@ class WorkingSet(NonDeletableFolderMixIn, Folder): MsgFolderRoot, MyContacts, Notes, + OneNotePagePreviews, Outbox, + PeopleCentricConversationBuddies, PeopleConnect, + QedcDefaultRetention, + QedcLongRetention, + QedcMediumRetention, + QedcShortRetention, + QuarantinedEmail, + QuarantinedEmailDefaultCategory, QuickContacts, RecipientCache, + RelevantContacts, RecoverableItemsDeletions, RecoverableItemsPurges, RecoverableItemsRoot, + RecoverableItemsSubstrateHolds, RecoverableItemsVersions, SearchFolders, SentItems, ServerFailures, + SharePointNotifications, + ShortNotes, SyncIssues, Tasks, + TemporarySaves, ToDoSearch, + UserCuratedContacts, VoiceMail, ] +# Folders that have a distinguished ID and are located in the archive root folder hierarchy WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT = [ ArchiveDeletedItems, ArchiveInbox, @@ -747,9 +808,12 @@ class WorkingSet(NonDeletableFolderMixIn, Folder): ArchiveRecoverableItemsVersions, ] +# Folders that do not have a distinguished ID but have their own container class MISC_FOLDERS = [ + ApplicationData, CrawlerData, - DlpPolicyEvaluation, + EventCheckPoints, + FolderMemberships, FreeBusyCache, RecoveryPoints, SwssItems, diff --git a/tests/test_folder.py b/tests/test_folder.py index b6c483ae..2fb0ae98 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -1,8 +1,11 @@ from contextlib import suppress +from inspect import isclass from unittest.mock import Mock import requests_mock +import exchangelib.folders +import exchangelib.folders.known_folders from exchangelib.errors import ( DoesNotExist, ErrorCannotEmptyFolder, @@ -18,11 +21,14 @@ ) from exchangelib.extended_properties import ExtendedProperty from exchangelib.folders import ( - NON_DELETABLE_FOLDERS, SHALLOW, + AllCategorizedItems, AllContacts, AllItems, + AllPersonMetadata, + AllTodoTasks, ApplicationData, + BaseFolder, Birthdays, Calendar, CommonViews, @@ -36,13 +42,16 @@ DlpPolicyEvaluation, Drafts, EventCheckPoints, + ExternalContacts, Favorites, Files, Folder, FolderCollection, + FolderMemberships, FolderQuerySet, FreeBusyCache, Friends, + FromFavoriteSenders, GALContacts, GraphAnalytics, IMContactList, @@ -52,6 +61,7 @@ Messages, MyContacts, MyContactsExtended, + NonDeletableFolder, Notes, OrganizationalContacts, Outbox, @@ -62,6 +72,7 @@ QuickContacts, RecipientCache, RecoveryPoints, + RelevantContacts, Reminders, RootOfHierarchy, RSSFeeds, @@ -75,7 +86,15 @@ SyncIssues, Tasks, ToDoSearch, + UserCuratedContacts, VoiceMail, + WellknownFolder, +) +from exchangelib.folders.known_folders import ( + MISC_FOLDERS, + NON_DELETABLE_FOLDERS, + WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT, + WELLKNOWN_FOLDERS_IN_ROOT, ) from exchangelib.items import Message from exchangelib.properties import CalendarPermission, EffectiveRights, InvalidField, Mailbox, PermissionSet, UserId @@ -498,18 +517,28 @@ def test_get_folders_with_distinguished_id(self): def test_folder_grouping(self): # If you get errors here, you probably need to fill out [folder class].LOCALIZED_NAMES for your locale. for f in self.account.root.walk(): + with self.subTest(f=f): + if f.is_distinguished: + self.assertIsNotNone(f.DISTINGUISHED_FOLDER_ID) + else: + self.assertIsNone(f.DISTINGUISHED_FOLDER_ID) with self.subTest(f=f): if isinstance( f, ( Messages, DeletedItems, + AllCategorizedItems, AllContacts, + AllPersonMetadata, MyContactsExtended, Sharing, Favorites, + FromFavoriteSenders, + RelevantContacts, SyncIssues, MyContacts, + UserCuratedContacts, ), ): self.assertEqual(f.folder_class, "IPF.Note") @@ -549,13 +578,13 @@ def test_folder_grouping(self): self.assertEqual(f.folder_class, "IPF.Contact.MOC.ImContactList") elif isinstance(f, QuickContacts): self.assertEqual(f.folder_class, "IPF.Contact.MOC.QuickContacts") - elif isinstance(f, Contacts): + elif isinstance(f, (Contacts, ExternalContacts)): self.assertEqual(f.folder_class, "IPF.Contact") elif isinstance(f, Birthdays): self.assertEqual(f.folder_class, "IPF.Appointment.Birthday") elif isinstance(f, Calendar): self.assertEqual(f.folder_class, "IPF.Appointment") - elif isinstance(f, (Tasks, ToDoSearch)): + elif isinstance(f, (Tasks, ToDoSearch, AllTodoTasks, FolderMemberships)): self.assertEqual(f.folder_class, "IPF.Task") elif isinstance(f, Reminders): self.assertEqual(f.folder_class, "Outlook.Reminder") @@ -973,9 +1002,64 @@ def test_generic_folder(self): def test_non_deletable_folders(self): for f in self.account.root.walk(): - if f.__class__ not in NON_DELETABLE_FOLDERS: + with self.subTest(item=f): + if f.__class__ in NON_DELETABLE_FOLDERS + WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT + WELLKNOWN_FOLDERS_IN_ROOT: + self.assertEqual(f.is_deletable, False) + with self.assertRaises(ErrorDeleteDistinguishedFolder): + f.delete() + else: + self.assertEqual(f.is_deletable, True) + # Don't attempt to delete. That could affect parallel tests + + def test_folder_collections(self): + # Test that all custom folders are exposed in the top-level module + top_level_classes = [ + cls + for cls in vars(exchangelib.folders).values() + if isclass(cls) and issubclass(cls, exchangelib.folders.BaseFolder) + ] + known_folder_classes = [ + cls + for cls in vars(exchangelib.folders.known_folders).values() + if isclass(cls) and issubclass(cls, exchangelib.folders.BaseFolder) + ] + for cls in known_folder_classes: + with self.subTest(item=cls): + self.assertIn(cls, top_level_classes) + + # Test that all custom folders are in one of the following folder collections + all_cls = NON_DELETABLE_FOLDERS + WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT + WELLKNOWN_FOLDERS_IN_ROOT + MISC_FOLDERS + for cls in top_level_classes: + if not isclass(cls) or not issubclass(cls, BaseFolder): continue - self.assertEqual(f.is_deletable, False) + with self.subTest(item=cls): + if cls in NON_DELETABLE_FOLDERS + [NonDeletableFolder]: + self.assertTrue(issubclass(cls, NonDeletableFolder)) + elif cls in WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT + WELLKNOWN_FOLDERS_IN_ROOT + [WellknownFolder, Messages]: + self.assertTrue(issubclass(cls, WellknownFolder)) + else: + self.assertFalse(issubclass(cls, WellknownFolder)) + self.assertFalse(issubclass(cls, NonDeletableFolder)) + with self.subTest(item=cls): + if cls in WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT + WELLKNOWN_FOLDERS_IN_ROOT: + self.assertIsNotNone(cls.DISTINGUISHED_FOLDER_ID) + elif cls in (BaseFolder, Folder, WellknownFolder, RootOfHierarchy): + self.assertIsNone(cls.DISTINGUISHED_FOLDER_ID) + elif issubclass(cls, RootOfHierarchy): + self.assertIsNotNone(cls.DISTINGUISHED_FOLDER_ID) + else: + self.assertIsNone(cls.DISTINGUISHED_FOLDER_ID) + with self.subTest(item=cls): + if issubclass(cls, RootOfHierarchy) or cls in ( + BaseFolder, + Folder, + WellknownFolder, + Messages, + NonDeletableFolder, + ): + self.assertNotIn(cls, all_cls) + else: + self.assertIn(cls, all_cls) def test_folder_query_set(self): # Create a folder hierarchy and test a folder queryset From f0c661ccda0dbddeac4dc8f4127e08a20fe1ed53 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 18 Mar 2024 12:58:35 +0100 Subject: [PATCH 408/509] fix: ApplicationData is not deletable but throws a different error --- exchangelib/folders/known_folders.py | 8 ++++---- tests/test_folder.py | 5 ++++- tests/test_items/test_basics.py | 4 +++- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/exchangelib/folders/known_folders.py b/exchangelib/folders/known_folders.py index 29c36ac1..73dcde99 100644 --- a/exchangelib/folders/known_folders.py +++ b/exchangelib/folders/known_folders.py @@ -532,7 +532,7 @@ class AllTodoTasks(NonDeletableFolder): } -class ApplicationData(Folder): +class ApplicationData(NonDeletableFolder): CONTAINER_CLASS = "IPM.ApplicationData" @@ -701,9 +701,10 @@ class WorkingSet(NonDeletableFolder): } -# Folders that do not have a distinguished folder ID but return 'ErrorDeleteDistinguishedFolder' when we try to delete -# them. I can't find any official docs listing these folders. +# Folders that do not have a distinguished folder ID but return 'ErrorDeleteDistinguishedFolder' or +# 'ErrorCannotDeleteObject' when we try to delete them. I can't find any official docs listing these folders. NON_DELETABLE_FOLDERS = [ + ApplicationData, AllTodoTasks, Audits, CalendarLogging, @@ -810,7 +811,6 @@ class WorkingSet(NonDeletableFolder): # Folders that do not have a distinguished ID but have their own container class MISC_FOLDERS = [ - ApplicationData, CrawlerData, EventCheckPoints, FolderMemberships, diff --git a/tests/test_folder.py b/tests/test_folder.py index 2fb0ae98..3085d2d3 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -8,6 +8,7 @@ import exchangelib.folders.known_folders from exchangelib.errors import ( DoesNotExist, + ErrorCannotDeleteObject, ErrorCannotEmptyFolder, ErrorDeleteDistinguishedFolder, ErrorFolderExists, @@ -1005,8 +1006,10 @@ def test_non_deletable_folders(self): with self.subTest(item=f): if f.__class__ in NON_DELETABLE_FOLDERS + WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT + WELLKNOWN_FOLDERS_IN_ROOT: self.assertEqual(f.is_deletable, False) - with self.assertRaises(ErrorDeleteDistinguishedFolder): + try: f.delete() + except (ErrorDeleteDistinguishedFolder, ErrorCannotDeleteObject): + pass else: self.assertEqual(f.is_deletable, True) # Don't attempt to delete. That could affect parallel tests diff --git a/tests/test_items/test_basics.py b/tests/test_items/test_basics.py index 7f49b51d..ec19ed16 100644 --- a/tests/test_items/test_basics.py +++ b/tests/test_items/test_basics.py @@ -317,8 +317,10 @@ def test_queryset_nonsearchable_fields(self): # Filtering is accepted but doesn't work self.assertEqual(self.test_folder.filter(**filter_kwargs).count(), 0) else: - with self.assertRaises((ErrorUnsupportedPathForQuery, ErrorInvalidValueForProperty)): + try: list(self.test_folder.filter(**filter_kwargs)) + except (ErrorUnsupportedPathForQuery, ErrorInvalidValueForProperty): + pass finally: f.is_searchable = False From 1fa4a0500a9d184e8463a5360c7308f641052bc7 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 18 Mar 2024 13:17:49 +0100 Subject: [PATCH 409/509] test: add ome more possible exception --- tests/test_folder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_folder.py b/tests/test_folder.py index 3085d2d3..2218c95a 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -1008,7 +1008,7 @@ def test_non_deletable_folders(self): self.assertEqual(f.is_deletable, False) try: f.delete() - except (ErrorDeleteDistinguishedFolder, ErrorCannotDeleteObject): + except (ErrorDeleteDistinguishedFolder, ErrorCannotDeleteObject, ErrorItemNotFound): pass else: self.assertEqual(f.is_deletable, True) From 602086651744dbcff45d6775005faca3b5ab3605 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 18 Mar 2024 13:35:36 +0100 Subject: [PATCH 410/509] test: update to latest actions --- .github/workflows/codeql.yml | 14 +++++++------- .github/workflows/dependency-review.yml | 4 ++-- .github/workflows/python-package.yml | 8 ++++---- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b96d4349..12bba736 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -38,30 +38,30 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. - + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality - + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - # If the Autobuild fails above, remove it and uncomment the following three lines. + # If the Autobuild fails above, remove it and uncomment the following three lines. # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. # - run: | @@ -69,6 +69,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index fe461b42..0d4a0136 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -15,6 +15,6 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout Repository' - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: 'Dependency Review' - uses: actions/dependency-review-action@v2 + uses: actions/dependency-review-action@v4 diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index e25ef7ae..d73782ed 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -37,10 +37,10 @@ jobs: max-parallel: 1 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -91,10 +91,10 @@ jobs: if: ${{ always() }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' From bab137aab04749df227c92ca942d867bbb485f21 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 18 Mar 2024 14:09:19 +0100 Subject: [PATCH 411/509] fix: get() should never return an exception instance --- exchangelib/queryset.py | 5 ++++- tests/test_items/test_queryset.py | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/exchangelib/queryset.py b/exchangelib/queryset.py index 18047966..55bba536 100644 --- a/exchangelib/queryset.py +++ b/exchangelib/queryset.py @@ -529,7 +529,10 @@ def get(self, *args, **kwargs): raise DoesNotExist() if len(items) != 1: raise MultipleObjectsReturned() - return items[0] + item = items[0] + if isinstance(item, Exception): + raise item + return item def count(self, page_size=1000): """Get the query count, with as little effort as possible diff --git a/tests/test_items/test_queryset.py b/tests/test_items/test_queryset.py index f871a578..b3ae38e0 100644 --- a/tests/test_items/test_queryset.py +++ b/tests/test_items/test_queryset.py @@ -1,5 +1,6 @@ import time +from exchangelib.errors import ErrorItemNotFound from exchangelib.folders import FolderCollection, Inbox from exchangelib.items import ASSOCIATED, SHALLOW, Message from exchangelib.queryset import DoesNotExist, MultipleObjectsReturned, QuerySet @@ -192,6 +193,11 @@ def test_queryset_get_by_id(self): self.assertEqual(item.changekey, get_item.changekey) self.assertEqual(item.subject, get_item.subject) self.assertEqual(item.body, get_item.body) + item_id = item.id + item.delete() + with self.assertRaises(ErrorItemNotFound): + self.test_folder.get(id=item_id, changekey=None) + item = self.get_test_item().save() # Test a get() from queryset get_item = self.test_folder.all().get(id=item.id, changekey=item.changekey) From 72df4eebfac17be1c790d5b2d38630e5c124121d Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 26 Mar 2024 10:32:04 +0100 Subject: [PATCH 412/509] fix: fix and test more inbox rule actions. Fixes #1280 #1283 --- exchangelib/fields.py | 13 ++++++++ exchangelib/properties.py | 31 +++++++----------- exchangelib/services/inbox_rules.py | 50 +++++++++++------------------ tests/common.py | 2 ++ tests/test_account.py | 49 +++++++++++++++++++++------- 5 files changed, 82 insertions(+), 63 deletions(-) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index 76f51b67..3e59d5e3 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -1175,6 +1175,19 @@ def clean(self, value, version=None): return super().clean(value, version=version) +class AddressListField(EWSElementListField): + def __init__(self, *args, **kwargs): + from .properties import Address + + kwargs["value_cls"] = Address + super().__init__(*args, **kwargs) + + def clean(self, value, version=None): + if value is not None: + value = [self.value_cls(email_address=s) if isinstance(s, str) else s for s in value] + return super().clean(value, version=version) + + class MemberListField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import Member diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 96947dcc..e68ed397 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -17,6 +17,7 @@ ) from .fields import ( WEEKDAY_NAMES, + AddressListField, AssociatedCalendarItemIdField, Base64Field, BooleanField, @@ -2226,10 +2227,9 @@ class CopyToFolder(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/copytofolder""" ELEMENT_NAME = "CopyToFolder" - NAMESPACE = MNS - folder_id = EWSElementField(value_cls=FolderId, field_uri="FolderId") - distinguished_folder_id = EWSElementField(value_cls=DistinguishedFolderId, field_uri="DistinguishedFolderId") + folder_id = EWSElementField(value_cls=FolderId) + distinguished_folder_id = EWSElementField(value_cls=DistinguishedFolderId) class MoveToFolder(CopyToFolder): @@ -2242,21 +2242,18 @@ class Actions(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/actions""" ELEMENT_NAME = "Actions" - NAMESPACE = TNS assign_categories = CharListField(field_uri="AssignCategories") - copy_to_folder = EWSElementField(value_cls=CopyToFolder, field_uri="CopyToFolder") + copy_to_folder = EWSElementField(value_cls=CopyToFolder) delete = BooleanField(field_uri="Delete") - forward_as_attachment_to_recipients = EWSElementField( - value_cls=Mailbox, field_uri="ForwardAsAttachmentToRecipients" - ) - forward_to_recipients = EWSElementField(value_cls=Mailbox, field_uri="ForwardToRecipients") + forward_as_attachment_to_recipients = AddressListField(field_uri="ForwardAsAttachmentToRecipients") + forward_to_recipients = AddressListField(field_uri="ForwardToRecipients") mark_importance = ImportanceField(field_uri="MarkImportance") mark_as_read = BooleanField(field_uri="MarkAsRead") - move_to_folder = EWSElementField(value_cls=MoveToFolder, field_uri="MoveToFolder") + move_to_folder = EWSElementField(value_cls=MoveToFolder) permanent_delete = BooleanField(field_uri="PermanentDelete") - redirect_to_recipients = EWSElementField(value_cls=Mailbox, field_uri="RedirectToRecipients") - send_sms_alert_to_recipients = EWSElementField(value_cls=Mailbox, field_uri="SendSMSAlertToRecipients") + redirect_to_recipients = AddressListField(field_uri="RedirectToRecipients") + send_sms_alert_to_recipients = AddressListField(field_uri="SendSMSAlertToRecipients") server_reply_with_message = EWSElementField(value_cls=ItemId, field_uri="ServerReplyWithMessage") stop_processing_rules = BooleanField(field_uri="StopProcessingRules") @@ -2265,17 +2262,16 @@ class Rule(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/rule-ruletype""" ELEMENT_NAME = "Rule" - NAMESPACE = TNS id = CharField(field_uri="RuleId") - display_name = CharField(field_uri="DisplayName") - priority = IntegerField(field_uri="Priority") + display_name = CharField(field_uri="DisplayName", is_required=True) + priority = IntegerField(field_uri="Priority", is_required=True) is_enabled = BooleanField(field_uri="IsEnabled") is_not_supported = BooleanField(field_uri="IsNotSupported") is_in_error = BooleanField(field_uri="IsInError") conditions = EWSElementField(value_cls=Conditions) exceptions = EWSElementField(value_cls=Exceptions) - actions = EWSElementField(value_cls=Actions) + actions = EWSElementField(value_cls=Actions, is_required=True) class InboxRules(EWSElement): @@ -2291,7 +2287,6 @@ class CreateRuleOperation(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createruleoperation""" ELEMENT_NAME = "CreateRuleOperation" - NAMESPACE = TNS rule = EWSElementField(value_cls=Rule) @@ -2300,7 +2295,6 @@ class SetRuleOperation(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/setruleoperation""" ELEMENT_NAME = "SetRuleOperation" - NAMESPACE = TNS rule = EWSElementField(value_cls=Rule) @@ -2309,7 +2303,6 @@ class DeleteRuleOperation(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteruleoperation""" ELEMENT_NAME = "DeleteRuleOperation" - NAMESPACE = TNS id = CharField(field_uri="RuleId") diff --git a/exchangelib/services/inbox_rules.py b/exchangelib/services/inbox_rules.py index 3545d7c1..eba0772f 100644 --- a/exchangelib/services/inbox_rules.py +++ b/exchangelib/services/inbox_rules.py @@ -62,43 +62,39 @@ class UpdateInboxRules(EWSAccountService): supported_from = EXCHANGE_2010 ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (ErrorInvalidOperation,) - -class CreateInboxRule(UpdateInboxRules): - """ - MSDN: - https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-create-rule-request-example - """ - def call(self, rule: Rule, remove_outlook_rule_blob: bool = True): payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob) return self._get_elements(payload=payload) + def _get_operation(self, rule): + raise NotImplementedError() + def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True): payload = create_element(f"m:{self.SERVICE_NAME}") add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob) - operations = Operations(create_rule_operation=CreateRuleOperation(rule=rule)) + operations = self._get_operation(rule) set_xml_value(payload, operations, version=self.account.version) return payload +class CreateInboxRule(UpdateInboxRules): + """ + MSDN: + https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-create-rule-request-example + """ + + def _get_operation(self, rule): + return Operations(create_rule_operation=CreateRuleOperation(rule=rule)) + + class SetInboxRule(UpdateInboxRules): """ MSDN: https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-set-rule-request-example """ - def call(self, rule: Rule, remove_outlook_rule_blob: bool = True): - payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob) - return self._get_elements(payload=payload) - - def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True): - if not rule.id: - raise ValueError("Rule must have an ID") - payload = create_element(f"m:{self.SERVICE_NAME}") - add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob) - operations = Operations(set_rule_operation=SetRuleOperation(rule=rule)) - set_xml_value(payload, operations, version=self.account.version) - return payload + def _get_operation(self, rule): + return Operations(set_rule_operation=SetRuleOperation(rule=rule)) class DeleteInboxRule(UpdateInboxRules): @@ -107,15 +103,5 @@ class DeleteInboxRule(UpdateInboxRules): https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-delete-rule-request-example """ - def call(self, rule: Rule, remove_outlook_rule_blob: bool = True): - payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob) - return self._get_elements(payload=payload) - - def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True): - if not rule.id: - raise ValueError("Rule must have an ID") - payload = create_element(f"m:{self.SERVICE_NAME}") - add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob) - operations = Operations(delete_rule_operation=DeleteRuleOperation(id=rule.id)) - set_xml_value(payload, operations, version=self.account.version) - return payload + def _get_operation(self, rule): + return Operations(delete_rule_operation=DeleteRuleOperation(id=rule.id)) diff --git a/tests/common.py b/tests/common.py index 49f15fef..d262ecb3 100644 --- a/tests/common.py +++ b/tests/common.py @@ -160,6 +160,8 @@ def setUp(self): def wipe_test_account(self): # Deletes up all deletable items in the test account. Not run in a normal test run self.account.root.wipe() + for rule in self.account.rules: + self.account.delete_rule(rule) def bulk_delete(self, ids): # Clean up items and check return values diff --git a/tests/test_account.py b/tests/test_account.py index 090c1fc9..9a775b02 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -20,11 +20,14 @@ from exchangelib.items import Message from exchangelib.properties import ( Actions, + Address, Conditions, + CopyToFolder, DelegatePermissions, DelegateUser, Exceptions, MailTips, + MoveToFolder, OutOfOffice, RecipientAddress, Rule, @@ -35,7 +38,7 @@ from exchangelib.services import GetDelegate, GetMailTips from exchangelib.version import EXCHANGE_2007_SP1, Version -from .common import EWSTest, get_random_string +from .common import EWSTest, get_random_choice, get_random_email, get_random_int, get_random_string class AccountTest(EWSTest): @@ -342,13 +345,7 @@ def test_protocol_default_values(self): self.assertIsNotNone(a.protocol.auth_type) self.assertIsNotNone(a.protocol.retry_policy) - def test_inbox_rules(self): - # Clean up first - for rule in self.account.rules: - self.account.delete_rule(rule) - - self.assertEqual(len(self.account.rules), 0) - + def test_basic_inbox_rule(self): # Create rule display_name = get_random_string(16) rule = Rule( @@ -363,14 +360,42 @@ def test_inbox_rules(self): self.account.create_rule(rule=rule) self.assertIsNotNone(rule.id) self.assertEqual(len(self.account.rules), 1) - self.assertEqual(self.account.rules[0].display_name, display_name) + self.assertIn(display_name, {r.display_name for r in self.account.rules}) # Update rule rule.display_name = get_random_string(16) self.account.set_rule(rule=rule) - self.assertEqual(len(self.account.rules), 1) - self.assertNotEqual(self.account.rules[0].display_name, display_name) + self.assertIn(rule.display_name, {r.display_name for r in self.account.rules}) + self.assertNotIn(display_name, {r.display_name for r in self.account.rules}) # Delete rule self.account.delete_rule(rule=rule) - self.assertEqual(len(self.account.rules), 0) + self.assertNotIn(rule.display_name, {r.display_name for r in self.account.rules}) + + def test_all_inbox_rule_actions(self): + for action_name, action in { + "assign_categories": ["foo", "bar"], + "copy_to_folder": CopyToFolder(distinguished_folder_id=self.account.trash.to_id()), + "delete": True, # Cannot be random. False would be a no-op action + "forward_as_attachment_to_recipients": [Address(email_address=get_random_email())], + "mark_importance": get_random_choice( + Actions.mark_importance.supported_choices(version=self.account.version) + ), + "mark_as_read": True, # Cannot be random. False would be a no-op action + "move_to_folder": MoveToFolder(distinguished_folder_id=self.account.trash.to_id()), + "permanent_delete": True, # Cannot be random. False would be a no-op action + "redirect_to_recipients": [Address(email_address=get_random_email())], + # TODO: Throws "UnsupportedRule: The operation on this unsupported rule is not allowed." + # "send_sms_alert_to_recipients": [Address(email_address=get_random_email())], + # TODO: throws "InvalidValue: Id must be non-empty." even though we follow MSDN docs + # "server_reply_with_message": Message(folder=self.account.inbox, subject="Foo").save().to_id(), + "stop_processing_rules": True, # Cannot be random. False would be a no-op action + }.items(): + with self.subTest(action_name=action_name, action=action): + rule = Rule( + display_name=get_random_string(16), + priority=get_random_int(), + actions=Actions(**{action_name: action}), + ) + self.account.create_rule(rule=rule) + self.account.delete_rule(rule=rule) From 8c07041f7fab9797836f9da789cdf096d1a79d18 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 26 Mar 2024 10:33:15 +0100 Subject: [PATCH 413/509] chore: just pass account here. We weren't using root anyway --- exchangelib/folders/base.py | 8 ++++---- exchangelib/folders/roots.py | 2 +- tests/test_account.py | 4 ++-- tests/test_folder.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 0db73e88..83d8307a 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -857,18 +857,18 @@ def deregister(cls, *args, **kwargs): return super().deregister(*args, **kwargs) @classmethod - def get_distinguished(cls, root): + def get_distinguished(cls, account): """Get the distinguished folder for this folder class. - :param root: + :param account: :return: """ try: return cls.resolve( - account=root.account, + account=account, folder=DistinguishedFolderId( id=cls.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=root.account.primary_smtp_address), + mailbox=Mailbox(email_address=account.primary_smtp_address), ), ) except MISSING_FOLDER_ERRORS: diff --git a/exchangelib/folders/roots.py b/exchangelib/folders/roots.py index aa2a80b0..4b6d4227 100644 --- a/exchangelib/folders/roots.py +++ b/exchangelib/folders/roots.py @@ -136,7 +136,7 @@ def get_default_folder(self, folder_cls): return f try: log.debug("Requesting distinguished %s folder explicitly", folder_cls) - return folder_cls.get_distinguished(root=self) + return folder_cls.get_distinguished(account=self.account) except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) diff --git a/tests/test_account.py b/tests/test_account.py index 9a775b02..7c8b2b96 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -118,7 +118,7 @@ def test_get_default_folder(self): class MockCalendar1(Calendar): @classmethod - def get_distinguished(cls, root): + def get_distinguished(cls, account): raise ErrorAccessDenied("foo") # Test an indirect folder lookup with FindItem, when we're not allowed to do a GetFolder. We don't get the @@ -132,7 +132,7 @@ def get_distinguished(cls, root): class MockCalendar2(Calendar): @classmethod - def get_distinguished(cls, root): + def get_distinguished(cls, account): raise ErrorFolderNotFound("foo") # Test using the one folder of this folder type diff --git a/tests/test_folder.py b/tests/test_folder.py index 2218c95a..4dd73e33 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -154,7 +154,7 @@ def test_folder_failure(self): self.account.root.remove_folder(Folder(id="XXX")) # Must be called on a distinguished folder class with self.assertRaises(ValueError): - RootOfHierarchy.get_distinguished(self.account) + RootOfHierarchy.get_distinguished(account=self.account) with self.assertRaises(ValueError): self.account.root.get_default_folder(Folder) From dd5f9a46e8fe16f2621d4ff379a56cc5d0ae03af Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 26 Mar 2024 10:34:10 +0100 Subject: [PATCH 414/509] chore: I can't find any indication that 2007 was not supported --- exchangelib/services/inbox_rules.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/exchangelib/services/inbox_rules.py b/exchangelib/services/inbox_rules.py index eba0772f..2b6ac4ab 100644 --- a/exchangelib/services/inbox_rules.py +++ b/exchangelib/services/inbox_rules.py @@ -3,7 +3,6 @@ from ..errors import ErrorInvalidOperation from ..properties import CreateRuleOperation, DeleteRuleOperation, InboxRules, Operations, Rule, SetRuleOperation from ..util import MNS, add_xml_child, create_element, get_xml_attr, set_xml_value -from ..version import EXCHANGE_2010 from .common import EWSAccountService @@ -15,7 +14,6 @@ class GetInboxRules(EWSAccountService): """ SERVICE_NAME = "GetInboxRules" - supported_from = EXCHANGE_2010 element_container_name = InboxRules.response_tag() ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (ErrorInvalidOperation,) @@ -59,7 +57,6 @@ class UpdateInboxRules(EWSAccountService): """ SERVICE_NAME = "UpdateInboxRules" - supported_from = EXCHANGE_2010 ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (ErrorInvalidOperation,) def call(self, rule: Rule, remove_outlook_rule_blob: bool = True): From a39ed40c217e9bd51528374e5564f25f3a7c3b52 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 26 Mar 2024 10:47:52 +0100 Subject: [PATCH 415/509] feat: add a more intuitive API for inbox rules --- docs/index.md | 7 ++++--- exchangelib/properties.py | 25 +++++++++++++++++++++++++ tests/test_account.py | 14 +++++++------- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/docs/index.md b/docs/index.md index 86fa19e4..35a6dcb3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1937,6 +1937,7 @@ print("Rules before creation:", a.rules, "\n") # Create Rule instance rule = Rule( + account=a, display_name="test_exchangelib_rule", priority=1, is_enabled=True, @@ -1946,7 +1947,7 @@ rule = Rule( ) # Create rule -a.create_rule(rule) +rule.save() print("Rule:", rule) print("Created rule with ID:", rule.id, "\n") @@ -1956,12 +1957,12 @@ print("Rules after creation:", a.rules, "\n") # Modify rule print("Modifying rule with ID:", rule.id) rule.display_name = "test_exchangelib_rule(modified)" -a.set_rule(rule) +rule.save() print("Rules after modification:", a.rules, "\n") # Delete rule print("Deleting rule with ID:", rule.id) -a.delete_rule(rule=rule) +rule.delete() print("Rules after deletion:", a.rules) ``` diff --git a/exchangelib/properties.py b/exchangelib/properties.py index e68ed397..f7c0d70f 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -2261,6 +2261,21 @@ class Actions(EWSElement): class Rule(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/rule-ruletype""" + def __init__(self, **kwargs): + """Pick out optional 'account' kwarg, and pass the rest to the parent class. + + :param kwargs: + 'account' is optional but allows calling 'send()' and 'delete()' + """ + from .account import Account + + self.account = kwargs.pop("account", None) + if self.account is not None and not isinstance(self.account, Account): + raise InvalidTypeError("account", self.account, Account) + super().__init__(**kwargs) + + __slots__ = ("account",) + ELEMENT_NAME = "Rule" id = CharField(field_uri="RuleId") @@ -2273,6 +2288,16 @@ class Rule(EWSElement): exceptions = EWSElementField(value_cls=Exceptions) actions = EWSElementField(value_cls=Actions, is_required=True) + def save(self): + if self.id is None: + self.account.create_rule(self) + else: + self.account.set_rule(self) + return self + + def delete(self): + self.account.delete_rule(self) + class InboxRules(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/inboxrules""" diff --git a/tests/test_account.py b/tests/test_account.py index 7c8b2b96..2b4b6375 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -349,6 +349,7 @@ def test_basic_inbox_rule(self): # Create rule display_name = get_random_string(16) rule = Rule( + account=self.account, display_name=display_name, priority=1, is_enabled=True, @@ -357,19 +358,18 @@ def test_basic_inbox_rule(self): actions=Actions(delete=True), ) self.assertIsNone(rule.id) - self.account.create_rule(rule=rule) + rule.save() self.assertIsNotNone(rule.id) - self.assertEqual(len(self.account.rules), 1) self.assertIn(display_name, {r.display_name for r in self.account.rules}) # Update rule rule.display_name = get_random_string(16) - self.account.set_rule(rule=rule) + rule.save() self.assertIn(rule.display_name, {r.display_name for r in self.account.rules}) self.assertNotIn(display_name, {r.display_name for r in self.account.rules}) # Delete rule - self.account.delete_rule(rule=rule) + rule.delete() self.assertNotIn(rule.display_name, {r.display_name for r in self.account.rules}) def test_all_inbox_rule_actions(self): @@ -393,9 +393,9 @@ def test_all_inbox_rule_actions(self): }.items(): with self.subTest(action_name=action_name, action=action): rule = Rule( + account=self.account, display_name=get_random_string(16), priority=get_random_int(), actions=Actions(**{action_name: action}), - ) - self.account.create_rule(rule=rule) - self.account.delete_rule(rule=rule) + ).save() + rule.delete() From b47983ac27f19906d6ad20bbd41343161726e885 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 26 Mar 2024 10:58:09 +0100 Subject: [PATCH 416/509] feat: Simplify move/copy to folder inbox rul action creation --- docs/index.md | 5 ++++- exchangelib/fields.py | 16 ++++++++++++++++ exchangelib/properties.py | 5 +++-- tests/test_account.py | 3 +-- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/index.md b/docs/index.md index 35a6dcb3..7bb2a204 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1943,7 +1943,10 @@ rule = Rule( is_enabled=True, conditions=Conditions(contains_sender_strings=["sender_example"]), exceptions=Exceptions(), - actions=Actions(delete=True), + actions=Actions( + move_to_folder=a.trash, + mark_as_read=True, + ), ) # Create rule diff --git a/exchangelib/fields.py b/exchangelib/fields.py index 3e59d5e3..c5dcb912 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -1798,3 +1798,19 @@ class SensitivityField(ChoiceField): def __init__(self, *args, **kwargs): kwargs["choices"] = SENSITIVITY_CHOICES super().__init__(*args, **kwargs) + + +class FolderActionField(EWSElementField): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def clean(self, value, version=None): + from .folders import DistinguishedFolderId, Folder + + if isinstance(value, Folder): + folder_id = value.to_id() + if isinstance(folder_id, DistinguishedFolderId): + value = self.value_cls(distinguished_folder_id=folder_id) + else: + value = self.value_cls(folder_id=folder_id) + return super().clean(value, version=version) diff --git a/exchangelib/properties.py b/exchangelib/properties.py index f7c0d70f..17d64785 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -38,6 +38,7 @@ Field, FieldPath, FlaggedForActionField, + FolderActionField, FreeBusyStatusField, GenericEventListField, IdElementField, @@ -2244,13 +2245,13 @@ class Actions(EWSElement): ELEMENT_NAME = "Actions" assign_categories = CharListField(field_uri="AssignCategories") - copy_to_folder = EWSElementField(value_cls=CopyToFolder) + copy_to_folder = FolderActionField(value_cls=CopyToFolder) delete = BooleanField(field_uri="Delete") forward_as_attachment_to_recipients = AddressListField(field_uri="ForwardAsAttachmentToRecipients") forward_to_recipients = AddressListField(field_uri="ForwardToRecipients") mark_importance = ImportanceField(field_uri="MarkImportance") mark_as_read = BooleanField(field_uri="MarkAsRead") - move_to_folder = EWSElementField(value_cls=MoveToFolder) + move_to_folder = FolderActionField(value_cls=MoveToFolder) permanent_delete = BooleanField(field_uri="PermanentDelete") redirect_to_recipients = AddressListField(field_uri="RedirectToRecipients") send_sms_alert_to_recipients = AddressListField(field_uri="SendSMSAlertToRecipients") diff --git a/tests/test_account.py b/tests/test_account.py index 2b4b6375..2b513eb5 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -22,7 +22,6 @@ Actions, Address, Conditions, - CopyToFolder, DelegatePermissions, DelegateUser, Exceptions, @@ -375,7 +374,7 @@ def test_basic_inbox_rule(self): def test_all_inbox_rule_actions(self): for action_name, action in { "assign_categories": ["foo", "bar"], - "copy_to_folder": CopyToFolder(distinguished_folder_id=self.account.trash.to_id()), + "copy_to_folder": self.account.trash, "delete": True, # Cannot be random. False would be a no-op action "forward_as_attachment_to_recipients": [Address(email_address=get_random_email())], "mark_importance": get_random_choice( From 32bc90a8524bac8908582b54b8cabbb8e6d378c8 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 26 Mar 2024 12:47:41 +0100 Subject: [PATCH 417/509] fix: Support deleting disabled rules. Simplify delete_rule() by requiring an ID --- exchangelib/account.py | 11 +++-------- exchangelib/properties.py | 10 ++++++++++ tests/common.py | 2 +- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/exchangelib/account.py b/exchangelib/account.py index 402f5ea1..ebf26270 100644 --- a/exchangelib/account.py +++ b/exchangelib/account.py @@ -6,7 +6,7 @@ from .autodiscover import Autodiscovery from .configuration import Configuration from .credentials import ACCESS_TYPES, DELEGATE, IMPERSONATION -from .errors import ErrorItemNotFound, InvalidEnumValue, InvalidTypeError, ResponseMessageError, UnknownTimeZone +from .errors import InvalidEnumValue, InvalidTypeError, ResponseMessageError, UnknownTimeZone from .ewsdatetime import UTC, EWSTimeZone from .fields import FieldPath, TextField from .folders import ( @@ -787,16 +787,11 @@ def set_rule(self, rule: Rule): def delete_rule(self, rule: Rule): """Delete an Inbox rule. - :param rule: The rule to delete. Must have ID or 'display_name'. + :param rule: The rule to delete. Must have an ID. :return: None if success, else raises an error. """ if not rule.id: - if not rule.display_name: - raise ValueError("Rule must have ID or display_name") - try: - rule = {i.display_name: i for i in GetInboxRules(account=self).call()}[rule.display_name] - except KeyError: - raise ErrorItemNotFound(f"No rule with name {rule.display_name!r}") + raise ValueError("Rule must have an ID") DeleteInboxRule(account=self).get(rule=rule) rule.id = None diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 17d64785..26352024 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -2289,6 +2289,12 @@ def __init__(self, **kwargs): exceptions = EWSElementField(value_cls=Exceptions) actions = EWSElementField(value_cls=Actions, is_required=True) + @classmethod + def from_xml(cls, elem, account): + res = super().from_xml(elem=elem, account=account) + res.account = account + return res + def save(self): if self.id is None: self.account.create_rule(self) @@ -2297,6 +2303,10 @@ def save(self): return self def delete(self): + if self.is_enabled is False: + # Cannot delete a disabled rule - server throws 'ErrorItemNotFound' + self.is_enabled = True + self.save() self.account.delete_rule(self) diff --git a/tests/common.py b/tests/common.py index d262ecb3..cde04c3e 100644 --- a/tests/common.py +++ b/tests/common.py @@ -161,7 +161,7 @@ def wipe_test_account(self): # Deletes up all deletable items in the test account. Not run in a normal test run self.account.root.wipe() for rule in self.account.rules: - self.account.delete_rule(rule) + rule.delete() def bulk_delete(self, ids): # Clean up items and check return values From d2fbf1dd90dafc130fde8d2276741663f2d3e221 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 26 Mar 2024 12:48:20 +0100 Subject: [PATCH 418/509] chore: Alicn and clean up from_xml() subclassing --- exchangelib/properties.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 26352024..344b6a27 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -824,7 +824,7 @@ class TimeZoneTransition(EWSElement, metaclass=EWSMeta): @classmethod def from_xml(cls, elem, account): - res = super().from_xml(elem, account) + res = super().from_xml(elem=elem, account=account) # Some parts of EWS use '5' to mean 'last occurrence in month', others use '-1'. Let's settle on '5' because # only '5' is accepted in requests. if res.occurrence == -1: @@ -1871,7 +1871,7 @@ class RecurringDayTransition(BaseTransition): @classmethod def from_xml(cls, elem, account): - res = super().from_xml(elem, account) + res = super().from_xml(elem=elem, account=account) # See TimeZoneTransition.from_xml() if res.occurrence == -1: res.occurrence = 5 @@ -1923,10 +1923,6 @@ class TimeZoneDefinition(EWSElement): transitions_groups = EWSElementListField(field_uri="TransitionsGroups", value_cls=TransitionsGroup) transitions = TransitionListField(field_uri="Transitions", value_cls=BaseTransition) - @classmethod - def from_xml(cls, elem, account): - return super().from_xml(elem, account) - def _get_standard_period(self, transitions_group): # Find the first standard period referenced from transitions_group standard_periods_map = {p.id: p for p in self.periods if p.name == "Standard"} From 7afd889eda52c91fc8fa6272e8ab477671184fec Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 4 Apr 2024 11:40:24 +0200 Subject: [PATCH 419/509] fix: Catch and return all exceptions that _folders_map() expects to handle as exception objects. Refs #1290 --- exchangelib/services/get_folder.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/exchangelib/services/get_folder.py b/exchangelib/services/get_folder.py index 856a509f..e4496df2 100644 --- a/exchangelib/services/get_folder.py +++ b/exchangelib/services/get_folder.py @@ -1,4 +1,10 @@ -from ..errors import ErrorFolderNotFound, ErrorInvalidOperation, ErrorNoPublicFolderReplicaAvailable +from ..errors import ( + ErrorAccessDenied, + ErrorFolderNotFound, + ErrorInvalidOperation, + ErrorItemNotFound, + ErrorNoPublicFolderReplicaAvailable, +) from ..util import MNS, create_element from .common import EWSAccountService, folder_ids_element, parse_folder_elem, shape_element @@ -9,9 +15,11 @@ class GetFolder(EWSAccountService): SERVICE_NAME = "GetFolder" element_container_name = f"{{{MNS}}}Folders" ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + ( + ErrorAccessDenied, ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorInvalidOperation, + ErrorItemNotFound, ) def __init__(self, *args, **kwargs): From 6f6515bc168e17f30250c9a0a5d090499231cac5 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 11 Apr 2024 19:12:56 +0200 Subject: [PATCH 420/509] Bump version --- CHANGELOG.md | 8 +++++++- exchangelib/__init__.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c475834b..8f59f091 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ HEAD ---- +5.2.1 +----- +- Fix `ErrorAccessDenied: Not allowed to access Non IPM folder` caused by recent changes in O365. +- Add more intuitive API for inbox rules +- Fix various bugs with inbox creation + + 5.2.0 ----- - Allow setting a custom `Configuration.max_conections` in autodiscover mode @@ -13,7 +20,6 @@ HEAD - Support subscribing to all folders instead of specific folders - 5.1.0 ----- - Fix QuerySet operations on shared folders diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index 1c988d3a..ad342ff5 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -36,7 +36,7 @@ from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "5.2.0" +__version__ = "5.2.1" __all__ = [ "__version__", From ff43417ddfb3da3b0c252601beb1cd402471c073 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 11 Apr 2024 19:14:16 +0200 Subject: [PATCH 421/509] docs: Update docs --- docs/exchangelib/account.html | 31 +- docs/exchangelib/fields.html | 169 +- docs/exchangelib/folders/base.html | 76 +- docs/exchangelib/folders/index.html | 9291 +++++++++++-------- docs/exchangelib/folders/known_folders.html | 5732 +++++++----- docs/exchangelib/folders/roots.html | 6 +- docs/exchangelib/index.html | 84 +- docs/exchangelib/properties.html | 289 +- docs/exchangelib/queryset.html | 15 +- docs/exchangelib/services/common.html | 4 - docs/exchangelib/services/get_folder.html | 12 +- docs/exchangelib/services/inbox_rules.html | 269 +- docs/exchangelib/services/index.html | 166 +- 13 files changed, 9553 insertions(+), 6591 deletions(-) diff --git a/docs/exchangelib/account.html b/docs/exchangelib/account.html index 17be1cbe..974be9c9 100644 --- a/docs/exchangelib/account.html +++ b/docs/exchangelib/account.html @@ -34,7 +34,7 @@

                        Module exchangelib.account

                        from .autodiscover import Autodiscovery from .configuration import Configuration from .credentials import ACCESS_TYPES, DELEGATE, IMPERSONATION -from .errors import ErrorItemNotFound, InvalidEnumValue, InvalidTypeError, ResponseMessageError, UnknownTimeZone +from .errors import InvalidEnumValue, InvalidTypeError, ResponseMessageError, UnknownTimeZone from .ewsdatetime import UTC, EWSTimeZone from .fields import FieldPath, TextField from .folders import ( @@ -815,16 +815,11 @@

                        Module exchangelib.account

                        def delete_rule(self, rule: Rule): """Delete an Inbox rule. - :param rule: The rule to delete. Must have ID or 'display_name'. + :param rule: The rule to delete. Must have an ID. :return: None if success, else raises an error. """ if not rule.id: - if not rule.display_name: - raise ValueError("Rule must have ID or display_name") - try: - rule = {i.display_name: i for i in GetInboxRules(account=self).call()}[rule.display_name] - except KeyError: - raise ErrorItemNotFound(f"No rule with name {rule.display_name!r}") + raise ValueError("Rule must have an ID") DeleteInboxRule(account=self).get(rule=rule) rule.id = None @@ -1617,16 +1612,11 @@

                        Classes

                        def delete_rule(self, rule: Rule): """Delete an Inbox rule. - :param rule: The rule to delete. Must have ID or 'display_name'. + :param rule: The rule to delete. Must have an ID. :return: None if success, else raises an error. """ if not rule.id: - if not rule.display_name: - raise ValueError("Rule must have ID or display_name") - try: - rule = {i.display_name: i for i in GetInboxRules(account=self).call()}[rule.display_name] - except KeyError: - raise ErrorItemNotFound(f"No rule with name {rule.display_name!r}") + raise ValueError("Rule must have an ID") DeleteInboxRule(account=self).get(rule=rule) rule.id = None @@ -3169,7 +3159,7 @@

                        Methods

  • Delete an Inbox rule.

    -

    :param rule: The rule to delete. Must have ID or 'display_name'. +

    :param rule: The rule to delete. Must have an ID. :return: None if success, else raises an error.

    @@ -3178,16 +3168,11 @@

    Methods

    def delete_rule(self, rule: Rule):
         """Delete an Inbox rule.
     
    -    :param rule: The rule to delete. Must have ID or 'display_name'.
    +    :param rule: The rule to delete. Must have an ID.
         :return: None if success, else raises an error.
         """
         if not rule.id:
    -        if not rule.display_name:
    -            raise ValueError("Rule must have ID or display_name")
    -        try:
    -            rule = {i.display_name: i for i in GetInboxRules(account=self).call()}[rule.display_name]
    -        except KeyError:
    -            raise ErrorItemNotFound(f"No rule with name {rule.display_name!r}")
    +        raise ValueError("Rule must have an ID")
         DeleteInboxRule(account=self).get(rule=rule)
         rule.id = None
    diff --git a/docs/exchangelib/fields.html b/docs/exchangelib/fields.html index e59334b0..252253d4 100644 --- a/docs/exchangelib/fields.html +++ b/docs/exchangelib/fields.html @@ -1203,6 +1203,19 @@

    Module exchangelib.fields

    return super().clean(value, version=version) +class AddressListField(EWSElementListField): + def __init__(self, *args, **kwargs): + from .properties import Address + + kwargs["value_cls"] = Address + super().__init__(*args, **kwargs) + + def clean(self, value, version=None): + if value is not None: + value = [self.value_cls(email_address=s) if isinstance(s, str) else s for s in value] + return super().clean(value, version=version) + + class MemberListField(EWSElementListField): def __init__(self, *args, **kwargs): from .properties import Member @@ -1812,7 +1825,23 @@

    Module exchangelib.fields

    def __init__(self, *args, **kwargs): kwargs["choices"] = SENSITIVITY_CHOICES - super().__init__(*args, **kwargs)
    + super().__init__(*args, **kwargs) + + +class FolderActionField(EWSElementField): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def clean(self, value, version=None): + from .folders import DistinguishedFolderId, Folder + + if isinstance(value, Folder): + folder_id = value.to_id() + if isinstance(folder_id, DistinguishedFolderId): + value = self.value_cls(distinguished_folder_id=folder_id) + else: + value = self.value_cls(folder_id=folder_id) + return super().clean(value, version=version)
    @@ -1928,6 +1957,64 @@

    Functions

    Classes

    +
    +class AddressListField +(*args, **kwargs) +
    +
    +

    Like EWSElementField, but for lists of EWSElement objects.

    +
    + +Expand source code + +
    class AddressListField(EWSElementListField):
    +    def __init__(self, *args, **kwargs):
    +        from .properties import Address
    +
    +        kwargs["value_cls"] = Address
    +        super().__init__(*args, **kwargs)
    +
    +    def clean(self, value, version=None):
    +        if value is not None:
    +            value = [self.value_cls(email_address=s) if isinstance(s, str) else s for s in value]
    +        return super().clean(value, version=version)
    +
    +

    Ancestors

    + +

    Methods

    +
    +
    +def clean(self, value, version=None) +
    +
    +
    +
    + +Expand source code + +
    def clean(self, value, version=None):
    +    if value is not None:
    +        value = [self.value_cls(email_address=s) if isinstance(s, str) else s for s in value]
    +    return super().clean(value, version=version)
    +
    +
    +
    +

    Inherited members

    + +
    class AppointmentStateField (*args, **kwargs) @@ -3308,6 +3395,7 @@

    Subclasses

  • BodyContentAttributedValueField
  • EWSElementListField
  • EffectiveRightsField
  • +
  • FolderActionField
  • GenericEventListField
  • IdElementField
  • IndexedField
  • @@ -3372,6 +3460,7 @@

    Ancestors

    Subclasses

    +
    +class FolderActionField +(*args, **kwargs) +
    +
    +

    A generic field for any EWSElement object.

    +
    + +Expand source code + +
    class FolderActionField(EWSElementField):
    +    def __init__(self, *args, **kwargs):
    +        super().__init__(*args, **kwargs)
    +
    +    def clean(self, value, version=None):
    +        from .folders import DistinguishedFolderId, Folder
    +
    +        if isinstance(value, Folder):
    +            folder_id = value.to_id()
    +            if isinstance(folder_id, DistinguishedFolderId):
    +                value = self.value_cls(distinguished_folder_id=folder_id)
    +            else:
    +                value = self.value_cls(folder_id=folder_id)
    +        return super().clean(value, version=version)
    +
    +

    Ancestors

    + +

    Methods

    +
    +
    +def clean(self, value, version=None) +
    +
    +
    +
    + +Expand source code + +
    def clean(self, value, version=None):
    +    from .folders import DistinguishedFolderId, Folder
    +
    +    if isinstance(value, Folder):
    +        folder_id = value.to_id()
    +        if isinstance(folder_id, DistinguishedFolderId):
    +            value = self.value_cls(distinguished_folder_id=folder_id)
    +        else:
    +            value = self.value_cls(folder_id=folder_id)
    +    return super().clean(value, version=version)
    +
    +
    +
    +

    Inherited members

    + +
    class FreeBusyStatusField (*args, **kwargs) @@ -7223,6 +7378,12 @@

    Index

  • Classes

    • +

      AddressListField

      + +
    • +
    • AppointmentStateField

      • CANCELLED
      • @@ -7449,6 +7610,12 @@

        FlaggedForActionField

      • +

        FolderActionField

        + +
      • +
      • FreeBusyStatusField

      • diff --git a/docs/exchangelib/folders/base.html b/docs/exchangelib/folders/base.html index 778ac491..9ba89d1b 100644 --- a/docs/exchangelib/folders/base.html +++ b/docs/exchangelib/folders/base.html @@ -105,7 +105,7 @@

        Module exchangelib.folders.base

        ID_ELEMENT_CLS = FolderId _id = IdElementField(field_uri="folder:FolderId", value_cls=ID_ELEMENT_CLS) - _distinguished_id = IdElementField(field_uri="folder:FolderId", value_cls=DistinguishedFolderId) + _distinguished_id = IdElementField(field_uri="folder:DistinguishedFolderId", value_cls=DistinguishedFolderId) parent_folder_id = EWSElementField(field_uri="folder:ParentFolderId", value_cls=ParentFolderId, is_read_only=True) folder_class = CharField(field_uri="folder:FolderClass", is_required_after_save=True) name = CharField(field_uri="folder:DisplayName") @@ -249,7 +249,7 @@

        Module exchangelib.folders.base

        def localized_names(cls, locale): # Return localized names for a specific locale. If no locale-specific names exist, return the default names, # if any. - return tuple(s.lower() for s in cls.LOCALIZED_NAMES.get(locale, cls.LOCALIZED_NAMES.get(None, []))) + return tuple(s.lower() for s in cls.LOCALIZED_NAMES.get(locale, cls.LOCALIZED_NAMES.get(None, [cls.__name__]))) @staticmethod def folder_cls_from_container_class(container_class): @@ -885,18 +885,18 @@

        Module exchangelib.folders.base

        return super().deregister(*args, **kwargs) @classmethod - def get_distinguished(cls, root): + def get_distinguished(cls, account): """Get the distinguished folder for this folder class. - :param root: + :param account: :return: """ try: return cls.resolve( - account=root.account, + account=account, folder=DistinguishedFolderId( id=cls.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=root.account.primary_smtp_address), + mailbox=Mailbox(email_address=account.primary_smtp_address), ), ) except MISSING_FOLDER_ERRORS: @@ -1008,7 +1008,7 @@

        Classes

        ID_ELEMENT_CLS = FolderId _id = IdElementField(field_uri="folder:FolderId", value_cls=ID_ELEMENT_CLS) - _distinguished_id = IdElementField(field_uri="folder:FolderId", value_cls=DistinguishedFolderId) + _distinguished_id = IdElementField(field_uri="folder:DistinguishedFolderId", value_cls=DistinguishedFolderId) parent_folder_id = EWSElementField(field_uri="folder:ParentFolderId", value_cls=ParentFolderId, is_read_only=True) folder_class = CharField(field_uri="folder:FolderClass", is_required_after_save=True) name = CharField(field_uri="folder:DisplayName") @@ -1152,7 +1152,7 @@

        Classes

        def localized_names(cls, locale): # Return localized names for a specific locale. If no locale-specific names exist, return the default names, # if any. - return tuple(s.lower() for s in cls.LOCALIZED_NAMES.get(locale, cls.LOCALIZED_NAMES.get(None, []))) + return tuple(s.lower() for s in cls.LOCALIZED_NAMES.get(locale, cls.LOCALIZED_NAMES.get(None, [cls.__name__]))) @staticmethod def folder_cls_from_container_class(container_class): @@ -1941,7 +1941,7 @@

        Static methods

        def localized_names(cls, locale): # Return localized names for a specific locale. If no locale-specific names exist, return the default names, # if any. - return tuple(s.lower() for s in cls.LOCALIZED_NAMES.get(locale, cls.LOCALIZED_NAMES.get(None, [])))
        + return tuple(s.lower() for s in cls.LOCALIZED_NAMES.get(locale, cls.LOCALIZED_NAMES.get(None, [cls.__name__])))
        @@ -3008,18 +3008,18 @@

        Inherited members

        return super().deregister(*args, **kwargs) @classmethod - def get_distinguished(cls, root): + def get_distinguished(cls, account): """Get the distinguished folder for this folder class. - :param root: + :param account: :return: """ try: return cls.resolve( - account=root.account, + account=account, folder=DistinguishedFolderId( id=cls.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=root.account.primary_smtp_address), + mailbox=Mailbox(email_address=account.primary_smtp_address), ), ) except MISSING_FOLDER_ERRORS: @@ -3102,50 +3102,16 @@

        Ancestors

      Subclasses

      Class variables

      @@ -3207,29 +3173,29 @@

      Static methods

      -def get_distinguished(root) +def get_distinguished(account)

      Get the distinguished folder for this folder class.

      -

      :param root: +

      :param account: :return:

      Expand source code
      @classmethod
      -def get_distinguished(cls, root):
      +def get_distinguished(cls, account):
           """Get the distinguished folder for this folder class.
       
      -    :param root:
      +    :param account:
           :return:
           """
           try:
               return cls.resolve(
      -            account=root.account,
      +            account=account,
                   folder=DistinguishedFolderId(
                       id=cls.DISTINGUISHED_FOLDER_ID,
      -                mailbox=Mailbox(email_address=root.account.primary_smtp_address),
      +                mailbox=Mailbox(email_address=account.primary_smtp_address),
                   ),
               )
           except MISSING_FOLDER_ERRORS:
      diff --git a/docs/exchangelib/folders/index.html b/docs/exchangelib/folders/index.html
      index cc32001d..44e7b377 100644
      --- a/docs/exchangelib/folders/index.html
      +++ b/docs/exchangelib/folders/index.html
      @@ -32,8 +32,11 @@ 

      Module exchangelib.folders

      from .known_folders import ( NON_DELETABLE_FOLDERS, AdminAuditLogs, + AllCategorizedItems, AllContacts, AllItems, + AllPersonMetadata, + AllTodoTasks, ApplicationData, ArchiveDeletedItems, ArchiveInbox, @@ -61,15 +64,19 @@

      Module exchangelib.folders

      Drafts, EventCheckPoints, ExchangeSyncData, + ExternalContacts, Favorites, Files, + FolderMemberships, FreeBusyCache, FreebusyData, Friends, + FromFavoriteSenders, GALContacts, GraphAnalytics, IMContactList, Inbox, + Inference, Journal, JunkEmail, LocalFailures, @@ -79,8 +86,9 @@

      Module exchangelib.folders

      MsgFolderRoot, MyContacts, MyContactsExtended, - NonDeletableFolderMixIn, + NonDeletableFolder, Notes, + OneNotePagePreviews, OrganizationalContacts, Outbox, ParkedMessages, @@ -88,21 +96,31 @@

      Module exchangelib.folders

      PdpProfileV2Secured, PeopleCentricConversationBuddies, PeopleConnect, + QedcDefaultRetention, + QedcLongRetention, + QedcMediumRetention, + QedcShortRetention, + QuarantinedEmail, + QuarantinedEmailDefaultCategory, QuickContacts, RecipientCache, RecoverableItemsDeletions, RecoverableItemsPurges, RecoverableItemsRoot, + RecoverableItemsSubstrateHolds, RecoverableItemsVersions, RecoveryPoints, + RelevantContacts, Reminders, RSSFeeds, Schedule, SearchFolders, SentItems, ServerFailures, + SharePointNotifications, Sharing, Shortcuts, + ShortNotes, Signal, SkypeTeamsMessages, SmsAndChatsSync, @@ -110,9 +128,11 @@

      Module exchangelib.folders

      SwssItems, SyncIssues, System, + System1, Tasks, TemporarySaves, ToDoSearch, + UserCuratedContacts, Views, VoiceMail, WellknownFolder, @@ -123,8 +143,11 @@

      Module exchangelib.folders

      __all__ = [ "AdminAuditLogs", + "AllCategorizedItems", "AllContacts", "AllItems", + "AllPersonMetadata", + "AllTodoTasks", "ApplicationData", "ArchiveDeletedItems", "ArchiveInbox", @@ -156,20 +179,24 @@

      Module exchangelib.folders

      "Drafts", "EventCheckPoints", "ExchangeSyncData", + "ExternalContacts", "FOLDER_TRAVERSAL_CHOICES", "Favorites", "Files", "Folder", "FolderCollection", "FolderId", + "FolderMemberships", "FolderQuerySet", "FreeBusyCache", "FreebusyData", "Friends", + "FromFavoriteSenders", "GALContacts", "GraphAnalytics", "IMContactList", "Inbox", + "Inference", "Journal", "JunkEmail", "LocalFailures", @@ -180,8 +207,9 @@

      Module exchangelib.folders

      "MyContacts", "MyContactsExtended", "NON_DELETABLE_FOLDERS", - "NonDeletableFolderMixIn", + "NonDeletableFolder", "Notes", + "OneNotePagePreviews", "OrganizationalContacts", "Outbox", "ParkedMessages", @@ -189,6 +217,12 @@

      Module exchangelib.folders

      "PdpProfileV2Secured", "PeopleCentricConversationBuddies", "PeopleConnect", + "QedcDefaultRetention", + "QedcMediumRetention", + "QedcLongRetention", + "QedcShortRetention", + "QuarantinedEmail", + "QuarantinedEmailDefaultCategory", "PublicFoldersRoot", "QuickContacts", "RSSFeeds", @@ -196,8 +230,10 @@

      Module exchangelib.folders

      "RecoverableItemsDeletions", "RecoverableItemsPurges", "RecoverableItemsRoot", + "RecoverableItemsSubstrateHolds", "RecoverableItemsVersions", "RecoveryPoints", + "RelevantContacts", "Reminders", "Root", "RootOfHierarchy", @@ -207,8 +243,10 @@

      Module exchangelib.folders

      "SearchFolders", "SentItems", "ServerFailures", + "SharePointNotifications", "Sharing", "Shortcuts", + "ShortNotes", "Signal", "SingleFolderQuerySet", "SkypeTeamsMessages", @@ -217,9 +255,11 @@

      Module exchangelib.folders

      "SwssItems", "SyncIssues", "System", + "System1", "Tasks", "TemporarySaves", "ToDoSearch", + "UserCuratedContacts", "Views", "VoiceMail", "WellknownFolder", @@ -338,232 +378,8 @@

      Inherited members

    -
    -class AllContacts -(**kwargs) -
    -
    -

    A mixin for non-wellknown folders than that are not deletable.

    -
    - -Expand source code - -
    class AllContacts(NonDeletableFolderMixIn, Contacts):
    -    CONTAINER_CLASS = "IPF.Note"
    -
    -    LOCALIZED_NAMES = {
    -        None: ("AllContacts",),
    -    }
    -
    -

    Ancestors

    - -

    Class variables

    -
    -
    var CONTAINER_CLASS
    -
    -
    -
    -
    var LOCALIZED_NAMES
    -
    -
    -
    -
    -

    Inherited members

    - -
    -
    -class AllItems -(**kwargs) -
    -
    -

    A mixin for non-wellknown folders than that are not deletable.

    -
    - -Expand source code - -
    class AllItems(NonDeletableFolderMixIn, Folder):
    -    CONTAINER_CLASS = "IPF"
    -
    -    LOCALIZED_NAMES = {
    -        None: ("AllItems",),
    -    }
    -
    -

    Ancestors

    - -

    Class variables

    -
    -
    var CONTAINER_CLASS
    -
    -
    -
    -
    var LOCALIZED_NAMES
    -
    -
    -
    -
    -

    Inherited members

    - -
    -
    -class ApplicationData -(**kwargs) -
    -
    -

    A mixin for non-wellknown folders than that are not deletable.

    -
    - -Expand source code - -
    class ApplicationData(NonDeletableFolderMixIn, Folder):
    -    CONTAINER_CLASS = "IPM.ApplicationData"
    -
    -

    Ancestors

    - -

    Class variables

    -
    -
    var CONTAINER_CLASS
    -
    -
    -
    -
    -

    Inherited members

    - -
    -
    -class ArchiveDeletedItems +
    +class AllCategorizedItems (**kwargs)
    @@ -572,9 +388,9 @@

    Inherited members

    Expand source code -
    class ArchiveDeletedItems(WellknownFolder):
    -    DISTINGUISHED_FOLDER_ID = "archivedeleteditems"
    -    supported_from = EXCHANGE_2010_SP1
    +
    class AllCategorizedItems(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "allcategorizeditems"
    +    CONTAINER_CLASS = "IPF.Note"

    Ancestors

      @@ -589,11 +405,11 @@

      Ancestors

    Class variables

    -
    var DISTINGUISHED_FOLDER_ID
    +
    var CONTAINER_CLASS
    -
    var supported_from
    +
    var DISTINGUISHED_FOLDER_ID
    @@ -636,8 +452,8 @@

    Inherited members

  • -
    -class ArchiveInbox +
    +class AllContacts (**kwargs)
    @@ -646,9 +462,9 @@

    Inherited members

    Expand source code -
    class ArchiveInbox(WellknownFolder):
    -    DISTINGUISHED_FOLDER_ID = "archiveinbox"
    -    supported_from = EXCHANGE_2013_SP1
    +
    class AllContacts(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "allcontacts"
    +    CONTAINER_CLASS = "IPF.Note"

    Ancestors

      @@ -663,11 +479,11 @@

      Ancestors

    Class variables

    -
    var DISTINGUISHED_FOLDER_ID
    +
    var CONTAINER_CLASS
    -
    var supported_from
    +
    var DISTINGUISHED_FOLDER_ID
    @@ -710,8 +526,8 @@

    Inherited members

    -
    -class ArchiveMsgFolderRoot +
    +class AllItems (**kwargs)
    @@ -720,9 +536,9 @@

    Inherited members

    Expand source code -
    class ArchiveMsgFolderRoot(WellknownFolder):
    -    DISTINGUISHED_FOLDER_ID = "archivemsgfolderroot"
    -    supported_from = EXCHANGE_2010_SP1
    +
    class AllItems(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "allitems"
    +    CONTAINER_CLASS = "IPF"

    Ancestors

      @@ -737,11 +553,11 @@

      Ancestors

    Class variables

    -
    var DISTINGUISHED_FOLDER_ID
    +
    var CONTAINER_CLASS
    -
    var supported_from
    +
    var DISTINGUISHED_FOLDER_ID
    @@ -784,8 +600,8 @@

    Inherited members

    -
    -class ArchiveRecoverableItemsDeletions +
    +class AllPersonMetadata (**kwargs)
    @@ -794,9 +610,9 @@

    Inherited members

    Expand source code -
    class ArchiveRecoverableItemsDeletions(WellknownFolder):
    -    DISTINGUISHED_FOLDER_ID = "archiverecoverableitemsdeletions"
    -    supported_from = EXCHANGE_2010_SP1
    +
    class AllPersonMetadata(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "allpersonmetadata"
    +    CONTAINER_CLASS = "IPF.Note"

    Ancestors

      @@ -811,11 +627,11 @@

      Ancestors

    Class variables

    -
    var DISTINGUISHED_FOLDER_ID
    +
    var CONTAINER_CLASS
    -
    var supported_from
    +
    var DISTINGUISHED_FOLDER_ID
    @@ -858,23 +674,27 @@

    Inherited members

    -
    -class ArchiveRecoverableItemsPurges +
    +class AllTodoTasks (**kwargs)
    -

    Base class to use until we have a more specific folder implementation for this folder.

    +

    A mixin for non-wellknown folders than that are not deletable.

    Expand source code -
    class ArchiveRecoverableItemsPurges(WellknownFolder):
    -    DISTINGUISHED_FOLDER_ID = "archiverecoverableitemspurges"
    -    supported_from = EXCHANGE_2010_SP1
    +
    class AllTodoTasks(NonDeletableFolder):
    +    DISTINGUISHED_FOLDER_ID = None
    +    CONTAINER_CLASS = "IPF.Task"
    +    supported_item_models = (Task,)
    +    LOCALIZED_NAMES = {
    +        None: ("AllTodoTasks",),
    +    }

    Ancestors

    Class variables

    -
    var DISTINGUISHED_FOLDER_ID
    +
    var CONTAINER_CLASS
    -
    var supported_from
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    var LOCALIZED_NAMES
    +
    +
    +
    +
    var supported_item_models

    Inherited members

    +
    +
    +class ApplicationData +(**kwargs) +
    +
    +

    A mixin for non-wellknown folders than that are not deletable.

    +
    + +Expand source code + +
    class ApplicationData(NonDeletableFolder):
    +    CONTAINER_CLASS = "IPM.ApplicationData"
    +
    +

    Ancestors

    +

    Class variables

    +
    +
    var CONTAINER_CLASS
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class ArchiveDeletedItems +(**kwargs) +
    +
    +

    Base class to use until we have a more specific folder implementation for this folder.

    +
    + +Expand source code + +
    class ArchiveDeletedItems(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "archivedeleteditems"
    +    supported_from = EXCHANGE_2010_SP1
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    var supported_from
    +
    +
    +
    +
    +

    Inherited members

    +
    -
    -class ArchiveRecoverableItemsRoot +
    +class ArchiveInbox (**kwargs)
    @@ -942,9 +913,9 @@

    Inherited members

    Expand source code -
    class ArchiveRecoverableItemsRoot(WellknownFolder):
    -    DISTINGUISHED_FOLDER_ID = "archiverecoverableitemsroot"
    -    supported_from = EXCHANGE_2010_SP1
    +
    class ArchiveInbox(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "archiveinbox"
    +    supported_from = EXCHANGE_2013_SP1

    Ancestors

      @@ -959,11 +930,11 @@

      Ancestors

    Class variables

    -
    var DISTINGUISHED_FOLDER_ID
    +
    var DISTINGUISHED_FOLDER_ID
    -
    var supported_from
    +
    var supported_from
    @@ -1006,8 +977,8 @@

    Inherited members

    -
    -class ArchiveRecoverableItemsVersions +
    +class ArchiveMsgFolderRoot (**kwargs)
    @@ -1016,8 +987,8 @@

    Inherited members

    Expand source code -
    class ArchiveRecoverableItemsVersions(WellknownFolder):
    -    DISTINGUISHED_FOLDER_ID = "archiverecoverableitemsversions"
    +
    class ArchiveMsgFolderRoot(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "archivemsgfolderroot"
         supported_from = EXCHANGE_2010_SP1

    Ancestors

    @@ -1033,11 +1004,11 @@

    Ancestors

    Class variables

    -
    var DISTINGUISHED_FOLDER_ID
    +
    var DISTINGUISHED_FOLDER_ID
    -
    var supported_from
    +
    var supported_from
    @@ -1080,26 +1051,24 @@

    Inherited members

    -
    -class ArchiveRoot +
    +class ArchiveRecoverableItemsDeletions (**kwargs)
    -

    The root of the archive folders hierarchy. Not available on all mailboxes.

    +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    class ArchiveRoot(RootOfHierarchy):
    -    """The root of the archive folders hierarchy. Not available on all mailboxes."""
    -
    -    DISTINGUISHED_FOLDER_ID = "archiveroot"
    -    supported_from = EXCHANGE_2010_SP1
    -    WELLKNOWN_FOLDERS = WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT
    +
    class ArchiveRecoverableItemsDeletions(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "archiverecoverableitemsdeletions"
    +    supported_from = EXCHANGE_2010_SP1

    Ancestors

    Class variables

    -
    var DISTINGUISHED_FOLDER_ID
    -
    -
    -
    -
    var WELLKNOWN_FOLDERS
    +
    var DISTINGUISHED_FOLDER_ID
    -
    var supported_from
    +
    var supported_from

    Inherited members

    -
    -class Audits +
    +class ArchiveRecoverableItemsPurges (**kwargs)
    -

    A mixin for non-wellknown folders than that are not deletable.

    +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    class Audits(NonDeletableFolderMixIn, Folder):
    -    LOCALIZED_NAMES = {
    -        None: ("Audits",),
    -    }
    -    get_folder_allowed = False
    +
    class ArchiveRecoverableItemsPurges(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "archiverecoverableitemspurges"
    +    supported_from = EXCHANGE_2010_SP1

    Ancestors

    Class variables

    -
    var LOCALIZED_NAMES
    +
    var DISTINGUISHED_FOLDER_ID
    -
    var get_folder_allowed
    +
    var supported_from

    Inherited members

    -
    -
    -class BaseFolder -(**kwargs) -
    -
    -

    Base class for all classes that implement a folder.

    -
    - -Expand source code - -
    class BaseFolder(RegisterMixIn, SearchableMixIn, SupportedVersionClassMixIn, metaclass=EWSMeta):
    -    """Base class for all classes that implement a folder."""
    -
    -    ELEMENT_NAME = "Folder"
    -    NAMESPACE = TNS
    -    # See https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/distinguishedfolderid
    -    DISTINGUISHED_FOLDER_ID = None
    -    # Default item type for this folder. See
    -    # https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxosfld/68a85898-84fe-43c4-b166-4711c13cdd61
    -    CONTAINER_CLASS = None
    -    supported_item_models = ITEM_CLASSES  # The Item types that this folder can contain. Default is all
    -    # Whether this folder type is allowed with the GetFolder service
    -    get_folder_allowed = True
    -    DEFAULT_FOLDER_TRAVERSAL_DEPTH = DEEP_FOLDERS
    -    DEFAULT_ITEM_TRAVERSAL_DEPTH = SHALLOW_ITEMS
    +
  • ID_ELEMENT_CLS
  • +
  • account
  • +
  • add_field
  • +
  • all
  • +
  • deregister
  • +
  • exclude
  • +
  • filter
  • +
  • folder_cls_from_container_class
  • +
  • folder_sync_state
  • +
  • get
  • +
  • get_distinguished
  • +
  • get_events
  • +
  • get_streaming_events
  • +
  • item_sync_state
  • +
  • none
  • +
  • parent
  • +
  • people
  • +
  • register
  • +
  • remove_field
  • +
  • root
  • +
  • subscribe_to_pull
  • +
  • subscribe_to_push
  • +
  • subscribe_to_streaming
  • +
  • supported_fields
  • +
  • sync_hierarchy
  • +
  • sync_items
  • +
  • test_access
  • +
  • tree
  • +
  • unsubscribe
  • +
  • validate_field
  • + + + +
    +
    +class ArchiveRecoverableItemsRoot +(**kwargs) +
    +
    +

    Base class to use until we have a more specific folder implementation for this folder.

    +
    + +Expand source code + +
    class ArchiveRecoverableItemsRoot(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "archiverecoverableitemsroot"
    +    supported_from = EXCHANGE_2010_SP1
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    var supported_from
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class ArchiveRecoverableItemsVersions +(**kwargs) +
    +
    +

    Base class to use until we have a more specific folder implementation for this folder.

    +
    + +Expand source code + +
    class ArchiveRecoverableItemsVersions(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "archiverecoverableitemsversions"
    +    supported_from = EXCHANGE_2010_SP1
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    var supported_from
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class ArchiveRoot +(**kwargs) +
    +
    +

    The root of the archive folders hierarchy. Not available on all mailboxes.

    +
    + +Expand source code + +
    class ArchiveRoot(RootOfHierarchy):
    +    """The root of the archive folders hierarchy. Not available on all mailboxes."""
    +
    +    DISTINGUISHED_FOLDER_ID = "archiveroot"
    +    supported_from = EXCHANGE_2010_SP1
    +    WELLKNOWN_FOLDERS = WELLKNOWN_FOLDERS_IN_ARCHIVE_ROOT
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    var WELLKNOWN_FOLDERS
    +
    +
    +
    +
    var supported_from
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class Audits +(**kwargs) +
    +
    +

    A mixin for non-wellknown folders than that are not deletable.

    +
    + +Expand source code + +
    class Audits(NonDeletableFolder):
    +    get_folder_allowed = False
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var get_folder_allowed
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class BaseFolder +(**kwargs) +
    +
    +

    Base class for all classes that implement a folder.

    +
    + +Expand source code + +
    class BaseFolder(RegisterMixIn, SearchableMixIn, SupportedVersionClassMixIn, metaclass=EWSMeta):
    +    """Base class for all classes that implement a folder."""
    +
    +    ELEMENT_NAME = "Folder"
    +    NAMESPACE = TNS
    +    # See https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/distinguishedfolderid
    +    DISTINGUISHED_FOLDER_ID = None
    +    # Default item type for this folder. See
    +    # https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxosfld/68a85898-84fe-43c4-b166-4711c13cdd61
    +    CONTAINER_CLASS = None
    +    supported_item_models = ITEM_CLASSES  # The Item types that this folder can contain. Default is all
    +    # Whether this folder type is allowed with the GetFolder service
    +    get_folder_allowed = True
    +    DEFAULT_FOLDER_TRAVERSAL_DEPTH = DEEP_FOLDERS
    +    DEFAULT_ITEM_TRAVERSAL_DEPTH = SHALLOW_ITEMS
         LOCALIZED_NAMES = {}  # A map of (str)locale: (tuple)localized_folder_names
         ITEM_MODEL_MAP = {cls.response_tag(): cls for cls in ITEM_CLASSES}
         ID_ELEMENT_CLS = FolderId
     
         _id = IdElementField(field_uri="folder:FolderId", value_cls=ID_ELEMENT_CLS)
    -    _distinguished_id = IdElementField(field_uri="folder:FolderId", value_cls=DistinguishedFolderId)
    +    _distinguished_id = IdElementField(field_uri="folder:DistinguishedFolderId", value_cls=DistinguishedFolderId)
         parent_folder_id = EWSElementField(field_uri="folder:ParentFolderId", value_cls=ParentFolderId, is_read_only=True)
         folder_class = CharField(field_uri="folder:FolderClass", is_required_after_save=True)
         name = CharField(field_uri="folder:DisplayName")
    @@ -1412,7 +1672,7 @@ 

    Inherited members

    def localized_names(cls, locale): # Return localized names for a specific locale. If no locale-specific names exist, return the default names, # if any. - return tuple(s.lower() for s in cls.LOCALIZED_NAMES.get(locale, cls.LOCALIZED_NAMES.get(None, []))) + return tuple(s.lower() for s in cls.LOCALIZED_NAMES.get(locale, cls.LOCALIZED_NAMES.get(None, [cls.__name__]))) @staticmethod def folder_cls_from_container_class(container_class): @@ -2201,7 +2461,7 @@

    Static methods

    def localized_names(cls, locale): # Return localized names for a specific locale. If no locale-specific names exist, return the default names, # if any. - return tuple(s.lower() for s in cls.LOCALIZED_NAMES.get(locale, cls.LOCALIZED_NAMES.get(None, [])))
    + return tuple(s.lower() for s in cls.LOCALIZED_NAMES.get(locale, cls.LOCALIZED_NAMES.get(None, [cls.__name__])))
    @@ -3220,7 +3480,6 @@

    Inherited members

    class Birthdays(Folder):
         CONTAINER_CLASS = "IPF.Appointment.Birthday"
         LOCALIZED_NAMES = {
    -        None: ("Birthdays",),
             "da_DK": ("Fødselsdage",),
         }
    @@ -3293,13 +3552,12 @@

    Inherited members

    Expand source code -
    class Calendar(Folder):
    +
    class Calendar(WellknownFolder):
         """An interface for the Exchange calendar."""
     
         DISTINGUISHED_FOLDER_ID = "calendar"
         CONTAINER_CLASS = "IPF.Appointment"
         supported_item_models = (CalendarItem,)
    -
         LOCALIZED_NAMES = {
             "da_DK": ("Kalender",),
             "de_DE": ("Kalender",),
    @@ -3317,6 +3575,7 @@ 

    Inherited members

    Ancestors

    -

    A mixin for non-wellknown folders than that are not deletable.

    +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    class Companies(NonDeletableFolderMixIn, Contacts):
    -    DISTINGUISHED_FOLDER_ID = None
    +
    class Companies(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "companycontacts"
         CONTAINER_CLASS = "IPF.Contact.Company"
    +    supported_item_models = (Contact, DistributionList)
         LOCALIZED_NAMES = {
    -        None: ("Companies",),
             "da_DK": ("Firmaer",),
         }

    Ancestors

      -
    • NonDeletableFolderMixIn
    • -
    • Contacts
    • +
    • WellknownFolder
    • Folder
    • BaseFolder
    • RegisterMixIn
    • @@ -3589,41 +3847,45 @@

      Class variables

      +
      var supported_item_models
      +
      +
      +

      Inherited members

        -
      • Contacts: - @@ -3707,16 +3969,15 @@

        Inherited members

        (**kwargs)
        - +

        Base class to use until we have a more specific folder implementation for this folder.

        Expand source code -
        class Contacts(Folder):
        +
        class Contacts(WellknownFolder):
             DISTINGUISHED_FOLDER_ID = "contacts"
             CONTAINER_CLASS = "IPF.Contact"
             supported_item_models = (Contact, DistributionList)
        -
             LOCALIZED_NAMES = {
                 "da_DK": ("Kontaktpersoner",),
                 "de_DE": ("Kontakte",),
        @@ -3731,6 +3992,7 @@ 

        Inherited members

        Ancestors

        -

        Subclasses

        -

        Class variables

        var CONTAINER_CLASS
        @@ -3771,38 +4022,38 @@

        Class variables

        Inherited members

        @@ -3891,7 +4142,7 @@

        Inherited members

        Expand source code -
        class ConversationSettings(NonDeletableFolderMixIn, Folder):
        +
        class ConversationSettings(NonDeletableFolder):
             CONTAINER_CLASS = "IPF.Configuration"
             LOCALIZED_NAMES = {
                 "da_DK": ("Indstillinger for samtalehandlinger",),
        @@ -3899,7 +4150,7 @@ 

        Inherited members

        Ancestors

        +

        Static methods

        +
        +
        +def from_xml(elem, account) +
        +
        +
        +
        + +Expand source code + +
        @classmethod
        +def from_xml(cls, elem, account):
        +    return cls(id=elem.text or None)
        +
        +
        +

        Instance variables

        var mailbox
        @@ -4428,16 +4693,18 @@

        Inherited members

        (**kwargs)
        - +

        Base class to use until we have a more specific folder implementation for this folder.

        Expand source code -
        class DlpPolicyEvaluation(Folder):
        +
        class DlpPolicyEvaluation(WellknownFolder):
        +    DISTINGUISHED_FOLDER_ID = "dlppolicyevaluation"
             CONTAINER_CLASS = "IPF.StoreItem.DlpPolicyEvaluation"

        Ancestors

        Inherited members

        @@ -4496,14 +4767,13 @@

        Inherited members

        (**kwargs)
        - +

        Base class to use until we have a more specific folder implementation for this folder.

        Expand source code
        class Drafts(Messages):
             DISTINGUISHED_FOLDER_ID = "drafts"
        -
             LOCALIZED_NAMES = {
                 "da_DK": ("Kladder",),
                 "de_DE": ("Entwürfe",),
        @@ -4519,6 +4789,7 @@ 

        Inherited members

        Ancestors

        +

        Ancestors

        + +

        Inherited members

        + +
        +
        +class ExternalContacts +(**kwargs) +
        +
        +

        A mixin for non-wellknown folders than that are not deletable.

        +
        + +Expand source code + +
        class ExternalContacts(NonDeletableFolder):
        +    DISTINGUISHED_FOLDER_ID = None
        +    CONTAINER_CLASS = "IPF.Contact"
        +    supported_item_models = (Contact, DistributionList)
             LOCALIZED_NAMES = {
        -        None: ("ExchangeSyncData",),
        +        None: ("ExternalContacts",),
             }

        Ancestors

        Class variables

        -
        var LOCALIZED_NAMES
        +
        var CONTAINER_CLASS
        +
        +
        +
        +
        var DISTINGUISHED_FOLDER_ID
        +
        +
        +
        +
        var LOCALIZED_NAMES
        +
        +
        +
        +
        var supported_item_models

        Inherited members

        -def get_distinguished(root) +def get_distinguished(account)

        Get the distinguished folder for this folder class.

        -

        :param root: +

        :param account: :return:

        Expand source code
        @classmethod
        -def get_distinguished(cls, root):
        +def get_distinguished(cls, account):
             """Get the distinguished folder for this folder class.
         
        -    :param root:
        +    :param account:
             :return:
             """
             try:
                 return cls.resolve(
        -            account=root.account,
        +            account=account,
                     folder=DistinguishedFolderId(
                         id=cls.DISTINGUISHED_FOLDER_ID,
        -                mailbox=Mailbox(email_address=root.account.primary_smtp_address),
        +                mailbox=Mailbox(email_address=account.primary_smtp_address),
                     ),
                 )
             except MISSING_FOLDER_ERRORS:
        @@ -5403,1409 +5716,2527 @@ 

        Inherited members

        :param max_items: the max number of items to return (Default value = None) :param offset: the offset relative to the first item in the item collection (Default value = 0) - :return: a generator for the returned item IDs or items - """ - from ..services import FindItem + :return: a generator for the returned item IDs or items + """ + from ..services import FindItem + + if not self.folders: + log.debug("Folder list is empty") + return + if q.is_never(): + log.debug("Query will never return results") + return + depth, restriction, query_string = self._rinse_args( + q=q, depth=depth, additional_fields=additional_fields, field_validator=self.validate_item_field + ) + if calendar_view is not None and not isinstance(calendar_view, CalendarView): + raise InvalidTypeError("calendar_view", calendar_view, CalendarView) + + log.debug( + "Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)", + self.account, + self.folders, + shape, + depth, + additional_fields, + restriction.q if restriction else None, + ) + yield from FindItem(account=self.account, page_size=page_size).call( + folders=self.folders, + additional_fields=additional_fields, + restriction=restriction, + order_fields=order_fields, + shape=shape, + query_string=query_string, + depth=depth, + calendar_view=calendar_view, + max_items=calendar_view.max_items if calendar_view else max_items, + offset=offset, + ) + + def _get_single_folder(self): + if len(self.folders) > 1: + raise ValueError("Syncing folder hierarchy can only be done on a single folder") + if not self.folders: + log.debug("Folder list is empty") + return None + return self.folders[0] + + def find_people( + self, + q, + shape=ID_ONLY, + depth=None, + additional_fields=None, + order_fields=None, + page_size=None, + max_items=None, + offset=0, + ): + """Private method to call the FindPeople service. + + :param q: a Q instance containing any restrictions + :param shape: controls whether to return (id, changekey) tuples or Persona objects. If additional_fields is + non-null, we always return Persona objects. (Default value = ID_ONLY) + :param depth: controls the whether to return soft-deleted items or not. (Default value = None) + :param additional_fields: the extra properties we want on the return objects. Default is no properties. + :param order_fields: the SortOrder fields, if any (Default value = None) + :param page_size: the requested number of items per page (Default value = None) + :param max_items: the max number of items to return (Default value = None) + :param offset: the offset relative to the first item in the item collection (Default value = 0) + + :return: a generator for the returned personas + """ + from ..services import FindPeople + + folder = self._get_single_folder() + if q.is_never(): + log.debug("Query will never return results") + return + depth, restriction, query_string = self._rinse_args( + q=q, depth=depth, additional_fields=additional_fields, field_validator=Persona.validate_field + ) + + yield from FindPeople(account=self.account, page_size=page_size).call( + folder=folder, + additional_fields=additional_fields, + restriction=restriction, + order_fields=order_fields, + shape=shape, + query_string=query_string, + depth=depth, + max_items=max_items, + offset=offset, + ) + + def get_folder_fields(self, target_cls, is_complex=None): + return { + FieldPath(field=f) + for f in target_cls.supported_fields(version=self.account.version) + if is_complex is None or f.is_complex is is_complex + } + + def _get_target_cls(self): + # We may have root folders that don't support the same set of fields as normal folders. If there is a mix of + # both folder types in self.folders, raise an error, so we don't risk losing some fields in the query. + from .base import Folder + from .roots import RootOfHierarchy + + has_roots = False + has_non_roots = False + for f in self.folders: + if isinstance(f, RootOfHierarchy): + if has_non_roots: + raise ValueError(f"Cannot call GetFolder on a mix of folder types: {self.folders}") + has_roots = True + else: + if has_roots: + raise ValueError(f"Cannot call GetFolder on a mix of folder types: {self.folders}") + has_non_roots = True + return RootOfHierarchy if has_roots else Folder + + def _get_default_traversal_depth(self, traversal_attr): + unique_depths = {getattr(f, traversal_attr) for f in self.folders} + if len(unique_depths) == 1: + return unique_depths.pop() + raise ValueError( + f"Folders in this collection do not have a common {traversal_attr} value. You need to define an explicit " + f"traversal depth with QuerySet.depth() (values: {unique_depths})" + ) + + def _get_default_item_traversal_depth(self): + # When searching folders, some folders require 'Shallow' and others 'Associated' traversal depth. + return self._get_default_traversal_depth("DEFAULT_ITEM_TRAVERSAL_DEPTH") + + def _get_default_folder_traversal_depth(self): + # When searching folders, some folders require 'Shallow' and others 'Deep' traversal depth. + return self._get_default_traversal_depth("DEFAULT_FOLDER_TRAVERSAL_DEPTH") + + def resolve(self): + # Looks up the folders or folder IDs in the collection and returns full Folder instances with all fields set. + from .base import BaseFolder + + resolveable_folders = [] + for f in self.folders: + if isinstance(f, BaseFolder) and not f.get_folder_allowed: + log.debug("GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder", f) + yield f + else: + resolveable_folders.append(f) + # Fetch all properties for the remaining folders of folder IDs + additional_fields = self.get_folder_fields(target_cls=self._get_target_cls()) + yield from self.__class__(account=self.account, folders=resolveable_folders).get_folders( + additional_fields=additional_fields + ) + + @require_account + def find_folders( + self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None, offset=0 + ): + from ..services import FindFolder + + # 'depth' controls whether to return direct children or recurse into sub-folders + from .base import BaseFolder, Folder + + if q is None: + q = Q() + if not self.folders: + log.debug("Folder list is empty") + return + if q.is_never(): + log.debug("Query will never return results") + return + if q.is_empty(): + restriction = None + else: + restriction = Restriction(q, folders=self.folders, applies_to=Restriction.FOLDERS) + if depth is None: + depth = self._get_default_folder_traversal_depth() + if additional_fields is None: + # Default to all non-complex properties. Sub-folders will always be of class Folder + additional_fields = self.get_folder_fields(target_cls=Folder, is_complex=False) + else: + for f in additional_fields: + if f.field.is_complex: + raise ValueError(f"find_folders() does not support field {f.field.name!r}. Use get_folders().") + + # Add required fields + additional_fields.update( + (FieldPath(field=BaseFolder.get_field_by_fieldname(f)) for f in self.REQUIRED_FOLDER_FIELDS) + ) + + yield from FindFolder(account=self.account, page_size=page_size).call( + folders=self.folders, + additional_fields=additional_fields, + restriction=restriction, + shape=shape, + depth=depth, + max_items=max_items, + offset=offset, + ) + + def get_folders(self, additional_fields=None): + from ..services import GetFolder + + # Expand folders with their full set of properties + from .base import BaseFolder if not self.folders: log.debug("Folder list is empty") return - if q.is_never(): - log.debug("Query will never return results") - return - depth, restriction, query_string = self._rinse_args( - q=q, depth=depth, additional_fields=additional_fields, field_validator=self.validate_item_field - ) - if calendar_view is not None and not isinstance(calendar_view, CalendarView): - raise InvalidTypeError("calendar_view", calendar_view, CalendarView) + if additional_fields is None: + # Default to all complex properties + additional_fields = self.get_folder_fields(target_cls=self._get_target_cls(), is_complex=True) - log.debug( - "Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)", - self.account, - self.folders, - shape, - depth, - additional_fields, - restriction.q if restriction else None, + # Add required fields + additional_fields.update( + (FieldPath(field=BaseFolder.get_field_by_fieldname(f)) for f in self.REQUIRED_FOLDER_FIELDS) ) - yield from FindItem(account=self.account, page_size=page_size).call( + + yield from GetFolder(account=self.account).call( folders=self.folders, additional_fields=additional_fields, - restriction=restriction, - order_fields=order_fields, - shape=shape, - query_string=query_string, - depth=depth, - calendar_view=calendar_view, - max_items=calendar_view.max_items if calendar_view else max_items, - offset=offset, + shape=ID_ONLY, ) - def _get_single_folder(self): - if len(self.folders) > 1: - raise ValueError("Syncing folder hierarchy can only be done on a single folder") + def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60): + from ..services import SubscribeToPull + if not self.folders: log.debug("Folder list is empty") return None - return self.folders[0] + if event_types is None: + event_types = SubscribeToPull.EVENT_TYPES + return SubscribeToPull(account=self.account).get( + folders=self.folders, + event_types=event_types, + watermark=watermark, + timeout=timeout, + ) - def find_people( - self, - q, - shape=ID_ONLY, - depth=None, - additional_fields=None, - order_fields=None, - page_size=None, - max_items=None, - offset=0, - ): - """Private method to call the FindPeople service. + def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1): + from ..services import SubscribeToPush - :param q: a Q instance containing any restrictions - :param shape: controls whether to return (id, changekey) tuples or Persona objects. If additional_fields is - non-null, we always return Persona objects. (Default value = ID_ONLY) - :param depth: controls the whether to return soft-deleted items or not. (Default value = None) - :param additional_fields: the extra properties we want on the return objects. Default is no properties. - :param order_fields: the SortOrder fields, if any (Default value = None) - :param page_size: the requested number of items per page (Default value = None) - :param max_items: the max number of items to return (Default value = None) - :param offset: the offset relative to the first item in the item collection (Default value = 0) + if not self.folders: + log.debug("Folder list is empty") + return None + if event_types is None: + event_types = SubscribeToPush.EVENT_TYPES + return SubscribeToPush(account=self.account).get( + folders=self.folders, + event_types=event_types, + watermark=watermark, + status_frequency=status_frequency, + url=callback_url, + ) - :return: a generator for the returned personas + def subscribe_to_streaming(self, event_types=None): + from ..services import SubscribeToStreaming + + if not self.folders: + log.debug("Folder list is empty") + return None + if event_types is None: + event_types = SubscribeToStreaming.EVENT_TYPES + return SubscribeToStreaming(account=self.account).get(folders=self.folders, event_types=event_types) + + def pull_subscription(self, **kwargs): + return PullSubscription(target=self, **kwargs) + + def push_subscription(self, **kwargs): + return PushSubscription(target=self, **kwargs) + + def streaming_subscription(self, **kwargs): + return StreamingSubscription(target=self, **kwargs) + + def unsubscribe(self, subscription_id): + """Unsubscribe. Only applies to pull and streaming notifications. + + :param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|streaming]() + :return: True + + This method doesn't need the current collection instance, but it makes sense to keep the method along the other + sync methods. """ - from ..services import FindPeople + from ..services import Unsubscribe + + return Unsubscribe(account=self.account).get(subscription_id=subscription_id) + + def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None): + from ..services import SyncFolderItems folder = self._get_single_folder() - if q.is_never(): - log.debug("Query will never return results") - return - depth, restriction, query_string = self._rinse_args( - q=q, depth=depth, additional_fields=additional_fields, field_validator=Persona.validate_field - ) + if only_fields is None: + # We didn't restrict list of field paths. Get all fields from the server, including extended properties. + additional_fields = {FieldPath(field=f) for f in folder.allowed_item_fields(version=self.account.version)} + else: + for field in only_fields: + folder.validate_item_field(field=field, version=self.account.version) + # Remove ItemId and ChangeKey. We get them unconditionally + additional_fields = {f for f in folder.normalize_fields(fields=only_fields) if not f.field.is_attribute} - yield from FindPeople(account=self.account, page_size=page_size).call( - folder=folder, - additional_fields=additional_fields, - restriction=restriction, - order_fields=order_fields, - shape=shape, - query_string=query_string, - depth=depth, - max_items=max_items, - offset=offset, - ) + svc = SyncFolderItems(account=self.account) + while True: + yield from svc.call( + folder=folder, + shape=ID_ONLY, + additional_fields=additional_fields, + sync_state=sync_state, + ignore=ignore, + max_changes_returned=max_changes_returned, + sync_scope=sync_scope, + ) + if svc.sync_state == sync_state: + # We sometimes get the same sync_state back, even though includes_last_item_in_range is False. Stop here + break + sync_state = svc.sync_state # Set the new sync state in the next call + if svc.includes_last_item_in_range: # Try again if there are more items + break + raise SyncCompleted(sync_state=svc.sync_state) - def get_folder_fields(self, target_cls, is_complex=None): - return { - FieldPath(field=f) - for f in target_cls.supported_fields(version=self.account.version) - if is_complex is None or f.is_complex is is_complex - } + def sync_hierarchy(self, sync_state=None, only_fields=None): + from ..services import SyncFolderHierarchy - def _get_target_cls(self): - # We may have root folders that don't support the same set of fields as normal folders. If there is a mix of - # both folder types in self.folders, raise an error, so we don't risk losing some fields in the query. - from .base import Folder - from .roots import RootOfHierarchy + folder = self._get_single_folder() + if only_fields is None: + # We didn't restrict list of field paths. Get all fields from the server, including extended properties. + additional_fields = {FieldPath(field=f) for f in folder.supported_fields(version=self.account.version)} + else: + additional_fields = set() + for field_name in only_fields: + folder.validate_field(field=field_name, version=self.account.version) + f = folder.get_field_by_fieldname(fieldname=field_name) + if not f.is_attribute: + # Remove ItemId and ChangeKey. We get them unconditionally + additional_fields.add(FieldPath(field=f)) - has_roots = False - has_non_roots = False - for f in self.folders: - if isinstance(f, RootOfHierarchy): - if has_non_roots: - raise ValueError(f"Cannot call GetFolder on a mix of folder types: {self.folders}") - has_roots = True - else: - if has_roots: - raise ValueError(f"Cannot call GetFolder on a mix of folder types: {self.folders}") - has_non_roots = True - return RootOfHierarchy if has_roots else Folder + # Add required fields + additional_fields.update( + (FieldPath(field=folder.get_field_by_fieldname(f)) for f in self.REQUIRED_FOLDER_FIELDS) + ) + + svc = SyncFolderHierarchy(account=self.account) + while True: + yield from svc.call( + folder=folder, + shape=ID_ONLY, + additional_fields=additional_fields, + sync_state=sync_state, + ) + if svc.sync_state == sync_state: + # We sometimes get the same sync_state back, even though includes_last_item_in_range is False. Stop here + break + sync_state = svc.sync_state # Set the new sync state in the next call + if svc.includes_last_item_in_range: # Try again if there are more items + break + raise SyncCompleted(sync_state=svc.sync_state)
        +
        +

        Ancestors

        + +

        Class variables

        +
        +
        var REQUIRED_FOLDER_FIELDS
        +
        +
        +
        +
        +

        Instance variables

        +
        +
        var folders
        +
        +
        +
        + +Expand source code + +
        def __get__(self, obj, cls):
        +    if obj is None:
        +        return self
         
        -    def _get_default_traversal_depth(self, traversal_attr):
        -        unique_depths = {getattr(f, traversal_attr) for f in self.folders}
        -        if len(unique_depths) == 1:
        -            return unique_depths.pop()
        -        raise ValueError(
        -            f"Folders in this collection do not have a common {traversal_attr} value. You need to define an explicit "
        -            f"traversal depth with QuerySet.depth() (values: {unique_depths})"
        -        )
        +    obj_dict = obj.__dict__
        +    name = self.func.__name__
        +    with self.lock:
        +        try:
        +            # check if the value was computed before the lock was acquired
        +            return obj_dict[name]
         
        -    def _get_default_item_traversal_depth(self):
        -        # When searching folders, some folders require 'Shallow' and others 'Associated' traversal depth.
        -        return self._get_default_traversal_depth("DEFAULT_ITEM_TRAVERSAL_DEPTH")
        +        except KeyError:
        +            # if not, do the calculation and release the lock
        +            return obj_dict.setdefault(name, self.func(obj))
        +
        +
        +
        var supported_item_models
        +
        +
        +
        + +Expand source code + +
        @property
        +def supported_item_models(self):
        +    return tuple(item_model for folder in self.folders for item_model in folder.supported_item_models)
        +
        +
        +
        +

        Methods

        +
        +
        +def allowed_item_fields(self) +
        +
        +
        +
        + +Expand source code + +
        def allowed_item_fields(self):
        +    # Return non-ID fields of all item classes allowed in this folder type
        +    fields = set()
        +    for item_model in self.supported_item_models:
        +        fields.update(set(item_model.supported_fields(version=self.account.version)))
        +    return fields
        +
        +
        +
        +def filter(self, *args, **kwargs) +
        +
        +

        Find items in the folder(s).

        +

        Non-keyword args may be a list of Q instances.

        +

        Optional extra keyword arguments follow a Django-like QuerySet filter syntax (see +https://docs.djangoproject.com/en/1.10/ref/models/querysets/#field-lookups).

        +

        We don't support '__year' and other date-related lookups. We also don't support '__endswith' or '__iendswith'.

        +

        We support the additional '__not' lookup in place of Django's exclude() for simple cases. For more complicated +cases you need to create a Q object and use ~Q().

        +

        Examples

        +

        my_account.inbox.filter(datetime_received__gt=EWSDateTime(2016, 1, 1)) +my_account.calendar.filter(start__range=(EWSDateTime(2016, 1, 1), EWSDateTime(2017, 1, 1))) +my_account.tasks.filter(subject='Hi mom') +my_account.tasks.filter(subject__not='Hi mom') +my_account.tasks.filter(subject__contains='Foo') +my_account.tasks.filter(subject__icontains='foo')

        +

        'endswith' and 'iendswith' could be emulated by searching with 'contains' or 'icontains' and then +post-processing items. Fetch the field in question with additional_fields and remove items where the search +string is not a postfix.

        +
        + +Expand source code + +
        def filter(self, *args, **kwargs):
        +    """Find items in the folder(s).
         
        -    def _get_default_folder_traversal_depth(self):
        -        # When searching folders, some folders require 'Shallow' and others 'Deep' traversal depth.
        -        return self._get_default_traversal_depth("DEFAULT_FOLDER_TRAVERSAL_DEPTH")
        +    Non-keyword args may be a list of Q instances.
         
        -    def resolve(self):
        -        # Looks up the folders or folder IDs in the collection and returns full Folder instances with all fields set.
        -        from .base import BaseFolder
        +    Optional extra keyword arguments follow a Django-like QuerySet filter syntax (see
        +       https://docs.djangoproject.com/en/1.10/ref/models/querysets/#field-lookups).
         
        -        resolveable_folders = []
        -        for f in self.folders:
        -            if isinstance(f, BaseFolder) and not f.get_folder_allowed:
        -                log.debug("GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder", f)
        -                yield f
        -            else:
        -                resolveable_folders.append(f)
        -        # Fetch all properties for the remaining folders of folder IDs
        -        additional_fields = self.get_folder_fields(target_cls=self._get_target_cls())
        -        yield from self.__class__(account=self.account, folders=resolveable_folders).get_folders(
        -            additional_fields=additional_fields
        -        )
        +    We don't support '__year' and other date-related lookups. We also don't support '__endswith' or '__iendswith'.
         
        -    @require_account
        -    def find_folders(
        -        self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None, offset=0
        -    ):
        -        from ..services import FindFolder
        +    We support the additional '__not' lookup in place of Django's exclude() for simple cases. For more complicated
        +    cases you need to create a Q object and use ~Q().
         
        -        # 'depth' controls whether to return direct children or recurse into sub-folders
        -        from .base import BaseFolder, Folder
        +    Examples:
         
        -        if q is None:
        -            q = Q()
        -        if not self.folders:
        -            log.debug("Folder list is empty")
        -            return
        -        if q.is_never():
        -            log.debug("Query will never return results")
        -            return
        -        if q.is_empty():
        -            restriction = None
        -        else:
        -            restriction = Restriction(q, folders=self.folders, applies_to=Restriction.FOLDERS)
        -        if depth is None:
        -            depth = self._get_default_folder_traversal_depth()
        -        if additional_fields is None:
        -            # Default to all non-complex properties. Sub-folders will always be of class Folder
        -            additional_fields = self.get_folder_fields(target_cls=Folder, is_complex=False)
        -        else:
        -            for f in additional_fields:
        -                if f.field.is_complex:
        -                    raise ValueError(f"find_folders() does not support field {f.field.name!r}. Use get_folders().")
        +        my_account.inbox.filter(datetime_received__gt=EWSDateTime(2016, 1, 1))
        +        my_account.calendar.filter(start__range=(EWSDateTime(2016, 1, 1), EWSDateTime(2017, 1, 1)))
        +        my_account.tasks.filter(subject='Hi mom')
        +        my_account.tasks.filter(subject__not='Hi mom')
        +        my_account.tasks.filter(subject__contains='Foo')
        +        my_account.tasks.filter(subject__icontains='foo')
         
        -        # Add required fields
        -        additional_fields.update(
        -            (FieldPath(field=BaseFolder.get_field_by_fieldname(f)) for f in self.REQUIRED_FOLDER_FIELDS)
        -        )
        +    'endswith' and 'iendswith' could be emulated by searching with 'contains' or 'icontains' and then
        +    post-processing items. Fetch the field in question with additional_fields and remove items where the search
        +    string is not a postfix.
        +    """
        +    return QuerySet(self).filter(*args, **kwargs)
        +
        +
        +
        +def find_folders(self, q=None, shape='IdOnly', depth=None, additional_fields=None, page_size=None, max_items=None, offset=0) +
        +
        +
        +
        + +Expand source code + +
        @require_account
        +def find_folders(
        +    self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None, offset=0
        +):
        +    from ..services import FindFolder
         
        -        yield from FindFolder(account=self.account, page_size=page_size).call(
        -            folders=self.folders,
        -            additional_fields=additional_fields,
        -            restriction=restriction,
        -            shape=shape,
        -            depth=depth,
        -            max_items=max_items,
        -            offset=offset,
        -        )
        +    # 'depth' controls whether to return direct children or recurse into sub-folders
        +    from .base import BaseFolder, Folder
         
        -    def get_folders(self, additional_fields=None):
        -        from ..services import GetFolder
        +    if q is None:
        +        q = Q()
        +    if not self.folders:
        +        log.debug("Folder list is empty")
        +        return
        +    if q.is_never():
        +        log.debug("Query will never return results")
        +        return
        +    if q.is_empty():
        +        restriction = None
        +    else:
        +        restriction = Restriction(q, folders=self.folders, applies_to=Restriction.FOLDERS)
        +    if depth is None:
        +        depth = self._get_default_folder_traversal_depth()
        +    if additional_fields is None:
        +        # Default to all non-complex properties. Sub-folders will always be of class Folder
        +        additional_fields = self.get_folder_fields(target_cls=Folder, is_complex=False)
        +    else:
        +        for f in additional_fields:
        +            if f.field.is_complex:
        +                raise ValueError(f"find_folders() does not support field {f.field.name!r}. Use get_folders().")
         
        -        # Expand folders with their full set of properties
        -        from .base import BaseFolder
        +    # Add required fields
        +    additional_fields.update(
        +        (FieldPath(field=BaseFolder.get_field_by_fieldname(f)) for f in self.REQUIRED_FOLDER_FIELDS)
        +    )
         
        -        if not self.folders:
        -            log.debug("Folder list is empty")
        -            return
        -        if additional_fields is None:
        -            # Default to all complex properties
        -            additional_fields = self.get_folder_fields(target_cls=self._get_target_cls(), is_complex=True)
        +    yield from FindFolder(account=self.account, page_size=page_size).call(
        +        folders=self.folders,
        +        additional_fields=additional_fields,
        +        restriction=restriction,
        +        shape=shape,
        +        depth=depth,
        +        max_items=max_items,
        +        offset=offset,
        +    )
        +
        +
        +
        +def find_items(self, q, shape='IdOnly', depth=None, additional_fields=None, order_fields=None, calendar_view=None, page_size=None, max_items=None, offset=0) +
        +
        +

        Private method to call the FindItem service.

        +

        :param q: a Q instance containing any restrictions +:param shape: controls whether to return (id, changekey) tuples or Item objects. If additional_fields is +non-null, we always return Item objects. (Default value = ID_ONLY) +:param depth: controls the whether to return soft-deleted items or not. (Default value = None) +:param additional_fields: the extra properties we want on the return objects. Default is no properties. Be aware +that complex fields can only be fetched with fetch() (i.e. the GetItem service). +:param order_fields: the SortOrder fields, if any (Default value = None) +:param calendar_view: a CalendarView instance, if any (Default value = None) +:param page_size: the requested number of items per page (Default value = None) +:param max_items: the max number of items to return (Default value = None) +:param offset: the offset relative to the first item in the item collection (Default value = 0)

        +

        :return: a generator for the returned item IDs or items

        +
        + +Expand source code + +
        def find_items(
        +    self,
        +    q,
        +    shape=ID_ONLY,
        +    depth=None,
        +    additional_fields=None,
        +    order_fields=None,
        +    calendar_view=None,
        +    page_size=None,
        +    max_items=None,
        +    offset=0,
        +):
        +    """Private method to call the FindItem service.
         
        -        # Add required fields
        -        additional_fields.update(
        -            (FieldPath(field=BaseFolder.get_field_by_fieldname(f)) for f in self.REQUIRED_FOLDER_FIELDS)
        -        )
        +    :param q: a Q instance containing any restrictions
        +    :param shape: controls whether to return (id, changekey) tuples or Item objects. If additional_fields is
        +      non-null, we always return Item objects. (Default value = ID_ONLY)
        +    :param depth: controls the whether to return soft-deleted items or not. (Default value = None)
        +    :param additional_fields: the extra properties we want on the return objects. Default is no properties. Be aware
        +      that complex fields can only be fetched with fetch() (i.e. the GetItem service).
        +    :param order_fields: the SortOrder fields, if any (Default value = None)
        +    :param calendar_view: a CalendarView instance, if any (Default value = None)
        +    :param page_size: the requested number of items per page (Default value = None)
        +    :param max_items: the max number of items to return (Default value = None)
        +    :param offset: the offset relative to the first item in the item collection (Default value = 0)
         
        -        yield from GetFolder(account=self.account).call(
        -            folders=self.folders,
        -            additional_fields=additional_fields,
        -            shape=ID_ONLY,
        -        )
        +    :return: a generator for the returned item IDs or items
        +    """
        +    from ..services import FindItem
         
        -    def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60):
        -        from ..services import SubscribeToPull
        +    if not self.folders:
        +        log.debug("Folder list is empty")
        +        return
        +    if q.is_never():
        +        log.debug("Query will never return results")
        +        return
        +    depth, restriction, query_string = self._rinse_args(
        +        q=q, depth=depth, additional_fields=additional_fields, field_validator=self.validate_item_field
        +    )
        +    if calendar_view is not None and not isinstance(calendar_view, CalendarView):
        +        raise InvalidTypeError("calendar_view", calendar_view, CalendarView)
         
        -        if not self.folders:
        -            log.debug("Folder list is empty")
        -            return None
        -        if event_types is None:
        -            event_types = SubscribeToPull.EVENT_TYPES
        -        return SubscribeToPull(account=self.account).get(
        -            folders=self.folders,
        -            event_types=event_types,
        -            watermark=watermark,
        -            timeout=timeout,
        -        )
        +    log.debug(
        +        "Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)",
        +        self.account,
        +        self.folders,
        +        shape,
        +        depth,
        +        additional_fields,
        +        restriction.q if restriction else None,
        +    )
        +    yield from FindItem(account=self.account, page_size=page_size).call(
        +        folders=self.folders,
        +        additional_fields=additional_fields,
        +        restriction=restriction,
        +        order_fields=order_fields,
        +        shape=shape,
        +        query_string=query_string,
        +        depth=depth,
        +        calendar_view=calendar_view,
        +        max_items=calendar_view.max_items if calendar_view else max_items,
        +        offset=offset,
        +    )
        +
        +
        +
        +def find_people(self, q, shape='IdOnly', depth=None, additional_fields=None, order_fields=None, page_size=None, max_items=None, offset=0) +
        +
        +

        Private method to call the FindPeople service.

        +

        :param q: a Q instance containing any restrictions +:param shape: controls whether to return (id, changekey) tuples or Persona objects. If additional_fields is +non-null, we always return Persona objects. (Default value = ID_ONLY) +:param depth: controls the whether to return soft-deleted items or not. (Default value = None) +:param additional_fields: the extra properties we want on the return objects. Default is no properties. +:param order_fields: the SortOrder fields, if any (Default value = None) +:param page_size: the requested number of items per page (Default value = None) +:param max_items: the max number of items to return (Default value = None) +:param offset: the offset relative to the first item in the item collection (Default value = 0)

        +

        :return: a generator for the returned personas

        +
        + +Expand source code + +
        def find_people(
        +    self,
        +    q,
        +    shape=ID_ONLY,
        +    depth=None,
        +    additional_fields=None,
        +    order_fields=None,
        +    page_size=None,
        +    max_items=None,
        +    offset=0,
        +):
        +    """Private method to call the FindPeople service.
         
        -    def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1):
        -        from ..services import SubscribeToPush
        +    :param q: a Q instance containing any restrictions
        +    :param shape: controls whether to return (id, changekey) tuples or Persona objects. If additional_fields is
        +      non-null, we always return Persona objects. (Default value = ID_ONLY)
        +    :param depth: controls the whether to return soft-deleted items or not. (Default value = None)
        +    :param additional_fields: the extra properties we want on the return objects. Default is no properties.
        +    :param order_fields: the SortOrder fields, if any (Default value = None)
        +    :param page_size: the requested number of items per page (Default value = None)
        +    :param max_items: the max number of items to return (Default value = None)
        +    :param offset: the offset relative to the first item in the item collection (Default value = 0)
         
        -        if not self.folders:
        -            log.debug("Folder list is empty")
        -            return None
        -        if event_types is None:
        -            event_types = SubscribeToPush.EVENT_TYPES
        -        return SubscribeToPush(account=self.account).get(
        -            folders=self.folders,
        -            event_types=event_types,
        -            watermark=watermark,
        -            status_frequency=status_frequency,
        -            url=callback_url,
        -        )
        +    :return: a generator for the returned personas
        +    """
        +    from ..services import FindPeople
         
        -    def subscribe_to_streaming(self, event_types=None):
        -        from ..services import SubscribeToStreaming
        +    folder = self._get_single_folder()
        +    if q.is_never():
        +        log.debug("Query will never return results")
        +        return
        +    depth, restriction, query_string = self._rinse_args(
        +        q=q, depth=depth, additional_fields=additional_fields, field_validator=Persona.validate_field
        +    )
         
        -        if not self.folders:
        -            log.debug("Folder list is empty")
        -            return None
        -        if event_types is None:
        -            event_types = SubscribeToStreaming.EVENT_TYPES
        -        return SubscribeToStreaming(account=self.account).get(folders=self.folders, event_types=event_types)
        +    yield from FindPeople(account=self.account, page_size=page_size).call(
        +        folder=folder,
        +        additional_fields=additional_fields,
        +        restriction=restriction,
        +        order_fields=order_fields,
        +        shape=shape,
        +        query_string=query_string,
        +        depth=depth,
        +        max_items=max_items,
        +        offset=offset,
        +    )
        +
        +
        +
        +def get_folder_fields(self, target_cls, is_complex=None) +
        +
        +
        +
        + +Expand source code + +
        def get_folder_fields(self, target_cls, is_complex=None):
        +    return {
        +        FieldPath(field=f)
        +        for f in target_cls.supported_fields(version=self.account.version)
        +        if is_complex is None or f.is_complex is is_complex
        +    }
        +
        +
        +
        +def get_folders(self, additional_fields=None) +
        +
        +
        +
        + +Expand source code + +
        def get_folders(self, additional_fields=None):
        +    from ..services import GetFolder
         
        -    def pull_subscription(self, **kwargs):
        -        return PullSubscription(target=self, **kwargs)
        +    # Expand folders with their full set of properties
        +    from .base import BaseFolder
         
        -    def push_subscription(self, **kwargs):
        -        return PushSubscription(target=self, **kwargs)
        +    if not self.folders:
        +        log.debug("Folder list is empty")
        +        return
        +    if additional_fields is None:
        +        # Default to all complex properties
        +        additional_fields = self.get_folder_fields(target_cls=self._get_target_cls(), is_complex=True)
         
        -    def streaming_subscription(self, **kwargs):
        -        return StreamingSubscription(target=self, **kwargs)
        +    # Add required fields
        +    additional_fields.update(
        +        (FieldPath(field=BaseFolder.get_field_by_fieldname(f)) for f in self.REQUIRED_FOLDER_FIELDS)
        +    )
         
        -    def unsubscribe(self, subscription_id):
        -        """Unsubscribe. Only applies to pull and streaming notifications.
        +    yield from GetFolder(account=self.account).call(
        +        folders=self.folders,
        +        additional_fields=additional_fields,
        +        shape=ID_ONLY,
        +    )
        +
        +
        +
        +def pull_subscription(self, **kwargs) +
        +
        +
        +
        + +Expand source code + +
        def pull_subscription(self, **kwargs):
        +    return PullSubscription(target=self, **kwargs)
        +
        +
        +
        +def push_subscription(self, **kwargs) +
        +
        +
        +
        + +Expand source code + +
        def push_subscription(self, **kwargs):
        +    return PushSubscription(target=self, **kwargs)
        +
        +
        +
        +def resolve(self) +
        +
        +
        +
        + +Expand source code + +
        def resolve(self):
        +    # Looks up the folders or folder IDs in the collection and returns full Folder instances with all fields set.
        +    from .base import BaseFolder
         
        -        :param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|streaming]()
        -        :return: True
        +    resolveable_folders = []
        +    for f in self.folders:
        +        if isinstance(f, BaseFolder) and not f.get_folder_allowed:
        +            log.debug("GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder", f)
        +            yield f
        +        else:
        +            resolveable_folders.append(f)
        +    # Fetch all properties for the remaining folders of folder IDs
        +    additional_fields = self.get_folder_fields(target_cls=self._get_target_cls())
        +    yield from self.__class__(account=self.account, folders=resolveable_folders).get_folders(
        +        additional_fields=additional_fields
        +    )
        +
        +
        +
        +def streaming_subscription(self, **kwargs) +
        +
        +
        +
        + +Expand source code + +
        def streaming_subscription(self, **kwargs):
        +    return StreamingSubscription(target=self, **kwargs)
        +
        +
        +
        +def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60) +
        +
        +
        +
        + +Expand source code + +
        def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60):
        +    from ..services import SubscribeToPull
         
        -        This method doesn't need the current collection instance, but it makes sense to keep the method along the other
        -        sync methods.
        -        """
        -        from ..services import Unsubscribe
        +    if not self.folders:
        +        log.debug("Folder list is empty")
        +        return None
        +    if event_types is None:
        +        event_types = SubscribeToPull.EVENT_TYPES
        +    return SubscribeToPull(account=self.account).get(
        +        folders=self.folders,
        +        event_types=event_types,
        +        watermark=watermark,
        +        timeout=timeout,
        +    )
        +
        +
        +
        +def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1) +
        +
        +
        +
        + +Expand source code + +
        def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1):
        +    from ..services import SubscribeToPush
         
        -        return Unsubscribe(account=self.account).get(subscription_id=subscription_id)
        +    if not self.folders:
        +        log.debug("Folder list is empty")
        +        return None
        +    if event_types is None:
        +        event_types = SubscribeToPush.EVENT_TYPES
        +    return SubscribeToPush(account=self.account).get(
        +        folders=self.folders,
        +        event_types=event_types,
        +        watermark=watermark,
        +        status_frequency=status_frequency,
        +        url=callback_url,
        +    )
        +
        +
        +
        +def subscribe_to_streaming(self, event_types=None) +
        +
        +
        +
        + +Expand source code + +
        def subscribe_to_streaming(self, event_types=None):
        +    from ..services import SubscribeToStreaming
         
        -    def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None):
        -        from ..services import SyncFolderItems
        +    if not self.folders:
        +        log.debug("Folder list is empty")
        +        return None
        +    if event_types is None:
        +        event_types = SubscribeToStreaming.EVENT_TYPES
        +    return SubscribeToStreaming(account=self.account).get(folders=self.folders, event_types=event_types)
        +
        +
        +
        +def sync_hierarchy(self, sync_state=None, only_fields=None) +
        +
        +
        +
        + +Expand source code + +
        def sync_hierarchy(self, sync_state=None, only_fields=None):
        +    from ..services import SyncFolderHierarchy
         
        -        folder = self._get_single_folder()
        -        if only_fields is None:
        -            # We didn't restrict list of field paths. Get all fields from the server, including extended properties.
        -            additional_fields = {FieldPath(field=f) for f in folder.allowed_item_fields(version=self.account.version)}
        -        else:
        -            for field in only_fields:
        -                folder.validate_item_field(field=field, version=self.account.version)
        -            # Remove ItemId and ChangeKey. We get them unconditionally
        -            additional_fields = {f for f in folder.normalize_fields(fields=only_fields) if not f.field.is_attribute}
        +    folder = self._get_single_folder()
        +    if only_fields is None:
        +        # We didn't restrict list of field paths. Get all fields from the server, including extended properties.
        +        additional_fields = {FieldPath(field=f) for f in folder.supported_fields(version=self.account.version)}
        +    else:
        +        additional_fields = set()
        +        for field_name in only_fields:
        +            folder.validate_field(field=field_name, version=self.account.version)
        +            f = folder.get_field_by_fieldname(fieldname=field_name)
        +            if not f.is_attribute:
        +                # Remove ItemId and ChangeKey. We get them unconditionally
        +                additional_fields.add(FieldPath(field=f))
         
        -        svc = SyncFolderItems(account=self.account)
        -        while True:
        -            yield from svc.call(
        -                folder=folder,
        -                shape=ID_ONLY,
        -                additional_fields=additional_fields,
        -                sync_state=sync_state,
        -                ignore=ignore,
        -                max_changes_returned=max_changes_returned,
        -                sync_scope=sync_scope,
        -            )
        -            if svc.sync_state == sync_state:
        -                # We sometimes get the same sync_state back, even though includes_last_item_in_range is False. Stop here
        -                break
        -            sync_state = svc.sync_state  # Set the new sync state in the next call
        -            if svc.includes_last_item_in_range:  # Try again if there are more items
        -                break
        -        raise SyncCompleted(sync_state=svc.sync_state)
        +    # Add required fields
        +    additional_fields.update(
        +        (FieldPath(field=folder.get_field_by_fieldname(f)) for f in self.REQUIRED_FOLDER_FIELDS)
        +    )
         
        -    def sync_hierarchy(self, sync_state=None, only_fields=None):
        -        from ..services import SyncFolderHierarchy
        +    svc = SyncFolderHierarchy(account=self.account)
        +    while True:
        +        yield from svc.call(
        +            folder=folder,
        +            shape=ID_ONLY,
        +            additional_fields=additional_fields,
        +            sync_state=sync_state,
        +        )
        +        if svc.sync_state == sync_state:
        +            # We sometimes get the same sync_state back, even though includes_last_item_in_range is False. Stop here
        +            break
        +        sync_state = svc.sync_state  # Set the new sync state in the next call
        +        if svc.includes_last_item_in_range:  # Try again if there are more items
        +            break
        +    raise SyncCompleted(sync_state=svc.sync_state)
        +
        +
        +
        +def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None) +
        +
        +
        +
        + +Expand source code + +
        def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None):
        +    from ..services import SyncFolderItems
         
        -        folder = self._get_single_folder()
        -        if only_fields is None:
        -            # We didn't restrict list of field paths. Get all fields from the server, including extended properties.
        -            additional_fields = {FieldPath(field=f) for f in folder.supported_fields(version=self.account.version)}
        -        else:
        -            additional_fields = set()
        -            for field_name in only_fields:
        -                folder.validate_field(field=field_name, version=self.account.version)
        -                f = folder.get_field_by_fieldname(fieldname=field_name)
        -                if not f.is_attribute:
        -                    # Remove ItemId and ChangeKey. We get them unconditionally
        -                    additional_fields.add(FieldPath(field=f))
        +    folder = self._get_single_folder()
        +    if only_fields is None:
        +        # We didn't restrict list of field paths. Get all fields from the server, including extended properties.
        +        additional_fields = {FieldPath(field=f) for f in folder.allowed_item_fields(version=self.account.version)}
        +    else:
        +        for field in only_fields:
        +            folder.validate_item_field(field=field, version=self.account.version)
        +        # Remove ItemId and ChangeKey. We get them unconditionally
        +        additional_fields = {f for f in folder.normalize_fields(fields=only_fields) if not f.field.is_attribute}
         
        -        # Add required fields
        -        additional_fields.update(
        -            (FieldPath(field=folder.get_field_by_fieldname(f)) for f in self.REQUIRED_FOLDER_FIELDS)
        +    svc = SyncFolderItems(account=self.account)
        +    while True:
        +        yield from svc.call(
        +            folder=folder,
        +            shape=ID_ONLY,
        +            additional_fields=additional_fields,
        +            sync_state=sync_state,
        +            ignore=ignore,
        +            max_changes_returned=max_changes_returned,
        +            sync_scope=sync_scope,
                 )
        +        if svc.sync_state == sync_state:
        +            # We sometimes get the same sync_state back, even though includes_last_item_in_range is False. Stop here
        +            break
        +        sync_state = svc.sync_state  # Set the new sync state in the next call
        +        if svc.includes_last_item_in_range:  # Try again if there are more items
        +            break
        +    raise SyncCompleted(sync_state=svc.sync_state)
        +
        +
        +
        +def unsubscribe(self, subscription_id) +
        +
        +

        Unsubscribe. Only applies to pull and streaming notifications.

        +

        :param subscription_id: A subscription ID as acquired by .subscribe_to_pull|streaming +:return: True

        +

        This method doesn't need the current collection instance, but it makes sense to keep the method along the other +sync methods.

        +
        + +Expand source code + +
        def unsubscribe(self, subscription_id):
        +    """Unsubscribe. Only applies to pull and streaming notifications.
         
        -        svc = SyncFolderHierarchy(account=self.account)
        -        while True:
        -            yield from svc.call(
        -                folder=folder,
        -                shape=ID_ONLY,
        -                additional_fields=additional_fields,
        -                sync_state=sync_state,
        -            )
        -            if svc.sync_state == sync_state:
        -                # We sometimes get the same sync_state back, even though includes_last_item_in_range is False. Stop here
        -                break
        -            sync_state = svc.sync_state  # Set the new sync state in the next call
        -            if svc.includes_last_item_in_range:  # Try again if there are more items
        -                break
        -        raise SyncCompleted(sync_state=svc.sync_state)
        + :param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|streaming]() + :return: True + + This method doesn't need the current collection instance, but it makes sense to keep the method along the other + sync methods. + """ + from ..services import Unsubscribe + + return Unsubscribe(account=self.account).get(subscription_id=subscription_id)
        -

        Ancestors

        - -

        Class variables

        -
        -
        var REQUIRED_FOLDER_FIELDS
        -
        -
        -
        -

        Instance variables

        -
        -
        var folders
        +
        +def validate_item_field(self, field, version) +
        Expand source code -
        def __get__(self, obj, cls):
        -    if obj is None:
        -        return self
        -
        -    obj_dict = obj.__dict__
        -    name = self.func.__name__
        -    with self.lock:
        +
        def validate_item_field(self, field, version):
        +    # Takes a fieldname, Field or FieldPath object pointing to an item field, and checks that it is valid
        +    # for the item types supported by this folder collection.
        +    for item_model in self.supported_item_models:
                 try:
        -            # check if the value was computed before the lock was acquired
        -            return obj_dict[name]
        -
        -        except KeyError:
        -            # if not, do the calculation and release the lock
        -            return obj_dict.setdefault(name, self.func(obj))
        + item_model.validate_field(field=field, version=version) + break + except InvalidField: + continue + else: + raise InvalidField(f"{field!r} is not a valid field on {self.supported_item_models}")
        -
        var supported_item_models
        +
        +def view(self, start, end, max_items=None, *args, **kwargs) +
        -
        +

        Implement the CalendarView option to FindItem. The difference between 'filter' and 'view' is that 'filter' +only returns the master CalendarItem for recurring items, while 'view' unfolds recurring items and returns all +CalendarItem occurrences as one would normally expect when presenting a calendar.

        +

        Supports the same semantics as filter, except for 'start' and 'end' keyword attributes which are both required +and behave differently than filter. Here, they denote the start and end of the timespan of the view. All items +the overlap the timespan are returned (items that end exactly on 'start' are also returned, for some reason).

        +

        EWS does not allow combining CalendarView with search restrictions (filter and exclude).

        +

        'max_items' defines the maximum number of items returned in this view. Optional.

        +

        :param start: +:param end: +:param max_items: +(Default value = None) +:return:

        Expand source code -
        @property
        -def supported_item_models(self):
        -    return tuple(item_model for folder in self.folders for item_model in folder.supported_item_models)
        +
        def view(self, start, end, max_items=None, *args, **kwargs):
        +    """Implement the CalendarView option to FindItem. The difference between 'filter' and 'view' is that 'filter'
        +    only returns the master CalendarItem for recurring items, while 'view' unfolds recurring items and returns all
        +    CalendarItem occurrences as one would normally expect when presenting a calendar.
        +
        +    Supports the same semantics as filter, except for 'start' and 'end' keyword attributes which are both required
        +    and behave differently than filter. Here, they denote the start and end of the timespan of the view. All items
        +    the overlap the timespan are returned (items that end exactly on 'start' are also returned, for some reason).
        +
        +    EWS does not allow combining CalendarView with search restrictions (filter and exclude).
        +
        +    'max_items' defines the maximum number of items returned in this view. Optional.
        +
        +    :param start:
        +    :param end:
        +    :param max_items:  (Default value = None)
        +    :return:
        +    """
        +    qs = QuerySet(self).filter(*args, **kwargs)
        +    qs.calendar_view = CalendarView(start=start, end=end, max_items=max_items)
        +    return qs
        -

        Methods

        -
        -
        -def allowed_item_fields(self) +

        Inherited members

        + +
        +
        +class FolderId +(*args, **kwargs)
        -
        +
        Expand source code -
        def allowed_item_fields(self):
        -    # Return non-ID fields of all item classes allowed in this folder type
        -    fields = set()
        -    for item_model in self.supported_item_models:
        -        fields.update(set(item_model.supported_fields(version=self.account.version)))
        -    return fields
        +
        class FolderId(ItemId):
        +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/folderid"""
        +
        +    ELEMENT_NAME = "FolderId"
        +

        Ancestors

        + +

        Subclasses

        + +

        Class variables

        +
        +
        var ELEMENT_NAME
        +
        +
        -
        -def filter(self, *args, **kwargs) +
        +

        Inherited members

        + +
        +
        +class FolderMemberships +(**kwargs)
        -

        Find items in the folder(s).

        -

        Non-keyword args may be a list of Q instances.

        -

        Optional extra keyword arguments follow a Django-like QuerySet filter syntax (see -https://docs.djangoproject.com/en/1.10/ref/models/querysets/#field-lookups).

        -

        We don't support '__year' and other date-related lookups. We also don't support '__endswith' or '__iendswith'.

        -

        We support the additional '__not' lookup in place of Django's exclude() for simple cases. For more complicated -cases you need to create a Q object and use ~Q().

        -

        Examples

        -

        my_account.inbox.filter(datetime_received__gt=EWSDateTime(2016, 1, 1)) -my_account.calendar.filter(start__range=(EWSDateTime(2016, 1, 1), EWSDateTime(2017, 1, 1))) -my_account.tasks.filter(subject='Hi mom') -my_account.tasks.filter(subject__not='Hi mom') -my_account.tasks.filter(subject__contains='Foo') -my_account.tasks.filter(subject__icontains='foo')

        -

        'endswith' and 'iendswith' could be emulated by searching with 'contains' or 'icontains' and then -post-processing items. Fetch the field in question with additional_fields and remove items where the search -string is not a postfix.

        +
        Expand source code -
        def filter(self, *args, **kwargs):
        -    """Find items in the folder(s).
        -
        -    Non-keyword args may be a list of Q instances.
        -
        -    Optional extra keyword arguments follow a Django-like QuerySet filter syntax (see
        -       https://docs.djangoproject.com/en/1.10/ref/models/querysets/#field-lookups).
        -
        -    We don't support '__year' and other date-related lookups. We also don't support '__endswith' or '__iendswith'.
        -
        -    We support the additional '__not' lookup in place of Django's exclude() for simple cases. For more complicated
        -    cases you need to create a Q object and use ~Q().
        -
        -    Examples:
        -
        -        my_account.inbox.filter(datetime_received__gt=EWSDateTime(2016, 1, 1))
        -        my_account.calendar.filter(start__range=(EWSDateTime(2016, 1, 1), EWSDateTime(2017, 1, 1)))
        -        my_account.tasks.filter(subject='Hi mom')
        -        my_account.tasks.filter(subject__not='Hi mom')
        -        my_account.tasks.filter(subject__contains='Foo')
        -        my_account.tasks.filter(subject__icontains='foo')
        -
        -    'endswith' and 'iendswith' could be emulated by searching with 'contains' or 'icontains' and then
        -    post-processing items. Fetch the field in question with additional_fields and remove items where the search
        -    string is not a postfix.
        -    """
        -    return QuerySet(self).filter(*args, **kwargs)
        +
        class FolderMemberships(Folder):
        +    CONTAINER_CLASS = "IPF.Task"
        +    LOCALIZED_NAMES = {
        +        None: ("Folder Memberships",),
        +    }
        +

        Ancestors

        + +

        Class variables

        +
        +
        var CONTAINER_CLASS
        +
        +
        +
        +
        var LOCALIZED_NAMES
        +
        +
        +
        +
        +

        Inherited members

        +
        -
        -def find_folders(self, q=None, shape='IdOnly', depth=None, additional_fields=None, page_size=None, max_items=None, offset=0) +
        +class FolderQuerySet +(folder_collection)
        -
        +

        A QuerySet-like class for finding sub-folders of a folder collection.

        Expand source code -
        @require_account
        -def find_folders(
        -    self, q=None, shape=ID_ONLY, depth=None, additional_fields=None, page_size=None, max_items=None, offset=0
        -):
        -    from ..services import FindFolder
        +
        class FolderQuerySet:
        +    """A QuerySet-like class for finding sub-folders of a folder collection."""
         
        -    # 'depth' controls whether to return direct children or recurse into sub-folders
        -    from .base import BaseFolder, Folder
        +    def __init__(self, folder_collection):
        +        from .collections import FolderCollection
         
        -    if q is None:
        -        q = Q()
        -    if not self.folders:
        -        log.debug("Folder list is empty")
        -        return
        -    if q.is_never():
        -        log.debug("Query will never return results")
        -        return
        -    if q.is_empty():
        -        restriction = None
        -    else:
        -        restriction = Restriction(q, folders=self.folders, applies_to=Restriction.FOLDERS)
        -    if depth is None:
        -        depth = self._get_default_folder_traversal_depth()
        -    if additional_fields is None:
        -        # Default to all non-complex properties. Sub-folders will always be of class Folder
        -        additional_fields = self.get_folder_fields(target_cls=Folder, is_complex=False)
        -    else:
        -        for f in additional_fields:
        -            if f.field.is_complex:
        -                raise ValueError(f"find_folders() does not support field {f.field.name!r}. Use get_folders().")
        +        if not isinstance(folder_collection, FolderCollection):
        +            raise InvalidTypeError("folder_collection", folder_collection, FolderCollection)
        +        self.folder_collection = folder_collection
        +        self.q = Q()  # Default to no restrictions
        +        self.only_fields = None
        +        self._depth = None
         
        -    # Add required fields
        -    additional_fields.update(
        -        (FieldPath(field=BaseFolder.get_field_by_fieldname(f)) for f in self.REQUIRED_FOLDER_FIELDS)
        -    )
        +    def _copy_cls(self):
        +        return self.__class__(folder_collection=self.folder_collection)
         
        -    yield from FindFolder(account=self.account, page_size=page_size).call(
        -        folders=self.folders,
        -        additional_fields=additional_fields,
        -        restriction=restriction,
        -        shape=shape,
        -        depth=depth,
        -        max_items=max_items,
        -        offset=offset,
        -    )
        -
        -
        -
        -def find_items(self, q, shape='IdOnly', depth=None, additional_fields=None, order_fields=None, calendar_view=None, page_size=None, max_items=None, offset=0) -
        -
        -

        Private method to call the FindItem service.

        -

        :param q: a Q instance containing any restrictions -:param shape: controls whether to return (id, changekey) tuples or Item objects. If additional_fields is -non-null, we always return Item objects. (Default value = ID_ONLY) -:param depth: controls the whether to return soft-deleted items or not. (Default value = None) -:param additional_fields: the extra properties we want on the return objects. Default is no properties. Be aware -that complex fields can only be fetched with fetch() (i.e. the GetItem service). -:param order_fields: the SortOrder fields, if any (Default value = None) -:param calendar_view: a CalendarView instance, if any (Default value = None) -:param page_size: the requested number of items per page (Default value = None) -:param max_items: the max number of items to return (Default value = None) -:param offset: the offset relative to the first item in the item collection (Default value = 0)

        -

        :return: a generator for the returned item IDs or items

        -
        - -Expand source code - -
        def find_items(
        -    self,
        -    q,
        -    shape=ID_ONLY,
        -    depth=None,
        -    additional_fields=None,
        -    order_fields=None,
        -    calendar_view=None,
        -    page_size=None,
        -    max_items=None,
        -    offset=0,
        -):
        -    """Private method to call the FindItem service.
        +    def _copy_self(self):
        +        """Chaining operations must make a copy of self before making any modifications."""
        +        new_qs = self._copy_cls()
        +        new_qs.q = deepcopy(self.q)
        +        new_qs.only_fields = self.only_fields
        +        new_qs._depth = self._depth
        +        return new_qs
         
        -    :param q: a Q instance containing any restrictions
        -    :param shape: controls whether to return (id, changekey) tuples or Item objects. If additional_fields is
        -      non-null, we always return Item objects. (Default value = ID_ONLY)
        -    :param depth: controls the whether to return soft-deleted items or not. (Default value = None)
        -    :param additional_fields: the extra properties we want on the return objects. Default is no properties. Be aware
        -      that complex fields can only be fetched with fetch() (i.e. the GetItem service).
        -    :param order_fields: the SortOrder fields, if any (Default value = None)
        -    :param calendar_view: a CalendarView instance, if any (Default value = None)
        -    :param page_size: the requested number of items per page (Default value = None)
        -    :param max_items: the max number of items to return (Default value = None)
        -    :param offset: the offset relative to the first item in the item collection (Default value = 0)
        +    def only(self, *args):
        +        """Restrict the fields returned. 'name' and 'folder_class' are always returned."""
        +        from .base import Folder
         
        -    :return: a generator for the returned item IDs or items
        -    """
        -    from ..services import FindItem
        +        # Sub-folders will always be of class Folder
        +        all_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=None)
        +        all_fields.update(Folder.attribute_fields())
        +        only_fields = []
        +        for arg in args:
        +            for field_path in all_fields:
        +                if field_path.field.name == arg:
        +                    only_fields.append(field_path)
        +                    break
        +            else:
        +                raise InvalidField(f"Unknown field {arg!r} on folders {self.folder_collection.folders}")
        +        new_qs = self._copy_self()
        +        new_qs.only_fields = only_fields
        +        return new_qs
         
        -    if not self.folders:
        -        log.debug("Folder list is empty")
        -        return
        -    if q.is_never():
        -        log.debug("Query will never return results")
        -        return
        -    depth, restriction, query_string = self._rinse_args(
        -        q=q, depth=depth, additional_fields=additional_fields, field_validator=self.validate_item_field
        -    )
        -    if calendar_view is not None and not isinstance(calendar_view, CalendarView):
        -        raise InvalidTypeError("calendar_view", calendar_view, CalendarView)
        +    def depth(self, depth):
        +        """Specify the search depth. Possible values are: SHALLOW or DEEP.
         
        -    log.debug(
        -        "Finding %s items in folders %s (shape: %s, depth: %s, additional_fields: %s, restriction: %s)",
        -        self.account,
        -        self.folders,
        -        shape,
        -        depth,
        -        additional_fields,
        -        restriction.q if restriction else None,
        -    )
        -    yield from FindItem(account=self.account, page_size=page_size).call(
        -        folders=self.folders,
        -        additional_fields=additional_fields,
        -        restriction=restriction,
        -        order_fields=order_fields,
        -        shape=shape,
        -        query_string=query_string,
        -        depth=depth,
        -        calendar_view=calendar_view,
        -        max_items=calendar_view.max_items if calendar_view else max_items,
        -        offset=offset,
        -    )
        + :param depth: + """ + new_qs = self._copy_self() + new_qs._depth = depth + return new_qs + + def get(self, *args, **kwargs): + """Return the query result as exactly one item. Raises DoesNotExist if there are no results, and + MultipleObjectsReturned if there are multiple results. + """ + from .collections import FolderCollection + + if not args and set(kwargs) in ({"id"}, {"id", "changekey"}): + folders = list( + FolderCollection(account=self.folder_collection.account, folders=[FolderId(**kwargs)]).resolve() + ) + elif args or kwargs: + folders = list(self.filter(*args, **kwargs)) + else: + folders = list(self.all()) + if not folders: + raise DoesNotExist("Could not find a child folder matching the query") + if len(folders) != 1: + raise MultipleObjectsReturned(f"Expected result length 1, but got {folders}") + f = folders[0] + if isinstance(f, Exception): + raise f + return f + + def all(self): + """ """ + new_qs = self._copy_self() + return new_qs + + def filter(self, *args, **kwargs): + """Add restrictions to the folder search.""" + new_qs = self._copy_self() + q = Q(*args, **kwargs) + new_qs.q = new_qs.q & q + return new_qs + + def __iter__(self): + return self._query() + + def _query(self): + from .base import Folder + from .collections import FolderCollection + + if self.only_fields is None: + # Sub-folders will always be of class Folder + non_complex_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=False) + complex_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=True) + else: + non_complex_fields = {f for f in self.only_fields if not f.field.is_complex} + complex_fields = {f for f in self.only_fields if f.field.is_complex} + + # First, fetch all non-complex fields using FindFolder. We do this because some folders do not support + # GetFolder, but we still want to get as much information as possible. + folders = self.folder_collection.find_folders(q=self.q, depth=self._depth, additional_fields=non_complex_fields) + if not complex_fields: + yield from folders + return + + # Fetch all properties for the found folders + resolveable_folders = [] + for f in folders: + if isinstance(f, Exception): + yield f + continue + if not f.get_folder_allowed: + log.debug("GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder", f) + yield f + else: + resolveable_folders.append(f) + + # Get the complex fields using GetFolder, for the folders that support it, and add the extra field values + complex_folders = FolderCollection( + account=self.folder_collection.account, folders=resolveable_folders + ).get_folders(additional_fields=complex_fields) + for f, complex_f in zip(resolveable_folders, complex_folders): + if isinstance(f, MISSING_FOLDER_ERRORS): + # We were unlucky. The folder disappeared between the FindFolder and the GetFolder calls + continue + if isinstance(complex_f, Exception): + yield complex_f + continue + # Add the extra field values to the folders we fetched with find_folders() + if f.__class__ != complex_f.__class__: + raise ValueError(f"Type mismatch: {f} vs {complex_f}") + for complex_field in complex_fields: + field_name = complex_field.field.name + setattr(f, field_name, getattr(complex_f, field_name)) + yield f
        +
        +

        Subclasses

        + +

        Methods

        +
        +
        +def all(self) +
        +
        +
        +
        + +Expand source code + +
        def all(self):
        +    """ """
        +    new_qs = self._copy_self()
        +    return new_qs
        -
        -def find_people(self, q, shape='IdOnly', depth=None, additional_fields=None, order_fields=None, page_size=None, max_items=None, offset=0) +
        +def depth(self, depth)
        -

        Private method to call the FindPeople service.

        -

        :param q: a Q instance containing any restrictions -:param shape: controls whether to return (id, changekey) tuples or Persona objects. If additional_fields is -non-null, we always return Persona objects. (Default value = ID_ONLY) -:param depth: controls the whether to return soft-deleted items or not. (Default value = None) -:param additional_fields: the extra properties we want on the return objects. Default is no properties. -:param order_fields: the SortOrder fields, if any (Default value = None) -:param page_size: the requested number of items per page (Default value = None) -:param max_items: the max number of items to return (Default value = None) -:param offset: the offset relative to the first item in the item collection (Default value = 0)

        -

        :return: a generator for the returned personas

        +

        Specify the search depth. Possible values are: SHALLOW or DEEP.

        +

        :param depth:

        Expand source code -
        def find_people(
        -    self,
        -    q,
        -    shape=ID_ONLY,
        -    depth=None,
        -    additional_fields=None,
        -    order_fields=None,
        -    page_size=None,
        -    max_items=None,
        -    offset=0,
        -):
        -    """Private method to call the FindPeople service.
        -
        -    :param q: a Q instance containing any restrictions
        -    :param shape: controls whether to return (id, changekey) tuples or Persona objects. If additional_fields is
        -      non-null, we always return Persona objects. (Default value = ID_ONLY)
        -    :param depth: controls the whether to return soft-deleted items or not. (Default value = None)
        -    :param additional_fields: the extra properties we want on the return objects. Default is no properties.
        -    :param order_fields: the SortOrder fields, if any (Default value = None)
        -    :param page_size: the requested number of items per page (Default value = None)
        -    :param max_items: the max number of items to return (Default value = None)
        -    :param offset: the offset relative to the first item in the item collection (Default value = 0)
        +
        def depth(self, depth):
        +    """Specify the search depth. Possible values are: SHALLOW or DEEP.
         
        -    :return: a generator for the returned personas
        +    :param depth:
             """
        -    from ..services import FindPeople
        -
        -    folder = self._get_single_folder()
        -    if q.is_never():
        -        log.debug("Query will never return results")
        -        return
        -    depth, restriction, query_string = self._rinse_args(
        -        q=q, depth=depth, additional_fields=additional_fields, field_validator=Persona.validate_field
        -    )
        -
        -    yield from FindPeople(account=self.account, page_size=page_size).call(
        -        folder=folder,
        -        additional_fields=additional_fields,
        -        restriction=restriction,
        -        order_fields=order_fields,
        -        shape=shape,
        -        query_string=query_string,
        -        depth=depth,
        -        max_items=max_items,
        -        offset=offset,
        -    )
        + new_qs = self._copy_self() + new_qs._depth = depth + return new_qs
        -
        -def get_folder_fields(self, target_cls, is_complex=None) +
        +def filter(self, *args, **kwargs)
        -
        +

        Add restrictions to the folder search.

        Expand source code -
        def get_folder_fields(self, target_cls, is_complex=None):
        -    return {
        -        FieldPath(field=f)
        -        for f in target_cls.supported_fields(version=self.account.version)
        -        if is_complex is None or f.is_complex is is_complex
        -    }
        +
        def filter(self, *args, **kwargs):
        +    """Add restrictions to the folder search."""
        +    new_qs = self._copy_self()
        +    q = Q(*args, **kwargs)
        +    new_qs.q = new_qs.q & q
        +    return new_qs
        -
        -def get_folders(self, additional_fields=None) +
        +def get(self, *args, **kwargs)
        -
        +

        Return the query result as exactly one item. Raises DoesNotExist if there are no results, and +MultipleObjectsReturned if there are multiple results.

        Expand source code -
        def get_folders(self, additional_fields=None):
        -    from ..services import GetFolder
        -
        -    # Expand folders with their full set of properties
        -    from .base import BaseFolder
        -
        -    if not self.folders:
        -        log.debug("Folder list is empty")
        -        return
        -    if additional_fields is None:
        -        # Default to all complex properties
        -        additional_fields = self.get_folder_fields(target_cls=self._get_target_cls(), is_complex=True)
        +
        def get(self, *args, **kwargs):
        +    """Return the query result as exactly one item. Raises DoesNotExist if there are no results, and
        +    MultipleObjectsReturned if there are multiple results.
        +    """
        +    from .collections import FolderCollection
         
        -    # Add required fields
        -    additional_fields.update(
        -        (FieldPath(field=BaseFolder.get_field_by_fieldname(f)) for f in self.REQUIRED_FOLDER_FIELDS)
        -    )
        +    if not args and set(kwargs) in ({"id"}, {"id", "changekey"}):
        +        folders = list(
        +            FolderCollection(account=self.folder_collection.account, folders=[FolderId(**kwargs)]).resolve()
        +        )
        +    elif args or kwargs:
        +        folders = list(self.filter(*args, **kwargs))
        +    else:
        +        folders = list(self.all())
        +    if not folders:
        +        raise DoesNotExist("Could not find a child folder matching the query")
        +    if len(folders) != 1:
        +        raise MultipleObjectsReturned(f"Expected result length 1, but got {folders}")
        +    f = folders[0]
        +    if isinstance(f, Exception):
        +        raise f
        +    return f
        +
        +
        +
        +def only(self, *args) +
        +
        +

        Restrict the fields returned. 'name' and 'folder_class' are always returned.

        +
        + +Expand source code + +
        def only(self, *args):
        +    """Restrict the fields returned. 'name' and 'folder_class' are always returned."""
        +    from .base import Folder
         
        -    yield from GetFolder(account=self.account).call(
        -        folders=self.folders,
        -        additional_fields=additional_fields,
        -        shape=ID_ONLY,
        -    )
        + # Sub-folders will always be of class Folder + all_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=None) + all_fields.update(Folder.attribute_fields()) + only_fields = [] + for arg in args: + for field_path in all_fields: + if field_path.field.name == arg: + only_fields.append(field_path) + break + else: + raise InvalidField(f"Unknown field {arg!r} on folders {self.folder_collection.folders}") + new_qs = self._copy_self() + new_qs.only_fields = only_fields + return new_qs
        -
        -def pull_subscription(self, **kwargs) +
        +
        +
        +class FreeBusyCache +(**kwargs)
        -
        +
        Expand source code -
        def pull_subscription(self, **kwargs):
        -    return PullSubscription(target=self, **kwargs)
        +
        class FreeBusyCache(Folder):
        +    CONTAINER_CLASS = "IPF.StoreItem.FreeBusyCache"
        +

        Ancestors

        + +

        Class variables

        +
        +
        var CONTAINER_CLASS
        +
        +
        +
        +
        +

        Inherited members

        +
        -
        -def push_subscription(self, **kwargs) +
        +class FreebusyData +(**kwargs)
        -
        +

        A mixin for non-wellknown folders than that are not deletable.

        Expand source code -
        def push_subscription(self, **kwargs):
        -    return PushSubscription(target=self, **kwargs)
        +
        class FreebusyData(NonDeletableFolder):
        +    LOCALIZED_NAMES = {
        +        None: ("Freebusy Data",),
        +    }
        +

        Ancestors

        + +

        Class variables

        +
        +
        var LOCALIZED_NAMES
        +
        +
        -
        -def resolve(self) +
        +

        Inherited members

        + +
        +
        +class Friends +(**kwargs)
        -
        +

        A mixin for non-wellknown folders than that are not deletable.

        Expand source code -
        def resolve(self):
        -    # Looks up the folders or folder IDs in the collection and returns full Folder instances with all fields set.
        -    from .base import BaseFolder
        -
        -    resolveable_folders = []
        -    for f in self.folders:
        -        if isinstance(f, BaseFolder) and not f.get_folder_allowed:
        -            log.debug("GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder", f)
        -            yield f
        -        else:
        -            resolveable_folders.append(f)
        -    # Fetch all properties for the remaining folders of folder IDs
        -    additional_fields = self.get_folder_fields(target_cls=self._get_target_cls())
        -    yield from self.__class__(account=self.account, folders=resolveable_folders).get_folders(
        -        additional_fields=additional_fields
        -    )
        +
        class Friends(NonDeletableFolder):
        +    CONTAINER_CLASS = "IPF.Note"
        +    supported_item_models = (Contact, DistributionList)
        +    LOCALIZED_NAMES = {
        +        "de_DE": ("Bekannte",),
        +    }
        +

        Ancestors

        + +

        Class variables

        +
        +
        var CONTAINER_CLASS
        +
        +
        -
        -def streaming_subscription(self, **kwargs) -
        +
        var LOCALIZED_NAMES
        -
        - -Expand source code - -
        def streaming_subscription(self, **kwargs):
        -    return StreamingSubscription(target=self, **kwargs)
        -
        -
        -def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60) -
        +
        var supported_item_models
        -
        - -Expand source code - -
        def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60):
        -    from ..services import SubscribeToPull
        -
        -    if not self.folders:
        -        log.debug("Folder list is empty")
        -        return None
        -    if event_types is None:
        -        event_types = SubscribeToPull.EVENT_TYPES
        -    return SubscribeToPull(account=self.account).get(
        -        folders=self.folders,
        -        event_types=event_types,
        -        watermark=watermark,
        -        timeout=timeout,
        -    )
        -
        -
        -def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1) +
        +

        Inherited members

        + +
        +
        +class FromFavoriteSenders +(**kwargs)
        -
        +

        Base class to use until we have a more specific folder implementation for this folder.

        Expand source code -
        def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1):
        -    from ..services import SubscribeToPush
        -
        -    if not self.folders:
        -        log.debug("Folder list is empty")
        -        return None
        -    if event_types is None:
        -        event_types = SubscribeToPush.EVENT_TYPES
        -    return SubscribeToPush(account=self.account).get(
        -        folders=self.folders,
        -        event_types=event_types,
        -        watermark=watermark,
        -        status_frequency=status_frequency,
        -        url=callback_url,
        -    )
        +
        class FromFavoriteSenders(WellknownFolder):
        +    CONTAINER_CLASS = "IPF.Note"
        +    DISTINGUISHED_FOLDER_ID = "fromfavoritesenders"
        +    LOCALIZED_NAMES = {
        +        "da_DK": ("Personer jeg kender",),
        +    }
        +

        Ancestors

        + +

        Class variables

        +
        +
        var CONTAINER_CLASS
        +
        +
        -
        -def subscribe_to_streaming(self, event_types=None) -
        +
        var DISTINGUISHED_FOLDER_ID
        -
        - -Expand source code - -
        def subscribe_to_streaming(self, event_types=None):
        -    from ..services import SubscribeToStreaming
        -
        -    if not self.folders:
        -        log.debug("Folder list is empty")
        -        return None
        -    if event_types is None:
        -        event_types = SubscribeToStreaming.EVENT_TYPES
        -    return SubscribeToStreaming(account=self.account).get(folders=self.folders, event_types=event_types)
        -
        -
        -def sync_hierarchy(self, sync_state=None, only_fields=None) -
        +
        var LOCALIZED_NAMES
        -
        - -Expand source code - -
        def sync_hierarchy(self, sync_state=None, only_fields=None):
        -    from ..services import SyncFolderHierarchy
        -
        -    folder = self._get_single_folder()
        -    if only_fields is None:
        -        # We didn't restrict list of field paths. Get all fields from the server, including extended properties.
        -        additional_fields = {FieldPath(field=f) for f in folder.supported_fields(version=self.account.version)}
        -    else:
        -        additional_fields = set()
        -        for field_name in only_fields:
        -            folder.validate_field(field=field_name, version=self.account.version)
        -            f = folder.get_field_by_fieldname(fieldname=field_name)
        -            if not f.is_attribute:
        -                # Remove ItemId and ChangeKey. We get them unconditionally
        -                additional_fields.add(FieldPath(field=f))
        -
        -    # Add required fields
        -    additional_fields.update(
        -        (FieldPath(field=folder.get_field_by_fieldname(f)) for f in self.REQUIRED_FOLDER_FIELDS)
        -    )
        -
        -    svc = SyncFolderHierarchy(account=self.account)
        -    while True:
        -        yield from svc.call(
        -            folder=folder,
        -            shape=ID_ONLY,
        -            additional_fields=additional_fields,
        -            sync_state=sync_state,
        -        )
        -        if svc.sync_state == sync_state:
        -            # We sometimes get the same sync_state back, even though includes_last_item_in_range is False. Stop here
        -            break
        -        sync_state = svc.sync_state  # Set the new sync state in the next call
        -        if svc.includes_last_item_in_range:  # Try again if there are more items
        -            break
        -    raise SyncCompleted(sync_state=svc.sync_state)
        -
        -
        -def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None) +
        +

        Inherited members

        + +
        +
        +class GALContacts +(**kwargs)
        -
        +

        A mixin for non-wellknown folders than that are not deletable.

        Expand source code -
        def sync_items(self, sync_state=None, only_fields=None, ignore=None, max_changes_returned=None, sync_scope=None):
        -    from ..services import SyncFolderItems
        -
        -    folder = self._get_single_folder()
        -    if only_fields is None:
        -        # We didn't restrict list of field paths. Get all fields from the server, including extended properties.
        -        additional_fields = {FieldPath(field=f) for f in folder.allowed_item_fields(version=self.account.version)}
        -    else:
        -        for field in only_fields:
        -            folder.validate_item_field(field=field, version=self.account.version)
        -        # Remove ItemId and ChangeKey. We get them unconditionally
        -        additional_fields = {f for f in folder.normalize_fields(fields=only_fields) if not f.field.is_attribute}
        -
        -    svc = SyncFolderItems(account=self.account)
        -    while True:
        -        yield from svc.call(
        -            folder=folder,
        -            shape=ID_ONLY,
        -            additional_fields=additional_fields,
        -            sync_state=sync_state,
        -            ignore=ignore,
        -            max_changes_returned=max_changes_returned,
        -            sync_scope=sync_scope,
        -        )
        -        if svc.sync_state == sync_state:
        -            # We sometimes get the same sync_state back, even though includes_last_item_in_range is False. Stop here
        -            break
        -        sync_state = svc.sync_state  # Set the new sync state in the next call
        -        if svc.includes_last_item_in_range:  # Try again if there are more items
        -            break
        -    raise SyncCompleted(sync_state=svc.sync_state)
        +
        class GALContacts(NonDeletableFolder):
        +    CONTAINER_CLASS = "IPF.Contact.GalContacts"
        +    supported_item_models = (Contact, DistributionList)
        +    LOCALIZED_NAMES = {
        +        None: ("GAL Contacts",),
        +    }
        +

        Ancestors

        + +

        Class variables

        +
        +
        var CONTAINER_CLASS
        +
        +
        -
        -def unsubscribe(self, subscription_id) +
        var LOCALIZED_NAMES
        +
        +
        +
        +
        var supported_item_models
        +
        +
        +
        +
        +

        Inherited members

        + +
        +
        +class GraphAnalytics +(**kwargs)
        -

        Unsubscribe. Only applies to pull and streaming notifications.

        -

        :param subscription_id: A subscription ID as acquired by .subscribe_to_pull|streaming -:return: True

        -

        This method doesn't need the current collection instance, but it makes sense to keep the method along the other -sync methods.

        +

        A mixin for non-wellknown folders than that are not deletable.

        Expand source code -
        def unsubscribe(self, subscription_id):
        -    """Unsubscribe. Only applies to pull and streaming notifications.
        -
        -    :param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|streaming]()
        -    :return: True
        -
        -    This method doesn't need the current collection instance, but it makes sense to keep the method along the other
        -    sync methods.
        -    """
        -    from ..services import Unsubscribe
        -
        -    return Unsubscribe(account=self.account).get(subscription_id=subscription_id)
        +
        class GraphAnalytics(NonDeletableFolder):
        +    CONTAINER_CLASS = "IPF.StoreItem.GraphAnalytics"
        +

        Ancestors

        + +

        Class variables

        +
        +
        var CONTAINER_CLASS
        +
        +
        -
        -def validate_item_field(self, field, version) +
        +

        Inherited members

        + +
        +
        +class IMContactList +(**kwargs)
        -
        +

        Base class to use until we have a more specific folder implementation for this folder.

        Expand source code -
        def validate_item_field(self, field, version):
        -    # Takes a fieldname, Field or FieldPath object pointing to an item field, and checks that it is valid
        -    # for the item types supported by this folder collection.
        -    for item_model in self.supported_item_models:
        -        try:
        -            item_model.validate_field(field=field, version=version)
        -            break
        -        except InvalidField:
        -            continue
        -    else:
        -        raise InvalidField(f"{field!r} is not a valid field on {self.supported_item_models}")
        +
        class IMContactList(WellknownFolder):
        +    CONTAINER_CLASS = "IPF.Contact.MOC.ImContactList"
        +    DISTINGUISHED_FOLDER_ID = "imcontactlist"
        +    supported_from = EXCHANGE_2013
        +

        Ancestors

        + +

        Class variables

        +
        +
        var CONTAINER_CLASS
        +
        +
        -
        -def view(self, start, end, max_items=None, *args, **kwargs) +
        var DISTINGUISHED_FOLDER_ID
        +
        +
        +
        +
        var supported_from
        +
        +
        +
        +
        +

        Inherited members

        + +
        +
        +class Inbox +(**kwargs)
        -

        Implement the CalendarView option to FindItem. The difference between 'filter' and 'view' is that 'filter' -only returns the master CalendarItem for recurring items, while 'view' unfolds recurring items and returns all -CalendarItem occurrences as one would normally expect when presenting a calendar.

        -

        Supports the same semantics as filter, except for 'start' and 'end' keyword attributes which are both required -and behave differently than filter. Here, they denote the start and end of the timespan of the view. All items -the overlap the timespan are returned (items that end exactly on 'start' are also returned, for some reason).

        -

        EWS does not allow combining CalendarView with search restrictions (filter and exclude).

        -

        'max_items' defines the maximum number of items returned in this view. Optional.

        -

        :param start: -:param end: -:param max_items: -(Default value = None) -:return:

        +

        Base class to use until we have a more specific folder implementation for this folder.

        Expand source code -
        def view(self, start, end, max_items=None, *args, **kwargs):
        -    """Implement the CalendarView option to FindItem. The difference between 'filter' and 'view' is that 'filter'
        -    only returns the master CalendarItem for recurring items, while 'view' unfolds recurring items and returns all
        -    CalendarItem occurrences as one would normally expect when presenting a calendar.
        -
        -    Supports the same semantics as filter, except for 'start' and 'end' keyword attributes which are both required
        -    and behave differently than filter. Here, they denote the start and end of the timespan of the view. All items
        -    the overlap the timespan are returned (items that end exactly on 'start' are also returned, for some reason).
        -
        -    EWS does not allow combining CalendarView with search restrictions (filter and exclude).
        -
        -    'max_items' defines the maximum number of items returned in this view. Optional.
        -
        -    :param start:
        -    :param end:
        -    :param max_items:  (Default value = None)
        -    :return:
        -    """
        -    qs = QuerySet(self).filter(*args, **kwargs)
        -    qs.calendar_view = CalendarView(start=start, end=end, max_items=max_items)
        -    return qs
        +
        class Inbox(Messages):
        +    DISTINGUISHED_FOLDER_ID = "inbox"
        +    LOCALIZED_NAMES = {
        +        "da_DK": ("Indbakke",),
        +        "de_DE": ("Posteingang",),
        +        "en_US": ("Inbox",),
        +        "es_ES": ("Bandeja de entrada",),
        +        "fr_CA": ("Boîte de réception",),
        +        "nl_NL": ("Postvak IN",),
        +        "ru_RU": ("Входящие",),
        +        "sv_SE": ("Inkorgen",),
        +        "zh_CN": ("收件箱",),
        +    }
        +

        Ancestors

        + +

        Class variables

        +
        +
        var DISTINGUISHED_FOLDER_ID
        +
        +
        +
        +
        var LOCALIZED_NAMES
        +
        +

        Inherited members

        -
        -class FolderId -(*args, **kwargs) +
        +class Inference +(**kwargs)
        - +

        Base class to use until we have a more specific folder implementation for this folder.

        Expand source code -
        class FolderId(ItemId):
        -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/folderid"""
        -
        -    ELEMENT_NAME = "FolderId"
        +
        class Inference(WellknownFolder):
        +    DISTINGUISHED_FOLDER_ID = "inference"

        Ancestors

        -

        Subclasses

        -

        Class variables

        -
        var ELEMENT_NAME
        +
        var DISTINGUISHED_FOLDER_ID

        Inherited members

        -
        -
        -class FolderQuerySet -(folder_collection) -
        -
        -

        A QuerySet-like class for finding sub-folders of a folder collection.

        -
        - -Expand source code - -
        class FolderQuerySet:
        -    """A QuerySet-like class for finding sub-folders of a folder collection."""
        -
        -    def __init__(self, folder_collection):
        -        from .collections import FolderCollection
        -
        -        if not isinstance(folder_collection, FolderCollection):
        -            raise InvalidTypeError("folder_collection", folder_collection, FolderCollection)
        -        self.folder_collection = folder_collection
        -        self.q = Q()  # Default to no restrictions
        -        self.only_fields = None
        -        self._depth = None
        -
        -    def _copy_cls(self):
        -        return self.__class__(folder_collection=self.folder_collection)
        -
        -    def _copy_self(self):
        -        """Chaining operations must make a copy of self before making any modifications."""
        -        new_qs = self._copy_cls()
        -        new_qs.q = deepcopy(self.q)
        -        new_qs.only_fields = self.only_fields
        -        new_qs._depth = self._depth
        -        return new_qs
        -
        -    def only(self, *args):
        -        """Restrict the fields returned. 'name' and 'folder_class' are always returned."""
        -        from .base import Folder
        -
        -        # Sub-folders will always be of class Folder
        -        all_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=None)
        -        all_fields.update(Folder.attribute_fields())
        -        only_fields = []
        -        for arg in args:
        -            for field_path in all_fields:
        -                if field_path.field.name == arg:
        -                    only_fields.append(field_path)
        -                    break
        -            else:
        -                raise InvalidField(f"Unknown field {arg!r} on folders {self.folder_collection.folders}")
        -        new_qs = self._copy_self()
        -        new_qs.only_fields = only_fields
        -        return new_qs
        -
        -    def depth(self, depth):
        -        """Specify the search depth. Possible values are: SHALLOW or DEEP.
        -
        -        :param depth:
        -        """
        -        new_qs = self._copy_self()
        -        new_qs._depth = depth
        -        return new_qs
        -
        -    def get(self, *args, **kwargs):
        -        """Return the query result as exactly one item. Raises DoesNotExist if there are no results, and
        -        MultipleObjectsReturned if there are multiple results.
        -        """
        -        from .collections import FolderCollection
        -
        -        if not args and set(kwargs) in ({"id"}, {"id", "changekey"}):
        -            folders = list(
        -                FolderCollection(account=self.folder_collection.account, folders=[FolderId(**kwargs)]).resolve()
        -            )
        -        elif args or kwargs:
        -            folders = list(self.filter(*args, **kwargs))
        -        else:
        -            folders = list(self.all())
        -        if not folders:
        -            raise DoesNotExist("Could not find a child folder matching the query")
        -        if len(folders) != 1:
        -            raise MultipleObjectsReturned(f"Expected result length 1, but got {folders}")
        -        f = folders[0]
        -        if isinstance(f, Exception):
        -            raise f
        -        return f
        -
        -    def all(self):
        -        """ """
        -        new_qs = self._copy_self()
        -        return new_qs
        -
        -    def filter(self, *args, **kwargs):
        -        """Add restrictions to the folder search."""
        -        new_qs = self._copy_self()
        -        q = Q(*args, **kwargs)
        -        new_qs.q = new_qs.q & q
        -        return new_qs
        -
        -    def __iter__(self):
        -        return self._query()
        -
        -    def _query(self):
        -        from .base import Folder
        -        from .collections import FolderCollection
        -
        -        if self.only_fields is None:
        -            # Sub-folders will always be of class Folder
        -            non_complex_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=False)
        -            complex_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=True)
        -        else:
        -            non_complex_fields = {f for f in self.only_fields if not f.field.is_complex}
        -            complex_fields = {f for f in self.only_fields if f.field.is_complex}
        -
        -        # First, fetch all non-complex fields using FindFolder. We do this because some folders do not support
        -        # GetFolder, but we still want to get as much information as possible.
        -        folders = self.folder_collection.find_folders(q=self.q, depth=self._depth, additional_fields=non_complex_fields)
        -        if not complex_fields:
        -            yield from folders
        -            return
        -
        -        # Fetch all properties for the found folders
        -        resolveable_folders = []
        -        for f in folders:
        -            if isinstance(f, Exception):
        -                yield f
        -                continue
        -            if not f.get_folder_allowed:
        -                log.debug("GetFolder not allowed on folder %s. Non-complex fields must be fetched with FindFolder", f)
        -                yield f
        -            else:
        -                resolveable_folders.append(f)
        -
        -        # Get the complex fields using GetFolder, for the folders that support it, and add the extra field values
        -        complex_folders = FolderCollection(
        -            account=self.folder_collection.account, folders=resolveable_folders
        -        ).get_folders(additional_fields=complex_fields)
        -        for f, complex_f in zip(resolveable_folders, complex_folders):
        -            if isinstance(f, MISSING_FOLDER_ERRORS):
        -                # We were unlucky. The folder disappeared between the FindFolder and the GetFolder calls
        -                continue
        -            if isinstance(complex_f, Exception):
        -                yield complex_f
        -                continue
        -            # Add the extra field values to the folders we fetched with find_folders()
        -            if f.__class__ != complex_f.__class__:
        -                raise ValueError(f"Type mismatch: {f} vs {complex_f}")
        -            for complex_field in complex_fields:
        -                field_name = complex_field.field.name
        -                setattr(f, field_name, getattr(complex_f, field_name))
        -            yield f
        -
        -

        Subclasses

        +
      • WellknownFolder: -

        Methods

        -
        -
        -def all(self) +
      • +
      +
    +
    +class Journal +(**kwargs)
    -
    +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    def all(self):
    -    """ """
    -    new_qs = self._copy_self()
    -    return new_qs
    +
    class Journal(WellknownFolder):
    +    CONTAINER_CLASS = "IPF.Journal"
    +    DISTINGUISHED_FOLDER_ID = "journal"
    +

    Ancestors

    + +

    Class variables

    +
    +
    var CONTAINER_CLASS
    +
    +
    -
    -def depth(self, depth) +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class JunkEmail +(**kwargs)
    -

    Specify the search depth. Possible values are: SHALLOW or DEEP.

    -

    :param depth:

    +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    def depth(self, depth):
    -    """Specify the search depth. Possible values are: SHALLOW or DEEP.
    -
    -    :param depth:
    -    """
    -    new_qs = self._copy_self()
    -    new_qs._depth = depth
    -    return new_qs
    +
    class JunkEmail(Messages):
    +    DISTINGUISHED_FOLDER_ID = "junkemail"
    +    LOCALIZED_NAMES = {
    +        "da_DK": ("Uønsket e-mail",),
    +        "de_DE": ("Junk-E-Mail",),
    +        "en_US": ("Junk E-mail",),
    +        "es_ES": ("Correo no deseado",),
    +        "fr_CA": ("Courrier indésirables",),
    +        "nl_NL": ("Ongewenste e-mail",),
    +        "ru_RU": ("Нежелательная почта",),
    +        "sv_SE": ("Skräppost",),
    +        "zh_CN": ("垃圾邮件",),
    +    }
    +

    Ancestors

    + +

    Class variables

    +
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    -
    -def filter(self, *args, **kwargs) +
    var LOCALIZED_NAMES
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class LocalFailures +(**kwargs)
    -

    Add restrictions to the folder search.

    +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    def filter(self, *args, **kwargs):
    -    """Add restrictions to the folder search."""
    -    new_qs = self._copy_self()
    -    q = Q(*args, **kwargs)
    -    new_qs.q = new_qs.q & q
    -    return new_qs
    +
    class LocalFailures(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "localfailures"
    +    supported_from = EXCHANGE_2013
    +

    Ancestors

    + +

    Class variables

    +
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    var supported_from
    +
    +
    +
    +
    +

    Inherited members

    +
    -
    -def get(self, *args, **kwargs) +
    +class Location +(**kwargs)
    -

    Return the query result as exactly one item. Raises DoesNotExist if there are no results, and -MultipleObjectsReturned if there are multiple results.

    +

    A mixin for non-wellknown folders than that are not deletable.

    Expand source code -
    def get(self, *args, **kwargs):
    -    """Return the query result as exactly one item. Raises DoesNotExist if there are no results, and
    -    MultipleObjectsReturned if there are multiple results.
    -    """
    -    from .collections import FolderCollection
    -
    -    if not args and set(kwargs) in ({"id"}, {"id", "changekey"}):
    -        folders = list(
    -            FolderCollection(account=self.folder_collection.account, folders=[FolderId(**kwargs)]).resolve()
    -        )
    -    elif args or kwargs:
    -        folders = list(self.filter(*args, **kwargs))
    -    else:
    -        folders = list(self.all())
    -    if not folders:
    -        raise DoesNotExist("Could not find a child folder matching the query")
    -    if len(folders) != 1:
    -        raise MultipleObjectsReturned(f"Expected result length 1, but got {folders}")
    -    f = folders[0]
    -    if isinstance(f, Exception):
    -        raise f
    -    return f
    +
    class Location(NonDeletableFolder):
    +    pass
    +

    Ancestors

    + +

    Inherited members

    +
    -
    -def only(self, *args) +
    +class MailboxAssociations +(**kwargs)
    -

    Restrict the fields returned. 'name' and 'folder_class' are always returned.

    +

    A mixin for non-wellknown folders than that are not deletable.

    Expand source code -
    def only(self, *args):
    -    """Restrict the fields returned. 'name' and 'folder_class' are always returned."""
    -    from .base import Folder
    -
    -    # Sub-folders will always be of class Folder
    -    all_fields = self.folder_collection.get_folder_fields(target_cls=Folder, is_complex=None)
    -    all_fields.update(Folder.attribute_fields())
    -    only_fields = []
    -    for arg in args:
    -        for field_path in all_fields:
    -            if field_path.field.name == arg:
    -                only_fields.append(field_path)
    -                break
    -        else:
    -            raise InvalidField(f"Unknown field {arg!r} on folders {self.folder_collection.folders}")
    -    new_qs = self._copy_self()
    -    new_qs.only_fields = only_fields
    -    return new_qs
    +
    class MailboxAssociations(NonDeletableFolder):
    +    pass
    +

    Ancestors

    + +

    Inherited members

    +
    - - -
    -class FreeBusyCache +
    +class Messages (**kwargs)
    - +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    class FreeBusyCache(Folder):
    -    CONTAINER_CLASS = "IPF.StoreItem.FreeBusyCache"
    +
    class Messages(WellknownFolder):
    +    CONTAINER_CLASS = "IPF.Note"
    +    supported_item_models = (Message, MeetingRequest, MeetingResponse, MeetingCancellation)

    Ancestors

    +

    Subclasses

    +

    Class variables

    -
    var CONTAINER_CLASS
    +
    var CONTAINER_CLASS
    +
    +
    +
    +
    var supported_item_models

    Inherited members

    -
    -class FreebusyData +
    +class MsgFolderRoot (**kwargs)
    -

    A mixin for non-wellknown folders than that are not deletable.

    +

    Also known as the 'Top of Information Store' folder.

    Expand source code -
    class FreebusyData(NonDeletableFolderMixIn, Folder):
    +
    class MsgFolderRoot(WellknownFolder):
    +    """Also known as the 'Top of Information Store' folder."""
    +
    +    DISTINGUISHED_FOLDER_ID = "msgfolderroot"
         LOCALIZED_NAMES = {
    -        None: ("Freebusy Data",),
    +        None: ("Top of Information Store",),
    +        "da_DK": ("Informationslagerets øverste niveau",),
    +        "zh_CN": ("信息存储顶部",),
         }

    Ancestors

    Class variables

    -
    var LOCALIZED_NAMES
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    var LOCALIZED_NAMES

    Inherited members

    -
    -class Friends +
    +class MyContacts (**kwargs)
    -

    A mixin for non-wellknown folders than that are not deletable.

    +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    class Friends(NonDeletableFolderMixIn, Contacts):
    +
    class MyContacts(WellknownFolder):
         CONTAINER_CLASS = "IPF.Note"
    -
    -    LOCALIZED_NAMES = {
    -        "de_DE": ("Bekannte",),
    -    }
    + DISTINGUISHED_FOLDER_ID = "mycontacts" + supported_from = EXCHANGE_2013

    Ancestors

    Class variables

    -
    var CONTAINER_CLASS
    +
    var CONTAINER_CLASS
    -
    var LOCALIZED_NAMES
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    var supported_from

    Inherited members

    -
    -class GALContacts +
    +class MyContactsExtended (**kwargs)
    @@ -7018,18 +8471,13 @@

    Inherited members

    Expand source code -
    class GALContacts(NonDeletableFolderMixIn, Contacts):
    -    DISTINGUISHED_FOLDER_ID = None
    -    CONTAINER_CLASS = "IPF.Contact.GalContacts"
    -
    -    LOCALIZED_NAMES = {
    -        None: ("GAL Contacts",),
    -    }
    +
    class MyContactsExtended(NonDeletableFolder):
    +    CONTAINER_CLASS = "IPF.Note"
    +    supported_item_models = (Contact, DistributionList)

    Ancestors

    Class variables

    -
    var CONTAINER_CLASS
    -
    -
    -
    -
    var DISTINGUISHED_FOLDER_ID
    +
    var CONTAINER_CLASS
    -
    var LOCALIZED_NAMES
    +
    var supported_item_models

    Inherited members

    -
    -
    -class GraphAnalytics +
  • NonDeletableFolder: + +
  • + + +
    +class NonDeletableFolder (**kwargs)
    @@ -7101,15 +8545,15 @@

    Inherited members

    Expand source code -
    class GraphAnalytics(NonDeletableFolderMixIn, Folder):
    -    CONTAINER_CLASS = "IPF.StoreItem.GraphAnalytics"
    -    LOCALIZED_NAMES = {
    -        None: ("GraphAnalytics",),
    -    }
    +
    class NonDeletableFolder(Folder):
    +    """A mixin for non-wellknown folders than that are not deletable."""
    +
    +    @property
    +    def is_deletable(self):
    +        return False

    Ancestors

    -

    Class variables

    +

    Subclasses

    + +

    Instance variables

    -
    var CONTAINER_CLASS
    -
    -
    -
    -
    var LOCALIZED_NAMES
    +
    var is_deletable
    +
    + +Expand source code + +
    @property
    +def is_deletable(self):
    +    return False
    +

    Inherited members

    @@ -7167,8 +8652,8 @@

    Inherited members

    -
    -class IMContactList +
    +class Notes (**kwargs)
    @@ -7177,10 +8662,12 @@

    Inherited members

    Expand source code -
    class IMContactList(WellknownFolder):
    -    CONTAINER_CLASS = "IPF.Contact.MOC.ImContactList"
    -    DISTINGUISHED_FOLDER_ID = "imcontactlist"
    -    supported_from = EXCHANGE_2013
    +
    class Notes(WellknownFolder):
    +    CONTAINER_CLASS = "IPF.StickyNote"
    +    DISTINGUISHED_FOLDER_ID = "notes"
    +    LOCALIZED_NAMES = {
    +        "da_DK": ("Noter",),
    +    }

    Ancestors

      @@ -7195,15 +8682,15 @@

      Ancestors

    Class variables

    -
    var CONTAINER_CLASS
    +
    var CONTAINER_CLASS
    -
    var DISTINGUISHED_FOLDER_ID
    +
    var DISTINGUISHED_FOLDER_ID
    -
    var supported_from
    +
    var LOCALIZED_NAMES
    @@ -7227,112 +8714,27 @@

    Inherited members

  • get_streaming_events
  • item_sync_state
  • none
  • -
  • parent
  • -
  • people
  • -
  • register
  • -
  • remove_field
  • -
  • root
  • -
  • subscribe_to_pull
  • -
  • subscribe_to_push
  • -
  • subscribe_to_streaming
  • -
  • supported_fields
  • -
  • sync_hierarchy
  • -
  • sync_items
  • -
  • test_access
  • -
  • tree
  • -
  • unsubscribe
  • -
  • validate_field
  • - - - -
    -
    -class Inbox -(**kwargs) -
    -
    - -
    - -Expand source code - -
    class Inbox(Messages):
    -    DISTINGUISHED_FOLDER_ID = "inbox"
    -
    -    LOCALIZED_NAMES = {
    -        "da_DK": ("Indbakke",),
    -        "de_DE": ("Posteingang",),
    -        "en_US": ("Inbox",),
    -        "es_ES": ("Bandeja de entrada",),
    -        "fr_CA": ("Boîte de réception",),
    -        "nl_NL": ("Postvak IN",),
    -        "ru_RU": ("Входящие",),
    -        "sv_SE": ("Inkorgen",),
    -        "zh_CN": ("收件箱",),
    -    }
    -
    -

    Ancestors

    - -

    Class variables

    -
    -
    var DISTINGUISHED_FOLDER_ID
    -
    -
    -
    -
    var LOCALIZED_NAMES
    -
    -
    -
    -
    -

    Inherited members

    -
    -
    -class Journal +
    +class OneNotePagePreviews (**kwargs)
    @@ -7341,9 +8743,8 @@

    Inherited members

    Expand source code -
    class Journal(WellknownFolder):
    -    CONTAINER_CLASS = "IPF.Journal"
    -    DISTINGUISHED_FOLDER_ID = "journal"
    +
    class OneNotePagePreviews(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "onenotepagepreviews"

    Ancestors

      @@ -7358,11 +8759,7 @@

      Ancestors

    Class variables

    -
    var CONTAINER_CLASS
    -
    -
    -
    -
    var DISTINGUISHED_FOLDER_ID
    +
    var DISTINGUISHED_FOLDER_ID
    @@ -7405,34 +8802,26 @@

    Inherited members

    -
    -class JunkEmail +
    +class OrganizationalContacts (**kwargs)
    - +

    A mixin for non-wellknown folders than that are not deletable.

    Expand source code -
    class JunkEmail(Messages):
    -    DISTINGUISHED_FOLDER_ID = "junkemail"
    -
    +
    class OrganizationalContacts(NonDeletableFolder):
    +    CONTAINER_CLASS = "IPF.Contact.OrganizationalContacts"
    +    supported_item_models = (Contact, DistributionList)
         LOCALIZED_NAMES = {
    -        "da_DK": ("Uønsket e-mail",),
    -        "de_DE": ("Junk-E-Mail",),
    -        "en_US": ("Junk E-mail",),
    -        "es_ES": ("Correo no deseado",),
    -        "fr_CA": ("Courrier indésirables",),
    -        "nl_NL": ("Ongewenste e-mail",),
    -        "ru_RU": ("Нежелательная почта",),
    -        "sv_SE": ("Skräppost",),
    -        "zh_CN": ("垃圾邮件",),
    +        None: ("Organizational Contacts",),
         }

    Ancestors

    Class variables

    -
    var DISTINGUISHED_FOLDER_ID
    +
    var CONTAINER_CLASS
    -
    var LOCALIZED_NAMES
    +
    var LOCALIZED_NAMES
    +
    +
    +
    +
    var supported_item_models

    Inherited members

    -
    -class LocalFailures +
    +class Outbox (**kwargs)
    @@ -7500,12 +8893,23 @@

    Inherited members

    Expand source code -
    class LocalFailures(WellknownFolder):
    -    DISTINGUISHED_FOLDER_ID = "localfailures"
    -    supported_from = EXCHANGE_2013
    +
    class Outbox(Messages):
    +    DISTINGUISHED_FOLDER_ID = "outbox"
    +    LOCALIZED_NAMES = {
    +        "da_DK": ("Udbakke",),
    +        "de_DE": ("Postausgang",),
    +        "en_US": ("Outbox",),
    +        "es_ES": ("Bandeja de salida",),
    +        "fr_CA": ("Boîte d'envoi",),
    +        "nl_NL": ("Postvak UIT",),
    +        "ru_RU": ("Исходящие",),
    +        "sv_SE": ("Utkorgen",),
    +        "zh_CN": ("发件箱",),
    +    }

    Ancestors

    Class variables

    -
    var DISTINGUISHED_FOLDER_ID
    +
    var DISTINGUISHED_FOLDER_ID
    -
    var supported_from
    +
    var LOCALIZED_NAMES

    Inherited members

    -
    -class Location +
    +class ParkedMessages (**kwargs)
    @@ -7574,14 +8978,12 @@

    Inherited members

    Expand source code -
    class Location(NonDeletableFolderMixIn, Folder):
    -    LOCALIZED_NAMES = {
    -        None: ("Location",),
    -    }
    +
    class ParkedMessages(NonDeletableFolder):
    +    CONTAINER_CLASS = None

    Ancestors

    Class variables

    -
    var LOCALIZED_NAMES
    +
    var CONTAINER_CLASS

    Inherited members

    -
    -class MailboxAssociations +
    +class PassThroughSearchResults (**kwargs)
    @@ -7645,14 +9047,15 @@

    Inherited members

    Expand source code -
    class MailboxAssociations(NonDeletableFolderMixIn, Folder):
    +
    class PassThroughSearchResults(NonDeletableFolder):
    +    CONTAINER_CLASS = "IPF.StoreItem.PassThroughSearchResults"
         LOCALIZED_NAMES = {
    -        None: ("MailboxAssociations",),
    +        None: ("Pass-Through Search Results",),
         }

    Ancestors

    Class variables

    -
    var LOCALIZED_NAMES
    -
    -
    -
    -
    -

    Inherited members

    -
    -
    -class Messages +
    +class PdpProfileV2Secured (**kwargs)
    - +

    A mixin for non-wellknown folders than that are not deletable.

    Expand source code -
    class Messages(Folder):
    -    CONTAINER_CLASS = "IPF.Note"
    -    supported_item_models = (Message, MeetingRequest, MeetingResponse, MeetingCancellation)
    +
    class PdpProfileV2Secured(NonDeletableFolder):
    +    CONTAINER_CLASS = "IPF.StoreItem.PdpProfileSecured"

    Ancestors

    -

    Subclasses

    -

    Class variables

    -
    var CONTAINER_CLASS
    -
    -
    -
    -
    var supported_item_models
    +
    var CONTAINER_CLASS

    Inherited members

    -
    -class MsgFolderRoot +
    +class PeopleCentricConversationBuddies (**kwargs)
    -

    Also known as the 'Top of Information Store' folder.

    +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    class MsgFolderRoot(WellknownFolder):
    -    """Also known as the 'Top of Information Store' folder."""
    -
    -    DISTINGUISHED_FOLDER_ID = "msgfolderroot"
    +
    class PeopleCentricConversationBuddies(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "peoplecentricconversationbuddies"
    +    CONTAINER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies"
         LOCALIZED_NAMES = {
    -        None: ("Top of Information Store",),
    -        "da_DK": ("Informationslagerets øverste niveau",),
    -        "zh_CN": ("信息存储顶部",),
    +        None: ("PeopleCentricConversation Buddies",),
         }

    Ancestors

    @@ -7820,11 +9212,15 @@

    Ancestors

    Class variables

    -
    var DISTINGUISHED_FOLDER_ID
    +
    var CONTAINER_CLASS
    -
    var LOCALIZED_NAMES
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    var LOCALIZED_NAMES
    @@ -7867,8 +9263,8 @@

    Inherited members

    -
    -class MyContacts +
    +class PeopleConnect (**kwargs)
    @@ -7877,9 +9273,8 @@

    Inherited members

    Expand source code -
    class MyContacts(WellknownFolder):
    -    CONTAINER_CLASS = "IPF.Note"
    -    DISTINGUISHED_FOLDER_ID = "mycontacts"
    +
    class PeopleConnect(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "peopleconnect"
         supported_from = EXCHANGE_2013

    Ancestors

    @@ -7895,15 +9290,11 @@

    Ancestors

    Class variables

    -
    var CONTAINER_CLASS
    -
    -
    -
    -
    var DISTINGUISHED_FOLDER_ID
    +
    var DISTINGUISHED_FOLDER_ID
    -
    var supported_from
    +
    var supported_from
    @@ -7946,27 +9337,62 @@

    Inherited members

    -
    -class MyContactsExtended +
    +class PublicFoldersRoot (**kwargs)
    -

    A mixin for non-wellknown folders than that are not deletable.

    +

    The root of the public folder hierarchy. Not available on all mailboxes.

    Expand source code -
    class MyContactsExtended(NonDeletableFolderMixIn, Contacts):
    -    CONTAINER_CLASS = "IPF.Note"
    -    LOCALIZED_NAMES = {
    -        None: ("MyContactsExtended",),
    -    }
    +
    class PublicFoldersRoot(RootOfHierarchy):
    +    """The root of the public folder hierarchy. Not available on all mailboxes."""
    +
    +    DISTINGUISHED_FOLDER_ID = "publicfoldersroot"
    +    DEFAULT_FOLDER_TRAVERSAL_DEPTH = SHALLOW
    +    supported_from = EXCHANGE_2007_SP1
    +
    +    def get_children(self, folder):
    +        # EWS does not allow deep traversal of public folders, so self._folders_map will only populate the top-level
    +        # subfolders. To traverse public folders at arbitrary depth, we need to get child folders on demand.
    +
    +        # Let's check if this folder already has any cached children. If so, assume we can just return those.
    +        children = list(super().get_children(folder=folder))
    +        if children:
    +            # Return a generator like our parent does
    +            yield from children
    +            return
    +
    +        # Also return early if the server told us that there are no child folders.
    +        if folder.child_folder_count == 0:
    +            return
    +
    +        children_map = {}
    +        with suppress(ErrorAccessDenied):
    +            for f in (
    +                SingleFolderQuerySet(account=self.account, folder=folder)
    +                .depth(self.DEFAULT_FOLDER_TRAVERSAL_DEPTH)
    +                .all()
    +            ):
    +                if isinstance(f, MISSING_FOLDER_ERRORS):
    +                    # We were unlucky. The folder disappeared between the FindFolder and the GetFolder calls
    +                    continue
    +                if isinstance(f, Exception):
    +                    raise f
    +                children_map[f.id] = f
    +
    +        # Let's update the cache atomically, to avoid partial reads of the cache.
    +        with self._subfolders_lock:
    +            self._subfolders.update(children_map)
    +
    +        # Child folders have been cached now. Try super().get_children() again.
    +        yield from super().get_children(folder=folder)

    Ancestors

    Class variables

    -
    var CONTAINER_CLASS
    +
    var DEFAULT_FOLDER_TRAVERSAL_DEPTH
    -
    var LOCALIZED_NAMES
    +
    var DISTINGUISHED_FOLDER_ID
    -
    -

    Inherited members

    - -
    -
    -class NonDeletableFolderMixIn -
    +
    var supported_from
    -

    A mixin for non-wellknown folders than that are not deletable.

    -
    - -Expand source code - -
    class NonDeletableFolderMixIn:
    -    """A mixin for non-wellknown folders than that are not deletable."""
    -
    -    @property
    -    def is_deletable(self):
    -        return False
    -
    -

    Subclasses

    - -

    Instance variables

    +
    +
    + +

    Methods

    -
    var is_deletable
    +
    +def get_children(self, folder) +
    Expand source code -
    @property
    -def is_deletable(self):
    -    return False
    +
    def get_children(self, folder):
    +    # EWS does not allow deep traversal of public folders, so self._folders_map will only populate the top-level
    +    # subfolders. To traverse public folders at arbitrary depth, we need to get child folders on demand.
    +
    +    # Let's check if this folder already has any cached children. If so, assume we can just return those.
    +    children = list(super().get_children(folder=folder))
    +    if children:
    +        # Return a generator like our parent does
    +        yield from children
    +        return
    +
    +    # Also return early if the server told us that there are no child folders.
    +    if folder.child_folder_count == 0:
    +        return
    +
    +    children_map = {}
    +    with suppress(ErrorAccessDenied):
    +        for f in (
    +            SingleFolderQuerySet(account=self.account, folder=folder)
    +            .depth(self.DEFAULT_FOLDER_TRAVERSAL_DEPTH)
    +            .all()
    +        ):
    +            if isinstance(f, MISSING_FOLDER_ERRORS):
    +                # We were unlucky. The folder disappeared between the FindFolder and the GetFolder calls
    +                continue
    +            if isinstance(f, Exception):
    +                raise f
    +            children_map[f.id] = f
    +
    +    # Let's update the cache atomically, to avoid partial reads of the cache.
    +    with self._subfolders_lock:
    +        self._subfolders.update(children_map)
    +
    +    # Child folders have been cached now. Try super().get_children() again.
    +    yield from super().get_children(folder=folder)
    +

    Inherited members

    + -
    -class Notes +
    +class QedcDefaultRetention (**kwargs)
    @@ -8105,12 +9514,8 @@

    Instance variables

    Expand source code -
    class Notes(WellknownFolder):
    -    CONTAINER_CLASS = "IPF.StickyNote"
    -    DISTINGUISHED_FOLDER_ID = "notes"
    -    LOCALIZED_NAMES = {
    -        "da_DK": ("Noter",),
    -    }
    +
    class QedcDefaultRetention(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "qedcdefaultretention"

    Ancestors

      @@ -8125,15 +9530,7 @@

      Ancestors

    Class variables

    -
    var CONTAINER_CLASS
    -
    -
    -
    -
    var DISTINGUISHED_FOLDER_ID
    -
    -
    -
    -
    var LOCALIZED_NAMES
    +
    var DISTINGUISHED_FOLDER_ID
    @@ -8176,116 +9573,22 @@

    Inherited members

    -
    -class OrganizationalContacts -(**kwargs) -
    -
    -

    A mixin for non-wellknown folders than that are not deletable.

    -
    - -Expand source code - -
    class OrganizationalContacts(NonDeletableFolderMixIn, Contacts):
    -    DISTINGUISHED_FOLDER_ID = None
    -    CONTAINER_CLASS = "IPF.Contact.OrganizationalContacts"
    -    LOCALIZED_NAMES = {
    -        None: ("Organizational Contacts",),
    -    }
    -
    -

    Ancestors

    - -

    Class variables

    -
    -
    var CONTAINER_CLASS
    -
    -
    -
    -
    var DISTINGUISHED_FOLDER_ID
    -
    -
    -
    -
    var LOCALIZED_NAMES
    -
    -
    -
    -
    -

    Inherited members

    - -
    -
    -class Outbox +
    +class QedcLongRetention (**kwargs)
    - +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    class Outbox(Messages):
    -    DISTINGUISHED_FOLDER_ID = "outbox"
    -
    -    LOCALIZED_NAMES = {
    -        "da_DK": ("Udbakke",),
    -        "de_DE": ("Postausgang",),
    -        "en_US": ("Outbox",),
    -        "es_ES": ("Bandeja de salida",),
    -        "fr_CA": ("Boîte d'envoi",),
    -        "nl_NL": ("Postvak UIT",),
    -        "ru_RU": ("Исходящие",),
    -        "sv_SE": ("Utkorgen",),
    -        "zh_CN": ("发件箱",),
    -    }
    +
    class QedcLongRetention(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "qedclongretention"

    Ancestors

    Class variables

    -
    var DISTINGUISHED_FOLDER_ID
    -
    -
    -
    -
    var LOCALIZED_NAMES
    +
    var DISTINGUISHED_FOLDER_ID

    Inherited members

    -
    -class ParkedMessages +
    +class QedcMediumRetention (**kwargs)
    -

    A mixin for non-wellknown folders than that are not deletable.

    +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    class ParkedMessages(NonDeletableFolderMixIn, Folder):
    -    CONTAINER_CLASS = None
    -    LOCALIZED_NAMES = {
    -        None: ("ParkedMessages",),
    -    }
    +
    class QedcMediumRetention(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "qedcmediumretention"

    Ancestors

    -

    Class variables

    -
    -
    var CONTAINER_CLASS
    -
    -
    -
    -
    var LOCALIZED_NAMES
    -
    -
    -
    -
    -

    Inherited members

    -
    -
    -class PassThroughSearchResults +
    +class QedcShortRetention (**kwargs)
    -

    A mixin for non-wellknown folders than that are not deletable.

    +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    class PassThroughSearchResults(NonDeletableFolderMixIn, Folder):
    -    CONTAINER_CLASS = "IPF.StoreItem.PassThroughSearchResults"
    -    LOCALIZED_NAMES = {
    -        None: ("Pass-Through Search Results",),
    -    }
    +
    class QedcShortRetention(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "qedcshortretention"

    Ancestors

    Class variables

    -
    var CONTAINER_CLASS
    -
    -
    -
    -
    var LOCALIZED_NAMES
    +
    var DISTINGUISHED_FOLDER_ID

    Inherited members

    -
    -class PdpProfileV2Secured +
    +class QuarantinedEmail (**kwargs)
    -

    A mixin for non-wellknown folders than that are not deletable.

    +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    class PdpProfileV2Secured(NonDeletableFolderMixIn, Folder):
    -    CONTAINER_CLASS = "IPF.StoreItem.PdpProfileSecured"
    -    LOCALIZED_NAMES = {
    -        None: ("PdpProfileV2Secured",),
    -    }
    +
    class QuarantinedEmail(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "quarantinedemail"

    Ancestors

    Class variables

    -
    var CONTAINER_CLASS
    -
    -
    -
    -
    var LOCALIZED_NAMES
    +
    var DISTINGUISHED_FOLDER_ID

    Inherited members

    -
    -class PeopleCentricConversationBuddies +
    +class QuarantinedEmailDefaultCategory (**kwargs)
    -

    A mixin for non-wellknown folders than that are not deletable.

    +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    class PeopleCentricConversationBuddies(NonDeletableFolderMixIn, Contacts):
    -    DISTINGUISHED_FOLDER_ID = None
    -    CONTAINER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies"
    -    LOCALIZED_NAMES = {
    -        None: ("PeopleCentricConversation Buddies",),
    -    }
    +
    class QuarantinedEmailDefaultCategory(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "quarantinedemaildefaultcategory"

    Ancestors

    Class variables

    -
    var CONTAINER_CLASS
    -
    -
    -
    -
    var DISTINGUISHED_FOLDER_ID
    -
    -
    -
    -
    var LOCALIZED_NAMES
    +
    var DISTINGUISHED_FOLDER_ID

    Inherited members

    -
    -class PeopleConnect +
    +class QuickContacts (**kwargs)
    @@ -8663,8 +9928,9 @@

    Inherited members

    Expand source code -
    class PeopleConnect(WellknownFolder):
    -    DISTINGUISHED_FOLDER_ID = "peopleconnect"
    +
    class QuickContacts(WellknownFolder):
    +    CONTAINER_CLASS = "IPF.Contact.MOC.QuickContacts"
    +    DISTINGUISHED_FOLDER_ID = "quickcontacts"
         supported_from = EXCHANGE_2013

    Ancestors

    @@ -8680,11 +9946,15 @@

    Ancestors

    Class variables

    -
    var DISTINGUISHED_FOLDER_ID
    +
    var CONTAINER_CLASS
    -
    var supported_from
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    var supported_from
    @@ -8727,62 +9997,26 @@

    Inherited members

    -
    -class PublicFoldersRoot +
    +class RSSFeeds (**kwargs)
    -

    The root of the public folder hierarchy. Not available on all mailboxes.

    +

    A mixin for non-wellknown folders than that are not deletable.

    Expand source code -
    class PublicFoldersRoot(RootOfHierarchy):
    -    """The root of the public folder hierarchy. Not available on all mailboxes."""
    -
    -    DISTINGUISHED_FOLDER_ID = "publicfoldersroot"
    -    DEFAULT_FOLDER_TRAVERSAL_DEPTH = SHALLOW
    -    supported_from = EXCHANGE_2007_SP1
    -
    -    def get_children(self, folder):
    -        # EWS does not allow deep traversal of public folders, so self._folders_map will only populate the top-level
    -        # subfolders. To traverse public folders at arbitrary depth, we need to get child folders on demand.
    -
    -        # Let's check if this folder already has any cached children. If so, assume we can just return those.
    -        children = list(super().get_children(folder=folder))
    -        if children:
    -            # Return a generator like our parent does
    -            yield from children
    -            return
    -
    -        # Also return early if the server told us that there are no child folders.
    -        if folder.child_folder_count == 0:
    -            return
    -
    -        children_map = {}
    -        with suppress(ErrorAccessDenied):
    -            for f in (
    -                SingleFolderQuerySet(account=self.account, folder=folder)
    -                .depth(self.DEFAULT_FOLDER_TRAVERSAL_DEPTH)
    -                .all()
    -            ):
    -                if isinstance(f, MISSING_FOLDER_ERRORS):
    -                    # We were unlucky. The folder disappeared between the FindFolder and the GetFolder calls
    -                    continue
    -                if isinstance(f, Exception):
    -                    raise f
    -                children_map[f.id] = f
    -
    -        # Let's update the cache atomically, to avoid partial reads of the cache.
    -        with self._subfolders_lock:
    -            self._subfolders.update(children_map)
    -
    -        # Child folders have been cached now. Try super().get_children() again.
    -        yield from super().get_children(folder=folder)
    +
    class RSSFeeds(NonDeletableFolder):
    +    CONTAINER_CLASS = "IPF.Note.OutlookHomepage"
    +    LOCALIZED_NAMES = {
    +        None: ("RSS Feeds",),
    +    }

    Ancestors

    Class variables

    -
    var DEFAULT_FOLDER_TRAVERSAL_DEPTH
    -
    -
    -
    -
    var DISTINGUISHED_FOLDER_ID
    -
    -
    -
    -
    var supported_from
    +
    var CONTAINER_CLASS
    -
    -

    Methods

    -
    -
    -def get_children(self, folder) -
    +
    var LOCALIZED_NAMES
    -
    - -Expand source code - -
    def get_children(self, folder):
    -    # EWS does not allow deep traversal of public folders, so self._folders_map will only populate the top-level
    -    # subfolders. To traverse public folders at arbitrary depth, we need to get child folders on demand.
    -
    -    # Let's check if this folder already has any cached children. If so, assume we can just return those.
    -    children = list(super().get_children(folder=folder))
    -    if children:
    -        # Return a generator like our parent does
    -        yield from children
    -        return
    -
    -    # Also return early if the server told us that there are no child folders.
    -    if folder.child_folder_count == 0:
    -        return
    -
    -    children_map = {}
    -    with suppress(ErrorAccessDenied):
    -        for f in (
    -            SingleFolderQuerySet(account=self.account, folder=folder)
    -            .depth(self.DEFAULT_FOLDER_TRAVERSAL_DEPTH)
    -            .all()
    -        ):
    -            if isinstance(f, MISSING_FOLDER_ERRORS):
    -                # We were unlucky. The folder disappeared between the FindFolder and the GetFolder calls
    -                continue
    -            if isinstance(f, Exception):
    -                raise f
    -            children_map[f.id] = f
    -
    -    # Let's update the cache atomically, to avoid partial reads of the cache.
    -    with self._subfolders_lock:
    -        self._subfolders.update(children_map)
    -
    -    # Child folders have been cached now. Try super().get_children() again.
    -    yield from super().get_children(folder=folder)
    -

    Inherited members

    -
    -class QuickContacts +
    +class RecipientCache (**kwargs)
    @@ -8904,10 +10083,13 @@

    Inherited members

    Expand source code -
    class QuickContacts(WellknownFolder):
    -    CONTAINER_CLASS = "IPF.Contact.MOC.QuickContacts"
    -    DISTINGUISHED_FOLDER_ID = "quickcontacts"
    -    supported_from = EXCHANGE_2013
    +
    class RecipientCache(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "recipientcache"
    +    CONTAINER_CLASS = "IPF.Contact.RecipientCache"
    +    supported_from = EXCHANGE_2013
    +    LOCALIZED_NAMES = {
    +        None: ("RecipientCache",),
    +    }

    Ancestors

      @@ -8922,15 +10104,19 @@

      Ancestors

    Class variables

    -
    var CONTAINER_CLASS
    +
    var CONTAINER_CLASS
    -
    var DISTINGUISHED_FOLDER_ID
    +
    var DISTINGUISHED_FOLDER_ID
    -
    var supported_from
    +
    var LOCALIZED_NAMES
    +
    +
    +
    +
    var supported_from
    @@ -8973,102 +10159,23 @@

    Inherited members

    -
    -class RSSFeeds -(**kwargs) -
    -
    -

    A mixin for non-wellknown folders than that are not deletable.

    -
    - -Expand source code - -
    class RSSFeeds(NonDeletableFolderMixIn, Folder):
    -    CONTAINER_CLASS = "IPF.Note.OutlookHomepage"
    -    LOCALIZED_NAMES = {
    -        None: ("RSS Feeds",),
    -    }
    -
    -

    Ancestors

    - -

    Class variables

    -
    -
    var CONTAINER_CLASS
    -
    -
    -
    -
    var LOCALIZED_NAMES
    -
    -
    -
    -
    -

    Inherited members

    - -
    -
    -class RecipientCache +
    +class RecoverableItemsDeletions (**kwargs)
    - +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    class RecipientCache(Contacts):
    -    DISTINGUISHED_FOLDER_ID = "recipientcache"
    -    CONTAINER_CLASS = "IPF.Contact.RecipientCache"
    -    supported_from = EXCHANGE_2013
    -
    -    LOCALIZED_NAMES = {}
    +
    class RecoverableItemsDeletions(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "recoverableitemsdeletions"
    +    supported_from = EXCHANGE_2010_SP1

    Ancestors

    Class variables

    -
    var CONTAINER_CLASS
    -
    -
    -
    -
    var DISTINGUISHED_FOLDER_ID
    -
    -
    -
    -
    var LOCALIZED_NAMES
    +
    var DISTINGUISHED_FOLDER_ID
    -
    var supported_from
    +
    var supported_from

    Inherited members

    -
    -class RecoverableItemsDeletions +
    +class RecoverableItemsPurges (**kwargs)
    @@ -9144,8 +10243,8 @@

    Inherited members

    Expand source code -
    class RecoverableItemsDeletions(WellknownFolder):
    -    DISTINGUISHED_FOLDER_ID = "recoverableitemsdeletions"
    +
    class RecoverableItemsPurges(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "recoverableitemspurges"
         supported_from = EXCHANGE_2010_SP1

    Ancestors

    @@ -9161,11 +10260,11 @@

    Ancestors

    Class variables

    -
    var DISTINGUISHED_FOLDER_ID
    +
    var DISTINGUISHED_FOLDER_ID
    -
    var supported_from
    +
    var supported_from
    @@ -9208,8 +10307,8 @@

    Inherited members

    -
    -class RecoverableItemsPurges +
    +class RecoverableItemsRoot (**kwargs)
    @@ -9218,8 +10317,8 @@

    Inherited members

    Expand source code -
    class RecoverableItemsPurges(WellknownFolder):
    -    DISTINGUISHED_FOLDER_ID = "recoverableitemspurges"
    +
    class RecoverableItemsRoot(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "recoverableitemsroot"
         supported_from = EXCHANGE_2010_SP1

    Ancestors

    @@ -9235,11 +10334,11 @@

    Ancestors

    Class variables

    -
    var DISTINGUISHED_FOLDER_ID
    +
    var DISTINGUISHED_FOLDER_ID
    -
    var supported_from
    +
    var supported_from
    @@ -9282,8 +10381,8 @@

    Inherited members

    -
    -class RecoverableItemsRoot +
    +class RecoverableItemsSubstrateHolds (**kwargs)
    @@ -9292,9 +10391,12 @@

    Inherited members

    Expand source code -
    class RecoverableItemsRoot(WellknownFolder):
    -    DISTINGUISHED_FOLDER_ID = "recoverableitemsroot"
    -    supported_from = EXCHANGE_2010_SP1
    +
    class RecoverableItemsSubstrateHolds(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "recoverableitemssubstrateholds"
    +    supported_from = EXCHANGE_2010_SP1
    +    LOCALIZED_NAMES = {
    +        None: ("SubstrateHolds",),
    +    }

    Ancestors

      @@ -9309,11 +10411,15 @@

      Ancestors

    Class variables

    -
    var DISTINGUISHED_FOLDER_ID
    +
    var DISTINGUISHED_FOLDER_ID
    -
    var supported_from
    +
    var LOCALIZED_NAMES
    +
    +
    +
    +
    var supported_from
    @@ -9498,6 +10604,87 @@

    Inherited members

    +
    +class RelevantContacts +(**kwargs) +
    +
    +

    Base class to use until we have a more specific folder implementation for this folder.

    +
    + +Expand source code + +
    class RelevantContacts(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "relevantcontacts"
    +    CONTAINER_CLASS = "IPF.Note"
    +    LOCALIZED_NAMES = {
    +        None: ("RelevantContacts",),
    +    }
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var CONTAINER_CLASS
    +
    +
    +
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    var LOCALIZED_NAMES
    +
    +
    +
    +
    +

    Inherited members

    + +
    class Reminders (**kwargs) @@ -9508,7 +10695,7 @@

    Inherited members

    Expand source code -
    class Reminders(NonDeletableFolderMixIn, Folder):
    +
    class Reminders(NonDeletableFolder):
         CONTAINER_CLASS = "Outlook.Reminder"
         LOCALIZED_NAMES = {
             "da_DK": ("Påmindelser",),
    @@ -9516,7 +10703,7 @@ 

    Inherited members

    Ancestors

    +
    +

    A mixin for non-wellknown folders than that are not deletable.

    +
    + +Expand source code + +
    class Schedule(NonDeletableFolder):
    +    pass
    +
    +

    Ancestors

    + +

    Inherited members

    + @@ -10411,14 +11589,13 @@

    Inherited members

    (**kwargs)
    - +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code
    class SentItems(Messages):
         DISTINGUISHED_FOLDER_ID = "sentitems"
    -
         LOCALIZED_NAMES = {
             "da_DK": ("Sendt post",),
             "de_DE": ("Gesendete Elemente",),
    @@ -10434,6 +11611,7 @@ 

    Inherited members

    Ancestors

    -
    -class Sharing +
    +class SharePointNotifications (**kwargs)
    -

    A mixin for non-wellknown folders than that are not deletable.

    +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    class Sharing(NonDeletableFolderMixIn, Folder):
    -    CONTAINER_CLASS = "IPF.Note"
    -    LOCALIZED_NAMES = {
    -        None: ("Sharing",),
    -    }
    +
    class SharePointNotifications(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "sharepointnotifications"

    Ancestors

    Class variables

    -
    var CONTAINER_CLASS
    -
    -
    -
    -
    var LOCALIZED_NAMES
    +
    var DISTINGUISHED_FOLDER_ID

    Inherited members

    -
    -class Shortcuts +
    +class Sharing (**kwargs)
    @@ -10651,14 +11822,12 @@

    Inherited members

    Expand source code -
    class Shortcuts(NonDeletableFolderMixIn, Folder):
    -    LOCALIZED_NAMES = {
    -        None: ("Shortcuts",),
    -    }
    +
    class Sharing(NonDeletableFolder):
    +    CONTAINER_CLASS = "IPF.Note"

    Ancestors

    Class variables

    -
    var LOCALIZED_NAMES
    +
    var CONTAINER_CLASS

    Inherited members

    -
    -
    -class Signal +
  • NonDeletableFolder: + +
  • + + +
    +class ShortNotes (**kwargs)
    -

    A mixin for non-wellknown folders than that are not deletable.

    +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    class Signal(NonDeletableFolderMixIn, Folder):
    -    CONTAINER_CLASS = "IPF.StoreItem.Signal"
    -    LOCALIZED_NAMES = {
    -        None: ("Signal",),
    -    }
    +
    class ShortNotes(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "shortnotes"

    Ancestors

    Class variables

    -
    var CONTAINER_CLASS
    -
    -
    -
    -
    var LOCALIZED_NAMES
    +
    var DISTINGUISHED_FOLDER_ID

    Inherited members

    +
    +
    +class Shortcuts +(**kwargs) +
    +
    +

    A mixin for non-wellknown folders than that are not deletable.

    +
    + +Expand source code + +
    class Shortcuts(NonDeletableFolder):
    +    pass
    +
    +

    Ancestors

    + +

    Inherited members

    + +
    +
    +class Signal +(**kwargs) +
    +
    +

    A mixin for non-wellknown folders than that are not deletable.

    +
    + +Expand source code + +
    class Signal(NonDeletableFolder):
    +    CONTAINER_CLASS = "IPF.StoreItem.Signal"
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var CONTAINER_CLASS
    +
    +
    +
    +
    +

    Inherited members

    + @@ -10930,15 +12223,12 @@

    Inherited members

    Expand source code -
    class SmsAndChatsSync(NonDeletableFolderMixIn, Folder):
    -    CONTAINER_CLASS = "IPF.SmsAndChatsSync"
    -    LOCALIZED_NAMES = {
    -        None: ("SmsAndChatsSync",),
    -    }
    +
    class SmsAndChatsSync(NonDeletableFolder):
    +    CONTAINER_CLASS = "IPF.SmsAndChatsSync"

    Ancestors

    +
    +class System1 +(**kwargs) +
    +
    +

    A mixin for non-wellknown folders than that are not deletable.

    +
    + +Expand source code + +
    class System1(NonDeletableFolder):
    +    get_folder_allowed = False
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var get_folder_allowed

    Inherited members

    -
    -class AllContacts +
    +class AllCategorizedItems (**kwargs)
    -

    A mixin for non-wellknown folders than that are not deletable.

    +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    class AllContacts(NonDeletableFolderMixIn, Contacts):
    -    CONTAINER_CLASS = "IPF.Note"
    -
    -    LOCALIZED_NAMES = {
    -        None: ("AllContacts",),
    -    }
    +
    class AllCategorizedItems(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "allcategorizeditems"
    +    CONTAINER_CLASS = "IPF.Note"

    Ancestors

    Class variables

    -
    var CONTAINER_CLASS
    +
    var CONTAINER_CLASS
    -
    var LOCALIZED_NAMES
    +
    var DISTINGUISHED_FOLDER_ID

    Inherited members

    -
    -class AllItems +
    +class AllContacts (**kwargs)
    -

    A mixin for non-wellknown folders than that are not deletable.

    +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    class AllItems(NonDeletableFolderMixIn, Folder):
    -    CONTAINER_CLASS = "IPF"
    -
    -    LOCALIZED_NAMES = {
    -        None: ("AllItems",),
    -    }
    +
    class AllContacts(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "allcontacts"
    +    CONTAINER_CLASS = "IPF.Note"

    Ancestors

    Class variables

    -
    var CONTAINER_CLASS
    -
    -
    -
    -
    var LOCALIZED_NAMES
    +
    var CONTAINER_CLASS
    -
    -

    Inherited members

    - -
    -
    -class ApplicationData -(**kwargs) -
    -
    -

    A mixin for non-wellknown folders than that are not deletable.

    -
    - -Expand source code - -
    class ApplicationData(NonDeletableFolderMixIn, Folder):
    -    CONTAINER_CLASS = "IPM.ApplicationData"
    -
    -

    Ancestors

    - -

    Class variables

    -
    -
    var CONTAINER_CLASS
    +
    var DISTINGUISHED_FOLDER_ID

    Inherited members

    -
    -class ArchiveDeletedItems +
    +class AllItems (**kwargs)
    @@ -1108,9 +1096,9 @@

    Inherited members

    Expand source code -
    class ArchiveDeletedItems(WellknownFolder):
    -    DISTINGUISHED_FOLDER_ID = "archivedeleteditems"
    -    supported_from = EXCHANGE_2010_SP1
    +
    class AllItems(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "allitems"
    +    CONTAINER_CLASS = "IPF"

    Ancestors

      @@ -1125,11 +1113,11 @@

      Ancestors

    Class variables

    -
    var DISTINGUISHED_FOLDER_ID
    +
    var CONTAINER_CLASS
    -
    var supported_from
    +
    var DISTINGUISHED_FOLDER_ID
    @@ -1172,7 +1160,310 @@

    Inherited members

    -
    +
    +class AllPersonMetadata +(**kwargs) +
    +
    +

    Base class to use until we have a more specific folder implementation for this folder.

    +
    + +Expand source code + +
    class AllPersonMetadata(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "allpersonmetadata"
    +    CONTAINER_CLASS = "IPF.Note"
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var CONTAINER_CLASS
    +
    +
    +
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class AllTodoTasks +(**kwargs) +
    +
    +

    A mixin for non-wellknown folders than that are not deletable.

    +
    + +Expand source code + +
    class AllTodoTasks(NonDeletableFolder):
    +    DISTINGUISHED_FOLDER_ID = None
    +    CONTAINER_CLASS = "IPF.Task"
    +    supported_item_models = (Task,)
    +    LOCALIZED_NAMES = {
    +        None: ("AllTodoTasks",),
    +    }
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var CONTAINER_CLASS
    +
    +
    +
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    var LOCALIZED_NAMES
    +
    +
    +
    +
    var supported_item_models
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class ApplicationData +(**kwargs) +
    +
    +

    A mixin for non-wellknown folders than that are not deletable.

    +
    + +Expand source code + +
    class ApplicationData(NonDeletableFolder):
    +    CONTAINER_CLASS = "IPM.ApplicationData"
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var CONTAINER_CLASS
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class ArchiveDeletedItems +(**kwargs) +
    +
    +

    Base class to use until we have a more specific folder implementation for this folder.

    +
    + +Expand source code + +
    class ArchiveDeletedItems(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "archivedeleteditems"
    +    supported_from = EXCHANGE_2010_SP1
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    var supported_from
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    class ArchiveInbox (**kwargs)
    @@ -1626,15 +1917,12 @@

    Inherited members

    Expand source code -
    class Audits(NonDeletableFolderMixIn, Folder):
    -    LOCALIZED_NAMES = {
    -        None: ("Audits",),
    -    }
    +
    class Audits(NonDeletableFolder):
         get_folder_allowed = False

    Ancestors

    Class variables

    -
    var LOCALIZED_NAMES
    -
    -
    -
    var get_folder_allowed
    @@ -1656,60 +1940,59 @@

    Class variables

    Inherited members

    - -
    -class Birthdays -(**kwargs) -
    -
    - -
    - -Expand source code - -
    class Birthdays(Folder):
    -    CONTAINER_CLASS = "IPF.Appointment.Birthday"
    -    LOCALIZED_NAMES = {
    -        None: ("Birthdays",),
    -        "da_DK": ("Fødselsdage",),
    -    }
    -
    -

    Ancestors

    +
  • NonDeletableFolder: + +
  • + +
    +
    +class Birthdays +(**kwargs) +
    +
    + +
    + +Expand source code + +
    class Birthdays(Folder):
    +    CONTAINER_CLASS = "IPF.Appointment.Birthday"
    +    LOCALIZED_NAMES = {
    +        "da_DK": ("Fødselsdage",),
    +    }
    +
    +

    Ancestors

    +
    +class NonDeletableFolder +(**kwargs)

    A mixin for non-wellknown folders than that are not deletable.

    @@ -4514,26 +5064,35 @@

    Inherited members

    Expand source code -
    class NonDeletableFolderMixIn:
    +
    class NonDeletableFolder(Folder):
         """A mixin for non-wellknown folders than that are not deletable."""
     
         @property
         def is_deletable(self):
             return False
    +

    Ancestors

    +

    Subclasses

    Instance variables

    -
    var is_deletable
    +
    var is_deletable
    @@ -4576,6 +5133,43 @@

    Instance variables

    +

    Inherited members

    +
    class Notes @@ -4658,6 +5252,75 @@

    Inherited members

    +
    +class OneNotePagePreviews +(**kwargs) +
    +
    +

    Base class to use until we have a more specific folder implementation for this folder.

    +
    + +Expand source code + +
    class OneNotePagePreviews(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "onenotepagepreviews"
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    +

    Inherited members

    + +
    class OrganizationalContacts (**kwargs) @@ -4668,17 +5331,16 @@

    Inherited members

    Expand source code -
    class OrganizationalContacts(NonDeletableFolderMixIn, Contacts):
    -    DISTINGUISHED_FOLDER_ID = None
    +
    class OrganizationalContacts(NonDeletableFolder):
         CONTAINER_CLASS = "IPF.Contact.OrganizationalContacts"
    +    supported_item_models = (Contact, DistributionList)
         LOCALIZED_NAMES = {
             None: ("Organizational Contacts",),
         }

    Ancestors

    - +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code
    class Outbox(Messages):
         DISTINGUISHED_FOLDER_ID = "outbox"
    -
         LOCALIZED_NAMES = {
             "da_DK": ("Udbakke",),
             "de_DE": ("Postausgang",),
    @@ -4768,6 +5429,7 @@ 

    Inherited members

    Ancestors

    • Messages
    • +
    • WellknownFolder
    • Folder
    • BaseFolder
    • RegisterMixIn
    • @@ -4835,15 +5497,12 @@

      Inherited members

      Expand source code -
      class ParkedMessages(NonDeletableFolderMixIn, Folder):
      -    CONTAINER_CLASS = None
      -    LOCALIZED_NAMES = {
      -        None: ("ParkedMessages",),
      -    }
      +
      class ParkedMessages(NonDeletableFolder):
      +    CONTAINER_CLASS = None

    Ancestors

    -
    -class RSSFeeds +
    +class QedcLongRetention (**kwargs)
    -

    A mixin for non-wellknown folders than that are not deletable.

    +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    class RSSFeeds(NonDeletableFolderMixIn, Folder):
    -    CONTAINER_CLASS = "IPF.Note.OutlookHomepage"
    -    LOCALIZED_NAMES = {
    -        None: ("RSS Feeds",),
    -    }
    +
    class QedcLongRetention(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "qedclongretention"

    Ancestors

    Class variables

    -
    var CONTAINER_CLASS
    -
    -
    -
    -
    var LOCALIZED_NAMES
    +
    var DISTINGUISHED_FOLDER_ID

    Inherited members

    -
    -class RecipientCache +
    +class QedcMediumRetention (**kwargs)
    - +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    class RecipientCache(Contacts):
    -    DISTINGUISHED_FOLDER_ID = "recipientcache"
    -    CONTAINER_CLASS = "IPF.Contact.RecipientCache"
    -    supported_from = EXCHANGE_2013
    -
    -    LOCALIZED_NAMES = {}
    +
    class QedcMediumRetention(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "qedcmediumretention"

    Ancestors

    Class variables

    -
    var CONTAINER_CLASS
    -
    -
    -
    -
    var DISTINGUISHED_FOLDER_ID
    -
    -
    -
    -
    var LOCALIZED_NAMES
    -
    -
    -
    -
    var supported_from
    +
    var DISTINGUISHED_FOLDER_ID

    Inherited members

    -
    -class RecoverableItemsDeletions +
    +class QedcShortRetention (**kwargs)
    @@ -5459,9 +6073,8 @@

    Inherited members

    Expand source code -
    class RecoverableItemsDeletions(WellknownFolder):
    -    DISTINGUISHED_FOLDER_ID = "recoverableitemsdeletions"
    -    supported_from = EXCHANGE_2010_SP1
    +
    class QedcShortRetention(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "qedcshortretention"

    Ancestors

      @@ -5476,11 +6089,7 @@

      Ancestors

    Class variables

    -
    var DISTINGUISHED_FOLDER_ID
    -
    -
    -
    -
    var supported_from
    +
    var DISTINGUISHED_FOLDER_ID
    @@ -5523,8 +6132,8 @@

    Inherited members

    -
    -class RecoverableItemsPurges +
    +class QuarantinedEmail (**kwargs)
    @@ -5533,9 +6142,8 @@

    Inherited members

    Expand source code -
    class RecoverableItemsPurges(WellknownFolder):
    -    DISTINGUISHED_FOLDER_ID = "recoverableitemspurges"
    -    supported_from = EXCHANGE_2010_SP1
    +
    class QuarantinedEmail(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "quarantinedemail"

    Ancestors

      @@ -5550,11 +6158,7 @@

      Ancestors

    Class variables

    -
    var DISTINGUISHED_FOLDER_ID
    -
    -
    -
    -
    var supported_from
    +
    var DISTINGUISHED_FOLDER_ID
    @@ -5597,8 +6201,8 @@

    Inherited members

    -
    -class RecoverableItemsRoot +
    +class QuarantinedEmailDefaultCategory (**kwargs)
    @@ -5607,9 +6211,8 @@

    Inherited members

    Expand source code -
    class RecoverableItemsRoot(WellknownFolder):
    -    DISTINGUISHED_FOLDER_ID = "recoverableitemsroot"
    -    supported_from = EXCHANGE_2010_SP1
    +
    class QuarantinedEmailDefaultCategory(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "quarantinedemaildefaultcategory"

    Ancestors

      @@ -5624,11 +6227,7 @@

      Ancestors

    Class variables

    -
    var DISTINGUISHED_FOLDER_ID
    -
    -
    -
    -
    var supported_from
    +
    var DISTINGUISHED_FOLDER_ID
    @@ -5671,8 +6270,8 @@

    Inherited members

    -
    -class RecoverableItemsVersions +
    +class QuickContacts (**kwargs)
    @@ -5681,9 +6280,10 @@

    Inherited members

    Expand source code -
    class RecoverableItemsVersions(WellknownFolder):
    -    DISTINGUISHED_FOLDER_ID = "recoverableitemsversions"
    -    supported_from = EXCHANGE_2010_SP1
    +
    class QuickContacts(WellknownFolder):
    +    CONTAINER_CLASS = "IPF.Contact.MOC.QuickContacts"
    +    DISTINGUISHED_FOLDER_ID = "quickcontacts"
    +    supported_from = EXCHANGE_2013

    Ancestors

      @@ -5698,11 +6298,15 @@

      Ancestors

    Class variables

    -
    var DISTINGUISHED_FOLDER_ID
    +
    var CONTAINER_CLASS
    -
    var supported_from
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    var supported_from
    @@ -5745,7 +6349,546 @@

    Inherited members

    -
    +
    +class RSSFeeds +(**kwargs) +
    +
    +

    A mixin for non-wellknown folders than that are not deletable.

    +
    + +Expand source code + +
    class RSSFeeds(NonDeletableFolder):
    +    CONTAINER_CLASS = "IPF.Note.OutlookHomepage"
    +    LOCALIZED_NAMES = {
    +        None: ("RSS Feeds",),
    +    }
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var CONTAINER_CLASS
    +
    +
    +
    +
    var LOCALIZED_NAMES
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class RecipientCache +(**kwargs) +
    +
    +

    Base class to use until we have a more specific folder implementation for this folder.

    +
    + +Expand source code + +
    class RecipientCache(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "recipientcache"
    +    CONTAINER_CLASS = "IPF.Contact.RecipientCache"
    +    supported_from = EXCHANGE_2013
    +    LOCALIZED_NAMES = {
    +        None: ("RecipientCache",),
    +    }
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var CONTAINER_CLASS
    +
    +
    +
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    var LOCALIZED_NAMES
    +
    +
    +
    +
    var supported_from
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class RecoverableItemsDeletions +(**kwargs) +
    +
    +

    Base class to use until we have a more specific folder implementation for this folder.

    +
    + +Expand source code + +
    class RecoverableItemsDeletions(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "recoverableitemsdeletions"
    +    supported_from = EXCHANGE_2010_SP1
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    var supported_from
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class RecoverableItemsPurges +(**kwargs) +
    +
    +

    Base class to use until we have a more specific folder implementation for this folder.

    +
    + +Expand source code + +
    class RecoverableItemsPurges(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "recoverableitemspurges"
    +    supported_from = EXCHANGE_2010_SP1
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    var supported_from
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class RecoverableItemsRoot +(**kwargs) +
    +
    +

    Base class to use until we have a more specific folder implementation for this folder.

    +
    + +Expand source code + +
    class RecoverableItemsRoot(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "recoverableitemsroot"
    +    supported_from = EXCHANGE_2010_SP1
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    var supported_from
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class RecoverableItemsSubstrateHolds +(**kwargs) +
    +
    +

    Base class to use until we have a more specific folder implementation for this folder.

    +
    + +Expand source code + +
    class RecoverableItemsSubstrateHolds(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "recoverableitemssubstrateholds"
    +    supported_from = EXCHANGE_2010_SP1
    +    LOCALIZED_NAMES = {
    +        None: ("SubstrateHolds",),
    +    }
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    var LOCALIZED_NAMES
    +
    +
    +
    +
    var supported_from
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    +class RecoverableItemsVersions +(**kwargs) +
    +
    +

    Base class to use until we have a more specific folder implementation for this folder.

    +
    + +Expand source code + +
    class RecoverableItemsVersions(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "recoverableitemsversions"
    +    supported_from = EXCHANGE_2010_SP1
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    var supported_from
    +
    +
    +
    +
    +

    Inherited members

    + +
    +
    class RecoveryPoints (**kwargs)
    @@ -5813,25 +6956,26 @@

    Inherited members

    -
    -class Reminders +
    +class RelevantContacts (**kwargs)
    -

    A mixin for non-wellknown folders than that are not deletable.

    +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    class Reminders(NonDeletableFolderMixIn, Folder):
    -    CONTAINER_CLASS = "Outlook.Reminder"
    +
    class RelevantContacts(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "relevantcontacts"
    +    CONTAINER_CLASS = "IPF.Note"
         LOCALIZED_NAMES = {
    -        "da_DK": ("Påmindelser",),
    +        None: ("RelevantContacts",),
         }

    Ancestors

    Class variables

    -
    var CONTAINER_CLASS
    +
    var CONTAINER_CLASS
    -
    var LOCALIZED_NAMES
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    var LOCALIZED_NAMES

    Inherited members

    -
    -class Schedule +
    +class Reminders (**kwargs)
    @@ -5899,14 +7047,15 @@

    Inherited members

    Expand source code -
    class Schedule(NonDeletableFolderMixIn, Folder):
    +
    class Reminders(NonDeletableFolder):
    +    CONTAINER_CLASS = "Outlook.Reminder"
         LOCALIZED_NAMES = {
    -        None: ("Schedule",),
    +        "da_DK": ("Påmindelser",),
         }

    Ancestors

    Class variables

    -
    var LOCALIZED_NAMES
    +
    var CONTAINER_CLASS
    +
    +
    +
    +
    var LOCALIZED_NAMES

    Inherited members

    +
    +class Schedule +(**kwargs) +
    +
    +

    A mixin for non-wellknown folders than that are not deletable.

    +
    + +Expand source code + +
    class Schedule(NonDeletableFolder):
    +    pass
    +
    +

    Ancestors

    + +

    Inherited members

    + @@ -6034,14 +7249,13 @@

    Inherited members

    (**kwargs)
    - +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code
    class SentItems(Messages):
         DISTINGUISHED_FOLDER_ID = "sentitems"
    -
         LOCALIZED_NAMES = {
             "da_DK": ("Sendt post",),
             "de_DE": ("Gesendete Elemente",),
    @@ -6057,6 +7271,7 @@ 

    Inherited members

    Ancestors

    +
    +class SharePointNotifications +(**kwargs) +
    +
    +

    Base class to use until we have a more specific folder implementation for this folder.

    +
    + +Expand source code + +
    class SharePointNotifications(WellknownFolder):
    +    DISTINGUISHED_FOLDER_ID = "sharepointnotifications"
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var DISTINGUISHED_FOLDER_ID
    +
    +
    +
    +
    +

    Inherited members

    + +
    class Sharing (**kwargs) @@ -6198,15 +7482,12 @@

    Inherited members

    Expand source code -
    class Sharing(NonDeletableFolderMixIn, Folder):
    -    CONTAINER_CLASS = "IPF.Note"
    -    LOCALIZED_NAMES = {
    -        None: ("Sharing",),
    -    }
    +
    class Sharing(NonDeletableFolder):
    +    CONTAINER_CLASS = "IPF.Note"

    Ancestors

    - +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    class Tasks(Folder):
    +
    class Tasks(WellknownFolder):
         DISTINGUISHED_FOLDER_ID = "tasks"
         CONTAINER_CLASS = "IPF.Task"
         supported_item_models = (Task,)
    -
         LOCALIZED_NAMES = {
             "da_DK": ("Opgaver",),
             "de_DE": ("Aufgaben",),
    @@ -6961,6 +8269,7 @@ 

    Inherited members

    Ancestors

    @@ -7184,24 +8490,23 @@

    Inherited members

    -
    -class Views +
    +class UserCuratedContacts (**kwargs)
    -

    A mixin for non-wellknown folders than that are not deletable.

    +

    Base class to use until we have a more specific folder implementation for this folder.

    Expand source code -
    class Views(NonDeletableFolderMixIn, Folder):
    -    LOCALIZED_NAMES = {
    -        None: ("Views",),
    -    }
    +
    class UserCuratedContacts(WellknownFolder):
    +    CONTAINER_CLASS = "IPF.Note"
    +    DISTINGUISHED_FOLDER_ID = "usercuratedcontacts"

    Ancestors

    Class variables

    -
    var LOCALIZED_NAMES
    +
    var CONTAINER_CLASS
    +
    +
    +
    +
    var DISTINGUISHED_FOLDER_ID

    Inherited members

    +
    +
    +class Views +(**kwargs) +
    +
    +

    A mixin for non-wellknown folders than that are not deletable.

    +
    + +Expand source code + +
    class Views(NonDeletableFolder):
    +    pass
    +
    +

    Ancestors

    + +

    Inherited members

    + @@ -7364,6 +8735,10 @@

    Ancestors

    Subclasses

    Class variables

    @@ -7448,14 +8847,14 @@

    Inherited members

    Expand source code -
    class WorkingSet(NonDeletableFolderMixIn, Folder):
    +
    class WorkingSet(NonDeletableFolder):
         LOCALIZED_NAMES = {
             None: ("Working Set",),
         }

    Ancestors

    -def get_distinguished(root) +def get_distinguished(account)

    Get the distinguished folder for this folder class.

    -

    :param root: +

    :param account: :return:

    Expand source code
    @classmethod
    -def get_distinguished(cls, root):
    +def get_distinguished(cls, account):
         """Get the distinguished folder for this folder class.
     
    -    :param root:
    +    :param account:
         :return:
         """
         try:
             return cls.resolve(
    -            account=root.account,
    +            account=account,
                 folder=DistinguishedFolderId(
                     id=cls.DISTINGUISHED_FOLDER_ID,
    -                mailbox=Mailbox(email_address=root.account.primary_smtp_address),
    +                mailbox=Mailbox(email_address=account.primary_smtp_address),
                 ),
             )
         except MISSING_FOLDER_ERRORS:
    @@ -11944,7 +11900,7 @@ 

    Inherited members

    return f try: log.debug("Requesting distinguished %s folder explicitly", folder_cls) - return folder_cls.get_distinguished(root=self) + return folder_cls.get_distinguished(account=self.account) except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) @@ -12261,7 +12217,7 @@

    Methods

    return f try: log.debug("Requesting distinguished %s folder explicitly", folder_cls) - return folder_cls.get_distinguished(root=self) + return folder_cls.get_distinguished(account=self.account) except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) diff --git a/docs/exchangelib/properties.html b/docs/exchangelib/properties.html index 84865a2d..a181f509 100644 --- a/docs/exchangelib/properties.html +++ b/docs/exchangelib/properties.html @@ -45,6 +45,7 @@

    Module exchangelib.properties

    ) from .fields import ( WEEKDAY_NAMES, + AddressListField, AssociatedCalendarItemIdField, Base64Field, BooleanField, @@ -65,6 +66,7 @@

    Module exchangelib.properties

    Field, FieldPath, FlaggedForActionField, + FolderActionField, FreeBusyStatusField, GenericEventListField, IdElementField, @@ -777,6 +779,10 @@

    Module exchangelib.properties

    mailbox = MailboxField() + @classmethod + def from_xml(cls, elem, account): + return cls(id=elem.text or None) + def clean(self, version=None): from .folders import PublicFoldersRoot @@ -846,7 +852,7 @@

    Module exchangelib.properties

    @classmethod def from_xml(cls, elem, account): - res = super().from_xml(elem, account) + res = super().from_xml(elem=elem, account=account) # Some parts of EWS use '5' to mean 'last occurrence in month', others use '-1'. Let's settle on '5' because # only '5' is accepted in requests. if res.occurrence == -1: @@ -1893,7 +1899,7 @@

    Module exchangelib.properties

    @classmethod def from_xml(cls, elem, account): - res = super().from_xml(elem, account) + res = super().from_xml(elem=elem, account=account) # See TimeZoneTransition.from_xml() if res.occurrence == -1: res.occurrence = 5 @@ -1945,10 +1951,6 @@

    Module exchangelib.properties

    transitions_groups = EWSElementListField(field_uri="TransitionsGroups", value_cls=TransitionsGroup) transitions = TransitionListField(field_uri="Transitions", value_cls=BaseTransition) - @classmethod - def from_xml(cls, elem, account): - return super().from_xml(elem, account) - def _get_standard_period(self, transitions_group): # Find the first standard period referenced from transitions_group standard_periods_map = {p.id: p for p in self.periods if p.name == "Standard"} @@ -2250,10 +2252,9 @@

    Module exchangelib.properties

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/copytofolder""" ELEMENT_NAME = "CopyToFolder" - NAMESPACE = MNS - folder_id = EWSElementField(value_cls=FolderId, field_uri="FolderId") - distinguished_folder_id = EWSElementField(value_cls=DistinguishedFolderId, field_uri="DistinguishedFolderId") + folder_id = EWSElementField(value_cls=FolderId) + distinguished_folder_id = EWSElementField(value_cls=DistinguishedFolderId) class MoveToFolder(CopyToFolder): @@ -2266,21 +2267,18 @@

    Module exchangelib.properties

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/actions""" ELEMENT_NAME = "Actions" - NAMESPACE = TNS assign_categories = CharListField(field_uri="AssignCategories") - copy_to_folder = EWSElementField(value_cls=CopyToFolder, field_uri="CopyToFolder") + copy_to_folder = FolderActionField(value_cls=CopyToFolder) delete = BooleanField(field_uri="Delete") - forward_as_attachment_to_recipients = EWSElementField( - value_cls=Mailbox, field_uri="ForwardAsAttachmentToRecipients" - ) - forward_to_recipients = EWSElementField(value_cls=Mailbox, field_uri="ForwardToRecipients") + forward_as_attachment_to_recipients = AddressListField(field_uri="ForwardAsAttachmentToRecipients") + forward_to_recipients = AddressListField(field_uri="ForwardToRecipients") mark_importance = ImportanceField(field_uri="MarkImportance") mark_as_read = BooleanField(field_uri="MarkAsRead") - move_to_folder = EWSElementField(value_cls=MoveToFolder, field_uri="MoveToFolder") + move_to_folder = FolderActionField(value_cls=MoveToFolder) permanent_delete = BooleanField(field_uri="PermanentDelete") - redirect_to_recipients = EWSElementField(value_cls=Mailbox, field_uri="RedirectToRecipients") - send_sms_alert_to_recipients = EWSElementField(value_cls=Mailbox, field_uri="SendSMSAlertToRecipients") + redirect_to_recipients = AddressListField(field_uri="RedirectToRecipients") + send_sms_alert_to_recipients = AddressListField(field_uri="SendSMSAlertToRecipients") server_reply_with_message = EWSElementField(value_cls=ItemId, field_uri="ServerReplyWithMessage") stop_processing_rules = BooleanField(field_uri="StopProcessingRules") @@ -2288,18 +2286,52 @@

    Module exchangelib.properties

    class Rule(EWSElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/rule-ruletype""" + def __init__(self, **kwargs): + """Pick out optional 'account' kwarg, and pass the rest to the parent class. + + :param kwargs: + 'account' is optional but allows calling 'send()' and 'delete()' + """ + from .account import Account + + self.account = kwargs.pop("account", None) + if self.account is not None and not isinstance(self.account, Account): + raise InvalidTypeError("account", self.account, Account) + super().__init__(**kwargs) + + __slots__ = ("account",) + ELEMENT_NAME = "Rule" - NAMESPACE = TNS id = CharField(field_uri="RuleId") - display_name = CharField(field_uri="DisplayName") - priority = IntegerField(field_uri="Priority") + display_name = CharField(field_uri="DisplayName", is_required=True) + priority = IntegerField(field_uri="Priority", is_required=True) is_enabled = BooleanField(field_uri="IsEnabled") is_not_supported = BooleanField(field_uri="IsNotSupported") is_in_error = BooleanField(field_uri="IsInError") conditions = EWSElementField(value_cls=Conditions) exceptions = EWSElementField(value_cls=Exceptions) - actions = EWSElementField(value_cls=Actions) + actions = EWSElementField(value_cls=Actions, is_required=True) + + @classmethod + def from_xml(cls, elem, account): + res = super().from_xml(elem=elem, account=account) + res.account = account + return res + + def save(self): + if self.id is None: + self.account.create_rule(self) + else: + self.account.set_rule(self) + return self + + def delete(self): + if self.is_enabled is False: + # Cannot delete a disabled rule - server throws 'ErrorItemNotFound' + self.is_enabled = True + self.save() + self.account.delete_rule(self) class InboxRules(EWSElement): @@ -2315,7 +2347,6 @@

    Module exchangelib.properties

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createruleoperation""" ELEMENT_NAME = "CreateRuleOperation" - NAMESPACE = TNS rule = EWSElementField(value_cls=Rule) @@ -2324,7 +2355,6 @@

    Module exchangelib.properties

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/setruleoperation""" ELEMENT_NAME = "SetRuleOperation" - NAMESPACE = TNS rule = EWSElementField(value_cls=Rule) @@ -2333,7 +2363,6 @@

    Module exchangelib.properties

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteruleoperation""" ELEMENT_NAME = "DeleteRuleOperation" - NAMESPACE = TNS id = CharField(field_uri="RuleId") @@ -2475,21 +2504,18 @@

    Inherited members

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/actions""" ELEMENT_NAME = "Actions" - NAMESPACE = TNS assign_categories = CharListField(field_uri="AssignCategories") - copy_to_folder = EWSElementField(value_cls=CopyToFolder, field_uri="CopyToFolder") + copy_to_folder = FolderActionField(value_cls=CopyToFolder) delete = BooleanField(field_uri="Delete") - forward_as_attachment_to_recipients = EWSElementField( - value_cls=Mailbox, field_uri="ForwardAsAttachmentToRecipients" - ) - forward_to_recipients = EWSElementField(value_cls=Mailbox, field_uri="ForwardToRecipients") + forward_as_attachment_to_recipients = AddressListField(field_uri="ForwardAsAttachmentToRecipients") + forward_to_recipients = AddressListField(field_uri="ForwardToRecipients") mark_importance = ImportanceField(field_uri="MarkImportance") mark_as_read = BooleanField(field_uri="MarkAsRead") - move_to_folder = EWSElementField(value_cls=MoveToFolder, field_uri="MoveToFolder") + move_to_folder = FolderActionField(value_cls=MoveToFolder) permanent_delete = BooleanField(field_uri="PermanentDelete") - redirect_to_recipients = EWSElementField(value_cls=Mailbox, field_uri="RedirectToRecipients") - send_sms_alert_to_recipients = EWSElementField(value_cls=Mailbox, field_uri="SendSMSAlertToRecipients") + redirect_to_recipients = AddressListField(field_uri="RedirectToRecipients") + send_sms_alert_to_recipients = AddressListField(field_uri="SendSMSAlertToRecipients") server_reply_with_message = EWSElementField(value_cls=ItemId, field_uri="ServerReplyWithMessage") stop_processing_rules = BooleanField(field_uri="StopProcessingRules")
    @@ -2507,10 +2533,6 @@

    Class variables

    -
    var NAMESPACE
    -
    -
    -

    Instance variables

    @@ -4322,10 +4344,9 @@

    Inherited members

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/copytofolder""" ELEMENT_NAME = "CopyToFolder" - NAMESPACE = MNS - folder_id = EWSElementField(value_cls=FolderId, field_uri="FolderId") - distinguished_folder_id = EWSElementField(value_cls=DistinguishedFolderId, field_uri="DistinguishedFolderId")
    + folder_id = EWSElementField(value_cls=FolderId) + distinguished_folder_id = EWSElementField(value_cls=DistinguishedFolderId)

    Ancestors

      @@ -4345,10 +4366,6 @@

      Class variables

      -
      var NAMESPACE
      -
      -
      -

    Instance variables

    @@ -4387,7 +4404,6 @@

    Inherited members

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/createruleoperation""" ELEMENT_NAME = "CreateRuleOperation" - NAMESPACE = TNS rule = EWSElementField(value_cls=Rule)
    @@ -4405,10 +4421,6 @@

    Class variables

    -
    var NAMESPACE
    -
    -
    -

    Instance variables

    @@ -4731,7 +4743,6 @@

    Inherited members

    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/deleteruleoperation""" ELEMENT_NAME = "DeleteRuleOperation" - NAMESPACE = TNS id = CharField(field_uri="RuleId")
    @@ -4749,10 +4760,6 @@

    Class variables

    -
    var NAMESPACE
    -
    -
    -

    Instance variables

    @@ -4886,6 +4893,10 @@

    Inherited members

    mailbox = MailboxField() + @classmethod + def from_xml(cls, elem, account): + return cls(id=elem.text or None) + def clean(self, version=None): from .folders import PublicFoldersRoot @@ -4912,6 +4923,23 @@

    Class variables

    +

    Static methods

    +
    +
    +def from_xml(elem, account) +
    +
    +
    +
    + +Expand source code + +
    @classmethod
    +def from_xml(cls, elem, account):
    +    return cls(id=elem.text or None)
    +
    +
    +

    Instance variables

    var mailbox
    @@ -9122,7 +9150,7 @@

    Inherited members

    @classmethod def from_xml(cls, elem, account): - res = super().from_xml(elem, account) + res = super().from_xml(elem=elem, account=account) # See TimeZoneTransition.from_xml() if res.occurrence == -1: res.occurrence = 5 @@ -9157,7 +9185,7 @@

    Static methods

    @classmethod
     def from_xml(cls, elem, account):
    -    res = super().from_xml(elem, account)
    +    res = super().from_xml(elem=elem, account=account)
         # See TimeZoneTransition.from_xml()
         if res.occurrence == -1:
             res.occurrence = 5
    @@ -9756,7 +9784,10 @@ 

    Inherited members

    (**kwargs)
    - +

    MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/rule-ruletype

    +

    Pick out optional 'account' kwarg, and pass the rest to the parent class.

    +

    :param kwargs: +'account' is optional but allows calling 'send()' and 'delete()'

    Expand source code @@ -9764,18 +9795,52 @@

    Inherited members

    class Rule(EWSElement):
         """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/rule-ruletype"""
     
    +    def __init__(self, **kwargs):
    +        """Pick out optional 'account' kwarg, and pass the rest to the parent class.
    +
    +        :param kwargs:
    +            'account' is optional but allows calling 'send()' and 'delete()'
    +        """
    +        from .account import Account
    +
    +        self.account = kwargs.pop("account", None)
    +        if self.account is not None and not isinstance(self.account, Account):
    +            raise InvalidTypeError("account", self.account, Account)
    +        super().__init__(**kwargs)
    +
    +    __slots__ = ("account",)
    +
         ELEMENT_NAME = "Rule"
    -    NAMESPACE = TNS
     
         id = CharField(field_uri="RuleId")
    -    display_name = CharField(field_uri="DisplayName")
    -    priority = IntegerField(field_uri="Priority")
    +    display_name = CharField(field_uri="DisplayName", is_required=True)
    +    priority = IntegerField(field_uri="Priority", is_required=True)
         is_enabled = BooleanField(field_uri="IsEnabled")
         is_not_supported = BooleanField(field_uri="IsNotSupported")
         is_in_error = BooleanField(field_uri="IsInError")
         conditions = EWSElementField(value_cls=Conditions)
         exceptions = EWSElementField(value_cls=Exceptions)
    -    actions = EWSElementField(value_cls=Actions)
    + actions = EWSElementField(value_cls=Actions, is_required=True) + + @classmethod + def from_xml(cls, elem, account): + res = super().from_xml(elem=elem, account=account) + res.account = account + return res + + def save(self): + if self.id is None: + self.account.create_rule(self) + else: + self.account.set_rule(self) + return self + + def delete(self): + if self.is_enabled is False: + # Cannot delete a disabled rule - server throws 'ErrorItemNotFound' + self.is_enabled = True + self.save() + self.account.delete_rule(self)

    Ancestors

      @@ -9791,13 +9856,32 @@

      Class variables

      -
      var NAMESPACE
      +
    +

    Static methods

    +
    +
    +def from_xml(elem, account) +
    +
    + +Expand source code + +
    @classmethod
    +def from_xml(cls, elem, account):
    +    res = super().from_xml(elem=elem, account=account)
    +    res.account = account
    +    return res
    +

    Instance variables

    +
    var account
    +
    +

    Return an attribute of instance, which is of type owner.

    +
    var actions
    @@ -9835,6 +9919,43 @@

    Instance variables

    +

    Methods

    +
    +
    +def delete(self) +
    +
    +
    +
    + +Expand source code + +
    def delete(self):
    +    if self.is_enabled is False:
    +        # Cannot delete a disabled rule - server throws 'ErrorItemNotFound'
    +        self.is_enabled = True
    +        self.save()
    +    self.account.delete_rule(self)
    +
    +
    +
    +def save(self) +
    +
    +
    +
    + +Expand source code + +
    def save(self):
    +    if self.id is None:
    +        self.account.create_rule(self)
    +    else:
    +        self.account.set_rule(self)
    +    return self
    +
    +
    +

    Inherited members

    • EWSElement: @@ -9990,7 +10111,6 @@

      Inherited members

      """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/setruleoperation""" ELEMENT_NAME = "SetRuleOperation" - NAMESPACE = TNS rule = EWSElementField(value_cls=Rule)
      @@ -10008,10 +10128,6 @@

      Class variables

      -
      var NAMESPACE
      -
      -
      -

      Instance variables

      @@ -10563,10 +10679,6 @@

      Inherited members

      transitions_groups = EWSElementListField(field_uri="TransitionsGroups", value_cls=TransitionsGroup) transitions = TransitionListField(field_uri="Transitions", value_cls=BaseTransition) - @classmethod - def from_xml(cls, elem, account): - return super().from_xml(elem, account) - def _get_standard_period(self, transitions_group): # Find the first standard period referenced from transitions_group standard_periods_map = {p.id: p for p in self.periods if p.name == "Standard"} @@ -10644,23 +10756,6 @@

      Class variables

      -

      Static methods

      -
      -
      -def from_xml(elem, account) -
      -
      -
      -
      - -Expand source code - -
      @classmethod
      -def from_xml(cls, elem, account):
      -    return super().from_xml(elem, account)
      -
      -
      -

      Instance variables

      var id
      @@ -10769,7 +10864,7 @@

      Inherited members

      @classmethod def from_xml(cls, elem, account): - res = super().from_xml(elem, account) + res = super().from_xml(elem=elem, account=account) # Some parts of EWS use '5' to mean 'last occurrence in month', others use '-1'. Let's settle on '5' because # only '5' is accepted in requests. if res.occurrence == -1: @@ -10811,7 +10906,7 @@

      Static methods

      @classmethod
       def from_xml(cls, elem, account):
      -    res = super().from_xml(elem, account)
      +    res = super().from_xml(elem=elem, account=account)
           # Some parts of EWS use '5' to mean 'last occurrence in month', others use '-1'. Let's settle on '5' because
           # only '5' is accepted in requests.
           if res.occurrence == -1:
      @@ -12070,7 +12165,6 @@ 

    • ELEMENT_NAME
    • FIELDS
    • -
    • NAMESPACE
    • assign_categories
    • copy_to_folder
    • delete
    • @@ -12348,7 +12442,6 @@

    • ELEMENT_NAME
    • FIELDS
    • -
    • NAMESPACE
    • distinguished_folder_id
    • folder_id
    @@ -12358,7 +12451,6 @@

  • ELEMENT_NAME
  • FIELDS
  • -
  • NAMESPACE
  • rule
  • @@ -12411,7 +12503,6 @@

  • ELEMENT_NAME
  • FIELDS
  • -
  • NAMESPACE
  • id
  • @@ -12436,6 +12527,7 @@

    ELEMENT_NAME
  • FIELDS
  • clean
  • +
  • from_xml
  • mailbox
  • @@ -13047,16 +13139,19 @@

  • ELEMENT_NAME
  • FIELDS
  • -
  • NAMESPACE
  • +
  • account
  • actions
  • conditions
  • +
  • delete
  • display_name
  • exceptions
  • +
  • from_xml
  • id
  • is_enabled
  • is_in_error
  • is_not_supported
  • priority
  • +
  • save
  • @@ -13085,7 +13180,6 @@

  • ELEMENT_NAME
  • FIELDS
  • -
  • NAMESPACE
  • rule
  • @@ -13151,7 +13245,6 @@

  • ELEMENT_NAME
  • FIELDS
  • -
  • from_xml
  • get_std_and_dst
  • id
  • name
  • diff --git a/docs/exchangelib/queryset.html b/docs/exchangelib/queryset.html index ce90ed66..bc52f271 100644 --- a/docs/exchangelib/queryset.html +++ b/docs/exchangelib/queryset.html @@ -557,7 +557,10 @@

    Module exchangelib.queryset

    raise DoesNotExist() if len(items) != 1: raise MultipleObjectsReturned() - return items[0] + item = items[0] + if isinstance(item, Exception): + raise item + return item def count(self, page_size=1000): """Get the query count, with as little effort as possible @@ -1222,7 +1225,10 @@

    Classes

    raise DoesNotExist() if len(items) != 1: raise MultipleObjectsReturned() - return items[0] + item = items[0] + if isinstance(item, Exception): + raise item + return item def count(self, page_size=1000): """Get the query count, with as little effort as possible @@ -1563,7 +1569,10 @@

    Methods

    raise DoesNotExist() if len(items) != 1: raise MultipleObjectsReturned() - return items[0]
    + item = items[0] + if isinstance(item, Exception): + raise item + return item
    diff --git a/docs/exchangelib/services/common.html b/docs/exchangelib/services/common.html index 1de32399..05ba0f22 100644 --- a/docs/exchangelib/services/common.html +++ b/docs/exchangelib/services/common.html @@ -1003,7 +1003,6 @@

    Module exchangelib.services.common

    f = folder.from_xml(elem=elem, account=folder.account) elif isinstance(folder, Folder): f = folder.from_xml_with_root(elem=elem, root=folder.root) - f._distinguished_id = folder._distinguished_id elif isinstance(folder, DistinguishedFolderId): # We don't know the root or even account, but we need to attach the folder to something if we want to make # future requests with this folder. Use 'account' but make sure to always use the distinguished folder ID going @@ -1019,7 +1018,6 @@

    Module exchangelib.services.common

    f = folder_cls.from_xml(elem=elem, account=account) else: f = folder_cls.from_xml_with_root(elem=elem, root=account.root) - f._distinguished_id = folder else: # 'folder' is a generic FolderId instance. We don't know the root so assume account.root. f = Folder.from_xml_with_root(elem=elem, root=account.root) @@ -1086,7 +1084,6 @@

    Functions

    f = folder.from_xml(elem=elem, account=folder.account) elif isinstance(folder, Folder): f = folder.from_xml_with_root(elem=elem, root=folder.root) - f._distinguished_id = folder._distinguished_id elif isinstance(folder, DistinguishedFolderId): # We don't know the root or even account, but we need to attach the folder to something if we want to make # future requests with this folder. Use 'account' but make sure to always use the distinguished folder ID going @@ -1102,7 +1099,6 @@

    Functions

    f = folder_cls.from_xml(elem=elem, account=account) else: f = folder_cls.from_xml_with_root(elem=elem, root=account.root) - f._distinguished_id = folder else: # 'folder' is a generic FolderId instance. We don't know the root so assume account.root. f = Folder.from_xml_with_root(elem=elem, root=account.root) diff --git a/docs/exchangelib/services/get_folder.html b/docs/exchangelib/services/get_folder.html index ba473bda..33454a4a 100644 --- a/docs/exchangelib/services/get_folder.html +++ b/docs/exchangelib/services/get_folder.html @@ -26,7 +26,13 @@

    Module exchangelib.services.get_folder

    Expand source code -
    from ..errors import ErrorFolderNotFound, ErrorInvalidOperation, ErrorNoPublicFolderReplicaAvailable
    +
    from ..errors import (
    +    ErrorAccessDenied,
    +    ErrorFolderNotFound,
    +    ErrorInvalidOperation,
    +    ErrorItemNotFound,
    +    ErrorNoPublicFolderReplicaAvailable,
    +)
     from ..util import MNS, create_element
     from .common import EWSAccountService, folder_ids_element, parse_folder_elem, shape_element
     
    @@ -37,9 +43,11 @@ 

    Module exchangelib.services.get_folder

    SERVICE_NAME = "GetFolder" element_container_name = f"{{{MNS}}}Folders" ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + ( + ErrorAccessDenied, ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorInvalidOperation, + ErrorItemNotFound, ) def __init__(self, *args, **kwargs): @@ -110,9 +118,11 @@

    Classes

    SERVICE_NAME = "GetFolder" element_container_name = f"{{{MNS}}}Folders" ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + ( + ErrorAccessDenied, ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorInvalidOperation, + ErrorItemNotFound, ) def __init__(self, *args, **kwargs): diff --git a/docs/exchangelib/services/inbox_rules.html b/docs/exchangelib/services/inbox_rules.html index 60122aaf..e4afd216 100644 --- a/docs/exchangelib/services/inbox_rules.html +++ b/docs/exchangelib/services/inbox_rules.html @@ -31,7 +31,6 @@

    Module exchangelib.services.inbox_rules

    from ..errors import ErrorInvalidOperation from ..properties import CreateRuleOperation, DeleteRuleOperation, InboxRules, Operations, Rule, SetRuleOperation from ..util import MNS, add_xml_child, create_element, get_xml_attr, set_xml_value -from ..version import EXCHANGE_2010 from .common import EWSAccountService @@ -43,7 +42,6 @@

    Module exchangelib.services.inbox_rules

    """ SERVICE_NAME = "GetInboxRules" - supported_from = EXCHANGE_2010 element_container_name = InboxRules.response_tag() ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (ErrorInvalidOperation,) @@ -87,46 +85,41 @@

    Module exchangelib.services.inbox_rules

    """ SERVICE_NAME = "UpdateInboxRules" - supported_from = EXCHANGE_2010 ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (ErrorInvalidOperation,) - -class CreateInboxRule(UpdateInboxRules): - """ - MSDN: - https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-create-rule-request-example - """ - def call(self, rule: Rule, remove_outlook_rule_blob: bool = True): payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob) return self._get_elements(payload=payload) + def _get_operation(self, rule): + raise NotImplementedError() + def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True): payload = create_element(f"m:{self.SERVICE_NAME}") add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob) - operations = Operations(create_rule_operation=CreateRuleOperation(rule=rule)) + operations = self._get_operation(rule) set_xml_value(payload, operations, version=self.account.version) return payload +class CreateInboxRule(UpdateInboxRules): + """ + MSDN: + https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-create-rule-request-example + """ + + def _get_operation(self, rule): + return Operations(create_rule_operation=CreateRuleOperation(rule=rule)) + + class SetInboxRule(UpdateInboxRules): """ MSDN: https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-set-rule-request-example """ - def call(self, rule: Rule, remove_outlook_rule_blob: bool = True): - payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob) - return self._get_elements(payload=payload) - - def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True): - if not rule.id: - raise ValueError("Rule must have an ID") - payload = create_element(f"m:{self.SERVICE_NAME}") - add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob) - operations = Operations(set_rule_operation=SetRuleOperation(rule=rule)) - set_xml_value(payload, operations, version=self.account.version) - return payload + def _get_operation(self, rule): + return Operations(set_rule_operation=SetRuleOperation(rule=rule)) class DeleteInboxRule(UpdateInboxRules): @@ -135,18 +128,8 @@

    Module exchangelib.services.inbox_rules

    https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-delete-rule-request-example """ - def call(self, rule: Rule, remove_outlook_rule_blob: bool = True): - payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob) - return self._get_elements(payload=payload) - - def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True): - if not rule.id: - raise ValueError("Rule must have an ID") - payload = create_element(f"m:{self.SERVICE_NAME}") - add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob) - operations = Operations(delete_rule_operation=DeleteRuleOperation(id=rule.id)) - set_xml_value(payload, operations, version=self.account.version) - return payload
    + def _get_operation(self, rule): + return Operations(delete_rule_operation=DeleteRuleOperation(id=rule.id))
    @@ -175,16 +158,8 @@

    Classes

    https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-create-rule-request-example """ - def call(self, rule: Rule, remove_outlook_rule_blob: bool = True): - payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob) - return self._get_elements(payload=payload) - - def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True): - payload = create_element(f"m:{self.SERVICE_NAME}") - add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob) - operations = Operations(create_rule_operation=CreateRuleOperation(rule=rule)) - set_xml_value(payload, operations, version=self.account.version) - return payload
    + def _get_operation(self, rule): + return Operations(create_rule_operation=CreateRuleOperation(rule=rule))

    Ancestors

    -

    Methods

    -
    -
    -def call(self, rule: Rule, remove_outlook_rule_blob: bool = True) -
    -
    -
    -
    - -Expand source code - -
    def call(self, rule: Rule, remove_outlook_rule_blob: bool = True):
    -    payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob)
    -    return self._get_elements(payload=payload)
    -
    -
    -
    -def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True) -
    -
    -
    -
    - -Expand source code - -
    def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True):
    -    payload = create_element(f"m:{self.SERVICE_NAME}")
    -    add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob)
    -    operations = Operations(create_rule_operation=CreateRuleOperation(rule=rule))
    -    set_xml_value(payload, operations, version=self.account.version)
    -    return payload
    -
    -
    -

    Inherited members

    • UpdateInboxRules: @@ -258,18 +199,8 @@

      Inherited members

      https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-delete-rule-request-example """ - def call(self, rule: Rule, remove_outlook_rule_blob: bool = True): - payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob) - return self._get_elements(payload=payload) - - def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True): - if not rule.id: - raise ValueError("Rule must have an ID") - payload = create_element(f"m:{self.SERVICE_NAME}") - add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob) - operations = Operations(delete_rule_operation=DeleteRuleOperation(id=rule.id)) - set_xml_value(payload, operations, version=self.account.version) - return payload + def _get_operation(self, rule): + return Operations(delete_rule_operation=DeleteRuleOperation(id=rule.id))

      Ancestors

      -

      Methods

      -
      -
      -def call(self, rule: Rule, remove_outlook_rule_blob: bool = True) -
      -
      -
      -
      - -Expand source code - -
      def call(self, rule: Rule, remove_outlook_rule_blob: bool = True):
      -    payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob)
      -    return self._get_elements(payload=payload)
      -
      -
      -
      -def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True) -
      -
      -
      -
      - -Expand source code - -
      def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True):
      -    if not rule.id:
      -        raise ValueError("Rule must have an ID")
      -    payload = create_element(f"m:{self.SERVICE_NAME}")
      -    add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob)
      -    operations = Operations(delete_rule_operation=DeleteRuleOperation(id=rule.id))
      -    set_xml_value(payload, operations, version=self.account.version)
      -    return payload
      -
      -
      -

      Inherited members

      • UpdateInboxRules: @@ -347,7 +242,6 @@

        Inherited members

        """ SERVICE_NAME = "GetInboxRules" - supported_from = EXCHANGE_2010 element_container_name = InboxRules.response_tag() ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (ErrorInvalidOperation,) @@ -394,10 +288,6 @@

        Class variables

        -
        var supported_from
        -
        -
        -

        Methods

        @@ -465,18 +355,8 @@

        Inherited members

        https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-set-rule-request-example """ - def call(self, rule: Rule, remove_outlook_rule_blob: bool = True): - payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob) - return self._get_elements(payload=payload) - - def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True): - if not rule.id: - raise ValueError("Rule must have an ID") - payload = create_element(f"m:{self.SERVICE_NAME}") - add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob) - operations = Operations(set_rule_operation=SetRuleOperation(rule=rule)) - set_xml_value(payload, operations, version=self.account.version) - return payload + def _get_operation(self, rule): + return Operations(set_rule_operation=SetRuleOperation(rule=rule))

        Ancestors

        -

        Methods

        -
        -
        -def call(self, rule: Rule, remove_outlook_rule_blob: bool = True) -
        -
        -
        -
        - -Expand source code - -
        def call(self, rule: Rule, remove_outlook_rule_blob: bool = True):
        -    payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob)
        -    return self._get_elements(payload=payload)
        -
        -
        -
        -def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True) -
        -
        -
        -
        - -Expand source code - -
        def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True):
        -    if not rule.id:
        -        raise ValueError("Rule must have an ID")
        -    payload = create_element(f"m:{self.SERVICE_NAME}")
        -    add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob)
        -    operations = Operations(set_rule_operation=SetRuleOperation(rule=rule))
        -    set_xml_value(payload, operations, version=self.account.version)
        -    return payload
        -
        -
        -

        Inherited members

        • UpdateInboxRules: @@ -569,8 +413,21 @@

          Inherited members

          """ SERVICE_NAME = "UpdateInboxRules" - supported_from = EXCHANGE_2010 - ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (ErrorInvalidOperation,) + ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (ErrorInvalidOperation,) + + def call(self, rule: Rule, remove_outlook_rule_blob: bool = True): + payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob) + return self._get_elements(payload=payload) + + def _get_operation(self, rule): + raise NotImplementedError() + + def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True): + payload = create_element(f"m:{self.SERVICE_NAME}") + add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob) + operations = self._get_operation(rule) + set_xml_value(payload, operations, version=self.account.version) + return payload

          Ancestors

            @@ -594,9 +451,39 @@

            Class variables

            -
            var supported_from
            +
        +

        Methods

        +
        +
        +def call(self, rule: Rule, remove_outlook_rule_blob: bool = True) +
        +
        +
        +
        + +Expand source code + +
        def call(self, rule: Rule, remove_outlook_rule_blob: bool = True):
        +    payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob)
        +    return self._get_elements(payload=payload)
        +
        +
        +
        +def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True) +
        +
        + +Expand source code + +
        def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True):
        +    payload = create_element(f"m:{self.SERVICE_NAME}")
        +    add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob)
        +    operations = self._get_operation(rule)
        +    set_xml_value(payload, operations, version=self.account.version)
        +    return payload
        +

        Inherited members

        @@ -631,17 +518,9 @@

        Index

      • SetInboxRule

        -
      • UpdateInboxRules

      diff --git a/docs/exchangelib/services/index.html b/docs/exchangelib/services/index.html index 092b6f46..53555cf9 100644 --- a/docs/exchangelib/services/index.html +++ b/docs/exchangelib/services/index.html @@ -908,16 +908,8 @@

      Inherited members

      https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-create-rule-request-example """ - def call(self, rule: Rule, remove_outlook_rule_blob: bool = True): - payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob) - return self._get_elements(payload=payload) - - def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True): - payload = create_element(f"m:{self.SERVICE_NAME}") - add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob) - operations = Operations(create_rule_operation=CreateRuleOperation(rule=rule)) - set_xml_value(payload, operations, version=self.account.version) - return payload + def _get_operation(self, rule): + return Operations(create_rule_operation=CreateRuleOperation(rule=rule))

      Ancestors

      -

      Methods

      -
      -
      -def call(self, rule: Rule, remove_outlook_rule_blob: bool = True) -
      -
      -
      -
      - -Expand source code - -
      def call(self, rule: Rule, remove_outlook_rule_blob: bool = True):
      -    payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob)
      -    return self._get_elements(payload=payload)
      -
      -
      -
      -def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True) -
      -
      -
      -
      - -Expand source code - -
      def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True):
      -    payload = create_element(f"m:{self.SERVICE_NAME}")
      -    add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob)
      -    operations = Operations(create_rule_operation=CreateRuleOperation(rule=rule))
      -    set_xml_value(payload, operations, version=self.account.version)
      -    return payload
      -
      -
      -

      Inherited members

      • UpdateInboxRules: @@ -1490,18 +1448,8 @@

        Inherited members

        https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-delete-rule-request-example """ - def call(self, rule: Rule, remove_outlook_rule_blob: bool = True): - payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob) - return self._get_elements(payload=payload) - - def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True): - if not rule.id: - raise ValueError("Rule must have an ID") - payload = create_element(f"m:{self.SERVICE_NAME}") - add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob) - operations = Operations(delete_rule_operation=DeleteRuleOperation(id=rule.id)) - set_xml_value(payload, operations, version=self.account.version) - return payload + def _get_operation(self, rule): + return Operations(delete_rule_operation=DeleteRuleOperation(id=rule.id))

        Ancestors

        -

        Methods

        -
        -
        -def call(self, rule: Rule, remove_outlook_rule_blob: bool = True) -
        -
        -
        -
        - -Expand source code - -
        def call(self, rule: Rule, remove_outlook_rule_blob: bool = True):
        -    payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob)
        -    return self._get_elements(payload=payload)
        -
        -
        -
        -def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True) -
        -
        -
        -
        - -Expand source code - -
        def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True):
        -    if not rule.id:
        -        raise ValueError("Rule must have an ID")
        -    payload = create_element(f"m:{self.SERVICE_NAME}")
        -    add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob)
        -    operations = Operations(delete_rule_operation=DeleteRuleOperation(id=rule.id))
        -    set_xml_value(payload, operations, version=self.account.version)
        -    return payload
        -
        -
        -

        Inherited members

        • UpdateInboxRules: @@ -4154,9 +4066,11 @@

          Inherited members

          SERVICE_NAME = "GetFolder" element_container_name = f"{{{MNS}}}Folders" ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + ( + ErrorAccessDenied, ErrorFolderNotFound, ErrorNoPublicFolderReplicaAvailable, ErrorInvalidOperation, + ErrorItemNotFound, ) def __init__(self, *args, **kwargs): @@ -4313,7 +4227,6 @@

          Inherited members

          """ SERVICE_NAME = "GetInboxRules" - supported_from = EXCHANGE_2010 element_container_name = InboxRules.response_tag() ERRORS_TO_CATCH_IN_RESPONSE = EWSAccountService.ERRORS_TO_CATCH_IN_RESPONSE + (ErrorInvalidOperation,) @@ -4360,10 +4273,6 @@

          Class variables

          -
          var supported_from
          -
          -
          -

          Methods

          @@ -6688,18 +6597,8 @@

          Inherited members

          https://learn.microsoft.com/en-us/exchange/client-developer/web-service-reference/updateinboxrules-operation#updateinboxrules-set-rule-request-example """ - def call(self, rule: Rule, remove_outlook_rule_blob: bool = True): - payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob) - return self._get_elements(payload=payload) - - def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True): - if not rule.id: - raise ValueError("Rule must have an ID") - payload = create_element(f"m:{self.SERVICE_NAME}") - add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob) - operations = Operations(set_rule_operation=SetRuleOperation(rule=rule)) - set_xml_value(payload, operations, version=self.account.version) - return payload + def _get_operation(self, rule): + return Operations(set_rule_operation=SetRuleOperation(rule=rule))

          Ancestors

          -

          Methods

          -
          -
          -def call(self, rule: Rule, remove_outlook_rule_blob: bool = True) -
          -
          -
          -
          - -Expand source code - -
          def call(self, rule: Rule, remove_outlook_rule_blob: bool = True):
          -    payload = self.get_payload(rule=rule, remove_outlook_rule_blob=remove_outlook_rule_blob)
          -    return self._get_elements(payload=payload)
          -
          -
          -
          -def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True) -
          -
          -
          -
          - -Expand source code - -
          def get_payload(self, rule: Rule, remove_outlook_rule_blob: bool = True):
          -    if not rule.id:
          -        raise ValueError("Rule must have an ID")
          -    payload = create_element(f"m:{self.SERVICE_NAME}")
          -    add_xml_child(payload, "m:RemoveOutlookRuleBlob", remove_outlook_rule_blob)
          -    operations = Operations(set_rule_operation=SetRuleOperation(rule=rule))
          -    set_xml_value(payload, operations, version=self.account.version)
          -    return payload
          -
          -
          -

          Inherited members

        • @@ -8665,10 +8519,6 @@

          SetInboxRule

          -
        • SetUserOofSettings

          From cc45183289d125669c65890b794bc7dc51debb30 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 11 Apr 2024 20:54:55 +0200 Subject: [PATCH 422/509] fix: improve error messages for inbox rule validation errors --- exchangelib/services/common.py | 12 ++++++-- exchangelib/services/get_user_settings.py | 2 +- tests/test_account.py | 34 ++++++++++++++++++++--- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 405f3501..acd0e571 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -590,6 +590,7 @@ def _get_element_container(self, message, name=None): # Raise any non-acceptable errors in the container, or return the container or the acceptable exception instance msg_text = get_xml_attr(message, f"{{{MNS}}}MessageText") msg_xml = message.find(f"{{{MNS}}}MessageXml") + rule_errors = message.find(f"{{{MNS}}}RuleOperationErrors") if response_class == "Warning": try: raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml) @@ -603,12 +604,12 @@ def _get_element_container(self, message, name=None): return container # response_class == 'Error', or 'Success' and not 'NoError' try: - raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml) + raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml, rule_errors=rule_errors) except self.ERRORS_TO_CATCH_IN_RESPONSE as e: return e @staticmethod - def _get_exception(code, text, msg_xml): + def _get_exception(code, text, msg_xml=None, rule_errors=None): """Parse error messages contained in EWS responses and raise as exceptions defined in this package.""" if not code: return TransportError(f"Empty ResponseCode in ResponseMessage (MessageText: {text}, MessageXml: {msg_xml})") @@ -646,6 +647,13 @@ def _get_exception(code, text, msg_xml): except KeyError: # Inner code is unknown to us. Just append to the original text text += f" (inner error: {inner_code}({inner_text!r}))" + if rule_errors is not None: + for rule_error in rule_errors.findall(f"{{{TNS}}}RuleOperationError"): + for error in rule_error.find(f"{{{TNS}}}ValidationErrors").findall(f"{{{TNS}}}Error"): + field_uri = get_xml_attr(error, f"{{{TNS}}}FieldURI") + error_code = get_xml_attr(error, f"{{{TNS}}}ErrorCode") + error_message = get_xml_attr(error, f"{{{TNS}}}ErrorMessage") + text += f" ({error_code} on field {field_uri}: {error_message})" try: # Raise the error corresponding to the ResponseCode return vars(errors)[code](text) diff --git a/exchangelib/services/get_user_settings.py b/exchangelib/services/get_user_settings.py index 3ffe64ad..272bf67d 100644 --- a/exchangelib/services/get_user_settings.py +++ b/exchangelib/services/get_user_settings.py @@ -86,6 +86,6 @@ def _get_element_container(self, message, name=None): return container # Raise any non-acceptable errors in the container, or return the acceptable exception instance try: - raise self._get_exception(code=res.error_code, text=res.error_message, msg_xml=None) + raise self._get_exception(code=res.error_code, text=res.error_message) except self.ERRORS_TO_CATCH_IN_RESPONSE as e: return e diff --git a/tests/test_account.py b/tests/test_account.py index 2b513eb5..3d0b3c99 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -10,6 +10,7 @@ ErrorAccessDenied, ErrorDelegateNoUser, ErrorFolderNotFound, + ErrorInboxRulesValidationError, ErrorInvalidUserSid, ErrorNotDelegate, UnauthorizedError, @@ -384,10 +385,6 @@ def test_all_inbox_rule_actions(self): "move_to_folder": MoveToFolder(distinguished_folder_id=self.account.trash.to_id()), "permanent_delete": True, # Cannot be random. False would be a no-op action "redirect_to_recipients": [Address(email_address=get_random_email())], - # TODO: Throws "UnsupportedRule: The operation on this unsupported rule is not allowed." - # "send_sms_alert_to_recipients": [Address(email_address=get_random_email())], - # TODO: throws "InvalidValue: Id must be non-empty." even though we follow MSDN docs - # "server_reply_with_message": Message(folder=self.account.inbox, subject="Foo").save().to_id(), "stop_processing_rules": True, # Cannot be random. False would be a no-op action }.items(): with self.subTest(action_name=action_name, action=action): @@ -398,3 +395,32 @@ def test_all_inbox_rule_actions(self): actions=Actions(**{action_name: action}), ).save() rule.delete() + + # TODO: Throws "UnsupportedRule: The operation on this unsupported rule is not allowed." + with self.assertRaises(ErrorInboxRulesValidationError) as e: + Rule( + account=self.account, + display_name=get_random_string(16), + priority=get_random_int(), + actions=Actions(send_sms_alert_to_recipients=[Address(email_address=get_random_email())]), + ).save() + self.assertEqual( + e.exception.args[0], + "A validation error occurred while executing the rule operation. (UnsupportedRule on field " + "Action:SendSMSAlertToRecipients: The operation on this unsupported rule is not allowed.)", + ) + # TODO: throws "InvalidValue: Id must be non-empty." even though we follow MSDN docs + with self.assertRaises(ErrorInboxRulesValidationError) as e: + Rule( + account=self.account, + display_name=get_random_string(16), + priority=get_random_int(), + actions=Actions( + server_reply_with_message=Message(folder=self.account.inbox, subject="Foo").save().to_id() + ), + ).save() + self.assertEqual( + e.exception.args[0], + "A validation error occurred while executing the rule operation. (InvalidValue on field " + "Action:ServerReplyWithMessage: Id must be non-empty.)", + ) From 17bc8d6665542bd5dfc5738ab0544a51b3eae86a Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 11 Apr 2024 21:10:35 +0200 Subject: [PATCH 423/509] chore: deduplicate code --- exchangelib/folders/base.py | 38 +++++++++++++++++++----------------- exchangelib/folders/roots.py | 19 ------------------ 2 files changed, 20 insertions(+), 37 deletions(-) diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 83d8307a..e7b3904d 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -213,6 +213,26 @@ def tree(self): tree += f" {node}\n" return tree.strip() + @classmethod + def get_distinguished(cls, account): + """Get the distinguished folder for this folder class. + + :param account: + :return: + """ + if not cls.DISTINGUISHED_FOLDER_ID: + raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") + try: + return cls.resolve( + account=account, + folder=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=account.primary_smtp_address), + ), + ) + except MISSING_FOLDER_ERRORS as e: + raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r} ({e})") + @property def has_distinguished_name(self): return self.name and self.DISTINGUISHED_FOLDER_ID and self.name.lower() == self.DISTINGUISHED_FOLDER_ID.lower() @@ -856,24 +876,6 @@ def deregister(cls, *args, **kwargs): raise TypeError("For folders, custom fields must be registered on the Folder class") return super().deregister(*args, **kwargs) - @classmethod - def get_distinguished(cls, account): - """Get the distinguished folder for this folder class. - - :param account: - :return: - """ - try: - return cls.resolve( - account=account, - folder=DistinguishedFolderId( - id=cls.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=account.primary_smtp_address), - ), - ) - except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") - @property def parent(self): if not self.parent_folder_id: diff --git a/exchangelib/folders/roots.py b/exchangelib/folders/roots.py index 4b6d4227..6b6d8ea8 100644 --- a/exchangelib/folders/roots.py +++ b/exchangelib/folders/roots.py @@ -101,25 +101,6 @@ def get_children(self, folder): if f.parent.id == folder.id: yield f - @classmethod - def get_distinguished(cls, account): - """Get the distinguished folder for this folder class. - - :param account: - """ - if not cls.DISTINGUISHED_FOLDER_ID: - raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") - try: - return cls.resolve( - account=account, - folder=DistinguishedFolderId( - id=cls.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=account.primary_smtp_address), - ), - ) - except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") - def get_default_folder(self, folder_cls): """Return the distinguished folder instance of type folder_cls belonging to this account. If no distinguished folder was found, try as best we can to return the default folder of type 'folder_cls' From f65079f372a35d09a3d4b3e929ecc53bd60b6fc1 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 16 Apr 2024 20:04:44 +0200 Subject: [PATCH 424/509] fix: Require folders requested via a service to define its root. Refs #1267 --- exchangelib/folders/base.py | 32 ++++++---- exchangelib/folders/queryset.py | 14 ++++- exchangelib/folders/roots.py | 30 +++++++-- exchangelib/services/common.py | 41 +++--------- exchangelib/services/create_folder.py | 2 +- exchangelib/services/get_folder.py | 2 +- exchangelib/services/sync_folder_hierarchy.py | 2 +- exchangelib/services/update_folder.py | 2 +- tests/test_account.py | 4 +- tests/test_folder.py | 62 +++++++------------ 10 files changed, 92 insertions(+), 99 deletions(-) diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index e7b3904d..c89e7206 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -214,22 +214,11 @@ def tree(self): return tree.strip() @classmethod - def get_distinguished(cls, account): - """Get the distinguished folder for this folder class. - - :param account: - :return: - """ + def _get_distinguished(cls, folder): if not cls.DISTINGUISHED_FOLDER_ID: raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") try: - return cls.resolve( - account=account, - folder=DistinguishedFolderId( - id=cls.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=account.primary_smtp_address), - ), - ) + return cls.resolve(account=folder.account, folder=folder) except MISSING_FOLDER_ERRORS as e: raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r} ({e})") @@ -902,6 +891,23 @@ def clean(self, version=None): if self.root and not isinstance(self.root, RootOfHierarchy): raise InvalidTypeError("root", self.root, RootOfHierarchy) + @classmethod + def get_distinguished(cls, root): + """Get the distinguished folder for this folder class. + + :param root: + :return: + """ + return cls._get_distinguished( + folder=cls( + _distinguished_id=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=root.account.primary_smtp_address), + ), + root=root, + ) + ) + @classmethod def from_xml_with_root(cls, elem, root): folder = cls.from_xml(elem=elem, account=root.account) diff --git a/exchangelib/folders/queryset.py b/exchangelib/folders/queryset.py index 9593e1e5..c57a14b0 100644 --- a/exchangelib/folders/queryset.py +++ b/exchangelib/folders/queryset.py @@ -74,11 +74,23 @@ def get(self, *args, **kwargs): """Return the query result as exactly one item. Raises DoesNotExist if there are no results, and MultipleObjectsReturned if there are multiple results. """ + from .base import Folder from .collections import FolderCollection if not args and set(kwargs) in ({"id"}, {"id", "changekey"}): + roots = {f.root for f in self.folder_collection.folders} + if len(roots) != 1: + raise ValueError(f"All folders must have the same root hierarchy ({roots})") folders = list( - FolderCollection(account=self.folder_collection.account, folders=[FolderId(**kwargs)]).resolve() + FolderCollection( + account=self.folder_collection.account, + folders=[ + Folder( + _id=FolderId(**kwargs), + root=roots.pop(), + ) + ], + ).resolve() ) elif args or kwargs: folders = list(self.filter(*args, **kwargs)) diff --git a/exchangelib/folders/roots.py b/exchangelib/folders/roots.py index 6b6d8ea8..a467bf25 100644 --- a/exchangelib/folders/roots.py +++ b/exchangelib/folders/roots.py @@ -117,16 +117,16 @@ def get_default_folder(self, folder_cls): return f try: log.debug("Requesting distinguished %s folder explicitly", folder_cls) - return folder_cls.get_distinguished(account=self.account) + return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) fld = folder_cls( - root=self, _distinguished_id=DistinguishedFolderId( id=folder_cls.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address), ), + root=self, ) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available @@ -135,6 +135,23 @@ def get_default_folder(self, folder_cls): pass raise ErrorFolderNotFound(f"No usable default {folder_cls} folders") + @classmethod + def get_distinguished(cls, account): + """Get the distinguished folder for this folder class. + + :param account: + :return: + """ + return cls._get_distinguished( + folder=cls( + _distinguished_id=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=account.primary_smtp_address), + ), + account=account, + ) + ) + @property def _folders_map(self): if self._subfolders is not None: @@ -145,9 +162,12 @@ def _folders_map(self): # so we are sure to apply the correct Folder class, then fetch all sub-folders of this root. folders_map = {self.id: self} distinguished_folders = [ - DistinguishedFolderId( - id=cls.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=self.account.primary_smtp_address), + cls( + _distinguished_id=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ), + root=self, ) for cls in self.WELLKNOWN_FOLDERS if cls.get_folder_allowed and cls.supports_version(self.account.version) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index acd0e571..29547438 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -38,18 +38,9 @@ SOAPError, TransportError, ) -from ..folders import ArchiveRoot, BaseFolder, Folder, PublicFoldersRoot, Root, RootOfHierarchy +from ..folders import BaseFolder, Folder, RootOfHierarchy from ..items import BaseItem -from ..properties import ( - BaseItemId, - DistinguishedFolderId, - ExceptionFieldURI, - ExtendedFieldURI, - FieldURI, - FolderId, - IndexedFieldURI, - ItemId, -) +from ..properties import BaseItemId, ExceptionFieldURI, ExtendedFieldURI, FieldURI, FolderId, IndexedFieldURI, ItemId from ..transport import DEFAULT_ENCODING from ..util import ( ENS, @@ -978,27 +969,9 @@ def attachment_ids_element(items, version, tag="m:AttachmentIds"): return _ids_element(items, AttachmentId, version, tag) -def parse_folder_elem(elem, folder, account): +def parse_folder_elem(elem, folder): if isinstance(folder, RootOfHierarchy): - f = folder.from_xml(elem=elem, account=folder.account) - elif isinstance(folder, Folder): - f = folder.from_xml_with_root(elem=elem, root=folder.root) - elif isinstance(folder, DistinguishedFolderId): - # We don't know the root or even account, but we need to attach the folder to something if we want to make - # future requests with this folder. Use 'account' but make sure to always use the distinguished folder ID going - # forward, instead of referencing anything connected to 'account'. - roots = (Root, ArchiveRoot, PublicFoldersRoot) - for cls in roots + tuple(chain(*(r.WELLKNOWN_FOLDERS for r in roots))): - if cls.DISTINGUISHED_FOLDER_ID == folder.id: - folder_cls = cls - break - else: - raise ValueError(f"Unknown distinguished folder ID: {folder.id}") - if folder_cls in roots: - f = folder_cls.from_xml(elem=elem, account=account) - else: - f = folder_cls.from_xml_with_root(elem=elem, root=account.root) - else: - # 'folder' is a generic FolderId instance. We don't know the root so assume account.root. - f = Folder.from_xml_with_root(elem=elem, root=account.root) - return f + return folder.from_xml(elem=elem, account=folder.account) + if isinstance(folder, Folder): + return folder.from_xml_with_root(elem=elem, root=folder.root) + raise ValueError(f"Unsupported folder class: {folder}") diff --git a/exchangelib/services/create_folder.py b/exchangelib/services/create_folder.py index a340be74..86c8c5f1 100644 --- a/exchangelib/services/create_folder.py +++ b/exchangelib/services/create_folder.py @@ -31,7 +31,7 @@ def _elems_to_objs(self, elems): if isinstance(elem, Exception): yield elem continue - yield parse_folder_elem(elem=elem, folder=folder, account=self.account) + yield parse_folder_elem(elem=elem, folder=folder) def get_payload(self, folders, parent_folder): payload = create_element(f"m:{self.SERVICE_NAME}") diff --git a/exchangelib/services/get_folder.py b/exchangelib/services/get_folder.py index e4496df2..54c9c125 100644 --- a/exchangelib/services/get_folder.py +++ b/exchangelib/services/get_folder.py @@ -52,7 +52,7 @@ def _elems_to_objs(self, elems): if isinstance(elem, Exception): yield elem continue - yield parse_folder_elem(elem=elem, folder=folder, account=self.account) + yield parse_folder_elem(elem=elem, folder=folder) def get_payload(self, folders, additional_fields, shape): payload = create_element(f"m:{self.SERVICE_NAME}") diff --git a/exchangelib/services/sync_folder_hierarchy.py b/exchangelib/services/sync_folder_hierarchy.py index 81dcae68..d38f8699 100644 --- a/exchangelib/services/sync_folder_hierarchy.py +++ b/exchangelib/services/sync_folder_hierarchy.py @@ -86,7 +86,7 @@ def _elem_to_obj(self, elem): # We can't find() the element because we don't know which tag to look for. The change element can # contain multiple folder types, each with their own tag. folder_elem = elem[0] - folder = parse_folder_elem(elem=folder_elem, folder=self.folder, account=self.account) + folder = parse_folder_elem(elem=folder_elem, folder=self.folder) return change_type, folder def get_payload(self, folder, shape, additional_fields, sync_state): diff --git a/exchangelib/services/update_folder.py b/exchangelib/services/update_folder.py index a3361811..996d7abc 100644 --- a/exchangelib/services/update_folder.py +++ b/exchangelib/services/update_folder.py @@ -144,7 +144,7 @@ def _elems_to_objs(self, elems): if isinstance(elem, Exception): yield elem continue - yield parse_folder_elem(elem=elem, folder=folder, account=self.account) + yield parse_folder_elem(elem=elem, folder=folder) @staticmethod def _target_elem(target): diff --git a/tests/test_account.py b/tests/test_account.py index 3d0b3c99..21e18e7c 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -118,7 +118,7 @@ def test_get_default_folder(self): class MockCalendar1(Calendar): @classmethod - def get_distinguished(cls, account): + def get_distinguished(cls, root): raise ErrorAccessDenied("foo") # Test an indirect folder lookup with FindItem, when we're not allowed to do a GetFolder. We don't get the @@ -132,7 +132,7 @@ def get_distinguished(cls, account): class MockCalendar2(Calendar): @classmethod - def get_distinguished(cls, account): + def get_distinguished(cls, root): raise ErrorFolderNotFound("foo") # Test using the one folder of this folder type diff --git a/tests/test_folder.py b/tests/test_folder.py index 4dd73e33..0d894087 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -282,9 +282,10 @@ def test_public_folders_root(self, m): ], ) # Test top-level .children - self.assertListEqual( - [f.name for f in self.account.public_folders_root.children], ["Sample Contacts", "Sample Folder"] - ) + children = list(self.account.public_folders_root.children) + self.assertListEqual([f.name for f in children], ["Sample Contacts", "Sample Folder"]) + for f in children: + self.assertIsInstance(f.root, PublicFoldersRoot) find_public_subfolder1_children_xml = b"""\ @@ -379,11 +380,12 @@ def test_public_folders_root(self, m): # Test .get_children() on subfolders f_1 = self.account.public_folders_root / "Sample Contacts" f_2 = self.account.public_folders_root / "Sample Folder" - self.assertListEqual( - [f.name for f in self.account.public_folders_root.get_children(f_1)], - ["Sample Subfolder1", "Sample Subfolder2"], - ) - self.assertListEqual([f.name for f in self.account.public_folders_root.get_children(f_2)], []) + f_1_children = list(self.account.public_folders_root.get_children(f_1)) + self.assertListEqual([f.name for f in f_1_children], ["Sample Subfolder1", "Sample Subfolder2"]) + for f in f_1_children: + self.assertIsInstance(f.root, PublicFoldersRoot) + f_2_children = list(self.account.public_folders_root.get_children(f_2)) + self.assertListEqual([f.name for f in f_2_children], []) def test_invalid_deletefolder_args(self): with self.assertRaises(ValueError) as e: @@ -481,39 +483,19 @@ def test_get_folders(self): folders = list(FolderCollection(account=self.account, folders=[self.account.root]).get_folders()) self.assertEqual(len(folders), 1, sorted(f.name for f in folders)) - # Test that GetFolder can handle FolderId instances - folders = list( - FolderCollection( - account=self.account, - folders=[ - DistinguishedFolderId( - id=Inbox.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=self.account.primary_smtp_address), - ) - ], - ).get_folders() - ) - self.assertEqual(len(folders), 1, sorted(f.name for f in folders)) - - def test_get_folders_with_distinguished_id(self): - # Test that we return an Inbox instance and not a generic Messages or Folder instance when we call GetFolder - # with a DistinguishedFolderId instance with an ID of Inbox.DISTINGUISHED_FOLDER_ID. - inbox_folder_id = DistinguishedFolderId( - id=Inbox.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=self.account.primary_smtp_address), - ) - inbox = list( - GetFolder(account=self.account).call( - folders=[inbox_folder_id], - shape="IdOnly", - additional_fields=[], + # Test that GetFolder cannot handle FolderId instances + with self.assertRaises(ValueError): + list( + FolderCollection( + account=self.account, + folders=[ + DistinguishedFolderId( + id=Inbox.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ) + ], + ).get_folders() ) - )[0] - self.assertIsInstance(inbox, Inbox) - - # Test via SingleFolderQuerySet - inbox = SingleFolderQuerySet(account=self.account, folder=inbox_folder_id).resolve() - self.assertIsInstance(inbox, Inbox) def test_folder_grouping(self): # If you get errors here, you probably need to fill out [folder class].LOCALIZED_NAMES for your locale. From 56270b9056873f30b6fd5c3362a96f75e151da9b Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 23 Apr 2024 19:41:30 +0200 Subject: [PATCH 425/509] fix: no need for a setter. Consequences of switching root are not handled anyway --- exchangelib/folders/base.py | 8 ++------ tests/test_folder.py | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index c89e7206..391801c5 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -833,7 +833,7 @@ def __init__(self, **kwargs): if parent.root != self.root: raise ValueError("'parent.root' must match 'root'") else: - self.root = parent.root + self._root = parent.root if "parent_folder_id" in kwargs and parent.id != kwargs["parent_folder_id"]: raise ValueError("'parent_folder_id' must match 'parent' ID") kwargs["parent_folder_id"] = ParentFolderId(id=parent.id, changekey=parent.changekey) @@ -849,10 +849,6 @@ def account(self): def root(self): return self._root - @root.setter - def root(self, value): - self._root = value - @classmethod def register(cls, *args, **kwargs): if cls is not Folder: @@ -881,7 +877,7 @@ def parent(self, value): else: if not isinstance(value, BaseFolder): raise InvalidTypeError("value", value, BaseFolder) - self.root = value.root + self._root = value.root self.parent_folder_id = ParentFolderId(id=value.id, changekey=value.changekey) def clean(self, version=None): diff --git a/tests/test_folder.py b/tests/test_folder.py index 0d894087..a6fc7ce5 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -709,7 +709,7 @@ def test_refresh(self): folder = Folder() with self.assertRaises(ValueError): folder.refresh() # Must have root folder - folder.root = self.account.root + folder._root = self.account.root with self.assertRaises(ValueError): folder.refresh() # Must have an id From d9035d03960797f9277381845e4ec98f837798ea Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 23 Apr 2024 19:59:00 +0200 Subject: [PATCH 426/509] fix: Ensure that all other distinguished folders than PublicFoldersRoot specify their account mailbox --- exchangelib/folders/base.py | 2 ++ exchangelib/folders/roots.py | 5 +++++ exchangelib/properties.py | 2 ++ 3 files changed, 9 insertions(+) diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 391801c5..703c47de 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -94,6 +94,8 @@ def __init__(self, **kwargs): self.item_sync_state = kwargs.pop("item_sync_state", None) self.folder_sync_state = kwargs.pop("folder_sync_state", None) super().__init__(**kwargs) + if self._distinguished_id and self.account: + self._distinguished_id.mailbox = Mailbox(email_address=self.account.primary_smtp_address) @property @abc.abstractmethod diff --git a/exchangelib/folders/roots.py b/exchangelib/folders/roots.py index a467bf25..2de25b74 100644 --- a/exchangelib/folders/roots.py +++ b/exchangelib/folders/roots.py @@ -327,6 +327,11 @@ class PublicFoldersRoot(RootOfHierarchy): DEFAULT_FOLDER_TRAVERSAL_DEPTH = SHALLOW supported_from = EXCHANGE_2007_SP1 + def __init__(self, **kwargs): + super().__init__(**kwargs) + if self._distinguished_id: + self._distinguished_id.mailbox = None # See DistinguishedFolderId.clean() + def get_children(self, folder): # EWS does not allow deep traversal of public folders, so self._folders_map will only populate the top-level # subfolders. To traverse public folders at arbitrary depth, we need to get child folders on demand. diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 344b6a27..ab3e16b2 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -762,6 +762,8 @@ def clean(self, version=None): if self.id == PublicFoldersRoot.DISTINGUISHED_FOLDER_ID: # Avoid "ErrorInvalidOperation: It is not valid to specify a mailbox with the public folder root" from EWS self.mailbox = None + elif not self.mailbox: + raise ValueError(f"DistinguishedFolderId {self.id} must have a mailbox") class TimeWindow(EWSElement): From 661b8096692d84f4ba3fc573ed35cb40fa634702 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 23 Apr 2024 23:48:20 +0200 Subject: [PATCH 427/509] chore: Move common code up --- exchangelib/fields.py | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index c5dcb912..4a93f8aa 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -1330,6 +1330,9 @@ def response_tag(self): class IndexedField(EWSElementField, metaclass=abc.ABCMeta): """A base class for all indexed fields.""" + is_list = True + is_complex = True + PARENT_ELEMENT_NAME = None def __init__(self, *args, **kwargs): @@ -1346,41 +1349,36 @@ def to_xml(self, value, version): def response_tag(self): return f"{{{self.namespace}}}{self.PARENT_ELEMENT_NAME}" - def __hash__(self): - return hash(self.field_uri) - - -class EmailAddressesField(IndexedField): - is_list = True - is_complex = True - - PARENT_ELEMENT_NAME = "EmailAddresses" - - def __init__(self, *args, **kwargs): - from .indexed_properties import EmailAddress - - kwargs["value_cls"] = EmailAddress - super().__init__(*args, **kwargs) - def clean(self, value, version=None): if value is not None: default_labels = self.value_cls.LABEL_CHOICES if len(value) > len(default_labels): raise ValueError(f"This field can handle at most {len(default_labels)} values (value: {value})") tmp = [] + value_cls_fields = [f.name for f in self.value_cls.FIELDS] for s, default_label in zip(value, default_labels): if not isinstance(s, str): tmp.append(s) continue - tmp.append(self.value_cls(email=s, label=default_label)) + tmp.append(self.value_cls(**dict(zip(value_cls_fields, (default_label, s))))) value = tmp return super().clean(value, version=version) + def __hash__(self): + return hash(self.field_uri) -class PhoneNumberField(IndexedField): - is_list = True - is_complex = True +class EmailAddressesField(IndexedField): + PARENT_ELEMENT_NAME = "EmailAddresses" + + def __init__(self, *args, **kwargs): + from .indexed_properties import EmailAddress + + kwargs["value_cls"] = EmailAddress + super().__init__(*args, **kwargs) + + +class PhoneNumberField(IndexedField): PARENT_ELEMENT_NAME = "PhoneNumbers" def __init__(self, *args, **kwargs): From 6330c4ced303d638ac186d26d742daa4c1247815 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 23 Apr 2024 23:49:54 +0200 Subject: [PATCH 428/509] chore: label is directly settable --- tests/common.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/tests/common.py b/tests/common.py index cde04c3e..a8ac7181 100644 --- a/tests/common.py +++ b/tests/common.py @@ -245,31 +245,28 @@ def random_val(self, field): if isinstance(field, EmailAddressesField): addrs = [] for label in EmailAddress.get_field_by_fieldname("label").supported_choices(version=self.account.version): - addr = EmailAddress(email=get_random_email()) - addr.label = label - addrs.append(addr) + addrs.append(EmailAddress(email=get_random_email(), label=label)) return addrs if isinstance(field, PhysicalAddressField): addrs = [] for label in PhysicalAddress.get_field_by_fieldname("label").supported_choices( version=self.account.version ): - addr = PhysicalAddress( - street=get_random_string(32), - city=get_random_string(32), - state=get_random_string(32), - country=get_random_string(32), - zipcode=get_random_string(8), + addrs.append( + PhysicalAddress( + street=get_random_string(32), + city=get_random_string(32), + state=get_random_string(32), + country=get_random_string(32), + zipcode=get_random_string(8), + label=label, + ) ) - addr.label = label - addrs.append(addr) return addrs if isinstance(field, PhoneNumberField): pns = [] for label in PhoneNumber.get_field_by_fieldname("label").supported_choices(version=self.account.version): - pn = PhoneNumber(phone_number=get_random_string(16)) - pn.label = label - pns.append(pn) + pns.append(PhoneNumber(phone_number=get_random_string(16), label=label)) return pns if isinstance(field, EWSElementField): if field.value_cls == Recurrence: From 54390d06a17b184e36cd709eb3d6fb068cd3d8ec Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 24 Apr 2024 00:04:50 +0200 Subject: [PATCH 429/509] feat: make Contact.im_addresses writable --- exchangelib/fields.py | 10 ++++++++++ exchangelib/indexed_properties.py | 10 ++++++++++ exchangelib/items/contact.py | 3 ++- exchangelib/util.py | 4 +++- tests/common.py | 8 +++++++- 5 files changed, 32 insertions(+), 3 deletions(-) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index 4a93f8aa..9d26ef85 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -1378,6 +1378,16 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) +class ImAddressField(IndexedField): + PARENT_ELEMENT_NAME = "ImAddresses" + + def __init__(self, *args, **kwargs): + from .indexed_properties import ImAddress + + kwargs["value_cls"] = ImAddress + super().__init__(*args, **kwargs) + + class PhoneNumberField(IndexedField): PARENT_ELEMENT_NAME = "PhoneNumbers" diff --git a/exchangelib/indexed_properties.py b/exchangelib/indexed_properties.py index 503c09d1..122d34a0 100644 --- a/exchangelib/indexed_properties.py +++ b/exchangelib/indexed_properties.py @@ -33,6 +33,16 @@ class EmailAddress(SingleFieldIndexedElement): email = EmailSubField(is_required=True) +class ImAddress(SingleFieldIndexedElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/entry-imaddress""" + + ELEMENT_NAME = "Entry" + LABEL_CHOICES = ("ImAddress1", "ImAddress2", "ImAddress3") + + label = LabelField(field_uri="Key", choices={Choice(c) for c in LABEL_CHOICES}, default=LABEL_CHOICES[0]) + im_address = SubField(is_required=True) + + class PhoneNumber(SingleFieldIndexedElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/entry-phonenumber""" diff --git a/exchangelib/items/contact.py b/exchangelib/items/contact.py index 5a182f1e..c53bb215 100644 --- a/exchangelib/items/contact.py +++ b/exchangelib/items/contact.py @@ -16,6 +16,7 @@ EWSElementField, EWSElementListField, IdElementField, + ImAddressField, MailboxField, MailboxListField, MemberListField, @@ -86,7 +87,7 @@ class Contact(Item): ) department = TextField(field_uri="contacts:Department") generation = TextField(field_uri="contacts:Generation") - im_addresses = CharField(field_uri="contacts:ImAddresses", is_read_only=True) + im_addresses = ImAddressField(field_uri="contacts:ImAddress") job_title = TextField(field_uri="contacts:JobTitle") manager = TextField(field_uri="contacts:Manager") mileage = TextField(field_uri="contacts:Mileage") diff --git a/exchangelib/util.py b/exchangelib/util.py index 2abec794..20d09bdf 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -184,7 +184,7 @@ def get_xml_attrs(tree, name): def value_to_xml_text(value): from .ewsdatetime import EWSDate, EWSDateTime, EWSTimeZone - from .indexed_properties import EmailAddress, PhoneNumber + from .indexed_properties import EmailAddress, ImAddress, PhoneNumber from .properties import AssociatedCalendarItemId, Attendee, ConversationId, Mailbox # We can't just create a map and look up with type(value) because we want to support subtypes @@ -208,6 +208,8 @@ def value_to_xml_text(value): return value.phone_number if isinstance(value, EmailAddress): return value.email + if isinstance(value, ImAddress): + return value.im_address if isinstance(value, Mailbox): return value.email_address if isinstance(value, Attendee): diff --git a/tests/common.py b/tests/common.py index a8ac7181..d1c0be05 100644 --- a/tests/common.py +++ b/tests/common.py @@ -40,6 +40,7 @@ EmailAddressField, EWSElementField, ExtendedPropertyField, + ImAddressField, IntegerField, MailboxField, MailboxListField, @@ -52,7 +53,7 @@ TimeZoneField, URIField, ) -from exchangelib.indexed_properties import EmailAddress, PhoneNumber, PhysicalAddress +from exchangelib.indexed_properties import EmailAddress, ImAddress, PhoneNumber, PhysicalAddress from exchangelib.properties import ( Attendee, CompleteName, @@ -247,6 +248,11 @@ def random_val(self, field): for label in EmailAddress.get_field_by_fieldname("label").supported_choices(version=self.account.version): addrs.append(EmailAddress(email=get_random_email(), label=label)) return addrs + if isinstance(field, ImAddressField): + addrs = [] + for label in ImAddress.get_field_by_fieldname("label").supported_choices(version=self.account.version): + addrs.append(ImAddress(im_address=get_random_email(), label=label)) + return addrs if isinstance(field, PhysicalAddressField): addrs = [] for label in PhysicalAddress.get_field_by_fieldname("label").supported_choices( From 5144b885a726f9f06babadb0ce6d013fd284afc6 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 24 Apr 2024 17:27:36 +0200 Subject: [PATCH 430/509] chore: Simplify code --- exchangelib/fields.py | 4 ++-- exchangelib/util.py | 18 +++++------------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index 9d26ef85..8d6f5cc0 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -1355,12 +1355,12 @@ def clean(self, value, version=None): if len(value) > len(default_labels): raise ValueError(f"This field can handle at most {len(default_labels)} values (value: {value})") tmp = [] - value_cls_fields = [f.name for f in self.value_cls.FIELDS] + value_field_name = self.value_cls.value_field(version=version).name for s, default_label in zip(value, default_labels): if not isinstance(s, str): tmp.append(s) continue - tmp.append(self.value_cls(**dict(zip(value_cls_fields, (default_label, s))))) + tmp.append(self.value_cls(**{"label": default_label, value_field_name: s}) value = tmp return super().clean(value, version=version) diff --git a/exchangelib/util.py b/exchangelib/util.py index 20d09bdf..0c22e1fd 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -184,7 +184,7 @@ def get_xml_attrs(tree, name): def value_to_xml_text(value): from .ewsdatetime import EWSDate, EWSDateTime, EWSTimeZone - from .indexed_properties import EmailAddress, ImAddress, PhoneNumber + from .indexed_properties import SingleFieldIndexedElement from .properties import AssociatedCalendarItemId, Attendee, ConversationId, Mailbox # We can't just create a map and look up with type(value) because we want to support subtypes @@ -200,23 +200,15 @@ def value_to_xml_text(value): return value.isoformat() if isinstance(value, EWSTimeZone): return value.ms_id - if isinstance(value, EWSDateTime): + if isinstance(value, (EWSDate, EWSDateTime)): return value.ewsformat() - if isinstance(value, EWSDate): - return value.ewsformat() - if isinstance(value, PhoneNumber): - return value.phone_number - if isinstance(value, EmailAddress): - return value.email - if isinstance(value, ImAddress): - return value.im_address + if isinstance(value, SingleFieldIndexedElement): + return getattr(value, value.value_field(version=None).name) if isinstance(value, Mailbox): return value.email_address if isinstance(value, Attendee): return value.mailbox.email_address - if isinstance(value, ConversationId): - return value.id - if isinstance(value, AssociatedCalendarItemId): + if isinstance(value, (ConversationId, AssociatedCalendarItemId)): return value.id raise TypeError(f"Unsupported type: {type(value)} ({value})") From f8ea801669738e1daeebd008d432d7c45d346311 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 24 Apr 2024 17:48:00 +0200 Subject: [PATCH 431/509] docs: Add section on pre-commit hooks --- docs/index.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/index.md b/docs/index.md index 7bb2a204..a702744d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2308,4 +2308,13 @@ DEBUG=1 python -m unittest -k test_folder.FolderTest.test_refresh # Running tests in parallel using the 'unittest-parallel' dependency unittest-parallel -j 4 --level=class + +## Contributing + +This repo uses pre-commit hooks. Before committing changes, install the hooks: + +````bash +pip install pre-commit +pre-commit install +pre-commit run ``` From a2922f0defbb6691d57660e635e94332bf70f12b Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 24 Apr 2024 17:48:09 +0200 Subject: [PATCH 432/509] Fix syntax --- exchangelib/fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index 8d6f5cc0..0e87aa87 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -1360,7 +1360,7 @@ def clean(self, value, version=None): if not isinstance(s, str): tmp.append(s) continue - tmp.append(self.value_cls(**{"label": default_label, value_field_name: s}) + tmp.append(self.value_cls(**{"label": default_label, value_field_name: s})) value = tmp return super().clean(value, version=version) From 864b7c8e60b98670b401f38bbb6714fefbb96889 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 24 Apr 2024 19:12:38 +0200 Subject: [PATCH 433/509] fix: That clean() only works for single-field indexed fields --- exchangelib/fields.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index 0e87aa87..d2b8a850 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -1349,6 +1349,20 @@ def to_xml(self, value, version): def response_tag(self): return f"{{{self.namespace}}}{self.PARENT_ELEMENT_NAME}" + def __hash__(self): + return hash(self.field_uri) + + +class SingleFieldIndexedField(IndexedField): + """A base class for all single-field indexed fields.""" + def __init__(self, *args, **kwargs): + from .indexed_properties import SingleFieldIndexedElement + + value_cls = kwargs["value_cls"] + if not issubclass(value_cls, SingleFieldIndexedElement): + raise TypeError(f"'value_cls' {value_cls!r} must be a subclass of type {SingleFieldIndexedElement}") + super().__init__(*args, **kwargs) + def clean(self, value, version=None): if value is not None: default_labels = self.value_cls.LABEL_CHOICES @@ -1364,11 +1378,8 @@ def clean(self, value, version=None): value = tmp return super().clean(value, version=version) - def __hash__(self): - return hash(self.field_uri) - -class EmailAddressesField(IndexedField): +class EmailAddressesField(SingleFieldIndexedField): PARENT_ELEMENT_NAME = "EmailAddresses" def __init__(self, *args, **kwargs): @@ -1378,7 +1389,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) -class ImAddressField(IndexedField): +class ImAddressField(SingleFieldIndexedField): PARENT_ELEMENT_NAME = "ImAddresses" def __init__(self, *args, **kwargs): @@ -1388,7 +1399,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) -class PhoneNumberField(IndexedField): +class PhoneNumberField(SingleFieldIndexedField): PARENT_ELEMENT_NAME = "PhoneNumbers" def __init__(self, *args, **kwargs): From e2122189e7ae3960666cf6f9b4189cbf81236e29 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 24 Apr 2024 19:23:49 +0200 Subject: [PATCH 434/509] chore: blacken --- exchangelib/fields.py | 1 + 1 file changed, 1 insertion(+) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index d2b8a850..764b04c2 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -1355,6 +1355,7 @@ def __hash__(self): class SingleFieldIndexedField(IndexedField): """A base class for all single-field indexed fields.""" + def __init__(self, *args, **kwargs): from .indexed_properties import SingleFieldIndexedElement From 7b74fcf3bc912b03fe8ff4fc73533fd8643a0784 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 25 Apr 2024 13:14:39 +0200 Subject: [PATCH 435/509] Bump version --- CHANGELOG.md | 7 +++++++ exchangelib/__init__.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f59f091..fdd589e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ HEAD ---- +5.3.0 +----- +- Fix various issues related to public folders and archive folders +- Support read-write for ``Contact.im_addresses` +- Improve reporting of inbox rule validation errors + + 5.2.1 ----- - Fix `ErrorAccessDenied: Not allowed to access Non IPM folder` caused by recent changes in O365. diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index ad342ff5..4c3308d5 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -36,7 +36,7 @@ from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "5.2.1" +__version__ = "5.3.0" __all__ = [ "__version__", From 1371649cc1fec6f61ed13ed968eceb79a88f028d Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Thu, 25 Apr 2024 13:16:23 +0200 Subject: [PATCH 436/509] docs: Update docs --- docs/exchangelib/fields.html | 295 ++++++++++++------ docs/exchangelib/folders/base.html | 129 ++++---- docs/exchangelib/folders/index.html | 186 ++++++----- docs/exchangelib/folders/queryset.html | 42 ++- docs/exchangelib/folders/roots.html | 129 ++++---- docs/exchangelib/index.html | 138 ++++---- docs/exchangelib/indexed_properties.html | 86 ++++- docs/exchangelib/items/contact.html | 5 +- docs/exchangelib/items/index.html | 2 +- docs/exchangelib/properties.html | 10 +- docs/exchangelib/services/common.html | 95 ++---- docs/exchangelib/services/create_folder.html | 4 +- docs/exchangelib/services/get_folder.html | 4 +- .../services/get_user_settings.html | 4 +- docs/exchangelib/services/index.html | 22 +- .../services/sync_folder_hierarchy.html | 4 +- docs/exchangelib/services/update_folder.html | 4 +- docs/exchangelib/util.html | 32 +- pyproject.toml | 2 +- 19 files changed, 726 insertions(+), 467 deletions(-) diff --git a/docs/exchangelib/fields.html b/docs/exchangelib/fields.html index 252253d4..0ef39cd5 100644 --- a/docs/exchangelib/fields.html +++ b/docs/exchangelib/fields.html @@ -1358,6 +1358,9 @@

          Module exchangelib.fields

          class IndexedField(EWSElementField, metaclass=abc.ABCMeta): """A base class for all indexed fields.""" + is_list = True + is_complex = True + PARENT_ELEMENT_NAME = None def __init__(self, *args, **kwargs): @@ -1378,16 +1381,15 @@

          Module exchangelib.fields

          return hash(self.field_uri) -class EmailAddressesField(IndexedField): - is_list = True - is_complex = True - - PARENT_ELEMENT_NAME = "EmailAddresses" +class SingleFieldIndexedField(IndexedField): + """A base class for all single-field indexed fields.""" def __init__(self, *args, **kwargs): - from .indexed_properties import EmailAddress + from .indexed_properties import SingleFieldIndexedElement - kwargs["value_cls"] = EmailAddress + value_cls = kwargs["value_cls"] + if not issubclass(value_cls, SingleFieldIndexedElement): + raise TypeError(f"'value_cls' {value_cls!r} must be a subclass of type {SingleFieldIndexedElement}") super().__init__(*args, **kwargs) def clean(self, value, version=None): @@ -1396,19 +1398,37 @@

          Module exchangelib.fields

          if len(value) > len(default_labels): raise ValueError(f"This field can handle at most {len(default_labels)} values (value: {value})") tmp = [] + value_field_name = self.value_cls.value_field(version=version).name for s, default_label in zip(value, default_labels): if not isinstance(s, str): tmp.append(s) continue - tmp.append(self.value_cls(email=s, label=default_label)) + tmp.append(self.value_cls(**{"label": default_label, value_field_name: s})) value = tmp return super().clean(value, version=version) -class PhoneNumberField(IndexedField): - is_list = True - is_complex = True +class EmailAddressesField(SingleFieldIndexedField): + PARENT_ELEMENT_NAME = "EmailAddresses" + def __init__(self, *args, **kwargs): + from .indexed_properties import EmailAddress + + kwargs["value_cls"] = EmailAddress + super().__init__(*args, **kwargs) + + +class ImAddressField(SingleFieldIndexedField): + PARENT_ELEMENT_NAME = "ImAddresses" + + def __init__(self, *args, **kwargs): + from .indexed_properties import ImAddress + + kwargs["value_cls"] = ImAddress + super().__init__(*args, **kwargs) + + +class PhoneNumberField(SingleFieldIndexedField): PARENT_ELEMENT_NAME = "PhoneNumbers" def __init__(self, *args, **kwargs): @@ -3599,39 +3619,23 @@

          Inherited members

          (*args, **kwargs)
    -

    A base class for all indexed fields.

    +

    A base class for all single-field indexed fields.

    Expand source code -
    class EmailAddressesField(IndexedField):
    -    is_list = True
    -    is_complex = True
    -
    +
    class EmailAddressesField(SingleFieldIndexedField):
         PARENT_ELEMENT_NAME = "EmailAddresses"
     
         def __init__(self, *args, **kwargs):
             from .indexed_properties import EmailAddress
     
             kwargs["value_cls"] = EmailAddress
    -        super().__init__(*args, **kwargs)
    -
    -    def clean(self, value, version=None):
    -        if value is not None:
    -            default_labels = self.value_cls.LABEL_CHOICES
    -            if len(value) > len(default_labels):
    -                raise ValueError(f"This field can handle at most {len(default_labels)} values (value: {value})")
    -            tmp = []
    -            for s, default_label in zip(value, default_labels):
    -                if not isinstance(s, str):
    -                    tmp.append(s)
    -                    continue
    -                tmp.append(self.value_cls(email=s, label=default_label))
    -            value = tmp
    -        return super().clean(value, version=version)
    + super().__init__(*args, **kwargs)

    Ancestors

      +
    • SingleFieldIndexedField
    • IndexedField
    • EWSElementField
    • FieldURIField
    • @@ -3644,48 +3648,13 @@

      Class variables

      -
      var is_complex
      -
      -
      -
      -
      var is_list
      -
      -
      -
      - -

      Methods

      -
      -
      -def clean(self, value, version=None) -
      -
      -
      -
      - -Expand source code - -
      def clean(self, value, version=None):
      -    if value is not None:
      -        default_labels = self.value_cls.LABEL_CHOICES
      -        if len(value) > len(default_labels):
      -            raise ValueError(f"This field can handle at most {len(default_labels)} values (value: {value})")
      -        tmp = []
      -        for s, default_label in zip(value, default_labels):
      -            if not isinstance(s, str):
      -                tmp.append(s)
      -                continue
      -            tmp.append(self.value_cls(email=s, label=default_label))
      -        value = tmp
      -    return super().clean(value, version=version)
      -
      -

      Inherited members

      @@ -5099,6 +5068,51 @@

      Inherited members

    +
    +class ImAddressField +(*args, **kwargs) +
    +
    +

    A base class for all single-field indexed fields.

    +
    + +Expand source code + +
    class ImAddressField(SingleFieldIndexedField):
    +    PARENT_ELEMENT_NAME = "ImAddresses"
    +
    +    def __init__(self, *args, **kwargs):
    +        from .indexed_properties import ImAddress
    +
    +        kwargs["value_cls"] = ImAddress
    +        super().__init__(*args, **kwargs)
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var PARENT_ELEMENT_NAME
    +
    +
    +
    +
    +

    Inherited members

    + +
    class ImportanceField (*args, **kwargs) @@ -5155,6 +5169,9 @@

    Inherited members

    class IndexedField(EWSElementField, metaclass=abc.ABCMeta):
         """A base class for all indexed fields."""
     
    +    is_list = True
    +    is_complex = True
    +
         PARENT_ELEMENT_NAME = None
     
         def __init__(self, *args, **kwargs):
    @@ -5183,9 +5200,8 @@ 

    Ancestors

    Subclasses

    Class variables

    @@ -5193,6 +5209,14 @@

    Class variables

    +
    var is_complex
    +
    +
    +
    +
    var is_list
    +
    +
    +

    Methods

    @@ -6083,15 +6107,12 @@

    Inherited members

    (*args, **kwargs)
    -

    A base class for all indexed fields.

    +

    A base class for all single-field indexed fields.

    Expand source code -
    class PhoneNumberField(IndexedField):
    -    is_list = True
    -    is_complex = True
    -
    +
    class PhoneNumberField(SingleFieldIndexedField):
         PARENT_ELEMENT_NAME = "PhoneNumbers"
     
         def __init__(self, *args, **kwargs):
    @@ -6102,6 +6123,7 @@ 

    Inherited members

    Ancestors

    +
    +class SingleFieldIndexedField +(*args, **kwargs) +
    +
    +

    A base class for all single-field indexed fields.

    +
    + +Expand source code + +
    class SingleFieldIndexedField(IndexedField):
    +    """A base class for all single-field indexed fields."""
    +
    +    def __init__(self, *args, **kwargs):
    +        from .indexed_properties import SingleFieldIndexedElement
    +
    +        value_cls = kwargs["value_cls"]
    +        if not issubclass(value_cls, SingleFieldIndexedElement):
    +            raise TypeError(f"'value_cls' {value_cls!r} must be a subclass of type {SingleFieldIndexedElement}")
    +        super().__init__(*args, **kwargs)
    +
    +    def clean(self, value, version=None):
    +        if value is not None:
    +            default_labels = self.value_cls.LABEL_CHOICES
    +            if len(value) > len(default_labels):
    +                raise ValueError(f"This field can handle at most {len(default_labels)} values (value: {value})")
    +            tmp = []
    +            value_field_name = self.value_cls.value_field(version=version).name
    +            for s, default_label in zip(value, default_labels):
    +                if not isinstance(s, str):
    +                    tmp.append(s)
    +                    continue
    +                tmp.append(self.value_cls(**{"label": default_label, value_field_name: s}))
    +            value = tmp
    +        return super().clean(value, version=version)
    +
    +

    Ancestors

    + +

    Subclasses

    + +

    Methods

    +
    +
    +def clean(self, value, version=None) +
    +
    +
    +
    + +Expand source code + +
    def clean(self, value, version=None):
    +    if value is not None:
    +        default_labels = self.value_cls.LABEL_CHOICES
    +        if len(value) > len(default_labels):
    +            raise ValueError(f"This field can handle at most {len(default_labels)} values (value: {value})")
    +        tmp = []
    +        value_field_name = self.value_cls.value_field(version=version).name
    +        for s, default_label in zip(value, default_labels):
    +            if not isinstance(s, str):
    +                tmp.append(s)
    +                continue
    +            tmp.append(self.value_cls(**{"label": default_label, value_field_name: s}))
    +        value = tmp
    +    return super().clean(value, version=version)
    +
    +
    +
    +

    Inherited members

    + +
    class StringAttributedValueField (*args, **kwargs) @@ -7528,9 +7630,6 @@

    EmailAddressesField

  • @@ -7631,12 +7730,20 @@

    IdField

  • +

    ImAddressField

    + +
  • +
  • ImportanceField

  • IndexedField

  • @@ -7731,8 +7838,6 @@

    PhoneNumberField

  • @@ -7768,6 +7873,12 @@

    SensitivityField

  • +

    SingleFieldIndexedField

    + +
  • +
  • StringAttributedValueField

  • diff --git a/docs/exchangelib/folders/base.html b/docs/exchangelib/folders/base.html index 9ba89d1b..8c39d417 100644 --- a/docs/exchangelib/folders/base.html +++ b/docs/exchangelib/folders/base.html @@ -122,6 +122,8 @@

    Module exchangelib.folders.base

    self.item_sync_state = kwargs.pop("item_sync_state", None) self.folder_sync_state = kwargs.pop("folder_sync_state", None) super().__init__(**kwargs) + if self._distinguished_id and self.account: + self._distinguished_id.mailbox = Mailbox(email_address=self.account.primary_smtp_address) @property @abc.abstractmethod @@ -241,6 +243,15 @@

    Module exchangelib.folders.base

    tree += f" {node}\n" return tree.strip() + @classmethod + def _get_distinguished(cls, folder): + if not cls.DISTINGUISHED_FOLDER_ID: + raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") + try: + return cls.resolve(account=folder.account, folder=folder) + except MISSING_FOLDER_ERRORS as e: + raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r} ({e})") + @property def has_distinguished_name(self): return self.name and self.DISTINGUISHED_FOLDER_ID and self.name.lower() == self.DISTINGUISHED_FOLDER_ID.lower() @@ -852,7 +863,7 @@

    Module exchangelib.folders.base

    if parent.root != self.root: raise ValueError("'parent.root' must match 'root'") else: - self.root = parent.root + self._root = parent.root if "parent_folder_id" in kwargs and parent.id != kwargs["parent_folder_id"]: raise ValueError("'parent_folder_id' must match 'parent' ID") kwargs["parent_folder_id"] = ParentFolderId(id=parent.id, changekey=parent.changekey) @@ -868,10 +879,6 @@

    Module exchangelib.folders.base

    def root(self): return self._root - @root.setter - def root(self, value): - self._root = value - @classmethod def register(cls, *args, **kwargs): if cls is not Folder: @@ -884,24 +891,6 @@

    Module exchangelib.folders.base

    raise TypeError("For folders, custom fields must be registered on the Folder class") return super().deregister(*args, **kwargs) - @classmethod - def get_distinguished(cls, account): - """Get the distinguished folder for this folder class. - - :param account: - :return: - """ - try: - return cls.resolve( - account=account, - folder=DistinguishedFolderId( - id=cls.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=account.primary_smtp_address), - ), - ) - except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") - @property def parent(self): if not self.parent_folder_id: @@ -918,7 +907,7 @@

    Module exchangelib.folders.base

    else: if not isinstance(value, BaseFolder): raise InvalidTypeError("value", value, BaseFolder) - self.root = value.root + self._root = value.root self.parent_folder_id = ParentFolderId(id=value.id, changekey=value.changekey) def clean(self, version=None): @@ -928,6 +917,23 @@

    Module exchangelib.folders.base

    if self.root and not isinstance(self.root, RootOfHierarchy): raise InvalidTypeError("root", self.root, RootOfHierarchy) + @classmethod + def get_distinguished(cls, root): + """Get the distinguished folder for this folder class. + + :param root: + :return: + """ + return cls._get_distinguished( + folder=cls( + _distinguished_id=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=root.account.primary_smtp_address), + ), + root=root, + ) + ) + @classmethod def from_xml_with_root(cls, elem, root): folder = cls.from_xml(elem=elem, account=root.account) @@ -1025,6 +1031,8 @@

    Classes

    self.item_sync_state = kwargs.pop("item_sync_state", None) self.folder_sync_state = kwargs.pop("folder_sync_state", None) super().__init__(**kwargs) + if self._distinguished_id and self.account: + self._distinguished_id.mailbox = Mailbox(email_address=self.account.primary_smtp_address) @property @abc.abstractmethod @@ -1144,6 +1152,15 @@

    Classes

    tree += f" {node}\n" return tree.strip() + @classmethod + def _get_distinguished(cls, folder): + if not cls.DISTINGUISHED_FOLDER_ID: + raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") + try: + return cls.resolve(account=folder.account, folder=folder) + except MISSING_FOLDER_ERRORS as e: + raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r} ({e})") + @property def has_distinguished_name(self): return self.name and self.DISTINGUISHED_FOLDER_ID and self.name.lower() == self.DISTINGUISHED_FOLDER_ID.lower() @@ -2975,7 +2992,7 @@

    Inherited members

    if parent.root != self.root: raise ValueError("'parent.root' must match 'root'") else: - self.root = parent.root + self._root = parent.root if "parent_folder_id" in kwargs and parent.id != kwargs["parent_folder_id"]: raise ValueError("'parent_folder_id' must match 'parent' ID") kwargs["parent_folder_id"] = ParentFolderId(id=parent.id, changekey=parent.changekey) @@ -2991,10 +3008,6 @@

    Inherited members

    def root(self): return self._root - @root.setter - def root(self, value): - self._root = value - @classmethod def register(cls, *args, **kwargs): if cls is not Folder: @@ -3007,24 +3020,6 @@

    Inherited members

    raise TypeError("For folders, custom fields must be registered on the Folder class") return super().deregister(*args, **kwargs) - @classmethod - def get_distinguished(cls, account): - """Get the distinguished folder for this folder class. - - :param account: - :return: - """ - try: - return cls.resolve( - account=account, - folder=DistinguishedFolderId( - id=cls.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=account.primary_smtp_address), - ), - ) - except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") - @property def parent(self): if not self.parent_folder_id: @@ -3041,7 +3036,7 @@

    Inherited members

    else: if not isinstance(value, BaseFolder): raise InvalidTypeError("value", value, BaseFolder) - self.root = value.root + self._root = value.root self.parent_folder_id = ParentFolderId(id=value.id, changekey=value.changekey) def clean(self, version=None): @@ -3051,6 +3046,23 @@

    Inherited members

    if self.root and not isinstance(self.root, RootOfHierarchy): raise InvalidTypeError("root", self.root, RootOfHierarchy) + @classmethod + def get_distinguished(cls, root): + """Get the distinguished folder for this folder class. + + :param root: + :return: + """ + return cls._get_distinguished( + folder=cls( + _distinguished_id=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=root.account.primary_smtp_address), + ), + root=root, + ) + ) + @classmethod def from_xml_with_root(cls, elem, root): folder = cls.from_xml(elem=elem, account=root.account) @@ -3173,33 +3185,32 @@

    Static methods

    -def get_distinguished(account) +def get_distinguished(root)

    Get the distinguished folder for this folder class.

    -

    :param account: +

    :param root: :return:

    Expand source code
    @classmethod
    -def get_distinguished(cls, account):
    +def get_distinguished(cls, root):
         """Get the distinguished folder for this folder class.
     
    -    :param account:
    +    :param root:
         :return:
         """
    -    try:
    -        return cls.resolve(
    -            account=account,
    -            folder=DistinguishedFolderId(
    +    return cls._get_distinguished(
    +        folder=cls(
    +            _distinguished_id=DistinguishedFolderId(
                     id=cls.DISTINGUISHED_FOLDER_ID,
    -                mailbox=Mailbox(email_address=account.primary_smtp_address),
    +                mailbox=Mailbox(email_address=root.account.primary_smtp_address),
                 ),
    +            root=root,
             )
    -    except MISSING_FOLDER_ERRORS:
    -        raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}")
    + )
    diff --git a/docs/exchangelib/folders/index.html b/docs/exchangelib/folders/index.html index 44e7b377..556e1f7c 100644 --- a/docs/exchangelib/folders/index.html +++ b/docs/exchangelib/folders/index.html @@ -1545,6 +1545,8 @@

    Inherited members

    self.item_sync_state = kwargs.pop("item_sync_state", None) self.folder_sync_state = kwargs.pop("folder_sync_state", None) super().__init__(**kwargs) + if self._distinguished_id and self.account: + self._distinguished_id.mailbox = Mailbox(email_address=self.account.primary_smtp_address) @property @abc.abstractmethod @@ -1664,6 +1666,15 @@

    Inherited members

    tree += f" {node}\n" return tree.strip() + @classmethod + def _get_distinguished(cls, folder): + if not cls.DISTINGUISHED_FOLDER_ID: + raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") + try: + return cls.resolve(account=folder.account, folder=folder) + except MISSING_FOLDER_ERRORS as e: + raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r} ({e})") + @property def has_distinguished_name(self): return self.name and self.DISTINGUISHED_FOLDER_ID and self.name.lower() == self.DISTINGUISHED_FOLDER_ID.lower() @@ -4611,7 +4622,9 @@

    Inherited members

    super().clean(version=version) if self.id == PublicFoldersRoot.DISTINGUISHED_FOLDER_ID: # Avoid "ErrorInvalidOperation: It is not valid to specify a mailbox with the public folder root" from EWS - self.mailbox = None
    + self.mailbox = None + elif not self.mailbox: + raise ValueError(f"DistinguishedFolderId {self.id} must have a mailbox")

    Ancestors

      @@ -4672,7 +4685,9 @@

      Methods

      super().clean(version=version) if self.id == PublicFoldersRoot.DISTINGUISHED_FOLDER_ID: # Avoid "ErrorInvalidOperation: It is not valid to specify a mailbox with the public folder root" from EWS - self.mailbox = None
      + self.mailbox = None + elif not self.mailbox: + raise ValueError(f"DistinguishedFolderId {self.id} must have a mailbox") @@ -5246,7 +5261,7 @@

      Inherited members

      if parent.root != self.root: raise ValueError("'parent.root' must match 'root'") else: - self.root = parent.root + self._root = parent.root if "parent_folder_id" in kwargs and parent.id != kwargs["parent_folder_id"]: raise ValueError("'parent_folder_id' must match 'parent' ID") kwargs["parent_folder_id"] = ParentFolderId(id=parent.id, changekey=parent.changekey) @@ -5262,10 +5277,6 @@

      Inherited members

      def root(self): return self._root - @root.setter - def root(self, value): - self._root = value - @classmethod def register(cls, *args, **kwargs): if cls is not Folder: @@ -5278,24 +5289,6 @@

      Inherited members

      raise TypeError("For folders, custom fields must be registered on the Folder class") return super().deregister(*args, **kwargs) - @classmethod - def get_distinguished(cls, account): - """Get the distinguished folder for this folder class. - - :param account: - :return: - """ - try: - return cls.resolve( - account=account, - folder=DistinguishedFolderId( - id=cls.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=account.primary_smtp_address), - ), - ) - except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") - @property def parent(self): if not self.parent_folder_id: @@ -5312,7 +5305,7 @@

      Inherited members

      else: if not isinstance(value, BaseFolder): raise InvalidTypeError("value", value, BaseFolder) - self.root = value.root + self._root = value.root self.parent_folder_id = ParentFolderId(id=value.id, changekey=value.changekey) def clean(self, version=None): @@ -5322,6 +5315,23 @@

      Inherited members

      if self.root and not isinstance(self.root, RootOfHierarchy): raise InvalidTypeError("root", self.root, RootOfHierarchy) + @classmethod + def get_distinguished(cls, root): + """Get the distinguished folder for this folder class. + + :param root: + :return: + """ + return cls._get_distinguished( + folder=cls( + _distinguished_id=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=root.account.primary_smtp_address), + ), + root=root, + ) + ) + @classmethod def from_xml_with_root(cls, elem, root): folder = cls.from_xml(elem=elem, account=root.account) @@ -5444,33 +5454,32 @@

      Static methods

      -def get_distinguished(account) +def get_distinguished(root)

      Get the distinguished folder for this folder class.

      -

      :param account: +

      :param root: :return:

      Expand source code
      @classmethod
      -def get_distinguished(cls, account):
      +def get_distinguished(cls, root):
           """Get the distinguished folder for this folder class.
       
      -    :param account:
      +    :param root:
           :return:
           """
      -    try:
      -        return cls.resolve(
      -            account=account,
      -            folder=DistinguishedFolderId(
      +    return cls._get_distinguished(
      +        folder=cls(
      +            _distinguished_id=DistinguishedFolderId(
                       id=cls.DISTINGUISHED_FOLDER_ID,
      -                mailbox=Mailbox(email_address=account.primary_smtp_address),
      +                mailbox=Mailbox(email_address=root.account.primary_smtp_address),
                   ),
      +            root=root,
               )
      -    except MISSING_FOLDER_ERRORS:
      -        raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}")
      + )
      @@ -6972,11 +6981,23 @@

      Inherited members

      """Return the query result as exactly one item. Raises DoesNotExist if there are no results, and MultipleObjectsReturned if there are multiple results. """ + from .base import Folder from .collections import FolderCollection if not args and set(kwargs) in ({"id"}, {"id", "changekey"}): + roots = {f.root for f in self.folder_collection.folders} + if len(roots) != 1: + raise ValueError(f"All folders must have the same root hierarchy ({roots})") folders = list( - FolderCollection(account=self.folder_collection.account, folders=[FolderId(**kwargs)]).resolve() + FolderCollection( + account=self.folder_collection.account, + folders=[ + Folder( + _id=FolderId(**kwargs), + root=roots.pop(), + ) + ], + ).resolve() ) elif args or kwargs: folders = list(self.filter(*args, **kwargs)) @@ -7128,11 +7149,23 @@

      Methods

      """Return the query result as exactly one item. Raises DoesNotExist if there are no results, and MultipleObjectsReturned if there are multiple results. """ + from .base import Folder from .collections import FolderCollection if not args and set(kwargs) in ({"id"}, {"id", "changekey"}): + roots = {f.root for f in self.folder_collection.folders} + if len(roots) != 1: + raise ValueError(f"All folders must have the same root hierarchy ({roots})") folders = list( - FolderCollection(account=self.folder_collection.account, folders=[FolderId(**kwargs)]).resolve() + FolderCollection( + account=self.folder_collection.account, + folders=[ + Folder( + _id=FolderId(**kwargs), + root=roots.pop(), + ) + ], + ).resolve() ) elif args or kwargs: folders = list(self.filter(*args, **kwargs)) @@ -9354,6 +9387,11 @@

      Inherited members

      DEFAULT_FOLDER_TRAVERSAL_DEPTH = SHALLOW supported_from = EXCHANGE_2007_SP1 + def __init__(self, **kwargs): + super().__init__(**kwargs) + if self._distinguished_id: + self._distinguished_id.mailbox = None # See DistinguishedFolderId.clean() + def get_children(self, folder): # EWS does not allow deep traversal of public folders, so self._folders_map will only populate the top-level # subfolders. To traverse public folders at arbitrary depth, we need to get child folders on demand. @@ -10998,25 +11036,6 @@

      Inherited members

      if f.parent.id == folder.id: yield f - @classmethod - def get_distinguished(cls, account): - """Get the distinguished folder for this folder class. - - :param account: - """ - if not cls.DISTINGUISHED_FOLDER_ID: - raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") - try: - return cls.resolve( - account=account, - folder=DistinguishedFolderId( - id=cls.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=account.primary_smtp_address), - ), - ) - except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") - def get_default_folder(self, folder_cls): """Return the distinguished folder instance of type folder_cls belonging to this account. If no distinguished folder was found, try as best we can to return the default folder of type 'folder_cls' @@ -11033,16 +11052,16 @@

      Inherited members

      return f try: log.debug("Requesting distinguished %s folder explicitly", folder_cls) - return folder_cls.get_distinguished(account=self.account) + return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) fld = folder_cls( - root=self, _distinguished_id=DistinguishedFolderId( id=folder_cls.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address), ), + root=self, ) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available @@ -11051,6 +11070,23 @@

      Inherited members

      pass raise ErrorFolderNotFound(f"No usable default {folder_cls} folders") + @classmethod + def get_distinguished(cls, account): + """Get the distinguished folder for this folder class. + + :param account: + :return: + """ + return cls._get_distinguished( + folder=cls( + _distinguished_id=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=account.primary_smtp_address), + ), + account=account, + ) + ) + @property def _folders_map(self): if self._subfolders is not None: @@ -11061,9 +11097,12 @@

      Inherited members

      # so we are sure to apply the correct Folder class, then fetch all sub-folders of this root. folders_map = {self.id: self} distinguished_folders = [ - DistinguishedFolderId( - id=cls.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=self.account.primary_smtp_address), + cls( + _distinguished_id=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ), + root=self, ) for cls in self.WELLKNOWN_FOLDERS if cls.get_folder_allowed and cls.supports_version(self.account.version) @@ -11243,7 +11282,8 @@

      Static methods

  • Get the distinguished folder for this folder class.

    -

    :param account:

    +

    :param account: +:return:

    Expand source code @@ -11253,19 +11293,17 @@

    Static methods

    """Get the distinguished folder for this folder class. :param account: + :return: """ - if not cls.DISTINGUISHED_FOLDER_ID: - raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") - try: - return cls.resolve( - account=account, - folder=DistinguishedFolderId( + return cls._get_distinguished( + folder=cls( + _distinguished_id=DistinguishedFolderId( id=cls.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=account.primary_smtp_address), ), + account=account, ) - except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") + )
    @@ -11350,16 +11388,16 @@

    Methods

    return f try: log.debug("Requesting distinguished %s folder explicitly", folder_cls) - return folder_cls.get_distinguished(account=self.account) + return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) fld = folder_cls( - root=self, _distinguished_id=DistinguishedFolderId( id=folder_cls.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address), ), + root=self, ) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available diff --git a/docs/exchangelib/folders/queryset.html b/docs/exchangelib/folders/queryset.html index 4098679d..17aa4796 100644 --- a/docs/exchangelib/folders/queryset.html +++ b/docs/exchangelib/folders/queryset.html @@ -102,11 +102,23 @@

    Module exchangelib.folders.queryset

    """Return the query result as exactly one item. Raises DoesNotExist if there are no results, and MultipleObjectsReturned if there are multiple results. """ + from .base import Folder from .collections import FolderCollection if not args and set(kwargs) in ({"id"}, {"id", "changekey"}): + roots = {f.root for f in self.folder_collection.folders} + if len(roots) != 1: + raise ValueError(f"All folders must have the same root hierarchy ({roots})") folders = list( - FolderCollection(account=self.folder_collection.account, folders=[FolderId(**kwargs)]).resolve() + FolderCollection( + account=self.folder_collection.account, + folders=[ + Folder( + _id=FolderId(**kwargs), + root=roots.pop(), + ) + ], + ).resolve() ) elif args or kwargs: folders = list(self.filter(*args, **kwargs)) @@ -278,11 +290,23 @@

    Classes

    """Return the query result as exactly one item. Raises DoesNotExist if there are no results, and MultipleObjectsReturned if there are multiple results. """ + from .base import Folder from .collections import FolderCollection if not args and set(kwargs) in ({"id"}, {"id", "changekey"}): + roots = {f.root for f in self.folder_collection.folders} + if len(roots) != 1: + raise ValueError(f"All folders must have the same root hierarchy ({roots})") folders = list( - FolderCollection(account=self.folder_collection.account, folders=[FolderId(**kwargs)]).resolve() + FolderCollection( + account=self.folder_collection.account, + folders=[ + Folder( + _id=FolderId(**kwargs), + root=roots.pop(), + ) + ], + ).resolve() ) elif args or kwargs: folders = list(self.filter(*args, **kwargs)) @@ -434,11 +458,23 @@

    Methods

    """Return the query result as exactly one item. Raises DoesNotExist if there are no results, and MultipleObjectsReturned if there are multiple results. """ + from .base import Folder from .collections import FolderCollection if not args and set(kwargs) in ({"id"}, {"id", "changekey"}): + roots = {f.root for f in self.folder_collection.folders} + if len(roots) != 1: + raise ValueError(f"All folders must have the same root hierarchy ({roots})") folders = list( - FolderCollection(account=self.folder_collection.account, folders=[FolderId(**kwargs)]).resolve() + FolderCollection( + account=self.folder_collection.account, + folders=[ + Folder( + _id=FolderId(**kwargs), + root=roots.pop(), + ) + ], + ).resolve() ) elif args or kwargs: folders = list(self.filter(*args, **kwargs)) diff --git a/docs/exchangelib/folders/roots.html b/docs/exchangelib/folders/roots.html index e4e8957a..089b61cd 100644 --- a/docs/exchangelib/folders/roots.html +++ b/docs/exchangelib/folders/roots.html @@ -129,25 +129,6 @@

    Module exchangelib.folders.roots

    if f.parent.id == folder.id: yield f - @classmethod - def get_distinguished(cls, account): - """Get the distinguished folder for this folder class. - - :param account: - """ - if not cls.DISTINGUISHED_FOLDER_ID: - raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") - try: - return cls.resolve( - account=account, - folder=DistinguishedFolderId( - id=cls.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=account.primary_smtp_address), - ), - ) - except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") - def get_default_folder(self, folder_cls): """Return the distinguished folder instance of type folder_cls belonging to this account. If no distinguished folder was found, try as best we can to return the default folder of type 'folder_cls' @@ -164,16 +145,16 @@

    Module exchangelib.folders.roots

    return f try: log.debug("Requesting distinguished %s folder explicitly", folder_cls) - return folder_cls.get_distinguished(account=self.account) + return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) fld = folder_cls( - root=self, _distinguished_id=DistinguishedFolderId( id=folder_cls.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address), ), + root=self, ) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available @@ -182,6 +163,23 @@

    Module exchangelib.folders.roots

    pass raise ErrorFolderNotFound(f"No usable default {folder_cls} folders") + @classmethod + def get_distinguished(cls, account): + """Get the distinguished folder for this folder class. + + :param account: + :return: + """ + return cls._get_distinguished( + folder=cls( + _distinguished_id=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=account.primary_smtp_address), + ), + account=account, + ) + ) + @property def _folders_map(self): if self._subfolders is not None: @@ -192,9 +190,12 @@

    Module exchangelib.folders.roots

    # so we are sure to apply the correct Folder class, then fetch all sub-folders of this root. folders_map = {self.id: self} distinguished_folders = [ - DistinguishedFolderId( - id=cls.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=self.account.primary_smtp_address), + cls( + _distinguished_id=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ), + root=self, ) for cls in self.WELLKNOWN_FOLDERS if cls.get_folder_allowed and cls.supports_version(self.account.version) @@ -354,6 +355,11 @@

    Module exchangelib.folders.roots

    DEFAULT_FOLDER_TRAVERSAL_DEPTH = SHALLOW supported_from = EXCHANGE_2007_SP1 + def __init__(self, **kwargs): + super().__init__(**kwargs) + if self._distinguished_id: + self._distinguished_id.mailbox = None # See DistinguishedFolderId.clean() + def get_children(self, folder): # EWS does not allow deep traversal of public folders, so self._folders_map will only populate the top-level # subfolders. To traverse public folders at arbitrary depth, we need to get child folders on demand. @@ -507,6 +513,11 @@

    Inherited members

    DEFAULT_FOLDER_TRAVERSAL_DEPTH = SHALLOW supported_from = EXCHANGE_2007_SP1 + def __init__(self, **kwargs): + super().__init__(**kwargs) + if self._distinguished_id: + self._distinguished_id.mailbox = None # See DistinguishedFolderId.clean() + def get_children(self, folder): # EWS does not allow deep traversal of public folders, so self._folders_map will only populate the top-level # subfolders. To traverse public folders at arbitrary depth, we need to get child folders on demand. @@ -894,25 +905,6 @@

    Inherited members

    if f.parent.id == folder.id: yield f - @classmethod - def get_distinguished(cls, account): - """Get the distinguished folder for this folder class. - - :param account: - """ - if not cls.DISTINGUISHED_FOLDER_ID: - raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") - try: - return cls.resolve( - account=account, - folder=DistinguishedFolderId( - id=cls.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=account.primary_smtp_address), - ), - ) - except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") - def get_default_folder(self, folder_cls): """Return the distinguished folder instance of type folder_cls belonging to this account. If no distinguished folder was found, try as best we can to return the default folder of type 'folder_cls' @@ -929,16 +921,16 @@

    Inherited members

    return f try: log.debug("Requesting distinguished %s folder explicitly", folder_cls) - return folder_cls.get_distinguished(account=self.account) + return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) fld = folder_cls( - root=self, _distinguished_id=DistinguishedFolderId( id=folder_cls.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address), ), + root=self, ) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available @@ -947,6 +939,23 @@

    Inherited members

    pass raise ErrorFolderNotFound(f"No usable default {folder_cls} folders") + @classmethod + def get_distinguished(cls, account): + """Get the distinguished folder for this folder class. + + :param account: + :return: + """ + return cls._get_distinguished( + folder=cls( + _distinguished_id=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=account.primary_smtp_address), + ), + account=account, + ) + ) + @property def _folders_map(self): if self._subfolders is not None: @@ -957,9 +966,12 @@

    Inherited members

    # so we are sure to apply the correct Folder class, then fetch all sub-folders of this root. folders_map = {self.id: self} distinguished_folders = [ - DistinguishedFolderId( - id=cls.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=self.account.primary_smtp_address), + cls( + _distinguished_id=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ), + root=self, ) for cls in self.WELLKNOWN_FOLDERS if cls.get_folder_allowed and cls.supports_version(self.account.version) @@ -1139,7 +1151,8 @@

    Static methods

    Get the distinguished folder for this folder class.

    -

    :param account:

    +

    :param account: +:return:

    Expand source code @@ -1149,19 +1162,17 @@

    Static methods

    """Get the distinguished folder for this folder class. :param account: + :return: """ - if not cls.DISTINGUISHED_FOLDER_ID: - raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") - try: - return cls.resolve( - account=account, - folder=DistinguishedFolderId( + return cls._get_distinguished( + folder=cls( + _distinguished_id=DistinguishedFolderId( id=cls.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=account.primary_smtp_address), ), + account=account, ) - except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") + )
    @@ -1246,16 +1257,16 @@

    Methods

    return f try: log.debug("Requesting distinguished %s folder explicitly", folder_cls) - return folder_cls.get_distinguished(account=self.account) + return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) fld = folder_cls( - root=self, _distinguished_id=DistinguishedFolderId( id=folder_cls.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address), ), + root=self, ) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available diff --git a/docs/exchangelib/index.html b/docs/exchangelib/index.html index 6d8252d1..f1539a2f 100644 --- a/docs/exchangelib/index.html +++ b/docs/exchangelib/index.html @@ -64,7 +64,7 @@

    Package exchangelib

    from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "5.2.1" +__version__ = "5.3.0" __all__ = [ "__version__", @@ -5018,7 +5018,7 @@

    Instance variables

    ) department = TextField(field_uri="contacts:Department") generation = TextField(field_uri="contacts:Generation") - im_addresses = CharField(field_uri="contacts:ImAddresses", is_read_only=True) + im_addresses = ImAddressField(field_uri="contacts:ImAddress") job_title = TextField(field_uri="contacts:JobTitle") manager = TextField(field_uri="contacts:Manager") mileage = TextField(field_uri="contacts:Mileage") @@ -7292,7 +7292,7 @@

    Inherited members

    if parent.root != self.root: raise ValueError("'parent.root' must match 'root'") else: - self.root = parent.root + self._root = parent.root if "parent_folder_id" in kwargs and parent.id != kwargs["parent_folder_id"]: raise ValueError("'parent_folder_id' must match 'parent' ID") kwargs["parent_folder_id"] = ParentFolderId(id=parent.id, changekey=parent.changekey) @@ -7308,10 +7308,6 @@

    Inherited members

    def root(self): return self._root - @root.setter - def root(self, value): - self._root = value - @classmethod def register(cls, *args, **kwargs): if cls is not Folder: @@ -7324,24 +7320,6 @@

    Inherited members

    raise TypeError("For folders, custom fields must be registered on the Folder class") return super().deregister(*args, **kwargs) - @classmethod - def get_distinguished(cls, account): - """Get the distinguished folder for this folder class. - - :param account: - :return: - """ - try: - return cls.resolve( - account=account, - folder=DistinguishedFolderId( - id=cls.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=account.primary_smtp_address), - ), - ) - except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}") - @property def parent(self): if not self.parent_folder_id: @@ -7358,7 +7336,7 @@

    Inherited members

    else: if not isinstance(value, BaseFolder): raise InvalidTypeError("value", value, BaseFolder) - self.root = value.root + self._root = value.root self.parent_folder_id = ParentFolderId(id=value.id, changekey=value.changekey) def clean(self, version=None): @@ -7368,6 +7346,23 @@

    Inherited members

    if self.root and not isinstance(self.root, RootOfHierarchy): raise InvalidTypeError("root", self.root, RootOfHierarchy) + @classmethod + def get_distinguished(cls, root): + """Get the distinguished folder for this folder class. + + :param root: + :return: + """ + return cls._get_distinguished( + folder=cls( + _distinguished_id=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=root.account.primary_smtp_address), + ), + root=root, + ) + ) + @classmethod def from_xml_with_root(cls, elem, root): folder = cls.from_xml(elem=elem, account=root.account) @@ -7490,33 +7485,32 @@

    Static methods

    -def get_distinguished(account) +def get_distinguished(root)

    Get the distinguished folder for this folder class.

    -

    :param account: +

    :param root: :return:

    Expand source code
    @classmethod
    -def get_distinguished(cls, account):
    +def get_distinguished(cls, root):
         """Get the distinguished folder for this folder class.
     
    -    :param account:
    +    :param root:
         :return:
         """
    -    try:
    -        return cls.resolve(
    -            account=account,
    -            folder=DistinguishedFolderId(
    +    return cls._get_distinguished(
    +        folder=cls(
    +            _distinguished_id=DistinguishedFolderId(
                     id=cls.DISTINGUISHED_FOLDER_ID,
    -                mailbox=Mailbox(email_address=account.primary_smtp_address),
    +                mailbox=Mailbox(email_address=root.account.primary_smtp_address),
                 ),
    +            root=root,
             )
    -    except MISSING_FOLDER_ERRORS:
    -        raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID!r}")
    + )
    @@ -11865,25 +11859,6 @@

    Inherited members

    if f.parent.id == folder.id: yield f - @classmethod - def get_distinguished(cls, account): - """Get the distinguished folder for this folder class. - - :param account: - """ - if not cls.DISTINGUISHED_FOLDER_ID: - raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") - try: - return cls.resolve( - account=account, - folder=DistinguishedFolderId( - id=cls.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=account.primary_smtp_address), - ), - ) - except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") - def get_default_folder(self, folder_cls): """Return the distinguished folder instance of type folder_cls belonging to this account. If no distinguished folder was found, try as best we can to return the default folder of type 'folder_cls' @@ -11900,16 +11875,16 @@

    Inherited members

    return f try: log.debug("Requesting distinguished %s folder explicitly", folder_cls) - return folder_cls.get_distinguished(account=self.account) + return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) fld = folder_cls( - root=self, _distinguished_id=DistinguishedFolderId( id=folder_cls.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address), ), + root=self, ) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available @@ -11918,6 +11893,23 @@

    Inherited members

    pass raise ErrorFolderNotFound(f"No usable default {folder_cls} folders") + @classmethod + def get_distinguished(cls, account): + """Get the distinguished folder for this folder class. + + :param account: + :return: + """ + return cls._get_distinguished( + folder=cls( + _distinguished_id=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=account.primary_smtp_address), + ), + account=account, + ) + ) + @property def _folders_map(self): if self._subfolders is not None: @@ -11928,9 +11920,12 @@

    Inherited members

    # so we are sure to apply the correct Folder class, then fetch all sub-folders of this root. folders_map = {self.id: self} distinguished_folders = [ - DistinguishedFolderId( - id=cls.DISTINGUISHED_FOLDER_ID, - mailbox=Mailbox(email_address=self.account.primary_smtp_address), + cls( + _distinguished_id=DistinguishedFolderId( + id=cls.DISTINGUISHED_FOLDER_ID, + mailbox=Mailbox(email_address=self.account.primary_smtp_address), + ), + root=self, ) for cls in self.WELLKNOWN_FOLDERS if cls.get_folder_allowed and cls.supports_version(self.account.version) @@ -12110,7 +12105,8 @@

    Static methods

    Get the distinguished folder for this folder class.

    -

    :param account:

    +

    :param account: +:return:

    Expand source code @@ -12120,19 +12116,17 @@

    Static methods

    """Get the distinguished folder for this folder class. :param account: + :return: """ - if not cls.DISTINGUISHED_FOLDER_ID: - raise ValueError(f"Class {cls} must have a DISTINGUISHED_FOLDER_ID value") - try: - return cls.resolve( - account=account, - folder=DistinguishedFolderId( + return cls._get_distinguished( + folder=cls( + _distinguished_id=DistinguishedFolderId( id=cls.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=account.primary_smtp_address), ), + account=account, ) - except MISSING_FOLDER_ERRORS: - raise ErrorFolderNotFound(f"Could not find distinguished folder {cls.DISTINGUISHED_FOLDER_ID}") + )
    @@ -12217,16 +12211,16 @@

    Methods

    return f try: log.debug("Requesting distinguished %s folder explicitly", folder_cls) - return folder_cls.get_distinguished(account=self.account) + return folder_cls.get_distinguished(root=self) except ErrorAccessDenied: # Maybe we just don't have GetFolder access? Try FindItem instead log.debug("Testing default %s folder with FindItem", folder_cls) fld = folder_cls( - root=self, _distinguished_id=DistinguishedFolderId( id=folder_cls.DISTINGUISHED_FOLDER_ID, mailbox=Mailbox(email_address=self.account.primary_smtp_address), ), + root=self, ) fld.test_access() return self._folders_map.get(fld.id, fld) # Use cached instance if available diff --git a/docs/exchangelib/indexed_properties.html b/docs/exchangelib/indexed_properties.html index 797430b5..201fef0a 100644 --- a/docs/exchangelib/indexed_properties.html +++ b/docs/exchangelib/indexed_properties.html @@ -61,6 +61,16 @@

    Module exchangelib.indexed_properties

    email = EmailSubField(is_required=True) +class ImAddress(SingleFieldIndexedElement): + """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/entry-imaddress""" + + ELEMENT_NAME = "Entry" + LABEL_CHOICES = ("ImAddress1", "ImAddress2", "ImAddress3") + + label = LabelField(field_uri="Key", choices={Choice(c) for c in LABEL_CHOICES}, default=LABEL_CHOICES[0]) + im_address = SubField(is_required=True) + + class PhoneNumber(SingleFieldIndexedElement): """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/entry-phonenumber""" @@ -186,6 +196,69 @@

    Inherited members

    +
    +class ImAddress +(**kwargs) +
    +
    + +
    + +Expand source code + +
    class ImAddress(SingleFieldIndexedElement):
    +    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/entry-imaddress"""
    +
    +    ELEMENT_NAME = "Entry"
    +    LABEL_CHOICES = ("ImAddress1", "ImAddress2", "ImAddress3")
    +
    +    label = LabelField(field_uri="Key", choices={Choice(c) for c in LABEL_CHOICES}, default=LABEL_CHOICES[0])
    +    im_address = SubField(is_required=True)
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var ELEMENT_NAME
    +
    +
    +
    +
    var FIELDS
    +
    +
    +
    +
    var LABEL_CHOICES
    +
    +
    +
    +
    +

    Instance variables

    +
    +
    var im_address
    +
    +
    +
    +
    var label
    +
    +
    +
    +
    +

    Inherited members

    + +
    class IndexedElement (**kwargs) @@ -480,6 +553,7 @@

    Ancestors

    Subclasses

    Static methods

    @@ -541,6 +615,16 @@

    ImAddress

    + + +
  • IndexedElement

    • LABEL_CHOICES
    • @@ -589,4 +673,4 @@

      Generated by pdoc 0.10.0.

      - \ No newline at end of file + diff --git a/docs/exchangelib/items/contact.html b/docs/exchangelib/items/contact.html index 5af9f3b9..03769056 100644 --- a/docs/exchangelib/items/contact.html +++ b/docs/exchangelib/items/contact.html @@ -44,6 +44,7 @@

      Module exchangelib.items.contact

      EWSElementField, EWSElementListField, IdElementField, + ImAddressField, MailboxField, MailboxListField, MemberListField, @@ -114,7 +115,7 @@

      Module exchangelib.items.contact

      ) department = TextField(field_uri="contacts:Department") generation = TextField(field_uri="contacts:Generation") - im_addresses = CharField(field_uri="contacts:ImAddresses", is_read_only=True) + im_addresses = ImAddressField(field_uri="contacts:ImAddress") job_title = TextField(field_uri="contacts:JobTitle") manager = TextField(field_uri="contacts:Manager") mileage = TextField(field_uri="contacts:Mileage") @@ -363,7 +364,7 @@

      Classes

      ) department = TextField(field_uri="contacts:Department") generation = TextField(field_uri="contacts:Generation") - im_addresses = CharField(field_uri="contacts:ImAddresses", is_read_only=True) + im_addresses = ImAddressField(field_uri="contacts:ImAddress") job_title = TextField(field_uri="contacts:JobTitle") manager = TextField(field_uri="contacts:Manager") mileage = TextField(field_uri="contacts:Mileage") diff --git a/docs/exchangelib/items/index.html b/docs/exchangelib/items/index.html index 008cc1f0..bed3b10f 100644 --- a/docs/exchangelib/items/index.html +++ b/docs/exchangelib/items/index.html @@ -1236,7 +1236,7 @@

      Inherited members

      ) department = TextField(field_uri="contacts:Department") generation = TextField(field_uri="contacts:Generation") - im_addresses = CharField(field_uri="contacts:ImAddresses", is_read_only=True) + im_addresses = ImAddressField(field_uri="contacts:ImAddress") job_title = TextField(field_uri="contacts:JobTitle") manager = TextField(field_uri="contacts:Manager") mileage = TextField(field_uri="contacts:Mileage") diff --git a/docs/exchangelib/properties.html b/docs/exchangelib/properties.html index a181f509..97ded4ff 100644 --- a/docs/exchangelib/properties.html +++ b/docs/exchangelib/properties.html @@ -790,6 +790,8 @@

      Module exchangelib.properties

      if self.id == PublicFoldersRoot.DISTINGUISHED_FOLDER_ID: # Avoid "ErrorInvalidOperation: It is not valid to specify a mailbox with the public folder root" from EWS self.mailbox = None + elif not self.mailbox: + raise ValueError(f"DistinguishedFolderId {self.id} must have a mailbox") class TimeWindow(EWSElement): @@ -4903,7 +4905,9 @@

      Inherited members

      super().clean(version=version) if self.id == PublicFoldersRoot.DISTINGUISHED_FOLDER_ID: # Avoid "ErrorInvalidOperation: It is not valid to specify a mailbox with the public folder root" from EWS - self.mailbox = None
      + self.mailbox = None + elif not self.mailbox: + raise ValueError(f"DistinguishedFolderId {self.id} must have a mailbox")

      Ancestors

        @@ -4964,7 +4968,9 @@

        Methods

        super().clean(version=version) if self.id == PublicFoldersRoot.DISTINGUISHED_FOLDER_ID: # Avoid "ErrorInvalidOperation: It is not valid to specify a mailbox with the public folder root" from EWS - self.mailbox = None + self.mailbox = None + elif not self.mailbox: + raise ValueError(f"DistinguishedFolderId {self.id} must have a mailbox") diff --git a/docs/exchangelib/services/common.html b/docs/exchangelib/services/common.html index 05ba0f22..1d22fc85 100644 --- a/docs/exchangelib/services/common.html +++ b/docs/exchangelib/services/common.html @@ -66,18 +66,9 @@

        Module exchangelib.services.common

        SOAPError, TransportError, ) -from ..folders import ArchiveRoot, BaseFolder, Folder, PublicFoldersRoot, Root, RootOfHierarchy +from ..folders import BaseFolder, Folder, RootOfHierarchy from ..items import BaseItem -from ..properties import ( - BaseItemId, - DistinguishedFolderId, - ExceptionFieldURI, - ExtendedFieldURI, - FieldURI, - FolderId, - IndexedFieldURI, - ItemId, -) +from ..properties import BaseItemId, ExceptionFieldURI, ExtendedFieldURI, FieldURI, FolderId, IndexedFieldURI, ItemId from ..transport import DEFAULT_ENCODING from ..util import ( ENS, @@ -618,6 +609,7 @@

        Module exchangelib.services.common

        # Raise any non-acceptable errors in the container, or return the container or the acceptable exception instance msg_text = get_xml_attr(message, f"{{{MNS}}}MessageText") msg_xml = message.find(f"{{{MNS}}}MessageXml") + rule_errors = message.find(f"{{{MNS}}}RuleOperationErrors") if response_class == "Warning": try: raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml) @@ -631,12 +623,12 @@

        Module exchangelib.services.common

        return container # response_class == 'Error', or 'Success' and not 'NoError' try: - raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml) + raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml, rule_errors=rule_errors) except self.ERRORS_TO_CATCH_IN_RESPONSE as e: return e @staticmethod - def _get_exception(code, text, msg_xml): + def _get_exception(code, text, msg_xml=None, rule_errors=None): """Parse error messages contained in EWS responses and raise as exceptions defined in this package.""" if not code: return TransportError(f"Empty ResponseCode in ResponseMessage (MessageText: {text}, MessageXml: {msg_xml})") @@ -674,6 +666,13 @@

        Module exchangelib.services.common

        except KeyError: # Inner code is unknown to us. Just append to the original text text += f" (inner error: {inner_code}({inner_text!r}))" + if rule_errors is not None: + for rule_error in rule_errors.findall(f"{{{TNS}}}RuleOperationError"): + for error in rule_error.find(f"{{{TNS}}}ValidationErrors").findall(f"{{{TNS}}}Error"): + field_uri = get_xml_attr(error, f"{{{TNS}}}FieldURI") + error_code = get_xml_attr(error, f"{{{TNS}}}ErrorCode") + error_message = get_xml_attr(error, f"{{{TNS}}}ErrorMessage") + text += f" ({error_code} on field {field_uri}: {error_message})" try: # Raise the error corresponding to the ResponseCode return vars(errors)[code](text) @@ -998,30 +997,12 @@

        Module exchangelib.services.common

        return _ids_element(items, AttachmentId, version, tag) -def parse_folder_elem(elem, folder, account): +def parse_folder_elem(elem, folder): if isinstance(folder, RootOfHierarchy): - f = folder.from_xml(elem=elem, account=folder.account) - elif isinstance(folder, Folder): - f = folder.from_xml_with_root(elem=elem, root=folder.root) - elif isinstance(folder, DistinguishedFolderId): - # We don't know the root or even account, but we need to attach the folder to something if we want to make - # future requests with this folder. Use 'account' but make sure to always use the distinguished folder ID going - # forward, instead of referencing anything connected to 'account'. - roots = (Root, ArchiveRoot, PublicFoldersRoot) - for cls in roots + tuple(chain(*(r.WELLKNOWN_FOLDERS for r in roots))): - if cls.DISTINGUISHED_FOLDER_ID == folder.id: - folder_cls = cls - break - else: - raise ValueError(f"Unknown distinguished folder ID: {folder.id}") - if folder_cls in roots: - f = folder_cls.from_xml(elem=elem, account=account) - else: - f = folder_cls.from_xml_with_root(elem=elem, root=account.root) - else: - # 'folder' is a generic FolderId instance. We don't know the root so assume account.root. - f = Folder.from_xml_with_root(elem=elem, root=account.root) - return f + return folder.from_xml(elem=elem, account=folder.account) + if isinstance(folder, Folder): + return folder.from_xml_with_root(elem=elem, root=folder.root) + raise ValueError(f"Unsupported folder class: {folder}")
        @@ -1071,7 +1052,7 @@

        Functions

        -def parse_folder_elem(elem, folder, account) +def parse_folder_elem(elem, folder)
        @@ -1079,30 +1060,12 @@

        Functions

        Expand source code -
        def parse_folder_elem(elem, folder, account):
        +
        def parse_folder_elem(elem, folder):
             if isinstance(folder, RootOfHierarchy):
        -        f = folder.from_xml(elem=elem, account=folder.account)
        -    elif isinstance(folder, Folder):
        -        f = folder.from_xml_with_root(elem=elem, root=folder.root)
        -    elif isinstance(folder, DistinguishedFolderId):
        -        # We don't know the root or even account, but we need to attach the folder to something if we want to make
        -        # future requests with this folder. Use 'account' but make sure to always use the distinguished folder ID going
        -        # forward, instead of referencing anything connected to 'account'.
        -        roots = (Root, ArchiveRoot, PublicFoldersRoot)
        -        for cls in roots + tuple(chain(*(r.WELLKNOWN_FOLDERS for r in roots))):
        -            if cls.DISTINGUISHED_FOLDER_ID == folder.id:
        -                folder_cls = cls
        -                break
        -        else:
        -            raise ValueError(f"Unknown distinguished folder ID: {folder.id}")
        -        if folder_cls in roots:
        -            f = folder_cls.from_xml(elem=elem, account=account)
        -        else:
        -            f = folder_cls.from_xml_with_root(elem=elem, root=account.root)
        -    else:
        -        # 'folder' is a generic FolderId instance. We don't know the root so assume account.root.
        -        f = Folder.from_xml_with_root(elem=elem, root=account.root)
        -    return f
        + return folder.from_xml(elem=elem, account=folder.account) + if isinstance(folder, Folder): + return folder.from_xml_with_root(elem=elem, root=folder.root) + raise ValueError(f"Unsupported folder class: {folder}")
        @@ -2017,6 +1980,7 @@

        Inherited members

        # Raise any non-acceptable errors in the container, or return the container or the acceptable exception instance msg_text = get_xml_attr(message, f"{{{MNS}}}MessageText") msg_xml = message.find(f"{{{MNS}}}MessageXml") + rule_errors = message.find(f"{{{MNS}}}RuleOperationErrors") if response_class == "Warning": try: raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml) @@ -2030,12 +1994,12 @@

        Inherited members

        return container # response_class == 'Error', or 'Success' and not 'NoError' try: - raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml) + raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml, rule_errors=rule_errors) except self.ERRORS_TO_CATCH_IN_RESPONSE as e: return e @staticmethod - def _get_exception(code, text, msg_xml): + def _get_exception(code, text, msg_xml=None, rule_errors=None): """Parse error messages contained in EWS responses and raise as exceptions defined in this package.""" if not code: return TransportError(f"Empty ResponseCode in ResponseMessage (MessageText: {text}, MessageXml: {msg_xml})") @@ -2073,6 +2037,13 @@

        Inherited members

        except KeyError: # Inner code is unknown to us. Just append to the original text text += f" (inner error: {inner_code}({inner_text!r}))" + if rule_errors is not None: + for rule_error in rule_errors.findall(f"{{{TNS}}}RuleOperationError"): + for error in rule_error.find(f"{{{TNS}}}ValidationErrors").findall(f"{{{TNS}}}Error"): + field_uri = get_xml_attr(error, f"{{{TNS}}}FieldURI") + error_code = get_xml_attr(error, f"{{{TNS}}}ErrorCode") + error_message = get_xml_attr(error, f"{{{TNS}}}ErrorMessage") + text += f" ({error_code} on field {field_uri}: {error_message})" try: # Raise the error corresponding to the ResponseCode return vars(errors)[code](text) diff --git a/docs/exchangelib/services/create_folder.html b/docs/exchangelib/services/create_folder.html index 887cfbda..3f40c723 100644 --- a/docs/exchangelib/services/create_folder.html +++ b/docs/exchangelib/services/create_folder.html @@ -59,7 +59,7 @@

        Module exchangelib.services.create_folder

        if isinstance(elem, Exception): yield elem continue - yield parse_folder_elem(elem=elem, folder=folder, account=self.account) + yield parse_folder_elem(elem=elem, folder=folder) def get_payload(self, folders, parent_folder): payload = create_element(f"m:{self.SERVICE_NAME}") @@ -120,7 +120,7 @@

        Classes

        if isinstance(elem, Exception): yield elem continue - yield parse_folder_elem(elem=elem, folder=folder, account=self.account) + yield parse_folder_elem(elem=elem, folder=folder) def get_payload(self, folders, parent_folder): payload = create_element(f"m:{self.SERVICE_NAME}") diff --git a/docs/exchangelib/services/get_folder.html b/docs/exchangelib/services/get_folder.html index 33454a4a..b9afda0e 100644 --- a/docs/exchangelib/services/get_folder.html +++ b/docs/exchangelib/services/get_folder.html @@ -80,7 +80,7 @@

        Module exchangelib.services.get_folder

        if isinstance(elem, Exception): yield elem continue - yield parse_folder_elem(elem=elem, folder=folder, account=self.account) + yield parse_folder_elem(elem=elem, folder=folder) def get_payload(self, folders, additional_fields, shape): payload = create_element(f"m:{self.SERVICE_NAME}") @@ -155,7 +155,7 @@

        Classes

        if isinstance(elem, Exception): yield elem continue - yield parse_folder_elem(elem=elem, folder=folder, account=self.account) + yield parse_folder_elem(elem=elem, folder=folder) def get_payload(self, folders, additional_fields, shape): payload = create_element(f"m:{self.SERVICE_NAME}") diff --git a/docs/exchangelib/services/get_user_settings.html b/docs/exchangelib/services/get_user_settings.html index 339f8d40..83745e5d 100644 --- a/docs/exchangelib/services/get_user_settings.html +++ b/docs/exchangelib/services/get_user_settings.html @@ -114,7 +114,7 @@

        Module exchangelib.services.get_user_settings

        @@ -216,7 +216,7 @@

        Classes

        return container # Raise any non-acceptable errors in the container, or return the acceptable exception instance try: - raise self._get_exception(code=res.error_code, text=res.error_message, msg_xml=None) + raise self._get_exception(code=res.error_code, text=res.error_message) except self.ERRORS_TO_CATCH_IN_RESPONSE as e: return e
        diff --git a/docs/exchangelib/services/index.html b/docs/exchangelib/services/index.html index 53555cf9..7d9aa443 100644 --- a/docs/exchangelib/services/index.html +++ b/docs/exchangelib/services/index.html @@ -797,7 +797,7 @@

        Inherited members

        if isinstance(elem, Exception): yield elem continue - yield parse_folder_elem(elem=elem, folder=folder, account=self.account) + yield parse_folder_elem(elem=elem, folder=folder) def get_payload(self, folders, parent_folder): payload = create_element(f"m:{self.SERVICE_NAME}") @@ -2234,6 +2234,7 @@

        Inherited members

        # Raise any non-acceptable errors in the container, or return the container or the acceptable exception instance msg_text = get_xml_attr(message, f"{{{MNS}}}MessageText") msg_xml = message.find(f"{{{MNS}}}MessageXml") + rule_errors = message.find(f"{{{MNS}}}RuleOperationErrors") if response_class == "Warning": try: raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml) @@ -2247,12 +2248,12 @@

        Inherited members

        return container # response_class == 'Error', or 'Success' and not 'NoError' try: - raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml) + raise self._get_exception(code=response_code, text=msg_text, msg_xml=msg_xml, rule_errors=rule_errors) except self.ERRORS_TO_CATCH_IN_RESPONSE as e: return e @staticmethod - def _get_exception(code, text, msg_xml): + def _get_exception(code, text, msg_xml=None, rule_errors=None): """Parse error messages contained in EWS responses and raise as exceptions defined in this package.""" if not code: return TransportError(f"Empty ResponseCode in ResponseMessage (MessageText: {text}, MessageXml: {msg_xml})") @@ -2290,6 +2291,13 @@

        Inherited members

        except KeyError: # Inner code is unknown to us. Just append to the original text text += f" (inner error: {inner_code}({inner_text!r}))" + if rule_errors is not None: + for rule_error in rule_errors.findall(f"{{{TNS}}}RuleOperationError"): + for error in rule_error.find(f"{{{TNS}}}ValidationErrors").findall(f"{{{TNS}}}Error"): + field_uri = get_xml_attr(error, f"{{{TNS}}}FieldURI") + error_code = get_xml_attr(error, f"{{{TNS}}}ErrorCode") + error_message = get_xml_attr(error, f"{{{TNS}}}ErrorMessage") + text += f" ({error_code} on field {field_uri}: {error_message})" try: # Raise the error corresponding to the ResponseCode return vars(errors)[code](text) @@ -4103,7 +4111,7 @@

        Inherited members

        if isinstance(elem, Exception): yield elem continue - yield parse_folder_elem(elem=elem, folder=folder, account=self.account) + yield parse_folder_elem(elem=elem, folder=folder) def get_payload(self, folders, additional_fields, shape): payload = create_element(f"m:{self.SERVICE_NAME}") @@ -5750,7 +5758,7 @@

        Inherited members

        return container # Raise any non-acceptable errors in the container, or return the acceptable exception instance try: - raise self._get_exception(code=res.error_code, text=res.error_message, msg_xml=None) + raise self._get_exception(code=res.error_code, text=res.error_message) except self.ERRORS_TO_CATCH_IN_RESPONSE as e: return e @@ -7079,7 +7087,7 @@

        Inherited members

        # We can't find() the element because we don't know which tag to look for. The change element can # contain multiple folder types, each with their own tag. folder_elem = elem[0] - folder = parse_folder_elem(elem=folder_elem, folder=self.folder, account=self.account) + folder = parse_folder_elem(elem=folder_elem, folder=self.folder) return change_type, folder def get_payload(self, folder, shape, additional_fields, sync_state): @@ -7487,7 +7495,7 @@

        Inherited members

        if isinstance(elem, Exception): yield elem continue - yield parse_folder_elem(elem=elem, folder=folder, account=self.account) + yield parse_folder_elem(elem=elem, folder=folder) @staticmethod def _target_elem(target): diff --git a/docs/exchangelib/services/sync_folder_hierarchy.html b/docs/exchangelib/services/sync_folder_hierarchy.html index d487a96b..4661aa43 100644 --- a/docs/exchangelib/services/sync_folder_hierarchy.html +++ b/docs/exchangelib/services/sync_folder_hierarchy.html @@ -114,7 +114,7 @@

        Module exchangelib.services.sync_folder_hierarchy # We can't find() the element because we don't know which tag to look for. The change element can # contain multiple folder types, each with their own tag. folder_elem = elem[0] - folder = parse_folder_elem(elem=folder_elem, folder=self.folder, account=self.account) + folder = parse_folder_elem(elem=folder_elem, folder=self.folder) return change_type, folder def get_payload(self, folder, shape, additional_fields, sync_state): @@ -290,7 +290,7 @@

        Inherited members

        # We can't find() the element because we don't know which tag to look for. The change element can # contain multiple folder types, each with their own tag. folder_elem = elem[0] - folder = parse_folder_elem(elem=folder_elem, folder=self.folder, account=self.account) + folder = parse_folder_elem(elem=folder_elem, folder=self.folder) return change_type, folder def get_payload(self, folder, shape, additional_fields, sync_state): diff --git a/docs/exchangelib/services/update_folder.html b/docs/exchangelib/services/update_folder.html index 8dae3d15..6323aaac 100644 --- a/docs/exchangelib/services/update_folder.html +++ b/docs/exchangelib/services/update_folder.html @@ -172,7 +172,7 @@

        Module exchangelib.services.update_folder

        if isinstance(elem, Exception): yield elem continue - yield parse_folder_elem(elem=elem, folder=folder, account=self.account) + yield parse_folder_elem(elem=elem, folder=folder) @staticmethod def _target_elem(target): @@ -396,7 +396,7 @@

        Inherited members

        if isinstance(elem, Exception): yield elem continue - yield parse_folder_elem(elem=elem, folder=folder, account=self.account) + yield parse_folder_elem(elem=elem, folder=folder) @staticmethod def _target_elem(target): diff --git a/docs/exchangelib/util.html b/docs/exchangelib/util.html index f0df38d6..666efd08 100644 --- a/docs/exchangelib/util.html +++ b/docs/exchangelib/util.html @@ -212,7 +212,7 @@

        Module exchangelib.util

        def value_to_xml_text(value): from .ewsdatetime import EWSDate, EWSDateTime, EWSTimeZone - from .indexed_properties import EmailAddress, PhoneNumber + from .indexed_properties import SingleFieldIndexedElement from .properties import AssociatedCalendarItemId, Attendee, ConversationId, Mailbox # We can't just create a map and look up with type(value) because we want to support subtypes @@ -228,21 +228,15 @@

        Module exchangelib.util

        return value.isoformat() if isinstance(value, EWSTimeZone): return value.ms_id - if isinstance(value, EWSDateTime): + if isinstance(value, (EWSDate, EWSDateTime)): return value.ewsformat() - if isinstance(value, EWSDate): - return value.ewsformat() - if isinstance(value, PhoneNumber): - return value.phone_number - if isinstance(value, EmailAddress): - return value.email + if isinstance(value, SingleFieldIndexedElement): + return getattr(value, value.value_field(version=None).name) if isinstance(value, Mailbox): return value.email_address if isinstance(value, Attendee): return value.mailbox.email_address - if isinstance(value, ConversationId): - return value.id - if isinstance(value, AssociatedCalendarItemId): + if isinstance(value, (ConversationId, AssociatedCalendarItemId)): return value.id raise TypeError(f"Unsupported type: {type(value)} ({value})") @@ -1580,7 +1574,7 @@

        Functions

        def value_to_xml_text(value):
             from .ewsdatetime import EWSDate, EWSDateTime, EWSTimeZone
        -    from .indexed_properties import EmailAddress, PhoneNumber
        +    from .indexed_properties import SingleFieldIndexedElement
             from .properties import AssociatedCalendarItemId, Attendee, ConversationId, Mailbox
         
             # We can't just create a map and look up with type(value) because we want to support subtypes
        @@ -1596,21 +1590,15 @@ 

        Functions

        return value.isoformat() if isinstance(value, EWSTimeZone): return value.ms_id - if isinstance(value, EWSDateTime): + if isinstance(value, (EWSDate, EWSDateTime)): return value.ewsformat() - if isinstance(value, EWSDate): - return value.ewsformat() - if isinstance(value, PhoneNumber): - return value.phone_number - if isinstance(value, EmailAddress): - return value.email + if isinstance(value, SingleFieldIndexedElement): + return getattr(value, value.value_field(version=None).name) if isinstance(value, Mailbox): return value.email_address if isinstance(value, Attendee): return value.mailbox.email_address - if isinstance(value, ConversationId): - return value.id - if isinstance(value, AssociatedCalendarItemId): + if isinstance(value, (ConversationId, AssociatedCalendarItemId)): return value.id raise TypeError(f"Unsupported type: {type(value)} ({value})")
        diff --git a/pyproject.toml b/pyproject.toml index e391c9b3..0064c682 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ # * Bump version in exchangelib/__init__.py # * Bump version in CHANGELOG.md # * Generate documentation: -# rm -r docs/exchangelib && pdoc3 --html exchangelib -o docs --force && pre-commit run end-of-file-fixer +# rm -r docs/exchangelib && pdoc3 --html exchangelib -o docs --force && git add docs && pre-commit run end-of-file-fixer # * Commit and push changes # * Build package: # rm -rf build dist exchangelib.egg-info && python -m build From 6ed66173754f33b41f389771495bc870fd2dc5da Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 2 May 2024 23:57:47 +0200 Subject: [PATCH 437/509] fix: allow deleting even though the rule is not valid. Refs #1305 --- exchangelib/properties.py | 7 ++++++- tests/test_account.py | 13 +++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/exchangelib/properties.py b/exchangelib/properties.py index ab3e16b2..593b157e 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -2302,8 +2302,13 @@ def save(self): def delete(self): if self.is_enabled is False: - # Cannot delete a disabled rule - server throws 'ErrorItemNotFound' + # Cannot delete a disabled rule - the server throws 'ErrorItemNotFound'. We need to enable it first. self.is_enabled = True + # Make sure we can save the rule by wiping all possibly-misconfigured fields + self.priority = 10**6 + self.conditions = None + self.exceptions = None + self.actions = Actions(stop_processing_rules=True) self.save() self.account.delete_rule(self) diff --git a/tests/test_account.py b/tests/test_account.py index 21e18e7c..a65f3a24 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -372,6 +372,19 @@ def test_basic_inbox_rule(self): rule.delete() self.assertNotIn(rule.display_name, {r.display_name for r in self.account.rules}) + def test_disabled_inbox_rule(self): + # Make sure we can delete a disabled rule + rule = Rule( + account=self.account, + display_name=get_random_string(16), + priority=10**6, + is_enabled=False, + actions=Actions(stop_processing_rules=True), + ) + rule.save() + rule.actions = Actions(forward_to_recipients=[[Address()]]) # Test with an invalid action + rule.delete() + def test_all_inbox_rule_actions(self): for action_name, action in { "assign_categories": ["foo", "bar"], From be0cd9f48997e31158690cc2cd5ea53fa9610ec5 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 8 May 2024 13:05:06 +0200 Subject: [PATCH 438/509] Mark some distinguished folders with supported_from based on feedback in #1301 --- exchangelib/folders/known_folders.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/exchangelib/folders/known_folders.py b/exchangelib/folders/known_folders.py index 73dcde99..9aa5b979 100644 --- a/exchangelib/folders/known_folders.py +++ b/exchangelib/folders/known_folders.py @@ -11,7 +11,7 @@ Task, ) from ..properties import EWSMeta -from ..version import EXCHANGE_2010_SP1, EXCHANGE_2013, EXCHANGE_2013_SP1 +from ..version import EXCHANGE_2010_SP1, EXCHANGE_2013, EXCHANGE_2013_SP1, EXCHANGE_O365 from .base import Folder from .collections import FolderCollection @@ -228,6 +228,7 @@ class AllItems(WellknownFolder): class AllCategorizedItems(WellknownFolder): DISTINGUISHED_FOLDER_ID = "allcategorizeditems" CONTAINER_CLASS = "IPF.Note" + supported_from = EXCHANGE_O365 class AllPersonMetadata(WellknownFolder): @@ -297,11 +298,12 @@ class Directory(WellknownFolder): class DlpPolicyEvaluation(WellknownFolder): DISTINGUISHED_FOLDER_ID = "dlppolicyevaluation" CONTAINER_CLASS = "IPF.StoreItem.DlpPolicyEvaluation" + supported_from = EXCHANGE_O365 class Favorites(WellknownFolder): - CONTAINER_CLASS = "IPF.Note" DISTINGUISHED_FOLDER_ID = "favorites" + CONTAINER_CLASS = "IPF.Note" supported_from = EXCHANGE_2013 @@ -313,16 +315,16 @@ class FolderMemberships(Folder): class FromFavoriteSenders(WellknownFolder): - CONTAINER_CLASS = "IPF.Note" DISTINGUISHED_FOLDER_ID = "fromfavoritesenders" + CONTAINER_CLASS = "IPF.Note" LOCALIZED_NAMES = { "da_DK": ("Personer jeg kender",), } class IMContactList(WellknownFolder): - CONTAINER_CLASS = "IPF.Contact.MOC.ImContactList" DISTINGUISHED_FOLDER_ID = "imcontactlist" + CONTAINER_CLASS = "IPF.Contact.MOC.ImContactList" supported_from = EXCHANGE_2013 @@ -367,6 +369,7 @@ class Notes(WellknownFolder): class OneNotePagePreviews(WellknownFolder): DISTINGUISHED_FOLDER_ID = "onenotepagepreviews" + supported_from = EXCHANGE_O365 class PeopleCentricConversationBuddies(WellknownFolder): @@ -384,31 +387,37 @@ class PeopleConnect(WellknownFolder): class QedcDefaultRetention(WellknownFolder): DISTINGUISHED_FOLDER_ID = "qedcdefaultretention" + supported_from = EXCHANGE_O365 class QedcLongRetention(WellknownFolder): DISTINGUISHED_FOLDER_ID = "qedclongretention" + supported_from = EXCHANGE_O365 class QedcMediumRetention(WellknownFolder): DISTINGUISHED_FOLDER_ID = "qedcmediumretention" + supported_from = EXCHANGE_O365 class QedcShortRetention(WellknownFolder): DISTINGUISHED_FOLDER_ID = "qedcshortretention" + supported_from = EXCHANGE_O365 class QuarantinedEmail(WellknownFolder): DISTINGUISHED_FOLDER_ID = "quarantinedemail" + supported_from = EXCHANGE_O365 class QuarantinedEmailDefaultCategory(WellknownFolder): DISTINGUISHED_FOLDER_ID = "quarantinedemaildefaultcategory" + supported_from = EXCHANGE_O365 class QuickContacts(WellknownFolder): - CONTAINER_CLASS = "IPF.Contact.MOC.QuickContacts" DISTINGUISHED_FOLDER_ID = "quickcontacts" + CONTAINER_CLASS = "IPF.Contact.MOC.QuickContacts" supported_from = EXCHANGE_2013 @@ -446,7 +455,7 @@ class RecoverableItemsRoot(WellknownFolder): class RecoverableItemsSubstrateHolds(WellknownFolder): DISTINGUISHED_FOLDER_ID = "recoverableitemssubstrateholds" - supported_from = EXCHANGE_2010_SP1 + supported_from = EXCHANGE_O365 LOCALIZED_NAMES = { None: ("SubstrateHolds",), } @@ -472,6 +481,7 @@ class SharePointNotifications(WellknownFolder): class ShortNotes(WellknownFolder): DISTINGUISHED_FOLDER_ID = "shortnotes" + supported_from = EXCHANGE_O365 class SyncIssues(WellknownFolder): @@ -485,8 +495,8 @@ class TemporarySaves(WellknownFolder): class ToDoSearch(WellknownFolder): - CONTAINER_CLASS = "IPF.Task" DISTINGUISHED_FOLDER_ID = "todosearch" + CONTAINER_CLASS = "IPF.Task" supported_from = EXCHANGE_2013 LOCALIZED_NAMES = { None: ("To-Do Search",), @@ -494,8 +504,9 @@ class ToDoSearch(WellknownFolder): class UserCuratedContacts(WellknownFolder): - CONTAINER_CLASS = "IPF.Note" DISTINGUISHED_FOLDER_ID = "usercuratedcontacts" + CONTAINER_CLASS = "IPF.Note" + supported_from = EXCHANGE_O365 class VoiceMail(WellknownFolder): From ae3bff05fa0e1bb2b0fa965ecaa7e66611dfd8cf Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 13 May 2024 15:29:12 +0200 Subject: [PATCH 439/509] Add two more folders that have appeared on test account --- exchangelib/folders/__init__.py | 10 +++++++--- exchangelib/folders/known_folders.py | 28 ++++++++++++++++++---------- tests/test_folder.py | 6 ++++++ 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/exchangelib/folders/__init__.py b/exchangelib/folders/__init__.py index 928f219c..17f8064a 100644 --- a/exchangelib/folders/__init__.py +++ b/exchangelib/folders/__init__.py @@ -21,6 +21,7 @@ Birthdays, Calendar, CalendarLogging, + CalendarSearchCache, CommonViews, Companies, Conflicts, @@ -68,6 +69,7 @@ PdpProfileV2Secured, PeopleCentricConversationBuddies, PeopleConnect, + PersonMetadata, QedcDefaultRetention, QedcLongRetention, QedcMediumRetention, @@ -134,6 +136,7 @@ "Birthdays", "Calendar", "CalendarLogging", + "CalendarSearchCache", "CommonViews", "Companies", "Conflicts", @@ -189,13 +192,14 @@ "PdpProfileV2Secured", "PeopleCentricConversationBuddies", "PeopleConnect", + "PersonMetadata", + "PublicFoldersRoot", "QedcDefaultRetention", - "QedcMediumRetention", "QedcLongRetention", + "QedcMediumRetention", "QedcShortRetention", "QuarantinedEmail", "QuarantinedEmailDefaultCategory", - "PublicFoldersRoot", "QuickContacts", "RSSFeeds", "RecipientCache", @@ -217,8 +221,8 @@ "ServerFailures", "SharePointNotifications", "Sharing", - "Shortcuts", "ShortNotes", + "Shortcuts", "Signal", "SingleFolderQuerySet", "SkypeTeamsMessages", diff --git a/exchangelib/folders/known_folders.py b/exchangelib/folders/known_folders.py index 9aa5b979..fb611dd7 100644 --- a/exchangelib/folders/known_folders.py +++ b/exchangelib/folders/known_folders.py @@ -525,17 +525,7 @@ def is_deletable(self): return False -class ExternalContacts(NonDeletableFolder): - DISTINGUISHED_FOLDER_ID = None - CONTAINER_CLASS = "IPF.Contact" - supported_item_models = (Contact, DistributionList) - LOCALIZED_NAMES = { - None: ("ExternalContacts",), - } - - class AllTodoTasks(NonDeletableFolder): - DISTINGUISHED_FOLDER_ID = None CONTAINER_CLASS = "IPF.Task" supported_item_models = (Task,) LOCALIZED_NAMES = { @@ -557,6 +547,10 @@ class CalendarLogging(NonDeletableFolder): } +class CalendarSearchCache(NonDeletableFolder): + CONTAINER_CLASS = "IPF.Appointment" + + class CommonViews(NonDeletableFolder): DEFAULT_ITEM_TRAVERSAL_DEPTH = ASSOCIATED LOCALIZED_NAMES = { @@ -585,6 +579,14 @@ class ExchangeSyncData(NonDeletableFolder): pass +class ExternalContacts(NonDeletableFolder): + CONTAINER_CLASS = "IPF.Contact" + supported_item_models = (Contact, DistributionList) + LOCALIZED_NAMES = { + None: ("ExternalContacts",), + } + + class Files(NonDeletableFolder): CONTAINER_CLASS = "IPF.Files" LOCALIZED_NAMES = { @@ -650,6 +652,10 @@ class PassThroughSearchResults(NonDeletableFolder): } +class PersonMetadata(NonDeletableFolder): + CONTAINER_CLASS = "IPF.Contact" + + class PdpProfileV2Secured(NonDeletableFolder): CONTAINER_CLASS = "IPF.StoreItem.PdpProfileSecured" @@ -719,6 +725,7 @@ class WorkingSet(NonDeletableFolder): AllTodoTasks, Audits, CalendarLogging, + CalendarSearchCache, CommonViews, ConversationSettings, DefaultFoldersChangeHistory, @@ -736,6 +743,7 @@ class WorkingSet(NonDeletableFolder): OrganizationalContacts, ParkedMessages, PassThroughSearchResults, + PersonMetadata, PdpProfileV2Secured, Reminders, RSSFeeds, diff --git a/tests/test_folder.py b/tests/test_folder.py index a6fc7ce5..bdc6bf35 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -32,6 +32,7 @@ BaseFolder, Birthdays, Calendar, + CalendarSearchCache, CommonViews, Companies, Contacts, @@ -69,6 +70,7 @@ PassThroughSearchResults, PdpProfileV2Secured, PeopleCentricConversationBuddies, + PersonMetadata, PublicFoldersRoot, QuickContacts, RecipientCache, @@ -593,6 +595,10 @@ def test_folder_grouping(self): self.assertEqual(f.folder_class, "IPF.SkypeTeams.Message") elif isinstance(f, SmsAndChatsSync): self.assertEqual(f.folder_class, "IPF.SmsAndChatsSync") + elif isinstance(f, PersonMetadata): + self.assertEqual(f.folder_class, "IPF.Contact") + elif isinstance(f, CalendarSearchCache): + self.assertEqual(f.folder_class, "IPF.Appointment") else: self.assertIn(f.folder_class, (None, "IPF"), (f.name, f.__class__.__name__, f.folder_class)) self.assertIsInstance(f, Folder) From 1de9757296d96d6027c36c58aa46885934b09b6c Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 13 May 2024 15:30:25 +0200 Subject: [PATCH 440/509] Only request DistinguishedFolderId on servers that support that field. Fixes #1306 --- exchangelib/folders/base.py | 6 ++++-- exchangelib/services/common.py | 13 +++++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 703c47de..9030173e 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -40,7 +40,7 @@ ) from ..queryset import DoesNotExist, SearchableMixIn from ..util import TNS, is_iterable, require_id -from ..version import EXCHANGE_2007_SP1, EXCHANGE_2010, SupportedVersionClassMixIn +from ..version import EXCHANGE_2007_SP1, EXCHANGE_2010, EXCHANGE_2016, SupportedVersionClassMixIn from .collections import FolderCollection, PullSubscription, PushSubscription, StreamingSubscription, SyncCompleted from .queryset import DEEP as DEEP_FOLDERS from .queryset import MISSING_FOLDER_ERRORS @@ -77,7 +77,9 @@ class BaseFolder(RegisterMixIn, SearchableMixIn, SupportedVersionClassMixIn, met ID_ELEMENT_CLS = FolderId _id = IdElementField(field_uri="folder:FolderId", value_cls=ID_ELEMENT_CLS) - _distinguished_id = IdElementField(field_uri="folder:DistinguishedFolderId", value_cls=DistinguishedFolderId) + _distinguished_id = IdElementField( + field_uri="folder:DistinguishedFolderId", value_cls=DistinguishedFolderId, supported_from=EXCHANGE_2016 + ) parent_folder_id = EWSElementField(field_uri="folder:ParentFolderId", value_cls=ParentFolderId, is_read_only=True) folder_class = CharField(field_uri="folder:FolderClass", is_required_after_save=True) name = CharField(field_uri="folder:DisplayName") diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 29547438..40684d96 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -971,7 +971,12 @@ def attachment_ids_element(items, version, tag="m:AttachmentIds"): def parse_folder_elem(elem, folder): if isinstance(folder, RootOfHierarchy): - return folder.from_xml(elem=elem, account=folder.account) - if isinstance(folder, Folder): - return folder.from_xml_with_root(elem=elem, root=folder.root) - raise ValueError(f"Unsupported folder class: {folder}") + res = folder.from_xml(elem=elem, account=folder.account) + elif isinstance(folder, Folder): + res = folder.from_xml_with_root(elem=elem, root=folder.root) + else: + raise ValueError(f"Unsupported folder class: {folder}") + # Not all servers support fetching the DistinguishedFolderId field. Add it back here. + if folder._distinguished_id and not res._distinguished_id: + res._distinguished_id = folder._distinguished_id + return res From 138697ee0d9ebd18b19840f7db62c24bb344c4c3 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 13 May 2024 17:59:18 +0200 Subject: [PATCH 441/509] These are actually distinguished folders. --- exchangelib/folders/known_folders.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/exchangelib/folders/known_folders.py b/exchangelib/folders/known_folders.py index fb611dd7..a378b88f 100644 --- a/exchangelib/folders/known_folders.py +++ b/exchangelib/folders/known_folders.py @@ -271,6 +271,11 @@ class ArchiveRecoverableItemsVersions(WellknownFolder): supported_from = EXCHANGE_2010_SP1 +class CalendarSearchCache(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "calendarsearchcache" + CONTAINER_CLASS = "IPF.Appointment" + + class Companies(WellknownFolder): DISTINGUISHED_FOLDER_ID = "companycontacts" CONTAINER_CLASS = "IPF.Contact.Company" @@ -385,6 +390,11 @@ class PeopleConnect(WellknownFolder): supported_from = EXCHANGE_2013 +class PersonMetadata(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "personmetadata" + CONTAINER_CLASS = "IPF.Contact" + + class QedcDefaultRetention(WellknownFolder): DISTINGUISHED_FOLDER_ID = "qedcdefaultretention" supported_from = EXCHANGE_O365 @@ -547,10 +557,6 @@ class CalendarLogging(NonDeletableFolder): } -class CalendarSearchCache(NonDeletableFolder): - CONTAINER_CLASS = "IPF.Appointment" - - class CommonViews(NonDeletableFolder): DEFAULT_ITEM_TRAVERSAL_DEPTH = ASSOCIATED LOCALIZED_NAMES = { @@ -652,10 +658,6 @@ class PassThroughSearchResults(NonDeletableFolder): } -class PersonMetadata(NonDeletableFolder): - CONTAINER_CLASS = "IPF.Contact" - - class PdpProfileV2Secured(NonDeletableFolder): CONTAINER_CLASS = "IPF.StoreItem.PdpProfileSecured" @@ -725,7 +727,6 @@ class WorkingSet(NonDeletableFolder): AllTodoTasks, Audits, CalendarLogging, - CalendarSearchCache, CommonViews, ConversationSettings, DefaultFoldersChangeHistory, @@ -743,7 +744,6 @@ class WorkingSet(NonDeletableFolder): OrganizationalContacts, ParkedMessages, PassThroughSearchResults, - PersonMetadata, PdpProfileV2Secured, Reminders, RSSFeeds, @@ -767,6 +767,7 @@ class WorkingSet(NonDeletableFolder): AllItems, AllPersonMetadata, Calendar, + CalendarSearchCache, Companies, Conflicts, Contacts, @@ -790,6 +791,7 @@ class WorkingSet(NonDeletableFolder): Outbox, PeopleCentricConversationBuddies, PeopleConnect, + PersonMetadata, QedcDefaultRetention, QedcLongRetention, QedcMediumRetention, From 1e779610628631d150a852ae847a856d9db0354a Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 13 May 2024 20:00:04 +0200 Subject: [PATCH 442/509] Only allow values on the DistinguishedFolderId field if we recognize the ID. Refs #1301 --- exchangelib/services/common.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 40684d96..08d3fabf 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -979,4 +979,8 @@ def parse_folder_elem(elem, folder): # Not all servers support fetching the DistinguishedFolderId field. Add it back here. if folder._distinguished_id and not res._distinguished_id: res._distinguished_id = folder._distinguished_id + # Some servers return folders in a FindFolder result that have a DistinguishedFolderId element that the same server + # cannot handle in a GetFolder request. Only set the DistinguishedFolderId field if we recognize the ID. + elif res._distinguished_id and not folder.DISTINGUISHED_FOLDER_ID: + res._distinguished_id = None return res From f9fde28d9b2185ecb070f07ac1a378ed9a4aaab7 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 13 May 2024 23:29:23 +0200 Subject: [PATCH 443/509] chore: Sort __all_ entries --- exchangelib/__init__.py | 2 +- exchangelib/autodiscover/__init__.py | 4 +- exchangelib/items/__init__.py | 98 ++++++++++++++-------------- exchangelib/services/__init__.py | 12 ++-- 4 files changed, 58 insertions(+), 58 deletions(-) diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index 4c3308d5..9eba4311 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -39,7 +39,6 @@ __version__ = "5.3.0" __all__ = [ - "__version__", "AcceptItem", "Account", "Attendee", @@ -101,6 +100,7 @@ "UTC", "UTC_NOW", "Version", + "__version__", "close_connections", "discover", ] diff --git a/exchangelib/autodiscover/__init__.py b/exchangelib/autodiscover/__init__.py index acc00a8e..ae869fd6 100644 --- a/exchangelib/autodiscover/__init__.py +++ b/exchangelib/autodiscover/__init__.py @@ -17,8 +17,8 @@ def clear_cache(): "AutodiscoverCache", "AutodiscoverProtocol", "Autodiscovery", - "discover", "autodiscover_cache", - "close_connections", "clear_cache", + "close_connections", + "discover", ] diff --git a/exchangelib/items/__init__.py b/exchangelib/items/__init__.py index d924fadb..cec2e245 100644 --- a/exchangelib/items/__init__.py +++ b/exchangelib/items/__init__.py @@ -78,64 +78,64 @@ ) __all__ = [ - "RegisterMixIn", - "MESSAGE_DISPOSITION_CHOICES", - "SAVE_ONLY", - "SEND_ONLY", - "SEND_AND_SAVE_COPY", - "CalendarItem", - "AcceptItem", - "TentativelyAcceptItem", - "DeclineItem", - "CancelCalendarItem", - "MeetingRequest", - "MeetingResponse", - "MeetingCancellation", - "CONFERENCE_TYPES", - "Contact", - "Persona", - "DistributionList", - "SEND_MEETING_INVITATIONS_CHOICES", - "SEND_TO_NONE", - "SEND_ONLY_TO_ALL", - "SEND_TO_ALL_AND_SAVE_COPY", - "SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES", - "SEND_ONLY_TO_CHANGED", - "SEND_TO_CHANGED_AND_SAVE_COPY", - "SEND_MEETING_CANCELLATIONS_CHOICES", + "ACTIVE_DIRECTORY", + "ACTIVE_DIRECTORY_CONTACTS", "AFFECTED_TASK_OCCURRENCES_CHOICES", "ALL_OCCURRENCES", - "SPECIFIED_OCCURRENCE_ONLY", - "CONFLICT_RESOLUTION_CHOICES", - "NEVER_OVERWRITE", - "AUTO_RESOLVE", + "ALL_PROPERTIES", "ALWAYS_OVERWRITE", + "ASSOCIATED", + "AUTO_RESOLVE", + "AcceptItem", + "BaseItem", + "BulkCreateResult", + "CONFERENCE_TYPES", + "CONFLICT_RESOLUTION_CHOICES", + "CONTACTS", + "CONTACTS_ACTIVE_DIRECTORY", + "CalendarItem", + "CancelCalendarItem", + "Contact", + "DEFAULT", "DELETE_TYPE_CHOICES", + "DeclineItem", + "DistributionList", + "ForwardItem", "HARD_DELETE", - "SOFT_DELETE", - "MOVE_TO_DELETED_ITEMS", - "BaseItem", + "ID_ONLY", + "ITEM_CLASSES", + "ITEM_TRAVERSAL_CHOICES", "Item", - "BulkCreateResult", + "MESSAGE_DISPOSITION_CHOICES", + "MOVE_TO_DELETED_ITEMS", + "MeetingCancellation", + "MeetingRequest", + "MeetingResponse", "Message", - "ReplyToItem", - "ReplyAllToItem", - "ForwardItem", + "NEVER_OVERWRITE", + "Persona", "PostItem", "PostReplyItem", - "Task", - "ITEM_TRAVERSAL_CHOICES", + "RegisterMixIn", + "ReplyAllToItem", + "ReplyToItem", + "SAVE_ONLY", + "SEARCH_SCOPE_CHOICES", + "SEND_AND_SAVE_COPY", + "SEND_MEETING_CANCELLATIONS_CHOICES", + "SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES", + "SEND_MEETING_INVITATIONS_CHOICES", + "SEND_ONLY", + "SEND_ONLY_TO_ALL", + "SEND_ONLY_TO_CHANGED", + "SEND_TO_ALL_AND_SAVE_COPY", + "SEND_TO_CHANGED_AND_SAVE_COPY", + "SEND_TO_NONE", "SHALLOW", - "SOFT_DELETED", - "ASSOCIATED", "SHAPE_CHOICES", - "ID_ONLY", - "DEFAULT", - "ALL_PROPERTIES", - "SEARCH_SCOPE_CHOICES", - "ACTIVE_DIRECTORY", - "ACTIVE_DIRECTORY_CONTACTS", - "CONTACTS", - "CONTACTS_ACTIVE_DIRECTORY", - "ITEM_CLASSES", + "SOFT_DELETE", + "SOFT_DELETED", + "SPECIFIED_OCCURRENCE_ONLY", + "Task", + "TentativelyAcceptItem", ] diff --git a/exchangelib/services/__init__.py b/exchangelib/services/__init__.py index 77a22f21..aa4b9d76 100644 --- a/exchangelib/services/__init__.py +++ b/exchangelib/services/__init__.py @@ -64,14 +64,16 @@ "CopyItem", "CreateAttachment", "CreateFolder", + "CreateInboxRule", "CreateItem", "CreateUserConfiguration", "DeleteAttachment", "DeleteFolder", - "DeleteUserConfiguration", + "DeleteInboxRule", "DeleteItem", - "EmptyFolder", + "DeleteUserConfiguration", "EWSService", + "EmptyFolder", "ExpandDL", "ExportItems", "FindFolder", @@ -81,6 +83,7 @@ "GetDelegate", "GetEvents", "GetFolder", + "GetInboxRules", "GetItem", "GetMailTips", "GetPersona", @@ -99,6 +102,7 @@ "ResolveNames", "SendItem", "SendNotification", + "SetInboxRule", "SetUserOofSettings", "SubscribeToPull", "SubscribeToPush", @@ -110,8 +114,4 @@ "UpdateItem", "UpdateUserConfiguration", "UploadItems", - "GetInboxRules", - "CreateInboxRule", - "SetInboxRule", - "DeleteInboxRule", ] From 3fc789013e1c3b0549ab931443e5b92b7faa47c3 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Mon, 13 May 2024 23:30:45 +0200 Subject: [PATCH 444/509] fix: These folders aren't distinguished even though the response XML says so --- exchangelib/folders/known_folders.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/exchangelib/folders/known_folders.py b/exchangelib/folders/known_folders.py index a378b88f..fb611dd7 100644 --- a/exchangelib/folders/known_folders.py +++ b/exchangelib/folders/known_folders.py @@ -271,11 +271,6 @@ class ArchiveRecoverableItemsVersions(WellknownFolder): supported_from = EXCHANGE_2010_SP1 -class CalendarSearchCache(WellknownFolder): - DISTINGUISHED_FOLDER_ID = "calendarsearchcache" - CONTAINER_CLASS = "IPF.Appointment" - - class Companies(WellknownFolder): DISTINGUISHED_FOLDER_ID = "companycontacts" CONTAINER_CLASS = "IPF.Contact.Company" @@ -390,11 +385,6 @@ class PeopleConnect(WellknownFolder): supported_from = EXCHANGE_2013 -class PersonMetadata(WellknownFolder): - DISTINGUISHED_FOLDER_ID = "personmetadata" - CONTAINER_CLASS = "IPF.Contact" - - class QedcDefaultRetention(WellknownFolder): DISTINGUISHED_FOLDER_ID = "qedcdefaultretention" supported_from = EXCHANGE_O365 @@ -557,6 +547,10 @@ class CalendarLogging(NonDeletableFolder): } +class CalendarSearchCache(NonDeletableFolder): + CONTAINER_CLASS = "IPF.Appointment" + + class CommonViews(NonDeletableFolder): DEFAULT_ITEM_TRAVERSAL_DEPTH = ASSOCIATED LOCALIZED_NAMES = { @@ -658,6 +652,10 @@ class PassThroughSearchResults(NonDeletableFolder): } +class PersonMetadata(NonDeletableFolder): + CONTAINER_CLASS = "IPF.Contact" + + class PdpProfileV2Secured(NonDeletableFolder): CONTAINER_CLASS = "IPF.StoreItem.PdpProfileSecured" @@ -727,6 +725,7 @@ class WorkingSet(NonDeletableFolder): AllTodoTasks, Audits, CalendarLogging, + CalendarSearchCache, CommonViews, ConversationSettings, DefaultFoldersChangeHistory, @@ -744,6 +743,7 @@ class WorkingSet(NonDeletableFolder): OrganizationalContacts, ParkedMessages, PassThroughSearchResults, + PersonMetadata, PdpProfileV2Secured, Reminders, RSSFeeds, @@ -767,7 +767,6 @@ class WorkingSet(NonDeletableFolder): AllItems, AllPersonMetadata, Calendar, - CalendarSearchCache, Companies, Conflicts, Contacts, @@ -791,7 +790,6 @@ class WorkingSet(NonDeletableFolder): Outbox, PeopleCentricConversationBuddies, PeopleConnect, - PersonMetadata, QedcDefaultRetention, QedcLongRetention, QedcMediumRetention, From fbb8c45a8cfefddd8d50c0e2a9f9898b5b42ab0c Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 14 May 2024 00:04:40 +0200 Subject: [PATCH 445/509] chore: Sort classes in the known_folders module --- exchangelib/folders/known_folders.py | 376 +++++++++++++-------------- exchangelib/items/__init__.py | 6 + tests/test_folder.py | 13 +- 3 files changed, 197 insertions(+), 198 deletions(-) diff --git a/exchangelib/folders/known_folders.py b/exchangelib/folders/known_folders.py index fb611dd7..0fb49518 100644 --- a/exchangelib/folders/known_folders.py +++ b/exchangelib/folders/known_folders.py @@ -1,14 +1,10 @@ from ..items import ( ASSOCIATED, + CONTACT_ITEM_CLASSES, ITEM_CLASSES, + MESSAGE_ITEM_CLASSES, + TASK_ITEM_CLASSES, CalendarItem, - Contact, - DistributionList, - MeetingCancellation, - MeetingRequest, - MeetingResponse, - Message, - Task, ) from ..properties import EWSMeta from ..version import EXCHANGE_2010_SP1, EXCHANGE_2013, EXCHANGE_2013_SP1, EXCHANGE_O365 @@ -56,165 +52,18 @@ class WellknownFolder(Folder, metaclass=EWSMeta): supported_item_models = ITEM_CLASSES -class Messages(WellknownFolder): - CONTAINER_CLASS = "IPF.Note" - supported_item_models = (Message, MeetingRequest, MeetingResponse, MeetingCancellation) - - -class Calendar(WellknownFolder): - """An interface for the Exchange calendar.""" - - DISTINGUISHED_FOLDER_ID = "calendar" - CONTAINER_CLASS = "IPF.Appointment" - supported_item_models = (CalendarItem,) - LOCALIZED_NAMES = { - "da_DK": ("Kalender",), - "de_DE": ("Kalender",), - "en_US": ("Calendar",), - "es_ES": ("Calendario",), - "fr_CA": ("Calendrier",), - "nl_NL": ("Agenda",), - "ru_RU": ("Календарь",), - "sv_SE": ("Kalender",), - "zh_CN": ("日历",), - } - - def view(self, *args, **kwargs): - return FolderCollection(account=self.account, folders=[self]).view(*args, **kwargs) - - -class Contacts(WellknownFolder): - DISTINGUISHED_FOLDER_ID = "contacts" - CONTAINER_CLASS = "IPF.Contact" - supported_item_models = (Contact, DistributionList) - LOCALIZED_NAMES = { - "da_DK": ("Kontaktpersoner",), - "de_DE": ("Kontakte",), - "en_US": ("Contacts",), - "es_ES": ("Contactos",), - "fr_CA": ("Contacts",), - "nl_NL": ("Contactpersonen",), - "ru_RU": ("Контакты",), - "sv_SE": ("Kontakter",), - "zh_CN": ("联系人",), - } - - -class DeletedItems(WellknownFolder): - DISTINGUISHED_FOLDER_ID = "deleteditems" - CONTAINER_CLASS = "IPF.Note" - supported_item_models = ITEM_CLASSES - LOCALIZED_NAMES = { - "da_DK": ("Slettet post",), - "de_DE": ("Gelöschte Elemente",), - "en_US": ("Deleted Items",), - "es_ES": ("Elementos eliminados",), - "fr_CA": ("Éléments supprimés",), - "nl_NL": ("Verwijderde items",), - "ru_RU": ("Удаленные",), - "sv_SE": ("Borttaget",), - "zh_CN": ("已删除邮件",), - } - - -class Drafts(Messages): - DISTINGUISHED_FOLDER_ID = "drafts" - LOCALIZED_NAMES = { - "da_DK": ("Kladder",), - "de_DE": ("Entwürfe",), - "en_US": ("Drafts",), - "es_ES": ("Borradores",), - "fr_CA": ("Brouillons",), - "nl_NL": ("Concepten",), - "ru_RU": ("Черновики",), - "sv_SE": ("Utkast",), - "zh_CN": ("草稿",), - } - - -class Inbox(Messages): - DISTINGUISHED_FOLDER_ID = "inbox" - LOCALIZED_NAMES = { - "da_DK": ("Indbakke",), - "de_DE": ("Posteingang",), - "en_US": ("Inbox",), - "es_ES": ("Bandeja de entrada",), - "fr_CA": ("Boîte de réception",), - "nl_NL": ("Postvak IN",), - "ru_RU": ("Входящие",), - "sv_SE": ("Inkorgen",), - "zh_CN": ("收件箱",), - } - - -class JunkEmail(Messages): - DISTINGUISHED_FOLDER_ID = "junkemail" - LOCALIZED_NAMES = { - "da_DK": ("Uønsket e-mail",), - "de_DE": ("Junk-E-Mail",), - "en_US": ("Junk E-mail",), - "es_ES": ("Correo no deseado",), - "fr_CA": ("Courrier indésirables",), - "nl_NL": ("Ongewenste e-mail",), - "ru_RU": ("Нежелательная почта",), - "sv_SE": ("Skräppost",), - "zh_CN": ("垃圾邮件",), - } - - -class Outbox(Messages): - DISTINGUISHED_FOLDER_ID = "outbox" - LOCALIZED_NAMES = { - "da_DK": ("Udbakke",), - "de_DE": ("Postausgang",), - "en_US": ("Outbox",), - "es_ES": ("Bandeja de salida",), - "fr_CA": ("Boîte d'envoi",), - "nl_NL": ("Postvak UIT",), - "ru_RU": ("Исходящие",), - "sv_SE": ("Utkorgen",), - "zh_CN": ("发件箱",), - } - - -class SentItems(Messages): - DISTINGUISHED_FOLDER_ID = "sentitems" - LOCALIZED_NAMES = { - "da_DK": ("Sendt post",), - "de_DE": ("Gesendete Elemente",), - "en_US": ("Sent Items",), - "es_ES": ("Elementos enviados",), - "fr_CA": ("Éléments envoyés",), - "nl_NL": ("Verzonden items",), - "ru_RU": ("Отправленные",), - "sv_SE": ("Skickat",), - "zh_CN": ("已发送邮件",), - } - - -class Tasks(WellknownFolder): - DISTINGUISHED_FOLDER_ID = "tasks" - CONTAINER_CLASS = "IPF.Task" - supported_item_models = (Task,) - LOCALIZED_NAMES = { - "da_DK": ("Opgaver",), - "de_DE": ("Aufgaben",), - "en_US": ("Tasks",), - "es_ES": ("Tareas",), - "fr_CA": ("Tâches",), - "nl_NL": ("Taken",), - "ru_RU": ("Задачи",), - "sv_SE": ("Uppgifter",), - "zh_CN": ("任务",), - } - - class AdminAuditLogs(WellknownFolder): DISTINGUISHED_FOLDER_ID = "adminauditlogs" supported_from = EXCHANGE_2013 get_folder_allowed = False +class AllCategorizedItems(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "allcategorizeditems" + CONTAINER_CLASS = "IPF.Note" + supported_from = EXCHANGE_O365 + + class AllContacts(WellknownFolder): DISTINGUISHED_FOLDER_ID = "allcontacts" CONTAINER_CLASS = "IPF.Note" @@ -225,12 +74,6 @@ class AllItems(WellknownFolder): CONTAINER_CLASS = "IPF" -class AllCategorizedItems(WellknownFolder): - DISTINGUISHED_FOLDER_ID = "allcategorizeditems" - CONTAINER_CLASS = "IPF.Note" - supported_from = EXCHANGE_O365 - - class AllPersonMetadata(WellknownFolder): DISTINGUISHED_FOLDER_ID = "allpersonmetadata" CONTAINER_CLASS = "IPF.Note" @@ -271,10 +114,32 @@ class ArchiveRecoverableItemsVersions(WellknownFolder): supported_from = EXCHANGE_2010_SP1 +class Calendar(WellknownFolder): + """An interface for the Exchange calendar.""" + + DISTINGUISHED_FOLDER_ID = "calendar" + CONTAINER_CLASS = "IPF.Appointment" + supported_item_models = (CalendarItem,) + LOCALIZED_NAMES = { + "da_DK": ("Kalender",), + "de_DE": ("Kalender",), + "en_US": ("Calendar",), + "es_ES": ("Calendario",), + "fr_CA": ("Calendrier",), + "nl_NL": ("Agenda",), + "ru_RU": ("Календарь",), + "sv_SE": ("Kalender",), + "zh_CN": ("日历",), + } + + def view(self, *args, **kwargs): + return FolderCollection(account=self.account, folders=[self]).view(*args, **kwargs) + + class Companies(WellknownFolder): DISTINGUISHED_FOLDER_ID = "companycontacts" CONTAINER_CLASS = "IPF.Contact.Company" - supported_item_models = (Contact, DistributionList) + supported_item_models = CONTACT_ITEM_CLASSES LOCALIZED_NAMES = { "da_DK": ("Firmaer",), } @@ -285,11 +150,45 @@ class Conflicts(WellknownFolder): supported_from = EXCHANGE_2013 +class Contacts(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "contacts" + CONTAINER_CLASS = "IPF.Contact" + supported_item_models = CONTACT_ITEM_CLASSES + LOCALIZED_NAMES = { + "da_DK": ("Kontaktpersoner",), + "de_DE": ("Kontakte",), + "en_US": ("Contacts",), + "es_ES": ("Contactos",), + "fr_CA": ("Contacts",), + "nl_NL": ("Contactpersonen",), + "ru_RU": ("Контакты",), + "sv_SE": ("Kontakter",), + "zh_CN": ("联系人",), + } + + class ConversationHistory(WellknownFolder): DISTINGUISHED_FOLDER_ID = "conversationhistory" supported_from = EXCHANGE_2013 +class DeletedItems(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "deleteditems" + CONTAINER_CLASS = "IPF.Note" + supported_item_models = ITEM_CLASSES + LOCALIZED_NAMES = { + "da_DK": ("Slettet post",), + "de_DE": ("Gelöschte Elemente",), + "en_US": ("Deleted Items",), + "es_ES": ("Elementos eliminados",), + "fr_CA": ("Éléments supprimés",), + "nl_NL": ("Verwijderde items",), + "ru_RU": ("Удаленные",), + "sv_SE": ("Borttaget",), + "zh_CN": ("已删除邮件",), + } + + class Directory(WellknownFolder): DISTINGUISHED_FOLDER_ID = "directory" supported_from = EXCHANGE_2013_SP1 @@ -301,6 +200,23 @@ class DlpPolicyEvaluation(WellknownFolder): supported_from = EXCHANGE_O365 +class Drafts(WellknownFolder): + CONTAINER_CLASS = "IPF.Note" + DISTINGUISHED_FOLDER_ID = "drafts" + supported_item_models = MESSAGE_ITEM_CLASSES + LOCALIZED_NAMES = { + "da_DK": ("Kladder",), + "de_DE": ("Entwürfe",), + "en_US": ("Drafts",), + "es_ES": ("Borradores",), + "fr_CA": ("Brouillons",), + "nl_NL": ("Concepten",), + "ru_RU": ("Черновики",), + "sv_SE": ("Utkast",), + "zh_CN": ("草稿",), + } + + class Favorites(WellknownFolder): DISTINGUISHED_FOLDER_ID = "favorites" CONTAINER_CLASS = "IPF.Note" @@ -328,6 +244,23 @@ class IMContactList(WellknownFolder): supported_from = EXCHANGE_2013 +class Inbox(WellknownFolder): + CONTAINER_CLASS = "IPF.Note" + DISTINGUISHED_FOLDER_ID = "inbox" + supported_item_models = MESSAGE_ITEM_CLASSES + LOCALIZED_NAMES = { + "da_DK": ("Indbakke",), + "de_DE": ("Posteingang",), + "en_US": ("Inbox",), + "es_ES": ("Bandeja de entrada",), + "fr_CA": ("Boîte de réception",), + "nl_NL": ("Postvak IN",), + "ru_RU": ("Входящие",), + "sv_SE": ("Inkorgen",), + "zh_CN": ("收件箱",), + } + + class Inference(WellknownFolder): DISTINGUISHED_FOLDER_ID = "inference" @@ -337,11 +270,33 @@ class Journal(WellknownFolder): DISTINGUISHED_FOLDER_ID = "journal" +class JunkEmail(WellknownFolder): + CONTAINER_CLASS = "IPF.Note" + DISTINGUISHED_FOLDER_ID = "junkemail" + supported_item_models = MESSAGE_ITEM_CLASSES + LOCALIZED_NAMES = { + "da_DK": ("Uønsket e-mail",), + "de_DE": ("Junk-E-Mail",), + "en_US": ("Junk E-mail",), + "es_ES": ("Correo no deseado",), + "fr_CA": ("Courrier indésirables",), + "nl_NL": ("Ongewenste e-mail",), + "ru_RU": ("Нежелательная почта",), + "sv_SE": ("Skräppost",), + "zh_CN": ("垃圾邮件",), + } + + class LocalFailures(WellknownFolder): DISTINGUISHED_FOLDER_ID = "localfailures" supported_from = EXCHANGE_2013 +class Messages(WellknownFolder): + CONTAINER_CLASS = "IPF.Note" + supported_item_models = MESSAGE_ITEM_CLASSES + + class MsgFolderRoot(WellknownFolder): """Also known as the 'Top of Information Store' folder.""" @@ -372,6 +327,21 @@ class OneNotePagePreviews(WellknownFolder): supported_from = EXCHANGE_O365 +class Outbox(Messages): + DISTINGUISHED_FOLDER_ID = "outbox" + LOCALIZED_NAMES = { + "da_DK": ("Udbakke",), + "de_DE": ("Postausgang",), + "en_US": ("Outbox",), + "es_ES": ("Bandeja de salida",), + "fr_CA": ("Boîte d'envoi",), + "nl_NL": ("Postvak UIT",), + "ru_RU": ("Исходящие",), + "sv_SE": ("Utkorgen",), + "zh_CN": ("发件箱",), + } + + class PeopleCentricConversationBuddies(WellknownFolder): DISTINGUISHED_FOLDER_ID = "peoplecentricconversationbuddies" CONTAINER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies" @@ -425,17 +395,11 @@ class RecipientCache(WellknownFolder): DISTINGUISHED_FOLDER_ID = "recipientcache" CONTAINER_CLASS = "IPF.Contact.RecipientCache" supported_from = EXCHANGE_2013 - LOCALIZED_NAMES = { - None: ("RecipientCache",), - } class RelevantContacts(WellknownFolder): DISTINGUISHED_FOLDER_ID = "relevantcontacts" CONTAINER_CLASS = "IPF.Note" - LOCALIZED_NAMES = { - None: ("RelevantContacts",), - } class RecoverableItemsDeletions(WellknownFolder): @@ -470,6 +434,21 @@ class SearchFolders(WellknownFolder): DISTINGUISHED_FOLDER_ID = "searchfolders" +class SentItems(Messages): + DISTINGUISHED_FOLDER_ID = "sentitems" + LOCALIZED_NAMES = { + "da_DK": ("Sendt post",), + "de_DE": ("Gesendete Elemente",), + "en_US": ("Sent Items",), + "es_ES": ("Elementos enviados",), + "fr_CA": ("Éléments envoyés",), + "nl_NL": ("Verzonden items",), + "ru_RU": ("Отправленные",), + "sv_SE": ("Skickat",), + "zh_CN": ("已发送邮件",), + } + + class ServerFailures(WellknownFolder): DISTINGUISHED_FOLDER_ID = "serverfailures" supported_from = EXCHANGE_2013 @@ -490,6 +469,23 @@ class SyncIssues(WellknownFolder): supported_from = EXCHANGE_2013 +class Tasks(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "tasks" + CONTAINER_CLASS = "IPF.Task" + supported_item_models = TASK_ITEM_CLASSES + LOCALIZED_NAMES = { + "da_DK": ("Opgaver",), + "de_DE": ("Aufgaben",), + "en_US": ("Tasks",), + "es_ES": ("Tareas",), + "fr_CA": ("Tâches",), + "nl_NL": ("Taken",), + "ru_RU": ("Задачи",), + "sv_SE": ("Uppgifter",), + "zh_CN": ("任务",), + } + + class TemporarySaves(WellknownFolder): DISTINGUISHED_FOLDER_ID = "temporarysaves" @@ -527,10 +523,7 @@ def is_deletable(self): class AllTodoTasks(NonDeletableFolder): CONTAINER_CLASS = "IPF.Task" - supported_item_models = (Task,) - LOCALIZED_NAMES = { - None: ("AllTodoTasks",), - } + supported_item_models = TASK_ITEM_CLASSES class ApplicationData(NonDeletableFolder): @@ -581,10 +574,7 @@ class ExchangeSyncData(NonDeletableFolder): class ExternalContacts(NonDeletableFolder): CONTAINER_CLASS = "IPF.Contact" - supported_item_models = (Contact, DistributionList) - LOCALIZED_NAMES = { - None: ("ExternalContacts",), - } + supported_item_models = CONTACT_ITEM_CLASSES class Files(NonDeletableFolder): @@ -602,7 +592,7 @@ class FreebusyData(NonDeletableFolder): class Friends(NonDeletableFolder): CONTAINER_CLASS = "IPF.Note" - supported_item_models = (Contact, DistributionList) + supported_item_models = CONTACT_ITEM_CLASSES LOCALIZED_NAMES = { "de_DE": ("Bekannte",), } @@ -610,7 +600,7 @@ class Friends(NonDeletableFolder): class GALContacts(NonDeletableFolder): CONTAINER_CLASS = "IPF.Contact.GalContacts" - supported_item_models = (Contact, DistributionList) + supported_item_models = CONTACT_ITEM_CLASSES LOCALIZED_NAMES = { None: ("GAL Contacts",), } @@ -630,12 +620,12 @@ class MailboxAssociations(NonDeletableFolder): class MyContactsExtended(NonDeletableFolder): CONTAINER_CLASS = "IPF.Note" - supported_item_models = (Contact, DistributionList) + supported_item_models = CONTACT_ITEM_CLASSES class OrganizationalContacts(NonDeletableFolder): CONTAINER_CLASS = "IPF.Contact.OrganizationalContacts" - supported_item_models = (Contact, DistributionList) + supported_item_models = CONTACT_ITEM_CLASSES LOCALIZED_NAMES = { None: ("Organizational Contacts",), } @@ -721,8 +711,8 @@ class WorkingSet(NonDeletableFolder): # Folders that do not have a distinguished folder ID but return 'ErrorDeleteDistinguishedFolder' or # 'ErrorCannotDeleteObject' when we try to delete them. I can't find any official docs listing these folders. NON_DELETABLE_FOLDERS = [ - ApplicationData, AllTodoTasks, + ApplicationData, Audits, CalendarLogging, CalendarSearchCache, @@ -732,8 +722,8 @@ class WorkingSet(NonDeletableFolder): DeferredAction, ExchangeSyncData, ExternalContacts, - FreebusyData, Files, + FreebusyData, Friends, GALContacts, GraphAnalytics, @@ -743,10 +733,10 @@ class WorkingSet(NonDeletableFolder): OrganizationalContacts, ParkedMessages, PassThroughSearchResults, - PersonMetadata, PdpProfileV2Secured, - Reminders, + PersonMetadata, RSSFeeds, + Reminders, Schedule, Sharing, Shortcuts, @@ -778,8 +768,8 @@ class WorkingSet(NonDeletableFolder): Favorites, FromFavoriteSenders, IMContactList, - Inference, Inbox, + Inference, Journal, JunkEmail, LocalFailures, @@ -798,12 +788,12 @@ class WorkingSet(NonDeletableFolder): QuarantinedEmailDefaultCategory, QuickContacts, RecipientCache, - RelevantContacts, RecoverableItemsDeletions, RecoverableItemsPurges, RecoverableItemsRoot, RecoverableItemsSubstrateHolds, RecoverableItemsVersions, + RelevantContacts, SearchFolders, SentItems, ServerFailures, @@ -830,12 +820,12 @@ class WorkingSet(NonDeletableFolder): # Folders that do not have a distinguished ID but have their own container class MISC_FOLDERS = [ + Birthdays, CrawlerData, EventCheckPoints, FolderMemberships, FreeBusyCache, RecoveryPoints, - SwssItems, SkypeTeamsMessages, - Birthdays, + SwssItems, ] diff --git a/exchangelib/items/__init__.py b/exchangelib/items/__init__.py index cec2e245..4fbc05a1 100644 --- a/exchangelib/items/__init__.py +++ b/exchangelib/items/__init__.py @@ -76,6 +76,9 @@ PostItem, Task, ) +TASK_ITEM_CLASSES = (Task,) +CONTACT_ITEM_CLASSES = (Contact, DistributionList) +MESSAGE_ITEM_CLASSES = (Message, MeetingRequest, MeetingResponse, MeetingCancellation) __all__ = [ "ACTIVE_DIRECTORY", @@ -91,6 +94,7 @@ "BulkCreateResult", "CONFERENCE_TYPES", "CONFLICT_RESOLUTION_CHOICES", + "CONTACT_ITEM_CLASSES", "CONTACTS", "CONTACTS_ACTIVE_DIRECTORY", "CalendarItem", @@ -107,7 +111,9 @@ "ITEM_TRAVERSAL_CHOICES", "Item", "MESSAGE_DISPOSITION_CHOICES", + "MESSAGE_ITEM_CLASSES", "MOVE_TO_DELETED_ITEMS", + "TASK_ITEM_CLASSES", "MeetingCancellation", "MeetingRequest", "MeetingResponse", diff --git a/tests/test_folder.py b/tests/test_folder.py index bdc6bf35..1cb832c0 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -511,18 +511,21 @@ def test_folder_grouping(self): if isinstance( f, ( - Messages, - DeletedItems, AllCategorizedItems, AllContacts, AllPersonMetadata, - MyContactsExtended, - Sharing, + DeletedItems, + Drafts, Favorites, FromFavoriteSenders, + Inbox, + JunkEmail, + Messages, + MyContacts, + MyContactsExtended, RelevantContacts, + Sharing, SyncIssues, - MyContacts, UserCuratedContacts, ), ): From 17ea29a6569eb7c6dcdcb1f81c9ba3b017b9f2d9 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 14 May 2024 00:06:07 +0200 Subject: [PATCH 446/509] fix: move _distinguished_id wiping into the from_xml_with_root method. Fixes #1301 --- exchangelib/folders/base.py | 4 ++++ exchangelib/services/common.py | 4 ---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 9030173e..89f2c314 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -946,4 +946,8 @@ def from_xml_with_root(cls, elem, root): ) if folder_cls == Folder: log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) + # Some servers return folders in a FindFolder result that have a DistinguishedFolderId element that the same + # server cannot handle in a GetFolder request. Only set the DistinguishedFolderId field if we recognize the ID. + if folder._distinguished_id and not folder_cls.DISTINGUISHED_FOLDER_ID: + folder._distinguished_id = None return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS}) diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 08d3fabf..40684d96 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -979,8 +979,4 @@ def parse_folder_elem(elem, folder): # Not all servers support fetching the DistinguishedFolderId field. Add it back here. if folder._distinguished_id and not res._distinguished_id: res._distinguished_id = folder._distinguished_id - # Some servers return folders in a FindFolder result that have a DistinguishedFolderId element that the same server - # cannot handle in a GetFolder request. Only set the DistinguishedFolderId field if we recognize the ID. - elif res._distinguished_id and not folder.DISTINGUISHED_FOLDER_ID: - res._distinguished_id = None return res From c33040cf407d1be8ab9c22b29e641b0f76bc3d60 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 14 May 2024 00:12:40 +0200 Subject: [PATCH 447/509] test: Assert that we get the expected master ID after refreshing --- tests/test_items/test_calendaritems.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_items/test_calendaritems.py b/tests/test_items/test_calendaritems.py index 889209d8..30b484f7 100644 --- a/tests/test_items/test_calendaritems.py +++ b/tests/test_items/test_calendaritems.py @@ -494,6 +494,7 @@ def test_get_master_recurrence(self): master_from_occurrence = third_occurrence.recurring_master() master_from_occurrence.refresh() # Test that GetItem works + self.assertEqual(master_from_occurrence.id, master_item.id) self.assertEqual(master_from_occurrence.recurrence, recurrence) self.assertEqual(master_from_occurrence.subject, master_item.subject) From d971534d39dba4e177026f18d458ce4a2808f427 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 14 May 2024 00:23:27 +0200 Subject: [PATCH 448/509] ci: 3.13-dev is available for testing --- .github/workflows/python-package.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index d73782ed..99aa2745 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -30,10 +30,10 @@ jobs: strategy: matrix: python-version: ['3.8', '3.12'] - #include: - # # Allow failure on Python dev - e.g. Cython install regularly fails - # - python-version: "3.13-dev" - # allowed_failure: true + include: + # Allow failure on Python dev - e.g. Cython install regularly fails + - python-version: "3.13-dev" + allowed_failure: true max-parallel: 1 steps: From cc6e27628153b794a07cb96c38da2565003c1fee Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 14 May 2024 15:34:43 +0200 Subject: [PATCH 449/509] docs: fix code brackets --- docs/index.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index a702744d..502150eb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,6 +50,7 @@ Apart from this documentation, we also provide online * [Non-account services](#non-account-services) * [Troubleshooting](#troubleshooting) * [Tests](#tests) +* [Contributing](#contributing) ## Installation @@ -2308,12 +2309,13 @@ DEBUG=1 python -m unittest -k test_folder.FolderTest.test_refresh # Running tests in parallel using the 'unittest-parallel' dependency unittest-parallel -j 4 --level=class +``` ## Contributing This repo uses pre-commit hooks. Before committing changes, install the hooks: -````bash +```bash pip install pre-commit pre-commit install pre-commit run From e3d544c45b847ce89acc599d791ac78ff8794ecf Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 14 May 2024 15:35:48 +0200 Subject: [PATCH 450/509] feat: Add the 'msal' install flavor --- docs/index.md | 9 ++++++--- pyproject.toml | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/index.md b/docs/index.md index 502150eb..59590c80 100644 --- a/docs/index.md +++ b/docs/index.md @@ -60,16 +60,17 @@ You can install this package from PyPI: pip install exchangelib ``` -The default installation does not support Kerberos or SSPI. For additional Kerberos or SSPI support, -install with the extra `kerberos` or `sspi` dependencies (please note that SSPI is only supported on +The default installation does not support Kerberos, MSAL or SSPI authentication. For additional support for these, +install with the extra `kerberos`, `msal` or `sspi` dependencies (please note that SSPI is only supported on Windows): ```bash pip install exchangelib[kerberos] +pip install exchangelib[msal] pip install exchangelib[sspi] ``` -To get both, install as: +To get all of the above, install as: ```bash pip install exchangelib[complete] @@ -497,6 +498,8 @@ supports obtaining OAuth tokens via a range of different methods. You can use MSAL to fetch a token valid for EWS and then only provide the token to this library. +Note that MSAL is not supported by default. See [Installation](#installation) for installing with MSAL support. + In this example, we'll do an interactive login using a browser window, so you can use the login method supported by your organization, and take advantage of any SSO available to your browser. There are example scripts for other flows diff --git a/pyproject.toml b/pyproject.toml index 0064c682..5bcf3ef2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,8 +62,9 @@ Changelog = "https://github.com/ecederstrand/exchangelib/blob/master/CHANGELOG.m [project.optional-dependencies] kerberos = ["requests_gssapi"] +msal = ["msal"] sspi = ["requests_negotiate_sspi"] -complete = ["requests_gssapi", "requests_negotiate_sspi"] +complete = ["requests_gssapi", "msal", "requests_negotiate_sspi"] [tool.setuptools.dynamic] version = {attr = "exchangelib.__version__"} From 54ba36cffd95390cf9dfbb96b160069191dcdb29 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 14 May 2024 15:38:03 +0200 Subject: [PATCH 451/509] feat: Add O365 MSAL auth helper to reduce boilerplate --- docs/index.md | 32 +++++--------------------------- exchangelib/__init__.py | 3 ++- exchangelib/configuration.py | 10 +++++++++- exchangelib/credentials.py | 13 +++++++++++++ 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/docs/index.md b/docs/index.md index 59590c80..415670ee 100644 --- a/docs/index.md +++ b/docs/index.md @@ -513,36 +513,14 @@ using the following code: ```python # Script adapted from https://github.com/AzureAD/microsoft-authentication-library-for-python/blob/dev/sample/interactive_sample.py -from exchangelib import ( - Configuration, - OAUTH2, - Account, - DELEGATE, - OAuth2AuthorizationCodeCredentials, -) -import msal - -config = { - "authority": "https://login.microsoftonline.com/organizations", - "client_id": "MY_CLIENT_ID", - "scope": ["EWS.AccessAsUser.All"], - "username": "MY_ACCOUNT@example.com", - "account": "MY_ACCOUNT@example.com", - "server": "outlook.office365.com", -} -app = msal.PublicClientApplication(config["client_id"], authority=config["authority"]) -print("A local browser window will be open for you to sign in. CTRL+C to cancel.") -result = app.acquire_token_interactive( - config["scope"], login_hint=config.get("username") -) -assert "access_token" in result +from exchangelib import DELEGATE, Account, O365InteractiveConfiguration -creds = OAuth2AuthorizationCodeCredentials(access_token=result) -conf = Configuration(server=config["server"], auth_type=OAUTH2, credentials=creds) a = Account( - primary_smtp_address=config["account"], + primary_smtp_address="MY_ACCOUNT@example.com", + config=O365InteractiveConfiguration( + client_id="MY_CLIENT_ID", username="MY_ACCOUNT@example.com" + ), access_type=DELEGATE, - config=conf, autodiscover=False, ) print(a.root.tree()) diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index 9eba4311..8fd2e4c6 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -1,7 +1,7 @@ from .account import Account, Identity from .attachments import FileAttachment, ItemAttachment from .autodiscover import discover -from .configuration import Configuration +from .configuration import Configuration, O365InteractiveConfiguration from .credentials import ( DELEGATE, IMPERSONATION, @@ -78,6 +78,7 @@ "Message", "NTLM", "NoVerifyHTTPAdapter", + "O365InteractiveConfiguration", "OAUTH2", "OAuth2AuthorizationCodeCredentials", "OAuth2Credentials", diff --git a/exchangelib/configuration.py b/exchangelib/configuration.py index 2c32cacc..9cd45337 100644 --- a/exchangelib/configuration.py +++ b/exchangelib/configuration.py @@ -2,7 +2,7 @@ from cached_property import threaded_cached_property -from .credentials import BaseCredentials, BaseOAuth2Credentials +from .credentials import BaseCredentials, BaseOAuth2Credentials, O365InteractiveCredentials from .errors import InvalidEnumValue, InvalidTypeError from .protocol import FailFast, RetryPolicy from .transport import AUTH_TYPE_MAP, CREDENTIALS_REQUIRED, OAUTH2 @@ -96,3 +96,11 @@ def __repr__(self): for k in ("credentials", "service_endpoint", "auth_type", "version", "retry_policy") ) return f"{self.__class__.__name__}({args_str})" + + +class O365InteractiveConfiguration(Configuration): + SERVER = "outlook.office365.com" + + def __init__(self, client_id, username): + credentials = O365InteractiveCredentials(client_id=client_id, username=username) + super().__init__(server=self.SERVER, auth_type=OAUTH2, credentials=credentials) diff --git a/exchangelib/credentials.py b/exchangelib/credentials.py index c2154b4e..711aeb38 100644 --- a/exchangelib/credentials.py +++ b/exchangelib/credentials.py @@ -307,3 +307,16 @@ def __str__(self): ) description = " ".join(filter(None, [client_id, credential])) return description or "[underspecified credentials]" + + +class O365InteractiveCredentials(OAuth2AuthorizationCodeCredentials): + AUTHORITY = "https://login.microsoftonline.com/organizations" + SCOPE = ["EWS.AccessAsUser.All"] + + def __init__(self, client_id, username): + import msal + + app = msal.PublicClientApplication(client_id=client_id, authority=self.AUTHORITY) + print("A local browser window will be open for you to sign in. CTRL+C to cancel.") + access_token = app.acquire_token_interactive(self.SCOPE, login_hint=username) + super().__init__(access_token=access_token) From 39df56afc8fa46a1db4c16971fb24e6a1b7e40de Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 14 May 2024 22:18:45 +0200 Subject: [PATCH 452/509] Bump version --- CHANGELOG.md | 7 +++++++ exchangelib/__init__.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdd589e0..e320bffb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ HEAD ---- +5.4.0 +----- +- Add `O365InteractiveConfiguration` helper class to set up MSAL auth for O365. +- Add `exchangelib[msal]` installation flavor to match the above. +- Various bug fixes related to distinguished folders. + + 5.3.0 ----- - Fix various issues related to public folders and archive folders diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index 8fd2e4c6..561a24be 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -36,7 +36,7 @@ from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "5.3.0" +__version__ = "5.4.0" __all__ = [ "AcceptItem", From 73d3167aa3ce81167581de769e7e4e58f7993990 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Tue, 14 May 2024 22:21:42 +0200 Subject: [PATCH 453/509] docs: Update docs --- docs/exchangelib/autodiscover/index.html | 4 +- docs/exchangelib/configuration.html | 69 +- docs/exchangelib/credentials.html | 96 +- docs/exchangelib/ewsdatetime.html | 2 +- docs/exchangelib/folders/base.html | 22 +- docs/exchangelib/folders/index.html | 579 +++++++---- docs/exchangelib/folders/known_folders.html | 1008 +++++++++++-------- docs/exchangelib/index.html | 78 +- docs/exchangelib/items/index.html | 104 +- docs/exchangelib/properties.html | 21 +- docs/exchangelib/services/common.html | 26 +- docs/exchangelib/services/index.html | 12 +- pyproject.toml | 2 +- 13 files changed, 1368 insertions(+), 655 deletions(-) diff --git a/docs/exchangelib/autodiscover/index.html b/docs/exchangelib/autodiscover/index.html index e6e83246..03e865b6 100644 --- a/docs/exchangelib/autodiscover/index.html +++ b/docs/exchangelib/autodiscover/index.html @@ -45,10 +45,10 @@

        Module exchangelib.autodiscover

        "AutodiscoverCache", "AutodiscoverProtocol", "Autodiscovery", - "discover", "autodiscover_cache", - "close_connections", "clear_cache", + "close_connections", + "discover", ]
        diff --git a/docs/exchangelib/configuration.html b/docs/exchangelib/configuration.html index 48c91443..d524188d 100644 --- a/docs/exchangelib/configuration.html +++ b/docs/exchangelib/configuration.html @@ -30,7 +30,7 @@

        Module exchangelib.configuration

        from cached_property import threaded_cached_property -from .credentials import BaseCredentials, BaseOAuth2Credentials +from .credentials import BaseCredentials, BaseOAuth2Credentials, O365InteractiveCredentials from .errors import InvalidEnumValue, InvalidTypeError from .protocol import FailFast, RetryPolicy from .transport import AUTH_TYPE_MAP, CREDENTIALS_REQUIRED, OAUTH2 @@ -123,7 +123,15 @@

        Module exchangelib.configuration

        f"{k}={getattr(self, k)!r}" for k in ("credentials", "service_endpoint", "auth_type", "version", "retry_policy") ) - return f"{self.__class__.__name__}({args_str})" + return f"{self.__class__.__name__}({args_str})" + + +class O365InteractiveConfiguration(Configuration): + SERVER = "outlook.office365.com" + + def __init__(self, client_id, username): + credentials = O365InteractiveCredentials(client_id=client_id, username=username) + super().__init__(server=self.SERVER, auth_type=OAUTH2, credentials=credentials)
        @@ -248,6 +256,10 @@

        Classes

        ) return f"{self.__class__.__name__}({args_str})" +

        Subclasses

        +

        Instance variables

        var credentials
        @@ -288,6 +300,53 @@

        Instance variables

        +
        +class O365InteractiveConfiguration +(client_id, username) +
        +
        +

        Contains information needed to create an authenticated connection to an EWS endpoint.

        +

        The 'credentials' argument contains the credentials needed to authenticate with the server. Multiple credentials +implementations are available in 'exchangelib.credentials'.

        +

        config = Configuration(credentials=Credentials('john@example.com', 'MY_SECRET'), …)

        +

        The 'server' and 'service_endpoint' arguments are mutually exclusive. The former must contain only a domain name, +the latter a full URL:

        +
        config = Configuration(server='example.com', ...)
        +config = Configuration(service_endpoint='https://mail.example.com/EWS/Exchange.asmx', ...)
        +
        +

        If you know which authentication type the server uses, you add that as a hint in 'auth_type'. Likewise, you can +add the server version as a hint. This allows to skip the auth type and version guessing routines:

        +
        config = Configuration(auth_type=NTLM, ...)
        +config = Configuration(version=Version(build=Build(15, 1, 2, 3)), ...)
        +
        +

        You can use 'retry_policy' to define a custom retry policy for handling server connection failures:

        +
        config = Configuration(retry_policy=FaultTolerance(max_wait=3600), ...)
        +
        +

        'max_connections' defines the max number of connections allowed for this server. This may be restricted by +policies on the Exchange server.

        +
        + +Expand source code + +
        class O365InteractiveConfiguration(Configuration):
        +    SERVER = "outlook.office365.com"
        +
        +    def __init__(self, client_id, username):
        +        credentials = O365InteractiveCredentials(client_id=client_id, username=username)
        +        super().__init__(server=self.SERVER, auth_type=OAUTH2, credentials=credentials)
        +
        +

        Ancestors

        + +

        Class variables

        +
        +
        var SERVER
        +
        +
        +
        +
        +
        @@ -311,6 +370,12 @@

        server

      +
    • +

      O365InteractiveConfiguration

      + +
  • diff --git a/docs/exchangelib/credentials.html b/docs/exchangelib/credentials.html index 71e9e0d9..a6df53cb 100644 --- a/docs/exchangelib/credentials.html +++ b/docs/exchangelib/credentials.html @@ -339,7 +339,20 @@

    Module exchangelib.credentials

    else ("[authorization_code]" if self.authorization_code is not None else None) ) description = " ".join(filter(None, [client_id, credential])) - return description or "[underspecified credentials]" + return description or "[underspecified credentials]" + + +class O365InteractiveCredentials(OAuth2AuthorizationCodeCredentials): + AUTHORITY = "https://login.microsoftonline.com/organizations" + SCOPE = ["EWS.AccessAsUser.All"] + + def __init__(self, client_id, username): + import msal + + app = msal.PublicClientApplication(client_id=client_id, authority=self.AUTHORITY) + print("A local browser window will be open for you to sign in. CTRL+C to cancel.") + access_token = app.acquire_token_interactive(self.SCOPE, login_hint=username) + super().__init__(access_token=access_token)
    @@ -802,6 +815,76 @@

    Class variables

    +
    +class O365InteractiveCredentials +(client_id, username) +
    +
    +

    Login info for OAuth 2.0 authentication using the authorization code grant type. This can be used in one of +several ways: +* Given an authorization code, client ID, and client secret, fetch a token ourselves and refresh it as needed if +supplied with a refresh token. +* Given an existing access token, client ID, and client secret, use the access token until it expires and then +refresh it as needed. +* Given only an existing access token, use it until it expires. This can be used to let the calling application +refresh tokens itself by subclassing and implementing refresh().

    +

    Unlike the base (client credentials) grant, authorization code credentials don't require a Microsoft tenant ID +because each access token (and the authorization code used to get the access token) is restricted to a single +tenant.

    +

    :param authorization_code: Code obtained when authorizing the application to access an account. In combination +with client_id and client_secret, will be used to obtain an access token.

    +
    + +Expand source code + +
    class O365InteractiveCredentials(OAuth2AuthorizationCodeCredentials):
    +    AUTHORITY = "https://login.microsoftonline.com/organizations"
    +    SCOPE = ["EWS.AccessAsUser.All"]
    +
    +    def __init__(self, client_id, username):
    +        import msal
    +
    +        app = msal.PublicClientApplication(client_id=client_id, authority=self.AUTHORITY)
    +        print("A local browser window will be open for you to sign in. CTRL+C to cancel.")
    +        access_token = app.acquire_token_interactive(self.SCOPE, login_hint=username)
    +        super().__init__(access_token=access_token)
    +
    +

    Ancestors

    + +

    Class variables

    +
    +
    var AUTHORITY
    +
    +
    +
    +
    var SCOPE
    +
    +
    +
    +
    +

    Inherited members

    + +
    class OAuth2AuthorizationCodeCredentials (authorization_code=None, **kwargs) @@ -887,6 +970,10 @@

    Ancestors

  • BaseOAuth2Credentials
  • BaseCredentials
  • +

    Subclasses

    +

    Inherited members

    -

    string -> datetime from datetime.isoformat() output

    +

    string -> datetime from a string in most ISO 8601 formats

    Expand source code diff --git a/docs/exchangelib/folders/base.html b/docs/exchangelib/folders/base.html index 8c39d417..32a5ba63 100644 --- a/docs/exchangelib/folders/base.html +++ b/docs/exchangelib/folders/base.html @@ -68,7 +68,7 @@

    Module exchangelib.folders.base

    ) from ..queryset import DoesNotExist, SearchableMixIn from ..util import TNS, is_iterable, require_id -from ..version import EXCHANGE_2007_SP1, EXCHANGE_2010, SupportedVersionClassMixIn +from ..version import EXCHANGE_2007_SP1, EXCHANGE_2010, EXCHANGE_2016, SupportedVersionClassMixIn from .collections import FolderCollection, PullSubscription, PushSubscription, StreamingSubscription, SyncCompleted from .queryset import DEEP as DEEP_FOLDERS from .queryset import MISSING_FOLDER_ERRORS @@ -105,7 +105,9 @@

    Module exchangelib.folders.base

    ID_ELEMENT_CLS = FolderId _id = IdElementField(field_uri="folder:FolderId", value_cls=ID_ELEMENT_CLS) - _distinguished_id = IdElementField(field_uri="folder:DistinguishedFolderId", value_cls=DistinguishedFolderId) + _distinguished_id = IdElementField( + field_uri="folder:DistinguishedFolderId", value_cls=DistinguishedFolderId, supported_from=EXCHANGE_2016 + ) parent_folder_id = EWSElementField(field_uri="folder:ParentFolderId", value_cls=ParentFolderId, is_read_only=True) folder_class = CharField(field_uri="folder:FolderClass", is_required_after_save=True) name = CharField(field_uri="folder:DisplayName") @@ -972,6 +974,10 @@

    Module exchangelib.folders.base

    ) if folder_cls == Folder: log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) + # Some servers return folders in a FindFolder result that have a DistinguishedFolderId element that the same + # server cannot handle in a GetFolder request. Only set the DistinguishedFolderId field if we recognize the ID. + if folder._distinguished_id and not folder_cls.DISTINGUISHED_FOLDER_ID: + folder._distinguished_id = None return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS})
    @@ -1014,7 +1020,9 @@

    Classes

    ID_ELEMENT_CLS = FolderId _id = IdElementField(field_uri="folder:FolderId", value_cls=ID_ELEMENT_CLS) - _distinguished_id = IdElementField(field_uri="folder:DistinguishedFolderId", value_cls=DistinguishedFolderId) + _distinguished_id = IdElementField( + field_uri="folder:DistinguishedFolderId", value_cls=DistinguishedFolderId, supported_from=EXCHANGE_2016 + ) parent_folder_id = EWSElementField(field_uri="folder:ParentFolderId", value_cls=ParentFolderId, is_read_only=True) folder_class = CharField(field_uri="folder:FolderClass", is_required_after_save=True) name = CharField(field_uri="folder:DisplayName") @@ -3101,6 +3109,10 @@

    Inherited members

    ) if folder_cls == Folder: log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) + # Some servers return folders in a FindFolder result that have a DistinguishedFolderId element that the same + # server cannot handle in a GetFolder request. Only set the DistinguishedFolderId field if we recognize the ID. + if folder._distinguished_id and not folder_cls.DISTINGUISHED_FOLDER_ID: + folder._distinguished_id = None return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS})

    Ancestors

    @@ -3181,6 +3193,10 @@

    Static methods

    ) if folder_cls == Folder: log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) + # Some servers return folders in a FindFolder result that have a DistinguishedFolderId element that the same + # server cannot handle in a GetFolder request. Only set the DistinguishedFolderId field if we recognize the ID. + if folder._distinguished_id and not folder_cls.DISTINGUISHED_FOLDER_ID: + folder._distinguished_id = None return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS}) diff --git a/docs/exchangelib/folders/index.html b/docs/exchangelib/folders/index.html index 556e1f7c..982b1759 100644 --- a/docs/exchangelib/folders/index.html +++ b/docs/exchangelib/folders/index.html @@ -49,6 +49,7 @@

    Module exchangelib.folders

    Birthdays, Calendar, CalendarLogging, + CalendarSearchCache, CommonViews, Companies, Conflicts, @@ -96,6 +97,7 @@

    Module exchangelib.folders

    PdpProfileV2Secured, PeopleCentricConversationBuddies, PeopleConnect, + PersonMetadata, QedcDefaultRetention, QedcLongRetention, QedcMediumRetention, @@ -162,6 +164,7 @@

    Module exchangelib.folders

    "Birthdays", "Calendar", "CalendarLogging", + "CalendarSearchCache", "CommonViews", "Companies", "Conflicts", @@ -217,13 +220,14 @@

    Module exchangelib.folders

    "PdpProfileV2Secured", "PeopleCentricConversationBuddies", "PeopleConnect", + "PersonMetadata", + "PublicFoldersRoot", "QedcDefaultRetention", - "QedcMediumRetention", "QedcLongRetention", + "QedcMediumRetention", "QedcShortRetention", "QuarantinedEmail", "QuarantinedEmailDefaultCategory", - "PublicFoldersRoot", "QuickContacts", "RSSFeeds", "RecipientCache", @@ -245,8 +249,8 @@

    Module exchangelib.folders

    "ServerFailures", "SharePointNotifications", "Sharing", - "Shortcuts", "ShortNotes", + "Shortcuts", "Signal", "SingleFolderQuerySet", "SkypeTeamsMessages", @@ -390,7 +394,8 @@

    Inherited members

    class AllCategorizedItems(WellknownFolder):
         DISTINGUISHED_FOLDER_ID = "allcategorizeditems"
    -    CONTAINER_CLASS = "IPF.Note"
    + CONTAINER_CLASS = "IPF.Note" + supported_from = EXCHANGE_O365

    Ancestors

      @@ -413,6 +418,10 @@

      Class variables

      +
      var supported_from
      +
      +
      +

      Inherited members

        @@ -685,12 +694,8 @@

        Inherited members

        Expand source code
        class AllTodoTasks(NonDeletableFolder):
        -    DISTINGUISHED_FOLDER_ID = None
             CONTAINER_CLASS = "IPF.Task"
        -    supported_item_models = (Task,)
        -    LOCALIZED_NAMES = {
        -        None: ("AllTodoTasks",),
        -    }
        + supported_item_models = TASK_ITEM_CLASSES

        Ancestors

          @@ -709,14 +714,6 @@

          Class variables

          -
          var DISTINGUISHED_FOLDER_ID
          -
          -
          -
          -
          var LOCALIZED_NAMES
          -
          -
          -
          var supported_item_models
          @@ -1528,7 +1525,9 @@

          Inherited members

          ID_ELEMENT_CLS = FolderId _id = IdElementField(field_uri="folder:FolderId", value_cls=ID_ELEMENT_CLS) - _distinguished_id = IdElementField(field_uri="folder:DistinguishedFolderId", value_cls=DistinguishedFolderId) + _distinguished_id = IdElementField( + field_uri="folder:DistinguishedFolderId", value_cls=DistinguishedFolderId, supported_from=EXCHANGE_2016 + ) parent_folder_id = EWSElementField(field_uri="folder:ParentFolderId", value_cls=ParentFolderId, is_read_only=True) folder_class = CharField(field_uri="folder:FolderClass", is_required_after_save=True) name = CharField(field_uri="folder:DisplayName") @@ -3739,6 +3738,75 @@

          Inherited members

        +
        +class CalendarSearchCache +(**kwargs) +
        +
        +

        A mixin for non-wellknown folders than that are not deletable.

        +
        + +Expand source code + +
        class CalendarSearchCache(NonDeletableFolder):
        +    CONTAINER_CLASS = "IPF.Appointment"
        +
        +

        Ancestors

        + +

        Class variables

        +
        +
        var CONTAINER_CLASS
        +
        +
        +
        +
        +

        Inherited members

        + +
        class CommonViews (**kwargs) @@ -3828,7 +3896,7 @@

        Inherited members

        class Companies(WellknownFolder):
             DISTINGUISHED_FOLDER_ID = "companycontacts"
             CONTAINER_CLASS = "IPF.Contact.Company"
        -    supported_item_models = (Contact, DistributionList)
        +    supported_item_models = CONTACT_ITEM_CLASSES
             LOCALIZED_NAMES = {
                 "da_DK": ("Firmaer",),
             }
        @@ -3988,7 +4056,7 @@

        Inherited members

        class Contacts(WellknownFolder):
             DISTINGUISHED_FOLDER_ID = "contacts"
             CONTAINER_CLASS = "IPF.Contact"
        -    supported_item_models = (Contact, DistributionList)
        +    supported_item_models = CONTACT_ITEM_CLASSES
             LOCALIZED_NAMES = {
                 "da_DK": ("Kontaktpersoner",),
                 "de_DE": ("Kontakte",),
        @@ -4715,7 +4783,8 @@ 

        Inherited members

        class DlpPolicyEvaluation(WellknownFolder):
             DISTINGUISHED_FOLDER_ID = "dlppolicyevaluation"
        -    CONTAINER_CLASS = "IPF.StoreItem.DlpPolicyEvaluation"
        + CONTAINER_CLASS = "IPF.StoreItem.DlpPolicyEvaluation" + supported_from = EXCHANGE_O365

        Ancestors

          @@ -4738,6 +4807,10 @@

          Class variables

          +
          var supported_from
          +
          +
          +

          Inherited members

            @@ -4787,8 +4860,10 @@

            Inherited members

            Expand source code -
            class Drafts(Messages):
            +
            class Drafts(WellknownFolder):
            +    CONTAINER_CLASS = "IPF.Note"
                 DISTINGUISHED_FOLDER_ID = "drafts"
            +    supported_item_models = MESSAGE_ITEM_CLASSES
                 LOCALIZED_NAMES = {
                     "da_DK": ("Kladder",),
                     "de_DE": ("Entwürfe",),
            @@ -4803,7 +4878,6 @@ 

            Inherited members

            Ancestors

            Class variables

            +
            var CONTAINER_CLASS
            +
            +
            +
            var DISTINGUISHED_FOLDER_ID
            @@ -4823,41 +4901,45 @@

            Class variables

            +
            var supported_item_models
            +
            +
            +

            Inherited members

            @@ -5003,12 +5085,8 @@

            Inherited members

            Expand source code
            class ExternalContacts(NonDeletableFolder):
            -    DISTINGUISHED_FOLDER_ID = None
                 CONTAINER_CLASS = "IPF.Contact"
            -    supported_item_models = (Contact, DistributionList)
            -    LOCALIZED_NAMES = {
            -        None: ("ExternalContacts",),
            -    }
            + supported_item_models = CONTACT_ITEM_CLASSES

            Ancestors

              @@ -5027,14 +5105,6 @@

              Class variables

              -
              var DISTINGUISHED_FOLDER_ID
              -
              -
              -
              -
              var LOCALIZED_NAMES
              -
              -
              -
              var supported_item_models
              @@ -5089,8 +5159,8 @@

              Inherited members

              Expand source code
              class Favorites(WellknownFolder):
              -    CONTAINER_CLASS = "IPF.Note"
                   DISTINGUISHED_FOLDER_ID = "favorites"
              +    CONTAINER_CLASS = "IPF.Note"
                   supported_from = EXCHANGE_2013

              Ancestors

              @@ -5370,6 +5440,10 @@

              Inherited members

              ) if folder_cls == Folder: log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) + # Some servers return folders in a FindFolder result that have a DistinguishedFolderId element that the same + # server cannot handle in a GetFolder request. Only set the DistinguishedFolderId field if we recognize the ID. + if folder._distinguished_id and not folder_cls.DISTINGUISHED_FOLDER_ID: + folder._distinguished_id = None return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS})

            Ancestors

            @@ -5450,6 +5524,10 @@

            Static methods

            ) if folder_cls == Folder: log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) + # Some servers return folders in a FindFolder result that have a DistinguishedFolderId element that the same + # server cannot handle in a GetFolder request. Only set the DistinguishedFolderId field if we recognize the ID. + if folder._distinguished_id and not folder_cls.DISTINGUISHED_FOLDER_ID: + folder._distinguished_id = None return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS})
            @@ -7363,7 +7441,7 @@

            Inherited members

            class Friends(NonDeletableFolder):
                 CONTAINER_CLASS = "IPF.Note"
            -    supported_item_models = (Contact, DistributionList)
            +    supported_item_models = CONTACT_ITEM_CLASSES
                 LOCALIZED_NAMES = {
                     "de_DE": ("Bekannte",),
                 }
            @@ -7443,8 +7521,8 @@

            Inherited members

            Expand source code
            class FromFavoriteSenders(WellknownFolder):
            -    CONTAINER_CLASS = "IPF.Note"
                 DISTINGUISHED_FOLDER_ID = "fromfavoritesenders"
            +    CONTAINER_CLASS = "IPF.Note"
                 LOCALIZED_NAMES = {
                     "da_DK": ("Personer jeg kender",),
                 }
            @@ -7525,7 +7603,7 @@

            Inherited members

            class GALContacts(NonDeletableFolder):
                 CONTAINER_CLASS = "IPF.Contact.GalContacts"
            -    supported_item_models = (Contact, DistributionList)
            +    supported_item_models = CONTACT_ITEM_CLASSES
                 LOCALIZED_NAMES = {
                     None: ("GAL Contacts",),
                 }
            @@ -7674,8 +7752,8 @@

            Inherited members

            Expand source code
            class IMContactList(WellknownFolder):
            -    CONTAINER_CLASS = "IPF.Contact.MOC.ImContactList"
                 DISTINGUISHED_FOLDER_ID = "imcontactlist"
            +    CONTAINER_CLASS = "IPF.Contact.MOC.ImContactList"
                 supported_from = EXCHANGE_2013

            Ancestors

            @@ -7752,8 +7830,10 @@

            Inherited members

            Expand source code -
            class Inbox(Messages):
            +
            class Inbox(WellknownFolder):
            +    CONTAINER_CLASS = "IPF.Note"
                 DISTINGUISHED_FOLDER_ID = "inbox"
            +    supported_item_models = MESSAGE_ITEM_CLASSES
                 LOCALIZED_NAMES = {
                     "da_DK": ("Indbakke",),
                     "de_DE": ("Posteingang",),
            @@ -7768,7 +7848,6 @@ 

            Inherited members

            Ancestors

            Class variables

            +
            var CONTAINER_CLASS
            +
            +
            +
            var DISTINGUISHED_FOLDER_ID
            @@ -7788,41 +7871,45 @@

            Class variables

            +
            var supported_item_models
            +
            +
            +

            Inherited members

            @@ -7980,8 +8067,10 @@

            Inherited members

            Expand source code -
            class JunkEmail(Messages):
            +
            class JunkEmail(WellknownFolder):
            +    CONTAINER_CLASS = "IPF.Note"
                 DISTINGUISHED_FOLDER_ID = "junkemail"
            +    supported_item_models = MESSAGE_ITEM_CLASSES
                 LOCALIZED_NAMES = {
                     "da_DK": ("Uønsket e-mail",),
                     "de_DE": ("Junk-E-Mail",),
            @@ -7996,7 +8085,6 @@ 

            Inherited members

            Ancestors

            Class variables

            +
            var CONTAINER_CLASS
            +
            +
            +
            var DISTINGUISHED_FOLDER_ID
            @@ -8016,41 +8108,45 @@

            Class variables

            +
            var supported_item_models
            +
            +
            +

            Inherited members

            @@ -8265,7 +8361,7 @@

            Inherited members

            class Messages(WellknownFolder):
                 CONTAINER_CLASS = "IPF.Note"
            -    supported_item_models = (Message, MeetingRequest, MeetingResponse, MeetingCancellation)
            + supported_item_models = MESSAGE_ITEM_CLASSES

            Ancestors

              @@ -8280,9 +8376,6 @@

              Ancestors

            Subclasses

            @@ -8506,7 +8599,7 @@

            Inherited members

            class MyContactsExtended(NonDeletableFolder):
                 CONTAINER_CLASS = "IPF.Note"
            -    supported_item_models = (Contact, DistributionList)
            + supported_item_models = CONTACT_ITEM_CLASSES

            Ancestors

            Ancestors

            Ancestors

              @@ -9572,6 +9742,10 @@

              Class variables

              +
              var supported_from
              +
              +
              +

              Inherited members

                @@ -9622,7 +9796,8 @@

                Inherited members

                Expand source code
                class QedcLongRetention(WellknownFolder):
                -    DISTINGUISHED_FOLDER_ID = "qedclongretention"
                + DISTINGUISHED_FOLDER_ID = "qedclongretention" + supported_from = EXCHANGE_O365

                Ancestors

                  @@ -9641,6 +9816,10 @@

                  Class variables

                  +
                  var supported_from
                  +
                  +
                  +

                  Inherited members

                    @@ -9691,7 +9870,8 @@

                    Inherited members

                    Expand source code
                    class QedcMediumRetention(WellknownFolder):
                    -    DISTINGUISHED_FOLDER_ID = "qedcmediumretention"
                    + DISTINGUISHED_FOLDER_ID = "qedcmediumretention" + supported_from = EXCHANGE_O365

                    Ancestors

                      @@ -9710,6 +9890,10 @@

                      Class variables

                      +
                      var supported_from
                      +
                      +
                      +

                      Inherited members

                        @@ -9760,7 +9944,8 @@

                        Inherited members

                        Expand source code
                        class QedcShortRetention(WellknownFolder):
                        -    DISTINGUISHED_FOLDER_ID = "qedcshortretention"
                        + DISTINGUISHED_FOLDER_ID = "qedcshortretention" + supported_from = EXCHANGE_O365

                        Ancestors

                          @@ -9779,6 +9964,10 @@

                          Class variables

                          +
                          var supported_from
                          +
                          +
                          +

                          Inherited members

                            @@ -9829,7 +10018,8 @@

                            Inherited members

                            Expand source code
                            class QuarantinedEmail(WellknownFolder):
                            -    DISTINGUISHED_FOLDER_ID = "quarantinedemail"
                            + DISTINGUISHED_FOLDER_ID = "quarantinedemail" + supported_from = EXCHANGE_O365

                            Ancestors

                              @@ -9848,6 +10038,10 @@

                              Class variables

                              +
                              var supported_from
                              +
                              +
                              +

                              Inherited members

                                @@ -9898,7 +10092,8 @@

                                Inherited members

                                Expand source code
                                class QuarantinedEmailDefaultCategory(WellknownFolder):
                                -    DISTINGUISHED_FOLDER_ID = "quarantinedemaildefaultcategory"
                                + DISTINGUISHED_FOLDER_ID = "quarantinedemaildefaultcategory" + supported_from = EXCHANGE_O365

                                Ancestors

                                  @@ -9917,6 +10112,10 @@

                                  Class variables

                                  +
                                  var supported_from
                                  +
                                  +
                                  +

                                  Inherited members

                                    @@ -9967,8 +10166,8 @@

                                    Inherited members

                                    Expand source code
                                    class QuickContacts(WellknownFolder):
                                    -    CONTAINER_CLASS = "IPF.Contact.MOC.QuickContacts"
                                         DISTINGUISHED_FOLDER_ID = "quickcontacts"
                                    +    CONTAINER_CLASS = "IPF.Contact.MOC.QuickContacts"
                                         supported_from = EXCHANGE_2013

                                    Ancestors

                                    @@ -10124,10 +10323,7 @@

                                    Inherited members

                                    class RecipientCache(WellknownFolder):
                                         DISTINGUISHED_FOLDER_ID = "recipientcache"
                                         CONTAINER_CLASS = "IPF.Contact.RecipientCache"
                                    -    supported_from = EXCHANGE_2013
                                    -    LOCALIZED_NAMES = {
                                    -        None: ("RecipientCache",),
                                    -    }
                                    + supported_from = EXCHANGE_2013

                                    Ancestors

                                      @@ -10150,10 +10346,6 @@

                                      Class variables

                                      -
                                      var LOCALIZED_NAMES
                                      -
                                      -
                                      -
                                      var supported_from
                                      @@ -10431,7 +10623,7 @@

                                      Inherited members

                                      class RecoverableItemsSubstrateHolds(WellknownFolder):
                                           DISTINGUISHED_FOLDER_ID = "recoverableitemssubstrateholds"
                                      -    supported_from = EXCHANGE_2010_SP1
                                      +    supported_from = EXCHANGE_O365
                                           LOCALIZED_NAMES = {
                                               None: ("SubstrateHolds",),
                                           }
                                      @@ -10654,10 +10846,7 @@

                                      Inherited members

                                      class RelevantContacts(WellknownFolder):
                                           DISTINGUISHED_FOLDER_ID = "relevantcontacts"
                                      -    CONTAINER_CLASS = "IPF.Note"
                                      -    LOCALIZED_NAMES = {
                                      -        None: ("RelevantContacts",),
                                      -    }
                                      + CONTAINER_CLASS = "IPF.Note"

                                      Ancestors

                                        @@ -10680,10 +10869,6 @@

                                        Class variables

                                        -
                                        var LOCALIZED_NAMES
                                        -
                                        -
                                        -

                                        Inherited members

                                          @@ -11930,7 +12115,8 @@

                                          Inherited members

                                          Expand source code
                                          class ShortNotes(WellknownFolder):
                                          -    DISTINGUISHED_FOLDER_ID = "shortnotes"
                                          + DISTINGUISHED_FOLDER_ID = "shortnotes" + supported_from = EXCHANGE_O365

                                          Ancestors

                                            @@ -11949,6 +12135,10 @@

                                            Class variables

                                            +
                                            var supported_from
                                            +
                                            +
                                            +

                                            Inherited members

                                          • diff --git a/docs/exchangelib/folders/known_folders.html b/docs/exchangelib/folders/known_folders.html index 7ed9a9d9..bc3ec32a 100644 --- a/docs/exchangelib/folders/known_folders.html +++ b/docs/exchangelib/folders/known_folders.html @@ -28,18 +28,14 @@

                                            Module exchangelib.folders.known_folders

                                            from ..items import (
                                                 ASSOCIATED,
                                            +    CONTACT_ITEM_CLASSES,
                                                 ITEM_CLASSES,
                                            +    MESSAGE_ITEM_CLASSES,
                                            +    TASK_ITEM_CLASSES,
                                                 CalendarItem,
                                            -    Contact,
                                            -    DistributionList,
                                            -    MeetingCancellation,
                                            -    MeetingRequest,
                                            -    MeetingResponse,
                                            -    Message,
                                            -    Task,
                                             )
                                             from ..properties import EWSMeta
                                            -from ..version import EXCHANGE_2010_SP1, EXCHANGE_2013, EXCHANGE_2013_SP1
                                            +from ..version import EXCHANGE_2010_SP1, EXCHANGE_2013, EXCHANGE_2013_SP1, EXCHANGE_O365
                                             from .base import Folder
                                             from .collections import FolderCollection
                                             
                                            @@ -84,165 +80,18 @@ 

                                            Module exchangelib.folders.known_folders

                                            supported_item_models = ITEM_CLASSES -class Messages(WellknownFolder): - CONTAINER_CLASS = "IPF.Note" - supported_item_models = (Message, MeetingRequest, MeetingResponse, MeetingCancellation) - - -class Calendar(WellknownFolder): - """An interface for the Exchange calendar.""" - - DISTINGUISHED_FOLDER_ID = "calendar" - CONTAINER_CLASS = "IPF.Appointment" - supported_item_models = (CalendarItem,) - LOCALIZED_NAMES = { - "da_DK": ("Kalender",), - "de_DE": ("Kalender",), - "en_US": ("Calendar",), - "es_ES": ("Calendario",), - "fr_CA": ("Calendrier",), - "nl_NL": ("Agenda",), - "ru_RU": ("Календарь",), - "sv_SE": ("Kalender",), - "zh_CN": ("日历",), - } - - def view(self, *args, **kwargs): - return FolderCollection(account=self.account, folders=[self]).view(*args, **kwargs) - - -class Contacts(WellknownFolder): - DISTINGUISHED_FOLDER_ID = "contacts" - CONTAINER_CLASS = "IPF.Contact" - supported_item_models = (Contact, DistributionList) - LOCALIZED_NAMES = { - "da_DK": ("Kontaktpersoner",), - "de_DE": ("Kontakte",), - "en_US": ("Contacts",), - "es_ES": ("Contactos",), - "fr_CA": ("Contacts",), - "nl_NL": ("Contactpersonen",), - "ru_RU": ("Контакты",), - "sv_SE": ("Kontakter",), - "zh_CN": ("联系人",), - } - - -class DeletedItems(WellknownFolder): - DISTINGUISHED_FOLDER_ID = "deleteditems" - CONTAINER_CLASS = "IPF.Note" - supported_item_models = ITEM_CLASSES - LOCALIZED_NAMES = { - "da_DK": ("Slettet post",), - "de_DE": ("Gelöschte Elemente",), - "en_US": ("Deleted Items",), - "es_ES": ("Elementos eliminados",), - "fr_CA": ("Éléments supprimés",), - "nl_NL": ("Verwijderde items",), - "ru_RU": ("Удаленные",), - "sv_SE": ("Borttaget",), - "zh_CN": ("已删除邮件",), - } - - -class Drafts(Messages): - DISTINGUISHED_FOLDER_ID = "drafts" - LOCALIZED_NAMES = { - "da_DK": ("Kladder",), - "de_DE": ("Entwürfe",), - "en_US": ("Drafts",), - "es_ES": ("Borradores",), - "fr_CA": ("Brouillons",), - "nl_NL": ("Concepten",), - "ru_RU": ("Черновики",), - "sv_SE": ("Utkast",), - "zh_CN": ("草稿",), - } - - -class Inbox(Messages): - DISTINGUISHED_FOLDER_ID = "inbox" - LOCALIZED_NAMES = { - "da_DK": ("Indbakke",), - "de_DE": ("Posteingang",), - "en_US": ("Inbox",), - "es_ES": ("Bandeja de entrada",), - "fr_CA": ("Boîte de réception",), - "nl_NL": ("Postvak IN",), - "ru_RU": ("Входящие",), - "sv_SE": ("Inkorgen",), - "zh_CN": ("收件箱",), - } - - -class JunkEmail(Messages): - DISTINGUISHED_FOLDER_ID = "junkemail" - LOCALIZED_NAMES = { - "da_DK": ("Uønsket e-mail",), - "de_DE": ("Junk-E-Mail",), - "en_US": ("Junk E-mail",), - "es_ES": ("Correo no deseado",), - "fr_CA": ("Courrier indésirables",), - "nl_NL": ("Ongewenste e-mail",), - "ru_RU": ("Нежелательная почта",), - "sv_SE": ("Skräppost",), - "zh_CN": ("垃圾邮件",), - } - - -class Outbox(Messages): - DISTINGUISHED_FOLDER_ID = "outbox" - LOCALIZED_NAMES = { - "da_DK": ("Udbakke",), - "de_DE": ("Postausgang",), - "en_US": ("Outbox",), - "es_ES": ("Bandeja de salida",), - "fr_CA": ("Boîte d'envoi",), - "nl_NL": ("Postvak UIT",), - "ru_RU": ("Исходящие",), - "sv_SE": ("Utkorgen",), - "zh_CN": ("发件箱",), - } - - -class SentItems(Messages): - DISTINGUISHED_FOLDER_ID = "sentitems" - LOCALIZED_NAMES = { - "da_DK": ("Sendt post",), - "de_DE": ("Gesendete Elemente",), - "en_US": ("Sent Items",), - "es_ES": ("Elementos enviados",), - "fr_CA": ("Éléments envoyés",), - "nl_NL": ("Verzonden items",), - "ru_RU": ("Отправленные",), - "sv_SE": ("Skickat",), - "zh_CN": ("已发送邮件",), - } - - -class Tasks(WellknownFolder): - DISTINGUISHED_FOLDER_ID = "tasks" - CONTAINER_CLASS = "IPF.Task" - supported_item_models = (Task,) - LOCALIZED_NAMES = { - "da_DK": ("Opgaver",), - "de_DE": ("Aufgaben",), - "en_US": ("Tasks",), - "es_ES": ("Tareas",), - "fr_CA": ("Tâches",), - "nl_NL": ("Taken",), - "ru_RU": ("Задачи",), - "sv_SE": ("Uppgifter",), - "zh_CN": ("任务",), - } - - class AdminAuditLogs(WellknownFolder): DISTINGUISHED_FOLDER_ID = "adminauditlogs" supported_from = EXCHANGE_2013 get_folder_allowed = False +class AllCategorizedItems(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "allcategorizeditems" + CONTAINER_CLASS = "IPF.Note" + supported_from = EXCHANGE_O365 + + class AllContacts(WellknownFolder): DISTINGUISHED_FOLDER_ID = "allcontacts" CONTAINER_CLASS = "IPF.Note" @@ -253,11 +102,6 @@

                                            Module exchangelib.folders.known_folders

                                            CONTAINER_CLASS = "IPF" -class AllCategorizedItems(WellknownFolder): - DISTINGUISHED_FOLDER_ID = "allcategorizeditems" - CONTAINER_CLASS = "IPF.Note" - - class AllPersonMetadata(WellknownFolder): DISTINGUISHED_FOLDER_ID = "allpersonmetadata" CONTAINER_CLASS = "IPF.Note" @@ -298,10 +142,32 @@

                                            Module exchangelib.folders.known_folders

                                            supported_from = EXCHANGE_2010_SP1 +class Calendar(WellknownFolder): + """An interface for the Exchange calendar.""" + + DISTINGUISHED_FOLDER_ID = "calendar" + CONTAINER_CLASS = "IPF.Appointment" + supported_item_models = (CalendarItem,) + LOCALIZED_NAMES = { + "da_DK": ("Kalender",), + "de_DE": ("Kalender",), + "en_US": ("Calendar",), + "es_ES": ("Calendario",), + "fr_CA": ("Calendrier",), + "nl_NL": ("Agenda",), + "ru_RU": ("Календарь",), + "sv_SE": ("Kalender",), + "zh_CN": ("日历",), + } + + def view(self, *args, **kwargs): + return FolderCollection(account=self.account, folders=[self]).view(*args, **kwargs) + + class Companies(WellknownFolder): DISTINGUISHED_FOLDER_ID = "companycontacts" CONTAINER_CLASS = "IPF.Contact.Company" - supported_item_models = (Contact, DistributionList) + supported_item_models = CONTACT_ITEM_CLASSES LOCALIZED_NAMES = { "da_DK": ("Firmaer",), } @@ -312,11 +178,45 @@

                                            Module exchangelib.folders.known_folders

                                            supported_from = EXCHANGE_2013 +class Contacts(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "contacts" + CONTAINER_CLASS = "IPF.Contact" + supported_item_models = CONTACT_ITEM_CLASSES + LOCALIZED_NAMES = { + "da_DK": ("Kontaktpersoner",), + "de_DE": ("Kontakte",), + "en_US": ("Contacts",), + "es_ES": ("Contactos",), + "fr_CA": ("Contacts",), + "nl_NL": ("Contactpersonen",), + "ru_RU": ("Контакты",), + "sv_SE": ("Kontakter",), + "zh_CN": ("联系人",), + } + + class ConversationHistory(WellknownFolder): DISTINGUISHED_FOLDER_ID = "conversationhistory" supported_from = EXCHANGE_2013 +class DeletedItems(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "deleteditems" + CONTAINER_CLASS = "IPF.Note" + supported_item_models = ITEM_CLASSES + LOCALIZED_NAMES = { + "da_DK": ("Slettet post",), + "de_DE": ("Gelöschte Elemente",), + "en_US": ("Deleted Items",), + "es_ES": ("Elementos eliminados",), + "fr_CA": ("Éléments supprimés",), + "nl_NL": ("Verwijderde items",), + "ru_RU": ("Удаленные",), + "sv_SE": ("Borttaget",), + "zh_CN": ("已删除邮件",), + } + + class Directory(WellknownFolder): DISTINGUISHED_FOLDER_ID = "directory" supported_from = EXCHANGE_2013_SP1 @@ -325,11 +225,29 @@

                                            Module exchangelib.folders.known_folders

                                            class DlpPolicyEvaluation(WellknownFolder): DISTINGUISHED_FOLDER_ID = "dlppolicyevaluation" CONTAINER_CLASS = "IPF.StoreItem.DlpPolicyEvaluation" + supported_from = EXCHANGE_O365 -class Favorites(WellknownFolder): +class Drafts(WellknownFolder): CONTAINER_CLASS = "IPF.Note" + DISTINGUISHED_FOLDER_ID = "drafts" + supported_item_models = MESSAGE_ITEM_CLASSES + LOCALIZED_NAMES = { + "da_DK": ("Kladder",), + "de_DE": ("Entwürfe",), + "en_US": ("Drafts",), + "es_ES": ("Borradores",), + "fr_CA": ("Brouillons",), + "nl_NL": ("Concepten",), + "ru_RU": ("Черновики",), + "sv_SE": ("Utkast",), + "zh_CN": ("草稿",), + } + + +class Favorites(WellknownFolder): DISTINGUISHED_FOLDER_ID = "favorites" + CONTAINER_CLASS = "IPF.Note" supported_from = EXCHANGE_2013 @@ -341,19 +259,36 @@

                                            Module exchangelib.folders.known_folders

                                            class FromFavoriteSenders(WellknownFolder): - CONTAINER_CLASS = "IPF.Note" DISTINGUISHED_FOLDER_ID = "fromfavoritesenders" + CONTAINER_CLASS = "IPF.Note" LOCALIZED_NAMES = { "da_DK": ("Personer jeg kender",), } class IMContactList(WellknownFolder): - CONTAINER_CLASS = "IPF.Contact.MOC.ImContactList" DISTINGUISHED_FOLDER_ID = "imcontactlist" + CONTAINER_CLASS = "IPF.Contact.MOC.ImContactList" supported_from = EXCHANGE_2013 +class Inbox(WellknownFolder): + CONTAINER_CLASS = "IPF.Note" + DISTINGUISHED_FOLDER_ID = "inbox" + supported_item_models = MESSAGE_ITEM_CLASSES + LOCALIZED_NAMES = { + "da_DK": ("Indbakke",), + "de_DE": ("Posteingang",), + "en_US": ("Inbox",), + "es_ES": ("Bandeja de entrada",), + "fr_CA": ("Boîte de réception",), + "nl_NL": ("Postvak IN",), + "ru_RU": ("Входящие",), + "sv_SE": ("Inkorgen",), + "zh_CN": ("收件箱",), + } + + class Inference(WellknownFolder): DISTINGUISHED_FOLDER_ID = "inference" @@ -363,11 +298,33 @@

                                            Module exchangelib.folders.known_folders

                                            DISTINGUISHED_FOLDER_ID = "journal" +class JunkEmail(WellknownFolder): + CONTAINER_CLASS = "IPF.Note" + DISTINGUISHED_FOLDER_ID = "junkemail" + supported_item_models = MESSAGE_ITEM_CLASSES + LOCALIZED_NAMES = { + "da_DK": ("Uønsket e-mail",), + "de_DE": ("Junk-E-Mail",), + "en_US": ("Junk E-mail",), + "es_ES": ("Correo no deseado",), + "fr_CA": ("Courrier indésirables",), + "nl_NL": ("Ongewenste e-mail",), + "ru_RU": ("Нежелательная почта",), + "sv_SE": ("Skräppost",), + "zh_CN": ("垃圾邮件",), + } + + class LocalFailures(WellknownFolder): DISTINGUISHED_FOLDER_ID = "localfailures" supported_from = EXCHANGE_2013 +class Messages(WellknownFolder): + CONTAINER_CLASS = "IPF.Note" + supported_item_models = MESSAGE_ITEM_CLASSES + + class MsgFolderRoot(WellknownFolder): """Also known as the 'Top of Information Store' folder.""" @@ -395,6 +352,22 @@

                                            Module exchangelib.folders.known_folders

                                            class OneNotePagePreviews(WellknownFolder): DISTINGUISHED_FOLDER_ID = "onenotepagepreviews" + supported_from = EXCHANGE_O365 + + +class Outbox(Messages): + DISTINGUISHED_FOLDER_ID = "outbox" + LOCALIZED_NAMES = { + "da_DK": ("Udbakke",), + "de_DE": ("Postausgang",), + "en_US": ("Outbox",), + "es_ES": ("Bandeja de salida",), + "fr_CA": ("Boîte d'envoi",), + "nl_NL": ("Postvak UIT",), + "ru_RU": ("Исходящие",), + "sv_SE": ("Utkorgen",), + "zh_CN": ("发件箱",), + } class PeopleCentricConversationBuddies(WellknownFolder): @@ -412,31 +385,37 @@

                                            Module exchangelib.folders.known_folders

                                            class QedcDefaultRetention(WellknownFolder): DISTINGUISHED_FOLDER_ID = "qedcdefaultretention" + supported_from = EXCHANGE_O365 class QedcLongRetention(WellknownFolder): DISTINGUISHED_FOLDER_ID = "qedclongretention" + supported_from = EXCHANGE_O365 class QedcMediumRetention(WellknownFolder): DISTINGUISHED_FOLDER_ID = "qedcmediumretention" + supported_from = EXCHANGE_O365 class QedcShortRetention(WellknownFolder): DISTINGUISHED_FOLDER_ID = "qedcshortretention" + supported_from = EXCHANGE_O365 class QuarantinedEmail(WellknownFolder): DISTINGUISHED_FOLDER_ID = "quarantinedemail" + supported_from = EXCHANGE_O365 class QuarantinedEmailDefaultCategory(WellknownFolder): DISTINGUISHED_FOLDER_ID = "quarantinedemaildefaultcategory" + supported_from = EXCHANGE_O365 class QuickContacts(WellknownFolder): - CONTAINER_CLASS = "IPF.Contact.MOC.QuickContacts" DISTINGUISHED_FOLDER_ID = "quickcontacts" + CONTAINER_CLASS = "IPF.Contact.MOC.QuickContacts" supported_from = EXCHANGE_2013 @@ -444,17 +423,11 @@

                                            Module exchangelib.folders.known_folders

                                            DISTINGUISHED_FOLDER_ID = "recipientcache" CONTAINER_CLASS = "IPF.Contact.RecipientCache" supported_from = EXCHANGE_2013 - LOCALIZED_NAMES = { - None: ("RecipientCache",), - } class RelevantContacts(WellknownFolder): DISTINGUISHED_FOLDER_ID = "relevantcontacts" CONTAINER_CLASS = "IPF.Note" - LOCALIZED_NAMES = { - None: ("RelevantContacts",), - } class RecoverableItemsDeletions(WellknownFolder): @@ -474,7 +447,7 @@

                                            Module exchangelib.folders.known_folders

                                            class RecoverableItemsSubstrateHolds(WellknownFolder): DISTINGUISHED_FOLDER_ID = "recoverableitemssubstrateholds" - supported_from = EXCHANGE_2010_SP1 + supported_from = EXCHANGE_O365 LOCALIZED_NAMES = { None: ("SubstrateHolds",), } @@ -489,6 +462,21 @@

                                            Module exchangelib.folders.known_folders

                                            DISTINGUISHED_FOLDER_ID = "searchfolders" +class SentItems(Messages): + DISTINGUISHED_FOLDER_ID = "sentitems" + LOCALIZED_NAMES = { + "da_DK": ("Sendt post",), + "de_DE": ("Gesendete Elemente",), + "en_US": ("Sent Items",), + "es_ES": ("Elementos enviados",), + "fr_CA": ("Éléments envoyés",), + "nl_NL": ("Verzonden items",), + "ru_RU": ("Отправленные",), + "sv_SE": ("Skickat",), + "zh_CN": ("已发送邮件",), + } + + class ServerFailures(WellknownFolder): DISTINGUISHED_FOLDER_ID = "serverfailures" supported_from = EXCHANGE_2013 @@ -500,6 +488,7 @@

                                            Module exchangelib.folders.known_folders

                                            class ShortNotes(WellknownFolder): DISTINGUISHED_FOLDER_ID = "shortnotes" + supported_from = EXCHANGE_O365 class SyncIssues(WellknownFolder): @@ -508,13 +497,30 @@

                                            Module exchangelib.folders.known_folders

                                            supported_from = EXCHANGE_2013 +class Tasks(WellknownFolder): + DISTINGUISHED_FOLDER_ID = "tasks" + CONTAINER_CLASS = "IPF.Task" + supported_item_models = TASK_ITEM_CLASSES + LOCALIZED_NAMES = { + "da_DK": ("Opgaver",), + "de_DE": ("Aufgaben",), + "en_US": ("Tasks",), + "es_ES": ("Tareas",), + "fr_CA": ("Tâches",), + "nl_NL": ("Taken",), + "ru_RU": ("Задачи",), + "sv_SE": ("Uppgifter",), + "zh_CN": ("任务",), + } + + class TemporarySaves(WellknownFolder): DISTINGUISHED_FOLDER_ID = "temporarysaves" class ToDoSearch(WellknownFolder): - CONTAINER_CLASS = "IPF.Task" DISTINGUISHED_FOLDER_ID = "todosearch" + CONTAINER_CLASS = "IPF.Task" supported_from = EXCHANGE_2013 LOCALIZED_NAMES = { None: ("To-Do Search",), @@ -522,8 +528,9 @@

                                            Module exchangelib.folders.known_folders

                                            class UserCuratedContacts(WellknownFolder): - CONTAINER_CLASS = "IPF.Note" DISTINGUISHED_FOLDER_ID = "usercuratedcontacts" + CONTAINER_CLASS = "IPF.Note" + supported_from = EXCHANGE_O365 class VoiceMail(WellknownFolder): @@ -542,22 +549,9 @@

                                            Module exchangelib.folders.known_folders

                                            return False -class ExternalContacts(NonDeletableFolder): - DISTINGUISHED_FOLDER_ID = None - CONTAINER_CLASS = "IPF.Contact" - supported_item_models = (Contact, DistributionList) - LOCALIZED_NAMES = { - None: ("ExternalContacts",), - } - - class AllTodoTasks(NonDeletableFolder): - DISTINGUISHED_FOLDER_ID = None CONTAINER_CLASS = "IPF.Task" - supported_item_models = (Task,) - LOCALIZED_NAMES = { - None: ("AllTodoTasks",), - } + supported_item_models = TASK_ITEM_CLASSES class ApplicationData(NonDeletableFolder): @@ -574,6 +568,10 @@

                                            Module exchangelib.folders.known_folders

                                            } +class CalendarSearchCache(NonDeletableFolder): + CONTAINER_CLASS = "IPF.Appointment" + + class CommonViews(NonDeletableFolder): DEFAULT_ITEM_TRAVERSAL_DEPTH = ASSOCIATED LOCALIZED_NAMES = { @@ -602,6 +600,11 @@

                                            Module exchangelib.folders.known_folders

                                            pass +class ExternalContacts(NonDeletableFolder): + CONTAINER_CLASS = "IPF.Contact" + supported_item_models = CONTACT_ITEM_CLASSES + + class Files(NonDeletableFolder): CONTAINER_CLASS = "IPF.Files" LOCALIZED_NAMES = { @@ -617,7 +620,7 @@

                                            Module exchangelib.folders.known_folders

                                            class Friends(NonDeletableFolder): CONTAINER_CLASS = "IPF.Note" - supported_item_models = (Contact, DistributionList) + supported_item_models = CONTACT_ITEM_CLASSES LOCALIZED_NAMES = { "de_DE": ("Bekannte",), } @@ -625,7 +628,7 @@

                                            Module exchangelib.folders.known_folders

                                            class GALContacts(NonDeletableFolder): CONTAINER_CLASS = "IPF.Contact.GalContacts" - supported_item_models = (Contact, DistributionList) + supported_item_models = CONTACT_ITEM_CLASSES LOCALIZED_NAMES = { None: ("GAL Contacts",), } @@ -645,12 +648,12 @@

                                            Module exchangelib.folders.known_folders

                                            class MyContactsExtended(NonDeletableFolder): CONTAINER_CLASS = "IPF.Note" - supported_item_models = (Contact, DistributionList) + supported_item_models = CONTACT_ITEM_CLASSES class OrganizationalContacts(NonDeletableFolder): CONTAINER_CLASS = "IPF.Contact.OrganizationalContacts" - supported_item_models = (Contact, DistributionList) + supported_item_models = CONTACT_ITEM_CLASSES LOCALIZED_NAMES = { None: ("Organizational Contacts",), } @@ -667,6 +670,10 @@

                                            Module exchangelib.folders.known_folders

                                            } +class PersonMetadata(NonDeletableFolder): + CONTAINER_CLASS = "IPF.Contact" + + class PdpProfileV2Secured(NonDeletableFolder): CONTAINER_CLASS = "IPF.StoreItem.PdpProfileSecured" @@ -732,18 +739,19 @@

                                            Module exchangelib.folders.known_folders

                                            # Folders that do not have a distinguished folder ID but return 'ErrorDeleteDistinguishedFolder' or # 'ErrorCannotDeleteObject' when we try to delete them. I can't find any official docs listing these folders. NON_DELETABLE_FOLDERS = [ - ApplicationData, AllTodoTasks, + ApplicationData, Audits, CalendarLogging, + CalendarSearchCache, CommonViews, ConversationSettings, DefaultFoldersChangeHistory, DeferredAction, ExchangeSyncData, ExternalContacts, - FreebusyData, Files, + FreebusyData, Friends, GALContacts, GraphAnalytics, @@ -754,8 +762,9 @@

                                            Module exchangelib.folders.known_folders

                                            ParkedMessages, PassThroughSearchResults, PdpProfileV2Secured, - Reminders, + PersonMetadata, RSSFeeds, + Reminders, Schedule, Sharing, Shortcuts, @@ -787,8 +796,8 @@

                                            Module exchangelib.folders.known_folders

                                            Favorites, FromFavoriteSenders, IMContactList, - Inference, Inbox, + Inference, Journal, JunkEmail, LocalFailures, @@ -807,12 +816,12 @@

                                            Module exchangelib.folders.known_folders

                                            QuarantinedEmailDefaultCategory, QuickContacts, RecipientCache, - RelevantContacts, RecoverableItemsDeletions, RecoverableItemsPurges, RecoverableItemsRoot, RecoverableItemsSubstrateHolds, RecoverableItemsVersions, + RelevantContacts, SearchFolders, SentItems, ServerFailures, @@ -839,14 +848,14 @@

                                            Module exchangelib.folders.known_folders

                                            # Folders that do not have a distinguished ID but have their own container class MISC_FOLDERS = [ + Birthdays, CrawlerData, EventCheckPoints, FolderMemberships, FreeBusyCache, RecoveryPoints, - SwssItems, SkypeTeamsMessages, - Birthdays, + SwssItems, ]
                                            @@ -950,7 +959,8 @@

                                            Inherited members

                                            class AllCategorizedItems(WellknownFolder):
                                                 DISTINGUISHED_FOLDER_ID = "allcategorizeditems"
                                            -    CONTAINER_CLASS = "IPF.Note"
                                            + CONTAINER_CLASS = "IPF.Note" + supported_from = EXCHANGE_O365

                                            Ancestors

                                              @@ -973,6 +983,10 @@

                                              Class variables

                                              +
                                              var supported_from
                                              +
                                              +
                                              +

                                              Inherited members

                                                @@ -1245,12 +1259,8 @@

                                                Inherited members

                                                Expand source code
                                                class AllTodoTasks(NonDeletableFolder):
                                                -    DISTINGUISHED_FOLDER_ID = None
                                                     CONTAINER_CLASS = "IPF.Task"
                                                -    supported_item_models = (Task,)
                                                -    LOCALIZED_NAMES = {
                                                -        None: ("AllTodoTasks",),
                                                -    }
                                                + supported_item_models = TASK_ITEM_CLASSES

                                                Ancestors

                                                  @@ -1269,14 +1279,6 @@

                                                  Class variables

                                                  -
                                                  var DISTINGUISHED_FOLDER_ID
                                                  -
                                                  -
                                                  -
                                                  -
                                                  var LOCALIZED_NAMES
                                                  -
                                                  -
                                                  -
                                                  var supported_item_models
                                                  @@ -2237,6 +2239,75 @@

                                                  Inherited members

                                      +
                                      +class CalendarSearchCache +(**kwargs) +
                                      +
                                      +

                                      A mixin for non-wellknown folders than that are not deletable.

                                      +
                                      + +Expand source code + +
                                      class CalendarSearchCache(NonDeletableFolder):
                                      +    CONTAINER_CLASS = "IPF.Appointment"
                                      +
                                      +

                                      Ancestors

                                      + +

                                      Class variables

                                      +
                                      +
                                      var CONTAINER_CLASS
                                      +
                                      +
                                      +
                                      +
                                      +

                                      Inherited members

                                      + +
                                      class CommonViews (**kwargs) @@ -2326,7 +2397,7 @@

                                      Inherited members

                                      class Companies(WellknownFolder):
                                           DISTINGUISHED_FOLDER_ID = "companycontacts"
                                           CONTAINER_CLASS = "IPF.Contact.Company"
                                      -    supported_item_models = (Contact, DistributionList)
                                      +    supported_item_models = CONTACT_ITEM_CLASSES
                                           LOCALIZED_NAMES = {
                                               "da_DK": ("Firmaer",),
                                           }
                                      @@ -2486,7 +2557,7 @@

                                      Inherited members

                                      class Contacts(WellknownFolder):
                                           DISTINGUISHED_FOLDER_ID = "contacts"
                                           CONTAINER_CLASS = "IPF.Contact"
                                      -    supported_item_models = (Contact, DistributionList)
                                      +    supported_item_models = CONTACT_ITEM_CLASSES
                                           LOCALIZED_NAMES = {
                                               "da_DK": ("Kontaktpersoner",),
                                               "de_DE": ("Kontakte",),
                                      @@ -3105,7 +3176,8 @@ 

                                      Inherited members

                                      class DlpPolicyEvaluation(WellknownFolder):
                                           DISTINGUISHED_FOLDER_ID = "dlppolicyevaluation"
                                      -    CONTAINER_CLASS = "IPF.StoreItem.DlpPolicyEvaluation"
                                      + CONTAINER_CLASS = "IPF.StoreItem.DlpPolicyEvaluation" + supported_from = EXCHANGE_O365

                                      Ancestors

                                      -

                                      string -> datetime from datetime.isoformat() output

                                      +

                                      string -> datetime from a string in most ISO 8601 formats

                                      Expand source code @@ -7401,6 +7406,10 @@

                                      Inherited members

                                      ) if folder_cls == Folder: log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) + # Some servers return folders in a FindFolder result that have a DistinguishedFolderId element that the same + # server cannot handle in a GetFolder request. Only set the DistinguishedFolderId field if we recognize the ID. + if folder._distinguished_id and not folder_cls.DISTINGUISHED_FOLDER_ID: + folder._distinguished_id = None return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS})

                                      Ancestors

                                      @@ -7481,6 +7490,10 @@

                                      Static methods

                                      ) if folder_cls == Folder: log.debug("Fallback to class Folder (folder_class %s, name %s)", folder.folder_class, folder.name) + # Some servers return folders in a FindFolder result that have a DistinguishedFolderId element that the same + # server cannot handle in a GetFolder request. Only set the DistinguishedFolderId field if we recognize the ID. + if folder._distinguished_id and not folder_cls.DISTINGUISHED_FOLDER_ID: + folder._distinguished_id = None return folder_cls(root=root, **{f.name: getattr(folder, f.name) for f in folder.FIELDS})
                                      @@ -9908,6 +9921,53 @@

                                      Methods

                                      +
                                      +class O365InteractiveConfiguration +(client_id, username) +
                                      +
                                      +

                                      Contains information needed to create an authenticated connection to an EWS endpoint.

                                      +

                                      The 'credentials' argument contains the credentials needed to authenticate with the server. Multiple credentials +implementations are available in 'exchangelib.credentials'.

                                      +

                                      config = Configuration(credentials=Credentials('john@example.com', 'MY_SECRET'), …)

                                      +

                                      The 'server' and 'service_endpoint' arguments are mutually exclusive. The former must contain only a domain name, +the latter a full URL:

                                      +
                                      config = Configuration(server='example.com', ...)
                                      +config = Configuration(service_endpoint='https://mail.example.com/EWS/Exchange.asmx', ...)
                                      +
                                      +

                                      If you know which authentication type the server uses, you add that as a hint in 'auth_type'. Likewise, you can +add the server version as a hint. This allows to skip the auth type and version guessing routines:

                                      +
                                      config = Configuration(auth_type=NTLM, ...)
                                      +config = Configuration(version=Version(build=Build(15, 1, 2, 3)), ...)
                                      +
                                      +

                                      You can use 'retry_policy' to define a custom retry policy for handling server connection failures:

                                      +
                                      config = Configuration(retry_policy=FaultTolerance(max_wait=3600), ...)
                                      +
                                      +

                                      'max_connections' defines the max number of connections allowed for this server. This may be restricted by +policies on the Exchange server.

                                      +
                                      + +Expand source code + +
                                      class O365InteractiveConfiguration(Configuration):
                                      +    SERVER = "outlook.office365.com"
                                      +
                                      +    def __init__(self, client_id, username):
                                      +        credentials = O365InteractiveCredentials(client_id=client_id, username=username)
                                      +        super().__init__(server=self.SERVER, auth_type=OAUTH2, credentials=credentials)
                                      +
                                      +

                                      Ancestors

                                      + +

                                      Class variables

                                      +
                                      +
                                      var SERVER
                                      +
                                      +
                                      +
                                      +
                                      +
                                      class OAuth2AuthorizationCodeCredentials (authorization_code=None, **kwargs) @@ -9993,6 +10053,10 @@

                                      Ancestors

                                    • BaseOAuth2Credentials
                                    • BaseCredentials
                                    +

                                    Subclasses

                                    +

                                    Inherited members

                                    • BaseOAuth2Credentials: @@ -13715,6 +13779,12 @@

                                      O365InteractiveConfiguration

                                      + +
                                    • +
                                    • OAuth2AuthorizationCodeCredentials

                                    • diff --git a/docs/exchangelib/items/index.html b/docs/exchangelib/items/index.html index bed3b10f..950df36d 100644 --- a/docs/exchangelib/items/index.html +++ b/docs/exchangelib/items/index.html @@ -104,68 +104,74 @@

                                      Module exchangelib.items

                                      PostItem, Task, ) +TASK_ITEM_CLASSES = (Task,) +CONTACT_ITEM_CLASSES = (Contact, DistributionList) +MESSAGE_ITEM_CLASSES = (Message, MeetingRequest, MeetingResponse, MeetingCancellation) __all__ = [ - "RegisterMixIn", - "MESSAGE_DISPOSITION_CHOICES", - "SAVE_ONLY", - "SEND_ONLY", - "SEND_AND_SAVE_COPY", - "CalendarItem", - "AcceptItem", - "TentativelyAcceptItem", - "DeclineItem", - "CancelCalendarItem", - "MeetingRequest", - "MeetingResponse", - "MeetingCancellation", - "CONFERENCE_TYPES", - "Contact", - "Persona", - "DistributionList", - "SEND_MEETING_INVITATIONS_CHOICES", - "SEND_TO_NONE", - "SEND_ONLY_TO_ALL", - "SEND_TO_ALL_AND_SAVE_COPY", - "SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES", - "SEND_ONLY_TO_CHANGED", - "SEND_TO_CHANGED_AND_SAVE_COPY", - "SEND_MEETING_CANCELLATIONS_CHOICES", + "ACTIVE_DIRECTORY", + "ACTIVE_DIRECTORY_CONTACTS", "AFFECTED_TASK_OCCURRENCES_CHOICES", "ALL_OCCURRENCES", - "SPECIFIED_OCCURRENCE_ONLY", - "CONFLICT_RESOLUTION_CHOICES", - "NEVER_OVERWRITE", - "AUTO_RESOLVE", + "ALL_PROPERTIES", "ALWAYS_OVERWRITE", + "ASSOCIATED", + "AUTO_RESOLVE", + "AcceptItem", + "BaseItem", + "BulkCreateResult", + "CONFERENCE_TYPES", + "CONFLICT_RESOLUTION_CHOICES", + "CONTACT_ITEM_CLASSES", + "CONTACTS", + "CONTACTS_ACTIVE_DIRECTORY", + "CalendarItem", + "CancelCalendarItem", + "Contact", + "DEFAULT", "DELETE_TYPE_CHOICES", + "DeclineItem", + "DistributionList", + "ForwardItem", "HARD_DELETE", - "SOFT_DELETE", - "MOVE_TO_DELETED_ITEMS", - "BaseItem", + "ID_ONLY", + "ITEM_CLASSES", + "ITEM_TRAVERSAL_CHOICES", "Item", - "BulkCreateResult", + "MESSAGE_DISPOSITION_CHOICES", + "MESSAGE_ITEM_CLASSES", + "MOVE_TO_DELETED_ITEMS", + "TASK_ITEM_CLASSES", + "MeetingCancellation", + "MeetingRequest", + "MeetingResponse", "Message", - "ReplyToItem", - "ReplyAllToItem", - "ForwardItem", + "NEVER_OVERWRITE", + "Persona", "PostItem", "PostReplyItem", - "Task", - "ITEM_TRAVERSAL_CHOICES", + "RegisterMixIn", + "ReplyAllToItem", + "ReplyToItem", + "SAVE_ONLY", + "SEARCH_SCOPE_CHOICES", + "SEND_AND_SAVE_COPY", + "SEND_MEETING_CANCELLATIONS_CHOICES", + "SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES", + "SEND_MEETING_INVITATIONS_CHOICES", + "SEND_ONLY", + "SEND_ONLY_TO_ALL", + "SEND_ONLY_TO_CHANGED", + "SEND_TO_ALL_AND_SAVE_COPY", + "SEND_TO_CHANGED_AND_SAVE_COPY", + "SEND_TO_NONE", "SHALLOW", - "SOFT_DELETED", - "ASSOCIATED", "SHAPE_CHOICES", - "ID_ONLY", - "DEFAULT", - "ALL_PROPERTIES", - "SEARCH_SCOPE_CHOICES", - "ACTIVE_DIRECTORY", - "ACTIVE_DIRECTORY_CONTACTS", - "CONTACTS", - "CONTACTS_ACTIVE_DIRECTORY", - "ITEM_CLASSES", + "SOFT_DELETE", + "SOFT_DELETED", + "SPECIFIED_OCCURRENCE_ONLY", + "Task", + "TentativelyAcceptItem", ]
                                      diff --git a/docs/exchangelib/properties.html b/docs/exchangelib/properties.html index 97ded4ff..7bab2a1f 100644 --- a/docs/exchangelib/properties.html +++ b/docs/exchangelib/properties.html @@ -2330,8 +2330,13 @@

                                      Module exchangelib.properties

                                      def delete(self): if self.is_enabled is False: - # Cannot delete a disabled rule - server throws 'ErrorItemNotFound' + # Cannot delete a disabled rule - the server throws 'ErrorItemNotFound'. We need to enable it first. self.is_enabled = True + # Make sure we can save the rule by wiping all possibly-misconfigured fields + self.priority = 10**6 + self.conditions = None + self.exceptions = None + self.actions = Actions(stop_processing_rules=True) self.save() self.account.delete_rule(self) @@ -9843,8 +9848,13 @@

                                      Inherited members

                                      def delete(self): if self.is_enabled is False: - # Cannot delete a disabled rule - server throws 'ErrorItemNotFound' + # Cannot delete a disabled rule - the server throws 'ErrorItemNotFound'. We need to enable it first. self.is_enabled = True + # Make sure we can save the rule by wiping all possibly-misconfigured fields + self.priority = 10**6 + self.conditions = None + self.exceptions = None + self.actions = Actions(stop_processing_rules=True) self.save() self.account.delete_rule(self) @@ -9938,8 +9948,13 @@

                                      Methods

                                      def delete(self):
                                           if self.is_enabled is False:
                                      -        # Cannot delete a disabled rule - server throws 'ErrorItemNotFound'
                                      +        # Cannot delete a disabled rule - the server throws 'ErrorItemNotFound'. We need to enable it first.
                                               self.is_enabled = True
                                      +        # Make sure we can save the rule by wiping all possibly-misconfigured fields
                                      +        self.priority = 10**6
                                      +        self.conditions = None
                                      +        self.exceptions = None
                                      +        self.actions = Actions(stop_processing_rules=True)
                                               self.save()
                                           self.account.delete_rule(self)
                                      diff --git a/docs/exchangelib/services/common.html b/docs/exchangelib/services/common.html index 1d22fc85..b9ed6056 100644 --- a/docs/exchangelib/services/common.html +++ b/docs/exchangelib/services/common.html @@ -999,10 +999,15 @@

                                      Module exchangelib.services.common

                                      def parse_folder_elem(elem, folder): if isinstance(folder, RootOfHierarchy): - return folder.from_xml(elem=elem, account=folder.account) - if isinstance(folder, Folder): - return folder.from_xml_with_root(elem=elem, root=folder.root) - raise ValueError(f"Unsupported folder class: {folder}") + res = folder.from_xml(elem=elem, account=folder.account) + elif isinstance(folder, Folder): + res = folder.from_xml_with_root(elem=elem, root=folder.root) + else: + raise ValueError(f"Unsupported folder class: {folder}") + # Not all servers support fetching the DistinguishedFolderId field. Add it back here. + if folder._distinguished_id and not res._distinguished_id: + res._distinguished_id = folder._distinguished_id + return res
                                      @@ -1062,10 +1067,15 @@

                                      Functions

                                      def parse_folder_elem(elem, folder):
                                           if isinstance(folder, RootOfHierarchy):
                                      -        return folder.from_xml(elem=elem, account=folder.account)
                                      -    if isinstance(folder, Folder):
                                      -        return folder.from_xml_with_root(elem=elem, root=folder.root)
                                      -    raise ValueError(f"Unsupported folder class: {folder}")
                                      + res = folder.from_xml(elem=elem, account=folder.account) + elif isinstance(folder, Folder): + res = folder.from_xml_with_root(elem=elem, root=folder.root) + else: + raise ValueError(f"Unsupported folder class: {folder}") + # Not all servers support fetching the DistinguishedFolderId field. Add it back here. + if folder._distinguished_id and not res._distinguished_id: + res._distinguished_id = folder._distinguished_id + return res
                                      diff --git a/docs/exchangelib/services/index.html b/docs/exchangelib/services/index.html index 7d9aa443..b2779535 100644 --- a/docs/exchangelib/services/index.html +++ b/docs/exchangelib/services/index.html @@ -97,14 +97,16 @@

                                      Module exchangelib.services

                                      "CopyItem", "CreateAttachment", "CreateFolder", + "CreateInboxRule", "CreateItem", "CreateUserConfiguration", "DeleteAttachment", "DeleteFolder", - "DeleteUserConfiguration", + "DeleteInboxRule", "DeleteItem", - "EmptyFolder", + "DeleteUserConfiguration", "EWSService", + "EmptyFolder", "ExpandDL", "ExportItems", "FindFolder", @@ -114,6 +116,7 @@

                                      Module exchangelib.services

                                      "GetDelegate", "GetEvents", "GetFolder", + "GetInboxRules", "GetItem", "GetMailTips", "GetPersona", @@ -132,6 +135,7 @@

                                      Module exchangelib.services

                                      "ResolveNames", "SendItem", "SendNotification", + "SetInboxRule", "SetUserOofSettings", "SubscribeToPull", "SubscribeToPush", @@ -143,10 +147,6 @@

                                      Module exchangelib.services

                                      "UpdateItem", "UpdateUserConfiguration", "UploadItems", - "GetInboxRules", - "CreateInboxRule", - "SetInboxRule", - "DeleteInboxRule", ]
                                      diff --git a/pyproject.toml b/pyproject.toml index 5bcf3ef2..f4327339 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ # * Bump version in exchangelib/__init__.py # * Bump version in CHANGELOG.md # * Generate documentation: -# rm -r docs/exchangelib && pdoc3 --html exchangelib -o docs --force && git add docs && pre-commit run end-of-file-fixer +# rm -r docs/exchangelib && pdoc3 --html exchangelib -o docs --force && git add docs && pre-commit run end-of-file-fixer || git add docs # * Commit and push changes # * Build package: # rm -rf build dist exchangelib.egg-info && python -m build From 5da9b0bea0ec2a517cd5387f40c31cbc262731ac Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Fri, 17 May 2024 21:47:51 +0200 Subject: [PATCH 454/509] feat: Attempt to fetch all user settings when autodiscovering --- exchangelib/autodiscover/protocol.py | 11 ++--------- exchangelib/properties.py | 16 +++++++++++----- tests/test_autodiscover.py | 4 ++-- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/exchangelib/autodiscover/protocol.py b/exchangelib/autodiscover/protocol.py index 9d2ec777..5e199f6c 100644 --- a/exchangelib/autodiscover/protocol.py +++ b/exchangelib/autodiscover/protocol.py @@ -37,15 +37,8 @@ def get_auth_type(self): return get_autodiscover_authtype(protocol=self) def get_user_settings(self, user, settings=None): - if not settings: - settings = [ - "user_dn", - "mailbox_dn", - "user_display_name", - "auto_discover_smtp_address", - "external_ews_url", - "ews_supported_schemas", - ] + if settings is None: + settings = sorted(UserResponse.SETTINGS_MAP.keys()) for setting in settings: if setting not in UserResponse.SETTINGS_MAP: raise ValueError( diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 593b157e..537609b5 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -2018,7 +2018,7 @@ class UserResponse(EWSElement): "external_mailbox_server": "ExternalMailboxServer", "external_mailbox_server_requires_ssl": "ExternalMailboxServerRequiresSSL", "external_mailbox_server_authentication_methods": "ExternalMailboxServerAuthenticationMethods", - "ecp_voicemail_url_fragment,": "EcpVoicemailUrlFragment,", + "ecp_voicemail_url_fragment,": "EcpVoicemailUrlFragment", "ecp_email_subscriptions_url_fragment": "EcpEmailSubscriptionsUrlFragment", "ecp_text_messaging_url_fragment": "EcpTextMessagingUrlFragment", "ecp_delivery_report_url_fragment": "EcpDeliveryReportUrlFragment", @@ -2091,15 +2091,21 @@ def raise_errors(self): if self.error_code == "InvalidUser": raise ErrorNonExistentMailbox(self.error_message) if self.error_code in ( + "InvalidDomain", "InvalidRequest", "InvalidSetting", - "SettingIsNotAvailable", - "InvalidDomain", "NotFederated", + "SettingIsNotAvailable", ): raise AutoDiscoverFailed(f"{self.error_code}: {self.error_message}") - if self.user_settings_errors: - raise AutoDiscoverFailed(f"User settings errors: {self.user_settings_errors}") + errors_to_report = {} + for field_name, (error, message) in (self.user_settings_errors or {}).items(): + if error in ("InvalidSetting", "SettingIsNotAvailable") and field_name in self.SETTINGS_MAP: + # Setting is not available for this user or is unknown to this server. Harmless. + continue + errors_to_report[field_name] = (error, message) + if errors_to_report: + raise AutoDiscoverFailed(f"User settings errors: {errors_to_report}") @classmethod def parse_elem(cls, elem): diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index 2df07f05..d496e722 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -792,8 +792,8 @@ def test_raise_errors(self): UserResponse(error_code="InvalidRequest", error_message="FOO").raise_errors() self.assertEqual(e.exception.args[0], "InvalidRequest: FOO") with self.assertRaises(AutoDiscoverFailed) as e: - UserResponse(user_settings_errors={"FOO": "BAR"}).raise_errors() - self.assertEqual(e.exception.args[0], "User settings errors: {'FOO': 'BAR'}") + UserResponse(user_settings_errors={"foo": ("BAR", "BAZ")}).raise_errors() + self.assertEqual(e.exception.args[0], "User settings errors: {'foo': ('BAR', 'BAZ')}") def test_del_on_error(self): # Test that __del__ can handle exceptions on close() From 61a97d2be1b75d2fe2427bca1aaa48690b457659 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Fri, 17 May 2024 21:48:29 +0200 Subject: [PATCH 455/509] test: test server has a new folder type --- exchangelib/folders/__init__.py | 2 ++ exchangelib/folders/known_folders.py | 5 +++++ tests/test_folder.py | 3 +++ 3 files changed, 10 insertions(+) diff --git a/exchangelib/folders/__init__.py b/exchangelib/folders/__init__.py index 17f8064a..80d2f8d4 100644 --- a/exchangelib/folders/__init__.py +++ b/exchangelib/folders/__init__.py @@ -91,6 +91,7 @@ SearchFolders, SentItems, ServerFailures, + ShadowItems, SharePointNotifications, Sharing, Shortcuts, @@ -219,6 +220,7 @@ "SearchFolders", "SentItems", "ServerFailures", + "ShadowItems", "SharePointNotifications", "Sharing", "ShortNotes", diff --git a/exchangelib/folders/known_folders.py b/exchangelib/folders/known_folders.py index 0fb49518..979abd72 100644 --- a/exchangelib/folders/known_folders.py +++ b/exchangelib/folders/known_folders.py @@ -668,6 +668,10 @@ class Schedule(NonDeletableFolder): pass +class ShadowItems(NonDeletableFolder): + CONTAINER_CLASS = "IPF.StoreItem.ShadowItems" + + class Sharing(NonDeletableFolder): CONTAINER_CLASS = "IPF.Note" @@ -738,6 +742,7 @@ class WorkingSet(NonDeletableFolder): RSSFeeds, Reminders, Schedule, + ShadowItems, Sharing, Shortcuts, Signal, diff --git a/tests/test_folder.py b/tests/test_folder.py index 1cb832c0..d2fc4d22 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -80,6 +80,7 @@ RootOfHierarchy, RSSFeeds, SentItems, + ShadowItems, Sharing, Signal, SingleFolderQuerySet, @@ -602,6 +603,8 @@ def test_folder_grouping(self): self.assertEqual(f.folder_class, "IPF.Contact") elif isinstance(f, CalendarSearchCache): self.assertEqual(f.folder_class, "IPF.Appointment") + elif isinstance(f, ShadowItems): + self.assertEqual(f.folder_class, "IPF.StoreItem.ShadowItems") else: self.assertIn(f.folder_class, (None, "IPF"), (f.name, f.__class__.__name__, f.folder_class)) self.assertIsInstance(f, Folder) From c74b15368a4d6f846afe4ce08e49b2e41d2187ec Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Fri, 17 May 2024 21:50:09 +0200 Subject: [PATCH 456/509] chore: improve dev docs Add 'build' as release package, and collect all packages not mentioned in pyproject.toml in new requirements file. Sort keywords while here. --- dev-requirements.txt | 13 +++++++++++++ pyproject.toml | 8 ++++---- 2 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 dev-requirements.txt diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 00000000..e51b80df --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,13 @@ +# For packaging +build +pdoc3 +twine +wheel + +# For developing +pre-commit + +# extras +msal +requests_gssapi +requests_negotiate_sspi diff --git a/pyproject.toml b/pyproject.toml index f4327339..fa160517 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ # Release notes: -# * Install pdoc3, wheel, twine +# * Install pdoc3, wheel, twine, build # * Bump version in exchangelib/__init__.py # * Bump version in CHANGELOG.md # * Generate documentation: @@ -19,14 +19,14 @@ readme = {file = "README.md", content-type = "text/markdown"} requires-python = ">=3.8" license = {text = "BSD-2-Clause"} keywords = [ + "autodiscover", "ews", "exchange", - "autodiscover", - "microsoft", - "outlook", "exchange-web-services", + "microsoft", "o365", "office365", + "outlook", ] authors = [ {name = "Erik Cederstrand", email = "erik@cederstrand.dk"} From 8559eb9f9116259c49d41f23e751705431204a90 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Fri, 17 May 2024 23:50:31 +0200 Subject: [PATCH 457/509] chore: Fix some Deepsource suggestions --- exchangelib/autodiscover/cache.py | 5 ++-- exchangelib/credentials.py | 3 ++- exchangelib/fields.py | 3 +-- exchangelib/folders/collections.py | 4 +-- exchangelib/services/common.py | 27 ++++++++++---------- exchangelib/services/get_attachment.py | 24 ++++++++--------- exchangelib/services/get_streaming_events.py | 11 +++----- exchangelib/services/get_user_settings.py | 4 --- exchangelib/services/subscribe.py | 2 -- exchangelib/util.py | 18 ++++++------- exchangelib/winzone.py | 10 +++----- tests/test_account.py | 17 +++++++----- tests/test_items/test_sync.py | 8 ------ 13 files changed, 58 insertions(+), 78 deletions(-) diff --git a/exchangelib/autodiscover/cache.py b/exchangelib/autodiscover/cache.py index 1ada1281..d17854a6 100644 --- a/exchangelib/autodiscover/cache.py +++ b/exchangelib/autodiscover/cache.py @@ -116,9 +116,8 @@ def __delitem__(self, key): # Empty both local and persistent cache. Don't fail on non-existing entries because we could end here # multiple times due to race conditions. domain = key[0] - with shelve_open_with_failover(self._storage_file) as db: - with suppress(KeyError): - del db[str(domain)] + with shelve_open_with_failover(self._storage_file) as db, suppress(KeyError): + del db[str(domain)] with suppress(KeyError): del self._protocols[key] diff --git a/exchangelib/credentials.py b/exchangelib/credentials.py index 711aeb38..4475177d 100644 --- a/exchangelib/credentials.py +++ b/exchangelib/credentials.py @@ -166,7 +166,8 @@ def session_params(self): ) return res - def token_params(self): + @staticmethod + def token_params(): """Extra parameters when requesting the token""" return {"include_client_id": True} diff --git a/exchangelib/fields.py b/exchangelib/fields.py index 764b04c2..b565c504 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -293,7 +293,6 @@ def __init__( is_searchable=True, is_attribute=False, default=None, - *args, **kwargs, ): self.name = name # Usually set by the EWSMeta metaclass @@ -310,7 +309,7 @@ def __init__( self.is_searchable = is_searchable # When true, this field is treated as an XML attribute instead of an element self.is_attribute = is_attribute - super().__init__(*args, **kwargs) + super().__init__(**kwargs) def clean(self, value, version=None): if version and not self.supports_version(version): diff --git a/exchangelib/folders/collections.py b/exchangelib/folders/collections.py index c91efba9..03a52082 100644 --- a/exchangelib/folders/collections.py +++ b/exchangelib/folders/collections.py @@ -91,7 +91,7 @@ def exclude(self, *args, **kwargs): def people(self): return QuerySet(self).people() - def view(self, start, end, max_items=None, *args, **kwargs): + def view(self, start, end, max_items=None): """Implement the CalendarView option to FindItem. The difference between 'filter' and 'view' is that 'filter' only returns the master CalendarItem for recurring items, while 'view' unfolds recurring items and returns all CalendarItem occurrences as one would normally expect when presenting a calendar. @@ -109,7 +109,7 @@ def view(self, start, end, max_items=None, *args, **kwargs): :param max_items: (Default value = None) :return: """ - qs = QuerySet(self).filter(*args, **kwargs) + qs = QuerySet(self) qs.calendar_view = CalendarView(start=start, end=end, max_items=max_items) return qs diff --git a/exchangelib/services/common.py b/exchangelib/services/common.py index 40684d96..20e30796 100644 --- a/exchangelib/services/common.py +++ b/exchangelib/services/common.py @@ -221,6 +221,7 @@ def _elems_to_objs(self, elems): yield self._elem_to_obj(elem) def _elem_to_obj(self, elem): + """Convert a single XML element to a single Python object""" if not self.returns_elements: raise RuntimeError("Incorrect call to method when 'returns_elements' is False") raise NotImplementedError() @@ -335,7 +336,7 @@ def _get_elements(self, payload): self.stop_streaming() def _handle_response_cookies(self, session): - pass + """Code to react on response cookies""" def _get_response(self, payload, api_version): """Send the actual HTTP request and get the response.""" @@ -375,13 +376,13 @@ def _api_versions_to_try(self): v for v in self.supported_api_versions() if v != self._version_hint.api_version ) - def _get_response_xml(self, payload, **parse_opts): + def _get_response_xml(self, payload): """Send the payload to the server and return relevant elements from the result. Several things happen here: - * The payload is wrapped in SOAP headers and sent to the server - * The Exchange API version is negotiated and stored in the protocol object - * Connection errors are handled and possibly reraised as ErrorServerBusy - * SOAP errors are raised - * EWS errors are raised, or passed on to the caller + * Wraps the payload is wrapped in SOAP headers and sends to the server + * Negotiates the Exchange API version and stores it in the protocol object + * Handles connection errors and possibly re-raises them as ErrorServerBusy + * Raises SOAP errors + * Raises EWS errors or passes them on to the caller :param payload: The request payload, as an XML object :return: A generator of XML objects or None if the service does not return a result @@ -397,15 +398,15 @@ def _get_response_xml(self, payload, **parse_opts): # Let 'requests' decode raw data automatically r.raw.decode_content = True try: - header, body = self._get_soap_parts(response=r, **parse_opts) + header, body = self._get_soap_parts(response=r) except Exception: r.close() # Release memory raise # The body may contain error messages from Exchange, but we still want to collect version info if header is not None: - self._update_api_version(api_version=api_version, header=header, **parse_opts) + self._update_api_version(api_version=api_version, header=header) try: - return self._get_soap_messages(body=body, **parse_opts) + return self._get_soap_messages(body=body) except ( ErrorInvalidServerVersion, ErrorIncorrectSchemaVersion, @@ -438,7 +439,7 @@ def _handle_backoff(self, e): self.protocol.retry_policy.back_off(e.back_off) # We'll warn about this later if we actually need to sleep - def _update_api_version(self, api_version, header, **parse_opts): + def _update_api_version(self, api_version, header): """Parse the server version contained in SOAP headers and update the version hint stored by the caller, if necessary. """ @@ -471,7 +472,7 @@ def _response_message_tag(cls): return f"{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage" @classmethod - def _get_soap_parts(cls, response, **parse_opts): + def _get_soap_parts(cls, response): """Split the SOAP response into its headers and body elements.""" try: root = to_xml(response.iter_content()) @@ -486,7 +487,7 @@ def _get_soap_parts(cls, response, **parse_opts): raise MalformedResponseError("No Body element in SOAP response") return header, body - def _get_soap_messages(self, body, **parse_opts): + def _get_soap_messages(self, body): """Return the elements in the response containing the response messages. Raises any SOAP exceptions.""" response = body.find(self._response_tag()) if response is None: diff --git a/exchangelib/services/get_attachment.py b/exchangelib/services/get_attachment.py index e34f3e27..97356355 100644 --- a/exchangelib/services/get_attachment.py +++ b/exchangelib/services/get_attachment.py @@ -65,23 +65,20 @@ def get_payload(self, items, include_mime_content, body_type, filter_html_conten payload.append(attachment_ids_element(items=items, version=self.account.version)) return payload - def _update_api_version(self, api_version, header, **parse_opts): - if not parse_opts.get("stream_file_content", False): - super()._update_api_version(api_version, header, **parse_opts) + def _update_api_version(self, api_version, header): + if not self.streaming: + super()._update_api_version(api_version, header) # TODO: We're skipping this part in streaming mode because StreamingBase64Parser cannot parse the SOAP header - @classmethod - def _get_soap_parts(cls, response, **parse_opts): - if not parse_opts.get("stream_file_content", False): - return super()._get_soap_parts(response, **parse_opts) - + def _get_soap_parts(self, response): + if not self.streaming: + return super()._get_soap_parts(response) # Pass the response unaltered. We want to use our custom streaming parser return None, response - def _get_soap_messages(self, body, **parse_opts): - if not parse_opts.get("stream_file_content", False): - return super()._get_soap_messages(body, **parse_opts) - + def _get_soap_messages(self, body): + if not self.streaming: + return super()._get_soap_messages(body) # 'body' is actually the raw response passed on by '_get_soap_parts' r = body parser = StreamingBase64Parser() @@ -101,13 +98,14 @@ def stream_file_content(self, attachment_id): ) self.streaming = True try: - yield from self._get_response_xml(payload=payload, stream_file_content=True) + yield from self._get_response_xml(payload=payload) except ElementNotFound as enf: # When the returned XML does not contain a Content element, ElementNotFound is thrown by parser.parse(). # Let the non-streaming SOAP parser parse the response and hook into the normal exception handling. # Wrap in DummyResponse because _get_soap_parts() expects an iter_content() method. response = DummyResponse(content=enf.data) _, body = super()._get_soap_parts(response=response) + # TODO: We're skipping ._update_api_version() here because we don't have access to the 'api_version' used. res = super()._get_soap_messages(body=body) for e in self._get_elements_in_response(response=res): if isinstance(e, Exception): diff --git a/exchangelib/services/get_streaming_events.py b/exchangelib/services/get_streaming_events.py index fd5e53ff..a7ae25a0 100644 --- a/exchangelib/services/get_streaming_events.py +++ b/exchangelib/services/get_streaming_events.py @@ -48,11 +48,11 @@ def _elem_to_obj(self, elem): return Notification.from_xml(elem=elem, account=None) @classmethod - def _get_soap_parts(cls, response, **parse_opts): + def _get_soap_parts(cls, response): # Pass the response unaltered. We want to use our custom document yielder return None, response - def _get_soap_messages(self, body, **parse_opts): + def _get_soap_messages(self, body): # 'body' is actually the raw response passed on by '_get_soap_parts'. We want to continuously read the content, # looking for complete XML documents. When we have a full document, we want to parse it as if it was a normal # XML response. @@ -61,13 +61,13 @@ def _get_soap_messages(self, body, **parse_opts): xml_log.debug("Response XML (docs counter: %(i)s): %(xml_response)s", dict(i=i, xml_response=doc)) response = DummyResponse(content=doc) try: - _, body = super()._get_soap_parts(response=response, **parse_opts) + _, body = super()._get_soap_parts(response=response) except Exception: r.close() # Release memory raise # TODO: We're skipping ._update_api_version() here because we don't have access to the 'api_version' used. # TODO: We should be doing a lot of error handling for ._get_soap_messages(). - yield from super()._get_soap_messages(body=body, **parse_opts) + yield from super()._get_soap_messages(body=body) if self.connection_status == self.CLOSED: # Don't wait for the TCP connection to timeout break @@ -97,9 +97,6 @@ def get_payload(self, subscription_ids, connection_timeout): subscriptions_elem = create_element("m:SubscriptionIds") for subscription_id in subscription_ids: add_xml_child(subscriptions_elem, "t:SubscriptionId", subscription_id) - if not len(subscriptions_elem): - raise ValueError("'subscription_ids' must not be empty") - payload.append(subscriptions_elem) add_xml_child(payload, "m:ConnectionTimeout", connection_timeout) return payload diff --git a/exchangelib/services/get_user_settings.py b/exchangelib/services/get_user_settings.py index 272bf67d..ced5a33e 100644 --- a/exchangelib/services/get_user_settings.py +++ b/exchangelib/services/get_user_settings.py @@ -53,14 +53,10 @@ def get_payload(self, users, settings): mailbox = create_element("a:Mailbox") set_xml_value(mailbox, user) add_xml_child(users_elem, "a:User", mailbox) - if not len(users_elem): - raise ValueError("'users' must not be empty") request.append(users_elem) requested_settings = create_element("a:RequestedSettings") for setting in settings: add_xml_child(requested_settings, "a:Setting", UserResponse.SETTINGS_MAP[setting]) - if not len(requested_settings): - raise ValueError("'requested_settings' must not be empty") request.append(requested_settings) payload.append(request) return payload diff --git a/exchangelib/services/subscribe.py b/exchangelib/services/subscribe.py index c87c9b40..c3cc6456 100644 --- a/exchangelib/services/subscribe.py +++ b/exchangelib/services/subscribe.py @@ -51,8 +51,6 @@ def _partial_payload(self, folders, event_types): event_types_elem = create_element("t:EventTypes") for event_type in event_types: add_xml_child(event_types_elem, "t:EventType", event_type) - if not len(event_types_elem): - raise ValueError("'event_types' must not be empty") request_elem.append(event_types_elem) return request_elem diff --git a/exchangelib/util.py b/exchangelib/util.py index 0c22e1fd..9a4e4590 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -422,10 +422,8 @@ def tell(self): return self._tell def read(self, size=-1): - # requests `iter_content()` auto-adjusts the number of bytes based on bandwidth - # can't assume how many bytes next returns so stash any extra in `self._next` - if self.closed: - raise ValueError("read from a closed file") + # requests `iter_content()` auto-adjusts the number of bytes based on bandwidth. + # We can't assume how many bytes next returns so stash any extra in `self._next`. if self._next is None: return b"" if size is None: @@ -534,10 +532,9 @@ def to_xml(bytes_content): offending_line = stream.read().splitlines()[e.lineno - 1] except (IndexError, io.UnsupportedOperation): raise ParseError(str(e), "", e.lineno, e.offset) - else: - offending_excerpt = offending_line[max(0, e.offset - 20) : e.offset + 20] - msg = f'{e}\nOffending text: [...]{offending_excerpt.decode("utf-8", errors="ignore")}[...]' - raise ParseError(msg, "", e.lineno, e.offset) + offending_excerpt = offending_line[max(0, e.offset - 20) : e.offset + 20] + msg = f'{e}\nOffending text: [...]{offending_excerpt.decode("utf-8", errors="ignore")}[...]' + raise ParseError(msg, "", e.lineno, e.offset) except TypeError: with suppress(IndexError, io.UnsupportedOperation): stream.seek(0) @@ -576,7 +573,8 @@ def is_xml(text): class PrettyXmlHandler(logging.StreamHandler): """A steaming log handler that prettifies log statements containing XML when output is a terminal.""" - def parse_bytes(self, xml_bytes): + @staticmethod + def parse_bytes(xml_bytes): return to_xml(xml_bytes) def prettify_xml(self, xml_bytes): @@ -671,7 +669,7 @@ def iter_content(self): return self.content def close(self): - pass + """We don't have an actual socket to close""" def get_domain(email): diff --git a/exchangelib/winzone.py b/exchangelib/winzone.py index 56936a58..3062122d 100644 --- a/exchangelib/winzone.py +++ b/exchangelib/winzone.py @@ -639,10 +639,8 @@ def generate_map(timeout=10): ) # Reverse map from Microsoft timezone ID to IANA timezone name. Non-IANA timezone ID's can be added here. -MS_TIMEZONE_TO_IANA_MAP = dict( +MS_TIMEZONE_TO_IANA_MAP = { # Use the CLDR map because the IANA map contains deprecated aliases that not all systems support - {v[0]: k for k, v in CLDR_TO_MS_TIMEZONE_MAP.items() if v[1] == DEFAULT_TERRITORY}, - **{ - "tzone://Microsoft/Utc": "UTC", - }, -) + **{v[0]: k for k, v in CLDR_TO_MS_TIMEZONE_MAP.items() if v[1] == DEFAULT_TERRITORY}, + "tzone://Microsoft/Utc": "UTC", +} diff --git a/tests/test_account.py b/tests/test_account.py index a65f3a24..04d1a9ca 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -174,13 +174,16 @@ def test_pickle(self): def test_mail_tips(self): # Test that mail tips work self.assertEqual(self.account.mail_tips.recipient_address.email_address, self.account.primary_smtp_address) - # recipients must not be empty - list( - GetMailTips(protocol=self.account.protocol).call( - sending_as=SendingAs(email_address=self.account.primary_smtp_address), - recipients=[], - mail_tips_requested="All", - ) + # recipients may be empty + self.assertEqual( + list( + GetMailTips(protocol=self.account.protocol).call( + sending_as=SendingAs(email_address=self.account.primary_smtp_address), + recipients=[], + mail_tips_requested="All", + ) + ), + [], ) xml = b"""\ diff --git a/tests/test_items/test_sync.py b/tests/test_items/test_sync.py index 5eece424..a2bac8be 100644 --- a/tests/test_items/test_sync.py +++ b/tests/test_items/test_sync.py @@ -30,9 +30,6 @@ def test_subscribe_invalid_kwargs(self): self.assertEqual( e.exception.args[0], f"'event_types' values must consist of values in {SubscribeToPull.EVENT_TYPES}" ) - with self.assertRaises(ValueError) as e: - self.account.inbox.subscribe_to_pull(event_types=[]) - self.assertEqual(e.exception.args[0], "'event_types' must not be empty") def test_pull_subscribe(self): self.account.affinity_cookie = None @@ -410,11 +407,6 @@ def test_streaming_invalid_subscription(self): # Test that we can get the failing subscription IDs from the response message test_folder = self.account.drafts - # Test with empty list of subscription - with self.assertRaises(ValueError) as e: - list(test_folder.get_streaming_events([], connection_timeout=1, max_notifications_returned=1)) - self.assertEqual(e.exception.args[0], "'subscription_ids' must not be empty") - # Test with bad connection_timeout with self.assertRaises(TypeError) as e: list(test_folder.get_streaming_events("AAA-", connection_timeout="XXX", max_notifications_returned=1)) From 0b7962b0a9e0e0721b774be871f732e01729d142 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sat, 18 May 2024 00:29:09 +0200 Subject: [PATCH 458/509] test: Reuse global retry strategy for autodiscover tests --- tests/test_autodiscover.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index d496e722..b287c5c9 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -35,7 +35,7 @@ def setUp(self): super().setUp() # Enable retries, to make tests more robust - Autodiscovery.INITIAL_RETRY_POLICY = FaultTolerance(max_wait=5) + Autodiscovery.INITIAL_RETRY_POLICY = self.retry_policy AutodiscoverProtocol.RETRY_WAIT = 5 # Each test should start with a clean autodiscover cache @@ -218,6 +218,7 @@ def test_autodiscover_failure(self): ).discover() def test_failed_login_via_account(self): + Autodiscovery.INITIAL_RETRY_POLICY = FaultTolerance(max_wait=2) # We retry on 401's. Fail faster. with self.assertRaises(AutoDiscoverFailed): Account( primary_smtp_address=self.account.primary_smtp_address, From cf2a77d3177b85879b0f8b5c1e48a02784818ed2 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Wed, 29 May 2024 11:33:29 +0200 Subject: [PATCH 459/509] ci: One more package needs to be installed from source on Python dev --- .github/workflows/python-package.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 99aa2745..65e35b92 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -65,6 +65,7 @@ jobs: python -m pip install git+https://github.com/cython/cython.git python -m pip install git+https://github.com/lxml/lxml.git python -m pip install git+https://github.com/yaml/pyyaml.git + python -m pip install git+https://github.com/python-cffi/cffi.git - name: Install dependencies continue-on-error: ${{ matrix.allowed_failure || false }} From bc988fa978bf166314c271fb6f51560f1ef558b9 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 5 Jun 2024 13:02:32 +0200 Subject: [PATCH 460/509] docs: fix typos --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e320bffb..a91d448b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ HEAD 5.3.0 ----- - Fix various issues related to public folders and archive folders -- Support read-write for ``Contact.im_addresses` +- Support read-write for `Contact.im_addresses` - Improve reporting of inbox rule validation errors @@ -857,7 +857,7 @@ shared_calendar = SingleFolderQuerySet( - Removed `fetch(.., with_extra=True)` in favor of the more fine-grained `fetch(.., only_fields=[...])` - Added a `QuerySet` class that supports QuerySet-returning methods `filter()`, `exclude()`, `only()`, `order_by()`, - `reverse()``values()` and `values_list()` that all allow for chaining. `QuerySet` also has methods `iterator()` + `reverse()`, `values()` and `values_list()` that all allow for chaining. `QuerySet` also has methods `iterator()` , `get()`, `count()`, `exists()` and `delete()`. All these methods behave like their counterparts in Django. From 2831454c270d7d03d7db0e772d26a1244b0a7cd4 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 5 Jun 2024 13:06:55 +0200 Subject: [PATCH 461/509] fix: Use a class name that matches the distinguished name --- exchangelib/folders/__init__.py | 4 ++-- exchangelib/folders/base.py | 4 ++-- exchangelib/folders/known_folders.py | 4 ++-- tests/test_folder.py | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/exchangelib/folders/__init__.py b/exchangelib/folders/__init__.py index 80d2f8d4..4d56d1e7 100644 --- a/exchangelib/folders/__init__.py +++ b/exchangelib/folders/__init__.py @@ -23,7 +23,7 @@ CalendarLogging, CalendarSearchCache, CommonViews, - Companies, + CompanyContacts, Conflicts, Contacts, ConversationHistory, @@ -139,7 +139,7 @@ "CalendarLogging", "CalendarSearchCache", "CommonViews", - "Companies", + "CompanyContacts", "Conflicts", "Contacts", "ConversationHistory", diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py index 89f2c314..a8b5647a 100644 --- a/exchangelib/folders/base.py +++ b/exchangelib/folders/base.py @@ -247,7 +247,7 @@ def folder_cls_from_container_class(container_class): from .known_folders import ( ApplicationData, Calendar, - Companies, + CompanyContacts, Contacts, ConversationSettings, CrawlerData, @@ -270,7 +270,7 @@ def folder_cls_from_container_class(container_class): for folder_cls in ( ApplicationData, Calendar, - Companies, + CompanyContacts, Contacts, ConversationSettings, CrawlerData, diff --git a/exchangelib/folders/known_folders.py b/exchangelib/folders/known_folders.py index 979abd72..94cd379f 100644 --- a/exchangelib/folders/known_folders.py +++ b/exchangelib/folders/known_folders.py @@ -136,7 +136,7 @@ def view(self, *args, **kwargs): return FolderCollection(account=self.account, folders=[self]).view(*args, **kwargs) -class Companies(WellknownFolder): +class CompanyContacts(WellknownFolder): DISTINGUISHED_FOLDER_ID = "companycontacts" CONTAINER_CLASS = "IPF.Contact.Company" supported_item_models = CONTACT_ITEM_CLASSES @@ -762,7 +762,7 @@ class WorkingSet(NonDeletableFolder): AllItems, AllPersonMetadata, Calendar, - Companies, + CompanyContacts, Conflicts, Contacts, ConversationHistory, diff --git a/tests/test_folder.py b/tests/test_folder.py index d2fc4d22..13556afa 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -34,7 +34,7 @@ Calendar, CalendarSearchCache, CommonViews, - Companies, + CompanyContacts, Contacts, ConversationSettings, CrawlerData, @@ -553,7 +553,7 @@ def test_folder_grouping(self): self.assertEqual(f.folder_class, "IPF.StoreItem.Signal") elif isinstance(f, PdpProfileV2Secured): self.assertEqual(f.folder_class, "IPF.StoreItem.PdpProfileSecured") - elif isinstance(f, Companies): + elif isinstance(f, CompanyContacts): self.assertEqual(f.folder_class, "IPF.Contact.Company") elif isinstance(f, OrganizationalContacts): self.assertEqual(f.folder_class, "IPF.Contact.OrganizationalContacts") From c310c65e7b4eb12e2ba8037a589794f9eb668b51 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Wed, 5 Jun 2024 13:08:02 +0200 Subject: [PATCH 462/509] fix: Don't request new distinguished folders on older Exchange versions. Fixes #1315 --- exchangelib/folders/known_folders.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/exchangelib/folders/known_folders.py b/exchangelib/folders/known_folders.py index 94cd379f..7a2d61c3 100644 --- a/exchangelib/folders/known_folders.py +++ b/exchangelib/folders/known_folders.py @@ -67,16 +67,19 @@ class AllCategorizedItems(WellknownFolder): class AllContacts(WellknownFolder): DISTINGUISHED_FOLDER_ID = "allcontacts" CONTAINER_CLASS = "IPF.Note" + supported_from = EXCHANGE_O365 class AllItems(WellknownFolder): DISTINGUISHED_FOLDER_ID = "allitems" CONTAINER_CLASS = "IPF" + supported_from = EXCHANGE_O365 class AllPersonMetadata(WellknownFolder): DISTINGUISHED_FOLDER_ID = "allpersonmetadata" CONTAINER_CLASS = "IPF.Note" + supported_from = EXCHANGE_O365 class ArchiveDeletedItems(WellknownFolder): @@ -139,6 +142,7 @@ def view(self, *args, **kwargs): class CompanyContacts(WellknownFolder): DISTINGUISHED_FOLDER_ID = "companycontacts" CONTAINER_CLASS = "IPF.Contact.Company" + supported_from = EXCHANGE_O365 supported_item_models = CONTACT_ITEM_CLASSES LOCALIZED_NAMES = { "da_DK": ("Firmaer",), @@ -233,6 +237,7 @@ class FolderMemberships(Folder): class FromFavoriteSenders(WellknownFolder): DISTINGUISHED_FOLDER_ID = "fromfavoritesenders" CONTAINER_CLASS = "IPF.Note" + supported_from = EXCHANGE_O365 LOCALIZED_NAMES = { "da_DK": ("Personer jeg kender",), } @@ -263,6 +268,7 @@ class Inbox(WellknownFolder): class Inference(WellknownFolder): DISTINGUISHED_FOLDER_ID = "inference" + supported_from = EXCHANGE_O365 class Journal(WellknownFolder): @@ -345,6 +351,7 @@ class Outbox(Messages): class PeopleCentricConversationBuddies(WellknownFolder): DISTINGUISHED_FOLDER_ID = "peoplecentricconversationbuddies" CONTAINER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies" + supported_from = EXCHANGE_O365 LOCALIZED_NAMES = { None: ("PeopleCentricConversation Buddies",), } @@ -400,6 +407,7 @@ class RecipientCache(WellknownFolder): class RelevantContacts(WellknownFolder): DISTINGUISHED_FOLDER_ID = "relevantcontacts" CONTAINER_CLASS = "IPF.Note" + supported_from = EXCHANGE_O365 class RecoverableItemsDeletions(WellknownFolder): @@ -456,6 +464,7 @@ class ServerFailures(WellknownFolder): class SharePointNotifications(WellknownFolder): DISTINGUISHED_FOLDER_ID = "sharepointnotifications" + supported_from = EXCHANGE_O365 class ShortNotes(WellknownFolder): @@ -488,6 +497,7 @@ class Tasks(WellknownFolder): class TemporarySaves(WellknownFolder): DISTINGUISHED_FOLDER_ID = "temporarysaves" + supported_from = EXCHANGE_O365 class ToDoSearch(WellknownFolder): From 3c13440a7e619f169fdca9b8c6a357755ba733ab Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sun, 9 Jun 2024 17:06:01 +0200 Subject: [PATCH 463/509] Change the 'parent' attribute of direct public subfolders (#1316) * fix: change the 'parent' attribute of direct public subfolders so folder traversal finds these folders * fix: parent method is a property * fix: roots do not have a setter --------- Co-authored-by: Erik Cederstrand --- exchangelib/folders/roots.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/exchangelib/folders/roots.py b/exchangelib/folders/roots.py index 2de25b74..ee4eb15e 100644 --- a/exchangelib/folders/roots.py +++ b/exchangelib/folders/roots.py @@ -332,6 +332,19 @@ def __init__(self, **kwargs): if self._distinguished_id: self._distinguished_id.mailbox = None # See DistinguishedFolderId.clean() + @property + def _folders_map(self): + # Top-level public folders may point to the root folder of the owning account and not the public folders root + # of this account. This breaks the assumption of get_children(). Fix it by overwriting the parent folder. + fix_parents = self._subfolders is None + res = super()._folders_map + if fix_parents: + with self._subfolders_lock: + for f in res.values(): + if f.id != self.id: + f.parent = self + return res + def get_children(self, folder): # EWS does not allow deep traversal of public folders, so self._folders_map will only populate the top-level # subfolders. To traverse public folders at arbitrary depth, we need to get child folders on demand. From e86f915eee1713568d64a60a804926c3348edc7d Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jun 2024 09:25:52 +0200 Subject: [PATCH 464/509] Bump version --- CHANGELOG.md | 7 +++++++ exchangelib/__init__.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a91d448b..85bf14ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ HEAD ---- +5.4.1 +----- +- Fix traversal of public folders in `Account.public_folders_root` +- Mark certain distinguished folders as only supported on newer Exchange versions +- Fetch *all* autodiscover information by default + + 5.4.0 ----- - Add `O365InteractiveConfiguration` helper class to set up MSAL auth for O365. diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index 561a24be..7ac4de12 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -36,7 +36,7 @@ from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "5.4.0" +__version__ = "5.4.1" __all__ = [ "AcceptItem", From 5fa602e49c01a1953942c6228d75f45ffc3020f6 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 10 Jun 2024 09:27:03 +0200 Subject: [PATCH 465/509] docs: Update docs --- docs/exchangelib/autodiscover/cache.html | 10 +- docs/exchangelib/autodiscover/index.html | 27 +-- docs/exchangelib/autodiscover/protocol.html | 33 +-- docs/exchangelib/credentials.html | 38 ++-- docs/exchangelib/fields.html | 12 +- docs/exchangelib/folders/base.html | 12 +- docs/exchangelib/folders/collections.html | 14 +- docs/exchangelib/folders/index.html | 213 +++++++++++++++--- docs/exchangelib/folders/known_folders.html | 195 ++++++++++++++-- docs/exchangelib/folders/roots.html | 26 +++ docs/exchangelib/index.html | 12 +- docs/exchangelib/properties.html | 46 ++-- docs/exchangelib/services/common.html | 54 ++--- docs/exchangelib/services/get_attachment.html | 51 ++--- .../services/get_streaming_events.html | 25 +- .../services/get_user_settings.html | 12 - docs/exchangelib/services/index.html | 76 +++---- docs/exchangelib/services/subscribe.html | 4 - docs/exchangelib/util.html | 73 +++--- docs/exchangelib/winzone.html | 10 +- 20 files changed, 604 insertions(+), 339 deletions(-) diff --git a/docs/exchangelib/autodiscover/cache.html b/docs/exchangelib/autodiscover/cache.html index 907e0b57..bce9493c 100644 --- a/docs/exchangelib/autodiscover/cache.html +++ b/docs/exchangelib/autodiscover/cache.html @@ -144,9 +144,8 @@

                                      Module exchangelib.autodiscover.cache

                                      # Empty both local and persistent cache. Don't fail on non-existing entries because we could end here # multiple times due to race conditions. domain = key[0] - with shelve_open_with_failover(self._storage_file) as db: - with suppress(KeyError): - del db[str(domain)] + with shelve_open_with_failover(self._storage_file) as db, suppress(KeyError): + del db[str(domain)] with suppress(KeyError): del self._protocols[key] @@ -328,9 +327,8 @@

                                      Classes

                                      # Empty both local and persistent cache. Don't fail on non-existing entries because we could end here # multiple times due to race conditions. domain = key[0] - with shelve_open_with_failover(self._storage_file) as db: - with suppress(KeyError): - del db[str(domain)] + with shelve_open_with_failover(self._storage_file) as db, suppress(KeyError): + del db[str(domain)] with suppress(KeyError): del self._protocols[key] diff --git a/docs/exchangelib/autodiscover/index.html b/docs/exchangelib/autodiscover/index.html index 03e865b6..4f60b5f9 100644 --- a/docs/exchangelib/autodiscover/index.html +++ b/docs/exchangelib/autodiscover/index.html @@ -208,9 +208,8 @@

                                      Classes

                                      # Empty both local and persistent cache. Don't fail on non-existing entries because we could end here # multiple times due to race conditions. domain = key[0] - with shelve_open_with_failover(self._storage_file) as db: - with suppress(KeyError): - del db[str(domain)] + with shelve_open_with_failover(self._storage_file) as db, suppress(KeyError): + del db[str(domain)] with suppress(KeyError): del self._protocols[key] @@ -319,15 +318,8 @@

                                      Methods

                                      return get_autodiscover_authtype(protocol=self) def get_user_settings(self, user, settings=None): - if not settings: - settings = [ - "user_dn", - "mailbox_dn", - "user_display_name", - "auto_discover_smtp_address", - "external_ews_url", - "ews_supported_schemas", - ] + if settings is None: + settings = sorted(UserResponse.SETTINGS_MAP.keys()) for setting in settings: if setting not in UserResponse.SETTINGS_MAP: raise ValueError( @@ -420,15 +412,8 @@

                                      Methods

                                      Expand source code
                                      def get_user_settings(self, user, settings=None):
                                      -    if not settings:
                                      -        settings = [
                                      -            "user_dn",
                                      -            "mailbox_dn",
                                      -            "user_display_name",
                                      -            "auto_discover_smtp_address",
                                      -            "external_ews_url",
                                      -            "ews_supported_schemas",
                                      -        ]
                                      +    if settings is None:
                                      +        settings = sorted(UserResponse.SETTINGS_MAP.keys())
                                           for setting in settings:
                                               if setting not in UserResponse.SETTINGS_MAP:
                                                   raise ValueError(
                                      diff --git a/docs/exchangelib/autodiscover/protocol.html b/docs/exchangelib/autodiscover/protocol.html
                                      index 99875ef1..93dfd587 100644
                                      --- a/docs/exchangelib/autodiscover/protocol.html
                                      +++ b/docs/exchangelib/autodiscover/protocol.html
                                      @@ -65,15 +65,8 @@ 

                                      Module exchangelib.autodiscover.protocol

                                      return get_autodiscover_authtype(protocol=self) def get_user_settings(self, user, settings=None): - if not settings: - settings = [ - "user_dn", - "mailbox_dn", - "user_display_name", - "auto_discover_smtp_address", - "external_ews_url", - "ews_supported_schemas", - ] + if settings is None: + settings = sorted(UserResponse.SETTINGS_MAP.keys()) for setting in settings: if setting not in UserResponse.SETTINGS_MAP: raise ValueError( @@ -144,15 +137,8 @@

                                      Classes

                                      return get_autodiscover_authtype(protocol=self) def get_user_settings(self, user, settings=None): - if not settings: - settings = [ - "user_dn", - "mailbox_dn", - "user_display_name", - "auto_discover_smtp_address", - "external_ews_url", - "ews_supported_schemas", - ] + if settings is None: + settings = sorted(UserResponse.SETTINGS_MAP.keys()) for setting in settings: if setting not in UserResponse.SETTINGS_MAP: raise ValueError( @@ -245,15 +231,8 @@

                                      Methods

                                      Expand source code
                                      def get_user_settings(self, user, settings=None):
                                      -    if not settings:
                                      -        settings = [
                                      -            "user_dn",
                                      -            "mailbox_dn",
                                      -            "user_display_name",
                                      -            "auto_discover_smtp_address",
                                      -            "external_ews_url",
                                      -            "ews_supported_schemas",
                                      -        ]
                                      +    if settings is None:
                                      +        settings = sorted(UserResponse.SETTINGS_MAP.keys())
                                           for setting in settings:
                                               if setting not in UserResponse.SETTINGS_MAP:
                                                   raise ValueError(
                                      diff --git a/docs/exchangelib/credentials.html b/docs/exchangelib/credentials.html
                                      index a6df53cb..ed5d708b 100644
                                      --- a/docs/exchangelib/credentials.html
                                      +++ b/docs/exchangelib/credentials.html
                                      @@ -199,7 +199,8 @@ 

                                      Module exchangelib.credentials

                                      ) return res - def token_params(self): + @staticmethod + def token_params(): """Extra parameters when requesting the token""" return {"include_client_id": True} @@ -506,7 +507,8 @@

                                      Subclasses

                                      ) return res - def token_params(self): + @staticmethod + def token_params(): """Extra parameters when requesting the token""" return {"include_client_id": True} @@ -549,6 +551,24 @@

                                      Subclasses

                                    • OAuth2AuthorizationCodeCredentials
                                    • OAuth2Credentials
                                    +

                                    Static methods

                                    +
                                    +
                                    +def token_params() +
                                    +
                                    +

                                    Extra parameters when requesting the token

                                    +
                                    + +Expand source code + +
                                    @staticmethod
                                    +def token_params():
                                    +    """Extra parameters when requesting the token"""
                                    +    return {"include_client_id": True}
                                    +
                                    +
                                    +

                                    Instance variables

                                    var access_token
                                    @@ -733,20 +753,6 @@

                                    Methods

                                    return hash(tuple(res))
                                    -
                                    -def token_params(self) -
                                    -
                                    -

                                    Extra parameters when requesting the token

                                    -
                                    - -Expand source code - -
                                    def token_params(self):
                                    -    """Extra parameters when requesting the token"""
                                    -    return {"include_client_id": True}
                                    -
                                    -
                                    diff --git a/docs/exchangelib/fields.html b/docs/exchangelib/fields.html index 0ef39cd5..ac6784f7 100644 --- a/docs/exchangelib/fields.html +++ b/docs/exchangelib/fields.html @@ -321,7 +321,6 @@

                                    Module exchangelib.fields

                                    is_searchable=True, is_attribute=False, default=None, - *args, **kwargs, ): self.name = name # Usually set by the EWSMeta metaclass @@ -338,7 +337,7 @@

                                    Module exchangelib.fields

                                    self.is_searchable = is_searchable # When true, this field is treated as an XML attribute instead of an element self.is_attribute = is_attribute - super().__init__(*args, **kwargs) + super().__init__(**kwargs) def clean(self, value, version=None): if version and not self.supports_version(version): @@ -3696,7 +3695,7 @@

                                    Inherited members

                                    class EmailSubField -(name=None, is_required=False, is_required_after_save=False, is_read_only=False, is_read_only_after_send=False, is_searchable=True, is_attribute=False, default=None, *args, **kwargs) +(name=None, is_required=False, is_required_after_save=False, is_read_only=False, is_read_only_after_send=False, is_searchable=True, is_attribute=False, default=None, **kwargs)

                                    A field to hold the value on an SingleFieldIndexedElement.

                                    @@ -4126,7 +4125,7 @@

                                    Inherited members

                                    class Field -(name=None, is_required=False, is_required_after_save=False, is_read_only=False, is_read_only_after_send=False, is_searchable=True, is_attribute=False, default=None, *args, **kwargs) +(name=None, is_required=False, is_required_after_save=False, is_read_only=False, is_read_only_after_send=False, is_searchable=True, is_attribute=False, default=None, **kwargs)

                                    Holds information related to an item field.

                                    @@ -4157,7 +4156,6 @@

                                    Inherited members

                                    is_searchable=True, is_attribute=False, default=None, - *args, **kwargs, ): self.name = name # Usually set by the EWSMeta metaclass @@ -4174,7 +4172,7 @@

                                    Inherited members

                                    self.is_searchable = is_searchable # When true, this field is treated as an XML attribute instead of an element self.is_attribute = is_attribute - super().__init__(*args, **kwargs) + super().__init__(**kwargs) def clean(self, value, version=None): if version and not self.supports_version(version): @@ -6562,7 +6560,7 @@

                                    Inherited members

                                    class SubField -(name=None, is_required=False, is_required_after_save=False, is_read_only=False, is_read_only_after_send=False, is_searchable=True, is_attribute=False, default=None, *args, **kwargs) +(name=None, is_required=False, is_required_after_save=False, is_read_only=False, is_read_only_after_send=False, is_searchable=True, is_attribute=False, default=None, **kwargs)

                                    A field to hold the value on an IndexedElement.

                                    diff --git a/docs/exchangelib/folders/base.html b/docs/exchangelib/folders/base.html index 32a5ba63..6b8dc2b3 100644 --- a/docs/exchangelib/folders/base.html +++ b/docs/exchangelib/folders/base.html @@ -275,7 +275,7 @@

                                    Module exchangelib.folders.base

                                    from .known_folders import ( ApplicationData, Calendar, - Companies, + CompanyContacts, Contacts, ConversationSettings, CrawlerData, @@ -298,7 +298,7 @@

                                    Module exchangelib.folders.base

                                    for folder_cls in ( ApplicationData, Calendar, - Companies, + CompanyContacts, Contacts, ConversationSettings, CrawlerData, @@ -1190,7 +1190,7 @@

                                    Classes

                                    from .known_folders import ( ApplicationData, Calendar, - Companies, + CompanyContacts, Contacts, ConversationSettings, CrawlerData, @@ -1213,7 +1213,7 @@

                                    Classes

                                    for folder_cls in ( ApplicationData, Calendar, - Companies, + CompanyContacts, Contacts, ConversationSettings, CrawlerData, @@ -1872,7 +1872,7 @@

                                    Static methods

                                    from .known_folders import ( ApplicationData, Calendar, - Companies, + CompanyContacts, Contacts, ConversationSettings, CrawlerData, @@ -1895,7 +1895,7 @@

                                    Static methods

                                    for folder_cls in ( ApplicationData, Calendar, - Companies, + CompanyContacts, Contacts, ConversationSettings, CrawlerData, diff --git a/docs/exchangelib/folders/collections.html b/docs/exchangelib/folders/collections.html index f6b93b79..f1c1b9bd 100644 --- a/docs/exchangelib/folders/collections.html +++ b/docs/exchangelib/folders/collections.html @@ -119,7 +119,7 @@

                                    Module exchangelib.folders.collections

                                    def people(self): return QuerySet(self).people() - def view(self, start, end, max_items=None, *args, **kwargs): + def view(self, start, end, max_items=None): """Implement the CalendarView option to FindItem. The difference between 'filter' and 'view' is that 'filter' only returns the master CalendarItem for recurring items, while 'view' unfolds recurring items and returns all CalendarItem occurrences as one would normally expect when presenting a calendar. @@ -137,7 +137,7 @@

                                    Module exchangelib.folders.collections

                                    :param max_items: (Default value = None) :return: """ - qs = QuerySet(self).filter(*args, **kwargs) + qs = QuerySet(self) qs.calendar_view = CalendarView(start=start, end=end, max_items=max_items) return qs @@ -726,7 +726,7 @@

                                    Subclasses

                                    def people(self): return QuerySet(self).people() - def view(self, start, end, max_items=None, *args, **kwargs): + def view(self, start, end, max_items=None): """Implement the CalendarView option to FindItem. The difference between 'filter' and 'view' is that 'filter' only returns the master CalendarItem for recurring items, while 'view' unfolds recurring items and returns all CalendarItem occurrences as one would normally expect when presenting a calendar. @@ -744,7 +744,7 @@

                                    Subclasses

                                    :param max_items: (Default value = None) :return: """ - qs = QuerySet(self).filter(*args, **kwargs) + qs = QuerySet(self) qs.calendar_view = CalendarView(start=start, end=end, max_items=max_items) return qs @@ -1831,7 +1831,7 @@

                                    Examples

                                    -def view(self, start, end, max_items=None, *args, **kwargs) +def view(self, start, end, max_items=None)

                                    Implement the CalendarView option to FindItem. The difference between 'filter' and 'view' is that 'filter' @@ -1851,7 +1851,7 @@

                                    Examples

                                    Expand source code -
                                    def view(self, start, end, max_items=None, *args, **kwargs):
                                    +
                                    def view(self, start, end, max_items=None):
                                         """Implement the CalendarView option to FindItem. The difference between 'filter' and 'view' is that 'filter'
                                         only returns the master CalendarItem for recurring items, while 'view' unfolds recurring items and returns all
                                         CalendarItem occurrences as one would normally expect when presenting a calendar.
                                    @@ -1869,7 +1869,7 @@ 

                                    Examples

                                    :param max_items: (Default value = None) :return: """ - qs = QuerySet(self).filter(*args, **kwargs) + qs = QuerySet(self) qs.calendar_view = CalendarView(start=start, end=end, max_items=max_items) return qs
                                    diff --git a/docs/exchangelib/folders/index.html b/docs/exchangelib/folders/index.html index 982b1759..df85f225 100644 --- a/docs/exchangelib/folders/index.html +++ b/docs/exchangelib/folders/index.html @@ -51,7 +51,7 @@

                                    Module exchangelib.folders

                                    CalendarLogging, CalendarSearchCache, CommonViews, - Companies, + CompanyContacts, Conflicts, Contacts, ConversationHistory, @@ -119,6 +119,7 @@

                                    Module exchangelib.folders

                                    SearchFolders, SentItems, ServerFailures, + ShadowItems, SharePointNotifications, Sharing, Shortcuts, @@ -166,7 +167,7 @@

                                    Module exchangelib.folders

                                    "CalendarLogging", "CalendarSearchCache", "CommonViews", - "Companies", + "CompanyContacts", "Conflicts", "Contacts", "ConversationHistory", @@ -247,6 +248,7 @@

                                    Module exchangelib.folders

                                    "SearchFolders", "SentItems", "ServerFailures", + "ShadowItems", "SharePointNotifications", "Sharing", "ShortNotes", @@ -473,7 +475,8 @@

                                    Inherited members

                                    class AllContacts(WellknownFolder):
                                         DISTINGUISHED_FOLDER_ID = "allcontacts"
                                    -    CONTAINER_CLASS = "IPF.Note"
                                    + CONTAINER_CLASS = "IPF.Note" + supported_from = EXCHANGE_O365

                                    Ancestors

                                      @@ -496,6 +499,10 @@

                                      Class variables

                                      +
                                      var supported_from
                                      +
                                      +
                                      +

                                      Inherited members

                                        @@ -547,7 +554,8 @@

                                        Inherited members

                                        class AllItems(WellknownFolder):
                                             DISTINGUISHED_FOLDER_ID = "allitems"
                                        -    CONTAINER_CLASS = "IPF"
                                        + CONTAINER_CLASS = "IPF" + supported_from = EXCHANGE_O365

                                        Ancestors

                                          @@ -570,6 +578,10 @@

                                          Class variables

                                          +
                                          var supported_from
                                          +
                                          +
                                          +

                                          Inherited members

                                            @@ -621,7 +633,8 @@

                                            Inherited members

                                            class AllPersonMetadata(WellknownFolder):
                                                 DISTINGUISHED_FOLDER_ID = "allpersonmetadata"
                                            -    CONTAINER_CLASS = "IPF.Note"
                                            + CONTAINER_CLASS = "IPF.Note" + supported_from = EXCHANGE_O365

                                            Ancestors

                                              @@ -644,6 +657,10 @@

                                              Class variables

                                              +
                                              var supported_from
                                              +
                                              +
                                              +

                                              Inherited members

                                                @@ -1695,7 +1712,7 @@

                                                Inherited members

                                                from .known_folders import ( ApplicationData, Calendar, - Companies, + CompanyContacts, Contacts, ConversationSettings, CrawlerData, @@ -1718,7 +1735,7 @@

                                                Inherited members

                                                for folder_cls in ( ApplicationData, Calendar, - Companies, + CompanyContacts, Contacts, ConversationSettings, CrawlerData, @@ -2377,7 +2394,7 @@

                                                Static methods

                                                from .known_folders import ( ApplicationData, Calendar, - Companies, + CompanyContacts, Contacts, ConversationSettings, CrawlerData, @@ -2400,7 +2417,7 @@

                                                Static methods

                                                for folder_cls in ( ApplicationData, Calendar, - Companies, + CompanyContacts, Contacts, ConversationSettings, CrawlerData, @@ -3883,8 +3900,8 @@

                                                Inherited members

                                    -
                                    -class Companies +
                                    +class CompanyContacts (**kwargs)
                                    @@ -3893,9 +3910,10 @@

                                    Inherited members

                                    Expand source code -
                                    class Companies(WellknownFolder):
                                    +
                                    class CompanyContacts(WellknownFolder):
                                         DISTINGUISHED_FOLDER_ID = "companycontacts"
                                         CONTAINER_CLASS = "IPF.Contact.Company"
                                    +    supported_from = EXCHANGE_O365
                                         supported_item_models = CONTACT_ITEM_CLASSES
                                         LOCALIZED_NAMES = {
                                             "da_DK": ("Firmaer",),
                                    @@ -3914,19 +3932,23 @@ 

                                    Ancestors

                                  Class variables

                                  -
                                  var CONTAINER_CLASS
                                  +
                                  var CONTAINER_CLASS
                                  +
                                  +
                                  +
                                  +
                                  var DISTINGUISHED_FOLDER_ID
                                  -
                                  var DISTINGUISHED_FOLDER_ID
                                  +
                                  var LOCALIZED_NAMES
                                  -
                                  var LOCALIZED_NAMES
                                  +
                                  var supported_from
                                  -
                                  var supported_item_models
                                  +
                                  var supported_item_models
                                  @@ -5711,7 +5733,7 @@

                                  Inherited members

                                  def people(self): return QuerySet(self).people() - def view(self, start, end, max_items=None, *args, **kwargs): + def view(self, start, end, max_items=None): """Implement the CalendarView option to FindItem. The difference between 'filter' and 'view' is that 'filter' only returns the master CalendarItem for recurring items, while 'view' unfolds recurring items and returns all CalendarItem occurrences as one would normally expect when presenting a calendar. @@ -5729,7 +5751,7 @@

                                  Inherited members

                                  :param max_items: (Default value = None) :return: """ - qs = QuerySet(self).filter(*args, **kwargs) + qs = QuerySet(self) qs.calendar_view = CalendarView(start=start, end=end, max_items=max_items) return qs @@ -6816,7 +6838,7 @@

                                  Examples

                                  -def view(self, start, end, max_items=None, *args, **kwargs) +def view(self, start, end, max_items=None)

                                  Implement the CalendarView option to FindItem. The difference between 'filter' and 'view' is that 'filter' @@ -6836,7 +6858,7 @@

                                  Examples

                                  Expand source code -
                                  def view(self, start, end, max_items=None, *args, **kwargs):
                                  +
                                  def view(self, start, end, max_items=None):
                                       """Implement the CalendarView option to FindItem. The difference between 'filter' and 'view' is that 'filter'
                                       only returns the master CalendarItem for recurring items, while 'view' unfolds recurring items and returns all
                                       CalendarItem occurrences as one would normally expect when presenting a calendar.
                                  @@ -6854,7 +6876,7 @@ 

                                  Examples

                                  :param max_items: (Default value = None) :return: """ - qs = QuerySet(self).filter(*args, **kwargs) + qs = QuerySet(self) qs.calendar_view = CalendarView(start=start, end=end, max_items=max_items) return qs
                                  @@ -7523,6 +7545,7 @@

                                  Inherited members

                                  class FromFavoriteSenders(WellknownFolder):
                                       DISTINGUISHED_FOLDER_ID = "fromfavoritesenders"
                                       CONTAINER_CLASS = "IPF.Note"
                                  +    supported_from = EXCHANGE_O365
                                       LOCALIZED_NAMES = {
                                           "da_DK": ("Personer jeg kender",),
                                       }
                                  @@ -7552,6 +7575,10 @@

                                  Class variables

                                  +
                                  var supported_from
                                  +
                                  +
                                  +

                                  Inherited members

                                    @@ -7925,7 +7952,8 @@

                                    Inherited members

                                    Expand source code
                                    class Inference(WellknownFolder):
                                    -    DISTINGUISHED_FOLDER_ID = "inference"
                                    + DISTINGUISHED_FOLDER_ID = "inference" + supported_from = EXCHANGE_O365

                                    Ancestors

                                      @@ -7944,6 +7972,10 @@

                                      Class variables

                                      +
                                      var supported_from
                                      +
                                      +
                                      +

                                      Inherited members

                                        @@ -8717,6 +8749,7 @@

                                        Subclasses

                                      • RSSFeeds
                                      • Reminders
                                      • Schedule
                                      • +
                                      • ShadowItems
                                      • Sharing
                                      • Shortcuts
                                      • Signal
                                      • @@ -9328,6 +9361,7 @@

                                        Inherited members

                                        class PeopleCentricConversationBuddies(WellknownFolder):
                                             DISTINGUISHED_FOLDER_ID = "peoplecentricconversationbuddies"
                                             CONTAINER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies"
                                        +    supported_from = EXCHANGE_O365
                                             LOCALIZED_NAMES = {
                                                 None: ("PeopleCentricConversation Buddies",),
                                             }
                                        @@ -9357,6 +9391,10 @@

                                        Class variables

                                        +
                                        var supported_from
                                        +
                                        +
                                        +

                                        Inherited members

                                      • @@ -14357,9 +14500,16 @@

                                        ShadowItems

                                        + +
                                      • +
                                      • SharePointNotifications

                                      • @@ -14448,6 +14598,7 @@

                                        TemporarySaves

                                      • diff --git a/docs/exchangelib/folders/known_folders.html b/docs/exchangelib/folders/known_folders.html index bc3ec32a..a597eb83 100644 --- a/docs/exchangelib/folders/known_folders.html +++ b/docs/exchangelib/folders/known_folders.html @@ -95,16 +95,19 @@

                                        Module exchangelib.folders.known_folders

                                        class AllContacts(WellknownFolder): DISTINGUISHED_FOLDER_ID = "allcontacts" CONTAINER_CLASS = "IPF.Note" + supported_from = EXCHANGE_O365 class AllItems(WellknownFolder): DISTINGUISHED_FOLDER_ID = "allitems" CONTAINER_CLASS = "IPF" + supported_from = EXCHANGE_O365 class AllPersonMetadata(WellknownFolder): DISTINGUISHED_FOLDER_ID = "allpersonmetadata" CONTAINER_CLASS = "IPF.Note" + supported_from = EXCHANGE_O365 class ArchiveDeletedItems(WellknownFolder): @@ -164,9 +167,10 @@

                                        Module exchangelib.folders.known_folders

                                        return FolderCollection(account=self.account, folders=[self]).view(*args, **kwargs) -class Companies(WellknownFolder): +class CompanyContacts(WellknownFolder): DISTINGUISHED_FOLDER_ID = "companycontacts" CONTAINER_CLASS = "IPF.Contact.Company" + supported_from = EXCHANGE_O365 supported_item_models = CONTACT_ITEM_CLASSES LOCALIZED_NAMES = { "da_DK": ("Firmaer",), @@ -261,6 +265,7 @@

                                        Module exchangelib.folders.known_folders

                                        class FromFavoriteSenders(WellknownFolder): DISTINGUISHED_FOLDER_ID = "fromfavoritesenders" CONTAINER_CLASS = "IPF.Note" + supported_from = EXCHANGE_O365 LOCALIZED_NAMES = { "da_DK": ("Personer jeg kender",), } @@ -291,6 +296,7 @@

                                        Module exchangelib.folders.known_folders

                                        class Inference(WellknownFolder): DISTINGUISHED_FOLDER_ID = "inference" + supported_from = EXCHANGE_O365 class Journal(WellknownFolder): @@ -373,6 +379,7 @@

                                        Module exchangelib.folders.known_folders

                                        class PeopleCentricConversationBuddies(WellknownFolder): DISTINGUISHED_FOLDER_ID = "peoplecentricconversationbuddies" CONTAINER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies" + supported_from = EXCHANGE_O365 LOCALIZED_NAMES = { None: ("PeopleCentricConversation Buddies",), } @@ -428,6 +435,7 @@

                                        Module exchangelib.folders.known_folders

                                        class RelevantContacts(WellknownFolder): DISTINGUISHED_FOLDER_ID = "relevantcontacts" CONTAINER_CLASS = "IPF.Note" + supported_from = EXCHANGE_O365 class RecoverableItemsDeletions(WellknownFolder): @@ -484,6 +492,7 @@

                                        Module exchangelib.folders.known_folders

                                        class SharePointNotifications(WellknownFolder): DISTINGUISHED_FOLDER_ID = "sharepointnotifications" + supported_from = EXCHANGE_O365 class ShortNotes(WellknownFolder): @@ -516,6 +525,7 @@

                                        Module exchangelib.folders.known_folders

                                        class TemporarySaves(WellknownFolder): DISTINGUISHED_FOLDER_ID = "temporarysaves" + supported_from = EXCHANGE_O365 class ToDoSearch(WellknownFolder): @@ -696,6 +706,10 @@

                                        Module exchangelib.folders.known_folders

                                        pass +class ShadowItems(NonDeletableFolder): + CONTAINER_CLASS = "IPF.StoreItem.ShadowItems" + + class Sharing(NonDeletableFolder): CONTAINER_CLASS = "IPF.Note" @@ -766,6 +780,7 @@

                                        Module exchangelib.folders.known_folders

                                        RSSFeeds, Reminders, Schedule, + ShadowItems, Sharing, Shortcuts, Signal, @@ -785,7 +800,7 @@

                                        Module exchangelib.folders.known_folders

                                        AllItems, AllPersonMetadata, Calendar, - Companies, + CompanyContacts, Conflicts, Contacts, ConversationHistory, @@ -1038,7 +1053,8 @@

                                        Inherited members

                                        class AllContacts(WellknownFolder):
                                             DISTINGUISHED_FOLDER_ID = "allcontacts"
                                        -    CONTAINER_CLASS = "IPF.Note"
                                        + CONTAINER_CLASS = "IPF.Note" + supported_from = EXCHANGE_O365

                                        Ancestors

                                          @@ -1061,6 +1077,10 @@

                                          Class variables

                                          +
                                          var supported_from
                                          +
                                          +
                                          +

                                          Inherited members

                                            @@ -1112,7 +1132,8 @@

                                            Inherited members

                                            class AllItems(WellknownFolder):
                                                 DISTINGUISHED_FOLDER_ID = "allitems"
                                            -    CONTAINER_CLASS = "IPF"
                                            + CONTAINER_CLASS = "IPF" + supported_from = EXCHANGE_O365

                                            Ancestors

                                              @@ -1135,6 +1156,10 @@

                                              Class variables

                                              +
                                              var supported_from
                                              +
                                              +
                                              +

                                              Inherited members

                                                @@ -1186,7 +1211,8 @@

                                                Inherited members

                                                class AllPersonMetadata(WellknownFolder):
                                                     DISTINGUISHED_FOLDER_ID = "allpersonmetadata"
                                                -    CONTAINER_CLASS = "IPF.Note"
                                                + CONTAINER_CLASS = "IPF.Note" + supported_from = EXCHANGE_O365

                                                Ancestors

                                                  @@ -1209,6 +1235,10 @@

                                                  Class variables

                                                  +
                                                  var supported_from
                                                  +
                                                  +
                                                  +

                                                  Inherited members

                                                    @@ -2384,8 +2414,8 @@

                                                    Inherited members

                                                  -
                                                  -class Companies +
                                                  +class CompanyContacts (**kwargs)
                                                  @@ -2394,9 +2424,10 @@

                                                  Inherited members

                                                  Expand source code -
                                                  class Companies(WellknownFolder):
                                                  +
                                                  class CompanyContacts(WellknownFolder):
                                                       DISTINGUISHED_FOLDER_ID = "companycontacts"
                                                       CONTAINER_CLASS = "IPF.Contact.Company"
                                                  +    supported_from = EXCHANGE_O365
                                                       supported_item_models = CONTACT_ITEM_CLASSES
                                                       LOCALIZED_NAMES = {
                                                           "da_DK": ("Firmaer",),
                                                  @@ -2415,19 +2446,23 @@ 

                                                  Ancestors

                                                Class variables

                                                -
                                                var CONTAINER_CLASS
                                                +
                                                var CONTAINER_CLASS
                                                +
                                                +
                                                +
                                                +
                                                var DISTINGUISHED_FOLDER_ID
                                                -
                                                var DISTINGUISHED_FOLDER_ID
                                                +
                                                var LOCALIZED_NAMES
                                                -
                                                var LOCALIZED_NAMES
                                                +
                                                var supported_from
                                                -
                                                var supported_item_models
                                                +
                                                var supported_item_models
                                                @@ -4004,6 +4039,7 @@

                                                Inherited members

                                                class FromFavoriteSenders(WellknownFolder):
                                                     DISTINGUISHED_FOLDER_ID = "fromfavoritesenders"
                                                     CONTAINER_CLASS = "IPF.Note"
                                                +    supported_from = EXCHANGE_O365
                                                     LOCALIZED_NAMES = {
                                                         "da_DK": ("Personer jeg kender",),
                                                     }
                                                @@ -4033,6 +4069,10 @@

                                                Class variables

                                                +
                                                var supported_from
                                                +
                                                +
                                                +

                                                Inherited members

                                                  @@ -4406,7 +4446,8 @@

                                                  Inherited members

                                                  Expand source code
                                                  class Inference(WellknownFolder):
                                                  -    DISTINGUISHED_FOLDER_ID = "inference"
                                                  + DISTINGUISHED_FOLDER_ID = "inference" + supported_from = EXCHANGE_O365

                                                  Ancestors

                                                    @@ -4425,6 +4466,10 @@

                                                    Class variables

                                                    +
                                                    var supported_from
                                                    +
                                                    +
                                                    +

                                                    Inherited members

                                                      @@ -5198,6 +5243,7 @@

                                                      Subclasses

                                                    • RSSFeeds
                                                    • Reminders
                                                    • Schedule
                                                    • +
                                                    • ShadowItems
                                                    • Sharing
                                                    • Shortcuts
                                                    • Signal
                                                    • @@ -5809,6 +5855,7 @@

                                                      Inherited members

                                                      class PeopleCentricConversationBuddies(WellknownFolder):
                                                           DISTINGUISHED_FOLDER_ID = "peoplecentricconversationbuddies"
                                                           CONTAINER_CLASS = "IPF.Contact.PeopleCentricConversationBuddies"
                                                      +    supported_from = EXCHANGE_O365
                                                           LOCALIZED_NAMES = {
                                                               None: ("PeopleCentricConversation Buddies",),
                                                           }
                                                      @@ -5838,6 +5885,10 @@

                                                      Class variables

                                                      +
                                                      var supported_from
                                                      +
                                                      +
                                                      +

                                                      Inherited members

                                                    • @@ -9730,9 +9873,16 @@

                                                      ShadowItems

                                                      + +
                                                    • +
                                                    • SharePointNotifications

                                                    • @@ -9815,6 +9965,7 @@

                                                      TemporarySaves

                                                    • diff --git a/docs/exchangelib/folders/roots.html b/docs/exchangelib/folders/roots.html index 089b61cd..269eca9c 100644 --- a/docs/exchangelib/folders/roots.html +++ b/docs/exchangelib/folders/roots.html @@ -360,6 +360,19 @@

                                                      Module exchangelib.folders.roots

                                                      if self._distinguished_id: self._distinguished_id.mailbox = None # See DistinguishedFolderId.clean() + @property + def _folders_map(self): + # Top-level public folders may point to the root folder of the owning account and not the public folders root + # of this account. This breaks the assumption of get_children(). Fix it by overwriting the parent folder. + fix_parents = self._subfolders is None + res = super()._folders_map + if fix_parents: + with self._subfolders_lock: + for f in res.values(): + if f.id != self.id: + f.parent = self + return res + def get_children(self, folder): # EWS does not allow deep traversal of public folders, so self._folders_map will only populate the top-level # subfolders. To traverse public folders at arbitrary depth, we need to get child folders on demand. @@ -518,6 +531,19 @@

                                                      Inherited members

                                                      if self._distinguished_id: self._distinguished_id.mailbox = None # See DistinguishedFolderId.clean() + @property + def _folders_map(self): + # Top-level public folders may point to the root folder of the owning account and not the public folders root + # of this account. This breaks the assumption of get_children(). Fix it by overwriting the parent folder. + fix_parents = self._subfolders is None + res = super()._folders_map + if fix_parents: + with self._subfolders_lock: + for f in res.values(): + if f.id != self.id: + f.parent = self + return res + def get_children(self, folder): # EWS does not allow deep traversal of public folders, so self._folders_map will only populate the top-level # subfolders. To traverse public folders at arbitrary depth, we need to get child folders on demand. diff --git a/docs/exchangelib/index.html b/docs/exchangelib/index.html index d819c223..ce03f73f 100644 --- a/docs/exchangelib/index.html +++ b/docs/exchangelib/index.html @@ -64,7 +64,7 @@

                                                      Package exchangelib

                                                      from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "5.4.0" +__version__ = "5.4.1" __all__ = [ "AcceptItem", @@ -7677,7 +7677,7 @@

                                                      Inherited members

                                                      def people(self): return QuerySet(self).people() - def view(self, start, end, max_items=None, *args, **kwargs): + def view(self, start, end, max_items=None): """Implement the CalendarView option to FindItem. The difference between 'filter' and 'view' is that 'filter' only returns the master CalendarItem for recurring items, while 'view' unfolds recurring items and returns all CalendarItem occurrences as one would normally expect when presenting a calendar. @@ -7695,7 +7695,7 @@

                                                      Inherited members

                                                      :param max_items: (Default value = None) :return: """ - qs = QuerySet(self).filter(*args, **kwargs) + qs = QuerySet(self) qs.calendar_view = CalendarView(start=start, end=end, max_items=max_items) return qs @@ -8782,7 +8782,7 @@

                                                      Examples

                                                      -def view(self, start, end, max_items=None, *args, **kwargs) +def view(self, start, end, max_items=None)

                                                      Implement the CalendarView option to FindItem. The difference between 'filter' and 'view' is that 'filter' @@ -8802,7 +8802,7 @@

                                                      Examples

                                                      Expand source code -
                                                      def view(self, start, end, max_items=None, *args, **kwargs):
                                                      +
                                                      def view(self, start, end, max_items=None):
                                                           """Implement the CalendarView option to FindItem. The difference between 'filter' and 'view' is that 'filter'
                                                           only returns the master CalendarItem for recurring items, while 'view' unfolds recurring items and returns all
                                                           CalendarItem occurrences as one would normally expect when presenting a calendar.
                                                      @@ -8820,7 +8820,7 @@ 

                                                      Examples

                                                      :param max_items: (Default value = None) :return: """ - qs = QuerySet(self).filter(*args, **kwargs) + qs = QuerySet(self) qs.calendar_view = CalendarView(start=start, end=end, max_items=max_items) return qs
                                                      diff --git a/docs/exchangelib/properties.html b/docs/exchangelib/properties.html index 7bab2a1f..f8178df5 100644 --- a/docs/exchangelib/properties.html +++ b/docs/exchangelib/properties.html @@ -2046,7 +2046,7 @@

                                                      Module exchangelib.properties

                                                      "external_mailbox_server": "ExternalMailboxServer", "external_mailbox_server_requires_ssl": "ExternalMailboxServerRequiresSSL", "external_mailbox_server_authentication_methods": "ExternalMailboxServerAuthenticationMethods", - "ecp_voicemail_url_fragment,": "EcpVoicemailUrlFragment,", + "ecp_voicemail_url_fragment,": "EcpVoicemailUrlFragment", "ecp_email_subscriptions_url_fragment": "EcpEmailSubscriptionsUrlFragment", "ecp_text_messaging_url_fragment": "EcpTextMessagingUrlFragment", "ecp_delivery_report_url_fragment": "EcpDeliveryReportUrlFragment", @@ -2119,15 +2119,21 @@

                                                      Module exchangelib.properties

                                                      if self.error_code == "InvalidUser": raise ErrorNonExistentMailbox(self.error_message) if self.error_code in ( + "InvalidDomain", "InvalidRequest", "InvalidSetting", - "SettingIsNotAvailable", - "InvalidDomain", "NotFederated", + "SettingIsNotAvailable", ): raise AutoDiscoverFailed(f"{self.error_code}: {self.error_message}") - if self.user_settings_errors: - raise AutoDiscoverFailed(f"User settings errors: {self.user_settings_errors}") + errors_to_report = {} + for field_name, (error, message) in (self.user_settings_errors or {}).items(): + if error in ("InvalidSetting", "SettingIsNotAvailable") and field_name in self.SETTINGS_MAP: + # Setting is not available for this user or is unknown to this server. Harmless. + continue + errors_to_report[field_name] = (error, message) + if errors_to_report: + raise AutoDiscoverFailed(f"User settings errors: {errors_to_report}") @classmethod def parse_elem(cls, elem): @@ -11627,7 +11633,7 @@

                                                      Inherited members

                                                      "external_mailbox_server": "ExternalMailboxServer", "external_mailbox_server_requires_ssl": "ExternalMailboxServerRequiresSSL", "external_mailbox_server_authentication_methods": "ExternalMailboxServerAuthenticationMethods", - "ecp_voicemail_url_fragment,": "EcpVoicemailUrlFragment,", + "ecp_voicemail_url_fragment,": "EcpVoicemailUrlFragment", "ecp_email_subscriptions_url_fragment": "EcpEmailSubscriptionsUrlFragment", "ecp_text_messaging_url_fragment": "EcpTextMessagingUrlFragment", "ecp_delivery_report_url_fragment": "EcpDeliveryReportUrlFragment", @@ -11700,15 +11706,21 @@

                                                      Inherited members

                                                      if self.error_code == "InvalidUser": raise ErrorNonExistentMailbox(self.error_message) if self.error_code in ( + "InvalidDomain", "InvalidRequest", "InvalidSetting", - "SettingIsNotAvailable", - "InvalidDomain", "NotFederated", + "SettingIsNotAvailable", ): raise AutoDiscoverFailed(f"{self.error_code}: {self.error_message}") - if self.user_settings_errors: - raise AutoDiscoverFailed(f"User settings errors: {self.user_settings_errors}") + errors_to_report = {} + for field_name, (error, message) in (self.user_settings_errors or {}).items(): + if error in ("InvalidSetting", "SettingIsNotAvailable") and field_name in self.SETTINGS_MAP: + # Setting is not available for this user or is unknown to this server. Harmless. + continue + errors_to_report[field_name] = (error, message) + if errors_to_report: + raise AutoDiscoverFailed(f"User settings errors: {errors_to_report}") @classmethod def parse_elem(cls, elem): @@ -11936,15 +11948,21 @@

                                                      Methods

                                                      if self.error_code == "InvalidUser": raise ErrorNonExistentMailbox(self.error_message) if self.error_code in ( + "InvalidDomain", "InvalidRequest", "InvalidSetting", - "SettingIsNotAvailable", - "InvalidDomain", "NotFederated", + "SettingIsNotAvailable", ): raise AutoDiscoverFailed(f"{self.error_code}: {self.error_message}") - if self.user_settings_errors: - raise AutoDiscoverFailed(f"User settings errors: {self.user_settings_errors}")
                                                      + errors_to_report = {} + for field_name, (error, message) in (self.user_settings_errors or {}).items(): + if error in ("InvalidSetting", "SettingIsNotAvailable") and field_name in self.SETTINGS_MAP: + # Setting is not available for this user or is unknown to this server. Harmless. + continue + errors_to_report[field_name] = (error, message) + if errors_to_report: + raise AutoDiscoverFailed(f"User settings errors: {errors_to_report}")
                                                      diff --git a/docs/exchangelib/services/common.html b/docs/exchangelib/services/common.html index b9ed6056..a4b6ee0c 100644 --- a/docs/exchangelib/services/common.html +++ b/docs/exchangelib/services/common.html @@ -249,6 +249,7 @@

                                                      Module exchangelib.services.common

                                                      yield self._elem_to_obj(elem) def _elem_to_obj(self, elem): + """Convert a single XML element to a single Python object""" if not self.returns_elements: raise RuntimeError("Incorrect call to method when 'returns_elements' is False") raise NotImplementedError() @@ -363,7 +364,7 @@

                                                      Module exchangelib.services.common

                                                      self.stop_streaming() def _handle_response_cookies(self, session): - pass + """Code to react on response cookies""" def _get_response(self, payload, api_version): """Send the actual HTTP request and get the response.""" @@ -403,13 +404,13 @@

                                                      Module exchangelib.services.common

                                                      v for v in self.supported_api_versions() if v != self._version_hint.api_version ) - def _get_response_xml(self, payload, **parse_opts): + def _get_response_xml(self, payload): """Send the payload to the server and return relevant elements from the result. Several things happen here: - * The payload is wrapped in SOAP headers and sent to the server - * The Exchange API version is negotiated and stored in the protocol object - * Connection errors are handled and possibly reraised as ErrorServerBusy - * SOAP errors are raised - * EWS errors are raised, or passed on to the caller + * Wraps the payload is wrapped in SOAP headers and sends to the server + * Negotiates the Exchange API version and stores it in the protocol object + * Handles connection errors and possibly re-raises them as ErrorServerBusy + * Raises SOAP errors + * Raises EWS errors or passes them on to the caller :param payload: The request payload, as an XML object :return: A generator of XML objects or None if the service does not return a result @@ -425,15 +426,15 @@

                                                      Module exchangelib.services.common

                                                      # Let 'requests' decode raw data automatically r.raw.decode_content = True try: - header, body = self._get_soap_parts(response=r, **parse_opts) + header, body = self._get_soap_parts(response=r) except Exception: r.close() # Release memory raise # The body may contain error messages from Exchange, but we still want to collect version info if header is not None: - self._update_api_version(api_version=api_version, header=header, **parse_opts) + self._update_api_version(api_version=api_version, header=header) try: - return self._get_soap_messages(body=body, **parse_opts) + return self._get_soap_messages(body=body) except ( ErrorInvalidServerVersion, ErrorIncorrectSchemaVersion, @@ -466,7 +467,7 @@

                                                      Module exchangelib.services.common

                                                      self.protocol.retry_policy.back_off(e.back_off) # We'll warn about this later if we actually need to sleep - def _update_api_version(self, api_version, header, **parse_opts): + def _update_api_version(self, api_version, header): """Parse the server version contained in SOAP headers and update the version hint stored by the caller, if necessary. """ @@ -499,7 +500,7 @@

                                                      Module exchangelib.services.common

                                                      return f"{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage" @classmethod - def _get_soap_parts(cls, response, **parse_opts): + def _get_soap_parts(cls, response): """Split the SOAP response into its headers and body elements.""" try: root = to_xml(response.iter_content()) @@ -514,7 +515,7 @@

                                                      Module exchangelib.services.common

                                                      raise MalformedResponseError("No Body element in SOAP response") return header, body - def _get_soap_messages(self, body, **parse_opts): + def _get_soap_messages(self, body): """Return the elements in the response containing the response messages. Raises any SOAP exceptions.""" response = body.find(self._response_tag()) if response is None: @@ -1630,6 +1631,7 @@

                                                      Inherited members

                                                      yield self._elem_to_obj(elem) def _elem_to_obj(self, elem): + """Convert a single XML element to a single Python object""" if not self.returns_elements: raise RuntimeError("Incorrect call to method when 'returns_elements' is False") raise NotImplementedError() @@ -1744,7 +1746,7 @@

                                                      Inherited members

                                                      self.stop_streaming() def _handle_response_cookies(self, session): - pass + """Code to react on response cookies""" def _get_response(self, payload, api_version): """Send the actual HTTP request and get the response.""" @@ -1784,13 +1786,13 @@

                                                      Inherited members

                                                      v for v in self.supported_api_versions() if v != self._version_hint.api_version ) - def _get_response_xml(self, payload, **parse_opts): + def _get_response_xml(self, payload): """Send the payload to the server and return relevant elements from the result. Several things happen here: - * The payload is wrapped in SOAP headers and sent to the server - * The Exchange API version is negotiated and stored in the protocol object - * Connection errors are handled and possibly reraised as ErrorServerBusy - * SOAP errors are raised - * EWS errors are raised, or passed on to the caller + * Wraps the payload is wrapped in SOAP headers and sends to the server + * Negotiates the Exchange API version and stores it in the protocol object + * Handles connection errors and possibly re-raises them as ErrorServerBusy + * Raises SOAP errors + * Raises EWS errors or passes them on to the caller :param payload: The request payload, as an XML object :return: A generator of XML objects or None if the service does not return a result @@ -1806,15 +1808,15 @@

                                                      Inherited members

                                                      # Let 'requests' decode raw data automatically r.raw.decode_content = True try: - header, body = self._get_soap_parts(response=r, **parse_opts) + header, body = self._get_soap_parts(response=r) except Exception: r.close() # Release memory raise # The body may contain error messages from Exchange, but we still want to collect version info if header is not None: - self._update_api_version(api_version=api_version, header=header, **parse_opts) + self._update_api_version(api_version=api_version, header=header) try: - return self._get_soap_messages(body=body, **parse_opts) + return self._get_soap_messages(body=body) except ( ErrorInvalidServerVersion, ErrorIncorrectSchemaVersion, @@ -1847,7 +1849,7 @@

                                                      Inherited members

                                                      self.protocol.retry_policy.back_off(e.back_off) # We'll warn about this later if we actually need to sleep - def _update_api_version(self, api_version, header, **parse_opts): + def _update_api_version(self, api_version, header): """Parse the server version contained in SOAP headers and update the version hint stored by the caller, if necessary. """ @@ -1880,7 +1882,7 @@

                                                      Inherited members

                                                      return f"{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage" @classmethod - def _get_soap_parts(cls, response, **parse_opts): + def _get_soap_parts(cls, response): """Split the SOAP response into its headers and body elements.""" try: root = to_xml(response.iter_content()) @@ -1895,7 +1897,7 @@

                                                      Inherited members

                                                      raise MalformedResponseError("No Body element in SOAP response") return header, body - def _get_soap_messages(self, body, **parse_opts): + def _get_soap_messages(self, body): """Return the elements in the response containing the response messages. Raises any SOAP exceptions.""" response = body.find(self._response_tag()) if response is None: diff --git a/docs/exchangelib/services/get_attachment.html b/docs/exchangelib/services/get_attachment.html index 5f7c379c..a9cb27b6 100644 --- a/docs/exchangelib/services/get_attachment.html +++ b/docs/exchangelib/services/get_attachment.html @@ -93,23 +93,20 @@

                                                      Module exchangelib.services.get_attachment

                                                      payload.append(attachment_ids_element(items=items, version=self.account.version)) return payload - def _update_api_version(self, api_version, header, **parse_opts): - if not parse_opts.get("stream_file_content", False): - super()._update_api_version(api_version, header, **parse_opts) + def _update_api_version(self, api_version, header): + if not self.streaming: + super()._update_api_version(api_version, header) # TODO: We're skipping this part in streaming mode because StreamingBase64Parser cannot parse the SOAP header - @classmethod - def _get_soap_parts(cls, response, **parse_opts): - if not parse_opts.get("stream_file_content", False): - return super()._get_soap_parts(response, **parse_opts) - + def _get_soap_parts(self, response): + if not self.streaming: + return super()._get_soap_parts(response) # Pass the response unaltered. We want to use our custom streaming parser return None, response - def _get_soap_messages(self, body, **parse_opts): - if not parse_opts.get("stream_file_content", False): - return super()._get_soap_messages(body, **parse_opts) - + def _get_soap_messages(self, body): + if not self.streaming: + return super()._get_soap_messages(body) # 'body' is actually the raw response passed on by '_get_soap_parts' r = body parser = StreamingBase64Parser() @@ -129,13 +126,14 @@

                                                      Module exchangelib.services.get_attachment

                                                      ) self.streaming = True try: - yield from self._get_response_xml(payload=payload, stream_file_content=True) + yield from self._get_response_xml(payload=payload) except ElementNotFound as enf: # When the returned XML does not contain a Content element, ElementNotFound is thrown by parser.parse(). # Let the non-streaming SOAP parser parse the response and hook into the normal exception handling. # Wrap in DummyResponse because _get_soap_parts() expects an iter_content() method. response = DummyResponse(content=enf.data) _, body = super()._get_soap_parts(response=response) + # TODO: We're skipping ._update_api_version() here because we don't have access to the 'api_version' used. res = super()._get_soap_messages(body=body) for e in self._get_elements_in_response(response=res): if isinstance(e, Exception): @@ -213,23 +211,20 @@

                                                      Classes

                                                      payload.append(attachment_ids_element(items=items, version=self.account.version)) return payload - def _update_api_version(self, api_version, header, **parse_opts): - if not parse_opts.get("stream_file_content", False): - super()._update_api_version(api_version, header, **parse_opts) + def _update_api_version(self, api_version, header): + if not self.streaming: + super()._update_api_version(api_version, header) # TODO: We're skipping this part in streaming mode because StreamingBase64Parser cannot parse the SOAP header - @classmethod - def _get_soap_parts(cls, response, **parse_opts): - if not parse_opts.get("stream_file_content", False): - return super()._get_soap_parts(response, **parse_opts) - + def _get_soap_parts(self, response): + if not self.streaming: + return super()._get_soap_parts(response) # Pass the response unaltered. We want to use our custom streaming parser return None, response - def _get_soap_messages(self, body, **parse_opts): - if not parse_opts.get("stream_file_content", False): - return super()._get_soap_messages(body, **parse_opts) - + def _get_soap_messages(self, body): + if not self.streaming: + return super()._get_soap_messages(body) # 'body' is actually the raw response passed on by '_get_soap_parts' r = body parser = StreamingBase64Parser() @@ -249,13 +244,14 @@

                                                      Classes

                                                      ) self.streaming = True try: - yield from self._get_response_xml(payload=payload, stream_file_content=True) + yield from self._get_response_xml(payload=payload) except ElementNotFound as enf: # When the returned XML does not contain a Content element, ElementNotFound is thrown by parser.parse(). # Let the non-streaming SOAP parser parse the response and hook into the normal exception handling. # Wrap in DummyResponse because _get_soap_parts() expects an iter_content() method. response = DummyResponse(content=enf.data) _, body = super()._get_soap_parts(response=response) + # TODO: We're skipping ._update_api_version() here because we don't have access to the 'api_version' used. res = super()._get_soap_messages(body=body) for e in self._get_elements_in_response(response=res): if isinstance(e, Exception): @@ -366,13 +362,14 @@

                                                      Methods

                                                      ) self.streaming = True try: - yield from self._get_response_xml(payload=payload, stream_file_content=True) + yield from self._get_response_xml(payload=payload) except ElementNotFound as enf: # When the returned XML does not contain a Content element, ElementNotFound is thrown by parser.parse(). # Let the non-streaming SOAP parser parse the response and hook into the normal exception handling. # Wrap in DummyResponse because _get_soap_parts() expects an iter_content() method. response = DummyResponse(content=enf.data) _, body = super()._get_soap_parts(response=response) + # TODO: We're skipping ._update_api_version() here because we don't have access to the 'api_version' used. res = super()._get_soap_messages(body=body) for e in self._get_elements_in_response(response=res): if isinstance(e, Exception): diff --git a/docs/exchangelib/services/get_streaming_events.html b/docs/exchangelib/services/get_streaming_events.html index e5cda087..f422c883 100644 --- a/docs/exchangelib/services/get_streaming_events.html +++ b/docs/exchangelib/services/get_streaming_events.html @@ -76,11 +76,11 @@

                                                      Module exchangelib.services.get_streaming_events< return Notification.from_xml(elem=elem, account=None) @classmethod - def _get_soap_parts(cls, response, **parse_opts): + def _get_soap_parts(cls, response): # Pass the response unaltered. We want to use our custom document yielder return None, response - def _get_soap_messages(self, body, **parse_opts): + def _get_soap_messages(self, body): # 'body' is actually the raw response passed on by '_get_soap_parts'. We want to continuously read the content, # looking for complete XML documents. When we have a full document, we want to parse it as if it was a normal # XML response. @@ -89,13 +89,13 @@

                                                      Module exchangelib.services.get_streaming_events< xml_log.debug("Response XML (docs counter: %(i)s): %(xml_response)s", dict(i=i, xml_response=doc)) response = DummyResponse(content=doc) try: - _, body = super()._get_soap_parts(response=response, **parse_opts) + _, body = super()._get_soap_parts(response=response) except Exception: r.close() # Release memory raise # TODO: We're skipping ._update_api_version() here because we don't have access to the 'api_version' used. # TODO: We should be doing a lot of error handling for ._get_soap_messages(). - yield from super()._get_soap_messages(body=body, **parse_opts) + yield from super()._get_soap_messages(body=body) if self.connection_status == self.CLOSED: # Don't wait for the TCP connection to timeout break @@ -125,9 +125,6 @@

                                                      Module exchangelib.services.get_streaming_events< subscriptions_elem = create_element("m:SubscriptionIds") for subscription_id in subscription_ids: add_xml_child(subscriptions_elem, "t:SubscriptionId", subscription_id) - if not len(subscriptions_elem): - raise ValueError("'subscription_ids' must not be empty") - payload.append(subscriptions_elem) add_xml_child(payload, "m:ConnectionTimeout", connection_timeout) return payload @@ -192,11 +189,11 @@

                                                      Classes

                                                      return Notification.from_xml(elem=elem, account=None) @classmethod - def _get_soap_parts(cls, response, **parse_opts): + def _get_soap_parts(cls, response): # Pass the response unaltered. We want to use our custom document yielder return None, response - def _get_soap_messages(self, body, **parse_opts): + def _get_soap_messages(self, body): # 'body' is actually the raw response passed on by '_get_soap_parts'. We want to continuously read the content, # looking for complete XML documents. When we have a full document, we want to parse it as if it was a normal # XML response. @@ -205,13 +202,13 @@

                                                      Classes

                                                      xml_log.debug("Response XML (docs counter: %(i)s): %(xml_response)s", dict(i=i, xml_response=doc)) response = DummyResponse(content=doc) try: - _, body = super()._get_soap_parts(response=response, **parse_opts) + _, body = super()._get_soap_parts(response=response) except Exception: r.close() # Release memory raise # TODO: We're skipping ._update_api_version() here because we don't have access to the 'api_version' used. # TODO: We should be doing a lot of error handling for ._get_soap_messages(). - yield from super()._get_soap_messages(body=body, **parse_opts) + yield from super()._get_soap_messages(body=body) if self.connection_status == self.CLOSED: # Don't wait for the TCP connection to timeout break @@ -241,9 +238,6 @@

                                                      Classes

                                                      subscriptions_elem = create_element("m:SubscriptionIds") for subscription_id in subscription_ids: add_xml_child(subscriptions_elem, "t:SubscriptionId", subscription_id) - if not len(subscriptions_elem): - raise ValueError("'subscription_ids' must not be empty") - payload.append(subscriptions_elem) add_xml_child(payload, "m:ConnectionTimeout", connection_timeout) return payload
                                                      @@ -319,9 +313,6 @@

                                                      Methods

                                                      subscriptions_elem = create_element("m:SubscriptionIds") for subscription_id in subscription_ids: add_xml_child(subscriptions_elem, "t:SubscriptionId", subscription_id) - if not len(subscriptions_elem): - raise ValueError("'subscription_ids' must not be empty") - payload.append(subscriptions_elem) add_xml_child(payload, "m:ConnectionTimeout", connection_timeout) return payload diff --git a/docs/exchangelib/services/get_user_settings.html b/docs/exchangelib/services/get_user_settings.html index 83745e5d..795fe28a 100644 --- a/docs/exchangelib/services/get_user_settings.html +++ b/docs/exchangelib/services/get_user_settings.html @@ -81,14 +81,10 @@

                                                      Module exchangelib.services.get_user_settings

                                                      Classes

    mailbox = create_element("a:Mailbox") set_xml_value(mailbox, user) add_xml_child(users_elem, "a:User", mailbox) - if not len(users_elem): - raise ValueError("'users' must not be empty") request.append(users_elem) requested_settings = create_element("a:RequestedSettings") for setting in settings: add_xml_child(requested_settings, "a:Setting", UserResponse.SETTINGS_MAP[setting]) - if not len(requested_settings): - raise ValueError("'requested_settings' must not be empty") request.append(requested_settings) payload.append(request) return payload @@ -276,14 +268,10 @@

    Methods

    mailbox = create_element("a:Mailbox") set_xml_value(mailbox, user) add_xml_child(users_elem, "a:User", mailbox) - if not len(users_elem): - raise ValueError("'users' must not be empty") request.append(users_elem) requested_settings = create_element("a:RequestedSettings") for setting in settings: add_xml_child(requested_settings, "a:Setting", UserResponse.SETTINGS_MAP[setting]) - if not len(requested_settings): - raise ValueError("'requested_settings' must not be empty") request.append(requested_settings) payload.append(request) return payload diff --git a/docs/exchangelib/services/index.html b/docs/exchangelib/services/index.html index b2779535..bddc49e5 100644 --- a/docs/exchangelib/services/index.html +++ b/docs/exchangelib/services/index.html @@ -1874,6 +1874,7 @@

    Inherited members

    yield self._elem_to_obj(elem) def _elem_to_obj(self, elem): + """Convert a single XML element to a single Python object""" if not self.returns_elements: raise RuntimeError("Incorrect call to method when 'returns_elements' is False") raise NotImplementedError() @@ -1988,7 +1989,7 @@

    Inherited members

    self.stop_streaming() def _handle_response_cookies(self, session): - pass + """Code to react on response cookies""" def _get_response(self, payload, api_version): """Send the actual HTTP request and get the response.""" @@ -2028,13 +2029,13 @@

    Inherited members

    v for v in self.supported_api_versions() if v != self._version_hint.api_version ) - def _get_response_xml(self, payload, **parse_opts): + def _get_response_xml(self, payload): """Send the payload to the server and return relevant elements from the result. Several things happen here: - * The payload is wrapped in SOAP headers and sent to the server - * The Exchange API version is negotiated and stored in the protocol object - * Connection errors are handled and possibly reraised as ErrorServerBusy - * SOAP errors are raised - * EWS errors are raised, or passed on to the caller + * Wraps the payload is wrapped in SOAP headers and sends to the server + * Negotiates the Exchange API version and stores it in the protocol object + * Handles connection errors and possibly re-raises them as ErrorServerBusy + * Raises SOAP errors + * Raises EWS errors or passes them on to the caller :param payload: The request payload, as an XML object :return: A generator of XML objects or None if the service does not return a result @@ -2050,15 +2051,15 @@

    Inherited members

    # Let 'requests' decode raw data automatically r.raw.decode_content = True try: - header, body = self._get_soap_parts(response=r, **parse_opts) + header, body = self._get_soap_parts(response=r) except Exception: r.close() # Release memory raise # The body may contain error messages from Exchange, but we still want to collect version info if header is not None: - self._update_api_version(api_version=api_version, header=header, **parse_opts) + self._update_api_version(api_version=api_version, header=header) try: - return self._get_soap_messages(body=body, **parse_opts) + return self._get_soap_messages(body=body) except ( ErrorInvalidServerVersion, ErrorIncorrectSchemaVersion, @@ -2091,7 +2092,7 @@

    Inherited members

    self.protocol.retry_policy.back_off(e.back_off) # We'll warn about this later if we actually need to sleep - def _update_api_version(self, api_version, header, **parse_opts): + def _update_api_version(self, api_version, header): """Parse the server version contained in SOAP headers and update the version hint stored by the caller, if necessary. """ @@ -2124,7 +2125,7 @@

    Inherited members

    return f"{{{MNS}}}{cls.SERVICE_NAME}ResponseMessage" @classmethod - def _get_soap_parts(cls, response, **parse_opts): + def _get_soap_parts(cls, response): """Split the SOAP response into its headers and body elements.""" try: root = to_xml(response.iter_content()) @@ -2139,7 +2140,7 @@

    Inherited members

    raise MalformedResponseError("No Body element in SOAP response") return header, body - def _get_soap_messages(self, body, **parse_opts): + def _get_soap_messages(self, body): """Return the elements in the response containing the response messages. Raises any SOAP exceptions.""" response = body.find(self._response_tag()) if response is None: @@ -3630,23 +3631,20 @@

    Inherited members

    payload.append(attachment_ids_element(items=items, version=self.account.version)) return payload - def _update_api_version(self, api_version, header, **parse_opts): - if not parse_opts.get("stream_file_content", False): - super()._update_api_version(api_version, header, **parse_opts) + def _update_api_version(self, api_version, header): + if not self.streaming: + super()._update_api_version(api_version, header) # TODO: We're skipping this part in streaming mode because StreamingBase64Parser cannot parse the SOAP header - @classmethod - def _get_soap_parts(cls, response, **parse_opts): - if not parse_opts.get("stream_file_content", False): - return super()._get_soap_parts(response, **parse_opts) - + def _get_soap_parts(self, response): + if not self.streaming: + return super()._get_soap_parts(response) # Pass the response unaltered. We want to use our custom streaming parser return None, response - def _get_soap_messages(self, body, **parse_opts): - if not parse_opts.get("stream_file_content", False): - return super()._get_soap_messages(body, **parse_opts) - + def _get_soap_messages(self, body): + if not self.streaming: + return super()._get_soap_messages(body) # 'body' is actually the raw response passed on by '_get_soap_parts' r = body parser = StreamingBase64Parser() @@ -3666,13 +3664,14 @@

    Inherited members

    ) self.streaming = True try: - yield from self._get_response_xml(payload=payload, stream_file_content=True) + yield from self._get_response_xml(payload=payload) except ElementNotFound as enf: # When the returned XML does not contain a Content element, ElementNotFound is thrown by parser.parse(). # Let the non-streaming SOAP parser parse the response and hook into the normal exception handling. # Wrap in DummyResponse because _get_soap_parts() expects an iter_content() method. response = DummyResponse(content=enf.data) _, body = super()._get_soap_parts(response=response) + # TODO: We're skipping ._update_api_version() here because we don't have access to the 'api_version' used. res = super()._get_soap_messages(body=body) for e in self._get_elements_in_response(response=res): if isinstance(e, Exception): @@ -3783,13 +3782,14 @@

    Methods

    ) self.streaming = True try: - yield from self._get_response_xml(payload=payload, stream_file_content=True) + yield from self._get_response_xml(payload=payload) except ElementNotFound as enf: # When the returned XML does not contain a Content element, ElementNotFound is thrown by parser.parse(). # Let the non-streaming SOAP parser parse the response and hook into the normal exception handling. # Wrap in DummyResponse because _get_soap_parts() expects an iter_content() method. response = DummyResponse(content=enf.data) _, body = super()._get_soap_parts(response=response) + # TODO: We're skipping ._update_api_version() here because we don't have access to the 'api_version' used. res = super()._get_soap_messages(body=body) for e in self._get_elements_in_response(response=res): if isinstance(e, Exception): @@ -5169,11 +5169,11 @@

    Inherited members

    return Notification.from_xml(elem=elem, account=None) @classmethod - def _get_soap_parts(cls, response, **parse_opts): + def _get_soap_parts(cls, response): # Pass the response unaltered. We want to use our custom document yielder return None, response - def _get_soap_messages(self, body, **parse_opts): + def _get_soap_messages(self, body): # 'body' is actually the raw response passed on by '_get_soap_parts'. We want to continuously read the content, # looking for complete XML documents. When we have a full document, we want to parse it as if it was a normal # XML response. @@ -5182,13 +5182,13 @@

    Inherited members

    xml_log.debug("Response XML (docs counter: %(i)s): %(xml_response)s", dict(i=i, xml_response=doc)) response = DummyResponse(content=doc) try: - _, body = super()._get_soap_parts(response=response, **parse_opts) + _, body = super()._get_soap_parts(response=response) except Exception: r.close() # Release memory raise # TODO: We're skipping ._update_api_version() here because we don't have access to the 'api_version' used. # TODO: We should be doing a lot of error handling for ._get_soap_messages(). - yield from super()._get_soap_messages(body=body, **parse_opts) + yield from super()._get_soap_messages(body=body) if self.connection_status == self.CLOSED: # Don't wait for the TCP connection to timeout break @@ -5218,9 +5218,6 @@

    Inherited members

    subscriptions_elem = create_element("m:SubscriptionIds") for subscription_id in subscription_ids: add_xml_child(subscriptions_elem, "t:SubscriptionId", subscription_id) - if not len(subscriptions_elem): - raise ValueError("'subscription_ids' must not be empty") - payload.append(subscriptions_elem) add_xml_child(payload, "m:ConnectionTimeout", connection_timeout) return payload @@ -5296,9 +5293,6 @@

    Methods

    subscriptions_elem = create_element("m:SubscriptionIds") for subscription_id in subscription_ids: add_xml_child(subscriptions_elem, "t:SubscriptionId", subscription_id) - if not len(subscriptions_elem): - raise ValueError("'subscription_ids' must not be empty") - payload.append(subscriptions_elem) add_xml_child(payload, "m:ConnectionTimeout", connection_timeout) return payload @@ -5725,14 +5719,10 @@

    Inherited members

    mailbox = create_element("a:Mailbox") set_xml_value(mailbox, user) add_xml_child(users_elem, "a:User", mailbox) - if not len(users_elem): - raise ValueError("'users' must not be empty") request.append(users_elem) requested_settings = create_element("a:RequestedSettings") for setting in settings: add_xml_child(requested_settings, "a:Setting", UserResponse.SETTINGS_MAP[setting]) - if not len(requested_settings): - raise ValueError("'requested_settings' must not be empty") request.append(requested_settings) payload.append(request) return payload @@ -5818,14 +5808,10 @@

    Methods

    mailbox = create_element("a:Mailbox") set_xml_value(mailbox, user) add_xml_child(users_elem, "a:User", mailbox) - if not len(users_elem): - raise ValueError("'users' must not be empty") request.append(users_elem) requested_settings = create_element("a:RequestedSettings") for setting in settings: add_xml_child(requested_settings, "a:Setting", UserResponse.SETTINGS_MAP[setting]) - if not len(requested_settings): - raise ValueError("'requested_settings' must not be empty") request.append(requested_settings) payload.append(request) return payload diff --git a/docs/exchangelib/services/subscribe.html b/docs/exchangelib/services/subscribe.html index 1b9ae971..484bdf34 100644 --- a/docs/exchangelib/services/subscribe.html +++ b/docs/exchangelib/services/subscribe.html @@ -82,8 +82,6 @@

    Module exchangelib.services.subscribe

    event_types_elem = create_element("t:EventTypes") for event_type in event_types: add_xml_child(event_types_elem, "t:EventType", event_type) - if not len(event_types_elem): - raise ValueError("'event_types' must not be empty") request_elem.append(event_types_elem) return request_elem @@ -221,8 +219,6 @@

    Classes

    event_types_elem = create_element("t:EventTypes") for event_type in event_types: add_xml_child(event_types_elem, "t:EventType", event_type) - if not len(event_types_elem): - raise ValueError("'event_types' must not be empty") request_elem.append(event_types_elem) return request_elem diff --git a/docs/exchangelib/util.html b/docs/exchangelib/util.html index 666efd08..e3a75255 100644 --- a/docs/exchangelib/util.html +++ b/docs/exchangelib/util.html @@ -450,10 +450,8 @@

    Module exchangelib.util

    return self._tell def read(self, size=-1): - # requests `iter_content()` auto-adjusts the number of bytes based on bandwidth - # can't assume how many bytes next returns so stash any extra in `self._next` - if self.closed: - raise ValueError("read from a closed file") + # requests `iter_content()` auto-adjusts the number of bytes based on bandwidth. + # We can't assume how many bytes next returns so stash any extra in `self._next`. if self._next is None: return b"" if size is None: @@ -562,10 +560,9 @@

    Module exchangelib.util

    offending_line = stream.read().splitlines()[e.lineno - 1] except (IndexError, io.UnsupportedOperation): raise ParseError(str(e), "<not from file>", e.lineno, e.offset) - else: - offending_excerpt = offending_line[max(0, e.offset - 20) : e.offset + 20] - msg = f'{e}\nOffending text: [...]{offending_excerpt.decode("utf-8", errors="ignore")}[...]' - raise ParseError(msg, "<not from file>", e.lineno, e.offset) + offending_excerpt = offending_line[max(0, e.offset - 20) : e.offset + 20] + msg = f'{e}\nOffending text: [...]{offending_excerpt.decode("utf-8", errors="ignore")}[...]' + raise ParseError(msg, "<not from file>", e.lineno, e.offset) except TypeError: with suppress(IndexError, io.UnsupportedOperation): stream.seek(0) @@ -604,7 +601,8 @@

    Module exchangelib.util

    class PrettyXmlHandler(logging.StreamHandler): """A steaming log handler that prettifies log statements containing XML when output is a terminal.""" - def parse_bytes(self, xml_bytes): + @staticmethod + def parse_bytes(xml_bytes): return to_xml(xml_bytes) def prettify_xml(self, xml_bytes): @@ -699,7 +697,7 @@

    Module exchangelib.util

    return self.content def close(self): - pass + """We don't have an actual socket to close""" def get_domain(email): @@ -1544,10 +1542,9 @@

    Functions

    offending_line = stream.read().splitlines()[e.lineno - 1] except (IndexError, io.UnsupportedOperation): raise ParseError(str(e), "<not from file>", e.lineno, e.offset) - else: - offending_excerpt = offending_line[max(0, e.offset - 20) : e.offset + 20] - msg = f'{e}\nOffending text: [...]{offending_excerpt.decode("utf-8", errors="ignore")}[...]' - raise ParseError(msg, "<not from file>", e.lineno, e.offset) + offending_excerpt = offending_line[max(0, e.offset - 20) : e.offset + 20] + msg = f'{e}\nOffending text: [...]{offending_excerpt.decode("utf-8", errors="ignore")}[...]' + raise ParseError(msg, "<not from file>", e.lineno, e.offset) except TypeError: with suppress(IndexError, io.UnsupportedOperation): stream.seek(0) @@ -1787,10 +1784,8 @@

    Inherited members

    return self._tell def read(self, size=-1): - # requests `iter_content()` auto-adjusts the number of bytes based on bandwidth - # can't assume how many bytes next returns so stash any extra in `self._next` - if self.closed: - raise ValueError("read from a closed file") + # requests `iter_content()` auto-adjusts the number of bytes based on bandwidth. + # We can't assume how many bytes next returns so stash any extra in `self._next`. if self._next is None: return b"" if size is None: @@ -1851,10 +1846,8 @@

    Methods

    Expand source code
    def read(self, size=-1):
    -    # requests `iter_content()` auto-adjusts the number of bytes based on bandwidth
    -    # can't assume how many bytes next returns so stash any extra in `self._next`
    -    if self.closed:
    -        raise ValueError("read from a closed file")
    +    # requests `iter_content()` auto-adjusts the number of bytes based on bandwidth.
    +    # We can't assume how many bytes next returns so stash any extra in `self._next`.
         if self._next is None:
             return b""
         if size is None:
    @@ -2022,7 +2015,7 @@ 

    Methods

    return self.content def close(self): - pass
    + """We don't have an actual socket to close"""

    Methods

    @@ -2030,13 +2023,13 @@

    Methods

    def close(self)
    -
    +

    We don't have an actual socket to close

    Expand source code
    def close(self):
    -    pass
    + """We don't have an actual socket to close"""
    @@ -2116,7 +2109,8 @@

    Ancestors

    class PrettyXmlHandler(logging.StreamHandler):
         """A steaming log handler that prettifies log statements containing XML when output is a terminal."""
     
    -    def parse_bytes(self, xml_bytes):
    +    @staticmethod
    +    def parse_bytes(xml_bytes):
             return to_xml(xml_bytes)
     
         def prettify_xml(self, xml_bytes):
    @@ -2189,6 +2183,20 @@ 

    Static methods

    return highlight(xml_str, XmlLexer(), TerminalFormatter()).rstrip()
    +
    +def parse_bytes(xml_bytes) +
    +
    +
    +
    + +Expand source code + +
    @staticmethod
    +def parse_bytes(xml_bytes):
    +    return to_xml(xml_bytes)
    +
    +

    Methods

    @@ -2246,19 +2254,6 @@

    Methods

    return False
    -
    -def parse_bytes(self, xml_bytes) -
    -
    -
    -
    - -Expand source code - -
    def parse_bytes(self, xml_bytes):
    -    return to_xml(xml_bytes)
    -
    -
    def prettify_xml(self, xml_bytes)
    diff --git a/docs/exchangelib/winzone.html b/docs/exchangelib/winzone.html index 013ebaaf..715564e4 100644 --- a/docs/exchangelib/winzone.html +++ b/docs/exchangelib/winzone.html @@ -668,13 +668,11 @@

    Module exchangelib.winzone

    ) # Reverse map from Microsoft timezone ID to IANA timezone name. Non-IANA timezone ID's can be added here. -MS_TIMEZONE_TO_IANA_MAP = dict( +MS_TIMEZONE_TO_IANA_MAP = { # Use the CLDR map because the IANA map contains deprecated aliases that not all systems support - {v[0]: k for k, v in CLDR_TO_MS_TIMEZONE_MAP.items() if v[1] == DEFAULT_TERRITORY}, - **{ - "tzone://Microsoft/Utc": "UTC", - }, -) + **{v[0]: k for k, v in CLDR_TO_MS_TIMEZONE_MAP.items() if v[1] == DEFAULT_TERRITORY}, + "tzone://Microsoft/Utc": "UTC", +}
    From 7350d3de1a7fa4b30d6caa386f93419d12257ac9 Mon Sep 17 00:00:00 2001 From: Jamaal Scarlett Date: Thu, 11 Jul 2024 05:12:03 -0400 Subject: [PATCH 466/509] Fixes error thrown when using NoVerifyHTTPAdapter with requests 2.32.3 (#1320) * Fixes error thrown when using NoVerifyHTTPAdapter with requests 2.32.3 Overrides get_connection_with_tls_context on the adapter to set verify=False. * Don't need docstring for overridden method --------- Co-authored-by: Erik Cederstrand --- exchangelib/protocol.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index 1d4d50c3..eed12974 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -638,6 +638,12 @@ def cert_verify(self, conn, url, verify, cert): # We're overriding a method, so we have to keep the signature super().cert_verify(conn=conn, url=url, verify=False, cert=cert) + def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None): + # pylint: disable=unused-argument + # Required for requests >= 2.32.3 + # See: https://github.com/psf/requests/pull/6710 + return super().get_connection_with_tls_context(request=request, verify=False, proxies=proxies, cert=cert) + class TLSClientAuth(requests.adapters.HTTPAdapter): """An HTTP adapter that implements Certificate Based Authentication (CBA).""" From 9462ad2f1e75d00fdf053054fde7ab3eb6cb1708 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 11 Jul 2024 12:24:54 +0200 Subject: [PATCH 467/509] fix: Give GetUserAvailability enough info to parse datetimes as tz-aware. Only warn about naive datetimes when they are unexpected. Fixes #1319 --- exchangelib/fields.py | 13 ++++++++++--- exchangelib/properties.py | 3 ++- exchangelib/protocol.py | 1 + exchangelib/services/get_user_availability.py | 16 ++++++++++++++-- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index b565c504..a51a1724 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -598,9 +598,10 @@ class DateTimeBackedDateField(DateField): def __init__(self, *args, **kwargs): # Not all fields assume a default time of 00:00, so make this configurable self._default_time = kwargs.pop("default_time", datetime.time(0, 0)) - super().__init__(*args, **kwargs) # Create internal field to handle datetime-only logic self._datetime_field = DateTimeField(*args, **kwargs) + kwargs.pop("allow_naive", None) + super().__init__(*args, **kwargs) def date_to_datetime(self, value): return self._datetime_field.value_cls.combine(value, self._default_time).replace(tzinfo=UTC) @@ -665,6 +666,10 @@ class DateTimeField(FieldURIField): value_cls = EWSDateTime + def __init__(self, *args, **kwargs): + self.allow_naive = kwargs.pop("allow_naive", False) + super().__init__(*args, **kwargs) + def clean(self, value, version=None): if isinstance(value, datetime.datetime): if not value.tzinfo: @@ -686,8 +691,10 @@ def from_xml(self, elem, account): tz = account.default_timezone log.info("Found naive datetime %s on field %s. Assuming timezone %s", e.local_dt, self.name, tz) return e.local_dt.replace(tzinfo=tz) - # There's nothing we can do but return the naive date. It's better than assuming e.g. UTC. - log.warning("Returning naive datetime %s on field %s", e.local_dt, self.name) + if not self.allow_naive: + # There's nothing we can do but return the naive date. It's better than assuming e.g. UTC. + # Making this a hard error is probably too risky. Warn instead. + log.warning("Returning naive datetime %s on field %s", e.local_dt, self.name) return e.local_dt log.info("Cannot convert value '%s' on field '%s' to type %s", val, self.name, self.value_cls) return None diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 537609b5..8d0955f5 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -1857,7 +1857,8 @@ class AbsoluteDateTransition(BaseTransition): ELEMENT_NAME = "AbsoluteDateTransition" - date = DateTimeBackedDateField(field_uri="DateTime") + # Values are returned as naive, and we have no timezone to hook up the values to yet + date = DateTimeBackedDateField(field_uri="DateTime", allow_naive=True) class RecurringDayTransition(BaseTransition): diff --git a/exchangelib/protocol.py b/exchangelib/protocol.py index eed12974..34bb8ce1 100644 --- a/exchangelib/protocol.py +++ b/exchangelib/protocol.py @@ -511,6 +511,7 @@ def get_free_busy_info(self, accounts, start, end, merged_free_busy_interval=30, tz_definition = list(self.get_timezones(timezones=[start.tzinfo], return_full_timezone_data=True))[0] return GetUserAvailability(self).call( + tzinfo=start.tzinfo, mailbox_data=[ MailboxData( email=account.primary_smtp_address if isinstance(account, Account) else account, diff --git a/exchangelib/services/get_user_availability.py b/exchangelib/services/get_user_availability.py index 975e3bab..15f7dfbc 100644 --- a/exchangelib/services/get_user_availability.py +++ b/exchangelib/services/get_user_availability.py @@ -1,3 +1,5 @@ +from collections import namedtuple + from ..properties import FreeBusyView from ..util import MNS, create_element, set_xml_value from .common import EWSService @@ -11,9 +13,14 @@ class GetUserAvailability(EWSService): SERVICE_NAME = "GetUserAvailability" - def call(self, mailbox_data, timezone, free_busy_view_options): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.tzinfo = None + + def call(self, tzinfo, mailbox_data, timezone, free_busy_view_options): # TODO: Also supports SuggestionsViewOptions, see # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/suggestionsviewoptions + self.tzinfo = tzinfo return self._elems_to_objs( self._chunked_get_elements( self.get_payload, @@ -23,8 +30,13 @@ def call(self, mailbox_data, timezone, free_busy_view_options): ) ) + @property + def _timezone(self): + return self.tzinfo + def _elem_to_obj(self, elem): - return FreeBusyView.from_xml(elem=elem, account=None) + fake_account = namedtuple("Account", ["default_timezone"])(default_timezone=self.tzinfo) + return FreeBusyView.from_xml(elem=elem, account=fake_account) def get_payload(self, mailbox_data, timezone, free_busy_view_options): payload = create_element(f"m:{self.SERVICE_NAME}Request") From 6bdddeb33a3480d7a947e74d48abb01ede304395 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Fri, 12 Jul 2024 02:10:21 +0200 Subject: [PATCH 468/509] Bump version --- CHANGELOG.md | 6 ++++++ exchangelib/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85bf14ac..14ed5763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ HEAD ---- +5.4.2 +----- +- Remove timezone warnings in `GetUserAvailability` +- Update `NoVerifyHTTPAdapter` for newer requests versions + + 5.4.1 ----- - Fix traversal of public folders in `Account.public_folders_root` diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index 7ac4de12..d56be1da 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -36,7 +36,7 @@ from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "5.4.1" +__version__ = "5.4.2" __all__ = [ "AcceptItem", From 07e189c2d8df86116df8f495dc49051c1da7da38 Mon Sep 17 00:00:00 2001 From: ecederstrand Date: Fri, 12 Jul 2024 02:11:21 +0200 Subject: [PATCH 469/509] docs: Update docs --- docs/exchangelib/fields.html | 26 ++++++++++--- docs/exchangelib/index.html | 27 ++++++++++++- docs/exchangelib/properties.html | 6 ++- docs/exchangelib/protocol.html | 34 +++++++++++++++- .../services/get_user_availability.html | 39 +++++++++++++++---- docs/exchangelib/services/index.html | 21 +++++++--- 6 files changed, 129 insertions(+), 24 deletions(-) diff --git a/docs/exchangelib/fields.html b/docs/exchangelib/fields.html index ac6784f7..4c364301 100644 --- a/docs/exchangelib/fields.html +++ b/docs/exchangelib/fields.html @@ -626,9 +626,10 @@

    Module exchangelib.fields

    def __init__(self, *args, **kwargs): # Not all fields assume a default time of 00:00, so make this configurable self._default_time = kwargs.pop("default_time", datetime.time(0, 0)) - super().__init__(*args, **kwargs) # Create internal field to handle datetime-only logic self._datetime_field = DateTimeField(*args, **kwargs) + kwargs.pop("allow_naive", None) + super().__init__(*args, **kwargs) def date_to_datetime(self, value): return self._datetime_field.value_cls.combine(value, self._default_time).replace(tzinfo=UTC) @@ -693,6 +694,10 @@

    Module exchangelib.fields

    value_cls = EWSDateTime + def __init__(self, *args, **kwargs): + self.allow_naive = kwargs.pop("allow_naive", False) + super().__init__(*args, **kwargs) + def clean(self, value, version=None): if isinstance(value, datetime.datetime): if not value.tzinfo: @@ -714,8 +719,10 @@

    Module exchangelib.fields

    tz = account.default_timezone log.info("Found naive datetime %s on field %s. Assuming timezone %s", e.local_dt, self.name, tz) return e.local_dt.replace(tzinfo=tz) - # There's nothing we can do but return the naive date. It's better than assuming e.g. UTC. - log.warning("Returning naive datetime %s on field %s", e.local_dt, self.name) + if not self.allow_naive: + # There's nothing we can do but return the naive date. It's better than assuming e.g. UTC. + # Making this a hard error is probably too risky. Warn instead. + log.warning("Returning naive datetime %s on field %s", e.local_dt, self.name) return e.local_dt log.info("Cannot convert value '%s' on field '%s' to type %s", val, self.name, self.value_cls) return None @@ -3063,9 +3070,10 @@

    Inherited members

    def __init__(self, *args, **kwargs): # Not all fields assume a default time of 00:00, so make this configurable self._default_time = kwargs.pop("default_time", datetime.time(0, 0)) - super().__init__(*args, **kwargs) # Create internal field to handle datetime-only logic self._datetime_field = DateTimeField(*args, **kwargs) + kwargs.pop("allow_naive", None) + super().__init__(*args, **kwargs) def date_to_datetime(self, value): return self._datetime_field.value_cls.combine(value, self._default_time).replace(tzinfo=UTC) @@ -3138,6 +3146,10 @@

    Inherited members

    value_cls = EWSDateTime + def __init__(self, *args, **kwargs): + self.allow_naive = kwargs.pop("allow_naive", False) + super().__init__(*args, **kwargs) + def clean(self, value, version=None): if isinstance(value, datetime.datetime): if not value.tzinfo: @@ -3159,8 +3171,10 @@

    Inherited members

    tz = account.default_timezone log.info("Found naive datetime %s on field %s. Assuming timezone %s", e.local_dt, self.name, tz) return e.local_dt.replace(tzinfo=tz) - # There's nothing we can do but return the naive date. It's better than assuming e.g. UTC. - log.warning("Returning naive datetime %s on field %s", e.local_dt, self.name) + if not self.allow_naive: + # There's nothing we can do but return the naive date. It's better than assuming e.g. UTC. + # Making this a hard error is probably too risky. Warn instead. + log.warning("Returning naive datetime %s on field %s", e.local_dt, self.name) return e.local_dt log.info("Cannot convert value '%s' on field '%s' to type %s", val, self.name, self.value_cls) return None diff --git a/docs/exchangelib/index.html b/docs/exchangelib/index.html index ce03f73f..c227954d 100644 --- a/docs/exchangelib/index.html +++ b/docs/exchangelib/index.html @@ -64,7 +64,7 @@

    Package exchangelib

    from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "5.4.1" +__version__ = "5.4.2" __all__ = [ "AcceptItem", @@ -9887,7 +9887,13 @@

    Inherited members

    def cert_verify(self, conn, url, verify, cert): # pylint: disable=unused-argument # We're overriding a method, so we have to keep the signature - super().cert_verify(conn=conn, url=url, verify=False, cert=cert) + super().cert_verify(conn=conn, url=url, verify=False, cert=cert) + + def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None): + # pylint: disable=unused-argument + # Required for requests >= 2.32.3 + # See: https://github.com/psf/requests/pull/6710 + return super().get_connection_with_tls_context(request=request, verify=False, proxies=proxies, cert=cert)

    Ancestors

      @@ -9919,6 +9925,22 @@

      Methods

      super().cert_verify(conn=conn, url=url, verify=False, cert=cert) +
      +def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None) +
      +
      +
      +
      + +Expand source code + +
      def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None):
      +    # pylint: disable=unused-argument
      +    # Required for requests >= 2.32.3
      +    # See: https://github.com/psf/requests/pull/6710
      +    return super().get_connection_with_tls_context(request=request, verify=False, proxies=proxies, cert=cert)
      +
      +
    @@ -13776,6 +13798,7 @@

    Message

    NoVerifyHTTPAdapter

  • diff --git a/docs/exchangelib/properties.html b/docs/exchangelib/properties.html index f8178df5..8752c1f8 100644 --- a/docs/exchangelib/properties.html +++ b/docs/exchangelib/properties.html @@ -1885,7 +1885,8 @@

    Module exchangelib.properties

    ELEMENT_NAME = "AbsoluteDateTransition" - date = DateTimeBackedDateField(field_uri="DateTime") + # Values are returned as naive, and we have no timezone to hook up the values to yet + date = DateTimeBackedDateField(field_uri="DateTime", allow_naive=True) class RecurringDayTransition(BaseTransition): @@ -2415,7 +2416,8 @@

    Classes

    ELEMENT_NAME = "AbsoluteDateTransition" - date = DateTimeBackedDateField(field_uri="DateTime")
    + # Values are returned as naive, and we have no timezone to hook up the values to yet + date = DateTimeBackedDateField(field_uri="DateTime", allow_naive=True)

    Ancestors

      diff --git a/docs/exchangelib/protocol.html b/docs/exchangelib/protocol.html index ed6062db..e20a6bf1 100644 --- a/docs/exchangelib/protocol.html +++ b/docs/exchangelib/protocol.html @@ -542,6 +542,7 @@

      Module exchangelib.protocol

      tz_definition = list(self.get_timezones(timezones=[start.tzinfo], return_full_timezone_data=True))[0] return GetUserAvailability(self).call( + tzinfo=start.tzinfo, mailbox_data=[ MailboxData( email=account.primary_smtp_address if isinstance(account, Account) else account, @@ -669,6 +670,12 @@

      Module exchangelib.protocol

      # We're overriding a method, so we have to keep the signature super().cert_verify(conn=conn, url=url, verify=False, cert=cert) + def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None): + # pylint: disable=unused-argument + # Required for requests >= 2.32.3 + # See: https://github.com/psf/requests/pull/6710 + return super().get_connection_with_tls_context(request=request, verify=False, proxies=proxies, cert=cert) + class TLSClientAuth(requests.adapters.HTTPAdapter): """An HTTP adapter that implements Certificate Based Authentication (CBA).""" @@ -2004,7 +2011,13 @@

      Inherited members

      def cert_verify(self, conn, url, verify, cert): # pylint: disable=unused-argument # We're overriding a method, so we have to keep the signature - super().cert_verify(conn=conn, url=url, verify=False, cert=cert) + super().cert_verify(conn=conn, url=url, verify=False, cert=cert) + + def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None): + # pylint: disable=unused-argument + # Required for requests >= 2.32.3 + # See: https://github.com/psf/requests/pull/6710 + return super().get_connection_with_tls_context(request=request, verify=False, proxies=proxies, cert=cert)

      Ancestors

        @@ -2036,6 +2049,22 @@

        Methods

        super().cert_verify(conn=conn, url=url, verify=False, cert=cert) +
        +def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None) +
        +
        +
        +
        + +Expand source code + +
        def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None):
        +    # pylint: disable=unused-argument
        +    # Required for requests >= 2.32.3
        +    # See: https://github.com/psf/requests/pull/6710
        +    return super().get_connection_with_tls_context(request=request, verify=False, proxies=proxies, cert=cert)
        +
        +
        @@ -2107,6 +2136,7 @@

        Methods

        tz_definition = list(self.get_timezones(timezones=[start.tzinfo], return_full_timezone_data=True))[0] return GetUserAvailability(self).call( + tzinfo=start.tzinfo, mailbox_data=[ MailboxData( email=account.primary_smtp_address if isinstance(account, Account) else account, @@ -2356,6 +2386,7 @@

        Methods

        tz_definition = list(self.get_timezones(timezones=[start.tzinfo], return_full_timezone_data=True))[0] return GetUserAvailability(self).call( + tzinfo=start.tzinfo, mailbox_data=[ MailboxData( email=account.primary_smtp_address if isinstance(account, Account) else account, @@ -2858,6 +2889,7 @@

        NoVerifyHTTPAdapter

      • diff --git a/docs/exchangelib/services/get_user_availability.html b/docs/exchangelib/services/get_user_availability.html index 9e966f85..bc86d0c0 100644 --- a/docs/exchangelib/services/get_user_availability.html +++ b/docs/exchangelib/services/get_user_availability.html @@ -26,7 +26,9 @@

        Module exchangelib.services.get_user_availability Expand source code -
        from ..properties import FreeBusyView
        +
        from collections import namedtuple
        +
        +from ..properties import FreeBusyView
         from ..util import MNS, create_element, set_xml_value
         from .common import EWSService
         
        @@ -39,9 +41,14 @@ 

        Module exchangelib.services.get_user_availability SERVICE_NAME = "GetUserAvailability" - def call(self, mailbox_data, timezone, free_busy_view_options): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.tzinfo = None + + def call(self, tzinfo, mailbox_data, timezone, free_busy_view_options): # TODO: Also supports SuggestionsViewOptions, see # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/suggestionsviewoptions + self.tzinfo = tzinfo return self._elems_to_objs( self._chunked_get_elements( self.get_payload, @@ -51,8 +58,13 @@

        Module exchangelib.services.get_user_availability ) ) + @property + def _timezone(self): + return self.tzinfo + def _elem_to_obj(self, elem): - return FreeBusyView.from_xml(elem=elem, account=None) + fake_account = namedtuple("Account", ["default_timezone"])(default_timezone=self.tzinfo) + return FreeBusyView.from_xml(elem=elem, account=fake_account) def get_payload(self, mailbox_data, timezone, free_busy_view_options): payload = create_element(f"m:{self.SERVICE_NAME}Request") @@ -94,7 +106,7 @@

        Classes

        class GetUserAvailability -(protocol, chunk_size=None, timeout=None) +(*args, **kwargs)

        Get detailed availability information for a list of users. @@ -112,9 +124,14 @@

        Classes

        SERVICE_NAME = "GetUserAvailability" - def call(self, mailbox_data, timezone, free_busy_view_options): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.tzinfo = None + + def call(self, tzinfo, mailbox_data, timezone, free_busy_view_options): # TODO: Also supports SuggestionsViewOptions, see # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/suggestionsviewoptions + self.tzinfo = tzinfo return self._elems_to_objs( self._chunked_get_elements( self.get_payload, @@ -124,8 +141,13 @@

        Classes

        ) ) + @property + def _timezone(self): + return self.tzinfo + def _elem_to_obj(self, elem): - return FreeBusyView.from_xml(elem=elem, account=None) + fake_account = namedtuple("Account", ["default_timezone"])(default_timezone=self.tzinfo) + return FreeBusyView.from_xml(elem=elem, account=fake_account) def get_payload(self, mailbox_data, timezone, free_busy_view_options): payload = create_element(f"m:{self.SERVICE_NAME}Request") @@ -170,7 +192,7 @@

        Class variables

        Methods

        -def call(self, mailbox_data, timezone, free_busy_view_options) +def call(self, tzinfo, mailbox_data, timezone, free_busy_view_options)
        @@ -178,9 +200,10 @@

        Methods

        Expand source code -
        def call(self, mailbox_data, timezone, free_busy_view_options):
        +
        def call(self, tzinfo, mailbox_data, timezone, free_busy_view_options):
             # TODO: Also supports SuggestionsViewOptions, see
             #  https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/suggestionsviewoptions
        +    self.tzinfo = tzinfo
             return self._elems_to_objs(
                 self._chunked_get_elements(
                     self.get_payload,
        diff --git a/docs/exchangelib/services/index.html b/docs/exchangelib/services/index.html
        index bddc49e5..5a80372b 100644
        --- a/docs/exchangelib/services/index.html
        +++ b/docs/exchangelib/services/index.html
        @@ -5315,7 +5315,7 @@ 

        Inherited members

        class GetUserAvailability -(protocol, chunk_size=None, timeout=None) +(*args, **kwargs)

        Get detailed availability information for a list of users. @@ -5333,9 +5333,14 @@

        Inherited members

        SERVICE_NAME = "GetUserAvailability" - def call(self, mailbox_data, timezone, free_busy_view_options): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.tzinfo = None + + def call(self, tzinfo, mailbox_data, timezone, free_busy_view_options): # TODO: Also supports SuggestionsViewOptions, see # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/suggestionsviewoptions + self.tzinfo = tzinfo return self._elems_to_objs( self._chunked_get_elements( self.get_payload, @@ -5345,8 +5350,13 @@

        Inherited members

        ) ) + @property + def _timezone(self): + return self.tzinfo + def _elem_to_obj(self, elem): - return FreeBusyView.from_xml(elem=elem, account=None) + fake_account = namedtuple("Account", ["default_timezone"])(default_timezone=self.tzinfo) + return FreeBusyView.from_xml(elem=elem, account=fake_account) def get_payload(self, mailbox_data, timezone, free_busy_view_options): payload = create_element(f"m:{self.SERVICE_NAME}Request") @@ -5391,7 +5401,7 @@

        Class variables

        Methods

        -def call(self, mailbox_data, timezone, free_busy_view_options) +def call(self, tzinfo, mailbox_data, timezone, free_busy_view_options)
        @@ -5399,9 +5409,10 @@

        Methods

        Expand source code -
        def call(self, mailbox_data, timezone, free_busy_view_options):
        +
        def call(self, tzinfo, mailbox_data, timezone, free_busy_view_options):
             # TODO: Also supports SuggestionsViewOptions, see
             #  https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/suggestionsviewoptions
        +    self.tzinfo = tzinfo
             return self._elems_to_objs(
                 self._chunked_get_elements(
                     self.get_payload,
        
        From 73a3b945322c765902cb9ceab6575fa1870300cb Mon Sep 17 00:00:00 2001
        From: ecederstrand 
        Date: Fri, 12 Jul 2024 02:33:02 +0200
        Subject: [PATCH 470/509] ci: Remove arvhived location of python-cffi
        
        ---
         .github/workflows/python-package.yml | 1 -
         1 file changed, 1 deletion(-)
        
        diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
        index 65e35b92..00d2c297 100644
        --- a/.github/workflows/python-package.yml
        +++ b/.github/workflows/python-package.yml
        @@ -61,7 +61,6 @@ jobs:
               if: matrix.python-version == '3.13-dev'
               run: |
                 sudo apt-get install libxml2-dev libxslt1-dev
        -        python -m pip install hg+https://foss.heptapod.net/pypy/cffi
                 python -m pip install git+https://github.com/cython/cython.git
                 python -m pip install git+https://github.com/lxml/lxml.git
                 python -m pip install git+https://github.com/yaml/pyyaml.git
        
        From e8a811d433c207a5147acf74900524cc0d60c637 Mon Sep 17 00:00:00 2001
        From: Erik Cederstrand 
        Date: Mon, 29 Jul 2024 14:13:16 +0200
        Subject: [PATCH 471/509] docs: Fix example code for shared folder access.
         Fixes #1325
        
        ---
         docs/index.md | 13 ++++++++-----
         1 file changed, 8 insertions(+), 5 deletions(-)
        
        diff --git a/docs/index.md b/docs/index.md
        index 415670ee..38777ff8 100644
        --- a/docs/index.md
        +++ b/docs/index.md
        @@ -199,18 +199,21 @@ johns_account = Account(
         ```
         
         If you want to impersonate an account and access a shared folder that this
        -account has access to, you need to specify the email adress of the shared
        +account has access to, you need to specify the email address of the shared
         folder to access the folder:
         
         ```python
        -from exchangelib.folders import Calendar, SingleFolderQuerySet
        +from exchangelib.folders import Calendar, Folder, SingleFolderQuerySet
         from exchangelib.properties import DistinguishedFolderId, Mailbox
         
         shared_calendar = SingleFolderQuerySet(
             account=johns_account,
        -    folder=DistinguishedFolderId(
        -        id=Calendar.DISTINGUISHED_FOLDER_ID,
        -        mailbox=Mailbox(email_address="mary@example.com"),
        +    folder=Folder(
        +        root=johns_account.root,
        +        _distinguished_id=DistinguishedFolderId(
        +            id=Calendar.DISTINGUISHED_FOLDER_ID,
        +            mailbox=Mailbox(email_address="mary@example.com"),
        +        ),
             ),
         ).resolve()
         ```
        
        From 88d04bf37cfd02f905755c6e6010fab64d51454e Mon Sep 17 00:00:00 2001
        From: Erik Cederstrand 
        Date: Mon, 2 Sep 2024 14:51:40 +0200
        Subject: [PATCH 472/509] chore: Bring in timezone updates
        
        ---
         exchangelib/winzone.py    |  4 ----
         tests/test_ewsdatetime.py | 18 +++++++++++-------
         2 files changed, 11 insertions(+), 11 deletions(-)
        
        diff --git a/exchangelib/winzone.py b/exchangelib/winzone.py
        index 3062122d..ac9c84a4 100644
        --- a/exchangelib/winzone.py
        +++ b/exchangelib/winzone.py
        @@ -350,8 +350,6 @@ def generate_map(timeout=10):
             "Australia/Melbourne": ("AUS Eastern Standard Time", "AU"),
             "Australia/Perth": ("W. Australia Standard Time", "001"),
             "Australia/Sydney": ("AUS Eastern Standard Time", "001"),
        -    "CST6CDT": ("Central Standard Time", "ZZ"),
        -    "EST5EDT": ("Eastern Standard Time", "ZZ"),
             "Etc/GMT": ("UTC", "ZZ"),
             "Etc/GMT+1": ("Cape Verde Standard Time", "ZZ"),
             "Etc/GMT+10": ("Hawaiian Standard Time", "ZZ"),
        @@ -449,8 +447,6 @@ def generate_map(timeout=10):
             "Indian/Mauritius": ("Mauritius Standard Time", "001"),
             "Indian/Mayotte": ("E. Africa Standard Time", "YT"),
             "Indian/Reunion": ("Mauritius Standard Time", "RE"),
        -    "MST7MDT": ("Mountain Standard Time", "ZZ"),
        -    "PST8PDT": ("Pacific Standard Time", "ZZ"),
             "Pacific/Apia": ("Samoa Standard Time", "001"),
             "Pacific/Auckland": ("New Zealand Standard Time", "001"),
             "Pacific/Bougainville": ("Bougainville Standard Time", "001"),
        diff --git a/tests/test_ewsdatetime.py b/tests/test_ewsdatetime.py
        index c4c05733..86e0ccb4 100644
        --- a/tests/test_ewsdatetime.py
        +++ b/tests/test_ewsdatetime.py
        @@ -230,17 +230,21 @@ def test_generate(self):
                 self.assertEqual(
                     set(sanitized) - set(EWSTimeZone.IANA_TO_MS_MAP),
                     {
        -                "Europe/Uzhgorod",
        +                "America/Montreal",
        +                "America/Nipigon",
        +                "America/Pangnirtung",
                         "America/Rainy_River",
                         "America/Santa_Isabel",
        -                "Australia/Currie",
        -                "Pacific/Johnston",
        -                "America/Pangnirtung",
        +                "America/Thunder_Bay",
                         "America/Yellowknife",
        +                "Australia/Currie",
        +                "CST6CDT",
        +                "EST5EDT",
        +                "Europe/Uzhgorod",
                         "Europe/Zaporozhye",
        -                "America/Montreal",
        -                "America/Nipigon",
        -                "America/Thunder_Bay",
        +                "MST7MDT",
        +                "PST8PDT",
        +                "Pacific/Johnston",
                     },
                 )
         
        
        From 4317e4b2d45996381e110656c36e5fbb7f7c61dc Mon Sep 17 00:00:00 2001
        From: Erik Cederstrand 
        Date: Mon, 2 Sep 2024 14:53:52 +0200
        Subject: [PATCH 473/509] Don't override a custom mailbox. Fixes #1325
        
        ---
         exchangelib/folders/base.py |  3 ++-
         tests/test_folder.py        | 12 ++++++++++++
         2 files changed, 14 insertions(+), 1 deletion(-)
        
        diff --git a/exchangelib/folders/base.py b/exchangelib/folders/base.py
        index a8b5647a..d9af1029 100644
        --- a/exchangelib/folders/base.py
        +++ b/exchangelib/folders/base.py
        @@ -96,7 +96,8 @@ def __init__(self, **kwargs):
                 self.item_sync_state = kwargs.pop("item_sync_state", None)
                 self.folder_sync_state = kwargs.pop("folder_sync_state", None)
                 super().__init__(**kwargs)
        -        if self._distinguished_id and self.account:
        +        if self._distinguished_id and not self._distinguished_id.mailbox and self.account:
        +            # Ensure that distinguished IDs have a mailbox, but don't override a custom mailbox (e.g. shared folders)
                     self._distinguished_id.mailbox = Mailbox(email_address=self.account.primary_smtp_address)
         
             @property
        diff --git a/tests/test_folder.py b/tests/test_folder.py
        index 13556afa..dde0c224 100644
        --- a/tests/test_folder.py
        +++ b/tests/test_folder.py
        @@ -500,6 +500,18 @@ def test_get_folders(self):
                         ).get_folders()
                     )
         
        +    def test_shared_folders(self):
        +        # Test that the custom 'email_address' is not overwritten. The test account does not have access to any shared
        +        # accounts, so we can't expand the test to actual queries.
        +        shared_folder = Folder(
        +            root=self.account.root,
        +            _distinguished_id=DistinguishedFolderId(
        +                id=Inbox.DISTINGUISHED_FOLDER_ID,
        +                mailbox=Mailbox(email_address="foo@example.com"),
        +            ),
        +        )
        +        self.assertEqual(shared_folder._distinguished_id.mailbox.email_address, "foo@example.com")
        +
             def test_folder_grouping(self):
                 # If you get errors here, you probably need to fill out [folder class].LOCALIZED_NAMES for your locale.
                 for f in self.account.root.walk():
        
        From 2a4f294aaf8d88e5eba7be3c3edfd69a58f275fa Mon Sep 17 00:00:00 2001
        From: ecederstrand 
        Date: Thu, 5 Sep 2024 09:32:01 +0200
        Subject: [PATCH 474/509] Bump version
        
        ---
         CHANGELOG.md            | 5 +++++
         exchangelib/__init__.py | 2 +-
         2 files changed, 6 insertions(+), 1 deletion(-)
        
        diff --git a/CHANGELOG.md b/CHANGELOG.md
        index 14ed5763..e1c2ec9a 100644
        --- a/CHANGELOG.md
        +++ b/CHANGELOG.md
        @@ -5,6 +5,11 @@ HEAD
         ----
         
         
        +5.4.3
        +-----
        +- Fix access to shared folders
        +
        +
         5.4.2
         -----
         - Remove timezone warnings in `GetUserAvailability`
        diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py
        index d56be1da..7f5776c6 100644
        --- a/exchangelib/__init__.py
        +++ b/exchangelib/__init__.py
        @@ -36,7 +36,7 @@
         from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI
         from .version import Build, Version
         
        -__version__ = "5.4.2"
        +__version__ = "5.4.3"
         
         __all__ = [
             "AcceptItem",
        
        From 4abf34cc2f87cf3a04375fdbfa3b41bbc48485b3 Mon Sep 17 00:00:00 2001
        From: ecederstrand 
        Date: Thu, 5 Sep 2024 09:32:46 +0200
        Subject: [PATCH 475/509] docs: Update docs
        
        ---
         docs/exchangelib/folders/base.html  | 6 ++++--
         docs/exchangelib/folders/index.html | 3 ++-
         docs/exchangelib/index.html         | 2 +-
         docs/exchangelib/winzone.html       | 4 ----
         4 files changed, 7 insertions(+), 8 deletions(-)
        
        diff --git a/docs/exchangelib/folders/base.html b/docs/exchangelib/folders/base.html
        index 6b8dc2b3..3aa92c5a 100644
        --- a/docs/exchangelib/folders/base.html
        +++ b/docs/exchangelib/folders/base.html
        @@ -124,7 +124,8 @@ 

        Module exchangelib.folders.base

        self.item_sync_state = kwargs.pop("item_sync_state", None) self.folder_sync_state = kwargs.pop("folder_sync_state", None) super().__init__(**kwargs) - if self._distinguished_id and self.account: + if self._distinguished_id and not self._distinguished_id.mailbox and self.account: + # Ensure that distinguished IDs have a mailbox, but don't override a custom mailbox (e.g. shared folders) self._distinguished_id.mailbox = Mailbox(email_address=self.account.primary_smtp_address) @property @@ -1039,7 +1040,8 @@

        Classes

        self.item_sync_state = kwargs.pop("item_sync_state", None) self.folder_sync_state = kwargs.pop("folder_sync_state", None) super().__init__(**kwargs) - if self._distinguished_id and self.account: + if self._distinguished_id and not self._distinguished_id.mailbox and self.account: + # Ensure that distinguished IDs have a mailbox, but don't override a custom mailbox (e.g. shared folders) self._distinguished_id.mailbox = Mailbox(email_address=self.account.primary_smtp_address) @property diff --git a/docs/exchangelib/folders/index.html b/docs/exchangelib/folders/index.html index df85f225..a0b44c5a 100644 --- a/docs/exchangelib/folders/index.html +++ b/docs/exchangelib/folders/index.html @@ -1561,7 +1561,8 @@

        Inherited members

        self.item_sync_state = kwargs.pop("item_sync_state", None) self.folder_sync_state = kwargs.pop("folder_sync_state", None) super().__init__(**kwargs) - if self._distinguished_id and self.account: + if self._distinguished_id and not self._distinguished_id.mailbox and self.account: + # Ensure that distinguished IDs have a mailbox, but don't override a custom mailbox (e.g. shared folders) self._distinguished_id.mailbox = Mailbox(email_address=self.account.primary_smtp_address) @property diff --git a/docs/exchangelib/index.html b/docs/exchangelib/index.html index c227954d..3e727087 100644 --- a/docs/exchangelib/index.html +++ b/docs/exchangelib/index.html @@ -64,7 +64,7 @@

        Package exchangelib

        from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "5.4.2" +__version__ = "5.4.3" __all__ = [ "AcceptItem", diff --git a/docs/exchangelib/winzone.html b/docs/exchangelib/winzone.html index 715564e4..2bea7901 100644 --- a/docs/exchangelib/winzone.html +++ b/docs/exchangelib/winzone.html @@ -379,8 +379,6 @@

        Module exchangelib.winzone

        "Australia/Melbourne": ("AUS Eastern Standard Time", "AU"), "Australia/Perth": ("W. Australia Standard Time", "001"), "Australia/Sydney": ("AUS Eastern Standard Time", "001"), - "CST6CDT": ("Central Standard Time", "ZZ"), - "EST5EDT": ("Eastern Standard Time", "ZZ"), "Etc/GMT": ("UTC", "ZZ"), "Etc/GMT+1": ("Cape Verde Standard Time", "ZZ"), "Etc/GMT+10": ("Hawaiian Standard Time", "ZZ"), @@ -478,8 +476,6 @@

        Module exchangelib.winzone

        "Indian/Mauritius": ("Mauritius Standard Time", "001"), "Indian/Mayotte": ("E. Africa Standard Time", "YT"), "Indian/Reunion": ("Mauritius Standard Time", "RE"), - "MST7MDT": ("Mountain Standard Time", "ZZ"), - "PST8PDT": ("Pacific Standard Time", "ZZ"), "Pacific/Apia": ("Samoa Standard Time", "001"), "Pacific/Auckland": ("New Zealand Standard Time", "001"), "Pacific/Bougainville": ("Bougainville Standard Time", "001"), From c1fba89ed5aff7b69ef8717915d2101b20cef8d3 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Fri, 4 Oct 2024 16:11:12 +0200 Subject: [PATCH 476/509] 'Asia/Choibalsan' is no longer in CLDR --- exchangelib/winzone.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exchangelib/winzone.py b/exchangelib/winzone.py index ac9c84a4..a041beff 100644 --- a/exchangelib/winzone.py +++ b/exchangelib/winzone.py @@ -264,7 +264,6 @@ def generate_map(timeout=10): "Asia/Brunei": ("Singapore Standard Time", "BN"), "Asia/Calcutta": ("India Standard Time", "001"), "Asia/Chita": ("Transbaikal Standard Time", "001"), - "Asia/Choibalsan": ("Ulaanbaatar Standard Time", "MN"), "Asia/Colombo": ("Sri Lanka Standard Time", "001"), "Asia/Damascus": ("Syria Standard Time", "001"), "Asia/Dhaka": ("Bangladesh Standard Time", "001"), @@ -516,6 +515,7 @@ def generate_map(timeout=10): "Antarctica/South_Pole": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Auckland"], "Antarctica/Troll": CLDR_TO_MS_TIMEZONE_MAP["Europe/Oslo"], "Asia/Ashkhabad": CLDR_TO_MS_TIMEZONE_MAP["Asia/Ashgabat"], + "Asia/Choibalsan": CLDR_TO_MS_TIMEZONE_MAP["Asia/Ulaanbaatar"], "Asia/Chongqing": CLDR_TO_MS_TIMEZONE_MAP["Asia/Shanghai"], "Asia/Chungking": CLDR_TO_MS_TIMEZONE_MAP["Asia/Shanghai"], "Asia/Dacca": CLDR_TO_MS_TIMEZONE_MAP["Asia/Dhaka"], From b4ce1873cc04f17f7e9203ed4840f51a480d44ce Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Fri, 4 Oct 2024 16:11:40 +0200 Subject: [PATCH 477/509] Some servers only accept UTC timestamps. Refs #1323 --- exchangelib/settings.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/exchangelib/settings.py b/exchangelib/settings.py index 35340b5b..8e57c179 100644 --- a/exchangelib/settings.py +++ b/exchangelib/settings.py @@ -35,6 +35,9 @@ def clean(self, version=None): raise ValueError("'start' must be before 'end'") if self.end < datetime.datetime.now(tz=UTC): raise ValueError("'end' must be in the future") + # Some servers only like UTC timestamps + self.start = self.start.astimezone(UTC) + self.end = self.end.astimezone(UTC) if self.state != self.DISABLED and (not self.internal_reply or not self.external_reply): raise ValueError(f"'internal_reply' and 'external_reply' must be set when state is not {self.DISABLED!r}") From 573f6b74e1729d06d5a63a140544e2f4ff8238df Mon Sep 17 00:00:00 2001 From: Stefan <96178532+stefan6419846@users.noreply.github.com> Date: Tue, 22 Oct 2024 14:59:55 +0200 Subject: [PATCH 478/509] Add data copyright information (#1337) --- exchangelib/winzone.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/exchangelib/winzone.py b/exchangelib/winzone.py index a041beff..78e9bf61 100644 --- a/exchangelib/winzone.py +++ b/exchangelib/winzone.py @@ -1,6 +1,11 @@ """A dict to translate from IANA location name to Windows timezone name. Translations taken from CLDR_WINZONE_URL """ +# Data taken from https://github.com/unicode-org/cldr/blob/main/common/supplemental/windowsZones.xml +# SPDX-FileCopyrightText: Copyright © 1991-2013 Unicode, Inc. +# SPDX-License-Identifier: Unicode-3.0 +# See https://www.unicode.org/license.txt for the license text. + import re import requests From 3b2b02c5d58d52bc184f46276508d7021721e566 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 28 Oct 2024 13:01:06 +0100 Subject: [PATCH 479/509] Drop support for Python 3.8 --- .github/workflows/python-package.yml | 8 ++++---- CHANGELOG.md | 1 + exchangelib/__init__.py | 2 +- exchangelib/ewsdatetime.py | 7 +------ pyproject.toml | 3 +-- scripts/optimize.py | 6 +----- tests/common.py | 6 +----- tests/test_ewsdatetime.py | 6 +----- tests/test_field.py | 6 +----- tests/test_items/test_contacts.py | 6 +----- tests/test_items/test_generic.py | 6 +----- tests/test_protocol.py | 6 +----- tests/test_restriction.py | 6 +----- 13 files changed, 16 insertions(+), 53 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 00d2c297..3016a424 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -29,10 +29,10 @@ jobs: needs: pre_job strategy: matrix: - python-version: ['3.8', '3.12'] + python-version: ['3.9', '3.13'] include: # Allow failure on Python dev - e.g. Cython install regularly fails - - python-version: "3.13-dev" + - python-version: "3.14-dev" allowed_failure: true max-parallel: 1 @@ -58,7 +58,7 @@ jobs: - name: Install cutting-edge Cython-based packages on Python dev versions continue-on-error: ${{ matrix.allowed_failure || false }} - if: matrix.python-version == '3.13-dev' + if: matrix.python-version == '3.14-dev' run: | sudo apt-get install libxml2-dev libxslt1-dev python -m pip install git+https://github.com/cython/cython.git @@ -96,7 +96,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.13' - name: Unencrypt secret file env: diff --git a/CHANGELOG.md b/CHANGELOG.md index e1c2ec9a..c44bf75e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Change Log HEAD ---- +- Dropped support for Python 3.8 which is EOL per October 7, 2024. 5.4.3 diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index 7f5776c6..940a7619 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -106,9 +106,9 @@ "discover", ] -# Set a default user agent, e.g. "exchangelib/3.1.1 (python-requests/2.22.0)" import requests.utils +# Set a default user agent, e.g. "exchangelib/5.4.3 (python-requests/2.31.0)" BaseProtocol.USERAGENT = f"{__name__}/{__version__} ({requests.utils.default_user_agent()})" diff --git a/exchangelib/ewsdatetime.py b/exchangelib/ewsdatetime.py index d2f15b9b..ac3ad204 100644 --- a/exchangelib/ewsdatetime.py +++ b/exchangelib/ewsdatetime.py @@ -1,10 +1,6 @@ import datetime import logging - -try: - import zoneinfo -except ImportError: - from backports import zoneinfo +import zoneinfo import tzlocal @@ -283,7 +279,6 @@ def from_timezone(cls, tz): try: return { cls.__module__.split(".")[0]: lambda z: z, - "backports": cls.from_zoneinfo, "datetime": cls.from_datetime, "dateutil": cls.from_dateutil, "pytz": cls.from_pytz, diff --git a/pyproject.toml b/pyproject.toml index fa160517..551f438a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ name = "exchangelib" dynamic = ["version"] description = "Client for Microsoft Exchange Web Services (EWS)" readme = {file = "README.md", content-type = "text/markdown"} -requires-python = ">=3.8" +requires-python = ">=3.9" license = {text = "BSD-2-Clause"} keywords = [ "autodiscover", @@ -38,7 +38,6 @@ classifiers = [ "Programming Language :: Python :: 3", ] dependencies = [ - 'backports.zoneinfo; python_version < "3.9"', "cached_property", "defusedxml >= 0.6.0", "dnspython >= 2.2.0", diff --git a/scripts/optimize.py b/scripts/optimize.py index 45934509..4325b167 100755 --- a/scripts/optimize.py +++ b/scripts/optimize.py @@ -5,13 +5,9 @@ import datetime import logging import time +import zoneinfo from pathlib import Path -try: - import zoneinfo -except ImportError: - from backports import zoneinfo - from yaml import safe_load from exchangelib import DELEGATE, Account, CalendarItem, Configuration, Credentials, FaultTolerance diff --git a/tests/common.py b/tests/common.py index d1c0be05..9092d3df 100644 --- a/tests/common.py +++ b/tests/common.py @@ -5,17 +5,13 @@ import time import unittest import unittest.util +import zoneinfo from collections import namedtuple from decimal import Decimal from pathlib import Path from yaml import safe_load -try: - import zoneinfo -except ImportError: - from backports import zoneinfo - from exchangelib.account import Account, Identity from exchangelib.attachments import FileAttachment from exchangelib.configuration import Configuration diff --git a/tests/test_ewsdatetime.py b/tests/test_ewsdatetime.py index 86e0ccb4..1c1b4ded 100644 --- a/tests/test_ewsdatetime.py +++ b/tests/test_ewsdatetime.py @@ -1,15 +1,11 @@ import datetime import unittest +import zoneinfo import dateutil.tz import pytz import requests_mock -try: - import zoneinfo -except ImportError: - from backports import zoneinfo - from exchangelib.errors import NaiveDateTimeNotAllowed, UnknownTimeZone from exchangelib.ewsdatetime import UTC, EWSDate, EWSDateTime, EWSTimeZone from exchangelib.util import CONNECTION_ERRORS diff --git a/tests/test_field.py b/tests/test_field.py index ad7128b1..db877d6c 100644 --- a/tests/test_field.py +++ b/tests/test_field.py @@ -1,13 +1,9 @@ import datetime import warnings +import zoneinfo from collections import namedtuple from decimal import Decimal -try: - import zoneinfo -except ImportError: - from backports import zoneinfo - from exchangelib.extended_properties import ExternId from exchangelib.fields import ( Base64Field, diff --git a/tests/test_items/test_contacts.py b/tests/test_items/test_contacts.py index 9498cee8..2ae9aa8e 100644 --- a/tests/test_items/test_contacts.py +++ b/tests/test_items/test_contacts.py @@ -1,9 +1,5 @@ import datetime - -try: - import zoneinfo -except ImportError: - from backports import zoneinfo +import zoneinfo from exchangelib.errors import ErrorInvalidIdMalformed from exchangelib.folders import Contacts diff --git a/tests/test_items/test_generic.py b/tests/test_items/test_generic.py index b9e73a83..7880e270 100644 --- a/tests/test_items/test_generic.py +++ b/tests/test_items/test_generic.py @@ -1,9 +1,5 @@ import datetime - -try: - import zoneinfo -except ImportError: - from backports import zoneinfo +import zoneinfo from exchangelib.attachments import ItemAttachment from exchangelib.errors import ErrorInternalServerError, ErrorItemNotFound diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 52d0a42c..c5567aad 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -4,14 +4,10 @@ import socket import tempfile import warnings +import zoneinfo from contextlib import suppress from unittest.mock import Mock, patch -try: - import zoneinfo -except ImportError: - from backports import zoneinfo - import psutil import requests_mock from oauthlib.oauth2 import InvalidClientIdError diff --git a/tests/test_restriction.py b/tests/test_restriction.py index 0e31cae0..8abe16cb 100644 --- a/tests/test_restriction.py +++ b/tests/test_restriction.py @@ -1,9 +1,5 @@ import datetime - -try: - import zoneinfo -except ImportError: - from backports import zoneinfo +import zoneinfo from exchangelib.folders import Calendar, Root from exchangelib.queryset import Q From 243a7cf01478b19e8d2a60a434a56468a0266a59 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 28 Oct 2024 13:16:06 +0100 Subject: [PATCH 480/509] Bump version --- CHANGELOG.md | 6 +++++- exchangelib/__init__.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c44bf75e..208a5ab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,13 @@ Change Log HEAD ---- -- Dropped support for Python 3.8 which is EOL per October 7, 2024. +5.5.0 +----- +- Dropped support for Python 3.8 which is EOL per October 7, 2024. +- Fix setting OOF on servers that only accept UTC timestamps. + 5.4.3 ----- - Fix access to shared folders diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index 940a7619..288b6e5c 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -36,7 +36,7 @@ from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "5.4.3" +__version__ = "5.5.0" __all__ = [ "AcceptItem", From 44a020e6ea42aa1a3babf902755b82f4e7412682 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 28 Oct 2024 13:17:09 +0100 Subject: [PATCH 481/509] Update docs --- docs/exchangelib/ewsdatetime.html | 9 +-------- docs/exchangelib/index.html | 12 ++++++++---- docs/exchangelib/settings.html | 11 ++++++++++- docs/exchangelib/winzone.html | 7 ++++++- 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/docs/exchangelib/ewsdatetime.html b/docs/exchangelib/ewsdatetime.html index 403cfa61..e1528e59 100644 --- a/docs/exchangelib/ewsdatetime.html +++ b/docs/exchangelib/ewsdatetime.html @@ -28,11 +28,7 @@

        Module exchangelib.ewsdatetime

        import datetime
         import logging
        -
        -try:
        -    import zoneinfo
        -except ImportError:
        -    from backports import zoneinfo
        +import zoneinfo
         
         import tzlocal
         
        @@ -311,7 +307,6 @@ 

        Module exchangelib.ewsdatetime

        try: return { cls.__module__.split(".")[0]: lambda z: z, - "backports": cls.from_zoneinfo, "datetime": cls.from_datetime, "dateutil": cls.from_dateutil, "pytz": cls.from_pytz, @@ -953,7 +948,6 @@

        Methods

        try: return { cls.__module__.split(".")[0]: lambda z: z, - "backports": cls.from_zoneinfo, "datetime": cls.from_datetime, "dateutil": cls.from_dateutil, "pytz": cls.from_pytz, @@ -1085,7 +1079,6 @@

        Static methods

        try: return { cls.__module__.split(".")[0]: lambda z: z, - "backports": cls.from_zoneinfo, "datetime": cls.from_datetime, "dateutil": cls.from_dateutil, "pytz": cls.from_pytz, diff --git a/docs/exchangelib/index.html b/docs/exchangelib/index.html index 3e727087..3b14064a 100644 --- a/docs/exchangelib/index.html +++ b/docs/exchangelib/index.html @@ -64,7 +64,7 @@

        Package exchangelib

        from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "5.4.3" +__version__ = "5.5.0" __all__ = [ "AcceptItem", @@ -134,9 +134,9 @@

        Package exchangelib

        "discover", ] -# Set a default user agent, e.g. "exchangelib/3.1.1 (python-requests/2.22.0)" import requests.utils +# Set a default user agent, e.g. "exchangelib/5.4.3 (python-requests/2.31.0)" BaseProtocol.USERAGENT = f"{__name__}/{__version__} ({requests.utils.default_user_agent()})" @@ -6124,7 +6124,6 @@

        Methods

        try: return { cls.__module__.split(".")[0]: lambda z: z, - "backports": cls.from_zoneinfo, "datetime": cls.from_datetime, "dateutil": cls.from_dateutil, "pytz": cls.from_pytz, @@ -6256,7 +6255,6 @@

        Static methods

        try: return { cls.__module__.split(".")[0]: lambda z: z, - "backports": cls.from_zoneinfo, "datetime": cls.from_datetime, "dateutil": cls.from_dateutil, "pytz": cls.from_pytz, @@ -10266,6 +10264,9 @@

        Inherited members

        raise ValueError("'start' must be before 'end'") if self.end < datetime.datetime.now(tz=UTC): raise ValueError("'end' must be in the future") + # Some servers only like UTC timestamps + self.start = self.start.astimezone(UTC) + self.end = self.end.astimezone(UTC) if self.state != self.DISABLED and (not self.internal_reply or not self.external_reply): raise ValueError(f"'internal_reply' and 'external_reply' must be set when state is not {self.DISABLED!r}") @@ -10420,6 +10421,9 @@

        Methods

        raise ValueError("'start' must be before 'end'") if self.end < datetime.datetime.now(tz=UTC): raise ValueError("'end' must be in the future") + # Some servers only like UTC timestamps + self.start = self.start.astimezone(UTC) + self.end = self.end.astimezone(UTC) if self.state != self.DISABLED and (not self.internal_reply or not self.external_reply): raise ValueError(f"'internal_reply' and 'external_reply' must be set when state is not {self.DISABLED!r}")
        diff --git a/docs/exchangelib/settings.html b/docs/exchangelib/settings.html index d4bf01e8..653e1091 100644 --- a/docs/exchangelib/settings.html +++ b/docs/exchangelib/settings.html @@ -63,6 +63,9 @@

        Module exchangelib.settings

        raise ValueError("'start' must be before 'end'") if self.end < datetime.datetime.now(tz=UTC): raise ValueError("'end' must be in the future") + # Some servers only like UTC timestamps + self.start = self.start.astimezone(UTC) + self.end = self.end.astimezone(UTC) if self.state != self.DISABLED and (not self.internal_reply or not self.external_reply): raise ValueError(f"'internal_reply' and 'external_reply' must be set when state is not {self.DISABLED!r}") @@ -161,6 +164,9 @@

        Classes

        raise ValueError("'start' must be before 'end'") if self.end < datetime.datetime.now(tz=UTC): raise ValueError("'end' must be in the future") + # Some servers only like UTC timestamps + self.start = self.start.astimezone(UTC) + self.end = self.end.astimezone(UTC) if self.state != self.DISABLED and (not self.internal_reply or not self.external_reply): raise ValueError(f"'internal_reply' and 'external_reply' must be set when state is not {self.DISABLED!r}") @@ -315,6 +321,9 @@

        Methods

        raise ValueError("'start' must be before 'end'") if self.end < datetime.datetime.now(tz=UTC): raise ValueError("'end' must be in the future") + # Some servers only like UTC timestamps + self.start = self.start.astimezone(UTC) + self.end = self.end.astimezone(UTC) if self.state != self.DISABLED and (not self.internal_reply or not self.external_reply): raise ValueError(f"'internal_reply' and 'external_reply' must be set when state is not {self.DISABLED!r}")
        @@ -412,4 +421,4 @@

        pdoc 0.10.0.

        - \ No newline at end of file + diff --git a/docs/exchangelib/winzone.html b/docs/exchangelib/winzone.html index 2bea7901..a4959a64 100644 --- a/docs/exchangelib/winzone.html +++ b/docs/exchangelib/winzone.html @@ -30,6 +30,11 @@

        Module exchangelib.winzone

        """A dict to translate from IANA location name to Windows timezone name. Translations taken from CLDR_WINZONE_URL
         """
         
        +# Data taken from https://github.com/unicode-org/cldr/blob/main/common/supplemental/windowsZones.xml
        +# SPDX-FileCopyrightText: Copyright © 1991-2013 Unicode, Inc.
        +# SPDX-License-Identifier: Unicode-3.0
        +# See https://www.unicode.org/license.txt for the license text.
        +
         import re
         
         import requests
        @@ -293,7 +298,6 @@ 

        Module exchangelib.winzone

        "Asia/Brunei": ("Singapore Standard Time", "BN"), "Asia/Calcutta": ("India Standard Time", "001"), "Asia/Chita": ("Transbaikal Standard Time", "001"), - "Asia/Choibalsan": ("Ulaanbaatar Standard Time", "MN"), "Asia/Colombo": ("Sri Lanka Standard Time", "001"), "Asia/Damascus": ("Syria Standard Time", "001"), "Asia/Dhaka": ("Bangladesh Standard Time", "001"), @@ -545,6 +549,7 @@

        Module exchangelib.winzone

        "Antarctica/South_Pole": CLDR_TO_MS_TIMEZONE_MAP["Pacific/Auckland"], "Antarctica/Troll": CLDR_TO_MS_TIMEZONE_MAP["Europe/Oslo"], "Asia/Ashkhabad": CLDR_TO_MS_TIMEZONE_MAP["Asia/Ashgabat"], + "Asia/Choibalsan": CLDR_TO_MS_TIMEZONE_MAP["Asia/Ulaanbaatar"], "Asia/Chongqing": CLDR_TO_MS_TIMEZONE_MAP["Asia/Shanghai"], "Asia/Chungking": CLDR_TO_MS_TIMEZONE_MAP["Asia/Shanghai"], "Asia/Dacca": CLDR_TO_MS_TIMEZONE_MAP["Asia/Dhaka"], From 3da439b0bb69c214b4ea217d4e8731a1371a3db6 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 28 Oct 2024 13:42:20 +0100 Subject: [PATCH 482/509] chore: Bump git commit hook versions --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 17ba68cd..40524a43 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,14 +2,14 @@ # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.2.0 + rev: v5.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files - repo: https://github.com/compilerla/conventional-pre-commit - rev: v1.2.0 + rev: v3.6.0 hooks: - id: conventional-pre-commit stages: [ commit-msg ] From 5bc53bda5f5c710f50a780082e2e9ae39556245c Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Sat, 2 Nov 2024 23:29:13 +0100 Subject: [PATCH 483/509] test: Ensure that recurrence updates are reflected --- tests/test_items/test_calendaritems.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_items/test_calendaritems.py b/tests/test_items/test_calendaritems.py index 30b484f7..3359dd41 100644 --- a/tests/test_items/test_calendaritems.py +++ b/tests/test_items/test_calendaritems.py @@ -332,6 +332,21 @@ def test_recurring_item(self): self.assertEqual(last_occurrence.start, master_item.last_occurrence.start) self.assertEqual(last_occurrence.end, master_item.last_occurrence.end) + # Test that we can update the recurrence and see it reflected in the view + print(master_item.recurrence) + + master_item.recurrence.pattern.interval = 2 + master_item.recurrence.boundary.number = 3 + master_item.save() + + range_start, range_end = start, end + datetime.timedelta(days=5) + unfolded = [i for i in self.test_folder.view(start=range_start, end=range_end) if self.match_cat(i)] + self.assertEqual(len(unfolded), 3) + + print(master_item.recurrence) + master_item.refresh() + print(master_item.recurrence) + def test_change_occurrence(self): # Test that we can make changes to individual occurrences and see the effect on the master item. start = datetime.datetime(2016, 1, 1, 8, tzinfo=self.account.default_timezone) From 7da64d79589b8a9fd34067d7ef53edc119f414b3 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 4 Nov 2024 09:03:55 +0100 Subject: [PATCH 484/509] test: remove debug prints --- tests/test_items/test_calendaritems.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/test_items/test_calendaritems.py b/tests/test_items/test_calendaritems.py index 3359dd41..5bb568f3 100644 --- a/tests/test_items/test_calendaritems.py +++ b/tests/test_items/test_calendaritems.py @@ -333,20 +333,13 @@ def test_recurring_item(self): self.assertEqual(last_occurrence.end, master_item.last_occurrence.end) # Test that we can update the recurrence and see it reflected in the view - print(master_item.recurrence) - master_item.recurrence.pattern.interval = 2 master_item.recurrence.boundary.number = 3 master_item.save() - range_start, range_end = start, end + datetime.timedelta(days=5) unfolded = [i for i in self.test_folder.view(start=range_start, end=range_end) if self.match_cat(i)] self.assertEqual(len(unfolded), 3) - print(master_item.recurrence) - master_item.refresh() - print(master_item.recurrence) - def test_change_occurrence(self): # Test that we can make changes to individual occurrences and see the effect on the master item. start = datetime.datetime(2016, 1, 1, 8, tzinfo=self.account.default_timezone) From b63353da7f0f66e4f3474cf29cd1b3de421e766d Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Fri, 20 Dec 2024 15:23:33 +0100 Subject: [PATCH 485/509] chore: This is also an indication of an expired token. Refs #1345 --- exchangelib/util.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/exchangelib/util.py b/exchangelib/util.py index 9a4e4590..7c9467a6 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -19,7 +19,7 @@ import requests.exceptions from defusedxml.expatreader import DefusedExpatParser from defusedxml.sax import _InputSource -from oauthlib.oauth2 import TokenExpiredError +from oauthlib.oauth2 import InvalidClientIdError, TokenExpiredError from pygments import highlight from pygments.formatters.terminal import TerminalFormatter from pygments.lexers.html import XmlLexer @@ -831,7 +831,7 @@ def post_ratelimited(protocol, session, url, headers, data, stream=False, timeou protocol.retire_session(session) log.debug("Session %s thread %s: connection error POST'ing to %s", session.session_id, thread_id, url) raise ErrorTimeoutExpired(f"Reraised from {e.__class__.__name__}({e})") - except TokenExpiredError: + except (InvalidClientIdError, TokenExpiredError): log.debug("Session %s thread %s: OAuth token expired; refreshing", session.session_id, thread_id) protocol.release_session(protocol.refresh_credentials(session)) raise From cac0e462124aa44975bc37ac6df3e2118cb50e45 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 20 Jan 2025 15:41:14 +0100 Subject: [PATCH 486/509] chore: Compress --- exchangelib/credentials.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/exchangelib/credentials.py b/exchangelib/credentials.py index 4475177d..2110ca31 100644 --- a/exchangelib/credentials.py +++ b/exchangelib/credentials.py @@ -234,12 +234,8 @@ def __init__(self, username, password, **kwargs): def token_params(self): res = super().token_params() - res.update( - { - "username": self.username, - "password": self.password, - } - ) + res["username"] = self.username + res["password"] = self.password return res @threaded_cached_property From 575dded1be05ae7644cb6563d989c77cdd82ee06 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 20 Jan 2025 15:42:47 +0100 Subject: [PATCH 487/509] fix: Support date strings with UTC timezone info. Closes #1351 --- exchangelib/fields.py | 4 ++-- tests/test_field.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/exchangelib/fields.py b/exchangelib/fields.py index a51a1724..8dce46b4 100644 --- a/exchangelib/fields.py +++ b/exchangelib/fields.py @@ -726,8 +726,8 @@ def clean(self, value, version=None): def from_xml(self, elem, account): val = self._get_val_from_elem(elem) - if val is not None and len(val) == 16: - # This is a date format with timezone info, as sent by task recurrences. Eg: '2006-01-09+01:00' + if val is not None and len(val) in (11, 16): + # This is a date with timezone info, as sent by task recurrences. Eg: '2006-01-09+01:00' or '2006-01-09Z' return self._date_field.from_xml(elem=elem, account=account) return super().from_xml(elem=elem, account=account) diff --git a/tests/test_field.py b/tests/test_field.py index db877d6c..da60adb7 100644 --- a/tests/test_field.py +++ b/tests/test_field.py @@ -13,6 +13,7 @@ Choice, ChoiceField, DateField, + DateOrDateTimeField, DateTimeField, DecimalField, EnumField, @@ -209,6 +210,34 @@ def test_versioned_choice(self): field.clean("c2", version=Version(EXCHANGE_2010)) field.clean("c2", version=Version(EXCHANGE_2013)) + def test_date_or_datetime_field(self): + # Test edge cases with timezone info on date strings + tz = zoneinfo.ZoneInfo("Europe/Copenhagen") + account = namedtuple("Account", ["default_timezone"])(default_timezone=tz) + field = DateOrDateTimeField("foo", field_uri="calendar:Start") + + # TZ-aware date string + payload = b"""\ + + + + 2017-06-21Z + +""" + elem = to_xml(payload).find(f"{{{TNS}}}Item") + self.assertEqual(field.from_xml(elem=elem, account=account), datetime.datetime(2017, 6, 21)) + + # TZ-aware date string + payload = b"""\ + + + + 2017-06-21+01:00 + +""" + elem = to_xml(payload).find(f"{{{TNS}}}Item") + self.assertEqual(field.from_xml(elem=elem, account=account), datetime.date(2017, 6, 21)) + def test_naive_datetime(self): # Test that we can survive naive datetimes on a datetime field tz = zoneinfo.ZoneInfo("Europe/Copenhagen") From 999a930b99c5f3863291c050e2198b42906b6f3b Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 21 Jan 2025 09:22:10 +0100 Subject: [PATCH 488/509] test: Skip archive tests when the server throws an ErrorInternalServerError --- tests/test_items/test_generic.py | 13 +++++++++++-- tests/test_items/test_messages.py | 4 ++-- tests/test_items/test_queryset.py | 17 +++++++++++++---- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/tests/test_items/test_generic.py b/tests/test_items/test_generic.py index 7880e270..53152944 100644 --- a/tests/test_items/test_generic.py +++ b/tests/test_items/test_generic.py @@ -873,8 +873,17 @@ def test_export_with_error(self): def test_archive(self): item = self.get_test_item(folder=self.test_folder).save() - item_id, changekey = item.archive(to_folder=self.account.trash) - self.account.root.get(id=item_id, changekey=changekey) + try: + item_id, changekey = item.archive(to_folder=self.account.trash) + self.account.root.get(id=item_id, changekey=changekey) + except ErrorInternalServerError as e: + # O365 is apparently unable to archive anymore + self.assertEqual( + str(e), + "An internal server error occurred. The operation failed., Unable to cast object of type " + "'Microsoft.Exchange.Services.Core.Types.MoveItemRequest' to type " + "'Microsoft.Exchange.Services.Core.ILegacyServiceCommandFactory'.", + ) def test_item_attachments(self): item = self.get_test_item(folder=self.test_folder) diff --git a/tests/test_items/test_messages.py b/tests/test_items/test_messages.py index 060b0920..6736dab7 100644 --- a/tests/test_items/test_messages.py +++ b/tests/test_items/test_messages.py @@ -169,8 +169,8 @@ def test_create_forward(self): self.assertEqual(self.account.sent.filter(subject=new_subject).count(), 1) def test_mark_as_junk(self): - # Test that we can mark a Message item as junk and non-junk, and that the message goes to the junk forlder and - # back to the the inbox. + # Test that we can mark a Message item as junk and non-junk, and that the message goes to the junk folder and + # back to the inbox. item = self.get_test_item().save() item.mark_as_junk(is_junk=False, move_item=False) self.assertEqual(item.folder, self.test_folder) diff --git a/tests/test_items/test_queryset.py b/tests/test_items/test_queryset.py index b3ae38e0..abe123a6 100644 --- a/tests/test_items/test_queryset.py +++ b/tests/test_items/test_queryset.py @@ -1,6 +1,6 @@ import time -from exchangelib.errors import ErrorItemNotFound +from exchangelib.errors import ErrorInternalServerError, ErrorItemNotFound from exchangelib.folders import FolderCollection, Inbox from exchangelib.items import ASSOCIATED, SHALLOW, Message from exchangelib.queryset import DoesNotExist, MultipleObjectsReturned, QuerySet @@ -333,9 +333,18 @@ def test_archive_via_queryset(self): self.get_test_item().save() qs = self.test_folder.filter(categories__contains=self.categories) to_folder = self.account.trash - qs.archive(to_folder=to_folder) - self.assertEqual(qs.count(), 0) - self.assertEqual(to_folder.filter(categories__contains=self.categories).count(), 1) + try: + qs.archive(to_folder=to_folder) + self.assertEqual(qs.count(), 0) + self.assertEqual(to_folder.filter(categories__contains=self.categories).count(), 1) + except ErrorInternalServerError as e: + # O365 is apparently unable to archive anymore + self.assertEqual( + str(e), + "An internal server error occurred. The operation failed., Unable to cast object of type " + "'Microsoft.Exchange.Services.Core.Types.MoveItemRequest' to type " + "'Microsoft.Exchange.Services.Core.ILegacyServiceCommandFactory'.", + ) def test_depth(self): self.assertGreaterEqual(self.test_folder.all().depth(ASSOCIATED).count(), 0) From fd6cee6cba627b0a14c87edd489e8e8d2a8de565 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 21 Jan 2025 09:27:55 +0100 Subject: [PATCH 489/509] test: Improve coverage of AQS querystring filtering --- tests/test_items/test_messages.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/tests/test_items/test_messages.py b/tests/test_items/test_messages.py index 6736dab7..850cd1fd 100644 --- a/tests/test_items/test_messages.py +++ b/tests/test_items/test_messages.py @@ -19,18 +19,28 @@ class MessagesTest(CommonItemTest): FOLDER_CLASS = Inbox ITEM_CLASS = Message INCOMING_MESSAGE_TIMEOUT = 60 + AQS_INDEXING_TIMEOUT = 60 def get_incoming_message(self, subject): t1 = time.monotonic() while True: - t2 = time.monotonic() - if t2 - t1 > self.INCOMING_MESSAGE_TIMEOUT: + if time.monotonic() - t1 > self.INCOMING_MESSAGE_TIMEOUT: self.skipTest(f"Too bad. Gave up in {self.id()} waiting for the incoming message to show up") try: return self.account.inbox.get(subject=subject) except DoesNotExist: time.sleep(5) + def wait_for_aqs_indexing(self, folder, aqs_filter): + t1 = time.monotonic() + while True: + if time.monotonic() - t1 > self.AQS_INDEXING_TIMEOUT: + self.skipTest(f"Too bad. Gave up in {self.id()} waiting for the AQS indexing to complete") + try: + return folder.get(aqs_filter) + except DoesNotExist: + time.sleep(5) + def test_send(self): # Test that we can send (only) Message items item = self.get_test_item(folder=None) @@ -227,3 +237,18 @@ def test_invalid_kwargs_on_send(self): item = self.get_test_item() with self.assertRaises(AttributeError): item.send(copy_to_folder=self.account.trash, save_copy=False) # Inconsistent args + + def test_filter_with_aqs(self): + # Test AQS filtering on subject and recipients + item = self.get_test_item(folder=None) + item.send_and_save() + sent_item = self.account.sent.get(subject=item.subject) + subject = sent_item.subject.strip(' :"') # Remove special chars to not interfere with AQS syntax + to_email = sent_item.to_recipients[0].email_address + to_name = sent_item.to_recipients[0].name + self.wait_for_aqs_indexing(self.account.sent, f"Subject:{subject}") + self.assertEqual(self.account.sent.get(f"Subject:{subject}").id, sent_item.id) + self.assertEqual(self.account.sent.get(f"To:{to_email} AND Subject:{subject}").id, sent_item.id) + self.assertEqual(self.account.sent.get(f"To:{to_name} AND Subject:{subject}").id, sent_item.id) + self.assertEqual(self.account.sent.get(f'To:"{to_name}" AND Subject:{subject}').id, sent_item.id) + self.assertEqual(self.account.sent.get(f"Participants:{to_name} AND Subject:{subject}").id, sent_item.id) From 7a00a2b48c08fcbc5fb89f3ecb017e626f21eae8 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 21 Jan 2025 09:31:37 +0100 Subject: [PATCH 490/509] fix: Support extra attributes of QueryString --- exchangelib/restriction.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/exchangelib/restriction.py b/exchangelib/restriction.py index 212d588e..152e48dc 100644 --- a/exchangelib/restriction.py +++ b/exchangelib/restriction.py @@ -5,7 +5,7 @@ from .errors import InvalidEnumValue from .fields import DateTimeBackedDateField, FieldPath, InvalidField from .util import create_element, is_iterable, value_to_xml_text, xml_to_str -from .version import EXCHANGE_2010 +from .version import EXCHANGE_2010, EXCHANGE_2013 log = logging.getLogger(__name__) @@ -346,7 +346,12 @@ def to_xml(self, folders, version, applies_to): self._check_integrity() if version.build < EXCHANGE_2010: raise NotImplementedError("QueryString filtering is only supported for Exchange 2010 servers and later") - elem = create_element("m:QueryString") + if version.build < EXCHANGE_2013: + elem = create_element("m:QueryString") + else: + elem = create_element( + "m:QueryString", attrs=dict(ResetCache=True, ReturnDeletedItems=False, ReturnHighlightTerms=False) + ) elem.text = self.query_string return elem # Translate this Q object to a valid Restriction XML tree From bc8e9c1360a71872d3de332783d64c111770a465 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 21 Jan 2025 10:00:37 +0100 Subject: [PATCH 491/509] test: fix type --- tests/test_field.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_field.py b/tests/test_field.py index da60adb7..7537072b 100644 --- a/tests/test_field.py +++ b/tests/test_field.py @@ -225,7 +225,7 @@ def test_date_or_datetime_field(self): """ elem = to_xml(payload).find(f"{{{TNS}}}Item") - self.assertEqual(field.from_xml(elem=elem, account=account), datetime.datetime(2017, 6, 21)) + self.assertEqual(field.from_xml(elem=elem, account=account), datetime.date(2017, 6, 21)) # TZ-aware date string payload = b"""\ From f2c12db3e312786c92a8ac1484d529c23d1eb05a Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Tue, 21 Jan 2025 10:57:20 +0100 Subject: [PATCH 492/509] test: test AQS query result caching --- tests/test_items/test_messages.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/test_items/test_messages.py b/tests/test_items/test_messages.py index 850cd1fd..cc159bfe 100644 --- a/tests/test_items/test_messages.py +++ b/tests/test_items/test_messages.py @@ -242,13 +242,23 @@ def test_filter_with_aqs(self): # Test AQS filtering on subject and recipients item = self.get_test_item(folder=None) item.send_and_save() + subject = item.subject.strip(' :"') # Remove special chars to not interfere with AQS syntax sent_item = self.account.sent.get(subject=item.subject) - subject = sent_item.subject.strip(' :"') # Remove special chars to not interfere with AQS syntax to_email = sent_item.to_recipients[0].email_address to_name = sent_item.to_recipients[0].name self.wait_for_aqs_indexing(self.account.sent, f"Subject:{subject}") - self.assertEqual(self.account.sent.get(f"Subject:{subject}").id, sent_item.id) self.assertEqual(self.account.sent.get(f"To:{to_email} AND Subject:{subject}").id, sent_item.id) self.assertEqual(self.account.sent.get(f"To:{to_name} AND Subject:{subject}").id, sent_item.id) self.assertEqual(self.account.sent.get(f'To:"{to_name}" AND Subject:{subject}').id, sent_item.id) self.assertEqual(self.account.sent.get(f"Participants:{to_name} AND Subject:{subject}").id, sent_item.id) + self.assertEqual(self.account.sent.get(f"Subject:{subject}").id, sent_item.id) + + # Test AQS search index cache invalidation. We should be able to find a second item with the same subject, + # using the same search string as before. + item2 = self.get_test_item(folder=None) + item2.subject = item.subject + self.assertNotEqual(item.references, item2.references) + item2.send_and_save() + body = item2.body[:100].strip(' :"') # Remove special chars to not interfere with AQS syntax + self.wait_for_aqs_indexing(self.account.sent, f"Body:{body}") + self.assertEqual(self.account.sent.filter(f"Subject:{subject}").count(), 2) From 3a07502d80dcd29df2813738f9f0e9f59c021abd Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 24 Feb 2025 16:45:29 +0100 Subject: [PATCH 493/509] fix: This field is required on update if routing type is MAPIPDL. Fixes #1355 --- exchangelib/properties.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exchangelib/properties.py b/exchangelib/properties.py index 8d0955f5..5221a42e 100644 --- a/exchangelib/properties.py +++ b/exchangelib/properties.py @@ -635,7 +635,7 @@ class Mailbox(EWSElement): # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/routingtype-emailaddresstype routing_type = TextField(field_uri="RoutingType", default="SMTP") mailbox_type = ChoiceField(field_uri="MailboxType", choices=MAILBOX_TYPE_CHOICES, default=MAILBOX) - item_id = EWSElementField(value_cls=ItemId, is_read_only=True) + item_id = EWSElementField(value_cls=ItemId) def clean(self, version=None): super().clean(version=version) From 6bd529854b128a3b34370e3b9b257fc7d75a07b8 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 24 Feb 2025 16:56:41 +0100 Subject: [PATCH 494/509] fix: Allow lazy resolving of Account.version --- exchangelib/account.py | 36 ++++++++++++++++++++++++++++++++---- tests/common.py | 1 + 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/exchangelib/account.py b/exchangelib/account.py index ebf26270..58fb3771 100644 --- a/exchangelib/account.py +++ b/exchangelib/account.py @@ -1,5 +1,6 @@ import locale as stdlib_locale from logging import getLogger +from threading import Lock from cached_property import threaded_cached_property @@ -199,16 +200,32 @@ def __init__( # For maintaining affinity in e.g. subscriptions self.affinity_cookie = None - # We may need to override the default server version on a per-account basis because Microsoft may report one - # server version up-front but delegate account requests to an older backend server. Create a new instance to - # avoid changing the protocol version. - self.version = self.protocol.version.copy() + self._version = None + self._version_lock = Lock() log.debug("Added account: %s", self) @property def primary_smtp_address(self): return self.identity.primary_smtp_address + @property + def version(self): + # We may need to override the default server version on a per-account basis because Microsoft may report one + # server version up-front but delegate account requests to an older backend server. Create a new instance to + # avoid changing the protocol version instance. + if self._version: + return self._version + with self._version_lock: + if self._version: + return self._version + self._version = self.protocol.version.copy() + return self._version + + @version.setter + def version(self, value): + with self._version_lock: + self._version = value + @threaded_cached_property def admin_audit_logs(self): return self.root.get_default_folder(AdminAuditLogs) @@ -862,6 +879,17 @@ def unsubscribe(self, subscription_id): """ return Unsubscribe(account=self).get(subscription_id=subscription_id) + def __getstate__(self): + # The lock cannot be pickled + state = self.__dict__.copy() + del state["_version_lock"] + return state + + def __setstate__(self, state): + # Restore the lock + self.__dict__.update(state) + self._version_lock = Lock() + def __str__(self): if self.fullname: return f"{self.primary_smtp_address} ({self.fullname})" diff --git a/tests/common.py b/tests/common.py index 9092d3df..f18f5223 100644 --- a/tests/common.py +++ b/tests/common.py @@ -127,6 +127,7 @@ def setUpClass(cls): retry_policy=cls.retry_policy, ) cls.account = cls.get_account() + _ = cls.account.version # Some mocked-out tests require version @classmethod def credentials(cls): From ab27e5a0bd7b52a9f7557a83f9a33e195453f4e4 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Mon, 24 Feb 2025 16:57:31 +0100 Subject: [PATCH 495/509] chore: fixes from newer package versions --- .pre-commit-config.yaml | 8 ++++---- exchangelib/util.py | 2 +- exchangelib/winzone.py | 3 +-- tests/test_items/test_contacts.py | 4 +++- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 40524a43..df263b15 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,25 +18,25 @@ repos: hooks: - id: black name: black - stages: [ commit ] + stages: [ pre-commit ] entry: black --check --diff language: system types: [ python ] - id: isort name: isort - stages: [ commit ] + stages: [ pre-commit ] entry: isort --check --diff types: [ python ] language: system - id: flake8 name: flake8 - stages: [ commit ] + stages: [ pre-commit ] entry: flake8 types: [ python ] language: system - id: blacken-docs name: blacken-docs - stages: [ commit ] + stages: [ pre-commit ] entry: blacken-docs types: [ markdown ] language: system diff --git a/exchangelib/util.py b/exchangelib/util.py index 7c9467a6..7db4b87e 100644 --- a/exchangelib/util.py +++ b/exchangelib/util.py @@ -67,7 +67,7 @@ def __init__(self, msg, data): # Regex of UTF-8 control characters that are illegal in XML 1.0 (and XML 1.1). # See https://stackoverflow.com/a/22273639/219640 -_ILLEGAL_XML_CHARS_RE = re.compile("[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F-\x84\x86-\x9F\uFDD0-\uFDDF\uFFFE\uFFFF]") +_ILLEGAL_XML_CHARS_RE = re.compile("[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f-\x84\x86-\x9f\ufdd0-\ufddf\ufffe\uffff]") _ILLEGAL_XML_ESCAPE_CHARS_RE = re.compile(rb"&(#[0-9]+;?|#[xX][0-9a-fA-F]+;?)") # Could match the above better # XML namespaces diff --git a/exchangelib/winzone.py b/exchangelib/winzone.py index 78e9bf61..e775a6f9 100644 --- a/exchangelib/winzone.py +++ b/exchangelib/winzone.py @@ -1,5 +1,4 @@ -"""A dict to translate from IANA location name to Windows timezone name. Translations taken from CLDR_WINZONE_URL -""" +"""A dict to translate from IANA location name to Windows timezone name. Translations taken from CLDR_WINZONE_URL""" # Data taken from https://github.com/unicode-org/cldr/blob/main/common/supplemental/windowsZones.xml # SPDX-FileCopyrightText: Copyright © 1991-2013 Unicode, Inc. diff --git a/tests/test_items/test_contacts.py b/tests/test_items/test_contacts.py index 2ae9aa8e..bbe28774 100644 --- a/tests/test_items/test_contacts.py +++ b/tests/test_items/test_contacts.py @@ -5,7 +5,9 @@ from exchangelib.folders import Contacts from exchangelib.indexed_properties import EmailAddress, PhoneNumber, PhysicalAddress from exchangelib.items import Contact, DistributionList, Persona -from exchangelib.properties import Attribution +from exchangelib.properties import ( + Attribution, +) from exchangelib.properties import EmailAddress as EmailAddressProp from exchangelib.properties import ( FolderId, From 93ae1b6cd1ce40903a7b793d8439c50174883ee3 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Fri, 14 Mar 2025 12:22:32 +0100 Subject: [PATCH 496/509] Bump version --- CHANGELOG.md | 7 +++++++ exchangelib/__init__.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 208a5ab8..64ea2706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,18 @@ HEAD ---- +5.5.1 +----- +- `Account.version` is now lazy and merely creating an `Account` will not throw errors if the specified credentials + have insufficient permissions to the account. This only happens if an attempt is made to access the mailbox. + + 5.5.0 ----- - Dropped support for Python 3.8 which is EOL per October 7, 2024. - Fix setting OOF on servers that only accept UTC timestamps. + 5.4.3 ----- - Fix access to shared folders diff --git a/exchangelib/__init__.py b/exchangelib/__init__.py index 288b6e5c..97608069 100644 --- a/exchangelib/__init__.py +++ b/exchangelib/__init__.py @@ -36,7 +36,7 @@ from .transport import BASIC, CBA, DIGEST, GSSAPI, NTLM, OAUTH2, SSPI from .version import Build, Version -__version__ = "5.5.0" +__version__ = "5.5.1" __all__ = [ "AcceptItem", From 139debcf6054b6e777e65d9df92f7c3e047dc40d Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Fri, 14 Mar 2025 12:23:42 +0100 Subject: [PATCH 497/509] docs: Regenerate docs --- docs/exchangelib/account.html | 1549 +--- docs/exchangelib/attachments.html | 431 +- docs/exchangelib/autodiscover/cache.html | 222 +- docs/exchangelib/autodiscover/discovery.html | 579 +- docs/exchangelib/autodiscover/index.html | 156 +- docs/exchangelib/autodiscover/protocol.html | 114 +- docs/exchangelib/configuration.html | 210 +- docs/exchangelib/credentials.html | 525 +- docs/exchangelib/errors.html | 2568 +----- docs/exchangelib/ewsdatetime.html | 595 +- docs/exchangelib/extended_properties.html | 471 +- docs/exchangelib/fields.html | 2293 +---- docs/exchangelib/folders/base.html | 1840 ++-- docs/exchangelib/folders/collections.html | 792 +- docs/exchangelib/folders/index.html | 3187 ++++--- docs/exchangelib/folders/known_folders.html | 1330 +-- docs/exchangelib/folders/queryset.html | 249 +- docs/exchangelib/folders/roots.html | 526 +- docs/exchangelib/index.html | 7979 ++++++++++++----- docs/exchangelib/indexed_properties.html | 165 +- docs/exchangelib/items/base.html | 539 +- docs/exchangelib/items/calendar_item.html | 732 +- docs/exchangelib/items/contact.html | 335 +- docs/exchangelib/items/index.html | 727 +- docs/exchangelib/items/item.html | 517 +- docs/exchangelib/items/message.html | 298 +- docs/exchangelib/items/post.html | 110 +- docs/exchangelib/items/task.html | 199 +- docs/exchangelib/properties.html | 3278 +------ docs/exchangelib/protocol.html | 1148 +-- docs/exchangelib/queryset.html | 851 +- docs/exchangelib/recurrence.html | 461 +- docs/exchangelib/restriction.html | 3159 ++++++- docs/exchangelib/services/archive_item.html | 87 +- docs/exchangelib/services/common.html | 1086 +-- docs/exchangelib/services/convert_id.html | 97 +- docs/exchangelib/services/copy_item.html | 51 +- .../services/create_attachment.html | 84 +- docs/exchangelib/services/create_folder.html | 93 +- docs/exchangelib/services/create_item.html | 175 +- .../services/create_user_configuration.html | 69 +- .../services/delete_attachment.html | 78 +- docs/exchangelib/services/delete_folder.html | 69 +- docs/exchangelib/services/delete_item.html | 107 +- .../services/delete_user_configuration.html | 69 +- docs/exchangelib/services/empty_folder.html | 73 +- docs/exchangelib/services/expand_dl.html | 69 +- docs/exchangelib/services/export_items.html | 75 +- docs/exchangelib/services/find_folder.html | 144 +- docs/exchangelib/services/find_item.html | 186 +- docs/exchangelib/services/find_people.html | 178 +- docs/exchangelib/services/get_attachment.html | 167 +- docs/exchangelib/services/get_delegate.html | 93 +- docs/exchangelib/services/get_events.html | 89 +- docs/exchangelib/services/get_folder.html | 121 +- docs/exchangelib/services/get_item.html | 97 +- docs/exchangelib/services/get_mail_tips.html | 91 +- docs/exchangelib/services/get_persona.html | 79 +- docs/exchangelib/services/get_room_lists.html | 69 +- docs/exchangelib/services/get_rooms.html | 69 +- .../services/get_searchable_mailboxes.html | 98 +- .../services/get_server_time_zones.html | 89 +- .../services/get_streaming_events.html | 152 +- .../services/get_user_availability.html | 119 +- .../services/get_user_configuration.html | 93 +- .../services/get_user_oof_settings.html | 90 +- .../services/get_user_settings.html | 139 +- docs/exchangelib/services/inbox_rules.html | 194 +- docs/exchangelib/services/index.html | 734 +- docs/exchangelib/services/mark_as_junk.html | 75 +- docs/exchangelib/services/move_folder.html | 75 +- docs/exchangelib/services/move_item.html | 76 +- docs/exchangelib/services/resolve_names.html | 156 +- docs/exchangelib/services/send_item.html | 74 +- .../services/send_notification.html | 99 +- .../services/set_user_oof_settings.html | 84 +- docs/exchangelib/services/subscribe.html | 195 +- .../services/sync_folder_hierarchy.html | 147 +- .../services/sync_folder_items.html | 131 +- docs/exchangelib/services/unsubscribe.html | 71 +- docs/exchangelib/services/update_folder.html | 208 +- docs/exchangelib/services/update_item.html | 165 +- .../services/update_user_configuration.html | 67 +- docs/exchangelib/services/upload_items.html | 105 +- docs/exchangelib/settings.html | 150 +- docs/exchangelib/transport.html | 268 +- docs/exchangelib/util.html | 1188 +-- docs/exchangelib/version.html | 1211 +-- docs/exchangelib/winzone.html | 697 +- 89 files changed, 17944 insertions(+), 30806 deletions(-) diff --git a/docs/exchangelib/account.html b/docs/exchangelib/account.html index 974be9c9..63add70c 100644 --- a/docs/exchangelib/account.html +++ b/docs/exchangelib/account.html @@ -2,18 +2,32 @@ - - + + exchangelib.account API documentation - - - - - - + + + + + + - - + +
        @@ -22,116 +36,26 @@

        Module exchangelib.account

        +
        +
        +
        +
        +
        +
        +
        +
        +

        Classes

        +
        +
        +class Account +(primary_smtp_address,
        fullname=None,
        access_type=None,
        autodiscover=False,
        credentials=None,
        config=None,
        locale=None,
        default_timezone=None)
        +
        +
        Expand source code -
        import locale as stdlib_locale
        -from logging import getLogger
        -
        -from cached_property import threaded_cached_property
        -
        -from .autodiscover import Autodiscovery
        -from .configuration import Configuration
        -from .credentials import ACCESS_TYPES, DELEGATE, IMPERSONATION
        -from .errors import InvalidEnumValue, InvalidTypeError, ResponseMessageError, UnknownTimeZone
        -from .ewsdatetime import UTC, EWSTimeZone
        -from .fields import FieldPath, TextField
        -from .folders import (
        -    AdminAuditLogs,
        -    ArchiveDeletedItems,
        -    ArchiveInbox,
        -    ArchiveMsgFolderRoot,
        -    ArchiveRecoverableItemsDeletions,
        -    ArchiveRecoverableItemsPurges,
        -    ArchiveRecoverableItemsRoot,
        -    ArchiveRecoverableItemsVersions,
        -    ArchiveRoot,
        -    Calendar,
        -    Conflicts,
        -    Contacts,
        -    ConversationHistory,
        -    DeletedItems,
        -    Directory,
        -    Drafts,
        -    Favorites,
        -    Folder,
        -    IMContactList,
        -    Inbox,
        -    Journal,
        -    JunkEmail,
        -    LocalFailures,
        -    MsgFolderRoot,
        -    MyContacts,
        -    Notes,
        -    Outbox,
        -    PeopleConnect,
        -    PublicFoldersRoot,
        -    QuickContacts,
        -    RecipientCache,
        -    RecoverableItemsDeletions,
        -    RecoverableItemsPurges,
        -    RecoverableItemsRoot,
        -    RecoverableItemsVersions,
        -    Root,
        -    SearchFolders,
        -    SentItems,
        -    ServerFailures,
        -    SyncIssues,
        -    Tasks,
        -    ToDoSearch,
        -    VoiceMail,
        -)
        -from .folders.collections import PullSubscription, PushSubscription, StreamingSubscription
        -from .items import ALL_OCCURRENCES, AUTO_RESOLVE, HARD_DELETE, ID_ONLY, SAVE_ONLY, SEND_TO_NONE
        -from .properties import EWSElement, Mailbox, Rule, SendingAs
        -from .protocol import Protocol
        -from .queryset import QuerySet
        -from .services import (
        -    ArchiveItem,
        -    CopyItem,
        -    CreateInboxRule,
        -    CreateItem,
        -    DeleteInboxRule,
        -    DeleteItem,
        -    ExportItems,
        -    GetDelegate,
        -    GetInboxRules,
        -    GetItem,
        -    GetMailTips,
        -    GetPersona,
        -    GetUserOofSettings,
        -    MarkAsJunk,
        -    MoveItem,
        -    SendItem,
        -    SetInboxRule,
        -    SetUserOofSettings,
        -    SubscribeToPull,
        -    SubscribeToPush,
        -    SubscribeToStreaming,
        -    Unsubscribe,
        -    UpdateItem,
        -    UploadItems,
        -)
        -from .util import get_domain, peek
        -
        -log = getLogger(__name__)
        -
        -
        -class Identity(EWSElement):
        -    """Contains information that uniquely identifies an account. Currently only used for SOAP impersonation headers."""
        -
        -    ELEMENT_NAME = "ConnectingSID"
        -
        -    # We have multiple options for uniquely identifying the user. Here's a prioritized list in accordance with
        -    # https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/connectingsid
        -    sid = TextField(field_uri="SID")
        -    upn = TextField(field_uri="PrincipalName")
        -    smtp_address = TextField(field_uri="SmtpAddress")  # The (non-)primary email address for the account
        -    primary_smtp_address = TextField(field_uri="PrimarySmtpAddress")  # The primary email address for the account
        -
        -
        -class Account:
        +
        class Account:
             """Models an Exchange server user account."""
         
             def __init__(
        @@ -227,16 +151,32 @@ 

        Module exchangelib.account

        # For maintaining affinity in e.g. subscriptions self.affinity_cookie = None - # We may need to override the default server version on a per-account basis because Microsoft may report one - # server version up-front but delegate account requests to an older backend server. Create a new instance to - # avoid changing the protocol version. - self.version = self.protocol.version.copy() + self._version = None + self._version_lock = Lock() log.debug("Added account: %s", self) @property def primary_smtp_address(self): return self.identity.primary_smtp_address + @property + def version(self): + # We may need to override the default server version on a per-account basis because Microsoft may report one + # server version up-front but delegate account requests to an older backend server. Create a new instance to + # avoid changing the protocol version instance. + if self._version: + return self._version + with self._version_lock: + if self._version: + return self._version + self._version = self.protocol.version.copy() + return self._version + + @version.setter + def version(self, value): + with self._version_lock: + self._version = value + @threaded_cached_property def admin_audit_logs(self): return self.root.get_default_folder(AdminAuditLogs) @@ -890,26 +830,22 @@

        Module exchangelib.account

        """ return Unsubscribe(account=self).get(subscription_id=subscription_id) + def __getstate__(self): + # The lock cannot be pickled + state = self.__dict__.copy() + del state["_version_lock"] + return state + + def __setstate__(self, state): + # Restore the lock + self.__dict__.update(state) + self._version_lock = Lock() + def __str__(self): if self.fullname: return f"{self.primary_smtp_address} ({self.fullname})" return self.primary_smtp_address
        -
        -
        -
        -
        -
        -
        -
        -
        -

        Classes

        -
        -
        -class Account -(primary_smtp_address, fullname=None, access_type=None, autodiscover=False, credentials=None, config=None, locale=None, default_timezone=None) -
        -

        Models an Exchange server user account.

        :param primary_smtp_address: The primary email address associated with the account on the Exchange server :param fullname: The full name of the account. Optional. (Default value = None) @@ -924,894 +860,125 @@

        Classes

        :param default_timezone: EWS may return some datetime values without timezone information. In this case, we will assume values to be in the provided timezone. Defaults to the timezone of the host. :return:

        +

        Instance variables

        +
        +
        var admin_audit_logs
        +
        Expand source code -
        class Account:
        -    """Models an Exchange server user account."""
        -
        -    def __init__(
        -        self,
        -        primary_smtp_address,
        -        fullname=None,
        -        access_type=None,
        -        autodiscover=False,
        -        credentials=None,
        -        config=None,
        -        locale=None,
        -        default_timezone=None,
        -    ):
        -        """
        +
        def __get__(self, obj, cls):
        +    if obj is None:
        +        return self
         
        -        :param primary_smtp_address: The primary email address associated with the account on the Exchange server
        -        :param fullname: The full name of the account. Optional. (Default value = None)
        -        :param access_type: The access type granted to 'credentials' for this account. Valid options are 'delegate'
        -            and 'impersonation'. 'delegate' is default if 'credentials' is set. Otherwise, 'impersonation' is default.
        -        :param autodiscover: Whether to look up the EWS endpoint automatically using the autodiscover protocol.
        -            (Default value = False)
        -        :param credentials: A Credentials object containing valid credentials for this account. (Default value = None)
        -        :param config: A Configuration object containing EWS endpoint information. Required if autodiscover is disabled
        -            (Default value = None)
        -        :param locale: The locale of the user, e.g. 'en_US'. Defaults to the locale of the host, if available.
        -        :param default_timezone: EWS may return some datetime values without timezone information. In this case, we will
        -            assume values to be in the provided timezone. Defaults to the timezone of the host.
        -        :return:
        -        """
        -        if "@" not in primary_smtp_address:
        -            raise ValueError(f"primary_smtp_address {primary_smtp_address!r} is not an email address")
        -        self.fullname = fullname
        -        # Assume delegate access if individual credentials are provided. Else, assume service user with impersonation
        -        self.access_type = access_type or (DELEGATE if credentials else IMPERSONATION)
        -        if self.access_type not in ACCESS_TYPES:
        -            raise InvalidEnumValue("access_type", self.access_type, ACCESS_TYPES)
        +    obj_dict = obj.__dict__
        +    name = self.func.__name__
        +    with self.lock:
                 try:
        -            # get_locale() might not be able to determine the locale
        -            self.locale = locale or stdlib_locale.getlocale()[0] or None
        -        except ValueError as e:
        -            # getlocale() may throw ValueError if it fails to parse the system locale
        -            log.warning("Failed to get locale (%s)", e)
        -            self.locale = None
        -        if not isinstance(self.locale, (type(None), str)):
        -            raise InvalidTypeError("locale", self.locale, str)
        -        if default_timezone:
        -            try:
        -                self.default_timezone = EWSTimeZone.from_timezone(default_timezone)
        -            except TypeError:
        -                raise InvalidTypeError("default_timezone", default_timezone, EWSTimeZone)
        -        else:
        -            try:
        -                self.default_timezone = EWSTimeZone.localzone()
        -            except (ValueError, UnknownTimeZone) as e:
        -                # There is no translation from local timezone name to Windows timezone name, or e failed to find the
        -                # local timezone.
        -                log.warning("%s. Fallback to UTC", e.args[0])
        -                self.default_timezone = UTC
        -        if not isinstance(config, (Configuration, type(None))):
        -            raise InvalidTypeError("config", config, Configuration)
        -        if autodiscover:
        -            if config:
        -                auth_type, retry_policy, version, max_connections = (
        -                    config.auth_type,
        -                    config.retry_policy,
        -                    config.version,
        -                    config.max_connections,
        -                )
        -                if not credentials:
        -                    credentials = config.credentials
        -            else:
        -                auth_type, retry_policy, version, max_connections = None, None, None, None
        -            self.ad_response, self.protocol = Autodiscovery(
        -                email=primary_smtp_address, credentials=credentials
        -            ).discover()
        -            # Let's not use the auth_package hint from the AD response. It could be incorrect and we can just guess.
        -            self.protocol.config.auth_type = auth_type
        -            if retry_policy:
        -                self.protocol.config.retry_policy = retry_policy
        -            if version:
        -                self.protocol.config.version = version
        -            self.protocol.max_connections = max_connections
        -            primary_smtp_address = self.ad_response.autodiscover_smtp_address
        -        else:
        -            if not config:
        -                raise AttributeError("non-autodiscover requires a config")
        -            self.ad_response = None
        -            self.protocol = Protocol(config=config)
        +            # check if the value was computed before the lock was acquired
        +            return obj_dict[name]
         
        -        # Other ways of identifying the account can be added later
        -        self.identity = Identity(primary_smtp_address=primary_smtp_address)
        +        except KeyError:
        +            # if not, do the calculation and release the lock
        +            return obj_dict.setdefault(name, self.func(obj))
        +
        +
        +
        +
        var archive_deleted_items
        +
        +
        + +Expand source code + +
        def __get__(self, obj, cls):
        +    if obj is None:
        +        return self
         
        -        # For maintaining affinity in e.g. subscriptions
        -        self.affinity_cookie = None
        +    obj_dict = obj.__dict__
        +    name = self.func.__name__
        +    with self.lock:
        +        try:
        +            # check if the value was computed before the lock was acquired
        +            return obj_dict[name]
         
        -        # We may need to override the default server version on a per-account basis because Microsoft may report one
        -        # server version up-front but delegate account requests to an older backend server. Create a new instance to
        -        # avoid changing the protocol version.
        -        self.version = self.protocol.version.copy()
        -        log.debug("Added account: %s", self)
        +        except KeyError:
        +            # if not, do the calculation and release the lock
        +            return obj_dict.setdefault(name, self.func(obj))
        +
        +
        +
        +
        var archive_inbox
        +
        +
        + +Expand source code + +
        def __get__(self, obj, cls):
        +    if obj is None:
        +        return self
         
        -    @property
        -    def primary_smtp_address(self):
        -        return self.identity.primary_smtp_address
        +    obj_dict = obj.__dict__
        +    name = self.func.__name__
        +    with self.lock:
        +        try:
        +            # check if the value was computed before the lock was acquired
        +            return obj_dict[name]
         
        -    @threaded_cached_property
        -    def admin_audit_logs(self):
        -        return self.root.get_default_folder(AdminAuditLogs)
        +        except KeyError:
        +            # if not, do the calculation and release the lock
        +            return obj_dict.setdefault(name, self.func(obj))
        +
        +
        +
        +
        var archive_msg_folder_root
        +
        +
        + +Expand source code + +
        def __get__(self, obj, cls):
        +    if obj is None:
        +        return self
         
        -    @threaded_cached_property
        -    def archive_deleted_items(self):
        -        return self.archive_root.get_default_folder(ArchiveDeletedItems)
        +    obj_dict = obj.__dict__
        +    name = self.func.__name__
        +    with self.lock:
        +        try:
        +            # check if the value was computed before the lock was acquired
        +            return obj_dict[name]
         
        -    @threaded_cached_property
        -    def archive_inbox(self):
        -        return self.archive_root.get_default_folder(ArchiveInbox)
        +        except KeyError:
        +            # if not, do the calculation and release the lock
        +            return obj_dict.setdefault(name, self.func(obj))
        +
        +
        +
        +
        var archive_recoverable_items_deletions
        +
        +
        + +Expand source code + +
        def __get__(self, obj, cls):
        +    if obj is None:
        +        return self
         
        -    @threaded_cached_property
        -    def archive_msg_folder_root(self):
        -        return self.archive_root.get_default_folder(ArchiveMsgFolderRoot)
        -
        -    @threaded_cached_property
        -    def archive_recoverable_items_deletions(self):
        -        return self.archive_root.get_default_folder(ArchiveRecoverableItemsDeletions)
        -
        -    @threaded_cached_property
        -    def archive_recoverable_items_purges(self):
        -        return self.archive_root.get_default_folder(ArchiveRecoverableItemsPurges)
        -
        -    @threaded_cached_property
        -    def archive_recoverable_items_root(self):
        -        return self.archive_root.get_default_folder(ArchiveRecoverableItemsRoot)
        -
        -    @threaded_cached_property
        -    def archive_recoverable_items_versions(self):
        -        return self.archive_root.get_default_folder(ArchiveRecoverableItemsVersions)
        -
        -    @threaded_cached_property
        -    def archive_root(self):
        -        return ArchiveRoot.get_distinguished(account=self)
        -
        -    @threaded_cached_property
        -    def calendar(self):
        -        # If the account contains a shared calendar from a different user, that calendar will be in the folder list.
        -        # Attempt not to return one of those. An account may not always have a calendar called "Calendar", but a
        -        # Calendar folder with a localized name instead. Return that, if it's available, but always prefer any
        -        # distinguished folder returned by the server.
        -        return self.root.get_default_folder(Calendar)
        -
        -    @threaded_cached_property
        -    def conflicts(self):
        -        return self.root.get_default_folder(Conflicts)
        -
        -    @threaded_cached_property
        -    def contacts(self):
        -        return self.root.get_default_folder(Contacts)
        -
        -    @threaded_cached_property
        -    def conversation_history(self):
        -        return self.root.get_default_folder(ConversationHistory)
        -
        -    @threaded_cached_property
        -    def directory(self):
        -        return self.root.get_default_folder(Directory)
        -
        -    @threaded_cached_property
        -    def drafts(self):
        -        return self.root.get_default_folder(Drafts)
        -
        -    @threaded_cached_property
        -    def favorites(self):
        -        return self.root.get_default_folder(Favorites)
        -
        -    @threaded_cached_property
        -    def im_contact_list(self):
        -        return self.root.get_default_folder(IMContactList)
        -
        -    @threaded_cached_property
        -    def inbox(self):
        -        return self.root.get_default_folder(Inbox)
        -
        -    @threaded_cached_property
        -    def journal(self):
        -        return self.root.get_default_folder(Journal)
        -
        -    @threaded_cached_property
        -    def junk(self):
        -        return self.root.get_default_folder(JunkEmail)
        -
        -    @threaded_cached_property
        -    def local_failures(self):
        -        return self.root.get_default_folder(LocalFailures)
        -
        -    @threaded_cached_property
        -    def msg_folder_root(self):
        -        return self.root.get_default_folder(MsgFolderRoot)
        -
        -    @threaded_cached_property
        -    def my_contacts(self):
        -        return self.root.get_default_folder(MyContacts)
        -
        -    @threaded_cached_property
        -    def notes(self):
        -        return self.root.get_default_folder(Notes)
        -
        -    @threaded_cached_property
        -    def outbox(self):
        -        return self.root.get_default_folder(Outbox)
        -
        -    @threaded_cached_property
        -    def people_connect(self):
        -        return self.root.get_default_folder(PeopleConnect)
        -
        -    @threaded_cached_property
        -    def public_folders_root(self):
        -        return PublicFoldersRoot.get_distinguished(account=self)
        -
        -    @threaded_cached_property
        -    def quick_contacts(self):
        -        return self.root.get_default_folder(QuickContacts)
        -
        -    @threaded_cached_property
        -    def recipient_cache(self):
        -        return self.root.get_default_folder(RecipientCache)
        -
        -    @threaded_cached_property
        -    def recoverable_items_deletions(self):
        -        return self.root.get_default_folder(RecoverableItemsDeletions)
        -
        -    @threaded_cached_property
        -    def recoverable_items_purges(self):
        -        return self.root.get_default_folder(RecoverableItemsPurges)
        -
        -    @threaded_cached_property
        -    def recoverable_items_root(self):
        -        return self.root.get_default_folder(RecoverableItemsRoot)
        -
        -    @threaded_cached_property
        -    def recoverable_items_versions(self):
        -        return self.root.get_default_folder(RecoverableItemsVersions)
        -
        -    @threaded_cached_property
        -    def root(self):
        -        return Root.get_distinguished(account=self)
        -
        -    @threaded_cached_property
        -    def search_folders(self):
        -        return self.root.get_default_folder(SearchFolders)
        -
        -    @threaded_cached_property
        -    def sent(self):
        -        return self.root.get_default_folder(SentItems)
        -
        -    @threaded_cached_property
        -    def server_failures(self):
        -        return self.root.get_default_folder(ServerFailures)
        -
        -    @threaded_cached_property
        -    def sync_issues(self):
        -        return self.root.get_default_folder(SyncIssues)
        -
        -    @threaded_cached_property
        -    def tasks(self):
        -        return self.root.get_default_folder(Tasks)
        -
        -    @threaded_cached_property
        -    def todo_search(self):
        -        return self.root.get_default_folder(ToDoSearch)
        -
        -    @threaded_cached_property
        -    def trash(self):
        -        return self.root.get_default_folder(DeletedItems)
        -
        -    @threaded_cached_property
        -    def voice_mail(self):
        -        return self.root.get_default_folder(VoiceMail)
        -
        -    @property
        -    def domain(self):
        -        return get_domain(self.primary_smtp_address)
        -
        -    @property
        -    def oof_settings(self):
        -        # We don't want to cache this property because then we can't easily get updates. 'threaded_cached_property'
        -        # supports the 'del self.oof_settings' syntax to invalidate the cache, but does not support custom setter
        -        # methods. Having a non-cached service call here goes against the assumption that properties are cheap, but the
        -        # alternative is to create get_oof_settings() and set_oof_settings(), and that's just too Java-ish for my taste.
        -        return GetUserOofSettings(account=self).get(
        -            mailbox=Mailbox(email_address=self.primary_smtp_address),
        -        )
        -
        -    @oof_settings.setter
        -    def oof_settings(self, value):
        -        SetUserOofSettings(account=self).get(
        -            oof_settings=value,
        -            mailbox=Mailbox(email_address=self.primary_smtp_address),
        -        )
        -
        -    def _consume_item_service(self, service_cls, items, chunk_size, kwargs):
        -        if isinstance(items, QuerySet):
        -            # We just want an iterator over the results
        -            items = iter(items)
        -        is_empty, items = peek(items)
        -        if is_empty:
        -            # We accept generators, so it's not always convenient for caller to know up-front if 'ids' is empty. Allow
        -            # empty 'ids' and return early.
        -            return
        -        kwargs["items"] = items
        -        yield from service_cls(account=self, chunk_size=chunk_size).call(**kwargs)
        -
        -    def export(self, items, chunk_size=None):
        -        """Return export strings of the given items.
        -
        -        :param items: An iterable containing the Items we want to export
        -        :param chunk_size: The number of items to send to the server in a single request (Default value = None)
        -
        -        :return: A list of strings, the exported representation of the object
        -        """
        -        return list(self._consume_item_service(service_cls=ExportItems, items=items, chunk_size=chunk_size, kwargs={}))
        -
        -    def upload(self, data, chunk_size=None):
        -        """Upload objects retrieved from an export to the given folders.
        -
        -        :param data: An iterable of tuples containing the folder we want to upload the data to and the string outputs of
        -            exports. If you want to update items instead of create, the data must be a tuple of
        -            (ItemId, is_associated, data) values.
        -        :param chunk_size: The number of items to send to the server in a single request (Default value = None)
        -
        -        :return: A list of tuples with the new ids and changekeys
        -
        -          Example:
        -          account.upload([
        -              (account.inbox, "AABBCC..."),
        -              (account.inbox, (ItemId('AA', 'BB'), False, "XXYYZZ...")),
        -              (account.inbox, (('CC', 'DD'), None, "XXYYZZ...")),
        -              (account.calendar, "ABCXYZ..."),
        -          ])
        -          -> [("idA", "changekey"), ("idB", "changekey"), ("idC", "changekey")]
        -        """
        -        items = ((f, (None, False, d) if isinstance(d, str) else d) for f, d in data)
        -        return list(self._consume_item_service(service_cls=UploadItems, items=items, chunk_size=chunk_size, kwargs={}))
        -
        -    def bulk_create(
        -        self, folder, items, message_disposition=SAVE_ONLY, send_meeting_invitations=SEND_TO_NONE, chunk_size=None
        -    ):
        -        """Create new items in 'folder'.
        -
        -        :param folder: the folder to create the items in
        -        :param items: an iterable of Item objects
        -        :param message_disposition: only applicable to Message items. Possible values are specified in
        -            MESSAGE_DISPOSITION_CHOICES (Default value = SAVE_ONLY)
        -        :param send_meeting_invitations: only applicable to CalendarItem items. Possible values are specified in
        -            SEND_MEETING_INVITATIONS_CHOICES (Default value = SEND_TO_NONE)
        -        :param chunk_size: The number of items to send to the server in a single request (Default value = None)
        -
        -        :return: a list of either BulkCreateResult or exception instances in the same order as the input. The returned
        -          BulkCreateResult objects are normal Item objects except they only contain the 'id' and 'changekey'
        -          of the created item, and the 'id' of any attachments that were also created.
        -        """
        -        if isinstance(items, QuerySet):
        -            # bulk_create() on a queryset does not make sense because it returns items that have already been created
        -            raise ValueError("Cannot bulk create items from a QuerySet")
        -        log.debug(
        -            "Adding items for %s (folder %s, message_disposition: %s, send_meeting_invitations: %s)",
        -            self,
        -            folder,
        -            message_disposition,
        -            send_meeting_invitations,
        -        )
        -        return list(
        -            self._consume_item_service(
        -                service_cls=CreateItem,
        -                items=items,
        -                chunk_size=chunk_size,
        -                kwargs=dict(
        -                    folder=folder,
        -                    message_disposition=message_disposition,
        -                    send_meeting_invitations=send_meeting_invitations,
        -                ),
        -            )
        -        )
        -
        -    def bulk_update(
        -        self,
        -        items,
        -        conflict_resolution=AUTO_RESOLVE,
        -        message_disposition=SAVE_ONLY,
        -        send_meeting_invitations_or_cancellations=SEND_TO_NONE,
        -        suppress_read_receipts=True,
        -        chunk_size=None,
        -    ):
        -        """Bulk update existing items.
        -
        -        :param items: a list of (Item, fieldnames) tuples, where 'Item' is an Item object, and 'fieldnames' is a list
        -            containing the attributes on this Item object that we want to be updated.
        -        :param conflict_resolution: Possible values are specified in CONFLICT_RESOLUTION_CHOICES
        -            (Default value = AUTO_RESOLVE)
        -        :param message_disposition: only applicable to Message items. Possible values are specified in
        -            MESSAGE_DISPOSITION_CHOICES (Default value = SAVE_ONLY)
        -        :param send_meeting_invitations_or_cancellations: only applicable to CalendarItem items. Possible values are
        -            specified in SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES (Default value = SEND_TO_NONE)
        -        :param suppress_read_receipts: nly supported from Exchange 2013. True or False (Default value = True)
        -        :param chunk_size: The number of items to send to the server in a single request (Default value = None)
        -
        -        :return: a list of either (id, changekey) tuples or exception instances, in the same order as the input
        -        """
        -        # bulk_update() on a queryset does not make sense because there would be no opportunity to alter the items. In
        -        # fact, it could be dangerous if the queryset contains an '.only()'. This would wipe out certain fields
        -        # entirely.
        -        if isinstance(items, QuerySet):
        -            raise ValueError("Cannot bulk update on a queryset")
        -        log.debug(
        -            "Updating items for %s (conflict_resolution %s, message_disposition: %s, send_meeting_invitations: %s)",
        -            self,
        -            conflict_resolution,
        -            message_disposition,
        -            send_meeting_invitations_or_cancellations,
        -        )
        -        return list(
        -            self._consume_item_service(
        -                service_cls=UpdateItem,
        -                items=items,
        -                chunk_size=chunk_size,
        -                kwargs=dict(
        -                    conflict_resolution=conflict_resolution,
        -                    message_disposition=message_disposition,
        -                    send_meeting_invitations_or_cancellations=send_meeting_invitations_or_cancellations,
        -                    suppress_read_receipts=suppress_read_receipts,
        -                ),
        -            )
        -        )
        -
        -    def bulk_delete(
        -        self,
        -        ids,
        -        delete_type=HARD_DELETE,
        -        send_meeting_cancellations=SEND_TO_NONE,
        -        affected_task_occurrences=ALL_OCCURRENCES,
        -        suppress_read_receipts=True,
        -        chunk_size=None,
        -    ):
        -        """Bulk delete items.
        -
        -        :param ids: an iterable of either (id, changekey) tuples or Item objects.
        -        :param delete_type: the type of delete to perform. Possible values are specified in DELETE_TYPE_CHOICES
        -            (Default value = HARD_DELETE)
        -        :param send_meeting_cancellations: only applicable to CalendarItem. Possible values are specified in
        -            SEND_MEETING_CANCELLATIONS_CHOICES. (Default value = SEND_TO_NONE)
        -        :param affected_task_occurrences: only applicable for recurring Task items. Possible values are specified in
        -            AFFECTED_TASK_OCCURRENCES_CHOICES. (Default value = ALL_OCCURRENCES)
        -        :param suppress_read_receipts: only supported from Exchange 2013. True or False. (Default value = True)
        -        :param chunk_size: The number of items to send to the server in a single request (Default value = None)
        -
        -        :return: a list of either True or exception instances, in the same order as the input
        -        """
        -        log.debug(
        -            "Deleting items for %s (delete_type: %s, send_meeting_invitations: %s, affected_task_occurrences: %s)",
        -            self,
        -            delete_type,
        -            send_meeting_cancellations,
        -            affected_task_occurrences,
        -        )
        -        return list(
        -            self._consume_item_service(
        -                service_cls=DeleteItem,
        -                items=ids,
        -                chunk_size=chunk_size,
        -                kwargs=dict(
        -                    delete_type=delete_type,
        -                    send_meeting_cancellations=send_meeting_cancellations,
        -                    affected_task_occurrences=affected_task_occurrences,
        -                    suppress_read_receipts=suppress_read_receipts,
        -                ),
        -            )
        -        )
        -
        -    def bulk_send(self, ids, save_copy=True, copy_to_folder=None, chunk_size=None):
        -        """Send existing draft messages. If requested, save a copy in 'copy_to_folder'.
        -
        -        :param ids: an iterable of either (id, changekey) tuples or Item objects.
        -        :param save_copy: If true, saves a copy of the message (Default value = True)
        -        :param copy_to_folder: If requested, save a copy of the message in this folder. Default is the Sent folder
        -        :param chunk_size: The number of items to send to the server in a single request (Default value = None)
        -
        -        :return: Status for each send operation, in the same order as the input
        -        """
        -        if copy_to_folder and not save_copy:
        -            raise AttributeError("'save_copy' must be True when 'copy_to_folder' is set")
        -        if save_copy and not copy_to_folder:
        -            copy_to_folder = self.sent  # 'Sent' is default EWS behaviour
        -        return list(
        -            self._consume_item_service(
        -                service_cls=SendItem,
        -                items=ids,
        -                chunk_size=chunk_size,
        -                kwargs=dict(
        -                    saved_item_folder=copy_to_folder,
        -                ),
        -            )
        -        )
        -
        -    def bulk_copy(self, ids, to_folder, chunk_size=None):
        -        """Copy items to another folder.
        -
        -        :param ids: an iterable of either (id, changekey) tuples or Item objects.
        -        :param to_folder: The destination folder of the copy operation
        -        :param chunk_size: The number of items to send to the server in a single request (Default value = None)
        -
        -        :return: Status for each send operation, in the same order as the input
        -        """
        -        return list(
        -            self._consume_item_service(
        -                service_cls=CopyItem,
        -                items=ids,
        -                chunk_size=chunk_size,
        -                kwargs=dict(
        -                    to_folder=to_folder,
        -                ),
        -            )
        -        )
        -
        -    def bulk_move(self, ids, to_folder, chunk_size=None):
        -        """Move items to another folder.
        -
        -        :param ids: an iterable of either (id, changekey) tuples or Item objects.
        -        :param to_folder: The destination folder of the copy operation
        -        :param chunk_size: The number of items to send to the server in a single request (Default value = None)
        -
        -        :return: The new IDs of the moved items, in the same order as the input. If 'to_folder' is a public folder or a
        -          folder in a different mailbox, an empty list is returned.
        -        """
        -        return list(
        -            self._consume_item_service(
        -                service_cls=MoveItem,
        -                items=ids,
        -                chunk_size=chunk_size,
        -                kwargs=dict(
        -                    to_folder=to_folder,
        -                ),
        -            )
        -        )
        -
        -    def bulk_archive(self, ids, to_folder, chunk_size=None):
        -        """Archive items to a folder in the archive mailbox. An archive mailbox must be enabled in order for this
        -        to work.
        -
        -        :param ids: an iterable of either (id, changekey) tuples or Item objects.
        -        :param to_folder: The destination folder of the archive operation
        -        :param chunk_size: The number of items to send to the server in a single request (Default value = None)
        -
        -        :return: A list containing True or an exception instance in stable order of the requested items
        -        """
        -        return list(
        -            self._consume_item_service(
        -                service_cls=ArchiveItem,
        -                items=ids,
        -                chunk_size=chunk_size,
        -                kwargs=dict(
        -                    to_folder=to_folder,
        -                ),
        -            )
        -        )
        -
        -    def bulk_mark_as_junk(self, ids, is_junk, move_item, chunk_size=None):
        -        """Mark or un-mark message items as junk email and add or remove the sender from the blocked sender list.
        -
        -        :param ids: an iterable of either (id, changekey) tuples or Item objects.
        -        :param is_junk: Whether the messages are junk or not
        -        :param move_item: Whether to move the messages to the junk folder or not
        -        :param chunk_size: The number of items to send to the server in a single request (Default value = None)
        -
        -        :return: A list containing the new IDs of the moved items, if items were moved, or True, or an exception
        -          instance, in stable order of the requested items.
        -        """
        -        return list(
        -            self._consume_item_service(
        -                service_cls=MarkAsJunk,
        -                items=ids,
        -                chunk_size=chunk_size,
        -                kwargs=dict(
        -                    is_junk=is_junk,
        -                    move_item=move_item,
        -                ),
        -            )
        -        )
        -
        -    def fetch(self, ids, folder=None, only_fields=None, chunk_size=None):
        -        """Fetch items by ID.
        -
        -        :param ids: an iterable of either (id, changekey) tuples or Item objects.
        -        :param folder: used for validating 'only_fields' (Default value = None)
        -        :param only_fields: A list of string or FieldPath items specifying the fields to fetch. Default to all fields
        -        :param chunk_size: The number of items to send to the server in a single request (Default value = None)
        -
        -        :return: A generator of Item objects, in the same order as the input
        -        """
        -        validation_folder = folder or Folder(root=self.root)  # Default to a folder type that supports all item types
        -        # 'ids' could be an unevaluated QuerySet, e.g. if we ended up here via `fetch(ids=some_folder.filter(...))`. In
        -        # that case, we want to use its iterator. Otherwise, peek() will start a count() which is wasteful because we
        -        # need the item IDs immediately afterwards. iterator() will only do the bare minimum.
        -        if only_fields is None:
        -            # We didn't restrict list of field paths. Get all fields from the server, including extended properties.
        -            additional_fields = {
        -                FieldPath(field=f) for f in validation_folder.allowed_item_fields(version=self.version)
        -            }
        -        else:
        -            for field in only_fields:
        -                validation_folder.validate_item_field(field=field, version=self.version)
        -            # Remove ItemId and ChangeKey. We get them unconditionally
        -            additional_fields = {
        -                f for f in validation_folder.normalize_fields(fields=only_fields) if not f.field.is_attribute
        -            }
        -        # Always use IdOnly here, because AllProperties doesn't actually get *all* properties
        -        yield from self._consume_item_service(
        -            service_cls=GetItem,
        -            items=ids,
        -            chunk_size=chunk_size,
        -            kwargs=dict(
        -                additional_fields=additional_fields,
        -                shape=ID_ONLY,
        -            ),
        -        )
        -
        -    def fetch_personas(self, ids):
        -        """Fetch personas by ID.
        -
        -        :param ids: an iterable of either (id, changekey) tuples or Persona objects.
        -        :return: A generator of Persona objects, in the same order as the input
        -        """
        -        if isinstance(ids, QuerySet):
        -            # We just want an iterator over the results
        -            ids = iter(ids)
        -        is_empty, ids = peek(ids)
        -        if is_empty:
        -            # We accept generators, so it's not always convenient for caller to know up-front if 'ids' is empty. Allow
        -            # empty 'ids' and return early.
        -            return
        -        yield from GetPersona(account=self).call(personas=ids)
        -
        -    @property
        -    def mail_tips(self):
        -        """See self.oof_settings about caching considerations."""
        -        return GetMailTips(protocol=self.protocol).get(
        -            sending_as=SendingAs(email_address=self.primary_smtp_address),
        -            recipients=[Mailbox(email_address=self.primary_smtp_address)],
        -            mail_tips_requested="All",
        -        )
        -
        -    @property
        -    def delegates(self):
        -        """Return a list of DelegateUser objects representing the delegates that are set on this account."""
        -        return list(GetDelegate(account=self).call(user_ids=None, include_permissions=True))
        -
        -    @property
        -    def rules(self):
        -        """Return a list of Rule objects representing the rules that are set on this account."""
        -        return list(GetInboxRules(account=self).call())
        -
        -    def create_rule(self, rule: Rule):
        -        """Create an Inbox rule.
        -
        -        :param rule: The rule to create. Must have at least 'display_name' set.
        -        :return: None if success, else raises an error.
        -        """
        -        CreateInboxRule(account=self).get(rule=rule, remove_outlook_rule_blob=True)
        -        # After creating the rule, query all rules,
        -        # find the rule that was just created, and return its ID.
        -        try:
        -            rule.id = {i.display_name: i for i in GetInboxRules(account=self).call()}[rule.display_name].id
        -        except KeyError:
        -            raise ResponseMessageError(f"Failed to create rule ({rule.display_name})!")
        -
        -    def set_rule(self, rule: Rule):
        -        """Modify an Inbox rule.
        -
        -        :param rule: The rule to set. Must have an ID.
        -        :return: None if success, else raises an error.
        -        """
        -        SetInboxRule(account=self).get(rule=rule)
        -
        -    def delete_rule(self, rule: Rule):
        -        """Delete an Inbox rule.
        -
        -        :param rule: The rule to delete. Must have an ID.
        -        :return: None if success, else raises an error.
        -        """
        -        if not rule.id:
        -            raise ValueError("Rule must have an ID")
        -        DeleteInboxRule(account=self).get(rule=rule)
        -        rule.id = None
        -
        -    def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60):
        -        """Create a pull subscription.
        -
        -        :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPull.EVENT_TYPES
        -        :param watermark: An event bookmark as returned by some sync services
        -        :param timeout: Timeout of the subscription, in minutes. Timeout is reset when the server receives a
        -        GetEvents request for this subscription.
        -        :return: The subscription ID and a watermark
        -        """
        -        if event_types is None:
        -            event_types = SubscribeToPull.EVENT_TYPES
        -        return SubscribeToPull(account=self).get(
        -            folders=None,
        -            event_types=event_types,
        -            watermark=watermark,
        -            timeout=timeout,
        -        )
        -
        -    def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1):
        -        """Create a push subscription.
        -
        -        :param callback_url: A client-defined URL that the server will call
        -        :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES
        -        :param watermark: An event bookmark as returned by some sync services
        -        :param status_frequency: The frequency, in minutes, that the callback URL will be called with.
        -        :return: The subscription ID and a watermark
        -        """
        -        if event_types is None:
        -            event_types = SubscribeToPush.EVENT_TYPES
        -        return SubscribeToPush(account=self).get(
        -            folders=None,
        -            event_types=event_types,
        -            watermark=watermark,
        -            status_frequency=status_frequency,
        -            url=callback_url,
        -        )
        -
        -    def subscribe_to_streaming(self, event_types=None):
        -        """Create a streaming subscription.
        -
        -        :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES
        -        :return: The subscription ID
        -        """
        -        if event_types is None:
        -            event_types = SubscribeToStreaming.EVENT_TYPES
        -        return SubscribeToStreaming(account=self).get(folders=None, event_types=event_types)
        -
        -    def pull_subscription(self, **kwargs):
        -        return PullSubscription(target=self, **kwargs)
        -
        -    def push_subscription(self, **kwargs):
        -        return PushSubscription(target=self, **kwargs)
        -
        -    def streaming_subscription(self, **kwargs):
        -        return StreamingSubscription(target=self, **kwargs)
        -
        -    def unsubscribe(self, subscription_id):
        -        """Unsubscribe. Only applies to pull and streaming notifications.
        -
        -        :param subscription_id: A subscription ID as acquired by .subscribe_to_[pull|streaming]()
        -        :return: True
        -
        -        This method doesn't need the current collection instance, but it makes sense to keep the method along the other
        -        sync methods.
        -        """
        -        return Unsubscribe(account=self).get(subscription_id=subscription_id)
        -
        -    def __str__(self):
        -        if self.fullname:
        -            return f"{self.primary_smtp_address} ({self.fullname})"
        -        return self.primary_smtp_address
        -
        -

        Instance variables

        -
        -
        var admin_audit_logs
        -
        -
        -
        - -Expand source code - -
        def __get__(self, obj, cls):
        -    if obj is None:
        -        return self
        -
        -    obj_dict = obj.__dict__
        -    name = self.func.__name__
        -    with self.lock:
        -        try:
        -            # check if the value was computed before the lock was acquired
        -            return obj_dict[name]
        -
        -        except KeyError:
        -            # if not, do the calculation and release the lock
        -            return obj_dict.setdefault(name, self.func(obj))
        -
        -
        -
        var archive_deleted_items
        -
        -
        -
        - -Expand source code - -
        def __get__(self, obj, cls):
        -    if obj is None:
        -        return self
        -
        -    obj_dict = obj.__dict__
        -    name = self.func.__name__
        -    with self.lock:
        -        try:
        -            # check if the value was computed before the lock was acquired
        -            return obj_dict[name]
        -
        -        except KeyError:
        -            # if not, do the calculation and release the lock
        -            return obj_dict.setdefault(name, self.func(obj))
        -
        -
        -
        var archive_inbox
        -
        -
        -
        - -Expand source code - -
        def __get__(self, obj, cls):
        -    if obj is None:
        -        return self
        -
        -    obj_dict = obj.__dict__
        -    name = self.func.__name__
        -    with self.lock:
        -        try:
        -            # check if the value was computed before the lock was acquired
        -            return obj_dict[name]
        -
        -        except KeyError:
        -            # if not, do the calculation and release the lock
        -            return obj_dict.setdefault(name, self.func(obj))
        -
        -
        -
        var archive_msg_folder_root
        -
        -
        -
        - -Expand source code - -
        def __get__(self, obj, cls):
        -    if obj is None:
        -        return self
        -
        -    obj_dict = obj.__dict__
        -    name = self.func.__name__
        -    with self.lock:
        -        try:
        -            # check if the value was computed before the lock was acquired
        -            return obj_dict[name]
        +    obj_dict = obj.__dict__
        +    name = self.func.__name__
        +    with self.lock:
        +        try:
        +            # check if the value was computed before the lock was acquired
        +            return obj_dict[name]
         
                 except KeyError:
                     # if not, do the calculation and release the lock
                     return obj_dict.setdefault(name, self.func(obj))
        -
        -
        var archive_recoverable_items_deletions
        -
        -
        - -Expand source code - -
        def __get__(self, obj, cls):
        -    if obj is None:
        -        return self
        -
        -    obj_dict = obj.__dict__
        -    name = self.func.__name__
        -    with self.lock:
        -        try:
        -            # check if the value was computed before the lock was acquired
        -            return obj_dict[name]
        -
        -        except KeyError:
        -            # if not, do the calculation and release the lock
        -            return obj_dict.setdefault(name, self.func(obj))
        -
        var archive_recoverable_items_purges
        -
        Expand source code @@ -1831,10 +998,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +

        var archive_recoverable_items_root
        -
        Expand source code @@ -1854,10 +1021,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var archive_recoverable_items_versions
        -
        Expand source code @@ -1877,10 +1044,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var archive_root
        -
        Expand source code @@ -1900,10 +1067,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var calendar
        -
        Expand source code @@ -1923,10 +1090,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var conflicts
        -
        Expand source code @@ -1946,10 +1113,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var contacts
        -
        Expand source code @@ -1969,10 +1136,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var conversation_history
        -
        Expand source code @@ -1992,10 +1159,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        -
        var delegates
        +
        prop delegates
        -

        Return a list of DelegateUser objects representing the delegates that are set on this account.

        Expand source code @@ -2005,10 +1172,10 @@

        Instance variables

        """Return a list of DelegateUser objects representing the delegates that are set on this account.""" return list(GetDelegate(account=self).call(user_ids=None, include_permissions=True))
        +

        Return a list of DelegateUser objects representing the delegates that are set on this account.

        var directory
        -
        Expand source code @@ -2028,10 +1195,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        -
        var domain
        +
        prop domain
        -
        Expand source code @@ -2040,10 +1207,10 @@

        Instance variables

        def domain(self): return get_domain(self.primary_smtp_address)
        +
        var drafts
        -
        Expand source code @@ -2063,10 +1230,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var favorites
        -
        Expand source code @@ -2086,10 +1253,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var im_contact_list
        -
        Expand source code @@ -2109,10 +1276,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var inbox
        -
        Expand source code @@ -2132,10 +1299,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var journal
        -
        Expand source code @@ -2155,10 +1322,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var junk
        -
        Expand source code @@ -2178,10 +1345,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var local_failures
        -
        Expand source code @@ -2201,10 +1368,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        -
        var mail_tips
        +
        prop mail_tips
        -

        See self.oof_settings about caching considerations.

        Expand source code @@ -2218,10 +1385,10 @@

        Instance variables

        mail_tips_requested="All", )
        +

        See self.oof_settings about caching considerations.

        var msg_folder_root
        -
        Expand source code @@ -2241,10 +1408,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var my_contacts
        -
        Expand source code @@ -2264,10 +1431,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var notes
        -
        Expand source code @@ -2287,10 +1454,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        -
        var oof_settings
        +
        prop oof_settings
        -
        Expand source code @@ -2305,10 +1472,10 @@

        Instance variables

        mailbox=Mailbox(email_address=self.primary_smtp_address), )
        +
        var outbox
        -
        Expand source code @@ -2328,10 +1495,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var people_connect
        -
        Expand source code @@ -2351,10 +1518,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        -
        var primary_smtp_address
        +
        prop primary_smtp_address
        -
        Expand source code @@ -2363,10 +1530,10 @@

        Instance variables

        def primary_smtp_address(self): return self.identity.primary_smtp_address
        +
        var public_folders_root
        -
        Expand source code @@ -2386,10 +1553,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var quick_contacts
        -
        Expand source code @@ -2409,10 +1576,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var recipient_cache
        -
        Expand source code @@ -2432,10 +1599,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var recoverable_items_deletions
        -
        Expand source code @@ -2455,10 +1622,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var recoverable_items_purges
        -
        Expand source code @@ -2478,10 +1645,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var recoverable_items_root
        -
        Expand source code @@ -2501,10 +1668,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var recoverable_items_versions
        -
        Expand source code @@ -2524,10 +1691,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var root
        -
        Expand source code @@ -2547,10 +1714,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        -
        var rules
        +
        prop rules
        -

        Return a list of Rule objects representing the rules that are set on this account.

        Expand source code @@ -2560,10 +1727,10 @@

        Instance variables

        """Return a list of Rule objects representing the rules that are set on this account.""" return list(GetInboxRules(account=self).call())
        +

        Return a list of Rule objects representing the rules that are set on this account.

        var search_folders
        -
        Expand source code @@ -2583,10 +1750,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var sent
        -
        Expand source code @@ -2606,10 +1773,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var server_failures
        -
        Expand source code @@ -2629,10 +1796,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var sync_issues
        -
        Expand source code @@ -2652,10 +1819,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var tasks
        -
        Expand source code @@ -2675,10 +1842,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        -
        Expand source code @@ -2698,10 +1865,10 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        var trash
        -
        Expand source code @@ -2721,10 +1888,31 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +
        +
        +
        prop version
        +
        +
        + +Expand source code + +
        @property
        +def version(self):
        +    # We may need to override the default server version on a per-account basis because Microsoft may report one
        +    # server version up-front but delegate account requests to an older backend server. Create a new instance to
        +    # avoid changing the protocol version instance.
        +    if self._version:
        +        return self._version
        +    with self._version_lock:
        +        if self._version:
        +            return self._version
        +        self._version = self.protocol.version.copy()
        +        return self._version
        +
        +
        var voice_mail
        -
        Expand source code @@ -2744,6 +1932,7 @@

        Instance variables

        # if not, do the calculation and release the lock return obj_dict.setdefault(name, self.func(obj))
        +

        Methods

        @@ -2752,12 +1941,6 @@

        Methods

        def bulk_archive(self, ids, to_folder, chunk_size=None)
      • -

        Archive items to a folder in the archive mailbox. An archive mailbox must be enabled in order for this -to work.

        -

        :param ids: an iterable of either (id, changekey) tuples or Item objects. -:param to_folder: The destination folder of the archive operation -:param chunk_size: The number of items to send to the server in a single request (Default value = None)

        -

        :return: A list containing True or an exception instance in stable order of the requested items

        Expand source code @@ -2783,16 +1966,17 @@

        Methods

        ) )
        +

        Archive items to a folder in the archive mailbox. An archive mailbox must be enabled in order for this +to work.

        +

        :param ids: an iterable of either (id, changekey) tuples or Item objects. +:param to_folder: The destination folder of the archive operation +:param chunk_size: The number of items to send to the server in a single request (Default value = None)

        +

        :return: A list containing True or an exception instance in stable order of the requested items

        def bulk_copy(self, ids, to_folder, chunk_size=None)
        -

        Copy items to another folder.

        -

        :param ids: an iterable of either (id, changekey) tuples or Item objects. -:param to_folder: The destination folder of the copy operation -:param chunk_size: The number of items to send to the server in a single request (Default value = None)

        -

        :return: Status for each send operation, in the same order as the input

        Expand source code @@ -2817,22 +2001,16 @@

        Methods

        ) )
        +

        Copy items to another folder.

        +

        :param ids: an iterable of either (id, changekey) tuples or Item objects. +:param to_folder: The destination folder of the copy operation +:param chunk_size: The number of items to send to the server in a single request (Default value = None)

        +

        :return: Status for each send operation, in the same order as the input

        -def bulk_create(self, folder, items, message_disposition='SaveOnly', send_meeting_invitations='SendToNone', chunk_size=None) +def bulk_create(self,
        folder,
        items,
        message_disposition='SaveOnly',
        send_meeting_invitations='SendToNone',
        chunk_size=None)
        -

        Create new items in 'folder'.

        -

        :param folder: the folder to create the items in -:param items: an iterable of Item objects -:param message_disposition: only applicable to Message items. Possible values are specified in -MESSAGE_DISPOSITION_CHOICES (Default value = SAVE_ONLY) -:param send_meeting_invitations: only applicable to CalendarItem items. Possible values are specified in -SEND_MEETING_INVITATIONS_CHOICES (Default value = SEND_TO_NONE) -:param chunk_size: The number of items to send to the server in a single request (Default value = None)

        -

        :return: a list of either BulkCreateResult or exception instances in the same order as the input. The returned -BulkCreateResult objects are normal Item objects except they only contain the 'id' and 'changekey' -of the created item, and the 'id' of any attachments that were also created.

        Expand source code @@ -2877,22 +2055,22 @@

        Methods

        ) )
        +

        Create new items in 'folder'.

        +

        :param folder: the folder to create the items in +:param items: an iterable of Item objects +:param message_disposition: only applicable to Message items. Possible values are specified in +MESSAGE_DISPOSITION_CHOICES (Default value = SAVE_ONLY) +:param send_meeting_invitations: only applicable to CalendarItem items. Possible values are specified in +SEND_MEETING_INVITATIONS_CHOICES (Default value = SEND_TO_NONE) +:param chunk_size: The number of items to send to the server in a single request (Default value = None)

        +

        :return: a list of either BulkCreateResult or exception instances in the same order as the input. The returned +BulkCreateResult objects are normal Item objects except they only contain the 'id' and 'changekey' +of the created item, and the 'id' of any attachments that were also created.

        -def bulk_delete(self, ids, delete_type='HardDelete', send_meeting_cancellations='SendToNone', affected_task_occurrences='AllOccurrences', suppress_read_receipts=True, chunk_size=None) +def bulk_delete(self,
        ids,
        delete_type='HardDelete',
        send_meeting_cancellations='SendToNone',
        affected_task_occurrences='AllOccurrences',
        suppress_read_receipts=True,
        chunk_size=None)
        -

        Bulk delete items.

        -

        :param ids: an iterable of either (id, changekey) tuples or Item objects. -:param delete_type: the type of delete to perform. Possible values are specified in DELETE_TYPE_CHOICES -(Default value = HARD_DELETE) -:param send_meeting_cancellations: only applicable to CalendarItem. Possible values are specified in -SEND_MEETING_CANCELLATIONS_CHOICES. (Default value = SEND_TO_NONE) -:param affected_task_occurrences: only applicable for recurring Task items. Possible values are specified in -AFFECTED_TASK_OCCURRENCES_CHOICES. (Default value = ALL_OCCURRENCES) -:param suppress_read_receipts: only supported from Exchange 2013. True or False. (Default value = True) -:param chunk_size: The number of items to send to the server in a single request (Default value = None)

        -

        :return: a list of either True or exception instances, in the same order as the input

        Expand source code @@ -2941,18 +2119,22 @@

        Methods

        ) )
        +

        Bulk delete items.

        +

        :param ids: an iterable of either (id, changekey) tuples or Item objects. +:param delete_type: the type of delete to perform. Possible values are specified in DELETE_TYPE_CHOICES +(Default value = HARD_DELETE) +:param send_meeting_cancellations: only applicable to CalendarItem. Possible values are specified in +SEND_MEETING_CANCELLATIONS_CHOICES. (Default value = SEND_TO_NONE) +:param affected_task_occurrences: only applicable for recurring Task items. Possible values are specified in +AFFECTED_TASK_OCCURRENCES_CHOICES. (Default value = ALL_OCCURRENCES) +:param suppress_read_receipts: only supported from Exchange 2013. True or False. (Default value = True) +:param chunk_size: The number of items to send to the server in a single request (Default value = None)

        +

        :return: a list of either True or exception instances, in the same order as the input

        def bulk_mark_as_junk(self, ids, is_junk, move_item, chunk_size=None)
        -

        Mark or un-mark message items as junk email and add or remove the sender from the blocked sender list.

        -

        :param ids: an iterable of either (id, changekey) tuples or Item objects. -:param is_junk: Whether the messages are junk or not -:param move_item: Whether to move the messages to the junk folder or not -:param chunk_size: The number of items to send to the server in a single request (Default value = None)

        -

        :return: A list containing the new IDs of the moved items, if items were moved, or True, or an exception -instance, in stable order of the requested items.

        Expand source code @@ -2980,17 +2162,18 @@

        Methods

        ) )
        +

        Mark or un-mark message items as junk email and add or remove the sender from the blocked sender list.

        +

        :param ids: an iterable of either (id, changekey) tuples or Item objects. +:param is_junk: Whether the messages are junk or not +:param move_item: Whether to move the messages to the junk folder or not +:param chunk_size: The number of items to send to the server in a single request (Default value = None)

        +

        :return: A list containing the new IDs of the moved items, if items were moved, or True, or an exception +instance, in stable order of the requested items.

        def bulk_move(self, ids, to_folder, chunk_size=None)
        -

        Move items to another folder.

        -

        :param ids: an iterable of either (id, changekey) tuples or Item objects. -:param to_folder: The destination folder of the copy operation -:param chunk_size: The number of items to send to the server in a single request (Default value = None)

        -

        :return: The new IDs of the moved items, in the same order as the input. If 'to_folder' is a public folder or a -folder in a different mailbox, an empty list is returned.

        Expand source code @@ -3016,17 +2199,17 @@

        Methods

        ) )
        +

        Move items to another folder.

        +

        :param ids: an iterable of either (id, changekey) tuples or Item objects. +:param to_folder: The destination folder of the copy operation +:param chunk_size: The number of items to send to the server in a single request (Default value = None)

        +

        :return: The new IDs of the moved items, in the same order as the input. If 'to_folder' is a public folder or a +folder in a different mailbox, an empty list is returned.

        def bulk_send(self, ids, save_copy=True, copy_to_folder=None, chunk_size=None)
        -

        Send existing draft messages. If requested, save a copy in 'copy_to_folder'.

        -

        :param ids: an iterable of either (id, changekey) tuples or Item objects. -:param save_copy: If true, saves a copy of the message (Default value = True) -:param copy_to_folder: If requested, save a copy of the message in this folder. Default is the Sent folder -:param chunk_size: The number of items to send to the server in a single request (Default value = None)

        -

        :return: Status for each send operation, in the same order as the input

        Expand source code @@ -3056,23 +2239,17 @@

        Methods

        ) )
        +

        Send existing draft messages. If requested, save a copy in 'copy_to_folder'.

        +

        :param ids: an iterable of either (id, changekey) tuples or Item objects. +:param save_copy: If true, saves a copy of the message (Default value = True) +:param copy_to_folder: If requested, save a copy of the message in this folder. Default is the Sent folder +:param chunk_size: The number of items to send to the server in a single request (Default value = None)

        +

        :return: Status for each send operation, in the same order as the input

        -def bulk_update(self, items, conflict_resolution='AutoResolve', message_disposition='SaveOnly', send_meeting_invitations_or_cancellations='SendToNone', suppress_read_receipts=True, chunk_size=None) +def bulk_update(self,
        items,
        conflict_resolution='AutoResolve',
        message_disposition='SaveOnly',
        send_meeting_invitations_or_cancellations='SendToNone',
        suppress_read_receipts=True,
        chunk_size=None)
        -

        Bulk update existing items.

        -

        :param items: a list of (Item, fieldnames) tuples, where 'Item' is an Item object, and 'fieldnames' is a list -containing the attributes on this Item object that we want to be updated. -:param conflict_resolution: Possible values are specified in CONFLICT_RESOLUTION_CHOICES -(Default value = AUTO_RESOLVE) -:param message_disposition: only applicable to Message items. Possible values are specified in -MESSAGE_DISPOSITION_CHOICES (Default value = SAVE_ONLY) -:param send_meeting_invitations_or_cancellations: only applicable to CalendarItem items. Possible values are -specified in SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES (Default value = SEND_TO_NONE) -:param suppress_read_receipts: nly supported from Exchange 2013. True or False (Default value = True) -:param chunk_size: The number of items to send to the server in a single request (Default value = None)

        -

        :return: a list of either (id, changekey) tuples or exception instances, in the same order as the input

        Expand source code @@ -3127,14 +2304,23 @@

        Methods

        ) )
        +

        Bulk update existing items.

        +

        :param items: a list of (Item, fieldnames) tuples, where 'Item' is an Item object, and 'fieldnames' is a list +containing the attributes on this Item object that we want to be updated. +:param conflict_resolution: Possible values are specified in CONFLICT_RESOLUTION_CHOICES +(Default value = AUTO_RESOLVE) +:param message_disposition: only applicable to Message items. Possible values are specified in +MESSAGE_DISPOSITION_CHOICES (Default value = SAVE_ONLY) +:param send_meeting_invitations_or_cancellations: only applicable to CalendarItem items. Possible values are +specified in SEND_MEETING_INVITATIONS_AND_CANCELLATIONS_CHOICES (Default value = SEND_TO_NONE) +:param suppress_read_receipts: nly supported from Exchange 2013. True or False (Default value = True) +:param chunk_size: The number of items to send to the server in a single request (Default value = None)

        +

        :return: a list of either (id, changekey) tuples or exception instances, in the same order as the input

        -def create_rule(self, rule: Rule) +def create_rule(self,
        rule: Rule)
        -

        Create an Inbox rule.

        -

        :param rule: The rule to create. Must have at least 'display_name' set. -:return: None if success, else raises an error.

        Expand source code @@ -3153,14 +2339,14 @@

        Methods

        except KeyError: raise ResponseMessageError(f"Failed to create rule ({rule.display_name})!")
        +

        Create an Inbox rule.

        +

        :param rule: The rule to create. Must have at least 'display_name' set. +:return: None if success, else raises an error.

        -def delete_rule(self, rule: Rule) +def delete_rule(self,
        rule: Rule)
        -

        Delete an Inbox rule.

        -

        :param rule: The rule to delete. Must have an ID. -:return: None if success, else raises an error.

        Expand source code @@ -3176,15 +2362,14 @@

        Methods

        DeleteInboxRule(account=self).get(rule=rule) rule.id = None
        +

        Delete an Inbox rule.

        +

        :param rule: The rule to delete. Must have an ID. +:return: None if success, else raises an error.

        def export(self, items, chunk_size=None)
        -

        Return export strings of the given items.

        -

        :param items: An iterable containing the Items we want to export -:param chunk_size: The number of items to send to the server in a single request (Default value = None)

        -

        :return: A list of strings, the exported representation of the object

        Expand source code @@ -3199,17 +2384,15 @@

        Methods

        """ return list(self._consume_item_service(service_cls=ExportItems, items=items, chunk_size=chunk_size, kwargs={}))
        +

        Return export strings of the given items.

        +

        :param items: An iterable containing the Items we want to export +:param chunk_size: The number of items to send to the server in a single request (Default value = None)

        +

        :return: A list of strings, the exported representation of the object

        def fetch(self, ids, folder=None, only_fields=None, chunk_size=None)
        -

        Fetch items by ID.

        -

        :param ids: an iterable of either (id, changekey) tuples or Item objects. -:param folder: used for validating 'only_fields' (Default value = None) -:param only_fields: A list of string or FieldPath items specifying the fields to fetch. Default to all fields -:param chunk_size: The number of items to send to the server in a single request (Default value = None)

        -

        :return: A generator of Item objects, in the same order as the input

        Expand source code @@ -3251,14 +2434,17 @@

        Methods

        ), )
        +

        Fetch items by ID.

        +

        :param ids: an iterable of either (id, changekey) tuples or Item objects. +:param folder: used for validating 'only_fields' (Default value = None) +:param only_fields: A list of string or FieldPath items specifying the fields to fetch. Default to all fields +:param chunk_size: The number of items to send to the server in a single request (Default value = None)

        +

        :return: A generator of Item objects, in the same order as the input

        def fetch_personas(self, ids)
        -

        Fetch personas by ID.

        -

        :param ids: an iterable of either (id, changekey) tuples or Persona objects. -:return: A generator of Persona objects, in the same order as the input

        Expand source code @@ -3279,12 +2465,14 @@

        Methods

        return yield from GetPersona(account=self).call(personas=ids)
        +

        Fetch personas by ID.

        +

        :param ids: an iterable of either (id, changekey) tuples or Persona objects. +:return: A generator of Persona objects, in the same order as the input

        def pull_subscription(self, **kwargs)
        -
        Expand source code @@ -3292,12 +2480,12 @@

        Methods

        def pull_subscription(self, **kwargs):
             return PullSubscription(target=self, **kwargs)
        +
        def push_subscription(self, **kwargs)
        -
        Expand source code @@ -3305,14 +2493,12 @@

        Methods

        def push_subscription(self, **kwargs):
             return PushSubscription(target=self, **kwargs)
        +
        -def set_rule(self, rule: Rule) +def set_rule(self,
        rule: Rule)
        -

        Modify an Inbox rule.

        -

        :param rule: The rule to set. Must have an ID. -:return: None if success, else raises an error.

        Expand source code @@ -3325,12 +2511,14 @@

        Methods

        """ SetInboxRule(account=self).get(rule=rule)
        +

        Modify an Inbox rule.

        +

        :param rule: The rule to set. Must have an ID. +:return: None if success, else raises an error.

        def streaming_subscription(self, **kwargs)
        -
        Expand source code @@ -3338,17 +2526,12 @@

        Methods

        def streaming_subscription(self, **kwargs):
             return StreamingSubscription(target=self, **kwargs)
        +
        def subscribe_to_pull(self, event_types=None, watermark=None, timeout=60)
        -

        Create a pull subscription.

        -

        :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPull.EVENT_TYPES -:param watermark: An event bookmark as returned by some sync services -:param timeout: Timeout of the subscription, in minutes. Timeout is reset when the server receives a -GetEvents request for this subscription. -:return: The subscription ID and a watermark

        Expand source code @@ -3371,17 +2554,17 @@

        Methods

        timeout=timeout, )
        +

        Create a pull subscription.

        +

        :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPull.EVENT_TYPES +:param watermark: An event bookmark as returned by some sync services +:param timeout: Timeout of the subscription, in minutes. Timeout is reset when the server receives a +GetEvents request for this subscription. +:return: The subscription ID and a watermark

        def subscribe_to_push(self, callback_url, event_types=None, watermark=None, status_frequency=1)
        -

        Create a push subscription.

        -

        :param callback_url: A client-defined URL that the server will call -:param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES -:param watermark: An event bookmark as returned by some sync services -:param status_frequency: The frequency, in minutes, that the callback URL will be called with. -:return: The subscription ID and a watermark

        Expand source code @@ -3405,14 +2588,17 @@

        Methods

        url=callback_url, )
        +

        Create a push subscription.

        +

        :param callback_url: A client-defined URL that the server will call +:param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES +:param watermark: An event bookmark as returned by some sync services +:param status_frequency: The frequency, in minutes, that the callback URL will be called with. +:return: The subscription ID and a watermark

        def subscribe_to_streaming(self, event_types=None)
        -

        Create a streaming subscription.

        -

        :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES -:return: The subscription ID

        Expand source code @@ -3427,16 +2613,14 @@

        Methods

        event_types = SubscribeToStreaming.EVENT_TYPES return SubscribeToStreaming(account=self).get(folders=None, event_types=event_types)
        +

        Create a streaming subscription.

        +

        :param event_types: List of event types to subscribe to. Possible values defined in SubscribeToPush.EVENT_TYPES +:return: The subscription ID

        def unsubscribe(self, subscription_id)
        -

        Unsubscribe. Only applies to pull and streaming notifications.

        -

        :param subscription_id: A subscription ID as acquired by .subscribe_to_pull|streaming -:return: True

        -

        This method doesn't need the current collection instance, but it makes sense to keep the method along the other -sync methods.

        Expand source code @@ -3452,25 +2636,16 @@

        Methods

        """ return Unsubscribe(account=self).get(subscription_id=subscription_id)
        +

        Unsubscribe. Only applies to pull and streaming notifications.

        +

        :param subscription_id: A subscription ID as acquired by .subscribe_to_pull|streaming +:return: True

        +

        This method doesn't need the current collection instance, but it makes sense to keep the method along the other +sync methods.

        def upload(self, data, chunk_size=None)
        -

        Upload objects retrieved from an export to the given folders.

        -

        :param data: An iterable of tuples containing the folder we want to upload the data to and the string outputs of -exports. If you want to update items instead of create, the data must be a tuple of -(ItemId, is_associated, data) values. -:param chunk_size: The number of items to send to the server in a single request (Default value = None)

        -

        :return: A list of tuples with the new ids and changekeys

        -

        Example: -account.upload([ -(account.inbox, "AABBCC…"), -(account.inbox, (ItemId('AA', 'BB'), False, "XXYYZZ…")), -(account.inbox, (('CC', 'DD'), None, "XXYYZZ…")), -(account.calendar, "ABCXYZ…"), -]) --> [("idA", "changekey"), ("idB", "changekey"), ("idC", "changekey")]

        Expand source code @@ -3497,6 +2672,20 @@

        Methods

        items = ((f, (None, False, d) if isinstance(d, str) else d) for f, d in data) return list(self._consume_item_service(service_cls=UploadItems, items=items, chunk_size=chunk_size, kwargs={}))
        +

        Upload objects retrieved from an export to the given folders.

        +

        :param data: An iterable of tuples containing the folder we want to upload the data to and the string outputs of +exports. If you want to update items instead of create, the data must be a tuple of +(ItemId, is_associated, data) values. +:param chunk_size: The number of items to send to the server in a single request (Default value = None)

        +

        :return: A list of tuples with the new ids and changekeys

        +

        Example: +account.upload([ +(account.inbox, "AABBCC…"), +(account.inbox, (ItemId('AA', 'BB'), False, "XXYYZZ…")), +(account.inbox, (('CC', 'DD'), None, "XXYYZZ…")), +(account.calendar, "ABCXYZ…"), +]) +-> [("idA", "changekey"), ("idB", "changekey"), ("idC", "changekey")]

        @@ -3505,7 +2694,6 @@

        Methods

        (**kwargs)
  • -

    Contains information that uniquely identifies an account. Currently only used for SOAP impersonation headers.

    Expand source code @@ -3522,6 +2710,7 @@

    Methods

    smtp_address = TextField(field_uri="SmtpAddress") # The (non-)primary email address for the account primary_smtp_address = TextField(field_uri="PrimarySmtpAddress") # The primary email address for the account
    +

    Contains information that uniquely identifies an account. Currently only used for SOAP impersonation headers.

    Ancestors

    @@ -3676,7 +2865,7 @@

    -

    Generated by pdoc 0.10.0.

    +

    Generated by pdoc 0.11.5.

    diff --git a/docs/exchangelib/attachments.html b/docs/exchangelib/attachments.html index 4436d8e6..d6d61e7e 100644 --- a/docs/exchangelib/attachments.html +++ b/docs/exchangelib/attachments.html @@ -2,18 +2,32 @@ - - + + exchangelib.attachments API documentation - - - - - - + + + + + + - - + +
    @@ -22,47 +36,26 @@

    Module exchangelib.attachments

    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class Attachment +(**kwargs) +
    +
    Expand source code -
    import io
    -import logging
    -import mimetypes
    -
    -from .errors import InvalidTypeError
    -from .fields import (
    -    Base64Field,
    -    BooleanField,
    -    DateTimeField,
    -    EWSElementField,
    -    FieldPath,
    -    IdField,
    -    IntegerField,
    -    ItemField,
    -    TextField,
    -    URIField,
    -)
    -from .properties import BaseItemId, EWSElement, EWSMeta
    -
    -log = logging.getLogger(__name__)
    -
    -
    -class AttachmentId(BaseItemId):
    -    """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/attachmentid"""
    -
    -    ELEMENT_NAME = "AttachmentId"
    -
    -    ID_ATTR = "Id"
    -    ROOT_ID_ATTR = "RootItemId"
    -    ROOT_CHANGEKEY_ATTR = "RootItemChangeKey"
    -
    -    id = IdField(field_uri=ID_ATTR, is_required=True)
    -    root_id = IdField(field_uri=ROOT_ID_ATTR)
    -    root_changekey = IdField(field_uri=ROOT_CHANGEKEY_ATTR)
    -
    -
    -class Attachment(EWSElement, metaclass=EWSMeta):
    +
    class Attachment(EWSElement, metaclass=EWSMeta):
         """Base class for FileAttachment and ItemAttachment."""
     
         attachment_id = EWSElementField(value_cls=AttachmentId)
    @@ -127,190 +120,57 @@ 

    Module exchangelib.attachments

    args_str = ", ".join( f"{f.name}={getattr(self, f.name)!r}" for f in self.FIELDS if f.name not in ("_item", "_content") ) - return f"{self.__class__.__name__}({args_str})" - - -class FileAttachment(Attachment): - """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/fileattachment""" - - ELEMENT_NAME = "FileAttachment" - - is_contact_photo = BooleanField(field_uri="IsContactPhoto") - _content = Base64Field(field_uri="Content") - - __slots__ = ("_fp",) - - def __init__(self, **kwargs): - kwargs["_content"] = kwargs.pop("content", None) - super().__init__(**kwargs) - self._fp = None - - @property - def fp(self): - # Return a file-like object for the content. This avoids creating multiple in-memory copies of the content. - if self._fp is None: - self._init_fp() - return self._fp - - def _init_fp(self): - # Create a file-like object for the attachment content. We try hard to reduce memory consumption, so we never - # store the full attachment content in-memory. - if not self.parent_item or not self.parent_item.account: - raise ValueError(f"{self.__class__.__name__} must have an account") - self._fp = FileAttachmentIO(attachment=self) - - @property - def content(self): - """Return the attachment content. Stores a local copy of the content in case you want to upload the attachment - again later. - """ - if self.attachment_id is None: - return self._content - if self._content is not None: - return self._content - # We have an ID to the data but still haven't called GetAttachment to get the actual data. Do that now. - with self.fp as fp: - self._content = fp.read() - return self._content - - @content.setter - def content(self, value): - """Replace the attachment content.""" - if not isinstance(value, bytes): - raise InvalidTypeError("value", value, bytes) - self._content = value - - @classmethod - def from_xml(cls, elem, account): - kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS} - kwargs["content"] = kwargs.pop("_content") - cls._clear(elem) - return cls(**kwargs) - - def to_xml(self, version): - self._content = self.content # Make sure content is available, to avoid ErrorRequiredPropertyMissing - return super().to_xml(version=version) - - def __getstate__(self): - # The fp does not need to be pickled - state = {k: getattr(self, k) for k in self._slots_keys} - del state["_fp"] - return state - - def __setstate__(self, state): - # Restore the fp - for k in self._slots_keys: - setattr(self, k, state.get(k)) - self._fp = None - - -class ItemAttachment(Attachment): - """MSDN: https://docs.microsoft.com/en-us/exchange/client-developer/web-service-reference/itemattachment""" - - ELEMENT_NAME = "ItemAttachment" - - _item = ItemField(field_uri="Item") - - def __init__(self, **kwargs): - kwargs["_item"] = kwargs.pop("item", None) - super().__init__(**kwargs) - - @property - def item(self): - from .folders import BaseFolder - from .services import GetAttachment - - if self.attachment_id is None: - return self._item - if self._item is not None: - return self._item - # We have an ID to the data but still haven't called GetAttachment to get the actual data. Do that now. - if not self.parent_item or not self.parent_item.account: - raise ValueError(f"{self.__class__.__name__} must have an account") - additional_fields = { - FieldPath(field=f) for f in BaseFolder.allowed_item_fields(version=self.parent_item.account.version) - } - attachment = GetAttachment(account=self.parent_item.account).get( - items=[self.attachment_id], - include_mime_content=True, - body_type=None, - filter_html_content=None, - additional_fields=additional_fields, - ) - self._item = attachment.item - return self._item - - @item.setter - def item(self, value): - from .items import Item - - if not isinstance(value, Item): - raise InvalidTypeError("value", value, Item) - self._item = value - - @classmethod - def from_xml(cls, elem, account): - kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS} - kwargs["item"] = kwargs.pop("_item") - cls._clear(elem) - return cls(**kwargs) - - -class FileAttachmentIO(io.RawIOBase): - """A BytesIO where the stream of data comes from the GetAttachment service.""" - - def __init__(self, attachment): - self._attachment = attachment - self._stream = None - self._overflow = None - - def readable(self): - return True - - @property - def closed(self): - return self._stream is None - - def readinto(self, b): - buf_size = len(b) # We can't return more than l bytes - try: - chunk = self._overflow or next(self._stream) - except StopIteration: - return 0 - else: - output, self._overflow = chunk[:buf_size], chunk[buf_size:] - b[: len(output)] = output - return len(output) - - def __enter__(self): - from .services import GetAttachment - - self._stream = GetAttachment(account=self._attachment.parent_item.account).stream_file_content( - attachment_id=self._attachment.attachment_id - ) - self._overflow = None - return io.BufferedReader(self, buffer_size=io.DEFAULT_BUFFER_SIZE) - - def __exit__(self, *args, **kwargs): - self._stream = None - self._overflow = None
    + return f"{self.__class__.__name__}({args_str})"
    -
    -
    -
    -
    -
    -
    -
    -
    -

    Classes

    +

    Base class for FileAttachment and ItemAttachment.

    +

    Ancestors

    + +

    Subclasses

    + +

    Class variables

    -
    -class Attachment -(**kwargs) -
    +
    var FIELDS
    +
    +
    +
    +
    +

    Instance variables

    +
    +
    var attachment_id
    +
    +
    +
    +
    var content_id
    +
    +
    +
    +
    var content_location
    +
    +
    +
    +
    var content_type
    +
    +
    +
    +
    var is_inline
    +
    +
    +
    +
    var last_modified_time
    +
    +
    +
    +
    var name
    +
    +
    +
    +
    var parent_item
    -

    Base class for FileAttachment and ItemAttachment.

    Expand source code @@ -382,56 +242,8 @@

    Classes

    ) return f"{self.__class__.__name__}({args_str})"
    -

    Ancestors

    - -

    Subclasses

    - -

    Class variables

    -
    -
    var FIELDS
    -
    -
    -
    -
    -

    Instance variables

    -
    -
    var attachment_id
    -
    -
    -
    -
    var content_id
    -
    -
    -
    -
    var content_location
    -
    -
    -
    -
    var content_type
    -
    -
    -
    -
    var is_inline
    -
    -
    -
    -
    var last_modified_time
    -
    -
    var name
    -
    -
    -
    -
    var parent_item
    -
    -

    Return an attribute of instance, which is of type owner.

    -
    var size
    @@ -443,7 +255,6 @@

    Methods

    def attach(self)
    -
    Expand source code @@ -464,12 +275,12 @@

    Methods

    attachment_id.root_changekey = None self.attachment_id = attachment_id
    +
    def clean(self, version=None)
    -
    Expand source code @@ -483,12 +294,12 @@

    Methods

    self.content_type = mimetypes.guess_type(self.name)[0] or "application/octet-stream" super().clean(version=version)
    +
    def detach(self)
    -
    Expand source code @@ -505,6 +316,7 @@

    Methods

    self.parent_item = None self.attachment_id = None
    +

    Inherited members

    @@ -524,7 +336,6 @@

    Inherited members

    (*args, **kwargs)
    -
    Expand source code @@ -542,6 +353,7 @@

    Inherited members

    root_id = IdField(field_uri=ROOT_ID_ATTR) root_changekey = IdField(field_uri=ROOT_CHANGEKEY_ATTR)
    +

    Ancestors

    Instance variables

    -
    var content
    +
    prop content
    -

    Return the attachment content. Stores a local copy of the content in case you want to upload the attachment -again later.

    Expand source code @@ -740,10 +539,11 @@

    Instance variables

    self._content = fp.read() return self._content
    +

    Return the attachment content. Stores a local copy of the content in case you want to upload the attachment +again later.

    -
    var fp
    +
    prop fp
    -
    Expand source code @@ -755,6 +555,7 @@

    Instance variables

    self._init_fp() return self._fp
    +
    var is_contact_photo
    @@ -767,7 +568,6 @@

    Methods

    def to_xml(self, version)
    -
    Expand source code @@ -776,6 +576,7 @@

    Methods

    self._content = self.content # Make sure content is available, to avoid ErrorRequiredPropertyMissing return super().to_xml(version=version)
    +

    Inherited members

    @@ -783,7 +584,6 @@

    Inherited members

  • Attachment:
    • add_field
    • -
    • parent_item
    • remove_field
    • supported_fields
    • validate_field
    • @@ -796,7 +596,6 @@

      Inherited members

      (attachment)
      -

      A BytesIO where the stream of data comes from the GetAttachment service.

      Expand source code @@ -840,6 +639,7 @@

      Inherited members

      self._stream = None self._overflow = None
      +

      A BytesIO where the stream of data comes from the GetAttachment service.

      Ancestors

      • io.RawIOBase
      • @@ -849,9 +649,8 @@

        Ancestors

      Instance variables

      -
      var closed
      +
      prop closed
      -
      Expand source code @@ -860,6 +659,7 @@

      Instance variables

      def closed(self): return self._stream is None
      +

      Methods

      @@ -868,8 +668,6 @@

      Methods

      def readable(self)
      -

      Return whether object was opened for reading.

      -

      If False, read() will raise OSError.

      Expand source code @@ -877,12 +675,13 @@

      Methods

      def readable(self):
           return True
      +

      Return whether object was opened for reading.

      +

      If False, read() will raise OSError.

      def readinto(self, b)
      -
      Expand source code @@ -898,6 +697,7 @@

      Methods

      b[: len(output)] = output return len(output)
      +
  • @@ -906,7 +706,6 @@

    Methods

    (**kwargs)
    -
    Expand source code @@ -962,6 +761,7 @@

    Methods

    cls._clear(elem) return cls(**kwargs)
    +

    Ancestors

    • Attachment
    • @@ -985,24 +785,12 @@

      Static methods

      -
      - -Expand source code - -
      @classmethod
      -def from_xml(cls, elem, account):
      -    kwargs = {f.name: f.from_xml(elem=elem, account=account) for f in cls.FIELDS}
      -    kwargs["item"] = kwargs.pop("_item")
      -    cls._clear(elem)
      -    return cls(**kwargs)
      -

      Instance variables

      -
      var item
      +
      prop item
      -
      Expand source code @@ -1032,6 +820,7 @@

      Instance variables

      self._item = attachment.item return self._item
      +

      Inherited members

      @@ -1039,7 +828,6 @@

      Inherited members

    • Attachment:
      • add_field
      • -
      • parent_item
      • remove_field
      • supported_fields
      • validate_field
      • @@ -1051,7 +839,6 @@

        Inherited members